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:
@ -5,10 +5,14 @@ import { useAggregateRecordsQuery } from '@/object-record/hooks/useAggregateReco
|
|||||||
import { AGGREGATE_OPERATIONS } from '@/object-record/record-table/constants/AggregateOperations';
|
import { AGGREGATE_OPERATIONS } from '@/object-record/record-table/constants/AggregateOperations';
|
||||||
import { generateAggregateQuery } from '@/object-record/utils/generateAggregateQuery';
|
import { generateAggregateQuery } from '@/object-record/utils/generateAggregateQuery';
|
||||||
import { renderHook } from '@testing-library/react';
|
import { renderHook } from '@testing-library/react';
|
||||||
|
import { getColumnNameForAggregateOperation } from 'twenty-shared';
|
||||||
import { FieldMetadataType } from '~/generated/graphql';
|
import { FieldMetadataType } from '~/generated/graphql';
|
||||||
|
|
||||||
jest.mock('@/object-metadata/hooks/useObjectMetadataItem');
|
jest.mock('@/object-metadata/hooks/useObjectMetadataItem');
|
||||||
jest.mock('@/object-record/utils/generateAggregateQuery');
|
jest.mock('@/object-record/utils/generateAggregateQuery');
|
||||||
|
jest.mock('twenty-shared', () => ({
|
||||||
|
getColumnNameForAggregateOperation: jest.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
const mockObjectMetadataItem: ObjectMetadataItem = {
|
const mockObjectMetadataItem: ObjectMetadataItem = {
|
||||||
nameSingular: 'company',
|
nameSingular: 'company',
|
||||||
@ -65,6 +69,7 @@ describe('useAggregateRecordsQuery', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should handle simple count operation', () => {
|
it('should handle simple count operation', () => {
|
||||||
|
(getColumnNameForAggregateOperation as jest.Mock).mockReturnValue('name');
|
||||||
const { result } = renderHook(() =>
|
const { result } = renderHook(() =>
|
||||||
useAggregateRecordsQuery({
|
useAggregateRecordsQuery({
|
||||||
objectNameSingular: 'company',
|
objectNameSingular: 'company',
|
||||||
@ -86,6 +91,7 @@ describe('useAggregateRecordsQuery', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should handle field aggregation', () => {
|
it('should handle field aggregation', () => {
|
||||||
|
(getColumnNameForAggregateOperation as jest.Mock).mockReturnValue('amount');
|
||||||
const { result } = renderHook(() =>
|
const { result } = renderHook(() =>
|
||||||
useAggregateRecordsQuery({
|
useAggregateRecordsQuery({
|
||||||
objectNameSingular: 'company',
|
objectNameSingular: 'company',
|
||||||
|
|||||||
@ -140,8 +140,8 @@ describe('computeAggregateValueAndLabel', () => {
|
|||||||
|
|
||||||
expect(result).toEqual({
|
expect(result).toEqual({
|
||||||
value: 42,
|
value: 42,
|
||||||
label: 'Count',
|
label: 'Count all',
|
||||||
labelWithFieldName: 'Count',
|
labelWithFieldName: 'Count all',
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -10,8 +10,23 @@ describe('getAggregateOperationLabel', () => {
|
|||||||
);
|
);
|
||||||
expect(getAggregateOperationLabel(AGGREGATE_OPERATIONS.sum)).toBe('Sum');
|
expect(getAggregateOperationLabel(AGGREGATE_OPERATIONS.sum)).toBe('Sum');
|
||||||
expect(getAggregateOperationLabel(AGGREGATE_OPERATIONS.count)).toBe(
|
expect(getAggregateOperationLabel(AGGREGATE_OPERATIONS.count)).toBe(
|
||||||
'Count',
|
'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', () => {
|
it('should throw error for unknown operation', () => {
|
||||||
|
|||||||
@ -2,6 +2,8 @@ import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
|
|||||||
import { AggregateRecordsData } from '@/object-record/hooks/useAggregateRecords';
|
import { AggregateRecordsData } from '@/object-record/hooks/useAggregateRecords';
|
||||||
import { getAggregateOperationLabel } from '@/object-record/record-board/record-board-column/utils/getAggregateOperationLabel';
|
import { getAggregateOperationLabel } from '@/object-record/record-board/record-board-column/utils/getAggregateOperationLabel';
|
||||||
import { AGGREGATE_OPERATIONS } from '@/object-record/record-table/constants/AggregateOperations';
|
import { AGGREGATE_OPERATIONS } from '@/object-record/record-table/constants/AggregateOperations';
|
||||||
|
import { COUNT_AGGREGATE_OPERATION_OPTIONS } from '@/object-record/record-table/record-table-footer/constants/countAggregateOperationOptions';
|
||||||
|
import { PERCENT_AGGREGATE_OPERATION_OPTIONS } from '@/object-record/record-table/record-table-footer/constants/percentAggregateOperationOption';
|
||||||
import isEmpty from 'lodash.isempty';
|
import isEmpty from 'lodash.isempty';
|
||||||
import { FieldMetadataType } from '~/generated-metadata/graphql';
|
import { FieldMetadataType } from '~/generated-metadata/graphql';
|
||||||
import { formatAmount } from '~/utils/format/formatAmount';
|
import { formatAmount } from '~/utils/format/formatAmount';
|
||||||
@ -47,10 +49,12 @@ export const computeAggregateValueAndLabel = ({
|
|||||||
|
|
||||||
let value;
|
let value;
|
||||||
|
|
||||||
if (aggregateOperation === AGGREGATE_OPERATIONS.count) {
|
if (COUNT_AGGREGATE_OPERATION_OPTIONS.includes(aggregateOperation)) {
|
||||||
value = aggregateValue;
|
value = aggregateValue;
|
||||||
} else if (!isDefined(aggregateValue)) {
|
} else if (!isDefined(aggregateValue)) {
|
||||||
value = '-';
|
value = '-';
|
||||||
|
} else if (PERCENT_AGGREGATE_OPERATION_OPTIONS.includes(aggregateOperation)) {
|
||||||
|
value = `${formatNumber(Number(aggregateValue) * 100)}%`;
|
||||||
} else {
|
} else {
|
||||||
value = Number(aggregateValue);
|
value = Number(aggregateValue);
|
||||||
|
|
||||||
|
|||||||
@ -11,7 +11,17 @@ export const getAggregateOperationLabel = (operation: AGGREGATE_OPERATIONS) => {
|
|||||||
case AGGREGATE_OPERATIONS.sum:
|
case AGGREGATE_OPERATIONS.sum:
|
||||||
return 'Sum';
|
return 'Sum';
|
||||||
case AGGREGATE_OPERATIONS.count:
|
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:
|
default:
|
||||||
throw new Error(`Unknown aggregate operation: ${operation}`);
|
throw new Error(`Unknown aggregate operation: ${operation}`);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
export type RecordBoardColumnHeaderAggregateContentId =
|
export type RecordBoardColumnHeaderAggregateContentId =
|
||||||
| 'aggregateOperations'
|
| 'aggregateOperations'
|
||||||
| 'aggregateFields'
|
| 'aggregateFields'
|
||||||
|
| 'countAggregateOperationsOptions'
|
||||||
| 'moreAggregateOperationOptions';
|
| 'moreAggregateOperationOptions';
|
||||||
|
|||||||
@ -4,4 +4,9 @@ export enum AGGREGATE_OPERATIONS {
|
|||||||
avg = 'AVG',
|
avg = 'AVG',
|
||||||
sum = 'SUM',
|
sum = 'SUM',
|
||||||
count = 'COUNT',
|
count = 'COUNT',
|
||||||
|
countEmpty = 'COUNT_EMPTY',
|
||||||
|
countNotEmpty = 'COUNT_NOT_EMPTY',
|
||||||
|
countUniqueValues = 'COUNT_UNIQUE_VALUES',
|
||||||
|
percentageEmpty = 'PERCENTAGE_EMPTY',
|
||||||
|
percentageNotEmpty = 'PERCENTAGE_NOT_EMPTY',
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -2,7 +2,6 @@ import { getAggregateOperationLabel } from '@/object-record/record-board/record-
|
|||||||
import { AGGREGATE_OPERATIONS } from '@/object-record/record-table/constants/AggregateOperations';
|
import { AGGREGATE_OPERATIONS } from '@/object-record/record-table/constants/AggregateOperations';
|
||||||
import { RecordTableColumnAggregateFooterDropdownContext } from '@/object-record/record-table/record-table-footer/components/RecordTableColumnAggregateFooterDropdownContext';
|
import { RecordTableColumnAggregateFooterDropdownContext } from '@/object-record/record-table/record-table-footer/components/RecordTableColumnAggregateFooterDropdownContext';
|
||||||
import { useViewFieldAggregateOperation } from '@/object-record/record-table/record-table-footer/hooks/useViewFieldAggregateOperation';
|
import { useViewFieldAggregateOperation } from '@/object-record/record-table/record-table-footer/hooks/useViewFieldAggregateOperation';
|
||||||
import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer';
|
|
||||||
import { useDropdown } from '@/ui/layout/dropdown/hooks/useDropdown';
|
import { useDropdown } from '@/ui/layout/dropdown/hooks/useDropdown';
|
||||||
import { ReactNode, useContext } from 'react';
|
import { ReactNode, useContext } from 'react';
|
||||||
import { IconCheck, isDefined, MenuItem } from 'twenty-ui';
|
import { IconCheck, isDefined, MenuItem } from 'twenty-ui';
|
||||||
@ -24,7 +23,7 @@ export const RecordTableColumnAggregateFooterAggregateOperationMenuItems = ({
|
|||||||
);
|
);
|
||||||
const { closeDropdown } = useDropdown(dropdownId);
|
const { closeDropdown } = useDropdown(dropdownId);
|
||||||
return (
|
return (
|
||||||
<DropdownMenuItemsContainer>
|
<>
|
||||||
{aggregateOperations.map((operation) => (
|
{aggregateOperations.map((operation) => (
|
||||||
<MenuItem
|
<MenuItem
|
||||||
key={operation}
|
key={operation}
|
||||||
@ -55,6 +54,6 @@ export const RecordTableColumnAggregateFooterAggregateOperationMenuItems = ({
|
|||||||
}
|
}
|
||||||
aria-selected={!isDefined(currentViewFieldAggregateOperation)}
|
aria-selected={!isDefined(currentViewFieldAggregateOperation)}
|
||||||
/>
|
/>
|
||||||
</DropdownMenuItemsContainer>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -1,16 +1,67 @@
|
|||||||
import { useDropdown } from '@/dropdown/hooks/useDropdown';
|
import { useDropdown } from '@/dropdown/hooks/useDropdown';
|
||||||
|
import { useRecordTableContextOrThrow } from '@/object-record/record-table/contexts/RecordTableContext';
|
||||||
|
import { RecordTableColumnAggregateFooterDropdownSubmenuContent } from '@/object-record/record-table/record-table-footer/components/RecordTableColumnAggregateDropdownSubmenuContent';
|
||||||
import { RecordTableColumnAggregateFooterDropdownContext } from '@/object-record/record-table/record-table-footer/components/RecordTableColumnAggregateFooterDropdownContext';
|
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 { 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 = () => {
|
export const RecordTableColumnAggregateFooterDropdownContent = () => {
|
||||||
const { currentContentId } = useDropdown({
|
const { currentContentId, fieldMetadataId } = useDropdown({
|
||||||
context: RecordTableColumnAggregateFooterDropdownContext,
|
context: RecordTableColumnAggregateFooterDropdownContext,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const { objectMetadataItem } = useRecordTableContextOrThrow();
|
||||||
|
|
||||||
|
const fieldMetadata = objectMetadataItem.fields.find(
|
||||||
|
(field) => field.id === fieldMetadataId,
|
||||||
|
);
|
||||||
|
|
||||||
|
const availableAggregateOperations =
|
||||||
|
getAvailableAggregateOperationsForFieldMetadataType({
|
||||||
|
fieldMetadataType: fieldMetadata?.type,
|
||||||
|
});
|
||||||
|
|
||||||
switch (currentContentId) {
|
switch (currentContentId) {
|
||||||
case 'moreAggregateOperationOptions':
|
case 'moreAggregateOperationOptions': {
|
||||||
return <RecordTableColumnAggregateFooterDropdownMoreOptionsContent />;
|
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:
|
default:
|
||||||
return <RecordTableColumnAggregateFooterMenuContent />;
|
return <RecordTableColumnAggregateFooterMenuContent />;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@ -1,5 +1,4 @@
|
|||||||
import { useRecordTableContextOrThrow } from '@/object-record/record-table/contexts/RecordTableContext';
|
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 { 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 { 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 { 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 { useContext, useMemo } from 'react';
|
||||||
import { Key } from 'ts-key-enum';
|
import { Key } from 'ts-key-enum';
|
||||||
import { MenuItem } from 'twenty-ui';
|
import { MenuItem } from 'twenty-ui';
|
||||||
|
import { FieldMetadataType } from '~/generated-metadata/graphql';
|
||||||
|
|
||||||
export const RecordTableColumnAggregateFooterMenuContent = () => {
|
export const RecordTableColumnAggregateFooterMenuContent = () => {
|
||||||
const { fieldMetadataId, dropdownId, onContentChange } = useContext(
|
const { fieldMetadataId, dropdownId, onContentChange } = useContext(
|
||||||
@ -36,32 +36,43 @@ export const RecordTableColumnAggregateFooterMenuContent = () => {
|
|||||||
[fieldMetadataId, objectMetadataItem.fields],
|
[fieldMetadataId, objectMetadataItem.fields],
|
||||||
);
|
);
|
||||||
|
|
||||||
const standardAvailableAggregateOperation =
|
|
||||||
availableAggregateOperation.filter((aggregateOperation) =>
|
|
||||||
STANDARD_AGGREGATE_OPERATION_OPTIONS.includes(aggregateOperation),
|
|
||||||
);
|
|
||||||
|
|
||||||
const otherAvailableAggregateOperation = availableAggregateOperation.filter(
|
const otherAvailableAggregateOperation = availableAggregateOperation.filter(
|
||||||
(aggregateOperation) =>
|
(aggregateOperation) =>
|
||||||
!STANDARD_AGGREGATE_OPERATION_OPTIONS.includes(aggregateOperation),
|
!STANDARD_AGGREGATE_OPERATION_OPTIONS.includes(aggregateOperation),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const fieldIsRelation =
|
||||||
|
objectMetadataItem.fields.find((field) => field.id === fieldMetadataId)
|
||||||
|
?.type === FieldMetadataType.Relation;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<DropdownMenuItemsContainer>
|
<DropdownMenuItemsContainer>
|
||||||
<RecordTableColumnAggregateFooterAggregateOperationMenuItems
|
<MenuItem
|
||||||
aggregateOperations={standardAvailableAggregateOperation}
|
onClick={() => {
|
||||||
>
|
onContentChange('countAggregateOperationsOptions');
|
||||||
{otherAvailableAggregateOperation.length > 0 ? (
|
}}
|
||||||
<MenuItem
|
text={'Count'}
|
||||||
key={'more-options'}
|
hasSubMenu
|
||||||
onClick={() => {
|
/>
|
||||||
onContentChange('moreAggregateOperationOptions');
|
{!fieldIsRelation && (
|
||||||
}}
|
<MenuItem
|
||||||
text={'More options'}
|
onClick={() => {
|
||||||
hasSubMenu
|
onContentChange('percentAggregateOperationsOptions');
|
||||||
/>
|
}}
|
||||||
) : null}
|
text={'Percent'}
|
||||||
</RecordTableColumnAggregateFooterAggregateOperationMenuItems>
|
hasSubMenu
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{otherAvailableAggregateOperation.length > 0 ? (
|
||||||
|
<MenuItem
|
||||||
|
onClick={() => {
|
||||||
|
onContentChange('moreAggregateOperationOptions');
|
||||||
|
}}
|
||||||
|
text={'More options'}
|
||||||
|
hasSubMenu
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
</DropdownMenuItemsContainer>
|
</DropdownMenuItemsContainer>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
import { useAggregateRecordsForRecordTableColumnFooter } from '@/object-record/record-table/record-table-footer/hooks/useAggregateRecordsForRecordTableColumnFooter';
|
import { useAggregateRecordsForRecordTableColumnFooter } from '@/object-record/record-table/record-table-footer/hooks/useAggregateRecordsForRecordTableColumnFooter';
|
||||||
import styled from '@emotion/styled';
|
import styled from '@emotion/styled';
|
||||||
import { isDefined } from 'twenty-ui';
|
import { isDefined, OverflowingTextWithTooltip } from 'twenty-ui';
|
||||||
|
|
||||||
const StyledText = styled.span`
|
const StyledText = styled.span`
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
@ -23,13 +23,7 @@ const StyledValueContainer = styled.div`
|
|||||||
gap: 4px;
|
gap: 4px;
|
||||||
height: 32px;
|
height: 32px;
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
padding: 8px;
|
padding: 0 8px;
|
||||||
`;
|
|
||||||
|
|
||||||
const StyledLabel = styled.div`
|
|
||||||
align-items: center;
|
|
||||||
display: flex;
|
|
||||||
gap: 4px;
|
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const StyledValue = styled.div`
|
const StyledValue = styled.div`
|
||||||
@ -57,7 +51,7 @@ export const RecordTableColumnAggregateFooterValue = ({
|
|||||||
<></>
|
<></>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<StyledLabel>{aggregateLabel}</StyledLabel>
|
<OverflowingTextWithTooltip text={aggregateLabel} />
|
||||||
<StyledValue>{aggregateValue}</StyledValue>
|
<StyledValue>{aggregateValue}</StyledValue>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -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,
|
||||||
|
];
|
||||||
@ -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,
|
||||||
|
];
|
||||||
@ -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 = [
|
export const STANDARD_AGGREGATE_OPERATION_OPTIONS = [
|
||||||
AGGREGATE_OPERATIONS.count,
|
...COUNT_AGGREGATE_OPERATION_OPTIONS,
|
||||||
|
...PERCENT_AGGREGATE_OPERATION_OPTIONS,
|
||||||
];
|
];
|
||||||
|
|||||||
@ -1,2 +1,4 @@
|
|||||||
export type RecordTableFooterAggregateContentId =
|
export type RecordTableFooterAggregateContentId =
|
||||||
'moreAggregateOperationOptions';
|
| 'moreAggregateOperationOptions'
|
||||||
|
| 'countAggregateOperationsOptions'
|
||||||
|
| 'percentAggregateOperationsOptions';
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
import { AGGREGATE_OPERATIONS } from '@/object-record/record-table/constants/AggregateOperations';
|
import { AGGREGATE_OPERATIONS } from '@/object-record/record-table/constants/AggregateOperations';
|
||||||
import { FIELDS_AVAILABLE_BY_AGGREGATE_OPERATION } from '@/object-record/record-table/constants/FieldsAvailableByAggregateOperation';
|
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 { isFieldTypeValidForAggregateOperation } from '@/object-record/utils/isFieldTypeValidForAggregateOperation';
|
||||||
import { FieldMetadataType } from '~/generated/graphql';
|
import { FieldMetadataType } from '~/generated/graphql';
|
||||||
import { isDefined } from '~/utils/isDefined';
|
import { isDefined } from '~/utils/isDefined';
|
||||||
@ -10,8 +10,17 @@ export const getAvailableAggregateOperationsForFieldMetadataType = ({
|
|||||||
}: {
|
}: {
|
||||||
fieldMetadataType?: FieldMetadataType;
|
fieldMetadataType?: FieldMetadataType;
|
||||||
}) => {
|
}) => {
|
||||||
|
if (fieldMetadataType === FieldMetadataType.Relation) {
|
||||||
|
return [AGGREGATE_OPERATIONS.count];
|
||||||
|
}
|
||||||
|
|
||||||
const availableAggregateOperations = new Set<AGGREGATE_OPERATIONS>([
|
const availableAggregateOperations = new Set<AGGREGATE_OPERATIONS>([
|
||||||
AGGREGATE_OPERATIONS.count,
|
AGGREGATE_OPERATIONS.count,
|
||||||
|
AGGREGATE_OPERATIONS.countEmpty,
|
||||||
|
AGGREGATE_OPERATIONS.countNotEmpty,
|
||||||
|
AGGREGATE_OPERATIONS.countUniqueValues,
|
||||||
|
AGGREGATE_OPERATIONS.percentageEmpty,
|
||||||
|
AGGREGATE_OPERATIONS.percentageNotEmpty,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if (!isDefined(fieldMetadataType)) {
|
if (!isDefined(fieldMetadataType)) {
|
||||||
@ -22,7 +31,7 @@ export const getAvailableAggregateOperationsForFieldMetadataType = ({
|
|||||||
.filter((operation) =>
|
.filter((operation) =>
|
||||||
isFieldTypeValidForAggregateOperation(
|
isFieldTypeValidForAggregateOperation(
|
||||||
fieldMetadataType,
|
fieldMetadataType,
|
||||||
operation as AggregateOperationsOmittingCount,
|
operation as AggregateOperationsOmittingStandardOperations,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
.forEach((operation) =>
|
.forEach((operation) =>
|
||||||
|
|||||||
@ -1,6 +0,0 @@
|
|||||||
import { AGGREGATE_OPERATIONS } from '@/object-record/record-table/constants/AggregateOperations';
|
|
||||||
|
|
||||||
export type AggregateOperationsOmittingCount = Exclude<
|
|
||||||
AGGREGATE_OPERATIONS,
|
|
||||||
AGGREGATE_OPERATIONS.count
|
|
||||||
>;
|
|
||||||
@ -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
|
||||||
|
>;
|
||||||
@ -1,5 +1,5 @@
|
|||||||
import { AggregateOperationsOmittingCount } from '@/object-record/types/AggregateOperationsOmittingCount';
|
import { AggregateOperationsOmittingStandardOperations } from '@/object-record/types/AggregateOperationsOmittingStandardOperations';
|
||||||
|
|
||||||
export type AvailableFieldsForAggregateOperation = {
|
export type AvailableFieldsForAggregateOperation = {
|
||||||
[T in AggregateOperationsOmittingCount]?: string[];
|
[T in AggregateOperationsOmittingStandardOperations]?: string[];
|
||||||
};
|
};
|
||||||
|
|||||||
@ -1,5 +1,7 @@
|
|||||||
import { FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
|
import { FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
|
||||||
import { AGGREGATE_OPERATIONS } from '@/object-record/record-table/constants/AggregateOperations';
|
import { AGGREGATE_OPERATIONS } from '@/object-record/record-table/constants/AggregateOperations';
|
||||||
|
import { 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 { getAvailableFieldsIdsForAggregationFromObjectFields } from '@/object-record/utils/getAvailableFieldsIdsForAggregationFromObjectFields';
|
||||||
import { FieldMetadataType } from '~/generated/graphql';
|
import { FieldMetadataType } from '~/generated/graphql';
|
||||||
|
|
||||||
@ -43,8 +45,10 @@ describe('getAvailableFieldsIdsForAggregationFromObjectFields', () => {
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
Object.values(AGGREGATE_OPERATIONS).forEach((operation) => {
|
Object.values(AGGREGATE_OPERATIONS).forEach((operation) => {
|
||||||
if (operation !== AGGREGATE_OPERATIONS.count) {
|
if (!STANDARD_AGGREGATE_OPERATION_OPTIONS.includes(operation)) {
|
||||||
expect(result[operation]).toEqual([]);
|
expect(
|
||||||
|
result[operation as AggregateOperationsOmittingStandardOperations],
|
||||||
|
).toEqual([]);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@ -53,8 +57,10 @@ describe('getAvailableFieldsIdsForAggregationFromObjectFields', () => {
|
|||||||
const result = getAvailableFieldsIdsForAggregationFromObjectFields([]);
|
const result = getAvailableFieldsIdsForAggregationFromObjectFields([]);
|
||||||
|
|
||||||
Object.values(AGGREGATE_OPERATIONS).forEach((operation) => {
|
Object.values(AGGREGATE_OPERATIONS).forEach((operation) => {
|
||||||
if (operation !== AGGREGATE_OPERATIONS.count) {
|
if (!STANDARD_AGGREGATE_OPERATION_OPTIONS.includes(operation)) {
|
||||||
expect(result[operation]).toEqual([]);
|
expect(
|
||||||
|
result[operation as AggregateOperationsOmittingStandardOperations],
|
||||||
|
).toEqual([]);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
import { AGGREGATE_OPERATIONS } from '@/object-record/record-table/constants/AggregateOperations';
|
import { AGGREGATE_OPERATIONS } from '@/object-record/record-table/constants/AggregateOperations';
|
||||||
import { FIELDS_AVAILABLE_BY_AGGREGATE_OPERATION } from '@/object-record/record-table/constants/FieldsAvailableByAggregateOperation';
|
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';
|
import { initializeAvailableFieldsForAggregateOperationMap } from '@/object-record/utils/initializeAvailableFieldsForAggregateOperationMap';
|
||||||
|
|
||||||
describe('initializeAvailableFieldsForAggregateOperationMap', () => {
|
describe('initializeAvailableFieldsForAggregateOperationMap', () => {
|
||||||
@ -18,7 +18,9 @@ describe('initializeAvailableFieldsForAggregateOperationMap', () => {
|
|||||||
it('should not include count operation', () => {
|
it('should not include count operation', () => {
|
||||||
const result = initializeAvailableFieldsForAggregateOperationMap();
|
const result = initializeAvailableFieldsForAggregateOperationMap();
|
||||||
expect(
|
expect(
|
||||||
result[AGGREGATE_OPERATIONS.count as AggregateOperationsOmittingCount],
|
result[
|
||||||
|
AGGREGATE_OPERATIONS.count as AggregateOperationsOmittingStandardOperations
|
||||||
|
],
|
||||||
).toBeUndefined();
|
).toBeUndefined();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import { AGGREGATE_OPERATIONS } from '@/object-record/record-table/constants/AggregateOperations';
|
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 { isFieldTypeValidForAggregateOperation } from '@/object-record/utils/isFieldTypeValidForAggregateOperation';
|
||||||
import { FieldMetadataType } from '~/generated/graphql';
|
import { FieldMetadataType } from '~/generated/graphql';
|
||||||
|
|
||||||
@ -49,7 +49,7 @@ describe('isFieldTypeValidForAggregateOperation', () => {
|
|||||||
expect(
|
expect(
|
||||||
isFieldTypeValidForAggregateOperation(
|
isFieldTypeValidForAggregateOperation(
|
||||||
numericField,
|
numericField,
|
||||||
operation as AggregateOperationsOmittingCount,
|
operation as AggregateOperationsOmittingStandardOperations,
|
||||||
),
|
),
|
||||||
).toBe(true);
|
).toBe(true);
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
import { FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
|
import { FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
|
||||||
import { AGGREGATE_OPERATIONS } from '@/object-record/record-table/constants/AggregateOperations';
|
import { AGGREGATE_OPERATIONS } from '@/object-record/record-table/constants/AggregateOperations';
|
||||||
|
import { getColumnNameForAggregateOperation } from 'twenty-shared';
|
||||||
import { FieldMetadataType } from '~/generated-metadata/graphql';
|
import { FieldMetadataType } from '~/generated-metadata/graphql';
|
||||||
import { capitalize } from '~/utils/string/capitalize';
|
import { capitalize } from '~/utils/string/capitalize';
|
||||||
|
|
||||||
@ -15,8 +16,30 @@ export const getAvailableAggregationsFromObjectFields = (
|
|||||||
fields: FieldMetadataItem[],
|
fields: FieldMetadataItem[],
|
||||||
): Aggregations => {
|
): Aggregations => {
|
||||||
return fields.reduce<Record<string, NameForAggregation>>((acc, field) => {
|
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) {
|
if (field.type === FieldMetadataType.DateTime) {
|
||||||
acc[field.name] = {
|
acc[field.name] = {
|
||||||
|
...acc[field.name],
|
||||||
[AGGREGATE_OPERATIONS.min]: `min${capitalize(field.name)}`,
|
[AGGREGATE_OPERATIONS.min]: `min${capitalize(field.name)}`,
|
||||||
[AGGREGATE_OPERATIONS.max]: `max${capitalize(field.name)}`,
|
[AGGREGATE_OPERATIONS.max]: `max${capitalize(field.name)}`,
|
||||||
};
|
};
|
||||||
@ -24,6 +47,7 @@ export const getAvailableAggregationsFromObjectFields = (
|
|||||||
|
|
||||||
if (field.type === FieldMetadataType.Number) {
|
if (field.type === FieldMetadataType.Number) {
|
||||||
acc[field.name] = {
|
acc[field.name] = {
|
||||||
|
...acc[field.name],
|
||||||
[AGGREGATE_OPERATIONS.min]: `min${capitalize(field.name)}`,
|
[AGGREGATE_OPERATIONS.min]: `min${capitalize(field.name)}`,
|
||||||
[AGGREGATE_OPERATIONS.max]: `max${capitalize(field.name)}`,
|
[AGGREGATE_OPERATIONS.max]: `max${capitalize(field.name)}`,
|
||||||
[AGGREGATE_OPERATIONS.avg]: `avg${capitalize(field.name)}`,
|
[AGGREGATE_OPERATIONS.avg]: `avg${capitalize(field.name)}`,
|
||||||
@ -33,6 +57,7 @@ export const getAvailableAggregationsFromObjectFields = (
|
|||||||
|
|
||||||
if (field.type === FieldMetadataType.Currency) {
|
if (field.type === FieldMetadataType.Currency) {
|
||||||
acc[field.name] = {
|
acc[field.name] = {
|
||||||
|
...acc[field.name],
|
||||||
[AGGREGATE_OPERATIONS.min]: `min${capitalize(field.name)}AmountMicros`,
|
[AGGREGATE_OPERATIONS.min]: `min${capitalize(field.name)}AmountMicros`,
|
||||||
[AGGREGATE_OPERATIONS.max]: `max${capitalize(field.name)}AmountMicros`,
|
[AGGREGATE_OPERATIONS.max]: `max${capitalize(field.name)}AmountMicros`,
|
||||||
[AGGREGATE_OPERATIONS.avg]: `avg${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] = {};
|
||||||
}
|
}
|
||||||
|
|
||||||
acc[field.name][AGGREGATE_OPERATIONS.count] = 'totalCount';
|
|
||||||
|
|
||||||
return acc;
|
return acc;
|
||||||
}, {});
|
}, {});
|
||||||
};
|
};
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
import { FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
|
import { FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
|
||||||
import { FIELDS_AVAILABLE_BY_AGGREGATE_OPERATION } from '@/object-record/record-table/constants/FieldsAvailableByAggregateOperation';
|
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 { AvailableFieldsForAggregateOperation } from '@/object-record/types/AvailableFieldsForAggregateOperation';
|
||||||
import { initializeAvailableFieldsForAggregateOperationMap } from '@/object-record/utils/initializeAvailableFieldsForAggregateOperationMap';
|
import { initializeAvailableFieldsForAggregateOperationMap } from '@/object-record/utils/initializeAvailableFieldsForAggregateOperationMap';
|
||||||
import { isFieldTypeValidForAggregateOperation } from '@/object-record/utils/isFieldTypeValidForAggregateOperation';
|
import { isFieldTypeValidForAggregateOperation } from '@/object-record/utils/isFieldTypeValidForAggregateOperation';
|
||||||
@ -14,7 +14,7 @@ export const getAvailableFieldsIdsForAggregationFromObjectFields = (
|
|||||||
Object.keys(FIELDS_AVAILABLE_BY_AGGREGATE_OPERATION).forEach(
|
Object.keys(FIELDS_AVAILABLE_BY_AGGREGATE_OPERATION).forEach(
|
||||||
(aggregateOperation) => {
|
(aggregateOperation) => {
|
||||||
const typedAggregateOperation =
|
const typedAggregateOperation =
|
||||||
aggregateOperation as AggregateOperationsOmittingCount;
|
aggregateOperation as AggregateOperationsOmittingStandardOperations;
|
||||||
|
|
||||||
if (
|
if (
|
||||||
isFieldTypeValidForAggregateOperation(
|
isFieldTypeValidForAggregateOperation(
|
||||||
|
|||||||
@ -1,10 +1,10 @@
|
|||||||
import { FIELDS_AVAILABLE_BY_AGGREGATE_OPERATION } from '@/object-record/record-table/constants/FieldsAvailableByAggregateOperation';
|
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';
|
import { FieldMetadataType } from '~/generated-metadata/graphql';
|
||||||
|
|
||||||
export const isFieldTypeValidForAggregateOperation = (
|
export const isFieldTypeValidForAggregateOperation = (
|
||||||
fieldType: FieldMetadataType,
|
fieldType: FieldMetadataType,
|
||||||
aggregateOperation: AggregateOperationsOmittingCount,
|
aggregateOperation: AggregateOperationsOmittingStandardOperations,
|
||||||
): boolean => {
|
): boolean => {
|
||||||
return FIELDS_AVAILABLE_BY_AGGREGATE_OPERATION[aggregateOperation].includes(
|
return FIELDS_AVAILABLE_BY_AGGREGATE_OPERATION[aggregateOperation].includes(
|
||||||
fieldType,
|
fieldType,
|
||||||
|
|||||||
@ -78,7 +78,7 @@ export const seedFeatureFlags = async (
|
|||||||
{
|
{
|
||||||
key: FeatureFlagKey.IsAggregateQueryEnabled,
|
key: FeatureFlagKey.IsAggregateQueryEnabled,
|
||||||
workspaceId: workspaceId,
|
workspaceId: workspaceId,
|
||||||
value: false,
|
value: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: FeatureFlagKey.IsPageHeaderV2Enabled,
|
key: FeatureFlagKey.IsPageHeaderV2Enabled,
|
||||||
|
|||||||
@ -4,4 +4,9 @@ export enum AGGREGATE_OPERATIONS {
|
|||||||
avg = 'AVG',
|
avg = 'AVG',
|
||||||
sum = 'SUM',
|
sum = 'SUM',
|
||||||
count = 'COUNT',
|
count = 'COUNT',
|
||||||
|
countUniqueValues = 'COUNT_UNIQUE_VALUES',
|
||||||
|
countEmpty = 'COUNT_EMPTY',
|
||||||
|
countNotEmpty = 'COUNT_NOT_EMPTY',
|
||||||
|
percentageEmpty = 'PERCENTAGE_EMPTY',
|
||||||
|
percentageNotEmpty = 'PERCENTAGE_NOT_EMPTY',
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,6 +2,7 @@ import { SelectQueryBuilder } from 'typeorm';
|
|||||||
|
|
||||||
import { AGGREGATE_OPERATIONS } from 'src/engine/api/graphql/graphql-query-runner/constants/aggregate-operations.constant';
|
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 { 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 { formatColumnNameFromCompositeFieldAndSubfield } from 'src/engine/twenty-orm/utils/format-column-name-from-composite-field-and-subfield.util';
|
||||||
import { isDefined } from 'src/utils/is-defined';
|
import { isDefined } from 'src/utils/is-defined';
|
||||||
|
|
||||||
@ -38,10 +39,50 @@ export class ProcessAggregateHelper {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
queryBuilder.addSelect(
|
const columnEmptyValueExpression =
|
||||||
`${aggregatedField.aggregateOperation}("${columnName}")`,
|
FIELD_METADATA_TYPES_TO_TEXT_COLUMN_TYPE.includes(
|
||||||
`${aggregatedFieldName}`,
|
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}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,6 +1,10 @@
|
|||||||
import { GraphQLISODateTime } from '@nestjs/graphql';
|
import { GraphQLISODateTime } from '@nestjs/graphql';
|
||||||
|
|
||||||
import { GraphQLFloat, GraphQLInt, GraphQLScalarType } from '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';
|
import { FieldMetadataInterface } from 'src/engine/metadata-modules/field-metadata/interfaces/field-metadata.interface';
|
||||||
|
|
||||||
@ -12,6 +16,7 @@ export type AggregationField = {
|
|||||||
type: GraphQLScalarType;
|
type: GraphQLScalarType;
|
||||||
description: string;
|
description: string;
|
||||||
fromField: string;
|
fromField: string;
|
||||||
|
fromFieldType: FieldMetadataType;
|
||||||
fromSubField?: string;
|
fromSubField?: string;
|
||||||
aggregateOperation: AGGREGATE_OPERATIONS;
|
aggregateOperation: AGGREGATE_OPERATIONS;
|
||||||
};
|
};
|
||||||
@ -19,94 +24,164 @@ export type AggregationField = {
|
|||||||
export const getAvailableAggregationsFromObjectFields = (
|
export const getAvailableAggregationsFromObjectFields = (
|
||||||
fields: FieldMetadataInterface[],
|
fields: FieldMetadataInterface[],
|
||||||
): Record<string, AggregationField> => {
|
): Record<string, AggregationField> => {
|
||||||
return fields.reduce<Record<string, AggregationField>>((acc, field) => {
|
return fields.reduce<Record<string, AggregationField>>(
|
||||||
acc['totalCount'] = {
|
(acc, field) => {
|
||||||
type: GraphQLInt,
|
if (field.type === FieldMetadataType.RELATION) {
|
||||||
description: `Total number of records in the connection`,
|
return acc;
|
||||||
fromField: 'id',
|
}
|
||||||
aggregateOperation: AGGREGATE_OPERATIONS.count,
|
|
||||||
};
|
|
||||||
|
|
||||||
if (field.type === FieldMetadataType.DATE_TIME) {
|
const columnName = getColumnNameForAggregateOperation(
|
||||||
acc[`min${capitalize(field.name)}`] = {
|
field.name,
|
||||||
type: GraphQLISODateTime,
|
field.type,
|
||||||
description: `Oldest date contained in the field ${field.name}`,
|
);
|
||||||
|
|
||||||
|
const fromSubField = getSubfieldForAggregateOperation(field.type);
|
||||||
|
|
||||||
|
acc[`countUniqueValues${capitalize(columnName)}`] = {
|
||||||
|
type: GraphQLInt,
|
||||||
|
description: `Number of unique values for ${field.name}`,
|
||||||
fromField: field.name,
|
fromField: field.name,
|
||||||
aggregateOperation: AGGREGATE_OPERATIONS.min,
|
fromFieldType: field.type,
|
||||||
|
fromSubField,
|
||||||
|
aggregateOperation: AGGREGATE_OPERATIONS.countUniqueValues,
|
||||||
};
|
};
|
||||||
|
|
||||||
acc[`max${capitalize(field.name)}`] = {
|
acc[`countEmpty${capitalize(columnName)}`] = {
|
||||||
type: GraphQLISODateTime,
|
type: GraphQLInt,
|
||||||
description: `Most recent date contained in the field ${field.name}`,
|
description: `Number of empty values for ${field.name}`,
|
||||||
fromField: field.name,
|
fromField: field.name,
|
||||||
aggregateOperation: AGGREGATE_OPERATIONS.max,
|
fromFieldType: field.type,
|
||||||
|
fromSubField,
|
||||||
|
aggregateOperation: AGGREGATE_OPERATIONS.countEmpty,
|
||||||
};
|
};
|
||||||
}
|
|
||||||
|
|
||||||
if (field.type === FieldMetadataType.NUMBER) {
|
acc[`countNotEmpty${capitalize(columnName)}`] = {
|
||||||
acc[`min${capitalize(field.name)}`] = {
|
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,
|
type: GraphQLFloat,
|
||||||
description: `Minimum amount contained in the field ${field.name}`,
|
description: `Percentage of empty values for ${field.name}`,
|
||||||
fromField: 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,
|
type: GraphQLFloat,
|
||||||
description: `Maximum amount contained in the field ${field.name}`,
|
description: `Percentage of non-empty values for ${field.name}`,
|
||||||
fromField: field.name,
|
fromField: field.name,
|
||||||
aggregateOperation: AGGREGATE_OPERATIONS.max,
|
fromFieldType: field.type,
|
||||||
|
fromSubField,
|
||||||
|
aggregateOperation: AGGREGATE_OPERATIONS.percentageNotEmpty,
|
||||||
};
|
};
|
||||||
|
|
||||||
acc[`avg${capitalize(field.name)}`] = {
|
switch (field.type) {
|
||||||
type: GraphQLFloat,
|
case FieldMetadataType.DATE_TIME:
|
||||||
description: `Average amount contained in the field ${field.name}`,
|
acc[`min${capitalize(field.name)}`] = {
|
||||||
fromField: field.name,
|
type: GraphQLISODateTime,
|
||||||
aggregateOperation: AGGREGATE_OPERATIONS.avg,
|
description: `Oldest date contained in the field ${field.name}`,
|
||||||
};
|
fromField: field.name,
|
||||||
|
fromFieldType: field.type,
|
||||||
|
aggregateOperation: AGGREGATE_OPERATIONS.min,
|
||||||
|
};
|
||||||
|
|
||||||
acc[`sum${capitalize(field.name)}`] = {
|
acc[`max${capitalize(field.name)}`] = {
|
||||||
type: GraphQLFloat,
|
type: GraphQLISODateTime,
|
||||||
description: `Sum of amounts contained in the field ${field.name}`,
|
description: `Most recent date contained in the field ${field.name}`,
|
||||||
fromField: field.name,
|
fromField: field.name,
|
||||||
aggregateOperation: AGGREGATE_OPERATIONS.sum,
|
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[`max${capitalize(field.name)}`] = {
|
||||||
acc[`min${capitalize(field.name)}AmountMicros`] = {
|
type: GraphQLFloat,
|
||||||
type: GraphQLFloat,
|
description: `Maximum amount contained in the field ${field.name}`,
|
||||||
description: `Minimum amount contained in the field ${field.name}`,
|
fromField: field.name,
|
||||||
fromField: field.name,
|
fromFieldType: field.type,
|
||||||
fromSubField: 'amountMicros',
|
aggregateOperation: AGGREGATE_OPERATIONS.max,
|
||||||
aggregateOperation: AGGREGATE_OPERATIONS.min,
|
};
|
||||||
};
|
|
||||||
|
|
||||||
acc[`max${capitalize(field.name)}AmountMicros`] = {
|
acc[`avg${capitalize(field.name)}`] = {
|
||||||
type: GraphQLFloat,
|
type: GraphQLFloat,
|
||||||
description: `Maximal amount contained in the field ${field.name}`,
|
description: `Average amount contained in the field ${field.name}`,
|
||||||
fromField: field.name,
|
fromField: field.name,
|
||||||
fromSubField: 'amountMicros',
|
fromFieldType: field.type,
|
||||||
aggregateOperation: AGGREGATE_OPERATIONS.max,
|
aggregateOperation: AGGREGATE_OPERATIONS.avg,
|
||||||
};
|
};
|
||||||
|
|
||||||
acc[`sum${capitalize(field.name)}AmountMicros`] = {
|
acc[`sum${capitalize(field.name)}`] = {
|
||||||
type: GraphQLFloat,
|
type: GraphQLFloat,
|
||||||
description: `Sum of amounts contained in the field ${field.name}`,
|
description: `Sum of amounts contained in the field ${field.name}`,
|
||||||
fromField: field.name,
|
fromField: field.name,
|
||||||
fromSubField: 'amountMicros',
|
fromFieldType: field.type,
|
||||||
aggregateOperation: AGGREGATE_OPERATIONS.sum,
|
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`] = {
|
acc[`max${capitalize(field.name)}AmountMicros`] = {
|
||||||
type: GraphQLFloat,
|
type: GraphQLFloat,
|
||||||
description: `Average amount contained in the field ${field.name}`,
|
description: `Maximal amount contained in the field ${field.name}`,
|
||||||
fromField: field.name,
|
fromField: field.name,
|
||||||
fromSubField: 'amountMicros',
|
fromSubField: 'amountMicros',
|
||||||
aggregateOperation: AGGREGATE_OPERATIONS.avg,
|
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,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -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,
|
||||||
|
];
|
||||||
@ -1,4 +1,5 @@
|
|||||||
import { FieldMetadataType } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity';
|
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 {
|
import {
|
||||||
WorkspaceMigrationException,
|
WorkspaceMigrationException,
|
||||||
WorkspaceMigrationExceptionCode,
|
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.
|
* Composite types are not implemented here, as they are flattened by their composite definitions.
|
||||||
* See src/metadata/field-metadata/composite-types for more information.
|
* See src/metadata/field-metadata/composite-types for more information.
|
||||||
*/
|
*/
|
||||||
|
if (isTextColumnType(fieldMetadataType)) {
|
||||||
|
return 'text';
|
||||||
|
}
|
||||||
switch (fieldMetadataType) {
|
switch (fieldMetadataType) {
|
||||||
case FieldMetadataType.UUID:
|
case FieldMetadataType.UUID:
|
||||||
return 'uuid';
|
return 'uuid';
|
||||||
case FieldMetadataType.TEXT:
|
|
||||||
case FieldMetadataType.RICH_TEXT:
|
|
||||||
case FieldMetadataType.ARRAY:
|
|
||||||
return 'text';
|
|
||||||
case FieldMetadataType.NUMERIC:
|
case FieldMetadataType.NUMERIC:
|
||||||
return 'numeric';
|
return 'numeric';
|
||||||
case FieldMetadataType.NUMBER:
|
case FieldMetadataType.NUMBER:
|
||||||
|
|||||||
@ -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
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -126,6 +126,36 @@ export class ViewFieldWorkspaceEntity extends BaseWorkspaceEntity {
|
|||||||
position: 4,
|
position: 4,
|
||||||
color: 'yellow',
|
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,
|
defaultValue: null,
|
||||||
})
|
})
|
||||||
|
|||||||
@ -218,6 +218,36 @@ export class ViewWorkspaceEntity extends BaseWorkspaceEntity {
|
|||||||
position: 4,
|
position: 4,
|
||||||
color: 'yellow',
|
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}'`,
|
defaultValue: `'${AGGREGATE_OPERATIONS.count}'`,
|
||||||
})
|
})
|
||||||
|
|||||||
@ -1,3 +1,5 @@
|
|||||||
export * from './constants/TwentyCompaniesBaseUrl';
|
export * from './constants/TwentyCompaniesBaseUrl';
|
||||||
export * from './constants/TwentyIconsBaseUrl';
|
export * from './constants/TwentyIconsBaseUrl';
|
||||||
|
export * from './utils/aggregateOperations';
|
||||||
export * from './utils/image/getImageAbsoluteURI';
|
export * from './utils/image/getImageAbsoluteURI';
|
||||||
|
|
||||||
|
|||||||
25
packages/twenty-shared/src/types/FieldMetadataType.ts
Normal file
25
packages/twenty-shared/src/types/FieldMetadataType.ts
Normal 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',
|
||||||
|
}
|
||||||
@ -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)}`;
|
||||||
|
};
|
||||||
@ -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}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
@ -0,0 +1,3 @@
|
|||||||
|
export * from './getColumnNameForAggregateOperation.util';
|
||||||
|
export * from './getSubFieldForAggregateOperation.util';
|
||||||
|
|
||||||
@ -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);
|
||||||
|
};
|
||||||
7
packages/twenty-shared/src/utils/strings/capitalize.ts
Normal file
7
packages/twenty-shared/src/utils/strings/capitalize.ts
Normal 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);
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user