diff --git a/packages/twenty-front/src/modules/dropdown/constants/DropdownOffsetY.tsx b/packages/twenty-front/src/modules/dropdown/constants/DropdownOffsetY.tsx new file mode 100644 index 0000000000..1df8482e78 --- /dev/null +++ b/packages/twenty-front/src/modules/dropdown/constants/DropdownOffsetY.tsx @@ -0,0 +1 @@ +export const DROPDOWN_OFFSET_Y = 8; diff --git a/packages/twenty-front/src/modules/dropdown/constants/DropdownWidth.tsx b/packages/twenty-front/src/modules/dropdown/constants/DropdownWidth.tsx new file mode 100644 index 0000000000..e7e8339655 --- /dev/null +++ b/packages/twenty-front/src/modules/dropdown/constants/DropdownWidth.tsx @@ -0,0 +1 @@ +export const DROPDOWN_WIDTH = '200px'; diff --git a/packages/twenty-front/src/modules/dropdown/hooks/useCurrentContentId.ts b/packages/twenty-front/src/modules/dropdown/hooks/useCurrentContentId.ts new file mode 100644 index 0000000000..879050ffd3 --- /dev/null +++ b/packages/twenty-front/src/modules/dropdown/hooks/useCurrentContentId.ts @@ -0,0 +1,19 @@ +import { useCallback, useState } from 'react'; + +export const useCurrentContentId = () => { + const [currentContentId, setCurrentContentId] = useState(null); + + const handleContentChange = useCallback((key: T) => { + setCurrentContentId(key); + }, []); + + const handleResetContent = useCallback(() => { + setCurrentContentId(null); + }, []); + + return { + currentContentId, + handleContentChange, + handleResetContent, + }; +}; diff --git a/packages/twenty-front/src/modules/dropdown/hooks/useDropdown.ts b/packages/twenty-front/src/modules/dropdown/hooks/useDropdown.ts new file mode 100644 index 0000000000..17b08ef800 --- /dev/null +++ b/packages/twenty-front/src/modules/dropdown/hooks/useDropdown.ts @@ -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; +}) => { + 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, + }; +}; diff --git a/packages/twenty-front/src/modules/object-record/graphql/types/RecordGqlConnection.ts b/packages/twenty-front/src/modules/object-record/graphql/types/RecordGqlConnection.ts index df64c8e35f..99de9b65ea 100644 --- a/packages/twenty-front/src/modules/object-record/graphql/types/RecordGqlConnection.ts +++ b/packages/twenty-front/src/modules/object-record/graphql/types/RecordGqlConnection.ts @@ -14,4 +14,5 @@ export type RecordGqlConnection = { totalCount?: number; }; totalCount?: number; + [aggregateFieldName: string]: any; }; diff --git a/packages/twenty-front/src/modules/object-record/graphql/types/RecordGqlFieldsAggregate.ts b/packages/twenty-front/src/modules/object-record/graphql/types/RecordGqlFieldsAggregate.ts new file mode 100644 index 0000000000..7217a34dcc --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/graphql/types/RecordGqlFieldsAggregate.ts @@ -0,0 +1,3 @@ +import { AGGREGATE_OPERATIONS } from '@/object-record/record-table/constants/AggregateOperations'; + +export type RecordGqlFieldsAggregate = Record; diff --git a/packages/twenty-front/src/modules/object-record/hooks/useAggregateManyRecords.ts b/packages/twenty-front/src/modules/object-record/hooks/useAggregateManyRecords.ts new file mode 100644 index 0000000000..8fc9e313fb --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/hooks/useAggregateManyRecords.ts @@ -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( + 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, + }; +}; diff --git a/packages/twenty-front/src/modules/object-record/hooks/useAggregateManyRecordsQuery.ts b/packages/twenty-front/src/modules/object-record/hooks/useAggregateManyRecordsQuery.ts new file mode 100644 index 0000000000..ae47c346a5 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/hooks/useAggregateManyRecordsQuery.ts @@ -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, + }; +}; diff --git a/packages/twenty-front/src/modules/object-record/object-options-dropdown/components/ObjectOptionsDropdown.tsx b/packages/twenty-front/src/modules/object-record/object-options-dropdown/components/ObjectOptionsDropdown.tsx index dd5890c716..f051fa4e3b 100644 --- a/packages/twenty-front/src/modules/object-record/object-options-dropdown/components/ObjectOptionsDropdown.tsx +++ b/packages/twenty-front/src/modules/object-record/object-options-dropdown/components/ObjectOptionsDropdown.tsx @@ -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(null); - - const handleContentChange = useCallback((key: ObjectOptionsContentId) => { - setCurrentContentId(key); - }, []); - - const handleResetContent = useCallback(() => { - setCurrentContentId(null); - }, []); + const { currentContentId, handleContentChange, handleResetContent } = + useCurrentContentId(); return ( } - dropdownMenuWidth={'200px'} dropdownHotkeyScope={{ scope: TableOptionsHotkeyScope.Dropdown }} - dropdownOffset={{ y: 8 }} + dropdownMenuWidth={DROPDOWN_WIDTH} + dropdownOffset={{ y: DROPDOWN_OFFSET_Y }} + clickableComponent={ + Options + } dropdownComponents={ diff --git a/packages/twenty-front/src/modules/object-record/object-options-dropdown/components/ObjectOptionsDropdownButton.tsx b/packages/twenty-front/src/modules/object-record/object-options-dropdown/components/ObjectOptionsDropdownButton.tsx deleted file mode 100644 index ecb46aa886..0000000000 --- a/packages/twenty-front/src/modules/object-record/object-options-dropdown/components/ObjectOptionsDropdownButton.tsx +++ /dev/null @@ -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 ( - - Options - - ); -}; diff --git a/packages/twenty-front/src/modules/object-record/object-options-dropdown/components/__stories__/ObjectOptionsDropdownContent.stories.tsx b/packages/twenty-front/src/modules/object-record/object-options-dropdown/components/__stories__/ObjectOptionsDropdownContent.stories.tsx index e4d33d839a..f53d7f995b 100644 --- a/packages/twenty-front/src/modules/object-record/object-options-dropdown/components/__stories__/ObjectOptionsDropdownContent.stories.tsx +++ b/packages/twenty-front/src/modules/object-record/object-options-dropdown/components/__stories__/ObjectOptionsDropdownContent.stories.tsx @@ -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, }} > diff --git a/packages/twenty-front/src/modules/object-record/object-options-dropdown/hooks/__tests__/useOptionsDropdown.test.tsx b/packages/twenty-front/src/modules/object-record/object-options-dropdown/hooks/__tests__/useOptionsDropdown.test.tsx index 763fd02d26..edebe6b213 100644 --- a/packages/twenty-front/src/modules/object-record/object-options-dropdown/hooks/__tests__/useOptionsDropdown.test.tsx +++ b/packages/twenty-front/src/modules/object-record/object-options-dropdown/hooks/__tests__/useOptionsDropdown.test.tsx @@ -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, }} > diff --git a/packages/twenty-front/src/modules/object-record/object-options-dropdown/hooks/useOptionsDropdown.ts b/packages/twenty-front/src/modules/object-record/object-options-dropdown/hooks/useOptionsDropdown.ts index 8c3bda6fbd..c8825af6c2 100644 --- a/packages/twenty-front/src/modules/object-record/object-options-dropdown/hooks/useOptionsDropdown.ts +++ b/packages/twenty-front/src/modules/object-record/object-options-dropdown/hooks/useOptionsDropdown.ts @@ -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, }; }; diff --git a/packages/twenty-front/src/modules/object-record/object-options-dropdown/states/contexts/ObjectOptionsDropdownContext.ts b/packages/twenty-front/src/modules/object-record/object-options-dropdown/states/contexts/ObjectOptionsDropdownContext.ts index c3abb0d678..fcfc16eee1 100644 --- a/packages/twenty-front/src/modules/object-record/object-options-dropdown/states/contexts/ObjectOptionsDropdownContext.ts +++ b/packages/twenty-front/src/modules/object-record/object-options-dropdown/states/contexts/ObjectOptionsDropdownContext.ts @@ -10,6 +10,7 @@ export type ObjectOptionsDropdownContextValue = { currentContentId: ObjectOptionsContentId | null; onContentChange: (key: ObjectOptionsContentId) => void; resetContent: () => void; + dropdownId: string; }; export const ObjectOptionsDropdownContext = diff --git a/packages/twenty-front/src/modules/object-record/record-board/contexts/RecordBoardColumnHeaderAggregateDropdownComponentInstanceContext.ts b/packages/twenty-front/src/modules/object-record/record-board/contexts/RecordBoardColumnHeaderAggregateDropdownComponentInstanceContext.ts new file mode 100644 index 0000000000..f16fbf6c4e --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-board/contexts/RecordBoardColumnHeaderAggregateDropdownComponentInstanceContext.ts @@ -0,0 +1,4 @@ +import { createComponentInstanceContext } from '@/ui/utilities/state/component-state/utils/createComponentInstanceContext'; + +export const RecordBoardColumnHeaderAggregateDropdownComponentInstanceContext = + createComponentInstanceContext(); diff --git a/packages/twenty-front/src/modules/object-record/record-board/record-board-column/components/RecordBoardColumnHeader.tsx b/packages/twenty-front/src/modules/object-record/record-board/record-board-column/components/RecordBoardColumnHeader.tsx index 6c6590ec05..f07b3e4cd3 100644 --- a/packages/twenty-front/src/modules/object-record/record-board/record-board-column/components/RecordBoardColumnHeader.tsx +++ b/packages/twenty-front/src/modules/object-record/record-board/record-board-column/components/RecordBoardColumnHeader.tsx @@ -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 && ( - ${boardColumnTotal} + {isAggregateQueryEnabled ? ( + + ) : ( + + {aggregateValue} + )} - {recordCount} {isHeaderHovered && ( diff --git a/packages/twenty-front/src/modules/object-record/record-board/record-board-column/components/RecordBoardColumnHeaderAggregateDropdown.tsx b/packages/twenty-front/src/modules/object-record/record-board/record-board-column/components/RecordBoardColumnHeaderAggregateDropdown.tsx new file mode 100644 index 0000000000..bb1f48a8aa --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-board/record-board-column/components/RecordBoardColumnHeaderAggregateDropdown.tsx @@ -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(); + + return ( + + + } + dropdownComponents={ + + + + } + /> + + ); +}; diff --git a/packages/twenty-front/src/modules/object-record/record-board/record-board-column/components/RecordBoardColumnHeaderAggregateDropdownButton.tsx b/packages/twenty-front/src/modules/object-record/record-board/record-board-column/components/RecordBoardColumnHeaderAggregateDropdownButton.tsx new file mode 100644 index 0000000000..d9e52f7291 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-board/record-board-column/components/RecordBoardColumnHeaderAggregateDropdownButton.tsx @@ -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 ( +
+ + + + +
+ ); +}; diff --git a/packages/twenty-front/src/modules/object-record/record-board/record-board-column/components/RecordBoardColumnHeaderAggregateDropdownContent.tsx b/packages/twenty-front/src/modules/object-record/record-board/record-board-column/components/RecordBoardColumnHeaderAggregateDropdownContent.tsx new file mode 100644 index 0000000000..255480d0ab --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-board/record-board-column/components/RecordBoardColumnHeaderAggregateDropdownContent.tsx @@ -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 ; + default: + return ; + } +}; diff --git a/packages/twenty-front/src/modules/object-record/record-board/record-board-column/components/RecordBoardColumnHeaderAggregateDropdownContext.tsx b/packages/twenty-front/src/modules/object-record/record-board/record-board-column/components/RecordBoardColumnHeaderAggregateDropdownContext.tsx new file mode 100644 index 0000000000..d2225bd189 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-board/record-board-column/components/RecordBoardColumnHeaderAggregateDropdownContext.tsx @@ -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( + {} as RecordBoardColumnHeaderAggregateDropdownContextValue, + ); diff --git a/packages/twenty-front/src/modules/object-record/record-board/record-board-column/components/RecordBoardColumnHeaderAggregateDropdownFieldsContent.tsx b/packages/twenty-front/src/modules/object-record/record-board/record-board-column/components/RecordBoardColumnHeaderAggregateDropdownFieldsContent.tsx new file mode 100644 index 0000000000..ea519eb02d --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-board/record-board-column/components/RecordBoardColumnHeaderAggregateDropdownFieldsContent.tsx @@ -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 ( + <> + + {getAggregateOperationLabel(aggregateOperation)} + + {availableFieldsIdsForAggregateOperation.map((fieldId) => { + const fieldMetadata = objectMetadataItem.fields.find( + (field) => field.id === fieldId, + ); + + if (!fieldMetadata) return null; + return ( + + { + updateViewAggregate({ + kanbanAggregateOperationFieldMetadataId: fieldId, + kanbanAggregateOperation: aggregateOperation, + }); + closeDropdown(); + }} + LeftIcon={getIcon(fieldMetadata.icon) ?? Icon123} + text={fieldMetadata.label} + /> + + ); + })} + + ); +}; diff --git a/packages/twenty-front/src/modules/object-record/record-board/record-board-column/components/RecordBoardColumnHeaderAggregateDropdownMenuContent.tsx b/packages/twenty-front/src/modules/object-record/record-board/record-board-column/components/RecordBoardColumnHeaderAggregateDropdownMenuContent.tsx new file mode 100644 index 0000000000..1d36b8aeb3 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-board/record-board-column/components/RecordBoardColumnHeaderAggregateDropdownMenuContent.tsx @@ -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({ + 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 ( + <> + + { + updateViewAggregate({ + kanbanAggregateOperationFieldMetadataId: null, + kanbanAggregateOperation: AGGREGATE_OPERATIONS.count, + }); + }} + text={getAggregateOperationLabel(AGGREGATE_OPERATIONS.count)} + /> + + {Object.entries(availableAggregations).map( + ([ + availableAggregationOperation, + availableAggregationFieldsIdsForOperation, + ]) => + isEmpty(availableAggregationFieldsIdsForOperation) ? ( + <> + ) : ( + + { + setAggregateOperation( + availableAggregationOperation as AGGREGATE_OPERATIONS, + ); + setAvailableFieldsForAggregateOperation( + availableAggregationFieldsIdsForOperation, + ); + onContentChange('aggregateFields'); + }} + text={getAggregateOperationLabel( + availableAggregationOperation as AGGREGATE_OPERATIONS, + )} + hasSubMenu + /> + + ), + )} + + ); +}; diff --git a/packages/twenty-front/src/modules/object-record/record-board/record-board-column/components/RecordBoardColumnHeaderAggregateDropdownMenuItem.tsx b/packages/twenty-front/src/modules/object-record/record-board/record-board-column/components/RecordBoardColumnHeaderAggregateDropdownMenuItem.tsx new file mode 100644 index 0000000000..2b641776f5 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-board/record-board-column/components/RecordBoardColumnHeaderAggregateDropdownMenuItem.tsx @@ -0,0 +1,15 @@ +import { MenuItem } from 'twenty-ui'; + +export const RecordBoardColumnHeaderAggregateDropdownMenuItem = ({ + onContentChange, + text, + hasSubMenu, +}: { + onContentChange: () => void; + hasSubMenu: boolean; + text: string; +}) => { + return ( + + ); +}; diff --git a/packages/twenty-front/src/modules/object-record/record-board/record-board-column/constants/AggregateDropdownButtonId.ts b/packages/twenty-front/src/modules/object-record/record-board/record-board-column/constants/AggregateDropdownButtonId.ts new file mode 100644 index 0000000000..65efa70954 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-board/record-board-column/constants/AggregateDropdownButtonId.ts @@ -0,0 +1 @@ +export const AGGREGATE_DROPDOWN_ID = 'aggregate-dropdown-id'; diff --git a/packages/twenty-front/src/modules/object-record/record-board/record-board-column/hooks/useAggregateManyRecordsForRecordBoardColumn.ts b/packages/twenty-front/src/modules/object-record/record-board/record-board-column/hooks/useAggregateManyRecordsForRecordBoardColumn.ts new file mode 100644 index 0000000000..5a7c5560bf --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-board/record-board-column/hooks/useAggregateManyRecordsForRecordBoardColumn.ts @@ -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, + }; +}; diff --git a/packages/twenty-front/src/modules/object-record/record-board/record-board-column/states/aggregateDropdownState.ts b/packages/twenty-front/src/modules/object-record/record-board/record-board-column/states/aggregateDropdownState.ts new file mode 100644 index 0000000000..31eeb84fc7 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-board/record-board-column/states/aggregateDropdownState.ts @@ -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({ + key: 'aggregateDropdownState', + defaultValue: { + operation: null, + availableFieldIdsForOperation: [], + }, +}); diff --git a/packages/twenty-front/src/modules/object-record/record-board/record-board-column/states/aggregateOperationComponentState.ts b/packages/twenty-front/src/modules/object-record/record-board/record-board-column/states/aggregateOperationComponentState.ts new file mode 100644 index 0000000000..9b10c7caff --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-board/record-board-column/states/aggregateOperationComponentState.ts @@ -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({ + key: 'aggregateOperationComponentFamilyState', + defaultValue: null, + componentInstanceContext: + RecordBoardColumnHeaderAggregateDropdownComponentInstanceContext, + }); diff --git a/packages/twenty-front/src/modules/object-record/record-board/record-board-column/states/availableFieldIdsForAggregateOperationComponentState.ts b/packages/twenty-front/src/modules/object-record/record-board/record-board-column/states/availableFieldIdsForAggregateOperationComponentState.ts new file mode 100644 index 0000000000..fae2666b63 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-board/record-board-column/states/availableFieldIdsForAggregateOperationComponentState.ts @@ -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({ + key: 'availableFieldIdsForAggregateOperationComponentFamilyState', + defaultValue: [], + componentInstanceContext: + RecordBoardColumnHeaderAggregateDropdownComponentInstanceContext, + }); diff --git a/packages/twenty-front/src/modules/object-record/record-board/record-board-column/utils/__tests__/buildRecordGqlFieldsAggregate.test.ts b/packages/twenty-front/src/modules/object-record/record-board/record-board-column/utils/__tests__/buildRecordGqlFieldsAggregate.test.ts new file mode 100644 index 0000000000..62696fcd66 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-board/record-board-column/utils/__tests__/buildRecordGqlFieldsAggregate.test.ts @@ -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}`, + ); + }); +}); diff --git a/packages/twenty-front/src/modules/object-record/record-board/record-board-column/utils/__tests__/computeAggregateValueAndLabel.test.ts b/packages/twenty-front/src/modules/object-record/record-board/record-board-column/utils/__tests__/computeAggregateValueAndLabel.test.ts new file mode 100644 index 0000000000..f81f89e24f --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-board/record-board-column/utils/__tests__/computeAggregateValueAndLabel.test.ts @@ -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', + }); + }); +}); diff --git a/packages/twenty-front/src/modules/object-record/record-board/record-board-column/utils/__tests__/getAggregateOperationLabel.test.ts b/packages/twenty-front/src/modules/object-record/record-board/record-board-column/utils/__tests__/getAggregateOperationLabel.test.ts new file mode 100644 index 0000000000..4ea4c2e422 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-board/record-board-column/utils/__tests__/getAggregateOperationLabel.test.ts @@ -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'); + }); +}); diff --git a/packages/twenty-front/src/modules/object-record/record-board/record-board-column/utils/buildRecordGqlFieldsAggregate.ts b/packages/twenty-front/src/modules/object-record/record-board/record-board-column/utils/buildRecordGqlFieldsAggregate.ts new file mode 100644 index 0000000000..f7bca49306 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-board/record-board-column/utils/buildRecordGqlFieldsAggregate.ts @@ -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; +}; diff --git a/packages/twenty-front/src/modules/object-record/record-board/record-board-column/utils/computeAggregateValueAndLabel.ts b/packages/twenty-front/src/modules/object-record/record-board/record-board-column/utils/computeAggregateValueAndLabel.ts new file mode 100644 index 0000000000..9140d84058 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-board/record-board-column/utils/computeAggregateValueAndLabel.ts @@ -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}`, + }; +}; diff --git a/packages/twenty-front/src/modules/object-record/record-board/record-board-column/utils/getAggregateOperationLabel.ts b/packages/twenty-front/src/modules/object-record/record-board/record-board-column/utils/getAggregateOperationLabel.ts new file mode 100644 index 0000000000..8a59d0e7d8 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-board/record-board-column/utils/getAggregateOperationLabel.ts @@ -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}`); + } +}; diff --git a/packages/twenty-front/src/modules/object-record/record-board/types/AggregateContentId.ts b/packages/twenty-front/src/modules/object-record/record-board/types/AggregateContentId.ts new file mode 100644 index 0000000000..800a2d3de7 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-board/types/AggregateContentId.ts @@ -0,0 +1 @@ +export type AggregateContentId = 'aggregateOperations' | 'aggregateFields'; diff --git a/packages/twenty-front/src/modules/object-record/record-board/types/BoardColumnHotkeyScope.ts b/packages/twenty-front/src/modules/object-record/record-board/types/BoardColumnHotkeyScope.ts index c62d939355..abb33cec1c 100644 --- a/packages/twenty-front/src/modules/object-record/record-board/types/BoardColumnHotkeyScope.ts +++ b/packages/twenty-front/src/modules/object-record/record-board/types/BoardColumnHotkeyScope.ts @@ -1,3 +1,4 @@ export enum RecordBoardColumnHotkeyScope { BoardColumn = 'board-column', + ColumnHeader = 'column-header', } diff --git a/packages/twenty-front/src/modules/object-record/record-index/components/RecordIndexContainer.tsx b/packages/twenty-front/src/modules/object-record/record-index/components/RecordIndexContainer.tsx index 0da7a71721..7af9419473 100644 --- a/packages/twenty-front/src/modules/object-record/record-index/components/RecordIndexContainer.tsx +++ b/packages/twenty-front/src/modules/object-record/record-index/components/RecordIndexContainer.tsx @@ -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); }} /> diff --git a/packages/twenty-front/src/modules/object-record/record-index/states/recordIndexKanbanAggregateOperationState.ts b/packages/twenty-front/src/modules/object-record/record-index/states/recordIndexKanbanAggregateOperationState.ts new file mode 100644 index 0000000000..9b60fae0c9 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-index/states/recordIndexKanbanAggregateOperationState.ts @@ -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({ + key: 'recordIndexKanbanAggregateOperationState', + defaultValue: null, + }); diff --git a/packages/twenty-front/src/modules/object-record/record-table/constants/AggregateOperations.ts b/packages/twenty-front/src/modules/object-record/record-table/constants/AggregateOperations.ts new file mode 100644 index 0000000000..1ee85c2522 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-table/constants/AggregateOperations.ts @@ -0,0 +1,7 @@ +export enum AGGREGATE_OPERATIONS { + min = 'MIN', + max = 'MAX', + avg = 'AVG', + sum = 'SUM', + count = 'COUNT', +} diff --git a/packages/twenty-front/src/modules/object-record/record-table/constants/FieldsAvailableByAggregateOperation.ts b/packages/twenty-front/src/modules/object-record/record-table/constants/FieldsAvailableByAggregateOperation.ts new file mode 100644 index 0000000000..0caed3982c --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-table/constants/FieldsAvailableByAggregateOperation.ts @@ -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, + ], +}; diff --git a/packages/twenty-front/src/modules/object-record/types/AggregateOperationsOmittingCount.ts b/packages/twenty-front/src/modules/object-record/types/AggregateOperationsOmittingCount.ts new file mode 100644 index 0000000000..3ffcb7a534 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/types/AggregateOperationsOmittingCount.ts @@ -0,0 +1,6 @@ +import { AGGREGATE_OPERATIONS } from '@/object-record/record-table/constants/AggregateOperations'; + +export type AggregateOperationsOmittingCount = Exclude< + AGGREGATE_OPERATIONS, + AGGREGATE_OPERATIONS.count +>; diff --git a/packages/twenty-front/src/modules/object-record/types/AvailableFieldsForAggregateOperation.ts b/packages/twenty-front/src/modules/object-record/types/AvailableFieldsForAggregateOperation.ts new file mode 100644 index 0000000000..1e277e08b4 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/types/AvailableFieldsForAggregateOperation.ts @@ -0,0 +1,5 @@ +import { AggregateOperationsOmittingCount } from '@/object-record/types/AggregateOperationsOmittingCount'; + +export type AvailableFieldsForAggregateOperation = { + [T in AggregateOperationsOmittingCount]?: string[]; +}; diff --git a/packages/twenty-front/src/modules/object-record/utils/__tests__/generateAggregateQuery.test.ts b/packages/twenty-front/src/modules/object-record/utils/__tests__/generateAggregateQuery.test.ts new file mode 100644 index 0000000000..a1f6c7aaea --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/utils/__tests__/generateAggregateQuery.test.ts @@ -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 } }', + ); + }); +}); diff --git a/packages/twenty-front/src/modules/object-record/utils/__tests__/getAvailableFieldsIdsForAggregationFromObjectFields.test.ts b/packages/twenty-front/src/modules/object-record/utils/__tests__/getAvailableFieldsIdsForAggregationFromObjectFields.test.ts new file mode 100644 index 0000000000..5d37247f83 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/utils/__tests__/getAvailableFieldsIdsForAggregationFromObjectFields.test.ts @@ -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([]); + } + }); + }); +}); diff --git a/packages/twenty-front/src/modules/object-record/utils/__tests__/initializeAvailableFieldsForAggregateOperationMap.test.ts b/packages/twenty-front/src/modules/object-record/utils/__tests__/initializeAvailableFieldsForAggregateOperationMap.test.ts new file mode 100644 index 0000000000..5e5902dce4 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/utils/__tests__/initializeAvailableFieldsForAggregateOperationMap.test.ts @@ -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(); + }); +}); diff --git a/packages/twenty-front/src/modules/object-record/utils/__tests__/isFieldTypeValidForAggregateOperation.test.ts b/packages/twenty-front/src/modules/object-record/utils/__tests__/isFieldTypeValidForAggregateOperation.test.ts new file mode 100644 index 0000000000..4d975347b9 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/utils/__tests__/isFieldTypeValidForAggregateOperation.test.ts @@ -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); + }); + }); +}); diff --git a/packages/twenty-front/src/modules/object-record/utils/generateAggregateQuery.ts b/packages/twenty-front/src/modules/object-record/utils/generateAggregateQuery.ts new file mode 100644 index 0000000000..476d6fc378 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/utils/generateAggregateQuery.ts @@ -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} + } + } + `; +}; diff --git a/packages/twenty-front/src/modules/object-record/utils/getAvailableAggregationsFromObjectFields.ts b/packages/twenty-front/src/modules/object-record/utils/getAvailableAggregationsFromObjectFields.ts new file mode 100644 index 0000000000..241324df6c --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/utils/getAvailableAggregationsFromObjectFields.ts @@ -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>((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; + }, {}); +}; diff --git a/packages/twenty-front/src/modules/object-record/utils/getAvailableFieldsIdsForAggregationFromObjectFields.ts b/packages/twenty-front/src/modules/object-record/utils/getAvailableFieldsIdsForAggregationFromObjectFields.ts new file mode 100644 index 0000000000..60ec12954d --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/utils/getAvailableFieldsIdsForAggregationFromObjectFields.ts @@ -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); +}; diff --git a/packages/twenty-front/src/modules/object-record/utils/initializeAvailableFieldsForAggregateOperationMap.ts b/packages/twenty-front/src/modules/object-record/utils/initializeAvailableFieldsForAggregateOperationMap.ts new file mode 100644 index 0000000000..67cb086a50 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/utils/initializeAvailableFieldsForAggregateOperationMap.ts @@ -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]: [], + }), + {}, + ); + }; diff --git a/packages/twenty-front/src/modules/object-record/utils/isFieldTypeValidForAggregateOperation.ts b/packages/twenty-front/src/modules/object-record/utils/isFieldTypeValidForAggregateOperation.ts new file mode 100644 index 0000000000..b480ce0484 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/utils/isFieldTypeValidForAggregateOperation.ts @@ -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, + ); +}; diff --git a/packages/twenty-front/src/modules/prefetch/graphql/operation-signatures/factories/findAllViewsOperationSignatureFactory.ts b/packages/twenty-front/src/modules/prefetch/graphql/operation-signatures/factories/findAllViewsOperationSignatureFactory.ts index f588061160..04af6f0108 100644 --- a/packages/twenty-front/src/modules/prefetch/graphql/operation-signatures/factories/findAllViewsOperationSignatureFactory.ts +++ b/packages/twenty-front/src/modules/prefetch/graphql/operation-signatures/factories/findAllViewsOperationSignatureFactory.ts @@ -14,6 +14,8 @@ export const findAllViewsOperationSignatureFactory: RecordGqlOperationSignatureF position: true, type: true, kanbanFieldMetadataId: true, + kanbanAggregateOperation: true, + kanbanAggregateOperationFieldMetadataId: true, name: true, icon: true, key: true, diff --git a/packages/twenty-front/src/modules/views/hooks/useCreateViewFromCurrentView.ts b/packages/twenty-front/src/modules/views/hooks/useCreateViewFromCurrentView.ts index c936ab87b3..b159ffd51c 100644 --- a/packages/twenty-front/src/modules/views/hooks/useCreateViewFromCurrentView.ts +++ b/packages/twenty-front/src/modules/views/hooks/useCreateViewFromCurrentView.ts @@ -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, ); diff --git a/packages/twenty-front/src/modules/views/hooks/useUpdateViewAggregate.ts b/packages/twenty-front/src/modules/views/hooks/useUpdateViewAggregate.ts new file mode 100644 index 0000000000..6e0b8a704c --- /dev/null +++ b/packages/twenty-front/src/modules/views/hooks/useUpdateViewAggregate.ts @@ -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, + }; +}; diff --git a/packages/twenty-front/src/modules/views/types/GraphQLView.ts b/packages/twenty-front/src/modules/views/types/GraphQLView.ts index cb8729f915..7ea55b6690 100644 --- a/packages/twenty-front/src/modules/views/types/GraphQLView.ts +++ b/packages/twenty-front/src/modules/views/types/GraphQLView.ts @@ -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[]; diff --git a/packages/twenty-front/src/modules/views/types/View.ts b/packages/twenty-front/src/modules/views/types/View.ts index 111c5a5002..3a976710c0 100644 --- a/packages/twenty-front/src/modules/views/types/View.ts +++ b/packages/twenty-front/src/modules/views/types/View.ts @@ -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'; diff --git a/packages/twenty-front/src/modules/views/types/ViewField.ts b/packages/twenty-front/src/modules/views/types/ViewField.ts index 708f2b0343..1cd2dff16a 100644 --- a/packages/twenty-front/src/modules/views/types/ViewField.ts +++ b/packages/twenty-front/src/modules/views/types/ViewField.ts @@ -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 | RecordBoardFieldDefinition; diff --git a/packages/twenty-front/src/modules/views/utils/getObjectMetadataItemViews.ts b/packages/twenty-front/src/modules/views/utils/getObjectMetadataItemViews.ts index ebc1f502f1..88c961c39d 100644 --- a/packages/twenty-front/src/modules/views/utils/getObjectMetadataItemViews.ts +++ b/packages/twenty-front/src/modules/views/utils/getObjectMetadataItemViews.ts @@ -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, })); }; diff --git a/packages/twenty-front/src/modules/views/utils/mapViewFieldsToColumnDefinitions.ts b/packages/twenty-front/src/modules/views/utils/mapViewFieldsToColumnDefinitions.ts index 1c8ef4cd3d..95b7b16953 100644 --- a/packages/twenty-front/src/modules/views/utils/mapViewFieldsToColumnDefinitions.ts +++ b/packages/twenty-front/src/modules/views/utils/mapViewFieldsToColumnDefinitions.ts @@ -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, diff --git a/packages/twenty-front/src/modules/views/view-picker/hooks/useCreateViewFromCurrentState.ts b/packages/twenty-front/src/modules/views/view-picker/hooks/useCreateViewFromCurrentState.ts index c84ec24f29..edd8421e54 100644 --- a/packages/twenty-front/src/modules/views/view-picker/hooks/useCreateViewFromCurrentState.ts +++ b/packages/twenty-front/src/modules/views/view-picker/hooks/useCreateViewFromCurrentState.ts @@ -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(); diff --git a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/constants/aggregate-operations.constant.ts b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/constants/aggregate-operations.constant.ts new file mode 100644 index 0000000000..1ee85c2522 --- /dev/null +++ b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/constants/aggregate-operations.constant.ts @@ -0,0 +1,7 @@ +export enum AGGREGATE_OPERATIONS { + min = 'MIN', + max = 'MAX', + avg = 'AVG', + sum = 'SUM', + count = 'COUNT', +} diff --git a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query-selected-fields/graphql-selected-fields.parser.ts b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query-selected-fields/graphql-selected-fields.parser.ts index 5c35b1eff0..baceea4997 100644 --- a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query-selected-fields/graphql-selected-fields.parser.ts +++ b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query-selected-fields/graphql-selected-fields.parser.ts @@ -45,6 +45,12 @@ export class GraphqlQuerySelectedFieldsParser { return accumulator; } + this.aggregateParser.parse( + graphqlSelectedFields, + fieldMetadataMapByName, + accumulator, + ); + this.parseRecordField( graphqlSelectedFields, fieldMetadataMapByName, diff --git a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/helpers/process-aggregate.helper.ts b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/helpers/process-aggregate.helper.ts index 6f229a0860..1388c55b7e 100644 --- a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/helpers/process-aggregate.helper.ts +++ b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/helpers/process-aggregate.helper.ts @@ -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}`, ); } diff --git a/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/utils/get-available-aggregations-from-object-fields.util.ts b/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/utils/get-available-aggregations-from-object-fields.util.ts index 0a0e475357..4872f6b9df 100644 --- a/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/utils/get-available-aggregations-from-object-fields.util.ts +++ b/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/utils/get-available-aggregations-from-object-fields.util.ts @@ -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, }; } diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-sync-metadata/constants/standard-field-ids.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-sync-metadata/constants/standard-field-ids.ts index 60e26ddcb6..462c3748dd 100644 --- a/packages/twenty-server/src/engine/workspace-manager/workspace-sync-metadata/constants/standard-field-ids.ts +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-sync-metadata/constants/standard-field-ids.ts @@ -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', diff --git a/packages/twenty-server/src/modules/view/standard-objects/view-field.workspace-entity.ts b/packages/twenty-server/src/modules/view/standard-objects/view-field.workspace-entity.ts index 9de31a8f02..daf1bfc7a6 100644 --- a/packages/twenty-server/src/modules/view/standard-objects/view-field.workspace-entity.ts +++ b/packages/twenty-server/src/modules/view/standard-objects/view-field.workspace-entity.ts @@ -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; + @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; } diff --git a/packages/twenty-server/src/modules/view/standard-objects/view.workspace-entity.ts b/packages/twenty-server/src/modules/view/standard-objects/view.workspace-entity.ts index 67af0c9fa2..499b597219 100644 --- a/packages/twenty-server/src/modules/view/standard-objects/view.workspace-entity.ts +++ b/packages/twenty-server/src/modules/view/standard-objects/view.workspace-entity.ts @@ -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; + + @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; }