From 3838fef812765174a04af128082935c71f6f2818 Mon Sep 17 00:00:00 2001
From: Marie <51697796+ijreilly@users.noreply.github.com>
Date: Mon, 20 Jan 2025 18:26:30 +0100
Subject: [PATCH] Add aggregate on view groups headers (#9749)
Adding aggregate operations on view groups headers, with a design
similar to what is done on kanban headers: all view groups share the
same operation on the same field.
https://github.com/user-attachments/assets/26f6dd6f-1cf7-4ea6-9600-78d5ad5d690a
---
packages/twenty-front/nyc.config.cjs | 2 +-
...ardColumnHeaderAggregateDropdownButton.tsx | 11 +--
...useAggregateRecordsForRecordBoardColumn.ts | 62 +---------------
...ldRecordGqlFieldsAggregateForView.test.ts} | 16 ++--
...> buildRecordGqlFieldsAggregateForView.ts} | 8 +-
.../hooks/useAggregateRecordsForHeader.ts | 74 +++++++++++++++++++
.../RecordTableRecordGroupSection.tsx | 34 +++++----
...eAggregateRecordsForRecordTableSection.tsx | 16 ++++
8 files changed, 132 insertions(+), 91 deletions(-)
rename packages/twenty-front/src/modules/object-record/record-board/record-board-column/utils/__tests__/{buildRecordGqlFieldsAggregateForRecordBoard.test.ts => buildRecordGqlFieldsAggregateForView.test.ts} (84%)
rename packages/twenty-front/src/modules/object-record/record-board/record-board-column/utils/{buildRecordGqlFieldsAggregateForRecordBoard.ts => buildRecordGqlFieldsAggregateForView.ts} (90%)
create mode 100644 packages/twenty-front/src/modules/object-record/record-table/hooks/useAggregateRecordsForHeader.ts
create mode 100644 packages/twenty-front/src/modules/object-record/record-table/record-table-section/hooks/useAggregateRecordsForRecordTableSection.tsx
diff --git a/packages/twenty-front/nyc.config.cjs b/packages/twenty-front/nyc.config.cjs
index 35ca9cb24..29f6d82cb 100644
--- a/packages/twenty-front/nyc.config.cjs
+++ b/packages/twenty-front/nyc.config.cjs
@@ -10,7 +10,7 @@ const globalCoverage = {
const modulesCoverage = {
branches: 25,
statements: 44,
- lines: 45,
+ lines: 44,
functions: 38,
include: ['src/modules/**/*'],
exclude: ['src/**/*.ts'],
diff --git a/packages/twenty-front/src/modules/object-record/record-board/record-board-column/components/RecordBoardColumnHeaderAggregateDropdownButton.tsx b/packages/twenty-front/src/modules/object-record/record-board/record-board-column/components/RecordBoardColumnHeaderAggregateDropdownButton.tsx
index 2bc92e711..35a3188ae 100644
--- a/packages/twenty-front/src/modules/object-record/record-board/record-board-column/components/RecordBoardColumnHeaderAggregateDropdownButton.tsx
+++ b/packages/twenty-front/src/modules/object-record/record-board/record-board-column/components/RecordBoardColumnHeaderAggregateDropdownButton.tsx
@@ -1,3 +1,4 @@
+import { StyledHeaderDropdownButton } from '@/ui/layout/dropdown/components/StyledHeaderDropdownButton';
import { useDropdown } from '@/ui/layout/dropdown/hooks/useDropdown';
import styled from '@emotion/styled';
import { AppTooltip, Tag, TooltipDelay } from 'twenty-ui';
@@ -6,7 +7,7 @@ const StyledTag = styled(Tag)`
width: 100%;
`;
-const StyledContainer = styled.div`
+const StyledHeader = styled(StyledHeaderDropdownButton)`
padding: 0;
`;
@@ -22,8 +23,8 @@ export const RecordBoardColumnHeaderAggregateDropdownButton = ({
const { isDropdownOpen } = useDropdown(dropdownId);
return (
-
-
+
+ <>
)}
-
-
+ >
+
);
};
diff --git a/packages/twenty-front/src/modules/object-record/record-board/record-board-column/hooks/useAggregateRecordsForRecordBoardColumn.ts b/packages/twenty-front/src/modules/object-record/record-board/record-board-column/hooks/useAggregateRecordsForRecordBoardColumn.ts
index 664bf184f..7c4d89988 100644
--- a/packages/twenty-front/src/modules/object-record/record-board/record-board-column/hooks/useAggregateRecordsForRecordBoardColumn.ts
+++ b/packages/twenty-front/src/modules/object-record/record-board/record-board-column/hooks/useAggregateRecordsForRecordBoardColumn.ts
@@ -1,28 +1,14 @@
-import { useAggregateRecords } from '@/object-record/hooks/useAggregateRecords';
import { RecordBoardContext } from '@/object-record/record-board/contexts/RecordBoardContext';
import { RecordBoardColumnContext } from '@/object-record/record-board/record-board-column/contexts/RecordBoardColumnContext';
-import { buildRecordGqlFieldsAggregateForRecordBoard } from '@/object-record/record-board/record-board-column/utils/buildRecordGqlFieldsAggregateForRecordBoard';
-import { computeAggregateValueAndLabel } from '@/object-record/record-board/record-board-column/utils/computeAggregateValueAndLabel';
-import { useFilterValueDependencies } from '@/object-record/record-filter/hooks/useFilterValueDependencies';
-import { computeViewRecordGqlOperationFilter } from '@/object-record/record-filter/utils/computeViewRecordGqlOperationFilter';
-import { recordIndexFiltersState } from '@/object-record/record-index/states/recordIndexFiltersState';
-import { recordIndexKanbanAggregateOperationState } from '@/object-record/record-index/states/recordIndexKanbanAggregateOperationState';
import { recordIndexKanbanFieldMetadataIdState } from '@/object-record/record-index/states/recordIndexKanbanFieldMetadataIdState';
-import { recordIndexViewFilterGroupsState } from '@/object-record/record-index/states/recordIndexViewFilterGroupsState';
-import { UserContext } from '@/users/contexts/UserContext';
+import { useAggregateRecordsForHeader } from '@/object-record/record-table/hooks/useAggregateRecordsForHeader';
import { useContext } from 'react';
import { useRecoilValue } from 'recoil';
import { isDefined } from '~/utils/isDefined';
export const useAggregateRecordsForRecordBoardColumn = () => {
const { columnDefinition } = useContext(RecordBoardColumnContext);
-
const { objectMetadataItem } = useContext(RecordBoardContext);
-
- const recordIndexKanbanAggregateOperation = useRecoilValue(
- recordIndexKanbanAggregateOperationState,
- );
-
const recordIndexKanbanFieldMetadataId = useRecoilValue(
recordIndexKanbanFieldMetadataIdState,
);
@@ -37,56 +23,16 @@ export const useAggregateRecordsForRecordBoardColumn = () => {
);
}
- const recordGqlFieldsAggregate = buildRecordGqlFieldsAggregateForRecordBoard({
- objectMetadataItem: objectMetadataItem,
- recordIndexKanbanAggregateOperation: recordIndexKanbanAggregateOperation,
- kanbanFieldName: kanbanFieldName,
- });
-
- const recordIndexViewFilterGroups = useRecoilValue(
- recordIndexViewFilterGroupsState,
- );
-
- const recordIndexFilters = useRecoilValue(recordIndexFiltersState);
-
- const { filterValueDependencies } = useFilterValueDependencies();
-
- const requestFilters = computeViewRecordGqlOperationFilter(
- filterValueDependencies,
- recordIndexFilters,
- objectMetadataItem.fields,
- recordIndexViewFilterGroups,
- );
-
- const filter = {
- ...requestFilters,
+ const additionalFilters = {
[kanbanFieldName]:
columnDefinition.value === null
? { is: 'NULL' }
: { eq: columnDefinition.value },
};
- const { data } = useAggregateRecords({
- objectNameSingular: objectMetadataItem.nameSingular,
- recordGqlFieldsAggregate,
- filter,
- });
-
- const { dateFormat, timeFormat, timeZone } = useContext(UserContext);
-
- const { value, labelWithFieldName } = computeAggregateValueAndLabel({
- data,
+ return useAggregateRecordsForHeader({
objectMetadataItem,
- fieldMetadataId: recordIndexKanbanAggregateOperation?.fieldMetadataId,
- aggregateOperation: recordIndexKanbanAggregateOperation?.operation,
+ additionalFilters,
fallbackFieldName: kanbanFieldName,
- dateFormat,
- timeFormat,
- timeZone,
});
-
- return {
- aggregateValue: value,
- aggregateLabel: isDefined(value) ? labelWithFieldName : undefined,
- };
};
diff --git a/packages/twenty-front/src/modules/object-record/record-board/record-board-column/utils/__tests__/buildRecordGqlFieldsAggregateForRecordBoard.test.ts b/packages/twenty-front/src/modules/object-record/record-board/record-board-column/utils/__tests__/buildRecordGqlFieldsAggregateForView.test.ts
similarity index 84%
rename from packages/twenty-front/src/modules/object-record/record-board/record-board-column/utils/__tests__/buildRecordGqlFieldsAggregateForRecordBoard.test.ts
rename to packages/twenty-front/src/modules/object-record/record-board/record-board-column/utils/__tests__/buildRecordGqlFieldsAggregateForView.test.ts
index 2afbf90e0..45a3db3a8 100644
--- a/packages/twenty-front/src/modules/object-record/record-board/record-board-column/utils/__tests__/buildRecordGqlFieldsAggregateForRecordBoard.test.ts
+++ b/packages/twenty-front/src/modules/object-record/record-board/record-board-column/utils/__tests__/buildRecordGqlFieldsAggregateForView.test.ts
@@ -1,6 +1,6 @@
import { FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
-import { buildRecordGqlFieldsAggregateForRecordBoard } from '@/object-record/record-board/record-board-column/utils/buildRecordGqlFieldsAggregateForRecordBoard';
+import { buildRecordGqlFieldsAggregateForView } from '@/object-record/record-board/record-board-column/utils/buildRecordGqlFieldsAggregateForView';
import { KanbanAggregateOperation } from '@/object-record/record-index/states/recordIndexKanbanAggregateOperationState';
import { AGGREGATE_OPERATIONS } from '@/object-record/record-table/constants/AggregateOperations';
import { FieldMetadataType } from '~/generated-metadata/graphql';
@@ -8,7 +8,7 @@ import { FieldMetadataType } from '~/generated-metadata/graphql';
const MOCK_FIELD_ID = '7d2d7b5e-7b3e-4b4a-8b0a-7b3e4b4a8b0a';
const MOCK_KANBAN_FIELD = 'stage';
-describe('buildRecordGqlFieldsAggregateForRecordBoard', () => {
+describe('buildRecordGqlFieldsAggregateForView', () => {
const mockObjectMetadata: ObjectMetadataItem = {
id: '123',
nameSingular: 'opportunity',
@@ -50,10 +50,10 @@ describe('buildRecordGqlFieldsAggregateForRecordBoard', () => {
operation: AGGREGATE_OPERATIONS.sum,
};
- const result = buildRecordGqlFieldsAggregateForRecordBoard({
+ const result = buildRecordGqlFieldsAggregateForView({
objectMetadataItem: mockObjectMetadata,
recordIndexKanbanAggregateOperation: kanbanAggregateOperation,
- kanbanFieldName: MOCK_KANBAN_FIELD,
+ fieldNameForCount: MOCK_KANBAN_FIELD,
});
expect(result).toEqual({
@@ -67,10 +67,10 @@ describe('buildRecordGqlFieldsAggregateForRecordBoard', () => {
operation: AGGREGATE_OPERATIONS.count,
};
- const result = buildRecordGqlFieldsAggregateForRecordBoard({
+ const result = buildRecordGqlFieldsAggregateForView({
objectMetadataItem: mockObjectMetadata,
recordIndexKanbanAggregateOperation: operation,
- kanbanFieldName: MOCK_KANBAN_FIELD,
+ fieldNameForCount: MOCK_KANBAN_FIELD,
});
expect(result).toEqual({
@@ -85,10 +85,10 @@ describe('buildRecordGqlFieldsAggregateForRecordBoard', () => {
};
expect(() =>
- buildRecordGqlFieldsAggregateForRecordBoard({
+ buildRecordGqlFieldsAggregateForView({
objectMetadataItem: mockObjectMetadata,
recordIndexKanbanAggregateOperation: operation,
- kanbanFieldName: MOCK_KANBAN_FIELD,
+ fieldNameForCount: MOCK_KANBAN_FIELD,
}),
).toThrow(
`No field found to compute aggregate operation ${AGGREGATE_OPERATIONS.sum} on object ${mockObjectMetadata.nameSingular}`,
diff --git a/packages/twenty-front/src/modules/object-record/record-board/record-board-column/utils/buildRecordGqlFieldsAggregateForRecordBoard.ts b/packages/twenty-front/src/modules/object-record/record-board/record-board-column/utils/buildRecordGqlFieldsAggregateForView.ts
similarity index 90%
rename from packages/twenty-front/src/modules/object-record/record-board/record-board-column/utils/buildRecordGqlFieldsAggregateForRecordBoard.ts
rename to packages/twenty-front/src/modules/object-record/record-board/record-board-column/utils/buildRecordGqlFieldsAggregateForView.ts
index 7a3f44a04..0c0b1b82d 100644
--- a/packages/twenty-front/src/modules/object-record/record-board/record-board-column/utils/buildRecordGqlFieldsAggregateForRecordBoard.ts
+++ b/packages/twenty-front/src/modules/object-record/record-board/record-board-column/utils/buildRecordGqlFieldsAggregateForView.ts
@@ -4,14 +4,14 @@ import { KanbanAggregateOperation } from '@/object-record/record-index/states/re
import { AGGREGATE_OPERATIONS } from '@/object-record/record-table/constants/AggregateOperations';
import { isDefined } from '~/utils/isDefined';
-export const buildRecordGqlFieldsAggregateForRecordBoard = ({
+export const buildRecordGqlFieldsAggregateForView = ({
objectMetadataItem,
recordIndexKanbanAggregateOperation,
- kanbanFieldName,
+ fieldNameForCount,
}: {
objectMetadataItem: ObjectMetadataItem;
recordIndexKanbanAggregateOperation: KanbanAggregateOperation;
- kanbanFieldName: string;
+ fieldNameForCount: string;
}): RecordGqlFieldsAggregate => {
let recordGqlFieldsAggregate = {};
@@ -31,7 +31,7 @@ export const buildRecordGqlFieldsAggregateForRecordBoard = ({
);
} else {
recordGqlFieldsAggregate = {
- [kanbanFieldName]: [AGGREGATE_OPERATIONS.count],
+ [fieldNameForCount]: [AGGREGATE_OPERATIONS.count],
};
}
} else {
diff --git a/packages/twenty-front/src/modules/object-record/record-table/hooks/useAggregateRecordsForHeader.ts b/packages/twenty-front/src/modules/object-record/record-table/hooks/useAggregateRecordsForHeader.ts
new file mode 100644
index 000000000..42d5cff5d
--- /dev/null
+++ b/packages/twenty-front/src/modules/object-record/record-table/hooks/useAggregateRecordsForHeader.ts
@@ -0,0 +1,74 @@
+import { 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 { useFilterValueDependencies } from '@/object-record/record-filter/hooks/useFilterValueDependencies';
+import { computeViewRecordGqlOperationFilter } from '@/object-record/record-filter/utils/computeViewRecordGqlOperationFilter';
+import { recordIndexFiltersState } from '@/object-record/record-index/states/recordIndexFiltersState';
+import { recordIndexKanbanAggregateOperationState } from '@/object-record/record-index/states/recordIndexKanbanAggregateOperationState';
+import { recordIndexViewFilterGroupsState } from '@/object-record/record-index/states/recordIndexViewFilterGroupsState';
+import { UserContext } from '@/users/contexts/UserContext';
+import { useContext } from 'react';
+import { useRecoilValue } from 'recoil';
+import { isDefined } from '~/utils/isDefined';
+
+type UseAggregateRecordsProps = {
+ objectMetadataItem: ObjectMetadataItem;
+ additionalFilters?: Record;
+ fallbackFieldName: string;
+};
+
+export const useAggregateRecordsForHeader = ({
+ objectMetadataItem,
+ additionalFilters = {},
+ fallbackFieldName,
+}: UseAggregateRecordsProps) => {
+ const recordIndexViewFilterGroups = useRecoilValue(
+ recordIndexViewFilterGroupsState,
+ );
+
+ const recordIndexFilters = useRecoilValue(recordIndexFiltersState);
+
+ const recordIndexKanbanAggregateOperation = useRecoilValue(
+ recordIndexKanbanAggregateOperationState,
+ );
+
+ const { filterValueDependencies } = useFilterValueDependencies();
+
+ const { dateFormat, timeFormat, timeZone } = useContext(UserContext);
+
+ const requestFilters = computeViewRecordGqlOperationFilter(
+ filterValueDependencies,
+ recordIndexFilters,
+ objectMetadataItem.fields,
+ recordIndexViewFilterGroups,
+ );
+
+ const recordGqlFieldsAggregate = buildRecordGqlFieldsAggregateForView({
+ objectMetadataItem,
+ recordIndexKanbanAggregateOperation,
+ fieldNameForCount: fallbackFieldName,
+ });
+
+ const { data } = useAggregateRecords({
+ objectNameSingular: objectMetadataItem.nameSingular,
+ recordGqlFieldsAggregate,
+ filter: { ...requestFilters, ...additionalFilters },
+ });
+
+ const { value, labelWithFieldName } = computeAggregateValueAndLabel({
+ data,
+ objectMetadataItem,
+ fieldMetadataId: recordIndexKanbanAggregateOperation?.fieldMetadataId,
+ aggregateOperation: recordIndexKanbanAggregateOperation?.operation,
+ fallbackFieldName,
+ dateFormat,
+ timeFormat,
+ timeZone,
+ });
+
+ return {
+ aggregateValue: value,
+ aggregateLabel: isDefined(value) ? labelWithFieldName : undefined,
+ };
+};
diff --git a/packages/twenty-front/src/modules/object-record/record-table/record-table-section/components/RecordTableRecordGroupSection.tsx b/packages/twenty-front/src/modules/object-record/record-table/record-table-section/components/RecordTableRecordGroupSection.tsx
index 7503b4a50..1d192d690 100644
--- a/packages/twenty-front/src/modules/object-record/record-table/record-table-section/components/RecordTableRecordGroupSection.tsx
+++ b/packages/twenty-front/src/modules/object-record/record-table/record-table-section/components/RecordTableRecordGroupSection.tsx
@@ -8,16 +8,17 @@ import {
Tag,
} from 'twenty-ui';
+import { RecordBoardColumnHeaderAggregateDropdown } from '@/object-record/record-board/record-board-column/components/RecordBoardColumnHeaderAggregateDropdown';
import { useCurrentRecordGroupId } from '@/object-record/record-group/hooks/useCurrentRecordGroupId';
import { recordGroupDefinitionFamilyState } from '@/object-record/record-group/states/recordGroupDefinitionFamilyState';
import { RecordGroupDefinitionType } from '@/object-record/record-group/types/RecordGroupDefinition';
-import { recordIndexRecordIdsByGroupComponentFamilyState } from '@/object-record/record-index/states/recordIndexRecordIdsByGroupComponentFamilyState';
+import { useRecordTableContextOrThrow } from '@/object-record/record-table/contexts/RecordTableContext';
import { RecordTableTd } from '@/object-record/record-table/record-table-cell/components/RecordTableTd';
import { RecordTableRecordGroupStickyEffect } from '@/object-record/record-table/record-table-section/components/RecordTableRecordGroupStickyEffect';
+import { useAggregateRecordsForRecordTableSection } from '@/object-record/record-table/record-table-section/hooks/useAggregateRecordsForRecordTableSection';
import { isRecordGroupTableSectionToggledComponentState } from '@/object-record/record-table/record-table-section/states/isRecordGroupTableSectionToggledComponentState';
import { visibleTableColumnsComponentSelector } from '@/object-record/record-table/states/selectors/visibleTableColumnsComponentSelector';
import { useRecoilComponentFamilyStateV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentFamilyStateV2';
-import { useRecoilComponentFamilyValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentFamilyValueV2';
import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2';
import { useRecoilValue } from 'recoil';
@@ -37,24 +38,22 @@ const StyledAnimatedLightIconButton = styled(AnimatedLightIconButton)`
margin: auto;
`;
-const StyledTotalRow = styled.span`
- color: ${({ theme }) => theme.font.color.tertiary};
- margin-left: ${({ theme }) => theme.spacing(2)};
- text-align: center;
- vertical-align: middle;
-`;
-
const StyledRecordGroupSection = styled(RecordTableTd)`
border-right: none;
height: 32px;
display: flex;
align-items: center;
+ gap: ${({ theme }) => theme.spacing(1)};
`;
const StyledEmptyTd = styled.td`
border-bottom: 1px solid ${({ theme }) => theme.border.color.light};
`;
+const StyledTag = styled(Tag)`
+ flex-shrink: 0;
+`;
+
export const RecordTableRecordGroupSection = () => {
const theme = useTheme();
@@ -64,10 +63,10 @@ export const RecordTableRecordGroupSection = () => {
visibleTableColumnsComponentSelector,
);
- const recordIdsByGroup = useRecoilComponentFamilyValueV2(
- recordIndexRecordIdsByGroupComponentFamilyState,
- currentRecordGroupId,
- );
+ const { objectMetadataItem } = useRecordTableContextOrThrow();
+
+ const { aggregateValue, aggregateLabel } =
+ useAggregateRecordsForRecordTableSection();
const [
isRecordGroupTableSectionToggled,
@@ -102,7 +101,7 @@ export const RecordTableRecordGroupSection = () => {
/>
- {
text={recordGroup.title}
weight="medium"
/>
- {recordIdsByGroup.length}
+
diff --git a/packages/twenty-front/src/modules/object-record/record-table/record-table-section/hooks/useAggregateRecordsForRecordTableSection.tsx b/packages/twenty-front/src/modules/object-record/record-table/record-table-section/hooks/useAggregateRecordsForRecordTableSection.tsx
new file mode 100644
index 000000000..d8b20f046
--- /dev/null
+++ b/packages/twenty-front/src/modules/object-record/record-table/record-table-section/hooks/useAggregateRecordsForRecordTableSection.tsx
@@ -0,0 +1,16 @@
+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,
+ });
+};