From 8f48b31c4de89517a3f7fd924b477e57722f7488 Mon Sep 17 00:00:00 2001 From: Macgregor Aubertin-Young <108430771+mauberti-bc@users.noreply.github.com> Date: Fri, 22 Nov 2024 15:46:24 -0800 Subject: [PATCH] UI: Adjust Sampling Data Container Styling (#1395) * Update survey sampling components * Update backend for fetching sites/periods * Remove techniques dataload from survey context * Add backend data model files for sampling tables --------- Co-authored-by: Nick Phura --- api/src/database-models/method_technique.ts | 37 ++ api/src/database-models/survey_block.ts | 38 ++ .../database-models/survey_sample_block.ts | 34 ++ .../database-models/survey_sample_method.ts | 36 ++ .../database-models/survey_sample_period.ts | 37 ++ api/src/database-models/survey_sample_site.ts | 38 ++ .../database-models/survey_sample_stratum.ts | 34 ++ api/src/database-models/survey_stratum.ts | 35 ++ .../survey/{surveyId}/technique/index.test.ts | 1 - .../survey/{surveyId}/technique/index.ts | 9 +- .../paths/sampling-locations/periods/index.ts | 145 ++++---- .../paths/sampling-locations/sites/index.ts | 82 ++++- .../sample-location-repository.test.ts | 2 +- .../sample-location-repository.ts | 230 ++++++++---- .../sample-location-repository/utils.ts | 70 +++- .../repositories/sample-method-repository.ts | 46 +-- .../repositories/sample-period-repository.ts | 53 +-- .../services/sample-location-service.test.ts | 6 +- api/src/services/sample-location-service.ts | 66 +++- .../services/sample-method-service.test.ts | 22 +- api/src/services/sample-method-service.ts | 18 +- .../services/sample-period-service.test.ts | 14 +- api/src/services/sample-period-service.ts | 22 +- .../chips/ColouredRectangleChip.tsx | 7 +- app/src/components/overlay/NoDataOverlay.tsx | 2 +- .../toolbar/CustomToggleButtonGroup.tsx | 62 ++++ app/src/contexts/surveyContext.tsx | 14 +- .../view/components/AccordionStandardCard.tsx | 44 +-- .../methods/SamplingMethodFormContainer.tsx | 14 +- .../methods/components/SamplingMethodForm.tsx | 14 +- .../periods/table/SamplingPeriodTable.tsx | 53 ++- .../sites/SamplingSiteContainer.tsx | 33 +- .../sites/edit/EditSamplingSitePage.tsx | 4 +- .../sites/table/SamplingSiteTable.tsx | 75 ++-- .../table/SamplingSiteTableContainer.tsx | 63 +++- .../techniques/SamplingTechniqueContainer.tsx | 37 +- .../techniques/create/CreateTechniquePage.tsx | 3 - .../techniques/edit/EditTechniquePage.tsx | 3 - .../table/SamplingTechniqueTable.tsx | 56 +-- .../surveys/view/SurveyDetails.test.tsx | 3 - .../surveys/view/SurveyHeader.test.tsx | 3 - .../SurveyGeneralInformation.test.tsx | 8 +- .../components/SurveyProprietaryData.test.tsx | 8 +- .../SurveyPurposeAndMethodologyData.test.tsx | 8 +- .../view/components/SurveyStudyArea.test.tsx | 22 +- .../analytics/SurveyObservationAnalytics.tsx | 23 +- ...ObservationAnalyticsDataTableContainer.tsx | 36 +- .../ObservationAnalyticsNoDataOverlay.tsx | 49 --- ...rvationsAnalyticsGridColumnDefinitions.tsx | 37 +- .../SurveyObservationTabularDataContainer.tsx | 2 +- .../SurveySamplingTableContainer.tsx | 330 +++++++++++------- .../components/period/SurveyPeriodsTable.tsx | 135 +++++++ .../components/site/SurveySitesTable.tsx | 27 +- .../SurveyTechniqueCardContainer.tsx | 98 ++++++ .../technique/SurveyTechniquesTable.tsx | 143 -------- .../components/SurveyTechniqueCard.tsx | 121 +++++++ .../TechniqueCardQualitativeAttributes.tsx | 55 +++ .../TechniqueCardQuantitativeAttribute.tsx | 54 +++ .../view/SurveySamplingViewTabs.tsx | 65 ---- app/src/hooks/api/useSamplingSiteApi.ts | 92 +++-- .../useSamplingSiteApi.interface.ts | 72 +++- .../interfaces/useTechniqueApi.interface.ts | 5 +- app/src/utils/datetime.ts | 11 +- 63 files changed, 2003 insertions(+), 963 deletions(-) create mode 100644 api/src/database-models/method_technique.ts create mode 100644 api/src/database-models/survey_block.ts create mode 100644 api/src/database-models/survey_sample_block.ts create mode 100644 api/src/database-models/survey_sample_method.ts create mode 100644 api/src/database-models/survey_sample_period.ts create mode 100644 api/src/database-models/survey_sample_site.ts create mode 100644 api/src/database-models/survey_sample_stratum.ts create mode 100644 api/src/database-models/survey_stratum.ts create mode 100644 app/src/components/toolbar/CustomToggleButtonGroup.tsx delete mode 100644 app/src/features/surveys/view/components/analytics/components/ObservationAnalyticsNoDataOverlay.tsx create mode 100644 app/src/features/surveys/view/components/sampling-data/components/period/SurveyPeriodsTable.tsx create mode 100644 app/src/features/surveys/view/components/sampling-data/components/technique/SurveyTechniqueCardContainer.tsx delete mode 100644 app/src/features/surveys/view/components/sampling-data/components/technique/SurveyTechniquesTable.tsx create mode 100644 app/src/features/surveys/view/components/sampling-data/components/technique/components/SurveyTechniqueCard.tsx create mode 100644 app/src/features/surveys/view/components/sampling-data/components/technique/components/qualitative/TechniqueCardQualitativeAttributes.tsx create mode 100644 app/src/features/surveys/view/components/sampling-data/components/technique/components/quantitative/TechniqueCardQuantitativeAttribute.tsx delete mode 100644 app/src/features/surveys/view/components/sampling-data/components/view/SurveySamplingViewTabs.tsx diff --git a/api/src/database-models/method_technique.ts b/api/src/database-models/method_technique.ts new file mode 100644 index 0000000000..e8288d351f --- /dev/null +++ b/api/src/database-models/method_technique.ts @@ -0,0 +1,37 @@ +import { z } from 'zod'; + +/** + * Method Technique Model. + * + * @description Data model for `method_technique`. + */ +export const MethodTechniqueModel = z.object({ + method_technique_id: z.number(), + survey_id: z.number(), + name: z.string(), + description: z.string().nullable(), + distance_threshold: z.number().nullable(), + method_lookup_id: z.number(), + create_date: z.string(), + create_user: z.number(), + update_date: z.string().nullable(), + update_user: z.number().nullable(), + revision_count: z.number() +}); + +export type MethodTechniqueModel = z.infer; + +/** + * Method Technique Record. + * + * @description Data record for `method_technique`. + */ +export const MethodTechniqueRecord = MethodTechniqueModel.omit({ + create_date: true, + create_user: true, + update_date: true, + update_user: true, + revision_count: true +}); + +export type MethodTechniqueRecord = z.infer; diff --git a/api/src/database-models/survey_block.ts b/api/src/database-models/survey_block.ts new file mode 100644 index 0000000000..1a7efd430b --- /dev/null +++ b/api/src/database-models/survey_block.ts @@ -0,0 +1,38 @@ +import { z } from 'zod'; + +/** + * Survey Block Model. + * + * @description Data model for `survey_block`. + */ +export const SurveyBlockModel = z.object({ + survey_block_id: z.number(), + survey_id: z.number(), + name: z.string().nullable(), + description: z.string().nullable(), + geometry: z.null(), + geography: z.string(), + geojson: z.any(), + create_date: z.string(), + create_user: z.number(), + update_date: z.string().nullable(), + update_user: z.number().nullable(), + revision_count: z.number() +}); + +export type SurveyBlockModel = z.infer; + +/** + * Survey Block Record. + * + * @description Data record for `survey_block`. + */ +export const SurveyBlockRecord = SurveyBlockModel.omit({ + create_date: true, + create_user: true, + update_date: true, + update_user: true, + revision_count: true +}); + +export type SurveyBlockRecord = z.infer; diff --git a/api/src/database-models/survey_sample_block.ts b/api/src/database-models/survey_sample_block.ts new file mode 100644 index 0000000000..6916659bf5 --- /dev/null +++ b/api/src/database-models/survey_sample_block.ts @@ -0,0 +1,34 @@ +import { z } from 'zod'; + +/** + * Survey Sample Block Model. + * + * @description Data model for `survey_sample_block`. + */ +export const SurveySampleBlockModel = z.object({ + survey_sample_block_id: z.number(), + survey_sample_site_id: z.number(), + survey_block_id: z.number(), + create_date: z.string(), + create_user: z.number(), + update_date: z.string().nullable(), + update_user: z.number().nullable(), + revision_count: z.number() +}); + +export type SurveySampleBlockModel = z.infer; + +/** + * Survey Sample Block Record. + * + * @description Data record for `survey_sample_block`. + */ +export const SurveySampleBlockRecord = SurveySampleBlockModel.omit({ + create_date: true, + create_user: true, + update_date: true, + update_user: true, + revision_count: true +}); + +export type SurveySampleBlockRecord = z.infer; diff --git a/api/src/database-models/survey_sample_method.ts b/api/src/database-models/survey_sample_method.ts new file mode 100644 index 0000000000..f83b693f78 --- /dev/null +++ b/api/src/database-models/survey_sample_method.ts @@ -0,0 +1,36 @@ +import { z } from 'zod'; + +/** + * Survey Sample Method Model. + * + * @description Data model for `survey_sample_method`. + */ +export const SurveySampleMethodModel = z.object({ + survey_sample_method_id: z.number(), + survey_sample_site_id: z.number(), + description: z.string().nullable(), + method_response_metric_id: z.number(), + method_technique_id: z.number(), + create_date: z.string(), + create_user: z.number(), + update_date: z.string().nullable(), + update_user: z.number().nullable(), + revision_count: z.number() +}); + +export type SurveySampleMethodModel = z.infer; + +/** + * Survey Sample Method Record. + * + * @description Data record for `survey_sample_method`. + */ +export const SurveySampleMethodRecord = SurveySampleMethodModel.omit({ + create_date: true, + create_user: true, + update_date: true, + update_user: true, + revision_count: true +}); + +export type SurveySampleMethodRecord = z.infer; diff --git a/api/src/database-models/survey_sample_period.ts b/api/src/database-models/survey_sample_period.ts new file mode 100644 index 0000000000..9801a579b5 --- /dev/null +++ b/api/src/database-models/survey_sample_period.ts @@ -0,0 +1,37 @@ +import { z } from 'zod'; + +/** + * Survey Sample Period Model. + * + * @description Data model for `survey_sample_period`. + */ +export const SurveySamplePeriodModel = z.object({ + survey_sample_period_id: z.number(), + survey_sample_method_id: z.number(), + start_date: z.string().nullable(), + end_date: z.string().nullable(), + start_time: z.string().nullable(), + end_time: z.string().nullable(), + create_date: z.string(), + create_user: z.number(), + update_date: z.string().nullable(), + update_user: z.number().nullable(), + revision_count: z.number() +}); + +export type SurveySamplePeriodModel = z.infer; + +/** + * Survey Sample Period Record. + * + * @description Data record for `survey_sample_period`. + */ +export const SurveySamplePeriodRecord = SurveySamplePeriodModel.omit({ + create_date: true, + create_user: true, + update_date: true, + update_user: true, + revision_count: true +}); + +export type SurveySamplePeriodRecord = z.infer; diff --git a/api/src/database-models/survey_sample_site.ts b/api/src/database-models/survey_sample_site.ts new file mode 100644 index 0000000000..0a6697b735 --- /dev/null +++ b/api/src/database-models/survey_sample_site.ts @@ -0,0 +1,38 @@ +import { z } from 'zod'; + +/** + * Survey Sample Site Model. + * + * @description Data model for `survey_sample_site`. + */ +export const SurveySampleSiteModel = z.object({ + survey_sample_site_id: z.number(), + survey_id: z.number(), + name: z.string(), + description: z.string().nullable(), + geometry: z.null(), + geography: z.string(), + geojson: z.any(), + create_date: z.string(), + create_user: z.number(), + update_date: z.string().nullable(), + update_user: z.number().nullable(), + revision_count: z.number() +}); + +export type SurveySampleSiteModel = z.infer; + +/** + * Survey Sample Site Record. + * + * @description Data record for `survey_sample_site`. + */ +export const SurveySampleSiteRecord = SurveySampleSiteModel.omit({ + create_date: true, + create_user: true, + update_date: true, + update_user: true, + revision_count: true +}); + +export type SurveySampleSiteRecord = z.infer; diff --git a/api/src/database-models/survey_sample_stratum.ts b/api/src/database-models/survey_sample_stratum.ts new file mode 100644 index 0000000000..9274a6a74c --- /dev/null +++ b/api/src/database-models/survey_sample_stratum.ts @@ -0,0 +1,34 @@ +import { z } from 'zod'; + +/** + * Survey Sample Stratum Model. + * + * @description Data model for `survey_sample_stratum`. + */ +export const SurveySampleStratumModel = z.object({ + survey_sample_stratum_id: z.number(), + survey_sample_site_id: z.number(), + survey_stratum_id: z.number(), + create_date: z.string(), + create_user: z.number(), + update_date: z.string().nullable(), + update_user: z.number().nullable(), + revision_count: z.number() +}); + +export type SurveySampleStratumModel = z.infer; + +/** + * Survey Sample Stratum Record. + * + * @description Data record for `survey_sample_stratum`. + */ +export const SurveySampleStratumRecord = SurveySampleStratumModel.omit({ + create_date: true, + create_user: true, + update_date: true, + update_user: true, + revision_count: true +}); + +export type SurveySampleStratumRecord = z.infer; diff --git a/api/src/database-models/survey_stratum.ts b/api/src/database-models/survey_stratum.ts new file mode 100644 index 0000000000..6260431e80 --- /dev/null +++ b/api/src/database-models/survey_stratum.ts @@ -0,0 +1,35 @@ +import { z } from 'zod'; + +/** + * Survey Stratum Model. + * + * @description Data model for `survey_stratum`. + */ +export const SurveyStratumModel = z.object({ + survey_stratum_id: z.number(), + survey_id: z.number(), + name: z.string(), + description: z.string(), + create_date: z.string(), + create_user: z.number(), + update_date: z.string().nullable(), + update_user: z.number().nullable(), + revision_count: z.number() +}); + +export type SurveyStratumModel = z.infer; + +/** + * Survey Stratum Record. + * + * @description Data record for `survey_stratum`. + */ +export const SurveyStratumRecord = SurveyStratumModel.omit({ + create_date: true, + create_user: true, + update_date: true, + update_user: true, + revision_count: true +}); + +export type SurveyStratumRecord = z.infer; diff --git a/api/src/paths/project/{projectId}/survey/{surveyId}/technique/index.test.ts b/api/src/paths/project/{projectId}/survey/{surveyId}/technique/index.test.ts index c02f2a1b17..4caa4f5864 100644 --- a/api/src/paths/project/{projectId}/survey/{surveyId}/technique/index.test.ts +++ b/api/src/paths/project/{projectId}/survey/{surveyId}/technique/index.test.ts @@ -253,7 +253,6 @@ describe('getTechniques', () => { expect(mockRes.jsonValue).to.eql({ techniques: [techniqueRecord], - count: 1, pagination: { total: 1, per_page: 1, diff --git a/api/src/paths/project/{projectId}/survey/{surveyId}/technique/index.ts b/api/src/paths/project/{projectId}/survey/{surveyId}/technique/index.ts index 9d66e73cfc..2862f31236 100644 --- a/api/src/paths/project/{projectId}/survey/{surveyId}/technique/index.ts +++ b/api/src/paths/project/{projectId}/survey/{surveyId}/technique/index.ts @@ -193,18 +193,14 @@ GET.apiDoc = { 'application/json': { schema: { type: 'object', - required: ['techniques', 'count'], + required: ['techniques', 'pagination'], additionalProperties: false, properties: { techniques: { type: 'array', items: techniqueViewSchema }, - count: { - type: 'number', - description: 'Count of method techniques in the respective survey.' - }, - pagination: { ...paginationResponseSchema } + pagination: paginationResponseSchema } } } @@ -254,7 +250,6 @@ export function getTechniques(): RequestHandler { return res.status(200).json({ techniques, - count: techniquesCount, pagination: makePaginationResponse(techniquesCount, paginationOptions) }); } catch (error) { diff --git a/api/src/paths/sampling-locations/periods/index.ts b/api/src/paths/sampling-locations/periods/index.ts index 1ea52eb5df..e0553c2a2e 100644 --- a/api/src/paths/sampling-locations/periods/index.ts +++ b/api/src/paths/sampling-locations/periods/index.ts @@ -3,11 +3,15 @@ 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 { paginationRequestQueryParamSchema, paginationResponseSchema } 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 { + ensureCompletePaginationOptions, + makePaginationOptionsFromRequest, + makePaginationResponse +} from '../../../utils/pagination'; import { getSystemUserFromRequest } from '../../../utils/request'; const defaultLog = getLogger('paths/period/index'); @@ -36,84 +40,31 @@ GET.apiDoc = { 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', + name: 'survey_id', required: false, schema: { type: 'integer', + minimum: 1, 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', + name: 'sample_site_id', required: false, schema: { - type: 'string', + type: 'integer', + minimum: 1, nullable: true } }, { in: 'query', - name: 'min_count', - description: 'Minimum period count (inclusive).', + name: 'sample_method_id', required: false, schema: { - type: 'number', - minimum: 0, + type: 'integer', + minimum: 1, nullable: true } }, @@ -136,7 +87,7 @@ GET.apiDoc = { 'application/json': { schema: { type: 'object', - required: ['periods'], + required: ['periods', 'pagination'], additionalProperties: false, properties: { periods: { @@ -149,7 +100,10 @@ GET.apiDoc = { 'start_date', 'start_time', 'end_date', - 'end_time' + 'end_time', + 'sample_method', + 'method_technique', + 'sample_site' ], additionalProperties: false, properties: { @@ -174,10 +128,50 @@ GET.apiDoc = { end_time: { type: 'string', nullable: true + }, + sample_method: { + type: 'object', + required: ['method_response_metric_id'], + additionalProperties: false, + properties: { + method_response_metric_id: { + type: 'integer', + minimum: 1 + } + } + }, + method_technique: { + type: 'object', + required: ['method_technique_id', 'name'], + additionalProperties: false, + properties: { + method_technique_id: { + type: 'integer', + minimum: 1 + }, + name: { + type: 'string' + } + } + }, + sample_site: { + type: 'object', + required: ['survey_sample_site_id', 'name'], + additionalProperties: false, + properties: { + survey_sample_site_id: { + type: 'integer', + minimum: 1 + }, + name: { + type: 'string' + } + } } } } - } + }, + pagination: paginationResponseSchema } } } @@ -230,18 +224,21 @@ export function findPeriods(): RequestHandler { const sampleLocationService = new SampleLocationService(connection); - const periods = await sampleLocationService.findPeriods( - isUserAdmin, - systemUserId, - filterFields, - ensureCompletePaginationOptions(paginationOptions) - ); + const [periods, periodsCount] = await Promise.all([ + sampleLocationService.findPeriods( + isUserAdmin, + systemUserId, + filterFields, + ensureCompletePaginationOptions(paginationOptions) + ), + sampleLocationService.findPeriodsCount(isUserAdmin, systemUserId, filterFields) + ]); await connection.commit(); const response = { - periods: periods - // TODO NICK add count and pagination to response and openapi schema? + periods: periods, + pagination: makePaginationResponse(periodsCount, paginationOptions) }; // Allow browsers to cache this response for 30 seconds diff --git a/api/src/paths/sampling-locations/sites/index.ts b/api/src/paths/sampling-locations/sites/index.ts index 295e6475a1..14db25a686 100644 --- a/api/src/paths/sampling-locations/sites/index.ts +++ b/api/src/paths/sampling-locations/sites/index.ts @@ -3,11 +3,15 @@ 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 { paginationRequestQueryParamSchema, paginationResponseSchema } 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 { + ensureCompletePaginationOptions, + makePaginationOptionsFromRequest, + makePaginationResponse +} from '../../../utils/pagination'; import { getSystemUserFromRequest } from '../../../utils/request'; const defaultLog = getLogger('paths/site/index'); @@ -98,10 +102,61 @@ GET.apiDoc = { }, geometry_type: { type: 'string' + }, + 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' + } + } + } } } } - } + }, + pagination: paginationResponseSchema } } } @@ -154,18 +209,21 @@ export function findSites(): RequestHandler { const sampleLocationService = new SampleLocationService(connection); - const sites = await sampleLocationService.findSites( - isUserAdmin, - systemUserId, - filterFields, - ensureCompletePaginationOptions(paginationOptions) - ); + const [sites, sitesCount] = await Promise.all([ + sampleLocationService.findSites( + isUserAdmin, + systemUserId, + filterFields, + ensureCompletePaginationOptions(paginationOptions) + ), + sampleLocationService.findSitesCount(isUserAdmin, systemUserId, filterFields) + ]); await connection.commit(); const response = { - sites: sites - // TODO NICK add count and pagination to response and openapi schema? + sites: sites, + pagination: makePaginationResponse(sitesCount, paginationOptions) }; // Allow browsers to cache this response for 30 seconds @@ -173,7 +231,7 @@ export function findSites(): RequestHandler { return res.status(200).json(response); } catch (error) { - defaultLog.error({ label: 'getSites', message: 'error', error }); + defaultLog.error({ label: 'findSites', message: 'error', error }); await connection.rollback(); throw error; } finally { diff --git a/api/src/repositories/sample-location-repository/sample-location-repository.test.ts b/api/src/repositories/sample-location-repository/sample-location-repository.test.ts index 06ad389054..e1cbae4866 100644 --- a/api/src/repositories/sample-location-repository/sample-location-repository.test.ts +++ b/api/src/repositories/sample-location-repository/sample-location-repository.test.ts @@ -220,7 +220,7 @@ describe('SampleLocationRepository', () => { await repo.deleteSampleSiteRecord(mockSurveyId, surveySampleLocationId); } catch (error) { expect(dbConnectionObj.sql).to.have.been.calledOnce; - expect((error as ApiExecuteSQLError).message).to.be.eql('Failed to delete survey block record'); + expect((error as ApiExecuteSQLError).message).to.be.eql('Failed to delete survey sample site record'); } }); }); diff --git a/api/src/repositories/sample-location-repository/sample-location-repository.ts b/api/src/repositories/sample-location-repository/sample-location-repository.ts index 6770d0cf49..c4c3fb5fdb 100644 --- a/api/src/repositories/sample-location-repository/sample-location-repository.ts +++ b/api/src/repositories/sample-location-repository/sample-location-repository.ts @@ -1,6 +1,14 @@ import { Feature } from 'geojson'; import SQL from 'sql-template-strings'; import { z } from 'zod'; +import { MethodTechniqueRecord } from '../../database-models/method_technique'; +import { SurveyBlockRecord } from '../../database-models/survey_block'; +import { SurveySampleBlockRecord } from '../../database-models/survey_sample_block'; +import { SurveySampleMethodRecord } from '../../database-models/survey_sample_method'; +import { SurveySamplePeriodRecord } from '../../database-models/survey_sample_period'; +import { SurveySampleSiteModel, SurveySampleSiteRecord } from '../../database-models/survey_sample_site'; +import { SurveySampleStratumRecord } from '../../database-models/survey_sample_stratum'; +import { SurveyStratumRecord } from '../../database-models/survey_stratum'; import { getKnex } from '../../database/db'; import { ApiExecuteSQLError } from '../../errors/api-error'; import { @@ -12,8 +20,7 @@ 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 { UpdateSampleMethodRecord } from '../sample-method-repository'; import { SampleStratumRecord, UpdateSampleStratumRecord } from '../sample-stratums-repository'; import { getSamplingLocationBaseQuery, @@ -34,7 +41,7 @@ export const SampleLocationNonSpatialRecord = z.object({ description: z.string().nullable(), geometry_type: z.string(), sample_methods: z.array( - SampleMethodRecord.pick({ + SurveySampleMethodRecord.pick({ survey_sample_method_id: true, survey_sample_site_id: true, description: true, @@ -52,7 +59,7 @@ export const SampleLocationNonSpatialRecord = z.object({ ) }), sample_periods: z.array( - SamplePeriodRecord.pick({ + SurveySamplePeriodRecord.pick({ survey_sample_period_id: true, survey_sample_method_id: true, start_date: true, @@ -94,7 +101,7 @@ export const SampleLocationBasicRecord = z.object({ survey_sample_site_id: z.number(), name: z.string(), sample_methods: z.array( - SampleMethodRecord.pick({ + SurveySampleMethodRecord.pick({ survey_sample_method_id: true, survey_sample_site_id: true, method_response_metric_id: true @@ -105,7 +112,7 @@ export const SampleLocationBasicRecord = z.object({ name: z.string() }), sample_periods: z.array( - SamplePeriodRecord.pick({ + SurveySamplePeriodRecord.pick({ survey_sample_period_id: true, survey_sample_method_id: true, start_date: true, @@ -138,37 +145,82 @@ export const SampleSiteGeometryRecord = z.object({ }); 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(), - geometry: z.null(), - geography: z.any(), - geojson: z.any(), - create_date: z.string(), - create_user: z.number(), - update_date: z.string().nullable(), - update_user: z.number().nullable(), - revision_count: z.number() -}); -export type SampleSiteRecord = z.infer; - /** * Insert object for a single sample site record. */ -export type InsertSampleSiteRecord = Pick; +export type InsertSampleSiteRecord = Pick; /** * Update object for a single sample site record. */ export type UpdateSampleSiteRecord = Pick< - SampleSiteRecord, + SurveySampleSiteRecord, 'survey_sample_site_id' | 'survey_id' | 'name' | 'description' | 'geojson' >; +export const FindSampleSiteRecord = SurveySampleSiteRecord.pick({ + survey_sample_site_id: true, + survey_id: true, + name: true, + description: true +}).extend({ + geometry_type: z.string(), + blocks: z.array( + SurveySampleBlockRecord.pick({ + survey_sample_block_id: true, + survey_sample_site_id: true, + survey_block_id: true + }).merge( + SurveyBlockRecord.pick({ + name: true, + description: true + }) + ) + ), + stratums: z.array( + SurveySampleStratumRecord.pick({ + survey_sample_stratum_id: true, + survey_sample_site_id: true, + survey_stratum_id: true + }).merge( + SurveyStratumRecord.pick({ + name: true, + description: true + }) + ) + ) +}); + +export type FindSampleSiteRecord = z.infer; + +export const FindSamplePeriodRecord = SurveySamplePeriodRecord.pick({ + survey_sample_period_id: true, + survey_sample_method_id: true, + start_date: true, + start_time: true, + end_date: true, + end_time: true +}) + .extend({ + sample_method: SurveySampleMethodRecord.pick({ + method_response_metric_id: true + }) + }) + .extend({ + method_technique: MethodTechniqueRecord.pick({ + method_technique_id: true, + name: true + }) + }) + .extend({ + sample_site: SurveySampleSiteRecord.pick({ + survey_sample_site_id: true, + name: true + }) + }); + +export type FindSamplePeriodRecord = z.infer; + /** * Update object for a sample site record, including all associated methods and periods. */ @@ -390,10 +442,10 @@ export class SampleLocationRepository extends BaseRepository { * * @param {number} surveyId * @param {number} surveySampleSiteId - * @return {*} {Promise} + * @return {*} {Promise} * @memberof SampleLocationService */ - async getSurveySampleSiteById(surveyId: number, surveySampleSiteId: number): Promise { + async getSurveySampleSiteById(surveyId: number, surveySampleSiteId: number): Promise { const sqlStatement = SQL` SELECT sss.* @@ -405,7 +457,7 @@ export class SampleLocationRepository extends BaseRepository { sss.survey_sample_site_id = ${surveySampleSiteId} `; - const response = await this.connection.sql(sqlStatement, SampleSiteRecord); + const response = await this.connection.sql(sqlStatement, SurveySampleSiteModel); if (!response.rowCount) { throw new ApiExecuteSQLError('Failed to get sample site by ID', [ @@ -544,20 +596,23 @@ export class SampleLocationRepository extends BaseRepository { return response.rows; } - /** Retrieve the list of sites that the user has access to, based on filters and pagination options. + /** + * 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 {ISiteAdvancedFilters} filterFields The filter fields to apply. * @param {ApiPaginationOptions} [pagination] The pagination options. * @return {Promise} A promise resolving to the list of sites. + * @return {*} {Promise} + * @memberof SampleLocationRepository */ async findSites( isUserAdmin: boolean, systemUserId: number | null, filterFields: ISiteAdvancedFilters, pagination?: ApiPaginationOptions - ) { + ): Promise { const query = makeFindSamplingSiteBaseQuery(isUserAdmin, systemUserId, filterFields); if (pagination) { @@ -568,27 +623,45 @@ export class SampleLocationRepository extends BaseRepository { } } - 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() - }) - ); + const response = await this.connection.knex(query, FindSampleSiteRecord); return response.rows; } - /** Retrieve the list of methods that the user has access to, based on filters and pagination options. + /** + * Retrieve the count 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 {ISiteAdvancedFilters} filterFields The filter fields to apply. + * @return {*} {Promise} + * @memberof SampleLocationRepository + */ + async findSitesCount( + isUserAdmin: boolean, + systemUserId: number | null, + filterFields: ISiteAdvancedFilters + ): Promise { + const knex = getKnex(); + + const findSitesQuery = makeFindSamplingSiteBaseQuery(isUserAdmin, systemUserId, filterFields); + + const query = knex.from(findSitesQuery.as('fsq')).select(knex.raw('count(*)::integer as count')); + + const response = await this.connection.knex(query, z.object({ count: z.number() })); + + return response.rows[0].count; + } + + /** + * 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 {ISiteAdvancedFilters} filterFields The filter fields to apply. * @param {ApiPaginationOptions} [pagination] The pagination options. - * @return {Promise} A promise resolving to the list of methods. + * @return {*} + * @memberof SampleLocationRepository */ async findMethods( isUserAdmin: boolean, @@ -629,20 +702,22 @@ export class SampleLocationRepository extends BaseRepository { return response.rows; } - /** Retrieve the list of periods that the user has access to, based on filters and pagination options. + /** + * 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 {ISiteAdvancedFilters} filterFields The filter fields to apply. * @param {ApiPaginationOptions} [pagination] The pagination options. - * @return {Promise} A promise resolving to the list of periods. + * @return {*} {Promise} + * @memberof SampleLocationRepository */ async findPeriods( isUserAdmin: boolean, systemUserId: number | null, filterFields: IPeriodAdvancedFilters, pagination?: ApiPaginationOptions - ) { + ): Promise { const query = makeFindSamplingPeriodBaseQuery(isUserAdmin, systemUserId, filterFields); if (pagination) { @@ -653,29 +728,44 @@ export class SampleLocationRepository extends BaseRepository { } } - 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() - }) - ); + const response = await this.connection.knex(query, FindSamplePeriodRecord); return response.rows; } + /** + * Retrieve the count 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 {ISiteAdvancedFilters} filterFields The filter fields to apply. + * @return {*} {Promise} + * @memberof SampleLocationRepository + */ + async findPeriodsCount( + isUserAdmin: boolean, + systemUserId: number | null, + filterFields: IPeriodAdvancedFilters + ): Promise { + const knex = getKnex(); + + const findPeriodsQuery = makeFindSamplingPeriodBaseQuery(isUserAdmin, systemUserId, filterFields); + + const query = knex.from(findPeriodsQuery.as('fpq')).select(knex.raw('count(*)::integer as count')); + + const response = await this.connection.knex(query, z.object({ count: z.number() })); + + return response.rows[0].count; + } + /** * Updates a survey sample site record. * * @param {UpdateSampleSiteRecord} sample - * @return {*} {Promise} + * @return {*} {Promise} * @memberof SampleLocationRepository */ - async updateSampleSite(sample: UpdateSampleSiteRecord): Promise { + async updateSampleSite(sample: UpdateSampleSiteRecord): Promise { const sql = SQL` UPDATE survey_sample_site @@ -697,7 +787,7 @@ export class SampleLocationRepository extends BaseRepository { RETURNING *;`); - const response = await this.connection.sql(sql, SampleSiteRecord); + const response = await this.connection.sql(sql, SurveySampleSiteModel); if (!response.rowCount) { throw new ApiExecuteSQLError('Failed to update sample location record', [ @@ -717,10 +807,10 @@ export class SampleLocationRepository extends BaseRepository { * * @param {number} surveyId * @param {InsertSampleSiteRecord} sampleSite - * @return {*} {Promise} + * @return {*} {Promise} * @memberof SampleLocationRepository */ - async insertSampleSite(surveyId: number, sampleSite: InsertSampleSiteRecord): Promise { + async insertSampleSite(surveyId: number, sampleSite: InsertSampleSiteRecord): Promise { const sqlStatement = SQL` INSERT INTO survey_sample_site ( survey_id, @@ -754,7 +844,7 @@ export class SampleLocationRepository extends BaseRepository { *; `); - const response = await this.connection.sql(sqlStatement, SampleSiteRecord); + const response = await this.connection.sql(sqlStatement, SurveySampleSiteModel); if (!response.rowCount) { throw new ApiExecuteSQLError('Failed to insert sample location', [ @@ -771,10 +861,10 @@ export class SampleLocationRepository extends BaseRepository { * * @param {number} surveyId * @param {number} surveySampleSiteId - * @return {*} {Promise} + * @return {*} {Promise} * @memberof SampleLocationRepository */ - async deleteSampleSiteRecord(surveyId: number, surveySampleSiteId: number): Promise { + async deleteSampleSiteRecord(surveyId: number, surveySampleSiteId: number): Promise { const sqlStatement = SQL` DELETE FROM survey_sample_site @@ -786,10 +876,10 @@ export class SampleLocationRepository extends BaseRepository { *; `; - const response = await this.connection.sql(sqlStatement, SampleSiteRecord); + const response = await this.connection.sql(sqlStatement, SurveySampleSiteModel); if (!response?.rowCount) { - throw new ApiExecuteSQLError('Failed to delete survey block record', [ + throw new ApiExecuteSQLError('Failed to delete survey sample site record', [ 'SampleLocationRepository->deleteSampleSiteRecord', 'rows was null or undefined, expected rows != null' ]); diff --git a/api/src/repositories/sample-location-repository/utils.ts b/api/src/repositories/sample-location-repository/utils.ts index 9b07ffae08..4b5dd8de1b 100644 --- a/api/src/repositories/sample-location-repository/utils.ts +++ b/api/src/repositories/sample-location-repository/utils.ts @@ -142,14 +142,53 @@ export function getSamplingSiteBaseQuery(queryBuilder: Knex.QueryBuilder): Knex. const knex = getKnex(); queryBuilder + .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'); + }) .select( 'sss.survey_sample_site_id', 'sss.survey_id', 'sss.name', 'sss.description', - knex.raw(`sss.geojson->'geometry'->>'type' as geometry_type`) + knex.raw(`sss.geojson->'geometry'->>'type' as geometry_type`), + knex.raw(` + COALESCE(wssb.blocks, '[]'::json) as blocks, + COALESCE(wssst.stratums, '[]'::json) as stratums`) ) - .from({ sss: 'survey_sample_site' }); + .from({ sss: 'survey_sample_site' }) + .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'); return queryBuilder; } @@ -337,6 +376,8 @@ export function makeFindSamplingMethodBaseQuery( * @return {*} {Knex.QueryBuilder} The base query for retrieving survey sample periods */ export function getSamplingPeriodBaseQuery(queryBuilder: Knex.QueryBuilder): Knex.QueryBuilder { + const knex = getKnex(); + queryBuilder .select( 'ssp.survey_sample_period_id', @@ -344,9 +385,26 @@ export function getSamplingPeriodBaseQuery(queryBuilder: Knex.QueryBuilder): Kne 'ssp.start_date', 'ssp.start_time', 'ssp.end_date', - 'ssp.end_time' + 'ssp.end_time', + knex.raw(` + json_build_object( + 'method_response_metric_id', ssm.method_response_metric_id + ) as sample_method`), + knex.raw(` + json_build_object( + 'method_technique_id', mt.method_technique_id, + 'name', mt.name + ) as method_technique`), + knex.raw(` + json_build_object( + 'survey_sample_site_id', sss.survey_sample_site_id, + 'name', sss.name + ) as sample_site`) ) - .from({ ssp: 'survey_sample_period' }); + .from({ ssp: 'survey_sample_period' }) + .join('survey_sample_method as ssm', 'ssm.survey_sample_method_id', 'ssp.survey_sample_method_id') + .join('method_technique as mt', 'mt.method_technique_id', 'ssm.method_technique_id') + .join('survey_sample_site as sss', 'sss.survey_sample_site_id', 'ssm.survey_sample_site_id'); return queryBuilder; } @@ -392,11 +450,11 @@ export function makeFindSamplingPeriodBaseQuery( getSamplingPeriodsQuery.modify(getSamplingPeriodBaseQuery); // Filter by the survey ids the user has access to - getSamplingPeriodsQuery.whereIn('ssp.survey_id', getSurveyIdsQuery); + getSamplingPeriodsQuery.whereIn('sss.survey_id', getSurveyIdsQuery); if (filterFields.survey_id) { // Filter by a specific survey id - getSamplingPeriodsQuery.andWhere('ssp.survey_id', filterFields.survey_id); + getSamplingPeriodsQuery.andWhere('sss.survey_id', filterFields.survey_id); } if (filterFields.sample_site_id) { diff --git a/api/src/repositories/sample-method-repository.ts b/api/src/repositories/sample-method-repository.ts index 882044c70b..406c271f01 100644 --- a/api/src/repositories/sample-method-repository.ts +++ b/api/src/repositories/sample-method-repository.ts @@ -1,5 +1,6 @@ import SQL from 'sql-template-strings'; import { z } from 'zod'; +import { SurveySampleMethodModel, SurveySampleMethodRecord } from '../database-models/survey_sample_method'; import { getKnex } from '../database/db'; import { ApiExecuteSQLError } from '../errors/api-error'; import { BaseRepository } from './base-repository'; @@ -9,7 +10,7 @@ import { InsertSamplePeriodRecord, UpdateSamplePeriodRecord } from './sample-per * Insert object for a single sample method record. */ export type InsertSampleMethodRecord = Pick< - SampleMethodRecord, + SurveySampleMethodRecord, 'survey_sample_site_id' | 'method_technique_id' | 'description' | 'method_response_metric_id' > & { sample_periods: InsertSamplePeriodRecord[] }; @@ -17,7 +18,7 @@ export type InsertSampleMethodRecord = Pick< * Update object for a single sample method record. */ export type UpdateSampleMethodRecord = Pick< - SampleMethodRecord, + SurveySampleMethodRecord, | 'survey_sample_method_id' | 'survey_sample_site_id' | 'method_technique_id' @@ -25,27 +26,10 @@ export type UpdateSampleMethodRecord = Pick< | 'method_response_metric_id' > & { sample_periods: UpdateSamplePeriodRecord[] }; -/** - * A survey_sample_method record. - */ -export const SampleMethodRecord = z.object({ - survey_sample_method_id: z.number(), - survey_sample_site_id: z.number(), - method_technique_id: z.number(), - method_response_metric_id: z.number(), - description: z.string(), - create_date: z.string(), - create_user: z.number(), - update_date: z.string().nullable(), - update_user: z.number().nullable(), - revision_count: z.number() -}); -export type SampleMethodRecord = z.infer; - /** * A survey_sample_method detail object. */ -export const SampleMethodDetails = SampleMethodRecord.extend({ +export const SampleMethodDetails = SurveySampleMethodModel.extend({ technique: z.object({ method_technique_id: z.number(), name: z.string(), @@ -67,13 +51,13 @@ export class SampleMethodRepository extends BaseRepository { * * @param {number} surveyId * @param {number} surveySampleSiteId - * @return {*} {Promise} + * @return {*} {Promise} * @memberof SampleMethodRepository */ async getSampleMethodsForSurveySampleSiteId( surveyId: number, surveySampleSiteId: number - ): Promise { + ): Promise { const sql = SQL` SELECT * @@ -94,7 +78,7 @@ export class SampleMethodRepository extends BaseRepository { ; `; - const response = await this.connection.sql(sql, SampleMethodRecord); + const response = await this.connection.sql(sql, SurveySampleMethodModel); return response.rows; } @@ -122,10 +106,10 @@ export class SampleMethodRepository extends BaseRepository { * updates a survey Sample method. * * @param {UpdateSampleMethodRecord} sampleMethod - * @return {*} {Promise} + * @return {*} {Promise} * @memberof SampleMethodRepository */ - async updateSampleMethod(surveyId: number, sampleMethod: UpdateSampleMethodRecord): Promise { + async updateSampleMethod(surveyId: number, sampleMethod: UpdateSampleMethodRecord): Promise { const sql = SQL` UPDATE survey_sample_method ssm SET @@ -158,10 +142,10 @@ export class SampleMethodRepository extends BaseRepository { * Inserts a new survey Sample method. * * @param {InsertSampleMethodRecord} sampleMethod - * @return {*} {Promise} + * @return {*} {Promise} * @memberof SampleMethodRepository */ - async insertSampleMethod(sampleMethod: InsertSampleMethodRecord): Promise { + async insertSampleMethod(sampleMethod: InsertSampleMethodRecord): Promise { const sqlStatement = SQL` INSERT INTO survey_sample_method ( survey_sample_site_id, @@ -178,7 +162,7 @@ export class SampleMethodRepository extends BaseRepository { *; `; - const response = await this.connection.sql(sqlStatement, SampleMethodRecord); + const response = await this.connection.sql(sqlStatement, SurveySampleMethodModel); if (!response.rowCount) { throw new ApiExecuteSQLError('Failed to insert sample method', [ @@ -195,10 +179,10 @@ export class SampleMethodRepository extends BaseRepository { * * @param {number} surveyId * @param {number} surveySampleMethodId - * @return {*} {Promise} + * @return {*} {Promise} * @memberof SampleMethodRepository */ - async deleteSampleMethodRecord(surveyId: number, surveySampleMethodId: number): Promise { + async deleteSampleMethodRecord(surveyId: number, surveySampleMethodId: number): Promise { const sqlStatement = SQL` DELETE FROM survey_sample_method USING survey_sample_site sss @@ -209,7 +193,7 @@ export class SampleMethodRepository extends BaseRepository { RETURNING survey_sample_method.*; `; - const response = await this.connection.sql(sqlStatement, SampleMethodRecord); + const response = await this.connection.sql(sqlStatement, SurveySampleMethodModel); if (!response.rowCount) { throw new ApiExecuteSQLError('Failed to delete sample method', [ diff --git a/api/src/repositories/sample-period-repository.ts b/api/src/repositories/sample-period-repository.ts index 15279a85aa..2c95683688 100644 --- a/api/src/repositories/sample-period-repository.ts +++ b/api/src/repositories/sample-period-repository.ts @@ -1,5 +1,6 @@ import SQL from 'sql-template-strings'; import { z } from 'zod'; +import { SurveySamplePeriodModel, SurveySamplePeriodRecord } from '../database-models/survey_sample_period'; import { getKnex } from '../database/db'; import { ApiExecuteSQLError } from '../errors/api-error'; import { BaseRepository } from './base-repository'; @@ -8,7 +9,7 @@ import { BaseRepository } from './base-repository'; * Insert object for a single sample period record. */ export type InsertSamplePeriodRecord = Pick< - SamplePeriodRecord, + SurveySamplePeriodRecord, 'survey_sample_method_id' | 'start_date' | 'end_date' | 'start_time' | 'end_time' >; @@ -16,28 +17,10 @@ export type InsertSamplePeriodRecord = Pick< * Update object for a single sample period record. */ export type UpdateSamplePeriodRecord = Pick< - SamplePeriodRecord, + SurveySamplePeriodRecord, 'survey_sample_period_id' | 'survey_sample_method_id' | 'start_date' | 'end_date' | 'start_time' | 'end_time' >; -/** - * A survey_sample_period record. - */ -export const SamplePeriodRecord = z.object({ - survey_sample_period_id: z.number(), - survey_sample_method_id: z.number(), - start_date: z.string(), - end_date: z.string(), - start_time: z.string().nullable(), - end_time: z.string().nullable(), - create_date: z.string(), - create_user: z.number(), - update_date: z.string().nullable(), - update_user: z.number().nullable(), - revision_count: z.number() -}); -export type SamplePeriodRecord = z.infer; - /** * The full hierarchy of sample_* ids for a sample period. */ @@ -61,13 +44,13 @@ export class SamplePeriodRepository extends BaseRepository { * * @param {number} surveyId * @param {number} surveySampleMethodId - * @return {*} {Promise} + * @return {*} {Promise} * @memberof SamplePeriodRepository */ async getSamplePeriodsForSurveyMethodId( surveyId: number, surveySampleMethodId: number - ): Promise { + ): Promise { const sql = SQL` SELECT ssp.* @@ -87,7 +70,7 @@ export class SamplePeriodRepository extends BaseRepository { sss.survey_id = ${surveyId} ORDER BY ssp.start_date, ssp.start_time;`; - const response = await this.connection.sql(sql, SamplePeriodRecord); + const response = await this.connection.sql(sql, SurveySamplePeriodModel); return response.rows; } @@ -140,10 +123,10 @@ export class SamplePeriodRepository extends BaseRepository { * * @param {number} surveyId * @param {UpdateSamplePeriodRecord} samplePeriod - * @return {*} {Promise} + * @return {*} {Promise} * @memberof SamplePeriodRepository */ - async updateSamplePeriod(surveyId: number, samplePeriod: UpdateSamplePeriodRecord): Promise { + async updateSamplePeriod(surveyId: number, samplePeriod: UpdateSamplePeriodRecord): Promise { const sql = SQL` UPDATE survey_sample_period AS ssp SET @@ -167,7 +150,7 @@ export class SamplePeriodRepository extends BaseRepository { `; - const response = await this.connection.sql(sql, SamplePeriodRecord); + const response = await this.connection.sql(sql, SurveySamplePeriodModel); if (!response.rowCount) { throw new ApiExecuteSQLError('Failed to update sample period', [ @@ -183,10 +166,10 @@ export class SamplePeriodRepository extends BaseRepository { * Inserts a new survey Sample Period. * * @param {InsertSamplePeriodRecord} sample - * @return {*} {Promise} + * @return {*} {Promise} * @memberof SamplePeriodRepository */ - async insertSamplePeriod(sample: InsertSamplePeriodRecord): Promise { + async insertSamplePeriod(sample: InsertSamplePeriodRecord): Promise { const sqlStatement = SQL` INSERT INTO survey_sample_period ( survey_sample_method_id, @@ -204,7 +187,7 @@ export class SamplePeriodRepository extends BaseRepository { RETURNING *;`; - const response = await this.connection.sql(sqlStatement, SamplePeriodRecord); + const response = await this.connection.sql(sqlStatement, SurveySamplePeriodModel); if (!response.rowCount) { throw new ApiExecuteSQLError('Failed to insert sample period', [ @@ -221,10 +204,10 @@ export class SamplePeriodRepository extends BaseRepository { * * @param {number} surveyId * @param {number} surveySamplePeriodId - * @return {*} {Promise} + * @return {*} {Promise} * @memberof SamplePeriodRepository */ - async deleteSamplePeriodRecord(surveyId: number, surveySamplePeriodId: number): Promise { + async deleteSamplePeriodRecord(surveyId: number, surveySamplePeriodId: number): Promise { const sqlStatement = SQL` DELETE ssp @@ -245,7 +228,7 @@ export class SamplePeriodRepository extends BaseRepository { ; `; - const response = await this.connection.sql(sqlStatement, SamplePeriodRecord); + const response = await this.connection.sql(sqlStatement, SurveySamplePeriodModel); if (!response?.rowCount) { throw new ApiExecuteSQLError('Failed to delete sample period', [ @@ -261,10 +244,10 @@ export class SamplePeriodRepository extends BaseRepository { * Deletes multiple Survey Sample Periods for a given array of period ids. * * @param {number[]} periodsToDelete an array of period ids to delete - * @returns {*} {Promise} an array of promises for the deleted periods + * @returns {*} {Promise} an array of promises for the deleted periods * @memberof SamplePeriodRepository */ - async deleteSamplePeriods(surveyId: number, periodsToDelete: number[]): Promise { + async deleteSamplePeriods(surveyId: number, periodsToDelete: number[]): Promise { const knex = getKnex(); const sqlStatement = knex @@ -277,7 +260,7 @@ export class SamplePeriodRepository extends BaseRepository { .andWhere('survey_id', surveyId) .returning('ssp.*'); - const response = await this.connection.knex(sqlStatement, SamplePeriodRecord); + const response = await this.connection.knex(sqlStatement, SurveySamplePeriodModel); if (!response?.rowCount) { throw new ApiExecuteSQLError('Failed to delete sample periods', [ diff --git a/api/src/services/sample-location-service.test.ts b/api/src/services/sample-location-service.test.ts index 0f9c6f972d..399c2abad0 100644 --- a/api/src/services/sample-location-service.test.ts +++ b/api/src/services/sample-location-service.test.ts @@ -84,7 +84,7 @@ describe('SampleLocationService', () => { name: 'Sample Site 1', description: '', geometry: null, - geography: [], + geography: '', geojson: [], create_date: '', create_user: 1, @@ -227,7 +227,7 @@ describe('SampleLocationService', () => { name: 'Sample Site 1', description: '', geometry: null, - geography: [], + geography: '', geojson: [], create_date: '', create_user: 1, @@ -307,7 +307,7 @@ describe('SampleLocationService', () => { name: 'Cool new site', description: 'Check out this description', geometry: null, - geography: [], + geography: '', geojson: [], create_date: '', create_user: 1, diff --git a/api/src/services/sample-location-service.ts b/api/src/services/sample-location-service.ts index 427282d7c6..d31e3a2a6d 100644 --- a/api/src/services/sample-location-service.ts +++ b/api/src/services/sample-location-service.ts @@ -1,3 +1,5 @@ +import { SurveySamplePeriodRecord } from '../database-models/survey_sample_period'; +import { SurveySampleSiteModel } from '../database-models/survey_sample_site'; import { IDBConnection } from '../database/db'; import { IMethodAdvancedFilters, @@ -6,12 +8,12 @@ import { } from '../models/sampling-locations-view'; import { InsertSampleBlockRecord } from '../repositories/sample-blocks-repository'; import { + FindSampleSiteRecord, InsertSampleSiteRecord, SampleLocationBasicRecord, SampleLocationRecord, SampleLocationRepository, SampleSiteGeometryRecord, - SampleSiteRecord, UpdateSampleLocationRecord } from '../repositories/sample-location-repository/sample-location-repository'; import { InsertSampleMethodRecord } from '../repositories/sample-method-repository'; @@ -99,10 +101,10 @@ export class SampleLocationService extends DBService { * * @param {number} surveyId * @param {number} surveySampleSiteId - * @return {*} {Promise} + * @return {*} {Promise} * @memberof SampleLocationService */ - async getSurveySampleSiteById(surveyId: number, surveySampleSiteId: number): Promise { + async getSurveySampleSiteById(surveyId: number, surveySampleSiteId: number): Promise { return this.sampleLocationRepository.getSurveySampleSiteById(surveyId, surveySampleSiteId); } @@ -141,18 +143,36 @@ export class SampleLocationService extends DBService { * @param {(number | null)} systemUserId The system user id of the user making the request * @param {ISiteAdvancedFilters} filterFields * @param {ApiPaginationOptions} [pagination] - * @return {*} - * @memberof ObservationService + * @return {*} {Promise} + * @memberof SampleLocationService */ async findSites( isUserAdmin: boolean, systemUserId: number | null, filterFields: ISiteAdvancedFilters, pagination?: ApiPaginationOptions - ) { + ): Promise { return this.sampleLocationRepository.findSites(isUserAdmin, systemUserId, filterFields, pagination); } + /** + * Retrieves the count 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 + * @return {*} {Promise} + * @memberof SampleLocationService + */ + async findSitesCount( + isUserAdmin: boolean, + systemUserId: number | null, + filterFields: ISiteAdvancedFilters + ): Promise { + return this.sampleLocationRepository.findSitesCount(isUserAdmin, systemUserId, filterFields); + } + /** * Retrieves the paginated list of all methods that are available to the user, based on their permissions and * provided filter criteria. @@ -162,7 +182,7 @@ export class SampleLocationService extends DBService { * @param {IMethodAdvancedFilters} filterFields * @param {ApiPaginationOptions} [pagination] * @return {*} - * @memberof ObservationService + * @memberof SampleLocationService */ async findMethods( isUserAdmin: boolean, @@ -181,27 +201,45 @@ export class SampleLocationService extends DBService { * @param {(number | null)} systemUserId The system user id of the user making the request * @param {IPeriodAdvancedFilters} filterFields * @param {ApiPaginationOptions} [pagination] - * @return {*} - * @memberof ObservationService + * @return {*} {Promise} + * @memberof SampleLocationService */ async findPeriods( isUserAdmin: boolean, systemUserId: number | null, filterFields: IPeriodAdvancedFilters, pagination?: ApiPaginationOptions - ) { + ): Promise { return this.sampleLocationRepository.findPeriods(isUserAdmin, systemUserId, filterFields, pagination); } + /** + * Retrieves the count 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 + * @return {*} {Promise} + * @memberof SampleLocationService + */ + async findPeriodsCount( + isUserAdmin: boolean, + systemUserId: number | null, + filterFields: IPeriodAdvancedFilters + ): Promise { + return this.sampleLocationRepository.findPeriodsCount(isUserAdmin, systemUserId, filterFields); + } + /** * Deletes a survey Sample Location. * * @param {number} surveyId * @param {number} surveySampleSiteId - * @return {*} {Promise} + * @return {*} {Promise} * @memberof SampleLocationService */ - async deleteSampleSiteRecord(surveyId: number, surveySampleSiteId: number): Promise { + async deleteSampleSiteRecord(surveyId: number, surveySampleSiteId: number): Promise { const sampleMethodService = new SampleMethodService(this.connection); const sampleBlockService = new SampleBlockService(this.connection); const sampleStratumService = new SampleStratumService(this.connection); @@ -245,10 +283,10 @@ export class SampleLocationService extends DBService { * integer id + 1 in the db. * * @param {PostSampleLocations} sampleLocations - * @return {*} {Promise} + * @return {*} {Promise} * @memberof SampleLocationService */ - async insertSampleLocations(sampleLocations: PostSampleLocations): Promise { + async insertSampleLocations(sampleLocations: PostSampleLocations): Promise { defaultLog.debug({ label: 'insertSampleLocations' }); // Create a sample site record for each feature found diff --git a/api/src/services/sample-method-service.test.ts b/api/src/services/sample-method-service.test.ts index 4ca9bf77b7..21c8c3b9f2 100644 --- a/api/src/services/sample-method-service.test.ts +++ b/api/src/services/sample-method-service.test.ts @@ -1,13 +1,13 @@ import chai, { expect } from 'chai'; import sinon from 'sinon'; import sinonChai from 'sinon-chai'; +import { SurveySampleMethodModel } from '../database-models/survey_sample_method'; +import { SurveySamplePeriodModel, SurveySamplePeriodRecord } from '../database-models/survey_sample_period'; import { InsertSampleMethodRecord, - SampleMethodRecord, SampleMethodRepository, UpdateSampleMethodRecord } from '../repositories/sample-method-repository'; -import { SamplePeriodRecord } from '../repositories/sample-period-repository'; import { getMockDBConnection } from '../__mocks__/db'; import { ObservationService } from './observation-service'; import { SampleMethodService } from './sample-method-service'; @@ -32,7 +32,7 @@ describe('SampleMethodService', () => { it('Gets a sample method by survey sample site ID', async () => { const mockDBConnection = getMockDBConnection(); - const mockSampleMethodRecords: SampleMethodRecord[] = [ + const mockSampleMethodRecords: SurveySampleMethodModel[] = [ { survey_sample_method_id: 1, survey_sample_site_id: 2, @@ -98,7 +98,7 @@ describe('SampleMethodService', () => { const mockSamplePeriodId = 1; const mockSampleMethodId = 1; - const mockSampleMethodRecord: SampleMethodRecord = { + const mockSampleMethodRecord: SurveySampleMethodModel = { survey_sample_method_id: 1, survey_sample_site_id: 2, method_technique_id: 3, @@ -116,7 +116,7 @@ describe('SampleMethodService', () => { sinon .stub(SamplePeriodService.prototype, 'getSamplePeriodsForSurveyMethodId') - .resolves([{ survey_sample_period_id: mockSamplePeriodId } as SamplePeriodRecord]); + .resolves([{ survey_sample_period_id: mockSamplePeriodId } as SurveySamplePeriodModel]); const deleteSamplePeriodRecordStub = sinon .stub(SamplePeriodService.prototype, 'deleteSamplePeriodRecords') .resolves(); @@ -138,7 +138,7 @@ describe('SampleMethodService', () => { it('Inserts a sample method successfully', async () => { const mockDBConnection = getMockDBConnection(); - const mockSampleMethodRecord: SampleMethodRecord = { + const mockSampleMethodRecord: SurveySampleMethodModel = { survey_sample_method_id: 1, survey_sample_site_id: 2, method_technique_id: 3, @@ -154,7 +154,7 @@ describe('SampleMethodService', () => { .stub(SampleMethodRepository.prototype, 'insertSampleMethod') .resolves(mockSampleMethodRecord); - const mockSamplePeriodRecord: SamplePeriodRecord = { + const mockSamplePeriodRecord: SurveySamplePeriodModel = { survey_sample_method_id: 1, survey_sample_period_id: 2, start_date: '2023-10-04', @@ -223,7 +223,7 @@ describe('SampleMethodService', () => { it('Updates a sample method successfully', async () => { const mockDBConnection = getMockDBConnection(); - const mockSampleMethodRecord: SampleMethodRecord = { + const mockSampleMethodRecord: SurveySampleMethodModel = { survey_sample_method_id: 1, survey_sample_site_id: 2, method_technique_id: 3, @@ -265,7 +265,7 @@ describe('SampleMethodService', () => { start_time: '12:00:00', end_time: '13:00:00', survey_sample_method_id: 1 - } as SamplePeriodRecord + } as SurveySamplePeriodRecord ] }; const sampleMethodService = new SampleMethodService(mockDBConnection); @@ -287,7 +287,7 @@ describe('SampleMethodService', () => { const mockSampleMethodId = 1; const surveySampleSiteId = 1; - const mockSampleMethodRecord: SampleMethodRecord = { + const mockSampleMethodRecord: SurveySampleMethodModel = { survey_sample_method_id: mockSampleMethodId, survey_sample_site_id: 2, method_technique_id: 3, @@ -300,7 +300,7 @@ describe('SampleMethodService', () => { revision_count: 0 }; - const mockSampleMethodRecords: SampleMethodRecord[] = [mockSampleMethodRecord]; + const mockSampleMethodRecords: SurveySampleMethodModel[] = [mockSampleMethodRecord]; const getSampleMethodsForSurveySampleSiteIdStub = sinon .stub(SampleMethodRepository.prototype, 'getSampleMethodsForSurveySampleSiteId') .resolves(mockSampleMethodRecords); diff --git a/api/src/services/sample-method-service.ts b/api/src/services/sample-method-service.ts index ad6e2f7a0f..7de513b0aa 100644 --- a/api/src/services/sample-method-service.ts +++ b/api/src/services/sample-method-service.ts @@ -1,8 +1,8 @@ +import { SurveySampleMethodModel } from '../database-models/survey_sample_method'; import { IDBConnection } from '../database/db'; import { HTTP409 } from '../errors/http-error'; import { InsertSampleMethodRecord, - SampleMethodRecord, SampleMethodRepository, UpdateSampleMethodRecord } from '../repositories/sample-method-repository'; @@ -30,13 +30,13 @@ export class SampleMethodService extends DBService { * * @param {number} surveyId * @param {number} surveySampleSiteId - * @return {*} {Promise} + * @return {*} {Promise} * @memberof SampleMethodService */ async getSampleMethodsForSurveySampleSiteId( surveyId: number, surveySampleSiteId: number - ): Promise { + ): Promise { return this.sampleMethodRepository.getSampleMethodsForSurveySampleSiteId(surveyId, surveySampleSiteId); } @@ -56,10 +56,10 @@ export class SampleMethodService extends DBService { * * @param {number} surveyId * @param {number} surveySampleMethodId - * @return {*} {Promise} + * @return {*} {Promise} * @memberof SampleMethodService */ - async deleteSampleMethodRecord(surveyId: number, surveySampleMethodId: number): Promise { + async deleteSampleMethodRecord(surveyId: number, surveySampleMethodId: number): Promise { const samplePeriodService = new SamplePeriodService(this.connection); // Collect list of periods to delete @@ -78,10 +78,10 @@ export class SampleMethodService extends DBService { * Inserts survey Sample Method and associated Sample Periods. * * @param {InsertSampleMethodRecord} sampleMethod - * @return {*} {Promise} + * @return {*} {Promise} * @memberof SampleMethodService */ - async insertSampleMethod(sampleMethod: InsertSampleMethodRecord): Promise { + async insertSampleMethod(sampleMethod: InsertSampleMethodRecord): Promise { // Create new sample method const sampleMethodRecord = await this.sampleMethodRepository.insertSampleMethod(sampleMethod); @@ -156,10 +156,10 @@ export class SampleMethodService extends DBService { * * @param {number} surveyId * @param {InsertSampleMethodRecord} sampleMethod - * @return {*} {Promise} + * @return {*} {Promise} * @memberof SampleMethodService */ - async updateSampleMethod(surveyId: number, sampleMethod: UpdateSampleMethodRecord): Promise { + async updateSampleMethod(surveyId: number, sampleMethod: UpdateSampleMethodRecord): Promise { const samplePeriodService = new SamplePeriodService(this.connection); // Check for any sample periods to delete diff --git a/api/src/services/sample-period-service.test.ts b/api/src/services/sample-period-service.test.ts index 7306048d27..a59fc19ab4 100644 --- a/api/src/services/sample-period-service.test.ts +++ b/api/src/services/sample-period-service.test.ts @@ -1,10 +1,10 @@ import chai, { expect } from 'chai'; import sinon from 'sinon'; import sinonChai from 'sinon-chai'; +import { SurveySamplePeriodModel } from '../database-models/survey_sample_period'; import { InsertSamplePeriodRecord, SamplePeriodHierarchyIds, - SamplePeriodRecord, SamplePeriodRepository, UpdateSamplePeriodRecord } from '../repositories/sample-period-repository'; @@ -31,7 +31,7 @@ describe('SamplePeriodService', () => { it('Gets a sample period by survey method ID', async () => { const mockDBConnection = getMockDBConnection(); - const mockSamplePeriodRecords: SamplePeriodRecord[] = [ + const mockSamplePeriodRecords: SurveySamplePeriodModel[] = [ { survey_sample_period_id: 1, survey_sample_method_id: 2, @@ -97,7 +97,7 @@ describe('SamplePeriodService', () => { it('Deletes a sample period record', async () => { const mockDBConnection = getMockDBConnection(); - const mockSamplePeriodRecord: SamplePeriodRecord = { + const mockSamplePeriodRecord: SurveySamplePeriodModel = { survey_sample_period_id: 1, survey_sample_method_id: 2, start_date: '2023-10-02', @@ -132,7 +132,7 @@ describe('SamplePeriodService', () => { it('Inserts a sample period successfully', async () => { const mockDBConnection = getMockDBConnection(); - const mockSamplePeriodRecord: SamplePeriodRecord = { + const mockSamplePeriodRecord: SurveySamplePeriodModel = { survey_sample_period_id: 1, survey_sample_method_id: 2, start_date: '2023-10-02', @@ -172,7 +172,7 @@ describe('SamplePeriodService', () => { it('Updates a sample period successfully', async () => { const mockDBConnection = getMockDBConnection(); - const mockSamplePeriodRecord: SamplePeriodRecord = { + const mockSamplePeriodRecord: SurveySamplePeriodModel = { survey_sample_period_id: 1, survey_sample_method_id: 2, start_date: '2023-10-02', @@ -211,7 +211,7 @@ describe('SamplePeriodService', () => { it('should delete sample sites not in array successfully', async () => { const mockDBConnection = getMockDBConnection(); - const mockSamplePeriodRecords: SamplePeriodRecord[] = [ + const mockSamplePeriodRecords: SurveySamplePeriodModel[] = [ { survey_sample_period_id: 1, survey_sample_method_id: 2, @@ -242,7 +242,7 @@ describe('SamplePeriodService', () => { const surveySampleMethodId = 1; const samplePeriodService = new SamplePeriodService(mockDBConnection); const response = await samplePeriodService.deleteSamplePeriodsNotInArray(mockSurveyId, surveySampleMethodId, [ - { survey_sample_period_id: 2 } as SamplePeriodRecord + { survey_sample_period_id: 2 } as SurveySamplePeriodModel ]); expect(getSamplePeriodsForSurveyMethodIdStub).to.be.calledOnceWith(mockSurveyId, surveySampleMethodId); diff --git a/api/src/services/sample-period-service.ts b/api/src/services/sample-period-service.ts index 38cd96d8c7..8a7307fcca 100644 --- a/api/src/services/sample-period-service.ts +++ b/api/src/services/sample-period-service.ts @@ -1,9 +1,9 @@ +import { SurveySamplePeriodModel } from '../database-models/survey_sample_period'; import { IDBConnection } from '../database/db'; import { HTTP409 } from '../errors/http-error'; import { InsertSamplePeriodRecord, SamplePeriodHierarchyIds, - SamplePeriodRecord, SamplePeriodRepository, UpdateSamplePeriodRecord } from '../repositories/sample-period-repository'; @@ -30,13 +30,13 @@ export class SamplePeriodService extends DBService { * * @param {number} surveyId * @param {number} surveySampleMethodId - * @return {*} {Promise} + * @return {*} {Promise} * @memberof SamplePeriodService */ async getSamplePeriodsForSurveyMethodId( surveyId: number, surveySampleMethodId: number - ): Promise { + ): Promise { return this.samplePeriodRepository.getSamplePeriodsForSurveyMethodId(surveyId, surveySampleMethodId); } @@ -57,10 +57,10 @@ export class SamplePeriodService extends DBService { * * @param {number} surveyId * @param {number} surveySamplePeriodId - * @return {*} {Promise} + * @return {*} {Promise} * @memberof SamplePeriodService */ - async deleteSamplePeriodRecord(surveyId: number, surveySamplePeriodId: number): Promise { + async deleteSamplePeriodRecord(surveyId: number, surveySamplePeriodId: number): Promise { return this.samplePeriodRepository.deleteSamplePeriodRecord(surveyId, surveySamplePeriodId); } @@ -68,10 +68,10 @@ export class SamplePeriodService extends DBService { * Deletes multiple Survey Sample Periods for a given array of period ids. * * @param {number[]} periodsToDelete an array of period ids to delete - * @returns {*} {Promise} an array of promises for the deleted periods + * @returns {*} {Promise} an array of promises for the deleted periods * @memberof SamplePeriodService */ - async deleteSamplePeriodRecords(surveyId: number, periodsToDelete: number[]): Promise { + async deleteSamplePeriodRecords(surveyId: number, periodsToDelete: number[]): Promise { return this.samplePeriodRepository.deleteSamplePeriods(surveyId, periodsToDelete); } @@ -79,10 +79,10 @@ export class SamplePeriodService extends DBService { * Inserts survey Sample Period. * * @param {InsertSamplePeriodRecord} samplePeriod - * @return {*} {Promise} + * @return {*} {Promise} * @memberof SamplePeriodService */ - async insertSamplePeriod(samplePeriod: InsertSamplePeriodRecord): Promise { + async insertSamplePeriod(samplePeriod: InsertSamplePeriodRecord): Promise { return this.samplePeriodRepository.insertSamplePeriod(samplePeriod); } @@ -90,10 +90,10 @@ export class SamplePeriodService extends DBService { * updates a survey Sample Period. * * @param {UpdateSamplePeriodRecord} samplePeriod - * @return {*} {Promise} + * @return {*} {Promise} * @memberof SamplePeriodService */ - async updateSamplePeriod(surveyId: number, samplePeriod: UpdateSamplePeriodRecord): Promise { + async updateSamplePeriod(surveyId: number, samplePeriod: UpdateSamplePeriodRecord): Promise { return this.samplePeriodRepository.updateSamplePeriod(surveyId, samplePeriod); } diff --git a/app/src/components/chips/ColouredRectangleChip.tsx b/app/src/components/chips/ColouredRectangleChip.tsx index 9baf0282f0..e6922e596a 100644 --- a/app/src/components/chips/ColouredRectangleChip.tsx +++ b/app/src/components/chips/ColouredRectangleChip.tsx @@ -1,9 +1,10 @@ import { Color } from '@mui/material'; import Chip, { ChipProps } from '@mui/material/Chip'; +import { ReactElement } from 'react'; export interface IColouredRectangleChipProps extends ChipProps { colour: Color; - label: string | JSX.Element; + label: string | ReactElement; } /** @@ -26,7 +27,9 @@ const ColouredRectangleChip = (props: IColouredRectangleChipProps) => { fontWeight: 700, fontSize: '0.75rem', p: 1, - textTransform: 'uppercase' + textTransform: 'uppercase', + overflow: 'hidden', + textOverflow: 'ellipsis' }, ...props.sx }} diff --git a/app/src/components/overlay/NoDataOverlay.tsx b/app/src/components/overlay/NoDataOverlay.tsx index cfb5ef4cce..f61111ca29 100644 --- a/app/src/components/overlay/NoDataOverlay.tsx +++ b/app/src/components/overlay/NoDataOverlay.tsx @@ -17,7 +17,7 @@ interface INoDataOverlayProps extends BoxProps { export const NoDataOverlay = (props: INoDataOverlayProps) => { const { title, subtitle, icon } = props; return ( - + {title} {icon && } diff --git a/app/src/components/toolbar/CustomToggleButtonGroup.tsx b/app/src/components/toolbar/CustomToggleButtonGroup.tsx new file mode 100644 index 0000000000..c0f504971d --- /dev/null +++ b/app/src/components/toolbar/CustomToggleButtonGroup.tsx @@ -0,0 +1,62 @@ +import Icon from '@mdi/react'; +import Button from '@mui/material/Button'; +import ToggleButton from '@mui/material/ToggleButton'; +import ToggleButtonGroup from '@mui/material/ToggleButtonGroup'; + +interface CustomToggleButtonGroupProps { + views: Array<{ value: T; label: string; icon: string }>; + activeView: T; + onViewChange: (view: T) => void; +} + +/** + * A custom toggle button group that allows users to select from multiple views. + * + * TODO: Update all togglebuttongroups throughout the app to use this component for consistent styling + * + * @param {CustomToggleButtonGroupProps} props + * @return {*} + */ +const CustomToggleButtonGroup = (props: CustomToggleButtonGroupProps) => { + const { views, activeView, onViewChange } = props; + + return ( + { + if (view) { + onViewChange(view); + } + }} + exclusive + sx={{ + display: 'flex', + flex: '1 1 auto', + gap: 0.5, + '& Button': { + py: 1, + px: 2, + border: 'none', + borderRadius: '4px !important', + fontSize: '0.875rem', + fontWeight: 700, + letterSpacing: '0.02rem', + justifyContent: 'flex-start' + } + }}> + {views.map((view) => ( + } + value={view.value}> + {view.label} + + ))} + + ); +}; + +export default CustomToggleButtonGroup; diff --git a/app/src/contexts/surveyContext.tsx b/app/src/contexts/surveyContext.tsx index f1b5ed2a44..9dcfef9477 100644 --- a/app/src/contexts/surveyContext.tsx +++ b/app/src/contexts/surveyContext.tsx @@ -2,7 +2,6 @@ import { useBiohubApi } from 'hooks/useBioHubApi'; import useDataLoader, { DataLoader } from 'hooks/useDataLoader'; import { ICritterSimpleResponse } from 'interfaces/useCritterApi.interface'; import { IGetSurveyAttachmentsResponse, IGetSurveyForViewResponse } from 'interfaces/useSurveyApi.interface'; -import { IGetTechniquesResponse } from 'interfaces/useTechniqueApi.interface'; import { createContext, PropsWithChildren, useEffect, useMemo } from 'react'; import { useParams } from 'react-router'; @@ -29,14 +28,6 @@ export interface ISurveyContext { */ artifactDataLoader: DataLoader<[project_id: number, survey_id: number], IGetSurveyAttachmentsResponse, unknown>; - /** - * The Data Loader used to load survey techniques - * - * @type {DataLoader<[project_id: number, survey_id: number], IGetTechniquesResponse, unknown>} - * @memberof ISurveyContext - */ - techniqueDataLoader: DataLoader<[project_id: number, survey_id: number], IGetTechniquesResponse, unknown>; - /** * The Data Loader used to load critters for a given survey * @@ -65,7 +56,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>, - techniqueDataLoader: {} as DataLoader<[project_id: number, survey_id: number], IGetTechniquesResponse, unknown>, critterDataLoader: {} as DataLoader<[project_id: number, survey_id: number], ICritterSimpleResponse[], unknown>, projectId: -1, surveyId: -1 @@ -76,7 +66,6 @@ export const SurveyContextProvider = (props: PropsWithChildren = useParams(); @@ -122,11 +111,10 @@ export const SurveyContextProvider = (props: PropsWithChildren{props.children}; }; diff --git a/app/src/features/standards/view/components/AccordionStandardCard.tsx b/app/src/features/standards/view/components/AccordionStandardCard.tsx index 2513448d24..4966a82f4b 100644 --- a/app/src/features/standards/view/components/AccordionStandardCard.tsx +++ b/app/src/features/standards/view/components/AccordionStandardCard.tsx @@ -1,26 +1,27 @@ import { mdiChevronDown, mdiChevronUp } from '@mdi/js'; import { Icon } from '@mdi/react'; import { Collapse } from '@mui/material'; -import Box, { BoxProps } from '@mui/material/Box'; -import Paper from '@mui/material/Paper'; +import Box from '@mui/material/Box'; +import Paper, { PaperProps } from '@mui/material/Paper'; import Typography from '@mui/material/Typography'; -import { PropsWithChildren, useState } from 'react'; +import React, { PropsWithChildren, ReactElement, useState } from 'react'; -interface IAccordionStandardCardProps extends BoxProps { - label: string; +interface IAccordionStandardCardProps extends PaperProps { + label: string | React.ReactElement; subtitle?: string | null; - ornament?: JSX.Element; + ornament?: ReactElement; colour: string; disableCollapse?: boolean; } /** * Returns a collapsible paper component for displaying lookup values - * @param props - * @returns + * + * @param {PropsWithChildren} props + * @return {*} */ export const AccordionStandardCard = (props: PropsWithChildren) => { - const { label, subtitle, children, colour, ornament, disableCollapse } = props; + const { label, subtitle, children, colour, ornament, disableCollapse, ...paperProps } = props; const [isCollapsed, setIsCollapsed] = useState(true); @@ -33,27 +34,26 @@ export const AccordionStandardCard = (props: PropsWithChildren + - - - {label} - + + {label} + + {ornament} + {expandable && } - {expandable && } diff --git a/app/src/features/surveys/sampling-information/methods/SamplingMethodFormContainer.tsx b/app/src/features/surveys/sampling-information/methods/SamplingMethodFormContainer.tsx index 5c435e414d..429da93169 100644 --- a/app/src/features/surveys/sampling-information/methods/SamplingMethodFormContainer.tsx +++ b/app/src/features/surveys/sampling-information/methods/SamplingMethodFormContainer.tsx @@ -27,7 +27,9 @@ import { SamplingPeriodFormContainer } from 'features/surveys/sampling-informati import { ICreateSampleSiteFormData } from 'features/surveys/sampling-information/sites/create/CreateSamplingSitePage'; import { IEditSampleSiteFormData } from 'features/surveys/sampling-information/sites/edit/EditSamplingSitePage'; import { useFormikContext } from 'formik'; +import { useBiohubApi } from 'hooks/useBioHubApi'; import { useSurveyContext } from 'hooks/useContext'; +import useDataLoader from 'hooks/useDataLoader'; import { useContext, useEffect, useState } from 'react'; import { TransitionGroup } from 'react-transition-group'; import { getCodesName } from 'utils/Utils'; @@ -48,11 +50,21 @@ export const SamplingMethodFormContainer = () => { const surveyContext = useSurveyContext(); + const biohubApi = useBiohubApi(); + const codesContext = useContext(CodesContext); useEffect(() => { codesContext.codesDataLoader.load(); }, [codesContext.codesDataLoader]); + const techniquesDataLoader = useDataLoader(() => + biohubApi.technique.getTechniquesForSurvey(surveyContext.projectId, surveyContext.surveyId) + ); + + useEffect(() => { + techniquesDataLoader.load(); + }, [techniquesDataLoader]); + const handleMenuClick = (event: React.MouseEvent, index: number) => { setAnchorEl(event.currentTarget); setEditData({ data: values.sample_methods[index], index }); @@ -172,7 +184,7 @@ export const SamplingMethodFormContainer = () => { { - surveyContext.techniqueDataLoader.data?.techniques.find( + techniquesDataLoader.data?.techniques.find( (technique) => technique.method_technique_id === sampleMethod.technique.method_technique_id )?.name diff --git a/app/src/features/surveys/sampling-information/methods/components/SamplingMethodForm.tsx b/app/src/features/surveys/sampling-information/methods/components/SamplingMethodForm.tsx index 2a9f7b03f2..28d61e67cd 100644 --- a/app/src/features/surveys/sampling-information/methods/components/SamplingMethodForm.tsx +++ b/app/src/features/surveys/sampling-information/methods/components/SamplingMethodForm.tsx @@ -5,7 +5,9 @@ import AutocompleteField, { IAutocompleteFieldOption } from 'components/fields/A import CustomTextField from 'components/fields/CustomTextField'; import { CodesContext } from 'contexts/codesContext'; import { useFormikContext } from 'formik'; +import { useBiohubApi } from 'hooks/useBioHubApi'; import { useSurveyContext } from 'hooks/useContext'; +import useDataLoader from 'hooks/useDataLoader'; import { useContext, useEffect } from 'react'; import yup from 'utils/YupSchema'; import { v4 } from 'uuid'; @@ -62,6 +64,8 @@ export const SamplingMethodForm = () => { const codesContext = useContext(CodesContext); const surveyContext = useSurveyContext(); + const biohubApi = useBiohubApi(); + const { setFieldValue } = useFormikContext(); const methodResponseMetricOptions: IAutocompleteFieldOption[] = @@ -75,7 +79,13 @@ export const SamplingMethodForm = () => { codesContext.codesDataLoader.load(); }, [codesContext.codesDataLoader]); - const techniques = surveyContext.techniqueDataLoader.data?.techniques; + const techniquesDataLoader = useDataLoader(() => + biohubApi.technique.getTechniquesForSurvey(surveyContext.projectId, surveyContext.surveyId) + ); + + useEffect(() => { + techniquesDataLoader.load(); + }, [techniquesDataLoader]); if (!codesContext.codesDataLoader.data) { return ; @@ -91,7 +101,7 @@ export const SamplingMethodForm = () => { label="Technique" name="technique.method_technique_id" options={ - techniques?.map((option) => ({ + techniquesDataLoader.data?.techniques.map((option) => ({ value: option.method_technique_id, label: option.name, subText: option.description ?? undefined 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 9523da8f36..9ef1a9e0a1 100644 --- a/app/src/features/surveys/sampling-information/periods/table/SamplingPeriodTable.tsx +++ b/app/src/features/surveys/sampling-information/periods/table/SamplingPeriodTable.tsx @@ -1,5 +1,5 @@ import Typography from '@mui/material/Typography'; -import { GridColDef } from '@mui/x-data-grid'; +import { GridColDef, GridPaginationModel, GridSortModel } from '@mui/x-data-grid'; import { StyledDataGrid } from 'components/data-grid/StyledDataGrid'; import { DATE_FORMAT } from 'constants/dateTimeFormats'; import dayjs from 'dayjs'; @@ -12,14 +12,19 @@ export interface ISamplingSitePeriodRowData { sample_site: string; sample_method: string; method_response_metric_id: number; - start_date: string; - end_date: string; + start_date: string | null; + end_date: string | null; start_time: string | null; end_time: string | null; } interface ISamplingPeriodTableProps { periods: ISamplingSitePeriodRowData[]; + paginationModel: GridPaginationModel; + setPaginationModel: React.Dispatch>; + sortModel: GridSortModel; + setSortModel: React.Dispatch>; + rowCount: number; } /** @@ -29,11 +34,11 @@ interface ISamplingPeriodTableProps { * @returns {*} */ export const SamplingPeriodTable = (props: ISamplingPeriodTableProps) => { - const { periods } = props; + const { periods, paginationModel, setPaginationModel, sortModel, setSortModel, rowCount } = props; const codesContext = useCodesContext(); - const columns: GridColDef[] = [ + const columns: GridColDef[] = [ { field: 'sample_site', headerName: 'Site', @@ -48,15 +53,15 @@ export const SamplingPeriodTable = (props: ISamplingPeriodTableProps) => { field: 'method_response_metric_id', headerName: 'Response Metric', flex: 1, - renderCell: (params) => ( - <> - {getCodesName( - codesContext.codesDataLoader.data, - 'method_response_metrics', - params.row.method_response_metric_id - )} - - ) + valueGetter: (params) => { + const value = getCodesName( + codesContext.codesDataLoader.data, + 'method_response_metrics', + params.row.method_response_metric_id + ); + + return value; + } }, { field: 'start_date', @@ -88,8 +93,13 @@ export const SamplingPeriodTable = (props: ISamplingPeriodTableProps) => { field: 'duration', headerName: 'Duration', flex: 1, - renderCell: (params) => { + valueGetter: (params) => { const { start_date, start_time, end_date, end_time } = params.row; + + if (!start_date || !end_date) { + return null; + } + return formatTimeDifference(start_date, start_time, end_date, end_time); } } @@ -97,18 +107,25 @@ export const SamplingPeriodTable = (props: ISamplingPeriodTableProps) => { return ( 'auto'} - disableColumnMenu rows={periods} getRowId={(row: ISamplingSitePeriodRowData) => row.id} columns={columns} checkboxSelection={false} disableRowSelectionOnClick - rowCount={periods.length} + 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]} diff --git a/app/src/features/surveys/sampling-information/sites/SamplingSiteContainer.tsx b/app/src/features/surveys/sampling-information/sites/SamplingSiteContainer.tsx index a095be9583..59a09a1947 100644 --- a/app/src/features/surveys/sampling-information/sites/SamplingSiteContainer.tsx +++ b/app/src/features/surveys/sampling-information/sites/SamplingSiteContainer.tsx @@ -5,9 +5,14 @@ import Button from '@mui/material/Button'; import Divider from '@mui/material/Divider'; import Toolbar from '@mui/material/Toolbar'; import Typography from '@mui/material/Typography'; +import { LoadingGuard } from 'components/loading/LoadingGuard'; +import { SkeletonMap, SkeletonTable } from 'components/loading/SkeletonLoaders'; import { useSamplingSiteStaticLayer } from 'features/surveys/view/survey-spatial/components/map/useSamplingSiteStaticLayer'; import SurveyMap from 'features/surveys/view/SurveyMap'; +import { useBiohubApi } from 'hooks/useBioHubApi'; import { useSurveyContext } from 'hooks/useContext'; +import useDataLoader from 'hooks/useDataLoader'; +import { useEffect } from 'react'; import { Link as RouterLink } from 'react-router-dom'; import { SamplingSiteTableContainer } from './table/SamplingSiteTableContainer'; @@ -20,8 +25,18 @@ import { SamplingSiteTableContainer } from './table/SamplingSiteTableContainer'; const SamplingSiteContainer = () => { const surveyContext = useSurveyContext(); + const biohubApi = useBiohubApi(); + const samplingSiteStaticLayer = useSamplingSiteStaticLayer(); + const techniquesDataLoader = useDataLoader(() => + biohubApi.technique.getTechniquesForSurvey(surveyContext.projectId, surveyContext.surveyId) + ); + + useEffect(() => { + techniquesDataLoader.load(); + }, [techniquesDataLoader]); + return ( <> @@ -31,7 +46,7 @@ const SamplingSiteContainer = () => {