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 { getAggregateOperationLabel } from '@/object-record/record-board/record-board-column/utils/getAggregateOperationLabel';
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 { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer';
import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2';
@ -47,6 +48,8 @@ export const RecordBoardColumnHeaderAggregateDropdownFieldsContent = () => {
if (!isDefined(aggregateOperation)) return <></>;
const convertedAggregateOperation =
convertExtendedAggregateOperationToAggregateOperation(aggregateOperation);
return (
<>
<DropdownMenuHeader
@ -72,7 +75,7 @@ export const RecordBoardColumnHeaderAggregateDropdownFieldsContent = () => {
onClick={() => {
updateViewAggregate({
kanbanAggregateOperationFieldMetadataId: fieldId,
kanbanAggregateOperation: aggregateOperation,
kanbanAggregateOperation: convertedAggregateOperation,
});
closeDropdown();
}}
@ -82,7 +85,7 @@ export const RecordBoardColumnHeaderAggregateDropdownFieldsContent = () => {
recordIndexKanbanAggregateOperation?.fieldMetadataId ===
fieldId &&
recordIndexKanbanAggregateOperation?.operation ===
aggregateOperation
convertedAggregateOperation
? IconCheck
: 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 { getAggregateOperationLabel } from '@/object-record/record-board/record-board-column/utils/getAggregateOperationLabel';
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 { AvailableFieldsForAggregateOperation } from '@/object-record/types/AvailableFieldsForAggregateOperation';
import { DropdownMenuHeader } from '@/ui/layout/dropdown/components/DropdownMenuHeader';
@ -69,7 +70,7 @@ export const RecordBoardColumnHeaderAggregateDropdownOptionsContent = ({
availableAggregationOperation !== AGGREGATE_OPERATIONS.count
) {
setAggregateOperation(
availableAggregationOperation as AGGREGATE_OPERATIONS,
availableAggregationOperation as ExtendedAggregateOperations,
);
setAvailableFieldsForAggregateOperation(
@ -87,7 +88,7 @@ export const RecordBoardColumnHeaderAggregateDropdownOptionsContent = ({
}
}}
text={getAggregateOperationLabel(
availableAggregationOperation as AGGREGATE_OPERATIONS,
availableAggregationOperation as ExtendedAggregateOperations,
)}
hasSubMenu={
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 { recordIndexKanbanFieldMetadataIdState } from '@/object-record/record-index/states/recordIndexKanbanFieldMetadataIdState';
import { recordIndexViewFilterGroupsState } from '@/object-record/record-index/states/recordIndexViewFilterGroupsState';
import { UserContext } from '@/users/contexts/UserContext';
import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
import { useContext } from 'react';
import { useRecoilValue } from 'recoil';
@ -80,12 +81,17 @@ export const useAggregateRecordsForRecordBoardColumn = () => {
skip: !isAggregateQueryEnabled,
});
const { dateFormat, timeFormat, timeZone } = useContext(UserContext);
const { value, labelWithFieldName } = computeAggregateValueAndLabel({
data,
objectMetadataItem,
fieldMetadataId: recordIndexKanbanAggregateOperation?.fieldMetadataId,
aggregateOperation: recordIndexKanbanAggregateOperation?.operation,
fallbackFieldName: kanbanFieldName,
dateFormat,
timeFormat,
timeZone,
});
return {

View File

@ -1,9 +1,9 @@
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';
export const aggregateOperationComponentState =
createComponentStateV2<AGGREGATE_OPERATIONS | null>({
createComponentStateV2<ExtendedAggregateOperations | null>({
key: 'aggregateOperationComponentFamilyState',
defaultValue: null,
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 { 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 { AGGREGATE_OPERATIONS } from '@/object-record/record-table/constants/AggregateOperations';
import { FieldMetadataType } from '~/generated/graphql';
@ -20,13 +23,20 @@ describe('computeAggregateValueAndLabel', () => {
],
} as ObjectMetadataItem;
const defaultParams = {
dateFormat: DateFormat.DAY_FIRST,
timeFormat: TimeFormat.HOUR_24,
timeZone: 'UTC',
};
it('should return empty object for empty data', () => {
const result = computeAggregateValueAndLabel({
data: {},
data: {} as AggregateRecordsData,
objectMetadataItem: mockObjectMetadata,
fieldMetadataId: MOCK_FIELD_ID,
aggregateOperation: AGGREGATE_OPERATIONS.sum,
fallbackFieldName: MOCK_KANBAN_FIELD_NAME,
...defaultParams,
});
expect(result).toEqual({});
@ -37,7 +47,7 @@ describe('computeAggregateValueAndLabel', () => {
amount: {
[AGGREGATE_OPERATIONS.sum]: 2000000,
},
};
} as AggregateRecordsData;
const result = computeAggregateValueAndLabel({
data: mockData,
@ -45,6 +55,7 @@ describe('computeAggregateValueAndLabel', () => {
fieldMetadataId: MOCK_FIELD_ID,
aggregateOperation: AGGREGATE_OPERATIONS.sum,
fallbackFieldName: MOCK_KANBAN_FIELD_NAME,
...defaultParams,
});
expect(result).toEqual({
@ -74,7 +85,7 @@ describe('computeAggregateValueAndLabel', () => {
percentage: {
[AGGREGATE_OPERATIONS.avg]: 0.3,
},
};
} as AggregateRecordsData;
const result = computeAggregateValueAndLabel({
data: mockData,
@ -82,6 +93,7 @@ describe('computeAggregateValueAndLabel', () => {
fieldMetadataId: MOCK_FIELD_ID,
aggregateOperation: AGGREGATE_OPERATIONS.avg,
fallbackFieldName: MOCK_KANBAN_FIELD_NAME,
...defaultParams,
});
expect(result).toEqual({
@ -111,7 +123,7 @@ describe('computeAggregateValueAndLabel', () => {
decimals: {
[AGGREGATE_OPERATIONS.sum]: 0.009,
},
};
} as AggregateRecordsData;
const result = computeAggregateValueAndLabel({
data: mockData,
@ -119,6 +131,7 @@ describe('computeAggregateValueAndLabel', () => {
fieldMetadataId: MOCK_FIELD_ID,
aggregateOperation: AGGREGATE_OPERATIONS.sum,
fallbackFieldName: MOCK_KANBAN_FIELD_NAME,
...defaultParams,
});
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', () => {
const mockData = {
[MOCK_KANBAN_FIELD_NAME]: {
[AGGREGATE_OPERATIONS.count]: 42,
},
};
} as AggregateRecordsData;
const result = computeAggregateValueAndLabel({
data: mockData,
objectMetadataItem: mockObjectMetadata,
fallbackFieldName: MOCK_KANBAN_FIELD_NAME,
...defaultParams,
});
expect(result).toEqual({
@ -153,13 +237,14 @@ describe('computeAggregateValueAndLabel', () => {
amount: {
[AGGREGATE_OPERATIONS.sum]: undefined,
},
};
} as AggregateRecordsData;
const result = computeAggregateValueAndLabel({
data: mockData,
objectMetadataItem: mockObjectMetadata,
fieldMetadataId: MOCK_FIELD_ID,
aggregateOperation: AGGREGATE_OPERATIONS.sum,
...defaultParams,
});
expect(result).toEqual({

View File

@ -1,5 +1,6 @@
import { getAggregateOperationLabel } from '@/object-record/record-board/record-board-column/utils/getAggregateOperationLabel';
import { AGGREGATE_OPERATIONS } from '@/object-record/record-table/constants/AggregateOperations';
import { expect } from '@storybook/test';
describe('getAggregateOperationLabel', () => {
it('should return correct labels for each operation', () => {
@ -8,6 +9,8 @@ describe('getAggregateOperationLabel', () => {
expect(getAggregateOperationLabel(AGGREGATE_OPERATIONS.avg)).toBe(
'Average',
);
expect(getAggregateOperationLabel('EARLIEST')).toBe('Earliest date');
expect(getAggregateOperationLabel('LATEST')).toBe('Latest date');
expect(getAggregateOperationLabel(AGGREGATE_OPERATIONS.sum)).toBe('Sum');
expect(getAggregateOperationLabel(AGGREGATE_OPERATIONS.count)).toBe(
'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 { AggregateRecordsData } from '@/object-record/hooks/useAggregateRecords';
import { getAggregateOperationLabel } from '@/object-record/record-board/record-board-column/utils/getAggregateOperationLabel';
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 { 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 { FieldMetadataType } from '~/generated-metadata/graphql';
import { formatAmount } from '~/utils/format/formatAmount';
import { formatNumber } from '~/utils/format/number';
import { isDefined } from '~/utils/isDefined';
import { formatDateString } from '~/utils/string/formatDateString';
export const computeAggregateValueAndLabel = ({
data,
@ -16,12 +20,18 @@ export const computeAggregateValueAndLabel = ({
fieldMetadataId,
aggregateOperation,
fallbackFieldName,
dateFormat,
timeFormat,
timeZone,
}: {
data: AggregateRecordsData;
objectMetadataItem: ObjectMetadataItem;
fieldMetadataId?: string | null;
aggregateOperation?: AGGREGATE_OPERATIONS | null;
fallbackFieldName?: string;
dateFormat: DateFormat;
timeFormat: TimeFormat;
timeZone: string;
}) => {
if (isEmpty(data)) {
return {};
@ -49,6 +59,8 @@ export const computeAggregateValueAndLabel = ({
let value;
const displayAsRelativeDate = field?.settings?.displayAsRelativeDate;
if (COUNT_AGGREGATE_OPERATION_OPTIONS.includes(aggregateOperation)) {
value = aggregateValue;
} else if (!isDefined(aggregateValue)) {
@ -56,15 +68,15 @@ export const computeAggregateValueAndLabel = ({
} else if (PERCENT_AGGREGATE_OPERATION_OPTIONS.includes(aggregateOperation)) {
value = `${formatNumber(Number(aggregateValue) * 100)}%`;
} else {
value = Number(aggregateValue);
switch (field.type) {
case FieldMetadataType.Currency: {
value = Number(aggregateValue);
value = formatAmount(value / 1_000_000);
break;
}
case FieldMetadataType.Number: {
value = Number(aggregateValue);
const { decimals, type } = field.settings ?? {};
value =
type === 'percentage'
@ -72,13 +84,30 @@ export const computeAggregateValueAndLabel = ({
: formatNumber(value, decimals);
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 =
aggregateOperation === AGGREGATE_OPERATIONS.count
? `${getAggregateOperationLabel(AGGREGATE_OPERATIONS.count)}`
: `${getAggregateOperationLabel(aggregateOperation)} of ${field.label}`;
: `${getAggregateOperationLabel(convertedAggregateOperation)} of ${field.label}`;
return {
value,

View File

@ -1,6 +1,9 @@
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) {
case AGGREGATE_OPERATIONS.min:
return 'Min';
@ -22,6 +25,10 @@ export const getAggregateOperationLabel = (operation: AGGREGATE_OPERATIONS) => {
return 'Percent empty';
case AGGREGATE_OPERATIONS.percentageNotEmpty:
return 'Percent not empty';
case 'EARLIEST':
return 'Earliest date';
case 'LATEST':
return 'Latest date';
default:
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]: [
FieldMetadataType.Number,
FieldMetadataType.Currency,
FieldMetadataType.DateTime,
],
[AGGREGATE_OPERATIONS.max]: [
FieldMetadataType.Number,
FieldMetadataType.Currency,
FieldMetadataType.DateTime,
],
[AGGREGATE_OPERATIONS.avg]: [
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 { RecordTableColumnAggregateFooterDropdownContext } from '@/object-record/record-table/record-table-footer/components/RecordTableColumnAggregateFooterDropdownContext';
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 { ReactNode, useContext } from 'react';
import { IconCheck, isDefined, MenuItem } from 'twenty-ui';
@ -18,7 +19,7 @@ export const RecordTableColumnAggregateFooterAggregateOperationMenuItems = ({
currentViewFieldAggregateOperation,
} = useViewFieldAggregateOperation();
const { dropdownId, resetContent } = useContext(
const { dropdownId, resetContent, fieldMetadataType } = useContext(
RecordTableColumnAggregateFooterDropdownContext,
);
const { closeDropdown } = useDropdown(dropdownId);
@ -31,7 +32,12 @@ export const RecordTableColumnAggregateFooterAggregateOperationMenuItems = ({
updateViewFieldAggregateOperation(operation);
closeDropdown();
}}
text={getAggregateOperationLabel(operation)}
text={getAggregateOperationLabel(
convertAggregateOperationToExtendedAggregateOperation(
operation,
fieldMetadataType,
),
)}
RightIcon={
currentViewFieldAggregateOperation === operation
? IconCheck

View File

@ -1,5 +1,4 @@
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 { RecordTableColumnAggregateFooterDropdownContext } from '@/object-record/record-table/record-table-footer/components/RecordTableColumnAggregateFooterDropdownContext';
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';
export const RecordTableColumnAggregateFooterDropdownContent = () => {
const { currentContentId, fieldMetadataId } = useDropdown({
const { currentContentId, fieldMetadataType } = useDropdown({
context: RecordTableColumnAggregateFooterDropdownContext,
});
const { objectMetadataItem } = useRecordTableContextOrThrow();
const fieldMetadata = objectMetadataItem.fields.find(
(field) => field.id === fieldMetadataId,
);
const availableAggregateOperations =
getAvailableAggregateOperationsForFieldMetadataType({
fieldMetadataType: fieldMetadata?.type,
fieldMetadataType: fieldMetadataType,
});
switch (currentContentId) {

View File

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

View File

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

View File

@ -16,19 +16,28 @@ const StyledText = styled.span`
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;
display: flex;
flex: 1 0 0;
gap: 4px;
height: 32px;
justify-content: flex-end;
padding: 0 8px;
`;
const StyledValue = styled.div`
const StyledValue = styled(StyledScrollableContainer)`
color: ${({ theme }) => theme.color.gray60};
flex: 1 0 0;
`;
export const RecordTableColumnAggregateFooterValue = ({

View File

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

View File

@ -1,4 +1,5 @@
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 { RecordTableColumnAggregateFooterDropdownContent } from '@/object-record/record-table/record-table-footer/components/RecordTableColumnAggregateFooterDropdownContent';
import { RecordTableColumnAggregateFooterDropdownContext } from '@/object-record/record-table/record-table-footer/components/RecordTableColumnAggregateFooterDropdownContext';
@ -24,6 +25,12 @@ export const RecordTableColumnFooterWithDropdown = ({
RecordTableColumnAggregateFooterCellContext,
);
const { objectMetadataItem } = useRecordTableContextOrThrow();
const fieldMetadata = objectMetadataItem.fields.find(
(field) => field.id === fieldMetadataId,
);
const { toggleScrollXWrapper, toggleScrollYWrapper } =
useToggleScrollWrapper();
@ -61,6 +68,7 @@ export const RecordTableColumnFooterWithDropdown = ({
resetContent: handleResetContent,
dropdownId: dropdownId,
fieldMetadataId: fieldMetadataId,
fieldMetadataType: fieldMetadata?.type,
}}
>
<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 { RecordTableColumnAggregateFooterCellContext } from '@/object-record/record-table/record-table-footer/components/RecordTableColumnAggregateFooterCellContext';
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 { useContext } from 'react';
import { useRecoilValue } from 'recoil';
@ -64,11 +65,16 @@ export const useAggregateRecordsForRecordTableColumnFooter = (
!isAggregateQueryEnabled || !isDefined(aggregateOperationForViewField),
});
const { dateFormat, timeFormat, timeZone } = useContext(UserContext);
const { value, label } = computeAggregateValueAndLabel({
data,
objectMetadataItem,
fieldMetadataId: fieldMetadataId,
aggregateOperation: aggregateOperationForViewField,
dateFormat,
timeFormat,
timeZone,
});
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 = {
[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) {
acc[field.name] = {};
}

View File

@ -1,6 +1,8 @@
import { FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
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 { convertAggregateOperationToExtendedAggregateOperation } from '@/object-record/utils/convertAggregateOperationToExtendedAggregateOperation';
import { getAvailableAggregationsFromObjectFields } from '@/object-record/utils/getAvailableAggregationsFromObjectFields';
import { initializeAvailableFieldsForAggregateOperationMap } from '@/object-record/utils/initializeAvailableFieldsForAggregateOperationMap';
import { isDefined } from '~/utils/isDefined';
@ -18,10 +20,20 @@ export const getAvailableFieldsIdsForAggregationFromObjectFields = (
return fields.reduce((acc, field) => {
if (isDefined(allAggregations[field.name])) {
Object.keys(allAggregations[field.name]).forEach((aggregation) => {
const typedAggregateOperation = aggregation as AGGREGATE_OPERATIONS;
if (targetAggregateOperations.includes(typedAggregateOperation)) {
acc[typedAggregateOperation]?.push(field.id);
if (
targetAggregateOperations.includes(
aggregation as AGGREGATE_OPERATIONS,
)
) {
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 { AvailableFieldsForAggregateOperation } from '@/object-record/types/AvailableFieldsForAggregateOperation';
import { convertAggregateOperationToExtendedAggregateOperation } from '@/object-record/utils/convertAggregateOperationToExtendedAggregateOperation';
export const initializeAvailableFieldsForAggregateOperationMap = (
aggregateOperations: AGGREGATE_OPERATIONS[],
@ -7,7 +8,7 @@ export const initializeAvailableFieldsForAggregateOperationMap = (
return aggregateOperations.reduce(
(acc, operation) => ({
...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 { useContext } from 'react';
import { formatDateString } from '~/utils/string/formatDateString';
import { EllipsisDisplay } from './EllipsisDisplay';
type DateTimeDisplayProps = {
@ -15,11 +14,13 @@ export const DateTimeDisplay = ({
}: DateTimeDisplayProps) => {
const { dateFormat, timeFormat, timeZone } = useContext(UserContext);
const formattedDate = value
? displayAsRelativeDate
? formatDateISOStringToRelativeDate(value)
: formatDateISOStringToDateTime(value, timeZone, dateFormat, timeFormat)
: '';
const formattedDate = formatDateString({
value,
displayAsRelativeDate,
timeZone,
dateFormat,
timeFormat,
});
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:
acc[`min${capitalize(field.name)}`] = {
type: GraphQLISODateTime,
description: `Oldest date contained in the field ${field.name}`,
description: `Earliest date contained in the field ${field.name}`,
fromField: field.name,
fromFieldType: field.type,
aggregateOperation: AGGREGATE_OPERATIONS.min,
@ -87,7 +87,7 @@ export const getAvailableAggregationsFromObjectFields = (
acc[`max${capitalize(field.name)}`] = {
type: GraphQLISODateTime,
description: `Most recent date contained in the field ${field.name}`,
description: `Latest date contained in the field ${field.name}`,
fromField: field.name,
fromFieldType: field.type,
aggregateOperation: AGGREGATE_OPERATIONS.max,