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
This commit is contained in:
Marie
2025-01-20 18:26:30 +01:00
committed by GitHub
parent 8762af050a
commit 3838fef812
8 changed files with 132 additions and 91 deletions

View File

@ -10,7 +10,7 @@ const globalCoverage = {
const modulesCoverage = {
branches: 25,
statements: 44,
lines: 45,
lines: 44,
functions: 38,
include: ['src/modules/**/*'],
exclude: ['src/**/*.ts'],

View File

@ -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 (
<div id={dropdownId}>
<StyledContainer>
<StyledHeader id={dropdownId} isUnfolded={isDropdownOpen}>
<>
<StyledTag
text={value ? value.toString() : '-'}
color={'transparent'}
@ -39,7 +40,7 @@ export const RecordBoardColumnHeaderAggregateDropdownButton = ({
delay={TooltipDelay.mediumDelay}
/>
)}
</StyledContainer>
</div>
</>
</StyledHeader>
);
};

View File

@ -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,
};
};

View File

@ -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}`,

View File

@ -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 {

View File

@ -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<string, unknown>;
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,
};
};

View File

@ -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 = () => {
/>
</StyledChevronContainer>
<StyledRecordGroupSection className="disable-shadow">
<Tag
<StyledTag
variant={
recordGroup.type !== RecordGroupDefinitionType.NoValue
? 'solid'
@ -116,7 +115,12 @@ export const RecordTableRecordGroupSection = () => {
text={recordGroup.title}
weight="medium"
/>
<StyledTotalRow>{recordIdsByGroup.length}</StyledTotalRow>
<RecordBoardColumnHeaderAggregateDropdown
aggregateValue={aggregateValue}
dropdownId={`record-group-section-aggregate-dropdown-${currentRecordGroupId}`}
objectMetadataItem={objectMetadataItem}
aggregateLabel={aggregateLabel}
/>
<RecordTableRecordGroupStickyEffect />
</StyledRecordGroupSection>
<StyledEmptyTd colSpan={visibleColumns.length - 1} />

View File

@ -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,
});
};