mirror of
https://github.com/twentyhq/twenty.git
synced 2024-12-23 03:51:36 +03:00
Display and update aggregate queries in kanban views (#8833)
Closes #8752, #8753, #8754 Implements usage of aggregate queries in kanban views. https://github.com/user-attachments/assets/732590ca-2785-4c57-82d5-d999a2279e92 TO DO 1. write tests + storybook 2. Fix values displayed should have the same format as defined in number fields + Fix display for amountMicros --------- Co-authored-by: Weiko <corentin@twenty.com>
This commit is contained in:
parent
5e891a135b
commit
2fc247cb21
@ -0,0 +1 @@
|
||||
export const DROPDOWN_OFFSET_Y = 8;
|
@ -0,0 +1 @@
|
||||
export const DROPDOWN_WIDTH = '200px';
|
@ -0,0 +1,19 @@
|
||||
import { useCallback, useState } from 'react';
|
||||
|
||||
export const useCurrentContentId = <T>() => {
|
||||
const [currentContentId, setCurrentContentId] = useState<T | null>(null);
|
||||
|
||||
const handleContentChange = useCallback((key: T) => {
|
||||
setCurrentContentId(key);
|
||||
}, []);
|
||||
|
||||
const handleResetContent = useCallback(() => {
|
||||
setCurrentContentId(null);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
currentContentId,
|
||||
handleContentChange,
|
||||
handleResetContent,
|
||||
};
|
||||
};
|
@ -0,0 +1,35 @@
|
||||
import { ObjectOptionsDropdownContextValue } from '@/object-record/object-options-dropdown/states/contexts/ObjectOptionsDropdownContext';
|
||||
import { RecordBoardColumnHeaderAggregateDropdownContextValue } from '@/object-record/record-board/record-board-column/components/RecordBoardColumnHeaderAggregateDropdownContext';
|
||||
import { useDropdown as useDropdownUi } from '@/ui/layout/dropdown/hooks/useDropdown';
|
||||
import { Context, useCallback, useContext } from 'react';
|
||||
|
||||
export const useDropdown = <
|
||||
T extends
|
||||
| RecordBoardColumnHeaderAggregateDropdownContextValue
|
||||
| ObjectOptionsDropdownContextValue,
|
||||
>({
|
||||
context,
|
||||
}: {
|
||||
context: Context<T>;
|
||||
}) => {
|
||||
const dropdownContext = useContext(context);
|
||||
|
||||
if (!dropdownContext) {
|
||||
throw new Error(
|
||||
`useDropdown must be used within a context provider (${context.Provider.name})`,
|
||||
);
|
||||
}
|
||||
const dropdownId = dropdownContext.dropdownId;
|
||||
const { closeDropdown } = useDropdownUi(dropdownId);
|
||||
|
||||
const handleCloseDropdown = useCallback(() => {
|
||||
dropdownContext.resetContent();
|
||||
closeDropdown();
|
||||
}, [closeDropdown, dropdownContext]);
|
||||
|
||||
return {
|
||||
...dropdownContext,
|
||||
closeDropdown: handleCloseDropdown,
|
||||
resetContent: dropdownContext.resetContent,
|
||||
};
|
||||
};
|
@ -14,4 +14,5 @@ export type RecordGqlConnection = {
|
||||
totalCount?: number;
|
||||
};
|
||||
totalCount?: number;
|
||||
[aggregateFieldName: string]: any;
|
||||
};
|
||||
|
@ -0,0 +1,3 @@
|
||||
import { AGGREGATE_OPERATIONS } from '@/object-record/record-table/constants/AggregateOperations';
|
||||
|
||||
export type RecordGqlFieldsAggregate = Record<string, AGGREGATE_OPERATIONS>;
|
@ -0,0 +1,70 @@
|
||||
import { useQuery } from '@apollo/client';
|
||||
|
||||
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
|
||||
import { RecordGqlFieldsAggregate } from '@/object-record/graphql/types/RecordGqlFieldsAggregate';
|
||||
import { RecordGqlOperationFilter } from '@/object-record/graphql/types/RecordGqlOperationFilter';
|
||||
import { RecordGqlOperationFindManyResult } from '@/object-record/graphql/types/RecordGqlOperationFindManyResult';
|
||||
import { useAggregateManyRecordsQuery } from '@/object-record/hooks/useAggregateManyRecordsQuery';
|
||||
import { AGGREGATE_OPERATIONS } from '@/object-record/record-table/constants/AggregateOperations';
|
||||
import isEmpty from 'lodash.isempty';
|
||||
import { isDefined } from 'twenty-ui';
|
||||
|
||||
export type AggregateManyRecordsData = {
|
||||
[fieldName: string]: {
|
||||
[operation in AGGREGATE_OPERATIONS]?: string | number | undefined;
|
||||
};
|
||||
};
|
||||
|
||||
export const useAggregateManyRecords = ({
|
||||
objectNameSingular,
|
||||
filter,
|
||||
recordGqlFieldsAggregate,
|
||||
skip,
|
||||
}: {
|
||||
objectNameSingular: string;
|
||||
recordGqlFieldsAggregate: RecordGqlFieldsAggregate;
|
||||
filter?: RecordGqlOperationFilter;
|
||||
skip?: boolean;
|
||||
}) => {
|
||||
const { objectMetadataItem } = useObjectMetadataItem({
|
||||
objectNameSingular,
|
||||
});
|
||||
|
||||
const { aggregateQuery, gqlFieldToFieldMap } = useAggregateManyRecordsQuery({
|
||||
objectNameSingular,
|
||||
recordGqlFieldsAggregate,
|
||||
});
|
||||
|
||||
const { data, loading, error } = useQuery<RecordGqlOperationFindManyResult>(
|
||||
aggregateQuery,
|
||||
{
|
||||
skip: skip || !objectMetadataItem,
|
||||
variables: {
|
||||
filter,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const formattedData: AggregateManyRecordsData = {};
|
||||
|
||||
if (!isEmpty(data)) {
|
||||
Object.entries(data?.[objectMetadataItem.namePlural] ?? {})?.forEach(
|
||||
([gqlField, result]) => {
|
||||
if (isDefined(gqlFieldToFieldMap[gqlField])) {
|
||||
const [fieldName, aggregateOperation] = gqlFieldToFieldMap[gqlField];
|
||||
formattedData[fieldName] = {
|
||||
...(formattedData[fieldName] ?? {}),
|
||||
[aggregateOperation]: result,
|
||||
};
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
objectMetadataItem,
|
||||
data: formattedData,
|
||||
loading,
|
||||
error,
|
||||
};
|
||||
};
|
@ -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,
|
||||
};
|
||||
};
|
@ -1,13 +1,15 @@
|
||||
import { DROPDOWN_OFFSET_Y } from '@/dropdown/constants/DropdownOffsetY';
|
||||
import { DROPDOWN_WIDTH } from '@/dropdown/constants/DropdownWidth';
|
||||
import { useCurrentContentId } from '@/dropdown/hooks/useCurrentContentId';
|
||||
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
|
||||
import { ObjectOptionsDropdownButton } from '@/object-record/object-options-dropdown/components/ObjectOptionsDropdownButton';
|
||||
import { ObjectOptionsDropdownContent } from '@/object-record/object-options-dropdown/components/ObjectOptionsDropdownContent';
|
||||
import { OBJECT_OPTIONS_DROPDOWN_ID } from '@/object-record/object-options-dropdown/constants/ObjectOptionsDropdownId';
|
||||
import { ObjectOptionsDropdownContext } from '@/object-record/object-options-dropdown/states/contexts/ObjectOptionsDropdownContext';
|
||||
import { ObjectOptionsContentId } from '@/object-record/object-options-dropdown/types/ObjectOptionsContentId';
|
||||
import { TableOptionsHotkeyScope } from '@/object-record/record-table/types/TableOptionsHotkeyScope';
|
||||
import { Dropdown } from '@/ui/layout/dropdown/components/Dropdown';
|
||||
import { StyledHeaderDropdownButton } from '@/ui/layout/dropdown/components/StyledHeaderDropdownButton';
|
||||
import { ViewType } from '@/views/types/ViewType';
|
||||
import { useCallback, useState } from 'react';
|
||||
|
||||
type ObjectOptionsDropdownProps = {
|
||||
viewType: ViewType;
|
||||
@ -20,24 +22,18 @@ export const ObjectOptionsDropdown = ({
|
||||
objectMetadataItem,
|
||||
viewType,
|
||||
}: ObjectOptionsDropdownProps) => {
|
||||
const [currentContentId, setCurrentContentId] =
|
||||
useState<ObjectOptionsContentId | null>(null);
|
||||
|
||||
const handleContentChange = useCallback((key: ObjectOptionsContentId) => {
|
||||
setCurrentContentId(key);
|
||||
}, []);
|
||||
|
||||
const handleResetContent = useCallback(() => {
|
||||
setCurrentContentId(null);
|
||||
}, []);
|
||||
const { currentContentId, handleContentChange, handleResetContent } =
|
||||
useCurrentContentId<ObjectOptionsContentId>();
|
||||
|
||||
return (
|
||||
<Dropdown
|
||||
dropdownId={OBJECT_OPTIONS_DROPDOWN_ID}
|
||||
clickableComponent={<ObjectOptionsDropdownButton />}
|
||||
dropdownMenuWidth={'200px'}
|
||||
dropdownHotkeyScope={{ scope: TableOptionsHotkeyScope.Dropdown }}
|
||||
dropdownOffset={{ y: 8 }}
|
||||
dropdownMenuWidth={DROPDOWN_WIDTH}
|
||||
dropdownOffset={{ y: DROPDOWN_OFFSET_Y }}
|
||||
clickableComponent={
|
||||
<StyledHeaderDropdownButton>Options</StyledHeaderDropdownButton>
|
||||
}
|
||||
dropdownComponents={
|
||||
<ObjectOptionsDropdownContext.Provider
|
||||
value={{
|
||||
@ -47,6 +43,7 @@ export const ObjectOptionsDropdown = ({
|
||||
currentContentId,
|
||||
onContentChange: handleContentChange,
|
||||
resetContent: handleResetContent,
|
||||
dropdownId: OBJECT_OPTIONS_DROPDOWN_ID,
|
||||
}}
|
||||
>
|
||||
<ObjectOptionsDropdownContent />
|
||||
|
@ -1,18 +0,0 @@
|
||||
import { OBJECT_OPTIONS_DROPDOWN_ID } from '@/object-record/object-options-dropdown/constants/ObjectOptionsDropdownId';
|
||||
import { StyledHeaderDropdownButton } from '@/ui/layout/dropdown/components/StyledHeaderDropdownButton';
|
||||
import { useDropdown } from '@/ui/layout/dropdown/hooks/useDropdown';
|
||||
|
||||
export const ObjectOptionsDropdownButton = () => {
|
||||
const { isDropdownOpen, toggleDropdown } = useDropdown(
|
||||
OBJECT_OPTIONS_DROPDOWN_ID,
|
||||
);
|
||||
|
||||
return (
|
||||
<StyledHeaderDropdownButton
|
||||
isUnfolded={isDropdownOpen}
|
||||
onClick={toggleDropdown}
|
||||
>
|
||||
Options
|
||||
</StyledHeaderDropdownButton>
|
||||
);
|
||||
};
|
@ -4,6 +4,7 @@ import { ComponentDecorator } from 'twenty-ui';
|
||||
import { ContextStoreComponentInstanceContext } from '@/context-store/states/contexts/ContextStoreComponentInstanceContext';
|
||||
import { objectMetadataItemsState } from '@/object-metadata/states/objectMetadataItemsState';
|
||||
import { ObjectOptionsDropdownContent } from '@/object-record/object-options-dropdown/components/ObjectOptionsDropdownContent';
|
||||
import { OBJECT_OPTIONS_DROPDOWN_ID } from '@/object-record/object-options-dropdown/constants/ObjectOptionsDropdownId';
|
||||
import { ObjectOptionsDropdownContext } from '@/object-record/object-options-dropdown/states/contexts/ObjectOptionsDropdownContext';
|
||||
import { ObjectOptionsContentId } from '@/object-record/object-options-dropdown/types/ObjectOptionsContentId';
|
||||
import { RecordIndexRootPropsContext } from '@/object-record/record-index/contexts/RecordIndexRootPropsContext';
|
||||
@ -94,6 +95,7 @@ const createStory = (contentId: ObjectOptionsContentId | null): Story => ({
|
||||
currentContentId: contentId,
|
||||
onContentChange: () => {},
|
||||
resetContent: () => {},
|
||||
dropdownId: OBJECT_OPTIONS_DROPDOWN_ID,
|
||||
}}
|
||||
>
|
||||
<DropdownMenu>
|
||||
|
@ -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,
|
||||
}}
|
||||
>
|
||||
|
@ -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,
|
||||
};
|
||||
};
|
||||
|
@ -10,6 +10,7 @@ export type ObjectOptionsDropdownContextValue = {
|
||||
currentContentId: ObjectOptionsContentId | null;
|
||||
onContentChange: (key: ObjectOptionsContentId) => void;
|
||||
resetContent: () => void;
|
||||
dropdownId: string;
|
||||
};
|
||||
|
||||
export const ObjectOptionsDropdownContext =
|
||||
|
@ -0,0 +1,4 @@
|
||||
import { createComponentInstanceContext } from '@/ui/utilities/state/component-state/utils/createComponentInstanceContext';
|
||||
|
||||
export const RecordBoardColumnHeaderAggregateDropdownComponentInstanceContext =
|
||||
createComponentInstanceContext();
|
@ -4,12 +4,15 @@ import { useContext, useState } from 'react';
|
||||
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
|
||||
import { RecordBoardContext } from '@/object-record/record-board/contexts/RecordBoardContext';
|
||||
import { RecordBoardColumnDropdownMenu } from '@/object-record/record-board/record-board-column/components/RecordBoardColumnDropdownMenu';
|
||||
import { RecordBoardColumnHeaderAggregateDropdown } from '@/object-record/record-board/record-board-column/components/RecordBoardColumnHeaderAggregateDropdown';
|
||||
import { RecordBoardColumnContext } from '@/object-record/record-board/record-board-column/contexts/RecordBoardColumnContext';
|
||||
import { useAggregateManyRecordsForRecordBoardColumn } from '@/object-record/record-board/record-board-column/hooks/useAggregateManyRecordsForRecordBoardColumn';
|
||||
import { useColumnNewCardActions } from '@/object-record/record-board/record-board-column/hooks/useColumnNewCardActions';
|
||||
import { useIsOpportunitiesCompanyFieldDisabled } from '@/object-record/record-board/record-board-column/hooks/useIsOpportunitiesCompanyFieldDisabled';
|
||||
import { RecordBoardColumnHotkeyScope } from '@/object-record/record-board/types/BoardColumnHotkeyScope';
|
||||
import { RecordGroupDefinitionType } from '@/object-record/record-group/types/RecordGroupDefinition';
|
||||
import { usePreviousHotkeyScope } from '@/ui/utilities/hotkey/hooks/usePreviousHotkeyScope';
|
||||
import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
|
||||
import { IconDotsVertical, IconPlus, LightIconButton, Tag } from 'twenty-ui';
|
||||
|
||||
const StyledHeader = styled.div`
|
||||
@ -19,21 +22,7 @@ const StyledHeader = styled.div`
|
||||
flex-direction: row;
|
||||
justify-content: left;
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
const StyledAmount = styled.div`
|
||||
color: ${({ theme }) => theme.font.color.tertiary};
|
||||
margin-left: ${({ theme }) => theme.spacing(2)};
|
||||
`;
|
||||
|
||||
const StyledNumChildren = styled.div`
|
||||
align-items: center;
|
||||
color: ${({ theme }) => theme.font.color.tertiary};
|
||||
display: flex;
|
||||
height: 24px;
|
||||
justify-content: center;
|
||||
line-height: ${({ theme }) => theme.text.lineHeight.lg};
|
||||
width: 22px;
|
||||
height: 100%;
|
||||
`;
|
||||
|
||||
const StyledHeaderActions = styled.div`
|
||||
@ -52,6 +41,16 @@ const StyledLeftContainer = styled.div`
|
||||
gap: ${({ theme }) => theme.spacing(1)};
|
||||
`;
|
||||
|
||||
const StyledRecordCountChildren = styled.div`
|
||||
align-items: center;
|
||||
color: ${({ theme }) => theme.font.color.tertiary};
|
||||
display: flex;
|
||||
height: 24px;
|
||||
justify-content: center;
|
||||
line-height: ${({ theme }) => theme.text.lineHeight.lg};
|
||||
width: 22px;
|
||||
`;
|
||||
|
||||
const StyledRightContainer = styled.div`
|
||||
align-items: center;
|
||||
display: flex;
|
||||
@ -70,9 +69,7 @@ const StyledColumn = styled.div`
|
||||
`;
|
||||
|
||||
export const RecordBoardColumnHeader = () => {
|
||||
const { columnDefinition, recordCount } = useContext(
|
||||
RecordBoardColumnContext,
|
||||
);
|
||||
const { columnDefinition } = useContext(RecordBoardColumnContext);
|
||||
const [isBoardColumnMenuOpen, setIsBoardColumnMenuOpen] = useState(false);
|
||||
const [isHeaderHovered, setIsHeaderHovered] = useState(false);
|
||||
|
||||
@ -98,10 +95,15 @@ export const RecordBoardColumnHeader = () => {
|
||||
setIsBoardColumnMenuOpen(false);
|
||||
};
|
||||
|
||||
const boardColumnTotal = 0;
|
||||
const { aggregateValue, aggregateLabel } =
|
||||
useAggregateManyRecordsForRecordBoardColumn();
|
||||
|
||||
const { handleNewButtonClick } = useColumnNewCardActions(
|
||||
columnDefinition?.id ?? '',
|
||||
columnDefinition.id ?? '',
|
||||
);
|
||||
|
||||
const isAggregateQueryEnabled = useIsFeatureEnabled(
|
||||
'IS_AGGREGATE_QUERY_ENABLED',
|
||||
);
|
||||
|
||||
const { isOpportunitiesCompanyFieldDisabled } =
|
||||
@ -138,10 +140,18 @@ export const RecordBoardColumnHeader = () => {
|
||||
: 'medium'
|
||||
}
|
||||
/>
|
||||
{!!boardColumnTotal && (
|
||||
<StyledAmount>${boardColumnTotal}</StyledAmount>
|
||||
{isAggregateQueryEnabled ? (
|
||||
<RecordBoardColumnHeaderAggregateDropdown
|
||||
aggregateValue={aggregateValue}
|
||||
dropdownId={`record-board-column-aggregate-dropdown-${columnDefinition.id}`}
|
||||
objectMetadataItem={objectMetadataItem}
|
||||
aggregateLabel={aggregateLabel}
|
||||
/>
|
||||
) : (
|
||||
<StyledRecordCountChildren>
|
||||
{aggregateValue}
|
||||
</StyledRecordCountChildren>
|
||||
)}
|
||||
<StyledNumChildren>{recordCount}</StyledNumChildren>
|
||||
</StyledLeftContainer>
|
||||
<StyledRightContainer>
|
||||
{isHeaderHovered && (
|
||||
|
@ -0,0 +1,63 @@
|
||||
import { DROPDOWN_OFFSET_Y } from '@/dropdown/constants/DropdownOffsetY';
|
||||
import { DROPDOWN_WIDTH } from '@/dropdown/constants/DropdownWidth';
|
||||
import { useCurrentContentId } from '@/dropdown/hooks/useCurrentContentId';
|
||||
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
|
||||
import { RecordBoardColumnHeaderAggregateDropdownComponentInstanceContext } from '@/object-record/record-board/contexts/RecordBoardColumnHeaderAggregateDropdownComponentInstanceContext';
|
||||
import { RecordBoardColumnHeaderAggregateDropdownButton } from '@/object-record/record-board/record-board-column/components/RecordBoardColumnHeaderAggregateDropdownButton';
|
||||
import { AggregateDropdownContent } from '@/object-record/record-board/record-board-column/components/RecordBoardColumnHeaderAggregateDropdownContent';
|
||||
import { RecordBoardColumnHeaderAggregateDropdownContext } from '@/object-record/record-board/record-board-column/components/RecordBoardColumnHeaderAggregateDropdownContext';
|
||||
import { AggregateContentId } from '@/object-record/record-board/types/AggregateContentId';
|
||||
import { RecordBoardColumnHotkeyScope } from '@/object-record/record-board/types/BoardColumnHotkeyScope';
|
||||
import { Dropdown } from '@/ui/layout/dropdown/components/Dropdown';
|
||||
|
||||
type RecordBoardColumnHeaderAggregateDropdownProps = {
|
||||
aggregateValue: string | number;
|
||||
aggregateLabel?: string;
|
||||
objectMetadataItem: ObjectMetadataItem;
|
||||
dropdownId: string;
|
||||
};
|
||||
|
||||
export const RecordBoardColumnHeaderAggregateDropdown = ({
|
||||
objectMetadataItem,
|
||||
aggregateValue,
|
||||
aggregateLabel,
|
||||
dropdownId,
|
||||
}: RecordBoardColumnHeaderAggregateDropdownProps) => {
|
||||
const { currentContentId, handleContentChange, handleResetContent } =
|
||||
useCurrentContentId<AggregateContentId>();
|
||||
|
||||
return (
|
||||
<RecordBoardColumnHeaderAggregateDropdownComponentInstanceContext.Provider
|
||||
value={{ instanceId: dropdownId }}
|
||||
>
|
||||
<Dropdown
|
||||
dropdownId={dropdownId}
|
||||
dropdownHotkeyScope={{
|
||||
scope: RecordBoardColumnHotkeyScope.ColumnHeader,
|
||||
}}
|
||||
dropdownMenuWidth={DROPDOWN_WIDTH}
|
||||
dropdownOffset={{ y: DROPDOWN_OFFSET_Y }}
|
||||
clickableComponent={
|
||||
<RecordBoardColumnHeaderAggregateDropdownButton
|
||||
dropdownId={dropdownId}
|
||||
value={aggregateValue}
|
||||
tooltip={aggregateLabel}
|
||||
/>
|
||||
}
|
||||
dropdownComponents={
|
||||
<RecordBoardColumnHeaderAggregateDropdownContext.Provider
|
||||
value={{
|
||||
currentContentId,
|
||||
onContentChange: handleContentChange,
|
||||
resetContent: handleResetContent,
|
||||
objectMetadataItem: objectMetadataItem,
|
||||
dropdownId: dropdownId,
|
||||
}}
|
||||
>
|
||||
<AggregateDropdownContent />
|
||||
</RecordBoardColumnHeaderAggregateDropdownContext.Provider>
|
||||
}
|
||||
/>
|
||||
</RecordBoardColumnHeaderAggregateDropdownComponentInstanceContext.Provider>
|
||||
);
|
||||
};
|
@ -0,0 +1,34 @@
|
||||
import { StyledHeaderDropdownButton } from '@/ui/layout/dropdown/components/StyledHeaderDropdownButton';
|
||||
import styled from '@emotion/styled';
|
||||
import { AppTooltip, Tag, TooltipDelay } from 'twenty-ui';
|
||||
import { formatNumber } from '~/utils/format/number';
|
||||
|
||||
const StyledButton = styled(StyledHeaderDropdownButton)`
|
||||
padding: 0;
|
||||
`;
|
||||
|
||||
export const RecordBoardColumnHeaderAggregateDropdownButton = ({
|
||||
dropdownId,
|
||||
value,
|
||||
tooltip,
|
||||
}: {
|
||||
dropdownId: string;
|
||||
value: string | number;
|
||||
tooltip?: string;
|
||||
}) => {
|
||||
return (
|
||||
<div id={dropdownId}>
|
||||
<StyledButton>
|
||||
<Tag text={formatNumber(Number(value))} color={'transparent'} />
|
||||
<AppTooltip
|
||||
anchorSelect={`#${dropdownId}`}
|
||||
content={tooltip}
|
||||
noArrow
|
||||
place="right"
|
||||
positionStrategy="fixed"
|
||||
delay={TooltipDelay.shortDelay}
|
||||
/>
|
||||
</StyledButton>
|
||||
</div>
|
||||
);
|
||||
};
|
@ -0,0 +1,17 @@
|
||||
import { useDropdown } from '@/dropdown/hooks/useDropdown';
|
||||
import { RecordBoardColumnHeaderAggregateDropdownContext } from '@/object-record/record-board/record-board-column/components/RecordBoardColumnHeaderAggregateDropdownContext';
|
||||
import { RecordBoardColumnHeaderAggregateDropdownFieldsContent } from '@/object-record/record-board/record-board-column/components/RecordBoardColumnHeaderAggregateDropdownFieldsContent';
|
||||
import { RecordBoardColumnHeaderAggregateDropdownMenuContent } from '@/object-record/record-board/record-board-column/components/RecordBoardColumnHeaderAggregateDropdownMenuContent';
|
||||
|
||||
export const AggregateDropdownContent = () => {
|
||||
const { currentContentId } = useDropdown({
|
||||
context: RecordBoardColumnHeaderAggregateDropdownContext,
|
||||
});
|
||||
|
||||
switch (currentContentId) {
|
||||
case 'aggregateFields':
|
||||
return <RecordBoardColumnHeaderAggregateDropdownFieldsContent />;
|
||||
default:
|
||||
return <RecordBoardColumnHeaderAggregateDropdownMenuContent />;
|
||||
}
|
||||
};
|
@ -0,0 +1,16 @@
|
||||
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
|
||||
import { AggregateContentId } from '@/object-record/record-board/types/AggregateContentId';
|
||||
import { createContext } from 'react';
|
||||
|
||||
export type RecordBoardColumnHeaderAggregateDropdownContextValue = {
|
||||
objectMetadataItem: ObjectMetadataItem;
|
||||
currentContentId: AggregateContentId | null;
|
||||
onContentChange: (key: AggregateContentId) => void;
|
||||
resetContent: () => void;
|
||||
dropdownId: string;
|
||||
};
|
||||
|
||||
export const RecordBoardColumnHeaderAggregateDropdownContext =
|
||||
createContext<RecordBoardColumnHeaderAggregateDropdownContextValue>(
|
||||
{} as RecordBoardColumnHeaderAggregateDropdownContextValue,
|
||||
);
|
@ -0,0 +1,62 @@
|
||||
import { useDropdown } from '@/dropdown/hooks/useDropdown';
|
||||
import { RecordBoardColumnHeaderAggregateDropdownContext } from '@/object-record/record-board/record-board-column/components/RecordBoardColumnHeaderAggregateDropdownContext';
|
||||
import { aggregateOperationComponentState } from '@/object-record/record-board/record-board-column/states/aggregateOperationComponentState';
|
||||
import { availableFieldIdsForAggregateOperationComponentState } from '@/object-record/record-board/record-board-column/states/availableFieldIdsForAggregateOperationComponentState';
|
||||
import { getAggregateOperationLabel } from '@/object-record/record-board/record-board-column/utils/getAggregateOperationLabel';
|
||||
import { DropdownMenuHeader } from '@/ui/layout/dropdown/components/DropdownMenuHeader';
|
||||
import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer';
|
||||
import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2';
|
||||
import { useUpdateViewAggregate } from '@/views/hooks/useUpdateViewAggregate';
|
||||
import { Icon123, IconChevronLeft, MenuItem, useIcons } from 'twenty-ui';
|
||||
import { isDefined } from '~/utils/isDefined';
|
||||
|
||||
export const RecordBoardColumnHeaderAggregateDropdownFieldsContent = () => {
|
||||
const { closeDropdown, resetContent, objectMetadataItem } = useDropdown({
|
||||
context: RecordBoardColumnHeaderAggregateDropdownContext,
|
||||
});
|
||||
|
||||
const { updateViewAggregate } = useUpdateViewAggregate();
|
||||
|
||||
const { getIcon } = useIcons();
|
||||
|
||||
const aggregateOperation = useRecoilComponentValueV2(
|
||||
aggregateOperationComponentState,
|
||||
);
|
||||
|
||||
const availableFieldsIdsForAggregateOperation = useRecoilComponentValueV2(
|
||||
availableFieldIdsForAggregateOperationComponentState,
|
||||
);
|
||||
|
||||
if (!isDefined(aggregateOperation)) return <></>;
|
||||
|
||||
return (
|
||||
<>
|
||||
<DropdownMenuHeader StartIcon={IconChevronLeft} onClick={resetContent}>
|
||||
{getAggregateOperationLabel(aggregateOperation)}
|
||||
</DropdownMenuHeader>
|
||||
{availableFieldsIdsForAggregateOperation.map((fieldId) => {
|
||||
const fieldMetadata = objectMetadataItem.fields.find(
|
||||
(field) => field.id === fieldId,
|
||||
);
|
||||
|
||||
if (!fieldMetadata) return null;
|
||||
return (
|
||||
<DropdownMenuItemsContainer>
|
||||
<MenuItem
|
||||
key={fieldId}
|
||||
onClick={() => {
|
||||
updateViewAggregate({
|
||||
kanbanAggregateOperationFieldMetadataId: fieldId,
|
||||
kanbanAggregateOperation: aggregateOperation,
|
||||
});
|
||||
closeDropdown();
|
||||
}}
|
||||
LeftIcon={getIcon(fieldMetadata.icon) ?? Icon123}
|
||||
text={fieldMetadata.label}
|
||||
/>
|
||||
</DropdownMenuItemsContainer>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
};
|
@ -0,0 +1,101 @@
|
||||
import { Key } from 'ts-key-enum';
|
||||
import { MenuItem } from 'twenty-ui';
|
||||
|
||||
import { useDropdown } from '@/dropdown/hooks/useDropdown';
|
||||
import {
|
||||
RecordBoardColumnHeaderAggregateDropdownContext,
|
||||
RecordBoardColumnHeaderAggregateDropdownContextValue,
|
||||
} from '@/object-record/record-board/record-board-column/components/RecordBoardColumnHeaderAggregateDropdownContext';
|
||||
|
||||
import { RecordBoardColumnHeaderAggregateDropdownMenuItem } from '@/object-record/record-board/record-board-column/components/RecordBoardColumnHeaderAggregateDropdownMenuItem';
|
||||
import { aggregateOperationComponentState } from '@/object-record/record-board/record-board-column/states/aggregateOperationComponentState';
|
||||
import { availableFieldIdsForAggregateOperationComponentState } from '@/object-record/record-board/record-board-column/states/availableFieldIdsForAggregateOperationComponentState';
|
||||
import { getAggregateOperationLabel } from '@/object-record/record-board/record-board-column/utils/getAggregateOperationLabel';
|
||||
import { AGGREGATE_OPERATIONS } from '@/object-record/record-table/constants/AggregateOperations';
|
||||
import { TableOptionsHotkeyScope } from '@/object-record/record-table/types/TableOptionsHotkeyScope';
|
||||
import { AvailableFieldsForAggregateOperation } from '@/object-record/types/AvailableFieldsForAggregateOperation';
|
||||
import { getAvailableFieldsIdsForAggregationFromObjectFields } from '@/object-record/utils/getAvailableFieldsIdsForAggregationFromObjectFields';
|
||||
import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer';
|
||||
import { useScopedHotkeys } from '@/ui/utilities/hotkey/hooks/useScopedHotkeys';
|
||||
import { useSetRecoilComponentStateV2 } from '@/ui/utilities/state/component-state/hooks/useSetRecoilComponentStateV2';
|
||||
import { useUpdateViewAggregate } from '@/views/hooks/useUpdateViewAggregate';
|
||||
import isEmpty from 'lodash.isempty';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
export const RecordBoardColumnHeaderAggregateDropdownMenuContent = () => {
|
||||
const { objectMetadataItem, onContentChange, closeDropdown } =
|
||||
useDropdown<RecordBoardColumnHeaderAggregateDropdownContextValue>({
|
||||
context: RecordBoardColumnHeaderAggregateDropdownContext,
|
||||
});
|
||||
|
||||
useScopedHotkeys(
|
||||
[Key.Escape],
|
||||
() => {
|
||||
closeDropdown();
|
||||
},
|
||||
TableOptionsHotkeyScope.Dropdown,
|
||||
);
|
||||
|
||||
const availableAggregations: AvailableFieldsForAggregateOperation = useMemo(
|
||||
() =>
|
||||
getAvailableFieldsIdsForAggregationFromObjectFields(
|
||||
objectMetadataItem.fields,
|
||||
),
|
||||
[objectMetadataItem.fields],
|
||||
);
|
||||
|
||||
const setAggregateOperation = useSetRecoilComponentStateV2(
|
||||
aggregateOperationComponentState,
|
||||
);
|
||||
|
||||
const setAvailableFieldsForAggregateOperation = useSetRecoilComponentStateV2(
|
||||
availableFieldIdsForAggregateOperationComponentState,
|
||||
);
|
||||
|
||||
const { updateViewAggregate } = useUpdateViewAggregate();
|
||||
|
||||
return (
|
||||
<>
|
||||
<DropdownMenuItemsContainer>
|
||||
<MenuItem
|
||||
onClick={() => {
|
||||
updateViewAggregate({
|
||||
kanbanAggregateOperationFieldMetadataId: null,
|
||||
kanbanAggregateOperation: AGGREGATE_OPERATIONS.count,
|
||||
});
|
||||
}}
|
||||
text={getAggregateOperationLabel(AGGREGATE_OPERATIONS.count)}
|
||||
/>
|
||||
</DropdownMenuItemsContainer>
|
||||
{Object.entries(availableAggregations).map(
|
||||
([
|
||||
availableAggregationOperation,
|
||||
availableAggregationFieldsIdsForOperation,
|
||||
]) =>
|
||||
isEmpty(availableAggregationFieldsIdsForOperation) ? (
|
||||
<></>
|
||||
) : (
|
||||
<DropdownMenuItemsContainer
|
||||
key={`aggregate-dropdown-menu-content-${availableAggregationOperation}`}
|
||||
>
|
||||
<RecordBoardColumnHeaderAggregateDropdownMenuItem
|
||||
onContentChange={() => {
|
||||
setAggregateOperation(
|
||||
availableAggregationOperation as AGGREGATE_OPERATIONS,
|
||||
);
|
||||
setAvailableFieldsForAggregateOperation(
|
||||
availableAggregationFieldsIdsForOperation,
|
||||
);
|
||||
onContentChange('aggregateFields');
|
||||
}}
|
||||
text={getAggregateOperationLabel(
|
||||
availableAggregationOperation as AGGREGATE_OPERATIONS,
|
||||
)}
|
||||
hasSubMenu
|
||||
/>
|
||||
</DropdownMenuItemsContainer>
|
||||
),
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
@ -0,0 +1,15 @@
|
||||
import { MenuItem } from 'twenty-ui';
|
||||
|
||||
export const RecordBoardColumnHeaderAggregateDropdownMenuItem = ({
|
||||
onContentChange,
|
||||
text,
|
||||
hasSubMenu,
|
||||
}: {
|
||||
onContentChange: () => void;
|
||||
hasSubMenu: boolean;
|
||||
text: string;
|
||||
}) => {
|
||||
return (
|
||||
<MenuItem onClick={onContentChange} text={text} hasSubMenu={hasSubMenu} />
|
||||
);
|
||||
};
|
@ -0,0 +1 @@
|
||||
export const AGGREGATE_DROPDOWN_ID = 'aggregate-dropdown-id';
|
@ -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,
|
||||
};
|
||||
};
|
@ -0,0 +1,15 @@
|
||||
import { AGGREGATE_OPERATIONS } from '@/object-record/record-table/constants/AggregateOperations';
|
||||
import { createState } from 'twenty-ui';
|
||||
|
||||
type AggregateOperation = {
|
||||
operation: AGGREGATE_OPERATIONS | null;
|
||||
availableFieldIdsForOperation: string[];
|
||||
};
|
||||
|
||||
export const aggregateDropdownState = createState<AggregateOperation>({
|
||||
key: 'aggregateDropdownState',
|
||||
defaultValue: {
|
||||
operation: null,
|
||||
availableFieldIdsForOperation: [],
|
||||
},
|
||||
});
|
@ -0,0 +1,11 @@
|
||||
import { RecordBoardColumnHeaderAggregateDropdownComponentInstanceContext } from '@/object-record/record-board/contexts/RecordBoardColumnHeaderAggregateDropdownComponentInstanceContext';
|
||||
import { AGGREGATE_OPERATIONS } from '@/object-record/record-table/constants/AggregateOperations';
|
||||
import { createComponentStateV2 } from '@/ui/utilities/state/component-state/utils/createComponentStateV2';
|
||||
|
||||
export const aggregateOperationComponentState =
|
||||
createComponentStateV2<AGGREGATE_OPERATIONS | null>({
|
||||
key: 'aggregateOperationComponentFamilyState',
|
||||
defaultValue: null,
|
||||
componentInstanceContext:
|
||||
RecordBoardColumnHeaderAggregateDropdownComponentInstanceContext,
|
||||
});
|
@ -0,0 +1,10 @@
|
||||
import { RecordBoardColumnHeaderAggregateDropdownComponentInstanceContext } from '@/object-record/record-board/contexts/RecordBoardColumnHeaderAggregateDropdownComponentInstanceContext';
|
||||
import { createComponentStateV2 } from '@/ui/utilities/state/component-state/utils/createComponentStateV2';
|
||||
|
||||
export const availableFieldIdsForAggregateOperationComponentState =
|
||||
createComponentStateV2<string[]>({
|
||||
key: 'availableFieldIdsForAggregateOperationComponentFamilyState',
|
||||
defaultValue: [],
|
||||
componentInstanceContext:
|
||||
RecordBoardColumnHeaderAggregateDropdownComponentInstanceContext,
|
||||
});
|
@ -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}`,
|
||||
);
|
||||
});
|
||||
});
|
@ -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',
|
||||
});
|
||||
});
|
||||
});
|
@ -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');
|
||||
});
|
||||
});
|
@ -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;
|
||||
};
|
@ -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}`,
|
||||
};
|
||||
};
|
@ -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}`);
|
||||
}
|
||||
};
|
@ -0,0 +1 @@
|
||||
export type AggregateContentId = 'aggregateOperations' | 'aggregateFields';
|
@ -1,3 +1,4 @@
|
||||
export enum RecordBoardColumnHotkeyScope {
|
||||
BoardColumn = 'board-column',
|
||||
ColumnHeader = 'column-header',
|
||||
}
|
||||
|
@ -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);
|
||||
}}
|
||||
/>
|
||||
|
@ -0,0 +1,13 @@
|
||||
import { AGGREGATE_OPERATIONS } from '@/object-record/record-table/constants/AggregateOperations';
|
||||
import { createState } from 'twenty-ui';
|
||||
|
||||
export type KanbanAggregateOperation = {
|
||||
operation?: AGGREGATE_OPERATIONS | null;
|
||||
fieldMetadataId?: string | null;
|
||||
} | null;
|
||||
|
||||
export const recordIndexKanbanAggregateOperationState =
|
||||
createState<KanbanAggregateOperation>({
|
||||
key: 'recordIndexKanbanAggregateOperationState',
|
||||
defaultValue: null,
|
||||
});
|
@ -0,0 +1,7 @@
|
||||
export enum AGGREGATE_OPERATIONS {
|
||||
min = 'MIN',
|
||||
max = 'MAX',
|
||||
avg = 'AVG',
|
||||
sum = 'SUM',
|
||||
count = 'COUNT',
|
||||
}
|
@ -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,
|
||||
],
|
||||
};
|
@ -0,0 +1,6 @@
|
||||
import { AGGREGATE_OPERATIONS } from '@/object-record/record-table/constants/AggregateOperations';
|
||||
|
||||
export type AggregateOperationsOmittingCount = Exclude<
|
||||
AGGREGATE_OPERATIONS,
|
||||
AGGREGATE_OPERATIONS.count
|
||||
>;
|
@ -0,0 +1,5 @@
|
||||
import { AggregateOperationsOmittingCount } from '@/object-record/types/AggregateOperationsOmittingCount';
|
||||
|
||||
export type AvailableFieldsForAggregateOperation = {
|
||||
[T in AggregateOperationsOmittingCount]?: string[];
|
||||
};
|
@ -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 } }',
|
||||
);
|
||||
});
|
||||
});
|
@ -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([]);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
@ -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();
|
||||
});
|
||||
});
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
@ -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}
|
||||
}
|
||||
}
|
||||
`;
|
||||
};
|
@ -0,0 +1,51 @@
|
||||
import { FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
|
||||
import { AGGREGATE_OPERATIONS } from '@/object-record/record-table/constants/AggregateOperations';
|
||||
import { FieldMetadataType } from '~/generated-metadata/graphql';
|
||||
import { capitalize } from '~/utils/string/capitalize';
|
||||
|
||||
type NameForAggregation = {
|
||||
[T in AGGREGATE_OPERATIONS]?: string;
|
||||
};
|
||||
|
||||
type Aggregations = {
|
||||
[key: string]: NameForAggregation;
|
||||
};
|
||||
|
||||
export const getAvailableAggregationsFromObjectFields = (
|
||||
fields: FieldMetadataItem[],
|
||||
): Aggregations => {
|
||||
return fields.reduce<Record<string, NameForAggregation>>((acc, field) => {
|
||||
if (field.type === FieldMetadataType.DateTime) {
|
||||
acc[field.name] = {
|
||||
[AGGREGATE_OPERATIONS.min]: `min${capitalize(field.name)}`,
|
||||
[AGGREGATE_OPERATIONS.max]: `max${capitalize(field.name)}`,
|
||||
};
|
||||
}
|
||||
|
||||
if (field.type === FieldMetadataType.Number) {
|
||||
acc[field.name] = {
|
||||
[AGGREGATE_OPERATIONS.min]: `min${capitalize(field.name)}`,
|
||||
[AGGREGATE_OPERATIONS.max]: `max${capitalize(field.name)}`,
|
||||
[AGGREGATE_OPERATIONS.avg]: `avg${capitalize(field.name)}`,
|
||||
[AGGREGATE_OPERATIONS.sum]: `sum${capitalize(field.name)}`,
|
||||
};
|
||||
}
|
||||
|
||||
if (field.type === FieldMetadataType.Currency) {
|
||||
acc[field.name] = {
|
||||
[AGGREGATE_OPERATIONS.min]: `min${capitalize(field.name)}AmountMicros`,
|
||||
[AGGREGATE_OPERATIONS.max]: `max${capitalize(field.name)}AmountMicros`,
|
||||
[AGGREGATE_OPERATIONS.avg]: `avg${capitalize(field.name)}AmountMicros`,
|
||||
[AGGREGATE_OPERATIONS.sum]: `sum${capitalize(field.name)}AmountMicros`,
|
||||
};
|
||||
}
|
||||
|
||||
if (acc[field.name] === undefined) {
|
||||
acc[field.name] = {};
|
||||
}
|
||||
|
||||
acc[field.name][AGGREGATE_OPERATIONS.count] = 'totalCount';
|
||||
|
||||
return acc;
|
||||
}, {});
|
||||
};
|
@ -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);
|
||||
};
|
@ -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]: [],
|
||||
}),
|
||||
{},
|
||||
);
|
||||
};
|
@ -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,
|
||||
);
|
||||
};
|
@ -14,6 +14,8 @@ export const findAllViewsOperationSignatureFactory: RecordGqlOperationSignatureF
|
||||
position: true,
|
||||
type: true,
|
||||
kanbanFieldMetadataId: true,
|
||||
kanbanAggregateOperation: true,
|
||||
kanbanAggregateOperationFieldMetadataId: true,
|
||||
name: true,
|
||||
icon: true,
|
||||
key: true,
|
||||
|
@ -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,
|
||||
);
|
||||
|
@ -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,
|
||||
};
|
||||
};
|
@ -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[];
|
||||
|
@ -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';
|
||||
|
@ -1,5 +1,6 @@
|
||||
import { RecordBoardFieldDefinition } from '@/object-record/record-board/types/RecordBoardFieldDefinition';
|
||||
import { FieldMetadata } from '@/object-record/record-field/types/FieldMetadata';
|
||||
import { AGGREGATE_OPERATIONS } from '@/object-record/record-table/constants/AggregateOperations';
|
||||
import { ColumnDefinition } from '@/object-record/record-table/types/ColumnDefinition';
|
||||
|
||||
export type ViewField = {
|
||||
@ -9,6 +10,7 @@ export type ViewField = {
|
||||
position: number;
|
||||
isVisible: boolean;
|
||||
size: number;
|
||||
aggregateOperation?: AGGREGATE_OPERATIONS | null;
|
||||
definition:
|
||||
| ColumnDefinition<FieldMetadata>
|
||||
| RecordBoardFieldDefinition<FieldMetadata>;
|
||||
|
@ -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,
|
||||
}));
|
||||
};
|
||||
|
@ -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,
|
||||
|
@ -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();
|
||||
|
@ -0,0 +1,7 @@
|
||||
export enum AGGREGATE_OPERATIONS {
|
||||
min = 'MIN',
|
||||
max = 'MAX',
|
||||
avg = 'AVG',
|
||||
sum = 'SUM',
|
||||
count = 'COUNT',
|
||||
}
|
@ -45,6 +45,12 @@ export class GraphqlQuerySelectedFieldsParser {
|
||||
return accumulator;
|
||||
}
|
||||
|
||||
this.aggregateParser.parse(
|
||||
graphqlSelectedFields,
|
||||
fieldMetadataMapByName,
|
||||
accumulator,
|
||||
);
|
||||
|
||||
this.parseRecordField(
|
||||
graphqlSelectedFields,
|
||||
fieldMetadataMapByName,
|
||||
|
@ -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}`,
|
||||
);
|
||||
}
|
||||
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
@ -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',
|
||||
|
@ -1,12 +1,18 @@
|
||||
import { registerEnumType } from '@nestjs/graphql';
|
||||
|
||||
import { Relation } from 'typeorm';
|
||||
|
||||
import { AGGREGATE_OPERATIONS } from 'src/engine/api/graphql/graphql-query-runner/constants/aggregate-operations.constant';
|
||||
import { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/feature-flag-key.enum';
|
||||
import { FieldMetadataType } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity';
|
||||
import { RelationMetadataType } from 'src/engine/metadata-modules/relation-metadata/relation-metadata.entity';
|
||||
import { BaseWorkspaceEntity } from 'src/engine/twenty-orm/base.workspace-entity';
|
||||
import { WorkspaceEntity } from 'src/engine/twenty-orm/decorators/workspace-entity.decorator';
|
||||
import { WorkspaceField } from 'src/engine/twenty-orm/decorators/workspace-field.decorator';
|
||||
import { WorkspaceGate } from 'src/engine/twenty-orm/decorators/workspace-gate.decorator';
|
||||
import { WorkspaceIndex } from 'src/engine/twenty-orm/decorators/workspace-index.decorator';
|
||||
import { WorkspaceIsNotAuditLogged } from 'src/engine/twenty-orm/decorators/workspace-is-not-audit-logged.decorator';
|
||||
import { WorkspaceIsNullable } from 'src/engine/twenty-orm/decorators/workspace-is-nullable.decorator';
|
||||
import { WorkspaceIsSystem } from 'src/engine/twenty-orm/decorators/workspace-is-system.decorator';
|
||||
import { WorkspaceJoinColumn } from 'src/engine/twenty-orm/decorators/workspace-join-column.decorator';
|
||||
import { WorkspaceRelation } from 'src/engine/twenty-orm/decorators/workspace-relation.decorator';
|
||||
@ -15,6 +21,10 @@ import { STANDARD_OBJECT_ICONS } from 'src/engine/workspace-manager/workspace-sy
|
||||
import { STANDARD_OBJECT_IDS } from 'src/engine/workspace-manager/workspace-sync-metadata/constants/standard-object-ids';
|
||||
import { ViewWorkspaceEntity } from 'src/modules/view/standard-objects/view.workspace-entity';
|
||||
|
||||
registerEnumType(AGGREGATE_OPERATIONS, {
|
||||
name: 'AggregateOperations',
|
||||
});
|
||||
|
||||
@WorkspaceEntity({
|
||||
standardId: STANDARD_OBJECT_IDS.viewField,
|
||||
namePlural: 'viewFields',
|
||||
@ -80,6 +90,52 @@ export class ViewFieldWorkspaceEntity extends BaseWorkspaceEntity {
|
||||
})
|
||||
view: Relation<ViewWorkspaceEntity>;
|
||||
|
||||
@WorkspaceField({
|
||||
standardId: VIEW_FIELD_STANDARD_FIELD_IDS.aggregateOperation,
|
||||
type: FieldMetadataType.SELECT,
|
||||
label: 'Aggregate operation',
|
||||
description: 'Optional aggregate operation',
|
||||
icon: 'IconCalculator',
|
||||
options: [
|
||||
{
|
||||
value: AGGREGATE_OPERATIONS.avg,
|
||||
label: 'Average',
|
||||
position: 0,
|
||||
color: 'red',
|
||||
},
|
||||
{
|
||||
value: AGGREGATE_OPERATIONS.count,
|
||||
label: 'Count',
|
||||
position: 1,
|
||||
color: 'purple',
|
||||
},
|
||||
{
|
||||
value: AGGREGATE_OPERATIONS.max,
|
||||
label: 'Maximum',
|
||||
position: 2,
|
||||
color: 'sky',
|
||||
},
|
||||
{
|
||||
value: AGGREGATE_OPERATIONS.min,
|
||||
label: 'Minimum',
|
||||
position: 3,
|
||||
color: 'turquoise',
|
||||
},
|
||||
{
|
||||
value: AGGREGATE_OPERATIONS.sum,
|
||||
label: 'Sum',
|
||||
position: 4,
|
||||
color: 'yellow',
|
||||
},
|
||||
],
|
||||
defaultValue: null,
|
||||
})
|
||||
@WorkspaceGate({
|
||||
featureFlag: FeatureFlagKey.IsAggregateQueryEnabled,
|
||||
})
|
||||
@WorkspaceIsNullable()
|
||||
aggregateOperation?: AGGREGATE_OPERATIONS | null;
|
||||
|
||||
@WorkspaceJoinColumn('view')
|
||||
viewId: string;
|
||||
}
|
||||
|
@ -1,5 +1,7 @@
|
||||
import { Relation } from 'src/engine/workspace-manager/workspace-sync-metadata/interfaces/relation.interface';
|
||||
|
||||
import { AGGREGATE_OPERATIONS } from 'src/engine/api/graphql/graphql-query-runner/constants/aggregate-operations.constant';
|
||||
import { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/feature-flag-key.enum';
|
||||
import { FieldMetadataType } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity';
|
||||
import {
|
||||
RelationMetadataType,
|
||||
@ -8,6 +10,7 @@ import {
|
||||
import { BaseWorkspaceEntity } from 'src/engine/twenty-orm/base.workspace-entity';
|
||||
import { WorkspaceEntity } from 'src/engine/twenty-orm/decorators/workspace-entity.decorator';
|
||||
import { WorkspaceField } from 'src/engine/twenty-orm/decorators/workspace-field.decorator';
|
||||
import { WorkspaceGate } from 'src/engine/twenty-orm/decorators/workspace-gate.decorator';
|
||||
import { WorkspaceIsNotAuditLogged } from 'src/engine/twenty-orm/decorators/workspace-is-not-audit-logged.decorator';
|
||||
import { WorkspaceIsNullable } from 'src/engine/twenty-orm/decorators/workspace-is-nullable.decorator';
|
||||
import { WorkspaceIsSystem } from 'src/engine/twenty-orm/decorators/workspace-is-system.decorator';
|
||||
@ -175,4 +178,63 @@ export class ViewWorkspaceEntity extends BaseWorkspaceEntity {
|
||||
})
|
||||
@WorkspaceIsSystem()
|
||||
favorites: Relation<FavoriteWorkspaceEntity[]>;
|
||||
|
||||
@WorkspaceField({
|
||||
standardId: VIEW_STANDARD_FIELD_IDS.kanbanAggregateOperation,
|
||||
type: FieldMetadataType.SELECT,
|
||||
label: 'Aggregate operation',
|
||||
description: 'Optional aggregate operation',
|
||||
icon: 'IconCalculator',
|
||||
options: [
|
||||
{
|
||||
value: AGGREGATE_OPERATIONS.avg,
|
||||
label: 'Average',
|
||||
position: 0,
|
||||
color: 'red',
|
||||
},
|
||||
{
|
||||
value: AGGREGATE_OPERATIONS.count,
|
||||
label: 'Count',
|
||||
position: 1,
|
||||
color: 'purple',
|
||||
},
|
||||
{
|
||||
value: AGGREGATE_OPERATIONS.max,
|
||||
label: 'Maximum',
|
||||
position: 2,
|
||||
color: 'sky',
|
||||
},
|
||||
{
|
||||
value: AGGREGATE_OPERATIONS.min,
|
||||
label: 'Minimum',
|
||||
position: 3,
|
||||
color: 'turquoise',
|
||||
},
|
||||
{
|
||||
value: AGGREGATE_OPERATIONS.sum,
|
||||
label: 'Sum',
|
||||
position: 4,
|
||||
color: 'yellow',
|
||||
},
|
||||
],
|
||||
defaultValue: `'${AGGREGATE_OPERATIONS.count}'`,
|
||||
})
|
||||
@WorkspaceGate({
|
||||
featureFlag: FeatureFlagKey.IsAggregateQueryEnabled,
|
||||
})
|
||||
@WorkspaceIsNullable()
|
||||
kanbanAggregateOperation?: AGGREGATE_OPERATIONS | null;
|
||||
|
||||
@WorkspaceField({
|
||||
standardId: VIEW_STANDARD_FIELD_IDS.kanbanAggregateOperationFieldMetadataId,
|
||||
type: FieldMetadataType.UUID,
|
||||
label: 'Field metadata used for aggregate operation',
|
||||
description: 'Field metadata used for aggregate operation',
|
||||
defaultValue: null,
|
||||
})
|
||||
@WorkspaceGate({
|
||||
featureFlag: FeatureFlagKey.IsAggregateQueryEnabled,
|
||||
})
|
||||
@WorkspaceIsNullable()
|
||||
kanbanAggregateOperationFieldMetadataId?: string | null;
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user