Display and update aggregate queries in kanban views (#8833)

Closes #8752, #8753, #8754

Implements usage of aggregate queries in kanban views.

https://github.com/user-attachments/assets/732590ca-2785-4c57-82d5-d999a2279e92

TO DO

1. write tests + storybook
2. Fix values displayed should have the same format as defined in number
fields + Fix display for amountMicros

---------

Co-authored-by: Weiko <corentin@twenty.com>
This commit is contained in:
Marie 2024-12-03 22:46:57 +01:00 committed by GitHub
parent 5e891a135b
commit 2fc247cb21
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
67 changed files with 1670 additions and 104 deletions

View File

@ -0,0 +1 @@
export const DROPDOWN_OFFSET_Y = 8;

View File

@ -0,0 +1 @@
export const DROPDOWN_WIDTH = '200px';

View File

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

View File

@ -0,0 +1,35 @@
import { ObjectOptionsDropdownContextValue } from '@/object-record/object-options-dropdown/states/contexts/ObjectOptionsDropdownContext';
import { RecordBoardColumnHeaderAggregateDropdownContextValue } from '@/object-record/record-board/record-board-column/components/RecordBoardColumnHeaderAggregateDropdownContext';
import { useDropdown as useDropdownUi } from '@/ui/layout/dropdown/hooks/useDropdown';
import { Context, useCallback, useContext } from 'react';
export const useDropdown = <
T extends
| RecordBoardColumnHeaderAggregateDropdownContextValue
| ObjectOptionsDropdownContextValue,
>({
context,
}: {
context: Context<T>;
}) => {
const dropdownContext = useContext(context);
if (!dropdownContext) {
throw new Error(
`useDropdown must be used within a context provider (${context.Provider.name})`,
);
}
const dropdownId = dropdownContext.dropdownId;
const { closeDropdown } = useDropdownUi(dropdownId);
const handleCloseDropdown = useCallback(() => {
dropdownContext.resetContent();
closeDropdown();
}, [closeDropdown, dropdownContext]);
return {
...dropdownContext,
closeDropdown: handleCloseDropdown,
resetContent: dropdownContext.resetContent,
};
};

View File

@ -14,4 +14,5 @@ export type RecordGqlConnection = {
totalCount?: number;
};
totalCount?: number;
[aggregateFieldName: string]: any;
};

View File

@ -0,0 +1,3 @@
import { AGGREGATE_OPERATIONS } from '@/object-record/record-table/constants/AggregateOperations';
export type RecordGqlFieldsAggregate = Record<string, AGGREGATE_OPERATIONS>;

View File

@ -0,0 +1,70 @@
import { useQuery } from '@apollo/client';
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
import { RecordGqlFieldsAggregate } from '@/object-record/graphql/types/RecordGqlFieldsAggregate';
import { RecordGqlOperationFilter } from '@/object-record/graphql/types/RecordGqlOperationFilter';
import { RecordGqlOperationFindManyResult } from '@/object-record/graphql/types/RecordGqlOperationFindManyResult';
import { useAggregateManyRecordsQuery } from '@/object-record/hooks/useAggregateManyRecordsQuery';
import { AGGREGATE_OPERATIONS } from '@/object-record/record-table/constants/AggregateOperations';
import isEmpty from 'lodash.isempty';
import { isDefined } from 'twenty-ui';
export type AggregateManyRecordsData = {
[fieldName: string]: {
[operation in AGGREGATE_OPERATIONS]?: string | number | undefined;
};
};
export const useAggregateManyRecords = ({
objectNameSingular,
filter,
recordGqlFieldsAggregate,
skip,
}: {
objectNameSingular: string;
recordGqlFieldsAggregate: RecordGqlFieldsAggregate;
filter?: RecordGqlOperationFilter;
skip?: boolean;
}) => {
const { objectMetadataItem } = useObjectMetadataItem({
objectNameSingular,
});
const { aggregateQuery, gqlFieldToFieldMap } = useAggregateManyRecordsQuery({
objectNameSingular,
recordGqlFieldsAggregate,
});
const { data, loading, error } = useQuery<RecordGqlOperationFindManyResult>(
aggregateQuery,
{
skip: skip || !objectMetadataItem,
variables: {
filter,
},
},
);
const formattedData: AggregateManyRecordsData = {};
if (!isEmpty(data)) {
Object.entries(data?.[objectMetadataItem.namePlural] ?? {})?.forEach(
([gqlField, result]) => {
if (isDefined(gqlFieldToFieldMap[gqlField])) {
const [fieldName, aggregateOperation] = gqlFieldToFieldMap[gqlField];
formattedData[fieldName] = {
...(formattedData[fieldName] ?? {}),
[aggregateOperation]: result,
};
}
},
);
}
return {
objectMetadataItem,
data: formattedData,
loading,
error,
};
};

View File

@ -0,0 +1,69 @@
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
import { RecordGqlFields } from '@/object-record/graphql/types/RecordGqlFields';
import { RecordGqlFieldsAggregate } from '@/object-record/graphql/types/RecordGqlFieldsAggregate';
import { AGGREGATE_OPERATIONS } from '@/object-record/record-table/constants/AggregateOperations';
import { generateAggregateQuery } from '@/object-record/utils/generateAggregateQuery';
import { getAvailableAggregationsFromObjectFields } from '@/object-record/utils/getAvailableAggregationsFromObjectFields';
import { useMemo } from 'react';
import { isDefined } from 'twenty-ui';
export type GqlFieldToFieldMap = {
[gqlField: string]: [
fieldName: string,
aggregateOperation: AGGREGATE_OPERATIONS,
];
};
export const useAggregateManyRecordsQuery = ({
objectNameSingular,
recordGqlFieldsAggregate = {},
}: {
objectNameSingular: string;
recordGqlFieldsAggregate: RecordGqlFieldsAggregate;
}) => {
const { objectMetadataItem } = useObjectMetadataItem({
objectNameSingular,
});
const availableAggregations = useMemo(
() => getAvailableAggregationsFromObjectFields(objectMetadataItem.fields),
[objectMetadataItem.fields],
);
const recordGqlFields: RecordGqlFields = {};
const gqlFieldToFieldMap: GqlFieldToFieldMap = {};
Object.entries(recordGqlFieldsAggregate).forEach(
([fieldName, aggregateOperation]) => {
if (
!isDefined(fieldName) &&
aggregateOperation === AGGREGATE_OPERATIONS.count
) {
recordGqlFields.totalCount = true;
return;
}
const fieldToQuery =
availableAggregations[fieldName]?.[aggregateOperation];
if (!isDefined(fieldToQuery)) {
throw new Error(
`Cannot query operation ${aggregateOperation} on field ${fieldName}`,
);
}
gqlFieldToFieldMap[fieldToQuery] = [fieldName, aggregateOperation];
recordGqlFields[fieldToQuery] = true;
},
);
const aggregateQuery = generateAggregateQuery({
objectMetadataItem,
recordGqlFields,
});
return {
aggregateQuery,
gqlFieldToFieldMap,
};
};

View File

@ -1,13 +1,15 @@
import { DROPDOWN_OFFSET_Y } from '@/dropdown/constants/DropdownOffsetY';
import { DROPDOWN_WIDTH } from '@/dropdown/constants/DropdownWidth';
import { useCurrentContentId } from '@/dropdown/hooks/useCurrentContentId';
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { ObjectOptionsDropdownButton } from '@/object-record/object-options-dropdown/components/ObjectOptionsDropdownButton';
import { ObjectOptionsDropdownContent } from '@/object-record/object-options-dropdown/components/ObjectOptionsDropdownContent';
import { OBJECT_OPTIONS_DROPDOWN_ID } from '@/object-record/object-options-dropdown/constants/ObjectOptionsDropdownId';
import { ObjectOptionsDropdownContext } from '@/object-record/object-options-dropdown/states/contexts/ObjectOptionsDropdownContext';
import { ObjectOptionsContentId } from '@/object-record/object-options-dropdown/types/ObjectOptionsContentId';
import { TableOptionsHotkeyScope } from '@/object-record/record-table/types/TableOptionsHotkeyScope';
import { Dropdown } from '@/ui/layout/dropdown/components/Dropdown';
import { StyledHeaderDropdownButton } from '@/ui/layout/dropdown/components/StyledHeaderDropdownButton';
import { ViewType } from '@/views/types/ViewType';
import { useCallback, useState } from 'react';
type ObjectOptionsDropdownProps = {
viewType: ViewType;
@ -20,24 +22,18 @@ export const ObjectOptionsDropdown = ({
objectMetadataItem,
viewType,
}: ObjectOptionsDropdownProps) => {
const [currentContentId, setCurrentContentId] =
useState<ObjectOptionsContentId | null>(null);
const handleContentChange = useCallback((key: ObjectOptionsContentId) => {
setCurrentContentId(key);
}, []);
const handleResetContent = useCallback(() => {
setCurrentContentId(null);
}, []);
const { currentContentId, handleContentChange, handleResetContent } =
useCurrentContentId<ObjectOptionsContentId>();
return (
<Dropdown
dropdownId={OBJECT_OPTIONS_DROPDOWN_ID}
clickableComponent={<ObjectOptionsDropdownButton />}
dropdownMenuWidth={'200px'}
dropdownHotkeyScope={{ scope: TableOptionsHotkeyScope.Dropdown }}
dropdownOffset={{ y: 8 }}
dropdownMenuWidth={DROPDOWN_WIDTH}
dropdownOffset={{ y: DROPDOWN_OFFSET_Y }}
clickableComponent={
<StyledHeaderDropdownButton>Options</StyledHeaderDropdownButton>
}
dropdownComponents={
<ObjectOptionsDropdownContext.Provider
value={{
@ -47,6 +43,7 @@ export const ObjectOptionsDropdown = ({
currentContentId,
onContentChange: handleContentChange,
resetContent: handleResetContent,
dropdownId: OBJECT_OPTIONS_DROPDOWN_ID,
}}
>
<ObjectOptionsDropdownContent />

View File

@ -1,18 +0,0 @@
import { OBJECT_OPTIONS_DROPDOWN_ID } from '@/object-record/object-options-dropdown/constants/ObjectOptionsDropdownId';
import { StyledHeaderDropdownButton } from '@/ui/layout/dropdown/components/StyledHeaderDropdownButton';
import { useDropdown } from '@/ui/layout/dropdown/hooks/useDropdown';
export const ObjectOptionsDropdownButton = () => {
const { isDropdownOpen, toggleDropdown } = useDropdown(
OBJECT_OPTIONS_DROPDOWN_ID,
);
return (
<StyledHeaderDropdownButton
isUnfolded={isDropdownOpen}
onClick={toggleDropdown}
>
Options
</StyledHeaderDropdownButton>
);
};

View File

@ -4,6 +4,7 @@ import { ComponentDecorator } from 'twenty-ui';
import { ContextStoreComponentInstanceContext } from '@/context-store/states/contexts/ContextStoreComponentInstanceContext';
import { objectMetadataItemsState } from '@/object-metadata/states/objectMetadataItemsState';
import { ObjectOptionsDropdownContent } from '@/object-record/object-options-dropdown/components/ObjectOptionsDropdownContent';
import { OBJECT_OPTIONS_DROPDOWN_ID } from '@/object-record/object-options-dropdown/constants/ObjectOptionsDropdownId';
import { ObjectOptionsDropdownContext } from '@/object-record/object-options-dropdown/states/contexts/ObjectOptionsDropdownContext';
import { ObjectOptionsContentId } from '@/object-record/object-options-dropdown/types/ObjectOptionsContentId';
import { RecordIndexRootPropsContext } from '@/object-record/record-index/contexts/RecordIndexRootPropsContext';
@ -94,6 +95,7 @@ const createStory = (contentId: ObjectOptionsContentId | null): Story => ({
currentContentId: contentId,
onContentChange: () => {},
resetContent: () => {},
dropdownId: OBJECT_OPTIONS_DROPDOWN_ID,
}}
>
<DropdownMenu>

View File

@ -2,6 +2,7 @@ import { renderHook } from '@testing-library/react';
import { act } from 'react';
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { OBJECT_OPTIONS_DROPDOWN_ID } from '@/object-record/object-options-dropdown/constants/ObjectOptionsDropdownId';
import { useOptionsDropdown } from '@/object-record/object-options-dropdown/hooks/useOptionsDropdown';
import { ObjectOptionsDropdownContext } from '@/object-record/object-options-dropdown/states/contexts/ObjectOptionsDropdownContext';
import { useDropdown } from '@/ui/layout/dropdown/hooks/useDropdown';
@ -55,6 +56,7 @@ describe('useOptionsDropdown', () => {
currentContentId: 'recordGroups',
onContentChange: mockOnContentChange,
resetContent: mockResetContent,
dropdownId: OBJECT_OPTIONS_DROPDOWN_ID,
...contextValue,
}}
>

View File

@ -1,26 +1,20 @@
import { OBJECT_OPTIONS_DROPDOWN_ID } from '@/object-record/object-options-dropdown/constants/ObjectOptionsDropdownId';
import { useDropdown } from '@/dropdown/hooks/useDropdown';
import { ObjectOptionsDropdownContext } from '@/object-record/object-options-dropdown/states/contexts/ObjectOptionsDropdownContext';
import { useDropdown } from '@/ui/layout/dropdown/hooks/useDropdown';
import { useCallback, useContext } from 'react';
import { useContext } from 'react';
export const useOptionsDropdown = () => {
const { closeDropdown } = useDropdown(OBJECT_OPTIONS_DROPDOWN_ID);
const context = useContext(ObjectOptionsDropdownContext);
if (!context) {
throw new Error(
'useOptionsDropdown must be used within a ObjectOptionsDropdownContext.Provider',
);
throw new Error('useOptionsDropdown must be used within a context');
}
const handleCloseDropdown = useCallback(() => {
context.resetContent();
closeDropdown();
}, [closeDropdown, context]);
const { closeDropdown } = useDropdown({
context: ObjectOptionsDropdownContext,
});
return {
...context,
closeDropdown: handleCloseDropdown,
closeDropdown,
};
};

View File

@ -10,6 +10,7 @@ export type ObjectOptionsDropdownContextValue = {
currentContentId: ObjectOptionsContentId | null;
onContentChange: (key: ObjectOptionsContentId) => void;
resetContent: () => void;
dropdownId: string;
};
export const ObjectOptionsDropdownContext =

View File

@ -0,0 +1,4 @@
import { createComponentInstanceContext } from '@/ui/utilities/state/component-state/utils/createComponentInstanceContext';
export const RecordBoardColumnHeaderAggregateDropdownComponentInstanceContext =
createComponentInstanceContext();

View File

@ -4,12 +4,15 @@ import { useContext, useState } from 'react';
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
import { RecordBoardContext } from '@/object-record/record-board/contexts/RecordBoardContext';
import { RecordBoardColumnDropdownMenu } from '@/object-record/record-board/record-board-column/components/RecordBoardColumnDropdownMenu';
import { RecordBoardColumnHeaderAggregateDropdown } from '@/object-record/record-board/record-board-column/components/RecordBoardColumnHeaderAggregateDropdown';
import { RecordBoardColumnContext } from '@/object-record/record-board/record-board-column/contexts/RecordBoardColumnContext';
import { useAggregateManyRecordsForRecordBoardColumn } from '@/object-record/record-board/record-board-column/hooks/useAggregateManyRecordsForRecordBoardColumn';
import { useColumnNewCardActions } from '@/object-record/record-board/record-board-column/hooks/useColumnNewCardActions';
import { useIsOpportunitiesCompanyFieldDisabled } from '@/object-record/record-board/record-board-column/hooks/useIsOpportunitiesCompanyFieldDisabled';
import { RecordBoardColumnHotkeyScope } from '@/object-record/record-board/types/BoardColumnHotkeyScope';
import { RecordGroupDefinitionType } from '@/object-record/record-group/types/RecordGroupDefinition';
import { usePreviousHotkeyScope } from '@/ui/utilities/hotkey/hooks/usePreviousHotkeyScope';
import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
import { IconDotsVertical, IconPlus, LightIconButton, Tag } from 'twenty-ui';
const StyledHeader = styled.div`
@ -19,21 +22,7 @@ const StyledHeader = styled.div`
flex-direction: row;
justify-content: left;
width: 100%;
`;
const StyledAmount = styled.div`
color: ${({ theme }) => theme.font.color.tertiary};
margin-left: ${({ theme }) => theme.spacing(2)};
`;
const StyledNumChildren = styled.div`
align-items: center;
color: ${({ theme }) => theme.font.color.tertiary};
display: flex;
height: 24px;
justify-content: center;
line-height: ${({ theme }) => theme.text.lineHeight.lg};
width: 22px;
height: 100%;
`;
const StyledHeaderActions = styled.div`
@ -52,6 +41,16 @@ const StyledLeftContainer = styled.div`
gap: ${({ theme }) => theme.spacing(1)};
`;
const StyledRecordCountChildren = styled.div`
align-items: center;
color: ${({ theme }) => theme.font.color.tertiary};
display: flex;
height: 24px;
justify-content: center;
line-height: ${({ theme }) => theme.text.lineHeight.lg};
width: 22px;
`;
const StyledRightContainer = styled.div`
align-items: center;
display: flex;
@ -70,9 +69,7 @@ const StyledColumn = styled.div`
`;
export const RecordBoardColumnHeader = () => {
const { columnDefinition, recordCount } = useContext(
RecordBoardColumnContext,
);
const { columnDefinition } = useContext(RecordBoardColumnContext);
const [isBoardColumnMenuOpen, setIsBoardColumnMenuOpen] = useState(false);
const [isHeaderHovered, setIsHeaderHovered] = useState(false);
@ -98,10 +95,15 @@ export const RecordBoardColumnHeader = () => {
setIsBoardColumnMenuOpen(false);
};
const boardColumnTotal = 0;
const { aggregateValue, aggregateLabel } =
useAggregateManyRecordsForRecordBoardColumn();
const { handleNewButtonClick } = useColumnNewCardActions(
columnDefinition?.id ?? '',
columnDefinition.id ?? '',
);
const isAggregateQueryEnabled = useIsFeatureEnabled(
'IS_AGGREGATE_QUERY_ENABLED',
);
const { isOpportunitiesCompanyFieldDisabled } =
@ -138,10 +140,18 @@ export const RecordBoardColumnHeader = () => {
: 'medium'
}
/>
{!!boardColumnTotal && (
<StyledAmount>${boardColumnTotal}</StyledAmount>
{isAggregateQueryEnabled ? (
<RecordBoardColumnHeaderAggregateDropdown
aggregateValue={aggregateValue}
dropdownId={`record-board-column-aggregate-dropdown-${columnDefinition.id}`}
objectMetadataItem={objectMetadataItem}
aggregateLabel={aggregateLabel}
/>
) : (
<StyledRecordCountChildren>
{aggregateValue}
</StyledRecordCountChildren>
)}
<StyledNumChildren>{recordCount}</StyledNumChildren>
</StyledLeftContainer>
<StyledRightContainer>
{isHeaderHovered && (

View File

@ -0,0 +1,63 @@
import { DROPDOWN_OFFSET_Y } from '@/dropdown/constants/DropdownOffsetY';
import { DROPDOWN_WIDTH } from '@/dropdown/constants/DropdownWidth';
import { useCurrentContentId } from '@/dropdown/hooks/useCurrentContentId';
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { RecordBoardColumnHeaderAggregateDropdownComponentInstanceContext } from '@/object-record/record-board/contexts/RecordBoardColumnHeaderAggregateDropdownComponentInstanceContext';
import { RecordBoardColumnHeaderAggregateDropdownButton } from '@/object-record/record-board/record-board-column/components/RecordBoardColumnHeaderAggregateDropdownButton';
import { AggregateDropdownContent } from '@/object-record/record-board/record-board-column/components/RecordBoardColumnHeaderAggregateDropdownContent';
import { RecordBoardColumnHeaderAggregateDropdownContext } from '@/object-record/record-board/record-board-column/components/RecordBoardColumnHeaderAggregateDropdownContext';
import { AggregateContentId } from '@/object-record/record-board/types/AggregateContentId';
import { RecordBoardColumnHotkeyScope } from '@/object-record/record-board/types/BoardColumnHotkeyScope';
import { Dropdown } from '@/ui/layout/dropdown/components/Dropdown';
type RecordBoardColumnHeaderAggregateDropdownProps = {
aggregateValue: string | number;
aggregateLabel?: string;
objectMetadataItem: ObjectMetadataItem;
dropdownId: string;
};
export const RecordBoardColumnHeaderAggregateDropdown = ({
objectMetadataItem,
aggregateValue,
aggregateLabel,
dropdownId,
}: RecordBoardColumnHeaderAggregateDropdownProps) => {
const { currentContentId, handleContentChange, handleResetContent } =
useCurrentContentId<AggregateContentId>();
return (
<RecordBoardColumnHeaderAggregateDropdownComponentInstanceContext.Provider
value={{ instanceId: dropdownId }}
>
<Dropdown
dropdownId={dropdownId}
dropdownHotkeyScope={{
scope: RecordBoardColumnHotkeyScope.ColumnHeader,
}}
dropdownMenuWidth={DROPDOWN_WIDTH}
dropdownOffset={{ y: DROPDOWN_OFFSET_Y }}
clickableComponent={
<RecordBoardColumnHeaderAggregateDropdownButton
dropdownId={dropdownId}
value={aggregateValue}
tooltip={aggregateLabel}
/>
}
dropdownComponents={
<RecordBoardColumnHeaderAggregateDropdownContext.Provider
value={{
currentContentId,
onContentChange: handleContentChange,
resetContent: handleResetContent,
objectMetadataItem: objectMetadataItem,
dropdownId: dropdownId,
}}
>
<AggregateDropdownContent />
</RecordBoardColumnHeaderAggregateDropdownContext.Provider>
}
/>
</RecordBoardColumnHeaderAggregateDropdownComponentInstanceContext.Provider>
);
};

View File

@ -0,0 +1,34 @@
import { StyledHeaderDropdownButton } from '@/ui/layout/dropdown/components/StyledHeaderDropdownButton';
import styled from '@emotion/styled';
import { AppTooltip, Tag, TooltipDelay } from 'twenty-ui';
import { formatNumber } from '~/utils/format/number';
const StyledButton = styled(StyledHeaderDropdownButton)`
padding: 0;
`;
export const RecordBoardColumnHeaderAggregateDropdownButton = ({
dropdownId,
value,
tooltip,
}: {
dropdownId: string;
value: string | number;
tooltip?: string;
}) => {
return (
<div id={dropdownId}>
<StyledButton>
<Tag text={formatNumber(Number(value))} color={'transparent'} />
<AppTooltip
anchorSelect={`#${dropdownId}`}
content={tooltip}
noArrow
place="right"
positionStrategy="fixed"
delay={TooltipDelay.shortDelay}
/>
</StyledButton>
</div>
);
};

View File

@ -0,0 +1,17 @@
import { useDropdown } from '@/dropdown/hooks/useDropdown';
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 { RecordBoardColumnHeaderAggregateDropdownMenuContent } from '@/object-record/record-board/record-board-column/components/RecordBoardColumnHeaderAggregateDropdownMenuContent';
export const AggregateDropdownContent = () => {
const { currentContentId } = useDropdown({
context: RecordBoardColumnHeaderAggregateDropdownContext,
});
switch (currentContentId) {
case 'aggregateFields':
return <RecordBoardColumnHeaderAggregateDropdownFieldsContent />;
default:
return <RecordBoardColumnHeaderAggregateDropdownMenuContent />;
}
};

View File

@ -0,0 +1,16 @@
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { AggregateContentId } from '@/object-record/record-board/types/AggregateContentId';
import { createContext } from 'react';
export type RecordBoardColumnHeaderAggregateDropdownContextValue = {
objectMetadataItem: ObjectMetadataItem;
currentContentId: AggregateContentId | null;
onContentChange: (key: AggregateContentId) => void;
resetContent: () => void;
dropdownId: string;
};
export const RecordBoardColumnHeaderAggregateDropdownContext =
createContext<RecordBoardColumnHeaderAggregateDropdownContextValue>(
{} as RecordBoardColumnHeaderAggregateDropdownContextValue,
);

View File

@ -0,0 +1,62 @@
import { useDropdown } from '@/dropdown/hooks/useDropdown';
import { RecordBoardColumnHeaderAggregateDropdownContext } from '@/object-record/record-board/record-board-column/components/RecordBoardColumnHeaderAggregateDropdownContext';
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 { DropdownMenuHeader } from '@/ui/layout/dropdown/components/DropdownMenuHeader';
import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer';
import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2';
import { useUpdateViewAggregate } from '@/views/hooks/useUpdateViewAggregate';
import { Icon123, IconChevronLeft, MenuItem, useIcons } from 'twenty-ui';
import { isDefined } from '~/utils/isDefined';
export const RecordBoardColumnHeaderAggregateDropdownFieldsContent = () => {
const { closeDropdown, resetContent, objectMetadataItem } = useDropdown({
context: RecordBoardColumnHeaderAggregateDropdownContext,
});
const { updateViewAggregate } = useUpdateViewAggregate();
const { getIcon } = useIcons();
const aggregateOperation = useRecoilComponentValueV2(
aggregateOperationComponentState,
);
const availableFieldsIdsForAggregateOperation = useRecoilComponentValueV2(
availableFieldIdsForAggregateOperationComponentState,
);
if (!isDefined(aggregateOperation)) return <></>;
return (
<>
<DropdownMenuHeader StartIcon={IconChevronLeft} onClick={resetContent}>
{getAggregateOperationLabel(aggregateOperation)}
</DropdownMenuHeader>
{availableFieldsIdsForAggregateOperation.map((fieldId) => {
const fieldMetadata = objectMetadataItem.fields.find(
(field) => field.id === fieldId,
);
if (!fieldMetadata) return null;
return (
<DropdownMenuItemsContainer>
<MenuItem
key={fieldId}
onClick={() => {
updateViewAggregate({
kanbanAggregateOperationFieldMetadataId: fieldId,
kanbanAggregateOperation: aggregateOperation,
});
closeDropdown();
}}
LeftIcon={getIcon(fieldMetadata.icon) ?? Icon123}
text={fieldMetadata.label}
/>
</DropdownMenuItemsContainer>
);
})}
</>
);
};

View File

@ -0,0 +1,101 @@
import { Key } from 'ts-key-enum';
import { MenuItem } from 'twenty-ui';
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 { 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 { useMemo } from 'react';
export const RecordBoardColumnHeaderAggregateDropdownMenuContent = () => {
const { objectMetadataItem, onContentChange, closeDropdown } =
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,
);
const { updateViewAggregate } = useUpdateViewAggregate();
return (
<>
<DropdownMenuItemsContainer>
<MenuItem
onClick={() => {
updateViewAggregate({
kanbanAggregateOperationFieldMetadataId: null,
kanbanAggregateOperation: AGGREGATE_OPERATIONS.count,
});
}}
text={getAggregateOperationLabel(AGGREGATE_OPERATIONS.count)}
/>
</DropdownMenuItemsContainer>
{Object.entries(availableAggregations).map(
([
availableAggregationOperation,
availableAggregationFieldsIdsForOperation,
]) =>
isEmpty(availableAggregationFieldsIdsForOperation) ? (
<></>
) : (
<DropdownMenuItemsContainer
key={`aggregate-dropdown-menu-content-${availableAggregationOperation}`}
>
<RecordBoardColumnHeaderAggregateDropdownMenuItem
onContentChange={() => {
setAggregateOperation(
availableAggregationOperation as AGGREGATE_OPERATIONS,
);
setAvailableFieldsForAggregateOperation(
availableAggregationFieldsIdsForOperation,
);
onContentChange('aggregateFields');
}}
text={getAggregateOperationLabel(
availableAggregationOperation as AGGREGATE_OPERATIONS,
)}
hasSubMenu
/>
</DropdownMenuItemsContainer>
),
)}
</>
);
};

View File

@ -0,0 +1,15 @@
import { MenuItem } from 'twenty-ui';
export const RecordBoardColumnHeaderAggregateDropdownMenuItem = ({
onContentChange,
text,
hasSubMenu,
}: {
onContentChange: () => void;
hasSubMenu: boolean;
text: string;
}) => {
return (
<MenuItem onClick={onContentChange} text={text} hasSubMenu={hasSubMenu} />
);
};

View File

@ -0,0 +1 @@
export const AGGREGATE_DROPDOWN_ID = 'aggregate-dropdown-id';

View File

@ -0,0 +1,88 @@
import { useAggregateManyRecords } from '@/object-record/hooks/useAggregateManyRecords';
import { RecordBoardContext } from '@/object-record/record-board/contexts/RecordBoardContext';
import { RecordBoardColumnContext } from '@/object-record/record-board/record-board-column/contexts/RecordBoardColumnContext';
import { buildRecordGqlFieldsAggregate } from '@/object-record/record-board/record-board-column/utils/buildRecordGqlFieldsAggregate';
import { computeAggregateValueAndLabel } from '@/object-record/record-board/record-board-column/utils/computeAggregateValueAndLabel';
import { computeViewRecordGqlOperationFilter } from '@/object-record/record-filter/utils/computeViewRecordGqlOperationFilter';
import { recordIndexFiltersState } from '@/object-record/record-index/states/recordIndexFiltersState';
import { recordIndexKanbanAggregateOperationState } from '@/object-record/record-index/states/recordIndexKanbanAggregateOperationState';
import { recordIndexKanbanFieldMetadataIdState } from '@/object-record/record-index/states/recordIndexKanbanFieldMetadataIdState';
import { recordIndexViewFilterGroupsState } from '@/object-record/record-index/states/recordIndexViewFilterGroupsState';
import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
import { useContext } from 'react';
import { useRecoilValue } from 'recoil';
import { isDefined } from '~/utils/isDefined';
export const useAggregateManyRecordsForRecordBoardColumn = () => {
const isAggregateQueryEnabled = useIsFeatureEnabled(
'IS_AGGREGATE_QUERY_ENABLED',
);
const { columnDefinition, recordCount } = useContext(
RecordBoardColumnContext,
);
const { objectMetadataItem } = useContext(RecordBoardContext);
const recordIndexKanbanAggregateOperation = useRecoilValue(
recordIndexKanbanAggregateOperationState,
);
const recordIndexKanbanFieldMetadataId = useRecoilValue(
recordIndexKanbanFieldMetadataIdState,
);
const kanbanFieldName = objectMetadataItem.fields?.find(
(field) => field.id === recordIndexKanbanFieldMetadataId,
)?.name;
if (!isDefined(kanbanFieldName)) {
throw new Error(
`Field name is not found for field with id ${recordIndexKanbanFieldMetadataId} on object ${objectMetadataItem.nameSingular}`,
);
}
const recordGqlFieldsAggregate = buildRecordGqlFieldsAggregate({
objectMetadataItem: objectMetadataItem,
recordIndexKanbanAggregateOperation: recordIndexKanbanAggregateOperation,
kanbanFieldName: kanbanFieldName,
});
const recordIndexViewFilterGroups = useRecoilValue(
recordIndexViewFilterGroupsState,
);
const recordIndexFilters = useRecoilValue(recordIndexFiltersState);
const requestFilters = computeViewRecordGqlOperationFilter(
recordIndexFilters,
objectMetadataItem.fields,
recordIndexViewFilterGroups,
);
const filter = {
...requestFilters,
[kanbanFieldName]:
columnDefinition.value === null
? { is: 'NULL' }
: { eq: columnDefinition.value },
};
const { data } = useAggregateManyRecords({
objectNameSingular: objectMetadataItem.nameSingular,
recordGqlFieldsAggregate,
filter,
skip: !isAggregateQueryEnabled,
});
const { value, label } = computeAggregateValueAndLabel(
data,
objectMetadataItem,
recordIndexKanbanAggregateOperation,
kanbanFieldName,
);
return {
aggregateValue: value ?? recordCount,
aggregateLabel: isDefined(value) ? label : undefined,
};
};

View File

@ -0,0 +1,15 @@
import { AGGREGATE_OPERATIONS } from '@/object-record/record-table/constants/AggregateOperations';
import { createState } from 'twenty-ui';
type AggregateOperation = {
operation: AGGREGATE_OPERATIONS | null;
availableFieldIdsForOperation: string[];
};
export const aggregateDropdownState = createState<AggregateOperation>({
key: 'aggregateDropdownState',
defaultValue: {
operation: null,
availableFieldIdsForOperation: [],
},
});

View File

@ -0,0 +1,11 @@
import { RecordBoardColumnHeaderAggregateDropdownComponentInstanceContext } from '@/object-record/record-board/contexts/RecordBoardColumnHeaderAggregateDropdownComponentInstanceContext';
import { AGGREGATE_OPERATIONS } from '@/object-record/record-table/constants/AggregateOperations';
import { createComponentStateV2 } from '@/ui/utilities/state/component-state/utils/createComponentStateV2';
export const aggregateOperationComponentState =
createComponentStateV2<AGGREGATE_OPERATIONS | null>({
key: 'aggregateOperationComponentFamilyState',
defaultValue: null,
componentInstanceContext:
RecordBoardColumnHeaderAggregateDropdownComponentInstanceContext,
});

View File

@ -0,0 +1,10 @@
import { RecordBoardColumnHeaderAggregateDropdownComponentInstanceContext } from '@/object-record/record-board/contexts/RecordBoardColumnHeaderAggregateDropdownComponentInstanceContext';
import { createComponentStateV2 } from '@/ui/utilities/state/component-state/utils/createComponentStateV2';
export const availableFieldIdsForAggregateOperationComponentState =
createComponentStateV2<string[]>({
key: 'availableFieldIdsForAggregateOperationComponentFamilyState',
defaultValue: [],
componentInstanceContext:
RecordBoardColumnHeaderAggregateDropdownComponentInstanceContext,
});

View File

@ -0,0 +1,97 @@
import { FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { buildRecordGqlFieldsAggregate } from '@/object-record/record-board/record-board-column/utils/buildRecordGqlFieldsAggregate';
import { KanbanAggregateOperation } from '@/object-record/record-index/states/recordIndexKanbanAggregateOperationState';
import { AGGREGATE_OPERATIONS } from '@/object-record/record-table/constants/AggregateOperations';
import { FieldMetadataType } from '~/generated-metadata/graphql';
const MOCK_FIELD_ID = '7d2d7b5e-7b3e-4b4a-8b0a-7b3e4b4a8b0a';
const MOCK_KANBAN_FIELD = 'stage';
describe('buildRecordGqlFieldsAggregate', () => {
const mockObjectMetadata: ObjectMetadataItem = {
id: '123',
nameSingular: 'opportunity',
namePlural: 'opportunities',
labelSingular: 'Opportunity',
labelPlural: 'Opportunities',
isCustom: false,
isActive: true,
isSystem: false,
isRemote: false,
labelIdentifierFieldMetadataId: null,
imageIdentifierFieldMetadataId: null,
isLabelSyncedWithName: true,
fields: [
{
id: MOCK_FIELD_ID,
name: 'amount',
type: FieldMetadataType.Number,
} as FieldMetadataItem,
{
id: '06b33746-5293-4d07-9f7f-ebf5ad396064',
name: 'name',
type: FieldMetadataType.Text,
} as FieldMetadataItem,
{
id: 'e46b9ba4-144b-4d10-a092-03a7521c8aa0',
name: 'createdAt',
type: FieldMetadataType.DateTime,
} as FieldMetadataItem,
],
indexMetadatas: [],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
it('should build fields for numeric aggregate', () => {
const kanbanAggregateOperation: KanbanAggregateOperation = {
fieldMetadataId: MOCK_FIELD_ID,
operation: AGGREGATE_OPERATIONS.sum,
};
const result = buildRecordGqlFieldsAggregate({
objectMetadataItem: mockObjectMetadata,
recordIndexKanbanAggregateOperation: kanbanAggregateOperation,
kanbanFieldName: MOCK_KANBAN_FIELD,
});
expect(result).toEqual({
amount: AGGREGATE_OPERATIONS.sum,
});
});
it('should default to count when no field is found', () => {
const operation: KanbanAggregateOperation = {
fieldMetadataId: 'non-existent-id',
operation: AGGREGATE_OPERATIONS.count,
};
const result = buildRecordGqlFieldsAggregate({
objectMetadataItem: mockObjectMetadata,
recordIndexKanbanAggregateOperation: operation,
kanbanFieldName: MOCK_KANBAN_FIELD,
});
expect(result).toEqual({
[MOCK_KANBAN_FIELD]: AGGREGATE_OPERATIONS.count,
});
});
it('should throw error for non-count operation with invalid field', () => {
const operation: KanbanAggregateOperation = {
fieldMetadataId: 'non-existent-id',
operation: AGGREGATE_OPERATIONS.sum,
};
expect(() =>
buildRecordGqlFieldsAggregate({
objectMetadataItem: mockObjectMetadata,
recordIndexKanbanAggregateOperation: operation,
kanbanFieldName: MOCK_KANBAN_FIELD,
}),
).toThrow(
`No field found to compute aggregate operation ${AGGREGATE_OPERATIONS.sum} on object ${mockObjectMetadata.nameSingular}`,
);
});
});

View File

@ -0,0 +1,92 @@
import { FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { computeAggregateValueAndLabel } from '@/object-record/record-board/record-board-column/utils/computeAggregateValueAndLabel';
import { AGGREGATE_OPERATIONS } from '@/object-record/record-table/constants/AggregateOperations';
import { FieldMetadataType } from '~/generated/graphql';
const MOCK_FIELD_ID = '7d2d7b5e-7b3e-4b4a-8b0a-7b3e4b4a8b0a';
const MOCK_KANBAN_FIELD = 'stage';
describe('computeAggregateValueAndLabel', () => {
const mockObjectMetadata: ObjectMetadataItem = {
id: '123',
fields: [
{
id: MOCK_FIELD_ID,
name: 'amount',
type: FieldMetadataType.Currency,
} as FieldMetadataItem,
],
} as ObjectMetadataItem;
it('should return empty object for empty data', () => {
const result = computeAggregateValueAndLabel(
{},
mockObjectMetadata,
{ fieldMetadataId: MOCK_FIELD_ID, operation: AGGREGATE_OPERATIONS.sum },
MOCK_KANBAN_FIELD,
);
expect(result).toEqual({});
});
it('should handle currency field with division by 1M', () => {
const mockData = {
amount: {
[AGGREGATE_OPERATIONS.sum]: 2000000,
},
};
const result = computeAggregateValueAndLabel(
mockData,
mockObjectMetadata,
{ fieldMetadataId: MOCK_FIELD_ID, operation: AGGREGATE_OPERATIONS.sum },
MOCK_KANBAN_FIELD,
);
expect(result).toEqual({
value: 2,
label: 'Sum of amount',
});
});
it('should default to count when field not found', () => {
const mockData = {
[MOCK_KANBAN_FIELD]: {
[AGGREGATE_OPERATIONS.count]: 42,
},
};
const result = computeAggregateValueAndLabel(
mockData,
mockObjectMetadata,
{ fieldMetadataId: 'non-existent', operation: AGGREGATE_OPERATIONS.sum },
MOCK_KANBAN_FIELD,
);
expect(result).toEqual({
value: 42,
label: 'Count',
});
});
it('should handle undefined aggregate value', () => {
const mockData = {
amount: {
[AGGREGATE_OPERATIONS.sum]: undefined,
},
};
const result = computeAggregateValueAndLabel(
mockData,
mockObjectMetadata,
{ fieldMetadataId: MOCK_FIELD_ID, operation: AGGREGATE_OPERATIONS.sum },
MOCK_KANBAN_FIELD,
);
expect(result).toEqual({
value: undefined,
label: 'Sum of amount',
});
});
});

View File

@ -0,0 +1,22 @@
import { getAggregateOperationLabel } from '@/object-record/record-board/record-board-column/utils/getAggregateOperationLabel';
import { AGGREGATE_OPERATIONS } from '@/object-record/record-table/constants/AggregateOperations';
describe('getAggregateOperationLabel', () => {
it('should return correct labels for each operation', () => {
expect(getAggregateOperationLabel(AGGREGATE_OPERATIONS.min)).toBe('Min');
expect(getAggregateOperationLabel(AGGREGATE_OPERATIONS.max)).toBe('Max');
expect(getAggregateOperationLabel(AGGREGATE_OPERATIONS.avg)).toBe(
'Average',
);
expect(getAggregateOperationLabel(AGGREGATE_OPERATIONS.sum)).toBe('Sum');
expect(getAggregateOperationLabel(AGGREGATE_OPERATIONS.count)).toBe(
'Count',
);
});
it('should throw error for unknown operation', () => {
expect(() =>
getAggregateOperationLabel('INVALID' as AGGREGATE_OPERATIONS),
).toThrow('Unknown aggregate operation: INVALID');
});
});

View File

@ -0,0 +1,45 @@
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { KanbanAggregateOperation } from '@/object-record/record-index/states/recordIndexKanbanAggregateOperationState';
import { AGGREGATE_OPERATIONS } from '@/object-record/record-table/constants/AggregateOperations';
import { isDefined } from '~/utils/isDefined';
export const buildRecordGqlFieldsAggregate = ({
objectMetadataItem,
recordIndexKanbanAggregateOperation,
kanbanFieldName,
}: {
objectMetadataItem: ObjectMetadataItem;
recordIndexKanbanAggregateOperation: KanbanAggregateOperation;
kanbanFieldName: string;
}) => {
let recordGqlFieldsAggregate = {};
const kanbanAggregateOperationFieldName = objectMetadataItem.fields?.find(
(field) =>
field.id === recordIndexKanbanAggregateOperation?.fieldMetadataId,
)?.name;
if (!kanbanAggregateOperationFieldName) {
if (
isDefined(recordIndexKanbanAggregateOperation?.operation) &&
recordIndexKanbanAggregateOperation.operation !==
AGGREGATE_OPERATIONS.count
) {
throw new Error(
`No field found to compute aggregate operation ${recordIndexKanbanAggregateOperation.operation} on object ${objectMetadataItem.nameSingular}`,
);
} else {
recordGqlFieldsAggregate = {
[kanbanFieldName]: AGGREGATE_OPERATIONS.count,
};
}
} else {
recordGqlFieldsAggregate = {
[kanbanAggregateOperationFieldName]:
recordIndexKanbanAggregateOperation?.operation ??
AGGREGATE_OPERATIONS.count,
};
}
return recordGqlFieldsAggregate;
};

View File

@ -0,0 +1,51 @@
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { AggregateManyRecordsData } from '@/object-record/hooks/useAggregateManyRecords';
import { getAggregateOperationLabel } from '@/object-record/record-board/record-board-column/utils/getAggregateOperationLabel';
import { KanbanAggregateOperation } from '@/object-record/record-index/states/recordIndexKanbanAggregateOperationState';
import { AGGREGATE_OPERATIONS } from '@/object-record/record-table/constants/AggregateOperations';
import isEmpty from 'lodash.isempty';
import { FieldMetadataType } from '~/generated-metadata/graphql';
import { isDefined } from '~/utils/isDefined';
export const computeAggregateValueAndLabel = (
data: AggregateManyRecordsData,
objectMetadataItem: ObjectMetadataItem,
recordIndexKanbanAggregateOperation: KanbanAggregateOperation,
kanbanFieldName: string,
) => {
if (isEmpty(data)) {
return {};
}
const kanbanAggregateOperationField = objectMetadataItem.fields?.find(
(field) =>
field.id === recordIndexKanbanAggregateOperation?.fieldMetadataId,
);
const kanbanAggregateOperationFieldName = kanbanAggregateOperationField?.name;
if (
!isDefined(kanbanAggregateOperationFieldName) ||
!isDefined(recordIndexKanbanAggregateOperation?.operation)
) {
return {
value: data?.[kanbanFieldName]?.[AGGREGATE_OPERATIONS.count],
label: `${getAggregateOperationLabel(AGGREGATE_OPERATIONS.count)}`,
};
}
const aggregateValue =
data[kanbanAggregateOperationFieldName]?.[
recordIndexKanbanAggregateOperation.operation
];
const value =
isDefined(aggregateValue) &&
kanbanAggregateOperationField?.type === FieldMetadataType.Currency
? Number(aggregateValue) / 1_000_000
: aggregateValue;
return {
value,
label: `${getAggregateOperationLabel(recordIndexKanbanAggregateOperation.operation)} of ${kanbanAggregateOperationFieldName}`,
};
};

View File

@ -0,0 +1,18 @@
import { AGGREGATE_OPERATIONS } from '@/object-record/record-table/constants/AggregateOperations';
export const getAggregateOperationLabel = (operation: AGGREGATE_OPERATIONS) => {
switch (operation) {
case AGGREGATE_OPERATIONS.min:
return 'Min';
case AGGREGATE_OPERATIONS.max:
return 'Max';
case AGGREGATE_OPERATIONS.avg:
return 'Average';
case AGGREGATE_OPERATIONS.sum:
return 'Sum';
case AGGREGATE_OPERATIONS.count:
return 'Count';
default:
throw new Error(`Unknown aggregate operation: ${operation}`);
}
};

View File

@ -0,0 +1 @@
export type AggregateContentId = 'aggregateOperations' | 'aggregateFields';

View File

@ -1,3 +1,4 @@
export enum RecordBoardColumnHotkeyScope {
BoardColumn = 'board-column',
ColumnHeader = 'column-header',
}

View File

@ -26,6 +26,7 @@ import { RecordIndexActionMenu } from '@/action-menu/components/RecordIndexActio
import { contextStoreTargetedRecordsRuleComponentState } from '@/context-store/states/contextStoreTargetedRecordsRuleComponentState';
import { useSetRecordGroup } from '@/object-record/record-group/hooks/useSetRecordGroup';
import { RecordIndexFiltersToContextStoreEffect } from '@/object-record/record-index/components/RecordIndexFiltersToContextStoreEffect';
import { recordIndexKanbanAggregateOperationState } from '@/object-record/record-index/states/recordIndexKanbanAggregateOperationState';
import { recordIndexViewFilterGroupsState } from '@/object-record/record-index/states/recordIndexViewFilterGroupsState';
import { useSetRecoilComponentStateV2 } from '@/ui/utilities/state/component-state/hooks/useSetRecoilComponentStateV2';
import { ViewBar } from '@/views/components/ViewBar';
@ -82,6 +83,9 @@ export const RecordIndexContainer = () => {
const setRecordIndexViewKanbanFieldMetadataIdState = useSetRecoilState(
recordIndexKanbanFieldMetadataIdState,
);
const setRecordIndexViewKanbanAggregateOperationState = useSetRecoilState(
recordIndexKanbanAggregateOperationState,
);
const {
setTableViewFilterGroups,
@ -180,6 +184,10 @@ export const RecordIndexContainer = () => {
setRecordIndexViewKanbanFieldMetadataIdState(
view.kanbanFieldMetadataId,
);
setRecordIndexViewKanbanAggregateOperationState({
operation: view.kanbanAggregateOperation,
fieldMetadataId: view.kanbanAggregateOperationFieldMetadataId,
});
setRecordIndexIsCompactModeActive(view.isCompact);
}}
/>

View File

@ -0,0 +1,13 @@
import { AGGREGATE_OPERATIONS } from '@/object-record/record-table/constants/AggregateOperations';
import { createState } from 'twenty-ui';
export type KanbanAggregateOperation = {
operation?: AGGREGATE_OPERATIONS | null;
fieldMetadataId?: string | null;
} | null;
export const recordIndexKanbanAggregateOperationState =
createState<KanbanAggregateOperation>({
key: 'recordIndexKanbanAggregateOperationState',
defaultValue: null,
});

View File

@ -0,0 +1,7 @@
export enum AGGREGATE_OPERATIONS {
min = 'MIN',
max = 'MAX',
avg = 'AVG',
sum = 'SUM',
count = 'COUNT',
}

View File

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

View File

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

View File

@ -0,0 +1,5 @@
import { AggregateOperationsOmittingCount } from '@/object-record/types/AggregateOperationsOmittingCount';
export type AvailableFieldsForAggregateOperation = {
[T in AggregateOperationsOmittingCount]?: string[];
};

View File

@ -0,0 +1,75 @@
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { generateAggregateQuery } from '../generateAggregateQuery';
describe('generateAggregateQuery', () => {
it('should generate correct aggregate query', () => {
const mockObjectMetadataItem: ObjectMetadataItem = {
nameSingular: 'company',
namePlural: 'companies',
id: 'test-id',
labelSingular: 'Company',
labelPlural: 'Companies',
isCustom: false,
isActive: true,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
fields: [],
indexMetadatas: [],
isLabelSyncedWithName: true,
isRemote: false,
isSystem: false,
};
const mockRecordGqlFields = {
id: true,
name: true,
address: false,
createdAt: true,
};
const result = generateAggregateQuery({
objectMetadataItem: mockObjectMetadataItem,
recordGqlFields: mockRecordGqlFields,
});
const normalizedQuery = result.loc?.source.body.replace(/\s+/g, ' ').trim();
expect(normalizedQuery).toBe(
'query AggregateManyCompanies($filter: CompanyFilterInput) { companies(filter: $filter) { id name createdAt } }',
);
});
it('should handle empty record fields', () => {
const mockObjectMetadataItem: ObjectMetadataItem = {
nameSingular: 'person',
namePlural: 'people',
id: 'test-id',
labelSingular: 'Person',
labelPlural: 'People',
isCustom: false,
isActive: true,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
fields: [],
indexMetadatas: [],
isLabelSyncedWithName: true,
isRemote: false,
isSystem: false,
};
const mockRecordGqlFields = {
id: true,
};
const result = generateAggregateQuery({
objectMetadataItem: mockObjectMetadataItem,
recordGqlFields: mockRecordGqlFields,
});
const normalizedQuery = result.loc?.source.body.replace(/\s+/g, ' ').trim();
expect(normalizedQuery).toBe(
'query AggregateManyPeople($filter: PersonFilterInput) { people(filter: $filter) { id } }',
);
});
});

View File

@ -0,0 +1,61 @@
import { FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { AGGREGATE_OPERATIONS } from '@/object-record/record-table/constants/AggregateOperations';
import { getAvailableFieldsIdsForAggregationFromObjectFields } from '@/object-record/utils/getAvailableFieldsIdsForAggregationFromObjectFields';
import { FieldMetadataType } from '~/generated/graphql';
const AMOUNT_FIELD_ID = '7d2d7b5e-7b3e-4b4a-8b0a-7b3e4b4a8b0a';
const PRICE_FIELD_ID = '9d2d7b5e-7b3e-4b4a-8b0a-7b3e4b4a8b0b';
const NAME_FIELD_ID = '5d2d7b5e-7b3e-4b4a-8b0a-7b3e4b4a8b0c';
describe('getAvailableFieldsIdsForAggregationFromObjectFields', () => {
const mockFields = [
{ id: AMOUNT_FIELD_ID, type: FieldMetadataType.Number, name: 'amount' },
{ id: PRICE_FIELD_ID, type: FieldMetadataType.Currency, name: 'price' },
{ id: NAME_FIELD_ID, type: FieldMetadataType.Text, name: 'name' },
];
it('should correctly map fields to available aggregate operations', () => {
const result = getAvailableFieldsIdsForAggregationFromObjectFields(
mockFields as FieldMetadataItem[],
);
expect(result[AGGREGATE_OPERATIONS.sum]).toEqual([
AMOUNT_FIELD_ID,
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 (operation !== AGGREGATE_OPERATIONS.count) {
expect(result[operation]).toEqual([]);
}
});
});
it('should handle empty fields array', () => {
const result = getAvailableFieldsIdsForAggregationFromObjectFields([]);
Object.values(AGGREGATE_OPERATIONS).forEach((operation) => {
if (operation !== AGGREGATE_OPERATIONS.count) {
expect(result[operation]).toEqual([]);
}
});
});
});

View File

@ -0,0 +1,24 @@
import { AGGREGATE_OPERATIONS } from '@/object-record/record-table/constants/AggregateOperations';
import { FIELDS_AVAILABLE_BY_AGGREGATE_OPERATION } from '@/object-record/record-table/constants/FieldsAvailableByAggregateOperation';
import { AggregateOperationsOmittingCount } from '@/object-record/types/AggregateOperationsOmittingCount';
import { initializeAvailableFieldsForAggregateOperationMap } from '@/object-record/utils/initializeAvailableFieldsForAggregateOperationMap';
describe('initializeAvailableFieldsForAggregateOperationMap', () => {
it('should initialize empty arrays for each aggregate operation', () => {
const result = initializeAvailableFieldsForAggregateOperationMap();
expect(Object.keys(result)).toEqual(
Object.keys(FIELDS_AVAILABLE_BY_AGGREGATE_OPERATION),
);
Object.values(result).forEach((array) => {
expect(array).toEqual([]);
});
});
it('should not include count operation', () => {
const result = initializeAvailableFieldsForAggregateOperationMap();
expect(
result[AGGREGATE_OPERATIONS.count as AggregateOperationsOmittingCount],
).toBeUndefined();
});
});

View File

@ -0,0 +1,57 @@
import { AGGREGATE_OPERATIONS } from '@/object-record/record-table/constants/AggregateOperations';
import { AggregateOperationsOmittingCount } from '@/object-record/types/AggregateOperationsOmittingCount';
import { isFieldTypeValidForAggregateOperation } from '@/object-record/utils/isFieldTypeValidForAggregateOperation';
import { FieldMetadataType } from '~/generated/graphql';
describe('isFieldTypeValidForAggregateOperation', () => {
it('should return true for valid field types and operations', () => {
expect(
isFieldTypeValidForAggregateOperation(
FieldMetadataType.Number,
AGGREGATE_OPERATIONS.sum,
),
).toBe(true);
expect(
isFieldTypeValidForAggregateOperation(
FieldMetadataType.Currency,
AGGREGATE_OPERATIONS.min,
),
).toBe(true);
});
it('should return false for invalid field types', () => {
expect(
isFieldTypeValidForAggregateOperation(
FieldMetadataType.Text,
AGGREGATE_OPERATIONS.avg,
),
).toBe(false);
expect(
isFieldTypeValidForAggregateOperation(
FieldMetadataType.Boolean,
AGGREGATE_OPERATIONS.max,
),
).toBe(false);
});
it('should handle all aggregate operations', () => {
const numericField = FieldMetadataType.Number;
const operations = [
AGGREGATE_OPERATIONS.min,
AGGREGATE_OPERATIONS.max,
AGGREGATE_OPERATIONS.avg,
AGGREGATE_OPERATIONS.sum,
];
operations.forEach((operation) => {
expect(
isFieldTypeValidForAggregateOperation(
numericField,
operation as AggregateOperationsOmittingCount,
),
).toBe(true);
});
});
});

View File

@ -0,0 +1,28 @@
import gql from 'graphql-tag';
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { RecordGqlFields } from '@/object-record/graphql/types/RecordGqlFields';
import { capitalize } from '~/utils/string/capitalize';
export const generateAggregateQuery = ({
objectMetadataItem,
recordGqlFields,
}: {
objectMetadataItem: ObjectMetadataItem;
recordGqlFields: RecordGqlFields;
}) => {
const selectedFields = Object.entries(recordGqlFields)
.filter(([_, shouldBeQueried]) => shouldBeQueried)
.map(([fieldName]) => fieldName)
.join('\n ');
return gql`
query AggregateMany${capitalize(objectMetadataItem.namePlural)}($filter: ${capitalize(
objectMetadataItem.nameSingular,
)}FilterInput) {
${objectMetadataItem.namePlural}(filter: $filter) {
${selectedFields}
}
}
`;
};

View File

@ -0,0 +1,51 @@
import { FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { AGGREGATE_OPERATIONS } from '@/object-record/record-table/constants/AggregateOperations';
import { FieldMetadataType } from '~/generated-metadata/graphql';
import { capitalize } from '~/utils/string/capitalize';
type NameForAggregation = {
[T in AGGREGATE_OPERATIONS]?: string;
};
type Aggregations = {
[key: string]: NameForAggregation;
};
export const getAvailableAggregationsFromObjectFields = (
fields: FieldMetadataItem[],
): Aggregations => {
return fields.reduce<Record<string, NameForAggregation>>((acc, field) => {
if (field.type === FieldMetadataType.DateTime) {
acc[field.name] = {
[AGGREGATE_OPERATIONS.min]: `min${capitalize(field.name)}`,
[AGGREGATE_OPERATIONS.max]: `max${capitalize(field.name)}`,
};
}
if (field.type === FieldMetadataType.Number) {
acc[field.name] = {
[AGGREGATE_OPERATIONS.min]: `min${capitalize(field.name)}`,
[AGGREGATE_OPERATIONS.max]: `max${capitalize(field.name)}`,
[AGGREGATE_OPERATIONS.avg]: `avg${capitalize(field.name)}`,
[AGGREGATE_OPERATIONS.sum]: `sum${capitalize(field.name)}`,
};
}
if (field.type === FieldMetadataType.Currency) {
acc[field.name] = {
[AGGREGATE_OPERATIONS.min]: `min${capitalize(field.name)}AmountMicros`,
[AGGREGATE_OPERATIONS.max]: `max${capitalize(field.name)}AmountMicros`,
[AGGREGATE_OPERATIONS.avg]: `avg${capitalize(field.name)}AmountMicros`,
[AGGREGATE_OPERATIONS.sum]: `sum${capitalize(field.name)}AmountMicros`,
};
}
if (acc[field.name] === undefined) {
acc[field.name] = {};
}
acc[field.name][AGGREGATE_OPERATIONS.count] = 'totalCount';
return acc;
}, {});
};

View File

@ -0,0 +1,32 @@
import { FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { FIELDS_AVAILABLE_BY_AGGREGATE_OPERATION } from '@/object-record/record-table/constants/FieldsAvailableByAggregateOperation';
import { AggregateOperationsOmittingCount } from '@/object-record/types/AggregateOperationsOmittingCount';
import { AvailableFieldsForAggregateOperation } from '@/object-record/types/AvailableFieldsForAggregateOperation';
import { initializeAvailableFieldsForAggregateOperationMap } from '@/object-record/utils/initializeAvailableFieldsForAggregateOperationMap';
import { isFieldTypeValidForAggregateOperation } from '@/object-record/utils/isFieldTypeValidForAggregateOperation';
export const getAvailableFieldsIdsForAggregationFromObjectFields = (
fields: FieldMetadataItem[],
): AvailableFieldsForAggregateOperation => {
const aggregationMap = initializeAvailableFieldsForAggregateOperationMap();
return fields.reduce((acc, field) => {
Object.keys(FIELDS_AVAILABLE_BY_AGGREGATE_OPERATION).forEach(
(aggregateOperation) => {
const typedAggregateOperation =
aggregateOperation as AggregateOperationsOmittingCount;
if (
isFieldTypeValidForAggregateOperation(
field.type,
typedAggregateOperation,
)
) {
acc[typedAggregateOperation]?.push(field.id);
}
},
);
return acc;
}, aggregationMap);
};

View File

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

View File

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

View File

@ -14,6 +14,8 @@ export const findAllViewsOperationSignatureFactory: RecordGqlOperationSignatureF
position: true,
type: true,
kanbanFieldMetadataId: true,
kanbanAggregateOperation: true,
kanbanAggregateOperationFieldMetadataId: true,
name: true,
icon: true,
key: true,

View File

@ -74,7 +74,7 @@ export const useCreateViewFromCurrentView = (viewBarComponentId?: string) => {
'id' | 'name' | 'icon' | 'kanbanFieldMetadataId' | 'type'
>
>,
shouldCopyFiltersAndSorts?: boolean,
shouldCopyFiltersAndSortsAndAggregate?: boolean,
) => {
const currentViewId = getSnapshotValue(
snapshot,
@ -101,6 +101,13 @@ export const useCreateViewFromCurrentView = (viewBarComponentId?: string) => {
key: null,
kanbanFieldMetadataId:
kanbanFieldMetadataId ?? sourceView.kanbanFieldMetadataId,
kanbanAggregateOperation: shouldCopyFiltersAndSortsAndAggregate
? sourceView.kanbanAggregateOperation
: undefined,
kanbanAggregateOperationFieldMetadataId:
shouldCopyFiltersAndSortsAndAggregate
? sourceView.kanbanAggregateOperationFieldMetadataId
: undefined,
type: type ?? sourceView.type,
objectMetadataId: sourceView.objectMetadataId,
});
@ -143,7 +150,7 @@ export const useCreateViewFromCurrentView = (viewBarComponentId?: string) => {
await createViewGroupRecords(viewGroupsToCreate, newView);
}
if (shouldCopyFiltersAndSorts === true) {
if (shouldCopyFiltersAndSortsAndAggregate === true) {
const sourceViewCombinedFilterGroups = getViewFilterGroupsCombined(
sourceView.id,
);

View File

@ -0,0 +1,29 @@
import { AGGREGATE_OPERATIONS } from '@/object-record/record-table/constants/AggregateOperations';
import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2';
import { useUpdateView } from '@/views/hooks/useUpdateView';
import { currentViewIdComponentState } from '@/views/states/currentViewIdComponentState';
import { useCallback } from 'react';
export const useUpdateViewAggregate = () => {
const currentViewId = useRecoilComponentValueV2(currentViewIdComponentState);
const { updateView } = useUpdateView();
const updateViewAggregate = useCallback(
({
kanbanAggregateOperationFieldMetadataId,
kanbanAggregateOperation,
}: {
kanbanAggregateOperationFieldMetadataId: string | null;
kanbanAggregateOperation: AGGREGATE_OPERATIONS;
}) =>
updateView({
id: currentViewId,
kanbanAggregateOperationFieldMetadataId,
kanbanAggregateOperation,
}),
[currentViewId, updateView],
);
return {
updateViewAggregate,
};
};

View File

@ -1,3 +1,4 @@
import { AGGREGATE_OPERATIONS } from '@/object-record/record-table/constants/AggregateOperations';
import { ViewField } from '@/views/types/ViewField';
import { ViewFilter } from '@/views/types/ViewFilter';
import { ViewFilterGroup } from '@/views/types/ViewFilterGroup';
@ -12,6 +13,8 @@ export type GraphQLView = {
type: ViewType;
key: ViewKey | null;
kanbanFieldMetadataId: string;
kanbanAggregateOperation?: AGGREGATE_OPERATIONS | null;
kanbanAggregateOperationFieldMetadataId?: string | null;
objectMetadataId: string;
isCompact: boolean;
viewFields: ViewField[];

View File

@ -1,3 +1,4 @@
import { AGGREGATE_OPERATIONS } from '@/object-record/record-table/constants/AggregateOperations';
import { ViewField } from '@/views/types/ViewField';
import { ViewFilter } from '@/views/types/ViewFilter';
import { ViewFilterGroup } from '@/views/types/ViewFilterGroup';
@ -19,6 +20,8 @@ export type View = {
viewFilterGroups?: ViewFilterGroup[];
viewSorts: ViewSort[];
kanbanFieldMetadataId: string;
kanbanAggregateOperation: AGGREGATE_OPERATIONS | null;
kanbanAggregateOperationFieldMetadataId: string | null;
position: number;
icon: string;
__typename: 'View';

View File

@ -1,5 +1,6 @@
import { RecordBoardFieldDefinition } from '@/object-record/record-board/types/RecordBoardFieldDefinition';
import { FieldMetadata } from '@/object-record/record-field/types/FieldMetadata';
import { AGGREGATE_OPERATIONS } from '@/object-record/record-table/constants/AggregateOperations';
import { ColumnDefinition } from '@/object-record/record-table/types/ColumnDefinition';
export type ViewField = {
@ -9,6 +10,7 @@ export type ViewField = {
position: number;
isVisible: boolean;
size: number;
aggregateOperation?: AGGREGATE_OPERATIONS | null;
definition:
| ColumnDefinition<FieldMetadata>
| RecordBoardFieldDefinition<FieldMetadata>;

View File

@ -17,6 +17,9 @@ export const getObjectMetadataItemViews = (
position: view.position,
objectMetadataId: view.objectMetadataId,
kanbanFieldMetadataId: view.kanbanFieldMetadataId,
kanbanAggregateOperation: view.kanbanAggregateOperation,
kanbanAggregateOperationFieldMetadataId:
view.kanbanAggregateOperationFieldMetadataId,
icon: view.icon,
}));
};

View File

@ -47,6 +47,7 @@ export const mapViewFieldsToColumnDefinitions = ({
isLabelIdentifier,
isVisible: isLabelIdentifier || viewField.isVisible,
viewFieldId: viewField.id,
aggregateOperation: viewField.aggregateOperation,
isSortable: correspondingColumnDefinition.isSortable,
isFilterable: correspondingColumnDefinition.isFilterable,
defaultValue: correspondingColumnDefinition.defaultValue,

View File

@ -13,48 +13,40 @@ import { viewPickerTypeComponentState } from '@/views/view-picker/states/viewPic
import { useRecoilCallback } from 'recoil';
import { v4 } from 'uuid';
export const useCreateViewFromCurrentState = (viewBarInstanceId?: string) => {
export const useCreateViewFromCurrentState = () => {
const { closeAndResetViewPicker } = useCloseAndResetViewPicker();
const viewPickerInputNameCallbackState = useRecoilComponentCallbackStateV2(
viewPickerInputNameComponentState,
viewBarInstanceId,
);
const viewPickerSelectedIconCallbackState = useRecoilComponentCallbackStateV2(
viewPickerSelectedIconComponentState,
viewBarInstanceId,
);
const viewPickerTypeCallbackState = useRecoilComponentCallbackStateV2(
viewPickerTypeComponentState,
viewBarInstanceId,
);
const viewPickerKanbanFieldMetadataIdCallbackState =
useRecoilComponentCallbackStateV2(
viewPickerKanbanFieldMetadataIdComponentState,
viewBarInstanceId,
);
const viewPickerIsPersistingCallbackState = useRecoilComponentCallbackStateV2(
viewPickerIsPersistingComponentState,
viewBarInstanceId,
);
const viewPickerIsDirtyCallbackState = useRecoilComponentCallbackStateV2(
viewPickerIsDirtyComponentState,
viewBarInstanceId,
);
const viewPickerModeCallbackState = useRecoilComponentCallbackStateV2(
viewPickerModeComponentState,
viewBarInstanceId,
);
const { createViewFromCurrentView } =
useCreateViewFromCurrentView(viewBarInstanceId);
const { changeView } = useChangeView(viewBarInstanceId);
const { createViewFromCurrentView } = useCreateViewFromCurrentView();
const { changeView } = useChangeView();
const createViewFromCurrentState = useRecoilCallback(
({ snapshot, set }) =>
@ -78,7 +70,7 @@ export const useCreateViewFromCurrentState = (viewBarInstanceId?: string) => {
viewPickerModeCallbackState,
);
const shouldCopyFiltersAndSorts =
const shouldCopyFiltersAndSortsAndAggregate =
viewPickerMode === 'create-from-current';
const id = v4();
@ -94,7 +86,7 @@ export const useCreateViewFromCurrentState = (viewBarInstanceId?: string) => {
type,
kanbanFieldMetadataId,
},
shouldCopyFiltersAndSorts,
shouldCopyFiltersAndSortsAndAggregate,
);
closeAndResetViewPicker();

View File

@ -0,0 +1,7 @@
export enum AGGREGATE_OPERATIONS {
min = 'MIN',
max = 'MAX',
avg = 'AVG',
sum = 'SUM',
count = 'COUNT',
}

View File

@ -45,6 +45,12 @@ export class GraphqlQuerySelectedFieldsParser {
return accumulator;
}
this.aggregateParser.parse(
graphqlSelectedFields,
fieldMetadataMapByName,
accumulator,
);
this.parseRecordField(
graphqlSelectedFields,
fieldMetadataMapByName,

View File

@ -1,5 +1,6 @@
import { SelectQueryBuilder } from 'typeorm';
import { AGGREGATE_OPERATIONS } from 'src/engine/api/graphql/graphql-query-runner/constants/aggregate-operations.constant';
import { AggregationField } from 'src/engine/api/graphql/workspace-schema-builder/utils/get-available-aggregations-from-object-fields.util';
import { formatColumnNameFromCompositeFieldAndSubfield } from 'src/engine/twenty-orm/utils/format-column-name-from-composite-field-and-subfield.util';
import { isDefined } from 'src/utils/is-defined';
@ -19,7 +20,7 @@ export class ProcessAggregateHelper {
)) {
if (
!isDefined(aggregatedField?.fromField) ||
!isDefined(aggregatedField?.aggregationOperation)
!isDefined(aggregatedField?.aggregateOperation)
) {
continue;
}
@ -28,10 +29,17 @@ export class ProcessAggregateHelper {
aggregatedField.fromField,
aggregatedField.fromSubField,
);
const operation = aggregatedField.aggregationOperation;
if (
!Object.values(AGGREGATE_OPERATIONS).includes(
aggregatedField.aggregateOperation,
)
) {
continue;
}
queryBuilder.addSelect(
`${operation}("${columnName}")`,
`${aggregatedField.aggregateOperation}("${columnName}")`,
`${aggregatedFieldName}`,
);
}

View File

@ -4,23 +4,16 @@ import { GraphQLFloat, GraphQLInt, GraphQLScalarType } from 'graphql';
import { FieldMetadataInterface } from 'src/engine/metadata-modules/field-metadata/interfaces/field-metadata.interface';
import { AGGREGATE_OPERATIONS } from 'src/engine/api/graphql/graphql-query-runner/constants/aggregate-operations.constant';
import { FieldMetadataType } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity';
import { capitalize } from 'src/utils/capitalize';
enum AGGREGATION_OPERATIONS {
min = 'MIN',
max = 'MAX',
avg = 'AVG',
sum = 'SUM',
count = 'COUNT',
}
export type AggregationField = {
type: GraphQLScalarType;
description: string;
fromField: string;
fromSubField?: string;
aggregationOperation: AGGREGATION_OPERATIONS;
aggregateOperation: AGGREGATE_OPERATIONS;
};
export const getAvailableAggregationsFromObjectFields = (
@ -31,7 +24,7 @@ export const getAvailableAggregationsFromObjectFields = (
type: GraphQLInt,
description: `Total number of records in the connection`,
fromField: 'id',
aggregationOperation: AGGREGATION_OPERATIONS.count,
aggregateOperation: AGGREGATE_OPERATIONS.count,
};
if (field.type === FieldMetadataType.DATE_TIME) {
@ -39,14 +32,14 @@ export const getAvailableAggregationsFromObjectFields = (
type: GraphQLISODateTime,
description: `Oldest date contained in the field ${field.name}`,
fromField: field.name,
aggregationOperation: AGGREGATION_OPERATIONS.min,
aggregateOperation: AGGREGATE_OPERATIONS.min,
};
acc[`max${capitalize(field.name)}`] = {
type: GraphQLISODateTime,
description: `Most recent date contained in the field ${field.name}`,
fromField: field.name,
aggregationOperation: AGGREGATION_OPERATIONS.max,
aggregateOperation: AGGREGATE_OPERATIONS.max,
};
}
@ -55,38 +48,62 @@ export const getAvailableAggregationsFromObjectFields = (
type: GraphQLFloat,
description: `Minimum amount contained in the field ${field.name}`,
fromField: field.name,
aggregationOperation: AGGREGATION_OPERATIONS.min,
aggregateOperation: AGGREGATE_OPERATIONS.min,
};
acc[`max${capitalize(field.name)}`] = {
type: GraphQLFloat,
description: `Maximum amount contained in the field ${field.name}`,
fromField: field.name,
aggregationOperation: AGGREGATION_OPERATIONS.max,
aggregateOperation: AGGREGATE_OPERATIONS.max,
};
acc[`avg${capitalize(field.name)}`] = {
type: GraphQLFloat,
description: `Average amount contained in the field ${field.name}`,
fromField: field.name,
aggregationOperation: AGGREGATION_OPERATIONS.avg,
aggregateOperation: AGGREGATE_OPERATIONS.avg,
};
acc[`sum${capitalize(field.name)}`] = {
type: GraphQLFloat,
description: `Sum of amounts contained in the field ${field.name}`,
fromField: field.name,
aggregationOperation: AGGREGATION_OPERATIONS.sum,
aggregateOperation: AGGREGATE_OPERATIONS.sum,
};
}
if (field.type === FieldMetadataType.CURRENCY) {
acc[`min${capitalize(field.name)}AmountMicros`] = {
type: GraphQLFloat,
description: `Minimum amount contained in the field ${field.name}`,
fromField: field.name,
fromSubField: 'amountMicros',
aggregateOperation: AGGREGATE_OPERATIONS.min,
};
acc[`max${capitalize(field.name)}AmountMicros`] = {
type: GraphQLFloat,
description: `Maximal amount contained in the field ${field.name}`,
fromField: field.name,
fromSubField: 'amountMicros',
aggregateOperation: AGGREGATE_OPERATIONS.max,
};
acc[`sum${capitalize(field.name)}AmountMicros`] = {
type: GraphQLFloat,
description: `Sum of amounts contained in the field ${field.name}`,
fromField: field.name,
fromSubField: 'amountMicros',
aggregateOperation: AGGREGATE_OPERATIONS.sum,
};
acc[`avg${capitalize(field.name)}AmountMicros`] = {
type: GraphQLFloat,
description: `Average amount contained in the field ${field.name}`,
fromField: field.name,
fromSubField: 'amountMicros',
aggregationOperation: AGGREGATION_OPERATIONS.avg,
aggregateOperation: AGGREGATE_OPERATIONS.avg,
};
}

View File

@ -380,6 +380,7 @@ export const VIEW_FIELD_STANDARD_FIELD_IDS = {
size: '20202020-6fab-4bd0-ae72-20f3ee39d581',
position: '20202020-19e5-4e4c-8c15-3a96d1fd0650',
view: '20202020-e8da-4521-afab-d6d231f9fa18',
aggregateOperation: '20202020-2cd7-4f94-ae83-4a14f5731a04',
};
export const VIEW_GROUP_STANDARD_FIELD_IDS = {
@ -420,6 +421,9 @@ export const VIEW_STANDARD_FIELD_IDS = {
key: '20202020-298e-49fa-9f4a-7b416b110443',
icon: '20202020-1f08-4fd9-929b-cbc07f317166',
kanbanFieldMetadataId: '20202020-d09b-4f65-ac42-06a2f20ba0e8',
kanbanAggregateOperation: '20202020-8da2-45de-a731-61bed84b17a8',
kanbanAggregateOperationFieldMetadataId:
'20202020-b1b3-4bf3-85e4-dc7d58aa9b02',
position: '20202020-e9db-4303-b271-e8250c450172',
isCompact: '20202020-674e-4314-994d-05754ea7b22b',
viewFields: '20202020-542b-4bdc-b177-b63175d48edf',

View File

@ -1,12 +1,18 @@
import { registerEnumType } from '@nestjs/graphql';
import { Relation } from 'typeorm';
import { AGGREGATE_OPERATIONS } from 'src/engine/api/graphql/graphql-query-runner/constants/aggregate-operations.constant';
import { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/feature-flag-key.enum';
import { FieldMetadataType } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity';
import { RelationMetadataType } from 'src/engine/metadata-modules/relation-metadata/relation-metadata.entity';
import { BaseWorkspaceEntity } from 'src/engine/twenty-orm/base.workspace-entity';
import { WorkspaceEntity } from 'src/engine/twenty-orm/decorators/workspace-entity.decorator';
import { WorkspaceField } from 'src/engine/twenty-orm/decorators/workspace-field.decorator';
import { WorkspaceGate } from 'src/engine/twenty-orm/decorators/workspace-gate.decorator';
import { WorkspaceIndex } from 'src/engine/twenty-orm/decorators/workspace-index.decorator';
import { WorkspaceIsNotAuditLogged } from 'src/engine/twenty-orm/decorators/workspace-is-not-audit-logged.decorator';
import { WorkspaceIsNullable } from 'src/engine/twenty-orm/decorators/workspace-is-nullable.decorator';
import { WorkspaceIsSystem } from 'src/engine/twenty-orm/decorators/workspace-is-system.decorator';
import { WorkspaceJoinColumn } from 'src/engine/twenty-orm/decorators/workspace-join-column.decorator';
import { WorkspaceRelation } from 'src/engine/twenty-orm/decorators/workspace-relation.decorator';
@ -15,6 +21,10 @@ import { STANDARD_OBJECT_ICONS } from 'src/engine/workspace-manager/workspace-sy
import { STANDARD_OBJECT_IDS } from 'src/engine/workspace-manager/workspace-sync-metadata/constants/standard-object-ids';
import { ViewWorkspaceEntity } from 'src/modules/view/standard-objects/view.workspace-entity';
registerEnumType(AGGREGATE_OPERATIONS, {
name: 'AggregateOperations',
});
@WorkspaceEntity({
standardId: STANDARD_OBJECT_IDS.viewField,
namePlural: 'viewFields',
@ -80,6 +90,52 @@ export class ViewFieldWorkspaceEntity extends BaseWorkspaceEntity {
})
view: Relation<ViewWorkspaceEntity>;
@WorkspaceField({
standardId: VIEW_FIELD_STANDARD_FIELD_IDS.aggregateOperation,
type: FieldMetadataType.SELECT,
label: 'Aggregate operation',
description: 'Optional aggregate operation',
icon: 'IconCalculator',
options: [
{
value: AGGREGATE_OPERATIONS.avg,
label: 'Average',
position: 0,
color: 'red',
},
{
value: AGGREGATE_OPERATIONS.count,
label: 'Count',
position: 1,
color: 'purple',
},
{
value: AGGREGATE_OPERATIONS.max,
label: 'Maximum',
position: 2,
color: 'sky',
},
{
value: AGGREGATE_OPERATIONS.min,
label: 'Minimum',
position: 3,
color: 'turquoise',
},
{
value: AGGREGATE_OPERATIONS.sum,
label: 'Sum',
position: 4,
color: 'yellow',
},
],
defaultValue: null,
})
@WorkspaceGate({
featureFlag: FeatureFlagKey.IsAggregateQueryEnabled,
})
@WorkspaceIsNullable()
aggregateOperation?: AGGREGATE_OPERATIONS | null;
@WorkspaceJoinColumn('view')
viewId: string;
}

View File

@ -1,5 +1,7 @@
import { Relation } from 'src/engine/workspace-manager/workspace-sync-metadata/interfaces/relation.interface';
import { AGGREGATE_OPERATIONS } from 'src/engine/api/graphql/graphql-query-runner/constants/aggregate-operations.constant';
import { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/feature-flag-key.enum';
import { FieldMetadataType } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity';
import {
RelationMetadataType,
@ -8,6 +10,7 @@ import {
import { BaseWorkspaceEntity } from 'src/engine/twenty-orm/base.workspace-entity';
import { WorkspaceEntity } from 'src/engine/twenty-orm/decorators/workspace-entity.decorator';
import { WorkspaceField } from 'src/engine/twenty-orm/decorators/workspace-field.decorator';
import { WorkspaceGate } from 'src/engine/twenty-orm/decorators/workspace-gate.decorator';
import { WorkspaceIsNotAuditLogged } from 'src/engine/twenty-orm/decorators/workspace-is-not-audit-logged.decorator';
import { WorkspaceIsNullable } from 'src/engine/twenty-orm/decorators/workspace-is-nullable.decorator';
import { WorkspaceIsSystem } from 'src/engine/twenty-orm/decorators/workspace-is-system.decorator';
@ -175,4 +178,63 @@ export class ViewWorkspaceEntity extends BaseWorkspaceEntity {
})
@WorkspaceIsSystem()
favorites: Relation<FavoriteWorkspaceEntity[]>;
@WorkspaceField({
standardId: VIEW_STANDARD_FIELD_IDS.kanbanAggregateOperation,
type: FieldMetadataType.SELECT,
label: 'Aggregate operation',
description: 'Optional aggregate operation',
icon: 'IconCalculator',
options: [
{
value: AGGREGATE_OPERATIONS.avg,
label: 'Average',
position: 0,
color: 'red',
},
{
value: AGGREGATE_OPERATIONS.count,
label: 'Count',
position: 1,
color: 'purple',
},
{
value: AGGREGATE_OPERATIONS.max,
label: 'Maximum',
position: 2,
color: 'sky',
},
{
value: AGGREGATE_OPERATIONS.min,
label: 'Minimum',
position: 3,
color: 'turquoise',
},
{
value: AGGREGATE_OPERATIONS.sum,
label: 'Sum',
position: 4,
color: 'yellow',
},
],
defaultValue: `'${AGGREGATE_OPERATIONS.count}'`,
})
@WorkspaceGate({
featureFlag: FeatureFlagKey.IsAggregateQueryEnabled,
})
@WorkspaceIsNullable()
kanbanAggregateOperation?: AGGREGATE_OPERATIONS | null;
@WorkspaceField({
standardId: VIEW_STANDARD_FIELD_IDS.kanbanAggregateOperationFieldMetadataId,
type: FieldMetadataType.UUID,
label: 'Field metadata used for aggregate operation',
description: 'Field metadata used for aggregate operation',
defaultValue: null,
})
@WorkspaceGate({
featureFlag: FeatureFlagKey.IsAggregateQueryEnabled,
})
@WorkspaceIsNullable()
kanbanAggregateOperationFieldMetadataId?: string | null;
}