Implement aggregate operations on dates (#9444)
Adding aggregate operations for dates: min ("Earliest date") and max
("Latest date")
This commit is contained in:
@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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:
|
||||||
|
|||||||
@ -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({
|
||||||
|
|||||||
@ -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',
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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}`);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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) {
|
||||||
|
|||||||
@ -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 =
|
||||||
|
|||||||
@ -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');
|
||||||
|
|||||||
@ -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 = ({
|
||||||
|
|||||||
@ -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)};
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
|||||||
@ -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 />
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -0,0 +1,6 @@
|
|||||||
|
import { AGGREGATE_OPERATIONS } from '@/object-record/record-table/constants/AggregateOperations';
|
||||||
|
|
||||||
|
export type ExtendedAggregateOperations =
|
||||||
|
| AGGREGATE_OPERATIONS
|
||||||
|
| 'EARLIEST'
|
||||||
|
| 'LATEST';
|
||||||
@ -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[];
|
||||||
};
|
};
|
||||||
|
|||||||
@ -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;
|
||||||
|
};
|
||||||
@ -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;
|
||||||
|
};
|
||||||
@ -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] = {};
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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)]: [],
|
||||||
}),
|
}),
|
||||||
{},
|
{},
|
||||||
);
|
);
|
||||||
|
|||||||
@ -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>;
|
||||||
};
|
};
|
||||||
|
|||||||
@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
26
packages/twenty-front/src/utils/string/formatDateString.ts
Normal file
26
packages/twenty-front/src/utils/string/formatDateString.ts
Normal 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;
|
||||||
|
};
|
||||||
@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user