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 7479c500a7c..2ce6f8dd0ae 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 @@ -6,13 +6,13 @@ import { getRelativeDateDisplayValue } from '@/object-record/object-filter-dropd import { DateTimePicker } from '@/ui/input/components/internal/date/components/InternalDatePicker'; import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue'; import { computeVariableDateViewFilterValue } from '@/views/view-filter-value/utils/computeVariableDateViewFilterValue'; +import { resolveDateViewFilterValue } from '@/views/view-filter-value/utils/resolveDateViewFilterValue'; +import { useState } from 'react'; import { - resolveDateViewFilterValue, + ViewFilterOperand, type VariableDateViewFilterValueDirection, type VariableDateViewFilterValueUnit, -} from '@/views/view-filter-value/utils/resolveDateViewFilterValue'; -import { useState } from 'react'; -import { ViewFilterOperand } from 'twenty-shared/types'; +} from 'twenty-shared/types'; import { isDefined } from 'twenty-shared/utils'; import { FieldMetadataType } from '~/generated-metadata/graphql'; diff --git a/packages/twenty-front/src/modules/object-record/object-filter-dropdown/hooks/useApplyObjectFilterDropdownOperand.ts b/packages/twenty-front/src/modules/object-record/object-filter-dropdown/hooks/useApplyObjectFilterDropdownOperand.ts index a861da31caf..862910bd301 100644 --- a/packages/twenty-front/src/modules/object-record/object-filter-dropdown/hooks/useApplyObjectFilterDropdownOperand.ts +++ b/packages/twenty-front/src/modules/object-record/object-filter-dropdown/hooks/useApplyObjectFilterDropdownOperand.ts @@ -14,7 +14,7 @@ import { computeVariableDateViewFilterValue } from '@/views/view-filter-value/ut import { type VariableDateViewFilterValueDirection, type VariableDateViewFilterValueUnit, -} from '@/views/view-filter-value/utils/resolveDateViewFilterValue'; +} from 'twenty-shared/types'; import { isDefined } from 'twenty-shared/utils'; export const useApplyObjectFilterDropdownOperand = () => { 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 index 337145f3bc9..ca3c992b2c9 100644 --- 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 @@ -1,8 +1,8 @@ +import { plural } from 'pluralize'; import { type VariableDateViewFilterValueDirection, type VariableDateViewFilterValueUnit, -} from '@/views/view-filter-value/utils/resolveDateViewFilterValue'; -import { plural } from 'pluralize'; +} from 'twenty-shared/types'; import { capitalize } from 'twenty-shared/utils'; export const getRelativeDateDisplayValue = ( diff --git a/packages/twenty-front/src/modules/object-record/record-field/ui/form-types/components/FormRelativeDatePicker.tsx b/packages/twenty-front/src/modules/object-record/record-field/ui/form-types/components/FormRelativeDatePicker.tsx new file mode 100644 index 00000000000..4f66819cf2a --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-field/ui/form-types/components/FormRelativeDatePicker.tsx @@ -0,0 +1,43 @@ +import { RelativeDatePickerHeader } from '@/ui/input/components/internal/date/components/RelativeDatePickerHeader'; + +import { isNonEmptyString, isString } from '@sniptt/guards'; +import { + DEFAULT_RELATIVE_DATE_VALUE, + type VariableDateViewFilterValue, +} from 'twenty-shared/types'; +import { safeParseRelativeDateFilterValue } from 'twenty-shared/utils'; +import { type JsonValue } from 'type-fest'; + +export type FormRelativeDatePickerProps = { + label?: string; + defaultValue?: string; + onChange: (value: JsonValue) => void; + readonly?: boolean; +}; + +export const FormRelativeDatePicker = ({ + defaultValue, + onChange, + readonly, +}: FormRelativeDatePickerProps) => { + const value = + isString(defaultValue) && isNonEmptyString(defaultValue) + ? safeParseRelativeDateFilterValue(defaultValue) + : DEFAULT_RELATIVE_DATE_VALUE; + + const handleValueChange = (newValue: VariableDateViewFilterValue) => { + onChange(JSON.stringify(newValue)); + }; + + 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 43d7f8ee6f8..58314010ae4 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 @@ -14,14 +14,14 @@ import { RelativeDatePickerHeader } from '@/ui/input/components/internal/date/co import { getHighlightedDates } from '@/ui/input/components/internal/date/utils/getHighlightedDates'; import { useCloseDropdown } from '@/ui/layout/dropdown/hooks/useCloseDropdown'; import { UserContext } from '@/users/contexts/UserContext'; -import { - type VariableDateViewFilterValueDirection, - type VariableDateViewFilterValueUnit, -} from '@/views/view-filter-value/utils/resolveDateViewFilterValue'; import { useTheme } from '@emotion/react'; import { t } from '@lingui/core/macro'; import 'react-datepicker/dist/react-datepicker.css'; import { useRecoilValue } from 'recoil'; +import { + type VariableDateViewFilterValueDirection, + type VariableDateViewFilterValueUnit, +} from 'twenty-shared/types'; import { IconCalendarX } from 'twenty-ui/display'; import { MenuItemLeftContent, 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 index 80f00504468..fcc0d7dc42d 100644 --- 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 @@ -2,20 +2,20 @@ import { Select } from '@/ui/input/components/Select'; import { SettingsTextInput } from '@/ui/input/components/SettingsTextInput'; 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 { variableDateViewFilterValuePartsSchema } from '@/views/view-filter-value/utils/resolveDateViewFilterValue'; import { type VariableDateViewFilterValueDirection, type VariableDateViewFilterValueUnit, - variableDateViewFilterValuePartsSchema, -} from '@/views/view-filter-value/utils/resolveDateViewFilterValue'; +} from 'twenty-shared/types'; import styled from '@emotion/styled'; import { useEffect, useState } from 'react'; -const StyledContainer = styled.div` +const StyledContainer = styled.div<{ noPadding: boolean }>` display: flex; align-items: center; gap: ${({ theme }) => theme.spacing(1)}; - padding: ${({ theme }) => theme.spacing(2)}; + padding: ${({ theme, noPadding }) => (noPadding ? '0' : theme.spacing(2))}; padding-bottom: 0; `; @@ -28,6 +28,9 @@ type RelativeDatePickerHeaderProps = { amount?: number; unit: VariableDateViewFilterValueUnit; }) => void; + isFormField?: boolean; + readonly?: boolean; + unitDropdownWidth?: number; }; export const RelativeDatePickerHeader = ( @@ -55,7 +58,7 @@ export const RelativeDatePickerHeader = ( })); return ( - + ); 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 index 64207805459..b378e36dad7 100644 --- 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 @@ -1,4 +1,4 @@ -import { type VariableDateViewFilterValueDirection } from '@/views/view-filter-value/utils/resolveDateViewFilterValue'; +import { type VariableDateViewFilterValueDirection } from 'twenty-shared/types'; type RelativeDateDirectionOption = { value: VariableDateViewFilterValueDirection; 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 index 7a62cb2820e..6377cae3efd 100644 --- 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 @@ -1,4 +1,4 @@ -import { type VariableDateViewFilterValueUnit } from '@/views/view-filter-value/utils/resolveDateViewFilterValue'; +import { type VariableDateViewFilterValueUnit } from 'twenty-shared/types'; type RelativeDateUnit = { value: VariableDateViewFilterValueUnit; diff --git a/packages/twenty-front/src/modules/views/view-filter-value/utils/computeVariableDateViewFilterValue.ts b/packages/twenty-front/src/modules/views/view-filter-value/utils/computeVariableDateViewFilterValue.ts index 34a91eec7ea..d77b30ffa4e 100644 --- a/packages/twenty-front/src/modules/views/view-filter-value/utils/computeVariableDateViewFilterValue.ts +++ b/packages/twenty-front/src/modules/views/view-filter-value/utils/computeVariableDateViewFilterValue.ts @@ -1,7 +1,7 @@ import { type VariableDateViewFilterValueDirection, type VariableDateViewFilterValueUnit, -} from '@/views/view-filter-value/utils/resolveDateViewFilterValue'; +} from 'twenty-shared/types'; export const computeVariableDateViewFilterValue = ( direction: VariableDateViewFilterValueDirection, diff --git a/packages/twenty-front/src/modules/views/view-filter-value/utils/resolveDateViewFilterValue.ts b/packages/twenty-front/src/modules/views/view-filter-value/utils/resolveDateViewFilterValue.ts index be61429fad5..32735ff0477 100644 --- a/packages/twenty-front/src/modules/views/view-filter-value/utils/resolveDateViewFilterValue.ts +++ b/packages/twenty-front/src/modules/views/view-filter-value/utils/resolveDateViewFilterValue.ts @@ -19,7 +19,6 @@ import { subYears, } from 'date-fns'; import { ViewFilterOperand } from 'twenty-shared/types'; - import { z } from 'zod'; const variableDateViewFilterValueDirectionSchema = z.enum([ diff --git a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/filter-action/components/WorkflowStepFilterOperandSelect.tsx b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/filter-action/components/WorkflowStepFilterOperandSelect.tsx index aea33ee13f8..1a8c02be77c 100644 --- a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/filter-action/components/WorkflowStepFilterOperandSelect.tsx +++ b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/filter-action/components/WorkflowStepFilterOperandSelect.tsx @@ -33,6 +33,7 @@ export const WorkflowStepFilterOperandSelect = ({ stepFilterToUpsert: { ...stepFilter, operand, + value: '', }, }); }; diff --git a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/filter-action/components/WorkflowStepFilterValueInput.tsx b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/filter-action/components/WorkflowStepFilterValueInput.tsx index 335f8f881c0..0a5bc7892ea 100644 --- a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/filter-action/components/WorkflowStepFilterValueInput.tsx +++ b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/filter-action/components/WorkflowStepFilterValueInput.tsx @@ -1,9 +1,11 @@ import { configurableViewFilterOperands } from '@/object-record/object-filter-dropdown/utils/configurableViewFilterOperands'; import { FormFieldInput } from '@/object-record/record-field/ui/components/FormFieldInput'; import { FormMultiSelectFieldInput } from '@/object-record/record-field/ui/form-types/components/FormMultiSelectFieldInput'; +import { FormRelativeDatePicker } from '@/object-record/record-field/ui/form-types/components/FormRelativeDatePicker'; import { FormSingleRecordPicker } from '@/object-record/record-field/ui/form-types/components/FormSingleRecordPicker'; import { FormTextFieldInput } from '@/object-record/record-field/ui/form-types/components/FormTextFieldInput'; import { type FieldMetadata } from '@/object-record/record-field/ui/types/FieldMetadata'; + import { WorkflowStepFilterValueCompositeInput } from '@/workflow/workflow-steps/workflow-actions/filter-action/components/WorkflowStepFilterValueCompositeInput'; import { useGetFilterFieldMetadataItem } from '@/workflow/workflow-steps/workflow-actions/filter-action/hooks/useGetFilterFieldMetadataItem'; import { useUpsertStepFilterSettings } from '@/workflow/workflow-steps/workflow-actions/filter-action/hooks/useUpsertStepFilterSettings'; @@ -12,7 +14,11 @@ import { WorkflowVariablePicker } from '@/workflow/workflow-variables/components import { useLingui } from '@lingui/react/macro'; import { isObject, isString } from '@sniptt/guards'; import { useContext } from 'react'; -import { FieldMetadataType, type StepFilter } from 'twenty-shared/types'; +import { + FieldMetadataType, + ViewFilterOperand, + type StepFilter, +} from 'twenty-shared/types'; import { isDefined } from 'twenty-shared/utils'; import { type JsonValue } from 'type-fest'; @@ -105,6 +111,10 @@ export const WorkflowStepFilterValueInput = ({ selectedFieldMetadataItem?.name === 'id' && isDefined(objectMetadataItem?.nameSingular); + const isDateField = + variableType === FieldMetadataType.DATE_TIME || + variableType === FieldMetadataType.DATE; + if (isFullRecord) { return ( + ); + } + const field = { type: variableType as FieldMetadataType, label: '', diff --git a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/filter-action/utils/getStepFilterOperands.ts b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/filter-action/utils/getStepFilterOperands.ts index 9c4b913ffd6..f48d7d58f67 100644 --- a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/filter-action/utils/getStepFilterOperands.ts +++ b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/filter-action/utils/getStepFilterOperands.ts @@ -43,15 +43,7 @@ export const FILTER_OPERANDS_MAP = { ViewFilterOperand.IsToday, ViewFilterOperand.IsBefore, ViewFilterOperand.IsAfter, - ...emptyOperands, - ], - DATE: [ - ViewFilterOperand.Is, - ViewFilterOperand.IsInPast, - ViewFilterOperand.IsInFuture, - ViewFilterOperand.IsToday, - ViewFilterOperand.IsBefore, - ViewFilterOperand.IsAfter, + ViewFilterOperand.IsRelative, ...emptyOperands, ], RATING: [ViewFilterOperand.Is, ViewFilterOperand.IsNot, ...emptyOperands], diff --git a/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/filter/utils/__tests__/evaluate-relative-date-filter.util.spec.ts b/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/filter/utils/__tests__/evaluate-relative-date-filter.util.spec.ts new file mode 100644 index 00000000000..28272621e4e --- /dev/null +++ b/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/filter/utils/__tests__/evaluate-relative-date-filter.util.spec.ts @@ -0,0 +1,590 @@ +import { + addDays, + addMonths, + addWeeks, + addYears, + subDays, + subMonths, + subWeeks, + subYears, +} from 'date-fns'; +import { type VariableDateViewFilterValue } from 'twenty-shared/types'; + +import { + evaluateRelativeDateFilter, + parseAndEvaluateRelativeDateFilter, +} from 'src/modules/workflow/workflow-executor/workflow-actions/filter/utils/parse-and-evaluate-relative-date-filter.util'; + +describe('Relative Date Filter Utils', () => { + const now = new Date('2024-01-15T12:00:00Z'); // Monday, January 15, 2024 at noon + + beforeEach(() => { + // Mock Date constructor to return a fixed date for consistent testing + jest.useFakeTimers(); + jest.setSystemTime(now); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + describe('parseAndEvaluateRelativeDateFilter', () => { + describe('Edge cases', () => { + it('should handle invalid JSON gracefully', () => { + const invalidJson = 'invalid json'; + + expect( + parseAndEvaluateRelativeDateFilter({ + dateToCheck: now, + relativeDateString: invalidJson, + }), + ).toBe(false); + }); + + it('should handle missing direction gracefully', () => { + const relativeDateString = JSON.stringify({ + amount: 1, + unit: 'DAY', + }); + + expect( + parseAndEvaluateRelativeDateFilter({ + dateToCheck: now, + relativeDateString, + }), + ).toBe(false); + }); + + it('should handle missing unit gracefully', () => { + const relativeDateString = JSON.stringify({ + direction: 'NEXT', + amount: 1, + }); + + expect( + parseAndEvaluateRelativeDateFilter({ + dateToCheck: now, + relativeDateString, + }), + ).toBe(false); + }); + + it('should handle unknown direction gracefully', () => { + const relativeDateString = JSON.stringify({ + direction: 'UNKNOWN', + amount: 1, + unit: 'DAY', + }); + + expect( + parseAndEvaluateRelativeDateFilter({ + dateToCheck: now, + relativeDateString, + }), + ).toBe(false); + }); + + it('should handle unknown unit gracefully', () => { + const relativeDateString = JSON.stringify({ + direction: 'NEXT', + amount: 1, + unit: 'UNKNOWN', + }); + + expect( + parseAndEvaluateRelativeDateFilter({ + dateToCheck: now, + relativeDateString, + }), + ).toBe(false); + }); + }); + }); + + describe('evaluateRelativeDateFilter', () => { + describe('NEXT direction', () => { + it('should return true for dates within the next N days', () => { + const relativeDateFilterValue: VariableDateViewFilterValue = { + direction: 'NEXT', + amount: 3, + unit: 'DAY', + }; + + // Dates within the next 3 days should match + expect( + evaluateRelativeDateFilter({ + dateToCheck: addDays(now, 1), + relativeDateFilterValue, + }), + ).toBe(true); + expect( + evaluateRelativeDateFilter({ + dateToCheck: addDays(now, 2), + relativeDateFilterValue, + }), + ).toBe(true); + expect( + evaluateRelativeDateFilter({ + dateToCheck: addDays(now, 3), + relativeDateFilterValue, + }), + ).toBe(true); + + // Dates outside the range should not match + expect( + evaluateRelativeDateFilter({ + dateToCheck: addDays(now, 4), + relativeDateFilterValue, + }), + ).toBe(false); + expect( + evaluateRelativeDateFilter({ + dateToCheck: subDays(now, 1), + relativeDateFilterValue, + }), + ).toBe(false); + }); + + it('should return true for dates within the next N weeks', () => { + const relativeDateFilterValue: VariableDateViewFilterValue = { + direction: 'NEXT', + amount: 2, + unit: 'WEEK', + }; + + // Dates within the next 2 weeks should match + expect( + evaluateRelativeDateFilter({ + dateToCheck: addWeeks(now, 1), + relativeDateFilterValue, + }), + ).toBe(true); + expect( + evaluateRelativeDateFilter({ + dateToCheck: addDays(now, 10), + relativeDateFilterValue, + }), + ).toBe(true); + + // Dates outside the range should not match + expect( + evaluateRelativeDateFilter({ + dateToCheck: addWeeks(now, 3), + relativeDateFilterValue, + }), + ).toBe(false); + expect( + evaluateRelativeDateFilter({ + dateToCheck: subDays(now, 1), + relativeDateFilterValue, + }), + ).toBe(false); + }); + + it('should return true for dates within the next N months', () => { + const relativeDateFilterValue: VariableDateViewFilterValue = { + direction: 'NEXT', + amount: 2, + unit: 'MONTH', + }; + + // Dates within the next 2 months should match + expect( + evaluateRelativeDateFilter({ + dateToCheck: addMonths(now, 1), + relativeDateFilterValue, + }), + ).toBe(true); + expect( + evaluateRelativeDateFilter({ + dateToCheck: addDays(now, 45), + relativeDateFilterValue, + }), + ).toBe(true); + + // Dates outside the range should not match + expect( + evaluateRelativeDateFilter({ + dateToCheck: addMonths(now, 3), + relativeDateFilterValue, + }), + ).toBe(false); + expect( + evaluateRelativeDateFilter({ + dateToCheck: subDays(now, 1), + relativeDateFilterValue, + }), + ).toBe(false); + }); + + it('should return true for dates within the next N years', () => { + const relativeDateFilterValue: VariableDateViewFilterValue = { + direction: 'NEXT', + amount: 1, + unit: 'YEAR', + }; + + // Dates within the next year should match + expect( + evaluateRelativeDateFilter({ + dateToCheck: addMonths(now, 6), + relativeDateFilterValue, + }), + ).toBe(true); + expect( + evaluateRelativeDateFilter({ + dateToCheck: addMonths(now, 11), + relativeDateFilterValue, + }), + ).toBe(true); + + // Dates outside the range should not match + expect( + evaluateRelativeDateFilter({ + dateToCheck: addYears(now, 2), + relativeDateFilterValue, + }), + ).toBe(false); + expect( + evaluateRelativeDateFilter({ + dateToCheck: subDays(now, 1), + relativeDateFilterValue, + }), + ).toBe(false); + }); + + it('should return false when amount is undefined', () => { + const relativeDateFilterValue: VariableDateViewFilterValue = { + direction: 'NEXT', + unit: 'DAY', + }; + + expect( + evaluateRelativeDateFilter({ + dateToCheck: addDays(now, 1), + relativeDateFilterValue, + }), + ).toBe(false); + }); + }); + + describe('PAST direction', () => { + it('should return true for dates within the past N days', () => { + const relativeDateFilterValue: VariableDateViewFilterValue = { + direction: 'PAST', + amount: 3, + unit: 'DAY', + }; + + // Dates within the past 3 days should match + expect( + evaluateRelativeDateFilter({ + dateToCheck: subDays(now, 1), + relativeDateFilterValue, + }), + ).toBe(true); + expect( + evaluateRelativeDateFilter({ + dateToCheck: subDays(now, 2), + relativeDateFilterValue, + }), + ).toBe(true); + expect( + evaluateRelativeDateFilter({ + dateToCheck: subDays(now, 3), + relativeDateFilterValue, + }), + ).toBe(true); + + // Dates outside the range should not match + expect( + evaluateRelativeDateFilter({ + dateToCheck: subDays(now, 4), + relativeDateFilterValue, + }), + ).toBe(false); + expect( + evaluateRelativeDateFilter({ + dateToCheck: addDays(now, 1), + relativeDateFilterValue, + }), + ).toBe(false); + }); + + it('should return true for dates within the past N weeks', () => { + const relativeDateFilterValue: VariableDateViewFilterValue = { + direction: 'PAST', + amount: 2, + unit: 'WEEK', + }; + + // Dates within the past 2 weeks should match + expect( + evaluateRelativeDateFilter({ + dateToCheck: subWeeks(now, 1), + relativeDateFilterValue, + }), + ).toBe(true); + expect( + evaluateRelativeDateFilter({ + dateToCheck: subDays(now, 10), + relativeDateFilterValue, + }), + ).toBe(true); + + // Dates outside the range should not match + expect( + evaluateRelativeDateFilter({ + dateToCheck: subWeeks(now, 3), + relativeDateFilterValue, + }), + ).toBe(false); + expect( + evaluateRelativeDateFilter({ + dateToCheck: addDays(now, 1), + relativeDateFilterValue, + }), + ).toBe(false); + }); + + it('should return true for dates within the past N months', () => { + const relativeDateFilterValue: VariableDateViewFilterValue = { + direction: 'PAST', + amount: 2, + unit: 'MONTH', + }; + + // Dates within the past 2 months should match + expect( + evaluateRelativeDateFilter({ + dateToCheck: subMonths(now, 1), + relativeDateFilterValue, + }), + ).toBe(true); + expect( + evaluateRelativeDateFilter({ + dateToCheck: subDays(now, 45), + relativeDateFilterValue, + }), + ).toBe(true); + + // Dates outside the range should not match + expect( + evaluateRelativeDateFilter({ + dateToCheck: subMonths(now, 3), + relativeDateFilterValue, + }), + ).toBe(false); + expect( + evaluateRelativeDateFilter({ + dateToCheck: addDays(now, 1), + relativeDateFilterValue, + }), + ).toBe(false); + }); + + it('should return true for dates within the past N years', () => { + const relativeDateFilterValue: VariableDateViewFilterValue = { + direction: 'PAST', + amount: 1, + unit: 'YEAR', + }; + + // Dates within the past year should match + expect( + evaluateRelativeDateFilter({ + dateToCheck: subMonths(now, 6), + relativeDateFilterValue, + }), + ).toBe(true); + expect( + evaluateRelativeDateFilter({ + dateToCheck: subMonths(now, 11), + relativeDateFilterValue, + }), + ).toBe(true); + + // Dates outside the range should not match + expect( + evaluateRelativeDateFilter({ + dateToCheck: subYears(now, 2), + relativeDateFilterValue, + }), + ).toBe(false); + expect( + evaluateRelativeDateFilter({ + dateToCheck: addDays(now, 1), + relativeDateFilterValue, + }), + ).toBe(false); + }); + + it('should return false when amount is undefined', () => { + const relativeDateFilterValue: VariableDateViewFilterValue = { + direction: 'PAST', + unit: 'DAY', + }; + + expect( + evaluateRelativeDateFilter({ + dateToCheck: subDays(now, 1), + relativeDateFilterValue, + }), + ).toBe(false); + }); + }); + + describe('THIS direction', () => { + it('should return true for dates within this day', () => { + const relativeDateFilterValue: VariableDateViewFilterValue = { + direction: 'THIS', + unit: 'DAY', + }; + + // Same day should match + expect( + evaluateRelativeDateFilter({ + dateToCheck: now, + relativeDateFilterValue, + }), + ).toBe(true); + expect( + evaluateRelativeDateFilter({ + dateToCheck: new Date('2024-01-15T08:00:00Z'), + relativeDateFilterValue, + }), + ).toBe(true); + + // Different days should not match + expect( + evaluateRelativeDateFilter({ + dateToCheck: new Date('2024-01-14T20:00:00Z'), + relativeDateFilterValue, + }), + ).toBe(false); + }); + + it('should return true for dates within this week', () => { + const relativeDateFilterValue: VariableDateViewFilterValue = { + direction: 'THIS', + unit: 'WEEK', + }; + + expect( + evaluateRelativeDateFilter({ + dateToCheck: now, + relativeDateFilterValue, + }), + ).toBe(true); + expect( + evaluateRelativeDateFilter({ + dateToCheck: new Date('2024-01-14T12:00:00Z'), + relativeDateFilterValue, + }), + ).toBe(true); + expect( + evaluateRelativeDateFilter({ + dateToCheck: new Date('2024-01-20T12:00:00Z'), + relativeDateFilterValue, + }), + ).toBe(true); + + // Different weeks should not match + expect( + evaluateRelativeDateFilter({ + dateToCheck: new Date('2024-01-13T12:00:00Z'), + relativeDateFilterValue, + }), + ).toBe(false); + expect( + evaluateRelativeDateFilter({ + dateToCheck: new Date('2024-01-21T12:00:00Z'), + relativeDateFilterValue, + }), + ).toBe(false); + }); + + it('should return true for dates within this month', () => { + const relativeDateFilterValue: VariableDateViewFilterValue = { + direction: 'THIS', + unit: 'MONTH', + }; + + // Same month should match + expect( + evaluateRelativeDateFilter({ + dateToCheck: now, + relativeDateFilterValue, + }), + ).toBe(true); + expect( + evaluateRelativeDateFilter({ + dateToCheck: new Date('2024-01-01T12:00:00Z'), + relativeDateFilterValue, + }), + ).toBe(true); + expect( + evaluateRelativeDateFilter({ + dateToCheck: new Date('2024-01-31T12:00:00Z'), + relativeDateFilterValue, + }), + ).toBe(true); + + // Different months should not match + expect( + evaluateRelativeDateFilter({ + dateToCheck: new Date('2023-12-31T12:00:00Z'), + relativeDateFilterValue, + }), + ).toBe(false); + expect( + evaluateRelativeDateFilter({ + dateToCheck: new Date('2024-02-01T12:00:00Z'), + relativeDateFilterValue, + }), + ).toBe(false); + }); + + it('should return true for dates within this year', () => { + const relativeDateFilterValue: VariableDateViewFilterValue = { + direction: 'THIS', + unit: 'YEAR', + }; + + // Same year should match + expect( + evaluateRelativeDateFilter({ + dateToCheck: now, + relativeDateFilterValue, + }), + ).toBe(true); + expect( + evaluateRelativeDateFilter({ + dateToCheck: new Date('2024-01-01T12:00:00Z'), + relativeDateFilterValue, + }), + ).toBe(true); + expect( + evaluateRelativeDateFilter({ + dateToCheck: new Date('2024-12-31T12:00:00Z'), + relativeDateFilterValue, + }), + ).toBe(true); + + // Different years should not match + expect( + evaluateRelativeDateFilter({ + dateToCheck: new Date('2023-12-31T12:00:00Z'), + relativeDateFilterValue, + }), + ).toBe(false); + expect( + evaluateRelativeDateFilter({ + dateToCheck: new Date('2025-01-01T12:00:00Z'), + relativeDateFilterValue, + }), + ).toBe(false); + }); + }); + }); +}); diff --git a/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/filter/utils/evaluate-filter-conditions.util.ts b/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/filter/utils/evaluate-filter-conditions.util.ts index d74ad030868..8638372bd2a 100644 --- a/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/filter/utils/evaluate-filter-conditions.util.ts +++ b/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/filter/utils/evaluate-filter-conditions.util.ts @@ -5,6 +5,8 @@ import { ViewFilterOperand, } from 'twenty-shared/types'; +import { parseAndEvaluateRelativeDateFilter } from 'src/modules/workflow/workflow-executor/workflow-actions/filter/utils/parse-and-evaluate-relative-date-filter.util'; + type ResolvedFilter = Omit & { rightOperand: unknown; leftOperand: unknown; @@ -153,11 +155,13 @@ function evaluateBooleanFilter(filter: ResolvedFilter): boolean { function evaluateDateFilter(filter: ResolvedFilter): boolean { const dateLeftValue = new Date(String(filter.leftOperand)); - const dateRightValue = new Date(String(filter.rightOperand)); switch (filter.operand) { case ViewFilterOperand.Is: - return dateLeftValue.getDate() === dateRightValue.getDate(); + return ( + dateLeftValue.getDate() === + new Date(String(filter.rightOperand)).getDate() + ); case ViewFilterOperand.IsInPast: return dateLeftValue.getTime() < Date.now(); @@ -168,10 +172,16 @@ function evaluateDateFilter(filter: ResolvedFilter): boolean { return dateLeftValue.toDateString() === new Date().toDateString(); case ViewFilterOperand.IsBefore: - return dateLeftValue.getTime() < dateRightValue.getTime(); + return ( + dateLeftValue.getTime() < + new Date(String(filter.rightOperand)).getTime() + ); case ViewFilterOperand.IsAfter: - return dateLeftValue.getTime() > dateRightValue.getTime(); + return ( + dateLeftValue.getTime() > + new Date(String(filter.rightOperand)).getTime() + ); case ViewFilterOperand.IsEmpty: return ( @@ -187,6 +197,12 @@ function evaluateDateFilter(filter: ResolvedFilter): boolean { filter.leftOperand !== '' ); + case ViewFilterOperand.IsRelative: + return parseAndEvaluateRelativeDateFilter({ + dateToCheck: dateLeftValue, + relativeDateString: String(filter.rightOperand), + }); + default: throw new Error( `Operand ${filter.operand} not supported for date filter`, diff --git a/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/filter/utils/parse-and-evaluate-relative-date-filter.util.ts b/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/filter/utils/parse-and-evaluate-relative-date-filter.util.ts new file mode 100644 index 00000000000..5a4419df5e8 --- /dev/null +++ b/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/filter/utils/parse-and-evaluate-relative-date-filter.util.ts @@ -0,0 +1,182 @@ +import { + addDays, + addMonths, + addWeeks, + addYears, + endOfDay, + endOfMonth, + endOfWeek, + endOfYear, + isWithinInterval, + startOfDay, + startOfMonth, + startOfWeek, + startOfYear, + subDays, + subMonths, + subWeeks, + subYears, +} from 'date-fns'; +import { + type VariableDateViewFilterValue, + type VariableDateViewFilterValueUnit, +} from 'twenty-shared/types'; +import { safeParseRelativeDateFilterValue } from 'twenty-shared/utils'; + +export const parseAndEvaluateRelativeDateFilter = ({ + dateToCheck, + relativeDateString, +}: { + dateToCheck: Date; + relativeDateString: string; +}): boolean => { + const relativeDateFilterValue = + safeParseRelativeDateFilterValue(relativeDateString); + + if (!relativeDateFilterValue) { + return false; + } + + return evaluateRelativeDateFilter({ + dateToCheck, + relativeDateFilterValue, + }); +}; + +export const evaluateRelativeDateFilter = ({ + dateToCheck, + relativeDateFilterValue, +}: { + dateToCheck: Date; + relativeDateFilterValue: VariableDateViewFilterValue; +}): boolean => { + const now = new Date(); + + switch (relativeDateFilterValue.direction) { + case 'NEXT': + return evaluateNextDirection(dateToCheck, relativeDateFilterValue, now); + case 'THIS': + return evaluateThisDirection(dateToCheck, relativeDateFilterValue, now); + case 'PAST': + return evaluatePastDirection(dateToCheck, relativeDateFilterValue, now); + default: + return false; + } +}; + +const evaluateNextDirection = ( + dateToCheck: Date, + relativeDateFilterValue: VariableDateViewFilterValue, + now: Date, +): boolean => { + if (relativeDateFilterValue.amount === undefined) { + return false; + } + + const { amount, unit } = relativeDateFilterValue; + + const endOfPeriod = addUnitToDate(now, amount, unit); + + if (!endOfPeriod) { + return false; + } + + return isWithinInterval(dateToCheck, { + start: now, + end: endOfPeriod, + }); +}; + +function evaluatePastDirection( + dateToCheck: Date, + relativeDateFilterValue: VariableDateViewFilterValue, + now: Date, +): boolean { + if (relativeDateFilterValue.amount === undefined) { + return false; + } + + const { amount, unit } = relativeDateFilterValue; + + const startOfPeriod = subtractUnitFromDate(now, amount, unit); + + if (!startOfPeriod) { + return false; + } + + return isWithinInterval(dateToCheck, { + start: startOfPeriod, + end: now, + }); +} + +function evaluateThisDirection( + dateToCheck: Date, + relativeDateValue: VariableDateViewFilterValue, + now: Date, +): boolean { + const { unit } = relativeDateValue; + + switch (unit) { + case 'DAY': + return isWithinInterval(dateToCheck, { + start: startOfDay(now), + end: endOfDay(now), + }); + case 'WEEK': + return isWithinInterval(dateToCheck, { + start: startOfWeek(now), + end: endOfWeek(now), + }); + case 'MONTH': + return isWithinInterval(dateToCheck, { + start: startOfMonth(now), + end: endOfMonth(now), + }); + case 'YEAR': + return isWithinInterval(dateToCheck, { + start: startOfYear(now), + end: endOfYear(now), + }); + default: + return false; + } +} + +function addUnitToDate( + date: Date, + amount: number, + unit: VariableDateViewFilterValueUnit, +): Date | null { + 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); + default: + return null; + } +} + +function subtractUnitFromDate( + date: Date, + amount: number, + unit: VariableDateViewFilterValueUnit, +): Date | null { + 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); + default: + return null; + } +} diff --git a/packages/twenty-shared/src/types/RelativeDateValue.ts b/packages/twenty-shared/src/types/RelativeDateValue.ts new file mode 100644 index 00000000000..655eb27dd49 --- /dev/null +++ b/packages/twenty-shared/src/types/RelativeDateValue.ts @@ -0,0 +1,15 @@ +export type VariableDateViewFilterValueDirection = 'NEXT' | 'THIS' | 'PAST'; + +export type VariableDateViewFilterValueUnit = 'DAY' | 'WEEK' | 'MONTH' | 'YEAR'; + +export type VariableDateViewFilterValue = { + direction: VariableDateViewFilterValueDirection; + amount?: number; + unit: VariableDateViewFilterValueUnit; +}; + +export const DEFAULT_RELATIVE_DATE_VALUE: VariableDateViewFilterValue = { + direction: 'THIS', + unit: 'DAY', + amount: undefined, +}; diff --git a/packages/twenty-shared/src/types/index.ts b/packages/twenty-shared/src/types/index.ts index 29ac2191e7f..8d40d8acb54 100644 --- a/packages/twenty-shared/src/types/index.ts +++ b/packages/twenty-shared/src/types/index.ts @@ -24,6 +24,12 @@ export type { ObjectsPermissionsByRoleId } from './ObjectsPermissionsByRoleId'; export type { ObjectsPermissionsByRoleIdDeprecated } from './ObjectsPermissionsByRoleIdDeprecated'; export type { ObjectsPermissionsDeprecated } from './ObjectsPermissionsDeprecated'; export type { RelationAndMorphRelationFieldMetadataType } from './RelationAndMorphRelationFieldMetadataType'; +export type { + VariableDateViewFilterValueDirection, + VariableDateViewFilterValueUnit, + VariableDateViewFilterValue, +} from './RelativeDateValue'; +export { DEFAULT_RELATIVE_DATE_VALUE } from './RelativeDateValue'; export type { RestrictedFieldPermissions } from './RestrictedFieldPermissions'; export type { RestrictedFieldsPermissions } from './RestrictedFieldsPermissions'; export type { StepFilterGroup, StepFilter } from './StepFilters'; diff --git a/packages/twenty-shared/src/utils/__tests__/safeParseRelativeDateFilterValue.test.ts b/packages/twenty-shared/src/utils/__tests__/safeParseRelativeDateFilterValue.test.ts new file mode 100644 index 00000000000..39444f4a9e3 --- /dev/null +++ b/packages/twenty-shared/src/utils/__tests__/safeParseRelativeDateFilterValue.test.ts @@ -0,0 +1,422 @@ +import { safeParseRelativeDateFilterValue } from '../safeParseRelativeDateFilterValue'; + +describe('safeParseRelativeDateFilterValue', () => { + describe('valid inputs', () => { + describe('NEXT direction', () => { + it('should parse NEXT direction with DAY unit', () => { + const input = JSON.stringify({ + direction: 'NEXT', + amount: 3, + unit: 'DAY', + }); + + const result = safeParseRelativeDateFilterValue(input); + + expect(result).toEqual({ + direction: 'NEXT', + amount: 3, + unit: 'DAY', + }); + }); + + it('should parse NEXT direction with WEEK unit', () => { + const input = JSON.stringify({ + direction: 'NEXT', + amount: 2, + unit: 'WEEK', + }); + + const result = safeParseRelativeDateFilterValue(input); + + expect(result).toEqual({ + direction: 'NEXT', + amount: 2, + unit: 'WEEK', + }); + }); + + it('should parse NEXT direction with MONTH unit', () => { + const input = JSON.stringify({ + direction: 'NEXT', + amount: 1, + unit: 'MONTH', + }); + + const result = safeParseRelativeDateFilterValue(input); + + expect(result).toEqual({ + direction: 'NEXT', + amount: 1, + unit: 'MONTH', + }); + }); + + it('should parse NEXT direction with YEAR unit', () => { + const input = JSON.stringify({ + direction: 'NEXT', + amount: 5, + unit: 'YEAR', + }); + + const result = safeParseRelativeDateFilterValue(input); + + expect(result).toEqual({ + direction: 'NEXT', + amount: 5, + unit: 'YEAR', + }); + }); + }); + + describe('PAST direction', () => { + it('should parse PAST direction with DAY unit', () => { + const input = JSON.stringify({ + direction: 'PAST', + amount: 7, + unit: 'DAY', + }); + + const result = safeParseRelativeDateFilterValue(input); + + expect(result).toEqual({ + direction: 'PAST', + amount: 7, + unit: 'DAY', + }); + }); + + it('should parse PAST direction with WEEK unit', () => { + const input = JSON.stringify({ + direction: 'PAST', + amount: 3, + unit: 'WEEK', + }); + + const result = safeParseRelativeDateFilterValue(input); + + expect(result).toEqual({ + direction: 'PAST', + amount: 3, + unit: 'WEEK', + }); + }); + + it('should parse PAST direction with MONTH unit', () => { + const input = JSON.stringify({ + direction: 'PAST', + amount: 6, + unit: 'MONTH', + }); + + const result = safeParseRelativeDateFilterValue(input); + + expect(result).toEqual({ + direction: 'PAST', + amount: 6, + unit: 'MONTH', + }); + }); + + it('should parse PAST direction with YEAR unit', () => { + const input = JSON.stringify({ + direction: 'PAST', + amount: 2, + unit: 'YEAR', + }); + + const result = safeParseRelativeDateFilterValue(input); + + expect(result).toEqual({ + direction: 'PAST', + amount: 2, + unit: 'YEAR', + }); + }); + }); + + describe('THIS direction', () => { + it('should parse THIS direction with DAY unit (no amount)', () => { + const input = JSON.stringify({ + direction: 'THIS', + unit: 'DAY', + }); + + const result = safeParseRelativeDateFilterValue(input); + + expect(result).toEqual({ + direction: 'THIS', + unit: 'DAY', + }); + }); + + it('should parse THIS direction with WEEK unit (no amount)', () => { + const input = JSON.stringify({ + direction: 'THIS', + unit: 'WEEK', + }); + + const result = safeParseRelativeDateFilterValue(input); + + expect(result).toEqual({ + direction: 'THIS', + unit: 'WEEK', + }); + }); + + it('should parse THIS direction with MONTH unit (no amount)', () => { + const input = JSON.stringify({ + direction: 'THIS', + unit: 'MONTH', + }); + + const result = safeParseRelativeDateFilterValue(input); + + expect(result).toEqual({ + direction: 'THIS', + unit: 'MONTH', + }); + }); + + it('should parse THIS direction with YEAR unit (no amount)', () => { + const input = JSON.stringify({ + direction: 'THIS', + unit: 'YEAR', + }); + + const result = safeParseRelativeDateFilterValue(input); + + expect(result).toEqual({ + direction: 'THIS', + unit: 'YEAR', + }); + }); + + it('should parse THIS direction with undefined amount explicitly', () => { + const input = JSON.stringify({ + direction: 'THIS', + unit: 'DAY', + amount: undefined, + }); + + const result = safeParseRelativeDateFilterValue(input); + + expect(result).toEqual({ + direction: 'THIS', + unit: 'DAY', + }); + }); + }); + }); + + describe('invalid inputs', () => { + describe('JSON parsing errors', () => { + it('should return undefined for invalid JSON', () => { + const result = safeParseRelativeDateFilterValue('invalid json'); + expect(result).toBeUndefined(); + }); + + it('should return undefined for empty string', () => { + const result = safeParseRelativeDateFilterValue(''); + expect(result).toBeUndefined(); + }); + + it('should return undefined for unclosed JSON', () => { + const result = safeParseRelativeDateFilterValue('{"direction": "NEXT"'); + expect(result).toBeUndefined(); + }); + }); + + describe('schema validation errors', () => { + it('should return undefined for missing direction', () => { + const input = JSON.stringify({ + amount: 1, + unit: 'DAY', + }); + + const result = safeParseRelativeDateFilterValue(input); + expect(result).toBeUndefined(); + }); + + it('should return undefined for missing unit', () => { + const input = JSON.stringify({ + direction: 'NEXT', + amount: 1, + }); + + const result = safeParseRelativeDateFilterValue(input); + expect(result).toBeUndefined(); + }); + + it('should return undefined for invalid direction', () => { + const input = JSON.stringify({ + direction: 'INVALID', + amount: 1, + unit: 'DAY', + }); + + const result = safeParseRelativeDateFilterValue(input); + expect(result).toBeUndefined(); + }); + + it('should return undefined for invalid unit', () => { + const input = JSON.stringify({ + direction: 'NEXT', + amount: 1, + unit: 'HOUR', + }); + + const result = safeParseRelativeDateFilterValue(input); + expect(result).toBeUndefined(); + }); + + it('should return undefined for NEXT direction without amount', () => { + const input = JSON.stringify({ + direction: 'NEXT', + unit: 'DAY', + }); + + const result = safeParseRelativeDateFilterValue(input); + expect(result).toBeUndefined(); + }); + + it('should return undefined for PAST direction without amount', () => { + const input = JSON.stringify({ + direction: 'PAST', + unit: 'DAY', + }); + + const result = safeParseRelativeDateFilterValue(input); + expect(result).toBeUndefined(); + }); + + it('should return undefined for NEXT direction with zero amount', () => { + const input = JSON.stringify({ + direction: 'NEXT', + amount: 0, + unit: 'DAY', + }); + + const result = safeParseRelativeDateFilterValue(input); + expect(result).toBeUndefined(); + }); + + it('should return undefined for PAST direction with negative amount', () => { + const input = JSON.stringify({ + direction: 'PAST', + amount: -1, + unit: 'DAY', + }); + + const result = safeParseRelativeDateFilterValue(input); + expect(result).toBeUndefined(); + }); + + it('should return undefined for NEXT direction with string amount', () => { + const input = JSON.stringify({ + direction: 'NEXT', + amount: '1', + unit: 'DAY', + }); + + const result = safeParseRelativeDateFilterValue(input); + expect(result).toBeUndefined(); + }); + + it('should return undefined for non-object input', () => { + const result = safeParseRelativeDateFilterValue('"string"'); + expect(result).toBeUndefined(); + }); + + it('should return undefined for array input', () => { + const result = safeParseRelativeDateFilterValue('[1, 2, 3]'); + expect(result).toBeUndefined(); + }); + + it('should return undefined for null input', () => { + const result = safeParseRelativeDateFilterValue('null'); + expect(result).toBeUndefined(); + }); + + it('should return undefined for boolean input', () => { + const result = safeParseRelativeDateFilterValue('true'); + expect(result).toBeUndefined(); + }); + + it('should return undefined for number input', () => { + const result = safeParseRelativeDateFilterValue('123'); + expect(result).toBeUndefined(); + }); + }); + + describe('edge cases', () => { + it('should return undefined for NEXT direction with decimal amount', () => { + const input = JSON.stringify({ + direction: 'NEXT', + amount: 1.5, + unit: 'DAY', + }); + + const result = safeParseRelativeDateFilterValue(input); + expect(result).toEqual({ + direction: 'NEXT', + amount: 1.5, + unit: 'DAY', + }); + }); + + it('should return undefined for object with extra properties', () => { + const input = JSON.stringify({ + direction: 'NEXT', + amount: 1, + unit: 'DAY', + extraProperty: 'should be ignored', + }); + + const result = safeParseRelativeDateFilterValue(input); + expect(result).toEqual({ + direction: 'NEXT', + amount: 1, + unit: 'DAY', + }); + }); + + it('should return undefined for empty object', () => { + const input = JSON.stringify({}); + + const result = safeParseRelativeDateFilterValue(input); + expect(result).toBeUndefined(); + }); + + it('should return undefined for THIS direction with amount', () => { + const input = JSON.stringify({ + direction: 'THIS', + amount: 1, + unit: 'DAY', + }); + + // THIS direction should work with amount present, as the schema allows it + const result = safeParseRelativeDateFilterValue(input); + expect(result).toEqual({ + direction: 'THIS', + amount: 1, + unit: 'DAY', + }); + }); + + it('should handle very large amounts', () => { + const input = JSON.stringify({ + direction: 'NEXT', + amount: 999999, + unit: 'DAY', + }); + + const result = safeParseRelativeDateFilterValue(input); + expect(result).toEqual({ + direction: 'NEXT', + amount: 999999, + unit: 'DAY', + }); + }); + }); + }); +}); \ No newline at end of file diff --git a/packages/twenty-shared/src/utils/index.ts b/packages/twenty-shared/src/utils/index.ts index f7bd03d0d9d..86448ed42e8 100644 --- a/packages/twenty-shared/src/utils/index.ts +++ b/packages/twenty-shared/src/utils/index.ts @@ -23,6 +23,7 @@ export { getUniqueConstraintsFields } from './indexMetadata/getUniqueConstraints export { parseJson } from './parseJson'; export { removePropertiesFromRecord } from './removePropertiesFromRecord'; export { removeUndefinedFields } from './removeUndefinedFields'; +export { safeParseRelativeDateFilterValue } from './safeParseRelativeDateFilterValue'; export { getGenericOperationName } from './sentry/getGenericOperationName'; export { getHumanReadableNameFromCode } from './sentry/getHumanReadableNameFromCode'; export { capitalize } from './strings/capitalize'; diff --git a/packages/twenty-shared/src/utils/safeParseRelativeDateFilterValue.ts b/packages/twenty-shared/src/utils/safeParseRelativeDateFilterValue.ts new file mode 100644 index 00000000000..88e445a33b1 --- /dev/null +++ b/packages/twenty-shared/src/utils/safeParseRelativeDateFilterValue.ts @@ -0,0 +1,37 @@ +import { + type VariableDateViewFilterValue, + type VariableDateViewFilterValueDirection, + type VariableDateViewFilterValueUnit, +} from '@/types/RelativeDateValue'; +import { z } from 'zod'; + +const RelativeDateValueSchema = z.object({ + direction: z.enum(['NEXT', 'THIS', 'PAST'] as const) as z.ZodType, + unit: z.enum(['DAY', 'WEEK', 'MONTH', 'YEAR'] as const) as z.ZodType, + amount: z.number().positive().optional(), +}).refine((data) => { + if (data.direction === 'NEXT' || data.direction === 'PAST') { + return data.amount !== undefined && data.amount > 0; + } + return true; +}, { + message: 'Amount is required for NEXT and PAST directions and must be positive', +}); + +export const safeParseRelativeDateFilterValue = ( + value: string, +): VariableDateViewFilterValue | undefined => { + try { + const parsedJson = JSON.parse(value); + + const result = RelativeDateValueSchema.safeParse(parsedJson); + + if (result.success) { + return result.data; + } + + return undefined; + } catch { + return undefined; + } +}