Add count and percent aggregations to kanban headers (#9348)
Closes https://github.com/twentyhq/private-issues/issues/226 https://github.com/user-attachments/assets/cee78080-6dda-4102-9595-d32971cf9104
This commit is contained in:
@ -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,
|
||||||
|
|||||||
@ -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',
|
||||||
|
|||||||
@ -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,
|
||||||
}}
|
}}
|
||||||
|
|||||||
@ -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:
|
||||||
|
|||||||
@ -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;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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={() => {
|
||||||
|
|||||||
@ -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>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@ -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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -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,
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -2,4 +2,5 @@ export type RecordBoardColumnHeaderAggregateContentId =
|
|||||||
| 'aggregateOperations'
|
| 'aggregateOperations'
|
||||||
| 'aggregateFields'
|
| 'aggregateFields'
|
||||||
| 'countAggregateOperationsOptions'
|
| 'countAggregateOperationsOptions'
|
||||||
|
| 'percentAggregateOperationsOptions'
|
||||||
| 'moreAggregateOperationOptions';
|
| 'moreAggregateOperationOptions';
|
||||||
|
|||||||
@ -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,
|
||||||
@ -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,
|
||||||
|
];
|
||||||
@ -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,
|
||||||
|
|||||||
@ -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[];
|
||||||
};
|
};
|
||||||
|
|||||||
@ -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]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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],
|
||||||
|
|||||||
@ -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);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -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]: [],
|
||||||
{},
|
}),
|
||||||
);
|
{},
|
||||||
};
|
);
|
||||||
|
};
|
||||||
|
|||||||
@ -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);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -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}`,
|
||||||
);
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@ -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,
|
||||||
};
|
};
|
||||||
|
|||||||
@ -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']);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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;
|
|
||||||
};
|
|
||||||
@ -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];
|
||||||
|
};
|
||||||
@ -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)}`;
|
|
||||||
};
|
|
||||||
@ -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}`);
|
||||||
}
|
}
|
||||||
@ -1,3 +1 @@
|
|||||||
export * from './getColumnNameForAggregateOperation.util';
|
export * from './getSubFieldsForAggregateOperation.util';
|
||||||
export * from './getSubFieldForAggregateOperation.util';
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user