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 6157455bf..c29d42880 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,10 +5,14 @@ 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', @@ -65,6 +69,7 @@ describe('useAggregateRecordsQuery', () => { }); it('should handle simple count operation', () => { + (getColumnNameForAggregateOperation as jest.Mock).mockReturnValue('name'); const { result } = renderHook(() => useAggregateRecordsQuery({ objectNameSingular: 'company', @@ -86,6 +91,7 @@ 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/utils/__tests__/computeAggregateValueAndLabel.test.ts b/packages/twenty-front/src/modules/object-record/record-board/record-board-column/utils/__tests__/computeAggregateValueAndLabel.test.ts index d16166f63..38b45de7e 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 @@ -140,8 +140,8 @@ describe('computeAggregateValueAndLabel', () => { expect(result).toEqual({ value: 42, - label: 'Count', - labelWithFieldName: 'Count', + label: 'Count all', + labelWithFieldName: 'Count all', }); }); diff --git a/packages/twenty-front/src/modules/object-record/record-board/record-board-column/utils/__tests__/getAggregateOperationLabel.test.ts b/packages/twenty-front/src/modules/object-record/record-board/record-board-column/utils/__tests__/getAggregateOperationLabel.test.ts index 4ea4c2e42..de7afc3cc 100644 --- a/packages/twenty-front/src/modules/object-record/record-board/record-board-column/utils/__tests__/getAggregateOperationLabel.test.ts +++ b/packages/twenty-front/src/modules/object-record/record-board/record-board-column/utils/__tests__/getAggregateOperationLabel.test.ts @@ -10,8 +10,23 @@ describe('getAggregateOperationLabel', () => { ); expect(getAggregateOperationLabel(AGGREGATE_OPERATIONS.sum)).toBe('Sum'); expect(getAggregateOperationLabel(AGGREGATE_OPERATIONS.count)).toBe( - 'Count', + 'Count all', ); + expect(getAggregateOperationLabel(AGGREGATE_OPERATIONS.countEmpty)).toBe( + 'Count empty', + ); + expect(getAggregateOperationLabel(AGGREGATE_OPERATIONS.countNotEmpty)).toBe( + 'Count not empty', + ); + expect( + getAggregateOperationLabel(AGGREGATE_OPERATIONS.countUniqueValues), + ).toBe('Count unique values'); + expect( + getAggregateOperationLabel(AGGREGATE_OPERATIONS.percentageEmpty), + ).toBe('Percent empty'); + expect( + getAggregateOperationLabel(AGGREGATE_OPERATIONS.percentageNotEmpty), + ).toBe('Percent not empty'); }); it('should throw error for unknown operation', () => { 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 03a97d0d2..d68654660 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 @@ -2,6 +2,8 @@ import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; import { AggregateRecordsData } from '@/object-record/hooks/useAggregateRecords'; import { getAggregateOperationLabel } from '@/object-record/record-board/record-board-column/utils/getAggregateOperationLabel'; import { AGGREGATE_OPERATIONS } from '@/object-record/record-table/constants/AggregateOperations'; +import { COUNT_AGGREGATE_OPERATION_OPTIONS } from '@/object-record/record-table/record-table-footer/constants/countAggregateOperationOptions'; +import { PERCENT_AGGREGATE_OPERATION_OPTIONS } from '@/object-record/record-table/record-table-footer/constants/percentAggregateOperationOption'; import isEmpty from 'lodash.isempty'; import { FieldMetadataType } from '~/generated-metadata/graphql'; import { formatAmount } from '~/utils/format/formatAmount'; @@ -47,10 +49,12 @@ export const computeAggregateValueAndLabel = ({ let value; - if (aggregateOperation === AGGREGATE_OPERATIONS.count) { + if (COUNT_AGGREGATE_OPERATION_OPTIONS.includes(aggregateOperation)) { value = aggregateValue; } else if (!isDefined(aggregateValue)) { value = '-'; + } else if (PERCENT_AGGREGATE_OPERATION_OPTIONS.includes(aggregateOperation)) { + value = `${formatNumber(Number(aggregateValue) * 100)}%`; } else { value = Number(aggregateValue); diff --git a/packages/twenty-front/src/modules/object-record/record-board/record-board-column/utils/getAggregateOperationLabel.ts b/packages/twenty-front/src/modules/object-record/record-board/record-board-column/utils/getAggregateOperationLabel.ts index 8a59d0e7d..d2b969e31 100644 --- a/packages/twenty-front/src/modules/object-record/record-board/record-board-column/utils/getAggregateOperationLabel.ts +++ b/packages/twenty-front/src/modules/object-record/record-board/record-board-column/utils/getAggregateOperationLabel.ts @@ -11,7 +11,17 @@ export const getAggregateOperationLabel = (operation: AGGREGATE_OPERATIONS) => { case AGGREGATE_OPERATIONS.sum: return 'Sum'; case AGGREGATE_OPERATIONS.count: - return 'Count'; + return 'Count all'; + case AGGREGATE_OPERATIONS.countEmpty: + return 'Count empty'; + case AGGREGATE_OPERATIONS.countNotEmpty: + return 'Count not empty'; + case AGGREGATE_OPERATIONS.countUniqueValues: + return 'Count unique values'; + case AGGREGATE_OPERATIONS.percentageEmpty: + return 'Percent empty'; + case AGGREGATE_OPERATIONS.percentageNotEmpty: + return 'Percent not empty'; default: throw new Error(`Unknown aggregate operation: ${operation}`); } 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 8ecb4603b..76dd903f5 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 @@ -1,4 +1,5 @@ export type RecordBoardColumnHeaderAggregateContentId = | 'aggregateOperations' | 'aggregateFields' + | 'countAggregateOperationsOptions' | 'moreAggregateOperationOptions'; diff --git a/packages/twenty-front/src/modules/object-record/record-table/constants/AggregateOperations.ts b/packages/twenty-front/src/modules/object-record/record-table/constants/AggregateOperations.ts index 1ee85c252..74a9bc6d6 100644 --- a/packages/twenty-front/src/modules/object-record/record-table/constants/AggregateOperations.ts +++ b/packages/twenty-front/src/modules/object-record/record-table/constants/AggregateOperations.ts @@ -4,4 +4,9 @@ export enum AGGREGATE_OPERATIONS { avg = 'AVG', sum = 'SUM', count = 'COUNT', + countEmpty = 'COUNT_EMPTY', + countNotEmpty = 'COUNT_NOT_EMPTY', + countUniqueValues = 'COUNT_UNIQUE_VALUES', + percentageEmpty = 'PERCENTAGE_EMPTY', + percentageNotEmpty = 'PERCENTAGE_NOT_EMPTY', } diff --git a/packages/twenty-front/src/modules/object-record/record-table/record-table-footer/components/RecordTableColumnAggregateDropdownSubmenuContent.tsx b/packages/twenty-front/src/modules/object-record/record-table/record-table-footer/components/RecordTableColumnAggregateDropdownSubmenuContent.tsx new file mode 100644 index 000000000..d394ba3a8 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-table/record-table-footer/components/RecordTableColumnAggregateDropdownSubmenuContent.tsx @@ -0,0 +1,45 @@ +import { AGGREGATE_OPERATIONS } from '@/object-record/record-table/constants/AggregateOperations'; +import { RecordTableColumnAggregateFooterAggregateOperationMenuItems } from '@/object-record/record-table/record-table-footer/components/RecordTableColumnAggregateFooterAggregateOperationMenuItems'; +import { RecordTableColumnAggregateFooterDropdownContext } from '@/object-record/record-table/record-table-footer/components/RecordTableColumnAggregateFooterDropdownContext'; +import { TableOptionsHotkeyScope } from '@/object-record/record-table/types/TableOptionsHotkeyScope'; +import { DropdownMenuHeader } from '@/ui/layout/dropdown/components/DropdownMenuHeader'; +import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer'; +import { useDropdown } from '@/ui/layout/dropdown/hooks/useDropdown'; +import { useScopedHotkeys } from '@/ui/utilities/hotkey/hooks/useScopedHotkeys'; +import { useContext } from 'react'; +import { Key } from 'ts-key-enum'; +import { IconChevronLeft } from 'twenty-ui'; + +export const RecordTableColumnAggregateFooterDropdownSubmenuContent = ({ + aggregateOperations, + title, +}: { + aggregateOperations: AGGREGATE_OPERATIONS[]; + title: string; +}) => { + const { dropdownId, resetContent } = useContext( + RecordTableColumnAggregateFooterDropdownContext, + ); + const { closeDropdown } = useDropdown(dropdownId); + + useScopedHotkeys( + [Key.Escape], + () => { + resetContent(); + closeDropdown(); + }, + TableOptionsHotkeyScope.Dropdown, + ); + return ( + <> + + {title} + + + + + + ); +}; diff --git a/packages/twenty-front/src/modules/object-record/record-table/record-table-footer/components/RecordTableColumnAggregateFooterAggregateOperationMenuItems.tsx b/packages/twenty-front/src/modules/object-record/record-table/record-table-footer/components/RecordTableColumnAggregateFooterAggregateOperationMenuItems.tsx index 651b64e2f..e32103872 100644 --- a/packages/twenty-front/src/modules/object-record/record-table/record-table-footer/components/RecordTableColumnAggregateFooterAggregateOperationMenuItems.tsx +++ b/packages/twenty-front/src/modules/object-record/record-table/record-table-footer/components/RecordTableColumnAggregateFooterAggregateOperationMenuItems.tsx @@ -2,7 +2,6 @@ import { getAggregateOperationLabel } from '@/object-record/record-board/record- import { AGGREGATE_OPERATIONS } from '@/object-record/record-table/constants/AggregateOperations'; import { RecordTableColumnAggregateFooterDropdownContext } from '@/object-record/record-table/record-table-footer/components/RecordTableColumnAggregateFooterDropdownContext'; import { useViewFieldAggregateOperation } from '@/object-record/record-table/record-table-footer/hooks/useViewFieldAggregateOperation'; -import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer'; import { useDropdown } from '@/ui/layout/dropdown/hooks/useDropdown'; import { ReactNode, useContext } from 'react'; import { IconCheck, isDefined, MenuItem } from 'twenty-ui'; @@ -24,7 +23,7 @@ export const RecordTableColumnAggregateFooterAggregateOperationMenuItems = ({ ); const { closeDropdown } = useDropdown(dropdownId); return ( - + <> {aggregateOperations.map((operation) => ( - + ); }; diff --git a/packages/twenty-front/src/modules/object-record/record-table/record-table-footer/components/RecordTableColumnAggregateFooterDropdownContent.tsx b/packages/twenty-front/src/modules/object-record/record-table/record-table-footer/components/RecordTableColumnAggregateFooterDropdownContent.tsx index 20dca610f..8fccf398a 100644 --- a/packages/twenty-front/src/modules/object-record/record-table/record-table-footer/components/RecordTableColumnAggregateFooterDropdownContent.tsx +++ b/packages/twenty-front/src/modules/object-record/record-table/record-table-footer/components/RecordTableColumnAggregateFooterDropdownContent.tsx @@ -1,16 +1,67 @@ import { useDropdown } from '@/dropdown/hooks/useDropdown'; +import { useRecordTableContextOrThrow } from '@/object-record/record-table/contexts/RecordTableContext'; +import { RecordTableColumnAggregateFooterDropdownSubmenuContent } from '@/object-record/record-table/record-table-footer/components/RecordTableColumnAggregateDropdownSubmenuContent'; import { RecordTableColumnAggregateFooterDropdownContext } from '@/object-record/record-table/record-table-footer/components/RecordTableColumnAggregateFooterDropdownContext'; -import { RecordTableColumnAggregateFooterDropdownMoreOptionsContent } from '@/object-record/record-table/record-table-footer/components/RecordTableColumnAggregateFooterDropdownMoreOptionsContent'; import { RecordTableColumnAggregateFooterMenuContent } from '@/object-record/record-table/record-table-footer/components/RecordTableColumnAggregateFooterMenuContent'; +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 { STANDARD_AGGREGATE_OPERATION_OPTIONS } from '@/object-record/record-table/record-table-footer/constants/standardAggregateOperationOptions'; +import { getAvailableAggregateOperationsForFieldMetadataType } from '@/object-record/record-table/record-table-footer/utils/getAvailableAggregateOperationsForFieldMetadataType'; export const RecordTableColumnAggregateFooterDropdownContent = () => { - const { currentContentId } = useDropdown({ + const { currentContentId, fieldMetadataId } = useDropdown({ context: RecordTableColumnAggregateFooterDropdownContext, }); + const { objectMetadataItem } = useRecordTableContextOrThrow(); + + const fieldMetadata = objectMetadataItem.fields.find( + (field) => field.id === fieldMetadataId, + ); + + const availableAggregateOperations = + getAvailableAggregateOperationsForFieldMetadataType({ + fieldMetadataType: fieldMetadata?.type, + }); + switch (currentContentId) { - case 'moreAggregateOperationOptions': - return ; + case 'moreAggregateOperationOptions': { + const aggregateOperations = availableAggregateOperations.filter( + (aggregateOperation) => + !STANDARD_AGGREGATE_OPERATION_OPTIONS.includes(aggregateOperation), + ); + + return ( + + ); + } + case 'countAggregateOperationsOptions': { + const aggregateOperations = availableAggregateOperations.filter( + (aggregateOperation) => + COUNT_AGGREGATE_OPERATION_OPTIONS.includes(aggregateOperation), + ); + return ( + + ); + } + case 'percentAggregateOperationsOptions': { + const aggregateOperations = availableAggregateOperations.filter( + (aggregateOperation) => + PERCENT_AGGREGATE_OPERATION_OPTIONS.includes(aggregateOperation), + ); + return ( + + ); + } default: return ; } diff --git a/packages/twenty-front/src/modules/object-record/record-table/record-table-footer/components/RecordTableColumnAggregateFooterDropdownMoreOptionsContent.tsx b/packages/twenty-front/src/modules/object-record/record-table/record-table-footer/components/RecordTableColumnAggregateFooterDropdownMoreOptionsContent.tsx deleted file mode 100644 index 649e91382..000000000 --- a/packages/twenty-front/src/modules/object-record/record-table/record-table-footer/components/RecordTableColumnAggregateFooterDropdownMoreOptionsContent.tsx +++ /dev/null @@ -1,57 +0,0 @@ -import { useRecordTableContextOrThrow } from '@/object-record/record-table/contexts/RecordTableContext'; -import { RecordTableColumnAggregateFooterAggregateOperationMenuItems } from '@/object-record/record-table/record-table-footer/components/RecordTableColumnAggregateFooterAggregateOperationMenuItems'; -import { RecordTableColumnAggregateFooterDropdownContext } from '@/object-record/record-table/record-table-footer/components/RecordTableColumnAggregateFooterDropdownContext'; -import { STANDARD_AGGREGATE_OPERATION_OPTIONS } from '@/object-record/record-table/record-table-footer/constants/standardAggregateOperationOptions'; -import { getAvailableAggregateOperationsForFieldMetadataType } from '@/object-record/record-table/record-table-footer/utils/getAvailableAggregateOperationsForFieldMetadataType'; -import { TableOptionsHotkeyScope } from '@/object-record/record-table/types/TableOptionsHotkeyScope'; -import { DropdownMenuHeader } from '@/ui/layout/dropdown/components/DropdownMenuHeader'; -import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer'; -import { useDropdown } from '@/ui/layout/dropdown/hooks/useDropdown'; -import { useScopedHotkeys } from '@/ui/utilities/hotkey/hooks/useScopedHotkeys'; -import { useContext, useMemo } from 'react'; -import { Key } from 'ts-key-enum'; -import { IconChevronLeft } from 'twenty-ui'; - -export const RecordTableColumnAggregateFooterDropdownMoreOptionsContent = - () => { - const { fieldMetadataId, dropdownId, resetContent } = useContext( - RecordTableColumnAggregateFooterDropdownContext, - ); - const { closeDropdown } = useDropdown(dropdownId); - const { objectMetadataItem } = useRecordTableContextOrThrow(); - - useScopedHotkeys( - [Key.Escape], - () => { - resetContent(); - closeDropdown(); - }, - TableOptionsHotkeyScope.Dropdown, - ); - - const availableAggregateOperations = useMemo( - () => - getAvailableAggregateOperationsForFieldMetadataType({ - fieldMetadataType: objectMetadataItem.fields.find( - (field) => field.id === fieldMetadataId, - )?.type, - }).filter( - (aggregateOperation) => - !STANDARD_AGGREGATE_OPERATION_OPTIONS.includes(aggregateOperation), - ), - [fieldMetadataId, objectMetadataItem.fields], - ); - - return ( - <> - - More options - - - - - - ); - }; diff --git a/packages/twenty-front/src/modules/object-record/record-table/record-table-footer/components/RecordTableColumnAggregateFooterMenuContent.tsx b/packages/twenty-front/src/modules/object-record/record-table/record-table-footer/components/RecordTableColumnAggregateFooterMenuContent.tsx index 4b9612109..6ddaece2a 100644 --- a/packages/twenty-front/src/modules/object-record/record-table/record-table-footer/components/RecordTableColumnAggregateFooterMenuContent.tsx +++ b/packages/twenty-front/src/modules/object-record/record-table/record-table-footer/components/RecordTableColumnAggregateFooterMenuContent.tsx @@ -1,5 +1,4 @@ import { useRecordTableContextOrThrow } from '@/object-record/record-table/contexts/RecordTableContext'; -import { RecordTableColumnAggregateFooterAggregateOperationMenuItems } from '@/object-record/record-table/record-table-footer/components/RecordTableColumnAggregateFooterAggregateOperationMenuItems'; import { RecordTableColumnAggregateFooterDropdownContext } from '@/object-record/record-table/record-table-footer/components/RecordTableColumnAggregateFooterDropdownContext'; import { STANDARD_AGGREGATE_OPERATION_OPTIONS } from '@/object-record/record-table/record-table-footer/constants/standardAggregateOperationOptions'; import { getAvailableAggregateOperationsForFieldMetadataType } from '@/object-record/record-table/record-table-footer/utils/getAvailableAggregateOperationsForFieldMetadataType'; @@ -10,6 +9,7 @@ import { useScopedHotkeys } from '@/ui/utilities/hotkey/hooks/useScopedHotkeys'; import { useContext, useMemo } from 'react'; import { Key } from 'ts-key-enum'; import { MenuItem } from 'twenty-ui'; +import { FieldMetadataType } from '~/generated-metadata/graphql'; export const RecordTableColumnAggregateFooterMenuContent = () => { const { fieldMetadataId, dropdownId, onContentChange } = useContext( @@ -36,32 +36,43 @@ export const RecordTableColumnAggregateFooterMenuContent = () => { [fieldMetadataId, objectMetadataItem.fields], ); - const standardAvailableAggregateOperation = - availableAggregateOperation.filter((aggregateOperation) => - STANDARD_AGGREGATE_OPERATION_OPTIONS.includes(aggregateOperation), - ); - const otherAvailableAggregateOperation = availableAggregateOperation.filter( (aggregateOperation) => !STANDARD_AGGREGATE_OPERATION_OPTIONS.includes(aggregateOperation), ); + + const fieldIsRelation = + objectMetadataItem.fields.find((field) => field.id === fieldMetadataId) + ?.type === FieldMetadataType.Relation; + return ( <> - - {otherAvailableAggregateOperation.length > 0 ? ( - { - onContentChange('moreAggregateOperationOptions'); - }} - text={'More options'} - hasSubMenu - /> - ) : null} - + { + onContentChange('countAggregateOperationsOptions'); + }} + text={'Count'} + hasSubMenu + /> + {!fieldIsRelation && ( + { + onContentChange('percentAggregateOperationsOptions'); + }} + text={'Percent'} + hasSubMenu + /> + )} + {otherAvailableAggregateOperation.length > 0 ? ( + { + onContentChange('moreAggregateOperationOptions'); + }} + text={'More options'} + hasSubMenu + /> + ) : null} ); diff --git a/packages/twenty-front/src/modules/object-record/record-table/record-table-footer/components/RecordTableColumnAggregateFooterValue.tsx b/packages/twenty-front/src/modules/object-record/record-table/record-table-footer/components/RecordTableColumnAggregateFooterValue.tsx index 1675620c4..0294770f1 100644 --- a/packages/twenty-front/src/modules/object-record/record-table/record-table-footer/components/RecordTableColumnAggregateFooterValue.tsx +++ b/packages/twenty-front/src/modules/object-record/record-table/record-table-footer/components/RecordTableColumnAggregateFooterValue.tsx @@ -1,6 +1,6 @@ import { useAggregateRecordsForRecordTableColumnFooter } from '@/object-record/record-table/record-table-footer/hooks/useAggregateRecordsForRecordTableColumnFooter'; import styled from '@emotion/styled'; -import { isDefined } from 'twenty-ui'; +import { isDefined, OverflowingTextWithTooltip } from 'twenty-ui'; const StyledText = styled.span` overflow: hidden; @@ -23,13 +23,7 @@ const StyledValueContainer = styled.div` gap: 4px; height: 32px; justify-content: flex-end; - padding: 8px; -`; - -const StyledLabel = styled.div` - align-items: center; - display: flex; - gap: 4px; + padding: 0 8px; `; const StyledValue = styled.div` @@ -57,7 +51,7 @@ export const RecordTableColumnAggregateFooterValue = ({ <> ) : ( <> - {aggregateLabel} + {aggregateValue} )} diff --git a/packages/twenty-front/src/modules/object-record/record-table/record-table-footer/constants/countAggregateOperationOptions.tsx b/packages/twenty-front/src/modules/object-record/record-table/record-table-footer/constants/countAggregateOperationOptions.tsx new file mode 100644 index 000000000..c8c6bb530 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-table/record-table-footer/constants/countAggregateOperationOptions.tsx @@ -0,0 +1,8 @@ +import { AGGREGATE_OPERATIONS } from '@/object-record/record-table/constants/AggregateOperations'; + +export const COUNT_AGGREGATE_OPERATION_OPTIONS = [ + AGGREGATE_OPERATIONS.count, + AGGREGATE_OPERATIONS.countEmpty, + AGGREGATE_OPERATIONS.countNotEmpty, + AGGREGATE_OPERATIONS.countUniqueValues, +]; diff --git a/packages/twenty-front/src/modules/object-record/record-table/record-table-footer/constants/percentAggregateOperationOption.tsx b/packages/twenty-front/src/modules/object-record/record-table/record-table-footer/constants/percentAggregateOperationOption.tsx new file mode 100644 index 000000000..44066de82 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-table/record-table-footer/constants/percentAggregateOperationOption.tsx @@ -0,0 +1,6 @@ +import { AGGREGATE_OPERATIONS } from '@/object-record/record-table/constants/AggregateOperations'; + +export const PERCENT_AGGREGATE_OPERATION_OPTIONS = [ + AGGREGATE_OPERATIONS.percentageEmpty, + AGGREGATE_OPERATIONS.percentageNotEmpty, +]; diff --git a/packages/twenty-front/src/modules/object-record/record-table/record-table-footer/constants/standardAggregateOperationOptions.tsx b/packages/twenty-front/src/modules/object-record/record-table/record-table-footer/constants/standardAggregateOperationOptions.tsx index ec4f26eb0..46b9bdbde 100644 --- a/packages/twenty-front/src/modules/object-record/record-table/record-table-footer/constants/standardAggregateOperationOptions.tsx +++ b/packages/twenty-front/src/modules/object-record/record-table/record-table-footer/constants/standardAggregateOperationOptions.tsx @@ -1,5 +1,7 @@ -import { AGGREGATE_OPERATIONS } from '@/object-record/record-table/constants/AggregateOperations'; +import { COUNT_AGGREGATE_OPERATION_OPTIONS } from '@/object-record/record-table/record-table-footer/constants/countAggregateOperationOptions'; +import { PERCENT_AGGREGATE_OPERATION_OPTIONS } from '@/object-record/record-table/record-table-footer/constants/percentAggregateOperationOption'; export const STANDARD_AGGREGATE_OPERATION_OPTIONS = [ - AGGREGATE_OPERATIONS.count, + ...COUNT_AGGREGATE_OPERATION_OPTIONS, + ...PERCENT_AGGREGATE_OPERATION_OPTIONS, ]; diff --git a/packages/twenty-front/src/modules/object-record/record-table/record-table-footer/types/RecordTableFooterAggregateContentId.tsx b/packages/twenty-front/src/modules/object-record/record-table/record-table-footer/types/RecordTableFooterAggregateContentId.tsx index f788a9503..72e870622 100644 --- a/packages/twenty-front/src/modules/object-record/record-table/record-table-footer/types/RecordTableFooterAggregateContentId.tsx +++ b/packages/twenty-front/src/modules/object-record/record-table/record-table-footer/types/RecordTableFooterAggregateContentId.tsx @@ -1,2 +1,4 @@ export type RecordTableFooterAggregateContentId = - 'moreAggregateOperationOptions'; + | 'moreAggregateOperationOptions' + | 'countAggregateOperationsOptions' + | 'percentAggregateOperationsOptions'; 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 c49fcc66b..33915ebb8 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,6 +1,6 @@ import { AGGREGATE_OPERATIONS } from '@/object-record/record-table/constants/AggregateOperations'; import { FIELDS_AVAILABLE_BY_AGGREGATE_OPERATION } from '@/object-record/record-table/constants/FieldsAvailableByAggregateOperation'; -import { AggregateOperationsOmittingCount } from '@/object-record/types/AggregateOperationsOmittingCount'; +import { AggregateOperationsOmittingStandardOperations } from '@/object-record/types/AggregateOperationsOmittingStandardOperations'; import { isFieldTypeValidForAggregateOperation } from '@/object-record/utils/isFieldTypeValidForAggregateOperation'; import { FieldMetadataType } from '~/generated/graphql'; import { isDefined } from '~/utils/isDefined'; @@ -10,8 +10,17 @@ export const getAvailableAggregateOperationsForFieldMetadataType = ({ }: { fieldMetadataType?: FieldMetadataType; }) => { + if (fieldMetadataType === FieldMetadataType.Relation) { + return [AGGREGATE_OPERATIONS.count]; + } + const availableAggregateOperations = new Set([ AGGREGATE_OPERATIONS.count, + AGGREGATE_OPERATIONS.countEmpty, + AGGREGATE_OPERATIONS.countNotEmpty, + AGGREGATE_OPERATIONS.countUniqueValues, + AGGREGATE_OPERATIONS.percentageEmpty, + AGGREGATE_OPERATIONS.percentageNotEmpty, ]); if (!isDefined(fieldMetadataType)) { @@ -22,7 +31,7 @@ export const getAvailableAggregateOperationsForFieldMetadataType = ({ .filter((operation) => isFieldTypeValidForAggregateOperation( fieldMetadataType, - operation as AggregateOperationsOmittingCount, + operation as AggregateOperationsOmittingStandardOperations, ), ) .forEach((operation) => diff --git a/packages/twenty-front/src/modules/object-record/types/AggregateOperationsOmittingCount.ts b/packages/twenty-front/src/modules/object-record/types/AggregateOperationsOmittingCount.ts deleted file mode 100644 index 3ffcb7a53..000000000 --- a/packages/twenty-front/src/modules/object-record/types/AggregateOperationsOmittingCount.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { AGGREGATE_OPERATIONS } from '@/object-record/record-table/constants/AggregateOperations'; - -export type AggregateOperationsOmittingCount = Exclude< - AGGREGATE_OPERATIONS, - AGGREGATE_OPERATIONS.count ->; diff --git a/packages/twenty-front/src/modules/object-record/types/AggregateOperationsOmittingStandardOperations.ts b/packages/twenty-front/src/modules/object-record/types/AggregateOperationsOmittingStandardOperations.ts new file mode 100644 index 000000000..3ce73bef4 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/types/AggregateOperationsOmittingStandardOperations.ts @@ -0,0 +1,11 @@ +import { AGGREGATE_OPERATIONS } from '@/object-record/record-table/constants/AggregateOperations'; + +export type AggregateOperationsOmittingStandardOperations = Exclude< + AGGREGATE_OPERATIONS, + | AGGREGATE_OPERATIONS.count + | AGGREGATE_OPERATIONS.countEmpty + | AGGREGATE_OPERATIONS.countNotEmpty + | AGGREGATE_OPERATIONS.countUniqueValues + | AGGREGATE_OPERATIONS.percentageEmpty + | AGGREGATE_OPERATIONS.percentageNotEmpty +>; 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 1e277e08b..47fb09ed1 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 { AggregateOperationsOmittingCount } from '@/object-record/types/AggregateOperationsOmittingCount'; +import { AggregateOperationsOmittingStandardOperations } from '@/object-record/types/AggregateOperationsOmittingStandardOperations'; export type AvailableFieldsForAggregateOperation = { - [T in AggregateOperationsOmittingCount]?: string[]; + [T in AggregateOperationsOmittingStandardOperations]?: 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 5d37247f8..cbc7f4ff5 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,5 +1,7 @@ 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 { getAvailableFieldsIdsForAggregationFromObjectFields } from '@/object-record/utils/getAvailableFieldsIdsForAggregationFromObjectFields'; import { FieldMetadataType } from '~/generated/graphql'; @@ -43,8 +45,10 @@ describe('getAvailableFieldsIdsForAggregationFromObjectFields', () => { ]); Object.values(AGGREGATE_OPERATIONS).forEach((operation) => { - if (operation !== AGGREGATE_OPERATIONS.count) { - expect(result[operation]).toEqual([]); + if (!STANDARD_AGGREGATE_OPERATION_OPTIONS.includes(operation)) { + expect( + result[operation as AggregateOperationsOmittingStandardOperations], + ).toEqual([]); } }); }); @@ -53,8 +57,10 @@ describe('getAvailableFieldsIdsForAggregationFromObjectFields', () => { const result = getAvailableFieldsIdsForAggregationFromObjectFields([]); Object.values(AGGREGATE_OPERATIONS).forEach((operation) => { - if (operation !== AGGREGATE_OPERATIONS.count) { - expect(result[operation]).toEqual([]); + if (!STANDARD_AGGREGATE_OPERATION_OPTIONS.includes(operation)) { + expect( + result[operation as AggregateOperationsOmittingStandardOperations], + ).toEqual([]); } }); }); 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 5e5902dce..6bcb71598 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,6 +1,6 @@ import { AGGREGATE_OPERATIONS } from '@/object-record/record-table/constants/AggregateOperations'; import { FIELDS_AVAILABLE_BY_AGGREGATE_OPERATION } from '@/object-record/record-table/constants/FieldsAvailableByAggregateOperation'; -import { AggregateOperationsOmittingCount } from '@/object-record/types/AggregateOperationsOmittingCount'; +import { AggregateOperationsOmittingStandardOperations } from '@/object-record/types/AggregateOperationsOmittingStandardOperations'; import { initializeAvailableFieldsForAggregateOperationMap } from '@/object-record/utils/initializeAvailableFieldsForAggregateOperationMap'; describe('initializeAvailableFieldsForAggregateOperationMap', () => { @@ -18,7 +18,9 @@ describe('initializeAvailableFieldsForAggregateOperationMap', () => { it('should not include count operation', () => { const result = initializeAvailableFieldsForAggregateOperationMap(); expect( - result[AGGREGATE_OPERATIONS.count as AggregateOperationsOmittingCount], + result[ + AGGREGATE_OPERATIONS.count as AggregateOperationsOmittingStandardOperations + ], ).toBeUndefined(); }); }); diff --git a/packages/twenty-front/src/modules/object-record/utils/__tests__/isFieldTypeValidForAggregateOperation.test.ts b/packages/twenty-front/src/modules/object-record/utils/__tests__/isFieldTypeValidForAggregateOperation.test.ts index 4d975347b..217189572 100644 --- a/packages/twenty-front/src/modules/object-record/utils/__tests__/isFieldTypeValidForAggregateOperation.test.ts +++ b/packages/twenty-front/src/modules/object-record/utils/__tests__/isFieldTypeValidForAggregateOperation.test.ts @@ -1,5 +1,5 @@ import { AGGREGATE_OPERATIONS } from '@/object-record/record-table/constants/AggregateOperations'; -import { AggregateOperationsOmittingCount } from '@/object-record/types/AggregateOperationsOmittingCount'; +import { AggregateOperationsOmittingStandardOperations } from '@/object-record/types/AggregateOperationsOmittingStandardOperations'; import { isFieldTypeValidForAggregateOperation } from '@/object-record/utils/isFieldTypeValidForAggregateOperation'; import { FieldMetadataType } from '~/generated/graphql'; @@ -49,7 +49,7 @@ describe('isFieldTypeValidForAggregateOperation', () => { expect( isFieldTypeValidForAggregateOperation( numericField, - operation as AggregateOperationsOmittingCount, + operation as AggregateOperationsOmittingStandardOperations, ), ).toBe(true); }); 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 241324df6..6f540990c 100644 --- a/packages/twenty-front/src/modules/object-record/utils/getAvailableAggregationsFromObjectFields.ts +++ b/packages/twenty-front/src/modules/object-record/utils/getAvailableAggregationsFromObjectFields.ts @@ -1,5 +1,6 @@ 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'; @@ -15,8 +16,30 @@ export const getAvailableAggregationsFromObjectFields = ( fields: FieldMetadataItem[], ): Aggregations => { return fields.reduce>((acc, field) => { + if (field.type === FieldMetadataType.Relation) { + acc[field.name] = { + [AGGREGATE_OPERATIONS.count]: 'totalCount', + }; + 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.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)}`, }; @@ -24,6 +47,7 @@ export const getAvailableAggregationsFromObjectFields = ( if (field.type === FieldMetadataType.Number) { acc[field.name] = { + ...acc[field.name], [AGGREGATE_OPERATIONS.min]: `min${capitalize(field.name)}`, [AGGREGATE_OPERATIONS.max]: `max${capitalize(field.name)}`, [AGGREGATE_OPERATIONS.avg]: `avg${capitalize(field.name)}`, @@ -33,6 +57,7 @@ export const getAvailableAggregationsFromObjectFields = ( if (field.type === FieldMetadataType.Currency) { acc[field.name] = { + ...acc[field.name], [AGGREGATE_OPERATIONS.min]: `min${capitalize(field.name)}AmountMicros`, [AGGREGATE_OPERATIONS.max]: `max${capitalize(field.name)}AmountMicros`, [AGGREGATE_OPERATIONS.avg]: `avg${capitalize(field.name)}AmountMicros`, @@ -44,8 +69,6 @@ export const getAvailableAggregationsFromObjectFields = ( acc[field.name] = {}; } - acc[field.name][AGGREGATE_OPERATIONS.count] = 'totalCount'; - return acc; }, {}); }; 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 60ec12954..3586760f2 100644 --- a/packages/twenty-front/src/modules/object-record/utils/getAvailableFieldsIdsForAggregationFromObjectFields.ts +++ b/packages/twenty-front/src/modules/object-record/utils/getAvailableFieldsIdsForAggregationFromObjectFields.ts @@ -1,6 +1,6 @@ import { FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem'; import { FIELDS_AVAILABLE_BY_AGGREGATE_OPERATION } from '@/object-record/record-table/constants/FieldsAvailableByAggregateOperation'; -import { AggregateOperationsOmittingCount } from '@/object-record/types/AggregateOperationsOmittingCount'; +import { AggregateOperationsOmittingStandardOperations } from '@/object-record/types/AggregateOperationsOmittingStandardOperations'; import { AvailableFieldsForAggregateOperation } from '@/object-record/types/AvailableFieldsForAggregateOperation'; import { initializeAvailableFieldsForAggregateOperationMap } from '@/object-record/utils/initializeAvailableFieldsForAggregateOperationMap'; import { isFieldTypeValidForAggregateOperation } from '@/object-record/utils/isFieldTypeValidForAggregateOperation'; @@ -14,7 +14,7 @@ export const getAvailableFieldsIdsForAggregationFromObjectFields = ( Object.keys(FIELDS_AVAILABLE_BY_AGGREGATE_OPERATION).forEach( (aggregateOperation) => { const typedAggregateOperation = - aggregateOperation as AggregateOperationsOmittingCount; + aggregateOperation as AggregateOperationsOmittingStandardOperations; if ( isFieldTypeValidForAggregateOperation( 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 b480ce048..2a80366f2 100644 --- a/packages/twenty-front/src/modules/object-record/utils/isFieldTypeValidForAggregateOperation.ts +++ b/packages/twenty-front/src/modules/object-record/utils/isFieldTypeValidForAggregateOperation.ts @@ -1,10 +1,10 @@ import { FIELDS_AVAILABLE_BY_AGGREGATE_OPERATION } from '@/object-record/record-table/constants/FieldsAvailableByAggregateOperation'; -import { AggregateOperationsOmittingCount } from '@/object-record/types/AggregateOperationsOmittingCount'; +import { AggregateOperationsOmittingStandardOperations } from '@/object-record/types/AggregateOperationsOmittingStandardOperations'; import { FieldMetadataType } from '~/generated-metadata/graphql'; export const isFieldTypeValidForAggregateOperation = ( fieldType: FieldMetadataType, - aggregateOperation: AggregateOperationsOmittingCount, + aggregateOperation: AggregateOperationsOmittingStandardOperations, ): boolean => { return FIELDS_AVAILABLE_BY_AGGREGATE_OPERATION[aggregateOperation].includes( fieldType, diff --git a/packages/twenty-server/src/database/typeorm-seeds/core/feature-flags.ts b/packages/twenty-server/src/database/typeorm-seeds/core/feature-flags.ts index fd9957cf2..4b73c35da 100644 --- a/packages/twenty-server/src/database/typeorm-seeds/core/feature-flags.ts +++ b/packages/twenty-server/src/database/typeorm-seeds/core/feature-flags.ts @@ -78,7 +78,7 @@ export const seedFeatureFlags = async ( { key: FeatureFlagKey.IsAggregateQueryEnabled, workspaceId: workspaceId, - value: false, + value: true, }, { key: FeatureFlagKey.IsPageHeaderV2Enabled, diff --git a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/constants/aggregate-operations.constant.ts b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/constants/aggregate-operations.constant.ts index 1ee85c252..39b916caf 100644 --- a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/constants/aggregate-operations.constant.ts +++ b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/constants/aggregate-operations.constant.ts @@ -4,4 +4,9 @@ export enum AGGREGATE_OPERATIONS { avg = 'AVG', sum = 'SUM', count = 'COUNT', + countUniqueValues = 'COUNT_UNIQUE_VALUES', + countEmpty = 'COUNT_EMPTY', + countNotEmpty = 'COUNT_NOT_EMPTY', + percentageEmpty = 'PERCENTAGE_EMPTY', + percentageNotEmpty = 'PERCENTAGE_NOT_EMPTY', } 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 1388c55b7..1b9c7b7ef 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 @@ -2,6 +2,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 { isDefined } from 'src/utils/is-defined'; @@ -38,10 +39,50 @@ export class ProcessAggregateHelper { continue; } - queryBuilder.addSelect( - `${aggregatedField.aggregateOperation}("${columnName}")`, - `${aggregatedFieldName}`, - ); + const columnEmptyValueExpression = + FIELD_METADATA_TYPES_TO_TEXT_COLUMN_TYPE.includes( + aggregatedField.fromFieldType, + ) + ? `NULLIF("${columnName}", '')` + : `"${columnName}"`; + + switch (aggregatedField.aggregateOperation) { + case AGGREGATE_OPERATIONS.countEmpty: + queryBuilder.addSelect( + `CASE WHEN COUNT(*) = 0 THEN NULL ELSE COUNT(*) - COUNT(${columnEmptyValueExpression}) END`, + `${aggregatedFieldName}`, + ); + break; + case AGGREGATE_OPERATIONS.countNotEmpty: + queryBuilder.addSelect( + `CASE WHEN COUNT(*) = 0 THEN NULL ELSE COUNT(${columnEmptyValueExpression}) END`, + `${aggregatedFieldName}`, + ); + break; + case AGGREGATE_OPERATIONS.countUniqueValues: + queryBuilder.addSelect( + `CASE WHEN COUNT(*) = 0 THEN NULL ELSE COUNT(DISTINCT "${columnName}") 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`, + `${aggregatedFieldName}`, + ); + break; + case AGGREGATE_OPERATIONS.percentageNotEmpty: + queryBuilder.addSelect( + `CASE WHEN COUNT(*) = 0 THEN NULL ELSE CAST((COUNT(${columnEmptyValueExpression})::decimal / COUNT(*)) AS DECIMAL) END`, + `${aggregatedFieldName}`, + ); + break; + default: + queryBuilder.addSelect( + `${aggregatedField.aggregateOperation}("${columnName}")`, + `${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 4872f6b9d..e75e86093 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,6 +1,10 @@ import { GraphQLISODateTime } from '@nestjs/graphql'; import { GraphQLFloat, GraphQLInt, GraphQLScalarType } from 'graphql'; +import { + getColumnNameForAggregateOperation, + getSubfieldForAggregateOperation, +} from 'twenty-shared'; import { FieldMetadataInterface } from 'src/engine/metadata-modules/field-metadata/interfaces/field-metadata.interface'; @@ -12,6 +16,7 @@ export type AggregationField = { type: GraphQLScalarType; description: string; fromField: string; + fromFieldType: FieldMetadataType; fromSubField?: string; aggregateOperation: AGGREGATE_OPERATIONS; }; @@ -19,94 +24,164 @@ export type AggregationField = { export const getAvailableAggregationsFromObjectFields = ( fields: FieldMetadataInterface[], ): Record => { - return fields.reduce>((acc, field) => { - acc['totalCount'] = { - type: GraphQLInt, - description: `Total number of records in the connection`, - fromField: 'id', - aggregateOperation: AGGREGATE_OPERATIONS.count, - }; + return fields.reduce>( + (acc, field) => { + if (field.type === FieldMetadataType.RELATION) { + return acc; + } - if (field.type === FieldMetadataType.DATE_TIME) { - acc[`min${capitalize(field.name)}`] = { - type: GraphQLISODateTime, - description: `Oldest date contained in the field ${field.name}`, + const columnName = getColumnNameForAggregateOperation( + field.name, + field.type, + ); + + const fromSubField = getSubfieldForAggregateOperation(field.type); + + acc[`countUniqueValues${capitalize(columnName)}`] = { + type: GraphQLInt, + description: `Number of unique values for ${field.name}`, fromField: field.name, - aggregateOperation: AGGREGATE_OPERATIONS.min, + fromFieldType: field.type, + fromSubField, + aggregateOperation: AGGREGATE_OPERATIONS.countUniqueValues, }; - acc[`max${capitalize(field.name)}`] = { - type: GraphQLISODateTime, - description: `Most recent date contained in the field ${field.name}`, + acc[`countEmpty${capitalize(columnName)}`] = { + type: GraphQLInt, + description: `Number of empty values for ${field.name}`, fromField: field.name, - aggregateOperation: AGGREGATE_OPERATIONS.max, + fromFieldType: field.type, + fromSubField, + aggregateOperation: AGGREGATE_OPERATIONS.countEmpty, }; - } - if (field.type === FieldMetadataType.NUMBER) { - acc[`min${capitalize(field.name)}`] = { + acc[`countNotEmpty${capitalize(columnName)}`] = { + type: GraphQLInt, + description: `Number of non-empty values for ${field.name}`, + fromField: field.name, + fromFieldType: field.type, + fromSubField, + aggregateOperation: AGGREGATE_OPERATIONS.countNotEmpty, + }; + + acc[`percentageEmpty${capitalize(columnName)}`] = { type: GraphQLFloat, - description: `Minimum amount contained in the field ${field.name}`, + description: `Percentage of empty values for ${field.name}`, fromField: field.name, - aggregateOperation: AGGREGATE_OPERATIONS.min, + fromFieldType: field.type, + fromSubField, + aggregateOperation: AGGREGATE_OPERATIONS.percentageEmpty, }; - acc[`max${capitalize(field.name)}`] = { + acc[`percentageNotEmpty${capitalize(columnName)}`] = { type: GraphQLFloat, - description: `Maximum amount contained in the field ${field.name}`, + description: `Percentage of non-empty values for ${field.name}`, fromField: field.name, - aggregateOperation: AGGREGATE_OPERATIONS.max, + fromFieldType: field.type, + fromSubField, + aggregateOperation: AGGREGATE_OPERATIONS.percentageNotEmpty, }; - acc[`avg${capitalize(field.name)}`] = { - type: GraphQLFloat, - description: `Average amount contained in the field ${field.name}`, - fromField: field.name, - aggregateOperation: AGGREGATE_OPERATIONS.avg, - }; + switch (field.type) { + case FieldMetadataType.DATE_TIME: + acc[`min${capitalize(field.name)}`] = { + type: GraphQLISODateTime, + description: `Oldest date contained in the field ${field.name}`, + fromField: field.name, + fromFieldType: field.type, + aggregateOperation: AGGREGATE_OPERATIONS.min, + }; - acc[`sum${capitalize(field.name)}`] = { - type: GraphQLFloat, - description: `Sum of amounts contained in the field ${field.name}`, - fromField: field.name, - aggregateOperation: AGGREGATE_OPERATIONS.sum, - }; - } + acc[`max${capitalize(field.name)}`] = { + type: GraphQLISODateTime, + description: `Most recent date contained in the field ${field.name}`, + fromField: field.name, + fromFieldType: field.type, + aggregateOperation: AGGREGATE_OPERATIONS.max, + }; + break; + case FieldMetadataType.NUMBER: + acc[`min${capitalize(field.name)}`] = { + type: GraphQLFloat, + description: `Minimum amount contained in the field ${field.name}`, + fromField: field.name, + fromFieldType: field.type, + aggregateOperation: AGGREGATE_OPERATIONS.min, + }; - if (field.type === FieldMetadataType.CURRENCY) { - acc[`min${capitalize(field.name)}AmountMicros`] = { - type: GraphQLFloat, - description: `Minimum amount contained in the field ${field.name}`, - fromField: field.name, - fromSubField: 'amountMicros', - aggregateOperation: AGGREGATE_OPERATIONS.min, - }; + acc[`max${capitalize(field.name)}`] = { + type: GraphQLFloat, + description: `Maximum amount contained in the field ${field.name}`, + fromField: field.name, + fromFieldType: field.type, + aggregateOperation: AGGREGATE_OPERATIONS.max, + }; - acc[`max${capitalize(field.name)}AmountMicros`] = { - type: GraphQLFloat, - description: `Maximal amount contained in the field ${field.name}`, - fromField: field.name, - fromSubField: 'amountMicros', - aggregateOperation: AGGREGATE_OPERATIONS.max, - }; + acc[`avg${capitalize(field.name)}`] = { + type: GraphQLFloat, + description: `Average amount contained in the field ${field.name}`, + fromField: field.name, + fromFieldType: field.type, + aggregateOperation: AGGREGATE_OPERATIONS.avg, + }; - acc[`sum${capitalize(field.name)}AmountMicros`] = { - type: GraphQLFloat, - description: `Sum of amounts contained in the field ${field.name}`, - fromField: field.name, - fromSubField: 'amountMicros', - aggregateOperation: AGGREGATE_OPERATIONS.sum, - }; + acc[`sum${capitalize(field.name)}`] = { + type: GraphQLFloat, + description: `Sum of amounts contained in the field ${field.name}`, + fromField: field.name, + fromFieldType: field.type, + aggregateOperation: AGGREGATE_OPERATIONS.sum, + }; + break; + case FieldMetadataType.CURRENCY: + acc[`min${capitalize(field.name)}AmountMicros`] = { + type: GraphQLFloat, + description: `Minimum amount contained in the field ${field.name}`, + fromField: field.name, + fromSubField: 'amountMicros', + fromFieldType: field.type, + aggregateOperation: AGGREGATE_OPERATIONS.min, + }; - acc[`avg${capitalize(field.name)}AmountMicros`] = { - type: GraphQLFloat, - description: `Average amount contained in the field ${field.name}`, - fromField: field.name, - fromSubField: 'amountMicros', - aggregateOperation: AGGREGATE_OPERATIONS.avg, - }; - } + acc[`max${capitalize(field.name)}AmountMicros`] = { + type: GraphQLFloat, + description: `Maximal amount contained in the field ${field.name}`, + fromField: field.name, + fromSubField: 'amountMicros', + fromFieldType: field.type, + aggregateOperation: AGGREGATE_OPERATIONS.max, + }; - return acc; - }, {}); + acc[`sum${capitalize(field.name)}AmountMicros`] = { + type: GraphQLFloat, + description: `Sum of amounts contained in the field ${field.name}`, + fromField: field.name, + fromSubField: 'amountMicros', + fromFieldType: field.type, + aggregateOperation: AGGREGATE_OPERATIONS.sum, + }; + + acc[`avg${capitalize(field.name)}AmountMicros`] = { + type: GraphQLFloat, + description: `Average amount contained in the field ${field.name}`, + fromField: field.name, + fromSubField: 'amountMicros', + fromFieldType: field.type, + aggregateOperation: AGGREGATE_OPERATIONS.avg, + }; + break; + } + + return acc; + }, + { + totalCount: { + type: GraphQLInt, + description: `Total number of records in the connection`, + fromField: 'id', + fromFieldType: FieldMetadataType.UUID, + aggregateOperation: AGGREGATE_OPERATIONS.count, + }, + }, + ); }; diff --git a/packages/twenty-server/src/engine/metadata-modules/workspace-migration/constants/fieldMetadataTypesToTextColumnType.ts b/packages/twenty-server/src/engine/metadata-modules/workspace-migration/constants/fieldMetadataTypesToTextColumnType.ts new file mode 100644 index 000000000..889cd3035 --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/workspace-migration/constants/fieldMetadataTypesToTextColumnType.ts @@ -0,0 +1,7 @@ +import { FieldMetadataType } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity'; + +export const FIELD_METADATA_TYPES_TO_TEXT_COLUMN_TYPE = [ + FieldMetadataType.TEXT, + FieldMetadataType.RICH_TEXT, + FieldMetadataType.ARRAY, +]; diff --git a/packages/twenty-server/src/engine/metadata-modules/workspace-migration/utils/field-metadata-type-to-column-type.util.ts b/packages/twenty-server/src/engine/metadata-modules/workspace-migration/utils/field-metadata-type-to-column-type.util.ts index 46ec14ad3..48961fe85 100644 --- a/packages/twenty-server/src/engine/metadata-modules/workspace-migration/utils/field-metadata-type-to-column-type.util.ts +++ b/packages/twenty-server/src/engine/metadata-modules/workspace-migration/utils/field-metadata-type-to-column-type.util.ts @@ -1,4 +1,5 @@ import { FieldMetadataType } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity'; +import { isTextColumnType } from 'src/engine/metadata-modules/workspace-migration/utils/is-text-column-type.util'; import { WorkspaceMigrationException, WorkspaceMigrationExceptionCode, @@ -11,13 +12,12 @@ export const fieldMetadataTypeToColumnType = ( * Composite types are not implemented here, as they are flattened by their composite definitions. * See src/metadata/field-metadata/composite-types for more information. */ + if (isTextColumnType(fieldMetadataType)) { + return 'text'; + } switch (fieldMetadataType) { case FieldMetadataType.UUID: return 'uuid'; - case FieldMetadataType.TEXT: - case FieldMetadataType.RICH_TEXT: - case FieldMetadataType.ARRAY: - return 'text'; case FieldMetadataType.NUMERIC: return 'numeric'; case FieldMetadataType.NUMBER: diff --git a/packages/twenty-server/src/engine/metadata-modules/workspace-migration/utils/is-text-column-type.util.ts b/packages/twenty-server/src/engine/metadata-modules/workspace-migration/utils/is-text-column-type.util.ts new file mode 100644 index 000000000..d0a1d70a2 --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/workspace-migration/utils/is-text-column-type.util.ts @@ -0,0 +1,9 @@ +import { FieldMetadataType } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity'; + +export const isTextColumnType = (type: FieldMetadataType) => { + return ( + type === FieldMetadataType.TEXT || + type === FieldMetadataType.RICH_TEXT || + type === FieldMetadataType.ARRAY + ); +}; diff --git a/packages/twenty-server/src/modules/view/standard-objects/view-field.workspace-entity.ts b/packages/twenty-server/src/modules/view/standard-objects/view-field.workspace-entity.ts index 66d14100b..9f34ac243 100644 --- a/packages/twenty-server/src/modules/view/standard-objects/view-field.workspace-entity.ts +++ b/packages/twenty-server/src/modules/view/standard-objects/view-field.workspace-entity.ts @@ -126,6 +126,36 @@ export class ViewFieldWorkspaceEntity extends BaseWorkspaceEntity { position: 4, color: 'yellow', }, + { + value: AGGREGATE_OPERATIONS.countEmpty, + label: 'Count empty', + position: 5, + color: 'red', + }, + { + value: AGGREGATE_OPERATIONS.countNotEmpty, + label: 'Count not empty', + position: 6, + color: 'purple', + }, + { + value: AGGREGATE_OPERATIONS.countUniqueValues, + label: 'Count unique values', + position: 7, + color: 'sky', + }, + { + value: AGGREGATE_OPERATIONS.percentageEmpty, + label: 'Percent empty', + position: 8, + color: 'turquoise', + }, + { + value: AGGREGATE_OPERATIONS.percentageNotEmpty, + label: 'Percent not empty', + position: 9, + color: 'yellow', + }, ], defaultValue: null, }) diff --git a/packages/twenty-server/src/modules/view/standard-objects/view.workspace-entity.ts b/packages/twenty-server/src/modules/view/standard-objects/view.workspace-entity.ts index bdbdb24f7..06fd46286 100644 --- a/packages/twenty-server/src/modules/view/standard-objects/view.workspace-entity.ts +++ b/packages/twenty-server/src/modules/view/standard-objects/view.workspace-entity.ts @@ -218,6 +218,36 @@ export class ViewWorkspaceEntity extends BaseWorkspaceEntity { position: 4, color: 'yellow', }, + { + value: AGGREGATE_OPERATIONS.countEmpty, + label: 'Count empty', + position: 5, + color: 'red', + }, + { + value: AGGREGATE_OPERATIONS.countNotEmpty, + label: 'Count not empty', + position: 6, + color: 'purple', + }, + { + value: AGGREGATE_OPERATIONS.countUniqueValues, + label: 'Count unique values', + position: 7, + color: 'sky', + }, + { + value: AGGREGATE_OPERATIONS.percentageEmpty, + label: 'Percent empty', + position: 8, + color: 'turquoise', + }, + { + value: AGGREGATE_OPERATIONS.percentageNotEmpty, + label: 'Percent not empty', + position: 9, + color: 'yellow', + }, ], defaultValue: `'${AGGREGATE_OPERATIONS.count}'`, }) diff --git a/packages/twenty-shared/src/index.ts b/packages/twenty-shared/src/index.ts index 894818340..271c0d102 100644 --- a/packages/twenty-shared/src/index.ts +++ b/packages/twenty-shared/src/index.ts @@ -1,3 +1,5 @@ export * from './constants/TwentyCompaniesBaseUrl'; export * from './constants/TwentyIconsBaseUrl'; +export * from './utils/aggregateOperations'; export * from './utils/image/getImageAbsoluteURI'; + diff --git a/packages/twenty-shared/src/types/FieldMetadataType.ts b/packages/twenty-shared/src/types/FieldMetadataType.ts new file mode 100644 index 000000000..83589ce92 --- /dev/null +++ b/packages/twenty-shared/src/types/FieldMetadataType.ts @@ -0,0 +1,25 @@ +export enum FieldMetadataType { + UUID = 'UUID', + TEXT = 'TEXT', + PHONES = 'PHONES', + EMAILS = 'EMAILS', + DATE_TIME = 'DATE_TIME', + DATE = 'DATE', + BOOLEAN = 'BOOLEAN', + NUMBER = 'NUMBER', + NUMERIC = 'NUMERIC', + LINKS = 'LINKS', + CURRENCY = 'CURRENCY', + FULL_NAME = 'FULL_NAME', + RATING = 'RATING', + SELECT = 'SELECT', + MULTI_SELECT = 'MULTI_SELECT', + RELATION = 'RELATION', + POSITION = 'POSITION', + ADDRESS = 'ADDRESS', + RAW_JSON = 'RAW_JSON', + RICH_TEXT = 'RICH_TEXT', + ACTOR = 'ACTOR', + ARRAY = 'ARRAY', + TS_VECTOR = 'TS_VECTOR', +} diff --git a/packages/twenty-shared/src/utils/aggregateOperations/getColumnNameForAggregateOperation.util.ts b/packages/twenty-shared/src/utils/aggregateOperations/getColumnNameForAggregateOperation.util.ts new file mode 100644 index 000000000..f5a117828 --- /dev/null +++ b/packages/twenty-shared/src/utils/aggregateOperations/getColumnNameForAggregateOperation.util.ts @@ -0,0 +1,15 @@ +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/getSubFieldForAggregateOperation.util.ts new file mode 100644 index 000000000..25829c39c --- /dev/null +++ b/packages/twenty-shared/src/utils/aggregateOperations/getSubFieldForAggregateOperation.util.ts @@ -0,0 +1,29 @@ +import { FieldMetadataType } from 'src/types/FieldMetadataType'; +import { isCompositeFieldMetadataType } from 'src/utils/aggregateOperations/isCompositeFieldMetadataType.util'; + +export const getSubfieldForAggregateOperation = ( + fieldType: FieldMetadataType, +) => { + if (!isCompositeFieldMetadataType(fieldType)) { + return undefined; + } else { + switch (fieldType) { + case FieldMetadataType.CURRENCY: + return 'amountMicros'; + case FieldMetadataType.FULL_NAME: + return 'lastName'; + case FieldMetadataType.ADDRESS: + return 'addressStreet1'; + case FieldMetadataType.LINKS: + return 'primaryLinkLabel'; + case FieldMetadataType.ACTOR: + return 'workspaceMemberId'; + case FieldMetadataType.EMAILS: + return 'primaryEmail'; + case FieldMetadataType.PHONES: + return 'primaryPhoneNumber'; + 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 new file mode 100644 index 000000000..b91a842e3 --- /dev/null +++ b/packages/twenty-shared/src/utils/aggregateOperations/index.ts @@ -0,0 +1,3 @@ +export * from './getColumnNameForAggregateOperation.util'; +export * from './getSubFieldForAggregateOperation.util'; + diff --git a/packages/twenty-shared/src/utils/aggregateOperations/isCompositeFieldMetadataType.util.ts b/packages/twenty-shared/src/utils/aggregateOperations/isCompositeFieldMetadataType.util.ts new file mode 100644 index 000000000..dbb30a2ef --- /dev/null +++ b/packages/twenty-shared/src/utils/aggregateOperations/isCompositeFieldMetadataType.util.ts @@ -0,0 +1,22 @@ +import { FieldMetadataType } from 'src/types/FieldMetadataType'; + +export const isCompositeFieldMetadataType = ( + type: FieldMetadataType, +): type is + | FieldMetadataType.CURRENCY + | FieldMetadataType.FULL_NAME + | FieldMetadataType.ADDRESS + | FieldMetadataType.LINKS + | FieldMetadataType.ACTOR + | FieldMetadataType.EMAILS + | FieldMetadataType.PHONES => { + return [ + FieldMetadataType.CURRENCY, + FieldMetadataType.FULL_NAME, + FieldMetadataType.ADDRESS, + FieldMetadataType.LINKS, + FieldMetadataType.ACTOR, + FieldMetadataType.EMAILS, + FieldMetadataType.PHONES, + ].includes(type); +}; diff --git a/packages/twenty-shared/src/utils/strings/capitalize.ts b/packages/twenty-shared/src/utils/strings/capitalize.ts new file mode 100644 index 000000000..9953f3751 --- /dev/null +++ b/packages/twenty-shared/src/utils/strings/capitalize.ts @@ -0,0 +1,7 @@ +import { isNonEmptyString } from '@sniptt/guards'; + +export const capitalize = (stringToCapitalize: string) => { + if (!isNonEmptyString(stringToCapitalize)) return ''; + + return stringToCapitalize[0].toUpperCase() + stringToCapitalize.slice(1); +};