From bb198fa5a0492e2740ff20e32655d9fa8fd4337f Mon Sep 17 00:00:00 2001 From: Macgregor Aubertin-Young <108430771+mauberti-bc@users.noreply.github.com> Date: Tue, 3 Sep 2024 12:26:31 -0700 Subject: [PATCH] Filter measurements search by focal and observed species (#1338) * wip: styling on configure table dialog * wip: configure columns styling * configure obs dialog styling * add applicable label to measurements search * write unit tests * linter * no data overlay in configure columns dialog * rename variable for clarity * add eslint ignore & update prop names * fix dependency array warning * feat: removed log file from git cache --------- Co-authored-by: Mac Deluca --- .../observations/taxon/index.test.ts | 86 +++++++++ .../{surveyId}/observations/taxon/index.ts | 140 +++++++++++++++ .../observation-repository.test.ts | 16 ++ .../observation-repository.ts | 25 +++ api/src/services/observation-service.test.ts | 21 +++ api/src/services/observation-service.ts | 12 ++ .../chips/ColouredRectangleChip.tsx | 2 +- .../GenericGridColumnDefinitions.tsx | 16 +- .../components/SpeciesAutocompleteField.tsx | 2 +- app/src/contexts/observationsContext.tsx | 10 +- .../ObservationsTableContainer.tsx | 60 +++++-- .../components/ConfigureColumnsDialog.tsx | 8 +- .../components/ConfigureColumnsPage.tsx | 6 +- .../ConfigureEnvironmentColumns.tsx | 166 +++++++++--------- .../general/ConfigureGeneralColumns.tsx | 44 +++-- .../ConfigureMeasurementColumns.tsx | 153 +++++++++------- .../search/MeasurementsSearch.tsx | 63 ++++++- .../search/MeasurementsSearchAutocomplete.tsx | 97 ++++++---- .../GridColumnDefinitions.tsx | 9 + app/src/hooks/api/useObservationApi.ts | 17 ++ app/src/hooks/api/useTaxonomyApi.ts | 26 ++- app/src/hooks/cb_api/useXrefApi.tsx | 15 +- app/src/interfaces/useCritterApi.interface.ts | 4 +- .../interfaces/useTaxonomyApi.interface.ts | 2 + 24 files changed, 759 insertions(+), 241 deletions(-) create mode 100644 api/src/paths/project/{projectId}/survey/{surveyId}/observations/taxon/index.test.ts create mode 100644 api/src/paths/project/{projectId}/survey/{surveyId}/observations/taxon/index.ts 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 new file mode 100644 index 0000000000..4177286777 --- /dev/null +++ b/api/src/paths/project/{projectId}/survey/{surveyId}/observations/taxon/index.test.ts @@ -0,0 +1,86 @@ +import chai, { expect } from 'chai'; +import { describe } from 'mocha'; +import sinon from 'sinon'; +import sinonChai from 'sinon-chai'; +import { getSurveyObservedSpecies } from '.'; +import * as db from '../../../../../../../database/db'; +import { HTTPError } from '../../../../../../../errors/http-error'; +import { ObservationService } from '../../../../../../../services/observation-service'; +import { PlatformService } from '../../../../../../../services/platform-service'; +import { getMockDBConnection, getRequestHandlerMocks } from '../../../../../../../__mocks__/db'; + +chai.use(sinonChai); + +describe('getSurveyObservedSpecies', () => { + afterEach(() => { + sinon.restore(); + }); + + it('gets species observed in a survey', async () => { + const dbConnectionObj = getMockDBConnection(); + + sinon.stub(db, 'getDBConnection').returns(dbConnectionObj); + + const mockSurveyId = 2; + const mockProjectId = 1; + 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' } + ]; + const mockFormattedItisResponse = mockItisResponse.map((species) => ({ ...species, tsn: Number(species.tsn) })); + + const getObservedSpeciesForSurveyStub = sinon + .stub(ObservationService.prototype, 'getObservedSpeciesForSurvey') + .resolves(mockSpecies); + + const getTaxonomyByTsnsStub = sinon.stub(PlatformService.prototype, 'getTaxonomyByTsns').resolves(mockItisResponse); + + const { mockReq, mockRes, mockNext } = getRequestHandlerMocks(); + + mockReq.params = { + projectId: String(mockProjectId), + surveyId: String(mockSurveyId) + }; + + const requestHandler = getSurveyObservedSpecies(); + + await requestHandler(mockReq, mockRes, mockNext); + + expect(getObservedSpeciesForSurveyStub).to.have.been.calledOnceWith(mockSurveyId); + expect(getTaxonomyByTsnsStub).to.have.been.calledOnceWith(mockTsns); + expect(mockRes.statusValue).to.equal(200); + expect(mockRes.jsonValue).to.eql(mockFormattedItisResponse); + }); + + it('catches and re-throws error', async () => { + const dbConnectionObj = getMockDBConnection({ release: sinon.stub() }); + + sinon.stub(db, 'getDBConnection').returns(dbConnectionObj); + + const mockSurveyId = 2; + const mockProjectId = 1; + + sinon.stub(ObservationService.prototype, 'getObservedSpeciesForSurvey').rejects(new Error('a test error')); + + const { mockReq, mockRes, mockNext } = getRequestHandlerMocks(); + + mockReq.params = { + projectId: String(mockProjectId), + surveyId: String(mockSurveyId) + }; + + try { + const requestHandler = getSurveyObservedSpecies(); + + await requestHandler(mockReq, mockRes, mockNext); + + expect.fail(); + } catch (actualError) { + expect(dbConnectionObj.release).to.have.been.called; + expect((actualError as HTTPError).message).to.equal('a test error'); + } + }); +}); 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 new file mode 100644 index 0000000000..cfe8f152df --- /dev/null +++ b/api/src/paths/project/{projectId}/survey/{surveyId}/observations/taxon/index.ts @@ -0,0 +1,140 @@ +import { RequestHandler } from 'express'; +import { Operation } from 'express-openapi'; +import { PROJECT_PERMISSION, SYSTEM_ROLE } from '../../../../../../../constants/roles'; +import { getDBConnection } from '../../../../../../../database/db'; +import { authorizeRequestHandler } from '../../../../../../../request-handlers/security/authorization'; +import { ObservationService } from '../../../../../../../services/observation-service'; +import { PlatformService } from '../../../../../../../services/platform-service'; +import { getLogger } from '../../../../../../../utils/logger'; + +const defaultLog = getLogger('/api/project/{projectId}/survey/{surveyId}/observation/taxon'); + +export const GET: Operation = [ + authorizeRequestHandler((req) => { + return { + or: [ + { + validProjectPermissions: [ + PROJECT_PERMISSION.COORDINATOR, + PROJECT_PERMISSION.COLLABORATOR, + PROJECT_PERMISSION.OBSERVER + ], + surveyId: Number(req.params.surveyId), + discriminator: 'ProjectPermission' + }, + { + validSystemRoles: [SYSTEM_ROLE.DATA_ADMINISTRATOR, SYSTEM_ROLE.SYSTEM_ADMIN], + discriminator: 'SystemRole' + } + ] + }; + }), + getSurveyObservedSpecies() +]; + +GET.apiDoc = { + description: 'Get observed species for a survey', + tags: ['observation'], + security: [ + { + Bearer: [] + } + ], + parameters: [ + { + in: 'path', + name: 'projectId', + schema: { + type: 'integer', + minimum: 1 + }, + required: true + }, + { + in: 'path', + name: 'surveyId', + schema: { + type: 'integer', + minimum: 1 + }, + required: true + } + ], + responses: { + 200: { + description: 'Survey observed species get response.', + content: { + 'application/json': { + schema: { + type: 'array', + description: 'Array of objects describing observed species in the survey', + items: { + type: 'object', + additionalProperties: false, + properties: { + tsn: { type: 'number', description: 'The TSN of the observed species' }, + commonNames: { + type: 'array', + items: { type: 'string' }, + description: 'Common names of the observed species' + }, + scientificName: { type: 'string', description: 'Scientific name of the observed species' } + } + } + } + } + } + }, + 400: { + $ref: '#/components/responses/400' + }, + 401: { + $ref: '#/components/responses/401' + }, + 403: { + $ref: '#/components/responses/403' + }, + 500: { + $ref: '#/components/responses/500' + }, + default: { + $ref: '#/components/responses/default' + } + } +}; + +/** + * Fetch species that were observed in the survey + * + * @export + * @return {*} {RequestHandler} + */ +export function getSurveyObservedSpecies(): RequestHandler { + return async (req, res) => { + const surveyId = Number(req.params.surveyId); + defaultLog.debug({ label: 'getSurveyObservedSpecies', surveyId }); + + const connection = getDBConnection(req.keycloak_token); + + try { + await connection.open(); + + const observationService = new ObservationService(connection); + const platformService = new PlatformService(connection); + + const observedSpecies = await observationService.getObservedSpeciesForSurvey(surveyId); + + const species = await platformService.getTaxonomyByTsns(observedSpecies.flatMap((species) => species.itis_tsn)); + + const formattedResponse = species.map((taxon) => ({ ...taxon, tsn: Number(taxon.tsn) })); + + return res.status(200).json(formattedResponse); + } catch (error) { + defaultLog.error({ label: 'getSurveyObservedSpecies', message: 'error', error }); + await connection.rollback(); + throw error; + } finally { + connection.release(); + } + }; +} diff --git a/api/src/repositories/observation-repository/observation-repository.test.ts b/api/src/repositories/observation-repository/observation-repository.test.ts index 4b3f953c5b..f4b3122b28 100644 --- a/api/src/repositories/observation-repository/observation-repository.test.ts +++ b/api/src/repositories/observation-repository/observation-repository.test.ts @@ -224,6 +224,22 @@ describe('ObservationRepository', () => { }); }); + describe('getObservedSpeciesForSurvey', () => { + it('gets observed species for a given survey', async () => { + const mockQueryResponse = { rows: [{ itis_tsn: 5 }], rowCount: 1 } as unknown as QueryResult; + + const mockDBConnection = getMockDBConnection({ + knex: sinon.stub().resolves(mockQueryResponse) + }); + + const repo = new ObservationRepository(mockDBConnection); + + const response = await repo.getObservedSpeciesForSurvey(1); + + expect(response).to.eql([{ itis_tsn: 5 }]); + }); + }); + describe('getObservationsCountBySampleSiteIds', () => { it('gets the observation count by sample site ids', async () => { const mockQueryResponse = { rows: [{ count: 50 }], rowCount: 1 } as unknown as QueryResult; diff --git a/api/src/repositories/observation-repository/observation-repository.ts b/api/src/repositories/observation-repository/observation-repository.ts index 80a0db0611..2402f68838 100644 --- a/api/src/repositories/observation-repository/observation-repository.ts +++ b/api/src/repositories/observation-repository/observation-repository.ts @@ -45,6 +45,12 @@ export const ObservationRecord = z.object({ export type ObservationRecord = z.infer; +export const ObservationSpecies = z.object({ + itis_tsn: z.number() +}); + +export type ObservationSpecies = z.infer; + const ObservationSamplingData = z.object({ survey_sample_site_name: z.string().nullable(), survey_sample_method_name: z.string().nullable(), @@ -416,6 +422,25 @@ export class ObservationRepository extends BaseRepository { return response.rows; } + /** + * Retrieves species observed in a given survey + * + * @param {number} surveyId + * @return {*} {Promise} + * @memberof ObservationRepository + */ + async getObservedSpeciesForSurvey(surveyId: number): Promise { + const knex = getKnex(); + const allRowsQuery = knex + .queryBuilder() + .distinct('itis_tsn') + .from('survey_observation') + .where('survey_id', surveyId); + + const response = await this.connection.knex(allRowsQuery, ObservationSpecies); + return response.rows; + } + /** * Retrieves the count of survey observations for the given survey * diff --git a/api/src/services/observation-service.test.ts b/api/src/services/observation-service.test.ts index 157c613664..245df3f0b2 100644 --- a/api/src/services/observation-service.test.ts +++ b/api/src/services/observation-service.test.ts @@ -186,4 +186,25 @@ describe('ObservationService', () => { expect(response).to.eql(mockObservationCount); }); }); + + describe('getObservedSpeciesForSurvey', () => { + it('Gets the species observed in a survey', async () => { + const mockDBConnection = getMockDBConnection(); + + const mockTsns = [1, 2, 3]; + const surveyId = 1; + const mockSpecies = mockTsns.map((tsn) => ({ itis_tsn: tsn })); + + const getObservedSpeciesForSurveyStub = sinon + .stub(ObservationRepository.prototype, 'getObservedSpeciesForSurvey') + .resolves(mockSpecies); + + const observationService = new ObservationService(mockDBConnection); + + const response = await observationService.getObservedSpeciesForSurvey(surveyId); + + expect(getObservedSpeciesForSurveyStub).to.be.calledOnceWith(surveyId); + expect(response).to.eql(mockSpecies); + }); + }); }); diff --git a/api/src/services/observation-service.ts b/api/src/services/observation-service.ts index 92c1e8e260..b5ae70c09d 100644 --- a/api/src/services/observation-service.ts +++ b/api/src/services/observation-service.ts @@ -7,6 +7,7 @@ import { ObservationRecord, ObservationRecordWithSamplingAndSubcountData, ObservationRepository, + ObservationSpecies, ObservationSubmissionRecord, UpdateObservation } from '../repositories/observation-repository/observation-repository'; @@ -246,6 +247,17 @@ export class ObservationService extends DBService { return this.observationRepository.getAllSurveyObservations(surveyId); } + /** + * Retrieves all species observed in a given survey + * + * @param {number} surveyId + * @return {*} {Promise} + * @memberof ObservationRepository + */ + async getObservedSpeciesForSurvey(surveyId: number): Promise { + return this.observationRepository.getObservedSpeciesForSurvey(surveyId); + } + /** * Retrieves a single observation records by ID * diff --git a/app/src/components/chips/ColouredRectangleChip.tsx b/app/src/components/chips/ColouredRectangleChip.tsx index ff430479e2..9baf0282f0 100644 --- a/app/src/components/chips/ColouredRectangleChip.tsx +++ b/app/src/components/chips/ColouredRectangleChip.tsx @@ -28,7 +28,7 @@ const ColouredRectangleChip = (props: IColouredRectangleChipProps) => { p: 1, textTransform: 'uppercase' }, - userSelect: 'none' + ...props.sx }} /> ); diff --git a/app/src/components/data-grid/GenericGridColumnDefinitions.tsx b/app/src/components/data-grid/GenericGridColumnDefinitions.tsx index 9fb95d74da..f432551087 100644 --- a/app/src/components/data-grid/GenericGridColumnDefinitions.tsx +++ b/app/src/components/data-grid/GenericGridColumnDefinitions.tsx @@ -10,13 +10,15 @@ import { getFormattedDate } from 'utils/Utils'; export const GenericDateColDef = (props: { field: string; headerName: string; + description?: string; hasError: (params: GridCellParams) => boolean; }): GridColDef => { - const { field, headerName, hasError } = props; + const { field, headerName, hasError, description } = props; return { field, headerName, + description: description ?? undefined, editable: true, hideable: true, type: 'date', @@ -59,15 +61,17 @@ export const GenericDateColDef = (props: { export const GenericTimeColDef = (props: { field: string; headerName: string; + description?: string; hasError: (params: GridCellParams) => boolean; }): GridColDef => { - const { hasError, field, headerName } = props; + const { hasError, field, headerName, description } = props; return { field, headerName, editable: true, hideable: true, + description: description ?? undefined, type: 'string', width: 150, disableColumnMenu: true, @@ -121,13 +125,15 @@ export const GenericTimeColDef = (props: { export const GenericLatitudeColDef = (props: { field: string; headerName: string; + description?: string; hasError: (params: GridCellParams) => boolean; }): GridColDef => { - const { hasError, field, headerName } = props; + const { hasError, field, headerName, description } = props; return { field, headerName, + description: description ?? undefined, editable: true, hideable: true, width: 120, @@ -180,13 +186,15 @@ export const GenericLatitudeColDef = (props: { export const GenericLongitudeColDef = (props: { field: string; headerName: string; + description?: string; hasError: (params: GridCellParams) => boolean; }): GridColDef => { - const { hasError, field, headerName } = props; + const { hasError, field, headerName, description } = props; return { field, headerName, + description: description ?? undefined, editable: true, hideable: true, width: 120, diff --git a/app/src/components/species/components/SpeciesAutocompleteField.tsx b/app/src/components/species/components/SpeciesAutocompleteField.tsx index 07be1ec261..0956567a28 100644 --- a/app/src/components/species/components/SpeciesAutocompleteField.tsx +++ b/app/src/components/species/components/SpeciesAutocompleteField.tsx @@ -186,7 +186,7 @@ const SpeciesAutocompleteField = (props: ISpeciesAutocompleteFieldProps) => { id={formikFieldName} disabled={disabled} data-testid={formikFieldName} - noOptionsText="No matching options" + noOptionsText={isLoading ? 'Loading...' : 'No matching options'} options={options} getOptionLabel={(option) => option.scientificName} filterOptions={(item) => item} diff --git a/app/src/contexts/observationsContext.tsx b/app/src/contexts/observationsContext.tsx index 2d469b74a2..a5d92d4d86 100644 --- a/app/src/contexts/observationsContext.tsx +++ b/app/src/contexts/observationsContext.tsx @@ -1,6 +1,7 @@ import { useBiohubApi } from 'hooks/useBioHubApi'; import useDataLoader, { DataLoader } from 'hooks/useDataLoader'; import { IGetSurveyObservationsResponse } from 'interfaces/useObservationApi.interface'; +import { IPartialTaxonomy } from 'interfaces/useTaxonomyApi.interface'; import { createContext, PropsWithChildren, useContext } from 'react'; import { ApiPaginationRequestOptions } from 'types/misc'; import { SurveyContext } from './surveyContext'; @@ -20,6 +21,10 @@ export type IObservationsContext = { IGetSurveyObservationsResponse, unknown >; + /** + * Data Loader used for retrieving species observed in a survey + */ + observedSpeciesDataLoader: DataLoader<[], IPartialTaxonomy[], unknown>; }; export const ObservationsContext = createContext(undefined); @@ -33,8 +38,11 @@ export const ObservationsContextProvider = (props: PropsWithChildren biohubApi.observation.getObservedSpecies(projectId, surveyId)); + const observationsContext: IObservationsContext = { - observationsDataLoader + observationsDataLoader, + observedSpeciesDataLoader }; return {props.children}; diff --git a/app/src/features/surveys/observations/observations-table/ObservationsTableContainer.tsx b/app/src/features/surveys/observations/observations-table/ObservationsTableContainer.tsx index c7ac13bbde..d01dd564b0 100644 --- a/app/src/features/surveys/observations/observations-table/ObservationsTableContainer.tsx +++ b/app/src/features/surveys/observations/observations-table/ObservationsTableContainer.tsx @@ -39,7 +39,7 @@ import { IGetSampleMethodDetails, IGetSamplePeriodRecord } from 'interfaces/useSamplingSiteApi.interface'; -import { useContext } from 'react'; +import { useContext, useMemo } from 'react'; import { getCodesName } from 'utils/Utils'; import { ConfigureColumnsButton } from './configure-columns/ConfigureColumnsButton'; import ExportHeadersButton from './export-button/ExportHeadersButton'; @@ -91,22 +91,48 @@ const ObservationsTableContainer = () => { })); // The column definitions of the columns to render in the observations table - const columns: GridColDef[] = [ - // Add standard observation columns to the table - TaxonomyColDef({ hasError: observationsTableContext.hasError }), - SampleSiteColDef({ sampleSiteOptions, hasError: observationsTableContext.hasError }), - SampleMethodColDef({ sampleMethodOptions, hasError: observationsTableContext.hasError }), - SamplePeriodColDef({ samplePeriodOptions, hasError: observationsTableContext.hasError }), - ObservationCountColDef({ sampleMethodOptions, hasError: observationsTableContext.hasError }), - GenericDateColDef({ field: 'observation_date', headerName: 'Date', hasError: observationsTableContext.hasError }), - GenericTimeColDef({ field: 'observation_time', headerName: 'Time', hasError: observationsTableContext.hasError }), - GenericLatitudeColDef({ field: 'latitude', headerName: 'Lat', hasError: observationsTableContext.hasError }), - GenericLongitudeColDef({ field: 'longitude', headerName: 'Long', hasError: observationsTableContext.hasError }), - // Add measurement columns to the table - ...getMeasurementColumnDefinitions(observationsTableContext.measurementColumns, observationsTableContext.hasError), - // Add environment columns to the table - ...getEnvironmentColumnDefinitions(observationsTableContext.environmentColumns, observationsTableContext.hasError) - ]; + const columns: GridColDef[] = useMemo( + () => [ + // Add standard observation columns to the table + TaxonomyColDef({ hasError: observationsTableContext.hasError }), + SampleSiteColDef({ sampleSiteOptions, hasError: observationsTableContext.hasError }), + SampleMethodColDef({ sampleMethodOptions, hasError: observationsTableContext.hasError }), + SamplePeriodColDef({ samplePeriodOptions, hasError: observationsTableContext.hasError }), + ObservationCountColDef({ sampleMethodOptions, hasError: observationsTableContext.hasError }), + GenericDateColDef({ + field: 'observation_date', + headerName: 'Date', + description: 'The date when the observation was made', + hasError: observationsTableContext.hasError + }), + GenericTimeColDef({ + field: 'observation_time', + headerName: 'Time', + description: 'The time when the observation was made', + hasError: observationsTableContext.hasError + }), + GenericLatitudeColDef({ + field: 'latitude', + headerName: 'Latitude', + description: 'The latitude where the observation was made', + hasError: observationsTableContext.hasError + }), + GenericLongitudeColDef({ + field: 'longitude', + headerName: 'Longitude', + description: 'The longitude where the observation was made', + hasError: observationsTableContext.hasError + }), + // Add measurement columns to the table + ...getMeasurementColumnDefinitions( + observationsTableContext.measurementColumns, + observationsTableContext.hasError + ), + // Add environment columns to the table + ...getEnvironmentColumnDefinitions(observationsTableContext.environmentColumns, observationsTableContext.hasError) + ], + [observationsTableContext.environmentColumns, observationsTableContext.measurementColumns] + ); return ( diff --git a/app/src/features/surveys/observations/observations-table/configure-columns/components/ConfigureColumnsDialog.tsx b/app/src/features/surveys/observations/observations-table/configure-columns/components/ConfigureColumnsDialog.tsx index 55a02aab10..4d942a67bb 100644 --- a/app/src/features/surveys/observations/observations-table/configure-columns/components/ConfigureColumnsDialog.tsx +++ b/app/src/features/surveys/observations/observations-table/configure-columns/components/ConfigureColumnsDialog.tsx @@ -125,7 +125,7 @@ export const ConfigureColumnsDialog = (props: IConfigureColumnsDialogProps) => { return ( { measurements. - + { /> - - Close + + Save & Close diff --git a/app/src/features/surveys/observations/observations-table/configure-columns/components/ConfigureColumnsPage.tsx b/app/src/features/surveys/observations/observations-table/configure-columns/components/ConfigureColumnsPage.tsx index 2d5f97e20c..3dd4626823 100644 --- a/app/src/features/surveys/observations/observations-table/configure-columns/components/ConfigureColumnsPage.tsx +++ b/app/src/features/surveys/observations/observations-table/configure-columns/components/ConfigureColumnsPage.tsx @@ -127,7 +127,7 @@ export const ConfigureColumnsPage = (props: IConfigureColumnsPageProps) => { const [activeView, setActiveView] = useState(ConfigureColumnsViewEnum.GENERAL); return ( - + { startIcon={} disabled={disabled} value={ConfigureColumnsViewEnum.MEASUREMENTS}> - Measurements + Species Attributes { - + {activeView === ConfigureColumnsViewEnum.GENERAL && ( + - Configure Environment Columns + Add Environmental Variables onAddEnvironmentColumns(environmentColumn)} /> - - {hasEnvironmentColumns ? ( - <> - - Selected environments - - - {environmentColumns.qualitative_environments.map((environment) => ( - - - {environment.options.map((option) => ( - - ))} - + {hasEnvironmentColumns ? ( + <> + + Selected environments + + + {environmentColumns.qualitative_environments.map((environment) => ( + + + {environment.options.map((option) => ( + + ))} + + } + /> + + + onRemoveEnvironmentColumns({ + qualitative_environments: [environment.environment_qualitative_id], + quantitative_environments: [] + }) } - /> - - - onRemoveEnvironmentColumns({ - qualitative_environments: [environment.environment_qualitative_id], - quantitative_environments: [] - }) - } - data-testid="configure-environment-qualitative-column-remove-button"> - - - + data-testid="configure-environment-qualitative-column-remove-button"> + + - ))} - {environmentColumns.quantitative_environments.map((environment) => ( - - - ) : undefined + + ))} + {environmentColumns.quantitative_environments.map((environment) => ( + + : undefined + } + /> + + + onRemoveEnvironmentColumns({ + qualitative_environments: [], + quantitative_environments: [environment.environment_quantitative_id] + }) } - /> - - - onRemoveEnvironmentColumns({ - qualitative_environments: [], - quantitative_environments: [environment.environment_quantitative_id] - }) - } - data-testid="configure-environment-quantitative-column-remove-button"> - - - + data-testid="configure-environment-quantitative-column-remove-button"> + + - ))} - - - ) : ( - - No environmental variables selected - - )} - - + + ))} + + + ) : ( + + )} + ); }; diff --git a/app/src/features/surveys/observations/observations-table/configure-columns/components/general/ConfigureGeneralColumns.tsx b/app/src/features/surveys/observations/observations-table/configure-columns/components/general/ConfigureGeneralColumns.tsx index 3ca4654a46..39a1e4b84f 100644 --- a/app/src/features/surveys/observations/observations-table/configure-columns/components/general/ConfigureGeneralColumns.tsx +++ b/app/src/features/surveys/observations/observations-table/configure-columns/components/general/ConfigureGeneralColumns.tsx @@ -97,18 +97,10 @@ export const ConfigureGeneralColumns = (props: IConfigureGeneralColumnsProps) => } = props; return ( - - - Select Columns to Show - - + // display="flex" and flexDirection="column" is necessary for the scrollbars to be correctly positioned + + + Select Columns to Show checked={hiddenFields.length === 0} onClick={() => onToggleShowHideAll()} disabled={disabled} + sx={{ m: 0, p: 0 }} /> } label={ - + Show/Hide all } @@ -130,12 +128,14 @@ export const ConfigureGeneralColumns = (props: IConfigureGeneralColumnsProps) => component={Stack} gap={0.5} sx={{ + my: 2, p: 0.5, - maxHeight: { sm: 300, md: 500 }, + maxHeight: '100%', overflowY: 'auto' }} disablePadding> {hideableColumns.map((column) => { + const isSelected = !hiddenFields.includes(column.field); return ( dense onClick={() => onToggleColumnVisibility(column.field)} disabled={disabled} - sx={{ background: grey[50], borderRadius: '5px' }}> + sx={{ + background: isSelected ? grey[50] : '#fff', + borderRadius: '5px', + alignItems: 'flex-start', + '& .MuiListItemText-root': { my: 0.25 } + }}> - + - {column.headerName} + + {column.headerName} + + {column.description} + + ); diff --git a/app/src/features/surveys/observations/observations-table/configure-columns/components/measurements/ConfigureMeasurementColumns.tsx b/app/src/features/surveys/observations/observations-table/configure-columns/components/measurements/ConfigureMeasurementColumns.tsx index 44f0ecaaa6..2b8e1b6dbb 100644 --- a/app/src/features/surveys/observations/observations-table/configure-columns/components/measurements/ConfigureMeasurementColumns.tsx +++ b/app/src/features/surveys/observations/observations-table/configure-columns/components/measurements/ConfigureMeasurementColumns.tsx @@ -1,10 +1,19 @@ -import { mdiTrashCanOutline } from '@mdi/js'; +import { mdiArrowTopRight, mdiTrashCanOutline } from '@mdi/js'; import Icon from '@mdi/react'; -import { Box, IconButton, Stack, Typography } from '@mui/material'; +import Box from '@mui/material/Box'; +import Checkbox from '@mui/material/Checkbox'; import { blueGrey, grey } from '@mui/material/colors'; +import FormControlLabel from '@mui/material/FormControlLabel'; +import FormGroup from '@mui/material/FormGroup'; +import IconButton from '@mui/material/IconButton'; +import List from '@mui/material/List'; +import Stack from '@mui/material/Stack'; +import Typography from '@mui/material/Typography'; import ColouredRectangleChip from 'components/chips/ColouredRectangleChip'; +import { NoDataOverlay } from 'components/overlay/NoDataOverlay'; import { AccordionStandardCard } from 'features/standards/view/components/AccordionStandardCard'; import { CBMeasurementType } from 'interfaces/useCritterApi.interface'; +import { useState } from 'react'; import { MeasurementsSearch } from './search/MeasurementsSearch'; export interface IConfigureMeasurementColumnsProps { @@ -35,72 +44,98 @@ export interface IConfigureMeasurementColumnsProps { * @param {IConfigureMeasurementColumnsProps} props * @return {*} */ + export const ConfigureMeasurementColumns = (props: IConfigureMeasurementColumnsProps) => { const { measurementColumns, onAddMeasurementColumns, onRemoveMeasurementColumns } = props; + const [isFocalSpeciesMeasurementsOnly, setIsFocalSpeciesMeasurementsOnly] = useState(true); + return ( - <> + - Configure Measurement Columns + Add Species Attributes onAddMeasurementColumns([measurementColumn])} + focalOrObservedSpeciesOnly={isFocalSpeciesMeasurementsOnly} /> - - {measurementColumns.length ? ( - <> - - Selected measurements - - - {measurementColumns.map((measurement) => ( - - - {measurement.options.map((option) => ( - - ))} - - ) : undefined - } - ornament={ - 'unit' in measurement && measurement.unit ? ( - - ) : undefined - } - /> - - onRemoveMeasurementColumns([measurement.taxon_measurement_id])} - data-testid="configure-measurement-column-remove-button"> - - - - - ))} - - - ) : ( - - No measurements selected - - )} - - + + setIsFocalSpeciesMeasurementsOnly((prev) => !prev)} + /> + } + /> + + {measurementColumns.length ? ( + + {measurementColumns.map((measurement) => ( + + + {measurement.options.map((option) => ( + + ))} + + ) : undefined + } + ornament={ + 'unit' in measurement && measurement.unit ? ( + + ) : undefined + } + /> + + onRemoveMeasurementColumns([measurement.taxon_measurement_id])} + data-testid="configure-measurement-column-remove-button"> + + + + + ))} + + ) : ( + + )} + ); }; diff --git a/app/src/features/surveys/observations/observations-table/configure-columns/components/measurements/search/MeasurementsSearch.tsx b/app/src/features/surveys/observations/observations-table/configure-columns/components/measurements/search/MeasurementsSearch.tsx index 00540e15a4..d8c2d8762d 100644 --- a/app/src/features/surveys/observations/observations-table/configure-columns/components/measurements/search/MeasurementsSearch.tsx +++ b/app/src/features/surveys/observations/observations-table/configure-columns/components/measurements/search/MeasurementsSearch.tsx @@ -1,4 +1,8 @@ +import green from '@mui/material/colors/green'; +import ColouredRectangleChip from 'components/chips/ColouredRectangleChip'; import { MeasurementsSearchAutocomplete } from 'features/surveys/observations/observations-table/configure-columns/components/measurements/search/MeasurementsSearchAutocomplete'; +import { useBiohubApi } from 'hooks/useBioHubApi'; +import { useObservationsContext, useSurveyContext } from 'hooks/useContext'; import { useCritterbaseApi } from 'hooks/useCritterbaseApi'; import useDataLoader from 'hooks/useDataLoader'; import { CBMeasurementType } from 'interfaces/useCritterApi.interface'; @@ -17,6 +21,12 @@ export interface IMeasurementsSearchProps { * @memberof IMeasurementsSearchProps */ onAddMeasurementColumn: (measurementColumn: CBMeasurementType) => void; + /** + * Whether to only show measurements that focal or observed species can have + * + * @memberof IMeasurementsSearchProps + */ + focalOrObservedSpeciesOnly?: boolean; } /** @@ -25,20 +35,59 @@ export interface IMeasurementsSearchProps { * @param {IMeasurementsSearchProps} props * @return {*} */ -export const MeasurementsSearch = (props: IMeasurementsSearchProps) => { - const { selectedMeasurements, onAddMeasurementColumn } = props; +import React, { useEffect } from 'react'; + +export const MeasurementsSearch: React.FC = (props) => { + const { selectedMeasurements, onAddMeasurementColumn, focalOrObservedSpeciesOnly } = props; const critterbaseApi = useCritterbaseApi(); + const surveyContext = useSurveyContext(); + const observationsContext = useObservationsContext(); + const biohubApi = useBiohubApi(); + + const measurementsDataLoader = useDataLoader((searchTerm: string, tsns?: number[]) => + critterbaseApi.xref.getMeasurementTypeDefinitionsBySearchTerm(searchTerm, tsns) + ); + + const hierarchyDataLoader = useDataLoader((tsns: number[]) => biohubApi.taxonomy.getTaxonHierarchyByTSNs(tsns)); + + useEffect(() => { + if (!observationsContext.observedSpeciesDataLoader.data) { + observationsContext.observedSpeciesDataLoader.load(); + } + }, [observationsContext.observedSpeciesDataLoader]); + + const focalOrObservedSpecies: number[] = [ + ...(surveyContext.surveyDataLoader.data?.surveyData.species.focal_species.map((species) => species.tsn) ?? []), + ...(observationsContext.observedSpeciesDataLoader.data?.map((species) => species.tsn) ?? []) + ]; + + useEffect(() => { + if (focalOrObservedSpecies.length) { + hierarchyDataLoader.load(focalOrObservedSpecies); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [hierarchyDataLoader]); + + const getOptions = async (inputValue: string): Promise => { + const response = focalOrObservedSpeciesOnly + ? await measurementsDataLoader.refresh(inputValue, focalOrObservedSpecies) + : await measurementsDataLoader.refresh(inputValue); + + return response ? [...response.qualitative, ...response.quantitative] : []; + }; - const measurementsDataLoader = useDataLoader(critterbaseApi.xref.getMeasurementTypeDefinitionsBySearchTerm); + const focalOrObservedSpeciesTsns = [ + ...focalOrObservedSpecies, + ...(hierarchyDataLoader.data?.flatMap((taxon) => taxon.hierarchy) ?? []) + ]; return ( { - const response = await measurementsDataLoader.refresh(inputValue); - return (response && [...response.qualitative, ...response.quantitative]) || []; - }} + ornament={} + applicableTsns={focalOrObservedSpeciesTsns} + getOptions={getOptions} onAddMeasurementColumn={onAddMeasurementColumn} /> ); diff --git a/app/src/features/surveys/observations/observations-table/configure-columns/components/measurements/search/MeasurementsSearchAutocomplete.tsx b/app/src/features/surveys/observations/observations-table/configure-columns/components/measurements/search/MeasurementsSearchAutocomplete.tsx index a531d1d57a..0d72aa8b43 100644 --- a/app/src/features/surveys/observations/observations-table/configure-columns/components/measurements/search/MeasurementsSearchAutocomplete.tsx +++ b/app/src/features/surveys/observations/observations-table/configure-columns/components/measurements/search/MeasurementsSearchAutocomplete.tsx @@ -2,10 +2,13 @@ import { mdiMagnify } from '@mdi/js'; import Icon from '@mdi/react'; import Autocomplete from '@mui/material/Autocomplete'; import Box from '@mui/material/Box'; +import CircularProgress from '@mui/material/CircularProgress'; import ListItem from '@mui/material/ListItem'; import Stack from '@mui/material/Stack'; import TextField from '@mui/material/TextField'; import Typography from '@mui/material/Typography'; +import { ScientificNameTypography } from 'features/surveys/animals/components/ScientificNameTypography'; +import { useTaxonomyContext } from 'hooks/useContext'; import { CBMeasurementType } from 'interfaces/useCritterApi.interface'; import { debounce } from 'lodash-es'; import { useMemo, useState } from 'react'; @@ -39,6 +42,20 @@ export interface IMeasurementsSearchAutocompleteProps { * @memberof IMeasurementsSearchAutocompleteProps */ speciesTsn?: number[]; + /** + * Measurements applied to any of these TSNs will have the ornament applied to them in the options list + * + * @type {number[]} + * @memberof IMeasurementsSearchAutocompleteProps + */ + applicableTsns?: number[]; + /** + * Ornament to display on the option card, typically indicating whether focal species can have the measurement + * + * @type {JSX.Element} + * @memberof IMeasurementSearchAutocompleteProps + */ + ornament?: JSX.Element; } /** @@ -48,10 +65,11 @@ export interface IMeasurementsSearchAutocompleteProps { * @return {*} */ export const MeasurementsSearchAutocomplete = (props: IMeasurementsSearchAutocompleteProps) => { - const { selectedOptions, getOptions, onAddMeasurementColumn } = props; + const { selectedOptions, getOptions, onAddMeasurementColumn, ornament, applicableTsns } = props; const [inputValue, setInputValue] = useState(''); const [options, setOptions] = useState([]); + const [isLoading, setIsLoading] = useState(false); const handleSearch = useMemo( () => @@ -62,14 +80,17 @@ export const MeasurementsSearchAutocomplete = (props: IMeasurementsSearchAutocom [getOptions] ); + const taxonomyContext = useTaxonomyContext(); + return ( option.measurement_name} @@ -103,8 +124,10 @@ export const MeasurementsSearchAutocomplete = (props: IMeasurementsSearchAutocom } setInputValue(value); + setIsLoading(true); handleSearch(value, (newOptions) => { setOptions(() => newOptions); + setIsLoading(false); }); }} value={null} // The selected value is not displayed in the input field or tracked by this component @@ -116,53 +139,49 @@ export const MeasurementsSearchAutocomplete = (props: IMeasurementsSearchAutocom } }} renderOption={(renderProps, renderOption) => { + const isApplicable = renderOption.itis_tsn && applicableTsns?.includes(renderOption.itis_tsn); + return ( - - - - {renderOption.itis_tsn} - - {/* - {renderOption.commonNames ? ( - <> - {renderOption.commonNames}  - - ({renderOption.scientificName}) - - - ) : ( - {renderOption.scientificName} - )} - */} - - - - {renderOption.measurement_name} - - + + - {renderOption.measurement_desc} - + name={ + renderOption.itis_tsn + ? taxonomyContext.getCachedSpeciesTaxonomyById(renderOption.itis_tsn)?.scientificName ?? '' + : '' + } + /> + {isApplicable && ornament} + + {renderOption.measurement_name} + + + {renderOption.measurement_desc} + ); @@ -180,6 +199,12 @@ export const MeasurementsSearchAutocomplete = (props: IMeasurementsSearchAutocom + ), + endAdornment: ( + <> + {inputValue && isLoading ? : null} + {params.InputProps.endAdornment} + ) }} data-testid="measurements-autocomplete-input" diff --git a/app/src/features/surveys/observations/observations-table/grid-column-definitions/GridColumnDefinitions.tsx b/app/src/features/surveys/observations/observations-table/grid-column-definitions/GridColumnDefinitions.tsx index 06e7e9315c..fe13e339a2 100644 --- a/app/src/features/surveys/observations/observations-table/grid-column-definitions/GridColumnDefinitions.tsx +++ b/app/src/features/surveys/observations/observations-table/grid-column-definitions/GridColumnDefinitions.tsx @@ -40,6 +40,7 @@ export const TaxonomyColDef = (props: { return { field: 'itis_tsn', headerName: 'Species', + description: 'The observed species, or if the species is unknown, a higher taxon', editable: true, hideable: true, flex: 1, @@ -67,6 +68,7 @@ export const SampleSiteColDef = (props: { return { field: 'survey_sample_site_id', + description: 'A sampling site where the observation was made', headerName: 'Site', editable: true, hideable: true, @@ -111,6 +113,7 @@ export const SampleMethodColDef = (props: { return { field: 'survey_sample_method_id', headerName: 'Method', + description: 'A method with which the observation was made', editable: true, hideable: true, flex: 1, @@ -158,6 +161,7 @@ export const SamplePeriodColDef = (props: { return { field: 'survey_sample_period_id', headerName: 'Period', + description: 'A sampling period in which the observation was made', editable: true, hideable: true, flex: 0, @@ -211,6 +215,7 @@ export const ObservationCountColDef = (props: { return { field: 'count', headerName: 'Count', + description: 'The number of individuals observed', editable: true, hideable: true, type: 'number', @@ -271,6 +276,7 @@ export const ObservationQuantitativeMeasurementColDef = (props: { return { field: measurement.taxon_measurement_id, headerName: measurement.measurement_name, + description: measurement.measurement_desc ?? '', editable: true, hideable: true, sortable: false, @@ -326,6 +332,7 @@ export const ObservationQualitativeMeasurementColDef = (props: { return { field: measurement.taxon_measurement_id, headerName: measurement.measurement_name, + description: measurement.measurement_desc ?? '', editable: true, hideable: true, sortable: false, @@ -355,6 +362,7 @@ export const ObservationQuantitativeEnvironmentColDef = (props: { return { field: String(environment.environment_quantitative_id), headerName: environment.name, + description: environment.description ?? '', editable: true, hideable: true, sortable: false, @@ -409,6 +417,7 @@ export const ObservationQualitativeEnvironmentColDef = (props: { return { field: String(environment.environment_qualitative_id), headerName: environment.name, + description: environment.description ?? '', editable: true, hideable: true, sortable: false, diff --git a/app/src/hooks/api/useObservationApi.ts b/app/src/hooks/api/useObservationApi.ts index 3d53ab2ee6..0ad602d329 100644 --- a/app/src/hooks/api/useObservationApi.ts +++ b/app/src/hooks/api/useObservationApi.ts @@ -8,6 +8,7 @@ import { SupplementaryObservationCountData } from 'interfaces/useObservationApi.interface'; import { EnvironmentTypeIds } from 'interfaces/useReferenceApi.interface'; +import { IPartialTaxonomy } from 'interfaces/useTaxonomyApi.interface'; import qs from 'qs'; import { ApiPaginationRequestOptions } from 'types/misc'; @@ -121,6 +122,21 @@ const useObservationApi = (axios: AxiosInstance) => { return data; }; + /** + * Retrieves species observed in a given survey + * + * @param {number} projectId + * @param {number} surveyId + * @return {*} {Promise} + */ + const getObservedSpecies = async (projectId: number, surveyId: number): Promise => { + const { data } = await axios.get( + `/api/project/${projectId}/survey/${surveyId}/observations/taxon` + ); + + return data; + }; + /** * Retrieves all survey observation records for the given survey * @@ -295,6 +311,7 @@ const useObservationApi = (axios: AxiosInstance) => { insertUpdateObservationRecords, getObservationRecords, getObservationRecord, + getObservedSpecies, findObservations, getObservationsGeometry, deleteObservationRecords, diff --git a/app/src/hooks/api/useTaxonomyApi.ts b/app/src/hooks/api/useTaxonomyApi.ts index 6aa8d92d52..919fcb8c1d 100644 --- a/app/src/hooks/api/useTaxonomyApi.ts +++ b/app/src/hooks/api/useTaxonomyApi.ts @@ -1,5 +1,5 @@ import { useConfigContext } from 'hooks/useContext'; -import { IPartialTaxonomy, ITaxonomy } from 'interfaces/useTaxonomyApi.interface'; +import { IPartialTaxonomy, ITaxonomy, ITaxonomyHierarchy } from 'interfaces/useTaxonomyApi.interface'; import { startCase } from 'lodash-es'; import qs from 'qs'; import useAxios from './useAxios'; @@ -32,6 +32,25 @@ const useTaxonomyApi = () => { return parseSearchResponse(data.searchResponse); }; + /** + * Retrieves parent taxons for multiple TSNs + * + * @param {number[]} tsns + * @return {*} {Promise} + */ + const getTaxonHierarchyByTSNs = async (tsns: number[]): Promise => { + const { data } = await apiAxios.get('/api/taxonomy/taxon/tsn/hierarchy', { + params: { + tsn: [...new Set(tsns)] + }, + paramsSerializer: (params) => { + return qs.stringify(params); + } + }); + + return data; + }; + /** * Search for taxon records by search terms. * @@ -59,7 +78,8 @@ const useTaxonomyApi = () => { return { getSpeciesFromIds, - searchSpeciesByTerms + searchSpeciesByTerms, + getTaxonHierarchyByTSNs }; }; @@ -74,7 +94,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..018a8dc1a5 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) => { /** @@ -21,13 +22,21 @@ export const useXrefApi = (axios: AxiosInstance) => { /** * Get measurement definitions by search term. * - * @param {string} searchTerm + * @param {string} name + * @param {string[]} tsns * @return {*} {Promise} */ const getMeasurementTypeDefinitionsBySearchTerm = async ( - searchTerm: string + name: string, + tsns?: number[] ): Promise => { - const { data } = await axios.get(`/api/critterbase/xref/taxon-measurements/search?name=${searchTerm}`); + const t = tsns?.map((tsn) => Number(tsn)); + const { data } = await axios.get(`/api/critterbase/xref/taxon-measurements/search`, { + params: { name, tsns: t }, + paramsSerializer: (params) => { + return qs.stringify(params); + } + }); return data; }; diff --git a/app/src/interfaces/useCritterApi.interface.ts b/app/src/interfaces/useCritterApi.interface.ts index 0eaf0dbe4d..f0217dffd8 100644 --- a/app/src/interfaces/useCritterApi.interface.ts +++ b/app/src/interfaces/useCritterApi.interface.ts @@ -389,6 +389,6 @@ export type CBMeasurementSearchByTsnResponse = { * Response object when searching for measurement type definitions by search term. */ export type CBMeasurementSearchByTermResponse = { - qualitative: (CBQualitativeMeasurementTypeDefinition & { tsnHierarchy: number[] })[]; - quantitative: (CBQuantitativeMeasurementTypeDefinition & { tsnHierarchy: number[] })[]; + qualitative: CBQualitativeMeasurementTypeDefinition[]; + quantitative: CBQuantitativeMeasurementTypeDefinition[]; }; diff --git a/app/src/interfaces/useTaxonomyApi.interface.ts b/app/src/interfaces/useTaxonomyApi.interface.ts index 27ce499345..d8779bd256 100644 --- a/app/src/interfaces/useTaxonomyApi.interface.ts +++ b/app/src/interfaces/useTaxonomyApi.interface.ts @@ -21,3 +21,5 @@ export type ITaxonomy = { // to return the extra `rank` and `kingdom` fields, which are currently only available in some of the BioHub taxonomy // endpoints. export type IPartialTaxonomy = Partial & Pick; + +export type ITaxonomyHierarchy = Pick & { hierarchy: number[] };