Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions apps/backend/db_patches/0214_AddDateRangePicker.sql
Original file line number Diff line number Diff line change
@@ -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;
1 change: 1 addition & 0 deletions apps/backend/src/models/Template.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
62 changes: 62 additions & 0 deletions apps/backend/src/models/questionTypes/DateRange.ts
Original file line number Diff line number Diff line change
@@ -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.DATE_RANGE_PICKER> = {
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}`
);
}
},
};
8 changes: 6 additions & 2 deletions apps/backend/src/models/questionTypes/QuestionRegistry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -108,8 +110,9 @@ export type QuestionDataTypeConfigMapping<T extends DataType> =
? InstrumentPickerConfig
: T extends DataType.TECHNIQUE_PICKER
? TechniquePickerConfig
: never;

: T extends DataType.DATE_RANGE_PICKER
? DateRangeConfig
: never;
export interface Question<T extends DataType> {
/**
* The enum value from DataType
Expand Down Expand Up @@ -172,6 +175,7 @@ export interface Question<T extends DataType> {
const registry = [
booleanDefinition,
dateDefinition,
dateRangeDefinition,
embellishmentDefinition,
feedbackBasisDefinition,
fileUploadDefinition,
Expand Down
1 change: 1 addition & 0 deletions apps/backend/src/resolvers/CustomScalars.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
2 changes: 2 additions & 0 deletions apps/backend/src/resolvers/mutations/AnswerTopicMutation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}
15 changes: 15 additions & 0 deletions apps/backend/src/resolvers/types/FieldConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -389,6 +403,7 @@ export const FieldConfigType = createUnionType({
types: () => [
BooleanConfig,
DateConfig,
DateRangeConfig,
EmbellishmentConfig,
FileUploadConfig,
SelectionFromOptionsConfig,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -163,6 +164,7 @@ export interface QuestionaryComponentDefinition {
const registry = [
booleanDefinition,
dateDefinition,
dateRangeDefinition,
embellishmentDefinition,
feedbackBasisDefinition,
fileUploadDefinition,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
Original file line number Diff line number Diff line change
@@ -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<Scalars['IntStringDateBoolArray']['input']>;
}) => {
const settingsFormatToUse = SettingsId.DATE_FORMAT;
const { toFormattedDateTime } = useFormattedDateTime({
settingsFormatToUse,
});
if (value?.dateRanges?.[0]?.from && value?.dateRanges?.[0]?.to) {
return (
<span>
{toFormattedDateTime(value.dateRanges[0].from)} :{' '}
{toFormattedDateTime(value.dateRanges[0].to)}
</span>
);
}

return <span>Invalid date range</span>;
};

const DateAnswerRenderer: AnswerRenderer = ({ value }) => {
if (!value) {
return <span>Left blank</span>;
}

return <DateRangeAnswerValueRenderer value={value} />;
};

export default DateAnswerRenderer;
Original file line number Diff line number Diff line change
@@ -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: <TodayIcon />,
renderers: {
questionRenderer: defaultRenderer.questionRenderer,
answerRenderer: DateRangeAnswerRenderer,
},
createYupValidationSchema: dateRangeQuestionValidationSchema,
getYupInitialValue: ({ answer }) => answer.value,
searchCriteriaComponent: DateSearchCriteriaInput,
};
Original file line number Diff line number Diff line change
@@ -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<DateTime | null>(
searchCriteria
? DateTime.fromISO((searchCriteria.value as string) || '')
: null
);
const [comparator, setComparator] = useState<QuestionFilterCompareOperator>(
searchCriteria?.compareOperator ?? QuestionFilterCompareOperator.EQUALS
);

useEffect(() => {
if (!searchCriteria?.value) {
setValue(null);
}
}, [searchCriteria?.value]);

return (
<Grid container spacing={2} alignItems="end">
<Grid item xs={6}>
<FormControl fullWidth>
<InputLabel shrink id="comparator">
Operator
</InputLabel>
<Select
onChange={(event) => {
const newComparator = event.target
.value as QuestionFilterCompareOperator;
setComparator(newComparator);
if (value) {
onChange(newComparator, value.toJSDate().toISOString());
}
}}
value={comparator}
labelId="comparator"
data-cy="comparator"
>
<MenuItem key="eq" value={QuestionFilterCompareOperator.EQUALS}>
Exact
</MenuItem>
<MenuItem key="lt" value={QuestionFilterCompareOperator.LESS_THAN}>
Before
</MenuItem>
<MenuItem
key="gt"
value={QuestionFilterCompareOperator.GREATER_THAN}
>
After
</MenuItem>
</Select>
</FormControl>
</Grid>
<Grid item xs={6}>
<LocalizationProvider dateAdapter={DateAdapter}>
<DatePicker
format={format || undefined}
slotProps={{
textField: {
margin: 'none',
size: 'small',
fullWidth: true,
InputLabelProps: {
shrink: value ? true : undefined,
},
id: 'date-value',
},
}}
label={`Date(${format})`}
value={value}
onChange={(date: DateTime | null) => {
const newDate = date?.startOf('day');

if (newDate && newDate.isValid) {
onChange(comparator, newDate.toJSDate().toISOString());
}
setValue(newDate || null);
}}
desktopModeMediaQuery={theme.breakpoints.up('sm')}
/>
</LocalizationProvider>
</Grid>
</Grid>
);
}

export default DateSearchCriteriaInput;
Loading
Loading