From 9d36493cf0f5e1a50f4f7ada88fec9d3fafd310c Mon Sep 17 00:00:00 2001 From: ad-elias Date: Fri, 27 Sep 2024 15:57:38 +0200 Subject: [PATCH] Date filter improvements (#5917) (#7196) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- package.json | 1 + .../MultipleFiltersDropdownContent.tsx | 19 +- .../ObjectFilterDropdownDateInput.tsx | 83 +++++++- .../ObjectFilterDropdownOperandSelect.tsx | 22 +- .../hooks/useSelectFilter.ts | 19 ++ .../object-filter-dropdown/types/Filter.ts | 1 - .../getOperandsForFilterType.test.tsx | 13 +- .../utils/getInitialFilterValue.ts | 42 ++++ .../utils/getOperandLabel.ts | 22 ++ .../utils/getOperandsForFilterType.ts | 14 +- .../utils/getRelativeDateDisplayValue.ts | 28 +++ ...turnObjectDropdownFilterIntoQueryFilter.ts | 122 ++++++++++- .../components/AbsoluteDatePickerHeader.tsx | 108 ++++++++++ .../date/components/InternalDatePicker.tsx | 141 ++++++------- .../components/RelativeDatePickerHeader.tsx | 113 +++++++++++ .../RelativeDateDirectionSelectOptions.ts | 13 ++ .../RelativeDateUnitSelectOptions.ts | 13 ++ .../date/utils/getHighlightedDates.ts | 24 +++ .../date/utils/getMonthSelectOptions.ts | 16 ++ .../EditableFilterDropdownButton.tsx | 9 +- .../modules/views/types/ViewFilterOperand.ts | 6 + .../computeVariableDateViewFilterValue.ts | 10 + .../resolveDateViewFilterValue.ts | 190 ++++++++++++++++++ .../view-filter-value/resolveFilterValue.ts | 42 ++++ .../resolveNumberViewFilterValue.ts | 7 + .../graphql-query.parser.ts | 6 +- .../view-filter.workspace-entity.ts | 14 +- yarn.lock | 8 + 28 files changed, 979 insertions(+), 127 deletions(-) create mode 100644 packages/twenty-front/src/modules/object-record/object-filter-dropdown/utils/getInitialFilterValue.ts create mode 100644 packages/twenty-front/src/modules/object-record/object-filter-dropdown/utils/getRelativeDateDisplayValue.ts create mode 100644 packages/twenty-front/src/modules/ui/input/components/internal/date/components/AbsoluteDatePickerHeader.tsx create mode 100644 packages/twenty-front/src/modules/ui/input/components/internal/date/components/RelativeDatePickerHeader.tsx create mode 100644 packages/twenty-front/src/modules/ui/input/components/internal/date/constants/RelativeDateDirectionSelectOptions.ts create mode 100644 packages/twenty-front/src/modules/ui/input/components/internal/date/constants/RelativeDateUnitSelectOptions.ts create mode 100644 packages/twenty-front/src/modules/ui/input/components/internal/date/utils/getHighlightedDates.ts create mode 100644 packages/twenty-front/src/modules/ui/input/components/internal/date/utils/getMonthSelectOptions.ts create mode 100644 packages/twenty-front/src/modules/views/utils/view-filter-value/computeVariableDateViewFilterValue.ts create mode 100644 packages/twenty-front/src/modules/views/utils/view-filter-value/resolveDateViewFilterValue.ts create mode 100644 packages/twenty-front/src/modules/views/utils/view-filter-value/resolveFilterValue.ts create mode 100644 packages/twenty-front/src/modules/views/utils/view-filter-value/resolveNumberViewFilterValue.ts diff --git a/package.json b/package.json index 640ba3bb5c96..1e57fde1ae42 100644 --- a/package.json +++ b/package.json @@ -273,6 +273,7 @@ "@types/node": "18.19.26", "@types/passport-google-oauth20": "^2.0.11", "@types/passport-jwt": "^3.0.8", + "@types/pluralize": "^0.0.33", "@types/react": "^18.2.39", "@types/react-datepicker": "^6.2.0", "@types/react-dom": "^18.2.15", diff --git a/packages/twenty-front/src/modules/object-record/object-filter-dropdown/components/MultipleFiltersDropdownContent.tsx b/packages/twenty-front/src/modules/object-record/object-filter-dropdown/components/MultipleFiltersDropdownContent.tsx index 7638532ee3b6..da02a28a6731 100644 --- a/packages/twenty-front/src/modules/object-record/object-filter-dropdown/components/MultipleFiltersDropdownContent.tsx +++ b/packages/twenty-front/src/modules/object-record/object-filter-dropdown/components/MultipleFiltersDropdownContent.tsx @@ -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 ( @@ -72,7 +81,7 @@ export const MultipleFiltersDropdownContent = ({ )} - {!isEmptyOperand && selectedOperandInDropdown && ( + {isConfigurable && selectedOperandInDropdown && ( <> {[ 'TEXT', diff --git a/packages/twenty-front/src/modules/object-record/object-filter-dropdown/components/ObjectFilterDropdownDateInput.tsx b/packages/twenty-front/src/modules/object-record/object-filter-dropdown/components/ObjectFilterDropdownDateInput.tsx index 20d1dee8389f..3961f28c836b 100644 --- a/packages/twenty-front/src/modules/object-record/object-filter-dropdown/components/ObjectFilterDropdownDateInput.tsx +++ b/packages/twenty-front/src/modules/object-record/object-filter-dropdown/components/ObjectFilterDropdownDateInput.tsx @@ -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( - 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 ( ); diff --git a/packages/twenty-front/src/modules/object-record/object-filter-dropdown/components/ObjectFilterDropdownOperandSelect.tsx b/packages/twenty-front/src/modules/object-record/object-filter-dropdown/components/ObjectFilterDropdownOperandSelect.tsx index 5f500b916461..c710d8f23334 100644 --- a/packages/twenty-front/src/modules/object-record/object-filter-dropdown/components/ObjectFilterDropdownOperandSelect.tsx +++ b/packages/twenty-front/src/modules/object-record/object-filter-dropdown/components/ObjectFilterDropdownOperandSelect.tsx @@ -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, }); } diff --git a/packages/twenty-front/src/modules/object-record/object-filter-dropdown/hooks/useSelectFilter.ts b/packages/twenty-front/src/modules/object-record/object-filter-dropdown/hooks/useSelectFilter.ts index 3954d8d6dc9f..005135444a9e 100644 --- a/packages/twenty-front/src/modules/object-record/object-filter-dropdown/hooks/useSelectFilter.ts +++ b/packages/twenty-front/src/modules/object-record/object-filter-dropdown/hooks/useSelectFilter.ts @@ -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(''); }; diff --git a/packages/twenty-front/src/modules/object-record/object-filter-dropdown/types/Filter.ts b/packages/twenty-front/src/modules/object-record/object-filter-dropdown/types/Filter.ts index 52ed99ac5531..4d2eddb8756e 100644 --- a/packages/twenty-front/src/modules/object-record/object-filter-dropdown/types/Filter.ts +++ b/packages/twenty-front/src/modules/object-record/object-filter-dropdown/types/Filter.ts @@ -1,5 +1,4 @@ import { ViewFilterOperand } from '@/views/types/ViewFilterOperand'; - import { FilterDefinition } from './FilterDefinition'; export type Filter = { diff --git a/packages/twenty-front/src/modules/object-record/object-filter-dropdown/utils/__tests__/getOperandsForFilterType.test.tsx b/packages/twenty-front/src/modules/object-record/object-filter-dropdown/utils/__tests__/getOperandsForFilterType.test.tsx index 7b4b9516e7f4..a06f8455d1f4 100644 --- a/packages/twenty-front/src/modules/object-record/object-filter-dropdown/utils/__tests__/getOperandsForFilterType.test.tsx +++ b/packages/twenty-front/src/modules/object-record/object-filter-dropdown/utils/__tests__/getOperandsForFilterType.test.tsx @@ -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, []], diff --git a/packages/twenty-front/src/modules/object-record/object-filter-dropdown/utils/getInitialFilterValue.ts b/packages/twenty-front/src/modules/object-record/object-filter-dropdown/utils/getInitialFilterValue.ts new file mode 100644 index 000000000000..3076bef96d03 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/object-filter-dropdown/utils/getInitialFilterValue.ts @@ -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 | Record => { + 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 ?? '', + }; +}; diff --git a/packages/twenty-front/src/modules/object-record/object-filter-dropdown/utils/getOperandLabel.ts b/packages/twenty-front/src/modules/object-record/object-filter-dropdown/utils/getOperandLabel.ts index 9c9e297ef960..b68049b51750 100644 --- a/packages/twenty-front/src/modules/object-record/object-filter-dropdown/utils/getOperandLabel.ts +++ b/packages/twenty-front/src/modules/object-record/object-filter-dropdown/utils/getOperandLabel.ts @@ -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 ': '; } diff --git a/packages/twenty-front/src/modules/object-record/object-filter-dropdown/utils/getOperandsForFilterType.ts b/packages/twenty-front/src/modules/object-record/object-filter-dropdown/utils/getOperandsForFilterType.ts index f75dca40f76c..d1066e2a5491 100644 --- a/packages/twenty-front/src/modules/object-record/object-filter-dropdown/utils/getOperandsForFilterType.ts +++ b/packages/twenty-front/src/modules/object-record/object-filter-dropdown/utils/getOperandsForFilterType.ts @@ -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, diff --git a/packages/twenty-front/src/modules/object-record/object-filter-dropdown/utils/getRelativeDateDisplayValue.ts b/packages/twenty-front/src/modules/object-record/object-filter-dropdown/utils/getRelativeDateDisplayValue.ts new file mode 100644 index 000000000000..fb59e540180b --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/object-filter-dropdown/utils/getRelativeDateDisplayValue.ts @@ -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(' '); +}; diff --git a/packages/twenty-front/src/modules/object-record/record-filter/utils/turnObjectDropdownFilterIntoQueryFilter.ts b/packages/twenty-front/src/modules/object-record/record-filter/utils/turnObjectDropdownFilterIntoQueryFilter.ts index 3b0e9f8c2c72..58cbaca25e9d 100644 --- a/packages/twenty-front/src/modules/object-record/record-filter/utils/turnObjectDropdownFilterIntoQueryFilter.ts +++ b/packages/twenty-front/src/modules/object-record/record-filter/utils/turnObjectDropdownFilterIntoQueryFilter.ts @@ -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 & { @@ -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, diff --git a/packages/twenty-front/src/modules/ui/input/components/internal/date/components/AbsoluteDatePickerHeader.tsx b/packages/twenty-front/src/modules/ui/input/components/internal/date/components/AbsoluteDatePickerHeader.tsx new file mode 100644 index 000000000000..1efc985d34f6 --- /dev/null +++ b/packages/twenty-front/src/modules/ui/input/components/internal/date/components/AbsoluteDatePickerHeader.tsx @@ -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 ( + <> + + + + + + + + ); +}; diff --git a/packages/twenty-front/src/modules/ui/input/components/internal/date/components/InternalDatePicker.tsx b/packages/twenty-front/src/modules/ui/input/components/internal/date/components/InternalDatePicker.tsx index a3004373d059..e7a330c81ff3 100644 --- a/packages/twenty-front/src/modules/ui/input/components/internal/date/components/InternalDatePicker.tsx +++ b/packages/twenty-front/src/modules/ui/input/components/internal/date/components/InternalDatePicker.tsx @@ -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 ( - +
( - <> - + isRelative ? ( + + ) : ( + - - - - - - - )} + ) + } onSelect={handleDateSelect} + selectsMultiple={isRelative} />
{clearable && ( diff --git a/packages/twenty-front/src/modules/ui/input/components/internal/date/components/RelativeDatePickerHeader.tsx b/packages/twenty-front/src/modules/ui/input/components/internal/date/components/RelativeDatePickerHeader.tsx new file mode 100644 index 000000000000..0a9328577dba --- /dev/null +++ b/packages/twenty-front/src/modules/ui/input/components/internal/date/components/RelativeDatePickerHeader.tsx @@ -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 ( + + { + setUnit(newUnit); + if (direction !== 'THIS' && props.amount === undefined) return; + props.onChange?.({ + direction, + amount: props.amount, + unit: newUnit, + }); + }} + options={unitSelectOptions} + /> + + ); +}; diff --git a/packages/twenty-front/src/modules/ui/input/components/internal/date/constants/RelativeDateDirectionSelectOptions.ts b/packages/twenty-front/src/modules/ui/input/components/internal/date/constants/RelativeDateDirectionSelectOptions.ts new file mode 100644 index 000000000000..d13926719f0f --- /dev/null +++ b/packages/twenty-front/src/modules/ui/input/components/internal/date/constants/RelativeDateDirectionSelectOptions.ts @@ -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' }, + ]; diff --git a/packages/twenty-front/src/modules/ui/input/components/internal/date/constants/RelativeDateUnitSelectOptions.ts b/packages/twenty-front/src/modules/ui/input/components/internal/date/constants/RelativeDateUnitSelectOptions.ts new file mode 100644 index 000000000000..bf65953f63bc --- /dev/null +++ b/packages/twenty-front/src/modules/ui/input/components/internal/date/constants/RelativeDateUnitSelectOptions.ts @@ -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' }, +]; diff --git a/packages/twenty-front/src/modules/ui/input/components/internal/date/utils/getHighlightedDates.ts b/packages/twenty-front/src/modules/ui/input/components/internal/date/utils/getHighlightedDates.ts new file mode 100644 index 000000000000..813b36996833 --- /dev/null +++ b/packages/twenty-front/src/modules/ui/input/components/internal/date/utils/getHighlightedDates.ts @@ -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; +}; diff --git a/packages/twenty-front/src/modules/ui/input/components/internal/date/utils/getMonthSelectOptions.ts b/packages/twenty-front/src/modules/ui/input/components/internal/date/utils/getMonthSelectOptions.ts new file mode 100644 index 000000000000..3f5e395174ee --- /dev/null +++ b/packages/twenty-front/src/modules/ui/input/components/internal/date/utils/getMonthSelectOptions.ts @@ -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, + })); diff --git a/packages/twenty-front/src/modules/views/components/EditableFilterDropdownButton.tsx b/packages/twenty-front/src/modules/views/components/EditableFilterDropdownButton.tsx index 45c974eca420..b8b9fe56ffa4 100644 --- a/packages/twenty-front/src/modules/views/components/EditableFilterDropdownButton.tsx +++ b/packages/twenty-front/src/modules/views/components/EditableFilterDropdownButton.tsx @@ -9,6 +9,7 @@ import { useDropdown } from '@/ui/layout/dropdown/hooks/useDropdown'; import { HotkeyScope } from '@/ui/utilities/hotkey/types/HotkeyScope'; import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2'; import { EditableFilterChip } from '@/views/components/EditableFilterChip'; +import { ViewFilterOperand } from '@/views/types/ViewFilterOperand'; import { useDeleteCombinedViewFilters } from '@/views/hooks/useDeleteCombinedViewFilters'; import { availableFilterDefinitionsComponentState } from '@/views/states/availableFilterDefinitionsComponentState'; @@ -74,7 +75,13 @@ export const EditableFilterDropdownButton = ({ const { id: fieldId, value, operand } = viewFilter; if ( !value && - ![FilterOperand.IsEmpty, FilterOperand.IsNotEmpty].includes(operand) + ![ + FilterOperand.IsEmpty, + FilterOperand.IsNotEmpty, + ViewFilterOperand.IsInPast, + ViewFilterOperand.IsInFuture, + ViewFilterOperand.IsToday, + ].includes(operand) ) { deleteCombinedViewFilter(fieldId); } diff --git a/packages/twenty-front/src/modules/views/types/ViewFilterOperand.ts b/packages/twenty-front/src/modules/views/types/ViewFilterOperand.ts index 025d0085d49d..0d6446de9ea4 100644 --- a/packages/twenty-front/src/modules/views/types/ViewFilterOperand.ts +++ b/packages/twenty-front/src/modules/views/types/ViewFilterOperand.ts @@ -4,8 +4,14 @@ export enum ViewFilterOperand { IsNot = 'isNot', LessThan = 'lessThan', GreaterThan = 'greaterThan', + IsBefore = 'isBefore', + IsAfter = 'isAfter', Contains = 'contains', DoesNotContain = 'doesNotContain', IsEmpty = 'isEmpty', IsNotEmpty = 'isNotEmpty', + IsRelative = 'isRelative', + IsInPast = 'isInPast', + IsInFuture = 'isInFuture', + IsToday = 'isToday', } diff --git a/packages/twenty-front/src/modules/views/utils/view-filter-value/computeVariableDateViewFilterValue.ts b/packages/twenty-front/src/modules/views/utils/view-filter-value/computeVariableDateViewFilterValue.ts new file mode 100644 index 000000000000..1b09bc91348b --- /dev/null +++ b/packages/twenty-front/src/modules/views/utils/view-filter-value/computeVariableDateViewFilterValue.ts @@ -0,0 +1,10 @@ +import { + VariableDateViewFilterValueDirection, + VariableDateViewFilterValueUnit, +} from '@/views/utils/view-filter-value/resolveDateViewFilterValue'; + +export const computeVariableDateViewFilterValue = ( + direction: VariableDateViewFilterValueDirection, + amount: number | undefined, + unit: VariableDateViewFilterValueUnit, +) => `${direction}_${amount?.toString()}_${unit}`; diff --git a/packages/twenty-front/src/modules/views/utils/view-filter-value/resolveDateViewFilterValue.ts b/packages/twenty-front/src/modules/views/utils/view-filter-value/resolveDateViewFilterValue.ts new file mode 100644 index 000000000000..da940310505c --- /dev/null +++ b/packages/twenty-front/src/modules/views/utils/view-filter-value/resolveDateViewFilterValue.ts @@ -0,0 +1,190 @@ +import { ViewFilter } from '@/views/types/ViewFilter'; +import { ViewFilterOperand } from '@/views/types/ViewFilterOperand'; +import { + addDays, + addMonths, + addWeeks, + addYears, + endOfDay, + endOfMonth, + endOfWeek, + endOfYear, + roundToNearestMinutes, + startOfDay, + startOfMonth, + startOfWeek, + startOfYear, + subDays, + subMonths, + subWeeks, + subYears, +} from 'date-fns'; + +import { z } from 'zod'; + +const variableDateViewFilterValueDirectionSchema = z.enum([ + 'NEXT', + 'THIS', + 'PAST', +]); + +export type VariableDateViewFilterValueDirection = z.infer< + typeof variableDateViewFilterValueDirectionSchema +>; + +const variableDateViewFilterValueAmountSchema = z + .union([z.coerce.number().int().positive(), z.literal('undefined')]) + .transform((val) => (val === 'undefined' ? undefined : val)); + +export const variableDateViewFilterValueUnitSchema = z.enum([ + 'DAY', + 'WEEK', + 'MONTH', + 'YEAR', +]); + +export type VariableDateViewFilterValueUnit = z.infer< + typeof variableDateViewFilterValueUnitSchema +>; + +export const variableDateViewFilterValuePartsSchema = z + .object({ + direction: variableDateViewFilterValueDirectionSchema, + amount: variableDateViewFilterValueAmountSchema, + unit: variableDateViewFilterValueUnitSchema, + }) + .refine((data) => !(data.amount === undefined && data.direction !== 'THIS'), { + message: "Amount cannot be 'undefined' unless direction is 'THIS'", + }); + +const variableDateViewFilterValueSchema = z.string().transform((value) => { + const [direction, amount, unit] = value.split('_'); + + return variableDateViewFilterValuePartsSchema.parse({ + direction, + amount, + unit, + }); +}); + +const addUnit = ( + date: Date, + amount: number, + unit: VariableDateViewFilterValueUnit, +) => { + switch (unit) { + case 'DAY': + return addDays(date, amount); + case 'WEEK': + return addWeeks(date, amount); + case 'MONTH': + return addMonths(date, amount); + case 'YEAR': + return addYears(date, amount); + } +}; + +const subUnit = ( + date: Date, + amount: number, + unit: VariableDateViewFilterValueUnit, +) => { + switch (unit) { + case 'DAY': + return subDays(date, amount); + case 'WEEK': + return subWeeks(date, amount); + case 'MONTH': + return subMonths(date, amount); + case 'YEAR': + return subYears(date, amount); + } +}; + +const startOfUnit = (date: Date, unit: VariableDateViewFilterValueUnit) => { + switch (unit) { + case 'DAY': + return startOfDay(date); + case 'WEEK': + return startOfWeek(date); + case 'MONTH': + return startOfMonth(date); + case 'YEAR': + return startOfYear(date); + } +}; + +const endOfUnit = (date: Date, unit: VariableDateViewFilterValueUnit) => { + switch (unit) { + case 'DAY': + return endOfDay(date); + case 'WEEK': + return endOfWeek(date); + case 'MONTH': + return endOfMonth(date); + case 'YEAR': + return endOfYear(date); + } +}; + +const resolveVariableDateViewFilterValueFromRelativeDate = (relativeDate: { + direction: VariableDateViewFilterValueDirection; + amount?: number; + unit: VariableDateViewFilterValueUnit; +}) => { + const { direction, amount, unit } = relativeDate; + const now = roundToNearestMinutes(new Date()); + + switch (direction) { + case 'NEXT': + if (amount === undefined) throw new Error('Amount is required'); + return { + start: now, + end: addUnit(now, amount, unit), + ...relativeDate, + }; + case 'PAST': + if (amount === undefined) throw new Error('Amount is required'); + return { + start: subUnit(now, amount, unit), + end: now, + ...relativeDate, + }; + case 'THIS': + return { + start: startOfUnit(now, unit), + end: endOfUnit(now, unit), + ...relativeDate, + }; + } +}; + +const resolveVariableDateViewFilterValue = (value?: string | null) => { + if (!value) return null; + + const relativeDate = variableDateViewFilterValueSchema.parse(value); + return resolveVariableDateViewFilterValueFromRelativeDate(relativeDate); +}; + +export type ResolvedDateViewFilterValue = + O extends ViewFilterOperand.IsRelative + ? ReturnType + : Date | null; + +type PartialViewFilter = Pick< + ViewFilter, + 'value' +> & { operand: O }; + +export const resolveDateViewFilterValue = ( + viewFilter: PartialViewFilter, +): ResolvedDateViewFilterValue => { + if (!viewFilter.value) return null; + + if (viewFilter.operand === ViewFilterOperand.IsRelative) { + return resolveVariableDateViewFilterValue( + viewFilter.value, + ) as ResolvedDateViewFilterValue; + } + return new Date(viewFilter.value) as ResolvedDateViewFilterValue; +}; diff --git a/packages/twenty-front/src/modules/views/utils/view-filter-value/resolveFilterValue.ts b/packages/twenty-front/src/modules/views/utils/view-filter-value/resolveFilterValue.ts new file mode 100644 index 000000000000..310666488f1b --- /dev/null +++ b/packages/twenty-front/src/modules/views/utils/view-filter-value/resolveFilterValue.ts @@ -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 { resolveNumberViewFilterValue } from '@/views/utils/view-filter-value/resolveNumberViewFilterValue'; +import { + resolveDateViewFilterValue, + ResolvedDateViewFilterValue, +} from './resolveDateViewFilterValue'; + +type ResolvedFilterValue< + T extends FilterType, + O extends ViewFilterOperand, +> = T extends 'DATE' | 'DATE_TIME' + ? ResolvedDateViewFilterValue + : T extends 'NUMBER' + ? ReturnType + : string; + +type PartialFilter = Pick< + Filter, + 'value' +> & { + definition: { type: T }; + operand: O; +}; + +export const resolveFilterValue = < + T extends FilterType, + O extends ViewFilterOperand, +>( + filter: PartialFilter, +) => { + switch (filter.definition.type) { + case 'DATE': + case 'DATE_TIME': + return resolveDateViewFilterValue(filter) as ResolvedFilterValue; + case 'NUMBER': + return resolveNumberViewFilterValue(filter) as ResolvedFilterValue; + default: + return filter.value as ResolvedFilterValue; + } +}; diff --git a/packages/twenty-front/src/modules/views/utils/view-filter-value/resolveNumberViewFilterValue.ts b/packages/twenty-front/src/modules/views/utils/view-filter-value/resolveNumberViewFilterValue.ts new file mode 100644 index 000000000000..4e26ca096332 --- /dev/null +++ b/packages/twenty-front/src/modules/views/utils/view-filter-value/resolveNumberViewFilterValue.ts @@ -0,0 +1,7 @@ +import { ViewFilter } from '@/views/types/ViewFilter'; + +export const resolveNumberViewFilterValue = ( + viewFilter: Pick, +) => { + return viewFilter.value === '' ? null : +viewFilter.value; +}; diff --git a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query.parser.ts b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query.parser.ts index 157187fd0bd2..d83667211bd0 100644 --- a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query.parser.ts +++ b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query.parser.ts @@ -62,9 +62,9 @@ export class GraphqlQueryParser { return queryBuilder; } - private checkForDeletedAtFilter( + private checkForDeletedAtFilter = ( filter: FindOptionsWhere | FindOptionsWhere[], - ): boolean { + ): boolean => { if (Array.isArray(filter)) { return filter.some((subFilter) => this.checkForDeletedAtFilter(subFilter), @@ -86,7 +86,7 @@ export class GraphqlQueryParser { } return false; - } + }; applyOrderToBuilder( queryBuilder: SelectQueryBuilder, diff --git a/packages/twenty-server/src/modules/view/standard-objects/view-filter.workspace-entity.ts b/packages/twenty-server/src/modules/view/standard-objects/view-filter.workspace-entity.ts index 149171e6eb7f..3e45fee95d60 100644 --- a/packages/twenty-server/src/modules/view/standard-objects/view-filter.workspace-entity.ts +++ b/packages/twenty-server/src/modules/view/standard-objects/view-filter.workspace-entity.ts @@ -1,18 +1,18 @@ import { Relation } from 'src/engine/workspace-manager/workspace-sync-metadata/interfaces/relation.interface'; import { FieldMetadataType } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity'; -import { VIEW_FILTER_STANDARD_FIELD_IDS } from 'src/engine/workspace-manager/workspace-sync-metadata/constants/standard-field-ids'; -import { STANDARD_OBJECT_IDS } from 'src/engine/workspace-manager/workspace-sync-metadata/constants/standard-object-ids'; -import { ViewWorkspaceEntity } from 'src/modules/view/standard-objects/view.workspace-entity'; +import { RelationMetadataType } from 'src/engine/metadata-modules/relation-metadata/relation-metadata.entity'; import { BaseWorkspaceEntity } from 'src/engine/twenty-orm/base.workspace-entity'; import { WorkspaceEntity } from 'src/engine/twenty-orm/decorators/workspace-entity.decorator'; -import { WorkspaceIsNotAuditLogged } from 'src/engine/twenty-orm/decorators/workspace-is-not-audit-logged.decorator'; -import { WorkspaceIsSystem } from 'src/engine/twenty-orm/decorators/workspace-is-system.decorator'; import { WorkspaceField } from 'src/engine/twenty-orm/decorators/workspace-field.decorator'; -import { WorkspaceRelation } from 'src/engine/twenty-orm/decorators/workspace-relation.decorator'; -import { RelationMetadataType } from 'src/engine/metadata-modules/relation-metadata/relation-metadata.entity'; +import { WorkspaceIsNotAuditLogged } from 'src/engine/twenty-orm/decorators/workspace-is-not-audit-logged.decorator'; import { WorkspaceIsNullable } from 'src/engine/twenty-orm/decorators/workspace-is-nullable.decorator'; +import { WorkspaceIsSystem } from 'src/engine/twenty-orm/decorators/workspace-is-system.decorator'; import { WorkspaceJoinColumn } from 'src/engine/twenty-orm/decorators/workspace-join-column.decorator'; +import { WorkspaceRelation } from 'src/engine/twenty-orm/decorators/workspace-relation.decorator'; +import { VIEW_FILTER_STANDARD_FIELD_IDS } from 'src/engine/workspace-manager/workspace-sync-metadata/constants/standard-field-ids'; +import { STANDARD_OBJECT_IDS } from 'src/engine/workspace-manager/workspace-sync-metadata/constants/standard-object-ids'; +import { ViewWorkspaceEntity } from 'src/modules/view/standard-objects/view.workspace-entity'; @WorkspaceEntity({ standardId: STANDARD_OBJECT_IDS.viewFilter, diff --git a/yarn.lock b/yarn.lock index 293917a2211f..46889dbdb463 100644 --- a/yarn.lock +++ b/yarn.lock @@ -17310,6 +17310,13 @@ __metadata: languageName: node linkType: hard +"@types/pluralize@npm:^0.0.33": + version: 0.0.33 + resolution: "@types/pluralize@npm:0.0.33" + checksum: 10c0/24899caf85b79dd291a6b6e9b9f3b67b452b18d578d0ac0d531a705bf5ee0361d9386ea1f8532c64de9e22c6e9606c5497787bb5e31bd299c487980436c59785 + languageName: node + linkType: hard + "@types/pretty-hrtime@npm:^1.0.0": version: 1.0.3 resolution: "@types/pretty-hrtime@npm:1.0.3" @@ -47693,6 +47700,7 @@ __metadata: "@types/passport-google-oauth20": "npm:^2.0.11" "@types/passport-jwt": "npm:^3.0.8" "@types/passport-microsoft": "npm:^1.0.3" + "@types/pluralize": "npm:^0.0.33" "@types/react": "npm:^18.2.39" "@types/react-datepicker": "npm:^6.2.0" "@types/react-dom": "npm:^18.2.15"