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:
Thomas Trompette
2025-08-18 14:12:22 +02:00
committed by GitHub
parent 483c1e2214
commit 578a2f4e6b
21 changed files with 1366 additions and 36 deletions
@@ -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;
}
}