From a9b95bcf03af7c491ea7388e4c27c6000a86d847 Mon Sep 17 00:00:00 2001 From: Marie <51697796+ijreilly@users.noreply.github.com> Date: Mon, 6 Jan 2025 17:57:32 +0100 Subject: [PATCH] Add count and percent aggregations to kanban headers (#9348) Closes https://github.com/twentyhq/private-issues/issues/226 https://github.com/user-attachments/assets/cee78080-6dda-4102-9595-d32971cf9104 --- .../dropdown/hooks/useCurrentContentId.ts | 14 +- .../useAggregateRecordsQuery.test.tsx | 6 - ...cordBoardColumnHeaderAggregateDropdown.tsx | 9 +- ...rdColumnHeaderAggregateDropdownContent.tsx | 50 ++++- ...rdColumnHeaderAggregateDropdownContext.tsx | 1 + ...mnHeaderAggregateDropdownFieldsContent.tsx | 14 +- ...lumnHeaderAggregateDropdownMenuContent.tsx | 37 ++-- ...derAggregateDropdownMoreOptionsContent.tsx | 89 --------- ...nHeaderAggregateDropdownOptionsContent.tsx | 103 +++++++++++ .../computeAggregateValueAndLabel.test.ts | 3 + .../utils/computeAggregateValueAndLabel.ts | 2 +- ...cordBoardColumnHeaderAggregateContentId.ts | 1 + ...ilableForNonStandardAggregateOperation.ts} | 2 +- .../nonStandardAggregateOperationsOptions.tsx | 8 + ...AggregateOperationsForFieldMetadataType.ts | 4 +- .../AvailableFieldsForAggregateOperation.ts | 4 +- ...sIdsForAggregationFromObjectFields.test.ts | 171 +++++++++++++----- ...ableFieldsForAggregateOperationMap.test.ts | 44 ++++- ...etAvailableAggregationsFromObjectFields.ts | 28 +-- ...FieldsIdsForAggregationFromObjectFields.ts | 32 ++-- ...AvailableFieldsForAggregateOperationMap.ts | 23 +-- .../isFieldTypeValidForAggregateOperation.ts | 8 +- .../helpers/process-aggregate.helper.ts | 40 ++-- ...le-aggregations-from-object-fields.util.ts | 44 ++--- ...-composite-field-and-subfield.util.spec.ts | 16 +- ...-from-composite-field-and-subfield.util.ts | 13 -- ...-from-composite-field-and-subfield.util.ts | 15 ++ ...getColumnNameForAggregateOperation.util.ts | 15 -- ...getSubFieldsForAggregateOperation.util.ts} | 31 +++- .../src/utils/aggregateOperations/index.ts | 4 +- 30 files changed, 503 insertions(+), 328 deletions(-) delete mode 100644 packages/twenty-front/src/modules/object-record/record-board/record-board-column/components/RecordBoardColumnHeaderAggregateDropdownMoreOptionsContent.tsx create mode 100644 packages/twenty-front/src/modules/object-record/record-board/record-board-column/components/RecordBoardColumnHeaderAggregateDropdownOptionsContent.tsx rename packages/twenty-front/src/modules/object-record/record-table/constants/{FieldsAvailableByAggregateOperation.ts => FieldTypesAvailableForNonStandardAggregateOperation.ts} (88%) create mode 100644 packages/twenty-front/src/modules/object-record/record-table/record-table-footer/constants/nonStandardAggregateOperationsOptions.tsx delete mode 100644 packages/twenty-server/src/engine/twenty-orm/utils/format-column-name-from-composite-field-and-subfield.util.ts create mode 100644 packages/twenty-server/src/engine/twenty-orm/utils/format-column-names-from-composite-field-and-subfield.util.ts delete mode 100644 packages/twenty-shared/src/utils/aggregateOperations/getColumnNameForAggregateOperation.util.ts rename packages/twenty-shared/src/utils/aggregateOperations/{getSubFieldForAggregateOperation.util.ts => getSubFieldsForAggregateOperation.util.ts} (51%) diff --git a/packages/twenty-front/src/modules/dropdown/hooks/useCurrentContentId.ts b/packages/twenty-front/src/modules/dropdown/hooks/useCurrentContentId.ts index 879050ffd..d19e18dc2 100644 --- a/packages/twenty-front/src/modules/dropdown/hooks/useCurrentContentId.ts +++ b/packages/twenty-front/src/modules/dropdown/hooks/useCurrentContentId.ts @@ -3,15 +3,23 @@ import { useCallback, useState } from 'react'; export const useCurrentContentId = () => { const [currentContentId, setCurrentContentId] = useState(null); - const handleContentChange = useCallback((key: T) => { - setCurrentContentId(key); - }, []); + const [previousContentId, setPreviousContentId] = useState(null); + + const handleContentChange = useCallback( + (key: T) => { + setPreviousContentId(currentContentId); + setCurrentContentId(key); + }, + [currentContentId], + ); const handleResetContent = useCallback(() => { + setPreviousContentId(null); setCurrentContentId(null); }, []); return { + previousContentId, currentContentId, handleContentChange, handleResetContent, diff --git a/packages/twenty-front/src/modules/object-record/hooks/__tests__/useAggregateRecordsQuery.test.tsx b/packages/twenty-front/src/modules/object-record/hooks/__tests__/useAggregateRecordsQuery.test.tsx index c29d42880..6157455bf 100644 --- a/packages/twenty-front/src/modules/object-record/hooks/__tests__/useAggregateRecordsQuery.test.tsx +++ b/packages/twenty-front/src/modules/object-record/hooks/__tests__/useAggregateRecordsQuery.test.tsx @@ -5,14 +5,10 @@ import { useAggregateRecordsQuery } from '@/object-record/hooks/useAggregateReco import { AGGREGATE_OPERATIONS } from '@/object-record/record-table/constants/AggregateOperations'; import { generateAggregateQuery } from '@/object-record/utils/generateAggregateQuery'; import { renderHook } from '@testing-library/react'; -import { getColumnNameForAggregateOperation } from 'twenty-shared'; import { FieldMetadataType } from '~/generated/graphql'; jest.mock('@/object-metadata/hooks/useObjectMetadataItem'); jest.mock('@/object-record/utils/generateAggregateQuery'); -jest.mock('twenty-shared', () => ({ - getColumnNameForAggregateOperation: jest.fn(), -})); const mockObjectMetadataItem: ObjectMetadataItem = { nameSingular: 'company', @@ -69,7 +65,6 @@ describe('useAggregateRecordsQuery', () => { }); it('should handle simple count operation', () => { - (getColumnNameForAggregateOperation as jest.Mock).mockReturnValue('name'); const { result } = renderHook(() => useAggregateRecordsQuery({ objectNameSingular: 'company', @@ -91,7 +86,6 @@ describe('useAggregateRecordsQuery', () => { }); it('should handle field aggregation', () => { - (getColumnNameForAggregateOperation as jest.Mock).mockReturnValue('amount'); const { result } = renderHook(() => useAggregateRecordsQuery({ objectNameSingular: 'company', diff --git a/packages/twenty-front/src/modules/object-record/record-board/record-board-column/components/RecordBoardColumnHeaderAggregateDropdown.tsx b/packages/twenty-front/src/modules/object-record/record-board/record-board-column/components/RecordBoardColumnHeaderAggregateDropdown.tsx index b8e20a118..197f63433 100644 --- a/packages/twenty-front/src/modules/object-record/record-board/record-board-column/components/RecordBoardColumnHeaderAggregateDropdown.tsx +++ b/packages/twenty-front/src/modules/object-record/record-board/record-board-column/components/RecordBoardColumnHeaderAggregateDropdown.tsx @@ -23,8 +23,12 @@ export const RecordBoardColumnHeaderAggregateDropdown = ({ aggregateLabel, dropdownId, }: RecordBoardColumnHeaderAggregateDropdownProps) => { - const { currentContentId, handleContentChange, handleResetContent } = - useCurrentContentId(); + const { + currentContentId, + handleContentChange, + handleResetContent, + previousContentId, + } = useCurrentContentId(); return ( { - const { currentContentId } = useDropdown({ + const { currentContentId, objectMetadataItem } = useDropdown({ context: RecordBoardColumnHeaderAggregateDropdownContext, }); switch (currentContentId) { - case 'moreAggregateOperationOptions': - return ; + case 'countAggregateOperationsOptions': { + const availableAggregations: AvailableFieldsForAggregateOperation = + getAvailableFieldsIdsForAggregationFromObjectFields( + objectMetadataItem.fields, + COUNT_AGGREGATE_OPERATION_OPTIONS, + ); + return ( + + ); + } + case 'percentAggregateOperationsOptions': { + const availableAggregations: AvailableFieldsForAggregateOperation = + getAvailableFieldsIdsForAggregationFromObjectFields( + objectMetadataItem.fields, + PERCENT_AGGREGATE_OPERATION_OPTIONS, + ); + return ( + + ); + } + case 'moreAggregateOperationOptions': { + const availableAggregations: AvailableFieldsForAggregateOperation = + getAvailableFieldsIdsForAggregationFromObjectFields( + objectMetadataItem.fields, + NON_STANDARD_AGGREGATE_OPERATION_OPTIONS, + ); + return ( + + ); + } case 'aggregateFields': return ; default: diff --git a/packages/twenty-front/src/modules/object-record/record-board/record-board-column/components/RecordBoardColumnHeaderAggregateDropdownContext.tsx b/packages/twenty-front/src/modules/object-record/record-board/record-board-column/components/RecordBoardColumnHeaderAggregateDropdownContext.tsx index d5e9f8310..bfc9fe127 100644 --- a/packages/twenty-front/src/modules/object-record/record-board/record-board-column/components/RecordBoardColumnHeaderAggregateDropdownContext.tsx +++ b/packages/twenty-front/src/modules/object-record/record-board/record-board-column/components/RecordBoardColumnHeaderAggregateDropdownContext.tsx @@ -7,6 +7,7 @@ export type RecordBoardColumnHeaderAggregateDropdownContextValue = { currentContentId: RecordBoardColumnHeaderAggregateContentId | null; onContentChange: (key: RecordBoardColumnHeaderAggregateContentId) => void; resetContent: () => void; + previousContentId: RecordBoardColumnHeaderAggregateContentId | null; dropdownId: string; }; diff --git a/packages/twenty-front/src/modules/object-record/record-board/record-board-column/components/RecordBoardColumnHeaderAggregateDropdownFieldsContent.tsx b/packages/twenty-front/src/modules/object-record/record-board/record-board-column/components/RecordBoardColumnHeaderAggregateDropdownFieldsContent.tsx index 7ab628644..855116d1a 100644 --- a/packages/twenty-front/src/modules/object-record/record-board/record-board-column/components/RecordBoardColumnHeaderAggregateDropdownFieldsContent.tsx +++ b/packages/twenty-front/src/modules/object-record/record-board/record-board-column/components/RecordBoardColumnHeaderAggregateDropdownFieldsContent.tsx @@ -19,7 +19,13 @@ import { import { isDefined } from '~/utils/isDefined'; export const RecordBoardColumnHeaderAggregateDropdownFieldsContent = () => { - const { closeDropdown, objectMetadataItem, onContentChange } = useDropdown({ + const { + closeDropdown, + objectMetadataItem, + onContentChange, + resetContent, + previousContentId, + } = useDropdown({ context: RecordBoardColumnHeaderAggregateDropdownContext, }); @@ -45,7 +51,11 @@ export const RecordBoardColumnHeaderAggregateDropdownFieldsContent = () => { <> onContentChange('moreAggregateOperationOptions')} + onClick={() => + previousContentId + ? onContentChange(previousContentId) + : resetContent() + } > {getAggregateOperationLabel(aggregateOperation)} diff --git a/packages/twenty-front/src/modules/object-record/record-board/record-board-column/components/RecordBoardColumnHeaderAggregateDropdownMenuContent.tsx b/packages/twenty-front/src/modules/object-record/record-board/record-board-column/components/RecordBoardColumnHeaderAggregateDropdownMenuContent.tsx index dc60c6747..558282439 100644 --- a/packages/twenty-front/src/modules/object-record/record-board/record-board-column/components/RecordBoardColumnHeaderAggregateDropdownMenuContent.tsx +++ b/packages/twenty-front/src/modules/object-record/record-board/record-board-column/components/RecordBoardColumnHeaderAggregateDropdownMenuContent.tsx @@ -1,5 +1,5 @@ import { Key } from 'ts-key-enum'; -import { IconCheck, MenuItem } from 'twenty-ui'; +import { MenuItem } from 'twenty-ui'; import { useDropdown } from '@/dropdown/hooks/useDropdown'; import { @@ -7,15 +7,9 @@ import { RecordBoardColumnHeaderAggregateDropdownContextValue, } from '@/object-record/record-board/record-board-column/components/RecordBoardColumnHeaderAggregateDropdownContext'; -import { getAggregateOperationLabel } from '@/object-record/record-board/record-board-column/utils/getAggregateOperationLabel'; -import { recordIndexKanbanAggregateOperationState } from '@/object-record/record-index/states/recordIndexKanbanAggregateOperationState'; -import { AGGREGATE_OPERATIONS } from '@/object-record/record-table/constants/AggregateOperations'; import { TableOptionsHotkeyScope } from '@/object-record/record-table/types/TableOptionsHotkeyScope'; import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer'; import { useScopedHotkeys } from '@/ui/utilities/hotkey/hooks/useScopedHotkeys'; -import { useUpdateViewAggregate } from '@/views/hooks/useUpdateViewAggregate'; -import { useRecoilValue } from 'recoil'; -import { isDefined } from '~/utils/isDefined'; export const RecordBoardColumnHeaderAggregateDropdownMenuContent = () => { const { onContentChange, closeDropdown } = @@ -31,31 +25,22 @@ export const RecordBoardColumnHeaderAggregateDropdownMenuContent = () => { TableOptionsHotkeyScope.Dropdown, ); - const { updateViewAggregate } = useUpdateViewAggregate(); - - const recordIndexKanbanAggregateOperation = useRecoilValue( - recordIndexKanbanAggregateOperationState, - ); - return ( <> { - updateViewAggregate({ - kanbanAggregateOperationFieldMetadataId: null, - kanbanAggregateOperation: AGGREGATE_OPERATIONS.count, - }); - closeDropdown(); + onContentChange('countAggregateOperationsOptions'); }} - text={getAggregateOperationLabel(AGGREGATE_OPERATIONS.count)} - RightIcon={ - !isDefined(recordIndexKanbanAggregateOperation?.operation) || - recordIndexKanbanAggregateOperation?.operation === - AGGREGATE_OPERATIONS.count - ? IconCheck - : undefined - } + text={'Count'} + hasSubMenu + /> + { + onContentChange('percentAggregateOperationsOptions'); + }} + text={'Percent'} + hasSubMenu /> { diff --git a/packages/twenty-front/src/modules/object-record/record-board/record-board-column/components/RecordBoardColumnHeaderAggregateDropdownMoreOptionsContent.tsx b/packages/twenty-front/src/modules/object-record/record-board/record-board-column/components/RecordBoardColumnHeaderAggregateDropdownMoreOptionsContent.tsx deleted file mode 100644 index c14a63adf..000000000 --- a/packages/twenty-front/src/modules/object-record/record-board/record-board-column/components/RecordBoardColumnHeaderAggregateDropdownMoreOptionsContent.tsx +++ /dev/null @@ -1,89 +0,0 @@ -import { useDropdown } from '@/dropdown/hooks/useDropdown'; -import { - RecordBoardColumnHeaderAggregateDropdownContext, - RecordBoardColumnHeaderAggregateDropdownContextValue, -} from '@/object-record/record-board/record-board-column/components/RecordBoardColumnHeaderAggregateDropdownContext'; -import { RecordBoardColumnHeaderAggregateDropdownMenuItem } from '@/object-record/record-board/record-board-column/components/RecordBoardColumnHeaderAggregateDropdownMenuItem'; -import { aggregateOperationComponentState } from '@/object-record/record-board/record-board-column/states/aggregateOperationComponentState'; -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 { TableOptionsHotkeyScope } from '@/object-record/record-table/types/TableOptionsHotkeyScope'; -import { AvailableFieldsForAggregateOperation } from '@/object-record/types/AvailableFieldsForAggregateOperation'; -import { getAvailableFieldsIdsForAggregationFromObjectFields } from '@/object-record/utils/getAvailableFieldsIdsForAggregationFromObjectFields'; -import { DropdownMenuHeader } from '@/ui/layout/dropdown/components/DropdownMenuHeader'; -import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer'; -import { useScopedHotkeys } from '@/ui/utilities/hotkey/hooks/useScopedHotkeys'; -import { useSetRecoilComponentStateV2 } from '@/ui/utilities/state/component-state/hooks/useSetRecoilComponentStateV2'; -import isEmpty from 'lodash.isempty'; -import { useMemo } from 'react'; -import { Key } from 'ts-key-enum'; -import { IconChevronLeft } from 'twenty-ui'; - -export const RecordBoardColumnHeaderAggregateDropdownMoreOptionsContent = - () => { - const { objectMetadataItem, onContentChange, closeDropdown, resetContent } = - useDropdown({ - context: RecordBoardColumnHeaderAggregateDropdownContext, - }); - - useScopedHotkeys( - [Key.Escape], - () => { - closeDropdown(); - }, - TableOptionsHotkeyScope.Dropdown, - ); - - const availableAggregations: AvailableFieldsForAggregateOperation = useMemo( - () => - getAvailableFieldsIdsForAggregationFromObjectFields( - objectMetadataItem.fields, - ), - [objectMetadataItem.fields], - ); - - const setAggregateOperation = useSetRecoilComponentStateV2( - aggregateOperationComponentState, - ); - - const setAvailableFieldsForAggregateOperation = - useSetRecoilComponentStateV2( - availableFieldIdsForAggregateOperationComponentState, - ); - - return ( - <> - - More options - - - {Object.entries(availableAggregations) - .filter(([, fields]) => !isEmpty(fields)) - .map( - ([ - availableAggregationOperation, - availableAggregationFieldsIdsForOperation, - ]) => ( - { - setAggregateOperation( - availableAggregationOperation as AGGREGATE_OPERATIONS, - ); - setAvailableFieldsForAggregateOperation( - availableAggregationFieldsIdsForOperation, - ); - onContentChange('aggregateFields'); - }} - text={getAggregateOperationLabel( - availableAggregationOperation as AGGREGATE_OPERATIONS, - )} - hasSubMenu - /> - ), - )} - - - ); - }; diff --git a/packages/twenty-front/src/modules/object-record/record-board/record-board-column/components/RecordBoardColumnHeaderAggregateDropdownOptionsContent.tsx b/packages/twenty-front/src/modules/object-record/record-board/record-board-column/components/RecordBoardColumnHeaderAggregateDropdownOptionsContent.tsx new file mode 100644 index 000000000..2145c4efa --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-board/record-board-column/components/RecordBoardColumnHeaderAggregateDropdownOptionsContent.tsx @@ -0,0 +1,103 @@ +import { useDropdown } from '@/dropdown/hooks/useDropdown'; +import { + RecordBoardColumnHeaderAggregateDropdownContext, + RecordBoardColumnHeaderAggregateDropdownContextValue, +} from '@/object-record/record-board/record-board-column/components/RecordBoardColumnHeaderAggregateDropdownContext'; +import { RecordBoardColumnHeaderAggregateDropdownMenuItem } from '@/object-record/record-board/record-board-column/components/RecordBoardColumnHeaderAggregateDropdownMenuItem'; +import { aggregateOperationComponentState } from '@/object-record/record-board/record-board-column/states/aggregateOperationComponentState'; +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 { TableOptionsHotkeyScope } from '@/object-record/record-table/types/TableOptionsHotkeyScope'; +import { AvailableFieldsForAggregateOperation } from '@/object-record/types/AvailableFieldsForAggregateOperation'; +import { DropdownMenuHeader } from '@/ui/layout/dropdown/components/DropdownMenuHeader'; +import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer'; +import { useScopedHotkeys } from '@/ui/utilities/hotkey/hooks/useScopedHotkeys'; +import { useSetRecoilComponentStateV2 } from '@/ui/utilities/state/component-state/hooks/useSetRecoilComponentStateV2'; +import { useUpdateViewAggregate } from '@/views/hooks/useUpdateViewAggregate'; +import isEmpty from 'lodash.isempty'; +import { Key } from 'ts-key-enum'; +import { IconChevronLeft } from 'twenty-ui'; + +export const RecordBoardColumnHeaderAggregateDropdownOptionsContent = ({ + availableAggregations, + title, +}: { + availableAggregations: AvailableFieldsForAggregateOperation; + title: string; +}) => { + const { onContentChange, closeDropdown, resetContent } = + useDropdown({ + context: RecordBoardColumnHeaderAggregateDropdownContext, + }); + + useScopedHotkeys( + [Key.Escape], + () => { + closeDropdown(); + }, + TableOptionsHotkeyScope.Dropdown, + ); + + const setAggregateOperation = useSetRecoilComponentStateV2( + aggregateOperationComponentState, + ); + + const setAvailableFieldsForAggregateOperation = useSetRecoilComponentStateV2( + availableFieldIdsForAggregateOperationComponentState, + ); + + const { updateViewAggregate } = useUpdateViewAggregate(); + + return ( + <> + + {title} + + + {Object.entries(availableAggregations) + .filter(([, fields]) => !isEmpty(fields)) + .map( + ([ + availableAggregationOperation, + availableAggregationFieldsIdsForOperation, + ]) => ( + { + if ( + availableAggregationOperation !== AGGREGATE_OPERATIONS.count + ) { + setAggregateOperation( + availableAggregationOperation as AGGREGATE_OPERATIONS, + ); + + setAvailableFieldsForAggregateOperation( + availableAggregationFieldsIdsForOperation, + ); + onContentChange('aggregateFields'); + } else { + updateViewAggregate({ + kanbanAggregateOperationFieldMetadataId: + availableAggregationFieldsIdsForOperation[0], + kanbanAggregateOperation: + availableAggregationOperation as AGGREGATE_OPERATIONS, + }); + closeDropdown(); + } + }} + text={getAggregateOperationLabel( + availableAggregationOperation as AGGREGATE_OPERATIONS, + )} + hasSubMenu={ + availableAggregationOperation === AGGREGATE_OPERATIONS.count + ? false + : true + } + /> + ), + )} + + + ); +}; diff --git a/packages/twenty-front/src/modules/object-record/record-board/record-board-column/utils/__tests__/computeAggregateValueAndLabel.test.ts b/packages/twenty-front/src/modules/object-record/record-board/record-board-column/utils/__tests__/computeAggregateValueAndLabel.test.ts index 38b45de7e..0af203810 100644 --- a/packages/twenty-front/src/modules/object-record/record-board/record-board-column/utils/__tests__/computeAggregateValueAndLabel.test.ts +++ b/packages/twenty-front/src/modules/object-record/record-board/record-board-column/utils/__tests__/computeAggregateValueAndLabel.test.ts @@ -14,6 +14,7 @@ describe('computeAggregateValueAndLabel', () => { { id: MOCK_FIELD_ID, name: 'amount', + label: 'amount', type: FieldMetadataType.Currency, } as FieldMetadataItem, ], @@ -60,6 +61,7 @@ describe('computeAggregateValueAndLabel', () => { { id: MOCK_FIELD_ID, name: 'percentage', + label: 'percentage', type: FieldMetadataType.Number, settings: { type: 'percentage', @@ -96,6 +98,7 @@ describe('computeAggregateValueAndLabel', () => { { id: MOCK_FIELD_ID, name: 'decimals', + label: 'decimals', type: FieldMetadataType.Number, settings: { decimals: 2, diff --git a/packages/twenty-front/src/modules/object-record/record-board/record-board-column/utils/computeAggregateValueAndLabel.ts b/packages/twenty-front/src/modules/object-record/record-board/record-board-column/utils/computeAggregateValueAndLabel.ts index d68654660..f78945028 100644 --- a/packages/twenty-front/src/modules/object-record/record-board/record-board-column/utils/computeAggregateValueAndLabel.ts +++ b/packages/twenty-front/src/modules/object-record/record-board/record-board-column/utils/computeAggregateValueAndLabel.ts @@ -78,7 +78,7 @@ export const computeAggregateValueAndLabel = ({ const labelWithFieldName = aggregateOperation === AGGREGATE_OPERATIONS.count ? `${getAggregateOperationLabel(AGGREGATE_OPERATIONS.count)}` - : `${getAggregateOperationLabel(aggregateOperation)} of ${field.name}`; + : `${getAggregateOperationLabel(aggregateOperation)} of ${field.label}`; return { value, diff --git a/packages/twenty-front/src/modules/object-record/record-board/types/RecordBoardColumnHeaderAggregateContentId.ts b/packages/twenty-front/src/modules/object-record/record-board/types/RecordBoardColumnHeaderAggregateContentId.ts index 76dd903f5..0de0a4ba4 100644 --- a/packages/twenty-front/src/modules/object-record/record-board/types/RecordBoardColumnHeaderAggregateContentId.ts +++ b/packages/twenty-front/src/modules/object-record/record-board/types/RecordBoardColumnHeaderAggregateContentId.ts @@ -2,4 +2,5 @@ export type RecordBoardColumnHeaderAggregateContentId = | 'aggregateOperations' | 'aggregateFields' | 'countAggregateOperationsOptions' + | 'percentAggregateOperationsOptions' | 'moreAggregateOperationOptions'; diff --git a/packages/twenty-front/src/modules/object-record/record-table/constants/FieldsAvailableByAggregateOperation.ts b/packages/twenty-front/src/modules/object-record/record-table/constants/FieldTypesAvailableForNonStandardAggregateOperation.ts similarity index 88% rename from packages/twenty-front/src/modules/object-record/record-table/constants/FieldsAvailableByAggregateOperation.ts rename to packages/twenty-front/src/modules/object-record/record-table/constants/FieldTypesAvailableForNonStandardAggregateOperation.ts index 0caed3982..3b6f62738 100644 --- a/packages/twenty-front/src/modules/object-record/record-table/constants/FieldsAvailableByAggregateOperation.ts +++ b/packages/twenty-front/src/modules/object-record/record-table/constants/FieldTypesAvailableForNonStandardAggregateOperation.ts @@ -1,7 +1,7 @@ import { AGGREGATE_OPERATIONS } from '@/object-record/record-table/constants/AggregateOperations'; import { FieldMetadataType } from '~/generated-metadata/graphql'; -export const FIELDS_AVAILABLE_BY_AGGREGATE_OPERATION = { +export const FIELD_TYPES_AVAILABLE_FOR_NON_STANDARD_AGGREGATE_OPERATION = { [AGGREGATE_OPERATIONS.min]: [ FieldMetadataType.Number, FieldMetadataType.Currency, diff --git a/packages/twenty-front/src/modules/object-record/record-table/record-table-footer/constants/nonStandardAggregateOperationsOptions.tsx b/packages/twenty-front/src/modules/object-record/record-table/record-table-footer/constants/nonStandardAggregateOperationsOptions.tsx new file mode 100644 index 000000000..4d843e187 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-table/record-table-footer/constants/nonStandardAggregateOperationsOptions.tsx @@ -0,0 +1,8 @@ +import { AGGREGATE_OPERATIONS } from '@/object-record/record-table/constants/AggregateOperations'; + +export const NON_STANDARD_AGGREGATE_OPERATION_OPTIONS = [ + AGGREGATE_OPERATIONS.min, + AGGREGATE_OPERATIONS.max, + AGGREGATE_OPERATIONS.avg, + AGGREGATE_OPERATIONS.sum, +]; diff --git a/packages/twenty-front/src/modules/object-record/record-table/record-table-footer/utils/getAvailableAggregateOperationsForFieldMetadataType.ts b/packages/twenty-front/src/modules/object-record/record-table/record-table-footer/utils/getAvailableAggregateOperationsForFieldMetadataType.ts index 33915ebb8..9b44db458 100644 --- a/packages/twenty-front/src/modules/object-record/record-table/record-table-footer/utils/getAvailableAggregateOperationsForFieldMetadataType.ts +++ b/packages/twenty-front/src/modules/object-record/record-table/record-table-footer/utils/getAvailableAggregateOperationsForFieldMetadataType.ts @@ -1,5 +1,5 @@ import { AGGREGATE_OPERATIONS } from '@/object-record/record-table/constants/AggregateOperations'; -import { FIELDS_AVAILABLE_BY_AGGREGATE_OPERATION } from '@/object-record/record-table/constants/FieldsAvailableByAggregateOperation'; +import { FIELD_TYPES_AVAILABLE_FOR_NON_STANDARD_AGGREGATE_OPERATION } from '@/object-record/record-table/constants/FieldTypesAvailableForNonStandardAggregateOperation'; import { AggregateOperationsOmittingStandardOperations } from '@/object-record/types/AggregateOperationsOmittingStandardOperations'; import { isFieldTypeValidForAggregateOperation } from '@/object-record/utils/isFieldTypeValidForAggregateOperation'; import { FieldMetadataType } from '~/generated/graphql'; @@ -27,7 +27,7 @@ export const getAvailableAggregateOperationsForFieldMetadataType = ({ return Array.from(availableAggregateOperations); } - Object.keys(FIELDS_AVAILABLE_BY_AGGREGATE_OPERATION) + Object.keys(FIELD_TYPES_AVAILABLE_FOR_NON_STANDARD_AGGREGATE_OPERATION) .filter((operation) => isFieldTypeValidForAggregateOperation( fieldMetadataType, diff --git a/packages/twenty-front/src/modules/object-record/types/AvailableFieldsForAggregateOperation.ts b/packages/twenty-front/src/modules/object-record/types/AvailableFieldsForAggregateOperation.ts index 47fb09ed1..8b3983a26 100644 --- a/packages/twenty-front/src/modules/object-record/types/AvailableFieldsForAggregateOperation.ts +++ b/packages/twenty-front/src/modules/object-record/types/AvailableFieldsForAggregateOperation.ts @@ -1,5 +1,5 @@ -import { AggregateOperationsOmittingStandardOperations } from '@/object-record/types/AggregateOperationsOmittingStandardOperations'; +import { AGGREGATE_OPERATIONS } from '@/object-record/record-table/constants/AggregateOperations'; export type AvailableFieldsForAggregateOperation = { - [T in AggregateOperationsOmittingStandardOperations]?: string[]; + [T in AGGREGATE_OPERATIONS]?: string[]; }; diff --git a/packages/twenty-front/src/modules/object-record/utils/__tests__/getAvailableFieldsIdsForAggregationFromObjectFields.test.ts b/packages/twenty-front/src/modules/object-record/utils/__tests__/getAvailableFieldsIdsForAggregationFromObjectFields.test.ts index cbc7f4ff5..01880c2a2 100644 --- a/packages/twenty-front/src/modules/object-record/utils/__tests__/getAvailableFieldsIdsForAggregationFromObjectFields.test.ts +++ b/packages/twenty-front/src/modules/object-record/utils/__tests__/getAvailableFieldsIdsForAggregationFromObjectFields.test.ts @@ -1,7 +1,8 @@ import { FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem'; import { AGGREGATE_OPERATIONS } from '@/object-record/record-table/constants/AggregateOperations'; -import { STANDARD_AGGREGATE_OPERATION_OPTIONS } from '@/object-record/record-table/record-table-footer/constants/standardAggregateOperationOptions'; -import { AggregateOperationsOmittingStandardOperations } from '@/object-record/types/AggregateOperationsOmittingStandardOperations'; +import { COUNT_AGGREGATE_OPERATION_OPTIONS } from '@/object-record/record-table/record-table-footer/constants/countAggregateOperationOptions'; +import { NON_STANDARD_AGGREGATE_OPERATION_OPTIONS } from '@/object-record/record-table/record-table-footer/constants/nonStandardAggregateOperationsOptions'; +import { PERCENT_AGGREGATE_OPERATION_OPTIONS } from '@/object-record/record-table/record-table-footer/constants/percentAggregateOperationOption'; import { getAvailableFieldsIdsForAggregationFromObjectFields } from '@/object-record/utils/getAvailableFieldsIdsForAggregationFromObjectFields'; import { FieldMetadataType } from '~/generated/graphql'; @@ -9,59 +10,135 @@ const AMOUNT_FIELD_ID = '7d2d7b5e-7b3e-4b4a-8b0a-7b3e4b4a8b0a'; const PRICE_FIELD_ID = '9d2d7b5e-7b3e-4b4a-8b0a-7b3e4b4a8b0b'; const NAME_FIELD_ID = '5d2d7b5e-7b3e-4b4a-8b0a-7b3e4b4a8b0c'; -describe('getAvailableFieldsIdsForAggregationFromObjectFields', () => { - const mockFields = [ - { id: AMOUNT_FIELD_ID, type: FieldMetadataType.Number, name: 'amount' }, - { id: PRICE_FIELD_ID, type: FieldMetadataType.Currency, name: 'price' }, - { id: NAME_FIELD_ID, type: FieldMetadataType.Text, name: 'name' }, - ]; +const FIELDS_MOCKS = [ + { id: AMOUNT_FIELD_ID, type: FieldMetadataType.Number, name: 'amount' }, + { id: PRICE_FIELD_ID, type: FieldMetadataType.Currency, name: 'price' }, + { id: NAME_FIELD_ID, type: FieldMetadataType.Text, name: 'name' }, +]; - it('should correctly map fields to available aggregate operations', () => { +jest.mock( + '@/object-record/utils/getAvailableAggregationsFromObjectFields', + () => ({ + getAvailableAggregationsFromObjectFields: jest.fn().mockReturnValue({ + amount: { + [AGGREGATE_OPERATIONS.sum]: 'sumAmount', + [AGGREGATE_OPERATIONS.avg]: 'avgAmount', + [AGGREGATE_OPERATIONS.min]: 'minAmount', + [AGGREGATE_OPERATIONS.max]: 'maxAmount', + [AGGREGATE_OPERATIONS.count]: 'totalCount', + [AGGREGATE_OPERATIONS.countUniqueValues]: 'countUniqueValuesAmount', + [AGGREGATE_OPERATIONS.countEmpty]: 'countEmptyAmount', + [AGGREGATE_OPERATIONS.countNotEmpty]: 'countNotEmptyAmount', + [AGGREGATE_OPERATIONS.percentageEmpty]: 'percentageEmptyAmount', + [AGGREGATE_OPERATIONS.percentageNotEmpty]: 'percentageNotEmptyAmount', + }, + price: { + [AGGREGATE_OPERATIONS.sum]: 'sumPriceAmountMicros', + [AGGREGATE_OPERATIONS.avg]: 'avgPriceAmountMicros', + [AGGREGATE_OPERATIONS.min]: 'minPriceAmountMicros', + [AGGREGATE_OPERATIONS.max]: 'maxPriceAmountMicros', + [AGGREGATE_OPERATIONS.count]: 'totalCount', + [AGGREGATE_OPERATIONS.countUniqueValues]: + 'countUniqueValuesPriceAmountMicros', + [AGGREGATE_OPERATIONS.countEmpty]: 'countEmptyPriceAmountMicros', + [AGGREGATE_OPERATIONS.countNotEmpty]: 'countNotEmptyPriceAmountMicros', + [AGGREGATE_OPERATIONS.percentageEmpty]: + 'percentageEmptyPriceAmountMicros', + [AGGREGATE_OPERATIONS.percentageNotEmpty]: + 'percentageNotEmptyPriceAmountMicros', + }, + name: { + [AGGREGATE_OPERATIONS.count]: 'totalCount', + [AGGREGATE_OPERATIONS.countUniqueValues]: 'countUniqueValuesName', + [AGGREGATE_OPERATIONS.countEmpty]: 'countEmptyName', + [AGGREGATE_OPERATIONS.countNotEmpty]: 'countNotEmptyName', + [AGGREGATE_OPERATIONS.percentageEmpty]: 'percentageEmptyName', + [AGGREGATE_OPERATIONS.percentageNotEmpty]: 'percentageNotEmptyName', + }, + }), + }), +); + +describe('getAvailableFieldsIdsForAggregationFromObjectFields', () => { + it('should handle empty fields array', () => { const result = getAvailableFieldsIdsForAggregationFromObjectFields( - mockFields as FieldMetadataItem[], + [], + COUNT_AGGREGATE_OPERATION_OPTIONS, ); - expect(result[AGGREGATE_OPERATIONS.sum]).toEqual([ - AMOUNT_FIELD_ID, - PRICE_FIELD_ID, - ]); - expect(result[AGGREGATE_OPERATIONS.avg]).toEqual([ - AMOUNT_FIELD_ID, - PRICE_FIELD_ID, - ]); - expect(result[AGGREGATE_OPERATIONS.min]).toEqual([ - AMOUNT_FIELD_ID, - PRICE_FIELD_ID, - ]); - expect(result[AGGREGATE_OPERATIONS.max]).toEqual([ - AMOUNT_FIELD_ID, - PRICE_FIELD_ID, - ]); - }); - - it('should exclude non-numeric fields', () => { - const result = getAvailableFieldsIdsForAggregationFromObjectFields([ - { id: NAME_FIELD_ID, type: FieldMetadataType.Text } as FieldMetadataItem, - ]); - - Object.values(AGGREGATE_OPERATIONS).forEach((operation) => { - if (!STANDARD_AGGREGATE_OPERATION_OPTIONS.includes(operation)) { - expect( - result[operation as AggregateOperationsOmittingStandardOperations], - ).toEqual([]); - } + COUNT_AGGREGATE_OPERATION_OPTIONS.forEach((operation) => { + expect(result[operation]).toEqual([]); }); }); - it('should handle empty fields array', () => { - const result = getAvailableFieldsIdsForAggregationFromObjectFields([]); + describe('with count aggregate operations', () => { + it('should include all fields', () => { + const result = getAvailableFieldsIdsForAggregationFromObjectFields( + FIELDS_MOCKS as FieldMetadataItem[], + COUNT_AGGREGATE_OPERATION_OPTIONS, + ); - Object.values(AGGREGATE_OPERATIONS).forEach((operation) => { - if (!STANDARD_AGGREGATE_OPERATION_OPTIONS.includes(operation)) { - expect( - result[operation as AggregateOperationsOmittingStandardOperations], - ).toEqual([]); - } + COUNT_AGGREGATE_OPERATION_OPTIONS.forEach((operation) => { + expect(result[operation]).toEqual([ + AMOUNT_FIELD_ID, + PRICE_FIELD_ID, + NAME_FIELD_ID, + ]); + }); + + PERCENT_AGGREGATE_OPERATION_OPTIONS.forEach((operation) => { + expect(result[operation]).toBeUndefined(); + }); + + NON_STANDARD_AGGREGATE_OPERATION_OPTIONS.forEach((operation) => { + expect(result[operation]).toBeUndefined(); + }); + }); + }); + + describe('with percentage aggregate operations', () => { + it('should include all fields', () => { + const result = getAvailableFieldsIdsForAggregationFromObjectFields( + FIELDS_MOCKS as FieldMetadataItem[], + PERCENT_AGGREGATE_OPERATION_OPTIONS, + ); + + PERCENT_AGGREGATE_OPERATION_OPTIONS.forEach((operation) => { + expect(result[operation]).toEqual([ + AMOUNT_FIELD_ID, + PRICE_FIELD_ID, + NAME_FIELD_ID, + ]); + }); + + COUNT_AGGREGATE_OPERATION_OPTIONS.forEach((operation) => { + expect(result[operation]).toBeUndefined(); + }); + + NON_STANDARD_AGGREGATE_OPERATION_OPTIONS.forEach((operation) => { + expect(result[operation]).toBeUndefined(); + }); + }); + }); + + describe('with non standard aggregate operations', () => { + it('should exclude non-numeric fields', () => { + const result = getAvailableFieldsIdsForAggregationFromObjectFields( + FIELDS_MOCKS as FieldMetadataItem[], + NON_STANDARD_AGGREGATE_OPERATION_OPTIONS, + ); + + COUNT_AGGREGATE_OPERATION_OPTIONS.forEach((operation) => { + expect(result[operation]).toBeUndefined(); + }); + + PERCENT_AGGREGATE_OPERATION_OPTIONS.forEach((operation) => { + expect(result[operation]).toBeUndefined(); + }); + + NON_STANDARD_AGGREGATE_OPERATION_OPTIONS.forEach((operation) => { + expect(result[operation]).toEqual([AMOUNT_FIELD_ID, PRICE_FIELD_ID]); + }); }); }); }); diff --git a/packages/twenty-front/src/modules/object-record/utils/__tests__/initializeAvailableFieldsForAggregateOperationMap.test.ts b/packages/twenty-front/src/modules/object-record/utils/__tests__/initializeAvailableFieldsForAggregateOperationMap.test.ts index 6bcb71598..67e1e26ba 100644 --- a/packages/twenty-front/src/modules/object-record/utils/__tests__/initializeAvailableFieldsForAggregateOperationMap.test.ts +++ b/packages/twenty-front/src/modules/object-record/utils/__tests__/initializeAvailableFieldsForAggregateOperationMap.test.ts @@ -1,26 +1,58 @@ import { AGGREGATE_OPERATIONS } from '@/object-record/record-table/constants/AggregateOperations'; -import { FIELDS_AVAILABLE_BY_AGGREGATE_OPERATION } from '@/object-record/record-table/constants/FieldsAvailableByAggregateOperation'; +import { FIELD_TYPES_AVAILABLE_FOR_NON_STANDARD_AGGREGATE_OPERATION } from '@/object-record/record-table/constants/FieldTypesAvailableForNonStandardAggregateOperation'; +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 { AggregateOperationsOmittingStandardOperations } from '@/object-record/types/AggregateOperationsOmittingStandardOperations'; import { initializeAvailableFieldsForAggregateOperationMap } from '@/object-record/utils/initializeAvailableFieldsForAggregateOperationMap'; describe('initializeAvailableFieldsForAggregateOperationMap', () => { - it('should initialize empty arrays for each aggregate operation', () => { - const result = initializeAvailableFieldsForAggregateOperationMap(); + it('should initialize empty arrays for each non standard aggregate operation', () => { + const result = initializeAvailableFieldsForAggregateOperationMap( + Object.keys( + FIELD_TYPES_AVAILABLE_FOR_NON_STANDARD_AGGREGATE_OPERATION, + ) as AGGREGATE_OPERATIONS[], + ); expect(Object.keys(result)).toEqual( - Object.keys(FIELDS_AVAILABLE_BY_AGGREGATE_OPERATION), + Object.keys(FIELD_TYPES_AVAILABLE_FOR_NON_STANDARD_AGGREGATE_OPERATION), ); Object.values(result).forEach((array) => { expect(array).toEqual([]); }); }); - it('should not include count operation', () => { - const result = initializeAvailableFieldsForAggregateOperationMap(); + it('should not include count operation when called with non standard aggregate operations', () => { + const result = initializeAvailableFieldsForAggregateOperationMap( + Object.keys( + FIELD_TYPES_AVAILABLE_FOR_NON_STANDARD_AGGREGATE_OPERATION, + ) as AGGREGATE_OPERATIONS[], + ); expect( result[ AGGREGATE_OPERATIONS.count as AggregateOperationsOmittingStandardOperations ], ).toBeUndefined(); }); + + it('should include count operation when called with count aggregate operations', () => { + const result = initializeAvailableFieldsForAggregateOperationMap( + COUNT_AGGREGATE_OPERATION_OPTIONS, + ); + expect(result[AGGREGATE_OPERATIONS.count]).toEqual([]); + expect(result[AGGREGATE_OPERATIONS.countEmpty]).toEqual([]); + expect(result[AGGREGATE_OPERATIONS.countNotEmpty]).toEqual([]); + expect(result[AGGREGATE_OPERATIONS.countUniqueValues]).toEqual([]); + expect(result[AGGREGATE_OPERATIONS.min]).toBeUndefined(); + expect(result[AGGREGATE_OPERATIONS.percentageEmpty]).toBeUndefined(); + }); + + it('should include percent operation when called with count aggregate operations', () => { + const result = initializeAvailableFieldsForAggregateOperationMap( + PERCENT_AGGREGATE_OPERATION_OPTIONS, + ); + expect(result[AGGREGATE_OPERATIONS.percentageEmpty]).toEqual([]); + expect(result[AGGREGATE_OPERATIONS.percentageNotEmpty]).toEqual([]); + expect(result[AGGREGATE_OPERATIONS.count]).toBeUndefined(); + expect(result[AGGREGATE_OPERATIONS.min]).toBeUndefined(); + }); }); diff --git a/packages/twenty-front/src/modules/object-record/utils/getAvailableAggregationsFromObjectFields.ts b/packages/twenty-front/src/modules/object-record/utils/getAvailableAggregationsFromObjectFields.ts index 6f540990c..0ae301a75 100644 --- a/packages/twenty-front/src/modules/object-record/utils/getAvailableAggregationsFromObjectFields.ts +++ b/packages/twenty-front/src/modules/object-record/utils/getAvailableAggregationsFromObjectFields.ts @@ -1,6 +1,5 @@ import { FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem'; import { AGGREGATE_OPERATIONS } from '@/object-record/record-table/constants/AggregateOperations'; -import { getColumnNameForAggregateOperation } from 'twenty-shared'; import { FieldMetadataType } from '~/generated-metadata/graphql'; import { capitalize } from '~/utils/string/capitalize'; @@ -16,6 +15,10 @@ export const getAvailableAggregationsFromObjectFields = ( fields: FieldMetadataItem[], ): Aggregations => { return fields.reduce>((acc, field) => { + if (field.isSystem === true) { + return acc; + } + if (field.type === FieldMetadataType.Relation) { acc[field.name] = { [AGGREGATE_OPERATIONS.count]: 'totalCount', @@ -23,28 +26,15 @@ export const getAvailableAggregationsFromObjectFields = ( return acc; } - const columnName = getColumnNameForAggregateOperation( - field.name, - field.type, - ); - acc[field.name] = { - [AGGREGATE_OPERATIONS.countUniqueValues]: `countUniqueValues${capitalize(columnName)}`, - [AGGREGATE_OPERATIONS.countEmpty]: `countEmpty${capitalize(columnName)}`, - [AGGREGATE_OPERATIONS.countNotEmpty]: `countNotEmpty${capitalize(columnName)}`, - [AGGREGATE_OPERATIONS.percentageEmpty]: `percentageEmpty${capitalize(columnName)}`, - [AGGREGATE_OPERATIONS.percentageNotEmpty]: `percentageNotEmpty${capitalize(columnName)}`, + [AGGREGATE_OPERATIONS.countUniqueValues]: `countUniqueValues${capitalize(field.name)}`, + [AGGREGATE_OPERATIONS.countEmpty]: `countEmpty${capitalize(field.name)}`, + [AGGREGATE_OPERATIONS.countNotEmpty]: `countNotEmpty${capitalize(field.name)}`, + [AGGREGATE_OPERATIONS.percentageEmpty]: `percentageEmpty${capitalize(field.name)}`, + [AGGREGATE_OPERATIONS.percentageNotEmpty]: `percentageNotEmpty${capitalize(field.name)}`, [AGGREGATE_OPERATIONS.count]: 'totalCount', }; - 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 (field.type === FieldMetadataType.Number) { acc[field.name] = { ...acc[field.name], diff --git a/packages/twenty-front/src/modules/object-record/utils/getAvailableFieldsIdsForAggregationFromObjectFields.ts b/packages/twenty-front/src/modules/object-record/utils/getAvailableFieldsIdsForAggregationFromObjectFields.ts index 3586760f2..88bbc0698 100644 --- a/packages/twenty-front/src/modules/object-record/utils/getAvailableFieldsIdsForAggregationFromObjectFields.ts +++ b/packages/twenty-front/src/modules/object-record/utils/getAvailableFieldsIdsForAggregationFromObjectFields.ts @@ -1,32 +1,30 @@ import { FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem'; -import { FIELDS_AVAILABLE_BY_AGGREGATE_OPERATION } from '@/object-record/record-table/constants/FieldsAvailableByAggregateOperation'; -import { AggregateOperationsOmittingStandardOperations } from '@/object-record/types/AggregateOperationsOmittingStandardOperations'; +import { AGGREGATE_OPERATIONS } from '@/object-record/record-table/constants/AggregateOperations'; import { AvailableFieldsForAggregateOperation } from '@/object-record/types/AvailableFieldsForAggregateOperation'; +import { getAvailableAggregationsFromObjectFields } from '@/object-record/utils/getAvailableAggregationsFromObjectFields'; import { initializeAvailableFieldsForAggregateOperationMap } from '@/object-record/utils/initializeAvailableFieldsForAggregateOperationMap'; -import { isFieldTypeValidForAggregateOperation } from '@/object-record/utils/isFieldTypeValidForAggregateOperation'; +import { isDefined } from '~/utils/isDefined'; export const getAvailableFieldsIdsForAggregationFromObjectFields = ( fields: FieldMetadataItem[], + targetAggregateOperations: AGGREGATE_OPERATIONS[], ): AvailableFieldsForAggregateOperation => { - const aggregationMap = initializeAvailableFieldsForAggregateOperationMap(); + const aggregationMap = initializeAvailableFieldsForAggregateOperationMap( + targetAggregateOperations, + ); + + const allAggregations = getAvailableAggregationsFromObjectFields(fields); return fields.reduce((acc, field) => { - Object.keys(FIELDS_AVAILABLE_BY_AGGREGATE_OPERATION).forEach( - (aggregateOperation) => { - const typedAggregateOperation = - aggregateOperation as AggregateOperationsOmittingStandardOperations; + if (isDefined(allAggregations[field.name])) { + Object.keys(allAggregations[field.name]).forEach((aggregation) => { + const typedAggregateOperation = aggregation as AGGREGATE_OPERATIONS; - if ( - isFieldTypeValidForAggregateOperation( - field.type, - typedAggregateOperation, - ) - ) { + if (targetAggregateOperations.includes(typedAggregateOperation)) { acc[typedAggregateOperation]?.push(field.id); } - }, - ); - + }); + } return acc; }, aggregationMap); }; diff --git a/packages/twenty-front/src/modules/object-record/utils/initializeAvailableFieldsForAggregateOperationMap.ts b/packages/twenty-front/src/modules/object-record/utils/initializeAvailableFieldsForAggregateOperationMap.ts index 67cb086a5..d1e65967e 100644 --- a/packages/twenty-front/src/modules/object-record/utils/initializeAvailableFieldsForAggregateOperationMap.ts +++ b/packages/twenty-front/src/modules/object-record/utils/initializeAvailableFieldsForAggregateOperationMap.ts @@ -1,13 +1,14 @@ -import { FIELDS_AVAILABLE_BY_AGGREGATE_OPERATION } from '@/object-record/record-table/constants/FieldsAvailableByAggregateOperation'; +import { AGGREGATE_OPERATIONS } from '@/object-record/record-table/constants/AggregateOperations'; import { AvailableFieldsForAggregateOperation } from '@/object-record/types/AvailableFieldsForAggregateOperation'; -export const initializeAvailableFieldsForAggregateOperationMap = - (): AvailableFieldsForAggregateOperation => { - return Object.keys(FIELDS_AVAILABLE_BY_AGGREGATE_OPERATION).reduce( - (acc, operation) => ({ - ...acc, - [operation]: [], - }), - {}, - ); - }; +export const initializeAvailableFieldsForAggregateOperationMap = ( + aggregateOperations: AGGREGATE_OPERATIONS[], +): AvailableFieldsForAggregateOperation => { + return aggregateOperations.reduce( + (acc, operation) => ({ + ...acc, + [operation]: [], + }), + {}, + ); +}; diff --git a/packages/twenty-front/src/modules/object-record/utils/isFieldTypeValidForAggregateOperation.ts b/packages/twenty-front/src/modules/object-record/utils/isFieldTypeValidForAggregateOperation.ts index 2a80366f2..6c0956792 100644 --- a/packages/twenty-front/src/modules/object-record/utils/isFieldTypeValidForAggregateOperation.ts +++ b/packages/twenty-front/src/modules/object-record/utils/isFieldTypeValidForAggregateOperation.ts @@ -1,4 +1,4 @@ -import { FIELDS_AVAILABLE_BY_AGGREGATE_OPERATION } from '@/object-record/record-table/constants/FieldsAvailableByAggregateOperation'; +import { FIELD_TYPES_AVAILABLE_FOR_NON_STANDARD_AGGREGATE_OPERATION } from '@/object-record/record-table/constants/FieldTypesAvailableForNonStandardAggregateOperation'; import { AggregateOperationsOmittingStandardOperations } from '@/object-record/types/AggregateOperationsOmittingStandardOperations'; import { FieldMetadataType } from '~/generated-metadata/graphql'; @@ -6,7 +6,7 @@ export const isFieldTypeValidForAggregateOperation = ( fieldType: FieldMetadataType, aggregateOperation: AggregateOperationsOmittingStandardOperations, ): boolean => { - return FIELDS_AVAILABLE_BY_AGGREGATE_OPERATION[aggregateOperation].includes( - fieldType, - ); + return FIELD_TYPES_AVAILABLE_FOR_NON_STANDARD_AGGREGATE_OPERATION[ + aggregateOperation + ].includes(fieldType); }; diff --git a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/helpers/process-aggregate.helper.ts b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/helpers/process-aggregate.helper.ts index 1b9c7b7ef..4b22f5d4d 100644 --- a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/helpers/process-aggregate.helper.ts +++ b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/helpers/process-aggregate.helper.ts @@ -3,7 +3,7 @@ import { SelectQueryBuilder } from 'typeorm'; import { AGGREGATE_OPERATIONS } from 'src/engine/api/graphql/graphql-query-runner/constants/aggregate-operations.constant'; import { AggregationField } from 'src/engine/api/graphql/workspace-schema-builder/utils/get-available-aggregations-from-object-fields.util'; import { FIELD_METADATA_TYPES_TO_TEXT_COLUMN_TYPE } from 'src/engine/metadata-modules/workspace-migration/constants/fieldMetadataTypesToTextColumnType'; -import { formatColumnNameFromCompositeFieldAndSubfield } from 'src/engine/twenty-orm/utils/format-column-name-from-composite-field-and-subfield.util'; +import { formatColumnNamesFromCompositeFieldAndSubfields } from 'src/engine/twenty-orm/utils/format-column-names-from-composite-field-and-subfield.util'; import { isDefined } from 'src/utils/is-defined'; export class ProcessAggregateHelper { @@ -26,11 +26,20 @@ export class ProcessAggregateHelper { continue; } - const columnName = formatColumnNameFromCompositeFieldAndSubfield( + const columnNames = formatColumnNamesFromCompositeFieldAndSubfields( aggregatedField.fromField, - aggregatedField.fromSubField, + aggregatedField.fromSubFields, ); + const columnNameForNumericOperation = isDefined( + aggregatedField.subFieldForNumericOperation, + ) + ? formatColumnNamesFromCompositeFieldAndSubfields( + aggregatedField.fromField, + [aggregatedField.subFieldForNumericOperation], + )[0] + : columnNames[0]; + if ( !Object.values(AGGREGATE_OPERATIONS).includes( aggregatedField.aggregateOperation, @@ -39,49 +48,54 @@ export class ProcessAggregateHelper { continue; } - const columnEmptyValueExpression = + const concatenatedColumns = columnNames + .map((col) => `"${col}"`) + .join(", ' ', "); + + const columnExpression = FIELD_METADATA_TYPES_TO_TEXT_COLUMN_TYPE.includes( aggregatedField.fromFieldType, ) - ? `NULLIF("${columnName}", '')` - : `"${columnName}"`; + ? `NULLIF(CONCAT(${concatenatedColumns}), '')` + : `CONCAT(${concatenatedColumns})`; switch (aggregatedField.aggregateOperation) { case AGGREGATE_OPERATIONS.countEmpty: queryBuilder.addSelect( - `CASE WHEN COUNT(*) = 0 THEN NULL ELSE COUNT(*) - COUNT(${columnEmptyValueExpression}) END`, + `CASE WHEN COUNT(*) = 0 THEN NULL ELSE COUNT(*) - COUNT(${columnExpression}) END`, `${aggregatedFieldName}`, ); break; case AGGREGATE_OPERATIONS.countNotEmpty: queryBuilder.addSelect( - `CASE WHEN COUNT(*) = 0 THEN NULL ELSE COUNT(${columnEmptyValueExpression}) END`, + `CASE WHEN COUNT(*) = 0 THEN NULL ELSE COUNT(${columnExpression}) END`, `${aggregatedFieldName}`, ); break; case AGGREGATE_OPERATIONS.countUniqueValues: queryBuilder.addSelect( - `CASE WHEN COUNT(*) = 0 THEN NULL ELSE COUNT(DISTINCT "${columnName}") END`, + `CASE WHEN COUNT(*) = 0 THEN NULL ELSE COUNT(DISTINCT ${columnExpression}) END`, `${aggregatedFieldName}`, ); break; case AGGREGATE_OPERATIONS.percentageEmpty: queryBuilder.addSelect( - `CASE WHEN COUNT(*) = 0 THEN NULL ELSE CAST(((COUNT(*) - COUNT(${columnEmptyValueExpression})::decimal) / COUNT(*)) AS DECIMAL) END`, + `CASE WHEN COUNT(*) = 0 THEN NULL ELSE CAST(((COUNT(*) - COUNT(${columnExpression})::decimal) / COUNT(*)) AS DECIMAL) END`, `${aggregatedFieldName}`, ); break; case AGGREGATE_OPERATIONS.percentageNotEmpty: queryBuilder.addSelect( - `CASE WHEN COUNT(*) = 0 THEN NULL ELSE CAST((COUNT(${columnEmptyValueExpression})::decimal / COUNT(*)) AS DECIMAL) END`, + `CASE WHEN COUNT(*) = 0 THEN NULL ELSE CAST((COUNT(${columnExpression})::decimal / COUNT(*)) AS DECIMAL) END`, `${aggregatedFieldName}`, ); break; - default: + default: { queryBuilder.addSelect( - `${aggregatedField.aggregateOperation}("${columnName}")`, + `${aggregatedField.aggregateOperation}("${columnNameForNumericOperation}")`, `${aggregatedFieldName}`, ); + } } } }; diff --git a/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/utils/get-available-aggregations-from-object-fields.util.ts b/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/utils/get-available-aggregations-from-object-fields.util.ts index e75e86093..be06a390d 100644 --- a/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/utils/get-available-aggregations-from-object-fields.util.ts +++ b/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/utils/get-available-aggregations-from-object-fields.util.ts @@ -1,10 +1,7 @@ import { GraphQLISODateTime } from '@nestjs/graphql'; import { GraphQLFloat, GraphQLInt, GraphQLScalarType } from 'graphql'; -import { - getColumnNameForAggregateOperation, - getSubfieldForAggregateOperation, -} from 'twenty-shared'; +import { getSubfieldsForAggregateOperation } from 'twenty-shared'; import { FieldMetadataInterface } from 'src/engine/metadata-modules/field-metadata/interfaces/field-metadata.interface'; @@ -17,7 +14,8 @@ export type AggregationField = { description: string; fromField: string; fromFieldType: FieldMetadataType; - fromSubField?: string; + fromSubFields?: string[]; + subFieldForNumericOperation?: string; aggregateOperation: AGGREGATE_OPERATIONS; }; @@ -30,55 +28,50 @@ export const getAvailableAggregationsFromObjectFields = ( return acc; } - const columnName = getColumnNameForAggregateOperation( - field.name, - field.type, - ); + const fromSubFields = getSubfieldsForAggregateOperation(field.type); - const fromSubField = getSubfieldForAggregateOperation(field.type); - - acc[`countUniqueValues${capitalize(columnName)}`] = { + acc[`countUniqueValues${capitalize(field.name)}`] = { type: GraphQLInt, description: `Number of unique values for ${field.name}`, fromField: field.name, fromFieldType: field.type, - fromSubField, + fromSubFields, aggregateOperation: AGGREGATE_OPERATIONS.countUniqueValues, }; - acc[`countEmpty${capitalize(columnName)}`] = { + acc[`countEmpty${capitalize(field.name)}`] = { type: GraphQLInt, description: `Number of empty values for ${field.name}`, fromField: field.name, fromFieldType: field.type, - fromSubField, + fromSubFields, aggregateOperation: AGGREGATE_OPERATIONS.countEmpty, }; - acc[`countNotEmpty${capitalize(columnName)}`] = { + acc[`countNotEmpty${capitalize(field.name)}`] = { type: GraphQLInt, description: `Number of non-empty values for ${field.name}`, fromField: field.name, fromFieldType: field.type, - fromSubField, + fromSubFields, aggregateOperation: AGGREGATE_OPERATIONS.countNotEmpty, }; - acc[`percentageEmpty${capitalize(columnName)}`] = { + acc[`percentageEmpty${capitalize(field.name)}`] = { type: GraphQLFloat, description: `Percentage of empty values for ${field.name}`, fromField: field.name, fromFieldType: field.type, - fromSubField, + fromSubFields, aggregateOperation: AGGREGATE_OPERATIONS.percentageEmpty, }; - acc[`percentageNotEmpty${capitalize(columnName)}`] = { + acc[`percentageNotEmpty${capitalize(field.name)}`] = { type: GraphQLFloat, description: `Percentage of non-empty values for ${field.name}`, fromField: field.name, fromFieldType: field.type, - fromSubField, + fromSubFields, aggregateOperation: AGGREGATE_OPERATIONS.percentageNotEmpty, }; @@ -138,7 +131,8 @@ export const getAvailableAggregationsFromObjectFields = ( type: GraphQLFloat, description: `Minimum amount contained in the field ${field.name}`, fromField: field.name, - fromSubField: 'amountMicros', + fromSubFields: getSubfieldsForAggregateOperation(field.type), + subFieldForNumericOperation: 'amountMicros', fromFieldType: field.type, aggregateOperation: AGGREGATE_OPERATIONS.min, }; @@ -147,7 +141,7 @@ export const getAvailableAggregationsFromObjectFields = ( type: GraphQLFloat, description: `Maximal amount contained in the field ${field.name}`, fromField: field.name, - fromSubField: 'amountMicros', + fromSubFields: getSubfieldsForAggregateOperation(field.type), fromFieldType: field.type, aggregateOperation: AGGREGATE_OPERATIONS.max, }; @@ -156,7 +150,7 @@ export const getAvailableAggregationsFromObjectFields = ( type: GraphQLFloat, description: `Sum of amounts contained in the field ${field.name}`, fromField: field.name, - fromSubField: 'amountMicros', + fromSubFields: getSubfieldsForAggregateOperation(field.type), fromFieldType: field.type, aggregateOperation: AGGREGATE_OPERATIONS.sum, }; @@ -165,7 +159,7 @@ export const getAvailableAggregationsFromObjectFields = ( type: GraphQLFloat, description: `Average amount contained in the field ${field.name}`, fromField: field.name, - fromSubField: 'amountMicros', + fromSubFields: getSubfieldsForAggregateOperation(field.type), fromFieldType: field.type, aggregateOperation: AGGREGATE_OPERATIONS.avg, }; diff --git a/packages/twenty-server/src/engine/twenty-orm/utils/__tests__/format-column-name-from-composite-field-and-subfield.util.spec.ts b/packages/twenty-server/src/engine/twenty-orm/utils/__tests__/format-column-name-from-composite-field-and-subfield.util.spec.ts index c1f015e27..2996f0369 100644 --- a/packages/twenty-server/src/engine/twenty-orm/utils/__tests__/format-column-name-from-composite-field-and-subfield.util.spec.ts +++ b/packages/twenty-server/src/engine/twenty-orm/utils/__tests__/format-column-name-from-composite-field-and-subfield.util.spec.ts @@ -1,18 +1,18 @@ -import { formatColumnNameFromCompositeFieldAndSubfield } from 'src/engine/twenty-orm/utils/format-column-name-from-composite-field-and-subfield.util'; +import { formatColumnNamesFromCompositeFieldAndSubfields } from 'src/engine/twenty-orm/utils/format-column-names-from-composite-field-and-subfield.util'; -describe('formatColumnNameFromCompositeFieldAndSubfield', () => { +describe('formatColumnNamesFromCompositeFieldAndSubfields', () => { it('should return fieldName when subFieldName is not defined', () => { - const result = formatColumnNameFromCompositeFieldAndSubfield('firstName'); + const result = formatColumnNamesFromCompositeFieldAndSubfields('firstName'); - expect(result).toBe('firstName'); + expect(result).toEqual(['firstName']); }); it('should return concatenated fieldName and capitalized subFieldName when subFieldName is defined', () => { - const result = formatColumnNameFromCompositeFieldAndSubfield( - 'user', + const result = formatColumnNamesFromCompositeFieldAndSubfields('user', [ 'firstName', - ); + 'lastName', + ]); - expect(result).toBe('userFirstName'); + expect(result).toEqual(['userFirstName', 'userLastName']); }); }); diff --git a/packages/twenty-server/src/engine/twenty-orm/utils/format-column-name-from-composite-field-and-subfield.util.ts b/packages/twenty-server/src/engine/twenty-orm/utils/format-column-name-from-composite-field-and-subfield.util.ts deleted file mode 100644 index a84299c64..000000000 --- a/packages/twenty-server/src/engine/twenty-orm/utils/format-column-name-from-composite-field-and-subfield.util.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { capitalize } from 'src/utils/capitalize'; -import { isDefined } from 'src/utils/is-defined'; - -export const formatColumnNameFromCompositeFieldAndSubfield = ( - fieldName: string, - subFieldName?: string, -): string => { - if (isDefined(subFieldName)) { - return `${fieldName}${capitalize(subFieldName)}`; - } - - return fieldName; -}; diff --git a/packages/twenty-server/src/engine/twenty-orm/utils/format-column-names-from-composite-field-and-subfield.util.ts b/packages/twenty-server/src/engine/twenty-orm/utils/format-column-names-from-composite-field-and-subfield.util.ts new file mode 100644 index 000000000..311a2d985 --- /dev/null +++ b/packages/twenty-server/src/engine/twenty-orm/utils/format-column-names-from-composite-field-and-subfield.util.ts @@ -0,0 +1,15 @@ +import { capitalize } from 'src/utils/capitalize'; +import { isDefined } from 'src/utils/is-defined'; + +export const formatColumnNamesFromCompositeFieldAndSubfields = ( + fieldName: string, + subFieldNames?: string[], +): string[] => { + if (isDefined(subFieldNames)) { + return subFieldNames.map( + (subFieldName) => `${fieldName}${capitalize(subFieldName)}`, + ); + } + + return [fieldName]; +}; diff --git a/packages/twenty-shared/src/utils/aggregateOperations/getColumnNameForAggregateOperation.util.ts b/packages/twenty-shared/src/utils/aggregateOperations/getColumnNameForAggregateOperation.util.ts deleted file mode 100644 index f5a117828..000000000 --- a/packages/twenty-shared/src/utils/aggregateOperations/getColumnNameForAggregateOperation.util.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { FieldMetadataType } from 'src/types/FieldMetadataType'; -import { getSubfieldForAggregateOperation } from 'src/utils/aggregateOperations/getSubFieldForAggregateOperation.util'; -import { isCompositeFieldMetadataType } from 'src/utils/aggregateOperations/isCompositeFieldMetadataType.util'; -import { capitalize } from 'src/utils/strings/capitalize'; - -export const getColumnNameForAggregateOperation = ( - fieldName: string, - fieldType: FieldMetadataType, -) => { - if (!isCompositeFieldMetadataType(fieldType)) { - return fieldName; - } - - return `${fieldName}${capitalize(getSubfieldForAggregateOperation(fieldType) as string)}`; -}; diff --git a/packages/twenty-shared/src/utils/aggregateOperations/getSubFieldForAggregateOperation.util.ts b/packages/twenty-shared/src/utils/aggregateOperations/getSubFieldsForAggregateOperation.util.ts similarity index 51% rename from packages/twenty-shared/src/utils/aggregateOperations/getSubFieldForAggregateOperation.util.ts rename to packages/twenty-shared/src/utils/aggregateOperations/getSubFieldsForAggregateOperation.util.ts index 25829c39c..44aadbfb7 100644 --- a/packages/twenty-shared/src/utils/aggregateOperations/getSubFieldForAggregateOperation.util.ts +++ b/packages/twenty-shared/src/utils/aggregateOperations/getSubFieldsForAggregateOperation.util.ts @@ -1,27 +1,40 @@ import { FieldMetadataType } from 'src/types/FieldMetadataType'; import { isCompositeFieldMetadataType } from 'src/utils/aggregateOperations/isCompositeFieldMetadataType.util'; -export const getSubfieldForAggregateOperation = ( +export const getSubfieldsForAggregateOperation = ( fieldType: FieldMetadataType, -) => { +): string[] | undefined => { if (!isCompositeFieldMetadataType(fieldType)) { return undefined; } else { switch (fieldType) { case FieldMetadataType.CURRENCY: - return 'amountMicros'; + return ['amountMicros', 'currencyCode']; case FieldMetadataType.FULL_NAME: - return 'lastName'; + return ['firstName', 'lastName']; case FieldMetadataType.ADDRESS: - return 'addressStreet1'; + return [ + 'addressStreet1', + 'addressStreet2', + 'addressCity', + 'addressPostcode', + 'addressState', + 'addressCountry', + 'addressLat', + 'addressLng', + ]; case FieldMetadataType.LINKS: - return 'primaryLinkLabel'; + return ['primaryLinkUrl']; case FieldMetadataType.ACTOR: - return 'workspaceMemberId'; + return ['workspaceMemberId']; case FieldMetadataType.EMAILS: - return 'primaryEmail'; + return ['primaryEmail']; case FieldMetadataType.PHONES: - return 'primaryPhoneNumber'; + return [ + 'primaryPhoneNumber', + 'primaryPhoneCountryCode', + 'primaryPhoneCallingCode', + ]; default: throw new Error(`Unsupported composite field type: ${fieldType}`); } diff --git a/packages/twenty-shared/src/utils/aggregateOperations/index.ts b/packages/twenty-shared/src/utils/aggregateOperations/index.ts index b91a842e3..099762ff2 100644 --- a/packages/twenty-shared/src/utils/aggregateOperations/index.ts +++ b/packages/twenty-shared/src/utils/aggregateOperations/index.ts @@ -1,3 +1 @@ -export * from './getColumnNameForAggregateOperation.util'; -export * from './getSubFieldForAggregateOperation.util'; - +export * from './getSubFieldsForAggregateOperation.util';