From 0756ecf944d7c247f6017b5066ba988866e99ebf Mon Sep 17 00:00:00 2001 From: Macgregor Aubertin-Young Date: Sat, 10 Aug 2024 21:51:00 -0700 Subject: [PATCH 1/6] add ecological units to survey focal species --- api/src/models/survey-create.ts | 17 ++- api/src/models/survey-update.ts | 4 +- api/src/models/survey-view.ts | 4 +- api/src/openapi/schemas/critter.ts | 68 ++++----- api/src/openapi/schemas/survey.ts | 24 +++- .../survey/{surveyId}/update/get.test.ts | 2 +- api/src/repositories/survey-repository.ts | 90 +++++++++++- api/src/services/platform-service.ts | 9 ++ api/src/services/survey-service.ts | 63 ++++++++- .../species/FocalSpeciesComponent.tsx | 54 ++++++-- .../species/components/SelectedSpecies.tsx | 43 ------ .../components/SelectedSurveySpecies.tsx | 94 +++++++++++++ .../components/SpeciesAutocompleteField.tsx | 9 +- .../species/components/SpeciesCard.tsx | 36 ++--- .../components/SpeciesSelectedCard.tsx | 37 +++-- .../EcologicalUnitsOptionSelect.tsx | 27 ++-- .../EcologicalUnitsSelect.tsx | 103 ++++++++++++++ app/src/features/surveys/CreateSurveyPage.tsx | 2 +- .../ecological-units/EcologicalUnitsForm.tsx | 15 +- .../components/EcologicalUnitsSelect.tsx | 130 ------------------ .../AnimalGeneralInformationForm.tsx | 4 +- .../components/SelectedAnimalSpecies.tsx | 32 +++++ .../components/ScientificNameTypography.tsx | 2 +- .../GeneralInformationForm.tsx | 12 +- .../permit}/SurveyPermitForm.test.tsx | 0 .../permit}/SurveyPermitForm.tsx | 0 .../SurveySiteSelectionForm.tsx | 2 +- .../components/species/SpeciesForm.tsx | 11 +- .../features/surveys/edit/EditSurveyForm.tsx | 41 ++++-- app/src/hooks/api/useProjectApi.test.ts | 2 +- app/src/hooks/api/useTaxonomyApi.ts | 8 +- app/src/hooks/cb_api/useXrefApi.tsx | 15 +- app/src/interfaces/useSurveyApi.interface.ts | 10 +- .../20240809140000_study_species_units.ts | 64 +++++++++ 34 files changed, 683 insertions(+), 351 deletions(-) delete mode 100644 app/src/components/species/components/SelectedSpecies.tsx create mode 100644 app/src/components/species/components/SelectedSurveySpecies.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 (100%) 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..7960e64979 100644 --- a/api/src/models/survey-create.ts +++ b/api/src/models/survey-create.ts @@ -1,6 +1,7 @@ +import { z } from 'zod'; 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 +90,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) || []; @@ -150,3 +151,15 @@ export class PostAgreementsData { this.sedis_procedures_accepted = obj?.sedis_procedures_accepted === 'true' || false; } } + +export const SpeciesWithEcologicalUnits = 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 SpeciesWithEcologicalUnits = z.infer; 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 1ad2b6ef4d..812c34c270 100644 --- a/api/src/openapi/schemas/critter.ts +++ b/api/src/openapi/schemas/critter.ts @@ -1,5 +1,38 @@ 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 +87,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..91f4338375 100644 --- a/api/src/openapi/schemas/survey.ts +++ b/api/src/openapi/schemas/survey.ts @@ -55,6 +55,25 @@ export const surveyDetailsSchema: OpenAPIV3.SchemaObject = { } }; +export const SurveyCollectionUnitsSchema: 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 +131,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 +156,8 @@ export const focalSpeciesSchema: OpenAPIV3.SchemaObject = { kingdom: { description: 'Taxonomy kingdom name', type: 'string' - } + }, + ecological_units: SurveyCollectionUnitsSchema } }; diff --git a/api/src/paths/project/{projectId}/survey/{surveyId}/update/get.test.ts b/api/src/paths/project/{projectId}/survey/{surveyId}/update/get.test.ts index 98d4b7a284..86751b2c87 100644 --- a/api/src/paths/project/{projectId}/survey/{surveyId}/update/get.test.ts +++ b/api/src/paths/project/{projectId}/survey/{surveyId}/update/get.test.ts @@ -2,11 +2,11 @@ import chai, { expect } from 'chai'; import { describe } from 'mocha'; import sinon from 'sinon'; import sinonChai from 'sinon-chai'; +import { getMockDBConnection } from '../../../../../../__mocks__/db'; import * as db from '../../../../../../database/db'; import { HTTPError } from '../../../../../../errors/http-error'; import { SurveyObject } from '../../../../../../models/survey-view'; import { SurveyService } from '../../../../../../services/survey-service'; -import { getMockDBConnection } from '../../../../../../__mocks__/db'; import * as get from './get'; chai.use(sinonChai); diff --git a/api/src/repositories/survey-repository.ts b/api/src/repositories/survey-repository.ts index 78eb33774f..98efa72665 100644 --- a/api/src/repositories/survey-repository.ts +++ b/api/src/repositories/survey-repository.ts @@ -3,7 +3,7 @@ import SQL from 'sql-template-strings'; import { z } from 'zod'; import { getKnex } from '../database/db'; import { ApiExecuteSQLError } from '../errors/api-error'; -import { PostProprietorData, PostSurveyObject } from '../models/survey-create'; +import { PostProprietorData, PostSurveyObject, SpeciesWithEcologicalUnits } from '../models/survey-create'; import { PutSurveyObject } from '../models/survey-update'; import { FindSurveysResponse, @@ -13,6 +13,7 @@ import { GetSurveyPurposeAndMethodologyData, ISurveyAdvancedFilters } from '../models/survey-view'; +import { IPostCollectionUnit } from '../services/platform-service'; import { ApiPaginationOptions } from '../zod-schema/pagination'; import { BaseRepository } from './base-repository'; @@ -360,17 +361,38 @@ export class SurveyRepository extends BaseRepository { * @returns {*} {Promise} * @memberof SurveyRepository */ - async getSpeciesData(surveyId: number): Promise { + async getSpeciesData(surveyId: number): Promise { const sqlStatement = SQL` + WITH 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(eu.units, '[]'::json) AS ecological_units + FROM + study_species ss + LEFT JOIN + ecological_units eu ON eu.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, SpeciesWithEcologicalUnits); return response.rows; } @@ -751,7 +773,43 @@ 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; + `; + + console.log(ecologicalUnitObject); + + 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 +1059,24 @@ export class SurveyRepository extends BaseRepository { await this.connection.sql(sqlStatement); } + /** + * Deletes Survey species data for 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/platform-service.ts b/api/src/services/platform-service.ts index 5243bb7fe6..28d7d8958f 100644 --- a/api/src/services/platform-service.ts +++ b/api/src/services/platform-service.ts @@ -57,6 +57,15 @@ export interface ITaxonomy { kingdom: string; } +export interface IPostCollectionUnit { + critterbase_collection_unit_id: string; + critterbase_collection_category_id: 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/survey-service.ts b/api/src/services/survey-service.ts index 7f41b9319e..7c808f7c29 100644 --- a/api/src/services/survey-service.ts +++ b/api/src/services/survey-service.ts @@ -26,7 +26,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'; @@ -173,12 +173,20 @@ export class SurveyService extends DBService { async getSpeciesData(surveyId: number): Promise { const studySpeciesResponse = await this.surveyRepository.getSpeciesData(surveyId); - const platformService = new PlatformService(this.connection); - - const focalSpecies = await platformService.getTaxonomyByTsns( + const response = await this.platformService.getTaxonomyByTsns( studySpeciesResponse.map((species) => species.itis_tsn) ); + // Create a lookup map for taxonomy data, to be used for injecting ecological units for each study species + const taxonomyMap = new Map(response.map((taxonomy) => [Number(taxonomy.tsn), taxonomy])); + + // Combine species data with taxonomy data and ecological units + const focalSpecies = studySpeciesResponse.map((species) => ({ + ...taxonomyMap.get(species.itis_tsn), + ecological_units: species.ecological_units + })); + + // Return the combined data return new GetFocalSpeciesData(focalSpecies); } @@ -375,9 +383,17 @@ 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) => { + const units = species.ecological_units; + if (units.length) { + this.insertFocalSpeciesWithUnits(species, surveyId); + } else { + this.insertFocalSpecies(species.tsn, surveyId); + } + }) ) ); @@ -550,6 +566,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 * @@ -769,12 +805,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); @@ -791,6 +829,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 index 657059e3cf..2f25036c95 100644 --- a/app/src/components/species/FocalSpeciesComponent.tsx +++ b/app/src/components/species/FocalSpeciesComponent.tsx @@ -1,37 +1,63 @@ import Stack from '@mui/material/Stack'; import AlertBar from 'components/alert/AlertBar'; +import { ISpeciesWithEcologicalUnits } from 'features/surveys/components/species/SpeciesForm'; import { useFormikContext } from 'formik'; -import { IPartialTaxonomy, ITaxonomy } from 'interfaces/useTaxonomyApi.interface'; +import { useCritterbaseApi } from 'hooks/useCritterbaseApi'; +import useDataLoader from 'hooks/useDataLoader'; +import { ICreateSurveyRequest, IEditSurveyRequest } from 'interfaces/useSurveyApi.interface'; +import { IPartialTaxonomy } from 'interfaces/useTaxonomyApi.interface'; import get from 'lodash-es/get'; -import SelectedSpecies from './components/SelectedSpecies'; +import { useEffect } from 'react'; +import SelectedSpecies from './components/SelectedSurveySpecies'; import SpeciesAutocompleteField from './components/SpeciesAutocompleteField'; const FocalSpeciesComponent = () => { - const { values, setFieldValue, setFieldError, errors, submitCount } = useFormikContext(); + const { values, setFieldValue, setFieldError, errors, submitCount } = useFormikContext< + ICreateSurveyRequest | IEditSurveyRequest + >(); - const selectedSpecies: ITaxonomy[] = get(values, 'species.focal_species') || []; + const selectedSpecies: ISpeciesWithEcologicalUnits[] = get(values, 'species.focal_species') || []; + const critterbaseApi = useCritterbaseApi(); + + const ecologicalUnitDataLoader = useDataLoader((tsn: number[]) => + critterbaseApi.xref.getTsnCollectionCategories(tsn) + ); + + const selectedSpeciesTsns = selectedSpecies.map((species) => species.tsn); + + useEffect(() => { + if (selectedSpeciesTsns) { + ecologicalUnitDataLoader.load(selectedSpeciesTsns); + } + // Should not re-run this effect on `ecologicalUnitsDataLoader.refresh` changes + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [selectedSpeciesTsns]); const handleAddSpecies = (species?: IPartialTaxonomy) => { - setFieldValue(`species.focal_species[${selectedSpecies.length}]`, species); + // Add species with empty ecological units array + setFieldValue(`species.focal_species[${selectedSpecies.length}]`, { ...species, ecological_units: [] }); setFieldError(`species.focal_species`, undefined); + // Fetch ecological units for new species + if (species) { + ecologicalUnitDataLoader.refresh([...selectedSpeciesTsns, species.tsn]); + } }; const handleRemoveSpecies = (species_id: number) => { - const filteredSpecies = selectedSpecies.filter((value: ITaxonomy) => { + const filteredSpecies = selectedSpecies.filter((value: ISpeciesWithEcologicalUnits) => { return value.tsn !== species_id; }); - setFieldValue('species.focal_species', filteredSpecies); }; return ( - + {submitCount > 0 && errors && get(errors, 'species.focal_species') && ( )} { handleSpecies={handleAddSpecies} clearOnSelect={true} /> - + {selectedSpecies.length > 0 && ( + + )} ); }; 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/SelectedSurveySpecies.tsx b/app/src/components/species/components/SelectedSurveySpecies.tsx new file mode 100644 index 0000000000..ab7986879a --- /dev/null +++ b/app/src/components/species/components/SelectedSurveySpecies.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 CircularProgress from '@mui/material/CircularProgress'; +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 { 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 { ICollectionCategory } from 'interfaces/useCritterApi.interface'; +import { ICreateSurveyRequest, IEditSurveyRequest } from 'interfaces/useSurveyApi.interface'; +import { IPartialTaxonomy } from 'interfaces/useTaxonomyApi.interface'; +import { TransitionGroup } from 'react-transition-group'; + +export interface ISelectedSpeciesProps { + name: string; + selectedSpecies: IPartialTaxonomy[]; + handleRemoveSpecies?: (species_id: number) => void; + ecologicalUnits?: ICollectionCategory[]; + isLoading?: boolean; +} + +const SelectedSurveySpecies = (props: ISelectedSpeciesProps) => { + const { selectedSpecies, handleRemoveSpecies, name, ecologicalUnits, isLoading } = props; + + const { values } = useFormikContext(); + + return ( + + {selectedSpecies.map((species, speciesIndex) => { + const ecologicalUnitsForSpecies = + ecologicalUnits?.filter((category) => category.itis_tsn === species.tsn) ?? []; + const selectedUnits = values.species.focal_species[speciesIndex]?.ecological_units; + + return ( + + + + + ( + + {selectedUnits.map((ecological_unit, ecologicalUnitIndex) => ( + unit.critterbase_collection_category_id + )} + ecologicalUnits={ecologicalUnitsForSpecies} + arrayHelpers={arrayHelpers} + index={ecologicalUnitIndex} + /> + ))} + + {isLoading ? ( + + ) : ( + + )} + + + )} + /> + + + ); + })} + + ); +}; + +export default SelectedSurveySpecies; diff --git a/app/src/components/species/components/SpeciesAutocompleteField.tsx b/app/src/components/species/components/SpeciesAutocompleteField.tsx index 07be1ec261..081aa4dbba 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 { @@ -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 1f7ffc08b0..2e88c176f4 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,26 +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..a43702f60a 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,23 @@ 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..3e4863faa1 --- /dev/null +++ b/app/src/components/species/ecological-units/EcologicalUnitsSelect.tsx @@ -0,0 +1,103 @@ +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; +} + +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); + } + }, [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..2e57d80157 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 }; /** @@ -26,7 +26,9 @@ export const EcologicalUnitsForm = () => { const critterbaseApi = useCritterbaseApi(); - const ecologicalUnitsDataLoader = useDataLoader((tsn: number) => critterbaseApi.xref.getTsnCollectionCategories(tsn)); + const ecologicalUnitsDataLoader = useDataLoader((tsn: number) => + critterbaseApi.xref.getTsnCollectionCategories([tsn]) + ); useEffect(() => { if (values.species?.tsn) { @@ -46,6 +48,9 @@ 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..35f1316c50 --- /dev/null +++ b/app/src/features/surveys/animals/animal-form/components/general-information/components/SelectedAnimalSpecies.tsx @@ -0,0 +1,32 @@ +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; +} + +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..91d0087c28 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: [ @@ -122,14 +120,6 @@ const GeneralInformationForm: React.FC = (props) = /> - - - 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 100% rename from app/src/features/surveys/SurveyPermitForm.tsx rename to app/src/features/surveys/components/permit/SurveyPermitForm.tsx diff --git a/app/src/features/surveys/components/sampling-strategy/SurveySiteSelectionForm.tsx b/app/src/features/surveys/components/sampling-strategy/SurveySiteSelectionForm.tsx index add11ca6f1..0c001ab212 100644 --- a/app/src/features/surveys/components/sampling-strategy/SurveySiteSelectionForm.tsx +++ b/app/src/features/surveys/components/sampling-strategy/SurveySiteSelectionForm.tsx @@ -116,7 +116,7 @@ const SurveySiteSelectionForm = (props: ISurveySiteSelectionFormProps) => { /> + + Were any permits used in this survey? + + + } + /> + + + + + Do any funding agencies require this survey to be submitted? + + + } + /> + + + - - Does a funding agency require this survey to be submitted? - - - } - /> - - - { diff --git a/app/src/hooks/api/useTaxonomyApi.ts b/app/src/hooks/api/useTaxonomyApi.ts index 6aa8d92d52..f5583b3ab3 100644 --- a/app/src/hooks/api/useTaxonomyApi.ts +++ b/app/src/hooks/api/useTaxonomyApi.ts @@ -66,6 +66,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 @@ -74,7 +80,7 @@ const parseSearchResponse = (searchResponse: T[]): T return searchResponse.map((taxon) => ({ ...taxon, commonNames: taxon.commonNames.map((commonName) => startCase(commonName)), - scientificName: startCase(taxon.scientificName) + scientificName: taxon.scientificName })); }; diff --git a/app/src/hooks/cb_api/useXrefApi.tsx b/app/src/hooks/cb_api/useXrefApi.tsx index 14056c47ad..39c81e4f64 100644 --- a/app/src/hooks/cb_api/useXrefApi.tsx +++ b/app/src/hooks/cb_api/useXrefApi.tsx @@ -5,6 +5,7 @@ import { ICollectionCategory, ICollectionUnit } from 'interfaces/useCritterApi.interface'; +import qs from 'qs'; export const useXrefApi = (axios: AxiosInstance) => { /** @@ -34,16 +35,22 @@ export const useXrefApi = (axios: AxiosInstance) => { /** * Get collection (ie. ecological) units that are available for a given taxon (by itis tsn). * - * @param {number} tsn + * @param {number[]} tsns * @return {*} {Promise} */ - const getTsnCollectionCategories = async (tsn: number): Promise => { - const { data } = await axios.get(`/api/critterbase/xref/taxon-collection-categories?tsn=${tsn}`); + const getTsnCollectionCategories = async (tsns: number[]): Promise => { + const { data } = await axios.get('/api/critterbase/xref/taxon-collection-categories', { + params: { tsn: tsns }, + paramsSerializer: (params: any) => { + return qs.stringify(params); + } + }); + return data; }; /** - * Get collection (ie. ecological) units that are available for a given taxon + * Get collection (ie. ecological) units for a unit category * * @param {string} unit_id * @return {*} {Promise} diff --git a/app/src/interfaces/useSurveyApi.interface.ts b/app/src/interfaces/useSurveyApi.interface.ts index ec55a2ad41..0320a38066 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, ISpeciesWithEcologicalUnits } 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 { ApiPaginationResponseParams, StringBoolean } from 'types/misc'; import { ICritterDetailedResponse, ICritterSimpleResponse } from './useCritterApi.interface'; @@ -185,7 +185,7 @@ export type IUpdateSurveyRequest = ISurveyLocationForm & { revision_count: number; }; species: { - focal_species: IPartialTaxonomy[]; + focal_species: ISpeciesWithEcologicalUnits[]; }; permit: { permits: { @@ -359,7 +359,7 @@ export interface IGetSurveyForUpdateResponse { revision_count: number; }; species: { - focal_species: IPartialTaxonomy[]; + focal_species: ISpeciesWithEcologicalUnits[]; }; permit: { permits: { 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..3d062d3705 --- /dev/null +++ b/database/src/migrations/20240809140000_study_species_units.ts @@ -0,0 +1,64 @@ +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(` + 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, + description VARCHAR(1000), + 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 description associated with the record.'; + 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(``); +} From 478060dfe86f720ad679a64f281fa33b529187e1 Mon Sep 17 00:00:00 2001 From: Macgregor Aubertin-Young Date: Mon, 19 Aug 2024 17:58:54 -0700 Subject: [PATCH 2/6] fix broken tests --- api/src/models/survey-create.ts | 4 +- api/src/openapi/schemas/critter.ts | 6 +- api/src/openapi/schemas/survey.ts | 9 +- .../survey/{surveyId}/update/get.test.ts | 2 +- api/src/repositories/survey-repository.ts | 12 +- api/src/services/critterbase-service.ts | 8 ++ api/src/services/platform-service.ts | 6 +- api/src/services/survey-service.test.ts | 73 +++++++++-- api/src/services/survey-service.ts | 10 +- .../species/FocalSpeciesComponent.tsx | 83 ------------ .../components/SelectedSurveySpecies.tsx | 122 +++++++++--------- .../species/components/SpeciesCard.tsx | 3 +- .../components/SpeciesSelectedCard.tsx | 8 +- .../EcologicalUnitsSelect.tsx | 7 + .../ecological-units/EcologicalUnitsForm.tsx | 6 +- .../components/SelectedAnimalSpecies.tsx | 6 + .../components/species/SpeciesForm.tsx | 49 ++++++- .../components/FocalSpeciesComponent.tsx | 78 +++++++++++ app/src/hooks/cb_api/useXrefApi.tsx | 10 +- app/src/interfaces/useCritterApi.interface.ts | 5 + app/src/interfaces/useSurveyApi.interface.ts | 6 +- .../20240809140000_study_species_units.ts | 4 +- 22 files changed, 310 insertions(+), 207 deletions(-) delete mode 100644 app/src/components/species/FocalSpeciesComponent.tsx create mode 100644 app/src/features/surveys/components/species/components/FocalSpeciesComponent.tsx diff --git a/api/src/models/survey-create.ts b/api/src/models/survey-create.ts index 7960e64979..1e4fbffb40 100644 --- a/api/src/models/survey-create.ts +++ b/api/src/models/survey-create.ts @@ -152,7 +152,7 @@ export class PostAgreementsData { } } -export const SpeciesWithEcologicalUnits = z.object({ +export const TaxonomyWithEcologicalUnits = z.object({ itis_tsn: z.number(), ecological_units: z.array( z.object({ @@ -162,4 +162,4 @@ export const SpeciesWithEcologicalUnits = z.object({ ) }); -export type SpeciesWithEcologicalUnits = z.infer; +export type TaxonomyWithEcologicalUnits = z.infer; diff --git a/api/src/openapi/schemas/critter.ts b/api/src/openapi/schemas/critter.ts index 812c34c270..139dfcfd59 100644 --- a/api/src/openapi/schemas/critter.ts +++ b/api/src/openapi/schemas/critter.ts @@ -1,15 +1,11 @@ import { OpenAPIV3 } from 'openapi-types'; - export const collectionUnitsSchema: OpenAPIV3.SchemaObject = { type: 'array', items: { type: 'object', additionalProperties: false, - required: [ - 'collection_category_id', - 'collection_unit_id', - ], + required: ['collection_category_id', 'collection_unit_id'], properties: { critter_collection_unit_id: { type: 'string', diff --git a/api/src/openapi/schemas/survey.ts b/api/src/openapi/schemas/survey.ts index 91f4338375..6e9a35a298 100644 --- a/api/src/openapi/schemas/survey.ts +++ b/api/src/openapi/schemas/survey.ts @@ -55,7 +55,12 @@ export const surveyDetailsSchema: OpenAPIV3.SchemaObject = { } }; -export const SurveyCollectionUnitsSchema: 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', @@ -157,7 +162,7 @@ export const focalSpeciesSchema: OpenAPIV3.SchemaObject = { description: 'Taxonomy kingdom name', type: 'string' }, - ecological_units: SurveyCollectionUnitsSchema + ecological_units: SurveyEcologicalUnitsSchema } }; diff --git a/api/src/paths/project/{projectId}/survey/{surveyId}/update/get.test.ts b/api/src/paths/project/{projectId}/survey/{surveyId}/update/get.test.ts index 86751b2c87..98d4b7a284 100644 --- a/api/src/paths/project/{projectId}/survey/{surveyId}/update/get.test.ts +++ b/api/src/paths/project/{projectId}/survey/{surveyId}/update/get.test.ts @@ -2,11 +2,11 @@ import chai, { expect } from 'chai'; import { describe } from 'mocha'; import sinon from 'sinon'; import sinonChai from 'sinon-chai'; -import { getMockDBConnection } from '../../../../../../__mocks__/db'; import * as db from '../../../../../../database/db'; import { HTTPError } from '../../../../../../errors/http-error'; import { SurveyObject } from '../../../../../../models/survey-view'; import { SurveyService } from '../../../../../../services/survey-service'; +import { getMockDBConnection } from '../../../../../../__mocks__/db'; import * as get from './get'; chai.use(sinonChai); diff --git a/api/src/repositories/survey-repository.ts b/api/src/repositories/survey-repository.ts index 98efa72665..8a13c56fc8 100644 --- a/api/src/repositories/survey-repository.ts +++ b/api/src/repositories/survey-repository.ts @@ -3,7 +3,7 @@ import SQL from 'sql-template-strings'; import { z } from 'zod'; import { getKnex } from '../database/db'; import { ApiExecuteSQLError } from '../errors/api-error'; -import { PostProprietorData, PostSurveyObject, SpeciesWithEcologicalUnits } from '../models/survey-create'; +import { PostProprietorData, PostSurveyObject, TaxonomyWithEcologicalUnits } from '../models/survey-create'; import { PutSurveyObject } from '../models/survey-update'; import { FindSurveysResponse, @@ -13,7 +13,7 @@ import { GetSurveyPurposeAndMethodologyData, ISurveyAdvancedFilters } from '../models/survey-view'; -import { IPostCollectionUnit } from '../services/platform-service'; +import { IPostCollectionUnit } from '../services/critterbase-service'; import { ApiPaginationOptions } from '../zod-schema/pagination'; import { BaseRepository } from './base-repository'; @@ -361,7 +361,7 @@ export class SurveyRepository extends BaseRepository { * @returns {*} {Promise} * @memberof SurveyRepository */ - async getSpeciesData(surveyId: number): Promise { + async getSpeciesData(surveyId: number): Promise { const sqlStatement = SQL` WITH ecological_units AS ( SELECT @@ -392,7 +392,7 @@ export class SurveyRepository extends BaseRepository { ss.survey_id = ${surveyId}; `; - const response = await this.connection.sql(sqlStatement, SpeciesWithEcologicalUnits); + const response = await this.connection.sql(sqlStatement, TaxonomyWithEcologicalUnits); return response.rows; } @@ -802,8 +802,6 @@ export class SurveyRepository extends BaseRepository { ) RETURNING study_species_id AS id; `; - console.log(ecologicalUnitObject); - const response = await this.connection.sql(sqlStatement); const result = response.rows?.[0]; @@ -1060,7 +1058,7 @@ export class SurveyRepository extends BaseRepository { } /** - * Deletes Survey species data for a given survey ID + * Deletes ecological units data for focal species in a given survey ID * * @param {number} surveyId * @returns {*} Promise diff --git a/api/src/services/critterbase-service.ts b/api/src/services/critterbase-service.ts index dd1d16bbb4..c1e7183061 100644 --- a/api/src/services/critterbase-service.ts +++ b/api/src/services/critterbase-service.ts @@ -243,6 +243,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/platform-service.ts b/api/src/services/platform-service.ts index 28d7d8958f..35157021bc 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'; @@ -57,11 +58,6 @@ export interface ITaxonomy { kingdom: string; } -export interface IPostCollectionUnit { - critterbase_collection_unit_id: string; - critterbase_collection_category_id: string; -} - export interface ITaxonomyWithEcologicalUnits extends ITaxonomy { ecological_units: IPostCollectionUnit[]; } diff --git a/api/src/services/survey-service.test.ts b/api/src/services/survey-service.test.ts index c35a50953a..7eaf03155a 100644 --- a/api/src/services/survey-service.test.ts +++ b/api/src/services/survey-service.test.ts @@ -7,7 +7,7 @@ import sinon from 'sinon'; import sinonChai from 'sinon-chai'; import { ApiGeneralError } from '../errors/api-error'; import { GetReportAttachmentsData } from '../models/project-view'; -import { PostProprietorData, PostSurveyObject } from '../models/survey-create'; +import { PostProprietorData, PostSurveyObject, TaxonomyWithEcologicalUnits } from '../models/survey-create'; import { PostSurveyLocationData, PutSurveyObject, PutSurveyPermitData } from '../models/survey-update'; import { GetAttachmentsData, @@ -21,7 +21,6 @@ import { FundingSourceRepository } from '../repositories/funding-source-reposito import { IPermitModel } from '../repositories/permit-repository'; import { SurveyLocationRecord, SurveyLocationRepository } from '../repositories/survey-location-repository'; import { - IGetSpeciesData, ISurveyProprietorModel, SurveyRecord, SurveyRepository, @@ -358,22 +357,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 TaxonomyWithEcologicalUnits[]; + 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 +596,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 +734,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 7c808f7c29..f836511363 100644 --- a/api/src/services/survey-service.ts +++ b/api/src/services/survey-service.ts @@ -178,7 +178,12 @@ export class SurveyService extends DBService { ); // Create a lookup map for taxonomy data, to be used for injecting ecological units for each study species - const taxonomyMap = new Map(response.map((taxonomy) => [Number(taxonomy.tsn), taxonomy])); + const taxonomyMap = new Map( + response.map((taxonomy) => { + const taxon = { ...taxonomy, tsn: Number(taxonomy.tsn) }; + return [Number(taxonomy.tsn), taxon]; + }) + ); // Combine species data with taxonomy data and ecological units const focalSpecies = studySpeciesResponse.map((species) => ({ @@ -387,8 +392,7 @@ export class SurveyService extends DBService { promises.push( Promise.all( postSurveyData.species.focal_species.map((species: ITaxonomyWithEcologicalUnits) => { - const units = species.ecological_units; - if (units.length) { + if (species.ecological_units.length) { this.insertFocalSpeciesWithUnits(species, surveyId); } else { this.insertFocalSpecies(species.tsn, surveyId); diff --git a/app/src/components/species/FocalSpeciesComponent.tsx b/app/src/components/species/FocalSpeciesComponent.tsx deleted file mode 100644 index 2f25036c95..0000000000 --- a/app/src/components/species/FocalSpeciesComponent.tsx +++ /dev/null @@ -1,83 +0,0 @@ -import Stack from '@mui/material/Stack'; -import AlertBar from 'components/alert/AlertBar'; -import { ISpeciesWithEcologicalUnits } from 'features/surveys/components/species/SpeciesForm'; -import { 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 get from 'lodash-es/get'; -import { useEffect } from 'react'; -import SelectedSpecies from './components/SelectedSurveySpecies'; -import SpeciesAutocompleteField from './components/SpeciesAutocompleteField'; - -const FocalSpeciesComponent = () => { - const { values, setFieldValue, setFieldError, errors, submitCount } = useFormikContext< - ICreateSurveyRequest | IEditSurveyRequest - >(); - - const selectedSpecies: ISpeciesWithEcologicalUnits[] = get(values, 'species.focal_species') || []; - const critterbaseApi = useCritterbaseApi(); - - const ecologicalUnitDataLoader = useDataLoader((tsn: number[]) => - critterbaseApi.xref.getTsnCollectionCategories(tsn) - ); - - const selectedSpeciesTsns = selectedSpecies.map((species) => species.tsn); - - useEffect(() => { - if (selectedSpeciesTsns) { - ecologicalUnitDataLoader.load(selectedSpeciesTsns); - } - // Should not re-run this effect on `ecologicalUnitsDataLoader.refresh` changes - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [selectedSpeciesTsns]); - - const handleAddSpecies = (species?: IPartialTaxonomy) => { - // Add species with empty ecological units array - setFieldValue(`species.focal_species[${selectedSpecies.length}]`, { ...species, ecological_units: [] }); - setFieldError(`species.focal_species`, undefined); - // Fetch ecological units for new species - if (species) { - ecologicalUnitDataLoader.refresh([...selectedSpeciesTsns, species.tsn]); - } - }; - - const handleRemoveSpecies = (species_id: number) => { - const filteredSpecies = selectedSpecies.filter((value: ISpeciesWithEcologicalUnits) => { - return value.tsn !== species_id; - }); - setFieldValue('species.focal_species', filteredSpecies); - }; - - return ( - - {submitCount > 0 && errors && get(errors, 'species.focal_species') && ( - - )} - - {selectedSpecies.length > 0 && ( - - )} - - ); -}; - -export default FocalSpeciesComponent; diff --git a/app/src/components/species/components/SelectedSurveySpecies.tsx b/app/src/components/species/components/SelectedSurveySpecies.tsx index ab7986879a..74d7108cf5 100644 --- a/app/src/components/species/components/SelectedSurveySpecies.tsx +++ b/app/src/components/species/components/SelectedSurveySpecies.tsx @@ -2,8 +2,6 @@ import { mdiPlus } from '@mdi/js'; import Icon from '@mdi/react'; import Box from '@mui/material/Box'; import Button from '@mui/material/Button'; -import CircularProgress from '@mui/material/CircularProgress'; -import Collapse from '@mui/material/Collapse'; import grey from '@mui/material/colors/grey'; import Paper from '@mui/material/Paper'; import Stack from '@mui/material/Stack'; @@ -11,83 +9,79 @@ import SpeciesSelectedCard from 'components/species/components/SpeciesSelectedCa 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 { ICollectionCategory } from 'interfaces/useCritterApi.interface'; +import { useCritterbaseApi } from 'hooks/useCritterbaseApi'; +import useDataLoader from 'hooks/useDataLoader'; import { ICreateSurveyRequest, IEditSurveyRequest } from 'interfaces/useSurveyApi.interface'; import { IPartialTaxonomy } from 'interfaces/useTaxonomyApi.interface'; -import { TransitionGroup } from 'react-transition-group'; +import { useEffect } from 'react'; export interface ISelectedSpeciesProps { name: string; - selectedSpecies: IPartialTaxonomy[]; + selectedSpecies: IPartialTaxonomy; handleRemoveSpecies?: (species_id: number) => void; - ecologicalUnits?: ICollectionCategory[]; - isLoading?: boolean; + speciesIndex: number; } const SelectedSurveySpecies = (props: ISelectedSpeciesProps) => { - const { selectedSpecies, handleRemoveSpecies, name, ecologicalUnits, isLoading } = props; + const { name, selectedSpecies, speciesIndex, handleRemoveSpecies } = props; - const { values } = useFormikContext(); + const { values } = useFormikContext(); - return ( - - {selectedSpecies.map((species, speciesIndex) => { - const ecologicalUnitsForSpecies = - ecologicalUnits?.filter((category) => category.itis_tsn === species.tsn) ?? []; - const selectedUnits = values.species.focal_species[speciesIndex]?.ecological_units; + const critterbaseApi = useCritterbaseApi(); + + const ecologicalUnitDataLoader = useDataLoader((tsn: number) => critterbaseApi.xref.getTsnCollectionCategories(tsn)); + + useEffect(() => { + ecologicalUnitDataLoader.load(selectedSpecies.tsn); + }); - return ( - - - + const ecologicalUnitsForSpecies = ecologicalUnitDataLoader.data ?? []; + + const selectedUnits = + values.species.focal_species + .filter((species) => species.tsn === selectedSpecies.tsn) + .flatMap((species) => species.ecological_units) ?? []; + + return ( + + - ( - - {selectedUnits.map((ecological_unit, ecologicalUnitIndex) => ( - unit.critterbase_collection_category_id - )} - ecologicalUnits={ecologicalUnitsForSpecies} - arrayHelpers={arrayHelpers} - index={ecologicalUnitIndex} - /> - ))} - - {isLoading ? ( - - ) : ( - - )} - - + ( + + {selectedUnits.map((ecological_unit, ecologicalUnitIndex) => ( + unit.critterbase_collection_category_id )} + ecologicalUnits={ecologicalUnitsForSpecies} + arrayHelpers={arrayHelpers} + index={ecologicalUnitIndex} /> - - - ); - })} - + ))} + + + + + )} + /> + ); }; diff --git a/app/src/components/species/components/SpeciesCard.tsx b/app/src/components/species/components/SpeciesCard.tsx index 2e88c176f4..a4f11013aa 100644 --- a/app/src/components/species/components/SpeciesCard.tsx +++ b/app/src/components/species/components/SpeciesCard.tsx @@ -35,9 +35,8 @@ const SpeciesCard = (props: ISpeciesCardProps) => { { const { index, species, handleRemove } = props; return ( - - + + + + {handleRemove && ( - + { const { index, ecologicalUnits, arrayHelpers, categoryFieldName, unitFieldName, selectedCategoryIds } = props; const { setFieldValue } = useFormikContext(); @@ -36,6 +42,7 @@ export const EcologicalUnitsSelect = (props: EcologicalUnitsSelectProps) => { if (selectedCategoryId) { ecologicalUnitOptionsLoader.refresh(selectedCategoryId); } + // eslint-disable-next-line react-hooks/exhaustive-deps }, [selectedCategoryId]); // Memoized label for the selected ecological unit 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 2e57d80157..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 @@ -26,9 +26,7 @@ export const EcologicalUnitsForm = () => { const critterbaseApi = useCritterbaseApi(); - const ecologicalUnitsDataLoader = useDataLoader((tsn: number) => - critterbaseApi.xref.getTsnCollectionCategories([tsn]) - ); + const ecologicalUnitsDataLoader = useDataLoader((tsn: number) => critterbaseApi.xref.getTsnCollectionCategories(tsn)); useEffect(() => { if (values.species?.tsn) { @@ -51,7 +49,7 @@ export const EcologicalUnitsForm = () => { categoryFieldName={`ecological_units[${index}].collection_category_id`} unitFieldName={`ecological_units[${index}].collection_unit_id`} selectedCategoryIds={values.ecological_units.map((unit) => unit.collection_category_id)} - ecologicalUnits={ecologicalUnitsDataLoader.data ?? []} + ecologicalUnits={ecologicalUnitsDataLoader?.data ?? []} arrayHelpers={arrayHelpers} index={index} /> 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 index 35f1316c50..122d0f7b15 100644 --- 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 @@ -11,6 +11,12 @@ export interface ISelectedAnimalSpeciesProps { handleRemoveSpecies?: (species_id: number) => void; } +/** + * Returns a stack of selected species cards. + * + * @param props {ISelectedAnimalSpeciesProps} + * @returns + */ const SelectedAnimalSpecies = (props: ISelectedAnimalSpeciesProps) => { const { selectedSpecies, handleRemoveSpecies } = props; diff --git a/app/src/features/surveys/components/species/SpeciesForm.tsx b/app/src/features/surveys/components/species/SpeciesForm.tsx index 0d3950ae42..2d5adadfbc 100644 --- a/app/src/features/surveys/components/species/SpeciesForm.tsx +++ b/app/src/features/surveys/components/species/SpeciesForm.tsx @@ -1,21 +1,21 @@ import Box from '@mui/material/Box'; import Grid from '@mui/material/Grid'; -import FocalSpeciesComponent from 'components/species/FocalSpeciesComponent'; import { IPartialTaxonomy } from 'interfaces/useTaxonomyApi.interface'; import yup from 'utils/YupSchema'; +import FocalSpeciesComponent from './components/FocalSpeciesComponent'; export type PostCollectionUnit = { critterbase_collection_category_id: string; critterbase_collection_unit_id: string; }; -export interface ISpeciesWithEcologicalUnits extends IPartialTaxonomy { +export interface ITaxonomyWithEcologicalUnits extends IPartialTaxonomy { ecological_units: PostCollectionUnit[]; } export interface ISpeciesForm { species: { - focal_species: ISpeciesWithEcologicalUnits[]; + focal_species: ITaxonomyWithEcologicalUnits[]; }; } @@ -27,7 +27,48 @@ export const SpeciesInitialValues: ISpeciesForm = { export const SpeciesYupSchema = yup.object().shape({ species: yup.object().shape({ - focal_species: yup.array().min(1, 'You must specify a focal species').required('Required') + focal_species: yup + .array() + .of( + yup.object().shape({ + ecological_units: yup + .array() + .of( + yup.object().shape({ + critterbase_collection_category_id: yup + .string() + .test( + 'is-unique-ecological-unit', + 'Ecological unit must be unique', + function (collection_category_id) { + const formValues = this.options.context; + + if (!formValues?.ecological_units?.length) { + return true; + } + + return ( + formValues.ecological_units.filter( + (ecologicalUnit: PostCollectionUnit) => + ecologicalUnit.critterbase_collection_category_id === collection_category_id + ).length <= 1 + ); + } + ) + .required('Ecological unit is required') + .nullable(), + critterbase_collection_unit_id: yup.string().when('critterbase_collection_category_id', { + is: (critterbase_collection_category_id: string) => critterbase_collection_category_id !== null, + then: yup.string().required('Ecological unit is required').nullable(), + otherwise: yup.string().nullable() + }) + }) + ) + .nullable() + }) + ) + .min(1, 'You must specify a focal species') + .required('Required') }) }); diff --git a/app/src/features/surveys/components/species/components/FocalSpeciesComponent.tsx b/app/src/features/surveys/components/species/components/FocalSpeciesComponent.tsx new file mode 100644 index 0000000000..6a00415616 --- /dev/null +++ b/app/src/features/surveys/components/species/components/FocalSpeciesComponent.tsx @@ -0,0 +1,78 @@ +import Collapse from '@mui/material/Collapse'; +import Stack from '@mui/material/Stack'; +import AlertBar from 'components/alert/AlertBar'; +import { ITaxonomyWithEcologicalUnits } from 'features/surveys/components/species/SpeciesForm'; +import { useFormikContext } from 'formik'; +import { ICreateSurveyRequest, IEditSurveyRequest } from 'interfaces/useSurveyApi.interface'; +import { IPartialTaxonomy } from 'interfaces/useTaxonomyApi.interface'; +import get from 'lodash-es/get'; +import { TransitionGroup } from 'react-transition-group'; +import SelectedSurveySpecies from '../../../../../components/species/components/SelectedSurveySpecies'; +import SpeciesAutocompleteField from '../../../../../components/species/components/SpeciesAutocompleteField'; + +/** + * Returns an autocomplete component for selecting focal species and ecological units for + * each focal species on the survey form + * + * @returns + */ +const FocalSpeciesComponent = () => { + const { values, setFieldValue, setFieldError, errors, submitCount } = useFormikContext< + ICreateSurveyRequest | IEditSurveyRequest + >(); + + const selectedSpecies: ITaxonomyWithEcologicalUnits[] = get(values, 'species.focal_species') || []; + + const handleAddSpecies = (species?: IPartialTaxonomy) => { + // Add species with empty ecological units array + setFieldValue(`species.focal_species[${selectedSpecies.length}]`, { ...species, ecological_units: [] }); + setFieldError(`species.focal_species`, undefined); + }; + + const handleRemoveSpecies = (species_id: number) => { + const filteredSpecies = selectedSpecies.filter((value: ITaxonomyWithEcologicalUnits) => { + return value.tsn !== species_id; + }); + setFieldValue('species.focal_species', filteredSpecies); + }; + + console.log(errors); + + return ( + + {submitCount > 0 && + errors && + get(errors, 'species.focal_species') && + !Array.isArray(get(errors, 'species.focal_species')) && ( + + )} + + + + {selectedSpecies.map((species, index) => ( + + + + ))} + + + ); +}; + +export default FocalSpeciesComponent; diff --git a/app/src/hooks/cb_api/useXrefApi.tsx b/app/src/hooks/cb_api/useXrefApi.tsx index 39c81e4f64..75db75a59d 100644 --- a/app/src/hooks/cb_api/useXrefApi.tsx +++ b/app/src/hooks/cb_api/useXrefApi.tsx @@ -35,13 +35,13 @@ export const useXrefApi = (axios: AxiosInstance) => { /** * Get collection (ie. ecological) units that are available for a given taxon (by itis tsn). * - * @param {number[]} tsns + * @param {number} tsn * @return {*} {Promise} */ - const getTsnCollectionCategories = async (tsns: number[]): Promise => { + const getTsnCollectionCategories = async (tsn: number): Promise => { const { data } = await axios.get('/api/critterbase/xref/taxon-collection-categories', { - params: { tsn: tsns }, - paramsSerializer: (params: any) => { + params: { tsn }, + paramsSerializer: (params) => { return qs.stringify(params); } }); @@ -50,7 +50,7 @@ export const useXrefApi = (axios: AxiosInstance) => { }; /** - * Get collection (ie. ecological) units for a unit category + * 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 f39a325111..042554d14a 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 0320a38066..6f543f5275 100644 --- a/app/src/interfaces/useSurveyApi.interface.ts +++ b/app/src/interfaces/useSurveyApi.interface.ts @@ -9,7 +9,7 @@ import { IGeneralInformationForm } from 'features/surveys/components/general-inf import { ISurveyLocationForm } from 'features/surveys/components/locations/StudyAreaForm'; import { IPurposeAndMethodologyForm } from 'features/surveys/components/methodology/PurposeAndMethodologyForm'; import { ISurveyPermitForm } from 'features/surveys/components/permit/SurveyPermitForm'; -import { ISpeciesForm, ISpeciesWithEcologicalUnits } from 'features/surveys/components/species/SpeciesForm'; +import { ISpeciesForm, ITaxonomyWithEcologicalUnits } from 'features/surveys/components/species/SpeciesForm'; import { ISurveyPartnershipsForm } from 'features/surveys/view/components/SurveyPartnershipsForm'; import { Feature } from 'geojson'; import { ITaxonomy } from 'interfaces/useTaxonomyApi.interface'; @@ -185,7 +185,7 @@ export type IUpdateSurveyRequest = ISurveyLocationForm & { revision_count: number; }; species: { - focal_species: ISpeciesWithEcologicalUnits[]; + focal_species: ITaxonomyWithEcologicalUnits[]; }; permit: { permits: { @@ -359,7 +359,7 @@ export interface IGetSurveyForUpdateResponse { revision_count: number; }; species: { - focal_species: ISpeciesWithEcologicalUnits[]; + focal_species: ITaxonomyWithEcologicalUnits[]; }; permit: { permits: { diff --git a/database/src/migrations/20240809140000_study_species_units.ts b/database/src/migrations/20240809140000_study_species_units.ts index 3d062d3705..e87815ffb4 100644 --- a/database/src/migrations/20240809140000_study_species_units.ts +++ b/database/src/migrations/20240809140000_study_species_units.ts @@ -20,7 +20,6 @@ export async function up(knex: Knex): Promise { study_species_id integer NOT NULL, critterbase_collection_category_id UUID NOT NULL, critterbase_collection_unit_id UUID NOT NULL, - description VARCHAR(1000), create_date timestamptz(6) DEFAULT now() NOT NULL, create_user integer NOT NULL, update_date timestamptz(6), @@ -34,14 +33,13 @@ export async function up(knex: Knex): Promise { 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 description associated with the record.'; 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 + -- 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 From 6e5459a5dc5b6ddbdc8b5d012ec42912368735e3 Mon Sep 17 00:00:00 2001 From: Macgregor Aubertin-Young Date: Wed, 28 Aug 2024 11:37:59 -0700 Subject: [PATCH 3/6] address code smells --- .../GeneralInformationForm.tsx | 58 +++++++++---------- .../components/permit/SurveyPermitForm.tsx | 17 ++---- 2 files changed, 33 insertions(+), 42 deletions(-) diff --git a/app/src/features/surveys/components/general-information/GeneralInformationForm.tsx b/app/src/features/surveys/components/general-information/GeneralInformationForm.tsx index 91d0087c28..5156d7680a 100644 --- a/app/src/features/surveys/components/general-information/GeneralInformationForm.tsx +++ b/app/src/features/surveys/components/general-information/GeneralInformationForm.tsx @@ -90,37 +90,35 @@ export interface IGeneralInformationFormProps { */ const GeneralInformationForm: React.FC = (props) => { return ( - <> - - - - - - - - - - + + + - + + + + + + + ); }; diff --git a/app/src/features/surveys/components/permit/SurveyPermitForm.tsx b/app/src/features/surveys/components/permit/SurveyPermitForm.tsx index 15a3c88a5d..b7a5e1a085 100644 --- a/app/src/features/surveys/components/permit/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 ( - + Date: Wed, 4 Sep 2024 18:38:12 -0700 Subject: [PATCH 4/6] Fix react exhaustive deps warnings --- .../ObservationsTableContainer.tsx | 28 ++++++++++++++----- 1 file changed, 21 insertions(+), 7 deletions(-) diff --git a/app/src/features/surveys/observations/observations-table/ObservationsTableContainer.tsx b/app/src/features/surveys/observations/observations-table/ObservationsTableContainer.tsx index d01dd564b0..24e300a0b7 100644 --- a/app/src/features/surveys/observations/observations-table/ObservationsTableContainer.tsx +++ b/app/src/features/surveys/observations/observations-table/ObservationsTableContainer.tsx @@ -57,12 +57,19 @@ const ObservationsTableContainer = () => { const observationsTableContext = useObservationsTableContext(); // Collect sample sites - const surveySampleSites: IGetSampleLocationDetails[] = surveyContext.sampleSiteDataLoader.data?.sampleSites ?? []; - const sampleSiteOptions: ISampleSiteOption[] = - surveySampleSites.map((site) => ({ - survey_sample_site_id: site.survey_sample_site_id, - sample_site_name: site.name - })) ?? []; + const surveySampleSites: IGetSampleLocationDetails[] = useMemo( + () => surveyContext.sampleSiteDataLoader.data?.sampleSites ?? [], + [surveyContext.sampleSiteDataLoader.data?.sampleSites] + ); + + const sampleSiteOptions: ISampleSiteOption[] = useMemo( + () => + surveySampleSites.map((site) => ({ + survey_sample_site_id: site.survey_sample_site_id, + sample_site_name: site.name + })) ?? [], + [surveySampleSites] + ); // Collect sample methods const surveySampleMethods: IGetSampleMethodDetails[] = surveySampleSites @@ -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 ( From 2d14585211dc4e5fa54c0d0032620799895a4816 Mon Sep 17 00:00:00 2001 From: Nick Phura Date: Wed, 4 Sep 2024 18:38:37 -0700 Subject: [PATCH 5/6] ignore-skip From c3e219d0c41fc80d62ab61f1c2bdedc7bcac3d33 Mon Sep 17 00:00:00 2001 From: Nick Phura Date: Thu, 5 Sep 2024 17:24:10 -0700 Subject: [PATCH 6/6] Updates/fixes/tweaks --- api/src/models/survey-create.ts | 13 --- .../observations/taxon/index.test.ts | 6 +- .../{surveyId}/observations/taxon/index.ts | 2 +- .../repositories/survey-repository.test.ts | 39 ++++++-- api/src/repositories/survey-repository.ts | 26 +++-- .../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 | 2 +- api/src/services/standards-service.test.ts | 2 +- api/src/services/survey-service.test.ts | 9 +- api/src/services/survey-service.ts | 23 ++--- .../components/SelectedSurveySpecies.tsx | 88 ----------------- .../components/SpeciesAutocompleteField.tsx | 2 +- .../components/species/SpeciesForm.tsx | 75 +++++++-------- .../species/components/FocalSpeciesAlert.tsx | 25 +++++ .../components/FocalSpeciesComponent.tsx | 78 --------------- .../FocalSpeciesEcologicalUnitsForm.tsx | 94 +++++++++++++++++++ .../species/components/FocalSpeciesForm.tsx | 69 ++++++++++++++ .../features/surveys/edit/EditSurveyForm.tsx | 5 +- .../ObservationRowValidationUtils.ts | 2 +- .../components/animal/SurveySpatialAnimal.tsx | 2 +- .../observation/SurveySpatialObservation.tsx | 4 +- .../telemetry/SurveySpatialTelemetry.tsx | 2 +- .../interfaces/useTaxonomyApi.interface.ts | 11 --- .../20240809140000_study_species_units.ts | 20 ++-- 26 files changed, 317 insertions(+), 290 deletions(-) delete mode 100644 app/src/components/species/components/SelectedSurveySpecies.tsx create mode 100644 app/src/features/surveys/components/species/components/FocalSpeciesAlert.tsx delete mode 100644 app/src/features/surveys/components/species/components/FocalSpeciesComponent.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 diff --git a/api/src/models/survey-create.ts b/api/src/models/survey-create.ts index 1e4fbffb40..746fe7f5cf 100644 --- a/api/src/models/survey-create.ts +++ b/api/src/models/survey-create.ts @@ -1,4 +1,3 @@ -import { z } from 'zod'; import { SurveyStratum } from '../repositories/site-selection-strategy-repository'; import { PostSurveyBlock } from '../repositories/survey-block-repository'; import { ITaxonomyWithEcologicalUnits } from '../services/platform-service'; @@ -151,15 +150,3 @@ export class PostAgreementsData { this.sedis_procedures_accepted = obj?.sedis_procedures_accepted === 'true' || false; } } - -export const TaxonomyWithEcologicalUnits = 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 TaxonomyWithEcologicalUnits = z.infer; 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 8a13c56fc8..83c2193590 100644 --- a/api/src/repositories/survey-repository.ts +++ b/api/src/repositories/survey-repository.ts @@ -3,7 +3,7 @@ import SQL from 'sql-template-strings'; import { z } from 'zod'; import { getKnex } from '../database/db'; import { ApiExecuteSQLError } from '../errors/api-error'; -import { PostProprietorData, PostSurveyObject, TaxonomyWithEcologicalUnits } from '../models/survey-create'; +import { PostProprietorData, PostSurveyObject } from '../models/survey-create'; import { PutSurveyObject } from '../models/survey-update'; import { FindSurveysResponse, @@ -118,6 +118,18 @@ export const SurveyBasicFields = z.object({ export type SurveyBasicFields = z.infer; +export const SurveyTaxonomyWithEcologicalUnits = z.object({ + itis_tsn: z.number(), + ecological_units: z.array( + z.object({ + critterbase_collection_unit_id: z.string().uuid(), + critterbase_collection_category_id: z.string().uuid() + }) + ) +}); + +export type SurveyTaxonomyWithEcologicalUnits = z.infer; + export class SurveyRepository extends BaseRepository { /** * Deletes a survey and any associations for a given survey @@ -358,12 +370,12 @@ 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 ecological_units AS ( + WITH w_ecological_units AS ( SELECT ssu.study_species_id, json_agg( @@ -383,16 +395,16 @@ export class SurveyRepository extends BaseRepository { ) SELECT ss.itis_tsn, - COALESCE(eu.units, '[]'::json) AS ecological_units + COALESCE(weu.units, '[]'::json) AS ecological_units FROM study_species ss LEFT JOIN - ecological_units eu ON eu.study_species_id = ss.study_species_id + 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, TaxonomyWithEcologicalUnits); + const response = await this.connection.sql(sqlStatement, SurveyTaxonomyWithEcologicalUnits); return response.rows; } 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 35157021bc..506c2d74ec 100644 --- a/api/src/services/platform-service.ts +++ b/api/src/services/platform-service.ts @@ -45,7 +45,7 @@ export interface IArtifact { } export interface IItisSearchResult { - tsn: string; + tsn: number; commonNames?: string[]; scientificName: string; } 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 7eaf03155a..b10e809880 100644 --- a/api/src/services/survey-service.test.ts +++ b/api/src/services/survey-service.test.ts @@ -7,7 +7,7 @@ import sinon from 'sinon'; import sinonChai from 'sinon-chai'; import { ApiGeneralError } from '../errors/api-error'; import { GetReportAttachmentsData } from '../models/project-view'; -import { PostProprietorData, PostSurveyObject, TaxonomyWithEcologicalUnits } from '../models/survey-create'; +import { PostProprietorData, PostSurveyObject } from '../models/survey-create'; import { PostSurveyLocationData, PutSurveyObject, PutSurveyPermitData } from '../models/survey-update'; import { GetAttachmentsData, @@ -24,6 +24,7 @@ import { ISurveyProprietorModel, SurveyRecord, SurveyRepository, + SurveyTaxonomyWithEcologicalUnits, SurveyTypeRecord } from '../repositories/survey-repository'; import { getMockDBConnection } from '../__mocks__/db'; @@ -370,10 +371,10 @@ describe('SurveyService', () => { itis_tsn: 456, ecological_units: mockEcologicalUnits } - ] as unknown as TaxonomyWithEcologicalUnits[]; + ] as unknown as SurveyTaxonomyWithEcologicalUnits[]; const mockTaxonomyData = [ - { tsn: '123', scientificName: 'Species 1' }, - { tsn: '456', scientificName: 'Species 2' } + { tsn: 123, scientificName: 'Species 1' }, + { tsn: 456, scientificName: 'Species 2' } ]; const mockResponse = new GetFocalSpeciesData([ { tsn: 123, scientificName: 'Species 1', ecological_units: [] }, diff --git a/api/src/services/survey-service.ts b/api/src/services/survey-service.ts index 4987e63b67..c414dbf420 100644 --- a/api/src/services/survey-service.ts +++ b/api/src/services/survey-service.ts @@ -168,25 +168,20 @@ 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 response = await this.platformService.getTaxonomyByTsns( + // Fetch taxonomy data for each survey species + const taxonomyResponse = await this.platformService.getTaxonomyByTsns( studySpeciesResponse.map((species) => species.itis_tsn) ); - // Create a lookup map for taxonomy data, to be used for injecting ecological units for each study species - const taxonomyMap = new Map( - response.map((taxonomy) => { - const taxon = { ...taxonomy, tsn: Number(taxonomy.tsn) }; - return [Number(taxonomy.tsn), taxon]; - }) - ); + const focalSpecies = []; - // Combine species data with taxonomy data and ecological units - const focalSpecies = studySpeciesResponse.map((species) => ({ - ...taxonomyMap.get(species.itis_tsn), - ecological_units: species.ecological_units - })); + 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); @@ -305,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 }); diff --git a/app/src/components/species/components/SelectedSurveySpecies.tsx b/app/src/components/species/components/SelectedSurveySpecies.tsx deleted file mode 100644 index 74d7108cf5..0000000000 --- a/app/src/components/species/components/SelectedSurveySpecies.tsx +++ /dev/null @@ -1,88 +0,0 @@ -import { mdiPlus } from '@mdi/js'; -import Icon from '@mdi/react'; -import Box from '@mui/material/Box'; -import Button from '@mui/material/Button'; -import 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 { 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'; - -export interface ISelectedSpeciesProps { - name: string; - selectedSpecies: IPartialTaxonomy; - handleRemoveSpecies?: (species_id: number) => void; - speciesIndex: number; -} - -const SelectedSurveySpecies = (props: ISelectedSpeciesProps) => { - const { name, selectedSpecies, speciesIndex, handleRemoveSpecies } = props; - - const { values } = useFormikContext(); - - const critterbaseApi = useCritterbaseApi(); - - const ecologicalUnitDataLoader = useDataLoader((tsn: number) => critterbaseApi.xref.getTsnCollectionCategories(tsn)); - - useEffect(() => { - ecologicalUnitDataLoader.load(selectedSpecies.tsn); - }); - - const ecologicalUnitsForSpecies = ecologicalUnitDataLoader.data ?? []; - - const selectedUnits = - values.species.focal_species - .filter((species) => species.tsn === selectedSpecies.tsn) - .flatMap((species) => species.ecological_units) ?? []; - - return ( - - - - ( - - {selectedUnits.map((ecological_unit, ecologicalUnitIndex) => ( - unit.critterbase_collection_category_id - )} - ecologicalUnits={ecologicalUnitsForSpecies} - arrayHelpers={arrayHelpers} - index={ecologicalUnitIndex} - /> - ))} - - - - - )} - /> - - ); -}; - -export default SelectedSurveySpecies; diff --git a/app/src/components/species/components/SpeciesAutocompleteField.tsx b/app/src/components/species/components/SpeciesAutocompleteField.tsx index 5c8dc75009..e0d553bc8f 100644 --- a/app/src/components/species/components/SpeciesAutocompleteField.tsx +++ b/app/src/components/species/components/SpeciesAutocompleteField.tsx @@ -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 * diff --git a/app/src/features/surveys/components/species/SpeciesForm.tsx b/app/src/features/surveys/components/species/SpeciesForm.tsx index 2d5adadfbc..a4fab7b867 100644 --- a/app/src/features/surveys/components/species/SpeciesForm.tsx +++ b/app/src/features/surveys/components/species/SpeciesForm.tsx @@ -1,16 +1,21 @@ import Box from '@mui/material/Box'; import Grid from '@mui/material/Grid'; +import { FocalSpeciesForm } from 'features/surveys/components/species/components/FocalSpeciesForm'; import { IPartialTaxonomy } from 'interfaces/useTaxonomyApi.interface'; import yup from 'utils/YupSchema'; -import FocalSpeciesComponent from './components/FocalSpeciesComponent'; -export type PostCollectionUnit = { - critterbase_collection_category_id: string; - critterbase_collection_unit_id: string; +export type IEcologicalUnit = { + critterbase_collection_category_id: string | null; + critterbase_collection_unit_id: string | null; +}; + +export const EcologicalUnitInitialValues: IEcologicalUnit = { + critterbase_collection_category_id: null, + critterbase_collection_unit_id: null }; export interface ITaxonomyWithEcologicalUnits extends IPartialTaxonomy { - ecological_units: PostCollectionUnit[]; + ecological_units: IEcologicalUnit[]; } export interface ISpeciesForm { @@ -31,44 +36,34 @@ export const SpeciesYupSchema = yup.object().shape({ .array() .of( yup.object().shape({ - ecological_units: yup - .array() - .of( - yup.object().shape({ - critterbase_collection_category_id: yup - .string() - .test( - 'is-unique-ecological-unit', - 'Ecological unit must be unique', - function (collection_category_id) { - const formValues = this.options.context; - - if (!formValues?.ecological_units?.length) { - return true; - } - - return ( - formValues.ecological_units.filter( - (ecologicalUnit: PostCollectionUnit) => - ecologicalUnit.critterbase_collection_category_id === collection_category_id - ).length <= 1 - ); - } - ) - .required('Ecological unit is required') - .nullable(), - critterbase_collection_unit_id: yup.string().when('critterbase_collection_category_id', { - is: (critterbase_collection_category_id: string) => critterbase_collection_category_id !== null, - then: yup.string().required('Ecological unit is required').nullable(), - otherwise: yup.string().nullable() - }) - }) - ) - .nullable() + ecological_units: yup.array().of( + yup.object().shape({ + critterbase_collection_category_id: yup.string().nullable().required('Ecological unit is required'), + critterbase_collection_unit_id: yup.string().nullable().required('Ecological unit is required') + }) + ) }) ) .min(1, 'You must specify a focal species') .required('Required') + .test('is-unique-ecological-unit', 'Ecological units must be unique', function () { + const focalSpecies = (this.options.context?.species.focal_species ?? []) as ITaxonomyWithEcologicalUnits[]; + + const seenCollectionUnitIts = new Set(); + + for (const focalSpeciesItem of focalSpecies) { + for (const ecologicalUnit of focalSpeciesItem.ecological_units) { + if (seenCollectionUnitIts.has(ecologicalUnit.critterbase_collection_category_id)) { + // Duplicate ecological collection category id found, return false + return false; + } + seenCollectionUnitIts.add(ecologicalUnit.critterbase_collection_category_id); + } + } + + // Valid, return true + return true; + }) }) }); @@ -82,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/FocalSpeciesComponent.tsx b/app/src/features/surveys/components/species/components/FocalSpeciesComponent.tsx deleted file mode 100644 index 6a00415616..0000000000 --- a/app/src/features/surveys/components/species/components/FocalSpeciesComponent.tsx +++ /dev/null @@ -1,78 +0,0 @@ -import Collapse from '@mui/material/Collapse'; -import Stack from '@mui/material/Stack'; -import AlertBar from 'components/alert/AlertBar'; -import { ITaxonomyWithEcologicalUnits } from 'features/surveys/components/species/SpeciesForm'; -import { useFormikContext } from 'formik'; -import { ICreateSurveyRequest, IEditSurveyRequest } from 'interfaces/useSurveyApi.interface'; -import { IPartialTaxonomy } from 'interfaces/useTaxonomyApi.interface'; -import get from 'lodash-es/get'; -import { TransitionGroup } from 'react-transition-group'; -import SelectedSurveySpecies from '../../../../../components/species/components/SelectedSurveySpecies'; -import SpeciesAutocompleteField from '../../../../../components/species/components/SpeciesAutocompleteField'; - -/** - * Returns an autocomplete component for selecting focal species and ecological units for - * each focal species on the survey form - * - * @returns - */ -const FocalSpeciesComponent = () => { - const { values, setFieldValue, setFieldError, errors, submitCount } = useFormikContext< - ICreateSurveyRequest | IEditSurveyRequest - >(); - - const selectedSpecies: ITaxonomyWithEcologicalUnits[] = get(values, 'species.focal_species') || []; - - const handleAddSpecies = (species?: IPartialTaxonomy) => { - // Add species with empty ecological units array - setFieldValue(`species.focal_species[${selectedSpecies.length}]`, { ...species, ecological_units: [] }); - setFieldError(`species.focal_species`, undefined); - }; - - const handleRemoveSpecies = (species_id: number) => { - const filteredSpecies = selectedSpecies.filter((value: ITaxonomyWithEcologicalUnits) => { - return value.tsn !== species_id; - }); - setFieldValue('species.focal_species', filteredSpecies); - }; - - console.log(errors); - - return ( - - {submitCount > 0 && - errors && - get(errors, 'species.focal_species') && - !Array.isArray(get(errors, 'species.focal_species')) && ( - - )} - - - - {selectedSpecies.map((species, index) => ( - - - - ))} - - - ); -}; - -export default FocalSpeciesComponent; 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 981c173d97..c228734833 100644 --- a/app/src/features/surveys/edit/EditSurveyForm.tsx +++ b/app/src/features/surveys/edit/EditSurveyForm.tsx @@ -106,8 +106,9 @@ const EditSurveyForm = < }> + summary="Enter focal species that were targetted in the survey"> + + 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/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 index e87815ffb4..5b51403485 100644 --- a/database/src/migrations/20240809140000_study_species_units.ts +++ b/database/src/migrations/20240809140000_study_species_units.ts @@ -9,7 +9,7 @@ import { Knex } from 'knex'; * @return {*} {Promise} */ export async function up(knex: Knex): Promise { - await knex.raw(` + await knex.raw(`--sql SET SEARCH_PATH=biohub; ----------------------------------------------------------------------------------------------------------------- @@ -28,16 +28,16 @@ export async function up(knex: Knex): Promise { 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 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.'; + 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);