From a3816b268a12222a840316b9b13cec256cf2323c Mon Sep 17 00:00:00 2001 From: Nick Phura Date: Fri, 22 Mar 2024 11:33:43 -0700 Subject: [PATCH 1/9] BugFix: Bump Git Action Versions, Fix Publish To Biohub (#1261) * Bump git action versions * Fix openapi spec when publishing to biohub * Enable biohub publishing in dev/test * Update biohub paths --- .github/workflows/addComments.yml | 2 +- .github/workflows/cleanClosedPR.yml | 6 +-- .github/workflows/cleanMergedPR.yml | 6 +-- .github/workflows/deploy.yml | 59 ++++++++++++++-------------- .github/workflows/deployStatic.yml | 60 ++++++++++++++--------------- .github/workflows/e2e-pr-test.yml | 11 ++---- .github/workflows/e2e-test.yaml | 4 +- .github/workflows/lint-format.yml | 18 ++++----- .github/workflows/test.yml | 20 +++++----- .github/workflows/zap.yml | 8 ++-- api/.pipeline/config.js | 10 ++--- api/src/paths/publish/survey.ts | 17 +++++++- app/.pipeline/config.js | 2 +- env_config/env.docker | 2 +- 14 files changed, 118 insertions(+), 107 deletions(-) diff --git a/.github/workflows/addComments.yml b/.github/workflows/addComments.yml index fb2f63be4e..45e14bc914 100644 --- a/.github/workflows/addComments.yml +++ b/.github/workflows/addComments.yml @@ -14,7 +14,7 @@ jobs: timeout-minutes: 20 steps: - name: Add Comment - uses: peter-evans/create-or-update-comment@v2 + uses: peter-evans/create-or-update-comment@v4 with: issue-number: ${{ github.event.number }} body: | diff --git a/.github/workflows/cleanClosedPR.yml b/.github/workflows/cleanClosedPR.yml index dd49ecd25d..82ca87d725 100644 --- a/.github/workflows/cleanClosedPR.yml +++ b/.github/workflows/cleanClosedPR.yml @@ -19,13 +19,13 @@ jobs: # 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 + uses: actions/setup-node@v4 with: node-version: 14 # Load repo from cache - name: Cache repo - uses: actions/cache@v3 + uses: actions/cache@v4 id: cache-repo env: cache-name: cache-repo @@ -36,7 +36,7 @@ jobs: # Checkout the branch if not restored via cache - name: Checkout Target Branch if: steps.cache-repo.outputs.cache-hit != 'true' - uses: actions/checkout@v3 + uses: actions/checkout@v4 with: persist-credentials: false diff --git a/.github/workflows/cleanMergedPR.yml b/.github/workflows/cleanMergedPR.yml index 680c34c0b3..7fbd4e2c18 100644 --- a/.github/workflows/cleanMergedPR.yml +++ b/.github/workflows/cleanMergedPR.yml @@ -23,13 +23,13 @@ jobs: # 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 + uses: actions/setup-node@v4 with: node-version: 14 # Load repo from cache - name: Cache repo - uses: actions/cache@v3 + uses: actions/cache@v4 id: cache-repo env: cache-name: cache-repo @@ -40,7 +40,7 @@ jobs: # Checkout the branch if not restored via cache - name: Checkout Target Branch if: steps.cache-repo.outputs.cache-hit != 'true' - uses: actions/checkout@v3 + uses: actions/checkout@v4 with: persist-credentials: false diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index c0a4ae674c..d5ab8247c4 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -46,7 +46,8 @@ jobs: # Set to `true` if the latest commit message contains `ignore-skip` anywhere in the message OR the base branch # is dev, test, or prod. # Used to disable duplicate action skipping, if needed. - ignore_skip: ${{ contains(steps.head_commit_message.outputs.commit_message, 'ignore-skip') || + ignore_skip: + ${{ contains(steps.head_commit_message.outputs.commit_message, 'ignore-skip') || github.head_ref == 'dev' || github.head_ref == 'test' || github.head_ref == 'prod' }} steps: - id: skip_check @@ -86,7 +87,7 @@ jobs: # Get the head commit for this pull request, parse out the commit message, and assign to the `commit_message` # output variable, which is then used to determine if the term `ignore-skip` is found in the commit message. - name: Checkout head commit - uses: actions/checkout@v3 + uses: actions/checkout@v4 with: ref: ${{ github.event.pull_request.head.sha }} - name: Get head commit message @@ -107,13 +108,13 @@ jobs: - skipDuplicateActions steps: - name: Checkout Target Branch - uses: actions/checkout@v3 + uses: actions/checkout@v4 with: persist-credentials: false # Cache the repo - name: Cache repo - uses: actions/cache@v3 + uses: actions/cache@v4 id: cache-repo env: cache-name: cache-repo @@ -125,7 +126,7 @@ jobs: # 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 + uses: actions/setup-node@v4 with: node-version: 14 @@ -148,13 +149,13 @@ jobs: steps: # Install Node - for `node` and `npm` commands - name: Setup Node.js - uses: actions/setup-node@v3 + uses: actions/setup-node@v4 with: node-version: 14 # Load repo from cache - name: Cache repo - uses: actions/cache@v3 + uses: actions/cache@v4 id: cache-repo env: cache-name: cache-repo @@ -165,7 +166,7 @@ jobs: # Checkout the branch if not restored via cache - name: Checkout Target Branch if: steps.cache-repo.outputs.cache-hit != 'true' - uses: actions/checkout@v3 + uses: actions/checkout@v4 # Log in to OpenShift. # Note: The secrets needed to log in are NOT available if the PR comes from a FORK. @@ -210,13 +211,13 @@ jobs: steps: # Install Node - for `node` and `npm` commands - name: Setup Node.js - uses: actions/setup-node@v3 + uses: actions/setup-node@v4 with: node-version: 14 # Load repo from cache - name: Cache repo - uses: actions/cache@v3 + uses: actions/cache@v4 id: cache-repo env: cache-name: cache-repo @@ -227,7 +228,7 @@ jobs: # Checkout the branch if not restored via cache - name: Checkout Target Branch if: steps.cache-repo.outputs.cache-hit != 'true' - uses: actions/checkout@v3 + uses: actions/checkout@v4 # Log in to OpenShift. # Note: The secrets needed to log in are NOT available if the PR comes from a FORK. @@ -271,13 +272,13 @@ jobs: steps: # Install Node - for `node` and `npm` commands - name: Setup Node.js - uses: actions/setup-node@v3 + uses: actions/setup-node@v4 with: node-version: 14 # Load repo from cache - name: Cache repo - uses: actions/cache@v3 + uses: actions/cache@v4 id: cache-repo env: cache-name: cache-repo @@ -288,7 +289,7 @@ jobs: # Checkout the branch if not restored via cache - name: Checkout Target Branch if: steps.cache-repo.outputs.cache-hit != 'true' - uses: actions/checkout@v3 + uses: actions/checkout@v4 # Log in to OpenShift. # Note: The secrets needed to log in are NOT available if the PR comes from a FORK. @@ -327,13 +328,13 @@ jobs: steps: # Install Node - for `node` and `npm` commands - name: Setup Node.js - uses: actions/setup-node@v3 + uses: actions/setup-node@v4 with: node-version: 14 # Load repo from cache - name: Cache repo - uses: actions/cache@v3 + uses: actions/cache@v4 id: cache-repo env: cache-name: cache-repo @@ -344,7 +345,7 @@ jobs: # Checkout the branch if not restored via cache - name: Checkout Target Branch if: steps.cache-repo.outputs.cache-hit != 'true' - uses: actions/checkout@v3 + uses: actions/checkout@v4 # Log in to OpenShift. # Note: The secrets needed to log in are NOT available if the PR comes from a FORK. @@ -387,13 +388,13 @@ jobs: steps: # Install Node - for `node` and `npm` commands - name: Setup Node.js - uses: actions/setup-node@v3 + uses: actions/setup-node@v4 with: node-version: 14 # Load repo from cache - name: Cache repo - uses: actions/cache@v3 + uses: actions/cache@v4 id: cache-repo env: cache-name: cache-repo @@ -404,7 +405,7 @@ jobs: # Checkout the branch if not restored via cache - name: Checkout Target Branch if: steps.cache-repo.outputs.cache-hit != 'true' - uses: actions/checkout@v3 + uses: actions/checkout@v4 # Log in to OpenShift. # Note: The secrets needed to log in are NOT available if the PR comes from a FORK. @@ -441,13 +442,13 @@ jobs: steps: # Install Node - for `node` and `npm` commands - name: Setup Node.js - uses: actions/setup-node@v3 + uses: actions/setup-node@v4 with: node-version: 14 # Load repo from cache - name: Cache repo - uses: actions/cache@v3 + uses: actions/cache@v4 id: cache-repo env: cache-name: cache-repo @@ -458,7 +459,7 @@ jobs: # Checkout the branch if not restored via cache - name: Checkout Target Branch if: steps.cache-repo.outputs.cache-hit != 'true' - uses: actions/checkout@v3 + uses: actions/checkout@v4 # Log in to OpenShift. # Note: The secrets needed to log in are NOT available if the PR comes from a FORK. @@ -496,13 +497,13 @@ jobs: steps: # Install Node - for `node` and `npm` commands - name: Setup Node.js - uses: actions/setup-node@v3 + uses: actions/setup-node@v4 with: node-version: 14 # Load repo from cache - name: Cache repo - uses: actions/cache@v3 + uses: actions/cache@v4 id: cache-repo env: cache-name: cache-repo @@ -513,7 +514,7 @@ jobs: # Checkout the branch if not restored via cache - name: Checkout Target Branch if: steps.cache-repo.outputs.cache-hit != 'true' - uses: actions/checkout@v3 + uses: actions/checkout@v4 # Log in to OpenShift. # Note: The secrets needed to log in are NOT available if the PR comes from a FORK. @@ -553,13 +554,13 @@ jobs: steps: # Install Node - for `node` and `npm` commands - name: Setup Node.js - uses: actions/setup-node@v3 + uses: actions/setup-node@v4 with: node-version: 14 # Load repo from cache - name: Cache repo - uses: actions/cache@v3 + uses: actions/cache@v4 id: cache-repo env: cache-name: cache-repo @@ -570,7 +571,7 @@ jobs: # Checkout the branch if not restored via cache - name: Checkout Target Branch if: steps.cache-repo.outputs.cache-hit != 'true' - uses: actions/checkout@v3 + uses: actions/checkout@v4 # Log in to OpenShift. # Note: The secrets needed to log in are NOT available if the PR comes from a FORK. diff --git a/.github/workflows/deployStatic.yml b/.github/workflows/deployStatic.yml index 16cce447ba..307d0326ef 100644 --- a/.github/workflows/deployStatic.yml +++ b/.github/workflows/deployStatic.yml @@ -65,18 +65,18 @@ jobs: # 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 + uses: actions/setup-node@v4 with: node-version: 14 - name: Checkout Target Branch - uses: actions/checkout@v3 + uses: actions/checkout@v4 with: persist-credentials: false # Cache the repo - name: Cache repo - uses: actions/cache@v3 + uses: actions/cache@v4 id: cache-repo env: cache-name: cache-repo @@ -99,13 +99,13 @@ jobs: steps: # Install Node - for `node` and `npm` commands - name: Setup Node.js - uses: actions/setup-node@v3 + uses: actions/setup-node@v4 with: node-version: 14 # Load repo from cache - name: Cache repo - uses: actions/cache@v3 + uses: actions/cache@v4 id: cache-repo env: cache-name: cache-repo @@ -116,7 +116,7 @@ jobs: # Checkout the branch if not restored via cache - name: Checkout Target Branch if: steps.cache-repo.outputs.cache-hit != 'true' - uses: actions/checkout@v3 + uses: actions/checkout@v4 # Log in to OpenShift. # Note: The secrets needed to log in are NOT available if the PR comes from a FORK. @@ -149,13 +149,13 @@ jobs: steps: # Install Node - for `node` and `npm` commands - name: Setup Node.js - uses: actions/setup-node@v3 + uses: actions/setup-node@v4 with: node-version: 14 # Load repo from cache - name: Cache repo - uses: actions/cache@v3 + uses: actions/cache@v4 id: cache-repo env: cache-name: cache-repo @@ -166,7 +166,7 @@ jobs: # Checkout the branch if not restored via cache - name: Checkout Target Branch if: steps.cache-repo.outputs.cache-hit != 'true' - uses: actions/checkout@v3 + uses: actions/checkout@v4 # Log in to OpenShift. # Note: The secrets needed to log in are NOT available if the PR comes from a FORK. @@ -199,13 +199,13 @@ jobs: steps: # Install Node - for `node` and `npm` commands - name: Setup Node.js - uses: actions/setup-node@v3 + uses: actions/setup-node@v4 with: node-version: 14 # Load repo from cache - name: Cache repo - uses: actions/cache@v3 + uses: actions/cache@v4 id: cache-repo env: cache-name: cache-repo @@ -216,7 +216,7 @@ jobs: # Checkout the branch if not restored via cache - name: Checkout Target Branch if: steps.cache-repo.outputs.cache-hit != 'true' - uses: actions/checkout@v3 + uses: actions/checkout@v4 # Log in to OpenShift. # Note: The secrets needed to log in are NOT available if the PR comes from a FORK. @@ -249,13 +249,13 @@ jobs: steps: # Install Node - for `node` and `npm` commands - name: Setup Node.js - uses: actions/setup-node@v3 + uses: actions/setup-node@v4 with: node-version: 14 # Load repo from cache - name: Cache repo - uses: actions/cache@v3 + uses: actions/cache@v4 id: cache-repo env: cache-name: cache-repo @@ -266,7 +266,7 @@ jobs: # Checkout the branch if not restored via cache - name: Checkout Target Branch if: steps.cache-repo.outputs.cache-hit != 'true' - uses: actions/checkout@v3 + uses: actions/checkout@v4 # Log in to OpenShift. # Note: The secrets needed to log in are NOT available if the PR comes from a FORK. @@ -300,13 +300,13 @@ jobs: steps: # Install Node - for `node` and `npm` commands - name: Setup Node.js - uses: actions/setup-node@v3 + uses: actions/setup-node@v4 with: node-version: 14 # Load repo from cache - name: Cache repo - uses: actions/cache@v3 + uses: actions/cache@v4 id: cache-repo env: cache-name: cache-repo @@ -317,7 +317,7 @@ jobs: # Checkout the branch if not restored via cache - name: Checkout Target Branch if: steps.cache-repo.outputs.cache-hit != 'true' - uses: actions/checkout@v3 + uses: actions/checkout@v4 # Log in to OpenShift. # Note: The secrets needed to log in are NOT available if the PR comes from a FORK. @@ -351,13 +351,13 @@ jobs: steps: # Install Node - for `node` and `npm` commands - name: Setup Node.js - uses: actions/setup-node@v3 + uses: actions/setup-node@v4 with: node-version: 14 # Load repo from cache - name: Cache repo - uses: actions/cache@v3 + uses: actions/cache@v4 id: cache-repo env: cache-name: cache-repo @@ -368,7 +368,7 @@ jobs: # Checkout the branch if not restored via cache - name: Checkout Target Branch if: steps.cache-repo.outputs.cache-hit != 'true' - uses: actions/checkout@v3 + uses: actions/checkout@v4 # Log in to OpenShift. # Note: The secrets needed to log in are NOT available if the PR comes from a FORK. @@ -403,13 +403,13 @@ jobs: steps: # Install Node - for `node` and `npm` commands - name: Setup Node.js - uses: actions/setup-node@v3 + uses: actions/setup-node@v4 with: node-version: 14 # Load repo from cache - name: Cache repo - uses: actions/cache@v3 + uses: actions/cache@v4 id: cache-repo env: cache-name: cache-repo @@ -420,7 +420,7 @@ jobs: # Checkout the branch if not restored via cache - name: Checkout Target Branch if: steps.cache-repo.outputs.cache-hit != 'true' - uses: actions/checkout@v3 + uses: actions/checkout@v4 # Log in to OpenShift. # Note: The secrets needed to log in are NOT available if the PR comes from a FORK. @@ -455,13 +455,13 @@ jobs: steps: # Install Node - for `node` and `npm` commands - name: Setup Node.js - uses: actions/setup-node@v3 + uses: actions/setup-node@v4 with: node-version: 14 # Load repo from cache - name: Cache repo - uses: actions/cache@v3 + uses: actions/cache@v4 id: cache-repo env: cache-name: cache-repo @@ -472,7 +472,7 @@ jobs: # Checkout the branch if not restored via cache - name: Checkout Target Branch if: steps.cache-repo.outputs.cache-hit != 'true' - uses: actions/checkout@v3 + uses: actions/checkout@v4 # Log in to OpenShift. # Note: The secrets needed to log in are NOT available if the PR comes from a FORK. @@ -508,13 +508,13 @@ jobs: steps: # Install Node - for `node` and `npm` commands - name: Setup Node.js - uses: actions/setup-node@v3 + uses: actions/setup-node@v4 with: node-version: 14 # Load repo from cache - name: Cache repo - uses: actions/cache@v3 + uses: actions/cache@v4 id: cache-repo env: cache-name: cache-repo @@ -525,7 +525,7 @@ jobs: # Checkout the branch if not restored via cache - name: Checkout Target Branch if: steps.cache-repo.outputs.cache-hit != 'true' - uses: actions/checkout@v3 + uses: actions/checkout@v4 # Log in to OpenShift. # Note: The secrets needed to log in are NOT available if the PR comes from a FORK. diff --git a/.github/workflows/e2e-pr-test.yml b/.github/workflows/e2e-pr-test.yml index bfc56e7bea..f275ae602d 100644 --- a/.github/workflows/e2e-pr-test.yml +++ b/.github/workflows/e2e-pr-test.yml @@ -20,11 +20,6 @@ jobs: CYPRESS_authRealm: "35r1iman" CYPRESS_authClientId: "biohubbc" CYPRESS_authUrl: "https://${{ github.base_ref }}.oidc.gov.bc.ca" - needs: - - deployDatabase - - deployDatabaseSetup - - deployAPI - - deployAPP steps: - name: Print Env Vars run: | @@ -38,7 +33,7 @@ jobs: # Checkout the PR branch - name: Checkout Target Branch - uses: actions/checkout@v3 + uses: actions/checkout@v4 - name: Wait for API response uses: nev7n/wait_for_response@v1.0.1 @@ -59,12 +54,12 @@ jobs: # 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 + uses: actions/setup-node@v4 with: node-version: 14 - name: E2E Smoke tests - uses: cypress-io/github-action@v3 + uses: cypress-io/github-action@v6 # let's give this action an ID so we can refer # to its output values later id: smoke diff --git a/.github/workflows/e2e-test.yaml b/.github/workflows/e2e-test.yaml index cdbce8b082..956a5335b8 100644 --- a/.github/workflows/e2e-test.yaml +++ b/.github/workflows/e2e-test.yaml @@ -8,10 +8,10 @@ jobs: timeout-minutes: 20 steps: - name: Checkout - uses: actions/checkout@v3 + uses: actions/checkout@v4 - name: E2E Smoke tests - uses: cypress-io/github-action@v2 + uses: cypress-io/github-action@v6 # let's give this action an ID so we can refer # to its output values later id: smoke diff --git a/.github/workflows/lint-format.yml b/.github/workflows/lint-format.yml index 88862ce00f..0ca215756d 100644 --- a/.github/workflows/lint-format.yml +++ b/.github/workflows/lint-format.yml @@ -20,18 +20,18 @@ jobs: # 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 + uses: actions/setup-node@v4 with: node-version: 14 - name: Checkout Target Branch - uses: actions/checkout@v3 + uses: actions/checkout@v4 with: persist-credentials: false # Cache the repo - name: Cache repo - uses: actions/cache@v3 + uses: actions/cache@v4 id: cache-repo env: cache-name: cache-repo @@ -48,13 +48,13 @@ jobs: - checkoutRepo steps: - name: Setup node - uses: actions/setup-node@v3 + uses: actions/setup-node@v4 with: node-version: 14 # Load repo from cache - name: Cache repo - uses: actions/cache@v3 + uses: actions/cache@v4 id: cache-repo env: cache-name: cache-repo @@ -65,11 +65,11 @@ jobs: # Checkout the branch if not restored via cache - name: Checkout Target Branch if: steps.cache-repo.outputs.cache-hit != 'true' - uses: actions/checkout@v3 + uses: actions/checkout@v4 # api - name: Cache api node modules - uses: actions/cache@v3 + uses: actions/cache@v4 env: cache-name: cache-api-node-modules with: @@ -93,7 +93,7 @@ jobs: # app - name: Cache app node modules - uses: actions/cache@v3 + uses: actions/cache@v4 env: cache-name: cache-app-node-modules with: @@ -117,7 +117,7 @@ jobs: # database - name: Cache database node modules - uses: actions/cache@v3 + uses: actions/cache@v4 env: cache-name: cache-database-node-modules with: diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 445c7ae0b7..c21b0913ba 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -23,18 +23,18 @@ jobs: # 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 + uses: actions/setup-node@v4 with: node-version: 14 - name: Checkout Target Branch - uses: actions/checkout@v3 + uses: actions/checkout@v4 with: persist-credentials: false # Cache the repo - name: Cache repo - uses: actions/cache@v3 + uses: actions/cache@v4 id: cache-repo env: cache-name: cache-repo @@ -51,13 +51,13 @@ jobs: - checkoutRepo steps: - name: Setup node - uses: actions/setup-node@v3 + uses: actions/setup-node@v4 with: node-version: 14 # Load repo from cache - name: Cache repo - uses: actions/cache@v3 + uses: actions/cache@v4 id: cache-repo env: cache-name: cache-repo @@ -68,11 +68,11 @@ jobs: # Checkout the branch if not restored via cache - name: Checkout Target Branch if: steps.cache-repo.outputs.cache-hit != 'true' - uses: actions/checkout@v3 + uses: actions/checkout@v4 # api - name: Cache api node modules - uses: actions/cache@v3 + uses: actions/cache@v4 env: cache-name: cache-api-node-modules with: @@ -91,14 +91,14 @@ jobs: run: CI=true npm run coverage - name: Upload coverage to Codecov - uses: codecov/codecov-action@v3 + uses: codecov/codecov-action@v4 with: token: ${{secrets.CODECOV_TOKEN}} fail_ci_if_error: false # app - name: Cache app node modules - uses: actions/cache@v3 + uses: actions/cache@v4 env: cache-name: cache-app-node-modules with: @@ -117,7 +117,7 @@ jobs: run: CI=true npm run coverage - name: Upload coverage to Codecov - uses: codecov/codecov-action@v3 + uses: codecov/codecov-action@v4 with: token: ${{secrets.CODECOV_TOKEN}} fail_ci_if_error: false diff --git a/.github/workflows/zap.yml b/.github/workflows/zap.yml index c43a554405..2e68f4298c 100644 --- a/.github/workflows/zap.yml +++ b/.github/workflows/zap.yml @@ -9,7 +9,7 @@ jobs: CYPRESS_password: ${{ secrets.CYPRESS_PASSWORD }} steps: - name: Checkout - uses: actions/checkout@v3 + uses: actions/checkout@v4 with: ref: dev - name: Subtitute Password @@ -22,6 +22,6 @@ jobs: uses: zaproxy/action-full-scan@v0.3.0 with: token: ${{ secrets.GITHUB_TOKEN }} - docker_name: 'owasp/zap2docker-stable' - target: 'https://dev-biohubbc.apps.silver.devops.gov.bc.ca/' - cmd_options: '-a' + docker_name: "owasp/zap2docker-stable" + target: "https://dev-biohubbc.apps.silver.devops.gov.bc.ca/" + cmd_options: "-a" diff --git a/api/.pipeline/config.js b/api/.pipeline/config.js index 9091a706f2..ae315d1a09 100644 --- a/api/.pipeline/config.js +++ b/api/.pipeline/config.js @@ -80,11 +80,11 @@ const phases = { appHost: (isStaticDeployment && staticUrls.dev) || `${appName}-${changeId}-af2668-dev.apps.silver.devops.gov.bc.ca`, backboneInternalApiHost: 'https://api-dev-biohub-platform.apps.silver.devops.gov.bc.ca', backbonePublicApiHost: 'https://api-dev-biohub-platform.apps.silver.devops.gov.bc.ca', - backboneIntakePath: '/api/dwc/submission/queue', + backboneIntakePath: '/api/submission/intake', backboneArtifactIntakePath: '/api/artifact/intake', biohubTaxonPath: '/api/taxonomy/taxon', biohubTaxonTsnPath: '/api/taxonomy/taxon/tsn', - backboneIntakeEnabled: false, + backboneIntakeEnabled: true, 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', nodeEnv: 'development', @@ -116,11 +116,11 @@ const phases = { appHost: staticUrls.test, backboneInternalApiHost: 'https://api-test-biohub-platform.apps.silver.devops.gov.bc.ca', backbonePublicApiHost: 'https://api-test-biohub-platform.apps.silver.devops.gov.bc.ca', - backboneIntakePath: '/api/dwc/submission/queue', + backboneIntakePath: '/api/submission/intake', backboneArtifactIntakePath: '/api/artifact/intake', biohubTaxonPath: '/api/taxonomy/taxon', biohubTaxonTsnPath: '/api/taxonomy/taxon/tsn', - backboneIntakeEnabled: false, + backboneIntakeEnabled: true, bctwApiHost: 'https://moe-bctw-api-test.apps.silver.devops.gov.bc.ca', critterbaseApiHost: 'https://moe-critterbase-api-test.apps.silver.devops.gov.bc.ca/api', nodeEnv: 'production', @@ -152,7 +152,7 @@ const phases = { appHost: staticUrls.prodVanityUrl, backboneInternalApiHost: 'https://api-biohub-platform.apps.silver.devops.gov.bc.ca', backbonePublicApiHost: 'https://api-biohub-platform.apps.silver.devops.gov.bc.ca', - backboneIntakePath: '/api/dwc/submission/queue', + backboneIntakePath: '/api/submission/intake', backboneArtifactIntakePath: '/api/artifact/intake', biohubTaxonPath: '/api/taxonomy/taxon', biohubTaxonTsnPath: '/api/taxonomy/taxon/tsn', diff --git a/api/src/paths/publish/survey.ts b/api/src/paths/publish/survey.ts index 737820c11f..99fb728161 100644 --- a/api/src/paths/publish/survey.ts +++ b/api/src/paths/publish/survey.ts @@ -52,12 +52,27 @@ POST.apiDoc = { description: 'Additional data to include in the submission to BioHub', type: 'object', additionalProperties: false, - required: ['submissionComment'], + required: ['submissionComment', 'agreement1', 'agreement2', 'agreement3'], properties: { submissionComment: { type: 'string', description: 'Submission comment to include in the submission to BioHub. May include sensitive information.' + }, + agreement1: { + type: 'boolean', + enum: [true], + description: 'Publishing agreement 1. Agreement must be accepted.' + }, + agreement2: { + type: 'boolean', + enum: [true], + description: 'Publishing agreement 2. Agreement must be accepted.' + }, + agreement3: { + type: 'boolean', + enum: [true], + description: 'Publishing agreement 3. Agreement must be accepted.' } } } diff --git a/app/.pipeline/config.js b/app/.pipeline/config.js index 4931167e4d..d5c194ad0c 100644 --- a/app/.pipeline/config.js +++ b/app/.pipeline/config.js @@ -116,7 +116,7 @@ const phases = { memoryLimit: '500Mi', replicas: '2', replicasMax: '2', - biohubFeatureFlag: 'false', + biohubFeatureFlag: 'true', backbonePublicApiHost: 'https://api-test-biohub-platform.apps.silver.devops.gov.bc.ca', biohubTaxonPath: '/api/taxonomy/taxon', biohubTaxonTsnPath: '/api/taxonomy/taxon/tsn' diff --git a/env_config/env.docker b/env_config/env.docker index 4e31d0fa73..8d12854dec 100644 --- a/env_config/env.docker +++ b/env_config/env.docker @@ -69,7 +69,7 @@ BACKBONE_PUBLIC_API_HOST=https://api-dev-biohub-platform.apps.silver.devops.gov. REACT_APP_BIOHUB_FEATURE_FLAG=false -BACKBONE_INTAKE_PATH=/api/dwc/submission/queue +BACKBONE_INTAKE_PATH=/api/submission/intake BACKBONE_ARTIFACT_INTAKE_PATH=/api/artifact/intake # Set to `true` to enable SIMS submitting data to the BioHub Backbone From 2618d2359eea76c4295e7026fd4f81518cf6f0bf Mon Sep 17 00:00:00 2001 From: Nick Phura Date: Fri, 22 Mar 2024 16:56:44 -0700 Subject: [PATCH 2/9] SIMSBIOHUB-507: Add Caribou Herd Layer to Survey Study Area Map (#1259) * Add Caribou Herd Layer to survey study area map --- api/package-lock.json | 42 ++++----- app/package-lock.json | 92 +++++++++---------- .../map/components/RegionSelector.tsx | 4 + app/src/components/map/wfs-utils.tsx | 49 ++++++++-- .../locations/SurveyAreaMapControl.tsx | 2 +- 5 files changed, 112 insertions(+), 77 deletions(-) diff --git a/api/package-lock.json b/api/package-lock.json index 707c1339cf..03743587c5 100644 --- a/api/package-lock.json +++ b/api/package-lock.json @@ -662,7 +662,7 @@ "@types/json5": { "version": "0.0.29", "resolved": "https://registry.npmjs.org/@types/json5/-/json5-0.0.29.tgz", - "integrity": "sha1-7ihweulOEdK4J7y+UnC86n8+ce4=", + "integrity": "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==", "dev": true, "optional": true }, @@ -1415,7 +1415,7 @@ "arrify": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/arrify/-/arrify-1.0.1.tgz", - "integrity": "sha1-iYUI2iIm84DfkEcoRWhJwVAaSw0=", + "integrity": "sha512-3CYzex9M9FGQjCGMGyi6/31c8GJbgb0qGyrx5HWxPd0aCwh4cB2YjMb2Xf9UuoogrMrlO9cTqnB5rI5GHZTcUA==", "dev": true }, "asn1": { @@ -2743,7 +2743,7 @@ "encodeurl": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz", - "integrity": "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==" + "integrity": "sha1-rT/0yG7C0CkyL1oCw6mmBslbP1k=" }, "end-of-stream": { "version": "1.4.4", @@ -2906,7 +2906,7 @@ "escape-html": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", - "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==" + "integrity": "sha1-Aljq5NPQwJdN4cFpGI7wBR0dGYg=" }, "escape-string-regexp": { "version": "1.0.5", @@ -3180,7 +3180,7 @@ "etag": { "version": "1.8.1", "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", - "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==" + "integrity": "sha1-Qa4u62XvpiJorr/qg6x9eSmbCIc=" }, "eventemitter3": { "version": "4.0.7", @@ -3190,7 +3190,7 @@ "events": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/events/-/events-1.1.1.tgz", - "integrity": "sha1-nr23Y1rQmccNzEwqH1AEKI6L2SQ=" + "integrity": "sha512-kEcvvCBByWXGnZy6JUlgAp2gBIUjfCAV6P6TgT1/aaQKcmuAEC4OZTV1I4EWQLz2gxZw76atuVyvHhTxvi0Flw==" }, "expand-brackets": { "version": "2.1.4", @@ -3820,7 +3820,7 @@ "fresh": { "version": "0.5.2", "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", - "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==" + "integrity": "sha1-PYyt2Q2XZWn6g1qx+OSyOhBWBac=" }, "fromentries": { "version": "1.3.2", @@ -4722,7 +4722,7 @@ "ignore-by-default": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/ignore-by-default/-/ignore-by-default-1.0.1.tgz", - "integrity": "sha1-SMptcvbGo68Aqa1K5odr44ieKwk=", + "integrity": "sha512-Ius2VYcGNk7T90CppJqcIkS5ooHUZyIQK+ClZfMfMNFEF9VSE73Fq+906u/CWu92x4gzZMWOwfFYckPObzdEbA==", "dev": true }, "import-fresh": { @@ -4943,7 +4943,7 @@ "is-dir": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/is-dir/-/is-dir-1.0.0.tgz", - "integrity": "sha512-vLwCNpTNkFC5k7SBRxPubhOCryeulkOsSkjbGyZ8eOzZmzMS+hSEO/Kn9ZOVhFNAlRZTFc4ZKql48hESuYUPIQ==" + "integrity": "sha1-QdN/SV/MrMBaR3jWboMCTCkro/8=" }, "is-extendable": { "version": "0.1.1", @@ -5651,7 +5651,7 @@ "lodash.clonedeep": { "version": "4.5.0", "resolved": "https://registry.npmjs.org/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz", - "integrity": "sha1-4j8/nE+Pvd6HJSnBBxhXoIblzO8=" + "integrity": "sha512-H5ZhCF25riFd9uB5UCkVKo61m3S/xZk1x4wA6yp/L3RFP6Z/eHH1ymQcGLo7J3GMPfm0V/7m1tryHuGVxpqEBQ==" }, "lodash.defaults": { "version": "4.2.0", @@ -5667,7 +5667,7 @@ "lodash.get": { "version": "4.4.2", "resolved": "https://registry.npmjs.org/lodash.get/-/lodash.get-4.4.2.tgz", - "integrity": "sha1-LRd/ZS+jHpObRDjVNBSZ36OCXpk=", + "integrity": "sha512-z+Uw/vLuy6gQe8cfaFWD7p0wVv8fJl3mbzXh33RS+0oW2wvUqiRXiQ69gLWSLpgB5/6sU+r6BlQR0MBILadqTQ==", "dev": true }, "lodash.includes": { @@ -5748,7 +5748,7 @@ "lru-cache": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-4.0.2.tgz", - "integrity": "sha1-HRdnnAac2l0ECZGgnbwsDbN35V4=", + "integrity": "sha512-uQw9OqphAGiZhkuPlpFGmdTU2tEuhxTourM/19qGJrxBPHAr/f8BT1a0i/lOclESnGatdJG/UCkP9kZB/Lh1iw==", "requires": { "pseudomap": "^1.0.1", "yallist": "^2.0.0" @@ -6410,7 +6410,7 @@ "nopt": { "version": "1.0.10", "resolved": "https://registry.npmjs.org/nopt/-/nopt-1.0.10.tgz", - "integrity": "sha1-bd0hvSoxQXuScn3Vhfim83YI6+4=", + "integrity": "sha512-NWmpvLSqUrgrAC9HCuxEvb+PSloHpqVu+FqcO4eeF2h5qYRhA7ev6KvelyQAKtegUbC6RypJnlEOhd8vloNKYg==", "dev": true, "requires": { "abbrev": "1" @@ -7387,7 +7387,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==" }, "pstree.remy": { "version": "1.1.8", @@ -7419,7 +7419,7 @@ "punycode": { "version": "1.3.2", "resolved": "https://registry.npmjs.org/punycode/-/punycode-1.3.2.tgz", - "integrity": "sha1-llOgNvt8HuQjQvIyXM7v6jkmxI0=" + "integrity": "sha512-RofWgt/7fL5wP1Y7fxE7/EmTLzQVnB0ycyibJ0OOHIlJqTNzglYFxVwETOcIoJqJmpDXJ9xImDv+Fq34F/d4Dw==" }, "qs": { "version": "6.10.1", @@ -7432,7 +7432,7 @@ "querystring": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/querystring/-/querystring-0.2.0.tgz", - "integrity": "sha1-sgmEkgO7Jd+CDadW50cAWHhSFiA=" + "integrity": "sha512-X/xY82scca2tau62i9mDyU9K+I+djTMUsvwf7xnUX5GLvVzgJybOJf4Y6o9Zx3oJK/LSXg5tTZBjwzqVPaPO2g==" }, "randombytes": { "version": "2.1.0", @@ -7965,7 +7965,7 @@ "simple-swizzle": { "version": "0.2.2", "resolved": "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.2.tgz", - "integrity": "sha1-pNprY1/8zMoz9w0Xy5JZLeleVXo=", + "integrity": "sha512-JA//kQgZtbuY83m+xT+tXJkmJncGMTFT+C+g2h2R9uxkYIrE2yy9sgmcLhCnw57/WSD+Eh3J97FPEDFnbXnDUg==", "requires": { "is-arrayish": "^0.3.1" } @@ -8983,7 +8983,7 @@ "unpipe": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", - "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==" + "integrity": "sha1-sr9O6FFKrmFltIF4KdIbLvSZBOw=" }, "unset-value": { "version": "1.0.0", @@ -9055,7 +9055,7 @@ "url": { "version": "0.10.3", "resolved": "https://registry.npmjs.org/url/-/url-0.10.3.tgz", - "integrity": "sha1-Ah5NnHcF8hu/N9A861h2dAJ3TGQ=", + "integrity": "sha512-hzSUW2q06EqL1gKM/a+obYHLIO6ct2hwPuviqTTOcfFVc61UbfJ2Q32+uGL/HCPxKqrdGB5QUwIe7UqlDgwsOQ==", "requires": { "punycode": "1.3.2", "querystring": "0.2.0" @@ -9443,7 +9443,7 @@ "yallist": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/yallist/-/yallist-2.1.2.tgz", - "integrity": "sha1-HBH5IY8HYImkfdUS+TxmmaaoHVI=" + "integrity": "sha512-ncTzHV7NvsQZkYe1DW7cbDLm0YpzHmZF5r/iyP3ZnQtMiJ+pjzisCiMNI+Sj+xQF5pXhSHxSB3uDbsBTzY/c2A==" }, "yargs": { "version": "15.4.1", @@ -9501,7 +9501,7 @@ "yn": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/yn/-/yn-2.0.0.tgz", - "integrity": "sha1-5a2ryKz0CPY4X8dklWhMiOavaJo=", + "integrity": "sha512-uTv8J/wiWTgUTg+9vLTi//leUl5vDQS6uii/emeTb2ssY7vl6QWf2fFbIIGjnhjvbdKlU0ed7QPgY1htTC86jQ==", "dev": true }, "yocto-queue": { diff --git a/app/package-lock.json b/app/package-lock.json index 290583fdaf..67b7a4058d 100644 --- a/app/package-lock.json +++ b/app/package-lock.json @@ -5453,7 +5453,7 @@ "array-flatten": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", - "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==" + "integrity": "sha1-ml9pkFGx5wczKPKgCJaLZOopVdI=" }, "array-includes": { "version": "3.1.6", @@ -5594,7 +5594,7 @@ "asynckit": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", - "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==" + "integrity": "sha1-x57Zf380y48robyXkLzDZkdLS3k=" }, "at-least-node": { "version": "1.0.0", @@ -6407,7 +6407,7 @@ "color-name": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", - "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==" + "integrity": "sha1-p9BVi9icQveV3UIyj3QIMcpTvCU=" }, "color-support": { "version": "1.1.3", @@ -6514,7 +6514,7 @@ "concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", - "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==" + "integrity": "sha1-2Klr13/Wjfd5OnMDajug1UBdR3s=" }, "concaveman": { "version": "1.2.1", @@ -6570,7 +6570,7 @@ "cookie-signature": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz", - "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==" + "integrity": "sha1-4wOogrNCzD7oylE6eZmXNNqzriw=" }, "core-js": { "version": "3.31.1", @@ -7234,7 +7234,7 @@ "delayed-stream": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", - "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==" + "integrity": "sha1-3zrhmayt+31ECqrgsp4icrJOxhk=" }, "delegates": { "version": "1.0.0", @@ -7244,7 +7244,7 @@ "depd": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/depd/-/depd-1.1.2.tgz", - "integrity": "sha512-7emPTl6Dpo6JRXOXjLRxck+FlLRX5847cLKEn00PLAgc3g2hTZZgr+e4c2v6QpSmLeFP3n5yUo7ft6avBK/5jQ==" + "integrity": "sha1-m81S4UwJd2PnSbJ0xDRu0uVgtak=" }, "dequal": { "version": "2.0.3", @@ -7255,7 +7255,7 @@ "destroy": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.0.4.tgz", - "integrity": "sha512-3NdhDuEXnfun/z7x9GOElY49LoqVHoGScmOKwmxhsS8N5Y+Z8KyPPDnaSzqWgYt/ji4mqwfTS34Htrk0zPIXVg==" + "integrity": "sha1-l4hXRCxEdJ5CBmE+N5RiBYJqvYA=" }, "detect-newline": { "version": "3.1.0", @@ -7486,7 +7486,7 @@ "ee-first": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", - "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==" + "integrity": "sha1-WQxhFWsK4vTwJVcyoViyZrxWsh0=" }, "ejs": { "version": "3.1.9", @@ -7522,7 +7522,7 @@ "encodeurl": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz", - "integrity": "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==" + "integrity": "sha1-rT/0yG7C0CkyL1oCw6mmBslbP1k=" }, "encoding": { "version": "0.1.13", @@ -7739,12 +7739,12 @@ "escape-html": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", - "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==" + "integrity": "sha1-Aljq5NPQwJdN4cFpGI7wBR0dGYg=" }, "escape-string-regexp": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", - "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==" + "integrity": "sha1-G2HAViGQqN/2rjuyzwIAyhMLhtQ=" }, "escodegen": { "version": "2.1.0", @@ -8448,7 +8448,7 @@ "etag": { "version": "1.8.1", "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", - "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==" + "integrity": "sha1-Qa4u62XvpiJorr/qg6x9eSmbCIc=" }, "eventemitter3": { "version": "4.0.7", @@ -9042,7 +9042,7 @@ "fresh": { "version": "0.5.2", "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", - "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==" + "integrity": "sha1-PYyt2Q2XZWn6g1qx+OSyOhBWBac=" }, "fs-constants": { "version": "1.0.0", @@ -9078,7 +9078,7 @@ "fs.realpath": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", - "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==" + "integrity": "sha1-FQStJSMVjKpA20onh8sBQRmU6k8=" }, "fsevents": { "version": "2.3.2", @@ -9385,7 +9385,7 @@ "has-flag": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", - "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==" + "integrity": "sha1-tdRU3CGZriJWmfNGfloH87lVuv0=" }, "has-property-descriptors": { "version": "1.0.0", @@ -9727,7 +9727,7 @@ "immediate": { "version": "3.0.6", "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz", - "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==" + "integrity": "sha1-nbHb0Pr43m++D13V5Wu2BigN5ps=" }, "immer": { "version": "9.0.21", @@ -9772,7 +9772,7 @@ "inflight": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", - "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "integrity": "sha1-Sb1jMdfQLQwJvJEKEHW6gWW1bfk=", "requires": { "once": "^1.3.0", "wrappy": "1" @@ -9842,7 +9842,7 @@ "is-arrayish": { "version": "0.2.1", "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", - "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==" + "integrity": "sha1-d8mYQFJ6qOyxqLppe4BkWnqSap0=" }, "is-bigint": { "version": "1.0.4", @@ -10085,7 +10085,7 @@ "is-typedarray": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/is-typedarray/-/is-typedarray-1.0.0.tgz", - "integrity": "sha512-cyA56iCMHAh5CdzjJIa4aohJyeO1YbwLi3Jc35MmRU6poroFjIGZzUzupGiRPOjgHg9TLu43xbpwXk523fMxKA==" + "integrity": "sha1-5HnICFjfDBsR3dppQPlgEfzaSpo=" }, "is-weakmap": { "version": "2.0.1", @@ -10123,12 +10123,12 @@ "isarray": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", - "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==" + "integrity": "sha1-u5NdSFgsuhaMBoNJV6VKPgcSTxE=" }, "isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", - "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==" + "integrity": "sha1-6PvzdNxVb/iUehDcsFctYz8s+hA=" }, "isstream": { "version": "0.1.2", @@ -12966,7 +12966,7 @@ "leaflet-fullscreen": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/leaflet-fullscreen/-/leaflet-fullscreen-1.0.2.tgz", - "integrity": "sha512-1Yxm8RZg6KlKX25+hbP2H/wnOAphH7hFcvuADJFb4QZTN7uOSN9Hsci5EZpow8vtNej9OGzu59Jxmn+0qKOO9Q==" + "integrity": "sha1-CcYcS6xF9jsu4Sav2H5c2XZQ/Bs=" }, "leaflet.locatecontrol": { "version": "0.79.0", @@ -13043,7 +13043,7 @@ "path-exists": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-3.0.0.tgz", - "integrity": "sha512-bpC7GYwiDYQ4wYLe+FA8lhRjhQCMcQGuSgGGqDkg/QerRWw9CmGRT0iSOVRSZJ29NMLZgIzqaljJ63oaL4NIJQ==", + "integrity": "sha1-zg6+ql94yxiSXqfYENe1mwEP1RU=", "dev": true } } @@ -13205,7 +13205,7 @@ "media-typer": { "version": "0.3.0", "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", - "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==" + "integrity": "sha1-hxDXrwqmJvj/+hzgAWhUUmMlV0g=" }, "memfs": { "version": "3.5.3", @@ -13295,7 +13295,7 @@ "merge-descriptors": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.1.tgz", - "integrity": "sha512-cCi6g3/Zr1iqQi6ySbseM1Xvooa98N0w31jzUYrXPX2xqObmFGHJ0tQ5u74H3mVh7wLouTseZyYIq39g8cNp1w==" + "integrity": "sha1-sAqqVW3YtEVoFQ7J0blT8/kMu2E=" }, "merge-stream": { "version": "2.0.0", @@ -13312,7 +13312,7 @@ "methods": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", - "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==" + "integrity": "sha1-VSmk1nZUE07cxSZmVoNbD4Ua/O4=" }, "mgrs": { "version": "1.0.0", @@ -14043,7 +14043,7 @@ "object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", - "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==" + "integrity": "sha1-IQmtx5ZYh8/AXLvUQsrIv7s2CGM=" }, "object-hash": { "version": "3.0.0", @@ -14155,7 +14155,7 @@ "on-finished": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.3.0.tgz", - "integrity": "sha512-ikqdkGAAyf/X/gPhXGvfgAytDZtDbr+bkNUJ0N9h5MI/dmdgCs3l6hoHrcUv41sRKew3jIwrp4qQDXiK99Utww==", + "integrity": "sha1-IPEzZIGwg811M3mSoWlxqi2QaUc=", "requires": { "ee-first": "1.1.1" } @@ -14169,7 +14169,7 @@ "once": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", - "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "integrity": "sha1-WDsap3WWHUsROsF9nFC6753Xa9E=", "requires": { "wrappy": "1" } @@ -14326,7 +14326,7 @@ "path-is-absolute": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", - "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==" + "integrity": "sha1-F0uSaHNVNP+8es5r9TpanhtcX18=" }, "path-key": { "version": "3.1.1", @@ -14341,7 +14341,7 @@ "path-to-regexp": { "version": "0.1.7", "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.7.tgz", - "integrity": "sha512-5DFkuoqlv1uYQKxy8omFBeJPQcdoE07Kv2sferDCrAq1ohOU+MSDswDIbnx3YAM60qIOnYa53wBhXW0EbMonrQ==" + "integrity": "sha1-32BBeABfUi8V60SQ5yR6G/qmf4w=" }, "path-type": { "version": "4.0.0", @@ -14351,7 +14351,7 @@ "performance-now": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz", - "integrity": "sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow==" + "integrity": "sha1-Ywn04OX6kT7BxpMHrjZLSzd8nns=" }, "picocolors": { "version": "1.0.0", @@ -14372,7 +14372,7 @@ "pify": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", - "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==", + "integrity": "sha1-7RQaasBDqEnqWISY59yosVMw6Qw=", "dev": true }, "pirates": { @@ -17157,7 +17157,7 @@ "require-directory": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", - "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==" + "integrity": "sha1-jGStX9MNqxyXbiNE/+f3kqam30I=" }, "require-from-string": { "version": "2.0.2", @@ -17726,7 +17726,7 @@ "lru-cache": { "version": "2.7.3", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-2.7.3.tgz", - "integrity": "sha512-WpibWJ60c3AgAz8a2iYErDrcT2C7OmKnsWhIcHOjkUHFjkXncJhtLxNSqUmxRxRunpb5I8Vprd7aNSd2NtksJQ==" + "integrity": "sha1-bUUk6LlV+V1PW1iFHOId1y+06VI=" } } }, @@ -17801,7 +17801,7 @@ "source-map": { "version": "0.5.7", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz", - "integrity": "sha512-LbrmJOMUSdEVxIKvdcJzQC+nQhe8FUZQTXQy6+I75skNgn3OoQ0DZA8YnFa7gp8tqtL3KPf1kmo0R5DoApeSGQ==" + "integrity": "sha1-igOdLRAh0i0eoUyA2OpGi6LvP8w=" }, "source-map-js": { "version": "1.0.2", @@ -17992,7 +17992,7 @@ "statuses": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/statuses/-/statuses-1.5.0.tgz", - "integrity": "sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA==" + "integrity": "sha1-Fhx9rBd2Wf2YEfQ3cfqZOBR4Yow=" }, "stdout-stream": { "version": "1.4.1", @@ -18611,7 +18611,7 @@ "to-fast-properties": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/to-fast-properties/-/to-fast-properties-2.0.0.tgz", - "integrity": "sha512-/OaKK0xYrs3DmxRYqL/yDc+FxFUVYhDlXMhRmv3z915w2HF1tnN1omB354j8VUGO/hbRzyD6Y3sA7v7GS/ceog==" + "integrity": "sha1-3F5pjL0HkmW8c+A3doGk5Og/YW4=" }, "to-regex-range": { "version": "5.0.1", @@ -18630,7 +18630,7 @@ "toposort": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/toposort/-/toposort-2.0.2.tgz", - "integrity": "sha512-0a5EOkAUp8D4moMi2W8ZF8jcga7BgZd91O/yabJCFY8az+XSzeGyTKs0Aoo897iV1Nj6guFq8orWDS96z91oGg==" + "integrity": "sha1-riF2gXXRVZ1IvvNUILL0li8JwzA=" }, "tough-cookie": { "version": "2.5.0", @@ -18896,7 +18896,7 @@ "unpipe": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", - "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==" + "integrity": "sha1-sr9O6FFKrmFltIF4KdIbLvSZBOw=" }, "unquote": { "version": "1.1.1", @@ -18953,7 +18953,7 @@ "util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", - "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==" + "integrity": "sha1-RQ1Nyfpw3nMnYvvS1KKJgUGaDM8=" }, "util.promisify": { "version": "1.0.1", @@ -18976,7 +18976,7 @@ "utils-merge": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", - "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==" + "integrity": "sha1-n5VxD1CiZ5R7LMwSR0HBAoQn5xM=" }, "uuid": { "version": "8.3.2", @@ -19019,7 +19019,7 @@ "vary": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", - "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==" + "integrity": "sha1-IpnwLG3tMNSllhsLn3RSShj2NPw=" }, "verror": { "version": "1.10.0", @@ -19739,7 +19739,7 @@ "wrappy": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", - "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==" + "integrity": "sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=" }, "write-file-atomic": { "version": "4.0.2", @@ -19760,7 +19760,7 @@ "xml": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/xml/-/xml-1.0.1.tgz", - "integrity": "sha512-huCv9IH9Tcf95zuYCsQraZtWnJvBtLVE0QHMOs8bWyZAFZNDcYjsPq1nEx8jKA9y+Beo9v+7OBPRisQTjinQMw==", + "integrity": "sha1-eLpyAgApxbyHuKgaPPzXS0ovweU=", "dev": true }, "xml-name-validator": { diff --git a/app/src/components/map/components/RegionSelector.tsx b/app/src/components/map/components/RegionSelector.tsx index d55b585c52..b16a2718c7 100644 --- a/app/src/components/map/components/RegionSelector.tsx +++ b/app/src/components/map/components/RegionSelector.tsx @@ -24,6 +24,10 @@ export const RegionSelector = (props: IRegionSelectorProps) => { { key: 'pub:WHSE_ADMIN_BOUNDARIES.ADM_NR_REGIONS_SPG', name: 'NRM Regional Boundaries' + }, + { + key: 'pub:WHSE_WILDLIFE_INVENTORY.GCPB_CARIBOU_POPULATION_SP', + name: 'Caribou Population Units' } ]; diff --git a/app/src/components/map/wfs-utils.tsx b/app/src/components/map/wfs-utils.tsx index c4f000444c..aff304bb14 100644 --- a/app/src/components/map/wfs-utils.tsx +++ b/app/src/components/map/wfs-utils.tsx @@ -18,6 +18,12 @@ export const layerNameHandler: Record = { return 'Unparsable Feature'; } return feature.properties.REGION_NAME; + }, + 'pub:WHSE_WILDLIFE_INVENTORY.GCPB_CARIBOU_POPULATION_SP': (feature: Feature) => { + if (!feature?.properties) { + return 'Unparsable Feature'; + } + return feature.properties.HERD_NAME; } }; @@ -39,9 +45,11 @@ export const layerContentHandlers: Record = { key={`${feature.id}-game-management-zone-id`}>{`Game Management Zone: ${feature.properties.GAME_MANAGEMENT_ZONE_ID}`}
{`Game Management Zone Name: ${feature.properties.GAME_MANAGEMENT_ZONE_NAME}`}
-
{`Region Area: ${(feature.properties.FEATURE_AREA_SQM / 10000).toFixed( - 0 - )} ha`}
+
+ {`Region Area: ${(feature.properties.FEATURE_AREA_SQM / 10000).toLocaleString(undefined, { + maximumFractionDigits: 0 + })} ha`} +
); @@ -62,9 +70,11 @@ export const layerContentHandlers: Record = {
{`Lands Name: ${feature.properties.PROTECTED_LANDS_NAME}`}
{`Lands Designation: ${feature.properties.PROTECTED_LANDS_DESIGNATION}`}
-
{`Region Area: ${(feature.properties.FEATURE_AREA_SQM / 10000).toFixed( - 0 - )} ha`}
+
+ {`Region Area: ${(feature.properties.FEATURE_AREA_SQM / 10000).toLocaleString(undefined, { + maximumFractionDigits: 0 + })} ha`} +
); @@ -83,9 +93,30 @@ export const layerContentHandlers: Record = { const content = ( <>
{`Region Name: ${feature.properties.REGION_NAME}`}
-
{`Region Area: ${(feature.properties.FEATURE_AREA_SQM / 10000).toFixed( - 0 - )} ha`}
+
+ {`Region Area: ${(feature.properties.FEATURE_AREA_SQM / 10000).toLocaleString(undefined, { + maximumFractionDigits: 0 + })} ha`} +
+ + ); + + return { tooltip, content }; + } + }, + 'pub:WHSE_WILDLIFE_INVENTORY.GCPB_CARIBOU_POPULATION_SP': { + featureKeyHandler: (feature: Feature) => feature?.properties?.OBJECTID, + popupContentHandler: (feature: Feature) => { + if (!feature?.properties) { + return { tooltip: 'Unparsable Feature', content: [] }; + } + + const tooltip = feature.properties.HERD_NAME; + + const content = ( + <> +
{`Herd Name: ${feature.properties.HERD_NAME}`}
+
{`Herd Number: ${feature.properties.HERD_NUMBER}`}
); diff --git a/app/src/features/surveys/components/locations/SurveyAreaMapControl.tsx b/app/src/features/surveys/components/locations/SurveyAreaMapControl.tsx index 0215409628..6998375b66 100644 --- a/app/src/features/surveys/components/locations/SurveyAreaMapControl.tsx +++ b/app/src/features/surveys/components/locations/SurveyAreaMapControl.tsx @@ -204,7 +204,7 @@ export const SurveyAreaMapControl = (props: ISurveyAreMapControlProps) => { !location?.leaflet_id) // filter out user drawn locations - .map((location, index) => { + .map((location) => { // Map geojson features into layer objects for leaflet return { layerName: location.name, From 07c28598e17f0c81e07335c51d73233352bc8531 Mon Sep 17 00:00:00 2001 From: Curtis Upshall Date: Mon, 25 Mar 2024 12:52:12 -0400 Subject: [PATCH 3/9] SIMSBIOHUB-500 Part 2: Removing more dead code (#1262) * Removed all references to 'occurrence' and 'dwc' * Removed CSV transforming * Removed occurrence submitting + error handling --- api/.pipeline/templates/api.dc.yaml | 4 +- api/src/constants/database.ts | 2 +- api/src/constants/status.ts | 92 +-- api/src/models/occurrence-create.test.ts | 112 --- api/src/models/occurrence-create.ts | 31 - api/src/paths/project/list.test.ts | 3 +- api/src/repositories/error-repository.test.ts | 85 --- api/src/repositories/error-repository.ts | 112 --- .../occurrence-repository.test.ts | 181 ----- api/src/repositories/occurrence-repository.ts | 304 -------- .../repositories/spatial-repository.test.ts | 256 ------- api/src/repositories/spatial-repository.ts | 224 ------ .../submission-repository.test.ts | 52 -- api/src/repositories/submission-repository.ts | 46 -- .../repositories/survey-repository.test.ts | 187 +---- api/src/repositories/survey-repository.ts | 256 ------- api/src/services/dwc-service.test.ts | 69 -- api/src/services/dwc-service.ts | 87 --- api/src/services/error-service.test.ts | 121 ---- api/src/services/error-service.ts | 103 --- api/src/services/observation-service.ts | 7 +- api/src/services/occurrence-service.test.ts | 125 ---- api/src/services/occurrence-service.ts | 116 --- api/src/services/project-service.ts | 12 +- api/src/services/spatial-service.test.ts | 153 ---- api/src/services/spatial-service.ts | 101 --- api/src/services/survey-service.test.ts | 252 +------ api/src/services/survey-service.ts | 111 +-- api/src/services/telemetry-service.ts | 3 +- api/src/utils/file-utils.test.ts | 15 +- api/src/utils/file-utils.ts | 18 +- api/src/utils/media/dwc/dwc-archive-file.ts | 196 ----- .../file-type-and-content-validator.test.ts | 102 +-- .../file-type-and-content-validator.ts | 43 +- .../validation/validation-schema-parser.ts | 5 +- .../xlsx-transform-schema-parser.ts | 385 ---------- .../transformation/xlsx-transform-utils.ts | 91 --- .../xlsx/transformation/xlsx-transform.ts | 668 ------------------ .../media/xlsx/validation/xlsx-validation.ts | 2 +- api/src/utils/media/xlsx/xlsx-file.ts | 7 +- api/src/utils/submission-error.ts | 62 -- api/src/utils/xlsx-utils/worksheet-utils.ts | 7 +- app/src/constants/spatial.ts | 6 - app/src/constants/submissions.ts | 28 - app/src/interfaces/useDwcaApi.interface.ts | 52 -- app/src/interfaces/useSurveyApi.interface.ts | 29 - app/src/test-helpers/survey-helpers.ts | 18 - app/src/utils/spatial-utils.tsx | 128 ---- .../src/seeds/02_dwc_spatial_transform.ts | 97 --- .../05_moose_summary_validation_insert.ts | 65 -- 50 files changed, 52 insertions(+), 5179 deletions(-) delete mode 100644 api/src/models/occurrence-create.test.ts delete mode 100644 api/src/models/occurrence-create.ts delete mode 100644 api/src/repositories/error-repository.test.ts delete mode 100644 api/src/repositories/error-repository.ts delete mode 100644 api/src/repositories/occurrence-repository.test.ts delete mode 100644 api/src/repositories/occurrence-repository.ts delete mode 100644 api/src/repositories/spatial-repository.test.ts delete mode 100644 api/src/repositories/spatial-repository.ts delete mode 100644 api/src/repositories/submission-repository.test.ts delete mode 100644 api/src/repositories/submission-repository.ts delete mode 100644 api/src/services/dwc-service.test.ts delete mode 100644 api/src/services/dwc-service.ts delete mode 100644 api/src/services/error-service.test.ts delete mode 100644 api/src/services/error-service.ts delete mode 100644 api/src/services/occurrence-service.test.ts delete mode 100644 api/src/services/occurrence-service.ts delete mode 100644 api/src/services/spatial-service.test.ts delete mode 100644 api/src/services/spatial-service.ts delete mode 100644 api/src/utils/media/dwc/dwc-archive-file.ts delete mode 100644 api/src/utils/media/xlsx/transformation/xlsx-transform-schema-parser.ts delete mode 100644 api/src/utils/media/xlsx/transformation/xlsx-transform-utils.ts delete mode 100644 api/src/utils/media/xlsx/transformation/xlsx-transform.ts delete mode 100644 api/src/utils/submission-error.ts delete mode 100644 app/src/constants/submissions.ts delete mode 100644 app/src/interfaces/useDwcaApi.interface.ts delete mode 100644 app/src/utils/spatial-utils.tsx delete mode 100644 database/src/seeds/02_dwc_spatial_transform.ts delete mode 100644 database/src/seeds/05_moose_summary_validation_insert.ts diff --git a/api/.pipeline/templates/api.dc.yaml b/api/.pipeline/templates/api.dc.yaml index cc61045ce5..bcf5fd08f8 100644 --- a/api/.pipeline/templates/api.dc.yaml +++ b/api/.pipeline/templates/api.dc.yaml @@ -45,10 +45,10 @@ parameters: description: API host for BioHub Platform Backbone. Example "https://platform.com". - name: BACKBONE_INTAKE_PATH required: true - description: API path for BioHub Platform Backbone DwCA submission intake endpoint. Example "/api/path/to/intake". + description: The publishing intake endpoint path for BioHub Platform submissions. Example "/api/path/to/intake". - name: BACKBONE_ARTIFACT_INTAKE_PATH required: true - description: API path for BioHub Platform Backbone artifact submission intake endpoint. Example "/api/path/to/artifact/intake". + description: The publishing intake endpoint path for BioHub Platform artifact submissions. Example "/api/path/to/artifact/intake". - name: BACKBONE_INTAKE_ENABLED required: true description: Controls whether or not SIMS will submit DwCA datasets to the BioHub Platform Backbone. Set to "true" to enable it, will be disabled by default otherwise. diff --git a/api/src/constants/database.ts b/api/src/constants/database.ts index 0eb06aef26..9368a96ce5 100644 --- a/api/src/constants/database.ts +++ b/api/src/constants/database.ts @@ -19,7 +19,7 @@ export enum SCHEMAS { } /** - * The source system of a DwCA data set submission. + * The source system for a dataset submission. * * Typically an external system that is participating in BioHub by submitting data to the BioHub Platform Backbone. * diff --git a/api/src/constants/status.ts b/api/src/constants/status.ts index 53874880fa..ab4a7a880c 100644 --- a/api/src/constants/status.ts +++ b/api/src/constants/status.ts @@ -1,65 +1,3 @@ -/** - * Completion Statuses - * - * @export - * @enum {string} - */ -export enum COMPLETION_STATUS { - COMPLETED = 'Completed', - ACTIVE = 'Active' -} - -/** - * Submission Status Types. - * - * See submission_status_type table -> name. - * - * @export - * @enum {number} - */ -export enum SUBMISSION_STATUS_TYPE { - 'SUBMITTED' = 'Submitted', - 'TEMPLATE_VALIDATED' = 'Template Validated', - 'DARWIN_CORE_VALIDATED' = 'Darwin Core Validated', - 'TEMPLATE_TRANSFORMED' = 'Template Transformed', - 'SUBMISSION_DATA_INGESTED' = 'Submission Data Ingested', - 'SECURED' = 'Secured', - 'AWAITING CURRATION' = 'Awaiting Curration', - 'REJECTED' = 'Rejected', - 'ON HOLD' = 'On Hold', - 'SYSTEM_ERROR' = 'System Error', - - //Failure - 'FAILED_OCCURRENCE_PREPARATION' = 'Failed to prepare submission', - 'INVALID_MEDIA' = 'Media is not valid', - 'FAILED_VALIDATION' = 'Failed to validate', - 'FAILED_TRANSFORMED' = 'Failed to transform', - 'FAILED_PROCESSING_OCCURRENCE_DATA' = 'Failed to process occurrence data', - 'FAILED_SUMMARY_PREPARATION' = 'Failed to prepare summary submission' -} - -export enum SUMMARY_SUBMISSION_MESSAGE_TYPE { - 'DUPLICATE_HEADER' = 'Duplicate Header', - 'UNKNOWN_HEADER' = 'Unknown Header', - 'MISSING_REQUIRED_HEADER' = 'Missing Required Header', - 'MISSING_RECOMMENDED_HEADER' = 'Missing Recommended Header', - 'DANGLING_PARENT_CHILD_KEY' = 'Missing Child Key from Parent', - 'MISCELLANEOUS' = 'Miscellaneous', - 'MISSING_REQUIRED_FIELD' = 'Missing Required Field', - 'UNEXPECTED_FORMAT' = 'Unexpected Format', - 'OUT_OF_RANGE' = 'Out of Range', - 'INVALID_VALUE' = 'Invalid Value', - 'MISSING_VALIDATION_SCHEMA' = 'Missing Validation Schema', - 'INVALID_MEDIA' = 'Media is Invalid', - 'INVALID_XLSX_CSV' = 'XLSX CSV is Invalid', - 'FAILED_TO_GET_TEMPLATE_NAME_VERSION' = 'Missing Name or Version Number', - 'FAILED_GET_VALIDATION_RULES' = 'Failed to Get Validation Rules', - 'FAILED_PARSE_VALIDATION_SCHEMA' = 'Failed to Parse Validation Schema', - 'UNSUPPORTED_FILE_TYPE' = 'Unsupported File Type', - 'FOUND_VALIDATION' = 'Found Validation', - 'SYSTEM_ERROR' = 'System Error' -} - // Message types that match the submission_message_type table export enum SUBMISSION_MESSAGE_TYPE { 'DUPLICATE_HEADER' = 'Duplicate Header', @@ -67,37 +5,9 @@ export enum SUBMISSION_MESSAGE_TYPE { 'MISSING_REQUIRED_HEADER' = 'Missing Required Header', 'MISSING_RECOMMENDED_HEADER' = 'Missing Recommended Header', 'DANGLING_PARENT_CHILD_KEY' = 'Missing Child Key from Parent', - 'MISCELLANEOUS' = 'Miscellaneous', 'MISSING_REQUIRED_FIELD' = 'Missing Required Field', 'UNEXPECTED_FORMAT' = 'Unexpected Format', 'OUT_OF_RANGE' = 'Out of Range', 'INVALID_VALUE' = 'Invalid Value', - 'MISSING_VALIDATION_SCHEMA' = 'Missing Validation Schema', - 'FAILED_GET_OCCURRENCE' = 'Failed to Get Occurrence Submission', - 'FAILED_GET_FILE_FROM_S3' = 'Failed to get file from S3', - 'FAILED_UPLOAD_FILE_TO_S3' = 'Failed to upload file to S3', - 'FAILED_PARSE_SUBMISSION' = 'Failed to parse submission', - 'FAILED_PREP_DWC_ARCHIVE' = 'Failed to prep DarwinCore Archive', - 'FAILED_PREP_XLSX' = 'Failed to prep XLSX', - 'FAILED_PERSIST_PARSE_ERRORS' = 'Failed to persist parse errors', - 'FAILED_GET_VALIDATION_RULES' = 'Failed to get validation rules', - 'FAILED_GET_TRANSFORMATION_RULES' = 'Failed to get transformation rules', - 'FAILED_PERSIST_TRANSFORMATION_RESULTS' = 'Failed to persist transformation results', - 'FAILED_TRANSFORM_XLSX' = 'Failed to transform XLSX', - 'FAILED_VALIDATE_DWC_ARCHIVE' = 'Failed to validate DarwinCore Archive', - 'FAILED_PERSIST_VALIDATION_RESULTS' = 'Failed to persist validation results', - 'FAILED_UPDATE_OCCURRENCE_SUBMISSION' = 'Failed to update occurrence submission', - 'FAILED_TO_GET_TRANSFORM_SCHEMA' = 'Unable to get transform schema for submission', - 'FAILED_TO_GET_TEMPLATE_NAME_VERSION' = 'Missing name or version number.', - 'INVALID_MEDIA' = 'Media is invalid', - 'INVALID_XLSX_CSV' = 'Media is not a valid XLSX CSV file.', - 'UNSUPPORTED_FILE_TYPE' = 'File submitted is not a supported type', - 'NON_UNIQUE_KEY' = 'Duplicate Key(s) found in file.', - 'MISMATCHED_TEMPLATE_SURVEY_SPECIES' = 'Mismatched template with survey focal species' -} - -export enum MESSAGE_CLASS_NAME { - NOTICE = 'Notice', - ERROR = 'Error', - WARNING = 'Warning' + 'NON_UNIQUE_KEY' = 'Duplicate Key(s) found in file.' } diff --git a/api/src/models/occurrence-create.test.ts b/api/src/models/occurrence-create.test.ts deleted file mode 100644 index a3005714b9..0000000000 --- a/api/src/models/occurrence-create.test.ts +++ /dev/null @@ -1,112 +0,0 @@ -import { expect } from 'chai'; -import { describe } from 'mocha'; -import { PostOccurrence } from './occurrence-create'; - -describe('PostOccurrence', () => { - describe('No values provided', () => { - let data: PostOccurrence; - - before(() => { - data = new PostOccurrence(null); - }); - - it('sets associatedTaxa', () => { - expect(data.associatedTaxa).to.equal(null); - }); - - it('sets lifeStage', () => { - expect(data.lifeStage).to.equal(null); - }); - - it('sets sex', () => { - expect(data.sex).to.equal(null); - }); - - it('sets data', () => { - expect(data.data).to.eql(null); - }); - - it('sets verbatimCoordinates', () => { - expect(data.verbatimCoordinates).to.eql(null); - }); - - it('sets individualCount', () => { - expect(data.individualCount).to.equal(null); - }); - - it('sets vernacularName', () => { - expect(data.vernacularName).to.equal(null); - }); - - it('sets organismQuantity', () => { - expect(data.organismQuantity).to.equal(null); - }); - - it('sets organismQuantityType', () => { - expect(data.organismQuantityType).to.equal(null); - }); - - it('sets eventDate', () => { - expect(data.eventDate).to.equal(null); - }); - }); - - describe('All values provided', () => { - let data: PostOccurrence; - - before(() => { - data = new PostOccurrence({ - associatedTaxa: 'associatedTaxa', - lifeStage: 'lifeStage', - sex: 'sex', - data: 'data', - verbatimCoordinates: 'verbatimCoordinates', - individualCount: 'individualCount', - vernacularName: 'vernacularName', - organismQuantity: 'organismQuantity', - organismQuantityType: 'organismQuantityType', - eventDate: 'eventDate' - }); - }); - - it('sets associatedTaxa', () => { - expect(data.associatedTaxa).to.equal('associatedTaxa'); - }); - - it('sets lifeStage', () => { - expect(data.lifeStage).to.equal('lifeStage'); - }); - - it('sets sex', () => { - expect(data.sex).to.equal('sex'); - }); - - it('sets data', () => { - expect(data.data).to.eql('data'); - }); - - it('sets verbatimCoordinates', () => { - expect(data.verbatimCoordinates).to.eql('verbatimCoordinates'); - }); - - it('sets individualCount', () => { - expect(data.individualCount).to.equal('individualCount'); - }); - - it('sets vernacularName', () => { - expect(data.vernacularName).to.equal('vernacularName'); - }); - - it('sets organismQuantity', () => { - expect(data.organismQuantity).to.equal('organismQuantity'); - }); - - it('sets organismQuantityType', () => { - expect(data.organismQuantityType).to.equal('organismQuantityType'); - }); - - it('sets eventDate', () => { - expect(data.eventDate).to.equal('eventDate'); - }); - }); -}); diff --git a/api/src/models/occurrence-create.ts b/api/src/models/occurrence-create.ts deleted file mode 100644 index b0b72e86ad..0000000000 --- a/api/src/models/occurrence-create.ts +++ /dev/null @@ -1,31 +0,0 @@ -/** - * Pre-processes POST occurrences data - * - * @export - * @class PostOccurrence - */ -export class PostOccurrence { - associatedTaxa: string; - lifeStage: string; - sex: string; - data: object; - verbatimCoordinates: string; - individualCount: number; - vernacularName: string; - organismQuantity: string; - organismQuantityType: string; - eventDate: string; - - constructor(obj?: any) { - this.associatedTaxa = obj?.associatedTaxa || null; - this.lifeStage = obj?.lifeStage || null; - this.sex = obj?.sex || null; - this.data = obj?.data || null; - this.verbatimCoordinates = obj?.verbatimCoordinates || null; - this.individualCount = obj?.individualCount || null; - this.vernacularName = obj?.vernacularName || null; - this.organismQuantity = obj?.organismQuantity || null; - this.organismQuantityType = obj?.organismQuantityType || null; - this.eventDate = obj?.eventDate || null; - } -} diff --git a/api/src/paths/project/list.test.ts b/api/src/paths/project/list.test.ts index d27a0944d2..250d2ff335 100644 --- a/api/src/paths/project/list.test.ts +++ b/api/src/paths/project/list.test.ts @@ -4,11 +4,10 @@ import { describe } from 'mocha'; import sinon from 'sinon'; import sinonChai from 'sinon-chai'; import { SYSTEM_ROLE } from '../../constants/roles'; -import { COMPLETION_STATUS } from '../../constants/status'; import * as db from '../../database/db'; import { HTTPError } from '../../errors/http-error'; import * as authorization from '../../request-handlers/security/authorization'; -import { ProjectService } from '../../services/project-service'; +import { COMPLETION_STATUS, ProjectService } from '../../services/project-service'; import { getMockDBConnection } from '../../__mocks__/db'; import * as list from './list'; diff --git a/api/src/repositories/error-repository.test.ts b/api/src/repositories/error-repository.test.ts deleted file mode 100644 index 8eceb4a0b3..0000000000 --- a/api/src/repositories/error-repository.test.ts +++ /dev/null @@ -1,85 +0,0 @@ -import chai, { expect } from 'chai'; -import { describe } from 'mocha'; -import { QueryResult } from 'pg'; -import sinon from 'sinon'; -import sinonChai from 'sinon-chai'; -import { SUBMISSION_MESSAGE_TYPE, SUBMISSION_STATUS_TYPE } from '../constants/status'; -import { ApiError } from '../errors/api-error'; -import { getMockDBConnection } from '../__mocks__/db'; -import { ErrorRepository } from './error-repository'; - -chai.use(sinonChai); - -describe('OccurrenceRepository', () => { - afterEach(() => { - sinon.restore(); - }); - - describe('insertSubmissionStatus', () => { - it('should return submission ids if valid', async () => { - const returnValue = { submission_status_id: 1, submission_status_type_id: 2 }; - const mockResponse = ({ rows: [returnValue], rowCount: 1 } as any) as Promise>; - const dbConnection = getMockDBConnection({ - sql: async () => { - return mockResponse; - } - }); - const repo = new ErrorRepository(dbConnection); - const response = await repo.insertSubmissionStatus(1, SUBMISSION_STATUS_TYPE.SUBMITTED); - - expect(response).to.eql(returnValue); - }); - - it('should throw `Failed to insert` error', async () => { - const mockResponse = ({ rows: [], rowCount: 0 } as any) as Promise>; - const dbConnection = getMockDBConnection({ - sql: async () => { - return mockResponse; - } - }); - const repo = new ErrorRepository(dbConnection); - try { - await repo.insertSubmissionStatus(1, SUBMISSION_STATUS_TYPE.SUBMITTED); - expect.fail(); - } catch (error) { - expect((error as ApiError).message).to.equal('Failed to insert submission status record'); - } - }); - }); - - describe('insertSubmissionMessage', () => { - it('should return submission ids if valid', async () => { - const returnValue = { submission_message_id: 1, submission_message_type_id: 2 }; - const mockResponse = ({ rows: [returnValue], rowCount: 1 } as any) as Promise>; - const dbConnection = getMockDBConnection({ - sql: async () => { - return mockResponse; - } - }); - const repo = new ErrorRepository(dbConnection); - const response = await repo.insertSubmissionMessage( - 1, - SUBMISSION_MESSAGE_TYPE.FAILED_GET_TRANSFORMATION_RULES, - 'msg' - ); - - expect(response).to.eql(returnValue); - }); - - it('should throw `Failed to insert` error', async () => { - const mockResponse = ({ rows: [], rowCount: 0 } as any) as Promise>; - const dbConnection = getMockDBConnection({ - sql: async () => { - return mockResponse; - } - }); - const repo = new ErrorRepository(dbConnection); - try { - await repo.insertSubmissionMessage(1, SUBMISSION_MESSAGE_TYPE.FAILED_GET_TRANSFORMATION_RULES, 'msg'); - expect.fail(); - } catch (error) { - expect((error as ApiError).message).to.equal('Failed to insert submission message record'); - } - }); - }); -}); diff --git a/api/src/repositories/error-repository.ts b/api/src/repositories/error-repository.ts deleted file mode 100644 index 094b5514b8..0000000000 --- a/api/src/repositories/error-repository.ts +++ /dev/null @@ -1,112 +0,0 @@ -import SQL from 'sql-template-strings'; -import { SUBMISSION_MESSAGE_TYPE, SUBMISSION_STATUS_TYPE } from '../constants/status'; -import { ApiExecuteSQLError } from '../errors/api-error'; -import { BaseRepository } from './base-repository'; - -/** - * A repository class for accessing permit data. - * - * @export - * @class PermitRepository - * @extends {BaseRepository} - */ -export class ErrorRepository extends BaseRepository { - /** - * Insert a new submission status record. - * - * @param {number} submissionId - * @param {SUBMISSION_STATUS_TYPE} submissionStatusType - * @return {*} {Promise<{ submission_status_id: number; submission_status_type_id: number }>} - * @memberof SubmissionRepository - */ - async insertSubmissionStatus( - submissionId: number, - submissionStatusType: SUBMISSION_STATUS_TYPE - ): Promise<{ submission_status_id: number; submission_status_type_id: number }> { - const sqlStatement = SQL` - INSERT INTO submission_status ( - occurrence_submission_id, - submission_status_type_id, - event_timestamp - ) VALUES ( - ${submissionId}, - ( - SELECT - submission_status_type_id - FROM - submission_status_type - WHERE - name = ${submissionStatusType} - ), - now() - ) - RETURNING - submission_status_id, - submission_status_type_id; - `; - - const response = await this.connection.sql<{ submission_status_id: number; submission_status_type_id: number }>( - sqlStatement - ); - - if (response.rowCount !== 1) { - throw new ApiExecuteSQLError('Failed to insert submission status record', [ - 'ErrorRepository->insertSubmissionStatus', - 'rowCount was null or undefined, expected rowCount = 1' - ]); - } - - return response.rows[0]; - } - - /** - * Insert a submission message record. - * - * @param {number} submissionStatusId - * @param {SUBMISSION_MESSAGE_TYPE} submissionMessageType - * @return {*} {Promise<{ submission_message_id: number; submission_message_type_id: number }>} - * @memberof SubmissionRepository - */ - async insertSubmissionMessage( - submissionStatusId: number, - submissionMessageType: SUBMISSION_MESSAGE_TYPE, - submissionMessage: string - ): Promise<{ submission_message_id: number; submission_message_type_id: number }> { - const sqlStatement = SQL` - INSERT INTO submission_message ( - submission_status_id, - submission_message_type_id, - event_timestamp, - message - ) VALUES ( - ${submissionStatusId}, - ( - SELECT - submission_message_type_id - FROM - submission_message_type - WHERE - name = ${submissionMessageType} - ), - now(), - ${submissionMessage} - ) - RETURNING - submission_message_id, - submission_message_type_id; - `; - - const response = await this.connection.sql<{ submission_message_id: number; submission_message_type_id: number }>( - sqlStatement - ); - - if (response.rowCount !== 1) { - throw new ApiExecuteSQLError('Failed to insert submission message record', [ - 'ErrorRepository->insertSubmissionMessage', - 'rowCount was null or undefined, expected rowCount = 1' - ]); - } - - return response.rows[0]; - } -} diff --git a/api/src/repositories/occurrence-repository.test.ts b/api/src/repositories/occurrence-repository.test.ts deleted file mode 100644 index b7d3c2bdc9..0000000000 --- a/api/src/repositories/occurrence-repository.test.ts +++ /dev/null @@ -1,181 +0,0 @@ -import chai, { expect } from 'chai'; -import { describe } from 'mocha'; -import { QueryResult } from 'pg'; -import sinon from 'sinon'; -import sinonChai from 'sinon-chai'; -import { SUBMISSION_MESSAGE_TYPE } from '../constants/status'; -import { HTTP400 } from '../errors/http-error'; -import { OccurrenceRepository } from '../repositories/occurrence-repository'; -import { SubmissionError } from '../utils/submission-error'; -import { getMockDBConnection } from '../__mocks__/db'; - -chai.use(sinonChai); - -describe('OccurrenceRepository', () => { - afterEach(() => { - sinon.restore(); - }); - - describe('getOccurrenceSubmission', () => { - it('should return a submission', async () => { - const mockResponse = ({ rows: [{ occurrence_submission_id: 1 }] } as any) as Promise>; - const dbConnection = getMockDBConnection({ - sql: async () => { - return mockResponse; - } - }); - const repo = new OccurrenceRepository(dbConnection); - const response = await repo.getOccurrenceSubmission(1); - - expect(response).to.eql({ occurrence_submission_id: 1 }); - }); - - it('should throw Failed to get occurrence submission error', async () => { - const mockResponse = ({ rows: [], rowCount: 0 } as any) as Promise>; - const dbConnection = getMockDBConnection({ - sql: async () => { - return mockResponse; - } - }); - - try { - const repo = new OccurrenceRepository(dbConnection); - await repo.getOccurrenceSubmission(1); - expect.fail(); - } catch (error) { - expect(error).to.be.instanceOf(SubmissionError); - expect((error as SubmissionError).submissionMessages[0].type).to.be.eql( - SUBMISSION_MESSAGE_TYPE.FAILED_GET_OCCURRENCE - ); - } - }); - }); - - describe('getOccurrencesForView', () => { - it('should return list of occurrences', async () => { - const mockResponse = ({ rows: [{ occurrence_id: 1 }] } as any) as Promise>; - const dbConnection = getMockDBConnection({ - knex: async () => { - return mockResponse; - } - }); - const repo = new OccurrenceRepository(dbConnection); - const response = await repo.getOccurrencesForView(1); - - expect(response).to.have.length.greaterThan(0); - }); - }); - - describe('updateSurveyOccurrenceSubmissionWithOutputKey', () => { - it('should succeed with valid data', async () => { - const mockResponse = ({ rowCount: 1, rows: [{ id: 1 }] } as any) as Promise>; - const dbConnection = getMockDBConnection({ - sql: () => mockResponse - }); - const repo = new OccurrenceRepository(dbConnection); - const response = await repo.updateSurveyOccurrenceSubmissionWithOutputKey(1, 'fileName', 'outputkey'); - expect(response).to.be.eql({ id: 1 }); - }); - - it('should throw `Failed to update` error', async () => { - const mockResponse = ({} as any) as Promise>; - const dbConnection = getMockDBConnection({ - sql: async () => { - return mockResponse; - } - }); - const repo = new OccurrenceRepository(dbConnection); - try { - await repo.updateSurveyOccurrenceSubmissionWithOutputKey(1, 'file', 'key'); - expect.fail(); - } catch (error) { - expect((error as HTTP400).message).to.equal('Rejected'); - } - }); - }); - - describe('updateDWCSourceForOccurrenceSubmission', () => { - it('should return submission id', async () => { - const mockResponse = ({ rows: [{ occurrence_submission_id: 1 }], rowCount: 1 } as any) as Promise< - QueryResult - >; - const dbConnection = getMockDBConnection({ - sql: async () => { - return mockResponse; - } - }); - - const repo = new OccurrenceRepository(dbConnection); - const id = await repo.updateDWCSourceForOccurrenceSubmission(1, '{}'); - expect(id).to.be.eql(1); - }); - - it('should throw Failed to update occurrence submission error', async () => { - const mockResponse = ({ rows: [], rowCount: 0 } as any) as Promise>; - const dbConnection = getMockDBConnection({ - sql: async () => { - return mockResponse; - } - }); - - try { - const repo = new OccurrenceRepository(dbConnection); - await repo.updateDWCSourceForOccurrenceSubmission(1, '{}'); - expect.fail(); - } catch (error) { - expect(error).to.be.instanceOf(SubmissionError); - expect((error as SubmissionError).submissionMessages[0].type).to.be.eql( - SUBMISSION_MESSAGE_TYPE.FAILED_UPDATE_OCCURRENCE_SUBMISSION - ); - } - }); - }); - - describe('findSpatialMetadataBySubmissionSpatialComponentIds', () => { - it('should succeed with valid data', async () => { - const mockResponse = ({ rowCount: 1, rows: [{ id: 1 }] } as any) as Promise>; - const dbConnection = getMockDBConnection({ - knex: () => mockResponse - }); - const repo = new OccurrenceRepository(dbConnection); - const response = await repo.findSpatialMetadataBySubmissionSpatialComponentIds([1]); - expect(response).to.be.eql([{ id: 1 }]); - }); - }); - - describe('softDeleteOccurrenceSubmission', () => { - it('should succeed with valid data', async () => { - const mockResponse = ({ rowCount: 1, rows: [{ id: 1 }] } as any) as Promise>; - const dbConnection = getMockDBConnection({ - sql: () => mockResponse - }); - const repo = new OccurrenceRepository(dbConnection); - const response = await repo.softDeleteOccurrenceSubmission(1); - expect(response).to.be.eql(undefined); - }); - }); - - describe('deleteSubmissionSpatialComponent', () => { - it('should succeed with valid data', async () => { - const mockResponse = ({ rowCount: 1, rows: [{ id: 1 }] } as any) as Promise>; - const dbConnection = getMockDBConnection({ - sql: () => mockResponse - }); - const repo = new OccurrenceRepository(dbConnection); - const response = await repo.deleteSubmissionSpatialComponent(1); - expect(response).to.be.eql([{ id: 1 }]); - }); - }); - - describe('deleteSpatialTransformSubmission', () => { - it('should succeed with valid data', async () => { - const mockResponse = ({ rowCount: 1, rows: [{ id: 1 }] } as any) as Promise>; - const dbConnection = getMockDBConnection({ - sql: () => mockResponse - }); - const repo = new OccurrenceRepository(dbConnection); - const response = await repo.deleteSpatialTransformSubmission(1); - expect(response).to.be.eql(undefined); - }); - }); -}); diff --git a/api/src/repositories/occurrence-repository.ts b/api/src/repositories/occurrence-repository.ts deleted file mode 100644 index 3bab775219..0000000000 --- a/api/src/repositories/occurrence-repository.ts +++ /dev/null @@ -1,304 +0,0 @@ -import { FeatureCollection, GeoJsonProperties } from 'geojson'; -import { Knex } from 'knex'; -import SQL from 'sql-template-strings'; -import { SUBMISSION_MESSAGE_TYPE } from '../constants/status'; -import { getKnex } from '../database/db'; -import { appendSQLColumnsEqualValues, AppendSQLColumnsEqualValues } from '../utils/sql-utils'; -import { SubmissionErrorFromMessageType } from '../utils/submission-error'; -import { BaseRepository } from './base-repository'; - -export interface IOccurrenceSubmission { - occurrence_submission_id: number; - survey_id: number; - template_methodology_species_id: number; - source: string; - input_key: string; - input_file_name: string; - output_key: string; - output_file_name: string; - darwin_core_source: Record; -} - -export type EmptyObject = Record; -export interface ITaxaData { - associated_taxa?: string; - vernacular_name?: string; - submission_spatial_component_id: number; -} - -export interface ISubmissionSpatialSearchResponseRow { - taxa_data: ITaxaData[]; - spatial_component: { - spatial_data: FeatureCollection | EmptyObject; - }; -} - -export interface ISpatialComponentFeaturePropertiesRow { - spatial_component_properties: GeoJsonProperties; -} - -export class OccurrenceRepository extends BaseRepository { - async updateDWCSourceForOccurrenceSubmission(submissionId: number, jsonData: string): Promise { - try { - const sql = SQL` - UPDATE - occurrence_submission - SET - darwin_core_source = ${jsonData} - WHERE - occurrence_submission_id = ${submissionId} - RETURNING - occurrence_submission_id; - `; - const response = await this.connection.sql<{ occurrence_submission_id: number }>(sql); - - if (!response.rowCount) { - throw SubmissionErrorFromMessageType(SUBMISSION_MESSAGE_TYPE.FAILED_UPDATE_OCCURRENCE_SUBMISSION); - } - return response.rows[0].occurrence_submission_id; - } catch (error) { - throw SubmissionErrorFromMessageType(SUBMISSION_MESSAGE_TYPE.FAILED_UPDATE_OCCURRENCE_SUBMISSION); - } - } - - /** - * Gets an `occurrence_submission` for an id or null if nothing is found - * - * @param {number} submissionId - * @return {*} {Promise} - */ - async getOccurrenceSubmission(submissionId: number): Promise { - const sqlStatement = SQL` - SELECT - * - FROM - occurrence_submission - WHERE - occurrence_submission_id = ${submissionId}; - `; - - const response = await this.connection.sql(sqlStatement); - - const result = (response && response.rows && response.rows[0]) || null; - - if (!result) { - throw SubmissionErrorFromMessageType(SUBMISSION_MESSAGE_TYPE.FAILED_GET_OCCURRENCE); - } - return result; - } - - /** - * Gets a list of `occurrence` for a `occurrence_submission_id`. - * - * @param {number} submissionId - * @return {*} {Promise} - */ - async getOccurrencesForView(submissionId: number): Promise { - const knex = getKnex(); - - const queryBuilder = knex - .queryBuilder() - .with('distinct_geographic_points', this._withDistinctGeographicPoints) - .with('with_filtered_spatial_component', (qb1) => { - // Get the spatial components that match the search filters - qb1 - .select( - knex.raw( - "jsonb_array_elements(ssc.spatial_component -> 'features') #> '{properties, dwc, datasetID}' as dataset_id" - ), - knex.raw( - "jsonb_array_elements(ssc.spatial_component -> 'features') #> '{properties, dwc, associatedTaxa}' as associated_taxa" - ), - knex.raw( - "jsonb_array_elements(ssc.spatial_component -> 'features') #> '{properties, dwc, vernacularName}' as vernacular_name" - ), - 'ssc.submission_spatial_component_id', - 'ssc.occurrence_submission_id', - 'ssc.spatial_component', - 'ssc.geography' - ) - .from('submission_spatial_component as ssc') - .leftJoin('distinct_geographic_points as p', 'p.geography', 'ssc.geography') - .groupBy('ssc.submission_spatial_component_id') - .groupBy('ssc.occurrence_submission_id') - .groupBy('ssc.spatial_component') - .groupBy('ssc.geography'); - - qb1.where((qb2) => { - qb2.whereRaw( - `occurrence_submission_id in (select occurrence_submission_id from submission_spatial_component where occurrence_submission_id in (${submissionId}))` - ); - }); - }) - .with('with_coalesced_spatial_components', (qb3) => { - qb3 - .select( - // Select the non-secure spatial component from the search results - 'submission_spatial_component_id', - 'occurrence_submission_id', - 'geography', - knex.raw( - `jsonb_build_object( 'submission_spatial_component_id', wfsc.submission_spatial_component_id, 'associated_taxa', wfsc.associated_taxa, 'vernacular_name', wfsc.vernacular_name) taxa_data_object` - ), - knex.raw(`jsonb_build_object( 'spatial_data', wfsc.spatial_component) spatial_component`) - ) - .from(knex.raw('with_filtered_spatial_component as wfsc')); - }) - .select( - knex.raw('array_agg(submission_spatial_component_id) as submission_spatial_component_ids'), - knex.raw('array_agg(taxa_data_object) as taxa_data'), - knex.raw('(array_agg(spatial_component))[1] as spatial_component'), - 'geography' - ) - .from('with_coalesced_spatial_components') - // Filter out secure spatial components that have no spatial representation - // The user is not allowed to see any aspect of these particular spatial components - .whereRaw("spatial_component->'spatial_data' != '{}'") - .groupBy('geography'); - - const response = await this.connection.knex(queryBuilder); - - return response.rows; - } - - _withDistinctGeographicPoints(qb1: Knex.QueryBuilder) { - qb1 - .distinct() - .select('geography') - .from('submission_spatial_component') - .whereRaw(`geometrytype(geography) = 'POINT'`) - .whereRaw(`jsonb_path_exists(spatial_component,'$.features[*] \\? (@.properties.type == "Occurrence")')`); - } - - /** - * Update existing `occurrence_submission` record with outputKey and outputFileName. - * - * @param {number} submissionId - * @param {string} outputFileName - * @param {string} outputKey - * @return {*} {Promise} - */ - async updateSurveyOccurrenceSubmissionWithOutputKey( - submissionId: number, - outputFileName: string, - outputKey: string - ): Promise { - const items: AppendSQLColumnsEqualValues[] = []; - - items.push({ columnName: 'output_file_name', columnValue: outputFileName }); - - items.push({ columnName: 'output_key', columnValue: outputKey }); - - const sqlStatement = SQL` - UPDATE occurrence_submission - SET - `; - - appendSQLColumnsEqualValues(sqlStatement, items); - - sqlStatement.append(SQL` - WHERE - occurrence_submission_id = ${submissionId} - RETURNING occurrence_submission_id as id; - `); - - const updateResponse = await await this.connection.sql(sqlStatement); - - if (!updateResponse || !updateResponse.rowCount) { - throw SubmissionErrorFromMessageType(SUBMISSION_MESSAGE_TYPE.FAILED_UPDATE_OCCURRENCE_SUBMISSION); - } - - return updateResponse.rows[0]; - } - - /** - * Query builder to find spatial component from a given submission id - * - * @param {number} submission_spatial_component_id - * @return {*} {Promise} - * @memberof SpatialRepository - */ - async findSpatialMetadataBySubmissionSpatialComponentIds( - submission_spatial_component_ids: number[] - ): Promise { - const knex = getKnex(); - const queryBuilder = knex - .queryBuilder() - .with('with_filtered_spatial_component', (qb1) => { - // Get the spatial components that match the search filters - qb1 - .select() - .from('submission_spatial_component as ssc') - .whereIn('submission_spatial_component_id', submission_spatial_component_ids); - }) - .select( - // Select the non-secure spatial component from the search results - knex.raw( - `jsonb_array_elements(wfsc.spatial_component -> 'features') #> '{properties}' as spatial_component_properties` - ) - ) - .from(knex.raw('with_filtered_spatial_component as wfsc')); - - const response = await this.connection.knex(queryBuilder); - - return response.rows; - } - - /** - * Soft delete Occurrence Submission, setting a delete Timestamp - * - * @param {number} occurrenceSubmissionId - * @memberof OccurrenceRepository - */ - async softDeleteOccurrenceSubmission(occurrenceSubmissionId: number) { - const sqlStatement = SQL` - UPDATE occurrence_submission - SET delete_timestamp = now() - WHERE occurrence_submission_id = ${occurrenceSubmissionId}; - `; - - await this.connection.sql(sqlStatement); - } - - /** - * Delete all spatial components by occurrence Id - * - * @param {number} occurrenceSubmissionId - * @return {*} {Promise<{ submission_spatial_component_id: number }[]>} - * @memberof OccurrenceRepository - */ - async deleteSubmissionSpatialComponent( - occurrenceSubmissionId: number - ): Promise<{ submission_spatial_component_id: number }[]> { - const sqlDeleteStatement = SQL` - DELETE FROM - submission_spatial_component - WHERE - occurrence_submission_id = ${occurrenceSubmissionId} - RETURNING - submission_spatial_component_id; - `; - - return (await this.connection.sql<{ submission_spatial_component_id: number }>(sqlDeleteStatement)).rows; - } - - /** - * Delete all spatial transform history by occurrence Id - * - * @param {number} occurrenceSubmissionId - * @return {*} {Promise} - * @memberof OccurrenceRepository - */ - async deleteSpatialTransformSubmission(occurrenceSubmissionId: number): Promise { - const sqlDeleteStatement = SQL` - DELETE FROM spatial_transform_submission - USING spatial_transform_submission as sts - LEFT OUTER JOIN submission_spatial_component as ssc ON - sts.submission_spatial_component_id = ssc.submission_spatial_component_id - WHERE - ssc.occurrence_submission_id = ${occurrenceSubmissionId}; - `; - - await this.connection.sql(sqlDeleteStatement); - } -} diff --git a/api/src/repositories/spatial-repository.test.ts b/api/src/repositories/spatial-repository.test.ts deleted file mode 100644 index 6de60d24c0..0000000000 --- a/api/src/repositories/spatial-repository.test.ts +++ /dev/null @@ -1,256 +0,0 @@ -import chai, { expect } from 'chai'; -import { FeatureCollection } from 'geojson'; -import { describe } from 'mocha'; -import { QueryResult } from 'pg'; -import sinon from 'sinon'; -import sinonChai from 'sinon-chai'; -import SQL from 'sql-template-strings'; -import { ApiGeneralError } from '../errors/api-error'; -import * as spatialUtils from '../utils/spatial-utils'; -import { getMockDBConnection } from '../__mocks__/db'; -import { SpatialRepository } from './spatial-repository'; - -chai.use(sinonChai); - -describe('SpatialRepository', () => { - describe('getSpatialTransformRecords', () => { - afterEach(() => { - sinon.restore(); - }); - - it('should succeed with valid data', async () => { - const mockQueryResponse = ({ - rowCount: 1, - rows: [ - { - spatial_transform_id: 1, - name: 'transform name', - description: 'transform description', - notes: 'notes', - transform: 'transform details' - } - ] - } as any) as Promise>; - - const mockDBConnection = getMockDBConnection({ - sql: async () => { - return mockQueryResponse; - } - }); - - const spatialRepository = new SpatialRepository(mockDBConnection); - - const response = await spatialRepository.getSpatialTransformRecords(); - - expect(response[0].spatial_transform_id).to.equal(1); - expect(response[0].name).to.equal('transform name'); - expect(response[0].description).to.equal('transform description'); - expect(response[0].notes).to.equal('notes'); - expect(response[0].transform).to.equal('transform details'); - }); - }); - - describe('insertSpatialTransformSubmissionRecord', () => { - afterEach(() => { - sinon.restore(); - }); - - it('should throw an error when insert sql fails', async () => { - const mockQueryResponse = ({ rowCount: 0 } as any) as Promise>; - - const mockDBConnection = getMockDBConnection({ - sql: async () => { - return mockQueryResponse; - } - }); - - const spatialRepository = new SpatialRepository(mockDBConnection); - - try { - await spatialRepository.insertSpatialTransformSubmissionRecord(1, 1); - expect.fail(); - } catch (actualError) { - expect((actualError as ApiGeneralError).message).to.equal( - 'Failed to insert spatial transform submission id and submission spatial component id' - ); - } - }); - - it('should succeed with valid data', async () => { - const mockQueryResponse = ({ rowCount: 1, rows: [{ spatial_transform_submission_id: 1 }] } as any) as Promise< - QueryResult - >; - - const mockDBConnection = getMockDBConnection({ - sql: async () => { - return mockQueryResponse; - } - }); - - const spatialRepository = new SpatialRepository(mockDBConnection); - - const response = await spatialRepository.insertSpatialTransformSubmissionRecord(1, 1); - - expect(response.spatial_transform_submission_id).to.equal(1); - }); - }); - - describe('runSpatialTransformOnSubmissionId', () => { - afterEach(() => { - sinon.restore(); - }); - - it('should succeed with valid data', async () => { - const mockQueryResponse = ({ - rowCount: 1, - rows: [ - { - result_data: { - type: 'FeatureCollection', - features: [] - } as FeatureCollection - } - ] - } as any) as Promise>; - - const mockDBConnection = getMockDBConnection({ - query: async () => { - return mockQueryResponse; - } - }); - - const spatialRepository = new SpatialRepository(mockDBConnection); - - const response = await spatialRepository.runSpatialTransformOnSubmissionId(1, 'string'); - - expect(response).to.eql([ - { - result_data: { - type: 'FeatureCollection', - features: [] - } as FeatureCollection - } - ]); - }); - }); - - describe('insertSubmissionSpatialComponent', () => { - afterEach(() => { - sinon.restore(); - }); - - it('should throw an error when insert sql fails', async () => { - const mockQueryResponse = ({ rowCount: 0 } as any) as Promise>; - - const mockDBConnection = getMockDBConnection({ - sql: async () => { - return mockQueryResponse; - } - }); - - const spatialRepository = new SpatialRepository(mockDBConnection); - - try { - await spatialRepository.insertSubmissionSpatialComponent(1, {} as FeatureCollection); - expect.fail(); - } catch (actualError) { - expect((actualError as ApiGeneralError).message).to.equal( - 'Failed to insert submission spatial component details' - ); - } - }); - - it('should succeed with valid data', async () => { - const mockQueryResponse = ({ rowCount: 1, rows: [{ submission_spatial_component_id: 1 }] } as any) as Promise< - QueryResult - >; - - const mockDBConnection = getMockDBConnection({ - sql: async () => { - return mockQueryResponse; - } - }); - - const spatialRepository = new SpatialRepository(mockDBConnection); - - const response = await spatialRepository.insertSubmissionSpatialComponent(1, {} as FeatureCollection); - - expect(response.submission_spatial_component_id).to.equal(1); - }); - - it('should succeed with valid data and append geography to sql statement', async () => { - const mockQueryResponse = ({ rowCount: 1, rows: [{ submission_spatial_component_id: 1 }] } as any) as Promise< - QueryResult - >; - - const mockDBConnection = getMockDBConnection({ - sql: async () => { - return mockQueryResponse; - } - }); - - const generateGeometryCollectionSQLStub = sinon - .stub(spatialUtils, 'generateGeometryCollectionSQL') - .returns(SQL`valid`); - - const spatialRepository = new SpatialRepository(mockDBConnection); - - const response = await spatialRepository.insertSubmissionSpatialComponent(1, { - type: 'FeatureCollection', - features: [ - { - type: 'Feature', - geometry: { - type: 'Point', - coordinates: [125.6, 10.1] - }, - properties: {} - } - ] - } as FeatureCollection); - - expect(response.submission_spatial_component_id).to.equal(1); - expect(generateGeometryCollectionSQLStub).to.be.calledOnce; - }); - }); - - describe('deleteSpatialComponentsBySubmissionId', () => { - it('should successfully return submission IDs for delete spatial data', async () => { - const mockQueryResponse = ({ rowCount: 1, rows: [{ occurrence_submission_id: 2 }] } as any) as Promise< - QueryResult - >; - - const mockDBConnection = getMockDBConnection({ - sql: async () => { - return mockQueryResponse; - } - }); - - const spatialRepository = new SpatialRepository(mockDBConnection); - - const response = await spatialRepository.deleteSpatialComponentsBySubmissionId(2); - - expect(response[0].occurrence_submission_id).to.equal(2); - }); - }); - - describe('deleteSpatialComponentsSpatialRefsBySubmissionId', () => { - it('should successfully return submission IDs for deleted spatial component reference', async () => { - const mockQueryResponse = ({ rowCount: 1, rows: [{ occurrence_submission_id: 2 }] } as any) as Promise< - QueryResult - >; - - const mockDBConnection = getMockDBConnection({ - sql: async () => { - return mockQueryResponse; - } - }); - - const spatialRepository = new SpatialRepository(mockDBConnection); - - const response = await spatialRepository.deleteSpatialComponentsSpatialTransformRefsBySubmissionId(2); - - expect(response[0].occurrence_submission_id).to.equal(2); - }); - }); -}); diff --git a/api/src/repositories/spatial-repository.ts b/api/src/repositories/spatial-repository.ts deleted file mode 100644 index ef79e0f428..0000000000 --- a/api/src/repositories/spatial-repository.ts +++ /dev/null @@ -1,224 +0,0 @@ -import { FeatureCollection } from 'geojson'; -import SQL from 'sql-template-strings'; -import { ApiExecuteSQLError } from '../errors/api-error'; -import { generateGeometryCollectionSQL } from '../utils/spatial-utils'; -import { BaseRepository } from './base-repository'; - -export interface IInsertSpatialTransform { - name: string; - description: string; - notes: string; - transform: string; -} - -export interface IGetSpatialTransformRecord { - spatial_transform_id: number; - name: string; - description: string | null; - notes: string | null; - transform: string; -} - -export interface ITransformSpatialRow { - result_data: FeatureCollection; -} - -export interface ISubmissionSpatialComponent { - submission_spatial_component_ids: number[]; - occurrence_submission_id: number; - spatial_component: FeatureCollection; - geometry: null; - geography: string; -} - -export class SpatialRepository extends BaseRepository { - /** - * get spatial transform records - * - * @param - * @return {*} {Promise} - * @memberof SpatialRepository - */ - async getSpatialTransformRecords(): Promise { - const sqlStatement = SQL` - SELECT - spatial_transform_id, - name, - description, - notes, - transform - FROM - spatial_transform; - `; - - const response = await this.connection.sql(sqlStatement); - - return response.rows; - } - - /** - * Insert record of transform id used for submission spatial component record - * - * @param {number} spatialTransformId - * @param {number} submissionSpatialComponentId - * @return {*} {Promise<{ spatial_transform_submission_id: number }>} - * @memberof SpatialRepository - */ - async insertSpatialTransformSubmissionRecord( - spatialTransformId: number, - submissionSpatialComponentId: number - ): Promise<{ spatial_transform_submission_id: number }> { - const sqlStatement = SQL` - INSERT INTO spatial_transform_submission ( - spatial_transform_id, - submission_spatial_component_id - ) VALUES ( - ${spatialTransformId}, - ${submissionSpatialComponentId} - ) - RETURNING - spatial_transform_submission_id; - `; - - const response = await this.connection.sql<{ spatial_transform_submission_id: number }>(sqlStatement); - - if (response.rowCount !== 1) { - throw new ApiExecuteSQLError( - 'Failed to insert spatial transform submission id and submission spatial component id', - [ - 'SpatialRepository->insertSpatialTransformSubmissionRecord', - 'rowCount was null or undefined, expected rowCount >= 1' - ] - ); - } - return response.rows[0]; - } - - /** - * Run Spatial Transform with transform string on submissionId - * - * @param {number} submissionId - * @param {string} transform - * @return {*} {Promise} - * @memberof SpatialRepository - */ - async runSpatialTransformOnSubmissionId(submissionId: number, transform: string): Promise { - const response = await this.connection.query(transform, [submissionId]); - - return response.rows; - } - - /** - * Insert given transformed data into Spatial Component Table - * - * @param {number} submissionId - * @param {Feature[]} transformedData - * @return {*} {Promise<{ submission_spatial_component_id: number }>} - * @memberof SpatialRepository - */ - async insertSubmissionSpatialComponent( - submissionId: number, - transformedData: FeatureCollection - ): Promise<{ submission_spatial_component_id: number }> { - const sqlStatement = SQL` - INSERT INTO submission_spatial_component ( - occurrence_submission_id, - spatial_component, - geography - ) VALUES ( - ${submissionId}, - ${JSON.stringify(transformedData)} - `; - - if (transformedData.features && transformedData.features.length > 0) { - const geoCollection = generateGeometryCollectionSQL(transformedData.features); - - sqlStatement.append(SQL` - ,public.geography( - public.ST_Force2D( - public.ST_SetSRID( - `); - - sqlStatement.append(geoCollection); - - sqlStatement.append(SQL` - , 4326))) - `); - } else { - sqlStatement.append(SQL` - ,null - `); - } - - sqlStatement.append(SQL` - ) - RETURNING - submission_spatial_component_id; - `); - - const response = await this.connection.sql<{ submission_spatial_component_id: number }>(sqlStatement); - - if (response.rowCount !== 1) { - throw new ApiExecuteSQLError('Failed to insert submission spatial component details', [ - 'SpatialRepository->insertSubmissionSpatialComponent', - 'rowCount was null or undefined, expected rowCount = 1' - ]); - } - return response.rows[0]; - } - - /** - * Deletes spatial components in a submission id before updating it with new data - * - * @param {number} occurrence_submission_id - * @return {*} {Promise<{ occurrence_submission_id: number }[]>} - * @memberof SpatialRepository - */ - async deleteSpatialComponentsBySubmissionId( - occurrence_submission_id: number - ): Promise<{ occurrence_submission_id: number }[]> { - const sqlStatement = SQL` - DELETE FROM - submission_spatial_component - WHERE - occurrence_submission_id=${occurrence_submission_id} - RETURNING - occurrence_submission_id; - ;`; - - const response = await this.connection.sql<{ occurrence_submission_id: number }>(sqlStatement); - - return response.rows; - } - - /** - * Remove references in spatial_transform_submission table - * - * @param {number} occurrence_submission_id - * @return {*} {Promise<{ occurrence_submission_id: number }[]>} - * @memberof SpatialRepository - */ - async deleteSpatialComponentsSpatialTransformRefsBySubmissionId( - occurrence_submission_id: number - ): Promise<{ occurrence_submission_id: number }[]> { - const sqlStatement = SQL` - DELETE FROM - spatial_transform_submission - WHERE - submission_spatial_component_id IN ( - SELECT - submission_spatial_component_id - FROM - submission_spatial_component - WHERE - occurrence_submission_id=${occurrence_submission_id} - ) - RETURNING - ${occurrence_submission_id}; - `; - - const response = await this.connection.sql<{ occurrence_submission_id: number }>(sqlStatement); - - return response.rows; - } -} diff --git a/api/src/repositories/submission-repository.test.ts b/api/src/repositories/submission-repository.test.ts deleted file mode 100644 index 1210892881..0000000000 --- a/api/src/repositories/submission-repository.test.ts +++ /dev/null @@ -1,52 +0,0 @@ -import chai, { expect } from 'chai'; -import { describe } from 'mocha'; -import { QueryResult } from 'pg'; -import sinon from 'sinon'; -import sinonChai from 'sinon-chai'; -import { HTTP400 } from '../errors/http-error'; -import { getMockDBConnection } from '../__mocks__/db'; -import { SubmissionRepository } from './submission-repository'; - -chai.use(sinonChai); - -describe('SubmissionRepository', () => { - afterEach(() => { - sinon.restore(); - }); - - describe('insertSubmissionStatus', () => { - it('should succeed with valid data', async () => { - const mockResponse = ({ - rows: [ - { - id: 1 - } - ] - } as any) as Promise>; - const dbConnection = getMockDBConnection({ - sql: () => mockResponse - }); - - const repo = new SubmissionRepository(dbConnection); - const response = await repo.insertSubmissionStatus(1, 'validated'); - - expect(response).to.be.eql(1); - }); - - it('should throw `Failed to update` error', async () => { - const mockResponse = ({} as any) as Promise>; - const dbConnection = getMockDBConnection({ - sql: () => mockResponse - }); - - const repo = new SubmissionRepository(dbConnection); - - try { - await repo.insertSubmissionStatus(1, 'validated'); - expect.fail(); - } catch (error) { - expect((error as HTTP400).message).to.be.eql('Rejected'); - } - }); - }); -}); diff --git a/api/src/repositories/submission-repository.ts b/api/src/repositories/submission-repository.ts deleted file mode 100644 index b89715a6ef..0000000000 --- a/api/src/repositories/submission-repository.ts +++ /dev/null @@ -1,46 +0,0 @@ -import SQL from 'sql-template-strings'; -import { SUBMISSION_MESSAGE_TYPE } from '../constants/status'; -import { SubmissionErrorFromMessageType } from '../utils/submission-error'; -import { BaseRepository } from './base-repository'; - -export class SubmissionRepository extends BaseRepository { - /** - * Insert a record into the submission_status table. - * - * @param {number} occurrenceSubmissionId - * @param {string} submissionStatusType - * @return {*} {Promise} - */ - async insertSubmissionStatus(occurrenceSubmissionId: number, submissionStatusType: string): Promise { - const sqlStatement = SQL` - INSERT INTO submission_status ( - occurrence_submission_id, - submission_status_type_id, - event_timestamp - ) VALUES ( - ${occurrenceSubmissionId}, - ( - SELECT - submission_status_type_id - FROM - submission_status_type - WHERE - name = ${submissionStatusType} - ), - now() - ) - RETURNING - submission_status_id as id; - `; - - const response = await this.connection.sql(sqlStatement); - - const result = (response && response.rows && response.rows[0]) || null; - - if (!result || !result.id) { - throw SubmissionErrorFromMessageType(SUBMISSION_MESSAGE_TYPE.FAILED_UPDATE_OCCURRENCE_SUBMISSION); - } - - return result.id; - } -} diff --git a/api/src/repositories/survey-repository.test.ts b/api/src/repositories/survey-repository.test.ts index 1cfce8959a..7926f32112 100644 --- a/api/src/repositories/survey-repository.test.ts +++ b/api/src/repositories/survey-repository.test.ts @@ -9,13 +9,7 @@ import { PostProprietorData, PostSurveyObject } from '../models/survey-create'; import { PutSurveyObject } from '../models/survey-update'; import { GetAttachmentsData, GetSurveyProprietorData, GetSurveyPurposeAndMethodologyData } from '../models/survey-view'; import { getMockDBConnection } from '../__mocks__/db'; -import { - IObservationSubmissionInsertDetails, - IObservationSubmissionUpdateDetails, - SurveyRecord, - SurveyRepository, - SurveyTypeRecord -} from './survey-repository'; +import { SurveyRecord, SurveyRepository, SurveyTypeRecord } from './survey-repository'; chai.use(sinonChai); @@ -354,56 +348,6 @@ describe('SurveyRepository', () => { }); }); - describe('getOccurrenceSubmissionId', () => { - it('should return result', async () => { - const mockResponse = ({ rows: [{ id: 1 }], rowCount: 1 } as any) as Promise>; - const dbConnection = getMockDBConnection({ sql: () => mockResponse }); - - const repository = new SurveyRepository(dbConnection); - - const response = await repository.getOccurrenceSubmission(1); - - expect(response).to.eql({ id: 1 }); - }); - - it('should return null if now rows returned', async () => { - const mockResponse = ({ rows: [{ occurrence_submission_id: null }], rowCount: 1 } as any) as Promise< - QueryResult - >; - const dbConnection = getMockDBConnection({ sql: () => mockResponse }); - - const repository = new SurveyRepository(dbConnection); - - const response = await repository.getOccurrenceSubmission(1); - - expect(response).to.eql({ occurrence_submission_id: null }); - }); - }); - - describe('getLatestSurveyOccurrenceSubmission', () => { - it('should return result', async () => { - const mockResponse = ({ rows: [{ id: 1 }], rowCount: 1 } as any) as Promise>; - const dbConnection = getMockDBConnection({ sql: () => mockResponse }); - - const repository = new SurveyRepository(dbConnection); - - const response = await repository.getLatestSurveyOccurrenceSubmission(1); - - expect(response).to.eql({ id: 1 }); - }); - - it('should return Null', async () => { - const mockResponse = ({ rows: undefined, rowCount: 0 } as any) as Promise>; - const dbConnection = getMockDBConnection({ sql: () => mockResponse }); - - const repository = new SurveyRepository(dbConnection); - - const response = await repository.getLatestSurveyOccurrenceSubmission(1); - - expect(response).to.eql(null); - }); - }); - describe('getAttachmentsData', () => { it('should return result', async () => { const mockResponse = ({ rows: [{ id: 1 }], rowCount: 1 } as any) as Promise>; @@ -917,135 +861,6 @@ describe('SurveyRepository', () => { }); }); - describe('getOccurrenceSubmissionMessages', () => { - it('should return result', async () => { - const mockResponse = ({ - rows: [ - { - id: 1, - type: 'type', - status: 'status', - class: 'class', - message: 'message' - } - ], - rowCount: 1 - } as any) as Promise>; - - const dbConnection = getMockDBConnection({ sql: () => mockResponse }); - - const repository = new SurveyRepository(dbConnection); - - const response = await repository.getOccurrenceSubmissionMessages(1); - - expect(response).to.eql([ - { - id: 1, - type: 'type', - status: 'status', - class: 'class', - message: 'message' - } - ]); - }); - - it('should return empty array', async () => { - const mockResponse = ({ rows: [], rowCount: 0 } as any) as Promise>; - const dbConnection = getMockDBConnection({ sql: () => mockResponse }); - - const repository = new SurveyRepository(dbConnection); - - const response = await repository.getOccurrenceSubmissionMessages(1); - - expect(response).to.eql([]); - }); - }); - - describe('insertSurveyOccurrenceSubmission', () => { - it('should return result', async () => { - const mockResponse = ({ rows: [{ submissionId: 1 }], rowCount: 1 } as any) as Promise>; - const dbConnection = getMockDBConnection({ knex: () => mockResponse }); - - const repository = new SurveyRepository(dbConnection); - - const response = await repository.insertSurveyOccurrenceSubmission({ - surveyId: 1 - } as IObservationSubmissionInsertDetails); - - expect(response).to.eql({ submissionId: 1 }); - }); - - it('should throw an error', async () => { - const mockResponse = ({ rows: undefined, rowCount: 0 } as any) as Promise>; - const dbConnection = getMockDBConnection({ knex: () => mockResponse }); - - const repository = new SurveyRepository(dbConnection); - - try { - await repository.insertSurveyOccurrenceSubmission({ surveyId: 1 } as IObservationSubmissionInsertDetails); - expect.fail(); - } catch (error) { - expect((error as Error).message).to.equal('Failed to insert survey occurrence submission'); - } - }); - }); - - describe('updateSurveyOccurrenceSubmission', () => { - it('should return result', async () => { - const mockResponse = ({ rows: [{ submissionId: 1 }], rowCount: 1 } as any) as Promise>; - const dbConnection = getMockDBConnection({ knex: () => mockResponse }); - - const repository = new SurveyRepository(dbConnection); - - const response = await repository.updateSurveyOccurrenceSubmission({ - submissionId: 1 - } as IObservationSubmissionUpdateDetails); - - expect(response).to.eql({ submissionId: 1 }); - }); - - it('should throw an error', async () => { - const mockResponse = ({ rows: undefined, rowCount: 0 } as any) as Promise>; - const dbConnection = getMockDBConnection({ knex: () => mockResponse }); - - const repository = new SurveyRepository(dbConnection); - - try { - await repository.updateSurveyOccurrenceSubmission({ submissionId: 1 } as IObservationSubmissionUpdateDetails); - expect.fail(); - } catch (error) { - expect((error as Error).message).to.equal('Failed to update survey occurrence submission'); - } - }); - }); - - describe('deleteOccurrenceSubmission', () => { - it('should return 1 upon success', async () => { - const mockResponse = ({ rows: [{ submissionId: 2 }], rowCount: 1 } as any) as Promise>; - const dbConnection = getMockDBConnection({ knex: () => mockResponse }); - - const repository = new SurveyRepository(dbConnection); - - const response = await repository.deleteOccurrenceSubmission(2); - - expect(response).to.eql(1); - }); - - it('should throw an error upon failure', async () => { - const mockResponse = ({ rows: [], rowCount: 0 } as any) as Promise>; - const dbConnection = getMockDBConnection({ knex: () => mockResponse }); - - const repository = new SurveyRepository(dbConnection); - - try { - await await repository.deleteOccurrenceSubmission(2); - expect.fail(); - } catch (error) { - expect((error as Error).message).to.equal('Failed to delete survey occurrence submission'); - } - }); - }); - describe('insertManySurveyIntendedOutcomes', () => { it('should insert intended outcome ids', async () => { const mockResponse = ({ rowCount: 0 } as any) as Promise>; diff --git a/api/src/repositories/survey-repository.ts b/api/src/repositories/survey-repository.ts index d92f59663b..3a4647257a 100644 --- a/api/src/repositories/survey-repository.ts +++ b/api/src/repositories/survey-repository.ts @@ -1,6 +1,5 @@ import SQL from 'sql-template-strings'; import { z } from 'zod'; -import { MESSAGE_CLASS_NAME, SUBMISSION_MESSAGE_TYPE, SUBMISSION_STATUS_TYPE } from '../constants/status'; import { getKnex } from '../database/db'; import { ApiExecuteSQLError } from '../errors/api-error'; import { PostProprietorData, PostSurveyObject } from '../models/survey-create'; @@ -20,33 +19,6 @@ export interface IGetSpeciesData { is_focal: boolean; } -export interface IGetLatestSurveyOccurrenceSubmission { - occurrence_submission_id: number; - survey_id: number; - source: string; - delete_timestamp: string; - event_timestamp: string; - input_key: string; - input_file_name: string; - output_key: string; - output_file_name: string; - submission_status_id: number; - submission_status_type_id: number; - submission_status_type_name?: SUBMISSION_STATUS_TYPE; - submission_message_id: number; - submission_message_type_id: number; - message: string; - submission_message_type_name: string; -} - -export interface IOccurrenceSubmissionMessagesResponse { - id: number; - class: MESSAGE_CLASS_NAME; - type: SUBMISSION_MESSAGE_TYPE; - status: SUBMISSION_STATUS_TYPE; - message: string; -} - export interface IObservationSubmissionInsertDetails { surveyId: number; source: string; @@ -379,138 +351,6 @@ export class SurveyRepository extends BaseRepository { return new GetSurveyProprietorData(result); } - /** - * Get Occurrence submission for a given survey id. - * - * @param {number} surveyId - * @return {*} {(Promise<{ occurrence_submission_id: number | null }>)} - * @memberof SurveyRepository - */ - async getOccurrenceSubmission(surveyId: number): Promise<{ occurrence_submission_id: number | null }> { - // Note: `max()` will always return a row, even if the table is empty. The value will be `null` in this case. - const sqlStatement = SQL` - SELECT - max(occurrence_submission_id) as occurrence_submission_id - FROM - occurrence_submission - WHERE - survey_id = ${surveyId} - AND - delete_timestamp is null; - `; - - const response = await this.connection.sql<{ occurrence_submission_id: number | null }>(sqlStatement); - - return response.rows[0]; - } - - /** - * Gets the latest Survey occurrence submission or null for a given surveyId - * - * @param {number} surveyId - * @returns {*} Promise - * @memberof SurveyRepository - */ - async getLatestSurveyOccurrenceSubmission(surveyId: number): Promise { - const sqlStatement = SQL` - SELECT - os.occurrence_submission_id, - os.survey_id, - os.source, - os.delete_timestamp, - os.event_timestamp, - os.input_key, - os.input_file_name, - os.output_key, - os.output_file_name, - ss.submission_status_id, - ss.submission_status_type_id, - sst.name as submission_status_type_name, - sm.submission_message_id, - sm.submission_message_type_id, - sm.message, - smt.name as submission_message_type_name - FROM - occurrence_submission as os - LEFT OUTER JOIN - submission_status as ss - ON - os.occurrence_submission_id = ss.occurrence_submission_id - LEFT OUTER JOIN - submission_status_type as sst - ON - sst.submission_status_type_id = ss.submission_status_type_id - LEFT OUTER JOIN - submission_message as sm - ON - sm.submission_status_id = ss.submission_status_id - LEFT OUTER JOIN - submission_message_type as smt - ON - smt.submission_message_type_id = sm.submission_message_type_id - WHERE - os.survey_id = ${surveyId} - ORDER BY - os.event_timestamp DESC, ss.submission_status_id DESC - LIMIT 1 - ; - `; - - const response = await this.connection.sql(sqlStatement); - - const result = response.rows?.[0] || null; - - return result; - } - - /** - * SQL query to get the list of messages for an occurrence submission. - * - * @param {number} submissionId The ID of the submission - * @returns {*} Promise Promise resolving the array of submission messages - */ - async getOccurrenceSubmissionMessages(submissionId: number): Promise { - const sqlStatement = SQL` - SELECT - sm.submission_message_id as id, - smt.name as type, - sst.name as status, - smc.name as class, - sm.message - FROM - occurrence_submission as os - LEFT OUTER JOIN - submission_status as ss - ON - os.occurrence_submission_id = ss.occurrence_submission_id - LEFT OUTER JOIN - submission_status_type as sst - ON - sst.submission_status_type_id = ss.submission_status_type_id - LEFT OUTER JOIN - submission_message as sm - ON - sm.submission_status_id = ss.submission_status_id - LEFT OUTER JOIN - submission_message_type as smt - ON - smt.submission_message_type_id = sm.submission_message_type_id - LEFT OUTER JOIN - submission_message_class smc - ON - smc.submission_message_class_id = smt.submission_message_class_id - WHERE - os.occurrence_submission_id = ${submissionId} - AND - sm.submission_message_id IS NOT NULL - ORDER BY sm.submission_message_id; - `; - - const response = await this.connection.sql(sqlStatement); - - return response.rows; - } - /** * Get Survey attachments data for a given surveyId * @@ -1129,102 +969,6 @@ export class SurveyRepository extends BaseRepository { await this.connection.sql(sqlStatement); } - /** - * Inserts a survey occurrence submission row. - * - * @param {IObservationSubmissionInsertDetails} submission The details of the submission - * @return {*} {Promise<{ submissionId: number }>} Promise resolving the ID of the submission upon successful insertion - */ - async insertSurveyOccurrenceSubmission( - submission: IObservationSubmissionInsertDetails - ): Promise<{ submissionId: number }> { - defaultLog.debug({ label: 'insertSurveyOccurrenceSubmission', submission }); - const queryBuilder = getKnex() - .table('occurrence_submission') - .insert({ - input_file_name: submission.inputFileName, - input_key: submission.inputKey, - output_file_name: submission.outputFileName, - output_key: submission.outputKey, - survey_id: submission.surveyId, - source: submission.source, - event_timestamp: `now()` - }) - .returning('occurrence_submission_id as submissionId'); - - const response = await this.connection.knex<{ submissionId: number }>(queryBuilder); - - if (response.rowCount !== 1) { - throw new ApiExecuteSQLError('Failed to insert survey occurrence submission', [ - 'ErrorRepository->insertSurveyOccurrenceSubmission', - 'rowCount was null or undefined, expected rowCount = 1' - ]); - } - - return response.rows[0]; - } - - /** - * Updates a survey occurrence submission with the given details. - * - * @param {IObservationSubmissionUpdateDetails} submission The details of the submission to be updated - * @return {*} {Promise<{ submissionId: number }>} Promise resolving the ID of the submission upon successfully updating it - */ - async updateSurveyOccurrenceSubmission( - submission: IObservationSubmissionUpdateDetails - ): Promise<{ submissionId: number }> { - defaultLog.debug({ label: 'updateSurveyOccurrenceSubmission', submission }); - const queryBuilder = getKnex() - .table('occurrence_submission') - .update({ - input_file_name: submission.inputFileName, - input_key: submission.inputKey, - output_file_name: submission.outputFileName, - output_key: submission.outputKey - }) - .where('occurrence_submission_id', submission.submissionId) - .returning('occurrence_submission_id as submissionId'); - - const response = await this.connection.knex<{ submissionId: number }>(queryBuilder); - - if (response.rowCount !== 1) { - throw new ApiExecuteSQLError('Failed to update survey occurrence submission', [ - 'ErrorRepository->updateSurveyOccurrenceSubmission', - 'rowCount was null or undefined, expected rowCount = 1' - ]); - } - - return response.rows[0]; - } - - /** - * Soft-deletes an occurrence submission. - * - * @param {number} submissionId The ID of the submission to soft delete - * @returns {*} {number} The row count of the affected records, namely `1` if the delete succeeds, `0` if it does not - */ - async deleteOccurrenceSubmission(submissionId: number): Promise { - defaultLog.debug({ label: 'deleteOccurrenceSubmission', submissionId }); - const queryBuilder = getKnex() - .table('occurrence_submission') - .update({ - delete_timestamp: `now()` - }) - .where('occurrence_submission_id', submissionId) - .returning('occurrence_submission_id as submissionId'); - - const response = await this.connection.knex<{ submissionId: number }>(queryBuilder); - - if (response.rowCount !== 1) { - throw new ApiExecuteSQLError('Failed to delete survey occurrence submission', [ - 'ErrorRepository->deleteOccurrenceSubmission', - 'rowCount was null or undefined, expected rowCount = 1' - ]); - } - - return response.rowCount; - } - /** * Gets all indigenous partnerships belonging to the given survey * diff --git a/api/src/services/dwc-service.test.ts b/api/src/services/dwc-service.test.ts deleted file mode 100644 index 45f12eb736..0000000000 --- a/api/src/services/dwc-service.test.ts +++ /dev/null @@ -1,69 +0,0 @@ -import chai, { expect } from 'chai'; -import { describe } from 'mocha'; -import sinon from 'sinon'; -import sinonChai from 'sinon-chai'; -import * as spatial_utils from '../utils/spatial-utils'; -import { getMockDBConnection } from '../__mocks__/db'; -import { DwCService } from './dwc-service'; - -chai.use(sinonChai); - -describe('DwCService', () => { - it('constructs', () => { - const dbConnectionObj = getMockDBConnection(); - - const dwcService = new DwCService(dbConnectionObj); - - expect(dwcService).to.be.instanceof(DwCService); - }); - - describe('decorateLatLong', () => { - afterEach(() => { - sinon.restore(); - }); - - it('returns if decimalLatitude and decimalLongitude are filled ', async () => { - const dbConnectionObj = getMockDBConnection(); - - const dwcService = new DwCService(dbConnectionObj); - const jsonObject = { - item_with_depth_1: { - item_with_depth_2: [{ verbatimCoordinates: '', decimalLatitude: 123, decimalLongitude: 123 }] - } - }; - - const newJson = await dwcService.decorateLatLong(jsonObject); - - expect(newJson).to.eql(jsonObject); - }); - - it('succeeds and decorates Lat Long', async () => { - const dbConnectionObj = getMockDBConnection(); - - const dwcService = new DwCService(dbConnectionObj); - const jsonObject = { - item_with_depth_1: { - item_with_depth_2: [{ verbatimCoordinates: '12 12314 12241' }] - } - }; - - sinon.stub(spatial_utils, 'parseUTMString').returns({ - easting: 1, - northing: 2, - zone_letter: 'a', - zone_number: 3, - zone_srid: 4 - }); - - sinon.stub(spatial_utils, 'utmToLatLng').returns({ latitude: 1, longitude: 2 }); - - const response = await await dwcService.decorateLatLong(jsonObject); - - expect(response).to.eql({ - item_with_depth_1: { - item_with_depth_2: [{ verbatimCoordinates: '12 12314 12241', decimalLatitude: 1, decimalLongitude: 2 }] - } - }); - }); - }); -}); diff --git a/api/src/services/dwc-service.ts b/api/src/services/dwc-service.ts deleted file mode 100644 index 9ce37d22e7..0000000000 --- a/api/src/services/dwc-service.ts +++ /dev/null @@ -1,87 +0,0 @@ -import jsonpatch, { Operation } from 'fast-json-patch'; -import { JSONPath } from 'jsonpath-plus'; -import { IDBConnection } from '../database/db'; -import { parseUTMString, utmToLatLng } from '../utils/spatial-utils'; -import { DBService } from './db-service'; - -/** - * Service to produce DWC data for a project. - * - * @see https://eml.ecoinformatics.org for EML specification - * @see https://knb.ecoinformatics.org/emlparser/ for an online EML validator. - * @export - * @class EmlService - * @extends {DBService} - */ -export class DwCService extends DBService { - constructor(connection: IDBConnection) { - super(connection); - } - - /** - * Creates a set of all taxon IDs from the provided object - * - * @param {any} patchToPatch - * @return {*} {Set} - * @memberof DwCService - */ - collectTaxonIDs(pathsToPatch: any): Set { - const taxonSet = new Set(); - pathsToPatch.forEach((item: any) => { - taxonSet.add(item.value['taxonID']); - }); - return taxonSet; - } - - /** - * Decorate Lat Long details for Location data - * - * @param {string} jsonObject - * @return {*} {Promise} - * @memberof DwCService - */ - async decorateLatLong(jsonObject: Record): Promise> { - const pathsToPatch = JSONPath({ - path: '$..[verbatimCoordinates]^', - json: jsonObject, - resultType: 'all' - }); - - const patchOperations: Operation[] = []; - - pathsToPatch.forEach(async (item: any) => { - if ( - Object.prototype.hasOwnProperty.call(item.value, 'decimalLatitude') && - Object.prototype.hasOwnProperty.call(item.value, 'decimalLongitude') - ) { - if (!!item.value['decimalLatitude'] && !!item.value['decimalLongitude']) { - return jsonObject; - } - } - - const verbatimCoordinates = parseUTMString(item.value['verbatimCoordinates']); - - if (!verbatimCoordinates) { - return; - } - - const latLongValues = utmToLatLng(verbatimCoordinates); - - const decimalLatitudePatch: Operation = { - op: 'add', - path: item.pointer + '/decimalLatitude', - value: latLongValues.latitude - }; - - const decimalLongitudePatch: Operation = { - op: 'add', - path: item.pointer + '/decimalLongitude', - value: latLongValues.longitude - }; - - patchOperations.push(decimalLatitudePatch, decimalLongitudePatch); - }); - - return jsonpatch.applyPatch(jsonObject, patchOperations).newDocument; - } -} diff --git a/api/src/services/error-service.test.ts b/api/src/services/error-service.test.ts deleted file mode 100644 index b69944f3c5..0000000000 --- a/api/src/services/error-service.test.ts +++ /dev/null @@ -1,121 +0,0 @@ -import chai, { expect } from 'chai'; -import { describe } from 'mocha'; -import sinon from 'sinon'; -import sinonChai from 'sinon-chai'; -import { SUBMISSION_MESSAGE_TYPE, SUBMISSION_STATUS_TYPE } from '../constants/status'; -import { ErrorRepository } from '../repositories/error-repository'; -import { SubmissionError } from '../utils/submission-error'; -import { getMockDBConnection } from '../__mocks__/db'; -import { ErrorService } from './error-service'; - -chai.use(sinonChai); - -describe('ErrorService', () => { - afterEach(() => { - sinon.restore(); - }); - - describe('insertSubmissionStatus', () => { - it('should return submission_id and submission_status_type_id on insert', async () => { - const mockDBConnection = getMockDBConnection(); - const errorService = new ErrorService(mockDBConnection); - - const repo = sinon - .stub(ErrorRepository.prototype, 'insertSubmissionStatus') - .resolves({ submission_status_id: 1, submission_status_type_id: 1 }); - - const response = await errorService.insertSubmissionStatus(1, SUBMISSION_STATUS_TYPE.DARWIN_CORE_VALIDATED); - - expect(repo).to.be.calledOnce; - expect(response).to.be.eql({ submission_status_id: 1, submission_status_type_id: 1 }); - }); - }); - - describe('insertSubmissionMessage', () => { - it('should return submission message id and submission_message_type_id', async () => { - const mockDBConnection = getMockDBConnection(); - const errorService = new ErrorService(mockDBConnection); - - const repo = sinon - .stub(ErrorRepository.prototype, 'insertSubmissionMessage') - .resolves({ submission_message_id: 1, submission_message_type_id: 1 }); - - const response = await errorService.insertSubmissionMessage( - 1, - SUBMISSION_MESSAGE_TYPE.FAILED_GET_OCCURRENCE, - 'some message' - ); - - expect(repo).to.be.calledOnce; - expect(response).to.be.eql({ submission_message_id: 1, submission_message_type_id: 1 }); - }); - }); - - describe('insertSubmissionStatusAndMessage', () => { - it('should return submission status id and message id', async () => { - const mockDBConnection = getMockDBConnection(); - const errorService = new ErrorService(mockDBConnection); - - const mockMessageResponse = { submission_message_id: 1, submission_message_type_id: 1 }; - const mockStatusResponse = { submission_status_id: 2, submission_status_type_id: 2 }; - - const repoStatus = sinon.stub(ErrorRepository.prototype, 'insertSubmissionStatus').resolves(mockStatusResponse); - - const repoMessage = sinon - .stub(ErrorRepository.prototype, 'insertSubmissionMessage') - .resolves(mockMessageResponse); - - const response = await errorService.insertSubmissionStatusAndMessage( - 1, - SUBMISSION_STATUS_TYPE.FAILED_VALIDATION, - SUBMISSION_MESSAGE_TYPE.FAILED_PARSE_SUBMISSION, - 'message' - ); - expect(repoStatus).to.be.calledOnce; - expect(repoMessage).to.be.calledOnce; - expect(response).to.be.eql({ - submission_status_id: 2, - submission_message_id: 1 - }); - }); - }); - - describe('insertSubmissionError', () => { - it('should insert a submission status id and an array of submission messages', async () => { - const mockDBConnection = getMockDBConnection(); - const errorService = new ErrorService(mockDBConnection); - - const mockMessageResponse = { submission_message_id: 1, submission_message_type_id: 1 }; - const mockStatusResponse = { submission_status_id: 2, submission_status_type_id: 2 }; - - const repoStatusStub = sinon - .stub(ErrorRepository.prototype, 'insertSubmissionStatus') - .resolves(mockStatusResponse); - - const repoMessageStub = sinon - .stub(ErrorRepository.prototype, 'insertSubmissionMessage') - .resolves(mockMessageResponse); - - const submissionError = { - status: SUBMISSION_STATUS_TYPE.INVALID_MEDIA, - submissionMessages: [ - { - type: SUBMISSION_MESSAGE_TYPE.FAILED_PARSE_SUBMISSION, - description: 'there is a problem in row 10', - errorCode: 'some error code' - } - ] - }; - - await errorService.insertSubmissionError(1, submissionError as SubmissionError); - - expect(repoStatusStub).to.be.calledOnce; - expect(repoMessageStub).to.be.calledOnce; - expect(repoMessageStub).to.have.been.calledWith( - mockStatusResponse.submission_status_id, - submissionError.submissionMessages[0].type, - submissionError.submissionMessages[0].description - ); - }); - }); -}); diff --git a/api/src/services/error-service.ts b/api/src/services/error-service.ts deleted file mode 100644 index 2a99f9d8ad..0000000000 --- a/api/src/services/error-service.ts +++ /dev/null @@ -1,103 +0,0 @@ -import { SUBMISSION_MESSAGE_TYPE, SUBMISSION_STATUS_TYPE } from '../constants/status'; -import { IDBConnection } from '../database/db'; -import { ErrorRepository } from '../repositories/error-repository'; -import { SubmissionError } from '../utils/submission-error'; -import { DBService } from './db-service'; - -export class ErrorService extends DBService { - errorRepository: ErrorRepository; - - constructor(connection: IDBConnection) { - super(connection); - - this.errorRepository = new ErrorRepository(connection); - } - - /** - * Inserts both the status and message of a submission - * - * @param {number} submissionId - * @param {SUBMISSION_STATUS_TYPE} submissionStatusType - * @param {SUBMISSION_MESSAGE_TYPE} submissionMessageType - * @param {string} submissionMessage - * @return {*} {Promise<{ - * submission_status_id: number; - * submission_message_id: number; - * }>} - * @memberof SubmissionService - */ - async insertSubmissionStatusAndMessage( - submissionId: number, - submissionStatusType: SUBMISSION_STATUS_TYPE, - submissionMessageType: SUBMISSION_MESSAGE_TYPE, - submissionMessage: string - ): Promise<{ - submission_status_id: number; - submission_message_id: number; - }> { - const submission_status_id = (await this.errorRepository.insertSubmissionStatus(submissionId, submissionStatusType)) - .submission_status_id; - - const submission_message_id = ( - await this.errorRepository.insertSubmissionMessage(submission_status_id, submissionMessageType, submissionMessage) - ).submission_message_id; - - return { - submission_status_id, - submission_message_id - }; - } - - /** - * Insert a submission status record. - * - * @param {number} submissionId - * @param {SUBMISSION_STATUS_TYPE} submissionStatusType - * @return {*} {Promise<{ - * submission_status_id: number; - * submission_status_type_id: number; - * }>} - * @memberof SubmissionService - */ - async insertSubmissionStatus( - submissionId: number, - submissionStatusType: SUBMISSION_STATUS_TYPE - ): Promise<{ - submission_status_id: number; - submission_status_type_id: number; - }> { - return this.errorRepository.insertSubmissionStatus(submissionId, submissionStatusType); - } - - /** - * Insert a submission m record. - * - * @param {number} submissionId - * @param {SUBMISSION_STATUS_TYPE} submissionStatusType - * @return {*} {Promise<{ - * submission_status_id: number; - * submission_status_type_id: number; - * }>} - * @memberof SubmissionService - */ - async insertSubmissionMessage( - submissionStatusId: number, - submissionMessageType: SUBMISSION_MESSAGE_TYPE, - submissionMessage: string - ): Promise<{ - submission_message_id: number; - submission_message_type_id: number; - }> { - return this.errorRepository.insertSubmissionMessage(submissionStatusId, submissionMessageType, submissionMessage); - } - - async insertSubmissionError(submissionId: number, error: SubmissionError) { - const submission_status_id = (await this.errorRepository.insertSubmissionStatus(submissionId, error.status)) - .submission_status_id; - const promises = error.submissionMessages.map((message) => { - return this.errorRepository.insertSubmissionMessage(submission_status_id, message.type, message.description); - }); - - await Promise.all(promises); - } -} diff --git a/api/src/services/observation-service.ts b/api/src/services/observation-service.ts index 448f71e13d..36f8471034 100644 --- a/api/src/services/observation-service.ts +++ b/api/src/services/observation-service.ts @@ -17,6 +17,7 @@ import { import { generateS3FileKey, getFileFromS3 } from '../utils/file-utils'; import { getLogger } from '../utils/logger'; import { parseS3File } from '../utils/media/media-utils'; +import { DEFAULT_XLSX_SHEET_NAME } from '../utils/media/xlsx/xlsx-file'; import { constructWorksheets, constructXLSXWorkbook, @@ -103,12 +104,12 @@ export class ObservationService extends DBService { */ validateCsvFile(xlsxWorksheets: xlsx.WorkSheet, columnValidator: IXLSXCSVValidator): boolean { // Validate the worksheet headers - if (!validateWorksheetHeaders(xlsxWorksheets['Sheet1'], columnValidator)) { + if (!validateWorksheetHeaders(xlsxWorksheets[DEFAULT_XLSX_SHEET_NAME], columnValidator)) { return false; } // Validate the worksheet column types - if (!validateWorksheetColumnTypes(xlsxWorksheets['Sheet1'], columnValidator)) { + if (!validateWorksheetColumnTypes(xlsxWorksheets[DEFAULT_XLSX_SHEET_NAME], columnValidator)) { return false; } @@ -430,7 +431,7 @@ export class ObservationService extends DBService { const measurementColumns = getMeasurementColumnNameFromWorksheet(xlsxWorksheets, observationCSVColumnValidator); // Get the worksheet row objects - const worksheetRowObjects = getWorksheetRowObjects(xlsxWorksheets['Sheet1']); + const worksheetRowObjects = getWorksheetRowObjects(xlsxWorksheets[DEFAULT_XLSX_SHEET_NAME]); // Validate measurement data against if (!validateCsvMeasurementColumns(worksheetRowObjects, measurementColumns, tsnMeasurements)) { throw new Error('Failed to process file for importing observations. Measurement column validator failed.'); diff --git a/api/src/services/occurrence-service.test.ts b/api/src/services/occurrence-service.test.ts deleted file mode 100644 index 37ce3ce5cd..0000000000 --- a/api/src/services/occurrence-service.test.ts +++ /dev/null @@ -1,125 +0,0 @@ -import chai, { expect } from 'chai'; -import { Feature, FeatureCollection } from 'geojson'; -import { describe } from 'mocha'; -import sinon from 'sinon'; -import sinonChai from 'sinon-chai'; -import { ISpatialComponentFeaturePropertiesRow, OccurrenceRepository } from '../repositories/occurrence-repository'; -import { getMockDBConnection } from '../__mocks__/db'; -import { OccurrenceService } from './occurrence-service'; - -chai.use(sinonChai); - -describe('OccurrenceService', () => { - afterEach(() => { - sinon.restore(); - }); - - const mockService = () => { - const dbConnection = getMockDBConnection(); - return new OccurrenceService(dbConnection); - }; - - describe('getOccurrenceSubmission', () => { - it('should return a post occurrence', async () => { - const submissionId = 1; - const repo = sinon.stub(OccurrenceRepository.prototype, 'getOccurrenceSubmission').resolves({ - occurrence_submission_id: 1, - survey_id: 1, - template_methodology_species_id: 1, - source: '', - input_key: '', - input_file_name: '', - output_key: '', - output_file_name: '', - darwin_core_source: {} - }); - const dbConnection = getMockDBConnection(); - const service = new OccurrenceService(dbConnection); - const response = await service.getOccurrenceSubmission(submissionId); - - expect(repo).to.be.calledOnce; - expect(response?.occurrence_submission_id).to.be.eql(submissionId); - }); - }); - - describe('getOccurrences', () => { - it('should return a post occurrence', async () => { - const submissionId = 1; - const repo = sinon.stub(OccurrenceRepository.prototype, 'getOccurrencesForView').resolves([ - { - taxa_data: [{ associated_taxa: 'string;', vernacular_name: 'string;', submission_spatial_component_id: 1 }], - spatial_component: { - spatial_data: ({ features: [({ id: 1 } as unknown) as Feature] } as unknown) as FeatureCollection - } - } - ]); - - const dbConnection = getMockDBConnection(); - const service = new OccurrenceService(dbConnection); - const response = await service.getOccurrences(submissionId); - - expect(repo).to.be.calledOnce; - expect(response).to.be.eql([ - { - taxa_data: [{ associated_taxa: 'string;', vernacular_name: 'string;', submission_spatial_component_id: 1 }], - spatial_data: { features: [({ id: 1 } as unknown) as Feature] } - } - ]); - }); - }); - - describe('updateSurveyOccurrenceSubmissionWithOutputKey', () => { - it('should return a submission id', async () => { - const service = mockService(); - sinon.stub(OccurrenceRepository.prototype, 'updateSurveyOccurrenceSubmissionWithOutputKey').resolves({}); - - const result = await service.updateSurveyOccurrenceSubmissionWithOutputKey(1, 'file name', 'key'); - expect(result).to.be.eql({}); - }); - }); - - describe('updateDWCSourceForOccurrenceSubmission', () => { - it('should return a submission id', async () => { - const service = mockService(); - sinon.stub(OccurrenceRepository.prototype, 'updateDWCSourceForOccurrenceSubmission').resolves(1); - - const id = await service.updateDWCSourceForOccurrenceSubmission(1, '{}'); - expect(id).to.be.eql(1); - }); - }); - - describe('findSpatialMetadataBySubmissionSpatialComponentIds', () => { - it('should return spatial components', async () => { - const service = mockService(); - sinon - .stub(OccurrenceRepository.prototype, 'findSpatialMetadataBySubmissionSpatialComponentIds') - .resolves([({ spatial_component_properties: { id: 1 } } as unknown) as ISpatialComponentFeaturePropertiesRow]); - - const id = await service.findSpatialMetadataBySubmissionSpatialComponentIds([1]); - expect(id).to.be.eql([{ id: 1 }]); - }); - }); - - describe('deleteOccurrenceSubmission', () => { - it('should delete all occurrence data by id', async () => { - const service = mockService(); - - const softDeleteOccurrenceSubmissionStub = sinon - .stub(OccurrenceRepository.prototype, 'softDeleteOccurrenceSubmission') - .resolves(); - const deleteSpatialTransformSubmissionStub = sinon - .stub(OccurrenceRepository.prototype, 'deleteSpatialTransformSubmission') - .resolves(); - const deleteSubmissionSpatialComponentStub = sinon - .stub(OccurrenceRepository.prototype, 'deleteSubmissionSpatialComponent') - .resolves([{ submission_spatial_component_id: 1 }]); - - const id = await service.deleteOccurrenceSubmission(1); - - expect(softDeleteOccurrenceSubmissionStub).to.be.calledOnce; - expect(deleteSpatialTransformSubmissionStub).to.be.calledOnce; - expect(deleteSubmissionSpatialComponentStub).to.be.calledOnce; - expect(id).to.be.eql([{ submission_spatial_component_id: 1 }]); - }); - }); -}); diff --git a/api/src/services/occurrence-service.ts b/api/src/services/occurrence-service.ts deleted file mode 100644 index 768f3786a9..0000000000 --- a/api/src/services/occurrence-service.ts +++ /dev/null @@ -1,116 +0,0 @@ -import { GeoJsonProperties } from 'geojson'; -import { IDBConnection } from '../database/db'; -import { IOccurrenceSubmission, OccurrenceRepository } from '../repositories/occurrence-repository'; -import { DBService } from './db-service'; - -export class OccurrenceService extends DBService { - occurrenceRepository: OccurrenceRepository; - - constructor(connection: IDBConnection) { - super(connection); - this.occurrenceRepository = new OccurrenceRepository(connection); - } - - /** - * Gets a `occurrence_submission` for an id. - * - * @param {number} submissionId - * @return {*} {Promise} - */ - async getOccurrenceSubmission(submissionId: number): Promise { - return this.occurrenceRepository.getOccurrenceSubmission(submissionId); - } - - /** - * Gets list `occurrence` and maps them for use on a map - * - * @param {number} submissionId - * @return {*} {Promise} - */ - async getOccurrences(submissionId: number): Promise { - const response = await this.occurrenceRepository.getOccurrencesForView(submissionId); - - const occurrenceData = response.map((row) => { - const { spatial_component, taxa_data } = row; - const { spatial_data, ...rest } = spatial_component; - return { - taxa_data, - ...rest, - spatial_data: { - ...spatial_data, - features: spatial_data.features.map((feature) => { - delete feature?.properties?.dwc; - return feature; - }) - } - }; - }); - - return occurrenceData; - } - - /** - * Updates `occurrence_submission` output key field. - * - * @param {number} submissionId - * @param {string} fileName - * @param {string} key - * @return {*} {Promise} - */ - async updateSurveyOccurrenceSubmissionWithOutputKey( - submissionId: number, - fileName: string, - key: string - ): Promise { - return this.occurrenceRepository.updateSurveyOccurrenceSubmissionWithOutputKey(submissionId, fileName, key); - } - - /** - * Updates `darwin_core_source` with passed a stringified json object. - * - * @param {number} submissionId - * @param {string} jsonData - * @return {*} {Promise} - */ - async updateDWCSourceForOccurrenceSubmission(submissionId: number, jsonData: string): Promise { - return this.occurrenceRepository.updateDWCSourceForOccurrenceSubmission(submissionId, jsonData); - } - - /** - * Query builder to find spatial component by given criteria - * - * @param {ISpatialComponentsSearchCriteria} criteria - * @return {*} {Promise} - * @memberof SpatialService - */ - async findSpatialMetadataBySubmissionSpatialComponentIds( - submissionSpatialComponentIds: number[] - ): Promise { - const response = await this.occurrenceRepository.findSpatialMetadataBySubmissionSpatialComponentIds( - submissionSpatialComponentIds - ); - - return response.map((row) => row.spatial_component_properties); - } - - /** - * Soft delete Occurrence Submission - * - * @param {number} occurrenceSubmissionId - * @return {*} - * @memberof OccurrenceService - */ - async deleteOccurrenceSubmission( - occurrenceSubmissionId: number - ): Promise< - { - submission_spatial_component_id: number; - }[] - > { - await this.occurrenceRepository.softDeleteOccurrenceSubmission(occurrenceSubmissionId); - - await this.occurrenceRepository.deleteSpatialTransformSubmission(occurrenceSubmissionId); - - return this.occurrenceRepository.deleteSubmissionSpatialComponent(occurrenceSubmissionId); - } -} diff --git a/api/src/services/project-service.ts b/api/src/services/project-service.ts index 2a18c1fe1d..9c1cc71aa5 100644 --- a/api/src/services/project-service.ts +++ b/api/src/services/project-service.ts @@ -1,6 +1,5 @@ import { default as dayjs } from 'dayjs'; import { Feature } from 'geojson'; -import { COMPLETION_STATUS } from '../constants/status'; import { IDBConnection } from '../database/db'; import { HTTP400 } from '../errors/http-error'; import { IPostIUCN, PostProjectObject } from '../models/project-create'; @@ -29,6 +28,17 @@ import { ProjectParticipationService } from './project-participation-service'; import { RegionService } from './region-service'; import { SurveyService } from './survey-service'; +/** + * Project Completion Status + * + * @export + * @enum {string} + */ +export enum COMPLETION_STATUS { + COMPLETED = 'Completed', + ACTIVE = 'Active' +} + export class ProjectService extends DBService { attachmentService: AttachmentService; projectRepository: ProjectRepository; diff --git a/api/src/services/spatial-service.test.ts b/api/src/services/spatial-service.test.ts deleted file mode 100644 index 1d4ca92df3..0000000000 --- a/api/src/services/spatial-service.test.ts +++ /dev/null @@ -1,153 +0,0 @@ -import chai, { expect } from 'chai'; -import { FeatureCollection } from 'geojson'; -import { describe } from 'mocha'; -import sinon from 'sinon'; -import sinonChai from 'sinon-chai'; -import { IGetSpatialTransformRecord, SpatialRepository } from '../repositories/spatial-repository'; -import { getMockDBConnection } from '../__mocks__/db'; -import { SpatialService } from './spatial-service'; - -chai.use(sinonChai); - -describe('SpatialService', () => { - afterEach(() => { - sinon.restore(); - }); - - describe('getSpatialTransformRecords', () => { - it('should return IGetSpatialTransformRecord on get', async () => { - const mockDBConnection = getMockDBConnection(); - const spatialService = new SpatialService(mockDBConnection); - - const repo = sinon - .stub(SpatialRepository.prototype, 'getSpatialTransformRecords') - .resolves(([{ name: 'name' }] as unknown) as IGetSpatialTransformRecord[]); - - const response = await spatialService.getSpatialTransformRecords(); - - expect(repo).to.be.calledOnce; - expect(response).to.be.eql([{ name: 'name' }]); - }); - }); - - describe('insertSpatialTransformSubmissionRecord', () => { - it('should return spatial_transform_submission_id after insert', async () => { - const mockDBConnection = getMockDBConnection(); - const spatialService = new SpatialService(mockDBConnection); - - const repo = sinon - .stub(SpatialRepository.prototype, 'insertSpatialTransformSubmissionRecord') - .resolves({ spatial_transform_submission_id: 1 }); - - const response = await spatialService.insertSpatialTransformSubmissionRecord(1, 1); - - expect(repo).to.be.calledOnce; - expect(response).to.be.eql({ spatial_transform_submission_id: 1 }); - }); - }); - - describe('runSpatialTransforms', () => { - it('should return submission_spatial_component_id after running transform and inserting data', async () => { - const mockDBConnection = getMockDBConnection(); - const spatialService = new SpatialService(mockDBConnection); - - const getSpatialTransformRecordsStub = sinon - .stub(SpatialService.prototype, 'getSpatialTransformRecords') - .resolves([ - { - spatial_transform_id: 1, - name: 'name1', - description: null, - notes: null, - transform: 'transform1' - }, - { - spatial_transform_id: 2, - name: 'name2', - description: null, - notes: null, - transform: 'transform2' - } - ]); - - const runSpatialTransformOnSubmissionIdStub = sinon - .stub(SpatialRepository.prototype, 'runSpatialTransformOnSubmissionId') - .onCall(0) - .resolves([ - { result_data: ('result1' as unknown) as FeatureCollection }, - { result_data: ('result2' as unknown) as FeatureCollection } - ]) - .onCall(1) - .resolves([ - { result_data: ('result3' as unknown) as FeatureCollection }, - { result_data: ('result4' as unknown) as FeatureCollection } - ]); - - const insertSubmissionSpatialComponentStub = sinon - .stub(SpatialRepository.prototype, 'insertSubmissionSpatialComponent') - .onCall(0) - .resolves({ submission_spatial_component_id: 3 }) - .onCall(1) - .resolves({ submission_spatial_component_id: 4 }) - .onCall(2) - .resolves({ submission_spatial_component_id: 5 }) - .onCall(3) - .resolves({ submission_spatial_component_id: 6 }); - - const insertSpatialTransformSubmissionRecordStub = sinon - .stub(SpatialRepository.prototype, 'insertSpatialTransformSubmissionRecord') - .resolves(); - - await spatialService.runSpatialTransforms(9); - - expect(getSpatialTransformRecordsStub).to.be.calledOnceWith(); - expect(runSpatialTransformOnSubmissionIdStub).to.be.calledWith(9, 'transform1').calledWith(9, 'transform2'); - expect(insertSubmissionSpatialComponentStub) - .to.be.calledWith(9, 'result1') - .calledWith(9, 'result2') - .calledWith(9, 'result3') - .calledWith(9, 'result4'); - expect(insertSpatialTransformSubmissionRecordStub) - .to.be.calledWith(1, 3) - .calledWith(1, 4) - .calledWith(2, 5) - .calledWith(2, 6); - }); - }); - - describe('deleteSpatialComponentsBySubmissionId', () => { - it('should return submission IDs upon deleting spatial data', async () => { - const mockDBConnection = getMockDBConnection(); - const spatialService = new SpatialService(mockDBConnection); - - const mockResponseRows = ([{ occurrence_submission_id: 3 }] as unknown) as { occurrence_submission_id: number }[]; - - const repo = sinon - .stub(SpatialRepository.prototype, 'deleteSpatialComponentsBySubmissionId') - .resolves(mockResponseRows); - - const response = await spatialService.deleteSpatialComponentsBySubmissionId(3); - - expect(repo).to.be.calledOnce; - expect(response).to.be.eql(mockResponseRows); - }); - }); - - describe('deleteSpatialComponentsSpatialTransformRefsBySubmissionId', () => { - it('should return submission IDs upon deleting spatial data', async () => { - const mockDBConnection = getMockDBConnection(); - const spatialService = new SpatialService(mockDBConnection); - - const mockResponseRows = ([{ occurrence_submission_id: 3 }] as unknown) as { occurrence_submission_id: number }[]; - - const repo = sinon - .stub(SpatialRepository.prototype, 'deleteSpatialComponentsSpatialTransformRefsBySubmissionId') - .resolves(mockResponseRows); - - const response = await spatialService.deleteSpatialComponentsSpatialTransformRefsBySubmissionId(3); - - expect(repo).to.be.calledOnce; - expect(response).to.be.eql(mockResponseRows); - }); - }); -}); diff --git a/api/src/services/spatial-service.ts b/api/src/services/spatial-service.ts deleted file mode 100644 index 234bc081bf..0000000000 --- a/api/src/services/spatial-service.ts +++ /dev/null @@ -1,101 +0,0 @@ -import { IDBConnection } from '../database/db'; -import { IGetSpatialTransformRecord, SpatialRepository } from '../repositories/spatial-repository'; -import { DBService } from './db-service'; - -export class SpatialService extends DBService { - spatialRepository: SpatialRepository; - - constructor(connection: IDBConnection) { - super(connection); - - this.spatialRepository = new SpatialRepository(connection); - } - - /** - * get spatial transform records - * - * @return {*} {Promise} - * @memberof SpatialService - */ - async getSpatialTransformRecords(): Promise { - return this.spatialRepository.getSpatialTransformRecords(); - } - - /** - * Insert record of transform id used for submission spatial component record - * - * @param {number} spatialTransformId - * @param {number} submissionSpatialComponentId - * @return {*} {Promise<{ spatial_transform_submission_id: number }>} - * @memberof SpatialService - */ - async insertSpatialTransformSubmissionRecord( - spatialTransformId: number, - submissionSpatialComponentId: number - ): Promise<{ spatial_transform_submission_id: number }> { - return this.spatialRepository.insertSpatialTransformSubmissionRecord( - spatialTransformId, - submissionSpatialComponentId - ); - } - - /** - * Collect transforms from db, run transformations on submission id, save result to spatial component table - * - * @param {number} submissionId - * @return {*} {Promise} - * @memberof SpatialService - */ - async runSpatialTransforms(submissionId: number): Promise { - const spatialTransformRecords = await this.getSpatialTransformRecords(); - - const transformRecordPromises = spatialTransformRecords.map(async (transformRecord) => { - const transformed = await this.spatialRepository.runSpatialTransformOnSubmissionId( - submissionId, - transformRecord.transform - ); - - const insertSpatialTransformSubmissionRecordPromises = transformed.map(async (dataPoint) => { - const submissionSpatialComponentId = await this.spatialRepository.insertSubmissionSpatialComponent( - submissionId, - dataPoint.result_data - ); - - await this.insertSpatialTransformSubmissionRecord( - transformRecord.spatial_transform_id, - submissionSpatialComponentId.submission_spatial_component_id - ); - }); - - await Promise.all(insertSpatialTransformSubmissionRecordPromises); - }); - - await Promise.all(transformRecordPromises); - } - - /** - * Delete spatial component records by submission id. - * - * @param {number} occurrence_submission_id - * @return {*} {Promise<{ occurrence_submission_id: number }[]>} - * @memberof SpatialService - */ - async deleteSpatialComponentsBySubmissionId( - occurrence_submission_id: number - ): Promise<{ occurrence_submission_id: number }[]> { - return this.spatialRepository.deleteSpatialComponentsBySubmissionId(occurrence_submission_id); - } - - /** - * Delete records referencing which spatial transforms were applied to a spatial component - * - * @param {number} occurrence_submission_id - * @return {*} {Promise<{ occurrence_submission_id: number }[]>} - * @memberof SpatialService - */ - async deleteSpatialComponentsSpatialTransformRefsBySubmissionId( - occurrence_submission_id: number - ): Promise<{ occurrence_submission_id: number }[]> { - return this.spatialRepository.deleteSpatialComponentsSpatialTransformRefsBySubmissionId(occurrence_submission_id); - } -} diff --git a/api/src/services/survey-service.test.ts b/api/src/services/survey-service.test.ts index d0fa3483b5..999437ac12 100644 --- a/api/src/services/survey-service.test.ts +++ b/api/src/services/survey-service.test.ts @@ -4,8 +4,7 @@ import { describe } from 'mocha'; import { QueryResult } from 'pg'; import sinon from 'sinon'; import sinonChai from 'sinon-chai'; -import { MESSAGE_CLASS_NAME, SUBMISSION_MESSAGE_TYPE, SUBMISSION_STATUS_TYPE } from '../constants/status'; -import { ApiExecuteSQLError, ApiGeneralError } from '../errors/api-error'; +import { ApiGeneralError } from '../errors/api-error'; import { GetReportAttachmentsData } from '../models/project-view'; import { PostProprietorData, PostSurveyObject } from '../models/survey-create'; import { PostSurveyLocationData, PutSurveyObject, PutSurveyPermitData } from '../models/survey-update'; @@ -22,7 +21,6 @@ import { FundingSourceRepository } from '../repositories/funding-source-reposito import { IPermitModel } from '../repositories/permit-repository'; import { SurveyLocationRecord, SurveyLocationRepository } from '../repositories/survey-location-repository'; import { - IGetLatestSurveyOccurrenceSubmission, IGetSpeciesData, ISurveyProprietorModel, SurveyRecord, @@ -252,22 +250,6 @@ describe('SurveyService', () => { }); }); - describe('getLatestSurveyOccurrenceSubmission', () => { - it('returns the first row on success', async () => { - const dbConnection = getMockDBConnection(); - const service = new SurveyService(dbConnection); - - const data = ({ id: 1 } as unknown) as IGetLatestSurveyOccurrenceSubmission; - - const repoStub = sinon.stub(SurveyRepository.prototype, 'getLatestSurveyOccurrenceSubmission').resolves(data); - - const response = await service.getLatestSurveyOccurrenceSubmission(1); - - expect(repoStub).to.be.calledOnce; - expect(response).to.eql(data); - }); - }); - describe('getSurveyProprietorDataForSecurityRequest', () => { it('returns the first row on success', async () => { const dbConnection = getMockDBConnection(); @@ -487,38 +469,6 @@ describe('SurveyService', () => { }); }); - describe('getOccurrenceSubmission', () => { - it('returns the first row on success', async () => { - const dbConnection = getMockDBConnection(); - const service = new SurveyService(dbConnection); - - const data = { occurrence_submission_id: 1 }; - - const repoStub = sinon.stub(SurveyRepository.prototype, 'getOccurrenceSubmission').resolves(data); - - const response = await service.getOccurrenceSubmission(1); - - expect(repoStub).to.be.calledOnce; - expect(response).to.eql(data); - }); - }); - - describe('getLatestSurveyOccurrenceSubmission', () => { - it('returns the first row on success', async () => { - const dbConnection = getMockDBConnection(); - const service = new SurveyService(dbConnection); - - const data = ({ id: 1 } as unknown) as IGetLatestSurveyOccurrenceSubmission; - - const repoStub = sinon.stub(SurveyRepository.prototype, 'getLatestSurveyOccurrenceSubmission').resolves(data); - - const response = await service.getLatestSurveyOccurrenceSubmission(1); - - expect(repoStub).to.be.calledOnce; - expect(response).to.eql(data); - }); - }); - describe('getSurveysByIds', () => { it('returns the first row on success', async () => { const dbConnection = getMockDBConnection(); @@ -1016,83 +966,6 @@ describe('SurveyService', () => { }); }); - describe('getOccurrenceSubmissionMessages', () => { - it('should return empty array if no messages are found', async () => { - const dbConnection = getMockDBConnection(); - const service = new SurveyService(dbConnection); - - const repoStub = sinon.stub(SurveyRepository.prototype, 'getOccurrenceSubmissionMessages').resolves([]); - - const response = await service.getOccurrenceSubmissionMessages(1); - - expect(repoStub).to.be.calledOnce; - expect(response).to.eql([]); - }); - - it('should successfully group messages by message type', async () => { - const dbConnection = getMockDBConnection(); - const service = new SurveyService(dbConnection); - - const repoStub = sinon.stub(SurveyRepository.prototype, 'getOccurrenceSubmissionMessages').resolves([ - { - id: 1, - type: SUBMISSION_MESSAGE_TYPE.DUPLICATE_HEADER, - status: SUBMISSION_STATUS_TYPE.FAILED_VALIDATION, - class: MESSAGE_CLASS_NAME.ERROR, - message: 'message 1' - }, - { - id: 2, - type: SUBMISSION_MESSAGE_TYPE.DUPLICATE_HEADER, - status: SUBMISSION_STATUS_TYPE.FAILED_VALIDATION, - class: MESSAGE_CLASS_NAME.ERROR, - message: 'message 2' - }, - { - id: 3, - type: SUBMISSION_MESSAGE_TYPE.MISSING_RECOMMENDED_HEADER, - status: SUBMISSION_STATUS_TYPE.SUBMITTED, - class: MESSAGE_CLASS_NAME.WARNING, - message: 'message 3' - }, - { - id: 4, - type: SUBMISSION_MESSAGE_TYPE.MISCELLANEOUS, - status: SUBMISSION_STATUS_TYPE.SUBMITTED, - class: MESSAGE_CLASS_NAME.NOTICE, - message: 'message 4' - } - ]); - - const response = await service.getOccurrenceSubmissionMessages(1); - - expect(repoStub).to.be.calledOnce; - expect(response).to.eql([ - { - severityLabel: 'Error', - messageTypeLabel: 'Duplicate Header', - messageStatus: 'Failed to validate', - messages: [ - { id: 1, message: 'message 1' }, - { id: 2, message: 'message 2' } - ] - }, - { - severityLabel: 'Warning', - messageTypeLabel: 'Missing Recommended Header', - messageStatus: 'Submitted', - messages: [{ id: 3, message: 'message 3' }] - }, - { - severityLabel: 'Notice', - messageTypeLabel: 'Miscellaneous', - messageStatus: 'Submitted', - messages: [{ id: 4, message: 'message 4' }] - } - ]); - }); - }); - describe('deleteSurvey', () => { it('should delete the survey and return nothing', async () => { const dbConnection = getMockDBConnection(); @@ -1109,106 +982,6 @@ describe('SurveyService', () => { }); }); - describe('insertSurveyOccurrenceSubmission', () => { - it('should return submissionId upon success', async () => { - const dbConnection = getMockDBConnection(); - const service = new SurveyService(dbConnection); - - const repoStub = sinon - .stub(SurveyRepository.prototype, 'insertSurveyOccurrenceSubmission') - .resolves({ submissionId: 1 }); - - const response = await service.insertSurveyOccurrenceSubmission({ - surveyId: 1, - source: 'Test' - }); - - expect(repoStub).to.be.calledOnce; - expect(response).to.eql({ submissionId: 1 }); - }); - - it('should throw an error', async () => { - const dbConnection = getMockDBConnection(); - const service = new SurveyService(dbConnection); - - sinon - .stub(SurveyRepository.prototype, 'insertSurveyOccurrenceSubmission') - .throws(new ApiExecuteSQLError('Failed to insert survey occurrence submission')); - - try { - await service.insertSurveyOccurrenceSubmission({ - surveyId: 1, - source: 'Test' - }); - expect.fail(); - } catch (actualError) { - expect((actualError as ApiGeneralError).message).to.equal('Failed to insert survey occurrence submission'); - } - }); - }); - - describe('updateSurveyOccurrenceSubmission', () => { - it('should return submissionId upon success', async () => { - const dbConnection = getMockDBConnection(); - const service = new SurveyService(dbConnection); - - const repoStub = sinon - .stub(SurveyRepository.prototype, 'updateSurveyOccurrenceSubmission') - .resolves({ submissionId: 1 }); - - const response = await service.updateSurveyOccurrenceSubmission({ submissionId: 1 }); - - expect(repoStub).to.be.calledOnce; - expect(response).to.eql({ submissionId: 1 }); - }); - - it('should throw an error', async () => { - const dbConnection = getMockDBConnection(); - const service = new SurveyService(dbConnection); - - sinon - .stub(SurveyRepository.prototype, 'updateSurveyOccurrenceSubmission') - .throws(new ApiExecuteSQLError('Failed to update survey occurrence submission')); - - try { - await service.updateSurveyOccurrenceSubmission({ submissionId: 1 }); - expect.fail(); - } catch (actualError) { - expect((actualError as ApiGeneralError).message).to.equal('Failed to update survey occurrence submission'); - } - }); - }); - - describe('deleteOccurrenceSubmission', () => { - it('should return 1 upon success', async () => { - const dbConnection = getMockDBConnection(); - const service = new SurveyService(dbConnection); - - const repoStub = sinon.stub(SurveyRepository.prototype, 'deleteOccurrenceSubmission').resolves(1); - - const response = await service.deleteOccurrenceSubmission(2); - - expect(repoStub).to.be.calledOnce; - expect(response).to.eql(1); - }); - - it('should throw an error upon failure', async () => { - const dbConnection = getMockDBConnection(); - const service = new SurveyService(dbConnection); - - sinon - .stub(SurveyRepository.prototype, 'deleteOccurrenceSubmission') - .throws(new ApiExecuteSQLError('Failed to delete survey occurrence submission')); - - try { - await service.deleteOccurrenceSubmission(2); - expect.fail(); - } catch (actualError) { - expect((actualError as ApiGeneralError).message).to.equal('Failed to delete survey occurrence submission'); - } - }); - }); - describe('insertUpdateDeleteSurveyLocation', () => { afterEach(() => { sinon.restore(); @@ -1391,26 +1164,3 @@ describe('SurveyService', () => { }); }); }); - -/* -TODO - -describe('getPartnershipsData', () => { - it('returns the first row on success', async () => { - const dbConnection = getMockDBConnection(); - const service = new ProjectService(dbConnection); - - const data = new GetPartnershipsData([{ id: 1 }], [{ id: 1 }]); - - const repoStub1 = sinon.stub(ProjectRepository.prototype, 'getIndigenousPartnershipsRows').resolves([{ id: 1 }]); - const repoStub2 = sinon.stub(ProjectRepository.prototype, 'getStakeholderPartnershipsRows').resolves([{ id: 1 }]); - - const response = await service.getPartnershipsData(1); - - expect(repoStub1).to.be.calledOnce; - expect(repoStub2).to.be.calledOnce; - expect(response).to.eql(data); - }); -}); - -*/ diff --git a/api/src/services/survey-service.ts b/api/src/services/survey-service.ts index 930828c32a..61f52ea647 100644 --- a/api/src/services/survey-service.ts +++ b/api/src/services/survey-service.ts @@ -1,5 +1,4 @@ import { Feature } from 'geojson'; -import { MESSAGE_CLASS_NAME, SUBMISSION_MESSAGE_TYPE, SUBMISSION_STATUS_TYPE } from '../constants/status'; import { IDBConnection } from '../database/db'; import { PostProprietorData, PostSurveyObject } from '../models/survey-create'; import { PostSurveyLocationData, PutPartnershipsData, PutSurveyObject } from '../models/survey-update'; @@ -20,16 +19,7 @@ import { import { AttachmentRepository } from '../repositories/attachment-repository'; import { PostSurveyBlock, SurveyBlockRecordWithCount } from '../repositories/survey-block-repository'; import { SurveyLocationRecord } from '../repositories/survey-location-repository'; -import { - IGetLatestSurveyOccurrenceSubmission, - IObservationSubmissionInsertDetails, - IObservationSubmissionUpdateDetails, - IOccurrenceSubmissionMessagesResponse, - ISurveyProprietorModel, - SurveyBasicFields, - SurveyRepository -} from '../repositories/survey-repository'; -import { getLogger } from '../utils/logger'; +import { ISurveyProprietorModel, SurveyBasicFields, SurveyRepository } from '../repositories/survey-repository'; import { ApiPaginationOptions } from '../zod-schema/pagination'; import { DBService } from './db-service'; import { FundingSourceService } from './funding-source-service'; @@ -42,15 +32,6 @@ import { SurveyBlockService } from './survey-block-service'; import { SurveyLocationService } from './survey-location-service'; import { SurveyParticipationService } from './survey-participation-service'; -const defaultLog = getLogger('services/survey-service'); - -export interface IMessageTypeGroup { - severityLabel: MESSAGE_CLASS_NAME; - messageTypeLabel: SUBMISSION_MESSAGE_TYPE; - messageStatus: SUBMISSION_STATUS_TYPE; - messages: { id: number; message: string }[]; -} - export class SurveyService extends DBService { attachmentRepository: AttachmentRepository; surveyRepository: SurveyRepository; @@ -245,28 +226,6 @@ export class SurveyService extends DBService { return service.getSurveyLocationsData(surveyId); } - /** - * Get Occurrence Submission for a given survey id. - * - * @param {number} surveyId - * @return {*} {(Promise<{ occurrence_submission_id: number | null }>)} - * @memberof SurveyService - */ - async getOccurrenceSubmission(surveyId: number): Promise<{ occurrence_submission_id: number | null }> { - return this.surveyRepository.getOccurrenceSubmission(surveyId); - } - - /** - * Get latest Occurrence Submission or null for a given survey ID - * - * @param {number} surveyID - * @returns {*} {Promise} - * @memberof SurveyService - */ - async getLatestSurveyOccurrenceSubmission(surveyId: number): Promise { - return this.surveyRepository.getLatestSurveyOccurrenceSubmission(surveyId); - } - /** * Gets the Proprietor Data to be be submitted * to BioHub as a Security Request @@ -279,40 +238,6 @@ export class SurveyService extends DBService { return this.surveyRepository.getSurveyProprietorDataForSecurityRequest(surveyId); } - /** - * Retrieves all submission messages by the given submission ID, then groups them based on the message type. - * @param {number} submissionId The ID of the submission - * @returns {*} {Promise} Promise resolving the array of message groups containing the submission messages - */ - async getOccurrenceSubmissionMessages(submissionId: number): Promise { - const messages = await this.surveyRepository.getOccurrenceSubmissionMessages(submissionId); - defaultLog.debug({ label: 'getOccurrenceSubmissionMessages', submissionId, messages }); - - return messages.reduce((typeGroups: IMessageTypeGroup[], message: IOccurrenceSubmissionMessagesResponse) => { - const groupIndex = typeGroups.findIndex((group) => { - return group.messageTypeLabel === message.type; - }); - - const messageObject = { - id: message.id, - message: message.message - }; - - if (groupIndex < 0) { - typeGroups.push({ - severityLabel: message.class, - messageTypeLabel: message.type, - messageStatus: message.status, - messages: [messageObject] - }); - } else { - typeGroups[groupIndex].messages.push(messageObject); - } - - return typeGroups; - }, []); - } - /** * Get surveys by their ids. * @@ -1218,38 +1143,4 @@ export class SurveyService extends DBService { async deleteSurvey(surveyId: number): Promise { return this.surveyRepository.deleteSurvey(surveyId); } - - /** - * Inserts a survey occurrence submission row. - * - * @param {IObservationSubmissionInsertDetails} submission The details of the submission - * @return {*} {Promise<{ submissionId: number }>} Promise resolving the ID of the submission upon successful insertion - */ - async insertSurveyOccurrenceSubmission( - submission: IObservationSubmissionInsertDetails - ): Promise<{ submissionId: number }> { - return this.surveyRepository.insertSurveyOccurrenceSubmission(submission); - } - - /** - * Updates a survey occurrence submission with the given details. - * - * @param {IObservationSubmissionUpdateDetails} submission The details of the submission to be updated - * @return {*} {Promise<{ submissionId: number }>} Promise resolving the ID of the submission upon successfully updating it - */ - async updateSurveyOccurrenceSubmission( - submission: IObservationSubmissionUpdateDetails - ): Promise<{ submissionId: number }> { - return this.surveyRepository.updateSurveyOccurrenceSubmission(submission); - } - - /** - * Soft-deletes an occurrence submission. - * - * @param {number} submissionId The ID of the submission to soft delete - * @returns {*} {number} The row count of the affected records, namely `1` if the delete succeeds, `0` if it does not - */ - async deleteOccurrenceSubmission(submissionId: number): Promise { - return this.surveyRepository.deleteOccurrenceSubmission(submissionId); - } } diff --git a/api/src/services/telemetry-service.ts b/api/src/services/telemetry-service.ts index ff98be7698..d50e5a903d 100644 --- a/api/src/services/telemetry-service.ts +++ b/api/src/services/telemetry-service.ts @@ -4,6 +4,7 @@ import { ApiGeneralError } from '../errors/api-error'; import { TelemetryRepository, TelemetrySubmissionRecord } from '../repositories/telemetry-repository'; import { generateS3FileKey, getFileFromS3 } from '../utils/file-utils'; import { parseS3File } from '../utils/media/media-utils'; +import { DEFAULT_XLSX_SHEET_NAME } from '../utils/media/xlsx/xlsx-file'; import { constructWorksheets, constructXLSXWorkbook, @@ -84,7 +85,7 @@ export class TelemetryService extends DBService { throw new ApiGeneralError('Failed to process file for importing telemetry. Invalid CSV file.'); } - const worksheetRowObjects = getWorksheetRowObjects(xlsxWorksheets['Sheet1']); + const worksheetRowObjects = getWorksheetRowObjects(xlsxWorksheets[DEFAULT_XLSX_SHEET_NAME]); // step 7 fetch survey deployments const bctwService = new BctwService(user); diff --git a/api/src/utils/file-utils.test.ts b/api/src/utils/file-utils.test.ts index 1c1d652e2c..71fa5b5efd 100644 --- a/api/src/utils/file-utils.test.ts +++ b/api/src/utils/file-utils.test.ts @@ -68,7 +68,7 @@ describe('generateS3FileKey', () => { expect(result).to.equal('some/s3/prefix/projects/1/surveys/2/folder/testFileName'); }); - it('returns survey occurrence folder file path', async () => { + it('returns survey submission folder file path when a submission ID is passed', async () => { process.env.S3_KEY_PREFIX = 'some/s3/prefix'; const result = generateS3FileKey({ @@ -80,19 +80,6 @@ describe('generateS3FileKey', () => { expect(result).to.equal('some/s3/prefix/projects/1/surveys/2/submissions/3/testFileName'); }); - - it('returns survey summaryresults folder file path', async () => { - process.env.S3_KEY_PREFIX = 'some/s3/prefix'; - - const result = generateS3FileKey({ - projectId: 1, - surveyId: 2, - summaryId: 3, - fileName: 'testFileName' - }); - - expect(result).to.equal('some/s3/prefix/projects/1/surveys/2/summaryresults/3/testFileName'); - }); }); describe('getS3HostUrl', () => { diff --git a/api/src/utils/file-utils.ts b/api/src/utils/file-utils.ts index bc0c8ac796..e51efe7552 100644 --- a/api/src/utils/file-utils.ts +++ b/api/src/utils/file-utils.ts @@ -8,8 +8,6 @@ import { Metadata } from 'aws-sdk/clients/s3'; import clamd from 'clamdjs'; -import { SUBMISSION_MESSAGE_TYPE } from '../constants/status'; -import { SubmissionErrorFromMessageType } from './submission-error'; /** * Local getter for retrieving the ClamAV client. @@ -145,10 +143,7 @@ export async function uploadBufferToS3( Key: key, Metadata: metadata }) - .promise() - .catch(() => { - throw SubmissionErrorFromMessageType(SUBMISSION_MESSAGE_TYPE.FAILED_UPLOAD_FILE_TO_S3); - }); + .promise(); } /** @@ -168,10 +163,7 @@ export async function getFileFromS3(key: string, versionId?: string): Promise { - throw SubmissionErrorFromMessageType(SUBMISSION_MESSAGE_TYPE.FAILED_GET_FILE_FROM_S3); - }); + .promise(); } /** @@ -225,7 +217,6 @@ export interface IS3FileKey { projectId: number; surveyId?: number; submissionId?: number; - summaryId?: number; folder?: string; fileName: string; } @@ -248,11 +239,6 @@ export function generateS3FileKey(options: IS3FileKey): string { keyParts.push(options.submissionId); } - if (options.summaryId) { - keyParts.push('summaryresults'); - keyParts.push(options.summaryId); - } - if (options.folder) { keyParts.push(options.folder); } diff --git a/api/src/utils/media/dwc/dwc-archive-file.ts b/api/src/utils/media/dwc/dwc-archive-file.ts deleted file mode 100644 index 5b32a5a574..0000000000 --- a/api/src/utils/media/dwc/dwc-archive-file.ts +++ /dev/null @@ -1,196 +0,0 @@ -import xlsx from 'xlsx'; -import { CSVWorkBook, CSVWorksheet, ICsvState } from '../csv/csv-file'; -import { ArchiveFile, IMediaState, MediaValidation } from '../media-file'; -import { ValidationSchemaParser } from '../validation/validation-schema-parser'; - -export enum DWC_CLASS { - RECORD = 'record', - EVENT = 'event', - LOCATION = 'location', - OCCURRENCE = 'occurrence', - MEASUREMENTORFACT = 'measurementorfact', - RESOURCERELATIONSHIP = 'resourcerelationship', - TAXON = 'taxon', - META = 'meta', - EML = 'eml' -} - -export const DEFAULT_XLSX_SHEET = 'Sheet1'; - -export type DWCWorksheets = Partial<{ [name in DWC_CLASS]: CSVWorksheet }>; - -/** - * Supports Darwin Core Archive CSV files. - * - * Expects an array of known named-files - * - * @export - * @class DWCArchive - */ -export class DWCArchive { - rawFile: ArchiveFile; - - mediaValidation: MediaValidation; - - worksheets: DWCWorksheets; - - extra: { [name: string]: any }; - - constructor(archiveFile: ArchiveFile) { - this.rawFile = archiveFile; - - this.mediaValidation = new MediaValidation(this.rawFile.fileName); - - this.worksheets = {}; - - // temporary storage for other non-csv files - this.extra = {}; - - // parse archive files - this._initArchiveFiles(); - } - - _initArchiveFiles() { - for (const rawFile of this.rawFile.mediaFiles) { - switch (rawFile.name) { - case DWC_CLASS.RECORD: - this.worksheets[DWC_CLASS.RECORD] = new CSVWorksheet( - rawFile.name, - xlsx.read(rawFile.buffer).Sheets[DEFAULT_XLSX_SHEET] - ); - break; - case DWC_CLASS.EVENT: - this.worksheets[DWC_CLASS.EVENT] = new CSVWorksheet( - rawFile.name, - xlsx.read(rawFile.buffer).Sheets[DEFAULT_XLSX_SHEET] - ); - break; - case DWC_CLASS.LOCATION: - this.worksheets[DWC_CLASS.LOCATION] = new CSVWorksheet( - rawFile.name, - xlsx.read(rawFile.buffer).Sheets[DEFAULT_XLSX_SHEET] - ); - break; - case DWC_CLASS.OCCURRENCE: - this.worksheets[DWC_CLASS.OCCURRENCE] = new CSVWorksheet( - rawFile.name, - xlsx.read(rawFile.buffer).Sheets[DEFAULT_XLSX_SHEET] - ); - break; - case DWC_CLASS.MEASUREMENTORFACT: - this.worksheets[DWC_CLASS.MEASUREMENTORFACT] = new CSVWorksheet( - rawFile.name, - xlsx.read(rawFile.buffer).Sheets[DEFAULT_XLSX_SHEET] - ); - break; - case DWC_CLASS.RESOURCERELATIONSHIP: - this.worksheets[DWC_CLASS.RESOURCERELATIONSHIP] = new CSVWorksheet( - rawFile.name, - xlsx.read(rawFile.buffer).Sheets[DEFAULT_XLSX_SHEET] - ); - break; - case DWC_CLASS.TAXON: - this.worksheets[DWC_CLASS.TAXON] = new CSVWorksheet( - rawFile.name, - xlsx.read(rawFile.buffer).Sheets[DEFAULT_XLSX_SHEET] - ); - break; - case DWC_CLASS.META: - this.extra[DWC_CLASS.META] = rawFile; - break; - case DWC_CLASS.EML: - this.extra[DWC_CLASS.EML] = rawFile; - break; - } - } - } - - /** - * Makes a CSV workbook from the worksheets included in the DwC archive file, enabling us - * to run workbook validation on them. - * - * @return {*} {xlsx.WorkBook} The workbook made from all worksheets. - * @memberof DWCArchive - */ - _workbookFromWorksheets(): xlsx.WorkBook { - const workbook = xlsx.utils.book_new(); - - Object.entries(this.worksheets).forEach(([key, worksheet]) => { - if (worksheet) { - xlsx.utils.book_append_sheet(workbook, worksheet, key); - } - }); - - return workbook; - } - - /** - * Runs all media-related validation for this DwC archive, based on given validation schema parser. - * @param validationSchemaParser The validation schema - * @returns {*} {void} - * @memberof DWCArchive - */ - validateMedia(validationSchemaParser: ValidationSchemaParser): void { - const validators = validationSchemaParser.getSubmissionValidations(); - - this.validate(validators as DWCArchiveValidator[]); - } - - /** - * Runs all content and workbook-related validation for this DwC archive, based on the given validation - * schema parser. - * @param {ValidationSchemaParser} validationSchemaParser The validation schema - * @returns {*} {void} - * @memberof DWCArchive - */ - validateContent(validationSchemaParser: ValidationSchemaParser): void { - // Run workbook validators - const workbookValidators = validationSchemaParser.getWorkbookValidations(); - const csvWorkbook = new CSVWorkBook(this._workbookFromWorksheets()); - csvWorkbook.validate(workbookValidators); - - // Run content validators - Object.entries(this.worksheets).forEach(([fileName, worksheet]) => { - const fileValidators = validationSchemaParser.getFileValidations(fileName); - const columnValidators = validationSchemaParser.getAllColumnValidations(fileName); - - if (worksheet) { - worksheet.validate([...fileValidators, ...columnValidators]); - } - }); - } - - /** - * Returns the current media state belonging to the DwC archive file. - * @returns {*} {IMediaState} The state of the DwC archive media. - */ - getMediaState(): IMediaState { - return this.mediaValidation.getState(); - } - - /** - * Returns the current CSV states belonging to all worksheets in the DwC archive file. - * @returns {*} {ICsvState[]} The state of each worksheet in the archive file. - */ - getContentState(): ICsvState[] { - return Object.values(this.worksheets) - .filter((worksheet: CSVWorksheet | undefined): worksheet is CSVWorksheet => Boolean(worksheet)) - .map((worksheet: CSVWorksheet) => worksheet.csvValidation.getState()); - } - - /** - * Executes each validator function in the provided `validators` against this instance, returning - * `this.mediaValidation` - * - * @param {DWCArchiveValidator[]} validators - * @return {*} {MediaValidation} - * @memberof DWCArchive - */ - validate(validators: DWCArchiveValidator[]): MediaValidation { - validators.forEach((validator) => validator(this)); - - return this.mediaValidation; - } -} - -export type DWCArchiveValidator = (dwcArchive: DWCArchive) => DWCArchive; diff --git a/api/src/utils/media/validation/file-type-and-content-validator.test.ts b/api/src/utils/media/validation/file-type-and-content-validator.test.ts index 786ece1234..892cc89b24 100644 --- a/api/src/utils/media/validation/file-type-and-content-validator.test.ts +++ b/api/src/utils/media/validation/file-type-and-content-validator.test.ts @@ -1,9 +1,8 @@ import { expect } from 'chai'; import { describe } from 'mocha'; import xlsx from 'xlsx'; -import { DEFAULT_XLSX_SHEET, DWCArchive, DWCArchiveValidator } from '../dwc/dwc-archive-file'; -import { ArchiveFile, IMediaFile, MediaFile, MediaValidation } from '../media-file'; -import { XLSXCSV, XLSXCSVValidator } from '../xlsx/xlsx-file'; +import { IMediaFile, MediaFile, MediaValidation } from '../media-file'; +import { DEFAULT_XLSX_SHEET_NAME, XLSXCSV, XLSXCSVValidator } from '../xlsx/xlsx-file'; import { getFileEmptyValidator, getFileMimeTypeValidator, @@ -68,17 +67,15 @@ describe('getFileMimeTypeValidator', () => { } }; - const validator = getFileMimeTypeValidator(validMimetypes) as DWCArchiveValidator; + const validator = getFileMimeTypeValidator(validMimetypes); - const mediaFile = new MediaFile('otherName', 'otherMime', Buffer.from('')); + const mediaFile = new MediaFile('otherName', 'validMime', Buffer.from('')); - const archiveFile = new ArchiveFile('testName', 'validMime', Buffer.from(''), [mediaFile]); - - const dwcArchive = new DWCArchive(archiveFile); + const xlsxCSV = new XLSXCSV(mediaFile); - validator(dwcArchive); + validator(xlsxCSV); - expect(dwcArchive.mediaValidation.fileErrors).to.eql([]); + expect(xlsxCSV.mediaValidation.fileErrors).to.eql([]); }); it('adds no errors when no valid mimes provided', () => { @@ -98,22 +95,6 @@ describe('getFileMimeTypeValidator', () => { expect(xlsxCSV.mediaValidation.fileErrors).to.eql([]); }); - - it('adds no errors when config it not found', () => { - const validMimetypes = undefined; - - const validator = getFileMimeTypeValidator(validMimetypes) as DWCArchiveValidator; - - const mediaFile = new MediaFile('otherName', 'otherMime', Buffer.from('')); - - const archiveFile = new ArchiveFile('testName', 'validMime', Buffer.from(''), [mediaFile]); - - const dwcArchive = new DWCArchive(archiveFile); - - validator(dwcArchive); - - expect(dwcArchive.mediaValidation.fileErrors).to.eql([]); - }); }); describe('getRequiredFilesValidator', () => { @@ -149,26 +130,6 @@ describe('getRequiredFilesValidator', () => { expect(xlsxCSV.mediaValidation.fileErrors).to.eql([]); }); - it('checks that a submission is a valid DWCArchive, and adds an error if a required file is missing', () => { - const submissionRequiredFilesValidatorConfig = { - submission_required_files_validator: { - required_files: ['event', 'occurrence'] - } - }; - - const validator = getRequiredFilesValidator(submissionRequiredFilesValidatorConfig) as DWCArchiveValidator; - - const archiveFile = new ArchiveFile('testName', 'validMime', Buffer.from(''), [ - new MediaFile('occurrence', 'b', Buffer.from('')) - ]); - - const xlsxCSV = new DWCArchive(archiveFile); - - validator(xlsxCSV); - - expect(xlsxCSV.mediaValidation.fileErrors).to.eql(['Missing required file: event']); - }); - it('checks that a submission is a valid XLSXCSV, and adds an error if a required file is missing', () => { const submissionRequiredFilesValidatorConfig = { submission_required_files_validator: { @@ -182,7 +143,7 @@ describe('getRequiredFilesValidator', () => { const worksheet = xlsx.utils.aoa_to_sheet([[]]); - xlsx.utils.book_append_sheet(newWorkbook, worksheet, DEFAULT_XLSX_SHEET); + xlsx.utils.book_append_sheet(newWorkbook, worksheet, DEFAULT_XLSX_SHEET_NAME); const mediaFile = new MediaFile('worksheet', 'validMime', Buffer.from('')); @@ -193,50 +154,3 @@ describe('getRequiredFilesValidator', () => { expect(xlsxCSV.mediaValidation.fileErrors).to.eql(['Missing required sheet: sheet2']); }); }); - -describe('checkRequiredFieldsInDWCArchive', () => { - it('checks that a submission is a valid DWCArchive with an empty mediaFile, and adds an error if a required file is missing', () => { - const submissionRequiredFilesValidatorConfig = { - submission_required_files_validator: { - required_files: ['event'] - } - }; - - const validator = getRequiredFilesValidator(submissionRequiredFilesValidatorConfig) as DWCArchiveValidator; - - //empty media file - const archiveFile = new ArchiveFile('someFile', 'validMime', Buffer.from(''), []); - - const xlsxCSV = new DWCArchive(archiveFile); - - validator(xlsxCSV); - - expect(xlsxCSV.mediaValidation.fileErrors).to.eql(['Missing required file: event']); - }); - - it('checks that a submission is a valid XLSXCSV with an empty workbook, and adds an error if a required file is missing', () => { - const submissionRequiredFilesValidatorConfig = { - submission_required_files_validator: { - required_files: ['sheet2'] - } - }; - - const validator = getRequiredFilesValidator(submissionRequiredFilesValidatorConfig) as XLSXCSVValidator; - - const newWorkbook = xlsx.utils.book_new(); - - const worksheet = xlsx.utils.aoa_to_sheet([[]]); - - xlsx.utils.book_append_sheet(newWorkbook, worksheet, DEFAULT_XLSX_SHEET); - - const mediaFile = new MediaFile('worksheet', 'validMime', Buffer.from('')); - - const xlsxCSV = new XLSXCSV(mediaFile); - - //force worksheets to be empty - xlsxCSV.workbook.worksheets = {}; - - validator(xlsxCSV); - expect(xlsxCSV.mediaValidation.fileErrors).to.eql(['Missing required sheet: sheet2']); - }); -}); diff --git a/api/src/utils/media/validation/file-type-and-content-validator.ts b/api/src/utils/media/validation/file-type-and-content-validator.ts index 1b48f6c3ef..6023a13e67 100644 --- a/api/src/utils/media/validation/file-type-and-content-validator.ts +++ b/api/src/utils/media/validation/file-type-and-content-validator.ts @@ -1,5 +1,4 @@ import { safeToLowerCase } from '../../string-utils'; -import { DWCArchive, DWCArchiveValidator } from '../dwc/dwc-archive-file'; import { MediaValidator } from '../media-file'; import { XLSXCSV, XLSXCSVValidator } from '../xlsx/xlsx-file'; @@ -30,9 +29,9 @@ export type MimetypeValidatorConfig = { * Return a validator function that checks the mimetype of the file. * * @param {MimetypeValidatorConfig} [config] - * @return {*} {(DWCArchiveValidator | XLSXCSVValidator)} + * @return {*} {(XLSXCSVValidator)} */ -export const getFileMimeTypeValidator = (config?: MimetypeValidatorConfig): DWCArchiveValidator | XLSXCSVValidator => { +export const getFileMimeTypeValidator = (config?: MimetypeValidatorConfig): XLSXCSVValidator => { return (file: any) => { if (!config) { return file; @@ -70,11 +69,9 @@ export type SubmissionRequiredFilesValidatorConfig = { * Return a validator function that checks that the file contains all required files. * * @param {SubmissionRequiredFilesValidatorConfig} [config] - * @return {*} {(DWCArchiveValidator | XLSXCSVValidator)} + * @return {*} {(XLSXCSVValidator)} */ -export const getRequiredFilesValidator = ( - config?: SubmissionRequiredFilesValidatorConfig -): DWCArchiveValidator | XLSXCSVValidator => { +export const getRequiredFilesValidator = (config?: SubmissionRequiredFilesValidatorConfig): XLSXCSVValidator => { return (file: any) => { if (!config) { // No required files specified @@ -86,9 +83,7 @@ export const getRequiredFilesValidator = ( return file; } - if (file instanceof DWCArchive) { - checkRequiredFieldsInDWCArchive(file, config); - } else if (file instanceof XLSXCSV) { + if (file instanceof XLSXCSV) { checkRequiredFieldsInXLSXCSV(file, config); } @@ -96,34 +91,6 @@ export const getRequiredFilesValidator = ( }; }; -/** - * Check that the DWCArchive contains all required files. - * - * @param {DWCArchive} dwcArchive - * @param {SubmissionRequiredFilesValidatorConfig} config - * @return {*} - */ -const checkRequiredFieldsInDWCArchive = (dwcArchive: DWCArchive, config: SubmissionRequiredFilesValidatorConfig) => { - // If there are no files in the archive, then add errors for all required files - if (!dwcArchive.rawFile.mediaFiles || !dwcArchive.rawFile.mediaFiles.length) { - dwcArchive.mediaValidation.addFileErrors( - config.submission_required_files_validator.required_files.map((requiredFile) => { - return `Missing required file: ${requiredFile}`; - }) - ); - - return dwcArchive; - } - - const fileNames = dwcArchive.rawFile.mediaFiles.map((mediaFile) => mediaFile.name); - - config.submission_required_files_validator.required_files.forEach((requiredFile) => { - if (!fileNames.includes(safeToLowerCase(requiredFile))) { - dwcArchive.mediaValidation.addFileErrors([`Missing required file: ${requiredFile}`]); - } - }); -}; - /** * Check that the XLSX workbook contains all required sheets. * diff --git a/api/src/utils/media/validation/validation-schema-parser.ts b/api/src/utils/media/validation/validation-schema-parser.ts index b2811545c7..6c130c55d0 100644 --- a/api/src/utils/media/validation/validation-schema-parser.ts +++ b/api/src/utils/media/validation/validation-schema-parser.ts @@ -14,7 +14,6 @@ import { getValidFormatFieldsValidator, getValidRangeFieldsValidator } from '../csv/validation/csv-row-validator'; -import { DWCArchiveValidator } from '../dwc/dwc-archive-file'; import { getParentChildKeyMatchValidator } from '../xlsx/validation/xlsx-validation'; import { XLSXCSVValidator } from '../xlsx/xlsx-file'; import { @@ -98,10 +97,10 @@ export class ValidationSchemaParser { } } - getSubmissionValidations(): (DWCArchiveValidator | XLSXCSVValidator)[] { + getSubmissionValidations(): XLSXCSVValidator[] { const validationSchemas = this.getSubmissionValidationSchemas(); - const rules: (DWCArchiveValidator | XLSXCSVValidator)[] = []; + const rules: XLSXCSVValidator[] = []; validationSchemas.forEach((validationSchema) => { const keys = Object.keys(validationSchema); diff --git a/api/src/utils/media/xlsx/transformation/xlsx-transform-schema-parser.ts b/api/src/utils/media/xlsx/transformation/xlsx-transform-schema-parser.ts deleted file mode 100644 index ed38ecc7e6..0000000000 --- a/api/src/utils/media/xlsx/transformation/xlsx-transform-schema-parser.ts +++ /dev/null @@ -1,385 +0,0 @@ -import { JSONPath } from 'jsonpath-plus'; - -export type TemplateSheetName = string; -export type TemplateColumnName = string; - -export type DWCSheetName = string; -export type DWCColumnName = string; - -export type JSONPathString = string; -export type ConditionSchema = { - type: 'and' | 'or'; - /** - * One or more checks. - * - * @type {IfNotEmptyCheck[]} - */ - checks: IfNotEmptyCheck[]; - /** - * Logically `not` the result of the condition. - * - * @type {boolean} - */ - not?: boolean; -}; - -export type IfNotEmptyCheck = { - /** - * `true` if the `JSONPathString` value, when processed, results in a non-empty non-null value. - * - * @type {JSONPathString} - */ - ifNotEmpty: JSONPathString; - /** - * Logically `not` the result of this check. - * - * @type {boolean} - */ - not?: boolean; -}; - -export type TemplateMetaForeignKeySchema = { - sheetName: string; - primaryKey: string[]; -}; - -/** - * - `root`: indicates the node is the top level root node (only 1 node can be type `root`). - * - `leaf`: indicates the node is a leaf node. A node that is type `leaf` will prevent the hierarchical - * parsing from progressing further, into this nodes children (if any). - * - ``: indicates a regular node, with no particular special considerations. - */ -export type TemplateMetaSchemaType = 'root' | 'leaf' | ''; - -export type TemplateMetaSchema = { - /** - * The name of the template sheet. - * - * @type {string} - */ - sheetName: TemplateSheetName; - /** - * An array of json path query strings. - * - * @type {string[]} - */ - primaryKey: string[]; - /** - * An array of json path query strings. - * - * @type {string[]} - */ - parentKey: string[]; - /** - * The type of the node. - * - * @type {TemplateMetaSchemaType} - */ - type: TemplateMetaSchemaType; - /** - * An array of linked child nodes. - * - * @type {TemplateMetaForeignKeySchema[]} - */ - foreignKeys: TemplateMetaForeignKeySchema[]; -}; - -export type MapColumnValuePostfixSchema = { - /** - * An array of json path query strings. - * - * If multiple query strings are provided, they will be fetched in order, and the first one that returns a non-empty - * value will be used. - * - * A single JSON path string may return one value, or an array of values. - * - * @see {@link RowObject} for special known fields that may be used in these `JSONPathString` values. - * @type {JSONPathString[]} - */ - paths?: JSONPathString[]; - /** - * A static value to append to the end of the final `paths` value. - * - * Note: - * - `unique` - If `static` is set to the string `unique`, at transformation time this will be replaced with a unique - * number. This number will be distinct from all other `unique` values, but not necessarily unique from other values - * in the transformed data (it is not a guid). - * - * If `static` is set in addition to `paths`, the `paths` will be ignored. - * - * @type {(string | 'unique')} - */ - static?: string | 'unique'; -}; - -export type MapColumnValueSchema = { - /** - * An array of json path strings. - * - * If multiple JSON path strings are provided, they will be fetched in order, and the first one that returns a - * non-empty value will be used. - * - * A single JSON path string may return one value, or an array of values. - * - * If an array of values is returned, they will be joined using the specified `join` string. - * If `join` string is not specified, a colon `:` will be used as the default `join` string. - * - * @see {@link RowObject} for special known fields that may be used in these `JSONPathString` values. - * @type {JSONPathString[]} - */ - paths?: JSONPathString[]; - /** - * A static value to be used, in place of any `paths`. - * - * If `static` is set in addition to `paths`, the `paths` will be ignored. - * - * @type {string} - */ - static?: string; - /** - * A string used to join multiple path values (if the `paths` query string returns multiple values that need joining). - * - * Defaults to a colon `:` if not provided. - * - * @type {string} - */ - join?: string; - /** - * A value to append to the end of the final `paths` value. - * - * Will be joined using the `join` value. - * - * @type {MapColumnValuePostfixSchema} - */ - postfix?: MapColumnValuePostfixSchema; - /** - * A condition, which contains one or more checks that must be met in order to proceed processing the schema element. - * - * @type {ConditionSchema} - */ - condition?: ConditionSchema; - /** - * An array of additional Schemas to add to process. Used to create additional records. - * - * @type {MapSchema[]} - */ - add?: MapSchema[]; -}; - -export type MapFieldSchema = { - /** - * The name of the DWC column (term). - * - * @type {DWCColumnName} - */ - columnName: DWCColumnName; - /** - * The schema that defines how the value of the column is produced. - * - * If multiple values are provided, the first one that passes all conditions (if any) and returns a non-empty path - * result will be used. and the remaining values will be skipped. - * - * @type {MapColumnValueSchema[]} - */ - columnValue: MapColumnValueSchema[]; -}; - -export type MapSchema = { - /** - * The name of the DWC sheet. - * - * @type {DWCSheetName} - */ - sheetName: DWCSheetName; - /** - * A condition, which contains one or more checks that must be met in order to proceed processing the schema element. - * - * @type {ConditionSchema} - */ - condition?: ConditionSchema; - /** - * An array of additional Schemas to add to process. Used to create additional records. - * - * @type {MapSchema[]} - */ - add?: MapSchema[]; - /** - * The schema tht defines all of the columns the be produced under this sheet. - * - * @type {MapFieldSchema[]} - */ - fields: MapFieldSchema[]; -}; - -export type DwcSchema = { - sheetName: string; - primaryKey: string[]; -}; - -export type TransformSchema = { - /** - * Defines the structure of the template, and any other relevant meta. - * - * The template, and the corresponding templateMeta definition, must correspond to a valid tree structure, with no loops. - * - * @type {TemplateMetaSchema[]} - */ - templateMeta: TemplateMetaSchema[]; - /** - * Defines the mapping from parsed raw template data to DarwinCore (DWC) templateMeta. - * - * @type {MapSchema[]} - */ - map: MapSchema[]; - /** - * Defines DWC specific meta needed by various steps of the transformation. - * - * @type {DwcSchema[]} - */ - dwcMeta: DwcSchema[]; -}; - -export type PreparedTransformSchema = TransformSchema & { - templateMeta: (TemplateMetaSchema & { distanceToRoot: number })[]; -}; - -/** - * Wraps a raw template transform config, modifying the config in preparation for use by the transformation engine, and - * providing additional helper functions for retrieving information from the config. - * - * @class XLSXTransformSchemaParser - */ -class XLSXTransformSchemaParser { - preparedTransformSchema: PreparedTransformSchema = { - templateMeta: [], - map: [], - dwcMeta: [] - }; - - /** - * Creates an instance of XLSXTransformSchemaParser. - * - * @param {TransformSchema} transformSchema - * @memberof XLSXTransformSchemaParser - */ - constructor(transformSchema: TransformSchema) { - this._processRawTransformSchema(transformSchema); - } - - /** - * Process the original transform schema, building a modified version that contains additional generated data used by - * the transform process. - * - * @param {TransformSchema} transformSchema - * @memberof XLSXTransformSchemaParser - */ - _processRawTransformSchema(transformSchema: TransformSchema) { - // prepare the `templateMeta` portion of the original transform schema - this.preparedTransformSchema.templateMeta = this._processTemplateMeta(transformSchema.templateMeta); - this.preparedTransformSchema.map = transformSchema.map; - this.preparedTransformSchema.dwcMeta = transformSchema.dwcMeta; - } - - /** - * Prepare the `templateMeta` portion of the transform schema. - * - * Recurse through the 'templateMeta' portion of the transform schema and build a modified version which has all items - * arranged in processing order (example: the root element is at index=0 in the array, etc) and where each item - * includes a new value `distanceToRoot` which indicates which tier of the tree that item is at (example: the root - * element is at `distanceToRoot=0`, its direct children are at `distanceToRoot=1`, etc) - * - * Note: This step could in be removed if the order of the transform schema was assumed to be correct by default and - * the `distanceToRoot` field was added to the type as a required field, and assumed to be set correctly. - * - * @param {TransformSchema['templateMeta']} templateMeta - * @return {*} {PreparedTransformSchema['templateMeta']} - * @memberof XLSXTransformSchemaParser - */ - _processTemplateMeta(templateMeta: TransformSchema['templateMeta']): PreparedTransformSchema['templateMeta'] { - const preparedTemplateMeta = []; - - const rootSheetSchema = Object.values(templateMeta).find((sheet) => sheet.type === 'root'); - - if (!rootSheetSchema) { - throw Error('No root template meta schema was defined'); - } - - preparedTemplateMeta.push({ ...rootSheetSchema, distanceToRoot: 0 }); - - const loop = (sheetNames: string[], distanceToRoot: number) => { - let nextSheetNames: string[] = []; - - sheetNames.forEach((sheetName) => { - const sheetSchema = Object.values(templateMeta).find((sheet) => sheet.sheetName === sheetName); - - if (!sheetSchema) { - return; - } - - preparedTemplateMeta.push({ ...sheetSchema, distanceToRoot: distanceToRoot }); - - nextSheetNames = nextSheetNames.concat(sheetSchema.foreignKeys.map((item) => item.sheetName)); - }); - - if (!nextSheetNames.length) { - return; - } - - loop(nextSheetNames, distanceToRoot + 1); - }; - - loop( - rootSheetSchema.foreignKeys.map((item) => item.sheetName), - 1 - ); - - return preparedTemplateMeta; - } - - /** - * Find and return the template meta config for a template sheet. - * - * Note: parses the `templateMeta` portion of the transform config. - * - * @param {string} sheetName - * @return {*} {(TemplateMetaSchema | undefined)} - * @memberof XLSXTransformSchemaParser - */ - getTemplateMetaConfigBySheetName(sheetName: string): TemplateMetaSchema | undefined { - return Object.values(this.preparedTransformSchema.templateMeta).find((sheet) => sheet.sheetName === sheetName); - } - - /** - * Get a list of all unique DWC sheet names. - * - * Note: parses the `map` portion of the transform config. - * - * @return {*} {string[]} - * @memberof XLSXTransformSchemaParser - */ - getDWCSheetNames(): string[] { - const names = JSONPath({ path: '$.[sheetName]', json: this.preparedTransformSchema.map }); - - return Array.from(new Set(names)); - } - - /** - * Find and return the dwc sheet keys for a DWC sheet. - * - * Note: parses the `dwcMeta` portion of the transform config. - * - * @param {string} sheetName - * @return {*} {string[]} - * @memberof XLSXTransformSchemaParser - */ - getDWCSheetKeyBySheetName(sheetName: string): string[] { - const result = JSONPath({ - path: `$..[?(@.sheetName === '${sheetName}' )][primaryKey]`, - json: this.preparedTransformSchema.dwcMeta - }); - - return result[0]; - } -} - -export default XLSXTransformSchemaParser; diff --git a/api/src/utils/media/xlsx/transformation/xlsx-transform-utils.ts b/api/src/utils/media/xlsx/transformation/xlsx-transform-utils.ts deleted file mode 100644 index 968df6ba36..0000000000 --- a/api/src/utils/media/xlsx/transformation/xlsx-transform-utils.ts +++ /dev/null @@ -1,91 +0,0 @@ -/** - * Iterates over an object and returns an array of all unique combinations of values. - * - * @example - * const obj = { - * 'type1': [1, 2] - * 'type2': [A, B] - * } - * - * const result = getCombinations(obj); - * - * // result = [ - * // [ 1,A ], - * // [ 1,B ], - * // [ 2,A ], - * // [ 2,B ] - * // ] - * - * @example - * const obj = { - * 'type1': [1, 2] - * 'type2': [A] - * } - * - * const result = getCombinations(obj); - * - * // result = [ - * // [ 1,A ], - * // [ 2,A ], - * // ] - * - * @param {Record>} obj - * @returns An array of all combinations of the incoming `obj` values. - */ -export function getCombinations>(obj: O) { - let combos: { [k in keyof O]: O[k][number] }[] = []; - for (const key in obj) { - const values = obj[key]; - const all: typeof combos = []; - for (const value of values) { - for (let j = 0; j < (combos.length || 1); j++) { - const newCombo = { ...combos[j], [key]: value }; - all.push(newCombo); - } - } - combos = all; - } - return combos; -} - -/** - * Filters objects from an array based on the keys provided. - * - * @example - * const arrayOfObjects = [ - * {key: 1, name: 1, value: 1}, - * {key: 1, name: 2, value: 2}, - * {key: 1, name: 2, value: 3}, - * {key: 2, name: 3, value: 4} - * ] - * - * const result = filterDuplicateKeys(arrayOfObjects, ['key']); - * - * // result = [ - * // {key: 1, name: 2, value: 3}, - * // {key: 2, name: 3, value: 4} - * // ] - * - * const result = filterDuplicateKeys(arrayOfObjects, ['key', 'name']); - * - * // result = [ - * // {key: 1, name: 1, value: 1}, - * // {key: 1, name: 2, value: 3}, - * // {key: 2, name: 3, value: 4} - * // ] - * - * @param {Record[]} arrayOfObjects - * @param {string[]} keys - * @return {*} {Record[]} - * @memberof XLSXTransform - */ -export function filterDuplicateKeys(arrayOfObjects: Record[], keys: string[]): Record[] { - const keyValues: [string, any][] = arrayOfObjects.map((value) => { - const key = keys.map((k) => value[k]).join('|'); - return [key, value]; - }); - - const kvMap = new Map(keyValues); - - return [...kvMap.values()]; -} diff --git a/api/src/utils/media/xlsx/transformation/xlsx-transform.ts b/api/src/utils/media/xlsx/transformation/xlsx-transform.ts deleted file mode 100644 index 24769c72d3..0000000000 --- a/api/src/utils/media/xlsx/transformation/xlsx-transform.ts +++ /dev/null @@ -1,668 +0,0 @@ -import jsonpatch, { Operation } from 'fast-json-patch'; -import { JSONPath, JSONPathOptions } from 'jsonpath-plus'; -import xlsx from 'xlsx'; -import { getWorksheetByName, getWorksheetRange, prepareWorksheetCells } from '../xlsx-utils'; -import XLSXTransformSchemaParser, { - ConditionSchema, - DWCColumnName, - DWCSheetName, - IfNotEmptyCheck, - JSONPathString, - TemplateColumnName, - TemplateMetaSchema, - TemplateMetaSchemaType, - TemplateSheetName, - TransformSchema -} from './xlsx-transform-schema-parser'; -import { filterDuplicateKeys, getCombinations } from './xlsx-transform-utils'; - -export type NonObjectPrimitive = string | number | null | boolean; - -/** - * Defines a type that indicates a `Partial` value, but with some exceptions. - * - * @example - * type MyType = { - * val1: string, // required - * val2: number, // required - * val3: boolean // required - * } - * - * Partial = { - * val1?: string, // optional - * val2?: number, // optional - * val3?: noolean, // optional - * } - * - * AtLeast = { - * val1: string, // required - * val2: number, // required - * val3?: boolean // optional - * } - */ -type AtLeast = Partial & Pick; - -/** - * Contains information about a single row, including information about its parent row and/or child row(s). - * - * It also includes calculated fields that are often used repeatedly, where re-calculation would be impossible or - * inefficient. - */ -export type RowObject = { - /** - * The row data. - * - * @type {{ [key: string]: NonObjectPrimitive }} - */ - _data: { [key: string]: NonObjectPrimitive }; - /** - * The name of the source file/sheet. - * - * @type {string} - */ - _name: string; - /** - * The key for this row. - * - * @type {string} - */ - _key: string; - /** - * The key of the parent row, if there is one. - * - * Note: All row objects will have a parent, unless they are `_type='root'` - * - * @type {string} - */ - _parentKey: string; - /** - * The type of the row object. - * - * @type {TemplateMetaSchemaType} - */ - _type: TemplateMetaSchemaType; - /** - * The index of the row from the source file. - * - * @type {number} - */ - _row: number; - /** - * The keys of all connected child rows, if any. - * - * @type {string[]} - */ - _childKeys: string[]; - /** - * The child row objects, if any. - * - * @type {RowObject[]} - */ - _children: RowObject[]; -}; - -export class XLSXTransform { - workbook: xlsx.WorkBook; - schemaParser: XLSXTransformSchemaParser; - - _uniqueIncrement = 0; - - constructor(workbook: xlsx.WorkBook, schema: TransformSchema) { - this.workbook = workbook; - this.schemaParser = new XLSXTransformSchemaParser(schema); - } - - /** - * Run the transformation process. - * - * @memberof XLSXTransform - */ - start() { - // Prepare the raw data, by adding keys and other dwcMeta to the raw row objects - const preparedRowObjects = this.prepareRowObjects(); - - // Recurse through the data, and create a hierarchical structure for each logical record - const hierarchicalRowObjects = this.buildRowObjectsHierarchy(preparedRowObjects); - - // Iterate over the hierarchical row objects, mapping original values to their DWC equivalents - const processedHierarchicalRowObjects = this.processHierarchicalRowObjects(hierarchicalRowObjects); - - // Iterate over the Darwin Core records, group them by DWC sheet name, and remove duplicate records in each sheet - return this.prepareRowObjectsForJSONToSheet(processedHierarchicalRowObjects); - } - - /** - * Modifies the raw row objects returned by xlsx, and adds additional data (row numbers, keys, etc) that will be used - * in later steps of the transformation process. - * - * @return {*} {Record} - * @memberof XLSXTransform - */ - prepareRowObjects(): Record { - const output: Record = {}; - - this.workbook.SheetNames.forEach((sheetName) => { - const templateMetaSchema = this.schemaParser.getTemplateMetaConfigBySheetName(sheetName); - - if (!templateMetaSchema) { - // Skip worksheet as no transform schema was provided - return; - } - - const worksheet = getWorksheetByName(this.workbook, sheetName); - - // Trim all whitespace on string values - prepareWorksheetCells(worksheet); - - const range = getWorksheetRange(worksheet); - - if (!range) { - throw Error('Worksheet range is undefined'); - } - - const worksheetJSON = xlsx.utils.sheet_to_json>(worksheet, { - blankrows: false, - raw: true, - rawNumbers: false - }); - - const numberOfRows = range['e']['r']; - - const preparedRowObjects = this._prepareRowObjects(worksheetJSON, templateMetaSchema, numberOfRows); - - output[sheetName] = preparedRowObjects; - }); - - return output; - } - - _prepareRowObjects( - worksheetJSON: Record[], - templateMetaSchema: TemplateMetaSchema, - numberOfRows: number - ): RowObject[] { - const worksheetJSONWithKey: RowObject[] = []; - - for (let i = 0; i < numberOfRows; i++) { - const primaryKey = this._getKeyForRowObject(worksheetJSON[i], templateMetaSchema.primaryKey); - - if (!primaryKey) { - continue; - } - - const parentKey = this._getKeyForRowObject(worksheetJSON[i], templateMetaSchema.parentKey); - - const childKeys = templateMetaSchema.foreignKeys - .map((foreignKeys: { sheetName: TemplateColumnName; primaryKey: string[] }) => { - return this._getKeyForRowObject(worksheetJSON[i], foreignKeys.primaryKey); - }) - .filter((item): item is string => !!item); - - worksheetJSONWithKey.push({ - _data: { ...worksheetJSON[i] }, - _name: templateMetaSchema.sheetName, - _key: primaryKey, - _parentKey: parentKey, - _type: templateMetaSchema.type, - // add 2 to _row: while the transform array index starts at 0, actual csv data (excel) starts at 1. And with the - // header row removed, the first real data rows start at 2. This _row value does not have to match the data row - // precisely, but it is convenient if they do as it better aligns with a humans understanding of the data. - _row: i + 2, - _childKeys: childKeys || [], - _children: [] - }); - } - - return worksheetJSONWithKey; - } - - _getKeyForRowObject(RowObject: Record, keyColumnNames: string[]): string { - if (!keyColumnNames.length) { - return ''; - } - - if (!RowObject || Object.getPrototypeOf(RowObject) !== Object.prototype || Object.keys(RowObject).length === 0) { - return ''; - } - - const primaryKey: string = keyColumnNames - .map((columnName: string) => { - return RowObject[columnName]; - }) - .filter(Boolean) - .join(':'); - - return primaryKey; - } - - /** - * De-normalize the original template data into a nested hierarchical object structure, based on the `templateMeta` - * portion of the transform config. - * - * @param {Record} preparedRowObjects - * @return {*} {{ _children: RowObject[] }} - * @memberof XLSXTransform - */ - buildRowObjectsHierarchy(preparedRowObjects: Record): { _children: RowObject[] } { - const hierarchicalRowObjects: { _children: RowObject[] } = { _children: [] }; - - for (const templateMetaItem of this.schemaParser.preparedTransformSchema.templateMeta) { - const sheetName = templateMetaItem.sheetName; - - const rowObjects = preparedRowObjects[sheetName]; - - if (!rowObjects) { - // No row objects for sheet - continue; - } - - const distanceToRoot = templateMetaItem.distanceToRoot; - if (distanceToRoot === 0) { - // These are root row objects, and can be added to the `hierarchicalRowObjects` array directly as they have no - // parent to be nested under - hierarchicalRowObjects._children = rowObjects; - - continue; - } - - // Add non-root row objects - for (const rowObjectsItem of rowObjects) { - const pathsToPatch: string[] = JSONPath({ - json: hierarchicalRowObjects, - path: `$${'._children[*]'.repeat(distanceToRoot - 1)}._children[?(@._childKeys.indexOf("${ - rowObjectsItem._parentKey - }") != -1)]`, - resultType: 'pointer' - }); - - if (pathsToPatch.length === 0) { - // Found no parent row object, even though this row object is a non-root row object - // This could indicate a possible error in the transform schema or the raw data - continue; - } - - const patchOperations: Operation[] = pathsToPatch.map((pathToPatch) => { - return { op: 'add', path: `${pathToPatch}/_children/`, value: rowObjectsItem }; - }); - - jsonpatch.applyPatch(hierarchicalRowObjects, patchOperations); - } - } - - return hierarchicalRowObjects; - } - - /** - * Map the original template data to their corresponding DWC terms, based on the operations in the `map` portion - * of the transform config. - * - * @param {{ - * _children: RowObject[]; - * }} hierarchicalRowObjects - * @return {*} {Record[]>[]} - * @memberof XLSXTransform - */ - processHierarchicalRowObjects(hierarchicalRowObjects: { - _children: RowObject[]; - }): Record[]>[] { - const mapRowObjects: Record[]>[] = []; - - // For each hierarchicalRowObjects - for (const hierarchicalRowObjectsItem of hierarchicalRowObjects._children) { - const flattenedRowObjects = this._flattenHierarchicalRowObject(hierarchicalRowObjectsItem); - - for (const flattenedRowObjectsItem of flattenedRowObjects) { - const result = this._mapFlattenedRowObject(flattenedRowObjectsItem as RowObject[]); - - mapRowObjects.push(result); - } - } - - return mapRowObjects; - } - - _flattenHierarchicalRowObject(hierarchicalRowObject: RowObject) { - const flattenedRowObjects: AtLeast[][] = [ - // Wrap the root element in `_children` so that the looping logic doesn't have to distinguish between the root - // element and subsequent children elements, it can just always grab the `_children`, of which the first one - // just so happens to only contain the root element. - [{ _children: [{ ...hierarchicalRowObject }] }] - ]; - - const prepGetCombinations = (source: AtLeast[]): Record => { - const prepGetCombinations: Record = {}; - - for (const sourceItem of source) { - if (sourceItem._type === 'leaf') { - // This node is marked as a leaf, so do not descend into its children. - continue; - } - - const children = sourceItem._children; - - for (const childrenItem of children) { - if (!prepGetCombinations[childrenItem._name]) { - prepGetCombinations[childrenItem._name] = []; - } - - prepGetCombinations[childrenItem._name].push(childrenItem); - } - } - - return prepGetCombinations; - }; - - const loop = (index: number, source: AtLeast[]) => { - // Grab all of the children of the current `source` and build an object in the format needed by the `getCombinations` - // function. - const preppedForGetCombinations = prepGetCombinations(source); - - // Loop over the prepped records, and build an array of objects which contain all of the possible combinations - // of the records. See function for more details. - const combinations = getCombinations(preppedForGetCombinations); - - if (combinations.length === 0) { - // No combinations elements, which means there were no children to process, indicating we've reached the end of - // the tree - return; - } - - if (combinations.length > 1) { - // This for loop is intentionally looping backwards, and stopping 1 element short of the 0'th element. - // This is because we only want to process the additional elements, pushing them onto the array, and leaving - // the code further below to handle the 0'th element, which will be set at the current `index` - for (let getCombinationsIndex = combinations.length - 1; getCombinationsIndex > 0; getCombinationsIndex--) { - let newSource: AtLeast[] = []; - for (const sourceItem of source) { - if (Object.keys(sourceItem).length <= 1) { - continue; - } - newSource.push({ ...sourceItem, _children: [] }); - } - newSource = newSource.concat(Object.values(combinations[getCombinationsIndex])); - flattenedRowObjects.push(newSource); - } - } - - // Handle the 0'th element of `combinations`, setting the `newSource` at whatever the current `index` is - let newSource: AtLeast[] = []; - for (const sourceItem of source) { - if (Object.keys(sourceItem).length <= 1) { - continue; - } - newSource.push({ ...sourceItem, _children: [] }); - } - newSource = newSource.concat(Object.values(combinations[0])); - flattenedRowObjects[index] = newSource; - - // Recurse into the newSource - loop(index, newSource); - }; - - // For each element in `flattenedRowObjects`, recursively descend through its children, flattening them as we - // go. If 2 children are of the same type, then push a copy of the current `flattenedRowObjects` element onto - // the `flattenedRowObjects` array, which will be processed on the next iteration of the for loop. - for (const [flatIndex, flattenedRowObjectsItem] of flattenedRowObjects.entries()) { - loop(flatIndex, flattenedRowObjectsItem); - } - - return flattenedRowObjects; - } - - _mapFlattenedRowObject(flattenedRow: RowObject[]) { - const output: Record[]> = {}; - - const indexBySheetName: Record = {}; - - const mapSchema = [...this.schemaParser.preparedTransformSchema.map]; - - // For each sheet - for (const mapSchemaItem of mapSchema) { - // Check conditions, if any - const sheetCondition = mapSchemaItem.condition; - if (sheetCondition) { - if (!this._processCondition(sheetCondition, flattenedRow)) { - // Conditions not met, skip processing this item - continue; - } - } - - const sheetName = mapSchemaItem.sheetName; - - if (!output[sheetName]) { - output[sheetName] = []; - indexBySheetName[sheetName] = 0; - } else { - indexBySheetName[sheetName] = indexBySheetName[sheetName] + 1; - } - - const fields = mapSchemaItem.fields; - - if (fields?.length) { - // For each item in the `fields` array - for (const fieldsItem of fields) { - // The final computed cell value for this particular schema field element - let cellValue = ''; - - const columnName = fieldsItem.columnName; - const columnValue = fieldsItem.columnValue; - - // For each item in the `columnValue` array - for (const columnValueItem of columnValue) { - // Check conditions, if any - const columnValueItemCondition = columnValueItem.condition; - if (columnValueItemCondition) { - if (!this._processCondition(columnValueItemCondition, flattenedRow)) { - // Conditions not met, skip processing this item - continue; - } - } - - // Check for static value - const columnValueItemValue = columnValueItem.static; - if (columnValueItemValue) { - // cell value is a static value - cellValue = columnValueItemValue; - } - - // Check for path value(s) - const columnValueItemPaths = columnValueItem.paths; - if (columnValueItemPaths) { - const pathValues = this._processPaths(columnValueItemPaths, flattenedRow); - - let pathValue = ''; - if (Array.isArray(pathValues)) { - // cell value is the concatenation of multiple values - pathValue = (pathValues.length && pathValues.flat(Infinity).join(columnValueItem.join ?? ':')) || ''; - } else { - // cell value is a single value - pathValue = pathValues || ''; - } - - cellValue = pathValue; - - // Add the optional postfix - const columnValueItemPostfix = columnValueItem.postfix; - if (cellValue && columnValueItemPostfix) { - let postfixValue = ''; - - if (columnValueItemPostfix.static) { - postfixValue = columnValueItemPostfix.static; - - if (columnValueItemPostfix.static === 'unique') { - postfixValue = String(this._getNextUniqueNumber()); - } - } - - if (columnValueItemPostfix.paths) { - const postfixPathValues = this._processPaths(columnValueItemPostfix.paths, flattenedRow); - - if (Array.isArray(postfixPathValues)) { - // postfix value is the concatenation of multiple values - postfixValue = - (postfixPathValues.length && - postfixPathValues.flat(Infinity).join(columnValueItem.join ?? ':')) || - ''; - } else { - // postfix value is a single value - postfixValue = postfixPathValues || ''; - } - } - - cellValue = `${cellValue}${columnValueItem.join ?? ':'}${postfixValue}`; - } - } - - // Check for `add` additions at the field level - const columnValueItemAdd = columnValueItem.add; - if (columnValueItemAdd?.length) { - for (const columnValueItemAddItem of columnValueItemAdd) { - mapSchema.push(columnValueItemAddItem); - } - } - - if (cellValue) { - // One of the columnValue array items yielded a non-empty cell value, skip any remaining columnValue items. - break; - } - } - - // add the cell key value - output[sheetName][indexBySheetName[sheetName]] = { - ...output[sheetName][indexBySheetName[sheetName]], - [columnName]: cellValue - }; - } - } - - // Check for additions at the sheet level - const sheetAdds = mapSchemaItem.add; - if (sheetAdds?.length) { - for (const sheetAddsItem of sheetAdds) { - mapSchema.push(sheetAddsItem); - } - } - } - - return output; - } - - /** - * Process a transform config `condition`, returning `true` if the condition passed and `false` otherwise. - * - * @param {ConditionSchema} condition - * @param {RowObject[]} rowObjects - * @return {*} {boolean} `true` if the condition passed, `false` otherwise - * @memberof XLSXTransform - */ - _processCondition(condition: ConditionSchema, rowObjects: RowObject[]): boolean { - if (!condition) { - // No conditions to process - return true; - } - - const conditionsMet = new Set(); - - for (const checksItem of condition.checks) { - if (checksItem.ifNotEmpty) { - conditionsMet.add(this._processIfNotEmptyCondition(checksItem, rowObjects)); - } - } - - let result = false; - - if (condition.type === 'or') { - // condition passes if at least 1 check passes (logical `or`) - result = conditionsMet.has(true); - } else { - // condition passes if no check fails (logical `and`) - result = !conditionsMet.has(false); - } - - if (condition.not) { - // if `true`, negate the result of the condition (logical `not`) - result = !result; - } - - return result; - } - - _processIfNotEmptyCondition(check: IfNotEmptyCheck, rowObjects: RowObject[]): boolean { - const pathValues = this._processPaths([check.ifNotEmpty], rowObjects); - - let result = false; - - if (pathValues?.length) { - // path is not empty - result = true; - } - - if (check.not) { - // if `true`, negate the result of the condition (logical `not`) - result = !result; - } - - return result; - } - - _processPaths(paths: JSONPathString[], json: JSONPathOptions['json']): string | string[] | string[][] { - if (paths.length === 0) { - return ''; - } - - if (paths.length === 1) { - return JSONPath({ path: paths[0], json: json }) || ''; - } - - const values = []; - - for (const pathsItem of paths) { - const value = JSONPath({ path: pathsItem, json: json }) || ''; - - if (value) { - values.push(value); - } - } - - return values; - } - - /** - * Groups all of the DWC records based on DWC sheet name. - * - * @param {Record[]>[]} processedHierarchicalRowObjects - * @return {*} {Record[]>} - * @memberof XLSXTransform - */ - prepareRowObjectsForJSONToSheet( - processedHierarchicalRowObjects: Record[]>[] - ): Record[]> { - const groupedByDWCSheetName: Record[]> = {}; - const uniqueGroupedByDWCSheetName: Record[]> = {}; - - const dwcSheetNames = this.schemaParser.getDWCSheetNames(); - - dwcSheetNames.forEach((sheetName) => { - groupedByDWCSheetName[sheetName] = []; - uniqueGroupedByDWCSheetName[sheetName] = []; - }); - - processedHierarchicalRowObjects.forEach((item) => { - const entries = Object.entries(item); - for (const [key, value] of entries) { - groupedByDWCSheetName[key] = groupedByDWCSheetName[key].concat(value); - } - }); - - Object.entries(groupedByDWCSheetName).forEach(([key, value]) => { - const keys = this.schemaParser.getDWCSheetKeyBySheetName(key); - uniqueGroupedByDWCSheetName[key] = filterDuplicateKeys(value, keys) as any; - }); - - return uniqueGroupedByDWCSheetName; - } - - _getNextUniqueNumber(): number { - return this._uniqueIncrement++; - } -} diff --git a/api/src/utils/media/xlsx/validation/xlsx-validation.ts b/api/src/utils/media/xlsx/validation/xlsx-validation.ts index 2d44d51fd7..8608214851 100644 --- a/api/src/utils/media/xlsx/validation/xlsx-validation.ts +++ b/api/src/utils/media/xlsx/validation/xlsx-validation.ts @@ -65,7 +65,7 @@ export const getParentChildKeyMatchValidator = (config?: ParentChildKeyMatchVali // Remove empty column values .filter(Boolean) - // Escape possible column deliminator occurrences from column value string + // Escape possible column deliminator instances from column value string // Trim whitespace .map(safeTrim) diff --git a/api/src/utils/media/xlsx/xlsx-file.ts b/api/src/utils/media/xlsx/xlsx-file.ts index 5c951fee4b..6a6a763e1d 100644 --- a/api/src/utils/media/xlsx/xlsx-file.ts +++ b/api/src/utils/media/xlsx/xlsx-file.ts @@ -1,9 +1,10 @@ import xlsx from 'xlsx'; import { CSVWorkBook, CSVWorksheet, ICsvState } from '../csv/csv-file'; -import { DEFAULT_XLSX_SHEET } from '../dwc/dwc-archive-file'; import { IMediaState, MediaFile, MediaValidation } from '../media-file'; import { ValidationSchemaParser } from '../validation/validation-schema-parser'; +export const DEFAULT_XLSX_SHEET_NAME = 'Sheet1' as const; + /** * Supports XLSX CSV files. * @@ -67,7 +68,7 @@ export class XLSXCSV { validateMedia(validationSchemaParser: ValidationSchemaParser): void { const validators = validationSchemaParser.getSubmissionValidations(); - this.validate(validators as XLSXCSVValidator[]); + this.validate(validators); } /** @@ -119,7 +120,7 @@ export class XLSXCSV { worksheetToBuffer(worksheet: xlsx.WorkSheet): Buffer { const newWorkbook = xlsx.utils.book_new(); - xlsx.utils.book_append_sheet(newWorkbook, worksheet, DEFAULT_XLSX_SHEET); + xlsx.utils.book_append_sheet(newWorkbook, worksheet, DEFAULT_XLSX_SHEET_NAME); return xlsx.write(newWorkbook, { type: 'buffer', bookType: 'csv' }); } diff --git a/api/src/utils/submission-error.ts b/api/src/utils/submission-error.ts deleted file mode 100644 index 3ecee340c8..0000000000 --- a/api/src/utils/submission-error.ts +++ /dev/null @@ -1,62 +0,0 @@ -import { SUBMISSION_MESSAGE_TYPE, SUBMISSION_STATUS_TYPE, SUMMARY_SUBMISSION_MESSAGE_TYPE } from '../constants/status'; - -export const SubmissionErrorFromMessageType = (type: SUBMISSION_MESSAGE_TYPE): SubmissionError => { - const message = new MessageError(type); - return new SubmissionError({ messages: [message] }); -}; - -export const SummarySubmissionErrorFromMessageType = ( - type: SUMMARY_SUBMISSION_MESSAGE_TYPE -): SummarySubmissionError => { - const message = new MessageError(type); - return new SummarySubmissionError({ messages: [message] }); -}; - -export class MessageError extends Error { - type: T; - description: string; - errorCode: string; - - constructor(type: T, description?: string, errorCode?: string) { - super(type); - this.type = type; - this.description = type; - this.errorCode = type; - - if (description) { - this.description = description; - } - - if (errorCode) { - this.errorCode = errorCode; - } - } -} - -export class SubmissionError extends Error { - status: SUBMISSION_STATUS_TYPE; - submissionMessages: MessageError[]; - - constructor(params: { status?: SUBMISSION_STATUS_TYPE; messages?: MessageError[] }) { - const { status, messages } = params; - super(status || SUBMISSION_STATUS_TYPE.REJECTED); - - this.status = status || SUBMISSION_STATUS_TYPE.REJECTED; - this.submissionMessages = messages || []; - } - - setStatus(status: SUBMISSION_STATUS_TYPE) { - this.status = status; - } -} - -export class SummarySubmissionError extends Error { - summarySubmissionMessages: MessageError[]; - - constructor(params: { messages?: MessageError[] }) { - super(SUBMISSION_MESSAGE_TYPE.FAILED_PARSE_SUBMISSION); - const { messages } = params; - - this.summarySubmissionMessages = messages || []; - } -} diff --git a/api/src/utils/xlsx-utils/worksheet-utils.ts b/api/src/utils/xlsx-utils/worksheet-utils.ts index ee5c0fb59b..b3a85264aa 100644 --- a/api/src/utils/xlsx-utils/worksheet-utils.ts +++ b/api/src/utils/xlsx-utils/worksheet-utils.ts @@ -8,6 +8,7 @@ import { } from '../../services/critterbase-service'; import { getLogger } from '../logger'; import { MediaFile } from '../media/media-file'; +import { DEFAULT_XLSX_SHEET_NAME } from '../media/xlsx/xlsx-file'; import { safeToLowerCase } from '../string-utils'; import { replaceCellDates, trimCellWhitespace } from './cell-utils'; @@ -302,7 +303,7 @@ export const prepareWorksheetCells = (worksheet: xlsx.WorkSheet) => { export function validateCsvFile( xlsxWorksheets: xlsx.WorkSheet, columnValidator: IXLSXCSVValidator, - sheet = 'Sheet1' + sheet = DEFAULT_XLSX_SHEET_NAME ): boolean { // Validate the worksheet headers if (!validateWorksheetHeaders(xlsxWorksheets[sheet], columnValidator)) { @@ -466,7 +467,7 @@ export function isQualitativeValueValid( export function getMeasurementColumnNameFromWorksheet( xlsxWorksheets: xlsx.WorkSheet, columnValidator: IXLSXCSVValidator, - sheet = 'Sheet1' + sheet = DEFAULT_XLSX_SHEET_NAME ): string[] { const columns = getWorksheetHeaders(xlsxWorksheets[sheet]); let aliasColumns: string[] = []; @@ -496,7 +497,7 @@ export function getMeasurementColumnNameFromWorksheet( export async function getCBMeasurementsFromWorksheet( xlsxWorksheets: xlsx.WorkSheet, critterBaseService: CritterbaseService, - sheet = 'Sheet1' + sheet = DEFAULT_XLSX_SHEET_NAME ): Promise { const tsnMeasurements: TsnMeasurementMap = {}; const rows = getWorksheetRowObjects(xlsxWorksheets[sheet]); diff --git a/app/src/constants/spatial.ts b/app/src/constants/spatial.ts index 4ae83b7454..3f06fbabd4 100644 --- a/app/src/constants/spatial.ts +++ b/app/src/constants/spatial.ts @@ -19,9 +19,3 @@ export const ALL_OF_BC_BOUNDARY: Feature = { ] } }; - -export enum SPATIAL_COMPONENT_TYPE { - OCCURRENCE = 'Occurrence', - BOUNDARY = 'Boundary', - BOUNDARY_CENTROID = 'Boundary Centroid' -} diff --git a/app/src/constants/submissions.ts b/app/src/constants/submissions.ts deleted file mode 100644 index 00b80208e8..0000000000 --- a/app/src/constants/submissions.ts +++ /dev/null @@ -1,28 +0,0 @@ -/** - * Submission Status Types. - * - * See submission_status_type table -> name. - * - * @export - * @enum {number} - */ -export enum SUBMISSION_STATUS_TYPE { - 'SUBMITTED' = 'Submitted', - 'TEMPLATE_VALIDATED' = 'Template Validated', - 'DARWIN_CORE_VALIDATED' = 'Darwin Core Validated', - 'TEMPLATE_TRANSFORMED' = 'Template Transformed', - 'SUBMISSION_DATA_INGESTED' = 'Submission Data Ingested', - 'SECURED' = 'Secured', - 'AWAITING CURRATION' = 'Awaiting Curration', - 'REJECTED' = 'Rejected', - 'ON HOLD' = 'On Hold', - 'SYSTEM_ERROR' = 'System Error', - - //Failure - 'FAILED_OCCURRENCE_PREPARATION' = 'Failed to prepare submission', - 'INVALID_MEDIA' = 'Media is not valid', - 'FAILED_VALIDATION' = 'Failed to validate', - 'FAILED_TRANSFORMED' = 'Failed to transform', - 'FAILED_PROCESSING_OCCURRENCE_DATA' = 'Failed to process occurrence data', - 'FAILED_SUMMARY_PREPARATION' = 'Failed to prepare summary submission' -} diff --git a/app/src/interfaces/useDwcaApi.interface.ts b/app/src/interfaces/useDwcaApi.interface.ts deleted file mode 100644 index de93ef6512..0000000000 --- a/app/src/interfaces/useDwcaApi.interface.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { Feature, FeatureCollection } from 'geojson'; - -export interface IGetSubmissionCSVForViewItem { - name: string; - headers: string[]; - rows: string[][]; -} - -export interface IGetSubmissionCSVForViewResponse { - data: IGetSubmissionCSVForViewItem[]; -} - -export interface ISurveySupplementaryData { - occurrence_submission_publish_id: number; - occurrence_submission_id: number; - event_timestamp: string; - submission_uuid: string; - create_date: string; - create_user: number; - update_date: string | null; - update_user: number | null; - revision_count: number; -} - -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/useSurveyApi.interface.ts b/app/src/interfaces/useSurveyApi.interface.ts index 65173127e6..ef7ce15ffd 100644 --- a/app/src/interfaces/useSurveyApi.interface.ts +++ b/app/src/interfaces/useSurveyApi.interface.ts @@ -200,36 +200,7 @@ export interface SurveyUpdateObject extends ISurveyLocationForm { participants?: IGetSurveyParticipant[]; } -// TODO remove in subsequent PR export interface SurveySupplementaryData { - occurrence_submission: { - occurrence_submission_id: number | null; - }; - occurrence_submission_publish: { - occurrence_submission_publish_id: number; - occurrence_submission_id: number; - event_timestamp: string; - submission_uuid: string; - create_date: string; - create_user: number; - update_date: string | null; - update_user: number | null; - revision_count: number; - } | null; - survey_summary_submission: { - survey_summary_submission_id: number | null; - }; - survey_summary_submission_publish: { - survey_summary_submission_publish_id: number; - survey_summary_submission_id: number; - event_timestamp: string; - artifact_revision_id: number; - create_date: string; - create_user: number; - update_date: string | null; - update_user: number | null; - revision_count: number; - } | null; survey_metadata_publish: { survey_metadata_publish_id: number; survey_id: number; diff --git a/app/src/test-helpers/survey-helpers.ts b/app/src/test-helpers/survey-helpers.ts index ab8057d19d..5738e6676c 100644 --- a/app/src/test-helpers/survey-helpers.ts +++ b/app/src/test-helpers/survey-helpers.ts @@ -90,24 +90,6 @@ export const surveyObject: SurveyViewObject = { }; export const surveySupplementaryData: SurveySupplementaryData = { - occurrence_submission: { - occurrence_submission_id: 1 - }, - occurrence_submission_publish: { - occurrence_submission_publish_id: 1, - occurrence_submission_id: 1, - event_timestamp: '2000-05-10 11:53:53', - submission_uuid: '123-456-789', - create_date: '2000-06-10 11:53:53', - create_user: 1, - update_date: null, - update_user: null, - revision_count: 1 - }, - survey_summary_submission: { - survey_summary_submission_id: null - }, - survey_summary_submission_publish: null, survey_metadata_publish: { survey_metadata_publish_id: 1, survey_id: 1, diff --git a/app/src/utils/spatial-utils.tsx b/app/src/utils/spatial-utils.tsx deleted file mode 100644 index 5e62c49e58..0000000000 --- a/app/src/utils/spatial-utils.tsx +++ /dev/null @@ -1,128 +0,0 @@ -import { SPATIAL_COMPONENT_TYPE } from 'constants/spatial'; -import { Feature } from 'geojson'; -import { EmptyObject, ISpatialData } from 'interfaces/useDwcaApi.interface'; -import { isObject } from 'lodash-es'; - -export interface IDataResult { - key: string; - name: string; - count: number; -} - -export type OccurrenceFeature = Feature & { properties: OccurrenceFeatureProperties }; - -export type OccurrenceFeatureProperties = { - type: SPATIAL_COMPONENT_TYPE.OCCURRENCE; -}; - -export type BoundaryFeature = Feature & { properties: BoundaryFeatureProperties }; - -export type BoundaryFeatureProperties = { - type: SPATIAL_COMPONENT_TYPE.BOUNDARY; -}; - -export type BoundaryCentroidFeature = Feature & { properties: BoundaryCentroidFeatureProperties }; - -export type BoundaryCentroidFeatureProperties = { - type: SPATIAL_COMPONENT_TYPE.BOUNDARY_CENTROID; -}; - -export interface ISpatialDataGroupedBySpecies { - [species: string]: ISpatialData[]; -} - -/** - * Helper function to *consistently* make React keys from an array of submission_spatial_component_ids. - * @param submissionSpatialComponentIds A list of IDs - * @returns A string joining all the id's by a seperator - */ -const makeKeyFromIds = (submissionSpatialComponentIds: number[]): string => { - return submissionSpatialComponentIds.join('-'); -}; - -/** - * Gleans submission spatial component IDs from a spatial component. - * @param spatialRecord A spatial component record - * @returns an array of submission_spatial_component_ids - */ -const getSubmissionSpatialComponentIds = (spatialRecord: ISpatialData): number[] => { - return spatialRecord.taxa_data.map((record: any) => record.submission_spatial_component_id); -}; - -/** - * Takes an array of ISpatialData and maps it to an IDataResult array - * @param data The array of spatial data - * @returns an array of type IDataResult - */ -export const parseBoundaryCentroidResults = (data: ISpatialData[]): IDataResult[] => { - const results: IDataResult[] = []; - data.forEach((spatialData: ISpatialData) => { - if (isBoundaryCentroidFeature(spatialData.spatial_data.features[0])) { - const key = makeKeyFromIds(getSubmissionSpatialComponentIds(spatialData)); - results.push({ - key: key, - name: `${spatialData.spatial_data?.features[0]?.properties?.datasetTitle}`, - count: 0 - }); - } - }); - - return results; -}; - -/** - * Takes an array of ISpatialData and maps it to an IDataResult array denoting visibility based - * on the given datasetVisibility Record, while numerating the count of each species. - * @param data The array of spatial data - * @param datasetVisibility a Record denoting dataset visiblity - * @returns an array of type IDataResult - */ -export const parseOccurrenceResults = (data: ISpatialData[]): IDataResult[] => { - const taxaMap: Record = {}; - data.forEach((spatialData) => { - spatialData.taxa_data.forEach((item: any) => { - // need to check if it is an occurrence or not - if (isOccurrenceFeature(spatialData.spatial_data.features[0]) && item.associated_taxa) { - if (taxaMap[item.associated_taxa] === undefined) { - taxaMap[item.associated_taxa] = { - key: item.associated_taxa, - name: `${item.vernacular_name} (${item.associated_taxa})`, - count: 0 - }; - } - - taxaMap[item.associated_taxa].count++; - } - }); - }); - - return Object.values(taxaMap); -}; - -/** - * Checks if `obj` is an object with no keys (aka: an empty object) - */ -export const isEmptyObject = (obj: any): obj is EmptyObject => { - return !!(isObject(obj) && !Object.keys(obj).length); -}; - -/** - * Asserts if a feature is an OccurrenceFeature. - */ -export const isOccurrenceFeature = (feature: Feature): feature is OccurrenceFeature => { - return feature.geometry.type === 'Point' && feature.properties?.['type'] === SPATIAL_COMPONENT_TYPE.OCCURRENCE; -}; - -/** - * Asserts if a feature is an BoundaryFeature. - */ -export const isBoundaryFeature = (feature: Feature): feature is BoundaryFeature => { - return feature?.properties?.['type'] === SPATIAL_COMPONENT_TYPE.BOUNDARY; -}; - -/** - * Asserts if a feature is an BoundaryCentroidFeature. - */ -export const isBoundaryCentroidFeature = (feature: Feature): feature is BoundaryCentroidFeature => { - return feature?.properties?.['type'] === SPATIAL_COMPONENT_TYPE.BOUNDARY_CENTROID; -}; diff --git a/database/src/seeds/02_dwc_spatial_transform.ts b/database/src/seeds/02_dwc_spatial_transform.ts deleted file mode 100644 index e31b7866b3..0000000000 --- a/database/src/seeds/02_dwc_spatial_transform.ts +++ /dev/null @@ -1,97 +0,0 @@ -import { Knex } from 'knex'; - -const DB_SCHEMA = process.env.DB_SCHEMA; -const DB_SCHEMA_DAPI_V1 = process.env.DB_SCHEMA_DAPI_V1; - -/** - * Add spatial transform - * - * @export - * @param {Knex} knex - * @return {*} {Promise} - */ -export async function seed(knex: Knex): Promise { - await knex.raw(` - SET SCHEMA '${DB_SCHEMA}'; - SET SEARCH_PATH = ${DB_SCHEMA}, ${DB_SCHEMA_DAPI_V1}; - `); - - const response = await knex.raw(` - ${checkTransformExists()} - `); - - if (!response?.rows?.[0]) { - await knex.raw(` - ${insertSpatialTransform()} - `); - } else { - await knex.raw(` - ${updateSpatialTransform()} - `); - } -} - -const checkTransformExists = () => ` - SELECT - spatial_transform_id - FROM - spatial_transform - WHERE - name = 'DwC Occurrences'; -`; - -/** - * SQL to insert DWC Occurrences transform - * - */ -const insertSpatialTransform = () => ` - INSERT into spatial_transform - (name, description, record_effective_date, transform) - VALUES ( - 'DwC Occurrences', 'Extracts occurrences and properties from DwC JSON source.', now(),${transformString} - ); -`; - -const updateSpatialTransform = () => ` -UPDATE - spatial_transform SET transform = ${transformString} -WHERE -name = 'DwC Occurrences'; -`; - -const transformString = ` -$transform$ -with submission as (select * - from occurrence_submission - where occurrence_submission_id = ?) - , occurrences as (select occurrence_submission_id, occs - from submission, jsonb_path_query(darwin_core_source, '$.occurrence') occs) - , occurrence as (select occurrence_submission_id, jsonb_array_elements(occs) occ - from occurrences) - , events as (select evns - from submission, jsonb_path_query(darwin_core_source, '$.event') evns) - , event as (select jsonb_array_elements(evns) evn - from events) - , locations as (select locs - from submission, jsonb_path_query(darwin_core_source, '$.location') locs) - , location as (select jsonb_array_elements(locs) loc - from locations) - , event_coord as (select coalesce(st_x(pt), 0) x, coalesce(st_y(pt), 0) y, loc - from location, ST_SetSRID(ST_MakePoint((nullif(loc->>'decimalLongitude', ''))::float, (nullif(loc->>'decimalLatitude', ''))::float), 4326) pt) - , normal as (select distinct o.occurrence_submission_id, o.occ, ec.*, e.evn - from occurrence o - left outer join event_coord ec on - (ec.loc->'eventID' = o.occ->'eventID') - left outer join event e on - (e.evn->'eventID' = o.occ->'eventID')) - select jsonb_build_object('type', 'FeatureCollection' - , 'features', jsonb_build_array(jsonb_build_object('type', 'Feature' - , 'geometry', jsonb_build_object('type', 'Point', 'coordinates', json_build_array(n.x, n.y)) - , 'properties', jsonb_build_object('type', 'Occurrence', 'dwc', jsonb_build_object( - 'type', 'PhysicalObject', 'basisOfRecord', 'Occurrence', 'datasetID', n.occurrence_submission_id, 'occurrenceID', n.occ->'occurrenceID' - , 'sex', n.occ->'sex', 'lifeStage', n.occ->'lifeStage', 'taxonID', n.occ->'taxonID', 'vernacularName', n.occ->'vernacularName', 'individualCount', n.occ->'individualCount' - , 'eventDate', n.evn->'eventDate', 'verbatimSRS', n.loc->'verbatimSRS', 'verbatimCoordinates', n.loc->'verbatimCoordinates' - )))) - )result_data - from normal n; -$transform$`; diff --git a/database/src/seeds/05_moose_summary_validation_insert.ts b/database/src/seeds/05_moose_summary_validation_insert.ts deleted file mode 100644 index 0233c4460a..0000000000 --- a/database/src/seeds/05_moose_summary_validation_insert.ts +++ /dev/null @@ -1,65 +0,0 @@ -import { Knex } from 'knex'; - -const DB_SCHEMA = process.env.DB_SCHEMA; -const DB_SCHEMA_DAPI_V1 = process.env.DB_SCHEMA_DAPI_V1; - -/** - * Add spatial transform - * - * @export - * @param {Knex} knex - * @return {*} {Promise} - */ -export async function seed(knex: Knex): Promise { - await knex.raw(` - SET SCHEMA '${DB_SCHEMA}'; - SET SEARCH_PATH = ${DB_SCHEMA}, ${DB_SCHEMA_DAPI_V1}; - `); - - const response = await knex.raw(` - ${checkTemplateExists()} - `); - - if (!response?.rows?.[0]) { - await knex.raw(` - ${insertSummaryTemplate()} - `); - } -} - -const validationName = 'Moose_Summary_Results_1.0'; -const validationVersion = '1.0'; -const mooseWldtaxonomicUnitsId = 2065; - -const checkTemplateExists = () => ` - SELECT - summary_template_id - FROM - summary_template - WHERE - name = 'Moose_Summary_Results_1.0'; -`; - -/** - * SQL to insert Summary Template - * - */ -const insertSummaryTemplate = () => ` - WITH new_template_record AS ( - INSERT into summary_template - (name, version, record_effective_date, description) - VALUES - ('${validationName}', '${validationVersion}', now(), '${validationName}') - RETURNING summary_template_id - ) - INSERT into summary_template_species - (summary_template_id, wldtaxonomic_units_id, validation) - VALUES ( - (SELECT summary_template_id FROM new_template_record), - ${mooseWldtaxonomicUnitsId} , - '${summaryValidation}' - ); -`; - -const summaryValidation = - '{"name":"","description":"","defaultFile":{"description":"","columns":[{"name":"Observed","description":"","validations":[{"column_numeric_validator":{"name":"","description":""}}]},{"name":"Estimated","description":"","validations":[{"column_numeric_validator":{"name":"","description":""}}]},{"name":"Sightability Correction Factor","description":"","validations":[{"column_numeric_validator":{"name":"","description":""}}]},{"name":"SE","description":"","validations":[{"column_numeric_validator":{"name":"","description":""}}]},{"name":"Coefficient of Variation (%)","description":"","validations":[{"column_numeric_validator":{"name":"","description":""}}]},{"name":"Confidence Level (%)","description":"","validations":[{"column_numeric_validator":{"name":"","description":""}}]},{"name":"Area Flown (km2)","description":"","validations":[{"column_numeric_validator":{"name":"","description":""}}]},{"name":"Total Survey Area (km2)","description":"","validations":[{"column_numeric_validator":{"name":"","description":""}}]},{"name":"Total Kilometers Surveyed (km)","description":"","validations":[{"column_numeric_validator":{"name":"","description":""}}]},{"name":"Best Parameter Value Flag","description":"","validations":[{"column_code_validator":{"name":"","description":"","allowed_code_values":[{"name":"Yes","description":""},{"name":"No","description":""},{"name":"Unknown","description":""},{"name":"Not Evaluated","description":""}]}}]},{"name":"Total Marked Animals Observed","description":"","validations":[{"column_numeric_validator":{"name":"","description":""}}]},{"name":"Marked Animals Available","description":"","validations":[{"column_numeric_validator":{"name":"","description":""}}]}],"validations":[{"file_duplicate_columns_validator":{}},{"file_required_columns_validator":{"required_columns":["Study Area","Population Unit","Block/Sample Unit","Parameter","Stratum","Observed","Estimated","Sightability Model","Sightability Correction Factor","SE","Coefficient of Variation (%)","Confidence Level (%)","Lower CL","Upper CL","Total Survey Area (km2)","Area Flown (km2)","Total Kilometers Surveyed (km)","Best Parameter Value Flag","Outlier Blocks Removed","Total Marked Animals Observed","Marked Animals Available","Parameter Comments"]}}]},"validations":[{"mimetype_validator":{"reg_exps":["text\\/csv","application\\/vnd.*"]}}]}'; From a25ea1e86cc7c85f1f92bf2445e3b6de7df686cc Mon Sep 17 00:00:00 2001 From: Curtis Upshall Date: Mon, 25 Mar 2024 17:39:13 -0400 Subject: [PATCH 4/9] SIMSBIOHUB-464: App effect dependancy warnings (#1256) * Addresses linting/console errors related to `useEffect` dependencies. * Fixes a bug introduced by #1221 in which saving edits to a survey causes the old data to be shown while saving --- app/src/contexts/telemetryTableContext.tsx | 2 +- .../projects/list/ProjectsListPage.tsx | 29 +++++++++---------- .../projects/view/components/TeamMember.tsx | 24 +++++++-------- .../features/surveys/edit/EditSurveyPage.tsx | 20 ++++--------- .../features/surveys/list/SurveysListPage.tsx | 11 ++++--- .../components/SamplingSiteEditMapControl.tsx | 10 +++++-- .../surveys/telemetry/ManualTelemetryList.tsx | 3 ++ .../surveys/view/components/Permits.tsx | 10 +++---- .../seeds/03_basic_project_survey_setup.ts | 15 +++++++++- 9 files changed, 66 insertions(+), 58 deletions(-) diff --git a/app/src/contexts/telemetryTableContext.tsx b/app/src/contexts/telemetryTableContext.tsx index 765ad1b28f..6fc5d7f3c5 100644 --- a/app/src/contexts/telemetryTableContext.tsx +++ b/app/src/contexts/telemetryTableContext.tsx @@ -462,7 +462,7 @@ export const TelemetryTableContextProvider: React.FC 0 || addedRowIds.length > 0; diff --git a/app/src/features/projects/list/ProjectsListPage.tsx b/app/src/features/projects/list/ProjectsListPage.tsx index 27207be562..97da4fa7f0 100644 --- a/app/src/features/projects/list/ProjectsListPage.tsx +++ b/app/src/features/projects/list/ProjectsListPage.tsx @@ -82,20 +82,6 @@ const ProjectsListPage = () => { ); }; - const refreshProjectsList = () => { - const sort = firstOrNull(sortModel); - const pagination = { - limit: paginationModel.pageSize, - sort: sort?.field || undefined, - order: sort?.sort || undefined, - - // API pagination pages begin at 1, but MUI DataGrid pagination begins at 0. - page: paginationModel.page + 1 - }; - - return projectsDataLoader.refresh(pagination, advancedFiltersModel); - }; - const projectRows = projectsDataLoader.data?.projects.map((project) => { return { @@ -150,7 +136,20 @@ const ProjectsListPage = () => { // Refresh projects when pagination or sort changes useEffect(() => { - refreshProjectsList(); + const sort = firstOrNull(sortModel); + const pagination = { + limit: paginationModel.pageSize, + sort: sort?.field || undefined, + order: sort?.sort || undefined, + + // API pagination pages begin at 1, but MUI DataGrid pagination begins at 0. + page: paginationModel.page + 1 + }; + + projectsDataLoader.refresh(pagination, advancedFiltersModel); + + // Adding a DataLoader as a dependency causes an infinite rerender loop if a useEffect calls `.refresh` + // eslint-disable-next-line react-hooks/exhaustive-deps }, [sortModel, paginationModel, advancedFiltersModel]); /** diff --git a/app/src/features/projects/view/components/TeamMember.tsx b/app/src/features/projects/view/components/TeamMember.tsx index e819c3560f..b5e0fe3413 100644 --- a/app/src/features/projects/view/components/TeamMember.tsx +++ b/app/src/features/projects/view/components/TeamMember.tsx @@ -13,21 +13,21 @@ interface IProjectParticipantsRoles { avatarColor: string; } +// Define a custom sorting order for roles +const roleOrder: { [key: string]: number } = { + Coordinator: 1, + Collaborator: 2, + Observer: 3 +}; + const TeamMembers = () => { const projectContext = useContext(ProjectContext); // Project data must be loaded by a parent before this component is rendered assert(projectContext.projectDataLoader.data); - // Define a custom sorting order for roles - const roleOrder: { [key: string]: number } = { - Coordinator: 1, - Collaborator: 2, - Observer: 3 - }; - - const projectTeamMembers: IProjectParticipantsRoles[] = useMemo( - () => + const projectTeamMembers: IProjectParticipantsRoles[] = useMemo(() => { + return ( projectContext.projectDataLoader.data?.projectData.participants .map((member) => { const initials = member.display_name @@ -46,9 +46,9 @@ const TeamMembers = () => { const roleA = a.roles[0] || ''; const roleB = b.roles[0] || ''; return roleOrder[roleA] - roleOrder[roleB]; - }) || [], - [projectContext.projectDataLoader.data.projectData.participants] - ); + }) ?? [] + ); + }, [projectContext.projectDataLoader.data.projectData.participants]); return ( diff --git a/app/src/features/surveys/edit/EditSurveyPage.tsx b/app/src/features/surveys/edit/EditSurveyPage.tsx index 0900e6d7c3..bdb4fb12a1 100644 --- a/app/src/features/surveys/edit/EditSurveyPage.tsx +++ b/app/src/features/surveys/edit/EditSurveyPage.tsx @@ -44,8 +44,8 @@ const EditSurveyPage = () => { const [isSaving, setIsSaving] = useState(false); const dialogContext = useContext(DialogContext); - const codesContext = useContext(CodesContext); + useEffect(() => { codesContext.codesDataLoader.load(); }, [codesContext.codesDataLoader]); @@ -58,25 +58,15 @@ const EditSurveyPage = () => { const surveyContext = useContext(SurveyContext); - const editSurveyDL = useDataLoader((projectId: number, surveyId: number) => + const editSurveyDataLoader = useDataLoader((projectId: number, surveyId: number) => biohubApi.survey.getSurveyForUpdate(projectId, surveyId) ); - if (!editSurveyDL.data && surveyId) { - editSurveyDL.load(projectContext.projectId, surveyId); + if (surveyId) { + editSurveyDataLoader.load(projectContext.projectId, surveyId); } - const surveyData = editSurveyDL.data?.surveyData; - useEffect(() => { - const setFormikValues = (data: IEditSurveyRequest) => { - formikRef.current?.setValues(data); - }; - - if (editSurveyDL.data) { - setFormikValues(editSurveyDL.data.surveyData as unknown as IEditSurveyRequest); - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [editSurveyDL]); + const surveyData = editSurveyDataLoader.data?.surveyData; const defaultCancelDialogProps = { dialogTitle: EditSurveyI18N.cancelTitle, diff --git a/app/src/features/surveys/list/SurveysListPage.tsx b/app/src/features/surveys/list/SurveysListPage.tsx index dcd0c74470..bf40f160cf 100644 --- a/app/src/features/surveys/list/SurveysListPage.tsx +++ b/app/src/features/surveys/list/SurveysListPage.tsx @@ -31,7 +31,8 @@ const SurveysListPage = () => { }); const [sortModel, setSortModel] = useState([]); - const refreshSurveyList = () => { + // Refresh survey list when pagination or sort changes + useEffect(() => { const sort = firstOrNull(sortModel); const pagination: ApiPaginationRequestOptions = { limit: paginationModel.pageSize, @@ -42,12 +43,10 @@ const SurveysListPage = () => { page: paginationModel.page + 1 }; - return projectContext.surveysListDataLoader.refresh(pagination); - }; + projectContext.surveysListDataLoader.refresh(pagination); - // Refresh survey list when pagination or sort changes - useEffect(() => { - refreshSurveyList(); + // Adding a DataLoader as a dependency causes an infinite rerender loop if a useEffect calls `.refresh` + // eslint-disable-next-line react-hooks/exhaustive-deps }, [sortModel, paginationModel]); const columns: GridColDef[] = [ 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 534247a637..2db627a52c 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 @@ -102,13 +102,17 @@ const SamplingSiteEditMapControl = (props: ISamplingSiteEditMapControlProps) => const [editedGeometry, setEditedGeometry] = useState(undefined); useEffect(() => { - if (editedGeometry) { - updateStaticLayers(editedGeometry); + if (!editedGeometry) { + return; } - }, [editedGeometry]); + + updateStaticLayers(editedGeometry); + }, [editedGeometry, updateStaticLayers]); useEffect(() => { updateStaticLayers(samplingSiteGeoJsonFeatures); + + // eslint-disable-next-line react-hooks/exhaustive-deps }, [samplingSiteGeoJsonFeatures]); return ( diff --git a/app/src/features/surveys/telemetry/ManualTelemetryList.tsx b/app/src/features/surveys/telemetry/ManualTelemetryList.tsx index 251614a94b..6ce2d72913 100644 --- a/app/src/features/surveys/telemetry/ManualTelemetryList.tsx +++ b/app/src/features/surveys/telemetry/ManualTelemetryList.tsx @@ -94,6 +94,9 @@ const ManualTelemetryList = () => { useEffect(() => { surveyContext.deploymentDataLoader.refresh(surveyContext.projectId, surveyContext.surveyId); surveyContext.critterDataLoader.refresh(surveyContext.projectId, surveyContext.surveyId); + + // Adding a DataLoader as a dependency causes an infinite rerender loop if a useEffect calls `.refresh` + // eslint-disable-next-line react-hooks/exhaustive-deps }, []); const deployments = surveyContext.deploymentDataLoader.data; diff --git a/app/src/features/surveys/view/components/Permits.tsx b/app/src/features/surveys/view/components/Permits.tsx index 4a6fe81849..2bcdb9771d 100644 --- a/app/src/features/surveys/view/components/Permits.tsx +++ b/app/src/features/surveys/view/components/Permits.tsx @@ -25,12 +25,12 @@ const Permits = () => { return ( <> - {permit.permits?.map((item) => { + {permit.permits?.map((permit, index) => { return ( - - - {`#${item.permit_number}`} - {item.permit_type} + + + {`#${permit.permit_number}`} + {permit.permit_type} ); diff --git a/database/src/seeds/03_basic_project_survey_setup.ts b/database/src/seeds/03_basic_project_survey_setup.ts index 9518473569..e158ce47d6 100644 --- a/database/src/seeds/03_basic_project_survey_setup.ts +++ b/database/src/seeds/03_basic_project_survey_setup.ts @@ -246,16 +246,21 @@ const insertSurveyFundingData = (surveyId: number) => ` */ const insertSurveyFocalSpeciesData = (surveyId: number) => { const focalSpecies = focalTaxonIdOptions[Math.floor(Math.random() * focalTaxonIdOptions.length)]; + const testValue = [ + 2012, 2013, 828, 2019, 1594, 1718, 2037, 2062, 2068, 2065, 2070, 2069, 23918, 23922, 23920, 35369, 35370, 28516 + ][Math.floor(Math.random() * 18)]; return ` INSERT into study_species ( survey_id, + wldtaxonomic_units_id, itis_tsn, is_focal ) VALUES ( ${surveyId}, + ${testValue}, ${focalSpecies.itis_tsn}, 'Y' ); @@ -614,10 +619,16 @@ const insertObservationSubCount = (surveyObservationId: number) => ` * SQL to insert survey observation data. Requires sampling site, method, period. * */ -const insertSurveyObservationData = (surveyId: number, count: number) => ` +const insertSurveyObservationData = (surveyId: number, count: number) => { + const testValue = [ + 2012, 2013, 828, 2019, 1594, 1718, 2037, 2062, 2068, 2065, 2070, 2069, 23918, 23922, 23920, 35369, 35370, 28516 + ][Math.floor(Math.random() * 18)]; + + return ` INSERT INTO survey_observation ( survey_id, + wldtaxonomic_units_id, itis_tsn, itis_scientific_name, latitude, @@ -632,6 +643,7 @@ const insertSurveyObservationData = (surveyId: number, count: number) => ` VALUES ( ${surveyId}, + ${testValue}, $$${focalTaxonIdOptions[0].itis_tsn}$$, $$${focalTaxonIdOptions[0].itis_scientific_name}$$, $$${faker.number.int({ min: 48, max: 60 })}$$, @@ -658,6 +670,7 @@ const insertSurveyObservationData = (surveyId: number, count: number) => ` ) RETURNING survey_observation_id; `; +}; /** * SQL to insert Project data From 0f534e3284deb51cd107d36b1f692633b8753da1 Mon Sep 17 00:00:00 2001 From: Macgregor Aubertin-Young <108430771+mauberti-bc@users.noreply.github.com> Date: Tue, 26 Mar 2024 11:46:05 -0700 Subject: [PATCH 5/9] Add Response Metric field to Sampling Method (#1253) * Added new "Response Metric" field to Sampling Methods * Updated Method form to include Response Metric dropdown * Updated the method lookup values * Added description field to method lookup table --- api/src/paths/codes.ts | 25 +++- .../survey/{surveyId}/sample-site/index.ts | 11 +- .../sample-site/{surveySampleSiteId}/index.ts | 9 +- .../sample-method/index.test.ts | 1 + .../{surveySampleMethodId}/index.test.ts | 1 + api/src/repositories/code-repository.ts | 30 ++++- .../repositories/observation-repository.ts | 15 ++- .../sample-location-repository.ts | 6 +- .../sample-method-repository.test.ts | 10 +- .../repositories/sample-method-repository.ts | 45 ++++--- .../repositories/sample-period-repository.ts | 49 ++++---- api/src/services/code-service.test.ts | 3 +- api/src/services/code-service.ts | 9 +- api/src/services/eml-service.test.ts | 3 +- .../services/sample-location-service.test.ts | 14 ++- api/src/services/sample-location-service.ts | 5 +- .../services/sample-method-service.test.ts | 13 +- api/src/services/sample-method-service.ts | 4 +- .../services/sample-period-service.test.ts | 6 +- api/src/services/sample-period-service.ts | 6 +- .../data-grid/TextFieldDataGrid.tsx | 1 - .../surveys/components/MethodForm.tsx | 71 ++++++----- .../ObservationsTableContainer.tsx | 12 +- .../GridColumnDefinitions.tsx | 14 +++ .../edit/SamplingSiteEditPage.tsx | 1 + app/src/hooks/api/useSamplingSiteApi.ts | 1 + app/src/interfaces/useCodesApi.interface.ts | 3 +- app/src/interfaces/useSurveyApi.interface.ts | 1 + app/src/test-helpers/code-helpers.ts | 13 +- .../20240309121400_method_response_metric.ts | 110 +++++++++++++++++ .../20240310180000_update_lookup_options.ts | 111 ++++++++++++++++++ .../seeds/03_basic_project_survey_setup.ts | 6 +- 32 files changed, 492 insertions(+), 117 deletions(-) create mode 100644 database/src/migrations/20240309121400_method_response_metric.ts create mode 100644 database/src/migrations/20240310180000_update_lookup_options.ts diff --git a/api/src/paths/codes.ts b/api/src/paths/codes.ts index 424c7b0bc2..a622f14e10 100644 --- a/api/src/paths/codes.ts +++ b/api/src/paths/codes.ts @@ -37,7 +37,8 @@ GET.apiDoc = { 'intended_outcomes', 'vantage_codes', 'site_selection_strategies', - 'survey_progress' + 'survey_progress', + 'method_response_metrics' ], properties: { management_action_type: { @@ -322,6 +323,9 @@ GET.apiDoc = { }, name: { type: 'string' + }, + description: { + type: 'string' } } } @@ -342,6 +346,25 @@ GET.apiDoc = { } } } + }, + method_response_metrics: { + type: 'array', + items: { + type: 'object', + additionalProperties: false, + required: ['id', 'name', 'description'], + properties: { + id: { + type: 'integer' + }, + name: { + type: 'string' + }, + description: { + type: 'string' + } + } + } } } } 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 6ed21900ca..7581776389 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 @@ -112,6 +112,7 @@ GET.apiDoc = { 'survey_sample_method_id', 'survey_sample_site_id', 'method_lookup_id', + 'method_response_metric_id', 'sample_periods' ], items: { @@ -172,7 +173,8 @@ GET.apiDoc = { } } } - } + }, + method_response_metric_id: { type: 'integer', minimum: 1 } } } }, @@ -277,6 +279,7 @@ export function getSurveySampleLocationRecords(): RequestHandler { surveyId, ensureCompletePaginationOptions(paginationOptions) ); + const sampleSitesTotalCount = await sampleLocationService.getSampleLocationsCountBySurveyId(surveyId); await connection.commit(); @@ -365,7 +368,7 @@ POST.apiDoc = { items: { type: 'object', additionalProperties: false, - required: ['method_lookup_id', 'description', 'periods'], + required: ['method_lookup_id', 'description', 'periods', 'method_response_metric_id'], properties: { survey_sample_site_id: { type: 'integer', @@ -418,6 +421,10 @@ POST.apiDoc = { } } } + }, + method_response_metric_id: { + type: 'integer', + minimum: 1 } } } 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 76e82e3e33..bc98f6c6d3 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 @@ -98,8 +98,9 @@ PUT.apiDoc = { minItems: 1, items: { type: 'object', - additionalProperties: false, - required: ['description', 'periods'], + // TODO: Set additionalProperties = false, which requires more extensive changes. + // Removing the restriction here to properly test editing sampling sites + required: ['method_lookup_id', 'description', 'periods', 'method_response_metric_id'], properties: { survey_sample_site_id: { type: 'integer', @@ -153,6 +154,10 @@ PUT.apiDoc = { } } } + }, + method_response_metric_id: { + type: 'integer', + minimum: 1 } } } diff --git a/api/src/paths/project/{projectId}/survey/{surveyId}/sample-site/{surveySampleSiteId}/sample-method/index.test.ts b/api/src/paths/project/{projectId}/survey/{surveyId}/sample-site/{surveySampleSiteId}/sample-method/index.test.ts index 5720d283bf..e7780abe1d 100644 --- a/api/src/paths/project/{projectId}/survey/{surveyId}/sample-site/{surveySampleSiteId}/sample-method/index.test.ts +++ b/api/src/paths/project/{projectId}/survey/{surveyId}/sample-site/{surveySampleSiteId}/sample-method/index.test.ts @@ -70,6 +70,7 @@ describe('getSurveySampleMethodRecords', () => { const sampleMethod = { survey_sample_method_id: 1, survey_sample_site_id: 1, + method_response_metric_id: 1, method_lookup_id: 1, description: 'desc', create_date: 'date', diff --git a/api/src/paths/project/{projectId}/survey/{surveyId}/sample-site/{surveySampleSiteId}/sample-method/{surveySampleMethodId}/index.test.ts b/api/src/paths/project/{projectId}/survey/{surveyId}/sample-site/{surveySampleSiteId}/sample-method/{surveySampleMethodId}/index.test.ts index 1b14548c2a..4e637f8871 100644 --- a/api/src/paths/project/{projectId}/survey/{surveyId}/sample-site/{surveySampleSiteId}/sample-method/{surveySampleMethodId}/index.test.ts +++ b/api/src/paths/project/{projectId}/survey/{surveyId}/sample-site/{surveySampleSiteId}/sample-method/{surveySampleMethodId}/index.test.ts @@ -91,6 +91,7 @@ describe('updateSurveySampleMethod', () => { mockReq.body = { sampleMethod: { method_lookup_id: 1, + method_response_metric_id: 1, description: 'description' } }; diff --git a/api/src/repositories/code-repository.ts b/api/src/repositories/code-repository.ts index 92c31be6f0..6caa261979 100644 --- a/api/src/repositories/code-repository.ts +++ b/api/src/repositories/code-repository.ts @@ -43,8 +43,9 @@ export const IAllCodeSets = z.object({ vantage_codes: CodeSet(), survey_jobs: CodeSet(), site_selection_strategies: CodeSet(), - sample_methods: CodeSet(), - survey_progress: CodeSet(z.object({ id: z.number(), name: z.string(), description: z.string() }).shape) + survey_progress: CodeSet(z.object({ id: z.number(), name: z.string(), description: z.string() }).shape), + sample_methods: CodeSet(z.object({ id: z.number(), name: z.string(), description: z.string() }).shape), + method_response_metrics: CodeSet(z.object({ id: z.number(), name: z.string(), description: z.string() }).shape) }); export type IAllCodeSets = z.infer; @@ -52,7 +53,7 @@ export type IAllCodeSets = z.infer; export class CodeRepository extends BaseRepository { async getSampleMethods() { const sql = SQL` - SELECT method_lookup_id as id, name FROM method_lookup; + SELECT method_lookup_id as id, name, description FROM method_lookup; `; const response = await this.connection.sql(sql); return response.rows; @@ -455,4 +456,27 @@ export class CodeRepository extends BaseRepository { return response.rows; } + + /** + * Fetch method response metrics + * + * @return {Promise} + * @memberof CodeRepository + */ + async getMethodResponseMetrics(): Promise { + const sqlStatement = SQL` + SELECT + method_response_metric_id AS id, + name, + description + FROM + method_response_metric + WHERE + record_end_date IS null; + `; + + const response = await this.connection.sql(sqlStatement, ICodeWithDescription); + + return response.rows; + } } diff --git a/api/src/repositories/observation-repository.ts b/api/src/repositories/observation-repository.ts index bda88129f2..bce7ca911c 100644 --- a/api/src/repositories/observation-repository.ts +++ b/api/src/repositories/observation-repository.ts @@ -78,9 +78,17 @@ export const ObservationRecordWithSamplingAndSubcountData = ObservationRecord.ex export type ObservationRecordWithSamplingAndSubcountData = z.infer; +const GeoJSONPointSchema = z.object({ + type: z + .string() + .optional() + .refine((val) => val === 'Point', { message: 'Type must be "Point"' }), + coordinates: z.array(z.number()).min(2).max(2) // Assuming GeoJSON Point has 2 coordinates (longitude and latitude) +}); + export const ObservationGeometryRecord = z.object({ survey_observation_id: z.number(), - geometry: z.string().transform((jsonString) => JSON.parse(jsonString)) + geometry: GeoJSONPointSchema }); export type ObservationGeometryRecord = z.infer; @@ -447,7 +455,10 @@ export class ObservationRepository extends BaseRepository { const knex = getKnex(); const query = knex - .select('survey_observation_id', knex.raw('ST_AsGeoJSON(ST_MakePoint(longitude, latitude)) as geometry')) + .select( + 'survey_observation_id', + knex.raw("JSON_BUILD_OBJECT('type', 'Point', 'coordinates', JSON_BUILD_ARRAY(longitude, latitude)) as geometry") + ) .from('survey_observation') .where('survey_id', surveyId); diff --git a/api/src/repositories/sample-location-repository.ts b/api/src/repositories/sample-location-repository.ts index c227db2035..d63e9f8a21 100644 --- a/api/src/repositories/sample-location-repository.ts +++ b/api/src/repositories/sample-location-repository.ts @@ -26,7 +26,8 @@ export const SampleLocationRecord = z.object({ survey_sample_method_id: true, survey_sample_site_id: true, method_lookup_id: true, - description: true + description: true, + method_response_metric_id: true }).extend( z.object({ sample_periods: z.array( @@ -160,7 +161,8 @@ export class SampleLocationRepository extends BaseRepository { 'survey_sample_site_id', ssm.survey_sample_site_id, 'method_lookup_id', ssm.method_lookup_id, 'description', ssm.description, - 'sample_periods', COALESCE(wssp.sample_periods, '[]'::json) + 'sample_periods', COALESCE(wssp.sample_periods, '[]'::json), + 'method_response_metric_id', ssm.method_response_metric_id )) as sample_methods`) ) .from({ ssm: 'survey_sample_method' }) diff --git a/api/src/repositories/sample-method-repository.test.ts b/api/src/repositories/sample-method-repository.test.ts index 1135e636e3..258edc06e0 100644 --- a/api/src/repositories/sample-method-repository.test.ts +++ b/api/src/repositories/sample-method-repository.test.ts @@ -50,9 +50,11 @@ describe('SampleMethodRepository', () => { const mockResponse = ({ rows: [mockRow], rowCount: 1 } as any) as Promise>; const dbConnectionObj = getMockDBConnection({ sql: sinon.stub().resolves(mockResponse) }); + const surveyId = 1; const sampleMethod: UpdateSampleMethodRecord = { survey_sample_method_id: 1, survey_sample_site_id: 2, + method_response_metric_id: 1, method_lookup_id: 3, description: 'description', periods: [ @@ -75,7 +77,7 @@ describe('SampleMethodRepository', () => { ] }; const repo = new SampleMethodRepository(dbConnectionObj); - const response = await repo.updateSampleMethod(sampleMethod); + const response = await repo.updateSampleMethod(surveyId, sampleMethod); expect(dbConnectionObj.sql).to.have.been.calledOnce; expect(response).to.eql(mockRow); @@ -85,10 +87,12 @@ describe('SampleMethodRepository', () => { const mockResponse = ({ rows: [], rowCount: 0 } as any) as Promise>; const dbConnectionObj = getMockDBConnection({ sql: sinon.stub().resolves(mockResponse) }); + const surveyId = 1; const sampleMethod: UpdateSampleMethodRecord = { survey_sample_method_id: 1, survey_sample_site_id: 2, method_lookup_id: 3, + method_response_metric_id: 1, description: 'description', periods: [ { @@ -112,7 +116,7 @@ describe('SampleMethodRepository', () => { const repo = new SampleMethodRepository(dbConnectionObj); try { - await repo.updateSampleMethod(sampleMethod); + await repo.updateSampleMethod(surveyId, sampleMethod); } catch (error) { expect((error as ApiExecuteSQLError).message).to.be.eql('Failed to update sample method'); expect(dbConnectionObj.sql).to.have.been.calledOnce; @@ -129,6 +133,7 @@ describe('SampleMethodRepository', () => { const sampleMethod: InsertSampleMethodRecord = { survey_sample_site_id: 2, method_lookup_id: 3, + method_response_metric_id: 1, description: 'description', periods: [ { @@ -160,6 +165,7 @@ describe('SampleMethodRepository', () => { const sampleMethod: InsertSampleMethodRecord = { survey_sample_site_id: 2, + method_response_metric_id: 1, method_lookup_id: 3, description: 'description', periods: [ diff --git a/api/src/repositories/sample-method-repository.ts b/api/src/repositories/sample-method-repository.ts index 45eb9ce9a5..69310e9225 100644 --- a/api/src/repositories/sample-method-repository.ts +++ b/api/src/repositories/sample-method-repository.ts @@ -9,7 +9,7 @@ import { InsertSamplePeriodRecord, UpdateSamplePeriodRecord } from './sample-per */ export type InsertSampleMethodRecord = Pick< SampleMethodRecord, - 'survey_sample_site_id' | 'method_lookup_id' | 'description' + 'survey_sample_site_id' | 'method_lookup_id' | 'description' | 'method_response_metric_id' > & { periods: InsertSamplePeriodRecord[] }; /** @@ -17,7 +17,7 @@ export type InsertSampleMethodRecord = Pick< */ export type UpdateSampleMethodRecord = Pick< SampleMethodRecord, - 'survey_sample_method_id' | 'survey_sample_site_id' | 'method_lookup_id' | 'description' + 'survey_sample_method_id' | 'survey_sample_site_id' | 'method_lookup_id' | 'description' | 'method_response_metric_id' > & { periods: UpdateSamplePeriodRecord[] }; /** @@ -27,6 +27,7 @@ export const SampleMethodRecord = z.object({ survey_sample_method_id: z.number(), survey_sample_site_id: z.number(), method_lookup_id: z.number(), + method_response_metric_id: z.number(), description: z.string(), create_date: z.string(), create_user: z.number(), @@ -87,17 +88,22 @@ export class SampleMethodRepository extends BaseRepository { * @return {*} {Promise} * @memberof SampleMethodRepository */ - async updateSampleMethod(sampleMethod: UpdateSampleMethodRecord): Promise { + async updateSampleMethod(surveyId: number, sampleMethod: UpdateSampleMethodRecord): Promise { const sql = SQL` - UPDATE survey_sample_method + UPDATE survey_sample_method ssm SET - survey_sample_site_id=${sampleMethod.survey_sample_site_id}, - method_lookup_id = ${sampleMethod.method_lookup_id}, - description=${sampleMethod.description} + survey_sample_site_id = ${sampleMethod.survey_sample_site_id}, + method_lookup_id = ${sampleMethod.method_lookup_id}, + description = ${sampleMethod.description}, + method_response_metric_id = ${sampleMethod.method_response_metric_id} + FROM + survey_sample_site sss WHERE - survey_sample_method_id = ${sampleMethod.survey_sample_method_id} - RETURNING - *;`; + ssm.survey_sample_site_id = sss.survey_sample_site_id + AND ssm.survey_sample_method_id = ${sampleMethod.survey_sample_method_id} + AND sss.survey_id = ${surveyId} + RETURNING ssm.*; + `; const response = await this.connection.sql(sql); @@ -123,11 +129,13 @@ export class SampleMethodRepository extends BaseRepository { INSERT INTO survey_sample_method ( survey_sample_site_id, method_lookup_id, - description + description, + method_response_metric_id ) VALUES ( ${sampleMethod.survey_sample_site_id}, ${sampleMethod.method_lookup_id}, - ${sampleMethod.description} + ${sampleMethod.description}, + ${sampleMethod.method_response_metric_id} ) RETURNING *; @@ -154,15 +162,14 @@ export class SampleMethodRepository extends BaseRepository { * @memberof SampleMethodRepository */ async deleteSampleMethodRecord(surveyId: number, surveySampleMethodId: number): Promise { - // @TODO join on surveyId - surveyId; const sqlStatement = SQL` - DELETE FROM - survey_sample_method + DELETE FROM survey_sample_method + USING survey_sample_site sss WHERE - survey_sample_method_id = ${surveySampleMethodId} - RETURNING - *; + survey_sample_method.survey_sample_site_id = sss.survey_sample_site_id + AND survey_sample_method_id = ${surveySampleMethodId} + AND survey_id = ${surveyId} + RETURNING *; `; const response = await this.connection.sql(sqlStatement, SampleMethodRecord); diff --git a/api/src/repositories/sample-period-repository.ts b/api/src/repositories/sample-period-repository.ts index 22c0f0a058..87d8b257c8 100644 --- a/api/src/repositories/sample-period-repository.ts +++ b/api/src/repositories/sample-period-repository.ts @@ -90,25 +90,26 @@ export class SamplePeriodRepository extends BaseRepository { */ async updateSamplePeriod(surveyId: number, samplePeriod: UpdateSamplePeriodRecord): Promise { const sql = SQL` - UPDATE survey_sample_period ssp - SET - survey_sample_method_id=${samplePeriod.survey_sample_method_id}, - start_date=${samplePeriod.start_date}, - end_date=${samplePeriod.end_date}, - start_time=${samplePeriod.start_time || null}, - end_time=${samplePeriod.end_time || null} - FROM - survey_sample_method ssm - JOIN - survey_sample_site sss ON ssm.survey_sample_site_id = sss.survey_sample_site_id - WHERE - ssp.survey_sample_method_id = ssm.survey_sample_method_id - AND - sss.survey_id = ${surveyId} - AND - ssp.survey_sample_period_id = ${samplePeriod.survey_sample_period_id} - RETURNING - ssp.*; + UPDATE survey_sample_period AS ssp + SET + survey_sample_method_id = ${samplePeriod.survey_sample_method_id}, + start_date = ${samplePeriod.start_date}, + end_date = ${samplePeriod.end_date}, + start_time = ${samplePeriod.start_time || null}, + end_time = ${samplePeriod.end_time || null} + FROM + survey_sample_method AS ssm + JOIN + survey_sample_site AS sss ON ssm.survey_sample_site_id = sss.survey_sample_site_id + WHERE + ssp.survey_sample_method_id = ssm.survey_sample_method_id + AND + ssp.survey_sample_period_id = ${samplePeriod.survey_sample_period_id} + AND + sss.survey_id = ${surveyId} + RETURNING + ssp.*; + `; const response = await this.connection.sql(sql, SamplePeriodRecord); @@ -186,6 +187,7 @@ export class SamplePeriodRepository extends BaseRepository { ssp.survey_sample_period_id = ${surveySamplePeriodId} AND sss.survey_id = ${surveyId} + ; `; const response = await this.connection.sql(sqlStatement, SamplePeriodRecord); @@ -207,15 +209,18 @@ export class SamplePeriodRepository extends BaseRepository { * @returns {*} {Promise} an array of promises for the deleted periods * @memberof SamplePeriodRepository */ - async deleteSamplePeriods(periodsToDelete: number[]): Promise { + async deleteSamplePeriods(surveyId: number, periodsToDelete: number[]): Promise { const knex = getKnex(); const sqlStatement = knex .queryBuilder() .delete() - .from('survey_sample_period') + .from('survey_sample_period as ssp') + .leftJoin('survey_sample_method as ssm', 'ssm.survey_sample_method_id', 'ssp.survey_sample_method_id') + .leftJoin('survey_sample_site as sss', 'sss.survey_sample_site_id', 'ssm.survey_sample_site_id') .whereIn('survey_sample_period_id', periodsToDelete) - .returning('*'); + .andWhere('survey_id', surveyId) + .returning('ssp.*'); const response = await this.connection.knex(sqlStatement, SamplePeriodRecord); diff --git a/api/src/services/code-service.test.ts b/api/src/services/code-service.test.ts index 96fdc7b596..fb396dbe02 100644 --- a/api/src/services/code-service.test.ts +++ b/api/src/services/code-service.test.ts @@ -44,7 +44,8 @@ describe('CodeService', () => { 'site_selection_strategies', 'survey_jobs', 'sample_methods', - 'survey_progress' + 'survey_progress', + 'method_response_metrics' ); }); }); diff --git a/api/src/services/code-service.ts b/api/src/services/code-service.ts index 7161f89bd0..266ae2310d 100644 --- a/api/src/services/code-service.ts +++ b/api/src/services/code-service.ts @@ -41,7 +41,8 @@ export class CodeService extends DBService { survey_jobs, site_selection_strategies, sample_methods, - survey_progress + survey_progress, + method_response_metrics ] = await Promise.all([ await this.codeRepository.getManagementActionType(), await this.codeRepository.getFirstNations(), @@ -61,7 +62,8 @@ export class CodeService extends DBService { await this.codeRepository.getSurveyJobs(), await this.codeRepository.getSiteSelectionStrategies(), await this.codeRepository.getSampleMethods(), - await this.codeRepository.getSurveyProgress() + await this.codeRepository.getSurveyProgress(), + await this.codeRepository.getMethodResponseMetrics() ]); return { @@ -83,7 +85,8 @@ export class CodeService extends DBService { survey_jobs, site_selection_strategies, sample_methods, - survey_progress + survey_progress, + method_response_metrics }; } } diff --git a/api/src/services/eml-service.test.ts b/api/src/services/eml-service.test.ts index ccb524ba80..3b57947fe5 100644 --- a/api/src/services/eml-service.test.ts +++ b/api/src/services/eml-service.test.ts @@ -936,7 +936,8 @@ describe.skip('EmlService', () => { site_selection_strategies: [], survey_jobs: [], sample_methods: [], - survey_progress: [] + survey_progress: [], + method_response_metrics: [] }; it('should retrieve codes if _codes is undefined', async () => { diff --git a/api/src/services/sample-location-service.test.ts b/api/src/services/sample-location-service.test.ts index a4a9facceb..703535251b 100644 --- a/api/src/services/sample-location-service.test.ts +++ b/api/src/services/sample-location-service.test.ts @@ -51,6 +51,7 @@ describe('SampleLocationService', () => { { survey_sample_site_id: 1, method_lookup_id: 1, + method_response_metric_id: 1, description: '', periods: [ { @@ -210,8 +211,14 @@ describe('SampleLocationService', () => { const survey_sample_site_id = 1; const methods = [ - { survey_sample_method_id: 2, method_lookup_id: 3, description: 'Cool method', periods: [] } as any, - { method_lookup_id: 4, description: 'Cool method', periods: [] } as any + { + survey_sample_method_id: 2, + method_lookup_id: 3, + method_response_metric_id: 1, + description: 'Cool method', + periods: [] + } as any, + { method_lookup_id: 4, method_response_metric_id: 1, description: 'Cool method', periods: [] } as any ]; const blocks = [ { @@ -310,6 +317,8 @@ describe('SampleLocationService', () => { expect(insertSampleMethodStub).to.be.calledOnceWith({ survey_sample_site_id: survey_sample_site_id, method_lookup_id: 4, + + method_response_metric_id: 1, description: 'Cool method', periods: [] }); @@ -317,6 +326,7 @@ describe('SampleLocationService', () => { survey_sample_site_id: survey_sample_site_id, survey_sample_method_id: 2, method_lookup_id: 3, + method_response_metric_id: 1, description: 'Cool method', periods: [] }); diff --git a/api/src/services/sample-location-service.ts b/api/src/services/sample-location-service.ts index fe310082f1..f7c969f70d 100644 --- a/api/src/services/sample-location-service.ts +++ b/api/src/services/sample-location-service.ts @@ -157,7 +157,8 @@ export class SampleLocationService extends DBService { survey_sample_site_id: sampleSiteRecord.survey_sample_site_id, method_lookup_id: item.method_lookup_id, description: item.description, - periods: item.periods + periods: item.periods, + method_response_metric_id: item.method_response_metric_id }; return methodService.insertSampleMethod(sampleMethod); }) @@ -264,6 +265,7 @@ export class SampleLocationService extends DBService { survey_sample_site_id: sampleSite.survey_sample_site_id, survey_sample_method_id: item.survey_sample_method_id, method_lookup_id: item.method_lookup_id, + method_response_metric_id: item.method_response_metric_id, description: item.description, periods: item.periods }; @@ -272,6 +274,7 @@ export class SampleLocationService extends DBService { const sampleMethod = { survey_sample_site_id: sampleSite.survey_sample_site_id, method_lookup_id: item.method_lookup_id, + method_response_metric_id: item.method_response_metric_id, description: item.description, periods: item.periods }; diff --git a/api/src/services/sample-method-service.test.ts b/api/src/services/sample-method-service.test.ts index eceb67af5c..5cc5296ab2 100644 --- a/api/src/services/sample-method-service.test.ts +++ b/api/src/services/sample-method-service.test.ts @@ -37,6 +37,7 @@ describe('SampleMethodService', () => { survey_sample_method_id: 1, survey_sample_site_id: 2, method_lookup_id: 3, + method_response_metric_id: 1, description: 'description', create_date: '2023-05-06', create_user: 1, @@ -79,6 +80,7 @@ describe('SampleMethodService', () => { survey_sample_method_id: 1, survey_sample_site_id: 2, method_lookup_id: 3, + method_response_metric_id: 1, description: 'description', create_date: '2023-05-06', create_user: 1, @@ -100,8 +102,8 @@ describe('SampleMethodService', () => { const sampleMethodService = new SampleMethodService(mockDBConnection); const response = await sampleMethodService.deleteSampleMethodRecord(mockSurveyId, mockSamplePeriodId); - expect(deleteSampleMethodRecordStub).to.be.calledOnceWith(1001, mockSampleMethodId); - expect(deleteSamplePeriodRecordStub).to.be.calledOnceWith([mockSamplePeriodId]); + expect(deleteSampleMethodRecordStub).to.be.calledOnceWith(mockSurveyId, mockSampleMethodId); + expect(deleteSamplePeriodRecordStub).to.be.calledOnceWith(mockSurveyId, [mockSamplePeriodId]); expect(response).to.eql(mockSampleMethodRecord); }); }); @@ -118,6 +120,7 @@ describe('SampleMethodService', () => { survey_sample_method_id: 1, survey_sample_site_id: 2, method_lookup_id: 3, + method_response_metric_id: 1, description: 'description', create_date: '2023-05-06', create_user: 1, @@ -149,6 +152,7 @@ describe('SampleMethodService', () => { const sampleMethod: InsertSampleMethodRecord = { survey_sample_site_id: 2, method_lookup_id: 3, + method_response_metric_id: 1, description: 'description', periods: [ { @@ -201,6 +205,7 @@ describe('SampleMethodService', () => { survey_sample_method_id: 1, survey_sample_site_id: 2, method_lookup_id: 3, + method_response_metric_id: 1, description: 'description', create_date: '2023-05-06', create_user: 1, @@ -221,6 +226,7 @@ describe('SampleMethodService', () => { survey_sample_method_id: 1, survey_sample_site_id: 2, method_lookup_id: 3, + method_response_metric_id: 1, description: 'description', periods: [ { @@ -243,7 +249,7 @@ describe('SampleMethodService', () => { const sampleMethodService = new SampleMethodService(mockDBConnection); const response = await sampleMethodService.updateSampleMethod(mockSurveyId, sampleMethod); - expect(updateSampleMethodStub).to.be.calledOnceWith(sampleMethod); + expect(updateSampleMethodStub).to.be.calledOnceWith(mockSurveyId, sampleMethod); expect(response).to.eql(mockSampleMethodRecord); }); }); @@ -263,6 +269,7 @@ describe('SampleMethodService', () => { survey_sample_method_id: mockSampleMethodId, survey_sample_site_id: 2, method_lookup_id: 3, + method_response_metric_id: 1, description: 'description', create_date: '2023-05-06', create_user: 1, diff --git a/api/src/services/sample-method-service.ts b/api/src/services/sample-method-service.ts index 8528ee47a9..690f294d58 100644 --- a/api/src/services/sample-method-service.ts +++ b/api/src/services/sample-method-service.ts @@ -58,7 +58,7 @@ export class SampleMethodService extends DBService { ); const periodsToDelete = existingSamplePeriods.map((item) => item.survey_sample_period_id); // Delete all associated sample periods - await samplePeriodService.deleteSamplePeriodRecords(periodsToDelete); + await samplePeriodService.deleteSamplePeriodRecords(surveyId, periodsToDelete); return this.sampleMethodRepository.deleteSampleMethodRecord(surveyId, surveySampleMethodId); } @@ -175,6 +175,6 @@ export class SampleMethodService extends DBService { } } - return this.sampleMethodRepository.updateSampleMethod(sampleMethod); + return this.sampleMethodRepository.updateSampleMethod(surveyId, sampleMethod); } } diff --git a/api/src/services/sample-period-service.test.ts b/api/src/services/sample-period-service.test.ts index a23586e431..75616637fe 100644 --- a/api/src/services/sample-period-service.test.ts +++ b/api/src/services/sample-period-service.test.ts @@ -215,8 +215,10 @@ describe('SamplePeriodService', () => { { survey_sample_period_id: 2 } as SamplePeriodRecord ]); - expect(getSamplePeriodsForSurveyMethodIdStub).to.be.calledOnceWith(1001, surveySampleMethodId); - expect(deleteSamplePeriodRecordsStub).to.be.calledOnceWith([mockSamplePeriodRecords[0].survey_sample_period_id]); + expect(getSamplePeriodsForSurveyMethodIdStub).to.be.calledOnceWith(mockSurveyId, surveySampleMethodId); + expect(deleteSamplePeriodRecordsStub).to.be.calledOnceWith(mockSurveyId, [ + 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 7e662684f4..aa52071045 100644 --- a/api/src/services/sample-period-service.ts +++ b/api/src/services/sample-period-service.ts @@ -58,8 +58,8 @@ export class SamplePeriodService extends DBService { * @returns {*} {Promise} an array of promises for the deleted periods * @memberof SamplePeriodService */ - async deleteSamplePeriodRecords(periodsToDelete: number[]): Promise { - return this.samplePeriodRepository.deleteSamplePeriods(periodsToDelete); + async deleteSamplePeriodRecords(surveyId: number, periodsToDelete: number[]): Promise { + return this.samplePeriodRepository.deleteSamplePeriods(surveyId, periodsToDelete); } /** @@ -121,7 +121,7 @@ export class SamplePeriodService extends DBService { throw new HTTP400('Cannot delete a sample period that is associated with an observation'); } - await this.deleteSamplePeriodRecords(existingSamplePeriodIds); + await this.deleteSamplePeriodRecords(surveyId, existingSamplePeriodIds); } } } diff --git a/app/src/components/data-grid/TextFieldDataGrid.tsx b/app/src/components/data-grid/TextFieldDataGrid.tsx index 414f543c78..7dd0c33ae4 100644 --- a/app/src/components/data-grid/TextFieldDataGrid.tsx +++ b/app/src/components/data-grid/TextFieldDataGrid.tsx @@ -25,7 +25,6 @@ const TextFieldDataGrid = ({ value={dataGridProps.value ?? ''} variant="outlined" type="text" - inputProps={{ inputMode: 'numeric' }} {...textFieldProps} /> ); diff --git a/app/src/features/surveys/components/MethodForm.tsx b/app/src/features/surveys/components/MethodForm.tsx index bc55e8a9ca..b738972a6d 100644 --- a/app/src/features/surveys/components/MethodForm.tsx +++ b/app/src/features/surveys/components/MethodForm.tsx @@ -4,22 +4,17 @@ import Alert from '@mui/material/Alert'; import Box from '@mui/material/Box'; import Button from '@mui/material/Button'; import CircularProgress from '@mui/material/CircularProgress'; -import FormControl from '@mui/material/FormControl'; -import FormHelperText from '@mui/material/FormHelperText'; 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 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 { DateTimeFields } from 'components/fields/DateTimeFields'; +import SelectWithSubtextField, { ISelectWithSubtextFieldOption } from 'components/fields/SelectWithSubtext'; import { CodesContext } from 'contexts/codesContext'; import { default as dayjs } from 'dayjs'; import { FieldArray, FieldArrayRenderProps, useFormikContext } from 'formik'; -import get from 'lodash-es/get'; import { useContext, useEffect } from 'react'; import yup from 'utils/YupSchema'; @@ -38,6 +33,7 @@ export interface ISurveySampleMethodData { method_lookup_id: number | null; description: string; periods: ISurveySampleMethodPeriodData[]; + method_response_metric_id: number | null; } export interface IEditSurveySampleMethodData extends ISurveySampleMethodData { @@ -59,11 +55,16 @@ export const SurveySampleMethodDataInitialValues = { survey_sample_site_id: null, method_lookup_id: null, description: '', - periods: [SurveySampleMethodPeriodArrayItemInitialValues] + periods: [SurveySampleMethodPeriodArrayItemInitialValues], + method_response_metric_id: '' as unknown as null }; export const SamplingSiteMethodYupSchema = yup.object({ method_lookup_id: yup.number().typeError('Method is required').required('Method is required'), + method_response_metric_id: yup + .number() + .typeError('Response Metric is required') + .required('Response Metric is required'), description: yup.string().max(250, 'Maximum 250 characters'), periods: yup .array( @@ -104,9 +105,24 @@ export const SamplingSiteMethodYupSchema = yup.object({ const MethodForm = () => { const formikProps = useFormikContext(); - const { values, errors, touched, handleChange } = formikProps; + const { values, errors } = formikProps; const codesContext = useContext(CodesContext); + + const methodResponseMetricOptions: ISelectWithSubtextFieldOption[] = + codesContext.codesDataLoader.data?.method_response_metrics.map((option) => ({ + value: option.id, + label: option.name, + subText: option.description + })) ?? []; + + const methodOptions: ISelectWithSubtextFieldOption[] = + codesContext.codesDataLoader.data?.sample_methods.map((option) => ({ + value: option.id, + label: option.name, + subText: option.description + })) ?? []; + useEffect(() => { codesContext.codesDataLoader.load(); }, [codesContext.codesDataLoader]); @@ -120,32 +136,23 @@ const MethodForm = () => { Details - - Method Type - - {get(touched, 'method_lookup_id') && get(errors, 'method_lookup_id')} - + + diff --git a/app/src/features/surveys/observations/observations-table/ObservationsTableContainer.tsx b/app/src/features/surveys/observations/observations-table/ObservationsTableContainer.tsx index 9fc1b77509..52fb61fd93 100644 --- a/app/src/features/surveys/observations/observations-table/ObservationsTableContainer.tsx +++ b/app/src/features/surveys/observations/observations-table/ObservationsTableContainer.tsx @@ -11,7 +11,6 @@ import Toolbar from '@mui/material/Toolbar'; import Typography from '@mui/material/Typography'; import { GridColDef } from '@mui/x-data-grid'; import DataGridValidationAlert from 'components/data-grid/DataGridValidationAlert'; -import { CodesContext } from 'contexts/codesContext'; import { IObservationTableRow } from 'contexts/observationsTableContext'; import { SurveyContext } from 'contexts/surveyContext'; import { BulkActionsButton } from 'features/surveys/observations/observations-table/bulk-actions/BulkActionsButton'; @@ -34,7 +33,7 @@ import { } from 'features/surveys/observations/observations-table/grid-column-definitions/GridColumnDefinitions'; import { ImportObservationsButton } from 'features/surveys/observations/observations-table/import-observations/ImportObservationsButton'; import ObservationsTable from 'features/surveys/observations/observations-table/ObservationsTable'; -import { useObservationsTableContext } from 'hooks/useContext'; +import { useCodesContext, useObservationsTableContext } from 'hooks/useContext'; import { IGetSampleLocationDetails, IGetSampleMethodRecord, @@ -44,7 +43,7 @@ import { useContext } from 'react'; import { getCodesName } from 'utils/Utils'; const ObservationComponent = () => { - const codesContext = useContext(CodesContext); + const codesContext = useCodesContext(); const surveyContext = useContext(SurveyContext); @@ -66,7 +65,10 @@ const ObservationComponent = () => { const sampleMethodOptions: ISampleMethodOption[] = 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) ?? '' + sample_method_name: + getCodesName(codesContext.codesDataLoader.data, 'sample_methods', method.method_lookup_id) ?? '', + response_metric: + getCodesName(codesContext.codesDataLoader.data, 'method_response_metrics', method.method_response_metric_id) ?? '' })); // Collect sample periods @@ -88,7 +90,7 @@ const ObservationComponent = () => { SampleSiteColDef({ sampleSiteOptions, hasError: observationsTableContext.hasError }), SampleMethodColDef({ sampleMethodOptions, hasError: observationsTableContext.hasError }), SamplePeriodColDef({ samplePeriodOptions, hasError: observationsTableContext.hasError }), - ObservationCountColDef({ hasError: observationsTableContext.hasError }), + ObservationCountColDef({ sampleMethodOptions, hasError: observationsTableContext.hasError }), ObservationDateColDef({ hasError: observationsTableContext.hasError }), ObservationTimeColDef({ hasError: observationsTableContext.hasError }), ObservationLatitudeColDef({ hasError: observationsTableContext.hasError }), diff --git a/app/src/features/surveys/observations/observations-table/grid-column-definitions/GridColumnDefinitions.tsx b/app/src/features/surveys/observations/observations-table/grid-column-definitions/GridColumnDefinitions.tsx index ab9365ad5a..e981e37880 100644 --- a/app/src/features/surveys/observations/observations-table/grid-column-definitions/GridColumnDefinitions.tsx +++ b/app/src/features/surveys/observations/observations-table/grid-column-definitions/GridColumnDefinitions.tsx @@ -26,6 +26,7 @@ export type ISampleMethodOption = { survey_sample_method_id: number; survey_sample_site_id: number; sample_method_name: string; + response_metric: string; }; export type ISamplePeriodOption = { @@ -205,6 +206,7 @@ export const SamplePeriodColDef = (props: { }; export const ObservationCountColDef = (props: { + sampleMethodOptions: ISampleMethodOption[]; hasError: (params: GridCellParams) => boolean; }): GridColDef => { const { hasError } = props; @@ -227,10 +229,22 @@ export const ObservationCountColDef = (props: { renderEditCell: (params) => { const error: boolean = hasError(params); + const maxCount = + props.sampleMethodOptions.find( + (option) => option.survey_sample_method_id === params.row.survey_sample_method_id + )?.response_metric === 'Presence-absence' + ? 1 + : undefined; + return ( { if (!/^\d{0,7}$/.test(event.target.value)) { diff --git a/app/src/features/surveys/observations/sampling-sites/edit/SamplingSiteEditPage.tsx b/app/src/features/surveys/observations/sampling-sites/edit/SamplingSiteEditPage.tsx index d9735b7a63..1bc280978d 100644 --- a/app/src/features/surveys/observations/sampling-sites/edit/SamplingSiteEditPage.tsx +++ b/app/src/features/surveys/observations/sampling-sites/edit/SamplingSiteEditPage.tsx @@ -65,6 +65,7 @@ const SamplingSiteEditPage = () => { survey_sample_method_id: item.survey_sample_method_id, survey_sample_site_id: item.survey_sample_site_id, method_lookup_id: item.method_lookup_id, + method_response_metric_id: item.method_response_metric_id, description: item.description, periods: item.sample_periods || [] }; diff --git a/app/src/hooks/api/useSamplingSiteApi.ts b/app/src/hooks/api/useSamplingSiteApi.ts index 6047092bdd..2eab339c6d 100644 --- a/app/src/hooks/api/useSamplingSiteApi.ts +++ b/app/src/hooks/api/useSamplingSiteApi.ts @@ -35,6 +35,7 @@ const useSamplingSiteApi = (axios: AxiosInstance) => { */ const getSampleSites = async (projectId: number, surveyId: number): Promise => { const { data } = await axios.get(`/api/project/${projectId}/survey/${surveyId}/sample-site`); + return data; }; diff --git a/app/src/interfaces/useCodesApi.interface.ts b/app/src/interfaces/useCodesApi.interface.ts index 51443c4aca..ec4845fec5 100644 --- a/app/src/interfaces/useCodesApi.interface.ts +++ b/app/src/interfaces/useCodesApi.interface.ts @@ -38,6 +38,7 @@ export interface IGetAllCodeSetsResponse { vantage_codes: CodeSet; survey_jobs: CodeSet; site_selection_strategies: CodeSet; - sample_methods: CodeSet; survey_progress: CodeSet<{ id: number; name: string; description: string }>; + sample_methods: CodeSet<{ id: number; name: string; description: string }>; + method_response_metrics: CodeSet<{ id: number; name: string; description: string }>; } diff --git a/app/src/interfaces/useSurveyApi.interface.ts b/app/src/interfaces/useSurveyApi.interface.ts index ef7ce15ffd..f26bdab803 100644 --- a/app/src/interfaces/useSurveyApi.interface.ts +++ b/app/src/interfaces/useSurveyApi.interface.ts @@ -403,6 +403,7 @@ export interface IGetSampleMethodRecord { survey_sample_method_id: number; survey_sample_site_id: number; method_lookup_id: number; + method_response_metric_id: number; description: string; create_date: string; create_user: number; diff --git a/app/src/test-helpers/code-helpers.ts b/app/src/test-helpers/code-helpers.ts index 787885f737..61b4e85863 100644 --- a/app/src/test-helpers/code-helpers.ts +++ b/app/src/test-helpers/code-helpers.ts @@ -53,8 +53,15 @@ export const codes: IGetAllCodeSetsResponse = { { id: 2, name: 'Strategy 2' } ], sample_methods: [ - { id: 1, name: 'Camera Trap' }, - { id: 2, name: 'Dip Net' } + { id: 1, name: 'Camera Trap', description: 'Description 1' }, + { id: 2, name: 'Aerial Transect', description: 'Description 2' } ], - survey_progress: [{ id: 1, name: 'Planning', description: 'Description 1' }] + survey_progress: [ + { id: 1, name: 'Planning', description: 'Description 1' }, + { id: 1, name: 'In progress', description: 'Description 1' } + ], + method_response_metrics: [ + { id: 1, name: 'Count', description: 'Description 1' }, + { id: 2, name: 'Presence-absence', description: 'Description 2' } + ] }; diff --git a/database/src/migrations/20240309121400_method_response_metric.ts b/database/src/migrations/20240309121400_method_response_metric.ts new file mode 100644 index 0000000000..58e49ea70b --- /dev/null +++ b/database/src/migrations/20240309121400_method_response_metric.ts @@ -0,0 +1,110 @@ +import { Knex } from 'knex'; + +/** + * Create new tables with initial seed data: + * - method_response_metric + * + * Update existing tables: + * - Add 'method_response_metric_id' column to Survey table + * + * @export + * @param {Knex} knex + * @return {*} {Promise} + */ +export async function up(knex: Knex): Promise { + await knex.raw(`--sql + ---------------------------------------------------------------------------------------- + -- Create method response metric lookup table + ---------------------------------------------------------------------------------------- + SET search_path = biohub; + + CREATE TABLE method_response_metric ( + method_response_metric_id integer GENERATED ALWAYS AS IDENTITY (START WITH 1 INCREMENT BY 1), + name varchar(32) NOT NULL, + description varchar(128), + record_end_date timestamptz(6), + create_date timestamptz(6) DEFAULT now() NOT NULL, + create_user integer NOT NULL, + update_date timestamptz(6), + update_user integer, + revision_count integer DEFAULT 0 NOT NULL, + CONSTRAINT method_response_metric_id_pk PRIMARY KEY (method_response_metric_id) + ); + + COMMENT ON TABLE method_response_metric IS 'This table is intended to store options that users can select for the response metric of a sampling method.'; + COMMENT ON COLUMN method_response_metric.method_response_metric_id IS 'Primary key for method_response_metric.'; + COMMENT ON COLUMN method_response_metric.name IS 'Name of the response metric option.'; + COMMENT ON COLUMN method_response_metric.description IS 'Description of the response metric option.'; + COMMENT ON COLUMN method_response_metric.record_end_date IS 'End date of the response metric option.'; + COMMENT ON COLUMN method_response_metric.create_date IS 'The datetime the record was created.'; + COMMENT ON COLUMN method_response_metric.create_user IS 'The id of the user who created the record as identified in the system user table.'; + COMMENT ON COLUMN method_response_metric.update_date IS 'The datetime the record was updated.'; + COMMENT ON COLUMN method_response_metric.update_user IS 'The id of the user who updated the record as identified in the system user table.'; + COMMENT ON COLUMN method_response_metric.revision_count IS 'Revision count used for concurrency control.'; + + ---------------------------------------------------------------------------------------- + -- Add triggers for user data + ---------------------------------------------------------------------------------------- + CREATE TRIGGER audit_method_response_metric BEFORE INSERT OR UPDATE OR DELETE ON biohub.method_response_metric FOR EACH ROW EXECUTE PROCEDURE tr_audit_trigger(); + CREATE TRIGGER journal_method_response_metric AFTER INSERT OR UPDATE OR DELETE ON biohub.method_response_metric FOR EACH ROW EXECUTE PROCEDURE tr_journal_trigger(); + + ---------------------------------------------------------------------------------------- + -- Add initial values + ---------------------------------------------------------------------------------------- + INSERT INTO method_response_metric (name, description) + VALUES + ( + 'Count', + 'Counting the number of individuals at a sampling site.' + ), + ( + 'Presence-absence', + 'Recording the presence or absence of species at a sampling site, irrespective of abundance.' + ), + ( + 'Percent cover', + 'Estimating the percentage area that a species covers at a sampling site.' + ), + ( + 'Biomass', + 'Measuring the weight or biomass of a species at a sampling site.' + ); + + ---------------------------------------------------------------------------------------- + -- Modify sample method table to include method_response_metric + ---------------------------------------------------------------------------------------- + ALTER TABLE survey_sample_method ADD COLUMN method_response_metric_id INTEGER; + COMMENT ON COLUMN survey_sample_method.method_response_metric_id IS 'Foreign key referencing the response metric value.'; + + -- Add initial values to survey_sample_method table + UPDATE survey_sample_method + SET method_response_metric_id = ( + SELECT method_response_metric_id + FROM method_response_metric + WHERE name = 'Count' + ); + + -- Add not null constraint + ALTER TABLE survey_sample_method ALTER COLUMN method_response_metric_id SET NOT NULL; + ALTER TABLE survey_sample_method ADD CONSTRAINT method_response_metric_fk FOREIGN KEY (method_response_metric_id) REFERENCES method_response_metric(method_response_metric_id); + + -- Add index + CREATE INDEX method_response_metric_idx1 ON survey_sample_method(method_response_metric_id); + + ---------------------------------------------------------------------------------------- + -- Add view for method_response_metric table + ---------------------------------------------------------------------------------------- + SET SEARCH_PATH=biohub_dapi_v1; + + CREATE VIEW method_response_metric AS SELECT * FROM biohub.method_response_metric; + + ---------------------------------------------------------------------------------------- + -- Replace survey_sample_method view to include method_response_metric_id + ---------------------------------------------------------------------------------------- + CREATE OR REPLACE VIEW survey_sample_method AS SELECT * FROM biohub.survey_sample_method; + `); +} + +export async function down(knex: Knex): Promise { + await knex.raw(``); +} diff --git a/database/src/migrations/20240310180000_update_lookup_options.ts b/database/src/migrations/20240310180000_update_lookup_options.ts new file mode 100644 index 0000000000..a0390ee6cb --- /dev/null +++ b/database/src/migrations/20240310180000_update_lookup_options.ts @@ -0,0 +1,111 @@ +import { Knex } from 'knex'; + +/** + * + * Update lookup values: + * - Update method_lookup options + * - Update site_strategy options + * + * Update existing tables: + * - Add description column to method_lookup + * + * @export + * @param {Knex} knex + * @return {*} {Promise} + */ +export async function up(knex: Knex): Promise { + await knex.raw(`--sql + ---------------------------------------------------------------------------------------- + -- Add description to method lookup table + ---------------------------------------------------------------------------------------- + SET search_path = biohub; + + ALTER TABLE method_lookup ADD COLUMN description VARCHAR(512); + COMMENT ON COLUMN method_lookup.description IS 'Description of the method lookup option.'; + + ---------------------------------------------------------------------------------------- + -- Update existing method_lookup values + ---------------------------------------------------------------------------------------- + -- Update 'Electrofishing' + UPDATE method_lookup + SET name = 'Electrofishing', + description = 'Recording observations of aquatic species temporarily stunned with an electrical current.' + WHERE name = 'Electro Fishing'; + + -- Update 'Camera Trap' + UPDATE method_lookup + SET name = 'Camera trap', description = 'Recording observations of species using a motion-activated camera attached to a fixed object like a tree.' + WHERE name = 'Camera Trap'; + + -- Update 'Box Trap' and its description + UPDATE method_lookup + SET name = 'Box or live trap', + description = 'Capturing species in a box-like structure that closes shut when a species enters the trap.' + WHERE name = 'Box Trap'; + + -- Delete 'Dip Net' + DELETE FROM method_lookup + WHERE name = 'Dip Net'; + + + ---------------------------------------------------------------------------------------- + -- Add new method_lookup values + ---------------------------------------------------------------------------------------- + INSERT INTO method_lookup (name, description) + VALUES + ( + 'Aerial transect', + 'Recording observations from an aircraft or drone along a specific path.' + ), + ( + 'Ground transect', + 'Recording observations from the ground along a specific path, travelling either on foot or by vehicle.' + ), + ( + 'Aquatic transect', + 'Recording observations from the water along a specific path, travelling either on foot or by watercraft.' + ), + ( + 'Underwater transect', + 'Recording observations under the water along a specific path.' + ), + ( + 'Quadrat', + 'Recording observations within a square or rectangular frame placed on the ground.' + ), + ( + 'Mist net', + 'Capturing species in a mesh net suspended between two poles or trees.' + ), + ( + 'Point count', + 'Recording observations of species while standing in a fixed location.' + ), + ( + 'Pitfall trap', + 'Capturing species in a container or hole that organisms fall into and are unable to escape from.' + ); + + ---------------------------------------------------------------------------------------- + -- Add site_strategy values + ---------------------------------------------------------------------------------------- + INSERT INTO site_strategy (name, description, record_effective_date) + VALUES + ( + 'Opportunistic', + 'Selecting sites haphazardly without a probability-based strategy.', + 'NOW()' + ); + + ---------------------------------------------------------------------------------------- + -- Replace method_lookup view to include description + ---------------------------------------------------------------------------------------- + SET SEARCH_PATH=biohub_dapi_v1; + + CREATE OR REPLACE VIEW method_lookup AS SELECT * FROM biohub.method_lookup; + `); +} + +export async function down(knex: Knex): Promise { + await knex.raw(``); +} diff --git a/database/src/seeds/03_basic_project_survey_setup.ts b/database/src/seeds/03_basic_project_survey_setup.ts index e158ce47d6..8f7523804a 100644 --- a/database/src/seeds/03_basic_project_survey_setup.ts +++ b/database/src/seeds/03_basic_project_survey_setup.ts @@ -566,13 +566,15 @@ const insertSurveySamplingMethodData = (surveyId: number) => ( survey_sample_site_id, method_lookup_id, - description + description, + method_response_metric_id ) VALUES ( (SELECT survey_sample_site_id FROM survey_sample_site WHERE survey_id = ${surveyId} LIMIT 1), (SELECT method_lookup_id FROM method_lookup ORDER BY random() LIMIT 1), - $$${faker.lorem.sentences(2)}$$ + $$${faker.lorem.sentences(2)}$$, + $$${faker.number.int({ min: 1, max: 4 })}$$ ); `; From 3551469266ca8ebd3562d373b9c178fb8a977d3c Mon Sep 17 00:00:00 2001 From: Al Rosenthal Date: Tue, 26 Mar 2024 14:45:41 -0700 Subject: [PATCH 6/9] SIMSBIOHUB-470: Observation Table Measurement Validation (#1257) SIMSBIOHUB-470: Observation table measurement validation - Added support to validate measurements in table - Added validation to observation upsert endpoint --------- Co-authored-by: Kjartan <35311998+KjartanE@users.noreply.github.com> Co-authored-by: Nick Phura --- .../{surveyId}/observations/index.test.ts | 36 ++- .../survey/{surveyId}/observations/index.ts | 19 +- api/src/services/observation-service.ts | 63 ++++- api/src/utils/xlsx-utils/worksheet-utils.ts | 122 +++++---- app/src/constants/i18n.ts | 7 +- app/src/contexts/observationsTableContext.tsx | 153 +++++++----- .../ObservationsTableContainer.tsx | 3 +- .../configure-table/ConfigureColumns.tsx | 6 +- .../ConfigureColumnsContainer.tsx | 24 +- .../ConfigureColumnsPopoverContent.tsx | 8 +- .../GridColumnDefinitions.tsx | 8 +- .../GridColumnDefinitionsUtils.tsx | 36 +-- .../ObservationRowValidationUtils.ts | 236 ++++++++++++++++++ app/src/hooks/cb_api/useXrefApi.tsx | 1 - 14 files changed, 544 insertions(+), 178 deletions(-) create mode 100644 app/src/features/surveys/observations/observations-table/observation-row-validation/ObservationRowValidationUtils.ts diff --git a/api/src/paths/project/{projectId}/survey/{surveyId}/observations/index.test.ts b/api/src/paths/project/{projectId}/survey/{surveyId}/observations/index.test.ts index e3ad0897bf..d1224647a8 100644 --- a/api/src/paths/project/{projectId}/survey/{surveyId}/observations/index.test.ts +++ b/api/src/paths/project/{projectId}/survey/{surveyId}/observations/index.test.ts @@ -5,6 +5,7 @@ import sinonChai from 'sinon-chai'; import * as db from '../../../../../../database/db'; import { HTTPError } from '../../../../../../errors/http-error'; import { ObservationRecordWithSamplingAndSubcountData } from '../../../../../../repositories/observation-repository'; +import { CBMeasurementUnit, CritterbaseService } from '../../../../../../services/critterbase-service'; import { ObservationService } from '../../../../../../services/observation-service'; import { PlatformService } from '../../../../../../services/platform-service'; import { getMockDBConnection, getRequestHandlerMocks } from '../../../../../../__mocks__/db'; @@ -26,6 +27,28 @@ describe('insertUpdateSurveyObservationsWithMeasurements', () => { .stub(ObservationService.prototype, 'insertUpdateSurveyObservationsWithMeasurements') .resolves(); + sinon.stub(CritterbaseService.prototype, 'getTaxonMeasurements').resolves({ + qualitative: [ + { + itis_tsn: 1, + taxon_measurement_id: '', + measurement_name: '', + measurement_desc: '', + options: [] + } + ], + quantitative: [ + { + itis_tsn: 1, + taxon_measurement_id: '', + measurement_name: '', + measurement_desc: '', + min_value: 0, + max_value: 100, + unit: CBMeasurementUnit.Values.centimeter + } + ] + }); const { mockReq, mockRes, mockNext } = getRequestHandlerMocks(); mockReq.params = { @@ -48,8 +71,7 @@ describe('insertUpdateSurveyObservationsWithMeasurements', () => { survey_sample_method_id: 1, survey_sample_period_id: 1 }, - qualitative_measurements: [], - quantitative_measurements: [] + subcounts: [] }, { standardColumns: { @@ -64,8 +86,7 @@ describe('insertUpdateSurveyObservationsWithMeasurements', () => { survey_sample_method_id: 1, survey_sample_period_id: 1 }, - qualitative_measurements: [], - quantitative_measurements: [] + subcounts: [] } ]; @@ -117,8 +138,7 @@ describe('insertUpdateSurveyObservationsWithMeasurements', () => { survey_sample_method_id: 1, survey_sample_site_id: 1 }, - qualitative_measurements: [], - quantitative_measurements: [] + subcounts: [] } ] }; @@ -131,7 +151,9 @@ describe('insertUpdateSurveyObservationsWithMeasurements', () => { } catch (actualError) { expect(dbConnectionObj.release).to.have.been.called; - expect((actualError as HTTPError).message).to.equal('a test error'); + expect((actualError as HTTPError).message).to.equal( + 'Error connecting to the Critterbase API: Unknown Error: API request failed with status code undefined' + ); } }); }); diff --git a/api/src/paths/project/{projectId}/survey/{surveyId}/observations/index.ts b/api/src/paths/project/{projectId}/survey/{surveyId}/observations/index.ts index 920d2e4404..89cc829626 100644 --- a/api/src/paths/project/{projectId}/survey/{surveyId}/observations/index.ts +++ b/api/src/paths/project/{projectId}/survey/{surveyId}/observations/index.ts @@ -7,7 +7,11 @@ import { paginationResponseSchema } from '../../../../../../openapi/schemas/pagination'; import { authorizeRequestHandler } from '../../../../../../request-handlers/security/authorization'; -import { ObservationService } from '../../../../../../services/observation-service'; +import { CritterbaseService } from '../../../../../../services/critterbase-service'; +import { + InsertUpdateObservationsWithMeasurements, + ObservationService +} from '../../../../../../services/observation-service'; import { getLogger } from '../../../../../../utils/logger'; import { ensureCompletePaginationOptions, makePaginationResponse } from '../../../../../../utils/pagination'; import { ApiPaginationOptions } from '../../../../../../zod-schema/pagination'; @@ -668,7 +672,18 @@ export function insertUpdateSurveyObservationsWithMeasurements(): RequestHandler const observationService = new ObservationService(connection); - const observationRows = req.body.surveyObservations; + const observationRows: InsertUpdateObservationsWithMeasurements[] = req.body.surveyObservations; + + const critterBaseService = new CritterbaseService({ + keycloak_guid: req['system_user']?.user_guid, + username: req['system_user']?.user_identifier + }); + + // Validate measurement data against fetched measurement definition + const isValid = await observationService.validateSurveyObservations(observationRows, critterBaseService); + if (!isValid) { + throw new Error('Failed to save observation data, measurement values failed validation.'); + } await observationService.insertUpdateSurveyObservationsWithMeasurements(surveyId, observationRows); diff --git a/api/src/services/observation-service.ts b/api/src/services/observation-service.ts index 36f8471034..ac9c11b52a 100644 --- a/api/src/services/observation-service.ts +++ b/api/src/services/observation-service.ts @@ -22,14 +22,17 @@ import { constructWorksheets, constructXLSXWorkbook, findMeasurementFromTsnMeasurements, + getCBMeasurementsFromTSN, getCBMeasurementsFromWorksheet, getMeasurementColumnNameFromWorksheet, getWorksheetRowObjects, + IMeasurementDataToValidate, isMeasurementCBQualitativeTypeDefinition, IXLSXCSVValidator, TsnMeasurementMap, validateCsvFile, validateCsvMeasurementColumns, + validateMeasurements, validateWorksheetColumnTypes, validateWorksheetHeaders } from '../utils/xlsx-utils/worksheet-utils'; @@ -500,18 +503,19 @@ export class ObservationService extends DBService { return; } - const measurement = findMeasurementFromTsnMeasurements( - String(row['ITIS_TSN'] ?? row['TSN'] ?? row['TAXON'] ?? row['SPECIES']), - mColumn, - tsnMeasurements - ); - const rowData = row[mColumn]; // Ignore empty rows if (rowData === undefined) { return; } + + const measurement = findMeasurementFromTsnMeasurements( + String(row['ITIS_TSN'] ?? row['TSN'] ?? row['TAXON'] ?? row['SPECIES']), + mColumn, + tsnMeasurements + ); + // Ignore empty measurements if (!measurement) { return; @@ -588,11 +592,56 @@ export class ObservationService extends DBService { */ async deleteObservationsByIds(surveyId: number, observationIds: number[]): Promise { // Remove any existing child subcount records (observation_subcount, subcount_event, subcount_critter) before - // deleteing survey_observation records + // deleting survey_observation records const service = new SubCountService(this.connection); await service.deleteObservationSubCountRecords(surveyId, observationIds); // Delete survey_observation records return this.observationRepository.deleteObservationsByIds(surveyId, observationIds); } + + /** + * Validates given observations against measurement definitions found in Critterbase. + * This validation is all or nothing, any failed validation will return a false value and stop processing. + * + * @param {InsertUpdateObservationsWithMeasurements[]} observationRows The observations to validate + * @param {CritterbaseService} critterBaseService Used to collection measurement definitions to validate against + * @returns {*} boolean True: Observations are valid False: Observations are invalid + */ + async validateSurveyObservations( + observationRows: InsertUpdateObservationsWithMeasurements[], + critterBaseService: CritterbaseService + ): Promise { + // Fetch measurement definitions from CritterBase + const tsns = observationRows.map((item: any) => String(item.standardColumns.itis_tsn)); + const tsnMeasurementsMap = await getCBMeasurementsFromTSN(tsns, critterBaseService); + + // Map observation subcount data into objects to a IMeasurementDataToValidate array + const measurementsToValidate: IMeasurementDataToValidate[] = observationRows.flatMap( + (item: InsertUpdateObservationsWithMeasurements) => { + return item.subcounts.flatMap((subcount) => { + const qualitativeValues = subcount.qualitative.map((qualitative) => { + return { + tsn: String(item.standardColumns.itis_tsn), + measurement_key: qualitative.measurement_id, + measurement_value: qualitative.measurement_option_id + }; + }); + + const quantitativeValues: IMeasurementDataToValidate[] = subcount.quantitative.map((quantitative) => { + return { + tsn: String(item.standardColumns.itis_tsn), + measurement_key: quantitative.measurement_id, + measurement_value: quantitative.measurement_value + }; + }); + + return [...qualitativeValues, ...quantitativeValues]; + }); + } + ); + + // Validate measurement data against fetched measurement definition + return validateMeasurements(measurementsToValidate, tsnMeasurementsMap); + } } diff --git a/api/src/utils/xlsx-utils/worksheet-utils.ts b/api/src/utils/xlsx-utils/worksheet-utils.ts index b3a85264aa..d2de0527dd 100644 --- a/api/src/utils/xlsx-utils/worksheet-utils.ts +++ b/api/src/utils/xlsx-utils/worksheet-utils.ts @@ -307,11 +307,13 @@ export function validateCsvFile( ): boolean { // Validate the worksheet headers if (!validateWorksheetHeaders(xlsxWorksheets[sheet], columnValidator)) { + defaultLog.debug({ label: 'validateCsvFile', message: 'Invalid: Headers' }); return false; } // Validate the worksheet column types if (!validateWorksheetColumnTypes(xlsxWorksheets[sheet], columnValidator)) { + defaultLog.debug({ label: 'validateCsvFile', message: 'Invalid: Column types' }); return false; } @@ -320,32 +322,40 @@ export function validateCsvFile( export interface IMeasurementDataToValidate { tsn: string; - measurement_name: string; + measurement_key: string; // Column name, Grid table field or measurement_taxon_id to validate measurement_value: string | number; } +/** + * Checks if all passed in measurement data is valid or returns false at first invalid measurement. + * + * @param {IMeasurementDataToValidate[]} data The measurement data to validate + * @param {TsnMeasurementMap} tsnMeasurementMap An object map of measurement definitions from Critterbase organized by TSN numbers + * @returns {*} boolean Results of validation + */ export function validateMeasurements( data: IMeasurementDataToValidate[], tsnMeasurementMap: TsnMeasurementMap ): boolean { return data.every((item) => { - defaultLog.debug({ label: 'validateMeasurements', message: 'validating item', item }); const measurements = tsnMeasurementMap[item.tsn]; if (!measurements) { - defaultLog.debug({ label: 'validateMeasurements', message: 'no measurements found for tsn', tsn: item.tsn }); + defaultLog.debug({ label: 'validateMeasurements', message: 'Invalid: No measurements' }); return false; } + // only validate if the column has data if (!item.measurement_value) { - // only validate if the column has data return true; } // find the correct measurement if (measurements.qualitative.length > 0) { const measurement = measurements.qualitative.find( - (measurement) => measurement.measurement_name.toLowerCase() === item.measurement_name.toLowerCase() + (measurement) => + measurement.measurement_name.toLowerCase() === item.measurement_key.toLowerCase() || + measurement.taxon_measurement_id === item.measurement_key ); if (measurement) { return isQualitativeValueValid(item.measurement_value, measurement); @@ -354,7 +364,9 @@ export function validateMeasurements( if (measurements.quantitative.length > 0) { const measurement = measurements.quantitative.find( - (measurement) => measurement.measurement_name.toLowerCase() === item.measurement_name.toLowerCase() + (measurement) => + measurement.measurement_name.toLowerCase() === item.measurement_key.toLowerCase() || + measurement.taxon_measurement_id === item.measurement_key ); if (measurement) { return isQuantitativeValueValid(Number(item.measurement_value), measurement); @@ -363,11 +375,7 @@ export function validateMeasurements( // Has measurements for tsn // Has data but no matches found, entry is invalid - defaultLog.debug({ - label: 'validateMeasurements', - message: 'no matching measurement found for tsn', - tsn: item.tsn - }); + defaultLog.debug({ label: 'validateMeasurements', message: 'Invalid', item }); return false; }); } @@ -388,7 +396,7 @@ export function validateCsvMeasurementColumns( const mappedData: IMeasurementDataToValidate[] = rows.flatMap((row) => { return measurementColumns.map((mColumn) => ({ tsn: String(row['ITIS_TSN'] ?? row['TSN'] ?? row['TAXON'] ?? row['SPECIES']), - measurement_name: mColumn, + measurement_key: mColumn, measurement_value: row[mColumn] })); }); @@ -425,12 +433,7 @@ export function isQuantitativeValueValid(value: number, measurement: CBQuantitat return true; } - defaultLog.debug({ - label: 'isQuantitativeValueValid', - message: 'quantitative measurement error', - value, - measurement - }); + defaultLog.debug({ label: 'isQuantitativeValueValid', message: 'Invalid', value, measurement }); return false; } @@ -447,13 +450,20 @@ export function isQualitativeValueValid( value: string | number, measurement: CBQualitativeMeasurementTypeDefinition ): boolean { - // check if data is in the options for the + // Check for option value, label OR option uuid const foundOption = measurement.options.find( - (option) => option.option_value === value || option.option_label.toLowerCase() === String(value).toLowerCase() + (option) => + option.option_value === Number(value) || + option.option_label.toLowerCase() === String(value).toLowerCase() || + option.qualitative_option_id.toLowerCase() === String(value) ); - defaultLog.debug({ label: 'isQualitativeValueValid', message: 'qualitative measurement error', value, measurement }); - return Boolean(foundOption); + if (foundOption) { + return true; + } + + defaultLog.debug({ label: 'isQualitativeValueValid', message: 'Invalid', value, measurement }); + return false; } /** @@ -499,17 +509,30 @@ export async function getCBMeasurementsFromWorksheet( critterBaseService: CritterbaseService, sheet = DEFAULT_XLSX_SHEET_NAME ): Promise { - const tsnMeasurements: TsnMeasurementMap = {}; const rows = getWorksheetRowObjects(xlsxWorksheets[sheet]); + const tsns = rows.map((row) => String(row['ITIS_TSN'] ?? row['TSN'] ?? row['TAXON'] ?? row['SPECIES'])); + return getCBMeasurementsFromTSN(tsns, critterBaseService); +} +/** + * Fetches measurement definitions from critterbase for a given list of TSNs and creates and returns a map with all data fetched + * Throws if a TSN does not return measurements. + * + * @param {string[]} tsns List of TSNs + * @param {CritterbaseService} critterBaseService Critterbase service + * @returns {*} Promise + */ +export async function getCBMeasurementsFromTSN( + tsns: string[], + critterBaseService: CritterbaseService +): Promise { + const tsnMeasurements: TsnMeasurementMap = {}; try { - for (const row of rows) { - const tsn = String(row['ITIS_TSN'] ?? row['TSN'] ?? row['TAXON'] ?? row['SPECIES']); + for (const tsn of tsns) { if (!tsnMeasurements[tsn]) { - // TODO: modify Critter Base to accept multiple TSN at once const measurements = await critterBaseService.getTaxonMeasurements(tsn); if (!measurements) { - // TODO: Any data for a TSN that has no measurements is invalid and should reject the uploaded CSV + throw new Error(`No measurements found for tsn: ${tsn}`); } tsnMeasurements[tsn] = measurements; @@ -517,9 +540,8 @@ export async function getCBMeasurementsFromWorksheet( } } catch (error) { getLogger('utils/xlsx-utils').error({ label: 'getCBMeasurementsFromWorksheet', message: 'error', error }); - throw new ApiGeneralError('Error connecting to the Critterbase API'); + throw new ApiGeneralError(`Error connecting to the Critterbase API: ${error}`); } - return tsnMeasurements; } @@ -536,29 +558,37 @@ export function findMeasurementFromTsnMeasurements( measurementColumnName: string, tsnMeasurements: TsnMeasurementMap ): CBQuantitativeMeasurementTypeDefinition | CBQualitativeMeasurementTypeDefinition | null | undefined { - let foundMeasurement: - | CBQuantitativeMeasurementTypeDefinition - | CBQualitativeMeasurementTypeDefinition - | null - | undefined = null; const measurements = tsnMeasurements[tsn]; - if (measurements) { - // find the correct measurement - if (measurements.qualitative.length > 0) { - foundMeasurement = measurements.qualitative.find( - (measurement) => measurement.measurement_name.toLowerCase() === measurementColumnName.toLowerCase() - ); + + if (!measurements) { + // No measurements for tsn + return null; + } + + if (measurements.qualitative.length > 0) { + const qualitativeMeasurement = measurements.qualitative.find( + (measurement) => measurement.measurement_name.toLowerCase() === measurementColumnName.toLowerCase() + ); + + if (qualitativeMeasurement) { + // Found qualitative measurement for tsn + return qualitativeMeasurement; } + } - // don't bother searching if we already found a measurement - if (measurements.quantitative.length > 0 && !foundMeasurement) { - foundMeasurement = measurements.quantitative.find( - (measurement) => measurement.measurement_name.toLowerCase() === measurementColumnName.toLowerCase() - ); + if (measurements.quantitative.length > 0) { + const quantitativeMeasurement = measurements.quantitative.find( + (measurement) => measurement.measurement_name.toLowerCase() === measurementColumnName.toLowerCase() + ); + + if (quantitativeMeasurement) { + // Found quantitative measurement for tsn + return quantitativeMeasurement; } } - return foundMeasurement; + // No measurements found for tsn + return null; } /** diff --git a/app/src/constants/i18n.ts b/app/src/constants/i18n.ts index 4c2ff91d4b..3772a0d3ce 100644 --- a/app/src/constants/i18n.ts +++ b/app/src/constants/i18n.ts @@ -315,7 +315,12 @@ export const ObservationsTableI18N = { // Import observation records importRecordsSuccessSnackbarMessage: 'Observations imported successfully.', importRecordsErrorDialogTitle: 'Error Importing Observation Records', - importRecordsErrorDialogText: 'An error occurred while importing observation records.' + importRecordsErrorDialogText: 'An error occurred while importing observation records.', + + // Fetching TSN Measurements from CritterBase error + fetchingTSNMeasurementErrorDialogTitle: 'Error fetching measurement validation', + fetchingTSNMeasurementErrorDialogText: + 'An error occurred while fetching measurement data from Critterbase. The selected taxon may not be supported. Please try again. If the error persists, please contact your system administrator.' }; export const TelemetryTableI18N = { diff --git a/app/src/contexts/observationsTableContext.tsx b/app/src/contexts/observationsTableContext.tsx index 4b2234cc9c..0219e6634c 100644 --- a/app/src/contexts/observationsTableContext.tsx +++ b/app/src/contexts/observationsTableContext.tsx @@ -1,7 +1,6 @@ import Typography from '@mui/material/Typography'; import { GridCellParams, - GridColDef, GridColumnVisibilityModel, GridPaginationModel, GridRowId, @@ -15,16 +14,19 @@ import { GridApiCommunity } from '@mui/x-data-grid/internals'; import { ObservationsTableI18N } from 'constants/i18n'; import { getSurveySessionStorageKey, SIMS_OBSERVATIONS_MEASUREMENT_COLUMNS } from 'constants/session-storage'; import { DialogContext } from 'contexts/dialogContext'; -import { default as dayjs } from 'dayjs'; import { - getMeasurementColumns, isQualitativeMeasurementTypeDefinition, isQuantitativeMeasurementTypeDefinition } from 'features/surveys/observations/observations-table/grid-column-definitions/GridColumnDefinitionsUtils'; +import { + validateObservationTableRow, + validateObservationTableRowMeasurements +} from 'features/surveys/observations/observations-table/observation-row-validation/ObservationRowValidationUtils'; import { APIError } from 'hooks/api/useAxios'; import { IObservationTableRowToSave, SubcountToSave } from 'hooks/api/useObservationApi'; import { useBiohubApi } from 'hooks/useBioHubApi'; import { useObservationsContext, useTaxonomyContext } from 'hooks/useContext'; +import { useCritterbaseApi } from 'hooks/useCritterbaseApi'; import { CBMeasurementType, CBMeasurementValue, @@ -55,16 +57,27 @@ export type StandardObservationColumns = { export type SubcountObservationColumns = { observation_subcount_id: number | null; subcount: number | null; + qualitative_measurements: { + field: string; + critterbase_taxon_measurement_id: string; + critterbase_measurement_qualitative_option_id: string; + }[]; + quantitative_measurements: { + critterbase_taxon_measurement_id: string; + value: number; + }[]; [key: string]: any; }; -export type ObservationRecord = StandardObservationColumns & SubcountObservationColumns; - -export type MeasurementColumn = { - measurement: CBMeasurementType; - colDef: GridColDef; +export type TSNMeasurement = { + qualitative: CBQualitativeMeasurementTypeDefinition[]; + quantitative: CBQuantitativeMeasurementTypeDefinition[]; }; +export type TSNMeasurementMap = Record; + +export type ObservationRecord = StandardObservationColumns & SubcountObservationColumns; + export type SupplementaryObservationCountData = { observationCount: number; }; @@ -235,11 +248,11 @@ export type IObservationsTableContext = { /** * User-added measurement columns that are not part of the default observation table columns. */ - measurementColumns: MeasurementColumn[]; + measurementColumns: CBMeasurementType[]; /** * Sets the user-added measurement columns. */ - setMeasurementColumns: React.Dispatch>; + setMeasurementColumns: React.Dispatch>; /** * Used to disable the entire table. */ @@ -265,6 +278,7 @@ export const ObservationsTableContextProvider = (props: PropsWithChildren({}); // Stores any measurement columns that are not part of the default observation table columns - const [measurementColumns, setMeasurementColumns] = useState([]); + const [measurementColumns, setMeasurementColumns] = useState([]); const _hasLoadedMeasurementColumns = useRef(false); // Global disabled state for the observations table @@ -321,6 +335,9 @@ export const ObservationsTableContextProvider = (props: PropsWithChildren([{ field: 'observation_date', sort: 'desc' }]); + // TSN Measurement Map + const tsnMeasurementMapRef = useRef({}); + /** * Returns true if the given row has a validation error. * @@ -381,10 +398,7 @@ export const ObservationsTableContextProvider = (props: PropsWithChildren => { + const currentMap = tsnMeasurementMapRef.current; + if (!currentMap[tsn]) { + const response = await critterbaseApi.xref.getTaxonMeasurements(tsn); + + currentMap[String(tsn)] = response; + tsnMeasurementMapRef.current = currentMap; + } + return currentMap[tsn]; + }, []); + /** * Validates all rows belonging to the table. Returns null if validation passes, otherwise * returns the validation model */ - const _validateRows = useCallback((): ObservationTableValidationModel | null => { + const _validateRows = useCallback(async (): Promise => { const rowValues = _getRowsWithEditedValues(); const tableColumns = _muiDataGridApiRef.current.getAllColumns?.() ?? []; @@ -433,52 +461,33 @@ export const ObservationsTableContextProvider = (props: PropsWithChildren { - 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 && !dayjs(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' }); - } + // build an array of all the standard non measurement columns + const nonMeasurementColumns: string[] = [ + '__check__', // add check box column to filter out when looking for measurement columns + 'actions', // actions column (trash can) to filter out when looking for measurement columns + ...(requiredColumns as string[]), + ...(samplingRequiredColumns as string[]) + ]; - if (rowErrors.length > 0) { - tableModel[row.id] = rowErrors; + // filter all table columns out that do not appear in the nonMeasurementColumns array + const measurementColumns = tableColumns + .filter((tc) => { + return nonMeasurementColumns.indexOf(String(tc.field)) < 0; + }) + .map((item) => item.field); + const validation: ObservationTableValidationModel = {}; + for (const row of rowValues) { + // check standard required columns + const standardColumnErrors = validateObservationTableRow(row, requiredColumns, tableColumns); + + // check any measurement columns found + const measurementErrors = await validateObservationTableRowMeasurements(row, measurementColumns, tsnMeasurements); + + const totalErrors = [...standardColumnErrors, ...measurementErrors]; + if (totalErrors.length > 0) { + validation[row.id] = totalErrors; } - - return tableModel; - }, {}); + } setValidationModel(validation); @@ -771,16 +780,26 @@ export const ObservationsTableContextProvider = (props: PropsWithChildren { + const saveObservationRecords = useCallback(async () => { if (_isStoppingEdit.current) { // Stop edit mode already in progress return; } // Validate rows - const validationErrors = _validateRows(); - - if (validationErrors) { + try { + const validationErrors = await _validateRows(); + if (validationErrors) { + return; + } + } catch (error) { + setErrorDialog({ + onOk: () => setErrorDialog({ open: false }), + onClose: () => setErrorDialog({ open: false }), + dialogTitle: ObservationsTableI18N.fetchingTSNMeasurementErrorDialogTitle, + dialogText: ObservationsTableI18N.fetchingTSNMeasurementErrorDialogText, + open: true + }); return; } @@ -907,13 +926,11 @@ export const ObservationsTableContextProvider = (props: PropsWithChildren { - const measurementDefinitions = measurementColumns.map((item) => item.measurement); - const qualitative: SubcountToSave['qualitative'] = []; const quantitative: SubcountToSave['quantitative'] = []; // For each measurement column in the data grid - for (const measurementDefinition of measurementDefinitions) { + for (const measurementDefinition of measurementColumns) { // If the row has a non-null/non-undefined value for the measurement column if (row[measurementDefinition.taxon_measurement_id]) { // If the measurement column is a qualitative measurement, add it to the qualitative array @@ -1123,7 +1140,7 @@ export const ObservationsTableContextProvider = (props: PropsWithChildren { const codesContext = useCodesContext(); @@ -96,7 +97,7 @@ const ObservationComponent = () => { ObservationLatitudeColDef({ hasError: observationsTableContext.hasError }), ObservationLongitudeColDef({ hasError: observationsTableContext.hasError }), // Add measurement columns to the table - ...observationsTableContext.measurementColumns.map((item) => item.colDef), + ...getMeasurementColumnDefinitions(observationsTableContext.measurementColumns, observationsTableContext.hasError), ObservationActionsColDef({ disabled: observationsTableContext.isSaving, onDelete: observationsTableContext.deleteObservationRecords diff --git a/app/src/features/surveys/observations/observations-table/configure-table/ConfigureColumns.tsx b/app/src/features/surveys/observations/observations-table/configure-table/ConfigureColumns.tsx index c701351123..6083fb11a4 100644 --- a/app/src/features/surveys/observations/observations-table/configure-table/ConfigureColumns.tsx +++ b/app/src/features/surveys/observations/observations-table/configure-table/ConfigureColumns.tsx @@ -3,7 +3,7 @@ import Icon from '@mdi/react'; import IconButton from '@mui/material/IconButton'; import Popover from '@mui/material/Popover'; import { GridColDef } from '@mui/x-data-grid'; -import { IObservationTableRow, MeasurementColumn } from 'contexts/observationsTableContext'; +import { IObservationTableRow } from 'contexts/observationsTableContext'; import { ConfigureColumnsPopoverContent } from 'features/surveys/observations/observations-table/configure-table/ConfigureColumnsPopoverContent'; import { CBMeasurementType } from 'interfaces/useCritterApi.interface'; import { useState } from 'react'; @@ -59,10 +59,10 @@ export interface IConfigureColumnsProps { /** * The measurement columns to render in the table. * - * @type {MeasurementColumn[]} + * @type {CBMeasurementType[]} * @memberof IConfigureColumnsProps */ - measurementColumns: MeasurementColumn[]; + measurementColumns: CBMeasurementType[]; /** * Callback fired when a measurement column is removed. * diff --git a/app/src/features/surveys/observations/observations-table/configure-table/ConfigureColumnsContainer.tsx b/app/src/features/surveys/observations/observations-table/configure-table/ConfigureColumnsContainer.tsx index 45f7b8fcf2..cdab325d5c 100644 --- a/app/src/features/surveys/observations/observations-table/configure-table/ConfigureColumnsContainer.tsx +++ b/app/src/features/surveys/observations/observations-table/configure-table/ConfigureColumnsContainer.tsx @@ -4,10 +4,9 @@ import { SIMS_OBSERVATIONS_HIDDEN_COLUMNS, SIMS_OBSERVATIONS_MEASUREMENT_COLUMNS } from 'constants/session-storage'; -import { IObservationTableRow, MeasurementColumn } from 'contexts/observationsTableContext'; +import { IObservationTableRow } from 'contexts/observationsTableContext'; import { SurveyContext } from 'contexts/surveyContext'; import { ConfigureColumns } from 'features/surveys/observations/observations-table/configure-table/ConfigureColumns'; -import { getMeasurementColumns } from 'features/surveys/observations/observations-table/grid-column-definitions/GridColumnDefinitionsUtils'; import { useObservationsTableContext } from 'hooks/useContext'; import { CBMeasurementType } from 'interfaces/useCritterApi.interface'; import { useCallback, useContext, useEffect, useMemo } from 'react'; @@ -102,13 +101,13 @@ export const ConfigureColumnsContainer = (props: IConfigureColumnsContainerProps // Remove the measurement columns from the table context observationsTableContext.setMeasurementColumns((currentColumns) => { const remainingColumns = currentColumns.filter( - (currentColumn) => !measurementColumnsToRemove.includes(currentColumn.colDef.field) + (currentColumn) => !measurementColumnsToRemove.includes(currentColumn.taxon_measurement_id) ); // Store all remaining measurement definitions in local storage sessionStorage.setItem( getSurveySessionStorageKey(surveyId, SIMS_OBSERVATIONS_MEASUREMENT_COLUMNS), - JSON.stringify(remainingColumns.map((column) => column.measurement)) + JSON.stringify(remainingColumns) ); return remainingColumns; @@ -129,26 +128,19 @@ export const ConfigureColumnsContainer = (props: IConfigureColumnsContainerProps return; } - // Transform the measurement definitions into measurement columns to add to the table - const measurementColumnsToAdd: MeasurementColumn[] = getMeasurementColumns( - measurements, - observationsTableContext.hasError - ); - // Add the measurement columns to the table context observationsTableContext.setMeasurementColumns((currentColumns) => { - const newColumns = measurementColumnsToAdd.filter( + const newColumns = measurements.filter( (columnToAdd) => - !currentColumns.find((currentColumn) => currentColumn.colDef.field === columnToAdd.colDef.field) + !currentColumns.find( + (currentColumn) => currentColumn.taxon_measurement_id === columnToAdd.taxon_measurement_id + ) ); // Store all measurement definitions in local storage sessionStorage.setItem( getSurveySessionStorageKey(surveyId, SIMS_OBSERVATIONS_MEASUREMENT_COLUMNS), - JSON.stringify([ - ...currentColumns.map((column) => column.measurement), - ...newColumns.map((column) => column.measurement) - ]) + JSON.stringify([...currentColumns, ...newColumns]) ); return [...currentColumns, ...newColumns]; diff --git a/app/src/features/surveys/observations/observations-table/configure-table/ConfigureColumnsPopoverContent.tsx b/app/src/features/surveys/observations/observations-table/configure-table/ConfigureColumnsPopoverContent.tsx index 501809758e..93aee4c374 100644 --- a/app/src/features/surveys/observations/observations-table/configure-table/ConfigureColumnsPopoverContent.tsx +++ b/app/src/features/surveys/observations/observations-table/configure-table/ConfigureColumnsPopoverContent.tsx @@ -14,7 +14,7 @@ import ListItemText from '@mui/material/ListItemText'; import Stack from '@mui/material/Stack'; import Typography from '@mui/material/Typography'; import { GridColDef } from '@mui/x-data-grid'; -import { IObservationTableRow, MeasurementColumn } from 'contexts/observationsTableContext'; +import { IObservationTableRow } from 'contexts/observationsTableContext'; import { MeasurementsButton } from 'features/surveys/observations/observations-table/configure-table/measurements/dialog/MeasurementsButton'; import { CBMeasurementType } from 'interfaces/useCritterApi.interface'; import ExportHeadersButton from '../export-button/ExportHeadersButton'; @@ -26,7 +26,7 @@ export interface IConfigureColumnsPopoverContentProps { onToggledShowHideAll: () => void; disabledAddMeasurements: boolean; disabledRemoveMeasurements: boolean; - measurementColumns: MeasurementColumn[]; + measurementColumns: CBMeasurementType[]; onRemoveMeasurements: (measurementFields: string[]) => void; onAddMeasurements: (measurements: CBMeasurementType[]) => void; } @@ -60,7 +60,7 @@ export const ConfigureColumnsPopoverContent = (props: IConfigureColumnsPopoverCo item.measurement)} + selectedMeasurements={measurementColumns} onAddMeasurements={onAddMeasurements} /> @@ -108,7 +108,7 @@ export const ConfigureColumnsPopoverContent = (props: IConfigureColumnsPopoverCo item.colDef.field === column.field) && ( + measurementColumns.some((item) => item.taxon_measurement_id === column.field) && ( boolean; }): GridColDef => { const { measurement, hasError } = props; - return { field: measurement.taxon_measurement_id, headerName: measurement.measurement_name, @@ -575,7 +574,6 @@ export const ObservationQualitativeMeasurementColDef = (props: { label: item.option_label, value: item.qualitative_option_id })); - return { field: measurement.taxon_measurement_id, headerName: measurement.measurement_name, @@ -593,9 +591,9 @@ export const ObservationQualitativeMeasurementColDef = (props: { ); }, renderEditCell: (params) => { - const error = hasError(params); - - return ; + return ( + + ); } }; }; diff --git a/app/src/features/surveys/observations/observations-table/grid-column-definitions/GridColumnDefinitionsUtils.tsx b/app/src/features/surveys/observations/observations-table/grid-column-definitions/GridColumnDefinitionsUtils.tsx index bdde31bea2..0555f70233 100644 --- a/app/src/features/surveys/observations/observations-table/grid-column-definitions/GridColumnDefinitionsUtils.tsx +++ b/app/src/features/surveys/observations/observations-table/grid-column-definitions/GridColumnDefinitionsUtils.tsx @@ -1,5 +1,5 @@ -import { GridCellParams } from '@mui/x-data-grid'; -import { MeasurementColumn } from 'contexts/observationsTableContext'; +import { GridCellParams, GridColDef } from '@mui/x-data-grid'; +import { IObservationTableRow } from 'contexts/observationsTableContext'; import { ObservationQualitativeMeasurementColDef, ObservationQuantitativeMeasurementColDef @@ -78,28 +78,30 @@ export const getQualitativeMeasurementColumn = ( }; }; -/** - * Given an array of measurement type definitions, returns an array of measurement columns. - * - * @param {CBMeasurementType[]} measurements - * @param {(params: GridCellParams) => boolean} hasError - * @return {*} {MeasurementColumn[]} - */ -export const getMeasurementColumns = ( +export const getMeasurementColumnDefinitions = ( measurements: CBMeasurementType[], hasError: (params: GridCellParams) => boolean -): MeasurementColumn[] => { - const measurementColumns: MeasurementColumn[] = []; - +): GridColDef[] => { + const colDefs: GridColDef[] = []; for (const measurement of measurements) { if (isQuantitativeMeasurementTypeDefinition(measurement)) { - measurementColumns.push(getQuantitativeMeasurementColumn(measurement, hasError)); + colDefs.push( + ObservationQuantitativeMeasurementColDef({ + measurement: measurement, + hasError: hasError + }) + ); } if (isQualitativeMeasurementTypeDefinition(measurement)) { - measurementColumns.push(getQualitativeMeasurementColumn(measurement, hasError)); + colDefs.push( + ObservationQualitativeMeasurementColDef({ + measurement: measurement, + measurementOptions: measurement.options, + hasError: hasError + }) + ); } } - - return measurementColumns; + return colDefs; }; diff --git a/app/src/features/surveys/observations/observations-table/observation-row-validation/ObservationRowValidationUtils.ts b/app/src/features/surveys/observations/observations-table/observation-row-validation/ObservationRowValidationUtils.ts new file mode 100644 index 0000000000..7e7e6dfa48 --- /dev/null +++ b/app/src/features/surveys/observations/observations-table/observation-row-validation/ObservationRowValidationUtils.ts @@ -0,0 +1,236 @@ +import { GridColDef } from '@mui/x-data-grid'; +import { IObservationTableRow, ObservationRowValidationError, TSNMeasurement } from 'contexts/observationsTableContext'; +import dayjs from 'dayjs'; +import { + CBQualitativeMeasurementTypeDefinition, + CBQualitativeOption, + CBQuantitativeMeasurementTypeDefinition +} from 'interfaces/useCritterApi.interface'; + +/** + * Validates a given observation table row against the given measurement columns. + * + * @param {IObservationTableRow} row The observation table row to validate + * @param {string[]} measurementColumns A list of the measurement columns to validate + * @param {(tsn: number) => Promise} getTSNMeasurements A function that fetches measurement definitions from Critterbase based on the row itis_tsn value + * @returns {*} Promise + */ +export const validateObservationTableRowMeasurements = async ( + row: IObservationTableRow, + measurementColumns: string[], + getTSNMeasurements: (tsn: number) => Promise +): Promise => { + const measurementErrors: ObservationRowValidationError[] = []; + // no taxon or measurements, nothing to validate + if (!row.itis_tsn || !measurementColumns.length) { + return []; + } + + // Fetch measurement definitions for the provided itis_tsn + const measurements = await getTSNMeasurements(Number(row.itis_tsn)); + if (!measurements) { + return [ + { + field: 'itis_tsn', + message: 'No valid measurements were found for this taxon. Please contact an administrator.' + } + ]; + } + + // go through each measurement on the table and validate against the measurement definition from Critterbase + measurementColumns.forEach((measurementColumn) => { + const data = row[measurementColumn]; + if (data) { + const foundQualitative = measurements.qualitative.find( + (q: CBQualitativeMeasurementTypeDefinition) => q.taxon_measurement_id === measurementColumn + ); + if (foundQualitative) { + const error = _validateQualitativeMeasurement(measurementColumn, String(data), foundQualitative.options); + if (error) { + measurementErrors.push(error); + } + } + + const foundQuantitative = measurements.quantitative.find( + (q: CBQuantitativeMeasurementTypeDefinition) => q.taxon_measurement_id === measurementColumn + ); + if (foundQuantitative) { + const error = _validateQuantitativeMeasurement( + measurementColumn, + Number(data), + foundQuantitative.min_value, + foundQuantitative.max_value + ); + + if (error) { + measurementErrors.push(error); + } + } + + // A measurement column has data but no measurements were found for the itis_tsn + if (!foundQualitative && !foundQuantitative) { + measurementErrors.push({ + field: measurementColumn, + message: `Invalid measurement set for taxon.` + }); + } + } + }); + + return measurementErrors; +}; + +/** + * This validates if the provided option UUID exists within the given list of CBQualitativeOption. + * Returns null if the option is valid or an ObservationRowValidationError if it is not found + * + * @param {string} field The column key for the data being validated + * @param {string} value The options UUID to look for + * @param {CBQualitativeOption[]} options + * @returns {*} ObservationRowValidationError | null + */ +const _validateQualitativeMeasurement = ( + field: string, + value: string, + options: CBQualitativeOption[] +): ObservationRowValidationError | null => { + const foundOption = options.find((op) => op.qualitative_option_id === value); + + if (!foundOption) { + // found measurement, no option, no bueno + return { + field, + message: 'Invalid option selected for taxon.' + }; + } + return null; +}; + +/** + * Validates if a given value is in between min and max values from a Quantitative Measurement definition in Critterbase. + * Returns null if the value is valid or a ObservationRowValidationError describing that the value is out of the valid range. + * + * @param {string} field The column key for the data being validated + * @param {number} value The number to validate + * @param {number | null} minValue The min value provided by the measurement definition from Critterbase + * @param {number | null} maxValue The max value provided by the measurement definition from Critterbase + * @returns {*} ObservationRowValidationError | null + */ +const _validateQuantitativeMeasurement = ( + field: string, + value: number, + minValue: number | null, + maxValue: number | null +): ObservationRowValidationError | null => { + if (minValue && maxValue) { + if (minValue <= value && value <= maxValue) { + return null; + } + } else { + if (minValue !== null && minValue <= value) { + return null; + } + + if (maxValue !== null && value <= maxValue) { + return null; + } + + if (minValue === null && maxValue === null) { + return null; + } + } + + // Measurement values are invalid, create an error and return + return { + field, + message: `Value provided is outside of the valid range [${minValue}, ${maxValue}]` + }; +}; + +/** + * This function will validate sample site data is input correctly. + * If the Sampling Site is set, both method and period become required. + * If no Sampling Site is set, then no sampling columns are required and the data is valid. + * + * @param {IObservationTableRow} row The observation table row to validate + * @param {GridColDef[]} tableColumns Grid column definitions array. Used to find the column field + * @returns {*} ObservationRowValidationError | null + */ +export const findMissingSamplingColumns = ( + row: IObservationTableRow, + tableColumns: GridColDef[] +): ObservationRowValidationError[] => { + const errors: ObservationRowValidationError[] = []; + // if this row has survey_sample_site_id we need to validate that the other 2 sampling columns are also present + if (row['survey_sample_site_id']) { + if (!row['survey_sample_method_id']) { + const header = tableColumns.find((tc) => tc.field === 'survey_sample_method_id')?.headerName; + errors.push({ field: 'survey_sample_method_id', message: `Missing column: ${header}` }); + } + + if (!row['survey_sample_period_id']) { + const header = tableColumns.find((tc) => tc.field === 'survey_sample_period_id')?.headerName; + errors.push({ field: 'survey_sample_period_id', message: `Missing column: ${header}` }); + } + } + + return errors; +}; + +/** + * This function will scan through the provided row and check if any of the required columns are missing (no data set). + * Any missing columns create a ObservationRowValidationError describing the missing column and returns that array. + * + * @param {IObservationTableRow} row The observation table row to validate + * @param {(keyof IObservationTableRow)[]} requiredColumns An array of required columns to look for + * @param {GridColDef[]} tableColumns Grid column definitions array. Used to find the column field + * @returns {*} ObservationRowValidationError[] + */ +export const findMissingColumns = ( + row: IObservationTableRow, + requiredColumns: (keyof IObservationTableRow)[], + tableColumns: GridColDef[] +): ObservationRowValidationError[] => { + const errors: ObservationRowValidationError[] = []; + requiredColumns.forEach((column) => { + if (!row[column]) { + const header = tableColumns.find((tc) => tc.field === column)?.headerName; + errors.push({ field: column, message: `Missing column: ${header}` }); + } + }); + + return errors; +}; + +/** + * This function validates the given row for required columns, sampling columns and the date and time columns. + * An array of errors is returned containing any validation errors. + * + * @param {IObservationTableRow} row The observation row to validate + * @param {(keyof IObservationTableRow)[]} requiredColumns + * @param {GridColDef[]} tableColumns Grid column definitions for the table + * @returns {*} ObservationRowValidationError[] + */ +export const validateObservationTableRow = ( + row: IObservationTableRow, + requiredColumns: (keyof IObservationTableRow)[], + tableColumns: GridColDef[] +): ObservationRowValidationError[] => { + let errors: ObservationRowValidationError[] = []; + const requiredColumnErrors = findMissingColumns(row, requiredColumns, tableColumns); + const samplingColumnErrors = findMissingSamplingColumns(row, tableColumns); + // spread missing column information into single array + errors = [...requiredColumnErrors, ...samplingColumnErrors]; + + // Validate date value + if (row.observation_date && !dayjs(row.observation_date).isValid()) { + errors.push({ field: 'observation_date', message: 'Invalid date' }); + } + + // Validate time value + if (row.observation_time === 'Invalid date') { + errors.push({ field: 'observation_time', message: 'Invalid time' }); + } + + return errors; +}; diff --git a/app/src/hooks/cb_api/useXrefApi.tsx b/app/src/hooks/cb_api/useXrefApi.tsx index b2ad08a348..49bd7fa45c 100644 --- a/app/src/hooks/cb_api/useXrefApi.tsx +++ b/app/src/hooks/cb_api/useXrefApi.tsx @@ -26,7 +26,6 @@ export const useXrefApi = (axios: AxiosInstance) => { searchTerm: string ): Promise => { const { data } = await axios.get(`/api/critterbase/xref/taxon-measurements/search?name=${searchTerm}`); - return data; }; From a2e9422aea3128ca8a6223373c9949ce5be57ae1 Mon Sep 17 00:00:00 2001 From: Curtis Upshall Date: Thu, 28 Mar 2024 13:30:26 -0400 Subject: [PATCH 7/9] SIMSBIOHUB-471: Updates to Critterbase to support ITIS (#1240) SIMS patch for Critterbase ITIS migration - Manage Animals Page + Telemetry Deployments + detailedCritter payloads --- api/src/middleware/critterbase-proxy.test.ts | 114 +++ api/src/middleware/critterbase-proxy.ts | 74 +- api/src/openapi/schemas/critter.ts | 279 ++++++-- .../survey/{surveyId}/critters/index.test.ts | 9 +- .../survey/{surveyId}/critters/index.ts | 20 +- .../{surveyId}/observations/index.test.ts | 2 +- api/src/services/critterbase-service.test.ts | 34 +- api/src/services/critterbase-service.ts | 40 +- api/src/services/geo-service.test.ts | 2 + app/package-lock.json | 18 +- app/src/components/dialog/EditDialog.tsx | 24 +- app/src/components/fields/CbSelectField.tsx | 43 +- .../fields/CbSelectFieldWrapper.tsx | 3 +- .../components/formik/FormikDevDebugger.tsx | 2 +- .../components/MarkerWithResizableRadius.tsx | 40 +- .../species/AncillarySpeciesComponent.tsx | 4 +- .../species/FocalSpeciesComponent.tsx | 4 +- .../SpeciesAutoCompleteFormikField.tsx | 62 ++ .../components/SpeciesAutocompleteField.tsx | 116 +++- app/src/contexts/surveyContext.tsx | 12 +- .../surveys/components/EditDeleteStubCard.tsx | 2 +- .../surveys/telemetry/ManualTelemetryList.tsx | 25 +- .../spatial-data/SurveySpatialData.tsx | 6 +- .../survey-animals/AddEditAnimal.test.tsx | 209 ------ .../view/survey-animals/AddEditAnimal.tsx | 279 -------- .../view/survey-animals/AnimalList.tsx | 80 ++- .../survey-animals/AnimalSection.test.tsx | 147 ++++ .../view/survey-animals/AnimalSection.tsx | 406 +++++++++++ .../survey-animals/AnimalSectionDataCards.tsx | 216 ------ .../survey-animals/AnimalSectionWrapper.tsx | 102 +++ .../survey-animals/GeneralAnimalSummary.tsx | 22 +- .../survey-animals/SurveyAnimalsPage.test.tsx | 21 +- .../view/survey-animals/SurveyAnimalsPage.tsx | 349 ++-------- .../survey-animals/SurveyAnimalsTable.tsx | 5 +- .../animal-form-helpers.test.ts | 333 --------- .../survey-animals/animal-form-helpers.ts | 269 -------- .../view/survey-animals/animal-sections.ts | 258 ------- .../view/survey-animals/animal.test.ts | 209 ------ .../surveys/view/survey-animals/animal.ts | 653 +++++------------- .../form-sections/CaptureAnimalForm.tsx | 351 ++++++++-- .../CollectionUnitAnimalForm.test.tsx | 23 +- .../CollectionUnitAnimalForm.tsx | 141 ++-- .../form-sections/FamilyAnimalForm.test.tsx | 21 +- .../form-sections/FamilyAnimalForm.tsx | 408 ++++++----- .../form-sections/GeneralAnimalForm.tsx | 170 +++-- .../form-sections/LocationEntryForm.tsx | 304 ++------ .../form-sections/MarkingAnimalForm.tsx | 253 ++++--- .../form-sections/MeasurementAnimalForm.tsx | 284 +++++--- .../form-sections/MortalityAnimalForm.tsx | 243 +++++-- .../DeploymentFormSection.tsx | 149 ---- .../TelemetryDeviceFormContent.tsx | 178 ----- app/src/hooks/api/useSurveyApi.test.ts | 28 +- app/src/hooks/api/useSurveyApi.ts | 91 +-- app/src/hooks/cb_api/useAuthenticationApi.tsx | 14 +- app/src/hooks/cb_api/useCaptureApi.tsx | 54 ++ app/src/hooks/cb_api/useCollectionUnitApi.tsx | 49 ++ app/src/hooks/cb_api/useCritterApi.tsx | 57 ++ app/src/hooks/cb_api/useFamilyApi.test.tsx | 4 +- app/src/hooks/cb_api/useFamilyApi.tsx | 134 +++- app/src/hooks/cb_api/useLookupApi.test.tsx | 176 +++-- app/src/hooks/cb_api/useLookupApi.tsx | 95 +-- app/src/hooks/cb_api/useMarkingApi.tsx | 46 ++ app/src/hooks/cb_api/useMarkings.tsx | 14 - app/src/hooks/cb_api/useMeasurementApi.tsx | 110 +++ app/src/hooks/cb_api/useMortalityApi.tsx | 46 ++ app/src/hooks/useCritterbaseApi.ts | 28 +- app/src/hooks/useCritterbaseUserWrapper.tsx | 9 +- app/src/interfaces/useCritterApi.interface.ts | 65 +- app/src/interfaces/useSurveyApi.interface.ts | 6 +- app/src/utils/mapProjectionHelpers.ts | 24 +- .../seeds/03_basic_project_survey_setup.ts | 4 +- 71 files changed, 3650 insertions(+), 4422 deletions(-) create mode 100644 api/src/middleware/critterbase-proxy.test.ts create mode 100644 app/src/components/species/components/SpeciesAutoCompleteFormikField.tsx delete mode 100644 app/src/features/surveys/view/survey-animals/AddEditAnimal.test.tsx delete mode 100644 app/src/features/surveys/view/survey-animals/AddEditAnimal.tsx create mode 100644 app/src/features/surveys/view/survey-animals/AnimalSection.test.tsx create mode 100644 app/src/features/surveys/view/survey-animals/AnimalSection.tsx delete mode 100644 app/src/features/surveys/view/survey-animals/AnimalSectionDataCards.tsx create mode 100644 app/src/features/surveys/view/survey-animals/AnimalSectionWrapper.tsx delete mode 100644 app/src/features/surveys/view/survey-animals/animal-form-helpers.test.ts delete mode 100644 app/src/features/surveys/view/survey-animals/animal-form-helpers.ts delete mode 100644 app/src/features/surveys/view/survey-animals/animal-sections.ts delete mode 100644 app/src/features/surveys/view/survey-animals/animal.test.ts delete mode 100644 app/src/features/surveys/view/survey-animals/telemetry-device/DeploymentFormSection.tsx delete mode 100644 app/src/features/surveys/view/survey-animals/telemetry-device/TelemetryDeviceFormContent.tsx create mode 100644 app/src/hooks/cb_api/useCaptureApi.tsx create mode 100644 app/src/hooks/cb_api/useCollectionUnitApi.tsx create mode 100644 app/src/hooks/cb_api/useCritterApi.tsx create mode 100644 app/src/hooks/cb_api/useMarkingApi.tsx delete mode 100644 app/src/hooks/cb_api/useMarkings.tsx create mode 100644 app/src/hooks/cb_api/useMeasurementApi.tsx create mode 100644 app/src/hooks/cb_api/useMortalityApi.tsx diff --git a/api/src/middleware/critterbase-proxy.test.ts b/api/src/middleware/critterbase-proxy.test.ts new file mode 100644 index 0000000000..a6a01a3c34 --- /dev/null +++ b/api/src/middleware/critterbase-proxy.test.ts @@ -0,0 +1,114 @@ +import { expect } from 'chai'; +import { Request } from 'express'; +import sinon from 'sinon'; +import * as CritterbaseProxy from './critterbase-proxy'; +import { proxyFilter } from './critterbase-proxy'; + +describe('CritterbaseProxy', () => { + describe('proxyFilter', () => { + beforeEach(() => { + sinon.stub(CritterbaseProxy, 'getSimsAppHostUrl').returns('SIMS'); + }); + + afterEach(() => { + sinon.restore(); + }); + + it('should reject all requests not coming from SIMS APP', () => { + expect(proxyFilter('test', { headers: { origin: 'NOT-SIMS' } } as Request)).to.be.false; + }); + it('should allow requests coming from SIMS APP', () => { + expect(proxyFilter('test', { headers: { origin: 'SIMS' } } as Request)).to.be.false; + }); + + it('should allow all GET/POST/PATCH requests', () => { + expect(proxyFilter('test', { method: 'GET', headers: { origin: 'SIMS' } } as Request)).to.be.true; + expect(proxyFilter('test', { method: 'POST', headers: { origin: 'SIMS' } } as Request)).to.be.true; + expect(proxyFilter('test', { method: 'PATCH', headers: { origin: 'SIMS' } } as Request)).to.be.true; + }); + + it('should reject unknown request methods', () => { + expect(proxyFilter('test', { method: 'UNKNOWN', headers: { origin: 'SIMS' } } as Request)).to.be.false; + }); + + it('should allow DELETE requests to capture endpoint', () => { + expect( + proxyFilter('/api/critterbase/captures/id', { + method: 'DELETE', + headers: { origin: 'SIMS' } + } as Request) + ).to.be.true; + + expect( + proxyFilter('/api/critterbase/captures/id/test', { + method: 'DELETE', + headers: { origin: 'SIMS' } + } as Request) + ).to.be.false; + }); + + it('should allow DELETE requests to markings endpoint', () => { + expect( + proxyFilter('/api/critterbase/markings/id', { + method: 'DELETE', + headers: { origin: 'SIMS' } + } as Request) + ).to.be.true; + + expect( + proxyFilter('/api/critterbase/markings/id/test', { + method: 'DELETE', + headers: { origin: 'SIMS' } + } as Request) + ).to.be.false; + }); + + it('should allow DELETE requests to measurement qualitative endpoint', () => { + expect( + proxyFilter('/api/critterbase/measurements/qualitative/id', { + method: 'DELETE', + headers: { origin: 'SIMS' } + } as Request) + ).to.be.true; + + expect( + proxyFilter('/api/critterbase/measurements/qualitative/id/test', { + method: 'DELETE', + headers: { origin: 'SIMS' } + } as Request) + ).to.be.false; + }); + + it('should allow DELETE requests to collection units endpoint', () => { + expect( + proxyFilter('/api/critterbase/collection-units/id', { + method: 'DELETE', + headers: { origin: 'SIMS' } + } as Request) + ).to.be.true; + + expect( + proxyFilter('/api/critterbase/collection-units/id/test', { + method: 'DELETE', + headers: { origin: 'SIMS' } + } as Request) + ).to.be.false; + }); + + it('should allow DELETE requests to collection units endpoint', () => { + expect( + proxyFilter('/api/critterbase/mortality/id', { + method: 'DELETE', + headers: { origin: 'SIMS' } + } as Request) + ).to.be.true; + + expect( + proxyFilter('/api/critterbase/mortality/id/test', { + method: 'DELETE', + headers: { origin: 'SIMS' } + } as Request) + ).to.be.false; + }); + }); +}); diff --git a/api/src/middleware/critterbase-proxy.ts b/api/src/middleware/critterbase-proxy.ts index 9b596582a6..2e9d089793 100644 --- a/api/src/middleware/critterbase-proxy.ts +++ b/api/src/middleware/critterbase-proxy.ts @@ -1,4 +1,4 @@ -import { RequestHandler } from 'express'; +import { Request, RequestHandler } from 'express'; import { createProxyMiddleware } from 'http-proxy-middleware'; import { KeycloakService } from '../services/keycloak-service'; import { HTTP403 } from './../errors/http-error'; @@ -7,29 +7,66 @@ import { authorizeRequest } from './../request-handlers/security/authorization'; import { getLogger } from './../utils/logger'; const defaultLog = getLogger('middleware/critterbase-proxy'); - /** - * Restrict the proxy to these Critterbase routes. + * Currently supported Critterbase delete endpoints. + * */ -const proxyRoutes = [ - '/api/critterbase/signup', - '/api/critterbase/family', - '/api/critterbase/family/:familyId', - '/api/critterbase/lookups/:key', - '/api/critterbase/xref/taxon-marking-body-locations', - '/api/critterbase/xref/taxon-measurements', - '/api/critterbase/xref/taxon-quantitative-measurements', - '/api/critterbase/xref/taxon-qualitative-measurements', - '/api/critterbase/xref/taxon-qualitative-measurement-options', - '/api/critterbase/xref/taxon-measurements/search' +const allowedDeleteRoutesRegex: RegExp[] = [ + /** + * example: allows requests to /api/critterbase/captures/:id + * but rejects requests to /api/critterbase/captures/:id/other-path + * + */ + /^\/api\/critterbase\/captures\/[^/]+$/, + /^\/api\/critterbase\/markings\/[^/]+$/, + /^\/api\/critterbase\/family\/[^/]+$/, + /^\/api\/critterbase\/measurements\/qualitative\/[^/]+$/, + /^\/api\/critterbase\/measurements\/quantitative\/[^/]+$/, + /^\/api\/critterbase\/collection-units\/[^/]+$/, + /^\/api\/critterbase\/mortality\/[^/]+$/ ]; +/** + * Filters requests coming into the CritterbaseProxy. + * Handles different request methods differently. With extra + * scrutiny around delete requests. + * + * @param {string} pathname - Critterbase pathname. + * @param {Request} req - Express request. + * @returns {boolean} If request can be passed to CritterbaeProxy. + */ +export const proxyFilter = (pathname: string, req: Request) => { + // Reject requests NOT coming directly from SIMS APP / frontend. + if (req.headers.origin !== getSimsAppHostUrl()) { + return false; + } + // Only supporting specific delete requests. + if (req.method === 'DELETE') { + return allowedDeleteRoutesRegex.some((regex) => regex.test(pathname)); + } + // Support all POST / PATCH / GET requests. + if (req.method === 'POST' || req.method === 'PATCH' || req.method === 'GET') { + return true; + } + // Block all other requests. + return false; +}; + +/** + * Get the SIMS APP host URL. + * + * @return {*} + */ +export const getSimsAppHostUrl = () => { + return process.env.APP_HOST; +}; + /** * Get the Critterbase API host URL. * * @return {*} */ -const getCritterbaseApiHostUrl = () => { +export const getCritterbaseApiHostUrl = () => { return process.env.CB_API_HOST; }; @@ -86,17 +123,18 @@ export const replaceAuthorizationHeaderMiddleware: RequestHandler = async (req, * needing to re-define every Critterbase endpoint. */ export const getCritterbaseProxyMiddleware = () => - createProxyMiddleware(proxyRoutes, { + createProxyMiddleware(proxyFilter, { target: getCritterbaseApiHostUrl(), + logLevel: 'warn', changeOrigin: true, pathRewrite: async (path) => { - defaultLog.debug({ label: 'pathRewrite', message: 'path', req: path }); + defaultLog.debug({ label: 'onCritterbaseProxyPathRewrite', message: 'path', req: path }); const matchRoutePrefix = /\/api\/critterbase(\/?)(.*)/; return path.replace(matchRoutePrefix, '/$2'); }, onProxyReq: (client, req) => { - defaultLog.debug({ label: 'onProxyReq', message: 'path', req: req.path }); + defaultLog.debug({ label: 'onCritterbaseProxyRequest', message: 'path', req: req.path }); // Set user header as required by Critterbase client.setHeader( diff --git a/api/src/openapi/schemas/critter.ts b/api/src/openapi/schemas/critter.ts index acdcc7e02c..6692c41e0b 100644 --- a/api/src/openapi/schemas/critter.ts +++ b/api/src/openapi/schemas/critter.ts @@ -1,6 +1,6 @@ import { OpenAPIV3 } from 'openapi-types'; -const critterSchema: OpenAPIV3.SchemaObject = { +export const critterSchema: OpenAPIV3.SchemaObject = { type: 'object', additionalProperties: false, properties: { @@ -9,16 +9,30 @@ const critterSchema: OpenAPIV3.SchemaObject = { format: 'uuid' }, animal_id: { - type: 'string' + type: 'string', + nullable: true }, wlh_id: { - type: 'string' + type: 'string', + nullable: true }, itis_tsn: { + type: 'integer' + }, + itis_scientific_name: { type: 'string' }, sex: { type: 'string' + }, + responsible_region_nr_id: { + type: 'string', + format: 'uuid', + nullable: true + }, + critter_comment: { + type: 'string', + nullable: true } } }; @@ -149,10 +163,10 @@ const mortalitySchema: OpenAPIV3.SchemaObject = { mortality_comment: { type: 'string' }, proximate_cause_of_death_id: { type: 'string', format: 'uuid' }, proximate_cause_of_death_confidence: { type: 'string' }, - proximate_predated_by_taxon_id: { type: 'string', format: 'uuid' }, + proximate_predated_by_itis_tsn: { type: 'integer' }, ultimate_cause_of_death_id: { type: 'string', format: 'uuid' }, ultimate_cause_of_death_confidence: { type: 'string' }, - ultimate_predated_by_taxon_id: { type: 'string', format: 'uuid' }, + ultimate_predated_by_itis_tsn: { type: 'integer' }, projection_mode: { type: 'string', enum: ['wgs', 'utm'] }, location: locationSchema } @@ -190,6 +204,30 @@ const quantitativeMeasurmentSchema: OpenAPIV3.SchemaObject = { } }; +export const critterCreateRequestObject: OpenAPIV3.SchemaObject = { + title: 'Create critter request object', + type: 'object', + properties: { + critter_id: { + type: 'string', + format: 'uuid' + }, + animal_id: { + type: 'string' + }, + wlh_id: { + type: 'string' + }, + itis_tsn: { + type: 'integer' + }, + sex: { + type: 'string' + } + }, + additionalProperties: false +}; + export const critterBulkRequestObject: OpenAPIV3.SchemaObject = { title: 'Bulk post request object', type: 'object', @@ -203,6 +241,64 @@ export const critterBulkRequestObject: OpenAPIV3.SchemaObject = { ...critterSchema } }, + families: { + title: 'families', + type: 'object', + properties: { + children: { + type: 'array', + items: { + type: 'object', + properties: { + family_id: { + type: 'string' + }, + child_critter_id: { + type: 'string' + }, + _delete: { + type: 'boolean' + } + }, + additionalProperties: false + } + }, + families: { + type: 'array', + items: { + type: 'object', + properties: { + family_id: { + type: 'string' + }, + family_label: { + type: 'string' + } + }, + additionalProperties: false + } + }, + parents: { + type: 'array', + items: { + type: 'object', + properties: { + family_id: { + type: 'string' + }, + parent_critter_id: { + type: 'string' + }, + _delete: { + type: 'boolean' + } + }, + additionalProperties: false + } + } + }, + additionalProperties: false + }, captures: { title: 'captures', type: 'array', @@ -243,62 +339,110 @@ export const critterBulkRequestObject: OpenAPIV3.SchemaObject = { ...mortalitySchema } }, - families: { - title: 'family', - type: 'object', - additionalProperties: false, - required: ['families', 'parents', 'children'], - properties: { - families: { - type: 'array', - items: { - type: 'object', - additionalProperties: false, - properties: { - family_id: { - type: 'string', - format: 'uuid' - }, - family_label: { - type: 'string' - } - } + qualitative_measurements: { + title: 'qualitative measurements', + type: 'array', + items: { + title: 'qualitative measurement', + ...qualitativeMeasurementSchema + } + }, + quantitative_measurements: { + title: 'quantitative measurements', + type: 'array', + items: { + title: 'quantitative measurement', + ...quantitativeMeasurmentSchema + } + } + } +}; + +export const critterBulkRequestPatchObject: OpenAPIV3.SchemaObject = { + title: 'Bulk post request object', + type: 'object', + properties: { + critters: { + title: 'critters', + type: 'array', + items: { + title: 'critter', + ...critterSchema + } + }, + captures: { + title: 'captures', + type: 'array', + items: { + title: 'capture', + type: 'object', + properties: { + ...captureSchema.properties, + _delete: { + type: 'boolean' } }, - parents: { - type: 'array', - items: { - type: 'object', - additionalProperties: false, - properties: { - family_id: { - type: 'string', - format: 'uuid' - }, - parent_critter_id: { - type: 'string', - format: 'uuid' - } - } + additionalProperties: false + } + }, + collections: { + title: 'collection units', + type: 'array', + items: { + title: 'collection unit', + type: 'object', + properties: { + ...collectionUnits.properties, + _delete: { + type: 'boolean' } }, - children: { - type: 'array', - items: { - type: 'object', - additionalProperties: false, - properties: { - family_id: { - type: 'string', - format: 'uuid' - }, - child_critter_id: { - type: 'string', - format: 'uuid' - } - } + additionalProperties: false + } + }, + markings: { + title: 'markings', + type: 'array', + items: { + title: 'marking', + type: 'object', + properties: { + ...markingSchema.properties, + _delete: { + type: 'boolean' } - } + }, + additionalProperties: false + } + }, + locations: { + title: 'locations', + type: 'array', + items: { + title: 'location', + type: 'object', + properties: { + ...locationSchema.properties, + _delete: { + type: 'boolean' + } + }, + additionalProperties: false + } + }, + mortalities: { + title: 'locations', + type: 'array', + items: { + title: 'location', + type: 'object', + properties: { + ...mortalitySchema.properties, + _delete: { + type: 'boolean' + } + }, + additionalProperties: false } }, qualitative_measurements: { @@ -306,7 +450,14 @@ export const critterBulkRequestObject: OpenAPIV3.SchemaObject = { type: 'array', items: { title: 'qualitative measurement', - ...qualitativeMeasurementSchema + type: 'object', + properties: { + ...qualitativeMeasurementSchema.properties, + _delete: { + type: 'boolean' + } + }, + additionalProperties: false } }, quantitative_measurements: { @@ -314,10 +465,18 @@ export const critterBulkRequestObject: OpenAPIV3.SchemaObject = { type: 'array', items: { title: 'quantitative measurement', - ...quantitativeMeasurmentSchema + type: 'object', + properties: { + ...quantitativeMeasurmentSchema.properties, + _delete: { + type: 'boolean' + } + }, + additionalProperties: false } } - } + }, + additionalProperties: false }; const bulkResponseCounts: OpenAPIV3.SchemaObject = { @@ -329,7 +488,7 @@ const bulkResponseCounts: OpenAPIV3.SchemaObject = { captures: { type: 'integer' }, markings: { type: 'integer' }, locations: { type: 'integer' }, - moralities: { type: 'integer' }, + mortalities: { type: 'integer' }, collections: { type: 'integer' }, quantitative_measurements: { type: 'integer' }, qualitative_measurements: { type: 'integer' }, diff --git a/api/src/paths/project/{projectId}/survey/{surveyId}/critters/index.test.ts b/api/src/paths/project/{projectId}/survey/{surveyId}/critters/index.test.ts index 2f7660ee01..7386aaa590 100644 --- a/api/src/paths/project/{projectId}/survey/{surveyId}/critters/index.test.ts +++ b/api/src/paths/project/{projectId}/survey/{surveyId}/critters/index.test.ts @@ -20,7 +20,9 @@ describe('getCrittersFromSurvey', () => { const mockGetCrittersInSurvey = sinon .stub(SurveyCritterService.prototype, 'getCrittersInSurvey') .resolves([mockSurveyCritter]); - const mockFilterCritters = sinon.stub(CritterbaseService.prototype, 'filterCritters').resolves([mockCBCritter]); + const mockGetMultipleCrittersByIds = sinon + .stub(CritterbaseService.prototype, 'getMultipleCrittersByIds') + .resolves([mockCBCritter]); const { mockReq, mockRes, mockNext } = getRequestHandlerMocks(); const requestHandler = getCrittersFromSurvey(); @@ -28,7 +30,7 @@ describe('getCrittersFromSurvey', () => { expect(mockGetDBConnection.calledOnce).to.be.true; expect(mockGetCrittersInSurvey.calledOnce).to.be.true; - expect(mockFilterCritters.calledOnce).to.be.true; + expect(mockGetMultipleCrittersByIds).to.be.calledOnceWith([mockSurveyCritter.critterbase_critter_id]); expect(mockRes.json).to.have.been.calledWith([ { ...mockCBCritter, survey_critter_id: mockSurveyCritter.critter_id } ]); @@ -37,7 +39,7 @@ describe('getCrittersFromSurvey', () => { it('returns empty array if no critters in survey', async () => { const mockGetDBConnection = sinon.stub(db, 'getDBConnection').returns(mockDBConnection); const mockGetCrittersInSurvey = sinon.stub(SurveyCritterService.prototype, 'getCrittersInSurvey').resolves([]); - const mockFilterCritters = sinon.stub(CritterbaseService.prototype, 'filterCritters').resolves([]); + const { mockReq, mockRes, mockNext } = getRequestHandlerMocks(); const requestHandler = getCrittersFromSurvey(); @@ -45,7 +47,6 @@ describe('getCrittersFromSurvey', () => { expect(mockGetDBConnection.calledOnce).to.be.true; expect(mockGetCrittersInSurvey.calledOnce).to.be.true; - expect(mockFilterCritters.calledOnce).to.be.false; expect(mockRes.json).to.have.been.calledWith([]); }); diff --git a/api/src/paths/project/{projectId}/survey/{surveyId}/critters/index.ts b/api/src/paths/project/{projectId}/survey/{surveyId}/critters/index.ts index 64ca9edfa3..96032b5476 100644 --- a/api/src/paths/project/{projectId}/survey/{surveyId}/critters/index.ts +++ b/api/src/paths/project/{projectId}/survey/{surveyId}/critters/index.ts @@ -2,7 +2,7 @@ import { RequestHandler } from 'express'; import { Operation } from 'express-openapi'; import { PROJECT_PERMISSION, SYSTEM_ROLE } from '../../../../../../constants/roles'; import { getDBConnection } from '../../../../../../database/db'; -import { bulkCreateResponse, critterBulkRequestObject } from '../../../../../../openapi/schemas/critter'; +import { critterCreateRequestObject, critterSchema } from '../../../../../../openapi/schemas/critter'; import { authorizeRequestHandler } from '../../../../../../request-handlers/security/authorization'; import { CritterbaseService, ICritterbaseUser } from '../../../../../../services/critterbase-service'; import { SurveyCritterService } from '../../../../../../services/survey-critter-service'; @@ -130,19 +130,19 @@ POST.apiDoc = { } ], requestBody: { - description: 'Critterbase bulk creation request object', + description: 'Critterbase create critter request object', content: { 'application/json': { - schema: critterBulkRequestObject + schema: critterCreateRequestObject } } }, responses: { 201: { - description: 'Responds with counts of objects created in critterbase.', + description: 'Responds with created critter.', content: { 'application/json': { - schema: bulkCreateResponse + schema: critterSchema } } }, @@ -185,10 +185,7 @@ export function getCrittersFromSurvey(): RequestHandler { } const critterIds = surveyCritters.map((critter) => String(critter.critterbase_critter_id)); - const result = await critterbaseService.filterCritters( - { critter_ids: { body: critterIds, negate: false } }, - 'detailed' - ); + const result = await critterbaseService.getMultipleCrittersByIds(critterIds); const critterMap = new Map(); for (const item of result) { @@ -218,17 +215,18 @@ export function addCritterToSurvey(): RequestHandler { username: req['system_user']?.user_identifier }; const surveyId = Number(req.params.surveyId); + const critterId = req.body.critter_id; const connection = getDBConnection(req['keycloak_token']); const surveyService = new SurveyCritterService(connection); const cb = new CritterbaseService(user); try { await connection.open(); - await surveyService.addCritterToSurvey(surveyId, req.body.critter_id); + await surveyService.addCritterToSurvey(surveyId, critterId); const result = await cb.createCritter(req.body); await connection.commit(); return res.status(201).json(result); } catch (error) { - defaultLog.error({ label: 'createCritter', message: 'error', error }); + defaultLog.error({ label: 'addCritterToSurvey', message: 'error', error }); await connection.rollback(); throw error; } finally { diff --git a/api/src/paths/project/{projectId}/survey/{surveyId}/observations/index.test.ts b/api/src/paths/project/{projectId}/survey/{surveyId}/observations/index.test.ts index d1224647a8..947e99d3d2 100644 --- a/api/src/paths/project/{projectId}/survey/{surveyId}/observations/index.test.ts +++ b/api/src/paths/project/{projectId}/survey/{surveyId}/observations/index.test.ts @@ -152,7 +152,7 @@ describe('insertUpdateSurveyObservationsWithMeasurements', () => { expect(dbConnectionObj.release).to.have.been.called; expect((actualError as HTTPError).message).to.equal( - 'Error connecting to the Critterbase API: Unknown Error: API request failed with status code undefined' + 'Error connecting to the Critterbase API: Error: API request failed with status code undefined' ); } }); diff --git a/api/src/services/critterbase-service.test.ts b/api/src/services/critterbase-service.test.ts index 99bcc60e6d..64181d831b 100644 --- a/api/src/services/critterbase-service.test.ts +++ b/api/src/services/critterbase-service.test.ts @@ -2,7 +2,7 @@ import { AxiosResponse } from 'axios'; import chai, { expect } from 'chai'; import sinon from 'sinon'; import sinonChai from 'sinon-chai'; -import { CritterbaseService, CRITTERBASE_API_HOST } from './critterbase-service'; +import { CritterbaseService, CRITTERBASE_API_HOST, ICritter } from './critterbase-service'; import { KeycloakService } from './keycloak-service'; chai.use(sinonChai); @@ -145,21 +145,18 @@ describe('CritterbaseService', () => { describe('createCritter', () => { it('should create a critter', async () => { - const data = { - locations: [{ latitude: 2, longitude: 2 }], - critters: [], - captures: [], - mortalities: [], - markings: [], - qualitative_measurements: [], - quantitative_measurements: [], - families: [], - collections: [] + const data: ICritter = { + wlh_id: 'aaaa', + animal_id: 'aaaa', + sex: 'male', + itis_tsn: 1, + itis_scientific_name: 'Name', + critter_comment: 'None.' }; const axiosStub = sinon.stub(cb.axiosInstance, 'post').resolves({ data: [] }); await cb.createCritter(data); - expect(axiosStub).to.have.been.calledOnceWith('/bulk', data); + expect(axiosStub).to.have.been.calledOnceWith('/critters/create', data); }); }); @@ -170,18 +167,5 @@ describe('CritterbaseService', () => { expect(axiosStub).to.have.been.calledOnceWith('/signup'); }); }); - - describe('filterCritters', () => { - it('should filter critters', async () => { - const axiosStub = sinon.stub(cb.axiosInstance, 'post').resolves({ data: [] }); - const mockFilterObj = { body: ['mock_id'], negate: false }; - const mockFilterCritters = { - critter_ids: mockFilterObj, - animal_ids: mockFilterObj - }; - await cb.filterCritters(mockFilterCritters); - expect(axiosStub).to.have.been.calledOnceWith('/critters/filter?format=default', mockFilterCritters); - }); - }); }); }); diff --git a/api/src/services/critterbase-service.ts b/api/src/services/critterbase-service.ts index cbab48c854..33c356be75 100644 --- a/api/src/services/critterbase-service.ts +++ b/api/src/services/critterbase-service.ts @@ -2,6 +2,7 @@ import axios, { AxiosError, AxiosInstance, AxiosResponse } from 'axios'; import { URLSearchParams } from 'url'; import { z } from 'zod'; import { ApiError, ApiErrorType } from '../errors/api-error'; +import { getLogger } from '../utils/logger'; import { KeycloakService } from './keycloak-service'; export interface ICritterbaseUser { @@ -19,6 +20,8 @@ export interface ICritter { wlh_id: string; animal_id: string; sex: string; + itis_tsn: number; + itis_scientific_name: string; critter_comment: string; } @@ -40,10 +43,10 @@ export interface IMortality { mortality_timestamp: string; proximate_cause_of_death_id: string; proximate_cause_of_death_confidence: string; - proximate_predated_by_taxon_id: string; + proximate_predated_by_itis_tsn: string; ultimate_cause_of_death_id: string; ultimate_cause_of_death_confidence: string; - ultimate_predated_by_taxon_id: string; + ultimate_predated_by_itis_tsn: string; mortality_comment: string; } @@ -118,25 +121,6 @@ export interface IBulkCreate { families: IFamilyPayload[]; } -interface IFilterObj { - body: string[]; - negate: boolean; -} - -export interface IFilterCritters { - critter_ids?: IFilterObj; - animal_ids?: IFilterObj; - wlh_ids?: IFilterObj; - collection_units?: IFilterObj; - taxon_name_commons?: IFilterObj; -} - -export interface ICbSelectRows { - key: string; - id: string; - value: string; -} - /** * A Critterbase quantitative measurement. */ @@ -271,11 +255,12 @@ export type CbRouteKey = keyof typeof CbRoutes; export const CRITTERBASE_API_HOST = process.env.CB_API_HOST || ``; const CRITTER_ENDPOINT = '/critters'; -const FILTER_ENDPOINT = `${CRITTER_ENDPOINT}/filter`; const BULK_ENDPOINT = '/bulk'; const SIGNUP_ENDPOINT = '/signup'; const FAMILY_ENDPOINT = '/family'; +const defaultLog = getLogger('CritterbaseServiceLogger'); + export class CritterbaseService { user: ICritterbaseUser; keycloak: KeycloakService; @@ -297,8 +282,9 @@ export class CritterbaseService { return response; }, (error: AxiosError) => { + defaultLog.error({ label: 'CritterbaseService', message: error.message, error }); return Promise.reject( - new ApiError(ApiErrorType.UNKNOWN, `API request failed with status code ${error?.response?.status}`) + new ApiError(ApiErrorType.GENERAL, `API request failed with status code ${error?.response?.status}`) ); } ); @@ -383,8 +369,8 @@ export class CritterbaseService { return this._makeGetRequest(`${CRITTER_ENDPOINT}/${critter_id}`, [{ key: 'format', value: 'detail' }]); } - async createCritter(data: IBulkCreate) { - const response = await this.axiosInstance.post(BULK_ENDPOINT, data); + async createCritter(data: ICritter) { + const response = await this.axiosInstance.post(`${CRITTER_ENDPOINT}/create`, data); return response.data; } @@ -393,8 +379,8 @@ export class CritterbaseService { return response.data; } - async filterCritters(data: IFilterCritters, format: 'default' | 'detailed' = 'default') { - const response = await this.axiosInstance.post(`${FILTER_ENDPOINT}?format=${format}`, data); + async getMultipleCrittersByIds(critter_ids: string[]) { + const response = await this.axiosInstance.post(CRITTER_ENDPOINT, { critter_ids }); return response.data; } diff --git a/api/src/services/geo-service.test.ts b/api/src/services/geo-service.test.ts index 644de9b586..d7643825a7 100644 --- a/api/src/services/geo-service.test.ts +++ b/api/src/services/geo-service.test.ts @@ -14,6 +14,8 @@ describe('GeoService', () => { describe('constructor', () => { it('with default options', async () => { + process.env.BcgwBaseUrl = 'https://openmaps.gov.bc.ca/geo/pub/ows'; + const geoService = new GeoService(); expect(geoService).not.to.be.undefined; diff --git a/app/package-lock.json b/app/package-lock.json index 67b7a4058d..59371c9872 100644 --- a/app/package-lock.json +++ b/app/package-lock.json @@ -5589,7 +5589,7 @@ "async-foreach": { "version": "0.1.3", "resolved": "https://registry.npmjs.org/async-foreach/-/async-foreach-0.1.3.tgz", - "integrity": "sha512-VUeSMD8nEGBWaZK4lizI1sf3yEC7pnAQ/mrI7pC2fBz2s/tq5jWWEngTwaf0Gruu/OoXRGLGg1XFqpYBiGTYJA==" + "integrity": "sha1-NhIfhFwFeBct5Bmpfb6x0W7DRUI=" }, "asynckit": { "version": "0.4.0", @@ -6542,7 +6542,7 @@ "console-control-strings": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/console-control-strings/-/console-control-strings-1.1.0.tgz", - "integrity": "sha512-ty/fTekppD2fIwRvnZAVdeOiGd1c7YXEixbgJTNzqcxJWKQnjJ/V1bNEEE6hygpM3WjwHFUVK6HTjWSzV4a8sQ==" + "integrity": "sha1-PXz0Rk22RG6mRL9LOVB/mFEAjo4=" }, "content-disposition": { "version": "0.5.4", @@ -7091,7 +7091,7 @@ "decamelize": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz", - "integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==" + "integrity": "sha1-9lNNFRSCabIDUue+4m9QH5oZEpA=" }, "decamelize-keys": { "version": "1.1.1", @@ -7239,7 +7239,7 @@ "delegates": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/delegates/-/delegates-1.0.0.tgz", - "integrity": "sha512-bd2L678uiWATM6m5Z1VzNCErI3jiGzt6HGY8OVICs40JQq/HALfbyNJmp0UDakEY4pMMaN0Ly5om/B1VI/+xfQ==" + "integrity": "sha1-hMbhWbgZBP3KWaDvRM2HDTElD5o=" }, "depd": { "version": "1.1.2", @@ -9139,7 +9139,7 @@ "geojson-equality": { "version": "0.1.6", "resolved": "https://registry.npmjs.org/geojson-equality/-/geojson-equality-0.1.6.tgz", - "integrity": "sha512-TqG8YbqizP3EfwP5Uw4aLu6pKkg6JQK9uq/XZ1lXQntvTHD1BBKJWhNpJ2M0ax6TuWMP3oyx6Oq7FCIfznrgpQ==", + "integrity": "sha1-oXE3TvBD5dR5eZWEC65GSOB1LXI=", "requires": { "deep-equal": "^1.0.0" } @@ -9175,7 +9175,7 @@ "get-stdin": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/get-stdin/-/get-stdin-4.0.1.tgz", - "integrity": "sha512-F5aQMywwJ2n85s4hJPTT9RPxGmubonuB10MNYo17/xph174n2MIR33HRguhzVag10O/npM7SPk73LMZNP+FaWw==" + "integrity": "sha1-uWjGsKBDhDJJAui/Gl3zJXmkUP4=" }, "get-stream": { "version": "6.0.1", @@ -9416,7 +9416,7 @@ "has-unicode": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/has-unicode/-/has-unicode-2.0.1.tgz", - "integrity": "sha512-8Rf9Y83NBReMnx0gFzA8JImQACstCYWUplepDa9xprwwtmgEZUF0h/i5xSA625zB/I37EtrswSST6OXxwaaIJQ==" + "integrity": "sha1-4Ob+aijPUROIVeCG0Wkedx3iqLk=" }, "hasown": { "version": "2.0.1", @@ -9941,7 +9941,7 @@ "is-in-browser": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/is-in-browser/-/is-in-browser-1.1.3.tgz", - "integrity": "sha512-FeXIBgG/CPGd/WUxuEyvgGTEfwiG9Z4EKGxjNMRqviiIIfsmgrpnHLffEDdwUHqNva1VEW91o3xBT/m8Elgl9g==" + "integrity": "sha1-Vv9NtoOgeMYILrldrX3GLh0E+DU=" }, "is-lambda": { "version": "1.0.1", @@ -17625,7 +17625,7 @@ "set-blocking": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", - "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==" + "integrity": "sha1-BF+XgtARrppoA93TgrJDkrPYkPc=" }, "set-function-length": { "version": "1.2.1", diff --git a/app/src/components/dialog/EditDialog.tsx b/app/src/components/dialog/EditDialog.tsx index fb1ed0ab89..e573074765 100644 --- a/app/src/components/dialog/EditDialog.tsx +++ b/app/src/components/dialog/EditDialog.tsx @@ -7,6 +7,8 @@ import DialogContentText from '@mui/material/DialogContentText'; import DialogTitle from '@mui/material/DialogTitle'; import useTheme from '@mui/material/styles/useTheme'; import useMediaQuery from '@mui/material/useMediaQuery'; +import { Breakpoint } from '@mui/system'; +import FormikDevDebugger from 'components/formik/FormikDevDebugger'; import { Formik, FormikValues } from 'formik'; import { PropsWithChildren } from 'react'; @@ -80,6 +82,24 @@ export interface IEditDialogProps { * @memberof IEditDialogProps */ onSave: (values: T) => void; + + /** + * Enables FormikDevDebugger. + * Renders status of Formik values, errors and touched fields. + * + * NOTE: This will only render in development environments if enabled. + * + * @memberof IEditDialogProps + */ + debug?: true; + + /** + * Adds a static size breakpoint for the dialog. + * Will stretch dialog to breakpoints max width. + * + * @memberof IEditDialogProps + */ + size?: Breakpoint; } /** @@ -113,7 +133,8 @@ export const EditDialog = (props: PropsWithChildren @@ -138,6 +159,7 @@ export const EditDialog = (props: PropsWithChildren {props.dialogError && {props.dialogError}} + {props.debug ? : null} )} diff --git a/app/src/components/fields/CbSelectField.tsx b/app/src/components/fields/CbSelectField.tsx index 15da30ee20..af43d7ce58 100644 --- a/app/src/components/fields/CbSelectField.tsx +++ b/app/src/components/fields/CbSelectField.tsx @@ -1,11 +1,11 @@ import { FormControlProps, MenuItem, SelectChangeEvent } from '@mui/material'; import { useFormikContext } from 'formik'; -import { ICbSelectRows, OrderBy } from 'hooks/cb_api/useLookupApi'; +import { ICbSelectRows, SelectOptionsProps } from 'hooks/cb_api/useLookupApi'; import { useCritterbaseApi } from 'hooks/useCritterbaseApi'; import useDataLoader from 'hooks/useDataLoader'; import { startCase } from 'lodash-es'; import get from 'lodash-es/get'; -import React, { useEffect, useMemo } from 'react'; +import { useEffect, useMemo } from 'react'; import { CbSelectWrapper } from './CbSelectFieldWrapper'; export interface ICbSelectSharedProps { @@ -14,15 +14,12 @@ export interface ICbSelectSharedProps { controlProps?: FormControlProps; } -export interface ICbSelectField extends ICbSelectSharedProps { - id: string; - route: string; - param?: string; - query?: string; - disabledValues?: Record; - handleChangeSideEffect?: (value: string, label: string) => void; - orderBy?: OrderBy; -} +export type ICbSelectField = ICbSelectSharedProps & + SelectOptionsProps & { + id: string; + disabledValues?: Record; + handleChangeSideEffect?: (value: string, label: string) => void; + }; interface ICbSelectOption { value: string | number; @@ -31,25 +28,31 @@ interface ICbSelectOption { /** * Critterbase Select Field. Handles data retrieval, formatting and error handling. * - * @param {ICbSelectField} + * @param {ICbSelectField} props * @return {*} * - **/ + */ +const CbSelectField = (props: ICbSelectField) => { + const { name, orderBy, label, route, query, handleChangeSideEffect, controlProps, disabledValues } = props; -const CbSelectField: React.FC = (props) => { - const { name, orderBy, label, route, param, query, handleChangeSideEffect, controlProps, disabledValues } = props; + const critterbaseApi = useCritterbaseApi(); - const api = useCritterbaseApi(); - const { data, refresh } = useDataLoader(api.lookup.getSelectOptions); + const { data, refresh } = useDataLoader(critterbaseApi.lookup.getSelectOptions); const { values, handleChange } = useFormikContext(); const val = get(values, name) ?? ''; + const selectParams = { route, query, orderBy }; + useEffect(() => { - // Only refresh when the query or param changes - refresh({ route, param, query, orderBy }); + // Skip fetching if route ends with an undefined id + // example: /xref/collection-units/{undefined} + if (route.endsWith('/')) { + return; + } + refresh(selectParams); // eslint-disable-next-line react-hooks/exhaustive-deps - }, [query, param]); + }, [JSON.stringify(selectParams)]); const isValueInRange = useMemo(() => { if (val === '') { diff --git a/app/src/components/fields/CbSelectFieldWrapper.tsx b/app/src/components/fields/CbSelectFieldWrapper.tsx index a5ecfbaee8..202c135253 100644 --- a/app/src/components/fields/CbSelectFieldWrapper.tsx +++ b/app/src/components/fields/CbSelectFieldWrapper.tsx @@ -31,8 +31,7 @@ export const CbSelectWrapper = ({ children, name, label, controlProps, onChange, value={value ?? val} onChange={onChange ?? handleChange} label={label} - onBlur={handleBlur} - displayEmpty> + onBlur={handleBlur}> {children} {err} diff --git a/app/src/components/formik/FormikDevDebugger.tsx b/app/src/components/formik/FormikDevDebugger.tsx index a339131ee8..886fe47d59 100644 --- a/app/src/components/formik/FormikDevDebugger.tsx +++ b/app/src/components/formik/FormikDevDebugger.tsx @@ -22,7 +22,7 @@ const FormikDevDebugger = ({ custom_payload }: FormikDevDebuggerProps) => { <> {/* Only render the button in Development */} {process.env.NODE_ENV === 'development' ? ( - + ) : null} {showFormDebugger ? (
diff --git a/app/src/components/map/components/MarkerWithResizableRadius.tsx b/app/src/components/map/components/MarkerWithResizableRadius.tsx
index bf00e0788a..e76d7bb648 100644
--- a/app/src/components/map/components/MarkerWithResizableRadius.tsx
+++ b/app/src/components/map/components/MarkerWithResizableRadius.tsx
@@ -6,8 +6,8 @@ import { distanceInMetresBetweenCoordinates } from 'utils/mapProjectionHelpers';
 export type MarkerIconColor = 'green' | 'blue' | 'red';
 
 interface IClickMarkerProps {
-  position: LatLng;
-  radius: number;
+  position?: LatLng;
+  radius?: number;
   markerColor?: MarkerIconColor;
   listenForMouseEvents: boolean; //Have this here so you can NOOP the mouse events in the case of multiple instances of this component on same mapf
   handlePlace?: (p: LatLng) => void;
@@ -63,7 +63,7 @@ const MarkerWithResizableRadius = (props: IClickMarkerProps): JSX.Element => {
     },
     mousemove: (e) => {
       if (!listenForMouseEvents) return;
-      if (holdingMouse) {
+      if (holdingMouse && position) {
         //If we move mouse between mouse down and mouse up, then change radius of circle
         handleResize?.(distanceInMetresBetweenCoordinates(position, e.latlng));
       }
@@ -78,22 +78,28 @@ const MarkerWithResizableRadius = (props: IClickMarkerProps): JSX.Element => {
     }
   });
 
+  if (!position) {
+    return <>;
+  }
+
   return (
     <>
-       {
-            if (!listenForMouseEvents) return;
-            map.dragging.disable(); //Need to disable map drag or else resizing circle will result in map moving
-            setHoldingMouse(true);
-            setLastMouseDown(e.latlng);
-          }
-        }}
-        color={markerColor ? iconMap[markerColor].hex : iconMap.blue.hex}
-        radius={radius}
-        center={position}
-      />
+      {props?.radius ? (
+         {
+              if (!listenForMouseEvents) return;
+              map.dragging.disable(); //Need to disable map drag or else resizing circle will result in map moving
+              setHoldingMouse(true);
+              setLastMouseDown(e.latlng);
+            }
+          }}
+          color={markerColor ? iconMap[markerColor].hex : iconMap.blue.hex}
+          radius={radius}
+          center={position}
+        />
+      ) : null}
       
     
   );
diff --git a/app/src/components/species/AncillarySpeciesComponent.tsx b/app/src/components/species/AncillarySpeciesComponent.tsx
index 43acc2a4b8..067961bc8f 100644
--- a/app/src/components/species/AncillarySpeciesComponent.tsx
+++ b/app/src/components/species/AncillarySpeciesComponent.tsx
@@ -11,7 +11,7 @@ const AncillarySpeciesComponent = () => {
 
   const selectedSpecies: ITaxonomy[] = get(values, 'species.ancillary_species') || [];
 
-  const handleAddSpecies = (species: ITaxonomy) => {
+  const handleAddSpecies = (species?: ITaxonomy) => {
     setFieldValue(`species.ancillary_species[${selectedSpecies.length}]`, species);
     setFieldError(`species.ancillary_species`, undefined);
   };
@@ -30,7 +30,7 @@ const AncillarySpeciesComponent = () => {
         formikFieldName={'species.ancillary_species'}
         label={'Ancillary Species'}
         required={false}
-        handleAddSpecies={handleAddSpecies}
+        handleSpecies={handleAddSpecies}
         clearOnSelect={true}
       />
       
diff --git a/app/src/components/species/FocalSpeciesComponent.tsx b/app/src/components/species/FocalSpeciesComponent.tsx
index 921ee048b3..a5217194f7 100644
--- a/app/src/components/species/FocalSpeciesComponent.tsx
+++ b/app/src/components/species/FocalSpeciesComponent.tsx
@@ -11,7 +11,7 @@ const FocalSpeciesComponent = () => {
 
   const selectedSpecies: ITaxonomy[] = get(values, 'species.focal_species') || [];
 
-  const handleAddSpecies = (species: ITaxonomy) => {
+  const handleAddSpecies = (species?: ITaxonomy) => {
     setFieldValue(`species.focal_species[${selectedSpecies.length}]`, species);
     setFieldError(`species.focal_species`, undefined);
   };
@@ -38,7 +38,7 @@ const FocalSpeciesComponent = () => {
         formikFieldName={'species.focal_species'}
         label={'Focal Species'}
         required={true}
-        handleAddSpecies={handleAddSpecies}
+        handleSpecies={handleAddSpecies}
         clearOnSelect={true}
       />
       
diff --git a/app/src/components/species/components/SpeciesAutoCompleteFormikField.tsx b/app/src/components/species/components/SpeciesAutoCompleteFormikField.tsx
new file mode 100644
index 0000000000..ceb330c5fb
--- /dev/null
+++ b/app/src/components/species/components/SpeciesAutoCompleteFormikField.tsx
@@ -0,0 +1,62 @@
+import SpeciesAutocompleteField, {
+  ISpeciesAutocompleteFieldProps
+} from 'components/species/components/SpeciesAutocompleteField';
+import { useFormikContext } from 'formik';
+import { useBiohubApi } from 'hooks/useBioHubApi';
+import useDataLoader from 'hooks/useDataLoader';
+import { get } from 'lodash-es';
+import { useEffect } from 'react';
+
+type SpeciesAutoCompleteFormikFieldProps = Pick<
+  ISpeciesAutocompleteFieldProps,
+  'formikFieldName' | 'required' | 'disabled'
+>;
+
+/**
+ * This component renders a ITIS 'Species Autocomplete Field' as Formik field.
+ *
+ * Must be a child of a Formik form.
+ *
+ * @param {AnimalFormProps} props - Subset of SpeciesAutocompleteFieldProps.
+ * @returns {*}
+ */
+export const SpeciesAutoCompleteFormikField = (props: SpeciesAutoCompleteFormikFieldProps) => {
+  const bhApi = useBiohubApi();
+
+  const { values, touched, errors, setFieldValue, setFieldError } = useFormikContext();
+  const { data: taxon, load: loadTaxon, clearData } = useDataLoader(bhApi.taxonomy.getSpeciesFromIds);
+
+  const tsn = get(values, props.formikFieldName);
+
+  if (tsn) {
+    loadTaxon([tsn]);
+  }
+
+  useEffect(() => {
+    if (!tsn) {
+      clearData();
+    }
+    // eslint-disable-next-line react-hooks/exhaustive-deps
+  }, [tsn]);
+
+  return (
+     {
+        if (taxon) {
+          setFieldValue(props.formikFieldName, taxon.tsn);
+          setFieldError(props.formikFieldName, undefined);
+        } else {
+          clearData();
+          setFieldValue(props.formikFieldName, '');
+        }
+      }}
+    />
+  );
+};
diff --git a/app/src/components/species/components/SpeciesAutocompleteField.tsx b/app/src/components/species/components/SpeciesAutocompleteField.tsx
index b8df8b5c5b..7df426102b 100644
--- a/app/src/components/species/components/SpeciesAutocompleteField.tsx
+++ b/app/src/components/species/components/SpeciesAutocompleteField.tsx
@@ -7,16 +7,63 @@ import grey from '@mui/material/colors/grey';
 import TextField from '@mui/material/TextField';
 import SpeciesCard from 'components/species/components/SpeciesCard';
 import { useBiohubApi } from 'hooks/useBioHubApi';
+import useIsMounted from 'hooks/useIsMounted';
 import { ITaxonomy } from 'interfaces/useTaxonomyApi.interface';
-import { debounce } from 'lodash-es';
-import { useEffect, useMemo, useState } from 'react';
+import { debounce, startCase } from 'lodash-es';
+import { ChangeEvent, useMemo, useState } from 'react';
 
 export interface ISpeciesAutocompleteFieldProps {
+  /**
+   * Formik field name.
+   *
+   * @type {string}
+   * @memberof ISpeciesAutocompleteFieldProps
+   */
   formikFieldName: string;
+  /**
+   * The field label.
+   *
+   * @type {string}
+   * @memberof ISpeciesAutocompleteFieldProps
+   */
   label: string;
+  /**
+   * Callback to fire on species option selection.
+   *
+   * @type {(species: ITaxonomy) => void}
+   * @memberof ISpeciesAutocompleteFieldProps
+   */
+  handleSpecies: (species?: ITaxonomy) => void;
+  /**
+   * Default species to render for input and options.
+   *
+   * @type {ITaxonomy}
+   * @memberof ISpeciesAutocompleteFieldProps
+   */
+  defaultSpecies?: ITaxonomy;
+  /**
+   * The error message to display.
+   *
+   * Note: the calling component is responsible for checking `touched`, if needed.
+   *
+   * @type {string}
+   * @memberof ISpeciesAutocompleteFieldProps
+   */
+  error?: string;
+  /**
+   * If field is required.
+   *
+   * @type {boolean}
+   * @memberof ISpeciesAutocompleteFieldProps
+   */
   required?: boolean;
-  handleAddSpecies: (species: ITaxonomy) => void;
-  value?: string;
+  /**
+   * If field is disabled.
+   *
+   * @type {boolean}
+   * @memberof ISpeciesAutocompleteFieldProps
+   */
+  disabled?: boolean;
   /**
    * Clear the input value after a selection is made
    * Defaults to false
@@ -28,15 +75,17 @@ export interface ISpeciesAutocompleteFieldProps {
 }
 
 const SpeciesAutocompleteField = (props: ISpeciesAutocompleteFieldProps) => {
-  const { formikFieldName, label, required, handleAddSpecies } = props;
+  const { formikFieldName, label, required, error, handleSpecies, defaultSpecies } = props;
+
   const biohubApi = useBiohubApi();
+  const isMounted = useIsMounted();
 
-  const [inputValue, setInputValue] = useState('');
-  const [options, setOptions] = useState([]);
+  const [inputValue, setInputValue] = useState(defaultSpecies?.scientificName ?? '');
+  const [options, setOptions] = useState(defaultSpecies ? [defaultSpecies] : []);
   // Is control loading (search in progress)
   const [isLoading, setIsLoading] = useState(false);
 
-  const handleSearch = useMemo(
+  const search = useMemo(
     () =>
       debounce(async (inputValue: string, callback: (searchedValues: ITaxonomy[]) => void) => {
         const searchTerms = inputValue.split(' ').filter(Boolean);
@@ -48,32 +97,30 @@ const SpeciesAutocompleteField = (props: ISpeciesAutocompleteFieldProps) => {
     [biohubApi.taxonomy]
   );
 
-  useEffect(() => {
-    let mounted = true;
+  const handleOnChange = (event: ChangeEvent) => {
+    const input = event.target.value;
+    setInputValue(input);
 
-    if (!inputValue) {
+    if (!input) {
       setOptions([]);
-      handleSearch.cancel();
-    } else {
-      setIsLoading(true);
-      handleSearch(inputValue, (newOptions) => {
-        if (!mounted) {
-          return;
-        }
-
-        setOptions(newOptions);
-        setIsLoading(false);
-      });
+      search.cancel();
+      handleSpecies();
+      return;
     }
-
-    return () => {
-      mounted = false;
-    };
-  }, [handleSearch, inputValue]);
+    setIsLoading(true);
+    search(input, (speciesOptions) => {
+      if (!isMounted()) {
+        return;
+      }
+      setOptions(speciesOptions);
+      setIsLoading(false);
+    });
+  };
 
   return (
      {
       }}
       filterOptions={(item) => item}
       inputValue={inputValue}
-      onInputChange={(_, value, reason) => {
+      onInputChange={(_, _value, reason) => {
         if (props.clearOnSelect && reason === 'reset') {
           setInputValue('');
-          return;
         }
-
-        setInputValue(value);
       }}
       onChange={(_, option) => {
         if (option) {
-          handleAddSpecies(option);
+          handleSpecies(option);
+          setInputValue(startCase(option.commonName ?? option.scientificName));
         }
       }}
       renderOption={(renderProps, renderOption) => {
@@ -106,8 +151,8 @@ const SpeciesAutocompleteField = (props: ISpeciesAutocompleteFieldProps) => {
                 borderTop: '1px solid' + grey[300]
               }
             }}
-            key={renderOption.tsn}
-            {...renderProps}>
+            {...renderProps}
+            key={renderOption.tsn}>
             
                {
          {
               
             )
           }}
+          error={Boolean(error)}
+          helperText={error}
         />
       )}
     />
diff --git a/app/src/contexts/surveyContext.tsx b/app/src/contexts/surveyContext.tsx
index aba3384a94..beac9fbdee 100644
--- a/app/src/contexts/surveyContext.tsx
+++ b/app/src/contexts/surveyContext.tsx
@@ -2,10 +2,10 @@ import { IAnimalDeployment } from 'features/surveys/view/survey-animals/telemetr
 import { useBiohubApi } from 'hooks/useBioHubApi';
 import useDataLoader, { DataLoader } from 'hooks/useDataLoader';
 import {
-  IDetailedCritterWithInternalId,
   IGetSampleSiteResponse,
   IGetSurveyAttachmentsResponse,
-  IGetSurveyForViewResponse
+  IGetSurveyForViewResponse,
+  ISimpleCritterWithInternalId
 } from 'interfaces/useSurveyApi.interface';
 import { createContext, PropsWithChildren, useEffect, useMemo } from 'react';
 import { useParams } from 'react-router';
@@ -49,7 +49,7 @@ export interface ISurveyContext {
    * @type {DataLoader<[project_id: number, survey_id: number], IDetailedCritterWithInternalId[], unknown>}
    * @memberof ISurveyContext
    */
-  critterDataLoader: DataLoader<[project_id: number, survey_id: number], IDetailedCritterWithInternalId[], unknown>;
+  critterDataLoader: DataLoader<[project_id: number, survey_id: number], ISimpleCritterWithInternalId[], unknown>;
 
   /**
    * The project ID belonging to the current project
@@ -73,11 +73,7 @@ export const SurveyContext = createContext({
   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
-  >,
+  critterDataLoader: {} as DataLoader<[project_id: number, survey_id: number], ISimpleCritterWithInternalId[], unknown>,
   projectId: -1,
   surveyId: -1
 });
diff --git a/app/src/features/surveys/components/EditDeleteStubCard.tsx b/app/src/features/surveys/components/EditDeleteStubCard.tsx
index 1eb8ecf642..7f2b4a912f 100644
--- a/app/src/features/surveys/components/EditDeleteStubCard.tsx
+++ b/app/src/features/surveys/components/EditDeleteStubCard.tsx
@@ -11,7 +11,7 @@ interface IEditDeleteStubCardProps {
   /*
    * sub header text of the card
    */
-  subHeader: string;
+  subHeader?: string;
 
   /*
    * edit handler - undefined prevents edit action from rendering
diff --git a/app/src/features/surveys/telemetry/ManualTelemetryList.tsx b/app/src/features/surveys/telemetry/ManualTelemetryList.tsx
index 6ce2d72913..80c572ddcc 100644
--- a/app/src/features/surveys/telemetry/ManualTelemetryList.tsx
+++ b/app/src/features/surveys/telemetry/ManualTelemetryList.tsx
@@ -24,7 +24,7 @@ import { default as dayjs } from 'dayjs';
 import { Formik } from 'formik';
 import { useBiohubApi } from 'hooks/useBioHubApi';
 import { useTelemetryApi } from 'hooks/useTelemetryApi';
-import { IDetailedCritterWithInternalId } from 'interfaces/useSurveyApi.interface';
+import { ISimpleCritterWithInternalId } from 'interfaces/useSurveyApi.interface';
 import { isEqual as _deepEquals } from 'lodash';
 import { get } from 'lodash-es';
 import { useContext, useEffect, useMemo, useState } from 'react';
@@ -32,12 +32,7 @@ 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 { AnimalTelemetryDeviceSchema, Device, IAnimalDeployment } from '../view/survey-animals/telemetry-device/device';
 import TelemetryDeviceForm from '../view/survey-animals/telemetry-device/TelemetryDeviceForm';
 import ManualTelemetryCard from './ManualTelemetryCard';
 
@@ -50,7 +45,7 @@ export const AnimalDeploymentSchema = AnimalTelemetryDeviceSchema.shape({
 export type AnimalDeployment = InferType;
 
 export interface ICritterDeployment {
-  critter: IDetailedCritterWithInternalId;
+  critter: ISimpleCritterWithInternalId;
   deployment: IAnimalDeployment;
 }
 
@@ -237,19 +232,27 @@ const ManualTelemetryList = () => {
   };
 
   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
+        //Being explicit here for simplicity.
+        {
+          critter_id: critter.critter_id,
+          device_id: data.device_id,
+          device_make: data.device_make ?? undefined,
+          frequency: data.frequency,
+          frequency_unit: data.frequency_unit,
+          device_model: data.device_model,
+          deployments: data.deployments
+        }
       );
       surveyContext.deploymentDataLoader.refresh(surveyContext.projectId, surveyContext.surveyId);
       // success snack bar
diff --git a/app/src/features/surveys/view/components/spatial-data/SurveySpatialData.tsx b/app/src/features/surveys/view/components/spatial-data/SurveySpatialData.tsx
index 601a5b3087..4c554338e0 100644
--- a/app/src/features/surveys/view/components/spatial-data/SurveySpatialData.tsx
+++ b/app/src/features/surveys/view/components/spatial-data/SurveySpatialData.tsx
@@ -9,7 +9,7 @@ import { useBiohubApi } from 'hooks/useBioHubApi';
 import { useObservationsContext, useTaxonomyContext } from 'hooks/useContext';
 import useDataLoader from 'hooks/useDataLoader';
 import { ITelemetry } from 'hooks/useTelemetryApi';
-import { IDetailedCritterWithInternalId } from 'interfaces/useSurveyApi.interface';
+import { ISimpleCritterWithInternalId } from 'interfaces/useSurveyApi.interface';
 import { useContext, useEffect, useMemo, useState } from 'react';
 import { IAnimalDeployment } from '../../survey-animals/telemetry-device/device';
 import SurveyMap, { ISurveyMapPoint, ISurveyMapPointMetadata, ISurveyMapSupplementaryLayer } from '../../SurveyMap';
@@ -67,7 +67,7 @@ const SurveySpatialData = () => {
    */
   const telemetryPoints: ISurveyMapPoint[] = useMemo(() => {
     const deployments: IAnimalDeployment[] = surveyContext.deploymentDataLoader.data ?? [];
-    const critters: IDetailedCritterWithInternalId[] = surveyContext.critterDataLoader.data ?? [];
+    const critters: ISimpleCritterWithInternalId[] = surveyContext.critterDataLoader.data ?? [];
     const telemetry: ITelemetry[] = telemetryContext.telemetryDataLoader.data ?? [];
 
     return (
@@ -77,7 +77,7 @@ const SurveySpatialData = () => {
         // Combine all critter and deployments data into a flat list
         .reduce(
           (
-            acc: { deployment: IAnimalDeployment; critter: IDetailedCritterWithInternalId; telemetry: ITelemetry }[],
+            acc: { deployment: IAnimalDeployment; critter: ISimpleCritterWithInternalId; telemetry: ITelemetry }[],
             telemetry: ITelemetry
           ) => {
             const deployment = deployments.find(
diff --git a/app/src/features/surveys/view/survey-animals/AddEditAnimal.test.tsx b/app/src/features/surveys/view/survey-animals/AddEditAnimal.test.tsx
deleted file mode 100644
index 555f31d3cf..0000000000
--- a/app/src/features/surveys/view/survey-animals/AddEditAnimal.test.tsx
+++ /dev/null
@@ -1,209 +0,0 @@
-import { AuthStateContext } from 'contexts/authStateContext';
-import { IProjectAuthStateContext, ProjectAuthStateContext } from 'contexts/projectAuthStateContext';
-import { IProjectContext, ProjectContext } from 'contexts/projectContext';
-import { ISurveyContext, SurveyContext } from 'contexts/surveyContext';
-import * 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: {
-    itis_tsn: 'itis_tsn',
-    itis_scientific_name: 'itis_scientific_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
deleted file mode 100644
index c6a45148b3..0000000000
--- a/app/src/features/surveys/view/survey-animals/AddEditAnimal.tsx
+++ /dev/null
@@ -1,279 +0,0 @@
-import { mdiPlus } from '@mdi/js';
-import Icon from '@mdi/react';
-import LoadingButton from '@mui/lab/LoadingButton/LoadingButton';
-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 Dialog from '@mui/material/Dialog';
-import DialogActions from '@mui/material/DialogActions';
-import DialogContent from '@mui/material/DialogContent';
-import DialogTitle from '@mui/material/DialogTitle';
-import Divider from '@mui/material/Divider';
-import Paper from '@mui/material/Paper';
-import Stack from '@mui/material/Stack';
-import { useTheme } from '@mui/material/styles';
-import Toolbar from '@mui/material/Toolbar';
-import Typography from '@mui/material/Typography';
-import useMediaQuery from '@mui/material/useMediaQuery';
-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.itis_tsn);
-    // eslint-disable-next-line react-hooks/exhaustive-deps
-  }, [values.general.itis_tsn]);
-
-  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),
-        saveValues.device[selectedIndex].device_make
-      );
-    } 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
index c8998ec70b..8176c646e1 100644
--- a/app/src/features/surveys/view/survey-animals/AnimalList.tsx
+++ b/app/src/features/surveys/view/survey-animals/AnimalList.tsx
@@ -1,4 +1,14 @@
-import { mdiChevronDown, mdiPlus } from '@mdi/js';
+import {
+  mdiChevronDown,
+  mdiFamilyTree,
+  mdiFormatListGroup,
+  mdiInformationOutline,
+  mdiPlus,
+  mdiRuler,
+  mdiSkullOutline,
+  mdiSpiderWeb,
+  mdiTagOutline
+} from '@mdi/js';
 import Icon from '@mdi/react';
 import Accordion from '@mui/material/Accordion';
 import AccordionDetails from '@mui/material/AccordionDetails';
@@ -16,18 +26,18 @@ import Skeleton from '@mui/material/Skeleton';
 import Stack from '@mui/material/Stack';
 import Toolbar from '@mui/material/Toolbar';
 import Typography from '@mui/material/Typography';
-import { SurveyAnimalsI18N } from 'constants/i18n';
 import { useQuery } from 'hooks/useQuery';
-import { IDetailedCritterWithInternalId } from 'interfaces/useSurveyApi.interface';
-import { useMemo } from 'react';
+import { ICritterDetailedResponse } from 'interfaces/useCritterApi.interface';
+import { ISimpleCritterWithInternalId } from 'interfaces/useSurveyApi.interface';
 import { useHistory } from 'react-router-dom';
-import { ANIMAL_SECTIONS_FORM_MAP, IAnimalSections } from './animal-sections';
+import { ANIMAL_SECTION } from './animal';
 
 interface IAnimalListProps {
   isLoading?: boolean;
-  critterData?: IDetailedCritterWithInternalId[];
-  selectedSection: IAnimalSections;
-  onSelectSection: (section: IAnimalSections) => void;
+  surveyCritters?: ISimpleCritterWithInternalId[];
+  selectedSection: ANIMAL_SECTION;
+  onSelectSection: (section: ANIMAL_SECTION) => void;
+  refreshCritter: (critter_id: string) => Promise;
   onAddButton: () => void;
 }
 
@@ -69,24 +79,42 @@ const ListPlaceholder = (props: { displaySkeleton: boolean }) =>
   );
 
 const AnimalList = (props: IAnimalListProps) => {
-  const { isLoading, selectedSection, onSelectSection, critterData, onAddButton } = props;
-  const { cid: survey_critter_id } = useQuery();
+  const { isLoading, selectedSection, onSelectSection, refreshCritter, surveyCritters, onAddButton } = props;
 
   const history = useHistory();
+  const { cid } = useQuery();
 
-  const sortedCritterData = useMemo(() => {
-    return [...(critterData ?? [])].sort(
-      (a, b) => new Date(a.create_timestamp).getTime() - new Date(b.create_timestamp).getTime()
-    );
-  }, [critterData]);
+  const survey_critter_id = Number(cid);
 
-  const handleCritterSelect = (id: string) => {
-    if (id === survey_critter_id) {
+  const getSectionIcon = (section: ANIMAL_SECTION) => {
+    switch (section) {
+      case ANIMAL_SECTION.GENERAL:
+        return mdiInformationOutline;
+      case ANIMAL_SECTION.COLLECTION_UNITS:
+        return mdiFormatListGroup;
+      case ANIMAL_SECTION.MARKINGS:
+        return mdiTagOutline;
+      case ANIMAL_SECTION.MEASUREMENTS:
+        return mdiRuler;
+      case ANIMAL_SECTION.CAPTURES:
+        return mdiSpiderWeb;
+      case ANIMAL_SECTION.MORTALITY:
+        return mdiSkullOutline;
+      case ANIMAL_SECTION.FAMILY:
+        return mdiFamilyTree;
+      default:
+        return mdiInformationOutline;
+    }
+  };
+
+  const handleCritterSelect = async (critter: ISimpleCritterWithInternalId) => {
+    if (critter.survey_critter_id === Number(survey_critter_id)) {
       history.replace(history.location.pathname);
     } else {
-      history.push(`?cid=${id}`);
+      refreshCritter(critter.critter_id);
+      history.push(`?cid=${critter.survey_critter_id}`);
     }
-    onSelectSection(SurveyAnimalsI18N.animalGeneralTitle);
+    onSelectSection(ANIMAL_SECTION.GENERAL);
   };
 
   return (
@@ -123,10 +151,10 @@ const AnimalList = (props: IAnimalListProps) => {
           sx={{
             background: grey[100]
           }}>
-          {!sortedCritterData.length ? (
-            
+          {!surveyCritters?.length ? (
+            
           ) : (
-            sortedCritterData.map((critter) => (
+            surveyCritters.map((critter) => (
                {
                   }
                 }}
                 key={critter.critter_id}
-                expanded={critter.survey_critter_id.toString() === survey_critter_id}>
+                expanded={critter.survey_critter_id === survey_critter_id}>
                 
                   }
-                    onClick={() => handleCritterSelect(critter.survey_critter_id.toString())}
+                    onClick={() => handleCritterSelect(critter)}
                     aria-controls="panel1bh-content"
                     sx={{
                       flex: '1 1 auto',
@@ -180,7 +208,7 @@ const AnimalList = (props: IAnimalListProps) => {
                         fontSize: '0.875rem'
                       }
                     }}>
-                    {(Object.keys(ANIMAL_SECTIONS_FORM_MAP) as IAnimalSections[]).map((section) => (
+                    {(Object.values(ANIMAL_SECTION) as ANIMAL_SECTION[]).map((section) => (
                        {
                           onSelectSection(section);
                         }}>
                         
-                          
+                          
                         
                         {section}
                       
diff --git a/app/src/features/surveys/view/survey-animals/AnimalSection.test.tsx b/app/src/features/surveys/view/survey-animals/AnimalSection.test.tsx
new file mode 100644
index 0000000000..7410458e23
--- /dev/null
+++ b/app/src/features/surveys/view/survey-animals/AnimalSection.test.tsx
@@ -0,0 +1,147 @@
+import { render } from '@testing-library/react';
+import { AuthStateContext } from 'contexts/authStateContext';
+import { useCritterbaseApi } from 'hooks/useCritterbaseApi';
+import { IDetailedCritterWithInternalId } from 'interfaces/useSurveyApi.interface';
+import { AuthProvider, AuthProviderProps } from 'react-oidc-context';
+import { getMockAuthState, SystemAdminAuthState } from 'test-helpers/auth-helpers';
+import { cleanup, waitFor } from 'test-helpers/test-utils';
+import { ANIMAL_SECTION } from './animal';
+import { AnimalSection } from './AnimalSection';
+jest.mock('../../../../hooks/useCritterbaseApi');
+const mockCritterbaseApi = useCritterbaseApi as jest.Mock;
+
+const mockRefreshCritter = jest.fn();
+
+const authState = getMockAuthState({ base: SystemAdminAuthState });
+
+const authConfig: AuthProviderProps = {
+  authority: 'authority',
+  client_id: 'client',
+  redirect_uri: 'redirect'
+};
+
+const animalSection = (section: ANIMAL_SECTION, critter?: IDetailedCritterWithInternalId) => (
+  
+    
+      
+    
+  
+);
+
+describe('AnimalSection', () => {
+  beforeEach(() => {
+    mockCritterbaseApi.mockImplementation(() => ({
+      family: {
+        getAllFamilies: jest.fn()
+      }
+    }));
+  });
+  afterEach(() => {
+    cleanup();
+  });
+  it('should mount with empty state with no critter', async () => {
+    const screen = render(animalSection(ANIMAL_SECTION.GENERAL));
+    await waitFor(() => {
+      const header = screen.getByRole('heading', { name: /no animal selected/i });
+      expect(header).toBeInTheDocument();
+    });
+  });
+
+  it('should render the general section', async () => {
+    const screen = render(
+      animalSection(ANIMAL_SECTION.GENERAL, {
+        critter_id: 'blah',
+        survey_critter_id: 1
+      } as IDetailedCritterWithInternalId)
+    );
+    await waitFor(() => {
+      const header = screen.getByRole('heading', { name: ANIMAL_SECTION.GENERAL });
+      expect(header).toBeInTheDocument();
+    });
+  });
+
+  it('should render the collection units section', async () => {
+    const screen = render(
+      animalSection(ANIMAL_SECTION.COLLECTION_UNITS, {
+        critter_id: 'blah',
+        survey_critter_id: 1,
+        collection_units: []
+      } as unknown as IDetailedCritterWithInternalId)
+    );
+    await waitFor(() => {
+      const header = screen.getByRole('heading', { name: ANIMAL_SECTION.COLLECTION_UNITS });
+      expect(header).toBeInTheDocument();
+    });
+  });
+
+  it('should render the markings section', async () => {
+    const screen = render(
+      animalSection(ANIMAL_SECTION.MARKINGS, {
+        critter_id: 'blah',
+        survey_critter_id: 1,
+        markings: []
+      } as unknown as IDetailedCritterWithInternalId)
+    );
+    await waitFor(() => {
+      const header = screen.getByRole('heading', { name: ANIMAL_SECTION.MARKINGS });
+      expect(header).toBeInTheDocument();
+    });
+  });
+
+  it('should render the measurements section', async () => {
+    const screen = render(
+      animalSection(ANIMAL_SECTION.MEASUREMENTS, {
+        critter_id: 'blah',
+        survey_critter_id: 1,
+        measurements: { qualitative: [], quantitative: [] }
+      } as unknown as IDetailedCritterWithInternalId)
+    );
+    await waitFor(() => {
+      const header = screen.getByRole('heading', { name: ANIMAL_SECTION.MEASUREMENTS });
+      expect(header).toBeInTheDocument();
+    });
+  });
+
+  it('should render the captures section', async () => {
+    const screen = render(
+      animalSection(ANIMAL_SECTION.CAPTURES, {
+        critter_id: 'blah',
+        survey_critter_id: 1,
+        captures: []
+      } as unknown as IDetailedCritterWithInternalId)
+    );
+    await waitFor(() => {
+      const header = screen.getByRole('heading', { name: ANIMAL_SECTION.CAPTURES });
+      expect(header).toBeInTheDocument();
+    });
+  });
+
+  it('should render the mortality section', async () => {
+    const screen = render(
+      animalSection(ANIMAL_SECTION.MORTALITY, {
+        critter_id: 'blah',
+        survey_critter_id: 1,
+        mortality: []
+      } as unknown as IDetailedCritterWithInternalId)
+    );
+    await waitFor(() => {
+      const header = screen.getByRole('heading', { name: ANIMAL_SECTION.MORTALITY });
+      expect(header).toBeInTheDocument();
+    });
+  });
+
+  it('should render the family section', async () => {
+    const screen = render(
+      animalSection(ANIMAL_SECTION.FAMILY, {
+        critter_id: 'blah',
+        survey_critter_id: 1,
+        family_parent: [],
+        family_child: []
+      } as unknown as IDetailedCritterWithInternalId)
+    );
+    await waitFor(() => {
+      const header = screen.getByRole('heading', { name: ANIMAL_SECTION.FAMILY });
+      expect(header).toBeInTheDocument();
+    });
+  });
+});
diff --git a/app/src/features/surveys/view/survey-animals/AnimalSection.tsx b/app/src/features/surveys/view/survey-animals/AnimalSection.tsx
new file mode 100644
index 0000000000..8b86843b55
--- /dev/null
+++ b/app/src/features/surveys/view/survey-animals/AnimalSection.tsx
@@ -0,0 +1,406 @@
+import { mdiPlus } from '@mdi/js';
+import Icon from '@mdi/react';
+import { Button } from '@mui/material';
+import Box from '@mui/material/Box';
+import Collapse from '@mui/material/Collapse';
+import grey from '@mui/material/colors/grey';
+import Typography from '@mui/material/Typography';
+import { SurveyAnimalsI18N } from 'constants/i18n';
+import dayjs from 'dayjs';
+import utc from 'dayjs/plugin/utc';
+import { EditDeleteStubCard } from 'features/surveys/components/EditDeleteStubCard';
+import { useDialogContext } from 'hooks/useContext';
+import { useCritterbaseApi } from 'hooks/useCritterbaseApi';
+import { ICritterDetailedResponse, IFamilyChildResponse } from 'interfaces/useCritterApi.interface';
+import { useState } from 'react';
+import { TransitionGroup } from 'react-transition-group';
+import { AnimalRelationship, ANIMAL_FORM_MODE, ANIMAL_SECTION } from './animal';
+import { AnimalSectionWrapper } from './AnimalSectionWrapper';
+import CaptureAnimalForm from './form-sections/CaptureAnimalForm';
+import CollectionUnitAnimalForm from './form-sections/CollectionUnitAnimalForm';
+import { FamilyAnimalForm } from './form-sections/FamilyAnimalForm';
+import GeneralAnimalForm from './form-sections/GeneralAnimalForm';
+import { MarkingAnimalForm } from './form-sections/MarkingAnimalForm';
+import MeasurementAnimalForm from './form-sections/MeasurementAnimalForm';
+import MortalityAnimalForm from './form-sections/MortalityAnimalForm';
+import GeneralAnimalSummary from './GeneralAnimalSummary';
+
+dayjs.extend(utc);
+
+type SubHeaderData = Record;
+type DeleteFn = (...args: any[]) => Promise;
+
+interface IAnimalSectionProps {
+  /**
+   * Detailed Critter from Critterbase.
+   * In most cases the Critter will be defined with the exception of adding new.
+   */
+  critter?: ICritterDetailedResponse;
+  /**
+   * Callback to refresh the detailed Critter.
+   * Children with the transition component are dependent on the Critter updating to trigger the transitions.
+   */
+  refreshCritter: (critter_id: string) => Promise;
+  /**
+   * The selected section.
+   *
+   * example: 'Captures' | 'Markings'.
+   */
+  section: ANIMAL_SECTION;
+}
+
+/**
+ * This component acts as a switch for the animal form sections.
+ *
+ * Goal was to make the form sections share common props and also make it flexible
+ * handling the different requirements / needs of the individual sections.
+ *
+ * @param {IAnimalSectionProps} props
+ * @returns {*}
+ */
+export const AnimalSection = (props: IAnimalSectionProps) => {
+  const cbApi = useCritterbaseApi();
+  const dialog = useDialogContext();
+
+  const [formObject, setFormObject] = useState(undefined);
+  const [openForm, setOpenForm] = useState(false);
+
+  const formatDate = (dt: Date) => dayjs(dt).utc().format('MMMM D, YYYY');
+
+  const handleOpenAddForm = () => {
+    setFormObject(undefined);
+    setOpenForm(true);
+  };
+
+  const handleOpenEditForm = (editObject: any) => {
+    setFormObject(editObject);
+    setOpenForm(true);
+  };
+
+  const refreshDetailedCritter = async () => {
+    if (props.critter) {
+      return props.refreshCritter(props.critter.critter_id);
+    }
+  };
+
+  const handleCloseForm = () => {
+    setFormObject(undefined);
+    setOpenForm(false);
+    refreshDetailedCritter();
+  };
+
+  const handleDelete = async (name: string, deleteService: T, ...args: Parameters) => {
+    const closeConfirmDialog = () => dialog.setYesNoDialog({ open: false });
+
+    dialog.setYesNoDialog({
+      dialogTitle: `Delete ${name[0].toUpperCase() + name.slice(1)}`,
+      dialogText: 'Are you sure you want to delete this record?',
+      open: true,
+      onYes: () => {
+        const handleConfirmDelete = async () => {
+          closeConfirmDialog();
+          try {
+            await deleteService(...args);
+            await refreshDetailedCritter();
+            dialog.setSnackbar({ open: true, snackbarMessage: `Successfully deleted ${name}` });
+          } catch (err) {
+            dialog.setSnackbar({ open: true, snackbarMessage: `Failed to delete ${name}` });
+          }
+        };
+        handleConfirmDelete();
+      },
+      onNo: () => closeConfirmDialog(),
+      onClose: () => closeConfirmDialog()
+    });
+  };
+
+  /**
+   * Formats the data card sub header to a unified format.
+   *
+   * example: 'marking: ear tag | colour: blue'
+   *
+   * @param {SubHeaderData} subHeaderData - Key value pairs.
+   * @returns {string} Formatted sub-header.
+   */
+  const formatSubHeader = (subHeaderData: SubHeaderData) => {
+    const formatArr: string[] = [];
+    const entries = Object.entries(subHeaderData);
+    entries.forEach(([key, value]) => {
+      if (value == null || value === '') {
+        return;
+      }
+      formatArr.push(`${key}: ${value}`);
+    });
+    return formatArr.join(' | ');
+  };
+
+  const getAddButton = (label: string) => (
+    
+  );
+
+  /**
+   * If the critter is not defined, render the empty state.
+   *
+   */
+  if (!props.critter) {
+    return (
+      
+        
+          
+            No Animal Selected
+          
+        
+      
+    );
+  }
+
+  /**
+   * Shared animal form props.
+   *
+   */
+  const SECTION_FORM_PROPS = {
+    formMode: formObject ? ANIMAL_FORM_MODE.EDIT : ANIMAL_FORM_MODE.ADD,
+    formObject: formObject,
+    critter: props.critter,
+    open: openForm,
+    handleClose: handleCloseForm
+  } as const;
+
+  /**
+   * Switch statements for the different form sections.
+   *
+   */
+  if (props.section === ANIMAL_SECTION.GENERAL) {
+    return (
+      }
+        infoText={SurveyAnimalsI18N.animalGeneralHelp}
+        section={props.section}
+        critter={props.critter}>
+         handleOpenEditForm(props.critter)} />
+      
+    );
+  }
+
+  if (props.section === ANIMAL_SECTION.COLLECTION_UNITS) {
+    return (
+      }
+        addBtn={getAddButton(SurveyAnimalsI18N.animalCollectionUnitAddBtn)}
+        infoText={SurveyAnimalsI18N.animalCollectionUnitHelp}
+        section={props.section}
+        critter={props.critter}>
+        
+          {props.critter.collection_units.map((unit) => (
+            
+               handleOpenEditForm(unit)}
+                onClickDelete={async () => {
+                  handleDelete(
+                    'ecological unit',
+                    cbApi.collectionUnit.deleteCollectionUnit,
+                    unit.critter_collection_unit_id
+                  );
+                }}
+              />
+            
+          ))}
+        
+      
+    );
+  }
+
+  if (props.section === ANIMAL_SECTION.MARKINGS) {
+    return (
+      }
+        addBtn={getAddButton(SurveyAnimalsI18N.animalMarkingAddBtn)}
+        infoText={SurveyAnimalsI18N.animalMarkingHelp}
+        section={props.section}
+        critter={props.critter}>
+        
+          {props.critter.markings.map((marking) => (
+            
+               handleOpenEditForm(marking)}
+                onClickDelete={async () => {
+                  handleDelete('marking', cbApi.marking.deleteMarking, marking.marking_id);
+                }}
+              />
+            
+          ))}
+        
+      
+    );
+  }
+
+  if (props.section === ANIMAL_SECTION.MEASUREMENTS) {
+    return (
+      }
+        infoText={SurveyAnimalsI18N.animalMeasurementHelp}
+        addBtn={getAddButton(SurveyAnimalsI18N.animalMeasurementAddBtn)}
+        section={props.section}
+        critter={props.critter}>
+        
+          {props.critter.measurements.quantitative.map((measurement) => (
+            
+               handleOpenEditForm(measurement)}
+                onClickDelete={async () => {
+                  handleDelete(
+                    'measurement',
+                    cbApi.measurement.deleteQuantitativeMeasurement,
+                    measurement.measurement_quantitative_id
+                  );
+                }}
+              />
+            
+          ))}
+          {props.critter.measurements.qualitative.map((measurement) => (
+            
+               handleOpenEditForm(measurement)}
+                onClickDelete={async () => {
+                  handleDelete(
+                    'measurement',
+                    cbApi.measurement.deleteQualitativeMeasurement,
+                    measurement.measurement_qualitative_id
+                  );
+                }}
+              />
+            
+          ))}
+        
+      
+    );
+  }
+
+  if (props.section === ANIMAL_SECTION.MORTALITY) {
+    return (
+      }
+        infoText={SurveyAnimalsI18N.animalMortalityHelp}
+        addBtn={
+          props.critter.mortality.length === 0 ? getAddButton(SurveyAnimalsI18N.animalMortalityAddBtn) : undefined
+        }
+        section={props.section}
+        critter={props.critter}>
+        
+          {props.critter.mortality.map((mortality) => (
+            
+               handleOpenEditForm(mortality)}
+                onClickDelete={async () => {
+                  handleDelete('mortality', cbApi.mortality.deleteMortality, mortality.mortality_id);
+                }}
+              />
+            
+          ))}
+        
+      
+    );
+  }
+
+  if (props.section === ANIMAL_SECTION.FAMILY) {
+    return (
+      }
+        infoText={SurveyAnimalsI18N.animalFamilyHelp}
+        addBtn={getAddButton(SurveyAnimalsI18N.animalFamilyAddBtn)}
+        section={props.section}
+        critter={props.critter}>
+        
+          {[...props.critter.family_child, ...props.critter.family_parent].map((family) => {
+            const isChild = (family as IFamilyChildResponse)?.child_critter_id;
+            return (
+              
+                 handleOpenEditForm({ ...family, critter_id: props.critter?.critter_id })}
+                  onClickDelete={async () => {
+                    handleDelete('family relationship', cbApi.family.deleteRelationship, {
+                      relationship: isChild ? AnimalRelationship.CHILD : AnimalRelationship.PARENT,
+                      family_id: family.family_id,
+                      critter_id: props.critter?.critter_id ?? ''
+                    });
+                  }}
+                />
+              
+            );
+          })}
+        
+      
+    );
+  }
+
+  if (props.section === ANIMAL_SECTION.CAPTURES) {
+    return (
+      }
+        addBtn={getAddButton(SurveyAnimalsI18N.animalCaptureAddBtn)}
+        infoText={SurveyAnimalsI18N.animalCaptureHelp}
+        section={props.section}
+        critter={props.critter}>
+        
+          {props.critter.captures.map((capture) => (
+            
+               handleOpenEditForm(capture)}
+                onClickDelete={async () => {
+                  handleDelete('capture', cbApi.capture.deleteCapture, capture.capture_id);
+                }}
+              />
+            
+          ))}
+        
+      
+    );
+  }
+
+  return null;
+};
diff --git a/app/src/features/surveys/view/survey-animals/AnimalSectionDataCards.tsx b/app/src/features/surveys/view/survey-animals/AnimalSectionDataCards.tsx
deleted file mode 100644
index 2d15de0599..0000000000
--- a/app/src/features/surveys/view/survey-animals/AnimalSectionDataCards.tsx
+++ /dev/null
@@ -1,216 +0,0 @@
-import Collapse from '@mui/material/Collapse';
-import { SurveyAnimalsI18N } from 'constants/i18n';
-import { DialogContext } from 'contexts/dialogContext';
-import { default as dayjs } from 'dayjs';
-import { EditDeleteStubCard } from 'features/surveys/components/EditDeleteStubCard';
-import { FieldArray, FieldArrayRenderProps, useFormikContext } from 'formik';
-import { IFamily } from 'hooks/cb_api/useFamilyApi';
-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) => dayjs(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.itis_scientific_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.itis_scientific_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/AnimalSectionWrapper.tsx b/app/src/features/surveys/view/survey-animals/AnimalSectionWrapper.tsx
new file mode 100644
index 0000000000..a22532bfdd
--- /dev/null
+++ b/app/src/features/surveys/view/survey-animals/AnimalSectionWrapper.tsx
@@ -0,0 +1,102 @@
+import Box from '@mui/material/Box';
+import grey from '@mui/material/colors/grey';
+import Divider from '@mui/material/Divider';
+import Paper from '@mui/material/Paper';
+import Stack from '@mui/material/Stack';
+import Toolbar from '@mui/material/Toolbar';
+import Typography from '@mui/material/Typography';
+import { ICritterDetailedResponse } from 'interfaces/useCritterApi.interface';
+import React, { PropsWithChildren } from 'react';
+import { ANIMAL_SECTION } from './animal';
+
+interface IAnimalSectionWrapperProps extends PropsWithChildren {
+  form?: JSX.Element;
+  section?: ANIMAL_SECTION;
+  infoText?: string;
+  critter?: ICritterDetailedResponse;
+  addBtn?: JSX.Element;
+}
+/**
+ * Wrapper for the selected section main content.
+ *
+ * This component renders beside the AnimalList navbar.
+ * In most cases it displays the currently selected critter + the attribute data cards.
+ *
+ * Note: All props can be undefined to easily handle the empty state.
+ *
+ * @param {IAnimalSectionWrapperProps} props
+ * @returns {*}
+ */
+export const AnimalSectionWrapper = (props: IAnimalSectionWrapperProps) => {
+  return (
+    <>
+      {props.form}
+      
+        
+          
+            {props.critter ? `Animal Details > ${props.critter?.animal_id}` : 'No animal selected'}
+          
+        
+
+        
+        {!props?.critter ? (
+          
+            
+              No Animal Selected
+            
+          
+        ) : (
+          
+            
+              
+                
+                  {props.section}
+                
+                {props.addBtn}
+              
+
+              
+                {props.infoText}
+              
+              {props.children}
+            
+          
+        )}
+      
+    
+  );
+};
diff --git a/app/src/features/surveys/view/survey-animals/GeneralAnimalSummary.tsx b/app/src/features/surveys/view/survey-animals/GeneralAnimalSummary.tsx
index dd98145643..d2188ff3b8 100644
--- a/app/src/features/surveys/view/survey-animals/GeneralAnimalSummary.tsx
+++ b/app/src/features/surveys/view/survey-animals/GeneralAnimalSummary.tsx
@@ -7,15 +7,14 @@ 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 { ICritterDetailedResponse } from 'interfaces/useCritterApi.interface';
 import { useContext } from 'react';
 import { setMessageSnackbar } from 'utils/Utils';
 import { DetailsWrapper } from '../SurveyDetails';
-import { IAnimal } from './animal';
 
 interface IGeneralDetail {
   title: string;
-  value?: string;
+  value?: string | null;
   valueEndIcon?: JSX.Element;
 }
 
@@ -24,29 +23,26 @@ interface GeneralAnimalSummaryProps {
    * Callback to be fired when edit action selected
    */
   handleEdit: () => void;
+  critter: ICritterDetailedResponse;
 }
 
 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.itis_scientific_name },
-    { title: 'Sex', value: general.sex },
-    { title: 'Wildlife Health ID', value: general.wlh_id },
+    { title: 'Alias', value: props.critter?.animal_id },
+    { title: 'Taxon', value: props.critter.itis_scientific_name },
+    { title: 'Sex', value: props.critter.sex },
+    { title: 'Wildlife Health ID', value: props.critter?.wlh_id },
     {
       title: 'Critterbase ID',
-      value: general.critter_id,
+      value: props?.critter?.critter_id,
       valueEndIcon: (
          {
-            navigator.clipboard.writeText(general.critter_id ?? '');
+            navigator.clipboard.writeText(props?.critter?.critter_id ?? '');
             setMessageSnackbar('Copied Critter ID', dialogContext);
           }}>
           
diff --git a/app/src/features/surveys/view/survey-animals/SurveyAnimalsPage.test.tsx b/app/src/features/surveys/view/survey-animals/SurveyAnimalsPage.test.tsx
index cdcc4872d1..70a38a7f55 100644
--- a/app/src/features/surveys/view/survey-animals/SurveyAnimalsPage.test.tsx
+++ b/app/src/features/surveys/view/survey-animals/SurveyAnimalsPage.test.tsx
@@ -23,6 +23,9 @@ const mockUseBiohub = {
   survey: {
     getSurveyCritters: jest.fn(),
     getDeploymentsInSurvey: jest.fn()
+  },
+  taxonomy: {
+    getSpeciesFromIds: jest.fn()
   }
 };
 
@@ -33,6 +36,9 @@ const mockUseTelemetry = {
 };
 
 const mockUseCritterbase = {
+  critters: {
+    getDetailedCritter: jest.fn()
+  },
   family: {
     getAllFamilies: jest.fn()
   },
@@ -108,18 +114,23 @@ describe('SurveyAnimalsPage', () => {
 
   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(() => {
+      fireEvent.click(screen.getByText('Add'));
+    });
 
     await waitFor(() => {
-      expect(screen.getByText('Create Animal')).toBeInTheDocument();
+      expect(screen.getByText('Add Critter')).toBeInTheDocument();
     });
 
-    fireEvent.click(screen.getByText('Cancel'));
+    await waitFor(() => {
+      fireEvent.click(screen.getByText('Cancel'));
+    });
   });
 
   it('should be able to select critter from navbar', async () => {
@@ -143,7 +154,9 @@ describe('SurveyAnimalsPage', () => {
       expect(screen.getByText('test-critter-alias')).toBeInTheDocument();
     });
 
-    fireEvent.click(screen.getByText('test-critter-alias'));
+    await waitFor(() => {
+      fireEvent.click(screen.getByText('test-critter-alias'));
+    });
 
     await waitFor(() => {
       expect(screen.getByText('General')).toBeInTheDocument();
diff --git a/app/src/features/surveys/view/survey-animals/SurveyAnimalsPage.tsx b/app/src/features/surveys/view/survey-animals/SurveyAnimalsPage.tsx
index 515bbf72c7..5de7d4b169 100644
--- a/app/src/features/surveys/view/survey-animals/SurveyAnimalsPage.tsx
+++ b/app/src/features/surveys/view/survey-animals/SurveyAnimalsPage.tsx
@@ -1,324 +1,83 @@
-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 { 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 { 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 { useContext, useEffect, useState } from 'react';
+import { ANIMAL_FORM_MODE, ANIMAL_SECTION } from './animal';
 import AnimalList from './AnimalList';
+import { AnimalSection } from './AnimalSection';
 import GeneralAnimalForm from './form-sections/GeneralAnimalForm';
-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 cbApi = useCritterbaseApi();
+  const { surveyId, projectId } = useContext(SurveyContext);
+  const { cid } = useQuery();
+
+  const survey_critter_id = Number(cid);
+
+  const [selectedSection, setSelectedSection] = useState(ANIMAL_SECTION.GENERAL);
+  const [openAddCritter, setOpenAddCritter] = useState(false);
 
   const {
-    data: critterData,
+    data: surveyCritters,
     load: loadCritters,
-    refresh: refreshCritters,
+    refresh: refreshSurveyCritters,
     isLoading: crittersLoading
   } = useDataLoader(() => bhApi.survey.getSurveyCritters(projectId, surveyId));
 
-  const {
-    data: deploymentData,
-    load: loadDeployments,
-    refresh: refreshDeployments
-  } = useDataLoader(() => bhApi.survey.getDeploymentsInSurvey(projectId, surveyId));
+  const { data: detailedCritter, refresh: refreshCritter } = useDataLoader(cbApi.critters.getDetailedCritter);
 
   loadCritters();
-  loadDeployments();
-
-  const defaultFormValues: IAnimal = useMemo(() => {
-    return {
-      general: {
-        wlh_id: '',
-        itis_tsn: '',
-        itis_scientific_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();
+  useEffect(() => {
+    const getDetailedCritterOnMount = async () => {
+      if (detailedCritter) {
+        return;
       }
-      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);
+      const focusCritter = surveyCritters?.find((critter) => critter.survey_critter_id === Number(survey_critter_id));
+      if (!focusCritter) {
+        return;
       }
-    } 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 ?? ''
+      await refreshCritter(focusCritter.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();
-  };
+    getDetailedCritterOnMount();
+  }, [surveyCritters, survey_critter_id, cbApi.critters, detailedCritter, refreshCritter]);
 
   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
+       {
+          setOpenAddCritter(false);
+          refreshSurveyCritters();
         }}
-        dialogSaveButtonLabel="Create Animal"
-        onCancel={() => setOpenAddDialog(false)}
-        onSave={(values) => handleCritterSave(values, ANIMAL_FORM_MODE.ADD)}
+      />
+       setOpenAddCritter(true)}
+            refreshCritter={refreshCritter}
+            surveyCritters={surveyCritters}
+            isLoading={crittersLoading}
+            selectedSection={selectedSection}
+            onSelectSection={(section) => setSelectedSection(section)}
+          />
+        }
+        mainComponent={
+          
+        }
       />
     
   );
diff --git a/app/src/features/surveys/view/survey-animals/SurveyAnimalsTable.tsx b/app/src/features/surveys/view/survey-animals/SurveyAnimalsTable.tsx
index 6d87f7fe10..e532c67a3b 100644
--- a/app/src/features/surveys/view/survey-animals/SurveyAnimalsTable.tsx
+++ b/app/src/features/surveys/view/survey-animals/SurveyAnimalsTable.tsx
@@ -3,7 +3,7 @@ import { StyledDataGrid } from 'components/data-grid/StyledDataGrid';
 import { ProjectRoleGuard } from 'components/security/Guards';
 import { PROJECT_PERMISSION, SYSTEM_ROLE } from 'constants/roles';
 import { default as dayjs } from 'dayjs';
-import { IDetailedCritterWithInternalId } from 'interfaces/useSurveyApi.interface';
+import { ISimpleCritterWithInternalId } from 'interfaces/useSurveyApi.interface';
 import SurveyAnimalsTableActions from './SurveyAnimalsTableActions';
 import { IAnimalDeployment } from './telemetry-device/device';
 
@@ -16,7 +16,7 @@ interface ISurveyAnimalsTableEntry {
 }
 
 interface ISurveyAnimalsTableProps {
-  animalData: IDetailedCritterWithInternalId[];
+  animalData: ISimpleCritterWithInternalId[];
   deviceData?: IAnimalDeployment[];
   onMenuOpen: (critter_id: number) => void;
   onRemoveCritter: (critter_id: number) => void;
@@ -34,7 +34,6 @@ export const SurveyAnimalsTable = ({
 }: ISurveyAnimalsTableProps): JSX.Element => {
   const animalDeviceData: ISurveyAnimalsTableEntry[] = deviceData
     ? [...animalData] // spreading this prevents this error "TypeError: Cannot assign to read only property '0' of object '[object Array]' in typescript"
-        .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);
           return {
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
deleted file mode 100644
index e53c27a34d..0000000000
--- a/app/src/features/surveys/view/survey-animals/animal-form-helpers.test.ts
+++ /dev/null
@@ -1,333 +0,0 @@
-import { IDetailedCritterWithInternalId } from 'interfaces/useSurveyApi.interface';
-import {
-  IAnimal,
-  IAnimalCapture,
-  IAnimalCollectionUnit,
-  IAnimalMarking,
-  IAnimalMeasurement,
-  IAnimalMortality,
-  IAnimalRelationship
-} from './animal';
-import { arrDiff, createCritterUpdatePayload, transformCritterbaseAPIResponseToForm } from './animal-form-helpers';
-
-describe('animal form helpers', () => {
-  describe('transformCritterbaseAPIResponseToForm', () => {
-    it('should return an object matching the IAnimal interface', () => {
-      const detailedResponse: IDetailedCritterWithInternalId = {
-        survey_critter_id: 1,
-        critter_id: 'c8601a4a-3946-4d1a-8c3f-a07088112284',
-        itis_tsn: '93ced109-d806-4851-90d7-064951cfc4f5',
-        wlh_id: 'abc',
-        animal_id: 'def',
-        sex: 'Male',
-        responsible_region_nr_id: '4a08bf72-86e3-435e-9423-1ade03fa1316',
-        create_user: '4e038522-53ca-43a4-af57-07af0218693c',
-        update_user: '4e038522-53ca-43a4-af57-07af0218693c',
-        create_timestamp: '2022-02-02',
-        update_timestamp: '2022-02-02',
-        critter_comment: '',
-        itis_scientific_name: 'Caribou',
-        responsible_region: 'Montana',
-        mortality_timestamp: null,
-        collection_units: [
-          {
-            critter_collection_unit_id: 'e1300b5e-6ea7-4537-a834-46be1b1fa573',
-            category_name: 'Population Unit',
-            unit_name: 'Itcha-Ilgachuz',
-            collection_unit_id: '0284c4ca-a279-4135-b6ef-d8f4f8c3d1e6',
-            collection_category_id: '9dcf05a8-9bfe-421b-b487-ce65299441ca'
-          }
-        ],
-        mortality: [
-          {
-            mortality_id: 'b93f66b4-8dfe-4810-9620-d3727989408d',
-            location_id: 'e51b93fb-a5fd-4816-aa16-bf14b21e27f9',
-            mortality_timestamp: '2020-10-10T07:00:00.000Z',
-            proximate_cause_of_death_id: '8d530b47-d4d3-4c6d-a87c-b440449d2781',
-            proximate_cause_of_death_confidence: '',
-            proximate_predated_by_taxon_id: '',
-            ultimate_cause_of_death_id: null,
-            ultimate_cause_of_death_confidence: '',
-            ultimate_predated_by_taxon_id: null,
-            mortality_comment: 'Mortality email Nov 11, 2020 & Sept 29th and 30, 2020',
-            location: {
-              latitude: 52.676422548679,
-              longitude: -124.9568080904715,
-              coordinate_uncertainty: null,
-              temperature: null,
-              location_comment: null,
-              region_env_id: 'b0f36d59-5cb5-423c-99e6-691215e964e9',
-              region_nr_id: '724f03c1-bed3-43bf-8a8b-67733dc0721e',
-              wmu_id: 'c9dbf5de-607b-466a-9804-fadc020295fc',
-              region_env_name: 'Cariboo',
-              region_nr_name: 'Cariboo Natural Resource Region',
-              wmu_name: '5-12'
-            }
-          }
-        ],
-        capture: [
-          {
-            capture_id: 'd9ae9a17-4889-4628-bf32-3eb126bfb924',
-            capture_location_id: '7f46207c-98db-43ab-9705-31fdd8fd9692',
-            release_location_id: '7f46207c-98db-43ab-9705-31fdd8fd9692',
-            capture_timestamp: '2019-02-05T08:00:00.000Z',
-            release_timestamp: null,
-            capture_comment: null,
-            release_comment: null,
-            capture_location: {
-              latitude: 52.29500572856892,
-              longitude: -124.550861955899,
-              coordinate_uncertainty: null,
-              temperature: null,
-              location_comment: null,
-              region_env_id: 'b0f36d59-5cb5-423c-99e6-691215e964e9',
-              region_nr_id: '724f03c1-bed3-43bf-8a8b-67733dc0721e',
-              wmu_id: 'c9dbf5de-607b-466a-9804-fadc020295fc',
-              region_env_name: 'Cariboo',
-              region_nr_name: 'Cariboo Natural Resource Region',
-              wmu_name: '5-12'
-            },
-            release_location: {
-              latitude: 52.29500572856892,
-              longitude: -124.550861955899,
-              coordinate_uncertainty: null,
-              temperature: null,
-              location_comment: null,
-              region_env_id: 'b0f36d59-5cb5-423c-99e6-691215e964e9',
-              region_nr_id: '724f03c1-bed3-43bf-8a8b-67733dc0721e',
-              wmu_id: 'c9dbf5de-607b-466a-9804-fadc020295fc',
-              region_env_name: 'Cariboo',
-              region_nr_name: 'Cariboo Natural Resource Region',
-              wmu_name: '5-12'
-            }
-          }
-        ],
-        marking: [
-          {
-            marking_id: '0e3afd57-a0bb-4704-a417-f4005f26e86b',
-            capture_id: 'd9ae9a17-4889-4628-bf32-3eb126bfb924',
-            mortality_id: null,
-            taxon_marking_body_location_id: '372020d9-b9ee-4eb3-abdd-b476711bd1aa',
-            marking_type_id: '274fe690-e253-4987-b11a-5b762d38adf3',
-            marking_material_id: '76ae6a61-c789-4b19-806d-5f38f300c14f',
-            primary_colour_id: '4aa3cce7-94d0-42d0-a183-078db5fbdd34',
-            secondary_colour_id: null,
-            identifier: '',
-            frequency: null,
-            frequency_unit: null,
-            order: null,
-            comment: 'Ported from BCTW, original data: < YELLOW >',
-            attached_timestamp: '2019-02-05T08:00:00.000Z',
-            removed_timestamp: null,
-            body_location: 'Left Ear',
-            marking_type: 'Ear Tag',
-            marking_material: 'Plastic',
-            primary_colour: 'Yellow',
-            secondary_colour: null,
-            text_colour: null
-          }
-        ],
-        measurement: {
-          qualitative: [
-            {
-              measurement_qualitative_id: 'd1ad55b2-c060-4ca0-863a-cd33e1da53c2',
-              taxon_measurement_id: '9a0a5ac1-f813-40b6-bb5e-58f70e87615d',
-              capture_id: 'd9ae9a17-4889-4628-bf32-3eb126bfb924',
-              mortality_id: null,
-              qualitative_option_id: 'c8691135-2ef3-44c5-81cb-eaabf3462664',
-              measurement_comment: 'Ported from BCTW, original data: < No >',
-              measured_timestamp: null,
-              measurement_name: 'Juvenile at heel indicator',
-              option_label: 'False',
-              option_value: 0
-            }
-          ],
-          quantitative: [
-            {
-              measurement_quantitative_id: 'efc1021d-9527-4ceb-8393-33fb2868ec25',
-              taxon_measurement_id: '398a4636-5a24-418d-ba48-4aaefcca7816',
-              capture_id: 'd9ae9a17-4889-4628-bf32-3eb126bfb924',
-              mortality_id: null,
-              value: 0,
-              measurement_comment: 'Ported from BCTW, original data: < No >',
-              measured_timestamp: null,
-              measurement_name: 'Juvenile count'
-            }
-          ]
-        },
-        family_parent: [
-          {
-            family_id: 'd9ae9a17-4889-4628-bf32-3eb126bfb924',
-            parent_critter_id: 'c8601a4a-3946-4d1a-8c3f-a07088112284'
-          }
-        ],
-        family_child: [
-          {
-            family_id: 'efc1021d-9527-4ceb-8393-33fb2868ec25',
-            child_critter_id: 'c8601a4a-3946-4d1a-8c3f-a07088112284'
-          }
-        ]
-      };
-
-      const result = transformCritterbaseAPIResponseToForm(detailedResponse);
-
-      expect(result.general.wlh_id).toBe('abc');
-      expect(result.general.critter_id).toBe('c8601a4a-3946-4d1a-8c3f-a07088112284');
-      expect(result.general.sex).toBe('Male');
-      expect(result.general.animal_id).toBe('def');
-      expect(result.general.itis_tsn).toBe('93ced109-d806-4851-90d7-064951cfc4f5');
-      expect(result.captures.length).toBe(1);
-      expect(result.markings.length).toBe(1);
-      expect(result.mortality.length).toBe(1);
-      expect(result.measurements.length).toBe(2);
-      expect(result.family.length).toBe(2);
-    });
-  });
-
-  describe('createCritterUpdatePayload', () => {
-    it('should return an object containing two instances of Critter', () => {
-      const capture: IAnimalCapture = {
-        capture_id: '8b9281ea-fbe8-411c-9b50-70ffd08737cb',
-        capture_location_id: undefined,
-        release_location_id: undefined,
-        capture_longitude: 0,
-        capture_latitude: 0,
-        capture_utm_northing: 0,
-        capture_utm_easting: 0,
-        capture_timestamp: new Date(),
-        capture_coordinate_uncertainty: 0,
-        capture_comment: 'before',
-        projection_mode: undefined,
-        show_release: false,
-        release_longitude: 0,
-        release_latitude: 0,
-        release_utm_northing: 0,
-        release_utm_easting: 0,
-        release_coordinate_uncertainty: 0,
-        release_timestamp: new Date(),
-        release_comment: 'undefined'
-      };
-
-      const marking: IAnimalMarking = {
-        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 = {
-        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_name: 'weight',
-        option_label: 'test'
-      };
-
-      const mortality: IAnimalMortality = {
-        mortality_id: 'eaf6b7a0-c47c-4dba-83b4-88e9331ee097',
-        location_id: 'eaf6b7a0-c47c-4dba-83b4-88e9331ee097',
-        mortality_longitude: 0,
-        mortality_latitude: 0,
-        mortality_utm_northing: 0,
-        mortality_utm_easting: 0,
-        mortality_timestamp: new Date(),
-        mortality_coordinate_uncertainty: 0,
-        mortality_comment: 'tttt',
-        proximate_cause_of_death_id: 'eaf6b7a0-c47c-4dba-83b4-88e9331ee097',
-        proximate_cause_of_death_confidence: undefined,
-        proximate_predated_by_taxon_id: undefined,
-        ultimate_cause_of_death_id: undefined,
-        ultimate_cause_of_death_confidence: undefined,
-        ultimate_predated_by_taxon_id: undefined,
-        projection_mode: 'wgs'
-      };
-
-      const collectionUnits: IAnimalCollectionUnit = {
-        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 = {
-        family_id: 'eaf6b7a0-c47c-4dba-83b4-88e9331ee097',
-        relationship: 'child'
-      };
-
-      const initialFormValues: IAnimal = {
-        general: {
-          wlh_id: 'wlh-a',
-          itis_tsn: '',
-          animal_id: '',
-          itis_scientific_name: undefined,
-          sex: undefined,
-          critter_id: undefined
-        },
-        captures: [capture, { ...capture, capture_id: '0ec88875-0219-4635-b7cf-8da8ba732fc1' }],
-        markings: [marking, { ...marking, marking_id: '0ec88875-0219-4635-b7cf-8da8ba732fc1' }],
-        measurements: [measure, { ...measure, measurement_qualitative_id: '0ec88875-0219-4635-b7cf-8da8ba732fc1' }],
-        mortality: [mortality, { ...mortality, mortality_id: '0ec88875-0219-4635-b7cf-8da8ba732fc1' }],
-        family: [family, { ...family, relationship: 'parent' }],
-        images: [],
-        collectionUnits: [
-          collectionUnits,
-          { ...collectionUnits, critter_collection_unit_id: '0ec88875-0219-4635-b7cf-8da8ba732fc1' }
-        ],
-        device: []
-      };
-
-      const currentFormValues: IAnimal = {
-        general: {
-          itis_tsn: '',
-          animal_id: '',
-          itis_scientific_name: undefined,
-          wlh_id: 'wlh-b',
-          sex: undefined,
-          critter_id: undefined
-        },
-        captures: [{ ...capture, capture_comment: 'after' }],
-        markings: [marking],
-        measurements: [measure],
-        mortality: [mortality],
-        family: [],
-        images: [],
-        collectionUnits: [],
-        device: []
-      };
-
-      const { create, update } = createCritterUpdatePayload(initialFormValues, currentFormValues);
-
-      expect(create.markings.length).toBe(1);
-      expect(update.wlh_id).toBe('wlh-b');
-      expect(update.captures.length).toBe(2);
-      expect(update.mortalities.length).toBe(2);
-      expect(update.collections.length).toBe(2);
-      expect(update.markings.length).toBe(2);
-      expect(update.measurements.qualitative.length).toBe(2);
-      expect(update.families.parents.length).toBe(1);
-      expect(update.families.children.length).toBe(1);
-    });
-  });
-
-  describe('arrDiff', () => {
-    it('should yield only elements from arr1 not present in arr2', () => {
-      const arr1 = [{ pk: 'a' }, { pk: 'b' }];
-      const arr2 = [{ pk: 'a' }, { pk: 'c' }];
-
-      const result = arrDiff(arr1, arr2, 'pk');
-
-      expect(result.length).toBe(1);
-      expect(result.find((a) => a.pk === 'b')).toBeDefined();
-    });
-  });
-});
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
deleted file mode 100644
index bc95b4ba7a..0000000000
--- a/app/src/features/surveys/view/survey-animals/animal-form-helpers.ts
+++ /dev/null
@@ -1,269 +0,0 @@
-import { IDetailedCritterWithInternalId } from 'interfaces/useSurveyApi.interface';
-import { v4 } from 'uuid';
-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
- * in the Formik form. Primary keys are included despite not being editable in the form to make it easier to differentitate between new and existing
- * form entries on submission.
- *
- * @param existingCritter The critter as seen from the Critterbase DB
- * @returns {*} IAnimal
- */
-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.
-  return {
-    general: {
-      wlh_id: existingCritter.wlh_id ?? '',
-      itis_tsn: existingCritter.itis_tsn,
-      animal_id: existingCritter.animal_id ?? '',
-      sex: existingCritter.sex as AnimalSex,
-      itis_scientific_name: existingCritter.itis_scientific_name,
-      critter_id: existingCritter.critter_id
-    },
-    captures: existingCritter?.capture.map((cap) => ({
-      ...cap,
-      capture_comment: cap.capture_comment ?? '',
-      release_comment: cap.release_comment ?? '',
-      capture_timestamp: new Date(cap.capture_timestamp),
-      release_timestamp: cap.release_timestamp ? new Date(cap.release_timestamp) : undefined,
-      capture_latitude: cap.capture_location?.latitude,
-      capture_longitude: cap.capture_location?.longitude,
-      capture_coordinate_uncertainty: cap.capture_location?.coordinate_uncertainty ?? 0,
-      release_longitude: cap.release_location?.longitude,
-      release_latitude: cap.release_location?.latitude,
-      release_coordinate_uncertainty: cap.release_location?.coordinate_uncertainty ?? 0,
-      capture_utm_northing: 0,
-      capture_utm_easting: 0,
-      release_utm_easting: 0,
-      release_utm_northing: 0,
-      projection_mode: 'wgs',
-      show_release: !!cap.release_location,
-      capture_location_id: cap.capture_location_id ?? undefined,
-      release_location_id: cap.release_location_id ?? undefined
-    })),
-    markings: existingCritter.marking.map((mark) => ({
-      ...mark,
-      primary_colour_id: mark.primary_colour_id ?? '',
-      secondary_colour_id: mark.secondary_colour_id ?? '',
-      marking_comment: mark.comment ?? '',
-      primary_colour: mark.primary_colour ?? undefined,
-      marking_type: mark.marking_type ?? undefined,
-      body_location: mark.body_location ?? undefined
-    })),
-    mortality: existingCritter?.mortality.map((mor) => ({
-      ...mor,
-      mortality_comment: mor.mortality_comment ?? '',
-      mortality_timestamp: new Date(mor.mortality_timestamp),
-      mortality_latitude: mor.location.latitude,
-      mortality_longitude: mor.location.longitude,
-      mortality_utm_easting: 0,
-      mortality_utm_northing: 0,
-      mortality_coordinate_uncertainty: mor.location.coordinate_uncertainty ?? 0,
-      proximate_cause_of_death_confidence: mor.proximate_cause_of_death_confidence,
-      proximate_cause_of_death_id: mor.proximate_cause_of_death_id ?? '',
-      proximate_predated_by_taxon_id: mor.proximate_predated_by_taxon_id ?? '',
-      ultimate_cause_of_death_confidence: mor.ultimate_cause_of_death_confidence ?? '',
-      ultimate_cause_of_death_id: mor.ultimate_cause_of_death_id ?? '',
-      ultimate_predated_by_taxon_id: mor.ultimate_predated_by_taxon_id ?? '',
-      projection_mode: 'wgs',
-      location_id: mor.location_id ?? undefined
-    })),
-    collectionUnits: existingCritter.collection_units.map((a) => ({
-      ...a
-    })),
-    measurements: [
-      ...existingCritter.measurement.qualitative.map((meas) => ({
-        ...meas,
-        measurement_quantitative_id: undefined,
-        value: undefined,
-        measured_timestamp: meas.measured_timestamp ? new Date(meas.measured_timestamp) : ('' as unknown as Date),
-        measurement_comment: meas.measurement_comment ?? '',
-        measurement_name: meas.measurement_name ?? undefined,
-        option_label: meas.option_label
-      })),
-      ...existingCritter.measurement.quantitative.map((meas) => ({
-        ...meas,
-        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_name: meas.measurement_name ?? undefined,
-        option_label: undefined
-      }))
-    ],
-    family: [
-      ...existingCritter.family_child.map((ch) => ({
-        family_id: ch.family_id,
-        relationship: 'child'
-      })),
-      ...existingCritter.family_parent.map((par) => ({
-        family_id: par.family_id,
-        relationship: 'parent'
-      }))
-    ],
-    images: [],
-    device: []
-  };
-};
-
-/**
- * This yields the difference between array 1 and array 2, specifically which items array 1 has
- * that array 2 does not. Argument order matters here, does not function like a true 'set difference.'
- *
- * @param arr1 First array
- * @param arr2 Second array
- * @param key A key present in objects from both arrays
- * @returns {*} subset of T[]
- */
-export const arrDiff = , V extends Record, K extends keyof T & keyof V>(
-  arr1: T[],
-  arr2: V[],
-  key: K
-) => {
-  return arr1.filter((a1: Record) => !arr2.some((a2: Record) => a1[key] === a2[key]));
-};
-
-interface CritterUpdatePayload {
-  create: Critter;
-  update: Critter;
-}
-
-/**
- * Returns two payload objects, one of which is for entries that should be newly created in the DB, the other should patch over
- * or delete existing rows.
- *
- * @param initialFormValues IAnimal
- * @param currentFormValues IAnimal
- * @returns {*} CritterUpdatePayload
- */
-export const createCritterUpdatePayload = (
-  initialFormValues: IAnimal,
-  currentFormValues: IAnimal
-): CritterUpdatePayload => {
-  const initialCritter = new Critter(initialFormValues);
-  //First we filter all parts of the form which do not have the primary key from CB nested in them.
-  //These had to have been created by the user and not autofilled by existing data, so we create these in CB.
-  const createCritter = new Critter({
-    ...currentFormValues,
-    captures: currentFormValues.captures.filter((a) => !a.capture_id),
-    mortality: currentFormValues.mortality.filter((a) => !a.mortality_id),
-    markings: currentFormValues.markings.filter((a) => !a.marking_id),
-    measurements: currentFormValues.measurements.filter(
-      (a) => !a.measurement_qualitative_id && !a.measurement_quantitative_id
-    ),
-    collectionUnits: currentFormValues.collectionUnits.filter((a) => !a.critter_collection_unit_id),
-    family: []
-  });
-  //Now we do the opposite operation. If the primary key was included in the object, it must have come from Critterbase.
-  //The user is unable to edit the primary key using the form fields.
-  const updateCritter = new Critter({
-    ...currentFormValues,
-    captures: currentFormValues.captures.filter((a) => a.capture_id),
-    mortality: currentFormValues.mortality.filter((a) => a.mortality_id),
-    markings: currentFormValues.markings.filter((a) => a.marking_id),
-    measurements: currentFormValues.measurements.filter(
-      (a) => a.measurement_qualitative_id || a.measurement_quantitative_id
-    ),
-    collectionUnits: currentFormValues.collectionUnits.filter((a) => a.critter_collection_unit_id),
-    family: []
-  });
-
-  //Family section is a bit of a special case. A true update operation is unsupported, since it doesn't really make sense
-  //for the various family schemas.
-  //Therefore, any "updated" entries have their previously existing selves deleted.
-  //Here we determine this by searching all initial form values and seeing which ones didn't make it into the final form values.
-  initialFormValues.family.forEach((prevFam) => {
-    if (
-      !currentFormValues.family.some(
-        (currFam) => currFam.family_id === prevFam.family_id && currFam.relationship === prevFam.relationship
-      )
-    ) {
-      prevFam.relationship === 'parent'
-        ? updateCritter.families.parents.push({
-            family_id: prevFam.family_id,
-            parent_critter_id: initialCritter.critter_id,
-            _delete: true
-          })
-        : updateCritter.families.children.push({
-            family_id: prevFam.family_id,
-            child_critter_id: initialCritter.critter_id,
-            _delete: true
-          });
-    }
-  });
-
-  //Now we do the inverse, see which records were not in the initial form, those are the ones that need to be created.
-  //Perhaps this could be rolled into the above? I couldn't seem to find a way that wouldn't miss certain cases.
-  currentFormValues.family.forEach((currFam) => {
-    if (
-      !initialFormValues.family.some(
-        (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.itis_scientific_name}_family`
-        });
-      }
-      currFam.relationship === 'parent'
-        ? createCritter.families.parents.push({
-            family_id: familyId,
-            parent_critter_id: initialCritter.critter_id
-          })
-        : createCritter.families.children.push({
-            family_id: familyId,
-            child_critter_id: initialCritter.critter_id
-          });
-    }
-  });
-
-  //Here we check for which entries were removed for all other sections in the final form submission.
-  //See arrDiff's doc for what it's doing here.
-  //Again, it would be nice if this could be rolled into the create / update differentiation somehow, but I don't think it's possible.
-  updateCritter.captures.push(
-    ...arrDiff(initialCritter.captures, updateCritter.captures, 'capture_id').map((cap) => ({
-      ...cap,
-      _delete: true
-    }))
-  );
-  updateCritter.mortalities.push(
-    ...arrDiff(initialCritter.mortalities, updateCritter.mortalities, 'mortality_id').map((mort) => ({
-      ...mort,
-      _delete: true
-    }))
-  );
-  updateCritter.collections.push(
-    ...arrDiff(initialCritter.collections, updateCritter.collections, 'critter_collection_unit_id').map((col) => ({
-      ...col,
-      _delete: true
-    }))
-  );
-  updateCritter.markings.push(
-    ...arrDiff(initialCritter.markings, updateCritter.markings, 'marking_id').map((mark) => ({
-      ...mark,
-      _delete: true
-    }))
-  );
-  updateCritter.measurements.qualitative.push(
-    ...arrDiff(
-      initialCritter.measurements.qualitative,
-      updateCritter.measurements.qualitative,
-      'measurement_qualitative_id'
-    ).map((meas) => ({ ...meas, _delete: true }))
-  );
-  updateCritter.measurements.quantitative.push(
-    ...arrDiff(
-      initialCritter.measurements.quantitative,
-      updateCritter.measurements.quantitative,
-      'measurement_quantitative_id'
-    ).map((meas) => ({ ...meas, _delete: true }))
-  );
-
-  return { create: createCritter, update: updateCritter };
-};
diff --git a/app/src/features/surveys/view/survey-animals/animal-sections.ts b/app/src/features/surveys/view/survey-animals/animal-sections.ts
deleted file mode 100644
index fdd2158017..0000000000
--- a/app/src/features/surveys/view/survey-animals/animal-sections.ts
+++ /dev/null
@@ -1,258 +0,0 @@
-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
deleted file mode 100644
index 671be8af98..0000000000
--- a/app/src/features/surveys/view/survey-animals/animal.test.ts
+++ /dev/null
@@ -1,209 +0,0 @@
-import yup from 'utils/YupSchema';
-import { v4 } from 'uuid';
-import {
-  AnimalSex,
-  Critter,
-  getAnimalFieldName,
-  glt,
-  IAnimal,
-  IAnimalMarking,
-  isRequiredInSchema,
-  lastAnimalValueValid
-} from './animal';
-
-const animal: IAnimal = {
-  general: {
-    itis_tsn: 'a',
-    itis_scientific_name: 'taxon',
-    animal_id: 'animal',
-    wlh_id: 'a',
-    sex: AnimalSex.MALE,
-    critter_id: v4()
-  },
-  captures: [
-    {
-      capture_id: v4(),
-      capture_location_id: undefined,
-      release_location_id: undefined,
-      capture_latitude: 3,
-      capture_longitude: 3,
-      capture_utm_northing: 19429156.095,
-      capture_utm_easting: 7659804.274,
-      capture_comment: 'comment',
-      capture_coordinate_uncertainty: 10,
-      capture_timestamp: new Date(),
-      projection_mode: 'wgs',
-      show_release: false,
-      release_latitude: 3,
-      release_longitude: 3,
-      release_utm_northing: 19429156.095,
-      release_utm_easting: 7659804.274,
-      release_comment: 'comment',
-      release_timestamp: new Date(),
-      release_coordinate_uncertainty: 3
-    }
-  ],
-  markings: [
-    {
-      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_type: 'tag',
-      body_location: 'head',
-      primary_colour: 'blue'
-    }
-  ],
-  mortality: [],
-  measurements: [],
-  family: [],
-  images: [],
-  device: [],
-  collectionUnits: [
-    {
-      collection_category_id: 'a',
-      collection_unit_id: 'b',
-      critter_collection_unit_id: v4(),
-      category_name: 'Population Unit',
-      unit_name: 'pop'
-    }
-  ]
-};
-
-describe('Animal', () => {
-  describe('helper functions', () => {
-    describe(getAnimalFieldName.name, () => {
-      it('should format the name correctly when an index is provided', () => {
-        const name = getAnimalFieldName<{ a: string }>('markings', 'a', 1);
-        expect(name).toBe('markings.1.a');
-      });
-
-      it('should format the name correctly when no index provided', () => {
-        const name = getAnimalFieldName<{ a: string }>('markings', 'a');
-        expect(name).toBe('markings.a');
-      });
-
-      it('should format the name correctly when an index of 0 provided', () => {
-        const name = getAnimalFieldName<{ a: string }>('markings', 'a', 0);
-        expect(name).toBe('markings.0.a');
-      });
-    });
-
-    describe(lastAnimalValueValid.name, () => {
-      it('should return true when value empty array', () => {
-        const valid = lastAnimalValueValid('captures', animal);
-        expect(valid).toBe(true);
-      });
-
-      it('should return true when last value in section parses successfully with yup schema', () => {
-        const valid = lastAnimalValueValid('markings', {
-          ...animal,
-          markings: [
-            {
-              marking_type_id: 'a',
-              taxon_marking_body_location_id: 'b'
-            } as IAnimalMarking
-          ]
-        });
-        expect(valid).toBe(true);
-      });
-
-      it('should return false when last value in section parses unsuccessfully with yup schema', () => {
-        const valid = lastAnimalValueValid('markings', {
-          ...animal,
-          markings: [
-            {
-              marking_type_id: '',
-              taxon_marking_body_location_id: 'b'
-            } as IAnimalMarking
-          ]
-        });
-        expect(valid).toBe(false);
-      });
-    });
-
-    describe(isRequiredInSchema.name, () => {
-      const schema = yup.object({
-        prop: yup.string().required()
-      });
-
-      const schema2 = yup.object({
-        prop: yup.string()
-      });
-
-      it('should return true when prop required in yup schema', () => {
-        expect(isRequiredInSchema(schema, 'prop')).toBe(true);
-      });
-
-      it('should return false when prop not required in yup schema', () => {
-        expect(isRequiredInSchema(schema2, 'prop')).toBe(false);
-      });
-    });
-
-    describe(glt.name, () => {
-      it('should format the correct greater than msg', () => {
-        expect(glt(1)).toBe('Must be greater than or equal to 1');
-      });
-
-      it('should format the correct less than msg', () => {
-        expect(glt(1, false)).toBe('Must be less than or equal to 1');
-      });
-    });
-  });
-  describe('Critter Class', () => {
-    const critter = new Critter(animal);
-    it('should generate a critter name', () => {
-      expect(critter.name).toBe('animal-taxon');
-    });
-
-    it('constructor should generate a critter uuid', () => {
-      expect(critter.critter_id).toBeDefined();
-    });
-
-    it('constructor should create critter captures and locations', () => {
-      const c_capture = critter.captures[0];
-      const a_capture = animal.captures[0];
-
-      const captureLocationID = c_capture.capture_location_id;
-      const releaseLocationID = c_capture.release_location_id;
-
-      expect(critter.captures).toBeDefined();
-      expect(critter.captures.length).toBe(1);
-      expect(c_capture.critter_id).toBe(critter.critter_id);
-      expect(c_capture.release_comment).toBe(a_capture.release_comment);
-      expect(c_capture.capture_comment).toBe(a_capture.capture_comment);
-      expect(c_capture.capture_timestamp).toBe(a_capture.capture_timestamp);
-      expect(c_capture.release_timestamp).toBe(a_capture.release_timestamp);
-      expect(c_capture.capture_location_id).toBeDefined();
-      expect(c_capture.release_location_id).toBeDefined();
-      //one for capture one for release
-      expect(critter.locations.length).toBe(2);
-      critter.locations.forEach((l) => {
-        const hasLocationID = [captureLocationID, releaseLocationID].includes(l.location_id);
-        expect(hasLocationID).toBe(true);
-      });
-    });
-
-    it('constructor should create critter markings', () => {
-      const c_marking = critter.markings[0];
-      const a_marking = animal.markings[0];
-      expect(c_marking.critter_id).toBe(critter.critter_id);
-
-      expect(c_marking.marking_type_id).toBe(a_marking.marking_type_id);
-      expect(c_marking.taxon_marking_body_location_id).toBe(a_marking.taxon_marking_body_location_id);
-      expect(c_marking.primary_colour_id).toBe(a_marking.primary_colour_id);
-      expect(c_marking.secondary_colour_id).toBe(a_marking.secondary_colour_id);
-      expect(c_marking.marking_comment).toBe(a_marking.marking_comment);
-    });
-
-    it('constructor should create collections and strip extra vals', () => {
-      const c_collection = critter.collections[0];
-      const a_collection = animal.collectionUnits[0];
-      expect(c_collection.critter_id).toBe(critter.critter_id);
-      expect((c_collection as any)['collection_category_id']).not.toBeDefined();
-      expect(c_collection.collection_unit_id).toBe(a_collection.collection_unit_id);
-    });
-  });
-});
diff --git a/app/src/features/surveys/view/survey-animals/animal.ts b/app/src/features/surveys/view/survey-animals/animal.ts
index 77cfaba91a..ea3dbfb3be 100644
--- a/app/src/features/surveys/view/survey-animals/animal.ts
+++ b/app/src/features/surveys/view/survey-animals/animal.ts
@@ -1,11 +1,14 @@
 import { DATE_LIMIT } from 'constants/dateTimeFormats';
 import { default as dayjs } from 'dayjs';
-import { isEqual as deepEquals, omit, omitBy } from 'lodash-es';
+import { ICritterDetailedResponse } from 'interfaces/useCritterApi.interface';
+import { PROJECTION_MODE } from 'utils/mapProjectionHelpers';
 import yup from 'utils/YupSchema';
-import { v4 } from 'uuid';
-import { AnyObjectSchema, InferType, reach } from 'yup';
-import { AnimalTelemetryDeviceSchema } from './telemetry-device/device';
+import { AnyObjectSchema, InferType } from 'yup';
 
+/**
+ * Critterbase related enums.
+ *
+ */
 export enum ANIMAL_FORM_MODE {
   ADD = 'add',
   EDIT = 'edit'
@@ -18,36 +21,69 @@ export enum AnimalSex {
   HERM = 'Hermaphroditic'
 }
 
-/**
- * Provides an acceptable amount of type security with formik field names for animal forms
- * Returns formatted field name in regular or array format
- */
-export const getAnimalFieldName = (animalKey: keyof IAnimal, fieldKey: keyof T, idx?: number) => {
-  return idx === undefined ? `${animalKey}.${String(fieldKey)}` : `${animalKey}.${idx}.${String(fieldKey)}`;
-};
+export enum AnimalRelationship {
+  CHILD = 'children',
+  PARENT = 'parents'
+}
+
+export enum ANIMAL_SECTION {
+  GENERAL = 'General',
+  COLLECTION_UNITS = 'Ecological Units',
+  MARKINGS = 'Markings',
+  MEASUREMENTS = 'Measurements',
+  CAPTURES = 'Captures',
+  MORTALITY = 'Mortality',
+  FAMILY = 'Family'
+}
 
 /**
- * Checks if last added value in animal form is valid.
- * Used to disable add btn.
- * ie: Added measurement, checks the measurement has no errors.
+ * Shared props for animal section forms.
+ * example: MarkingAnimalForm
+ *
  */
-export const lastAnimalValueValid = (animalKey: keyof IAnimal, values: IAnimal) => {
-  const section = values[animalKey];
-  if (Array.isArray(section)) {
-    const lastIndex = section?.length - 1;
-    const lastValue = section[lastIndex];
-    if (!lastValue) {
-      return true;
+
+export type AnimalFormProps =
+  | {
+      /**
+       * When formMode 'ADD' formObject is undefined.
+       */
+      formObject?: never;
+      /**
+       * The form mode -> Add / EDIT.
+       */
+      formMode: ANIMAL_FORM_MODE.ADD;
+      /**
+       * The dialog open state.
+       */
+      open: boolean;
+      /**
+       * Callback when dialog closes.
+       */
+      handleClose: () => void;
+      /**
+       * Critterbase detailed critter object.
+       */
+      critter: ICritterDetailedResponse;
     }
-    const schema = reach(AnimalSchema, `${animalKey}[${lastIndex}]`);
-    return schema.isValidSync(lastValue);
-  }
-  return true;
-};
+  | {
+      /**
+       * When formMode 'EDIT' formObject is defined.
+       */
+      formObject: T;
+      formMode: ANIMAL_FORM_MODE.EDIT;
+      open: boolean;
+      handleClose: () => void;
+      critter: ICritterDetailedResponse;
+    };
 
 /**
  * Checks if property in schema is required. Used to keep required fields in sync with schema.
  * ie: { required: true } -> { required: isReq(Schema, 'property_name') }
+ *
+ * @template T - AnyObjectSchema
+ * @param {T} schema - Yup Schema.
+ * @param {keyof T['fields']} key - Property of yup schema.
+ * @returns {boolean} indicator if required in schema.
  */
 export const isRequiredInSchema = (schema: T, key: keyof T['fields']): boolean => {
   return Boolean(schema.fields[key].exclusiveTests.required);
@@ -58,494 +94,131 @@ export const glt = (num: number, greater = true) => `Must be ${greater ? 'greate
 const req = 'Required';
 const mustBeNum = 'Must be a number';
 const numSchema = yup.number().typeError(mustBeNum);
+
 const latSchema = yup.number().min(-90, glt(-90)).max(90, glt(90, false)).typeError(mustBeNum);
 const lonSchema = yup.number().min(-180, glt(-180)).max(180, glt(180, false)).typeError(mustBeNum);
+
 const dateSchema = yup
   .date()
   .min(dayjs(DATE_LIMIT.min), `Must be after ${DATE_LIMIT.min}`)
   .max(dayjs(DATE_LIMIT.max), `Must be before ${DATE_LIMIT.max}`)
   .typeError('Invalid date format');
 
-export type ProjectionMode = 'wgs' | 'utm';
+/**
+ * Critterbase create schemas.
+ *
+ */
 
-export const AnimalGeneralSchema = yup.object({}).shape({
-  itis_tsn: yup.string().required(req),
-  animal_id: yup.string().required(req),
-  itis_scientific_name: yup.string(),
-  wlh_id: yup.string(),
-  sex: yup.mixed().oneOf(Object.values(AnimalSex)),
-  critter_id: yup.string()
+export const LocationSchema = yup.object().shape({
+  /**
+   * This is useful for when you need to have different validation for the projection mode.
+   * example: easting/northing or lat/lng fields have different min max values.
+   */
+  projection_mode: yup.mixed().oneOf(Object.values(PROJECTION_MODE)).default(PROJECTION_MODE.WGS),
+
+  location_id: yup.string().optional(),
+  latitude: yup
+    .number()
+    .when('projection_mode', {
+      is: (projection_mode: PROJECTION_MODE) => projection_mode === PROJECTION_MODE.WGS,
+      then: latSchema
+    })
+    .when('projection_mode', {
+      is: (projection_mode: PROJECTION_MODE) => projection_mode === PROJECTION_MODE.UTM,
+      then: yup.number()
+    })
+    .required(req),
+  longitude: yup
+    .number()
+    .when('projection_mode', {
+      is: (projection_mode: PROJECTION_MODE) => projection_mode === PROJECTION_MODE.WGS,
+      then: lonSchema
+    })
+    .when('projection_mode', {
+      is: (projection_mode: PROJECTION_MODE) => projection_mode === PROJECTION_MODE.UTM,
+      then: yup.number()
+    })
+    .required(req),
+  coordinate_uncertainty: yup.number().required(req),
+  coordinate_uncertainty_unit: yup.string()
 });
 
-export const AnimalCaptureSchema = yup.object({}).shape({
-  capture_id: yup.string(),
-  capture_location_id: yup.string(),
-  release_location_id: yup.string(),
-  capture_longitude: lonSchema.when('projection_mode', { is: 'wgs', then: lonSchema.required(req) }),
-  capture_latitude: latSchema.when('projection_mode', { is: 'wgs', then: latSchema.required(req) }),
-  capture_utm_northing: numSchema.when('projection_mode', { is: 'utm', then: numSchema.required(req) }),
-  capture_utm_easting: numSchema.when('projection_mode', { is: 'utm', then: numSchema.required(req) }),
+export const CreateCritterCaptureSchema = yup.object({
+  capture_id: yup.string().optional(),
+  critter_id: yup.string().required(req),
+  capture_location: LocationSchema.required(),
+  release_location: LocationSchema.optional().default(undefined),
+  capture_comment: yup.string().optional(),
   capture_timestamp: dateSchema.required(req),
-  capture_coordinate_uncertainty: numSchema.required(req),
-  capture_comment: yup.string(),
-  projection_mode: yup.mixed().oneOf(['wgs', 'utm']),
-  show_release: yup.boolean().required(), // used for conditional required release fields
-  release_longitude: lonSchema.when(['projection_mode', 'show_release'], {
-    is: (projection_mode: ProjectionMode, show_release: boolean) => projection_mode === 'wgs' && show_release,
-    then: lonSchema.required(req)
-  }),
-  release_latitude: latSchema.when(['projection_mode', 'show_release'], {
-    is: (projection_mode: ProjectionMode, show_release: boolean) => projection_mode === 'wgs' && show_release,
-    then: latSchema.required(req)
-  }),
-  release_utm_northing: numSchema.when(['projection_mode', 'show_release'], {
-    is: (projection_mode: ProjectionMode, show_release: boolean) => projection_mode === 'utm' && show_release,
-    then: numSchema.required(req)
-  }),
-  release_utm_easting: numSchema.when(['projection_mode', 'show_release'], {
-    is: (projection_mode: ProjectionMode, show_release: boolean) => projection_mode === 'utm' && show_release,
-    then: numSchema.required(req)
-  }),
-  release_coordinate_uncertainty: numSchema.when('show_release', { is: true, then: numSchema.required(req) }),
-  release_timestamp: dateSchema /*.when('show_release', { is: true, then: dateSchema.required(req) }),*/,
+  release_timestamp: dateSchema.optional(),
   release_comment: yup.string().optional()
 });
 
-export const AnimalMarkingSchema = yup.object({
-  marking_id: yup.string(),
-  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(),
-  primary_colour: yup.string().optional(),
-  marking_type: yup.string().optional(),
-  body_location: yup.string().optional()
+export const CreateCritterSchema = yup.object({
+  critter_id: yup.string().optional(),
+  itis_tsn: yup.number().required(req),
+  animal_id: yup.string().required(req),
+  wlh_id: yup.string().optional(),
+  sex: yup.mixed().oneOf(Object.values(AnimalSex)).required(req)
 });
 
-export const AnimalCollectionUnitSchema = yup.object({}).shape({
-  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 CreateCritterMarkingSchema = yup.object({
+  marking_id: yup.string().optional(),
+  critter_id: yup.string().required(req),
+  marking_type_id: yup.string().required('Marking type is required'),
+  taxon_marking_body_location_id: yup.string().required('Body location required'),
+  primary_colour_id: yup.string().optional().nullable(),
+  secondary_colour_id: yup.string().optional().nullable(),
+  comment: yup.string().optional().nullable()
 });
 
-export const AnimalMeasurementSchema = yup.object({}).shape(
-  {
-    measurement_qualitative_id: yup.string(),
-    measurement_quantitative_id: yup.string(),
-    taxon_measurement_id: yup.string().required('Type is required'),
-    qualitative_option_id: yup.string().when('value', {
-      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('Value is required'),
-      otherwise: numSchema
-    }),
-    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({
-  mortality_id: yup.string(),
-  location_id: yup.string(),
-  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),
-  proximate_cause_of_death_confidence: yup.string().nullable(),
-  proximate_predated_by_taxon_id: yup.string().uuid(),
-  ultimate_cause_of_death_id: yup.string().uuid(),
-  ultimate_cause_of_death_confidence: yup.string(),
-  ultimate_predated_by_taxon_id: yup.string().uuid(),
-  projection_mode: yup.mixed().oneOf(['wgs', 'utm'])
+export const CreateCritterMeasurementSchema = yup.object({
+  critter_id: yup.string().required().required(req),
+  measurement_qualitative_id: yup.string().optional(),
+  measurement_quantitative_id: yup.string().optional(),
+  taxon_measurement_id: yup.string().required('Type is required'),
+  qualitative_option_id: yup.string().optional(),
+  value: numSchema.required(req).optional(),
+  measured_timestamp: dateSchema.required('Date is required'),
+  measurement_comment: yup.string().optional()
 });
 
-export const AnimalRelationshipSchema = yup.object({}).shape({
-  family_id: yup.string().required(req),
-  relationship: yup.mixed().oneOf(['parent', 'child', 'sibling']).required(req)
+export const CreateCritterCollectionUnitSchema = yup.object({
+  critter_collection_unit_id: yup.string().optional(),
+  critter_id: yup.string().required(req),
+  collection_unit_id: yup.string().required('Name is required'),
+  collection_category_id: yup.string().required('Category is required')
 });
 
-const AnimalImageSchema = yup.object({}).shape({});
-
-export const AnimalSchema = yup.object({}).shape({
-  general: AnimalGeneralSchema,
-  captures: yup.array().of(AnimalCaptureSchema).required(),
-  markings: yup.array().of(AnimalMarkingSchema).required(),
-  measurements: yup.array().of(AnimalMeasurementSchema).required(),
-  mortality: yup.array().of(AnimalMortalitySchema).required(),
-  family: yup.array().of(AnimalRelationshipSchema).required(),
-  images: yup.array().of(AnimalImageSchema).required(),
-  collectionUnits: yup.array().of(AnimalCollectionUnitSchema).required(),
-  device: yup.array().of(AnimalTelemetryDeviceSchema).required()
+export const CreateCritterMortalitySchema = yup.object({
+  mortality_id: yup.string().optional(),
+  location: LocationSchema.required(),
+  mortality_timestamp: dateSchema.required('Mortality Date is required'),
+  mortality_comment: yup.string().optional(),
+  proximate_cause_of_death_id: yup.string().uuid().required(req),
+  proximate_cause_of_death_confidence: yup.string().nullable(),
+  proximate_predated_by_itis_tsn: yup.number().optional(),
+  ultimate_cause_of_death_id: yup.string().uuid().optional(),
+  ultimate_cause_of_death_confidence: yup.string().optional(),
+  ultimate_predated_by_itis_tsn: yup.number().optional()
 });
 
-export const LocationSchema = yup.object({}).shape({
-  location_id: yup.string(),
-  latitude: yup.number(),
-  longitude: yup.number(),
-  coordinate_uncertainty: yup.number(),
-  coordinate_uncertainty_unit: yup.string()
+export const CreateCritterFamilySchema = yup.object({
+  critter_id: yup.string().uuid().required(),
+  family_id: yup.string().optional(),
+  family_label: yup.string().optional(),
+  relationship: yup.mixed().oneOf(Object.values(AnimalRelationship)).required(req)
 });
 
-//Animal form related types
-
-export type IAnimalGeneral = InferType;
-
-export type IAnimalCapture = InferType;
-
-export type IAnimalMarking = InferType;
-
-export type IAnimalCollectionUnit = InferType;
-
-export type IAnimalMeasurement = InferType;
-
-export type IAnimalMortality = InferType;
-
-export type IAnimalRelationship = InferType;
-
-export type IAnimalImage = InferType;
-
-export type IAnimal = InferType;
-
-export type IAnimalKey = keyof IAnimal;
-
-//Critterbase related types
-type ICritterID = { critter_id: string };
-
-type ICritterLocation = InferType;
-
-type ICritterMortality = Omit<
-  ICritterID &
-    IAnimalMortality & {
-      location_id: string;
-      mortality_id: string | undefined;
-      location?: ICritterLocation;
-    },
-  | '_id'
-  | 'mortality_utm_easting'
-  | 'mortality_utm_northing'
-  | 'projection_mode'
-  | 'mortality_latitude'
-  | 'mortality_longitude'
-  | 'mortality_coordinate_uncertainty'
->;
-
-type ICritterCapture = Omit<
-  ICritterID &
-    Pick & {
-      capture_id: string | undefined;
-      capture_location_id: string;
-      release_location_id: string | undefined;
-      capture_location?: ICritterLocation;
-      release_location?: ICritterLocation;
-      force_create_release?: boolean;
-    },
-  '_id'
->;
-
-export type ICritterMarking = Omit;
-
-export type ICritterCollection = Omit;
-
-type ICritterQualitativeMeasurement = Omit<
-  ICritterID & IAnimalMeasurement,
-  'value' | '_id' | 'option_label' | 'measurement_name'
->;
-
-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[] };
-
-export const newFamilyIdPlaceholder = 'New Family';
-
-type ICritterFamilyParent = {
-  family_id: string;
-  parent_critter_id: string;
-  _delete?: boolean;
-};
-
-type ICritterFamilyChild = {
-  family_id: string;
-  child_critter_id: string;
-  _delete?: boolean;
-};
-
-type ICritterFamily = {
-  family_id: string;
-  family_label: string;
-};
-
-type ICritterRelationships = {
-  parents: ICritterFamilyParent[];
-  children: ICritterFamilyChild[];
-  families: ICritterFamily[];
-};
-
-//Converts IAnimal(Form data) to a Critterbase Critter
-
-export class Critter {
-  critter_id: string;
-  itis_tsn: string;
-  animal_id: string;
-  wlh_id?: string;
-  sex?: AnimalSex;
-  captures: ICritterCapture[];
-  markings: ICritterMarking[];
-  measurements: {
-    qualitative: ICritterQualitativeMeasurement[];
-    quantitative: ICritterQuantitativeMeasurement[];
-  };
-  mortalities: Omit[];
-  families: ICritterRelationships;
-  locations: ICritterLocation[];
-  collections: ICritterCollection[];
-
-  private itis_scientific_name?: string;
-
-  get name(): string {
-    return `${this.animal_id}-${this.itis_scientific_name}`;
-  }
-
-  _formatCritterCaptures(animal_captures: IAnimalCapture[]): ICapturesAndLocations {
-    const formattedCaptures: ICritterCapture[] = [];
-    const formattedLocations: ICritterLocation[] = [];
-    animal_captures.forEach((capture) => {
-      const cleanedCapture = omitBy(capture, (value) => value === '') as IAnimalCapture;
-      const c_loc_id = v4();
-      let r_loc_id: string | undefined = undefined;
-
-      const capture_location = {
-        latitude: Number(capture.capture_latitude),
-        longitude: Number(capture.capture_longitude),
-        coordinate_uncertainty: Number(capture.capture_coordinate_uncertainty),
-        coordinate_uncertainty_unit: 'm'
-      };
-      let release_location = undefined;
-      if (capture.release_latitude && capture.release_longitude) {
-        r_loc_id = v4();
-
-        release_location = {
-          latitude: Number(capture.release_latitude),
-          longitude: Number(capture.release_longitude),
-          coordinate_uncertainty: Number(capture.release_coordinate_uncertainty),
-          coordinate_uncertainty_unit: 'm'
-        };
-      }
-
-      let force_create_release = false;
-      if (release_location && !deepEquals(capture_location, release_location)) {
-        force_create_release = true;
-      }
-
-      formattedCaptures.push({
-        force_create_release: force_create_release,
-        capture_id: cleanedCapture.capture_id,
-        critter_id: this.critter_id,
-        capture_location_id: cleanedCapture.capture_location_id ?? c_loc_id,
-        release_location_id: cleanedCapture.release_location_id ?? r_loc_id,
-        capture_timestamp: cleanedCapture.capture_timestamp,
-        release_timestamp: cleanedCapture.release_timestamp,
-        capture_comment: cleanedCapture.capture_comment,
-        release_comment: cleanedCapture.release_comment,
-        capture_location: cleanedCapture.capture_location_id
-          ? { ...capture_location, location_id: cleanedCapture.capture_location_id }
-          : undefined,
-        release_location:
-          release_location && cleanedCapture.release_location_id
-            ? { ...release_location, location_id: cleanedCapture.release_location_id }
-            : undefined
-      });
-
-      if (!cleanedCapture.capture_location_id) {
-        formattedLocations.push({ ...capture_location, location_id: c_loc_id });
-      }
-      if (release_location && !cleanedCapture.release_location_id) {
-        formattedLocations.push({ ...release_location, location_id: r_loc_id });
-      }
-    });
-
-    return { captures: formattedCaptures, capture_locations: formattedLocations };
-  }
-
-  _formatCritterMortalities(animal_mortalities: IAnimalMortality[]): IMortalityAndLocation {
-    const formattedMortalities: ICritterMortality[] = [];
-    const formattedLocations: ICritterLocation[] = [];
-    animal_mortalities.forEach((mortality) => {
-      const cleanedMortality = omitBy(mortality, (value) => value === '') as IAnimalMortality;
-      const loc_id = v4();
-
-      const mortality_location = {
-        latitude: Number(mortality.mortality_latitude),
-        longitude: Number(mortality.mortality_longitude),
-        coordinate_uncertainty: Number(mortality.mortality_coordinate_uncertainty),
-        coordinate_uncertainty_unit: 'm'
-      };
-
-      formattedMortalities.push({
-        critter_id: this.critter_id,
-        location_id: cleanedMortality.location_id ?? loc_id,
-        mortality_id: cleanedMortality.mortality_id,
-        mortality_timestamp: cleanedMortality.mortality_timestamp,
-        mortality_comment: cleanedMortality.mortality_comment,
-        proximate_predated_by_taxon_id: cleanedMortality.proximate_predated_by_taxon_id,
-        proximate_cause_of_death_id: cleanedMortality.proximate_cause_of_death_id,
-        proximate_cause_of_death_confidence: cleanedMortality.proximate_cause_of_death_confidence,
-        ultimate_cause_of_death_id: cleanedMortality.ultimate_cause_of_death_id,
-        ultimate_cause_of_death_confidence: cleanedMortality.ultimate_cause_of_death_confidence,
-        ultimate_predated_by_taxon_id: cleanedMortality.ultimate_predated_by_taxon_id,
-        location: cleanedMortality.location_id
-          ? { ...mortality_location, location_id: cleanedMortality.location_id }
-          : undefined
-      });
-
-      if (!cleanedMortality.location_id) {
-        formattedLocations.push({ ...mortality_location, location_id: loc_id });
-      }
-    });
-    return { mortalities: formattedMortalities, mortalities_locations: formattedLocations };
-  }
-
-  _formatCritterMarkings(animal_markings: IAnimalMarking[]): ICritterMarking[] {
-    return animal_markings.map((marking) => {
-      const cleanedMarking = omitBy(marking, (value) => value === '') as IAnimalMarking;
-      return {
-        critter_id: this.critter_id,
-        ...omit(cleanedMarking, '_id')
-      };
-    });
-  }
-
-  _formatCritterCollectionUnits(animal_collections: IAnimalCollectionUnit[]): ICritterCollection[] {
-    return animal_collections.map((collection) => ({
-      critter_id: this.critter_id,
-      ...omit(collection, ['collection_category_id'])
-    }));
-  }
-
-  _formatCritterQualitativeMeasurements(animal_measurements: IAnimalMeasurement[]): ICritterQualitativeMeasurement[] {
-    const filteredQualitativeMeasurements = animal_measurements.filter((measurement) => {
-      if (measurement.qualitative_option_id && measurement.value) {
-        // Qualitative measurement must only contain option_id and no value
-        return false;
-      }
-      return measurement.qualitative_option_id;
-    });
-
-    return filteredQualitativeMeasurements.map((qual_measurement) => ({
-      critter_id: this.critter_id,
-      measurement_qualitative_id: qual_measurement.measurement_qualitative_id,
-      measurement_quantitative_id: undefined,
-      taxon_measurement_id: qual_measurement.taxon_measurement_id,
-      qualitative_option_id: qual_measurement.qualitative_option_id,
-      measured_timestamp: qual_measurement.measured_timestamp || undefined,
-      measurement_comment: qual_measurement.measurement_comment || undefined
-    }));
-  }
-
-  _formatCritterQuantitativeMeasurements(animal_measurements: IAnimalMeasurement[]): ICritterQuantitativeMeasurement[] {
-    const filteredQuantitativeMeasurements = animal_measurements.filter((measurement) => {
-      if (measurement.qualitative_option_id && measurement.value) {
-        return false;
-      }
-      return measurement.value;
-    });
-    return filteredQuantitativeMeasurements.map((quant_measurement) => {
-      return {
-        critter_id: this.critter_id,
-        measurement_qualitative_id: undefined,
-        measurement_quantitative_id: quant_measurement.measurement_quantitative_id,
-        taxon_measurement_id: quant_measurement.taxon_measurement_id,
-        value: Number(quant_measurement.value),
-        measured_timestamp: quant_measurement.measured_timestamp || undefined,
-        measurement_comment: quant_measurement.measurement_comment || undefined
-      };
-    });
-  }
-
-  _formatCritterFamilyRelationships(animal_family: IAnimalRelationship[]): ICritterRelationships {
-    let newFamily: ICritterFamily | undefined = undefined;
-    const families: ICritterFamily[] = [];
-    for (const fam of animal_family) {
-      //If animal form had the newFamilyIdPlaceholder used at some point, make a real uuid for the new family and add it for creation.
-      if (fam.family_id === newFamilyIdPlaceholder) {
-        if (!newFamily) {
-          newFamily = { family_id: v4(), family_label: this.name + '_family' };
-          families.push(newFamily);
-        }
-      }
-    }
-
-    const parents = animal_family
-      .filter((parent) => parent.relationship === 'parent')
-      .map((parent_fam) => ({
-        family_id:
-          parent_fam.family_id === newFamilyIdPlaceholder && newFamily ? newFamily.family_id : parent_fam.family_id,
-        parent_critter_id: this.critter_id
-      }));
-
-    const children = animal_family
-      .filter((children) => children.relationship === 'child')
-      .map((children_fam) => ({
-        family_id:
-          children_fam.family_id === newFamilyIdPlaceholder && newFamily ? newFamily.family_id : children_fam.family_id,
-        child_critter_id: this.critter_id
-      }));
-    //Currently not supporting siblings in the UI
-    return { parents, children, families };
-  }
-
-  constructor(animal: IAnimal) {
-    this.critter_id = animal.general.critter_id ? animal.general.critter_id : v4();
-    this.itis_tsn = animal.general.itis_tsn;
-    this.itis_scientific_name = animal.general.itis_scientific_name;
-    this.animal_id = animal.general.animal_id;
-    this.wlh_id = animal.general.wlh_id;
-    this.sex = animal.general.sex;
-    const { captures, capture_locations } = this._formatCritterCaptures(animal.captures);
-    const { mortalities, mortalities_locations } = this._formatCritterMortalities(animal.mortality);
-
-    this.captures = captures;
-    this.mortalities = mortalities;
-    this.locations = [...capture_locations, ...mortalities_locations];
-
-    this.markings = this._formatCritterMarkings(animal.markings);
-    this.collections = this._formatCritterCollectionUnits(animal.collectionUnits);
-
-    this.measurements = {
-      qualitative: this._formatCritterQualitativeMeasurements(animal.measurements),
-      quantitative: this._formatCritterQuantitativeMeasurements(animal.measurements)
-    };
-
-    const { parents, children, families } = this._formatCritterFamilyRelationships(animal.family);
-    this.families = { parents, children, families };
-  }
-}
+/**
+ * Critterbase schema infered types.
+ *
+ */
+export type ICreateCritter = InferType;
+
+export type ICreateCritterMarking = InferType;
+export type ICreateCritterMeasurement = InferType;
+export type ICreateCritterCollectionUnit = InferType;
+export type ICreateCritterCapture = InferType;
+export type ICreateCritterFamily = InferType;
+export type ICreateCritterMortality = InferType;
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 e4b9859408..4f1b50371d 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,113 +1,322 @@
-import { Box, Checkbox, FormControlLabel, Grid, Stack } from '@mui/material';
+import { Box, Checkbox, FormControlLabel, Grid, Stack, Switch } from '@mui/material';
 import Typography from '@mui/material/Typography';
+import EditDialog from 'components/dialog/EditDialog';
 import CustomTextField from 'components/fields/CustomTextField';
 import SingleDateField from 'components/fields/SingleDateField';
 import { SurveyAnimalsI18N } from 'constants/i18n';
-import { useFormikContext } from 'formik';
-import { AnimalCaptureSchema, getAnimalFieldName, IAnimal, IAnimalCapture, isRequiredInSchema } from '../animal';
-import LocationEntryForm from './LocationEntryForm';
+import { Field, useFormikContext } from 'formik';
+import { useDialogContext } from 'hooks/useContext';
+import { useCritterbaseApi } from 'hooks/useCritterbaseApi';
+import { ICaptureResponse } from 'interfaces/useCritterApi.interface';
+import React, { useState } from 'react';
+import { getLatLngAsUtm, getUtmAsLatLng, PROJECTION_MODE } from 'utils/mapProjectionHelpers';
+import {
+  AnimalFormProps,
+  ANIMAL_FORM_MODE,
+  CreateCritterCaptureSchema,
+  ICreateCritterCapture,
+  isRequiredInSchema
+} from '../animal';
+import FormLocationPreview from './LocationEntryForm';
 
 /**
- * Renders the Capture form inputs
+ * This component renders a 'critter capture' create / edit dialog.
  *
- * index of formik array item
- * @param {index}
+ * Ties into the LocationEntryForm to display capture / release details on map.
+ * Handles additional conversion of UTM <--> WGS coordinates during edit and submission.
  *
- * @return {*}
- *
- **/
+ * @param {AnimalFormProps} props - Generic AnimalFormProps.
+ * @returns {*}
+ */
+export const CaptureAnimalForm = (props: AnimalFormProps) => {
+  const cbApi = useCritterbaseApi();
+  const dialog = useDialogContext();
+
+  const [loading, setLoading] = useState(false);
+  const [projectionMode, setProjectionMode] = useState(PROJECTION_MODE.WGS);
+
+  const handleSave = async (values: ICreateCritterCapture) => {
+    setLoading(true);
+    try {
+      if (projectionMode === PROJECTION_MODE.UTM) {
+        if (values.release_location) {
+          const [latitude, longitude] = getUtmAsLatLng(
+            values.release_location.latitude,
+            values.release_location.longitude
+          );
+          values = { ...values, release_location: { ...values.release_location, latitude, longitude } };
+        }
+        const [latitude, longitude] = getUtmAsLatLng(
+          values.capture_location.latitude,
+          values.capture_location.longitude
+        );
+        values = { ...values, capture_location: { ...values.capture_location, latitude, longitude } };
+      }
+      if (props.formMode === ANIMAL_FORM_MODE.ADD) {
+        await cbApi.capture.createCapture(values);
+        dialog.setSnackbar({ open: true, snackbarMessage: `Successfully created capture.` });
+      }
+      if (props.formMode === ANIMAL_FORM_MODE.EDIT) {
+        await cbApi.capture.updateCapture(values);
+        dialog.setSnackbar({ open: true, snackbarMessage: `Successfully edited capture.` });
+      }
+    } catch (err) {
+      dialog.setSnackbar({ open: true, snackbarMessage: `Critter capture request failed.` });
+    } finally {
+      props.handleClose();
+      setLoading(false);
+    }
+  };
+
+  return (
+    
+        )
+      }}
+    />
+  );
+};
+
+type CaptureFormProps = Pick, 'formMode'> & {
+  projectionMode: PROJECTION_MODE;
+  handleProjection: (projection: PROJECTION_MODE) => void;
+};
+
+const CaptureFormFields = (props: CaptureFormProps) => {
+  const { values, setValues, setFieldValue } = useFormikContext();
+
+  const [showRelease, setShowRelease] = useState(values.release_location);
 
-interface CaptureAnimalFormContentProps {
-  index: number;
-}
+  const isUtmProjection = props.projectionMode === PROJECTION_MODE.UTM;
 
-export const CaptureAnimalFormContent = ({ index }: CaptureAnimalFormContentProps) => {
-  const name: keyof IAnimal = 'captures';
+  const disableUtmToggle =
+    !values.capture_location.latitude ||
+    !values.capture_location.longitude ||
+    (showRelease && !values?.release_location?.latitude) ||
+    !values?.release_location?.longitude;
 
-  const { values, handleChange } = useFormikContext();
+  const handleShowRelease = () => {
+    /**
+     * If release is currently showing wipe existing values in release_location.
+     *
+     */
+    if (showRelease) {
+      setFieldValue('release_location', undefined);
+      setShowRelease(false);
+      return;
+    }
+    setValues({
+      ...values,
+      release_location: { latitude: '', longitude: '', coordinate_uncertainty: '', coordinate_uncertainty_unit: 'm' }
+    });
+    setShowRelease(true);
+  };
 
-  const value = values.captures?.[index];
+  const handleProjectionChange = () => {
+    const switchProjection = isUtmProjection ? PROJECTION_MODE.WGS : PROJECTION_MODE.UTM;
 
-  const showReleaseSection = value?.show_release;
+    /**
+     * These projection conversions are expecting non null values for lat/lng.
+     * UI currently hides the UTM toggle when these values are not defined in the form.
+     *
+     */
+    const [captureLat, captureLon] = !isUtmProjection
+      ? getLatLngAsUtm(values.capture_location.latitude, values.capture_location.longitude)
+      : getUtmAsLatLng(values.capture_location.latitude, values.capture_location.longitude);
+
+    const [releaseLat, releaseLon] = !isUtmProjection
+      ? getLatLngAsUtm(values.release_location.latitude, values.release_location.longitude)
+      : getUtmAsLatLng(values.release_location.latitude, values.release_location.longitude);
+
+    setValues({
+      ...values,
+      capture_location: {
+        ...values.capture_location,
+        projection_mode: switchProjection,
+        latitude: captureLat,
+        longitude: captureLon
+      },
+      release_location: {
+        ...values.release_location,
+        projection_mode: switchProjection,
+        latitude: releaseLat,
+        longitude: releaseLon
+      }
+    });
+
+    props.handleProjection(switchProjection);
+  };
 
   return (
     
       
-        Event Dates
+        
+          Event Dates
+          }
+            label="UTM"
+          />
+        
         
           
-            (name, 'capture_timestamp', index)}
-              required={true}
-              label={'Capture Date'}
-            />
+            
           
           
-            (name, 'release_timestamp', index)}
-              label={'Release Date'}
+            
+          
+        
+      
+
+      
+        Capture Location
+        
+          
+            
+          
+          
+            
           
+          
+            
+          
+          {props.formMode === ANIMAL_FORM_MODE.ADD ? (
+            
+              }
+                label={SurveyAnimalsI18N.animalCaptureReleaseRadio}
+              />
+            
+          ) : null}
         
       
 
-      
+          Release Location
+          
+            
+              
+            
+            
+              
+            
+            
+              
+            
+          
+        
+      ) : null}
+
+      
-            Release Location
-            (name, 'show_release', index)}
-                />
-              }
-              label={SurveyAnimalsI18N.animalCaptureReleaseRadio}
-            />
-          
-        ]}
       />
-
       
         Additional Information
         (name, 'capture_comment', index)}
+          name={'capture_comment'}
         />
       
     
   );
 };
 
-export default CaptureAnimalFormContent;
+export default CaptureAnimalForm;
diff --git a/app/src/features/surveys/view/survey-animals/form-sections/CollectionUnitAnimalForm.test.tsx b/app/src/features/surveys/view/survey-animals/form-sections/CollectionUnitAnimalForm.test.tsx
index a45a308e8d..67280fb4ff 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,22 +1,22 @@
-import { Formik } from 'formik';
 import { useCritterbaseApi } from 'hooks/useCritterbaseApi';
+import { ICritterDetailedResponse } from 'interfaces/useCritterApi.interface';
 import { render, waitFor } from 'test-helpers/test-utils';
-import { ANIMAL_SECTIONS_FORM_MAP } from '../animal-sections';
-import CollectionUnitAnimalFormContent from './CollectionUnitAnimalForm';
+import { ANIMAL_FORM_MODE } from '../animal';
+import CollectionUnitAnimalForm from './CollectionUnitAnimalForm';
 
 jest.mock('hooks/useCritterbaseApi');
 
 const mockUseCritterbaseApi = useCritterbaseApi as jest.Mock;
 
+const mockHandleClose = jest.fn();
+
 const mockUseCritterbase = {
   lookup: {
     getSelectOptions: jest.fn()
   }
 };
 
-const defaultCollectionUnit = ANIMAL_SECTIONS_FORM_MAP['Ecological Units'].defaultFormValue;
-
-describe('CollectionUnitAnimalForm', () => {
+describe('CollectionUnitForm', () => {
   beforeEach(() => {
     mockUseCritterbaseApi.mockImplementation(() => mockUseCritterbase);
     mockUseCritterbase.lookup.getSelectOptions.mockClear();
@@ -27,11 +27,12 @@ describe('CollectionUnitAnimalForm', () => {
     ]);
 
     const { getByText } = render(
-       {}}>
-        {() => }
-      
+      
     );
 
     await waitFor(() => {
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 3f544d1d18..ec6871daf5 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,72 +1,111 @@
 import { Grid } from '@mui/material';
+import EditDialog from 'components/dialog/EditDialog';
 import CbSelectField from 'components/fields/CbSelectField';
-import { useFormikContext } from 'formik';
+import { Field, FieldProps } from 'formik';
+import { useDialogContext } from 'hooks/useContext';
+import { useCritterbaseApi } from 'hooks/useCritterbaseApi';
+import { ICollectionUnitResponse } from 'interfaces/useCritterApi.interface';
+import { get } from 'lodash-es';
+import React, { useState } from 'react';
 import {
-  AnimalCollectionUnitSchema,
-  getAnimalFieldName,
-  IAnimal,
-  IAnimalCollectionUnit,
+  AnimalFormProps,
+  ANIMAL_FORM_MODE,
+  CreateCritterCollectionUnitSchema,
+  ICreateCritterCollectionUnit,
   isRequiredInSchema
 } from '../animal';
 
-interface ICollectionUnitAnimalFormContentProps {
-  index: number;
-}
-
-export const CollectionUnitAnimalFormContent = ({ index }: ICollectionUnitAnimalFormContentProps) => {
-  const name: keyof IAnimal = 'collectionUnits';
-
-  const { values, setFieldValue } = useFormikContext();
+/**
+ * This component renders a 'critter collection unit' create / edit dialog.
+ *
+ * @param {AnimalFormProps} props - Generic AnimalFormProps.
+ * @returns {*}
+ */
+export const CollectionUnitAnimalForm = (props: AnimalFormProps) => {
+  const cbApi = useCritterbaseApi();
+  const dialog = useDialogContext();
   //Animals may have multiple collection units, but only one instance of each category.
   //We use this and pass to the select component to ensure categories already used in the form can't be selected again.
-  const disabledCategories = values.collectionUnits.reduce((acc: Record, curr) => {
+  const disabledCategories = props.critter.collection_units.reduce((acc: Record, curr) => {
     if (curr.collection_category_id) {
       acc[curr.collection_category_id] = true;
     }
     return acc;
   }, {});
 
-  const handleCategoryName = (_value: string, label: string) => {
-    setFieldValue(getAnimalFieldName(name, 'category_name', index), label);
-  };
+  const [loading, setLoading] = useState(false);
 
-  const handleUnitName = (_value: string, label: string) => {
-    setFieldValue(getAnimalFieldName(name, 'unit_name', index), label);
+  const handleSave = async (values: ICreateCritterCollectionUnit) => {
+    setLoading(true);
+    try {
+      if (props.formMode === ANIMAL_FORM_MODE.ADD) {
+        await cbApi.collectionUnit.createCollectionUnit(values);
+        dialog.setSnackbar({ open: true, snackbarMessage: `Successfully created ecological unit.` });
+      }
+      if (props.formMode === ANIMAL_FORM_MODE.EDIT) {
+        await cbApi.collectionUnit.updateCollectionUnit(values);
+        dialog.setSnackbar({ open: true, snackbarMessage: `Successfully edited ecological unit.` });
+      }
+    } catch (err) {
+      dialog.setSnackbar({ open: true, snackbarMessage: `Critter ecological unit request failed.` });
+    } finally {
+      props.handleClose();
+      setLoading(false);
+    }
   };
 
   return (
-    
-      
-        (name, 'collection_category_id', index)}
-          id={'collection_category_id'}
-          disabledValues={disabledCategories}
-          query={`itis_tsn=${values.general.itis_tsn}`}
-          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}
-        />
-      
-    
+    
+            
+              
+            
+            
+              
+                {({ form }: FieldProps) => (
+                  
+                )}
+              
+            
+          
+        )
+      }}
+    />
   );
 };
 
-export default CollectionUnitAnimalFormContent;
+export default CollectionUnitAnimalForm;
diff --git a/app/src/features/surveys/view/survey-animals/form-sections/FamilyAnimalForm.test.tsx b/app/src/features/surveys/view/survey-animals/form-sections/FamilyAnimalForm.test.tsx
index f9e75b92e8..533f37f741 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,11 +1,13 @@
-import { Formik } from 'formik';
 import { useCritterbaseApi } from 'hooks/useCritterbaseApi';
+import { ICritterDetailedResponse } from 'interfaces/useCritterApi.interface';
 import { render, waitFor } from 'test-helpers/test-utils';
-import FamilyAnimalFormContent from './FamilyAnimalForm';
+import { ANIMAL_FORM_MODE } from '../animal';
+import FamilyAnimalForm from './FamilyAnimalForm';
 
 jest.mock('hooks/useCritterbaseApi');
 
 const mockUseCritterbaseApi = useCritterbaseApi as jest.Mock;
+const mockHandleClose = jest.fn();
 
 const mockUseCritterbase = {
   lookup: {
@@ -26,16 +28,17 @@ describe('FamilyAnimalForm', () => {
     mockUseCritterbase.lookup.getSelectOptions.mockResolvedValueOnce([{ id: 'a', value: 'a', label: 'family_1' }]);
     mockUseCritterbase.family.getAllFamilies.mockResolvedValueOnce([{ family_id: 'a', family_label: 'family_1' }]);
     const { getByText } = render(
-       {}}>
-        {() => }
-      
+      
     );
 
     await waitFor(() => {
-      expect(getByText('Family ID')).toBeInTheDocument();
-      expect(getByText('Relationship')).toBeInTheDocument();
+      expect(getByText(/add family relationship/i)).toBeInTheDocument();
+      expect(getByText(/child in/i)).toBeInTheDocument();
     });
   });
 });
diff --git a/app/src/features/surveys/view/survey-animals/form-sections/FamilyAnimalForm.tsx b/app/src/features/surveys/view/survey-animals/form-sections/FamilyAnimalForm.tsx
index a66b0f1975..48f0c056b1 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
@@ -1,180 +1,262 @@
-import { Box, Button, Grid, MenuItem, Paper, Theme, Typography } from '@mui/material';
+import { Box, Button, Checkbox, FormControlLabel, Grid, MenuItem, Paper, Typography } from '@mui/material';
 import { grey } from '@mui/material/colors';
-import { makeStyles } from '@mui/styles';
 import ComponentDialog from 'components/dialog/ComponentDialog';
+import EditDialog from 'components/dialog/EditDialog';
 import { CbSelectWrapper } from 'components/fields/CbSelectFieldWrapper';
-import { useFormikContext } from 'formik';
-import { IFamily } from 'hooks/cb_api/useFamilyApi';
+import CustomTextField from 'components/fields/CustomTextField';
+import { useDialogContext } from 'hooks/useContext';
 import { useCritterbaseApi } from 'hooks/useCritterbaseApi';
 import useDataLoader from 'hooks/useDataLoader';
+import { IFamilyChildResponse, IFamilyParentResponse } from 'interfaces/useCritterApi.interface';
 import { useState } from 'react';
 import {
-  AnimalRelationshipSchema,
-  getAnimalFieldName,
-  IAnimal,
-  IAnimalRelationship,
-  isRequiredInSchema,
-  newFamilyIdPlaceholder
+  AnimalFormProps,
+  AnimalRelationship,
+  ANIMAL_FORM_MODE,
+  CreateCritterFamilySchema,
+  ICreateCritterFamily,
+  isRequiredInSchema
 } from '../animal';
-const useStyles = makeStyles((theme: Theme) => ({
-  surveyMetadataContainer: {
-    '& dt': {
-      flex: '0 0 40%'
-    },
-    '& dd': {
-      flex: '1 1 auto'
-    },
-    '& h4': {
-      fontSize: '14px',
-      fontWeight: 700,
-      letterSpacing: '0.02rem',
-      textTransform: 'uppercase',
-      color: grey[600],
-      '& + hr': {
-        marginTop: theme.spacing(1.5),
-        marginBottom: theme.spacing(1.5)
+
+/**
+ * This component renders a 'critter family relationship' create / edit dialog.
+ *
+ * @param {AnimalFormProps} props - Generic AnimalFormProps.
+ * @returns {*}
+ */
+export const FamilyAnimalForm = (props: AnimalFormProps) => {
+  const cbApi = useCritterbaseApi();
+  const dialog = useDialogContext();
+
+  const [showFamilyStructure, setShowFamilyStructure] = useState(false);
+  const [createNewFamily, setCreateNewFamily] = useState(false);
+  const [loading, setLoading] = useState(false);
+
+  const { data: familyHierarchy, load: loadHierarchy } = useDataLoader(cbApi.family.getImmediateFamily);
+  const {
+    data: allFamilies,
+    load: loadFamilies,
+    refresh: refreshFamilies
+  } = useDataLoader(cbApi.family.getAllFamilies);
+
+  loadFamilies();
+
+  const initialValues = {
+    critter_id: props.critter.critter_id,
+    family_id: props?.formObject?.family_id,
+    family_label: props?.formObject?.family_label ?? '',
+    relationship: (props?.formObject as IFamilyParentResponse)?.parent_critter_id
+      ? AnimalRelationship.PARENT
+      : AnimalRelationship.CHILD
+  };
+
+  const handleSave = async (values: ICreateCritterFamily) => {
+    setLoading(true);
+    try {
+      if (props.formMode === ANIMAL_FORM_MODE.ADD) {
+        if (values.family_label) {
+          const family = await cbApi.family.createFamily(values.family_label);
+          values.family_id = family.family_id;
+        }
+        await cbApi.family.createFamilyRelationship(values);
+
+        dialog.setSnackbar({ open: true, snackbarMessage: `Successfully created family relationship.` });
       }
-    }
-  }
-}));
+      if (props.formMode === ANIMAL_FORM_MODE.EDIT) {
+        if (!values.family_id || !initialValues.family_id) {
+          throw new Error(`family_id should be defined`);
+        }
+
+        if (values.family_label) {
+          await cbApi.family.editFamily(initialValues.family_id, values.family_label);
+        }
 
-interface IFamilyAnimalFormContentProps {
-  index: number;
-  allFamilies?: IFamily[];
-}
+        await cbApi.family.deleteRelationship({
+          family_id: initialValues.family_id,
+          critter_id: initialValues.critter_id,
+          relationship: initialValues.relationship
+        });
 
-export const FamilyAnimalFormContent = ({ index, allFamilies }: IFamilyAnimalFormContentProps) => {
-  const name: keyof IAnimal = 'family';
-  const { values, handleChange } = useFormikContext();
-  const disabledFamilyIds = values.family.reduce((acc: Record, curr) => {
-    if (curr.family_id) {
-      acc[curr.family_id] = true;
+        await cbApi.family.createFamilyRelationship({
+          family_id: values.family_id,
+          critter_id: values.critter_id,
+          relationship: values.relationship
+        });
+
+        dialog.setSnackbar({ open: true, snackbarMessage: `Successfully edited family relationship.` });
+      }
+    } catch (err) {
+      dialog.setSnackbar({ open: true, snackbarMessage: `Critter family relationship request failed.` });
+    } finally {
+      refreshFamilies();
+      props.handleClose();
+      setLoading(false);
     }
-    return acc;
-  }, {});
+  };
 
-  const classes = useStyles();
-  const [showFamilyStructure, setShowFamilyStructure] = useState(false);
-  const critterbase = useCritterbaseApi();
-  const { data: familyHierarchy, load: loadHierarchy } = useDataLoader(critterbase.family.getImmediateFamily);
   return (
-    
-      
-        (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} - - -
  • - ))} -
-
- - 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} - - -
  • - ) - )} -
-
-
-
-
+ + + setCreateNewFamily((create) => !create)} + /> + } + label={ + props.formMode === ANIMAL_FORM_MODE.ADD + ? 'Would you like to create a new critter family?' + : 'Would you like to modify the family label?' + } + /> + + {createNewFamily ? ( + + + + ) : ( + + + {allFamilies?.map((family) => ( + + {family.family_label ? family.family_label : family.family_id} + + ))} + + + )} + + + + + Parent in + + + Child in + + + + + + {props?.formObject?.family_id ? ( + + ) : null} + + setShowFamilyStructure(false)}> + + + Family ID + + + {props?.formObject?.family_id} + + + Parents: +
    + {familyHierarchy?.parents.map((a) => ( +
  • + + + + Critter ID + + {a.critter_id} + + + + Animal ID + + {a.animal_id} + + +
  • + ))} +
+
+ + Children: +
    + {familyHierarchy?.children.map((critter) => ( +
  • + + + + Critter ID + + {critter.critter_id} + + + + Animal ID + + {critter.animal_id} + + +
  • + ))} +
+
+
+
+ + ) + }} + /> ); }; -export default FamilyAnimalFormContent; +export default FamilyAnimalForm; diff --git a/app/src/features/surveys/view/survey-animals/form-sections/GeneralAnimalForm.tsx b/app/src/features/surveys/view/survey-animals/form-sections/GeneralAnimalForm.tsx index 5f5348e6de..2102789cd9 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,69 +1,133 @@ import Grid from '@mui/material/Grid'; import HelpButtonTooltip from 'components/buttons/HelpButtonTooltip'; +import EditDialog from 'components/dialog/EditDialog'; import CbSelectField from 'components/fields/CbSelectField'; import CustomTextField from 'components/fields/CustomTextField'; -import SpeciesAutocompleteField from 'components/species/components/SpeciesAutocompleteField'; +import { SpeciesAutoCompleteFormikField } from 'components/species/components/SpeciesAutoCompleteFormikField'; 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'; +import { useBiohubApi } from 'hooks/useBioHubApi'; +import { useDialogContext } from 'hooks/useContext'; +import { useCritterbaseApi } from 'hooks/useCritterbaseApi'; +import { ICritterDetailedResponse, ICritterSimpleResponse } from 'interfaces/useCritterApi.interface'; +import { useState } from 'react'; +import { v4 } from 'uuid'; +import { AnimalSex, ANIMAL_FORM_MODE, CreateCritterSchema, ICreateCritter, isRequiredInSchema } from '../animal'; + +export type GeneralAnimalFormProps = + | { + formObject?: never; + formMode: ANIMAL_FORM_MODE.ADD; + open: boolean; + handleClose: () => void; + critter?: never; + projectId: number; + surveyId: number; + } + | { + formObject: T; + formMode: ANIMAL_FORM_MODE.EDIT; + open: boolean; + handleClose: () => void; + critter: ICritterDetailedResponse | ICritterSimpleResponse; + projectId?: never; + surveyId?: never; + }; /** - * Renders the General section for the Individual Animal form + * This component renders a 'critter' create / edit dialog. + * Handles the basic properties of a Critterbase critter. * - * @return {*} + * @param {GeneralAnimalFormProps} props + * @returns {*} */ +const GeneralAnimalForm = (props: GeneralAnimalFormProps) => { + const cbApi = useCritterbaseApi(); + const bhApi = useBiohubApi(); + const dialog = useDialogContext(); + + const [loading, setLoading] = useState(false); -const GeneralAnimalForm = () => { - const { setFieldValue } = useFormikContext(); - const { animalKeyName } = ANIMAL_SECTIONS_FORM_MAP[SurveyAnimalsI18N.animalGeneralTitle]; - const handleTaxonName = (tsn: string, scientificName: string) => { - setFieldValue(getAnimalFieldName(animalKeyName, 'itis_tsn'), tsn); - setFieldValue(getAnimalFieldName(animalKeyName, 'itis_scientific_name'), scientificName); + const handleSave = async (values: ICreateCritter) => { + setLoading(true); + try { + if (props.formMode === ANIMAL_FORM_MODE.ADD) { + await bhApi.survey.createCritterAndAddToSurvey(props.projectId, props.surveyId, values); + dialog.setSnackbar({ open: true, snackbarMessage: `Successfully created critter.` }); + } + if (props.formMode === ANIMAL_FORM_MODE.EDIT) { + await cbApi.critters.updateCritter(values); + dialog.setSnackbar({ open: true, snackbarMessage: `Successfully edited critter.` }); + } + } catch (err) { + dialog.setSnackbar({ open: true, snackbarMessage: `Critter request failed.` }); + } finally { + props.handleClose(); + setLoading(false); + } }; return ( - - - - (animalKeyName, 'itis_tsn')} - label="Species" - required - handleAddSpecies={(species) => { - handleTaxonName(String(species.tsn), species.scientificName); - }} - /> - - - - (animalKeyName, 'sex')} - controlProps={{ required: isRequiredInSchema(AnimalGeneralSchema, 'sex') }} - label="Sex" - id={'sex'} - route={'lookups/sex'} - /> - - - - (animalKeyName, 'animal_id')} - /> - - - - - (animalKeyName, 'wlh_id')} - /> - - - + + + + + + + + + + + + + + + + + + + + + ) + }}> ); }; 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 c4679170b3..b87903628f 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,13 +1,9 @@ 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'; @@ -15,281 +11,93 @@ import { MarkerIconColor, MarkerWithResizableRadius } from 'components/map/compo import { MAP_DEFAULT_CENTER, MAP_DEFAULT_ZOOM } from 'constants/spatial'; import { useFormikContext } from 'formik'; import { LatLng } from 'leaflet'; -import { ChangeEvent, Fragment, useState } from 'react'; +import { get } from 'lodash-es'; +import { useState } from 'react'; import { LayersControl, MapContainer as LeafletMapContainer } from 'react-leaflet'; -import { getLatLngAsUtm, getUtmAsLatLng } from 'utils/mapProjectionHelpers'; -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; - utm_northing: keyof T; - utm_easting: keyof T; -}; +import { getLatLngAsUtm, getUtmAsLatLng, PROJECTION_MODE } from 'utils/mapProjectionHelpers'; + +export interface IFormLocation { + title: string; + pingColour: MarkerIconColor; + fields: { + latitude: keyof T; + longitude: keyof T; + }; +} -type LocationEntryFormProps = { - name: keyof IAnimal; - index: number; - value: T; - primaryLocationFields: LocationEntryFields; - secondaryLocationFields?: LocationEntryFields; - otherPrimaryFields?: JSX.Element[]; - otherSecondaryFields?: JSX.Element[]; +type FormLocationsPreviewProps = { + projection?: PROJECTION_MODE; + locations: IFormLocation[]; }; -const LocationEntryForm = ({ - name, - index, - value, - primaryLocationFields, - secondaryLocationFields, - otherPrimaryFields, - otherSecondaryFields -}: LocationEntryFormProps) => { - const { setFieldValue } = useFormikContext(); - const [markerEnabled, setMarkerEnabled] = useState(null); +export const FormLocationPreview = ({ + projection = PROJECTION_MODE.WGS, + locations +}: FormLocationsPreviewProps) => { + const { setFieldValue, values } = useFormikContext(); - const handleMarkerPlacement = (e: LatLng, fields: LocationEntryFields) => { - setFieldValue(getAnimalFieldName(name, fields.latitude, index), e.lat.toFixed(3)); - setFieldValue(getAnimalFieldName(name, fields.longitude, index), e.lng.toFixed(3)); - const utm_coords = getLatLngAsUtm(e.lat, e.lng); - setFieldValue(getAnimalFieldName(name, fields.utm_northing, index), utm_coords[1]); - setFieldValue(getAnimalFieldName(name, fields.utm_easting, index), utm_coords[0]); - }; + const [markerToggle, setMarkerToggle] = useState(null); - const setLatLonFromUTM = (fields: LocationEntryFields | undefined) => { - if (fields && (value[fields.latitude] || value[fields.longitude])) { - const utm_coords = getLatLngAsUtm( - value[fields.latitude] as unknown as number, - value[fields.longitude] as unknown as number - ); - setFieldValue(getAnimalFieldName(name, fields.utm_easting, index), utm_coords[0]); - setFieldValue(getAnimalFieldName(name, fields.utm_northing, index), utm_coords[1]); + const handleSetMarkerLocation = (coords: LatLng) => { + if (markerToggle === null) { + return; } - }; + let latitude = coords.lat; + let longitude = coords.lng; - const setUTMFromLatLng = (fields: LocationEntryFields | undefined) => { - if (fields && (value[fields.utm_northing] || value[fields.utm_easting])) { - const wgs_coords = getUtmAsLatLng( - value[fields.utm_northing] as unknown as number, - value[fields.utm_easting] as unknown as number - ); - setFieldValue(getAnimalFieldName(name, fields.latitude, index), wgs_coords[1]); - setFieldValue(getAnimalFieldName(name, fields.longitude, index), wgs_coords[0]); + if (projection === PROJECTION_MODE.UTM && latitude && longitude) { + [latitude, longitude] = getLatLngAsUtm(latitude, longitude); } - }; - const onProjectionModeSwitch = (e: ChangeEvent) => { - //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') { - setLatLonFromUTM(primaryLocationFields); - setLatLonFromUTM(secondaryLocationFields); - } else { - setUTMFromLatLng(primaryLocationFields); - setUTMFromLatLng(secondaryLocationFields); - } - setFieldValue(getAnimalFieldName(name, 'projection_mode', index), e.target.checked ? 'utm' : 'wgs'); - }; + setFieldValue(locations[markerToggle].fields.latitude as string, Number(latitude.toFixed(5))); + setFieldValue(locations[markerToggle].fields.longitude as string, Number(longitude.toFixed(5))); - const getCurrentMarkerPos = (fields: LocationEntryFields): LatLng => { - if (value?.projection_mode === 'utm') { - const latlng_coords = getUtmAsLatLng( - coerceZero(value[fields.utm_northing]), - coerceZero(value[fields.utm_easting]) - ); - return new LatLng(latlng_coords[1], latlng_coords[0]); - } else { - return new LatLng(coerceZero(value[fields.latitude]), coerceZero(value[fields.longitude])); - } + setMarkerToggle(null); }; - const handleMarkerSelected = (event: React.MouseEvent, enableMarker: Marker) => { - setMarkerEnabled(enableMarker); - }; + const renderMarker = (location: IFormLocation) => { + let latitude = get(values, location.fields.latitude); + let longitude = get(values, location.fields.longitude); - const renderLocationFields = (fields?: LocationEntryFields): JSX.Element => { - if (!fields) { - return <>; + if (projection === PROJECTION_MODE.UTM && latitude && longitude) { + [latitude, longitude] = getUtmAsLatLng(latitude, longitude); } - return ( - - {value?.projection_mode === 'wgs' ? ( - - - (name, fields.latitude, index)} - /> - - - (name, fields.longitude, index)} - /> - - - ) : ( - - - (name, fields.utm_northing, index)} - /> - - - (name, fields.utm_easting, index)} - /> - - - )} - - (name, fields.coordinate_uncertainty, index)} - /> - - - ); - }; + // Marking positions can be different than the fields if the projection is UTM. + const renderPosition = latitude && longitude ? new LatLng(latitude, longitude) : undefined; - const renderResizableMarker = ( - fields: LocationEntryFields | undefined, - listening: boolean, - color: MarkerIconColor - ): JSX.Element => { - if (!fields) { - return <>; - } return ( { - handleMarkerPlacement(p, fields); - setMarkerEnabled(null); - }} - handleResize={(n) => { - setFieldValue(getAnimalFieldName(name, fields.coordinate_uncertainty, index), n.toFixed(3)); - }} + key={location.title} + radius={0} + position={renderPosition} + markerColor={location.pingColour} + listenForMouseEvents={markerToggle !== null} + handlePlace={handleSetMarkerLocation} /> ); }; return ( - - - {primaryLocationFields.fieldsetTitle ? ( - {primaryLocationFields.fieldsetTitle} - ) : null} - - - {renderLocationFields(primaryLocationFields)} - - } - label="Use UTM Coordinates" - /> - - - - {otherSecondaryFields ? ( - - {otherSecondaryFields} - {renderLocationFields(secondaryLocationFields)} - - ) : null} - + Location Preview - - - - {primaryLocationFields ? ( - - {`Set ${primaryLocationFields?.fieldsetTitle ?? 'Primary Location'}`} - - ) : null} - {secondaryLocationFields ? ( - - {`Set ${secondaryLocationFields?.fieldsetTitle ?? 'Secondary Location'}`} - - ) : null} - - + setMarkerToggle(value)} exclusive> + {locations.map((location, idx) => ( + + {`Set ${location.title} Location`} + + ))} + + - - ) - ]} - /> + renderMarker(location))} /> @@ -300,4 +108,4 @@ const LocationEntryForm = ({ ); }; -export default LocationEntryForm; +export default FormLocationPreview; diff --git a/app/src/features/surveys/view/survey-animals/form-sections/MarkingAnimalForm.tsx b/app/src/features/surveys/view/survey-animals/form-sections/MarkingAnimalForm.tsx index 8cc500352e..042d2cbcb3 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,152 +1,137 @@ -import { Button, Grid } from '@mui/material'; +import { Grid } from '@mui/material'; import EditDialog from 'components/dialog/EditDialog'; import CbSelectField from 'components/fields/CbSelectField'; import CustomTextField from 'components/fields/CustomTextField'; import FormikDevDebugger from 'components/formik/FormikDevDebugger'; -import { EditDeleteStubCard } from 'features/surveys/components/EditDeleteStubCard'; -import { useFormikContext } from 'formik'; +import { useDialogContext } from 'hooks/useContext'; +import { useCritterbaseApi } from 'hooks/useCritterbaseApi'; +import { IMarkingResponse } from 'interfaces/useCritterApi.interface'; import { useState } from 'react'; import { - AnimalMarkingSchema, + AnimalFormProps, ANIMAL_FORM_MODE, - getAnimalFieldName, - IAnimal, - IAnimalMarking, + CreateCritterMarkingSchema, + ICreateCritterMarking, isRequiredInSchema } from '../animal'; -import { ANIMAL_SECTIONS_FORM_MAP } from '../animal-sections'; -type MarkingAnimalFormProps = - | { - itis_tsn: string; // temp will probably place inside a context - display: 'button'; - marking?: never; - } - | { - itis_tsn: 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 +/** + * This component renders a 'critter marking' create / edit dialog. + * + * @param {AnimalFormProps} props - Generic AnimalFormProps. + * @returns {*} */ -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); - }} - /> - {props.display === 'button' ? ( - - ) : ( - - )} - - ); -}; - -interface IMarkingAnimalFormContentProps { - index: number; -} - -export const MarkingAnimalFormContent = ({ index }: IMarkingAnimalFormContentProps) => { - const name: keyof IAnimal = 'markings'; +export const MarkingAnimalForm = (props: AnimalFormProps) => { + const cbApi = useCritterbaseApi(); + const dialog = useDialogContext(); - const { values, setFieldValue } = useFormikContext(); + const [loading, setLoading] = useState(false); - 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); - }; - - const handleMarkingLocationName = (_value: string, label: string) => { - setFieldValue(getAnimalFieldName(name, 'body_location', index), label); + const handleSave = async (values: ICreateCritterMarking) => { + setLoading(true); + try { + if (props.formMode === ANIMAL_FORM_MODE.ADD) { + await cbApi.marking.createMarking(values); + dialog.setSnackbar({ open: true, snackbarMessage: `Successfully created marking.` }); + } + if (props.formMode === ANIMAL_FORM_MODE.EDIT) { + await cbApi.marking.updateMarking(values); + dialog.setSnackbar({ open: true, snackbarMessage: `Successfully edited marking.` }); + } + } catch (err) { + dialog.setSnackbar({ open: true, snackbarMessage: `Critter marking request failed.` }); + } finally { + props.handleClose(); + setLoading(false); + } }; return ( - - - (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={`itis_tsn=${values.general.itis_tsn}`} - 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') - }} - /> - - - + + + + + + + + + + + + + + + + + + + ) + }} + /> ); }; 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 3b7bcd204a..ed01493df6 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 @@ -1,65 +1,95 @@ -import { Grid, MenuItem, SelectChangeEvent } from '@mui/material'; -import CbSelectField from 'components/fields/CbSelectField'; +import { Grid, MenuItem } from '@mui/material'; +import EditDialog from 'components/dialog/EditDialog'; import { CbSelectWrapper } from 'components/fields/CbSelectFieldWrapper'; import CustomTextField from 'components/fields/CustomTextField'; import SingleDateField from 'components/fields/SingleDateField'; -import { Field, useFormikContext } from 'formik'; -import { IMeasurementStub } from 'hooks/cb_api/useLookupApi'; +import { Field } from 'formik'; +import { useDialogContext } from 'hooks/useContext'; +import { useCritterbaseApi } from 'hooks/useCritterbaseApi'; +import useDataLoader from 'hooks/useDataLoader'; +import { + CBMeasurementType, + CBQualitativeMeasurementTypeDefinition, + CBQuantitativeMeasurementTypeDefinition, + IQualitativeMeasurementResponse, + IQuantitativeMeasurementResponse +} from 'interfaces/useCritterApi.interface'; import { has, startCase } from 'lodash-es'; -import { useEffect, useState } from 'react'; +import { useEffect, useMemo, useState } from 'react'; import { - AnimalMeasurementSchema, + AnimalFormProps, ANIMAL_FORM_MODE, - getAnimalFieldName, - IAnimal, - IAnimalMeasurement, + CreateCritterMeasurementSchema, + ICreateCritterMeasurement, isRequiredInSchema } from '../animal'; /** - * Renders the Measurement form inputs + * This component renders a 'critter measurement' create / edit dialog. * - * @return {*} + * @param {AnimalFormProps} props - Generic AnimalFormProps. + * @returns {*} */ +export const MeasurementAnimalForm = ( + props: AnimalFormProps +) => { + const cbApi = useCritterbaseApi(); + const dialog = useDialogContext(); -interface MeasurementFormContentProps { - index: number; - measurements?: IMeasurementStub[]; - mode: ANIMAL_FORM_MODE; -} + const [loading, setLoading] = useState(false); + const [measurementTypeDef, setMeasurementTypeDef] = useState(); -export const MeasurementAnimalFormContent = (props: MeasurementFormContentProps) => { - const { index, measurements, mode } = props; - const name: keyof IAnimal = 'measurements'; - const { values, handleChange, setFieldValue, handleBlur } = useFormikContext(); - const taxonMeasurementId = values.measurements?.[index]?.taxon_measurement_id; - const [currentMeasurement, setCurrentMeasurement] = useState( - measurements?.find((lookup_measurement) => lookup_measurement.taxon_measurement_id === taxonMeasurementId) + const { data: measurements, load: loadMeasurements } = useDataLoader(() => + cbApi.xref.getTaxonMeasurements(props.critter.itis_tsn) ); - const isQuantMeasurement = has(currentMeasurement, 'unit'); + loadMeasurements(); + + const isQualitative = has(measurementTypeDef, 'options'); - const taxonMeasurementIDName = getAnimalFieldName(name, 'taxon_measurement_id', index); - const valueName = getAnimalFieldName(name, 'value', index); - const optionName = getAnimalFieldName(name, 'qualitative_option_id', index); + const measurementDefs = useMemo(() => { + return measurements ? [...measurements.qualitative, ...measurements.quantitative] : []; + }, [measurements]); 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 foundMeasurementDef = measurementDefs.find( + (measurement) => measurement.taxon_measurement_id === props.formObject?.taxon_measurement_id + ); + setMeasurementTypeDef(foundMeasurementDef); + }, [measurementDefs, props?.formObject?.taxon_measurement_id]); + + const handleSave = async (values: ICreateCritterMeasurement) => { + setLoading(true); + try { + if (isQualitative) { + delete values.measurement_quantitative_id; + delete values.value; - const handleMeasurementTypeChange = (event: SelectChangeEvent) => { - handleChange(event); - setFieldValue(valueName, ''); - setFieldValue(optionName, ''); - const m = measurements?.find((m) => m.taxon_measurement_id === event.target.value); - setCurrentMeasurement(m); - handleMeasurementName('', m?.measurement_name ?? ''); + props.formMode === ANIMAL_FORM_MODE.ADD + ? await cbApi.measurement.createQualitativeMeasurement(values) + : await cbApi.measurement.updateQualitativeMeasurement(values); + } else { + delete values.measurement_qualitative_id; + delete values.qualitative_option_id; + values = { ...values, value: Number(values.value) }; + + props.formMode === ANIMAL_FORM_MODE.ADD + ? await cbApi.measurement.createQuantitativeMeasurement(values) + : await cbApi.measurement.updateQuantitativeMeasurement(values); + } + dialog.setSnackbar({ open: true, snackbarMessage: `Successfully created measurement.` }); + } catch (err) { + dialog.setSnackbar({ open: true, snackbarMessage: `Critter measurement request failed.` }); + } finally { + props.handleClose(); + setLoading(false); + } }; - const validateValue = async (val: '' | number) => { - const min = currentMeasurement?.min_value ?? 0; - const max = currentMeasurement?.max_value; - const unit = currentMeasurement?.unit ? currentMeasurement.unit : ``; + const validateQuantitativeMeasurement = async (val: '' | number) => { + const quantitativeTypeDef = measurementTypeDef as CBQuantitativeMeasurementTypeDefinition; + const min = quantitativeTypeDef?.min_value ?? 0; + const max = quantitativeTypeDef?.max_value; + const unit = quantitativeTypeDef?.unit ? quantitativeTypeDef.unit : ``; if (val === '') { return; } @@ -74,81 +104,105 @@ export const MeasurementAnimalFormContent = (props: MeasurementFormContentProps) } }; - 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) => ( - - {startCase(m.measurement_name)} - - ))} - - - - {!isQuantMeasurement && taxonMeasurementId ? ( - - ) : ( - - )} - - - (name, 'measured_timestamp', index)} - required={isRequiredInSchema(AnimalMeasurementSchema, 'measured_timestamp')} - label="Date Measurement Taken" - /> - - - (name, 'measurement_comment', index)} - /> - - + + + + {measurementDefs?.map((measurementDef) => ( + setMeasurementTypeDef(measurementDef)}> + {startCase(measurementDef.measurement_name)} + + ))} + + + + {isQualitative ? ( + + {(measurementTypeDef as CBQualitativeMeasurementTypeDefinition)?.options?.map((qualitativeOption) => ( + + {startCase(qualitativeOption.option_label)} + + ))} + + ) : ( + + )} + + + + + + + + + ) + }} + /> ); }; -export default MeasurementAnimalFormContent; +export default MeasurementAnimalForm; diff --git a/app/src/features/surveys/view/survey-animals/form-sections/MortalityAnimalForm.tsx b/app/src/features/surveys/view/survey-animals/form-sections/MortalityAnimalForm.tsx index b46aad54fc..7bdee6b150 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 @@ -2,90 +2,169 @@ import Box from '@mui/material/Box'; import Grid from '@mui/material/Grid'; import Stack from '@mui/material/Stack'; import Typography from '@mui/material/Typography'; +import EditDialog from 'components/dialog/EditDialog'; import CbSelectField from 'components/fields/CbSelectField'; import CustomTextField from 'components/fields/CustomTextField'; import SingleDateField from 'components/fields/SingleDateField'; -import { useFormikContext } from 'formik'; +import { SpeciesAutoCompleteFormikField } from 'components/species/components/SpeciesAutoCompleteFormikField'; +import { Field, useFormikContext } from 'formik'; +import { useDialogContext } from 'hooks/useContext'; +import { useCritterbaseApi } from 'hooks/useCritterbaseApi'; +import { IMortalityResponse } from 'interfaces/useCritterApi.interface'; +import { mapValues } from 'lodash-es'; import { useState } from 'react'; -import { AnimalMortalitySchema, getAnimalFieldName, IAnimal, IAnimalMortality, isRequiredInSchema } from '../animal'; -import LocationEntryForm from './LocationEntryForm'; +import { + AnimalFormProps, + ANIMAL_FORM_MODE, + CreateCritterMortalitySchema, + ICreateCritterMortality, + isRequiredInSchema +} from '../animal'; +import FormLocationPreview from './LocationEntryForm'; -interface MortalityAnimalFormContentProps { - index: number; -} +/** + * This component renders a 'critter mortality' create / edit dialog. + * + * @param {AnimalFormProps} props - Generic AnimalFormProps. + * @returns {*} + */ +const MortalityAnimalForm = (props: AnimalFormProps) => { + const cbApi = useCritterbaseApi(); + const dialog = useDialogContext(); -export const MortalityAnimalFormContent = ({ index }: MortalityAnimalFormContentProps) => { - const name: keyof IAnimal = 'mortality'; + const [loading, setLoading] = useState(false); - 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 handleSave = async (values: ICreateCritterMortality) => { + setLoading(true); + // Replaces empty strings with null values. + const patchedValues = mapValues(values, (value) => (value === '' ? null : value)); - const value = values.mortality[index]; + try { + if (props.formMode === ANIMAL_FORM_MODE.ADD) { + await cbApi.mortality.createMortality(patchedValues); + dialog.setSnackbar({ open: true, snackbarMessage: `Successfully created mortality.` }); + } + if (props.formMode === ANIMAL_FORM_MODE.EDIT) { + await cbApi.mortality.updateMortality(patchedValues); + dialog.setSnackbar({ open: true, snackbarMessage: `Successfully edited mortality.` }); + } + } catch (err) { + dialog.setSnackbar({ open: true, snackbarMessage: `Critter mortality request failed.` }); + } finally { + props.handleClose(); + setLoading(false); + } + }; + return ( + + }} + /> + ); +}; + +/** + * This component renders the 'critter mortality' form fields. + * Nested inside MortalityAnimalForm to use the formikContext hook. + * + * @param {Pick, 'formObject'>} props - IMortalityResponse. + * @returns {*} + */ +const MortalityForm = (props: Pick, 'formObject'>) => { + const { setFieldValue } = useFormikContext(); + + const proximateTsn = props.formObject?.proximate_predated_by_itis_tsn; + const ultimateTsn = props.formObject?.ultimate_predated_by_itis_tsn; + + const [pcodTaxonDisabled, setPcodTaxonDisabled] = useState(!proximateTsn); //Controls whether you can select taxons from the PCOD Taxon dropdown. + const [ucodTaxonDisabled, setUcodTaxonDisabled] = useState(!ultimateTsn); //Controls whether you can select taxons from the UCOD Taxon dropdown. + + const handleCauseOfDeathReasonChange = (label: string, isProximateCOD: boolean) => { + const isDisabled = !label.includes('Predation'); + if (isProximateCOD) { + setPcodTaxonDisabled(isDisabled); + } else { + setUcodTaxonDisabled(isDisabled); + } + + if (isDisabled) { + setFieldValue('proximate_predated_by_itis_tsn', '', true); + } else { + setFieldValue('ultimate_predated_by_itis_tsn', '', true); + } + }; return ( Date of Event (name, 'mortality_timestamp', index)} - required={isRequiredInSchema(AnimalMortalitySchema, 'mortality_timestamp')} + name={'mortality_timestamp'} + required={isRequiredInSchema(CreateCritterMortalitySchema, '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'))} + name={'proximate_cause_of_death_id'} + handleChangeSideEffect={(_value, label) => handleCauseOfDeathReasonChange(label, true)} orderBy={'asc'} label={'Reason'} controlProps={{ - required: isRequiredInSchema(AnimalMortalitySchema, 'proximate_cause_of_death_id') + required: isRequiredInSchema(CreateCritterMortalitySchema, 'proximate_cause_of_death_id') }} - id={`${index}-pcod-reason`} + id={`pcod-reason`} route={'lookups/cods'} /> (name, 'proximate_cause_of_death_confidence', index)} + name={'proximate_cause_of_death_confidence'} label={'Confidence'} controlProps={{ - required: isRequiredInSchema(AnimalMortalitySchema, 'proximate_cause_of_death_confidence') + required: isRequiredInSchema(CreateCritterMortalitySchema, 'proximate_cause_of_death_confidence') }} - id={`${index}-pcod-confidence`} - route={'lookups/cause-of-death-confidence'} + id={`pcod-confidence`} + route={'lookups/enum/cod-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'} + @@ -96,59 +175,95 @@ export const MortalityAnimalFormContent = ({ index }: MortalityAnimalFormContent (name, 'ultimate_cause_of_death_id', index)} + name={'ultimate_cause_of_death_id'} orderBy={'asc'} handleChangeSideEffect={(_value, label) => { - setUcodTaxonDisabled(!label.includes('Predation')); + handleCauseOfDeathReasonChange(label, false); }} label={'Reason'} controlProps={{ - required: isRequiredInSchema(AnimalMortalitySchema, 'ultimate_cause_of_death_id') + required: isRequiredInSchema(CreateCritterMortalitySchema, 'ultimate_cause_of_death_id') }} - id={`${index}-ucod-reason`} + id={`ucod-reason`} route={'lookups/cods'} /> (name, 'ultimate_cause_of_death_confidence', index)} + name={'ultimate_cause_of_death_confidence'} label={'Confidence'} controlProps={{ - required: isRequiredInSchema(AnimalMortalitySchema, 'ultimate_cause_of_death_confidence') + required: isRequiredInSchema(CreateCritterMortalitySchema, 'ultimate_cause_of_death_confidence') }} - id={`${index}-ucod-confidence`} - route={'lookups/cause-of-death-confidence'} + id={`ucod-confidence`} + route={'lookups/enum/cod-confidence'} /> - (name, 'ultimate_predated_by_taxon_id', index)} - label={'Taxon'} - controlProps={{ - disabled: ucodTaxonDisabled, - required: isRequiredInSchema(AnimalMortalitySchema, 'ultimate_predated_by_taxon_id') + + + + + + + Mortality Location + + + + + + + + + + + Additional Details (name, 'mortality_comment', index)} + name={'mortality_comment'} /> ); }; -export default MortalityAnimalFormContent; +export default MortalityAnimalForm; 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 deleted file mode 100644 index d81a15063a..0000000000 --- a/app/src/features/surveys/view/survey-animals/telemetry-device/DeploymentFormSection.tsx +++ /dev/null @@ -1,149 +0,0 @@ -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/TelemetryDeviceFormContent.tsx b/app/src/features/surveys/view/survey-animals/telemetry-device/TelemetryDeviceFormContent.tsx deleted file mode 100644 index 06b6429ea3..0000000000 --- a/app/src/features/surveys/view/survey-animals/telemetry-device/TelemetryDeviceFormContent.tsx +++ /dev/null @@ -1,178 +0,0 @@ -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, device.device_make) - ); - - 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/hooks/api/useSurveyApi.test.ts b/app/src/hooks/api/useSurveyApi.test.ts index 4693fc6754..2b9c73b435 100644 --- a/app/src/hooks/api/useSurveyApi.test.ts +++ b/app/src/hooks/api/useSurveyApi.test.ts @@ -1,6 +1,6 @@ import axios from 'axios'; import MockAdapter from 'axios-mock-adapter'; -import { AnimalSex, Critter, IAnimal } from 'features/surveys/view/survey-animals/animal'; +import { AnimalSex, ICreateCritter } from 'features/surveys/view/survey-animals/animal'; import { IAnimalDeployment } from 'features/surveys/view/survey-animals/telemetry-device/device'; import { ICreateSurveyRequest, @@ -66,31 +66,19 @@ describe('useSurveyApi', () => { describe('createCritterAndAddToSurvey', () => { it('creates a critter successfully', async () => { - const animal: IAnimal = { - general: { - animal_id: '1', - itis_tsn: v4(), - itis_scientific_name: '1', - wlh_id: 'a', - sex: AnimalSex.UNKNOWN, - critter_id: v4() - }, - captures: [], - markings: [], - measurements: [], - mortality: [], - family: [], - images: [], - device: [], - collectionUnits: [] + const critter: ICreateCritter = { + itis_tsn: 1, + critter_id: 'blah-blah', + wlh_id: '123-45', + animal_id: 'carl', + sex: AnimalSex.MALE }; - const critter = new Critter(animal); mock.onPost(`/api/project/${projectId}/survey/${surveyId}/critters`).reply(201, { create: { critters: 1 } }); const result = await useSurveyApi(axios).createCritterAndAddToSurvey(projectId, surveyId, critter); - expect(result.create.critters).toBe(1); + expect(result).toBeDefined(); }); }); diff --git a/app/src/hooks/api/useSurveyApi.ts b/app/src/hooks/api/useSurveyApi.ts index 4169df33f4..fd8f819c15 100644 --- a/app/src/hooks/api/useSurveyApi.ts +++ b/app/src/hooks/api/useSurveyApi.ts @@ -1,22 +1,23 @@ import { AxiosInstance, AxiosProgressEvent, CancelTokenSource } from 'axios'; import { IEditReportMetaForm } from 'components/attachments/EditReportMetaForm'; import { IReportMetaForm } from 'components/attachments/ReportMetaForm'; -import { Critter } from 'features/surveys/view/survey-animals/animal'; +import { ICreateCritter } from 'features/surveys/view/survey-animals/animal'; import { IAnimalDeployment, IAnimalTelemetryDevice, IDeploymentTimespan, ITelemetryPointCollection } from 'features/surveys/view/survey-animals/telemetry-device/device'; +import { ICritterSimpleResponse } from 'interfaces/useCritterApi.interface'; import { IGetReportDetails, IUploadAttachmentResponse } from 'interfaces/useProjectApi.interface'; import { ICreateSurveyRequest, ICreateSurveyResponse, - IDetailedCritterWithInternalId, IGetSurveyAttachmentsResponse, IGetSurveyForUpdateResponse, IGetSurveyForViewResponse, IGetSurveyListResponse, + ISimpleCritterWithInternalId, SurveyUpdateObject } from 'interfaces/useSurveyApi.interface'; import qs from 'qs'; @@ -369,53 +370,13 @@ const useSurveyApi = (axios: AxiosInstance) => { * * @param {number} projectId * @param {number} surveyId - * @returns {ICritterDetailedResponse[]} + * @returns {ISimpleCritterWithInternalId[]} */ - const getSurveyCritters = async (projectId: number, surveyId: number): Promise => { + const getSurveyCritters = async (projectId: number, surveyId: number): Promise => { const { data } = await axios.get(`/api/project/${projectId}/survey/${surveyId}/critters`); return data; }; - type CritterBulkCreationResponse = { - create: { - critters: number; - collections: number; - markings: number; - locations: number; - captures: number; - mortalities: number; - qualitative_measurements: number; - quantitative_measurements: number; - families: number; - family_children: number; - family_parents: number; - }; - }; - - const critterToPayloadTransform = (critter: Critter, ignoreTopLevel = false) => { - return { - critters: ignoreTopLevel - ? [] - : [ - { - critter_id: critter.critter_id, - animal_id: critter.animal_id, - sex: critter.sex, - itis_tsn: critter.itis_tsn, - wlh_id: critter.wlh_id - } - ], - captures: critter.captures, - collections: critter.collections, - mortalities: critter.mortalities, - markings: critter.markings, - locations: critter.locations, - families: critter.families, - qualitative_measurements: critter.measurements.qualitative, - quantitative_measurements: critter.measurements.quantitative - }; - }; - /** * Create a critter and add it to the list of critters associated with this survey. This will create a new critter in Critterbase. * @@ -427,35 +388,9 @@ const useSurveyApi = (axios: AxiosInstance) => { const createCritterAndAddToSurvey = async ( projectId: number, surveyId: number, - critter: Critter - ): Promise => { - const payload = critterToPayloadTransform(critter); - const { data } = await axios.post(`/api/project/${projectId}/survey/${surveyId}/critters`, payload); - return data; - }; - - /** - * Update a survey critter. Allows you to create, update, and delete associated rows like captures, mortalities etc in one request. - * - * @param {number} projectId - * @param {number} surveyId - * @param {number} critterId - * @param {Critter} updateSection - * @param {Critter | undefined} createSection - * @returns {*} - */ - const updateSurveyCritter = async ( - projectId: number, - surveyId: number, - critterId: number, - updateSection: Critter, - createSection: Critter | undefined - ) => { - const payload = { - update: critterToPayloadTransform(updateSection), - create: createSection ? critterToPayloadTransform(createSection, true) : undefined - }; - const { data } = await axios.patch(`/api/project/${projectId}/survey/${surveyId}/critters/${critterId}`, payload); + critter: ICreateCritter + ): Promise => { + const { data } = await axios.post(`/api/project/${projectId}/survey/${surveyId}/critters`, critter); return data; }; @@ -494,6 +429,13 @@ const useSurveyApi = (axios: AxiosInstance) => { throw Error('Calling this with any amount other than 1 deployments currently unsupported.'); } const flattened = { ...body, ...body.deployments[0] }; + + delete flattened.deployment_id; + delete flattened.deployments; + if (!flattened.device_model) { + delete flattened.device_model; + } + const { data } = await axios.post( `/api/project/${projectId}/survey/${surveyId}/critters/${critterId}/deployments`, flattened @@ -600,8 +542,7 @@ const useSurveyApi = (axios: AxiosInstance) => { getDeploymentsInSurvey, updateDeployment, getCritterTelemetry, - removeDeployment, - updateSurveyCritter + removeDeployment }; }; diff --git a/app/src/hooks/cb_api/useAuthenticationApi.tsx b/app/src/hooks/cb_api/useAuthenticationApi.tsx index 0eef92bc28..96116e1a9b 100644 --- a/app/src/hooks/cb_api/useAuthenticationApi.tsx +++ b/app/src/hooks/cb_api/useAuthenticationApi.tsx @@ -1,7 +1,15 @@ import { AxiosInstance } from 'axios'; -const useAuthentication = (axios: AxiosInstance) => { - const signUp = async (): Promise<{ user_id: string } | null> => { +type CritterbaseUser = { user_id: string }; + +export const useAuthentication = (axios: AxiosInstance) => { + /** + * Signs up / registers a SIMS user for CritterbaseAPI. + * + * @async + * @returns {Promise} Critterbase user ID or NULL if unable to sign up. + */ + const signUp = async (): Promise => { try { const { data } = await axios.post('/api/critterbase/signup'); return data; @@ -17,5 +25,3 @@ const useAuthentication = (axios: AxiosInstance) => { signUp }; }; - -export { useAuthentication }; diff --git a/app/src/hooks/cb_api/useCaptureApi.tsx b/app/src/hooks/cb_api/useCaptureApi.tsx new file mode 100644 index 0000000000..59351604e9 --- /dev/null +++ b/app/src/hooks/cb_api/useCaptureApi.tsx @@ -0,0 +1,54 @@ +import { AxiosInstance } from 'axios'; +import { ICreateCritterCapture } from 'features/surveys/view/survey-animals/animal'; +import { ICaptureResponse } from 'interfaces/useCritterApi.interface'; +import { isEqual } from 'lodash-es'; + +const useCaptureApi = (axios: AxiosInstance) => { + /** + * Create a critter Capture. + * + * @async + * @param {ICreateCritterCapture} payload + * @returns {Promise} - The created capture. + */ + const createCapture = async (payload: ICreateCritterCapture): Promise => { + const { data } = await axios.post(`/api/critterbase/captures/create`, payload); + return data; + }; + + /** + * Update a critter Capture. + * + * @async + * @param {ICreateCritterCapture} payload + * @returns {Promise} - The updated capture. + */ + const updateCapture = async (payload: ICreateCritterCapture): Promise => { + const force_create_release = + payload.capture_location.location_id === payload.release_location.location_id && + !isEqual(payload.capture_location, payload.release_location); + + const { data } = await axios.patch(`/api/critterbase/captures/${payload.capture_id}`, { + ...payload, + force_create_release + }); + return data; + }; + + /** + * Delete a critter Capture. + * + * @async + * @param {string} captureID + * @returns {Promise} - The deleted capture. + */ + const deleteCapture = async (captureID: string): Promise => { + const { data } = await axios.delete(`/api/critterbase/captures/${captureID}`); + + return data; + }; + + return { createCapture, updateCapture, deleteCapture }; +}; + +export { useCaptureApi }; diff --git a/app/src/hooks/cb_api/useCollectionUnitApi.tsx b/app/src/hooks/cb_api/useCollectionUnitApi.tsx new file mode 100644 index 0000000000..c08557aee6 --- /dev/null +++ b/app/src/hooks/cb_api/useCollectionUnitApi.tsx @@ -0,0 +1,49 @@ +import { AxiosInstance } from 'axios'; +import { ICreateCritterCollectionUnit } from 'features/surveys/view/survey-animals/animal'; +import { ICollectionUnitResponse } from 'interfaces/useCritterApi.interface'; + +const useCollectionUnitApi = (axios: AxiosInstance) => { + /** + * Create a critter collection-unit. + * + * @async + * @param {ICreateCritterCollectionUnit} payload + * @returns {Promise} The created collection-unit. + */ + const createCollectionUnit = async (payload: ICreateCritterCollectionUnit): Promise => { + const { data } = await axios.post(`/api/critterbase/collection-units/create`, payload); + return data; + }; + + /** + * Update a critter collection-unit. + * + * @async + * @param {ICreateCritterCollectionUnit} payload + * @returns {Promise} The updated collection-unit. + */ + const updateCollectionUnit = async (payload: ICreateCritterCollectionUnit): Promise => { + const { data } = await axios.patch( + `/api/critterbase/collection-units/${payload.critter_collection_unit_id}`, + payload + ); + return data; + }; + + /** + * Delete a critter collection-unit. + * + * @async + * @param {string} collectionUnitId - critter_collection_unit_id. + * @returns {Promise} The deleted collection-unit. + */ + const deleteCollectionUnit = async (collectionUnitId: string): Promise => { + const { data } = await axios.delete(`/api/critterbase/collection-units/${collectionUnitId}`); + + return data; + }; + + return { createCollectionUnit, updateCollectionUnit, deleteCollectionUnit }; +}; + +export { useCollectionUnitApi }; diff --git a/app/src/hooks/cb_api/useCritterApi.tsx b/app/src/hooks/cb_api/useCritterApi.tsx new file mode 100644 index 0000000000..7fa595a974 --- /dev/null +++ b/app/src/hooks/cb_api/useCritterApi.tsx @@ -0,0 +1,57 @@ +import { AxiosInstance } from 'axios'; +import { ICreateCritter } from 'features/surveys/view/survey-animals/animal'; +import { ICritterDetailedResponse, ICritterSimpleResponse } from 'interfaces/useCritterApi.interface'; + +const useCritterApi = (axios: AxiosInstance) => { + /** + * Create a critter. + * + * @async + * @param {ICreateCritter} payload - Create critter payload. + * @returns {Promise} Simple critterbase critter. + */ + const createCritter = async (payload: ICreateCritter): Promise => { + const { data } = await axios.post(`/api/critterbase/critters/create`, payload); + return data; + }; + + /** + * Update a critter. + * + * @async + * @param {ICraeteCritter} payload - Update critter payload. + * @returns {Promise} Simple critterbase critter. + */ + const updateCritter = async (payload: ICreateCritter): Promise => { + const { data } = await axios.patch(`/api/critterbase/critters/${payload.critter_id}`, payload); + return data; + }; + /** + * Get a critter with detailed response. + * Includes all markings, captures, mortalities etc. + * + * @async + * @param {string} critter_id - Critter identifier. + * @returns {Promise} + */ + const getDetailedCritter = async (critter_id: string): Promise => { + const { data } = await axios.get(`/api/critterbase/critters/${critter_id}?format=detailed`); + return data; + }; + + /** + * Get multiple critters by ids. + * + * @async + * @param {string[]} critter_ids - Critter identifiers. + * @returns {Promise} + */ + const getMultipleCrittersByIds = async (critter_ids: string[]): Promise => { + const { data } = await axios.post(`/api/critterbase/critters`, { critter_ids }); + return data; + }; + + return { getDetailedCritter, getMultipleCrittersByIds, createCritter, updateCritter }; +}; + +export { useCritterApi }; diff --git a/app/src/hooks/cb_api/useFamilyApi.test.tsx b/app/src/hooks/cb_api/useFamilyApi.test.tsx index a98cbf5ab5..e930a47bce 100644 --- a/app/src/hooks/cb_api/useFamilyApi.test.tsx +++ b/app/src/hooks/cb_api/useFamilyApi.test.tsx @@ -25,7 +25,7 @@ describe('useFamily', () => { }; it('should return a list of families', async () => { - mock.onGet('/api/critter-data/family').reply(200, [family]); + mock.onGet('/api/critterbase/family').reply(200, [family]); const result = await useFamilyApi(axios).getAllFamilies(); expect(Array.isArray(result)).toBe(true); expect(result.length).toBe(1); @@ -34,7 +34,7 @@ describe('useFamily', () => { it('should return an immediate family by id', async () => { const familyId = v4(); - mock.onGet('/api/critter-data/family/' + familyId).reply(200, immediateFamily); + mock.onGet('/api/critterbase/family/' + familyId).reply(200, immediateFamily); const result = await useFamilyApi(axios).getImmediateFamily(familyId); expect(Array.isArray(result.parents)).toBe(true); expect(Array.isArray(result.children)).toBe(true); diff --git a/app/src/hooks/cb_api/useFamilyApi.tsx b/app/src/hooks/cb_api/useFamilyApi.tsx index 428e9a7dae..15f293c90c 100644 --- a/app/src/hooks/cb_api/useFamilyApi.tsx +++ b/app/src/hooks/cb_api/useFamilyApi.tsx @@ -1,32 +1,140 @@ import { AxiosInstance } from 'axios'; +import { AnimalRelationship } from 'features/surveys/view/survey-animals/animal'; +import { IFamilyChildResponse, IFamilyParentResponse } from 'interfaces/useCritterApi.interface'; +import { v4 } from 'uuid'; + +interface ICritterStub { + critter_id: string; + animal_id: string | null; +} export type IFamily = { family_id: string; family_label: string; }; -const useFamilyApi = (axios: AxiosInstance) => { +export type IImmediateFamily = { + parents: ICritterStub[]; + siblings: ICritterStub[]; + children: ICritterStub[]; +}; + +type CreateFamilyRelationshipPayload = { + relationship: AnimalRelationship; + family_label?: string; + family_id?: string; + critter_id: string; +}; + +export const useFamilyApi = (axios: AxiosInstance) => { + /** + * Get all Critterbase families. + * + * @async + * @returns {Promise} Critter families. + */ const getAllFamilies = async (): Promise => { - try { - const { data } = await axios.get('/api/critter-data/family'); + const { data } = await axios.get('/api/critterbase/family'); + + return data; + }; + + /** + * Get immediate family of a specific critter. + * + * @async + * @param {string} family_id - Family primary key identifier. + * @returns {Promise} The critters parents, children and siblings. + */ + const getImmediateFamily = async (family_id: string): Promise => { + const { data } = await axios.get(`/api/critterbase/family/${family_id}`); + + return data; + }; + + /** + * Create a new family. + * Families must be created before parents or children can be added. + * + * @async + * @param {string} label - The family's label. example: `caribou-2024-skeena-family` + * @returns {Promise} Critter family. + */ + const createFamily = async (label: string): Promise => { + const { data } = await axios.post(`/api/critterbase/family/create`, { family_id: v4(), family_label: label }); + + return data; + }; + + /** + * Edit a family label. + * + * @async + * @param {string} family_id - The id of the family. + * @param {string} label - New family label. example: `caribou-2025-skeena-family-v2` + * @returns {Promise} Critter family. + */ + const editFamily = async (family_id: string, label: string) => { + const { data } = await axios.patch(`/api/critterbase/family/${family_id}`, { family_label: label }); + + return data; + }; + + /** + * Create (parent or child) relationship of a family. + * + * @async + * @param {CreateFamilyRelationshipPayload} payload - Create relationship payload. + * @returns {Promise} + */ + const createFamilyRelationship = async ( + payload: CreateFamilyRelationshipPayload + ): Promise => { + if (payload.relationship === AnimalRelationship.CHILD) { + const { data } = await axios.post(`/api/critterbase/family/children`, { + family_id: payload.family_id, + child_critter_id: payload.critter_id + }); + return data; - } catch (e) { - if (e instanceof Error) { - console.log(e.message); - } } - return []; + + const { data } = await axios.post(`/api/critterbase/family/parents`, { + family_id: payload.family_id, + parent_critter_id: payload.critter_id + }); + + return data; }; - const getImmediateFamily = async (family_id: string): Promise<{ parents: any[]; siblings: any[]; children: any }> => { - const { data } = await axios.get(`/api/critter-data/family/${family_id}`); + /** + * Delete a relationship (parent or child) of a family. + * + * @async + * @param {*} params + * @returns {Promise} Either parent or child delete response. + */ + const deleteRelationship = async (params: { + relationship: AnimalRelationship; + family_id: string; + critter_id: string; + }): Promise => { + const payload = + params.relationship === AnimalRelationship.CHILD + ? { family_id: params.family_id, child_critter_id: params.critter_id } + : { family_id: params.family_id, parent_critter_id: params.critter_id }; + + const { data } = await axios.delete(`/api/critterbase/family/${params.relationship}`, { data: payload }); + return data; }; return { getAllFamilies, - getImmediateFamily + getImmediateFamily, + editFamily, + deleteRelationship, + createFamily, + createFamilyRelationship }; }; - -export { useFamilyApi }; diff --git a/app/src/hooks/cb_api/useLookupApi.test.tsx b/app/src/hooks/cb_api/useLookupApi.test.tsx index b748d6eef7..785ff3e956 100644 --- a/app/src/hooks/cb_api/useLookupApi.test.tsx +++ b/app/src/hooks/cb_api/useLookupApi.test.tsx @@ -1,104 +1,102 @@ import axios from 'axios'; import MockAdapter from 'axios-mock-adapter'; -import { v4 } from 'uuid'; import { ICbSelectRows, useLookupApi } from './useLookupApi'; -describe('useLookup', () => { - let mock: any; +describe('useLookupApi', () => { + describe('getSelectOptions', () => { + let mock: MockAdapter; - beforeEach(() => { - mock = new MockAdapter(axios); - }); + beforeEach(() => { + mock = new MockAdapter(axios); + }); - afterEach(() => { - mock.restore(); - }); + afterEach(() => { + mock.restore(); + }); - const mockLookup = [ - { - key: 'colour_id', - id: '7a516697-c7ee-43b3-9e17-2fc31572d819', - value: 'Blue' - }, - { - key: 'colour_id', - id: '9a516697-c7ee-43b3-9e17-2fc31572d819', - value: 'Green' - } - ]; + const mockLookup = [ + { + 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 mockEnumLookup = ['A', 'B']; - const mockMeasurement = [ - { - taxon_measurement_id: '29425067-e5ea-4284-b629-26c3cac4cbbf', - taxon_id: '0db0129f-5969-4892-824d-459e5ac38dc2', - measurement_name: 'Life Stage', - measurement_desc: null, - create_user: 'dab7cd7c-75af-474e-abbf-fd31ae166577', - update_user: 'dab7cd7c-75af-474e-abbf-fd31ae166577', - create_timestamp: '2023-07-25T18:18:27.933Z', - update_timestamp: '2023-07-25T18:18:27.933Z' - } - ]; + it('should return a lookup table in a format to be used by select components', async () => { + mock.onGet('/api/critterbase/lookups/colours').reply(200, mockLookup); + const result = await useLookupApi(axios).getSelectOptions({ + route: 'lookups/colours', + query: { format: 'asSelect' } + }); + expect(Array.isArray(result)).toBe(true); + expect(typeof result).not.toBe('string'); + const res = result as ICbSelectRows[]; + expect(res[0].key).toBe('colour_id'); + expect(res[0].value).toBe('Blue'); + expect(res[0].id).toBeDefined(); + }); - it('should return a lookup table in a format to be used by select components', async () => { - mock.onGet('/api/critter-data/lookups/colours?format=asSelect').reply(200, mockLookup); - const result = await useLookupApi(axios).getSelectOptions({ route: 'lookups/colours' }); - expect(Array.isArray(result)).toBe(true); - expect(typeof result).not.toBe('string'); - const res = result as ICbSelectRows[]; - expect(res[0].key).toBe('colour_id'); - expect(res[0].value).toBe('Blue'); - expect(res[0].id).toBeDefined(); - }); + it('should order lookups by asc if param provided', async () => { + mock.onGet('/api/critterbase/lookups/colours').reply(200, mockLookup); + const result = await useLookupApi(axios).getSelectOptions({ + route: 'lookups/colours', + query: { format: 'asSelect' }, + 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 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/critterbase/lookups/colours').reply(200, mockEnumLookup); + const result = await useLookupApi(axios).getSelectOptions({ + route: 'lookups/colours', + query: { format: 'asSelect' }, + orderBy: 'asc' + }); + const res = result as ICbSelectRows[]; + expect(res[0]).toBe('A'); + expect(res[1]).toBe('B'); + }); - 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 order string lookups by desc if param provided', async () => { + mock.onGet('/api/critterbase/lookups/colours').reply(200, mockEnumLookup); + const result = await useLookupApi(axios).getSelectOptions({ + route: 'lookups/colours', + query: { format: 'asSelect' }, + orderBy: 'desc' + }); + const res = result as ICbSelectRows[]; + expect(res[0]).toBe('B'); + expect(res[1]).toBe('A'); + }); - 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); - const result = await useLookupApi(axios).getTaxonMeasurements(taxon_id); - expect(Array.isArray(result)).toBe(true); - expect(typeof result?.[0].taxon_measurement_id).toBe('string'); - expect(typeof result?.[0].measurement_name).toBe('string'); + it('should order lookups by desc if param provided', async () => { + mock.onGet('/api/critterbase/lookups/colours').reply(200, mockLookup); + const result = await useLookupApi(axios).getSelectOptions({ + route: 'lookups/colours', + query: { format: 'asSelect' }, + 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(); + }); }); }); diff --git a/app/src/hooks/cb_api/useLookupApi.tsx b/app/src/hooks/cb_api/useLookupApi.tsx index b84617f623..d8886243ef 100644 --- a/app/src/hooks/cb_api/useLookupApi.tsx +++ b/app/src/hooks/cb_api/useLookupApi.tsx @@ -1,5 +1,5 @@ -import { GridSortDirection } from '@mui/x-data-grid/models'; import { AxiosInstance } from 'axios'; +import qs from 'qs'; export type OrderBy = 'asc' | 'desc'; @@ -9,33 +9,56 @@ export interface ICbSelectRows { value: string; } -//export type ICbRouteKey = keyof typeof CbRoutes; - -interface SelectOptionsProps { +export interface SelectOptionsProps { + /** + * The Critterbase API path to call. + * + * Note: The route must be supported by the critterbase-proxy middleware in the api. + * + * @example + * 'lookups/taxon-collection-categories' + * + * @type {string} + * @memberof SelectOptionsProps + */ route: string; - param?: string; - query?: string; - asSelect?: boolean; - orderBy?: GridSortDirection; -} - -export interface IMeasurementStub { - taxon_measurement_id: string; - measurement_name: string; - min_value?: number; - max_value?: number; - unit?: string; + /** + * Query params to be added to the request. + * + * @example + * { + * taxon_id='1234', + * category_id='5678' + * } + * @type {(Record)} + * @memberof SelectOptionsProps + */ + query?: Record; + /** + * Order the results by the given value. + * + * @type {OrderBy} + * @memberof SelectOptionsProps + */ + orderBy?: OrderBy; } -const useLookupApi = (axios: AxiosInstance) => { - 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}` - ); +export const useLookupApi = (axios: AxiosInstance) => { + /** + * Queries the Critterbase API with `format=asSelect` and returns the results. + * + * @param {SelectOptionsProps} options + * @return {Promise>} + */ + const getSelectOptions = async (options: SelectOptionsProps): Promise> => { + const { data } = await axios.get>(`/api/critterbase/${options.route}`, { + params: { format: 'asSelect', ...options.query }, + paramsSerializer: (params: any) => { + return qs.stringify(params); + } + }); - if (!orderBy) { + if (!options.orderBy) { return data; } @@ -45,30 +68,10 @@ const useLookupApi = (axios: AxiosInstance) => { 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> => { - if (!taxon_id) { - return; - } - const { data } = await axios.get(`/api/critter-data/xref/taxon-measurements?taxon_id=${taxon_id}`); - return data; - }; - - const getTaxonMarkingBodyLocations = async (taxon_id?: string): Promise> => { - if (!taxon_id) { - return []; - } - const { data } = await axios.get(`/api/critter-data/xref/taxon-marking-body-locations?taxon_id=${taxon_id}`); - return data; + return options.orderBy === 'desc' ? data.sort(sorter) : data.sort(sorter).reverse(); }; return { - getSelectOptions, - getTaxonMeasurements, - getTaxonMarkingBodyLocations + getSelectOptions }; }; - -export { useLookupApi }; diff --git a/app/src/hooks/cb_api/useMarkingApi.tsx b/app/src/hooks/cb_api/useMarkingApi.tsx new file mode 100644 index 0000000000..4d95f5b71b --- /dev/null +++ b/app/src/hooks/cb_api/useMarkingApi.tsx @@ -0,0 +1,46 @@ +import { AxiosInstance } from 'axios'; +import { ICreateCritterMarking } from 'features/surveys/view/survey-animals/animal'; +import { IMarkingResponse } from 'interfaces/useCritterApi.interface'; + +const useMarkingApi = (axios: AxiosInstance) => { + /** + * Create a Critter Marking. + * + * @async + * @param {ICreateCritterMarking} payload + * @returns {Promise} - The created marking. + */ + const createMarking = async (payload: ICreateCritterMarking): Promise => { + const { data } = await axios.post(`/api/critterbase/markings/create`, payload); + return data; + }; + + /** + * Update a Critter Marking. + * + * @async + * @param {ICreateCritterMarking} payload + * @returns {Promise} - The updated marking. + */ + const updateMarking = async (payload: ICreateCritterMarking): Promise => { + const { data } = await axios.patch(`/api/critterbase/markings/${payload.marking_id}`, payload); + return data; + }; + + /** + * Delete a Critter Marking. + * + * @async + * @param {string} markingID + * @returns {Promise} - The deleted marking. + */ + const deleteMarking = async (markingID: string): Promise => { + const { data } = await axios.delete(`/api/critterbase/markings/${markingID}`); + + return data; + }; + + return { createMarking, updateMarking, deleteMarking }; +}; + +export { useMarkingApi }; diff --git a/app/src/hooks/cb_api/useMarkings.tsx b/app/src/hooks/cb_api/useMarkings.tsx deleted file mode 100644 index 909d0e599d..0000000000 --- a/app/src/hooks/cb_api/useMarkings.tsx +++ /dev/null @@ -1,14 +0,0 @@ -import { AxiosInstance } from 'axios'; - -const useMarkings = (axios: AxiosInstance) => { - const getAllMarkings = async (): Promise[]> => { - const { data } = await axios.get('/api/markings'); - return data; - }; - - return { - getAllMarkings - }; -}; - -export { useMarkings }; diff --git a/app/src/hooks/cb_api/useMeasurementApi.tsx b/app/src/hooks/cb_api/useMeasurementApi.tsx new file mode 100644 index 0000000000..4191e977fb --- /dev/null +++ b/app/src/hooks/cb_api/useMeasurementApi.tsx @@ -0,0 +1,110 @@ +import { AxiosInstance } from 'axios'; +import { ICreateCritterMeasurement } from 'features/surveys/view/survey-animals/animal'; +import { IQualitativeMeasurementResponse, IQuantitativeMeasurementResponse } from 'interfaces/useCritterApi.interface'; + +type CreateQualitativeMeasurement = Omit; +type CreateQuantitativeMeasurement = Omit< + ICreateCritterMeasurement, + 'measurement_qualitative_id' | 'qualitative_option_id' +>; + +const useMeasurementApi = (axios: AxiosInstance) => { + /** + * Create critter qualitative measurement. + * + * @async + * @param {CreateQualitativeMeasurement} payload - Create qualitative measurement payload. + * @returns {Promise} The created qualitative measurement. + */ + const createQualitativeMeasurement = async ( + payload: CreateQualitativeMeasurement + ): Promise => { + const { data } = await axios.post(`/api/critterbase/measurements/qualitative/create`, payload); + return data; + }; + + /** + * Create critter quantitative measurement. + * + * @async + * @param {CreateQuantitativeMeasurement} payload - Create quantitative measurement payload. + * @returns {Promise} The created quantitative measurement. + */ + const createQuantitativeMeasurement = async ( + payload: CreateQuantitativeMeasurement + ): Promise => { + const { data } = await axios.post(`/api/critterbase/measurements/quantitative/create`, payload); + return data; + }; + + /** + * Update critter qualitative measurement. + * + * @async + * @param {CreateQualitativeMeasurement} payload - Create quantitative measurement payload. + * @returns {Promise} The updated qualitative measurement. + */ + const updateQualitativeMeasurement = async ( + payload: CreateQualitativeMeasurement + ): Promise => { + const { data } = await axios.patch( + `/api/critterbase/measurements/qualitative/${payload.measurement_qualitative_id}`, + payload + ); + return data; + }; + + /** + * Update critter quantitative measurement. + * + * @async + * @param {CreateQuantitativeMeasurement} payload - Update quantitative measurement payload. + * @returns {Promise} The updated qualitative measurement. + */ + const updateQuantitativeMeasurement = async ( + payload: CreateQuantitativeMeasurement + ): Promise => { + const { data } = await axios.patch( + `/api/critterbase/measurements/quantitative/${payload.measurement_quantitative_id}`, + payload + ); + return data; + }; + + /** + * Delete critter qualitative measurement. + * + * @async + * @param {string} measurementID - taxon_measurement_id. + * @returns {Promise} The deleted qualitative measurement. + */ + const deleteQualitativeMeasurement = async (measurementID: string): Promise => { + const { data } = await axios.delete(`/api/critterbase/measurements/qualitative/${measurementID}`); + + return data; + }; + + /** + * Delete critter quantitative measurement. + * + * @async + * @param {string} measurementID - taxon_measurement_id. + * @returns {Promise} The deleted quantitative measurement. + */ + const deleteQuantitativeMeasurement = async (measurementID: string): Promise => { + const { data } = await axios.delete(`/api/critterbase/measurements/quantitative/${measurementID}`); + + return data; + }; + + return { + createQualitativeMeasurement, + createQuantitativeMeasurement, + updateQualitativeMeasurement, + updateQuantitativeMeasurement, + deleteQuantitativeMeasurement, + deleteQualitativeMeasurement + }; +}; + +export { useMeasurementApi }; diff --git a/app/src/hooks/cb_api/useMortalityApi.tsx b/app/src/hooks/cb_api/useMortalityApi.tsx new file mode 100644 index 0000000000..50012709fe --- /dev/null +++ b/app/src/hooks/cb_api/useMortalityApi.tsx @@ -0,0 +1,46 @@ +import { AxiosInstance } from 'axios'; +import { ICreateCritterMortality } from 'features/surveys/view/survey-animals/animal'; +import { IMortalityResponse } from 'interfaces/useCritterApi.interface'; + +const useMortalityApi = (axios: AxiosInstance) => { + /** + * Create critter mortality. + * + * @async + * @param {ICreateCritterMortality} payload - Create critter mortality payload. + * @returns {Promise} The created critter mortality. + */ + const createMortality = async (payload: ICreateCritterMortality): Promise => { + const { data } = await axios.post(`/api/critterbase/mortality/create`, payload); + return data; + }; + + /** + * Update critter mortality. + * + * @async + * @param {ICreateCritterMortality} payload - Update critter mortality payload. + * @returns {Promise} The updated critter mortality. + */ + const updateMortality = async (payload: ICreateCritterMortality): Promise => { + const { data } = await axios.patch(`/api/critterbase/mortality/${payload.mortality_id}`, payload); + return data; + }; + + /** + * Delete critter mortality. + * + * @async + * @param {string} mortalityID - mortality_id. + * @returns {Promise} The deleted critter mortality. + */ + const deleteMortality = async (mortalityID: string): Promise => { + const { data } = await axios.delete(`/api/critterbase/mortality/${mortalityID}`); + + return data; + }; + + return { createMortality, updateMortality, deleteMortality }; +}; + +export { useMortalityApi }; diff --git a/app/src/hooks/useCritterbaseApi.ts b/app/src/hooks/useCritterbaseApi.ts index c3a118d258..9fd2fa1c7e 100644 --- a/app/src/hooks/useCritterbaseApi.ts +++ b/app/src/hooks/useCritterbaseApi.ts @@ -3,9 +3,14 @@ import { useConfigContext } from 'hooks/useContext'; import { useMemo } from 'react'; import useAxios from './api/useAxios'; import { useAuthentication } from './cb_api/useAuthenticationApi'; +import { useCaptureApi } from './cb_api/useCaptureApi'; +import { useCollectionUnitApi } from './cb_api/useCollectionUnitApi'; +import { useCritterApi } from './cb_api/useCritterApi'; import { useFamilyApi } from './cb_api/useFamilyApi'; import { useLookupApi } from './cb_api/useLookupApi'; -import { useMarkings } from './cb_api/useMarkings'; +import { useMarkingApi } from './cb_api/useMarkingApi'; +import { useMeasurementApi } from './cb_api/useMeasurementApi'; +import { useMortalityApi } from './cb_api/useMortalityApi'; /** * Returns a set of supported api methods. @@ -16,7 +21,7 @@ export const useCritterbaseApi = () => { const config = useConfigContext(); const apiAxios = useAxios(config?.API_HOST); - const markings = useMarkings(apiAxios); + const critters = useCritterApi(apiAxios); const authentication = useAuthentication(apiAxios); @@ -26,13 +31,28 @@ export const useCritterbaseApi = () => { const xref = useXrefApi(apiAxios); + const marking = useMarkingApi(apiAxios); + + const collectionUnit = useCollectionUnitApi(apiAxios); + + const measurement = useMeasurementApi(apiAxios); + + const mortality = useMortalityApi(apiAxios); + + const capture = useCaptureApi(apiAxios); + return useMemo( () => ({ - markings, + critters, authentication, lookup, family, - xref + xref, + marking, + collectionUnit, + measurement, + mortality, + capture }), // eslint-disable-next-line react-hooks/exhaustive-deps diff --git a/app/src/hooks/useCritterbaseUserWrapper.tsx b/app/src/hooks/useCritterbaseUserWrapper.tsx index 3821794dde..58a3e51c66 100644 --- a/app/src/hooks/useCritterbaseUserWrapper.tsx +++ b/app/src/hooks/useCritterbaseUserWrapper.tsx @@ -14,9 +14,14 @@ export interface ICritterbaseUserWrapper { } function useCritterbaseUserWrapper(simsUserWrapper: ISimsUserWrapper): ICritterbaseUserWrapper { - const cbApi = useCritterbaseApi(); + const critterbaseApi = useCritterbaseApi(); - const critterbaseSignupLoader = useDataLoader(async () => cbApi.authentication.signUp()); + const critterbaseSignupLoader = useDataLoader(async () => + critterbaseApi.authentication.signUp().catch(() => { + // Squash the error + return undefined; + }) + ); if (!simsUserWrapper.isLoading && simsUserWrapper.systemUserId) { critterbaseSignupLoader.load(); diff --git a/app/src/interfaces/useCritterApi.interface.ts b/app/src/interfaces/useCritterApi.interface.ts index baad5ae190..be5ddd6083 100644 --- a/app/src/interfaces/useCritterApi.interface.ts +++ b/app/src/interfaces/useCritterApi.interface.ts @@ -1,4 +1,4 @@ -type ICollectionUnitResponse = { +export type ICollectionUnitResponse = { critter_collection_unit_id: string; category_name: string; unit_name: string; @@ -7,32 +7,29 @@ type ICollectionUnitResponse = { }; type ILocationResponse = { + location_id: string; latitude: number; longitude: number; coordinate_uncertainty: number | null; + coordinate_uncertainty_unit: string | null; temperature: number | null; location_comment: string | null; region_env_id: string | null; region_nr_id: string | null; wmu_id: string | null; - region_env_name: string | null; - region_nr_name: string | null; - wmu_name: string | null; }; -type ICaptureResponse = { +export type ICaptureResponse = { capture_id: string; - capture_location_id: string | null; - release_location_id: string | null; capture_timestamp: string; release_timestamp: string | null; capture_comment: string | null; release_comment: string | null; - capture_location: ILocationResponse | null; - release_location: ILocationResponse | null; + capture_location: ILocationResponse; + release_location: ILocationResponse | null | undefined; }; -type IMarkingResponse = { +export type IMarkingResponse = { marking_id: string; capture_id: string; mortality_id: string | null; @@ -45,7 +42,6 @@ type IMarkingResponse = { frequency: string | null; frequency_unit: string | null; order: string | null; - comment: string | null; attached_timestamp: string; removed_timestamp: string | null; body_location: string; @@ -54,9 +50,10 @@ type IMarkingResponse = { primary_colour: string | null; secondary_colour: string | null; text_colour: string | null; + comment: string | null; }; -type IQualitativeMeasurementResponse = { +export type IQualitativeMeasurementResponse = { measurement_qualitative_id: string; taxon_measurement_id: string; capture_id: string | null; @@ -65,11 +62,10 @@ type IQualitativeMeasurementResponse = { measurement_comment: string | null; measured_timestamp: string | null; measurement_name: string; - option_label: string; - option_value: number; + value: string; }; -type IQuantitativeMeasurementResponse = { +export type IQuantitativeMeasurementResponse = { measurement_quantitative_id: string; taxon_measurement_id: string; capture_id: string | null; @@ -80,50 +76,45 @@ type IQuantitativeMeasurementResponse = { measurement_name: string; }; -type IMortalityResponse = { +export type IMortalityResponse = { mortality_id: string; location_id: string | null; mortality_timestamp: string; location: ILocationResponse; proximate_cause_of_death_id: string | null; proximate_cause_of_death_confidence: string; - proximate_predated_by_taxon_id: string | null; + proximate_predated_by_itis_tsn: number | null; ultimate_cause_of_death_id: string | null; ultimate_cause_of_death_confidence: string; - ultimate_predated_by_taxon_id: string | null; + ultimate_predated_by_itis_tsn: number | null; mortality_comment: string | null; }; -type IFamilyParentResponse = { +export type IFamilyParentResponse = { family_id: string; + family_label: string; parent_critter_id: string; }; -type IFamilyChildResponse = { +export type IFamilyChildResponse = { family_id: string; + family_label: string; child_critter_id: string; }; export type ICritterDetailedResponse = { critter_id: string; - itis_tsn: string; + itis_tsn: number; + itis_scientific_name: string; wlh_id: string | null; animal_id: string | null; sex: string; responsible_region_nr_id: string; - create_user: string; - update_user: string; - create_timestamp: string; - update_timestamp: string; - critter_comment: string; - itis_scientific_name: string; - responsible_region: string; - mortality_timestamp: string | null; collection_units: ICollectionUnitResponse[]; mortality: IMortalityResponse[]; - capture: ICaptureResponse[]; - marking: IMarkingResponse[]; - measurement: { + captures: ICaptureResponse[]; + markings: IMarkingResponse[]; + measurements: { qualitative: IQualitativeMeasurementResponse[]; quantitative: IQuantitativeMeasurementResponse[]; }; @@ -133,12 +124,12 @@ export type ICritterDetailedResponse = { export interface ICritterSimpleResponse { critter_id: string; - wlh_id: string; - animal_id: string; + wlh_id: string | null; + animal_id: string | null; sex: string; - taxon: string; - collection_units: ICollectionUnitResponse[]; - mortality_timestamp?: string; + itis_tsn: number; + itis_scientific_name: string; + responsible_region_nr_id: string | null; } /** diff --git a/app/src/interfaces/useSurveyApi.interface.ts b/app/src/interfaces/useSurveyApi.interface.ts index f26bdab803..d918b15e71 100644 --- a/app/src/interfaces/useSurveyApi.interface.ts +++ b/app/src/interfaces/useSurveyApi.interface.ts @@ -9,7 +9,7 @@ import { ISurveySiteSelectionForm } from 'features/surveys/components/SurveySite import { Feature } from 'geojson'; import { ITaxonomy } from 'interfaces/useTaxonomyApi.interface'; import { ApiPaginationResponseParams, StringBoolean } from 'types/misc'; -import { ICritterDetailedResponse } from './useCritterApi.interface'; +import { ICritterDetailedResponse, ICritterSimpleResponse } from './useCritterApi.interface'; /** * Create survey post object. @@ -325,6 +325,10 @@ export interface IGetSurveyForUpdateResponse { surveyData: SurveyUpdateObject; } +export interface ISimpleCritterWithInternalId extends ICritterSimpleResponse { + survey_critter_id: number; +} + export interface IDetailedCritterWithInternalId extends ICritterDetailedResponse { survey_critter_id: number; //The internal critter_id in the SIMS DB. Called this to distinguish against the critterbase UUID of the same name. } diff --git a/app/src/utils/mapProjectionHelpers.ts b/app/src/utils/mapProjectionHelpers.ts index 026a24a052..8e600a42b7 100644 --- a/app/src/utils/mapProjectionHelpers.ts +++ b/app/src/utils/mapProjectionHelpers.ts @@ -1,6 +1,11 @@ import { LatLng } from 'leaflet'; import proj4 from 'proj4'; +export enum PROJECTION_MODE { + WGS = 'WGS', + UTM = 'UTM' +} + const degreesToRadians = (degrees: number): number => { return (degrees * Math.PI) / 180; }; @@ -28,27 +33,34 @@ const distanceInMetresBetweenCoordinates = (latlng1: LatLng, latlng2: LatLng): n return earthRadiusKm * c * 1000; }; -const utmProjection = `+proj=utm +zone=${10} +north +datum=WGS84 +units=m +no_defs`; +const utmProjection = `+proj=utm +zone=10 +north +datum=WGS84 +units=m +no_defs`; const wgs84Projection = `+proj=longlat +datum=WGS84 +no_defs`; /** * Returns the UTM Zone 10 coords as Latitude and Longitude + * Latitude === Northing (Y) + * Longitude === Easting (X) + * * @param northing * @param easting - * @returns [longitude, latitude] + * @returns [latitude, longitude] */ -const getUtmAsLatLng = (northing: number, easting: number) => { - return proj4(utmProjection, wgs84Projection, [Number(easting), Number(northing)]).map((a) => Number(a.toFixed(3))); +const getUtmAsLatLng = (northing: /*lat*/ number, easting: /*lng*/ number) => { + return proj4(utmProjection, wgs84Projection, [Number(easting), Number(northing)]) + .map((a) => Number(a.toFixed(5))) + .reverse(); }; /** * Returns the WGS84 LatLng coords as UTM Zone 10 Northing and Easting * @param lat latitude, in degrees * @param lng longitude, in degrees - * @returns [easting, northing] + * @returns [northing (latitude), easting (longitude)] */ const getLatLngAsUtm = (lat: number, lng: number) => { - return proj4(wgs84Projection, utmProjection, [Number(lng), Number(lat)]).map((a) => Number(a.toFixed(3))); + return proj4(wgs84Projection, utmProjection, [Number(lng), Number(lat)]) + .map((a) => Number(a.toFixed(0))) + .reverse(); }; export { getLatLngAsUtm, getUtmAsLatLng, distanceInMetresBetweenCoordinates }; diff --git a/database/src/seeds/03_basic_project_survey_setup.ts b/database/src/seeds/03_basic_project_survey_setup.ts index 8f7523804a..e8980d30e1 100644 --- a/database/src/seeds/03_basic_project_survey_setup.ts +++ b/database/src/seeds/03_basic_project_survey_setup.ts @@ -605,7 +605,7 @@ const insertSurveySamplePeriodData = (surveyId: number) => `; const insertObservationSubCount = (surveyObservationId: number) => ` - INSERT INTO observation_subcount + INSERT INTO observation_subcount ( survey_observation_id, subcount @@ -659,7 +659,7 @@ const insertSurveyObservationData = (surveyId: number, count: number) => { .toISOString()}$$::time, (SELECT survey_sample_site_id FROM survey_sample_site WHERE survey_id = ${surveyId} LIMIT 1), - + (SELECT survey_sample_method_id FROM survey_sample_method WHERE survey_sample_site_id = ( SELECT survey_sample_site_id FROM survey_sample_site WHERE survey_id = ${surveyId} LIMIT 1 ) LIMIT 1), From 91b11654ac0ad208afde8399f377822f38a0529e Mon Sep 17 00:00:00 2001 From: Curtis Upshall Date: Thu, 28 Mar 2024 19:44:41 -0400 Subject: [PATCH 8/9] SIMSBIOHUB-536: Refactored UI for downloading observation CSV template (#1264) * Adds the "Download CSV Observation Template" button as an iconbutton on the Manage Observations page. * When duplicate permit numbers are entered in the edit survey page, the permit number input renders the error on each form field. * When duplicate funding sources are selected in the edit survey page, the funding source select component renders the error on each form field. * When a survey participant's job has not been entered, only the participant job select component is styled with an error border. --- app/src/components/user/UserRoleSelector.tsx | 5 +- app/src/contexts/observationsTableContext.tsx | 2 +- app/src/contexts/telemetryTableContext.tsx | 68 +++++++++++-------- app/src/features/surveys/SurveyPermitForm.tsx | 35 +++++++--- .../components/SurveyFundingSourceForm.tsx | 38 ++++++++--- .../ObservationsTableContainer.tsx | 2 + .../ConfigureColumnsPopoverContent.tsx | 2 - .../export-button/ExportHeadersButton.tsx | 10 +-- .../telemetry-table/ManualTelemetryTable.tsx | 26 ++++++- .../ManualTelemetryTableContainer.tsx | 6 +- app/src/hooks/useContext.tsx | 22 +++++- app/src/types/yup.d.ts | 22 ------ app/src/utils/YupSchema.ts | 30 -------- 13 files changed, 145 insertions(+), 123 deletions(-) diff --git a/app/src/components/user/UserRoleSelector.tsx b/app/src/components/user/UserRoleSelector.tsx index bba3eb4770..2051453ed8 100644 --- a/app/src/components/user/UserRoleSelector.tsx +++ b/app/src/components/user/UserRoleSelector.tsx @@ -30,10 +30,6 @@ const UserRoleSelector: React.FC = (props) => { sx={{ background: grey[100], '&.userRoleItemError': { - borderColor: 'error.main', - '& .MuiOutlinedInput-notchedOutline': { - borderColor: 'error.main' - }, '& + p': { pt: 0.75, pb: 0.75, @@ -51,6 +47,7 @@ const UserRoleSelector: React.FC = (props) => { inputProps={{ 'aria-label': 'Select a role' }} + error={Boolean(props.error)} data-testid={`select-user-role-button-${index}`} sx={{ width: '200px', backgroundColor: '#fff' }} displayEmpty diff --git a/app/src/contexts/observationsTableContext.tsx b/app/src/contexts/observationsTableContext.tsx index 0219e6634c..213fb4ddb3 100644 --- a/app/src/contexts/observationsTableContext.tsx +++ b/app/src/contexts/observationsTableContext.tsx @@ -265,7 +265,7 @@ export type IObservationsTableContext = { export const ObservationsTableContext = createContext(undefined); -export const ObservationsTableContextProvider = (props: PropsWithChildren>) => { +export const ObservationsTableContextProvider = (props: PropsWithChildren) => { const { projectId, surveyId } = useContext(SurveyContext); const _muiDataGridApiRef = useGridApiRef(); diff --git a/app/src/contexts/telemetryTableContext.tsx b/app/src/contexts/telemetryTableContext.tsx index 6fc5d7f3c5..61d86c0a2c 100644 --- a/app/src/contexts/telemetryTableContext.tsx +++ b/app/src/contexts/telemetryTableContext.tsx @@ -1,12 +1,19 @@ import Typography from '@mui/material/Typography'; -import { GridRowId, GridRowSelectionModel, GridValidRowModel, useGridApiRef } from '@mui/x-data-grid'; +import { + GridRowId, + GridRowModes, + GridRowModesModel, + GridRowSelectionModel, + GridValidRowModel, + useGridApiRef +} from '@mui/x-data-grid'; import { GridApiCommunity, GridStateColDef } from '@mui/x-data-grid/internals'; import { TelemetryTableI18N } from 'constants/i18n'; import { DialogContext } from 'contexts/dialogContext'; import { default as dayjs } from 'dayjs'; import { APIError } from 'hooks/api/useAxios'; import { ICreateManualTelemetry, IUpdateManualTelemetry, useTelemetryApi } from 'hooks/useTelemetryApi'; -import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react'; +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 { TelemetryDataContext } from './telemetryDataContext'; @@ -88,6 +95,14 @@ export type ITelemetryTableContext = { * Sets the IDs of the selected telemetry table rows */ onRowSelectionModelChange: (rowSelectionModel: GridRowSelectionModel) => void; + /** + * The row modes model, which defines which rows are in edit mode. + */ + rowModesModel: GridRowModesModel; + /** + * Sets the row modes model. + */ + setRowModesModel: React.Dispatch>; /** * Indicates if the data is in the process of being persisted to the server. */ @@ -110,35 +125,13 @@ export type ITelemetryTableContext = { setRecordCount: (count: number) => void; }; -export const TelemetryTableContext = createContext({ - _muiDataGridApiRef: null as unknown as React.MutableRefObject, - rows: [], - setRows: () => {}, - getColumns: () => [], - 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 { +export const TelemetryTableContext = createContext(undefined); + +type ITelemetryTableContextProviderProps = PropsWithChildren<{ deployment_ids: string[]; - children?: React.ReactNode; -} +}>; -export const TelemetryTableContextProvider: React.FC = (props) => { +export const TelemetryTableContextProvider = (props: ITelemetryTableContextProviderProps) => { const { children, deployment_ids } = props; const _muiDataGridApiRef = useGridApiRef(); @@ -150,18 +143,28 @@ export const TelemetryTableContextProvider: React.FC([]); + // Stores the currently selected row ids const [rowSelectionModel, setRowSelectionModel] = useState([]); + + // The row modes model, which defines which rows are in edit mode + const [rowModesModel, setRowModesModel] = 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({}); @@ -396,7 +399,10 @@ export const TelemetryTableContextProvider: React.FC [...current, id]); // Set edit mode for the new row - _muiDataGridApiRef.current.startRowEditMode({ id, fieldToFocus: 'itis_tsn' }); + setRowModesModel((current) => ({ + ...current, + [id]: { mode: GridRowModes.Edit } + })); }, [_muiDataGridApiRef, rows]); /** @@ -649,6 +655,8 @@ export const TelemetryTableContextProvider: React.FC permit.permit_number === permitNumber + ).length <= 1 + ); + }), + permit_type: yup.string().required('Permit Type is Required') + }) + ) }) }); @@ -80,7 +93,7 @@ const SurveyPermitForm: React.FC = () => { display: 'none' } }}> - {values.permit.permits?.map((permit, index) => { + {values.permit.permits?.map((permit: ISurveyPermitFormArrayItem, index) => { const permitNumberMeta = getFieldMeta(`permit.permits.[${index}].permit_number`); const permitTypeMeta = getFieldMeta(`permit.permits.[${index}].permit_type`); diff --git a/app/src/features/surveys/components/SurveyFundingSourceForm.tsx b/app/src/features/surveys/components/SurveyFundingSourceForm.tsx index b4fcecc9fc..5f4571cc6c 100644 --- a/app/src/features/surveys/components/SurveyFundingSourceForm.tsx +++ b/app/src/features/surveys/components/SurveyFundingSourceForm.tsx @@ -40,22 +40,38 @@ const SurveyFundingSourceInitialValues: ISurveyFundingSource = { survey_id: 0 }; -export const SurveyFundingSourceYupSchema = yup.object().shape({ - funding_source_id: yup.number().required('Must select a funding source').min(1, 'Must select a funding source'), // TODO confirm that this is not triggered when the autocomplete is empty. - amount: yup - .number() - .min(0, 'Must be a positive number') - .max(9999999999, 'Cannot exceed $9,999,999,999') - .nullable(true) - .transform((value) => (isNaN(value) ? null : Number(value))) -}); - export const SurveyFundingSourceFormInitialValues: ISurveyFundingSourceForm = { funding_sources: [] }; export const SurveyFundingSourceFormYupSchema = yup.object().shape({ - funding_sources: yup.array(SurveyFundingSourceYupSchema).isUniqueFundingSource('Funding sources must be unique') + funding_sources: yup.array( + yup.object().shape({ + funding_source_id: yup + .number() + .required('Must select a funding source') + .min(1, 'Must select a funding source') + .test('is-unique-funding-source', 'Funding sources must be unique', function (fundingSourceId) { + const formValues = this.options.context; + + if (!formValues?.funding_sources?.length) { + return true; + } + + return ( + formValues.funding_sources.filter( + (fundingSource: ISurveyFundingSource) => fundingSource.funding_source_id === fundingSourceId + ).length <= 1 + ); + }), + amount: yup + .number() + .min(0, 'Must be a positive number') + .max(9999999999, 'Cannot exceed $9,999,999,999') + .nullable(true) + .transform((value) => (isNaN(value) ? null : Number(value))) + }) + ) }); /** diff --git a/app/src/features/surveys/observations/observations-table/ObservationsTableContainer.tsx b/app/src/features/surveys/observations/observations-table/ObservationsTableContainer.tsx index 4032518825..0565338c5d 100644 --- a/app/src/features/surveys/observations/observations-table/ObservationsTableContainer.tsx +++ b/app/src/features/surveys/observations/observations-table/ObservationsTableContainer.tsx @@ -41,6 +41,7 @@ import { } from 'interfaces/useSurveyApi.interface'; import { useContext } from 'react'; import { getCodesName } from 'utils/Utils'; +import ExportHeadersButton from './export-button/ExportHeadersButton'; import { getMeasurementColumnDefinitions } from './grid-column-definitions/GridColumnDefinitionsUtils'; const ObservationComponent = () => { @@ -155,6 +156,7 @@ const ObservationComponent = () => { /> + diff --git a/app/src/features/surveys/observations/observations-table/configure-table/ConfigureColumnsPopoverContent.tsx b/app/src/features/surveys/observations/observations-table/configure-table/ConfigureColumnsPopoverContent.tsx index 93aee4c374..de418c6951 100644 --- a/app/src/features/surveys/observations/observations-table/configure-table/ConfigureColumnsPopoverContent.tsx +++ b/app/src/features/surveys/observations/observations-table/configure-table/ConfigureColumnsPopoverContent.tsx @@ -17,7 +17,6 @@ import { GridColDef } from '@mui/x-data-grid'; import { IObservationTableRow } from 'contexts/observationsTableContext'; import { MeasurementsButton } from 'features/surveys/observations/observations-table/configure-table/measurements/dialog/MeasurementsButton'; import { CBMeasurementType } from 'interfaces/useCritterApi.interface'; -import ExportHeadersButton from '../export-button/ExportHeadersButton'; export interface IConfigureColumnsPopoverContentProps { hideableColumns: GridColDef[]; @@ -57,7 +56,6 @@ export const ConfigureColumnsPopoverContent = (props: IConfigureColumnsPopoverCo Configure Observations - { }; return ( - + + + ); }; diff --git a/app/src/features/surveys/telemetry/telemetry-table/ManualTelemetryTable.tsx b/app/src/features/surveys/telemetry/telemetry-table/ManualTelemetryTable.tsx index 5e2ed90a07..848d895339 100644 --- a/app/src/features/surveys/telemetry/telemetry-table/ManualTelemetryTable.tsx +++ b/app/src/features/surveys/telemetry/telemetry-table/ManualTelemetryTable.tsx @@ -3,7 +3,7 @@ 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 { DataGrid, GridCellParams, GridColDef, GridRowModesModel } 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'; @@ -11,16 +11,19 @@ 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 { IManualTelemetryTableRow } from 'contexts/telemetryTableContext'; import { default as dayjs } from 'dayjs'; +import { useTelemetryTableContext } from 'hooks/useContext'; 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 telemetryTableContext = useTelemetryTableContext(); const surveyContext = useContext(SurveyContext); useEffect(() => { @@ -309,6 +312,21 @@ const ManualTelemetryTable = (props: IManualTelemetryTableProps) => { } ]; + /** + * Callback fired when the row modes model changes. + * The row modes model stores the `view` vs `edit` state of the rows. + * + * Note: Any row not included in the model will default to `view` mode. + * + * @param {GridRowModesModel} model + */ + const onRowModesModelChange = useCallback( + (model: GridRowModesModel) => { + telemetryTableContext.setRowModesModel(() => model); + }, + [telemetryTableContext] + ); + return ( { editMode="row" columns={tableColumns} rows={telemetryTableContext.rows} + rowModesModel={telemetryTableContext.rowModesModel} + onRowModesModelChange={onRowModesModelChange} onRowEditStart={(params) => telemetryTableContext.onRowEditStart(params.id)} onRowEditStop={(_params, event) => { event.defaultMuiPrevented = true; diff --git a/app/src/features/surveys/telemetry/telemetry-table/ManualTelemetryTableContainer.tsx b/app/src/features/surveys/telemetry/telemetry-table/ManualTelemetryTableContainer.tsx index d33b730aa7..96bfd17723 100644 --- a/app/src/features/surveys/telemetry/telemetry-table/ManualTelemetryTableContainer.tsx +++ b/app/src/features/surveys/telemetry/telemetry-table/ManualTelemetryTableContainer.tsx @@ -22,7 +22,7 @@ import { TelemetryTableI18N } from 'constants/i18n'; import { getSurveySessionStorageKey, SIMS_TELEMETRY_HIDDEN_COLUMNS } from 'constants/session-storage'; import { DialogContext, ISnackbarProps } from 'contexts/dialogContext'; import { SurveyContext } from 'contexts/surveyContext'; -import { TelemetryTableContext } from 'contexts/telemetryTableContext'; +import { useTelemetryTableContext } from 'hooks/useContext'; import { useTelemetryApi } from 'hooks/useTelemetryApi'; import { useCallback, useContext, useEffect, useMemo, useState } from 'react'; import { pluralize as p } from 'utils/Utils'; @@ -35,10 +35,12 @@ const ManualTelemetryTableContainer = () => { const [contextMenuAnchorEl, setContextMenuAnchorEl] = useState(null); const [columnVisibilityMenuAnchorEl, setColumnVisibilityMenuAnchorEl] = useState(null); const [hiddenFields, setHiddenFields] = useState([]); + const dialogContext = useContext(DialogContext); - const telemetryTableContext = useContext(TelemetryTableContext); + const telemetryTableContext = useTelemetryTableContext(); const surveyContext = useContext(SurveyContext); const telemetryApi = useTelemetryApi(); + const { hasUnsavedChanges, validationModel, _muiDataGridApiRef } = telemetryTableContext; const showSnackBar = (textDialogProps?: Partial) => { diff --git a/app/src/hooks/useContext.tsx b/app/src/hooks/useContext.tsx index e58a7e61f5..78f17dee4f 100644 --- a/app/src/hooks/useContext.tsx +++ b/app/src/hooks/useContext.tsx @@ -6,6 +6,7 @@ import { IObservationsTableContext, ObservationsTableContext } from 'contexts/ob import { IProjectContext, ProjectContext } from 'contexts/projectContext'; import { ISurveyContext, SurveyContext } from 'contexts/surveyContext'; import { ITaxonomyContext, TaxonomyContext } from 'contexts/taxonomyContext'; +import { ITelemetryTableContext, TelemetryTableContext } from 'contexts/telemetryTableContext'; import { useContext } from 'react'; /** @@ -94,7 +95,7 @@ export const useSurveyContext = (): ISurveyContext => { }; /** - * Returns an instance of `IObservationsContext` from `SurveyContext`. + * Returns an instance of `IObservationsContext` from `ObservationsContext`. * * @return {*} {IObservationsContext} */ @@ -111,7 +112,7 @@ export const useObservationsContext = (): IObservationsContext => { }; /** - * Returns an instance of `IObservationsTableContext` from `SurveyContext`. + * Returns an instance of `IObservationsTableContext` from `ObservationsTableContext`. * * @return {*} {IObservationsTableContext} */ @@ -127,6 +128,23 @@ export const useObservationsTableContext = (): IObservationsTableContext => { return context; }; +/** + * Returns an instance of `IObservationsTableContext` from `ObservationsTableContext`. + * + * @return {*} {IObservationsTableContext} + */ +export const useTelemetryTableContext = (): ITelemetryTableContext => { + const context = useContext(TelemetryTableContext); + + if (!context) { + throw Error( + 'TelemetryTableContext is undefined, please verify you are calling useTelemetryTableContext() as child of an component.' + ); + } + + return context; +}; + /** * Returns an instance of `ITaxonomyContext` from `SurveyContext`. * diff --git a/app/src/types/yup.d.ts b/app/src/types/yup.d.ts index b99af0f454..30fce24097 100644 --- a/app/src/types/yup.d.ts +++ b/app/src/types/yup.d.ts @@ -100,28 +100,6 @@ declare module 'yup' { } export class ArraySchema extends yup.ArraySchema { - /** - * Determine if the array of permits has duplicate permit numbers - * - * @param {string} message='Permit numbers must be unique' - error message if this check fails - * @return {*} {(yup.StringSchema, string | undefined>)} - * @memberof ArraySchema - */ - isUniquePermitNumber( - message: string - ): yup.StringSchema, string | undefined>; - - /** - * Determine if the array of funding sources has duplicates - * - * @param {string} message - * @return {*} {(yup.StringSchema, string | undefined>)} - * @memberof ArraySchema - */ - isUniqueFundingSource( - message: string - ): yup.StringSchema, string | undefined>; - /** * Determine if the array of classification details has duplicates * diff --git a/app/src/utils/YupSchema.ts b/app/src/utils/YupSchema.ts index 4d1a85e5ea..811a482df3 100644 --- a/app/src/utils/YupSchema.ts +++ b/app/src/utils/YupSchema.ts @@ -7,36 +7,6 @@ import { DATE_FORMAT } from 'constants/dateTimeFormats'; import { default as dayjs } from 'dayjs'; import * as yup from 'yup'; -yup.addMethod(yup.array, 'isUniquePermitNumber', function (message: string) { - return this.test('is-unique-permit-number', message, (values) => { - if (!values || !values.length) { - return true; - } - - const seen = new Set(); - const hasDuplicates = values.some((permit) => { - return seen.size === seen.add(permit.permit_number).size; - }); - - return !hasDuplicates; - }); -}); - -yup.addMethod(yup.array, 'isUniqueFundingSource', function (message: string) { - return this.test('is-unique-funding-source-id', message, (values) => { - if (!values || !values.length) { - return true; - } - - const seen = new Set(); - const hasDuplicates = values.some((fundingSource) => { - return seen.size === seen.add(fundingSource.funding_source_id).size; - }); - - return !hasDuplicates; - }); -}); - yup.addMethod(yup.array, 'isUniqueIUCNClassificationDetail', function (message: string) { return this.test('is-unique-iucn-classification-detail', message, (values) => { if (!values || !values.length) { From 42b936b6ac281bde64a4d05d36f2b017f8b015a5 Mon Sep 17 00:00:00 2001 From: Al Rosenthal Date: Tue, 2 Apr 2024 11:36:15 -0700 Subject: [PATCH 9/9] Test coverage (#1265) * Added test coverage to: measurement validation in work sheet utils; Observation subcount service and repository. --- ...rvation-subcount-measurement-repository.ts | 2 +- .../repositories/subcount-repository.test.ts | 169 ++++ api/src/repositories/subcount-repository.ts | 25 +- api/src/services/subcount-service.test.ts | 87 +++ api/src/services/subcount-service.ts | 5 +- .../utils/xlsx-utils/worksheet-utils.test.ts | 722 ++++++++++++++++++ api/src/utils/xlsx-utils/worksheet-utils.ts | 25 +- 7 files changed, 1012 insertions(+), 23 deletions(-) create mode 100644 api/src/repositories/subcount-repository.test.ts create mode 100644 api/src/services/subcount-service.test.ts diff --git a/api/src/repositories/observation-subcount-measurement-repository.ts b/api/src/repositories/observation-subcount-measurement-repository.ts index 475df9393e..3b210b08e7 100644 --- a/api/src/repositories/observation-subcount-measurement-repository.ts +++ b/api/src/repositories/observation-subcount-measurement-repository.ts @@ -68,7 +68,7 @@ export class ObservationSubCountMeasurementRepository extends BaseRepository { return response.rows; } - async deleteObservationMeasurements(surveyObservationId: number[], surveyId: number) { + async deleteObservationMeasurements(surveyId: number, surveyObservationId: number[]) { await this.deleteObservationQualitativeMeasurementRecordsForSurveyObservationIds(surveyObservationId, surveyId); await this.deleteObservationQuantitativeMeasurementRecordsForSurveyObservationIds(surveyObservationId, surveyId); } diff --git a/api/src/repositories/subcount-repository.test.ts b/api/src/repositories/subcount-repository.test.ts new file mode 100644 index 0000000000..2fe38d9f6e --- /dev/null +++ b/api/src/repositories/subcount-repository.test.ts @@ -0,0 +1,169 @@ +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 { + InsertObservationSubCount, + InsertSubCountEvent, + ObservationSubCountRecord, + SubCountCritterRecord, + SubCountEventRecord, + SubCountRepository +} from './subcount-repository'; + +chai.use(sinonChai); + +describe('SubCountRepository', () => { + afterEach(() => { + sinon.restore(); + }); + + describe('insertObservationSubCount', () => { + it('should successfully insert observation subcount', async () => { + const mockSubcount: ObservationSubCountRecord = { + observation_subcount_id: 1, + survey_observation_id: 1, + subcount: 5, + create_date: '1970-01-01', + create_user: 1, + update_date: null, + update_user: null, + revision_count: 1 + }; + + const mockResponse = ({ + rows: [mockSubcount], + rowCount: 1 + } as any) as Promise>; + + const dbConnection = getMockDBConnection({ + knex: () => mockResponse + }); + + const repo = new SubCountRepository(dbConnection); + const response = await repo.insertObservationSubCount(mockSubcount); + + expect(response).to.eql(mockSubcount); + }); + + it('should catch query errors and throw an ApiExecuteSQLError', async () => { + const mockResponse = ({ + rows: [], + rowCount: 0 + } as any) as Promise>; + const dbConnection = getMockDBConnection({ + knex: () => mockResponse + }); + + const repo = new SubCountRepository(dbConnection); + try { + await repo.insertObservationSubCount((null as unknown) as InsertObservationSubCount); + expect.fail(); + } catch (error) { + expect(((error as any) as ApiExecuteSQLError).message).to.be.eq('Failed to insert observation subcount'); + } + }); + }); + + describe('insertSubCountEvent', () => { + it('should successfully insert subcount_event record', async () => { + const mockInsertSubcountEvent: InsertSubCountEvent = { + observation_subcount_id: 1, + critterbase_event_id: 'aaaa' + }; + + const mockSubcountEvent: SubCountEventRecord = { + observation_subcount_id: 1, + create_date: '1970-01-01', + create_user: 1, + update_date: null, + update_user: null, + revision_count: 1, + subcount_event_id: 1, + critterbase_event_id: 'aaaa' + }; + + const mockResponse = ({ + rows: [mockSubcountEvent], + rowCount: 1 + } as any) as Promise>; + + const dbConnection = getMockDBConnection({ + knex: () => mockResponse + }); + + const repo = new SubCountRepository(dbConnection); + const response = await repo.insertSubCountEvent(mockInsertSubcountEvent); + + expect(response).to.eql(mockSubcountEvent); + }); + + it('should catch query errors and throw an ApiExecuteSQLError', async () => { + const mockResponse = ({ + rows: [], + rowCount: 0 + } as any) as Promise>; + const dbConnection = getMockDBConnection({ + knex: () => mockResponse + }); + + const repo = new SubCountRepository(dbConnection); + try { + await repo.insertSubCountEvent((null as unknown) as InsertSubCountEvent); + expect.fail(); + } catch (error) { + expect(((error as any) as ApiExecuteSQLError).message).to.be.eq('Failed to insert subcount event'); + } + }); + }); + + describe('insertSubCountCritter', () => { + it('should successfully insert a subcount_critter record', async () => { + const mockSubcountCritterRecord: SubCountCritterRecord = { + subcount_critter_id: 1, + observation_subcount_id: 1, + critter_id: 1, + create_date: '1970-01-01', + create_user: 1, + update_date: null, + update_user: null, + revision_count: 1 + }; + + const mockResponse = ({ + rows: [mockSubcountCritterRecord], + rowCount: 1 + } as any) as Promise>; + + const dbConnection = getMockDBConnection({ + knex: () => mockResponse + }); + + const repo = new SubCountRepository(dbConnection); + const response = await repo.insertSubCountCritter(mockSubcountCritterRecord); + + expect(response).to.eql(mockSubcountCritterRecord); + }); + + it('should catch query errors and throw an ApiExecuteSQLError', async () => { + const mockResponse = ({ + rows: [], + rowCount: 0 + } as any) as Promise>; + const dbConnection = getMockDBConnection({ + knex: () => mockResponse + }); + + const repo = new SubCountRepository(dbConnection); + try { + await repo.insertSubCountCritter((null as unknown) as SubCountCritterRecord); + expect.fail(); + } catch (error) { + expect(((error as any) as ApiExecuteSQLError).message).to.be.eq('Failed to insert subcount critter'); + } + }); + }); +}); diff --git a/api/src/repositories/subcount-repository.ts b/api/src/repositories/subcount-repository.ts index 1b92e43e89..4e20cd55bc 100644 --- a/api/src/repositories/subcount-repository.ts +++ b/api/src/repositories/subcount-repository.ts @@ -29,6 +29,19 @@ export const SubCountEventRecord = z.object({ export type SubCountEventRecord = z.infer; export type InsertSubCountEvent = Pick; +export const SubCountCritterRecord = z.object({ + subcount_critter_id: z.number(), + observation_subcount_id: z.number(), + critter_id: z.number(), + create_date: z.string(), + create_user: z.number(), + update_date: z.string().nullable(), + update_user: z.number().nullable(), + revision_count: z.number() +}); + +export type SubCountCritterRecord = z.infer; + export class SubCountRepository extends BaseRepository { /** * Inserts a new observation_subcount record @@ -77,16 +90,14 @@ export class SubCountRepository extends BaseRepository { /** * Inserts a new subcount_critter record. * - * TODO: Implement this function fully. The incoming `record` parameter and the return value are of type `unknown`. - * - * @param {unknown} record - * @return {*} {Promise} + * @param {SubCountCritterRecord} subcountCritter + * @return {*} {Promise} * @memberof SubCountRepository */ - async insertSubCountCritter(record: unknown): Promise { - const queryBuilder = getKnex().insert(record).into('subcount_critter').returning('*'); + async insertSubCountCritter(subcountCritter: SubCountCritterRecord): Promise { + const queryBuilder = getKnex().insert(subcountCritter).into('subcount_critter').returning('*'); - const response = await this.connection.knex(queryBuilder); + const response = await this.connection.knex(queryBuilder, SubCountCritterRecord); if (response.rowCount !== 1) { throw new ApiExecuteSQLError('Failed to insert subcount critter', [ diff --git a/api/src/services/subcount-service.test.ts b/api/src/services/subcount-service.test.ts new file mode 100644 index 0000000000..a470eddbc9 --- /dev/null +++ b/api/src/services/subcount-service.test.ts @@ -0,0 +1,87 @@ +import chai, { expect } from 'chai'; +import { describe } from 'mocha'; +import sinon from 'sinon'; +import sinonChai from 'sinon-chai'; +import { ObservationSubCountMeasurementRepository } from '../repositories/observation-subcount-measurement-repository'; +import { + InsertObservationSubCount, + InsertSubCountEvent, + ObservationSubCountRecord, + SubCountEventRecord, + SubCountRepository +} from '../repositories/subcount-repository'; +import { getMockDBConnection } from '../__mocks__/db'; +import { SubCountService } from './subcount-service'; + +chai.use(sinonChai); + +describe('SubCountService', () => { + afterEach(() => { + sinon.restore(); + }); + + describe('insertObservationSubCount', () => { + it('should insert observation subcount', async () => { + const mockDbConnection = getMockDBConnection(); + const subCountService = new SubCountService(mockDbConnection); + + const insertObservationSubCountStub = sinon + .stub(SubCountRepository.prototype, 'insertObservationSubCount') + .resolves({ observation_subcount_id: 1 } as ObservationSubCountRecord); + + const response = await subCountService.insertObservationSubCount({ + survey_observation_id: 1 + } as InsertObservationSubCount); + + expect(insertObservationSubCountStub).to.be.calledOnceWith({ survey_observation_id: 1 }); + expect(response).to.eql({ observation_subcount_id: 1 }); + }); + }); + + describe('insertSubCountEvent', () => { + it('should insert subcount event', async () => { + const mockDbConnection = getMockDBConnection(); + const subCountService = new SubCountService(mockDbConnection); + + const insertSubCountEventStub = sinon + .stub(SubCountRepository.prototype, 'insertSubCountEvent') + .resolves({ observation_subcount_id: 1 } as SubCountEventRecord); + + const response = await subCountService.insertSubCountEvent({ observation_subcount_id: 1 } as InsertSubCountEvent); + + expect(insertSubCountEventStub).to.be.calledOnceWith({ observation_subcount_id: 1 }); + expect(response).to.eql({ observation_subcount_id: 1 }); + }); + }); + + describe('deleteObservationSubCountRecords', () => { + it('should delete observation_subcount records and related child records', async () => { + const mockDbConnection = getMockDBConnection(); + const subCountService = new SubCountService(mockDbConnection); + + const mockSurveyId = 1; + const mockSurveyObservationIds = [1, 2, 3, 4]; + + const deleteSubCountCritterRecordsForObservationIdStub = sinon + .stub(SubCountRepository.prototype, 'deleteSubCountCritterRecordsForObservationId') + .resolves(); + + const deleteObservationMeasurementsStub = sinon + .stub(ObservationSubCountMeasurementRepository.prototype, 'deleteObservationMeasurements') + .resolves(); + + const deleteObservationSubCountRecordsStub = sinon + .stub(SubCountRepository.prototype, 'deleteObservationSubCountRecords') + .resolves(); + + await subCountService.deleteObservationSubCountRecords(mockSurveyId, mockSurveyObservationIds); + + expect(deleteSubCountCritterRecordsForObservationIdStub).to.be.calledOnceWith( + mockSurveyId, + mockSurveyObservationIds + ); + expect(deleteObservationMeasurementsStub).to.be.calledOnceWith(mockSurveyId, mockSurveyObservationIds); + expect(deleteObservationSubCountRecordsStub).to.be.calledOnceWith(mockSurveyId, mockSurveyObservationIds); + }); + }); +}); diff --git a/api/src/services/subcount-service.ts b/api/src/services/subcount-service.ts index a19088ca73..f333d5e235 100644 --- a/api/src/services/subcount-service.ts +++ b/api/src/services/subcount-service.ts @@ -55,12 +55,13 @@ export class SubCountService extends DBService { * @memberof SubCountService */ async deleteObservationSubCountRecords(surveyId: number, surveyObservationIds: number[]): Promise { + const repo = new ObservationSubCountMeasurementRepository(this.connection); + // Delete child subcount_critter records, if any await this.subCountRepository.deleteSubCountCritterRecordsForObservationId(surveyId, surveyObservationIds); // Delete child observation measurements, if any - const repo = new ObservationSubCountMeasurementRepository(this.connection); - await repo.deleteObservationMeasurements(surveyObservationIds, surveyId); + await repo.deleteObservationMeasurements(surveyId, surveyObservationIds); // Delete observation_subcount records, if any return this.subCountRepository.deleteObservationSubCountRecords(surveyId, surveyObservationIds); diff --git a/api/src/utils/xlsx-utils/worksheet-utils.test.ts b/api/src/utils/xlsx-utils/worksheet-utils.test.ts index f191f39903..16e2d7ee11 100644 --- a/api/src/utils/xlsx-utils/worksheet-utils.test.ts +++ b/api/src/utils/xlsx-utils/worksheet-utils.test.ts @@ -2,9 +2,549 @@ import { expect } from 'chai'; import { describe } from 'mocha'; import sinon from 'sinon'; import xlsx from 'xlsx'; +import { + CBMeasurementUnit, + CBQualitativeMeasurementTypeDefinition, + CBQuantitativeMeasurementTypeDefinition, + CritterbaseService +} from '../../services/critterbase-service'; import * as worksheet_utils from './worksheet-utils'; describe('worksheet utils', () => { + describe('isMeasurementCBQualitativeTypeDefinition', () => { + it('returns a CBQualitativeMeasurementTypeDefinition', () => { + const item: CBQualitativeMeasurementTypeDefinition = { + itis_tsn: 1, + taxon_measurement_id: '', + measurement_name: 'Cool measurement', + measurement_desc: '', + options: [ + { + taxon_measurement_id: '', + qualitative_option_id: '', + option_label: '', + option_value: 0, + option_desc: '' + } + ] + }; + const result = worksheet_utils.isMeasurementCBQualitativeTypeDefinition(item); + + expect(result).to.be.true; + }); + it('returns a CBQuantitativeMeasurementTypeDefinition', () => { + const item: CBQuantitativeMeasurementTypeDefinition = { + itis_tsn: 111, + taxon_measurement_id: '', + measurement_name: '', + measurement_desc: '', + min_value: null, + max_value: 500, + unit: CBMeasurementUnit.Enum.centimeter + }; + const result = worksheet_utils.isMeasurementCBQualitativeTypeDefinition(item); + expect(result).to.be.false; + }); + }); + + describe('findMeasurementFromTsnMeasurements', () => { + it('finds no measurement and returns null', () => { + const tsnMap: worksheet_utils.TsnMeasurementMap = { + '123': { + qualitative: [ + { + itis_tsn: 123, + taxon_measurement_id: 'taxon_1', + measurement_name: 'Neck Girth', + measurement_desc: '', + options: [ + { + taxon_measurement_id: 'taxon_1_1', + qualitative_option_id: 'option_1', + option_label: 'Neck Girth', + option_value: 0, + option_desc: '' + } + ] + } + ], + quantitative: [ + { + itis_tsn: 123, + taxon_measurement_id: 'taxon_2', + measurement_name: 'legs', + measurement_desc: '', + min_value: null, + max_value: 4, + unit: CBMeasurementUnit.Enum.centimeter + } + ] + } + }; + const results = worksheet_utils.findMeasurementFromTsnMeasurements('tsn', '', tsnMap); + expect(results).to.be.null; + }); + it('has measurements but no qualitative or quantitative and returns null', () => { + const tsnMap: worksheet_utils.TsnMeasurementMap = { + '123': { + qualitative: [], + quantitative: [] + } + }; + const results = worksheet_utils.findMeasurementFromTsnMeasurements('123', '', tsnMap); + expect(results).to.be.null; + }); + + it('finds a qualitative measurement', () => { + const tsnMap: worksheet_utils.TsnMeasurementMap = { + '123': { + qualitative: [ + { + itis_tsn: 123, + taxon_measurement_id: 'taxon_1', + measurement_name: 'neck_girth', + measurement_desc: '', + options: [ + { + taxon_measurement_id: 'taxon_1_1', + qualitative_option_id: 'option_1', + option_label: 'Neck Girth', + option_value: 0, + option_desc: '' + } + ] + }, + { + itis_tsn: 223, + taxon_measurement_id: 'taxon_2', + measurement_name: 'neck_size', + measurement_desc: '', + options: [ + { + taxon_measurement_id: 'taxon_2_1', + qualitative_option_id: 'option_2', + option_label: 'Big', + option_value: 0, + option_desc: '' + } + ] + } + ], + quantitative: [ + { + itis_tsn: 123, + taxon_measurement_id: 'taxon_2', + measurement_name: 'legs', + measurement_desc: '', + min_value: null, + max_value: 4, + unit: CBMeasurementUnit.Enum.centimeter + } + ] + } + }; + const results = worksheet_utils.findMeasurementFromTsnMeasurements('123', 'neck_girth', tsnMap); + expect(results).to.eql({ + itis_tsn: 123, + taxon_measurement_id: 'taxon_1', + measurement_name: 'neck_girth', + measurement_desc: '', + options: [ + { + taxon_measurement_id: 'taxon_1_1', + qualitative_option_id: 'option_1', + option_label: 'Neck Girth', + option_value: 0, + option_desc: '' + } + ] + }); + }); + + it('finds a quantitative measurement', () => { + const tsnMap: worksheet_utils.TsnMeasurementMap = { + '123': { + qualitative: [ + { + itis_tsn: 123, + taxon_measurement_id: 'taxon_1', + measurement_name: 'neck_girth', + measurement_desc: '', + options: [ + { + taxon_measurement_id: 'taxon_1_1', + qualitative_option_id: 'option_1', + option_label: 'Neck Girth', + option_value: 0, + option_desc: '' + } + ] + }, + { + itis_tsn: 223, + taxon_measurement_id: 'taxon_2', + measurement_name: 'neck_size', + measurement_desc: '', + options: [ + { + taxon_measurement_id: 'taxon_2_1', + qualitative_option_id: 'option_2', + option_label: 'Big', + option_value: 0, + option_desc: '' + } + ] + } + ], + quantitative: [ + { + itis_tsn: 123, + taxon_measurement_id: 'taxon_2', + measurement_name: 'legs', + measurement_desc: '', + min_value: null, + max_value: 4, + unit: CBMeasurementUnit.Enum.centimeter + } + ] + } + }; + const results = worksheet_utils.findMeasurementFromTsnMeasurements('123', 'legs', tsnMap); + expect(results).to.eql({ + itis_tsn: 123, + taxon_measurement_id: 'taxon_2', + measurement_name: 'legs', + measurement_desc: '', + min_value: null, + max_value: 4, + unit: CBMeasurementUnit.Enum.centimeter + }); + }); + }); + + describe('getCBMeasurementsFromTSN', () => { + afterEach(() => { + sinon.restore(); + }); + it('fetch definitions per tsn', async () => { + const fetch = sinon + .stub(CritterbaseService.prototype, 'getTaxonMeasurements') + .resolves({ qualitative: [], quantitative: [] }); + + const service = new CritterbaseService({ keycloak_guid: '', username: '' }); + const results = await worksheet_utils.getCBMeasurementsFromTSN(['tsn', 'tsn1'], service); + expect(fetch).to.be.calledTwice; + expect(results).to.eql({ + tsn: { qualitative: [], quantitative: [] }, + tsn1: { qualitative: [], quantitative: [] } + }); + }); + + it('throws when no measurements are fetched', async () => { + const fetch = sinon + .stub(CritterbaseService.prototype, 'getTaxonMeasurements') + .resolves((null as unknown) as { qualitative: []; quantitative: [] }); + + const service = new CritterbaseService({ keycloak_guid: '', username: '' }); + + try { + await worksheet_utils.getCBMeasurementsFromTSN(['tsn', 'tsn1'], service); + expect(fetch).to.be.calledOnce; + expect.fail(); + } catch (error) { + expect((error as Error).message).contains('No measurements found for tsn: tsn'); + } + }); + + it('throws when critterbase is unavailable', async () => { + const fetch = sinon.stub(CritterbaseService.prototype, 'getTaxonMeasurements').rejects(); + + const service = new CritterbaseService({ keycloak_guid: '', username: '' }); + + try { + await worksheet_utils.getCBMeasurementsFromTSN(['tsn', 'tsn1'], service); + expect(fetch).to.be.calledOnce; + expect.fail(); + } catch (error) { + expect((error as Error).message).contains('Error connecting to the Critterbase API:'); + } + }); + }); + + describe('isQualitativeValueValid', () => { + it('qualitative measurement label value is valid', () => { + const measurement: CBQualitativeMeasurementTypeDefinition = { + itis_tsn: 1, + taxon_measurement_id: '', + measurement_name: 'Cool measurement', + measurement_desc: '', + options: [ + { + taxon_measurement_id: 'taxon_1', + qualitative_option_id: 'option_1', + option_label: 'Hind Leg', + option_value: 0, + option_desc: '' + }, + { + taxon_measurement_id: 'taxon_1', + qualitative_option_id: 'option_2', + option_label: 'Front Leg', + option_value: 1, + option_desc: '' + } + ] + }; + const results = worksheet_utils.isQualitativeValueValid('Hind Leg', measurement); + expect(results).to.be.true; + }); + it('qualitative measurement value is valid', () => { + const measurement: CBQualitativeMeasurementTypeDefinition = { + itis_tsn: 1, + taxon_measurement_id: '', + measurement_name: 'Cool measurement', + measurement_desc: '', + options: [ + { + taxon_measurement_id: 'taxon_1', + qualitative_option_id: 'option_1', + option_label: 'Hind Leg', + option_value: 0, + option_desc: '' + }, + { + taxon_measurement_id: 'taxon_1', + qualitative_option_id: 'option_2', + option_label: 'Front Leg', + option_value: 1, + option_desc: '' + } + ] + }; + const results = worksheet_utils.isQualitativeValueValid(0, measurement); + expect(results).to.be.true; + }); + it('qualitative measurement option id is valid', () => { + const measurement: CBQualitativeMeasurementTypeDefinition = { + itis_tsn: 1, + taxon_measurement_id: '', + measurement_name: 'Cool measurement', + measurement_desc: '', + options: [ + { + taxon_measurement_id: 'taxon_1', + qualitative_option_id: 'option_1', + option_label: 'Hind Leg', + option_value: 0, + option_desc: '' + }, + { + taxon_measurement_id: 'taxon_1', + qualitative_option_id: 'option_2', + option_label: 'Front Leg', + option_value: 1, + option_desc: '' + } + ] + }; + const results = worksheet_utils.isQualitativeValueValid('option_2', measurement); + expect(results).to.be.true; + }); + + it('qualitative measurement label value is invalid', () => { + const measurement: CBQualitativeMeasurementTypeDefinition = { + itis_tsn: 1, + taxon_measurement_id: '', + measurement_name: 'Cool measurement', + measurement_desc: '', + options: [ + { + taxon_measurement_id: 'taxon_1', + qualitative_option_id: 'option_1', + option_label: 'Hind Leg', + option_value: 0, + option_desc: '' + }, + { + taxon_measurement_id: 'taxon_1', + qualitative_option_id: 'option_2', + option_label: 'Front Leg', + option_value: 1, + option_desc: '' + } + ] + }; + const results = worksheet_utils.isQualitativeValueValid('Hide Leg', measurement); + expect(results).to.be.false; + }); + it('qualitative measurement value is invalid', () => { + const measurement: CBQualitativeMeasurementTypeDefinition = { + itis_tsn: 1, + taxon_measurement_id: '', + measurement_name: 'Cool measurement', + measurement_desc: '', + options: [ + { + taxon_measurement_id: 'taxon_1', + qualitative_option_id: 'option_1', + option_label: 'Hind Leg', + option_value: 0, + option_desc: '' + }, + { + taxon_measurement_id: 'taxon_1', + qualitative_option_id: 'option_2', + option_label: 'Front Leg', + option_value: 1, + option_desc: '' + } + ] + }; + const results = worksheet_utils.isQualitativeValueValid(2, measurement); + expect(results).to.be.false; + }); + it('qualitative measurement option id is invalid', () => { + const measurement: CBQualitativeMeasurementTypeDefinition = { + itis_tsn: 1, + taxon_measurement_id: '', + measurement_name: 'Cool measurement', + measurement_desc: '', + options: [ + { + taxon_measurement_id: 'taxon_1', + qualitative_option_id: 'option_1', + option_label: 'Hind Leg', + option_value: 0, + option_desc: '' + }, + { + taxon_measurement_id: 'taxon_1', + qualitative_option_id: 'option_2', + option_label: 'Front Leg', + option_value: 1, + option_desc: '' + } + ] + }; + const results = worksheet_utils.isQualitativeValueValid('option_32', measurement); + expect(results).to.be.false; + }); + }); + + describe('isQuantitativeValueValid', () => { + describe('min max range set', () => { + it('should be valid', () => { + const measurement: CBQuantitativeMeasurementTypeDefinition = { + itis_tsn: 123, + taxon_measurement_id: 'taxon_2', + measurement_name: 'legs', + measurement_desc: '', + min_value: 1, + max_value: 4, + unit: CBMeasurementUnit.Enum.centimeter + }; + + const results = worksheet_utils.isQuantitativeValueValid(2, measurement); + expect(results).to.be.true; + }); + + it('should be invalid', () => { + const measurement: CBQuantitativeMeasurementTypeDefinition = { + itis_tsn: 123, + taxon_measurement_id: 'taxon_2', + measurement_name: 'legs', + measurement_desc: '', + min_value: 1, + max_value: 4, + unit: CBMeasurementUnit.Enum.centimeter + }; + + const results = worksheet_utils.isQuantitativeValueValid(5, measurement); + expect(results).to.be.false; + }); + }); + + describe('min range set', () => { + it('should be valid', () => { + const measurement: CBQuantitativeMeasurementTypeDefinition = { + itis_tsn: 123, + taxon_measurement_id: 'taxon_2', + measurement_name: 'legs', + measurement_desc: '', + min_value: 1, + max_value: null, + unit: CBMeasurementUnit.Enum.centimeter + }; + + const results = worksheet_utils.isQuantitativeValueValid(100, measurement); + expect(results).to.be.true; + }); + + it('should be invalid', () => { + const measurement: CBQuantitativeMeasurementTypeDefinition = { + itis_tsn: 123, + taxon_measurement_id: 'taxon_2', + measurement_name: 'legs', + measurement_desc: '', + min_value: 2, + max_value: null, + unit: CBMeasurementUnit.Enum.centimeter + }; + + const results = worksheet_utils.isQuantitativeValueValid(1, measurement); + expect(results).to.be.false; + }); + }); + describe('max range set', () => { + it('should be valid', () => { + const measurement: CBQuantitativeMeasurementTypeDefinition = { + itis_tsn: 123, + taxon_measurement_id: 'taxon_2', + measurement_name: 'legs', + measurement_desc: '', + min_value: null, + max_value: 10, + unit: CBMeasurementUnit.Enum.centimeter + }; + + const results = worksheet_utils.isQuantitativeValueValid(10, measurement); + expect(results).to.be.true; + }); + + it('should be invalid', () => { + const measurement: CBQuantitativeMeasurementTypeDefinition = { + itis_tsn: 123, + taxon_measurement_id: 'taxon_2', + measurement_name: 'legs', + measurement_desc: '', + min_value: null, + max_value: 1000, + unit: CBMeasurementUnit.Enum.centimeter + }; + + const results = worksheet_utils.isQuantitativeValueValid(2000, measurement); + expect(results).to.be.false; + }); + }); + + describe('no range set', () => { + it('should be valid', () => { + const measurement: CBQuantitativeMeasurementTypeDefinition = { + itis_tsn: 123, + taxon_measurement_id: 'taxon_2', + measurement_name: 'legs', + measurement_desc: '', + min_value: null, + max_value: null, + unit: CBMeasurementUnit.Enum.centimeter + }; + + const results = worksheet_utils.isQuantitativeValueValid(10, measurement); + expect(results).to.be.true; + }); + }); + }); + describe('validateWorksheetHeaders', () => { afterEach(() => { sinon.restore(); @@ -55,4 +595,186 @@ describe('worksheet utils', () => { expect(result).to.equal(false); }); }); + + describe('validateMeasurements', () => { + it('no data to validate return true', () => { + const tsnMap: worksheet_utils.TsnMeasurementMap = { + '123': { + qualitative: [ + { + itis_tsn: 123, + taxon_measurement_id: 'taxon_1', + measurement_name: 'Neck Girth', + measurement_desc: '', + options: [ + { + taxon_measurement_id: 'taxon_1_1', + qualitative_option_id: 'option_1', + option_label: 'Neck Girth', + option_value: 0, + option_desc: '' + } + ] + } + ], + quantitative: [ + { + itis_tsn: 123, + taxon_measurement_id: 'taxon_2', + measurement_name: 'legs', + measurement_desc: '', + min_value: null, + max_value: 4, + unit: CBMeasurementUnit.Enum.centimeter + } + ] + } + }; + const data: worksheet_utils.IMeasurementDataToValidate[] = []; + const results = worksheet_utils.validateMeasurements(data, tsnMap); + expect(results).to.be.true; + }); + + it('no measurements returns false', () => { + const tsnMap: worksheet_utils.TsnMeasurementMap = { + '123': { + qualitative: [ + { + itis_tsn: 123, + taxon_measurement_id: 'taxon_1', + measurement_name: 'Neck Girth', + measurement_desc: '', + options: [ + { + taxon_measurement_id: 'taxon_1_1', + qualitative_option_id: 'option_1', + option_label: 'Neck Girth', + option_value: 0, + option_desc: '' + } + ] + } + ], + quantitative: [ + { + itis_tsn: 123, + taxon_measurement_id: 'taxon_2', + measurement_name: 'legs', + measurement_desc: '', + min_value: null, + max_value: 4, + unit: CBMeasurementUnit.Enum.centimeter + } + ] + } + }; + const data: worksheet_utils.IMeasurementDataToValidate[] = [ + { + tsn: '2', + measurement_key: 'taxon_1', + measurement_value: 'option_1' + } + ]; + const results = worksheet_utils.validateMeasurements(data, tsnMap); + expect(results).to.be.false; + }); + + it('data provided is valid', () => { + const tsnMap: worksheet_utils.TsnMeasurementMap = { + '123': { + qualitative: [ + { + itis_tsn: 123, + taxon_measurement_id: 'taxon_1', + measurement_name: 'Neck Girth', + measurement_desc: '', + options: [ + { + taxon_measurement_id: 'taxon_1_1', + qualitative_option_id: 'option_1', + option_label: 'Neck Girth', + option_value: 0, + option_desc: '' + } + ] + } + ], + quantitative: [ + { + itis_tsn: 123, + taxon_measurement_id: 'taxon_2', + measurement_name: 'legs', + measurement_desc: '', + min_value: null, + max_value: 4, + unit: CBMeasurementUnit.Enum.centimeter + } + ] + } + }; + const data: worksheet_utils.IMeasurementDataToValidate[] = [ + { + tsn: '123', + measurement_key: 'taxon_1', + measurement_value: 'option_1' + }, + { + tsn: '123', + measurement_key: 'taxon_2', + measurement_value: 3 + } + ]; + const results = worksheet_utils.validateMeasurements(data, tsnMap); + expect(results).to.be.true; + }); + + it('data provided, no measurements found, returns false', () => { + const tsnMap: worksheet_utils.TsnMeasurementMap = { + '123': { + qualitative: [ + { + itis_tsn: 123, + taxon_measurement_id: 'taxon_1', + measurement_name: 'Neck Girth', + measurement_desc: '', + options: [ + { + taxon_measurement_id: 'taxon_1_1', + qualitative_option_id: 'option_1', + option_label: 'Neck Girth', + option_value: 0, + option_desc: '' + } + ] + } + ], + quantitative: [ + { + itis_tsn: 123, + taxon_measurement_id: 'taxon_2', + measurement_name: 'legs', + measurement_desc: '', + min_value: null, + max_value: 4, + unit: CBMeasurementUnit.Enum.centimeter + } + ] + } + }; + const data: worksheet_utils.IMeasurementDataToValidate[] = [ + { + tsn: '123', + measurement_key: 'tax_1', + measurement_value: 'option_1' + }, + { + tsn: '123', + measurement_key: 'tax_2', + measurement_value: 3 + } + ]; + const results = worksheet_utils.validateMeasurements(data, tsnMap); + expect(results).to.be.false; + }); + }); }); diff --git a/api/src/utils/xlsx-utils/worksheet-utils.ts b/api/src/utils/xlsx-utils/worksheet-utils.ts index d2de0527dd..9c02371805 100644 --- a/api/src/utils/xlsx-utils/worksheet-utils.ts +++ b/api/src/utils/xlsx-utils/worksheet-utils.ts @@ -339,7 +339,6 @@ export function validateMeasurements( ): boolean { return data.every((item) => { const measurements = tsnMeasurementMap[item.tsn]; - if (!measurements) { defaultLog.debug({ label: 'validateMeasurements', message: 'Invalid: No measurements' }); return false; @@ -419,18 +418,18 @@ export function isQuantitativeValueValid(value: number, measurement: CBQuantitat if (min_value <= value && value <= max_value) { return true; } - } - - if (min_value !== null && min_value <= value) { - return true; - } + } else { + if (min_value !== null && min_value <= value) { + return true; + } - if (max_value !== null && value <= max_value) { - return true; - } + if (max_value !== null && value <= max_value) { + return true; + } - if (min_value === null && max_value === null) { - return true; + if (min_value === null && max_value === null) { + return true; + } } defaultLog.debug({ label: 'isQuantitativeValueValid', message: 'Invalid', value, measurement }); @@ -571,7 +570,7 @@ export function findMeasurementFromTsnMeasurements( ); if (qualitativeMeasurement) { - // Found qualitative measurement for tsn + // Found qualitative measurement by column/ measurement name return qualitativeMeasurement; } } @@ -582,7 +581,7 @@ export function findMeasurementFromTsnMeasurements( ); if (quantitativeMeasurement) { - // Found quantitative measurement for tsn + // Found quantitative measurement by column/ measurement name return quantitativeMeasurement; } }