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

@ -54,11 +54,20 @@ export const MultipleFiltersDropdownContent = ({
selectedOperandInDropdownState,
);
const isEmptyOperand =
const isConfigurable =
selectedOperandInDropdown &&
[ViewFilterOperand.IsEmpty, ViewFilterOperand.IsNotEmpty].includes(
selectedOperandInDropdown,
);
[
ViewFilterOperand.Is,
ViewFilterOperand.IsNotNull,
ViewFilterOperand.IsNot,
ViewFilterOperand.LessThan,
ViewFilterOperand.GreaterThan,
ViewFilterOperand.IsBefore,
ViewFilterOperand.IsAfter,
ViewFilterOperand.Contains,
ViewFilterOperand.DoesNotContain,
ViewFilterOperand.IsRelative,
].includes(selectedOperandInDropdown);
return (
<StyledContainer>
@ -72,7 +81,7 @@ export const MultipleFiltersDropdownContent = ({
<ObjectFilterDropdownOperandSelect />
</StyledOperandSelectContainer>
)}
{!isEmptyOperand && selectedOperandInDropdown && (
{isConfigurable && selectedOperandInDropdown && (
<>
{[
'TEXT',

View File

@ -2,10 +2,19 @@ import { useRecoilValue } from 'recoil';
import { v4 } from 'uuid';
import { useFilterDropdown } from '@/object-record/object-filter-dropdown/hooks/useFilterDropdown';
import { Filter } from '@/object-record/object-filter-dropdown/types/Filter';
import { getRelativeDateDisplayValue } from '@/object-record/object-filter-dropdown/utils/getRelativeDateDisplayValue';
import { InternalDatePicker } from '@/ui/input/components/internal/date/components/InternalDatePicker';
import { ViewFilterOperand } from '@/views/types/ViewFilterOperand';
import { computeVariableDateViewFilterValue } from '@/views/utils/view-filter-value/computeVariableDateViewFilterValue';
import {
VariableDateViewFilterValueDirection,
VariableDateViewFilterValueUnit,
} from '@/views/utils/view-filter-value/resolveDateViewFilterValue';
import { resolveFilterValue } from '@/views/utils/view-filter-value/resolveFilterValue';
import { useState } from 'react';
import { isDefined } from 'twenty-ui';
import { FieldMetadataType } from '~/generated-metadata/graphql';
import { isDefined } from '~/utils/isDefined';
export const ObjectFilterDropdownDateInput = () => {
const {
@ -23,28 +32,35 @@ export const ObjectFilterDropdownDateInput = () => {
selectedOperandInDropdownState,
);
const selectedFilter = useRecoilValue(selectedFilterState);
const selectedFilter = useRecoilValue(selectedFilterState) as
| (Filter & { definition: { type: 'DATE' | 'DATE_TIME' } })
| null
| undefined;
const initialFilterValue = selectedFilter
? resolveFilterValue(selectedFilter)
: null;
const [internalDate, setInternalDate] = useState<Date | null>(
selectedFilter?.value ? new Date(selectedFilter.value) : new Date(),
initialFilterValue instanceof Date ? initialFilterValue : null,
);
const isDateTimeInput =
filterDefinitionUsedInDropdown?.type === FieldMetadataType.DateTime;
const handleChange = (date: Date | null) => {
setInternalDate(date);
const handleAbsoluteDateChange = (newDate: Date | null) => {
setInternalDate(newDate);
if (!filterDefinitionUsedInDropdown || !selectedOperandInDropdown) return;
selectFilter?.({
id: selectedFilter?.id ? selectedFilter.id : v4(),
fieldMetadataId: filterDefinitionUsedInDropdown.fieldMetadataId,
value: isDefined(date) ? date.toISOString() : '',
value: newDate?.toISOString() ?? '',
operand: selectedOperandInDropdown,
displayValue: isDefined(date)
displayValue: isDefined(newDate)
? isDateTimeInput
? date.toLocaleString()
: date.toLocaleDateString()
? newDate.toLocaleString()
: newDate.toLocaleDateString()
: '',
definition: filterDefinitionUsedInDropdown,
});
@ -52,11 +68,56 @@ export const ObjectFilterDropdownDateInput = () => {
setIsObjectFilterDropdownUnfolded(false);
};
const handleRelativeDateChange = (
relativeDate: {
direction: VariableDateViewFilterValueDirection;
amount?: number;
unit: VariableDateViewFilterValueUnit;
} | null,
) => {
if (!filterDefinitionUsedInDropdown || !selectedOperandInDropdown) return;
const value = relativeDate
? computeVariableDateViewFilterValue(
relativeDate.direction,
relativeDate.amount,
relativeDate.unit,
)
: '';
selectFilter?.({
id: selectedFilter?.id ? selectedFilter.id : v4(),
fieldMetadataId: filterDefinitionUsedInDropdown.fieldMetadataId,
value,
operand: selectedOperandInDropdown,
displayValue: getRelativeDateDisplayValue(relativeDate),
definition: filterDefinitionUsedInDropdown,
});
setIsObjectFilterDropdownUnfolded(false);
};
const isRelativeOperand =
selectedOperandInDropdown === ViewFilterOperand.IsRelative;
const resolvedValue = selectedFilter
? resolveFilterValue(selectedFilter)
: null;
const relativeDate =
resolvedValue && !(resolvedValue instanceof Date)
? resolvedValue
: undefined;
return (
<InternalDatePicker
relativeDate={relativeDate}
highlightedDateRange={relativeDate}
isRelative={isRelativeOperand}
date={internalDate}
onChange={handleChange}
onMouseSelect={handleChange}
onChange={handleAbsoluteDateChange}
onRelativeDateChange={handleRelativeDateChange}
onMouseSelect={handleAbsoluteDateChange}
isDateTimeInput={isDateTimeInput}
/>
);

View File

@ -2,12 +2,12 @@ import { useRecoilValue } from 'recoil';
import { v4 } from 'uuid';
import { useFilterDropdown } from '@/object-record/object-filter-dropdown/hooks/useFilterDropdown';
import { FilterDefinition } from '@/object-record/object-filter-dropdown/types/FilterDefinition';
import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer';
import { MenuItem } from '@/ui/navigation/menu-item/components/MenuItem';
import { ViewFilterOperand } from '@/views/types/ViewFilterOperand';
import { isDefined } from '~/utils/isDefined';
import { getInitialFilterValue } from '@/object-record/object-filter-dropdown/utils/getInitialFilterValue';
import { getOperandLabel } from '../utils/getOperandLabel';
import { getOperandsForFilterType } from '../utils/getOperandsForFilterType';
@ -36,22 +36,25 @@ export const ObjectFilterDropdownOperandSelect = () => {
);
const handleOperandChange = (newOperand: ViewFilterOperand) => {
const isEmptyOperand = [
const isValuelessOperand = [
ViewFilterOperand.IsEmpty,
ViewFilterOperand.IsNotEmpty,
ViewFilterOperand.IsInPast,
ViewFilterOperand.IsInFuture,
ViewFilterOperand.IsToday,
].includes(newOperand);
setSelectedOperandInDropdown(newOperand);
setIsObjectFilterDropdownOperandSelectUnfolded(false);
if (isEmptyOperand) {
if (isValuelessOperand && isDefined(filterDefinitionUsedInDropdown)) {
selectFilter?.({
id: v4(),
fieldMetadataId: filterDefinitionUsedInDropdown?.fieldMetadataId ?? '',
displayValue: '',
operand: newOperand,
value: '',
definition: filterDefinitionUsedInDropdown as FilterDefinition,
definition: filterDefinitionUsedInDropdown,
});
return;
}
@ -60,12 +63,19 @@ export const ObjectFilterDropdownOperandSelect = () => {
isDefined(filterDefinitionUsedInDropdown) &&
isDefined(selectedFilter)
) {
const { value, displayValue } = getInitialFilterValue(
filterDefinitionUsedInDropdown.type,
newOperand,
selectedFilter.value,
selectedFilter.displayValue,
);
selectFilter?.({
id: selectedFilter.id ? selectedFilter.id : v4(),
fieldMetadataId: selectedFilter.fieldMetadataId,
displayValue: selectedFilter.displayValue,
displayValue,
operand: newOperand,
value: selectedFilter.value,
value,
definition: filterDefinitionUsedInDropdown,
});
}

View File

@ -1,8 +1,10 @@
import { useFilterDropdown } from '@/object-record/object-filter-dropdown/hooks/useFilterDropdown';
import { FilterDefinition } from '@/object-record/object-filter-dropdown/types/FilterDefinition';
import { getInitialFilterValue } from '@/object-record/object-filter-dropdown/utils/getInitialFilterValue';
import { getOperandsForFilterType } from '@/object-record/object-filter-dropdown/utils/getOperandsForFilterType';
import { RelationPickerHotkeyScope } from '@/object-record/relation-picker/types/RelationPickerHotkeyScope';
import { useSetHotkeyScope } from '@/ui/utilities/hotkey/hooks/useSetHotkeyScope';
import { v4 } from 'uuid';
type SelectFilterParams = {
filterDefinition: FilterDefinition;
@ -13,6 +15,7 @@ export const useSelectFilter = () => {
setFilterDefinitionUsedInDropdown,
setSelectedOperandInDropdown,
setObjectFilterDropdownSearchInput,
selectFilter: filterDropdownSelectFilter,
} = useFilterDropdown();
const setHotkeyScope = useSetHotkeyScope();
@ -31,6 +34,22 @@ export const useSelectFilter = () => {
getOperandsForFilterType(filterDefinition.type)?.[0],
);
const { value, displayValue } = getInitialFilterValue(
filterDefinition.type,
getOperandsForFilterType(filterDefinition.type)?.[0],
);
if (value !== '') {
filterDropdownSelectFilter({
id: v4(),
fieldMetadataId: filterDefinition.fieldMetadataId,
displayValue,
operand: getOperandsForFilterType(filterDefinition.type)?.[0],
value,
definition: filterDefinition,
});
}
setObjectFilterDropdownSearchInput('');
};

View File

@ -1,5 +1,4 @@
import { ViewFilterOperand } from '@/views/types/ViewFilterOperand';
import { FilterDefinition } from './FilterDefinition';
export type Filter = {

View File

@ -19,6 +19,16 @@ describe('getOperandsForFilterType', () => {
ViewFilterOperand.LessThan,
];
const dateOperands = [
ViewFilterOperand.Is,
ViewFilterOperand.IsRelative,
ViewFilterOperand.IsInPast,
ViewFilterOperand.IsInFuture,
ViewFilterOperand.IsToday,
ViewFilterOperand.IsBefore,
ViewFilterOperand.IsAfter,
];
const relationOperand = [ViewFilterOperand.Is, ViewFilterOperand.IsNot];
const testCases = [
@ -31,7 +41,8 @@ describe('getOperandsForFilterType', () => {
['ACTOR', [...containsOperands, ...emptyOperands]],
['CURRENCY', [...numberOperands, ...emptyOperands]],
['NUMBER', [...numberOperands, ...emptyOperands]],
['DATE_TIME', [...numberOperands, ...emptyOperands]],
['DATE', [...dateOperands, ...emptyOperands]],
['DATE_TIME', [...dateOperands, ...emptyOperands]],
['RELATION', [...relationOperand, ...emptyOperands]],
[undefined, []],
[null, []],

View File

@ -0,0 +1,42 @@
import { Filter } from '@/object-record/object-filter-dropdown/types/Filter';
import { FilterType } from '@/object-record/object-filter-dropdown/types/FilterType';
import { ViewFilterOperand } from '@/views/types/ViewFilterOperand';
import { z } from 'zod';
export const getInitialFilterValue = (
newType: FilterType,
newOperand: ViewFilterOperand,
oldValue?: string,
oldDisplayValue?: string,
): Pick<Filter, 'value' | 'displayValue'> | Record<string, never> => {
switch (newType) {
case 'DATE':
case 'DATE_TIME': {
const activeDatePickerOperands = [
ViewFilterOperand.IsBefore,
ViewFilterOperand.Is,
ViewFilterOperand.IsAfter,
];
if (activeDatePickerOperands.includes(newOperand)) {
const date = z.coerce.date().safeParse(oldValue).data ?? new Date();
const value = date.toISOString();
const displayValue =
newType === 'DATE'
? date.toLocaleString()
: date.toLocaleDateString();
return { value, displayValue };
}
if (newOperand === ViewFilterOperand.IsRelative) {
return { value: '', displayValue: '' };
}
break;
}
}
return {
value: oldValue ?? '',
displayValue: oldDisplayValue ?? '',
};
};

View File

@ -12,6 +12,10 @@ export const getOperandLabel = (
return 'Greater than';
case ViewFilterOperand.LessThan:
return 'Less than';
case ViewFilterOperand.IsBefore:
return 'Is before';
case ViewFilterOperand.IsAfter:
return 'Is after';
case ViewFilterOperand.Is:
return 'Is';
case ViewFilterOperand.IsNot:
@ -22,6 +26,14 @@ export const getOperandLabel = (
return 'Is empty';
case ViewFilterOperand.IsNotEmpty:
return 'Is not empty';
case ViewFilterOperand.IsRelative:
return 'Is relative';
case ViewFilterOperand.IsInPast:
return 'Is in past';
case ViewFilterOperand.IsInFuture:
return 'Is in future';
case ViewFilterOperand.IsToday:
return 'Is today';
default:
return '';
}
@ -47,6 +59,16 @@ export const getOperandLabelShort = (
return '\u00A0> ';
case ViewFilterOperand.LessThan:
return '\u00A0< ';
case ViewFilterOperand.IsBefore:
return '\u00A0< ';
case ViewFilterOperand.IsAfter:
return '\u00A0> ';
case ViewFilterOperand.IsInPast:
return ': Past';
case ViewFilterOperand.IsInFuture:
return ': Future';
case ViewFilterOperand.IsToday:
return ': Today';
default:
return ': ';
}

View File

@ -31,13 +31,23 @@ export const getOperandsForFilterType = (
];
case 'CURRENCY':
case 'NUMBER':
case 'DATE_TIME':
case 'DATE':
return [
ViewFilterOperand.GreaterThan,
ViewFilterOperand.LessThan,
...emptyOperands,
];
case 'DATE_TIME':
case 'DATE':
return [
ViewFilterOperand.Is,
ViewFilterOperand.IsRelative,
ViewFilterOperand.IsInPast,
ViewFilterOperand.IsInFuture,
ViewFilterOperand.IsToday,
ViewFilterOperand.IsBefore,
ViewFilterOperand.IsAfter,
...emptyOperands,
];
case 'RATING':
return [
ViewFilterOperand.Is,

View File

@ -0,0 +1,28 @@
import {
VariableDateViewFilterValueDirection,
VariableDateViewFilterValueUnit,
} from '@/views/utils/view-filter-value/resolveDateViewFilterValue';
import { plural } from 'pluralize';
import { capitalize } from '~/utils/string/capitalize';
export const getRelativeDateDisplayValue = (
relativeDate: {
direction: VariableDateViewFilterValueDirection;
amount?: number;
unit: VariableDateViewFilterValueUnit;
} | null,
) => {
if (!relativeDate) return '';
const { direction, amount, unit } = relativeDate;
const directionStr = capitalize(direction.toLowerCase());
const amountStr = direction === 'THIS' ? '' : amount;
const unitStr = amount
? amount > 1
? plural(unit.toLowerCase())
: unit.toLowerCase()
: undefined;
return [directionStr, amountStr, unitStr]
.filter((item) => item !== undefined)
.join(' ');
};

View File

@ -25,6 +25,9 @@ import {
convertLessThanRatingToArrayOfRatingValues,
convertRatingToRatingValue,
} from '@/object-record/object-filter-dropdown/components/ObjectFilterDropdownRatingInput';
import { resolveFilterValue } from '@/views/utils/view-filter-value/resolveFilterValue';
import { endOfDay, roundToNearestMinutes, startOfDay } from 'date-fns';
import { z } from 'zod';
import { Filter } from '../../object-filter-dropdown/types/Filter';
export type ObjectDropdownFilter = Omit<Filter, 'definition'> & {
@ -289,16 +292,19 @@ export const turnObjectDropdownFilterIntoQueryFilter = (
(field) => field.id === rawUIFilter.fieldMetadataId,
);
const isEmptyOperand = [
const isValuelessOperand = [
ViewFilterOperand.IsEmpty,
ViewFilterOperand.IsNotEmpty,
ViewFilterOperand.IsInPast,
ViewFilterOperand.IsInFuture,
ViewFilterOperand.IsToday,
].includes(rawUIFilter.operand);
if (!correspondingField) {
continue;
}
if (!isEmptyOperand) {
if (!isValuelessOperand) {
if (!isDefined(rawUIFilter.value) || rawUIFilter.value === '') {
continue;
}
@ -341,24 +347,31 @@ export const turnObjectDropdownFilterIntoQueryFilter = (
}
break;
case 'DATE':
case 'DATE_TIME':
case 'DATE_TIME': {
const resolvedFilterValue = resolveFilterValue(rawUIFilter);
const now = roundToNearestMinutes(new Date());
const date =
resolvedFilterValue instanceof Date ? resolvedFilterValue : now;
switch (rawUIFilter.operand) {
case ViewFilterOperand.GreaterThan:
case ViewFilterOperand.IsAfter: {
objectRecordFilters.push({
[correspondingField.name]: {
gte: rawUIFilter.value,
gt: date.toISOString(),
} as DateFilter,
});
break;
case ViewFilterOperand.LessThan:
}
case ViewFilterOperand.IsBefore: {
objectRecordFilters.push({
[correspondingField.name]: {
lte: rawUIFilter.value,
lt: date.toISOString(),
} as DateFilter,
});
break;
}
case ViewFilterOperand.IsEmpty:
case ViewFilterOperand.IsNotEmpty:
case ViewFilterOperand.IsNotEmpty: {
applyEmptyFilters(
rawUIFilter.operand,
correspondingField,
@ -366,12 +379,99 @@ export const turnObjectDropdownFilterIntoQueryFilter = (
rawUIFilter.definition.type,
);
break;
}
case ViewFilterOperand.IsRelative: {
const dateRange = z
.object({ start: z.date(), end: z.date() })
.safeParse(resolvedFilterValue).data;
const defaultDateRange = resolveFilterValue({
value: 'PAST_1_DAY',
definition: {
type: 'DATE',
},
operand: ViewFilterOperand.IsRelative,
});
if (!defaultDateRange)
throw new Error('Failed to resolve default date range');
const { start, end } = dateRange ?? defaultDateRange;
objectRecordFilters.push({
and: [
{
[correspondingField.name]: {
gte: start.toISOString(),
} as DateFilter,
},
{
[correspondingField.name]: {
lte: end.toISOString(),
} as DateFilter,
},
],
});
break;
}
case ViewFilterOperand.Is: {
const isValid = resolvedFilterValue instanceof Date;
const date = isValid ? resolvedFilterValue : now;
objectRecordFilters.push({
and: [
{
[correspondingField.name]: {
lte: endOfDay(date).toISOString(),
} as DateFilter,
},
{
[correspondingField.name]: {
gte: startOfDay(date).toISOString(),
} as DateFilter,
},
],
});
break;
}
case ViewFilterOperand.IsInPast:
objectRecordFilters.push({
[correspondingField.name]: {
lte: now.toISOString(),
} as DateFilter,
});
break;
case ViewFilterOperand.IsInFuture:
objectRecordFilters.push({
[correspondingField.name]: {
gte: now.toISOString(),
} as DateFilter,
});
break;
case ViewFilterOperand.IsToday: {
objectRecordFilters.push({
and: [
{
[correspondingField.name]: {
lte: endOfDay(now).toISOString(),
} as DateFilter,
},
{
[correspondingField.name]: {
gte: startOfDay(now).toISOString(),
} as DateFilter,
},
],
});
break;
}
default:
throw new Error(
`Unknown operand ${rawUIFilter.operand} for ${rawUIFilter.definition.type} filter`,
`Unknown operand ${rawUIFilter.operand} for ${rawUIFilter.definition.type} filter`, //
);
}
break;
}
case 'RATING':
switch (rawUIFilter.operand) {
case ViewFilterOperand.Is:
@ -446,7 +546,7 @@ export const turnObjectDropdownFilterIntoQueryFilter = (
}
break;
case 'RELATION': {
if (!isEmptyOperand) {
if (!isValuelessOperand) {
try {
JSON.parse(rawUIFilter.value);
} catch (e) {
@ -743,7 +843,7 @@ export const turnObjectDropdownFilterIntoQueryFilter = (
}
break;
case 'SELECT': {
if (isEmptyOperand) {
if (isValuelessOperand) {
applyEmptyFilters(
rawUIFilter.operand,
correspondingField,