From c6e2f4691a05ba5f691efaffa026af4b7f0edcd9 Mon Sep 17 00:00:00 2001 From: Macgregor Aubertin-Young <108430771+mauberti-bc@users.noreply.github.com> Date: Thu, 5 Sep 2024 18:52:10 -0700 Subject: [PATCH] Add Ecological Units to Survey Focal Species (#1343) * add ecological units to survey focal species * fix broken tests * address code smells * Fix react exhaustive deps warnings * ignore-skip * Updates/fixes/tweaks --------- Co-authored-by: Nick Phura --- api/src/models/survey-create.ts | 4 +- api/src/models/survey-update.ts | 4 +- api/src/models/survey-view.ts | 4 +- api/src/openapi/schemas/critter.ts | 64 ++++----- api/src/openapi/schemas/survey.ts | 29 +++- .../observations/taxon/index.test.ts | 6 +- .../{surveyId}/observations/taxon/index.ts | 2 +- .../repositories/survey-repository.test.ts | 39 +++++- api/src/repositories/survey-repository.ts | 100 +++++++++++++- api/src/services/critterbase-service.ts | 8 ++ .../critter/import-critters-strategy.test.ts | 4 +- .../critter/import-critters-strategy.ts | 2 +- api/src/services/observation-service.ts | 2 +- api/src/services/platform-service.ts | 7 +- api/src/services/standards-service.test.ts | 2 +- api/src/services/survey-service.test.ts | 72 ++++++++-- api/src/services/survey-service.ts | 64 +++++++-- .../species/FocalSpeciesComponent.tsx | 49 ------- .../species/components/SelectedSpecies.tsx | 43 ------ .../components/SpeciesAutocompleteField.tsx | 11 +- .../species/components/SpeciesCard.tsx | 36 ++--- .../components/SpeciesSelectedCard.tsx | 39 +++--- .../EcologicalUnitsOptionSelect.tsx | 27 ++-- .../EcologicalUnitsSelect.tsx | 110 +++++++++++++++ app/src/features/surveys/CreateSurveyPage.tsx | 2 +- .../ecological-units/EcologicalUnitsForm.tsx | 13 +- .../components/EcologicalUnitsSelect.tsx | 130 ------------------ .../AnimalGeneralInformationForm.tsx | 4 +- .../components/SelectedAnimalSpecies.tsx | 38 +++++ .../components/ScientificNameTypography.tsx | 2 +- .../GeneralInformationForm.tsx | 70 ++++------ .../permit}/SurveyPermitForm.test.tsx | 0 .../permit}/SurveyPermitForm.tsx | 17 +-- .../SurveySiteSelectionForm.tsx | 2 +- .../components/species/SpeciesForm.tsx | 53 ++++++- .../species/components/FocalSpeciesAlert.tsx | 25 ++++ .../FocalSpeciesEcologicalUnitsForm.tsx | 94 +++++++++++++ .../species/components/FocalSpeciesForm.tsx | 69 ++++++++++ .../features/surveys/edit/EditSurveyForm.tsx | 46 ++++--- .../ObservationsTableContainer.tsx | 28 +++- .../ObservationRowValidationUtils.ts | 2 +- .../components/animal/SurveySpatialAnimal.tsx | 2 +- .../observation/SurveySpatialObservation.tsx | 4 +- .../telemetry/SurveySpatialTelemetry.tsx | 2 +- app/src/hooks/api/useProjectApi.test.ts | 2 +- app/src/hooks/api/useTaxonomyApi.ts | 6 + app/src/hooks/cb_api/useXrefApi.tsx | 10 +- app/src/interfaces/useCritterApi.interface.ts | 5 + app/src/interfaces/useSurveyApi.interface.ts | 10 +- .../interfaces/useTaxonomyApi.interface.ts | 11 -- .../20240809140000_study_species_units.ts | 62 +++++++++ 51 files changed, 951 insertions(+), 486 deletions(-) delete mode 100644 app/src/components/species/FocalSpeciesComponent.tsx delete mode 100644 app/src/components/species/components/SelectedSpecies.tsx rename app/src/{features/surveys/animals/animal-form/components/ecological-units/components => components/species/ecological-units}/EcologicalUnitsOptionSelect.tsx (66%) create mode 100644 app/src/components/species/ecological-units/EcologicalUnitsSelect.tsx delete mode 100644 app/src/features/surveys/animals/animal-form/components/ecological-units/components/EcologicalUnitsSelect.tsx create mode 100644 app/src/features/surveys/animals/animal-form/components/general-information/components/SelectedAnimalSpecies.tsx rename app/src/features/surveys/{ => components/permit}/SurveyPermitForm.test.tsx (100%) rename app/src/features/surveys/{ => components/permit}/SurveyPermitForm.tsx (95%) create mode 100644 app/src/features/surveys/components/species/components/FocalSpeciesAlert.tsx create mode 100644 app/src/features/surveys/components/species/components/FocalSpeciesEcologicalUnitsForm.tsx create mode 100644 app/src/features/surveys/components/species/components/FocalSpeciesForm.tsx create mode 100644 database/src/migrations/20240809140000_study_species_units.ts diff --git a/api/src/models/survey-create.ts b/api/src/models/survey-create.ts index 014a787ef9..746fe7f5cf 100644 --- a/api/src/models/survey-create.ts +++ b/api/src/models/survey-create.ts @@ -1,6 +1,6 @@ import { SurveyStratum } from '../repositories/site-selection-strategy-repository'; import { PostSurveyBlock } from '../repositories/survey-block-repository'; -import { ITaxonomy } from '../services/platform-service'; +import { ITaxonomyWithEcologicalUnits } from '../services/platform-service'; import { PostSurveyLocationData } from './survey-update'; export class PostSurveyObject { @@ -89,7 +89,7 @@ export class PostSurveyDetailsData { } export class PostSpeciesData { - focal_species: ITaxonomy[]; + focal_species: ITaxonomyWithEcologicalUnits[]; constructor(obj?: any) { this.focal_species = (obj?.focal_species?.length && obj.focal_species) || []; diff --git a/api/src/models/survey-update.ts b/api/src/models/survey-update.ts index 7de3e4e4ec..5b87c118e7 100644 --- a/api/src/models/survey-update.ts +++ b/api/src/models/survey-update.ts @@ -1,7 +1,7 @@ import { Feature } from 'geojson'; import { SurveyStratum, SurveyStratumRecord } from '../repositories/site-selection-strategy-repository'; import { PostSurveyBlock } from '../repositories/survey-block-repository'; -import { ITaxonomy } from '../services/platform-service'; +import { ITaxonomyWithEcologicalUnits } from '../services/platform-service'; export class PutSurveyObject { survey_details: PutSurveyDetailsData; @@ -99,7 +99,7 @@ export class PutSurveyDetailsData { } export class PutSurveySpeciesData { - focal_species: ITaxonomy[]; + focal_species: ITaxonomyWithEcologicalUnits[]; constructor(obj?: any) { this.focal_species = (obj?.focal_species?.length && obj?.focal_species) || []; diff --git a/api/src/models/survey-view.ts b/api/src/models/survey-view.ts index 1b6036c462..04148517e0 100644 --- a/api/src/models/survey-view.ts +++ b/api/src/models/survey-view.ts @@ -7,7 +7,7 @@ import { SurveyBlockRecord } from '../repositories/survey-block-repository'; import { SurveyLocationRecord } from '../repositories/survey-location-repository'; import { SurveyUser } from '../repositories/survey-participation-repository'; import { SystemUser } from '../repositories/user-repository'; -import { ITaxonomy } from '../services/platform-service'; +import { ITaxonomyWithEcologicalUnits } from '../services/platform-service'; export interface ISurveyAdvancedFilters { keyword?: string; @@ -101,7 +101,7 @@ export class GetSurveyFundingSourceData { } export class GetFocalSpeciesData { - focal_species: ITaxonomy[]; + focal_species: ITaxonomyWithEcologicalUnits[]; constructor(obj?: any[]) { this.focal_species = []; diff --git a/api/src/openapi/schemas/critter.ts b/api/src/openapi/schemas/critter.ts index db1c2d8738..404ad27dfd 100644 --- a/api/src/openapi/schemas/critter.ts +++ b/api/src/openapi/schemas/critter.ts @@ -1,5 +1,34 @@ import { OpenAPIV3 } from 'openapi-types'; +export const collectionUnitsSchema: OpenAPIV3.SchemaObject = { + type: 'array', + items: { + type: 'object', + additionalProperties: false, + required: ['collection_category_id', 'collection_unit_id'], + properties: { + critter_collection_unit_id: { + type: 'string', + format: 'uuid' + }, + collection_category_id: { + type: 'string', + format: 'uuid' + }, + collection_unit_id: { + type: 'string', + format: 'uuid' + }, + unit_name: { + type: 'string' + }, + category_name: { + type: 'string' + } + } + } +}; + export const critterSchema: OpenAPIV3.SchemaObject = { type: 'object', additionalProperties: false, @@ -54,40 +83,7 @@ export const critterSchema: OpenAPIV3.SchemaObject = { } } }, - collection_units: { - type: 'array', - items: { - type: 'object', - additionalProperties: false, - required: [ - 'critter_collection_unit_id', - 'collection_category_id', - 'collection_unit_id', - 'unit_name', - 'category_name' - ], - properties: { - critter_collection_unit_id: { - type: 'string', - format: 'uuid' - }, - collection_category_id: { - type: 'string', - format: 'uuid' - }, - collection_unit_id: { - type: 'string', - format: 'uuid' - }, - unit_name: { - type: 'string' - }, - category_name: { - type: 'string' - } - } - } - } + collection_units: collectionUnitsSchema } }; diff --git a/api/src/openapi/schemas/survey.ts b/api/src/openapi/schemas/survey.ts index 3a064f8a02..6e9a35a298 100644 --- a/api/src/openapi/schemas/survey.ts +++ b/api/src/openapi/schemas/survey.ts @@ -55,6 +55,30 @@ export const surveyDetailsSchema: OpenAPIV3.SchemaObject = { } }; +/** + * Schema for creating, updating and retrieving ecological units for focal species in a SIMS survey. + * Prefixed with critterbase_* to match database field names in SIMS. + * + */ +export const SurveyEcologicalUnitsSchema: OpenAPIV3.SchemaObject = { + type: 'array', + items: { + type: 'object', + additionalProperties: false, + required: ['critterbase_collection_category_id', 'critterbase_collection_unit_id'], + properties: { + critterbase_collection_category_id: { + type: 'string', + format: 'uuid' + }, + critterbase_collection_unit_id: { + type: 'string', + format: 'uuid' + } + } + } +}; + export const surveyFundingSourceSchema: OpenAPIV3.SchemaObject = { title: 'survey funding source response object', type: 'object', @@ -112,7 +136,7 @@ export const focalSpeciesSchema: OpenAPIV3.SchemaObject = { title: 'focal species response object', type: 'object', additionalProperties: false, - required: ['tsn', 'commonNames', 'scientificName'], + required: ['tsn', 'commonNames', 'scientificName', 'ecological_units'], properties: { tsn: { description: 'Taxonomy tsn', @@ -137,7 +161,8 @@ export const focalSpeciesSchema: OpenAPIV3.SchemaObject = { kingdom: { description: 'Taxonomy kingdom name', type: 'string' - } + }, + ecological_units: SurveyEcologicalUnitsSchema } }; diff --git a/api/src/paths/project/{projectId}/survey/{surveyId}/observations/taxon/index.test.ts b/api/src/paths/project/{projectId}/survey/{surveyId}/observations/taxon/index.test.ts index 4177286777..4b2e0888af 100644 --- a/api/src/paths/project/{projectId}/survey/{surveyId}/observations/taxon/index.test.ts +++ b/api/src/paths/project/{projectId}/survey/{surveyId}/observations/taxon/index.test.ts @@ -26,9 +26,9 @@ describe('getSurveyObservedSpecies', () => { const mockTsns = [1, 2, 3]; const mockSpecies = mockTsns.map((tsn) => ({ itis_tsn: tsn })); const mockItisResponse = [ - { tsn: '1', commonNames: ['common name 1'], scientificName: 'scientific name 1' }, - { tsn: '2', commonNames: ['common name 2'], scientificName: 'scientific name 2' }, - { tsn: '3', commonNames: ['common name 3'], scientificName: 'scientific name 3' } + { tsn: 1, commonNames: ['common name 1'], scientificName: 'scientific name 1' }, + { tsn: 2, commonNames: ['common name 2'], scientificName: 'scientific name 2' }, + { tsn: 3, commonNames: ['common name 3'], scientificName: 'scientific name 3' } ]; const mockFormattedItisResponse = mockItisResponse.map((species) => ({ ...species, tsn: Number(species.tsn) })); diff --git a/api/src/paths/project/{projectId}/survey/{surveyId}/observations/taxon/index.ts b/api/src/paths/project/{projectId}/survey/{surveyId}/observations/taxon/index.ts index cfe8f152df..369be5171c 100644 --- a/api/src/paths/project/{projectId}/survey/{surveyId}/observations/taxon/index.ts +++ b/api/src/paths/project/{projectId}/survey/{surveyId}/observations/taxon/index.ts @@ -126,7 +126,7 @@ export function getSurveyObservedSpecies(): RequestHandler { const species = await platformService.getTaxonomyByTsns(observedSpecies.flatMap((species) => species.itis_tsn)); - const formattedResponse = species.map((taxon) => ({ ...taxon, tsn: Number(taxon.tsn) })); + const formattedResponse = species.map((taxon) => ({ ...taxon, tsn: taxon.tsn })); return res.status(200).json(formattedResponse); } catch (error) { diff --git a/api/src/repositories/survey-repository.test.ts b/api/src/repositories/survey-repository.test.ts index c1a7c357f7..cfcffdb4e2 100644 --- a/api/src/repositories/survey-repository.test.ts +++ b/api/src/repositories/survey-repository.test.ts @@ -9,7 +9,12 @@ import { PostProprietorData, PostSurveyObject } from '../models/survey-create'; import { PutSurveyObject } from '../models/survey-update'; import { GetAttachmentsData, GetSurveyProprietorData, GetSurveyPurposeAndMethodologyData } from '../models/survey-view'; import { getMockDBConnection } from '../__mocks__/db'; -import { SurveyRecord, SurveyRepository, SurveyTypeRecord } from './survey-repository'; +import { + SurveyRecord, + SurveyRepository, + SurveyTaxonomyWithEcologicalUnits, + SurveyTypeRecord +} from './survey-repository'; chai.use(sinonChai); @@ -140,26 +145,46 @@ describe('SurveyRepository', () => { describe('getSpeciesData', () => { it('should return result', async () => { - const mockResponse = { rows: [{ id: 1 }], rowCount: 1 } as any as Promise>; + const mockRows: SurveyTaxonomyWithEcologicalUnits[] = [ + { + itis_tsn: 123456, + ecological_units: [ + { + critterbase_collection_category_id: '123-456-789', + critterbase_collection_unit_id: '987-654-321' + } + ] + }, + { + itis_tsn: 654321, + ecological_units: [] + } + ]; + const mockResponse = { rows: mockRows, rowCount: 1 } as any as Promise>; const dbConnection = getMockDBConnection({ sql: () => mockResponse }); const repository = new SurveyRepository(dbConnection); - const response = await repository.getSpeciesData(1); + const surveyId = 1; - expect(response).to.eql([{ id: 1 }]); + const response = await repository.getSpeciesData(surveyId); + + expect(response).to.eql(mockRows); }); it('should return empty rows', async () => { - const mockResponse = { rows: [], rowCount: 1 } as any as Promise>; + const mockRows: SurveyTaxonomyWithEcologicalUnits[] = []; + const mockResponse = { rows: mockRows, rowCount: 1 } as any as Promise>; const dbConnection = getMockDBConnection({ sql: () => mockResponse }); const repository = new SurveyRepository(dbConnection); - const response = await repository.getSpeciesData(1); + const surveyId = 1; + + const response = await repository.getSpeciesData(surveyId); expect(response).to.not.be.null; - expect(response).to.eql([]); + expect(response).to.eql(mockRows); }); }); diff --git a/api/src/repositories/survey-repository.ts b/api/src/repositories/survey-repository.ts index 78eb33774f..83c2193590 100644 --- a/api/src/repositories/survey-repository.ts +++ b/api/src/repositories/survey-repository.ts @@ -13,6 +13,7 @@ import { GetSurveyPurposeAndMethodologyData, ISurveyAdvancedFilters } from '../models/survey-view'; +import { IPostCollectionUnit } from '../services/critterbase-service'; import { ApiPaginationOptions } from '../zod-schema/pagination'; import { BaseRepository } from './base-repository'; @@ -117,6 +118,18 @@ export const SurveyBasicFields = z.object({ export type SurveyBasicFields = z.infer; +export const SurveyTaxonomyWithEcologicalUnits = z.object({ + itis_tsn: z.number(), + ecological_units: z.array( + z.object({ + critterbase_collection_unit_id: z.string().uuid(), + critterbase_collection_category_id: z.string().uuid() + }) + ) +}); + +export type SurveyTaxonomyWithEcologicalUnits = z.infer; + export class SurveyRepository extends BaseRepository { /** * Deletes a survey and any associations for a given survey @@ -357,20 +370,41 @@ export class SurveyRepository extends BaseRepository { * Get species data for a given survey ID * * @param {number} surveyId - * @returns {*} {Promise} + * @return {*} {Promise} * @memberof SurveyRepository */ - async getSpeciesData(surveyId: number): Promise { + async getSpeciesData(surveyId: number): Promise { const sqlStatement = SQL` + WITH w_ecological_units AS ( SELECT - itis_tsn + ssu.study_species_id, + json_agg( + json_build_object( + 'critterbase_collection_category_id', ssu.critterbase_collection_category_id, + 'critterbase_collection_unit_id', ssu.critterbase_collection_unit_id + ) + ) AS units FROM - study_species + study_species_unit ssu + LEFT JOIN + study_species ss ON ss.study_species_id = ssu.study_species_id WHERE - survey_id = ${surveyId}; + ss.survey_id = ${surveyId} + GROUP BY + ssu.study_species_id + ) + SELECT + ss.itis_tsn, + COALESCE(weu.units, '[]'::json) AS ecological_units + FROM + study_species ss + LEFT JOIN + w_ecological_units weu ON weu.study_species_id = ss.study_species_id + WHERE + ss.survey_id = ${surveyId}; `; - const response = await this.connection.sql(sqlStatement); + const response = await this.connection.sql(sqlStatement, SurveyTaxonomyWithEcologicalUnits); return response.rows; } @@ -751,7 +785,41 @@ export class SurveyRepository extends BaseRepository { if (!result?.id) { throw new ApiExecuteSQLError('Failed to insert focal species data', [ - 'SurveyRepository->insertSurveyData', + 'SurveyRepository->insertFocalSpecies', + 'response was null or undefined, expected response != null' + ]); + } + + return result.id; + } + + /** + * Inserts focal ecological units for focal species + * + * @param {IPostCollectionUnit} ecologicalUnitObject + * @param {number} studySpeciesId + * @returns {*} Promise + * @memberof SurveyRepository + */ + async insertFocalSpeciesUnits(ecologicalUnitObject: IPostCollectionUnit, studySpeciesId: number): Promise { + const sqlStatement = SQL` + INSERT INTO study_species_unit ( + study_species_id, + critterbase_collection_category_id, + critterbase_collection_unit_id + ) VALUES ( + ${studySpeciesId}, + ${ecologicalUnitObject.critterbase_collection_category_id}, + ${ecologicalUnitObject.critterbase_collection_unit_id} + ) RETURNING study_species_id AS id; + `; + + const response = await this.connection.sql(sqlStatement); + const result = response.rows?.[0]; + + if (!result?.id) { + throw new ApiExecuteSQLError('Failed to insert focal species units data', [ + 'SurveyRepository->insertFocalSpeciesUnits', 'response was null or undefined, expected response != null' ]); } @@ -1001,6 +1069,24 @@ export class SurveyRepository extends BaseRepository { await this.connection.sql(sqlStatement); } + /** + * Deletes ecological units data for focal species in a given survey ID + * + * @param {number} surveyId + * @returns {*} Promise + * @memberof SurveyRepository + */ + async deleteSurveySpeciesUnitData(surveyId: number) { + const sqlStatement = SQL` + DELETE FROM study_species_unit ssu + USING study_species ss + WHERE ss.study_species_id = ssu.study_species_id + AND ss.survey_id = ${surveyId}; + `; + + await this.connection.sql(sqlStatement); + } + /** * Breaks permit survey link for a given survey ID * diff --git a/api/src/services/critterbase-service.ts b/api/src/services/critterbase-service.ts index f76067ad21..9ca6e1274b 100644 --- a/api/src/services/critterbase-service.ts +++ b/api/src/services/critterbase-service.ts @@ -249,6 +249,14 @@ export interface ICollectionCategory { itis_tsn: number; } +/** + * Prefixed with critterbase_* to match SIMS database field names + */ +export interface IPostCollectionUnit { + critterbase_collection_unit_id: string; + critterbase_collection_category_id: string; +} + // Lookup value `asSelect` format export interface IAsSelectLookup { id: string; diff --git a/api/src/services/import-services/critter/import-critters-strategy.test.ts b/api/src/services/import-services/critter/import-critters-strategy.test.ts index 0b48f8d2be..9872119aa4 100644 --- a/api/src/services/import-services/critter/import-critters-strategy.test.ts +++ b/api/src/services/import-services/critter/import-critters-strategy.test.ts @@ -98,8 +98,8 @@ describe('ImportCrittersStrategy', () => { const service = new ImportCrittersStrategy(mockConnection, 1); const getTaxonomyStub = sinon.stub(service.platformService, 'getTaxonomyByTsns').resolves([ - { tsn: '1', scientificName: 'a' }, - { tsn: '2', scientificName: 'b' } + { tsn: 1, scientificName: 'a' }, + { tsn: 2, scientificName: 'b' } ]); const tsns = await service._getValidTsns([ diff --git a/api/src/services/import-services/critter/import-critters-strategy.ts b/api/src/services/import-services/critter/import-critters-strategy.ts index f1695e87c0..dbe0cf7bf7 100644 --- a/api/src/services/import-services/critter/import-critters-strategy.ts +++ b/api/src/services/import-services/critter/import-critters-strategy.ts @@ -135,7 +135,7 @@ export class ImportCrittersStrategy extends DBService implements CSVImportStrate // Query the platform service (taxonomy) for matching tsns const taxonomy = await this.platformService.getTaxonomyByTsns(critterTsns); - return taxonomy.map((taxon) => taxon.tsn); + return taxonomy.map((taxon) => String(taxon.tsn)); } /** diff --git a/api/src/services/observation-service.ts b/api/src/services/observation-service.ts index 2d5d48f22d..3d44a837bd 100644 --- a/api/src/services/observation-service.ts +++ b/api/src/services/observation-service.ts @@ -793,7 +793,7 @@ export class ObservationService extends DBService { return recordsToPatch.map((recordToPatch: RecordWithTaxonFields) => { recordToPatch.itis_scientific_name = - taxonomyResponse.find((taxonItem) => Number(taxonItem.tsn) === recordToPatch.itis_tsn)?.scientificName ?? null; + taxonomyResponse.find((taxonItem) => taxonItem.tsn === recordToPatch.itis_tsn)?.scientificName ?? null; return recordToPatch; }); diff --git a/api/src/services/platform-service.ts b/api/src/services/platform-service.ts index 5243bb7fe6..506c2d74ec 100644 --- a/api/src/services/platform-service.ts +++ b/api/src/services/platform-service.ts @@ -12,6 +12,7 @@ import { isFeatureFlagPresent } from '../utils/feature-flag-utils'; import { getFileFromS3 } from '../utils/file-utils'; import { getLogger } from '../utils/logger'; import { AttachmentService } from './attachment-service'; +import { IPostCollectionUnit } from './critterbase-service'; import { DBService } from './db-service'; import { HistoryPublishService } from './history-publish-service'; import { KeycloakService } from './keycloak-service'; @@ -44,7 +45,7 @@ export interface IArtifact { } export interface IItisSearchResult { - tsn: string; + tsn: number; commonNames?: string[]; scientificName: string; } @@ -57,6 +58,10 @@ export interface ITaxonomy { kingdom: string; } +export interface ITaxonomyWithEcologicalUnits extends ITaxonomy { + ecological_units: IPostCollectionUnit[]; +} + const getBackboneInternalApiHost = () => process.env.BACKBONE_INTERNAL_API_HOST || ''; const getBackboneArtifactIntakePath = () => process.env.BACKBONE_ARTIFACT_INTAKE_PATH || ''; const getBackboneSurveyIntakePath = () => process.env.BACKBONE_INTAKE_PATH || ''; diff --git a/api/src/services/standards-service.test.ts b/api/src/services/standards-service.test.ts index 60a6d4dae1..c59038faf4 100644 --- a/api/src/services/standards-service.test.ts +++ b/api/src/services/standards-service.test.ts @@ -27,7 +27,7 @@ describe('StandardsService', () => { const getTaxonomyByTsnsStub = sinon .stub(standardsService.platformService, 'getTaxonomyByTsns') - .resolves([{ tsn: String(mockTsn), scientificName: 'caribou' }]); + .resolves([{ tsn: mockTsn, scientificName: 'caribou' }]); const getTaxonBodyLocationsStub = sinon .stub(standardsService.critterbaseService, 'getTaxonBodyLocations') diff --git a/api/src/services/survey-service.test.ts b/api/src/services/survey-service.test.ts index c35a50953a..b10e809880 100644 --- a/api/src/services/survey-service.test.ts +++ b/api/src/services/survey-service.test.ts @@ -21,10 +21,10 @@ import { FundingSourceRepository } from '../repositories/funding-source-reposito import { IPermitModel } from '../repositories/permit-repository'; import { SurveyLocationRecord, SurveyLocationRepository } from '../repositories/survey-location-repository'; import { - IGetSpeciesData, ISurveyProprietorModel, SurveyRecord, SurveyRepository, + SurveyTaxonomyWithEcologicalUnits, SurveyTypeRecord } from '../repositories/survey-repository'; import { getMockDBConnection } from '../__mocks__/db'; @@ -358,22 +358,44 @@ describe('SurveyService', () => { }); describe('getSpeciesData', () => { - it('returns the first row on success', async () => { + it('returns combined species and taxonomy data on success', async () => { const dbConnection = getMockDBConnection(); const service = new SurveyService(dbConnection); - const data = { id: 1 } as unknown as IGetSpeciesData; + const mockEcologicalUnits = [ + { critterbase_collection_category_id: 'abc', critterbase_collection_unit_id: 'xyz' } + ]; + const mockSpeciesData = [ + { itis_tsn: 123, ecological_units: [] }, + { + itis_tsn: 456, + ecological_units: mockEcologicalUnits + } + ] as unknown as SurveyTaxonomyWithEcologicalUnits[]; + const mockTaxonomyData = [ + { tsn: 123, scientificName: 'Species 1' }, + { tsn: 456, scientificName: 'Species 2' } + ]; + const mockResponse = new GetFocalSpeciesData([ + { tsn: 123, scientificName: 'Species 1', ecological_units: [] }, + { + tsn: 456, + scientificName: 'Species 2', + ecological_units: mockEcologicalUnits + } + ]); - const repoStub = sinon.stub(SurveyRepository.prototype, 'getSpeciesData').resolves([data]); - const getTaxonomyByTsnsStub = sinon.stub(PlatformService.prototype, 'getTaxonomyByTsns').resolves([]); + const repoStub = sinon.stub(SurveyRepository.prototype, 'getSpeciesData').resolves(mockSpeciesData); + const getTaxonomyByTsnsStub = sinon + .stub(PlatformService.prototype, 'getTaxonomyByTsns') + .resolves(mockTaxonomyData); const response = await service.getSpeciesData(1); + // Assertions expect(repoStub).to.be.calledOnce; - expect(getTaxonomyByTsnsStub).to.be.calledOnce; - expect(response).to.eql({ - ...new GetFocalSpeciesData([]) - }); + expect(getTaxonomyByTsnsStub).to.be.calledOnceWith([123, 456]); + expect(response.focal_species).to.eql(mockResponse.focal_species); }); }); @@ -575,6 +597,35 @@ describe('SurveyService', () => { }); }); + describe('insertFocalSpeciesWithUnits', () => { + it('returns the first row on success', async () => { + const dbConnection = getMockDBConnection(); + const service = new SurveyService(dbConnection); + + const mockFocalSpeciesId = 1; + const mockFocalSpeciesData = { + tsn: mockFocalSpeciesId, + scientificName: 'name', + commonNames: [], + rank: 'species', + kingdom: 'Animalia', + ecological_units: [{ critterbase_collection_category_id: 'abc', critterbase_collection_unit_id: 'xyz' }] + }; + const insertFocalSpeciesStub = sinon + .stub(SurveyRepository.prototype, 'insertFocalSpecies') + .resolves(mockFocalSpeciesId); + const insertFocalSpeciesUnitsStub = sinon + .stub(SurveyRepository.prototype, 'insertFocalSpeciesUnits') + .resolves(mockFocalSpeciesId); + + const response = await service.insertFocalSpeciesWithUnits(mockFocalSpeciesData, 1); + + expect(insertFocalSpeciesStub).to.be.calledOnce; + expect(insertFocalSpeciesUnitsStub).to.be.calledOnce; + expect(response).to.eql(mockFocalSpeciesId); + }); + }); + describe('insertSurveyProprietor', () => { it('returns the first row on success', async () => { const dbConnection = getMockDBConnection(); @@ -684,8 +735,9 @@ describe('SurveyService', () => { }); it('returns data if response is not null', async () => { + sinon.stub(SurveyService.prototype, 'deleteSurveySpeciesUnitData').resolves(); sinon.stub(SurveyService.prototype, 'deleteSurveySpeciesData').resolves(); - sinon.stub(SurveyService.prototype, 'insertFocalSpecies').resolves(1); + sinon.stub(SurveyService.prototype, 'insertFocalSpeciesWithUnits').resolves(1); const mockQueryResponse = { response: 'something', rowCount: 1 } as unknown as QueryResult; diff --git a/api/src/services/survey-service.ts b/api/src/services/survey-service.ts index 2bfb0647c2..c414dbf420 100644 --- a/api/src/services/survey-service.ts +++ b/api/src/services/survey-service.ts @@ -25,7 +25,7 @@ import { DBService } from './db-service'; import { FundingSourceService } from './funding-source-service'; import { HistoryPublishService } from './history-publish-service'; import { PermitService } from './permit-service'; -import { ITaxonomy, PlatformService } from './platform-service'; +import { ITaxonomyWithEcologicalUnits, PlatformService } from './platform-service'; import { RegionService } from './region-service'; import { SiteSelectionStrategyService } from './site-selection-strategy-service'; import { SurveyBlockService } from './survey-block-service'; @@ -168,14 +168,22 @@ export class SurveyService extends DBService { * @memberof SurveyService */ async getSpeciesData(surveyId: number): Promise { + // Fetch species data for the survey const studySpeciesResponse = await this.surveyRepository.getSpeciesData(surveyId); - const platformService = new PlatformService(this.connection); - - const focalSpecies = await platformService.getTaxonomyByTsns( + // Fetch taxonomy data for each survey species + const taxonomyResponse = await this.platformService.getTaxonomyByTsns( studySpeciesResponse.map((species) => species.itis_tsn) ); + const focalSpecies = []; + + for (const species of studySpeciesResponse) { + const taxon = taxonomyResponse.find((taxonomy) => Number(taxonomy.tsn) === species.itis_tsn) ?? {}; + focalSpecies.push({ ...taxon, tsn: species.itis_tsn, ecological_units: species.ecological_units }); + } + + // Return the combined data return new GetFocalSpeciesData(focalSpecies); } @@ -292,7 +300,7 @@ export class SurveyService extends DBService { const decoratedSurveys: SurveyBasicFields[] = []; for (const survey of surveys) { const matchingFocalSpeciesNames = focalSpecies - .filter((item) => survey.focal_species.includes(Number(item.tsn))) + .filter((item) => survey.focal_species.includes(item.tsn)) .map((item) => [item.commonNames, `(${item.scientificName})`].filter(Boolean).join(' ')); decoratedSurveys.push({ ...survey, focal_species_names: matchingFocalSpeciesNames }); @@ -372,9 +380,16 @@ export class SurveyService extends DBService { ); // Handle focal species associated to this survey + // If there are ecological units, insert them promises.push( Promise.all( - postSurveyData.species.focal_species.map((species: ITaxonomy) => this.insertFocalSpecies(species.tsn, surveyId)) + postSurveyData.species.focal_species.map((species: ITaxonomyWithEcologicalUnits) => { + if (species.ecological_units.length) { + this.insertFocalSpeciesWithUnits(species, surveyId); + } else { + this.insertFocalSpecies(species.tsn, surveyId); + } + }) ) ); @@ -547,6 +562,26 @@ export class SurveyService extends DBService { return this.surveyRepository.insertFocalSpecies(focal_species_id, surveyId); } + /** + * Inserts a new focal species record and associates it with ecological units for a survey. + * + * @param {ITaxonomyWithEcologicalUnits[]} taxonWithUnits - Array of species with ecological unit objects to associate. + * @param {number} surveyId - ID of the survey. + * @returns {Promise} - The ID of the newly created focal species. + * @memberof SurveyService + */ + async insertFocalSpeciesWithUnits(taxonWithUnits: ITaxonomyWithEcologicalUnits, surveyId: number): Promise { + // Insert the new focal species and get its ID + const studySpeciesId = await this.surveyRepository.insertFocalSpecies(taxonWithUnits.tsn, surveyId); + + // Insert ecological units associated with the newly created focal species + await Promise.all( + taxonWithUnits.ecological_units.map((unit) => this.surveyRepository.insertFocalSpeciesUnits(unit, studySpeciesId)) + ); + + return studySpeciesId; + } + /** * Inserts proprietor data for a survey * @@ -766,12 +801,14 @@ export class SurveyService extends DBService { * @memberof SurveyService */ async updateSurveySpeciesData(surveyId: number, surveyData: PutSurveyObject) { + // Delete any ecological units associated with the focal species record + await this.deleteSurveySpeciesUnitData(surveyId); await this.deleteSurveySpeciesData(surveyId); const promises: Promise[] = []; - surveyData.species.focal_species.forEach((focalSpecies: ITaxonomy) => - promises.push(this.insertFocalSpecies(focalSpecies.tsn, surveyId)) + surveyData.species.focal_species.forEach((focalSpecies: ITaxonomyWithEcologicalUnits) => + promises.push(this.insertFocalSpeciesWithUnits(focalSpecies, surveyId)) ); return Promise.all(promises); @@ -788,6 +825,17 @@ export class SurveyService extends DBService { return this.surveyRepository.deleteSurveySpeciesData(surveyId); } + /** + * Delete focal ecological units for a given survey ID + * + * @param {number} surveyId + * @returns {*} {Promise} + * @memberof SurveyService + */ + async deleteSurveySpeciesUnitData(surveyId: number) { + return this.surveyRepository.deleteSurveySpeciesUnitData(surveyId); + } + /** * Updates survey participants * diff --git a/app/src/components/species/FocalSpeciesComponent.tsx b/app/src/components/species/FocalSpeciesComponent.tsx deleted file mode 100644 index 657059e3cf..0000000000 --- a/app/src/components/species/FocalSpeciesComponent.tsx +++ /dev/null @@ -1,49 +0,0 @@ -import Stack from '@mui/material/Stack'; -import AlertBar from 'components/alert/AlertBar'; -import { useFormikContext } from 'formik'; -import { IPartialTaxonomy, ITaxonomy } from 'interfaces/useTaxonomyApi.interface'; -import get from 'lodash-es/get'; -import SelectedSpecies from './components/SelectedSpecies'; -import SpeciesAutocompleteField from './components/SpeciesAutocompleteField'; - -const FocalSpeciesComponent = () => { - const { values, setFieldValue, setFieldError, errors, submitCount } = useFormikContext(); - - const selectedSpecies: ITaxonomy[] = get(values, 'species.focal_species') || []; - - const handleAddSpecies = (species?: IPartialTaxonomy) => { - setFieldValue(`species.focal_species[${selectedSpecies.length}]`, species); - setFieldError(`species.focal_species`, undefined); - }; - - const handleRemoveSpecies = (species_id: number) => { - const filteredSpecies = selectedSpecies.filter((value: ITaxonomy) => { - return value.tsn !== species_id; - }); - - setFieldValue('species.focal_species', filteredSpecies); - }; - - return ( - - {submitCount > 0 && errors && get(errors, 'species.focal_species') && ( - - )} - - - - ); -}; - -export default FocalSpeciesComponent; diff --git a/app/src/components/species/components/SelectedSpecies.tsx b/app/src/components/species/components/SelectedSpecies.tsx deleted file mode 100644 index 3b72605553..0000000000 --- a/app/src/components/species/components/SelectedSpecies.tsx +++ /dev/null @@ -1,43 +0,0 @@ -import Box from '@mui/material/Box'; -import Collapse from '@mui/material/Collapse'; -import SpeciesSelectedCard from 'components/species/components/SpeciesSelectedCard'; -import { IPartialTaxonomy } from 'interfaces/useTaxonomyApi.interface'; -import { TransitionGroup } from 'react-transition-group'; - -export interface ISelectedSpeciesProps { - /** - * List of selected species to display. - * - * @type {IPartialTaxonomy[]} - * @memberof ISelectedSpeciesProps - */ - selectedSpecies: IPartialTaxonomy[]; - /** - * Callback to remove a species from the selected species list. - * If not provided, the remove button will not be displayed. - * - * @memberof ISelectedSpeciesProps - */ - handleRemoveSpecies?: (species_id: number) => void; -} - -const SelectedSpecies = (props: ISelectedSpeciesProps) => { - const { selectedSpecies, handleRemoveSpecies } = props; - - return ( - - - {selectedSpecies && - selectedSpecies.map((species: IPartialTaxonomy, index: number) => { - return ( - - - - ); - })} - - - ); -}; - -export default SelectedSpecies; diff --git a/app/src/components/species/components/SpeciesAutocompleteField.tsx b/app/src/components/species/components/SpeciesAutocompleteField.tsx index 0956567a28..e0d553bc8f 100644 --- a/app/src/components/species/components/SpeciesAutocompleteField.tsx +++ b/app/src/components/species/components/SpeciesAutocompleteField.tsx @@ -9,7 +9,7 @@ import SpeciesCard from 'components/species/components/SpeciesCard'; import { useBiohubApi } from 'hooks/useBioHubApi'; import useIsMounted from 'hooks/useIsMounted'; import { IPartialTaxonomy, ITaxonomy } from 'interfaces/useTaxonomyApi.interface'; -import { debounce, startCase } from 'lodash-es'; +import { debounce } from 'lodash-es'; import { useEffect, useMemo, useState } from 'react'; export interface ISpeciesAutocompleteFieldProps { @@ -33,7 +33,7 @@ export interface ISpeciesAutocompleteFieldProps { * @type {(species: ITaxonomy | IPartialTaxonomy) => void} * @memberof ISpeciesAutocompleteFieldProps */ - handleSpecies: (species?: ITaxonomy | IPartialTaxonomy) => void; + handleSpecies: (species: ITaxonomy | IPartialTaxonomy) => void; /** * Optional callback to fire on species option being cleared * @@ -258,7 +258,7 @@ const SpeciesAutocompleteField = (props: ISpeciesAutocompleteFieldProps) => { return; } - setInputValue(startCase(option?.commonNames?.length ? option.commonNames[0] : option.scientificName)); + setInputValue(option?.commonNames?.length ? option.commonNames[0] : option.scientificName); }} renderOption={(renderProps, renderOption) => { return ( @@ -283,11 +283,6 @@ const SpeciesAutocompleteField = (props: ISpeciesAutocompleteFieldProps) => { name={formikFieldName} required={required} label={label} - sx={{ - '& .MuiAutocomplete-input': { - fontStyle: inputValue.split(' ').length > 1 ? 'italic' : 'normal' - } - }} variant="outlined" fullWidth placeholder={placeholder || 'Enter a species or taxon'} diff --git a/app/src/components/species/components/SpeciesCard.tsx b/app/src/components/species/components/SpeciesCard.tsx index 903367591d..a4f11013aa 100644 --- a/app/src/components/species/components/SpeciesCard.tsx +++ b/app/src/components/species/components/SpeciesCard.tsx @@ -1,8 +1,10 @@ import Box from '@mui/material/Box'; +import Chip from '@mui/material/Chip'; import Stack from '@mui/material/Stack'; import Typography from '@mui/material/Typography'; import ColouredRectangleChip from 'components/chips/ColouredRectangleChip'; import { getTaxonRankColour, TaxonRankKeys } from 'constants/colours'; +import { ScientificNameTypography } from 'features/surveys/animals/components/ScientificNameTypography'; import { IPartialTaxonomy, ITaxonomy } from 'interfaces/useTaxonomyApi.interface'; interface ISpeciesCardProps { @@ -16,27 +18,10 @@ const SpeciesCard = (props: ISpeciesCardProps) => { const commonNames = taxon.commonNames.filter((item) => item !== null).join(`\u00A0\u00B7\u00A0`); return ( - + - - - {taxon.scientificName.split(' ')?.length > 1 ? ( - {taxon.scientificName} - ) : ( - <>{taxon.scientificName} - )} - + + {taxon?.rank && ( { {commonNames} - - {taxon.tsn} - + ); }; diff --git a/app/src/components/species/components/SpeciesSelectedCard.tsx b/app/src/components/species/components/SpeciesSelectedCard.tsx index 32e17112d4..ecb6909b79 100644 --- a/app/src/components/species/components/SpeciesSelectedCard.tsx +++ b/app/src/components/species/components/SpeciesSelectedCard.tsx @@ -1,9 +1,7 @@ import { mdiClose } from '@mdi/js'; import Icon from '@mdi/react'; import Box from '@mui/material/Box'; -import grey from '@mui/material/colors/grey'; import IconButton from '@mui/material/IconButton'; -import Paper from '@mui/material/Paper'; import { IPartialTaxonomy } from 'interfaces/useTaxonomyApi.interface'; import SpeciesCard from './SpeciesCard'; @@ -35,26 +33,25 @@ const SpeciesSelectedCard = (props: ISpeciesSelectedCardProps) => { const { index, species, handleRemove } = props; return ( - - - - - - {handleRemove && ( - - handleRemove(species.tsn)}> - - - - )} + + + - + + {handleRemove && ( + + handleRemove(species.tsn)}> + + + + )} + ); }; diff --git a/app/src/features/surveys/animals/animal-form/components/ecological-units/components/EcologicalUnitsOptionSelect.tsx b/app/src/components/species/ecological-units/EcologicalUnitsOptionSelect.tsx similarity index 66% rename from app/src/features/surveys/animals/animal-form/components/ecological-units/components/EcologicalUnitsOptionSelect.tsx rename to app/src/components/species/ecological-units/EcologicalUnitsOptionSelect.tsx index 162b3e07fa..45c3b6b546 100644 --- a/app/src/features/surveys/animals/animal-form/components/ecological-units/components/EcologicalUnitsOptionSelect.tsx +++ b/app/src/components/species/ecological-units/EcologicalUnitsOptionSelect.tsx @@ -1,8 +1,14 @@ import AutocompleteField, { IAutocompleteFieldOption } from 'components/fields/AutocompleteField'; import { useFormikContext } from 'formik'; -import { ICreateEditAnimalRequest } from 'interfaces/useCritterApi.interface'; interface IEcologicalUnitsOptionSelectProps { + /** + * Formik field name + * + * @type {string} + * @memberof IEcologicalUnitsOptionSelectProps + */ + name: string; /** * The label to display for the select field. * @@ -17,13 +23,6 @@ interface IEcologicalUnitsOptionSelectProps { * @memberof IEcologicalUnitsOptionSelectProps */ options: IAutocompleteFieldOption[]; - /** - * The index of the component in the list. - * - * @type {number} - * @memberof IEcologicalUnitsOptionSelectProps - */ - index: number; } /** @@ -33,22 +32,22 @@ interface IEcologicalUnitsOptionSelectProps { * @return {*} */ export const EcologicalUnitsOptionSelect = (props: IEcologicalUnitsOptionSelectProps) => { - const { label, options, index } = props; + const { label, options, name } = props; - const { values, setFieldValue } = useFormikContext(); + const { setFieldValue } = useFormikContext(); return ( { if (option?.value) { - setFieldValue(`ecological_units.[${index}].collection_unit_id`, option.value); + setFieldValue(name, option.value); } }} - disabled={Boolean(!values.ecological_units[index]?.collection_category_id)} + disabled={Boolean(!options.length)} required sx={{ flex: '1 1 auto' diff --git a/app/src/components/species/ecological-units/EcologicalUnitsSelect.tsx b/app/src/components/species/ecological-units/EcologicalUnitsSelect.tsx new file mode 100644 index 0000000000..d0ae926a47 --- /dev/null +++ b/app/src/components/species/ecological-units/EcologicalUnitsSelect.tsx @@ -0,0 +1,110 @@ +import { mdiClose } from '@mdi/js'; +import Icon from '@mdi/react'; +import Card from '@mui/material/Card'; +import grey from '@mui/material/colors/grey'; +import IconButton from '@mui/material/IconButton'; +import Stack from '@mui/material/Stack'; +import AutocompleteField from 'components/fields/AutocompleteField'; +import { FieldArrayRenderProps, useFormikContext } from 'formik'; +import { useCritterbaseApi } from 'hooks/useCritterbaseApi'; +import useDataLoader from 'hooks/useDataLoader'; +import { ICollectionCategory } from 'interfaces/useCritterApi.interface'; +import { useEffect, useMemo } from 'react'; +import { EcologicalUnitsOptionSelect } from './EcologicalUnitsOptionSelect'; + +interface EcologicalUnitsSelectProps { + categoryFieldName: string; + unitFieldName: string; + ecologicalUnits: ICollectionCategory[]; + selectedCategoryIds: string[]; + arrayHelpers: FieldArrayRenderProps; + index: number; +} + +/** + * Returns a pair of autocomplete fields for selecting an ecological unit category and value for the category. + * + * @param props {IEcologicalUnitsSelectProps} + * @returns + */ +export const EcologicalUnitsSelect = (props: EcologicalUnitsSelectProps) => { + const { index, ecologicalUnits, arrayHelpers, categoryFieldName, unitFieldName, selectedCategoryIds } = props; + const { setFieldValue } = useFormikContext(); + const critterbaseApi = useCritterbaseApi(); + + const ecologicalUnitOptionsLoader = useDataLoader((categoryId: string) => + critterbaseApi.xref.getCollectionUnits(categoryId) + ); + + const selectedCategoryId = selectedCategoryIds[index]; + + useEffect(() => { + if (selectedCategoryId) { + ecologicalUnitOptionsLoader.refresh(selectedCategoryId); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [selectedCategoryId]); + + // Memoized label for the selected ecological unit + const selectedCategoryLabel = useMemo(() => { + return ecologicalUnits.find((unit) => unit.collection_category_id === selectedCategoryId)?.category_name ?? ''; + }, [ecologicalUnits, selectedCategoryId]); + + // Filter out already selected categories + const availableCategories = useMemo(() => { + return ecologicalUnits + .filter( + (unit) => + !selectedCategoryIds.some( + (existingId) => existingId === unit.collection_category_id && existingId !== selectedCategoryId + ) + ) + .map((unit) => ({ + value: unit.collection_category_id, + label: unit.category_name + })); + }, [ecologicalUnits, selectedCategoryIds, selectedCategoryId]); + + const ecologicalUnitOptions = useMemo( + () => + ecologicalUnitOptionsLoader.data?.map((unit) => ({ + value: unit.collection_unit_id, + label: unit.unit_name + })) ?? [], + [ecologicalUnitOptionsLoader.data] + ); + + return ( + + { + if (option?.value) { + setFieldValue(categoryFieldName, option.value); + } + }} + required + sx={{ flex: '1 1 auto' }} + /> + + arrayHelpers.remove(index)} + sx={{ mt: 1.125 }}> + + + + ); +}; diff --git a/app/src/features/surveys/CreateSurveyPage.tsx b/app/src/features/surveys/CreateSurveyPage.tsx index 14c24f3773..54436b3f8d 100644 --- a/app/src/features/surveys/CreateSurveyPage.tsx +++ b/app/src/features/surveys/CreateSurveyPage.tsx @@ -13,7 +13,7 @@ import { CreateSurveyI18N } from 'constants/i18n'; import { CodesContext } from 'contexts/codesContext'; import { DialogContext } from 'contexts/dialogContext'; import { ProjectContext } from 'contexts/projectContext'; -import { ISurveyPermitForm, SurveyPermitFormInitialValues } from 'features/surveys/SurveyPermitForm'; +import { ISurveyPermitForm, SurveyPermitFormInitialValues } from 'features/surveys/components/permit/SurveyPermitForm'; import { SurveyPartnershipsFormInitialValues } from 'features/surveys/view/components/SurveyPartnershipsForm'; import { FormikProps } from 'formik'; import { APIError } from 'hooks/api/useAxios'; diff --git a/app/src/features/surveys/animals/animal-form/components/ecological-units/EcologicalUnitsForm.tsx b/app/src/features/surveys/animals/animal-form/components/ecological-units/EcologicalUnitsForm.tsx index 0ef6241a29..893a713f09 100644 --- a/app/src/features/surveys/animals/animal-form/components/ecological-units/EcologicalUnitsForm.tsx +++ b/app/src/features/surveys/animals/animal-form/components/ecological-units/EcologicalUnitsForm.tsx @@ -3,7 +3,7 @@ import { Icon } from '@mdi/react'; import Box from '@mui/material/Box'; import Button from '@mui/material/Button'; import Collapse from '@mui/material/Collapse'; -import { EcologicalUnitsSelect } from 'features/surveys/animals/animal-form/components/ecological-units/components/EcologicalUnitsSelect'; +import { EcologicalUnitsSelect } from 'components/species/ecological-units/EcologicalUnitsSelect'; import { FieldArray, FieldArrayRenderProps, useFormikContext } from 'formik'; import { useCritterbaseApi } from 'hooks/useCritterbaseApi'; import useDataLoader from 'hooks/useDataLoader'; @@ -11,9 +11,9 @@ import { ICreateEditAnimalRequest } from 'interfaces/useCritterApi.interface'; import { useEffect } from 'react'; import { TransitionGroup } from 'react-transition-group'; -const initialEcologicalUnitValues = { - collection_category_id: null, - collection_unit_id: null +export const initialEcologicalUnitValues = { + critterbase_collection_category_id: null, + critterbase_collection_unit_id: null }; /** @@ -46,7 +46,10 @@ export const EcologicalUnitsForm = () => { unit.collection_category_id)} + ecologicalUnits={ecologicalUnitsDataLoader?.data ?? []} arrayHelpers={arrayHelpers} index={index} /> diff --git a/app/src/features/surveys/animals/animal-form/components/ecological-units/components/EcologicalUnitsSelect.tsx b/app/src/features/surveys/animals/animal-form/components/ecological-units/components/EcologicalUnitsSelect.tsx deleted file mode 100644 index da703dbe1f..0000000000 --- a/app/src/features/surveys/animals/animal-form/components/ecological-units/components/EcologicalUnitsSelect.tsx +++ /dev/null @@ -1,130 +0,0 @@ -import { mdiClose } from '@mdi/js'; -import { Icon } from '@mdi/react'; -import Card from '@mui/material/Card'; -import grey from '@mui/material/colors/grey'; -import IconButton from '@mui/material/IconButton'; -import Stack from '@mui/material/Stack'; -import AutocompleteField from 'components/fields/AutocompleteField'; -import { FieldArrayRenderProps, useFormikContext } from 'formik'; -import { useCritterbaseApi } from 'hooks/useCritterbaseApi'; -import useDataLoader from 'hooks/useDataLoader'; -import { ICollectionCategory, ICreateEditAnimalRequest } from 'interfaces/useCritterApi.interface'; -import { useEffect, useMemo, useState } from 'react'; -import { EcologicalUnitsOptionSelect } from './EcologicalUnitsOptionSelect'; - -interface IEcologicalUnitsSelect { - // The collection units (categories) available to select from - ecologicalUnits: ICollectionCategory[]; - // Formik field array helpers - arrayHelpers: FieldArrayRenderProps; - // The index of the field array for these controls - index: number; -} - -/** - * Returns a component for selecting ecological (ie. collection) units for a given species. - * - * @param {IEcologicalUnitsSelect} props - * @return {*} - */ -export const EcologicalUnitsSelect = (props: IEcologicalUnitsSelect) => { - const { index, ecologicalUnits } = props; - - const { values, setFieldValue } = useFormikContext(); - - const critterbaseApi = useCritterbaseApi(); - - // Get the collection category ID for the selected ecological unit - const selectedEcologicalUnitId: string | undefined = values.ecological_units[index]?.collection_category_id; - - const ecologicalUnitOptionDataLoader = useDataLoader((collection_category_id: string) => - critterbaseApi.xref.getCollectionUnits(collection_category_id) - ); - - useEffect(() => { - // If a collection category is already selected, load the collection units for that category - if (!selectedEcologicalUnitId) { - return; - } - - ecologicalUnitOptionDataLoader.load(selectedEcologicalUnitId); - }, [ecologicalUnitOptionDataLoader, selectedEcologicalUnitId]); - - // Set the label for the ecological unit options autocomplete field - const [ecologicalUnitOptionLabel, setEcologicalUnitOptionLabel] = useState( - ecologicalUnits.find((ecologicalUnit) => ecologicalUnit.collection_category_id === selectedEcologicalUnitId) - ?.category_name ?? '' - ); - - // Filter out the categories that are already selected so they can't be selected again - const filteredCategories = useMemo( - () => - ecologicalUnits - .filter( - (ecologicalUnit) => - !values.ecological_units.some( - (existing) => - existing.collection_category_id === ecologicalUnit.collection_category_id && - existing.collection_category_id !== selectedEcologicalUnitId - ) - ) - .map((option) => { - return { - value: option.collection_category_id, - label: option.category_name - }; - }) ?? [], - [ecologicalUnits, selectedEcologicalUnitId, values.ecological_units] - ); - - // Map the collection unit options to the format required by the AutocompleteField - const ecologicalUnitOptions = useMemo( - () => - ecologicalUnitOptionDataLoader.data?.map((option) => ({ - value: option.collection_unit_id, - label: option.unit_name - })) ?? [], - [ecologicalUnitOptionDataLoader.data] - ); - - return ( - - { - if (option?.value) { - setFieldValue(`ecological_units.[${index}].collection_category_id`, option.value); - setEcologicalUnitOptionLabel(option.label); - } - }} - required - sx={{ - flex: '1 1 auto' - }} - /> - - props.arrayHelpers.remove(index)} - sx={{ mt: 1.125 }}> - - - - ); -}; diff --git a/app/src/features/surveys/animals/animal-form/components/general-information/AnimalGeneralInformationForm.tsx b/app/src/features/surveys/animals/animal-form/components/general-information/AnimalGeneralInformationForm.tsx index d89ec8cb5d..6bf20c9e5f 100644 --- a/app/src/features/surveys/animals/animal-form/components/general-information/AnimalGeneralInformationForm.tsx +++ b/app/src/features/surveys/animals/animal-form/components/general-information/AnimalGeneralInformationForm.tsx @@ -2,10 +2,10 @@ import Collapse from '@mui/material/Collapse'; import Grid from '@mui/material/Grid'; import Box from '@mui/system/Box'; import CustomTextField from 'components/fields/CustomTextField'; -import SelectedSpecies from 'components/species/components/SelectedSpecies'; import SpeciesAutocompleteField from 'components/species/components/SpeciesAutocompleteField'; import { useFormikContext } from 'formik'; import { ICreateEditAnimalRequest } from 'interfaces/useCritterApi.interface'; +import SelectedAnimalSpecies from './components/SelectedAnimalSpecies'; export interface IAnimalGeneralInformationFormProps { isEdit?: boolean; @@ -41,7 +41,7 @@ export const AnimalGeneralInformationForm = (props: IAnimalGeneralInformationFor /> {values.species && ( - setFieldValue('species', null)} diff --git a/app/src/features/surveys/animals/animal-form/components/general-information/components/SelectedAnimalSpecies.tsx b/app/src/features/surveys/animals/animal-form/components/general-information/components/SelectedAnimalSpecies.tsx new file mode 100644 index 0000000000..122d0f7b15 --- /dev/null +++ b/app/src/features/surveys/animals/animal-form/components/general-information/components/SelectedAnimalSpecies.tsx @@ -0,0 +1,38 @@ +import Collapse from '@mui/material/Collapse'; +import grey from '@mui/material/colors/grey'; +import Paper from '@mui/material/Paper'; +import Stack from '@mui/material/Stack'; +import SpeciesSelectedCard from 'components/species/components/SpeciesSelectedCard'; +import { IPartialTaxonomy } from 'interfaces/useTaxonomyApi.interface'; +import { TransitionGroup } from 'react-transition-group'; + +export interface ISelectedAnimalSpeciesProps { + selectedSpecies: IPartialTaxonomy[]; + handleRemoveSpecies?: (species_id: number) => void; +} + +/** + * Returns a stack of selected species cards. + * + * @param props {ISelectedAnimalSpeciesProps} + * @returns + */ +const SelectedAnimalSpecies = (props: ISelectedAnimalSpeciesProps) => { + const { selectedSpecies, handleRemoveSpecies } = props; + + return ( + + {selectedSpecies.map((species, speciesIndex) => { + return ( + + + + + + ); + })} + + ); +}; + +export default SelectedAnimalSpecies; diff --git a/app/src/features/surveys/animals/components/ScientificNameTypography.tsx b/app/src/features/surveys/animals/components/ScientificNameTypography.tsx index 405e137848..f4d0f9d851 100644 --- a/app/src/features/surveys/animals/components/ScientificNameTypography.tsx +++ b/app/src/features/surveys/animals/components/ScientificNameTypography.tsx @@ -16,7 +16,7 @@ export const ScientificNameTypography = (props: IScientificNameTypographyProps) if (terms.length > 1) { return ( - + {props.name} ); diff --git a/app/src/features/surveys/components/general-information/GeneralInformationForm.tsx b/app/src/features/surveys/components/general-information/GeneralInformationForm.tsx index b655e81a72..5156d7680a 100644 --- a/app/src/features/surveys/components/general-information/GeneralInformationForm.tsx +++ b/app/src/features/surveys/components/general-information/GeneralInformationForm.tsx @@ -1,13 +1,11 @@ -import Box from '@mui/material/Box'; import Grid from '@mui/material/Grid'; -import Typography from '@mui/material/Typography'; import AutocompleteField from 'components/fields/AutocompleteField'; import CustomTextField from 'components/fields/CustomTextField'; import { ISelectWithSubtextFieldOption } from 'components/fields/SelectWithSubtext'; import StartEndDateFields from 'components/fields/StartEndDateFields'; import React from 'react'; import yup from 'utils/YupSchema'; -import SurveyPermitForm, { SurveyPermitFormYupSchema } from '../../SurveyPermitForm'; +import { SurveyPermitFormYupSchema } from '../permit/SurveyPermitForm'; export const AddPermitFormInitialValues = { permits: [ @@ -92,45 +90,35 @@ export interface IGeneralInformationFormProps { */ const GeneralInformationForm: React.FC = (props) => { return ( - <> - - - - - - - - - - + + + - - - Were any permits used for this work? - - - - - - + + + + + + + ); }; diff --git a/app/src/features/surveys/SurveyPermitForm.test.tsx b/app/src/features/surveys/components/permit/SurveyPermitForm.test.tsx similarity index 100% rename from app/src/features/surveys/SurveyPermitForm.test.tsx rename to app/src/features/surveys/components/permit/SurveyPermitForm.test.tsx diff --git a/app/src/features/surveys/SurveyPermitForm.tsx b/app/src/features/surveys/components/permit/SurveyPermitForm.tsx similarity index 95% rename from app/src/features/surveys/SurveyPermitForm.tsx rename to app/src/features/surveys/components/permit/SurveyPermitForm.tsx index 15a3c88a5d..b7a5e1a085 100644 --- a/app/src/features/surveys/SurveyPermitForm.tsx +++ b/app/src/features/surveys/components/permit/SurveyPermitForm.tsx @@ -142,30 +142,23 @@ const SurveyPermitForm: React.FC = () => { } label="No" /> - - {values.permit.permits?.map((permit: ISurveyPermit, index) => { + + {values.permit.permits.map((permit: ISurveyPermit, index) => { const permitNumberMeta = getFieldMeta(`permit.permits.[${index}].permit_number`); const permitTypeMeta = getFieldMeta(`permit.permits.[${index}].permit_type`); return ( - + { /> (); + + for (const focalSpeciesItem of focalSpecies) { + for (const ecologicalUnit of focalSpeciesItem.ecological_units) { + if (seenCollectionUnitIts.has(ecologicalUnit.critterbase_collection_category_id)) { + // Duplicate ecological collection category id found, return false + return false; + } + seenCollectionUnitIts.add(ecologicalUnit.critterbase_collection_category_id); + } + } + + // Valid, return true + return true; + }) }) }); @@ -32,7 +77,7 @@ const SpeciesForm = () => { - + diff --git a/app/src/features/surveys/components/species/components/FocalSpeciesAlert.tsx b/app/src/features/surveys/components/species/components/FocalSpeciesAlert.tsx new file mode 100644 index 0000000000..13fd339187 --- /dev/null +++ b/app/src/features/surveys/components/species/components/FocalSpeciesAlert.tsx @@ -0,0 +1,25 @@ +import AlertBar from 'components/alert/AlertBar'; +import { useFormikContext } from 'formik'; +import { ICreateSurveyRequest, IEditSurveyRequest } from 'interfaces/useSurveyApi.interface'; +import get from 'lodash-es/get'; + +/** + * Renders an alert if formik has an error for the 'species.focal_species' field. + * + * @return {*} + */ +export const FocalSpeciesAlert = () => { + const { errors } = useFormikContext(); + + const errorText = get(errors, 'species.focal_species'); + + if (!errorText) { + return null; + } + + if (typeof errorText !== 'string') { + return null; + } + + return ; +}; diff --git a/app/src/features/surveys/components/species/components/FocalSpeciesEcologicalUnitsForm.tsx b/app/src/features/surveys/components/species/components/FocalSpeciesEcologicalUnitsForm.tsx new file mode 100644 index 0000000000..d877370212 --- /dev/null +++ b/app/src/features/surveys/components/species/components/FocalSpeciesEcologicalUnitsForm.tsx @@ -0,0 +1,94 @@ +import { mdiPlus } from '@mdi/js'; +import Icon from '@mdi/react'; +import Box from '@mui/material/Box'; +import Button from '@mui/material/Button'; +import Stack from '@mui/material/Stack'; +import { EcologicalUnitsSelect } from 'components/species/ecological-units/EcologicalUnitsSelect'; +import { initialEcologicalUnitValues } from 'features/surveys/animals/animal-form/components/ecological-units/EcologicalUnitsForm'; +import { FieldArray, FieldArrayRenderProps, useFormikContext } from 'formik'; +import { useCritterbaseApi } from 'hooks/useCritterbaseApi'; +import useDataLoader from 'hooks/useDataLoader'; +import { ICreateSurveyRequest, IEditSurveyRequest } from 'interfaces/useSurveyApi.interface'; +import { IPartialTaxonomy } from 'interfaces/useTaxonomyApi.interface'; +import { useEffect } from 'react'; +import { isDefined } from 'utils/Utils'; +import { v4 } from 'uuid'; + +export interface ISelectedSpeciesProps { + /** + * The species to display. + * + * @type {IPartialTaxonomy} + * @memberof ISelectedSpeciesProps + */ + species: IPartialTaxonomy; + /** + * The index of the component in the list. + * + * @type {number} + * @memberof ISelectedSpeciesProps + */ + index: number; +} + +/** + * Renders form controls for selecting ecological units for a focal species. + * + * @param {ISelectedSpeciesProps} props + * @return {*} + */ +export const FocalSpeciesEcologicalUnitsForm = (props: ISelectedSpeciesProps) => { + const { index, species } = props; + + const { values } = useFormikContext(); + + const critterbaseApi = useCritterbaseApi(); + + const ecologicalUnitDataLoader = useDataLoader((tsn: number) => critterbaseApi.xref.getTsnCollectionCategories(tsn)); + + useEffect(() => { + ecologicalUnitDataLoader.load(species.tsn); + }, [ecologicalUnitDataLoader, species.tsn]); + + const ecologicalUnitsForSpecies = ecologicalUnitDataLoader.data ?? []; + + const selectedUnits = + values.species.focal_species.filter((item) => item.tsn === species.tsn).flatMap((item) => item.ecological_units) ?? + []; + + return ( + ( + + {selectedUnits.map((ecological_unit, ecologicalUnitIndex) => ( + unit.critterbase_collection_category_id) + .filter(isDefined)} + ecologicalUnits={ecologicalUnitsForSpecies} + arrayHelpers={arrayHelpers} + index={ecologicalUnitIndex} + /> + ))} + + + + + )} + /> + ); +}; diff --git a/app/src/features/surveys/components/species/components/FocalSpeciesForm.tsx b/app/src/features/surveys/components/species/components/FocalSpeciesForm.tsx new file mode 100644 index 0000000000..7b0368a847 --- /dev/null +++ b/app/src/features/surveys/components/species/components/FocalSpeciesForm.tsx @@ -0,0 +1,69 @@ +import Collapse from '@mui/material/Collapse'; +import { grey } from '@mui/material/colors'; +import Paper from '@mui/material/Paper'; +import Stack from '@mui/material/Stack'; +import SpeciesAutocompleteField from 'components/species/components/SpeciesAutocompleteField'; +import SpeciesSelectedCard from 'components/species/components/SpeciesSelectedCard'; +import { FocalSpeciesAlert } from 'features/surveys/components/species/components/FocalSpeciesAlert'; +import { FocalSpeciesEcologicalUnitsForm } from 'features/surveys/components/species/components/FocalSpeciesEcologicalUnitsForm'; +import { ITaxonomyWithEcologicalUnits } from 'features/surveys/components/species/SpeciesForm'; +import { FieldArray, useFormikContext } from 'formik'; +import { ICreateSurveyRequest, IEditSurveyRequest } from 'interfaces/useSurveyApi.interface'; +import get from 'lodash-es/get'; +import { TransitionGroup } from 'react-transition-group'; + +/** + * Returns a form control for selecting focal species and ecological units for each focal species. + * + * @return {*} + */ +export const FocalSpeciesForm = () => { + const { values } = useFormikContext(); + + const selectedSpecies: ITaxonomyWithEcologicalUnits[] = get(values, 'species.focal_species') ?? []; + + return ( + { + return ( + + + + { + if (values.species.focal_species.some((focalSpecies) => focalSpecies.tsn === species.tsn)) { + // Species was already added, do not add again + return; + } + + arrayHelpers.push({ ...species, ecological_units: [] }); + }} + clearOnSelect={true} + /> + + + {selectedSpecies.map((species, index) => ( + + + { + arrayHelpers.remove(index); + }} + /> + + + + ))} + + + ); + }} + /> + ); +}; diff --git a/app/src/features/surveys/edit/EditSurveyForm.tsx b/app/src/features/surveys/edit/EditSurveyForm.tsx index 7ac2ef1b08..c228734833 100644 --- a/app/src/features/surveys/edit/EditSurveyForm.tsx +++ b/app/src/features/surveys/edit/EditSurveyForm.tsx @@ -7,8 +7,8 @@ import FormikErrorSnackbar from 'components/alert/FormikErrorSnackbar'; import HorizontalSplitFormComponent from 'components/fields/HorizontalSplitFormComponent'; import { CodesContext } from 'contexts/codesContext'; import { ProjectContext } from 'contexts/projectContext'; +import SurveyPermitForm, { ISurveyPermitForm } from 'features/surveys/components/permit/SurveyPermitForm'; import SamplingStrategyForm from 'features/surveys/components/sampling-strategy/SamplingStrategyForm'; -import { ISurveyPermitForm } from 'features/surveys/SurveyPermitForm'; import SurveyPartnershipsForm, { SurveyPartnershipsFormYupSchema } from 'features/surveys/view/components/SurveyPartnershipsForm'; @@ -106,8 +106,35 @@ const EditSurveyForm = < }> + summary="Enter focal species that were targetted in the survey"> + + + + + + + Were any permits used in this survey? + + + } + /> + + + + + Do any funding agencies require this survey to be submitted? + + + } + /> @@ -139,19 +166,6 @@ const EditSurveyForm = < - - Does a funding agency require this survey to be submitted? - - - } - /> - - - { const observationsTableContext = useObservationsTableContext(); // Collect sample sites - const surveySampleSites: IGetSampleLocationDetails[] = surveyContext.sampleSiteDataLoader.data?.sampleSites ?? []; - const sampleSiteOptions: ISampleSiteOption[] = - surveySampleSites.map((site) => ({ - survey_sample_site_id: site.survey_sample_site_id, - sample_site_name: site.name - })) ?? []; + const surveySampleSites: IGetSampleLocationDetails[] = useMemo( + () => surveyContext.sampleSiteDataLoader.data?.sampleSites ?? [], + [surveyContext.sampleSiteDataLoader.data?.sampleSites] + ); + + const sampleSiteOptions: ISampleSiteOption[] = useMemo( + () => + surveySampleSites.map((site) => ({ + survey_sample_site_id: site.survey_sample_site_id, + sample_site_name: site.name + })) ?? [], + [surveySampleSites] + ); // Collect sample methods const surveySampleMethods: IGetSampleMethodDetails[] = surveySampleSites @@ -131,7 +138,14 @@ const ObservationsTableContainer = () => { // Add environment columns to the table ...getEnvironmentColumnDefinitions(observationsTableContext.environmentColumns, observationsTableContext.hasError) ], - [observationsTableContext.environmentColumns, observationsTableContext.measurementColumns] + [ + observationsTableContext.environmentColumns, + observationsTableContext.hasError, + observationsTableContext.measurementColumns, + sampleMethodOptions, + samplePeriodOptions, + sampleSiteOptions + ] ); return ( diff --git a/app/src/features/surveys/observations/observations-table/observation-row-validation/ObservationRowValidationUtils.ts b/app/src/features/surveys/observations/observations-table/observation-row-validation/ObservationRowValidationUtils.ts index 949b4d779a..814986e8df 100644 --- a/app/src/features/surveys/observations/observations-table/observation-row-validation/ObservationRowValidationUtils.ts +++ b/app/src/features/surveys/observations/observations-table/observation-row-validation/ObservationRowValidationUtils.ts @@ -34,7 +34,7 @@ export const validateObservationTableRowMeasurements = async ( return []; } - const taxonMeasurements = await getTsnMeasurementTypeDefinitionMap(Number(row.itis_tsn)); + const taxonMeasurements = await getTsnMeasurementTypeDefinitionMap(row.itis_tsn); if (!taxonMeasurements) { // This taxon has no valid measurements, return an error diff --git a/app/src/features/surveys/view/survey-spatial/components/animal/SurveySpatialAnimal.tsx b/app/src/features/surveys/view/survey-spatial/components/animal/SurveySpatialAnimal.tsx index 1d17f7dc4e..8fb5799d49 100644 --- a/app/src/features/surveys/view/survey-spatial/components/animal/SurveySpatialAnimal.tsx +++ b/app/src/features/surveys/view/survey-spatial/components/animal/SurveySpatialAnimal.tsx @@ -90,7 +90,7 @@ export const SurveySpatialAnimal = () => { return ( <> {/* Display map with animal capture points */} - + diff --git a/app/src/features/surveys/view/survey-spatial/components/observation/SurveySpatialObservation.tsx b/app/src/features/surveys/view/survey-spatial/components/observation/SurveySpatialObservation.tsx index 5fad4c885c..34836bdf3a 100644 --- a/app/src/features/surveys/view/survey-spatial/components/observation/SurveySpatialObservation.tsx +++ b/app/src/features/surveys/view/survey-spatial/components/observation/SurveySpatialObservation.tsx @@ -61,12 +61,12 @@ export const SurveySpatialObservation = () => { return ( <> {/* Display map with observation points */} - + {/* Display data table with observation details */} - + diff --git a/app/src/features/surveys/view/survey-spatial/components/telemetry/SurveySpatialTelemetry.tsx b/app/src/features/surveys/view/survey-spatial/components/telemetry/SurveySpatialTelemetry.tsx index e43dc0ede3..3737f7bc89 100644 --- a/app/src/features/surveys/view/survey-spatial/components/telemetry/SurveySpatialTelemetry.tsx +++ b/app/src/features/surveys/view/survey-spatial/components/telemetry/SurveySpatialTelemetry.tsx @@ -127,7 +127,7 @@ export const SurveySpatialTelemetry = () => { return ( <> {/* Display map with telemetry points */} - + diff --git a/app/src/hooks/api/useProjectApi.test.ts b/app/src/hooks/api/useProjectApi.test.ts index 0446fca7d6..363e4ba6a3 100644 --- a/app/src/hooks/api/useProjectApi.test.ts +++ b/app/src/hooks/api/useProjectApi.test.ts @@ -6,7 +6,7 @@ import { IProjectIUCNForm } from 'features/projects/components/ProjectIUCNForm'; import { IProjectObjectivesForm } from 'features/projects/components/ProjectObjectivesForm'; import { ICreateProjectRequest, IFindProjectsResponse, UPDATE_GET_ENTITIES } from 'interfaces/useProjectApi.interface'; import { getProjectForViewResponse } from 'test-helpers/project-helpers'; -import { ISurveyPermitForm } from '../../features/surveys/SurveyPermitForm'; +import { ISurveyPermitForm } from '../../features/surveys/components/permit/SurveyPermitForm'; import useProjectApi from './useProjectApi'; describe('useProjectApi', () => { diff --git a/app/src/hooks/api/useTaxonomyApi.ts b/app/src/hooks/api/useTaxonomyApi.ts index 919fcb8c1d..23635a87e1 100644 --- a/app/src/hooks/api/useTaxonomyApi.ts +++ b/app/src/hooks/api/useTaxonomyApi.ts @@ -86,6 +86,12 @@ const useTaxonomyApi = () => { /** * Parses the taxon search response into start case. * + * The case of scientific names should not be modified. Genus names and higher are capitalized while + * species-level and subspecies-level names (the second and third words in a species/subspecies name) are not capitalized. + * Example: Ursus americanus, Rangifier tarandus caribou, Mammalia, Alces alces. + * + * The case of common names is less standardized and often just preference. + * * @template T * @param {T[]} searchResponse - Array of Taxonomy objects * @returns {T[]} Correctly cased Taxonomy diff --git a/app/src/hooks/cb_api/useXrefApi.tsx b/app/src/hooks/cb_api/useXrefApi.tsx index 018a8dc1a5..dcd8723fa7 100644 --- a/app/src/hooks/cb_api/useXrefApi.tsx +++ b/app/src/hooks/cb_api/useXrefApi.tsx @@ -47,12 +47,18 @@ export const useXrefApi = (axios: AxiosInstance) => { * @return {*} {Promise} */ const getTsnCollectionCategories = async (tsn: number): Promise => { - const { data } = await axios.get(`/api/critterbase/xref/taxon-collection-categories?tsn=${tsn}`); + const { data } = await axios.get('/api/critterbase/xref/taxon-collection-categories', { + params: { tsn }, + paramsSerializer: (params) => { + return qs.stringify(params); + } + }); + return data; }; /** - * Get collection (ie. ecological) units that are available for a given taxon + * Get collection (ie. ecological) units values for a given collection unit * * @param {string} unit_id * @return {*} {Promise} diff --git a/app/src/interfaces/useCritterApi.interface.ts b/app/src/interfaces/useCritterApi.interface.ts index f0217dffd8..f913f7cfdd 100644 --- a/app/src/interfaces/useCritterApi.interface.ts +++ b/app/src/interfaces/useCritterApi.interface.ts @@ -104,6 +104,11 @@ export interface IEditMortalityRequest extends IMarkings, IMeasurementsUpdate { mortality: IMortalityPostData; } +export interface ICollectionUnitMultiTsnResponse { + tsn: number; + categories: ICollectionCategory[]; +} + export interface ICollectionCategory { collection_category_id: string; category_name: string; diff --git a/app/src/interfaces/useSurveyApi.interface.ts b/app/src/interfaces/useSurveyApi.interface.ts index e8a24dd5c1..fd99d9a7fe 100644 --- a/app/src/interfaces/useSurveyApi.interface.ts +++ b/app/src/interfaces/useSurveyApi.interface.ts @@ -8,11 +8,11 @@ import { import { IGeneralInformationForm } from 'features/surveys/components/general-information/GeneralInformationForm'; import { ISurveyLocationForm } from 'features/surveys/components/locations/StudyAreaForm'; import { IPurposeAndMethodologyForm } from 'features/surveys/components/methodology/PurposeAndMethodologyForm'; -import { ISpeciesForm } from 'features/surveys/components/species/SpeciesForm'; -import { ISurveyPermitForm } from 'features/surveys/SurveyPermitForm'; +import { ISurveyPermitForm } from 'features/surveys/components/permit/SurveyPermitForm'; +import { ISpeciesForm, ITaxonomyWithEcologicalUnits } from 'features/surveys/components/species/SpeciesForm'; import { ISurveyPartnershipsForm } from 'features/surveys/view/components/SurveyPartnershipsForm'; import { Feature } from 'geojson'; -import { IPartialTaxonomy, ITaxonomy } from 'interfaces/useTaxonomyApi.interface'; +import { ITaxonomy } from 'interfaces/useTaxonomyApi.interface'; import { IAnimalDeployment } from 'interfaces/useTelemetryApi.interface'; import { ApiPaginationResponseParams, StringBoolean } from 'types/misc'; import { ICritterDetailedResponse, ICritterSimpleResponse } from './useCritterApi.interface'; @@ -186,7 +186,7 @@ export type IUpdateSurveyRequest = ISurveyLocationForm & { revision_count: number; }; species: { - focal_species: IPartialTaxonomy[]; + focal_species: ITaxonomyWithEcologicalUnits[]; }; permit: { permits: { @@ -360,7 +360,7 @@ export interface IGetSurveyForUpdateResponse { revision_count: number; }; species: { - focal_species: IPartialTaxonomy[]; + focal_species: ITaxonomyWithEcologicalUnits[]; }; permit: { permits: { diff --git a/app/src/interfaces/useTaxonomyApi.interface.ts b/app/src/interfaces/useTaxonomyApi.interface.ts index d8779bd256..83edf013bc 100644 --- a/app/src/interfaces/useTaxonomyApi.interface.ts +++ b/app/src/interfaces/useTaxonomyApi.interface.ts @@ -1,14 +1,3 @@ -export interface IItisSearchResponse { - commonNames: string[]; - kingdom: string; - name: string; - parentTSN: string; - scientificName: string; - tsn: string; - updateDate: string; - usage: string; -} - export type ITaxonomy = { tsn: number; commonNames: string[]; diff --git a/database/src/migrations/20240809140000_study_species_units.ts b/database/src/migrations/20240809140000_study_species_units.ts new file mode 100644 index 0000000000..5b51403485 --- /dev/null +++ b/database/src/migrations/20240809140000_study_species_units.ts @@ -0,0 +1,62 @@ +import { Knex } from 'knex'; + +/** + * Create new tables: + * - study_species_unit + * + * @export + * @param {Knex} knex + * @return {*} {Promise} + */ +export async function up(knex: Knex): Promise { + await knex.raw(`--sql + SET SEARCH_PATH=biohub; + + ----------------------------------------------------------------------------------------------------------------- + -- CREATE study_species_unit table for associating collection units / ecological units with a survey + ----------------------------------------------------------------------------------------------------------------- + CREATE TABLE study_species_unit ( + study_species_unit_id integer GENERATED ALWAYS AS IDENTITY (START WITH 1 INCREMENT BY 1), + study_species_id integer NOT NULL, + critterbase_collection_category_id UUID NOT NULL, + critterbase_collection_unit_id UUID NOT NULL, + create_date timestamptz(6) DEFAULT now() NOT NULL, + create_user integer NOT NULL, + update_date timestamptz(6), + update_user integer, + revision_count integer DEFAULT 0 NOT NULL, + CONSTRAINT study_species_unit_pk PRIMARY KEY (study_species_unit_id) + ); + + COMMENT ON TABLE study_species_unit IS 'This table is intended to track ecological units of interest for focal species in a survey.'; + COMMENT ON COLUMN study_species_unit.study_species_unit_id IS 'Primary key to the table.'; + COMMENT ON COLUMN study_species_unit.study_species_id IS 'Foreign key to the study_species table.'; + COMMENT ON COLUMN study_species_unit.critterbase_collection_category_id IS 'UUID of an external critterbase collection category.'; + COMMENT ON COLUMN study_species_unit.critterbase_collection_unit_id IS 'UUID of an external critterbase collection unit.'; + COMMENT ON COLUMN study_species_unit.create_date IS 'The datetime the record was created.'; + COMMENT ON COLUMN study_species_unit.create_user IS 'The id of the user who created the record as identified in the system user table.'; + COMMENT ON COLUMN study_species_unit.update_date IS 'The datetime the record was updated.'; + COMMENT ON COLUMN study_species_unit.update_user IS 'The id of the user who updated the record as identified in the system user table.'; + COMMENT ON COLUMN study_species_unit.revision_count IS 'Revision count used for concurrency control.'; + + -- add foreign key constraint + ALTER TABLE study_species_unit ADD CONSTRAINT study_species_unit_fk1 FOREIGN KEY (study_species_id) REFERENCES study_species(study_species_id); + + -- add indexes for foreign keys + CREATE INDEX study_species_unit_idx1 ON study_species_unit(study_species_id); + + -- add triggers for user data + CREATE TRIGGER audit_study_species_unit BEFORE INSERT OR UPDATE OR DELETE ON biohub.study_species_unit FOR EACH ROW EXECUTE PROCEDURE tr_audit_trigger(); + CREATE TRIGGER journal_study_species_unit AFTER INSERT OR UPDATE OR DELETE ON biohub.study_species_unit FOR EACH ROW EXECUTE PROCEDURE tr_journal_trigger(); + + ---------------------------------------------------------------------------------------- + -- Create measurement table views + ---------------------------------------------------------------------------------------- + SET SEARCH_PATH=biohub_dapi_v1; + CREATE OR REPLACE VIEW study_species_unit AS SELECT * FROM biohub.study_species_unit; + `); +} + +export async function down(knex: Knex): Promise { + await knex.raw(``); +}