Aggregate count variations (#9304)

Closes https://github.com/twentyhq/private-issues/issues/222

---------

Co-authored-by: Lucas Bordeau <bordeau.lucas@gmail.com>
Co-authored-by: Weiko <corentin@twenty.com>
This commit is contained in:
Marie
2025-01-02 17:35:05 +01:00
committed by GitHub
parent 0f1458cbe9
commit 5d857fbfb5
43 changed files with 650 additions and 203 deletions

View File

@ -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',

View File

@ -140,8 +140,8 @@ describe('computeAggregateValueAndLabel', () => {
expect(result).toEqual({
value: 42,
label: 'Count',
labelWithFieldName: 'Count',
label: 'Count all',
labelWithFieldName: 'Count all',
});
});

View File

@ -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', () => {

View File

@ -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);

View File

@ -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}`);
}

View File

@ -1,4 +1,5 @@
export type RecordBoardColumnHeaderAggregateContentId =
| 'aggregateOperations'
| 'aggregateFields'
| 'countAggregateOperationsOptions'
| 'moreAggregateOperationOptions';

View File

@ -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',
}

View File

@ -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 (
<>
<DropdownMenuHeader StartIcon={IconChevronLeft} onClick={resetContent}>
{title}
</DropdownMenuHeader>
<DropdownMenuItemsContainer>
<RecordTableColumnAggregateFooterAggregateOperationMenuItems
aggregateOperations={aggregateOperations}
/>
</DropdownMenuItemsContainer>
</>
);
};

View File

@ -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 (
<DropdownMenuItemsContainer>
<>
{aggregateOperations.map((operation) => (
<MenuItem
key={operation}
@ -55,6 +54,6 @@ export const RecordTableColumnAggregateFooterAggregateOperationMenuItems = ({
}
aria-selected={!isDefined(currentViewFieldAggregateOperation)}
/>
</DropdownMenuItemsContainer>
</>
);
};

View File

@ -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 <RecordTableColumnAggregateFooterDropdownMoreOptionsContent />;
case 'moreAggregateOperationOptions': {
const aggregateOperations = availableAggregateOperations.filter(
(aggregateOperation) =>
!STANDARD_AGGREGATE_OPERATION_OPTIONS.includes(aggregateOperation),
);
return (
<RecordTableColumnAggregateFooterDropdownSubmenuContent
aggregateOperations={aggregateOperations}
title="More options"
/>
);
}
case 'countAggregateOperationsOptions': {
const aggregateOperations = availableAggregateOperations.filter(
(aggregateOperation) =>
COUNT_AGGREGATE_OPERATION_OPTIONS.includes(aggregateOperation),
);
return (
<RecordTableColumnAggregateFooterDropdownSubmenuContent
aggregateOperations={aggregateOperations}
title="Count"
/>
);
}
case 'percentAggregateOperationsOptions': {
const aggregateOperations = availableAggregateOperations.filter(
(aggregateOperation) =>
PERCENT_AGGREGATE_OPERATION_OPTIONS.includes(aggregateOperation),
);
return (
<RecordTableColumnAggregateFooterDropdownSubmenuContent
aggregateOperations={aggregateOperations}
title="Percent"
/>
);
}
default:
return <RecordTableColumnAggregateFooterMenuContent />;
}

View File

@ -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 (
<>
<DropdownMenuHeader StartIcon={IconChevronLeft} onClick={resetContent}>
More options
</DropdownMenuHeader>
<DropdownMenuItemsContainer>
<RecordTableColumnAggregateFooterAggregateOperationMenuItems
aggregateOperations={availableAggregateOperations}
/>
</DropdownMenuItemsContainer>
</>
);
};

View File

@ -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 (
<>
<DropdownMenuItemsContainer>
<RecordTableColumnAggregateFooterAggregateOperationMenuItems
aggregateOperations={standardAvailableAggregateOperation}
>
{otherAvailableAggregateOperation.length > 0 ? (
<MenuItem
key={'more-options'}
onClick={() => {
onContentChange('moreAggregateOperationOptions');
}}
text={'More options'}
hasSubMenu
/>
) : null}
</RecordTableColumnAggregateFooterAggregateOperationMenuItems>
<MenuItem
onClick={() => {
onContentChange('countAggregateOperationsOptions');
}}
text={'Count'}
hasSubMenu
/>
{!fieldIsRelation && (
<MenuItem
onClick={() => {
onContentChange('percentAggregateOperationsOptions');
}}
text={'Percent'}
hasSubMenu
/>
)}
{otherAvailableAggregateOperation.length > 0 ? (
<MenuItem
onClick={() => {
onContentChange('moreAggregateOperationOptions');
}}
text={'More options'}
hasSubMenu
/>
) : null}
</DropdownMenuItemsContainer>
</>
);

View File

@ -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 = ({
<></>
) : (
<>
<StyledLabel>{aggregateLabel}</StyledLabel>
<OverflowingTextWithTooltip text={aggregateLabel} />
<StyledValue>{aggregateValue}</StyledValue>
</>
)}

View File

@ -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,
];

View File

@ -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,
];

View File

@ -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,
];

View File

@ -1,2 +1,4 @@
export type RecordTableFooterAggregateContentId =
'moreAggregateOperationOptions';
| 'moreAggregateOperationOptions'
| 'countAggregateOperationsOptions'
| 'percentAggregateOperationsOptions';

View File

@ -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>([
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) =>

View File

@ -1,6 +0,0 @@
import { AGGREGATE_OPERATIONS } from '@/object-record/record-table/constants/AggregateOperations';
export type AggregateOperationsOmittingCount = Exclude<
AGGREGATE_OPERATIONS,
AGGREGATE_OPERATIONS.count
>;

View File

@ -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
>;

View File

@ -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[];
};

View File

@ -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([]);
}
});
});

View File

@ -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();
});
});

View File

@ -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);
});

View File

@ -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<Record<string, NameForAggregation>>((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;
}, {});
};

View File

@ -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(

View File

@ -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,

View File

@ -78,7 +78,7 @@ export const seedFeatureFlags = async (
{
key: FeatureFlagKey.IsAggregateQueryEnabled,
workspaceId: workspaceId,
value: false,
value: true,
},
{
key: FeatureFlagKey.IsPageHeaderV2Enabled,

View File

@ -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',
}

View File

@ -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}`,
);
}
}
};
}

View File

@ -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<string, AggregationField> => {
return fields.reduce<Record<string, AggregationField>>((acc, field) => {
acc['totalCount'] = {
type: GraphQLInt,
description: `Total number of records in the connection`,
fromField: 'id',
aggregateOperation: AGGREGATE_OPERATIONS.count,
};
return fields.reduce<Record<string, AggregationField>>(
(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,
},
},
);
};

View File

@ -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,
];

View File

@ -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 = <Type extends FieldMetadataType>(
* 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:

View File

@ -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
);
};

View File

@ -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,
})

View File

@ -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}'`,
})

View File

@ -1,3 +1,5 @@
export * from './constants/TwentyCompaniesBaseUrl';
export * from './constants/TwentyIconsBaseUrl';
export * from './utils/aggregateOperations';
export * from './utils/image/getImageAbsoluteURI';

View File

@ -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',
}

View File

@ -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)}`;
};

View File

@ -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}`);
}
}
};

View File

@ -0,0 +1,3 @@
export * from './getColumnNameForAggregateOperation.util';
export * from './getSubFieldForAggregateOperation.util';

View File

@ -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);
};

View File

@ -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);
};