Skip to content

Commit 05cd0d1

Browse files
authored
[Aggregate queries for table views - #2] Add aggregate queries footer for simple views (twentyhq#9025)
In this PR, we are introducing aggregate queries on table views, behind a feature flag. This does not work with view groups yet, nor with views that have records until the bottom. (both will be tackled next)
1 parent 5f2a39d commit 05cd0d1

20 files changed

+662
-62
lines changed

packages/twenty-front/src/modules/object-record/record-board/record-board-column/hooks/useAggregateRecordsForRecordBoardColumn.ts

+7-6
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { useAggregateRecords } from '@/object-record/hooks/useAggregateRecords';
22
import { RecordBoardContext } from '@/object-record/record-board/contexts/RecordBoardContext';
33
import { RecordBoardColumnContext } from '@/object-record/record-board/record-board-column/contexts/RecordBoardColumnContext';
4-
import { buildRecordGqlFieldsAggregate } from '@/object-record/record-board/record-board-column/utils/buildRecordGqlFieldsAggregate';
4+
import { buildRecordGqlFieldsAggregateForRecordBoard } from '@/object-record/record-board/record-board-column/utils/buildRecordGqlFieldsAggregateForRecordBoard';
55
import { computeAggregateValueAndLabel } from '@/object-record/record-board/record-board-column/utils/computeAggregateValueAndLabel';
66
import { computeViewRecordGqlOperationFilter } from '@/object-record/record-filter/utils/computeViewRecordGqlOperationFilter';
77
import { recordIndexFiltersState } from '@/object-record/record-index/states/recordIndexFiltersState';
@@ -42,7 +42,7 @@ export const useAggregateRecordsForRecordBoardColumn = () => {
4242
);
4343
}
4444

45-
const recordGqlFieldsAggregate = buildRecordGqlFieldsAggregate({
45+
const recordGqlFieldsAggregate = buildRecordGqlFieldsAggregateForRecordBoard({
4646
objectMetadataItem: objectMetadataItem,
4747
recordIndexKanbanAggregateOperation: recordIndexKanbanAggregateOperation,
4848
kanbanFieldName: kanbanFieldName,
@@ -74,12 +74,13 @@ export const useAggregateRecordsForRecordBoardColumn = () => {
7474
skip: !isAggregateQueryEnabled,
7575
});
7676

77-
const { value, label } = computeAggregateValueAndLabel(
77+
const { value, label } = computeAggregateValueAndLabel({
7878
data,
7979
objectMetadataItem,
80-
recordIndexKanbanAggregateOperation,
81-
kanbanFieldName,
82-
);
80+
fieldMetadataId: recordIndexKanbanAggregateOperation?.fieldMetadataId,
81+
aggregateOperation: recordIndexKanbanAggregateOperation?.operation,
82+
fallbackFieldName: kanbanFieldName,
83+
});
8384

8485
return {
8586
aggregateValue: isAggregateQueryEnabled ? value : recordCount,
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,14 @@
11
import { FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
22
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
3-
import { buildRecordGqlFieldsAggregate } from '@/object-record/record-board/record-board-column/utils/buildRecordGqlFieldsAggregate';
3+
import { buildRecordGqlFieldsAggregateForRecordBoard } from '@/object-record/record-board/record-board-column/utils/buildRecordGqlFieldsAggregateForRecordBoard';
44
import { KanbanAggregateOperation } from '@/object-record/record-index/states/recordIndexKanbanAggregateOperationState';
55
import { AGGREGATE_OPERATIONS } from '@/object-record/record-table/constants/AggregateOperations';
66
import { FieldMetadataType } from '~/generated-metadata/graphql';
77

88
const MOCK_FIELD_ID = '7d2d7b5e-7b3e-4b4a-8b0a-7b3e4b4a8b0a';
99
const MOCK_KANBAN_FIELD = 'stage';
1010

11-
describe('buildRecordGqlFieldsAggregate', () => {
11+
describe('buildRecordGqlFieldsAggregateForRecordBoard', () => {
1212
const mockObjectMetadata: ObjectMetadataItem = {
1313
id: '123',
1414
nameSingular: 'opportunity',
@@ -50,7 +50,7 @@ describe('buildRecordGqlFieldsAggregate', () => {
5050
operation: AGGREGATE_OPERATIONS.sum,
5151
};
5252

53-
const result = buildRecordGqlFieldsAggregate({
53+
const result = buildRecordGqlFieldsAggregateForRecordBoard({
5454
objectMetadataItem: mockObjectMetadata,
5555
recordIndexKanbanAggregateOperation: kanbanAggregateOperation,
5656
kanbanFieldName: MOCK_KANBAN_FIELD,
@@ -67,7 +67,7 @@ describe('buildRecordGqlFieldsAggregate', () => {
6767
operation: AGGREGATE_OPERATIONS.count,
6868
};
6969

70-
const result = buildRecordGqlFieldsAggregate({
70+
const result = buildRecordGqlFieldsAggregateForRecordBoard({
7171
objectMetadataItem: mockObjectMetadata,
7272
recordIndexKanbanAggregateOperation: operation,
7373
kanbanFieldName: MOCK_KANBAN_FIELD,
@@ -85,7 +85,7 @@ describe('buildRecordGqlFieldsAggregate', () => {
8585
};
8686

8787
expect(() =>
88-
buildRecordGqlFieldsAggregate({
88+
buildRecordGqlFieldsAggregateForRecordBoard({
8989
objectMetadataItem: mockObjectMetadata,
9090
recordIndexKanbanAggregateOperation: operation,
9191
kanbanFieldName: MOCK_KANBAN_FIELD,

packages/twenty-front/src/modules/object-record/record-board/record-board-column/utils/__tests__/computeAggregateValueAndLabel.test.ts

+27-26
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import { AGGREGATE_OPERATIONS } from '@/object-record/record-table/constants/Agg
55
import { FieldMetadataType } from '~/generated/graphql';
66

77
const MOCK_FIELD_ID = '7d2d7b5e-7b3e-4b4a-8b0a-7b3e4b4a8b0a';
8-
const MOCK_KANBAN_FIELD = 'stage';
8+
const MOCK_KANBAN_FIELD_NAME = 'stage';
99

1010
describe('computeAggregateValueAndLabel', () => {
1111
const mockObjectMetadata: ObjectMetadataItem = {
@@ -20,12 +20,13 @@ describe('computeAggregateValueAndLabel', () => {
2020
} as ObjectMetadataItem;
2121

2222
it('should return empty object for empty data', () => {
23-
const result = computeAggregateValueAndLabel(
24-
{},
25-
mockObjectMetadata,
26-
{ fieldMetadataId: MOCK_FIELD_ID, operation: AGGREGATE_OPERATIONS.sum },
27-
MOCK_KANBAN_FIELD,
28-
);
23+
const result = computeAggregateValueAndLabel({
24+
data: {},
25+
objectMetadataItem: mockObjectMetadata,
26+
fieldMetadataId: MOCK_FIELD_ID,
27+
aggregateOperation: AGGREGATE_OPERATIONS.sum,
28+
fallbackFieldName: MOCK_KANBAN_FIELD_NAME,
29+
});
2930

3031
expect(result).toEqual({});
3132
});
@@ -37,12 +38,13 @@ describe('computeAggregateValueAndLabel', () => {
3738
},
3839
};
3940

40-
const result = computeAggregateValueAndLabel(
41-
mockData,
42-
mockObjectMetadata,
43-
{ fieldMetadataId: MOCK_FIELD_ID, operation: AGGREGATE_OPERATIONS.sum },
44-
MOCK_KANBAN_FIELD,
45-
);
41+
const result = computeAggregateValueAndLabel({
42+
data: mockData,
43+
objectMetadataItem: mockObjectMetadata,
44+
fieldMetadataId: MOCK_FIELD_ID,
45+
aggregateOperation: AGGREGATE_OPERATIONS.sum,
46+
fallbackFieldName: MOCK_KANBAN_FIELD_NAME,
47+
});
4648

4749
expect(result).toEqual({
4850
value: 2,
@@ -52,17 +54,16 @@ describe('computeAggregateValueAndLabel', () => {
5254

5355
it('should default to count when field not found', () => {
5456
const mockData = {
55-
[MOCK_KANBAN_FIELD]: {
57+
[MOCK_KANBAN_FIELD_NAME]: {
5658
[AGGREGATE_OPERATIONS.count]: 42,
5759
},
5860
};
5961

60-
const result = computeAggregateValueAndLabel(
61-
mockData,
62-
mockObjectMetadata,
63-
{ fieldMetadataId: 'non-existent', operation: AGGREGATE_OPERATIONS.sum },
64-
MOCK_KANBAN_FIELD,
65-
);
62+
const result = computeAggregateValueAndLabel({
63+
data: mockData,
64+
objectMetadataItem: mockObjectMetadata,
65+
fallbackFieldName: MOCK_KANBAN_FIELD_NAME,
66+
});
6667

6768
expect(result).toEqual({
6869
value: 42,
@@ -77,12 +78,12 @@ describe('computeAggregateValueAndLabel', () => {
7778
},
7879
};
7980

80-
const result = computeAggregateValueAndLabel(
81-
mockData,
82-
mockObjectMetadata,
83-
{ fieldMetadataId: MOCK_FIELD_ID, operation: AGGREGATE_OPERATIONS.sum },
84-
MOCK_KANBAN_FIELD,
85-
);
81+
const result = computeAggregateValueAndLabel({
82+
data: mockData,
83+
objectMetadataItem: mockObjectMetadata,
84+
fieldMetadataId: MOCK_FIELD_ID,
85+
aggregateOperation: AGGREGATE_OPERATIONS.sum,
86+
});
8687

8788
expect(result).toEqual({
8889
value: undefined,
+1-1
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { KanbanAggregateOperation } from '@/object-record/record-index/states/re
44
import { AGGREGATE_OPERATIONS } from '@/object-record/record-table/constants/AggregateOperations';
55
import { isDefined } from '~/utils/isDefined';
66

7-
export const buildRecordGqlFieldsAggregate = ({
7+
export const buildRecordGqlFieldsAggregateForRecordBoard = ({
88
objectMetadataItem,
99
recordIndexKanbanAggregateOperation,
1010
kanbanFieldName,
Original file line numberDiff line numberDiff line change
@@ -1,51 +1,59 @@
11
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
22
import { AggregateRecordsData } from '@/object-record/hooks/useAggregateRecords';
33
import { getAggregateOperationLabel } from '@/object-record/record-board/record-board-column/utils/getAggregateOperationLabel';
4-
import { KanbanAggregateOperation } from '@/object-record/record-index/states/recordIndexKanbanAggregateOperationState';
54
import { AGGREGATE_OPERATIONS } from '@/object-record/record-table/constants/AggregateOperations';
65
import isEmpty from 'lodash.isempty';
76
import { FieldMetadataType } from '~/generated-metadata/graphql';
87
import { isDefined } from '~/utils/isDefined';
98

10-
export const computeAggregateValueAndLabel = (
11-
data: AggregateRecordsData,
12-
objectMetadataItem: ObjectMetadataItem,
13-
recordIndexKanbanAggregateOperation: KanbanAggregateOperation,
14-
kanbanFieldName: string,
15-
) => {
9+
export const computeAggregateValueAndLabel = ({
10+
data,
11+
objectMetadataItem,
12+
fieldMetadataId,
13+
aggregateOperation,
14+
fallbackFieldName,
15+
}: {
16+
data: AggregateRecordsData;
17+
objectMetadataItem: ObjectMetadataItem;
18+
fieldMetadataId?: string | null;
19+
aggregateOperation?: AGGREGATE_OPERATIONS | null;
20+
fallbackFieldName?: string;
21+
}) => {
1622
if (isEmpty(data)) {
1723
return {};
1824
}
19-
const kanbanAggregateOperationField = objectMetadataItem.fields?.find(
20-
(field) =>
21-
field.id === recordIndexKanbanAggregateOperation?.fieldMetadataId,
25+
const field = objectMetadataItem.fields?.find(
26+
(field) => field.id === fieldMetadataId,
2227
);
2328

24-
const kanbanAggregateOperationFieldName = kanbanAggregateOperationField?.name;
25-
26-
if (
27-
!isDefined(kanbanAggregateOperationFieldName) ||
28-
!isDefined(recordIndexKanbanAggregateOperation?.operation)
29-
) {
29+
if (!isDefined(field)) {
30+
if (!fallbackFieldName) {
31+
throw new Error('Missing fallback field name');
32+
}
3033
return {
31-
value: data?.[kanbanFieldName]?.[AGGREGATE_OPERATIONS.count],
34+
value: data?.[fallbackFieldName]?.[AGGREGATE_OPERATIONS.count],
3235
label: `${getAggregateOperationLabel(AGGREGATE_OPERATIONS.count)}`,
3336
};
3437
}
3538

36-
const aggregateValue =
37-
data[kanbanAggregateOperationFieldName]?.[
38-
recordIndexKanbanAggregateOperation.operation
39-
];
39+
if (!isDefined(aggregateOperation)) {
40+
throw new Error('Missing aggregate operation');
41+
}
42+
43+
const aggregateValue = data[field.name]?.[aggregateOperation];
4044

4145
const value =
42-
isDefined(aggregateValue) &&
43-
kanbanAggregateOperationField?.type === FieldMetadataType.Currency
46+
isDefined(aggregateValue) && field.type === FieldMetadataType.Currency
4447
? Number(aggregateValue) / 1_000_000
4548
: aggregateValue;
4649

50+
const label =
51+
aggregateOperation === AGGREGATE_OPERATIONS.count
52+
? `${getAggregateOperationLabel(AGGREGATE_OPERATIONS.count)}`
53+
: `${getAggregateOperationLabel(aggregateOperation)} of ${field.name}`;
54+
4755
return {
4856
value,
49-
label: `${getAggregateOperationLabel(recordIndexKanbanAggregateOperation.operation)} of ${kanbanAggregateOperationFieldName}`,
57+
label,
5058
};
5159
};

packages/twenty-front/src/modules/object-record/record-table/components/RecordTable.tsx

+7
Original file line numberDiff line numberDiff line change
@@ -13,12 +13,14 @@ import { RecordTableNoRecordGroupBody } from '@/object-record/record-table/recor
1313
import { RecordTableNoRecordGroupBodyEffect } from '@/object-record/record-table/record-table-body/components/RecordTableNoRecordGroupBodyEffect';
1414
import { RecordTableRecordGroupBodyEffects } from '@/object-record/record-table/record-table-body/components/RecordTableRecordGroupBodyEffects';
1515
import { RecordTableRecordGroupsBody } from '@/object-record/record-table/record-table-body/components/RecordTableRecordGroupsBody';
16+
import { RecordTableFooter } from '@/object-record/record-table/record-table-footer/components/RecordTableFooter';
1617
import { RecordTableHeader } from '@/object-record/record-table/record-table-header/components/RecordTableHeader';
1718
import { isRecordTableInitialLoadingComponentState } from '@/object-record/record-table/states/isRecordTableInitialLoadingComponentState';
1819
import { recordTablePendingRecordIdComponentState } from '@/object-record/record-table/states/recordTablePendingRecordIdComponentState';
1920
import { DragSelect } from '@/ui/utilities/drag-select/components/DragSelect';
2021
import { useClickOutsideListener } from '@/ui/utilities/pointer-event/hooks/useClickOutsideListener';
2122
import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2';
23+
import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
2224
import { useRef } from 'react';
2325

2426
const StyledTable = styled.table`
@@ -33,6 +35,10 @@ export const RecordTable = () => {
3335

3436
const tableBodyRef = useRef<HTMLTableElement>(null);
3537

38+
const isAggregateQueryEnabled = useIsFeatureEnabled(
39+
'IS_AGGREGATE_QUERY_ENABLED',
40+
);
41+
3642
const { toggleClickOutsideListener } = useClickOutsideListener(
3743
RECORD_TABLE_CLICK_OUTSIDE_LISTENER_ID,
3844
);
@@ -90,6 +96,7 @@ export const RecordTable = () => {
9096
<RecordTableRecordGroupsBody />
9197
)}
9298
<RecordTableStickyEffect />
99+
{isAggregateQueryEnabled && <RecordTableFooter />}
93100
</StyledTable>
94101
<DragSelect
95102
dragSelectable={tableBodyRef}

packages/twenty-front/src/modules/object-record/record-table/components/RecordTableStickyEffect.tsx

+6
Original file line numberDiff line numberDiff line change
@@ -35,13 +35,19 @@ export const RecordTableStickyEffect = () => {
3535
document
3636
.getElementById('record-table-header')
3737
?.classList.add('first-columns-sticky');
38+
document
39+
.getElementById('record-table-footer')
40+
?.classList.add('first-columns-sticky');
3841
} else {
3942
document
4043
.getElementById('record-table-body')
4144
?.classList.remove('first-columns-sticky');
4245
document
4346
.getElementById('record-table-header')
4447
?.classList.remove('first-columns-sticky');
48+
document
49+
.getElementById('record-table-footer')
50+
?.classList.remove('first-columns-sticky');
4551
}
4652
}, [scrollLeft, setIsRecordTableScrolledLeft]);
4753

0 commit comments

Comments
 (0)