diff --git a/api/src/constants/dates.ts b/api/src/constants/dates.ts new file mode 100644 index 0000000000..00722e6895 --- /dev/null +++ b/api/src/constants/dates.ts @@ -0,0 +1,22 @@ +/* + * Date formats. + * + * See BC Gov standards: https://www2.gov.bc.ca/gov/content/governments/services-for-government/policies-procedures/web-content-development-guides/writing-for-the-web/web-style-guide/numbers + */ +export const DefaultDateFormat = 'YYYY-MM-DD'; // 2020-01-05 + +export const DefaultDateFormatReverse = 'DD-MM-YYYY'; // 05-01-2020 + +export const AltDateFormat = 'YYYY/MM/DD'; // 2020/01/05 + +export const AltDateFormatReverse = 'DD/MM/YYYY'; // 05/01/2020 + +/* + * Time formats. + */ +export const DefaultTimeFormat = 'HH:mm:ss'; // 23:00:00 + +/* + * Datetime formats. + */ +export const DefaultDateTimeFormat = `${DefaultDateFormat}T${DefaultTimeFormat}`; // 2020-01-05T23:00:00 diff --git a/api/src/models/project-survey-attachments.ts b/api/src/models/project-survey-attachments.ts index 7f025337f2..f7eef80b3c 100644 --- a/api/src/models/project-survey-attachments.ts +++ b/api/src/models/project-survey-attachments.ts @@ -1,4 +1,4 @@ -import { default as dayjs } from 'dayjs'; +import dayjs from 'dayjs'; import { ATTACHMENT_TYPE } from '../constants/attachments'; import { getLogger } from '../utils/logger'; import { SurveySupplementaryData } from './survey-view'; diff --git a/api/src/models/sampling-locations-view.ts b/api/src/models/sampling-locations-view.ts new file mode 100644 index 0000000000..59cf1e842c --- /dev/null +++ b/api/src/models/sampling-locations-view.ts @@ -0,0 +1,19 @@ +export interface ISiteAdvancedFilters { + survey_id?: number; + keyword?: string; + system_user_id?: number; +} + +export interface IMethodAdvancedFilters { + survey_id?: number; + sample_site_id?: number; + keyword?: string; + system_user_id?: number; +} + +export interface IPeriodAdvancedFilters { + survey_id?: number; + sample_site_id?: number; + sample_method_id?: number; + system_user_id?: number; +} diff --git a/api/src/openapi/schemas/observation.ts b/api/src/openapi/schemas/observation.ts index 6d67b3c8ad..00c8579f24 100644 --- a/api/src/openapi/schemas/observation.ts +++ b/api/src/openapi/schemas/observation.ts @@ -1,5 +1,6 @@ import { OpenAPIV3 } from 'openapi-types'; import { paginationResponseSchema } from './pagination'; +import { SampleLocationSchema } from './sample-site'; export const observervationsWithSubcountDataSchema: OpenAPIV3.SchemaObject = { type: 'object', @@ -60,19 +61,27 @@ export const observervationsWithSubcountDataSchema: OpenAPIV3.SchemaObject = { nullable: true }, latitude: { - type: 'number' + type: 'number', + nullable: true, + minimum: -90, + maximum: 90 }, longitude: { - type: 'number' + type: 'number', + nullable: true, + minimum: -180, + maximum: 180 }, count: { type: 'integer' }, observation_date: { - type: 'string' + type: 'string', + nullable: true }, observation_time: { - type: 'string' + type: 'string', + nullable: true }, survey_sample_site_name: { type: 'string', @@ -217,7 +226,8 @@ export const observervationsWithSubcountDataSchema: OpenAPIV3.SchemaObject = { 'qualitative_measurements', 'quantitative_measurements', 'qualitative_environments', - 'quantitative_environments' + 'quantitative_environments', + 'sample_sites' ], properties: { observationCount: { @@ -404,7 +414,8 @@ export const observervationsWithSubcountDataSchema: OpenAPIV3.SchemaObject = { } } } - } + }, + sample_sites: SampleLocationSchema } }, pagination: { ...paginationResponseSchema } diff --git a/api/src/openapi/schemas/sample-site.ts b/api/src/openapi/schemas/sample-site.ts new file mode 100644 index 0000000000..8e6582892b --- /dev/null +++ b/api/src/openapi/schemas/sample-site.ts @@ -0,0 +1,156 @@ +import { OpenAPIV3 } from 'openapi-types'; +import { techniqueSimpleViewSchema } from './technique'; + +export const SampleLocationSchema: OpenAPIV3.SchemaObject = { + type: 'array', + description: 'Sample location response object (includes sites, techniques, periods, stratums, blocks).', + items: { + type: 'object', + additionalProperties: false, + required: ['survey_sample_site_id', 'survey_id', 'name', 'description', 'geometry_type'], + properties: { + survey_sample_site_id: { + type: 'integer', + minimum: 1 + }, + survey_id: { + type: 'integer', + minimum: 1 + }, + name: { + type: 'string', + maxLength: 50 + }, + description: { + type: 'string', + maxLength: 250 + }, + geometry_type: { + type: 'string', + maxLength: 50 + }, + sample_methods: { + type: 'array', + required: [ + 'survey_sample_method_id', + 'survey_sample_site_id', + 'technique', + 'method_response_metric_id', + 'sample_periods' + ], + items: { + type: 'object', + additionalProperties: false, + properties: { + survey_sample_method_id: { + type: 'integer', + minimum: 1 + }, + survey_sample_site_id: { + type: 'integer', + minimum: 1 + }, + technique: techniqueSimpleViewSchema, + method_response_metric_id: { + type: 'integer', + minimum: 1 + }, + description: { + type: 'string', + maxLength: 250 + }, + sample_periods: { + type: 'array', + required: [ + 'survey_sample_period_id', + 'survey_sample_method_id', + 'start_date', + 'start_time', + 'end_date', + 'end_time' + ], + items: { + type: 'object', + additionalProperties: false, + properties: { + survey_sample_period_id: { + type: 'integer', + minimum: 1 + }, + survey_sample_method_id: { + type: 'integer', + minimum: 1 + }, + start_date: { + type: 'string' + }, + start_time: { + type: 'string', + nullable: true + }, + end_date: { + type: 'string' + }, + end_time: { + type: 'string', + nullable: true + } + } + } + } + } + } + }, + blocks: { + type: 'array', + items: { + type: 'object', + additionalProperties: false, + required: ['survey_sample_block_id', 'survey_sample_site_id', 'survey_block_id'], + properties: { + survey_sample_block_id: { + type: 'number' + }, + survey_sample_site_id: { + type: 'number' + }, + survey_block_id: { + type: 'number' + }, + name: { + type: 'string' + }, + description: { + type: 'string' + } + } + } + }, + stratums: { + type: 'array', + items: { + type: 'object', + additionalProperties: false, + required: ['survey_sample_stratum_id', 'survey_sample_site_id', 'survey_stratum_id'], + properties: { + survey_sample_stratum_id: { + type: 'number' + }, + survey_sample_site_id: { + type: 'number' + }, + survey_stratum_id: { + type: 'number' + }, + name: { + type: 'string' + }, + description: { + type: 'string' + } + } + } + } + } + } +}; diff --git a/api/src/paths/observation/index.test.ts b/api/src/paths/observation/index.test.ts index 46b46e9fe7..c366134e4c 100644 --- a/api/src/paths/observation/index.test.ts +++ b/api/src/paths/observation/index.test.ts @@ -104,7 +104,8 @@ describe('findObservations', () => { qualitative_measurements: [], quantitative_measurements: [], qualitative_environments: [], - quantitative_environments: [] + quantitative_environments: [], + sample_sites: [] }); expect(mockRes.jsonValue.pagination).not.to.be.null; diff --git a/api/src/paths/observation/index.ts b/api/src/paths/observation/index.ts index f90f83be54..c7c21917c5 100644 --- a/api/src/paths/observation/index.ts +++ b/api/src/paths/observation/index.ts @@ -209,7 +209,8 @@ export function findObservations(): RequestHandler { qualitative_measurements: [], quantitative_measurements: [], qualitative_environments: [], - quantitative_environments: [] + quantitative_environments: [], + sample_sites: [] }, pagination: makePaginationResponse(observationsTotalCount, paginationOptions) }; diff --git a/api/src/paths/project/{projectId}/survey/{surveyId}/deployments/index.ts b/api/src/paths/project/{projectId}/survey/{surveyId}/deployments/index.ts index 6bee8d9702..5ce785b665 100644 --- a/api/src/paths/project/{projectId}/survey/{surveyId}/deployments/index.ts +++ b/api/src/paths/project/{projectId}/survey/{surveyId}/deployments/index.ts @@ -1,6 +1,7 @@ import dayjs from 'dayjs'; import { RequestHandler } from 'express'; import { Operation } from 'express-openapi'; +import { DefaultDateFormat, DefaultTimeFormat } from '../../../../../../constants/dates'; import { PROJECT_PERMISSION, SYSTEM_ROLE } from '../../../../../../constants/roles'; import { getDBConnection } from '../../../../../../database/db'; import { getDeploymentSchema } from '../../../../../../openapi/schemas/deployment'; @@ -204,16 +205,16 @@ export function getDeploymentsInSurvey(): RequestHandler { assignment_id: matchingBctwDeployments[0].assignment_id, collar_id: matchingBctwDeployments[0].collar_id, attachment_start_date: matchingBctwDeployments[0].attachment_start - ? dayjs(matchingBctwDeployments[0].attachment_start).format('YYYY-MM-DD') + ? dayjs(matchingBctwDeployments[0].attachment_start).format(DefaultDateFormat) : null, attachment_start_time: matchingBctwDeployments[0].attachment_start - ? dayjs(matchingBctwDeployments[0].attachment_start).format('HH:mm:ss') + ? dayjs(matchingBctwDeployments[0].attachment_start).format(DefaultTimeFormat) : null, attachment_end_date: matchingBctwDeployments[0].attachment_end - ? dayjs(matchingBctwDeployments[0].attachment_end).format('YYYY-MM-DD') + ? dayjs(matchingBctwDeployments[0].attachment_end).format(DefaultDateFormat) : null, attachment_end_time: matchingBctwDeployments[0].attachment_end - ? dayjs(matchingBctwDeployments[0].attachment_end).format('HH:mm:ss') + ? dayjs(matchingBctwDeployments[0].attachment_end).format(DefaultTimeFormat) : null, bctw_deployment_id: matchingBctwDeployments[0].deployment_id, device_id: matchingBctwDeployments[0].device_id, diff --git a/api/src/paths/project/{projectId}/survey/{surveyId}/deployments/{deploymentId}/index.ts b/api/src/paths/project/{projectId}/survey/{surveyId}/deployments/{deploymentId}/index.ts index 7cf78b92cd..f4bfe664c1 100644 --- a/api/src/paths/project/{projectId}/survey/{surveyId}/deployments/{deploymentId}/index.ts +++ b/api/src/paths/project/{projectId}/survey/{surveyId}/deployments/{deploymentId}/index.ts @@ -2,6 +2,7 @@ import { AxiosError } from 'axios'; import dayjs from 'dayjs'; import { RequestHandler } from 'express'; import { Operation } from 'express-openapi'; +import { DefaultDateFormat, DefaultTimeFormat } from '../../../../../../../constants/dates'; import { PROJECT_PERMISSION, SYSTEM_ROLE } from '../../../../../../../constants/roles'; import { getDBConnection } from '../../../../../../../database/db'; import { HTTP400 } from '../../../../../../../errors/http-error'; @@ -211,16 +212,16 @@ export function getDeploymentById(): RequestHandler { assignment_id: matchingBctwDeployments[0].assignment_id, collar_id: matchingBctwDeployments[0].collar_id, attachment_start_date: matchingBctwDeployments[0].attachment_start - ? dayjs(matchingBctwDeployments[0].attachment_start).format('YYYY-MM-DD') + ? dayjs(matchingBctwDeployments[0].attachment_start).format(DefaultDateFormat) : null, attachment_start_time: matchingBctwDeployments[0].attachment_start - ? dayjs(matchingBctwDeployments[0].attachment_start).format('HH:mm:ss') + ? dayjs(matchingBctwDeployments[0].attachment_start).format(DefaultTimeFormat) : null, attachment_end_date: matchingBctwDeployments[0].attachment_end - ? dayjs(matchingBctwDeployments[0].attachment_end).format('YYYY-MM-DD') + ? dayjs(matchingBctwDeployments[0].attachment_end).format(DefaultDateFormat) : null, attachment_end_time: matchingBctwDeployments[0].attachment_end - ? dayjs(matchingBctwDeployments[0].attachment_end).format('HH:mm:ss') + ? dayjs(matchingBctwDeployments[0].attachment_end).format(DefaultTimeFormat) : null, bctw_deployment_id: matchingBctwDeployments[0].deployment_id, device_id: matchingBctwDeployments[0].device_id, diff --git a/api/src/paths/project/{projectId}/survey/{surveyId}/observations/index.test.ts b/api/src/paths/project/{projectId}/survey/{surveyId}/observations/index.test.ts index 744c7706a4..758016cbba 100644 --- a/api/src/paths/project/{projectId}/survey/{surveyId}/observations/index.test.ts +++ b/api/src/paths/project/{projectId}/survey/{surveyId}/observations/index.test.ts @@ -160,7 +160,8 @@ describe('getSurveyObservations', () => { qualitative_measurements: [], quantitative_measurements: [], qualitative_environments: [], - quantitative_environments: [] + quantitative_environments: [], + sample_sites: [] } }); @@ -190,7 +191,8 @@ describe('getSurveyObservations', () => { qualitative_measurements: [], quantitative_measurements: [], qualitative_environments: [], - quantitative_environments: [] + quantitative_environments: [], + sample_sites: [] }, pagination: { total: 59, @@ -220,7 +222,8 @@ describe('getSurveyObservations', () => { qualitative_measurements: [], quantitative_measurements: [], qualitative_environments: [], - quantitative_environments: [] + quantitative_environments: [], + sample_sites: [] } }); @@ -248,7 +251,8 @@ describe('getSurveyObservations', () => { qualitative_measurements: [], quantitative_measurements: [], qualitative_environments: [], - quantitative_environments: [] + quantitative_environments: [], + sample_sites: [] }, pagination: { total: 50, @@ -278,7 +282,8 @@ describe('getSurveyObservations', () => { qualitative_measurements: [], quantitative_measurements: [], qualitative_environments: [], - quantitative_environments: [] + quantitative_environments: [], + sample_sites: [] } }); @@ -301,7 +306,8 @@ describe('getSurveyObservations', () => { qualitative_measurements: [], quantitative_measurements: [], qualitative_environments: [], - quantitative_environments: [] + quantitative_environments: [], + sample_sites: [] }, pagination: { total: 2, diff --git a/api/src/paths/project/{projectId}/survey/{surveyId}/observations/index.ts b/api/src/paths/project/{projectId}/survey/{surveyId}/observations/index.ts index 21a471b793..8bae38b00d 100644 --- a/api/src/paths/project/{projectId}/survey/{surveyId}/observations/index.ts +++ b/api/src/paths/project/{projectId}/survey/{surveyId}/observations/index.ts @@ -9,8 +9,11 @@ import { CritterbaseService, getCritterbaseUser } from '../../../../../../servic import { InsertUpdateObservations, ObservationService } from '../../../../../../services/observation-service'; import { ObservationSubCountEnvironmentService } from '../../../../../../services/observation-subcount-environment-service'; import { getLogger } from '../../../../../../utils/logger'; -import { ensureCompletePaginationOptions, makePaginationResponse } from '../../../../../../utils/pagination'; -import { ApiPaginationOptions } from '../../../../../../zod-schema/pagination'; +import { + ensureCompletePaginationOptions, + makePaginationOptionsFromRequest, + makePaginationResponse +} from '../../../../../../utils/pagination'; const defaultLog = getLogger('/api/project/{projectId}/survey/{surveyId}/observation'); @@ -375,19 +378,11 @@ export function getSurveyObservations(): RequestHandler { const surveyId = Number(req.params.surveyId); defaultLog.debug({ label: 'getSurveyObservations', surveyId }); - const page: number | undefined = req.query.page ? Number(req.query.page) : undefined; - const limit: number | undefined = req.query.limit ? Number(req.query.limit) : undefined; - const order: 'asc' | 'desc' | undefined = req.query.order ? (String(req.query.order) as 'asc' | 'desc') : undefined; - - const sortQuery: string | undefined = req.query.sort ? String(req.query.sort) : undefined; - let sort = sortQuery; - - if (sortQuery && samplingSiteSortingColumnName[sortQuery]) { - sort = samplingSiteSortingColumnName[sortQuery]; + const paginationOptions = makePaginationOptionsFromRequest(req); + if (paginationOptions.sort && samplingSiteSortingColumnName[paginationOptions.sort]) { + paginationOptions.sort = samplingSiteSortingColumnName[paginationOptions.sort]; } - const paginationOptions: Partial = { page, limit, order, sort }; - const connection = getDBConnection(req.keycloak_token); try { diff --git a/api/src/paths/project/{projectId}/survey/{surveyId}/observations/process.ts b/api/src/paths/project/{projectId}/survey/{surveyId}/observations/process.ts index cc423f2d94..ab1516617e 100644 --- a/api/src/paths/project/{projectId}/survey/{surveyId}/observations/process.ts +++ b/api/src/paths/project/{projectId}/survey/{surveyId}/observations/process.ts @@ -68,7 +68,7 @@ POST.apiDoc = { surveySamplePeriodId: { type: 'integer', description: - 'The optional ID of a survey sample period to associate the parsed observation records with.' + 'The optional ID of a survey sample period to associate the parsed observation records with. This is used when uploading all observations to a specific sample period, not when each record is for a different sample period.' } } } diff --git a/api/src/paths/project/{projectId}/survey/{surveyId}/observations/spatial.ts b/api/src/paths/project/{projectId}/survey/{surveyId}/observations/spatial.ts index 2e6629ef53..f917c6e264 100644 --- a/api/src/paths/project/{projectId}/survey/{surveyId}/observations/spatial.ts +++ b/api/src/paths/project/{projectId}/survey/{surveyId}/observations/spatial.ts @@ -1,4 +1,3 @@ -import { SchemaObject } from 'ajv'; import { RequestHandler } from 'express'; import { Operation } from 'express-openapi'; import { PROJECT_PERMISSION, SYSTEM_ROLE } from '../../../../../../constants/roles'; @@ -32,123 +31,6 @@ export const GET: Operation = [ getSurveyObservationsGeometry() ]; -export const surveyObservationsSupplementaryData: SchemaObject = { - type: 'object', - additionalProperties: false, - required: ['observationCount'], - properties: { - observationCount: { - type: 'integer', - minimum: 0 - }, - measurementColumns: { - type: 'array', - items: { - anyOf: [ - { - description: 'A quantitative (number) measurement, with possible min/max constraint.', - type: 'object', - additionalProperties: false, - required: [ - 'itis_tsn', - 'taxon_measurement_id', - 'measurement_name', - 'measurement_desc', - 'min_value', - 'max_value', - 'unit' - ], - properties: { - itis_tsn: { - type: 'number', - nullable: true - }, - taxon_measurement_id: { - type: 'string' - }, - measurement_name: { - type: 'string' - }, - measurement_desc: { - type: 'string', - nullable: true - }, - min_value: { - type: 'number', - nullable: true - }, - max_value: { - type: 'number', - nullable: true - }, - unit: { - type: 'string', - nullable: true - } - } - }, - { - description: 'A qualitative (string) measurement, with array of valid/accepted options', - type: 'object', - additionalProperties: false, - required: ['itis_tsn', 'taxon_measurement_id', 'measurement_name', 'measurement_desc', 'options'], - properties: { - itis_tsn: { - type: 'number', - nullable: true - }, - taxon_measurement_id: { - type: 'string' - }, - measurement_name: { - type: 'string' - }, - measurement_desc: { - type: 'string', - nullable: true - }, - options: { - description: 'Valid options for the measurement.', - type: 'array', - items: { - type: 'object', - additionalProperties: false, - required: [ - 'taxon_measurement_id', - 'qualitative_option_id', - 'option_label', - 'option_value', - 'option_desc' - ], - properties: { - taxon_measurement_id: { - type: 'string' - }, - qualitative_option_id: { - type: 'string' - }, - option_label: { - type: 'string', - nullable: true - }, - option_value: { - type: 'number' - }, - option_desc: { - type: 'string', - nullable: true - } - } - } - } - } - } - ] - } - } - } -}; - GET.apiDoc = { description: 'Get all observations for the survey.', tags: ['observation'], diff --git a/api/src/paths/project/{projectId}/survey/{surveyId}/sample-site/index.test.ts b/api/src/paths/project/{projectId}/survey/{surveyId}/sample-site/index.test.ts index 4be0d3eea6..46f77143c1 100644 --- a/api/src/paths/project/{projectId}/survey/{surveyId}/sample-site/index.test.ts +++ b/api/src/paths/project/{projectId}/survey/{surveyId}/sample-site/index.test.ts @@ -16,23 +16,6 @@ describe('getSurveySampleLocationRecord', () => { sinon.restore(); }); - it('should throw a 400 error when no surveyId is provided', async () => { - const dbConnectionObj = getMockDBConnection(); - - sinon.stub(db, 'getDBConnection').returns(dbConnectionObj); - - const { mockReq, mockRes, mockNext } = getRequestHandlerMocks(); - - try { - const requestHandler = get_survey_sample_site_record.getSurveySampleLocationRecord(); - await requestHandler(mockReq, mockRes, mockNext); - expect.fail(); - } catch (actualError) { - expect((actualError as HTTPError).status).to.equal(400); - expect((actualError as HTTPError).message).to.equal('Missing required param `surveyId`'); - } - }); - it('should catch and re-throw an error if SampleLocationService throws an error', async () => { const dbConnectionObj = getMockDBConnection(); @@ -109,37 +92,10 @@ describe('getSurveySampleLocationRecord', () => { describe('createSurveySampleSiteRecord', () => { const dbConnectionObj = getMockDBConnection(); - const sampleReq = { - keycloak_token: {}, - body: { - participants: [[1, 1, 'job']] - }, - params: { - surveyId: 1 - } - } as any; - afterEach(() => { sinon.restore(); }); - it('should throw a 400 error when no surveyId in the param', async () => { - sinon.stub(db, 'getDBConnection').returns(dbConnectionObj); - - try { - const result = create_survey_sample_site_record.createSurveySampleSiteRecord(); - await result( - { ...sampleReq, params: { ...sampleReq.params, surveyId: null } }, - null as unknown as any, - null as unknown as any - ); - expect.fail(); - } catch (actualError) { - expect((actualError as HTTPError).status).to.equal(400); - expect((actualError as HTTPError).message).to.equal('Missing required path param `surveyId`'); - } - }); - it('should work', async () => { sinon.stub(db, 'getDBConnection').returns(dbConnectionObj); diff --git a/api/src/paths/project/{projectId}/survey/{surveyId}/sample-site/index.ts b/api/src/paths/project/{projectId}/survey/{surveyId}/sample-site/index.ts index 3d598335d0..597516446c 100644 --- a/api/src/paths/project/{projectId}/survey/{surveyId}/sample-site/index.ts +++ b/api/src/paths/project/{projectId}/survey/{surveyId}/sample-site/index.ts @@ -2,7 +2,6 @@ import { RequestHandler } from 'express'; import { Operation } from 'express-openapi'; import { PROJECT_PERMISSION, SYSTEM_ROLE } from '../../../../../../constants/roles'; import { getDBConnection } from '../../../../../../database/db'; -import { HTTP400 } from '../../../../../../errors/http-error'; import { GeoJSONFeature } from '../../../../../../openapi/schemas/geoJson'; import { paginationRequestQueryParamSchema, @@ -44,7 +43,7 @@ export const GET: Operation = [ ]; GET.apiDoc = { - description: 'Get all survey sample sites.', + description: 'Get survey sample sites.', tags: ['survey'], security: [ { @@ -70,6 +69,16 @@ GET.apiDoc = { }, required: true }, + { + in: 'query', + name: 'keyword', + schema: { + type: 'string', + description: + 'A keyword to search for in the sample site name or description. If provided, pagination will be ignored.' + }, + required: false + }, ...paginationRequestQueryParamSchema ], responses: { @@ -86,7 +95,7 @@ GET.apiDoc = { items: { type: 'object', additionalProperties: false, - required: ['survey_sample_site_id', 'survey_id', 'name', 'description', 'geojson'], + required: ['survey_sample_site_id', 'survey_id', 'name', 'description', 'geometry_type'], properties: { survey_sample_site_id: { type: 'integer', @@ -104,8 +113,9 @@ GET.apiDoc = { type: 'string', maxLength: 250 }, - geojson: { - ...(GeoJSONFeature as object) + geometry_type: { + type: 'string', + maxLength: 50 }, sample_methods: { type: 'array', @@ -257,29 +267,28 @@ GET.apiDoc = { }; /** - * Get all survey sample sites. + * Get all survey sample sites, paginated or filtered by keyword. * * @returns {RequestHandler} */ export function getSurveySampleLocationRecord(): RequestHandler { return async (req, res) => { - if (!req.params.surveyId) { - throw new HTTP400('Missing required param `surveyId`'); - } - const connection = getDBConnection(req.keycloak_token); try { - await connection.open(); - const surveyId = Number(req.params.surveyId); + + const keyword = req.query.keyword as string | undefined; + const paginationOptions = makePaginationOptionsFromRequest(req); + await connection.open(); + const sampleLocationService = new SampleLocationService(connection); - const sampleSites = await sampleLocationService.getSampleLocationsForSurveyId( - surveyId, - ensureCompletePaginationOptions(paginationOptions) - ); + const sampleSites = await sampleLocationService.getSampleLocationsForSurveyId(surveyId, { + keyword: keyword, + pagination: ensureCompletePaginationOptions(paginationOptions) + }); const sampleSitesTotalCount = await sampleLocationService.getSampleLocationsCountBySurveyId(surveyId); @@ -560,10 +569,6 @@ POST.apiDoc = { export function createSurveySampleSiteRecord(): RequestHandler { return async (req, res) => { - if (!req.params.surveyId) { - throw new HTTP400('Missing required path param `surveyId`'); - } - const connection = getDBConnection(req.keycloak_token); try { @@ -582,7 +587,7 @@ export function createSurveySampleSiteRecord(): RequestHandler { return res.status(201).send(); } catch (error) { - defaultLog.error({ label: 'insertProjectParticipants', message: 'error', error }); + defaultLog.error({ label: 'createSurveySampleSiteRecord', message: 'error', error }); await connection.rollback(); throw error; } finally { diff --git a/api/src/paths/project/{projectId}/survey/{surveyId}/sample-site/spatial.test.ts b/api/src/paths/project/{projectId}/survey/{surveyId}/sample-site/spatial.test.ts new file mode 100644 index 0000000000..e64f42ca3d --- /dev/null +++ b/api/src/paths/project/{projectId}/survey/{surveyId}/sample-site/spatial.test.ts @@ -0,0 +1,72 @@ +import chai, { expect } from 'chai'; +import { describe } from 'mocha'; +import sinon from 'sinon'; +import sinonChai from 'sinon-chai'; +import * as db from '../../../../../../database/db'; +import { HTTPError } from '../../../../../../errors/http-error'; +import { SampleLocationService } from '../../../../../../services/sample-location-service'; +import { getMockDBConnection, getRequestHandlerMocks } from '../../../../../../__mocks__/db'; +import { getSurveySampleSitesGeometry } from './spatial'; + +chai.use(sinonChai); + +describe('getSurveySampleSitesGeometry', () => { + afterEach(() => { + sinon.restore(); + }); + + it('should catch and re-throw an error if SampleLocationService throws an error', async () => { + const dbConnectionObj = getMockDBConnection(); + + sinon.stub(db, 'getDBConnection').returns(dbConnectionObj); + + const { mockReq, mockRes, mockNext } = getRequestHandlerMocks(); + + mockReq.params = { + projectId: '1', + surveyId: '1' + }; + + sinon.stub(SampleLocationService.prototype, 'getSampleLocationsGeometryBySurveyId').rejects(new Error('an error')); + + try { + const requestHandler = getSurveySampleSitesGeometry(); + await requestHandler(mockReq, mockRes, mockNext); + expect.fail(); + } catch (actualError) { + expect((actualError as HTTPError).message).to.equal('an error'); + } + }); + + it('should return sampleSites on success', async () => { + const dbConnectionObj = getMockDBConnection(); + + sinon.stub(db, 'getDBConnection').returns(dbConnectionObj); + + const { mockReq, mockRes, mockNext } = getRequestHandlerMocks(); + + mockReq.params = { + projectId: '1', + surveyId: '1' + }; + + const sampleSiteData = [ + { + survey_sample_site_id: 1, + geojson: { type: 'Feature', properties: {}, geometry: { type: 'Point', coordinates: [0, 0] } } + }, + { + survey_sample_site_id: 2, + geojson: { type: 'Feature', properties: {}, geometry: { type: 'Point', coordinates: [1, 1] } } + } + ]; + + sinon.stub(SampleLocationService.prototype, 'getSampleLocationsGeometryBySurveyId').resolves(sampleSiteData); + + const requestHandler = getSurveySampleSitesGeometry(); + + await requestHandler(mockReq, mockRes, mockNext); + + expect(mockRes.jsonValue).to.eql({ sampleSites: sampleSiteData }); + }); +}); diff --git a/api/src/paths/project/{projectId}/survey/{surveyId}/sample-site/spatial.ts b/api/src/paths/project/{projectId}/survey/{surveyId}/sample-site/spatial.ts new file mode 100644 index 0000000000..cdcd793460 --- /dev/null +++ b/api/src/paths/project/{projectId}/survey/{surveyId}/sample-site/spatial.ts @@ -0,0 +1,143 @@ +import { RequestHandler } from 'express'; +import { Operation } from 'express-openapi'; +import { PROJECT_PERMISSION, SYSTEM_ROLE } from '../../../../../../constants/roles'; +import { getDBConnection } from '../../../../../../database/db'; +import { GeoJSONFeature } from '../../../../../../openapi/schemas/geoJson'; +import { authorizeRequestHandler } from '../../../../../../request-handlers/security/authorization'; +import { SampleLocationService } from '../../../../../../services/sample-location-service'; +import { getLogger } from '../../../../../../utils/logger'; + +const defaultLog = getLogger('paths/project/{projectId}/survey/{surveyId}/sample-site/spatial'); + +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], + discriminator: 'SystemRole' + } + ] + }; + }), + getSurveySampleSitesGeometry() +]; + +GET.apiDoc = { + description: 'Get spatial information for all sample sites in the survey.', + tags: ['survey'], + security: [ + { + Bearer: [] + } + ], + parameters: [ + { + in: 'path', + name: 'projectId', + schema: { + type: 'number', + minimum: 1 + }, + required: true + }, + { + in: 'path', + name: 'surveyId', + schema: { + type: 'number', + minimum: 1 + }, + required: true + } + ], + responses: { + 200: { + description: 'Survey sample sites spatial get response.', + content: { + 'application/json': { + schema: { + type: 'object', + additionalProperties: false, + nullable: true, + required: ['sampleSites'], + properties: { + sampleSites: { + type: 'array', + items: { + type: 'object', + additionalProperties: false, + required: ['survey_sample_site_id', 'geojson'], + properties: { + survey_sample_site_id: { + type: 'integer' + }, + geojson: { ...(GeoJSONFeature as object) } + } + } + } + } + } + } + } + }, + 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 geometry for all sampling sites in the survey + * + * @export + * @return {*} {RequestHandler} + */ +export function getSurveySampleSitesGeometry(): RequestHandler { + return async (req, res) => { + const surveyId = Number(req.params.surveyId); + + defaultLog.debug({ label: 'getSurveySampleSitesGeometry', surveyId }); + + const connection = getDBConnection(req.keycloak_token); + + try { + await connection.open(); + + const sampleSiteService = new SampleLocationService(connection); + + const sampleSiteData = await sampleSiteService.getSampleLocationsGeometryBySurveyId(surveyId); + + await connection.commit(); + + return res.status(200).json({ sampleSites: sampleSiteData }); + } catch (error) { + defaultLog.error({ label: 'getSurveySampleSitesGeometry', message: 'error', error }); + await connection.rollback(); + throw error; + } finally { + connection.release(); + } + }; +} diff --git a/api/src/paths/project/{projectId}/survey/{surveyId}/sample-site/{surveySampleSiteId}/index.test.ts b/api/src/paths/project/{projectId}/survey/{surveyId}/sample-site/{surveySampleSiteId}/index.test.ts index 310e7007a0..5008d9bf7e 100644 --- a/api/src/paths/project/{projectId}/survey/{surveyId}/sample-site/{surveySampleSiteId}/index.test.ts +++ b/api/src/paths/project/{projectId}/survey/{surveyId}/sample-site/{surveySampleSiteId}/index.test.ts @@ -5,7 +5,7 @@ import sinonChai from 'sinon-chai'; import { deleteSurveySampleSiteRecord, getSurveySampleLocationRecord, updateSurveySampleSite } from '.'; import * as db from '../../../../../../../database/db'; import { HTTPError } from '../../../../../../../errors/http-error'; -import { UpdateSampleSiteRecord } from '../../../../../../../repositories/sample-location-repository'; +import { UpdateSampleSiteRecord } from '../../../../../../../repositories/sample-location-repository/sample-location-repository'; import { ObservationService } from '../../../../../../../services/observation-service'; import { SampleLocationService } from '../../../../../../../services/sample-location-service'; import { getMockDBConnection, getRequestHandlerMocks } from '../../../../../../../__mocks__/db'; @@ -212,38 +212,12 @@ describe('deleteSurveySampleSiteRecord', () => { }); describe('getSurveySampleLocationRecord', () => { - const dbConnectionObj = getMockDBConnection(); - - const sampleReq = { - keycloak_token: {}, - params: { - surveyId: 1, - surveySampleSiteId: 1 - } - } as any; - afterEach(() => { sinon.restore(); }); - it('should throw a 400 error when no surveySampleSiteId in the param', async () => { - sinon.stub(db, 'getDBConnection').returns(dbConnectionObj); - - try { - const result = getSurveySampleLocationRecord(); - await result( - { ...sampleReq, params: { ...sampleReq.params, surveySampleSiteId: null } }, - null as unknown as any, - null as unknown as any - ); - expect.fail(); - } catch (actualError) { - expect((actualError as HTTPError).status).to.equal(400); - expect((actualError as HTTPError).message).to.equal('Missing required param `surveySampleSiteId`'); - } - }); - it('should successfully get a sample location record', async () => { + const dbConnectionObj = getMockDBConnection(); sinon.stub(db, 'getDBConnection').returns(dbConnectionObj); const getSurveySampleLocationBySiteIdStub = sinon @@ -264,4 +238,35 @@ describe('getSurveySampleLocationRecord', () => { expect(mockRes.status).to.have.been.calledWith(200); expect(getSurveySampleLocationBySiteIdStub).to.have.been.calledOnce; }); + + it('catches and re-throws error', async () => { + const dbConnectionObj = getMockDBConnection({ + rollback: sinon.stub(), + release: sinon.stub() + }); + sinon.stub(db, 'getDBConnection').returns(dbConnectionObj); + + const mockError = new Error('a test error'); + + sinon.stub(SampleLocationService.prototype, 'getSurveySampleLocationBySiteId').rejects(mockError); + + const { mockReq, mockRes, mockNext } = getRequestHandlerMocks(); + + mockReq.params = { + surveyId: '1', + surveySampleSiteId: '2' + }; + + const requestHandler = getSurveySampleLocationRecord(); + + try { + await requestHandler(mockReq, mockRes, mockNext); + expect.fail(); + } catch (actualError) { + expect((actualError as HTTPError).message).to.equal('a test error'); + + expect(dbConnectionObj.rollback).to.have.been.calledOnce; + expect(dbConnectionObj.release).to.have.been.calledOnce; + } + }); }); diff --git a/api/src/paths/project/{projectId}/survey/{surveyId}/sample-site/{surveySampleSiteId}/index.ts b/api/src/paths/project/{projectId}/survey/{surveyId}/sample-site/{surveySampleSiteId}/index.ts index 4723a69b78..91be150913 100644 --- a/api/src/paths/project/{projectId}/survey/{surveyId}/sample-site/{surveySampleSiteId}/index.ts +++ b/api/src/paths/project/{projectId}/survey/{surveyId}/sample-site/{surveySampleSiteId}/index.ts @@ -5,7 +5,7 @@ import { getDBConnection } from '../../../../../../../database/db'; import { HTTP400, HTTP409 } from '../../../../../../../errors/http-error'; import { GeoJSONFeature } from '../../../../../../../openapi/schemas/geoJson'; import { techniqueSimpleViewSchema } from '../../../../../../../openapi/schemas/technique'; -import { UpdateSampleLocationRecord } from '../../../../../../../repositories/sample-location-repository'; +import { UpdateSampleLocationRecord } from '../../../../../../../repositories/sample-location-repository/sample-location-repository'; import { authorizeRequestHandler } from '../../../../../../../request-handlers/security/authorization'; import { ObservationService } from '../../../../../../../services/observation-service'; import { SampleLocationService } from '../../../../../../../services/sample-location-service'; @@ -427,6 +427,15 @@ GET.apiDoc = { minimum: 1 }, required: true + }, + { + in: 'path', + name: 'surveySampleSiteId', + schema: { + type: 'integer', + minimum: 1 + }, + required: true } ], responses: { @@ -639,13 +648,6 @@ GET.apiDoc = { */ export function getSurveySampleLocationRecord(): RequestHandler { return async (req, res) => { - if (!req.params.surveyId) { - throw new HTTP400('Missing required param `surveyId`'); - } - if (!req.params.surveySampleSiteId) { - throw new HTTP400('Missing required param `surveySampleSiteId`'); - } - const connection = getDBConnection(req.keycloak_token); try { diff --git a/api/src/paths/sampling-locations/methods/index.ts b/api/src/paths/sampling-locations/methods/index.ts new file mode 100644 index 0000000000..7d1c355545 --- /dev/null +++ b/api/src/paths/sampling-locations/methods/index.ts @@ -0,0 +1,297 @@ +import { Request, RequestHandler } from 'express'; +import { Operation } from 'express-openapi'; +import { SYSTEM_ROLE } from '../../../constants/roles'; +import { getDBConnection } from '../../../database/db'; +import { IMethodAdvancedFilters } from '../../../models/sampling-locations-view'; +import { paginationRequestQueryParamSchema } from '../../../openapi/schemas/pagination'; +import { authorizeRequestHandler, userHasValidRole } from '../../../request-handlers/security/authorization'; +import { SampleLocationService } from '../../../services/sample-location-service'; +import { getLogger } from '../../../utils/logger'; +import { ensureCompletePaginationOptions, makePaginationOptionsFromRequest } from '../../../utils/pagination'; +import { getSystemUserFromRequest } from '../../../utils/request'; + +const defaultLog = getLogger('paths/method/index'); + +export const GET: Operation = [ + authorizeRequestHandler(() => { + return { + and: [ + { + discriminator: 'SystemUser' + } + ] + }; + }), + findMethods() +]; + +GET.apiDoc = { + description: "Gets a list of methods based on the user's permissions and filter criteria.", + tags: ['methods'], + security: [ + { + Bearer: [] + } + ], + parameters: [ + { + in: 'query', + name: 'keyword', + required: false, + schema: { + type: 'string', + nullable: true + } + }, + { + in: 'query', + name: 'itis_tsns', + description: 'ITIS TSN numbers', + required: false, + schema: { + type: 'array', + items: { + type: 'integer' + }, + nullable: true + } + }, + { + in: 'query', + name: 'itis_tsn', + description: 'ITIS TSN number', + required: false, + schema: { + type: 'integer', + nullable: true + } + }, + { + in: 'query', + name: 'start_date', + description: 'ISO 8601 date string', + required: false, + schema: { + type: 'string', + nullable: true + } + }, + { + in: 'query', + name: 'end_date', + description: 'ISO 8601 date string', + required: false, + schema: { + type: 'string', + nullable: true + } + }, + { + in: 'query', + name: 'start_time', + description: 'ISO 8601 time string', + required: false, + schema: { + type: 'string', + nullable: true + } + }, + { + in: 'query', + name: 'end_time', + description: 'ISO 8601 time string', + required: false, + schema: { + type: 'string', + nullable: true + } + }, + { + in: 'query', + name: 'min_count', + description: 'Minimum method count (inclusive).', + required: false, + schema: { + type: 'number', + minimum: 0, + nullable: true + } + }, + { + in: 'query', + name: 'system_user_id', + required: false, + schema: { + type: 'number', + minimum: 1, + nullable: true + } + }, + ...paginationRequestQueryParamSchema + ], + responses: { + 200: { + description: 'methods response object.', + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + methods: { + type: 'array', + items: { + type: 'object', + required: [ + 'survey_sample_method_id', + 'survey_sample_site_id', + 'description', + 'method_response_metric_id', + 'technique' + ], + additionalProperties: false, + properties: { + survey_sample_method_id: { + type: 'integer', + minimum: 1 + }, + survey_sample_site_id: { + type: 'integer', + minimum: 1 + }, + description: { + type: 'string', + nullable: true + }, + method_response_metric_id: { + type: 'integer', + minimum: 1 + }, + technique: { + type: 'object', + required: ['method_technique_id', 'name', 'description', 'attractants'], + additionalProperties: false, + properties: { + method_technique_id: { + type: 'integer', + minimum: 1 + }, + name: { + type: 'string' + }, + description: { + type: 'string', + nullable: true + }, + attractants: { + type: 'array', + required: ['attractant_lookup_id'], + additionalProperties: false, + items: { + type: 'object', + properties: { + attractant_lookup_id: { + type: 'integer', + minimum: 1 + } + } + } + } + } + } + } + } + } + } + } + } + } + }, + 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' + } + } +}; + +/** + * Get methods for the current user, based on their permissions and filter criteria. + * + * @returns {RequestHandler} + */ +export function findMethods(): RequestHandler { + return async (req, res) => { + defaultLog.debug({ label: 'findMethods' }); + + const connection = getDBConnection(req.keycloak_token); + + try { + await connection.open(); + + const systemUserId = connection.systemUserId(); + + const systemUser = getSystemUserFromRequest(req); + + const isUserAdmin = userHasValidRole( + [SYSTEM_ROLE.SYSTEM_ADMIN, SYSTEM_ROLE.DATA_ADMINISTRATOR], + systemUser.role_names + ); + + const filterFields = parseQueryParams(req); + + const paginationOptions = makePaginationOptionsFromRequest(req); + + const sampleLocationService = new SampleLocationService(connection); + + const methods = await sampleLocationService.findMethods( + isUserAdmin, + systemUserId, + filterFields, + ensureCompletePaginationOptions(paginationOptions) + ); + + await connection.commit(); + + const response = { + methods: methods + // TODO NICK add count and pagination to response and openapi schema? + }; + + // Allow browsers to cache this response for 30 seconds + res.setHeader('Cache-Control', 'private, max-age=30'); + + return res.status(200).json(response); + } catch (error) { + defaultLog.error({ label: 'getMethods', message: 'error', error }); + await connection.rollback(); + throw error; + } finally { + connection.release(); + } + }; +} + +/** + * Parse the query parameters from the request into the expected format. + * + * @param {Request} req + * @return {*} {IMethodAdvancedFilters} + */ +function parseQueryParams(req: Request): IMethodAdvancedFilters { + return { + survey_id: (req.query.survey_id && Number(req.query.survey_id)) ?? undefined, + sample_site_id: (req.query.sample_site_id && Number(req.query.sample_site_id)) ?? undefined, + keyword: req.query.keyword ?? undefined, + system_user_id: req.query.system_user_id ?? undefined + }; +} diff --git a/api/src/paths/sampling-locations/periods/index.ts b/api/src/paths/sampling-locations/periods/index.ts new file mode 100644 index 0000000000..1ea52eb5df --- /dev/null +++ b/api/src/paths/sampling-locations/periods/index.ts @@ -0,0 +1,274 @@ +import { Request, RequestHandler } from 'express'; +import { Operation } from 'express-openapi'; +import { SYSTEM_ROLE } from '../../../constants/roles'; +import { getDBConnection } from '../../../database/db'; +import { IPeriodAdvancedFilters } from '../../../models/sampling-locations-view'; +import { paginationRequestQueryParamSchema } from '../../../openapi/schemas/pagination'; +import { authorizeRequestHandler, userHasValidRole } from '../../../request-handlers/security/authorization'; +import { SampleLocationService } from '../../../services/sample-location-service'; +import { getLogger } from '../../../utils/logger'; +import { ensureCompletePaginationOptions, makePaginationOptionsFromRequest } from '../../../utils/pagination'; +import { getSystemUserFromRequest } from '../../../utils/request'; + +const defaultLog = getLogger('paths/period/index'); + +export const GET: Operation = [ + authorizeRequestHandler(() => { + return { + and: [ + { + discriminator: 'SystemUser' + } + ] + }; + }), + findPeriods() +]; + +GET.apiDoc = { + description: "Gets a list of periods based on the user's permissions and filter criteria.", + tags: ['periods'], + security: [ + { + Bearer: [] + } + ], + parameters: [ + { + in: 'query', + name: 'keyword', + required: false, + schema: { + type: 'string', + nullable: true + } + }, + { + in: 'query', + name: 'itis_tsns', + description: 'ITIS TSN numbers', + required: false, + schema: { + type: 'array', + items: { + type: 'integer' + }, + nullable: true + } + }, + { + in: 'query', + name: 'itis_tsn', + description: 'ITIS TSN number', + required: false, + schema: { + type: 'integer', + nullable: true + } + }, + { + in: 'query', + name: 'start_date', + description: 'ISO 8601 date string', + required: false, + schema: { + type: 'string', + nullable: true + } + }, + { + in: 'query', + name: 'end_date', + description: 'ISO 8601 date string', + required: false, + schema: { + type: 'string', + nullable: true + } + }, + { + in: 'query', + name: 'start_time', + description: 'ISO 8601 time string', + required: false, + schema: { + type: 'string', + nullable: true + } + }, + { + in: 'query', + name: 'end_time', + description: 'ISO 8601 time string', + required: false, + schema: { + type: 'string', + nullable: true + } + }, + { + in: 'query', + name: 'min_count', + description: 'Minimum period count (inclusive).', + required: false, + schema: { + type: 'number', + minimum: 0, + nullable: true + } + }, + { + in: 'query', + name: 'system_user_id', + required: false, + schema: { + type: 'number', + minimum: 1, + nullable: true + } + }, + ...paginationRequestQueryParamSchema + ], + responses: { + 200: { + description: 'periods response object.', + content: { + 'application/json': { + schema: { + type: 'object', + required: ['periods'], + additionalProperties: false, + properties: { + periods: { + type: 'array', + items: { + type: 'object', + required: [ + 'survey_sample_period_id', + 'survey_sample_method_id', + 'start_date', + 'start_time', + 'end_date', + 'end_time' + ], + additionalProperties: false, + properties: { + survey_sample_period_id: { + type: 'integer', + minimum: 1 + }, + survey_sample_method_id: { + type: 'integer', + minimum: 1 + }, + start_date: { + type: 'string' + }, + start_time: { + type: 'string', + nullable: true + }, + end_date: { + type: 'string' + }, + end_time: { + type: 'string', + nullable: true + } + } + } + } + } + } + } + } + }, + 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' + } + } +}; + +/** + * Get periods for the current user, based on their permissions and filter criteria. + * + * @returns {RequestHandler} + */ +export function findPeriods(): RequestHandler { + return async (req, res) => { + defaultLog.debug({ label: 'findPeriods' }); + + const connection = getDBConnection(req.keycloak_token); + + try { + await connection.open(); + + const systemUserId = connection.systemUserId(); + + const systemUser = getSystemUserFromRequest(req); + + const isUserAdmin = userHasValidRole( + [SYSTEM_ROLE.SYSTEM_ADMIN, SYSTEM_ROLE.DATA_ADMINISTRATOR], + systemUser.role_names + ); + + const filterFields = parseQueryParams(req); + + const paginationOptions = makePaginationOptionsFromRequest(req); + + const sampleLocationService = new SampleLocationService(connection); + + const periods = await sampleLocationService.findPeriods( + isUserAdmin, + systemUserId, + filterFields, + ensureCompletePaginationOptions(paginationOptions) + ); + + await connection.commit(); + + const response = { + periods: periods + // TODO NICK add count and pagination to response and openapi schema? + }; + + // Allow browsers to cache this response for 30 seconds + res.setHeader('Cache-Control', 'private, max-age=30'); + + return res.status(200).json(response); + } catch (error) { + defaultLog.error({ label: 'getPeriods', message: 'error', error }); + await connection.rollback(); + throw error; + } finally { + connection.release(); + } + }; +} + +/** + * Parse the query parameters from the request into the expected format. + * + * @param {Request} req + * @return {*} {IPeriodAdvancedFilters} + */ +function parseQueryParams(req: Request): IPeriodAdvancedFilters { + return { + survey_id: (req.query.survey_id && Number(req.query.survey_id)) ?? undefined, + sample_site_id: (req.query.sample_site_id && Number(req.query.sample_site_id)) ?? undefined, + sample_method_id: (req.query.sample_method_id && Number(req.query.sample_method_id)) ?? undefined, + system_user_id: req.query.system_user_id ?? undefined + }; +} diff --git a/api/src/paths/sampling-locations/sites/index.ts b/api/src/paths/sampling-locations/sites/index.ts new file mode 100644 index 0000000000..295e6475a1 --- /dev/null +++ b/api/src/paths/sampling-locations/sites/index.ts @@ -0,0 +1,197 @@ +import { Request, RequestHandler } from 'express'; +import { Operation } from 'express-openapi'; +import { SYSTEM_ROLE } from '../../../constants/roles'; +import { getDBConnection } from '../../../database/db'; +import { ISiteAdvancedFilters } from '../../../models/sampling-locations-view'; +import { paginationRequestQueryParamSchema } from '../../../openapi/schemas/pagination'; +import { authorizeRequestHandler, userHasValidRole } from '../../../request-handlers/security/authorization'; +import { SampleLocationService } from '../../../services/sample-location-service'; +import { getLogger } from '../../../utils/logger'; +import { ensureCompletePaginationOptions, makePaginationOptionsFromRequest } from '../../../utils/pagination'; +import { getSystemUserFromRequest } from '../../../utils/request'; + +const defaultLog = getLogger('paths/site/index'); + +export const GET: Operation = [ + authorizeRequestHandler(() => { + return { + and: [ + { + discriminator: 'SystemUser' + } + ] + }; + }), + findSites() +]; + +GET.apiDoc = { + description: "Gets a list of sites based on the user's permissions and filter criteria.", + tags: ['sites'], + security: [ + { + Bearer: [] + } + ], + parameters: [ + { + in: 'query', + name: 'survey_id', + required: false, + schema: { + type: 'integer', + minimum: 1, + nullable: true + } + }, + { + in: 'query', + name: 'keyword', + required: false, + schema: { + type: 'string', + nullable: true + } + }, + { + in: 'query', + name: 'system_user_id', + required: false, + schema: { + type: 'number', + minimum: 1, + nullable: true + } + }, + ...paginationRequestQueryParamSchema + ], + responses: { + 200: { + description: 'Sites response object.', + content: { + 'application/json': { + schema: { + type: 'object', + required: ['sites'], + additionalProperties: false, + properties: { + sites: { + type: 'array', + items: { + type: 'object', + required: ['survey_sample_site_id', 'survey_id', 'name', 'description', 'geometry_type'], + additionalProperties: false, + properties: { + survey_sample_site_id: { + type: 'integer', + minimum: 1 + }, + survey_id: { + type: 'number' + }, + name: { + type: 'string' + }, + description: { + type: 'string', + nullable: true + }, + geometry_type: { + type: 'string' + } + } + } + } + } + } + } + } + }, + 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' + } + } +}; + +/** + * Get sites for the current user, based on their permissions and filter criteria. + * + * @returns {RequestHandler} + */ +export function findSites(): RequestHandler { + return async (req, res) => { + defaultLog.debug({ label: 'findSites' }); + + const connection = getDBConnection(req.keycloak_token); + + try { + await connection.open(); + + const systemUserId = connection.systemUserId(); + + const systemUser = getSystemUserFromRequest(req); + + const isUserAdmin = userHasValidRole( + [SYSTEM_ROLE.SYSTEM_ADMIN, SYSTEM_ROLE.DATA_ADMINISTRATOR], + systemUser.role_names + ); + + const filterFields = parseQueryParams(req); + + const paginationOptions = makePaginationOptionsFromRequest(req); + + const sampleLocationService = new SampleLocationService(connection); + + const sites = await sampleLocationService.findSites( + isUserAdmin, + systemUserId, + filterFields, + ensureCompletePaginationOptions(paginationOptions) + ); + + await connection.commit(); + + const response = { + sites: sites + // TODO NICK add count and pagination to response and openapi schema? + }; + + // Allow browsers to cache this response for 30 seconds + res.setHeader('Cache-Control', 'private, max-age=30'); + + return res.status(200).json(response); + } catch (error) { + defaultLog.error({ label: 'getSites', message: 'error', error }); + await connection.rollback(); + throw error; + } finally { + connection.release(); + } + }; +} + +/** + * Parse the query parameters from the request into the expected format. + * + * @param {Request} req + * @return {*} {ISiteAdvancedFilters} + */ +function parseQueryParams(req: Request): ISiteAdvancedFilters { + return { + survey_id: (req.query.survey_id && Number(req.query.survey_id)) ?? undefined, + keyword: req.query.keyword ?? undefined, + system_user_id: req.query.system_user_id ?? undefined + }; +} diff --git a/api/src/repositories/observation-repository/observation-repository.ts b/api/src/repositories/observation-repository/observation-repository.ts index 56576379b5..e35472f3fa 100644 --- a/api/src/repositories/observation-repository/observation-repository.ts +++ b/api/src/repositories/observation-repository/observation-repository.ts @@ -31,11 +31,11 @@ export const ObservationRecord = z.object({ survey_sample_site_id: z.number().nullable(), survey_sample_method_id: z.number().nullable(), survey_sample_period_id: z.number().nullable(), - latitude: z.number(), - longitude: z.number(), + latitude: z.number().nullable(), + longitude: z.number().nullable(), count: z.number(), - observation_time: z.string(), - observation_date: z.string(), + observation_time: z.string().nullable(), + observation_date: z.string().nullable(), create_date: z.string(), create_user: z.number(), update_date: z.string().nullable(), @@ -320,10 +320,10 @@ export class ObservationRepository extends BaseRepository { observation.survey_sample_method_id ?? 'NULL', observation.survey_sample_period_id ?? 'NULL', observation.count, - observation.latitude, - observation.longitude, - `'${observation.observation_date}'`, - `'${observation.observation_time}'`, + observation.latitude ?? 'NULL', + observation.longitude ?? 'NULL', + observation.observation_date ? `'${observation.observation_date}'` : 'NULL', + observation.observation_time ? `'${observation.observation_time}'` : 'NULL', observation.itis_tsn ?? 'NULL', observation.itis_scientific_name ? `'${observation.itis_scientific_name}'` : 'NULL' ].join(', ')})`; @@ -373,6 +373,9 @@ export class ObservationRepository extends BaseRepository { knex.raw("JSON_BUILD_OBJECT('type', 'Point', 'coordinates', JSON_BUILD_ARRAY(longitude, latitude)) as geometry") ) .from('survey_observation') + // TODO: For observations without lat/lon, get a location from the sampling site? + .whereNotNull('latitude') + .whereNotNull('longitude') .where('survey_id', surveyId); const response = await this.connection.knex(query, ObservationGeometryRecord); diff --git a/api/src/repositories/sample-location-repository.test.ts b/api/src/repositories/sample-location-repository/sample-location-repository.test.ts similarity index 84% rename from api/src/repositories/sample-location-repository.test.ts rename to api/src/repositories/sample-location-repository/sample-location-repository.test.ts index 58fb9fa2f1..06ad389054 100644 --- a/api/src/repositories/sample-location-repository.test.ts +++ b/api/src/repositories/sample-location-repository/sample-location-repository.test.ts @@ -3,8 +3,8 @@ import { describe } from 'mocha'; import { QueryResult } from 'pg'; import sinon from 'sinon'; import sinonChai from 'sinon-chai'; -import { ApiExecuteSQLError } from '../errors/api-error'; -import { getMockDBConnection } from '../__mocks__/db'; +import { ApiExecuteSQLError } from '../../errors/api-error'; +import { getMockDBConnection } from '../../__mocks__/db'; import { InsertSampleSiteRecord, SampleLocationRepository, UpdateSampleSiteRecord } from './sample-location-repository'; chai.use(sinonChai); @@ -82,6 +82,37 @@ describe('SampleLocationRepository', () => { }); }); + describe('getBasicSurveySampleLocationsBySiteIds', () => { + it('should successfully return sampling location records with basic data', async () => { + const mockRows = [{ survey_sample_site_id: 1, name: '', sample_methods: [] }]; + const mockResponse = { rows: mockRows, rowCount: 1 } as any as Promise>; + const dbConnectionObj = getMockDBConnection({ knex: () => mockResponse }); + + const surveySampleSiteIds = [1, 2]; + const surveyId = 2; + + const repo = new SampleLocationRepository(dbConnectionObj); + const response = await repo.getBasicSurveySampleLocationsBySiteIds(surveyId, surveySampleSiteIds); + + expect(response).to.eql(mockRows); + }); + }); + + describe('getSampleLocationsGeometryBySurveyId', () => { + it('should return sample site geometries', async () => { + const mockRows = [{ survey_sample_site_id: 1 }]; + const mockResponse = { rows: mockRows, rowCount: 1 } as any as Promise>; + const dbConnectionObj = getMockDBConnection({ sql: sinon.stub().resolves(mockResponse) }); + + const surveyId = 2; + + const repo = new SampleLocationRepository(dbConnectionObj); + const response = await repo.getSampleLocationsGeometryBySurveyId(surveyId); + + expect(response).to.eql(mockRows); + }); + }); + describe('updateSampleSite', () => { it('should update the record and return a single row', async () => { const mockRow = {}; diff --git a/api/src/repositories/sample-location-repository.ts b/api/src/repositories/sample-location-repository/sample-location-repository.ts similarity index 63% rename from api/src/repositories/sample-location-repository.ts rename to api/src/repositories/sample-location-repository/sample-location-repository.ts index e8731c44bd..6770d0cf49 100644 --- a/api/src/repositories/sample-location-repository.ts +++ b/api/src/repositories/sample-location-repository/sample-location-repository.ts @@ -1,26 +1,38 @@ import { Feature } from 'geojson'; import SQL from 'sql-template-strings'; import { z } from 'zod'; -import { getKnex } from '../database/db'; -import { ApiExecuteSQLError } from '../errors/api-error'; -import { generateGeometryCollectionSQL } from '../utils/spatial-utils'; -import { ApiPaginationOptions } from '../zod-schema/pagination'; -import { BaseRepository } from './base-repository'; -import { SampleBlockRecord, UpdateSampleBlockRecord } from './sample-blocks-repository'; -import { SampleMethodRecord, UpdateSampleMethodRecord } from './sample-method-repository'; -import { SamplePeriodRecord } from './sample-period-repository'; -import { SampleStratumRecord, UpdateSampleStratumRecord } from './sample-stratums-repository'; +import { getKnex } from '../../database/db'; +import { ApiExecuteSQLError } from '../../errors/api-error'; +import { + IMethodAdvancedFilters, + IPeriodAdvancedFilters, + ISiteAdvancedFilters +} from '../../models/sampling-locations-view'; +import { generateGeometryCollectionSQL } from '../../utils/spatial-utils'; +import { ApiPaginationOptions } from '../../zod-schema/pagination'; +import { BaseRepository } from '../base-repository'; +import { SampleBlockRecord, UpdateSampleBlockRecord } from '../sample-blocks-repository'; +import { SampleMethodRecord, UpdateSampleMethodRecord } from '../sample-method-repository'; +import { SamplePeriodRecord } from '../sample-period-repository'; +import { SampleStratumRecord, UpdateSampleStratumRecord } from '../sample-stratums-repository'; +import { + getSamplingLocationBaseQuery, + makeFindSamplingMethodBaseQuery, + makeFindSamplingPeriodBaseQuery, + makeFindSamplingSiteBaseQuery +} from './utils'; /** - * An aggregate record that includes a single sample site, all of its child sample methods, and for each child sample - * method, all of its child sample periods. Also includes any survey blocks or survey stratums that this site belongs to. + * An aggregate record of a sample site without spatial data, including all of the child sample methods, + * and for each child sample method, all of its child sample periods. Also includes any survey blocks or survey + * stratums that this site belongs to. */ -export const SampleLocationRecord = z.object({ +export const SampleLocationNonSpatialRecord = z.object({ survey_sample_site_id: z.number(), survey_id: z.number(), name: z.string(), description: z.string().nullable(), - geojson: z.any(), + geometry_type: z.string(), sample_methods: z.array( SampleMethodRecord.pick({ survey_sample_method_id: true, @@ -73,13 +85,63 @@ export const SampleLocationRecord = z.object({ }) ) }); +export type SampleLocationNonSpatialRecord = z.infer; + +/** + * Basic sample location data retrieved for supplementary observations data + */ +export const SampleLocationBasicRecord = z.object({ + survey_sample_site_id: z.number(), + name: z.string(), + sample_methods: z.array( + SampleMethodRecord.pick({ + survey_sample_method_id: true, + survey_sample_site_id: true, + method_response_metric_id: true + }).extend( + z.object({ + technique: z.object({ + method_technique_id: z.number(), + name: z.string() + }), + sample_periods: z.array( + SamplePeriodRecord.pick({ + survey_sample_period_id: true, + survey_sample_method_id: true, + start_date: true, + start_time: true, + end_date: true, + end_time: true + }) + ) + }).shape + ) + ) +}); +export type SampleLocationBasicRecord = z.infer; + +/** + * An aggregate record that includes a single sample site, its location, all of its child sample methods, and for each child sample + * method, all of its child sample periods. Also includes any survey blocks or survey stratums that this site belongs to. + */ +export const SampleLocationRecord = SampleLocationNonSpatialRecord.omit({ geometry_type: true }).extend({ + geojson: z.any() +}); export type SampleLocationRecord = z.infer; /** - * A survey_sample_site record. + * A survey_sample_site geometry */ -export const SampleSiteRecord = z.object({ +export const SampleSiteGeometryRecord = z.object({ survey_sample_site_id: z.number(), + geojson: z.any() +}); +export type SampleSiteGeometryRecord = z.infer; + +/** + * A survey_sample_site record. + */ +export const SampleSiteRecord = SampleSiteGeometryRecord.extend({ survey_id: z.number(), name: z.string(), description: z.string().nullable(), @@ -133,14 +195,26 @@ export class SampleLocationRepository extends BaseRepository { * Gets a paginated set of Sample Locations for the given survey for a given Survey * * @param {number} surveyId - * @return {*} {Promise} + * @param {{ + * keyword?: string; + * sampleSiteIds?: number[]; + * pagination?: ApiPaginationOptions; + * }} [options] + * @return {*} {Promise} * @memberof SampleLocationRepository */ async getSampleLocationsForSurveyId( surveyId: number, - pagination?: ApiPaginationOptions - ): Promise { + options?: { + keyword?: string; + sampleSiteIds?: number[]; + pagination?: ApiPaginationOptions; + } + ): Promise { + const { keyword, sampleSiteIds, pagination } = options || {}; + const knex = getKnex(); + const queryBuilder = knex .queryBuilder() .with('w_method_technique_attractant', (qb) => { @@ -246,7 +320,7 @@ export class SampleLocationRepository extends BaseRepository { 'sss.survey_id', 'sss.name', 'sss.description', - 'sss.geojson', + knex.raw(`sss.geojson->'geometry'->>'type' as geometry_type`), knex.raw(` COALESCE(wssm.sample_methods, '[]'::json) as sample_methods, COALESCE(wssb.blocks, '[]'::json) as blocks, @@ -258,7 +332,18 @@ export class SampleLocationRepository extends BaseRepository { .leftJoin('w_survey_sample_stratum as wssst', 'wssst.survey_sample_site_id', 'sss.survey_sample_site_id') .where('sss.survey_id', surveyId); - if (pagination) { + if (sampleSiteIds) { + // Filter results by sample site IDs + queryBuilder.whereIn('sss.survey_sample_site_id', sampleSiteIds); + } + + if (keyword) { + // Filter results by keyword + queryBuilder.andWhere((qb) => { + qb.orWhere('sss.name', 'ilike', `%${keyword}%`).orWhere('sss.description', 'ilike', `%${keyword}%`); + }); + } else if (pagination) { + // Filter results by pagination queryBuilder.limit(pagination.limit).offset((pagination.page - 1) * pagination.limit); if (pagination.sort && pagination.order) { @@ -266,7 +351,7 @@ export class SampleLocationRepository extends BaseRepository { } } - const response = await this.connection.knex(queryBuilder, SampleLocationRecord); + const response = await this.connection.knex(queryBuilder, SampleLocationNonSpatialRecord); return response.rows; } @@ -341,38 +426,42 @@ export class SampleLocationRepository extends BaseRepository { * @memberof SampleLocationService */ async getSurveySampleLocationBySiteId(surveyId: number, surveySampleSiteId: number): Promise { + const knex = getKnex(); + const queryBuilder = getSamplingLocationBaseQuery(knex) + .where('sss.survey_id', surveyId) + .where('sss.survey_sample_site_id', surveySampleSiteId); + + const response = await this.connection.knex(queryBuilder, SampleLocationRecord); + + if (!response.rowCount) { + throw new ApiExecuteSQLError('Failed to get sample site by ID', [ + 'SampleLocationRepository->getSurveySampleLocationBySiteId', + 'rowCount was < 1, expected rowCount > 0' + ]); + } + + return response.rows[0]; + } + + /** + * Gets basic data for survey sample sites for supplementary observations data + * + * @param {number} surveyId + * @param {number[]} surveySampleSiteIds + * @return {*} {Promise} + * @memberof SampleLocationService + */ + async getBasicSurveySampleLocationsBySiteIds( + surveyId: number, + surveySampleSiteIds: number[] + ): Promise { const knex = getKnex(); const queryBuilder = knex .queryBuilder() - .with('w_method_technique_attractant', (qb) => { - // Gather technique attractants - qb.select( - 'mta.method_technique_id', - knex.raw(` - json_agg(json_build_object( - 'attractant_lookup_id', mta.attractant_lookup_id - )) as attractants`) - ) - .from({ mta: 'method_technique_attractant' }) - .groupBy('mta.method_technique_id'); - }) .with('w_method_technique', (qb) => { - // Gather method techniques - qb.select( - 'mt.method_technique_id', - knex.raw(` - json_build_object( - 'method_technique_id', mt.method_technique_id, - 'name', mt.name, - 'description', mt.description, - 'attractants', COALESCE(wmta.attractants, '[]'::json) - ) as method_technique`) - ) - .from({ mt: 'method_technique' }) - .leftJoin('w_method_technique_attractant as wmta', 'wmta.method_technique_id', 'mt.method_technique_id'); + qb.select('mt.method_technique_id', 'mt.name').from({ mt: 'method_technique' }); }) .with('w_survey_sample_period', (qb) => { - // Aggregate sample periods into an array of objects qb.select( 'ssp.survey_sample_method_id', knex.raw(` @@ -380,8 +469,8 @@ export class SampleLocationRepository extends BaseRepository { 'survey_sample_period_id', ssp.survey_sample_period_id, 'survey_sample_method_id', ssp.survey_sample_method_id, 'start_date', ssp.start_date, - 'start_time', ssp.start_time, 'end_date', ssp.end_date, + 'start_time', ssp.start_time, 'end_time', ssp.end_time ) ORDER BY ssp.start_date, ssp.start_time) as sample_periods`) ) @@ -389,18 +478,18 @@ export class SampleLocationRepository extends BaseRepository { .groupBy('ssp.survey_sample_method_id'); }) .with('w_survey_sample_method', (qb) => { - // Aggregate sample methods into an array of objects and include the corresponding sample periods qb.select( 'ssm.survey_sample_site_id', knex.raw(` json_agg(json_build_object( 'survey_sample_method_id', ssm.survey_sample_method_id, 'survey_sample_site_id', ssm.survey_sample_site_id, - - 'technique', wmt.method_technique, - 'description', ssm.description, - 'sample_periods', COALESCE(wssp.sample_periods, '[]'::json), - 'method_response_metric_id', ssm.method_response_metric_id + 'method_response_metric_id', ssm.method_response_metric_id, + 'technique', json_build_object( + 'method_technique_id', wmt.method_technique_id, + 'name', wmt.name + ), + 'sample_periods', COALESCE(wssp.sample_periods, '[]'::json) )) as sample_methods`) ) .from({ ssm: 'survey_sample_method' }) @@ -408,69 +497,175 @@ export class SampleLocationRepository extends BaseRepository { .leftJoin('w_method_technique as wmt', 'wmt.method_technique_id', 'ssm.method_technique_id') .groupBy('ssm.survey_sample_site_id'); }) - .with('w_survey_sample_block', (qb) => { - // Aggregate sample blocks into an array of objects - qb.select( - 'ssb.survey_sample_site_id', - knex.raw(` - json_agg(json_build_object( - 'survey_sample_block_id', ssb.survey_sample_block_id, - 'survey_sample_site_id', ssb.survey_sample_site_id, - 'survey_block_id', ssb.survey_block_id, - 'name', sb.name, - 'description', sb.description - )) as blocks`) - ) - .from({ ssb: 'survey_sample_block' }) - .leftJoin('survey_block as sb', 'sb.survey_block_id', 'ssb.survey_block_id') - .groupBy('ssb.survey_sample_site_id'); - }) - .with('w_survey_sample_stratum', (qb) => { - // Aggregate sample stratums into an array of objects - qb.select( - 'ssst.survey_sample_site_id', - knex.raw(` - json_agg(json_build_object( - 'survey_sample_stratum_id', ssst.survey_sample_stratum_id, - 'survey_sample_site_id', ssst.survey_sample_site_id, - 'survey_stratum_id', ssst.survey_stratum_id, - 'name', ss.name, - 'description', ss.description - )) as stratums`) - ) - .from({ ssst: 'survey_sample_stratum' }) - .leftJoin('survey_stratum as ss', 'ss.survey_stratum_id', 'ssst.survey_stratum_id') - .groupBy('ssst.survey_sample_site_id'); - }) - // Fetch sample sites and include the corresponding sample methods, blocks, and stratums .select( 'sss.survey_sample_site_id', - 'sss.survey_id', 'sss.name', - 'sss.description', - 'sss.geojson', knex.raw(` - COALESCE(wssm.sample_methods, '[]'::json) as sample_methods, - COALESCE(wssb.blocks, '[]'::json) as blocks, - COALESCE(wssst.stratums, '[]'::json) as stratums`) + COALESCE(wssm.sample_methods, '[]'::json) as sample_methods + `) ) .from({ sss: 'survey_sample_site' }) .leftJoin('w_survey_sample_method as wssm', 'wssm.survey_sample_site_id', 'sss.survey_sample_site_id') - .leftJoin('w_survey_sample_block as wssb', 'wssb.survey_sample_site_id', 'sss.survey_sample_site_id') - .leftJoin('w_survey_sample_stratum as wssst', 'wssst.survey_sample_site_id', 'sss.survey_sample_site_id') .where('sss.survey_id', surveyId) - .where('sss.survey_sample_site_id', surveySampleSiteId); + .whereIn('sss.survey_sample_site_id', surveySampleSiteIds); - const response = await this.connection.knex(queryBuilder, SampleLocationRecord); + const response = await this.connection.knex(queryBuilder, SampleLocationBasicRecord); if (!response.rowCount) { - throw new ApiExecuteSQLError('Failed to get sample site by ID', [ - 'SampleLocationRepository->getSurveySampleSiteById', + throw new ApiExecuteSQLError('Failed to get sample sites by IDs', [ + 'SampleLocationRepository->getBasicSurveySampleLocationsBySiteIds', 'rowCount was < 1, expected rowCount > 0' ]); } - return response.rows[0]; + return response.rows; + } + + /** + * Gets geometry for sampling sites in the survey + * + * @param {number} surveyId + * @return {*} {Promise} + * @memberof SampleLocationRepository + */ + async getSampleLocationsGeometryBySurveyId(surveyId: number): Promise { + const sqlStatement = SQL` + SELECT + survey_sample_site_id, + geojson + FROM + survey_sample_site + WHERE + survey_id = ${surveyId}; + `; + + const response = await this.connection.sql(sqlStatement, SampleSiteGeometryRecord); + + return response.rows; + } + + /** Retrieve the list of sites that the user has access to, based on filters and pagination options. + * + * @param {boolean} isUserAdmin Whether the user is an admin. + * @param {number | null} systemUserId The user's ID. + * @param {IObservationAdvancedFilters} filterFields The filter fields to apply. + * @param {ApiPaginationOptions} [pagination] The pagination options. + * @return {Promise} A promise resolving to the list of sites. + */ + async findSites( + isUserAdmin: boolean, + systemUserId: number | null, + filterFields: ISiteAdvancedFilters, + pagination?: ApiPaginationOptions + ) { + const query = makeFindSamplingSiteBaseQuery(isUserAdmin, systemUserId, filterFields); + + if (pagination) { + query.limit(pagination.limit).offset((pagination.page - 1) * pagination.limit); + + if (pagination.sort && pagination.order) { + query.orderBy(pagination.sort, pagination.order); + } + } + + const response = await this.connection.knex( + query, + z.object({ + survey_sample_site_id: z.number(), + survey_id: z.number(), + name: z.string(), + description: z.string().nullable(), // TODO NICK nullable? + geometry_type: z.string() + }) + ); + + return response.rows; + } + + /** Retrieve the list of methods that the user has access to, based on filters and pagination options. + * + * @param {boolean} isUserAdmin Whether the user is an admin. + * @param {number | null} systemUserId The user's ID. + * @param {IObservationAdvancedFilters} filterFields The filter fields to apply. + * @param {ApiPaginationOptions} [pagination] The pagination options. + * @return {Promise} A promise resolving to the list of methods. + */ + async findMethods( + isUserAdmin: boolean, + systemUserId: number | null, + filterFields: IMethodAdvancedFilters, + pagination?: ApiPaginationOptions + ) { + const query = makeFindSamplingMethodBaseQuery(isUserAdmin, systemUserId, filterFields); + + if (pagination) { + query.limit(pagination.limit).offset((pagination.page - 1) * pagination.limit); + + if (pagination.sort && pagination.order) { + query.orderBy(pagination.sort, pagination.order); + } + } + + const response = await this.connection.knex( + query, + z.object({ + survey_sample_method_id: z.number(), + survey_sample_site_id: z.number(), + description: z.string().nullable(), // TODO NICK nullable? + method_response_metric_id: z.number(), + technique: z.object({ + method_technique_id: z.number(), + name: z.string(), + description: z.string().nullable(), // TODO NICK nullable? + attractants: z.array( + z.object({ + attractant_lookup_id: z.number() + }) + ) + }) + }) + ); + + return response.rows; + } + + /** Retrieve the list of periods that the user has access to, based on filters and pagination options. + * + * @param {boolean} isUserAdmin Whether the user is an admin. + * @param {number | null} systemUserId The user's ID. + * @param {IObservationAdvancedFilters} filterFields The filter fields to apply. + * @param {ApiPaginationOptions} [pagination] The pagination options. + * @return {Promise} A promise resolving to the list of periods. + */ + async findPeriods( + isUserAdmin: boolean, + systemUserId: number | null, + filterFields: IPeriodAdvancedFilters, + pagination?: ApiPaginationOptions + ) { + const query = makeFindSamplingPeriodBaseQuery(isUserAdmin, systemUserId, filterFields); + + if (pagination) { + query.limit(pagination.limit).offset((pagination.page - 1) * pagination.limit); + + if (pagination.sort && pagination.order) { + query.orderBy(pagination.sort, pagination.order); + } + } + + const response = await this.connection.knex( + query, + z.object({ + survey_sample_period_id: z.number(), + survey_sample_method_id: z.number(), + start_date: z.string(), + start_time: z.string().nullable(), + end_date: z.string(), + end_time: z.string().nullable() + }) + ); + + return response.rows; } /** diff --git a/api/src/repositories/sample-location-repository/utils.test.ts b/api/src/repositories/sample-location-repository/utils.test.ts new file mode 100644 index 0000000000..4fde53dbf6 --- /dev/null +++ b/api/src/repositories/sample-location-repository/utils.test.ts @@ -0,0 +1,37 @@ +import { expect } from 'chai'; +import { Knex } from 'knex'; +import { describe, it } from 'mocha'; +import { getKnex } from '../../database/db'; +import { getSamplingLocationBaseQuery } from './utils'; + +describe('getSamplingLocationBaseQuery', () => { + let knex: Knex; + + before(() => { + knex = getKnex(); + }); + + it('should return a query builder object', async () => { + const query = getSamplingLocationBaseQuery(knex); + + expect(query).to.be.an('object'); + expect(query.toString()).to.be.a('string'); + }); + + it('should select survey sample site fields correctly', async () => { + const query = getSamplingLocationBaseQuery(knex).toString(); + + expect(query).to.include('select "sss"."survey_sample_site_id", "sss"."survey_id"'); + expect(query).to.include("COALESCE(wssm.sample_methods, '[]'::json)"); + expect(query).to.include("COALESCE(wssb.blocks, '[]'::json)"); + expect(query).to.include("COALESCE(wssst.stratums, '[]'::json)"); + }); + + it('should join the correct tables', async () => { + const query = getSamplingLocationBaseQuery(knex).toString(); + + expect(query).to.include('left join "w_survey_sample_method" as "wssm"'); + expect(query).to.include('left join "w_survey_sample_block" as "wssb"'); + expect(query).to.include('left join "w_survey_sample_stratum" as "wssst"'); + }); +}); diff --git a/api/src/repositories/sample-location-repository/utils.ts b/api/src/repositories/sample-location-repository/utils.ts new file mode 100644 index 0000000000..9b07ffae08 --- /dev/null +++ b/api/src/repositories/sample-location-repository/utils.ts @@ -0,0 +1,413 @@ +import { Knex } from 'knex'; +import { getKnex } from '../../database/db'; +import { + IMethodAdvancedFilters, + IPeriodAdvancedFilters, + ISiteAdvancedFilters +} from '../../models/sampling-locations-view'; + +/** + * Get the base query for retrieving survey sample locations + * + * @param {Knex} knex The Knex instance. + * @return {*} {Knex.QueryBuilder} The base query for retrieving survey sample locations + */ +export function getSamplingLocationBaseQuery(knex: Knex): Knex.QueryBuilder { + return ( + knex + .queryBuilder() + .with('w_method_technique_attractant', (qb) => { + // Gather technique attractants + qb.select( + 'mta.method_technique_id', + knex.raw(` + json_agg(json_build_object( + 'attractant_lookup_id', mta.attractant_lookup_id + )) as attractants`) + ) + .from({ mta: 'method_technique_attractant' }) + .groupBy('mta.method_technique_id'); + }) + .with('w_method_technique', (qb) => { + // Gather method techniques + qb.select( + 'mt.method_technique_id', + knex.raw(` + json_build_object( + 'method_technique_id', mt.method_technique_id, + 'name', mt.name, + 'description', mt.description, + 'attractants', COALESCE(wmta.attractants, '[]'::json) + ) as method_technique`) + ) + .from({ mt: 'method_technique' }) + .leftJoin('w_method_technique_attractant as wmta', 'wmta.method_technique_id', 'mt.method_technique_id'); + }) + .with('w_survey_sample_period', (qb) => { + // Aggregate sample periods into an array of objects + qb.select( + 'ssp.survey_sample_method_id', + knex.raw(` + json_agg(json_build_object( + 'survey_sample_period_id', ssp.survey_sample_period_id, + 'survey_sample_method_id', ssp.survey_sample_method_id, + 'start_date', ssp.start_date, + 'start_time', ssp.start_time, + 'end_date', ssp.end_date, + 'end_time', ssp.end_time + ) ORDER BY ssp.start_date, ssp.start_time) as sample_periods`) + ) + .from({ ssp: 'survey_sample_period' }) + .groupBy('ssp.survey_sample_method_id'); + }) + .with('w_survey_sample_method', (qb) => { + // Aggregate sample methods into an array of objects and include the corresponding sample periods + qb.select( + 'ssm.survey_sample_site_id', + knex.raw(` + json_agg(json_build_object( + 'survey_sample_method_id', ssm.survey_sample_method_id, + 'survey_sample_site_id', ssm.survey_sample_site_id, + + 'technique', wmt.method_technique, + 'description', ssm.description, + 'sample_periods', COALESCE(wssp.sample_periods, '[]'::json), + 'method_response_metric_id', ssm.method_response_metric_id + )) as sample_methods`) + ) + .from({ ssm: 'survey_sample_method' }) + .leftJoin('w_survey_sample_period as wssp', 'wssp.survey_sample_method_id', 'ssm.survey_sample_method_id') + .leftJoin('w_method_technique as wmt', 'wmt.method_technique_id', 'ssm.method_technique_id') + .groupBy('ssm.survey_sample_site_id'); + }) + .with('w_survey_sample_block', (qb) => { + // Aggregate sample blocks into an array of objects + qb.select( + 'ssb.survey_sample_site_id', + knex.raw(` + json_agg(json_build_object( + 'survey_sample_block_id', ssb.survey_sample_block_id, + 'survey_sample_site_id', ssb.survey_sample_site_id, + 'survey_block_id', ssb.survey_block_id, + 'name', sb.name, + 'description', sb.description + )) as blocks`) + ) + .from({ ssb: 'survey_sample_block' }) + .leftJoin('survey_block as sb', 'sb.survey_block_id', 'ssb.survey_block_id') + .groupBy('ssb.survey_sample_site_id'); + }) + .with('w_survey_sample_stratum', (qb) => { + // Aggregate sample stratums into an array of objects + qb.select( + 'ssst.survey_sample_site_id', + knex.raw(` + json_agg(json_build_object( + 'survey_sample_stratum_id', ssst.survey_sample_stratum_id, + 'survey_sample_site_id', ssst.survey_sample_site_id, + 'survey_stratum_id', ssst.survey_stratum_id, + 'name', ss.name, + 'description', ss.description + )) as stratums`) + ) + .from({ ssst: 'survey_sample_stratum' }) + .leftJoin('survey_stratum as ss', 'ss.survey_stratum_id', 'ssst.survey_stratum_id') + .groupBy('ssst.survey_sample_site_id'); + }) + // Fetch sample sites and include the corresponding sample methods, blocks, and stratums + .select( + 'sss.survey_sample_site_id', + 'sss.survey_id', + 'sss.name', + 'sss.description', + 'sss.geojson', + knex.raw(` + COALESCE(wssm.sample_methods, '[]'::json) as sample_methods, + COALESCE(wssb.blocks, '[]'::json) as blocks, + COALESCE(wssst.stratums, '[]'::json) as stratums`) + ) + .from({ sss: 'survey_sample_site' }) + .leftJoin('w_survey_sample_method as wssm', 'wssm.survey_sample_site_id', 'sss.survey_sample_site_id') + .leftJoin('w_survey_sample_block as wssb', 'wssb.survey_sample_site_id', 'sss.survey_sample_site_id') + .leftJoin('w_survey_sample_stratum as wssst', 'wssst.survey_sample_site_id', 'sss.survey_sample_site_id') + ); +} +/** + * Get the base query for retrieving survey sample sites. + * + * @param {Knex} knex The Knex instance. + * @return {*} {Knex.QueryBuilder} The base query for retrieving survey sample sites + */ +export function getSamplingSiteBaseQuery(queryBuilder: Knex.QueryBuilder): Knex.QueryBuilder { + const knex = getKnex(); + + queryBuilder + .select( + 'sss.survey_sample_site_id', + 'sss.survey_id', + 'sss.name', + 'sss.description', + knex.raw(`sss.geojson->'geometry'->>'type' as geometry_type`) + ) + .from({ sss: 'survey_sample_site' }); + + return queryBuilder; +} + +/** + * Get the base query for retrieving survey sample sites, including blocks and stratums. + * + * @param {Knex} knex The Knex instance. + * @return {*} {Knex.QueryBuilder} The base query for retrieving survey sample sites + */ +export function makeFindSamplingSiteBaseQuery( + isUserAdmin: boolean, + systemUserId: number | null, + filterFields: ISiteAdvancedFilters +): Knex.QueryBuilder { + const knex = getKnex(); + + const getSurveyIdsQuery = knex.select(['survey_id']).from('survey'); + + // Ensure that users can only see observations that they are participating in, unless they are an administrator. + if (!isUserAdmin) { + getSurveyIdsQuery.whereIn('survey.project_id', (subqueryBuilder) => + subqueryBuilder + .select('project.project_id') + .from('project') + .leftJoin('project_participation', 'project_participation.project_id', 'project.project_id') + .where('project_participation.system_user_id', systemUserId) + ); + } + + if (filterFields.system_user_id) { + getSurveyIdsQuery.whereIn('p.project_id', (subQueryBuilder) => { + subQueryBuilder + .select('project_id') + .from('project_participation') + .where('system_user_id', filterFields.system_user_id); + }); + } + + const getSamplingSitesQuery = knex.queryBuilder(); + + // Add the base query + getSamplingSitesQuery.modify(getSamplingSiteBaseQuery); + + // Filter by the survey ids the user has access to + getSamplingSitesQuery.whereIn('sss.survey_id', getSurveyIdsQuery); + + if (filterFields.survey_id) { + // Filter by a specific survey id + getSamplingSitesQuery.andWhere('sss.survey_id', filterFields.survey_id); + } + + if (filterFields.keyword) { + // Filter by keyword + getSamplingSitesQuery.where((subqueryBuilder) => { + subqueryBuilder + .orWhere('sss.name', 'ilike', `%${filterFields.keyword}%`) + .orWhere('sss.description', 'ilike', `%${filterFields.keyword}%`); + }); + } + + return getSamplingSitesQuery; +} + +/** + * Get the base query for retrieving survey sample methods, including the technique and attractants. + * + * @param {Knex} knex The Knex instance. + * @return {*} {Knex.QueryBuilder} The base query for retrieving survey sample methods + */ +export function getSamplingMethodBaseQuery(queryBuilder: Knex.QueryBuilder): Knex.QueryBuilder { + const knex = getKnex(); + + queryBuilder + .with('w_method_technique_attractant', (qb) => { + // Gather technique attractants + qb.select( + 'mta.method_technique_id', + knex.raw(` + json_agg(json_build_object( + 'attractant_lookup_id', mta.attractant_lookup_id + )) as attractants`) + ) + .from({ mta: 'method_technique_attractant' }) + .groupBy('mta.method_technique_id'); + }) + .with('w_method_technique', (qb) => { + // Gather method techniques + qb.select( + 'mt.method_technique_id', + knex.raw(` + json_build_object( + 'method_technique_id', mt.method_technique_id, + 'name', mt.name, + 'description', mt.description, + 'attractants', COALESCE(wmta.attractants, '[]'::json) + ) as method_technique`) + ) + .from({ mt: 'method_technique' }) + .leftJoin('w_method_technique_attractant as wmta', 'wmta.method_technique_id', 'mt.method_technique_id'); + }) + .select( + 'ssm.survey_sample_method_id', + 'ssm.survey_sample_site_id', + 'ssm.description', + 'ssm.method_response_metric_id', + 'wmt.method_technique as technique' + ) + .from({ ssm: 'survey_sample_method' }) + .leftJoin('w_method_technique as wmt', 'wmt.method_technique_id', 'ssm.method_technique_id'); + + return queryBuilder; +} + +/** + * Get the base query for retrieving survey sample methods, including the technique and attractants. + * + * @param {Knex} knex The Knex instance. + * @return {*} {Knex.QueryBuilder} The base query for retrieving survey sample methods + */ +export function makeFindSamplingMethodBaseQuery( + isUserAdmin: boolean, + systemUserId: number | null, + filterFields: IMethodAdvancedFilters +): Knex.QueryBuilder { + const knex = getKnex(); + + const getSurveyIdsQuery = knex.select(['survey_id']).from('survey'); + + // Ensure that users can only see observations that they are participating in, unless they are an administrator. + if (!isUserAdmin) { + getSurveyIdsQuery.whereIn('survey.project_id', (subqueryBuilder) => + subqueryBuilder + .select('project.project_id') + .from('project') + .leftJoin('project_participation', 'project_participation.project_id', 'project.project_id') + .where('project_participation.system_user_id', systemUserId) + ); + } + + if (filterFields.system_user_id) { + getSurveyIdsQuery.whereIn('p.project_id', (subQueryBuilder) => { + subQueryBuilder + .select('project_id') + .from('project_participation') + .where('system_user_id', filterFields.system_user_id); + }); + } + + const getSamplingMethodsQuery = knex.queryBuilder(); + + // Add the base query + getSamplingMethodsQuery.modify(getSamplingMethodBaseQuery); + + // Filter by the survey ids the user has access to + getSamplingMethodsQuery.whereIn('ssm.survey_id', getSurveyIdsQuery); + + if (filterFields.survey_id) { + // Filter by a specific survey id + getSamplingMethodsQuery.andWhere('ssm.survey_id', filterFields.survey_id); + } + + if (filterFields.sample_site_id) { + // Filter by a specific sample site id + getSamplingMethodsQuery.andWhere('ssm.survey_sample_site_id', filterFields.sample_site_id); + } + + if (filterFields.keyword) { + // Filter by keyword + getSamplingMethodsQuery.where((subqueryBuilder) => { + subqueryBuilder + .orWhere('ssm.description', 'ilike', `%${filterFields.keyword}%`) + .orWhere('wmt.technique->name', 'ilike', `%${filterFields.keyword}%`) + .orWhere('wmt.technique->description', 'ilike', `%${filterFields.keyword}%`); + }); + } + + return getSamplingMethodsQuery; +} + +/** + * Get the base query for retrieving survey sample periods. + * + * @param {Knex} knex The Knex instance. + * @return {*} {Knex.QueryBuilder} The base query for retrieving survey sample periods + */ +export function getSamplingPeriodBaseQuery(queryBuilder: Knex.QueryBuilder): Knex.QueryBuilder { + queryBuilder + .select( + 'ssp.survey_sample_period_id', + 'ssp.survey_sample_method_id', + 'ssp.start_date', + 'ssp.start_time', + 'ssp.end_date', + 'ssp.end_time' + ) + .from({ ssp: 'survey_sample_period' }); + + return queryBuilder; +} + +/** + * Get the base query for retrieving survey sample periods. + * + * @param {Knex} knex The Knex instance. + * @return {*} {Knex.QueryBuilder} The base query for retrieving survey sample periods + */ +export function makeFindSamplingPeriodBaseQuery( + isUserAdmin: boolean, + systemUserId: number | null, + filterFields: IPeriodAdvancedFilters +): Knex.QueryBuilder { + const knex = getKnex(); + + const getSurveyIdsQuery = knex.select(['survey_id']).from('survey'); + + // Ensure that users can only see observations that they are participating in, unless they are an administrator. + if (!isUserAdmin) { + getSurveyIdsQuery.whereIn('survey.project_id', (subqueryBuilder) => + subqueryBuilder + .select('project.project_id') + .from('project') + .leftJoin('project_participation', 'project_participation.project_id', 'project.project_id') + .where('project_participation.system_user_id', systemUserId) + ); + } + + if (filterFields.system_user_id) { + getSurveyIdsQuery.whereIn('p.project_id', (subQueryBuilder) => { + subQueryBuilder + .select('project_id') + .from('project_participation') + .where('system_user_id', filterFields.system_user_id); + }); + } + + const getSamplingPeriodsQuery = knex.queryBuilder(); + + // Add the base query + getSamplingPeriodsQuery.modify(getSamplingPeriodBaseQuery); + + // Filter by the survey ids the user has access to + getSamplingPeriodsQuery.whereIn('ssp.survey_id', getSurveyIdsQuery); + + if (filterFields.survey_id) { + // Filter by a specific survey id + getSamplingPeriodsQuery.andWhere('ssp.survey_id', filterFields.survey_id); + } + + if (filterFields.sample_site_id) { + // Filter by a specific sample site id + getSamplingPeriodsQuery.andWhere('ssp.survey_sample_site_id', filterFields.sample_site_id); + } + + if (filterFields.sample_method_id) { + // Filter by a specific sample method id + getSamplingPeriodsQuery.andWhere('ssp.survey_sample_method_id', filterFields.sample_method_id); + } + + return getSamplingPeriodsQuery; +} diff --git a/api/src/services/import-services/capture/import-captures-strategy.test.ts b/api/src/services/import-services/capture/import-captures-strategy.test.ts index b7df1d6eff..ec5d8059f3 100644 --- a/api/src/services/import-services/capture/import-captures-strategy.test.ts +++ b/api/src/services/import-services/capture/import-captures-strategy.test.ts @@ -22,18 +22,18 @@ describe('import-captures-service', () => { I1: { t: 's', v: 'RELEASE_LONGITUDE' }, J1: { t: 's', v: 'RELEASE_COMMENT' }, K1: { t: 's', v: 'CAPTURE_COMMENT' }, - A2: { z: 'm/d/yy', t: 'd', v: '2024-10-10T07:00:00.000Z', w: '10/10/24' }, + A2: { t: 's', v: '2024-10-11' }, B2: { t: 's', v: 'Carl' }, C2: { t: 's', v: '10:10:10' }, D2: { t: 'n', w: '90', v: 90 }, E2: { t: 'n', w: '100', v: 100 }, - F2: { z: 'm/d/yy', t: 'd', v: '2024-10-10T07:00:00.000Z', w: '10/10/24' }, + F2: { t: 's', v: '2024-10-10' }, G2: { t: 's', v: '9:09' }, H2: { t: 'n', w: '90', v: 90 }, I2: { t: 'n', w: '90', v: 90 }, J2: { t: 's', v: 'release' }, K2: { t: 's', v: 'capture' }, - A3: { z: 'm/d/yy', t: 'd', v: '2024-10-10T07:00:00.000Z', w: '10/10/24' }, + A3: { z: 'yyyy-mm-dd', t: 'd', v: new Date('2024-10-10T07:00:00.000Z'), w: '2024-10-10' }, B3: { t: 's', v: 'Carlita' }, D3: { t: 'n', w: '90', v: 90 }, E3: { t: 'n', w: '100', v: 100 }, diff --git a/api/src/services/import-services/marking/import-markings-strategy.test.ts b/api/src/services/import-services/marking/import-markings-strategy.test.ts index 4cfc6e192a..074cc472f2 100644 --- a/api/src/services/import-services/marking/import-markings-strategy.test.ts +++ b/api/src/services/import-services/marking/import-markings-strategy.test.ts @@ -24,7 +24,7 @@ describe('ImportMarkingsStrategy', () => { G1: { t: 's', v: 'PRIMARY_COLOUR' }, H1: { t: 's', v: 'SECONDARY_COLOUR' }, I1: { t: 's', v: 'DESCRIPTION' }, // testing alias works - A2: { z: 'm/d/yy', t: 'd', v: '2024-10-10T07:00:00.000Z', w: '10/10/24' }, + A2: { z: 'yyyy-mm-dd', t: 'd', v: new Date('2024-10-10T07:00:00.000Z'), w: '2024-10-10' }, B2: { t: 's', v: 'Carl' }, C2: { t: 's', v: '10:10:12' }, D2: { t: 's', v: 'Left ear' }, // testing case insensitivity @@ -100,8 +100,8 @@ describe('ImportMarkingsStrategy', () => { try { const data = await importCSV(new MediaFile('test', 'test', 'test' as unknown as Buffer), strategy); expect(data).to.deep.equal(2); - } catch (err: any) { - expect.fail(); + } catch (error: any) { + expect.fail(error); } }); }); diff --git a/api/src/services/import-services/measurement/import-measurements-strategy.test.ts b/api/src/services/import-services/measurement/import-measurements-strategy.test.ts index 9503e0569c..c60c76e989 100644 --- a/api/src/services/import-services/measurement/import-measurements-strategy.test.ts +++ b/api/src/services/import-services/measurement/import-measurements-strategy.test.ts @@ -26,12 +26,12 @@ describe('importMeasurementsStrategy', () => { E1: { t: 's', v: 'skull condition' }, F1: { t: 's', v: 'unknown' }, A2: { t: 's', v: 'carl' }, - B2: { z: 'm/d/yy', t: 'd', v: '2024-10-10T07:00:00.000Z', w: '10/10/24' }, + B2: { t: 's', v: '2024-10-10' }, C2: { t: 's', v: '10:10:12' }, D2: { t: 'n', w: '2', v: 2 }, E2: { t: 'n', w: '0', v: 'good' }, A3: { t: 's', v: 'carlita' }, - B3: { z: 'm/d/yy', t: 'd', v: '2024-10-10T07:00:00.000Z', w: '10/10/24' }, + B3: { z: 'yyyy-mm-dd', t: 'd', v: new Date('2024-10-10T07:00:00.000Z'), w: '2024-10-10' }, C3: { t: 's', v: '10:10:12' }, D3: { t: 'n', w: '2', v: 2 }, E3: { t: 'n', w: '0', v: 'good' }, @@ -54,7 +54,7 @@ describe('importMeasurementsStrategy', () => { critter_id: 'A', animal_id: 'carl', itis_tsn: 'tsn1', - captures: [{ capture_id: 'B', capture_date: '10/10/2024', capture_time: '10:10:12' }] + captures: [{ capture_id: 'B', capture_date: '2024-10-10', capture_time: '10:10:12' }] } as any ], [ @@ -63,7 +63,7 @@ describe('importMeasurementsStrategy', () => { critter_id: 'B', animal_id: 'carlita', itis_tsn: 'tsn2', - captures: [{ capture_id: 'B', capture_date: '10/10/2024', capture_time: '10:10:12' }] + captures: [{ capture_id: 'B', capture_date: '2024-10-10', capture_time: '10:10:12' }] } as any ] ]); @@ -143,8 +143,8 @@ describe('importMeasurementsStrategy', () => { } ] }); - } catch (e: any) { - expect.fail(); + } catch (error: any) { + expect.fail(error); } }); }); @@ -183,7 +183,7 @@ describe('importMeasurementsStrategy', () => { const conn = getMockDBConnection(); const strategy = new ImportMeasurementsStrategy(conn, 1); - const row = { ALIAS: 'alias', CAPTURE_DATE: '10/10/2024', CAPTURE_TIME: '10:10:10' }; + const row = { ALIAS: 'alias', CAPTURE_DATE: '2024-10-10', CAPTURE_TIME: '10:10:10' }; const critterAliasMap = new Map([ [ 'alias', @@ -191,7 +191,7 @@ describe('importMeasurementsStrategy', () => { critter_id: 'A', animal_id: 'alias', itis_tsn: 'tsn1', - captures: [{ capture_id: 'B', capture_date: '10/10/2024', capture_time: '10:10:10' }] + captures: [{ capture_id: 'B', capture_date: '2024-10-10', capture_time: '10:10:10' }] } as any ] ]); @@ -205,7 +205,7 @@ describe('importMeasurementsStrategy', () => { const conn = getMockDBConnection(); const strategy = new ImportMeasurementsStrategy(conn, 1); - const row = { ALIAS: 'alias', CAPTURE_DATE: '10/10/2024', CAPTURE_TIME: '10:10:10' }; + const row = { ALIAS: 'alias', CAPTURE_DATE: '2024-10-10', CAPTURE_TIME: '10:10:10' }; const critterAliasMap = new Map([ [ 'alias2', @@ -213,7 +213,7 @@ describe('importMeasurementsStrategy', () => { critter_id: 'A', animal_id: 'alias2', itis_tsn: 'tsn1', - captures: [{ capture_id: 'B', capture_date: '10/10/2024', capture_time: '10:10:10' }] + captures: [{ capture_id: 'B', capture_date: '2024-10-10', capture_time: '10:10:10' }] } as any ] ]); @@ -227,7 +227,7 @@ describe('importMeasurementsStrategy', () => { const conn = getMockDBConnection(); const strategy = new ImportMeasurementsStrategy(conn, 1); - const row = { ALIAS: 'alias', CAPTURE_DATE: '10/10/2024', CAPTURE_TIME: '10:10:10' }; + const row = { ALIAS: 'alias', CAPTURE_DATE: '2024-10-10', CAPTURE_TIME: '10:10:10' }; const critterAliasMap = new Map([ [ 'alias', @@ -235,7 +235,7 @@ describe('importMeasurementsStrategy', () => { critter_id: 'A', animal_id: 'alias', itis_tsn: 'tsn1', - captures: [{ capture_id: 'B', capture_date: '11/11/2024', capture_time: '10:10:10' }] + captures: [{ capture_id: 'B', capture_date: '2024-11-11', capture_time: '10:10:10' }] } as any ] ]); @@ -344,7 +344,7 @@ describe('importMeasurementsStrategy', () => { critter_id: 'A', animal_id: 'alias', itis_tsn: 'tsn1', - captures: [{ capture_id: 'B', capture_date: '10/10/2024', capture_time: '10:10:10' }] + captures: [{ capture_id: 'B', capture_date: '2024-10-10', capture_time: '10:10:10' }] } as any ] ]); @@ -372,7 +372,7 @@ describe('importMeasurementsStrategy', () => { ); validateQualitativeMeasurementCellStub.returns({ error: undefined, optionId: 'C' }); - const rows = [{ ALIAS: 'alias', CAPTURE_DATE: '10/10/2024', CAPTURE_TIME: '10:10:10', measurement: 'length' }]; + const rows = [{ ALIAS: 'alias', CAPTURE_DATE: '2024-10-10', CAPTURE_TIME: '10:10:10', measurement: 'length' }]; const result = await strategy.validateRows(rows, {}); @@ -408,7 +408,7 @@ describe('importMeasurementsStrategy', () => { ); validateQualitativeMeasurementCellStub.returns({ error: undefined, optionId: 'C' }); - const rows = [{ ALIAS: 'alias', CAPTURE_DATE: '10/10/2024', CAPTURE_TIME: '10:10:10', measurement: 'length' }]; + const rows = [{ ALIAS: 'alias', CAPTURE_DATE: '2024-10-10', CAPTURE_TIME: '10:10:10', measurement: 'length' }]; const result = await strategy.validateRows(rows, {}); @@ -444,7 +444,7 @@ describe('importMeasurementsStrategy', () => { ); validateQualitativeMeasurementCellStub.returns({ error: undefined, optionId: 'C' }); - const rows = [{ ALIAS: 'alias', CAPTURE_DATE: '10/10/2024', CAPTURE_TIME: '10:10:10', measurement: 'length' }]; + const rows = [{ ALIAS: 'alias', CAPTURE_DATE: '2024-10-10', CAPTURE_TIME: '10:10:10', measurement: 'length' }]; const result = await strategy.validateRows(rows, {}); @@ -480,7 +480,7 @@ describe('importMeasurementsStrategy', () => { ); validateQualitativeMeasurementCellStub.returns({ error: undefined, optionId: 'C' }); - const rows = [{ ALIAS: 'alias', CAPTURE_DATE: '10/10/2024', CAPTURE_TIME: '10:10:10', measurement: 'length' }]; + const rows = [{ ALIAS: 'alias', CAPTURE_DATE: '2024-10-10', CAPTURE_TIME: '10:10:10', measurement: 'length' }]; const result = await strategy.validateRows(rows, {}); @@ -514,7 +514,7 @@ describe('importMeasurementsStrategy', () => { ); validateQualitativeMeasurementCellStub.returns({ error: 'qualitative failed', optionId: undefined }); - const rows = [{ ALIAS: 'alias', CAPTURE_DATE: '10/10/2024', CAPTURE_TIME: '10:10:10', measurement: 'length' }]; + const rows = [{ ALIAS: 'alias', CAPTURE_DATE: '2024-10-10', CAPTURE_TIME: '10:10:10', measurement: 'length' }]; const result = await strategy.validateRows(rows, {}); @@ -548,7 +548,7 @@ describe('importMeasurementsStrategy', () => { ); validateQuantitativeMeasurementCellStub.returns({ error: 'quantitative failed', value: undefined }); - const rows = [{ ALIAS: 'alias', CAPTURE_DATE: '10/10/2024', CAPTURE_TIME: '10:10:10', measurement: 'length' }]; + const rows = [{ ALIAS: 'alias', CAPTURE_DATE: '2024-10-10', CAPTURE_TIME: '10:10:10', measurement: 'length' }]; const result = await strategy.validateRows(rows, {}); @@ -573,7 +573,7 @@ describe('importMeasurementsStrategy', () => { getRowMetaStub.returns({ critter_id: 'A', tsn: 'tsn1', capture_id: 'C' }); getTsnMeasurementMapStub.resolves(new Map([['tsn1', { quantitative: [], qualitative: [] } as any]])); - const rows = [{ ALIAS: 'alias', CAPTURE_DATE: '10/10/2024', CAPTURE_TIME: '10:10:10', measurement: 'length' }]; + const rows = [{ ALIAS: 'alias', CAPTURE_DATE: '2024-10-10', CAPTURE_TIME: '10:10:10', measurement: 'length' }]; const result = await strategy.validateRows(rows, {}); @@ -607,7 +607,7 @@ describe('importMeasurementsStrategy', () => { ]) ); - const rows = [{ ALIAS: 'alias', CAPTURE_DATE: '10/10/2024', CAPTURE_TIME: '10:10:10', measurement: 'length' }]; + const rows = [{ ALIAS: 'alias', CAPTURE_DATE: '2024-10-10', CAPTURE_TIME: '10:10:10', measurement: 'length' }]; const result = await strategy.validateRows(rows, {}); diff --git a/api/src/services/import-services/utils/datetime.test.ts b/api/src/services/import-services/utils/datetime.test.ts index 24e0452a13..71eb5ac1c6 100644 --- a/api/src/services/import-services/utils/datetime.test.ts +++ b/api/src/services/import-services/utils/datetime.test.ts @@ -25,14 +25,56 @@ describe('formatTimeString', () => { }); describe('areDatesEqual', () => { + const date1 = '2024-10-11'; + const date2 = '24-10-11'; + const date3 = '11-10-2024'; + const date4 = '11-10-24'; + + const date5 = '2024/10/11'; + const date6 = '11/10/2024'; + const date7 = '24/10/11'; + const date8 = '11/10/24'; + it('should be true when dates are equal in all formats', () => { - expect(areDatesEqual('10-10-2024', '10-10-2024')).to.be.true; - expect(areDatesEqual('10-10-2024', '10/10/2024')).to.be.true; - expect(areDatesEqual('10-10-2024', '10/10/24')).to.be.true; - expect(areDatesEqual('10-10-2024', '2024-10-10')).to.be.true; + expect(areDatesEqual(date1, date5)).to.be.true; + + expect(areDatesEqual(date3, date4)).to.be.true; + expect(areDatesEqual(date3, date6)).to.be.true; + expect(areDatesEqual(date3, date8)).to.be.true; + + expect(areDatesEqual(date4, date6)).to.be.true; + expect(areDatesEqual(date4, date8)).to.be.true; + + expect(areDatesEqual(date6, date8)).to.be.true; }); it('should fail if dates are incorrect format', () => { - expect(areDatesEqual('BAD DATE BAD', '10/10/2024')).to.be.false; + expect(areDatesEqual(date1, date2)).to.be.false; + expect(areDatesEqual(date1, date3)).to.be.false; + expect(areDatesEqual(date1, date4)).to.be.false; + expect(areDatesEqual(date1, date6)).to.be.false; + expect(areDatesEqual(date1, date7)).to.be.false; + expect(areDatesEqual(date1, date8)).to.be.false; + expect(areDatesEqual(date2, date3)).to.be.false; + + expect(areDatesEqual(date2, date4)).to.be.false; + expect(areDatesEqual(date2, date5)).to.be.false; + expect(areDatesEqual(date2, date6)).to.be.false; + expect(areDatesEqual(date2, date7)).to.be.false; + expect(areDatesEqual(date2, date8)).to.be.false; + + expect(areDatesEqual(date3, date5)).to.be.false; + expect(areDatesEqual(date3, date7)).to.be.false; + + expect(areDatesEqual(date4, date5)).to.be.false; + expect(areDatesEqual(date4, date7)).to.be.false; + + expect(areDatesEqual(date5, date6)).to.be.false; + expect(areDatesEqual(date5, date7)).to.be.false; + expect(areDatesEqual(date5, date8)).to.be.false; + + expect(areDatesEqual(date6, date7)).to.be.false; + + expect(areDatesEqual(date7, date8)).to.be.false; }); }); diff --git a/api/src/services/observation-service.test.ts b/api/src/services/observation-service.test.ts index 245df3f0b2..016850d9b3 100644 --- a/api/src/services/observation-service.test.ts +++ b/api/src/services/observation-service.test.ts @@ -8,6 +8,7 @@ import { import * as file_utils from '../utils/file-utils'; import { getMockDBConnection } from '../__mocks__/db'; import { ObservationService } from './observation-service'; +import { SampleLocationService } from './sample-location-service'; import { SubCountService } from './subcount-service'; chai.use(sinonChai); @@ -73,7 +74,8 @@ describe('ObservationService', () => { qualitative_measurements: [], quantitative_measurements: [], qualitative_environments: [], - quantitative_environments: [] + quantitative_environments: [], + sample_sites: [] }; const getSurveyObservationsStub = sinon @@ -92,6 +94,10 @@ describe('ObservationService', () => { .stub(SubCountService.prototype, 'getEnvironmentTypeDefinitionsForSurvey') .resolves({ qualitative_environments: [], quantitative_environments: [] }); + const getSampleLocationsForSurveyIdStub = sinon + .stub(SampleLocationService.prototype, 'getSampleLocationsForSurveyId') + .resolves([]); + const surveyId = 1; const observationService = new ObservationService(mockDBConnection); @@ -104,6 +110,7 @@ describe('ObservationService', () => { expect(getSurveyObservationCountStub).to.be.calledOnceWith(surveyId); expect(getMeasurementTypeDefinitionsForSurveyStub).to.be.calledOnceWith(surveyId); expect(getEnvironmentTypeDefinitionsForSurveyStub).to.be.calledOnceWith(surveyId); + expect(getSampleLocationsForSurveyIdStub).to.be.calledOnceWith(surveyId); expect(response).to.eql({ surveyObservations: [ { diff --git a/api/src/services/observation-service.ts b/api/src/services/observation-service.ts index 4be3cc7e36..1f5b0f0c99 100644 --- a/api/src/services/observation-service.ts +++ b/api/src/services/observation-service.ts @@ -1,3 +1,5 @@ +import dayjs from 'dayjs'; +import { DefaultDateFormat } from '../constants/dates'; import { IDBConnection } from '../database/db'; import { ApiGeneralError } from '../errors/api-error'; import { IObservationAdvancedFilters } from '../models/observation-view'; @@ -22,6 +24,7 @@ import { InsertObservationSubCountQualitativeMeasurementRecord, InsertObservationSubCountQuantitativeMeasurementRecord } from '../repositories/observation-subcount-measurement-repository'; +import { SampleLocationRecord } from '../repositories/sample-location-repository/sample-location-repository'; import { SamplePeriodHierarchyIds } from '../repositories/sample-period-repository'; import { generateS3FileKey, getFileFromS3 } from '../utils/file-utils'; import { getLogger } from '../utils/logger'; @@ -65,10 +68,12 @@ import { DBService } from './db-service'; import { ObservationSubCountEnvironmentService } from './observation-subcount-environment-service'; import { ObservationSubCountMeasurementService } from './observation-subcount-measurement-service'; import { PlatformService } from './platform-service'; +import { SampleLocationService } from './sample-location-service'; import { SamplePeriodService } from './sample-period-service'; import { SubCountService } from './subcount-service'; const defaultLog = getLogger('services/observation-service'); +const defaultSubcountSign = 'direct sighting'; /** * An XLSX validation config for the standard columns of an Observation CSV. @@ -79,11 +84,14 @@ const defaultLog = getLogger('services/observation-service'); export const observationStandardColumnValidator = { ITIS_TSN: { type: 'number', aliases: CSV_COLUMN_ALIASES.ITIS_TSN }, COUNT: { type: 'number' }, - OBSERVATION_SUBCOUNT_SIGN: { type: 'code', aliases: CSV_COLUMN_ALIASES.OBSERVATION_SUBCOUNT_SIGN }, - DATE: { type: 'date' }, - TIME: { type: 'string' }, - LATITUDE: { type: 'number', aliases: CSV_COLUMN_ALIASES.LATITUDE }, - LONGITUDE: { type: 'number', aliases: CSV_COLUMN_ALIASES.LONGITUDE }, + OBSERVATION_SUBCOUNT_SIGN: { type: 'code', aliases: CSV_COLUMN_ALIASES.OBSERVATION_SUBCOUNT_SIGN, optional: true }, + DATE: { type: 'date', optional: true }, + TIME: { type: 'string', optional: true }, + LATITUDE: { type: 'number', aliases: CSV_COLUMN_ALIASES.LATITUDE, optional: true }, + LONGITUDE: { type: 'number', aliases: CSV_COLUMN_ALIASES.LONGITUDE, optional: true }, + SAMPLING_SITE: { type: 'string', aliases: CSV_COLUMN_ALIASES.SAMPLING_SITE, optional: true }, + SAMPLING_METHOD: { type: 'string', aliases: CSV_COLUMN_ALIASES.SAMPLING_METHOD, optional: true }, + SAMPLING_PERIOD: { type: 'string', aliases: CSV_COLUMN_ALIASES.SAMPLING_PERIOD, optional: true }, COMMENT: { type: 'string', aliases: CSV_COLUMN_ALIASES.COMMENT, optional: true } } satisfies IXLSXCSVValidator; @@ -128,8 +136,13 @@ export type ObservationMeasurementSupplementaryData = { quantitative_environments: QuantitativeEnvironmentTypeDefinition[]; }; +export type ObservationSamplingSupplementaryData = { + sample_sites: SampleLocationRecord[]; +}; + export type AllObservationSupplementaryData = ObservationCountSupplementaryData & - ObservationMeasurementSupplementaryData; + ObservationMeasurementSupplementaryData & + ObservationSamplingSupplementaryData; export class ObservationService extends DBService { observationRepository: ObservationRepository; @@ -298,16 +311,22 @@ export class ObservationService extends DBService { surveyObservations: ObservationRecordWithSamplingAndSubcountData[]; supplementaryObservationData: AllObservationSupplementaryData; }> { + const sampleLocationService = new SampleLocationService(this.connection); const surveyObservations = await this.observationRepository.getSurveyObservationsWithSamplingDataWithAttributesData( surveyId, pagination ); + const sampleSiteIds = surveyObservations + .filter((obs) => obs.survey_sample_site_id) + .map((observation) => observation.survey_sample_site_id!); + // Get supplementary observation data const observationCount = await this.observationRepository.getSurveyObservationCount(surveyId); const subCountService = new SubCountService(this.connection); const measurementTypeDefinitions = await subCountService.getMeasurementTypeDefinitionsForSurvey(surveyId); const environmentTypeDefinitions = await subCountService.getEnvironmentTypeDefinitionsForSurvey(surveyId); + const sampleLocations = await sampleLocationService.getSampleLocationsForSurveyId(surveyId, { sampleSiteIds }); return { surveyObservations: surveyObservations, @@ -316,7 +335,8 @@ export class ObservationService extends DBService { qualitative_measurements: measurementTypeDefinitions.qualitative_measurements, quantitative_measurements: measurementTypeDefinitions.quantitative_measurements, qualitative_environments: environmentTypeDefinitions.qualitative_environments, - quantitative_environments: environmentTypeDefinitions.quantitative_environments + quantitative_environments: environmentTypeDefinitions.quantitative_environments, + sample_sites: sampleLocations } }; } @@ -545,9 +565,7 @@ export class ObservationService extends DBService { }); // Fetch all measurement type definitions from Critterbase for all unique TSNs - const tsns = worksheetRowObjects.map((row) => - String(row['ITIS_TSN'] ?? row['TSN'] ?? row['TAXON'] ?? row['SPECIES']) - ); + const tsns = worksheetRowObjects.map((row) => String(getColumnCellValue(row, 'ITIS_TSN').cell)); const tsnMeasurementTypeDefinitionMap = await getTsnMeasurementTypeDefinitionMap(tsns, critterBaseService); @@ -556,7 +574,7 @@ export class ObservationService extends DBService { const measurementsToValidate: IMeasurementDataToValidate[] = worksheetRowObjects.flatMap((row) => { return measurementColumnNames.map((columnName) => ({ - tsn: String(row['ITIS_TSN'] ?? row['TSN'] ?? row['TAXON'] ?? row['SPECIES']), + tsn: String(getColumnCellValue(row, 'ITIS_TSN').cell), key: columnName, value: row[columnName] })); @@ -601,8 +619,15 @@ export class ObservationService extends DBService { throw new Error('Failed to process file for importing observations. Environment column validator failed.'); } - // ----------------------------------------------------------------------------------------- + // SAMPLING INFORMATION ----------------------------------------------------------------------------------------- + const sampleLocationService = new SampleLocationService(this.connection); + // Get sampling information for the survey to later validate + const samplingLocations = await sampleLocationService.getSampleLocationsForSurveyId(surveyId); + + // -------------------------------------------------------------------------------------------------------------- + + // SamplePeriodHierarchyIds is only for when all records are being assigned to the same sampling period let samplePeriodHierarchyIds: SamplePeriodHierarchyIds; if (options?.surveySamplePeriodId) { @@ -613,15 +638,19 @@ export class ObservationService extends DBService { ); } + // Get subcount sign options and default option for when sign is null + const codeMap = new Map( + codeTypeDefinitions.OBSERVATION_SUBCOUNT_SIGN.map((option) => [option.name.toLowerCase(), option.id]) + ); + const defaultSubcountSignId = codeMap.get(defaultSubcountSign) || null; + // Merge all the table rows into an array of InsertUpdateObservations[] const newRowData: InsertUpdateObservations[] = worksheetRowObjects.map((row) => { - // TODO: This observationSubcountSignId logic is specifically catered to the observation_subcount_signs code set, - // as it is the only code set currently being used in the observation CSVs, and is required. This logic will need - // to be updated to be more generic if other code sets are used in the future, or if they can be nullable. - const observationSubcountSignId = codeTypeDefinitions.OBSERVATION_SUBCOUNT_SIGN.find( - (option) => - option.name.toLowerCase() === getColumnCellValue(row, 'OBSERVATION_SUBCOUNT_SIGN')?.cell?.toLowerCase() - )?.id; + const observationSubcountSignId = this._getCodeIdFromCellValue( + getColumnCellValue(row, 'OBSERVATION_SUBCOUNT_SIGN').cell, + codeMap, + defaultSubcountSignId + ); const newSubcount: InsertSubCount = { observation_subcount_id: null, @@ -650,14 +679,41 @@ export class ObservationService extends DBService { newSubcount.qualitative_environments = environments.qualitative_environments; newSubcount.quantitative_environments = environments.quantitative_environments; + // If surveySamplePeriodId was included in the initial request, assign all rows to that sampling period + if (options?.surveySamplePeriodId) { + return { + standardColumns: { + survey_id: surveyId, + itis_tsn: getColumnCellValue(row, 'ITIS_TSN').cell as number, + itis_scientific_name: null, + survey_sample_site_id: samplePeriodHierarchyIds?.survey_sample_site_id ?? null, + survey_sample_method_id: samplePeriodHierarchyIds?.survey_sample_method_id ?? null, + survey_sample_period_id: samplePeriodHierarchyIds?.survey_sample_period_id ?? null, + latitude: getColumnCellValue(row, 'LATITUDE').cell as number, + longitude: getColumnCellValue(row, 'LONGITUDE').cell as number, + count: getColumnCellValue(row, 'COUNT').cell as number, + observation_time: getColumnCellValue(row, 'TIME').cell as string, + observation_date: getColumnCellValue(row, 'DATE').cell as string + }, + subcounts: [newSubcount] + }; + } + + // PROCESS AND VALIDATE SAMPLING INFORMATION ----------------------------------------------------------------------------------------- + const samplingData = this._pullSamplingDataFromWorksheetRowObject(row, samplingLocations); + + if (!samplingData && getColumnCellValue(row, 'SAMPLING_SITE').cell) { + throw new Error('Failed to process file for importing observations. Sampling data validator failed.'); + } + return { standardColumns: { survey_id: surveyId, itis_tsn: getColumnCellValue(row, 'ITIS_TSN').cell as number, itis_scientific_name: null, - survey_sample_site_id: samplePeriodHierarchyIds?.survey_sample_site_id ?? null, - survey_sample_method_id: samplePeriodHierarchyIds?.survey_sample_method_id ?? null, - survey_sample_period_id: samplePeriodHierarchyIds?.survey_sample_period_id ?? null, + survey_sample_site_id: samplingData?.sampleSiteId ?? null, + survey_sample_method_id: samplingData?.sampleMethodId ?? null, + survey_sample_period_id: samplingData?.samplePeriodId ?? null, latitude: getColumnCellValue(row, 'LATITUDE').cell as number, longitude: getColumnCellValue(row, 'LONGITUDE').cell as number, count: getColumnCellValue(row, 'COUNT').cell as number, @@ -799,6 +855,136 @@ export class ObservationService extends DBService { return foundEnvironments; } + /** + * Extracts sampling data from the worksheet row object and maps site names, method techniques, and periods + * to their respective IDs using the provided samplingLocations. + * + * @param {Record} row - The current row of the worksheet being processed. + * @param {SampleLocationRecord[]} samplingLocations - The available sampling locations for the survey, used for mapping names to IDs. + * @return { { sampleSiteId: number, sampleMethodId: number, samplePeriodId: number } | null } The sampling data with IDs, or null if no valid data is found. + */ + _pullSamplingDataFromWorksheetRowObject( + row: Record, + samplingLocations: SampleLocationRecord[] + ): { sampleSiteId: number; sampleMethodId: number; samplePeriodId: number } | null { + // Extract site, method, and period data from the row + const siteName = getColumnCellValue(row, 'SAMPLING_SITE').cell as string | null; + const techniqueName = getColumnCellValue(row, 'SAMPLING_METHOD').cell as string | null; + const period = getColumnCellValue(row, 'SAMPLING_PERIOD').cell as string | null; + + if (!siteName) { + return null; + } + + // Find the site record by name + const siteRecord = samplingLocations.find((site) => site.name.toLowerCase() === siteName.toLowerCase()); + + // If there is no site, exit early because a site is required when specifying any sampling information for the row. + if (!siteRecord) { + return null; + } + + let methodRecord = null; + + // Find the method record by technique name + if (techniqueName) { + methodRecord = siteRecord.sample_methods.find( + (method) => method.technique.name.toLowerCase() === techniqueName.toLowerCase() + ); + } + + // If we failed to find a method record based on technique name, we will check whether that site has just 1 technique. + // If the site has 1 technique, we will assume that the row belongs to that technique. + // This is a convenience for users because they only need to specify the sampling site for sites with 1 technique. + if (siteRecord.sample_methods.length === 1) { + methodRecord = siteRecord.sample_methods[0]; + } + + // If there are multiple techniques for the site but no technique specified in the row, + // exit early because we cannot determine which method to use. + if (!methodRecord) { + return null; + } + + // If period is specified, parse the row value and find a matching record + if (period) { + // Format the period timestamp data + const [startDate, endDate] = period.split('-').map((date: string) => dayjs(date).format(DefaultDateFormat)); + const startTime = dayjs(period.split('-')[0]).format('HH:mm:ss'); + const endTime = dayjs(period.split('-')[1]).format('HH:mm:ss'); + + // Find matching periods by date + const matchingPeriods = methodRecord.sample_periods.filter( + (p) => p.start_date === startDate && p.end_date === endDate + ); + + // Return if exactly one period matches the date, + // meaning that we have successfully determined a single site, method, and period Id for each row. + if (matchingPeriods.length === 1) { + return { + sampleSiteId: siteRecord.survey_sample_site_id, + sampleMethodId: methodRecord.survey_sample_method_id, + samplePeriodId: matchingPeriods[0].survey_sample_period_id + }; + } + + // If multiple periods match by date, try to match also by time + const matchingPeriod = matchingPeriods.find((p) => p.start_time === startTime && p.end_time === endTime); + + if (matchingPeriod) { + return { + sampleSiteId: siteRecord.survey_sample_site_id, + sampleMethodId: methodRecord.survey_sample_method_id, + samplePeriodId: matchingPeriod.survey_sample_period_id + }; + } + } + + // If period is not specified, infer it from the row data + const observationDate = getColumnCellValue(row, 'DATE').cell as string | null; + const observationTime = getColumnCellValue(row, 'TIME').cell as string | null; + + const formattedDate = dayjs(observationDate); + const formattedTime = dayjs(`${observationDate} ${observationTime}`).format('HH:mm:ss'); + + // TODO: Fix timezone of the observation date. Observation date is assumed to be UTC instead of local time, + // so the observation date being imported from the csv is incorrectly offset by 1 day. eg. "July 28, 2024" is + // imported at July 27, 2024 + // + // If no periods match by date/time but the site and method is given, check if the observation date falls within a period. + // If true, we will infer the period based on the observation date. + const encompassingPeriod = methodRecord.sample_periods.find( + (p) => + (formattedDate.isAfter(dayjs(p.start_date)) || formattedDate.isSame(dayjs(p.start_date))) && + (formattedDate.isBefore(dayjs(p.end_date)) || formattedDate.isAfter(dayjs(p.end_date))) && + (!p.start_time || formattedTime >= dayjs(`${p.start_date} ${p.start_time}`).format('HH:mm:ss')) && + (!p.end_time || formattedTime <= dayjs(`${p.end_date} ${p.end_time}`).format('HH:mm:ss')) + ); + + if (encompassingPeriod) { + return { + sampleSiteId: siteRecord.survey_sample_site_id, + sampleMethodId: methodRecord.survey_sample_method_id, + samplePeriodId: encompassingPeriod.survey_sample_period_id + }; + } + + // If there is no observation date and exactly 1 period for the matching method, and there is no period specified in the row, + // we will assume that the observation belongs to that period. This is a convenience for users since they don't need to specify + // the period if they have only 1 for the matching method. + // TODO: Might be worth checking if (!observationDate && methodRecord.sample_periods.length === 1), therefore + // failing if the specified date is not in a period + if (methodRecord.sample_periods.length === 1) { + return { + sampleSiteId: siteRecord.survey_sample_site_id, + sampleMethodId: methodRecord.survey_sample_method_id, + samplePeriodId: methodRecord.sample_periods[0].survey_sample_period_id + }; + } + + return null; + } + /** * Maps over an array of inserted/updated observation records in order to update its scientific * name to match its ITIS TSN. @@ -1019,4 +1205,29 @@ export class ObservationService extends DBService { // Return true if both environments and measurements are valid return true; } + + /** + * Gets the code id value with a matching name from a pre-mapped set of options. If the function returns null, the + * request should probably throw an error. + * + * @param cellValue The name of a code to find the id for + * @param codeMap A Map where the key is the normalized code name and the value is the ID + * @param defaultCodeId A precomputed default code ID for cases where cellValue is null + * @returns The ID of the matching code, or the default ID, or null if no match is found + */ + _getCodeIdFromCellValue( + cellValue: string | null, + codeMap: Map, + defaultCodeId?: number | null + ): number | null { + const value = cellValue?.toLowerCase(); // Normalize the cell value + + // If no value exists, return the default code ID or null + if (!value) { + return defaultCodeId ?? null; + } + + // Return the ID from the map if it exists, otherwise return null + return codeMap.get(value) ?? null; + } } diff --git a/api/src/services/sample-location-service.test.ts b/api/src/services/sample-location-service.test.ts index 57a17b7dcc..0f9c6f972d 100644 --- a/api/src/services/sample-location-service.test.ts +++ b/api/src/services/sample-location-service.test.ts @@ -3,7 +3,7 @@ import { describe } from 'mocha'; import sinon from 'sinon'; import sinonChai from 'sinon-chai'; import { UpdateSampleBlockRecord } from '../repositories/sample-blocks-repository'; -import { SampleLocationRepository } from '../repositories/sample-location-repository'; +import { SampleLocationRepository } from '../repositories/sample-location-repository/sample-location-repository'; import { UpdateSampleStratumRecord } from '../repositories/sample-stratums-repository'; import { getMockDBConnection } from '../__mocks__/db'; import { SampleBlockService } from './sample-block-service'; @@ -112,7 +112,7 @@ describe('SampleLocationService', () => { survey_id: 1, name: 'Sample Site 1', description: '', - geojson: [], + geometry_type: 'Point', blocks: [], sample_methods: [], stratums: [] @@ -141,6 +141,50 @@ describe('SampleLocationService', () => { }); }); + describe('getSampleLocationsGeometryBySurveyId', () => { + it('should return the sample site geometries successfully', async () => { + const dbConnectionObj = getMockDBConnection(); + + const mockRows = [{ survey_sample_site_id: 1, geojson: {} }]; + + const repoStub = sinon + .stub(SampleLocationRepository.prototype, 'getSampleLocationsGeometryBySurveyId') + .resolves(mockRows); + + const sampleLocationService = new SampleLocationService(dbConnectionObj); + const response = await sampleLocationService.getSampleLocationsGeometryBySurveyId(1001); + + expect(repoStub).to.be.calledOnceWith(1001); + expect(response).to.eql(mockRows); + }); + }); + + describe('getBasicSurveySampleLocationsBySiteIds', () => { + it('should successfully return sampling location records with basic data', async () => { + const dbConnectionObj = getMockDBConnection(); + + const mockSurveySampleSiteIds = [1, 2]; + const mockRows = mockSurveySampleSiteIds.map((site) => ({ + survey_sample_site_id: site, + name: '', + sample_methods: [] + })); + + const repoStub = sinon + .stub(SampleLocationRepository.prototype, 'getBasicSurveySampleLocationsBySiteIds') + .resolves(mockRows); + + const sampleLocationService = new SampleLocationService(dbConnectionObj); + const response = await sampleLocationService.getBasicSurveySampleLocationsBySiteIds( + 1001, + mockSurveySampleSiteIds + ); + + expect(repoStub).to.be.calledOnceWith(1001); + expect(response).to.eql(mockRows); + }); + }); + describe('deleteSampleSiteRecord', () => { it('should run without issue', async () => { const mockDBConnection = getMockDBConnection(); diff --git a/api/src/services/sample-location-service.ts b/api/src/services/sample-location-service.ts index 0eb3f45d67..427282d7c6 100644 --- a/api/src/services/sample-location-service.ts +++ b/api/src/services/sample-location-service.ts @@ -1,12 +1,19 @@ import { IDBConnection } from '../database/db'; +import { + IMethodAdvancedFilters, + IPeriodAdvancedFilters, + ISiteAdvancedFilters +} from '../models/sampling-locations-view'; import { InsertSampleBlockRecord } from '../repositories/sample-blocks-repository'; import { InsertSampleSiteRecord, + SampleLocationBasicRecord, SampleLocationRecord, SampleLocationRepository, + SampleSiteGeometryRecord, SampleSiteRecord, UpdateSampleLocationRecord -} from '../repositories/sample-location-repository'; +} from '../repositories/sample-location-repository/sample-location-repository'; import { InsertSampleMethodRecord } from '../repositories/sample-method-repository'; import { InsertSampleStratumRecord } from '../repositories/sample-stratums-repository'; import { getLogger } from '../utils/logger'; @@ -46,15 +53,23 @@ export class SampleLocationService extends DBService { * Gets a paginated set of survey Sample Locations for the given survey. * * @param {number} surveyId - * @param {ApiPaginationOptions} [pagination] + * @param {{ + * keyword?: string; + * sampleSiteIds?: number[]; + * pagination?: ApiPaginationOptions; + * }} [options] * @return {*} {Promise} * @memberof SampleLocationService */ async getSampleLocationsForSurveyId( surveyId: number, - pagination?: ApiPaginationOptions + options?: { + keyword?: string; + sampleSiteIds?: number[]; + pagination?: ApiPaginationOptions; + } ): Promise { - return this.sampleLocationRepository.getSampleLocationsForSurveyId(surveyId, pagination); + return this.sampleLocationRepository.getSampleLocationsForSurveyId(surveyId, options); } /** @@ -68,6 +83,17 @@ export class SampleLocationService extends DBService { return this.sampleLocationRepository.getSampleLocationsCountBySurveyId(surveyId); } + /** + * Returns the geometry for all sampling locations in the Survey + * + * @param {number} surveyId + * @return {*} {Promise} + * @memberof SampleLocationService + */ + async getSampleLocationsGeometryBySurveyId(surveyId: number): Promise { + return this.sampleLocationRepository.getSampleLocationsGeometryBySurveyId(surveyId); + } + /** * Gets a sample site record by sample site ID. * @@ -80,6 +106,21 @@ export class SampleLocationService extends DBService { return this.sampleLocationRepository.getSurveySampleSiteById(surveyId, surveySampleSiteId); } + /** + * Gets basic data for survey sample sites for supplementary observations data + * + * @param {number} surveyId + * @param {number[]} surveySampleSiteIds + * @return {*} {Promise} + * @memberof SampleLocationService + */ + async getBasicSurveySampleLocationsBySiteIds( + surveyId: number, + surveySampleSiteIds: number[] + ): Promise { + return this.sampleLocationRepository.getBasicSurveySampleLocationsBySiteIds(surveyId, surveySampleSiteIds); + } + /** * Gets a sample location by sample site ID. * @@ -92,6 +133,66 @@ export class SampleLocationService extends DBService { return this.sampleLocationRepository.getSurveySampleLocationBySiteId(surveyId, surveySampleSiteId); } + /** + * Retrieves the paginated list of all sites that are available to the user, based on their permissions and + * provided filter criteria. + * + * @param {boolean} isUserAdmin + * @param {(number | null)} systemUserId The system user id of the user making the request + * @param {ISiteAdvancedFilters} filterFields + * @param {ApiPaginationOptions} [pagination] + * @return {*} + * @memberof ObservationService + */ + async findSites( + isUserAdmin: boolean, + systemUserId: number | null, + filterFields: ISiteAdvancedFilters, + pagination?: ApiPaginationOptions + ) { + return this.sampleLocationRepository.findSites(isUserAdmin, systemUserId, filterFields, pagination); + } + + /** + * Retrieves the paginated list of all methods that are available to the user, based on their permissions and + * provided filter criteria. + * + * @param {boolean} isUserAdmin + * @param {(number | null)} systemUserId The system user id of the user making the request + * @param {IMethodAdvancedFilters} filterFields + * @param {ApiPaginationOptions} [pagination] + * @return {*} + * @memberof ObservationService + */ + async findMethods( + isUserAdmin: boolean, + systemUserId: number | null, + filterFields: IMethodAdvancedFilters, + pagination?: ApiPaginationOptions + ) { + return this.sampleLocationRepository.findMethods(isUserAdmin, systemUserId, filterFields, pagination); + } + + /** + * Retrieves the paginated list of all periods that are available to the user, based on their permissions and + * provided filter criteria. + * + * @param {boolean} isUserAdmin + * @param {(number | null)} systemUserId The system user id of the user making the request + * @param {IPeriodAdvancedFilters} filterFields + * @param {ApiPaginationOptions} [pagination] + * @return {*} + * @memberof ObservationService + */ + async findPeriods( + isUserAdmin: boolean, + systemUserId: number | null, + filterFields: IPeriodAdvancedFilters, + pagination?: ApiPaginationOptions + ) { + return this.sampleLocationRepository.findPeriods(isUserAdmin, systemUserId, filterFields, pagination); + } + /** * Deletes a survey Sample Location. * diff --git a/api/src/services/telemetry-service.ts b/api/src/services/telemetry-service.ts index 1d235ca823..86d39e521b 100644 --- a/api/src/services/telemetry-service.ts +++ b/api/src/services/telemetry-service.ts @@ -1,4 +1,5 @@ -import { default as dayjs } from 'dayjs'; +import dayjs from 'dayjs'; +import { DefaultDateFormat, DefaultTimeFormat } from '../constants/dates'; import { IDBConnection } from '../database/db'; import { ApiGeneralError } from '../errors/api-error'; import { IAllTelemetryAdvancedFilters } from '../models/telemetry-view'; @@ -149,13 +150,15 @@ export class TelemetryService extends DBService { if (foundDeployment) { itemsToAdd.push({ deployment_id: foundDeployment.deployment_id, - acquisition_date: dateTime.format('YYYY-MM-DD HH:mm:ss'), + acquisition_date: dateTime.format(`${DefaultDateFormat} ${DefaultTimeFormat}`), latitude: row['LATITUDE'], longitude: row['LONGITUDE'] }); } else { throw new ApiGeneralError( - `No deployment was found for device: ${deviceId} on: ${dateTime.format('YYYY-MM-DD HH:mm:ss')}` + `No deployment was found for device: ${deviceId} on: ${dateTime.format( + `${DefaultDateFormat} ${DefaultTimeFormat}` + )}` ); } }); diff --git a/api/src/utils/media/xlsx/xlsx-utils.ts b/api/src/utils/media/xlsx/xlsx-utils.ts index 2c3ad22eb9..72df06cfeb 100644 --- a/api/src/utils/media/xlsx/xlsx-utils.ts +++ b/api/src/utils/media/xlsx/xlsx-utils.ts @@ -1,5 +1,6 @@ -import { default as dayjs } from 'dayjs'; +import dayjs from 'dayjs'; import xlsx, { CellObject } from 'xlsx'; +import { DefaultDateFormat } from '../../../constants/dates'; import { safeTrim } from '../../string-utils'; /** @@ -106,7 +107,7 @@ export function replaceCellDates(cell: CellObject) { } if (isDateFormatCell(cell)) { - const DateFormat = 'YYYY-MM-DD'; + const DateFormat = DefaultDateFormat; cell.v = cellDate.format(DateFormat); return cell; } diff --git a/api/src/utils/xlsx-utils/cell-utils.ts b/api/src/utils/xlsx-utils/cell-utils.ts index eb2e75f704..bfc8c91daa 100644 --- a/api/src/utils/xlsx-utils/cell-utils.ts +++ b/api/src/utils/xlsx-utils/cell-utils.ts @@ -1,5 +1,11 @@ -import { default as dayjs } from 'dayjs'; +import dayjs from 'dayjs'; import { CellObject } from 'xlsx'; +import { + AltDateFormat, + AltDateFormatReverse, + DefaultDateFormat, + DefaultDateFormatReverse +} from '../../constants/dates'; import { safeTrim } from '../string-utils'; /** @@ -25,8 +31,8 @@ export function trimCellWhitespace(cell: CellObject) { } /** - * Attempts to update the cells value with a formatted date or time value if the cell is a date type cell that has a - * date or time format. + * Attempts to identify and update cells whose values are either date strings or date objects to a consistent date + * format. * * @see https://docs.sheetjs.com/docs/csf/cell for details on cell fields * @export @@ -34,28 +40,38 @@ export function trimCellWhitespace(cell: CellObject) { * @return {*} */ export function replaceCellDates(cell: CellObject) { - if (!isDateCell(cell)) { + if (!cell.v) { + // Cell has no value return cell; } - const cellDate = dayjs(cell.v as any); + // If the cell was already interpreted as a date, format it to the default date format, and return + if (isDateCell(cell) && cell.v instanceof Date) { + // Attempt to parse the date using the format and update the cell value + cell.v = dayjs((cell.v as Date).toISOString(), DefaultDateFormat).format(DefaultDateFormat); + // Update the format to desired default format + cell.z = DefaultDateFormat; + // Ensure the cell type is set to date + cell.t = 'd'; - if (!cellDate.isValid()) { return cell; } - if (isDateFormatCell(cell)) { - const DateFormat = 'YYYY-MM-DD'; - cell.v = cellDate.format(DateFormat); - return cell; - } + // If the cell is a string cell with a valid date value, update the cell value to a date type cell using the default + // format, and return + const matchingStringDateFormat = isStringCellWithDateValue(cell); + if (matchingStringDateFormat) { + // Attempt to parse the date using the format and update the cell value + cell.v = dayjs(cell.v as string, matchingStringDateFormat).format(DefaultDateFormat); + // Update the format to desired default format + cell.z = DefaultDateFormat; + // Ensure the cell type is set to date + cell.t = 'd'; - if (isTimeFormatCell(cell)) { - const TimeFormat = 'HH:mm:ss'; - cell.v = cellDate.format(TimeFormat); return cell; } + // The cell neither a date type cell nor a string type cell with a valid date string value return cell; } @@ -81,6 +97,27 @@ export function isDateCell(cell: CellObject): boolean { return cell.t === 'd'; } +/** + * Checks if the cell value is a date string in a known date format. + * + * @export + * @param {CellObject} cell + * @return {*} {(false | string)} Return the matched date format if the cell value is a date string matching one known + * date format, return `false` otherwise. + */ +export function isStringCellWithDateValue(cell: CellObject): false | string { + if (!isStringCell(cell)) { + return false; + } + + const matchedFormats = [DefaultDateFormat, DefaultDateFormatReverse, AltDateFormat, AltDateFormatReverse].filter( + (format) => dayjs(String(cell.v), format).isValid() + ); + + // Ensure only one format matched + return matchedFormats.length === 1 ? matchedFormats[0] : false; +} + /** * Checks if the cell has a format, and if the format is likely a date format. * @@ -88,7 +125,7 @@ export function isDateCell(cell: CellObject): boolean { * @param {CellObject} cell * @return {*} {boolean} `true` if the cell has a date format, `false` otherwise. */ -export function isDateFormatCell(cell: CellObject): boolean { +export function doesCellHaveDateFormat(cell: CellObject): boolean { if (!cell.z) { return false; } @@ -104,7 +141,7 @@ export function isDateFormatCell(cell: CellObject): boolean { * @param {CellObject} cell * @return {*} {boolean} `true` if the cell has a time format, `false` otherwise. */ -export function isTimeFormatCell(cell: CellObject): boolean { +export function doesCellHaveTimeFormat(cell: CellObject): boolean { if (!cell.z) { // Not a date cell and/or has no date format return false; diff --git a/api/src/utils/xlsx-utils/column-aliases.ts b/api/src/utils/xlsx-utils/column-aliases.ts index f2a0453376..48cf2a9a9b 100644 --- a/api/src/utils/xlsx-utils/column-aliases.ts +++ b/api/src/utils/xlsx-utils/column-aliases.ts @@ -6,5 +6,8 @@ export const CSV_COLUMN_ALIASES: Record, Uppercase[]> ALIAS: ['NICKNAME', 'ANIMAL'], MARKING_TYPE: ['TYPE'], OBSERVATION_SUBCOUNT_SIGN: ['SIGN'], + SAMPLING_SITE: ['SITE', 'SITE ID', 'LOCATION', 'SAMPLING SITE', 'STATION'], + SAMPLING_METHOD: ['METHOD', 'TECHNIQUE'], + SAMPLING_PERIOD: ['PERIOD', 'TIME PERIOD', 'SESSION'], COMMENT: ['COMMENTS', 'NOTE', 'NOTES'] }; diff --git a/api/src/utils/xlsx-utils/worksheet-utils.test.ts b/api/src/utils/xlsx-utils/worksheet-utils.test.ts index 067e1c9063..45517f713e 100644 --- a/api/src/utils/xlsx-utils/worksheet-utils.test.ts +++ b/api/src/utils/xlsx-utils/worksheet-utils.test.ts @@ -16,7 +16,7 @@ const xlsxWorksheet: xlsx.WorkSheet = { H1: { t: 's', v: 'Wind Direction' }, A2: { t: 'n', w: '180703', v: 180703 }, B2: { t: 'n', w: '1', v: 1 }, - C2: { z: 'm/d/yy', t: 'd', v: '1970-01-01T08:00:00.000Z', w: '1/1/70' }, + C2: { t: 's', v: '1970-01-01T08:00:00.000Z' }, D2: { t: 's', v: '9:01' }, E2: { t: 'n', w: '-58', v: -58 }, F2: { t: 'n', w: '-123', v: -123 }, @@ -24,14 +24,14 @@ const xlsxWorksheet: xlsx.WorkSheet = { H2: { t: 's', v: 'North' }, A3: { t: 'n', w: '180596', v: 180596 }, B3: { t: 'n', w: '2', v: 2 }, - C3: { z: 'm/d/yy', t: 'd', v: '1970-01-01T08:00:00.000Z', w: '1/1/70' }, + C3: { t: 's', v: '1970-01-01T08:00:00.000Z' }, D3: { t: 's', v: '9:02' }, E3: { t: 'n', w: '-57', v: -57 }, F3: { t: 'n', w: '-122', v: -122 }, H3: { t: 's', v: 'North' }, A4: { t: 'n', w: '180713', v: 180713 }, B4: { t: 'n', w: '3', v: 3 }, - C4: { z: 'm/d/yy', t: 'd', v: '1970-01-01T08:00:00.000Z', w: '1/1/70' }, + C4: { t: 's', v: '1970-01-01T08:00:00.000Z' }, D4: { t: 's', v: '9:03' }, E4: { t: 'n', w: '-56', v: -56 }, F4: { t: 'n', w: '-121', v: -121 }, diff --git a/api/src/utils/xlsx-utils/worksheet-utils.ts b/api/src/utils/xlsx-utils/worksheet-utils.ts index e5d27a560c..38812641ae 100644 --- a/api/src/utils/xlsx-utils/worksheet-utils.ts +++ b/api/src/utils/xlsx-utils/worksheet-utils.ts @@ -1,4 +1,4 @@ -import { default as dayjs } from 'dayjs'; +import dayjs from 'dayjs'; import customParseFormat from 'dayjs/plugin/customParseFormat'; import { intersection, isUndefined } from 'lodash'; import xlsx, { CellObject } from 'xlsx'; @@ -46,11 +46,16 @@ export interface IXLSXCSVValidator { * * @export * @param {MediaFile} file - * @param {xlsx.ParsingOptions} [options] * @return {*} {xlsx.WorkBook} */ -export const constructXLSXWorkbook = (file: MediaFile, options?: xlsx.ParsingOptions): xlsx.WorkBook => { - return xlsx.read(file.buffer, { cellDates: true, cellNF: true, cellHTML: false, ...options }); +export const constructXLSXWorkbook = (file: MediaFile): xlsx.WorkBook => { + return xlsx.read(file.buffer, { + cellDates: true, + cellNF: true, + cellHTML: false, + dateNF: '_', + raw: false + }); }; /** diff --git a/app/src/components/buttons/BreadcrumbNavButton.tsx b/app/src/components/buttons/BreadcrumbNavButton.tsx new file mode 100644 index 0000000000..ba5df84178 --- /dev/null +++ b/app/src/components/buttons/BreadcrumbNavButton.tsx @@ -0,0 +1,69 @@ +import { mdiChevronDown } from '@mdi/js'; +import { Icon } from '@mdi/react'; +import Button from '@mui/material/Button'; +import grey from '@mui/material/colors/grey'; +import Menu, { MenuProps } from '@mui/material/Menu'; +import MenuItem from '@mui/material/MenuItem'; +import { PropsWithChildren, useState } from 'react'; +import { Link as RouterLink } from 'react-router-dom'; + +interface IBreadcrumbNavButtonProps { + menuItems: { label: string; to: string; icon?: string }[]; +} + +/** + * Returns a button that opens a menu of router links when clicked + * + * @param {PropsWithChildren} props + * @returns {*} + */ +export const BreadcrumbNavButton = (props: PropsWithChildren) => { + const { menuItems, children } = props; + + // State for managing the menu + const [anchorEl, setAnchorEl] = useState(null); + + // Handle menu opening + const handleMenuClick = (event: React.MouseEvent) => { + setAnchorEl(event.currentTarget); + }; + + // Handle menu closing + const handleMenuClose = () => { + setAnchorEl(null); + }; + + return ( + <> + + {menuItems.map((item) => ( + { + handleMenuClose(); + }}> + {item.icon && } + {item.label} + + ))} + + + + + ); +}; diff --git a/app/src/components/data-grid/autocomplete/AsyncAutocompleteDataGridEditCell.tsx b/app/src/components/data-grid/autocomplete/AsyncAutocompleteDataGridEditCell.tsx index 91a244b3b9..afd145f91f 100644 --- a/app/src/components/data-grid/autocomplete/AsyncAutocompleteDataGridEditCell.tsx +++ b/app/src/components/data-grid/autocomplete/AsyncAutocompleteDataGridEditCell.tsx @@ -28,6 +28,12 @@ export interface IAsyncAutocompleteDataGridEditCell< * @memberof IAsyncAutocompleteDataGridEditCell */ getCurrentOption: (value: ValueType) => Promise; + /** + * Initial options to display in the autocomplete, before the user types anything. + * + * @memberof IAsyncAutocompleteDataGridEditCell + */ + getInitialOptions?: () => AutocompleteOptionType[]; /** * Search function that returns an array of options to choose from. * @@ -46,6 +52,17 @@ export interface IAsyncAutocompleteDataGridEditCell< * Optional function to render the autocomplete option. */ renderOption?: AutocompleteProps['renderOption']; + /** + * Optional callback fired when an option is selected. + */ + onSelectOption?: (selectedOption: AutocompleteOptionType | null) => void; + /** + * Placeholder text for the input field. + * + * @type {string} + * @memberof IAsyncAutocompleteDataGridEditCell + */ + placeholder?: string; } /** @@ -64,7 +81,16 @@ const AsyncAutocompleteDataGridEditCell = < >( props: IAsyncAutocompleteDataGridEditCell ) => { - const { dataGridProps, getCurrentOption, getOptions, error, renderOption } = props; + const { + dataGridProps, + getCurrentOption, + getOptions, + getInitialOptions, + error, + renderOption, + onSelectOption, + placeholder + } = props; const ref = useRef(); @@ -80,14 +106,21 @@ const AsyncAutocompleteDataGridEditCell = < const [inputValue, setInputValue] = useState(''); // The currently selected option const [currentOption, setCurrentOption] = useState(null); + // Reference to disable search (used when selecting an option to prevent a redundant search) + const isSearchDisabled = useRef(false); // The array of options to choose from - const [options, setOptions] = useState([]); + const [options, setOptions] = useState(getInitialOptions?.() ?? []); // Is control loading (search in progress) const [isLoading, setIsLoading] = useState(false); useEffect(() => { let mounted = true; + if (isSearchDisabled.current) { + // Search is disabled + return; + } + if (!dataGridValue) { // No current value return; @@ -123,14 +156,20 @@ const AsyncAutocompleteDataGridEditCell = < useEffect(() => { let mounted = true; + if (isSearchDisabled.current) { + // Search is disabled + return; + } + if (inputValue === '') { - // No input value, nothing to search with - setOptions(currentOption ? [currentOption] : []); + // No search term, do not initiate search, cancel any existing search + setIsLoading(false); return; } - // Call async search function setIsLoading(true); + + // Call async search function getOptions(inputValue, (searchResults) => { if (!mounted) { return; @@ -166,8 +205,13 @@ const AsyncAutocompleteDataGridEditCell = < }} filterOptions={(item) => item} onChange={(_, selectedOption) => { - setOptions(selectedOption ? [selectedOption, ...options] : options); + // Disable search when selecting an option, to prevent a redundant search when the input field is updated + // with the user's selection + isSearchDisabled.current = true; + setCurrentOption(selectedOption); + onSelectOption?.(selectedOption); + setIsLoading(false); // Set the data grid cell value with selected options value dataGridProps.api.setEditCellValue({ @@ -176,7 +220,13 @@ const AsyncAutocompleteDataGridEditCell = < value: selectedOption?.value }); }} - onInputChange={(_, newInputValue) => { + onInputChange={(_, newInputValue, reason) => { + if (reason === 'clear' || reason === 'input') { + // Enable search when the user interacts with the input field + // A 'reset' event is created when the user selects an option, which should not trigger a search + isSearchDisabled.current = false; + } + setInputValue(newInputValue); }} renderInput={(params) => ( @@ -187,6 +237,7 @@ const AsyncAutocompleteDataGridEditCell = < variant="outlined" fullWidth error={error} + placeholder={placeholder} InputProps={{ color: error ? 'error' : undefined, ...params.InputProps, diff --git a/app/src/components/data-grid/conditional-autocomplete/ConditionalAutocompleteDataGridEditCell.tsx b/app/src/components/data-grid/conditional-autocomplete/ConditionalAutocompleteDataGridEditCell.tsx index fadaca2fe0..55b1029a6b 100644 --- a/app/src/components/data-grid/conditional-autocomplete/ConditionalAutocompleteDataGridEditCell.tsx +++ b/app/src/components/data-grid/conditional-autocomplete/ConditionalAutocompleteDataGridEditCell.tsx @@ -16,14 +16,14 @@ export interface IConditionalAutocompleteDataGridEditCellProps< */ dataGridProps: GridRenderCellParams; /** - * + * All possible options for the autocomplete control. * * @type {OptionsType[]} * @memberof IConditionalAutocompleteDataGridEditCellProps */ allOptions: OptionsType[]; /** - * + * Given a row and list of all possible options, return the matching options for the autocomplete control. * * @memberof IConditionalAutocompleteDataGridEditCellProps */ diff --git a/app/src/components/data-grid/conditional-autocomplete/ConditionalAutocompleteDataGridViewCell.tsx b/app/src/components/data-grid/conditional-autocomplete/ConditionalAutocompleteDataGridViewCell.tsx index 0fda933e5f..611e8e9c21 100644 --- a/app/src/components/data-grid/conditional-autocomplete/ConditionalAutocompleteDataGridViewCell.tsx +++ b/app/src/components/data-grid/conditional-autocomplete/ConditionalAutocompleteDataGridViewCell.tsx @@ -16,14 +16,14 @@ export interface IConditionalAutocompleteDataGridViewCellProps< */ dataGridProps: GridRenderCellParams; /** - * + * All possible options for the autocomplete control. * * @type {OptionsType[]} * @memberof IConditionalAutocompleteDataGridViewCellProps */ allOptions: OptionsType[]; /** - * + * Given a row and list of all possible options, return the matching options for the autocomplete control. * * @memberof IConditionalAutocompleteDataGridViewCellProps */ diff --git a/app/src/components/data-grid/taxonomy/TaxonomyDataGridEditCell.tsx b/app/src/components/data-grid/taxonomy/TaxonomyDataGridEditCell.tsx index ba47cfd44f..c439286ddb 100644 --- a/app/src/components/data-grid/taxonomy/TaxonomyDataGridEditCell.tsx +++ b/app/src/components/data-grid/taxonomy/TaxonomyDataGridEditCell.tsx @@ -93,6 +93,7 @@ const TaxonomyDataGridEditCell = ( dataGridProps={dataGridProps} getCurrentOption={getCurrentOption} getOptions={getOptions} + placeholder="Search for a taxon" error={props.error} renderOption={(renderProps, renderOption) => ( ({ page: 0, - pageSize: 50 + pageSize: 25 }); // Sort model @@ -504,6 +504,7 @@ export const ObservationsTableContextProvider = (props: IObservationsTableContex row.itis_tsn && getTsnMeasurementTypeDefinitionMap(row.itis_tsn); } + // TODO: Either latitude/longitude OR sampling period is required, and either observation date OR sampling period is required const requiredStandardColumns: (keyof IObservationTableRow)[] = [ 'observation_subcount_sign_id', 'count', diff --git a/app/src/contexts/surveyContext.tsx b/app/src/contexts/surveyContext.tsx index 2443b62611..f1b5ed2a44 100644 --- a/app/src/contexts/surveyContext.tsx +++ b/app/src/contexts/surveyContext.tsx @@ -1,7 +1,6 @@ import { useBiohubApi } from 'hooks/useBioHubApi'; import useDataLoader, { DataLoader } from 'hooks/useDataLoader'; import { ICritterSimpleResponse } from 'interfaces/useCritterApi.interface'; -import { IGetSampleSiteResponse } from 'interfaces/useSamplingSiteApi.interface'; import { IGetSurveyAttachmentsResponse, IGetSurveyForViewResponse } from 'interfaces/useSurveyApi.interface'; import { IGetTechniquesResponse } from 'interfaces/useTechniqueApi.interface'; import { createContext, PropsWithChildren, useEffect, useMemo } from 'react'; @@ -30,18 +29,10 @@ export interface ISurveyContext { */ artifactDataLoader: DataLoader<[project_id: number, survey_id: number], IGetSurveyAttachmentsResponse, unknown>; - /** - * The Data Loader used to load survey sample site data - * - * @type {DataLoader<[project_id: number, survey_id: number], IGetSampleSiteResponse, unknown>} - * @memberof ISurveyContext - */ - sampleSiteDataLoader: DataLoader<[project_id: number, survey_id: number], IGetSampleSiteResponse, unknown>; - /** * The Data Loader used to load survey techniques * - * @type {DataLoader<[project_id: number, survey_id: number], IGetSampleSiteResponse, unknown>} + * @type {DataLoader<[project_id: number, survey_id: number], IGetTechniquesResponse, unknown>} * @memberof ISurveyContext */ techniqueDataLoader: DataLoader<[project_id: number, survey_id: number], IGetTechniquesResponse, unknown>; @@ -74,7 +65,6 @@ export interface ISurveyContext { export const SurveyContext = createContext({ surveyDataLoader: {} as DataLoader<[project_id: number, survey_id: number], IGetSurveyForViewResponse, unknown>, artifactDataLoader: {} as DataLoader<[project_id: number, survey_id: number], IGetSurveyAttachmentsResponse, unknown>, - sampleSiteDataLoader: {} as DataLoader<[project_id: number, survey_id: number], IGetSampleSiteResponse, unknown>, techniqueDataLoader: {} as DataLoader<[project_id: number, survey_id: number], IGetTechniquesResponse, unknown>, critterDataLoader: {} as DataLoader<[project_id: number, survey_id: number], ICritterSimpleResponse[], unknown>, projectId: -1, @@ -85,7 +75,6 @@ export const SurveyContextProvider = (props: PropsWithChildren{props.children}; }; diff --git a/app/src/features/surveys/animals/AnimalHeader.tsx b/app/src/features/surveys/animals/AnimalHeader.tsx index 5ea0cc4c52..fc93d4771a 100644 --- a/app/src/features/surveys/animals/AnimalHeader.tsx +++ b/app/src/features/surveys/animals/AnimalHeader.tsx @@ -1,6 +1,7 @@ +import { mdiEye, mdiWifiMarker } from '@mdi/js'; import Breadcrumbs from '@mui/material/Breadcrumbs'; import Link from '@mui/material/Link'; -import Typography from '@mui/material/Typography'; +import { BreadcrumbNavButton } from 'components/buttons/BreadcrumbNavButton'; import PageHeader from 'components/layout/PageHeader'; import { Link as RouterLink } from 'react-router-dom'; @@ -19,6 +20,20 @@ export interface IAnimalHeaderProps { */ export const AnimalHeader = (props: IAnimalHeaderProps) => { const { project_id, project_name, survey_id, survey_name } = props; + + const menuItems = [ + { + label: 'Telemetry', + to: `/admin/projects/${project_id}/surveys/${survey_id}/telemetry/details`, + icon: mdiWifiMarker + }, + { + label: 'Observations', + to: `/admin/projects/${project_id}/surveys/${survey_id}/observations`, + icon: mdiEye + } + ]; + return ( { to={`/admin/projects/${project_id}/surveys/${survey_id}/details`}> {survey_name} - - Manage Animals - + Animals } /> diff --git a/app/src/features/surveys/observations/SurveyObservationHeader.tsx b/app/src/features/surveys/observations/SurveyObservationHeader.tsx index 74bd6deb39..61e5974c46 100644 --- a/app/src/features/surveys/observations/SurveyObservationHeader.tsx +++ b/app/src/features/surveys/observations/SurveyObservationHeader.tsx @@ -1,6 +1,7 @@ +import { mdiPaw, mdiWifiMarker } from '@mdi/js'; import Breadcrumbs from '@mui/material/Breadcrumbs'; import Link from '@mui/material/Link'; -import Typography from '@mui/material/Typography'; +import { BreadcrumbNavButton } from 'components/buttons/BreadcrumbNavButton'; import PageHeader from 'components/layout/PageHeader'; import { Link as RouterLink } from 'react-router-dom'; @@ -13,6 +14,20 @@ export interface SurveyObservationHeaderProps { const SurveyObservationHeader: React.FC = (props) => { const { project_id, project_name, survey_id, survey_name } = props; + + const menuItems = [ + { + label: 'Animals', + to: `/admin/projects/${project_id}/surveys/${survey_id}/animals/details`, + icon: mdiPaw + }, + { + label: 'Telemetry', + to: `/admin/projects/${project_id}/surveys/${survey_id}/telemetry`, + icon: mdiWifiMarker + } + ]; + return ( = (props) to={`/admin/projects/${project_id}/surveys/${survey_id}/details`}> {survey_name} - - Manage Observations - + Observations } /> diff --git a/app/src/features/surveys/observations/observations-table/ObservationsTableContainer.tsx b/app/src/features/surveys/observations/observations-table/ObservationsTableContainer.tsx index c67bc51271..47961553cb 100644 --- a/app/src/features/surveys/observations/observations-table/ObservationsTableContainer.tsx +++ b/app/src/features/surveys/observations/observations-table/ObservationsTableContainer.tsx @@ -22,9 +22,6 @@ import { IObservationTableRow } from 'contexts/observationsTableContext'; import { BulkActionsButton } from 'features/surveys/observations/observations-table/bulk-actions/BulkActionsButton'; import { DiscardChangesButton } from 'features/surveys/observations/observations-table/discard-changes/DiscardChangesButton'; import { - ISampleMethodOption, - ISamplePeriodOption, - ISampleSiteOption, ObservationCountColDef, ObservationSubcountSignColDef, SampleMethodColDef, @@ -32,21 +29,17 @@ import { SampleSiteColDef, TaxonomyColDef } from 'features/surveys/observations/observations-table/grid-column-definitions/GridColumnDefinitions'; +import { useSampleLocationsCache } from 'features/surveys/observations/observations-table/grid-column-definitions/sampling-information/useSampleLocationsCache'; import { ImportObservationsButton } from 'features/surveys/observations/observations-table/import-obsevations/ImportObservationsButton'; import ObservationsTable from 'features/surveys/observations/observations-table/ObservationsTable'; import { useCodesContext, + useObservationsContext, useObservationsPageContext, - useObservationsTableContext, - useSurveyContext + useObservationsTableContext } from 'hooks/useContext'; -import { - IGetSampleLocationDetails, - IGetSampleMethodDetails, - IGetSamplePeriodRecord -} from 'interfaces/useSamplingSiteApi.interface'; +import { IGetSampleLocationNonSpatialDetails } from 'interfaces/useSamplingSiteApi.interface'; import { useEffect, useMemo } from 'react'; -import { getCodesName } from 'utils/Utils'; import { ConfigureColumnsButton } from './configure-columns/ConfigureColumnsButton'; import ExportHeadersButton from './export-button/ExportHeadersButton'; import { ObservationSubcountCommentDialog } from './grid-column-definitions/comment/ObservationSubcountCommentDialog'; @@ -55,62 +48,20 @@ import { getMeasurementColumnDefinitions } from './grid-column-definitions/GridColumnDefinitionsUtils'; +export type SampleLocationCache = { + locations: IGetSampleLocationNonSpatialDetails[]; +}; + const ObservationsTableContainer = () => { const codesContext = useCodesContext(); - const surveyContext = useSurveyContext(); + const observationsPageContext = useObservationsPageContext(); const observationsTableContext = useObservationsTableContext(); + const observationsContext = useObservationsContext(); useEffect(() => { codesContext.codesDataLoader.load(); - surveyContext.sampleSiteDataLoader.load(surveyContext.projectId, surveyContext.surveyId); - }, [ - codesContext.codesDataLoader, - surveyContext.projectId, - surveyContext.sampleSiteDataLoader, - surveyContext.surveyId - ]); - - // Collect sample sites - 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 - .filter((sampleSite) => Boolean(sampleSite.sample_methods)) - .map((sampleSite) => sampleSite.sample_methods as IGetSampleMethodDetails[]) - .flat(2); - const sampleMethodOptions: ISampleMethodOption[] = surveySampleMethods.map((method) => ({ - survey_sample_method_id: method.survey_sample_method_id, - survey_sample_site_id: method.survey_sample_site_id, - sample_method_name: method.technique.name, - response_metric: - getCodesName(codesContext.codesDataLoader.data, 'method_response_metrics', method.method_response_metric_id) ?? '' - })); - - // Collect sample periods - const samplePeriodOptions: ISamplePeriodOption[] = surveySampleMethods - .filter((sampleMethod) => Boolean(sampleMethod.sample_periods)) - .map((sampleMethod) => sampleMethod.sample_periods as IGetSamplePeriodRecord[]) - .flat(2) - .map((samplePeriod: IGetSamplePeriodRecord) => ({ - survey_sample_period_id: samplePeriod.survey_sample_period_id, - survey_sample_method_id: samplePeriod.survey_sample_method_id, - sample_period_name: `${samplePeriod.start_date} ${samplePeriod.start_time ?? ''} - ${samplePeriod.end_date} ${ - samplePeriod.end_time ?? '' - }` - })); + }, [codesContext.codesDataLoader]); const observationSubcountSignOptions = useMemo( () => @@ -121,58 +72,94 @@ const ObservationsTableContainer = () => { [codesContext.codesDataLoader.data?.observation_subcount_signs] ); + const sampleLocationsCache = useSampleLocationsCache(); + + useEffect(() => { + if (!observationsContext.observationsDataLoader.data?.supplementaryObservationData.sample_sites?.length) { + return; + } + + sampleLocationsCache.updateCachedSampleLocationsRef( + observationsContext.observationsDataLoader.data.supplementaryObservationData.sample_sites + ); + }, [ + observationsContext.observationsDataLoader.data?.supplementaryObservationData.sample_sites, + sampleLocationsCache + ]); + // The column definitions of the columns to render in the observations table 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 }), - ObservationSubcountSignColDef({ observationSubcountSignOptions, hasError: observationsTableContext.hasError }), - ObservationCountColDef({ sampleMethodOptions, hasError: observationsTableContext.hasError }), - GenericDateColDef({ - field: 'observation_date', - headerName: 'Date', - hasError: observationsTableContext.hasError, - description: 'The date when the observation was made' - }), - GenericTimeColDef({ - field: 'observation_time', - headerName: 'Time', - hasError: observationsTableContext.hasError, - description: 'The time of day when the observation was made' - }), - GenericLatitudeColDef({ - field: 'latitude', - headerName: 'Latitude', - hasError: observationsTableContext.hasError, - description: 'The latitude where the observation was made' - }), - GenericLongitudeColDef({ - field: 'longitude', - headerName: 'Longitude', - hasError: observationsTableContext.hasError, - description: 'The longitude where the observation was made' - }), - // Add measurement columns to the table - ...getMeasurementColumnDefinitions( - observationsTableContext.measurementColumns, - observationsTableContext.hasError - ), - // Add environment columns to the table - ...getEnvironmentColumnDefinitions( - observationsTableContext.environmentColumns, - observationsTableContext.hasError - ), - GenericCommentColDef({ - field: 'comment', - headerName: '', - hasError: observationsTableContext.hasError, - handleOpen: (params: GridRenderEditCellParams) => observationsTableContext.setCommentDialogParams(params), - handleClose: () => observationsTableContext.setCommentDialogParams(null) - }) - ], + () => { + return [ + // Add standard observation columns to the table + TaxonomyColDef({ hasError: observationsTableContext.hasError }), + SampleSiteColDef({ + cachedSampleLocationsRef: sampleLocationsCache.cachedSampleLocationsRef, + onSelectOption: (selectedSampleSite) => { + if (!selectedSampleSite) { + return; + } + + sampleLocationsCache.updateCachedSampleLocationsRef([selectedSampleSite]); + }, + hasError: observationsTableContext.hasError + }), + SampleMethodColDef({ + cachedSampleLocationsRef: sampleLocationsCache.cachedSampleLocationsRef, + hasError: observationsTableContext.hasError + }), + SamplePeriodColDef({ + cachedSampleLocationsRef: sampleLocationsCache.cachedSampleLocationsRef, + hasError: observationsTableContext.hasError + }), + ObservationSubcountSignColDef({ observationSubcountSignOptions, hasError: observationsTableContext.hasError }), + ObservationCountColDef({ + cachedSampleLocationsRef: sampleLocationsCache.cachedSampleLocationsRef, + hasError: observationsTableContext.hasError + }), + GenericDateColDef({ + field: 'observation_date', + headerName: 'Date', + hasError: observationsTableContext.hasError, + description: 'The date when the observation was made' + }), + GenericTimeColDef({ + field: 'observation_time', + headerName: 'Time', + hasError: observationsTableContext.hasError, + description: 'The time of day when the observation was made' + }), + GenericLatitudeColDef({ + field: 'latitude', + headerName: 'Latitude', + hasError: observationsTableContext.hasError, + description: 'The latitude where the observation was made' + }), + GenericLongitudeColDef({ + field: 'longitude', + headerName: 'Longitude', + hasError: observationsTableContext.hasError, + description: 'The longitude where the observation was made' + }), + // Add measurement columns to the table + ...getMeasurementColumnDefinitions( + observationsTableContext.measurementColumns, + observationsTableContext.hasError + ), + // Add environment columns to the table + ...getEnvironmentColumnDefinitions( + observationsTableContext.environmentColumns, + observationsTableContext.hasError + ), + GenericCommentColDef({ + field: 'comment', + headerName: '', + hasError: observationsTableContext.hasError, + handleOpen: (params: GridRenderEditCellParams) => observationsTableContext.setCommentDialogParams(params), + handleClose: () => observationsTableContext.setCommentDialogParams(null) + }) + ]; + }, // observationsTableContext is listed as a missing dependency // eslint-disable-next-line react-hooks/exhaustive-deps [ @@ -180,10 +167,7 @@ const ObservationsTableContainer = () => { observationsTableContext.environmentColumns, observationsTableContext.hasError, observationsTableContext.measurementColumns, - observationsTableContext.setCommentDialogParams, - sampleMethodOptions, - samplePeriodOptions, - sampleSiteOptions + observationsTableContext.setCommentDialogParams ] ); @@ -282,7 +266,7 @@ const ObservationsTableContainer = () => { observationsTableContext.isDisabled || codesContext.codesDataLoader.isLoading } - columns={columns} + columns={[...columns]} /> 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 7001069440..c809c0f4f7 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 @@ -2,17 +2,29 @@ import Typography from '@mui/material/Typography'; import { GridCellParams, GridColDef } from '@mui/x-data-grid'; import AutocompleteDataGridEditCell from 'components/data-grid/autocomplete/AutocompleteDataGridEditCell'; import AutocompleteDataGridViewCell from 'components/data-grid/autocomplete/AutocompleteDataGridViewCell'; -import ConditionalAutocompleteDataGridEditCell from 'components/data-grid/conditional-autocomplete/ConditionalAutocompleteDataGridEditCell'; -import ConditionalAutocompleteDataGridViewCell from 'components/data-grid/conditional-autocomplete/ConditionalAutocompleteDataGridViewCell'; import TaxonomyDataGridEditCell from 'components/data-grid/taxonomy/TaxonomyDataGridEditCell'; import TaxonomyDataGridViewCell from 'components/data-grid/taxonomy/TaxonomyDataGridViewCell'; import TextFieldDataGrid from 'components/data-grid/TextFieldDataGrid'; import { IObservationTableRow } from 'contexts/observationsTableContext'; +import { ObservationCountDataGridEditCell } from 'features/surveys/observations/observations-table/grid-column-definitions/count/ObservationCountDataGridEditCell'; +import SampleMethodDataGridEditCell from 'features/surveys/observations/observations-table/grid-column-definitions/sampling-information/methods/SampleMethodDataGridEditCell'; +import { SampleMethodDataGridViewCell } from 'features/surveys/observations/observations-table/grid-column-definitions/sampling-information/methods/SampleMethodDataGridViewCell'; +import SamplePeriodDataGridEditCell from 'features/surveys/observations/observations-table/grid-column-definitions/sampling-information/periods/SamplePeriodDataGridEditCell'; +import { SamplePeriodDataGridViewCell } from 'features/surveys/observations/observations-table/grid-column-definitions/sampling-information/periods/SamplePeriodDataGridViewCell'; +import { SampleSiteDataGridEditCell } from 'features/surveys/observations/observations-table/grid-column-definitions/sampling-information/sites/SampleSiteDataGridEditCell'; +import { SampleSiteDataGridViewCell } from 'features/surveys/observations/observations-table/grid-column-definitions/sampling-information/sites/SampleSiteDataGridViewCell'; +import { + getMethodsForRow, + getPeriodsForRow +} from 'features/surveys/observations/observations-table/grid-column-definitions/sampling-information/utils'; +import { SampleLocationCache } from 'features/surveys/observations/observations-table/ObservationsTableContainer'; import { CBMeasurementType, CBQualitativeOption } from 'interfaces/useCritterApi.interface'; import { EnvironmentQualitativeTypeDefinition, EnvironmentQuantitativeTypeDefinition } from 'interfaces/useReferenceApi.interface'; +import { IGetSampleLocationNonSpatialDetails } from 'interfaces/useSamplingSiteApi.interface'; +import { MutableRefObject } from 'react'; export type ISampleSiteOption = { survey_sample_site_id: number; @@ -66,10 +78,11 @@ export const TaxonomyColDef = (props: { }; export const SampleSiteColDef = (props: { - sampleSiteOptions: ISampleSiteOption[]; + cachedSampleLocationsRef: MutableRefObject; + onSelectOption: (selectedSampleSite: IGetSampleLocationNonSpatialDetails | null) => void; hasError: (params: GridCellParams) => boolean; }): GridColDef => { - const { sampleSiteOptions, hasError } = props; + const { cachedSampleLocationsRef, onSelectOption, hasError } = props; return { field: 'survey_sample_site_id', @@ -84,24 +97,19 @@ export const SampleSiteColDef = (props: { align: 'left', renderCell: (params) => { return ( - + ({ - label: item.sample_site_name, - value: item.survey_sample_site_id - }))} + cachedSampleLocationsRef={cachedSampleLocationsRef} error={hasError(params)} /> ); }, renderEditCell: (params) => { return ( - + ({ - label: item.sample_site_name, - value: item.survey_sample_site_id - }))} + cachedSampleLocationsRef={cachedSampleLocationsRef} + onSelectOption={(selectedSampleSite) => onSelectOption(selectedSampleSite)} error={hasError(params)} /> ); @@ -110,10 +118,10 @@ export const SampleSiteColDef = (props: { }; export const SampleMethodColDef = (props: { - sampleMethodOptions: ISampleMethodOption[]; + cachedSampleLocationsRef: MutableRefObject; hasError: (params: GridCellParams) => boolean; }): GridColDef => { - const { sampleMethodOptions, hasError } = props; + const { cachedSampleLocationsRef, hasError } = props; return { field: 'survey_sample_method_id', @@ -128,28 +136,21 @@ export const SampleMethodColDef = (props: { align: 'left', renderCell: (params) => { return ( - + { - return allOptions - .filter((item) => item.survey_sample_site_id === row.survey_sample_site_id) - .map((item) => ({ label: item.sample_method_name, value: item.survey_sample_method_id })); - }} - allOptions={sampleMethodOptions} + cachedSampleLocationsRef={cachedSampleLocationsRef} error={hasError(params)} /> ); }, renderEditCell: (params) => { + const methodOptions = getMethodsForRow(params, cachedSampleLocationsRef); + return ( - + { - return allOptions - .filter((item) => item.survey_sample_site_id === row.survey_sample_site_id) - .map((item) => ({ label: item.sample_method_name, value: item.survey_sample_method_id })); - }} - allOptions={sampleMethodOptions} + cachedSampleLocationsRef={cachedSampleLocationsRef} + methodOptions={methodOptions} error={hasError(params)} /> ); @@ -158,10 +159,10 @@ export const SampleMethodColDef = (props: { }; export const SamplePeriodColDef = (props: { - samplePeriodOptions: ISamplePeriodOption[]; + cachedSampleLocationsRef: MutableRefObject; hasError: (params: GridCellParams) => boolean; }): GridColDef => { - const { samplePeriodOptions, hasError } = props; + const { cachedSampleLocationsRef, hasError } = props; return { field: 'survey_sample_period_id', @@ -169,41 +170,28 @@ export const SamplePeriodColDef = (props: { description: 'The sampling period in which the observation was made', editable: true, hideable: true, - flex: 0, + flex: 1, minWidth: 180, disableColumnMenu: true, headerAlign: 'left', align: 'left', renderCell: (params) => { return ( - + { - return allOptions - .filter((item) => item.survey_sample_method_id === row.survey_sample_method_id) - .map((item) => ({ - label: item.sample_period_name, - value: item.survey_sample_period_id - })); - }} - allOptions={samplePeriodOptions} + cachedSampleLocationsRef={cachedSampleLocationsRef} error={hasError(params)} /> ); }, renderEditCell: (params) => { + const periodOptions = getPeriodsForRow(params, cachedSampleLocationsRef); + return ( - + { - return allOptions - .filter((item) => item.survey_sample_method_id === row.survey_sample_method_id) - .map((item) => ({ - label: item.sample_period_name, - value: item.survey_sample_period_id - })); - }} - allOptions={samplePeriodOptions} + cachedSampleLocationsRef={cachedSampleLocationsRef} + periodOptions={periodOptions} error={hasError(params)} /> ); @@ -212,10 +200,10 @@ export const SamplePeriodColDef = (props: { }; export const ObservationCountColDef = (props: { - sampleMethodOptions: ISampleMethodOption[]; + cachedSampleLocationsRef: MutableRefObject; hasError: (params: GridCellParams) => boolean; }): GridColDef => { - const { hasError } = props; + const { cachedSampleLocationsRef, hasError } = props; return { field: 'count', @@ -234,39 +222,11 @@ export const ObservationCountColDef = (props: { ), renderEditCell: (params) => { - const error: boolean = hasError(params); - - const maxCount = - props.sampleMethodOptions.find( - (option) => option.survey_sample_method_id === params.row.survey_sample_method_id - )?.response_metric === 'Presence-absence' - ? 1 - : undefined; - return ( - { - if (!/^\d{0,7}$/.test(event.target.value)) { - // If the value is not a number, return - return; - } - - params.api.setEditCellValue({ - id: params.id, - field: params.field, - value: event.target.value - }); - }, - error - }} + cachedSampleLocationsRef={cachedSampleLocationsRef} + error={hasError(params)} /> ); } @@ -278,6 +238,7 @@ export const ObservationSubcountSignColDef = (props: { hasError: (params: GridCellParams) => boolean; }): GridColDef => { const { observationSubcountSignOptions, hasError } = props; + const signOptions = observationSubcountSignOptions.map((item) => ({ label: item.name, value: item.observation_subcount_sign_id diff --git a/app/src/features/surveys/observations/observations-table/grid-column-definitions/count/ObservationCountDataGridEditCell.tsx b/app/src/features/surveys/observations/observations-table/grid-column-definitions/count/ObservationCountDataGridEditCell.tsx new file mode 100644 index 0000000000..bf762b996e --- /dev/null +++ b/app/src/features/surveys/observations/observations-table/grid-column-definitions/count/ObservationCountDataGridEditCell.tsx @@ -0,0 +1,71 @@ +import { GridRenderCellParams, GridValidRowModel } from '@mui/x-data-grid'; +import TextFieldDataGrid from 'components/data-grid/TextFieldDataGrid'; +import { getCurrentMethod } from 'features/surveys/observations/observations-table/grid-column-definitions/sampling-information/utils'; +import { SampleLocationCache } from 'features/surveys/observations/observations-table/ObservationsTableContainer'; +import { useCodesContext } from 'hooks/useContext'; +import { MutableRefObject } from 'react'; +import { getCodesName } from 'utils/Utils'; + +export interface IPartialObservationCountDataGridEditCellProps { + dataGridProps: GridRenderCellParams; + cachedSampleLocationsRef: MutableRefObject; + error?: boolean; +} + +/** + * + * + * @template DataGridType + * @param {IPartialObservationCountDataGridEditCellProps} props + * @return {*} + */ +export const ObservationCountDataGridEditCell = ( + props: IPartialObservationCountDataGridEditCellProps +) => { + const { dataGridProps, cachedSampleLocationsRef, error } = props; + + const codesContext = useCodesContext(); + + const getResponseMetric = () => { + const currentMethod = getCurrentMethod(dataGridProps, cachedSampleLocationsRef); + + if (!currentMethod) { + return null; + } + + return getCodesName( + codesContext.codesDataLoader.data, + 'method_response_metrics', + currentMethod.method_response_metric_id + ); + }; + + const maxCount = getResponseMetric() === 'Presence-absence' ? 1 : undefined; + + return ( + { + if (!/^\d{0,7}$/.test(event.target.value)) { + // If the value is not a number, return + return; + } + + dataGridProps.api.setEditCellValue({ + id: dataGridProps.id, + field: dataGridProps.field, + value: event.target.value + }); + }, + error + }} + /> + ); +}; diff --git a/app/src/features/surveys/observations/observations-table/grid-column-definitions/sampling-information/methods/SampleMethodDataGrid.interface.ts b/app/src/features/surveys/observations/observations-table/grid-column-definitions/sampling-information/methods/SampleMethodDataGrid.interface.ts new file mode 100644 index 0000000000..e3c5e864a1 --- /dev/null +++ b/app/src/features/surveys/observations/observations-table/grid-column-definitions/sampling-information/methods/SampleMethodDataGrid.interface.ts @@ -0,0 +1,6 @@ +import { IGetSampleMethodDetails } from 'interfaces/useSamplingSiteApi.interface'; + +export interface IAutocompleteDataGridSampleMethodOption extends IGetSampleMethodDetails { + value: number; + label: string; +} diff --git a/app/src/features/surveys/observations/observations-table/grid-column-definitions/sampling-information/methods/SampleMethodDataGridEditCell.tsx b/app/src/features/surveys/observations/observations-table/grid-column-definitions/sampling-information/methods/SampleMethodDataGridEditCell.tsx new file mode 100644 index 0000000000..d08df773d6 --- /dev/null +++ b/app/src/features/surveys/observations/observations-table/grid-column-definitions/sampling-information/methods/SampleMethodDataGridEditCell.tsx @@ -0,0 +1,117 @@ +import Autocomplete, { createFilterOptions } from '@mui/material/Autocomplete'; +import Box from '@mui/material/Box'; +import { grey } from '@mui/material/colors'; +import TextField from '@mui/material/TextField'; +import useEnhancedEffect from '@mui/material/utils/useEnhancedEffect'; +import { GridRenderCellParams, GridValidRowModel } from '@mui/x-data-grid'; +import { IAutocompleteDataGridSampleMethodOption } from 'features/surveys/observations/observations-table/grid-column-definitions/sampling-information/methods/SampleMethodDataGrid.interface'; +import { getCurrentMethod } from 'features/surveys/observations/observations-table/grid-column-definitions/sampling-information/utils'; +import { SampleLocationCache } from 'features/surveys/observations/observations-table/ObservationsTableContainer'; +import { MutableRefObject, useRef } from 'react'; + +export interface ISampleMethodDataGridEditCellProps { + dataGridProps: GridRenderCellParams; + cachedSampleLocationsRef: MutableRefObject; + methodOptions: IAutocompleteDataGridSampleMethodOption[]; + onSelectOption?: (selectedSampleSite: IAutocompleteDataGridSampleMethodOption | null) => void; + error?: boolean; +} + +/** + * + * + * @template DataGridType + * @param {ISampleMethodDataGridEditCellProps} props + * @return {*} + */ +const SampleMethodDataGridEditCell = ( + props: ISampleMethodDataGridEditCellProps +) => { + const { dataGridProps, cachedSampleLocationsRef, methodOptions, onSelectOption, error } = props; + + const ref = useRef(); + + useEnhancedEffect(() => { + if (dataGridProps.hasFocus) { + ref.current?.focus(); + } + }, [dataGridProps.hasFocus]); + + function getCurrentValue() { + const currentMethod = getCurrentMethod(dataGridProps, cachedSampleLocationsRef); + + return currentMethod; + } + + return ( + option.label} + isOptionEqualToValue={(option, value) => { + if (!option?.value || !value?.value) { + return false; + } + return option.value === value.value; + }} + filterOptions={createFilterOptions({ limit: 50 })} + onChange={(_, selectedOption) => { + // Set the sample method value with selected options value + dataGridProps.api.setEditCellValue({ + id: dataGridProps.id, + field: dataGridProps.field, + value: selectedOption?.value + }); + + // If the sample method is changed, clear the sample period as it is dependent on the method + dataGridProps.api.setEditCellValue({ + id: dataGridProps.id, + field: 'survey_sample_period_id', + value: null + }); + + onSelectOption?.(selectedOption); + }} + renderInput={(params) => ( + + )} + renderOption={(renderProps, renderOption) => { + return ( + + + {renderOption.label} + + + ); + }} + data-testid={dataGridProps.id} + /> + ); +}; + +export default SampleMethodDataGridEditCell; diff --git a/app/src/features/surveys/observations/observations-table/grid-column-definitions/sampling-information/methods/SampleMethodDataGridViewCell.tsx b/app/src/features/surveys/observations/observations-table/grid-column-definitions/sampling-information/methods/SampleMethodDataGridViewCell.tsx new file mode 100644 index 0000000000..ed491a67f2 --- /dev/null +++ b/app/src/features/surveys/observations/observations-table/grid-column-definitions/sampling-information/methods/SampleMethodDataGridViewCell.tsx @@ -0,0 +1,48 @@ +import Typography from '@mui/material/Typography'; +import { GridRenderCellParams, GridValidRowModel } from '@mui/x-data-grid'; +import { getCurrentMethod } from 'features/surveys/observations/observations-table/grid-column-definitions/sampling-information/utils'; +import { SampleLocationCache } from 'features/surveys/observations/observations-table/ObservationsTableContainer'; +import { MutableRefObject } from 'react'; + +export interface IPartialSampleMethodDataGridViewCellProps { + dataGridProps: GridRenderCellParams; + cachedSampleLocationsRef: MutableRefObject; + error?: boolean; +} + +/** + * Data grid taxonomy component for view. + * + * @template DataGridType + * @param {IPartialSampleMethodDataGridViewCellProps} props + * @return {*} + */ +export const SampleMethodDataGridViewCell = ( + props: IPartialSampleMethodDataGridViewCellProps +) => { + const { dataGridProps, cachedSampleLocationsRef, error } = props; + + const label = getCurrentMethod(dataGridProps, cachedSampleLocationsRef)?.label ?? ''; + + return ( + + + {label} + + + ); +}; diff --git a/app/src/features/surveys/observations/observations-table/grid-column-definitions/sampling-information/periods/SamplePeriodDataGrid.interface.ts b/app/src/features/surveys/observations/observations-table/grid-column-definitions/sampling-information/periods/SamplePeriodDataGrid.interface.ts new file mode 100644 index 0000000000..0d9eb6d4cc --- /dev/null +++ b/app/src/features/surveys/observations/observations-table/grid-column-definitions/sampling-information/periods/SamplePeriodDataGrid.interface.ts @@ -0,0 +1,6 @@ +import { IGetSamplePeriodRecord } from 'interfaces/useSamplingSiteApi.interface'; + +export interface IAutocompleteDataGridSamplePeriodOption extends IGetSamplePeriodRecord { + value: number; + label: string; +} diff --git a/app/src/features/surveys/observations/observations-table/grid-column-definitions/sampling-information/periods/SamplePeriodDataGridEditCell.tsx b/app/src/features/surveys/observations/observations-table/grid-column-definitions/sampling-information/periods/SamplePeriodDataGridEditCell.tsx new file mode 100644 index 0000000000..7c57b64439 --- /dev/null +++ b/app/src/features/surveys/observations/observations-table/grid-column-definitions/sampling-information/periods/SamplePeriodDataGridEditCell.tsx @@ -0,0 +1,110 @@ +import Autocomplete, { createFilterOptions } from '@mui/material/Autocomplete'; +import Box from '@mui/material/Box'; +import { grey } from '@mui/material/colors'; +import TextField from '@mui/material/TextField'; +import useEnhancedEffect from '@mui/material/utils/useEnhancedEffect'; +import { GridRenderCellParams, GridValidRowModel } from '@mui/x-data-grid'; +import { IAutocompleteDataGridSamplePeriodOption } from 'features/surveys/observations/observations-table/grid-column-definitions/sampling-information/periods/SamplePeriodDataGrid.interface'; +import { getCurrentPeriod } from 'features/surveys/observations/observations-table/grid-column-definitions/sampling-information/utils'; +import { SampleLocationCache } from 'features/surveys/observations/observations-table/ObservationsTableContainer'; +import { MutableRefObject, useRef } from 'react'; + +export interface ISamplePeriodDataGridEditCellProps { + dataGridProps: GridRenderCellParams; + cachedSampleLocationsRef: MutableRefObject; + periodOptions: IAutocompleteDataGridSamplePeriodOption[]; + onSelectOption?: (selectedSampleSite: IAutocompleteDataGridSamplePeriodOption | null) => void; + error?: boolean; +} + +/** + * + * + * @template DataGridType + * @param {ISamplePeriodDataGridEditCellProps} props + * @return {*} + */ +const SamplePeriodDataGridEditCell = ( + props: ISamplePeriodDataGridEditCellProps +) => { + const { dataGridProps, cachedSampleLocationsRef, periodOptions, onSelectOption, error } = props; + + const ref = useRef(); + + useEnhancedEffect(() => { + if (dataGridProps.hasFocus) { + ref.current?.focus(); + } + }, [dataGridProps.hasFocus]); + + function getCurrentValue() { + const currentPeriod = getCurrentPeriod(dataGridProps, cachedSampleLocationsRef); + + return currentPeriod; + } + + return ( + option.label} + isOptionEqualToValue={(option, value) => { + if (!option?.value || !value?.value) { + return false; + } + return option.value === value.value; + }} + filterOptions={createFilterOptions({ limit: 50 })} + onChange={(_, selectedOption) => { + // Set the sample period value with selected options value + dataGridProps.api.setEditCellValue({ + id: dataGridProps.id, + field: dataGridProps.field, + value: selectedOption?.value + }); + + onSelectOption?.(selectedOption); + }} + renderInput={(params) => ( + + )} + renderOption={(renderProps, renderOption) => { + return ( + + + {renderOption.label} + + + ); + }} + data-testid={dataGridProps.id} + /> + ); +}; + +export default SamplePeriodDataGridEditCell; diff --git a/app/src/features/surveys/observations/observations-table/grid-column-definitions/sampling-information/periods/SamplePeriodDataGridViewCell.tsx b/app/src/features/surveys/observations/observations-table/grid-column-definitions/sampling-information/periods/SamplePeriodDataGridViewCell.tsx new file mode 100644 index 0000000000..45f25c670c --- /dev/null +++ b/app/src/features/surveys/observations/observations-table/grid-column-definitions/sampling-information/periods/SamplePeriodDataGridViewCell.tsx @@ -0,0 +1,48 @@ +import Typography from '@mui/material/Typography'; +import { GridRenderCellParams, GridValidRowModel } from '@mui/x-data-grid'; +import { getCurrentPeriod } from 'features/surveys/observations/observations-table/grid-column-definitions/sampling-information/utils'; +import { SampleLocationCache } from 'features/surveys/observations/observations-table/ObservationsTableContainer'; +import { MutableRefObject } from 'react'; + +export interface IPartialSamplePeriodDataGridViewCellProps { + dataGridProps: GridRenderCellParams; + cachedSampleLocationsRef: MutableRefObject; + error?: boolean; +} + +/** + * Data grid taxonomy component for view. + * + * @template DataGridType + * @param {IPartialSamplePeriodDataGridViewCellProps} props + * @return {*} + */ +export const SamplePeriodDataGridViewCell = ( + props: IPartialSamplePeriodDataGridViewCellProps +) => { + const { dataGridProps, cachedSampleLocationsRef, error } = props; + + const label = getCurrentPeriod(dataGridProps, cachedSampleLocationsRef)?.label ?? ''; + + return ( + + + {label} + + + ); +}; diff --git a/app/src/features/surveys/observations/observations-table/grid-column-definitions/sampling-information/sites/SampleSiteDataGrid.interface.ts b/app/src/features/surveys/observations/observations-table/grid-column-definitions/sampling-information/sites/SampleSiteDataGrid.interface.ts new file mode 100644 index 0000000000..6429f69518 --- /dev/null +++ b/app/src/features/surveys/observations/observations-table/grid-column-definitions/sampling-information/sites/SampleSiteDataGrid.interface.ts @@ -0,0 +1,13 @@ +import { IGetSampleLocationNonSpatialDetails } from 'interfaces/useSamplingSiteApi.interface'; + +/** + * Defines a single option for a data grid taxonomy autocomplete control. + * + * @export + * @interface IAutocompleteDataGridSampleSiteOption + * @extends {IPartialSampleSite} + */ +export interface IAutocompleteDataGridSampleSiteOption extends IGetSampleLocationNonSpatialDetails { + value: number; + label: string; +} diff --git a/app/src/features/surveys/observations/observations-table/grid-column-definitions/sampling-information/sites/SampleSiteDataGridEditCell.tsx b/app/src/features/surveys/observations/observations-table/grid-column-definitions/sampling-information/sites/SampleSiteDataGridEditCell.tsx new file mode 100644 index 0000000000..8efb849320 --- /dev/null +++ b/app/src/features/surveys/observations/observations-table/grid-column-definitions/sampling-information/sites/SampleSiteDataGridEditCell.tsx @@ -0,0 +1,185 @@ +import Box from '@mui/material/Box'; +import { grey } from '@mui/material/colors'; +import { GridRenderEditCellParams, GridValidRowModel } from '@mui/x-data-grid'; +import AsyncAutocompleteDataGridEditCell from 'components/data-grid/autocomplete/AsyncAutocompleteDataGridEditCell'; +import { IAutocompleteDataGridSampleSiteOption } from 'features/surveys/observations/observations-table/grid-column-definitions/sampling-information/sites/SampleSiteDataGrid.interface'; +import { getCurrentSite } from 'features/surveys/observations/observations-table/grid-column-definitions/sampling-information/utils'; +import { SampleLocationCache } from 'features/surveys/observations/observations-table/ObservationsTableContainer'; +import { useBiohubApi } from 'hooks/useBioHubApi'; +import { useSurveyContext } from 'hooks/useContext'; +import useIsMounted from 'hooks/useIsMounted'; +import { IGetSampleLocationNonSpatialDetails } from 'interfaces/useSamplingSiteApi.interface'; +import debounce from 'lodash-es/debounce'; +import { MutableRefObject, useMemo } from 'react'; + +export interface ISampleSiteDataGridCellProps { + dataGridProps: GridRenderEditCellParams; + cachedSampleLocationsRef: MutableRefObject; + onSelectOption?: (selectedSampleSite: IGetSampleLocationNonSpatialDetails | null) => void; + error?: boolean; +} + +/** + * Data grid taxonomy component for edit. + * + * @template DataGridType + * @template ValueType + * @param {ISampleSiteDataGridCellProps} props + * @return {*} + */ +export const SampleSiteDataGridEditCell = ( + props: ISampleSiteDataGridCellProps +) => { + const { dataGridProps, cachedSampleLocationsRef, onSelectOption, error } = props; + + const biohubApi = useBiohubApi(); + const surveyContext = useSurveyContext(); + + const isMounted = useIsMounted(); + + /** + * Get the current option for the autocomplete, if the field has a value. + * + * @return {*} {(Promise)} + */ + const getCurrentOption = async (): Promise => { + const currentSite = getCurrentSite(dataGridProps, cachedSampleLocationsRef); + + if (!currentSite) { + return null; + } + + return { + ...currentSite, + label: currentSite.name, + value: currentSite.survey_sample_site_id + }; + }; + + /** + * Merge the cached sample locations with the new options returned by the async search, removing duplicates. + * + * @param {IGetSampleLocationNonSpatialDetails[]} cachedOptions + * @param {IGetSampleLocationNonSpatialDetails[]} options + * @return {*} + */ + const mergeOptions = ( + cachedOptions: IGetSampleLocationNonSpatialDetails[], + options: IGetSampleLocationNonSpatialDetails[] + ) => { + const mergedOptionsMap = new Map(); + + // Merge the cached options with the new options, ensuring no duplicates + [...options, ...cachedOptions].forEach((item) => { + mergedOptionsMap.set(item.survey_sample_site_id, { + ...item, + label: item.name, + value: item.survey_sample_site_id + }); + }); + + return Array.from(mergedOptionsMap.values()).sort((a, b) => a.name.localeCompare(b.name)); + }; + + /** + * Debounced function to get the options for the autocomplete, based on the search term. + * Includes the cached sample locations in the resulting options array. + */ + const getOptions = useMemo( + () => + debounce( + async ( + searchTerm: string, + onSearchResults: (searchedValues: IAutocompleteDataGridSampleSiteOption[]) => void + ) => { + const keyword = searchTerm?.trim(); + + biohubApi.samplingSite + .getSampleSites(surveyContext.projectId, surveyContext.surveyId, { keyword }) + .then((response) => { + const options = response.sampleSites.map((item) => ({ + ...item, + label: item.name, + value: item.survey_sample_site_id + })); + + if (!isMounted()) { + return; + } + + const mergedOptions = mergeOptions(cachedSampleLocationsRef.current?.locations ?? [], options); + + onSearchResults(mergedOptions); + }); + + onSearchResults( + cachedSampleLocationsRef.current?.locations.map((item) => ({ + ...item, + label: item.name, + value: item.survey_sample_site_id + })) ?? [] + ); + }, + 500 + ), + [biohubApi.samplingSite, cachedSampleLocationsRef, isMounted, surveyContext.projectId, surveyContext.surveyId] + ); + + /** + * Get the initial options for the autocomplete. + * + * @return {*} + */ + const getInitialOptions = () => { + return ( + cachedSampleLocationsRef.current?.locations.map((item) => ({ + ...item, + label: item.name, + value: item.survey_sample_site_id + })) ?? [] + ); + }; + + return ( + { + // If the sample site is changed, clear the sample method and period as they are dependent on the site + dataGridProps.api.setEditCellValue({ + id: dataGridProps.id, + field: 'survey_sample_method_id', + value: null + }); + dataGridProps.api.setEditCellValue({ + id: dataGridProps.id, + field: 'survey_sample_period_id', + value: null + }); + + onSelectOption?.(selectedOption); + }} + placeholder="Search for a site" + error={error} + renderOption={(renderProps, renderOption) => { + return ( + + + {renderOption.label} + + + ); + }} + /> + ); +}; diff --git a/app/src/features/surveys/observations/observations-table/grid-column-definitions/sampling-information/sites/SampleSiteDataGridViewCell.tsx b/app/src/features/surveys/observations/observations-table/grid-column-definitions/sampling-information/sites/SampleSiteDataGridViewCell.tsx new file mode 100644 index 0000000000..34f1674516 --- /dev/null +++ b/app/src/features/surveys/observations/observations-table/grid-column-definitions/sampling-information/sites/SampleSiteDataGridViewCell.tsx @@ -0,0 +1,48 @@ +import Typography from '@mui/material/Typography'; +import { GridRenderCellParams, GridValidRowModel } from '@mui/x-data-grid'; +import { getCurrentSite } from 'features/surveys/observations/observations-table/grid-column-definitions/sampling-information/utils'; +import { SampleLocationCache } from 'features/surveys/observations/observations-table/ObservationsTableContainer'; +import { MutableRefObject } from 'react'; + +export interface IPartialSampleSiteDataGridViewCellProps { + dataGridProps: GridRenderCellParams; + cachedSampleLocationsRef: MutableRefObject; + error?: boolean; +} + +/** + * Data grid taxonomy component for view. + * + * @template DataGridType + * @param {IPartialSampleSiteDataGridViewCellProps} props + * @return {*} + */ +export const SampleSiteDataGridViewCell = ( + props: IPartialSampleSiteDataGridViewCellProps +) => { + const { dataGridProps, cachedSampleLocationsRef, error } = props; + + const label = getCurrentSite(dataGridProps, cachedSampleLocationsRef)?.label ?? ''; + + return ( + + + {label} + + + ); +}; diff --git a/app/src/features/surveys/observations/observations-table/grid-column-definitions/sampling-information/useSampleLocationsCache.tsx b/app/src/features/surveys/observations/observations-table/grid-column-definitions/sampling-information/useSampleLocationsCache.tsx new file mode 100644 index 0000000000..70fc75d86e --- /dev/null +++ b/app/src/features/surveys/observations/observations-table/grid-column-definitions/sampling-information/useSampleLocationsCache.tsx @@ -0,0 +1,49 @@ +import { IGetSampleLocationNonSpatialDetails } from 'interfaces/useSamplingSiteApi.interface'; +import { useRef } from 'react'; + +export type SampleLocationCache = { + locations: IGetSampleLocationNonSpatialDetails[]; +}; + +export const useSampleLocationsCache = () => { + const cachedSampleLocationsRef = useRef(); + + const updateCachedSampleLocationsRef = (selectedSampleSites: IGetSampleLocationNonSpatialDetails[]) => { + if (!selectedSampleSites?.length) { + // If the selected sample site is null, nothing to add to the cache + return; + } + + if (!cachedSampleLocationsRef.current) { + // Initialize the cache + cachedSampleLocationsRef.current = { + locations: selectedSampleSites + }; + } + + const newSites = []; + + for (const site of selectedSampleSites) { + if ( + cachedSampleLocationsRef.current.locations.findIndex( + (item) => item.survey_sample_site_id === site.survey_sample_site_id + ) !== -1 + ) { + // The site is already in the cache + continue; + } + + newSites.push(site); + } + + // Update the cache + cachedSampleLocationsRef.current = { + locations: [...cachedSampleLocationsRef.current.locations, ...newSites] + }; + }; + + return { + cachedSampleLocationsRef, + updateCachedSampleLocationsRef + }; +}; diff --git a/app/src/features/surveys/observations/observations-table/grid-column-definitions/sampling-information/utils.ts b/app/src/features/surveys/observations/observations-table/grid-column-definitions/sampling-information/utils.ts new file mode 100644 index 0000000000..aa6a6fe630 --- /dev/null +++ b/app/src/features/surveys/observations/observations-table/grid-column-definitions/sampling-information/utils.ts @@ -0,0 +1,152 @@ +import { GridRenderCellParams, GridValidRowModel } from '@mui/x-data-grid'; +import { IAutocompleteDataGridSampleMethodOption } from 'features/surveys/observations/observations-table/grid-column-definitions/sampling-information/methods/SampleMethodDataGrid.interface'; +import { IAutocompleteDataGridSamplePeriodOption } from 'features/surveys/observations/observations-table/grid-column-definitions/sampling-information/periods/SamplePeriodDataGrid.interface'; +import { IAutocompleteDataGridSampleSiteOption } from 'features/surveys/observations/observations-table/grid-column-definitions/sampling-information/sites/SampleSiteDataGrid.interface'; +import { SampleLocationCache } from 'features/surveys/observations/observations-table/ObservationsTableContainer'; +import { IGetSampleLocationNonSpatialDetails, IGetSamplePeriodRecord } from 'interfaces/useSamplingSiteApi.interface'; +import { MutableRefObject } from 'react'; + +/** + * Given a site id and sample location cache, find the site object. + * + * @param {(number | undefined)} siteId + * @param {(SampleLocationCache | undefined)} cache + */ +const findSite = (siteId: number | undefined, cache: SampleLocationCache | undefined) => + cache?.locations.find((site) => site.survey_sample_site_id === siteId); + +/** + * Given a sample site object and method id, find the method object. + * + * @param {(IGetSampleLocationNonSpatialDetails | undefined)} site + * @param {(number | undefined)} methodId + */ +const findMethod = (site: IGetSampleLocationNonSpatialDetails | undefined, methodId: number | undefined) => + site?.sample_methods.find((method) => method.survey_sample_method_id === methodId); + +/** + * Transform a sampling option to be compatible with the autocomplete control. + * + * @template T + * @param {T} item + * @param {string} label + * @param {number} value + * @return {*} {(T & { label: string; value: number })} + */ +const formatOption = (item: T, label: string, value: number): T & { label: string; value: number } => ({ + ...item, + label, + value +}); + +/** + * Get the label for a period. + * + * @param {(IGetSamplePeriodRecord | null)} period + * @return {*} + */ +const getPeriodLabel = (period: IGetSamplePeriodRecord | null) => { + if (!period) { + return ''; + } + return `${period.start_date} ${period.start_time ?? ''} - ${period.end_date} ${period.end_time ?? ''}`; +}; + +/** + * Get the currently selected site for the row. + * + * @template DataGridType + * @param {GridRenderCellParams} dataGridProps + * @param {(MutableRefObject)} cachedSampleLocationsRef + * @return {*} {(IAutocompleteDataGridSampleSiteOption | null)} + */ +export const getCurrentSite = ( + dataGridProps: GridRenderCellParams, + cachedSampleLocationsRef: MutableRefObject +): IAutocompleteDataGridSampleSiteOption | null => { + const currentSite = findSite(dataGridProps.value as number, cachedSampleLocationsRef.current); + return currentSite ? formatOption(currentSite, currentSite.name, currentSite.survey_sample_site_id) : null; +}; + +/** + * Get the currently selected method for the row. + * + * @template DataGridType + * @param {GridRenderCellParams} dataGridProps + * @param {(MutableRefObject)} cachedSampleLocationsRef + * @return {*} {(IAutocompleteDataGridSampleMethodOption | null)} + */ +export const getCurrentMethod = ( + dataGridProps: GridRenderCellParams, + cachedSampleLocationsRef: MutableRefObject +): IAutocompleteDataGridSampleMethodOption | null => { + for (const site of cachedSampleLocationsRef.current?.locations ?? []) { + const currentMethod = findMethod(site, dataGridProps.value as number); + if (currentMethod) { + return formatOption(currentMethod, currentMethod.technique.name, currentMethod.survey_sample_method_id); + } + } + return null; +}; + +/** + * Get the currently selected period for the row. + * + * @template DataGridType + * @param {GridRenderCellParams} dataGridProps + * @param {(MutableRefObject)} cachedSampleLocationsRef + * @return {*} {(IAutocompleteDataGridSamplePeriodOption | null)} + */ +export const getCurrentPeriod = ( + dataGridProps: GridRenderCellParams, + cachedSampleLocationsRef: MutableRefObject +): IAutocompleteDataGridSamplePeriodOption | null => { + for (const site of cachedSampleLocationsRef.current?.locations ?? []) { + for (const method of site.sample_methods ?? []) { + const currentPeriod = method.sample_periods.find( + (period) => period.survey_sample_period_id === dataGridProps.value + ); + if (currentPeriod) { + return formatOption(currentPeriod, getPeriodLabel(currentPeriod), currentPeriod.survey_sample_period_id); + } + } + } + return null; +}; + +/** + * Get all valid methods for the currently selected site. + * + * @template DataGridType + * @param {GridRenderCellParams} dataGridProps + * @param {(MutableRefObject)} cachedSampleLocationsRef + * @return {*} {IAutocompleteDataGridSampleMethodOption[]} + */ +export const getMethodsForRow = ( + dataGridProps: GridRenderCellParams, + cachedSampleLocationsRef: MutableRefObject +): IAutocompleteDataGridSampleMethodOption[] => { + const site = findSite(dataGridProps.row.survey_sample_site_id, cachedSampleLocationsRef.current); + return (site?.sample_methods ?? []).map((method) => + formatOption(method, method.technique.name, method.survey_sample_method_id) + ); +}; + +/** + * Get all valid periods for the currently selected site and method. + * + * @template DataGridType + * @param {GridRenderCellParams} dataGridProps + * @param {(MutableRefObject)} cachedSampleLocationsRef + * @return {*} {IAutocompleteDataGridSamplePeriodOption[]} + */ +export const getPeriodsForRow = ( + dataGridProps: GridRenderCellParams, + cachedSampleLocationsRef: MutableRefObject +): IAutocompleteDataGridSamplePeriodOption[] => { + const site = findSite(dataGridProps.row.survey_sample_site_id, cachedSampleLocationsRef.current); + const method = findMethod(site, dataGridProps.row.survey_sample_method_id); + return (method?.sample_periods ?? []).map((period) => + formatOption(period, getPeriodLabel(period), period.survey_sample_period_id) + ); +}; diff --git a/app/src/features/surveys/observations/sampling-sites/SamplingSiteListContainer.tsx b/app/src/features/surveys/observations/sampling-sites/SamplingSiteListContainer.tsx index edbc98e77e..2506ec6e94 100644 --- a/app/src/features/surveys/observations/sampling-sites/SamplingSiteListContainer.tsx +++ b/app/src/features/surveys/observations/sampling-sites/SamplingSiteListContainer.tsx @@ -14,14 +14,21 @@ import Menu, { MenuProps } from '@mui/material/Menu'; import MenuItem from '@mui/material/MenuItem'; import Paper from '@mui/material/Paper'; import Stack from '@mui/material/Stack'; +import TablePagination from '@mui/material/TablePagination'; import Toolbar from '@mui/material/Toolbar'; import Typography from '@mui/material/Typography'; +import { GridPaginationModel, GridSortModel } from '@mui/x-data-grid'; import { SkeletonList } from 'components/loading/SkeletonLoaders'; -import { SamplingSiteListSite } from 'features/surveys/observations/sampling-sites/components/SamplingSiteListSite'; +import { SamplingSiteListSite } from 'features/surveys/observations/sampling-sites/site/SamplingSiteListSite'; import { useBiohubApi } from 'hooks/useBioHubApi'; import { useDialogContext, useObservationsPageContext, useSurveyContext } from 'hooks/useContext'; -import { useEffect, useState } from 'react'; +import useDataLoader from 'hooks/useDataLoader'; +import { useEffect, useMemo, useState } from 'react'; import { Link as RouterLink } from 'react-router-dom'; +import { ApiPaginationRequestOptions } from 'types/misc'; +import { firstOrNull } from 'utils/Utils'; + +const pageSizeOptions = [10, 25, 50, 1000]; /** * Renders a list of sampling sites. @@ -34,16 +41,43 @@ export const SamplingSiteListContainer = () => { const observationsPageContext = useObservationsPageContext(); const biohubApi = useBiohubApi(); - useEffect(() => { - surveyContext.sampleSiteDataLoader.load(surveyContext.projectId, surveyContext.surveyId); - }, [surveyContext.projectId, surveyContext.sampleSiteDataLoader, surveyContext.surveyId]); - const [sampleSiteAnchorEl, setSampleSiteAnchorEl] = useState(null); const [headerAnchorEl, setHeaderAnchorEl] = useState(null); const [selectedSampleSiteId, setSelectedSampleSiteId] = useState(); const [checkboxSelectedIds, setCheckboxSelectedIds] = useState([]); - const sampleSites = surveyContext.sampleSiteDataLoader.data?.sampleSites ?? []; + const [paginationModel, setPaginationModel] = useState({ + page: 0, + pageSize: pageSizeOptions[1] + }); + const [sortModel] = useState([]); + + const sampleSiteDataLoader = useDataLoader((pagination: ApiPaginationRequestOptions) => + biohubApi.samplingSite.getSampleSites(surveyContext.projectId, surveyContext.surveyId, { pagination }) + ); + + const pagination: ApiPaginationRequestOptions = useMemo(() => { + const sort = firstOrNull(sortModel); + + return { + limit: paginationModel.pageSize, + sort: sort?.field || undefined, + order: sort?.sort || undefined, + + // API pagination pages begin at 1, but MUI DataGrid pagination begins at 0. + page: paginationModel.page + 1 + }; + }, [sortModel, paginationModel]); + + // Refresh survey list when pagination or sort changes + useEffect(() => { + sampleSiteDataLoader.refresh(pagination); + + // Adding a DataLoader as a dependency causes an infinite rerender loop if a useEffect calls `.refresh` + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [pagination]); + + const sampleSites = sampleSiteDataLoader.data?.sampleSites ?? []; const handleSampleSiteMenuClick = ( event: React.MouseEvent, @@ -67,7 +101,7 @@ export const SamplingSiteListContainer = () => { .then(() => { dialogContext.setYesNoDialog({ open: false }); setSampleSiteAnchorEl(null); - surveyContext.sampleSiteDataLoader.refresh(surveyContext.projectId, surveyContext.surveyId); + sampleSiteDataLoader.refresh(pagination); }) .catch((error: any) => { dialogContext.setYesNoDialog({ open: false }); @@ -133,7 +167,7 @@ export const SamplingSiteListContainer = () => { dialogContext.setYesNoDialog({ open: false }); setCheckboxSelectedIds([]); setHeaderAnchorEl(null); - surveyContext.sampleSiteDataLoader.refresh(surveyContext.projectId, surveyContext.surveyId); + sampleSiteDataLoader.refresh(pagination); }) .catch((error: any) => { dialogContext.setYesNoDialog({ open: false }); @@ -155,6 +189,14 @@ export const SamplingSiteListContainer = () => { }); }; + const handleChangeRowsPerPage = (event: React.ChangeEvent) => { + setPaginationModel({ page: 0, pageSize: parseInt(event.target.value, 10) }); + }; + + const handleChangePage = (_: React.MouseEvent | null, newPage: number) => { + setPaginationModel((model) => ({ ...model, page: newPage })); + }; + const handlePromptConfirmBulkDelete = () => { dialogContext.setYesNoDialog({ dialogTitle: 'Delete Sampling Sites?', @@ -179,7 +221,10 @@ export const SamplingSiteListContainer = () => { }); }; - const samplingSiteCount = sampleSites.length ?? 0; + const samplingSiteCount = useMemo( + () => sampleSiteDataLoader.data?.pagination.total ?? 0, + [sampleSiteDataLoader.data] + ); return ( <> @@ -287,102 +332,118 @@ export const SamplingSiteListContainer = () => { + + + + Select All + + } + control={ + 0 && checkboxSelectedIds.length === samplingSiteCount} + indeterminate={checkboxSelectedIds.length >= 1 && checkboxSelectedIds.length < samplingSiteCount} + onClick={() => { + if (checkboxSelectedIds.length === samplingSiteCount) { + setCheckboxSelectedIds([]); + return; + } + + const sampleSiteIds = sampleSites.map((sampleSite) => sampleSite.survey_sample_site_id); + setCheckboxSelectedIds(sampleSiteIds); + }} + inputProps={{ 'aria-label': 'controlled' }} + /> + } + /> + + + - - {surveyContext.sampleSiteDataLoader.isLoading ? ( + {sampleSiteDataLoader.isLoading ? ( + - ) : ( - - - - - Select All - - } - control={ - 0 && checkboxSelectedIds.length === samplingSiteCount} - indeterminate={ - checkboxSelectedIds.length >= 1 && checkboxSelectedIds.length < samplingSiteCount - } - onClick={() => { - if (checkboxSelectedIds.length === samplingSiteCount) { - setCheckboxSelectedIds([]); - return; - } + + ) : ( + + + {/* Display text if the sample site data loader has no items in it */} + {!sampleSiteDataLoader.data?.sampleSites.length && ( + + No Sampling Sites + + )} - const sampleSiteIds = sampleSites.map((sampleSite) => sampleSite.survey_sample_site_id); - setCheckboxSelectedIds(sampleSiteIds); - }} - inputProps={{ 'aria-label': 'controlled' }} - /> - } + {sampleSiteDataLoader.data?.sampleSites.map((sampleSite) => { + return ( + - - - - - {/* Display text if the sample site data loader has no items in it */} - {!surveyContext.sampleSiteDataLoader.data?.sampleSites.length && ( - - No Sampling Sites - - )} - - {surveyContext.sampleSiteDataLoader.data?.sampleSites.map((sampleSite) => { - return ( - - ); - })} - - {/* TODO how should we handle controlling pagination? */} - {/* - - {}} - rowsPerPageOptions={[10, 50]} - count={69} - /> - */} - - )} - + ); + })} + + + )} + {/* Pagination control */} + + + + ); diff --git a/app/src/features/surveys/observations/sampling-sites/components/SamplingSiteListPeriod.tsx b/app/src/features/surveys/observations/sampling-sites/components/SamplingSiteListPeriod.tsx deleted file mode 100644 index fee52ec6e2..0000000000 --- a/app/src/features/surveys/observations/sampling-sites/components/SamplingSiteListPeriod.tsx +++ /dev/null @@ -1,152 +0,0 @@ -import { mdiArrowRightThin, mdiCalendarRange } from '@mdi/js'; -import Icon from '@mdi/react'; -import { Timeline, TimelineConnector, TimelineContent, TimelineDot, TimelineItem, TimelineSeparator } from '@mui/lab'; -import Box from '@mui/material/Box'; -import grey from '@mui/material/colors/grey'; -import Typography from '@mui/material/Typography'; -import { IObservationsContext } from 'contexts/observationsContext'; -import { IObservationsPageContext } from 'contexts/observationsPageContext'; -import dayjs from 'dayjs'; -import { ImportObservationsButton } from 'features/surveys/observations/sampling-sites/components/ImportObservationsButton'; -import { ISurveySampleMethodPeriodData } from 'features/surveys/sampling-information/periods/SamplingPeriodFormContainer'; -import { IGetSamplePeriodRecord } from 'interfaces/useSamplingSiteApi.interface'; - -interface ISamplingSiteListPeriodProps { - samplePeriods: (IGetSamplePeriodRecord | ISurveySampleMethodPeriodData)[]; - observationsPageContext?: IObservationsPageContext; - observationsContext?: IObservationsContext; -} -/** - * Renders sampling periods for a sampling method - * @param props {ISamplingSiteListPeriodProps} - * @returns - */ -export const SamplingSiteListPeriod = (props: ISamplingSiteListPeriodProps) => { - const formatDate = (dt: Date, time: boolean) => dayjs(dt).format(time ? 'MMM D, YYYY h:mm A' : 'MMM D, YYYY'); - - const { observationsPageContext, observationsContext } = props; - - const dateSx = { - fontSize: '0.85rem', - color: 'textSecondary' - }; - - const timeSx = { - fontSize: '0.85rem', - color: 'text.secondary' - }; - - return ( - - {props.samplePeriods - .sort((a, b) => { - const startDateA = new Date(a.start_date); - const startDateB = new Date(b.start_date); - - if (startDateA === startDateB) { - if (a.start_time && b.start_time) { - if (a.start_time < b.start_time) return 1; - if (a.start_time > b.start_time) return -1; - return 0; - } - if (a.start_time && !b.start_time) { - return -1; - } - } - if (startDateA < startDateB) { - return -1; - } - if (startDateA > startDateB) { - return 1; - } - return 0; - }) - .map((samplePeriod, index) => ( - - - {props.samplePeriods.length > 1 ? ( - - - {index < props.samplePeriods.length - 1 && ( - - )} - - ) : ( - - - - )} - - - - - - {formatDate(samplePeriod.start_date as unknown as Date, false)} - - - {samplePeriod.start_time} - - - - - - - - {formatDate(samplePeriod.end_date as unknown as Date, false)} - - - {samplePeriod.end_time} - - - {observationsPageContext && observationsContext && samplePeriod?.survey_sample_period_id && ( - - { - observationsPageContext.setIsDisabled(true); - observationsPageContext.setIsLoading(true); - }} - onSuccess={() => { - observationsContext.observationsDataLoader.refresh(); - }} - onFinish={() => { - observationsPageContext.setIsDisabled(false); - observationsPageContext.setIsLoading(false); - }} - processOptions={{ surveySamplePeriodId: samplePeriod.survey_sample_period_id }} - /> - - )} - - - - ))} - - ); -}; diff --git a/app/src/features/surveys/observations/sampling-sites/components/SamplingSiteListSite.tsx b/app/src/features/surveys/observations/sampling-sites/site/SamplingSiteListSite.tsx similarity index 64% rename from app/src/features/surveys/observations/sampling-sites/components/SamplingSiteListSite.tsx rename to app/src/features/surveys/observations/sampling-sites/site/SamplingSiteListSite.tsx index 8fc7756283..402dd1934d 100644 --- a/app/src/features/surveys/observations/sampling-sites/components/SamplingSiteListSite.tsx +++ b/app/src/features/surveys/observations/sampling-sites/site/SamplingSiteListSite.tsx @@ -5,20 +5,15 @@ import AccordionDetails from '@mui/material/AccordionDetails'; import AccordionSummary from '@mui/material/AccordionSummary'; import Box from '@mui/material/Box'; import Checkbox from '@mui/material/Checkbox'; -import blue from '@mui/material/colors/blue'; import grey from '@mui/material/colors/grey'; 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 { IStaticLayer } from 'components/map/components/StaticLayers'; -import { SamplingSiteListMethod } from 'features/surveys/observations/sampling-sites/components/SamplingSiteListMethod'; -import { SamplingStratumChips } from 'features/surveys/sampling-information/sites/edit/form/SamplingStratumChips'; -import SurveyMap from 'features/surveys/view/SurveyMap'; -import { IGetSampleLocationDetails } from 'interfaces/useSamplingSiteApi.interface'; +import { IGetSampleLocationNonSpatialDetails } from 'interfaces/useSamplingSiteApi.interface'; +import { SamplingSiteListContent } from './accordion-details/SamplingSiteListContent'; export interface ISamplingSiteListSiteProps { - sampleSite: IGetSampleLocationDetails; + sampleSite: IGetSampleLocationNonSpatialDetails; isChecked: boolean; handleSampleSiteMenuClick: (event: React.MouseEvent, sample_site_id: number) => void; handleCheckboxChange: (sampleSiteId: number) => void; @@ -33,24 +28,10 @@ export interface ISamplingSiteListSiteProps { export const SamplingSiteListSite = (props: ISamplingSiteListSiteProps) => { const { sampleSite, isChecked, handleSampleSiteMenuClick, handleCheckboxChange } = props; - const staticLayers: IStaticLayer[] = [ - { - layerName: 'Sample Sites', - layerOptions: { color: blue[500], fillColor: blue[500] }, - features: [ - { - id: sampleSite.survey_sample_site_id, - key: `sampling-site-${sampleSite.survey_sample_site_id}`, - geoJSON: sampleSite.geojson - } - ] - } - ]; - let icon; - if (sampleSite.geojson.geometry.type === 'Point') { + if (sampleSite.geometry_type === 'Point') { icon = { path: mdiMapMarker, title: 'Point sampling site' }; - } else if (sampleSite.geojson.geometry.type === 'LineString') { + } else if (sampleSite.geometry_type === 'LineString') { icon = { path: mdiVectorLine, title: 'Transect sampling site' }; } else { icon = { path: mdiVectorSquare, title: 'Polygon sampling site' }; @@ -60,6 +41,12 @@ export const SamplingSiteListSite = (props: ISamplingSiteListSiteProps) => { { handleSampleSiteMenuClick(event, sampleSite.survey_sample_site_id) } aria-label="sample-site-settings"> - + { pb: 1, pl: 1, pr: 0 - }}> - {sampleSite.stratums && sampleSite.stratums.length > 0 && ( - - - - )} - - {sampleSite.sample_methods?.map((sampleMethod, index) => { - return ( - - ); - })} - - - - - + }} + /> + ); }; diff --git a/app/src/features/surveys/observations/sampling-sites/site/accordion-details/SamplingSiteListContent.tsx b/app/src/features/surveys/observations/sampling-sites/site/accordion-details/SamplingSiteListContent.tsx new file mode 100644 index 0000000000..41f48384de --- /dev/null +++ b/app/src/features/surveys/observations/sampling-sites/site/accordion-details/SamplingSiteListContent.tsx @@ -0,0 +1,80 @@ +import Box from '@mui/material/Box'; +import List from '@mui/material/List'; +import Skeleton from '@mui/material/Skeleton'; +import Stack from '@mui/material/Stack'; +import { SamplingStratumChips } from 'features/surveys/sampling-information/sites/edit/form/SamplingStratumChips'; +import { useBiohubApi } from 'hooks/useBioHubApi'; +import { useSurveyContext } from 'hooks/useContext'; +import useDataLoader from 'hooks/useDataLoader'; +import { useEffect } from 'react'; +import { SamplingSiteListMap } from './map/SamplingSiteMap'; +import { SamplingSiteListMethod } from './method/SamplingSiteListMethod'; + +export interface ISamplingSiteListContentProps { + surveySampleSiteId: number; +} + +/** + * Renders a list item for a single sampling method. + * + * @param {ISamplingSiteListContentProps} props + * @return {*} + */ +export const SamplingSiteListContent = (props: ISamplingSiteListContentProps) => { + const { surveySampleSiteId } = props; + + const biohubApi = useBiohubApi(); + const { surveyId, projectId } = useSurveyContext(); + + const sampleSiteDataLoader = useDataLoader(() => + biohubApi.samplingSite.getSampleSiteById(projectId, surveyId, surveySampleSiteId) + ); + + useEffect(() => { + sampleSiteDataLoader.load(); + }, [sampleSiteDataLoader]); + + const sampleSite = sampleSiteDataLoader.data; + + if (!sampleSite) { + return ( + + + + + + + ); + } + + return ( + <> + {sampleSite.stratums && sampleSite.stratums.length > 0 && ( + + + + )} + + {sampleSite.sample_methods?.map((sampleMethod, index) => { + return ( + + ); + })} + + + + + + ); +}; diff --git a/app/src/features/surveys/observations/sampling-sites/site/accordion-details/map/SamplingSiteMap.tsx b/app/src/features/surveys/observations/sampling-sites/site/accordion-details/map/SamplingSiteMap.tsx new file mode 100644 index 0000000000..1756c6e8c7 --- /dev/null +++ b/app/src/features/surveys/observations/sampling-sites/site/accordion-details/map/SamplingSiteMap.tsx @@ -0,0 +1,34 @@ +import blue from '@mui/material/colors/blue'; +import { IStaticLayer } from 'components/map/components/StaticLayers'; +import SurveyMap from 'features/surveys/view/SurveyMap'; +import { IGetSampleLocationDetails } from 'interfaces/useSamplingSiteApi.interface'; + +export interface ISamplingSiteListMapProps { + sampleSite: IGetSampleLocationDetails; +} + +/** + * Renders a list item for a single sampling site. + * + * @param {ISamplingSiteListMapProps} props + * @return {*} + */ +export const SamplingSiteListMap = (props: ISamplingSiteListMapProps) => { + const { sampleSite } = props; + + const staticLayers: IStaticLayer[] = [ + { + layerName: 'Sample Sites', + layerOptions: { color: blue[500], fillColor: blue[500] }, + features: [ + { + id: sampleSite.survey_sample_site_id, + key: `sampling-site-${sampleSite.survey_sample_site_id}`, + geoJSON: sampleSite.geojson + } + ] + } + ]; + + return ; +}; diff --git a/app/src/features/surveys/observations/sampling-sites/components/SamplingSiteListMethod.tsx b/app/src/features/surveys/observations/sampling-sites/site/accordion-details/method/SamplingSiteListMethod.tsx similarity index 95% rename from app/src/features/surveys/observations/sampling-sites/components/SamplingSiteListMethod.tsx rename to app/src/features/surveys/observations/sampling-sites/site/accordion-details/method/SamplingSiteListMethod.tsx index 39be47b8e5..448ea43903 100644 --- a/app/src/features/surveys/observations/sampling-sites/components/SamplingSiteListMethod.tsx +++ b/app/src/features/surveys/observations/sampling-sites/site/accordion-details/method/SamplingSiteListMethod.tsx @@ -2,7 +2,7 @@ import grey from '@mui/material/colors/grey'; import List from '@mui/material/List'; import ListItem from '@mui/material/ListItem'; import ListItemText from '@mui/material/ListItemText'; -import { SamplingSiteListPeriod } from 'features/surveys/observations/sampling-sites/components/SamplingSiteListPeriod'; +import { SamplingSiteListPeriod } from 'features/surveys/observations/sampling-sites/site/accordion-details/method/period/SamplingSiteListPeriod'; import { useObservationsContext, useObservationsPageContext } from 'hooks/useContext'; import { IGetSampleMethodDetails } from 'interfaces/useSamplingSiteApi.interface'; diff --git a/app/src/features/surveys/observations/sampling-sites/site/accordion-details/method/period/SamplingSiteListPeriod.tsx b/app/src/features/surveys/observations/sampling-sites/site/accordion-details/method/period/SamplingSiteListPeriod.tsx new file mode 100644 index 0000000000..8371f5418c --- /dev/null +++ b/app/src/features/surveys/observations/sampling-sites/site/accordion-details/method/period/SamplingSiteListPeriod.tsx @@ -0,0 +1,143 @@ +import { mdiArrowRightThin, mdiCalendarRange } from '@mdi/js'; +import Icon from '@mdi/react'; +import { Timeline, TimelineConnector, TimelineContent, TimelineDot, TimelineItem, TimelineSeparator } from '@mui/lab'; +import Box from '@mui/material/Box'; +import grey from '@mui/material/colors/grey'; +import Typography from '@mui/material/Typography'; +import { IObservationsContext } from 'contexts/observationsContext'; +import { IObservationsPageContext } from 'contexts/observationsPageContext'; +import dayjs from 'dayjs'; +import { ImportObservationsButton } from 'features/surveys/observations/sampling-sites/components/ImportObservationsButton'; +import { ISurveySampleMethodPeriodData } from 'features/surveys/sampling-information/periods/SamplingPeriodFormContainer'; +import { IGetSamplePeriodRecord } from 'interfaces/useSamplingSiteApi.interface'; + +interface ISamplingSiteListPeriodProps { + samplePeriods: (IGetSamplePeriodRecord | ISurveySampleMethodPeriodData)[]; + observationsPageContext?: IObservationsPageContext; + observationsContext?: IObservationsContext; +} +/** + * Renders sampling periods for a sampling method + * @param props {ISamplingSiteListPeriodProps} + * @returns + */ +export const SamplingSiteListPeriod = (props: ISamplingSiteListPeriodProps) => { + const formatDate = (dt: Date, time: boolean) => dayjs(dt).format(time ? 'MMM D, YYYY h:mm A' : 'MMM D, YYYY'); + + const { observationsPageContext, observationsContext } = props; + + const dateSx = { + fontSize: '0.85rem', + color: 'textSecondary' + }; + + const timeSx = { + fontSize: '0.85rem', + color: 'text.secondary' + }; + + const sortedSamplePeriods = props.samplePeriods.sort((a, b) => { + const startDateA = new Date(a.start_date); + const startDateB = new Date(b.start_date); + + if (startDateA === startDateB) { + if (a.start_time && b.start_time) { + return a.start_time < b.start_time ? 1 : -1; + } + return a.start_time ? -1 : 1; + } + + return startDateA < startDateB ? -1 : 1; + }); + + return ( + + {sortedSamplePeriods.map((samplePeriod, index) => ( + + + {props.samplePeriods.length > 1 ? ( + + + {index < props.samplePeriods.length - 1 && ( + + )} + + ) : ( + + + + )} + + + + + + {formatDate(samplePeriod.start_date as unknown as Date, false)} + + + {samplePeriod.start_time} + + + + + + + + {formatDate(samplePeriod.end_date as unknown as Date, false)} + + + {samplePeriod.end_time} + + + {observationsPageContext && observationsContext && samplePeriod?.survey_sample_period_id && ( + + { + observationsPageContext.setIsDisabled(true); + observationsPageContext.setIsLoading(true); + }} + onSuccess={() => { + observationsContext.observationsDataLoader.refresh(); + }} + onFinish={() => { + observationsPageContext.setIsDisabled(false); + observationsPageContext.setIsLoading(false); + }} + processOptions={{ surveySamplePeriodId: samplePeriod.survey_sample_period_id }} + /> + + )} + + + + ))} + + ); +}; diff --git a/app/src/features/surveys/sampling-information/periods/table/SamplingPeriodTable.tsx b/app/src/features/surveys/sampling-information/periods/table/SamplingPeriodTable.tsx index ea9bcec0f8..9523da8f36 100644 --- a/app/src/features/surveys/sampling-information/periods/table/SamplingPeriodTable.tsx +++ b/app/src/features/surveys/sampling-information/periods/table/SamplingPeriodTable.tsx @@ -4,6 +4,7 @@ import { StyledDataGrid } from 'components/data-grid/StyledDataGrid'; import { DATE_FORMAT } from 'constants/dateTimeFormats'; import dayjs from 'dayjs'; import { useCodesContext } from 'hooks/useContext'; +import { formatTimeDifference } from 'utils/datetime'; import { getCodesName } from 'utils/Utils'; export interface ISamplingSitePeriodRowData { @@ -82,12 +83,21 @@ export const SamplingPeriodTable = (props: ISamplingPeriodTableProps) => { field: 'end_time', headerName: 'End time', flex: 1 + }, + { + field: 'duration', + headerName: 'Duration', + flex: 1, + renderCell: (params) => { + const { start_date, start_time, end_date, end_time } = params.row; + return formatTimeDifference(start_date, start_time, end_date, end_time); + } } ]; return ( 'auto'} disableColumnMenu rows={periods} @@ -95,6 +105,7 @@ export const SamplingPeriodTable = (props: ISamplingPeriodTableProps) => { columns={columns} checkboxSelection={false} disableRowSelectionOnClick + rowCount={periods.length} initialState={{ pagination: { paginationModel: { page: 1, pageSize: 10 } diff --git a/app/src/features/surveys/sampling-information/sites/SamplingSiteContainer.tsx b/app/src/features/surveys/sampling-information/sites/SamplingSiteContainer.tsx index f8d28ab0f3..a095be9583 100644 --- a/app/src/features/surveys/sampling-information/sites/SamplingSiteContainer.tsx +++ b/app/src/features/surveys/sampling-information/sites/SamplingSiteContainer.tsx @@ -1,33 +1,15 @@ -import { mdiArrowTopRight, mdiDotsVertical, mdiPlus, mdiTrashCanOutline } from '@mdi/js'; +import { mdiPlus } from '@mdi/js'; import Icon from '@mdi/react'; import Box from '@mui/material/Box'; import Button from '@mui/material/Button'; import Divider from '@mui/material/Divider'; -import IconButton from '@mui/material/IconButton'; -import ListItemIcon from '@mui/material/ListItemIcon'; -import ListItemText from '@mui/material/ListItemText'; -import Menu from '@mui/material/Menu'; -import MenuItem from '@mui/material/MenuItem'; import Toolbar from '@mui/material/Toolbar'; import Typography from '@mui/material/Typography'; -import { GridRowSelectionModel } from '@mui/x-data-grid'; -import { LoadingGuard } from 'components/loading/LoadingGuard'; -import { SkeletonMap, SkeletonTable } from 'components/loading/SkeletonLoaders'; -import { NoDataOverlay } from 'components/overlay/NoDataOverlay'; -import { - ISamplingSitePeriodRowData, - SamplingPeriodTable -} from 'features/surveys/sampling-information/periods/table/SamplingPeriodTable'; -import { SamplingSiteMapContainer } from 'features/surveys/sampling-information/sites/map/SamplingSiteMapContainer'; -import { SamplingSiteTable } from 'features/surveys/sampling-information/sites/table/SamplingSiteTable'; -import { - SamplingSiteManageTableView, - SamplingSiteTabs -} from 'features/surveys/sampling-information/sites/table/SamplingSiteTabs'; -import { useBiohubApi } from 'hooks/useBioHubApi'; -import { useDialogContext, useSurveyContext } from 'hooks/useContext'; -import { useEffect, useMemo, useState } from 'react'; +import { useSamplingSiteStaticLayer } from 'features/surveys/view/survey-spatial/components/map/useSamplingSiteStaticLayer'; +import SurveyMap from 'features/surveys/view/SurveyMap'; +import { useSurveyContext } from 'hooks/useContext'; import { Link as RouterLink } from 'react-router-dom'; +import { SamplingSiteTableContainer } from './table/SamplingSiteTableContainer'; /** * Component for managing sampling sites, methods, and periods. @@ -37,134 +19,14 @@ import { Link as RouterLink } from 'react-router-dom'; */ const SamplingSiteContainer = () => { const surveyContext = useSurveyContext(); - const dialogContext = useDialogContext(); - const biohubApi = useBiohubApi(); - // State for bulk actions - const [headerAnchorEl, setHeaderAnchorEl] = useState(null); - const [siteSelection, setSiteSelection] = useState([]); - - // Controls whether sites, methods, or periods are shown - const [activeView, setActiveView] = useState(SamplingSiteManageTableView.SITES); - - const sampleSites = useMemo( - () => surveyContext.sampleSiteDataLoader.data?.sampleSites ?? [], - [surveyContext.sampleSiteDataLoader.data?.sampleSites] - ); - const sampleSiteCount = surveyContext.sampleSiteDataLoader.data?.pagination.total ?? 0; - - const samplePeriods: ISamplingSitePeriodRowData[] = useMemo(() => { - const data: ISamplingSitePeriodRowData[] = []; - - for (const site of sampleSites) { - for (const method of site.sample_methods) { - for (const period of method.sample_periods) { - data.push({ - id: period.survey_sample_period_id, - sample_site: site.name, - sample_method: method.technique.name, - method_response_metric_id: method.method_response_metric_id, - start_date: period.start_date, - end_date: period.end_date, - start_time: period.start_time, - end_time: period.end_time - }); - } - } - } - - return data; - }, [sampleSites]); - - useEffect(() => { - surveyContext.sampleSiteDataLoader.load(surveyContext.projectId, surveyContext.surveyId); - }, [surveyContext.sampleSiteDataLoader, surveyContext.projectId, surveyContext.surveyId]); - - // Handler for bulk delete operation - const handleBulkDelete = async () => { - try { - await biohubApi.samplingSite.deleteSampleSites( - surveyContext.projectId, - surveyContext.surveyId, - siteSelection.map((site) => Number(site)) // Convert GridRowId to number[] - ); - dialogContext.setYesNoDialog({ open: false }); // Close confirmation dialog - setSiteSelection([]); // Clear selection - surveyContext.sampleSiteDataLoader.refresh(surveyContext.projectId, surveyContext.surveyId); // Refresh data - } catch (error) { - dialogContext.setYesNoDialog({ open: false }); // Close confirmation dialog on error - setSiteSelection([]); // Clear selection - // Show snackbar with error message - dialogContext.setSnackbar({ - snackbarMessage: ( - <> - - Error Deleting Items - - - {String(error)} - - - ), - open: true - }); - } - }; - - // Handler for clicking on header menu (bulk actions) - const handleHeaderMenuClick = (event: React.MouseEvent) => { - setHeaderAnchorEl(event.currentTarget); - }; - - // Handler for confirming bulk delete operation - const handlePromptConfirmBulkDelete = () => { - setHeaderAnchorEl(null); // Close header menu - dialogContext.setYesNoDialog({ - dialogTitle: 'Delete Sampling Sites?', - dialogContent: ( - - Are you sure you want to delete the selected sampling sites? - - ), - yesButtonLabel: 'Delete Sampling Sites', - noButtonLabel: 'Cancel', - yesButtonProps: { color: 'error' }, - onClose: () => dialogContext.setYesNoDialog({ open: false }), - onNo: () => dialogContext.setYesNoDialog({ open: false }), - open: true, - onYes: handleBulkDelete - }); - }; - - // Counts for the toggle button labels - const viewCounts = { - [SamplingSiteManageTableView.SITES]: sampleSiteCount, - [SamplingSiteManageTableView.PERIODS]: samplePeriods.length - }; + const samplingSiteStaticLayer = useSamplingSiteStaticLayer(); return ( <> - {/* Bulk action menu */} - setHeaderAnchorEl(null)} - anchorEl={headerAnchorEl} - anchorOrigin={{ vertical: 'top', horizontal: 'right' }} - transformOrigin={{ vertical: 'top', horizontal: 'right' }}> - - - - - Delete - - - - Sampling Sites ‌ - - ({sampleSiteCount}) - + Sampling Sites - - - - - - - - - } - isLoadingFallbackDelay={100}> - - - {/* Toggle buttons for changing between sites, methods, and periods */} - - - - - {/* Data tables */} - - {activeView === SamplingSiteManageTableView.SITES && ( - } - isLoadingFallbackDelay={100} - hasNoData={!viewCounts[SamplingSiteManageTableView.SITES]} - hasNoDataFallback={ - - } - hasNoDataFallbackDelay={100}> - - - )} - - {activeView === SamplingSiteManageTableView.PERIODS && ( - } - isLoadingFallbackDelay={100} - hasNoData={!viewCounts[SamplingSiteManageTableView.PERIODS]} - hasNoDataFallback={ - - } - hasNoDataFallbackDelay={100}> - - - )} - - + + + + ); }; diff --git a/app/src/features/surveys/sampling-information/sites/components/map/SamplingSiteMapControl.tsx b/app/src/features/surveys/sampling-information/sites/components/map/SamplingSiteMapControl.tsx index 9085af7c82..04bf652355 100644 --- a/app/src/features/surveys/sampling-information/sites/components/map/SamplingSiteMapControl.tsx +++ b/app/src/features/surveys/sampling-information/sites/components/map/SamplingSiteMapControl.tsx @@ -22,6 +22,8 @@ import SampleSiteFileUploadItemProgressBar from 'features/surveys/sampling-infor import SampleSiteFileUploadItemSubtext from 'features/surveys/sampling-information/sites/components/map/file-upload/SampleSiteFileUploadItemSubtext'; import { FormikContextType } from 'formik'; import { Feature } from 'geojson'; +import { useBiohubApi } from 'hooks/useBioHubApi'; +import useDataLoader from 'hooks/useDataLoader'; import { ICreateSamplingSiteRequest, ISurveySampleSite } from 'interfaces/useSamplingSiteApi.interface'; import { DrawEvents, LatLngBoundsExpression } from 'leaflet'; import 'leaflet-fullscreen/dist/leaflet.fullscreen.css'; @@ -65,6 +67,8 @@ export interface ISamplingSiteMapControlProps { const SamplingSiteMapControl = (props: ISamplingSiteMapControlProps) => { const classes = useStyles(); + const biohubApi = useBiohubApi(); + const surveyContext = useContext(SurveyContext); const [lastDrawn, setLastDrawn] = useState(null); @@ -74,7 +78,15 @@ const SamplingSiteMapControl = (props: ISamplingSiteMapControlProps) => { const { values, errors, setFieldValue, setFieldError } = formikProps; - let numSites = surveyContext.sampleSiteDataLoader.data?.sampleSites.length ?? 0; + const samplingSiteDataLoader = useDataLoader(() => + biohubApi.samplingSite.getSampleSitesGeometry(surveyContext.projectId, surveyContext.surveyId) + ); + + useEffect(() => { + samplingSiteDataLoader.load(); + }, [samplingSiteDataLoader]); + + let numSites = samplingSiteDataLoader.data?.sampleSites.length ?? 0; const [updatedBounds, setUpdatedBounds] = useState(undefined); diff --git a/app/src/features/surveys/sampling-information/sites/create/CreateSamplingSitePage.tsx b/app/src/features/surveys/sampling-information/sites/create/CreateSamplingSitePage.tsx index 454dcf5be8..318deeac1c 100644 --- a/app/src/features/surveys/sampling-information/sites/create/CreateSamplingSitePage.tsx +++ b/app/src/features/surveys/sampling-information/sites/create/CreateSamplingSitePage.tsx @@ -87,9 +87,6 @@ export const CreateSamplingSitePage = () => { await biohubApi.samplingSite.createSamplingSites(surveyContext.projectId, surveyContext.surveyId, data); - // Refresh the context, so the next page loads with the latest data - surveyContext.sampleSiteDataLoader.refresh(surveyContext.projectId, surveyContext.surveyId); - // create complete, navigate back to observations page history.push( `/admin/projects/${surveyContext.projectId}/surveys/${surveyContext.surveyId}/sampling`, diff --git a/app/src/features/surveys/sampling-information/sites/edit/EditSamplingSitePage.tsx b/app/src/features/surveys/sampling-information/sites/edit/EditSamplingSitePage.tsx index f82a081ed7..a1ff977b9e 100644 --- a/app/src/features/surveys/sampling-information/sites/edit/EditSamplingSitePage.tsx +++ b/app/src/features/surveys/sampling-information/sites/edit/EditSamplingSitePage.tsx @@ -128,15 +128,11 @@ export const EditSamplingSitePage = () => { .then(() => { setIsSubmitting(false); - // Refresh the context, so the next page loads with the latest data - surveyContext.sampleSiteDataLoader.refresh(surveyContext.projectId, surveyContext.surveyId); - // create complete, navigate back to observations page history.push( `/admin/projects/${surveyContext.projectId}/surveys/${surveyContext.surveyId}/sampling`, SKIP_CONFIRMATION_DIALOG ); - surveyContext.sampleSiteDataLoader.refresh(surveyContext.projectId, surveyContext.surveyId); }) .catch((error: any) => { dialogContext.setYesNoDialog({ open: false }); diff --git a/app/src/features/surveys/sampling-information/sites/edit/form/SamplingStratumChips.tsx b/app/src/features/surveys/sampling-information/sites/edit/form/SamplingStratumChips.tsx index fe02b40205..71267aa83e 100644 --- a/app/src/features/surveys/sampling-information/sites/edit/form/SamplingStratumChips.tsx +++ b/app/src/features/surveys/sampling-information/sites/edit/form/SamplingStratumChips.tsx @@ -1,12 +1,12 @@ import { blue, cyan, orange, pink, purple, teal } from '@mui/material/colors'; import Stack from '@mui/material/Stack'; import ColouredRectangleChip from 'components/chips/ColouredRectangleChip'; -import { IGetSampleLocationDetails } from 'interfaces/useSamplingSiteApi.interface'; +import { IGetSampleStratumDetails } from 'interfaces/useSamplingSiteApi.interface'; const SAMPLING_SITE_CHIP_COLOURS = [purple, blue, pink, teal, cyan, orange]; interface ISamplingStratumChipsProps { - sampleSite: IGetSampleLocationDetails; + stratums: IGetSampleStratumDetails[]; } /** @@ -18,9 +18,9 @@ interface ISamplingStratumChipsProps { export const SamplingStratumChips = (props: ISamplingStratumChipsProps) => { return ( - {props.sampleSite.stratums.map((stratum, index) => ( + {props.stratums.map((stratum, index) => ( { - const staticLayers: IStaticLayer[] = props.samplingSites.map((sampleSite) => ({ - layerName: 'Sample Sites', - layerOptions: { color: blue[500], fillColor: blue[500] }, - features: [ - { - id: sampleSite.survey_sample_site_id, - key: `sample-site-${sampleSite.survey_sample_site_id}`, - geoJSON: sampleSite.geojson - } - ] - })); - - return ( - - - - ); -}; diff --git a/app/src/features/surveys/sampling-information/sites/table/SamplingSiteTable.tsx b/app/src/features/surveys/sampling-information/sites/table/SamplingSiteTable.tsx index 8d446333a9..1ab7b58234 100644 --- a/app/src/features/surveys/sampling-information/sites/table/SamplingSiteTable.tsx +++ b/app/src/features/surveys/sampling-information/sites/table/SamplingSiteTable.tsx @@ -8,13 +8,12 @@ import ListItemText from '@mui/material/ListItemText'; import Menu, { MenuProps } from '@mui/material/Menu'; import MenuItem from '@mui/material/MenuItem'; import Typography from '@mui/material/Typography'; -import { GridColDef, GridRowSelectionModel } from '@mui/x-data-grid'; +import { GridColDef, GridPaginationModel, GridRowSelectionModel, GridSortModel } from '@mui/x-data-grid'; import ColouredRectangleChip from 'components/chips/ColouredRectangleChip'; import { StyledDataGrid } from 'components/data-grid/StyledDataGrid'; -import { Feature } from 'geojson'; import { useBiohubApi } from 'hooks/useBioHubApi'; import { useDialogContext, useSurveyContext } from 'hooks/useContext'; -import { IGetSampleLocationDetails } from 'interfaces/useSamplingSiteApi.interface'; +import { IGetSampleLocationNonSpatialDetails } from 'interfaces/useSamplingSiteApi.interface'; import { useState } from 'react'; import { Link as RouterLink } from 'react-router-dom'; import { getSamplingSiteSpatialType } from 'utils/spatial-utils'; @@ -23,15 +22,21 @@ export interface ISamplingSiteRowData { id: number; name: string; description: string; - geojson: Feature; + geometry_type: string; blocks: string[]; stratums: string[]; } interface ISamplingSiteTableProps { - sites: IGetSampleLocationDetails[]; + sites: IGetSampleLocationNonSpatialDetails[]; bulkActionSites: GridRowSelectionModel; setBulkActionSites: (selection: GridRowSelectionModel) => void; + paginationModel: GridPaginationModel; + setPaginationModel: React.Dispatch>; + setSortModel: React.Dispatch>; + sortModel: GridSortModel; + pageSizeOptions: number[]; + rowCount: number; } /** @@ -41,7 +46,17 @@ interface ISamplingSiteTableProps { * @returns {*} */ export const SamplingSiteTable = (props: ISamplingSiteTableProps) => { - const { sites, bulkActionSites, setBulkActionSites } = props; + const { + sites, + bulkActionSites, + setBulkActionSites, + paginationModel, + setPaginationModel, + sortModel, + setSortModel, + pageSizeOptions, + rowCount + } = props; const biohubApi = useBiohubApi(); const surveyContext = useSurveyContext(); @@ -60,7 +75,6 @@ export const SamplingSiteTable = (props: ISamplingSiteTableProps) => { .then(() => { dialogContext.setYesNoDialog({ open: false }); setActionMenuAnchorEl(null); - surveyContext.sampleSiteDataLoader.refresh(surveyContext.projectId, surveyContext.surveyId); }) .catch((error: any) => { dialogContext.setYesNoDialog({ open: false }); @@ -108,8 +122,8 @@ export const SamplingSiteTable = (props: ISamplingSiteTableProps) => { const rows: ISamplingSiteRowData[] = sites.map((site) => ({ id: site.survey_sample_site_id, name: site.name, + geometry_type: site.geometry_type, description: site.description || '', - geojson: site.geojson, blocks: site.blocks.map((block) => block.name), stratums: site.stratums.map((stratum) => stratum.name) })); @@ -127,7 +141,7 @@ export const SamplingSiteTable = (props: ISamplingSiteTableProps) => { renderCell: (params) => ( @@ -235,7 +249,7 @@ export const SamplingSiteTable = (props: ISamplingSiteTableProps) => { {/* DATA TABLE */} 'auto'} disableColumnMenu rows={rows} @@ -244,12 +258,19 @@ export const SamplingSiteTable = (props: ISamplingSiteTableProps) => { rowSelectionModel={bulkActionSites} onRowSelectionModelChange={setBulkActionSites} checkboxSelection + rowCount={rowCount} + paginationMode="server" + sortingMode="server" + sortModel={sortModel} + paginationModel={paginationModel} + onPaginationModelChange={setPaginationModel} + onSortModelChange={setSortModel} initialState={{ pagination: { - paginationModel: { page: 1, pageSize: 10 } + paginationModel } }} - pageSizeOptions={[10, 25, 50]} + pageSizeOptions={pageSizeOptions} /> ); diff --git a/app/src/features/surveys/sampling-information/sites/table/SamplingSiteTableContainer.tsx b/app/src/features/surveys/sampling-information/sites/table/SamplingSiteTableContainer.tsx new file mode 100644 index 0000000000..f9a3e817d9 --- /dev/null +++ b/app/src/features/surveys/sampling-information/sites/table/SamplingSiteTableContainer.tsx @@ -0,0 +1,249 @@ +import { mdiArrowTopRight, mdiDotsVertical, mdiTrashCanOutline } from '@mdi/js'; +import Icon from '@mdi/react'; +import Box from '@mui/material/Box'; +import Divider from '@mui/material/Divider'; +import IconButton from '@mui/material/IconButton'; +import ListItemIcon from '@mui/material/ListItemIcon'; +import ListItemText from '@mui/material/ListItemText'; +import Menu from '@mui/material/Menu'; +import MenuItem from '@mui/material/MenuItem'; +import Toolbar from '@mui/material/Toolbar'; +import Typography from '@mui/material/Typography'; +import { GridPaginationModel, GridRowSelectionModel, GridSortModel } from '@mui/x-data-grid'; +import { LoadingGuard } from 'components/loading/LoadingGuard'; +import { SkeletonTable } from 'components/loading/SkeletonLoaders'; +import { NoDataOverlay } from 'components/overlay/NoDataOverlay'; +import { useBiohubApi } from 'hooks/useBioHubApi'; +import { useDialogContext, useSurveyContext } from 'hooks/useContext'; +import useDataLoader from 'hooks/useDataLoader'; +import { useEffect, useMemo, useState } from 'react'; +import { ApiPaginationRequestOptions } from 'types/misc'; +import { firstOrNull } from 'utils/Utils'; +import { ISamplingSitePeriodRowData, SamplingPeriodTable } from '../../periods/table/SamplingPeriodTable'; +import { SamplingSiteTable } from './SamplingSiteTable'; +import { SamplingSiteManageTableView, SamplingSiteTableView } from './view/SamplingSiteTableView'; + +const pageSizeOptions = [10, 25, 50]; + +/** + * Returns a table of sampling sites with edit actions + * + * @returns {*} + */ +export const SamplingSiteTableContainer = () => { + const biohubApi = useBiohubApi(); + const surveyContext = useSurveyContext(); + const dialogContext = useDialogContext(); + + const [headerAnchorEl, setHeaderAnchorEl] = useState(null); + const [siteSelection, setSiteSelection] = useState([]); + + // Controls whether sites, methods, or periods are shown + const [activeView, setActiveView] = useState(SamplingSiteManageTableView.SITES); + + const [paginationModel, setPaginationModel] = useState({ + page: 0, + pageSize: pageSizeOptions[0] + }); + const [sortModel, setSortModel] = useState([]); + + const samplingSitesDataLoader = useDataLoader((pagination: ApiPaginationRequestOptions) => + biohubApi.samplingSite.getSampleSites(surveyContext.projectId, surveyContext.surveyId, { pagination }) + ); + + const pagination: ApiPaginationRequestOptions = useMemo(() => { + const sort = firstOrNull(sortModel); + + return { + limit: paginationModel.pageSize, + sort: sort?.field || undefined, + order: sort?.sort || undefined, + + // API pagination pages begin at 1, but MUI DataGrid pagination begins at 0. + page: paginationModel.page + 1 + }; + }, [sortModel, paginationModel]); + + // Refresh survey list when pagination or sort changes + useEffect(() => { + samplingSitesDataLoader.refresh(pagination); + + // Adding a DataLoader as a dependency causes an infinite rerender loop if a useEffect calls `.refresh` + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [pagination]); + + const sampleSites = useMemo(() => samplingSitesDataLoader.data?.sampleSites ?? [], [samplingSitesDataLoader.data]); + + const samplePeriods: ISamplingSitePeriodRowData[] = useMemo(() => { + const data: ISamplingSitePeriodRowData[] = []; + + for (const site of sampleSites) { + for (const method of site.sample_methods) { + for (const period of method.sample_periods) { + data.push({ + id: period.survey_sample_period_id, + sample_site: site.name, + sample_method: method.technique.name, + method_response_metric_id: method.method_response_metric_id, + start_date: period.start_date, + end_date: period.end_date, + start_time: period.start_time, + end_time: period.end_time + }); + } + } + } + + return data; + }, [sampleSites]); + + // Handler for bulk delete operation + const handleBulkDelete = async () => { + try { + await biohubApi.samplingSite.deleteSampleSites( + surveyContext.projectId, + surveyContext.surveyId, + siteSelection.map((site) => Number(site)) // Convert GridRowId to number[] + ); + dialogContext.setYesNoDialog({ open: false }); // Close confirmation dialog + setSiteSelection([]); // Clear selection + samplingSitesDataLoader.refresh(pagination); // Refresh data + } catch (error) { + dialogContext.setYesNoDialog({ open: false }); // Close confirmation dialog on error + setSiteSelection([]); // Clear selection + // Show snackbar with error message + dialogContext.setSnackbar({ + snackbarMessage: ( + <> + + Error Deleting Items + + + {String(error)} + + + ), + open: true + }); + } + }; + + // Handler for clicking on header menu (bulk actions) + const handleHeaderMenuClick = (event: React.MouseEvent) => { + setHeaderAnchorEl(event.currentTarget); + }; + + // Handler for confirming bulk delete operation + const handlePromptConfirmBulkDelete = () => { + setHeaderAnchorEl(null); // Close header menu + dialogContext.setYesNoDialog({ + dialogTitle: 'Delete Sampling Sites?', + dialogContent: ( + + Are you sure you want to delete the selected sampling sites? + + ), + yesButtonLabel: 'Delete Sampling Sites', + noButtonLabel: 'Cancel', + yesButtonProps: { color: 'error' }, + onClose: () => dialogContext.setYesNoDialog({ open: false }), + onNo: () => dialogContext.setYesNoDialog({ open: false }), + open: true, + onYes: handleBulkDelete + }); + }; + + return ( + <> + {/* Bulk action menu */} + setHeaderAnchorEl(null)} + anchorEl={headerAnchorEl} + anchorOrigin={{ vertical: 'top', horizontal: 'right' }} + transformOrigin={{ vertical: 'top', horizontal: 'right' }}> + + + + + Delete + + + + + {/* Toggle buttons for changing between sites, methods, and periods */} + + + + + + + + + + {/* Data tables */} + + {activeView === SamplingSiteManageTableView.SITES && ( + } + isLoadingFallbackDelay={100} + hasNoData={!sampleSites.length} + hasNoDataFallback={ + + } + hasNoDataFallbackDelay={100}> + + + )} + + {activeView === SamplingSiteManageTableView.PERIODS && ( + } + isLoadingFallbackDelay={100} + hasNoData={!samplePeriods.length} + hasNoDataFallback={ + + } + hasNoDataFallbackDelay={100}> + + + )} + + + ); +}; diff --git a/app/src/features/surveys/sampling-information/sites/table/SamplingSiteTabs.tsx b/app/src/features/surveys/sampling-information/sites/table/view/SamplingSiteTableView.tsx similarity index 54% rename from app/src/features/surveys/sampling-information/sites/table/SamplingSiteTabs.tsx rename to app/src/features/surveys/sampling-information/sites/table/view/SamplingSiteTableView.tsx index f7060fb206..7f008713b9 100644 --- a/app/src/features/surveys/sampling-information/sites/table/SamplingSiteTabs.tsx +++ b/app/src/features/surveys/sampling-information/sites/table/view/SamplingSiteTableView.tsx @@ -3,7 +3,6 @@ import { Icon } from '@mdi/react'; import Button from '@mui/material/Button'; import ToggleButton from '@mui/material/ToggleButton'; import ToggleButtonGroup from '@mui/material/ToggleButtonGroup'; -import Toolbar from '@mui/material/Toolbar'; import { SetStateAction } from 'react'; export enum SamplingSiteManageTableView { @@ -18,28 +17,25 @@ interface ISamplingSiteManageTableView { export type ISamplingSiteCount = Record; -interface ISamplingSiteTabsProps { +interface ISamplingSiteTableViewProps { activeView: SamplingSiteManageTableView; setActiveView: React.Dispatch>; - viewCounts: Record; } /** * Renders tab controls for the sampling site table, which allow the user to switch between viewing sites and periods. * - * @param {ISamplingSiteTabsProps} props + * @param {ISamplingSiteTableViewProps} props * @return {*} */ -export const SamplingSiteTabs = (props: ISamplingSiteTabsProps) => { - const { activeView, setActiveView, viewCounts } = props; +export const SamplingSiteTableView = (props: ISamplingSiteTableViewProps) => { + const { activeView, setActiveView } = props; const views: ISamplingSiteManageTableView[] = [ { value: SamplingSiteManageTableView.SITES, icon: }, { value: SamplingSiteManageTableView.PERIODS, icon: } ]; - const activeViewCount = viewCounts[activeView]; - const updateDatasetView = (_: React.MouseEvent, view: SamplingSiteManageTableView) => { if (view) { setActiveView(view); @@ -47,30 +43,29 @@ export const SamplingSiteTabs = (props: ISamplingSiteTabsProps) => { }; return ( - - - {views.map((view) => ( - - {view.value} ({activeViewCount}) - - ))} - - + + {views.map((view) => ( + + {view.value} + + ))} + ); }; diff --git a/app/src/features/surveys/telemetry/TelemetryHeader.tsx b/app/src/features/surveys/telemetry/TelemetryHeader.tsx index 85fb278961..9db58ca6a0 100644 --- a/app/src/features/surveys/telemetry/TelemetryHeader.tsx +++ b/app/src/features/surveys/telemetry/TelemetryHeader.tsx @@ -1,9 +1,9 @@ +import { mdiEye, mdiPaw } from '@mdi/js'; import Breadcrumbs from '@mui/material/Breadcrumbs'; import Link from '@mui/material/Link'; -import Typography from '@mui/material/Typography'; +import { BreadcrumbNavButton } from 'components/buttons/BreadcrumbNavButton'; import PageHeader from 'components/layout/PageHeader'; import { Link as RouterLink } from 'react-router-dom'; - export interface TelemetryHeaderProps { project_id: number; project_name: string; @@ -13,6 +13,20 @@ export interface TelemetryHeaderProps { export const TelemetryHeader = (props: TelemetryHeaderProps) => { const { project_id, project_name, survey_id, survey_name } = props; + + const menuItems = [ + { + label: 'Animals', + to: `/admin/projects/${project_id}/surveys/${survey_id}/animals`, + icon: mdiPaw + }, + { + label: 'Observations', + to: `/admin/projects/${project_id}/surveys/${survey_id}/observations`, + icon: mdiEye + } + ]; + return ( { to={`/admin/projects/${project_id}/surveys/${survey_id}/details`}> {survey_name} - - Manage Telemetry - + Telemetry } /> diff --git a/app/src/features/surveys/view/SurveyDetails.test.tsx b/app/src/features/surveys/view/SurveyDetails.test.tsx index 7c6044d245..519f0b42df 100644 --- a/app/src/features/surveys/view/SurveyDetails.test.tsx +++ b/app/src/features/surveys/view/SurveyDetails.test.tsx @@ -51,7 +51,6 @@ describe('SurveyDetails', () => { } } as DataLoader; const mockArtifactDataLoader = { data: null } as DataLoader; - const mockSampleSiteDataLoader = { data: null } as DataLoader; const mockCritterDataLoader = { data: [] } as DataLoader; const mockTechniqueDataLoader = { data: [] } as DataLoader; @@ -64,7 +63,7 @@ describe('SurveyDetails', () => { surveyId: 1, surveyDataLoader: mockSurveyDataLoader, artifactDataLoader: mockArtifactDataLoader, - sampleSiteDataLoader: mockSampleSiteDataLoader, + techniqueDataLoader: mockTechniqueDataLoader, critterDataLoader: mockCritterDataLoader }}> diff --git a/app/src/features/surveys/view/SurveyHeader.test.tsx b/app/src/features/surveys/view/SurveyHeader.test.tsx index a1c685afab..76eecd025e 100644 --- a/app/src/features/surveys/view/SurveyHeader.test.tsx +++ b/app/src/features/surveys/view/SurveyHeader.test.tsx @@ -44,9 +44,6 @@ const mockSurveyContext: ISurveyContext = { artifactDataLoader: { data: null } as DataLoader, - sampleSiteDataLoader: { - data: null - } as DataLoader, critterDataLoader: { data: null } as DataLoader, diff --git a/app/src/features/surveys/view/SurveyPage.tsx b/app/src/features/surveys/view/SurveyPage.tsx index a1e7c05aac..be139300f0 100644 --- a/app/src/features/surveys/view/SurveyPage.tsx +++ b/app/src/features/surveys/view/SurveyPage.tsx @@ -7,7 +7,7 @@ import { SurveyContext } from 'contexts/surveyContext'; import { TaxonomyContextProvider } from 'contexts/taxonomyContext'; import SurveyDetails from 'features/surveys/view/SurveyDetails'; import React, { useContext, useEffect } from 'react'; -import { SurveySamplingContainer } from './components/sampling-data/SurveySamplingContainer'; +import { SurveySamplingTableContainer } from './components/sampling-data/SurveySamplingTableContainer'; import SurveyStudyArea from './components/SurveyStudyArea'; import { SurveySpatialContainer } from './survey-spatial/SurveySpatialContainer'; import SurveyAttachments from './SurveyAttachments'; @@ -36,7 +36,7 @@ const SurveyPage: React.FC = () => { - + diff --git a/app/src/features/surveys/view/SurveySampleSiteMapPopup.tsx b/app/src/features/surveys/view/SurveySampleSiteMapPopup.tsx new file mode 100644 index 0000000000..827ac259fa --- /dev/null +++ b/app/src/features/surveys/view/SurveySampleSiteMapPopup.tsx @@ -0,0 +1,42 @@ +import { SurveyMapPopup } from 'features/surveys/view/SurveyMapPopup'; +import { useBiohubApi } from 'hooks/useBioHubApi'; +import { useSurveyContext } from 'hooks/useContext'; +import useDataLoader from 'hooks/useDataLoader'; +import { useEffect } from 'react'; + +interface ISurveySampleSiteMapPopupProps { + surveySampleSiteId: number; +} + +export const SurveySampleSiteMapPopup = (props: ISurveySampleSiteMapPopupProps) => { + const { surveySampleSiteId } = props; + const { surveyId, projectId } = useSurveyContext(); + + const biohubApi = useBiohubApi(); + + const surveyDataLoader = useDataLoader(() => + biohubApi.samplingSite.getSampleSiteById(projectId, surveyId, surveySampleSiteId) + ); + + useEffect(() => { + surveyDataLoader.load(); + }, [surveyDataLoader]); + + const sampleSite = surveyDataLoader.data; + + const metadata = sampleSite + ? [ + { label: 'Name', value: sampleSite.name }, + { label: 'Description', value: sampleSite.description } + ] + : []; + + return ( + + ); +}; diff --git a/app/src/features/surveys/view/components/SurveyGeneralInformation.test.tsx b/app/src/features/surveys/view/components/SurveyGeneralInformation.test.tsx index 1bb03a26b5..2c8b009931 100644 --- a/app/src/features/surveys/view/components/SurveyGeneralInformation.test.tsx +++ b/app/src/features/surveys/view/components/SurveyGeneralInformation.test.tsx @@ -22,7 +22,6 @@ describe('SurveyGeneralInformation', () => { it('renders correctly with end date', () => { const mockSurveyDataLoader = { data: getSurveyForViewResponse } as DataLoader; const mockArtifactDataLoader = { data: null } as DataLoader; - const mockSampleSiteDataLoader = { data: null } as DataLoader; const mockTechniqueDataLoader = { data: [] } as DataLoader; const mockCritterDataLoader = { data: [] } as DataLoader; @@ -34,7 +33,6 @@ describe('SurveyGeneralInformation', () => { surveyId: 1, surveyDataLoader: mockSurveyDataLoader, artifactDataLoader: mockArtifactDataLoader, - sampleSiteDataLoader: mockSampleSiteDataLoader, techniqueDataLoader: mockTechniqueDataLoader, critterDataLoader: mockCritterDataLoader }}> @@ -60,7 +58,6 @@ describe('SurveyGeneralInformation', () => { } } as DataLoader; const mockArtifactDataLoader = { data: null } as DataLoader; - const mockSampleSiteDataLoader = { data: null } as DataLoader; const mockTechniqueDataLoader = { data: [] } as DataLoader; const mockCritterDataLoader = { data: [] } as DataLoader; @@ -72,7 +69,6 @@ describe('SurveyGeneralInformation', () => { surveyId: 1, surveyDataLoader: mockSurveyDataLoader, artifactDataLoader: mockArtifactDataLoader, - sampleSiteDataLoader: mockSampleSiteDataLoader, critterDataLoader: mockCritterDataLoader, techniqueDataLoader: mockTechniqueDataLoader }}> @@ -87,7 +83,6 @@ describe('SurveyGeneralInformation', () => { it('renders an empty fragment if survey data has not loaded or is undefined', () => { const mockSurveyDataLoader = { data: undefined } as DataLoader; const mockArtifactDataLoader = { data: null } as DataLoader; - const mockSampleSiteDataLoader = { data: null } as DataLoader; const mockTechniqueDataLoader = { data: [] } as DataLoader; const mockCritterDataLoader = { data: [] } as DataLoader; @@ -99,7 +94,6 @@ describe('SurveyGeneralInformation', () => { surveyId: 1, surveyDataLoader: mockSurveyDataLoader, artifactDataLoader: mockArtifactDataLoader, - sampleSiteDataLoader: mockSampleSiteDataLoader, techniqueDataLoader: mockTechniqueDataLoader, critterDataLoader: mockCritterDataLoader }}> diff --git a/app/src/features/surveys/view/components/SurveyProprietaryData.test.tsx b/app/src/features/surveys/view/components/SurveyProprietaryData.test.tsx index ef52862732..1257227555 100644 --- a/app/src/features/surveys/view/components/SurveyProprietaryData.test.tsx +++ b/app/src/features/surveys/view/components/SurveyProprietaryData.test.tsx @@ -13,7 +13,6 @@ describe('SurveyProprietaryData', () => { it('renders correctly with proprietor data', () => { const mockSurveyDataLoader = { data: getSurveyForViewResponse } as DataLoader; const mockArtifactDataLoader = { data: null } as DataLoader; - const mockSampleSiteDataLoader = { data: null } as DataLoader; const mockCritterDataLoader = { data: [] } as DataLoader; const mockTechniqueDataLoader = { data: [] } as DataLoader; @@ -25,7 +24,6 @@ describe('SurveyProprietaryData', () => { surveyDataLoader: mockSurveyDataLoader, artifactDataLoader: mockArtifactDataLoader, techniqueDataLoader: mockTechniqueDataLoader, - sampleSiteDataLoader: mockSampleSiteDataLoader, critterDataLoader: mockCritterDataLoader }}> @@ -42,7 +40,6 @@ describe('SurveyProprietaryData', () => { data: { ...getSurveyForViewResponse, surveyData: { ...getSurveyForViewResponse.surveyData, proprietor: null } } } as DataLoader; const mockArtifactDataLoader = { data: null } as DataLoader; - const mockSampleSiteDataLoader = { data: null } as DataLoader; const mockCritterDataLoader = { data: [] } as DataLoader; const mockTechniqueDataLoader = { data: [] } as DataLoader; @@ -54,7 +51,6 @@ describe('SurveyProprietaryData', () => { surveyDataLoader: mockSurveyDataLoader, artifactDataLoader: mockArtifactDataLoader, techniqueDataLoader: mockTechniqueDataLoader, - sampleSiteDataLoader: mockSampleSiteDataLoader, critterDataLoader: mockCritterDataLoader }}> @@ -69,7 +65,6 @@ describe('SurveyProprietaryData', () => { it('renders an empty fragment if survey data has not loaded or is undefined', () => { const mockSurveyDataLoader = { data: undefined } as DataLoader; const mockArtifactDataLoader = { data: null } as DataLoader; - const mockSampleSiteDataLoader = { data: null } as DataLoader; const mockCritterDataLoader = { data: [] } as DataLoader; const mockTechniqueDataLoader = { data: [] } as DataLoader; @@ -80,7 +75,6 @@ describe('SurveyProprietaryData', () => { surveyId: 1, surveyDataLoader: mockSurveyDataLoader, artifactDataLoader: mockArtifactDataLoader, - sampleSiteDataLoader: mockSampleSiteDataLoader, critterDataLoader: mockCritterDataLoader, techniqueDataLoader: mockTechniqueDataLoader }}> diff --git a/app/src/features/surveys/view/components/SurveyPurposeAndMethodologyData.test.tsx b/app/src/features/surveys/view/components/SurveyPurposeAndMethodologyData.test.tsx index 3390775e55..fc26517788 100644 --- a/app/src/features/surveys/view/components/SurveyPurposeAndMethodologyData.test.tsx +++ b/app/src/features/surveys/view/components/SurveyPurposeAndMethodologyData.test.tsx @@ -21,7 +21,6 @@ describe('SurveyPurposeAndMethodologyData', () => { const mockSurveyDataLoader = { data: getSurveyForViewResponse } as DataLoader; const mockArtifactDataLoader = { data: null } as DataLoader; - const mockSampleSiteDataLoader = { data: null } as DataLoader; const mockCritterDataLoader = { data: [] } as DataLoader; const mockTechniqueDataLoader = { data: [] } as DataLoader; @@ -33,7 +32,6 @@ describe('SurveyPurposeAndMethodologyData', () => { surveyId: 1, surveyDataLoader: mockSurveyDataLoader, artifactDataLoader: mockArtifactDataLoader, - sampleSiteDataLoader: mockSampleSiteDataLoader, critterDataLoader: mockCritterDataLoader, techniqueDataLoader: mockTechniqueDataLoader }}> @@ -66,7 +64,6 @@ describe('SurveyPurposeAndMethodologyData', () => { } } as DataLoader; const mockArtifactDataLoader = { data: null } as DataLoader; - const mockSampleSiteDataLoader = { data: null } as DataLoader; const mockCritterDataLoader = { data: [] } as DataLoader; const mockTechniqueDataLoader = { data: [] } as DataLoader; @@ -78,7 +75,6 @@ describe('SurveyPurposeAndMethodologyData', () => { surveyId: 1, surveyDataLoader: mockSurveyDataLoader, artifactDataLoader: mockArtifactDataLoader, - sampleSiteDataLoader: mockSampleSiteDataLoader, critterDataLoader: mockCritterDataLoader, techniqueDataLoader: mockTechniqueDataLoader }}> diff --git a/app/src/features/surveys/view/components/SurveyStudyArea.test.tsx b/app/src/features/surveys/view/components/SurveyStudyArea.test.tsx index 47e7ca53e1..ac5e1f0d18 100644 --- a/app/src/features/surveys/view/components/SurveyStudyArea.test.tsx +++ b/app/src/features/surveys/view/components/SurveyStudyArea.test.tsx @@ -42,7 +42,6 @@ describe.skip('SurveyStudyArea', () => { it('renders correctly with no data', async () => { const mockSurveyDataLoader = { data: getSurveyForViewResponse } as DataLoader; const mockArtifactDataLoader = { data: null } as DataLoader; - const mockSampleSiteDataLoader = { data: null } as DataLoader; const mockCritterDataLoader = { data: [] } as DataLoader; const mockTechniqueDataLoader = { data: [] } as DataLoader; @@ -54,7 +53,7 @@ describe.skip('SurveyStudyArea', () => { surveyDataLoader: mockSurveyDataLoader, artifactDataLoader: mockArtifactDataLoader, techniqueDataLoader: mockTechniqueDataLoader, - sampleSiteDataLoader: mockSampleSiteDataLoader, + critterDataLoader: mockCritterDataLoader }}> @@ -78,7 +77,6 @@ describe.skip('SurveyStudyArea', () => { } } as DataLoader; const mockArtifactDataLoader = { data: null } as DataLoader; - const mockSampleSiteDataLoader = { data: null } as DataLoader; const mockCritterDataLoader = { data: [] } as DataLoader; const mockTechniqueDataLoader = { data: [] } as DataLoader; @@ -89,7 +87,7 @@ describe.skip('SurveyStudyArea', () => { surveyId: 1, surveyDataLoader: mockSurveyDataLoader, artifactDataLoader: mockArtifactDataLoader, - sampleSiteDataLoader: mockSampleSiteDataLoader, + techniqueDataLoader: mockTechniqueDataLoader, critterDataLoader: mockCritterDataLoader }}> @@ -106,7 +104,6 @@ describe.skip('SurveyStudyArea', () => { it('is rendered if there are geometries on the map', async () => { const mockSurveyDataLoader = { data: getSurveyForViewResponse } as DataLoader; const mockArtifactDataLoader = { data: null } as DataLoader; - const mockSampleSiteDataLoader = { data: null } as DataLoader; const mockCritterDataLoader = { data: [] } as DataLoader; const mockTechniqueDataLoader = { data: [] } as DataLoader; @@ -117,7 +114,7 @@ describe.skip('SurveyStudyArea', () => { surveyId: 1, surveyDataLoader: mockSurveyDataLoader, artifactDataLoader: mockArtifactDataLoader, - sampleSiteDataLoader: mockSampleSiteDataLoader, + critterDataLoader: mockCritterDataLoader, techniqueDataLoader: mockTechniqueDataLoader }}> @@ -138,7 +135,6 @@ describe.skip('SurveyStudyArea', () => { refresh: jest.fn() as unknown as any } as DataLoader; const mockArtifactDataLoader = { data: null } as DataLoader; - const mockSampleSiteDataLoader = { data: null } as DataLoader; const mockCritterDataLoader = { data: [] } as DataLoader; const mockTechniqueDataLoader = { data: [] } as DataLoader; @@ -159,7 +155,7 @@ describe.skip('SurveyStudyArea', () => { surveyId: 1, surveyDataLoader: mockSurveyDataLoader, artifactDataLoader: mockArtifactDataLoader, - sampleSiteDataLoader: mockSampleSiteDataLoader, + critterDataLoader: mockCritterDataLoader, techniqueDataLoader: mockTechniqueDataLoader }}> @@ -230,7 +226,6 @@ describe.skip('SurveyStudyArea', () => { it('shows error dialog with API error message when updating survey data fails', async () => { const mockSurveyDataLoader = { data: getSurveyForViewResponse } as DataLoader; const mockArtifactDataLoader = { data: null } as DataLoader; - const mockSampleSiteDataLoader = { data: null } as DataLoader; const mockCritterDataLoader = { data: [] } as DataLoader; const mockTechniqueDataLoader = { data: [] } as DataLoader; @@ -269,7 +264,7 @@ describe.skip('SurveyStudyArea', () => { surveyId: 1, surveyDataLoader: mockSurveyDataLoader, artifactDataLoader: mockArtifactDataLoader, - sampleSiteDataLoader: mockSampleSiteDataLoader, + critterDataLoader: mockCritterDataLoader, techniqueDataLoader: mockTechniqueDataLoader }}> diff --git a/app/src/features/surveys/view/components/analytics/components/ObservationAnalyticsDataTableContainer.tsx b/app/src/features/surveys/view/components/analytics/components/ObservationAnalyticsDataTableContainer.tsx index b5e09248f1..e071720b65 100644 --- a/app/src/features/surveys/view/components/analytics/components/ObservationAnalyticsDataTableContainer.tsx +++ b/app/src/features/surveys/view/components/analytics/components/ObservationAnalyticsDataTableContainer.tsx @@ -5,6 +5,7 @@ import { useBiohubApi } from 'hooks/useBioHubApi'; import { useSurveyContext, useTaxonomyContext } from 'hooks/useContext'; import useDataLoader from 'hooks/useDataLoader'; import { IObservationCountByGroup } from 'interfaces/useAnalyticsApi.interface'; +import { IGetSampleLocationNonSpatialDetails } from 'interfaces/useSamplingSiteApi.interface'; import { useEffect, useMemo } from 'react'; import { getBasicGroupByColDefs, @@ -96,10 +97,9 @@ export const ObservationAnalyticsDataTableContainer = (props: IObservationAnalyt [analyticsDataLoader?.data] ); - const sampleSites = useMemo( - () => surveyContext.sampleSiteDataLoader.data?.sampleSites ?? [], - [surveyContext.sampleSiteDataLoader.data?.sampleSites] - ); + // TODO: Include sampling information in the analytics response / otherwise get sampling information, + // which is now more complicated because sample sites are paginated. + const sampleSites: IGetSampleLocationNonSpatialDetails[] = []; const allGroupByColumns = useMemo( () => [...groupByColumns, ...groupByQualitativeMeasurements, ...groupByQuantitativeMeasurements], diff --git a/app/src/features/surveys/view/components/analytics/components/ObservationsAnalyticsGridColumnDefinitions.tsx b/app/src/features/surveys/view/components/analytics/components/ObservationsAnalyticsGridColumnDefinitions.tsx index b24ef04e98..7d7fdc9a92 100644 --- a/app/src/features/surveys/view/components/analytics/components/ObservationsAnalyticsGridColumnDefinitions.tsx +++ b/app/src/features/surveys/view/components/analytics/components/ObservationsAnalyticsGridColumnDefinitions.tsx @@ -6,7 +6,7 @@ import dayjs from 'dayjs'; import { ScientificNameTypography } from 'features/surveys/animals/components/ScientificNameTypography'; import { IObservationAnalyticsRow } from 'features/surveys/view/components/analytics/components/ObservationAnalyticsDataTableContainer'; import { IGroupByOption } from 'features/surveys/view/components/analytics/SurveyObservationAnalytics'; -import { IGetSampleLocationDetails } from 'interfaces/useSamplingSiteApi.interface'; +import { IGetSampleLocationNonSpatialDetails } from 'interfaces/useSamplingSiteApi.interface'; import { IPartialTaxonomy } from 'interfaces/useTaxonomyApi.interface'; import isEqual from 'lodash-es/isEqual'; @@ -91,11 +91,11 @@ export const getSpeciesColDef = ( /** * Get the column definition for the sampling site. * - * @param {IGetSampleLocationDetails[]} sampleSites + * @param {IGetSampleLocationNonSpatialDetails[]} sampleSites * @return {*} {GridColDef} */ export const getSamplingSiteColDef = ( - sampleSites: IGetSampleLocationDetails[] + sampleSites: IGetSampleLocationNonSpatialDetails[] ): GridColDef => ({ headerAlign: 'left', align: 'left', @@ -120,11 +120,11 @@ export const getSamplingSiteColDef = ( /** * Get the column definition for the sampling method. * - * @param {IGetSampleLocationDetails[]} sampleSites + * @param {IGetSampleLocationNonSpatialDetails[]} sampleSites * @return {*} {GridColDef} */ export const getSamplingMethodColDef = ( - sampleSites: IGetSampleLocationDetails[] + sampleSites: IGetSampleLocationNonSpatialDetails[] ): GridColDef => ({ headerAlign: 'left', align: 'left', @@ -151,11 +151,11 @@ export const getSamplingMethodColDef = ( /** * Get the column definition for the sampling period. * - * @param {IGetSampleLocationDetails[]} sampleSites + * @param {IGetSampleLocationNonSpatialDetails[]} sampleSites * @return {*} {GridColDef} */ export const getSamplingPeriodColDef = ( - sampleSites: IGetSampleLocationDetails[] + sampleSites: IGetSampleLocationNonSpatialDetails[] ): GridColDef => ({ headerAlign: 'left', align: 'left', diff --git a/app/src/features/surveys/view/components/data-container/SurveyObservationTabularDataContainer.tsx b/app/src/features/surveys/view/components/data-container/SurveyObservationTabularDataContainer.tsx index 34ed569e19..e2ac8bd3d4 100644 --- a/app/src/features/surveys/view/components/data-container/SurveyObservationTabularDataContainer.tsx +++ b/app/src/features/surveys/view/components/data-container/SurveyObservationTabularDataContainer.tsx @@ -14,13 +14,7 @@ export enum SurveyObservationTabularDataContainerViewEnum { ANALYTICS = 'ANALYTICS' } -interface ISurveyObservationTabularDataContainerProps { - isLoading: boolean; -} - -const SurveyObservationTabularDataContainer = (props: ISurveyObservationTabularDataContainerProps) => { - const { isLoading } = props; - +const SurveyObservationTabularDataContainer = () => { const [activeDataView, setActiveDataView] = useState( SurveyObservationTabularDataContainerViewEnum.COUNTS ); @@ -32,7 +26,7 @@ const SurveyObservationTabularDataContainer = (props: ISurveyObservationTabularD return ( <> - + { @@ -71,9 +65,7 @@ const SurveyObservationTabularDataContainer = (props: ISurveyObservationTabularD - {activeDataView === SurveyObservationTabularDataContainerViewEnum.COUNTS && ( - - )} + {activeDataView === SurveyObservationTabularDataContainerViewEnum.COUNTS && } {activeDataView === SurveyObservationTabularDataContainerViewEnum.ANALYTICS && } diff --git a/app/src/features/surveys/view/components/sampling-data/SurveySamplingContainer.tsx b/app/src/features/surveys/view/components/sampling-data/SurveySamplingContainer.tsx deleted file mode 100644 index 7d37ea1279..0000000000 --- a/app/src/features/surveys/view/components/sampling-data/SurveySamplingContainer.tsx +++ /dev/null @@ -1,21 +0,0 @@ -import Box from '@mui/material/Box'; -import Divider from '@mui/material/Divider'; -import { SurveySamplingTabs } from 'features/surveys/view/components/sampling-data/components/SurveySamplingTabs'; -import { SurveySamplingHeader } from './components/SurveySamplingHeader'; - -export const SurveySamplingContainer = () => { - return ( - - - - - - - - ); -}; diff --git a/app/src/features/surveys/view/components/sampling-data/SurveySamplingTableContainer.tsx b/app/src/features/surveys/view/components/sampling-data/SurveySamplingTableContainer.tsx new file mode 100644 index 0000000000..10a941e75a --- /dev/null +++ b/app/src/features/surveys/view/components/sampling-data/SurveySamplingTableContainer.tsx @@ -0,0 +1,181 @@ +import Box from '@mui/material/Box'; +import Divider from '@mui/material/Divider'; +import { GridPaginationModel, GridSortModel } from '@mui/x-data-grid'; +import { LoadingGuard } from 'components/loading/LoadingGuard'; +import { SkeletonTable } from 'components/loading/SkeletonLoaders'; +import { NoDataOverlay } from 'components/overlay/NoDataOverlay'; +import { + ISamplingSitePeriodRowData, + SamplingPeriodTable +} from 'features/surveys/sampling-information/periods/table/SamplingPeriodTable'; +import { useBiohubApi } from 'hooks/useBioHubApi'; +import { useSurveyContext } from 'hooks/useContext'; +import useDataLoader from 'hooks/useDataLoader'; +import { useEffect, useMemo, useState } from 'react'; +import { ApiPaginationRequestOptions } from 'types/misc'; +import { firstOrNull } from 'utils/Utils'; +import { SurveySitesTable } from './components/site/SurveySitesTable'; +import { SurveySamplingHeader } from './components/SurveySamplingHeader'; +import { SurveyTechniquesTable } from './components/technique/SurveyTechniquesTable'; +import { SurveySamplingViewTabs } from './components/view/SurveySamplingViewTabs'; + +const pageSizeOptions = [10, 25, 50]; + +export enum SurveySamplingView { + TECHNIQUES = 'TECHNIQUES', + SITES = 'SITES', + PERIODS = 'PERIODS' +} + +export const SurveySamplingTableContainer = () => { + const surveyContext = useSurveyContext(); + const biohubApi = useBiohubApi(); + + const [activeView, setActiveView] = useState(SurveySamplingView.TECHNIQUES); + + // Pagination and sorting for techniques + const [techniquesPaginationModel, setTechniquesPaginationModel] = useState({ + page: 0, + pageSize: pageSizeOptions[0] + }); + const [techniquesSortModel, setTechniquesSortModel] = useState([]); + + // Pagination and sorting for sites + const [sitesPaginationModel, setSitesPaginationModel] = useState({ + page: 0, + pageSize: pageSizeOptions[0] + }); + const [sitesSortModel, setSitesSortModel] = useState([]); + + // Sampling sites data loader and pagination + const samplingSitesDataLoader = useDataLoader((pagination: ApiPaginationRequestOptions) => + biohubApi.samplingSite.getSampleSites(surveyContext.projectId, surveyContext.surveyId, { pagination }) + ); + const sitesPagination: ApiPaginationRequestOptions = useMemo(() => { + const sort = firstOrNull(sitesSortModel); + return { + limit: sitesPaginationModel.pageSize, + sort: sort?.field || undefined, + order: sort?.sort || undefined, + page: sitesPaginationModel.page + 1 + }; + }, [sitesSortModel, sitesPaginationModel]); + + // Refresh data if there is data + useEffect(() => { + if ( + [SurveySamplingView.SITES, SurveySamplingView.PERIODS].includes(activeView) && + Number(samplingSitesDataLoader.data?.pagination.total) !== 0 + ) { + samplingSitesDataLoader.refresh(sitesPagination); + } + if ( + activeView === SurveySamplingView.TECHNIQUES && + Number(surveyContext.techniqueDataLoader.data?.pagination.total) !== 0 + ) { + surveyContext.techniqueDataLoader.refresh(surveyContext.projectId, surveyContext.surveyId); + } + // Including data loaders in the dependency cause infinite reloads + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [activeView, sitesPagination]); + + const sampleSites = useMemo(() => samplingSitesDataLoader.data?.sampleSites ?? [], [samplingSitesDataLoader.data]); + const techniques = surveyContext.techniqueDataLoader.data?.techniques ?? []; + + const samplePeriods: ISamplingSitePeriodRowData[] = useMemo(() => { + const data: ISamplingSitePeriodRowData[] = []; + for (const site of sampleSites) { + for (const method of site.sample_methods) { + for (const period of method.sample_periods) { + data.push({ + id: period.survey_sample_period_id, + sample_site: site.name, + sample_method: method.technique.name, + method_response_metric_id: method.method_response_metric_id, + start_date: period.start_date, + end_date: period.end_date, + start_time: period.start_time, + end_time: period.end_time + }); + } + } + } + return data; + }, [sampleSites]); + + return ( + <> + + + + + + {activeView === SurveySamplingView.TECHNIQUES && ( + } + isLoadingFallbackDelay={100} + hasNoData={!techniques.length} + hasNoDataFallback={ + + }> + + + )} + + {activeView === SurveySamplingView.SITES && ( + } + isLoadingFallbackDelay={100} + hasNoData={!sampleSites.length} + hasNoDataFallback={ + + }> + + + )} + + {/* TODO: Add pagination to the survey periods request */} + {activeView === SurveySamplingView.PERIODS && ( + } + isLoadingFallbackDelay={100} + hasNoData={!samplePeriods.length} + hasNoDataFallback={ + + }> + + + )} + + + ); +}; diff --git a/app/src/features/surveys/view/components/sampling-data/components/SurveySamplingTabs.tsx b/app/src/features/surveys/view/components/sampling-data/components/SurveySamplingTabs.tsx deleted file mode 100644 index c290494e35..0000000000 --- a/app/src/features/surveys/view/components/sampling-data/components/SurveySamplingTabs.tsx +++ /dev/null @@ -1,237 +0,0 @@ -import { mdiArrowTopRight, mdiAutoFix, mdiCalendarRange, mdiMapMarker } from '@mdi/js'; -import Icon from '@mdi/react'; -import Box from '@mui/material/Box'; -import Button from '@mui/material/Button'; -import Divider from '@mui/material/Divider'; -import ToggleButton from '@mui/material/ToggleButton'; -import ToggleButtonGroup from '@mui/material/ToggleButtonGroup'; -import { LoadingGuard } from 'components/loading/LoadingGuard'; -import { SkeletonTable } from 'components/loading/SkeletonLoaders'; -import { NoDataOverlay } from 'components/overlay/NoDataOverlay'; -import { - ISamplingSitePeriodRowData, - SamplingPeriodTable -} from 'features/surveys/sampling-information/periods/table/SamplingPeriodTable'; -import { - ISurveySitesRowData, - SurveySitesTable -} from 'features/surveys/view/components/sampling-data/components/SurveySitesTable'; -import { - ISurveyTechniqueRowData, - SurveyTechniquesTable -} from 'features/surveys/view/components/sampling-data/components/SurveyTechniquesTable'; -import { useSurveyContext } from 'hooks/useContext'; -import { useEffect, useMemo, useState } from 'react'; - -export enum SurveySamplingView { - TECHNIQUES = 'TECHNIQUES', - SITES = 'SITES', - PERIODS = 'PERIODS' -} - -export const SurveySamplingTabs = () => { - const surveyContext = useSurveyContext(); - - const [activeView, setActiveView] = useState(SurveySamplingView.TECHNIQUES); - - useEffect(() => { - // Refresh the data for the active view if the project or survey ID changes - if (activeView === SurveySamplingView.TECHNIQUES) { - surveyContext.techniqueDataLoader.refresh(surveyContext.projectId, surveyContext.surveyId); - } - if (activeView === SurveySamplingView.SITES) { - surveyContext.sampleSiteDataLoader.refresh(surveyContext.projectId, surveyContext.surveyId); - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [activeView]); - - useEffect(() => { - // Load the data initially once per tab, if/when the active view changes - if (activeView === SurveySamplingView.TECHNIQUES) { - surveyContext.techniqueDataLoader.load(surveyContext.projectId, surveyContext.surveyId); - } - if (activeView === SurveySamplingView.SITES) { - surveyContext.sampleSiteDataLoader.load(surveyContext.projectId, surveyContext.surveyId); - } - }, [ - activeView, - surveyContext.techniqueDataLoader, - surveyContext.sampleSiteDataLoader, - surveyContext.projectId, - surveyContext.surveyId - ]); - - const techniques: ISurveyTechniqueRowData[] = - surveyContext.techniqueDataLoader.data?.techniques.map((technique) => ({ - id: technique.method_technique_id, - name: technique.name, - method_lookup_id: technique.method_lookup_id, - description: technique.description, - attractants: technique.attractants, - distance_threshold: technique.distance_threshold - })) ?? []; - - const sampleSites: ISurveySitesRowData[] = useMemo( - () => - surveyContext.sampleSiteDataLoader.data?.sampleSites.map((site) => ({ - id: site.survey_sample_site_id, - name: site.name, - description: site.description, - geojson: site.geojson, - blocks: site.blocks.map((block) => block.name), - stratums: site.stratums.map((stratum) => stratum.name) - })) ?? [], - [surveyContext.sampleSiteDataLoader.data?.sampleSites] - ); - - const samplePeriods: ISamplingSitePeriodRowData[] = useMemo(() => { - const data: ISamplingSitePeriodRowData[] = []; - - for (const site of surveyContext.sampleSiteDataLoader.data?.sampleSites ?? []) { - for (const method of site.sample_methods) { - for (const period of method.sample_periods) { - data.push({ - id: period.survey_sample_period_id, - sample_site: site.name, - sample_method: method.technique.name, - method_response_metric_id: method.method_response_metric_id, - start_date: period.start_date, - end_date: period.end_date, - start_time: period.start_time, - end_time: period.end_time - }); - } - } - } - - return data; - }, [surveyContext.sampleSiteDataLoader.data?.sampleSites]); - - const techniquesCount = surveyContext.techniqueDataLoader.data?.count; - const sampleSitesCount = surveyContext.sampleSiteDataLoader.data?.sampleSites.length; - const samplePeriodsCount = samplePeriods.length; - - return ( - <> - - { - if (!view) { - // An active view must be selected at all times - return; - } - - setActiveView(view); - }} - exclusive - sx={{ - display: 'flex', - gap: 1, - '& Button': { - py: 0.25, - px: 1.5, - border: 'none', - borderRadius: '4px !important', - fontSize: '0.875rem', - fontWeight: 700, - letterSpacing: '0.02rem' - } - }}> - } - value={SurveySamplingView.TECHNIQUES}> - {`${SurveySamplingView.TECHNIQUES} (${techniquesCount ?? 0})`} - - } - value={SurveySamplingView.SITES}> - {`${SurveySamplingView.SITES} (${sampleSitesCount ?? 0})`} - - } - value={SurveySamplingView.PERIODS}> - {`${SurveySamplingView.PERIODS} (${samplePeriodsCount ?? 0})`} - - - - - - - - {activeView === SurveySamplingView.TECHNIQUES && ( - <> - } - isLoadingFallbackDelay={100} - hasNoData={!techniquesCount} - hasNoDataFallback={ - - } - hasNoDataFallbackDelay={100}> - - - - )} - - {activeView === SurveySamplingView.SITES && ( - <> - } - isLoadingFallbackDelay={100} - hasNoData={!sampleSitesCount} - hasNoDataFallback={ - - } - hasNoDataFallbackDelay={100}> - - - - )} - - {activeView === SurveySamplingView.PERIODS && ( - <> - } - isLoadingFallbackDelay={100} - hasNoData={!samplePeriodsCount} - hasNoDataFallback={ - - } - hasNoDataFallbackDelay={100}> - - - - )} - - - ); -}; diff --git a/app/src/features/surveys/view/components/sampling-data/components/SurveySitesTable.tsx b/app/src/features/surveys/view/components/sampling-data/components/site/SurveySitesTable.tsx similarity index 61% rename from app/src/features/surveys/view/components/sampling-data/components/SurveySitesTable.tsx rename to app/src/features/surveys/view/components/sampling-data/components/site/SurveySitesTable.tsx index 521efccde4..46f5d8cd69 100644 --- a/app/src/features/surveys/view/components/sampling-data/components/SurveySitesTable.tsx +++ b/app/src/features/surveys/view/components/sampling-data/components/site/SurveySitesTable.tsx @@ -1,27 +1,43 @@ import Box from '@mui/material/Box'; import blueGrey from '@mui/material/colors/blueGrey'; -import { GridColDef } from '@mui/x-data-grid'; +import { GridColDef, GridPaginationModel, GridSortModel } from '@mui/x-data-grid'; import ColouredRectangleChip from 'components/chips/ColouredRectangleChip'; import { StyledDataGrid } from 'components/data-grid/StyledDataGrid'; import { ISamplingSiteRowData } from 'features/surveys/sampling-information/sites/table/SamplingSiteTable'; -import { Feature } from 'geojson'; +import { IGetSampleLocationNonSpatialDetails } from 'interfaces/useSamplingSiteApi.interface'; import { getSamplingSiteSpatialType } from 'utils/spatial-utils'; +const pageSizeOptions = [10, 25, 50]; + export interface ISurveySitesRowData { id: number; name: string; description: string; - geojson: Feature; + geometry_type: string; blocks: string[]; stratums: string[]; } export interface ISurveySitesTableProps { - sites: ISurveySitesRowData[]; + sites: IGetSampleLocationNonSpatialDetails[]; + paginationModel: GridPaginationModel; + setPaginationModel: React.Dispatch>; + setSortModel: React.Dispatch>; + sortModel: GridSortModel; + rowCount: number; } export const SurveySitesTable = (props: ISurveySitesTableProps) => { - const { sites } = props; + const { sites, paginationModel, setPaginationModel, sortModel, setSortModel, rowCount } = props; + + const rows: ISamplingSiteRowData[] = sites.map((site) => ({ + id: site.survey_sample_site_id, + name: site.name, + geometry_type: site.geometry_type, + description: site.description || '', + blocks: site.blocks.map((block) => block.name), + stratums: site.stratums.map((stratum) => stratum.name) + })); const columns: GridColDef[] = [ { @@ -36,7 +52,7 @@ export const SurveySitesTable = (props: ISurveySitesTableProps) => { renderCell: (params) => ( @@ -81,18 +97,25 @@ export const SurveySitesTable = (props: ISurveySitesTableProps) => { 'auto'} - rows={sites} + rows={rows} getRowId={(row) => row.id} columns={columns} disableRowSelectionOnClick + onPaginationModelChange={setPaginationModel} + onSortModelChange={setSortModel} + sortModel={sortModel} + paginationModel={paginationModel} + paginationMode="server" + sortingMode="server" + rowCount={rowCount} initialState={{ pagination: { - paginationModel: { page: 1, pageSize: 10 } + paginationModel } }} - pageSizeOptions={[10, 25, 50]} + pageSizeOptions={pageSizeOptions} /> ); }; diff --git a/app/src/features/surveys/view/components/sampling-data/components/SurveyTechniquesTable.tsx b/app/src/features/surveys/view/components/sampling-data/components/technique/SurveyTechniquesTable.tsx similarity index 68% rename from app/src/features/surveys/view/components/sampling-data/components/SurveyTechniquesTable.tsx rename to app/src/features/surveys/view/components/sampling-data/components/technique/SurveyTechniquesTable.tsx index 3aa338f693..8deffaf8b1 100644 --- a/app/src/features/surveys/view/components/sampling-data/components/SurveyTechniquesTable.tsx +++ b/app/src/features/surveys/view/components/sampling-data/components/technique/SurveyTechniquesTable.tsx @@ -1,14 +1,16 @@ import Box from '@mui/material/Box'; import blueGrey from '@mui/material/colors/blueGrey'; import Typography from '@mui/material/Typography'; -import { GridColDef } from '@mui/x-data-grid'; +import { GridColDef, GridPaginationModel, GridSortModel } from '@mui/x-data-grid'; import ColouredRectangleChip from 'components/chips/ColouredRectangleChip'; import { StyledDataGrid } from 'components/data-grid/StyledDataGrid'; import { ITechniqueRowData } from 'features/surveys/sampling-information/techniques/table/SamplingTechniqueTable'; import { useCodesContext } from 'hooks/useContext'; -import { TechniqueAttractant } from 'interfaces/useTechniqueApi.interface'; +import { IGetTechniqueResponse, TechniqueAttractant } from 'interfaces/useTechniqueApi.interface'; import { getCodesName } from 'utils/Utils'; +const pageSizeOptions = [10, 25, 50]; + export interface ISurveyTechniqueRowData { id: number; method_lookup_id: number; @@ -19,14 +21,29 @@ export interface ISurveyTechniqueRowData { } export interface ISurveyTechniquesTableProps { - techniques: ISurveyTechniqueRowData[]; + techniques: IGetTechniqueResponse[]; + paginationModel: GridPaginationModel; + sortModel: GridSortModel; + setPaginationModel: React.Dispatch>; + setSortModel: React.Dispatch>; + rowCount: number; } export const SurveyTechniquesTable = (props: ISurveyTechniquesTableProps) => { - const { techniques } = props; + const { techniques, paginationModel, setPaginationModel, sortModel, setSortModel, rowCount } = props; const codesContext = useCodesContext(); + const rows: ISurveyTechniqueRowData[] = + techniques.map((technique) => ({ + id: technique.method_technique_id, + name: technique.name, + method_lookup_id: technique.method_lookup_id, + description: technique.description, + attractants: technique.attractants, + distance_threshold: technique.distance_threshold + })) ?? []; + const columns: GridColDef[] = [ { field: 'name', @@ -100,18 +117,27 @@ export const SurveyTechniquesTable = (props: ISurveyTechniquesTableProps) => { 'auto'} - rows={techniques} + rows={rows} getRowId={(row) => row.id} columns={columns} disableRowSelectionOnClick + onPaginationModelChange={setPaginationModel} + onSortModelChange={setSortModel} + sortModel={sortModel} + paginationModel={paginationModel} + // TODO: Enable pagination; saving for a separate PR because it should probably include + // removing techniquesDataLoader from the surveyContext + paginationMode="server" + sortingMode="server" + rowCount={rowCount} initialState={{ pagination: { - paginationModel: { page: 1, pageSize: 10 } + paginationModel } }} - pageSizeOptions={[10, 25, 50]} + pageSizeOptions={pageSizeOptions} /> ); }; diff --git a/app/src/features/surveys/view/components/sampling-data/components/view/SurveySamplingViewTabs.tsx b/app/src/features/surveys/view/components/sampling-data/components/view/SurveySamplingViewTabs.tsx new file mode 100644 index 0000000000..6d1607687e --- /dev/null +++ b/app/src/features/surveys/view/components/sampling-data/components/view/SurveySamplingViewTabs.tsx @@ -0,0 +1,65 @@ +import { mdiAutoFix, mdiCalendarRange, mdiMapMarker } from '@mdi/js'; +import Icon from '@mdi/react'; +import { Box, Button, ToggleButton, ToggleButtonGroup } from '@mui/material'; +import { SurveySamplingView } from '../../SurveySamplingTableContainer'; + +interface SurveySamplingViewTabsProps { + activeView: SurveySamplingView; + setActiveView: (view: SurveySamplingView) => void; +} + +export const SurveySamplingViewTabs = ({ activeView, setActiveView }: SurveySamplingViewTabsProps) => { + return ( + + { + if (!view) { + // An active view must be selected at all times + return; + } + + setActiveView(view); + }} + exclusive + sx={{ + display: 'flex', + gap: 1, + '& Button': { + py: 0.25, + px: 1.5, + border: 'none', + borderRadius: '4px !important', + fontSize: '0.875rem', + fontWeight: 700, + letterSpacing: '0.02rem' + } + }}> + } + value={SurveySamplingView.TECHNIQUES}> + Techniques + + } + value={SurveySamplingView.SITES}> + Sites + + } + value={SurveySamplingView.PERIODS}> + Periods + + + + ); +}; diff --git a/app/src/features/surveys/view/survey-spatial/SurveySpatialContainer.tsx b/app/src/features/surveys/view/survey-spatial/SurveySpatialContainer.tsx index db559114d6..35e3c3fb4c 100644 --- a/app/src/features/surveys/view/survey-spatial/SurveySpatialContainer.tsx +++ b/app/src/features/surveys/view/survey-spatial/SurveySpatialContainer.tsx @@ -9,7 +9,9 @@ import { import { SurveySpatialTelemetry } from 'features/surveys/view/survey-spatial/components/telemetry/SurveySpatialTelemetry'; import { useObservationsContext, useTaxonomyContext } from 'hooks/useContext'; import { isEqual } from 'lodash-es'; -import { useEffect, useState } from 'react'; +import { useEffect, useMemo, useState } from 'react'; +import { useSamplingSiteStaticLayer } from './components/map/useSamplingSiteStaticLayer'; +import { useStudyAreaStaticLayer } from './components/map/useStudyAreaStaticLayer'; /** * Container component for displaying survey spatial data. @@ -24,6 +26,14 @@ export const SurveySpatialContainer = (): JSX.Element => { const [activeView, setActiveView] = useState(SurveySpatialDatasetViewEnum.OBSERVATIONS); + const studyAreaStaticLayer = useStudyAreaStaticLayer(); + const samplingSiteStaticLayer = useSamplingSiteStaticLayer(); + + const staticLayers = useMemo( + () => [studyAreaStaticLayer, samplingSiteStaticLayer], + [samplingSiteStaticLayer, studyAreaStaticLayer] + ); + // Fetch and cache all taxonomic data required for the observations. useEffect(() => { const cacheTaxonomicData = async () => { @@ -71,13 +81,15 @@ export const SurveySpatialContainer = (): JSX.Element => { /> {/* Display the corresponding dataset view based on the selected active view */} - {isEqual(SurveySpatialDatasetViewEnum.OBSERVATIONS, activeView) && } + {isEqual(SurveySpatialDatasetViewEnum.OBSERVATIONS, activeView) && ( + + )} {isEqual(SurveySpatialDatasetViewEnum.TELEMETRY, activeView) && ( - + )} - {isEqual(SurveySpatialDatasetViewEnum.ANIMALS, activeView) && } + {isEqual(SurveySpatialDatasetViewEnum.ANIMALS, activeView) && } ); }; 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 d1688bd46c..12f12757ea 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 @@ -4,7 +4,7 @@ import { SURVEY_MAP_LAYER_COLOURS } from 'constants/colours'; import { SurveySpatialAnimalCapturePopup } from 'features/surveys/view/survey-spatial/components/animal/SurveySpatialAnimalCapturePopup'; import { SurveySpatialAnimalMortalityPopup } from 'features/surveys/view/survey-spatial/components/animal/SurveySpatialAnimalMortalityPopup'; import { SurveySpatialAnimalTable } from 'features/surveys/view/survey-spatial/components/animal/SurveySpatialAnimalTable'; -import { SurveySpatialMap } from 'features/surveys/view/survey-spatial/components/map/SurveySpatialMap'; +import SurveyMap from 'features/surveys/view/SurveyMap'; import SurveyMapTooltip from 'features/surveys/view/SurveyMapTooltip'; import { useSurveyContext } from 'hooks/useContext'; import { useCritterbaseApi } from 'hooks/useCritterbaseApi'; @@ -12,11 +12,18 @@ import useDataLoader from 'hooks/useDataLoader'; import { useEffect, useMemo } from 'react'; import { coloredCustomMortalityMarker } from 'utils/mapUtils'; +interface ISurveySpatialAnimalProps { + /** + * Array of additional static layers to be added to the map. + */ + staticLayers: IStaticLayer[]; +} + /** * Component for displaying animal capture points on a map and in a table. * Retrieves and displays data related to animal captures for a specific survey. */ -export const SurveySpatialAnimal = () => { +export const SurveySpatialAnimal = (props: ISurveySpatialAnimalProps) => { const surveyContext = useSurveyContext(); const crittersApi = useCritterbaseApi(); @@ -91,7 +98,10 @@ export const SurveySpatialAnimal = () => { <> {/* Display map with animal capture points */} - + {/* Display data table with animal capture details */} diff --git a/app/src/features/surveys/view/survey-spatial/components/map/SurveySpatialMap.tsx b/app/src/features/surveys/view/survey-spatial/components/map/SurveySpatialMap.tsx deleted file mode 100644 index ce916ecbc1..0000000000 --- a/app/src/features/surveys/view/survey-spatial/components/map/SurveySpatialMap.tsx +++ /dev/null @@ -1,42 +0,0 @@ -import { IStaticLayer } from 'components/map/components/StaticLayers'; -import { useSamplingSiteStaticLayer } from 'features/surveys/view/survey-spatial/components/map/useSamplingSiteStaticLayer'; -import { useStudyAreaStaticLayer } from 'features/surveys/view/survey-spatial/components/map/useStudyAreaStaticLayer'; -import SurveyMap from 'features/surveys/view/SurveyMap'; -import { useMemo } from 'react'; - -/** - * Props interface for SurveySpatialMap component. - */ -interface ISurveyDataMapProps { - /** - * Array of additional static layers to be added to the map. - */ - staticLayers: IStaticLayer[]; - /** - * Loading indicator to control map skeleton loader. - */ - isLoading: boolean; -} - -/** - * Component for displaying survey-related spatial data on a map. - * - * Automatically includes the study area and sampling site static layers. - * - * @param {ISurveyDataMapProps} props - * @return {*} - */ -export const SurveySpatialMap = (props: ISurveyDataMapProps) => { - const { staticLayers, isLoading } = props; - - const studyAreaStaticLayer = useStudyAreaStaticLayer(); - - const samplingSiteStaticLayer = useSamplingSiteStaticLayer(); - - const allStaticLayers = useMemo( - () => [studyAreaStaticLayer, samplingSiteStaticLayer, ...staticLayers], - [samplingSiteStaticLayer, staticLayers, studyAreaStaticLayer] - ); - - return ; -}; diff --git a/app/src/features/surveys/view/survey-spatial/components/map/useSamplingSiteStaticLayer.tsx b/app/src/features/surveys/view/survey-spatial/components/map/useSamplingSiteStaticLayer.tsx index 9fdf33aa54..325903a7d6 100644 --- a/app/src/features/surveys/view/survey-spatial/components/map/useSamplingSiteStaticLayer.tsx +++ b/app/src/features/surveys/view/survey-spatial/components/map/useSamplingSiteStaticLayer.tsx @@ -1,8 +1,11 @@ import { IStaticLayer } from 'components/map/components/StaticLayers'; import { SURVEY_MAP_LAYER_COLOURS } from 'constants/colours'; -import { SurveyMapPopup } from 'features/surveys/view/SurveyMapPopup'; import SurveyMapTooltip from 'features/surveys/view/SurveyMapTooltip'; +import { SurveySampleSiteMapPopup } from 'features/surveys/view/SurveySampleSiteMapPopup'; +import { useBiohubApi } from 'hooks/useBioHubApi'; import { useSurveyContext } from 'hooks/useContext'; +import useDataLoader from 'hooks/useDataLoader'; +import { useEffect } from 'react'; import { Popup } from 'react-leaflet'; /** @@ -12,6 +15,17 @@ import { Popup } from 'react-leaflet'; */ export const useSamplingSiteStaticLayer = (): IStaticLayer => { const surveyContext = useSurveyContext(); + const biohubApi = useBiohubApi(); + + const geometryDataLoader = useDataLoader(() => + biohubApi.samplingSite.getSampleSitesGeometry(surveyContext.projectId, surveyContext.surveyId) + ); + + useEffect(() => { + geometryDataLoader.load(); + }, [geometryDataLoader]); + + const samplingSites = geometryDataLoader.data?.sampleSites ?? []; const samplingSiteStaticLayer: IStaticLayer = { layerName: 'Sampling Sites', @@ -20,7 +34,7 @@ export const useSamplingSiteStaticLayer = (): IStaticLayer => { fillColor: SURVEY_MAP_LAYER_COLOURS.SAMPLING_SITE_COLOUR }, features: - surveyContext.sampleSiteDataLoader.data?.sampleSites.flatMap((site) => { + samplingSites.flatMap((site) => { return { id: site.survey_sample_site_id, key: `sampling-site-${site.survey_sample_site_id}`, @@ -28,32 +42,9 @@ export const useSamplingSiteStaticLayer = (): IStaticLayer => { }; }) ?? [], popup: (feature) => { - const sampleSite = surveyContext.sampleSiteDataLoader.data?.sampleSites.find( - (item) => item.survey_sample_site_id === feature.id - ); - - const metadata = []; - - if (sampleSite) { - metadata.push({ - label: 'Name', - value: sampleSite.name - }); - - metadata.push({ - label: 'Description', - value: sampleSite.description - }); - } - return ( - + ); }, 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 426c266a1f..a12f2b6630 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 @@ -2,8 +2,8 @@ import Box from '@mui/material/Box'; import { IStaticLayer, IStaticLayerFeature } from 'components/map/components/StaticLayers'; import { SURVEY_MAP_LAYER_COLOURS } from 'constants/colours'; import SurveyObservationTabularDataContainer from 'features/surveys/view/components/data-container/SurveyObservationTabularDataContainer'; -import { SurveySpatialMap } from 'features/surveys/view/survey-spatial/components/map/SurveySpatialMap'; import { SurveySpatialObservationPointPopup } from 'features/surveys/view/survey-spatial/components/observation/SurveySpatialObservationPointPopup'; +import SurveyMap from 'features/surveys/view/SurveyMap'; import SurveyMapTooltip from 'features/surveys/view/SurveyMapTooltip'; import { useBiohubApi } from 'hooks/useBioHubApi'; import { useSurveyContext } from 'hooks/useContext'; @@ -12,10 +12,17 @@ import { IGetSurveyObservationsGeometryResponse } from 'interfaces/useObservatio import { useEffect, useMemo } from 'react'; import { coloredCustomObservationMarker } from 'utils/mapUtils'; +interface ISurveySpatialObservationProps { + /** + * Array of additional static layers to be added to the map. + */ + staticLayers: IStaticLayer[]; +} + /** * Component to display survey observation data on a map and in a table. */ -export const SurveySpatialObservation = () => { +export const SurveySpatialObservation = (props: ISurveySpatialObservationProps) => { const surveyContext = useSurveyContext(); const { surveyId, projectId } = surveyContext; const biohubApi = useBiohubApi(); @@ -62,12 +69,15 @@ export const SurveySpatialObservation = () => { <> {/* Display map with observation points */} - + {/* Display data table with observation details */} - - + + ); diff --git a/app/src/features/surveys/view/survey-spatial/components/observation/SurveySpatialObservationTable.tsx b/app/src/features/surveys/view/survey-spatial/components/observation/SurveySpatialObservationTable.tsx index a7afdece4e..74fa78a213 100644 --- a/app/src/features/surveys/view/survey-spatial/components/observation/SurveySpatialObservationTable.tsx +++ b/app/src/features/surveys/view/survey-spatial/components/observation/SurveySpatialObservationTable.tsx @@ -28,17 +28,12 @@ interface IObservationTableRow { longitude: number | null; } -interface ISurveyDataObservationTableProps { - isLoading: boolean; -} - /** * Component to display observation data in a table with server-side pagination and sorting. * - * @param {ISurveyDataObservationTableProps} props - Component properties. - * @returns {JSX.Element} The rendered component. + * @returns {*} */ -export const SurveySpatialObservationTable = (props: ISurveyDataObservationTableProps) => { +export const SurveySpatialObservationTable = () => { const biohubApi = useBiohubApi(); const surveyContext = useContext(SurveyContext); const taxonomyContext = useTaxonomyContext(); @@ -160,7 +155,7 @@ export const SurveySpatialObservationTable = (props: ISurveyDataObservationTable return ( } isLoadingFallbackDelay={100} hasNoData={!rows.length} 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 25011e2585..2156146e37 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 @@ -1,9 +1,9 @@ import Box from '@mui/material/Box'; import { IStaticLayer, IStaticLayerFeature } from 'components/map/components/StaticLayers'; import { SURVEY_MAP_LAYER_COLOURS } from 'constants/colours'; -import { SurveySpatialMap } from 'features/surveys/view/survey-spatial/components/map/SurveySpatialMap'; import { SurveySpatialTelemetryPopup } from 'features/surveys/view/survey-spatial/components/telemetry/SurveySpatialTelemetryPopup'; import { SurveySpatialTelemetryTable } from 'features/surveys/view/survey-spatial/components/telemetry/SurveySpatialTelemetryTable'; +import SurveyMap from 'features/surveys/view/SurveyMap'; import SurveyMapTooltip from 'features/surveys/view/SurveyMapTooltip'; import { Position } from 'geojson'; import { useSurveyContext, useTelemetryDataContext } from 'hooks/useContext'; @@ -11,12 +11,19 @@ import { ICritterSimpleResponse } from 'interfaces/useCritterApi.interface'; import { IAnimalDeployment, ITelemetry } from 'interfaces/useTelemetryApi.interface'; import { useCallback, useEffect, useMemo } from 'react'; +interface ISurveySpatialTelemetryProps { + /** + * Array of additional static layers to be added to the map. + */ + staticLayers: IStaticLayer[]; +} + /** * Component to display telemetry data on a map and in a table. * - * @returns {JSX.Element} The rendered component. + * @returns {*} The rendered component. */ -export const SurveySpatialTelemetry = () => { +export const SurveySpatialTelemetry = (props: ISurveySpatialTelemetryProps) => { const surveyContext = useSurveyContext(); const telemetryDataContext = useTelemetryDataContext(); @@ -131,12 +138,12 @@ export const SurveySpatialTelemetry = () => { <> {/* Display map with telemetry points */} - + {/* Display data table with telemetry details */} - + ); diff --git a/app/src/features/surveys/view/survey-spatial/components/telemetry/SurveySpatialTelemetryTable.tsx b/app/src/features/surveys/view/survey-spatial/components/telemetry/SurveySpatialTelemetryTable.tsx index c36cad35b5..42e09d47a3 100644 --- a/app/src/features/surveys/view/survey-spatial/components/telemetry/SurveySpatialTelemetryTable.tsx +++ b/app/src/features/surveys/view/survey-spatial/components/telemetry/SurveySpatialTelemetryTable.tsx @@ -29,18 +29,12 @@ interface ITelemetryData { itis_scientific_name: string; } -interface ISurveyDataTelemetryTableProps { - isLoading: boolean; -} - /** * Component to display telemetry data in a table format. * - * @param {ISurveyDataTelemetryTableProps} props - The component props. - * @param {boolean} props.isLoading - Indicates if the data is currently loading. - * @returns {JSX.Element} The rendered component. + * @returns {*} The rendered component. */ -export const SurveySpatialTelemetryTable = (props: ISurveyDataTelemetryTableProps) => { +export const SurveySpatialTelemetryTable = () => { const surveyContext = useContext(SurveyContext); const telemetryDataContext = useTelemetryDataContext(); @@ -170,7 +164,7 @@ export const SurveySpatialTelemetryTable = (props: ISurveyDataTelemetryTableProp return ( } isLoadingFallbackDelay={100} hasNoData={!rows.length} diff --git a/app/src/hooks/api/useAnimalApi.ts b/app/src/hooks/api/useAnimalApi.ts index caf5bb9882..451c8e770c 100644 --- a/app/src/hooks/api/useAnimalApi.ts +++ b/app/src/hooks/api/useAnimalApi.ts @@ -20,7 +20,7 @@ const useAnimalApi = (axios: AxiosInstance) => { * * @param {number} projectId * @param {number} surveyId - * @return {*} {Promise} + * @return {*} {Promise} */ const getCaptureMortalityGeometry = async ( projectId: number, diff --git a/app/src/hooks/api/useObservationApi.test.ts b/app/src/hooks/api/useObservationApi.test.ts index 0b2e4173ff..eeec7a8e78 100644 --- a/app/src/hooks/api/useObservationApi.test.ts +++ b/app/src/hooks/api/useObservationApi.test.ts @@ -40,7 +40,8 @@ describe('useObservationApi', () => { qualitative_measurements: [], quantitative_measurements: [], qualitative_environments: [], - quantitative_environments: [] + quantitative_environments: [], + sample_sites: [] }, pagination: { total: 100, diff --git a/app/src/hooks/api/useObservationApi.ts b/app/src/hooks/api/useObservationApi.ts index 653c061ec3..702a76afca 100644 --- a/app/src/hooks/api/useObservationApi.ts +++ b/app/src/hooks/api/useObservationApi.ts @@ -105,23 +105,15 @@ const useObservationApi = (axios: AxiosInstance) => { surveyId: number, pagination?: ApiPaginationRequestOptions ): Promise => { - let urlParamsString = ''; - - if (pagination) { - const params = new URLSearchParams(); - params.append('page', pagination.page.toString()); - params.append('limit', pagination.limit.toString()); - if (pagination.sort) { - params.append('sort', pagination.sort); - } - if (pagination.order) { - params.append('order', pagination.order); - } - urlParamsString = `?${params.toString()}`; - } + const params = { + ...pagination + }; const { data } = await axios.get( - `/api/project/${projectId}/survey/${surveyId}/observations${urlParamsString}` + `/api/project/${projectId}/survey/${surveyId}/observations`, + { + params + } ); return data; diff --git a/app/src/hooks/api/useSamplingSiteApi.ts b/app/src/hooks/api/useSamplingSiteApi.ts index a8f7783595..519ea4e572 100644 --- a/app/src/hooks/api/useSamplingSiteApi.ts +++ b/app/src/hooks/api/useSamplingSiteApi.ts @@ -3,8 +3,10 @@ import { ICreateSamplingSiteRequest, IEditSampleSiteRequest, IGetSampleLocationDetails, - IGetSampleSiteResponse + IGetSampleLocationNonSpatialResponse, + IGetSampleSiteGeometryResponse } from 'interfaces/useSamplingSiteApi.interface'; +import { ApiPaginationRequestOptions } from 'types/misc'; /** * Returns a set of supported api methods for working with search functionality @@ -30,14 +32,45 @@ const useSamplingSiteApi = (axios: AxiosInstance) => { }; /** - * Get Sample Sites + * Get Sample Sites, paginated or filtered by keyword. * * @param {number} projectId * @param {number} surveyId - * @return {*} {Promise} + * @param {ApiPaginationRequestOptions} pagination + * @return {*} {Promise} */ - const getSampleSites = async (projectId: number, surveyId: number): Promise => { - const { data } = await axios.get(`/api/project/${projectId}/survey/${surveyId}/sample-site`); + const getSampleSites = async ( + projectId: number, + surveyId: number, + options?: { + keyword?: string; + pagination?: ApiPaginationRequestOptions; + } + ): Promise => { + const params = { + keyword: options?.keyword, + ...options?.pagination + }; + + const { data } = await axios.get(`/api/project/${projectId}/survey/${surveyId}/sample-site`, { + params + }); + + return data; + }; + + /** + * Get Sample Sites geometry data + * + * @param {number} projectId + * @param {number} surveyId + * @return {*} {Promise} + */ + const getSampleSitesGeometry = async ( + projectId: number, + surveyId: number + ): Promise => { + const { data } = await axios.get(`/api/project/${projectId}/survey/${surveyId}/sample-site/spatial`); return data; }; @@ -48,7 +81,7 @@ const useSamplingSiteApi = (axios: AxiosInstance) => { * @param {number} projectId * @param {number} surveyId * @param {number} sampleSiteId - * @return {*} {Promise} + * @return {*} {Promise} */ const getSampleSiteById = async ( projectId: number, @@ -59,6 +92,59 @@ const useSamplingSiteApi = (axios: AxiosInstance) => { return data; }; + const findSampleSites = async (filterFieldData?: { + survey_id?: number; + keyword?: string; + system_user_id?: number; + pagination?: ApiPaginationRequestOptions; + }): Promise => { + const params = { + ...filterFieldData + }; + + const { data } = await axios.get(`/api/sampling-locations/sites`, { + params + }); + + return data; + }; + + const findSampleMethods = async (filterFieldData?: { + survey_id?: number; + sample_site_id: number; + keyword?: string; + system_user_id?: number; + pagination?: ApiPaginationRequestOptions; + }): Promise => { + const params = { + ...filterFieldData + }; + + const { data } = await axios.get(`/api/sampling-locations/methods`, { + params + }); + + return data; + }; + + const findSamplePeriods = async (filterFieldData?: { + survey_id?: number; + sample_site_id: number; + sample_method_id: number; + system_user_id?: number; + pagination?: ApiPaginationRequestOptions; + }): Promise => { + const params = { + ...filterFieldData + }; + + const { data } = await axios.get(`/api/sampling-locations/periods`, { + params + }); + + return data; + }; + /** * Edit Sample Site * @@ -109,6 +195,10 @@ const useSamplingSiteApi = (axios: AxiosInstance) => { createSamplingSites, getSampleSites, getSampleSiteById, + getSampleSitesGeometry, + findSampleSites, + findSampleMethods, + findSamplePeriods, editSampleSite, deleteSampleSite, deleteSampleSites diff --git a/app/src/interfaces/useObservationApi.interface.ts b/app/src/interfaces/useObservationApi.interface.ts index bc92967884..dc521ed39d 100644 --- a/app/src/interfaces/useObservationApi.interface.ts +++ b/app/src/interfaces/useObservationApi.interface.ts @@ -7,6 +7,7 @@ import { EnvironmentQuantitativeTypeDefinition } from 'interfaces/useReferenceApi.interface'; import { ApiPaginationResponseParams } from 'types/misc'; +import { IGetSampleLocationNonSpatialDetails } from './useSamplingSiteApi.interface'; export interface IGetSurveyObservationsResponse { surveyObservations: ObservationRecordWithSamplingAndSubcountData[]; supplementaryObservationData: SupplementaryObservationData; @@ -20,7 +21,7 @@ export interface IGetSurveyObservationsGeometryObject { export interface IGetSurveyObservationsGeometryResponse { surveyObservationsGeometry: IGetSurveyObservationsGeometryObject[]; - supplementaryObservationData: SupplementaryObservationData; + supplementaryObservationData: SupplementaryObservationCountData; } type ObservationSamplingData = { @@ -37,8 +38,8 @@ export type StandardObservationColumns = { survey_sample_method_id: number | null; survey_sample_period_id: number | null; count: number | null; - observation_date: string; - observation_time: string; + observation_date: string | null; + observation_time: string | null; latitude: number | null; longitude: number | null; }; @@ -66,6 +67,11 @@ export type SupplementaryObservationCountData = { observationCount: number; }; +export type ObservationSamplingSupplementaryData = { + // sample_sites: IGetBasicSampleLocation[]; + sample_sites: IGetSampleLocationNonSpatialDetails[]; +}; + export type SupplementaryObservationMeasurementData = { qualitative_measurements: CBQualitativeMeasurementTypeDefinition[]; quantitative_measurements: CBQuantitativeMeasurementTypeDefinition[]; @@ -73,7 +79,9 @@ export type SupplementaryObservationMeasurementData = { quantitative_environments: EnvironmentQuantitativeTypeDefinition[]; }; -export type SupplementaryObservationData = SupplementaryObservationCountData & SupplementaryObservationMeasurementData; +export type SupplementaryObservationData = SupplementaryObservationCountData & + SupplementaryObservationMeasurementData & + ObservationSamplingSupplementaryData; type ObservationSubCountQualitativeMeasurementRecord = { observation_subcount_id: number; diff --git a/app/src/interfaces/useSamplingSiteApi.interface.ts b/app/src/interfaces/useSamplingSiteApi.interface.ts index 33799d4129..564c4b0062 100644 --- a/app/src/interfaces/useSamplingSiteApi.interface.ts +++ b/app/src/interfaces/useSamplingSiteApi.interface.ts @@ -40,11 +40,22 @@ export interface IEditSampleSiteRequest { }; } -export interface IGetSampleSiteResponse { - sampleSites: IGetSampleLocationDetails[]; +export interface IGetSampleLocationNonSpatialResponse { + sampleSites: IGetSampleLocationNonSpatialDetails[]; pagination: ApiPaginationResponseParams; } +export interface IGetSampleLocationNonSpatialDetails { + survey_sample_site_id: number; + survey_id: number; + name: string; + description: string; + geometry_type: string; + sample_methods: IGetSampleMethodDetails[]; + blocks: IGetSampleBlockDetails[]; + stratums: IGetSampleStratumDetails[]; +} + export interface IGetSampleLocationRecord { survey_sample_site_id: number; survey_id: number; @@ -58,6 +69,15 @@ export interface IGetSampleLocationRecord { revision_count: number; } +export interface IGetSampleSiteGeometryResponse { + sampleSites: IGetSampleSiteGeometry[]; +} + +export interface IGetSampleSiteGeometry { + survey_sample_site_id: number; + geojson: Feature; +} + export interface IGetSampleLocationDetails { survey_sample_site_id: number; survey_id: number; @@ -69,6 +89,29 @@ export interface IGetSampleLocationDetails { stratums: IGetSampleStratumDetails[]; } +export interface IGetBasicSamplePeriod { + survey_sample_period_id: number; + survey_sample_method_id: number; + start_date: string; + end_date: string; + start_time: string; + end_time: string; +} + +export interface IGetBasicSampleMethod { + survey_sample_method_id: number; + survey_sample_site_id: number; + method_response_metric_id: number; + technique: { survey_technique_id: number; name: string }; + sample_periods: IGetBasicSamplePeriod[]; +} + +export interface IGetBasicSampleLocation { + survey_sample_site_id: number; + name: string; + sample_methods: IGetBasicSampleMethod; +} + export interface IGetSampleLocationDetailsForUpdate { survey_sample_site_id: number | null; survey_id: number; diff --git a/app/src/themes/appTheme.ts b/app/src/themes/appTheme.ts index cd82c81de1..6dc932c415 100644 --- a/app/src/themes/appTheme.ts +++ b/app/src/themes/appTheme.ts @@ -134,7 +134,6 @@ const appTheme = createTheme({ textOverflow: 'ellipsis' }, '& span': { - display: 'block', fontSize: '0.9rem', overflow: 'hidden', textOverflow: 'ellipsis' diff --git a/app/src/utils/datetime.test.ts b/app/src/utils/datetime.test.ts new file mode 100644 index 0000000000..582fcd1e72 --- /dev/null +++ b/app/src/utils/datetime.test.ts @@ -0,0 +1,53 @@ +import { combineDateTime, formatTimeDifference } from './datetime'; + +describe('combineDateTime', () => { + it('combines date and time into an ISO string', () => { + const result = combineDateTime('2024-01-01', '12:30:00'); + expect(result).toEqual('2024-01-01T12:30:00.000Z'); + }); + + it('combines date without time into an ISO string', () => { + const result = combineDateTime('2024-01-01'); + expect(result).toEqual('2024-01-01T00:00:00.000Z'); + }); + + it('returns ISO string for a different date and time', () => { + const result = combineDateTime('2023-12-31', '23:59:59'); + expect(result).toEqual('2023-12-31T23:59:59.000Z'); + }); + + it('handles invalid date formats gracefully', () => { + const date = combineDateTime('badDate', '12:00'); + expect(date).toEqual('Invalid Date'); + + const time = combineDateTime('2024-01-01', 'badtime'); + expect(time).toEqual('Invalid Date'); + }); +}); + +describe('formatTimeDifference', () => { + it('formats the time difference correctly between two dates and times', () => { + const result = formatTimeDifference('2024-01-01', '12:00', '2024-01-02', '13:30'); + expect(result).toEqual('1 day and 1 hour'); + }); + + it('handles time difference with only dates', () => { + const result = formatTimeDifference('2024-01-01', null, '2024-01-03', null); + expect(result).toEqual('2 days'); + }); + + it('formats the time difference correctly with no time component', () => { + const result = formatTimeDifference('2024-01-01', null, '2024-01-01', '01:00'); + expect(result).toEqual('1 hour'); + }); + + it('returns null when there is no time difference', () => { + const result = formatTimeDifference('2024-01-01', null, '2024-01-01', null); + expect(result).toBeNull(); + }); + + it('handles cases with invalid inputs', () => { + const result = formatTimeDifference('invalid-date', null, '2024-01-01', null); + expect(result).toBeNull(); + }); +}); diff --git a/app/src/utils/datetime.ts b/app/src/utils/datetime.ts index cec5233cf9..81e4780663 100644 --- a/app/src/utils/datetime.ts +++ b/app/src/utils/datetime.ts @@ -1,3 +1,11 @@ +import dayjs from 'dayjs'; +import duration from 'dayjs/plugin/duration'; +import { pluralize } from './Utils'; + +const TIMESTAMP_FORMAT = 'YYYY-MM-DDTHH:mm:ss.SSS[Z]'; + +dayjs.extend(duration); + /** * Combine date and time and return ISO string. * @@ -7,7 +15,50 @@ */ export const combineDateTime = (date: string, time?: string | null) => { if (date && time) { - return new Date(`${date}T${time}`).toISOString(); + return dayjs(`${date} ${time}`).format(TIMESTAMP_FORMAT); } - return new Date(`${date}T00:00:00`).toISOString(); + + return dayjs(`${date}`).format(TIMESTAMP_FORMAT); +}; + +/** + * Formats the time difference between two timestamps into a human-readable string. + * + * @param {string} startDate + * @param {string | null} startTime + * @param {string} endDate + * @param {string | null} endTime + * @returns {string | null} A formatted string indicating an amount of time + */ +export const formatTimeDifference = ( + startDate: string, + startTime: string | null, + endDate: string, + endTime: string | null +): string | null => { + const startDateTime = startTime ? dayjs(`${startDate} ${startTime}`) : dayjs(startDate); + const endDateTime = endTime ? dayjs(`${endDate} ${endTime}`) : dayjs(endDate); + + if (!startDateTime.isValid() || !endDateTime.isValid()) { + return null; + } + + // Calculate the total difference + const diff = dayjs.duration(endDateTime.diff(startDateTime)); + + const parts = []; + + for (const unit of ['year', 'month', 'day', 'hour', 'minute', 'second']) { + const value = diff[`${unit}s`](); + + if (value > 0) { + parts.push(`${value} ${pluralize(value, unit)}`); + } + } + + if (!parts.length) { + return null; + } + + return parts.slice(0, 2).join(' and '); }; diff --git a/app/src/utils/spatial-utils.test.ts b/app/src/utils/spatial-utils.test.ts index 7f4456b85b..161c1e7cb7 100644 --- a/app/src/utils/spatial-utils.test.ts +++ b/app/src/utils/spatial-utils.test.ts @@ -165,7 +165,7 @@ describe('getSamplingSiteSpatialType', () => { properties: {} }; - const response = getSamplingSiteSpatialType(feature); + const response = getSamplingSiteSpatialType(feature.geometry.type); expect(response).toEqual(SAMPLING_SITE_SPATIAL_TYPE.TRANSECT); }); @@ -183,7 +183,7 @@ describe('getSamplingSiteSpatialType', () => { properties: {} }; - const response = getSamplingSiteSpatialType(feature); + const response = getSamplingSiteSpatialType(feature.geometry.type); expect(response).toEqual(SAMPLING_SITE_SPATIAL_TYPE.TRANSECT); }); @@ -198,7 +198,7 @@ describe('getSamplingSiteSpatialType', () => { properties: {} }; - const response = getSamplingSiteSpatialType(feature); + const response = getSamplingSiteSpatialType(feature.geometry.type); expect(response).toEqual(SAMPLING_SITE_SPATIAL_TYPE.POINT); }); @@ -216,7 +216,7 @@ describe('getSamplingSiteSpatialType', () => { properties: {} }; - const response = getSamplingSiteSpatialType(feature); + const response = getSamplingSiteSpatialType(feature.geometry.type); expect(response).toEqual(SAMPLING_SITE_SPATIAL_TYPE.POINT); }); @@ -238,7 +238,7 @@ describe('getSamplingSiteSpatialType', () => { properties: {} }; - const response = getSamplingSiteSpatialType(feature); + const response = getSamplingSiteSpatialType(feature.geometry.type); expect(response).toEqual(SAMPLING_SITE_SPATIAL_TYPE.AREA); }); @@ -262,7 +262,7 @@ describe('getSamplingSiteSpatialType', () => { properties: {} }; - const response = getSamplingSiteSpatialType(feature); + const response = getSamplingSiteSpatialType(feature.geometry.type); expect(response).toEqual(SAMPLING_SITE_SPATIAL_TYPE.AREA); }); diff --git a/app/src/utils/spatial-utils.ts b/app/src/utils/spatial-utils.ts index 0b088e5cf5..13432db010 100644 --- a/app/src/utils/spatial-utils.ts +++ b/app/src/utils/spatial-utils.ts @@ -52,18 +52,16 @@ export const isGeoJsonPointFeature = (feature?: unknown): feature is Feature { - const geometryType = feature.geometry.type; - - if (['MultiLineString', 'LineString'].includes(geometryType)) { +export const getSamplingSiteSpatialType = (type: string): SAMPLING_SITE_SPATIAL_TYPE | null => { + if (['MultiLineString', 'LineString'].includes(type)) { return SAMPLING_SITE_SPATIAL_TYPE.TRANSECT; } - if (['Point', 'MultiPoint'].includes(geometryType)) { + if (['Point', 'MultiPoint'].includes(type)) { return SAMPLING_SITE_SPATIAL_TYPE.POINT; } - if (['Polygon', 'MultiPolygon'].includes(geometryType)) { + if (['Polygon', 'MultiPolygon'].includes(type)) { return SAMPLING_SITE_SPATIAL_TYPE.AREA; } diff --git a/database/src/migrations/20241023115300_observation_contraints.ts b/database/src/migrations/20241023115300_observation_contraints.ts new file mode 100644 index 0000000000..394ac6dd23 --- /dev/null +++ b/database/src/migrations/20241023115300_observation_contraints.ts @@ -0,0 +1,34 @@ +import { Knex } from 'knex'; + +/** + * Drops NOT NULL constraints on observation latitude, longitude, date and time. + * Observations can be valid without locations and timestamps. eg. A surveyor only measured the start and end of their + * sampling period, not the time of each observation made during that period. + * + * When interpreting the data, observations without locations/timestamps are assumed to inherit the location/time range of + * associated sampling records. + * + * @export + * @param {Knex} knex + * @return {*} {Promise} + */ +export async function up(knex: Knex): Promise { + await knex.raw(` + SET SEARCH_PATH=biohub; + + ALTER TABLE survey_observation + ALTER COLUMN latitude DROP NOT NULL, + ALTER COLUMN longitude DROP NOT NULL, + ALTER COLUMN observation_date DROP NOT NULL, + ALTER COLUMN observation_time DROP NOT NULL, + ADD CONSTRAINT survey_observation_date_check + CHECK (observation_date IS NOT NULL OR survey_sample_period_id IS NOT NULL), + ADD CONSTRAINT survey_observation_location_check + CHECK ((latitude IS NOT NULL AND longitude IS NOT NULL) OR survey_sample_period_id IS NOT NULL); + + `); +} + +export async function down(knex: Knex): Promise { + await knex.raw(``); +}