diff --git a/front/app/components/admin/DatePickers/DatePhasePicker/Calendar/index.tsx b/front/app/components/admin/DatePickers/DatePhasePicker/Calendar/index.tsx index 589434076dfb..1a03ad9136b5 100644 --- a/front/app/components/admin/DatePickers/DatePhasePicker/Calendar/index.tsx +++ b/front/app/components/admin/DatePickers/DatePhasePicker/Calendar/index.tsx @@ -8,6 +8,8 @@ import styled from 'styled-components'; import useLocale from 'hooks/useLocale'; +import { userTimezone } from 'utils/dateUtils'; + import { getLocale } from '../../_shared/locales'; import { Props } from '../typings'; @@ -144,7 +146,6 @@ const Calendar = ({ defaultMonth, onUpdateRange, }: Props) => { - const userTimezone = Intl.DateTimeFormat().resolvedOptions().timeZone; const startMonth = getStartMonth({ startMonth: _startMonth, selectedRange, diff --git a/front/app/components/admin/DatePickers/DatePhasePicker/Calendar/utils/generateModifiers.ts b/front/app/components/admin/DatePickers/DatePhasePicker/Calendar/utils/generateModifiers.ts index 70efb722952a..433a3a1661e5 100644 --- a/front/app/components/admin/DatePickers/DatePhasePicker/Calendar/utils/generateModifiers.ts +++ b/front/app/components/admin/DatePickers/DatePhasePicker/Calendar/utils/generateModifiers.ts @@ -1,6 +1,8 @@ import { differenceInDays, addDays } from 'date-fns'; -import { DateRange, ClosedDateRange } from '../../typings'; +import { DateRange } from 'components/admin/DatePickers/_shared/typings'; + +import { ClosedDateRange } from '../../typings'; import { rangesValid } from './rangesValid'; import { allAreClosedDateRanges } from './utils'; diff --git a/front/app/components/admin/DatePickers/DatePhasePicker/Calendar/utils/getStartEndMonth.ts b/front/app/components/admin/DatePickers/DatePhasePicker/Calendar/utils/getStartEndMonth.ts index 8e664bbbf8f6..7dd5fbe22e0c 100644 --- a/front/app/components/admin/DatePickers/DatePhasePicker/Calendar/utils/getStartEndMonth.ts +++ b/front/app/components/admin/DatePickers/DatePhasePicker/Calendar/utils/getStartEndMonth.ts @@ -1,6 +1,6 @@ import { addYears } from 'date-fns'; -import { DateRange } from '../../typings'; +import { DateRange } from 'components/admin/DatePickers/_shared/typings'; interface GetStartMonthProps { startMonth?: Date; diff --git a/front/app/components/admin/DatePickers/DatePhasePicker/Calendar/utils/getUpdatedRange.ts b/front/app/components/admin/DatePickers/DatePhasePicker/Calendar/utils/getUpdatedRange.ts index 5dc82c13d11a..935881d256c0 100644 --- a/front/app/components/admin/DatePickers/DatePhasePicker/Calendar/utils/getUpdatedRange.ts +++ b/front/app/components/admin/DatePickers/DatePhasePicker/Calendar/utils/getUpdatedRange.ts @@ -1,6 +1,8 @@ import { addDays, isSameDay } from 'date-fns'; -import { ClosedDateRange, DateRange } from '../../typings'; +import { DateRange } from 'components/admin/DatePickers/_shared/typings'; + +import { ClosedDateRange } from '../../typings'; import { rangesValid } from './rangesValid'; import { isClosedDateRange } from './utils'; diff --git a/front/app/components/admin/DatePickers/DatePhasePicker/Calendar/utils/rangesValid.ts b/front/app/components/admin/DatePickers/DatePhasePicker/Calendar/utils/rangesValid.ts index 51c64dfd04f6..f7ad6ed3878a 100644 --- a/front/app/components/admin/DatePickers/DatePhasePicker/Calendar/utils/rangesValid.ts +++ b/front/app/components/admin/DatePickers/DatePhasePicker/Calendar/utils/rangesValid.ts @@ -1,6 +1,8 @@ import { differenceInDays } from 'date-fns'; -import { DateRange, ClosedDateRange } from '../../typings'; +import { DateRange } from 'components/admin/DatePickers/_shared/typings'; + +import { ClosedDateRange } from '../../typings'; import { allAreClosedDateRanges, isClosedDateRange } from './utils'; diff --git a/front/app/components/admin/DatePickers/DatePhasePicker/Calendar/utils/utils.ts b/front/app/components/admin/DatePickers/DatePhasePicker/Calendar/utils/utils.ts index edaf24afe124..07c33a4b83da 100644 --- a/front/app/components/admin/DatePickers/DatePhasePicker/Calendar/utils/utils.ts +++ b/front/app/components/admin/DatePickers/DatePhasePicker/Calendar/utils/utils.ts @@ -1,4 +1,5 @@ -import { DateRange, ClosedDateRange } from '../../typings'; +import { DateRange } from '../../../_shared/typings'; +import { ClosedDateRange } from '../../typings'; export const isClosedDateRange = (range: DateRange): range is ClosedDateRange => !!range.to; diff --git a/front/app/components/admin/DatePickers/DatePhasePicker/DatePhasePicker.stories.tsx b/front/app/components/admin/DatePickers/DatePhasePicker/DatePhasePicker.stories.tsx index acf9bb318008..aa9718eb683a 100644 --- a/front/app/components/admin/DatePickers/DatePhasePicker/DatePhasePicker.stories.tsx +++ b/front/app/components/admin/DatePickers/DatePhasePicker/DatePhasePicker.stories.tsx @@ -1,7 +1,8 @@ import React, { useState } from 'react'; +import { DateRange } from '../_shared/typings'; + import { patchDisabledRanges } from './patchDisabledRanges'; -import { DateRange } from './typings'; import DatePhasePicker from '.'; diff --git a/front/app/components/admin/DatePickers/DatePhasePicker/Input.tsx b/front/app/components/admin/DatePickers/DatePhasePicker/Input.tsx index c4feb490d180..19585f6b503f 100644 --- a/front/app/components/admin/DatePickers/DatePhasePicker/Input.tsx +++ b/front/app/components/admin/DatePickers/DatePhasePicker/Input.tsx @@ -6,9 +6,9 @@ import { useIntl } from 'utils/cl-intl'; import InputContainer from '../_shared/InputContainer'; import sharedMessages from '../_shared/messages'; +import { DateRange } from '../_shared/typings'; import messages from './messages'; -import { DateRange } from './typings'; interface Props { selectedRange: Partial; diff --git a/front/app/components/admin/DatePickers/DatePhasePicker/index.tsx b/front/app/components/admin/DatePickers/DatePhasePicker/index.tsx index c10852c01dcd..8be5b344d05b 100644 --- a/front/app/components/admin/DatePickers/DatePhasePicker/index.tsx +++ b/front/app/components/admin/DatePickers/DatePhasePicker/index.tsx @@ -1,9 +1,8 @@ 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 ClickOutsideContainer from '../_shared/ClickOutsideContainer'; import Calendar from './Calendar'; import Input from './Input'; @@ -12,13 +11,6 @@ import { Props } from './typings'; const WIDTH = '620px'; -const StyledClickOutside = styled(ClickOutside)` - div.tippy-box { - max-width: ${WIDTH} !important; - padding: 8px; - } -`; - const DatePhasePicker = ({ selectedRange, disabledRanges = [], @@ -35,7 +27,10 @@ const DatePhasePicker = ({ ); return ( - setCalendarOpen(false)}> + setCalendarOpen(false)} + > @@ -59,7 +54,7 @@ const DatePhasePicker = ({ onClick={() => setCalendarOpen((open) => !open)} /> - + ); }; diff --git a/front/app/components/admin/DatePickers/DatePhasePicker/isSelectedRangeOpenEnded.ts b/front/app/components/admin/DatePickers/DatePhasePicker/isSelectedRangeOpenEnded.ts index ed741d752209..31107e62eb26 100644 --- a/front/app/components/admin/DatePickers/DatePhasePicker/isSelectedRangeOpenEnded.ts +++ b/front/app/components/admin/DatePickers/DatePhasePicker/isSelectedRangeOpenEnded.ts @@ -1,4 +1,4 @@ -import { DateRange } from './typings'; +import { DateRange } from '../_shared/typings'; export const isSelectedRangeOpenEnded = ( { from, to }: Partial, diff --git a/front/app/components/admin/DatePickers/DatePhasePicker/patchDisabledRanges.ts b/front/app/components/admin/DatePickers/DatePhasePicker/patchDisabledRanges.ts index e8d90be92ba1..f417246c7daa 100644 --- a/front/app/components/admin/DatePickers/DatePhasePicker/patchDisabledRanges.ts +++ b/front/app/components/admin/DatePickers/DatePhasePicker/patchDisabledRanges.ts @@ -1,6 +1,6 @@ import { addDays } from 'date-fns'; -import { DateRange } from './typings'; +import { DateRange } from '../_shared/typings'; /** * Utility to handle the case where the last disabled range is open, diff --git a/front/app/components/admin/DatePickers/DatePhasePicker/typings.ts b/front/app/components/admin/DatePickers/DatePhasePicker/typings.ts index 0c6e29a30f40..829c671a87e3 100644 --- a/front/app/components/admin/DatePickers/DatePhasePicker/typings.ts +++ b/front/app/components/admin/DatePickers/DatePhasePicker/typings.ts @@ -1,7 +1,4 @@ -export type DateRange = { - from: Date; - to?: Date; -}; +import { DateRange } from '../_shared/typings'; export type ClosedDateRange = { from: Date; diff --git a/front/app/components/admin/DatePickers/DateRangePicker/Calendar/index.tsx b/front/app/components/admin/DatePickers/DateRangePicker/Calendar/index.tsx new file mode 100644 index 000000000000..a56f279d059e --- /dev/null +++ b/front/app/components/admin/DatePickers/DateRangePicker/Calendar/index.tsx @@ -0,0 +1,127 @@ +import React from 'react'; + +import { Box, colors, Button } from '@citizenlab/cl2-component-library'; +import { DayPicker, PropsBase } from 'react-day-picker'; +import 'react-day-picker/style.css'; +import styled from 'styled-components'; + +import useLocale from 'hooks/useLocale'; + +import { useIntl } from 'utils/cl-intl'; + +import { getEndMonth } from '../../_shared/getStartEndMonth'; +import { getLocale } from '../../_shared/locales'; +import { CalendarProps } from '../typings'; + +import messages from './messages'; +import { getNextSelectionMode } from './utils/getNextSelectionMode'; +import { getUpdatedRange } from './utils/getUpdatedRange'; + +const DayPickerStyles = styled.div` + .rdp-root { + --rdp-accent-color: ${colors.teal700}; + --rdp-accent-background-color: ${colors.teal100}; + } + + .rdp-selected > button.rdp-day_button { + font-size: 14px; + font-weight: normal; + } +`; + +const Calendar = ({ + selectedRange, + startMonth: _startMonth, + endMonth: _endMonth, + defaultMonth, + disabled, + selectionMode, + numberOfMonths = 2, + onUpdateRange, + onUpdateSelectionMode, +}: CalendarProps) => { + const locale = useLocale(); + const { formatMessage } = useIntl(); + + const startMonth = _startMonth ?? new Date(1900, 0); + const endMonth = getEndMonth({ + endMonth: _endMonth, + selectedDate: selectedRange.to, + }); + + const handleDayClick: PropsBase['onDayClick'] = (day: Date) => { + if (!selectionMode) return; // Should not be possible in practice + + const nextRange = getUpdatedRange({ + selectedRange, + selectionMode, + clickedDate: day, + }); + + onUpdateRange(nextRange); + + const nextSelectionMode = getNextSelectionMode({ + selectionMode, + selectedRange: nextRange, + }); + + onUpdateSelectionMode(nextSelectionMode); + }; + + return ( + + + {selectedRange.from && ( + + )} + {selectedRange.to && ( + + )} + + } + /> + + ); +}; + +const NOOP = () => {}; + +export default Calendar; diff --git a/front/app/components/admin/DatePickers/DateRangePicker/Calendar/messages.ts b/front/app/components/admin/DatePickers/DateRangePicker/Calendar/messages.ts new file mode 100644 index 000000000000..9a621ae0fe16 --- /dev/null +++ b/front/app/components/admin/DatePickers/DateRangePicker/Calendar/messages.ts @@ -0,0 +1,12 @@ +import { defineMessages } from 'react-intl'; + +export default defineMessages({ + clearStartDate: { + id: 'app.components.admin.DatePickers.DateRangePicker.Calendar.clearStartDate', + defaultMessage: 'Clear start date', + }, + clearEndDate: { + id: 'app.components.admin.DatePickers.DateRangePicker.Calendar.clearEndDate', + defaultMessage: 'Clear end date', + }, +}); diff --git a/front/app/components/admin/DatePickers/DateRangePicker/Calendar/utils/getNextSelectionMode.ts b/front/app/components/admin/DatePickers/DateRangePicker/Calendar/utils/getNextSelectionMode.ts new file mode 100644 index 000000000000..4a7b7cfaa015 --- /dev/null +++ b/front/app/components/admin/DatePickers/DateRangePicker/Calendar/utils/getNextSelectionMode.ts @@ -0,0 +1,23 @@ +import { DateRange } from 'components/admin/DatePickers/_shared/typings'; + +import { SelectionMode } from '../../typings'; + +interface GetNextSelectionModeParams { + selectedRange: Partial; + selectionMode: SelectionMode; +} + +export const getNextSelectionMode = ({ + selectedRange, + selectionMode, +}: GetNextSelectionModeParams) => { + if (selectedRange.from && !selectedRange.to) { + return 'to'; + } + + if (!selectedRange.from && selectedRange.to) { + return 'from'; + } + + return selectionMode; +}; diff --git a/front/app/components/admin/DatePickers/DateRangePicker/Calendar/utils/getUpdatedRange.test.ts b/front/app/components/admin/DatePickers/DateRangePicker/Calendar/utils/getUpdatedRange.test.ts new file mode 100644 index 000000000000..81c4b3730f89 --- /dev/null +++ b/front/app/components/admin/DatePickers/DateRangePicker/Calendar/utils/getUpdatedRange.test.ts @@ -0,0 +1,117 @@ +import { getUpdatedRange } from './getUpdatedRange'; + +describe('getUpdatedRange', () => { + describe('selectionMode = from', () => { + const selectionMode = 'from'; + + it('if no dates set, should set from date', () => { + const selectedRange = { from: undefined, to: undefined }; + const clickedDate = new Date(2021, 1, 1); + const result = getUpdatedRange({ + selectedRange, + selectionMode, + clickedDate, + }); + expect(result).toEqual({ from: clickedDate, to: undefined }); + }); + + it('if only from date set, should overwrite from date', () => { + const selectedRange = { from: new Date(2022, 1, 1), to: undefined }; + const clickedDate = new Date(2021, 1, 1); + const result = getUpdatedRange({ + selectedRange, + selectionMode, + clickedDate, + }); + expect(result).toEqual({ from: clickedDate, to: undefined }); + }); + + describe('if only to date set', () => { + const to = new Date(2022, 1, 1); + + it('if clicked date is before to date, should set from date', () => { + const selectedRange = { from: undefined, to }; + const clickedDate = new Date(2021, 1, 1); + const result = getUpdatedRange({ + selectedRange, + selectionMode, + clickedDate, + }); + expect(result).toEqual({ from: clickedDate, to }); + }); + + it('if clicked date is after to date, should set from date and remove to date', () => { + const selectedRange = { from: undefined, to }; + const clickedDate = new Date(2023, 1, 1); + const result = getUpdatedRange({ + selectedRange, + selectionMode, + clickedDate, + }); + expect(result).toEqual({ from: clickedDate, to: undefined }); + }); + + it('if clicked date is equal to to date, should set from date and remove to date', () => { + const selectedRange = { from: undefined, to }; + const clickedDate = new Date(2022, 1, 1); + const result = getUpdatedRange({ + selectedRange, + selectionMode, + clickedDate, + }); + expect(result).toEqual({ from: clickedDate, to: undefined }); + }); + }); + + describe('if both dates set', () => { + const from = new Date(2021, 1, 1); + const to = new Date(2022, 1, 1); + const selectedRange = { from, to }; + + it('if clicked date is before to date, should set from date', () => { + const clickedDate = new Date(2020, 1, 1); + const result = getUpdatedRange({ + selectedRange, + selectionMode, + clickedDate, + }); + expect(result).toEqual({ from: clickedDate, to }); + }); + + it('if clicked date is after to date, should set from date and remove to date', () => { + const clickedDate = new Date(2023, 1, 1); + const result = getUpdatedRange({ + selectedRange, + selectionMode, + clickedDate, + }); + expect(result).toEqual({ from: clickedDate, to: undefined }); + }); + + it('if clicked date is equal to to date, should set from date and remove to date', () => { + const clickedDate = new Date(2022, 1, 1); + const result = getUpdatedRange({ + selectedRange, + selectionMode, + clickedDate, + }); + expect(result).toEqual({ from: clickedDate, to: undefined }); + }); + }); + }); + + describe('selectionMode = to', () => { + const selectionMode = 'to'; + + it('if no dates set, should set to date', () => { + const selectedRange = { from: undefined, to: undefined }; + const clickedDate = new Date(2021, 1, 1); + const result = getUpdatedRange({ + selectedRange, + selectionMode, + clickedDate, + }); + expect(result).toEqual({ from: undefined, to: clickedDate }); + }); + }); +}); diff --git a/front/app/components/admin/DatePickers/DateRangePicker/Calendar/utils/getUpdatedRange.ts b/front/app/components/admin/DatePickers/DateRangePicker/Calendar/utils/getUpdatedRange.ts new file mode 100644 index 000000000000..766252e87f71 --- /dev/null +++ b/front/app/components/admin/DatePickers/DateRangePicker/Calendar/utils/getUpdatedRange.ts @@ -0,0 +1,49 @@ +import { DateRange } from 'components/admin/DatePickers/_shared/typings'; + +import { SelectionMode } from '../../typings'; + +interface GetUpdatedRangeParams { + selectedRange: Partial; + selectionMode: SelectionMode; + clickedDate: Date; +} + +export const getUpdatedRange = ({ + selectedRange: { from, to }, + selectionMode, + clickedDate, +}: GetUpdatedRangeParams): Partial => { + if (selectionMode === 'from') { + // If you are in the 'from' selection mode, + // but you click a date equal to or after the 'to' date, + // we will set the 'from' date but + // remove the 'to' date. + if (to && clickedDate >= to) { + return { + from: clickedDate, + to: undefined, + }; + } + + return { + from: clickedDate, + to, + }; + } + + // If you are the 'to' selection mode, + // but you click a date equal to or before the 'from' + // date, we will set the 'to' date but + // remove the 'from' date. + if (from && clickedDate <= from) { + return { + from: undefined, + to: clickedDate, + }; + } + + return { + from, + to: clickedDate, + }; +}; diff --git a/front/app/components/admin/DatePickers/DateRangePicker/DateRangePicker.stories.tsx b/front/app/components/admin/DatePickers/DateRangePicker/DateRangePicker.stories.tsx new file mode 100644 index 000000000000..c8fd959df0f9 --- /dev/null +++ b/front/app/components/admin/DatePickers/DateRangePicker/DateRangePicker.stories.tsx @@ -0,0 +1,53 @@ +import React, { useState } from 'react'; + +import { getMonth, addDays } from 'date-fns'; + +import { DateRange } from '../_shared/typings'; + +import DateRangePicker from '.'; + +import type { Meta } from '@storybook/react'; + +const meta = { + title: 'DateRangePicker', + component: DateRangePicker, +} satisfies Meta; + +export default meta; + +const WrapperStandard = () => { + const [selectedRange, setSelectedRange] = useState>({}); + + return ( + + ); +}; + +export const Standard = { + render: () => { + return ; + }, +}; + +const WrapperDisabled = () => { + const [selectedRange, setSelectedRange] = useState>({}); + const month = new Date(getMonth(new Date())); + + return ( + + ); +}; + +export const Disabled = { + render: () => { + return ; + }, +}; diff --git a/front/app/components/admin/DatePickers/DateRangePicker/Input/DateButton.tsx b/front/app/components/admin/DatePickers/DateRangePicker/Input/DateButton.tsx new file mode 100644 index 000000000000..2513c5427be1 --- /dev/null +++ b/front/app/components/admin/DatePickers/DateRangePicker/Input/DateButton.tsx @@ -0,0 +1,51 @@ +import React from 'react'; + +import { Box, colors, stylingConsts } from '@citizenlab/cl2-component-library'; +import styled from 'styled-components'; + +const StyledButton = styled(Box)<{ isSelected: boolean }>` + ${({ isSelected }) => + isSelected + ? ` + background-color: ${colors.teal100}; + ` + : ` + &:hover { + background-color: ${colors.teal100}; + } + `} +`; + +interface Props { + children: string; + className?: string; + isSelected: boolean; + mr?: string; + onClick: () => void; +} + +const DateButton = ({ + children, + className, + isSelected, + mr, + onClick, +}: Props) => { + return ( + + {children} + + ); +}; + +export default DateButton; diff --git a/front/app/components/admin/DatePickers/DateRangePicker/Input/InputContainer.tsx b/front/app/components/admin/DatePickers/DateRangePicker/Input/InputContainer.tsx new file mode 100644 index 000000000000..759bc3560875 --- /dev/null +++ b/front/app/components/admin/DatePickers/DateRangePicker/Input/InputContainer.tsx @@ -0,0 +1,70 @@ +import React from 'react'; + +import { + defaultInputStyle, + colors, + fontSizes, + Icon, +} from '@citizenlab/cl2-component-library'; +import styled from 'styled-components'; + +const Container = styled.div<{ disabled: boolean }>` + ${defaultInputStyle}; + display: flex; + flex-direction: row; + align-items: center; + font-size: ${fontSizes.base}px; + padding-left: 4px; + padding-right: 10px; + padding-top: 8px; + padding-bottom: 8px; + cursor: default; + + 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; +} + +const InputContainer = ({ id, disabled = false, children }: Props) => { + return ( + + {children} + + + ); +}; + +export default InputContainer; diff --git a/front/app/components/admin/DatePickers/DateRangePicker/Input/index.tsx b/front/app/components/admin/DatePickers/DateRangePicker/Input/index.tsx new file mode 100644 index 000000000000..6b9388b9fb53 --- /dev/null +++ b/front/app/components/admin/DatePickers/DateRangePicker/Input/index.tsx @@ -0,0 +1,53 @@ +import React from 'react'; + +import { Icon } from '@citizenlab/cl2-component-library'; + +import { useIntl } from 'utils/cl-intl'; + +import sharedMessages from '../../_shared/messages'; +import { DateRange } from '../../_shared/typings'; +import { SelectionMode } from '../typings'; + +import DateButton from './DateButton'; +import InputContainer from './InputContainer'; + +interface Props { + selectedRange: Partial; + selectionMode?: SelectionMode; + onClickFrom: () => void; + onClickTo: () => void; +} + +const Input = ({ + selectedRange, + selectionMode, + onClickFrom, + onClickTo, +}: Props) => { + const { formatMessage } = useIntl(); + const selectDate = formatMessage(sharedMessages.selectDate); + + return ( + + + {selectedRange.from + ? selectedRange.from.toLocaleDateString() + : selectDate} + + + + {selectedRange.to ? selectedRange.to.toLocaleDateString() : selectDate} + + + ); +}; + +export default Input; diff --git a/front/app/components/admin/DatePickers/DateRangePicker/index.tsx b/front/app/components/admin/DatePickers/DateRangePicker/index.tsx index 40886e0acae2..005b11628247 100644 --- a/front/app/components/admin/DatePickers/DateRangePicker/index.tsx +++ b/front/app/components/admin/DatePickers/DateRangePicker/index.tsx @@ -1,153 +1,69 @@ -import React from 'react'; -import 'react-datepicker/dist/react-datepicker.css'; +import React, { useState } from 'react'; -import { - Box, - Icon, - colors, - fontSizes, -} from '@citizenlab/cl2-component-library'; -import moment, { Moment } from 'moment'; -import DatePicker from 'react-datepicker'; -import styled from 'styled-components'; +import { Tooltip, Box } from '@citizenlab/cl2-component-library'; -import useLocale from 'hooks/useLocale'; +import ClickOutsideContainer from '../_shared/ClickOutsideContainer'; -import { isNilOrError } from 'utils/helperUtils'; - -const StylingWrapper = styled.div` - display: flex; - align-items: center; - - .react-datepicker-wrapper { - border-radius: ${(props) => props.theme.borderRadius}; - border: solid 1px ${colors.borderDark}; - background: white; - padding: 10px 8px; - - &:hover { - border-color: ${colors.black}; - } - - input[type='text'] { - color: ${colors.textPrimary}; - font-size: ${fontSizes.base}px; - line-height: normal; - font-weight: 400; - background: transparent; - width: 100%; - } - } -`; - -export type Dates = { - startDate: Moment | null; - endDate: Moment | null; -}; - -interface Props { - startDate: Moment | null; - endDate: Moment | null; - onDatesChange: ({ startDate, endDate }: Dates) => void; - minDate?: Moment; - maxDate?: Moment; - startDatePlaceholderText?: string; - endDatePlaceholderText?: string; - excludeDates?: Moment[]; -} +import Calendar from './Calendar'; +import Input from './Input'; +import { Props, SelectionMode } from './typings'; const DateRangePicker = ({ - startDate, - endDate, - onDatesChange, - minDate, - maxDate, - startDatePlaceholderText, - endDatePlaceholderText, - excludeDates, + selectedRange, + startMonth, + endMonth, + defaultMonth, + disabled, + numberOfMonths, + onUpdateRange, }: Props) => { - const locale = useLocale(); - - if (isNilOrError(locale)) return null; - const localeUsed = locale === 'en' ? 'en-GB' : locale; - - const handleOnChangeStartDate = (newStartDate: Date | null) => { - // with this check, we don't allow removing a date - // (forcing users to pick a date if changes need to persist) - if (newStartDate) { - onDatesChange({ - startDate: moment(newStartDate), - endDate: - endDate && endDate < moment(newStartDate) - ? // if the new start date is after the currently selected end date, - // we set the end date to the new start date - moment(newStartDate) - : endDate, - }); - } - }; - - const handleOnChangeEndDate = (newEndDate: Date | null) => { - // with this check, we don't allow removing a date - // (forcing users to pick a date if changes need to persist) - if (newEndDate) { - onDatesChange({ - // we don’t need a check here as we do in handleOnChangeStartDate - // because of the minDate prop in the end date DatePicker - startDate, - endDate: moment(newEndDate), - }); - } - }; + const [selectionMode, setSelectionMode] = useState(); - // Passing null to moment() crashes this component. Calling toDate on this returns "Invalid date", - // which crashes DatePicker. - const convertedStartDate = startDate ? moment(startDate).toDate() : null; - const convertedEndDate = endDate ? moment(endDate).toDate() : null; - const convertedMinDate = minDate ? moment(minDate).toDate() : null; - const convertedMaxDate = maxDate ? moment(maxDate).toDate() : null; - const convertedExcludeDates = - excludeDates?.map((date) => moment(date).toDate()) || []; + const width = numberOfMonths === 1 ? '310px' : '620px'; return ( - - - - - - - + { + setSelectionMode(undefined); + }} + > + + + + } + placement="bottom" + visible={!!selectionMode} + width="1200px" + > + { + selectionMode === 'from' + ? setSelectionMode(undefined) + : setSelectionMode('from'); + }} + onClickTo={() => { + selectionMode === 'to' + ? setSelectionMode(undefined) + : setSelectionMode('to'); + }} + /> + + ); }; diff --git a/front/app/components/admin/DatePickers/DateRangePicker/typings.ts b/front/app/components/admin/DatePickers/DateRangePicker/typings.ts new file mode 100644 index 000000000000..99cfae3b7557 --- /dev/null +++ b/front/app/components/admin/DatePickers/DateRangePicker/typings.ts @@ -0,0 +1,20 @@ +import { PropsBase } from 'react-day-picker'; + +import { DateRange } from '../_shared/typings'; + +export interface Props { + selectedRange: Partial; + startMonth?: Date; + endMonth?: Date; + defaultMonth?: Date; + disabled?: PropsBase['disabled']; + numberOfMonths?: 1 | 2; + onUpdateRange: (range: Partial) => void; +} + +export interface CalendarProps extends Props { + selectionMode?: SelectionMode; + onUpdateSelectionMode: (selectionMode: SelectionMode) => void; +} + +export type SelectionMode = 'from' | 'to'; diff --git a/front/app/components/admin/DatePickers/DateSinglePicker/Calendar/index.tsx b/front/app/components/admin/DatePickers/DateSinglePicker/Calendar/index.tsx index ab5ea119b8b8..0b0269bb9ab5 100644 --- a/front/app/components/admin/DatePickers/DateSinglePicker/Calendar/index.tsx +++ b/front/app/components/admin/DatePickers/DateSinglePicker/Calendar/index.tsx @@ -7,11 +7,12 @@ import styled from 'styled-components'; import useLocale from 'hooks/useLocale'; +import { userTimezone } from 'utils/dateUtils'; + +import { getEndMonth } from '../../_shared/getStartEndMonth'; import { getLocale } from '../../_shared/locales'; import { CalendarProps } from '../typings'; -import { getEndMonth } from './utils/getStartEndMonth'; - const DayPickerStyles = styled.div` .rdp-root { --rdp-accent-color: ${colors.teal700}; @@ -34,9 +35,8 @@ const Calendar = ({ onChange, }: CalendarProps) => { const locale = useLocale(); - const startMonth = new Date(1900, 0); + const startMonth = _startMonth ?? new Date(1900, 0); const endMonth = getEndMonth({ endMonth: _endMonth, selectedDate }); - const userTimezone = Intl.DateTimeFormat().resolvedOptions().timeZone; return ( diff --git a/front/app/components/admin/DatePickers/DateSinglePicker/index.tsx b/front/app/components/admin/DatePickers/DateSinglePicker/index.tsx index bf17932beb0b..cd9cc08582fb 100644 --- a/front/app/components/admin/DatePickers/DateSinglePicker/index.tsx +++ b/front/app/components/admin/DatePickers/DateSinglePicker/index.tsx @@ -1,9 +1,8 @@ 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 ClickOutsideContainer from '../_shared/ClickOutsideContainer'; import Calendar from './Calendar'; import Input from './Input'; @@ -11,13 +10,6 @@ import { Props } from './typings'; const WIDTH = '310px'; -const StyledClickOutside = styled(ClickOutside)` - div.tippy-box { - max-width: ${WIDTH} !important; - padding: 8px; - } -`; - const DateSinglePicker = ({ id, disabled, @@ -30,7 +22,10 @@ const DateSinglePicker = ({ const [calendarOpen, setCalendarOpen] = useState(false); return ( - setCalendarOpen(false)}> + setCalendarOpen(false)} + > @@ -59,7 +54,7 @@ const DateSinglePicker = ({ onClick={() => setCalendarOpen(true)} /> - + ); }; diff --git a/front/app/components/admin/DatePickers/_shared/ClickOutsideContainer.tsx b/front/app/components/admin/DatePickers/_shared/ClickOutsideContainer.tsx new file mode 100644 index 000000000000..b9b19d19ff51 --- /dev/null +++ b/front/app/components/admin/DatePickers/_shared/ClickOutsideContainer.tsx @@ -0,0 +1,28 @@ +import React from 'react'; + +import styled from 'styled-components'; + +import ClickOutside from 'utils/containers/clickOutside'; + +const StyledClickOutside = styled(ClickOutside)<{ width: string }>` + div.tippy-box { + max-width: ${({ width }) => width} !important; + padding: 8px; + } +`; + +interface Props { + width: string; + onClickOutside: () => void; + children: React.ReactNode; +} + +const ClickOutsideContainer = ({ width, onClickOutside, children }: Props) => { + return ( + + {children} + + ); +}; + +export default ClickOutsideContainer; diff --git a/front/app/components/admin/DatePickers/_shared/InputContainer.tsx b/front/app/components/admin/DatePickers/_shared/InputContainer.tsx index b4e8edace322..d294cd0c8044 100644 --- a/front/app/components/admin/DatePickers/_shared/InputContainer.tsx +++ b/front/app/components/admin/DatePickers/_shared/InputContainer.tsx @@ -13,6 +13,7 @@ const Container = styled.button<{ disabled: boolean }>` cursor: pointer; display: flex; flex-direction: row; + align-items: center; font-size: ${fontSizes.base}px; color: ${colors.grey800}; diff --git a/front/app/components/admin/DatePickers/DateSinglePicker/Calendar/utils/getStartEndMonth.ts b/front/app/components/admin/DatePickers/_shared/getStartEndMonth.ts similarity index 100% rename from front/app/components/admin/DatePickers/DateSinglePicker/Calendar/utils/getStartEndMonth.ts rename to front/app/components/admin/DatePickers/_shared/getStartEndMonth.ts diff --git a/front/app/components/admin/DatePickers/_shared/typings.ts b/front/app/components/admin/DatePickers/_shared/typings.ts new file mode 100644 index 000000000000..ee5d47b49332 --- /dev/null +++ b/front/app/components/admin/DatePickers/_shared/typings.ts @@ -0,0 +1,4 @@ +export type DateRange = { + from: Date; + to?: Date; +}; diff --git a/front/app/components/admin/GraphCards/_utils/query.ts b/front/app/components/admin/GraphCards/_utils/query.ts index fa2940d46382..09ac3e2e078c 100644 --- a/front/app/components/admin/GraphCards/_utils/query.ts +++ b/front/app/components/admin/GraphCards/_utils/query.ts @@ -65,10 +65,7 @@ const getLastPeriod = (resolution: IResolution) => { return moment().subtract({ days: 1 }).format('YYYY-MM-DD'); }; -export const getComparedTimeRange = ( - startAt: string | Moment, - endAt: string | Moment -) => { +export const getComparedTimeRange = (startAt?: string, endAt?: string) => { if (!startAt || !endAt) return {}; const startAtMoment = moment(startAt, 'YYYY-MM-DD'); diff --git a/front/app/containers/Admin/dashboard/components/TimeControl.tsx b/front/app/containers/Admin/dashboard/components/TimeControl.tsx index 267a5396aa71..fc4722823ea1 100644 --- a/front/app/containers/Admin/dashboard/components/TimeControl.tsx +++ b/front/app/containers/Admin/dashboard/components/TimeControl.tsx @@ -1,6 +1,7 @@ import React, { useState } from 'react'; import { Icon, Dropdown, colors } from '@citizenlab/cl2-component-library'; +import { getMonth } from 'date-fns'; import moment, { Moment } from 'moment'; import styled from 'styled-components'; @@ -192,10 +193,25 @@ const TimeControl = ({ /> { + handleDatesChange({ + startDate: from ? moment(from) : null, + endDate: to ? moment(to) : null, + }); + }} /> ); diff --git a/front/app/containers/Admin/projects/project/participation/ParticipationDateRange.tsx b/front/app/containers/Admin/projects/project/participation/ParticipationDateRange.tsx index 539d62392b0c..d1a120c84bfc 100644 --- a/front/app/containers/Admin/projects/project/participation/ParticipationDateRange.tsx +++ b/front/app/containers/Admin/projects/project/participation/ParticipationDateRange.tsx @@ -1,12 +1,12 @@ import React, { useState } from 'react'; import { Box, Text } from '@citizenlab/cl2-component-library'; -import moment, { Moment } from 'moment'; import { useParams } from 'react-router-dom'; import DateRangePicker from 'components/admin/DatePickers/DateRangePicker'; import { useIntl } from 'utils/cl-intl'; +import { parseBackendDateString, toBackendDateString } from 'utils/dateUtils'; import messages from './messages'; import ParticipationReportPreview from './ParticipationReportPreview'; @@ -25,28 +25,24 @@ const ParticipationDatesRange = ({ const [startAt, setStartAt] = useState(defaultStartDate); const [endAt, setEndAt] = useState(defaultEndDate); - const handleChangeTimeRange = ({ - startDate, - endDate, - }: { - startDate: Moment | null; - endDate: Moment | null; - }) => { - setStartAt(startDate?.format('YYYY-MM-DD')); - setEndAt(endDate?.format('YYYY-MM-DD')); - }; - return (
{formatMessage(messages.selectPeriod)} - + + { + setStartAt(toBackendDateString(from)); + setEndAt(toBackendDateString(to)); + }} + /> + , diff --git a/front/app/containers/Admin/projects/project/traffic/TrafficDatesRange.tsx b/front/app/containers/Admin/projects/project/traffic/TrafficDatesRange.tsx index bba79bf6f533..f55f3b1511e1 100644 --- a/front/app/containers/Admin/projects/project/traffic/TrafficDatesRange.tsx +++ b/front/app/containers/Admin/projects/project/traffic/TrafficDatesRange.tsx @@ -1,13 +1,13 @@ import React, { useState } from 'react'; import { Box, Text } from '@citizenlab/cl2-component-library'; -import moment, { Moment } from 'moment'; import { useParams } from 'react-router-dom'; import DateRangePicker from 'components/admin/DatePickers/DateRangePicker'; import Warning from 'components/UI/Warning'; import { useIntl } from 'utils/cl-intl'; +import { toBackendDateString, parseBackendDateString } from 'utils/dateUtils'; import messages from './messages'; import TrafficReportPreview from './TrafficReportPreview'; @@ -23,19 +23,8 @@ const TrafficDatesRange = ({ const { formatMessage } = useIntl(); - const [startAt, setStartAt] = useState(defaultStartDate); - const [endAt, setEndAt] = useState(defaultEndDate); - - const handleChangeTimeRange = ({ - startDate, - endDate, - }: { - startDate: Moment | null; - endDate: Moment | null; - }) => { - setStartAt(startDate?.format('YYYY-MM-DD')); - setEndAt(endDate?.format('YYYY-MM-DD')); - }; + const [startAt, setStartAt] = useState(defaultStartDate); + const [endAt, setEndAt] = useState(defaultEndDate); return (
@@ -43,11 +32,18 @@ const TrafficDatesRange = ({ {formatMessage(messages.selectPeriod)} - + + { + setStartAt(toBackendDateString(from)); + setEndAt(toBackendDateString(to)); + }} + /> + 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 553caa325f6e..d4154e6bedf4 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 @@ -2,7 +2,6 @@ import React from 'react'; import { Box, Text } from '@citizenlab/cl2-component-library'; import { useNode } from '@craftjs/core'; -import moment, { Moment } from 'moment'; import { IOption, Multiloc } from 'typings'; import DateRangePicker from 'components/admin/DatePickers/DateRangePicker'; @@ -10,6 +9,7 @@ import { getComparedTimeRange } from 'components/admin/GraphCards/_utils/query'; import InputMultilocWithLocaleSwitcher from 'components/UI/InputMultilocWithLocaleSwitcher'; import { useIntl } from 'utils/cl-intl'; +import { parseBackendDateString, toBackendDateString } from 'utils/dateUtils'; import ProjectFilter from '../../_shared/ProjectFilter'; import messages from '../messages'; @@ -70,15 +70,13 @@ export const DateRangeInput = ({ const { formatMessage } = useIntl(); const { actions: { setProp }, - startAtMoment, - endAtMoment, + startAt, + endAt, compareStartAt, compareEndAt, } = useNode((node) => ({ - startAtMoment: node.data.props.startAt - ? moment(node.data.props.startAt) - : null, - endAtMoment: node.data.props.endAt ? moment(node.data.props.endAt) : null, + startAt: node.data.props.startAt, + endAt: node.data.props.endAt, compareStartAt: node.data.props.compareStartAt, compareEndAt: node.data.props.compareEndAt, })); @@ -89,12 +87,12 @@ export const DateRangeInput = ({ startDate, endDate, }: { - startDate: Moment | null; - endDate: Moment | null; + startDate: string | undefined; + endDate: string | undefined; }) => { setProp((props: ChartWidgetProps) => { - props.startAt = startDate?.format('YYYY-MM-DD'); - props.endAt = endDate?.format('YYYY-MM-DD'); + props.startAt = startDate; + props.endAt = endDate; }); if (resetComparePeriod) { @@ -126,11 +124,21 @@ export const DateRangeInput = ({ {label ?? formatMessage(messages.analyticsChartDateRange)} - + + { + handleChangeTimeRange({ + startDate: toBackendDateString(from), + endDate: toBackendDateString(to), + }); + }} + /> + ); }; 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 270dcdc144ec..1723f9b1de58 100644 --- a/front/app/containers/Admin/reporting/components/ReportBuilderPage/CreateReportModal/index.tsx +++ b/front/app/containers/Admin/reporting/components/ReportBuilderPage/CreateReportModal/index.tsx @@ -14,9 +14,8 @@ import useAddReport from 'api/reports/useAddReport'; import reportTitleIsTaken from 'containers/Admin/reporting/utils/reportTitleIsTaken'; -import DateRangePicker, { - Dates, -} from 'components/admin/DatePickers/DateRangePicker'; +import { DateRange } from 'components/admin/DatePickers/_shared/typings'; +import DateRangePicker from 'components/admin/DatePickers/DateRangePicker'; import Button from 'components/UI/Button'; import Error from 'components/UI/Error'; import Modal from 'components/UI/Modal'; @@ -44,7 +43,7 @@ const CreateReportModal = ({ open, onClose }: Props) => { const [selectedProjectId, setSelectedProjectId] = useState< string | undefined >(); - const [dates, setDates] = useState({ startDate: null, endDate: null }); + const [dates, setDates] = useState>({}); const [errorMessage, setErrorMessage] = useState(); const { formatMessage } = useIntl(); @@ -53,7 +52,7 @@ const CreateReportModal = ({ open, onClose }: Props) => { const blockSubmit = reportTitleTooShort || (template === 'project' ? selectedProjectId === undefined : false) || - (template === 'platform' ? !dates.startDate || !dates.endDate : false); + (template === 'platform' ? !dates.from || !dates.to : false); const handleProjectFilter = (option: IOption) => { setSelectedProjectId(option.value === '' ? undefined : option.value); @@ -124,12 +123,8 @@ const CreateReportModal = ({ open, onClose }: Props) => { )} {template === 'platform' && ( - - + + )} {errorMessage && ( 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 54ef783bf7ef..0d703f8b7c8b 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,8 @@ import { RouteType } from 'routes'; -import { Dates } from 'components/admin/DatePickers/DateRangePicker'; +import { DateRange } from 'components/admin/DatePickers/_shared/typings'; + +import { toBackendDateString } from 'utils/dateUtils'; import { Template } from './typings'; @@ -8,14 +10,14 @@ interface Params { reportId: string; selectedProjectId: string | undefined; template: Template; - dates: Dates; + dates: Partial; } export const getRedirectUrl = ({ reportId, selectedProjectId, template, - dates: { startDate, endDate }, + dates: { from, to }, }: Params) => { const reportBuilderRoute = '/admin/reporting/report-builder'; const reportRoute = `${reportBuilderRoute}/${reportId}/editor`; @@ -26,13 +28,12 @@ export const getRedirectUrl = ({ params = `?templateProjectId=${selectedProjectId}`; } - if (template === 'platform' && startDate && endDate) { - const startDateParam = `startDatePlatformReport=${startDate.format( - 'YYYY-MM-DD' - )}`; - const endDateParam = `endDatePlatformReport=${endDate.format( - 'YYYY-MM-DD' - )}`; + if (template === 'platform' && from && to) { + const startDateFormat = toBackendDateString(from); + const endDateFormat = toBackendDateString(to); + + const startDateParam = `startDatePlatformReport=${startDateFormat}`; + const endDateParam = `endDatePlatformReport=${endDateFormat}`; params = `?${startDateParam}&${endDateParam}`; } diff --git a/front/app/translations/admin/en.json b/front/app/translations/admin/en.json index 82d98fb6f683..fdb87b89649d 100644 --- a/front/app/translations/admin/en.json +++ b/front/app/translations/admin/en.json @@ -120,6 +120,8 @@ "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.DatePickers.DateRangePicker.Calendar.clearEndDate": "Clear end date", + "app.components.admin.DatePickers.DateRangePicker.Calendar.clearStartDate": "Clear start 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.", diff --git a/front/app/utils/dateUtils.ts b/front/app/utils/dateUtils.ts index 7264501189c7..0d966d900415 100644 --- a/front/app/utils/dateUtils.ts +++ b/front/app/utils/dateUtils.ts @@ -249,3 +249,59 @@ export function calculateRoundedEndDate( endDate.setMinutes(startDate.getMinutes() + durationInMinutes); return endDate; } + +export const userTimezone = Intl.DateTimeFormat().resolvedOptions().timeZone; + +// Why do we need this function? +// The backend sends dates in the format "YYYY-MM-DD" without a time component. +// When we parse this date in the frontend, it is interpreted as +// midnight in UTC. +// This means that if we are west of UTC, e.g. in Brazil, +// The date will be interpreted as 21:00 the previous day. +// This function makes sure that the date is always interpreted as midnight in the user's timezone. +const backendDatestringRegex = /^\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\d|3[01])$/; + +export const parseBackendDateString = (_dateString?: string) => { + if (!_dateString) return undefined; + + let dateString = _dateString; + + // Sometimes, e.g. in the craftjson layouts, + // we still have old reports using datestrings like + // 2023-01-13T14:54:51.5151 + // This was an implementation bug- we should have used + // the yyyy-MM-DD from the start. + // But for now, we need to handle this case. + // TODO: fix this properly in a migration. + if (dateString.length > 10) { + dateString = dateString.slice(0, 10); + } + + if (!dateString.match(backendDatestringRegex)) { + throw new Error('Invalid date string'); + } + + const day = dateString.split('-').map(Number)[2]; + const date = new Date(dateString); + + const parsedDay = date.getDate(); + + if (day === parsedDay) { + date.setHours(0, 0, 0, 0); + } else { + date.setHours(24, 0, 0, 0); + } + + return date; +}; + +export const toBackendDateString = (date?: Date) => { + if (!date) return undefined; + const monthNumber = date.getMonth() + 1; + const dayNumber = date.getDate(); + + const month = monthNumber < 10 ? `0${monthNumber}` : monthNumber; + const day = dayNumber < 10 ? `0${dayNumber}` : dayNumber; + + return `${date.getFullYear()}-${month}-${day}`; +}; diff --git a/front/package-lock.json b/front/package-lock.json index ee53cd7b6b45..6ec5db55cd3e 100644 --- a/front/package-lock.json +++ b/front/package-lock.json @@ -2427,9 +2427,9 @@ } }, "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==" + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@date-fns/tz/-/tz-1.2.0.tgz", + "integrity": "sha512-LBrd7MiJZ9McsOgxqWX7AaxrDjcFVjWH/tIKJd7pnR7McaslGYOP1QmmiBXdJH/H/yLCT+rcQ7FaPBUxRGUtrg==" }, "node_modules/@discoveryjs/json-ext": { "version": "0.5.7",