mirror of
https://github.com/twentyhq/twenty.git
synced 2024-12-22 19:41:53 +03:00
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:
parent
5f2a39d9e9
commit
05cd0d1803
@ -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,
|
||||
|
@ -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,
|
@ -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,
|
||||
|
@ -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,
|
@ -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,
|
||||
};
|
||||
};
|
||||
|
@ -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}
|
||||
|
@ -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]);
|
||||
|
||||
|
@ -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>
|
||||
);
|
||||
};
|
@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
@ -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 }}
|
||||
/>
|
||||
);
|
||||
};
|
@ -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>
|
||||
);
|
||||
};
|
@ -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>
|
||||
);
|
||||
};
|
@ -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,
|
||||
};
|
||||
};
|
@ -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],
|
||||
};
|
||||
};
|
@ -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);
|
||||
};
|
@ -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;
|
||||
};
|
||||
|
@ -22,6 +22,7 @@ export const generateAggregateQuery = ({
|
||||
objectMetadataItem.nameSingular,
|
||||
)}FilterInput) {
|
||||
${objectMetadataItem.namePlural}(filter: $filter) {
|
||||
${selectedFields ? '' : '__typename'}
|
||||
${selectedFields}
|
||||
}
|
||||
}
|
||||
|
@ -88,6 +88,7 @@ export const usePersistViewFieldRecords = () => {
|
||||
isVisible: viewField.isVisible,
|
||||
position: viewField.position,
|
||||
size: viewField.size,
|
||||
aggregateOperation: viewField.aggregateOperation,
|
||||
},
|
||||
},
|
||||
update: (cache, { data }) => {
|
||||
|
@ -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,
|
||||
};
|
||||
};
|
@ -14,5 +14,6 @@ export const mapColumnDefinitionsToViewFields = (
|
||||
size: columnDefinition.size,
|
||||
isVisible: columnDefinition.isVisible ?? true,
|
||||
definition: columnDefinition,
|
||||
aggregateOperation: columnDefinition.aggregateOperation,
|
||||
}));
|
||||
};
|
||||
|
Loading…
Reference in New Issue
Block a user