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",