Handle relative date step filter (#13930)
https://github.com/user-attachments/assets/2fbb02dd-2170-4807-a1dd-faa3f374bd5a - move relative date types to twenty-shared - use and adapt existing relative date picker to be used in workflow forms - add backend logic to support relative dates in filters
This commit is contained in:
+4
-4
@@ -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';
|
||||
|
||||
|
||||
+1
-1
@@ -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 = () => {
|
||||
|
||||
+2
-2
@@ -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 = (
|
||||
|
||||
+43
@@ -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 (
|
||||
<RelativeDatePickerHeader
|
||||
onChange={handleValueChange}
|
||||
direction={value?.direction ?? 'THIS'}
|
||||
unit={value?.unit ?? 'DAY'}
|
||||
amount={value?.amount}
|
||||
isFormField={true}
|
||||
readonly={readonly}
|
||||
unitDropdownWidth={150}
|
||||
/>
|
||||
);
|
||||
};
|
||||
+4
-4
@@ -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,
|
||||
|
||||
+13
-7
@@ -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 (
|
||||
<StyledContainer>
|
||||
<StyledContainer noPadding={props.isFormField ?? false}>
|
||||
<Select
|
||||
dropdownId="direction-select"
|
||||
value={direction}
|
||||
@@ -70,6 +73,7 @@ export const RelativeDatePickerHeader = (
|
||||
}}
|
||||
options={RELATIVE_DATE_DIRECTION_SELECT_OPTIONS}
|
||||
fullWidth
|
||||
disabled={props.readonly}
|
||||
/>
|
||||
<SettingsTextInput
|
||||
instanceId="relative-date-picker-amount"
|
||||
@@ -94,7 +98,7 @@ export const RelativeDatePickerHeader = (
|
||||
}
|
||||
}}
|
||||
placeholder={textInputPlaceholder}
|
||||
disabled={direction === 'THIS'}
|
||||
disabled={direction === 'THIS' || props.readonly}
|
||||
/>
|
||||
<Select
|
||||
dropdownId="unit-select"
|
||||
@@ -108,8 +112,10 @@ export const RelativeDatePickerHeader = (
|
||||
unit: newUnit,
|
||||
});
|
||||
}}
|
||||
options={unitSelectOptions}
|
||||
fullWidth
|
||||
options={unitSelectOptions}
|
||||
disabled={props.readonly}
|
||||
dropdownWidth={props.unitDropdownWidth}
|
||||
/>
|
||||
</StyledContainer>
|
||||
);
|
||||
|
||||
+1
-1
@@ -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;
|
||||
|
||||
+1
-1
@@ -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;
|
||||
|
||||
+1
-1
@@ -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,
|
||||
|
||||
-1
@@ -19,7 +19,6 @@ import {
|
||||
subYears,
|
||||
} from 'date-fns';
|
||||
import { ViewFilterOperand } from 'twenty-shared/types';
|
||||
|
||||
import { z } from 'zod';
|
||||
|
||||
const variableDateViewFilterValueDirectionSchema = z.enum([
|
||||
|
||||
+1
@@ -33,6 +33,7 @@ export const WorkflowStepFilterOperandSelect = ({
|
||||
stepFilterToUpsert: {
|
||||
...stepFilter,
|
||||
operand,
|
||||
value: '',
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
+21
-1
@@ -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 (
|
||||
<FormSingleRecordPicker
|
||||
@@ -157,6 +167,16 @@ export const WorkflowStepFilterValueInput = ({
|
||||
);
|
||||
}
|
||||
|
||||
if (isDateField && stepFilter.operand === ViewFilterOperand.IsRelative) {
|
||||
return (
|
||||
<FormRelativeDatePicker
|
||||
defaultValue={stepFilter.value}
|
||||
onChange={handleValueChange}
|
||||
readonly={readonly}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const field = {
|
||||
type: variableType as FieldMetadataType,
|
||||
label: '',
|
||||
|
||||
+1
-9
@@ -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],
|
||||
|
||||
+590
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
+20
-4
@@ -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<StepFilter, 'value' | 'stepOutputKey'> & {
|
||||
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`,
|
||||
|
||||
+182
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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';
|
||||
|
||||
@@ -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',
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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';
|
||||
|
||||
@@ -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<VariableDateViewFilterValueDirection>,
|
||||
unit: z.enum(['DAY', 'WEEK', 'MONTH', 'YEAR'] as const) as z.ZodType<VariableDateViewFilterValueUnit>,
|
||||
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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user