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:
@@ -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