part 4 of filter/sort drilldown onChartDatum click (#16142)

This commit is contained in:
nitin
2025-11-27 22:33:10 +05:30
committed by GitHub
parent d4b3a8978d
commit 32a876bbd4
10 changed files with 629 additions and 4 deletions
@@ -0,0 +1,80 @@
import { ViewFilterOperand } from 'twenty-shared/types';
import { FieldMetadataType } from '~/generated-metadata/graphql';
import { buildDateFilterForDayGranularity } from '../buildDateFilterForDayGranularity';
describe('buildDateFilterForDayGranularity', () => {
describe('DATE field type', () => {
it('should return single IS filter with plain date for DATE field', () => {
const date = new Date('2024-03-15T10:00:00Z');
const result = buildDateFilterForDayGranularity(
date,
FieldMetadataType.DATE,
'createdAt',
);
expect(result).toHaveLength(1);
expect(result[0].fieldName).toBe('createdAt');
expect(result[0].operand).toBe(ViewFilterOperand.IS);
expect(result[0].value).toMatch(/^\d{4}-\d{2}-\d{2}$/);
});
});
describe('DATE_TIME field type', () => {
it('should return IS_AFTER and IS_BEFORE filters for DATE_TIME field', () => {
const date = new Date('2024-03-15T10:00:00Z');
const result = buildDateFilterForDayGranularity(
date,
FieldMetadataType.DATE_TIME,
'createdAt',
);
expect(result).toHaveLength(2);
expect(result[0].operand).toBe(ViewFilterOperand.IS_AFTER);
expect(result[1].operand).toBe(ViewFilterOperand.IS_BEFORE);
expect(result[0].value).toMatch(/^\d{4}-\d{2}-\d{2}T/);
expect(result[1].value).toMatch(/^\d{4}-\d{2}-\d{2}T/);
});
it('should apply timezone for DATE_TIME field when provided', () => {
const date = new Date('2024-03-15T10:00:00Z');
const timezone = 'America/New_York';
const result = buildDateFilterForDayGranularity(
date,
FieldMetadataType.DATE_TIME,
'createdAt',
timezone,
);
expect(result).toHaveLength(2);
expect(result[0].operand).toBe(ViewFilterOperand.IS_AFTER);
expect(result[1].operand).toBe(ViewFilterOperand.IS_BEFORE);
});
});
describe('unsupported field types', () => {
it('should return empty array for non-date field types', () => {
const date = new Date('2024-03-15T10:00:00Z');
const result = buildDateFilterForDayGranularity(
date,
FieldMetadataType.TEXT as FieldMetadataType.DATE,
'name',
);
expect(result).toEqual([]);
});
});
describe('field name handling', () => {
it('should use provided fieldName in filters', () => {
const date = new Date('2024-03-15');
const result = buildDateFilterForDayGranularity(
date,
FieldMetadataType.DATE,
'customFieldName',
);
expect(result[0].fieldName).toBe('customFieldName');
});
});
});
@@ -0,0 +1,200 @@
import {
ObjectRecordGroupByDateGranularity,
ViewFilterOperand,
} from 'twenty-shared/types';
import { FieldMetadataType } from '~/generated-metadata/graphql';
import { buildDateRangeFiltersForGranularity } from '../buildDateRangeFiltersForGranularity';
describe('buildDateRangeFiltersForGranularity', () => {
describe('WEEK granularity', () => {
it('should return IS_AFTER and IS_BEFORE filters for DATE field', () => {
const testDate = new Date('2024-03-15');
const result = buildDateRangeFiltersForGranularity(
testDate,
ObjectRecordGroupByDateGranularity.WEEK,
FieldMetadataType.DATE,
'createdAt',
);
expect(result).toHaveLength(2);
expect(result[0].operand).toBe(ViewFilterOperand.IS_AFTER);
expect(result[1].operand).toBe(ViewFilterOperand.IS_BEFORE);
});
it('should return ISO strings for DATE_TIME field', () => {
const testDate = new Date('2024-03-15T10:00:00Z');
const result = buildDateRangeFiltersForGranularity(
testDate,
ObjectRecordGroupByDateGranularity.WEEK,
FieldMetadataType.DATE_TIME,
'createdAt',
);
expect(result).toHaveLength(2);
expect(result[0].value).toMatch(/^\d{4}-\d{2}-\d{2}T/);
expect(result[1].value).toMatch(/^\d{4}-\d{2}-\d{2}T/);
});
});
describe('MONTH granularity', () => {
it('should return IS_AFTER and IS_BEFORE filters for DATE field', () => {
const testDate = new Date('2024-03-15');
const result = buildDateRangeFiltersForGranularity(
testDate,
ObjectRecordGroupByDateGranularity.MONTH,
FieldMetadataType.DATE,
'createdAt',
);
expect(result).toHaveLength(2);
expect(result[0].operand).toBe(ViewFilterOperand.IS_AFTER);
expect(result[1].operand).toBe(ViewFilterOperand.IS_BEFORE);
});
it('should return ISO strings for DATE_TIME field without timezone', () => {
const testDate = new Date('2024-03-15T10:00:00Z');
const result = buildDateRangeFiltersForGranularity(
testDate,
ObjectRecordGroupByDateGranularity.MONTH,
FieldMetadataType.DATE_TIME,
'createdAt',
);
expect(result).toHaveLength(2);
expect(result[0].operand).toBe(ViewFilterOperand.IS_AFTER);
expect(result[1].operand).toBe(ViewFilterOperand.IS_BEFORE);
expect(result[0].value).toMatch(/^\d{4}-\d{2}-\d{2}T/);
expect(result[1].value).toMatch(/^\d{4}-\d{2}-\d{2}T/);
});
it('should apply timezone for DATE_TIME field when provided', () => {
const testDate = new Date('2024-03-15T10:00:00Z');
const timezone = 'America/New_York';
const result = buildDateRangeFiltersForGranularity(
testDate,
ObjectRecordGroupByDateGranularity.MONTH,
FieldMetadataType.DATE_TIME,
'createdAt',
timezone,
);
expect(result).toHaveLength(2);
expect(result[0].operand).toBe(ViewFilterOperand.IS_AFTER);
expect(result[1].operand).toBe(ViewFilterOperand.IS_BEFORE);
});
});
describe('QUARTER granularity', () => {
it('should return filters for Q1 date', () => {
const testDate = new Date('2024-02-15');
const result = buildDateRangeFiltersForGranularity(
testDate,
ObjectRecordGroupByDateGranularity.QUARTER,
FieldMetadataType.DATE,
'createdAt',
);
expect(result).toHaveLength(2);
expect(result[0].operand).toBe(ViewFilterOperand.IS_AFTER);
expect(result[1].operand).toBe(ViewFilterOperand.IS_BEFORE);
});
it('should return ISO strings for DATE_TIME field', () => {
const testDate = new Date('2024-02-15');
const result = buildDateRangeFiltersForGranularity(
testDate,
ObjectRecordGroupByDateGranularity.QUARTER,
FieldMetadataType.DATE_TIME,
'createdAt',
);
expect(result).toHaveLength(2);
expect(result[0].operand).toBe(ViewFilterOperand.IS_AFTER);
expect(result[1].operand).toBe(ViewFilterOperand.IS_BEFORE);
expect(result[0].value).toMatch(/^\d{4}-\d{2}-\d{2}T/);
expect(result[1].value).toMatch(/^\d{4}-\d{2}-\d{2}T/);
});
});
describe('YEAR granularity', () => {
it('should return start and end of year for DATE field', () => {
const testDate = new Date('2024-06-15');
const result = buildDateRangeFiltersForGranularity(
testDate,
ObjectRecordGroupByDateGranularity.YEAR,
FieldMetadataType.DATE,
'createdAt',
);
expect(result).toHaveLength(2);
expect(result[0].operand).toBe(ViewFilterOperand.IS_AFTER);
expect(result[1].operand).toBe(ViewFilterOperand.IS_BEFORE);
});
it('should return ISO strings for DATE_TIME field', () => {
const testDate = new Date('2024-06-15T10:00:00Z');
const result = buildDateRangeFiltersForGranularity(
testDate,
ObjectRecordGroupByDateGranularity.YEAR,
FieldMetadataType.DATE_TIME,
'createdAt',
);
expect(result).toHaveLength(2);
expect(result[0].value).toMatch(/^2024-01-01T/);
expect(result[1].value).toMatch(/^2024-12-31T/);
});
it('should apply timezone for DATE_TIME field', () => {
const testDate = new Date('2024-06-15T10:00:00Z');
const timezone = 'Asia/Tokyo';
const result = buildDateRangeFiltersForGranularity(
testDate,
ObjectRecordGroupByDateGranularity.YEAR,
FieldMetadataType.DATE_TIME,
'createdAt',
timezone,
);
expect(result).toHaveLength(2);
expect(result[0].operand).toBe(ViewFilterOperand.IS_AFTER);
expect(result[1].operand).toBe(ViewFilterOperand.IS_BEFORE);
});
});
describe('Field name handling', () => {
it('should use provided fieldName in filters', () => {
const testDate = new Date('2024-03-15');
const result = buildDateRangeFiltersForGranularity(
testDate,
ObjectRecordGroupByDateGranularity.MONTH,
FieldMetadataType.DATE,
'customFieldName',
);
expect(result[0].fieldName).toBe('customFieldName');
expect(result[1].fieldName).toBe('customFieldName');
});
it('should handle subField-style fieldName', () => {
const testDate = new Date('2024-03-15');
const result = buildDateRangeFiltersForGranularity(
testDate,
ObjectRecordGroupByDateGranularity.MONTH,
FieldMetadataType.DATE,
'parent.subField',
);
expect(result[0].fieldName).toBe('parent.subField');
expect(result[1].fieldName).toBe('parent.subField');
});
});
});
@@ -0,0 +1,72 @@
import { calculateQuarterDateRange } from '../calculateQuarterDateRange';
describe('calculateQuarterDateRange', () => {
describe('Q1 (January - March)', () => {
it('should return Q1 range for January date', () => {
const date = new Date('2024-01-15');
const result = calculateQuarterDateRange(date);
expect(result.rangeStartDate.getFullYear()).toBe(2024);
expect(result.rangeStartDate.getMonth()).toBe(0);
expect(result.rangeStartDate.getDate()).toBe(1);
expect(result.rangeEndDate.getFullYear()).toBe(2024);
expect(result.rangeEndDate.getMonth()).toBe(2);
expect(result.rangeEndDate.getDate()).toBe(31);
});
it('should return Q1 range for March date', () => {
const date = new Date('2024-03-20');
const result = calculateQuarterDateRange(date);
expect(result.rangeStartDate.getMonth()).toBe(0);
expect(result.rangeEndDate.getMonth()).toBe(2);
});
});
describe('Q2 (April - June)', () => {
it('should return Q2 range for April date', () => {
const date = new Date('2024-04-15');
const result = calculateQuarterDateRange(date);
expect(result.rangeStartDate.getMonth()).toBe(3);
expect(result.rangeStartDate.getDate()).toBe(1);
expect(result.rangeEndDate.getMonth()).toBe(5);
expect(result.rangeEndDate.getDate()).toBe(30);
});
});
describe('Q3 (July - September)', () => {
it('should return Q3 range for August date', () => {
const date = new Date('2024-08-15');
const result = calculateQuarterDateRange(date);
expect(result.rangeStartDate.getMonth()).toBe(6);
expect(result.rangeStartDate.getDate()).toBe(1);
expect(result.rangeEndDate.getMonth()).toBe(8);
expect(result.rangeEndDate.getDate()).toBe(30);
});
});
describe('Q4 (October - December)', () => {
it('should return Q4 range for November date', () => {
const date = new Date('2024-11-15');
const result = calculateQuarterDateRange(date);
expect(result.rangeStartDate.getMonth()).toBe(9);
expect(result.rangeStartDate.getDate()).toBe(1);
expect(result.rangeEndDate.getMonth()).toBe(11);
expect(result.rangeEndDate.getDate()).toBe(31);
});
});
describe('year boundary', () => {
it('should handle year correctly for Q1', () => {
const date = new Date('2025-02-15');
const result = calculateQuarterDateRange(date);
expect(result.rangeStartDate.getFullYear()).toBe(2025);
expect(result.rangeEndDate.getFullYear()).toBe(2025);
});
});
});
@@ -0,0 +1,55 @@
import { TZDate } from '@date-fns/tz';
import { ViewFilterOperand } from 'twenty-shared/types';
import {
getEndUnitOfDateTime,
getPlainDateFromDate,
getStartUnitOfDateTime,
} from 'twenty-shared/utils';
import { FieldMetadataType } from '~/generated-metadata/graphql';
type ChartFilter = {
fieldName: string;
operand: ViewFilterOperand;
value: string;
};
export const buildDateFilterForDayGranularity = (
parsedBucketDate: Date,
fieldType: FieldMetadataType,
fieldName: string,
timezone?: string,
): ChartFilter[] => {
if (fieldType === FieldMetadataType.DATE) {
return [
{
fieldName,
operand: ViewFilterOperand.IS,
value: getPlainDateFromDate(parsedBucketDate),
},
];
}
if (fieldType === FieldMetadataType.DATE_TIME) {
const dateInTimezone = timezone
? new TZDate(parsedBucketDate, timezone)
: parsedBucketDate;
const startOfDayDate = getStartUnitOfDateTime(dateInTimezone, 'DAY');
const endOfDayDate = getEndUnitOfDateTime(dateInTimezone, 'DAY');
return [
{
fieldName,
operand: ViewFilterOperand.IS_AFTER,
value: startOfDayDate.toISOString(),
},
{
fieldName,
operand: ViewFilterOperand.IS_BEFORE,
value: endOfDayDate.toISOString(),
},
];
}
return [];
};
@@ -0,0 +1,67 @@
import { buildFiltersFromDateRange } from '@/page-layout/widgets/graph/utils/buildFiltersFromDateRange';
import { calculateQuarterDateRange } from '@/page-layout/widgets/graph/utils/calculateQuarterDateRange';
import { TZDate } from '@date-fns/tz';
import {
ObjectRecordGroupByDateGranularity,
type ViewFilterOperand,
} from 'twenty-shared/types';
import {
getEndUnitOfDateTime,
getStartUnitOfDateTime,
} from 'twenty-shared/utils';
import { FieldMetadataType } from '~/generated-metadata/graphql';
export type RangeChartFilter = {
fieldName: string;
operand: ViewFilterOperand;
value: string;
};
export const buildDateRangeFiltersForGranularity = (
parsedBucketDate: Date,
dateGranularity: ObjectRecordGroupByDateGranularity,
fieldType: FieldMetadataType,
fieldName: string,
timezone?: string,
): RangeChartFilter[] => {
const dateInTimezone =
fieldType === FieldMetadataType.DATE_TIME && timezone
? new TZDate(parsedBucketDate, timezone)
: parsedBucketDate;
if (dateGranularity === ObjectRecordGroupByDateGranularity.WEEK) {
return buildFiltersFromDateRange(
getStartUnitOfDateTime(dateInTimezone, 'WEEK'),
getEndUnitOfDateTime(dateInTimezone, 'WEEK'),
fieldType,
fieldName,
);
}
if (dateGranularity === ObjectRecordGroupByDateGranularity.MONTH) {
return buildFiltersFromDateRange(
getStartUnitOfDateTime(dateInTimezone, 'MONTH'),
getEndUnitOfDateTime(dateInTimezone, 'MONTH'),
fieldType,
fieldName,
);
}
if (dateGranularity === ObjectRecordGroupByDateGranularity.QUARTER) {
const quarterRange = calculateQuarterDateRange(dateInTimezone, timezone);
return buildFiltersFromDateRange(
quarterRange.rangeStartDate,
quarterRange.rangeEndDate,
fieldType,
fieldName,
);
}
return buildFiltersFromDateRange(
getStartUnitOfDateTime(dateInTimezone, 'YEAR'),
getEndUnitOfDateTime(dateInTimezone, 'YEAR'),
fieldType,
fieldName,
);
};
@@ -1,11 +1,19 @@
import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { getRecordFilterOperands } from '@/object-record/record-filter/utils/getRecordFilterOperands';
import { buildDateFilterForDayGranularity } from '@/page-layout/widgets/graph/utils/buildDateFilterForDayGranularity';
import { buildDateRangeFiltersForGranularity } from '@/page-layout/widgets/graph/utils/buildDateRangeFiltersForGranularity';
import { isCyclicalDateGranularity } from '@/page-layout/widgets/graph/utils/isCyclicalDateGranularity';
import { isTimeRangeDateGranularity } from '@/page-layout/widgets/graph/utils/isTimeRangeDateGranularity';
import { isNonEmptyString } from '@sniptt/guards';
import {
ObjectRecordGroupByDateGranularity,
ViewFilterOperand,
type ObjectRecordGroupByDateGranularity,
} from 'twenty-shared/types';
import { getFilterTypeFromFieldType, isDefined } from 'twenty-shared/utils';
import {
getFilterTypeFromFieldType,
isDefined,
isFieldMetadataDateKind,
} from 'twenty-shared/utils';
import { FieldMetadataType } from '~/generated-metadata/graphql';
type ChartFilter = {
@@ -17,9 +25,9 @@ type ChartFilter = {
type BuildFilterFromChartBucketParams = {
fieldMetadataItem: FieldMetadataItem;
bucketRawValue: unknown;
dateGranularity?: ObjectRecordGroupByDateGranularity | null; // TODO: Will be used for date filtering
dateGranularity?: ObjectRecordGroupByDateGranularity | null;
subFieldName?: string | null;
timezone?: string; // TODO: Will be used for date filtering
timezone?: string;
};
const formatChartFilterValue = (
@@ -43,7 +51,9 @@ const formatChartFilterValue = (
export const buildFilterFromChartBucket = ({
fieldMetadataItem,
bucketRawValue,
dateGranularity,
subFieldName,
timezone,
}: BuildFilterFromChartBucketParams): ChartFilter[] => {
const fieldName = isNonEmptyString(subFieldName)
? `${fieldMetadataItem.name}.${subFieldName}`
@@ -59,6 +69,43 @@ export const buildFilterFromChartBucket = ({
];
}
if (isFieldMetadataDateKind(fieldMetadataItem.type)) {
const parsedBucketDate = new Date(String(bucketRawValue));
if (isNaN(parsedBucketDate.getTime())) {
return [];
}
if (isCyclicalDateGranularity(dateGranularity)) {
return [];
}
if (
!isDefined(dateGranularity) ||
dateGranularity === ObjectRecordGroupByDateGranularity.DAY ||
dateGranularity === ObjectRecordGroupByDateGranularity.NONE
) {
return buildDateFilterForDayGranularity(
parsedBucketDate,
fieldMetadataItem.type,
fieldName,
timezone,
);
}
if (isTimeRangeDateGranularity(dateGranularity)) {
return buildDateRangeFiltersForGranularity(
parsedBucketDate,
dateGranularity,
fieldMetadataItem.type,
fieldName,
timezone,
);
}
return [];
}
const availableOperands = getRecordFilterOperands({
filterType: getFilterTypeFromFieldType(fieldMetadataItem.type),
subFieldName: subFieldName ?? undefined,
@@ -0,0 +1,39 @@
import { type RangeChartFilter } from '@/page-layout/widgets/graph/utils/buildDateRangeFiltersForGranularity';
import { ViewFilterOperand } from 'twenty-shared/types';
import { getPlainDateFromDate } from 'twenty-shared/utils';
import { FieldMetadataType } from '~/generated-metadata/graphql';
export const buildFiltersFromDateRange = (
rangeStartDate: Date,
rangeEndDate: Date,
fieldType: FieldMetadataType,
fieldName: string,
): RangeChartFilter[] => {
if (fieldType === FieldMetadataType.DATE_TIME) {
return [
{
fieldName,
operand: ViewFilterOperand.IS_AFTER,
value: rangeStartDate.toISOString(),
},
{
fieldName,
operand: ViewFilterOperand.IS_BEFORE,
value: rangeEndDate.toISOString(),
},
];
}
return [
{
fieldName,
operand: ViewFilterOperand.IS_AFTER,
value: getPlainDateFromDate(rangeStartDate),
},
{
fieldName,
operand: ViewFilterOperand.IS_BEFORE,
value: getPlainDateFromDate(rangeEndDate),
},
];
};
@@ -0,0 +1,38 @@
import { TZDate } from '@date-fns/tz';
import { getEndUnitOfDateTime } from 'twenty-shared/utils';
type QuarterDateRange = {
rangeStartDate: Date;
rangeEndDate: Date;
};
export const calculateQuarterDateRange = (
parsedBucketDate: Date,
timezone?: string,
): QuarterDateRange => {
const quarterStartMonthIndex =
Math.floor(parsedBucketDate.getMonth() / 3) * 3;
const rangeStartDate = timezone
? new TZDate(
parsedBucketDate.getFullYear(),
quarterStartMonthIndex,
1,
timezone,
)
: new Date(parsedBucketDate.getFullYear(), quarterStartMonthIndex, 1);
const quarterFinalMonthIndex = quarterStartMonthIndex + 2;
const rangeEndDate = getEndUnitOfDateTime(
timezone
? new TZDate(
parsedBucketDate.getFullYear(),
quarterFinalMonthIndex,
1,
timezone,
)
: new Date(parsedBucketDate.getFullYear(), quarterFinalMonthIndex, 1),
'MONTH',
);
return { rangeStartDate, rangeEndDate };
};
@@ -0,0 +1,13 @@
import { ObjectRecordGroupByDateGranularity } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
export const isCyclicalDateGranularity = (
granularity?: ObjectRecordGroupByDateGranularity | null,
): boolean => {
if (!isDefined(granularity)) return false;
return [
ObjectRecordGroupByDateGranularity.DAY_OF_THE_WEEK,
ObjectRecordGroupByDateGranularity.MONTH_OF_THE_YEAR,
ObjectRecordGroupByDateGranularity.QUARTER_OF_THE_YEAR,
].includes(granularity);
};
@@ -0,0 +1,14 @@
import { ObjectRecordGroupByDateGranularity } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
export const isTimeRangeDateGranularity = (
granularity?: ObjectRecordGroupByDateGranularity | null,
): boolean => {
if (!isDefined(granularity)) return false;
return [
ObjectRecordGroupByDateGranularity.WEEK,
ObjectRecordGroupByDateGranularity.MONTH,
ObjectRecordGroupByDateGranularity.QUARTER,
ObjectRecordGroupByDateGranularity.YEAR,
].includes(granularity);
};