Date filter improvements (#5917) (#7196)

Solves issue #5917.

This PR is now ready for the first review!

Filters do not fully work yet, there's a problem applying multiple
filters like the following:

```
{
  and: [
    {
      [correspondingField.name]: {
        gte: start.toISOString(),
      } as DateFilter,
    },
    {
      [correspondingField.name]: {
        lte: end.toISOString(),
      } as DateFilter,
    },
  ],
}
```

I'll do my best to dig into it tonight!

---------

Co-authored-by: Félix Malfait <felix@twenty.com>
This commit is contained in:
ad-elias
2024-09-27 15:57:38 +02:00
committed by GitHub
parent c9c2f32922
commit 9d36493cf0
28 changed files with 983 additions and 131 deletions

View File

@ -0,0 +1,108 @@
import styled from '@emotion/styled';
import { DateTime } from 'luxon';
import { IconChevronLeft, IconChevronRight } from 'twenty-ui';
import { LightIconButton } from '@/ui/input/button/components/LightIconButton';
import { Select } from '@/ui/input/components/Select';
import { DateTimeInput } from '@/ui/input/components/internal/date/components/DateTimeInput';
import { getMonthSelectOptions } from '@/ui/input/components/internal/date/utils/getMonthSelectOptions';
import {
MONTH_AND_YEAR_DROPDOWN_MONTH_SELECT_ID,
MONTH_AND_YEAR_DROPDOWN_YEAR_SELECT_ID,
} from './InternalDatePicker';
const StyledCustomDatePickerHeader = styled.div`
align-items: center;
display: flex;
justify-content: flex-end;
padding-left: ${({ theme }) => theme.spacing(2)};
padding-right: ${({ theme }) => theme.spacing(2)};
padding-top: ${({ theme }) => theme.spacing(2)};
gap: ${({ theme }) => theme.spacing(1)};
`;
const years = Array.from(
{ length: 200 },
(_, i) => new Date().getFullYear() + 5 - i,
).map((year) => ({ label: year.toString(), value: year }));
type AbsoluteDatePickerHeaderProps = {
date: Date;
onChange?: (date: Date | null) => void;
onChangeMonth: (month: number) => void;
onChangeYear: (year: number) => void;
onAddMonth: () => void;
onSubtractMonth: () => void;
prevMonthButtonDisabled: boolean;
nextMonthButtonDisabled: boolean;
isDateTimeInput?: boolean;
timeZone: string;
};
export const AbsoluteDatePickerHeader = ({
date,
onChange,
onChangeMonth,
onChangeYear,
onAddMonth,
onSubtractMonth,
prevMonthButtonDisabled,
nextMonthButtonDisabled,
isDateTimeInput,
timeZone,
}: AbsoluteDatePickerHeaderProps) => {
const endOfDayDateTimeInLocalTimezone = DateTime.now().set({
day: date.getDate(),
month: date.getMonth() + 1,
year: date.getFullYear(),
hour: 23,
minute: 59,
second: 59,
millisecond: 999,
});
const endOfDayInLocalTimezone = endOfDayDateTimeInLocalTimezone.toJSDate();
return (
<>
<DateTimeInput
date={date}
isDateTimeInput={isDateTimeInput}
onChange={onChange}
userTimezone={timeZone}
/>
<StyledCustomDatePickerHeader>
<Select
dropdownId={MONTH_AND_YEAR_DROPDOWN_MONTH_SELECT_ID}
options={getMonthSelectOptions()}
disableBlur
onChange={onChangeMonth}
value={endOfDayInLocalTimezone.getMonth()}
fullWidth
/>
<Select
dropdownId={MONTH_AND_YEAR_DROPDOWN_YEAR_SELECT_ID}
onChange={onChangeYear}
value={endOfDayInLocalTimezone.getFullYear()}
options={years}
disableBlur
fullWidth
/>
<LightIconButton
Icon={IconChevronLeft}
onClick={onSubtractMonth}
size="medium"
disabled={prevMonthButtonDisabled}
/>
<LightIconButton
Icon={IconChevronRight}
onClick={onAddMonth}
size="medium"
disabled={nextMonthButtonDisabled}
/>
</StyledCustomDatePickerHeader>
</>
);
};

View File

@ -2,52 +2,32 @@ import styled from '@emotion/styled';
import { DateTime } from 'luxon';
import ReactDatePicker from 'react-datepicker';
import { Key } from 'ts-key-enum';
import {
IconCalendarX,
IconChevronLeft,
IconChevronRight,
OVERLAY_BACKGROUND,
} from 'twenty-ui';
import { IconCalendarX, OVERLAY_BACKGROUND } from 'twenty-ui';
import { LightIconButton } from '@/ui/input/button/components/LightIconButton';
import { DateTimeInput } from '@/ui/input/components/internal/date/components/DateTimeInput';
import { Select } from '@/ui/input/components/Select';
import { useDropdown } from '@/ui/layout/dropdown/hooks/useDropdown';
import { MenuItemLeftContent } from '@/ui/navigation/menu-item/internals/components/MenuItemLeftContent';
import { StyledHoverableMenuItemBase } from '@/ui/navigation/menu-item/internals/components/StyledMenuItemBase';
import { isDefined } from '~/utils/isDefined';
import { AbsoluteDatePickerHeader } from '@/ui/input/components/internal/date/components/AbsoluteDatePickerHeader';
import { RelativeDatePickerHeader } from '@/ui/input/components/internal/date/components/RelativeDatePickerHeader';
import { getHighlightedDates } from '@/ui/input/components/internal/date/utils/getHighlightedDates';
import { UserContext } from '@/users/contexts/UserContext';
import {
VariableDateViewFilterValueDirection,
VariableDateViewFilterValueUnit,
} from '@/views/utils/view-filter-value/resolveDateViewFilterValue';
import { useContext } from 'react';
import 'react-datepicker/dist/react-datepicker.css';
const months = [
{ label: 'January', value: 0 },
{ label: 'February', value: 1 },
{ label: 'March', value: 2 },
{ label: 'April', value: 3 },
{ label: 'May', value: 4 },
{ label: 'June', value: 5 },
{ label: 'July', value: 6 },
{ label: 'August', value: 7 },
{ label: 'September', value: 8 },
{ label: 'October', value: 9 },
{ label: 'November', value: 10 },
{ label: 'December', value: 11 },
];
const years = Array.from(
{ length: 200 },
(_, i) => new Date().getFullYear() + 5 - i,
).map((year) => ({ label: year.toString(), value: year }));
export const MONTH_AND_YEAR_DROPDOWN_ID = 'date-picker-month-and-year-dropdown';
export const MONTH_AND_YEAR_DROPDOWN_MONTH_SELECT_ID =
'date-picker-month-and-year-dropdown-month-select';
export const MONTH_AND_YEAR_DROPDOWN_YEAR_SELECT_ID =
'date-picker-month-and-year-dropdown-year-select';
const StyledContainer = styled.div`
const StyledContainer = styled.div<{ calendarDisabled?: boolean }>`
& .react-datepicker {
border-color: ${({ theme }) => theme.border.color.light};
background: transparent;
@ -207,6 +187,10 @@ const StyledContainer = styled.div`
& .react-datepicker__month {
margin-top: 0;
pointer-events: ${({ calendarDisabled }) =>
calendarDisabled ? 'none' : 'auto'};
opacity: ${({ calendarDisabled }) => (calendarDisabled ? '0.5' : '1')};
}
& .react-datepicker__day {
@ -288,21 +272,27 @@ const StyledButton = styled(MenuItemLeftContent)`
justify-content: start;
`;
const StyledCustomDatePickerHeader = styled.div`
align-items: center;
display: flex;
justify-content: flex-end;
padding-left: ${({ theme }) => theme.spacing(2)};
padding-right: ${({ theme }) => theme.spacing(2)};
padding-top: ${({ theme }) => theme.spacing(2)};
gap: ${({ theme }) => theme.spacing(1)};
`;
type InternalDatePickerProps = {
isRelative?: boolean;
date: Date | null;
relativeDate?: {
direction: VariableDateViewFilterValueDirection;
amount?: number;
unit: VariableDateViewFilterValueUnit;
};
highlightedDateRange?: {
start: Date;
end: Date;
};
onMouseSelect?: (date: Date | null) => void;
onChange?: (date: Date | null) => void;
onRelativeDateChange?: (
relativeDate: {
direction: VariableDateViewFilterValueDirection;
amount?: number;
unit: VariableDateViewFilterValueUnit;
} | null,
) => void;
clearable?: boolean;
isDateTimeInput?: boolean;
onEnter?: (date: Date | null) => void;
@ -321,6 +311,10 @@ export const InternalDatePicker = ({
isDateTimeInput,
keyboardEventsDisabled,
onClear,
isRelative,
relativeDate,
onRelativeDateChange,
highlightedDateRange,
}: InternalDatePickerProps) => {
const internalDate = date ?? new Date();
@ -469,15 +463,20 @@ export const InternalDatePicker = ({
const dateToUse = isDateTimeInput ? endOfDayInLocalTimezone : dateWithoutTime;
const highlightedDates = getHighlightedDates(highlightedDateRange);
const selectedDates = isRelative ? highlightedDates : [dateToUse];
return (
<StyledContainer onKeyDown={handleKeyDown}>
<StyledContainer onKeyDown={handleKeyDown} calendarDisabled={isRelative}>
<div className={clearable ? 'clearable ' : ''}>
<ReactDatePicker
open={true}
selected={dateToUse}
selectedDates={selectedDates}
openToDate={isDefined(dateToUse) ? dateToUse : undefined}
disabledKeyboardNavigation
onChange={handleDateChange}
onChange={handleDateChange as any}
customInput={
<DateTimeInput
date={internalDate}
@ -489,47 +488,31 @@ export const InternalDatePicker = ({
renderCustomHeader={({
prevMonthButtonDisabled,
nextMonthButtonDisabled,
}) => (
<>
<DateTimeInput
date={internalDate}
isDateTimeInput={isDateTimeInput}
onChange={onChange}
userTimezone={timeZone}
}) =>
isRelative ? (
<RelativeDatePickerHeader
direction={relativeDate?.direction ?? 'PAST'}
amount={relativeDate?.amount}
unit={relativeDate?.unit ?? 'DAY'}
onChange={onRelativeDateChange}
/>
<StyledCustomDatePickerHeader>
<Select
dropdownId={MONTH_AND_YEAR_DROPDOWN_MONTH_SELECT_ID}
options={months}
disableBlur
onChange={handleChangeMonth}
value={endOfDayInLocalTimezone.getMonth()}
fullWidth
/>
<Select
dropdownId={MONTH_AND_YEAR_DROPDOWN_YEAR_SELECT_ID}
onChange={handleChangeYear}
value={endOfDayInLocalTimezone.getFullYear()}
options={years}
disableBlur
fullWidth
/>
<LightIconButton
Icon={IconChevronLeft}
onClick={handleSubtractMonth}
size="medium"
disabled={prevMonthButtonDisabled}
/>
<LightIconButton
Icon={IconChevronRight}
onClick={handleAddMonth}
size="medium"
disabled={nextMonthButtonDisabled}
/>
</StyledCustomDatePickerHeader>
</>
)}
) : (
<AbsoluteDatePickerHeader
date={internalDate}
onChange={onChange}
onChangeMonth={handleChangeMonth}
onChangeYear={handleChangeYear}
onAddMonth={handleAddMonth}
onSubtractMonth={handleSubtractMonth}
prevMonthButtonDisabled={prevMonthButtonDisabled}
nextMonthButtonDisabled={nextMonthButtonDisabled}
isDateTimeInput={isDateTimeInput}
timeZone={timeZone}
/>
)
}
onSelect={handleDateSelect}
selectsMultiple={isRelative}
/>
</div>
{clearable && (

View File

@ -0,0 +1,113 @@
import { RELATIVE_DATE_DIRECTION_SELECT_OPTIONS } from '@/ui/input/components/internal/date/constants/RelativeDateDirectionSelectOptions';
import { RELATIVE_DATE_UNITS_SELECT_OPTIONS } from '@/ui/input/components/internal/date/constants/RelativeDateUnitSelectOptions';
import { Select } from '@/ui/input/components/Select';
import { TextInput } from '@/ui/input/components/TextInput';
import {
VariableDateViewFilterValueDirection,
variableDateViewFilterValuePartsSchema,
VariableDateViewFilterValueUnit,
} from '@/views/utils/view-filter-value/resolveDateViewFilterValue';
import styled from '@emotion/styled';
import { useEffect, useState } from 'react';
const StyledContainer = styled.div`
display: flex;
align-items: center;
gap: ${({ theme }) => theme.spacing(1)};
padding: ${({ theme }) => theme.spacing(2)};
padding-bottom: 0;
`;
type RelativeDatePickerHeaderProps = {
direction: VariableDateViewFilterValueDirection;
amount?: number;
unit: VariableDateViewFilterValueUnit;
onChange?: (value: {
direction: VariableDateViewFilterValueDirection;
amount?: number;
unit: VariableDateViewFilterValueUnit;
}) => void;
};
export const RelativeDatePickerHeader = (
props: RelativeDatePickerHeaderProps,
) => {
const [direction, setDirection] = useState(props.direction);
const [amountString, setAmountString] = useState(
props.amount ? props.amount.toString() : '',
);
const [unit, setUnit] = useState(props.unit);
useEffect(() => {
setAmountString(props.amount ? props.amount.toString() : '');
setUnit(props.unit);
setDirection(props.direction);
}, [props.amount, props.unit, props.direction]);
const textInputValue = direction === 'THIS' ? '' : amountString;
const textInputPlaceholder = direction === 'THIS' ? '-' : 'Number';
const isUnitPlural = props.amount && props.amount > 1 && direction !== 'THIS';
const unitSelectOptions = RELATIVE_DATE_UNITS_SELECT_OPTIONS.map((unit) => ({
...unit,
label: `${unit.label}${isUnitPlural ? 's' : ''}`,
}));
return (
<StyledContainer>
<Select
disableBlur
dropdownId="direction-select"
value={direction}
onChange={(newDirection) => {
setDirection(newDirection);
if (props.amount === undefined && newDirection !== 'THIS') return;
props.onChange?.({
direction: newDirection,
amount: props.amount,
unit: unit,
});
}}
options={RELATIVE_DATE_DIRECTION_SELECT_OPTIONS}
/>
<TextInput
value={textInputValue}
onChange={(text) => {
const amountString = text.replace(/[^0-9]|^0+/g, '');
const amount = parseInt(amountString);
setAmountString(amountString);
const valueParts = {
direction,
amount,
unit,
};
if (
variableDateViewFilterValuePartsSchema.safeParse(valueParts).success
) {
props.onChange?.(valueParts);
}
}}
placeholder={textInputPlaceholder}
disabled={direction === 'THIS'}
/>
<Select
disableBlur
dropdownId="unit-select"
value={unit}
onChange={(newUnit) => {
setUnit(newUnit);
if (direction !== 'THIS' && props.amount === undefined) return;
props.onChange?.({
direction,
amount: props.amount,
unit: newUnit,
});
}}
options={unitSelectOptions}
/>
</StyledContainer>
);
};

View File

@ -0,0 +1,13 @@
import { VariableDateViewFilterValueDirection } from '@/views/utils/view-filter-value/resolveDateViewFilterValue';
type RelativeDateDirectionOption = {
value: VariableDateViewFilterValueDirection;
label: string;
};
export const RELATIVE_DATE_DIRECTION_SELECT_OPTIONS: RelativeDateDirectionOption[] =
[
{ value: 'PAST', label: 'Past' },
{ value: 'THIS', label: 'This' },
{ value: 'NEXT', label: 'Next' },
];

View File

@ -0,0 +1,13 @@
import { VariableDateViewFilterValueUnit } from '@/views/utils/view-filter-value/resolveDateViewFilterValue';
type RelativeDateUnit = {
value: VariableDateViewFilterValueUnit;
label: string;
};
export const RELATIVE_DATE_UNITS_SELECT_OPTIONS: RelativeDateUnit[] = [
{ value: 'DAY', label: 'Day' },
{ value: 'WEEK', label: 'Week' },
{ value: 'MONTH', label: 'Month' },
{ value: 'YEAR', label: 'Year' },
];

View File

@ -0,0 +1,24 @@
import { addDays, addMonths, startOfDay, subMonths } from 'date-fns';
export const getHighlightedDates = (highlightedDateRange?: {
start: Date;
end: Date;
}): Date[] => {
if (!highlightedDateRange) return [];
const { start, end } = highlightedDateRange;
const highlightedDates: Date[] = [];
const currentDate = startOfDay(new Date());
const minDate = subMonths(currentDate, 2);
const maxDate = addMonths(currentDate, 2);
let dateToHighlight = start < minDate ? minDate : start;
const lastDate = end > maxDate ? maxDate : end;
while (dateToHighlight <= lastDate) {
highlightedDates.push(dateToHighlight);
dateToHighlight = addDays(dateToHighlight, 1);
}
return highlightedDates;
};

View File

@ -0,0 +1,16 @@
const getMonthName = (index: number): string =>
new Intl.DateTimeFormat('en-US', { month: 'long' }).format(
new Date(0, index, 1),
);
const getMonthNames = (monthNames: string[] = []): string[] => {
if (monthNames.length === 12) return monthNames;
return getMonthNames([...monthNames, getMonthName(monthNames.length)]);
};
export const getMonthSelectOptions = (): { label: string; value: number }[] =>
getMonthNames().map((month, index) => ({
label: month,
value: index,
}));