From 05cd0d1803251514eba40c5044ba5db4ae71a86a Mon Sep 17 00:00:00 2001 From: Marie <51697796+ijreilly@users.noreply.github.com> Date: Thu, 12 Dec 2024 13:38:58 +0100 Subject: [PATCH] [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) --- ...useAggregateRecordsForRecordBoardColumn.ts | 13 +-- ...dGqlFieldsAggregateForRecordBoard.test.ts} | 10 +- .../computeAggregateValueAndLabel.test.ts | 53 +++++----- ...RecordGqlFieldsAggregateForRecordBoard.ts} | 2 +- .../utils/computeAggregateValueAndLabel.ts | 56 ++++++----- .../record-table/components/RecordTable.tsx | 7 ++ .../components/RecordTableStickyEffect.tsx | 6 ++ .../RecordTableColumnFooterAggregateValue.tsx | 92 ++++++++++++++++++ .../RecordTableColumnFooterDropdown.tsx | 77 +++++++++++++++ .../RecordTableColumnFooterWithDropdown.tsx | 68 +++++++++++++ .../components/RecordTableFooter.tsx | 97 +++++++++++++++++++ .../components/RecordTableFooterCell.tsx | 89 +++++++++++++++++ ...egateRecordsForRecordTableColumnFooter.tsx | 75 ++++++++++++++ ...dRecordGqlFieldsAggregateForRecordTable.ts | 14 +++ ...AggregateOperationsForFieldMetadataType.ts | 33 +++++++ .../record-table/types/ColumnDefinition.ts | 2 + .../utils/generateAggregateQuery.ts | 1 + .../internal/usePersistViewFieldRecords.ts | 1 + .../modules/views/hooks/useUpdateViewField.ts | 27 ++++++ .../utils/mapColumnDefinitionToViewField.ts | 1 + 20 files changed, 662 insertions(+), 62 deletions(-) rename packages/twenty-front/src/modules/object-record/record-board/record-board-column/utils/__tests__/{buildRecordGqlFieldsAggregate.test.ts => buildRecordGqlFieldsAggregateForRecordBoard.test.ts} (88%) rename packages/twenty-front/src/modules/object-record/record-board/record-board-column/utils/{buildRecordGqlFieldsAggregate.ts => buildRecordGqlFieldsAggregateForRecordBoard.ts} (96%) create mode 100644 packages/twenty-front/src/modules/object-record/record-table/record-table-footer/components/RecordTableColumnFooterAggregateValue.tsx create mode 100644 packages/twenty-front/src/modules/object-record/record-table/record-table-footer/components/RecordTableColumnFooterDropdown.tsx create mode 100644 packages/twenty-front/src/modules/object-record/record-table/record-table-footer/components/RecordTableColumnFooterWithDropdown.tsx create mode 100644 packages/twenty-front/src/modules/object-record/record-table/record-table-footer/components/RecordTableFooter.tsx create mode 100644 packages/twenty-front/src/modules/object-record/record-table/record-table-footer/components/RecordTableFooterCell.tsx create mode 100644 packages/twenty-front/src/modules/object-record/record-table/record-table-footer/hooks/useAggregateRecordsForRecordTableColumnFooter.tsx create mode 100644 packages/twenty-front/src/modules/object-record/record-table/record-table-footer/utils/buildRecordGqlFieldsAggregateForRecordTable.ts create mode 100644 packages/twenty-front/src/modules/object-record/record-table/record-table-footer/utils/getAvailableAggregateOperationsForFieldMetadataType.ts create mode 100644 packages/twenty-front/src/modules/views/hooks/useUpdateViewField.ts diff --git a/packages/twenty-front/src/modules/object-record/record-board/record-board-column/hooks/useAggregateRecordsForRecordBoardColumn.ts b/packages/twenty-front/src/modules/object-record/record-board/record-board-column/hooks/useAggregateRecordsForRecordBoardColumn.ts index b16db61e0c..f3da6c261d 100644 --- a/packages/twenty-front/src/modules/object-record/record-board/record-board-column/hooks/useAggregateRecordsForRecordBoardColumn.ts +++ b/packages/twenty-front/src/modules/object-record/record-board/record-board-column/hooks/useAggregateRecordsForRecordBoardColumn.ts @@ -1,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, diff --git a/packages/twenty-front/src/modules/object-record/record-board/record-board-column/utils/__tests__/buildRecordGqlFieldsAggregate.test.ts b/packages/twenty-front/src/modules/object-record/record-board/record-board-column/utils/__tests__/buildRecordGqlFieldsAggregateForRecordBoard.test.ts similarity index 88% rename from packages/twenty-front/src/modules/object-record/record-board/record-board-column/utils/__tests__/buildRecordGqlFieldsAggregate.test.ts rename to packages/twenty-front/src/modules/object-record/record-board/record-board-column/utils/__tests__/buildRecordGqlFieldsAggregateForRecordBoard.test.ts index 85e4c5994e..2afbf90e04 100644 --- a/packages/twenty-front/src/modules/object-record/record-board/record-board-column/utils/__tests__/buildRecordGqlFieldsAggregate.test.ts +++ b/packages/twenty-front/src/modules/object-record/record-board/record-board-column/utils/__tests__/buildRecordGqlFieldsAggregateForRecordBoard.test.ts @@ -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, diff --git a/packages/twenty-front/src/modules/object-record/record-board/record-board-column/utils/__tests__/computeAggregateValueAndLabel.test.ts b/packages/twenty-front/src/modules/object-record/record-board/record-board-column/utils/__tests__/computeAggregateValueAndLabel.test.ts index f81f89e24f..4440124861 100644 --- a/packages/twenty-front/src/modules/object-record/record-board/record-board-column/utils/__tests__/computeAggregateValueAndLabel.test.ts +++ b/packages/twenty-front/src/modules/object-record/record-board/record-board-column/utils/__tests__/computeAggregateValueAndLabel.test.ts @@ -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, diff --git a/packages/twenty-front/src/modules/object-record/record-board/record-board-column/utils/buildRecordGqlFieldsAggregate.ts b/packages/twenty-front/src/modules/object-record/record-board/record-board-column/utils/buildRecordGqlFieldsAggregateForRecordBoard.ts similarity index 96% rename from packages/twenty-front/src/modules/object-record/record-board/record-board-column/utils/buildRecordGqlFieldsAggregate.ts rename to packages/twenty-front/src/modules/object-record/record-board/record-board-column/utils/buildRecordGqlFieldsAggregateForRecordBoard.ts index d759868f94..7a3f44a04b 100644 --- a/packages/twenty-front/src/modules/object-record/record-board/record-board-column/utils/buildRecordGqlFieldsAggregate.ts +++ b/packages/twenty-front/src/modules/object-record/record-board/record-board-column/utils/buildRecordGqlFieldsAggregateForRecordBoard.ts @@ -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, diff --git a/packages/twenty-front/src/modules/object-record/record-board/record-board-column/utils/computeAggregateValueAndLabel.ts b/packages/twenty-front/src/modules/object-record/record-board/record-board-column/utils/computeAggregateValueAndLabel.ts index 52ef2b86d4..b30c673c70 100644 --- a/packages/twenty-front/src/modules/object-record/record-board/record-board-column/utils/computeAggregateValueAndLabel.ts +++ b/packages/twenty-front/src/modules/object-record/record-board/record-board-column/utils/computeAggregateValueAndLabel.ts @@ -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, }; }; diff --git a/packages/twenty-front/src/modules/object-record/record-table/components/RecordTable.tsx b/packages/twenty-front/src/modules/object-record/record-table/components/RecordTable.tsx index cc428d1980..4da31f56b7 100644 --- a/packages/twenty-front/src/modules/object-record/record-table/components/RecordTable.tsx +++ b/packages/twenty-front/src/modules/object-record/record-table/components/RecordTable.tsx @@ -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(null); + const isAggregateQueryEnabled = useIsFeatureEnabled( + 'IS_AGGREGATE_QUERY_ENABLED', + ); + const { toggleClickOutsideListener } = useClickOutsideListener( RECORD_TABLE_CLICK_OUTSIDE_LISTENER_ID, ); @@ -90,6 +96,7 @@ export const RecordTable = () => { )} + {isAggregateQueryEnabled && } { 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]); diff --git a/packages/twenty-front/src/modules/object-record/record-table/record-table-footer/components/RecordTableColumnFooterAggregateValue.tsx b/packages/twenty-front/src/modules/object-record/record-table/record-table-footer/components/RecordTableColumnFooterAggregateValue.tsx new file mode 100644 index 0000000000..b3b1a77fc5 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-table/record-table-footer/components/RecordTableColumnFooterAggregateValue.tsx @@ -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 ( +
{ + setIsHovered(true); + }} + onMouseLeave={() => setIsHovered(false)} + > + + {isHovered || isDefined(aggregateValue) ? ( + <> + + {aggregateValue ?? 'Calculate'} + + + {aggregateValue && isDefined(aggregateLabel) && ( + + )} + + ) : ( + <> + )} + +
+ ); +}; diff --git a/packages/twenty-front/src/modules/object-record/record-table/record-table-footer/components/RecordTableColumnFooterDropdown.tsx b/packages/twenty-front/src/modules/object-record/record-table/record-table-footer/components/RecordTableColumnFooterDropdown.tsx new file mode 100644 index 0000000000..776daeb25a --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-table/record-table-footer/components/RecordTableColumnFooterDropdown.tsx @@ -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; +}) => { + 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 ( + <> + + {availableAggregateOperations.map((aggregation) => ( + { + handleAggregationChange(aggregation); + }} + text={getAggregateOperationLabel(aggregation)} + /> + ))} + + + ); +}; diff --git a/packages/twenty-front/src/modules/object-record/record-table/record-table-footer/components/RecordTableColumnFooterWithDropdown.tsx b/packages/twenty-front/src/modules/object-record/record-table/record-table-footer/components/RecordTableColumnFooterWithDropdown.tsx new file mode 100644 index 0000000000..f66111a978 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-table/record-table-footer/components/RecordTableColumnFooterWithDropdown.tsx @@ -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; +}; + +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 ( + + } + dropdownComponents={} + dropdownOffset={{ x: -1 }} + dropdownPlacement="bottom-start" + dropdownHotkeyScope={{ scope: dropdownId }} + /> + ); +}; diff --git a/packages/twenty-front/src/modules/object-record/record-table/record-table-footer/components/RecordTableFooter.tsx b/packages/twenty-front/src/modules/object-record/record-table/record-table-footer/components/RecordTableFooter.tsx new file mode 100644 index 0000000000..dadd433de8 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-table/record-table-footer/components/RecordTableFooter.tsx @@ -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 ( + + + + + {visibleTableColumns.map((column) => ( + + ))} + + + ); +}; diff --git a/packages/twenty-front/src/modules/object-record/record-table/record-table-footer/components/RecordTableFooterCell.tsx b/packages/twenty-front/src/modules/object-record/record-table/record-table-footer/components/RecordTableFooterCell.tsx new file mode 100644 index 0000000000..74064645ac --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-table/record-table-footer/components/RecordTableFooterCell.tsx @@ -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; +}) => { + const tableColumns = useRecoilComponentValueV2(tableColumnsComponentState); + const tableColumnsByKey = useMemo( + () => + mapArrayToObject(tableColumns, ({ fieldMetadataId }) => fieldMetadataId), + [tableColumns], + ); + + return ( + + + + + + ); +}; diff --git a/packages/twenty-front/src/modules/object-record/record-table/record-table-footer/hooks/useAggregateRecordsForRecordTableColumnFooter.tsx b/packages/twenty-front/src/modules/object-record/record-table/record-table-footer/hooks/useAggregateRecordsForRecordTableColumnFooter.tsx new file mode 100644 index 0000000000..9fa7cd7416 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-table/record-table-footer/hooks/useAggregateRecordsForRecordTableColumnFooter.tsx @@ -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, + }; +}; diff --git a/packages/twenty-front/src/modules/object-record/record-table/record-table-footer/utils/buildRecordGqlFieldsAggregateForRecordTable.ts b/packages/twenty-front/src/modules/object-record/record-table/record-table-footer/utils/buildRecordGqlFieldsAggregateForRecordTable.ts new file mode 100644 index 0000000000..e9abb0077d --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-table/record-table-footer/utils/buildRecordGqlFieldsAggregateForRecordTable.ts @@ -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], + }; +}; diff --git a/packages/twenty-front/src/modules/object-record/record-table/record-table-footer/utils/getAvailableAggregateOperationsForFieldMetadataType.ts b/packages/twenty-front/src/modules/object-record/record-table/record-table-footer/utils/getAvailableAggregateOperationsForFieldMetadataType.ts new file mode 100644 index 0000000000..c49fcc66b7 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-table/record-table-footer/utils/getAvailableAggregateOperationsForFieldMetadataType.ts @@ -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.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); +}; diff --git a/packages/twenty-front/src/modules/object-record/record-table/types/ColumnDefinition.ts b/packages/twenty-front/src/modules/object-record/record-table/types/ColumnDefinition.ts index c14b745ef5..c649dc4c18 100644 --- a/packages/twenty-front/src/modules/object-record/record-table/types/ColumnDefinition.ts +++ b/packages/twenty-front/src/modules/object-record/record-table/types/ColumnDefinition.ts @@ -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 = FieldDefinition & { size: number; @@ -9,4 +10,5 @@ export type ColumnDefinition = FieldDefinition & { viewFieldId?: string; isFilterable?: boolean; isSortable?: boolean; + aggregateOperation?: AGGREGATE_OPERATIONS | null; }; diff --git a/packages/twenty-front/src/modules/object-record/utils/generateAggregateQuery.ts b/packages/twenty-front/src/modules/object-record/utils/generateAggregateQuery.ts index d6feb05e54..02e077ded1 100644 --- a/packages/twenty-front/src/modules/object-record/utils/generateAggregateQuery.ts +++ b/packages/twenty-front/src/modules/object-record/utils/generateAggregateQuery.ts @@ -22,6 +22,7 @@ export const generateAggregateQuery = ({ objectMetadataItem.nameSingular, )}FilterInput) { ${objectMetadataItem.namePlural}(filter: $filter) { + ${selectedFields ? '' : '__typename'} ${selectedFields} } } diff --git a/packages/twenty-front/src/modules/views/hooks/internal/usePersistViewFieldRecords.ts b/packages/twenty-front/src/modules/views/hooks/internal/usePersistViewFieldRecords.ts index f08c49e547..289911241a 100644 --- a/packages/twenty-front/src/modules/views/hooks/internal/usePersistViewFieldRecords.ts +++ b/packages/twenty-front/src/modules/views/hooks/internal/usePersistViewFieldRecords.ts @@ -88,6 +88,7 @@ export const usePersistViewFieldRecords = () => { isVisible: viewField.isVisible, position: viewField.position, size: viewField.size, + aggregateOperation: viewField.aggregateOperation, }, }, update: (cache, { data }) => { diff --git a/packages/twenty-front/src/modules/views/hooks/useUpdateViewField.ts b/packages/twenty-front/src/modules/views/hooks/useUpdateViewField.ts new file mode 100644 index 0000000000..2f73b062ca --- /dev/null +++ b/packages/twenty-front/src/modules/views/hooks/useUpdateViewField.ts @@ -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) => { + if (isDefined(viewField.id)) { + await updateOneRecord({ + idToUpdate: viewField.id, + updateOneRecordInput: viewField, + }); + } + }, + [updateOneRecord], + ); + + return { + updateViewField, + }; +}; diff --git a/packages/twenty-front/src/modules/views/utils/mapColumnDefinitionToViewField.ts b/packages/twenty-front/src/modules/views/utils/mapColumnDefinitionToViewField.ts index 7b00889bbd..3597398763 100644 --- a/packages/twenty-front/src/modules/views/utils/mapColumnDefinitionToViewField.ts +++ b/packages/twenty-front/src/modules/views/utils/mapColumnDefinitionToViewField.ts @@ -14,5 +14,6 @@ export const mapColumnDefinitionsToViewFields = ( size: columnDefinition.size, isVisible: columnDefinition.isVisible ?? true, definition: columnDefinition, + aggregateOperation: columnDefinition.aggregateOperation, })); };