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:
@ -10,7 +10,7 @@ const globalCoverage = {
|
||||
const modulesCoverage = {
|
||||
branches: 25,
|
||||
statements: 44,
|
||||
lines: 45,
|
||||
lines: 44,
|
||||
functions: 38,
|
||||
include: ['src/modules/**/*'],
|
||||
exclude: ['src/**/*.ts'],
|
||||
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
@ -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}`,
|
||||
@ -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 {
|
||||
@ -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,
|
||||
};
|
||||
};
|
||||
@ -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} />
|
||||
|
||||
@ -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,
|
||||
});
|
||||
};
|
||||
Reference in New Issue
Block a user