diff --git a/api/src/openapi/schemas/critter.ts b/api/src/openapi/schemas/critter.ts index 77b4ce5cfd..5c851dfce9 100644 --- a/api/src/openapi/schemas/critter.ts +++ b/api/src/openapi/schemas/critter.ts @@ -1,6 +1,188 @@ import { OpenAPIV3 } from 'openapi-types'; -export const critterCreateRequestObject: OpenAPIV3.SchemaObject = { +const critterSchema: OpenAPIV3.SchemaObject = { + type: 'object', + properties: { + critter_id: { + type: 'string', + format: 'uuid' + }, + animal_id: { + type: 'string' + }, + wlh_id: { + type: 'string' + }, + taxon_id: { + type: 'string' + }, + sex: { + type: 'string' + } + } +}; + +const locationSchema: OpenAPIV3.SchemaObject = { + type: 'object', + properties: { + location_id: { + type: 'string', + format: 'uuid' + }, + latitude: { + type: 'number' + }, + longitude: { + type: 'number' + }, + coordinate_uncertainty: { + type: 'number' + }, + coordinate_uncertainty_unit: { + type: 'string' + } + } +}; + +const captureSchema: OpenAPIV3.SchemaObject = { + type: 'object', + properties: { + critter_id: { + type: 'string', + format: 'uuid' + }, + capture_id: { + type: 'string', + format: 'uuid' + }, + capture_location_id: { + type: 'string', + format: 'uuid' + }, + release_location_id: { + type: 'string', + format: 'uuid' + }, + capture_location: locationSchema, + release_location: locationSchema, + force_create_release: { + type: 'boolean' + }, + capture_timestamp: { + type: 'string' + }, + release_timestamp: { + type: 'string' + }, + capture_comment: { + type: 'string' + }, + release_comment: { + type: 'string' + } + } +}; + +const collectionUnits: OpenAPIV3.SchemaObject = { + type: 'object', + properties: { + critter_id: { + type: 'string', + format: 'uuid' + }, + critter_collection_unit: { + type: 'string', + format: 'uuid' + }, + collection_unit_id: { + type: 'string', + format: 'uuid' + } + } +}; + +const markingSchema: OpenAPIV3.SchemaObject = { + type: 'object', + properties: { + critter_id: { + type: 'string', + format: 'uuid' + }, + marking_id: { + type: 'string', + format: 'uuid' + }, + marking_type_id: { + type: 'string', + format: 'uuid' + }, + taxon_marking_body_location_id: { + type: 'string', + format: 'uuid' + }, + primary_colour_id: { + type: 'string', + format: 'uuid' + }, + secondary_colour_id: { + type: 'string', + format: 'uuid' + }, + marking_comment: { + type: 'string' + } + } +}; + +const mortalitySchema: OpenAPIV3.SchemaObject = { + type: 'object', + properties: { + mortality_id: { type: 'string', format: 'uuid' }, + critter_id: { type: 'string', format: 'uuid' }, + location_id: { type: 'string', format: 'uuid' }, + 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' }, + 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' }, + projection_mode: { type: 'string', enum: ['wgs', 'utm'] }, + location: locationSchema + } +}; + +const qualitativeMeasurementSchema: OpenAPIV3.SchemaObject = { + type: 'object', + properties: { + measurement_qualitative_id: { type: 'string', format: 'uuid' }, + taxon_measurement_id: { type: 'string' }, + qualitative_option_id: { + type: 'string' + }, + measured_timestamp: { type: 'string' }, + measurement_comment: { type: 'string' } + } +}; + +const quantitativeMeasurmentSchema: OpenAPIV3.SchemaObject = { + type: 'object', + properties: { + measurement_qualitative_id: { type: 'string', format: 'uuid' }, + taxon_measurement_id: { type: 'string' }, + qualitative_option_id: { + type: 'string', + format: 'uuid' + }, + value: { + type: 'number' + }, + measured_timestamp: { type: 'string' }, + measurement_comment: { type: 'string' } + } +}; + +export const critterBulkRequestObject: OpenAPIV3.SchemaObject = { title: 'Bulk post request object', type: 'object', properties: { @@ -9,7 +191,7 @@ export const critterCreateRequestObject: OpenAPIV3.SchemaObject = { type: 'array', items: { title: 'critter', - type: 'object' + ...critterSchema } }, captures: { @@ -17,7 +199,7 @@ export const critterCreateRequestObject: OpenAPIV3.SchemaObject = { type: 'array', items: { title: 'capture', - type: 'object' + ...captureSchema } }, collections: { @@ -25,7 +207,7 @@ export const critterCreateRequestObject: OpenAPIV3.SchemaObject = { type: 'array', items: { title: 'collection unit', - type: 'object' + ...collectionUnits } }, markings: { @@ -33,7 +215,7 @@ export const critterCreateRequestObject: OpenAPIV3.SchemaObject = { type: 'array', items: { title: 'marking', - type: 'object' + ...markingSchema } }, locations: { @@ -41,7 +223,7 @@ export const critterCreateRequestObject: OpenAPIV3.SchemaObject = { type: 'array', items: { title: 'location', - type: 'object' + ...locationSchema } }, mortalities: { @@ -49,7 +231,7 @@ export const critterCreateRequestObject: OpenAPIV3.SchemaObject = { type: 'array', items: { title: 'location', - type: 'object' + ...mortalitySchema } }, qualitative_measurements: { @@ -57,7 +239,7 @@ export const critterCreateRequestObject: OpenAPIV3.SchemaObject = { type: 'array', items: { title: 'qualitative measurement', - type: 'object' + ...qualitativeMeasurementSchema } }, quantitative_measurements: { @@ -65,8 +247,157 @@ export const critterCreateRequestObject: OpenAPIV3.SchemaObject = { type: 'array', items: { title: 'quantitative measurement', - type: 'object' + ...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' + } + } + } + }, + collections: { + title: 'collection units', + type: 'array', + items: { + title: 'collection unit', + type: 'object', + properties: { + ...collectionUnits.properties, + _delete: { + type: 'boolean' + } + } + } + }, + markings: { + title: 'markings', + type: 'array', + items: { + title: 'marking', + type: 'object', + properties: { + ...markingSchema.properties, + _delete: { + type: 'boolean' + } + } + } + }, + locations: { + title: 'locations', + type: 'array', + items: { + title: 'location', + type: 'object', + properties: { + ...locationSchema.properties, + _delete: { + type: 'boolean' + } + } + } + }, + mortalities: { + title: 'locations', + type: 'array', + items: { + title: 'location', + type: 'object', + properties: { + ...mortalitySchema.properties, + _delete: { + type: 'boolean' + } + } + } + }, + qualitative_measurements: { + title: 'qualitative measurements', + type: 'array', + items: { + title: 'qualitative measurement', + type: 'object', + properties: { + ...qualitativeMeasurementSchema.properties, + _delete: { + type: 'boolean' + } + } + } + }, + quantitative_measurements: { + title: 'quantitative measurements', + type: 'array', + items: { + title: 'quantitative measurement', + type: 'object', + properties: { + ...quantitativeMeasurmentSchema.properties, + _delete: { + type: 'boolean' + } + } + } + } + } +}; + +const bulkResponseCounts: OpenAPIV3.SchemaObject = { + title: 'Bulk operation counts', + type: 'object', + properties: { + critters: { type: 'integer' }, + captures: { type: 'integer' }, + markings: { type: 'integer' }, + locations: { type: 'integer' }, + moralities: { type: 'integer' }, + collections: { type: 'integer' }, + quantitative_measurements: { type: 'integer' }, + qualitative_measurements: { type: 'integer' }, + families: { type: 'integer' }, + family_parents: { type: 'integer' }, + family_children: { type: 'integer' } + } +}; + +export const bulkCreateResponse: OpenAPIV3.SchemaObject = { + title: 'Critterbase bulk creation response object', + type: 'object', + properties: { + created: bulkResponseCounts + } +}; + +export const bulkUpdateResponse: OpenAPIV3.SchemaObject = { + title: 'Critterbase bulk update response object', + type: 'object', + properties: { + created: bulkCreateResponse, + updated: bulkResponseCounts, + deleted: bulkResponseCounts + } +}; diff --git a/api/src/paths/critter-data/critters/index.ts b/api/src/paths/critter-data/critters/index.ts index 10244e10f8..e30e687459 100644 --- a/api/src/paths/critter-data/critters/index.ts +++ b/api/src/paths/critter-data/critters/index.ts @@ -1,7 +1,7 @@ import { RequestHandler } from 'express'; import { Operation } from 'express-openapi'; import { PROJECT_PERMISSION, SYSTEM_ROLE } from '../../../constants/roles'; -import { critterCreateRequestObject } from '../../../openapi/schemas/critter'; +import { critterBulkRequestObject } from '../../../openapi/schemas/critter'; import { authorizeRequestHandler } from '../../../request-handlers/security/authorization'; import { CritterbaseService, ICritterbaseUser } from '../../../services/critterbase-service'; import { getLogger } from '../../../utils/logger'; @@ -39,7 +39,7 @@ POST.apiDoc = { description: 'Critterbase bulk creation request object', content: { 'application/json': { - schema: critterCreateRequestObject + schema: critterBulkRequestObject } } }, 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 ebe30bd8e4..2f7660ee01 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 @@ -76,14 +76,11 @@ describe('addCritterToSurvey', () => { }); const mockDBConnection = getMockDBConnection({ release: sinon.stub() }); - const mockSurveyCritter = 123; const mockCBCritter = { critter_id: 'critterbase1' }; it('returns critters from survey', async () => { const mockGetDBConnection = sinon.stub(db, 'getDBConnection').returns(mockDBConnection); - const mockAddCritterToSurvey = sinon - .stub(SurveyCritterService.prototype, 'addCritterToSurvey') - .resolves(mockSurveyCritter); + const mockAddCritterToSurvey = sinon.stub(SurveyCritterService.prototype, 'addCritterToSurvey').resolves(); const mockCreateCritter = sinon.stub(CritterbaseService.prototype, 'createCritter').resolves(mockCBCritter); const { mockReq, mockRes, mockNext } = getRequestHandlerMocks(); 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 67a6621741..737aae467c 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 { critterCreateRequestObject } from '../../../../../../openapi/schemas/critter'; +import { bulkCreateResponse, critterBulkRequestObject } from '../../../../../../openapi/schemas/critter'; import { authorizeRequestHandler } from '../../../../../../request-handlers/security/authorization'; import { CritterbaseService, ICritterbaseUser } from '../../../../../../services/critterbase-service'; import { SurveyCritterService } from '../../../../../../services/survey-critter-service'; @@ -129,7 +129,7 @@ POST.apiDoc = { description: 'Critterbase bulk creation request object', content: { 'application/json': { - schema: critterCreateRequestObject + schema: critterBulkRequestObject } } }, @@ -138,10 +138,7 @@ POST.apiDoc = { description: 'Responds with counts of objects created in critterbase.', content: { 'application/json': { - schema: { - title: 'Bulk creation response object', - type: 'object' - } + schema: bulkCreateResponse } } }, diff --git a/api/src/paths/project/{projectId}/survey/{surveyId}/critters/{critterId}.test.ts b/api/src/paths/project/{projectId}/survey/{surveyId}/critters/{critterId}.test.ts index 3c1bc4fb3b..491afe1e3f 100644 --- a/api/src/paths/project/{projectId}/survey/{surveyId}/critters/{critterId}.test.ts +++ b/api/src/paths/project/{projectId}/survey/{surveyId}/critters/{critterId}.test.ts @@ -1,9 +1,24 @@ +import Ajv from 'ajv'; import { expect } from 'chai'; import sinon from 'sinon'; import * as db from '../../../../../../database/db'; +import { HTTPError } from '../../../../../../errors/http-error'; +import { CritterbaseService } from '../../../../../../services/critterbase-service'; import { SurveyCritterService } from '../../../../../../services/survey-critter-service'; import { getMockDBConnection, getRequestHandlerMocks } from '../../../../../../__mocks__/db'; -import { removeCritterFromSurvey } from './{critterId}'; +import { DELETE, PATCH, removeCritterFromSurvey, updateSurveyCritter } from './{critterId}'; + +describe('critterId openapi schema', () => { + const ajv = new Ajv(); + + it('PATCH is valid openapi v3 schema', () => { + expect(ajv.validateSchema((PATCH.apiDoc as unknown) as object)).to.be.true; + }); + + it('DELETE is valid openapi v3 schema', () => { + expect(ajv.validateSchema((DELETE.apiDoc as unknown) as object)).to.be.true; + }); +}); describe('removeCritterFromSurvey', () => { afterEach(() => { @@ -11,11 +26,10 @@ describe('removeCritterFromSurvey', () => { }); const mockDBConnection = getMockDBConnection({ release: sinon.stub() }); - const mockSurveyCritter = 123; it('removes critter from survey', async () => { sinon.stub(db, 'getDBConnection').returns(mockDBConnection); - sinon.stub(SurveyCritterService.prototype, 'removeCritterFromSurvey').resolves(mockSurveyCritter); + sinon.stub(SurveyCritterService.prototype, 'removeCritterFromSurvey').resolves(); const { mockReq, mockRes, mockNext } = getRequestHandlerMocks(); const requestHandler = removeCritterFromSurvey(); @@ -23,7 +37,6 @@ describe('removeCritterFromSurvey', () => { await requestHandler(mockReq, mockRes, mockNext); expect(mockRes.statusValue).to.equal(200); - expect(mockRes.jsonValue).to.equal(mockSurveyCritter); }); it('catches and re-throws errors', async () => { @@ -43,3 +56,81 @@ describe('removeCritterFromSurvey', () => { } }); }); + +describe('updateSurveyCritter', () => { + afterEach(() => { + sinon.restore(); + }); + + const mockDBConnection = getMockDBConnection({ release: sinon.stub() }); + const mockCBCritter = { critter_id: 'critterbase1' }; + + it('returns critters from survey', async () => { + const mockGetDBConnection = sinon.stub(db, 'getDBConnection').returns(mockDBConnection); + const mockSurveyUpdateCritter = sinon.stub(SurveyCritterService.prototype, 'updateCritter').resolves(); + const mockCritterbaseUpdateCritter = sinon + .stub(CritterbaseService.prototype, 'updateCritter') + .resolves(mockCBCritter); + const mockCritterbaseCreateCritter = sinon + .stub(CritterbaseService.prototype, 'createCritter') + .resolves(mockCBCritter); + const { mockReq, mockRes, mockNext } = getRequestHandlerMocks(); + mockReq.body = { + create: {}, + update: { critter_id: 'critterbase1' } + }; + const requestHandler = updateSurveyCritter(); + await requestHandler(mockReq, mockRes, mockNext); + + expect(mockGetDBConnection.calledOnce).to.be.true; + expect(mockSurveyUpdateCritter.calledOnce).to.be.true; + expect(mockCritterbaseUpdateCritter.calledOnce).to.be.true; + expect(mockCritterbaseCreateCritter.calledOnce).to.be.true; + expect(mockRes.status).to.have.been.calledWith(200); + expect(mockRes.json).to.have.been.calledWith(mockCBCritter); + }); + + it('catches and re-throws errors', async () => { + const mockError = new Error('a test error'); + const mockGetDBConnection = sinon.stub(db, 'getDBConnection').returns(mockDBConnection); + const mockSurveyUpdateCritter = sinon.stub(SurveyCritterService.prototype, 'updateCritter').rejects(mockError); + const { mockReq, mockRes, mockNext } = getRequestHandlerMocks(); + mockReq.body = { + create: {}, + update: { critter_id: 'critterbase1' } + }; + const requestHandler = updateSurveyCritter(); + + try { + await requestHandler(mockReq, mockRes, mockNext); + expect.fail(); + } catch (actualError) { + expect(actualError).to.equal(mockError); + expect(mockSurveyUpdateCritter.calledOnce).to.be.true; + expect(mockGetDBConnection.calledOnce).to.be.true; + expect(mockDBConnection.release).to.have.been.called; + } + }); + + it('catches and re-throws errors', async () => { + const errMsg = 'No external critter ID was found.'; + const mockGetDBConnection = sinon.stub(db, 'getDBConnection').returns(mockDBConnection); + const mockSurveyUpdateCritter = sinon.stub(SurveyCritterService.prototype, 'updateCritter').resolves(); + const { mockReq, mockRes, mockNext } = getRequestHandlerMocks(); + mockReq.body = { + update: {} + }; + const requestHandler = updateSurveyCritter(); + + try { + await requestHandler(mockReq, mockRes, mockNext); + expect.fail(); + } catch (actualError) { + expect((actualError as HTTPError).message).to.equal(errMsg); + expect((actualError as HTTPError).status).to.equal(400); + expect(mockSurveyUpdateCritter.calledOnce).to.be.false; + expect(mockGetDBConnection.calledOnce).to.be.true; + expect(mockDBConnection.release).to.have.been.called; + } + }); +}); diff --git a/api/src/paths/project/{projectId}/survey/{surveyId}/critters/{critterId}.ts b/api/src/paths/project/{projectId}/survey/{surveyId}/critters/{critterId}.ts index c2e8c0fdd4..5032d24c7d 100644 --- a/api/src/paths/project/{projectId}/survey/{surveyId}/critters/{critterId}.ts +++ b/api/src/paths/project/{projectId}/survey/{surveyId}/critters/{critterId}.ts @@ -2,7 +2,10 @@ import { RequestHandler } from 'express'; import { Operation } from 'express-openapi'; import { PROJECT_PERMISSION, SYSTEM_ROLE } from '../../../../../../constants/roles'; import { getDBConnection } from '../../../../../../database/db'; +import { HTTPError, HTTPErrorType } from '../../../../../../errors/http-error'; +import { bulkUpdateResponse, critterBulkRequestObject } from '../../../../../../openapi/schemas/critter'; import { authorizeRequestHandler } from '../../../../../../request-handlers/security/authorization'; +import { CritterbaseService, ICritterbaseUser } from '../../../../../../services/critterbase-service'; import { SurveyCritterService } from '../../../../../../services/survey-critter-service'; import { getLogger } from '../../../../../../utils/logger'; @@ -26,6 +29,25 @@ export const DELETE: Operation = [ removeCritterFromSurvey() ]; +export const PATCH: Operation = [ + authorizeRequestHandler((req) => { + return { + or: [ + { + validProjectPermissions: [PROJECT_PERMISSION.COORDINATOR, PROJECT_PERMISSION.COLLABORATOR], + projectId: Number(req.params.projectId), + discriminator: 'ProjectPermission' + }, + { + validSystemRoles: [SYSTEM_ROLE.DATA_ADMINISTRATOR], + discriminator: 'SystemRole' + } + ] + }; + }), + updateSurveyCritter() +]; + DELETE.apiDoc = { description: 'Removes association of this critter to this survey.', tags: ['critterbase'], @@ -54,13 +76,58 @@ DELETE.apiDoc = { ], responses: { 200: { - description: 'Responds with affected number of rows.', + description: 'Critter was removed from survey' + }, + 400: { + $ref: '#/components/responses/400' + }, + 401: { + $ref: '#/components/responses/401' + }, + 403: { + $ref: '#/components/responses/401' + }, + 500: { + $ref: '#/components/responses/500' + }, + default: { + $ref: '#/components/responses/default' + } + } +}; + +PATCH.apiDoc = { + description: 'Patches a critter in critterbase, also capable of deleting relevant rows if marked with _delete.', + tags: ['critterbase'], + security: [ + { + Bearer: [] + } + ], + parameters: [ + { + in: 'path', + name: 'surveyId', + schema: { + type: 'number' + }, + required: true + } + ], + requestBody: { + description: 'Critterbase bulk patch request object', + content: { + 'application/json': { + schema: critterBulkRequestObject + } + } + }, + responses: { + 200: { + description: 'Responds with counts of objects created in critterbase.', content: { 'application/json': { - schema: { - title: 'Affected rows', - type: 'number' - } + schema: bulkUpdateResponse } } }, @@ -101,3 +168,40 @@ export function removeCritterFromSurvey(): RequestHandler { } }; } + +export function updateSurveyCritter(): RequestHandler { + return async (req, res) => { + const user: ICritterbaseUser = { + keycloak_guid: req['system_user']?.user_guid, + username: req['system_user']?.user_identifier + }; + + const critterId = Number(req.params.critterId); + const connection = getDBConnection(req['keycloak_token']); + const surveyService = new SurveyCritterService(connection); + const cb = new CritterbaseService(user); + try { + await connection.open(); + const critterbaseCritterId = req.body.update.critter_id; + if (!critterbaseCritterId) { + throw new HTTPError(HTTPErrorType.BAD_REQUEST, 400, 'No external critter ID was found.'); + } + await surveyService.updateCritter(critterId, critterbaseCritterId); + let createResult, updateResult; + if (req.body.update) { + updateResult = await cb.updateCritter(req.body.update); + } + if (req.body.create) { + createResult = await cb.createCritter(req.body.create); + } + await connection.commit(); + return res.status(200).json({ ...createResult, ...updateResult }); + } catch (error) { + defaultLog.error({ label: 'updateCritter', message: 'error', error }); + await connection.rollback(); + throw error; + } finally { + connection.release(); + } + }; +} diff --git a/api/src/paths/project/{projectId}/survey/{surveyId}/critters/{critterId}/deployments.test.ts b/api/src/paths/project/{projectId}/survey/{surveyId}/critters/{critterId}/deployments.test.ts index d62dfd02b5..4560838ae0 100644 --- a/api/src/paths/project/{projectId}/survey/{surveyId}/critters/{critterId}/deployments.test.ts +++ b/api/src/paths/project/{projectId}/survey/{surveyId}/critters/{critterId}/deployments.test.ts @@ -13,7 +13,6 @@ describe('critter deployments', () => { }); const mockDBConnection = getMockDBConnection({ release: sinon.stub() }); - const mockSurveyEntry = 123; describe('openapi schema', () => { const ajv = new Ajv(); @@ -27,7 +26,7 @@ describe('critter deployments', () => { describe('upsertDeployment', () => { it('updates an existing deployment', async () => { const mockGetDBConnection = sinon.stub(db, 'getDBConnection').returns(mockDBConnection); - const mockAddDeployment = sinon.stub(SurveyCritterService.prototype, 'upsertDeployment').resolves(1); + const mockAddDeployment = sinon.stub(SurveyCritterService.prototype, 'upsertDeployment').resolves(); const mockBctwService = sinon.stub(BctwService.prototype, 'updateDeployment'); const { mockReq, mockRes, mockNext } = getRequestHandlerMocks(); @@ -39,7 +38,6 @@ describe('critter deployments', () => { expect(mockAddDeployment.calledOnce).to.be.true; expect(mockBctwService.calledOnce).to.be.true; expect(mockRes.status).to.have.been.calledWith(200); - expect(mockRes.json).to.have.been.calledWith(1); }); it('catches and re-throws errors', async () => { @@ -63,9 +61,7 @@ describe('critter deployments', () => { describe('deployDevice', () => { it('deploys a new telemetry device', async () => { const mockGetDBConnection = sinon.stub(db, 'getDBConnection').returns(mockDBConnection); - const mockAddDeployment = sinon - .stub(SurveyCritterService.prototype, 'upsertDeployment') - .resolves(mockSurveyEntry); + const mockAddDeployment = sinon.stub(SurveyCritterService.prototype, 'upsertDeployment').resolves(); const mockBctwService = sinon.stub(BctwService.prototype, 'deployDevice'); const { mockReq, mockRes, mockNext } = getRequestHandlerMocks(); @@ -77,7 +73,6 @@ describe('critter deployments', () => { expect(mockAddDeployment.calledOnce).to.be.true; expect(mockBctwService.calledOnce).to.be.true; expect(mockRes.status).to.have.been.calledWith(201); - expect(mockRes.json).to.have.been.calledWith(mockSurveyEntry); }); it('catches and re-throws errors', async () => { diff --git a/api/src/repositories/survey-critter-repository.test.ts b/api/src/repositories/survey-critter-repository.test.ts index cc84ba8cc5..6b3522b813 100644 --- a/api/src/repositories/survey-critter-repository.test.ts +++ b/api/src/repositories/survey-critter-repository.test.ts @@ -35,7 +35,7 @@ describe('SurveyRepository', () => { const response = await repository.addCritterToSurvey(1, 'critter_id'); - expect(response).to.eql(1); + expect(response).to.be.undefined; }); }); @@ -48,7 +48,7 @@ describe('SurveyRepository', () => { const response = await repository.removeCritterFromSurvey(1); - expect(response).to.eql(1); + expect(response).to.be.undefined; }); }); @@ -61,7 +61,17 @@ describe('SurveyRepository', () => { const response = await repository.upsertDeployment(1, 'deployment_id'); - expect(response).to.eql(1); + expect(response).to.be.undefined; + }); + }); + + describe('updateCritter', () => { + it('should update existing row', async () => { + const mockResponse = ({ rows: [], rowCount: 0 } as any) as Promise>; + const dbConnection = getMockDBConnection({ knex: () => mockResponse }); + const repository = new SurveyCritterRepository(dbConnection); + const response = await repository.updateCritter(1, 'asdf'); + expect(response).to.be.undefined; }); }); }); diff --git a/api/src/repositories/survey-critter-repository.ts b/api/src/repositories/survey-critter-repository.ts index 491342fe06..35be6633a3 100644 --- a/api/src/repositories/survey-critter-repository.ts +++ b/api/src/repositories/survey-critter-repository.ts @@ -36,11 +36,19 @@ export class SurveyCritterRepository extends BaseRepository { * @returns {*} * @member SurveyRepository */ - async addCritterToSurvey(surveyId: number, critterId: string): Promise { + async addCritterToSurvey(surveyId: number, critterId: string): Promise { defaultLog.debug({ label: 'addCritterToSurvey', surveyId }); const queryBuilder = getKnex().table('critter').insert({ survey_id: surveyId, critterbase_critter_id: critterId }); - const response = await this.connection.knex(queryBuilder); - return response.rowCount; + await this.connection.knex(queryBuilder); + } + + async updateCritter(critterId: number, critterbaseCritterId: string): Promise { + defaultLog.debug({ label: 'updateCritter', critterId }); + const queryBuilder = getKnex() + .table('critter') + .update({ critterbase_critter_id: critterbaseCritterId }) + .where({ critter_id: critterId }); + await this.connection.knex(queryBuilder); } /** @@ -51,22 +59,21 @@ export class SurveyCritterRepository extends BaseRepository { * @returns {*} * @member SurveyRepository */ - async removeCritterFromSurvey(critterId: number): Promise { + async removeCritterFromSurvey(critterId: number): Promise { defaultLog.debug({ label: 'removeCritterFromSurvey', critterId }); const queryBuilder = getKnex().table('critter').delete().where({ critter_id: critterId }); - const response = await this.connection.knex(queryBuilder); - return response.rowCount; + await this.connection.knex(queryBuilder); } /** * Will insert a new critter - deployment uuid association, or update if it already exists. - * + * This update operation intentionally changes nothing. Only really being done to trigger update audit columns. * @param {number} critterId * @param {string} deplyomentId * @returns {*} * @memberof SurveyCritterRepository */ - async upsertDeployment(critterId: number, deplyomentId: string): Promise { + async upsertDeployment(critterId: number, deplyomentId: string): Promise { defaultLog.debug({ label: 'addDeployment', deplyomentId }); const queryBuilder = getKnex() .table('deployment') @@ -74,8 +81,6 @@ export class SurveyCritterRepository extends BaseRepository { .onConflict(['critter_id', 'bctw_deployment_id']) .merge(['critter_id', 'bctw_deployment_id']); - const response = await this.connection.knex(queryBuilder); - - return response.rowCount; + await this.connection.knex(queryBuilder); } } diff --git a/api/src/services/critterbase-service.ts b/api/src/services/critterbase-service.ts index 417702338f..acf06efdf6 100644 --- a/api/src/services/critterbase-service.ts +++ b/api/src/services/critterbase-service.ts @@ -286,6 +286,11 @@ export class CritterbaseService { return response.data; } + async updateCritter(data: IBulkCreate) { + const response = await this.axiosInstance.patch(BULK_ENDPOINT, data); + return response.data; + } + async filterCritters(data: IFilterCritters, format: 'default' | 'detailed' = 'default') { const response = await this.axiosInstance.post(`${FILTER_ENDPOINT}?format=${format}`, data); return response.data; diff --git a/api/src/services/survey-critter-service.test.ts b/api/src/services/survey-critter-service.test.ts index 78f55bb05e..36600f5ada 100644 --- a/api/src/services/survey-critter-service.test.ts +++ b/api/src/services/survey-critter-service.test.ts @@ -39,12 +39,12 @@ describe('SurveyService', () => { const dbConnection = getMockDBConnection(); const service = new SurveyCritterService(dbConnection); - const repoStub = sinon.stub(SurveyCritterRepository.prototype, 'addCritterToSurvey').resolves(1); + const repoStub = sinon.stub(SurveyCritterRepository.prototype, 'addCritterToSurvey').resolves(); const response = await service.addCritterToSurvey(1, 'critter_id'); expect(repoStub).to.be.calledOnce; - expect(response).to.eql(1); + expect(response).to.be.undefined; }); }); @@ -53,12 +53,12 @@ describe('SurveyService', () => { const dbConnection = getMockDBConnection(); const service = new SurveyCritterService(dbConnection); - const repoStub = sinon.stub(SurveyCritterRepository.prototype, 'removeCritterFromSurvey').resolves(1); + const repoStub = sinon.stub(SurveyCritterRepository.prototype, 'removeCritterFromSurvey').resolves(); const response = await service.removeCritterFromSurvey(1); expect(repoStub).to.be.calledOnce; - expect(response).to.eql(1); + expect(response).to.be.undefined; }); }); @@ -67,12 +67,26 @@ describe('SurveyService', () => { const dbConnection = getMockDBConnection(); const service = new SurveyCritterService(dbConnection); - const repoStub = sinon.stub(SurveyCritterRepository.prototype, 'upsertDeployment').resolves(1); + const repoStub = sinon.stub(SurveyCritterRepository.prototype, 'upsertDeployment').resolves(); const response = await service.upsertDeployment(1, 'deployment_id'); expect(repoStub).to.be.calledOnce; - expect(response).to.eql(1); + expect(response).to.be.undefined; + }); + }); + + describe('updateCritter', () => { + it('updates critter, returns nothing', async () => { + const dbConnection = getMockDBConnection(); + const service = new SurveyCritterService(dbConnection); + + const repoStub = sinon.stub(SurveyCritterRepository.prototype, 'updateCritter').resolves(); + + const response = await service.updateCritter(1, 'asdf'); + + expect(repoStub).to.be.calledOnce; + expect(response).to.be.undefined; }); }); }); diff --git a/api/src/services/survey-critter-service.ts b/api/src/services/survey-critter-service.ts index eb367b09a9..5d9bbc590f 100644 --- a/api/src/services/survey-critter-service.ts +++ b/api/src/services/survey-critter-service.ts @@ -26,16 +26,27 @@ export class SurveyCritterService extends DBService { * @param {string} critterId * @returns {*} */ - async addCritterToSurvey(surveyId: number, critterBaseCritterId: string): Promise { + async addCritterToSurvey(surveyId: number, critterBaseCritterId: string): Promise { return this.critterRepository.addCritterToSurvey(surveyId, critterBaseCritterId); } + /** + * Update critter already in survey. Only touches audit columns. + * + * @param {number} surveyId + * @param {string} critterBaseCritterId + * @returns {*} + */ + async updateCritter(critterId: number, critterBaseCritterId: string): Promise { + return this.critterRepository.updateCritter(critterId, critterBaseCritterId); + } + /** * Removes a critter from the survey. Does not affect the critter in the external system. * @param {string} critterId * @returns {*} */ - async removeCritterFromSurvey(critterId: number): Promise { + async removeCritterFromSurvey(critterId: number): Promise { return this.critterRepository.removeCritterFromSurvey(critterId); } @@ -46,7 +57,7 @@ export class SurveyCritterService extends DBService { * @param {id} deplyomentId * @returns {*} */ - async upsertDeployment(critterId: number, deplyomentId: string): Promise { + async upsertDeployment(critterId: number, deplyomentId: string): Promise { return this.critterRepository.upsertDeployment(critterId, deplyomentId); } } diff --git a/app/src/components/fields/CbSelectField.tsx b/app/src/components/fields/CbSelectField.tsx index a36d8d627b..96d6368ac4 100644 --- a/app/src/components/fields/CbSelectField.tsx +++ b/app/src/components/fields/CbSelectField.tsx @@ -3,7 +3,6 @@ import { useFormikContext } from 'formik'; import { ICbSelectRows } from 'hooks/cb_api/useLookupApi'; import { useCritterbaseApi } from 'hooks/useCritterbaseApi'; import useDataLoader from 'hooks/useDataLoader'; -import useIsMounted from 'hooks/useIsMounted'; import get from 'lodash-es/get'; import React, { useEffect, useMemo } from 'react'; import { CbSelectWrapper } from './CbSelectFieldWrapper'; @@ -39,19 +38,14 @@ const CbSelectField: React.FC = (props) => { const { name, label, route, param, query, handleChangeSideEffect, controlProps, disabledValues } = props; const api = useCritterbaseApi(); - const isMounted = useIsMounted(); - const { data, load, refresh, hasLoaded } = useDataLoader(api.lookup.getSelectOptions); - const { values, handleChange, setFieldValue, setFieldTouched } = useFormikContext(); + const { data, refresh } = useDataLoader(api.lookup.getSelectOptions); + const { values, handleChange } = useFormikContext(); const val = get(values, name) ?? ''; - load({ route, param, query }); - useEffect(() => { - if (hasLoaded) { - // Only refresh when the query or param changes - isMounted() && refresh({ route, param, query }); - } + // Only refresh when the query or param changes + refresh({ route, param, query }); // eslint-disable-next-line react-hooks/exhaustive-deps }, [query, param]); @@ -63,13 +57,10 @@ const CbSelectField: React.FC = (props) => { return false; } const inRange = data.some((d) => (typeof d === 'string' ? d === val : d.id === val)); - // For convenience reset form fields here - if (!inRange) { - setFieldValue(name, ''); - setFieldTouched(name); - } + return inRange; - }, [data, val, setFieldValue, setFieldTouched, name]); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [data, val, name]); const innerChangeHandler = (e: SelectChangeEvent) => { handleChange(e); diff --git a/app/src/features/surveys/view/SurveyAnimals.tsx b/app/src/features/surveys/view/SurveyAnimals.tsx index 5338074c4a..28aa4ce1e6 100644 --- a/app/src/features/surveys/view/SurveyAnimals.tsx +++ b/app/src/features/surveys/view/SurveyAnimals.tsx @@ -3,6 +3,7 @@ import Icon from '@mdi/react'; import { Box, Divider, Typography } from '@mui/material'; import HelpButtonTooltip from 'components/buttons/HelpButtonTooltip'; import EditDialog from 'components/dialog/EditDialog'; +import YesNoDialog from 'components/dialog/YesNoDialog'; import { H2ButtonToolbar } from 'components/toolbar/ActionToolbars'; import { AttachmentType } from 'constants/attachments'; import { SurveyAnimalsI18N } from 'constants/i18n'; @@ -11,19 +12,24 @@ import { SurveyContext } from 'contexts/surveyContext'; import { useBiohubApi } from 'hooks/useBioHubApi'; import useDataLoader from 'hooks/useDataLoader'; import { useTelemetryApi } from 'hooks/useTelemetryApi'; +import { IDetailedCritterWithInternalId } from 'interfaces/useSurveyApi.interface'; import { isEqual as _deepEquals } from 'lodash-es'; import React, { useContext, useState } from 'react'; import { datesSameNullable, pluralize } from 'utils/Utils'; import yup from 'utils/YupSchema'; import NoSurveySectionData from '../components/NoSurveySectionData'; import { AnimalSchema, AnimalSex, Critter, IAnimal } from './survey-animals/animal'; +import { + createCritterUpdatePayload, + transformCritterbaseAPIResponseToForm +} from './survey-animals/animal-form-helpers'; import { AnimalTelemetryDeviceSchema, Device, IAnimalTelemetryDevice, IDeploymentTimespan } from './survey-animals/device'; -import IndividualAnimalForm from './survey-animals/IndividualAnimalForm'; +import IndividualAnimalForm, { ANIMAL_FORM_MODE } from './survey-animals/IndividualAnimalForm'; import { SurveyAnimalsTable } from './survey-animals/SurveyAnimalsTable'; import TelemetryDeviceForm, { IAnimalTelemetryDeviceFile, @@ -36,6 +42,7 @@ const SurveyAnimals: React.FC = () => { const dialogContext = useContext(DialogContext); const surveyContext = useContext(SurveyContext); + const [openRemoveCritterDialog, setOpenRemoveCritterDialog] = useState(false); const [openAddCritterDialog, setOpenAddCritterDialog] = useState(false); const [openDeviceDialog, setOpenDeviceDialog] = useState(false); const [animalCount, setAnimalCount] = useState(0); @@ -43,6 +50,7 @@ const SurveyAnimals: React.FC = () => { const [telemetryFormMode, setTelemetryFormMode] = useState( TELEMETRY_DEVICE_FORM_MODE.ADD ); + const [animalFormMode, setAnimalFormMode] = useState(ANIMAL_FORM_MODE.ADD); const { projectId, surveyId } = surveyContext; const { @@ -68,6 +76,7 @@ const SurveyAnimals: React.FC = () => { } const toggleDialog = () => { + setAnimalFormMode(ANIMAL_FORM_MODE.ADD); setOpenAddCritterDialog((d) => !d); }; @@ -83,7 +92,7 @@ const SurveyAnimals: React.FC = () => { }; const AnimalFormValues: IAnimal = { - general: { wlh_id: '', taxon_id: '', taxon_name: '', animal_id: '', sex: AnimalSex.UNKNOWN }, + general: { wlh_id: '', taxon_id: '', taxon_name: '', animal_id: '', sex: AnimalSex.UNKNOWN, critter_id: '' }, captures: [], markings: [], mortality: [], @@ -109,6 +118,22 @@ const SurveyAnimals: React.FC = () => { ] }; + const obtainAnimalFormInitialvalues = (mode: ANIMAL_FORM_MODE): IAnimal | null => { + switch (mode) { + case ANIMAL_FORM_MODE.ADD: + return AnimalFormValues; + case ANIMAL_FORM_MODE.EDIT: { + const existingCritter = critterData?.find( + (critter: IDetailedCritterWithInternalId) => currentCritterbaseCritterId === critter.critter_id + ); + if (!existingCritter) { + return null; + } + return transformCritterbaseAPIResponseToForm(existingCritter); + } + } + }; + const obtainDeviceFormInitialValues = (mode: TELEMETRY_DEVICE_FORM_MODE) => { switch (mode) { case TELEMETRY_DEVICE_FORM_MODE.ADD: @@ -138,25 +163,92 @@ const SurveyAnimals: React.FC = () => { } }; - const handleCritterSave = async (animal: IAnimal) => { - const critter = new Critter(animal); + const renderAnimalFormSafe = (): JSX.Element => { + const initialValues = obtainAnimalFormInitialvalues(animalFormMode); + if (!initialValues) { + return ( + + ); + } else { + return ( + + + Individuals + + + {`${ + animalCount + ? `${animalCount} ${pluralize(animalCount, 'Animal')} reported in this survey` + : `No individual animals were captured or reported in this survey` + }`} + + + } + open={openAddCritterDialog} + onSave={(values) => { + handleCritterSave(values); + }} + onCancel={toggleDialog} + component={{ + element: ( + + ), + initialValues: initialValues, + validationSchema: AnimalSchema + }} + /> + ); + } + }; + + const handleCritterSave = async (currentFormValues: IAnimal) => { const postCritterPayload = async () => { + const critter = new Critter(currentFormValues); + toggleDialog(); await bhApi.survey.createCritterAndAddToSurvey(projectId, surveyId, critter); refreshCritters(); - dialogContext.setSnackbar({ - open: true, - snackbarMessage: ( - - {'Animal added to Survey'} - - ) - }); + setPopup('Animal added to survey.'); + }; + const patchCritterPayload = async () => { + const initialFormValues = obtainAnimalFormInitialvalues(ANIMAL_FORM_MODE.EDIT); + if (!initialFormValues) { + throw Error('Could not obtain initial form values.'); + } + const { create: createCritter, update: updateCritter } = createCritterUpdatePayload( + initialFormValues, + currentFormValues + ); toggleDialog(); + if (!selectedCritterId) { + throw Error('The internal critter id for this row was not set correctly.'); + } + await bhApi.survey.updateSurveyCritter(projectId, surveyId, selectedCritterId, updateCritter, createCritter); + refreshCritters(); + setPopup('Animal data updated.'); }; try { - await postCritterPayload(); + if (animalFormMode === ANIMAL_FORM_MODE.ADD) { + await postCritterPayload(); + } else { + await patchCritterPayload(); + } } catch (err) { - console.log(`Critter submission error ${JSON.stringify(err)}`); + setPopup(`Submission failed. ${(err as Error).message}`); + toggleDialog(); } }; @@ -251,34 +343,22 @@ const SurveyAnimals: React.FC = () => { refreshDeployments(); }; + const handleRemoveCritter = async () => { + try { + if (!selectedCritterId) { + throw Error('Critter ID not set correctly.'); + } + await bhApi.survey.removeCritterFromSurvey(projectId, surveyId, selectedCritterId); + } catch (e) { + setPopup('Failed to remove critter from survey.'); + } + setOpenRemoveCritterDialog(false); + refreshCritters(); + }; + return ( - - - Individuals - - - {`${ - animalCount - ? `${animalCount} ${pluralize(animalCount, 'Animal')} reported in this survey` - : `No individual animals were captured or reported in this survey` - }`} - - - } - open={openAddCritterDialog} - onSave={(values) => { - handleCritterSave(values); - }} - onCancel={toggleDialog} - component={{ - element: , - initialValues: AnimalFormValues, - validationSchema: AnimalSchema - }} - /> + {renderAnimalFormSafe()} { } }} /> + setOpenRemoveCritterDialog(false)} + onNo={() => setOpenRemoveCritterDialog(false)} + onYes={handleRemoveCritter} + /> { animalData={critterData} deviceData={deploymentData} onMenuOpen={setSelectedCritterId} - onRemoveCritter={(critter_id) => { - bhApi.survey.removeCritterFromSurvey(projectId, surveyId, critter_id); - refreshCritters(); + onRemoveCritter={() => { + setOpenRemoveCritterDialog(true); }} - onAddDevice={(critter_id) => { + onAddDevice={() => { setTelemetryFormMode(TELEMETRY_DEVICE_FORM_MODE.ADD); setOpenDeviceDialog(true); }} - onEditDevice={(device_id) => { + onEditDevice={() => { setTelemetryFormMode(TELEMETRY_DEVICE_FORM_MODE.EDIT); setOpenDeviceDialog(true); }} + onEditCritter={() => { + setAnimalFormMode(ANIMAL_FORM_MODE.EDIT); + setOpenAddCritterDialog(true); + }} /> ) : ( diff --git a/app/src/features/surveys/view/survey-animals/IndividualAnimalForm.tsx b/app/src/features/surveys/view/survey-animals/IndividualAnimalForm.tsx index 061192142a..d7c9bf7eea 100644 --- a/app/src/features/surveys/view/survey-animals/IndividualAnimalForm.tsx +++ b/app/src/features/surveys/view/survey-animals/IndividualAnimalForm.tsx @@ -20,11 +20,18 @@ import MortalityAnimalForm from './form-sections/MortalityAnimalForm'; * **/ +export enum ANIMAL_FORM_MODE { + ADD = 'add', + EDIT = 'edit' +} + interface IndividualAnimalFormProps { getAnimalCount: (num: number) => void; + critter_id?: string; + mode: ANIMAL_FORM_MODE; } -const IndividualAnimalForm = ({ getAnimalCount }: IndividualAnimalFormProps) => { +const IndividualAnimalForm = ({ getAnimalCount, critter_id, mode }: IndividualAnimalFormProps) => { const { values } = useFormikContext(); useEffect(() => { @@ -36,7 +43,12 @@ const IndividualAnimalForm = ({ getAnimalCount }: IndividualAnimalFormProps) => return (
- Add New Individual + {mode === ANIMAL_FORM_MODE.ADD ? 'Add New Individual' : 'Edit Individual'} + {mode === ANIMAL_FORM_MODE.EDIT && ( + + Critter ID: {critter_id} + + )} diff --git a/app/src/features/surveys/view/survey-animals/SurveyAnimalsTable.tsx b/app/src/features/surveys/view/survey-animals/SurveyAnimalsTable.tsx index 4e58d83afe..bfa459545d 100644 --- a/app/src/features/surveys/view/survey-animals/SurveyAnimalsTable.tsx +++ b/app/src/features/surveys/view/survey-animals/SurveyAnimalsTable.tsx @@ -23,6 +23,7 @@ interface ISurveyAnimalsTableProps { onRemoveCritter: (critter_id: number) => void; onAddDevice: (critter_id: number) => void; onEditDevice: (device_id: number) => void; + onEditCritter: (critter_id: number) => void; } const noOpPlaceHolder = (critter_id: number) => { @@ -35,16 +36,19 @@ export const SurveyAnimalsTable = ({ onMenuOpen, onRemoveCritter, onAddDevice, - onEditDevice + onEditDevice, + onEditCritter }: ISurveyAnimalsTableProps): JSX.Element => { const animalDeviceData: ISurveyAnimalsTableEntry[] = deviceData - ? animalData.map((animal) => { - const deployments = deviceData.filter((device) => device.critter_id === animal.critter_id); - return { - ...animal, - deployments: deployments - }; - }) + ? animalData + .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 { + ...animal, + deployments: deployments + }; + }) : animalData; const columns: GridColDef[] = [ @@ -57,7 +61,7 @@ export const SurveyAnimalsTable = ({ field: 'wlh_id', headerName: 'WLH ID', flex: 1, - renderCell: (params) => {params.value ? params.value : 'None'} + renderCell: (params) => {params.value || 'None'} }, { field: 'taxon', @@ -112,7 +116,7 @@ export const SurveyAnimalsTable = ({ onMenuOpen={onMenuOpen} onAddDevice={onAddDevice} onRemoveDevice={noOpPlaceHolder} - onEditCritter={noOpPlaceHolder} + onEditCritter={onEditCritter} onEditDevice={onEditDevice} onRemoveCritter={onRemoveCritter} /> diff --git a/app/src/features/surveys/view/survey-animals/SurveyAnimalsTableActions.test.tsx b/app/src/features/surveys/view/survey-animals/SurveyAnimalsTableActions.test.tsx index 1aefea4e59..65cb92d8e7 100644 --- a/app/src/features/surveys/view/survey-animals/SurveyAnimalsTableActions.test.tsx +++ b/app/src/features/surveys/view/survey-animals/SurveyAnimalsTableActions.test.tsx @@ -4,6 +4,7 @@ import SurveyAnimalsTableActions from './SurveyAnimalsTableActions'; describe('SurveyAnimalsTableActions', () => { const onAddDevice = jest.fn(); const onRemoveCritter = jest.fn(); + const onEditCritter = jest.fn(); it('all buttons should be clickable', async () => { const { getByTestId } = render( @@ -12,7 +13,7 @@ describe('SurveyAnimalsTableActions', () => { devices={[]} onAddDevice={onAddDevice} onEditDevice={() => {}} - onEditCritter={() => {}} + onEditCritter={onEditCritter} onRemoveCritter={onRemoveCritter} onMenuOpen={() => {}} onRemoveDevice={() => {}} @@ -31,5 +32,8 @@ describe('SurveyAnimalsTableActions', () => { fireEvent.click(getByTestId('animal-table-row-remove-critter')); expect(onRemoveCritter.mock.calls.length).toBe(1); + + fireEvent.click(getByTestId('animal-table-row-edit-critter')); + expect(onEditCritter.mock.calls.length).toBe(1); }); }); diff --git a/app/src/features/surveys/view/survey-animals/SurveyAnimalsTableActions.tsx b/app/src/features/surveys/view/survey-animals/SurveyAnimalsTableActions.tsx index 027623a7dd..f1a62081b5 100644 --- a/app/src/features/surveys/view/survey-animals/SurveyAnimalsTableActions.tsx +++ b/app/src/features/surveys/view/survey-animals/SurveyAnimalsTableActions.tsx @@ -77,9 +77,7 @@ const SurveyAnimalsTableActions = (props: ITableActionsMenuProps) => { Edit Deployment Timespan ) : null} - { - //To be implemented in 217 - Edit Critters - /* { handleClose(); props.onEditCritter(props.critter_id); @@ -89,8 +87,7 @@ const SurveyAnimalsTableActions = (props: ITableActionsMenuProps) => { Edit Critter Details - */ - } + {!props.devices?.length && ( { 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 new file mode 100644 index 0000000000..df16a53408 --- /dev/null +++ b/app/src/features/surveys/view/survey-animals/animal-form-helpers.test.ts @@ -0,0 +1,332 @@ +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', + taxon_id: '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: '', + taxon: '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.taxon_id).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 = { + _id: '', + 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 = { + _id: '', + marking_id: undefined, + marking_type_id: '845f27ac-f0b2-4128-9615-18980e5c8caa', + taxon_marking_body_location_id: '46e6b939-3485-4c45-9f26-607489e50def', + primary_colour_id: 'eaf6b7a0-c47c-4dba-83b4-88e9331ee097', + secondary_colour_id: 'eaf6b7a0-c47c-4dba-83b4-88e9331ee097', + marking_comment: '' + }; + + const measure: IAnimalMeasurement = { + _id: '', + measurement_qualitative_id: 'eaf6b7a0-c47c-4dba-83b4-88e9331ee097', + measurement_quantitative_id: undefined, + taxon_measurement_id: 'eaf6b7a0-c47c-4dba-83b4-88e9331ee097', + qualitative_option_id: 'eaf6b7a0-c47c-4dba-83b4-88e9331ee097', + value: undefined, + measured_timestamp: new Date(), + measurement_comment: 'a' + }; + + const mortality: IAnimalMortality = { + _id: '', + 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 = { + _id: '', + collection_unit_id: 'eaf6b7a0-c47c-4dba-83b4-88e9331ee097', + collection_category_id: 'eaf6b7a0-c47c-4dba-83b4-88e9331ee097', + critter_collection_unit_id: 'eaf6b7a0-c47c-4dba-83b4-88e9331ee097' + }; + + const family: IAnimalRelationship = { + _id: '', + family_id: 'eaf6b7a0-c47c-4dba-83b4-88e9331ee097', + relationship: 'child' + }; + + const initialFormValues: IAnimal = { + general: { + wlh_id: 'wlh-a', + taxon_id: '', + animal_id: '', + taxon_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: undefined + }; + + const currentFormValues: IAnimal = { + general: { + taxon_id: '', + animal_id: '', + taxon_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: undefined + }; + + 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 new file mode 100644 index 0000000000..7c4bea6ace --- /dev/null +++ b/app/src/features/surveys/view/survey-animals/animal-form-helpers.ts @@ -0,0 +1,262 @@ +import { IDetailedCritterWithInternalId } from 'interfaces/useSurveyApi.interface'; +import { v4 } from 'uuid'; +import { AnimalSex, Critter, IAnimal } 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) => { + //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 ?? '', + taxon_id: existingCritter.taxon_id, + animal_id: existingCritter.animal_id ?? '', + sex: existingCritter.sex as AnimalSex, + taxon_name: existingCritter.taxon, + 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', + _id: v4(), + 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 ?? '', + _id: v4() + })), + mortality: existingCritter?.mortality.map((mor) => ({ + ...mor, + _id: v4(), + mortality_comment: mor.mortality_comment ?? '', + mortality_timestamp: new Date(mor.mortality_timestamp), + mortality_latitude: mor.location.latitude, + 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, + _id: v4() + })), + measurements: [ + ...existingCritter.measurement.qualitative.map((meas) => ({ + ...meas, + measurement_quantitative_id: undefined, + _id: v4(), + value: undefined, + measured_timestamp: meas.measured_timestamp ? new Date(meas.measured_timestamp) : ('' as unknown as Date), + measurement_comment: meas.measurement_comment ?? '' + })), + ...existingCritter.measurement.quantitative.map((meas) => ({ + ...meas, + _id: v4(), + measurement_qualitative_id: undefined, + qualitative_option_id: undefined, + measured_timestamp: meas.measured_timestamp ? new Date(meas.measured_timestamp) : ('' as unknown as Date), + measurement_comment: meas.measurement_comment ?? '' + })) + ], + family: [ + ...existingCritter.family_child.map((ch) => ({ + _id: v4(), + family_id: ch.family_id, + relationship: 'child' + })), + ...existingCritter.family_parent.map((par) => ({ + _id: v4(), + family_id: par.family_id, + relationship: 'parent' + })) + ], + images: [], + device: undefined + }; +}; + +/** + * 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 + ) + ) { + currFam.relationship === 'parent' + ? createCritter.families.parents.push({ + family_id: currFam.family_id, + parent_critter_id: initialCritter.critter_id + }) + : createCritter.families.children.push({ + family_id: currFam.family_id, + 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.test.ts b/app/src/features/surveys/view/survey-animals/animal.test.ts index a7eb1e2fd6..c3c7d19c74 100644 --- a/app/src/features/surveys/view/survey-animals/animal.test.ts +++ b/app/src/features/surveys/view/survey-animals/animal.test.ts @@ -12,11 +12,20 @@ import { } from './animal'; const animal: IAnimal = { - general: { taxon_id: 'a', taxon_name: 'taxon', animal_id: 'animal', wlh_id: 'a', sex: AnimalSex.MALE }, + general: { + taxon_id: 'a', + taxon_name: 'taxon', + animal_id: 'animal', + wlh_id: 'a', + sex: AnimalSex.MALE, + critter_id: v4() + }, captures: [ { _id: v4(), - + capture_id: v4(), + capture_location_id: undefined, + release_location_id: undefined, capture_latitude: 3, capture_longitude: 3, capture_utm_northing: 19429156.095, @@ -43,7 +52,8 @@ const animal: IAnimal = { 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_comment: 'asdf', + marking_id: v4() } ], mortality: [], @@ -51,7 +61,9 @@ const animal: IAnimal = { family: [], images: [], device: undefined, - collectionUnits: [{ collection_category_id: 'a', collection_unit_id: 'b', _id: v4() }] + collectionUnits: [ + { collection_category_id: 'a', collection_unit_id: 'b', _id: v4(), critter_collection_unit_id: v4() } + ] }; describe('Animal', () => { diff --git a/app/src/features/surveys/view/survey-animals/animal.ts b/app/src/features/surveys/view/survey-animals/animal.ts index 9128528663..0dad0188c5 100644 --- a/app/src/features/surveys/view/survey-animals/animal.ts +++ b/app/src/features/surveys/view/survey-animals/animal.ts @@ -1,5 +1,5 @@ import { DATE_LIMIT } from 'constants/dateTimeFormats'; -import { omit, omitBy } from 'lodash-es'; +import { isEqual as deepEquals, omit, omitBy } from 'lodash-es'; import moment from 'moment'; import yup from 'utils/YupSchema'; import { v4 } from 'uuid'; @@ -65,12 +65,15 @@ export const AnimalGeneralSchema = yup.object({}).shape({ animal_id: yup.string().required(req), taxon_name: yup.string(), wlh_id: yup.string(), - sex: yup.mixed().oneOf(Object.values(AnimalSex)) + sex: yup.mixed().oneOf(Object.values(AnimalSex)), + critter_id: yup.string() }); export const AnimalCaptureSchema = yup.object({}).shape({ _id: yup.string().required(), - + 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) }), @@ -97,13 +100,13 @@ export const AnimalCaptureSchema = yup.object({}).shape({ 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 /*.when('show_release', { is: true, then: dateSchema.required(req) }),*/, release_comment: yup.string().optional() }); export const AnimalMarkingSchema = yup.object({}).shape({ _id: yup.string().required(), - + marking_id: yup.string(), marking_type_id: yup.string().required(req), taxon_marking_body_location_id: yup.string().required(req), primary_colour_id: yup.string().optional(), @@ -114,13 +117,15 @@ export const AnimalMarkingSchema = yup.object({}).shape({ export const AnimalCollectionUnitSchema = yup.object({}).shape({ _id: yup.string().required(), collection_unit_id: yup.string().required(), - collection_category_id: yup.string().required() + collection_category_id: yup.string().required(), + critter_collection_unit_id: yup.string() }); export const AnimalMeasurementSchema = yup.object({}).shape( { _id: yup.string().required(), - + measurement_qualitative_id: yup.string(), + measurement_quantitative_id: yup.string(), taxon_measurement_id: yup.string().required(req), qualitative_option_id: yup.string().when('value', { is: (value: '' | number) => value === 0 || !value, @@ -140,7 +145,8 @@ export const AnimalMeasurementSchema = yup.object({}).shape( export const AnimalMortalitySchema = yup.object({}).shape({ _id: yup.string().required(), - + mortality_id: yup.string(), + location_id: yup.string(), mortality_longitude: lonSchema.when('projection_mode', { is: 'wgs', then: lonSchema.required(req) }), mortality_latitude: latSchema.when('projection_mode', { is: 'wgs', then: latSchema.required(req) }), mortality_utm_northing: numSchema.when('projection_mode', { is: 'utm', then: numSchema.required(req) }), @@ -148,12 +154,12 @@ export const AnimalMortalitySchema = yup.object({}).shape({ mortality_timestamp: dateSchema.required(req), mortality_coordinate_uncertainty: numSchema, mortality_comment: yup.string(), - mortality_pcod_reason: yup.string().uuid().required(req), - mortality_pcod_confidence: yup.string(), - mortality_pcod_taxon_id: yup.string().uuid(), - mortality_ucod_reason: yup.string().uuid(), - mortality_ucod_confidence: yup.string(), - mortality_ucod_taxon_id: yup.string().uuid(), + 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']) }); @@ -217,6 +223,8 @@ type ICritterMortality = Omit< ICritterID & IAnimalMortality & { location_id: string; + mortality_id: string | undefined; + location?: ICritterLocation; }, | '_id' | 'mortality_utm_easting' @@ -230,8 +238,12 @@ type ICritterMortality = Omit< 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' >; @@ -252,11 +264,13 @@ 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 = { @@ -303,35 +317,54 @@ export class Critter { const c_loc_id = v4(); let r_loc_id: string | undefined = undefined; - formattedLocations.push({ - location_id: c_loc_id, + 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(); - formattedLocations.push({ - location_id: r_loc_id, + 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: c_loc_id, - release_location_id: r_loc_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 + 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 }; @@ -344,26 +377,33 @@ export class Critter { const cleanedMortality = omitBy(mortality, (value) => value === '') as IAnimalMortality; const loc_id = v4(); - formattedLocations.push({ - location_id: loc_id, + const mortality_location = { latitude: Number(mortality.mortality_latitude), longitude: Number(mortality.mortality_latitude), coordinate_uncertainty: Number(mortality.mortality_latitude), coordinate_uncertainty_unit: 'm' - }); + }; formattedMortalities.push({ critter_id: this.critter_id, - location_id: loc_id, + location_id: cleanedMortality.location_id ?? loc_id, + mortality_id: cleanedMortality.mortality_id, mortality_timestamp: cleanedMortality.mortality_timestamp, mortality_comment: cleanedMortality.mortality_comment, - mortality_pcod_taxon_id: cleanedMortality.mortality_pcod_taxon_id, - mortality_pcod_reason: cleanedMortality.mortality_pcod_reason, - mortality_pcod_confidence: cleanedMortality.mortality_pcod_confidence, - mortality_ucod_reason: cleanedMortality.mortality_ucod_reason, - mortality_ucod_confidence: cleanedMortality.mortality_ucod_confidence, - mortality_ucod_taxon_id: cleanedMortality.mortality_ucod_taxon_id + 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 }; } @@ -396,6 +436,8 @@ export class Critter { 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, @@ -406,7 +448,6 @@ export class Critter { _formatCritterQuantitativeMeasurements(animal_measurements: IAnimalMeasurement[]): ICritterQuantitativeMeasurement[] { const filteredQuantitativeMeasurements = animal_measurements.filter((measurement) => { if (measurement.qualitative_option_id && measurement.value) { - console.log('Quantitative measurement must only contain a value and no qualitative_option_id'); return false; } return measurement.value; @@ -414,6 +455,8 @@ export class Critter { 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, @@ -423,31 +466,39 @@ export class Critter { } _formatCritterFamilyRelationships(animal_family: IAnimalRelationship[]): ICritterRelationships { - let newFamily = undefined; + 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); } - fam.family_id = newFamily.family_id; } } const parents = animal_family .filter((parent) => parent.relationship === 'parent') - .map((parent_fam) => ({ family_id: parent_fam.family_id, parent_critter_id: this.critter_id })); + .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, child_critter_id: this.critter_id })); + .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 = v4(); + this.critter_id = animal.general.critter_id ? animal.general.critter_id : v4(); this.taxon_id = animal.general.taxon_id; this.taxon_name = animal.general.taxon_name; this.animal_id = animal.general.animal_id; diff --git a/app/src/features/surveys/view/survey-animals/form-sections/CaptureAnimalForm.tsx b/app/src/features/surveys/view/survey-animals/form-sections/CaptureAnimalForm.tsx index ecf3b6f45d..e86fed8f6b 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 @@ -45,7 +45,10 @@ const CaptureAnimalForm = () => { release_utm_easting: '' as unknown as number, release_comment: '', release_timestamp: '' as unknown as Date, - release_coordinate_uncertainty: 10 + release_coordinate_uncertainty: 10, + capture_id: undefined, + capture_location_id: undefined, + release_location_id: undefined }; const canAddNewCapture = () => { @@ -121,6 +124,8 @@ const CaptureAnimalFormContent = ({ name, index, value }: CaptureAnimalFormConte (name, 'show_release', index)} /> } @@ -137,7 +142,6 @@ const CaptureAnimalFormContent = ({ name, index, value }: CaptureAnimalFormConte (name, 'release_timestamp', index)} - required={true} label={'Release Date'} other={{ size: 'small' }} /> 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 ce983a04c2..cda5edd589 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 @@ -32,7 +32,8 @@ const CollectionUnitAnimalForm = () => { const newCollectionUnit = (): IAnimalCollectionUnit => ({ _id: v4(), collection_unit_id: '', - collection_category_id: '' + collection_category_id: '', + critter_collection_unit_id: undefined }); //Animals may have multiple collection units, but only one instance of each category. 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 new file mode 100644 index 0000000000..d2b6dd49fd --- /dev/null +++ b/app/src/features/surveys/view/survey-animals/form-sections/FamilyAnimalForm.test.tsx @@ -0,0 +1,44 @@ +import { SurveyAnimalsI18N } from 'constants/i18n'; +import { Formik } from 'formik'; +import { useCritterbaseApi } from 'hooks/useCritterbaseApi'; +import { fireEvent, render, waitFor } from 'test-helpers/test-utils'; +import FamilyAnimalForm from './FamilyAnimalForm'; + +jest.mock('hooks/useCritterbaseApi'); + +const mockUseCritterbaseApi = useCritterbaseApi as jest.Mock; + +const mockUseCritterbase = { + lookup: { + getSelectOptions: jest.fn() + }, + family: { + getAllFamilies: jest.fn() + } +}; + +describe('FamilyAnimalForm', () => { + beforeEach(() => { + mockUseCritterbaseApi.mockImplementation(() => mockUseCritterbase); + mockUseCritterbase.lookup.getSelectOptions.mockClear(); + mockUseCritterbase.family.getAllFamilies.mockClear(); + }); + it('should display a new part of the form when add unit clicked', async () => { + mockUseCritterbase.lookup.getSelectOptions.mockResolvedValueOnce([{ id: 'a', value: 'a', label: 'family_1' }]); + mockUseCritterbase.family.getAllFamilies.mockResolvedValueOnce([{ family_id: 'a', family_label: 'family_1' }]); + const { getByText } = render( + {}}> + {() => } + + ); + + await waitFor(() => { + fireEvent.click(getByText(SurveyAnimalsI18N.animalFamilyAddBtn)); + expect(getByText(SurveyAnimalsI18N.animalFamilyTitle2)).toBeInTheDocument(); + expect(getByText('Family ID')).toBeInTheDocument(); + expect(getByText('Relationship')).toBeInTheDocument(); + }); + }); +}); diff --git a/app/src/features/surveys/view/survey-animals/form-sections/FamilyAnimalForm.tsx b/app/src/features/surveys/view/survey-animals/form-sections/FamilyAnimalForm.tsx index 897bf5e5c6..343eb54558 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 @@ -69,6 +69,13 @@ const FamilyAnimalForm = () => { relationship: undefined }; + const disabledFamilyIds = values.family.reduce((acc: Record, curr) => { + if (curr.family_id) { + acc[curr.family_id] = true; + } + return acc; + }, {}); + return ( {({ remove, push }: FieldArrayRenderProps) => ( @@ -80,7 +87,6 @@ const FamilyAnimalForm = () => { btnLabel={SurveyAnimalsI18N.animalFamilyAddBtn} disableAddBtn={!lastAnimalValueValid('family', values)} handleAddSection={() => push(newRelationship)} - maxSections={1} handleRemoveSection={remove}> {values.family.map((fam, index) => ( @@ -97,7 +103,7 @@ const FamilyAnimalForm = () => { ...(allFamilies ?? []), { family_id: newFamilyIdPlaceholder, family_label: newFamilyIdPlaceholder } ]?.map((a) => ( - + {a.family_label ? a.family_label : a.family_id} ))} 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 9042e375df..069ddf2ba2 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 @@ -46,7 +46,8 @@ const MarkingAnimalForm = () => { taxon_marking_body_location_id: '', primary_colour_id: '', secondary_colour_id: '', - marking_comment: '' + marking_comment: '', + marking_id: undefined }; return ( diff --git a/app/src/features/surveys/view/survey-animals/form-sections/MeasurementAnimalForm.tsx b/app/src/features/surveys/view/survey-animals/form-sections/MeasurementAnimalForm.tsx index ad8c8898b6..df4e61d6b6 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 @@ -52,7 +52,8 @@ const MeasurementAnimalForm = () => { const newMeasurement: IAnimalMeasurement = { _id: v4(), - + measurement_qualitative_id: undefined, + measurement_quantitative_id: undefined, taxon_measurement_id: '', value: '' as unknown as number, qualitative_option_id: '', 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 00a2009e3b..1eb7dfe673 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 @@ -44,13 +44,15 @@ const MortalityAnimalForm = () => { mortality_timestamp: '' as unknown as Date, mortality_coordinate_uncertainty: 10, mortality_comment: '', - mortality_pcod_reason: '', - mortality_pcod_confidence: '', - mortality_pcod_taxon_id: '', - mortality_ucod_reason: '', - mortality_ucod_confidence: '', - mortality_ucod_taxon_id: '', - projection_mode: 'wgs' as ProjectionMode + proximate_cause_of_death_id: '', + proximate_cause_of_death_confidence: '', + proximate_predated_by_taxon_id: '', + ultimate_cause_of_death_id: '', + ultimate_cause_of_death_confidence: '', + ultimate_predated_by_taxon_id: '', + projection_mode: 'wgs' as ProjectionMode, + mortality_id: undefined, + location_id: undefined }; return ( @@ -101,12 +103,12 @@ const MortalityAnimalFormContent = ({ name, index, value }: MortalityAnimalFormC (name, 'mortality_pcod_reason', index)} + name={getAnimalFieldName(name, 'proximate_cause_of_death_id', index)} handleChangeSideEffect={(_value, label) => setPcodTaxonDisabled(!label.includes('Predation'))} label={'PCOD Reason'} controlProps={{ size: 'small', - required: isRequiredInSchema(AnimalMortalitySchema, 'mortality_pcod_reason') + required: isRequiredInSchema(AnimalMortalitySchema, 'proximate_cause_of_death_id') }} id={`${index}-pcod-reason`} route={'lookups/cods'} @@ -114,11 +116,11 @@ const MortalityAnimalFormContent = ({ name, index, value }: MortalityAnimalFormC (name, 'mortality_pcod_confidence', index)} + name={getAnimalFieldName(name, 'proximate_cause_of_death_confidence', index)} label={'PCOD Confidence'} controlProps={{ size: 'small', - required: isRequiredInSchema(AnimalMortalitySchema, 'mortality_pcod_confidence') + required: isRequiredInSchema(AnimalMortalitySchema, 'proximate_cause_of_death_confidence') }} id={`${index}-pcod-confidence`} route={'lookups/cause-of-death-confidence'} @@ -126,12 +128,12 @@ const MortalityAnimalFormContent = ({ name, index, value }: MortalityAnimalFormC (name, 'mortality_pcod_taxon_id', index)} + name={getAnimalFieldName(name, 'proximate_predated_by_taxon_id', index)} label={'PCOD Taxon'} controlProps={{ size: 'small', disabled: pcodTaxonDisabled, - required: isRequiredInSchema(AnimalMortalitySchema, 'mortality_pcod_taxon_id') + required: isRequiredInSchema(AnimalMortalitySchema, 'proximate_predated_by_taxon_id') }} id={`${index}-pcod-taxon`} route={'lookups/taxons'} @@ -139,14 +141,14 @@ const MortalityAnimalFormContent = ({ name, index, value }: MortalityAnimalFormC (name, 'mortality_ucod_reason', index)} + name={getAnimalFieldName(name, 'ultimate_cause_of_death_id', index)} handleChangeSideEffect={(_value, label) => { setUcodTaxonDisabled(!label.includes('Predation')); }} label={'UCOD Reason'} controlProps={{ size: 'small', - required: isRequiredInSchema(AnimalMortalitySchema, 'mortality_ucod_reason') + required: isRequiredInSchema(AnimalMortalitySchema, 'ultimate_cause_of_death_id') }} id={`${index}-ucod-reason`} route={'lookups/cods'} @@ -154,11 +156,11 @@ const MortalityAnimalFormContent = ({ name, index, value }: MortalityAnimalFormC (name, 'mortality_ucod_confidence', index)} + name={getAnimalFieldName(name, 'ultimate_cause_of_death_confidence', index)} label={'UCOD Confidence'} controlProps={{ size: 'small', - required: isRequiredInSchema(AnimalMortalitySchema, 'mortality_ucod_confidence') + required: isRequiredInSchema(AnimalMortalitySchema, 'ultimate_cause_of_death_confidence') }} id={`${index}-ucod-confidence`} route={'lookups/cause-of-death-confidence'} @@ -166,12 +168,12 @@ const MortalityAnimalFormContent = ({ name, index, value }: MortalityAnimalFormC (name, 'mortality_ucod_taxon_id', index)} + name={getAnimalFieldName(name, 'ultimate_predated_by_taxon_id', index)} label={'UCOD Taxon'} controlProps={{ size: 'small', disabled: ucodTaxonDisabled, - required: isRequiredInSchema(AnimalMortalitySchema, 'mortality_ucod_taxon_id') + required: isRequiredInSchema(AnimalMortalitySchema, 'ultimate_predated_by_taxon_id') }} id={`${index}-ucod-taxon`} route={'lookups/taxons'} diff --git a/app/src/hooks/api/useSurveyApi.test.ts b/app/src/hooks/api/useSurveyApi.test.ts index a0c1a523ee..8f0a696746 100644 --- a/app/src/hooks/api/useSurveyApi.test.ts +++ b/app/src/hooks/api/useSurveyApi.test.ts @@ -24,7 +24,14 @@ describe('useSurveyApi', () => { describe('createCritterAndAddToSurvey', () => { it('creates a critter successfully', async () => { const animal: IAnimal = { - general: { animal_id: '1', taxon_id: v4(), taxon_name: '1', wlh_id: 'a', sex: AnimalSex.UNKNOWN }, + general: { + animal_id: '1', + taxon_id: v4(), + taxon_name: '1', + wlh_id: 'a', + sex: AnimalSex.UNKNOWN, + critter_id: v4() + }, captures: [], markings: [], measurements: [], diff --git a/app/src/hooks/api/useSurveyApi.ts b/app/src/hooks/api/useSurveyApi.ts index 5e5075a112..33a5c294a2 100644 --- a/app/src/hooks/api/useSurveyApi.ts +++ b/app/src/hooks/api/useSurveyApi.ts @@ -473,6 +473,25 @@ const useSurveyApi = (axios: AxiosInstance) => { }; }; + const critterToPayloadTransform = (critter: Critter, ignoreTopLevel = false) => { + return { + critters: ignoreTopLevel + ? [] + : [ + { + critter_id: critter.critter_id, + animal_id: critter.animal_id, + sex: critter.sex, + taxon_id: critter.taxon_id, + wlh_id: critter.wlh_id + } + ], + qualitative_measurements: critter.measurements.qualitative, + quantitative_measurements: critter.measurements.quantitative, + ...critter + }; + }; + /** * Create a critter and add it to the list of critters associated with this survey. This will create a new critter in Critterbase. * @@ -486,21 +505,23 @@ const useSurveyApi = (axios: AxiosInstance) => { surveyId: number, critter: Critter ): Promise => { + const payload = critterToPayloadTransform(critter); + const { data } = await axios.post(`/api/project/${projectId}/survey/${surveyId}/critters`, payload); + return data; + }; + + const updateSurveyCritter = async ( + projectId: number, + surveyId: number, + critterId: number, + updateSection: Critter, + createSection: Critter | undefined + ) => { const payload = { - critters: [ - { - critter_id: critter.critter_id, - animal_id: critter.animal_id, - sex: critter.sex, - taxon_id: critter.taxon_id, - wlh_id: critter.wlh_id - } - ], - qualitative_measurements: critter.measurements.qualitative, - quantitative_measurements: critter.measurements.quantitative, - ...critter + update: critterToPayloadTransform(updateSection), + create: createSection ? critterToPayloadTransform(createSection, true) : undefined }; - const { data } = await axios.post(`/api/project/${projectId}/survey/${surveyId}/critters`, payload); + const { data } = await axios.patch(`/api/project/${projectId}/survey/${surveyId}/critters/${critterId}`, payload); return data; }; @@ -572,7 +593,8 @@ const useSurveyApi = (axios: AxiosInstance) => { removeCritterFromSurvey, addDeployment, getDeploymentsInSurvey, - updateDeployment + updateDeployment, + updateSurveyCritter }; }; diff --git a/app/src/hooks/cb_api/useCritterApi.test.tsx b/app/src/hooks/cb_api/useCritterApi.test.tsx index 863a0bf33e..80f191c962 100644 --- a/app/src/hooks/cb_api/useCritterApi.test.tsx +++ b/app/src/hooks/cb_api/useCritterApi.test.tsx @@ -66,7 +66,8 @@ describe('useCritterApi', () => { animal_id: mockCritter.animal_id, taxon_name: 'Joe', wlh_id: 'a', - sex: AnimalSex.MALE + sex: AnimalSex.MALE, + critter_id: v4() }, mortality: [], family: [], diff --git a/app/src/hooks/useDataLoader.ts b/app/src/hooks/useDataLoader.ts index fd235e4263..d4b609dff4 100644 --- a/app/src/hooks/useDataLoader.ts +++ b/app/src/hooks/useDataLoader.ts @@ -91,13 +91,13 @@ export default function useDataLoader { - * if (isMounted()) { + * if (!isMounted()) { * return; * } * updateState(value) diff --git a/app/src/interfaces/useCritterApi.interface.ts b/app/src/interfaces/useCritterApi.interface.ts index 7b7030da32..bc1024de6b 100644 --- a/app/src/interfaces/useCritterApi.interface.ts +++ b/app/src/interfaces/useCritterApi.interface.ts @@ -37,13 +37,15 @@ type IMarkingResponse = { capture_id: string; mortality_id: string | null; taxon_marking_body_location_id: string; + primary_colour_id: string | null; + secondary_colour_id: string | null; marking_type_id: string; marking_material_id: string; identifier: string; frequency: string | null; frequency_unit: string | null; order: string | null; - comment: string; + comment: string | null; attached_timestamp: string; removed_timestamp: string | null; body_location: string; @@ -80,9 +82,9 @@ type IQuantitativeMeasurementResponse = { type IMortalityResponse = { mortality_id: string; - critter_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; @@ -92,6 +94,16 @@ type IMortalityResponse = { mortality_comment: string | null; }; +type IFamilyParentResponse = { + family_id: string; + parent_critter_id: string; +}; + +type IFamilyChildResponse = { + family_id: string; + child_critter_id: string; +}; + export type ICritterDetailedResponse = { critter_id: string; taxon_id: string; @@ -115,6 +127,8 @@ export type ICritterDetailedResponse = { qualitative: IQualitativeMeasurementResponse[]; quantitative: IQuantitativeMeasurementResponse[]; }; + family_parent: IFamilyParentResponse[]; + family_child: IFamilyChildResponse[]; }; export interface ICritterSimpleResponse {