Improve record group aggregate query performance (#15828)

This PR is a first step for improving the performance on boards and
table with groups.

It is related to :
https://github.com/twentyhq/core-team-issues/issues/1870

Here we implement only a groupBy query for aggregate values in the group
section.

This also allows to improve the DX of aggregate computing and group by
query creation and parsing.

## Demo 

Main : 



https://github.com/user-attachments/assets/5d2a8077-5322-4928-a551-f03583bcfb87



This PR : 



https://github.com/user-attachments/assets/d0e82b28-72c3-40f0-b5cb-045f1a736ffb



## Aggregate update bug fix

This PR also solves a bug with aggregate update that was already present
on main.

The bug is linked to core views not being updated properly during a
modification of the aggregate operation on a view.

We should probably improve the view lifecycle and state management
because it is a bit too complex right now.

Main : 


https://github.com/user-attachments/assets/10dbfb8b-dfa0-4f21-8698-d222871a43e7

This PR : 


https://github.com/user-attachments/assets/bac41890-5191-4e4c-b82b-19b1039e9ab5

## Miscellaneous 

- Fixed optimistic rendering of group by queries, when adding a new
record, the aggregate recomputes well.

## TODO 

- We might want to improve the optimistic for group by queries that
don't have records nor more than one dimension.
This commit is contained in:
Lucas Bordeau
2025-11-23 20:40:23 +01:00
committed by GitHub
parent 4d55fef874
commit 061cc897af
61 changed files with 1356 additions and 930 deletions
+2 -3
View File
@@ -61,9 +61,8 @@ const jestConfig = {
extensionsToTreatAsEsm: ['.ts', '.tsx'],
coverageThreshold: {
global: {
statements: 52,
// Temporarily decreasing to 50.97 as introduced v1 code that aims to be deleted
lines: 50.95,
statements: 51,
lines: 50,
functions: 41,
},
},
@@ -1,48 +1,67 @@
import { normalizeGroupByDimensionValue } from '@/apollo/optimistic-effect/group-by/utils/normalizeGroupByDimensionValue';
import { type RecordGqlNode } from '@/object-record/graphql/types/RecordGqlNode';
import { isArray } from '@apollo/client/utilities';
import { isDefined } from 'twenty-shared/utils';
export const doesRecordBelongToGroup = (
record: RecordGqlNode,
groupByDimensionValues: readonly string[],
groupByConfig?: Array<Record<string, boolean | Record<string, string>>>,
groupByConfig?:
| Array<Record<string, boolean | Record<string, string>>>
| Record<string, boolean | Record<string, string>>,
): boolean => {
if (!isDefined(groupByConfig) || groupByConfig.length === 0) {
return true;
}
const groupByFieldNames = groupByConfig.map(
(groupByField) => Object.keys(groupByField)[0],
);
for (let i = 0; i < groupByFieldNames.length; i++) {
const fieldName = groupByFieldNames[i];
const expectedValue = groupByDimensionValues[i];
if (!isDefined(expectedValue)) {
continue;
}
let recordValue = record[fieldName];
if (!isDefined(recordValue)) {
return false;
}
const fieldConfig = groupByConfig[i][fieldName];
const recordValueStr = normalizeGroupByDimensionValue(
recordValue,
fieldConfig,
);
const expectedValueStr = normalizeGroupByDimensionValue(
expectedValue,
fieldConfig,
if (isArray(groupByConfig)) {
const groupByFieldNames = groupByConfig.map(
(groupByField) => Object.keys(groupByField)[0],
);
if (recordValueStr !== expectedValueStr) {
return false;
for (let i = 0; i < groupByFieldNames.length; i++) {
const fieldName = groupByFieldNames[i];
const expectedValue = groupByDimensionValues[i];
if (!isDefined(expectedValue)) {
continue;
}
let recordValue = record[fieldName];
if (!isDefined(recordValue)) {
return false;
}
const fieldConfig = groupByConfig[i][fieldName];
const recordValueStr = normalizeGroupByDimensionValue(
recordValue,
fieldConfig,
);
const expectedValueStr = normalizeGroupByDimensionValue(
expectedValue,
fieldConfig,
);
if (recordValueStr !== expectedValueStr) {
return false;
}
}
return true;
} else {
const recordGroupNames = Object.keys(groupByConfig);
for (const recordGroupName of recordGroupNames) {
if (groupByConfig[recordGroupName] === true) {
const recordGroupValue = record[recordGroupName];
if (groupByDimensionValues.includes(recordGroupValue)) {
return true;
}
}
}
return false;
}
return true;
};
@@ -23,7 +23,9 @@ type ProcessGroupByConnectionWithRecordsArgs = {
queryFilter: any;
shouldMatchRootQueryFilter: boolean;
groupByDimensionValues: readonly string[];
groupByConfig?: Array<Record<string, boolean | Record<string, string>>>;
groupByConfig?:
| Array<Record<string, boolean | Record<string, string>>>
| Record<string, boolean | Record<string, string>>;
objectMetadataItem: ObjectMetadataItem;
readField: ReadFieldFunction;
toReference: ToReferenceFunction;
@@ -10,6 +10,7 @@ import { type RecordGqlNode } from '@/object-record/graphql/types/RecordGqlNode'
import { type RecordGqlGroupByConnection } from '@/object-record/graphql/types/RecordGqlOperationGroupByResult';
import { type RecordGqlOperationGroupByVariables } from '@/object-record/graphql/types/RecordGqlOperationGroupByVariables';
import { isRecordMatchingFilter } from '@/object-record/record-filter/utils/isRecordMatchingFilter';
import { isArray } from '@sniptt/guards';
import { isDefined } from 'twenty-shared/utils';
import { parseApolloStoreFieldName } from '~/utils/parseApolloStoreFieldName';
@@ -56,12 +57,15 @@ export const triggerUpdateGroupByQueriesOptimisticEffect = ({
(groupConnection) => {
const groupByDimensionValues =
readField('groupByDimensionValues', groupConnection) || [];
const cachedEdges =
readField<RecordGqlRefEdge[]>('edges', groupConnection) || [];
const cachedTotalCount = readField<number | undefined>(
'totalCount',
groupConnection,
);
const cachedPageInfo = readField<{
startCursor?: string;
endCursor?: string;
@@ -132,6 +136,11 @@ export const triggerUpdateGroupByQueriesOptimisticEffect = ({
continue;
}
// TODO: see if we need to handle the case where it's not an array like for aggregate header
if (!isArray(groupByConfig)) {
continue;
}
const groupByFieldNames = groupByConfig.map(
(groupByField) => Object.keys(groupByField)[0],
);
@@ -1,15 +1,18 @@
import { useDeleteOneFieldMetadataItemMutation } from '~/generated-metadata/graphql';
import { MAIN_CONTEXT_STORE_INSTANCE_ID } from '@/context-store/constants/MainContextStoreInstanceId';
import { useMetadataErrorHandler } from '@/metadata-error-handler/hooks/useMetadataErrorHandler';
import { useRefreshObjectMetadataItems } from '@/object-metadata/hooks/useRefreshObjectMetadataItems';
import { type MetadataRequestResult } from '@/object-metadata/types/MetadataRequestResult.type';
import { recordIndexKanbanAggregateOperationState } from '@/object-record/record-index/states/recordIndexKanbanAggregateOperationState';
import { recordIndexGroupAggregateFieldMetadataItemComponentState } from '@/object-record/record-index/states/recordIndexGroupAggregateFieldMetadataItemComponentState';
import { recordIndexGroupAggregateOperationComponentState } from '@/object-record/record-index/states/recordIndexGroupAggregateOperationComponentState';
import { AggregateOperations } from '@/object-record/record-table/constants/AggregateOperations';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { useRecoilComponentState } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentState';
import { useSetRecoilComponentState } from '@/ui/utilities/state/component-state/hooks/useSetRecoilComponentState';
import { useRefreshCoreViewsByObjectMetadataId } from '@/views/hooks/useRefreshCoreViewsByObjectMetadataId';
import { ApolloError } from '@apollo/client';
import { t } from '@lingui/core/macro';
import { useRecoilState } from 'recoil';
export const useDeleteOneFieldMetadataItem = () => {
const [deleteOneFieldMetadataItemMutation] =
@@ -23,19 +26,25 @@ export const useDeleteOneFieldMetadataItem = () => {
const { handleMetadataError } = useMetadataErrorHandler();
const { enqueueErrorSnackBar } = useSnackBar();
const setRecordIndexGroupAggregateOperation = useSetRecoilComponentState(
recordIndexGroupAggregateOperationComponentState,
MAIN_CONTEXT_STORE_INSTANCE_ID,
);
const [
recordIndexKanbanAggregateOperation,
setRecordIndexKanbanAggregateOperation,
] = useRecoilState(recordIndexKanbanAggregateOperationState);
recordIndexGroupAggregateFieldMetadataItem,
setRecordIndexGroupAggregateFieldMetadataItem,
] = useRecoilComponentState(
recordIndexGroupAggregateFieldMetadataItemComponentState,
MAIN_CONTEXT_STORE_INSTANCE_ID,
);
const resetRecordIndexKanbanAggregateOperation = async (
idToDelete: string,
) => {
if (recordIndexKanbanAggregateOperation?.fieldMetadataId === idToDelete) {
setRecordIndexKanbanAggregateOperation({
operation: AggregateOperations.COUNT,
fieldMetadataId: null,
});
if (recordIndexGroupAggregateFieldMetadataItem?.id === idToDelete) {
setRecordIndexGroupAggregateOperation(AggregateOperations.COUNT);
setRecordIndexGroupAggregateFieldMetadataItem(null);
}
};
@@ -57,6 +66,7 @@ export const useDeleteOneFieldMetadataItem = () => {
},
});
// TODO: see if we can remove this lin altogether
await resetRecordIndexKanbanAggregateOperation(idToDelete);
await refreshObjectMetadataItems();
@@ -2,7 +2,7 @@ import { type RecordGqlOperationOrderBy } from '@/object-record/graphql/types/Re
import { type RecordGqlOperationFilter } from 'twenty-shared/types';
export type RecordGqlOperationGroupByVariables = {
groupBy: Record<string, any>[];
groupBy: Record<string, any>[] | Record<string, any>;
filter?: RecordGqlOperationFilter;
orderBy?: Record<string, any>[];
orderByForRecords?: RecordGqlOperationOrderBy;
@@ -0,0 +1,19 @@
import { getAggregateLabelWithFieldName } from '@/object-record/record-aggregate/utils/getAggregateLabelWithFieldName';
import { AggregateOperations } from '@/object-record/record-table/constants/AggregateOperations';
import { findByProperty } from 'twenty-shared/utils';
import { getMockCompanyObjectMetadataItem } from '~/testing/mock-data/companies';
describe('getAggregateLabelWithFieldName', () => {
const mockFieldMetadataItem = getMockCompanyObjectMetadataItem().fields.find(
findByProperty('name', 'name'),
)!;
it('should return correct label for provided field metadata item and operation', () => {
expect(
getAggregateLabelWithFieldName({
aggregateFieldMetadataItem: mockFieldMetadataItem,
aggregateOperation: AggregateOperations.COUNT,
}),
).toBe('All of Name');
});
});
@@ -0,0 +1,161 @@
import { DateFormat } from '@/localization/constants/DateFormat';
import { TimeFormat } from '@/localization/constants/TimeFormat';
import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { transformAggregateRawValueIntoAggregateDisplayValue } from '@/object-record/record-aggregate/utils/transformAggregateRawValueIntoAggregateDisplayValue';
import { AggregateOperations } from '@/object-record/record-table/constants/AggregateOperations';
import { DateAggregateOperations } from '@/object-record/record-table/constants/DateAggregateOperations';
import { enUS } from 'date-fns/locale';
import { findByProperty } from 'twenty-shared/utils';
import { FieldMetadataType } from '~/generated-metadata/graphql';
import { getMockCompanyObjectMetadataItem } from '~/testing/mock-data/companies';
describe('transformAggregateRawValueIntoAggregateDisplayValue', () => {
const mockCompanyObjectMetadataItem = getMockCompanyObjectMetadataItem();
const mockCompanyEmployeesFieldMetadataItem =
mockCompanyObjectMetadataItem.fields.find(
findByProperty('name', 'employees'),
);
it('should return correct display value for avg of employees', () => {
expect(
transformAggregateRawValueIntoAggregateDisplayValue({
aggregateFieldMetadataItem: mockCompanyEmployeesFieldMetadataItem,
aggregateOperation: AggregateOperations.AVG,
aggregateRawValue: 300,
dateFormat: DateFormat.DAY_FIRST,
timeFormat: TimeFormat.HOUR_24,
localeCatalog: enUS,
timeZone: 'UTC',
}),
).toBe('300');
});
it('should return correct display value for COUNT', () => {
expect(
transformAggregateRawValueIntoAggregateDisplayValue({
aggregateFieldMetadataItem: undefined,
aggregateOperation: AggregateOperations.COUNT,
aggregateRawValue: 4,
dateFormat: DateFormat.DAY_FIRST,
timeFormat: TimeFormat.HOUR_24,
localeCatalog: enUS,
timeZone: 'UTC',
}),
).toBe('4');
});
it('should return "-" for nullish aggregate raw value', () => {
expect(
transformAggregateRawValueIntoAggregateDisplayValue({
aggregateFieldMetadataItem: undefined,
aggregateOperation: AggregateOperations.SUM,
aggregateRawValue: undefined,
dateFormat: DateFormat.DAY_FIRST,
timeFormat: TimeFormat.HOUR_24,
localeCatalog: enUS,
timeZone: 'UTC',
}),
).toBe('-');
});
it('should return "-" for operation other than COUNT with an undefined field metadata item', () => {
expect(
transformAggregateRawValueIntoAggregateDisplayValue({
aggregateFieldMetadataItem: undefined,
aggregateOperation: AggregateOperations.SUM,
aggregateRawValue: 3,
dateFormat: DateFormat.DAY_FIRST,
timeFormat: TimeFormat.HOUR_24,
localeCatalog: enUS,
timeZone: 'UTC',
}),
).toBe('-');
});
it('should return correct percentage formatted value', () => {
expect(
transformAggregateRawValueIntoAggregateDisplayValue({
aggregateFieldMetadataItem: mockCompanyEmployeesFieldMetadataItem,
aggregateOperation: AggregateOperations.PERCENTAGE_EMPTY,
aggregateRawValue: 0.09,
dateFormat: DateFormat.DAY_FIRST,
timeFormat: TimeFormat.HOUR_24,
localeCatalog: enUS,
timeZone: 'UTC',
}),
).toBe('9%');
});
it('should return correct currency formatted value from amount micro', () => {
const mockCurrencyFieldMetadataItem = {
...mockCompanyEmployeesFieldMetadataItem,
type: FieldMetadataType.CURRENCY,
} as FieldMetadataItem;
expect(
transformAggregateRawValueIntoAggregateDisplayValue({
aggregateFieldMetadataItem: mockCurrencyFieldMetadataItem,
aggregateOperation: AggregateOperations.SUM,
aggregateRawValue: 230440000000,
dateFormat: DateFormat.DAY_FIRST,
timeFormat: TimeFormat.HOUR_24,
localeCatalog: enUS,
timeZone: 'UTC',
}),
).toBe('230.4k');
});
it('should return correct number formatted value', () => {
expect(
transformAggregateRawValueIntoAggregateDisplayValue({
aggregateFieldMetadataItem: mockCompanyEmployeesFieldMetadataItem,
aggregateOperation: AggregateOperations.SUM,
aggregateRawValue: 100000000,
dateFormat: DateFormat.DAY_FIRST,
timeFormat: TimeFormat.HOUR_24,
localeCatalog: enUS,
timeZone: 'UTC',
}),
).toBe('100,000,000');
});
it('should return correct DATE formatted value', () => {
const mockDateFieldMetadataItem = {
...mockCompanyEmployeesFieldMetadataItem,
type: FieldMetadataType.DATE,
settings: undefined,
} as FieldMetadataItem;
expect(
transformAggregateRawValueIntoAggregateDisplayValue({
aggregateFieldMetadataItem: mockDateFieldMetadataItem,
aggregateOperation: DateAggregateOperations.EARLIEST,
aggregateRawValue: '2000-01-01',
dateFormat: DateFormat.DAY_FIRST,
timeFormat: TimeFormat.HOUR_24,
localeCatalog: enUS,
timeZone: 'UTC',
}),
).toBe('1 Jan, 2000');
});
it('should return correct DATE_TIME formatted value', () => {
const mockDateFieldMetadataItem = {
...mockCompanyEmployeesFieldMetadataItem,
type: FieldMetadataType.DATE_TIME,
settings: undefined,
} as FieldMetadataItem;
expect(
transformAggregateRawValueIntoAggregateDisplayValue({
aggregateFieldMetadataItem: mockDateFieldMetadataItem,
aggregateOperation: DateAggregateOperations.EARLIEST,
aggregateRawValue: '2000-01-01T12:00:00.000Z',
dateFormat: DateFormat.DAY_FIRST,
timeFormat: TimeFormat.HOUR_24,
localeCatalog: enUS,
timeZone: 'UTC',
}),
).toBe('1 Jan, 2000 12:00');
});
});
@@ -2,18 +2,18 @@ import gql from 'graphql-tag';
import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { capitalize } from 'twenty-shared/utils';
import { getGroupByQueryName } from '../../../utils/getGroupByQueryName';
import { getGroupByQueryResultGqlFieldName } from '../../../page-layout/utils/getGroupByQueryResultGqlFieldName';
export const generateGroupByQuery = ({
export const generateGroupByAggregateQuery = ({
objectMetadataItem,
aggregateOperations,
aggregateOperationGqlFields,
}: {
objectMetadataItem: ObjectMetadataItem;
aggregateOperations: string[];
aggregateOperationGqlFields: string[];
}) => {
const capitalizedSingular = capitalize(objectMetadataItem.nameSingular);
const queryName = `${capitalize(objectMetadataItem.namePlural)}GroupBy`;
const queryFieldName = getGroupByQueryName(objectMetadataItem);
const queryFieldName = getGroupByQueryResultGqlFieldName(objectMetadataItem);
return gql`
query ${queryName}(
@@ -30,7 +30,7 @@ export const generateGroupByQuery = ({
viewId: $viewId
limit: $limit
) {
groupByDimensionValues${aggregateOperations.length > 0 ? `\n ${aggregateOperations.join('\n ')}` : ''}
groupByDimensionValues${aggregateOperationGqlFields.length > 0 ? `\n ${aggregateOperationGqlFields.join('\n ')}` : ''}
}
}
`;
@@ -0,0 +1,20 @@
import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { getAggregateOperationShortLabel } from '@/object-record/record-board/record-board-column/utils/getAggregateOperationShortLabel';
import { type ExtendedAggregateOperations } from '@/object-record/record-table/types/ExtendedAggregateOperations';
import { t } from '@lingui/core/macro';
export type GetAggregateLabelWithFieldNameParams = {
aggregateFieldMetadataItem: FieldMetadataItem;
aggregateOperation: ExtendedAggregateOperations;
};
export const getAggregateLabelWithFieldName = ({
aggregateFieldMetadataItem,
aggregateOperation,
}: GetAggregateLabelWithFieldNameParams): string => {
const aggregateLabel = t(getAggregateOperationShortLabel(aggregateOperation));
const fieldLabel = aggregateFieldMetadataItem.label;
return t`${aggregateLabel} of ${fieldLabel}`;
};
@@ -0,0 +1,95 @@
import { type DateFormat } from '@/localization/constants/DateFormat';
import { type TimeFormat } from '@/localization/constants/TimeFormat';
import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { COUNT_AGGREGATE_OPERATION_OPTIONS } from '@/object-record/record-table/record-table-footer/constants/countAggregateOperationOptions';
import { PERCENT_AGGREGATE_OPERATION_OPTIONS } from '@/object-record/record-table/record-table-footer/constants/percentAggregateOperationOptions';
import { type ExtendedAggregateOperations } from '@/object-record/record-table/types/ExtendedAggregateOperations';
import { FieldMetadataType, type Nullable } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { type AggregateOperations } from '~/generated-metadata/graphql';
import { formatNumber } from '~/utils/format/formatNumber';
import { formatToShortNumber } from '~/utils/format/formatToShortNumber';
import { formatDateString } from '~/utils/string/formatDateString';
import { formatDateTimeString } from '~/utils/string/formatDateTimeString';
export const transformAggregateRawValueIntoAggregateDisplayValue = ({
aggregateFieldMetadataItem,
aggregateOperation,
aggregateRawValue,
dateFormat,
timeFormat,
timeZone,
localeCatalog,
}: {
aggregateFieldMetadataItem: Nullable<FieldMetadataItem>;
aggregateOperation: ExtendedAggregateOperations;
aggregateRawValue: Nullable<string | number>;
dateFormat: DateFormat;
timeFormat: TimeFormat;
timeZone: string;
localeCatalog: Locale;
}): string => {
if (!isDefined(aggregateRawValue)) {
return '-';
} else if (
COUNT_AGGREGATE_OPERATION_OPTIONS.includes(
aggregateOperation as AggregateOperations,
)
) {
return `${aggregateRawValue}`;
} else if (!isDefined(aggregateFieldMetadataItem)) {
return '-';
} else if (
PERCENT_AGGREGATE_OPERATION_OPTIONS.includes(
aggregateOperation as AggregateOperations,
)
) {
return `${formatNumber(Number(aggregateRawValue) * 100)}%`;
} else {
switch (aggregateFieldMetadataItem.type) {
case FieldMetadataType.CURRENCY: {
return formatToShortNumber(Number(aggregateRawValue) / 1_000_000);
}
case FieldMetadataType.NUMBER: {
const castedValue = Number(aggregateRawValue);
const { decimals, type } = aggregateFieldMetadataItem.settings ?? {};
return type === 'percentage'
? `${formatNumber(castedValue * 100, { decimals })}%`
: formatNumber(castedValue, { decimals });
}
case FieldMetadataType.DATE_TIME: {
const dateFieldSettings = aggregateFieldMetadataItem.settings;
const dateISOStringRawValue = aggregateRawValue as string;
return formatDateTimeString({
value: dateISOStringRawValue,
timeZone,
dateFormat,
timeFormat,
dateFieldSettings,
localeCatalog,
});
}
case FieldMetadataType.DATE: {
const dateFieldSettings = aggregateFieldMetadataItem.settings;
const plainDateStringRawValue = aggregateRawValue as string;
return formatDateString({
value: plainDateStringRawValue,
timeZone,
dateFormat,
dateFieldSettings,
localeCatalog,
});
}
}
}
return '-';
};
@@ -1,5 +1,7 @@
import { RecordBoardColumnHeaderWrapper } from '@/object-record/record-board/record-board-column/components/RecordBoardColumnHeaderWrapper';
import { RecordGroupContext } from '@/object-record/record-group/states/context/RecordGroupContext';
import { visibleRecordGroupIdsComponentFamilySelector } from '@/object-record/record-group/states/selectors/visibleRecordGroupIdsComponentFamilySelector';
import { RecordIndexGroupAggregatesDataLoader } from '@/object-record/record-index/components/RecordIndexGroupAggregatesDataLoader';
import { useRecoilComponentFamilyValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentFamilyValue';
import { ViewType } from '@/views/types/ViewType';
import styled from '@emotion/styled';
@@ -32,12 +34,17 @@ export const RecordBoardHeader = () => {
return (
<StyledHeaderContainer id="record-board-header">
{visibleRecordGroupIds.map((recordGroupId, index) => (
<RecordBoardColumnHeaderWrapper
columnId={recordGroupId}
columnIndex={index}
<RecordGroupContext.Provider
key={recordGroupId}
/>
value={{ recordGroupId }}
>
<RecordBoardColumnHeaderWrapper
columnId={recordGroupId}
columnIndex={index}
/>
</RecordGroupContext.Provider>
))}
<RecordIndexGroupAggregatesDataLoader />
</StyledHeaderContainer>
);
};
@@ -6,12 +6,14 @@ import { RecordBoardContext } from '@/object-record/record-board/contexts/Record
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 { useAggregateRecordsForRecordBoardColumn } from '@/object-record/record-board/record-board-column/hooks/useAggregateRecordsForRecordBoardColumn';
import { hasAnySoftDeleteFilterOnViewComponentSelector } from '@/object-record/record-filter/states/hasAnySoftDeleteFilterOnView';
import { RecordGroupDefinitionType } from '@/object-record/record-group/types/RecordGroupDefinition';
import { recordIndexAggregateDisplayLabelComponentState } from '@/object-record/record-index/states/recordIndexAggregateDisplayLabelComponentState';
import { recordIndexAggregateDisplayValueForGroupValueComponentFamilyState } from '@/object-record/record-index/states/recordIndexAggregateDisplayValueForGroupValueComponentFamilyState';
import { useCreateNewIndexRecord } from '@/object-record/record-table/hooks/useCreateNewIndexRecord';
import { Dropdown } from '@/ui/layout/dropdown/components/Dropdown';
import { useToggleDropdown } from '@/ui/layout/dropdown/hooks/useToggleDropdown';
import { useRecoilComponentFamilyValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentFamilyValue';
import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue';
import { Tag } from 'twenty-ui/components';
import { IconDotsVertical, IconPlus } from 'twenty-ui/display';
@@ -68,14 +70,12 @@ const StyledTag = styled(Tag)`
export const RecordBoardColumnHeader = () => {
const { columnDefinition } = useContext(RecordBoardColumnContext);
const [isHeaderHovered, setIsHeaderHovered] = useState(false);
const { objectMetadataItem, selectFieldMetadataItem } =
useContext(RecordBoardContext);
const { aggregateValue, aggregateLabel } =
useAggregateRecordsForRecordBoardColumn();
const objectPermissions = useObjectPermissionsForObject(
objectMetadataItem.id,
);
@@ -90,6 +90,16 @@ export const RecordBoardColumnHeader = () => {
objectMetadataItem: objectMetadataItem,
});
const recordIndexAggregateDisplayValueForGroupValue =
useRecoilComponentFamilyValue(
recordIndexAggregateDisplayValueForGroupValueComponentFamilyState,
{ groupValue: columnDefinition?.value ?? '' },
);
const recordIndexAggregateDisplayLabel = useRecoilComponentValue(
recordIndexAggregateDisplayLabelComponentState,
);
const { toggleDropdown } = useToggleDropdown();
const dropdownId = `record-board-column-dropdown-${columnDefinition.id}`;
@@ -133,10 +143,10 @@ export const RecordBoardColumnHeader = () => {
/>
<RecordBoardColumnHeaderAggregateDropdown
aggregateValue={aggregateValue}
aggregateValue={recordIndexAggregateDisplayValueForGroupValue}
dropdownId={`record-board-column-aggregate-dropdown-${columnDefinition.id}`}
objectMetadataItem={objectMetadataItem}
aggregateLabel={aggregateLabel}
aggregateLabel={recordIndexAggregateDisplayLabel}
/>
</StyledLeftContainer>
<StyledRightContainer>
@@ -8,10 +8,11 @@ import { type RecordBoardColumnHeaderAggregateContentId } from '@/object-record/
import { Dropdown } from '@/ui/layout/dropdown/components/Dropdown';
import { DROPDOWN_OFFSET_Y } from '@/ui/layout/dropdown/constants/DropdownOffsetY';
import styled from '@emotion/styled';
import { type Nullable } from 'twenty-shared/types';
type RecordBoardColumnHeaderAggregateDropdownProps = {
aggregateValue?: string | number;
aggregateLabel?: string;
aggregateValue?: Nullable<string | number>;
aggregateLabel?: Nullable<string>;
objectMetadataItem: ObjectMetadataItem;
dropdownId: string;
};
@@ -2,6 +2,7 @@ import { StyledHeaderDropdownButton } from '@/ui/layout/dropdown/components/Styl
import { isDropdownOpenComponentState } from '@/ui/layout/dropdown/states/isDropdownOpenComponentState';
import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue';
import styled from '@emotion/styled';
import { type Nullable } from 'twenty-shared/types';
import { Tag } from 'twenty-ui/components';
import { AppTooltip, TooltipDelay } from 'twenty-ui/display';
@@ -19,8 +20,8 @@ export const RecordBoardColumnHeaderAggregateDropdownButton = ({
tooltip,
}: {
dropdownId: string;
value?: string | number;
tooltip?: string;
value?: Nullable<string | number>;
tooltip?: Nullable<string>;
}) => {
const isDropdownOpen = useRecoilComponentValue(
isDropdownOpenComponentState,
@@ -38,7 +39,7 @@ export const RecordBoardColumnHeaderAggregateDropdownButton = ({
{!isDropdownOpen && (
<AppTooltip
anchorSelect={`#${dropdownId}`}
content={tooltip}
content={tooltip ?? ''}
noArrow
place="right"
positionStrategy="fixed"
@@ -3,14 +3,14 @@ import { RecordBoardColumnHeaderAggregateDropdownContext } from '@/object-record
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 { recordIndexKanbanAggregateOperationState } from '@/object-record/record-index/states/recordIndexKanbanAggregateOperationState';
import { recordIndexGroupAggregateFieldMetadataItemComponentState } from '@/object-record/record-index/states/recordIndexGroupAggregateFieldMetadataItemComponentState';
import { recordIndexGroupAggregateOperationComponentState } from '@/object-record/record-index/states/recordIndexGroupAggregateOperationComponentState';
import { DropdownContent } from '@/ui/layout/dropdown/components/DropdownContent';
import { DropdownMenuHeader } from '@/ui/layout/dropdown/components/DropdownMenuHeader/DropdownMenuHeader';
import { DropdownMenuHeaderLeftComponent } from '@/ui/layout/dropdown/components/DropdownMenuHeader/internal/DropdownMenuHeaderLeftComponent';
import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer';
import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue';
import { useUpdateViewAggregate } from '@/views/hooks/useUpdateViewAggregate';
import { useRecoilValue } from 'recoil';
import { isDefined } from 'twenty-shared/utils';
import {
Icon123,
@@ -43,11 +43,18 @@ export const RecordBoardColumnHeaderAggregateDropdownFieldsContent = () => {
availableFieldIdsForAggregateOperationComponentState,
);
const recordIndexKanbanAggregateOperation = useRecoilValue(
recordIndexKanbanAggregateOperationState,
const recordIndexGroupAggregateOperation = useRecoilComponentValue(
recordIndexGroupAggregateOperationComponentState,
);
if (!isDefined(aggregateOperation)) return <></>;
const recordIndexGroupAggregateFieldMetadataItem = useRecoilComponentValue(
recordIndexGroupAggregateFieldMetadataItemComponentState,
);
if (!isDefined(aggregateOperation)) {
return <></>;
}
return (
<DropdownContent>
<DropdownMenuHeader
@@ -78,17 +85,15 @@ export const RecordBoardColumnHeaderAggregateDropdownFieldsContent = () => {
updateViewAggregate({
kanbanAggregateOperationFieldMetadataId: fieldId,
kanbanAggregateOperation: aggregateOperation,
objectMetadataId: objectMetadataItem.id,
objectMetadataItem,
});
closeDropdown();
}}
LeftIcon={getIcon(fieldMetadata.icon) ?? Icon123}
text={fieldMetadata.label}
RightIcon={
recordIndexKanbanAggregateOperation?.fieldMetadataId ===
fieldId &&
recordIndexKanbanAggregateOperation?.operation ===
aggregateOperation
recordIndexGroupAggregateFieldMetadataItem?.id === fieldId &&
recordIndexGroupAggregateOperation === aggregateOperation
? IconCheck
: undefined
}
@@ -7,7 +7,7 @@ import { RecordBoardColumnHeaderAggregateDropdownMenuItem } from '@/object-recor
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 { recordIndexKanbanAggregateOperationState } from '@/object-record/record-index/states/recordIndexKanbanAggregateOperationState';
import { recordIndexGroupAggregateOperationComponentState } from '@/object-record/record-index/states/recordIndexGroupAggregateOperationComponentState';
import { AggregateOperations } from '@/object-record/record-table/constants/AggregateOperations';
import { type ExtendedAggregateOperations } from '@/object-record/record-table/types/ExtendedAggregateOperations';
import { type AvailableFieldsForAggregateOperation } from '@/object-record/types/AvailableFieldsForAggregateOperation';
@@ -15,10 +15,10 @@ import { DropdownContent } from '@/ui/layout/dropdown/components/DropdownContent
import { DropdownMenuHeader } from '@/ui/layout/dropdown/components/DropdownMenuHeader/DropdownMenuHeader';
import { DropdownMenuHeaderLeftComponent } from '@/ui/layout/dropdown/components/DropdownMenuHeader/internal/DropdownMenuHeaderLeftComponent';
import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer';
import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue';
import { useSetRecoilComponentState } from '@/ui/utilities/state/component-state/hooks/useSetRecoilComponentState';
import { useUpdateViewAggregate } from '@/views/hooks/useUpdateViewAggregate';
import isEmpty from 'lodash.isempty';
import { useRecoilValue } from 'recoil';
import { IconCheck, IconChevronLeft } from 'twenty-ui/display';
export const RecordBoardColumnHeaderAggregateDropdownOptionsContent = ({
@@ -45,8 +45,8 @@ export const RecordBoardColumnHeaderAggregateDropdownOptionsContent = ({
const { updateViewAggregate } = useUpdateViewAggregate();
const recordIndexKanbanAggregateOperation = useRecoilValue(
recordIndexKanbanAggregateOperationState,
const recordIndexGroupAggregateOperation = useRecoilComponentValue(
recordIndexGroupAggregateOperationComponentState,
);
return (
@@ -89,7 +89,7 @@ export const RecordBoardColumnHeaderAggregateDropdownOptionsContent = ({
availableAggregationFieldsIdsForOperation[0],
kanbanAggregateOperation:
availableAggregationOperation as AggregateOperations,
objectMetadataId: objectMetadataItem.id,
objectMetadataItem,
});
closeDropdown();
}
@@ -104,7 +104,7 @@ export const RecordBoardColumnHeaderAggregateDropdownOptionsContent = ({
}
RightIcon={
availableAggregationOperation === AggregateOperations.COUNT &&
recordIndexKanbanAggregateOperation?.operation ===
recordIndexGroupAggregateOperation ===
AggregateOperations.COUNT
? IconCheck
: undefined
@@ -1,38 +0,0 @@
import { RecordBoardContext } from '@/object-record/record-board/contexts/RecordBoardContext';
import { RecordBoardColumnContext } from '@/object-record/record-board/record-board-column/contexts/RecordBoardColumnContext';
import { recordIndexKanbanFieldMetadataIdState } from '@/object-record/record-index/states/recordIndexKanbanFieldMetadataIdState';
import { useAggregateRecordsForHeader } from '@/object-record/record-table/hooks/useAggregateRecordsForHeader';
import { useContext } from 'react';
import { useRecoilValue } from 'recoil';
import { isDefined } from 'twenty-shared/utils';
export const useAggregateRecordsForRecordBoardColumn = () => {
const { columnDefinition } = useContext(RecordBoardColumnContext);
const { objectMetadataItem } = useContext(RecordBoardContext);
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 additionalFilters = {
[kanbanFieldName]:
columnDefinition.value === null
? { is: 'NULL' }
: { eq: columnDefinition.value },
};
return useAggregateRecordsForHeader({
objectMetadataItem,
additionalFilters,
fallbackFieldName: kanbanFieldName,
});
};
@@ -1,7 +1,7 @@
import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { buildRecordGqlFieldsAggregateForView } from '@/object-record/record-board/record-board-column/utils/buildRecordGqlFieldsAggregateForView';
import { type KanbanAggregateOperation } from '@/object-record/record-index/states/recordIndexKanbanAggregateOperationState';
import { AggregateOperations } from '@/object-record/record-table/constants/AggregateOperations';
import { FieldMetadataType } from '~/generated-metadata/graphql';
@@ -49,15 +49,13 @@ describe('buildRecordGqlFieldsAggregateForView', () => {
updatedAt: new Date().toISOString(),
};
it('should build fields for numeric aggregate', () => {
const kanbanAggregateOperation: KanbanAggregateOperation = {
fieldMetadataId: MOCK_FIELD_ID,
operation: AggregateOperations.SUM,
};
const mockFieldMetadataItem = fields[0];
it('should build fields for numeric aggregate', () => {
const result = buildRecordGqlFieldsAggregateForView({
objectMetadataItem: mockObjectMetadata,
recordIndexKanbanAggregateOperation: kanbanAggregateOperation,
recordIndexGroupAggregateFieldMetadataItem: mockFieldMetadataItem,
recordIndexGroupAggregateOperation: AggregateOperations.SUM,
});
expect(result).toEqual({
@@ -66,14 +64,10 @@ describe('buildRecordGqlFieldsAggregateForView', () => {
});
it('should default to count when no field is found', () => {
const operation: KanbanAggregateOperation = {
fieldMetadataId: 'non-existent-id',
operation: AggregateOperations.COUNT,
};
const result = buildRecordGqlFieldsAggregateForView({
objectMetadataItem: mockObjectMetadata,
recordIndexKanbanAggregateOperation: operation,
recordIndexGroupAggregateFieldMetadataItem: null,
recordIndexGroupAggregateOperation: AggregateOperations.COUNT,
});
expect(result).toEqual({
@@ -82,15 +76,11 @@ describe('buildRecordGqlFieldsAggregateForView', () => {
});
it('should throw error for non-count operation with invalid field', () => {
const operation: KanbanAggregateOperation = {
fieldMetadataId: 'non-existent-id',
operation: AggregateOperations.SUM,
};
expect(() =>
buildRecordGqlFieldsAggregateForView({
objectMetadataItem: mockObjectMetadata,
recordIndexKanbanAggregateOperation: operation,
recordIndexGroupAggregateFieldMetadataItem: null,
recordIndexGroupAggregateOperation: AggregateOperations.SUM,
}),
).toThrow(
`No field found to compute aggregate operation ${AggregateOperations.SUM} on object ${mockObjectMetadata.nameSingular}`,
@@ -1,258 +0,0 @@
import { DateFormat } from '@/localization/constants/DateFormat';
import { TimeFormat } from '@/localization/constants/TimeFormat';
import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { type AggregateRecordsData } from '@/object-record/hooks/useAggregateRecords';
import { computeAggregateValueAndLabel } from '@/object-record/record-board/record-board-column/utils/computeAggregateValueAndLabel';
import { AggregateOperations } from '@/object-record/record-table/constants/AggregateOperations';
import { DateAggregateOperations } from '@/object-record/record-table/constants/DateAggregateOperations';
import { enUS } from 'date-fns/locale';
import { FieldMetadataType } from '~/generated-metadata/graphql';
const MOCK_FIELD_ID = '7d2d7b5e-7b3e-4b4a-8b0a-7b3e4b4a8b0a';
describe('computeAggregateValueAndLabel', () => {
const mockObjectMetadata: ObjectMetadataItem = {
id: '123',
fields: [
{
id: MOCK_FIELD_ID,
name: 'amount',
label: 'amount',
type: FieldMetadataType.CURRENCY,
} as FieldMetadataItem,
],
} as ObjectMetadataItem;
const defaultParams = {
dateFormat: DateFormat.DAY_FIRST,
timeFormat: TimeFormat.HOUR_24,
timeZone: 'UTC',
};
it('should return empty object for empty data', () => {
const result = computeAggregateValueAndLabel({
data: {} as AggregateRecordsData,
objectMetadataItem: mockObjectMetadata,
fieldMetadataId: MOCK_FIELD_ID,
aggregateOperation: AggregateOperations.SUM,
localeCatalog: enUS,
...defaultParams,
});
expect(result).toEqual({});
});
it('should handle currency field with division by 1M', () => {
const mockData = {
amount: {
[AggregateOperations.SUM]: 2000000,
},
} as AggregateRecordsData;
const result = computeAggregateValueAndLabel({
data: mockData,
objectMetadataItem: mockObjectMetadata,
fieldMetadataId: MOCK_FIELD_ID,
aggregateOperation: AggregateOperations.SUM,
localeCatalog: enUS,
...defaultParams,
});
expect(result).toEqual({
value: '2',
label: 'Sum',
labelWithFieldName: 'Sum of amount',
});
});
it('should handle number field as percentage', () => {
const mockObjectMetadataWithPercentageField: ObjectMetadataItem = {
id: '123',
fields: [
{
id: MOCK_FIELD_ID,
name: 'percentage',
label: 'percentage',
type: FieldMetadataType.NUMBER,
settings: {
type: 'percentage',
},
} as FieldMetadataItem,
],
} as ObjectMetadataItem;
const mockData = {
percentage: {
[AggregateOperations.AVG]: 0.3,
},
} as AggregateRecordsData;
const result = computeAggregateValueAndLabel({
data: mockData,
objectMetadataItem: mockObjectMetadataWithPercentageField,
fieldMetadataId: MOCK_FIELD_ID,
aggregateOperation: AggregateOperations.AVG,
localeCatalog: enUS,
...defaultParams,
});
expect(result).toEqual({
value: '30%',
label: 'Average',
labelWithFieldName: 'Average of percentage',
});
});
it('should handle number field with decimals', () => {
const mockObjectMetadataWithDecimalsField: ObjectMetadataItem = {
id: '123',
fields: [
{
id: MOCK_FIELD_ID,
name: 'decimals',
label: 'decimals',
type: FieldMetadataType.NUMBER,
settings: {
decimals: 2,
},
} as FieldMetadataItem,
],
} as ObjectMetadataItem;
const mockData = {
decimals: {
[AggregateOperations.SUM]: 0.009,
},
} as AggregateRecordsData;
const result = computeAggregateValueAndLabel({
data: mockData,
objectMetadataItem: mockObjectMetadataWithDecimalsField,
fieldMetadataId: MOCK_FIELD_ID,
aggregateOperation: AggregateOperations.SUM,
localeCatalog: enUS,
...defaultParams,
});
expect(result).toEqual({
value: '0.01',
label: 'Sum',
labelWithFieldName: 'Sum of decimals',
});
});
it('should handle datetime field with min operation', () => {
const mockObjectMetadataWithDatetimeField: ObjectMetadataItem = {
id: '123',
fields: [
{
id: MOCK_FIELD_ID,
name: 'createdAt',
label: 'Created At',
type: FieldMetadataType.DATE_TIME,
} as FieldMetadataItem,
],
} as ObjectMetadataItem;
const mockFormattedData = {
createdAt: {
[DateAggregateOperations.EARLIEST]: '2023-01-01T12:00:00Z',
},
} as AggregateRecordsData;
const result = computeAggregateValueAndLabel({
data: mockFormattedData,
objectMetadataItem: mockObjectMetadataWithDatetimeField,
fieldMetadataId: MOCK_FIELD_ID,
aggregateOperation: DateAggregateOperations.EARLIEST,
localeCatalog: enUS,
...defaultParams,
});
expect(result).toEqual({
label: 'Earliest',
labelWithFieldName: 'Earliest of Created At',
value: '1 Jan, 2023 12:00',
});
});
it('should handle datetime field with max operation', () => {
const mockObjectMetadataWithDatetimeField: ObjectMetadataItem = {
id: '123',
fields: [
{
id: MOCK_FIELD_ID,
name: 'updatedAt',
label: 'Updated At',
type: FieldMetadataType.DATE_TIME,
} as FieldMetadataItem,
],
} as ObjectMetadataItem;
const mockFormattedData = {
updatedAt: {
[DateAggregateOperations.LATEST]: '2023-12-31T23:59:59Z',
},
} as AggregateRecordsData;
const result = computeAggregateValueAndLabel({
data: mockFormattedData,
objectMetadataItem: mockObjectMetadataWithDatetimeField,
fieldMetadataId: MOCK_FIELD_ID,
aggregateOperation: DateAggregateOperations.LATEST,
localeCatalog: enUS,
...defaultParams,
});
expect(result).toEqual({
value: '31 Dec, 2023 23:59',
label: 'Latest',
labelWithFieldName: 'Latest of Updated At',
});
});
it('should default to count when field not found', () => {
const mockData = {
id: {
[AggregateOperations.COUNT]: 42,
},
} as AggregateRecordsData;
const result = computeAggregateValueAndLabel({
data: mockData,
objectMetadataItem: mockObjectMetadata,
localeCatalog: enUS,
...defaultParams,
});
expect(result).toEqual({
value: 42,
label: 'Count all',
labelWithFieldName: 'Count all',
});
});
it('should handle undefined aggregate value', () => {
const mockData = {
amount: {
[AggregateOperations.SUM]: undefined,
},
} as AggregateRecordsData;
const result = computeAggregateValueAndLabel({
data: mockData,
objectMetadataItem: mockObjectMetadata,
fieldMetadataId: MOCK_FIELD_ID,
aggregateOperation: AggregateOperations.SUM,
localeCatalog: enUS,
...defaultParams,
});
expect(result).toEqual({
value: '-',
label: 'Sum',
labelWithFieldName: 'Sum of amount',
});
});
});
@@ -1,32 +1,28 @@
import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { type RecordGqlFieldsAggregate } from '@/object-record/graphql/types/RecordGqlFieldsAggregate';
import { type KanbanAggregateOperation } from '@/object-record/record-index/states/recordIndexKanbanAggregateOperationState';
import { AggregateOperations } from '@/object-record/record-table/constants/AggregateOperations';
import { type ExtendedAggregateOperations } from '@/object-record/record-table/types/ExtendedAggregateOperations';
import { FIELD_FOR_TOTAL_COUNT_AGGREGATE_OPERATION } from 'twenty-shared/constants';
import { type Nullable } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
export const buildRecordGqlFieldsAggregateForView = ({
objectMetadataItem,
recordIndexKanbanAggregateOperation,
recordIndexGroupAggregateFieldMetadataItem,
recordIndexGroupAggregateOperation,
}: {
objectMetadataItem: ObjectMetadataItem;
recordIndexKanbanAggregateOperation: KanbanAggregateOperation;
recordIndexGroupAggregateFieldMetadataItem: Nullable<FieldMetadataItem>;
recordIndexGroupAggregateOperation: ExtendedAggregateOperations;
}): RecordGqlFieldsAggregate => {
let recordGqlFieldsAggregate = {};
const kanbanAggregateOperationFieldName = objectMetadataItem.fields?.find(
(field) =>
field.id === recordIndexKanbanAggregateOperation?.fieldMetadataId,
)?.name;
if (!kanbanAggregateOperationFieldName) {
if (
isDefined(recordIndexKanbanAggregateOperation?.operation) &&
recordIndexKanbanAggregateOperation.operation !==
AggregateOperations.COUNT
) {
if (!isDefined(recordIndexGroupAggregateFieldMetadataItem)) {
if (recordIndexGroupAggregateOperation !== AggregateOperations.COUNT) {
throw new Error(
`No field found to compute aggregate operation ${recordIndexKanbanAggregateOperation.operation} on object ${objectMetadataItem.nameSingular}`,
`No field found to compute aggregate operation ${recordIndexGroupAggregateOperation} on object ${objectMetadataItem.nameSingular}`,
);
} else {
recordGqlFieldsAggregate = {
@@ -37,9 +33,8 @@ export const buildRecordGqlFieldsAggregateForView = ({
}
} else {
recordGqlFieldsAggregate = {
[kanbanAggregateOperationFieldName]: [
recordIndexKanbanAggregateOperation?.operation ??
AggregateOperations.COUNT,
[recordIndexGroupAggregateFieldMetadataItem.name]: [
recordIndexGroupAggregateOperation ?? AggregateOperations.COUNT,
],
};
}
@@ -1,154 +0,0 @@
import { type DateFormat } from '@/localization/constants/DateFormat';
import { type TimeFormat } from '@/localization/constants/TimeFormat';
import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { type AggregateRecordsData } from '@/object-record/hooks/useAggregateRecords';
import { getAggregateOperationLabel } from '@/object-record/record-board/record-board-column/utils/getAggregateOperationLabel';
import { getAggregateOperationShortLabel } from '@/object-record/record-board/record-board-column/utils/getAggregateOperationShortLabel';
import { AggregateOperations } from '@/object-record/record-table/constants/AggregateOperations';
import { COUNT_AGGREGATE_OPERATION_OPTIONS } from '@/object-record/record-table/record-table-footer/constants/countAggregateOperationOptions';
import { PERCENT_AGGREGATE_OPERATION_OPTIONS } from '@/object-record/record-table/record-table-footer/constants/percentAggregateOperationOptions';
import { type ExtendedAggregateOperations } from '@/object-record/record-table/types/ExtendedAggregateOperations';
import { t } from '@lingui/core/macro';
import isEmpty from 'lodash.isempty';
import { FIELD_FOR_TOTAL_COUNT_AGGREGATE_OPERATION } from 'twenty-shared/constants';
import { isDefined } from 'twenty-shared/utils';
import { FieldMetadataType } from '~/generated-metadata/graphql';
import {
formatNumber as utilFormatNumber,
type FormatNumberOptions,
} from '~/utils/format/formatNumber';
import { formatToShortNumber } from '~/utils/format/formatToShortNumber';
import { formatDateString } from '~/utils/string/formatDateString';
import { formatDateTimeString } from '~/utils/string/formatDateTimeString';
export const computeAggregateValueAndLabel = ({
data,
objectMetadataItem,
fieldMetadataId,
aggregateOperation,
dateFormat,
timeFormat,
timeZone,
localeCatalog,
formatNumberFn,
formatShortNumberFn,
}: {
data: AggregateRecordsData;
objectMetadataItem: ObjectMetadataItem;
fieldMetadataId?: string | null;
aggregateOperation?: ExtendedAggregateOperations | null;
dateFormat: DateFormat;
timeFormat: TimeFormat;
timeZone: string;
localeCatalog: Locale;
formatNumberFn?: (
value: number,
options?: Omit<FormatNumberOptions, 'format'>,
) => string;
formatShortNumberFn?: (value: number) => string | number;
}) => {
const formatNumber =
formatNumberFn ??
((v: number, opts?: Omit<FormatNumberOptions, 'format'>) =>
utilFormatNumber(v, opts));
if (isEmpty(data)) {
return {};
}
const field = objectMetadataItem.fields?.find(
(field) => field.id === fieldMetadataId,
);
if (!isDefined(field)) {
return {
value:
data?.[FIELD_FOR_TOTAL_COUNT_AGGREGATE_OPERATION]?.[
AggregateOperations.COUNT
],
label: getAggregateOperationLabel(AggregateOperations.COUNT),
labelWithFieldName: getAggregateOperationLabel(AggregateOperations.COUNT),
};
}
if (!isDefined(aggregateOperation)) {
throw new Error('Missing aggregate operation');
}
const aggregateValue = data[field.name]?.[aggregateOperation];
let value;
const dateFieldSettings = field?.settings;
if (
COUNT_AGGREGATE_OPERATION_OPTIONS.includes(
aggregateOperation as AggregateOperations,
)
) {
value = aggregateValue;
} else if (!isDefined(aggregateValue)) {
value = '-';
} else if (
PERCENT_AGGREGATE_OPERATION_OPTIONS.includes(
aggregateOperation as AggregateOperations,
)
) {
value = `${formatNumber(Number(aggregateValue) * 100)}%`;
} else {
switch (field.type) {
case FieldMetadataType.CURRENCY: {
value = Number(aggregateValue);
value = isDefined(formatShortNumberFn)
? formatShortNumberFn(value / 1_000_000)
: formatToShortNumber(value / 1_000_000);
break;
}
case FieldMetadataType.NUMBER: {
value = Number(aggregateValue);
const { decimals, type } = field.settings ?? {};
value =
type === 'percentage'
? `${formatNumber(value * 100, { decimals })}%`
: formatNumber(value, { decimals });
break;
}
case FieldMetadataType.DATE_TIME: {
value = aggregateValue as string;
value = formatDateTimeString({
value,
timeZone,
dateFormat,
timeFormat,
dateFieldSettings,
localeCatalog,
});
break;
}
case FieldMetadataType.DATE: {
value = aggregateValue as string;
value = formatDateString({
value,
timeZone,
dateFormat,
dateFieldSettings,
localeCatalog,
});
break;
}
}
}
const aggregateLabel = t(getAggregateOperationShortLabel(aggregateOperation));
const fieldLabel = field.label;
const labelWithFieldName =
aggregateOperation === AggregateOperations.COUNT
? `${getAggregateOperationLabel(AggregateOperations.COUNT)}`
: t`${aggregateLabel} of ${fieldLabel}`;
return {
value,
label: aggregateLabel,
labelWithFieldName,
};
};
@@ -1,5 +1,3 @@
import { useRecoilValue } from 'recoil';
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
import { useCreateOneRecord } from '@/object-record/hooks/useCreateOneRecord';
import { useDeleteOneRecord } from '@/object-record/hooks/useDeleteOneRecord';
@@ -11,9 +9,10 @@ import { RecordBoardHotkeyEffect } from '@/object-record/record-board/components
import { RecordBoardContext } from '@/object-record/record-board/contexts/RecordBoardContext';
import { RecordIndexRemoveSortingModal } from '@/object-record/record-index/components/RecordIndexRemoveSortingModal';
import { RECORD_INDEX_REMOVE_SORTING_MODAL_ID } from '@/object-record/record-index/constants/RecordIndexRemoveSortingModalId';
import { recordIndexKanbanFieldMetadataIdState } from '@/object-record/record-index/states/recordIndexKanbanFieldMetadataIdState';
import { recordIndexGroupFieldMetadataItemComponentState } from '@/object-record/record-index/states/recordIndexGroupFieldMetadataItemComponentState';
import { isModalOpenedComponentState } from '@/ui/layout/modal/states/isModalOpenedComponentState';
import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue';
import { isDefined } from 'twenty-shared/utils';
type RecordIndexBoardContainerProps = {
recordBoardId: string;
@@ -33,12 +32,8 @@ export const RecordIndexBoardContainer = ({
objectMetadataItem.id,
);
const recordIndexKanbanFieldMetadataId = useRecoilValue(
recordIndexKanbanFieldMetadataIdState,
);
const selectFieldMetadataItem = objectMetadataItem.fields.find(
(field) => field.id === recordIndexKanbanFieldMetadataId,
const recordIndexGroupFieldMetadataItem = useRecoilComponentValue(
recordIndexGroupFieldMetadataItemComponentState,
);
const { deleteOneRecord } = useDeleteOneRecord({ objectNameSingular });
@@ -53,15 +48,15 @@ export const RecordIndexBoardContainer = ({
RECORD_INDEX_REMOVE_SORTING_MODAL_ID,
);
if (!selectFieldMetadataItem) {
return;
if (!isDefined(recordIndexGroupFieldMetadataItem)) {
return null;
}
return (
<RecordBoardContext.Provider
value={{
objectMetadataItem,
selectFieldMetadataItem,
selectFieldMetadataItem: recordIndexGroupFieldMetadataItem,
createOneRecord,
updateOneRecord,
deleteOneRecord,
@@ -1,9 +1,6 @@
import { useRecoilValue } from 'recoil';
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
import { recordGroupIdsComponentState } from '@/object-record/record-group/states/recordGroupIdsComponentState';
import { RecordIndexBoardColumnLoaderEffect } from '@/object-record/record-index/components/RecordIndexBoardColumnLoaderEffect';
import { recordIndexKanbanFieldMetadataIdState } from '@/object-record/record-index/states/recordIndexKanbanFieldMetadataIdState';
import { recordIndexGroupFieldMetadataItemComponentState } from '@/object-record/record-index/states/recordIndexGroupFieldMetadataItemComponentState';
import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue';
import { isDefined } from 'twenty-shared/utils';
@@ -16,21 +13,13 @@ export const RecordIndexBoardDataLoader = ({
objectNameSingular,
recordBoardId,
}: RecordIndexBoardDataLoaderProps) => {
const { objectMetadataItem } = useObjectMetadataItem({
objectNameSingular,
});
const recordGroupIds = useRecoilComponentValue(recordGroupIdsComponentState);
const recordIndexKanbanFieldMetadataId = useRecoilValue(
recordIndexKanbanFieldMetadataIdState,
const recordIndexGroupFieldMetadataItem = useRecoilComponentValue(
recordIndexGroupFieldMetadataItemComponentState,
);
const recordIndexKanbanFieldMetadataItem = objectMetadataItem.fields.find(
(field) => field.id === recordIndexKanbanFieldMetadataId,
);
if (!isDefined(recordIndexKanbanFieldMetadataItem)) {
if (!isDefined(recordIndexGroupFieldMetadataItem)) {
return null;
}
@@ -39,16 +28,16 @@ export const RecordIndexBoardDataLoader = ({
{recordGroupIds.map((recordGroupId) => (
<RecordIndexBoardColumnLoaderEffect
objectNameSingular={objectNameSingular}
kanbanFieldMetadataItem={recordIndexKanbanFieldMetadataItem}
kanbanFieldMetadataItem={recordIndexGroupFieldMetadataItem}
recordBoardId={recordBoardId}
columnId={recordGroupId}
key={recordGroupId}
/>
))}
{recordIndexKanbanFieldMetadataItem.isNullable === true && (
{recordIndexGroupFieldMetadataItem.isNullable === true && (
<RecordIndexBoardColumnLoaderEffect
objectNameSingular={objectNameSingular}
kanbanFieldMetadataItem={recordIndexKanbanFieldMetadataItem}
kanbanFieldMetadataItem={recordIndexGroupFieldMetadataItem}
recordBoardId={recordBoardId}
columnId="no-value"
/>
@@ -0,0 +1,98 @@
import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { useRecordIndexContextOrThrow } from '@/object-record/record-index/contexts/RecordIndexContext';
import { useAggregateGqlFieldsFromRecordIndexGroupAggregates } from '@/object-record/record-index/hooks/useAggregateGqlFieldsFromRecordIndexGroupAggregates';
import { useRecordIndexGroupsAggregatesGroupBy } from '@/object-record/record-index/hooks/useRecordIndexGroupsAggregatesGroupBy';
import { useSetRecordIndexAggregateDisplayLabel } from '@/object-record/record-index/hooks/useSetRecordIndexAggregateDisplayLabel';
import { useSetRecordIndexAggregateDisplayValueForRecordGroupValue } from '@/object-record/record-index/hooks/useSetRecordIndexAggregateDisplayValueForRecordGroupValue';
import { recordIndexAggregateDisplayLabelComponentState } from '@/object-record/record-index/states/recordIndexAggregateDisplayLabelComponentState';
import { turnRecordIndexGroupByAggregateQueryResultIntoRecordAggregateValueByGroupValue } from '@/object-record/record-index/utils/turnRecordIndexGroupByAggregateQueryResultIntoRecordAggregateValueByGroupValue';
import { type ExtendedAggregateOperations } from '@/object-record/record-table/types/ExtendedAggregateOperations';
import { useRecoilComponentCallbackState } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentCallbackState';
import { useEffect } from 'react';
import { type Nullable } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
export const RecordIndexGroupAggregateQueryEffect = ({
recordIndexGroupFieldMetadataItem,
recordIndexGroupAggregateOperation,
recordIndexGroupAggregateFieldMetadataItem,
}: {
recordIndexGroupFieldMetadataItem: FieldMetadataItem;
recordIndexGroupAggregateFieldMetadataItem: Nullable<FieldMetadataItem>;
recordIndexGroupAggregateOperation: ExtendedAggregateOperations;
}) => {
const { objectMetadataItem } = useRecordIndexContextOrThrow();
const { data, loading, error } = useRecordIndexGroupsAggregatesGroupBy({
objectMetadataItem,
groupByFieldMetadataItem: recordIndexGroupFieldMetadataItem,
recordIndexGroupAggregateFieldMetadataItem,
recordIndexGroupAggregateOperation,
});
const { recordAggregateGqlField } =
useAggregateGqlFieldsFromRecordIndexGroupAggregates({
objectMetadataItem,
recordIndexGroupAggregateFieldMetadataItem,
recordIndexGroupAggregateOperation,
});
const recordIndexAggregateDisplayLabelCallbackState =
useRecoilComponentCallbackState(
recordIndexAggregateDisplayLabelComponentState,
);
const { setRecordIndexAggregateDisplayLabel } =
useSetRecordIndexAggregateDisplayLabel();
const { setRecordIndexAggregateDisplayValueForRecordGroupValue } =
useSetRecordIndexAggregateDisplayValueForRecordGroupValue();
useEffect(() => {
if (
!loading &&
!isDefined(error) &&
isDefined(data) &&
isDefined(recordAggregateGqlField)
) {
const { recordAggregateValueByGroupValueArray } =
turnRecordIndexGroupByAggregateQueryResultIntoRecordAggregateValueByGroupValue(
{
objectMetadataItem,
queryResult: data,
recordAggregateGqlField,
},
);
if (isDefined(recordIndexGroupAggregateFieldMetadataItem)) {
setRecordIndexAggregateDisplayLabel(
recordIndexGroupAggregateOperation,
recordIndexGroupAggregateFieldMetadataItem,
);
for (const recordAggregateValueByGroupValue of recordAggregateValueByGroupValueArray) {
setRecordIndexAggregateDisplayValueForRecordGroupValue(
recordIndexGroupAggregateOperation,
recordIndexGroupAggregateFieldMetadataItem,
recordAggregateValueByGroupValue.recordGroupValue,
recordAggregateValueByGroupValue.recordAggregateValue,
);
}
}
}
}, [
data,
loading,
error,
setRecordIndexAggregateDisplayValueForRecordGroupValue,
setRecordIndexAggregateDisplayLabel,
recordIndexGroupAggregateFieldMetadataItem,
recordIndexGroupAggregateOperation,
recordAggregateGqlField,
recordIndexAggregateDisplayLabelCallbackState,
objectMetadataItem,
]);
return null;
};
@@ -0,0 +1,38 @@
import { RecordIndexGroupAggregateQueryEffect } from '@/object-record/record-index/components/RecordIndexGroupAggregateQueryEffect';
import { recordIndexGroupAggregateFieldMetadataItemComponentState } from '@/object-record/record-index/states/recordIndexGroupAggregateFieldMetadataItemComponentState';
import { recordIndexGroupAggregateOperationComponentState } from '@/object-record/record-index/states/recordIndexGroupAggregateOperationComponentState';
import { recordIndexGroupFieldMetadataItemComponentState } from '@/object-record/record-index/states/recordIndexGroupFieldMetadataItemComponentState';
import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue';
import { isDefined } from 'twenty-shared/utils';
export const RecordIndexGroupAggregatesDataLoader = () => {
const recordIndexGroupFieldMetadataItem = useRecoilComponentValue(
recordIndexGroupFieldMetadataItemComponentState,
);
const recordIndexGroupAggregateFieldMetadataItem = useRecoilComponentValue(
recordIndexGroupAggregateFieldMetadataItemComponentState,
);
const recordIndexGroupAggregateOperation = useRecoilComponentValue(
recordIndexGroupAggregateOperationComponentState,
);
if (
!isDefined(recordIndexGroupFieldMetadataItem) ||
!isDefined(recordIndexGroupAggregateOperation)
) {
return null;
}
return (
<RecordIndexGroupAggregateQueryEffect
recordIndexGroupFieldMetadataItem={recordIndexGroupFieldMetadataItem}
recordIndexGroupAggregateFieldMetadataItem={
recordIndexGroupAggregateFieldMetadataItem
}
recordIndexGroupAggregateOperation={recordIndexGroupAggregateOperation}
/>
);
};
@@ -0,0 +1,49 @@
import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { type ExtendedAggregateOperations } from '@/object-record/record-table/types/ExtendedAggregateOperations';
import { getAvailableAggregationsFromObjectFields } from '@/object-record/utils/getAvailableAggregationsFromObjectFields';
import { useMemo } from 'react';
import { type Nullable } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { AggregateOperations } from '~/generated/graphql';
export const useAggregateGqlFieldsFromRecordIndexGroupAggregates = ({
objectMetadataItem,
recordIndexGroupAggregateFieldMetadataItem,
recordIndexGroupAggregateOperation,
}: {
objectMetadataItem: ObjectMetadataItem;
recordIndexGroupAggregateFieldMetadataItem: Nullable<FieldMetadataItem>;
recordIndexGroupAggregateOperation: ExtendedAggregateOperations;
}) => {
const availableAggregations = useMemo(
() =>
getAvailableAggregationsFromObjectFields(
objectMetadataItem.readableFields,
),
[objectMetadataItem.readableFields],
);
if (recordIndexGroupAggregateOperation === AggregateOperations.COUNT) {
return {
recordAggregateGqlField: 'totalCount',
};
}
if (!isDefined(recordIndexGroupAggregateFieldMetadataItem)) {
throw new Error(
`Cannot query an aggregate without a field metadata item for ${objectMetadataItem.nameSingular}, aggregate operation : ${recordIndexGroupAggregateOperation}`,
);
}
const recordAggregateGqlField =
availableAggregations[recordIndexGroupAggregateFieldMetadataItem.name]?.[
recordIndexGroupAggregateOperation
];
if (!isDefined(recordAggregateGqlField)) {
return { recordAggregateGqlField: null };
}
return { recordAggregateGqlField };
};
@@ -1,14 +1,17 @@
import { contextStoreTargetedRecordsRuleComponentState } from '@/context-store/states/contextStoreTargetedRecordsRuleComponentState';
import { useGetFieldMetadataItemByIdOrThrow } from '@/object-metadata/hooks/useGetFieldMetadataItemById';
import { availableFieldMetadataItemsForFilterFamilySelector } from '@/object-metadata/states/availableFieldMetadataItemsForFilterFamilySelector';
import { availableFieldMetadataItemsForSortFamilySelector } from '@/object-metadata/states/availableFieldMetadataItemsForSortFamilySelector';
import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { formatFieldMetadataItemAsColumnDefinition } from '@/object-metadata/utils/formatFieldMetadataItemAsColumnDefinition';
import { type FieldMetadata } from '@/object-record/record-field/ui/types/FieldMetadata';
import { useSetRecordGroups } from '@/object-record/record-group/hooks/useSetRecordGroups';
import { recordIndexCalendarFieldMetadataIdState } from '@/object-record/record-index/states/recordIndexCalendarFieldMetadataIdState';
import { recordIndexFieldDefinitionsState } from '@/object-record/record-index/states/recordIndexFieldDefinitionsState';
import { recordIndexKanbanAggregateOperationState } from '@/object-record/record-index/states/recordIndexKanbanAggregateOperationState';
import { recordIndexKanbanFieldMetadataIdState } from '@/object-record/record-index/states/recordIndexKanbanFieldMetadataIdState';
import { recordIndexGroupAggregateFieldMetadataItemComponentState } from '@/object-record/record-index/states/recordIndexGroupAggregateFieldMetadataItemComponentState';
import { recordIndexGroupAggregateOperationComponentState } from '@/object-record/record-index/states/recordIndexGroupAggregateOperationComponentState';
import { recordIndexGroupFieldMetadataItemComponentState } from '@/object-record/record-index/states/recordIndexGroupFieldMetadataItemComponentState';
import { recordIndexOpenRecordInState } from '@/object-record/record-index/states/recordIndexOpenRecordInState';
import { recordIndexViewTypeState } from '@/object-record/record-index/states/recordIndexViewTypeState';
import { viewFieldAggregateOperationState } from '@/object-record/record-table/record-table-footer/states/viewFieldAggregateOperationState';
@@ -32,16 +35,27 @@ export const useLoadRecordIndexStates = () => {
const setRecordIndexOpenRecordIn = useSetRecoilState(
recordIndexOpenRecordInState,
);
const setRecordIndexViewKanbanFieldMetadataIdState = useSetRecoilState(
recordIndexKanbanFieldMetadataIdState,
const setRecordIndexGroupFieldMetadataItem = useSetRecoilComponentState(
recordIndexGroupFieldMetadataItemComponentState,
);
const setRecordIndexGroupAggregateOperation = useSetRecoilComponentState(
recordIndexGroupAggregateOperationComponentState,
);
const setRecordIndexGroupAggregateFieldMetadataItem =
useSetRecoilComponentState(
recordIndexGroupAggregateFieldMetadataItemComponentState,
);
const setRecordIndexCalendarFieldMetadataIdState = useSetRecoilState(
recordIndexCalendarFieldMetadataIdState,
);
const setRecordIndexViewKanbanAggregateOperationState = useSetRecoilState(
recordIndexKanbanAggregateOperationState,
);
const { getFieldMetadataItemByIdOrThrow } =
useGetFieldMetadataItemByIdOrThrow();
const { setRecordGroupsFromViewGroups } = useSetRecordGroups();
const onViewFieldsChange = useRecoilCallback(
@@ -180,26 +194,46 @@ export const useLoadRecordIndexStates = () => {
setRecordIndexViewType(view.type);
setRecordIndexOpenRecordIn(view.openRecordIn);
setRecordIndexViewKanbanFieldMetadataIdState(
view.viewGroups?.[0]?.fieldMetadataId,
);
setRecordIndexCalendarFieldMetadataIdState(
view.calendarFieldMetadataId ?? null,
);
const kanbanAggregateOperationFieldMetadataType =
if (isDefined(view.viewGroups?.[0]?.fieldMetadataId)) {
const recordIndexGroupFieldMetadataItemId =
view.viewGroups?.[0]?.fieldMetadataId;
const { fieldMetadataItem: recordIndexGroupFieldMetadataItem } =
getFieldMetadataItemByIdOrThrow(
recordIndexGroupFieldMetadataItemId,
);
setRecordIndexGroupFieldMetadataItem(
recordIndexGroupFieldMetadataItem,
);
}
const recordIndexGroupAggregateFieldMetadataItem =
objectMetadataItem.fields?.find(
(field) =>
field.id === view.kanbanAggregateOperationFieldMetadataId,
)?.type;
setRecordIndexViewKanbanAggregateOperationState({
operation: isDefined(view.kanbanAggregateOperation)
? convertAggregateOperationToExtendedAggregateOperation(
view.kanbanAggregateOperation,
kanbanAggregateOperationFieldMetadataType,
)
: view.kanbanAggregateOperation,
fieldMetadataId: view.kanbanAggregateOperationFieldMetadataId,
});
);
if (isDefined(view.kanbanAggregateOperation)) {
const convertedAggregateOperation =
convertAggregateOperationToExtendedAggregateOperation(
view.kanbanAggregateOperation,
recordIndexGroupAggregateFieldMetadataItem?.type,
);
setRecordIndexGroupAggregateOperation(convertedAggregateOperation);
}
if (isDefined(recordIndexGroupAggregateFieldMetadataItem)) {
setRecordIndexGroupAggregateFieldMetadataItem(
recordIndexGroupAggregateFieldMetadataItem,
);
}
},
[
onViewFieldsChange,
@@ -207,9 +241,11 @@ export const useLoadRecordIndexStates = () => {
setContextStoreTargetedRecordsRuleComponentState,
setRecordIndexViewType,
setRecordIndexOpenRecordIn,
setRecordIndexViewKanbanFieldMetadataIdState,
setRecordIndexCalendarFieldMetadataIdState,
setRecordIndexViewKanbanAggregateOperationState,
setRecordIndexGroupAggregateFieldMetadataItem,
setRecordIndexGroupAggregateOperation,
getFieldMetadataItemByIdOrThrow,
setRecordIndexGroupFieldMetadataItem,
],
);
@@ -0,0 +1,110 @@
import { useApolloCoreClient } from '@/object-metadata/hooks/useApolloCoreClient';
import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { EMPTY_QUERY } from '@/object-record/constants/EmptyQuery';
import { useObjectPermissionsForObject } from '@/object-record/hooks/useObjectPermissionsForObject';
import { generateGroupByAggregateQuery } from '@/object-record/record-aggregate/utils/generateGroupByAggregateQuery';
import { currentRecordFilterGroupsComponentState } from '@/object-record/record-filter-group/states/currentRecordFilterGroupsComponentState';
import { useFilterValueDependencies } from '@/object-record/record-filter/hooks/useFilterValueDependencies';
import { anyFieldFilterValueComponentState } from '@/object-record/record-filter/states/anyFieldFilterValueComponentState';
import { currentRecordFiltersComponentState } from '@/object-record/record-filter/states/currentRecordFiltersComponentState';
import { useAggregateGqlFieldsFromRecordIndexGroupAggregates } from '@/object-record/record-index/hooks/useAggregateGqlFieldsFromRecordIndexGroupAggregates';
import { type ExtendedAggregateOperations } from '@/object-record/record-table/types/ExtendedAggregateOperations';
import { buildGroupByFieldObject } from '@/page-layout/widgets/graph/utils/buildGroupByFieldObject';
import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue';
import { useQuery } from '@apollo/client';
import { type Nullable } from 'twenty-shared/types';
import {
computeRecordGqlOperationFilter,
isDefined,
turnAnyFieldFilterIntoRecordGqlFilter,
} from 'twenty-shared/utils';
export const useRecordIndexGroupsAggregatesGroupBy = ({
objectMetadataItem,
skip,
groupByFieldMetadataItem,
recordIndexGroupAggregateFieldMetadataItem,
recordIndexGroupAggregateOperation,
}: {
skip?: boolean;
objectMetadataItem: ObjectMetadataItem;
groupByFieldMetadataItem: FieldMetadataItem;
recordIndexGroupAggregateFieldMetadataItem: Nullable<FieldMetadataItem>;
recordIndexGroupAggregateOperation: ExtendedAggregateOperations;
}) => {
const apolloCoreClient = useApolloCoreClient();
const currentRecordFilterGroups = useRecoilComponentValue(
currentRecordFilterGroupsComponentState,
);
const currentRecordFilters = useRecoilComponentValue(
currentRecordFiltersComponentState,
);
const { filterValueDependencies } = useFilterValueDependencies();
const requestFilters = computeRecordGqlOperationFilter({
filterValueDependencies,
recordFilters: currentRecordFilters,
recordFilterGroups: currentRecordFilterGroups,
fields: objectMetadataItem.fields,
});
const { recordAggregateGqlField } =
useAggregateGqlFieldsFromRecordIndexGroupAggregates({
objectMetadataItem,
recordIndexGroupAggregateFieldMetadataItem:
recordIndexGroupAggregateFieldMetadataItem,
recordIndexGroupAggregateOperation,
});
const groupByAggregateQuery = isDefined(recordAggregateGqlField)
? generateGroupByAggregateQuery({
aggregateOperationGqlFields: [recordAggregateGqlField],
objectMetadataItem,
})
: EMPTY_QUERY;
const anyFieldFilterValue = useRecoilComponentValue(
anyFieldFilterValueComponentState,
);
const { recordGqlOperationFilter: anyFieldFilter } =
turnAnyFieldFilterIntoRecordGqlFilter({
fields: objectMetadataItem.fields,
filterValue: anyFieldFilterValue,
});
const objectPermissions = useObjectPermissionsForObject(
objectMetadataItem.id,
);
const hasReadPermission = objectPermissions.canReadObjectRecords;
const groupByGqlInput = buildGroupByFieldObject({
field: groupByFieldMetadataItem,
});
const { data, loading, error } = useQuery(groupByAggregateQuery, {
skip:
!isDefined(objectMetadataItem) ||
!hasReadPermission ||
skip ||
!isDefined(recordAggregateGqlField),
variables: {
filter: { ...requestFilters, ...anyFieldFilter },
groupBy: {
...groupByGqlInput,
},
},
client: apolloCoreClient,
});
return {
data,
loading,
error,
};
};
@@ -0,0 +1,34 @@
import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { recordIndexAggregateDisplayLabelComponentState } from '@/object-record/record-index/states/recordIndexAggregateDisplayLabelComponentState';
import { getRecordAggregateDisplayLabel } from '@/object-record/record-index/utils/getRecordndexAggregateDisplayLabel';
import { type ExtendedAggregateOperations } from '@/object-record/record-table/types/ExtendedAggregateOperations';
import { useRecoilComponentCallbackState } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentCallbackState';
import { useRecoilCallback } from 'recoil';
export const useSetRecordIndexAggregateDisplayLabel = () => {
const recordIndexAggregateDisplayLabelCallbackState =
useRecoilComponentCallbackState(
recordIndexAggregateDisplayLabelComponentState,
);
const setRecordIndexAggregateDisplayLabel = useRecoilCallback(
({ set }) =>
(
recordIndexGroupAggregateOperation: ExtendedAggregateOperations,
recordIndexGroupAggregateFieldMetadataItem: FieldMetadataItem,
) => {
const { aggregateLabel } = getRecordAggregateDisplayLabel({
aggregateOperation: recordIndexGroupAggregateOperation,
aggregateFieldMetadataItem:
recordIndexGroupAggregateFieldMetadataItem,
});
set(recordIndexAggregateDisplayLabelCallbackState, aggregateLabel);
},
[recordIndexAggregateDisplayLabelCallbackState],
);
return {
setRecordIndexAggregateDisplayLabel,
};
};
@@ -0,0 +1,61 @@
import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { transformAggregateRawValueIntoAggregateDisplayValue } from '@/object-record/record-aggregate/utils/transformAggregateRawValueIntoAggregateDisplayValue';
import { recordIndexAggregateDisplayValueForGroupValueComponentFamilyState } from '@/object-record/record-index/states/recordIndexAggregateDisplayValueForGroupValueComponentFamilyState';
import { type ExtendedAggregateOperations } from '@/object-record/record-table/types/ExtendedAggregateOperations';
import { useRecoilComponentFamilyCallbackState } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentFamilyCallbackState';
import { UserContext } from '@/users/contexts/UserContext';
import { useContext } from 'react';
import { useRecoilCallback, useRecoilValue } from 'recoil';
import { type Nullable } from 'twenty-shared/types';
import { dateLocaleState } from '~/localization/states/dateLocaleState';
export const useSetRecordIndexAggregateDisplayValueForRecordGroupValue = () => {
const { dateFormat, timeFormat, timeZone } = useContext(UserContext);
const dateLocale = useRecoilValue(dateLocaleState);
const recordIndexAggregateValueByGroupValueCallbackState =
useRecoilComponentFamilyCallbackState(
recordIndexAggregateDisplayValueForGroupValueComponentFamilyState,
);
const setRecordIndexAggregateDisplayValueForRecordGroupValue =
useRecoilCallback(
({ set }) =>
(
recordIndexGroupAggregateOperation: ExtendedAggregateOperations,
recordIndexGroupAggregateFieldMetadataItem: FieldMetadataItem,
recordIndexGroupValue: string,
rawValue: Nullable<string | number>,
) => {
const aggregateDisplayValue =
transformAggregateRawValueIntoAggregateDisplayValue({
aggregateFieldMetadataItem:
recordIndexGroupAggregateFieldMetadataItem,
aggregateOperation: recordIndexGroupAggregateOperation,
aggregateRawValue: rawValue,
dateFormat,
timeFormat,
timeZone,
localeCatalog: dateLocale.localeCatalog,
});
set(
recordIndexAggregateValueByGroupValueCallbackState({
groupValue: recordIndexGroupValue,
}),
aggregateDisplayValue,
);
},
[
recordIndexAggregateValueByGroupValueCallbackState,
dateFormat,
dateLocale,
timeFormat,
timeZone,
],
);
return {
setRecordIndexAggregateDisplayValueForRecordGroupValue,
};
};
@@ -0,0 +1,9 @@
import { ContextStoreComponentInstanceContext } from '@/context-store/states/contexts/ContextStoreComponentInstanceContext';
import { createComponentState } from '@/ui/utilities/state/component-state/utils/createComponentState';
export const recordIndexAggregateDisplayLabelComponentState =
createComponentState<string | null>({
key: 'recordIndexAggregateDisplayLabelForGroupValueComponentFamilyState',
defaultValue: null,
componentInstanceContext: ContextStoreComponentInstanceContext,
});
@@ -0,0 +1,9 @@
import { ContextStoreComponentInstanceContext } from '@/context-store/states/contexts/ContextStoreComponentInstanceContext';
import { createComponentFamilyState } from '@/ui/utilities/state/component-state/utils/createComponentFamilyState';
export const recordIndexAggregateDisplayValueForGroupValueComponentFamilyState =
createComponentFamilyState<string | null, { groupValue: string }>({
key: 'recordIndexAggregateDisplayValueForGroupValueComponentFamilyState',
defaultValue: null,
componentInstanceContext: ContextStoreComponentInstanceContext,
});
@@ -0,0 +1,10 @@
import { ContextStoreComponentInstanceContext } from '@/context-store/states/contexts/ContextStoreComponentInstanceContext';
import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { createComponentState } from '@/ui/utilities/state/component-state/utils/createComponentState';
export const recordIndexGroupAggregateFieldMetadataItemComponentState =
createComponentState<FieldMetadataItem | null>({
key: 'recordIndexGroupAggregateFieldMetadataItemComponentState',
defaultValue: null,
componentInstanceContext: ContextStoreComponentInstanceContext,
});
@@ -0,0 +1,10 @@
import { ContextStoreComponentInstanceContext } from '@/context-store/states/contexts/ContextStoreComponentInstanceContext';
import { type ExtendedAggregateOperations } from '@/object-record/record-table/types/ExtendedAggregateOperations';
import { createComponentState } from '@/ui/utilities/state/component-state/utils/createComponentState';
export const recordIndexGroupAggregateOperationComponentState =
createComponentState<ExtendedAggregateOperations | null>({
key: 'recordIndexGroupAggregateOperationComponentState',
defaultValue: null,
componentInstanceContext: ContextStoreComponentInstanceContext,
});
@@ -0,0 +1,10 @@
import { ContextStoreComponentInstanceContext } from '@/context-store/states/contexts/ContextStoreComponentInstanceContext';
import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { createComponentState } from '@/ui/utilities/state/component-state/utils/createComponentState';
export const recordIndexGroupFieldMetadataItemComponentState =
createComponentState<FieldMetadataItem | null>({
key: 'recordIndexGroupFieldMetadataItemComponentState',
defaultValue: null,
componentInstanceContext: ContextStoreComponentInstanceContext,
});
@@ -1,13 +0,0 @@
import { type ExtendedAggregateOperations } from '@/object-record/record-table/types/ExtendedAggregateOperations';
import { createState } from 'twenty-ui/utilities';
export type KanbanAggregateOperation = {
operation?: ExtendedAggregateOperations | null;
fieldMetadataId?: string | null;
} | null;
export const recordIndexKanbanAggregateOperationState =
createState<KanbanAggregateOperation>({
key: 'recordIndexKanbanAggregateOperationState',
defaultValue: null,
});
@@ -1,7 +0,0 @@
import { createState } from 'twenty-ui/utilities';
export const recordIndexKanbanFieldMetadataIdState = createState<string | null>(
{
key: 'recordIndexKanbanFieldMetadataIdState',
defaultValue: null,
},
);
@@ -0,0 +1,6 @@
import { type Nullable } from 'twenty-shared/types';
export type RecordAggregateValueByRecordGroupValue = {
recordGroupValue: string;
recordAggregateValue: Nullable<string | number>;
};
@@ -0,0 +1,7 @@
export type RecordIndexGroupByQueryResult = {
[groupByQueryResultGqlFieldName: string]: ({
groupByDimensionValues: string[];
} & {
[aggregateGqlField: string]: string | number;
})[];
};
@@ -0,0 +1,35 @@
import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { getAggregateLabelWithFieldName } from '@/object-record/record-aggregate/utils/getAggregateLabelWithFieldName';
import { getAggregateOperationLabel } from '@/object-record/record-board/record-board-column/utils/getAggregateOperationLabel';
import { type ExtendedAggregateOperations } from '@/object-record/record-table/types/ExtendedAggregateOperations';
import { isDefined } from 'twenty-shared/utils';
import { AggregateOperations } from '~/generated/graphql';
type GetRecordIndexAggregateDisplayLabelParams = {
aggregateOperation: ExtendedAggregateOperations;
aggregateFieldMetadataItem: FieldMetadataItem;
};
export const getRecordAggregateDisplayLabel = ({
aggregateFieldMetadataItem,
aggregateOperation,
}: GetRecordIndexAggregateDisplayLabelParams) => {
const isCountOperation = aggregateOperation === AggregateOperations.COUNT;
let aggregateLabel: string | null = null;
if (isCountOperation) {
aggregateLabel = `${getAggregateOperationLabel(AggregateOperations.COUNT)}`;
} else {
if (isDefined(aggregateFieldMetadataItem)) {
aggregateLabel = getAggregateLabelWithFieldName({
aggregateFieldMetadataItem: aggregateFieldMetadataItem,
aggregateOperation: aggregateOperation,
});
}
}
return {
aggregateLabel,
};
};
@@ -0,0 +1,45 @@
import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { type RecordAggregateValueByRecordGroupValue } from '@/object-record/record-index/types/RecordAggregateValueByRecordGroupValue';
import { type RecordIndexGroupByQueryResult } from '@/object-record/record-index/types/RecordIndexGroupByQueryResult';
import { getGroupByQueryResultGqlFieldName } from '@/page-layout/utils/getGroupByQueryResultGqlFieldName';
type TurnRecordIndexGroupByAggregateQueryResultIntoRecordAggregateValueByGroupValueParams =
{
queryResult: RecordIndexGroupByQueryResult;
recordAggregateGqlField: string;
objectMetadataItem: ObjectMetadataItem;
};
export const turnRecordIndexGroupByAggregateQueryResultIntoRecordAggregateValueByGroupValue =
({
objectMetadataItem,
queryResult,
recordAggregateGqlField,
}: TurnRecordIndexGroupByAggregateQueryResultIntoRecordAggregateValueByGroupValueParams) => {
const recordAggregateValueByGroupValueArray: RecordAggregateValueByRecordGroupValue[] =
[];
const queryResultGqlFieldName =
getGroupByQueryResultGqlFieldName(objectMetadataItem);
const groupByQueryResultItems = queryResult[queryResultGqlFieldName];
for (const groupByQueryResultItem of groupByQueryResultItems) {
if (groupByQueryResultItem.groupByDimensionValues.length === 1) {
const groupByValue = groupByQueryResultItem.groupByDimensionValues[0];
const gqlAggregateFieldName = recordAggregateGqlField;
const aggregateValue = groupByQueryResultItem[gqlAggregateFieldName];
recordAggregateValueByGroupValueArray.push({
recordGroupValue: groupByValue,
recordAggregateValue: aggregateValue,
});
}
}
return {
recordAggregateValueByGroupValueArray,
};
};
@@ -1,92 +0,0 @@
import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { useAggregateRecords } from '@/object-record/hooks/useAggregateRecords';
import { buildRecordGqlFieldsAggregateForView } from '@/object-record/record-board/record-board-column/utils/buildRecordGqlFieldsAggregateForView';
import { computeAggregateValueAndLabel } from '@/object-record/record-board/record-board-column/utils/computeAggregateValueAndLabel';
import { currentRecordFilterGroupsComponentState } from '@/object-record/record-filter-group/states/currentRecordFilterGroupsComponentState';
import { useFilterValueDependencies } from '@/object-record/record-filter/hooks/useFilterValueDependencies';
import { anyFieldFilterValueComponentState } from '@/object-record/record-filter/states/anyFieldFilterValueComponentState';
import { currentRecordFiltersComponentState } from '@/object-record/record-filter/states/currentRecordFiltersComponentState';
import { recordIndexKanbanAggregateOperationState } from '@/object-record/record-index/states/recordIndexKanbanAggregateOperationState';
import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue';
import { UserContext } from '@/users/contexts/UserContext';
import { useContext } from 'react';
import { useRecoilValue } from 'recoil';
import {
computeRecordGqlOperationFilter,
isDefined,
turnAnyFieldFilterIntoRecordGqlFilter,
} from 'twenty-shared/utils';
import { dateLocaleState } from '~/localization/states/dateLocaleState';
type UseAggregateRecordsProps = {
objectMetadataItem: ObjectMetadataItem;
additionalFilters?: Record<string, unknown>;
fallbackFieldName: string;
};
export const useAggregateRecordsForHeader = ({
objectMetadataItem,
additionalFilters = {},
}: UseAggregateRecordsProps) => {
const currentRecordFilterGroups = useRecoilComponentValue(
currentRecordFilterGroupsComponentState,
);
const currentRecordFilters = useRecoilComponentValue(
currentRecordFiltersComponentState,
);
const recordIndexKanbanAggregateOperation = useRecoilValue(
recordIndexKanbanAggregateOperationState,
);
const dateLocale = useRecoilValue(dateLocaleState);
const { filterValueDependencies } = useFilterValueDependencies();
const { dateFormat, timeFormat, timeZone } = useContext(UserContext);
const requestFilters = computeRecordGqlOperationFilter({
filterValueDependencies,
recordFilters: currentRecordFilters,
recordFilterGroups: currentRecordFilterGroups,
fields: objectMetadataItem.fields,
});
const recordGqlFieldsAggregate = buildRecordGqlFieldsAggregateForView({
objectMetadataItem,
recordIndexKanbanAggregateOperation,
});
const anyFieldFilterValue = useRecoilComponentValue(
anyFieldFilterValueComponentState,
);
const { recordGqlOperationFilter: anyFieldFilter } =
turnAnyFieldFilterIntoRecordGqlFilter({
fields: objectMetadataItem.fields,
filterValue: anyFieldFilterValue,
});
const { data } = useAggregateRecords({
objectNameSingular: objectMetadataItem.nameSingular,
recordGqlFieldsAggregate,
filter: { ...requestFilters, ...additionalFilters, ...anyFieldFilter },
});
const { value, labelWithFieldName } = computeAggregateValueAndLabel({
data,
objectMetadataItem,
fieldMetadataId: recordIndexKanbanAggregateOperation?.fieldMetadataId,
aggregateOperation: recordIndexKanbanAggregateOperation?.operation,
dateFormat,
timeFormat,
timeZone,
localeCatalog: dateLocale.localeCatalog,
});
return {
aggregateValue: value,
aggregateLabel: isDefined(value) ? labelWithFieldName : undefined,
};
};
@@ -1,5 +1,6 @@
import { RecordGroupContext } from '@/object-record/record-group/states/context/RecordGroupContext';
import { visibleRecordGroupIdsComponentFamilySelector } from '@/object-record/record-group/states/selectors/visibleRecordGroupIdsComponentFamilySelector';
import { RecordIndexGroupAggregatesDataLoader } from '@/object-record/record-index/components/RecordIndexGroupAggregatesDataLoader';
import { recordIndexAllRecordIdsComponentSelector } from '@/object-record/record-index/states/selectors/recordIndexAllRecordIdsComponentSelector';
import { RecordTableRecordGroupBodyContextProvider } from '@/object-record/record-table/components/RecordTableRecordGroupBodyContextProvider';
import { RecordTableRecordGroupRows } from '@/object-record/record-table/components/RecordTableRecordGroupRows';
@@ -50,6 +51,7 @@ export const RecordTableRecordGroupsBody = () => {
</RecordGroupContext.Provider>
</RecordTableRecordGroupBodyContextProvider>
))}
<RecordIndexGroupAggregatesDataLoader />
</RecordTableBodyRecordGroupDragDropContextProvider>
</>
);
@@ -1,10 +1,13 @@
import { useAggregateRecords } from '@/object-record/hooks/useAggregateRecords';
import { computeAggregateValueAndLabel } from '@/object-record/record-board/record-board-column/utils/computeAggregateValueAndLabel';
import { transformAggregateRawValueIntoAggregateDisplayValue } from '@/object-record/record-aggregate/utils/transformAggregateRawValueIntoAggregateDisplayValue';
import { getAggregateOperationLabel } from '@/object-record/record-board/record-board-column/utils/getAggregateOperationLabel';
import { currentRecordFilterGroupsComponentState } from '@/object-record/record-filter-group/states/currentRecordFilterGroupsComponentState';
import { useFilterValueDependencies } from '@/object-record/record-filter/hooks/useFilterValueDependencies';
import { anyFieldFilterValueComponentState } from '@/object-record/record-filter/states/anyFieldFilterValueComponentState';
import { currentRecordFiltersComponentState } from '@/object-record/record-filter/states/currentRecordFiltersComponentState';
import { useRecordGroupFilter } from '@/object-record/record-group/hooks/useRecordGroupFilter';
import { getRecordAggregateDisplayLabel } from '@/object-record/record-index/utils/getRecordndexAggregateDisplayLabel';
import { AggregateOperations } from '@/object-record/record-table/constants/AggregateOperations';
import { useRecordTableContextOrThrow } from '@/object-record/record-table/contexts/RecordTableContext';
import { RecordTableColumnAggregateFooterCellContext } from '@/object-record/record-table/record-table-footer/components/RecordTableColumnAggregateFooterCellContext';
@@ -15,8 +18,10 @@ import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/ho
import { UserContext } from '@/users/contexts/UserContext';
import { useContext } from 'react';
import { useRecoilValue } from 'recoil';
import { FIELD_FOR_TOTAL_COUNT_AGGREGATE_OPERATION } from 'twenty-shared/constants';
import {
computeRecordGqlOperationFilter,
findById,
isDefined,
isFieldMetadataDateKind,
turnAnyFieldFilterIntoRecordGqlFilter,
@@ -24,7 +29,7 @@ import {
import { dateLocaleState } from '~/localization/states/dateLocaleState';
export const useAggregateRecordsForRecordTableColumnFooter = (
fieldMetadataId: string,
aggregateFieldMetadataId: string,
) => {
const { objectMetadataItem } = useRecordTableContextOrThrow();
const { recordGroupFilter } = useRecordGroupFilter(objectMetadataItem.fields);
@@ -53,7 +58,7 @@ export const useAggregateRecordsForRecordTableColumnFooter = (
);
const fieldMetadataItem = objectMetadataItem.fields.find(
(field) => field.id === fieldMetadataId,
(field) => field.id === aggregateFieldMetadataId,
);
// TODO: This shouldn't be set with impossible values,
@@ -109,20 +114,53 @@ export const useAggregateRecordsForRecordTableColumnFooter = (
const { dateFormat, timeFormat, timeZone } = useContext(UserContext);
const { value, label } = computeAggregateValueAndLabel({
data,
objectMetadataItem,
fieldMetadataId: fieldMetadataId,
const aggregateFieldMetadataItem = objectMetadataItem.fields.find(
findById(aggregateFieldMetadataId),
);
if (!isDefined(aggregateFieldMetadataItem)) {
return {
aggregateValue:
data?.[FIELD_FOR_TOTAL_COUNT_AGGREGATE_OPERATION]?.[
AggregateOperations.COUNT
],
aggregateLabel: getAggregateOperationLabel(AggregateOperations.COUNT),
isLoading: loading,
};
}
if (!isDefined(aggregateOperationForViewField)) {
return {
aggregateValue: null,
aggregateLabel: null,
isLoading: loading,
};
}
const aggregateRawValue =
data[aggregateFieldMetadataItem.name]?.[aggregateOperationForViewField];
const aggregateDisplayValue =
transformAggregateRawValueIntoAggregateDisplayValue({
aggregateFieldMetadataItem: aggregateFieldMetadataItem,
aggregateOperation: aggregateOperationForViewField,
aggregateRawValue: aggregateRawValue,
dateFormat,
localeCatalog: dateLocale.localeCatalog,
timeFormat,
timeZone,
});
const { aggregateLabel } = getRecordAggregateDisplayLabel({
aggregateFieldMetadataItem,
aggregateOperation: aggregateOperationForViewField,
dateFormat,
timeFormat,
timeZone,
localeCatalog: dateLocale.localeCatalog,
});
return {
aggregateValue: value,
aggregateLabel: isDefined(value) ? label : undefined,
aggregateValue: aggregateDisplayValue,
aggregateLabel: isDefined(aggregateDisplayValue)
? aggregateLabel
: undefined,
isLoading: loading,
};
};
@@ -18,9 +18,12 @@ import { RecordTableGroupSectionLastDynamicFillingCell } from '@/object-record/r
import { RECORD_TABLE_COLUMN_CHECKBOX_WIDTH } from '@/object-record/record-table/constants/RecordTableColumnCheckboxWidth';
import { RECORD_TABLE_COLUMN_MIN_WIDTH } from '@/object-record/record-table/constants/RecordTableColumnMinWidth';
import { RECORD_TABLE_LABEL_IDENTIFIER_COLUMN_WIDTH_ON_MOBILE } from '@/object-record/record-table/constants/RecordTableLabelIdentifierColumnWidthOnMobile';
import { useAggregateRecordsForRecordTableSection } from '@/object-record/record-table/record-table-section/hooks/useAggregateRecordsForRecordTableSection';
import { recordIndexAggregateDisplayLabelComponentState } from '@/object-record/record-index/states/recordIndexAggregateDisplayLabelComponentState';
import { recordIndexAggregateDisplayValueForGroupValueComponentFamilyState } from '@/object-record/record-index/states/recordIndexAggregateDisplayValueForGroupValueComponentFamilyState';
import { isRecordGroupTableSectionToggledComponentState } from '@/object-record/record-table/record-table-section/states/isRecordGroupTableSectionToggledComponentState';
import { useRecoilComponentFamilyState } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentFamilyState';
import { useRecoilComponentFamilyValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentFamilyValue';
import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue';
import { useRecoilValue } from 'recoil';
import {
@@ -116,8 +119,19 @@ export const RecordTableRecordGroupSection = () => {
const { objectMetadataItem } = useRecordTableContextOrThrow();
const { aggregateValue, aggregateLabel } =
useAggregateRecordsForRecordTableSection();
const recordGroup = useRecoilValue(
recordGroupDefinitionFamilyState(currentRecordGroupId),
);
const recordIndexAggregateDisplayValueForGroupValue =
useRecoilComponentFamilyValue(
recordIndexAggregateDisplayValueForGroupValueComponentFamilyState,
{ groupValue: recordGroup?.value ?? '' },
);
const recordIndexAggregateDisplayLabel = useRecoilComponentValue(
recordIndexAggregateDisplayLabelComponentState,
);
const { labelIdentifierFieldMetadataItem } = useRecordIndexContextOrThrow();
@@ -144,10 +158,6 @@ export const RecordTableRecordGroupSection = () => {
currentRecordGroupId,
);
const recordGroup = useRecoilValue(
recordGroupDefinitionFamilyState(currentRecordGroupId),
);
const handleDropdownToggle = useCallback(() => {
setIsRecordGroupTableSectionToggled((prevState) => !prevState);
}, [setIsRecordGroupTableSectionToggled]);
@@ -203,10 +213,10 @@ export const RecordTableRecordGroupSection = () => {
weight="medium"
/>
<RecordBoardColumnHeaderAggregateDropdown
aggregateValue={aggregateValue}
aggregateValue={recordIndexAggregateDisplayValueForGroupValue}
dropdownId={`record-group-section-aggregate-dropdown-${currentRecordGroupId}`}
objectMetadataItem={objectMetadataItem}
aggregateLabel={aggregateLabel}
aggregateLabel={recordIndexAggregateDisplayLabel}
/>
</StyledRecordGroupSection>
<StyledFieldPlaceholderCell widthOfFields={fieldsPlaceholderWidth} />
@@ -1,16 +0,0 @@
import { useRecordGroupFilter } from '@/object-record/record-group/hooks/useRecordGroupFilter';
import { useRecordTableContextOrThrow } from '@/object-record/record-table/contexts/RecordTableContext';
import { useAggregateRecordsForHeader } from '@/object-record/record-table/hooks/useAggregateRecordsForHeader';
const DEFAULT_FIELD_NAME_FOR_COUNT = 'name';
export const useAggregateRecordsForRecordTableSection = () => {
const { objectMetadataItem } = useRecordTableContextOrThrow();
const { recordGroupFilter } = useRecordGroupFilter(objectMetadataItem.fields);
return useAggregateRecordsForHeader({
objectMetadataItem,
additionalFilters: recordGroupFilter,
fallbackFieldName: DEFAULT_FIELD_NAME_FOR_COUNT,
});
};
@@ -1,5 +1,5 @@
import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { generateGroupByQuery } from '../generateGroupByQuery';
import { generateGroupByAggregateQuery } from '../../record-aggregate/utils/generateGroupByAggregateQuery';
describe('generateGroupByQuery', () => {
const testCases = [
@@ -36,9 +36,9 @@ describe('generateGroupByQuery', () => {
it.each(testCases)(
'should generate valid GraphQL query for $description',
({ objectMetadataItem, aggregateOperations }) => {
const result = generateGroupByQuery({
const result = generateGroupByAggregateQuery({
objectMetadataItem: objectMetadataItem as ObjectMetadataItem,
aggregateOperations,
aggregateOperationGqlFields: aggregateOperations,
});
expect(result.loc?.source.body).toMatchSnapshot();
@@ -11,8 +11,8 @@ import { MemoryRouter } from 'react-router-dom';
import { FIND_ONE_PAGE_LAYOUT } from '@/dashboards/graphql/queries/findOnePageLayout';
import { ApolloCoreClientContext } from '@/object-metadata/contexts/ApolloCoreClientContext';
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
import { generateGroupByAggregateQuery } from '@/object-record/record-aggregate/utils/generateGroupByAggregateQuery';
import { PageLayoutRenderer } from '@/page-layout/components/PageLayoutRenderer';
import { generateGroupByQuery } from '@/page-layout/widgets/graph/utils/generateGroupByQuery';
import { LayoutRenderingProvider } from '@/ui/layout/contexts/LayoutRenderingContext';
import {
GraphOrderBy,
@@ -181,9 +181,9 @@ const mixedGraphsPageLayoutMocks = {
],
};
const barChartGroupByQuery = generateGroupByQuery({
const barChartGroupByQuery = generateGroupByAggregateQuery({
objectMetadataItem: mockPersonObjectMetadataItem,
aggregateOperations: ['totalCount'],
aggregateOperationGqlFields: ['totalCount'],
});
const graphqlMocks: MockedResponse[] = [
@@ -1,6 +1,6 @@
import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
export const getGroupByQueryName = (
export const getGroupByQueryResultGqlFieldName = (
objectMetadataItem: ObjectMetadataItem,
): string => {
return `${objectMetadataItem.namePlural}GroupBy`;
@@ -13,13 +13,15 @@ import { objectMetadataItemsState } from '@/object-metadata/states/objectMetadat
import { shouldAppBeLoadingState } from '@/object-metadata/states/shouldAppBeLoadingState';
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
import { PageLayoutContentProvider } from '@/page-layout/contexts/PageLayoutContentContext';
import { pageLayoutPersistedComponentState } from '@/page-layout/states/pageLayoutPersistedComponentState';
import { WidgetRenderer } from '@/page-layout/widgets/components/WidgetRenderer';
import { generateGroupByAggregateQuery } from '@/object-record/record-aggregate/utils/generateGroupByAggregateQuery';
import {
PAGE_LAYOUT_TEST_INSTANCE_ID,
PageLayoutTestWrapper,
} from '@/page-layout/hooks/__tests__/PageLayoutTestWrapper';
import { pageLayoutPersistedComponentState } from '@/page-layout/states/pageLayoutPersistedComponentState';
import { WidgetRenderer } from '@/page-layout/widgets/components/WidgetRenderer';
import { generateGroupByQuery } from '@/page-layout/widgets/graph/utils/generateGroupByQuery';
import { LayoutRenderingProvider } from '@/ui/layout/contexts/LayoutRenderingContext';
import {
GraphOrderBy,
@@ -49,9 +51,9 @@ const createdAtField = getMockFieldMetadataItemOrThrow({
fieldName: 'createdAt',
});
const barChartGroupByQuery = generateGroupByQuery({
const barChartGroupByQuery = generateGroupByAggregateQuery({
objectMetadataItem: companyObjectMetadataItem,
aggregateOperations: ['totalCount'],
aggregateOperationGqlFields: ['totalCount'],
});
const graphqlMocks: MockedResponse[] = [
@@ -2,7 +2,7 @@ import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataIte
import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { getAggregateOperationLabel } from '@/object-record/record-board/record-board-column/utils/getAggregateOperationLabel';
import { type ExtendedAggregateOperations } from '@/object-record/record-table/types/ExtendedAggregateOperations';
import { getGroupByQueryName } from '@/page-layout/utils/getGroupByQueryName';
import { getGroupByQueryResultGqlFieldName } from '@/page-layout/utils/getGroupByQueryResultGqlFieldName';
import { GRAPH_DEFAULT_DATE_GRANULARITY } from '@/page-layout/widgets/graph/constants/GraphDefaultDateGranularity.constant';
import { type BarChartDataItem } from '@/page-layout/widgets/graph/graphWidgetBarChart/types/BarChartDataItem';
import { BarChartLayout } from '@/page-layout/widgets/graph/graphWidgetBarChart/types/BarChartLayout';
@@ -99,8 +99,9 @@ export const transformGroupByDataToBarChartData = ({
subFieldName: primaryAxisSubFieldName,
});
const queryName = getGroupByQueryName(objectMetadataItem);
const rawResults = groupByData[queryName];
const queryResultGqlFieldName =
getGroupByQueryResultGqlFieldName(objectMetadataItem);
const rawResults = groupByData[queryResultGqlFieldName];
if (!isDefined(rawResults) || !Array.isArray(rawResults)) {
return {
@@ -1,11 +1,19 @@
import { useAggregateRecords } from '@/object-record/hooks/useAggregateRecords';
import { computeAggregateValueAndLabel } from '@/object-record/record-board/record-board-column/utils/computeAggregateValueAndLabel';
import { transformAggregateRawValueIntoAggregateDisplayValue } from '@/object-record/record-aggregate/utils/transformAggregateRawValueIntoAggregateDisplayValue';
import { getAggregateOperationLabel } from '@/object-record/record-board/record-board-column/utils/getAggregateOperationLabel';
import { getRecordAggregateDisplayLabel } from '@/object-record/record-index/utils/getRecordndexAggregateDisplayLabel';
import { convertAggregateOperationToExtendedAggregateOperation } from '@/object-record/utils/convertAggregateOperationToExtendedAggregateOperation';
import { useGraphWidgetQueryCommon } from '@/page-layout/widgets/graph/hooks/useGraphWidgetQueryCommon';
import { UserContext } from '@/users/contexts/UserContext';
import { useContext } from 'react';
import { useRecoilValue } from 'recoil';
import { type AggregateChartConfiguration } from '~/generated/graphql';
import { FIELD_FOR_TOTAL_COUNT_AGGREGATE_OPERATION } from 'twenty-shared/constants';
import { findById, isDefined } from 'twenty-shared/utils';
import {
AggregateOperations,
type AggregateChartConfiguration,
} from '~/generated/graphql';
import { dateLocaleState } from '~/localization/states/dateLocaleState';
export const useGraphWidgetAggregateQuery = ({
@@ -40,20 +48,44 @@ export const useGraphWidgetAggregateQuery = ({
const { dateFormat, timeFormat, timeZone } = useContext(UserContext);
const dateLocale = useRecoilValue(dateLocaleState);
const { value, label } = computeAggregateValueAndLabel({
data,
objectMetadataItem,
fieldMetadataId: configuration.aggregateFieldMetadataId,
aggregateOperation: extendedAggregateOperation,
dateFormat,
timeFormat,
timeZone,
localeCatalog: dateLocale.localeCatalog,
const aggregateFieldMetadataItem = objectMetadataItem.fields.find(
findById(configuration.aggregateFieldMetadataId),
);
if (!isDefined(aggregateFieldMetadataItem)) {
return {
value:
data?.[FIELD_FOR_TOTAL_COUNT_AGGREGATE_OPERATION]?.[
AggregateOperations.COUNT
],
label: getAggregateOperationLabel(AggregateOperations.COUNT),
loading,
error,
};
}
const aggregateRawValue =
data[aggregateFieldMetadataItem.name]?.[aggregateOperation];
const aggregateDisplayLabel = getRecordAggregateDisplayLabel({
aggregateFieldMetadataItem,
aggregateOperation,
});
const aggregateDisplayValue =
transformAggregateRawValueIntoAggregateDisplayValue({
aggregateFieldMetadataItem: aggregateFieldMetadataItem,
aggregateOperation: extendedAggregateOperation,
aggregateRawValue: aggregateRawValue,
dateFormat,
localeCatalog: dateLocale.localeCatalog,
timeFormat,
timeZone,
});
return {
value,
label,
value: aggregateDisplayValue,
label: aggregateDisplayLabel,
loading,
error,
};
@@ -1,8 +1,8 @@
import { useApolloCoreClient } from '@/object-metadata/hooks/useApolloCoreClient';
import { generateGroupByAggregateQuery } from '@/object-record/record-aggregate/utils/generateGroupByAggregateQuery';
import { getAvailableAggregationsFromObjectFields } from '@/object-record/utils/getAvailableAggregationsFromObjectFields';
import { useGraphWidgetQueryCommon } from '@/page-layout/widgets/graph/hooks/useGraphWidgetQueryCommon';
import { type GroupByChartConfiguration } from '@/page-layout/widgets/graph/types/GroupByChartConfiguration';
import { generateGroupByQuery } from '@/page-layout/widgets/graph/utils/generateGroupByQuery';
import { generateGroupByQueryVariablesFromChartConfiguration } from '@/page-layout/widgets/graph/utils/generateGroupByQueryVariablesFromChartConfiguration';
import { useQuery } from '@apollo/client';
import { useMemo } from 'react';
@@ -58,14 +58,14 @@ export const useGraphWidgetGroupByQuery = ({
filter: gqlOperationFilter,
};
const query = generateGroupByQuery({
const groupByAggregateQuery = generateGroupByAggregateQuery({
objectMetadataItem,
aggregateOperations: [aggregateOperation],
aggregateOperationGqlFields: [aggregateOperation],
});
const apolloCoreClient = useApolloCoreClient();
const { data, loading, error, refetch } = useQuery(query, {
const { data, loading, error, refetch } = useQuery(groupByAggregateQuery, {
client: apolloCoreClient,
variables,
});
@@ -1,8 +1,8 @@
import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { type ExtendedAggregateOperations } from '@/object-record/record-table/types/ExtendedAggregateOperations';
import { getAggregateOperationLabel } from '@/object-record/record-board/record-board-column/utils/getAggregateOperationLabel';
import { getGroupByQueryName } from '@/page-layout/utils/getGroupByQueryName';
import { type ExtendedAggregateOperations } from '@/object-record/record-table/types/ExtendedAggregateOperations';
import { getGroupByQueryResultGqlFieldName } from '@/page-layout/utils/getGroupByQueryResultGqlFieldName';
import { type LineChartSeries } from '@/page-layout/widgets/graph/graphWidgetLineChart/types/LineChartSeries';
import { type GroupByRawResult } from '@/page-layout/widgets/graph/types/GroupByRawResult';
import { filterGroupByResults } from '@/page-layout/widgets/graph/utils/filterGroupByResults';
@@ -73,8 +73,9 @@ export const transformGroupByDataToLineChartData = ({
const primaryAxisSubFieldName =
configuration.primaryAxisGroupBySubFieldName ?? undefined;
const queryName = getGroupByQueryName(objectMetadataItem);
const rawResults = groupByData[queryName];
const queryResultGqlFieldName =
getGroupByQueryResultGqlFieldName(objectMetadataItem);
const rawResults = groupByData[queryResultGqlFieldName];
if (!isDefined(rawResults) || !Array.isArray(rawResults)) {
return EMPTY_LINE_CHART_RESULT;
@@ -1,66 +0,0 @@
import { AggregateOperations } from '@/object-record/record-table/constants/AggregateOperations';
import { DateAggregateOperations } from '@/object-record/record-table/constants/DateAggregateOperations';
import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue';
import { usePersistView } from '@/views/hooks/internal/usePersistView';
import { useCanPersistViewChanges } from '@/views/hooks/useCanPersistViewChanges';
import { useRefreshCoreViewsByObjectMetadataId } from '@/views/hooks/useRefreshCoreViewsByObjectMetadataId';
import { renderHook } from '@testing-library/react';
import { useSetRecoilState } from 'recoil';
import { useUpdateViewAggregate } from '../useUpdateViewAggregate';
jest.mock('@/ui/utilities/state/component-state/hooks/useRecoilComponentValue');
jest.mock('@/views/hooks/internal/usePersistView');
jest.mock('@/views/hooks/useCanPersistViewChanges');
jest.mock('@/views/hooks/useRefreshCoreViewsByObjectMetadataId');
jest.mock('recoil');
describe('useUpdateViewAggregate', () => {
const mockCurrentViewId = 'test-view-id';
const mockUpdateView = jest.fn();
const mockSetRecordIndexKanbanAggregateOperationState = jest.fn();
beforeEach(() => {
jest.clearAllMocks();
(useRecoilComponentValue as jest.Mock).mockReturnValue(mockCurrentViewId);
(usePersistView as jest.Mock).mockReturnValue({
updateView: mockUpdateView,
});
(useCanPersistViewChanges as jest.Mock).mockReturnValue({
canPersistChanges: true,
});
(useSetRecoilState as jest.Mock).mockReturnValue(
mockSetRecordIndexKanbanAggregateOperationState,
);
(useRefreshCoreViewsByObjectMetadataId as jest.Mock).mockReturnValue({
refreshCoreViewsByObjectMetadataId: jest.fn(),
});
});
describe('Aggregate operations on dates', () => {
it('should update view with rightfully converted values', () => {
const { result } = renderHook(() => useUpdateViewAggregate());
result.current.updateViewAggregate({
kanbanAggregateOperationFieldMetadataId: 'test-field-id',
kanbanAggregateOperation: DateAggregateOperations.EARLIEST,
objectMetadataId: 'test-object-metadata-id',
});
// updateView is called with 'EARLIEST' converted to 'MIN'
expect(mockUpdateView).toHaveBeenCalledWith({
id: mockCurrentViewId,
input: {
kanbanAggregateOperationFieldMetadataId: 'test-field-id',
kanbanAggregateOperation: AggregateOperations.MIN,
},
});
// setAggregateOperation is called with 'EARLIEST'
expect(
mockSetRecordIndexKanbanAggregateOperationState,
).toHaveBeenCalledWith({
operation: DateAggregateOperations.EARLIEST,
fieldMetadataId: 'test-field-id',
});
});
});
});
@@ -1,14 +1,19 @@
import { contextStoreCurrentViewIdComponentState } from '@/context-store/states/contextStoreCurrentViewIdComponentState';
import { recordIndexKanbanAggregateOperationState } from '@/object-record/record-index/states/recordIndexKanbanAggregateOperationState';
import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { useLoadRecordIndexStates } from '@/object-record/record-index/hooks/useLoadRecordIndexStates';
import { type ExtendedAggregateOperations } from '@/object-record/record-table/types/ExtendedAggregateOperations';
import { convertExtendedAggregateOperationToAggregateOperation } from '@/object-record/utils/convertExtendedAggregateOperationToAggregateOperation';
import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue';
import { usePersistView } from '@/views/hooks/internal/usePersistView';
import { useCanPersistViewChanges } from '@/views/hooks/useCanPersistViewChanges';
import { useRefreshCoreViewsByObjectMetadataId } from '@/views/hooks/useRefreshCoreViewsByObjectMetadataId';
import { useCallback } from 'react';
import { useSetRecoilState } from 'recoil';
import { isDefined } from 'twenty-shared/utils';
import { coreViewsState } from '@/views/states/coreViewState';
import { convertCoreViewToView } from '@/views/utils/convertCoreViewToView';
import { useRecoilCallback } from 'recoil';
import {
isDefined,
upsertIntoArrayOfObjectsComparingId,
} from 'twenty-shared/utils';
import { type CoreView } from '~/generated/graphql';
export const useUpdateViewAggregate = () => {
const { canPersistChanges } = useCanPersistViewChanges();
@@ -16,62 +21,64 @@ export const useUpdateViewAggregate = () => {
contextStoreCurrentViewIdComponentState,
);
const { updateView } = usePersistView();
const { loadRecordIndexStates } = useLoadRecordIndexStates();
const setRecordIndexKanbanAggregateOperationState = useSetRecoilState(
recordIndexKanbanAggregateOperationState,
);
const { refreshCoreViewsByObjectMetadataId } =
useRefreshCoreViewsByObjectMetadataId();
const updateViewAggregate = useCallback(
({
kanbanAggregateOperationFieldMetadataId,
kanbanAggregateOperation,
objectMetadataId,
}: {
kanbanAggregateOperationFieldMetadataId: string | null;
kanbanAggregateOperation: ExtendedAggregateOperations | null;
objectMetadataId: string;
}) => {
if (!canPersistChanges) {
return;
}
const convertedKanbanAggregateOperation = isDefined(
const updateViewAggregate = useRecoilCallback(
({ set }) =>
async ({
kanbanAggregateOperationFieldMetadataId,
kanbanAggregateOperation,
)
? convertExtendedAggregateOperationToAggregateOperation(
kanbanAggregateOperation,
)
: null;
objectMetadataItem,
}: {
kanbanAggregateOperationFieldMetadataId: string | null;
kanbanAggregateOperation: ExtendedAggregateOperations | null;
objectMetadataItem: ObjectMetadataItem;
}) => {
if (!canPersistChanges) {
return;
}
if (!isDefined(currentViewId)) {
return;
}
const convertedKanbanAggregateOperation = isDefined(
kanbanAggregateOperation,
)
? convertExtendedAggregateOperationToAggregateOperation(
kanbanAggregateOperation,
)
: null;
updateView({
id: currentViewId,
input: {
kanbanAggregateOperationFieldMetadataId,
kanbanAggregateOperation: convertedKanbanAggregateOperation,
},
});
if (!isDefined(currentViewId)) {
return;
}
setRecordIndexKanbanAggregateOperationState({
operation: kanbanAggregateOperation,
fieldMetadataId: kanbanAggregateOperationFieldMetadataId,
});
const updatedViewResult = await updateView({
id: currentViewId,
input: {
kanbanAggregateOperationFieldMetadataId,
kanbanAggregateOperation: convertedKanbanAggregateOperation,
},
});
refreshCoreViewsByObjectMetadataId(objectMetadataId);
},
[
canPersistChanges,
currentViewId,
updateView,
setRecordIndexKanbanAggregateOperationState,
refreshCoreViewsByObjectMetadataId,
],
if (updatedViewResult.status === 'successful') {
const updatedCoreView = updatedViewResult.response.data
?.updateCoreView as CoreView;
if (!isDefined(updatedCoreView)) {
return;
}
set(coreViewsState, (currentCoreViews) =>
upsertIntoArrayOfObjectsComparingId(
currentCoreViews,
updatedCoreView,
),
);
const updatedView = convertCoreViewToView(updatedCoreView);
loadRecordIndexStates(updatedView, objectMetadataItem);
}
},
[canPersistChanges, currentViewId, updateView, loadRecordIndexStates],
);
return {
@@ -0,0 +1,60 @@
import { upsertIntoArrayOfObjectsComparingId } from '@/utils/array/upsertIntoArrayOfObjectComparingId';
type TestObject = {
id: string;
name: string;
};
const mockTestObjects: TestObject[] = [
{
id: '1',
name: 'Test 1',
},
{
id: '2',
name: 'Test 2',
},
{
id: '3',
name: 'Test 3',
},
{
id: '4',
name: 'Test 4',
},
];
describe('upsertIntoArrayOfObjectsComparingId', () => {
it('should insert in empty array', () => {
expect(
upsertIntoArrayOfObjectsComparingId([], mockTestObjects[0]),
).toStrictEqual([mockTestObjects[0]]);
});
it('should insert in array', () => {
const newItem: TestObject = {
id: '5',
name: 'Test 5',
};
expect(
upsertIntoArrayOfObjectsComparingId(mockTestObjects, newItem),
).toStrictEqual([...mockTestObjects, newItem]);
});
it('should replace in array', () => {
const itemToReplace: TestObject = {
id: '4',
name: 'Test 4 replaced',
};
expect(
upsertIntoArrayOfObjectsComparingId(mockTestObjects, itemToReplace),
).toStrictEqual([
mockTestObjects[0],
mockTestObjects[1],
mockTestObjects[2],
itemToReplace,
]);
});
});
@@ -0,0 +1,22 @@
import { findById } from '@/utils/array/findById';
export const upsertIntoArrayOfObjectsComparingId = <T extends { id: string }>(
arrayToUpsertInto: T[],
itemToUpsert: T,
): T[] => {
const alreadyExistingItemIndex = arrayToUpsertInto.findIndex(
findById(itemToUpsert.id),
);
const shouldReplaceItem = alreadyExistingItemIndex > -1;
if (shouldReplaceItem) {
const newArray = [...arrayToUpsertInto];
newArray.splice(alreadyExistingItemIndex, 1, itemToUpsert);
return newArray;
} else {
return arrayToUpsertInto.concat(itemToUpsert);
}
};
@@ -15,6 +15,7 @@ export { findOrThrow } from './array/findOrThrow';
export { getContiguousIncrementalValues } from './array/getContiguousIncrementalValues';
export { isNonEmptyArray } from './array/isNonEmptyArray';
export { sumByProperty } from './array/sumByProperty';
export { upsertIntoArrayOfObjectsComparingId } from './array/upsertIntoArrayOfObjectComparingId';
export { assertUnreachable } from './assertUnreachable';
export { computeDiffBetweenObjects } from './compute-diff-between-objects';
export { deepMerge } from './deepMerge';