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;
|
||||||
};
|
};
|
||||||
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 { 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 { ObjectOptionsDropdownContent } from '@/object-record/object-options-dropdown/components/ObjectOptionsDropdownContent';
|
||||||
import { OBJECT_OPTIONS_DROPDOWN_ID } from '@/object-record/object-options-dropdown/constants/ObjectOptionsDropdownId';
|
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 { ObjectOptionsDropdownContext } from '@/object-record/object-options-dropdown/states/contexts/ObjectOptionsDropdownContext';
|
||||||
import { ObjectOptionsContentId } from '@/object-record/object-options-dropdown/types/ObjectOptionsContentId';
|
import { ObjectOptionsContentId } from '@/object-record/object-options-dropdown/types/ObjectOptionsContentId';
|
||||||
import { TableOptionsHotkeyScope } from '@/object-record/record-table/types/TableOptionsHotkeyScope';
|
import { TableOptionsHotkeyScope } from '@/object-record/record-table/types/TableOptionsHotkeyScope';
|
||||||
import { Dropdown } from '@/ui/layout/dropdown/components/Dropdown';
|
import { Dropdown } from '@/ui/layout/dropdown/components/Dropdown';
|
||||||
|
import { StyledHeaderDropdownButton } from '@/ui/layout/dropdown/components/StyledHeaderDropdownButton';
|
||||||
import { ViewType } from '@/views/types/ViewType';
|
import { ViewType } from '@/views/types/ViewType';
|
||||||
import { useCallback, useState } from 'react';
|
|
||||||
|
|
||||||
type ObjectOptionsDropdownProps = {
|
type ObjectOptionsDropdownProps = {
|
||||||
viewType: ViewType;
|
viewType: ViewType;
|
||||||
@ -20,24 +22,18 @@ export const ObjectOptionsDropdown = ({
|
|||||||
objectMetadataItem,
|
objectMetadataItem,
|
||||||
viewType,
|
viewType,
|
||||||
}: ObjectOptionsDropdownProps) => {
|
}: ObjectOptionsDropdownProps) => {
|
||||||
const [currentContentId, setCurrentContentId] =
|
const { currentContentId, handleContentChange, handleResetContent } =
|
||||||
useState<ObjectOptionsContentId | null>(null);
|
useCurrentContentId<ObjectOptionsContentId>();
|
||||||
|
|
||||||
const handleContentChange = useCallback((key: ObjectOptionsContentId) => {
|
|
||||||
setCurrentContentId(key);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleResetContent = useCallback(() => {
|
|
||||||
setCurrentContentId(null);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dropdown
|
<Dropdown
|
||||||
dropdownId={OBJECT_OPTIONS_DROPDOWN_ID}
|
dropdownId={OBJECT_OPTIONS_DROPDOWN_ID}
|
||||||
clickableComponent={<ObjectOptionsDropdownButton />}
|
|
||||||
dropdownMenuWidth={'200px'}
|
|
||||||
dropdownHotkeyScope={{ scope: TableOptionsHotkeyScope.Dropdown }}
|
dropdownHotkeyScope={{ scope: TableOptionsHotkeyScope.Dropdown }}
|
||||||
dropdownOffset={{ y: 8 }}
|
dropdownMenuWidth={DROPDOWN_WIDTH}
|
||||||
|
dropdownOffset={{ y: DROPDOWN_OFFSET_Y }}
|
||||||
|
clickableComponent={
|
||||||
|
<StyledHeaderDropdownButton>Options</StyledHeaderDropdownButton>
|
||||||
|
}
|
||||||
dropdownComponents={
|
dropdownComponents={
|
||||||
<ObjectOptionsDropdownContext.Provider
|
<ObjectOptionsDropdownContext.Provider
|
||||||
value={{
|
value={{
|
||||||
@ -47,6 +43,7 @@ export const ObjectOptionsDropdown = ({
|
|||||||
currentContentId,
|
currentContentId,
|
||||||
onContentChange: handleContentChange,
|
onContentChange: handleContentChange,
|
||||||
resetContent: handleResetContent,
|
resetContent: handleResetContent,
|
||||||
|
dropdownId: OBJECT_OPTIONS_DROPDOWN_ID,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<ObjectOptionsDropdownContent />
|
<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 { ContextStoreComponentInstanceContext } from '@/context-store/states/contexts/ContextStoreComponentInstanceContext';
|
||||||
import { objectMetadataItemsState } from '@/object-metadata/states/objectMetadataItemsState';
|
import { objectMetadataItemsState } from '@/object-metadata/states/objectMetadataItemsState';
|
||||||
import { ObjectOptionsDropdownContent } from '@/object-record/object-options-dropdown/components/ObjectOptionsDropdownContent';
|
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 { ObjectOptionsDropdownContext } from '@/object-record/object-options-dropdown/states/contexts/ObjectOptionsDropdownContext';
|
||||||
import { ObjectOptionsContentId } from '@/object-record/object-options-dropdown/types/ObjectOptionsContentId';
|
import { ObjectOptionsContentId } from '@/object-record/object-options-dropdown/types/ObjectOptionsContentId';
|
||||||
import { RecordIndexRootPropsContext } from '@/object-record/record-index/contexts/RecordIndexRootPropsContext';
|
import { RecordIndexRootPropsContext } from '@/object-record/record-index/contexts/RecordIndexRootPropsContext';
|
||||||
@ -94,6 +95,7 @@ const createStory = (contentId: ObjectOptionsContentId | null): Story => ({
|
|||||||
currentContentId: contentId,
|
currentContentId: contentId,
|
||||||
onContentChange: () => {},
|
onContentChange: () => {},
|
||||||
resetContent: () => {},
|
resetContent: () => {},
|
||||||
|
dropdownId: OBJECT_OPTIONS_DROPDOWN_ID,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
|
@ -2,6 +2,7 @@ import { renderHook } from '@testing-library/react';
|
|||||||
import { act } from 'react';
|
import { act } from 'react';
|
||||||
|
|
||||||
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
|
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 { useOptionsDropdown } from '@/object-record/object-options-dropdown/hooks/useOptionsDropdown';
|
||||||
import { ObjectOptionsDropdownContext } from '@/object-record/object-options-dropdown/states/contexts/ObjectOptionsDropdownContext';
|
import { ObjectOptionsDropdownContext } from '@/object-record/object-options-dropdown/states/contexts/ObjectOptionsDropdownContext';
|
||||||
import { useDropdown } from '@/ui/layout/dropdown/hooks/useDropdown';
|
import { useDropdown } from '@/ui/layout/dropdown/hooks/useDropdown';
|
||||||
@ -55,6 +56,7 @@ describe('useOptionsDropdown', () => {
|
|||||||
currentContentId: 'recordGroups',
|
currentContentId: 'recordGroups',
|
||||||
onContentChange: mockOnContentChange,
|
onContentChange: mockOnContentChange,
|
||||||
resetContent: mockResetContent,
|
resetContent: mockResetContent,
|
||||||
|
dropdownId: OBJECT_OPTIONS_DROPDOWN_ID,
|
||||||
...contextValue,
|
...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 { ObjectOptionsDropdownContext } from '@/object-record/object-options-dropdown/states/contexts/ObjectOptionsDropdownContext';
|
||||||
import { useDropdown } from '@/ui/layout/dropdown/hooks/useDropdown';
|
import { useContext } from 'react';
|
||||||
import { useCallback, useContext } from 'react';
|
|
||||||
|
|
||||||
export const useOptionsDropdown = () => {
|
export const useOptionsDropdown = () => {
|
||||||
const { closeDropdown } = useDropdown(OBJECT_OPTIONS_DROPDOWN_ID);
|
|
||||||
|
|
||||||
const context = useContext(ObjectOptionsDropdownContext);
|
const context = useContext(ObjectOptionsDropdownContext);
|
||||||
|
|
||||||
if (!context) {
|
if (!context) {
|
||||||
throw new Error(
|
throw new Error('useOptionsDropdown must be used within a context');
|
||||||
'useOptionsDropdown must be used within a ObjectOptionsDropdownContext.Provider',
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleCloseDropdown = useCallback(() => {
|
const { closeDropdown } = useDropdown({
|
||||||
context.resetContent();
|
context: ObjectOptionsDropdownContext,
|
||||||
closeDropdown();
|
});
|
||||||
}, [closeDropdown, context]);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...context,
|
...context,
|
||||||
closeDropdown: handleCloseDropdown,
|
closeDropdown,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
@ -10,6 +10,7 @@ export type ObjectOptionsDropdownContextValue = {
|
|||||||
currentContentId: ObjectOptionsContentId | null;
|
currentContentId: ObjectOptionsContentId | null;
|
||||||
onContentChange: (key: ObjectOptionsContentId) => void;
|
onContentChange: (key: ObjectOptionsContentId) => void;
|
||||||
resetContent: () => void;
|
resetContent: () => void;
|
||||||
|
dropdownId: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const ObjectOptionsDropdownContext =
|
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 { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
|
||||||
import { RecordBoardContext } from '@/object-record/record-board/contexts/RecordBoardContext';
|
import { RecordBoardContext } from '@/object-record/record-board/contexts/RecordBoardContext';
|
||||||
import { RecordBoardColumnDropdownMenu } from '@/object-record/record-board/record-board-column/components/RecordBoardColumnDropdownMenu';
|
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 { 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 { useColumnNewCardActions } from '@/object-record/record-board/record-board-column/hooks/useColumnNewCardActions';
|
||||||
import { useIsOpportunitiesCompanyFieldDisabled } from '@/object-record/record-board/record-board-column/hooks/useIsOpportunitiesCompanyFieldDisabled';
|
import { useIsOpportunitiesCompanyFieldDisabled } from '@/object-record/record-board/record-board-column/hooks/useIsOpportunitiesCompanyFieldDisabled';
|
||||||
import { RecordBoardColumnHotkeyScope } from '@/object-record/record-board/types/BoardColumnHotkeyScope';
|
import { RecordBoardColumnHotkeyScope } from '@/object-record/record-board/types/BoardColumnHotkeyScope';
|
||||||
import { RecordGroupDefinitionType } from '@/object-record/record-group/types/RecordGroupDefinition';
|
import { RecordGroupDefinitionType } from '@/object-record/record-group/types/RecordGroupDefinition';
|
||||||
import { usePreviousHotkeyScope } from '@/ui/utilities/hotkey/hooks/usePreviousHotkeyScope';
|
import { usePreviousHotkeyScope } from '@/ui/utilities/hotkey/hooks/usePreviousHotkeyScope';
|
||||||
|
import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
|
||||||
import { IconDotsVertical, IconPlus, LightIconButton, Tag } from 'twenty-ui';
|
import { IconDotsVertical, IconPlus, LightIconButton, Tag } from 'twenty-ui';
|
||||||
|
|
||||||
const StyledHeader = styled.div`
|
const StyledHeader = styled.div`
|
||||||
@ -19,21 +22,7 @@ const StyledHeader = styled.div`
|
|||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
justify-content: left;
|
justify-content: left;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
`;
|
height: 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;
|
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const StyledHeaderActions = styled.div`
|
const StyledHeaderActions = styled.div`
|
||||||
@ -52,6 +41,16 @@ const StyledLeftContainer = styled.div`
|
|||||||
gap: ${({ theme }) => theme.spacing(1)};
|
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`
|
const StyledRightContainer = styled.div`
|
||||||
align-items: center;
|
align-items: center;
|
||||||
display: flex;
|
display: flex;
|
||||||
@ -70,9 +69,7 @@ const StyledColumn = styled.div`
|
|||||||
`;
|
`;
|
||||||
|
|
||||||
export const RecordBoardColumnHeader = () => {
|
export const RecordBoardColumnHeader = () => {
|
||||||
const { columnDefinition, recordCount } = useContext(
|
const { columnDefinition } = useContext(RecordBoardColumnContext);
|
||||||
RecordBoardColumnContext,
|
|
||||||
);
|
|
||||||
const [isBoardColumnMenuOpen, setIsBoardColumnMenuOpen] = useState(false);
|
const [isBoardColumnMenuOpen, setIsBoardColumnMenuOpen] = useState(false);
|
||||||
const [isHeaderHovered, setIsHeaderHovered] = useState(false);
|
const [isHeaderHovered, setIsHeaderHovered] = useState(false);
|
||||||
|
|
||||||
@ -98,10 +95,15 @@ export const RecordBoardColumnHeader = () => {
|
|||||||
setIsBoardColumnMenuOpen(false);
|
setIsBoardColumnMenuOpen(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
const boardColumnTotal = 0;
|
const { aggregateValue, aggregateLabel } =
|
||||||
|
useAggregateManyRecordsForRecordBoardColumn();
|
||||||
|
|
||||||
const { handleNewButtonClick } = useColumnNewCardActions(
|
const { handleNewButtonClick } = useColumnNewCardActions(
|
||||||
columnDefinition?.id ?? '',
|
columnDefinition.id ?? '',
|
||||||
|
);
|
||||||
|
|
||||||
|
const isAggregateQueryEnabled = useIsFeatureEnabled(
|
||||||
|
'IS_AGGREGATE_QUERY_ENABLED',
|
||||||
);
|
);
|
||||||
|
|
||||||
const { isOpportunitiesCompanyFieldDisabled } =
|
const { isOpportunitiesCompanyFieldDisabled } =
|
||||||
@ -138,10 +140,18 @@ export const RecordBoardColumnHeader = () => {
|
|||||||
: 'medium'
|
: 'medium'
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
{!!boardColumnTotal && (
|
{isAggregateQueryEnabled ? (
|
||||||
<StyledAmount>${boardColumnTotal}</StyledAmount>
|
<RecordBoardColumnHeaderAggregateDropdown
|
||||||
|
aggregateValue={aggregateValue}
|
||||||
|
dropdownId={`record-board-column-aggregate-dropdown-${columnDefinition.id}`}
|
||||||
|
objectMetadataItem={objectMetadataItem}
|
||||||
|
aggregateLabel={aggregateLabel}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<StyledRecordCountChildren>
|
||||||
|
{aggregateValue}
|
||||||
|
</StyledRecordCountChildren>
|
||||||
)}
|
)}
|
||||||
<StyledNumChildren>{recordCount}</StyledNumChildren>
|
|
||||||
</StyledLeftContainer>
|
</StyledLeftContainer>
|
||||||
<StyledRightContainer>
|
<StyledRightContainer>
|
||||||
{isHeaderHovered && (
|
{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 {
|
export enum RecordBoardColumnHotkeyScope {
|
||||||
BoardColumn = 'board-column',
|
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 { contextStoreTargetedRecordsRuleComponentState } from '@/context-store/states/contextStoreTargetedRecordsRuleComponentState';
|
||||||
import { useSetRecordGroup } from '@/object-record/record-group/hooks/useSetRecordGroup';
|
import { useSetRecordGroup } from '@/object-record/record-group/hooks/useSetRecordGroup';
|
||||||
import { RecordIndexFiltersToContextStoreEffect } from '@/object-record/record-index/components/RecordIndexFiltersToContextStoreEffect';
|
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 { recordIndexViewFilterGroupsState } from '@/object-record/record-index/states/recordIndexViewFilterGroupsState';
|
||||||
import { useSetRecoilComponentStateV2 } from '@/ui/utilities/state/component-state/hooks/useSetRecoilComponentStateV2';
|
import { useSetRecoilComponentStateV2 } from '@/ui/utilities/state/component-state/hooks/useSetRecoilComponentStateV2';
|
||||||
import { ViewBar } from '@/views/components/ViewBar';
|
import { ViewBar } from '@/views/components/ViewBar';
|
||||||
@ -82,6 +83,9 @@ export const RecordIndexContainer = () => {
|
|||||||
const setRecordIndexViewKanbanFieldMetadataIdState = useSetRecoilState(
|
const setRecordIndexViewKanbanFieldMetadataIdState = useSetRecoilState(
|
||||||
recordIndexKanbanFieldMetadataIdState,
|
recordIndexKanbanFieldMetadataIdState,
|
||||||
);
|
);
|
||||||
|
const setRecordIndexViewKanbanAggregateOperationState = useSetRecoilState(
|
||||||
|
recordIndexKanbanAggregateOperationState,
|
||||||
|
);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
setTableViewFilterGroups,
|
setTableViewFilterGroups,
|
||||||
@ -180,6 +184,10 @@ export const RecordIndexContainer = () => {
|
|||||||
setRecordIndexViewKanbanFieldMetadataIdState(
|
setRecordIndexViewKanbanFieldMetadataIdState(
|
||||||
view.kanbanFieldMetadataId,
|
view.kanbanFieldMetadataId,
|
||||||
);
|
);
|
||||||
|
setRecordIndexViewKanbanAggregateOperationState({
|
||||||
|
operation: view.kanbanAggregateOperation,
|
||||||
|
fieldMetadataId: view.kanbanAggregateOperationFieldMetadataId,
|
||||||
|
});
|
||||||
setRecordIndexIsCompactModeActive(view.isCompact);
|
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,
|
position: true,
|
||||||
type: true,
|
type: true,
|
||||||
kanbanFieldMetadataId: true,
|
kanbanFieldMetadataId: true,
|
||||||
|
kanbanAggregateOperation: true,
|
||||||
|
kanbanAggregateOperationFieldMetadataId: true,
|
||||||
name: true,
|
name: true,
|
||||||
icon: true,
|
icon: true,
|
||||||
key: true,
|
key: true,
|
||||||
|
@ -74,7 +74,7 @@ export const useCreateViewFromCurrentView = (viewBarComponentId?: string) => {
|
|||||||
'id' | 'name' | 'icon' | 'kanbanFieldMetadataId' | 'type'
|
'id' | 'name' | 'icon' | 'kanbanFieldMetadataId' | 'type'
|
||||||
>
|
>
|
||||||
>,
|
>,
|
||||||
shouldCopyFiltersAndSorts?: boolean,
|
shouldCopyFiltersAndSortsAndAggregate?: boolean,
|
||||||
) => {
|
) => {
|
||||||
const currentViewId = getSnapshotValue(
|
const currentViewId = getSnapshotValue(
|
||||||
snapshot,
|
snapshot,
|
||||||
@ -101,6 +101,13 @@ export const useCreateViewFromCurrentView = (viewBarComponentId?: string) => {
|
|||||||
key: null,
|
key: null,
|
||||||
kanbanFieldMetadataId:
|
kanbanFieldMetadataId:
|
||||||
kanbanFieldMetadataId ?? sourceView.kanbanFieldMetadataId,
|
kanbanFieldMetadataId ?? sourceView.kanbanFieldMetadataId,
|
||||||
|
kanbanAggregateOperation: shouldCopyFiltersAndSortsAndAggregate
|
||||||
|
? sourceView.kanbanAggregateOperation
|
||||||
|
: undefined,
|
||||||
|
kanbanAggregateOperationFieldMetadataId:
|
||||||
|
shouldCopyFiltersAndSortsAndAggregate
|
||||||
|
? sourceView.kanbanAggregateOperationFieldMetadataId
|
||||||
|
: undefined,
|
||||||
type: type ?? sourceView.type,
|
type: type ?? sourceView.type,
|
||||||
objectMetadataId: sourceView.objectMetadataId,
|
objectMetadataId: sourceView.objectMetadataId,
|
||||||
});
|
});
|
||||||
@ -143,7 +150,7 @@ export const useCreateViewFromCurrentView = (viewBarComponentId?: string) => {
|
|||||||
await createViewGroupRecords(viewGroupsToCreate, newView);
|
await createViewGroupRecords(viewGroupsToCreate, newView);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (shouldCopyFiltersAndSorts === true) {
|
if (shouldCopyFiltersAndSortsAndAggregate === true) {
|
||||||
const sourceViewCombinedFilterGroups = getViewFilterGroupsCombined(
|
const sourceViewCombinedFilterGroups = getViewFilterGroupsCombined(
|
||||||
sourceView.id,
|
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 { ViewField } from '@/views/types/ViewField';
|
||||||
import { ViewFilter } from '@/views/types/ViewFilter';
|
import { ViewFilter } from '@/views/types/ViewFilter';
|
||||||
import { ViewFilterGroup } from '@/views/types/ViewFilterGroup';
|
import { ViewFilterGroup } from '@/views/types/ViewFilterGroup';
|
||||||
@ -12,6 +13,8 @@ export type GraphQLView = {
|
|||||||
type: ViewType;
|
type: ViewType;
|
||||||
key: ViewKey | null;
|
key: ViewKey | null;
|
||||||
kanbanFieldMetadataId: string;
|
kanbanFieldMetadataId: string;
|
||||||
|
kanbanAggregateOperation?: AGGREGATE_OPERATIONS | null;
|
||||||
|
kanbanAggregateOperationFieldMetadataId?: string | null;
|
||||||
objectMetadataId: string;
|
objectMetadataId: string;
|
||||||
isCompact: boolean;
|
isCompact: boolean;
|
||||||
viewFields: ViewField[];
|
viewFields: ViewField[];
|
||||||
|
@ -1,3 +1,4 @@
|
|||||||
|
import { AGGREGATE_OPERATIONS } from '@/object-record/record-table/constants/AggregateOperations';
|
||||||
import { ViewField } from '@/views/types/ViewField';
|
import { ViewField } from '@/views/types/ViewField';
|
||||||
import { ViewFilter } from '@/views/types/ViewFilter';
|
import { ViewFilter } from '@/views/types/ViewFilter';
|
||||||
import { ViewFilterGroup } from '@/views/types/ViewFilterGroup';
|
import { ViewFilterGroup } from '@/views/types/ViewFilterGroup';
|
||||||
@ -19,6 +20,8 @@ export type View = {
|
|||||||
viewFilterGroups?: ViewFilterGroup[];
|
viewFilterGroups?: ViewFilterGroup[];
|
||||||
viewSorts: ViewSort[];
|
viewSorts: ViewSort[];
|
||||||
kanbanFieldMetadataId: string;
|
kanbanFieldMetadataId: string;
|
||||||
|
kanbanAggregateOperation: AGGREGATE_OPERATIONS | null;
|
||||||
|
kanbanAggregateOperationFieldMetadataId: string | null;
|
||||||
position: number;
|
position: number;
|
||||||
icon: string;
|
icon: string;
|
||||||
__typename: 'View';
|
__typename: 'View';
|
||||||
|
@ -1,5 +1,6 @@
|
|||||||
import { RecordBoardFieldDefinition } from '@/object-record/record-board/types/RecordBoardFieldDefinition';
|
import { RecordBoardFieldDefinition } from '@/object-record/record-board/types/RecordBoardFieldDefinition';
|
||||||
import { FieldMetadata } from '@/object-record/record-field/types/FieldMetadata';
|
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';
|
import { ColumnDefinition } from '@/object-record/record-table/types/ColumnDefinition';
|
||||||
|
|
||||||
export type ViewField = {
|
export type ViewField = {
|
||||||
@ -9,6 +10,7 @@ export type ViewField = {
|
|||||||
position: number;
|
position: number;
|
||||||
isVisible: boolean;
|
isVisible: boolean;
|
||||||
size: number;
|
size: number;
|
||||||
|
aggregateOperation?: AGGREGATE_OPERATIONS | null;
|
||||||
definition:
|
definition:
|
||||||
| ColumnDefinition<FieldMetadata>
|
| ColumnDefinition<FieldMetadata>
|
||||||
| RecordBoardFieldDefinition<FieldMetadata>;
|
| RecordBoardFieldDefinition<FieldMetadata>;
|
||||||
|
@ -17,6 +17,9 @@ export const getObjectMetadataItemViews = (
|
|||||||
position: view.position,
|
position: view.position,
|
||||||
objectMetadataId: view.objectMetadataId,
|
objectMetadataId: view.objectMetadataId,
|
||||||
kanbanFieldMetadataId: view.kanbanFieldMetadataId,
|
kanbanFieldMetadataId: view.kanbanFieldMetadataId,
|
||||||
|
kanbanAggregateOperation: view.kanbanAggregateOperation,
|
||||||
|
kanbanAggregateOperationFieldMetadataId:
|
||||||
|
view.kanbanAggregateOperationFieldMetadataId,
|
||||||
icon: view.icon,
|
icon: view.icon,
|
||||||
}));
|
}));
|
||||||
};
|
};
|
||||||
|
@ -47,6 +47,7 @@ export const mapViewFieldsToColumnDefinitions = ({
|
|||||||
isLabelIdentifier,
|
isLabelIdentifier,
|
||||||
isVisible: isLabelIdentifier || viewField.isVisible,
|
isVisible: isLabelIdentifier || viewField.isVisible,
|
||||||
viewFieldId: viewField.id,
|
viewFieldId: viewField.id,
|
||||||
|
aggregateOperation: viewField.aggregateOperation,
|
||||||
isSortable: correspondingColumnDefinition.isSortable,
|
isSortable: correspondingColumnDefinition.isSortable,
|
||||||
isFilterable: correspondingColumnDefinition.isFilterable,
|
isFilterable: correspondingColumnDefinition.isFilterable,
|
||||||
defaultValue: correspondingColumnDefinition.defaultValue,
|
defaultValue: correspondingColumnDefinition.defaultValue,
|
||||||
|
@ -13,48 +13,40 @@ import { viewPickerTypeComponentState } from '@/views/view-picker/states/viewPic
|
|||||||
import { useRecoilCallback } from 'recoil';
|
import { useRecoilCallback } from 'recoil';
|
||||||
import { v4 } from 'uuid';
|
import { v4 } from 'uuid';
|
||||||
|
|
||||||
export const useCreateViewFromCurrentState = (viewBarInstanceId?: string) => {
|
export const useCreateViewFromCurrentState = () => {
|
||||||
const { closeAndResetViewPicker } = useCloseAndResetViewPicker();
|
const { closeAndResetViewPicker } = useCloseAndResetViewPicker();
|
||||||
|
|
||||||
const viewPickerInputNameCallbackState = useRecoilComponentCallbackStateV2(
|
const viewPickerInputNameCallbackState = useRecoilComponentCallbackStateV2(
|
||||||
viewPickerInputNameComponentState,
|
viewPickerInputNameComponentState,
|
||||||
viewBarInstanceId,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const viewPickerSelectedIconCallbackState = useRecoilComponentCallbackStateV2(
|
const viewPickerSelectedIconCallbackState = useRecoilComponentCallbackStateV2(
|
||||||
viewPickerSelectedIconComponentState,
|
viewPickerSelectedIconComponentState,
|
||||||
viewBarInstanceId,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const viewPickerTypeCallbackState = useRecoilComponentCallbackStateV2(
|
const viewPickerTypeCallbackState = useRecoilComponentCallbackStateV2(
|
||||||
viewPickerTypeComponentState,
|
viewPickerTypeComponentState,
|
||||||
viewBarInstanceId,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const viewPickerKanbanFieldMetadataIdCallbackState =
|
const viewPickerKanbanFieldMetadataIdCallbackState =
|
||||||
useRecoilComponentCallbackStateV2(
|
useRecoilComponentCallbackStateV2(
|
||||||
viewPickerKanbanFieldMetadataIdComponentState,
|
viewPickerKanbanFieldMetadataIdComponentState,
|
||||||
viewBarInstanceId,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const viewPickerIsPersistingCallbackState = useRecoilComponentCallbackStateV2(
|
const viewPickerIsPersistingCallbackState = useRecoilComponentCallbackStateV2(
|
||||||
viewPickerIsPersistingComponentState,
|
viewPickerIsPersistingComponentState,
|
||||||
viewBarInstanceId,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const viewPickerIsDirtyCallbackState = useRecoilComponentCallbackStateV2(
|
const viewPickerIsDirtyCallbackState = useRecoilComponentCallbackStateV2(
|
||||||
viewPickerIsDirtyComponentState,
|
viewPickerIsDirtyComponentState,
|
||||||
viewBarInstanceId,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const viewPickerModeCallbackState = useRecoilComponentCallbackStateV2(
|
const viewPickerModeCallbackState = useRecoilComponentCallbackStateV2(
|
||||||
viewPickerModeComponentState,
|
viewPickerModeComponentState,
|
||||||
viewBarInstanceId,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const { createViewFromCurrentView } =
|
const { createViewFromCurrentView } = useCreateViewFromCurrentView();
|
||||||
useCreateViewFromCurrentView(viewBarInstanceId);
|
const { changeView } = useChangeView();
|
||||||
const { changeView } = useChangeView(viewBarInstanceId);
|
|
||||||
|
|
||||||
const createViewFromCurrentState = useRecoilCallback(
|
const createViewFromCurrentState = useRecoilCallback(
|
||||||
({ snapshot, set }) =>
|
({ snapshot, set }) =>
|
||||||
@ -78,7 +70,7 @@ export const useCreateViewFromCurrentState = (viewBarInstanceId?: string) => {
|
|||||||
viewPickerModeCallbackState,
|
viewPickerModeCallbackState,
|
||||||
);
|
);
|
||||||
|
|
||||||
const shouldCopyFiltersAndSorts =
|
const shouldCopyFiltersAndSortsAndAggregate =
|
||||||
viewPickerMode === 'create-from-current';
|
viewPickerMode === 'create-from-current';
|
||||||
|
|
||||||
const id = v4();
|
const id = v4();
|
||||||
@ -94,7 +86,7 @@ export const useCreateViewFromCurrentState = (viewBarInstanceId?: string) => {
|
|||||||
type,
|
type,
|
||||||
kanbanFieldMetadataId,
|
kanbanFieldMetadataId,
|
||||||
},
|
},
|
||||||
shouldCopyFiltersAndSorts,
|
shouldCopyFiltersAndSortsAndAggregate,
|
||||||
);
|
);
|
||||||
|
|
||||||
closeAndResetViewPicker();
|
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;
|
return accumulator;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.aggregateParser.parse(
|
||||||
|
graphqlSelectedFields,
|
||||||
|
fieldMetadataMapByName,
|
||||||
|
accumulator,
|
||||||
|
);
|
||||||
|
|
||||||
this.parseRecordField(
|
this.parseRecordField(
|
||||||
graphqlSelectedFields,
|
graphqlSelectedFields,
|
||||||
fieldMetadataMapByName,
|
fieldMetadataMapByName,
|
||||||
|
@ -1,5 +1,6 @@
|
|||||||
import { SelectQueryBuilder } from 'typeorm';
|
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 { 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 { formatColumnNameFromCompositeFieldAndSubfield } from 'src/engine/twenty-orm/utils/format-column-name-from-composite-field-and-subfield.util';
|
||||||
import { isDefined } from 'src/utils/is-defined';
|
import { isDefined } from 'src/utils/is-defined';
|
||||||
@ -19,7 +20,7 @@ export class ProcessAggregateHelper {
|
|||||||
)) {
|
)) {
|
||||||
if (
|
if (
|
||||||
!isDefined(aggregatedField?.fromField) ||
|
!isDefined(aggregatedField?.fromField) ||
|
||||||
!isDefined(aggregatedField?.aggregationOperation)
|
!isDefined(aggregatedField?.aggregateOperation)
|
||||||
) {
|
) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@ -28,10 +29,17 @@ export class ProcessAggregateHelper {
|
|||||||
aggregatedField.fromField,
|
aggregatedField.fromField,
|
||||||
aggregatedField.fromSubField,
|
aggregatedField.fromSubField,
|
||||||
);
|
);
|
||||||
const operation = aggregatedField.aggregationOperation;
|
|
||||||
|
if (
|
||||||
|
!Object.values(AGGREGATE_OPERATIONS).includes(
|
||||||
|
aggregatedField.aggregateOperation,
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
queryBuilder.addSelect(
|
queryBuilder.addSelect(
|
||||||
`${operation}("${columnName}")`,
|
`${aggregatedField.aggregateOperation}("${columnName}")`,
|
||||||
`${aggregatedFieldName}`,
|
`${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 { 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 { FieldMetadataType } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity';
|
||||||
import { capitalize } from 'src/utils/capitalize';
|
import { capitalize } from 'src/utils/capitalize';
|
||||||
|
|
||||||
enum AGGREGATION_OPERATIONS {
|
|
||||||
min = 'MIN',
|
|
||||||
max = 'MAX',
|
|
||||||
avg = 'AVG',
|
|
||||||
sum = 'SUM',
|
|
||||||
count = 'COUNT',
|
|
||||||
}
|
|
||||||
|
|
||||||
export type AggregationField = {
|
export type AggregationField = {
|
||||||
type: GraphQLScalarType;
|
type: GraphQLScalarType;
|
||||||
description: string;
|
description: string;
|
||||||
fromField: string;
|
fromField: string;
|
||||||
fromSubField?: string;
|
fromSubField?: string;
|
||||||
aggregationOperation: AGGREGATION_OPERATIONS;
|
aggregateOperation: AGGREGATE_OPERATIONS;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getAvailableAggregationsFromObjectFields = (
|
export const getAvailableAggregationsFromObjectFields = (
|
||||||
@ -31,7 +24,7 @@ export const getAvailableAggregationsFromObjectFields = (
|
|||||||
type: GraphQLInt,
|
type: GraphQLInt,
|
||||||
description: `Total number of records in the connection`,
|
description: `Total number of records in the connection`,
|
||||||
fromField: 'id',
|
fromField: 'id',
|
||||||
aggregationOperation: AGGREGATION_OPERATIONS.count,
|
aggregateOperation: AGGREGATE_OPERATIONS.count,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (field.type === FieldMetadataType.DATE_TIME) {
|
if (field.type === FieldMetadataType.DATE_TIME) {
|
||||||
@ -39,14 +32,14 @@ export const getAvailableAggregationsFromObjectFields = (
|
|||||||
type: GraphQLISODateTime,
|
type: GraphQLISODateTime,
|
||||||
description: `Oldest date contained in the field ${field.name}`,
|
description: `Oldest date contained in the field ${field.name}`,
|
||||||
fromField: field.name,
|
fromField: field.name,
|
||||||
aggregationOperation: AGGREGATION_OPERATIONS.min,
|
aggregateOperation: AGGREGATE_OPERATIONS.min,
|
||||||
};
|
};
|
||||||
|
|
||||||
acc[`max${capitalize(field.name)}`] = {
|
acc[`max${capitalize(field.name)}`] = {
|
||||||
type: GraphQLISODateTime,
|
type: GraphQLISODateTime,
|
||||||
description: `Most recent date contained in the field ${field.name}`,
|
description: `Most recent date contained in the field ${field.name}`,
|
||||||
fromField: field.name,
|
fromField: field.name,
|
||||||
aggregationOperation: AGGREGATION_OPERATIONS.max,
|
aggregateOperation: AGGREGATE_OPERATIONS.max,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -55,38 +48,62 @@ export const getAvailableAggregationsFromObjectFields = (
|
|||||||
type: GraphQLFloat,
|
type: GraphQLFloat,
|
||||||
description: `Minimum amount contained in the field ${field.name}`,
|
description: `Minimum amount contained in the field ${field.name}`,
|
||||||
fromField: field.name,
|
fromField: field.name,
|
||||||
aggregationOperation: AGGREGATION_OPERATIONS.min,
|
aggregateOperation: AGGREGATE_OPERATIONS.min,
|
||||||
};
|
};
|
||||||
|
|
||||||
acc[`max${capitalize(field.name)}`] = {
|
acc[`max${capitalize(field.name)}`] = {
|
||||||
type: GraphQLFloat,
|
type: GraphQLFloat,
|
||||||
description: `Maximum amount contained in the field ${field.name}`,
|
description: `Maximum amount contained in the field ${field.name}`,
|
||||||
fromField: field.name,
|
fromField: field.name,
|
||||||
aggregationOperation: AGGREGATION_OPERATIONS.max,
|
aggregateOperation: AGGREGATE_OPERATIONS.max,
|
||||||
};
|
};
|
||||||
|
|
||||||
acc[`avg${capitalize(field.name)}`] = {
|
acc[`avg${capitalize(field.name)}`] = {
|
||||||
type: GraphQLFloat,
|
type: GraphQLFloat,
|
||||||
description: `Average amount contained in the field ${field.name}`,
|
description: `Average amount contained in the field ${field.name}`,
|
||||||
fromField: field.name,
|
fromField: field.name,
|
||||||
aggregationOperation: AGGREGATION_OPERATIONS.avg,
|
aggregateOperation: AGGREGATE_OPERATIONS.avg,
|
||||||
};
|
};
|
||||||
|
|
||||||
acc[`sum${capitalize(field.name)}`] = {
|
acc[`sum${capitalize(field.name)}`] = {
|
||||||
type: GraphQLFloat,
|
type: GraphQLFloat,
|
||||||
description: `Sum of amounts contained in the field ${field.name}`,
|
description: `Sum of amounts contained in the field ${field.name}`,
|
||||||
fromField: field.name,
|
fromField: field.name,
|
||||||
aggregationOperation: AGGREGATION_OPERATIONS.sum,
|
aggregateOperation: AGGREGATE_OPERATIONS.sum,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (field.type === FieldMetadataType.CURRENCY) {
|
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`] = {
|
acc[`avg${capitalize(field.name)}AmountMicros`] = {
|
||||||
type: GraphQLFloat,
|
type: GraphQLFloat,
|
||||||
description: `Average amount contained in the field ${field.name}`,
|
description: `Average amount contained in the field ${field.name}`,
|
||||||
fromField: field.name,
|
fromField: field.name,
|
||||||
fromSubField: 'amountMicros',
|
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',
|
size: '20202020-6fab-4bd0-ae72-20f3ee39d581',
|
||||||
position: '20202020-19e5-4e4c-8c15-3a96d1fd0650',
|
position: '20202020-19e5-4e4c-8c15-3a96d1fd0650',
|
||||||
view: '20202020-e8da-4521-afab-d6d231f9fa18',
|
view: '20202020-e8da-4521-afab-d6d231f9fa18',
|
||||||
|
aggregateOperation: '20202020-2cd7-4f94-ae83-4a14f5731a04',
|
||||||
};
|
};
|
||||||
|
|
||||||
export const VIEW_GROUP_STANDARD_FIELD_IDS = {
|
export const VIEW_GROUP_STANDARD_FIELD_IDS = {
|
||||||
@ -420,6 +421,9 @@ export const VIEW_STANDARD_FIELD_IDS = {
|
|||||||
key: '20202020-298e-49fa-9f4a-7b416b110443',
|
key: '20202020-298e-49fa-9f4a-7b416b110443',
|
||||||
icon: '20202020-1f08-4fd9-929b-cbc07f317166',
|
icon: '20202020-1f08-4fd9-929b-cbc07f317166',
|
||||||
kanbanFieldMetadataId: '20202020-d09b-4f65-ac42-06a2f20ba0e8',
|
kanbanFieldMetadataId: '20202020-d09b-4f65-ac42-06a2f20ba0e8',
|
||||||
|
kanbanAggregateOperation: '20202020-8da2-45de-a731-61bed84b17a8',
|
||||||
|
kanbanAggregateOperationFieldMetadataId:
|
||||||
|
'20202020-b1b3-4bf3-85e4-dc7d58aa9b02',
|
||||||
position: '20202020-e9db-4303-b271-e8250c450172',
|
position: '20202020-e9db-4303-b271-e8250c450172',
|
||||||
isCompact: '20202020-674e-4314-994d-05754ea7b22b',
|
isCompact: '20202020-674e-4314-994d-05754ea7b22b',
|
||||||
viewFields: '20202020-542b-4bdc-b177-b63175d48edf',
|
viewFields: '20202020-542b-4bdc-b177-b63175d48edf',
|
||||||
|
@ -1,12 +1,18 @@
|
|||||||
|
import { registerEnumType } from '@nestjs/graphql';
|
||||||
|
|
||||||
import { Relation } from 'typeorm';
|
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 { FieldMetadataType } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity';
|
||||||
import { RelationMetadataType } from 'src/engine/metadata-modules/relation-metadata/relation-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 { BaseWorkspaceEntity } from 'src/engine/twenty-orm/base.workspace-entity';
|
||||||
import { WorkspaceEntity } from 'src/engine/twenty-orm/decorators/workspace-entity.decorator';
|
import { WorkspaceEntity } from 'src/engine/twenty-orm/decorators/workspace-entity.decorator';
|
||||||
import { WorkspaceField } from 'src/engine/twenty-orm/decorators/workspace-field.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 { 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 { 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 { WorkspaceIsSystem } from 'src/engine/twenty-orm/decorators/workspace-is-system.decorator';
|
||||||
import { WorkspaceJoinColumn } from 'src/engine/twenty-orm/decorators/workspace-join-column.decorator';
|
import { WorkspaceJoinColumn } from 'src/engine/twenty-orm/decorators/workspace-join-column.decorator';
|
||||||
import { WorkspaceRelation } from 'src/engine/twenty-orm/decorators/workspace-relation.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 { 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';
|
import { ViewWorkspaceEntity } from 'src/modules/view/standard-objects/view.workspace-entity';
|
||||||
|
|
||||||
|
registerEnumType(AGGREGATE_OPERATIONS, {
|
||||||
|
name: 'AggregateOperations',
|
||||||
|
});
|
||||||
|
|
||||||
@WorkspaceEntity({
|
@WorkspaceEntity({
|
||||||
standardId: STANDARD_OBJECT_IDS.viewField,
|
standardId: STANDARD_OBJECT_IDS.viewField,
|
||||||
namePlural: 'viewFields',
|
namePlural: 'viewFields',
|
||||||
@ -80,6 +90,52 @@ export class ViewFieldWorkspaceEntity extends BaseWorkspaceEntity {
|
|||||||
})
|
})
|
||||||
view: Relation<ViewWorkspaceEntity>;
|
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')
|
@WorkspaceJoinColumn('view')
|
||||||
viewId: string;
|
viewId: string;
|
||||||
}
|
}
|
||||||
|
@ -1,5 +1,7 @@
|
|||||||
import { Relation } from 'src/engine/workspace-manager/workspace-sync-metadata/interfaces/relation.interface';
|
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 { FieldMetadataType } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity';
|
||||||
import {
|
import {
|
||||||
RelationMetadataType,
|
RelationMetadataType,
|
||||||
@ -8,6 +10,7 @@ import {
|
|||||||
import { BaseWorkspaceEntity } from 'src/engine/twenty-orm/base.workspace-entity';
|
import { BaseWorkspaceEntity } from 'src/engine/twenty-orm/base.workspace-entity';
|
||||||
import { WorkspaceEntity } from 'src/engine/twenty-orm/decorators/workspace-entity.decorator';
|
import { WorkspaceEntity } from 'src/engine/twenty-orm/decorators/workspace-entity.decorator';
|
||||||
import { WorkspaceField } from 'src/engine/twenty-orm/decorators/workspace-field.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 { 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 { WorkspaceIsNullable } from 'src/engine/twenty-orm/decorators/workspace-is-nullable.decorator';
|
||||||
import { WorkspaceIsSystem } from 'src/engine/twenty-orm/decorators/workspace-is-system.decorator';
|
import { WorkspaceIsSystem } from 'src/engine/twenty-orm/decorators/workspace-is-system.decorator';
|
||||||
@ -175,4 +178,63 @@ export class ViewWorkspaceEntity extends BaseWorkspaceEntity {
|
|||||||
})
|
})
|
||||||
@WorkspaceIsSystem()
|
@WorkspaceIsSystem()
|
||||||
favorites: Relation<FavoriteWorkspaceEntity[]>;
|
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