[Aggregate queries for table views - #2] Add aggregate queries footer for simple views (#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)
This commit is contained in:
Marie 2024-12-12 13:38:58 +01:00 committed by GitHub
parent 5f2a39d9e9
commit 05cd0d1803
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
20 changed files with 662 additions and 62 deletions

View File

@ -1,7 +1,7 @@
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 { buildRecordGqlFieldsAggregate } from '@/object-record/record-board/record-board-column/utils/buildRecordGqlFieldsAggregate';
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 { computeViewRecordGqlOperationFilter } from '@/object-record/record-filter/utils/computeViewRecordGqlOperationFilter';
import { recordIndexFiltersState } from '@/object-record/record-index/states/recordIndexFiltersState';
@ -42,7 +42,7 @@ export const useAggregateRecordsForRecordBoardColumn = () => {
);
}
const recordGqlFieldsAggregate = buildRecordGqlFieldsAggregate({
const recordGqlFieldsAggregate = buildRecordGqlFieldsAggregateForRecordBoard({
objectMetadataItem: objectMetadataItem,
recordIndexKanbanAggregateOperation: recordIndexKanbanAggregateOperation,
kanbanFieldName: kanbanFieldName,
@ -74,12 +74,13 @@ export const useAggregateRecordsForRecordBoardColumn = () => {
skip: !isAggregateQueryEnabled,
});
const { value, label } = computeAggregateValueAndLabel(
const { value, label } = computeAggregateValueAndLabel({
data,
objectMetadataItem,
recordIndexKanbanAggregateOperation,
kanbanFieldName,
);
fieldMetadataId: recordIndexKanbanAggregateOperation?.fieldMetadataId,
aggregateOperation: recordIndexKanbanAggregateOperation?.operation,
fallbackFieldName: kanbanFieldName,
});
return {
aggregateValue: isAggregateQueryEnabled ? value : recordCount,

View File

@ -1,6 +1,6 @@
import { FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { buildRecordGqlFieldsAggregate } from '@/object-record/record-board/record-board-column/utils/buildRecordGqlFieldsAggregate';
import { buildRecordGqlFieldsAggregateForRecordBoard } from '@/object-record/record-board/record-board-column/utils/buildRecordGqlFieldsAggregateForRecordBoard';
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('buildRecordGqlFieldsAggregate', () => {
describe('buildRecordGqlFieldsAggregateForRecordBoard', () => {
const mockObjectMetadata: ObjectMetadataItem = {
id: '123',
nameSingular: 'opportunity',
@ -50,7 +50,7 @@ describe('buildRecordGqlFieldsAggregate', () => {
operation: AGGREGATE_OPERATIONS.sum,
};
const result = buildRecordGqlFieldsAggregate({
const result = buildRecordGqlFieldsAggregateForRecordBoard({
objectMetadataItem: mockObjectMetadata,
recordIndexKanbanAggregateOperation: kanbanAggregateOperation,
kanbanFieldName: MOCK_KANBAN_FIELD,
@ -67,7 +67,7 @@ describe('buildRecordGqlFieldsAggregate', () => {
operation: AGGREGATE_OPERATIONS.count,
};
const result = buildRecordGqlFieldsAggregate({
const result = buildRecordGqlFieldsAggregateForRecordBoard({
objectMetadataItem: mockObjectMetadata,
recordIndexKanbanAggregateOperation: operation,
kanbanFieldName: MOCK_KANBAN_FIELD,
@ -85,7 +85,7 @@ describe('buildRecordGqlFieldsAggregate', () => {
};
expect(() =>
buildRecordGqlFieldsAggregate({
buildRecordGqlFieldsAggregateForRecordBoard({
objectMetadataItem: mockObjectMetadata,
recordIndexKanbanAggregateOperation: operation,
kanbanFieldName: MOCK_KANBAN_FIELD,

View File

@ -5,7 +5,7 @@ import { AGGREGATE_OPERATIONS } from '@/object-record/record-table/constants/Agg
import { FieldMetadataType } from '~/generated/graphql';
const MOCK_FIELD_ID = '7d2d7b5e-7b3e-4b4a-8b0a-7b3e4b4a8b0a';
const MOCK_KANBAN_FIELD = 'stage';
const MOCK_KANBAN_FIELD_NAME = 'stage';
describe('computeAggregateValueAndLabel', () => {
const mockObjectMetadata: ObjectMetadataItem = {
@ -20,12 +20,13 @@ describe('computeAggregateValueAndLabel', () => {
} as ObjectMetadataItem;
it('should return empty object for empty data', () => {
const result = computeAggregateValueAndLabel(
{},
mockObjectMetadata,
{ fieldMetadataId: MOCK_FIELD_ID, operation: AGGREGATE_OPERATIONS.sum },
MOCK_KANBAN_FIELD,
);
const result = computeAggregateValueAndLabel({
data: {},
objectMetadataItem: mockObjectMetadata,
fieldMetadataId: MOCK_FIELD_ID,
aggregateOperation: AGGREGATE_OPERATIONS.sum,
fallbackFieldName: MOCK_KANBAN_FIELD_NAME,
});
expect(result).toEqual({});
});
@ -37,12 +38,13 @@ describe('computeAggregateValueAndLabel', () => {
},
};
const result = computeAggregateValueAndLabel(
mockData,
mockObjectMetadata,
{ fieldMetadataId: MOCK_FIELD_ID, operation: AGGREGATE_OPERATIONS.sum },
MOCK_KANBAN_FIELD,
);
const result = computeAggregateValueAndLabel({
data: mockData,
objectMetadataItem: mockObjectMetadata,
fieldMetadataId: MOCK_FIELD_ID,
aggregateOperation: AGGREGATE_OPERATIONS.sum,
fallbackFieldName: MOCK_KANBAN_FIELD_NAME,
});
expect(result).toEqual({
value: 2,
@ -52,17 +54,16 @@ describe('computeAggregateValueAndLabel', () => {
it('should default to count when field not found', () => {
const mockData = {
[MOCK_KANBAN_FIELD]: {
[MOCK_KANBAN_FIELD_NAME]: {
[AGGREGATE_OPERATIONS.count]: 42,
},
};
const result = computeAggregateValueAndLabel(
mockData,
mockObjectMetadata,
{ fieldMetadataId: 'non-existent', operation: AGGREGATE_OPERATIONS.sum },
MOCK_KANBAN_FIELD,
);
const result = computeAggregateValueAndLabel({
data: mockData,
objectMetadataItem: mockObjectMetadata,
fallbackFieldName: MOCK_KANBAN_FIELD_NAME,
});
expect(result).toEqual({
value: 42,
@ -77,12 +78,12 @@ describe('computeAggregateValueAndLabel', () => {
},
};
const result = computeAggregateValueAndLabel(
mockData,
mockObjectMetadata,
{ fieldMetadataId: MOCK_FIELD_ID, operation: AGGREGATE_OPERATIONS.sum },
MOCK_KANBAN_FIELD,
);
const result = computeAggregateValueAndLabel({
data: mockData,
objectMetadataItem: mockObjectMetadata,
fieldMetadataId: MOCK_FIELD_ID,
aggregateOperation: AGGREGATE_OPERATIONS.sum,
});
expect(result).toEqual({
value: undefined,

View File

@ -4,7 +4,7 @@ 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 buildRecordGqlFieldsAggregate = ({
export const buildRecordGqlFieldsAggregateForRecordBoard = ({
objectMetadataItem,
recordIndexKanbanAggregateOperation,
kanbanFieldName,

View File

@ -1,51 +1,59 @@
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { AggregateRecordsData } from '@/object-record/hooks/useAggregateRecords';
import { getAggregateOperationLabel } from '@/object-record/record-board/record-board-column/utils/getAggregateOperationLabel';
import { KanbanAggregateOperation } from '@/object-record/record-index/states/recordIndexKanbanAggregateOperationState';
import { AGGREGATE_OPERATIONS } from '@/object-record/record-table/constants/AggregateOperations';
import isEmpty from 'lodash.isempty';
import { FieldMetadataType } from '~/generated-metadata/graphql';
import { isDefined } from '~/utils/isDefined';
export const computeAggregateValueAndLabel = (
data: AggregateRecordsData,
objectMetadataItem: ObjectMetadataItem,
recordIndexKanbanAggregateOperation: KanbanAggregateOperation,
kanbanFieldName: string,
) => {
export const computeAggregateValueAndLabel = ({
data,
objectMetadataItem,
fieldMetadataId,
aggregateOperation,
fallbackFieldName,
}: {
data: AggregateRecordsData;
objectMetadataItem: ObjectMetadataItem;
fieldMetadataId?: string | null;
aggregateOperation?: AGGREGATE_OPERATIONS | null;
fallbackFieldName?: string;
}) => {
if (isEmpty(data)) {
return {};
}
const kanbanAggregateOperationField = objectMetadataItem.fields?.find(
(field) =>
field.id === recordIndexKanbanAggregateOperation?.fieldMetadataId,
const field = objectMetadataItem.fields?.find(
(field) => field.id === fieldMetadataId,
);
const kanbanAggregateOperationFieldName = kanbanAggregateOperationField?.name;
if (
!isDefined(kanbanAggregateOperationFieldName) ||
!isDefined(recordIndexKanbanAggregateOperation?.operation)
) {
if (!isDefined(field)) {
if (!fallbackFieldName) {
throw new Error('Missing fallback field name');
}
return {
value: data?.[kanbanFieldName]?.[AGGREGATE_OPERATIONS.count],
value: data?.[fallbackFieldName]?.[AGGREGATE_OPERATIONS.count],
label: `${getAggregateOperationLabel(AGGREGATE_OPERATIONS.count)}`,
};
}
const aggregateValue =
data[kanbanAggregateOperationFieldName]?.[
recordIndexKanbanAggregateOperation.operation
];
if (!isDefined(aggregateOperation)) {
throw new Error('Missing aggregate operation');
}
const aggregateValue = data[field.name]?.[aggregateOperation];
const value =
isDefined(aggregateValue) &&
kanbanAggregateOperationField?.type === FieldMetadataType.Currency
isDefined(aggregateValue) && field.type === FieldMetadataType.Currency
? Number(aggregateValue) / 1_000_000
: aggregateValue;
const label =
aggregateOperation === AGGREGATE_OPERATIONS.count
? `${getAggregateOperationLabel(AGGREGATE_OPERATIONS.count)}`
: `${getAggregateOperationLabel(aggregateOperation)} of ${field.name}`;
return {
value,
label: `${getAggregateOperationLabel(recordIndexKanbanAggregateOperation.operation)} of ${kanbanAggregateOperationFieldName}`,
label,
};
};

View File

@ -13,12 +13,14 @@ import { RecordTableNoRecordGroupBody } from '@/object-record/record-table/recor
import { RecordTableNoRecordGroupBodyEffect } from '@/object-record/record-table/record-table-body/components/RecordTableNoRecordGroupBodyEffect';
import { RecordTableRecordGroupBodyEffects } from '@/object-record/record-table/record-table-body/components/RecordTableRecordGroupBodyEffects';
import { RecordTableRecordGroupsBody } from '@/object-record/record-table/record-table-body/components/RecordTableRecordGroupsBody';
import { RecordTableFooter } from '@/object-record/record-table/record-table-footer/components/RecordTableFooter';
import { RecordTableHeader } from '@/object-record/record-table/record-table-header/components/RecordTableHeader';
import { isRecordTableInitialLoadingComponentState } from '@/object-record/record-table/states/isRecordTableInitialLoadingComponentState';
import { recordTablePendingRecordIdComponentState } from '@/object-record/record-table/states/recordTablePendingRecordIdComponentState';
import { DragSelect } from '@/ui/utilities/drag-select/components/DragSelect';
import { useClickOutsideListener } from '@/ui/utilities/pointer-event/hooks/useClickOutsideListener';
import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2';
import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
import { useRef } from 'react';
const StyledTable = styled.table`
@ -33,6 +35,10 @@ export const RecordTable = () => {
const tableBodyRef = useRef<HTMLTableElement>(null);
const isAggregateQueryEnabled = useIsFeatureEnabled(
'IS_AGGREGATE_QUERY_ENABLED',
);
const { toggleClickOutsideListener } = useClickOutsideListener(
RECORD_TABLE_CLICK_OUTSIDE_LISTENER_ID,
);
@ -90,6 +96,7 @@ export const RecordTable = () => {
<RecordTableRecordGroupsBody />
)}
<RecordTableStickyEffect />
{isAggregateQueryEnabled && <RecordTableFooter />}
</StyledTable>
<DragSelect
dragSelectable={tableBodyRef}

View File

@ -35,6 +35,9 @@ export const RecordTableStickyEffect = () => {
document
.getElementById('record-table-header')
?.classList.add('first-columns-sticky');
document
.getElementById('record-table-footer')
?.classList.add('first-columns-sticky');
} else {
document
.getElementById('record-table-body')
@ -42,6 +45,9 @@ export const RecordTableStickyEffect = () => {
document
.getElementById('record-table-header')
?.classList.remove('first-columns-sticky');
document
.getElementById('record-table-footer')
?.classList.remove('first-columns-sticky');
}
}, [scrollLeft, setIsRecordTableScrolledLeft]);

View File

@ -0,0 +1,92 @@
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { useState } from 'react';
import {
AppTooltip,
IconChevronDown,
isDefined,
TooltipDelay,
} from 'twenty-ui';
const StyledCell = styled.div`
align-items: center;
display: flex;
flex-direction: row;
flex-shrink: 0;
font-weight: ${({ theme }) => theme.font.weight.medium};
gap: ${({ theme }) => theme.spacing(1)};
height: ${({ theme }) => theme.spacing(7)};
justify-content: space-between;
min-width: ${({ theme }) => theme.spacing(7)};
flex-grow: 1;
width: 100%;
`;
const StyledText = styled.span`
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
display: flex;
height: 20px;
align-items: center;
gap: 4px;
flex-grow: 1;
padding-left: ${({ theme }) => theme.spacing(2)};
z-index: 1;
`;
const StyledIcon = styled(IconChevronDown)`
align-items: center;
display: flex;
height: 20px;
justify-content: center;
flex-grow: 0;
padding-right: ${({ theme }) => theme.spacing(2)};
`;
export const RecordTableColumnFooterAggregateValue = ({
dropdownId,
aggregateValue,
aggregateLabel,
}: {
dropdownId: string;
aggregateValue?: string | number | null;
aggregateLabel?: string;
}) => {
const [isHovered, setIsHovered] = useState(false);
const sanitizedId = `tooltip-${dropdownId.replace(/[^a-zA-Z0-9-_]/g, '-')}`;
const theme = useTheme();
return (
<div
onMouseEnter={() => {
setIsHovered(true);
}}
onMouseLeave={() => setIsHovered(false)}
>
<StyledCell>
{isHovered || isDefined(aggregateValue) ? (
<>
<StyledText id={sanitizedId}>
{aggregateValue ?? 'Calculate'}
</StyledText>
<StyledIcon fontWeight={'light'} size={theme.icon.size.sm} />
{aggregateValue && isDefined(aggregateLabel) && (
<AppTooltip
anchorSelect={`#${sanitizedId}`}
content={aggregateLabel}
noArrow
place="top-start"
positionStrategy="fixed"
delay={TooltipDelay.shortDelay}
/>
)}
</>
) : (
<></>
)}
</StyledCell>
</div>
);
};

View File

@ -0,0 +1,77 @@
import { getAggregateOperationLabel } from '@/object-record/record-board/record-board-column/utils/getAggregateOperationLabel';
import { FieldMetadata } from '@/object-record/record-field/types/FieldMetadata';
import { AGGREGATE_OPERATIONS } from '@/object-record/record-table/constants/AggregateOperations';
import { useRecordTableContextOrThrow } from '@/object-record/record-table/contexts/RecordTableContext';
import { getAvailableAggregateOperationsForFieldMetadataType } from '@/object-record/record-table/record-table-footer/utils/getAvailableAggregateOperationsForFieldMetadataType';
import { ColumnDefinition } from '@/object-record/record-table/types/ColumnDefinition';
import { TableOptionsHotkeyScope } from '@/object-record/record-table/types/TableOptionsHotkeyScope';
import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer';
import { useDropdown } from '@/ui/layout/dropdown/hooks/useDropdown';
import { useScopedHotkeys } from '@/ui/utilities/hotkey/hooks/useScopedHotkeys';
import { usePersistViewFieldRecords } from '@/views/hooks/internal/usePersistViewFieldRecords';
import { useGetCurrentView } from '@/views/hooks/useGetCurrentView';
import { useMemo } from 'react';
import { Key } from 'ts-key-enum';
import { MenuItem } from 'twenty-ui';
export const RecordTableColumnFooterDropdown = ({
column,
}: {
column: ColumnDefinition<FieldMetadata>;
}) => {
const { closeDropdown } = useDropdown(column.fieldMetadataId + '-footer');
const { objectMetadataItem } = useRecordTableContextOrThrow();
const { currentViewWithSavedFiltersAndSorts } = useGetCurrentView();
const currentViewField =
currentViewWithSavedFiltersAndSorts?.viewFields?.find(
(viewField) => viewField.fieldMetadataId === column.fieldMetadataId,
);
if (!currentViewField) {
throw new Error('ViewField not found');
}
useScopedHotkeys(
[Key.Escape],
() => {
closeDropdown();
},
TableOptionsHotkeyScope.Dropdown,
);
const availableAggregateOperations = useMemo(
() =>
getAvailableAggregateOperationsForFieldMetadataType({
fieldMetadataType: objectMetadataItem.fields.find(
(field) => field.id === column.fieldMetadataId,
)?.type,
}),
[column.fieldMetadataId, objectMetadataItem.fields],
);
const { updateViewFieldRecords } = usePersistViewFieldRecords();
const handleAggregationChange = (
aggregateOperation: AGGREGATE_OPERATIONS,
) => {
updateViewFieldRecords([
{ ...currentViewField, aggregateOperation: aggregateOperation },
]);
};
return (
<>
<DropdownMenuItemsContainer>
{availableAggregateOperations.map((aggregation) => (
<MenuItem
key={aggregation}
onClick={() => {
handleAggregationChange(aggregation);
}}
text={getAggregateOperationLabel(aggregation)}
/>
))}
</DropdownMenuItemsContainer>
</>
);
};

View File

@ -0,0 +1,68 @@
import { FieldMetadata } from '@/object-record/record-field/types/FieldMetadata';
import { RecordTableColumnFooterAggregateValue } from '@/object-record/record-table/record-table-footer/components/RecordTableColumnFooterAggregateValue';
import { RecordTableColumnFooterDropdown } from '@/object-record/record-table/record-table-footer/components/RecordTableColumnFooterDropdown';
import { useAggregateRecordsForRecordTableColumnFooter } from '@/object-record/record-table/record-table-footer/hooks/useAggregateRecordsForRecordTableColumnFooter';
import { isScrollEnabledForRecordTableState } from '@/object-record/record-table/states/isScrollEnabledForRecordTableState';
import { ColumnDefinition } from '@/object-record/record-table/types/ColumnDefinition';
import { Dropdown } from '@/ui/layout/dropdown/components/Dropdown';
import { useSetRecoilComponentStateV2 } from '@/ui/utilities/state/component-state/hooks/useSetRecoilComponentStateV2';
import styled from '@emotion/styled';
import { useCallback } from 'react';
type RecordTableColumnFooterWithDropdownProps = {
column: ColumnDefinition<FieldMetadata>;
};
const StyledDropdown = styled(Dropdown)`
display: flex;
flex: 1;
z-index: ${({ theme }) => theme.lastLayerZIndex};
transition: opacity 150ms ease-in-out;
`;
export const RecordTableColumnFooterWithDropdown = ({
column,
}: RecordTableColumnFooterWithDropdownProps) => {
const setIsScrollEnabledForRecordTable = useSetRecoilComponentStateV2(
isScrollEnabledForRecordTableState,
);
const handleDropdownOpen = useCallback(() => {
setIsScrollEnabledForRecordTable({
enableXScroll: false,
enableYScroll: false,
});
}, [setIsScrollEnabledForRecordTable]);
const handleDropdownClose = useCallback(() => {
setIsScrollEnabledForRecordTable({
enableXScroll: true,
enableYScroll: true,
});
}, [setIsScrollEnabledForRecordTable]);
const { aggregateValue, aggregateLabel } =
useAggregateRecordsForRecordTableColumnFooter(column.fieldMetadataId);
const dropdownId = column.fieldMetadataId + '-footer';
return (
<StyledDropdown
onOpen={handleDropdownOpen}
onClose={handleDropdownClose}
dropdownId={dropdownId}
clickableComponent={
<RecordTableColumnFooterAggregateValue
aggregateLabel={aggregateLabel}
aggregateValue={aggregateValue}
dropdownId={dropdownId}
/>
}
dropdownComponents={<RecordTableColumnFooterDropdown column={column} />}
dropdownOffset={{ x: -1 }}
dropdownPlacement="bottom-start"
dropdownHotkeyScope={{ scope: dropdownId }}
/>
);
};

View File

@ -0,0 +1,97 @@
import styled from '@emotion/styled';
import { MOBILE_VIEWPORT } from 'twenty-ui';
import { RecordTableFooterCell } from '@/object-record/record-table/record-table-footer/components/RecordTableFooterCell';
import { visibleTableColumnsComponentSelector } from '@/object-record/record-table/states/selectors/visibleTableColumnsComponentSelector';
import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2';
const StyledTableFoot = styled.thead`
cursor: pointer;
th:nth-of-type(1) {
width: 9px;
left: 0;
border-right-color: ${({ theme }) => theme.background.primary};
}
th:nth-of-type(2) {
border-right-color: ${({ theme }) => theme.background.primary};
}
&.first-columns-sticky {
th:nth-of-type(1) {
position: sticky;
left: 0;
z-index: 5;
transition: 0.3s ease;
}
th:nth-of-type(2) {
position: sticky;
left: 11px;
z-index: 5;
transition: 0.3s ease;
}
th:nth-of-type(3) {
position: sticky;
left: 43px;
z-index: 5;
transition: 0.3s ease;
&::after {
content: '';
position: absolute;
top: -1px;
height: calc(100% + 2px);
width: 4px;
right: 0px;
box-shadow: ${({ theme }) => theme.boxShadow.light};
clip-path: inset(0px -4px 0px 0px);
}
@media (max-width: ${MOBILE_VIEWPORT}px) {
width: 34px;
max-width: 34px;
}
}
}
&.header-sticky {
th {
position: sticky;
top: 0;
z-index: 5;
}
}
&.header-sticky.first-columns-sticky {
th:nth-of-type(1),
th:nth-of-type(2),
th:nth-of-type(3) {
z-index: 10;
}
}
`;
const StyledDiv = styled.div`
width: 30px;
`;
export const RecordTableFooter = () => {
const visibleTableColumns = useRecoilComponentValueV2(
visibleTableColumnsComponentSelector,
);
return (
<StyledTableFoot id="record-table-footer" data-select-disable>
<tr>
<th />
<StyledDiv />
{visibleTableColumns.map((column) => (
<RecordTableFooterCell key={column.fieldMetadataId} column={column} />
))}
</tr>
</StyledTableFoot>
);
};

View File

@ -0,0 +1,89 @@
import styled from '@emotion/styled';
import { useMemo } from 'react';
import { FieldMetadata } from '@/object-record/record-field/types/FieldMetadata';
import { RecordTableColumnFooterWithDropdown } from '@/object-record/record-table/record-table-footer/components/RecordTableColumnFooterWithDropdown';
import { tableColumnsComponentState } from '@/object-record/record-table/states/tableColumnsComponentState';
import { ColumnDefinition } from '@/object-record/record-table/types/ColumnDefinition';
import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2';
import { mapArrayToObject } from '~/utils/array/mapArrayToObject';
const COLUMN_MIN_WIDTH = 104;
const StyledColumnFooterCell = styled.th<{
columnWidth: number;
isResizing?: boolean;
}>`
color: ${({ theme }) => theme.font.color.tertiary};
padding: 0;
text-align: left;
transition: 0.3s ease;
background-color: ${({ theme }) => theme.background.primary};
${({ columnWidth }) => `
min-width: ${columnWidth}px;
width: ${columnWidth}px;
`}
position: relative;
user-select: none;
${({ theme }) => {
return `
&:hover {
background: ${theme.background.secondary};
};
&:active {
background: ${theme.background.tertiary};
};
`;
}};
${({ isResizing, theme }) => {
if (isResizing === true) {
return `&:after {
background-color: ${theme.color.blue};
bottom: 0;
content: '';
display: block;
position: absolute;
right: -1px;
top: 0;
width: 2px;
}`;
}
}};
// TODO: refactor this, each component should own its CSS
overflow: auto;
`;
const StyledColumnFootContainer = styled.div`
position: relative;
z-index: 1;
width: 100%;
`;
export const RecordTableFooterCell = ({
column,
}: {
column: ColumnDefinition<FieldMetadata>;
}) => {
const tableColumns = useRecoilComponentValueV2(tableColumnsComponentState);
const tableColumnsByKey = useMemo(
() =>
mapArrayToObject(tableColumns, ({ fieldMetadataId }) => fieldMetadataId),
[tableColumns],
);
return (
<StyledColumnFooterCell
key={column.fieldMetadataId}
columnWidth={Math.max(
tableColumnsByKey[column.fieldMetadataId].size + 24,
COLUMN_MIN_WIDTH,
)}
>
<StyledColumnFootContainer>
<RecordTableColumnFooterWithDropdown column={column} />
</StyledColumnFootContainer>
</StyledColumnFooterCell>
);
};

View File

@ -0,0 +1,75 @@
import { useAggregateRecords } from '@/object-record/hooks/useAggregateRecords';
import { computeAggregateValueAndLabel } from '@/object-record/record-board/record-board-column/utils/computeAggregateValueAndLabel';
import { computeViewRecordGqlOperationFilter } from '@/object-record/record-filter/utils/computeViewRecordGqlOperationFilter';
import { recordIndexFiltersState } from '@/object-record/record-index/states/recordIndexFiltersState';
import { recordIndexViewFilterGroupsState } from '@/object-record/record-index/states/recordIndexViewFilterGroupsState';
import { useRecordTableContextOrThrow } from '@/object-record/record-table/contexts/RecordTableContext';
import { aggregateOperationForViewFieldState } from '@/object-record/record-table/record-table-footer/states/aggregateOperationForViewFieldState';
import { useGetCurrentView } from '@/views/hooks/useGetCurrentView';
import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
import { useRecoilValue } from 'recoil';
import { isDefined } from '~/utils/isDefined';
export const useAggregateRecordsForRecordTableColumnFooter = (
fieldMetadataId: string,
) => {
const isAggregateQueryEnabled = useIsFeatureEnabled(
'IS_AGGREGATE_QUERY_ENABLED',
);
const { objectMetadataItem } = useRecordTableContextOrThrow();
const { currentViewWithSavedFiltersAndSorts } = useGetCurrentView();
const recordIndexViewFilterGroups = useRecoilValue(
recordIndexViewFilterGroupsState,
);
const recordIndexFilters = useRecoilValue(recordIndexFiltersState);
const requestFilters = computeViewRecordGqlOperationFilter(
recordIndexFilters,
objectMetadataItem.fields,
recordIndexViewFilterGroups,
);
const viewFieldId = currentViewWithSavedFiltersAndSorts?.viewFields?.find(
(viewField) => viewField.fieldMetadataId === fieldMetadataId,
)?.id;
if (!viewFieldId) {
throw new Error('ViewField not found');
}
const aggregateOperationForViewField = useRecoilValue(
aggregateOperationForViewFieldState({ viewFieldId: viewFieldId }),
);
const fieldName = objectMetadataItem.fields.find(
(field) => field.id === fieldMetadataId,
)?.name;
const recordGqlFieldsAggregate =
isDefined(aggregateOperationForViewField) && isDefined(fieldName)
? {
[fieldName]: [aggregateOperationForViewField],
}
: {};
const { data } = useAggregateRecords({
objectNameSingular: objectMetadataItem.nameSingular,
recordGqlFieldsAggregate,
filter: { ...requestFilters },
skip:
!isAggregateQueryEnabled || !isDefined(aggregateOperationForViewField),
});
const { value, label } = computeAggregateValueAndLabel({
data,
objectMetadataItem,
fieldMetadataId: fieldMetadataId,
aggregateOperation: aggregateOperationForViewField,
});
return {
aggregateValue: value,
aggregateLabel: isDefined(value) ? label : undefined,
};
};

View File

@ -0,0 +1,14 @@
import { RecordGqlFieldsAggregate } from '@/object-record/graphql/types/RecordGqlFieldsAggregate';
import { AGGREGATE_OPERATIONS } from '@/object-record/record-table/constants/AggregateOperations';
export const buildRecordGqlFieldsAggregateForRecordTable = ({
aggregateOperation,
fieldName,
}: {
fieldName: string;
aggregateOperation?: AGGREGATE_OPERATIONS | null;
}): RecordGqlFieldsAggregate => {
return {
[fieldName]: [aggregateOperation ?? AGGREGATE_OPERATIONS.count],
};
};

View File

@ -0,0 +1,33 @@
import { AGGREGATE_OPERATIONS } from '@/object-record/record-table/constants/AggregateOperations';
import { FIELDS_AVAILABLE_BY_AGGREGATE_OPERATION } from '@/object-record/record-table/constants/FieldsAvailableByAggregateOperation';
import { AggregateOperationsOmittingCount } from '@/object-record/types/AggregateOperationsOmittingCount';
import { isFieldTypeValidForAggregateOperation } from '@/object-record/utils/isFieldTypeValidForAggregateOperation';
import { FieldMetadataType } from '~/generated/graphql';
import { isDefined } from '~/utils/isDefined';
export const getAvailableAggregateOperationsForFieldMetadataType = ({
fieldMetadataType,
}: {
fieldMetadataType?: FieldMetadataType;
}) => {
const availableAggregateOperations = new Set<AGGREGATE_OPERATIONS>([
AGGREGATE_OPERATIONS.count,
]);
if (!isDefined(fieldMetadataType)) {
return Array.from(availableAggregateOperations);
}
Object.keys(FIELDS_AVAILABLE_BY_AGGREGATE_OPERATION)
.filter((operation) =>
isFieldTypeValidForAggregateOperation(
fieldMetadataType,
operation as AggregateOperationsOmittingCount,
),
)
.forEach((operation) =>
availableAggregateOperations.add(operation as AGGREGATE_OPERATIONS),
);
return Array.from(availableAggregateOperations);
};

View File

@ -1,5 +1,6 @@
import { FieldDefinition } from '@/object-record/record-field/types/FieldDefinition';
import { FieldMetadata } from '@/object-record/record-field/types/FieldMetadata';
import { AGGREGATE_OPERATIONS } from '@/object-record/record-table/constants/AggregateOperations';
export type ColumnDefinition<T extends FieldMetadata> = FieldDefinition<T> & {
size: number;
@ -9,4 +10,5 @@ export type ColumnDefinition<T extends FieldMetadata> = FieldDefinition<T> & {
viewFieldId?: string;
isFilterable?: boolean;
isSortable?: boolean;
aggregateOperation?: AGGREGATE_OPERATIONS | null;
};

View File

@ -22,6 +22,7 @@ export const generateAggregateQuery = ({
objectMetadataItem.nameSingular,
)}FilterInput) {
${objectMetadataItem.namePlural}(filter: $filter) {
${selectedFields ? '' : '__typename'}
${selectedFields}
}
}

View File

@ -88,6 +88,7 @@ export const usePersistViewFieldRecords = () => {
isVisible: viewField.isVisible,
position: viewField.position,
size: viewField.size,
aggregateOperation: viewField.aggregateOperation,
},
},
update: (cache, { data }) => {

View File

@ -0,0 +1,27 @@
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
import { useUpdateOneRecord } from '@/object-record/hooks/useUpdateOneRecord';
import { ViewField } from '@/views/types/ViewField';
import { useRecoilCallback } from 'recoil';
import { isDefined } from 'twenty-ui';
export const useUpdateViewField = () => {
const { updateOneRecord } = useUpdateOneRecord({
objectNameSingular: CoreObjectNameSingular.ViewField,
});
const updateViewField = useRecoilCallback(
() => async (viewField: Partial<ViewField>) => {
if (isDefined(viewField.id)) {
await updateOneRecord({
idToUpdate: viewField.id,
updateOneRecordInput: viewField,
});
}
},
[updateOneRecord],
);
return {
updateViewField,
};
};

View File

@ -14,5 +14,6 @@ export const mapColumnDefinitionsToViewFields = (
size: columnDefinition.size,
isVisible: columnDefinition.isVisible ?? true,
definition: columnDefinition,
aggregateOperation: columnDefinition.aggregateOperation,
}));
};