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