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:
@@ -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,
|
||||
},
|
||||
},
|
||||
|
||||
+50
-31
@@ -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;
|
||||
};
|
||||
|
||||
+3
-1
@@ -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;
|
||||
|
||||
+9
@@ -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],
|
||||
);
|
||||
|
||||
+20
-10
@@ -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();
|
||||
|
||||
+1
-1
@@ -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;
|
||||
|
||||
+19
@@ -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');
|
||||
});
|
||||
});
|
||||
+161
@@ -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');
|
||||
});
|
||||
});
|
||||
+6
-6
@@ -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 ')}` : ''}
|
||||
}
|
||||
}
|
||||
`;
|
||||
+20
@@ -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}`;
|
||||
};
|
||||
+95
@@ -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 '-';
|
||||
};
|
||||
+11
-4
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
+16
-6
@@ -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>
|
||||
|
||||
+3
-2
@@ -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;
|
||||
};
|
||||
|
||||
+4
-3
@@ -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"
|
||||
|
||||
+15
-10
@@ -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
|
||||
}
|
||||
|
||||
+6
-6
@@ -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
|
||||
|
||||
-38
@@ -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,
|
||||
});
|
||||
};
|
||||
+9
-19
@@ -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}`,
|
||||
|
||||
-258
@@ -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',
|
||||
});
|
||||
});
|
||||
});
|
||||
+13
-18
@@ -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,
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
-154
@@ -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,
|
||||
};
|
||||
};
|
||||
+7
-12
@@ -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,
|
||||
|
||||
+7
-18
@@ -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"
|
||||
/>
|
||||
|
||||
+98
@@ -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;
|
||||
};
|
||||
+38
@@ -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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
+49
@@ -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 };
|
||||
};
|
||||
+59
-23
@@ -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,
|
||||
],
|
||||
);
|
||||
|
||||
|
||||
+110
@@ -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,
|
||||
};
|
||||
};
|
||||
+34
@@ -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,
|
||||
};
|
||||
};
|
||||
+61
@@ -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,
|
||||
};
|
||||
};
|
||||
+9
@@ -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,
|
||||
});
|
||||
+9
@@ -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,
|
||||
});
|
||||
+10
@@ -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,
|
||||
});
|
||||
+10
@@ -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,
|
||||
});
|
||||
+10
@@ -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,
|
||||
});
|
||||
-13
@@ -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,
|
||||
});
|
||||
-7
@@ -1,7 +0,0 @@
|
||||
import { createState } from 'twenty-ui/utilities';
|
||||
export const recordIndexKanbanFieldMetadataIdState = createState<string | null>(
|
||||
{
|
||||
key: 'recordIndexKanbanFieldMetadataIdState',
|
||||
defaultValue: null,
|
||||
},
|
||||
);
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
import { type Nullable } from 'twenty-shared/types';
|
||||
|
||||
export type RecordAggregateValueByRecordGroupValue = {
|
||||
recordGroupValue: string;
|
||||
recordAggregateValue: Nullable<string | number>;
|
||||
};
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
export type RecordIndexGroupByQueryResult = {
|
||||
[groupByQueryResultGqlFieldName: string]: ({
|
||||
groupByDimensionValues: string[];
|
||||
} & {
|
||||
[aggregateGqlField: string]: string | number;
|
||||
})[];
|
||||
};
|
||||
+35
@@ -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,
|
||||
};
|
||||
};
|
||||
+45
@@ -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,
|
||||
};
|
||||
};
|
||||
-92
@@ -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,
|
||||
};
|
||||
};
|
||||
+2
@@ -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>
|
||||
</>
|
||||
);
|
||||
|
||||
+51
-13
@@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
+19
-9
@@ -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} />
|
||||
|
||||
-16
@@ -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,
|
||||
});
|
||||
};
|
||||
+3
-3
@@ -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();
|
||||
+3
-3
@@ -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
-1
@@ -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`;
|
||||
+7
-5
@@ -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[] = [
|
||||
|
||||
+4
-3
@@ -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 {
|
||||
|
||||
+45
-13
@@ -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,
|
||||
};
|
||||
|
||||
+4
-4
@@ -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,
|
||||
});
|
||||
|
||||
+5
-4
@@ -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;
|
||||
|
||||
-66
@@ -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 {
|
||||
|
||||
+60
@@ -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';
|
||||
|
||||
Reference in New Issue
Block a user