diff --git a/front/app/components/Form/Components/Controls/DateControl.tsx b/front/app/components/Form/Components/Controls/DateControl.tsx index 8d5ba1070172..11d25f7da044 100644 --- a/front/app/components/Form/Components/Controls/DateControl.tsx +++ b/front/app/components/Form/Components/Controls/DateControl.tsx @@ -10,7 +10,7 @@ import { import { withJsonFormsControlProps } from '@jsonforms/react'; import moment from 'moment'; -import DateSinglePicker from 'components/admin/DateSinglePicker'; +import DateSinglePicker from 'components/admin/DatePickers/DateSinglePicker'; import { FormLabel } from 'components/UI/FormComponents'; import { getLabel, sanitizeForClassname } from 'utils/JSONFormUtils'; @@ -49,7 +49,7 @@ const DateControl = ({ { handleChange( path, diff --git a/front/app/components/admin/DatePickers/DatePhasePicker/Calendar/index.tsx b/front/app/components/admin/DatePickers/DatePhasePicker/Calendar/index.tsx new file mode 100644 index 000000000000..d504f29c0c11 --- /dev/null +++ b/front/app/components/admin/DatePickers/DatePhasePicker/Calendar/index.tsx @@ -0,0 +1,221 @@ +import React, { useMemo } from 'react'; + +import { colors } from '@citizenlab/cl2-component-library'; +import 'react-day-picker/style.css'; +import { transparentize } from 'polished'; +import { DayPicker, PropsBase } from 'react-day-picker'; +import styled from 'styled-components'; + +import useLocale from 'hooks/useLocale'; + +import { getLocale } from '../../_shared/locales'; +import { Props } from '../typings'; + +import { generateModifiers } from './utils/generateModifiers'; +import { getEndMonth, getStartMonth } from './utils/getStartEndMonth'; +import { getUpdatedRange } from './utils/getUpdatedRange'; + +const disabledBackground = colors.grey300; +const disabledBackground2 = transparentize(0.33, disabledBackground); +const disabledBackground3 = transparentize(0.66, disabledBackground); + +const selectedBackground = colors.teal100; +const selectedBackground2 = transparentize(0.33, selectedBackground); +const selectedBackground3 = transparentize(0.66, selectedBackground); + +const DayPickerStyles = styled.div` + .rdp-root { + --rdp-accent-color: ${colors.teal700}; + --rdp-accent-background-color: ${selectedBackground}; + } + + .rdp-range_middle > button { + font-size: 14px; + } + + .is-disabled-start { + background-color: ${disabledBackground}; + color: ${colors.grey800}; + border-radius: 50% 0 0 50%; + } + + .is-disabled-start > button { + cursor: not-allowed; + } + + .is-disabled-middle { + background-color: ${disabledBackground}; + color: ${colors.grey800}; + } + + .is-disabled-middle > button { + cursor: not-allowed; + } + + .is-disabled-end { + background-color: ${disabledBackground}; + color: ${colors.grey800}; + border-radius: 0 50% 50% 0; + } + + .is-disabled-end > button { + cursor: not-allowed; + } + + .is-disabled-gradient_one { + background: linear-gradient( + 90deg, + ${disabledBackground}, + ${disabledBackground2} + ); + } + + .is-disabled-gradient_two { + background: linear-gradient( + 90deg, + ${disabledBackground2}, + ${disabledBackground3} + ); + } + + .is-disabled-gradient_three { + background: linear-gradient(90deg, ${disabledBackground3}, ${colors.white}); + } + + .is-disabled-single { + background: ${disabledBackground}; + color: ${colors.grey800}; + border-radius: 50%; + } + + .is-disabled-single > button { + cursor: not-allowed; + } + + .is-selected-gradient_one { + background: linear-gradient( + 90deg, + ${selectedBackground}, + ${selectedBackground2} + ); + } + + .is-selected-gradient_two { + background: linear-gradient( + 90deg, + ${selectedBackground2}, + ${selectedBackground3} + ); + } + + .is-selected-gradient_three { + background: linear-gradient(90deg, ${selectedBackground3}, ${colors.white}); + } + + .is-selected-single-day { + background: var(--rdp-accent-color); + color: ${colors.white}; + border-radius: 50%; + } +`; + +const modifiersClassNames = { + isDisabledStart: 'is-disabled-start', + isDisabledMiddle: 'is-disabled-middle', + isDisabledEnd: 'is-disabled-end', + isDisabledGradient_one: 'is-disabled-gradient_one', + isDisabledGradient_two: 'is-disabled-gradient_two', + isDisabledGradient_three: 'is-disabled-gradient_three', + isDisabledSingle: 'is-disabled-single', + isSelectedStart: 'rdp-range_start', + isSelectedMiddle: 'rdp-range_middle', + isSelectedEnd: 'rdp-range_end', + isSelectedGradient_one: 'is-selected-gradient_one', + isSelectedGradient_two: 'is-selected-gradient_two', + isSelectedGradient_three: 'is-selected-gradient_three', + isSelectedSingleDay: 'is-selected-single-day', +}; + +const Calendar = ({ + selectedRange, + disabledRanges = [], + startMonth: _startMonth, + endMonth: _endMonth, + defaultMonth, + onUpdateRange, +}: Props) => { + const startMonth = getStartMonth({ + startMonth: _startMonth, + selectedRange, + disabledRanges, + defaultMonth, + }); + + const endMonth = getEndMonth({ + endMonth: _endMonth, + selectedRange, + disabledRanges, + defaultMonth, + }); + + const locale = useLocale(); + + const modifiers = useMemo( + () => + generateModifiers({ + selectedRange, + disabledRanges, + }), + [selectedRange, disabledRanges] + ); + + const handleDayClick: PropsBase['onDayClick'] = ( + day, + { isDisabledStart, isDisabledMiddle, isDisabledEnd, isDisabledSingle } + ) => { + if ( + isDisabledStart || + isDisabledMiddle || + isDisabledEnd || + isDisabledSingle + ) { + return; + } + + const updatedRange = getUpdatedRange({ + selectedRange, + disabledRanges, + clickedDate: day, + }); + + if (updatedRange) { + onUpdateRange(updatedRange); + } + }; + + return ( + + + + ); +}; + +const NOOP = () => {}; + +export default Calendar; diff --git a/front/app/components/admin/DatePickers/DatePhasePicker/Calendar/utils/generateModifiers.test.ts b/front/app/components/admin/DatePickers/DatePhasePicker/Calendar/utils/generateModifiers.test.ts new file mode 100644 index 000000000000..432386eef220 --- /dev/null +++ b/front/app/components/admin/DatePickers/DatePhasePicker/Calendar/utils/generateModifiers.test.ts @@ -0,0 +1,217 @@ +import { generateModifiers } from './generateModifiers'; + +describe('generateModifiers', () => { + describe('no selectedRange or disabledRanges', () => { + it('is empty if no selectedRange', () => { + expect( + generateModifiers({ selectedRange: {}, disabledRanges: [] }) + ).toEqual({}); + }); + }); + + describe('only selectedRange', () => { + const disabledRanges = []; + + it('shows selection if closed range', () => { + const selectedRange = { + from: new Date(2024, 2, 1), + to: new Date(2024, 3, 1), + }; + + expect(generateModifiers({ selectedRange, disabledRanges })).toEqual({ + isSelectedStart: selectedRange.from, + isSelectedEnd: selectedRange.to, + isSelectedMiddle: { + from: new Date(2024, 2, 2), + to: new Date(2024, 2, 31), + }, + }); + }); + + it('shows selection without middle if closed range of 1 day', () => { + const selectedRange = { + from: new Date(2024, 2, 1), + to: new Date(2024, 2, 2), + }; + + expect(generateModifiers({ selectedRange, disabledRanges })).toEqual({ + isSelectedStart: selectedRange.from, + isSelectedEnd: selectedRange.to, + }); + }); + + it('shows gradient if open range', () => { + const selectedRange = { + from: new Date(2024, 2, 1), + }; + + expect(generateModifiers({ selectedRange, disabledRanges })).toEqual({ + isSelectedStart: selectedRange.from, + isSelectedGradient_one: new Date(2024, 2, 2), + isSelectedGradient_two: new Date(2024, 2, 3), + isSelectedGradient_three: new Date(2024, 2, 4), + }); + }); + }); + + describe('only disabledRanges', () => { + const selectedRange = {}; + + it('correctly handles disabled ranges when all are closed', () => { + const disabledRanges = [ + { + from: new Date(2024, 2, 1), + to: new Date(2024, 3, 1), + }, + { + from: new Date(2024, 4, 1), + to: new Date(2024, 5, 1), + }, + ]; + + expect(generateModifiers({ selectedRange, disabledRanges })).toEqual({ + isDisabledStart: [new Date(2024, 2, 1), new Date(2024, 4, 1)], + isDisabledEnd: [new Date(2024, 3, 1), new Date(2024, 5, 1)], + isDisabledMiddle: [ + { from: new Date(2024, 2, 2), to: new Date(2024, 2, 31) }, + { from: new Date(2024, 4, 2), to: new Date(2024, 4, 31) }, + ], + }); + }); + + it('correctly handles disabled ranges when the last is open', () => { + const disabledRanges = [ + { + from: new Date(2024, 2, 1), + to: new Date(2024, 3, 1), + }, + { + from: new Date(2024, 4, 1), + }, + ]; + + expect(generateModifiers({ selectedRange, disabledRanges })).toEqual({ + isDisabledStart: [new Date(2024, 2, 1), new Date(2024, 4, 1)], + isDisabledEnd: [new Date(2024, 3, 1)], + isDisabledMiddle: [ + { from: new Date(2024, 2, 2), to: new Date(2024, 2, 31) }, + ], + isDisabledGradient_one: new Date(2024, 4, 2), + isDisabledGradient_two: new Date(2024, 4, 3), + isDisabledGradient_three: new Date(2024, 4, 4), + }); + }); + + it('correctly handles single-day disabled ranges when all are closed', () => { + const disabledRanges = [ + { + from: new Date(2024, 2, 1), + to: new Date(2024, 2, 1), + }, + { + from: new Date(2024, 2, 2), + to: new Date(2024, 2, 20), + }, + ]; + + expect(generateModifiers({ selectedRange, disabledRanges })).toEqual({ + isDisabledSingle: [new Date(2024, 2, 1)], + isDisabledStart: [new Date(2024, 2, 2)], + isDisabledMiddle: [ + { + from: new Date(2024, 2, 3), + to: new Date(2024, 2, 19), + }, + ], + isDisabledEnd: [new Date(2024, 2, 20)], + }); + }); + + it('correctly handles single-day disabled ranges when last is open', () => { + const disabledRanges = [ + { + from: new Date(2024, 2, 1), + to: new Date(2024, 2, 1), + }, + { + from: new Date(2024, 2, 2), + }, + ]; + + expect(generateModifiers({ selectedRange, disabledRanges })).toEqual({ + isDisabledSingle: [new Date(2024, 2, 1)], + isDisabledStart: [new Date(2024, 2, 2)], + isDisabledGradient_one: new Date(2024, 2, 3), + isDisabledGradient_two: new Date(2024, 2, 4), + isDisabledGradient_three: new Date(2024, 2, 5), + }); + }); + + it('correctly handles one single-day disabled range', () => { + const disabledRanges = [ + { + from: new Date(2024, 2, 1), + to: new Date(2024, 2, 1), + }, + ]; + + expect(generateModifiers({ selectedRange, disabledRanges })).toEqual({ + isDisabledSingle: [new Date(2024, 2, 1)], + }); + }); + }); + + describe('selectedRange and disabledRanges', () => { + it('shows selection gradient if selectedRange is open and last', () => { + const disabledRanges = [ + { + from: new Date(2024, 2, 1), + to: new Date(2024, 3, 1), + }, + ]; + + const selectedRange = { + from: new Date(2024, 4, 1), + }; + + expect(generateModifiers({ selectedRange, disabledRanges })).toEqual({ + isDisabledStart: [new Date(2024, 2, 1)], + isDisabledEnd: [new Date(2024, 3, 1)], + isDisabledMiddle: [ + { from: new Date(2024, 2, 2), to: new Date(2024, 2, 31) }, + ], + isSelectedStart: selectedRange.from, + isSelectedGradient_one: new Date(2024, 4, 2), + isSelectedGradient_two: new Date(2024, 4, 3), + isSelectedGradient_three: new Date(2024, 4, 4), + }); + }); + + it('shows single selected day if selectedRange is open and not last', () => { + const disabledRanges = [ + { + from: new Date(2024, 2, 1), + to: new Date(2024, 3, 1), + }, + { + from: new Date(2024, 5, 1), + to: new Date(2024, 6, 1), + }, + ]; + + const selectedRange = { + from: new Date(2024, 4, 1), + }; + + expect(generateModifiers({ selectedRange, disabledRanges })).toEqual({ + isDisabledStart: [new Date(2024, 2, 1), new Date(2024, 5, 1)], + isDisabledMiddle: [ + { from: new Date(2024, 2, 2), to: new Date(2024, 2, 31) }, + { from: new Date(2024, 5, 2), to: new Date(2024, 5, 30) }, + ], + isDisabledEnd: [new Date(2024, 3, 1), new Date(2024, 6, 1)], + isSelectedSingleDay: selectedRange.from, + }); + }); + }); +}); diff --git a/front/app/components/admin/DatePickers/DatePhasePicker/Calendar/utils/generateModifiers.ts b/front/app/components/admin/DatePickers/DatePhasePicker/Calendar/utils/generateModifiers.ts new file mode 100644 index 000000000000..70efb722952a --- /dev/null +++ b/front/app/components/admin/DatePickers/DatePhasePicker/Calendar/utils/generateModifiers.ts @@ -0,0 +1,206 @@ +import { differenceInDays, addDays } from 'date-fns'; + +import { DateRange, ClosedDateRange } from '../../typings'; + +import { rangesValid } from './rangesValid'; +import { allAreClosedDateRanges } from './utils'; + +interface GenerateModifiersParams { + selectedRange: Partial; + disabledRanges: DateRange[]; +} + +export const generateModifiers = ({ + selectedRange, + disabledRanges, +}: GenerateModifiersParams) => { + const { valid, reason } = rangesValid(selectedRange, disabledRanges); + + if (!valid) { + throw new Error(reason); + } + + const selectedModifiers = generateSelectedModifiers({ + selectedRange, + disabledRanges, + }); + + const disabledModifiers = generateDisabledModifiers(disabledRanges); + + return { + ...selectedModifiers, + ...disabledModifiers, + }; +}; + +const generateSelectedModifiers = ({ + selectedRange: { from, to }, + disabledRanges, +}: GenerateModifiersParams) => { + if (from === undefined) { + return {}; + } + + if (to !== undefined) { + return { + isSelectedStart: from, + isSelectedMiddle: generateMiddleRange({ from, to }), + isSelectedEnd: to, + }; + } else { + if (disabledRanges.length === 0) { + return generateIsSelectedGradient(from); + } + + if (allAreClosedDateRanges(disabledRanges)) { + const highestToDate = Math.max( + ...disabledRanges.map(({ to }) => to.getTime()) + ); + + const selectedRangeIsAfterAllDisabledRanges = + from.getTime() > highestToDate; + + if (selectedRangeIsAfterAllDisabledRanges) { + return generateIsSelectedGradient(from); + } else { + return { + isSelectedSingleDay: from, + }; + } + } else { + // If this is the case, the only possibility is that + // the last disabled range is open, and that therefore we + // must be before the start of that phase with a single + // day selected. + // Otherwise the props are invalid. + return { + isSelectedSingleDay: from, + }; + } + } +}; + +const generateDisabledModifiers = (disabledRanges: DateRange[]) => { + if (disabledRanges.length === 0) { + return {}; + } + + if (allAreClosedDateRanges(disabledRanges)) { + return generateClosedDisabledRanges(disabledRanges); + } else { + const lastDisabledRange = disabledRanges[disabledRanges.length - 1]; + const disabledRangesWithoutLast = disabledRanges.slice(0, -1); + + // This should not be possible, but we need this for the type check + if (!allAreClosedDateRanges(disabledRangesWithoutLast)) { + throw new Error('Invalid props'); + } + + const isDisabledGradient = { + isDisabledGradient_one: addDays(lastDisabledRange.from, 1), + isDisabledGradient_two: addDays(lastDisabledRange.from, 2), + isDisabledGradient_three: addDays(lastDisabledRange.from, 3), + }; + + if (disabledRangesWithoutLast.length === 0) { + return { + isDisabledStart: [lastDisabledRange.from], + ...isDisabledGradient, + }; + } else { + const closedDisabledRanges = generateClosedDisabledRanges( + disabledRangesWithoutLast + ); + + return { + isDisabledStart: [ + ...(closedDisabledRanges.isDisabledStart || []), + lastDisabledRange.from, + ], + isDisabledMiddle: closedDisabledRanges.isDisabledMiddle, + isDisabledEnd: closedDisabledRanges.isDisabledEnd, + isDisabledSingle: closedDisabledRanges.isDisabledSingle, + ...isDisabledGradient, + }; + } + } +}; + +const generateMiddleRange = ({ from, to }: ClosedDateRange) => { + const diff = differenceInDays(to, from); + if (diff < 2) return undefined; + if (diff === 2) return addDays(from, 1); + return { + from: addDays(from, 1), + to: addDays(to, -1), + }; +}; + +const generateIsSelectedGradient = (isSelectedStart: Date) => ({ + isSelectedStart, + isSelectedGradient_one: addDays(isSelectedStart, 1), + isSelectedGradient_two: addDays(isSelectedStart, 2), + isSelectedGradient_three: addDays(isSelectedStart, 3), +}); + +const generateDisabledMiddleRange = (disabledRanges: ClosedDateRange[]) => { + return disabledRanges.reduce((acc, range) => { + const middleRange = generateMiddleRange(range); + return middleRange ? [...acc, middleRange] : acc; + }, []); +}; + +const generateClosedDisabledRanges = (disabledRanges: ClosedDateRange[]) => { + const disabledRangesWithoutSingleDayRanges: ClosedDateRange[] = []; + const singleDayRanges: ClosedDateRange[] = []; + + for (const range of disabledRanges) { + if (range.to.getTime() === range.from.getTime()) { + singleDayRanges.push(range); + } else { + disabledRangesWithoutSingleDayRanges.push(range); + } + } + + if ( + disabledRangesWithoutSingleDayRanges.length === 0 && + singleDayRanges.length === 0 + ) { + return {}; + } + + if ( + disabledRangesWithoutSingleDayRanges.length === 0 && + singleDayRanges.length > 0 + ) { + return { + isDisabledSingle: singleDayRanges.map(({ from }) => from), + }; + } + + if ( + disabledRangesWithoutSingleDayRanges.length > 0 && + singleDayRanges.length === 0 + ) { + return { + isDisabledStart: disabledRangesWithoutSingleDayRanges.map( + ({ from }) => from + ), + isDisabledMiddle: generateDisabledMiddleRange( + disabledRangesWithoutSingleDayRanges + ), + isDisabledEnd: disabledRangesWithoutSingleDayRanges.map(({ to }) => to), + }; + } + + return { + isDisabledSingle: singleDayRanges.map(({ from }) => from), + isDisabledStart: disabledRangesWithoutSingleDayRanges.map( + ({ from }) => from + ), + isDisabledMiddle: generateDisabledMiddleRange( + disabledRangesWithoutSingleDayRanges + ), + isDisabledEnd: disabledRangesWithoutSingleDayRanges.map(({ to }) => to), + }; +}; diff --git a/front/app/components/admin/DatePickers/DatePhasePicker/Calendar/utils/getStartEndMonth.ts b/front/app/components/admin/DatePickers/DatePhasePicker/Calendar/utils/getStartEndMonth.ts new file mode 100644 index 000000000000..f31ea14e6cc8 --- /dev/null +++ b/front/app/components/admin/DatePickers/DatePhasePicker/Calendar/utils/getStartEndMonth.ts @@ -0,0 +1,68 @@ +import { addYears } from 'date-fns'; + +import { DateRange } from '../../typings'; + +interface GetStartMonthProps { + startMonth?: Date; + selectedRange: Partial; + disabledRanges: DateRange[]; + defaultMonth?: Date; +} + +export const getStartMonth = ({ + startMonth, + selectedRange, + disabledRanges, + defaultMonth, +}: GetStartMonthProps) => { + if (startMonth) return startMonth; + + const times: number[] = [addYears(new Date(), -2).getTime()]; + + if (selectedRange.from) { + times.push(addYears(selectedRange.from, -2).getTime()); + } + + if (disabledRanges.length > 0) { + times.push(disabledRanges[0].from.getTime()); + } + + if (defaultMonth) { + times.push(defaultMonth.getTime()); + } + + return new Date(Math.min(...times)); +}; + +interface GetEndMonthProps { + endMonth?: Date; + selectedRange: Partial; + disabledRanges: DateRange[]; + defaultMonth?: Date; +} + +export const getEndMonth = ({ + endMonth, + selectedRange, + disabledRanges, + defaultMonth, +}: GetEndMonthProps) => { + if (endMonth) return endMonth; + + const times: number[] = [addYears(new Date(), 2).getTime()]; + + if (selectedRange.to) { + times.push(addYears(selectedRange.to, 2).getTime()); + } + + if (disabledRanges.length > 0) { + const { from, to } = disabledRanges[0]; + times.push(to ? to.getTime() : from.getTime()); + } + + if (defaultMonth) { + times.push(defaultMonth.getTime()); + } + + return new Date(Math.max(...times)); +}; diff --git a/front/app/components/admin/DatePickers/DatePhasePicker/Calendar/utils/getUpdatedRange.test.ts b/front/app/components/admin/DatePickers/DatePhasePicker/Calendar/utils/getUpdatedRange.test.ts new file mode 100644 index 000000000000..b5b769d1d271 --- /dev/null +++ b/front/app/components/admin/DatePickers/DatePhasePicker/Calendar/utils/getUpdatedRange.test.ts @@ -0,0 +1,102 @@ +import { getUpdatedRange } from './getUpdatedRange'; + +describe('getUpdatedRange', () => { + describe('selectedRange is empty', () => { + it('sets start date on click', () => { + const selectedRange = {}; + const disabledRanges = []; + const clickedDate = new Date(2024, 2, 1); + + expect( + getUpdatedRange({ selectedRange, disabledRanges, clickedDate }) + ).toEqual({ + from: clickedDate, + }); + }); + + it('is possible to click one day after open-ended disabled range', () => { + const selectedRange = {}; + const disabledRanges = [{ from: new Date(2024, 3, 1) }]; + const clickedDate = new Date(2024, 3, 2); + + expect( + getUpdatedRange({ selectedRange, disabledRanges, clickedDate }) + ).toEqual({ + from: clickedDate, + }); + }); + }); + + describe('selectedRange has a start date', () => { + it('updates start date if clicked date is before start date', () => { + const selectedRange = { from: new Date(2024, 3, 1) }; + const disabledRanges = []; + const clickedDate = new Date(2024, 2, 1); + + expect( + getUpdatedRange({ selectedRange, disabledRanges, clickedDate }) + ).toEqual({ + from: clickedDate, + }); + }); + + it('creates single-day phase if clicked date is start date', () => { + const date = new Date(2024, 3, 1); + const selectedRange = { from: date }; + const disabledRanges = []; + + expect( + getUpdatedRange({ selectedRange, disabledRanges, clickedDate: date }) + ).toEqual({ + from: date, + to: date, + }); + }); + + it('updates start date if clicked date is after start date and overlaps with disabled range', () => { + const selectedRange = { from: new Date(2024, 3, 1) }; + const disabledRanges = [ + { from: new Date(2024, 4, 1), to: new Date(2024, 4, 4) }, + ]; + const clickedDate = new Date(2024, 5, 1); + + expect( + getUpdatedRange({ selectedRange, disabledRanges, clickedDate }) + ).toEqual({ + from: clickedDate, + }); + }); + + it('updates end date if clicked date is after start date and does not overlap with disabled range', () => { + const selectedRange = { from: new Date(2024, 3, 1) }; + const disabledRanges = [ + { from: new Date(2024, 4, 1), to: new Date(2024, 4, 4) }, + ]; + const clickedDate = new Date(2024, 3, 10); + + expect( + getUpdatedRange({ selectedRange, disabledRanges, clickedDate }) + ).toEqual({ + from: selectedRange.from, + to: clickedDate, + }); + }); + }); + + describe('selectedRange has a start and end date', () => { + it('replaces range by just start date', () => { + const selectedRange = { + from: new Date(2024, 3, 1), + to: new Date(2024, 3, 10), + }; + const disabledRanges = []; + const clickedDate = new Date(2024, 2, 1); + + expect( + getUpdatedRange({ selectedRange, disabledRanges, clickedDate }) + ).toEqual({ + from: clickedDate, + }); + }); + }); +}); diff --git a/front/app/components/admin/DatePickers/DatePhasePicker/Calendar/utils/getUpdatedRange.ts b/front/app/components/admin/DatePickers/DatePhasePicker/Calendar/utils/getUpdatedRange.ts new file mode 100644 index 000000000000..ca47c6163b5d --- /dev/null +++ b/front/app/components/admin/DatePickers/DatePhasePicker/Calendar/utils/getUpdatedRange.ts @@ -0,0 +1,93 @@ +import { addDays } from 'date-fns'; + +import { ClosedDateRange, DateRange } from '../../typings'; + +import { rangesValid } from './rangesValid'; +import { isClosedDateRange } from './utils'; + +interface GetUpdatedRangeParams { + selectedRange: Partial; + disabledRanges: DateRange[]; + clickedDate: Date; +} + +export const getUpdatedRange = ({ + selectedRange: { from, to }, + disabledRanges, + clickedDate, +}: GetUpdatedRangeParams) => { + const { valid, reason } = rangesValid({ from, to }, disabledRanges); + + if (!valid) { + throw new Error(reason); + } + + if (from === undefined) { + return { + from: clickedDate, + }; + } + + if (from > clickedDate) { + return { + from: clickedDate, + }; + } + + if (from.getTime() === clickedDate.getTime()) { + return { + from: clickedDate, + to: clickedDate, + }; + } + + if (to === undefined) { + const potentialNewRange = { + from, + to: clickedDate, + }; + + const newDisabledRanges = replaceLastOpenEndedRange(disabledRanges); + + if (rangeOverlapsWithDisabledRange(potentialNewRange, newDisabledRanges)) { + return { + from: clickedDate, + }; + } + + return { + from, + to: clickedDate, + }; + } + + return { + from: clickedDate, + }; +}; + +// Utility to replace the last open-ended range with a closed range. +// This simplifies a lot of logic and makes the types easier to work with. +const replaceLastOpenEndedRange = ( + disabledRanges: DateRange[] +): ClosedDateRange[] => { + return disabledRanges.map((range) => { + if (!isClosedDateRange(range)) { + return { + from: range.from, + to: addDays(range.from, 1), + }; + } + + return range; + }); +}; + +const rangeOverlapsWithDisabledRange = ( + range: ClosedDateRange, + disabledRanges: ClosedDateRange[] +) => { + return disabledRanges.some((disabledRange) => { + return range.from <= disabledRange.to && range.to >= disabledRange.from; + }); +}; diff --git a/front/app/components/admin/DatePickers/DatePhasePicker/Calendar/utils/rangesValid.test.ts b/front/app/components/admin/DatePickers/DatePhasePicker/Calendar/utils/rangesValid.test.ts new file mode 100644 index 000000000000..9858dc4080bb --- /dev/null +++ b/front/app/components/admin/DatePickers/DatePhasePicker/Calendar/utils/rangesValid.test.ts @@ -0,0 +1,196 @@ +import { rangesValid } from './rangesValid'; + +describe('rangesValid', () => { + describe('no selectedRange and no disabledRanges', () => { + it('is valid', () => { + expect(rangesValid({}, [])).toEqual({ valid: true }); + }); + }); + + describe('selectedRange but no disabledRanges', () => { + it('is valid when from is defined and to is undefined', () => { + expect(rangesValid({ from: new Date() }, [])).toEqual({ valid: true }); + }); + + it('is valid to have from and to be the same value', () => { + const date = new Date(); + expect(rangesValid({ from: date, to: date }, [])).toEqual({ + valid: true, + }); + }); + + it('is invalid when only to is defined', () => { + expect(rangesValid({ to: new Date() }, [])).toEqual({ + valid: false, + reason: + 'selectedRange.from cannot be undefined if selectedRange.to is defined', + }); + }); + }); + + describe('no selectedRange but disabledRanges', () => { + it('is valid', () => { + expect( + rangesValid({}, [ + { from: new Date(2024, 1, 1), to: new Date(2024, 2, 1) }, + { from: new Date(2024, 2, 2), to: new Date(2024, 3, 1) }, + ]) + ).toEqual({ valid: true }); + }); + + it('is invalid if disabledRanges overlap', () => { + expect( + rangesValid({}, [ + { from: new Date(2024, 1, 1), to: new Date(2024, 2, 1) }, + { from: new Date(2024, 2, 1), to: new Date(2024, 3, 1) }, + ]) + ).toEqual({ + valid: false, + reason: 'disabledRanges invalid', + }); + }); + + it('is possible to have one-day disabled ranges', () => { + expect( + rangesValid({}, [ + { from: new Date(2024, 1, 1), to: new Date(2024, 1, 1) }, + ]) + ).toEqual({ valid: true }); + }); + + it('should be valid 1', () => { + const disabledRanges = [ + { + from: new Date(2024, 2, 1), + to: new Date(2024, 2, 1), + }, + { + from: new Date(2024, 2, 2), + to: new Date(2024, 2, 20), + }, + ]; + + expect(rangesValid({}, disabledRanges)).toEqual({ valid: true }); + }); + + it('should be valid 2', () => { + const disabledRanges = [ + { + from: new Date(2024, 2, 1), + to: new Date(2024, 2, 1), + }, + { + from: new Date(2024, 2, 2), + }, + ]; + + expect(rangesValid({}, disabledRanges)).toEqual({ valid: true }); + }); + }); + + describe('selectedRange and disabledRanges', () => { + describe('closed disabled ranges', () => { + it('is valid when open selectedRange is between disabledRanges', () => { + expect( + rangesValid({ from: new Date(2024, 2, 1) }, [ + { from: new Date(2024, 1, 1), to: new Date(2024, 1, 25) }, + { from: new Date(2024, 2, 6), to: new Date(2024, 2, 10) }, + ]) + ).toEqual({ valid: true }); + }); + + it('is valid when closed selectedRange is within disabledRanges', () => { + expect( + rangesValid( + { from: new Date(2024, 2, 1), to: new Date(2024, 2, 5) }, + [ + { from: new Date(2024, 1, 1), to: new Date(2024, 1, 25) }, + { from: new Date(2024, 2, 6), to: new Date(2024, 2, 10) }, + ] + ) + ).toEqual({ valid: true }); + }); + + it('is valid when open selectedRange is after disabledRanges', () => { + expect( + rangesValid({ from: new Date(2024, 3, 1) }, [ + { from: new Date(2024, 1, 1), to: new Date(2024, 1, 25) }, + { from: new Date(2024, 2, 6), to: new Date(2024, 2, 10) }, + ]) + ).toEqual({ valid: true }); + }); + + it('is valid when closed selectedRange is after disabledRanges', () => { + expect( + rangesValid( + { from: new Date(2024, 3, 1), to: new Date(2024, 3, 5) }, + [ + { from: new Date(2024, 1, 1), to: new Date(2024, 1, 25) }, + { from: new Date(2024, 2, 6), to: new Date(2024, 2, 10) }, + ] + ) + ).toEqual({ valid: true }); + }); + + it('is invalid when closed selectedRange overlaps with disabledRanges', () => { + expect( + rangesValid( + { from: new Date(2024, 1, 28), to: new Date(2024, 3, 5) }, + [ + { from: new Date(2024, 1, 1), to: new Date(2024, 1, 25) }, + { from: new Date(2024, 2, 6), to: new Date(2024, 2, 10) }, + ] + ) + ).toEqual({ + valid: false, + reason: 'selectedRange and disabledRanges invalid together', + }); + }); + }); + + describe('open disabled range', () => { + it('is invalid when selectedRange.to is undefined and is last', () => { + expect( + rangesValid({ from: new Date(2024, 2, 1) }, [ + { from: new Date(2024, 1, 1) }, + ]) + ).toEqual({ + valid: false, + reason: + 'selectedRange cannot be last if disabledRanges ends with an open range', + }); + }); + + it('is valid when selectedRange.to is undefined and is not last', () => { + expect( + rangesValid({ from: new Date(2024, 2, 1) }, [ + { from: new Date(2024, 1, 1), to: new Date(2024, 1, 25) }, + { from: new Date(2024, 3, 1) }, + ]) + ).toEqual({ valid: true }); + }); + + it('is valid when selectedRange.to is defined and is not last', () => { + expect( + rangesValid( + { from: new Date(2024, 2, 1), to: new Date(2024, 2, 5) }, + [{ from: new Date(2024, 3, 1) }] + ) + ).toEqual({ valid: true }); + }); + + it('is invalid when selectedRange.to is defined and is last', () => { + expect( + rangesValid( + { from: new Date(2024, 3, 1), to: new Date(2024, 3, 5) }, + [{ from: new Date(2024, 2, 1) }] + ) + ).toEqual({ + valid: false, + reason: + 'selectedRange cannot be last if disabledRanges ends with an open range', + }); + }); + }); + }); +}); diff --git a/front/app/components/admin/DatePickers/DatePhasePicker/Calendar/utils/rangesValid.ts b/front/app/components/admin/DatePickers/DatePhasePicker/Calendar/utils/rangesValid.ts new file mode 100644 index 000000000000..47bb88b73098 --- /dev/null +++ b/front/app/components/admin/DatePickers/DatePhasePicker/Calendar/utils/rangesValid.ts @@ -0,0 +1,133 @@ +import { differenceInDays } from 'date-fns'; + +import { DateRange, ClosedDateRange } from '../../typings'; + +import { allAreClosedDateRanges, isClosedDateRange } from './utils'; + +type Validity = + | { + valid: true; + reason: undefined; + } + | { + valid: false; + reason: string; + }; + +/** + * This function validates that the combination of selectedRange + * and disabledRanges is valid. + * With 'valid' we mean a valid state for the component- + * this is not necessarily a valid state for the backend. + * For example, you might have picked a start date between two disabled ranges, + * at which point you still need to select an end date for the phase to make sense. + * If this data would be sent to the BE, it would be invalid. + * But it is a valid state for the date picker to be in (temporarily). + */ +export const rangesValid = ( + { from, to }: Partial, + disabledRanges: DateRange[] +): Validity => { + // First, make sure selectedRange itself is valid + if (from === undefined && to !== undefined) { + return { + valid: false, + reason: + 'selectedRange.from cannot be undefined if selectedRange.to is defined', + }; + } + + if (from !== undefined && to !== undefined && from > to) { + return { + valid: false, + reason: 'selectedRange.from cannot be after selectedRange.to', + }; + } + + // Second, make sure disabledRanges itself is valid + if (disabledRanges.length === 0) { + return { valid: true, reason: undefined }; + } + + if (!validRangeSequence(disabledRanges)) { + return { + valid: false, + reason: 'disabledRanges invalid', + }; + } + + // Third, make sure selectedRange and disabledRanges are valid together + if (from === undefined) { + return { valid: true, reason: undefined }; + } + + if (allAreClosedDateRanges(disabledRanges)) { + if (to === undefined) { + const fromOverlapsWithAnyDisabledRange = disabledRanges.some( + (disabledRange) => { + return from >= disabledRange.from && from <= disabledRange.to; + } + ); + + if (fromOverlapsWithAnyDisabledRange) { + return { + valid: false, + reason: 'selectedRange.from overlaps with a disabledRange', + }; + } else { + return { valid: true, reason: undefined }; + } + } else { + const combinedRanges = [...disabledRanges, { from, to }].sort( + (a, b) => a.from.getTime() - b.from.getTime() + ); + + if (validRangeSequence(combinedRanges)) { + return { valid: true, reason: undefined }; + } + + return { + valid: false, + reason: 'selectedRange and disabledRanges invalid together', + }; + } + } else { + const fromIsLast = from >= disabledRanges[disabledRanges.length - 1].from; + + if (fromIsLast) { + return { + valid: false, + reason: + 'selectedRange cannot be last if disabledRanges ends with an open range', + }; + } + + return { valid: true, reason: undefined }; + } +}; + +const validRangeSequence = (ranges: DateRange[]) => { + for (let i = 0; i < ranges.length - 1; i++) { + const currentRange = ranges[i]; + const nextRange = ranges[i + 1]; + + if (!isClosedDateRange(currentRange)) { + return false; + } + + if (!validDifference(currentRange, 0)) { + return false; + } + + if (!validDifference({ from: currentRange.to, to: nextRange.from }, 1)) { + return false; + } + } + + return true; +}; + +const validDifference = (range: ClosedDateRange, requiredDiff: number) => { + const diff = differenceInDays(range.to, range.from); + return diff >= requiredDiff; +}; diff --git a/front/app/components/admin/DatePickers/DatePhasePicker/Calendar/utils/utils.ts b/front/app/components/admin/DatePickers/DatePhasePicker/Calendar/utils/utils.ts new file mode 100644 index 000000000000..edaf24afe124 --- /dev/null +++ b/front/app/components/admin/DatePickers/DatePhasePicker/Calendar/utils/utils.ts @@ -0,0 +1,8 @@ +import { DateRange, ClosedDateRange } from '../../typings'; + +export const isClosedDateRange = (range: DateRange): range is ClosedDateRange => + !!range.to; + +export const allAreClosedDateRanges = ( + ranges: DateRange[] +): ranges is ClosedDateRange[] => ranges.every(isClosedDateRange); diff --git a/front/app/components/admin/DatePickers/DatePhasePicker/DatePhasePicker.stories.tsx b/front/app/components/admin/DatePickers/DatePhasePicker/DatePhasePicker.stories.tsx new file mode 100644 index 000000000000..acf9bb318008 --- /dev/null +++ b/front/app/components/admin/DatePickers/DatePhasePicker/DatePhasePicker.stories.tsx @@ -0,0 +1,79 @@ +import React, { useState } from 'react'; + +import { patchDisabledRanges } from './patchDisabledRanges'; +import { DateRange } from './typings'; + +import DatePhasePicker from '.'; + +import type { Meta } from '@storybook/react'; + +const meta = { + title: 'DatePhasePicker', + component: DatePhasePicker, +} satisfies Meta; + +export default meta; +// type Story = StoryObj; + +const WrapperStandard = () => { + const [selectedRange, setSelectedRange] = useState>({}); + + return ( + + ); +}; + +export const Standard = { + render: () => { + return ; + }, +}; + +const WrapperDisabledRanges = () => { + const DISABLED_RANGES = [ + { from: new Date(2024, 7, 1), to: new Date(2024, 8, 5) }, + { from: new Date(2024, 8, 21), to: new Date(2024, 9, 20) }, + ]; + const [selectedRange, setSelectedRange] = useState>({}); + + return ( + + ); +}; + +export const DisabledRanges = { + render: () => { + return ; + }, +}; + +const WrapperOpenEndedDisabledRanges = () => { + const DISABLED_RANGES = [ + { from: new Date(2024, 7, 1), to: new Date(2024, 8, 5) }, + { from: new Date(2024, 8, 21) }, + ]; + const [selectedRange, setSelectedRange] = useState>({}); + + return ( + + ); +}; + +export const OpenEndedDisabledRanges = { + render: () => { + return ; + }, +}; diff --git a/front/app/components/admin/DatePickers/DatePhasePicker/Input.tsx b/front/app/components/admin/DatePickers/DatePhasePicker/Input.tsx new file mode 100644 index 000000000000..c4feb490d180 --- /dev/null +++ b/front/app/components/admin/DatePickers/DatePhasePicker/Input.tsx @@ -0,0 +1,42 @@ +import React from 'react'; + +import { Icon, Box } from '@citizenlab/cl2-component-library'; + +import { useIntl } from 'utils/cl-intl'; + +import InputContainer from '../_shared/InputContainer'; +import sharedMessages from '../_shared/messages'; + +import messages from './messages'; +import { DateRange } from './typings'; + +interface Props { + selectedRange: Partial; + selectedRangeIsOpenEnded: boolean; + onClick: () => void; +} + +const Input = ({ selectedRange, selectedRangeIsOpenEnded, onClick }: Props) => { + const { formatMessage } = useIntl(); + const selectDate = formatMessage(sharedMessages.selectDate); + + return ( + + + {selectedRange.from + ? selectedRange.from.toLocaleDateString() + : selectDate} + + + + {selectedRangeIsOpenEnded + ? formatMessage(messages.openEnded) + : selectedRange.to + ? selectedRange.to.toLocaleDateString() + : selectDate} + + + ); +}; + +export default Input; diff --git a/front/app/components/admin/DatePickers/DatePhasePicker/index.tsx b/front/app/components/admin/DatePickers/DatePhasePicker/index.tsx new file mode 100644 index 000000000000..c10852c01dcd --- /dev/null +++ b/front/app/components/admin/DatePickers/DatePhasePicker/index.tsx @@ -0,0 +1,66 @@ +import React, { useState } from 'react'; + +import { Tooltip, Box } from '@citizenlab/cl2-component-library'; +import styled from 'styled-components'; + +import ClickOutside from 'utils/containers/clickOutside'; + +import Calendar from './Calendar'; +import Input from './Input'; +import { isSelectedRangeOpenEnded } from './isSelectedRangeOpenEnded'; +import { Props } from './typings'; + +const WIDTH = '620px'; + +const StyledClickOutside = styled(ClickOutside)` + div.tippy-box { + max-width: ${WIDTH} !important; + padding: 8px; + } +`; + +const DatePhasePicker = ({ + selectedRange, + disabledRanges = [], + startMonth, + endMonth, + defaultMonth, + onUpdateRange, +}: Props) => { + const [calendarOpen, setCalendarOpen] = useState(false); + + const selectedRangeIsOpenEnded = isSelectedRangeOpenEnded( + selectedRange, + disabledRanges + ); + + return ( + setCalendarOpen(false)}> + + + + } + placement="bottom" + visible={calendarOpen} + width="1200px" + > + setCalendarOpen((open) => !open)} + /> + + + ); +}; + +export default DatePhasePicker; diff --git a/front/app/components/admin/DatePickers/DatePhasePicker/isSelectedRangeOpenEnded.ts b/front/app/components/admin/DatePickers/DatePhasePicker/isSelectedRangeOpenEnded.ts new file mode 100644 index 000000000000..ed741d752209 --- /dev/null +++ b/front/app/components/admin/DatePickers/DatePhasePicker/isSelectedRangeOpenEnded.ts @@ -0,0 +1,20 @@ +import { DateRange } from './typings'; + +export const isSelectedRangeOpenEnded = ( + { from, to }: Partial, + disabledRanges: DateRange[] +) => { + if (from !== undefined && to === undefined) { + const lastDisabledRange = disabledRanges[disabledRanges.length - 1] as + | DateRange + | undefined; + + if (lastDisabledRange === undefined) { + return true; + } + + return from.getTime() > lastDisabledRange.from.getTime(); + } + + return false; +}; diff --git a/front/app/components/admin/DatePickers/DatePhasePicker/messages.ts b/front/app/components/admin/DatePickers/DatePhasePicker/messages.ts new file mode 100644 index 000000000000..8dd271c91abe --- /dev/null +++ b/front/app/components/admin/DatePickers/DatePhasePicker/messages.ts @@ -0,0 +1,8 @@ +import { defineMessages } from 'react-intl'; + +export default defineMessages({ + openEnded: { + id: 'app.components.admin.DatePhasePicker.Input.openEnded', + defaultMessage: 'Open ended', + }, +}); diff --git a/front/app/components/admin/DatePickers/DatePhasePicker/patchDisabledRanges.ts b/front/app/components/admin/DatePickers/DatePhasePicker/patchDisabledRanges.ts new file mode 100644 index 000000000000..e8d90be92ba1 --- /dev/null +++ b/front/app/components/admin/DatePickers/DatePhasePicker/patchDisabledRanges.ts @@ -0,0 +1,34 @@ +import { addDays } from 'date-fns'; + +import { DateRange } from './typings'; + +/** + * Utility to handle the case where the last disabled range is open, + * and the user selects a range after that. + */ +export const patchDisabledRanges = ( + { from }: Partial, + disabledRanges: DateRange[] +) => { + if (from === undefined) { + return disabledRanges; + } + + if (disabledRanges.length === 0) { + return disabledRanges; + } + + const lastDisabledRange = disabledRanges[disabledRanges.length - 1]; + + if ( + lastDisabledRange.to === undefined && + from.getTime() > lastDisabledRange.from.getTime() + ) { + return [ + ...disabledRanges.slice(0, -1), + { from: lastDisabledRange.from, to: addDays(from, -1) }, + ]; + } + + return disabledRanges; +}; diff --git a/front/app/components/admin/DatePickers/DatePhasePicker/typings.ts b/front/app/components/admin/DatePickers/DatePhasePicker/typings.ts new file mode 100644 index 000000000000..58d3b0713fbe --- /dev/null +++ b/front/app/components/admin/DatePickers/DatePhasePicker/typings.ts @@ -0,0 +1,18 @@ +export type DateRange = { + from: Date; + to?: Date; +}; + +export type ClosedDateRange = { + from: Date; + to: Date; +}; + +export interface Props { + selectedRange: Partial; + disabledRanges?: DateRange[]; + startMonth?: Date; + endMonth?: Date; + defaultMonth?: Date; + onUpdateRange: (range: DateRange) => void; +} diff --git a/front/app/components/admin/DateRangePicker/index.tsx b/front/app/components/admin/DatePickers/DateRangePicker/index.tsx similarity index 100% rename from front/app/components/admin/DateRangePicker/index.tsx rename to front/app/components/admin/DatePickers/DateRangePicker/index.tsx diff --git a/front/app/components/admin/DatePickers/DateSinglePicker/Calendar/index.tsx b/front/app/components/admin/DatePickers/DateSinglePicker/Calendar/index.tsx new file mode 100644 index 000000000000..7fd5826be341 --- /dev/null +++ b/front/app/components/admin/DatePickers/DateSinglePicker/Calendar/index.tsx @@ -0,0 +1,57 @@ +import React from 'react'; + +import { colors } from '@citizenlab/cl2-component-library'; +import { DayPicker } from 'react-day-picker'; +import 'react-day-picker/style.css'; +import styled from 'styled-components'; + +import useLocale from 'hooks/useLocale'; + +import { getLocale } from '../../_shared/locales'; +import { CalendarProps } from '../typings'; + +import { getEndMonth, getStartMonth } from './utils/getStartEndMonth'; + +const DayPickerStyles = styled.div` + .rdp-root { + --rdp-accent-color: ${colors.teal700}; + --rdp-accent-background-color: ${colors.teal100}; + } + + .rdp-selected > button.rdp-day_button { + background-color: ${colors.teal700}; + color: ${colors.white}; + font-size: 14px; + font-weight: normal; + } +`; + +const Calendar = ({ + selectedDate, + startMonth: _startMonth, + endMonth: _endMonth, + defaultMonth, + onChange, +}: CalendarProps) => { + const locale = useLocale(); + + const startMonth = getStartMonth({ startMonth: _startMonth, selectedDate }); + const endMonth = getEndMonth({ endMonth: _endMonth, selectedDate }); + + return ( + + + + ); +}; + +export default Calendar; diff --git a/front/app/components/admin/DatePickers/DateSinglePicker/Calendar/utils/getStartEndMonth.ts b/front/app/components/admin/DatePickers/DateSinglePicker/Calendar/utils/getStartEndMonth.ts new file mode 100644 index 000000000000..810e8a1f9553 --- /dev/null +++ b/front/app/components/admin/DatePickers/DateSinglePicker/Calendar/utils/getStartEndMonth.ts @@ -0,0 +1,40 @@ +import { addYears } from 'date-fns'; + +interface GetStartMonthProps { + startMonth?: Date; + selectedDate?: Date; +} + +export const getStartMonth = ({ + startMonth, + selectedDate, +}: GetStartMonthProps) => { + if (startMonth) return startMonth; + const twoYearsAgo = addYears(new Date(), -2); + + if (selectedDate) { + return new Date( + Math.min(addYears(selectedDate, -2).getTime(), twoYearsAgo.getTime()) + ); + } + + return twoYearsAgo; +}; + +interface GetEndMonthProps { + endMonth?: Date; + selectedDate?: Date; +} + +export const getEndMonth = ({ endMonth, selectedDate }: GetEndMonthProps) => { + if (endMonth) return endMonth; + const twoYearsFromNow = addYears(new Date(), 2); + + if (selectedDate) { + return new Date( + Math.max(addYears(selectedDate, 2).getTime(), twoYearsFromNow.getTime()) + ); + } + + return twoYearsFromNow; +}; diff --git a/front/app/components/admin/DatePickers/DateSinglePicker/DateSinglePicker.stories.tsx b/front/app/components/admin/DatePickers/DateSinglePicker/DateSinglePicker.stories.tsx new file mode 100644 index 000000000000..7dfece9769cf --- /dev/null +++ b/front/app/components/admin/DatePickers/DateSinglePicker/DateSinglePicker.stories.tsx @@ -0,0 +1,39 @@ +import React, { useState } from 'react'; + +import DateSinglePicker from '.'; + +import type { Meta, StoryObj } from '@storybook/react'; + +const meta = { + title: 'DateSinglePicker', + component: DateSinglePicker, +} satisfies Meta; + +type Story = StoryObj; + +export default meta; + +const WrapperStandard = () => { + const [selectedDate, setSelectedDate] = useState(); + + return ( + + ); +}; + +export const Standard = { + render: () => { + return ; + }, +}; + +export const Disabled: Story = { + args: { + disabled: true, + onChange: () => {}, + }, +}; diff --git a/front/app/components/admin/DatePickers/DateSinglePicker/Input.tsx b/front/app/components/admin/DatePickers/DateSinglePicker/Input.tsx new file mode 100644 index 000000000000..0c4062dee7c2 --- /dev/null +++ b/front/app/components/admin/DatePickers/DateSinglePicker/Input.tsx @@ -0,0 +1,30 @@ +import React from 'react'; + +import { Box } from '@citizenlab/cl2-component-library'; + +import { useIntl } from 'utils/cl-intl'; + +import InputContainer from '../_shared/InputContainer'; +import sharedMessages from '../_shared/messages'; + +interface Props { + id?: string; + disabled?: boolean; + selectedDate?: Date; + onClick: () => void; +} + +const Input = ({ id, disabled, selectedDate, onClick }: Props) => { + const { formatMessage } = useIntl(); + const selectDate = formatMessage(sharedMessages.selectDate); + + return ( + + + {selectedDate ? selectedDate.toLocaleDateString() : selectDate} + + + ); +}; + +export default Input; diff --git a/front/app/components/admin/DatePickers/DateSinglePicker/index.tsx b/front/app/components/admin/DatePickers/DateSinglePicker/index.tsx new file mode 100644 index 000000000000..f69e45100f67 --- /dev/null +++ b/front/app/components/admin/DatePickers/DateSinglePicker/index.tsx @@ -0,0 +1,64 @@ +import React, { useState } from 'react'; + +import { Tooltip, Box } from '@citizenlab/cl2-component-library'; +import styled from 'styled-components'; + +import ClickOutside from 'utils/containers/clickOutside'; + +import Calendar from './Calendar'; +import Input from './Input'; +import { Props } from './typings'; + +const WIDTH = '310px'; + +const StyledClickOutside = styled(ClickOutside)` + div.tippy-box { + max-width: ${WIDTH} !important; + padding: 8px; + } +`; + +const DateSinglePicker = ({ + id, + disabled, + selectedDate, + startMonth, + endMonth, + defaultMonth, + onChange, +}: Props) => { + const [calendarOpen, setCalendarOpen] = useState(false); + + return ( + setCalendarOpen(false)}> + + { + // We don't allow deselecting dates + if (!date) return; + onChange(date); + }} + /> + + } + placement="bottom" + visible={calendarOpen} + > + setCalendarOpen(true)} + /> + + + ); +}; + +export default DateSinglePicker; diff --git a/front/app/components/admin/DatePickers/DateSinglePicker/typings.ts b/front/app/components/admin/DatePickers/DateSinglePicker/typings.ts new file mode 100644 index 000000000000..22bffba71982 --- /dev/null +++ b/front/app/components/admin/DatePickers/DateSinglePicker/typings.ts @@ -0,0 +1,12 @@ +export interface CalendarProps { + startMonth?: Date; + endMonth?: Date; + defaultMonth?: Date; + selectedDate?: Date; + onChange: (date: Date) => void; +} + +export interface Props extends CalendarProps { + id?: string; + disabled?: boolean; +} diff --git a/front/app/components/admin/DateTimePicker/index.tsx b/front/app/components/admin/DatePickers/DateTimePicker/index.tsx similarity index 100% rename from front/app/components/admin/DateTimePicker/index.tsx rename to front/app/components/admin/DatePickers/DateTimePicker/index.tsx diff --git a/front/app/components/admin/DateTimePicker/messages.ts b/front/app/components/admin/DatePickers/DateTimePicker/messages.ts similarity index 100% rename from front/app/components/admin/DateTimePicker/messages.ts rename to front/app/components/admin/DatePickers/DateTimePicker/messages.ts diff --git a/front/app/components/admin/DatePickers/_shared/InputContainer.tsx b/front/app/components/admin/DatePickers/_shared/InputContainer.tsx new file mode 100644 index 000000000000..b4e8edace322 --- /dev/null +++ b/front/app/components/admin/DatePickers/_shared/InputContainer.tsx @@ -0,0 +1,71 @@ +import React from 'react'; + +import { + defaultInputStyle, + colors, + fontSizes, + Icon, +} from '@citizenlab/cl2-component-library'; +import styled from 'styled-components'; + +const Container = styled.button<{ disabled: boolean }>` + ${defaultInputStyle}; + cursor: pointer; + display: flex; + flex-direction: row; + font-size: ${fontSizes.base}px; + + color: ${colors.grey800}; + + ${({ disabled }) => + disabled + ? ` + cursor: not-allowed; + color: ${colors.grey500}; + svg { + fill: ${colors.grey500}; + } + ` + : ` + &:hover, + &:focus { + color: ${colors.black}; + } + + svg { + fill: ${colors.grey700}; + } + + &:hover svg, + &:focus svg { + fill: ${colors.black}; + } + `} +`; + +interface Props { + id?: string; + disabled?: boolean; + children: React.ReactNode; + onClick: () => void; +} + +const InputContainer = ({ id, disabled = false, children, onClick }: Props) => { + return ( + { + if (disabled) return; + e.preventDefault(); + onClick(); + }} + > + {children} + + + ); +}; + +export default InputContainer; diff --git a/front/app/components/admin/DatePickers/_shared/locales.ts b/front/app/components/admin/DatePickers/_shared/locales.ts new file mode 100644 index 000000000000..ab7542d816e5 --- /dev/null +++ b/front/app/components/admin/DatePickers/_shared/locales.ts @@ -0,0 +1,12 @@ +import { Locale } from 'date-fns'; +import { SupportedLocale } from 'typings'; + +const LOCALES = {}; + +export const addLocale = (localeName: SupportedLocale, localeData: Locale) => { + LOCALES[localeName] = localeData; +}; + +export const getLocale = (localeName: SupportedLocale) => { + return LOCALES[localeName]; +}; diff --git a/front/app/components/admin/DatePickers/_shared/messages.ts b/front/app/components/admin/DatePickers/_shared/messages.ts new file mode 100644 index 000000000000..424f577fead2 --- /dev/null +++ b/front/app/components/admin/DatePickers/_shared/messages.ts @@ -0,0 +1,8 @@ +import { defineMessages } from 'react-intl'; + +export default defineMessages({ + selectDate: { + id: 'app.components.admin.DatePhasePicker.Input.selectDate', + defaultMessage: 'Select date', + }, +}); diff --git a/front/app/components/admin/DateSinglePicker/index.tsx b/front/app/components/admin/DateSinglePicker/index.tsx deleted file mode 100644 index 5ce289efef11..000000000000 --- a/front/app/components/admin/DateSinglePicker/index.tsx +++ /dev/null @@ -1,96 +0,0 @@ -import React from 'react'; -import 'react-datepicker/dist/react-datepicker.css'; - -import { - colors, - fontSizes, - stylingConsts, -} from '@citizenlab/cl2-component-library'; -import DatePicker from 'react-datepicker'; -import styled from 'styled-components'; - -import useLocale from 'hooks/useLocale'; - -import { isNilOrError } from 'utils/helperUtils'; - -const Container = styled.div` - display: flex; - flex-grow: 1; - align-items: center; - border-radius: ${(props) => props.theme.borderRadius}; - border: solid 1px ${colors.borderDark}; - - /* - Added to ensure the color contrast required to meet WCAG AA standards. - */ - .react-datepicker__day--today { - background-color: ${colors.white}; - color: ${colors.black}; - border: 1px solid ${colors.black}; - border-radius: ${stylingConsts.borderRadius}; - } - - /* - If today's date is 20/5 and you go back to the previous month, 20/4 receives the "...keyboard-selected" class, - also resulting in the default light-blue background that the "...today" class receives. - No border is needed here because on focus, the browser adds a border - */ - .react-datepicker__day--keyboard-selected { - background-color: ${colors.white}; - } - - input { - width: 100%; - color: ${colors.grey800}; - font-size: ${fontSizes.base}px; - padding: 12px; - } -`; - -type Props = { - id?: string; - selectedDate: Date | null; - onChange: (date: Date | null) => void; - disabled?: boolean; -}; - -const DateSinglePicker = ({ - id, - selectedDate, - onChange, - disabled = false, -}: Props) => { - const locale = useLocale(); - - if (isNilOrError(locale)) return null; - - // - return ( - - - - ); -}; - -export default DateSinglePicker; diff --git a/front/app/containers/Admin/dashboard/components/TimeControl.tsx b/front/app/containers/Admin/dashboard/components/TimeControl.tsx index ed1452a6069b..267a5396aa71 100644 --- a/front/app/containers/Admin/dashboard/components/TimeControl.tsx +++ b/front/app/containers/Admin/dashboard/components/TimeControl.tsx @@ -4,7 +4,7 @@ import { Icon, Dropdown, colors } from '@citizenlab/cl2-component-library'; import moment, { Moment } from 'moment'; import styled from 'styled-components'; -import DateRangePicker from 'components/admin/DateRangePicker'; +import DateRangePicker from 'components/admin/DatePickers/DateRangePicker'; import Button from 'components/UI/Button'; import { FormattedMessage } from 'utils/cl-intl'; diff --git a/front/app/containers/Admin/projects/project/events/edit.tsx b/front/app/containers/Admin/projects/project/events/edit.tsx index abf71b223b20..7d1496acb4ad 100644 --- a/front/app/containers/Admin/projects/project/events/edit.tsx +++ b/front/app/containers/Admin/projects/project/events/edit.tsx @@ -33,7 +33,7 @@ import useUpdateEvent from 'api/events/useUpdateEvent'; import useContainerWidthAndHeight from 'hooks/useContainerWidthAndHeight'; import useLocale from 'hooks/useLocale'; -import DateTimePicker from 'components/admin/DateTimePicker'; +import DateTimePicker from 'components/admin/DatePickers/DateTimePicker'; import { Section, SectionTitle, SectionField } from 'components/admin/Section'; import SubmitWrapper from 'components/admin/SubmitWrapper'; import Button from 'components/UI/Button'; diff --git a/front/app/containers/Admin/projects/project/messages.ts b/front/app/containers/Admin/projects/project/messages.ts index 5f9c1173eeea..e82bb70abf30 100644 --- a/front/app/containers/Admin/projects/project/messages.ts +++ b/front/app/containers/Admin/projects/project/messages.ts @@ -587,4 +587,12 @@ export default defineMessages({ defaultMessage: 'Screening is not included in your current plan. Talk to your Government Success Manager or admin to unlock it.', }, + missingStartDateError: { + id: 'app.components.app.containers.AdminPage.ProjectEdit.missingStartDateError', + defaultMessage: 'Missing start date', + }, + missingEndDateError: { + id: 'app.components.app.containers.AdminPage.ProjectEdit.missingEndDateError', + defaultMessage: 'Missing end date', + }, }); diff --git a/front/app/containers/Admin/projects/project/participation/ParticipationDateRange.tsx b/front/app/containers/Admin/projects/project/participation/ParticipationDateRange.tsx index ce88da0f672f..539d62392b0c 100644 --- a/front/app/containers/Admin/projects/project/participation/ParticipationDateRange.tsx +++ b/front/app/containers/Admin/projects/project/participation/ParticipationDateRange.tsx @@ -4,7 +4,7 @@ import { Box, Text } from '@citizenlab/cl2-component-library'; import moment, { Moment } from 'moment'; import { useParams } from 'react-router-dom'; -import DateRangePicker from 'components/admin/DateRangePicker'; +import DateRangePicker from 'components/admin/DatePickers/DateRangePicker'; import { useIntl } from 'utils/cl-intl'; diff --git a/front/app/containers/Admin/projects/project/phaseSetup/components/DateSetup.tsx b/front/app/containers/Admin/projects/project/phaseSetup/components/DateSetup.tsx index 3618c096c03e..959a21da1f68 100644 --- a/front/app/containers/Admin/projects/project/phaseSetup/components/DateSetup.tsx +++ b/front/app/containers/Admin/projects/project/phaseSetup/components/DateSetup.tsx @@ -1,150 +1,131 @@ -import React, { useState, useEffect } from 'react'; +import React, { useMemo } from 'react'; -import { Text, CheckboxWithLabel } from '@citizenlab/cl2-component-library'; -import moment, { Moment } from 'moment'; +import { Box } from '@citizenlab/cl2-component-library'; +import { format } from 'date-fns'; import { useParams } from 'react-router-dom'; import { CLErrors } from 'typings'; import { IUpdatedPhaseProperties } from 'api/phases/types'; -import usePhase from 'api/phases/usePhase'; import usePhases from 'api/phases/usePhases'; -import DateRangePicker from 'components/admin/DateRangePicker'; +import DatePhasePicker from 'components/admin/DatePickers/DatePhasePicker'; +import { rangesValid } from 'components/admin/DatePickers/DatePhasePicker/Calendar/utils/rangesValid'; +import { isSelectedRangeOpenEnded } from 'components/admin/DatePickers/DatePhasePicker/isSelectedRangeOpenEnded'; +import { patchDisabledRanges } from 'components/admin/DatePickers/DatePhasePicker/patchDisabledRanges'; import { SectionField, SubSectionTitle } from 'components/admin/Section'; import Error from 'components/UI/Error'; import Warning from 'components/UI/Warning'; -import { FormattedMessage, useIntl } from 'utils/cl-intl'; +import { FormattedMessage } from 'utils/cl-intl'; import messages from '../messages'; -import { SubmitStateType } from '../typings'; -import { getStartDate, getExcludedDates, getMaxEndDate } from '../utils'; +import { SubmitStateType, ValidationErrors } from '../typings'; + +import { getDefaultMonth } from './utils'; interface Props { formData: IUpdatedPhaseProperties; errors: CLErrors | null; + validationErrors: ValidationErrors; setSubmitState: React.Dispatch>; setFormData: React.Dispatch>; + setValidationErrors: React.Dispatch>; } const DateSetup = ({ formData, errors, - setSubmitState, + validationErrors, setFormData, + setSubmitState, + setValidationErrors, }: Props) => { - const { formatMessage } = useIntl(); - const { projectId, phaseId } = useParams() as { - projectId: string; - phaseId?: string; - }; - const { data: phase } = usePhase(phaseId); + const { projectId, phaseId } = useParams(); const { data: phases } = usePhases(projectId); - const [hasEndDate, setHasEndDate] = useState(false); - const [disableNoEndDate, setDisableNoEndDate] = useState(false); - - useEffect(() => { - setHasEndDate(!!phase?.data.attributes.end_at); - }, [phase?.data.attributes.end_at]); - - const startDate = getStartDate({ - phase: phase?.data, - phases, - formData, - }); - - const endAt = formData.end_at ?? phase?.data.attributes.end_at; - const endDate = endAt ? moment(endAt) : null; - - const phasesWithOutCurrentPhase = phases - ? phases.data.filter((iteratedPhase) => iteratedPhase.id !== phase?.data.id) - : []; - const excludeDates = getExcludedDates(phasesWithOutCurrentPhase); - const maxEndDate = getMaxEndDate(phasesWithOutCurrentPhase, startDate, phase); - - const handleDateUpdate = ({ - startDate, - endDate, - }: { - startDate: Moment | null; - endDate: Moment | null; - }) => { - setSubmitState('enabled'); - setFormData({ - ...formData, - start_at: startDate ? startDate.locale('en').format('YYYY-MM-DD') : '', - end_at: endDate ? endDate.locale('en').format('YYYY-MM-DD') : '', - }); - setHasEndDate(!!endDate); - - if (startDate && phases) { - const hasPhaseWithLaterStartDate = phases.data.some((iteratedPhase) => { - const iteratedPhaseStartDate = moment( - iteratedPhase.attributes.start_at - ); - return iteratedPhaseStartDate.isAfter(startDate); - }); - - setDisableNoEndDate(hasPhaseWithLaterStartDate); - - if (hasPhaseWithLaterStartDate) { - setHasEndDate(true); - } - } - }; - - const setNoEndDate = () => { - if (endDate) { - setSubmitState('enabled'); - setFormData({ - ...formData, - end_at: '', - }); - } - setHasEndDate((prevValue) => !prevValue); - }; + + const { start_at, end_at } = formData; + + const selectedRange = useMemo( + () => ({ + from: start_at ? new Date(start_at) : undefined, + to: end_at ? new Date(end_at) : undefined, + }), + [start_at, end_at] + ); + + const disabledRanges = useMemo(() => { + if (!phases) return undefined; + + const otherPhases = phases.data.filter((phase) => phase.id !== phaseId); + const disabledRanges = otherPhases.map( + ({ attributes: { start_at, end_at } }) => ({ + from: new Date(start_at), + to: end_at ? new Date(end_at) : undefined, + }) + ); + + return patchDisabledRanges(selectedRange, disabledRanges); + }, [phases, phaseId, selectedRange]); + + if (!phases || !disabledRanges) return null; + + const selectedRangeIsOpenEnded = isSelectedRangeOpenEnded( + selectedRange, + disabledRanges + ); + + const { valid } = rangesValid(selectedRange, disabledRanges); + + if (!valid) { + // Sometimes, in between switching phases, the ranges + // might become temporarily invalid. In this case, + // we wait for them to become valid first. + return null; + } + + const defaultMonth = getDefaultMonth(selectedRange, disabledRanges); return ( - { + setSubmitState('enabled'); + setValidationErrors((prevState) => ({ + ...prevState, + phaseDateError: undefined, + })); + setFormData({ + ...formData, + start_at: from ? format(from, 'yyyy-MM-dd') : undefined, + end_at: to ? format(to, 'yyyy-MM-dd') : undefined, + }); + }} /> - - - - } - /> - {!hasEndDate && ( - - <> - -
    -
  • - -
  • -
  • - -
  • -
- -
+ + {selectedRangeIsOpenEnded && ( + + + <> + +
    +
  • + +
  • +
  • + +
  • +
+ +
+
)}
); diff --git a/front/app/containers/Admin/projects/project/phaseSetup/components/utils.ts b/front/app/containers/Admin/projects/project/phaseSetup/components/utils.ts new file mode 100644 index 000000000000..d6992d675153 --- /dev/null +++ b/front/app/containers/Admin/projects/project/phaseSetup/components/utils.ts @@ -0,0 +1,19 @@ +import { addDays } from 'date-fns'; + +import { DateRange } from 'components/admin/DatePickers/DatePhasePicker/typings'; + +export const getDefaultMonth = ( + { from }: Partial, + disabledRanges: DateRange[] +) => { + if (from) return from; + + const lastDisabledRange = disabledRanges[disabledRanges.length - 1]; + if (!lastDisabledRange) return undefined; + + if (lastDisabledRange.to) { + return new Date(lastDisabledRange.to); + } + + return addDays(lastDisabledRange.from, 2); +}; diff --git a/front/app/containers/Admin/projects/project/phaseSetup/index.tsx b/front/app/containers/Admin/projects/project/phaseSetup/index.tsx index f739eb8fff99..8dc7e71419b1 100644 --- a/front/app/containers/Admin/projects/project/phaseSetup/index.tsx +++ b/front/app/containers/Admin/projects/project/phaseSetup/index.tsx @@ -7,7 +7,6 @@ import { IconTooltip, colors, } from '@citizenlab/cl2-component-library'; -import moment from 'moment'; import { useParams } from 'react-router-dom'; import { CLErrors, UploadFile, Multiloc } from 'typings'; @@ -51,12 +50,13 @@ import clHistory from 'utils/cl-router/history'; import { defaultAdminCardPadding } from 'utils/styleConstants'; import CampaignRow from './components/CampaignRow'; +// import DateSetup from './components/DateSetup'; import DateSetup from './components/DateSetup'; import PhaseParticipationConfig from './components/PhaseParticipationConfig'; import { ideationDefaultConfig } from './components/PhaseParticipationConfig/utils/participationMethodConfigs'; import messages from './messages'; import { SubmitStateType, ValidationErrors } from './typings'; -import { getTimelineTab, getStartDate } from './utils'; +import { getTimelineTab } from './utils'; import validate from './validate'; const convertToFileType = (phaseFiles: IPhaseFiles | undefined) => { @@ -230,6 +230,7 @@ const AdminPhaseEdit = ({ projectId, phase, flatCampaigns }: Props) => { const { isValidated, errors } = validate( formData, + phases, formatMessage, tenantLocales ); @@ -296,31 +297,11 @@ const AdminPhaseEdit = ({ projectId, phase, flatCampaigns }: Props) => { const save = async (formData: IUpdatedPhaseProperties) => { if (processing) return; - setProcessing(true); - const start = getStartDate({ - phase: phase?.data, - phases, - formData, - }); - const end = formData.end_at ? moment(formData.end_at) : null; - - // If the start date was automatically calculated, we need to update the dates in submit if even if the user didn't change them - const updatedAttr = { - ...formData, - ...(!formData.start_at && - start && { - start_at: start.locale('en').format('YYYY-MM-DD'), - end_at: - formData.end_at || - (end ? end.locale('en').format('YYYY-MM-DD') : ''), - }), - }; - if (phase) { updatePhase( - { phaseId: phase?.data.id, ...updatedAttr }, + { phaseId: phase?.data.id, ...formData }, { onSuccess: (response) => { handleSaveResponse(response, false); @@ -332,7 +313,7 @@ const AdminPhaseEdit = ({ projectId, phase, flatCampaigns }: Props) => { addPhase( { projectId, - ...updatedAttr, + ...formData, }, { onSuccess: (response) => { @@ -381,8 +362,10 @@ const AdminPhaseEdit = ({ projectId, phase, flatCampaigns }: Props) => { { - it('should return the last phase when currentPhase is undefined', () => { - const lastPhase = { id: '3', ...phasesDataMockDataWithoutId }; - const phases = { - data: [ - { id: '1', ...phasesDataMockDataWithoutId }, - { id: '2', ...phasesDataMockDataWithoutId }, - lastPhase, - ], - } as IPhases; - const currentPhase = undefined; - const result = getPreviousPhase(phases, currentPhase); - expect(result).toEqual(lastPhase); - }); - - it('should return the previous phase when currentPhase is an existing phase', () => { - const firstPhase = { id: '1', ...phasesDataMockDataWithoutId }; - const phases = { - data: [ - firstPhase, - { id: '2', ...phasesDataMockDataWithoutId }, - { id: '3', ...phasesDataMockDataWithoutId }, - ], - } as IPhases; - const currentPhase = { - data: { id: '2', ...phasesDataMockDataWithoutId }, - } as IPhase; - const result = getPreviousPhase(phases, currentPhase); - expect(result).toEqual(firstPhase); - }); - - it('should return undefined when there is no phase before the currentPhase', () => { - const phases = { - data: [{ id: '1', ...phasesDataMockDataWithoutId }], - } as IPhases; - const currentPhase = { - data: { id: '1', ...phasesDataMockDataWithoutId }, - } as IPhase; - const result = getPreviousPhase(phases, currentPhase); - expect(result).toBeUndefined(); - }); -}); - -describe('getExcludedDates function', () => { - it('should block dates between start and end dates of a phase', () => { - const phases = [ - { - attributes: { - start_at: '2023-11-01', - end_at: '2023-11-03', - }, - }, - ] as IPhaseData[]; - - const blockedDates = getExcludedDates(phases); - - const expectedDates = [ - moment('2023-11-01'), - moment('2023-11-02'), - moment('2023-11-03'), - ]; - - blockedDates.forEach((date, index) => { - expect(date.isSame(expectedDates[index])).toEqual(true); - }); - }); - - it('should block only the start date of a phase with no end date', () => { - const phases = [ - { - attributes: { - start_at: '2023-11-01', - end_at: null, - }, - }, - ] as IPhaseData[]; - - const blockedDates = getExcludedDates(phases); - - const expectedDates = [moment('2023-11-01')]; - - blockedDates.forEach((date, index) => { - expect(date.isSame(expectedDates[index])).toEqual(true); - }); - }); - - it('should handle multiple phases with different start and end dates', () => { - const phases: IPhaseData[] = [ - { - attributes: { - start_at: '2023-11-01', - end_at: '2023-11-03', - }, - }, - { - attributes: { - start_at: '2023-11-05', - end_at: '2023-11-07', - }, - }, - ] as IPhaseData[]; - - const blockedDates = getExcludedDates(phases); - - const expectedDates = [ - moment('2023-11-01'), - moment('2023-11-02'), - moment('2023-11-03'), - moment('2023-11-05'), - moment('2023-11-06'), - moment('2023-11-07'), - ]; - - blockedDates.forEach((date, index) => { - expect(date.isSame(expectedDates[index])).toEqual(true); - }); - }); -}); diff --git a/front/app/containers/Admin/projects/project/phaseSetup/utils.ts b/front/app/containers/Admin/projects/project/phaseSetup/utils.ts index a0496904041b..1d4f01675945 100644 --- a/front/app/containers/Admin/projects/project/phaseSetup/utils.ts +++ b/front/app/containers/Admin/projects/project/phaseSetup/utils.ts @@ -1,85 +1,4 @@ -import moment, { Moment } from 'moment'; - -import { - IPhase, - IPhaseData, - IPhases, - IUpdatedPhaseProperties, -} from 'api/phases/types'; - -export const getPreviousPhase = ( - phases: IPhases | undefined, - currentPhase: IPhase | undefined -) => { - // if it is a new phase - if (!currentPhase) { - return phases && phases.data.length - ? phases.data[phases.data.length - 1] - : undefined; - } - - // If it is an existing phase - const currentPhaseId = currentPhase ? currentPhase.data.id : null; - const currentPhaseIndex = - phases && phases.data.findIndex((phase) => phase.id === currentPhaseId); - const hasPhaseBeforeCurrent = currentPhaseIndex && currentPhaseIndex > 0; - - return phases && hasPhaseBeforeCurrent - ? phases.data[currentPhaseIndex - 1] - : undefined; -}; - -export function getExcludedDates(phases: IPhaseData[]): moment.Moment[] { - const excludedDates: moment.Moment[] = []; - - phases.forEach((phase) => { - const startDate = moment(phase.attributes.start_at); - - if (phase.attributes.end_at) { - // If the phase has both start and end dates, block the range between them - const endDate = moment(phase.attributes.end_at); - const numberOfDays = endDate.diff(startDate, 'days'); - - for (let i = 0; i <= numberOfDays; i++) { - const excludedDate = startDate.clone().add(i, 'days'); - excludedDates.push(excludedDate); - } - } else { - // If the phase has no end date, block only the start date - excludedDates.push(startDate.clone()); - } - }); - - return excludedDates; -} - -export function getMaxEndDate( - phasesWithOutCurrentPhase: IPhaseData[], - startDate: moment.Moment | null, - currentPhase?: IPhase -) { - const sortedPhases = [ - ...phasesWithOutCurrentPhase.map((iteratedPhase) => ({ - startDate: moment(iteratedPhase.attributes.start_at), - id: iteratedPhase.id, - })), - ...(startDate && currentPhase?.data - ? [{ id: currentPhase.data.id, startDate }] - : []), - ].sort((a, b) => a.startDate.diff(b.startDate)); - - const currentPhaseIndex = sortedPhases.findIndex( - (iteratedPhase) => iteratedPhase.id === currentPhase?.data.id - ); - const hasNextPhase = - currentPhaseIndex !== -1 && - sortedPhases && - currentPhaseIndex !== sortedPhases.length - 1; - const maxEndDate = hasNextPhase - ? sortedPhases[currentPhaseIndex + 1].startDate - : undefined; - return maxEndDate; -} +import { IPhaseData } from 'api/phases/types'; export function getTimelineTab( phase: IPhaseData @@ -109,59 +28,3 @@ export function getTimelineTab( return 'setup'; } - -interface GetStartDateParams { - phase?: IPhaseData; - phases?: IPhases; - formData: IUpdatedPhaseProperties; -} - -export const getStartDate = ({ - phase, - phases, - formData, -}: GetStartDateParams) => { - const phaseAttrs = phase - ? { ...phase.attributes, ...formData } - : { ...formData }; - let startDate: Moment | null = null; - - // If this is a new phase - if (!phase) { - const previousPhase = phases && phases.data[phases.data.length - 1]; - const previousPhaseEndDate = - previousPhase && previousPhase.attributes.end_at - ? moment(previousPhase.attributes.end_at) - : null; - const previousPhaseStartDate = - previousPhase && previousPhase.attributes.start_at - ? moment(previousPhase.attributes.start_at) - : null; - - // And there's a previous phase (end date) and the phase hasn't been picked/changed - if (previousPhaseEndDate && !phaseAttrs.start_at) { - // Make startDate the previousEndDate + 1 day - startDate = previousPhaseEndDate.add(1, 'day'); - // However, if there's been a manual change to this start date - } else if (phaseAttrs.start_at) { - // Take this date as the start date - startDate = moment(phaseAttrs.start_at); - } else if (!previousPhaseEndDate && previousPhaseStartDate) { - // If there is no previous end date, then the previous phase is open ended - // Set the default start date to the previous start date + 2 days to account for single day phases - startDate = previousPhaseStartDate.add(2, 'day'); - } else if (!startDate) { - // If there is no start date at this point, then set the default start date to today - startDate = moment(); - } - - // else there is already a phase (which means we're in the edit form) - // and we take it from the attrs - } else { - if (phaseAttrs.start_at) { - startDate = moment(phaseAttrs.start_at); - } - } - - return startDate; -}; diff --git a/front/app/containers/Admin/projects/project/phaseSetup/validate.test.ts b/front/app/containers/Admin/projects/project/phaseSetup/validate.test.ts index 4f67e0b50ef9..90588ad19172 100644 --- a/front/app/containers/Admin/projects/project/phaseSetup/validate.test.ts +++ b/front/app/containers/Admin/projects/project/phaseSetup/validate.test.ts @@ -61,7 +61,7 @@ describe('validate', () => { const locales: SupportedLocale[] = ['nl-BE']; - const result = validate(formData, formatMessage, locales); + const result = validate(formData, { data: [] }, formatMessage, locales); expect(result.isValidated).toBe(true); }); diff --git a/front/app/containers/Admin/projects/project/phaseSetup/validate.tsx b/front/app/containers/Admin/projects/project/phaseSetup/validate.tsx index 37a6f80f276e..8505fc66d6e0 100644 --- a/front/app/containers/Admin/projects/project/phaseSetup/validate.tsx +++ b/front/app/containers/Admin/projects/project/phaseSetup/validate.tsx @@ -1,16 +1,19 @@ import { isFinite, isNaN } from 'lodash-es'; import { FormatMessage, SupportedLocale } from 'typings'; -import { IUpdatedPhaseProperties } from 'api/phases/types'; +import { IUpdatedPhaseProperties, IPhases } from 'api/phases/types'; import messages from '../messages'; const validate = ( state: IUpdatedPhaseProperties, + phases: IPhases | undefined, formatMessage: FormatMessage, locales?: SupportedLocale[] ) => { const { + start_at, + end_at, reacting_like_method, reacting_dislike_method, reacting_like_limited_max, @@ -27,6 +30,7 @@ const validate = ( } = state; let isValidated = true; + let phaseDateError: string | undefined; let noLikingLimitError: string | undefined; let noDislikingLimitError: string | undefined; let minTotalVotesError: string | undefined; @@ -36,6 +40,29 @@ const validate = ( let expireDateLimitError: string | undefined; let reactingThresholdError: string | undefined; + if (!phases || phases.data.length === 0) { + if (!start_at) { + phaseDateError = formatMessage(messages.missingStartDateError); + isValidated = false; + } + } else { + if (!start_at) { + phaseDateError = formatMessage(messages.missingStartDateError); + isValidated = false; + } else { + if (!end_at) { + const startAtDates = phases.data.map((phase) => + new Date(phase.attributes.start_at).getTime() + ); + const maxStartAt = Math.max(...startAtDates); + if (new Date(start_at).getTime() < maxStartAt) { + phaseDateError = formatMessage(messages.missingEndDateError); + isValidated = false; + } + } + } + } + if ( participation_method === 'voting' && voting_method === 'multiple_voting' @@ -153,6 +180,7 @@ const validate = ( return { isValidated, errors: { + phaseDateError, noLikingLimitError, noDislikingLimitError, minTotalVotesError, diff --git a/front/app/containers/Admin/projects/project/traffic/TrafficDatesRange.tsx b/front/app/containers/Admin/projects/project/traffic/TrafficDatesRange.tsx index ec586e4edc2d..bba79bf6f533 100644 --- a/front/app/containers/Admin/projects/project/traffic/TrafficDatesRange.tsx +++ b/front/app/containers/Admin/projects/project/traffic/TrafficDatesRange.tsx @@ -4,7 +4,7 @@ import { Box, Text } from '@citizenlab/cl2-component-library'; import moment, { Moment } from 'moment'; import { useParams } from 'react-router-dom'; -import DateRangePicker from 'components/admin/DateRangePicker'; +import DateRangePicker from 'components/admin/DatePickers/DateRangePicker'; import Warning from 'components/UI/Warning'; import { useIntl } from 'utils/cl-intl'; diff --git a/front/app/containers/Admin/reporting/components/ReportBuilder/Widgets/ChartWidgets/_shared/ChartWidgetSettings.tsx b/front/app/containers/Admin/reporting/components/ReportBuilder/Widgets/ChartWidgets/_shared/ChartWidgetSettings.tsx index 79ffeb0d2e21..553caa325f6e 100644 --- a/front/app/containers/Admin/reporting/components/ReportBuilder/Widgets/ChartWidgets/_shared/ChartWidgetSettings.tsx +++ b/front/app/containers/Admin/reporting/components/ReportBuilder/Widgets/ChartWidgets/_shared/ChartWidgetSettings.tsx @@ -5,7 +5,7 @@ import { useNode } from '@craftjs/core'; import moment, { Moment } from 'moment'; import { IOption, Multiloc } from 'typings'; -import DateRangePicker from 'components/admin/DateRangePicker'; +import DateRangePicker from 'components/admin/DatePickers/DateRangePicker'; import { getComparedTimeRange } from 'components/admin/GraphCards/_utils/query'; import InputMultilocWithLocaleSwitcher from 'components/UI/InputMultilocWithLocaleSwitcher'; diff --git a/front/app/containers/Admin/reporting/components/ReportBuilderPage/CreateReportModal/index.tsx b/front/app/containers/Admin/reporting/components/ReportBuilderPage/CreateReportModal/index.tsx index 9d63a00104eb..270dcdc144ec 100644 --- a/front/app/containers/Admin/reporting/components/ReportBuilderPage/CreateReportModal/index.tsx +++ b/front/app/containers/Admin/reporting/components/ReportBuilderPage/CreateReportModal/index.tsx @@ -14,7 +14,9 @@ import useAddReport from 'api/reports/useAddReport'; import reportTitleIsTaken from 'containers/Admin/reporting/utils/reportTitleIsTaken'; -import DateRangePicker, { Dates } from 'components/admin/DateRangePicker'; +import DateRangePicker, { + Dates, +} from 'components/admin/DatePickers/DateRangePicker'; import Button from 'components/UI/Button'; import Error from 'components/UI/Error'; import Modal from 'components/UI/Modal'; diff --git a/front/app/containers/Admin/reporting/components/ReportBuilderPage/CreateReportModal/utils.ts b/front/app/containers/Admin/reporting/components/ReportBuilderPage/CreateReportModal/utils.ts index 674234dec70d..54ef783bf7ef 100644 --- a/front/app/containers/Admin/reporting/components/ReportBuilderPage/CreateReportModal/utils.ts +++ b/front/app/containers/Admin/reporting/components/ReportBuilderPage/CreateReportModal/utils.ts @@ -1,6 +1,6 @@ import { RouteType } from 'routes'; -import { Dates } from 'components/admin/DateRangePicker'; +import { Dates } from 'components/admin/DatePickers/DateRangePicker'; import { Template } from './typings'; diff --git a/front/app/i18n/ar-MA.ts b/front/app/i18n/ar-MA.ts index 27fa5dd892f9..be3bd02f43d1 100644 --- a/front/app/i18n/ar-MA.ts +++ b/front/app/i18n/ar-MA.ts @@ -1,9 +1,12 @@ import arMA from 'date-fns/locale/ar-MA'; import { registerLocale } from 'react-datepicker'; +import { addLocale } from 'components/admin/DatePickers/_shared/locales'; + import { formatTranslationMessages } from './'; registerLocale('ar-MA', arMA); +addLocale('ar-MA', arMA); const arMAAdminTranslationMessages = require('translations/admin/ar-MA.json'); const arMATranslationMessages = require('translations/ar-MA.json'); const translationMessages = formatTranslationMessages('ar-MA', { diff --git a/front/app/i18n/ar-SA.ts b/front/app/i18n/ar-SA.ts index b2898b793758..4e3fbc656278 100644 --- a/front/app/i18n/ar-SA.ts +++ b/front/app/i18n/ar-SA.ts @@ -1,9 +1,12 @@ import arSA from 'date-fns/locale/ar-SA'; import { registerLocale } from 'react-datepicker'; +import { addLocale } from 'components/admin/DatePickers/_shared/locales'; + import { formatTranslationMessages } from './'; registerLocale('ar-SA', arSA); +addLocale('ar-SA', arSA); const translationMessages = formatTranslationMessages('ar-SA', { ...require('translations/ar-SA.json'), ...require('translations/admin/ar-SA.json'), diff --git a/front/app/i18n/ca-ES.ts b/front/app/i18n/ca-ES.ts index cb66ca85e4aa..74a5d06cf52b 100644 --- a/front/app/i18n/ca-ES.ts +++ b/front/app/i18n/ca-ES.ts @@ -1,9 +1,12 @@ import ca from 'date-fns/locale/ca'; import { registerLocale } from 'react-datepicker'; +import { addLocale } from 'components/admin/DatePickers/_shared/locales'; + import { formatTranslationMessages } from './'; registerLocale('ca-ES', ca); +addLocale('ca-ES', ca); const caESAdminTranslationMessages = require('translations/admin/ca-ES.json'); const caESTranslationMessages = require('translations/ca-ES.json'); const translationMessages = formatTranslationMessages('ca-ES', { diff --git a/front/app/i18n/cy-GB.ts b/front/app/i18n/cy-GB.ts index c5fe65b49228..263c222f8ce4 100644 --- a/front/app/i18n/cy-GB.ts +++ b/front/app/i18n/cy-GB.ts @@ -1,9 +1,12 @@ import cy from 'date-fns/locale/cy'; import { registerLocale } from 'react-datepicker'; +import { addLocale } from 'components/admin/DatePickers/_shared/locales'; + import { formatTranslationMessages } from '.'; registerLocale('cy-GB', cy); +addLocale('cy-GB', cy); const cyGBAdminTranslationMessages = require('translations/admin/cy-GB.json'); const cyGBTranslationMessages = require('translations/cy-GB.json'); const translationMessages = formatTranslationMessages('cy-GB', { diff --git a/front/app/i18n/da-DK.ts b/front/app/i18n/da-DK.ts index 56e311150198..5dd3136d6936 100644 --- a/front/app/i18n/da-DK.ts +++ b/front/app/i18n/da-DK.ts @@ -1,9 +1,12 @@ import da from 'date-fns/locale/da'; import { registerLocale } from 'react-datepicker'; +import { addLocale } from 'components/admin/DatePickers/_shared/locales'; + import { formatTranslationMessages } from './'; registerLocale('da-DK', da); +addLocale('da-DK', da); const daDKAdminTranslationMessages = require('translations/admin/da-DK.json'); const daDKTranslationMessages = require('translations/da-DK.json'); const translationMessages = formatTranslationMessages('da-DK', { diff --git a/front/app/i18n/de-DE.ts b/front/app/i18n/de-DE.ts index 4a64ea2054b6..eb456f6c9b14 100644 --- a/front/app/i18n/de-DE.ts +++ b/front/app/i18n/de-DE.ts @@ -1,9 +1,12 @@ import de from 'date-fns/locale/de'; import { registerLocale } from 'react-datepicker'; +import { addLocale } from 'components/admin/DatePickers/_shared/locales'; + import { formatTranslationMessages } from './'; registerLocale('de-DE', de); +addLocale('de-DE', de); const deDEAdminTranslationMessages = require('translations/admin/de-DE.json'); const deDETranslationMessages = require('translations/de-DE.json'); const translationMessages = formatTranslationMessages('de-DE', { diff --git a/front/app/i18n/el-GR.ts b/front/app/i18n/el-GR.ts index 9354234edce5..fcff60daf742 100644 --- a/front/app/i18n/el-GR.ts +++ b/front/app/i18n/el-GR.ts @@ -1,9 +1,12 @@ import el from 'date-fns/locale/el'; import { registerLocale } from 'react-datepicker'; +import { addLocale } from 'components/admin/DatePickers/_shared/locales'; + import { formatTranslationMessages } from './'; registerLocale('el-GR', el); +addLocale('el-GR', el); const elGRAdminTranslationMessages = require('translations/admin/el-GR.json'); const elGRTranslationMessages = require('translations/el-GR.json'); const translationMessages = formatTranslationMessages('el-GR', { diff --git a/front/app/i18n/en-CA.ts b/front/app/i18n/en-CA.ts index d82b7dac9f10..4a30531729cc 100644 --- a/front/app/i18n/en-CA.ts +++ b/front/app/i18n/en-CA.ts @@ -1,9 +1,12 @@ import enCA from 'date-fns/locale/en-CA'; import { registerLocale } from 'react-datepicker'; +import { addLocale } from 'components/admin/DatePickers/_shared/locales'; + import { formatTranslationMessages } from './'; registerLocale('en-CA', enCA); +addLocale('en-CA', enCA); const enCAAdminTranslationMessages = require('translations/admin/en-CA.json'); const enCATranslationMessages = require('translations/en-CA.json'); const translationMessages = formatTranslationMessages('en-CA', { diff --git a/front/app/i18n/en-GB.ts b/front/app/i18n/en-GB.ts index bd710d2a9833..9116c715568e 100644 --- a/front/app/i18n/en-GB.ts +++ b/front/app/i18n/en-GB.ts @@ -1,9 +1,12 @@ import enGB from 'date-fns/locale/en-GB'; import { registerLocale } from 'react-datepicker'; +import { addLocale } from 'components/admin/DatePickers/_shared/locales'; + import { formatTranslationMessages } from './'; registerLocale('en-GB', enGB); +addLocale('en-GB', enGB); const enGBAdminTranslationMessages = require('translations/admin/en-GB.json'); const enGBTranslationMessages = require('translations/en-GB.json'); const translationMessages = formatTranslationMessages('en-GB', { diff --git a/front/app/i18n/en-IE.ts b/front/app/i18n/en-IE.ts index cc0572d66c7d..c00675b542a6 100644 --- a/front/app/i18n/en-IE.ts +++ b/front/app/i18n/en-IE.ts @@ -1,9 +1,12 @@ import enIE from 'date-fns/locale/en-IE'; import { registerLocale } from 'react-datepicker'; +import { addLocale } from 'components/admin/DatePickers/_shared/locales'; + import { formatTranslationMessages } from './'; registerLocale('en-IE', enIE); +addLocale('en-IE', enIE); const enIEAdminTranslationMessages = require('translations/admin/en-IE.json'); const enIETranslationMessages = require('translations/en-IE.json'); const translationMessages = formatTranslationMessages('en-IE', { diff --git a/front/app/i18n/en.ts b/front/app/i18n/en.ts index d603b13f16d6..dc125d9ab63f 100644 --- a/front/app/i18n/en.ts +++ b/front/app/i18n/en.ts @@ -2,10 +2,14 @@ import enGB from 'date-fns/locale/en-GB'; import enUS from 'date-fns/locale/en-US'; import { registerLocale } from 'react-datepicker'; +import { addLocale } from 'components/admin/DatePickers/_shared/locales'; + import { formatTranslationMessages } from './'; registerLocale('en-GB', enGB); registerLocale('en-US', enUS); +addLocale('en-GB', enGB); +addLocale('en', enUS); const enAdminTranslationMessages = require('translations/admin/en.json'); const enTranslationMessages = require('translations/en.json'); const translationMessages = formatTranslationMessages('en', { diff --git a/front/app/i18n/es-CL.ts b/front/app/i18n/es-CL.ts index 8b9c2fd94e14..cb44afc459df 100644 --- a/front/app/i18n/es-CL.ts +++ b/front/app/i18n/es-CL.ts @@ -1,9 +1,12 @@ import es from 'date-fns/locale/es'; import { registerLocale } from 'react-datepicker'; +import { addLocale } from 'components/admin/DatePickers/_shared/locales'; + import { formatTranslationMessages } from './'; registerLocale('es-CL', es); +addLocale('es-CL', es); const esCLAdminTranslationMessages = require('translations/admin/es-CL.json'); const esCLTranslationMessages = require('translations/es-CL.json'); const translationMessages = formatTranslationMessages('es-CL', { diff --git a/front/app/i18n/es-ES.ts b/front/app/i18n/es-ES.ts index 501825248766..73e83c42cc41 100644 --- a/front/app/i18n/es-ES.ts +++ b/front/app/i18n/es-ES.ts @@ -1,9 +1,12 @@ import es from 'date-fns/locale/es'; import { registerLocale } from 'react-datepicker'; +import { addLocale } from 'components/admin/DatePickers/_shared/locales'; + import { formatTranslationMessages } from './'; registerLocale('es-ES', es); +addLocale('es-ES', es); const esESAdminTranslationMessages = require('translations/admin/es-ES.json'); const esESTranslationMessages = require('translations/es-ES.json'); const translationMessages = formatTranslationMessages('es-ES', { diff --git a/front/app/i18n/fi-FI.ts b/front/app/i18n/fi-FI.ts index 719102db8b38..e70fb400e49c 100644 --- a/front/app/i18n/fi-FI.ts +++ b/front/app/i18n/fi-FI.ts @@ -1,9 +1,12 @@ import fi from 'date-fns/locale/fi'; import { registerLocale } from 'react-datepicker'; +import { addLocale } from 'components/admin/DatePickers/_shared/locales'; + import { formatTranslationMessages } from './'; registerLocale('fi-FI', fi); +addLocale('fi-FI', fi); const fiFIAdminTranslationMessages = require('translations/admin/fi-FI.json'); const fiFITranslationMessages = require('translations/fi-FI.json'); const translationMessages = formatTranslationMessages('fi-FI', { diff --git a/front/app/i18n/fr-BE.ts b/front/app/i18n/fr-BE.ts index a3b21d37b6d5..f867278237ef 100644 --- a/front/app/i18n/fr-BE.ts +++ b/front/app/i18n/fr-BE.ts @@ -1,9 +1,12 @@ import frBE from 'date-fns/locale/fr'; import { registerLocale } from 'react-datepicker'; +import { addLocale } from 'components/admin/DatePickers/_shared/locales'; + import { formatTranslationMessages } from './'; registerLocale('fr-BE', frBE); +addLocale('fr-BE', frBE); const frBEAdminTranslationMessages = require('translations/admin/fr-BE.json'); const frBETranslationMessages = require('translations/fr-BE.json'); const translationMessages = formatTranslationMessages('fr-BE', { diff --git a/front/app/i18n/fr-FR.ts b/front/app/i18n/fr-FR.ts index d86b9c690522..22863663955e 100644 --- a/front/app/i18n/fr-FR.ts +++ b/front/app/i18n/fr-FR.ts @@ -1,9 +1,12 @@ import frFR from 'date-fns/locale/fr'; import { registerLocale } from 'react-datepicker'; +import { addLocale } from 'components/admin/DatePickers/_shared/locales'; + import { formatTranslationMessages } from './'; registerLocale('fr-FR', frFR); +addLocale('fr-FR', frFR); const frFRAdminTranslationMessages = require('translations/admin/fr-FR.json'); const frFRTranslationMessages = require('translations/fr-FR.json'); const translationMessages = formatTranslationMessages('fr-FR', { diff --git a/front/app/i18n/hr-HR.ts b/front/app/i18n/hr-HR.ts index a3e4c5bf6a96..9d3ab6bf50ff 100644 --- a/front/app/i18n/hr-HR.ts +++ b/front/app/i18n/hr-HR.ts @@ -1,9 +1,12 @@ import hr from 'date-fns/locale/hr'; import { registerLocale } from 'react-datepicker'; +import { addLocale } from 'components/admin/DatePickers/_shared/locales'; + import { formatTranslationMessages } from './'; registerLocale('hr-HR', hr); +addLocale('hr-HR', hr); const hrHRAdminTranslationMessages = require('translations/admin/hr-HR.json'); const hrHRTranslationMessages = require('translations/hr-HR.json'); const translationMessages = formatTranslationMessages('hr-HR', { diff --git a/front/app/i18n/hu-HU.ts b/front/app/i18n/hu-HU.ts index ff72e33a1cbd..7cc9b9bb967f 100644 --- a/front/app/i18n/hu-HU.ts +++ b/front/app/i18n/hu-HU.ts @@ -1,9 +1,12 @@ import hu from 'date-fns/locale/hu'; import { registerLocale } from 'react-datepicker'; +import { addLocale } from 'components/admin/DatePickers/_shared/locales'; + import { formatTranslationMessages } from './'; registerLocale('hu-HU', hu); +addLocale('hu-HU', hu); const huHUAdminTranslationMessages = require('translations/admin/hu-HU.json'); const huHUTranslationMessages = require('translations/hu-HU.json'); const translationMessages = formatTranslationMessages('hu-HU', { diff --git a/front/app/i18n/it-IT.ts b/front/app/i18n/it-IT.ts index 801f2db3a9d2..b1f25a26c29e 100644 --- a/front/app/i18n/it-IT.ts +++ b/front/app/i18n/it-IT.ts @@ -1,9 +1,12 @@ import it from 'date-fns/locale/it'; import { registerLocale } from 'react-datepicker'; +import { addLocale } from 'components/admin/DatePickers/_shared/locales'; + import { formatTranslationMessages } from './'; registerLocale('it-IT', it); +addLocale('it-IT', it); const itITAdminTranslationMessages = require('translations/admin/it-IT.json'); const itITTranslationMessages = require('translations/it-IT.json'); const translationMessages = formatTranslationMessages('it-IT', { diff --git a/front/app/i18n/lb-LU.ts b/front/app/i18n/lb-LU.ts index 931bb8aaa83c..1d345f922424 100644 --- a/front/app/i18n/lb-LU.ts +++ b/front/app/i18n/lb-LU.ts @@ -1,9 +1,12 @@ import lb from 'date-fns/locale/lb'; import { registerLocale } from 'react-datepicker'; +import { addLocale } from 'components/admin/DatePickers/_shared/locales'; + import { formatTranslationMessages } from './'; registerLocale('lb-LU', lb); +addLocale('lb-LU', lb); const lbLUAdminTranslationMessages = require('translations/admin/lb-LU.json'); const lbLUTranslationMessages = require('translations/lb-LU.json'); const translationMessages = formatTranslationMessages('lb-LU', { diff --git a/front/app/i18n/lv-LV.ts b/front/app/i18n/lv-LV.ts index 84e4e91b2266..0cd96e5187f5 100644 --- a/front/app/i18n/lv-LV.ts +++ b/front/app/i18n/lv-LV.ts @@ -1,9 +1,12 @@ import lv from 'date-fns/locale/lv'; import { registerLocale } from 'react-datepicker'; +import { addLocale } from 'components/admin/DatePickers/_shared/locales'; + import { formatTranslationMessages } from './'; registerLocale('lv-LV', lv); +addLocale('lv-LV', lv); const lvLVAdminTranslationMessages = require('translations/admin/lv-LV.json'); const lvLVTranslationMessages = require('translations/lv-LV.json'); const translationMessages = formatTranslationMessages('lv-LV', { diff --git a/front/app/i18n/nb-NO.ts b/front/app/i18n/nb-NO.ts index d6345f2fa8a9..7eb114e200d3 100644 --- a/front/app/i18n/nb-NO.ts +++ b/front/app/i18n/nb-NO.ts @@ -1,9 +1,12 @@ import nb from 'date-fns/locale/nb'; import { registerLocale } from 'react-datepicker'; +import { addLocale } from 'components/admin/DatePickers/_shared/locales'; + import { formatTranslationMessages } from './'; registerLocale('nb-NO', nb); +addLocale('nb-NO', nb); const nbNOAdminTranslationMessages = require('translations/admin/nb-NO.json'); const nbNOTranslationMessages = require('translations/nb-NO.json'); const translationMessages = formatTranslationMessages('nb-NO', { diff --git a/front/app/i18n/nl-BE.ts b/front/app/i18n/nl-BE.ts index 59b56ea61a86..4faa0df6f456 100644 --- a/front/app/i18n/nl-BE.ts +++ b/front/app/i18n/nl-BE.ts @@ -1,9 +1,12 @@ import nlBE from 'date-fns/locale/nl-BE'; import { registerLocale } from 'react-datepicker'; +import { addLocale } from 'components/admin/DatePickers/_shared/locales'; + import { formatTranslationMessages } from './'; registerLocale('nl-BE', nlBE); +addLocale('nl-BE', nlBE); const nlBEAdminTranslationMessages = require('translations/admin/nl-BE.json'); const nlBETranslationMessages = require('translations/nl-BE.json'); const translationMessages = formatTranslationMessages('nl-BE', { diff --git a/front/app/i18n/nl-NL.ts b/front/app/i18n/nl-NL.ts index 67afb5253719..3db8109b876c 100644 --- a/front/app/i18n/nl-NL.ts +++ b/front/app/i18n/nl-NL.ts @@ -1,9 +1,12 @@ import nl from 'date-fns/locale/nl'; import { registerLocale } from 'react-datepicker'; +import { addLocale } from 'components/admin/DatePickers/_shared/locales'; + import { formatTranslationMessages } from './'; registerLocale('nl-NL', nl); +addLocale('nl-NL', nl); const nlNLAdminTranslationMessages = require('translations/admin/nl-NL.json'); const nlNLTranslationMessages = require('translations/nl-NL.json'); const translationMessages = formatTranslationMessages('nl-NL', { diff --git a/front/app/i18n/pl-PL.ts b/front/app/i18n/pl-PL.ts index e6203a8ec20d..90162b4407de 100644 --- a/front/app/i18n/pl-PL.ts +++ b/front/app/i18n/pl-PL.ts @@ -1,9 +1,12 @@ import pl from 'date-fns/locale/pl'; import { registerLocale } from 'react-datepicker'; +import { addLocale } from 'components/admin/DatePickers/_shared/locales'; + import { formatTranslationMessages } from './'; registerLocale('pl-PL', pl); +addLocale('pl-PL', pl); const plPLAdminTranslationMessages = require('translations/admin/pl-PL.json'); const plPLTranslationMessages = require('translations/pl-PL.json'); const translationMessages = formatTranslationMessages('pl-PL', { diff --git a/front/app/i18n/pt-BR.ts b/front/app/i18n/pt-BR.ts index b23b348afc60..e9f2d00d0629 100644 --- a/front/app/i18n/pt-BR.ts +++ b/front/app/i18n/pt-BR.ts @@ -1,9 +1,12 @@ import ptBR from 'date-fns/locale/pt-BR'; import { registerLocale } from 'react-datepicker'; +import { addLocale } from 'components/admin/DatePickers/_shared/locales'; + import { formatTranslationMessages } from './'; registerLocale('pt-BR', ptBR); +addLocale('pt-BR', ptBR); const ptBRAdminTranslationMessages = require('translations/admin/pt-BR.json'); const ptBRTranslationMessages = require('translations/pt-BR.json'); const translationMessages = formatTranslationMessages('pt-BR', { diff --git a/front/app/i18n/ro-RO.ts b/front/app/i18n/ro-RO.ts index ad92c7fa44ec..e7da53214e47 100644 --- a/front/app/i18n/ro-RO.ts +++ b/front/app/i18n/ro-RO.ts @@ -1,9 +1,12 @@ import ro from 'date-fns/locale/ro'; import { registerLocale } from 'react-datepicker'; +import { addLocale } from 'components/admin/DatePickers/_shared/locales'; + import { formatTranslationMessages } from './'; registerLocale('ro-RO', ro); +addLocale('ro-RO', ro); const roROAdminTranslationMessages = require('translations/admin/ro-RO.json'); const roROTranslationMessages = require('translations/ro-RO.json'); const translationMessages = formatTranslationMessages('ro-RO', { diff --git a/front/app/i18n/sr-Latn.ts b/front/app/i18n/sr-Latn.ts index f96014453802..772a327f964e 100644 --- a/front/app/i18n/sr-Latn.ts +++ b/front/app/i18n/sr-Latn.ts @@ -1,9 +1,12 @@ import srLatn from 'date-fns/locale/sr-Latn'; import { registerLocale } from 'react-datepicker'; +import { addLocale } from 'components/admin/DatePickers/_shared/locales'; + import { formatTranslationMessages } from './'; registerLocale('sr-Latn', srLatn); +addLocale('sr-Latn', srLatn); const srLatnAdminTranslationMessages = require('translations/admin/sr-Latn.json'); const srLatnTranslationMessages = require('translations/sr-Latn.json'); const translationMessages = formatTranslationMessages('sr-Latn', { diff --git a/front/app/i18n/sr-SP.ts b/front/app/i18n/sr-SP.ts index 6a1a13081ffb..409b765b4382 100644 --- a/front/app/i18n/sr-SP.ts +++ b/front/app/i18n/sr-SP.ts @@ -1,9 +1,12 @@ import sr from 'date-fns/locale/sr'; import { registerLocale } from 'react-datepicker'; +import { addLocale } from 'components/admin/DatePickers/_shared/locales'; + import { formatTranslationMessages } from './'; registerLocale('sr-SP', sr); +addLocale('sr-SP', sr); const srSPAdminTranslationMessages = require('translations/admin/sr-SP.json'); const srSPTranslationMessages = require('translations/sr-SP.json'); const translationMessages = formatTranslationMessages('sr-SP', { diff --git a/front/app/i18n/sv-SE.ts b/front/app/i18n/sv-SE.ts index cf24dcf0e9f6..bae112f780b9 100644 --- a/front/app/i18n/sv-SE.ts +++ b/front/app/i18n/sv-SE.ts @@ -1,9 +1,12 @@ import sv from 'date-fns/locale/sv'; import { registerLocale } from 'react-datepicker'; +import { addLocale } from 'components/admin/DatePickers/_shared/locales'; + import { formatTranslationMessages } from './'; registerLocale('sv-SE', sv); +addLocale('sv-SE', sv); const svSEAdminTranslationMessages = require('translations/admin/sv-SE.json'); const svSETranslationMessages = require('translations/sv-SE.json'); const translationMessages = formatTranslationMessages('sv-SE', { diff --git a/front/app/i18n/tr-TR.ts b/front/app/i18n/tr-TR.ts index abcffff90ae0..61631e120fc3 100644 --- a/front/app/i18n/tr-TR.ts +++ b/front/app/i18n/tr-TR.ts @@ -1,9 +1,12 @@ import tr from 'date-fns/locale/tr'; import { registerLocale } from 'react-datepicker'; +import { addLocale } from 'components/admin/DatePickers/_shared/locales'; + import { formatTranslationMessages } from './'; registerLocale('tr-TR', tr); +addLocale('tr-TR', tr); const trTRAdminTranslationMessages = require('translations/admin/tr-TR.json'); const trTRTranslationMessages = require('translations/tr-TR.json'); const translationMessages = formatTranslationMessages('tr-TR', { diff --git a/front/app/modules/commercial/smart_groups/components/UserFilterConditions/ValueSelector/DateValueSelector.tsx b/front/app/modules/commercial/smart_groups/components/UserFilterConditions/ValueSelector/DateValueSelector.tsx index d7b64af94376..73c458e24e53 100644 --- a/front/app/modules/commercial/smart_groups/components/UserFilterConditions/ValueSelector/DateValueSelector.tsx +++ b/front/app/modules/commercial/smart_groups/components/UserFilterConditions/ValueSelector/DateValueSelector.tsx @@ -2,7 +2,7 @@ import React from 'react'; import moment from 'moment'; -import DateSinglePicker from 'components/admin/DateSinglePicker'; +import DateSinglePicker from 'components/admin/DatePickers/DateSinglePicker'; type Props = { value?: string; @@ -18,7 +18,7 @@ const DateValueSelector = ({ value, onChange }: Props) => { return ( ); diff --git a/front/app/translations/admin/en.json b/front/app/translations/admin/en.json index ba68fdc98791..be416ab0a6af 100644 --- a/front/app/translations/admin/en.json +++ b/front/app/translations/admin/en.json @@ -116,6 +116,8 @@ "app.components.admin.ContentBuilder.Widgets.SurveyQuestionResultWidget.numberOfResponses": "{count} responses", "app.components.admin.ContentBuilder.Widgets.SurveyQuestionResultWidget.surveyQuestion": "Survey question", "app.components.admin.ContentBuilder.Widgets.SurveyQuestionResultWidget.untilNow": "{date} until now", + "app.components.admin.DatePhasePicker.Input.openEnded": "Open ended", + "app.components.admin.DatePhasePicker.Input.selectDate": "Select date", "app.components.admin.DateTimePicker.time": "Time", "app.components.admin.Graphs": "No data available with the current filters.", "app.components.admin.Graphs.noDataShort": "No data available.", @@ -324,6 +326,8 @@ "app.components.app.containers.AdminPage.ProjectEdit.minBudgetRequired": "A minimum budget is required", "app.components.app.containers.AdminPage.ProjectEdit.minTotalVotesLargerThanMaxError": "The minimum number of votes can't be larger than the maximum number", "app.components.app.containers.AdminPage.ProjectEdit.minVotesRequired": "A minimum number of votes is required", + "app.components.app.containers.AdminPage.ProjectEdit.missingEndDateError": "Missing end date", + "app.components.app.containers.AdminPage.ProjectEdit.missingStartDateError": "Missing start date", "app.components.app.containers.AdminPage.ProjectEdit.optionTerm": "Option", "app.components.app.containers.AdminPage.ProjectEdit.optionsPageText2": "Input Manager tab", "app.components.app.containers.AdminPage.ProjectEdit.optionsToVoteOnDescWihoutPhase": "Configure the voting options in the Input manager tab after creating a phase.", diff --git a/front/cypress/e2e/admin/phases/proposals.cy.ts b/front/cypress/e2e/admin/phases/proposals.cy.ts index 5439814cb646..e183ca8c10f9 100644 --- a/front/cypress/e2e/admin/phases/proposals.cy.ts +++ b/front/cypress/e2e/admin/phases/proposals.cy.ts @@ -37,6 +37,13 @@ describe('Admin: proposal phase', () => { const phaseNameEN = randomString(); cy.get('#title').type(phaseNameEN); + // Set date + cy.get('.e2e-date-phase-picker-input').first().click(); + cy.get('.rdp-today').first().click(); + + // Click input again to close date picker + cy.get('.e2e-date-phase-picker-input').first().click(); + cy.get('#e2e-participation-method-choice-proposals').click(); cy.get('.e2e-submit-wrapper-button button').click(); diff --git a/front/package-lock.json b/front/package-lock.json index 461d81d3211d..d433aaded2b3 100644 --- a/front/package-lock.json +++ b/front/package-lock.json @@ -65,6 +65,7 @@ "react-color": "2.19.3", "react-csv": "2.2.2", "react-datepicker": "4.21.0", + "react-day-picker": "9.1.3", "react-dnd": "16.0.1", "react-dnd-html5-backend": "16.0.1", "react-dom": "17.0.2", @@ -2437,6 +2438,11 @@ "ms": "^2.1.1" } }, + "node_modules/@date-fns/tz": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@date-fns/tz/-/tz-1.1.2.tgz", + "integrity": "sha512-Xmg2cPmOPQieCLAdf62KtFPU9y7wbQDq1OAzrs/bEQFvhtCPXDiks1CHDE/sTXReRfh/MICVkw/vY6OANHUGiA==" + }, "node_modules/@discoveryjs/json-ext": { "version": "0.5.7", "resolved": "https://registry.npmjs.org/@discoveryjs/json-ext/-/json-ext-0.5.7.tgz", @@ -21017,6 +21023,31 @@ "react-dom": "^16.9.0 || ^17 || ^18" } }, + "node_modules/react-day-picker": { + "version": "9.1.3", + "resolved": "https://registry.npmjs.org/react-day-picker/-/react-day-picker-9.1.3.tgz", + "integrity": "sha512-2PLtAcO5QORfGosywl8KeqqjOkwz8r4PQYRA4QwHU3ayb7y9nDN5foXK3/hUiM8cNycOQD8vuV6DHy81H0wxPQ==", + "dependencies": { + "@date-fns/tz": "^1.1.2", + "date-fns": "^4.1.0" + }, + "funding": { + "type": "individual", + "url": "https://github.com/sponsors/gpbl" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, + "node_modules/react-day-picker/node_modules/date-fns": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz", + "integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/kossnocorp" + } + }, "node_modules/react-dnd": { "version": "16.0.1", "resolved": "https://registry.npmjs.org/react-dnd/-/react-dnd-16.0.1.tgz", diff --git a/front/package.json b/front/package.json index a750a26f3815..c311fb75ff34 100644 --- a/front/package.json +++ b/front/package.json @@ -110,6 +110,7 @@ "react-color": "2.19.3", "react-csv": "2.2.2", "react-datepicker": "4.21.0", + "react-day-picker": "9.1.3", "react-dnd": "16.0.1", "react-dnd-html5-backend": "16.0.1", "react-dom": "17.0.2",