From 0b230b3831d1595cf075dbb6e4e9f00011bd6191 Mon Sep 17 00:00:00 2001 From: Nick Phura Date: Thu, 9 Jan 2025 16:44:54 -0800 Subject: [PATCH] Update find periods to allow arrays of sample site and technique ids. Update create/edit periods to enforce site, technique, and period information. UI tweaks to create/edit periods pages. Enforce site/technique/period validation on observations table. Update sample sites left list, to show technique/period details. --- api/src/models/period-view.ts | 4 +- api/src/paths/periods/index.ts | 18 ++- api/src/repositories/analytics-repository.ts | 1 + .../repositories/sample-period-repository.ts | 8 +- .../services/observation-services/utils.ts | 21 +-- api/src/services/technique-service.ts | 2 +- app/src/contexts/dialogContext.tsx | 2 + .../ObservationRowValidationUtils.ts | 11 +- .../SamplingSiteListContent.tsx | 42 +++-- .../method/SamplingSiteListMethod.tsx | 57 ------- .../period/SamplingSiteListPeriod.tsx | 17 ++- .../SamplingSiteListPeriodContainer.tsx | 95 ++++++++++++ .../periods/create/CreateSamplePeriodPage.tsx | 137 +++++------------ .../periods/edit/EditSamplePeriodPage.tsx | 143 ++++++------------ .../periods/form/SamplePeriodForm.tsx | 19 +-- .../periods/SamplePeriodPeriodForm.tsx | 111 +++++++------- .../SamplingPeriodPeriodFormContainer.tsx | 72 ++++++--- .../components/period/SurveyPeriodsTable.tsx | 7 +- ...ObservationAnalyticsDataTableContainer.tsx | 6 +- app/src/hooks/api/useSamplingPeriodApi.ts | 8 +- .../useSamplingPeriodApi.interface.ts | 36 +---- .../interfaces/useTelemetryApi.interface.ts | 1 - app/src/utils/YupSchema.ts | 15 +- 23 files changed, 384 insertions(+), 449 deletions(-) delete mode 100644 app/src/features/surveys/observations/sampling-sites/site/accordion-details/method/SamplingSiteListMethod.tsx rename app/src/features/surveys/observations/sampling-sites/site/accordion-details/{method => }/period/SamplingSiteListPeriod.tsx (91%) create mode 100644 app/src/features/surveys/observations/sampling-sites/site/accordion-details/period/SamplingSiteListPeriodContainer.tsx diff --git a/api/src/models/period-view.ts b/api/src/models/period-view.ts index 6b0355472f..2e8f71acef 100644 --- a/api/src/models/period-view.ts +++ b/api/src/models/period-view.ts @@ -1,6 +1,6 @@ export interface IPeriodAdvancedFilters { survey_id?: number; - sample_site_id?: number; - method_technique_id?: number; + sample_site_id?: number[]; + method_technique_id?: number[]; system_user_id?: number; } diff --git a/api/src/paths/periods/index.ts b/api/src/paths/periods/index.ts index c4de78ffeb..a8c946c63f 100644 --- a/api/src/paths/periods/index.ts +++ b/api/src/paths/periods/index.ts @@ -53,8 +53,11 @@ GET.apiDoc = { name: 'sample_site_id', required: false, schema: { - type: 'integer', - minimum: 1, + type: 'array', + items: { + type: 'integer', + minimum: 1 + }, nullable: true } }, @@ -63,8 +66,11 @@ GET.apiDoc = { name: 'method_technique_id', required: false, schema: { - type: 'integer', - minimum: 1, + type: 'array', + items: { + type: 'integer', + minimum: 1 + }, nullable: true } }, @@ -276,8 +282,8 @@ export function findPeriods(): RequestHandler { 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, - method_technique_id: (req.query.method_technique_id && Number(req.query.method_technique_id)) ?? undefined, + sample_site_id: (req.query.sample_site_id && req.query.sample_site_id.map(Number)) ?? [], + method_technique_id: (req.query.method_technique_id && req.query.method_technique_id.map(Number)) ?? [], system_user_id: req.query.system_user_id ?? undefined }; } diff --git a/api/src/repositories/analytics-repository.ts b/api/src/repositories/analytics-repository.ts index 2c20e886d5..00525ad051 100644 --- a/api/src/repositories/analytics-repository.ts +++ b/api/src/repositories/analytics-repository.ts @@ -164,6 +164,7 @@ export class AnalyticsRepository extends BaseRepository { groupByQualitativeMeasurements ) ) + .select('w_observations.survey_sample_period_id') .from('w_observations'); if (groupByColumns.length) { diff --git a/api/src/repositories/sample-period-repository.ts b/api/src/repositories/sample-period-repository.ts index 747296f459..88e3f14fcf 100644 --- a/api/src/repositories/sample-period-repository.ts +++ b/api/src/repositories/sample-period-repository.ts @@ -407,14 +407,14 @@ export class SamplePeriodRepository extends BaseRepository { getSamplingPeriodsQuery.andWhere('survey_sample_period.survey_id', filterFields.survey_id); } - if (filterFields.sample_site_id) { + if (filterFields.sample_site_id?.length) { // Filter by a specific sample site id - getSamplingPeriodsQuery.andWhere('survey_sample_period.survey_sample_site_id', filterFields.sample_site_id); + getSamplingPeriodsQuery.whereIn('survey_sample_period.survey_sample_site_id', filterFields.sample_site_id); } - if (filterFields.method_technique_id) { + if (filterFields.method_technique_id?.length) { // Filter by a specific sample method id - getSamplingPeriodsQuery.andWhere('survey_sample_period.method_technique_id', filterFields.method_technique_id); + getSamplingPeriodsQuery.whereIn('survey_sample_period.method_technique_id', filterFields.method_technique_id); } return getSamplingPeriodsQuery; diff --git a/api/src/services/observation-services/utils.ts b/api/src/services/observation-services/utils.ts index 6bdaf1bd63..28856e8c9a 100644 --- a/api/src/services/observation-services/utils.ts +++ b/api/src/services/observation-services/utils.ts @@ -62,22 +62,27 @@ export function pullSamplingDataFromWorksheetRowObject( periodsMatchingTechnique, periodsMatchingPeriod ); + if (matchingPeriods.length === 1) { - // Found exactly one period that matches some or all of the filters above + // Found exactly one period record that uniquely matches some or all of the filters above return _formatMatchingPeriod(matchingPeriods[0]); } - // If no single period was matched above, then attempt to find a suitable period based on the observation date and time + // If no single period record was matched above, then attempt to find a suitable unique period based on the + // observation date and time const observationDate = getColumnCellValue(row, 'DATE').cell as string | null; const observationTime = getColumnCellValue(row, 'TIME').cell as string | null; const suitablePeriods = _findSamplePeriodFromWorksheetDateAndTime(observationDate, observationTime, samplingPeriods); if (suitablePeriods.length) { - // If at least one period is found, return the first one + // If at least one period record is found that satisfies the observation date and time, then return the first one return _formatMatchingPeriod(suitablePeriods[0]); } + // TODO Nick: If no period record is found by date/time, but multiple were found by site, technique, or period, + // should we return the first one here? Or should we return null to indicate that the observation record is invalid? + // Unable to match this observation record to any existing period return null; } @@ -95,7 +100,7 @@ function _findSamplePeriodFromWorksheetSite( siteName: string, samplingPeriods: SurveySamplePeriodDetails[] ): SurveySamplePeriodDetails[] { - return samplingPeriods.filter((period: any) => period.survey_sample_site.name === siteName); + return samplingPeriods.filter((period) => period?.survey_sample_site?.name === siteName); } /** @@ -111,7 +116,7 @@ function _findSamplePeriodFromWorksheetTechnique( techniqueName: string, samplingPeriods: SurveySamplePeriodDetails[] ): SurveySamplePeriodDetails[] { - return samplingPeriods.filter((period: any) => period.method_technique.name === techniqueName); + return samplingPeriods.filter((period) => period?.method_technique?.name === techniqueName); } /** @@ -135,7 +140,7 @@ function _findSamplePeriodFromWorksheetPeriod( // Find matching periods by date let matchingPeriods = samplingPeriods.filter( - (period: any) => period.start_date === startDate && period.end_date === endDate + (period) => period.start_date === startDate && period.end_date === endDate ); // Return if exactly one period matches by date @@ -144,9 +149,7 @@ function _findSamplePeriodFromWorksheetPeriod( } // If multiple periods match by date, try matching additionally by time - matchingPeriods = matchingPeriods.filter( - (period: any) => period.start_time === startTime && period.end_time === endTime - ); + matchingPeriods = matchingPeriods.filter((period) => period.start_time === startTime && period.end_time === endTime); return matchingPeriods; } diff --git a/api/src/services/technique-service.ts b/api/src/services/technique-service.ts index d878523f5d..fa2d1fd92d 100644 --- a/api/src/services/technique-service.ts +++ b/api/src/services/technique-service.ts @@ -211,7 +211,7 @@ export class TechniqueService extends DBService { const samplePeriodService = new SamplePeriodService(this.connection); await samplePeriodService .findSamplePeriodsCount(false, this.connection.systemUserId(), { - method_technique_id: methodTechniqueId, + method_technique_id: [methodTechniqueId], survey_id: surveyId }) .then((count) => { diff --git a/app/src/contexts/dialogContext.tsx b/app/src/contexts/dialogContext.tsx index 4055c4c623..d7752353c0 100644 --- a/app/src/contexts/dialogContext.tsx +++ b/app/src/contexts/dialogContext.tsx @@ -174,6 +174,8 @@ export const DialogContextProvider: React.FC = (props) setScoreDialogProps((prev) => ({ ...prev, ...partialProps })); }; + console.log(snackbarProps); + return ( { const errors: ObservationRowValidationError[] = []; - // if this row has survey_sample_site_id we need to validate that the other 2 sampling columns are also present - if (row['method_technique_id']) { + // If the row has any of the sampling columns set, then all of them must be set. + // Technically, only the period is required, but a period can only be selected if the site and technique are + // selected as well. + if (row['survey_sample_site_id'] || row['method_technique_id'] || row['survey_sample_period_id']) { + if (!row['survey_sample_site_id']) { + const header = tableColumns.find((tc) => tc.field === 'survey_sample_site_id')?.headerName; + errors.push({ field: 'survey_sample_site_id', message: `Missing column: ${header}` }); + } + if (!row['method_technique_id']) { const header = tableColumns.find((tc) => tc.field === 'method_technique_id')?.headerName; errors.push({ field: 'method_technique_id', message: `Missing column: ${header}` }); 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 index 53c9b85e11..f927a2dc68 100644 --- 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 @@ -2,6 +2,7 @@ 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 { SamplingSiteListPeriodContainer } from 'features/surveys/observations/sampling-sites/site/accordion-details/period/SamplingSiteListPeriodContainer'; import { SamplingStratumChips } from 'features/surveys/sampling-information/sites/edit/form/SamplingStratumChips'; import { useBiohubApi } from 'hooks/useBioHubApi'; import { useSurveyContext } from 'hooks/useContext'; @@ -29,12 +30,25 @@ export const SamplingSiteListContent = (props: ISamplingSiteListContentProps) => biohubApi.samplingSite.getSampleSiteById(projectId, surveyId, surveySampleSiteId) ); + const samplePeriodDataLoader = useDataLoader(() => + biohubApi.samplingPeriod.findSamplePeriods({ + survey_id: surveyId, + sample_site_id: [surveySampleSiteId] + }) + ); + useEffect(() => { sampleSiteDataLoader.load(); }, [sampleSiteDataLoader]); + useEffect(() => { + samplePeriodDataLoader.load(); + }, [samplePeriodDataLoader]); + const sampleSite = sampleSiteDataLoader.data; + const samplePeriods = samplePeriodDataLoader.data?.periods ?? []; + if (!sampleSite) { return ( @@ -47,34 +61,18 @@ export const SamplingSiteListContent = (props: ISamplingSiteListContentProps) => } return ( - <> + {sampleSite.stratums && sampleSite.stratums.length > 0 && ( - + )} - - {/* // TODO Nick */} - {/* {sampleSite.sample_methods?.map((sampleMethod, index) => { - return ( - - ); - })} */} + + - + - + ); }; diff --git a/app/src/features/surveys/observations/sampling-sites/site/accordion-details/method/SamplingSiteListMethod.tsx b/app/src/features/surveys/observations/sampling-sites/site/accordion-details/method/SamplingSiteListMethod.tsx deleted file mode 100644 index 4ef48797c5..0000000000 --- a/app/src/features/surveys/observations/sampling-sites/site/accordion-details/method/SamplingSiteListMethod.tsx +++ /dev/null @@ -1,57 +0,0 @@ -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/site/accordion-details/method/period/SamplingSiteListPeriod'; -import { useObservationsContext, useObservationsPageContext } from 'hooks/useContext'; - -export interface ISamplingSiteListMethodProps { - // TODO Nick - sampleMethod: any; -} - -/** - * Renders a list item for a single sampling method. - * - * @param {ISamplingSiteListMethodProps} props - * @return {*} - */ -export const SamplingSiteListMethod = (props: ISamplingSiteListMethodProps) => { - const { sampleMethod } = props; - - const observationsPageContext = useObservationsPageContext(); - const observationsContext = useObservationsContext(); - - return ( - - - {sampleMethod.sample_periods.length > 0 && ( - - - - )} - - ); -}; 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/period/SamplingSiteListPeriod.tsx similarity index 91% rename from app/src/features/surveys/observations/sampling-sites/site/accordion-details/method/period/SamplingSiteListPeriod.tsx rename to app/src/features/surveys/observations/sampling-sites/site/accordion-details/period/SamplingSiteListPeriod.tsx index baaa0842c0..7f0b52454a 100644 --- 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/period/SamplingSiteListPeriod.tsx @@ -8,7 +8,6 @@ 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 { ISurveySamplePeriodFormData } from 'features/surveys/sampling-information/periods/form/components/periods/SamplePeriodPeriodForm'; import { GetSamplingPeriod } from 'interfaces/useSamplingPeriodApi.interface'; interface ISamplingSiteListPeriodProps { @@ -17,14 +16,17 @@ interface ISamplingSiteListPeriodProps { observationsContext?: IObservationsContext; } /** - * Renders sampling periods for a sampling method + * Renders a timeline of sampling period dates. + * + * Includes an import observations button if the observationsPageContext and observationsContext are provided. + * * @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 { samplePeriods, observationsPageContext, observationsContext } = props; const dateSx = { fontSize: '0.85rem', @@ -36,7 +38,7 @@ export const SamplingSiteListPeriod = (props: ISamplingSiteListPeriodProps) => { color: 'text.secondary' }; - const sortedSamplePeriods = props.samplePeriods.sort((a, b) => { + const sortedSamplePeriods = samplePeriods.sort((a, b) => { if (!a.start_date && !b.start_date) { return 0; } @@ -75,12 +77,12 @@ export const SamplingSiteListPeriod = (props: ISamplingSiteListPeriodProps) => { m: 0, p: 0 }} - key={`${samplePeriod.survey_sample_period_id}-${index}`}> + key={`sample-period-${samplePeriod.survey_sample_period_id}`}> - {props.samplePeriods.length > 1 ? ( + {samplePeriods.length > 1 ? ( - {index < props.samplePeriods.length - 1 && ( + {index < samplePeriods.length - 1 && ( { { + const { samplePeriods } = props; + + const observationsPageContext = useObservationsPageContext(); + const observationsContext = useObservationsContext(); + + // Group sample periods by technique name + // Exclude sample periods that have no technique or have no start or end date. These records are incomplete, and + // users should not be able to upload observations against them. + const samplePeriodsByTechniqueMap = new Map>(); + samplePeriods.forEach((samplePeriod) => { + const techniqueName = samplePeriod.method_technique?.name; + + if (!techniqueName) { + // No technique name, skip + return; + } + + if (!samplePeriod.start_date || !samplePeriod.end_date) { + // No start or end date, skip + return; + } + + if (!samplePeriodsByTechniqueMap.has(techniqueName)) { + samplePeriodsByTechniqueMap.set(techniqueName, new Set()); + } + + const techniquePeriods = samplePeriodsByTechniqueMap.get(techniqueName); + + if (!techniquePeriods) { + return; + } + + // Add the sample period to the set for the technique name + techniquePeriods.add(samplePeriod); + }); + + return ( + <> + {Array.from(samplePeriodsByTechniqueMap).map(([techniqueName, samplePeriodsForTechniqueName]) => { + const samplePeriods = Array.from(samplePeriodsForTechniqueName); + + return ( + + + + + + + ); + })} + + ); +}; diff --git a/app/src/features/surveys/sampling-information/periods/create/CreateSamplePeriodPage.tsx b/app/src/features/surveys/sampling-information/periods/create/CreateSamplePeriodPage.tsx index 9b29de7686..96d4ca57f5 100644 --- a/app/src/features/surveys/sampling-information/periods/create/CreateSamplePeriodPage.tsx +++ b/app/src/features/surveys/sampling-information/periods/create/CreateSamplePeriodPage.tsx @@ -21,73 +21,38 @@ import { Prompt, useHistory } from 'react-router'; import yup from 'utils/YupSchema'; import SamplingSiteHeader from '../../sites/components/SamplingSiteHeader'; -export const CreateSamplingPeriodYupSchema = yup - .object({ - method_technique_id: yup.number().nullable().default(null), - survey_sample_site_id: yup.number().nullable().default(null), - sample_periods: yup - .array() - .of( - yup.object({ - start_date: yup - .string() - .typeError('Start Date is required') - .min(1, 'Start Date is required') - .isValidDateString() - .default(null), - end_date: yup - .string() - .typeError('End Date is required') - .min(1, 'End Date is required') - .isValidDateString() - .isEndDateSameOrAfterStartDate('start_date') - .default(null), - start_time: yup.string().nullable().default(null), - end_time: yup.string().nullable().default(null) - }) - ) - .nullable() - .default([]) - .test( - 'non-empty-array-validation', - 'Start and End Date are required for each period', - function (value: ISurveySamplePeriodFormData['sample_periods']) { - // Allow null or empty arrays of periods - if (value === null || value.length === 0) { - return true; - } - - // Check that each period has a non-null start_date and end_date - return value.every((item) => { - return item.start_date !== null && item.end_date !== null; - }); - } - ) - }) - .test('at-least-one-defined', 'At least one of site, technique, or sample period is required', function (value) { - const { survey_sample_site_id, method_technique_id, sample_periods } = value; - - const isSiteDefined = survey_sample_site_id !== null; - - const isTechniqueDefined = method_technique_id !== null; - - // Check if there is at least one sample period object. The validation above will handle if the contents are valid - const isAtLeastOnePeriodDefined = Array.isArray(sample_periods) && sample_periods.length > 0; - - // At least one of the conditions must be true - if (!isSiteDefined && !isTechniqueDefined && !isAtLeastOnePeriodDefined) { - const errors = [ - this.createError({ - path: 'formError', - message: 'At least one of site, technique, or sample period is required' - }) - ]; - - return new yup.ValidationError(errors); - } +export const CreateSamplingPeriodYupSchema = yup.object({ + method_technique_id: yup.number().required('Technique is required'), + survey_sample_site_id: yup.number().required('Site is required'), + sample_periods: yup + .array() + .of( + yup.object({ + start_date: yup + .string() + .typeError('Start Date is required') + .isValidDateString() + .required('Start Date is required'), + end_date: yup + .string() + .typeError('End Date is required') + .isValidDateString() + .required('End Date is required') + .isEndDateSameOrAfterStartDate('start_date'), + start_time: yup.string().nullable().default(null), + end_time: yup.string().nullable().default(null).isEndDateSameOrAfterStartDate('end_time') + }) + ) + .test('checkAtLeastOnePeriod', 'At least one period is required', function (value) { + const hasAtLeastOnPeriod = Array.isArray(value) && value.length > 0; + + if (!hasAtLeastOnPeriod) { + return this.createError({ path: 'sample_periods', message: 'At least one period is required' }); + } - return true; - }); + return true; + }) +}); /** * Renders the body content of the Sampling Period page. @@ -139,38 +104,16 @@ export const CreateSamplePeriodPage = () => { const samplePeriodData: CreateSamplingPeriod[] = []; // Transform the form data to match the API request format - // Default optional fields to null if the value is an empty-string + // Data should have already been validated by the Yup schema for (const period of values.sample_periods) { - if (values.survey_sample_site_id) { - samplePeriodData.push({ - survey_sample_site_id: values.survey_sample_site_id, - method_technique_id: values.method_technique_id || null, - start_date: period.start_date || null, - start_time: period.start_time || null, - end_date: period.end_date || null, - end_time: period.end_time || null - }); - } else if (values.method_technique_id) { - samplePeriodData.push({ - survey_sample_site_id: values.survey_sample_site_id || null, - method_technique_id: values.method_technique_id, - start_date: period.start_date || null, - start_time: period.start_time || null, - end_date: period.end_date || null, - end_time: period.end_time || null - }); - } else if (period.start_date && period.end_date) { - samplePeriodData.push({ - survey_sample_site_id: values.survey_sample_site_id || null, - method_technique_id: values.method_technique_id || null, - start_date: period.start_date, - start_time: period.start_time || null, - end_date: period.end_date, - end_time: period.end_time || null - }); - } else { - throw new Error('At least one of site, technique, or sample period is required'); - } + samplePeriodData.push({ + survey_sample_site_id: values.survey_sample_site_id as number, + method_technique_id: values.method_technique_id as number, + start_date: period.start_date as string, + start_time: period.start_time || null, + end_date: period.end_date as string, + end_time: period.end_time || null + }); } await biohubApi.samplingPeriod.createSamplingPeriods( diff --git a/app/src/features/surveys/sampling-information/periods/edit/EditSamplePeriodPage.tsx b/app/src/features/surveys/sampling-information/periods/edit/EditSamplePeriodPage.tsx index 17913b0a98..03e55e1040 100644 --- a/app/src/features/surveys/sampling-information/periods/edit/EditSamplePeriodPage.tsx +++ b/app/src/features/surveys/sampling-information/periods/edit/EditSamplePeriodPage.tsx @@ -21,73 +21,40 @@ import { Prompt, useHistory, useParams } from 'react-router'; import yup from 'utils/YupSchema'; import SamplingSiteHeader from '../../sites/components/SamplingSiteHeader'; -export const EditSamplingSiteMethodPeriodYupSchema = yup - .object({ - method_technique_id: yup.number().nullable().default(null), - survey_sample_site_id: yup.number().nullable().default(null), - sample_periods: yup - .array() - .of( - yup.object({ - start_date: yup - .string() - .typeError('Start Date is required') - .min(1, 'Start Date is required') - .isValidDateString() - .default(null), - end_date: yup - .string() - .typeError('End Date is required') - .min(1, 'End Date is required') - .isValidDateString() - .isEndDateSameOrAfterStartDate('start_date') - .default(null), - start_time: yup.string().nullable().default(null), - end_time: yup.string().nullable().default(null) - }) - ) - .nullable() - .default([]) - .test( - 'non-empty-array-validation', - 'Start and End Date are required for each period', - function (value: ISurveySamplePeriodFormData['sample_periods']) { - // Allow null or empty arrays of periods - if (value === null || value.length === 0) { - return true; - } - - // Check that each period has a non-null start_date and end_date - return value.every((item) => { - return item.start_date !== null && item.end_date !== null; - }); - } - ) - }) - .test('at-least-one-defined', 'At least one of site, technique, or sample period is required', function (value) { - const { survey_sample_site_id, method_technique_id, sample_periods } = value; - - const isSiteDefined = survey_sample_site_id !== null; - - const isTechniqueDefined = method_technique_id !== null; - - // Check if there is at least one sample period object. The validation above will handle if the contents are valid - const isAtLeastOnePeriodDefined = Array.isArray(sample_periods) && sample_periods.length > 0; - - // At least one of the conditions must be true - if (!isSiteDefined && !isTechniqueDefined && !isAtLeastOnePeriodDefined) { - const errors = [ - this.createError({ - path: 'formError', - message: 'At least one of site, technique, or sample period is required' - }) - ]; - - return new yup.ValidationError(errors); - } +export const EditSamplingSiteMethodPeriodYupSchema = yup.object({ + method_technique_id: yup.number().required('Technique is required'), + survey_sample_site_id: yup.number().required('Site is required'), + sample_periods: yup + .array() + .of( + yup.object({ + start_date: yup + .string() + .typeError('Start Date is required') + .isValidDateString() + .min(1, 'End Date is required') + .required('Start Date is required'), + end_date: yup + .string() + .typeError('End Date is required') + .isValidDateString() + .min(1, 'End Date is required') + .isEndDateSameOrAfterStartDate('start_date') + .required('End Date is required'), + start_time: yup.string().nullable().default(null), + end_time: yup.string().nullable().default(null).isEndTimeAfterStartTime('start_time') + }) + ) + .test('checkAtLeastOnePeriod', 'At least one period is required', function (value) { + const hasAtLeastOnPeriod = Array.isArray(value) && value.length > 0; + + if (!hasAtLeastOnPeriod) { + return this.createError({ path: 'sample_periods', message: 'At least one period is required' }); + } - return true; - }); + return true; + }) +}); /** * Renders page for editing a sampling period @@ -159,42 +126,18 @@ export const EditSamplePeriodPage = () => { throw new Error('Only one sample period can be edited at a time'); } - const samplePeriod = values.sample_periods.length ? values.sample_periods[0] : null; - - let samplePeriodData: UpdateSamplingPeriod; + const samplePeriod = values.sample_periods[0]; // Transform the form data to match the API request format - // Default optional fields to null if the value is an empty-string - if (values.survey_sample_site_id) { - samplePeriodData = { - survey_sample_site_id: values.survey_sample_site_id, - method_technique_id: values.method_technique_id || null, - start_date: samplePeriod?.start_date || null, - start_time: samplePeriod?.start_time || null, - end_date: samplePeriod?.end_date || null, - end_time: samplePeriod?.end_time || null - }; - } else if (values.method_technique_id) { - samplePeriodData = { - survey_sample_site_id: values.survey_sample_site_id || null, - method_technique_id: values.method_technique_id, - start_date: samplePeriod?.start_date || null, - start_time: samplePeriod?.start_time || null, - end_date: samplePeriod?.end_date || null, - end_time: samplePeriod?.end_time || null - }; - } else if (samplePeriod?.start_date && samplePeriod?.end_date) { - samplePeriodData = { - survey_sample_site_id: values.survey_sample_site_id || null, - method_technique_id: values.method_technique_id || null, - start_date: samplePeriod.start_date, - start_time: samplePeriod.start_time || null, - end_date: samplePeriod.end_date, - end_time: samplePeriod.end_time || null - }; - } else { - throw new Error('At least one of site, technique, or sample period is required'); - } + // Data should have already been validated by the Yup schema + const samplePeriodData: UpdateSamplingPeriod = { + survey_sample_site_id: values.survey_sample_site_id as number, + method_technique_id: values.method_technique_id as number, + start_date: samplePeriod.start_date as string, + start_time: samplePeriod?.start_time || null, + end_date: samplePeriod.end_date as string, + end_time: samplePeriod?.end_time || null + }; await biohubApi.samplingPeriod.updateSamplingPeriod( surveyContext.projectId, diff --git a/app/src/features/surveys/sampling-information/periods/form/SamplePeriodForm.tsx b/app/src/features/surveys/sampling-information/periods/form/SamplePeriodForm.tsx index 7559ffffaf..effac1dc7f 100644 --- a/app/src/features/surveys/sampling-information/periods/form/SamplePeriodForm.tsx +++ b/app/src/features/surveys/sampling-information/periods/form/SamplePeriodForm.tsx @@ -1,9 +1,7 @@ import LoadingButton from '@mui/lab/LoadingButton'; -import Box from '@mui/material/Box'; import Button from '@mui/material/Button'; import Divider from '@mui/material/Divider'; import Stack from '@mui/material/Stack'; -import AlertBar from 'components/alert/AlertBar'; import HorizontalSplitFormComponent from 'components/fields/HorizontalSplitFormComponent'; import { InitialSurveySamplePeriodPeriodFormData, @@ -21,7 +19,6 @@ export interface ISurveySamplePeriodFormData { method_technique_id: number | null; survey_sample_site_id: number | null; sample_periods: ISurveySamplePeriodPeriodFormData[]; - formError?: string; // Used to store form level errors, if any } export const InitialSurveySamplePeriodFormData = { @@ -48,9 +45,10 @@ export const SamplePeriodForm = (props: ISamplePeriodFormProps) => { const history = useHistory(); - const { submitForm, errors } = useFormikContext(); + const { submitForm } = useFormikContext(); - // Limit the number of periods that can be added to 1 if editing an existing period. + // Limit the number of periods that can be added or removed to 1 if editing an existing period. + const minimumNumberOfPeriods = editData !== undefined ? 1 : 0; const maximumNumberOfPeriods = editData !== undefined ? 1 : 0; return ( @@ -68,15 +66,12 @@ export const SamplePeriodForm = (props: ISamplePeriodFormProps) => { - + - {errors.formError && ( - - - - )} - diff --git a/app/src/features/surveys/sampling-information/periods/form/components/periods/SamplePeriodPeriodForm.tsx b/app/src/features/surveys/sampling-information/periods/form/components/periods/SamplePeriodPeriodForm.tsx index 9b4e948e49..8b22f5ee5d 100644 --- a/app/src/features/surveys/sampling-information/periods/form/components/periods/SamplePeriodPeriodForm.tsx +++ b/app/src/features/surveys/sampling-information/periods/form/components/periods/SamplePeriodPeriodForm.tsx @@ -13,8 +13,8 @@ export interface ISurveySamplePeriodPeriodFormData { // Data for the request survey_sample_period_id?: number | null; // Will be null for new periods, and a number for existing periods start_date: string | null; - end_date: string | null; start_time: string | null; + end_date: string | null; end_time: string | null; } @@ -29,76 +29,69 @@ export const InitialSurveySamplePeriodPeriodFormData: ISurveySamplePeriodPeriodF interface ISamplePeriodPeriodFormProps { index: number; + isDeleteDisabled?: boolean; onDelete?: () => void; } export const SamplePeriodPeriodForm = (props: ISamplePeriodPeriodFormProps) => { - const { index, onDelete } = props; + const { index, isDeleteDisabled, onDelete } = props; const formikProps = useFormikContext(); return ( - - {/* Start Date/Time Fields */} - - - + + + {/* Start Date/Time Fields */} + + + - {/* Arrow Separator */} - - - + {/* Arrow Separator */} + + + - {/* End Date/Time Fields */} - - - + {/* End Date/Time Fields */} + + + + {/* Remove Button */} - {onDelete && ( - + {onDelete && !isDeleteDisabled && ( + onDelete()}> diff --git a/app/src/features/surveys/sampling-information/periods/form/components/periods/SamplingPeriodPeriodFormContainer.tsx b/app/src/features/surveys/sampling-information/periods/form/components/periods/SamplingPeriodPeriodFormContainer.tsx index e8e095a412..5a5b8d843d 100644 --- a/app/src/features/surveys/sampling-information/periods/form/components/periods/SamplingPeriodPeriodFormContainer.tsx +++ b/app/src/features/surveys/sampling-information/periods/form/components/periods/SamplingPeriodPeriodFormContainer.tsx @@ -3,6 +3,7 @@ import Icon from '@mdi/react'; import Box from '@mui/material/Box'; import Button from '@mui/material/Button'; import Grid from '@mui/material/Grid'; +import AlertBar from 'components/alert/AlertBar'; import { InitialSurveySamplePeriodPeriodFormData, SamplePeriodPeriodForm @@ -12,6 +13,13 @@ import { FieldArray, FieldArrayRenderProps, useFormikContext } from 'formik'; import { v4 } from 'uuid'; export interface ISamplingPeriodPeriodFormContainerProps { + /** + * Limit the number of periods that can be removed. If not provided, there is no limit. + * + * @type {number} + * @memberof ISamplingPeriodPeriodFormContainerProps + */ + minimumNumberOfPeriods?: number; /** * Limit the number of periods that can be added. If not provided, there is no limit. * @@ -22,9 +30,16 @@ export interface ISamplingPeriodPeriodFormContainerProps { } export const SamplingPeriodPeriodFormContainer = (props: ISamplingPeriodPeriodFormContainerProps) => { - const { maximumNumberOfPeriods } = props; + const { minimumNumberOfPeriods, maximumNumberOfPeriods } = props; - const { values } = useFormikContext(); + const { values, errors } = useFormikContext(); + + const isAddPeriodButtonDisabled = maximumNumberOfPeriods && values.sample_periods.length >= maximumNumberOfPeriods; + + const isDeletePeriodDisabled = + minimumNumberOfPeriods !== undefined && values.sample_periods.length <= (props.minimumNumberOfPeriods ?? 0); + + const alertBarErrorText = errors.sample_periods && typeof errors.sample_periods === 'string' && errors.sample_periods; return ( { return ( - {values.sample_periods.map((_period, index) => { + {values.sample_periods.map((period, index) => { return ( - + { arrayHelpers.remove(index); }} @@ -46,27 +62,33 @@ export const SamplingPeriodPeriodFormContainer = (props: ISamplingPeriodPeriodFo ); })} - {/* Disable the ability to add additional periods if editing an existing period. */} - + {!isAddPeriodButtonDisabled && ( + + )} + + {alertBarErrorText && ( + + + + )} ); }} diff --git a/app/src/features/surveys/view/survey-sampling/components/period/SurveyPeriodsTable.tsx b/app/src/features/surveys/view/survey-sampling/components/period/SurveyPeriodsTable.tsx index dc39fe5675..80cff60333 100644 --- a/app/src/features/surveys/view/survey-sampling/components/period/SurveyPeriodsTable.tsx +++ b/app/src/features/surveys/view/survey-sampling/components/period/SurveyPeriodsTable.tsx @@ -2,12 +2,11 @@ import Typography from '@mui/material/Typography'; 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'; import { useCodesContext } from 'hooks/useContext'; import { GetSamplingPeriod } from 'interfaces/useSamplingPeriodApi.interface'; import { useEffect } from 'react'; import { formatTimeDifference } from 'utils/datetime'; -import { getCodesName } from 'utils/Utils'; +import { getCodesName, getFormattedDate } from 'utils/Utils'; interface ISamplingPeriodTableProps { periods: GetSamplingPeriod[]; @@ -77,7 +76,7 @@ export const SurveyPeriodsTable = (props: ISamplingPeriodTableProps) => { headerName: 'Start date', flex: 1, renderCell: (params) => ( - {dayjs(params.row.start_date).format(DATE_FORMAT.MediumDateFormat)} + {getFormattedDate(DATE_FORMAT.MediumDateFormat, params.row.start_date)} ) }, { @@ -90,7 +89,7 @@ export const SurveyPeriodsTable = (props: ISamplingPeriodTableProps) => { headerName: 'End date', flex: 1, renderCell: (params) => ( - {dayjs(params.row.end_date).format(DATE_FORMAT.MediumDateFormat)} + {getFormattedDate(DATE_FORMAT.MediumDateFormat, params.row.end_date)} ) }, { diff --git a/app/src/features/surveys/view/survey-spatial/components/observation/analytics/components/ObservationAnalyticsDataTableContainer.tsx b/app/src/features/surveys/view/survey-spatial/components/observation/analytics/components/ObservationAnalyticsDataTableContainer.tsx index bc7de42a01..be71083916 100644 --- a/app/src/features/surveys/view/survey-spatial/components/observation/analytics/components/ObservationAnalyticsDataTableContainer.tsx +++ b/app/src/features/surveys/view/survey-spatial/components/observation/analytics/components/ObservationAnalyticsDataTableContainer.tsx @@ -17,8 +17,10 @@ import { getDateColDef, getIndividualCountColDef, getIndividualPercentageColDef, + getMethodTechniqueColDef, getRowCountColDef, getSamplingPeriodColDef, + getSamplingSiteColDef, getSpeciesColDef } from './ObservationsAnalyticsGridColumnDefinitions'; @@ -114,8 +116,8 @@ export const ObservationAnalyticsDataTableContainer = (props: IObservationAnalyt getRowCountColDef(), getIndividualCountColDef(), getIndividualPercentageColDef(), - // getSamplingSiteColDef(samplePeriods), - // getMethodTechniqueColDef(samplePeriods), + getSamplingSiteColDef(samplePeriods), + getMethodTechniqueColDef(samplePeriods), getSamplingPeriodColDef(samplePeriods), getSpeciesColDef(taxonomyContext.getCachedSpeciesTaxonomyById), getDateColDef(), diff --git a/app/src/hooks/api/useSamplingPeriodApi.ts b/app/src/hooks/api/useSamplingPeriodApi.ts index 10c4f49ff7..0f65a36c15 100644 --- a/app/src/hooks/api/useSamplingPeriodApi.ts +++ b/app/src/hooks/api/useSamplingPeriodApi.ts @@ -80,8 +80,8 @@ export const useSamplingPeriodApi = (axios: AxiosInstance) => { * * @param {{ * survey_id?: number; - * sample_site_id: number; - * method_technique_id: number; + * sample_site_id?: number[]; + * method_technique_id?: number[]; * system_user_id?: number; * }} [filterFieldData] * @param {ApiPaginationRequestOptions} [pagination] @@ -90,8 +90,8 @@ export const useSamplingPeriodApi = (axios: AxiosInstance) => { const findSamplePeriods = async ( filterFieldData?: { survey_id?: number; - sample_site_id?: number; - method_technique_id?: number; + sample_site_id?: number[]; + method_technique_id?: number[]; system_user_id?: number; }, pagination?: ApiPaginationRequestOptions diff --git a/app/src/interfaces/useSamplingPeriodApi.interface.ts b/app/src/interfaces/useSamplingPeriodApi.interface.ts index 2e78581b27..d5c4c0555f 100644 --- a/app/src/interfaces/useSamplingPeriodApi.interface.ts +++ b/app/src/interfaces/useSamplingPeriodApi.interface.ts @@ -106,32 +106,10 @@ export type FindSamplingPeriods = { export type UpdateSamplingPeriod = { survey_sample_period_id?: number; -} & ( - | { - // At least survey sample site is not null - survey_sample_site_id: number; - method_technique_id: number | null; - start_date: string | null; - start_time: string | null; - end_date: string | null; - end_time: string | null; - } - | { - // At least method technique is not null - survey_sample_site_id: number | null; - method_technique_id: number; - start_date: string | null; - start_time: string | null; - end_date: string | null; - end_time: string | null; - } - | { - // At least start/end date are not null - survey_sample_site_id: number | null; - method_technique_id: number | null; - start_date: string; - start_time: string | null; - end_date: string; - end_time: string | null; - } -); + survey_sample_site_id: number; + method_technique_id: number; + start_date: string; + start_time: string | null; + end_date: string; + end_time: string | null; +}; diff --git a/app/src/interfaces/useTelemetryApi.interface.ts b/app/src/interfaces/useTelemetryApi.interface.ts index f4a1079a05..26f8c84b9c 100644 --- a/app/src/interfaces/useTelemetryApi.interface.ts +++ b/app/src/interfaces/useTelemetryApi.interface.ts @@ -3,7 +3,6 @@ import { FeatureCollection, Point } from 'geojson'; import { ApiPaginationResponseParams } from 'types/misc'; import yup from 'utils/YupSchema'; -// TODO Nick - Replace with new schema export interface IFindTelementryObj { telemetry_id: string; deployment_id: number; diff --git a/app/src/utils/YupSchema.ts b/app/src/utils/YupSchema.ts index cbe50cd099..a09847f03d 100644 --- a/app/src/utils/YupSchema.ts +++ b/app/src/utils/YupSchema.ts @@ -3,11 +3,14 @@ * - See types/yup.d.ts */ -import { DATE_FORMAT } from 'constants/dateTimeFormats'; -import { default as dayjs } from 'dayjs'; +import { DATE_FORMAT, TIME_FORMAT } from 'constants/dateTimeFormats'; +import dayjs from 'dayjs'; +import isSameOrBefore from 'dayjs/plugin/isSameOrBefore'; import { isValidCoordinates } from 'utils/spatial-utils'; import * as yup from 'yup'; +dayjs.extend(isSameOrBefore); + yup.addMethod(yup.array, 'isUniqueIUCNClassificationDetail', function (message: string) { return this.test('is-unique-iucn-classification-detail', message, (values) => { if (!values || !values.length) { @@ -50,11 +53,11 @@ yup.addMethod( return true; } - const endDateTime = dayjs(`2020-10-20 ${this.parent.end_time}`, DATE_FORMAT.ShortDateTimeFormat); - const startDateTime = dayjs(`2020-10-20 ${this.parent[startTimeName]}`, DATE_FORMAT.ShortDateTimeFormat); + const endDateTime = dayjs(this.parent.end_time, TIME_FORMAT.LongTimeFormat24Hour); + const startDateTime = dayjs(this.parent[startTimeName], TIME_FORMAT.LongTimeFormat24Hour); // compare valid start and end times - return startDateTime.isBefore(endDateTime); + return startDateTime.isSameOrBefore(endDateTime); }); } ); @@ -79,7 +82,7 @@ yup.addMethod( } // compare valid start and end dates - return dayjs(this.parent[startDateName], dateFormat, true).isBefore(dayjs(value, dateFormat, true)); + return dayjs(this.parent[startDateName], dateFormat, true).isSameOrBefore(dayjs(value, dateFormat, true)); }); } );