diff --git a/apps/backend/db_patches/0214_AddDateRangePicker.sql b/apps/backend/db_patches/0214_AddDateRangePicker.sql new file mode 100644 index 0000000000..fc7cfa379f --- /dev/null +++ b/apps/backend/db_patches/0214_AddDateRangePicker.sql @@ -0,0 +1,13 @@ +DO +$$ +BEGIN + IF register_patch('AddDateRangePicker.sql', 'Zachary Hankin', 'Adding date range picker type', '2026-07-07') THEN + BEGIN + + INSERT INTO question_datatypes VALUES('DATE_RANGE_PICKER'); + + END; + END IF; +END; +$$ +LANGUAGE plpgsql; \ No newline at end of file diff --git a/apps/backend/src/models/Template.ts b/apps/backend/src/models/Template.ts index ead41414ba..32937fd48d 100644 --- a/apps/backend/src/models/Template.ts +++ b/apps/backend/src/models/Template.ts @@ -17,6 +17,7 @@ export class FieldDependency { export enum DataType { BOOLEAN = 'BOOLEAN', DATE = 'DATE', + DATE_RANGE_PICKER = 'DATE_RANGE_PICKER', EMBELLISHMENT = 'EMBELLISHMENT', FILE_UPLOAD = 'FILE_UPLOAD', GENERIC_TEMPLATE = 'GENERIC_TEMPLATE', diff --git a/apps/backend/src/models/questionTypes/DateRange.ts b/apps/backend/src/models/questionTypes/DateRange.ts new file mode 100644 index 0000000000..0a7830ebb3 --- /dev/null +++ b/apps/backend/src/models/questionTypes/DateRange.ts @@ -0,0 +1,62 @@ +/* eslint-disable quotes */ +import { GraphQLError } from 'graphql'; +import * as Yup from 'yup'; + +import { DateRangeConfig } from '../../resolvers/types/FieldConfig'; +import { QuestionFilterCompareOperator } from '../Questionary'; +import { DataType, QuestionTemplateRelation } from '../Template'; +import { Question } from './QuestionRegistry'; + +//TODO add validation script to duo-validation +export const dateRangeQuestionValidationSchema = (answer: any) => { + const config = answer.config; + let dateRangeSchema = Yup.array() + .of( + Yup.object({ + from: Yup.date().required(), + to: Yup.date().required(), + }) + ) + .required(); + if (config.required) { + dateRangeSchema = dateRangeSchema.min(1, 'A daterange is reqruired'); + } + + return Yup.object({ dateRanges: dateRangeSchema }); +}; + +export const dateRangeDefinition: Question = { + dataType: DataType.DATE_RANGE_PICKER, + validate: (field: QuestionTemplateRelation, value: Date | null) => { + if (field.question.dataType !== DataType.DATE_RANGE_PICKER) { + throw new GraphQLError('DataType should be of DATE RANGE type'); + } + + return dateRangeQuestionValidationSchema(field).isValid(value); + }, + createBlankConfig: (): DateRangeConfig => { + const config = new DateRangeConfig(); + config.small_label = ''; + config.required = false; + config.tooltip = ''; + config.readPermissions = []; + config.includeTime = false; + + return config; + }, + getDefaultAnswer: (relation: QuestionTemplateRelation) => null, + filterQuery: (queryBuilder, filter) => { + switch (filter.compareOperator) { + case QuestionFilterCompareOperator.EQUALS: + return true; + case QuestionFilterCompareOperator.GREATER_THAN: + return true; + case QuestionFilterCompareOperator.LESS_THAN: + return true; + default: + throw new GraphQLError( + `Unsupported comparator for TextInput ${filter.compareOperator}` + ); + } + }, +}; diff --git a/apps/backend/src/models/questionTypes/QuestionRegistry.ts b/apps/backend/src/models/questionTypes/QuestionRegistry.ts index 52f5a80eb1..17f7f029fe 100644 --- a/apps/backend/src/models/questionTypes/QuestionRegistry.ts +++ b/apps/backend/src/models/questionTypes/QuestionRegistry.ts @@ -29,10 +29,12 @@ import { TextInputConfig, VisitBasisConfig, ExperimentSafetyReviewBasisConfig, + DateRangeConfig, } from '../../resolvers/types/FieldConfig'; import { DataType, QuestionTemplateRelation } from '../Template'; import { booleanDefinition } from './Boolean'; import { dateDefinition } from './Date'; +import { dateRangeDefinition } from './DateRange'; import { dynamicMultipleChoiceDefinition } from './DynamicMultipleChoice'; import { embellishmentDefinition } from './Embellishment'; import { experimentSafetyReviewBasisDefinition } from './ExperimentSafetyReviewBasis'; @@ -108,8 +110,9 @@ export type QuestionDataTypeConfigMapping = ? InstrumentPickerConfig : T extends DataType.TECHNIQUE_PICKER ? TechniquePickerConfig - : never; - + : T extends DataType.DATE_RANGE_PICKER + ? DateRangeConfig + : never; export interface Question { /** * The enum value from DataType @@ -172,6 +175,7 @@ export interface Question { const registry = [ booleanDefinition, dateDefinition, + dateRangeDefinition, embellishmentDefinition, feedbackBasisDefinition, fileUploadDefinition, diff --git a/apps/backend/src/resolvers/CustomScalars.ts b/apps/backend/src/resolvers/CustomScalars.ts index 7c6e506273..74334f9805 100644 --- a/apps/backend/src/resolvers/CustomScalars.ts +++ b/apps/backend/src/resolvers/CustomScalars.ts @@ -3,6 +3,7 @@ import { GraphQLScalarType, Kind } from 'graphql'; export type AnswerType = number | string | Date | boolean | number[] | unknown; const coerce = (value: AnswerType) => { + //console.log(`Coercing: ${JSON.stringify(value)}`); if (typeof value === 'number' && Number.isInteger(value)) { return value; } diff --git a/apps/backend/src/resolvers/mutations/AnswerTopicMutation.ts b/apps/backend/src/resolvers/mutations/AnswerTopicMutation.ts index fb2d6ac0e3..c47842c498 100644 --- a/apps/backend/src/resolvers/mutations/AnswerTopicMutation.ts +++ b/apps/backend/src/resolvers/mutations/AnswerTopicMutation.ts @@ -44,6 +44,8 @@ export class UpdateQuestionaryMutation { args: AnswerTopicArgs, @Ctx() context: ResolverContext ) { + //console.log(`backend answerTopic called with ${JSON.stringify(args)}`); + return context.mutations.questionary.answerTopic(context.user, args); } } diff --git a/apps/backend/src/resolvers/types/FieldConfig.ts b/apps/backend/src/resolvers/types/FieldConfig.ts index dd845a0bbe..74e4060496 100644 --- a/apps/backend/src/resolvers/types/FieldConfig.ts +++ b/apps/backend/src/resolvers/types/FieldConfig.ts @@ -94,6 +94,20 @@ export class DateConfig extends ConfigBase { includeTime: boolean; } +@ObjectType() +export class DateRangeConfig extends ConfigBase { + @Field(() => String, { nullable: true }) + defaultDate: + | { + from: Date | undefined; + to?: Date | undefined; + }[] + | null; + + @Field(() => Boolean) + includeTime: boolean; +} + @ObjectType() export class EmbellishmentConfig { @Field(() => Boolean) @@ -389,6 +403,7 @@ export const FieldConfigType = createUnionType({ types: () => [ BooleanConfig, DateConfig, + DateRangeConfig, EmbellishmentConfig, FileUploadConfig, SelectionFromOptionsConfig, diff --git a/apps/frontend/src/components/questionary/QuestionaryComponentRegistry.tsx b/apps/frontend/src/components/questionary/QuestionaryComponentRegistry.tsx index 96a9b809be..94f88bfa41 100644 --- a/apps/frontend/src/components/questionary/QuestionaryComponentRegistry.tsx +++ b/apps/frontend/src/components/questionary/QuestionaryComponentRegistry.tsx @@ -18,6 +18,7 @@ import { QuestionarySubmissionState } from 'models/questionary/QuestionarySubmis import { booleanDefinition } from './questionaryComponents/Boolean/BooleanDefinition'; import { dateDefinition } from './questionaryComponents/DatePicker/DatePickerDefinition'; +import { dateRangeDefinition } from './questionaryComponents/DateRangePicker/DateRangePickerDefinition'; import { dynamicMultipleChoiceDefinition } from './questionaryComponents/DynamicMultipleChoice/DynamicMultipleChoiceDefinition'; import { embellishmentDefinition } from './questionaryComponents/Embellishment/EmbellishmentDefinition'; import { experimentSafetyReviewBasisDefinition } from './questionaryComponents/ExperimentSafetyReviewBasis/experimentSafetyReviewBasisDefinition'; @@ -163,6 +164,7 @@ export interface QuestionaryComponentDefinition { const registry = [ booleanDefinition, dateDefinition, + dateRangeDefinition, embellishmentDefinition, feedbackBasisDefinition, fileUploadDefinition, diff --git a/apps/frontend/src/components/questionary/QuestionaryStepView.tsx b/apps/frontend/src/components/questionary/QuestionaryStepView.tsx index 42f443e705..a2e21a7f0f 100644 --- a/apps/frontend/src/components/questionary/QuestionaryStepView.tsx +++ b/apps/frontend/src/components/questionary/QuestionaryStepView.tsx @@ -340,6 +340,9 @@ function QuestionaryStepView(props: { answer: field, formikProps, onComplete: (newValue: Answer['value']) => { + console.log( + `Executing onComplete with ${JSON.stringify(newValue)}` + ); if (field.value !== newValue) { dispatch({ type: 'FIELD_CHANGED', diff --git a/apps/frontend/src/components/questionary/questionaryComponents/DateRangePicker/DateRangeAnswerRenderer.tsx b/apps/frontend/src/components/questionary/questionaryComponents/DateRangePicker/DateRangeAnswerRenderer.tsx new file mode 100644 index 0000000000..bd4d089e96 --- /dev/null +++ b/apps/frontend/src/components/questionary/questionaryComponents/DateRangePicker/DateRangeAnswerRenderer.tsx @@ -0,0 +1,37 @@ +import React from 'react'; + +import { AnswerRenderer } from 'components/questionary/QuestionaryComponentRegistry'; +import { Maybe, Scalars, SettingsId } from 'generated/sdk'; +import { useFormattedDateTime } from 'hooks/admin/useFormattedDateTime'; + +// NOTE: This is additional component because of some react warning with hooks when we use the useFormattedDateTime inside default DateAnswerRenderer component. +const DateRangeAnswerValueRenderer = ({ + value, +}: { + value: Maybe; +}) => { + const settingsFormatToUse = SettingsId.DATE_FORMAT; + const { toFormattedDateTime } = useFormattedDateTime({ + settingsFormatToUse, + }); + if (value?.dateRanges?.[0]?.from && value?.dateRanges?.[0]?.to) { + return ( + + {toFormattedDateTime(value.dateRanges[0].from)} :{' '} + {toFormattedDateTime(value.dateRanges[0].to)} + + ); + } + + return Invalid date range; +}; + +const DateAnswerRenderer: AnswerRenderer = ({ value }) => { + if (!value) { + return Left blank; + } + + return ; +}; + +export default DateAnswerRenderer; diff --git a/apps/frontend/src/components/questionary/questionaryComponents/DateRangePicker/DateRangePickerDefinition.tsx b/apps/frontend/src/components/questionary/questionaryComponents/DateRangePicker/DateRangePickerDefinition.tsx new file mode 100644 index 0000000000..761fb24974 --- /dev/null +++ b/apps/frontend/src/components/questionary/questionaryComponents/DateRangePicker/DateRangePickerDefinition.tsx @@ -0,0 +1,50 @@ +import TodayIcon from '@mui/icons-material/Today'; +//import { dateRangeQuestionValidationSchema } from '@user-office-software/duo-validation'; +import React from 'react'; +import * as Yup from 'yup'; + +import defaultRenderer from 'components/questionary/DefaultQuestionRenderer'; +import { DataType, DateRangeConfig, Answer } from 'generated/sdk'; + +import DateRangeAnswerRenderer from './DateRangeAnswerRenderer'; +import DateSearchCriteriaInput from './DateRangeSearchCriteriaInput'; +import { QuestionaryComponentDateRangePicker } from './QuestionaryComponentDateRangePicker'; +import { QuestionDateForm } from './QuestionDateRangeForm'; +import { QuestionTemplateRelationDateForm } from './QuestionTemplateRelationDateRangeForm'; +import { QuestionaryComponentDefinition } from '../../QuestionaryComponentRegistry'; + +//TODO add validation script to duo-validation +export const dateRangeQuestionValidationSchema = (answer: Answer) => { + const config = answer.config as DateRangeConfig; + let dateRangeSchema = Yup.array() + .of( + Yup.object({ + from: Yup.date().required(), + to: Yup.date().required(), + }) + ) + .required(); + if (config.required) { + dateRangeSchema = dateRangeSchema.min(1, 'A daterange is required.'); + } + + return Yup.object({ dateRanges: dateRangeSchema }); +}; + +export const dateRangeDefinition: QuestionaryComponentDefinition = { + dataType: DataType.DATE_RANGE_PICKER, + name: 'Date Range', + questionaryComponent: QuestionaryComponentDateRangePicker, + questionForm: () => QuestionDateForm, + questionTemplateRelationForm: () => QuestionTemplateRelationDateForm, + readonly: false, + creatable: true, + icon: , + renderers: { + questionRenderer: defaultRenderer.questionRenderer, + answerRenderer: DateRangeAnswerRenderer, + }, + createYupValidationSchema: dateRangeQuestionValidationSchema, + getYupInitialValue: ({ answer }) => answer.value, + searchCriteriaComponent: DateSearchCriteriaInput, +}; diff --git a/apps/frontend/src/components/questionary/questionaryComponents/DateRangePicker/DateRangeSearchCriteriaInput.tsx b/apps/frontend/src/components/questionary/questionaryComponents/DateRangePicker/DateRangeSearchCriteriaInput.tsx new file mode 100644 index 0000000000..217dd82bb0 --- /dev/null +++ b/apps/frontend/src/components/questionary/questionaryComponents/DateRangePicker/DateRangeSearchCriteriaInput.tsx @@ -0,0 +1,108 @@ +import FormControl from '@mui/material/FormControl'; +import Grid from '@mui/material/Grid'; +import InputLabel from '@mui/material/InputLabel'; +import MenuItem from '@mui/material/MenuItem'; +import Select from '@mui/material/Select'; +import { useTheme } from '@mui/material/styles'; +import { AdapterLuxon as DateAdapter } from '@mui/x-date-pickers/AdapterLuxon'; +import { DatePicker } from '@mui/x-date-pickers/DatePicker'; +import { LocalizationProvider } from '@mui/x-date-pickers/LocalizationProvider'; +import { DateTime } from 'luxon'; +import React, { useState, useEffect } from 'react'; + +import { SearchCriteriaInputProps } from 'components/proposal/SearchCriteriaInputProps'; +import { QuestionFilterCompareOperator, SettingsId } from 'generated/sdk'; +import { useFormattedDateTime } from 'hooks/admin/useFormattedDateTime'; + +function DateSearchCriteriaInput({ + onChange, + searchCriteria, +}: SearchCriteriaInputProps) { + const theme = useTheme(); + const { format } = useFormattedDateTime({ + settingsFormatToUse: SettingsId.DATE_FORMAT, + }); + const [value, setValue] = useState( + searchCriteria + ? DateTime.fromISO((searchCriteria.value as string) || '') + : null + ); + const [comparator, setComparator] = useState( + searchCriteria?.compareOperator ?? QuestionFilterCompareOperator.EQUALS + ); + + useEffect(() => { + if (!searchCriteria?.value) { + setValue(null); + } + }, [searchCriteria?.value]); + + return ( + + + + + Operator + + + + + + + { + const newDate = date?.startOf('day'); + + if (newDate && newDate.isValid) { + onChange(comparator, newDate.toJSDate().toISOString()); + } + setValue(newDate || null); + }} + desktopModeMediaQuery={theme.breakpoints.up('sm')} + /> + + + + ); +} + +export default DateSearchCriteriaInput; diff --git a/apps/frontend/src/components/questionary/questionaryComponents/DateRangePicker/QuestionDateRangeForm.tsx b/apps/frontend/src/components/questionary/questionaryComponents/DateRangePicker/QuestionDateRangeForm.tsx new file mode 100644 index 0000000000..586924b63e --- /dev/null +++ b/apps/frontend/src/components/questionary/questionaryComponents/DateRangePicker/QuestionDateRangeForm.tsx @@ -0,0 +1,76 @@ +import { Field } from 'formik'; +import React from 'react'; +import * as Yup from 'yup'; + +import CheckboxWithLabel from 'components/common/FormikUICheckboxWithLabel'; +import TextField from 'components/common/FormikUITextField'; +import TitledContainer from 'components/common/TitledContainer'; +import { QuestionFormProps } from 'components/questionary/QuestionaryComponentRegistry'; +import { useNaturalKeySchema } from 'utils/userFieldValidationSchema'; + +import { QuestionFormShell } from '../QuestionFormShell'; + +//This defines what the UI shows when adding this question parent template +export const QuestionDateForm = (props: QuestionFormProps) => { + const field = props.question; + + const naturalKeySchema = useNaturalKeySchema(field.naturalKey); + + return ( + + {() => { + return ( + <> + + + + + + + + + ); + }} + + ); +}; diff --git a/apps/frontend/src/components/questionary/questionaryComponents/DateRangePicker/QuestionTemplateRelationDateRangeForm.tsx b/apps/frontend/src/components/questionary/questionaryComponents/DateRangePicker/QuestionTemplateRelationDateRangeForm.tsx new file mode 100644 index 0000000000..38f868a564 --- /dev/null +++ b/apps/frontend/src/components/questionary/questionaryComponents/DateRangePicker/QuestionTemplateRelationDateRangeForm.tsx @@ -0,0 +1,67 @@ +import { Field } from 'formik'; +import React from 'react'; +import * as Yup from 'yup'; + +import CheckboxWithLabel from 'components/common/FormikUICheckboxWithLabel'; +import TextField from 'components/common/FormikUITextField'; +import TitledContainer from 'components/common/TitledContainer'; +import { QuestionTemplateRelationFormProps } from 'components/questionary/QuestionaryComponentRegistry'; + +import QuestionDependencyList from '../QuestionDependencyList'; +import { QuestionExcerpt } from '../QuestionExcerpt'; +import { QuestionTemplateRelationFormShell } from '../QuestionTemplateRelationFormShell'; + +//This defines what the user looks at when they create this question. It is the instanciation (child) +export const QuestionTemplateRelationDateForm = ( + props: QuestionTemplateRelationFormProps +) => { + return ( + + {(formikProps) => { + return ( + <> + + + + + + + + + + + ); + }} + + ); +}; diff --git a/apps/frontend/src/components/questionary/questionaryComponents/DateRangePicker/QuestionaryComponentDateRangePicker.tsx b/apps/frontend/src/components/questionary/questionaryComponents/DateRangePicker/QuestionaryComponentDateRangePicker.tsx new file mode 100644 index 0000000000..3a5f751617 --- /dev/null +++ b/apps/frontend/src/components/questionary/questionaryComponents/DateRangePicker/QuestionaryComponentDateRangePicker.tsx @@ -0,0 +1,126 @@ +import FormControl from '@mui/material/FormControl'; +import FormHelperText from '@mui/material/FormHelperText'; +import { styled } from '@mui/material/styles'; +import React, { useState } from 'react'; +import { DayPicker, DateRange } from 'react-day-picker'; + +import { BasicComponentProps } from 'components/proposal/IBasicComponentProps'; + +const StyledPickerWrapper = styled('div')(({ theme }) => ({ + padding: theme.spacing(2), + borderRadius: theme.shape.borderRadius * 2, + backgroundColor: theme.palette.background.paper, + boxShadow: theme.shadows[2], + + '.rdp-month_caption': { + fontSize: '1.5rem', + fontWeight: 600, + marginBottom: theme.spacing(1), + }, + + '.rdp-weekday': { + fontWeight: 600, + }, + + '.rdp-day': { + padding: 0, + }, + + '.rdp-day_button': { + width: 40, + height: 40, + borderRadius: '50%', + border: 'none', + transition: 'all 0.15s ease', + }, + + '.rdp-day_button:hover': { + backgroundColor: theme.palette.action.hover, + }, + + // Range background + '.rdp-range_middle': { + backgroundColor: theme.palette.primary.light, + }, + + '.rdp-range_middle .rdp-day_button': { + backgroundColor: 'transparent', + color: theme.palette.primary.contrastText, + borderRadius: 0, + }, + + // Start of range + '.rdp-range_start': { + background: `linear-gradient( + 90deg, + transparent 50%, + ${theme.palette.primary.light} 50% + )`, + }, + + '.rdp-range_start .rdp-day_button': { + backgroundColor: theme.palette.primary.main, + color: theme.palette.primary.contrastText, + borderRadius: '50%', + }, + + // End of range + '.rdp-range_end': { + background: `linear-gradient( + 90deg, + ${theme.palette.primary.light} 50%, + transparent 50% + )`, + }, + + '.rdp-range_end .rdp-day_button': { + backgroundColor: theme.palette.primary.main, + color: theme.palette.primary.contrastText, + borderRadius: '50%', + }, + + '.rdp-nav_button': { + borderRadius: '50%', + }, + + '.rdp-nav svg': { + fill: theme.palette.primary.main, + }, +})); + +//This is what appears to the user when filling in this question type +export function QuestionaryComponentDateRangePicker( + props: BasicComponentProps +) { + const { + answer, + onComplete, + formikProps: { errors }, + } = props; + const { + question: { id }, + } = answer; + const [range, setRange] = useState(); + const isError = errors[id] ? true : false; + + return ( + + + { + setRange(value); + if (value?.from && value?.to) { + onComplete({ dateRanges: [value] }); + } + }} + /> + + {isError && {errors[id]?.toString()}} + + ); +} diff --git a/apps/frontend/src/graphql/template/fragment.fieldConfig.graphql b/apps/frontend/src/graphql/template/fragment.fieldConfig.graphql index e0b3ba81be..963e030bf1 100644 --- a/apps/frontend/src/graphql/template/fragment.fieldConfig.graphql +++ b/apps/frontend/src/graphql/template/fragment.fieldConfig.graphql @@ -15,6 +15,14 @@ fragment fieldConfig on FieldConfig { defaultDate includeTime } + + ... on DateRangeConfig { + small_label + required + tooltip + readPermissions + includeTime + } ... on EmbellishmentConfig { html plain diff --git a/apps/frontend/src/hooks/admin/useFormattedDateTime.ts b/apps/frontend/src/hooks/admin/useFormattedDateTime.ts index 3619206cd2..e98c6f5c5e 100644 --- a/apps/frontend/src/hooks/admin/useFormattedDateTime.ts +++ b/apps/frontend/src/hooks/admin/useFormattedDateTime.ts @@ -24,7 +24,7 @@ export function useFormattedDateTime(params?: { }); if (!format) { - // IF format is not provided with the settings return some default one from luxon + // If format is not provided with the settings return some default one from luxon return dateTime.toLocaleString(DateTime.DATETIME_SHORT); } diff --git a/validation/src/Questionary/date.ts b/validation/src/Questionary/date.ts index f5cc64a988..ef1fa94d92 100644 --- a/validation/src/Questionary/date.ts +++ b/validation/src/Questionary/date.ts @@ -1,7 +1,7 @@ import { DateTime } from 'luxon'; import * as Yup from 'yup'; -function normalizeDate(date: string, includeTime: boolean) { +export function normalizeDate(date: string, includeTime: boolean) { let normalizedDate = DateTime.fromISO(date); if (includeTime) { diff --git a/validation/src/Questionary/dateRange.ts b/validation/src/Questionary/dateRange.ts new file mode 100644 index 0000000000..0cd66c351e --- /dev/null +++ b/validation/src/Questionary/dateRange.ts @@ -0,0 +1,17 @@ +import * as Yup from 'yup'; +import { normalizeDate } from './date'; + +export const dateRangeQuestionValidationSchema = (answer: any) => { + const schema = Yup.object({ + dateRanges: Yup.array() + .of( + Yup.object({ + from: Yup.date().required(), + to: Yup.date().required(), + }) + ) + .required(), + }); + + return schema; +}; \ No newline at end of file