Marie
2025-01-06 17:57:32 +01:00
committed by GitHub
parent b22a598d7d
commit a9b95bcf03
30 changed files with 503 additions and 328 deletions

View File

@ -3,15 +3,23 @@ import { useCallback, useState } from 'react';
export const useCurrentContentId = <T>() => { export const useCurrentContentId = <T>() => {
const [currentContentId, setCurrentContentId] = useState<T | null>(null); const [currentContentId, setCurrentContentId] = useState<T | null>(null);
const handleContentChange = useCallback((key: T) => { const [previousContentId, setPreviousContentId] = useState<T | null>(null);
setCurrentContentId(key);
}, []); const handleContentChange = useCallback(
(key: T) => {
setPreviousContentId(currentContentId);
setCurrentContentId(key);
},
[currentContentId],
);
const handleResetContent = useCallback(() => { const handleResetContent = useCallback(() => {
setPreviousContentId(null);
setCurrentContentId(null); setCurrentContentId(null);
}, []); }, []);
return { return {
previousContentId,
currentContentId, currentContentId,
handleContentChange, handleContentChange,
handleResetContent, handleResetContent,

View File

@ -5,14 +5,10 @@ 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',
@ -69,7 +65,6 @@ 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',
@ -91,7 +86,6 @@ 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',

View File

@ -23,8 +23,12 @@ export const RecordBoardColumnHeaderAggregateDropdown = ({
aggregateLabel, aggregateLabel,
dropdownId, dropdownId,
}: RecordBoardColumnHeaderAggregateDropdownProps) => { }: RecordBoardColumnHeaderAggregateDropdownProps) => {
const { currentContentId, handleContentChange, handleResetContent } = const {
useCurrentContentId<RecordBoardColumnHeaderAggregateContentId>(); currentContentId,
handleContentChange,
handleResetContent,
previousContentId,
} = useCurrentContentId<RecordBoardColumnHeaderAggregateContentId>();
return ( return (
<RecordBoardColumnHeaderAggregateDropdownComponentInstanceContext.Provider <RecordBoardColumnHeaderAggregateDropdownComponentInstanceContext.Provider
@ -51,6 +55,7 @@ export const RecordBoardColumnHeaderAggregateDropdown = ({
currentContentId, currentContentId,
onContentChange: handleContentChange, onContentChange: handleContentChange,
resetContent: handleResetContent, resetContent: handleResetContent,
previousContentId,
objectMetadataItem: objectMetadataItem, objectMetadataItem: objectMetadataItem,
dropdownId: dropdownId, dropdownId: dropdownId,
}} }}

View File

@ -2,16 +2,58 @@ import { useDropdown } from '@/dropdown/hooks/useDropdown';
import { RecordBoardColumnHeaderAggregateDropdownContext } from '@/object-record/record-board/record-board-column/components/RecordBoardColumnHeaderAggregateDropdownContext'; import { RecordBoardColumnHeaderAggregateDropdownContext } from '@/object-record/record-board/record-board-column/components/RecordBoardColumnHeaderAggregateDropdownContext';
import { RecordBoardColumnHeaderAggregateDropdownFieldsContent } from '@/object-record/record-board/record-board-column/components/RecordBoardColumnHeaderAggregateDropdownFieldsContent'; import { RecordBoardColumnHeaderAggregateDropdownFieldsContent } from '@/object-record/record-board/record-board-column/components/RecordBoardColumnHeaderAggregateDropdownFieldsContent';
import { RecordBoardColumnHeaderAggregateDropdownMenuContent } from '@/object-record/record-board/record-board-column/components/RecordBoardColumnHeaderAggregateDropdownMenuContent'; import { RecordBoardColumnHeaderAggregateDropdownMenuContent } from '@/object-record/record-board/record-board-column/components/RecordBoardColumnHeaderAggregateDropdownMenuContent';
import { RecordBoardColumnHeaderAggregateDropdownMoreOptionsContent } from '@/object-record/record-board/record-board-column/components/RecordBoardColumnHeaderAggregateDropdownMoreOptionsContent'; import { RecordBoardColumnHeaderAggregateDropdownOptionsContent } from '@/object-record/record-board/record-board-column/components/RecordBoardColumnHeaderAggregateDropdownOptionsContent';
import { COUNT_AGGREGATE_OPERATION_OPTIONS } from '@/object-record/record-table/record-table-footer/constants/countAggregateOperationOptions';
import { NON_STANDARD_AGGREGATE_OPERATION_OPTIONS } from '@/object-record/record-table/record-table-footer/constants/nonStandardAggregateOperationsOptions';
import { PERCENT_AGGREGATE_OPERATION_OPTIONS } from '@/object-record/record-table/record-table-footer/constants/percentAggregateOperationOption';
import { AvailableFieldsForAggregateOperation } from '@/object-record/types/AvailableFieldsForAggregateOperation';
import { getAvailableFieldsIdsForAggregationFromObjectFields } from '@/object-record/utils/getAvailableFieldsIdsForAggregationFromObjectFields';
export const AggregateDropdownContent = () => { export const AggregateDropdownContent = () => {
const { currentContentId } = useDropdown({ const { currentContentId, objectMetadataItem } = useDropdown({
context: RecordBoardColumnHeaderAggregateDropdownContext, context: RecordBoardColumnHeaderAggregateDropdownContext,
}); });
switch (currentContentId) { switch (currentContentId) {
case 'moreAggregateOperationOptions': case 'countAggregateOperationsOptions': {
return <RecordBoardColumnHeaderAggregateDropdownMoreOptionsContent />; const availableAggregations: AvailableFieldsForAggregateOperation =
getAvailableFieldsIdsForAggregationFromObjectFields(
objectMetadataItem.fields,
COUNT_AGGREGATE_OPERATION_OPTIONS,
);
return (
<RecordBoardColumnHeaderAggregateDropdownOptionsContent
availableAggregations={availableAggregations}
title="Count"
/>
);
}
case 'percentAggregateOperationsOptions': {
const availableAggregations: AvailableFieldsForAggregateOperation =
getAvailableFieldsIdsForAggregationFromObjectFields(
objectMetadataItem.fields,
PERCENT_AGGREGATE_OPERATION_OPTIONS,
);
return (
<RecordBoardColumnHeaderAggregateDropdownOptionsContent
availableAggregations={availableAggregations}
title="Percent"
/>
);
}
case 'moreAggregateOperationOptions': {
const availableAggregations: AvailableFieldsForAggregateOperation =
getAvailableFieldsIdsForAggregationFromObjectFields(
objectMetadataItem.fields,
NON_STANDARD_AGGREGATE_OPERATION_OPTIONS,
);
return (
<RecordBoardColumnHeaderAggregateDropdownOptionsContent
availableAggregations={availableAggregations}
title="More options"
/>
);
}
case 'aggregateFields': case 'aggregateFields':
return <RecordBoardColumnHeaderAggregateDropdownFieldsContent />; return <RecordBoardColumnHeaderAggregateDropdownFieldsContent />;
default: default:

View File

@ -7,6 +7,7 @@ export type RecordBoardColumnHeaderAggregateDropdownContextValue = {
currentContentId: RecordBoardColumnHeaderAggregateContentId | null; currentContentId: RecordBoardColumnHeaderAggregateContentId | null;
onContentChange: (key: RecordBoardColumnHeaderAggregateContentId) => void; onContentChange: (key: RecordBoardColumnHeaderAggregateContentId) => void;
resetContent: () => void; resetContent: () => void;
previousContentId: RecordBoardColumnHeaderAggregateContentId | null;
dropdownId: string; dropdownId: string;
}; };

View File

@ -19,7 +19,13 @@ import {
import { isDefined } from '~/utils/isDefined'; import { isDefined } from '~/utils/isDefined';
export const RecordBoardColumnHeaderAggregateDropdownFieldsContent = () => { export const RecordBoardColumnHeaderAggregateDropdownFieldsContent = () => {
const { closeDropdown, objectMetadataItem, onContentChange } = useDropdown({ const {
closeDropdown,
objectMetadataItem,
onContentChange,
resetContent,
previousContentId,
} = useDropdown({
context: RecordBoardColumnHeaderAggregateDropdownContext, context: RecordBoardColumnHeaderAggregateDropdownContext,
}); });
@ -45,7 +51,11 @@ export const RecordBoardColumnHeaderAggregateDropdownFieldsContent = () => {
<> <>
<DropdownMenuHeader <DropdownMenuHeader
StartIcon={IconChevronLeft} StartIcon={IconChevronLeft}
onClick={() => onContentChange('moreAggregateOperationOptions')} onClick={() =>
previousContentId
? onContentChange(previousContentId)
: resetContent()
}
> >
{getAggregateOperationLabel(aggregateOperation)} {getAggregateOperationLabel(aggregateOperation)}
</DropdownMenuHeader> </DropdownMenuHeader>

View File

@ -1,5 +1,5 @@
import { Key } from 'ts-key-enum'; import { Key } from 'ts-key-enum';
import { IconCheck, MenuItem } from 'twenty-ui'; import { MenuItem } from 'twenty-ui';
import { useDropdown } from '@/dropdown/hooks/useDropdown'; import { useDropdown } from '@/dropdown/hooks/useDropdown';
import { import {
@ -7,15 +7,9 @@ import {
RecordBoardColumnHeaderAggregateDropdownContextValue, RecordBoardColumnHeaderAggregateDropdownContextValue,
} from '@/object-record/record-board/record-board-column/components/RecordBoardColumnHeaderAggregateDropdownContext'; } from '@/object-record/record-board/record-board-column/components/RecordBoardColumnHeaderAggregateDropdownContext';
import { getAggregateOperationLabel } from '@/object-record/record-board/record-board-column/utils/getAggregateOperationLabel';
import { recordIndexKanbanAggregateOperationState } from '@/object-record/record-index/states/recordIndexKanbanAggregateOperationState';
import { AGGREGATE_OPERATIONS } from '@/object-record/record-table/constants/AggregateOperations';
import { TableOptionsHotkeyScope } from '@/object-record/record-table/types/TableOptionsHotkeyScope'; import { TableOptionsHotkeyScope } from '@/object-record/record-table/types/TableOptionsHotkeyScope';
import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer'; import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer';
import { useScopedHotkeys } from '@/ui/utilities/hotkey/hooks/useScopedHotkeys'; import { useScopedHotkeys } from '@/ui/utilities/hotkey/hooks/useScopedHotkeys';
import { useUpdateViewAggregate } from '@/views/hooks/useUpdateViewAggregate';
import { useRecoilValue } from 'recoil';
import { isDefined } from '~/utils/isDefined';
export const RecordBoardColumnHeaderAggregateDropdownMenuContent = () => { export const RecordBoardColumnHeaderAggregateDropdownMenuContent = () => {
const { onContentChange, closeDropdown } = const { onContentChange, closeDropdown } =
@ -31,31 +25,22 @@ export const RecordBoardColumnHeaderAggregateDropdownMenuContent = () => {
TableOptionsHotkeyScope.Dropdown, TableOptionsHotkeyScope.Dropdown,
); );
const { updateViewAggregate } = useUpdateViewAggregate();
const recordIndexKanbanAggregateOperation = useRecoilValue(
recordIndexKanbanAggregateOperationState,
);
return ( return (
<> <>
<DropdownMenuItemsContainer> <DropdownMenuItemsContainer>
<MenuItem <MenuItem
onClick={() => { onClick={() => {
updateViewAggregate({ onContentChange('countAggregateOperationsOptions');
kanbanAggregateOperationFieldMetadataId: null,
kanbanAggregateOperation: AGGREGATE_OPERATIONS.count,
});
closeDropdown();
}} }}
text={getAggregateOperationLabel(AGGREGATE_OPERATIONS.count)} text={'Count'}
RightIcon={ hasSubMenu
!isDefined(recordIndexKanbanAggregateOperation?.operation) || />
recordIndexKanbanAggregateOperation?.operation === <MenuItem
AGGREGATE_OPERATIONS.count onClick={() => {
? IconCheck onContentChange('percentAggregateOperationsOptions');
: undefined }}
} text={'Percent'}
hasSubMenu
/> />
<MenuItem <MenuItem
onClick={() => { onClick={() => {

View File

@ -1,89 +0,0 @@
import { useDropdown } from '@/dropdown/hooks/useDropdown';
import {
RecordBoardColumnHeaderAggregateDropdownContext,
RecordBoardColumnHeaderAggregateDropdownContextValue,
} from '@/object-record/record-board/record-board-column/components/RecordBoardColumnHeaderAggregateDropdownContext';
import { RecordBoardColumnHeaderAggregateDropdownMenuItem } from '@/object-record/record-board/record-board-column/components/RecordBoardColumnHeaderAggregateDropdownMenuItem';
import { aggregateOperationComponentState } from '@/object-record/record-board/record-board-column/states/aggregateOperationComponentState';
import { availableFieldIdsForAggregateOperationComponentState } from '@/object-record/record-board/record-board-column/states/availableFieldIdsForAggregateOperationComponentState';
import { getAggregateOperationLabel } from '@/object-record/record-board/record-board-column/utils/getAggregateOperationLabel';
import { AGGREGATE_OPERATIONS } from '@/object-record/record-table/constants/AggregateOperations';
import { TableOptionsHotkeyScope } from '@/object-record/record-table/types/TableOptionsHotkeyScope';
import { AvailableFieldsForAggregateOperation } from '@/object-record/types/AvailableFieldsForAggregateOperation';
import { getAvailableFieldsIdsForAggregationFromObjectFields } from '@/object-record/utils/getAvailableFieldsIdsForAggregationFromObjectFields';
import { DropdownMenuHeader } from '@/ui/layout/dropdown/components/DropdownMenuHeader';
import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer';
import { useScopedHotkeys } from '@/ui/utilities/hotkey/hooks/useScopedHotkeys';
import { useSetRecoilComponentStateV2 } from '@/ui/utilities/state/component-state/hooks/useSetRecoilComponentStateV2';
import isEmpty from 'lodash.isempty';
import { useMemo } from 'react';
import { Key } from 'ts-key-enum';
import { IconChevronLeft } from 'twenty-ui';
export const RecordBoardColumnHeaderAggregateDropdownMoreOptionsContent =
() => {
const { objectMetadataItem, onContentChange, closeDropdown, resetContent } =
useDropdown<RecordBoardColumnHeaderAggregateDropdownContextValue>({
context: RecordBoardColumnHeaderAggregateDropdownContext,
});
useScopedHotkeys(
[Key.Escape],
() => {
closeDropdown();
},
TableOptionsHotkeyScope.Dropdown,
);
const availableAggregations: AvailableFieldsForAggregateOperation = useMemo(
() =>
getAvailableFieldsIdsForAggregationFromObjectFields(
objectMetadataItem.fields,
),
[objectMetadataItem.fields],
);
const setAggregateOperation = useSetRecoilComponentStateV2(
aggregateOperationComponentState,
);
const setAvailableFieldsForAggregateOperation =
useSetRecoilComponentStateV2(
availableFieldIdsForAggregateOperationComponentState,
);
return (
<>
<DropdownMenuHeader StartIcon={IconChevronLeft} onClick={resetContent}>
More options
</DropdownMenuHeader>
<DropdownMenuItemsContainer>
{Object.entries(availableAggregations)
.filter(([, fields]) => !isEmpty(fields))
.map(
([
availableAggregationOperation,
availableAggregationFieldsIdsForOperation,
]) => (
<RecordBoardColumnHeaderAggregateDropdownMenuItem
key={`aggregate-dropdown-menu-content-${availableAggregationOperation}`}
onContentChange={() => {
setAggregateOperation(
availableAggregationOperation as AGGREGATE_OPERATIONS,
);
setAvailableFieldsForAggregateOperation(
availableAggregationFieldsIdsForOperation,
);
onContentChange('aggregateFields');
}}
text={getAggregateOperationLabel(
availableAggregationOperation as AGGREGATE_OPERATIONS,
)}
hasSubMenu
/>
),
)}
</DropdownMenuItemsContainer>
</>
);
};

View File

@ -0,0 +1,103 @@
import { useDropdown } from '@/dropdown/hooks/useDropdown';
import {
RecordBoardColumnHeaderAggregateDropdownContext,
RecordBoardColumnHeaderAggregateDropdownContextValue,
} from '@/object-record/record-board/record-board-column/components/RecordBoardColumnHeaderAggregateDropdownContext';
import { RecordBoardColumnHeaderAggregateDropdownMenuItem } from '@/object-record/record-board/record-board-column/components/RecordBoardColumnHeaderAggregateDropdownMenuItem';
import { aggregateOperationComponentState } from '@/object-record/record-board/record-board-column/states/aggregateOperationComponentState';
import { availableFieldIdsForAggregateOperationComponentState } from '@/object-record/record-board/record-board-column/states/availableFieldIdsForAggregateOperationComponentState';
import { getAggregateOperationLabel } from '@/object-record/record-board/record-board-column/utils/getAggregateOperationLabel';
import { AGGREGATE_OPERATIONS } from '@/object-record/record-table/constants/AggregateOperations';
import { TableOptionsHotkeyScope } from '@/object-record/record-table/types/TableOptionsHotkeyScope';
import { AvailableFieldsForAggregateOperation } from '@/object-record/types/AvailableFieldsForAggregateOperation';
import { DropdownMenuHeader } from '@/ui/layout/dropdown/components/DropdownMenuHeader';
import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer';
import { useScopedHotkeys } from '@/ui/utilities/hotkey/hooks/useScopedHotkeys';
import { useSetRecoilComponentStateV2 } from '@/ui/utilities/state/component-state/hooks/useSetRecoilComponentStateV2';
import { useUpdateViewAggregate } from '@/views/hooks/useUpdateViewAggregate';
import isEmpty from 'lodash.isempty';
import { Key } from 'ts-key-enum';
import { IconChevronLeft } from 'twenty-ui';
export const RecordBoardColumnHeaderAggregateDropdownOptionsContent = ({
availableAggregations,
title,
}: {
availableAggregations: AvailableFieldsForAggregateOperation;
title: string;
}) => {
const { onContentChange, closeDropdown, resetContent } =
useDropdown<RecordBoardColumnHeaderAggregateDropdownContextValue>({
context: RecordBoardColumnHeaderAggregateDropdownContext,
});
useScopedHotkeys(
[Key.Escape],
() => {
closeDropdown();
},
TableOptionsHotkeyScope.Dropdown,
);
const setAggregateOperation = useSetRecoilComponentStateV2(
aggregateOperationComponentState,
);
const setAvailableFieldsForAggregateOperation = useSetRecoilComponentStateV2(
availableFieldIdsForAggregateOperationComponentState,
);
const { updateViewAggregate } = useUpdateViewAggregate();
return (
<>
<DropdownMenuHeader StartIcon={IconChevronLeft} onClick={resetContent}>
{title}
</DropdownMenuHeader>
<DropdownMenuItemsContainer>
{Object.entries(availableAggregations)
.filter(([, fields]) => !isEmpty(fields))
.map(
([
availableAggregationOperation,
availableAggregationFieldsIdsForOperation,
]) => (
<RecordBoardColumnHeaderAggregateDropdownMenuItem
key={`aggregate-dropdown-menu-content-${availableAggregationOperation}`}
onContentChange={() => {
if (
availableAggregationOperation !== AGGREGATE_OPERATIONS.count
) {
setAggregateOperation(
availableAggregationOperation as AGGREGATE_OPERATIONS,
);
setAvailableFieldsForAggregateOperation(
availableAggregationFieldsIdsForOperation,
);
onContentChange('aggregateFields');
} else {
updateViewAggregate({
kanbanAggregateOperationFieldMetadataId:
availableAggregationFieldsIdsForOperation[0],
kanbanAggregateOperation:
availableAggregationOperation as AGGREGATE_OPERATIONS,
});
closeDropdown();
}
}}
text={getAggregateOperationLabel(
availableAggregationOperation as AGGREGATE_OPERATIONS,
)}
hasSubMenu={
availableAggregationOperation === AGGREGATE_OPERATIONS.count
? false
: true
}
/>
),
)}
</DropdownMenuItemsContainer>
</>
);
};

View File

@ -14,6 +14,7 @@ describe('computeAggregateValueAndLabel', () => {
{ {
id: MOCK_FIELD_ID, id: MOCK_FIELD_ID,
name: 'amount', name: 'amount',
label: 'amount',
type: FieldMetadataType.Currency, type: FieldMetadataType.Currency,
} as FieldMetadataItem, } as FieldMetadataItem,
], ],
@ -60,6 +61,7 @@ describe('computeAggregateValueAndLabel', () => {
{ {
id: MOCK_FIELD_ID, id: MOCK_FIELD_ID,
name: 'percentage', name: 'percentage',
label: 'percentage',
type: FieldMetadataType.Number, type: FieldMetadataType.Number,
settings: { settings: {
type: 'percentage', type: 'percentage',
@ -96,6 +98,7 @@ describe('computeAggregateValueAndLabel', () => {
{ {
id: MOCK_FIELD_ID, id: MOCK_FIELD_ID,
name: 'decimals', name: 'decimals',
label: 'decimals',
type: FieldMetadataType.Number, type: FieldMetadataType.Number,
settings: { settings: {
decimals: 2, decimals: 2,

View File

@ -78,7 +78,7 @@ export const computeAggregateValueAndLabel = ({
const labelWithFieldName = const labelWithFieldName =
aggregateOperation === AGGREGATE_OPERATIONS.count aggregateOperation === AGGREGATE_OPERATIONS.count
? `${getAggregateOperationLabel(AGGREGATE_OPERATIONS.count)}` ? `${getAggregateOperationLabel(AGGREGATE_OPERATIONS.count)}`
: `${getAggregateOperationLabel(aggregateOperation)} of ${field.name}`; : `${getAggregateOperationLabel(aggregateOperation)} of ${field.label}`;
return { return {
value, value,

View File

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

View File

@ -1,7 +1,7 @@
import { AGGREGATE_OPERATIONS } from '@/object-record/record-table/constants/AggregateOperations'; import { AGGREGATE_OPERATIONS } from '@/object-record/record-table/constants/AggregateOperations';
import { FieldMetadataType } from '~/generated-metadata/graphql'; import { FieldMetadataType } from '~/generated-metadata/graphql';
export const FIELDS_AVAILABLE_BY_AGGREGATE_OPERATION = { export const FIELD_TYPES_AVAILABLE_FOR_NON_STANDARD_AGGREGATE_OPERATION = {
[AGGREGATE_OPERATIONS.min]: [ [AGGREGATE_OPERATIONS.min]: [
FieldMetadataType.Number, FieldMetadataType.Number,
FieldMetadataType.Currency, FieldMetadataType.Currency,

View File

@ -0,0 +1,8 @@
import { AGGREGATE_OPERATIONS } from '@/object-record/record-table/constants/AggregateOperations';
export const NON_STANDARD_AGGREGATE_OPERATION_OPTIONS = [
AGGREGATE_OPERATIONS.min,
AGGREGATE_OPERATIONS.max,
AGGREGATE_OPERATIONS.avg,
AGGREGATE_OPERATIONS.sum,
];

View File

@ -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 { FIELDS_AVAILABLE_BY_AGGREGATE_OPERATION } from '@/object-record/record-table/constants/FieldsAvailableByAggregateOperation'; import { FIELD_TYPES_AVAILABLE_FOR_NON_STANDARD_AGGREGATE_OPERATION } from '@/object-record/record-table/constants/FieldTypesAvailableForNonStandardAggregateOperation';
import { AggregateOperationsOmittingStandardOperations } from '@/object-record/types/AggregateOperationsOmittingStandardOperations'; import { 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';
@ -27,7 +27,7 @@ export const getAvailableAggregateOperationsForFieldMetadataType = ({
return Array.from(availableAggregateOperations); return Array.from(availableAggregateOperations);
} }
Object.keys(FIELDS_AVAILABLE_BY_AGGREGATE_OPERATION) Object.keys(FIELD_TYPES_AVAILABLE_FOR_NON_STANDARD_AGGREGATE_OPERATION)
.filter((operation) => .filter((operation) =>
isFieldTypeValidForAggregateOperation( isFieldTypeValidForAggregateOperation(
fieldMetadataType, fieldMetadataType,

View File

@ -1,5 +1,5 @@
import { AggregateOperationsOmittingStandardOperations } from '@/object-record/types/AggregateOperationsOmittingStandardOperations'; import { AGGREGATE_OPERATIONS } from '@/object-record/record-table/constants/AggregateOperations';
export type AvailableFieldsForAggregateOperation = { export type AvailableFieldsForAggregateOperation = {
[T in AggregateOperationsOmittingStandardOperations]?: string[]; [T in AGGREGATE_OPERATIONS]?: string[];
}; };

View File

@ -1,7 +1,8 @@
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 { COUNT_AGGREGATE_OPERATION_OPTIONS } from '@/object-record/record-table/record-table-footer/constants/countAggregateOperationOptions';
import { AggregateOperationsOmittingStandardOperations } from '@/object-record/types/AggregateOperationsOmittingStandardOperations'; import { NON_STANDARD_AGGREGATE_OPERATION_OPTIONS } from '@/object-record/record-table/record-table-footer/constants/nonStandardAggregateOperationsOptions';
import { PERCENT_AGGREGATE_OPERATION_OPTIONS } from '@/object-record/record-table/record-table-footer/constants/percentAggregateOperationOption';
import { getAvailableFieldsIdsForAggregationFromObjectFields } from '@/object-record/utils/getAvailableFieldsIdsForAggregationFromObjectFields'; import { getAvailableFieldsIdsForAggregationFromObjectFields } from '@/object-record/utils/getAvailableFieldsIdsForAggregationFromObjectFields';
import { FieldMetadataType } from '~/generated/graphql'; import { FieldMetadataType } from '~/generated/graphql';
@ -9,59 +10,135 @@ const AMOUNT_FIELD_ID = '7d2d7b5e-7b3e-4b4a-8b0a-7b3e4b4a8b0a';
const PRICE_FIELD_ID = '9d2d7b5e-7b3e-4b4a-8b0a-7b3e4b4a8b0b'; const PRICE_FIELD_ID = '9d2d7b5e-7b3e-4b4a-8b0a-7b3e4b4a8b0b';
const NAME_FIELD_ID = '5d2d7b5e-7b3e-4b4a-8b0a-7b3e4b4a8b0c'; const NAME_FIELD_ID = '5d2d7b5e-7b3e-4b4a-8b0a-7b3e4b4a8b0c';
describe('getAvailableFieldsIdsForAggregationFromObjectFields', () => { const FIELDS_MOCKS = [
const mockFields = [ { id: AMOUNT_FIELD_ID, type: FieldMetadataType.Number, name: 'amount' },
{ id: AMOUNT_FIELD_ID, type: FieldMetadataType.Number, name: 'amount' }, { id: PRICE_FIELD_ID, type: FieldMetadataType.Currency, name: 'price' },
{ id: PRICE_FIELD_ID, type: FieldMetadataType.Currency, name: 'price' }, { id: NAME_FIELD_ID, type: FieldMetadataType.Text, name: 'name' },
{ id: NAME_FIELD_ID, type: FieldMetadataType.Text, name: 'name' }, ];
];
it('should correctly map fields to available aggregate operations', () => { jest.mock(
'@/object-record/utils/getAvailableAggregationsFromObjectFields',
() => ({
getAvailableAggregationsFromObjectFields: jest.fn().mockReturnValue({
amount: {
[AGGREGATE_OPERATIONS.sum]: 'sumAmount',
[AGGREGATE_OPERATIONS.avg]: 'avgAmount',
[AGGREGATE_OPERATIONS.min]: 'minAmount',
[AGGREGATE_OPERATIONS.max]: 'maxAmount',
[AGGREGATE_OPERATIONS.count]: 'totalCount',
[AGGREGATE_OPERATIONS.countUniqueValues]: 'countUniqueValuesAmount',
[AGGREGATE_OPERATIONS.countEmpty]: 'countEmptyAmount',
[AGGREGATE_OPERATIONS.countNotEmpty]: 'countNotEmptyAmount',
[AGGREGATE_OPERATIONS.percentageEmpty]: 'percentageEmptyAmount',
[AGGREGATE_OPERATIONS.percentageNotEmpty]: 'percentageNotEmptyAmount',
},
price: {
[AGGREGATE_OPERATIONS.sum]: 'sumPriceAmountMicros',
[AGGREGATE_OPERATIONS.avg]: 'avgPriceAmountMicros',
[AGGREGATE_OPERATIONS.min]: 'minPriceAmountMicros',
[AGGREGATE_OPERATIONS.max]: 'maxPriceAmountMicros',
[AGGREGATE_OPERATIONS.count]: 'totalCount',
[AGGREGATE_OPERATIONS.countUniqueValues]:
'countUniqueValuesPriceAmountMicros',
[AGGREGATE_OPERATIONS.countEmpty]: 'countEmptyPriceAmountMicros',
[AGGREGATE_OPERATIONS.countNotEmpty]: 'countNotEmptyPriceAmountMicros',
[AGGREGATE_OPERATIONS.percentageEmpty]:
'percentageEmptyPriceAmountMicros',
[AGGREGATE_OPERATIONS.percentageNotEmpty]:
'percentageNotEmptyPriceAmountMicros',
},
name: {
[AGGREGATE_OPERATIONS.count]: 'totalCount',
[AGGREGATE_OPERATIONS.countUniqueValues]: 'countUniqueValuesName',
[AGGREGATE_OPERATIONS.countEmpty]: 'countEmptyName',
[AGGREGATE_OPERATIONS.countNotEmpty]: 'countNotEmptyName',
[AGGREGATE_OPERATIONS.percentageEmpty]: 'percentageEmptyName',
[AGGREGATE_OPERATIONS.percentageNotEmpty]: 'percentageNotEmptyName',
},
}),
}),
);
describe('getAvailableFieldsIdsForAggregationFromObjectFields', () => {
it('should handle empty fields array', () => {
const result = getAvailableFieldsIdsForAggregationFromObjectFields( const result = getAvailableFieldsIdsForAggregationFromObjectFields(
mockFields as FieldMetadataItem[], [],
COUNT_AGGREGATE_OPERATION_OPTIONS,
); );
expect(result[AGGREGATE_OPERATIONS.sum]).toEqual([ COUNT_AGGREGATE_OPERATION_OPTIONS.forEach((operation) => {
AMOUNT_FIELD_ID, expect(result[operation]).toEqual([]);
PRICE_FIELD_ID,
]);
expect(result[AGGREGATE_OPERATIONS.avg]).toEqual([
AMOUNT_FIELD_ID,
PRICE_FIELD_ID,
]);
expect(result[AGGREGATE_OPERATIONS.min]).toEqual([
AMOUNT_FIELD_ID,
PRICE_FIELD_ID,
]);
expect(result[AGGREGATE_OPERATIONS.max]).toEqual([
AMOUNT_FIELD_ID,
PRICE_FIELD_ID,
]);
});
it('should exclude non-numeric fields', () => {
const result = getAvailableFieldsIdsForAggregationFromObjectFields([
{ id: NAME_FIELD_ID, type: FieldMetadataType.Text } as FieldMetadataItem,
]);
Object.values(AGGREGATE_OPERATIONS).forEach((operation) => {
if (!STANDARD_AGGREGATE_OPERATION_OPTIONS.includes(operation)) {
expect(
result[operation as AggregateOperationsOmittingStandardOperations],
).toEqual([]);
}
}); });
}); });
it('should handle empty fields array', () => { describe('with count aggregate operations', () => {
const result = getAvailableFieldsIdsForAggregationFromObjectFields([]); it('should include all fields', () => {
const result = getAvailableFieldsIdsForAggregationFromObjectFields(
FIELDS_MOCKS as FieldMetadataItem[],
COUNT_AGGREGATE_OPERATION_OPTIONS,
);
Object.values(AGGREGATE_OPERATIONS).forEach((operation) => { COUNT_AGGREGATE_OPERATION_OPTIONS.forEach((operation) => {
if (!STANDARD_AGGREGATE_OPERATION_OPTIONS.includes(operation)) { expect(result[operation]).toEqual([
expect( AMOUNT_FIELD_ID,
result[operation as AggregateOperationsOmittingStandardOperations], PRICE_FIELD_ID,
).toEqual([]); NAME_FIELD_ID,
} ]);
});
PERCENT_AGGREGATE_OPERATION_OPTIONS.forEach((operation) => {
expect(result[operation]).toBeUndefined();
});
NON_STANDARD_AGGREGATE_OPERATION_OPTIONS.forEach((operation) => {
expect(result[operation]).toBeUndefined();
});
});
});
describe('with percentage aggregate operations', () => {
it('should include all fields', () => {
const result = getAvailableFieldsIdsForAggregationFromObjectFields(
FIELDS_MOCKS as FieldMetadataItem[],
PERCENT_AGGREGATE_OPERATION_OPTIONS,
);
PERCENT_AGGREGATE_OPERATION_OPTIONS.forEach((operation) => {
expect(result[operation]).toEqual([
AMOUNT_FIELD_ID,
PRICE_FIELD_ID,
NAME_FIELD_ID,
]);
});
COUNT_AGGREGATE_OPERATION_OPTIONS.forEach((operation) => {
expect(result[operation]).toBeUndefined();
});
NON_STANDARD_AGGREGATE_OPERATION_OPTIONS.forEach((operation) => {
expect(result[operation]).toBeUndefined();
});
});
});
describe('with non standard aggregate operations', () => {
it('should exclude non-numeric fields', () => {
const result = getAvailableFieldsIdsForAggregationFromObjectFields(
FIELDS_MOCKS as FieldMetadataItem[],
NON_STANDARD_AGGREGATE_OPERATION_OPTIONS,
);
COUNT_AGGREGATE_OPERATION_OPTIONS.forEach((operation) => {
expect(result[operation]).toBeUndefined();
});
PERCENT_AGGREGATE_OPERATION_OPTIONS.forEach((operation) => {
expect(result[operation]).toBeUndefined();
});
NON_STANDARD_AGGREGATE_OPERATION_OPTIONS.forEach((operation) => {
expect(result[operation]).toEqual([AMOUNT_FIELD_ID, PRICE_FIELD_ID]);
});
}); });
}); });
}); });

View File

@ -1,26 +1,58 @@
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 { FIELD_TYPES_AVAILABLE_FOR_NON_STANDARD_AGGREGATE_OPERATION } from '@/object-record/record-table/constants/FieldTypesAvailableForNonStandardAggregateOperation';
import { COUNT_AGGREGATE_OPERATION_OPTIONS } from '@/object-record/record-table/record-table-footer/constants/countAggregateOperationOptions';
import { PERCENT_AGGREGATE_OPERATION_OPTIONS } from '@/object-record/record-table/record-table-footer/constants/percentAggregateOperationOption';
import { AggregateOperationsOmittingStandardOperations } from '@/object-record/types/AggregateOperationsOmittingStandardOperations'; import { AggregateOperationsOmittingStandardOperations } from '@/object-record/types/AggregateOperationsOmittingStandardOperations';
import { initializeAvailableFieldsForAggregateOperationMap } from '@/object-record/utils/initializeAvailableFieldsForAggregateOperationMap'; import { initializeAvailableFieldsForAggregateOperationMap } from '@/object-record/utils/initializeAvailableFieldsForAggregateOperationMap';
describe('initializeAvailableFieldsForAggregateOperationMap', () => { describe('initializeAvailableFieldsForAggregateOperationMap', () => {
it('should initialize empty arrays for each aggregate operation', () => { it('should initialize empty arrays for each non standard aggregate operation', () => {
const result = initializeAvailableFieldsForAggregateOperationMap(); const result = initializeAvailableFieldsForAggregateOperationMap(
Object.keys(
FIELD_TYPES_AVAILABLE_FOR_NON_STANDARD_AGGREGATE_OPERATION,
) as AGGREGATE_OPERATIONS[],
);
expect(Object.keys(result)).toEqual( expect(Object.keys(result)).toEqual(
Object.keys(FIELDS_AVAILABLE_BY_AGGREGATE_OPERATION), Object.keys(FIELD_TYPES_AVAILABLE_FOR_NON_STANDARD_AGGREGATE_OPERATION),
); );
Object.values(result).forEach((array) => { Object.values(result).forEach((array) => {
expect(array).toEqual([]); expect(array).toEqual([]);
}); });
}); });
it('should not include count operation', () => { it('should not include count operation when called with non standard aggregate operations', () => {
const result = initializeAvailableFieldsForAggregateOperationMap(); const result = initializeAvailableFieldsForAggregateOperationMap(
Object.keys(
FIELD_TYPES_AVAILABLE_FOR_NON_STANDARD_AGGREGATE_OPERATION,
) as AGGREGATE_OPERATIONS[],
);
expect( expect(
result[ result[
AGGREGATE_OPERATIONS.count as AggregateOperationsOmittingStandardOperations AGGREGATE_OPERATIONS.count as AggregateOperationsOmittingStandardOperations
], ],
).toBeUndefined(); ).toBeUndefined();
}); });
it('should include count operation when called with count aggregate operations', () => {
const result = initializeAvailableFieldsForAggregateOperationMap(
COUNT_AGGREGATE_OPERATION_OPTIONS,
);
expect(result[AGGREGATE_OPERATIONS.count]).toEqual([]);
expect(result[AGGREGATE_OPERATIONS.countEmpty]).toEqual([]);
expect(result[AGGREGATE_OPERATIONS.countNotEmpty]).toEqual([]);
expect(result[AGGREGATE_OPERATIONS.countUniqueValues]).toEqual([]);
expect(result[AGGREGATE_OPERATIONS.min]).toBeUndefined();
expect(result[AGGREGATE_OPERATIONS.percentageEmpty]).toBeUndefined();
});
it('should include percent operation when called with count aggregate operations', () => {
const result = initializeAvailableFieldsForAggregateOperationMap(
PERCENT_AGGREGATE_OPERATION_OPTIONS,
);
expect(result[AGGREGATE_OPERATIONS.percentageEmpty]).toEqual([]);
expect(result[AGGREGATE_OPERATIONS.percentageNotEmpty]).toEqual([]);
expect(result[AGGREGATE_OPERATIONS.count]).toBeUndefined();
expect(result[AGGREGATE_OPERATIONS.min]).toBeUndefined();
});
}); });

View File

@ -1,6 +1,5 @@
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';
@ -16,6 +15,10 @@ 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.isSystem === true) {
return acc;
}
if (field.type === FieldMetadataType.Relation) { if (field.type === FieldMetadataType.Relation) {
acc[field.name] = { acc[field.name] = {
[AGGREGATE_OPERATIONS.count]: 'totalCount', [AGGREGATE_OPERATIONS.count]: 'totalCount',
@ -23,28 +26,15 @@ export const getAvailableAggregationsFromObjectFields = (
return acc; return acc;
} }
const columnName = getColumnNameForAggregateOperation(
field.name,
field.type,
);
acc[field.name] = { acc[field.name] = {
[AGGREGATE_OPERATIONS.countUniqueValues]: `countUniqueValues${capitalize(columnName)}`, [AGGREGATE_OPERATIONS.countUniqueValues]: `countUniqueValues${capitalize(field.name)}`,
[AGGREGATE_OPERATIONS.countEmpty]: `countEmpty${capitalize(columnName)}`, [AGGREGATE_OPERATIONS.countEmpty]: `countEmpty${capitalize(field.name)}`,
[AGGREGATE_OPERATIONS.countNotEmpty]: `countNotEmpty${capitalize(columnName)}`, [AGGREGATE_OPERATIONS.countNotEmpty]: `countNotEmpty${capitalize(field.name)}`,
[AGGREGATE_OPERATIONS.percentageEmpty]: `percentageEmpty${capitalize(columnName)}`, [AGGREGATE_OPERATIONS.percentageEmpty]: `percentageEmpty${capitalize(field.name)}`,
[AGGREGATE_OPERATIONS.percentageNotEmpty]: `percentageNotEmpty${capitalize(columnName)}`, [AGGREGATE_OPERATIONS.percentageNotEmpty]: `percentageNotEmpty${capitalize(field.name)}`,
[AGGREGATE_OPERATIONS.count]: 'totalCount', [AGGREGATE_OPERATIONS.count]: 'totalCount',
}; };
if (field.type === FieldMetadataType.DateTime) {
acc[field.name] = {
...acc[field.name],
[AGGREGATE_OPERATIONS.min]: `min${capitalize(field.name)}`,
[AGGREGATE_OPERATIONS.max]: `max${capitalize(field.name)}`,
};
}
if (field.type === FieldMetadataType.Number) { if (field.type === FieldMetadataType.Number) {
acc[field.name] = { acc[field.name] = {
...acc[field.name], ...acc[field.name],

View File

@ -1,32 +1,30 @@
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 { AGGREGATE_OPERATIONS } from '@/object-record/record-table/constants/AggregateOperations';
import { AggregateOperationsOmittingStandardOperations } from '@/object-record/types/AggregateOperationsOmittingStandardOperations';
import { AvailableFieldsForAggregateOperation } from '@/object-record/types/AvailableFieldsForAggregateOperation'; import { AvailableFieldsForAggregateOperation } from '@/object-record/types/AvailableFieldsForAggregateOperation';
import { getAvailableAggregationsFromObjectFields } from '@/object-record/utils/getAvailableAggregationsFromObjectFields';
import { initializeAvailableFieldsForAggregateOperationMap } from '@/object-record/utils/initializeAvailableFieldsForAggregateOperationMap'; import { initializeAvailableFieldsForAggregateOperationMap } from '@/object-record/utils/initializeAvailableFieldsForAggregateOperationMap';
import { isFieldTypeValidForAggregateOperation } from '@/object-record/utils/isFieldTypeValidForAggregateOperation'; import { isDefined } from '~/utils/isDefined';
export const getAvailableFieldsIdsForAggregationFromObjectFields = ( export const getAvailableFieldsIdsForAggregationFromObjectFields = (
fields: FieldMetadataItem[], fields: FieldMetadataItem[],
targetAggregateOperations: AGGREGATE_OPERATIONS[],
): AvailableFieldsForAggregateOperation => { ): AvailableFieldsForAggregateOperation => {
const aggregationMap = initializeAvailableFieldsForAggregateOperationMap(); const aggregationMap = initializeAvailableFieldsForAggregateOperationMap(
targetAggregateOperations,
);
const allAggregations = getAvailableAggregationsFromObjectFields(fields);
return fields.reduce((acc, field) => { return fields.reduce((acc, field) => {
Object.keys(FIELDS_AVAILABLE_BY_AGGREGATE_OPERATION).forEach( if (isDefined(allAggregations[field.name])) {
(aggregateOperation) => { Object.keys(allAggregations[field.name]).forEach((aggregation) => {
const typedAggregateOperation = const typedAggregateOperation = aggregation as AGGREGATE_OPERATIONS;
aggregateOperation as AggregateOperationsOmittingStandardOperations;
if ( if (targetAggregateOperations.includes(typedAggregateOperation)) {
isFieldTypeValidForAggregateOperation(
field.type,
typedAggregateOperation,
)
) {
acc[typedAggregateOperation]?.push(field.id); acc[typedAggregateOperation]?.push(field.id);
} }
}, });
); }
return acc; return acc;
}, aggregationMap); }, aggregationMap);
}; };

View File

@ -1,13 +1,14 @@
import { FIELDS_AVAILABLE_BY_AGGREGATE_OPERATION } from '@/object-record/record-table/constants/FieldsAvailableByAggregateOperation'; import { AGGREGATE_OPERATIONS } from '@/object-record/record-table/constants/AggregateOperations';
import { AvailableFieldsForAggregateOperation } from '@/object-record/types/AvailableFieldsForAggregateOperation'; import { AvailableFieldsForAggregateOperation } from '@/object-record/types/AvailableFieldsForAggregateOperation';
export const initializeAvailableFieldsForAggregateOperationMap = export const initializeAvailableFieldsForAggregateOperationMap = (
(): AvailableFieldsForAggregateOperation => { aggregateOperations: AGGREGATE_OPERATIONS[],
return Object.keys(FIELDS_AVAILABLE_BY_AGGREGATE_OPERATION).reduce( ): AvailableFieldsForAggregateOperation => {
(acc, operation) => ({ return aggregateOperations.reduce(
...acc, (acc, operation) => ({
[operation]: [], ...acc,
}), [operation]: [],
{}, }),
); {},
}; );
};

View File

@ -1,4 +1,4 @@
import { FIELDS_AVAILABLE_BY_AGGREGATE_OPERATION } from '@/object-record/record-table/constants/FieldsAvailableByAggregateOperation'; import { FIELD_TYPES_AVAILABLE_FOR_NON_STANDARD_AGGREGATE_OPERATION } from '@/object-record/record-table/constants/FieldTypesAvailableForNonStandardAggregateOperation';
import { AggregateOperationsOmittingStandardOperations } from '@/object-record/types/AggregateOperationsOmittingStandardOperations'; import { AggregateOperationsOmittingStandardOperations } from '@/object-record/types/AggregateOperationsOmittingStandardOperations';
import { FieldMetadataType } from '~/generated-metadata/graphql'; import { FieldMetadataType } from '~/generated-metadata/graphql';
@ -6,7 +6,7 @@ export const isFieldTypeValidForAggregateOperation = (
fieldType: FieldMetadataType, fieldType: FieldMetadataType,
aggregateOperation: AggregateOperationsOmittingStandardOperations, aggregateOperation: AggregateOperationsOmittingStandardOperations,
): boolean => { ): boolean => {
return FIELDS_AVAILABLE_BY_AGGREGATE_OPERATION[aggregateOperation].includes( return FIELD_TYPES_AVAILABLE_FOR_NON_STANDARD_AGGREGATE_OPERATION[
fieldType, aggregateOperation
); ].includes(fieldType);
}; };

View File

@ -3,7 +3,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 { FIELD_METADATA_TYPES_TO_TEXT_COLUMN_TYPE } from 'src/engine/metadata-modules/workspace-migration/constants/fieldMetadataTypesToTextColumnType';
import { formatColumnNameFromCompositeFieldAndSubfield } from 'src/engine/twenty-orm/utils/format-column-name-from-composite-field-and-subfield.util'; import { formatColumnNamesFromCompositeFieldAndSubfields } from 'src/engine/twenty-orm/utils/format-column-names-from-composite-field-and-subfield.util';
import { isDefined } from 'src/utils/is-defined'; import { isDefined } from 'src/utils/is-defined';
export class ProcessAggregateHelper { export class ProcessAggregateHelper {
@ -26,11 +26,20 @@ export class ProcessAggregateHelper {
continue; continue;
} }
const columnName = formatColumnNameFromCompositeFieldAndSubfield( const columnNames = formatColumnNamesFromCompositeFieldAndSubfields(
aggregatedField.fromField, aggregatedField.fromField,
aggregatedField.fromSubField, aggregatedField.fromSubFields,
); );
const columnNameForNumericOperation = isDefined(
aggregatedField.subFieldForNumericOperation,
)
? formatColumnNamesFromCompositeFieldAndSubfields(
aggregatedField.fromField,
[aggregatedField.subFieldForNumericOperation],
)[0]
: columnNames[0];
if ( if (
!Object.values(AGGREGATE_OPERATIONS).includes( !Object.values(AGGREGATE_OPERATIONS).includes(
aggregatedField.aggregateOperation, aggregatedField.aggregateOperation,
@ -39,49 +48,54 @@ export class ProcessAggregateHelper {
continue; continue;
} }
const columnEmptyValueExpression = const concatenatedColumns = columnNames
.map((col) => `"${col}"`)
.join(", ' ', ");
const columnExpression =
FIELD_METADATA_TYPES_TO_TEXT_COLUMN_TYPE.includes( FIELD_METADATA_TYPES_TO_TEXT_COLUMN_TYPE.includes(
aggregatedField.fromFieldType, aggregatedField.fromFieldType,
) )
? `NULLIF("${columnName}", '')` ? `NULLIF(CONCAT(${concatenatedColumns}), '')`
: `"${columnName}"`; : `CONCAT(${concatenatedColumns})`;
switch (aggregatedField.aggregateOperation) { switch (aggregatedField.aggregateOperation) {
case AGGREGATE_OPERATIONS.countEmpty: case AGGREGATE_OPERATIONS.countEmpty:
queryBuilder.addSelect( queryBuilder.addSelect(
`CASE WHEN COUNT(*) = 0 THEN NULL ELSE COUNT(*) - COUNT(${columnEmptyValueExpression}) END`, `CASE WHEN COUNT(*) = 0 THEN NULL ELSE COUNT(*) - COUNT(${columnExpression}) END`,
`${aggregatedFieldName}`, `${aggregatedFieldName}`,
); );
break; break;
case AGGREGATE_OPERATIONS.countNotEmpty: case AGGREGATE_OPERATIONS.countNotEmpty:
queryBuilder.addSelect( queryBuilder.addSelect(
`CASE WHEN COUNT(*) = 0 THEN NULL ELSE COUNT(${columnEmptyValueExpression}) END`, `CASE WHEN COUNT(*) = 0 THEN NULL ELSE COUNT(${columnExpression}) END`,
`${aggregatedFieldName}`, `${aggregatedFieldName}`,
); );
break; break;
case AGGREGATE_OPERATIONS.countUniqueValues: case AGGREGATE_OPERATIONS.countUniqueValues:
queryBuilder.addSelect( queryBuilder.addSelect(
`CASE WHEN COUNT(*) = 0 THEN NULL ELSE COUNT(DISTINCT "${columnName}") END`, `CASE WHEN COUNT(*) = 0 THEN NULL ELSE COUNT(DISTINCT ${columnExpression}) END`,
`${aggregatedFieldName}`, `${aggregatedFieldName}`,
); );
break; break;
case AGGREGATE_OPERATIONS.percentageEmpty: case AGGREGATE_OPERATIONS.percentageEmpty:
queryBuilder.addSelect( queryBuilder.addSelect(
`CASE WHEN COUNT(*) = 0 THEN NULL ELSE CAST(((COUNT(*) - COUNT(${columnEmptyValueExpression})::decimal) / COUNT(*)) AS DECIMAL) END`, `CASE WHEN COUNT(*) = 0 THEN NULL ELSE CAST(((COUNT(*) - COUNT(${columnExpression})::decimal) / COUNT(*)) AS DECIMAL) END`,
`${aggregatedFieldName}`, `${aggregatedFieldName}`,
); );
break; break;
case AGGREGATE_OPERATIONS.percentageNotEmpty: case AGGREGATE_OPERATIONS.percentageNotEmpty:
queryBuilder.addSelect( queryBuilder.addSelect(
`CASE WHEN COUNT(*) = 0 THEN NULL ELSE CAST((COUNT(${columnEmptyValueExpression})::decimal / COUNT(*)) AS DECIMAL) END`, `CASE WHEN COUNT(*) = 0 THEN NULL ELSE CAST((COUNT(${columnExpression})::decimal / COUNT(*)) AS DECIMAL) END`,
`${aggregatedFieldName}`, `${aggregatedFieldName}`,
); );
break; break;
default: default: {
queryBuilder.addSelect( queryBuilder.addSelect(
`${aggregatedField.aggregateOperation}("${columnName}")`, `${aggregatedField.aggregateOperation}("${columnNameForNumericOperation}")`,
`${aggregatedFieldName}`, `${aggregatedFieldName}`,
); );
}
} }
} }
}; };

View File

@ -1,10 +1,7 @@
import { GraphQLISODateTime } from '@nestjs/graphql'; import { GraphQLISODateTime } from '@nestjs/graphql';
import { GraphQLFloat, GraphQLInt, GraphQLScalarType } from 'graphql'; import { GraphQLFloat, GraphQLInt, GraphQLScalarType } from 'graphql';
import { import { getSubfieldsForAggregateOperation } from 'twenty-shared';
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';
@ -17,7 +14,8 @@ export type AggregationField = {
description: string; description: string;
fromField: string; fromField: string;
fromFieldType: FieldMetadataType; fromFieldType: FieldMetadataType;
fromSubField?: string; fromSubFields?: string[];
subFieldForNumericOperation?: string;
aggregateOperation: AGGREGATE_OPERATIONS; aggregateOperation: AGGREGATE_OPERATIONS;
}; };
@ -30,55 +28,50 @@ export const getAvailableAggregationsFromObjectFields = (
return acc; return acc;
} }
const columnName = getColumnNameForAggregateOperation( const fromSubFields = getSubfieldsForAggregateOperation(field.type);
field.name,
field.type,
);
const fromSubField = getSubfieldForAggregateOperation(field.type); acc[`countUniqueValues${capitalize(field.name)}`] = {
acc[`countUniqueValues${capitalize(columnName)}`] = {
type: GraphQLInt, type: GraphQLInt,
description: `Number of unique values for ${field.name}`, description: `Number of unique values for ${field.name}`,
fromField: field.name, fromField: field.name,
fromFieldType: field.type, fromFieldType: field.type,
fromSubField, fromSubFields,
aggregateOperation: AGGREGATE_OPERATIONS.countUniqueValues, aggregateOperation: AGGREGATE_OPERATIONS.countUniqueValues,
}; };
acc[`countEmpty${capitalize(columnName)}`] = { acc[`countEmpty${capitalize(field.name)}`] = {
type: GraphQLInt, type: GraphQLInt,
description: `Number of empty values for ${field.name}`, description: `Number of empty values for ${field.name}`,
fromField: field.name, fromField: field.name,
fromFieldType: field.type, fromFieldType: field.type,
fromSubField, fromSubFields,
aggregateOperation: AGGREGATE_OPERATIONS.countEmpty, aggregateOperation: AGGREGATE_OPERATIONS.countEmpty,
}; };
acc[`countNotEmpty${capitalize(columnName)}`] = { acc[`countNotEmpty${capitalize(field.name)}`] = {
type: GraphQLInt, type: GraphQLInt,
description: `Number of non-empty values for ${field.name}`, description: `Number of non-empty values for ${field.name}`,
fromField: field.name, fromField: field.name,
fromFieldType: field.type, fromFieldType: field.type,
fromSubField, fromSubFields,
aggregateOperation: AGGREGATE_OPERATIONS.countNotEmpty, aggregateOperation: AGGREGATE_OPERATIONS.countNotEmpty,
}; };
acc[`percentageEmpty${capitalize(columnName)}`] = { acc[`percentageEmpty${capitalize(field.name)}`] = {
type: GraphQLFloat, type: GraphQLFloat,
description: `Percentage of empty values for ${field.name}`, description: `Percentage of empty values for ${field.name}`,
fromField: field.name, fromField: field.name,
fromFieldType: field.type, fromFieldType: field.type,
fromSubField, fromSubFields,
aggregateOperation: AGGREGATE_OPERATIONS.percentageEmpty, aggregateOperation: AGGREGATE_OPERATIONS.percentageEmpty,
}; };
acc[`percentageNotEmpty${capitalize(columnName)}`] = { acc[`percentageNotEmpty${capitalize(field.name)}`] = {
type: GraphQLFloat, type: GraphQLFloat,
description: `Percentage of non-empty values for ${field.name}`, description: `Percentage of non-empty values for ${field.name}`,
fromField: field.name, fromField: field.name,
fromFieldType: field.type, fromFieldType: field.type,
fromSubField, fromSubFields,
aggregateOperation: AGGREGATE_OPERATIONS.percentageNotEmpty, aggregateOperation: AGGREGATE_OPERATIONS.percentageNotEmpty,
}; };
@ -138,7 +131,8 @@ export const getAvailableAggregationsFromObjectFields = (
type: GraphQLFloat, type: GraphQLFloat,
description: `Minimum amount contained in the field ${field.name}`, description: `Minimum amount contained in the field ${field.name}`,
fromField: field.name, fromField: field.name,
fromSubField: 'amountMicros', fromSubFields: getSubfieldsForAggregateOperation(field.type),
subFieldForNumericOperation: 'amountMicros',
fromFieldType: field.type, fromFieldType: field.type,
aggregateOperation: AGGREGATE_OPERATIONS.min, aggregateOperation: AGGREGATE_OPERATIONS.min,
}; };
@ -147,7 +141,7 @@ export const getAvailableAggregationsFromObjectFields = (
type: GraphQLFloat, type: GraphQLFloat,
description: `Maximal amount contained in the field ${field.name}`, description: `Maximal amount contained in the field ${field.name}`,
fromField: field.name, fromField: field.name,
fromSubField: 'amountMicros', fromSubFields: getSubfieldsForAggregateOperation(field.type),
fromFieldType: field.type, fromFieldType: field.type,
aggregateOperation: AGGREGATE_OPERATIONS.max, aggregateOperation: AGGREGATE_OPERATIONS.max,
}; };
@ -156,7 +150,7 @@ export const getAvailableAggregationsFromObjectFields = (
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', fromSubFields: getSubfieldsForAggregateOperation(field.type),
fromFieldType: field.type, fromFieldType: field.type,
aggregateOperation: AGGREGATE_OPERATIONS.sum, aggregateOperation: AGGREGATE_OPERATIONS.sum,
}; };
@ -165,7 +159,7 @@ export const getAvailableAggregationsFromObjectFields = (
type: GraphQLFloat, type: GraphQLFloat,
description: `Average amount contained in the field ${field.name}`, description: `Average amount contained in the field ${field.name}`,
fromField: field.name, fromField: field.name,
fromSubField: 'amountMicros', fromSubFields: getSubfieldsForAggregateOperation(field.type),
fromFieldType: field.type, fromFieldType: field.type,
aggregateOperation: AGGREGATE_OPERATIONS.avg, aggregateOperation: AGGREGATE_OPERATIONS.avg,
}; };

View File

@ -1,18 +1,18 @@
import { formatColumnNameFromCompositeFieldAndSubfield } from 'src/engine/twenty-orm/utils/format-column-name-from-composite-field-and-subfield.util'; import { formatColumnNamesFromCompositeFieldAndSubfields } from 'src/engine/twenty-orm/utils/format-column-names-from-composite-field-and-subfield.util';
describe('formatColumnNameFromCompositeFieldAndSubfield', () => { describe('formatColumnNamesFromCompositeFieldAndSubfields', () => {
it('should return fieldName when subFieldName is not defined', () => { it('should return fieldName when subFieldName is not defined', () => {
const result = formatColumnNameFromCompositeFieldAndSubfield('firstName'); const result = formatColumnNamesFromCompositeFieldAndSubfields('firstName');
expect(result).toBe('firstName'); expect(result).toEqual(['firstName']);
}); });
it('should return concatenated fieldName and capitalized subFieldName when subFieldName is defined', () => { it('should return concatenated fieldName and capitalized subFieldName when subFieldName is defined', () => {
const result = formatColumnNameFromCompositeFieldAndSubfield( const result = formatColumnNamesFromCompositeFieldAndSubfields('user', [
'user',
'firstName', 'firstName',
); 'lastName',
]);
expect(result).toBe('userFirstName'); expect(result).toEqual(['userFirstName', 'userLastName']);
}); });
}); });

View File

@ -1,13 +0,0 @@
import { capitalize } from 'src/utils/capitalize';
import { isDefined } from 'src/utils/is-defined';
export const formatColumnNameFromCompositeFieldAndSubfield = (
fieldName: string,
subFieldName?: string,
): string => {
if (isDefined(subFieldName)) {
return `${fieldName}${capitalize(subFieldName)}`;
}
return fieldName;
};

View File

@ -0,0 +1,15 @@
import { capitalize } from 'src/utils/capitalize';
import { isDefined } from 'src/utils/is-defined';
export const formatColumnNamesFromCompositeFieldAndSubfields = (
fieldName: string,
subFieldNames?: string[],
): string[] => {
if (isDefined(subFieldNames)) {
return subFieldNames.map(
(subFieldName) => `${fieldName}${capitalize(subFieldName)}`,
);
}
return [fieldName];
};

View File

@ -1,15 +0,0 @@
import { FieldMetadataType } from 'src/types/FieldMetadataType';
import { getSubfieldForAggregateOperation } from 'src/utils/aggregateOperations/getSubFieldForAggregateOperation.util';
import { isCompositeFieldMetadataType } from 'src/utils/aggregateOperations/isCompositeFieldMetadataType.util';
import { capitalize } from 'src/utils/strings/capitalize';
export const getColumnNameForAggregateOperation = (
fieldName: string,
fieldType: FieldMetadataType,
) => {
if (!isCompositeFieldMetadataType(fieldType)) {
return fieldName;
}
return `${fieldName}${capitalize(getSubfieldForAggregateOperation(fieldType) as string)}`;
};

View File

@ -1,27 +1,40 @@
import { FieldMetadataType } from 'src/types/FieldMetadataType'; import { FieldMetadataType } from 'src/types/FieldMetadataType';
import { isCompositeFieldMetadataType } from 'src/utils/aggregateOperations/isCompositeFieldMetadataType.util'; import { isCompositeFieldMetadataType } from 'src/utils/aggregateOperations/isCompositeFieldMetadataType.util';
export const getSubfieldForAggregateOperation = ( export const getSubfieldsForAggregateOperation = (
fieldType: FieldMetadataType, fieldType: FieldMetadataType,
) => { ): string[] | undefined => {
if (!isCompositeFieldMetadataType(fieldType)) { if (!isCompositeFieldMetadataType(fieldType)) {
return undefined; return undefined;
} else { } else {
switch (fieldType) { switch (fieldType) {
case FieldMetadataType.CURRENCY: case FieldMetadataType.CURRENCY:
return 'amountMicros'; return ['amountMicros', 'currencyCode'];
case FieldMetadataType.FULL_NAME: case FieldMetadataType.FULL_NAME:
return 'lastName'; return ['firstName', 'lastName'];
case FieldMetadataType.ADDRESS: case FieldMetadataType.ADDRESS:
return 'addressStreet1'; return [
'addressStreet1',
'addressStreet2',
'addressCity',
'addressPostcode',
'addressState',
'addressCountry',
'addressLat',
'addressLng',
];
case FieldMetadataType.LINKS: case FieldMetadataType.LINKS:
return 'primaryLinkLabel'; return ['primaryLinkUrl'];
case FieldMetadataType.ACTOR: case FieldMetadataType.ACTOR:
return 'workspaceMemberId'; return ['workspaceMemberId'];
case FieldMetadataType.EMAILS: case FieldMetadataType.EMAILS:
return 'primaryEmail'; return ['primaryEmail'];
case FieldMetadataType.PHONES: case FieldMetadataType.PHONES:
return 'primaryPhoneNumber'; return [
'primaryPhoneNumber',
'primaryPhoneCountryCode',
'primaryPhoneCallingCode',
];
default: default:
throw new Error(`Unsupported composite field type: ${fieldType}`); throw new Error(`Unsupported composite field type: ${fieldType}`);
} }

View File

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