Implement aggregate operations on dates (#9444)

Adding aggregate operations for dates: min ("Earliest date") and max
("Latest date")
This commit is contained in:
Marie
2025-01-08 16:45:56 +01:00
committed by GitHub
parent 7036a8ccc3
commit 8475b55172
28 changed files with 388 additions and 53 deletions

View File

@ -4,6 +4,7 @@ import { aggregateOperationComponentState } from '@/object-record/record-board/r
import { availableFieldIdsForAggregateOperationComponentState } from '@/object-record/record-board/record-board-column/states/availableFieldIdsForAggregateOperationComponentState'; import { availableFieldIdsForAggregateOperationComponentState } from '@/object-record/record-board/record-board-column/states/availableFieldIdsForAggregateOperationComponentState';
import { getAggregateOperationLabel } from '@/object-record/record-board/record-board-column/utils/getAggregateOperationLabel'; import { getAggregateOperationLabel } from '@/object-record/record-board/record-board-column/utils/getAggregateOperationLabel';
import { recordIndexKanbanAggregateOperationState } from '@/object-record/record-index/states/recordIndexKanbanAggregateOperationState'; import { recordIndexKanbanAggregateOperationState } from '@/object-record/record-index/states/recordIndexKanbanAggregateOperationState';
import { convertExtendedAggregateOperationToAggregateOperation } from '@/object-record/utils/convertExtendedAggregateOperationToAggregateOperation';
import { DropdownMenuHeader } from '@/ui/layout/dropdown/components/DropdownMenuHeader'; import { DropdownMenuHeader } from '@/ui/layout/dropdown/components/DropdownMenuHeader';
import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer'; import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer';
import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2'; import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2';
@ -47,6 +48,8 @@ export const RecordBoardColumnHeaderAggregateDropdownFieldsContent = () => {
if (!isDefined(aggregateOperation)) return <></>; if (!isDefined(aggregateOperation)) return <></>;
const convertedAggregateOperation =
convertExtendedAggregateOperationToAggregateOperation(aggregateOperation);
return ( return (
<> <>
<DropdownMenuHeader <DropdownMenuHeader
@ -72,7 +75,7 @@ export const RecordBoardColumnHeaderAggregateDropdownFieldsContent = () => {
onClick={() => { onClick={() => {
updateViewAggregate({ updateViewAggregate({
kanbanAggregateOperationFieldMetadataId: fieldId, kanbanAggregateOperationFieldMetadataId: fieldId,
kanbanAggregateOperation: aggregateOperation, kanbanAggregateOperation: convertedAggregateOperation,
}); });
closeDropdown(); closeDropdown();
}} }}
@ -82,7 +85,7 @@ export const RecordBoardColumnHeaderAggregateDropdownFieldsContent = () => {
recordIndexKanbanAggregateOperation?.fieldMetadataId === recordIndexKanbanAggregateOperation?.fieldMetadataId ===
fieldId && fieldId &&
recordIndexKanbanAggregateOperation?.operation === recordIndexKanbanAggregateOperation?.operation ===
aggregateOperation convertedAggregateOperation
? IconCheck ? IconCheck
: undefined : undefined
} }

View File

@ -8,6 +8,7 @@ import { aggregateOperationComponentState } from '@/object-record/record-board/r
import { availableFieldIdsForAggregateOperationComponentState } from '@/object-record/record-board/record-board-column/states/availableFieldIdsForAggregateOperationComponentState'; import { availableFieldIdsForAggregateOperationComponentState } from '@/object-record/record-board/record-board-column/states/availableFieldIdsForAggregateOperationComponentState';
import { getAggregateOperationLabel } from '@/object-record/record-board/record-board-column/utils/getAggregateOperationLabel'; import { getAggregateOperationLabel } from '@/object-record/record-board/record-board-column/utils/getAggregateOperationLabel';
import { AGGREGATE_OPERATIONS } from '@/object-record/record-table/constants/AggregateOperations'; import { AGGREGATE_OPERATIONS } from '@/object-record/record-table/constants/AggregateOperations';
import { ExtendedAggregateOperations } from '@/object-record/record-table/types/ExtendedAggregateOperations';
import { TableOptionsHotkeyScope } from '@/object-record/record-table/types/TableOptionsHotkeyScope'; import { TableOptionsHotkeyScope } from '@/object-record/record-table/types/TableOptionsHotkeyScope';
import { AvailableFieldsForAggregateOperation } from '@/object-record/types/AvailableFieldsForAggregateOperation'; import { AvailableFieldsForAggregateOperation } from '@/object-record/types/AvailableFieldsForAggregateOperation';
import { DropdownMenuHeader } from '@/ui/layout/dropdown/components/DropdownMenuHeader'; import { DropdownMenuHeader } from '@/ui/layout/dropdown/components/DropdownMenuHeader';
@ -69,7 +70,7 @@ export const RecordBoardColumnHeaderAggregateDropdownOptionsContent = ({
availableAggregationOperation !== AGGREGATE_OPERATIONS.count availableAggregationOperation !== AGGREGATE_OPERATIONS.count
) { ) {
setAggregateOperation( setAggregateOperation(
availableAggregationOperation as AGGREGATE_OPERATIONS, availableAggregationOperation as ExtendedAggregateOperations,
); );
setAvailableFieldsForAggregateOperation( setAvailableFieldsForAggregateOperation(
@ -87,7 +88,7 @@ export const RecordBoardColumnHeaderAggregateDropdownOptionsContent = ({
} }
}} }}
text={getAggregateOperationLabel( text={getAggregateOperationLabel(
availableAggregationOperation as AGGREGATE_OPERATIONS, availableAggregationOperation as ExtendedAggregateOperations,
)} )}
hasSubMenu={ hasSubMenu={
availableAggregationOperation === AGGREGATE_OPERATIONS.count availableAggregationOperation === AGGREGATE_OPERATIONS.count

View File

@ -9,6 +9,7 @@ import { recordIndexFiltersState } from '@/object-record/record-index/states/rec
import { recordIndexKanbanAggregateOperationState } from '@/object-record/record-index/states/recordIndexKanbanAggregateOperationState'; import { recordIndexKanbanAggregateOperationState } from '@/object-record/record-index/states/recordIndexKanbanAggregateOperationState';
import { recordIndexKanbanFieldMetadataIdState } from '@/object-record/record-index/states/recordIndexKanbanFieldMetadataIdState'; import { recordIndexKanbanFieldMetadataIdState } from '@/object-record/record-index/states/recordIndexKanbanFieldMetadataIdState';
import { recordIndexViewFilterGroupsState } from '@/object-record/record-index/states/recordIndexViewFilterGroupsState'; import { recordIndexViewFilterGroupsState } from '@/object-record/record-index/states/recordIndexViewFilterGroupsState';
import { UserContext } from '@/users/contexts/UserContext';
import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled'; import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
import { useContext } from 'react'; import { useContext } from 'react';
import { useRecoilValue } from 'recoil'; import { useRecoilValue } from 'recoil';
@ -80,12 +81,17 @@ export const useAggregateRecordsForRecordBoardColumn = () => {
skip: !isAggregateQueryEnabled, skip: !isAggregateQueryEnabled,
}); });
const { dateFormat, timeFormat, timeZone } = useContext(UserContext);
const { value, labelWithFieldName } = computeAggregateValueAndLabel({ const { value, labelWithFieldName } = computeAggregateValueAndLabel({
data, data,
objectMetadataItem, objectMetadataItem,
fieldMetadataId: recordIndexKanbanAggregateOperation?.fieldMetadataId, fieldMetadataId: recordIndexKanbanAggregateOperation?.fieldMetadataId,
aggregateOperation: recordIndexKanbanAggregateOperation?.operation, aggregateOperation: recordIndexKanbanAggregateOperation?.operation,
fallbackFieldName: kanbanFieldName, fallbackFieldName: kanbanFieldName,
dateFormat,
timeFormat,
timeZone,
}); });
return { return {

View File

@ -1,9 +1,9 @@
import { RecordBoardColumnHeaderAggregateDropdownComponentInstanceContext } from '@/object-record/record-board/contexts/RecordBoardColumnHeaderAggregateDropdownComponentInstanceContext'; import { RecordBoardColumnHeaderAggregateDropdownComponentInstanceContext } from '@/object-record/record-board/contexts/RecordBoardColumnHeaderAggregateDropdownComponentInstanceContext';
import { AGGREGATE_OPERATIONS } from '@/object-record/record-table/constants/AggregateOperations'; import { ExtendedAggregateOperations } from '@/object-record/record-table/types/ExtendedAggregateOperations';
import { createComponentStateV2 } from '@/ui/utilities/state/component-state/utils/createComponentStateV2'; import { createComponentStateV2 } from '@/ui/utilities/state/component-state/utils/createComponentStateV2';
export const aggregateOperationComponentState = export const aggregateOperationComponentState =
createComponentStateV2<AGGREGATE_OPERATIONS | null>({ createComponentStateV2<ExtendedAggregateOperations | null>({
key: 'aggregateOperationComponentFamilyState', key: 'aggregateOperationComponentFamilyState',
defaultValue: null, defaultValue: null,
componentInstanceContext: componentInstanceContext:

View File

@ -1,5 +1,8 @@
import { DateFormat } from '@/localization/constants/DateFormat';
import { TimeFormat } from '@/localization/constants/TimeFormat';
import { FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem'; import { FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { AggregateRecordsData } from '@/object-record/hooks/useAggregateRecords';
import { computeAggregateValueAndLabel } from '@/object-record/record-board/record-board-column/utils/computeAggregateValueAndLabel'; import { computeAggregateValueAndLabel } from '@/object-record/record-board/record-board-column/utils/computeAggregateValueAndLabel';
import { AGGREGATE_OPERATIONS } from '@/object-record/record-table/constants/AggregateOperations'; import { AGGREGATE_OPERATIONS } from '@/object-record/record-table/constants/AggregateOperations';
import { FieldMetadataType } from '~/generated/graphql'; import { FieldMetadataType } from '~/generated/graphql';
@ -20,13 +23,20 @@ describe('computeAggregateValueAndLabel', () => {
], ],
} as ObjectMetadataItem; } as ObjectMetadataItem;
const defaultParams = {
dateFormat: DateFormat.DAY_FIRST,
timeFormat: TimeFormat.HOUR_24,
timeZone: 'UTC',
};
it('should return empty object for empty data', () => { it('should return empty object for empty data', () => {
const result = computeAggregateValueAndLabel({ const result = computeAggregateValueAndLabel({
data: {}, data: {} as AggregateRecordsData,
objectMetadataItem: mockObjectMetadata, objectMetadataItem: mockObjectMetadata,
fieldMetadataId: MOCK_FIELD_ID, fieldMetadataId: MOCK_FIELD_ID,
aggregateOperation: AGGREGATE_OPERATIONS.sum, aggregateOperation: AGGREGATE_OPERATIONS.sum,
fallbackFieldName: MOCK_KANBAN_FIELD_NAME, fallbackFieldName: MOCK_KANBAN_FIELD_NAME,
...defaultParams,
}); });
expect(result).toEqual({}); expect(result).toEqual({});
@ -37,7 +47,7 @@ describe('computeAggregateValueAndLabel', () => {
amount: { amount: {
[AGGREGATE_OPERATIONS.sum]: 2000000, [AGGREGATE_OPERATIONS.sum]: 2000000,
}, },
}; } as AggregateRecordsData;
const result = computeAggregateValueAndLabel({ const result = computeAggregateValueAndLabel({
data: mockData, data: mockData,
@ -45,6 +55,7 @@ describe('computeAggregateValueAndLabel', () => {
fieldMetadataId: MOCK_FIELD_ID, fieldMetadataId: MOCK_FIELD_ID,
aggregateOperation: AGGREGATE_OPERATIONS.sum, aggregateOperation: AGGREGATE_OPERATIONS.sum,
fallbackFieldName: MOCK_KANBAN_FIELD_NAME, fallbackFieldName: MOCK_KANBAN_FIELD_NAME,
...defaultParams,
}); });
expect(result).toEqual({ expect(result).toEqual({
@ -74,7 +85,7 @@ describe('computeAggregateValueAndLabel', () => {
percentage: { percentage: {
[AGGREGATE_OPERATIONS.avg]: 0.3, [AGGREGATE_OPERATIONS.avg]: 0.3,
}, },
}; } as AggregateRecordsData;
const result = computeAggregateValueAndLabel({ const result = computeAggregateValueAndLabel({
data: mockData, data: mockData,
@ -82,6 +93,7 @@ describe('computeAggregateValueAndLabel', () => {
fieldMetadataId: MOCK_FIELD_ID, fieldMetadataId: MOCK_FIELD_ID,
aggregateOperation: AGGREGATE_OPERATIONS.avg, aggregateOperation: AGGREGATE_OPERATIONS.avg,
fallbackFieldName: MOCK_KANBAN_FIELD_NAME, fallbackFieldName: MOCK_KANBAN_FIELD_NAME,
...defaultParams,
}); });
expect(result).toEqual({ expect(result).toEqual({
@ -111,7 +123,7 @@ describe('computeAggregateValueAndLabel', () => {
decimals: { decimals: {
[AGGREGATE_OPERATIONS.sum]: 0.009, [AGGREGATE_OPERATIONS.sum]: 0.009,
}, },
}; } as AggregateRecordsData;
const result = computeAggregateValueAndLabel({ const result = computeAggregateValueAndLabel({
data: mockData, data: mockData,
@ -119,6 +131,7 @@ describe('computeAggregateValueAndLabel', () => {
fieldMetadataId: MOCK_FIELD_ID, fieldMetadataId: MOCK_FIELD_ID,
aggregateOperation: AGGREGATE_OPERATIONS.sum, aggregateOperation: AGGREGATE_OPERATIONS.sum,
fallbackFieldName: MOCK_KANBAN_FIELD_NAME, fallbackFieldName: MOCK_KANBAN_FIELD_NAME,
...defaultParams,
}); });
expect(result).toEqual({ expect(result).toEqual({
@ -128,17 +141,88 @@ describe('computeAggregateValueAndLabel', () => {
}); });
}); });
it('should handle datetime field with min operation', () => {
const mockObjectMetadataWithDatetimeField: ObjectMetadataItem = {
id: '123',
fields: [
{
id: MOCK_FIELD_ID,
name: 'createdAt',
label: 'Created At',
type: FieldMetadataType.DateTime,
} as FieldMetadataItem,
],
} as ObjectMetadataItem;
const mockData = {
createdAt: {
[AGGREGATE_OPERATIONS.min]: '2023-01-01T12:00:00Z',
},
} as AggregateRecordsData;
const result = computeAggregateValueAndLabel({
data: mockData,
objectMetadataItem: mockObjectMetadataWithDatetimeField,
fieldMetadataId: MOCK_FIELD_ID,
aggregateOperation: AGGREGATE_OPERATIONS.min,
fallbackFieldName: MOCK_KANBAN_FIELD_NAME,
...defaultParams,
});
expect(result).toEqual({
label: 'Earliest date',
labelWithFieldName: 'Earliest date of Created At',
value: '1 Jan, 2023 12:00',
});
});
it('should handle datetime field with max operation', () => {
const mockObjectMetadataWithDatetimeField: ObjectMetadataItem = {
id: '123',
fields: [
{
id: MOCK_FIELD_ID,
name: 'updatedAt',
label: 'Updated At',
type: FieldMetadataType.DateTime,
} as FieldMetadataItem,
],
} as ObjectMetadataItem;
const mockData = {
updatedAt: {
[AGGREGATE_OPERATIONS.max]: '2023-12-31T23:59:59Z',
},
} as AggregateRecordsData;
const result = computeAggregateValueAndLabel({
data: mockData,
objectMetadataItem: mockObjectMetadataWithDatetimeField,
fieldMetadataId: MOCK_FIELD_ID,
aggregateOperation: AGGREGATE_OPERATIONS.max,
fallbackFieldName: MOCK_KANBAN_FIELD_NAME,
...defaultParams,
});
expect(result).toEqual({
value: '31 Dec, 2023 23:59',
label: 'Latest date',
labelWithFieldName: 'Latest date of Updated At',
});
});
it('should default to count when field not found', () => { it('should default to count when field not found', () => {
const mockData = { const mockData = {
[MOCK_KANBAN_FIELD_NAME]: { [MOCK_KANBAN_FIELD_NAME]: {
[AGGREGATE_OPERATIONS.count]: 42, [AGGREGATE_OPERATIONS.count]: 42,
}, },
}; } as AggregateRecordsData;
const result = computeAggregateValueAndLabel({ const result = computeAggregateValueAndLabel({
data: mockData, data: mockData,
objectMetadataItem: mockObjectMetadata, objectMetadataItem: mockObjectMetadata,
fallbackFieldName: MOCK_KANBAN_FIELD_NAME, fallbackFieldName: MOCK_KANBAN_FIELD_NAME,
...defaultParams,
}); });
expect(result).toEqual({ expect(result).toEqual({
@ -153,13 +237,14 @@ describe('computeAggregateValueAndLabel', () => {
amount: { amount: {
[AGGREGATE_OPERATIONS.sum]: undefined, [AGGREGATE_OPERATIONS.sum]: undefined,
}, },
}; } as AggregateRecordsData;
const result = computeAggregateValueAndLabel({ const result = computeAggregateValueAndLabel({
data: mockData, data: mockData,
objectMetadataItem: mockObjectMetadata, objectMetadataItem: mockObjectMetadata,
fieldMetadataId: MOCK_FIELD_ID, fieldMetadataId: MOCK_FIELD_ID,
aggregateOperation: AGGREGATE_OPERATIONS.sum, aggregateOperation: AGGREGATE_OPERATIONS.sum,
...defaultParams,
}); });
expect(result).toEqual({ expect(result).toEqual({

View File

@ -1,5 +1,6 @@
import { getAggregateOperationLabel } from '@/object-record/record-board/record-board-column/utils/getAggregateOperationLabel'; import { getAggregateOperationLabel } from '@/object-record/record-board/record-board-column/utils/getAggregateOperationLabel';
import { AGGREGATE_OPERATIONS } from '@/object-record/record-table/constants/AggregateOperations'; import { AGGREGATE_OPERATIONS } from '@/object-record/record-table/constants/AggregateOperations';
import { expect } from '@storybook/test';
describe('getAggregateOperationLabel', () => { describe('getAggregateOperationLabel', () => {
it('should return correct labels for each operation', () => { it('should return correct labels for each operation', () => {
@ -8,6 +9,8 @@ describe('getAggregateOperationLabel', () => {
expect(getAggregateOperationLabel(AGGREGATE_OPERATIONS.avg)).toBe( expect(getAggregateOperationLabel(AGGREGATE_OPERATIONS.avg)).toBe(
'Average', 'Average',
); );
expect(getAggregateOperationLabel('EARLIEST')).toBe('Earliest date');
expect(getAggregateOperationLabel('LATEST')).toBe('Latest date');
expect(getAggregateOperationLabel(AGGREGATE_OPERATIONS.sum)).toBe('Sum'); expect(getAggregateOperationLabel(AGGREGATE_OPERATIONS.sum)).toBe('Sum');
expect(getAggregateOperationLabel(AGGREGATE_OPERATIONS.count)).toBe( expect(getAggregateOperationLabel(AGGREGATE_OPERATIONS.count)).toBe(
'Count all', 'Count all',

View File

@ -1,14 +1,18 @@
import { DateFormat } from '@/localization/constants/DateFormat';
import { TimeFormat } from '@/localization/constants/TimeFormat';
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { AggregateRecordsData } from '@/object-record/hooks/useAggregateRecords'; import { AggregateRecordsData } from '@/object-record/hooks/useAggregateRecords';
import { getAggregateOperationLabel } from '@/object-record/record-board/record-board-column/utils/getAggregateOperationLabel'; import { getAggregateOperationLabel } from '@/object-record/record-board/record-board-column/utils/getAggregateOperationLabel';
import { AGGREGATE_OPERATIONS } from '@/object-record/record-table/constants/AggregateOperations'; import { AGGREGATE_OPERATIONS } from '@/object-record/record-table/constants/AggregateOperations';
import { COUNT_AGGREGATE_OPERATION_OPTIONS } from '@/object-record/record-table/record-table-footer/constants/countAggregateOperationOptions'; import { COUNT_AGGREGATE_OPERATION_OPTIONS } from '@/object-record/record-table/record-table-footer/constants/countAggregateOperationOptions';
import { PERCENT_AGGREGATE_OPERATION_OPTIONS } from '@/object-record/record-table/record-table-footer/constants/percentAggregateOperationOption'; import { PERCENT_AGGREGATE_OPERATION_OPTIONS } from '@/object-record/record-table/record-table-footer/constants/percentAggregateOperationOption';
import { convertAggregateOperationToExtendedAggregateOperation } from '@/object-record/utils/convertAggregateOperationToExtendedAggregateOperation';
import isEmpty from 'lodash.isempty'; import isEmpty from 'lodash.isempty';
import { FieldMetadataType } from '~/generated-metadata/graphql'; import { FieldMetadataType } from '~/generated-metadata/graphql';
import { formatAmount } from '~/utils/format/formatAmount'; import { formatAmount } from '~/utils/format/formatAmount';
import { formatNumber } from '~/utils/format/number'; import { formatNumber } from '~/utils/format/number';
import { isDefined } from '~/utils/isDefined'; import { isDefined } from '~/utils/isDefined';
import { formatDateString } from '~/utils/string/formatDateString';
export const computeAggregateValueAndLabel = ({ export const computeAggregateValueAndLabel = ({
data, data,
@ -16,12 +20,18 @@ export const computeAggregateValueAndLabel = ({
fieldMetadataId, fieldMetadataId,
aggregateOperation, aggregateOperation,
fallbackFieldName, fallbackFieldName,
dateFormat,
timeFormat,
timeZone,
}: { }: {
data: AggregateRecordsData; data: AggregateRecordsData;
objectMetadataItem: ObjectMetadataItem; objectMetadataItem: ObjectMetadataItem;
fieldMetadataId?: string | null; fieldMetadataId?: string | null;
aggregateOperation?: AGGREGATE_OPERATIONS | null; aggregateOperation?: AGGREGATE_OPERATIONS | null;
fallbackFieldName?: string; fallbackFieldName?: string;
dateFormat: DateFormat;
timeFormat: TimeFormat;
timeZone: string;
}) => { }) => {
if (isEmpty(data)) { if (isEmpty(data)) {
return {}; return {};
@ -49,6 +59,8 @@ export const computeAggregateValueAndLabel = ({
let value; let value;
const displayAsRelativeDate = field?.settings?.displayAsRelativeDate;
if (COUNT_AGGREGATE_OPERATION_OPTIONS.includes(aggregateOperation)) { if (COUNT_AGGREGATE_OPERATION_OPTIONS.includes(aggregateOperation)) {
value = aggregateValue; value = aggregateValue;
} else if (!isDefined(aggregateValue)) { } else if (!isDefined(aggregateValue)) {
@ -56,15 +68,15 @@ export const computeAggregateValueAndLabel = ({
} else if (PERCENT_AGGREGATE_OPERATION_OPTIONS.includes(aggregateOperation)) { } else if (PERCENT_AGGREGATE_OPERATION_OPTIONS.includes(aggregateOperation)) {
value = `${formatNumber(Number(aggregateValue) * 100)}%`; value = `${formatNumber(Number(aggregateValue) * 100)}%`;
} else { } else {
value = Number(aggregateValue);
switch (field.type) { switch (field.type) {
case FieldMetadataType.Currency: { case FieldMetadataType.Currency: {
value = Number(aggregateValue);
value = formatAmount(value / 1_000_000); value = formatAmount(value / 1_000_000);
break; break;
} }
case FieldMetadataType.Number: { case FieldMetadataType.Number: {
value = Number(aggregateValue);
const { decimals, type } = field.settings ?? {}; const { decimals, type } = field.settings ?? {};
value = value =
type === 'percentage' type === 'percentage'
@ -72,13 +84,30 @@ export const computeAggregateValueAndLabel = ({
: formatNumber(value, decimals); : formatNumber(value, decimals);
break; break;
} }
case FieldMetadataType.DateTime: {
value = aggregateValue as string;
value = formatDateString({
value,
displayAsRelativeDate,
timeZone,
dateFormat,
timeFormat,
});
break;
}
} }
} }
const label = getAggregateOperationLabel(aggregateOperation); const convertedAggregateOperation =
convertAggregateOperationToExtendedAggregateOperation(
aggregateOperation,
field.type,
);
const label = getAggregateOperationLabel(convertedAggregateOperation);
const labelWithFieldName = const labelWithFieldName =
aggregateOperation === AGGREGATE_OPERATIONS.count aggregateOperation === AGGREGATE_OPERATIONS.count
? `${getAggregateOperationLabel(AGGREGATE_OPERATIONS.count)}` ? `${getAggregateOperationLabel(AGGREGATE_OPERATIONS.count)}`
: `${getAggregateOperationLabel(aggregateOperation)} of ${field.label}`; : `${getAggregateOperationLabel(convertedAggregateOperation)} of ${field.label}`;
return { return {
value, value,

View File

@ -1,6 +1,9 @@
import { AGGREGATE_OPERATIONS } from '@/object-record/record-table/constants/AggregateOperations'; import { AGGREGATE_OPERATIONS } from '@/object-record/record-table/constants/AggregateOperations';
import { ExtendedAggregateOperations } from '@/object-record/record-table/types/ExtendedAggregateOperations';
export const getAggregateOperationLabel = (operation: AGGREGATE_OPERATIONS) => { export const getAggregateOperationLabel = (
operation: ExtendedAggregateOperations,
) => {
switch (operation) { switch (operation) {
case AGGREGATE_OPERATIONS.min: case AGGREGATE_OPERATIONS.min:
return 'Min'; return 'Min';
@ -22,6 +25,10 @@ export const getAggregateOperationLabel = (operation: AGGREGATE_OPERATIONS) => {
return 'Percent empty'; return 'Percent empty';
case AGGREGATE_OPERATIONS.percentageNotEmpty: case AGGREGATE_OPERATIONS.percentageNotEmpty:
return 'Percent not empty'; return 'Percent not empty';
case 'EARLIEST':
return 'Earliest date';
case 'LATEST':
return 'Latest date';
default: default:
throw new Error(`Unknown aggregate operation: ${operation}`); throw new Error(`Unknown aggregate operation: ${operation}`);
} }

View File

@ -5,10 +5,12 @@ export const FIELD_TYPES_AVAILABLE_FOR_NON_STANDARD_AGGREGATE_OPERATION = {
[AGGREGATE_OPERATIONS.min]: [ [AGGREGATE_OPERATIONS.min]: [
FieldMetadataType.Number, FieldMetadataType.Number,
FieldMetadataType.Currency, FieldMetadataType.Currency,
FieldMetadataType.DateTime,
], ],
[AGGREGATE_OPERATIONS.max]: [ [AGGREGATE_OPERATIONS.max]: [
FieldMetadataType.Number, FieldMetadataType.Number,
FieldMetadataType.Currency, FieldMetadataType.Currency,
FieldMetadataType.DateTime,
], ],
[AGGREGATE_OPERATIONS.avg]: [ [AGGREGATE_OPERATIONS.avg]: [
FieldMetadataType.Number, FieldMetadataType.Number,

View File

@ -2,6 +2,7 @@ import { getAggregateOperationLabel } from '@/object-record/record-board/record-
import { AGGREGATE_OPERATIONS } from '@/object-record/record-table/constants/AggregateOperations'; import { AGGREGATE_OPERATIONS } from '@/object-record/record-table/constants/AggregateOperations';
import { RecordTableColumnAggregateFooterDropdownContext } from '@/object-record/record-table/record-table-footer/components/RecordTableColumnAggregateFooterDropdownContext'; import { RecordTableColumnAggregateFooterDropdownContext } from '@/object-record/record-table/record-table-footer/components/RecordTableColumnAggregateFooterDropdownContext';
import { useViewFieldAggregateOperation } from '@/object-record/record-table/record-table-footer/hooks/useViewFieldAggregateOperation'; import { useViewFieldAggregateOperation } from '@/object-record/record-table/record-table-footer/hooks/useViewFieldAggregateOperation';
import { convertAggregateOperationToExtendedAggregateOperation } from '@/object-record/utils/convertAggregateOperationToExtendedAggregateOperation';
import { useDropdown } from '@/ui/layout/dropdown/hooks/useDropdown'; import { useDropdown } from '@/ui/layout/dropdown/hooks/useDropdown';
import { ReactNode, useContext } from 'react'; import { ReactNode, useContext } from 'react';
import { IconCheck, isDefined, MenuItem } from 'twenty-ui'; import { IconCheck, isDefined, MenuItem } from 'twenty-ui';
@ -18,7 +19,7 @@ export const RecordTableColumnAggregateFooterAggregateOperationMenuItems = ({
currentViewFieldAggregateOperation, currentViewFieldAggregateOperation,
} = useViewFieldAggregateOperation(); } = useViewFieldAggregateOperation();
const { dropdownId, resetContent } = useContext( const { dropdownId, resetContent, fieldMetadataType } = useContext(
RecordTableColumnAggregateFooterDropdownContext, RecordTableColumnAggregateFooterDropdownContext,
); );
const { closeDropdown } = useDropdown(dropdownId); const { closeDropdown } = useDropdown(dropdownId);
@ -31,7 +32,12 @@ export const RecordTableColumnAggregateFooterAggregateOperationMenuItems = ({
updateViewFieldAggregateOperation(operation); updateViewFieldAggregateOperation(operation);
closeDropdown(); closeDropdown();
}} }}
text={getAggregateOperationLabel(operation)} text={getAggregateOperationLabel(
convertAggregateOperationToExtendedAggregateOperation(
operation,
fieldMetadataType,
),
)}
RightIcon={ RightIcon={
currentViewFieldAggregateOperation === operation currentViewFieldAggregateOperation === operation
? IconCheck ? IconCheck

View File

@ -1,5 +1,4 @@
import { useDropdown } from '@/dropdown/hooks/useDropdown'; import { useDropdown } from '@/dropdown/hooks/useDropdown';
import { useRecordTableContextOrThrow } from '@/object-record/record-table/contexts/RecordTableContext';
import { RecordTableColumnAggregateFooterDropdownSubmenuContent } from '@/object-record/record-table/record-table-footer/components/RecordTableColumnAggregateDropdownSubmenuContent'; import { RecordTableColumnAggregateFooterDropdownSubmenuContent } from '@/object-record/record-table/record-table-footer/components/RecordTableColumnAggregateDropdownSubmenuContent';
import { RecordTableColumnAggregateFooterDropdownContext } from '@/object-record/record-table/record-table-footer/components/RecordTableColumnAggregateFooterDropdownContext'; import { RecordTableColumnAggregateFooterDropdownContext } from '@/object-record/record-table/record-table-footer/components/RecordTableColumnAggregateFooterDropdownContext';
import { RecordTableColumnAggregateFooterMenuContent } from '@/object-record/record-table/record-table-footer/components/RecordTableColumnAggregateFooterMenuContent'; import { RecordTableColumnAggregateFooterMenuContent } from '@/object-record/record-table/record-table-footer/components/RecordTableColumnAggregateFooterMenuContent';
@ -9,19 +8,13 @@ import { STANDARD_AGGREGATE_OPERATION_OPTIONS } from '@/object-record/record-tab
import { getAvailableAggregateOperationsForFieldMetadataType } from '@/object-record/record-table/record-table-footer/utils/getAvailableAggregateOperationsForFieldMetadataType'; import { getAvailableAggregateOperationsForFieldMetadataType } from '@/object-record/record-table/record-table-footer/utils/getAvailableAggregateOperationsForFieldMetadataType';
export const RecordTableColumnAggregateFooterDropdownContent = () => { export const RecordTableColumnAggregateFooterDropdownContent = () => {
const { currentContentId, fieldMetadataId } = useDropdown({ const { currentContentId, fieldMetadataType } = useDropdown({
context: RecordTableColumnAggregateFooterDropdownContext, context: RecordTableColumnAggregateFooterDropdownContext,
}); });
const { objectMetadataItem } = useRecordTableContextOrThrow();
const fieldMetadata = objectMetadataItem.fields.find(
(field) => field.id === fieldMetadataId,
);
const availableAggregateOperations = const availableAggregateOperations =
getAvailableAggregateOperationsForFieldMetadataType({ getAvailableAggregateOperationsForFieldMetadataType({
fieldMetadataType: fieldMetadata?.type, fieldMetadataType: fieldMetadataType,
}); });
switch (currentContentId) { switch (currentContentId) {

View File

@ -1,5 +1,6 @@
import { RecordTableFooterAggregateContentId } from '@/object-record/record-table/record-table-footer/types/RecordTableFooterAggregateContentId'; import { RecordTableFooterAggregateContentId } from '@/object-record/record-table/record-table-footer/types/RecordTableFooterAggregateContentId';
import { createContext } from 'react'; import { createContext } from 'react';
import { FieldMetadataType } from '~/generated-metadata/graphql';
export type RecordTableColumnAggregateFooterDropdownContextValue = { export type RecordTableColumnAggregateFooterDropdownContextValue = {
currentContentId: RecordTableFooterAggregateContentId | null; currentContentId: RecordTableFooterAggregateContentId | null;
@ -7,6 +8,7 @@ export type RecordTableColumnAggregateFooterDropdownContextValue = {
resetContent: () => void; resetContent: () => void;
dropdownId: string; dropdownId: string;
fieldMetadataId: string; fieldMetadataId: string;
fieldMetadataType?: FieldMetadataType;
}; };
export const RecordTableColumnAggregateFooterDropdownContext = export const RecordTableColumnAggregateFooterDropdownContext =

View File

@ -36,10 +36,11 @@ export const RecordTableColumnAggregateFooterMenuContent = () => {
[fieldMetadataId, objectMetadataItem.fields], [fieldMetadataId, objectMetadataItem.fields],
); );
const otherAvailableAggregateOperation = availableAggregateOperation.filter( const nonStandardAvailableAggregateOperation =
(aggregateOperation) => availableAggregateOperation.filter(
!STANDARD_AGGREGATE_OPERATION_OPTIONS.includes(aggregateOperation), (aggregateOperation) =>
); !STANDARD_AGGREGATE_OPERATION_OPTIONS.includes(aggregateOperation),
);
const fieldIsRelation = const fieldIsRelation =
objectMetadataItem.fields.find((field) => field.id === fieldMetadataId) objectMetadataItem.fields.find((field) => field.id === fieldMetadataId)
@ -64,7 +65,7 @@ export const RecordTableColumnAggregateFooterMenuContent = () => {
hasSubMenu hasSubMenu
/> />
)} )}
{otherAvailableAggregateOperation.length > 0 ? ( {nonStandardAvailableAggregateOperation.length > 0 ? (
<MenuItem <MenuItem
onClick={() => { onClick={() => {
onContentChange('moreAggregateOperationOptions'); onContentChange('moreAggregateOperationOptions');

View File

@ -16,19 +16,28 @@ const StyledText = styled.span`
z-index: 1; z-index: 1;
`; `;
const StyledValueContainer = styled.div` const StyledScrollableContainer = styled.div`
overflow-x: auto;
white-space: nowrap;
scrollbar-width: none;
-ms-overflow-style: none;
&::-webkit-scrollbar {
display: none;
}
`;
const StyledValueContainer = styled(StyledScrollableContainer)`
align-items: center; align-items: center;
display: flex; display: flex;
flex: 1 0 0;
gap: 4px; gap: 4px;
height: 32px; height: 32px;
justify-content: flex-end; justify-content: flex-end;
padding: 0 8px; padding: 0 8px;
`; `;
const StyledValue = styled.div` const StyledValue = styled(StyledScrollableContainer)`
color: ${({ theme }) => theme.color.gray60}; color: ${({ theme }) => theme.color.gray60};
flex: 1 0 0;
`; `;
export const RecordTableColumnAggregateFooterValue = ({ export const RecordTableColumnAggregateFooterValue = ({

View File

@ -29,6 +29,7 @@ const StyledIcon = styled(IconChevronDown)`
height: 20px; height: 20px;
justify-content: center; justify-content: center;
flex-grow: 0; flex-grow: 0;
flex-shrink: 0;
padding-right: ${({ theme }) => theme.spacing(2)}; padding-right: ${({ theme }) => theme.spacing(2)};
`; `;

View File

@ -1,4 +1,5 @@
import { useCurrentContentId } from '@/dropdown/hooks/useCurrentContentId'; import { useCurrentContentId } from '@/dropdown/hooks/useCurrentContentId';
import { useRecordTableContextOrThrow } from '@/object-record/record-table/contexts/RecordTableContext';
import { RecordTableColumnAggregateFooterCellContext } from '@/object-record/record-table/record-table-footer/components/RecordTableColumnAggregateFooterCellContext'; import { RecordTableColumnAggregateFooterCellContext } from '@/object-record/record-table/record-table-footer/components/RecordTableColumnAggregateFooterCellContext';
import { RecordTableColumnAggregateFooterDropdownContent } from '@/object-record/record-table/record-table-footer/components/RecordTableColumnAggregateFooterDropdownContent'; import { RecordTableColumnAggregateFooterDropdownContent } from '@/object-record/record-table/record-table-footer/components/RecordTableColumnAggregateFooterDropdownContent';
import { RecordTableColumnAggregateFooterDropdownContext } from '@/object-record/record-table/record-table-footer/components/RecordTableColumnAggregateFooterDropdownContext'; import { RecordTableColumnAggregateFooterDropdownContext } from '@/object-record/record-table/record-table-footer/components/RecordTableColumnAggregateFooterDropdownContext';
@ -24,6 +25,12 @@ export const RecordTableColumnFooterWithDropdown = ({
RecordTableColumnAggregateFooterCellContext, RecordTableColumnAggregateFooterCellContext,
); );
const { objectMetadataItem } = useRecordTableContextOrThrow();
const fieldMetadata = objectMetadataItem.fields.find(
(field) => field.id === fieldMetadataId,
);
const { toggleScrollXWrapper, toggleScrollYWrapper } = const { toggleScrollXWrapper, toggleScrollYWrapper } =
useToggleScrollWrapper(); useToggleScrollWrapper();
@ -61,6 +68,7 @@ export const RecordTableColumnFooterWithDropdown = ({
resetContent: handleResetContent, resetContent: handleResetContent,
dropdownId: dropdownId, dropdownId: dropdownId,
fieldMetadataId: fieldMetadataId, fieldMetadataId: fieldMetadataId,
fieldMetadataType: fieldMetadata?.type,
}} }}
> >
<RecordTableColumnAggregateFooterDropdownContent /> <RecordTableColumnAggregateFooterDropdownContent />

View File

@ -8,6 +8,7 @@ import { recordIndexViewFilterGroupsState } from '@/object-record/record-index/s
import { useRecordTableContextOrThrow } from '@/object-record/record-table/contexts/RecordTableContext'; import { useRecordTableContextOrThrow } from '@/object-record/record-table/contexts/RecordTableContext';
import { RecordTableColumnAggregateFooterCellContext } from '@/object-record/record-table/record-table-footer/components/RecordTableColumnAggregateFooterCellContext'; import { RecordTableColumnAggregateFooterCellContext } from '@/object-record/record-table/record-table-footer/components/RecordTableColumnAggregateFooterCellContext';
import { viewFieldAggregateOperationState } from '@/object-record/record-table/record-table-footer/states/viewFieldAggregateOperationState'; import { viewFieldAggregateOperationState } from '@/object-record/record-table/record-table-footer/states/viewFieldAggregateOperationState';
import { UserContext } from '@/users/contexts/UserContext';
import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled'; import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
import { useContext } from 'react'; import { useContext } from 'react';
import { useRecoilValue } from 'recoil'; import { useRecoilValue } from 'recoil';
@ -64,11 +65,16 @@ export const useAggregateRecordsForRecordTableColumnFooter = (
!isAggregateQueryEnabled || !isDefined(aggregateOperationForViewField), !isAggregateQueryEnabled || !isDefined(aggregateOperationForViewField),
}); });
const { dateFormat, timeFormat, timeZone } = useContext(UserContext);
const { value, label } = computeAggregateValueAndLabel({ const { value, label } = computeAggregateValueAndLabel({
data, data,
objectMetadataItem, objectMetadataItem,
fieldMetadataId: fieldMetadataId, fieldMetadataId: fieldMetadataId,
aggregateOperation: aggregateOperationForViewField, aggregateOperation: aggregateOperationForViewField,
dateFormat,
timeFormat,
timeZone,
}); });
return { return {

View File

@ -0,0 +1,6 @@
import { AGGREGATE_OPERATIONS } from '@/object-record/record-table/constants/AggregateOperations';
export type ExtendedAggregateOperations =
| AGGREGATE_OPERATIONS
| 'EARLIEST'
| 'LATEST';

View File

@ -1,5 +1,5 @@
import { AGGREGATE_OPERATIONS } from '@/object-record/record-table/constants/AggregateOperations'; import { ExtendedAggregateOperations } from '@/object-record/record-table/types/ExtendedAggregateOperations';
export type AvailableFieldsForAggregateOperation = { export type AvailableFieldsForAggregateOperation = {
[T in AGGREGATE_OPERATIONS]?: string[]; [T in ExtendedAggregateOperations]?: string[];
}; };

View File

@ -0,0 +1,18 @@
import { AGGREGATE_OPERATIONS } from '@/object-record/record-table/constants/AggregateOperations';
import { ExtendedAggregateOperations } from '@/object-record/record-table/types/ExtendedAggregateOperations';
import { FieldMetadataType } from '~/generated-metadata/graphql';
export const convertAggregateOperationToExtendedAggregateOperation = (
aggregateOperation: AGGREGATE_OPERATIONS,
fieldType?: FieldMetadataType,
): ExtendedAggregateOperations => {
if (fieldType === FieldMetadataType.DateTime) {
if (aggregateOperation === AGGREGATE_OPERATIONS.min) {
return 'EARLIEST';
}
if (aggregateOperation === AGGREGATE_OPERATIONS.max) {
return 'LATEST';
}
}
return aggregateOperation;
};

View File

@ -0,0 +1,15 @@
import { AGGREGATE_OPERATIONS } from '@/object-record/record-table/constants/AggregateOperations';
import { ExtendedAggregateOperations } from '@/object-record/record-table/types/ExtendedAggregateOperations';
export const convertExtendedAggregateOperationToAggregateOperation = (
extendedAggregateOperation: ExtendedAggregateOperations,
) => {
if (extendedAggregateOperation === 'EARLIEST') {
return AGGREGATE_OPERATIONS.min;
}
if (extendedAggregateOperation === 'LATEST') {
return AGGREGATE_OPERATIONS.max;
}
return extendedAggregateOperation;
};

View File

@ -55,6 +55,14 @@ export const getAvailableAggregationsFromObjectFields = (
}; };
} }
if (field.type === FieldMetadataType.DateTime) {
acc[field.name] = {
...acc[field.name],
[AGGREGATE_OPERATIONS.min]: `min${capitalize(field.name)}`,
[AGGREGATE_OPERATIONS.max]: `max${capitalize(field.name)}`,
};
}
if (acc[field.name] === undefined) { if (acc[field.name] === undefined) {
acc[field.name] = {}; acc[field.name] = {};
} }

View File

@ -1,6 +1,8 @@
import { FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem'; import { FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { AGGREGATE_OPERATIONS } from '@/object-record/record-table/constants/AggregateOperations'; import { AGGREGATE_OPERATIONS } from '@/object-record/record-table/constants/AggregateOperations';
import { ExtendedAggregateOperations } from '@/object-record/record-table/types/ExtendedAggregateOperations';
import { AvailableFieldsForAggregateOperation } from '@/object-record/types/AvailableFieldsForAggregateOperation'; import { AvailableFieldsForAggregateOperation } from '@/object-record/types/AvailableFieldsForAggregateOperation';
import { convertAggregateOperationToExtendedAggregateOperation } from '@/object-record/utils/convertAggregateOperationToExtendedAggregateOperation';
import { getAvailableAggregationsFromObjectFields } from '@/object-record/utils/getAvailableAggregationsFromObjectFields'; import { getAvailableAggregationsFromObjectFields } from '@/object-record/utils/getAvailableAggregationsFromObjectFields';
import { initializeAvailableFieldsForAggregateOperationMap } from '@/object-record/utils/initializeAvailableFieldsForAggregateOperationMap'; import { initializeAvailableFieldsForAggregateOperationMap } from '@/object-record/utils/initializeAvailableFieldsForAggregateOperationMap';
import { isDefined } from '~/utils/isDefined'; import { isDefined } from '~/utils/isDefined';
@ -18,10 +20,20 @@ export const getAvailableFieldsIdsForAggregationFromObjectFields = (
return fields.reduce((acc, field) => { return fields.reduce((acc, field) => {
if (isDefined(allAggregations[field.name])) { if (isDefined(allAggregations[field.name])) {
Object.keys(allAggregations[field.name]).forEach((aggregation) => { Object.keys(allAggregations[field.name]).forEach((aggregation) => {
const typedAggregateOperation = aggregation as AGGREGATE_OPERATIONS; if (
targetAggregateOperations.includes(
if (targetAggregateOperations.includes(typedAggregateOperation)) { aggregation as AGGREGATE_OPERATIONS,
acc[typedAggregateOperation]?.push(field.id); )
) {
const convertedAggregateOperation: ExtendedAggregateOperations =
convertAggregateOperationToExtendedAggregateOperation(
aggregation as AGGREGATE_OPERATIONS,
field.type,
);
if (!isDefined(acc[convertedAggregateOperation])) {
acc[convertedAggregateOperation] = [];
}
(acc[convertedAggregateOperation] as string[]).push(field.id);
} }
}); });
} }

View File

@ -1,5 +1,6 @@
import { AGGREGATE_OPERATIONS } from '@/object-record/record-table/constants/AggregateOperations'; import { AGGREGATE_OPERATIONS } from '@/object-record/record-table/constants/AggregateOperations';
import { AvailableFieldsForAggregateOperation } from '@/object-record/types/AvailableFieldsForAggregateOperation'; import { AvailableFieldsForAggregateOperation } from '@/object-record/types/AvailableFieldsForAggregateOperation';
import { convertAggregateOperationToExtendedAggregateOperation } from '@/object-record/utils/convertAggregateOperationToExtendedAggregateOperation';
export const initializeAvailableFieldsForAggregateOperationMap = ( export const initializeAvailableFieldsForAggregateOperationMap = (
aggregateOperations: AGGREGATE_OPERATIONS[], aggregateOperations: AGGREGATE_OPERATIONS[],
@ -7,7 +8,7 @@ export const initializeAvailableFieldsForAggregateOperationMap = (
return aggregateOperations.reduce( return aggregateOperations.reduce(
(acc, operation) => ({ (acc, operation) => ({
...acc, ...acc,
[operation]: [], [convertAggregateOperationToExtendedAggregateOperation(operation)]: [],
}), }),
{}, {},
); );

View File

@ -1,7 +1,6 @@
import { formatDateISOStringToDateTime } from '@/localization/utils/formatDateISOStringToDateTime';
import { formatDateISOStringToRelativeDate } from '@/localization/utils/formatDateISOStringToRelativeDate';
import { UserContext } from '@/users/contexts/UserContext'; import { UserContext } from '@/users/contexts/UserContext';
import { useContext } from 'react'; import { useContext } from 'react';
import { formatDateString } from '~/utils/string/formatDateString';
import { EllipsisDisplay } from './EllipsisDisplay'; import { EllipsisDisplay } from './EllipsisDisplay';
type DateTimeDisplayProps = { type DateTimeDisplayProps = {
@ -15,11 +14,13 @@ export const DateTimeDisplay = ({
}: DateTimeDisplayProps) => { }: DateTimeDisplayProps) => {
const { dateFormat, timeFormat, timeZone } = useContext(UserContext); const { dateFormat, timeFormat, timeZone } = useContext(UserContext);
const formattedDate = value const formattedDate = formatDateString({
? displayAsRelativeDate value,
? formatDateISOStringToRelativeDate(value) displayAsRelativeDate,
: formatDateISOStringToDateTime(value, timeZone, dateFormat, timeFormat) timeZone,
: ''; dateFormat,
timeFormat,
});
return <EllipsisDisplay>{formattedDate}</EllipsisDisplay>; return <EllipsisDisplay>{formattedDate}</EllipsisDisplay>;
}; };

View File

@ -0,0 +1,86 @@
import { DateFormat } from '@/localization/constants/DateFormat';
import { TimeFormat } from '@/localization/constants/TimeFormat';
import { DateTime } from 'luxon';
import { formatDateString } from '~/utils/string/formatDateString';
describe('formatDateString', () => {
const defaultParams = {
timeZone: 'UTC',
dateFormat: DateFormat.DAY_FIRST,
timeFormat: TimeFormat.HOUR_24,
};
it('should return empty string for null value', () => {
const result = formatDateString({
...defaultParams,
value: null,
});
expect(result).toBe('');
});
it('should return empty string for undefined value', () => {
const result = formatDateString({
...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 = formatDateString({
...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 = formatDateString({
...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 = formatDateString({
...defaultParams,
value: mockDate,
});
expect(result).toBe(mockFormattedDate);
});
});

View File

@ -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 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)
: '';
return formattedDate;
};

View File

@ -79,7 +79,7 @@ export const getAvailableAggregationsFromObjectFields = (
case FieldMetadataType.DATE_TIME: case FieldMetadataType.DATE_TIME:
acc[`min${capitalize(field.name)}`] = { acc[`min${capitalize(field.name)}`] = {
type: GraphQLISODateTime, type: GraphQLISODateTime,
description: `Oldest date contained in the field ${field.name}`, description: `Earliest date contained in the field ${field.name}`,
fromField: field.name, fromField: field.name,
fromFieldType: field.type, fromFieldType: field.type,
aggregateOperation: AGGREGATE_OPERATIONS.min, aggregateOperation: AGGREGATE_OPERATIONS.min,
@ -87,7 +87,7 @@ export const getAvailableAggregationsFromObjectFields = (
acc[`max${capitalize(field.name)}`] = { acc[`max${capitalize(field.name)}`] = {
type: GraphQLISODateTime, type: GraphQLISODateTime,
description: `Most recent date contained in the field ${field.name}`, description: `Latest date contained in the field ${field.name}`,
fromField: field.name, fromField: field.name,
fromFieldType: field.type, fromFieldType: field.type,
aggregateOperation: AGGREGATE_OPERATIONS.max, aggregateOperation: AGGREGATE_OPERATIONS.max,