From c535d21587f1abda80473048eb89d830570658d1 Mon Sep 17 00:00:00 2001 From: Marie <51697796+ijreilly@users.noreply.github.com> Date: Thu, 9 Jan 2025 13:13:21 +0100 Subject: [PATCH] Include Date fields in aggregate operations on dates (#9479) Follow-up on https://github.com/twentyhq/twenty/pull/9444/files - I had forgotten to include Date field types (in addition to DateTime) --- .../utils/computeAggregateValueAndLabel.ts | 14 ++- ...ailableForNonStandardAggregateOperation.ts | 2 + ...teOperationToExtendedAggregateOperation.ts | 3 +- ...etAvailableAggregationsFromObjectFields.ts | 4 +- .../field/display/components/DateDisplay.tsx | 14 +-- .../display/components/DateTimeDisplay.tsx | 4 +- .../string/__tests__/formatDateString.test.ts | 6 +- .../__tests__/formatDateTimeString.test.ts | 86 +++++++++++++++++++ .../src/utils/string/formatDateString.ts | 7 +- .../src/utils/string/formatDateTimeString.ts | 26 ++++++ ...le-aggregations-from-object-fields.util.ts | 37 ++++---- packages/twenty-shared/src/index.ts | 1 + .../fieldMetadata/isFieldMetadataDateKind.ts | 12 +++ 13 files changed, 176 insertions(+), 40 deletions(-) create mode 100644 packages/twenty-front/src/utils/string/__tests__/formatDateTimeString.test.ts create mode 100644 packages/twenty-front/src/utils/string/formatDateTimeString.ts create mode 100644 packages/twenty-shared/src/utils/fieldMetadata/isFieldMetadataDateKind.ts diff --git a/packages/twenty-front/src/modules/object-record/record-board/record-board-column/utils/computeAggregateValueAndLabel.ts b/packages/twenty-front/src/modules/object-record/record-board/record-board-column/utils/computeAggregateValueAndLabel.ts index d50e74f33..631e9f364 100644 --- a/packages/twenty-front/src/modules/object-record/record-board/record-board-column/utils/computeAggregateValueAndLabel.ts +++ b/packages/twenty-front/src/modules/object-record/record-board/record-board-column/utils/computeAggregateValueAndLabel.ts @@ -13,6 +13,7 @@ import { formatAmount } from '~/utils/format/formatAmount'; import { formatNumber } from '~/utils/format/number'; import { isDefined } from '~/utils/isDefined'; import { formatDateString } from '~/utils/string/formatDateString'; +import { formatDateTimeString } from '~/utils/string/formatDateTimeString'; export const computeAggregateValueAndLabel = ({ data, @@ -87,7 +88,7 @@ export const computeAggregateValueAndLabel = ({ case FieldMetadataType.DateTime: { value = aggregateValue as string; - value = formatDateString({ + value = formatDateTimeString({ value, displayAsRelativeDate, timeZone, @@ -96,6 +97,17 @@ export const computeAggregateValueAndLabel = ({ }); break; } + + case FieldMetadataType.Date: { + value = aggregateValue as string; + value = formatDateString({ + value, + displayAsRelativeDate, + timeZone, + dateFormat, + }); + break; + } } } const convertedAggregateOperation = diff --git a/packages/twenty-front/src/modules/object-record/record-table/constants/FieldTypesAvailableForNonStandardAggregateOperation.ts b/packages/twenty-front/src/modules/object-record/record-table/constants/FieldTypesAvailableForNonStandardAggregateOperation.ts index c6c413cfc..c2fea0db1 100644 --- a/packages/twenty-front/src/modules/object-record/record-table/constants/FieldTypesAvailableForNonStandardAggregateOperation.ts +++ b/packages/twenty-front/src/modules/object-record/record-table/constants/FieldTypesAvailableForNonStandardAggregateOperation.ts @@ -6,11 +6,13 @@ export const FIELD_TYPES_AVAILABLE_FOR_NON_STANDARD_AGGREGATE_OPERATION = { FieldMetadataType.Number, FieldMetadataType.Currency, FieldMetadataType.DateTime, + FieldMetadataType.Date, ], [AGGREGATE_OPERATIONS.max]: [ FieldMetadataType.Number, FieldMetadataType.Currency, FieldMetadataType.DateTime, + FieldMetadataType.Date, ], [AGGREGATE_OPERATIONS.avg]: [ FieldMetadataType.Number, diff --git a/packages/twenty-front/src/modules/object-record/utils/convertAggregateOperationToExtendedAggregateOperation.ts b/packages/twenty-front/src/modules/object-record/utils/convertAggregateOperationToExtendedAggregateOperation.ts index 5df5d2caf..e2d3ccd45 100644 --- a/packages/twenty-front/src/modules/object-record/utils/convertAggregateOperationToExtendedAggregateOperation.ts +++ b/packages/twenty-front/src/modules/object-record/utils/convertAggregateOperationToExtendedAggregateOperation.ts @@ -1,12 +1,13 @@ import { AGGREGATE_OPERATIONS } from '@/object-record/record-table/constants/AggregateOperations'; import { ExtendedAggregateOperations } from '@/object-record/record-table/types/ExtendedAggregateOperations'; +import { isFieldMetadataDateKind } from 'twenty-shared'; import { FieldMetadataType } from '~/generated-metadata/graphql'; export const convertAggregateOperationToExtendedAggregateOperation = ( aggregateOperation: AGGREGATE_OPERATIONS, fieldType?: FieldMetadataType, ): ExtendedAggregateOperations => { - if (fieldType === FieldMetadataType.DateTime) { + if (isFieldMetadataDateKind(fieldType) === true) { if (aggregateOperation === AGGREGATE_OPERATIONS.min) { return 'EARLIEST'; } diff --git a/packages/twenty-front/src/modules/object-record/utils/getAvailableAggregationsFromObjectFields.ts b/packages/twenty-front/src/modules/object-record/utils/getAvailableAggregationsFromObjectFields.ts index 541b63343..17d9bc503 100644 --- a/packages/twenty-front/src/modules/object-record/utils/getAvailableAggregationsFromObjectFields.ts +++ b/packages/twenty-front/src/modules/object-record/utils/getAvailableAggregationsFromObjectFields.ts @@ -1,6 +1,6 @@ import { FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem'; import { AGGREGATE_OPERATIONS } from '@/object-record/record-table/constants/AggregateOperations'; -import { capitalize } from 'twenty-shared'; +import { capitalize, isFieldMetadataDateKind } from 'twenty-shared'; import { FieldMetadataType } from '~/generated-metadata/graphql'; type NameForAggregation = { @@ -55,7 +55,7 @@ export const getAvailableAggregationsFromObjectFields = ( }; } - if (field.type === FieldMetadataType.DateTime) { + if (isFieldMetadataDateKind(field.type) === true) { acc[field.name] = { ...acc[field.name], [AGGREGATE_OPERATIONS.min]: `min${capitalize(field.name)}`, diff --git a/packages/twenty-front/src/modules/ui/field/display/components/DateDisplay.tsx b/packages/twenty-front/src/modules/ui/field/display/components/DateDisplay.tsx index 767671d62..55f4998f5 100644 --- a/packages/twenty-front/src/modules/ui/field/display/components/DateDisplay.tsx +++ b/packages/twenty-front/src/modules/ui/field/display/components/DateDisplay.tsx @@ -1,7 +1,6 @@ -import { formatDateISOStringToDate } from '@/localization/utils/formatDateISOStringToDate'; -import { formatDateISOStringToRelativeDate } from '@/localization/utils/formatDateISOStringToRelativeDate'; import { UserContext } from '@/users/contexts/UserContext'; import { useContext } from 'react'; +import { formatDateString } from '~/utils/string/formatDateString'; import { EllipsisDisplay } from './EllipsisDisplay'; type DateDisplayProps = { @@ -15,11 +14,12 @@ export const DateDisplay = ({ }: DateDisplayProps) => { const { dateFormat, timeZone } = useContext(UserContext); - const formattedDate = value - ? displayAsRelativeDate - ? formatDateISOStringToRelativeDate(value, true) - : formatDateISOStringToDate(value, timeZone, dateFormat) - : ''; + const formattedDate = formatDateString({ + value, + timeZone, + dateFormat, + displayAsRelativeDate, + }); return {formattedDate}; }; diff --git a/packages/twenty-front/src/modules/ui/field/display/components/DateTimeDisplay.tsx b/packages/twenty-front/src/modules/ui/field/display/components/DateTimeDisplay.tsx index fc0139e76..097f9180e 100644 --- a/packages/twenty-front/src/modules/ui/field/display/components/DateTimeDisplay.tsx +++ b/packages/twenty-front/src/modules/ui/field/display/components/DateTimeDisplay.tsx @@ -1,6 +1,6 @@ import { UserContext } from '@/users/contexts/UserContext'; import { useContext } from 'react'; -import { formatDateString } from '~/utils/string/formatDateString'; +import { formatDateTimeString } from '~/utils/string/formatDateTimeString'; import { EllipsisDisplay } from './EllipsisDisplay'; type DateTimeDisplayProps = { @@ -14,7 +14,7 @@ export const DateTimeDisplay = ({ }: DateTimeDisplayProps) => { const { dateFormat, timeFormat, timeZone } = useContext(UserContext); - const formattedDate = formatDateString({ + const formattedDate = formatDateTimeString({ value, displayAsRelativeDate, timeZone, diff --git a/packages/twenty-front/src/utils/string/__tests__/formatDateString.test.ts b/packages/twenty-front/src/utils/string/__tests__/formatDateString.test.ts index f4088b6e6..56a48dc44 100644 --- a/packages/twenty-front/src/utils/string/__tests__/formatDateString.test.ts +++ b/packages/twenty-front/src/utils/string/__tests__/formatDateString.test.ts @@ -1,5 +1,4 @@ import { DateFormat } from '@/localization/constants/DateFormat'; -import { TimeFormat } from '@/localization/constants/TimeFormat'; import { DateTime } from 'luxon'; import { formatDateString } from '~/utils/string/formatDateString'; @@ -7,7 +6,6 @@ describe('formatDateString', () => { const defaultParams = { timeZone: 'UTC', dateFormat: DateFormat.DAY_FIRST, - timeFormat: TimeFormat.HOUR_24, }; it('should return empty string for null value', () => { @@ -49,7 +47,7 @@ describe('formatDateString', () => { it('should format date as datetime when displayAsRelativeDate is false', () => { const mockDate = '2023-01-01T12:00:00Z'; - const mockFormattedDate = '1 Jan, 2023 12:00'; + const mockFormattedDate = '1 Jan, 2023'; jest.mock('@/localization/utils/formatDateISOStringToDateTime', () => ({ formatDateISOStringToDateTime: jest @@ -68,7 +66,7 @@ describe('formatDateString', () => { it('should format date as datetime by default when displayAsRelativeDate is not provided', () => { const mockDate = '2023-01-01T12:00:00Z'; - const mockFormattedDate = '1 Jan, 2023 12:00'; + const mockFormattedDate = '1 Jan, 2023'; jest.mock('@/localization/utils/formatDateISOStringToDateTime', () => ({ formatDateISOStringToDateTime: jest diff --git a/packages/twenty-front/src/utils/string/__tests__/formatDateTimeString.test.ts b/packages/twenty-front/src/utils/string/__tests__/formatDateTimeString.test.ts new file mode 100644 index 000000000..6cbf77a25 --- /dev/null +++ b/packages/twenty-front/src/utils/string/__tests__/formatDateTimeString.test.ts @@ -0,0 +1,86 @@ +import { DateFormat } from '@/localization/constants/DateFormat'; +import { TimeFormat } from '@/localization/constants/TimeFormat'; +import { DateTime } from 'luxon'; +import { formatDateTimeString } from '~/utils/string/formatDateTimeString'; + +describe('formatDateTimeString', () => { + const defaultParams = { + timeZone: 'UTC', + dateFormat: DateFormat.DAY_FIRST, + timeFormat: TimeFormat.HOUR_24, + }; + + it('should return empty string for null value', () => { + const result = formatDateTimeString({ + ...defaultParams, + value: null, + }); + + expect(result).toBe(''); + }); + + it('should return empty string for undefined value', () => { + const result = formatDateTimeString({ + ...defaultParams, + value: undefined, + }); + + expect(result).toBe(''); + }); + + it('should format date as relative when displayAsRelativeDate is true', () => { + const mockDate = DateTime.now().minus({ months: 2 }).toISO(); + const mockRelativeDate = '2 months ago'; + + jest.mock('@/localization/utils/formatDateISOStringToRelativeDate', () => ({ + formatDateISOStringToRelativeDate: jest + .fn() + .mockReturnValue(mockRelativeDate), + })); + + const result = formatDateTimeString({ + ...defaultParams, + value: mockDate, + displayAsRelativeDate: true, + }); + + expect(result).toBe(mockRelativeDate); + }); + + it('should format date as datetime when displayAsRelativeDate is false', () => { + const mockDate = '2023-01-01T12:00:00Z'; + const mockFormattedDate = '1 Jan, 2023 12:00'; + + jest.mock('@/localization/utils/formatDateISOStringToDateTime', () => ({ + formatDateISOStringToDateTime: jest + .fn() + .mockReturnValue(mockFormattedDate), + })); + + const result = formatDateTimeString({ + ...defaultParams, + value: mockDate, + displayAsRelativeDate: false, + }); + + expect(result).toBe(mockFormattedDate); + }); + + it('should format date as datetime by default when displayAsRelativeDate is not provided', () => { + const mockDate = '2023-01-01T12:00:00Z'; + const mockFormattedDate = '1 Jan, 2023 12:00'; + + jest.mock('@/localization/utils/formatDateISOStringToDateTime', () => ({ + formatDateISOStringToDateTime: jest + .fn() + .mockReturnValue(mockFormattedDate), + })); + + const result = formatDateTimeString({ + ...defaultParams, + value: mockDate, + }); + + expect(result).toBe(mockFormattedDate); + }); +}); diff --git a/packages/twenty-front/src/utils/string/formatDateString.ts b/packages/twenty-front/src/utils/string/formatDateString.ts index 6aaec870a..87417854b 100644 --- a/packages/twenty-front/src/utils/string/formatDateString.ts +++ b/packages/twenty-front/src/utils/string/formatDateString.ts @@ -1,25 +1,22 @@ import { DateFormat } from '@/localization/constants/DateFormat'; -import { TimeFormat } from '@/localization/constants/TimeFormat'; -import { formatDateISOStringToDateTime } from '@/localization/utils/formatDateISOStringToDateTime'; +import { formatDateISOStringToDate } from '@/localization/utils/formatDateISOStringToDate'; import { formatDateISOStringToRelativeDate } from '@/localization/utils/formatDateISOStringToRelativeDate'; export const formatDateString = ({ value, timeZone, dateFormat, - timeFormat, displayAsRelativeDate, }: { timeZone: string; dateFormat: DateFormat; - timeFormat: TimeFormat; value?: string | null; displayAsRelativeDate?: boolean; }) => { const formattedDate = value ? displayAsRelativeDate ? formatDateISOStringToRelativeDate(value) - : formatDateISOStringToDateTime(value, timeZone, dateFormat, timeFormat) + : formatDateISOStringToDate(value, timeZone, dateFormat) : ''; return formattedDate; diff --git a/packages/twenty-front/src/utils/string/formatDateTimeString.ts b/packages/twenty-front/src/utils/string/formatDateTimeString.ts new file mode 100644 index 000000000..6b244fd5c --- /dev/null +++ b/packages/twenty-front/src/utils/string/formatDateTimeString.ts @@ -0,0 +1,26 @@ +import { DateFormat } from '@/localization/constants/DateFormat'; +import { TimeFormat } from '@/localization/constants/TimeFormat'; +import { formatDateISOStringToDateTime } from '@/localization/utils/formatDateISOStringToDateTime'; +import { formatDateISOStringToRelativeDate } from '@/localization/utils/formatDateISOStringToRelativeDate'; + +export const formatDateTimeString = ({ + value, + timeZone, + dateFormat, + timeFormat, + displayAsRelativeDate, +}: { + timeZone: string; + dateFormat: DateFormat; + timeFormat: TimeFormat; + value?: string | null; + displayAsRelativeDate?: boolean; +}) => { + const formattedDate = value + ? displayAsRelativeDate + ? formatDateISOStringToRelativeDate(value) + : formatDateISOStringToDateTime(value, timeZone, dateFormat, timeFormat) + : ''; + + return formattedDate; +}; diff --git a/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/utils/get-available-aggregations-from-object-fields.util.ts b/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/utils/get-available-aggregations-from-object-fields.util.ts index 52b75d50d..0445e4ab5 100644 --- a/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/utils/get-available-aggregations-from-object-fields.util.ts +++ b/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/utils/get-available-aggregations-from-object-fields.util.ts @@ -1,7 +1,7 @@ import { GraphQLISODateTime } from '@nestjs/graphql'; import { GraphQLFloat, GraphQLInt, GraphQLScalarType } from 'graphql'; -import { capitalize } from 'twenty-shared'; +import { capitalize, isFieldMetadataDateKind } from 'twenty-shared'; import { FieldMetadataInterface } from 'src/engine/metadata-modules/field-metadata/interfaces/field-metadata.interface'; @@ -75,24 +75,25 @@ export const getAvailableAggregationsFromObjectFields = ( aggregateOperation: AGGREGATE_OPERATIONS.percentageNotEmpty, }; - switch (field.type) { - case FieldMetadataType.DATE_TIME: - acc[`min${capitalize(field.name)}`] = { - type: GraphQLISODateTime, - description: `Earliest date contained in the field ${field.name}`, - fromField: field.name, - fromFieldType: field.type, - aggregateOperation: AGGREGATE_OPERATIONS.min, - }; + if (isFieldMetadataDateKind(field.type)) { + acc[`min${capitalize(field.name)}`] = { + type: GraphQLISODateTime, + description: `Earliest date contained in the field ${field.name}`, + fromField: field.name, + fromFieldType: field.type, + aggregateOperation: AGGREGATE_OPERATIONS.min, + }; - acc[`max${capitalize(field.name)}`] = { - type: GraphQLISODateTime, - description: `Latest date contained in the field ${field.name}`, - fromField: field.name, - fromFieldType: field.type, - aggregateOperation: AGGREGATE_OPERATIONS.max, - }; - break; + acc[`max${capitalize(field.name)}`] = { + type: GraphQLISODateTime, + description: `Latest date contained in the field ${field.name}`, + fromField: field.name, + fromFieldType: field.type, + aggregateOperation: AGGREGATE_OPERATIONS.max, + }; + } + + switch (field.type) { case FieldMetadataType.NUMBER: acc[`min${capitalize(field.name)}`] = { type: GraphQLFloat, diff --git a/packages/twenty-shared/src/index.ts b/packages/twenty-shared/src/index.ts index 45c4fb447..91931cece 100644 --- a/packages/twenty-shared/src/index.ts +++ b/packages/twenty-shared/src/index.ts @@ -1,5 +1,6 @@ export * from './constants/TwentyCompaniesBaseUrl'; export * from './constants/TwentyIconsBaseUrl'; +export * from './utils/fieldMetadata/isFieldMetadataDateKind'; export * from './utils/image/getImageAbsoluteURI'; export * from './utils/strings'; diff --git a/packages/twenty-shared/src/utils/fieldMetadata/isFieldMetadataDateKind.ts b/packages/twenty-shared/src/utils/fieldMetadata/isFieldMetadataDateKind.ts new file mode 100644 index 000000000..b81943a06 --- /dev/null +++ b/packages/twenty-shared/src/utils/fieldMetadata/isFieldMetadataDateKind.ts @@ -0,0 +1,12 @@ +import { FieldMetadataType } from 'src/types/FieldMetadataType'; + +export const isFieldMetadataDateKind = ( + fieldMetadataType: FieldMetadataType, +): fieldMetadataType is + | FieldMetadataType.DATE + | FieldMetadataType.DATE_TIME => { + return ( + fieldMetadataType === FieldMetadataType.DATE || + fieldMetadataType === FieldMetadataType.DATE_TIME + ); +};