From 7d8f895ae918bca350db2bf1aa0a59207c3bc88f Mon Sep 17 00:00:00 2001 From: Antoine Moreaux Date: Thu, 19 Dec 2024 15:24:49 +0100 Subject: [PATCH 01/11] fix test (#9147) --- .../auth/token/services/refresh-token.service.spec.ts | 11 +++++++++-- .../rename-custom-object.integration-spec.ts | 4 ++-- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/packages/twenty-server/src/engine/core-modules/auth/token/services/refresh-token.service.spec.ts b/packages/twenty-server/src/engine/core-modules/auth/token/services/refresh-token.service.spec.ts index 5ac7ada615..a53238067d 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/token/services/refresh-token.service.spec.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/token/services/refresh-token.service.spec.ts @@ -66,8 +66,15 @@ describe('RefreshTokenService', () => { describe('verifyRefreshToken', () => { it('should verify a refresh token successfully', async () => { const mockToken = 'valid-refresh-token'; - const mockJwtPayload = { jti: 'token-id', sub: 'user-id' }; - const mockAppToken = { id: 'token-id', revokedAt: null }; + const mockJwtPayload = { + jti: 'token-id', + sub: 'user-id', + }; + const mockAppToken = { + id: 'token-id', + workspaceId: 'workspace-id', + revokedAt: null, + }; const mockUser: Partial = { id: 'some-id', firstName: 'John', diff --git a/packages/twenty-server/test/integration/metadata/suites/object-metadata/rename-custom-object.integration-spec.ts b/packages/twenty-server/test/integration/metadata/suites/object-metadata/rename-custom-object.integration-spec.ts index 9eb5773b29..ac4d836b49 100644 --- a/packages/twenty-server/test/integration/metadata/suites/object-metadata/rename-custom-object.integration-spec.ts +++ b/packages/twenty-server/test/integration/metadata/suites/object-metadata/rename-custom-object.integration-spec.ts @@ -5,8 +5,8 @@ import { createOneRelationMetadataFactory } from 'test/integration/metadata/suit import { deleteOneRelationMetadataItemFactory } from 'test/integration/metadata/suites/utils/delete-one-relation-metadata-factory.util'; import { fieldsMetadataFactory } from 'test/integration/metadata/suites/utils/fields-metadata-factory.util'; import { makeMetadataAPIRequest } from 'test/integration/metadata/suites/utils/make-metadata-api-request.util'; -import { objectsMetadataFactory } from 'test/integration/metadata/suites/utils/objects-metadata-factory.util'; -import { updateOneObjectMetadataItemFactory } from 'test/integration/metadata/suites/utils/update-one-object-metadata-factory.util'; +import { updateOneObjectMetadataItemFactory } from 'test/integration/metadata/suites/object-metadata/utils/update-one-object-metadata-factory.util'; +import { objectsMetadataFactory } from 'test/integration/metadata/suites/object-metadata/utils/objects-metadata-factory.util'; import { FieldMetadataType } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity'; import { RelationMetadataType } from 'src/engine/metadata-modules/relation-metadata/relation-metadata.entity'; From ed56a68b7c19713b3a60559fe7987fc75650ab4a Mon Sep 17 00:00:00 2001 From: Marie <51697796+ijreilly@users.noreply.github.com> Date: Thu, 19 Dec 2024 15:36:14 +0100 Subject: [PATCH 02/11] Improve aggregate footer cell display (#9124) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Jérémy Magrin Co-authored-by: Charles Bochet --- ...ardColumnHeaderAggregateDropdownButton.tsx | 6 +- ...useAggregateRecordsForRecordBoardColumn.ts | 4 +- .../computeAggregateValueAndLabel.test.ts | 82 ++++++++++++++++++- .../utils/computeAggregateValueAndLabel.ts | 34 ++++++-- ...ecordIndexRecordGroupHideComponentState.ts | 2 +- .../record-table/components/RecordTable.tsx | 6 +- .../components/RecordTableRecordGroupRows.tsx | 11 --- .../components/RecordTableBodyDroppable.tsx | 3 +- .../RecordTableRecordGroupsBody.tsx | 45 ++++++---- ...ter.tsx => RecordTableAggregateFooter.tsx} | 42 ++++------ ...tsx => RecordTableAggregateFooterCell.tsx} | 4 +- ...ordTableColumnAggregateFooterDropdown.tsx} | 9 +- ...RecordTableColumnAggregateFooterValue.tsx} | 51 +++++++----- ...ableColumnAggregateFooterWithDropdown.tsx} | 8 +- ...egateRecordsForRecordTableColumnFooter.tsx | 11 +-- .../components/RecordTableHeader.tsx | 4 +- .../RecordTableRecordGroupSection.tsx | 2 + .../RecordTableRecordGroupStickyEffect.tsx | 48 +++++++++++ .../scroll/contexts/ScrollWrapperContexts.tsx | 5 ++ ...ct-records-to-graphql-connection.helper.ts | 5 +- 20 files changed, 268 insertions(+), 114 deletions(-) rename packages/twenty-front/src/modules/object-record/record-table/record-table-footer/components/{RecordTableFooter.tsx => RecordTableAggregateFooter.tsx} (69%) rename packages/twenty-front/src/modules/object-record/record-table/record-table-footer/components/{RecordTableFooterCell.tsx => RecordTableAggregateFooterCell.tsx} (97%) rename packages/twenty-front/src/modules/object-record/record-table/record-table-footer/components/{RecordTableColumnFooterDropdown.tsx => RecordTableColumnAggregateFooterDropdown.tsx} (95%) rename packages/twenty-front/src/modules/object-record/record-table/record-table-footer/components/{RecordTableColumnFooterAggregateValue.tsx => RecordTableColumnAggregateFooterValue.tsx} (68%) rename packages/twenty-front/src/modules/object-record/record-table/record-table-footer/components/{RecordTableColumnFooterWithDropdown.tsx => RecordTableColumnAggregateFooterWithDropdown.tsx} (86%) create mode 100644 packages/twenty-front/src/modules/object-record/record-table/record-table-section/components/RecordTableRecordGroupStickyEffect.tsx diff --git a/packages/twenty-front/src/modules/object-record/record-board/record-board-column/components/RecordBoardColumnHeaderAggregateDropdownButton.tsx b/packages/twenty-front/src/modules/object-record/record-board/record-board-column/components/RecordBoardColumnHeaderAggregateDropdownButton.tsx index dd12c11f72..5b945ba8b3 100644 --- a/packages/twenty-front/src/modules/object-record/record-board/record-board-column/components/RecordBoardColumnHeaderAggregateDropdownButton.tsx +++ b/packages/twenty-front/src/modules/object-record/record-board/record-board-column/components/RecordBoardColumnHeaderAggregateDropdownButton.tsx @@ -1,7 +1,6 @@ import { StyledHeaderDropdownButton } from '@/ui/layout/dropdown/components/StyledHeaderDropdownButton'; import styled from '@emotion/styled'; import { AppTooltip, Tag, TooltipDelay } from 'twenty-ui'; -import { formatNumber } from '~/utils/format/number'; const StyledButton = styled(StyledHeaderDropdownButton)` padding: 0; @@ -19,10 +18,7 @@ export const RecordBoardColumnHeaderAggregateDropdownButton = ({ return (
- + { skip: !isAggregateQueryEnabled, }); - const { value, label } = computeAggregateValueAndLabel({ + const { value, labelWithFieldName } = computeAggregateValueAndLabel({ data, objectMetadataItem, fieldMetadataId: recordIndexKanbanAggregateOperation?.fieldMetadataId, @@ -84,6 +84,6 @@ export const useAggregateRecordsForRecordBoardColumn = () => { return { aggregateValue: isAggregateQueryEnabled ? value : recordCount, - aggregateLabel: isDefined(value) ? label : undefined, + aggregateLabel: isDefined(value) ? labelWithFieldName : undefined, }; }; 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 4440124861..5a03afa7b3 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 @@ -47,8 +47,81 @@ describe('computeAggregateValueAndLabel', () => { }); expect(result).toEqual({ - value: 2, - label: 'Sum of amount', + value: '2', + label: 'Sum', + labelWithFieldName: 'Sum of amount', + }); + }); + + it('should handle number field as percentage', () => { + const mockObjectMetadataWithPercentageField: ObjectMetadataItem = { + id: '123', + fields: [ + { + id: MOCK_FIELD_ID, + name: 'percentage', + type: FieldMetadataType.Number, + settings: { + type: 'percentage', + }, + } as FieldMetadataItem, + ], + } as ObjectMetadataItem; + + const mockData = { + percentage: { + [AGGREGATE_OPERATIONS.avg]: 0.3, + }, + }; + + const result = computeAggregateValueAndLabel({ + data: mockData, + objectMetadataItem: mockObjectMetadataWithPercentageField, + fieldMetadataId: MOCK_FIELD_ID, + aggregateOperation: AGGREGATE_OPERATIONS.avg, + fallbackFieldName: MOCK_KANBAN_FIELD_NAME, + }); + + expect(result).toEqual({ + value: '30%', + label: 'Average', + labelWithFieldName: 'Average of percentage', + }); + }); + + it('should handle number field with decimals', () => { + const mockObjectMetadataWithDecimalsField: ObjectMetadataItem = { + id: '123', + fields: [ + { + id: MOCK_FIELD_ID, + name: 'decimals', + type: FieldMetadataType.Number, + settings: { + decimals: 2, + }, + } as FieldMetadataItem, + ], + } as ObjectMetadataItem; + + const mockData = { + decimals: { + [AGGREGATE_OPERATIONS.sum]: 0.009, + }, + }; + + const result = computeAggregateValueAndLabel({ + data: mockData, + objectMetadataItem: mockObjectMetadataWithDecimalsField, + fieldMetadataId: MOCK_FIELD_ID, + aggregateOperation: AGGREGATE_OPERATIONS.sum, + fallbackFieldName: MOCK_KANBAN_FIELD_NAME, + }); + + expect(result).toEqual({ + value: '0.01', + label: 'Sum', + labelWithFieldName: 'Sum of decimals', }); }); @@ -86,8 +159,9 @@ describe('computeAggregateValueAndLabel', () => { }); expect(result).toEqual({ - value: undefined, - label: 'Sum of amount', + value: '-', + label: 'Sum', + labelWithFieldName: 'Sum of amount', }); }); }); 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 b30c673c70..80beb0e39d 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 @@ -4,6 +4,8 @@ import { getAggregateOperationLabel } from '@/object-record/record-board/record- import { AGGREGATE_OPERATIONS } from '@/object-record/record-table/constants/AggregateOperations'; import isEmpty from 'lodash.isempty'; import { FieldMetadataType } from '~/generated-metadata/graphql'; +import { formatAmount } from '~/utils/format/formatAmount'; +import { formatNumber } from '~/utils/format/number'; import { isDefined } from '~/utils/isDefined'; export const computeAggregateValueAndLabel = ({ @@ -42,12 +44,33 @@ export const computeAggregateValueAndLabel = ({ const aggregateValue = data[field.name]?.[aggregateOperation]; - const value = - isDefined(aggregateValue) && field.type === FieldMetadataType.Currency - ? Number(aggregateValue) / 1_000_000 - : aggregateValue; + let value; - const label = + if (aggregateOperation === AGGREGATE_OPERATIONS.count) { + value = aggregateValue; + } else if (!isDefined(aggregateValue)) { + value = '-'; + } else { + value = Number(aggregateValue); + + switch (field.type) { + case FieldMetadataType.Currency: { + value = formatAmount(value / 1_000_000); + break; + } + + case FieldMetadataType.Number: { + const { decimals, type } = field.settings ?? {}; + value = + type === 'percentage' + ? `${formatNumber(value * 100, decimals)}%` + : formatNumber(value, decimals); + break; + } + } + } + const label = getAggregateOperationLabel(aggregateOperation); + const labelWithFieldName = aggregateOperation === AGGREGATE_OPERATIONS.count ? `${getAggregateOperationLabel(AGGREGATE_OPERATIONS.count)}` : `${getAggregateOperationLabel(aggregateOperation)} of ${field.name}`; @@ -55,5 +78,6 @@ export const computeAggregateValueAndLabel = ({ return { value, label, + labelWithFieldName, }; }; diff --git a/packages/twenty-front/src/modules/object-record/record-index/states/recordIndexRecordGroupHideComponentState.ts b/packages/twenty-front/src/modules/object-record/record-index/states/recordIndexRecordGroupHideComponentState.ts index bc571f6755..3500e331e5 100644 --- a/packages/twenty-front/src/modules/object-record/record-index/states/recordIndexRecordGroupHideComponentState.ts +++ b/packages/twenty-front/src/modules/object-record/record-index/states/recordIndexRecordGroupHideComponentState.ts @@ -4,6 +4,6 @@ import { ViewComponentInstanceContext } from '@/views/states/contexts/ViewCompon export const recordIndexRecordGroupHideComponentState = createComponentStateV2({ key: 'recordIndexRecordGroupHideComponentState', - defaultValue: true, + defaultValue: false, componentInstanceContext: ViewComponentInstanceContext, }); 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 72d874499a..853a3623dc 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,7 +13,7 @@ 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 { RecordTableAggregateFooter } from '@/object-record/record-table/record-table-footer/components/RecordTableAggregateFooter'; 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'; @@ -88,7 +88,7 @@ export const RecordTable = () => { ) : ( <> - + {!hasRecordGroups ? ( @@ -97,7 +97,7 @@ export const RecordTable = () => { )} {isAggregateQueryEnabled && !hasRecordGroups && ( - + )} { - const isAggregateQueryEnabled = useIsFeatureEnabled( - 'IS_AGGREGATE_QUERY_ENABLED', - ); const currentRecordGroupId = useCurrentRecordGroupId(); const allRecordIds = useRecoilComponentValueV2( @@ -63,12 +58,6 @@ export const RecordTableRecordGroupRows = () => { })} - {isAggregateQueryEnabled && ( - - )} ); diff --git a/packages/twenty-front/src/modules/object-record/record-table/record-table-body/components/RecordTableBodyDroppable.tsx b/packages/twenty-front/src/modules/object-record/record-table/record-table-body/components/RecordTableBodyDroppable.tsx index f2e19d7506..8e731aaf5a 100644 --- a/packages/twenty-front/src/modules/object-record/record-table/record-table-body/components/RecordTableBodyDroppable.tsx +++ b/packages/twenty-front/src/modules/object-record/record-table/record-table-body/components/RecordTableBodyDroppable.tsx @@ -15,6 +15,7 @@ export const RecordTableBodyDroppable = ({ isDropDisabled, }: RecordTableBodyDroppableProps) => { const [v4Persistable] = useState(v4()); + const recordTableBodyId = `record-table-body${recordGroupId ? '-' + recordGroupId : ''}`; return ( {(provided) => ( { + const isAggregateQueryEnabled = useIsFeatureEnabled( + 'IS_AGGREGATE_QUERY_ENABLED', + ); const allRecordIds = useRecoilComponentValueV2( recordIndexAllRecordIdsComponentSelector, ); @@ -29,21 +34,29 @@ export const RecordTableRecordGroupsBody = () => { } return ( - - {visibleRecordGroupIds.map((recordGroupId, index) => ( - - - - {index > 0 && } - - - - - - ))} - + <> + + {visibleRecordGroupIds.map((recordGroupId, index) => ( + + + + {index > 0 && } + + + + {isAggregateQueryEnabled && ( + + )} + + + ))} + + ); }; 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/RecordTableAggregateFooter.tsx similarity index 69% rename from packages/twenty-front/src/modules/object-record/record-table/record-table-footer/components/RecordTableFooter.tsx rename to packages/twenty-front/src/modules/object-record/record-table/record-table-footer/components/RecordTableAggregateFooter.tsx index b0fb27dd64..31b7368da5 100644 --- 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/RecordTableAggregateFooter.tsx @@ -1,17 +1,16 @@ import styled from '@emotion/styled'; import { MOBILE_VIEWPORT } from 'twenty-ui'; -import { TABLE_CELL_CHECKBOX_MIN_WIDTH } from '@/object-record/record-table/record-table-cell/components/RecordTableCellCheckbox'; -import { TABLE_CELL_GRIP_WIDTH } from '@/object-record/record-table/record-table-cell/components/RecordTableCellGrip'; -import { RecordTableFooterCell } from '@/object-record/record-table/record-table-footer/components/RecordTableFooterCell'; +import { RecordTableAggregateFooterCell } from '@/object-record/record-table/record-table-footer/components/RecordTableAggregateFooterCell'; +import { FIRST_TH_WIDTH } from '@/object-record/record-table/record-table-header/components/RecordTableHeader'; import { visibleTableColumnsComponentSelector } from '@/object-record/record-table/states/selectors/visibleTableColumnsComponentSelector'; import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2'; -const StyledTableFoot = styled.thead` +const StyledTableFoot = styled.thead<{ endOfTableSticky?: boolean }>` cursor: pointer; th:nth-of-type(1) { - width: 9px; + width: ${FIRST_TH_WIDTH}; left: 0; border-right-color: ${({ theme }) => theme.background.primary}; } @@ -59,31 +58,23 @@ const StyledTableFoot = styled.thead` } } - &.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; - } + tr { + position: sticky; + z-index: 5; + ${({ endOfTableSticky }) => endOfTableSticky && `bottom: 0;`} } `; -const StyledDiv = styled.div` - width: calc(${TABLE_CELL_GRIP_WIDTH} + ${TABLE_CELL_CHECKBOX_MIN_WIDTH}); +const StyledTh = styled.th` + background-color: ${({ theme }) => theme.background.primary}; `; -export const RecordTableFooter = ({ +export const RecordTableAggregateFooter = ({ currentRecordGroupId, + endOfTableSticky, }: { currentRecordGroupId?: string; + endOfTableSticky?: boolean; }) => { const visibleTableColumns = useRecoilComponentValueV2( visibleTableColumnsComponentSelector, @@ -93,12 +84,13 @@ export const RecordTableFooter = ({ - - + + {visibleTableColumns.map((column, index) => ( - viewField.fieldMetadataId === column.fieldMetadataId, ); - if (!currentViewField) { - throw new Error('ViewField not found'); - } - useScopedHotkeys( [Key.Escape], () => { @@ -56,6 +52,9 @@ export const RecordTableColumnFooterDropdown = ({ const handleAggregationChange = ( aggregateOperation: AGGREGATE_OPERATIONS, ) => { + if (!currentViewField) { + throw new Error('ViewField not found'); + } updateViewFieldRecords([ { ...currentViewField, aggregateOperation: aggregateOperation }, ]); 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/RecordTableColumnAggregateFooterValue.tsx similarity index 68% rename from packages/twenty-front/src/modules/object-record/record-table/record-table-footer/components/RecordTableColumnFooterAggregateValue.tsx rename to packages/twenty-front/src/modules/object-record/record-table/record-table-footer/components/RecordTableColumnAggregateFooterValue.tsx index a306c1029e..45f36eb02e 100644 --- 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/RecordTableColumnAggregateFooterValue.tsx @@ -1,12 +1,7 @@ import { useTheme } from '@emotion/react'; import styled from '@emotion/styled'; import { useState } from 'react'; -import { - AppTooltip, - IconChevronDown, - isDefined, - TooltipDelay, -} from 'twenty-ui'; +import { IconChevronDown, isDefined } from 'twenty-ui'; const StyledCell = styled.div` align-items: center; @@ -37,6 +32,27 @@ const StyledText = styled.span` z-index: 1; `; +const StyledValueContainer = styled.div` + align-items: center; + display: flex; + flex: 1 0 0; + gap: 4px; + height: 32px; + justify-content: flex-end; + padding: 8px; +`; + +const StyledLabel = styled.div` + align-items: center; + display: flex; + gap: 4px; +`; + +const StyledValue = styled.div` + color: ${({ theme }) => theme.color.gray60}; + flex: 1 0 0; +`; + const StyledIcon = styled(IconChevronDown)` align-items: center; display: flex; @@ -46,7 +62,7 @@ const StyledIcon = styled(IconChevronDown)` padding-right: ${({ theme }) => theme.spacing(2)}; `; -export const RecordTableColumnFooterAggregateValue = ({ +export const RecordTableColumnAggregateFooterValue = ({ dropdownId, aggregateValue, aggregateLabel, @@ -70,20 +86,15 @@ export const RecordTableColumnFooterAggregateValue = ({ {isHovered || isDefined(aggregateValue) || isFirstCell ? ( <> - - {aggregateValue ?? 'Calculate'} - - - {isDefined(aggregateValue) && isDefined(aggregateLabel) && ( - + {isDefined(aggregateValue) ? ( + + {aggregateLabel} + {aggregateValue} + + ) : ( + Calculate )} + ) : ( <> 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/RecordTableColumnAggregateFooterWithDropdown.tsx similarity index 86% rename from packages/twenty-front/src/modules/object-record/record-table/record-table-footer/components/RecordTableColumnFooterWithDropdown.tsx rename to packages/twenty-front/src/modules/object-record/record-table/record-table-footer/components/RecordTableColumnAggregateFooterWithDropdown.tsx index 6c7cf7db19..13c49fc30e 100644 --- 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/RecordTableColumnAggregateFooterWithDropdown.tsx @@ -1,6 +1,6 @@ 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 { RecordTableColumnAggregateFooterDropdown } from '@/object-record/record-table/record-table-footer/components/RecordTableColumnAggregateFooterDropdown'; +import { RecordTableColumnAggregateFooterValue } from '@/object-record/record-table/record-table-footer/components/RecordTableColumnAggregateFooterValue'; import { useAggregateRecordsForRecordTableColumnFooter } from '@/object-record/record-table/record-table-footer/hooks/useAggregateRecordsForRecordTableColumnFooter'; import { ColumnDefinition } from '@/object-record/record-table/types/ColumnDefinition'; import { Dropdown } from '@/ui/layout/dropdown/components/Dropdown'; @@ -44,7 +44,7 @@ export const RecordTableColumnFooterWithDropdown = ({ onClose={handleDropdownClose} dropdownId={dropdownId} clickableComponent={ - } dropdownComponents={ - 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 index 96bb3d7ca1..c9851f04d1 100644 --- 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 @@ -32,13 +32,10 @@ export const useAggregateRecordsForRecordTableColumnFooter = ( recordIndexViewFilterGroups, ); - const viewFieldId = currentViewWithSavedFiltersAndSorts?.viewFields?.find( - (viewField) => viewField.fieldMetadataId === fieldMetadataId, - )?.id; - - if (!viewFieldId) { - throw new Error('ViewField not found'); - } + const viewFieldId = + currentViewWithSavedFiltersAndSorts?.viewFields?.find( + (viewField) => viewField.fieldMetadataId === fieldMetadataId, + )?.id ?? ''; const aggregateOperationForViewField = useRecoilValue( aggregateOperationForViewFieldState({ viewFieldId: viewFieldId }), diff --git a/packages/twenty-front/src/modules/object-record/record-table/record-table-header/components/RecordTableHeader.tsx b/packages/twenty-front/src/modules/object-record/record-table/record-table-header/components/RecordTableHeader.tsx index ae08ea612f..cf33fa7d71 100644 --- a/packages/twenty-front/src/modules/object-record/record-table/record-table-header/components/RecordTableHeader.tsx +++ b/packages/twenty-front/src/modules/object-record/record-table/record-table-header/components/RecordTableHeader.tsx @@ -7,11 +7,13 @@ import { RecordTableHeaderCheckboxColumn } from '@/object-record/record-table/re import { RecordTableHeaderDragDropColumn } from '@/object-record/record-table/record-table-header/components/RecordTableHeaderDragDropColumn'; import { RecordTableHeaderLastColumn } from '@/object-record/record-table/record-table-header/components/RecordTableHeaderLastColumn'; +export const FIRST_TH_WIDTH = '9px'; + const StyledTableHead = styled.thead` cursor: pointer; th:nth-of-type(1) { - width: 9px; + width: ${FIRST_TH_WIDTH}; left: 0; border-right-color: ${({ theme }) => theme.background.primary}; } diff --git a/packages/twenty-front/src/modules/object-record/record-table/record-table-section/components/RecordTableRecordGroupSection.tsx b/packages/twenty-front/src/modules/object-record/record-table/record-table-section/components/RecordTableRecordGroupSection.tsx index 9274eb501d..2fa9ff5906 100644 --- a/packages/twenty-front/src/modules/object-record/record-table/record-table-section/components/RecordTableRecordGroupSection.tsx +++ b/packages/twenty-front/src/modules/object-record/record-table/record-table-section/components/RecordTableRecordGroupSection.tsx @@ -9,6 +9,7 @@ import { recordGroupDefinitionFamilyState } from '@/object-record/record-group/s import { RecordGroupDefinitionType } from '@/object-record/record-group/types/RecordGroupDefinition'; import { recordIndexRecordIdsByGroupComponentFamilyState } from '@/object-record/record-index/states/recordIndexRecordIdsByGroupComponentFamilyState'; import { RecordTableTd } from '@/object-record/record-table/record-table-cell/components/RecordTableTd'; +import { RecordTableRecordGroupStickyEffect } from '@/object-record/record-table/record-table-section/components/RecordTableRecordGroupStickyEffect'; import { isRecordGroupTableSectionToggledComponentState } from '@/object-record/record-table/record-table-section/states/isRecordGroupTableSectionToggledComponentState'; import { visibleTableColumnsComponentSelector } from '@/object-record/record-table/states/selectors/visibleTableColumnsComponentSelector'; import { useRecoilComponentFamilyStateV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentFamilyStateV2'; @@ -107,6 +108,7 @@ export const RecordTableRecordGroupSection = () => { weight="medium" /> {recordIdsByGroup.length} + diff --git a/packages/twenty-front/src/modules/object-record/record-table/record-table-section/components/RecordTableRecordGroupStickyEffect.tsx b/packages/twenty-front/src/modules/object-record/record-table/record-table-section/components/RecordTableRecordGroupStickyEffect.tsx new file mode 100644 index 0000000000..889730417c --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-table/record-table-section/components/RecordTableRecordGroupStickyEffect.tsx @@ -0,0 +1,48 @@ +import { useEffect } from 'react'; + +import { useCurrentRecordGroupId } from '@/object-record/record-group/hooks/useCurrentRecordGroupId'; +import { isRecordTableScrolledLeftComponentState } from '@/object-record/record-table/states/isRecordTableScrolledLeftComponentState'; +import { scrollWrapperScrollLeftComponentState } from '@/ui/utilities/scroll/states/scrollWrapperScrollLeftComponentState'; +import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2'; +import { useSetRecoilComponentStateV2 } from '@/ui/utilities/state/component-state/hooks/useSetRecoilComponentStateV2'; + +export const RecordTableRecordGroupStickyEffect = () => { + const scrollLeft = useRecoilComponentValueV2( + scrollWrapperScrollLeftComponentState, + ); + + const setIsRecordTableScrolledLeft = useSetRecoilComponentStateV2( + isRecordTableScrolledLeftComponentState, + ); + + const currentRecordGroupId = useCurrentRecordGroupId(); + + useEffect(() => { + setIsRecordTableScrolledLeft(scrollLeft === 0); + if (scrollLeft > 0) { + document + .getElementById( + `record-table-footer${currentRecordGroupId ? '-' + currentRecordGroupId : ''}`, + ) + ?.classList.add('first-columns-sticky'); + document + .getElementById( + `record-table-body${currentRecordGroupId ? '-' + currentRecordGroupId : ''}`, + ) + ?.classList.add('first-columns-sticky'); + } else { + document + .getElementById( + `record-table-footer${currentRecordGroupId ? '-' + currentRecordGroupId : ''}`, + ) + ?.classList.remove('first-columns-sticky'); + document + .getElementById( + `record-table-body${currentRecordGroupId ? '-' + currentRecordGroupId : ''}`, + ) + ?.classList.remove('first-columns-sticky'); + } + }, [currentRecordGroupId, scrollLeft, setIsRecordTableScrolledLeft]); + + return <>; +}; diff --git a/packages/twenty-front/src/modules/ui/utilities/scroll/contexts/ScrollWrapperContexts.tsx b/packages/twenty-front/src/modules/ui/utilities/scroll/contexts/ScrollWrapperContexts.tsx index cf8da62582..4d5a34a457 100644 --- a/packages/twenty-front/src/modules/ui/utilities/scroll/contexts/ScrollWrapperContexts.tsx +++ b/packages/twenty-front/src/modules/ui/utilities/scroll/contexts/ScrollWrapperContexts.tsx @@ -19,6 +19,7 @@ export type ContextProviderName = | 'test' | 'showPageActivityContainer' | 'navigationDrawer' + | 'aggregateFooterCell' | 'modalContent'; const createScrollWrapperContext = (id: string) => @@ -52,6 +53,8 @@ export const ShowPageActivityContainerScrollWrapperContext = export const NavigationDrawerScrollWrapperContext = createScrollWrapperContext('navigationDrawer'); export const TestScrollWrapperContext = createScrollWrapperContext('test'); +export const AggregateFooterCellScrollWrapperContext = + createScrollWrapperContext('aggregateFooterCell'); export const ModalContentScrollWrapperContext = createScrollWrapperContext('modalContent'); @@ -85,6 +88,8 @@ export const getContextByProviderName = ( return ShowPageActivityContainerScrollWrapperContext; case 'navigationDrawer': return NavigationDrawerScrollWrapperContext; + case 'aggregateFooterCell': + return AggregateFooterCellScrollWrapperContext; case 'modalContent': return ModalContentScrollWrapperContext; default: diff --git a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/helpers/object-records-to-graphql-connection.helper.ts b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/helpers/object-records-to-graphql-connection.helper.ts index b91b0301b2..777919d2f5 100644 --- a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/helpers/object-records-to-graphql-connection.helper.ts +++ b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/helpers/object-records-to-graphql-connection.helper.ts @@ -20,6 +20,7 @@ import { ObjectMetadataMaps } from 'src/engine/metadata-modules/types/object-met import { getObjectMetadataMapItemByNameSingular } from 'src/engine/metadata-modules/utils/get-object-metadata-map-item-by-name-singular.util'; import { CompositeFieldMetadataType } from 'src/engine/metadata-modules/workspace-migration/factories/composite-column-action.factory'; import { isRelationFieldMetadataType } from 'src/engine/utils/is-relation-field-metadata-type.util'; +import { isDefined } from 'src/utils/is-defined'; import { isPlainObject } from 'src/utils/is-plain-object'; export class ObjectRecordsToGraphqlConnectionHelper { @@ -95,7 +96,7 @@ export class ObjectRecordsToGraphqlConnectionHelper { selectedAggregatedFields: Record; objectRecordsAggregatedValues: Record; }) => { - if (!objectRecordsAggregatedValues) { + if (!isDefined(objectRecordsAggregatedValues)) { return {}; } @@ -104,7 +105,7 @@ export class ObjectRecordsToGraphqlConnectionHelper { const aggregatedFieldValue = objectRecordsAggregatedValues[aggregatedFieldName]; - if (!aggregatedFieldValue) { + if (!isDefined(aggregatedFieldValue)) { return acc; } From 3f58a41d2fd9df88ff13b7b78c192c8ec8ecad3e Mon Sep 17 00:00:00 2001 From: Weiko Date: Thu, 19 Dec 2024 16:41:04 +0100 Subject: [PATCH 03/11] Fix sort with pagination and composite fields (#9150) Fixes https://github.com/twentyhq/twenty/issues/8863 ## Description This PR fixes an issue with cursor-based pagination when dealing with composite fields (like `fullName`). Previously, the pagination direction was incorrectly determined for composite fields because the code wasn't properly handling nested object structures in the `orderBy` parameter. Refactored the code accordingly. --- .../graphql-query-order.parser.ts | 5 +- .../compute-cursor-arg-filter.spec.ts | 141 ++++++++++++++++++ .../utils/compute-cursor-arg-filter.ts | 111 ++++++++++---- .../interfaces/object-record.interface.ts | 4 +- 4 files changed, 228 insertions(+), 33 deletions(-) create mode 100644 packages/twenty-server/src/engine/api/graphql/graphql-query-runner/utils/__tests__/compute-cursor-arg-filter.spec.ts diff --git a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query-order/graphql-query-order.parser.ts b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query-order/graphql-query-order.parser.ts index a16a9c0c14..681613c282 100644 --- a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query-order/graphql-query-order.parser.ts +++ b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query-order/graphql-query-order.parser.ts @@ -48,7 +48,10 @@ export class GraphqlQueryOrderFieldParser { Object.assign(acc, compositeOrder); } else { acc[`"${objectNameSingular}"."${key}"`] = - this.convertOrderByToFindOptionsOrder(value, isForwardPagination); + this.convertOrderByToFindOptionsOrder( + value as OrderByDirection, + isForwardPagination, + ); } }); diff --git a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/utils/__tests__/compute-cursor-arg-filter.spec.ts b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/utils/__tests__/compute-cursor-arg-filter.spec.ts new file mode 100644 index 0000000000..8aaaeb003b --- /dev/null +++ b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/utils/__tests__/compute-cursor-arg-filter.spec.ts @@ -0,0 +1,141 @@ +import { OrderByDirection } from 'src/engine/api/graphql/workspace-query-builder/interfaces/object-record.interface'; + +import { GraphqlQueryRunnerException } from 'src/engine/api/graphql/graphql-query-runner/errors/graphql-query-runner.exception'; +import { computeCursorArgFilter } from 'src/engine/api/graphql/graphql-query-runner/utils/compute-cursor-arg-filter'; +import { FieldMetadataType } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity'; + +describe('computeCursorArgFilter', () => { + const mockFieldMetadataMap = { + name: { + type: FieldMetadataType.TEXT, + id: 'name-id', + name: 'name', + label: 'Name', + objectMetadataId: 'object-id', + }, + age: { + type: FieldMetadataType.NUMBER, + id: 'age-id', + name: 'age', + label: 'Age', + objectMetadataId: 'object-id', + }, + fullName: { + type: FieldMetadataType.FULL_NAME, + id: 'fullname-id', + name: 'fullName', + label: 'Full Name', + objectMetadataId: 'object-id', + }, + }; + + describe('basic cursor filtering', () => { + it('should return empty array when cursor is empty', () => { + const result = computeCursorArgFilter({}, [], mockFieldMetadataMap, true); + + expect(result).toEqual([]); + }); + + it('should compute forward pagination filter for single field', () => { + const cursor = { name: 'John' }; + const orderBy = [{ name: OrderByDirection.AscNullsLast }]; + + const result = computeCursorArgFilter( + cursor, + orderBy, + mockFieldMetadataMap, + true, + ); + + expect(result).toEqual([{ name: { gt: 'John' } }]); + }); + + it('should compute backward pagination filter for single field', () => { + const cursor = { name: 'John' }; + const orderBy = [{ name: OrderByDirection.AscNullsLast }]; + + const result = computeCursorArgFilter( + cursor, + orderBy, + mockFieldMetadataMap, + false, + ); + + expect(result).toEqual([{ name: { lt: 'John' } }]); + }); + }); + + describe('multiple fields cursor filtering', () => { + it('should handle multiple cursor fields with forward pagination', () => { + const cursor = { name: 'John', age: 30 }; + const orderBy = [ + { name: OrderByDirection.AscNullsLast }, + { age: OrderByDirection.DescNullsLast }, + ]; + + const result = computeCursorArgFilter( + cursor, + orderBy, + mockFieldMetadataMap, + true, + ); + + expect(result).toEqual([ + { name: { gt: 'John' } }, + { name: { eq: 'John' }, age: { lt: 30 } }, + ]); + }); + }); + + describe('composite field handling', () => { + it('should handle fullName composite field', () => { + const cursor = { + fullName: { firstName: 'John', lastName: 'Doe' }, + }; + const orderBy = [ + { + fullName: { + firstName: OrderByDirection.AscNullsLast, + lastName: OrderByDirection.AscNullsLast, + }, + }, + ]; + + const result = computeCursorArgFilter( + cursor, + orderBy, + mockFieldMetadataMap, + true, + ); + + expect(result).toEqual([ + { + fullName: { + firstName: { gt: 'John' }, + lastName: { gt: 'Doe' }, + }, + }, + ]); + }); + }); + + describe('error handling', () => { + it('should throw error for invalid field metadata', () => { + const cursor = { invalidField: 'value' }; + const orderBy = [{ invalidField: OrderByDirection.AscNullsLast }]; + + expect(() => + computeCursorArgFilter(cursor, orderBy, mockFieldMetadataMap, true), + ).toThrow(GraphqlQueryRunnerException); + }); + + it('should throw error for missing orderBy entry', () => { + const cursor = { name: 'John' }; + const orderBy = [{ age: OrderByDirection.AscNullsLast }]; + + expect(() => + computeCursorArgFilter(cursor, orderBy, mockFieldMetadataMap, true), + ).toThrow(GraphqlQueryRunnerException); + }); + }); +}); diff --git a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/utils/compute-cursor-arg-filter.ts b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/utils/compute-cursor-arg-filter.ts index 02cd804447..030c515251 100644 --- a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/utils/compute-cursor-arg-filter.ts +++ b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/utils/compute-cursor-arg-filter.ts @@ -13,6 +13,42 @@ import { FieldMetadataType } from 'src/engine/metadata-modules/field-metadata/fi import { isCompositeFieldMetadataType } from 'src/engine/metadata-modules/field-metadata/utils/is-composite-field-metadata-type.util'; import { FieldMetadataMap } from 'src/engine/metadata-modules/types/field-metadata-map'; +const computeOperator = ( + isAscending: boolean, + isForwardPagination: boolean, + defaultOperator?: string, +): string => { + if (defaultOperator) return defaultOperator; + + return isAscending + ? isForwardPagination + ? 'gt' + : 'lt' + : isForwardPagination + ? 'lt' + : 'gt'; +}; + +const validateAndGetOrderBy = ( + key: string, + orderBy: ObjectRecordOrderBy, +): Record => { + const keyOrderBy = orderBy.find((order) => key in order); + + if (!keyOrderBy) { + throw new GraphqlQueryRunnerException( + 'Invalid cursor', + GraphqlQueryRunnerExceptionCode.INVALID_CURSOR, + ); + } + + return keyOrderBy; +}; + +const isAscendingOrder = (direction: OrderByDirection): boolean => + direction === OrderByDirection.AscNullsFirst || + direction === OrderByDirection.AscNullsLast; + export const computeCursorArgFilter = ( cursor: Record, orderBy: ObjectRecordOrderBy, @@ -40,35 +76,22 @@ export const computeCursorArgFilter = ( cursorKeys[subConditionIndex], cursorValues[subConditionIndex], fieldMetadataMapByName, + orderBy, + isForwardPagination, 'eq', ), }; } - const keyOrderBy = orderBy.find((order) => key in order); - - if (!keyOrderBy) { - throw new GraphqlQueryRunnerException( - 'Invalid cursor', - GraphqlQueryRunnerExceptionCode.INVALID_CURSOR, - ); - } - - const isAscending = - keyOrderBy[key] === OrderByDirection.AscNullsFirst || - keyOrderBy[key] === OrderByDirection.AscNullsLast; - - const operator = isAscending - ? isForwardPagination - ? 'gt' - : 'lt' - : isForwardPagination - ? 'lt' - : 'gt'; - return { ...whereCondition, - ...buildWhereCondition(key, value, fieldMetadataMapByName, operator), + ...buildWhereCondition( + key, + value, + fieldMetadataMapByName, + orderBy, + isForwardPagination, + ), } as ObjectRecordFilter; }); }; @@ -77,7 +100,9 @@ const buildWhereCondition = ( key: string, value: any, fieldMetadataMapByName: FieldMetadataMap, - operator: string, + orderBy: ObjectRecordOrderBy, + isForwardPagination: boolean, + operator?: string, ): Record => { const fieldMetadata = fieldMetadataMapByName[key]; @@ -93,18 +118,30 @@ const buildWhereCondition = ( key, value, fieldMetadata.type, + orderBy, + isForwardPagination, operator, ); } - return { [key]: { [operator]: value } }; + const keyOrderBy = validateAndGetOrderBy(key, orderBy); + const isAscending = isAscendingOrder(keyOrderBy[key]); + const computedOperator = computeOperator( + isAscending, + isForwardPagination, + operator, + ); + + return { [key]: { [computedOperator]: value } }; }; const buildCompositeWhereCondition = ( key: string, value: any, fieldType: FieldMetadataType, - operator: string, + orderBy: ObjectRecordOrderBy, + isForwardPagination: boolean, + operator?: string, ): Record => { const compositeType = compositeTypeDefinitions.get(fieldType); @@ -115,18 +152,30 @@ const buildCompositeWhereCondition = ( ); } + const keyOrderBy = validateAndGetOrderBy(key, orderBy); const result: Record = {}; compositeType.properties.forEach((property) => { if ( - property.type !== FieldMetadataType.RAW_JSON && - value[property.name] !== undefined + property.type === FieldMetadataType.RAW_JSON || + value[property.name] === undefined ) { - result[key] = { - ...result[key], - [property.name]: { [operator]: value[property.name] }, - }; + return; } + + const isAscending = isAscendingOrder(keyOrderBy[key][property.name]); + const computedOperator = computeOperator( + isAscending, + isForwardPagination, + operator, + ); + + result[key] = { + ...result[key], + [property.name]: { + [computedOperator]: value[property.name], + }, + }; }); return result; diff --git a/packages/twenty-server/src/engine/api/graphql/workspace-query-builder/interfaces/object-record.interface.ts b/packages/twenty-server/src/engine/api/graphql/workspace-query-builder/interfaces/object-record.interface.ts index a93e752e22..5c0395acac 100644 --- a/packages/twenty-server/src/engine/api/graphql/workspace-query-builder/interfaces/object-record.interface.ts +++ b/packages/twenty-server/src/engine/api/graphql/workspace-query-builder/interfaces/object-record.interface.ts @@ -18,7 +18,9 @@ export enum OrderByDirection { } export type ObjectRecordOrderBy = Array<{ - [Property in keyof ObjectRecord]?: OrderByDirection; + [Property in keyof ObjectRecord]?: + | OrderByDirection + | Record; }>; export interface ObjectRecordDuplicateCriteria { From 360c34fd18081b15125dfc784135c7ab331cdb24 Mon Sep 17 00:00:00 2001 From: Guillim Date: Thu, 19 Dec 2024 16:42:18 +0100 Subject: [PATCH 04/11] Phone country code unique (#9035) fix #8775 --- .../useRightDrawerEmailThread.test.tsx | 4 + .../favorites/hooks/__mocks__/useFavorites.ts | 4 + .../mapFieldMetadataToGraphQLQuery.test.ts | 1 + .../mapObjectMetadataToGraphQLQuery.test.ts | 1 + .../utils/mapFieldMetadataToGraphQLQuery.ts | 1 + .../hooks/__mocks__/personFragments.ts | 4 + .../hooks/__mocks__/useCreateManyRecords.ts | 1 + .../hooks/__mocks__/useCreateOneRecord.ts | 1 + .../useLazyLoadRecordIndexTable.test.tsx | 4 + .../hooks/__tests__/usePersistField.test.tsx | 6 +- .../__tests__/useToggleEditOnlyInput.test.tsx | 2 + .../input/components/PhonesFieldInput.tsx | 25 +- .../input/utils/__tests__/phonesUtils.test.ts | 67 +++- .../meta-types/input/utils/phonesUtils.ts | 5 +- .../types/FieldInputDraftValue.ts | 1 + .../record-field/types/FieldMetadata.ts | 7 +- .../types/guards/isFieldPhonesValue.ts | 9 +- .../utils/computeDraftValueFromFieldValue.ts | 3 + .../__tests__/useExportFetchRecords.test.ts | 1 + .../components/__stories__/perf/mock.ts | 3 +- ...jectRecordsSpreadsheetImportDialog.test.ts | 2 + .../utils/generateEmptyFieldValue.ts | 1 + .../SettingsCompositeFieldTypeConfigs.ts | 5 +- .../SettingsDataModelFieldPhonesForm.tsx | 24 +- .../utils/getPhonesFieldPreviewValue.ts | 28 ++ .../display/components/PhonesDisplay.tsx | 8 +- .../generated/mock-metadata-query-result.ts | 6 +- .../src/testing/mock-data/people.ts | 48 ++- .../commands/active-workspaces.command.ts | 5 - .../src/database/commands/base.command.ts | 21 +- .../commands/database-command.module.ts | 2 +- .../src/database/commands/logger.ts | 46 +++ ...hone-calling-code-create-column.command.ts | 144 +++++++++ ...phone-calling-code-migrate-data.command.ts | 302 ++++++++++++++++++ .../0-40/0-40-upgrade-version.command.ts | 22 +- .../0-40/0-40-upgrade-version.module.ts | 19 ++ .../typeorm-seeds/metadata/fieldsMetadata.ts | 13 +- .../typeorm-seeds/workspace/people.ts | 122 ++++--- .../__mocks__/object-metadata-item.mock.ts | 1 + ...p-field-metadata-to-graphql-query.utils.ts | 1 + .../utils/__tests__/components.utils.spec.ts | 9 + .../open-api/utils/components.utils.ts | 3 + .../composite-types/phones.composite-type.ts | 7 + .../dtos/default-value.input.ts | 4 + .../utils/generate-default-value.ts | 1 + .../src/test/creates/crud_record.test.ts | 7 +- .../src/test/utils/handleQueryParams.test.ts | 9 +- 47 files changed, 878 insertions(+), 132 deletions(-) create mode 100644 packages/twenty-server/src/database/commands/logger.ts create mode 100644 packages/twenty-server/src/database/commands/upgrade-version/0-40/0-40-phone-calling-code-create-column.command.ts create mode 100644 packages/twenty-server/src/database/commands/upgrade-version/0-40/0-40-phone-calling-code-migrate-data.command.ts diff --git a/packages/twenty-front/src/modules/activities/emails/right-drawer/hooks/__tests__/useRightDrawerEmailThread.test.tsx b/packages/twenty-front/src/modules/activities/emails/right-drawer/hooks/__tests__/useRightDrawerEmailThread.test.tsx index 0af5cec822..f71f7c40dc 100644 --- a/packages/twenty-front/src/modules/activities/emails/right-drawer/hooks/__tests__/useRightDrawerEmailThread.test.tsx +++ b/packages/twenty-front/src/modules/activities/emails/right-drawer/hooks/__tests__/useRightDrawerEmailThread.test.tsx @@ -88,6 +88,7 @@ const mocks = [ phones { primaryPhoneNumber primaryPhoneCountryCode + primaryPhoneCallingCode additionalPhones } position @@ -95,6 +96,7 @@ const mocks = [ whatsapp { primaryPhoneNumber primaryPhoneCountryCode + primaryPhoneCallingCode additionalPhones } workPreference @@ -246,6 +248,7 @@ const mocks = [ phones { primaryPhoneNumber primaryPhoneCountryCode + primaryPhoneCallingCode additionalPhones } position @@ -253,6 +256,7 @@ const mocks = [ whatsapp { primaryPhoneNumber primaryPhoneCountryCode + primaryPhoneCallingCode additionalPhones } workPreference diff --git a/packages/twenty-front/src/modules/favorites/hooks/__mocks__/useFavorites.ts b/packages/twenty-front/src/modules/favorites/hooks/__mocks__/useFavorites.ts index 154bad46cf..50c194f433 100644 --- a/packages/twenty-front/src/modules/favorites/hooks/__mocks__/useFavorites.ts +++ b/packages/twenty-front/src/modules/favorites/hooks/__mocks__/useFavorites.ts @@ -246,6 +246,7 @@ mutation UpdateOneFavorite( phones { primaryPhoneNumber primaryPhoneCountryCode + primaryPhoneCallingCode additionalPhones } position @@ -253,6 +254,7 @@ mutation UpdateOneFavorite( whatsapp { primaryPhoneNumber primaryPhoneCountryCode + primaryPhoneCallingCode additionalPhones } workPreference @@ -532,6 +534,7 @@ export const mocks = [ phones { primaryPhoneNumber primaryPhoneCountryCode + primaryPhoneCallingCode additionalPhones } position @@ -539,6 +542,7 @@ export const mocks = [ whatsapp { primaryPhoneNumber primaryPhoneCountryCode + primaryPhoneCallingCode additionalPhones } workPreference diff --git a/packages/twenty-front/src/modules/object-metadata/utils/__tests__/mapFieldMetadataToGraphQLQuery.test.ts b/packages/twenty-front/src/modules/object-metadata/utils/__tests__/mapFieldMetadataToGraphQLQuery.test.ts index 7208246e7e..6a41f42f71 100644 --- a/packages/twenty-front/src/modules/object-metadata/utils/__tests__/mapFieldMetadataToGraphQLQuery.test.ts +++ b/packages/twenty-front/src/modules/object-metadata/utils/__tests__/mapFieldMetadataToGraphQLQuery.test.ts @@ -198,6 +198,7 @@ phone { primaryPhoneNumber primaryPhoneCountryCode + primaryPhoneCallingCode } linkedinLink { diff --git a/packages/twenty-front/src/modules/object-metadata/utils/__tests__/mapObjectMetadataToGraphQLQuery.test.ts b/packages/twenty-front/src/modules/object-metadata/utils/__tests__/mapObjectMetadataToGraphQLQuery.test.ts index d2650b6980..1f39e7a59e 100644 --- a/packages/twenty-front/src/modules/object-metadata/utils/__tests__/mapObjectMetadataToGraphQLQuery.test.ts +++ b/packages/twenty-front/src/modules/object-metadata/utils/__tests__/mapObjectMetadataToGraphQLQuery.test.ts @@ -48,6 +48,7 @@ describe('mapObjectMetadataToGraphQLQuery', () => { { primaryPhoneNumber primaryPhoneCountryCode + primaryPhoneCallingCode } createdAt avatarUrl diff --git a/packages/twenty-front/src/modules/object-metadata/utils/mapFieldMetadataToGraphQLQuery.ts b/packages/twenty-front/src/modules/object-metadata/utils/mapFieldMetadataToGraphQLQuery.ts index bf29d99ee1..4cd8dbcbb0 100644 --- a/packages/twenty-front/src/modules/object-metadata/utils/mapFieldMetadataToGraphQLQuery.ts +++ b/packages/twenty-front/src/modules/object-metadata/utils/mapFieldMetadataToGraphQLQuery.ts @@ -157,6 +157,7 @@ ${mapObjectMetadataToGraphQLQuery({ { primaryPhoneNumber primaryPhoneCountryCode + primaryPhoneCallingCode additionalPhones }`; } diff --git a/packages/twenty-front/src/modules/object-record/hooks/__mocks__/personFragments.ts b/packages/twenty-front/src/modules/object-record/hooks/__mocks__/personFragments.ts index 97077eaacf..59c877457f 100644 --- a/packages/twenty-front/src/modules/object-record/hooks/__mocks__/personFragments.ts +++ b/packages/twenty-front/src/modules/object-record/hooks/__mocks__/personFragments.ts @@ -30,6 +30,7 @@ export const PERSON_FRAGMENT_WITH_DEPTH_ZERO_RELATIONS = ` phones { primaryPhoneNumber primaryPhoneCountryCode + primaryPhoneCallingCode additionalPhones } position @@ -37,6 +38,7 @@ export const PERSON_FRAGMENT_WITH_DEPTH_ZERO_RELATIONS = ` whatsapp { primaryPhoneNumber primaryPhoneCountryCode + primaryPhoneCallingCode additionalPhones } workPreference @@ -229,6 +231,7 @@ export const PERSON_FRAGMENT_WITH_DEPTH_ONE_RELATIONS = ` phones { primaryPhoneNumber primaryPhoneCountryCode + primaryPhoneCallingCode additionalPhones } pointOfContactForOpportunities { @@ -305,6 +308,7 @@ export const PERSON_FRAGMENT_WITH_DEPTH_ONE_RELATIONS = ` whatsapp { primaryPhoneNumber primaryPhoneCountryCode + primaryPhoneCallingCode additionalPhones } workPreference diff --git a/packages/twenty-front/src/modules/object-record/hooks/__mocks__/useCreateManyRecords.ts b/packages/twenty-front/src/modules/object-record/hooks/__mocks__/useCreateManyRecords.ts index 4202b495c6..9edba4dd4a 100644 --- a/packages/twenty-front/src/modules/object-record/hooks/__mocks__/useCreateManyRecords.ts +++ b/packages/twenty-front/src/modules/object-record/hooks/__mocks__/useCreateManyRecords.ts @@ -38,6 +38,7 @@ export const responseData = { }, phones: { primaryPhoneCountryCode: '', + primaryPhoneCallingCode: '', primaryPhoneNumber: '', }, linkedinLink: { diff --git a/packages/twenty-front/src/modules/object-record/hooks/__mocks__/useCreateOneRecord.ts b/packages/twenty-front/src/modules/object-record/hooks/__mocks__/useCreateOneRecord.ts index 00522c295e..1477530b90 100644 --- a/packages/twenty-front/src/modules/object-record/hooks/__mocks__/useCreateOneRecord.ts +++ b/packages/twenty-front/src/modules/object-record/hooks/__mocks__/useCreateOneRecord.ts @@ -43,6 +43,7 @@ export const responseData = { }, phones: { primaryPhoneCountryCode: '', + primaryPhoneCallingCode: '', primaryPhoneNumber: '', }, linkedinLink: { diff --git a/packages/twenty-front/src/modules/object-record/hooks/__tests__/useLazyLoadRecordIndexTable.test.tsx b/packages/twenty-front/src/modules/object-record/hooks/__tests__/useLazyLoadRecordIndexTable.test.tsx index a6232fefa5..4cebe899e0 100644 --- a/packages/twenty-front/src/modules/object-record/hooks/__tests__/useLazyLoadRecordIndexTable.test.tsx +++ b/packages/twenty-front/src/modules/object-record/hooks/__tests__/useLazyLoadRecordIndexTable.test.tsx @@ -178,6 +178,7 @@ const mocks: MockedResponse[] = [ phones { primaryPhoneNumber primaryPhoneCountryCode + primaryPhoneCallingCode additionalPhones } position @@ -185,6 +186,7 @@ const mocks: MockedResponse[] = [ whatsapp { primaryPhoneNumber primaryPhoneCountryCode + primaryPhoneCallingCode additionalPhones } workPreference @@ -332,6 +334,7 @@ const mocks: MockedResponse[] = [ phones { primaryPhoneNumber primaryPhoneCountryCode + primaryPhoneCallingCode additionalPhones } position @@ -339,6 +342,7 @@ const mocks: MockedResponse[] = [ whatsapp { primaryPhoneNumber primaryPhoneCountryCode + primaryPhoneCallingCode additionalPhones } workPreference diff --git a/packages/twenty-front/src/modules/object-record/record-field/hooks/__tests__/usePersistField.test.tsx b/packages/twenty-front/src/modules/object-record/record-field/hooks/__tests__/usePersistField.test.tsx index 4eea3aae83..be8d16d1aa 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/hooks/__tests__/usePersistField.test.tsx +++ b/packages/twenty-front/src/modules/object-record/record-field/hooks/__tests__/usePersistField.test.tsx @@ -39,7 +39,8 @@ const mocks: MockedResponse[] = [ input: { phones: { primaryPhoneNumber: '123 456', - primaryPhoneCountryCode: '+1', + primaryPhoneCountryCode: 'US', + primaryPhoneCallingCode: '+1', additionalPhones: [], }, }, @@ -134,7 +135,8 @@ describe('usePersistField', () => { act(() => { result.current.persistField({ primaryPhoneNumber: '123 456', - primaryPhoneCountryCode: '+1', + primaryPhoneCountryCode: 'US', + primaryPhoneCallingCode: '+1', additionalPhones: [], }); }); diff --git a/packages/twenty-front/src/modules/object-record/record-field/hooks/__tests__/useToggleEditOnlyInput.test.tsx b/packages/twenty-front/src/modules/object-record/record-field/hooks/__tests__/useToggleEditOnlyInput.test.tsx index 79b1a27166..7149e7c801 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/hooks/__tests__/useToggleEditOnlyInput.test.tsx +++ b/packages/twenty-front/src/modules/object-record/record-field/hooks/__tests__/useToggleEditOnlyInput.test.tsx @@ -208,6 +208,7 @@ const mocks: MockedResponse[] = [ phones { primaryPhoneNumber primaryPhoneCountryCode + primaryPhoneCallingCode additionalPhones } position @@ -215,6 +216,7 @@ const mocks: MockedResponse[] = [ whatsapp { primaryPhoneNumber primaryPhoneCountryCode + primaryPhoneCallingCode additionalPhones } workPreference diff --git a/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/PhonesFieldInput.tsx b/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/PhonesFieldInput.tsx index 58d8efe5ff..cca5d9b8a3 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/PhonesFieldInput.tsx +++ b/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/PhonesFieldInput.tsx @@ -9,12 +9,11 @@ import { TEXT_INPUT_STYLE } from 'twenty-ui'; import { MultiItemFieldInput } from './MultiItemFieldInput'; import { createPhonesFromFieldValue } from '@/object-record/record-field/meta-types/input/utils/phonesUtils'; -import { useCountries } from '@/ui/input/components/internal/hooks/useCountries'; import { PhoneCountryPickerDropdownButton } from '@/ui/input/components/internal/phone/components/PhoneCountryPickerDropdownButton'; import { FieldMetadataType } from '~/generated-metadata/graphql'; import { stripSimpleQuotesFromString } from '~/utils/string/stripSimpleQuotesFromString'; -export const DEFAULT_PHONE_COUNTRY_CODE = '1'; +export const DEFAULT_PHONE_CALLING_CODE = '1'; const StyledCustomPhoneInput = styled(ReactPhoneNumberInput)` font-family: ${({ theme }) => theme.font.family}; @@ -60,22 +59,22 @@ export const PhonesFieldInput = ({ const phones = createPhonesFromFieldValue(fieldValue); - const defaultCallingCode = - stripSimpleQuotesFromString( - fieldDefinition?.defaultValue?.primaryPhoneCountryCode, - ) ?? DEFAULT_PHONE_COUNTRY_CODE; - // TODO : improve once we store the real country code - const defaultCountry = useCountries().find( - (obj) => `+${obj.callingCode}` === defaultCallingCode, - )?.countryCode; + const defaultCountry = stripSimpleQuotesFromString( + fieldDefinition?.defaultValue?.primaryPhoneCountryCode, + ); const handlePersistPhones = ( - updatedPhones: { number: string; callingCode: string }[], + updatedPhones: { + number: string; + countryCode: string; + callingCode: string; + }[], ) => { const [nextPrimaryPhone, ...nextAdditionalPhones] = updatedPhones; persistPhonesField({ primaryPhoneNumber: nextPrimaryPhone?.number ?? '', - primaryPhoneCountryCode: nextPrimaryPhone?.callingCode ?? '', + primaryPhoneCountryCode: nextPrimaryPhone?.countryCode ?? '', + primaryPhoneCallingCode: nextPrimaryPhone?.callingCode ?? '', additionalPhones: nextAdditionalPhones, }); }; @@ -96,11 +95,13 @@ export const PhonesFieldInput = ({ return { number: phone.nationalNumber, callingCode: `+${phone.countryCallingCode}`, + countryCode: phone.country as string, }; } return { number: '', callingCode: '', + countryCode: '', }; }} renderItem={({ diff --git a/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/utils/__tests__/phonesUtils.test.ts b/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/utils/__tests__/phonesUtils.test.ts index 11f28a19b8..ddb7075bc4 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/utils/__tests__/phonesUtils.test.ts +++ b/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/utils/__tests__/phonesUtils.test.ts @@ -19,7 +19,8 @@ describe('createPhonesFromFieldValue test suite', () => { it('should return an array with primary phone number if it is defined', () => { const fieldValue: FieldPhonesValue = { primaryPhoneNumber: '123456789', - primaryPhoneCountryCode: '+1', + primaryPhoneCountryCode: 'US', + primaryPhoneCallingCode: '+1', additionalPhones: [], }; const result = createPhonesFromFieldValue(fieldValue); @@ -27,6 +28,24 @@ describe('createPhonesFromFieldValue test suite', () => { { number: '123456789', callingCode: '+1', + countryCode: 'US', + }, + ]); + }); + + it('should return an array with primary phone number if it is defined, even with incorrect callingCode', () => { + const fieldValue: FieldPhonesValue = { + primaryPhoneNumber: '123456789', + primaryPhoneCountryCode: 'US', + primaryPhoneCallingCode: '+33', + additionalPhones: [], + }; + const result = createPhonesFromFieldValue(fieldValue); + expect(result).toEqual([ + { + number: '123456789', + callingCode: '+33', + countryCode: 'US', }, ]); }); @@ -34,10 +53,11 @@ describe('createPhonesFromFieldValue test suite', () => { it('should return an array with both primary and additional phones if they are defined', () => { const fieldValue: FieldPhonesValue = { primaryPhoneNumber: '123456789', - primaryPhoneCountryCode: '+1', + primaryPhoneCountryCode: 'US', + primaryPhoneCallingCode: '+1', additionalPhones: [ - { number: '987654321', callingCode: '+44' }, - { number: '555555555', callingCode: '+33' }, + { number: '987654321', callingCode: '+44', countryCode: 'GB' }, + { number: '555555555', callingCode: '+33', countryCode: 'FR' }, ], }; const result = createPhonesFromFieldValue(fieldValue); @@ -45,9 +65,10 @@ describe('createPhonesFromFieldValue test suite', () => { { number: '123456789', callingCode: '+1', + countryCode: 'US', }, - { number: '987654321', callingCode: '+44' }, - { number: '555555555', callingCode: '+33' }, + { number: '987654321', callingCode: '+44', countryCode: 'GB' }, + { number: '555555555', callingCode: '+33', countryCode: 'FR' }, ]); }); @@ -56,14 +77,14 @@ describe('createPhonesFromFieldValue test suite', () => { primaryPhoneNumber: '', primaryPhoneCountryCode: '', additionalPhones: [ - { number: '987654321', callingCode: '+44' }, - { number: '555555555', callingCode: '+33' }, + { number: '987654321', callingCode: '+44', countryCode: 'GB' }, + { number: '555555555', callingCode: '+33', countryCode: 'FR' }, ], }; const result = createPhonesFromFieldValue(fieldValue); expect(result).toEqual([ - { number: '987654321', callingCode: '+44' }, - { number: '555555555', callingCode: '+33' }, + { number: '987654321', callingCode: '+44', countryCode: 'GB' }, + { number: '555555555', callingCode: '+33', countryCode: 'FR' }, ]); }); @@ -72,22 +93,34 @@ describe('createPhonesFromFieldValue test suite', () => { primaryPhoneNumber: ' ', primaryPhoneCountryCode: '', additionalPhones: [ - { number: '987654321', callingCode: '+44' }, - { number: '555555555', callingCode: '+33' }, + { number: '987654321', callingCode: '+44', countryCode: 'GB' }, + { number: '555555555', callingCode: '+33', countryCode: 'FR' }, ], }; const result = createPhonesFromFieldValue(fieldValue); expect(result).toEqual([ - { number: ' ', callingCode: '' }, - { number: '987654321', callingCode: '+44' }, - { number: '555555555', callingCode: '+33' }, + { number: ' ', callingCode: '', countryCode: '' }, + { number: '987654321', callingCode: '+44', countryCode: 'GB' }, + { number: '555555555', callingCode: '+33', countryCode: 'FR' }, ]); }); - it('should return an empty array if only country code is defined', () => { + it('should return an empty array if only country and calling code are defined', () => { const fieldValue: FieldPhonesValue = { primaryPhoneNumber: '', - primaryPhoneCountryCode: '+33', + primaryPhoneCountryCode: 'FR', + primaryPhoneCallingCode: '+33', + additionalPhones: [], + }; + const result = createPhonesFromFieldValue(fieldValue); + expect(result).toEqual([]); + }); + + it('should return an empty array if only calling code is defined', () => { + const fieldValue: FieldPhonesValue = { + primaryPhoneNumber: '', + primaryPhoneCallingCode: '+33', + primaryPhoneCountryCode: '', additionalPhones: [], }; const result = createPhonesFromFieldValue(fieldValue); diff --git a/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/utils/phonesUtils.ts b/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/utils/phonesUtils.ts index 56f413d8aa..b9e5db9526 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/utils/phonesUtils.ts +++ b/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/utils/phonesUtils.ts @@ -8,7 +8,10 @@ export const createPhonesFromFieldValue = (fieldValue: FieldPhonesValue) => { fieldValue.primaryPhoneNumber ? { number: fieldValue.primaryPhoneNumber, - callingCode: fieldValue.primaryPhoneCountryCode, + callingCode: fieldValue.primaryPhoneCallingCode + ? fieldValue.primaryPhoneCallingCode + : fieldValue.primaryPhoneCountryCode, + countryCode: fieldValue.primaryPhoneCountryCode, } : null, ...(fieldValue.additionalPhones ?? []), diff --git a/packages/twenty-front/src/modules/object-record/record-field/types/FieldInputDraftValue.ts b/packages/twenty-front/src/modules/object-record/record-field/types/FieldInputDraftValue.ts index 6bd2ee6681..9d8bf3105f 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/types/FieldInputDraftValue.ts +++ b/packages/twenty-front/src/modules/object-record/record-field/types/FieldInputDraftValue.ts @@ -27,6 +27,7 @@ export type FieldDateTimeDraftValue = string; export type FieldPhonesDraftValue = { primaryPhoneNumber: string; primaryPhoneCountryCode: string; + primaryPhoneCallingCode: string; additionalPhones?: PhoneRecord[] | null; }; export type FieldEmailsDraftValue = { diff --git a/packages/twenty-front/src/modules/object-record/record-field/types/FieldMetadata.ts b/packages/twenty-front/src/modules/object-record/record-field/types/FieldMetadata.ts index 0e0875b502..f61159ac27 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/types/FieldMetadata.ts +++ b/packages/twenty-front/src/modules/object-record/record-field/types/FieldMetadata.ts @@ -265,10 +265,15 @@ export type FieldActorValue = { export type FieldArrayValue = string[]; -export type PhoneRecord = { number: string; callingCode: string }; +export type PhoneRecord = { + number: string; + callingCode: string; + countryCode: string; +}; export type FieldPhonesValue = { primaryPhoneNumber: string; primaryPhoneCountryCode: string; + primaryPhoneCallingCode?: string; additionalPhones?: PhoneRecord[] | null; }; diff --git a/packages/twenty-front/src/modules/object-record/record-field/types/guards/isFieldPhonesValue.ts b/packages/twenty-front/src/modules/object-record/record-field/types/guards/isFieldPhonesValue.ts index 90cb812da9..178240825a 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/types/guards/isFieldPhonesValue.ts +++ b/packages/twenty-front/src/modules/object-record/record-field/types/guards/isFieldPhonesValue.ts @@ -5,8 +5,15 @@ import { FieldPhonesValue } from '../FieldMetadata'; export const phonesSchema = z.object({ primaryPhoneNumber: z.string(), primaryPhoneCountryCode: z.string(), + primaryPhoneCallingCode: z.string(), additionalPhones: z - .array(z.object({ number: z.string(), callingCode: z.string() })) + .array( + z.object({ + number: z.string(), + callingCode: z.string(), + countryCode: z.string(), + }), + ) .nullable(), }) satisfies z.ZodType; diff --git a/packages/twenty-front/src/modules/object-record/record-field/utils/computeDraftValueFromFieldValue.ts b/packages/twenty-front/src/modules/object-record/record-field/utils/computeDraftValueFromFieldValue.ts index a22e42259a..4e5b407f2c 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/utils/computeDraftValueFromFieldValue.ts +++ b/packages/twenty-front/src/modules/object-record/record-field/utils/computeDraftValueFromFieldValue.ts @@ -71,6 +71,9 @@ export const computeDraftValueFromFieldValue = ({ primaryPhoneCountryCode: stripSimpleQuotesFromString( fieldDefinition?.defaultValue?.primaryPhoneCountryCode, ), + primaryPhoneCallingCode: stripSimpleQuotesFromString( + fieldDefinition?.defaultValue?.primaryPhoneCallingCode, + ), } as unknown as FieldInputDraftValue; } diff --git a/packages/twenty-front/src/modules/object-record/record-index/export/hooks/__tests__/useExportFetchRecords.test.ts b/packages/twenty-front/src/modules/object-record/record-index/export/hooks/__tests__/useExportFetchRecords.test.ts index 21e9775c0d..807f15f58e 100644 --- a/packages/twenty-front/src/modules/object-record/record-index/export/hooks/__tests__/useExportFetchRecords.test.ts +++ b/packages/twenty-front/src/modules/object-record/record-index/export/hooks/__tests__/useExportFetchRecords.test.ts @@ -21,6 +21,7 @@ const mockPerson = { whatsapp: { primaryPhoneNumber: '+1', primaryPhoneCountryCode: '234-567-890', + primaryPhoneCallingCode: '+33', additionalPhones: [], }, linkedinLink: { diff --git a/packages/twenty-front/src/modules/object-record/record-table/components/__stories__/perf/mock.ts b/packages/twenty-front/src/modules/object-record/record-table/components/__stories__/perf/mock.ts index ce0253ddab..7e828037b1 100644 --- a/packages/twenty-front/src/modules/object-record/record-table/components/__stories__/perf/mock.ts +++ b/packages/twenty-front/src/modules/object-record/record-table/components/__stories__/perf/mock.ts @@ -663,7 +663,8 @@ export const mockPerformance = { id: '20202020-2d40-4e49-8df4-9c6a049191df', email: 'lorie.vladim@google.com', phones: { - primaryPhoneCountryCode: '+33', + primaryPhoneCountryCode: 'FR', + primaryPhoneCallingCode: '+33', primaryPhoneNumber: '788901235', }, linkedinLink: { diff --git a/packages/twenty-front/src/modules/object-record/spreadsheet-import/hooks/__tests__/useOpenObjectRecordsSpreadsheetImportDialog.test.ts b/packages/twenty-front/src/modules/object-record/spreadsheet-import/hooks/__tests__/useOpenObjectRecordsSpreadsheetImportDialog.test.ts index f52e401cbc..3327365b86 100644 --- a/packages/twenty-front/src/modules/object-record/spreadsheet-import/hooks/__tests__/useOpenObjectRecordsSpreadsheetImportDialog.test.ts +++ b/packages/twenty-front/src/modules/object-record/spreadsheet-import/hooks/__tests__/useOpenObjectRecordsSpreadsheetImportDialog.test.ts @@ -207,6 +207,7 @@ const companyMocks = [ phones { primaryPhoneNumber primaryPhoneCountryCode + primaryPhoneCallingCode additionalPhones } position @@ -214,6 +215,7 @@ const companyMocks = [ whatsapp { primaryPhoneNumber primaryPhoneCountryCode + primaryPhoneCallingCode additionalPhones } workPreference diff --git a/packages/twenty-front/src/modules/object-record/utils/generateEmptyFieldValue.ts b/packages/twenty-front/src/modules/object-record/utils/generateEmptyFieldValue.ts index 4d4a651380..7b1059b15e 100644 --- a/packages/twenty-front/src/modules/object-record/utils/generateEmptyFieldValue.ts +++ b/packages/twenty-front/src/modules/object-record/utils/generateEmptyFieldValue.ts @@ -95,6 +95,7 @@ export const generateEmptyFieldValue = ( return { primaryPhoneNumber: '', primaryPhoneCountryCode: '', + primaryPhoneCallingCode: '', additionalPhones: null, }; } diff --git a/packages/twenty-front/src/modules/settings/data-model/constants/SettingsCompositeFieldTypeConfigs.ts b/packages/twenty-front/src/modules/settings/data-model/constants/SettingsCompositeFieldTypeConfigs.ts index cd6c9a8b42..294dd35d75 100644 --- a/packages/twenty-front/src/modules/settings/data-model/constants/SettingsCompositeFieldTypeConfigs.ts +++ b/packages/twenty-front/src/modules/settings/data-model/constants/SettingsCompositeFieldTypeConfigs.ts @@ -91,7 +91,9 @@ export const SETTINGS_COMPOSITE_FIELD_TYPE_CONFIGS = { exampleValue: { primaryPhoneNumber: '234-567-890', primaryPhoneCountryCode: '+1', - additionalPhones: [{ number: '234-567-890', callingCode: '+1' }], + additionalPhones: [ + { number: '234-567-890', callingCode: '+1', countryCode: 'US' }, + ], }, subFields: [ 'primaryPhoneNumber', @@ -102,6 +104,7 @@ export const SETTINGS_COMPOSITE_FIELD_TYPE_CONFIGS = { labelBySubField: { primaryPhoneNumber: 'Primary Phone Number', primaryPhoneCountryCode: 'Primary Phone Country Code', + primaryPhoneCallingCode: 'Primary Phone Calling Code', additionalPhones: 'Additional Phones', }, category: 'Basic', diff --git a/packages/twenty-front/src/modules/settings/data-model/fields/forms/phones/components/SettingsDataModelFieldPhonesForm.tsx b/packages/twenty-front/src/modules/settings/data-model/fields/forms/phones/components/SettingsDataModelFieldPhonesForm.tsx index 070aa79cfe..f0a3d59e14 100644 --- a/packages/twenty-front/src/modules/settings/data-model/fields/forms/phones/components/SettingsDataModelFieldPhonesForm.tsx +++ b/packages/twenty-front/src/modules/settings/data-model/fields/forms/phones/components/SettingsDataModelFieldPhonesForm.tsx @@ -3,8 +3,10 @@ import { Controller, useFormContext } from 'react-hook-form'; import { FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem'; import { phonesSchema as phonesFieldDefaultValueSchema } from '@/object-record/record-field/types/guards/isFieldPhonesValue'; import { SettingsOptionCardContentSelect } from '@/settings/components/SettingsOptions/SettingsOptionCardContentSelect'; +import { countryCodeToCallingCode } from '@/settings/data-model/fields/preview/utils/getPhonesFieldPreviewValue'; import { useCountries } from '@/ui/input/components/internal/hooks/useCountries'; import { Select } from '@/ui/input/components/Select'; +import { CountryCode } from 'libphonenumber-js'; import { IconMap } from 'twenty-ui'; import { z } from 'zod'; import { applySimpleQuotesToString } from '~/utils/string/applySimpleQuotesToString'; @@ -27,22 +29,27 @@ export type SettingsDataModelFieldTextFormValues = z.infer< typeof settingsDataModelFieldPhonesFormSchema >; +export type CountryCodeOrEmpty = CountryCode | ''; + export const SettingsDataModelFieldPhonesForm = ({ disabled, fieldMetadataItem, }: SettingsDataModelFieldPhonesFormProps) => { const { control } = useFormContext(); - const countries = useCountries() - .sort((a, b) => a.countryName.localeCompare(b.countryName)) - .map((country) => ({ - label: `${country.countryName} (+${country.callingCode})`, - value: `+${country.callingCode}`, - })); - countries.unshift({ label: 'No country', value: '' }); + const countries = [ + { label: 'No country', value: '' }, + ...useCountries() + .sort((a, b) => a.countryName.localeCompare(b.countryName)) + .map((country) => ({ + label: `${country.countryName} (+${country.callingCode})`, + value: country.countryCode as CountryCodeOrEmpty, + })), + ]; const defaultDefaultValue = { primaryPhoneNumber: "''", primaryPhoneCountryCode: "''", + primaryPhoneCallingCode: "''", additionalPhones: null, }; const fieldMetadataItemDefaultValue = fieldMetadataItem?.defaultValue; @@ -73,6 +80,9 @@ export const SettingsDataModelFieldPhonesForm = ({ ...value, primaryPhoneCountryCode: applySimpleQuotesToString(newPhoneCountryCode), + primaryPhoneCallingCode: applySimpleQuotesToString( + countryCodeToCallingCode(newPhoneCountryCode), + ), }) } disabled={disabled} diff --git a/packages/twenty-front/src/modules/settings/data-model/fields/preview/utils/getPhonesFieldPreviewValue.ts b/packages/twenty-front/src/modules/settings/data-model/fields/preview/utils/getPhonesFieldPreviewValue.ts index 3cd113cd98..8e8c7bc053 100644 --- a/packages/twenty-front/src/modules/settings/data-model/fields/preview/utils/getPhonesFieldPreviewValue.ts +++ b/packages/twenty-front/src/modules/settings/data-model/fields/preview/utils/getPhonesFieldPreviewValue.ts @@ -1,9 +1,29 @@ import { FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem'; +import { DEFAULT_PHONE_CALLING_CODE } from '@/object-record/record-field/meta-types/input/components/PhonesFieldInput'; import { FieldPhonesValue } from '@/object-record/record-field/types/FieldMetadata'; import { getSettingsFieldTypeConfig } from '@/settings/data-model/utils/getSettingsFieldTypeConfig'; +import { + CountryCode, + getCountries, + getCountryCallingCode, +} from 'libphonenumber-js'; import { FieldMetadataType } from '~/generated-metadata/graphql'; import { stripSimpleQuotesFromString } from '~/utils/string/stripSimpleQuotesFromString'; +const isStrCountryCodeGuard = (str: string): str is CountryCode => { + return getCountries().includes(str as CountryCode); +}; + +export const countryCodeToCallingCode = (countryCode: string): string => { + if (!countryCode || !isStrCountryCodeGuard(countryCode)) { + return `+${DEFAULT_PHONE_CALLING_CODE}`; + } + + const callingCode = getCountryCallingCode(countryCode); + + return callingCode ? `+${callingCode}` : `+${DEFAULT_PHONE_CALLING_CODE}`; +}; + export const getPhonesFieldPreviewValue = ({ fieldMetadataItem, }: { @@ -26,8 +46,16 @@ export const getPhonesFieldPreviewValue = ({ fieldMetadataItem.defaultValue?.primaryPhoneCountryCode, ) : null; + const primaryPhoneCallingCode = + fieldMetadataItem.defaultValue?.primaryPhoneCallingCode && + fieldMetadataItem.defaultValue.primaryPhoneCallingCode !== '' + ? stripSimpleQuotesFromString( + fieldMetadataItem.defaultValue?.primaryPhoneCallingCode, + ) + : null; return { ...placeholderDefaultValue, primaryPhoneCountryCode, + primaryPhoneCallingCode, }; }; diff --git a/packages/twenty-front/src/modules/ui/field/display/components/PhonesDisplay.tsx b/packages/twenty-front/src/modules/ui/field/display/components/PhonesDisplay.tsx index de23dbc087..9b3e4c5e06 100644 --- a/packages/twenty-front/src/modules/ui/field/display/components/PhonesDisplay.tsx +++ b/packages/twenty-front/src/modules/ui/field/display/components/PhonesDisplay.tsx @@ -5,6 +5,7 @@ import { RoundedLink, THEME_COMMON } from 'twenty-ui'; import { FieldPhonesValue } from '@/object-record/record-field/types/FieldMetadata'; import { ExpandableList } from '@/ui/layout/expandable-list/components/ExpandableList'; +import { DEFAULT_PHONE_CALLING_CODE } from '@/object-record/record-field/meta-types/input/components/PhonesFieldInput'; import { parsePhoneNumber } from 'libphonenumber-js'; import { isDefined } from '~/utils/isDefined'; import { logError } from '~/utils/logError'; @@ -36,7 +37,10 @@ export const PhonesDisplay = ({ value, isFocused }: PhonesDisplayProps) => { value?.primaryPhoneNumber ? { number: value.primaryPhoneNumber, - callingCode: value.primaryPhoneCountryCode, + callingCode: + value.primaryPhoneCallingCode || + value.primaryPhoneCountryCode || + `+${DEFAULT_PHONE_CALLING_CODE}`, } : null, ...parseAdditionalPhones(value?.additionalPhones), @@ -50,11 +54,11 @@ export const PhonesDisplay = ({ value, isFocused }: PhonesDisplayProps) => { }), [ value?.primaryPhoneNumber, + value?.primaryPhoneCallingCode, value?.primaryPhoneCountryCode, value?.additionalPhones, ], ); - const parsePhoneNumberOrReturnInvalidValue = (number: string) => { try { return { parsedPhone: parsePhoneNumber(number) }; diff --git a/packages/twenty-front/src/testing/mock-data/generated/mock-metadata-query-result.ts b/packages/twenty-front/src/testing/mock-data/generated/mock-metadata-query-result.ts index 6a48fa7148..76e898dd3d 100644 --- a/packages/twenty-front/src/testing/mock-data/generated/mock-metadata-query-result.ts +++ b/packages/twenty-front/src/testing/mock-data/generated/mock-metadata-query-result.ts @@ -19461,7 +19461,8 @@ export const mockedStandardObjectMetadataQueryResult: ObjectMetadataItemsQuery = "defaultValue": { "additionalPhones": null, "primaryPhoneNumber": "''", - "primaryPhoneCountryCode": "''" + "primaryPhoneCountryCode": "''", + "primaryPhoneCallingCode": "''" }, "options": null, "isLabelSyncedWithName": false, @@ -19740,7 +19741,8 @@ export const mockedStandardObjectMetadataQueryResult: ObjectMetadataItemsQuery = { "additionalPhones": {}, "primaryPhoneNumber": "", - "primaryPhoneCountryCode": "" + "primaryPhoneCountryCode": "", + "primaryPhoneCallingCode": "" } ], "options": null, diff --git a/packages/twenty-front/src/testing/mock-data/people.ts b/packages/twenty-front/src/testing/mock-data/people.ts index b764118afd..f09fc18aff 100644 --- a/packages/twenty-front/src/testing/mock-data/people.ts +++ b/packages/twenty-front/src/testing/mock-data/people.ts @@ -47,7 +47,8 @@ export const peopleQueryResult: { people: RecordGqlConnection } = { city: 'ASd', phones: { primaryPhoneNumber: '781234562', - primaryPhoneCountryCode: '+33', + primaryPhoneCountryCode: 'FR', + primaryPhoneCallingCode: '+33', }, id: 'da3c2c4b-da01-4b81-9734-226069eb4cd0', jobTitle: '', @@ -177,7 +178,8 @@ export const peopleQueryResult: { people: RecordGqlConnection } = { city: 'Seattle', phones: { primaryPhoneNumber: '781234562', - primaryPhoneCountryCode: '+33', + primaryPhoneCountryCode: 'FR', + primaryPhoneCallingCode: '+33', }, id: '20202020-1c0e-494c-a1b6-85b1c6fefaa5', jobTitle: '', @@ -307,7 +309,8 @@ export const peopleQueryResult: { people: RecordGqlConnection } = { city: 'Los Angeles', phones: { primaryPhoneNumber: '781234576', - primaryPhoneCountryCode: '+33', + primaryPhoneCountryCode: 'FR', + primaryPhoneCallingCode: '+33', }, id: '20202020-ac73-4797-824e-87a1f5aea9e0', jobTitle: '', @@ -406,7 +409,8 @@ export const peopleQueryResult: { people: RecordGqlConnection } = { city: 'Seattle', phones: { primaryPhoneNumber: '781234545', - primaryPhoneCountryCode: '+33', + primaryPhoneCountryCode: 'FR', + primaryPhoneCallingCode: '+33', }, id: '20202020-f517-42fd-80ae-14173b3b70ae', jobTitle: '', @@ -505,7 +509,8 @@ export const peopleQueryResult: { people: RecordGqlConnection } = { city: 'Los Angeles', phones: { primaryPhoneNumber: '781234587', - primaryPhoneCountryCode: '+33', + primaryPhoneCountryCode: 'FR', + primaryPhoneCallingCode: '+33', }, id: '20202020-eee1-4690-ad2c-8619e5b56a2e', jobTitle: '', @@ -604,7 +609,8 @@ export const peopleQueryResult: { people: RecordGqlConnection } = { city: 'Seattle', phones: { primaryPhoneNumber: '781234599', - primaryPhoneCountryCode: '+33', + primaryPhoneCountryCode: 'FR', + primaryPhoneCallingCode: '+33', }, id: '20202020-6784-4449-afdf-dc62cb8702f2', jobTitle: '', @@ -703,7 +709,8 @@ export const peopleQueryResult: { people: RecordGqlConnection } = { city: 'New York', phones: { primaryPhoneNumber: '781234572', - primaryPhoneCountryCode: '+33', + primaryPhoneCountryCode: 'FR', + primaryPhoneCallingCode: '+33', }, id: '20202020-490f-4466-8391-733cfd66a0c8', jobTitle: '', @@ -802,7 +809,8 @@ export const peopleQueryResult: { people: RecordGqlConnection } = { city: 'Seattle', phones: { primaryPhoneNumber: '781234582', - primaryPhoneCountryCode: '+33', + primaryPhoneCountryCode: 'FR', + primaryPhoneCallingCode: '+33', }, id: '20202020-80f1-4dff-b570-a74942528de3', jobTitle: '', @@ -901,7 +909,8 @@ export const peopleQueryResult: { people: RecordGqlConnection } = { city: 'New York', phones: { primaryPhoneNumber: '781234569', - primaryPhoneCountryCode: '+33', + primaryPhoneCountryCode: 'FR', + primaryPhoneCallingCode: '+33', }, id: '20202020-338b-46df-8811-fa08c7d19d35', jobTitle: '', @@ -1000,7 +1009,8 @@ export const peopleQueryResult: { people: RecordGqlConnection } = { city: 'San Francisco', phones: { primaryPhoneNumber: '781234962', - primaryPhoneCountryCode: '+33', + primaryPhoneCountryCode: 'FR', + primaryPhoneCallingCode: '+33', }, id: '20202020-64ad-4b0e-bbfd-e9fd795b7016', jobTitle: '', @@ -1099,7 +1109,8 @@ export const peopleQueryResult: { people: RecordGqlConnection } = { city: 'New York', phones: { primaryPhoneNumber: '781234502', - primaryPhoneCountryCode: '+33', + primaryPhoneCountryCode: 'FR', + primaryPhoneCallingCode: '+33', }, id: '20202020-5d54-41b7-ba36-f0d20e1417ae', jobTitle: '', @@ -1198,7 +1209,8 @@ export const peopleQueryResult: { people: RecordGqlConnection } = { city: 'Los Angeles', phones: { primaryPhoneNumber: '781234563', - primaryPhoneCountryCode: '+33', + primaryPhoneCountryCode: 'FR', + primaryPhoneCallingCode: '+33', }, id: '20202020-623d-41fe-92e7-dd45b7c568e1', jobTitle: '', @@ -1297,7 +1309,8 @@ export const peopleQueryResult: { people: RecordGqlConnection } = { city: 'Seattle', phones: { primaryPhoneNumber: '781234542', - primaryPhoneCountryCode: '+33', + primaryPhoneCountryCode: 'FR', + primaryPhoneCallingCode: '+33', }, id: '20202020-2d40-4e49-8df4-9c6a049190ef', jobTitle: '', @@ -1396,7 +1409,8 @@ export const peopleQueryResult: { people: RecordGqlConnection } = { city: 'Seattle', phones: { primaryPhoneNumber: '782234562', - primaryPhoneCountryCode: '+33', + primaryPhoneCountryCode: 'FR', + primaryPhoneCallingCode: '+33', }, id: '20202020-2d40-4e49-8df4-9c6a049190df', jobTitle: '', @@ -1495,7 +1509,8 @@ export const peopleQueryResult: { people: RecordGqlConnection } = { city: 'Seattle', phones: { primaryPhoneNumber: '781274562', - primaryPhoneCountryCode: '+33', + primaryPhoneCountryCode: 'FR', + primaryPhoneCallingCode: '+33', }, id: '20202020-2d40-4e49-8df4-9c6a049191de', jobTitle: '', @@ -1594,7 +1609,8 @@ export const peopleQueryResult: { people: RecordGqlConnection } = { city: 'Seattle', phones: { primaryPhoneNumber: '781239562', - primaryPhoneCountryCode: '+33', + primaryPhoneCountryCode: 'FR', + primaryPhoneCallingCode: '+33', }, id: '20202020-2d40-4e49-8df4-9c6a049191df', jobTitle: '', diff --git a/packages/twenty-server/src/database/commands/active-workspaces.command.ts b/packages/twenty-server/src/database/commands/active-workspaces.command.ts index d741144cb8..46c7ad2201 100644 --- a/packages/twenty-server/src/database/commands/active-workspaces.command.ts +++ b/packages/twenty-server/src/database/commands/active-workspaces.command.ts @@ -1,5 +1,3 @@ -import { Logger } from '@nestjs/common'; - import chalk from 'chalk'; import { Option } from 'nest-commander'; import { Repository } from 'typeorm'; @@ -20,11 +18,8 @@ export type ActiveWorkspacesCommandOptions = BaseCommandOptions & { export abstract class ActiveWorkspacesCommandRunner extends BaseCommandRunner { private workspaceIds: string[] = []; - protected readonly logger: Logger; - constructor(protected readonly workspaceRepository: Repository) { super(); - this.logger = new Logger(this.constructor.name); } @Option({ diff --git a/packages/twenty-server/src/database/commands/base.command.ts b/packages/twenty-server/src/database/commands/base.command.ts index 419a6f114b..780eb18c1e 100644 --- a/packages/twenty-server/src/database/commands/base.command.ts +++ b/packages/twenty-server/src/database/commands/base.command.ts @@ -3,6 +3,7 @@ import { Logger } from '@nestjs/common'; import chalk from 'chalk'; import { CommandRunner, Option } from 'nest-commander'; +import { CommandLogger } from './logger'; export type BaseCommandOptions = { workspaceId?: string; dryRun?: boolean; @@ -10,11 +11,13 @@ export type BaseCommandOptions = { }; export abstract class BaseCommandRunner extends CommandRunner { - protected readonly logger: Logger; - + protected logger: CommandLogger | Logger; constructor() { super(); - this.logger = new Logger(this.constructor.name); + this.logger = new CommandLogger({ + verbose: false, + constructorName: this.constructor.name, + }); } @Option({ @@ -27,10 +30,11 @@ export abstract class BaseCommandRunner extends CommandRunner { } @Option({ - flags: '--verbose', + flags: '-v, --verbose', description: 'Verbose output', + required: false, }) - parseVerbose() { + parseVerbose(): boolean { return true; } @@ -38,6 +42,13 @@ export abstract class BaseCommandRunner extends CommandRunner { passedParams: string[], options: BaseCommandOptions, ): Promise { + if (options.verbose) { + this.logger = new CommandLogger({ + verbose: true, + constructorName: this.constructor.name, + }); + } + try { await this.executeBaseCommand(passedParams, options); } catch (error) { diff --git a/packages/twenty-server/src/database/commands/database-command.module.ts b/packages/twenty-server/src/database/commands/database-command.module.ts index 9c69bb3eb2..1ab5b849f4 100644 --- a/packages/twenty-server/src/database/commands/database-command.module.ts +++ b/packages/twenty-server/src/database/commands/database-command.module.ts @@ -52,8 +52,8 @@ import { WorkspaceSyncMetadataModule } from 'src/engine/workspace-manager/worksp UpgradeTo0_32CommandModule, UpgradeTo0_33CommandModule, UpgradeTo0_34CommandModule, - FeatureFlagModule, UpgradeTo0_40CommandModule, + FeatureFlagModule, ], providers: [ DataSeedWorkspaceCommand, diff --git a/packages/twenty-server/src/database/commands/logger.ts b/packages/twenty-server/src/database/commands/logger.ts new file mode 100644 index 0000000000..9bd2ebb020 --- /dev/null +++ b/packages/twenty-server/src/database/commands/logger.ts @@ -0,0 +1,46 @@ +import { Logger } from '@nestjs/common'; + +interface CommandLoggerOptions { + verbose?: boolean; + constructorName: string; +} + +export class CommandLogger { + private logger: Logger; + private verbose: boolean; + + constructor(options: CommandLoggerOptions) { + this.logger = new Logger(options.constructorName); + this.verbose = options.verbose ?? true; + } + + log(message: string, context?: string) { + if (this.verbose) { + this.logger.log(message, context); + } + } + + error(message: string, stack?: string, context?: string) { + if (this.verbose) { + this.logger.error(message, stack, context); + } + } + + warn(message: string, context?: string) { + if (this.verbose) { + this.logger.warn(message, context); + } + } + + debug(message: string, context?: string) { + if (this.verbose) { + this.logger.debug(message, context); + } + } + + verboseLog(message: string, context?: string) { + if (this.verbose) { + this.logger.verbose(message, context); + } + } +} diff --git a/packages/twenty-server/src/database/commands/upgrade-version/0-40/0-40-phone-calling-code-create-column.command.ts b/packages/twenty-server/src/database/commands/upgrade-version/0-40/0-40-phone-calling-code-create-column.command.ts new file mode 100644 index 0000000000..ea3750ab32 --- /dev/null +++ b/packages/twenty-server/src/database/commands/upgrade-version/0-40/0-40-phone-calling-code-create-column.command.ts @@ -0,0 +1,144 @@ +import { InjectRepository } from '@nestjs/typeorm'; + +import chalk from 'chalk'; +import { Command } from 'nest-commander'; +import { Repository } from 'typeorm'; +import { v4 } from 'uuid'; + +import { + ActiveWorkspacesCommandOptions, + ActiveWorkspacesCommandRunner, +} from 'src/database/commands/active-workspaces.command'; +import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; +import { + FieldMetadataEntity, + FieldMetadataType, +} from 'src/engine/metadata-modules/field-metadata/field-metadata.entity'; +import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity'; +import { WorkspaceMetadataVersionService } from 'src/engine/metadata-modules/workspace-metadata-version/services/workspace-metadata-version.service'; +import { generateMigrationName } from 'src/engine/metadata-modules/workspace-migration/utils/generate-migration-name.util'; +import { + WorkspaceMigrationColumnActionType, + WorkspaceMigrationTableAction, + WorkspaceMigrationTableActionType, +} from 'src/engine/metadata-modules/workspace-migration/workspace-migration.entity'; +import { WorkspaceMigrationFactory } from 'src/engine/metadata-modules/workspace-migration/workspace-migration.factory'; +import { WorkspaceMigrationService } from 'src/engine/metadata-modules/workspace-migration/workspace-migration.service'; +import { computeObjectTargetTable } from 'src/engine/utils/compute-object-target-table.util'; +import { WorkspaceMigrationRunnerService } from 'src/engine/workspace-manager/workspace-migration-runner/workspace-migration-runner.service'; +import { isDefined } from 'src/utils/is-defined'; + +@Command({ + name: 'upgrade-0.40:phone-calling-code-create-column', + description: 'Create the callingCode column', +}) +export class PhoneCallingCodeCreateColumnCommand extends ActiveWorkspacesCommandRunner { + constructor( + @InjectRepository(Workspace, 'core') + protected readonly workspaceRepository: Repository, + @InjectRepository(FieldMetadataEntity, 'metadata') + private readonly fieldMetadataRepository: Repository, + @InjectRepository(ObjectMetadataEntity, 'metadata') + private readonly objectMetadataRepository: Repository, + private readonly workspaceMigrationService: WorkspaceMigrationService, + private readonly workspaceMigrationFactory: WorkspaceMigrationFactory, + private readonly workspaceMigrationRunnerService: WorkspaceMigrationRunnerService, + private readonly workspaceMetadataVersionService: WorkspaceMetadataVersionService, + ) { + super(workspaceRepository); + } + + async executeActiveWorkspacesCommand( + _passedParam: string[], + options: ActiveWorkspacesCommandOptions, + workspaceIds: string[], + ): Promise { + this.logger.log( + 'Running command to add calling code and change country code with default one', + ); + + this.logger.log(`Part 1 - Workspace`); + let workspaceIterator = 1; + + for (const workspaceId of workspaceIds) { + this.logger.log( + `Running command for workspace ${workspaceId} ${workspaceIterator}/${workspaceIds.length}`, + ); + + this.logger.log( + `P1 Step 1 - let's find all the fieldsMetadata that have the PHONES type, and extract the objectMetadataId`, + ); + + try { + const phonesFieldMetadata = await this.fieldMetadataRepository.find({ + where: { + workspaceId, + type: FieldMetadataType.PHONES, + }, + relations: ['object'], + }); + + for (const phoneFieldMetadata of phonesFieldMetadata) { + if ( + isDefined(phoneFieldMetadata?.name && phoneFieldMetadata.object) + ) { + this.logger.log( + `P1 Step 1 - Let's find the "nameSingular" of this objectMetadata: ${phoneFieldMetadata.object.nameSingular || 'not found'}`, + ); + + if (!phoneFieldMetadata.object?.nameSingular) continue; + + this.logger.log( + `P1 Step 1 - Create migration for field ${phoneFieldMetadata.name}`, + ); + + if (options.dryRun === true) { + continue; + } + await this.workspaceMigrationService.createCustomMigration( + generateMigrationName( + `create-${phoneFieldMetadata.object.nameSingular}PrimaryPhoneCallingCode-for-field-${phoneFieldMetadata.name}`, + ), + workspaceId, + [ + { + name: computeObjectTargetTable(phoneFieldMetadata.object), + action: WorkspaceMigrationTableActionType.ALTER, + columns: this.workspaceMigrationFactory.createColumnActions( + WorkspaceMigrationColumnActionType.CREATE, + { + id: v4(), + type: FieldMetadataType.TEXT, + name: `${phoneFieldMetadata.name}PrimaryPhoneCallingCode`, + label: `${phoneFieldMetadata.name}PrimaryPhoneCallingCode`, + objectMetadataId: phoneFieldMetadata.object.id, + workspaceId: workspaceId, + isNullable: true, + defaultValue: "''", + } satisfies Partial, + ), + } satisfies WorkspaceMigrationTableAction, + ], + ); + } + } + + this.logger.log( + `P1 Step 1 - RUN migration to create callingCodes for ${workspaceId.slice(0, 5)}`, + ); + await this.workspaceMigrationRunnerService.executeMigrationFromPendingMigrations( + workspaceId, + ); + + await this.workspaceMetadataVersionService.incrementMetadataVersion( + workspaceId, + ); + } catch (error) { + console.log(`Error in workspace ${workspaceId} : ${error}`); + } + workspaceIterator++; + } + + this.logger.log(chalk.green(`Command completed!`)); + } +} diff --git a/packages/twenty-server/src/database/commands/upgrade-version/0-40/0-40-phone-calling-code-migrate-data.command.ts b/packages/twenty-server/src/database/commands/upgrade-version/0-40/0-40-phone-calling-code-migrate-data.command.ts new file mode 100644 index 0000000000..ac60af59bc --- /dev/null +++ b/packages/twenty-server/src/database/commands/upgrade-version/0-40/0-40-phone-calling-code-migrate-data.command.ts @@ -0,0 +1,302 @@ +import { InjectRepository } from '@nestjs/typeorm'; + +import chalk from 'chalk'; +import { getCountries, getCountryCallingCode } from 'libphonenumber-js'; +import { Command } from 'nest-commander'; +import { Repository } from 'typeorm'; +import { v4 } from 'uuid'; + +import { + ActiveWorkspacesCommandOptions, + ActiveWorkspacesCommandRunner, +} from 'src/database/commands/active-workspaces.command'; +import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; +import { + FieldMetadataEntity, + FieldMetadataType, +} from 'src/engine/metadata-modules/field-metadata/field-metadata.entity'; +import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity'; +import { WorkspaceMetadataVersionService } from 'src/engine/metadata-modules/workspace-metadata-version/services/workspace-metadata-version.service'; +import { generateMigrationName } from 'src/engine/metadata-modules/workspace-migration/utils/generate-migration-name.util'; +import { + WorkspaceMigrationColumnActionType, + WorkspaceMigrationTableAction, + WorkspaceMigrationTableActionType, +} from 'src/engine/metadata-modules/workspace-migration/workspace-migration.entity'; +import { WorkspaceMigrationFactory } from 'src/engine/metadata-modules/workspace-migration/workspace-migration.factory'; +import { WorkspaceMigrationService } from 'src/engine/metadata-modules/workspace-migration/workspace-migration.service'; +import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager'; +import { computeObjectTargetTable } from 'src/engine/utils/compute-object-target-table.util'; +import { WorkspaceMigrationRunnerService } from 'src/engine/workspace-manager/workspace-migration-runner/workspace-migration-runner.service'; +import { isDefined } from 'src/utils/is-defined'; + +const callingCodeToCountryCode = (callingCode: string): string => { + if (!callingCode) { + return ''; + } + let callingCodeSanitized = callingCode; + + if (callingCode.startsWith('+')) { + callingCodeSanitized = callingCode.slice(1); + } + + return ( + getCountries().find( + (countryCode) => + getCountryCallingCode(countryCode) === callingCodeSanitized, + ) || '' + ); +}; + +const isCallingCode = (callingCode: string): boolean => { + return callingCodeToCountryCode(callingCode) !== ''; +}; + +@Command({ + name: 'upgrade-0.40:phone-calling-code-migrate-data', + description: 'Add calling code and change country code with default one', +}) +export class PhoneCallingCodeMigrateDataCommand extends ActiveWorkspacesCommandRunner { + constructor( + @InjectRepository(Workspace, 'core') + protected readonly workspaceRepository: Repository, + @InjectRepository(FieldMetadataEntity, 'metadata') + private readonly fieldMetadataRepository: Repository, + @InjectRepository(ObjectMetadataEntity, 'metadata') + private readonly objectMetadataRepository: Repository, + private readonly twentyORMGlobalManager: TwentyORMGlobalManager, + private readonly workspaceMigrationService: WorkspaceMigrationService, + private readonly workspaceMigrationFactory: WorkspaceMigrationFactory, + private readonly workspaceMigrationRunnerService: WorkspaceMigrationRunnerService, + private readonly workspaceMetadataVersionService: WorkspaceMetadataVersionService, + ) { + super(workspaceRepository); + } + + async executeActiveWorkspacesCommand( + _passedParam: string[], + options: ActiveWorkspacesCommandOptions, + workspaceIds: string[], + ): Promise { + this.logger.log( + 'Running command to add calling code and change country code with default one', + ); + + this.logger.log(`Part 1 - Workspace`); + + let workspaceIterator = 1; + + for (const workspaceId of workspaceIds) { + this.logger.log( + `Running command for workspace ${workspaceId} ${workspaceIterator}/${workspaceIds.length}`, + ); + + this.logger.log( + `P1 Step 1 - let's find all the fieldsMetadata that have the PHONES type, and extract the objectMetadataId`, + ); + + try { + const phonesFieldMetadata = await this.fieldMetadataRepository.find({ + where: { + workspaceId, + type: FieldMetadataType.PHONES, + }, + relations: ['object'], + }); + + for (const phoneFieldMetadata of phonesFieldMetadata) { + if ( + isDefined(phoneFieldMetadata?.name) && + isDefined(phoneFieldMetadata.object) + ) { + this.logger.log( + `P1 Step 1 - Let's find the "nameSingular" of this objectMetadata: ${phoneFieldMetadata.object.nameSingular || 'not found'}`, + ); + + if (!phoneFieldMetadata.object?.nameSingular) continue; + + this.logger.log( + `P1 Step 1 - Create migration for field ${phoneFieldMetadata.name}`, + ); + if (options.dryRun === false) { + await this.workspaceMigrationService.createCustomMigration( + generateMigrationName( + `create-${phoneFieldMetadata.object.nameSingular}PrimaryPhoneCallingCode-for-field-${phoneFieldMetadata.name}`, + ), + workspaceId, + [ + { + name: computeObjectTargetTable(phoneFieldMetadata.object), + action: WorkspaceMigrationTableActionType.ALTER, + columns: this.workspaceMigrationFactory.createColumnActions( + WorkspaceMigrationColumnActionType.CREATE, + { + id: v4(), + type: FieldMetadataType.TEXT, + name: `${phoneFieldMetadata.name}PrimaryPhoneCallingCode`, + label: `${phoneFieldMetadata.name}PrimaryPhoneCallingCode`, + objectMetadataId: phoneFieldMetadata.object.id, + workspaceId: workspaceId, + isNullable: true, + defaultValue: "''", + }, + ), + } satisfies WorkspaceMigrationTableAction, + ], + ); + } + } + } + + this.logger.log( + `P1 Step 1 - RUN migration to create callingCodes for ${workspaceId.slice(0, 5)}`, + ); + await this.workspaceMigrationRunnerService.executeMigrationFromPendingMigrations( + workspaceId, + ); + + await this.workspaceMetadataVersionService.incrementMetadataVersion( + workspaceId, + ); + + this.logger.log( + `P1 Step 2 - Migrations for callingCode must be first. Now can use twentyORMGlobalManager to update countryCode`, + ); + + this.logger.log( + `P1 Step 3 (same time) - update CountryCode to letters: +33 => FR || +1 => US (if mulitple, first one)`, + ); + + this.logger.log( + `P1 Step 4 (same time) - update all additioanl phones to add a country code following the same logic`, + ); + + for (const phoneFieldMetadata of phonesFieldMetadata) { + this.logger.log(`P1 Step 2 - for ${phoneFieldMetadata.name}`); + if ( + isDefined(phoneFieldMetadata) && + isDefined(phoneFieldMetadata.name) + ) { + const [objectMetadata] = await this.objectMetadataRepository.find({ + where: { + id: phoneFieldMetadata?.objectMetadataId, + }, + }); + + const repository = + await this.twentyORMGlobalManager.getRepositoryForWorkspace( + workspaceId, + objectMetadata.nameSingular, + ); + const records = await repository.find(); + + for (const record of records) { + if ( + record?.[phoneFieldMetadata.name]?.primaryPhoneCountryCode && + isCallingCode( + record[phoneFieldMetadata.name].primaryPhoneCountryCode, + ) + ) { + let additionalPhones = null; + + if (record[phoneFieldMetadata.name].additionalPhones) { + additionalPhones = record[ + phoneFieldMetadata.name + ].additionalPhones.map((phone) => { + return { + ...phone, + countryCode: callingCodeToCountryCode(phone.callingCode), + }; + }); + } + if (options.dryRun === false) { + await repository.update(record.id, { + [`${phoneFieldMetadata.name}PrimaryPhoneCallingCode`]: + record[phoneFieldMetadata.name].primaryPhoneCountryCode, + [`${phoneFieldMetadata.name}PrimaryPhoneCountryCode`]: + callingCodeToCountryCode( + record[phoneFieldMetadata.name].primaryPhoneCountryCode, + ), + [`${phoneFieldMetadata.name}AdditionalPhones`]: + additionalPhones, + }); + } + } + } + } + } + } catch (error) { + console.log(`Error in workspace ${workspaceId} : ${error}`); + } + workspaceIterator++; + } + + this.logger.log(` + + Part 2 - FieldMetadata`); + + workspaceIterator = 1; + for (const workspaceId of workspaceIds) { + this.logger.log( + `Running command for workspace ${workspaceId} ${workspaceIterator}/${workspaceIds.length}`, + ); + + this.logger.log( + `P2 Step 1 - let's find all the fieldsMetadata that have the PHONES type, and extract the objectMetadataId`, + ); + + try { + const phonesFieldMetadata = await this.fieldMetadataRepository.find({ + where: { + workspaceId, + type: FieldMetadataType.PHONES, + }, + }); + + for (const phoneFieldMetadata of phonesFieldMetadata) { + if ( + !isDefined(phoneFieldMetadata) || + !isDefined(phoneFieldMetadata.defaultValue) + ) + continue; + let defaultValue = phoneFieldMetadata.defaultValue; + + // some cases look like it's an array. let's flatten it (not sure the case is supposed to happen but I saw it in my local db) + if (Array.isArray(defaultValue) && isDefined(defaultValue[0])) + defaultValue = phoneFieldMetadata.defaultValue[0]; + + if (!isDefined(defaultValue)) continue; + if (typeof defaultValue !== 'object') continue; + if (!('primaryPhoneCountryCode' in defaultValue)) continue; + if (!defaultValue.primaryPhoneCountryCode) continue; + + const primaryPhoneCountryCode = defaultValue.primaryPhoneCountryCode; + + const countryCode = callingCodeToCountryCode( + primaryPhoneCountryCode.replace(/["']/g, ''), + ); + + if (options.dryRun === false) { + await this.fieldMetadataRepository.update(phoneFieldMetadata.id, { + defaultValue: { + ...defaultValue, + primaryPhoneCountryCode: countryCode + ? `'${countryCode}'` + : "''", + primaryPhoneCallingCode: isCallingCode( + primaryPhoneCountryCode.replace(/["']/g, ''), + ) + ? primaryPhoneCountryCode + : "''", + }, + }); + } + } + } catch (error) { + console.log(`Error in workspace ${workspaceId} : ${error}`); + } + workspaceIterator++; + } + this.logger.log(chalk.green(`Command completed!`)); + } +} diff --git a/packages/twenty-server/src/database/commands/upgrade-version/0-40/0-40-upgrade-version.command.ts b/packages/twenty-server/src/database/commands/upgrade-version/0-40/0-40-upgrade-version.command.ts index 767c1486ec..f0063834a5 100644 --- a/packages/twenty-server/src/database/commands/upgrade-version/0-40/0-40-upgrade-version.command.ts +++ b/packages/twenty-server/src/database/commands/upgrade-version/0-40/0-40-upgrade-version.command.ts @@ -5,6 +5,8 @@ import { Repository } from 'typeorm'; import { ActiveWorkspacesCommandRunner } from 'src/database/commands/active-workspaces.command'; import { BaseCommandOptions } from 'src/database/commands/base.command'; +import { PhoneCallingCodeCreateColumnCommand } from 'src/database/commands/upgrade-version/0-40/0-40-phone-calling-code-create-column.command'; +import { PhoneCallingCodeMigrateDataCommand } from 'src/database/commands/upgrade-version/0-40/0-40-phone-calling-code-migrate-data.command'; import { RecordPositionBackfillCommand } from 'src/database/commands/upgrade-version/0-40/0-40-record-position-backfill.command'; import { ViewGroupNoValueBackfillCommand } from 'src/database/commands/upgrade-version/0-40/0-40-view-group-no-value-backfill.command'; import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; @@ -18,9 +20,11 @@ export class UpgradeTo0_40Command extends ActiveWorkspacesCommandRunner { constructor( @InjectRepository(Workspace, 'core') protected readonly workspaceRepository: Repository, - private readonly recordPositionBackfillCommand: RecordPositionBackfillCommand, private readonly viewGroupNoValueBackfillCommand: ViewGroupNoValueBackfillCommand, private readonly syncWorkspaceMetadataCommand: SyncWorkspaceMetadataCommand, + private readonly phoneCallingCodeMigrateDataCommand: PhoneCallingCodeMigrateDataCommand, + private readonly phoneCallingCodeCreateColumnCommand: PhoneCallingCodeCreateColumnCommand, + private readonly recordPositionBackfillCommand: RecordPositionBackfillCommand, ) { super(workspaceRepository); } @@ -30,6 +34,22 @@ export class UpgradeTo0_40Command extends ActiveWorkspacesCommandRunner { options: BaseCommandOptions, workspaceIds: string[], ): Promise { + this.logger.log( + 'Running command to upgrade to 0.40: must start with phone calling code otherwise SyncMetadata will fail', + ); + + await this.phoneCallingCodeCreateColumnCommand.executeActiveWorkspacesCommand( + passedParam, + options, + workspaceIds, + ); + + await this.phoneCallingCodeMigrateDataCommand.executeActiveWorkspacesCommand( + passedParam, + options, + workspaceIds, + ); + await this.recordPositionBackfillCommand.executeActiveWorkspacesCommand( passedParam, options, diff --git a/packages/twenty-server/src/database/commands/upgrade-version/0-40/0-40-upgrade-version.module.ts b/packages/twenty-server/src/database/commands/upgrade-version/0-40/0-40-upgrade-version.module.ts index ccd92107f6..b19780ed8a 100644 --- a/packages/twenty-server/src/database/commands/upgrade-version/0-40/0-40-upgrade-version.module.ts +++ b/packages/twenty-server/src/database/commands/upgrade-version/0-40/0-40-upgrade-version.module.ts @@ -1,6 +1,8 @@ import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; +import { PhoneCallingCodeCreateColumnCommand } from 'src/database/commands/upgrade-version/0-40/0-40-phone-calling-code-create-column.command'; +import { PhoneCallingCodeMigrateDataCommand } from 'src/database/commands/upgrade-version/0-40/0-40-phone-calling-code-migrate-data.command'; import { RecordPositionBackfillCommand } from 'src/database/commands/upgrade-version/0-40/0-40-record-position-backfill.command'; import { UpgradeTo0_40Command } from 'src/database/commands/upgrade-version/0-40/0-40-upgrade-version.command'; import { ViewGroupNoValueBackfillCommand } from 'src/database/commands/upgrade-version/0-40/0-40-view-group-no-value-backfill.command'; @@ -8,18 +10,35 @@ import { RecordPositionBackfillModule } from 'src/engine/api/graphql/workspace-q import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; import { FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity'; import { FieldMetadataModule } from 'src/engine/metadata-modules/field-metadata/field-metadata.module'; +import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity'; +import { SearchModule } from 'src/engine/metadata-modules/search/search.module'; +import { WorkspaceMetadataVersionModule } from 'src/engine/metadata-modules/workspace-metadata-version/workspace-metadata-version.module'; +import { WorkspaceMigrationFactory } from 'src/engine/metadata-modules/workspace-migration/workspace-migration.factory'; +import { WorkspaceMigrationModule } from 'src/engine/metadata-modules/workspace-migration/workspace-migration.module'; +import { WorkspaceMigrationRunnerModule } from 'src/engine/workspace-manager/workspace-migration-runner/workspace-migration-runner.module'; import { WorkspaceSyncMetadataCommandsModule } from 'src/engine/workspace-manager/workspace-sync-metadata/commands/workspace-sync-metadata-commands.module'; @Module({ imports: [ TypeOrmModule.forFeature([Workspace], 'core'), + TypeOrmModule.forFeature( + [ObjectMetadataEntity, FieldMetadataEntity], + 'metadata', + ), TypeOrmModule.forFeature([FieldMetadataEntity], 'metadata'), WorkspaceSyncMetadataCommandsModule, + SearchModule, + WorkspaceMigrationRunnerModule, + WorkspaceMetadataVersionModule, + WorkspaceMigrationModule, RecordPositionBackfillModule, FieldMetadataModule, ], providers: [ UpgradeTo0_40Command, + PhoneCallingCodeMigrateDataCommand, + PhoneCallingCodeCreateColumnCommand, + WorkspaceMigrationFactory, RecordPositionBackfillCommand, ViewGroupNoValueBackfillCommand, ], diff --git a/packages/twenty-server/src/database/typeorm-seeds/metadata/fieldsMetadata.ts b/packages/twenty-server/src/database/typeorm-seeds/metadata/fieldsMetadata.ts index a0b8081c9d..fd0bfe2c12 100644 --- a/packages/twenty-server/src/database/typeorm-seeds/metadata/fieldsMetadata.ts +++ b/packages/twenty-server/src/database/typeorm-seeds/metadata/fieldsMetadata.ts @@ -106,13 +106,12 @@ export const getDevSeedPeopleCustomFields = ( isActive: true, isNullable: false, isUnique: false, - defaultValue: [ - { - primaryPhoneNumber: '', - primaryPhoneCountryCode: '', - additionalPhones: {}, - }, - ], + defaultValue: { + primaryPhoneNumber: "''", + primaryPhoneCountryCode: "'FR'", + primaryPhoneCallingCode: "'+33'", + additionalPhones: null, + }, objectMetadataId, }, { diff --git a/packages/twenty-server/src/database/typeorm-seeds/workspace/people.ts b/packages/twenty-server/src/database/typeorm-seeds/workspace/people.ts index 22adfe0142..8fead7f84f 100644 --- a/packages/twenty-server/src/database/typeorm-seeds/workspace/people.ts +++ b/packages/twenty-server/src/database/typeorm-seeds/workspace/people.ts @@ -35,12 +35,14 @@ export const seedPeople = async ( 'nameFirstName', 'nameLastName', 'phonesPrimaryPhoneCountryCode', + 'phonesPrimaryPhoneCallingCode', 'phonesPrimaryPhoneNumber', 'city', 'companyId', 'emailsPrimaryEmail', 'position', 'whatsappPrimaryPhoneCountryCode', + 'whatsappPrimaryPhoneCallingCode', 'whatsappPrimaryPhoneNumber', 'createdBySource', 'createdByWorkspaceMemberId', @@ -52,13 +54,15 @@ export const seedPeople = async ( id: DEV_SEED_PERSON_IDS.CHRISTOPH, nameFirstName: 'Christoph', nameLastName: 'Callisto', - phonePrimaryPhoneCountryCode: '+33', - phonePrimaryPhoneNumber: '789012345', + phonesPrimaryPhoneCountryCode: 'FR', + phonesPrimaryPhoneCallingCode: '+33', + phonesPrimaryPhoneNumber: '789012345', city: 'Seattle', companyId: DEV_SEED_COMPANY_IDS.LINKEDIN, emailsPrimaryEmail: 'christoph.calisto@linkedin.com', position: 1, - whatsappPrimaryPhoneCountryCode: '+33', + whatsappPrimaryPhoneCountryCode: 'FR', + whatsappPrimaryPhoneCallingCode: '+33', whatsappPrimaryPhoneNumber: '789012345', createdBySource: 'MANUAL', createdByWorkspaceMemberId: DEV_SEED_WORKSPACE_MEMBER_IDS.TIM, @@ -68,13 +72,15 @@ export const seedPeople = async ( id: DEV_SEED_PERSON_IDS.SYLVIE, nameFirstName: 'Sylvie', nameLastName: 'Palmer', - phonePrimaryPhoneCountryCode: '+33', - phonePrimaryPhoneNumber: '780123456', + phonesPrimaryPhoneCountryCode: 'FR', + phonesPrimaryPhoneCallingCode: '+33', + phonesPrimaryPhoneNumber: '780123456', city: 'Los Angeles', companyId: DEV_SEED_COMPANY_IDS.LINKEDIN, emailsPrimaryEmail: 'sylvie.palmer@linkedin.com', position: 2, - whatsappPrimaryPhoneCountryCode: '+33', + whatsappPrimaryPhoneCountryCode: 'FR', + whatsappPrimaryPhoneCallingCode: '+33', whatsappPrimaryPhoneNumber: '780123456', createdBySource: 'MANUAL', createdByWorkspaceMemberId: DEV_SEED_WORKSPACE_MEMBER_IDS.TIM, @@ -84,13 +90,15 @@ export const seedPeople = async ( id: DEV_SEED_PERSON_IDS.CHRISTOPHER_G, nameFirstName: 'Christopher', nameLastName: 'Gonzalez', - phonePrimaryPhoneCountryCode: '+33', - phonePrimaryPhoneNumber: '789012345', + phonesPrimaryPhoneCountryCode: 'FR', + phonesPrimaryPhoneCallingCode: '+33', + phonesPrimaryPhoneNumber: '789012345', city: 'Seattle', companyId: DEV_SEED_COMPANY_IDS.QONTO, emailsPrimaryEmail: 'christopher.gonzalez@qonto.com', position: 3, - whatsappPrimaryPhoneCountryCode: '+33', + whatsappPrimaryPhoneCountryCode: 'FR', + whatsappPrimaryPhoneCallingCode: '+33', whatsappPrimaryPhoneNumber: '789012345', createdBySource: 'MANUAL', createdByWorkspaceMemberId: DEV_SEED_WORKSPACE_MEMBER_IDS.TIM, @@ -100,13 +108,15 @@ export const seedPeople = async ( id: DEV_SEED_PERSON_IDS.ASHLEY, nameFirstName: 'Ashley', nameLastName: 'Parker', - phonePrimaryPhoneCountryCode: '+33', - phonePrimaryPhoneNumber: '780123456', + phonesPrimaryPhoneCountryCode: 'FR', + phonesPrimaryPhoneCallingCode: '+33', + phonesPrimaryPhoneNumber: '780123456', city: 'Los Angeles', companyId: DEV_SEED_COMPANY_IDS.QONTO, emailsPrimaryEmail: 'ashley.parker@qonto.com', position: 4, - whatsappPrimaryPhoneCountryCode: '+33', + whatsappPrimaryPhoneCountryCode: 'FR', + whatsappPrimaryPhoneCallingCode: '+33', whatsappPrimaryPhoneNumber: '780123456', createdBySource: 'MANUAL', createdByWorkspaceMemberId: DEV_SEED_WORKSPACE_MEMBER_IDS.TIM, @@ -116,13 +126,15 @@ export const seedPeople = async ( id: DEV_SEED_PERSON_IDS.NICHOLAS, nameFirstName: 'Nicholas', nameLastName: 'Wright', - phonePrimaryPhoneCountryCode: '+33', - phonePrimaryPhoneNumber: '781234567', + phonesPrimaryPhoneCountryCode: 'FR', + phonesPrimaryPhoneCallingCode: '+33', + phonesPrimaryPhoneNumber: '781234567', city: 'Seattle', companyId: DEV_SEED_COMPANY_IDS.MICROSOFT, emailsPrimaryEmail: 'nicholas.wright@microsoft.com', position: 5, - whatsappPrimaryPhoneCountryCode: '+33', + whatsappPrimaryPhoneCountryCode: 'FR', + whatsappPrimaryPhoneCallingCode: '+33', whatsappPrimaryPhoneNumber: '781234567', createdBySource: 'MANUAL', createdByWorkspaceMemberId: DEV_SEED_WORKSPACE_MEMBER_IDS.TIM, @@ -132,13 +144,15 @@ export const seedPeople = async ( id: DEV_SEED_PERSON_IDS.ISABELLA, nameFirstName: 'Isabella', nameLastName: 'Scott', - phonePrimaryPhoneCountryCode: '+33', - phonePrimaryPhoneNumber: '782345678', + phonesPrimaryPhoneCountryCode: 'FR', + phonesPrimaryPhoneCallingCode: '+33', + phonesPrimaryPhoneNumber: '782345678', city: 'New York', companyId: DEV_SEED_COMPANY_IDS.MICROSOFT, emailsPrimaryEmail: 'isabella.scott@microsoft.com', position: 6, - whatsappPrimaryPhoneCountryCode: '+33', + whatsappPrimaryPhoneCountryCode: 'FR', + whatsappPrimaryPhoneCallingCode: '+33', whatsappPrimaryPhoneNumber: '782345678', createdBySource: 'MANUAL', createdByWorkspaceMemberId: DEV_SEED_WORKSPACE_MEMBER_IDS.TIM, @@ -148,13 +162,15 @@ export const seedPeople = async ( id: DEV_SEED_PERSON_IDS.MATTHEW, nameFirstName: 'Matthew', nameLastName: 'Green', - phonePrimaryPhoneCountryCode: '+33', - phonePrimaryPhoneNumber: '783456789', + phonesPrimaryPhoneCountryCode: 'FR', + phonesPrimaryPhoneCallingCode: '+33', + phonesPrimaryPhoneNumber: '783456789', city: 'Seattle', companyId: DEV_SEED_COMPANY_IDS.MICROSOFT, emailsPrimaryEmail: 'matthew.green@microsoft.com', position: 7, - whatsappPrimaryPhoneCountryCode: '+33', + whatsappPrimaryPhoneCountryCode: 'FR', + whatsappPrimaryPhoneCallingCode: '+33', whatsappPrimaryPhoneNumber: '783456789', createdBySource: 'MANUAL', createdByWorkspaceMemberId: DEV_SEED_WORKSPACE_MEMBER_IDS.TIM, @@ -164,13 +180,15 @@ export const seedPeople = async ( id: DEV_SEED_PERSON_IDS.ELIZABETH, nameFirstName: 'Elizabeth', nameLastName: 'Baker', - phonePrimaryPhoneCountryCode: '+33', - phonePrimaryPhoneNumber: '784567890', + phonesPrimaryPhoneCountryCode: 'FR', + phonesPrimaryPhoneCallingCode: '+33', + phonesPrimaryPhoneNumber: '784567890', city: 'New York', companyId: DEV_SEED_COMPANY_IDS.AIRBNB, emailsPrimaryEmail: 'elizabeth.baker@airbnb.com', position: 8, - whatsappPrimaryPhoneCountryCode: '+33', + whatsappPrimaryPhoneCountryCode: 'FR', + whatsappPrimaryPhoneCallingCode: '+33', whatsappPrimaryPhoneNumber: '784567890', createdBySource: 'MANUAL', createdByWorkspaceMemberId: DEV_SEED_WORKSPACE_MEMBER_IDS.TIM, @@ -180,13 +198,15 @@ export const seedPeople = async ( id: DEV_SEED_PERSON_IDS.CHRISTOPHER_N, nameFirstName: 'Christopher', nameLastName: 'Nelson', - phonePrimaryPhoneCountryCode: '+33', - phonePrimaryPhoneNumber: '785678901', + phonesPrimaryPhoneCountryCode: 'FR', + phonesPrimaryPhoneCallingCode: '+33', + phonesPrimaryPhoneNumber: '785678901', city: 'San Francisco', companyId: DEV_SEED_COMPANY_IDS.AIRBNB, emailsPrimaryEmail: 'christopher.nelson@airbnb.com', position: 9, - whatsappPrimaryPhoneCountryCode: '+33', + whatsappPrimaryPhoneCountryCode: 'FR', + whatsappPrimaryPhoneCallingCode: '+33', whatsappPrimaryPhoneNumber: '785678901', createdBySource: 'MANUAL', createdByWorkspaceMemberId: DEV_SEED_WORKSPACE_MEMBER_IDS.TIM, @@ -196,13 +216,15 @@ export const seedPeople = async ( id: DEV_SEED_PERSON_IDS.AVERY, nameFirstName: 'Avery', nameLastName: 'Carter', - phonePrimaryPhoneCountryCode: '+33', - phonePrimaryPhoneNumber: '786789012', + phonesPrimaryPhoneCountryCode: 'FR', + phonesPrimaryPhoneCallingCode: '+33', + phonesPrimaryPhoneNumber: '786789012', city: 'New York', companyId: DEV_SEED_COMPANY_IDS.AIRBNB, emailsPrimaryEmail: 'avery.carter@airbnb.com', position: 10, - whatsappPrimaryPhoneCountryCode: '+33', + whatsappPrimaryPhoneCountryCode: 'FR', + whatsappPrimaryPhoneCallingCode: '+33', whatsappPrimaryPhoneNumber: '786789012', createdBySource: 'MANUAL', createdByWorkspaceMemberId: DEV_SEED_WORKSPACE_MEMBER_IDS.TIM, @@ -212,13 +234,15 @@ export const seedPeople = async ( id: DEV_SEED_PERSON_IDS.ETHAN, nameFirstName: 'Ethan', nameLastName: 'Mitchell', - phonePrimaryPhoneCountryCode: '+33', - phonePrimaryPhoneNumber: '787890123', + phonesPrimaryPhoneCountryCode: 'FR', + phonesPrimaryPhoneCallingCode: '+33', + phonesPrimaryPhoneNumber: '787890123', city: 'Los Angeles', companyId: DEV_SEED_COMPANY_IDS.GOOGLE, emailsPrimaryEmail: 'ethan.mitchell@google.com', position: 11, - whatsappPrimaryPhoneCountryCode: '+33', + whatsappPrimaryPhoneCountryCode: 'FR', + whatsappPrimaryPhoneCallingCode: '+33', whatsappPrimaryPhoneNumber: '787890123', createdBySource: 'MANUAL', createdByWorkspaceMemberId: DEV_SEED_WORKSPACE_MEMBER_IDS.TIM, @@ -228,13 +252,15 @@ export const seedPeople = async ( id: DEV_SEED_PERSON_IDS.MADISON, nameFirstName: 'Madison', nameLastName: 'Perez', - phonePrimaryPhoneCountryCode: '+33', - phonePrimaryPhoneNumber: '788901234', + phonesPrimaryPhoneCountryCode: 'FR', + phonesPrimaryPhoneCallingCode: '+33', + phonesPrimaryPhoneNumber: '788901234', city: 'Seattle', companyId: DEV_SEED_COMPANY_IDS.GOOGLE, emailsPrimaryEmail: 'madison.perez@google.com', position: 12, - whatsappPrimaryPhoneCountryCode: '+33', + whatsappPrimaryPhoneCountryCode: 'FR', + whatsappPrimaryPhoneCallingCode: '+33', whatsappPrimaryPhoneNumber: '788901234', createdBySource: 'MANUAL', createdByWorkspaceMemberId: DEV_SEED_WORKSPACE_MEMBER_IDS.TIM, @@ -244,13 +270,15 @@ export const seedPeople = async ( id: DEV_SEED_PERSON_IDS.BERTRAND, nameFirstName: 'Bertrand', nameLastName: 'Voulzy', - phonePrimaryPhoneCountryCode: '+33', - phonePrimaryPhoneNumber: '788901234', + phonesPrimaryPhoneCountryCode: 'FR', + phonesPrimaryPhoneCallingCode: '+33', + phonesPrimaryPhoneNumber: '788901234', city: 'Seattle', companyId: DEV_SEED_COMPANY_IDS.GOOGLE, emailsPrimaryEmail: 'bertrand.voulzy@google.com', position: 13, - whatsappPrimaryPhoneCountryCode: '+33', + whatsappPrimaryPhoneCountryCode: 'FR', + whatsappPrimaryPhoneCallingCode: '+33', whatsappPrimaryPhoneNumber: '788901234', createdBySource: 'MANUAL', createdByWorkspaceMemberId: DEV_SEED_WORKSPACE_MEMBER_IDS.TIM, @@ -260,13 +288,15 @@ export const seedPeople = async ( id: DEV_SEED_PERSON_IDS.LOUIS, nameFirstName: 'Louis', nameLastName: 'Duss', - phonePrimaryPhoneCountryCode: '+33', - phonePrimaryPhoneNumber: '789012345', + phonesPrimaryPhoneCountryCode: 'FR', + phonesPrimaryPhoneCallingCode: '+33', + phonesPrimaryPhoneNumber: '789012345', city: 'Seattle', companyId: DEV_SEED_COMPANY_IDS.GOOGLE, emailsPrimaryEmail: 'louis.duss@google.com', position: 14, - whatsappPrimaryPhoneCountryCode: '+33', + whatsappPrimaryPhoneCountryCode: 'FR', + whatsappPrimaryPhoneCallingCode: '+33', whatsappPrimaryPhoneNumber: '789012345', createdBySource: 'MANUAL', createdByWorkspaceMemberId: DEV_SEED_WORKSPACE_MEMBER_IDS.TIM, @@ -276,13 +306,15 @@ export const seedPeople = async ( id: DEV_SEED_PERSON_IDS.LORIE, nameFirstName: 'Lorie', nameLastName: 'Vladim', - phonePrimaryPhoneCountryCode: '+33', - phonePrimaryPhoneNumber: '788901235', + phonesPrimaryPhoneCountryCode: 'FR', + phonesPrimaryPhoneCallingCode: '+33', + phonesPrimaryPhoneNumber: '788901235', city: 'Seattle', companyId: DEV_SEED_COMPANY_IDS.GOOGLE, emailsPrimaryEmail: 'lorie.vladim@google.com', position: 15, - whatsappPrimaryPhoneCountryCode: '+33', + whatsappPrimaryPhoneCountryCode: 'FR', + whatsappPrimaryPhoneCallingCode: '+33', whatsappPrimaryPhoneNumber: '788901235', createdBySource: 'MANUAL', createdByWorkspaceMemberId: null, diff --git a/packages/twenty-server/src/engine/api/__mocks__/object-metadata-item.mock.ts b/packages/twenty-server/src/engine/api/__mocks__/object-metadata-item.mock.ts index 77f1a24e44..093b4a0efb 100644 --- a/packages/twenty-server/src/engine/api/__mocks__/object-metadata-item.mock.ts +++ b/packages/twenty-server/src/engine/api/__mocks__/object-metadata-item.mock.ts @@ -231,6 +231,7 @@ const fieldPhonesMock = { { primaryPhoneNumber: '', primaryPhoneCountryCode: '', + primaryPhoneCallingCode: '', additionalPhones: {}, }, ], diff --git a/packages/twenty-server/src/engine/api/rest/core/query-builder/utils/map-field-metadata-to-graphql-query.utils.ts b/packages/twenty-server/src/engine/api/rest/core/query-builder/utils/map-field-metadata-to-graphql-query.utils.ts index a5ce9aa300..fb229a1f8e 100644 --- a/packages/twenty-server/src/engine/api/rest/core/query-builder/utils/map-field-metadata-to-graphql-query.utils.ts +++ b/packages/twenty-server/src/engine/api/rest/core/query-builder/utils/map-field-metadata-to-graphql-query.utils.ts @@ -150,6 +150,7 @@ export const mapFieldMetadataToGraphqlQuery = ( { primaryPhoneNumber primaryPhoneCountryCode + primaryPhoneCallingCode additionalPhones } `; diff --git a/packages/twenty-server/src/engine/core-modules/open-api/utils/__tests__/components.utils.spec.ts b/packages/twenty-server/src/engine/core-modules/open-api/utils/__tests__/components.utils.spec.ts index c414c6fe31..75ac244b86 100644 --- a/packages/twenty-server/src/engine/core-modules/open-api/utils/__tests__/components.utils.spec.ts +++ b/packages/twenty-server/src/engine/core-modules/open-api/utils/__tests__/components.utils.spec.ts @@ -31,6 +31,9 @@ describe('computeSchemaComponents', () => { primaryPhoneCountryCode: { type: 'string', }, + primaryPhoneCallingCode: { + type: 'string', + }, primaryPhoneNumber: { type: 'string', }, @@ -216,6 +219,9 @@ describe('computeSchemaComponents', () => { primaryPhoneCountryCode: { type: 'string', }, + primaryPhoneCallingCode: { + type: 'string', + }, primaryPhoneNumber: { type: 'string', }, @@ -400,6 +406,9 @@ describe('computeSchemaComponents', () => { primaryPhoneCountryCode: { type: 'string', }, + primaryPhoneCallingCode: { + type: 'string', + }, primaryPhoneNumber: { type: 'string', }, diff --git a/packages/twenty-server/src/engine/core-modules/open-api/utils/components.utils.ts b/packages/twenty-server/src/engine/core-modules/open-api/utils/components.utils.ts index 8b5d335896..4d93e29c07 100644 --- a/packages/twenty-server/src/engine/core-modules/open-api/utils/components.utils.ts +++ b/packages/twenty-server/src/engine/core-modules/open-api/utils/components.utils.ts @@ -259,6 +259,9 @@ const getSchemaComponentsProperties = ({ primaryPhoneCountryCode: { type: 'string', }, + primaryPhoneCallingCode: { + type: 'string', + }, primaryPhoneNumber: { type: 'string', }, diff --git a/packages/twenty-server/src/engine/metadata-modules/field-metadata/composite-types/phones.composite-type.ts b/packages/twenty-server/src/engine/metadata-modules/field-metadata/composite-types/phones.composite-type.ts index 366e957545..6f635f63bf 100644 --- a/packages/twenty-server/src/engine/metadata-modules/field-metadata/composite-types/phones.composite-type.ts +++ b/packages/twenty-server/src/engine/metadata-modules/field-metadata/composite-types/phones.composite-type.ts @@ -18,6 +18,12 @@ export const phonesCompositeType: CompositeType = { hidden: false, isRequired: false, }, + { + name: 'primaryPhoneCallingCode', + type: FieldMetadataType.TEXT, + hidden: false, + isRequired: false, + }, { name: 'additionalPhones', type: FieldMetadataType.RAW_JSON, @@ -30,5 +36,6 @@ export const phonesCompositeType: CompositeType = { export type PhonesMetadata = { primaryPhoneNumber: string; primaryPhoneCountryCode: string; + primaryPhoneCallingCode: string; additionalPhones: object | null; }; diff --git a/packages/twenty-server/src/engine/metadata-modules/field-metadata/dtos/default-value.input.ts b/packages/twenty-server/src/engine/metadata-modules/field-metadata/dtos/default-value.input.ts index 99bf6e07fe..68b88bc28f 100644 --- a/packages/twenty-server/src/engine/metadata-modules/field-metadata/dtos/default-value.input.ts +++ b/packages/twenty-server/src/engine/metadata-modules/field-metadata/dtos/default-value.input.ts @@ -193,6 +193,10 @@ export class FieldMetadataDefaultValuePhones { @IsQuotedString() primaryPhoneCountryCode: string | null; + @ValidateIf((_object, value) => value !== null) + @IsQuotedString() + primaryPhoneCallingCode: string | null; + @ValidateIf((_object, value) => value !== null) @IsObject() additionalPhones: object | null; diff --git a/packages/twenty-server/src/engine/metadata-modules/field-metadata/utils/generate-default-value.ts b/packages/twenty-server/src/engine/metadata-modules/field-metadata/utils/generate-default-value.ts index ac3642814c..17b55a4b66 100644 --- a/packages/twenty-server/src/engine/metadata-modules/field-metadata/utils/generate-default-value.ts +++ b/packages/twenty-server/src/engine/metadata-modules/field-metadata/utils/generate-default-value.ts @@ -44,6 +44,7 @@ export function generateDefaultValue( return { primaryPhoneNumber: "''", primaryPhoneCountryCode: "''", + primaryPhoneCallingCode: "''", additionalPhones: null, }; default: diff --git a/packages/twenty-zapier/src/test/creates/crud_record.test.ts b/packages/twenty-zapier/src/test/creates/crud_record.test.ts index 5065c87781..ff4dfc46dd 100644 --- a/packages/twenty-zapier/src/test/creates/crud_record.test.ts +++ b/packages/twenty-zapier/src/test/creates/crud_record.test.ts @@ -60,8 +60,11 @@ describe('creates.create_company', () => { name: { firstName: 'John', lastName: 'Doe' }, phones: { primaryPhoneNumber: '610203040', - primaryPhoneCountryCode: '+33', - additionalPhones: ['{number: "610203041", countryCode: "+33"}'], + primaryPhoneCountryCode: 'FR', + primaryPhoneCallingCode: '+33', + additionalPhones: [ + '{number: "610203041", countryCode: "FR", callingCode: "+33"}', + ], }, city: 'Paris', }); diff --git a/packages/twenty-zapier/src/test/utils/handleQueryParams.test.ts b/packages/twenty-zapier/src/test/utils/handleQueryParams.test.ts index d01fb2b38b..6453b906ff 100644 --- a/packages/twenty-zapier/src/test/utils/handleQueryParams.test.ts +++ b/packages/twenty-zapier/src/test/utils/handleQueryParams.test.ts @@ -25,8 +25,11 @@ describe('utils.handleQueryParams', () => { }, phones: { primaryPhoneNumber: '322110011', - primaryPhoneCountryCode: '+33', - additionalPhones: ["{ phoneNumber: '322110012', countryCode: '+33' }"], + primaryPhoneCountryCode: 'FR', + primaryPhoneCallingCode: '+33', + additionalPhones: [ + "{ phoneNumber: '322110012', countryCode: 'FR', callingCode: '+33' }", + ], }, xUrl__url: '/x_url', xUrl__label: 'Test xUrl', @@ -42,7 +45,7 @@ describe('utils.handleQueryParams', () => { 'linkedinUrl: {url: "/linkedin_url", label: "Test linkedinUrl"}, ' + 'whatsapp: {primaryLinkUrl: "/whatsapp_url", primaryLinkLabel: "Whatsapp Link", secondaryLinks: [{url: \'/secondary_whatsapp_url\',label: \'Secondary Whatsapp Link\'}]}, ' + 'emails: {primaryEmail: "primary@email.com", additionalEmails: ["secondary@email.com"]}, ' + - 'phones: {primaryPhoneNumber: "322110011", primaryPhoneCountryCode: "+33", additionalPhones: [{ phoneNumber: \'322110012\', countryCode: \'+33\' }]}, ' + + 'phones: {primaryPhoneNumber: "322110011", primaryPhoneCountryCode: "FR", primaryPhoneCallingCode: "+33", additionalPhones: [{ phoneNumber: \'322110012\', countryCode: \'+33\' }]}, ' + 'xUrl: {url: "/x_url", label: "Test xUrl"}, ' + 'annualRecurringRevenue: 100000, ' + 'idealCustomerProfile: true, ' + From 1d627039c0e4c03800b090c493b1014f3900ccaf Mon Sep 17 00:00:00 2001 From: Charles Bochet Date: Thu, 19 Dec 2024 17:00:30 +0100 Subject: [PATCH 05/11] Add possibility to destroy a record (#9144) There are two follow ups to this PR: - Bug: sometimes when opening Cmd+K from a deleted record, we are facing a global error - On Index page, actions in top right are displaying label and not short name - Implement multiple actions once refactoring on delete is complete --------- Co-authored-by: bosiraphael --- .../hooks/useDeleteMultipleRecordsAction.tsx | 7 +- .../hooks/useExportMultipleRecordsAction.tsx | 1 + .../DefaultSingleRecordActionsConfigV2.ts | 22 +++++- .../useAddToFavoritesSingleRecordAction.ts | 4 +- .../hooks/useDeleteSingleRecordAction.tsx | 8 +- .../hooks/useDestroySingleRecordAction.tsx | 73 +++++++++++++++++++ .../RecordIndexActionMenuButtons.tsx | 2 +- ...seFindManyRecordsSelectedInContextStore.ts | 1 + .../object-record/hooks/useFindManyRecords.ts | 11 ++- .../src/testing/mock-data/people.ts | 1 + .../display/icon/components/TablerIcons.ts | 1 + .../input/button/components/IconButton.tsx | 2 +- 12 files changed, 123 insertions(+), 10 deletions(-) create mode 100644 packages/twenty-front/src/modules/action-menu/actions/record-actions/single-record/hooks/useDestroySingleRecordAction.tsx diff --git a/packages/twenty-front/src/modules/action-menu/actions/record-actions/multiple-records/hooks/useDeleteMultipleRecordsAction.tsx b/packages/twenty-front/src/modules/action-menu/actions/record-actions/multiple-records/hooks/useDeleteMultipleRecordsAction.tsx index 7e2c5a405c..19fa6d75e3 100644 --- a/packages/twenty-front/src/modules/action-menu/actions/record-actions/multiple-records/hooks/useDeleteMultipleRecordsAction.tsx +++ b/packages/twenty-front/src/modules/action-menu/actions/record-actions/multiple-records/hooks/useDeleteMultipleRecordsAction.tsx @@ -11,16 +11,16 @@ import { computeContextStoreFilters } from '@/context-store/utils/computeContext import { useDeleteFavorite } from '@/favorites/hooks/useDeleteFavorite'; import { useFavorites } from '@/favorites/hooks/useFavorites'; import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; +import { DEFAULT_QUERY_PAGE_SIZE } from '@/object-record/constants/DefaultQueryPageSize'; import { DELETE_MAX_COUNT } from '@/object-record/constants/DeleteMaxCount'; import { useDeleteManyRecords } from '@/object-record/hooks/useDeleteManyRecords'; +import { useLazyFetchAllRecords } from '@/object-record/hooks/useLazyFetchAllRecords'; import { useRecordTable } from '@/object-record/record-table/hooks/useRecordTable'; import { ConfirmationModal } from '@/ui/layout/modal/components/ConfirmationModal'; import { useRightDrawer } from '@/ui/layout/right-drawer/hooks/useRightDrawer'; import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2'; import { useCallback, useContext, useState } from 'react'; import { IconTrash, isDefined } from 'twenty-ui'; -import { useLazyFetchAllRecords } from '@/object-record/hooks/useLazyFetchAllRecords'; -import { DEFAULT_QUERY_PAGE_SIZE } from '@/object-record/constants/DefaultQueryPageSize'; export const useDeleteMultipleRecordsAction = ({ objectMetadataItem, @@ -118,7 +118,8 @@ export const useDeleteMultipleRecordsAction = ({ type: ActionMenuEntryType.Standard, scope: ActionMenuEntryScope.RecordSelection, key: 'delete-multiple-records', - label: 'Delete', + label: 'Delete records', + shortLabel: 'Delete', position, Icon: IconTrash, accent: 'danger', diff --git a/packages/twenty-front/src/modules/action-menu/actions/record-actions/multiple-records/hooks/useExportMultipleRecordsAction.tsx b/packages/twenty-front/src/modules/action-menu/actions/record-actions/multiple-records/hooks/useExportMultipleRecordsAction.tsx index b8cebf5fc2..935c34db1c 100644 --- a/packages/twenty-front/src/modules/action-menu/actions/record-actions/multiple-records/hooks/useExportMultipleRecordsAction.tsx +++ b/packages/twenty-front/src/modules/action-menu/actions/record-actions/multiple-records/hooks/useExportMultipleRecordsAction.tsx @@ -36,6 +36,7 @@ export const useExportMultipleRecordsAction = ({ key: 'export-multiple-records', position, label: displayedExportProgress(progress), + shortLabel: 'Export', Icon: IconDatabaseExport, accent: 'default', onClick: () => download(), diff --git a/packages/twenty-front/src/modules/action-menu/actions/record-actions/single-record/constants/DefaultSingleRecordActionsConfigV2.ts b/packages/twenty-front/src/modules/action-menu/actions/record-actions/single-record/constants/DefaultSingleRecordActionsConfigV2.ts index 9847714562..b22f7e14f1 100644 --- a/packages/twenty-front/src/modules/action-menu/actions/record-actions/single-record/constants/DefaultSingleRecordActionsConfigV2.ts +++ b/packages/twenty-front/src/modules/action-menu/actions/record-actions/single-record/constants/DefaultSingleRecordActionsConfigV2.ts @@ -1,5 +1,6 @@ import { useAddToFavoritesSingleRecordAction } from '@/action-menu/actions/record-actions/single-record/hooks/useAddToFavoritesSingleRecordAction'; import { useDeleteSingleRecordAction } from '@/action-menu/actions/record-actions/single-record/hooks/useDeleteSingleRecordAction'; +import { useDestroySingleRecordAction } from '@/action-menu/actions/record-actions/single-record/hooks/useDestroySingleRecordAction'; import { useNavigateToNextRecordSingleRecordAction } from '@/action-menu/actions/record-actions/single-record/hooks/useNavigateToNextRecordSingleRecordAction'; import { useNavigateToPreviousRecordSingleRecordAction } from '@/action-menu/actions/record-actions/single-record/hooks/useNavigateToPreviousRecordSingleRecordAction'; import { useRemoveFromFavoritesSingleRecordAction } from '@/action-menu/actions/record-actions/single-record/hooks/useRemoveFromFavoritesSingleRecordAction'; @@ -16,6 +17,7 @@ import { IconHeart, IconHeartOff, IconTrash, + IconTrashX, } from 'twenty-ui'; export const DEFAULT_SINGLE_RECORD_ACTIONS_CONFIG_V2: Record< @@ -70,13 +72,29 @@ export const DEFAULT_SINGLE_RECORD_ACTIONS_CONFIG_V2: Record< ], actionHook: useDeleteSingleRecordAction, }, + destroySingleRecord: { + type: ActionMenuEntryType.Standard, + scope: ActionMenuEntryScope.RecordSelection, + key: 'destroy-single-record', + label: 'Permanently destroy record', + shortLabel: 'Destroy', + position: 3, + Icon: IconTrashX, + accent: 'danger', + isPinned: true, + availableOn: [ + ActionAvailableOn.INDEX_PAGE_SINGLE_RECORD_SELECTION, + ActionAvailableOn.SHOW_PAGE, + ], + actionHook: useDestroySingleRecordAction, + }, navigateToPreviousRecord: { type: ActionMenuEntryType.Standard, scope: ActionMenuEntryScope.RecordSelection, key: 'navigate-to-previous-record', label: 'Navigate to previous record', shortLabel: '', - position: 3, + position: 4, isPinned: true, Icon: IconChevronUp, availableOn: [ActionAvailableOn.SHOW_PAGE], @@ -88,7 +106,7 @@ export const DEFAULT_SINGLE_RECORD_ACTIONS_CONFIG_V2: Record< key: 'navigate-to-next-record', label: 'Navigate to next record', shortLabel: '', - position: 4, + position: 5, isPinned: true, Icon: IconChevronDown, availableOn: [ActionAvailableOn.SHOW_PAGE], diff --git a/packages/twenty-front/src/modules/action-menu/actions/record-actions/single-record/hooks/useAddToFavoritesSingleRecordAction.ts b/packages/twenty-front/src/modules/action-menu/actions/record-actions/single-record/hooks/useAddToFavoritesSingleRecordAction.ts index da9ee0be9e..88c8f4c192 100644 --- a/packages/twenty-front/src/modules/action-menu/actions/record-actions/single-record/hooks/useAddToFavoritesSingleRecordAction.ts +++ b/packages/twenty-front/src/modules/action-menu/actions/record-actions/single-record/hooks/useAddToFavoritesSingleRecordAction.ts @@ -2,6 +2,7 @@ import { SingleRecordActionHookWithObjectMetadataItem } from '@/action-menu/acti import { useCreateFavorite } from '@/favorites/hooks/useCreateFavorite'; import { useFavorites } from '@/favorites/hooks/useFavorites'; import { recordStoreFamilyState } from '@/object-record/record-store/states/recordStoreFamilyState'; +import { isNull } from '@sniptt/guards'; import { useRecoilValue } from 'recoil'; import { isDefined } from 'twenty-ui'; @@ -23,7 +24,8 @@ export const useAddToFavoritesSingleRecordAction: SingleRecordActionHookWithObje isDefined(objectMetadataItem) && isDefined(selectedRecord) && !objectMetadataItem.isRemote && - !isFavorite; + !isFavorite && + isNull(selectedRecord.deletedAt); const onClick = () => { if (!shouldBeRegistered) { diff --git a/packages/twenty-front/src/modules/action-menu/actions/record-actions/single-record/hooks/useDeleteSingleRecordAction.tsx b/packages/twenty-front/src/modules/action-menu/actions/record-actions/single-record/hooks/useDeleteSingleRecordAction.tsx index 2a8981ee09..cb58be678a 100644 --- a/packages/twenty-front/src/modules/action-menu/actions/record-actions/single-record/hooks/useDeleteSingleRecordAction.tsx +++ b/packages/twenty-front/src/modules/action-menu/actions/record-actions/single-record/hooks/useDeleteSingleRecordAction.tsx @@ -3,10 +3,13 @@ import { ActionMenuContext } from '@/action-menu/contexts/ActionMenuContext'; import { useDeleteFavorite } from '@/favorites/hooks/useDeleteFavorite'; import { useFavorites } from '@/favorites/hooks/useFavorites'; import { useDeleteOneRecord } from '@/object-record/hooks/useDeleteOneRecord'; +import { recordStoreFamilyState } from '@/object-record/record-store/states/recordStoreFamilyState'; import { useRecordTable } from '@/object-record/record-table/hooks/useRecordTable'; import { ConfirmationModal } from '@/ui/layout/modal/components/ConfirmationModal'; import { useRightDrawer } from '@/ui/layout/right-drawer/hooks/useRightDrawer'; +import { isNull } from '@sniptt/guards'; import { useCallback, useContext, useState } from 'react'; +import { useRecoilValue } from 'recoil'; import { isDefined } from 'twenty-ui'; export const useDeleteSingleRecordAction: SingleRecordActionHookWithObjectMetadataItem = @@ -22,6 +25,8 @@ export const useDeleteSingleRecordAction: SingleRecordActionHookWithObjectMetada objectNameSingular: objectMetadataItem.nameSingular, }); + const selectedRecord = useRecoilValue(recordStoreFamilyState(recordId)); + const { sortedFavorites: favorites } = useFavorites(); const { deleteFavorite } = useDeleteFavorite(); @@ -52,7 +57,8 @@ export const useDeleteSingleRecordAction: SingleRecordActionHookWithObjectMetada const { isInRightDrawer, onActionExecutedCallback } = useContext(ActionMenuContext); - const shouldBeRegistered = !isRemoteObject; + const shouldBeRegistered = + !isRemoteObject && isNull(selectedRecord?.deletedAt); const onClick = () => { if (!shouldBeRegistered) { diff --git a/packages/twenty-front/src/modules/action-menu/actions/record-actions/single-record/hooks/useDestroySingleRecordAction.tsx b/packages/twenty-front/src/modules/action-menu/actions/record-actions/single-record/hooks/useDestroySingleRecordAction.tsx new file mode 100644 index 0000000000..c024df812f --- /dev/null +++ b/packages/twenty-front/src/modules/action-menu/actions/record-actions/single-record/hooks/useDestroySingleRecordAction.tsx @@ -0,0 +1,73 @@ +import { SingleRecordActionHookWithObjectMetadataItem } from '@/action-menu/actions/types/singleRecordActionHook'; +import { ActionMenuContext } from '@/action-menu/contexts/ActionMenuContext'; +import { useDestroyOneRecord } from '@/object-record/hooks/useDestroyOneRecord'; +import { recordStoreFamilyState } from '@/object-record/record-store/states/recordStoreFamilyState'; +import { useRecordTable } from '@/object-record/record-table/hooks/useRecordTable'; +import { ConfirmationModal } from '@/ui/layout/modal/components/ConfirmationModal'; +import { useRightDrawer } from '@/ui/layout/right-drawer/hooks/useRightDrawer'; +import { useCallback, useContext, useState } from 'react'; +import { useRecoilValue } from 'recoil'; +import { isDefined } from 'twenty-ui'; + +export const useDestroySingleRecordAction: SingleRecordActionHookWithObjectMetadataItem = + ({ recordId, objectMetadataItem }) => { + const [isDestroyRecordsModalOpen, setIsDestroyRecordsModalOpen] = + useState(false); + + const { resetTableRowSelection } = useRecordTable({ + recordTableId: objectMetadataItem.namePlural, + }); + + const { destroyOneRecord } = useDestroyOneRecord({ + objectNameSingular: objectMetadataItem.nameSingular, + }); + + const selectedRecord = useRecoilValue(recordStoreFamilyState(recordId)); + + const { closeRightDrawer } = useRightDrawer(); + + const handleDeleteClick = useCallback(async () => { + resetTableRowSelection(); + + await destroyOneRecord(recordId); + }, [resetTableRowSelection, destroyOneRecord, recordId]); + + const isRemoteObject = objectMetadataItem.isRemote; + + const { isInRightDrawer, onActionExecutedCallback } = + useContext(ActionMenuContext); + + const shouldBeRegistered = + !isRemoteObject && isDefined(selectedRecord?.deletedAt); + + const onClick = () => { + if (!shouldBeRegistered) { + return; + } + + setIsDestroyRecordsModalOpen(true); + }; + + return { + shouldBeRegistered, + onClick, + ConfirmationModal: ( + { + await handleDeleteClick(); + onActionExecutedCallback?.(); + if (isInRightDrawer) { + closeRightDrawer(); + } + }} + deleteButtonText={'Permanently Destroy Record'} + /> + ), + }; + }; diff --git a/packages/twenty-front/src/modules/action-menu/components/RecordIndexActionMenuButtons.tsx b/packages/twenty-front/src/modules/action-menu/components/RecordIndexActionMenuButtons.tsx index 34090f9922..fdafd08d2c 100644 --- a/packages/twenty-front/src/modules/action-menu/components/RecordIndexActionMenuButtons.tsx +++ b/packages/twenty-front/src/modules/action-menu/components/RecordIndexActionMenuButtons.tsx @@ -27,7 +27,7 @@ export const RecordIndexActionMenuButtons = () => { size="small" variant="secondary" accent="default" - title={entry.label} + title={entry.shortLabel} onClick={() => entry.onClick?.()} ariaLabel={entry.label} /> diff --git a/packages/twenty-front/src/modules/context-store/hooks/useFindManyRecordsSelectedInContextStore.ts b/packages/twenty-front/src/modules/context-store/hooks/useFindManyRecordsSelectedInContextStore.ts index 4d6867e6d0..30b095a301 100644 --- a/packages/twenty-front/src/modules/context-store/hooks/useFindManyRecordsSelectedInContextStore.ts +++ b/packages/twenty-front/src/modules/context-store/hooks/useFindManyRecordsSelectedInContextStore.ts @@ -39,6 +39,7 @@ export const useFindManyRecordsSelectedInContextStore = ({ const { records, loading, totalCount } = useFindManyRecords({ objectNameSingular: objectMetadataItem.nameSingular, filter: queryFilter, + withSoftDeleted: true, orderBy: [ { position: 'AscNullsFirst', diff --git a/packages/twenty-front/src/modules/object-record/hooks/useFindManyRecords.ts b/packages/twenty-front/src/modules/object-record/hooks/useFindManyRecords.ts index 0dac0caa6d..b72f7ed255 100644 --- a/packages/twenty-front/src/modules/object-record/hooks/useFindManyRecords.ts +++ b/packages/twenty-front/src/modules/object-record/hooks/useFindManyRecords.ts @@ -20,6 +20,7 @@ export type UseFindManyRecordsParams = ObjectMetadataItemIdentifier & skip?: boolean; recordGqlFields?: RecordGqlOperationGqlRecordFields; fetchPolicy?: WatchQueryFetchPolicy; + withSoftDeleted?: boolean; }; export const useFindManyRecords = ({ @@ -33,6 +34,7 @@ export const useFindManyRecords = ({ onError, onCompleted, cursorFilter, + withSoftDeleted = false, }: UseFindManyRecordsParams) => { const { objectMetadataItem } = useObjectMetadataItem({ objectNameSingular, @@ -61,11 +63,18 @@ export const useFindManyRecords = ({ onCompleted, }); + const withSoftDeleterFilter = { + or: [{ deletedAt: { is: 'NULL' } }, { deletedAt: { is: 'NOT_NULL' } }], + }; + const { data, loading, error, fetchMore } = useQuery(findManyRecordsQuery, { skip: skip || !objectMetadataItem, variables: { - filter, + filter: { + ...filter, + ...(withSoftDeleted ? withSoftDeleterFilter : {}), + }, orderBy, lastCursor: cursorFilter?.cursor ?? undefined, limit: cursorFilter?.limit ?? limit, diff --git a/packages/twenty-front/src/testing/mock-data/people.ts b/packages/twenty-front/src/testing/mock-data/people.ts index f09fc18aff..47ef0afb45 100644 --- a/packages/twenty-front/src/testing/mock-data/people.ts +++ b/packages/twenty-front/src/testing/mock-data/people.ts @@ -45,6 +45,7 @@ export const peopleQueryResult: { people: RecordGqlConnection } = { __typename: 'Person', createdAt: '2024-08-02T09:52:46.814Z', city: 'ASd', + deletedAt: null, phones: { primaryPhoneNumber: '781234562', primaryPhoneCountryCode: 'FR', diff --git a/packages/twenty-ui/src/display/icon/components/TablerIcons.ts b/packages/twenty-ui/src/display/icon/components/TablerIcons.ts index 595f1abf22..10125f41cb 100644 --- a/packages/twenty-ui/src/display/icon/components/TablerIcons.ts +++ b/packages/twenty-ui/src/display/icon/components/TablerIcons.ts @@ -244,6 +244,7 @@ export { IconTextWrap, IconTimelineEvent, IconTrash, + IconTrashX, IconUnlink, IconUpload, IconUser, diff --git a/packages/twenty-ui/src/input/button/components/IconButton.tsx b/packages/twenty-ui/src/input/button/components/IconButton.tsx index 93bc7e79f7..1501bc4101 100644 --- a/packages/twenty-ui/src/input/button/components/IconButton.tsx +++ b/packages/twenty-ui/src/input/button/components/IconButton.tsx @@ -117,7 +117,7 @@ const StyledButton = styled.button< border-color: ${variant === 'secondary' ? !disabled && focus ? theme.color.blue - : theme.background.transparent.light + : theme.background.transparent.medium : focus ? theme.color.blue : 'transparent'}; From 811002d8b9ef2049f18b8e06ac0e1dce52e36fcc Mon Sep 17 00:00:00 2001 From: BOHEUS <56270748+BOHEUS@users.noreply.github.com> Date: Thu, 19 Dec 2024 16:01:58 +0000 Subject: [PATCH 06/11] Github CI workflow fix (#9137) --- .github/workflows/ci-demo-check.yml | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci-demo-check.yml b/.github/workflows/ci-demo-check.yml index 9f6af7d7a6..5340ff0037 100644 --- a/.github/workflows/ci-demo-check.yml +++ b/.github/workflows/ci-demo-check.yml @@ -1,7 +1,8 @@ -name: CI demo check +name: CI Demo check on: schedule: - cron: '30 7,19 * * *' + workflow_dispatch: concurrency: group: ${{ github.workflow }}-${{ github.ref }} @@ -11,6 +12,9 @@ jobs: test: timeout-minutes: 15 runs-on: ubuntu-latest + defaults: + run: + working-directory: ./packages/twenty-e2e-testing steps: - uses: actions/checkout@v4 with: @@ -27,7 +31,7 @@ jobs: - name: Run Playwright tests id: test - run: yarn playwright test --grep @demo-only + run: yarn playwright test --grep "@demo-only" - name: Upload report after tests uses: actions/upload-artifact@v4 From ded0a68b89b045ba58bd33677a1df05428092e1c Mon Sep 17 00:00:00 2001 From: Charles Bochet Date: Thu, 19 Dec 2024 17:49:29 +0100 Subject: [PATCH 07/11] Fix tests --- .../src/testing/mock-data/people.ts | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/packages/twenty-front/src/testing/mock-data/people.ts b/packages/twenty-front/src/testing/mock-data/people.ts index 47ef0afb45..b3a6c38b37 100644 --- a/packages/twenty-front/src/testing/mock-data/people.ts +++ b/packages/twenty-front/src/testing/mock-data/people.ts @@ -22,6 +22,7 @@ export const mockedEmptyPersonData = { xUrl: null, _activityCount: null, company: null, + deletedAt: null, __typename: 'Person', }; @@ -43,9 +44,9 @@ export const peopleQueryResult: { people: RecordGqlConnection } = { cursor: 'WzAsICJkYTNjMmM0Yi1kYTAxLTRiODEtOTczNC0yMjYwNjllYjRjZDAiXQ==', node: { __typename: 'Person', + deletedAt: null, createdAt: '2024-08-02T09:52:46.814Z', city: 'ASd', - deletedAt: null, phones: { primaryPhoneNumber: '781234562', primaryPhoneCountryCode: 'FR', @@ -175,6 +176,7 @@ export const peopleQueryResult: { people: RecordGqlConnection } = { cursor: 'WzEsICIyMDIwMjAyMC0xYzBlLTQ5NGMtYTFiNi04NWIxYzZmZWZhYTUiXQ==', node: { __typename: 'Person', + deletedAt: null, createdAt: '2024-08-01T09:50:00.000Z', city: 'Seattle', phones: { @@ -306,6 +308,7 @@ export const peopleQueryResult: { people: RecordGqlConnection } = { cursor: 'WzIsICIyMDIwMjAyMC1hYzczLTQ3OTctODI0ZS04N2ExZjVhZWE5ZTAiXQ==', node: { __typename: 'Person', + deletedAt: null, createdAt: '2024-08-02T09:48:36.193Z', city: 'Los Angeles', phones: { @@ -406,6 +409,7 @@ export const peopleQueryResult: { people: RecordGqlConnection } = { cursor: 'WzMsICIyMDIwMjAyMC1mNTE3LTQyZmQtODBhZS0xNDE3M2IzYjcwYWUiXQ==', node: { __typename: 'Person', + deletedAt: null, createdAt: '2024-08-02T09:48:36.193Z', city: 'Seattle', phones: { @@ -506,6 +510,7 @@ export const peopleQueryResult: { people: RecordGqlConnection } = { cursor: 'WzQsICIyMDIwMjAyMC1lZWUxLTQ2OTAtYWQyYy04NjE5ZTViNTZhMmUiXQ==', node: { __typename: 'Person', + deletedAt: null, createdAt: '2024-08-02T09:48:36.193Z', city: 'Los Angeles', phones: { @@ -606,6 +611,7 @@ export const peopleQueryResult: { people: RecordGqlConnection } = { cursor: 'WzUsICIyMDIwMjAyMC02Nzg0LTQ0NDktYWZkZi1kYzYyY2I4NzAyZjIiXQ==', node: { __typename: 'Person', + deletedAt: null, createdAt: '2024-08-02T09:48:36.193Z', city: 'Seattle', phones: { @@ -706,6 +712,7 @@ export const peopleQueryResult: { people: RecordGqlConnection } = { cursor: 'WzYsICIyMDIwMjAyMC00OTBmLTQ0NjYtODM5MS03MzNjZmQ2NmEwYzgiXQ==', node: { __typename: 'Person', + deletedAt: null, createdAt: '2024-08-02T09:48:36.193Z', city: 'New York', phones: { @@ -806,6 +813,7 @@ export const peopleQueryResult: { people: RecordGqlConnection } = { cursor: 'WzcsICIyMDIwMjAyMC04MGYxLTRkZmYtYjU3MC1hNzQ5NDI1MjhkZTMiXQ==', node: { __typename: 'Person', + deletedAt: null, createdAt: '2024-08-02T09:48:36.193Z', city: 'Seattle', phones: { @@ -906,6 +914,7 @@ export const peopleQueryResult: { people: RecordGqlConnection } = { cursor: 'WzgsICIyMDIwMjAyMC0zMzhiLTQ2ZGYtODgxMS1mYTA4YzdkMTlkMzUiXQ==', node: { __typename: 'Person', + deletedAt: null, createdAt: '2024-08-02T09:48:36.193Z', city: 'New York', phones: { @@ -1006,6 +1015,7 @@ export const peopleQueryResult: { people: RecordGqlConnection } = { cursor: 'WzksICIyMDIwMjAyMC02NGFkLTRiMGUtYmJmZC1lOWZkNzk1YjcwMTYiXQ==', node: { __typename: 'Person', + deletedAt: null, createdAt: '2024-08-02T09:48:36.193Z', city: 'San Francisco', phones: { @@ -1106,6 +1116,7 @@ export const peopleQueryResult: { people: RecordGqlConnection } = { cursor: 'WzEwLCAiMjAyMDIwMjAtNWQ1NC00MWI3LWJhMzYtZjBkMjBlMTQxN2FlIl0=', node: { __typename: 'Person', + deletedAt: null, createdAt: '2024-08-02T09:48:36.193Z', city: 'New York', phones: { @@ -1206,6 +1217,7 @@ export const peopleQueryResult: { people: RecordGqlConnection } = { cursor: 'WzExLCAiMjAyMDIwMjAtNjIzZC00MWZlLTkyZTctZGQ0NWI3YzU2OGUxIl0=', node: { __typename: 'Person', + deletedAt: null, createdAt: '2024-08-02T09:48:36.193Z', city: 'Los Angeles', phones: { @@ -1306,6 +1318,7 @@ export const peopleQueryResult: { people: RecordGqlConnection } = { cursor: 'WzEyLCAiMjAyMDIwMjAtMmQ0MC00ZTQ5LThkZjQtOWM2YTA0OTE5MGVmIl0=', node: { __typename: 'Person', + deletedAt: null, createdAt: '2024-08-02T09:48:36.193Z', city: 'Seattle', phones: { @@ -1406,6 +1419,7 @@ export const peopleQueryResult: { people: RecordGqlConnection } = { cursor: 'WzEzLCAiMjAyMDIwMjAtMmQ0MC00ZTQ5LThkZjQtOWM2YTA0OTE5MGRmIl0=', node: { __typename: 'Person', + deletedAt: null, createdAt: '2024-08-02T09:48:36.193Z', city: 'Seattle', phones: { @@ -1506,6 +1520,7 @@ export const peopleQueryResult: { people: RecordGqlConnection } = { cursor: 'WzE0LCAiMjAyMDIwMjAtMmQ0MC00ZTQ5LThkZjQtOWM2YTA0OTE5MWRlIl0=', node: { __typename: 'Person', + deletedAt: null, createdAt: '2024-08-02T09:48:36.193Z', city: 'Seattle', phones: { @@ -1606,6 +1621,7 @@ export const peopleQueryResult: { people: RecordGqlConnection } = { cursor: 'WzE1LCAiMjAyMDIwMjAtMmQ0MC00ZTQ5LThkZjQtOWM2YTA0OTE5MWRmIl0=', node: { __typename: 'Person', + deletedAt: null, createdAt: '2024-08-02T09:48:36.193Z', city: 'Seattle', phones: { From 94136d953ea1e88f6c127a78933c9e737b0d001a Mon Sep 17 00:00:00 2001 From: martmull Date: Fri, 20 Dec 2024 09:56:22 +0100 Subject: [PATCH 08/11] 8749 invalid openapi schema (#9156) --- .../core-modules/open-api/utils/base-schema.utils.ts | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/packages/twenty-server/src/engine/core-modules/open-api/utils/base-schema.utils.ts b/packages/twenty-server/src/engine/core-modules/open-api/utils/base-schema.utils.ts index 059f5ae291..1cb8ff05e2 100644 --- a/packages/twenty-server/src/engine/core-modules/open-api/utils/base-schema.utils.ts +++ b/packages/twenty-server/src/engine/core-modules/open-api/utils/base-schema.utils.ts @@ -9,17 +9,18 @@ export const baseSchema = ( serverUrl: string, ): OpenAPIV3_1.Document => { return { - openapi: '3.0.3', + openapi: '3.1.1', info: { title: 'Twenty Api', - description: `This is a **Twenty REST/API** playground based on the **OpenAPI 3.0 specification**.`, - termsOfService: 'https://github.com/twentyhq/twenty?tab=coc-ov-file', + description: `This is a **Twenty REST/API** playground based on the **OpenAPI 3.1 specification**.`, + termsOfService: + 'https://github.com/twentyhq/twenty?tab=coc-ov-file#readme', contact: { email: 'felix@twenty.com', }, license: { name: 'AGPL-3.0', - url: 'https://github.com/twentyhq/twenty?tab=AGPL-3.0-1-ov-file#readme', + url: 'https://github.com/twentyhq/twenty?tab=License-1-ov-file#readme', }, version: API_Version, }, From 54c4d64ae8b9be77a83d5fbe300ed785939a1a44 Mon Sep 17 00:00:00 2001 From: nitin <142569587+ehconitin@users.noreply.github.com> Date: Fri, 20 Dec 2024 15:13:48 +0530 Subject: [PATCH 09/11] Scrollable fixed dropdowns container minor refactor (#9159) Co-authored-by: Lucas Bordeau --- .../RecordIndexActionMenuDropdown.tsx | 8 ++- .../components/FavoriteFolderPicker.tsx | 1 + .../components/FavoriteFolderPickerFooter.tsx | 35 +++++------- .../ObjectOptionsDropdownFieldsContent.tsx | 2 +- ...jectOptionsDropdownHiddenFieldsContent.tsx | 2 +- .../ObjectOptionsDropdownMenuContent.tsx | 4 +- .../RecordTableHeaderPlusButtonContent.tsx | 2 +- .../components/MultiRecordSelect.tsx | 4 +- .../SingleRecordSelectMenuItemsWithSearch.tsx | 4 +- ...tingsDataModelFieldSelectFormOptionRow.tsx | 53 ++++++++----------- .../components/SettingsObjectSummaryCard.tsx | 28 +++++----- ...tegrationDatabaseConnectionSummaryCard.tsx | 23 ++++---- .../modules/ui/input/components/Select.tsx | 2 +- .../components/DropdownMenuItemsContainer.tsx | 6 +-- .../components/ShowPageAddButton.tsx | 31 +++++------ .../components/ShowPageSubContainer.tsx | 2 +- .../components/UpdateViewButtonGroup.tsx | 16 +++--- .../ViewPickerContentCreateMode.tsx | 2 +- .../components/ViewPickerContentEditMode.tsx | 2 +- .../components/ViewPickerListContent.tsx | 2 +- 20 files changed, 101 insertions(+), 128 deletions(-) diff --git a/packages/twenty-front/src/modules/action-menu/components/RecordIndexActionMenuDropdown.tsx b/packages/twenty-front/src/modules/action-menu/components/RecordIndexActionMenuDropdown.tsx index ed7fa1d7d5..1b91826c20 100644 --- a/packages/twenty-front/src/modules/action-menu/components/RecordIndexActionMenuDropdown.tsx +++ b/packages/twenty-front/src/modules/action-menu/components/RecordIndexActionMenuDropdown.tsx @@ -1,8 +1,3 @@ -import styled from '@emotion/styled'; -import { useRecoilValue } from 'recoil'; - -import { PositionType } from '../types/PositionType'; - import { actionMenuEntriesComponentSelector } from '@/action-menu/states/actionMenuEntriesComponentSelector'; import { ActionMenuComponentInstanceContext } from '@/action-menu/states/contexts/ActionMenuComponentInstanceContext'; import { recordIndexActionMenuDropdownPositionComponentState } from '@/action-menu/states/recordIndexActionMenuDropdownPositionComponentState'; @@ -13,7 +8,10 @@ import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/Drop import { useAvailableComponentInstanceIdOrThrow } from '@/ui/utilities/state/component-state/hooks/useAvailableComponentInstanceIdOrThrow'; import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2'; import { extractComponentState } from '@/ui/utilities/state/component-state/utils/extractComponentState'; +import styled from '@emotion/styled'; +import { useRecoilValue } from 'recoil'; import { MenuItem } from 'twenty-ui'; +import { PositionType } from '../types/PositionType'; type StyledContainerProps = { position: PositionType; diff --git a/packages/twenty-front/src/modules/favorites/favorite-folder-picker/components/FavoriteFolderPicker.tsx b/packages/twenty-front/src/modules/favorites/favorite-folder-picker/components/FavoriteFolderPicker.tsx index a8b58b34f4..d64b04cbf1 100644 --- a/packages/twenty-front/src/modules/favorites/favorite-folder-picker/components/FavoriteFolderPicker.tsx +++ b/packages/twenty-front/src/modules/favorites/favorite-folder-picker/components/FavoriteFolderPicker.tsx @@ -99,6 +99,7 @@ export const FavoriteFolderPicker = ({ toggleFolderSelection={toggleFolderSelection} /> + ); diff --git a/packages/twenty-front/src/modules/favorites/favorite-folder-picker/components/FavoriteFolderPickerFooter.tsx b/packages/twenty-front/src/modules/favorites/favorite-folder-picker/components/FavoriteFolderPickerFooter.tsx index b43b045a20..1f60d986c6 100644 --- a/packages/twenty-front/src/modules/favorites/favorite-folder-picker/components/FavoriteFolderPickerFooter.tsx +++ b/packages/twenty-front/src/modules/favorites/favorite-folder-picker/components/FavoriteFolderPickerFooter.tsx @@ -4,16 +4,9 @@ import { useDropdown } from '@/ui/layout/dropdown/hooks/useDropdown'; import { useNavigationSection } from '@/ui/navigation/navigation-drawer/hooks/useNavigationSection'; import { isNavigationDrawerExpandedState } from '@/ui/navigation/states/isNavigationDrawerExpanded'; import { useTheme } from '@emotion/react'; -import styled from '@emotion/styled'; import { useRecoilState, useSetRecoilState } from 'recoil'; import { IconPlus, MenuItem } from 'twenty-ui'; -const StyledFooter = styled.div` - border-bottom-left-radius: ${({ theme }) => theme.border.radius.md}; - border-bottom-right-radius: ${({ theme }) => theme.border.radius.md}; - border-top: 1px solid ${({ theme }) => theme.border.color.light}; -`; - export const FavoriteFolderPickerFooter = ({ dropdownId, }: { @@ -30,20 +23,18 @@ export const FavoriteFolderPickerFooter = ({ const { closeDropdown } = useDropdown(dropdownId); return ( - - - { - setIsNavigationDrawerExpanded(true); - openNavigationSection(); - setIsFavoriteFolderCreating(true); - closeDropdown(); - }} - text="Add folder" - LeftIcon={() => } - /> - - + + { + setIsNavigationDrawerExpanded(true); + openNavigationSection(); + setIsFavoriteFolderCreating(true); + closeDropdown(); + }} + text="Add folder" + LeftIcon={() => } + /> + ); }; diff --git a/packages/twenty-front/src/modules/object-record/object-options-dropdown/components/ObjectOptionsDropdownFieldsContent.tsx b/packages/twenty-front/src/modules/object-record/object-options-dropdown/components/ObjectOptionsDropdownFieldsContent.tsx index 69eda6e55a..efb1c84668 100644 --- a/packages/twenty-front/src/modules/object-record/object-options-dropdown/components/ObjectOptionsDropdownFieldsContent.tsx +++ b/packages/twenty-front/src/modules/object-record/object-options-dropdown/components/ObjectOptionsDropdownFieldsContent.tsx @@ -62,7 +62,7 @@ export const ObjectOptionsDropdownFieldsContent = () => { showDragGrip={true} /> - + onContentChange('hiddenFields')} LeftIcon={IconEyeOff} diff --git a/packages/twenty-front/src/modules/object-record/object-options-dropdown/components/ObjectOptionsDropdownHiddenFieldsContent.tsx b/packages/twenty-front/src/modules/object-record/object-options-dropdown/components/ObjectOptionsDropdownHiddenFieldsContent.tsx index 15a87ed2fa..05aa96d883 100644 --- a/packages/twenty-front/src/modules/object-record/object-options-dropdown/components/ObjectOptionsDropdownHiddenFieldsContent.tsx +++ b/packages/twenty-front/src/modules/object-record/object-options-dropdown/components/ObjectOptionsDropdownHiddenFieldsContent.tsx @@ -87,7 +87,7 @@ export const ObjectOptionsDropdownHiddenFieldsContent = () => { closeDropdown(); }} > - + diff --git a/packages/twenty-front/src/modules/object-record/object-options-dropdown/components/ObjectOptionsDropdownMenuContent.tsx b/packages/twenty-front/src/modules/object-record/object-options-dropdown/components/ObjectOptionsDropdownMenuContent.tsx index 99e21cd9d0..0cfa046491 100644 --- a/packages/twenty-front/src/modules/object-record/object-options-dropdown/components/ObjectOptionsDropdownMenuContent.tsx +++ b/packages/twenty-front/src/modules/object-record/object-options-dropdown/components/ObjectOptionsDropdownMenuContent.tsx @@ -98,7 +98,7 @@ export const ObjectOptionsDropdownMenuContent = () => { {/** TODO: Should be removed when view settings contains more options */} {viewType === ViewType.Kanban && ( <> - + onContentChange('viewSettings')} LeftIcon={IconLayout} @@ -109,7 +109,7 @@ export const ObjectOptionsDropdownMenuContent = () => { )} - + onContentChange('fields')} LeftIcon={IconTag} diff --git a/packages/twenty-front/src/modules/object-record/record-table/record-table-header/components/RecordTableHeaderPlusButtonContent.tsx b/packages/twenty-front/src/modules/object-record/record-table/record-table-header/components/RecordTableHeaderPlusButtonContent.tsx index 95db8d71f2..a6c628bcfe 100644 --- a/packages/twenty-front/src/modules/object-record/record-table/record-table-header/components/RecordTableHeaderPlusButtonContent.tsx +++ b/packages/twenty-front/src/modules/object-record/record-table/record-table-header/components/RecordTableHeaderPlusButtonContent.tsx @@ -53,7 +53,7 @@ export const RecordTableHeaderPlusButtonContent = () => { ))} - + {isDefined(onCreate) && ( - + {createNewButton} )} @@ -181,7 +181,7 @@ export const MultiRecordSelect = ({ )} {isDefined(onCreate) && ( - + {createNewButton} )} diff --git a/packages/twenty-front/src/modules/object-record/relation-picker/components/SingleRecordSelectMenuItemsWithSearch.tsx b/packages/twenty-front/src/modules/object-record/relation-picker/components/SingleRecordSelectMenuItemsWithSearch.tsx index a9331c4c71..a971eecff2 100644 --- a/packages/twenty-front/src/modules/object-record/relation-picker/components/SingleRecordSelectMenuItemsWithSearch.tsx +++ b/packages/twenty-front/src/modules/object-record/relation-picker/components/SingleRecordSelectMenuItemsWithSearch.tsx @@ -69,7 +69,7 @@ export const SingleRecordSelectMenuItemsWithSearch = ({ <> {dropdownPlacement?.includes('end') && ( <> - + {createNewButton} {records.recordsToSelect.length > 0 && } @@ -117,7 +117,7 @@ export const SingleRecordSelectMenuItemsWithSearch = ({ )} {isDefined(onCreate) && ( - + {createNewButton} )} diff --git a/packages/twenty-front/src/modules/settings/data-model/fields/forms/select/components/SettingsDataModelFieldSelectFormOptionRow.tsx b/packages/twenty-front/src/modules/settings/data-model/fields/forms/select/components/SettingsDataModelFieldSelectFormOptionRow.tsx index ffe61d6c4f..6f3002e785 100644 --- a/packages/twenty-front/src/modules/settings/data-model/fields/forms/select/components/SettingsDataModelFieldSelectFormOptionRow.tsx +++ b/packages/twenty-front/src/modules/settings/data-model/fields/forms/select/components/SettingsDataModelFieldSelectFormOptionRow.tsx @@ -8,7 +8,6 @@ import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/Drop import { useDropdown } from '@/ui/layout/dropdown/hooks/useDropdown'; import { useTheme } from '@emotion/react'; import styled from '@emotion/styled'; -import { useMemo } from 'react'; import { ColorSample, IconCheck, @@ -21,7 +20,6 @@ import { MenuItem, MenuItemSelectColor, } from 'twenty-ui'; -import { v4 } from 'uuid'; import { computeOptionValueFromLabel } from '~/pages/settings/data-model/utils/compute-option-value-from-label.utils'; type SettingsDataModelFieldSelectFormOptionRowProps = { @@ -81,17 +79,14 @@ export const SettingsDataModelFieldSelectFormOptionRow = ({ }: SettingsDataModelFieldSelectFormOptionRowProps) => { const theme = useTheme(); - const dropdownIds = useMemo(() => { - const baseScopeId = `select-field-option-row-${v4()}`; - return { - color: `${baseScopeId}-color`, - actions: `${baseScopeId}-actions`, - }; - }, []); + const SELECT_COLOR_DROPDOWN_ID = 'select-color-dropdown'; + const SELECT_ACTIONS_DROPDOWN_ID = 'select-actions-dropdown'; - const { closeDropdown: closeColorDropdown } = useDropdown(dropdownIds.color); + const { closeDropdown: closeColorDropdown } = useDropdown( + SELECT_COLOR_DROPDOWN_ID, + ); const { closeDropdown: closeActionsDropdown } = useDropdown( - dropdownIds.actions, + SELECT_ACTIONS_DROPDOWN_ID, ); const handleInputEnter = () => { @@ -120,28 +115,26 @@ export const SettingsDataModelFieldSelectFormOptionRow = ({ /> } dropdownComponents={ - - - {MAIN_COLOR_NAMES.map((colorName) => ( - { - onChange({ ...option, color: colorName }); - closeColorDropdown(); - }} - color={colorName} - selected={colorName === option.color} - /> - ))} - - + + {MAIN_COLOR_NAMES.map((colorName) => ( + { + onChange({ ...option, color: colorName }); + closeColorDropdown(); + }} + color={colorName} + selected={colorName === option.color} + /> + ))} + } /> diff --git a/packages/twenty-front/src/modules/settings/data-model/object-details/components/SettingsObjectSummaryCard.tsx b/packages/twenty-front/src/modules/settings/data-model/object-details/components/SettingsObjectSummaryCard.tsx index f818cded81..5567a3bf9a 100644 --- a/packages/twenty-front/src/modules/settings/data-model/object-details/components/SettingsObjectSummaryCard.tsx +++ b/packages/twenty-front/src/modules/settings/data-model/object-details/components/SettingsObjectSummaryCard.tsx @@ -16,7 +16,6 @@ import { SettingsSummaryCard } from '@/settings/components/SettingsSummaryCard'; import { SettingsDataModelObjectTypeTag } from '@/settings/data-model/objects/components/SettingsDataModelObjectTypeTag'; import { getObjectTypeLabel } from '@/settings/data-model/utils/getObjectTypeLabel'; import { Dropdown } from '@/ui/layout/dropdown/components/Dropdown'; -import { DropdownMenu } from '@/ui/layout/dropdown/components/DropdownMenu'; import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer'; import { useDropdown } from '@/ui/layout/dropdown/hooks/useDropdown'; @@ -86,21 +85,20 @@ export const SettingsObjectSummaryCard = ({ accent="tertiary" /> } + dropdownMenuWidth={160} dropdownComponents={ - - - - - - + + + + } dropdownHotkeyScope={{ scope: dropdownId, diff --git a/packages/twenty-front/src/modules/settings/integrations/database-connection/components/SettingsIntegrationDatabaseConnectionSummaryCard.tsx b/packages/twenty-front/src/modules/settings/integrations/database-connection/components/SettingsIntegrationDatabaseConnectionSummaryCard.tsx index 8d0b1bbc1f..26a56c983a 100644 --- a/packages/twenty-front/src/modules/settings/integrations/database-connection/components/SettingsIntegrationDatabaseConnectionSummaryCard.tsx +++ b/packages/twenty-front/src/modules/settings/integrations/database-connection/components/SettingsIntegrationDatabaseConnectionSummaryCard.tsx @@ -1,7 +1,6 @@ import { SettingsSummaryCard } from '@/settings/components/SettingsSummaryCard'; import { SettingsIntegrationDatabaseConnectionSyncStatus } from '@/settings/integrations/database-connection/components/SettingsIntegrationDatabaseConnectionSyncStatus'; import { Dropdown } from '@/ui/layout/dropdown/components/Dropdown'; -import { DropdownMenu } from '@/ui/layout/dropdown/components/DropdownMenu'; import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer'; import styled from '@emotion/styled'; import { @@ -64,18 +63,16 @@ export const SettingsIntegrationDatabaseConnectionSummaryCard = ({ } dropdownComponents={ - - - - - - - - + + + + + + } /> diff --git a/packages/twenty-front/src/modules/ui/input/components/Select.tsx b/packages/twenty-front/src/modules/ui/input/components/Select.tsx index 9c60672106..929eed1c02 100644 --- a/packages/twenty-front/src/modules/ui/input/components/Select.tsx +++ b/packages/twenty-front/src/modules/ui/input/components/Select.tsx @@ -165,7 +165,7 @@ export const Select = ({ )} {!!callToActionButton && ( - + { const id = useId(); - return withoutScrollWrapper === true ? ( + return scrollable !== true ? ( - - handleSelect(CoreObjectNameSingular.Note)} - accent="default" - LeftIcon={IconNotes} - text="Note" - /> - handleSelect(CoreObjectNameSingular.Task)} - accent="default" - LeftIcon={IconCheckbox} - text="Task" - /> - - + + handleSelect(CoreObjectNameSingular.Note)} + accent="default" + LeftIcon={IconNotes} + text="Note" + /> + handleSelect(CoreObjectNameSingular.Task)} + accent="default" + LeftIcon={IconCheckbox} + text="Task" + /> + } dropdownHotkeyScope={{ scope: PageHotkeyScope.ShowPage, diff --git a/packages/twenty-front/src/modules/ui/layout/show-page/components/ShowPageSubContainer.tsx b/packages/twenty-front/src/modules/ui/layout/show-page/components/ShowPageSubContainer.tsx index 80c69b86a8..587ed2e861 100644 --- a/packages/twenty-front/src/modules/ui/layout/show-page/components/ShowPageSubContainer.tsx +++ b/packages/twenty-front/src/modules/ui/layout/show-page/components/ShowPageSubContainer.tsx @@ -7,13 +7,13 @@ import { SummaryCard } from '@/object-record/record-show/components/SummaryCard' import { RecordLayout } from '@/object-record/record-show/types/RecordLayout'; import { recordStoreFamilyState } from '@/object-record/record-store/states/recordStoreFamilyState'; import { ObjectRecord } from '@/object-record/types/ObjectRecord'; +import { RightDrawerFooter } from '@/ui/layout/right-drawer/components/RightDrawerFooter'; import { ShowPageLeftContainer } from '@/ui/layout/show-page/components/ShowPageLeftContainer'; import { SingleTabProps, TabList } from '@/ui/layout/tab/components/TabList'; import { useTabList } from '@/ui/layout/tab/hooks/useTabList'; import { useIsMobile } from '@/ui/utilities/responsive/hooks/useIsMobile'; import styled from '@emotion/styled'; import { useRecoilState, useRecoilValue } from 'recoil'; -import { RightDrawerFooter } from '@/ui/layout/right-drawer/components/RightDrawerFooter'; const StyledShowPageRightContainer = styled.div<{ isMobile: boolean }>` display: flex; diff --git a/packages/twenty-front/src/modules/views/components/UpdateViewButtonGroup.tsx b/packages/twenty-front/src/modules/views/components/UpdateViewButtonGroup.tsx index e9001faf75..4d0e22b108 100644 --- a/packages/twenty-front/src/modules/views/components/UpdateViewButtonGroup.tsx +++ b/packages/twenty-front/src/modules/views/components/UpdateViewButtonGroup.tsx @@ -112,15 +112,13 @@ export const UpdateViewButtonGroup = ({ /> } dropdownComponents={ - <> - - - - + + + } /> diff --git a/packages/twenty-front/src/modules/views/view-picker/components/ViewPickerContentCreateMode.tsx b/packages/twenty-front/src/modules/views/view-picker/components/ViewPickerContentCreateMode.tsx index 84f655a28b..27618cea7a 100644 --- a/packages/twenty-front/src/modules/views/view-picker/components/ViewPickerContentCreateMode.tsx +++ b/packages/twenty-front/src/modules/views/view-picker/components/ViewPickerContentCreateMode.tsx @@ -190,7 +190,7 @@ export const ViewPickerContentCreateMode = () => { )} - + diff --git a/packages/twenty-front/src/modules/views/view-picker/components/ViewPickerContentEditMode.tsx b/packages/twenty-front/src/modules/views/view-picker/components/ViewPickerContentEditMode.tsx index 1e3b588f96..41dc8b6da6 100644 --- a/packages/twenty-front/src/modules/views/view-picker/components/ViewPickerContentEditMode.tsx +++ b/packages/twenty-front/src/modules/views/view-picker/components/ViewPickerContentEditMode.tsx @@ -89,7 +89,7 @@ export const ViewPickerContentEditMode = () => { - + diff --git a/packages/twenty-front/src/modules/views/view-picker/components/ViewPickerListContent.tsx b/packages/twenty-front/src/modules/views/view-picker/components/ViewPickerListContent.tsx index 85e0619876..aae3dc2d71 100644 --- a/packages/twenty-front/src/modules/views/view-picker/components/ViewPickerListContent.tsx +++ b/packages/twenty-front/src/modules/views/view-picker/components/ViewPickerListContent.tsx @@ -97,7 +97,7 @@ export const ViewPickerListContent = () => { /> - + Date: Fri, 20 Dec 2024 10:45:22 +0100 Subject: [PATCH 10/11] Fix modals being unregistered inside command menu (#9155) Fix modals being unregistered inside command menu --- .../command-menu/components/CommandMenu.tsx | 35 +++++++++++++++++-- .../components/CommandMenuItem.tsx | 10 +++++- .../constants/CommandMenuNavigateCommands.ts | 5 +++ .../hooks/__tests__/useCommandMenu.test.tsx | 6 +++- .../command-menu/hooks/useCommandMenu.ts | 17 +++++++-- .../src/modules/command-menu/types/Command.ts | 1 + .../layout/page/components/DefaultLayout.tsx | 18 +++++++--- 7 files changed, 82 insertions(+), 10 deletions(-) diff --git a/packages/twenty-front/src/modules/command-menu/components/CommandMenu.tsx b/packages/twenty-front/src/modules/command-menu/components/CommandMenu.tsx index bb2c949ff2..2d06ba743f 100644 --- a/packages/twenty-front/src/modules/command-menu/components/CommandMenu.tsx +++ b/packages/twenty-front/src/modules/command-menu/components/CommandMenu.tsx @@ -70,6 +70,7 @@ type CommandGroupConfig = { key?: string; firstHotKey?: string; secondHotKey?: string; + shouldCloseCommandMenuOnClick?: boolean; }; }; @@ -253,6 +254,7 @@ export const CommandMenu = () => { id, label: `${firstName} ${lastName}`, to: `object/person/${id}`, + shouldCloseCommandMenuOnClick: true, })), [people], ); @@ -263,6 +265,7 @@ export const CommandMenu = () => { id, label: name ?? '', to: `object/company/${id}`, + shouldCloseCommandMenuOnClick: true, })), [companies], ); @@ -273,6 +276,7 @@ export const CommandMenu = () => { id, label: name ?? '', to: `object/opportunity/${id}`, + shouldCloseCommandMenuOnClick: true, })), [opportunities], ); @@ -284,6 +288,7 @@ export const CommandMenu = () => { label: note.title ?? '', to: '', onCommandClick: () => openActivityRightDrawer(note.id), + shouldCloseCommandMenuOnClick: true, })), [notes, openActivityRightDrawer], ); @@ -295,6 +300,7 @@ export const CommandMenu = () => { label: task.title ?? '', to: '', onCommandClick: () => openActivityRightDrawer(task.id), + shouldCloseCommandMenuOnClick: true, })), [tasks, openActivityRightDrawer], ); @@ -307,6 +313,7 @@ export const CommandMenu = () => { id: objectRecord.record.id, label: objectRecord.recordIdentifier.name, to: `object/${objectRecord.objectMetadataItem.nameSingular}/${objectRecord.record.id}`, + shouldCloseCommandMenuOnClick: true, })), ); }); @@ -488,6 +495,7 @@ export const CommandMenu = () => { onClick: command.onCommandClick, firstHotKey: command.firstHotKey, secondHotKey: command.secondHotKey, + shouldCloseCommandMenuOnClick: command.shouldCloseCommandMenuOnClick, }), }, { @@ -501,6 +509,7 @@ export const CommandMenu = () => { onClick: command.onCommandClick, firstHotKey: command.firstHotKey, secondHotKey: command.secondHotKey, + shouldCloseCommandMenuOnClick: command.shouldCloseCommandMenuOnClick, }), }, { @@ -520,6 +529,7 @@ export const CommandMenu = () => { ), firstHotKey: person.firstHotKey, secondHotKey: person.secondHotKey, + shouldCloseCommandMenuOnClick: true, }), }, { @@ -540,6 +550,7 @@ export const CommandMenu = () => { ), firstHotKey: company.firstHotKey, secondHotKey: company.secondHotKey, + shouldCloseCommandMenuOnClick: true, }), }, { @@ -557,6 +568,7 @@ export const CommandMenu = () => { placeholder={opportunity.name ?? ''} /> ), + shouldCloseCommandMenuOnClick: true, }), }, { @@ -567,6 +579,7 @@ export const CommandMenu = () => { Icon: IconNotes, label: note.title ?? '', onClick: () => openActivityRightDrawer(note.id), + shouldCloseCommandMenuOnClick: true, }), }, { @@ -577,6 +590,7 @@ export const CommandMenu = () => { Icon: IconCheckbox, label: task.title ?? '', onClick: () => openActivityRightDrawer(task.id), + shouldCloseCommandMenuOnClick: true, }), }, ...Object.entries(customObjectRecordsMap).map( @@ -596,6 +610,7 @@ export const CommandMenu = () => { placeholder={objectRecord.recordIdentifier.name ?? ''} /> ), + shouldCloseCommandMenuOnClick: true, }), }), ), @@ -627,8 +642,17 @@ export const CommandMenu = () => { ].find((cmd) => cmd.id === itemId); if (isDefined(command)) { - const { to, onCommandClick } = command; - onItemClick(onCommandClick, to); + const { + to, + onCommandClick, + shouldCloseCommandMenuOnClick, + } = command; + + onItemClick({ + shouldCloseCommandMenuOnClick, + onClick: onCommandClick, + to, + }); } }} > @@ -745,6 +769,9 @@ export const CommandMenu = () => { secondHotKey={ workflowRunGlobalCommand.secondHotKey } + shouldCloseCommandMenuOnClick={ + workflowRunGlobalCommand.shouldCloseCommandMenuOnClick + } /> ), @@ -765,6 +792,7 @@ export const CommandMenu = () => { key, firstHotKey, secondHotKey, + shouldCloseCommandMenuOnClick, } = renderItem(item); return ( @@ -777,6 +805,9 @@ export const CommandMenu = () => { onClick={onClick} firstHotKey={firstHotKey} secondHotKey={secondHotKey} + shouldCloseCommandMenuOnClick={ + shouldCloseCommandMenuOnClick + } /> ); diff --git a/packages/twenty-front/src/modules/command-menu/components/CommandMenuItem.tsx b/packages/twenty-front/src/modules/command-menu/components/CommandMenuItem.tsx index 0895015e22..c772da2800 100644 --- a/packages/twenty-front/src/modules/command-menu/components/CommandMenuItem.tsx +++ b/packages/twenty-front/src/modules/command-menu/components/CommandMenuItem.tsx @@ -14,6 +14,7 @@ export type CommandMenuItemProps = { Icon?: IconComponent; firstHotKey?: string; secondHotKey?: string; + shouldCloseCommandMenuOnClick?: boolean; }; export const CommandMenuItem = ({ @@ -24,6 +25,7 @@ export const CommandMenuItem = ({ Icon, firstHotKey, secondHotKey, + shouldCloseCommandMenuOnClick, }: CommandMenuItemProps) => { const { onItemClick } = useCommandMenu(); @@ -40,7 +42,13 @@ export const CommandMenuItem = ({ text={label} firstHotKey={firstHotKey} secondHotKey={secondHotKey} - onClick={() => onItemClick(onClick, to)} + onClick={() => + onItemClick({ + shouldCloseCommandMenuOnClick, + onClick, + to, + }) + } isSelected={isSelectedItemId} /> ); diff --git a/packages/twenty-front/src/modules/command-menu/constants/CommandMenuNavigateCommands.ts b/packages/twenty-front/src/modules/command-menu/constants/CommandMenuNavigateCommands.ts index c997ff3047..6089cdcb75 100644 --- a/packages/twenty-front/src/modules/command-menu/constants/CommandMenuNavigateCommands.ts +++ b/packages/twenty-front/src/modules/command-menu/constants/CommandMenuNavigateCommands.ts @@ -17,6 +17,7 @@ export const COMMAND_MENU_NAVIGATE_COMMANDS: { [key: string]: Command } = { firstHotKey: 'G', secondHotKey: 'P', Icon: IconUser, + shouldCloseCommandMenuOnClick: true, }, companies: { id: 'go-to-companies', @@ -26,6 +27,7 @@ export const COMMAND_MENU_NAVIGATE_COMMANDS: { [key: string]: Command } = { firstHotKey: 'G', secondHotKey: 'C', Icon: IconBuildingSkyscraper, + shouldCloseCommandMenuOnClick: true, }, opportunities: { id: 'go-to-activities', @@ -35,6 +37,7 @@ export const COMMAND_MENU_NAVIGATE_COMMANDS: { [key: string]: Command } = { firstHotKey: 'G', secondHotKey: 'O', Icon: IconTargetArrow, + shouldCloseCommandMenuOnClick: true, }, settings: { id: 'go-to-settings', @@ -44,6 +47,7 @@ export const COMMAND_MENU_NAVIGATE_COMMANDS: { [key: string]: Command } = { firstHotKey: 'G', secondHotKey: 'S', Icon: IconSettings, + shouldCloseCommandMenuOnClick: true, }, tasks: { id: 'go-to-tasks', @@ -53,5 +57,6 @@ export const COMMAND_MENU_NAVIGATE_COMMANDS: { [key: string]: Command } = { firstHotKey: 'G', secondHotKey: 'T', Icon: IconCheckbox, + shouldCloseCommandMenuOnClick: true, }, }; diff --git a/packages/twenty-front/src/modules/command-menu/hooks/__tests__/useCommandMenu.test.tsx b/packages/twenty-front/src/modules/command-menu/hooks/__tests__/useCommandMenu.test.tsx index aac286a6e6..f9549c7372 100644 --- a/packages/twenty-front/src/modules/command-menu/hooks/__tests__/useCommandMenu.test.tsx +++ b/packages/twenty-front/src/modules/command-menu/hooks/__tests__/useCommandMenu.test.tsx @@ -82,7 +82,11 @@ describe('useCommandMenu', () => { const onClickMock = jest.fn(); act(() => { - result.current.commandMenu.onItemClick(onClickMock, '/test'); + result.current.commandMenu.onItemClick({ + shouldCloseCommandMenuOnClick: true, + onClick: onClickMock, + to: '/test', + }); }); expect(result.current.isCommandMenuOpened).toBe(true); diff --git a/packages/twenty-front/src/modules/command-menu/hooks/useCommandMenu.ts b/packages/twenty-front/src/modules/command-menu/hooks/useCommandMenu.ts index 46eea6f6cb..f8d178cd39 100644 --- a/packages/twenty-front/src/modules/command-menu/hooks/useCommandMenu.ts +++ b/packages/twenty-front/src/modules/command-menu/hooks/useCommandMenu.ts @@ -216,8 +216,21 @@ export const useCommandMenu = () => { ); const onItemClick = useCallback( - (onClick?: () => void, to?: string) => { - toggleCommandMenu(); + ({ + shouldCloseCommandMenuOnClick, + onClick, + to, + }: { + shouldCloseCommandMenuOnClick?: boolean; + onClick?: () => void; + to?: string; + }) => { + if ( + isDefined(shouldCloseCommandMenuOnClick) && + shouldCloseCommandMenuOnClick + ) { + toggleCommandMenu(); + } if (isDefined(onClick)) { onClick(); diff --git a/packages/twenty-front/src/modules/command-menu/types/Command.ts b/packages/twenty-front/src/modules/command-menu/types/Command.ts index 2f14cc6a10..1c23c100c1 100644 --- a/packages/twenty-front/src/modules/command-menu/types/Command.ts +++ b/packages/twenty-front/src/modules/command-menu/types/Command.ts @@ -21,4 +21,5 @@ export type Command = { firstHotKey?: string; secondHotKey?: string; onCommandClick?: () => void; + shouldCloseCommandMenuOnClick?: boolean; }; diff --git a/packages/twenty-front/src/modules/ui/layout/page/components/DefaultLayout.tsx b/packages/twenty-front/src/modules/ui/layout/page/components/DefaultLayout.tsx index 18bd1928cb..dc5b37ce1c 100644 --- a/packages/twenty-front/src/modules/ui/layout/page/components/DefaultLayout.tsx +++ b/packages/twenty-front/src/modules/ui/layout/page/components/DefaultLayout.tsx @@ -1,9 +1,11 @@ import { RecordActionMenuEntriesSetter } from '@/action-menu/actions/record-actions/components/RecordActionMenuEntriesSetter'; import { RecordAgnosticActionsSetterEffect } from '@/action-menu/actions/record-agnostic-actions/components/RecordAgnosticActionsSetterEffect'; import { ActionMenuConfirmationModals } from '@/action-menu/components/ActionMenuConfirmationModals'; +import { ActionMenuContext } from '@/action-menu/contexts/ActionMenuContext'; import { ActionMenuComponentInstanceContext } from '@/action-menu/states/contexts/ActionMenuComponentInstanceContext'; import { AuthModal } from '@/auth/components/AuthModal'; import { CommandMenu } from '@/command-menu/components/CommandMenu'; +import { useCommandMenu } from '@/command-menu/hooks/useCommandMenu'; import { ContextStoreComponentInstanceContext } from '@/context-store/states/contexts/ContextStoreComponentInstanceContext'; import { AppErrorBoundary } from '@/error-handler/components/AppErrorBoundary'; import { KeyboardShortcutMenu } from '@/keyboard-shortcut-menu/components/KeyboardShortcutMenu'; @@ -75,6 +77,7 @@ export const DefaultLayout = () => { const theme = useTheme(); const windowsWidth = useScreenSize().width; const showAuthModal = useShowAuthModal(); + const { toggleCommandMenu } = useCommandMenu(); const isWorkflowEnabled = useIsFeatureEnabled('IS_WORKFLOW_ENABLED'); @@ -96,10 +99,17 @@ export const DefaultLayout = () => { - - {isWorkflowEnabled && } - - + + + {isWorkflowEnabled && } + + + From 925294675caca902b752267c075240dc219bbcff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rapha=C3=ABl=20Bosi?= <71827178+bosiraphael@users.noreply.github.com> Date: Fri, 20 Dec 2024 10:46:24 +0100 Subject: [PATCH 11/11] 9018 fix batch delete (#9149) Closes #9018 --- .../hooks/useDeleteMultipleRecordsAction.tsx | 40 ++---- .../hooks/useDeleteSingleRecordAction.tsx | 4 +- .../hooks/useDestroySingleRecordAction.tsx | 2 +- .../components/RecordIndexActionMenu.tsx | 20 ++- .../RecordIndexActionMenuButtons.tsx | 2 +- .../action-menu/contexts/ActionMenuContext.ts | 4 +- ...ggerUpdateRecordOptimisticEffectByBatch.ts | 132 ++++++++++++++++++ .../hooks/useDeleteManyRecords.ts | 22 ++- .../RecordBoardColumnFetchMoreLoader.tsx | 16 ++- .../components/RecordIndexContainer.tsx | 5 +- .../components/RecordIndexPageHeader.tsx | 4 +- ...RecordIndexLoadMoreLockedComponentState.ts | 9 ++ .../RecordTableBodyFetchMoreLoader.tsx | 14 +- .../RecordTableRecordGroupSectionLoadMore.tsx | 7 +- .../core-modules/message-queue/jobs.module.ts | 2 + .../message-queue/message-queue.constants.ts | 1 + .../constants/favorite-deletion-batch-size.ts | 1 + .../src/modules/favorite/favorite.module.ts | 24 ++++ .../favorite/jobs/favorite-deletion.job.ts | 29 ++++ .../listeners/favorite-deletion.listener.ts | 36 +++++ .../services/favorite-deletion.service.ts | 87 ++++++++++++ .../src/modules/modules.module.ts | 2 + 22 files changed, 413 insertions(+), 50 deletions(-) create mode 100644 packages/twenty-front/src/modules/apollo/optimistic-effect/utils/triggerUpdateRecordOptimisticEffectByBatch.ts create mode 100644 packages/twenty-front/src/modules/object-record/record-index/states/isRecordIndexLoadMoreLockedComponentState.ts create mode 100644 packages/twenty-server/src/modules/favorite/constants/favorite-deletion-batch-size.ts create mode 100644 packages/twenty-server/src/modules/favorite/favorite.module.ts create mode 100644 packages/twenty-server/src/modules/favorite/jobs/favorite-deletion.job.ts create mode 100644 packages/twenty-server/src/modules/favorite/listeners/favorite-deletion.listener.ts create mode 100644 packages/twenty-server/src/modules/favorite/services/favorite-deletion.service.ts diff --git a/packages/twenty-front/src/modules/action-menu/actions/record-actions/multiple-records/hooks/useDeleteMultipleRecordsAction.tsx b/packages/twenty-front/src/modules/action-menu/actions/record-actions/multiple-records/hooks/useDeleteMultipleRecordsAction.tsx index 19fa6d75e3..d240e761cd 100644 --- a/packages/twenty-front/src/modules/action-menu/actions/record-actions/multiple-records/hooks/useDeleteMultipleRecordsAction.tsx +++ b/packages/twenty-front/src/modules/action-menu/actions/record-actions/multiple-records/hooks/useDeleteMultipleRecordsAction.tsx @@ -8,8 +8,6 @@ import { contextStoreFiltersComponentState } from '@/context-store/states/contex import { contextStoreNumberOfSelectedRecordsComponentState } from '@/context-store/states/contextStoreNumberOfSelectedRecordsComponentState'; import { contextStoreTargetedRecordsRuleComponentState } from '@/context-store/states/contextStoreTargetedRecordsRuleComponentState'; import { computeContextStoreFilters } from '@/context-store/utils/computeContextStoreFilters'; -import { useDeleteFavorite } from '@/favorites/hooks/useDeleteFavorite'; -import { useFavorites } from '@/favorites/hooks/useFavorites'; import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; import { DEFAULT_QUERY_PAGE_SIZE } from '@/object-record/constants/DefaultQueryPageSize'; import { DELETE_MAX_COUNT } from '@/object-record/constants/DeleteMaxCount'; @@ -40,9 +38,6 @@ export const useDeleteMultipleRecordsAction = ({ objectNameSingular: objectMetadataItem.nameSingular, }); - const { sortedFavorites: favorites } = useFavorites(); - const { deleteFavorite } = useDeleteFavorite(); - const contextStoreNumberOfSelectedRecords = useRecoilComponentValueV2( contextStoreNumberOfSelectedRecordsComponentState, ); @@ -76,26 +71,8 @@ export const useDeleteMultipleRecordsAction = ({ resetTableRowSelection(); - for (const recordIdToDelete of recordIdsToDelete) { - const foundFavorite = favorites?.find( - (favorite) => favorite.recordId === recordIdToDelete, - ); - - if (foundFavorite !== undefined) { - deleteFavorite(foundFavorite.id); - } - } - - await deleteManyRecords(recordIdsToDelete, { - delayInMsBetweenRequests: 50, - }); - }, [ - deleteFavorite, - deleteManyRecords, - favorites, - fetchAllRecordIds, - resetTableRowSelection, - ]); + await deleteManyRecords(recordIdsToDelete); + }, [deleteManyRecords, fetchAllRecordIds, resetTableRowSelection]); const isRemoteObject = objectMetadataItem.isRemote; @@ -105,7 +82,7 @@ export const useDeleteMultipleRecordsAction = ({ contextStoreNumberOfSelectedRecords < DELETE_MAX_COUNT && contextStoreNumberOfSelectedRecords > 0; - const { isInRightDrawer, onActionExecutedCallback } = + const { isInRightDrawer, onActionStartedCallback, onActionExecutedCallback } = useContext(ActionMenuContext); const registerDeleteMultipleRecordsAction = ({ @@ -133,9 +110,14 @@ export const useDeleteMultipleRecordsAction = ({ setIsOpen={setIsDeleteRecordsModalOpen} title={'Delete Records'} subtitle={`Are you sure you want to delete these records? They can be recovered from the Options menu.`} - onConfirmClick={() => { - handleDeleteClick(); - onActionExecutedCallback?.(); + onConfirmClick={async () => { + onActionStartedCallback?.({ + key: 'delete-multiple-records', + }); + await handleDeleteClick(); + onActionExecutedCallback?.({ + key: 'delete-multiple-records', + }); if (isInRightDrawer) { closeRightDrawer(); } diff --git a/packages/twenty-front/src/modules/action-menu/actions/record-actions/single-record/hooks/useDeleteSingleRecordAction.tsx b/packages/twenty-front/src/modules/action-menu/actions/record-actions/single-record/hooks/useDeleteSingleRecordAction.tsx index cb58be678a..6adbbab02d 100644 --- a/packages/twenty-front/src/modules/action-menu/actions/record-actions/single-record/hooks/useDeleteSingleRecordAction.tsx +++ b/packages/twenty-front/src/modules/action-menu/actions/record-actions/single-record/hooks/useDeleteSingleRecordAction.tsx @@ -54,8 +54,7 @@ export const useDeleteSingleRecordAction: SingleRecordActionHookWithObjectMetada const isRemoteObject = objectMetadataItem.isRemote; - const { isInRightDrawer, onActionExecutedCallback } = - useContext(ActionMenuContext); + const { isInRightDrawer } = useContext(ActionMenuContext); const shouldBeRegistered = !isRemoteObject && isNull(selectedRecord?.deletedAt); @@ -81,7 +80,6 @@ export const useDeleteSingleRecordAction: SingleRecordActionHookWithObjectMetada } onConfirmClick={() => { handleDeleteClick(); - onActionExecutedCallback?.(); if (isInRightDrawer) { closeRightDrawer(); } diff --git a/packages/twenty-front/src/modules/action-menu/actions/record-actions/single-record/hooks/useDestroySingleRecordAction.tsx b/packages/twenty-front/src/modules/action-menu/actions/record-actions/single-record/hooks/useDestroySingleRecordAction.tsx index c024df812f..e9e37d822e 100644 --- a/packages/twenty-front/src/modules/action-menu/actions/record-actions/single-record/hooks/useDestroySingleRecordAction.tsx +++ b/packages/twenty-front/src/modules/action-menu/actions/record-actions/single-record/hooks/useDestroySingleRecordAction.tsx @@ -61,7 +61,7 @@ export const useDestroySingleRecordAction: SingleRecordActionHookWithObjectMetad } onConfirmClick={async () => { await handleDeleteClick(); - onActionExecutedCallback?.(); + onActionExecutedCallback?.({ key: 'destroy-single-record' }); if (isInRightDrawer) { closeRightDrawer(); } diff --git a/packages/twenty-front/src/modules/action-menu/components/RecordIndexActionMenu.tsx b/packages/twenty-front/src/modules/action-menu/components/RecordIndexActionMenu.tsx index 8eafd9040d..1e3789a9e8 100644 --- a/packages/twenty-front/src/modules/action-menu/components/RecordIndexActionMenu.tsx +++ b/packages/twenty-front/src/modules/action-menu/components/RecordIndexActionMenu.tsx @@ -8,11 +8,13 @@ import { RecordIndexActionMenuEffect } from '@/action-menu/components/RecordInde import { ActionMenuContext } from '@/action-menu/contexts/ActionMenuContext'; import { contextStoreCurrentObjectMetadataIdComponentState } from '@/context-store/states/contextStoreCurrentObjectMetadataIdComponentState'; +import { isRecordIndexLoadMoreLockedComponentState } from '@/object-record/record-index/states/isRecordIndexLoadMoreLockedComponentState'; import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2'; +import { useSetRecoilComponentStateV2 } from '@/ui/utilities/state/component-state/hooks/useSetRecoilComponentStateV2'; import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled'; import { useIsMobile } from 'twenty-ui'; -export const RecordIndexActionMenu = () => { +export const RecordIndexActionMenu = ({ indexId }: { indexId: string }) => { const contextStoreCurrentObjectMetadataId = useRecoilComponentValueV2( contextStoreCurrentObjectMetadataIdComponentState, ); @@ -25,13 +27,27 @@ export const RecordIndexActionMenu = () => { const isMobile = useIsMobile(); + const setIsLoadMoreLocked = useSetRecoilComponentStateV2( + isRecordIndexLoadMoreLockedComponentState, + indexId, + ); + return ( <> {contextStoreCurrentObjectMetadataId && ( {}, + onActionStartedCallback: (action) => { + if (action.key === 'delete-multiple-records') { + setIsLoadMoreLocked(true); + } + }, + onActionExecutedCallback: (action) => { + if (action.key === 'delete-multiple-records') { + setIsLoadMoreLocked(false); + } + }, }} > {isPageHeaderV2Enabled ? ( diff --git a/packages/twenty-front/src/modules/action-menu/components/RecordIndexActionMenuButtons.tsx b/packages/twenty-front/src/modules/action-menu/components/RecordIndexActionMenuButtons.tsx index fdafd08d2c..80c1080e0d 100644 --- a/packages/twenty-front/src/modules/action-menu/components/RecordIndexActionMenuButtons.tsx +++ b/packages/twenty-front/src/modules/action-menu/components/RecordIndexActionMenuButtons.tsx @@ -28,7 +28,7 @@ export const RecordIndexActionMenuButtons = () => { variant="secondary" accent="default" title={entry.shortLabel} - onClick={() => entry.onClick?.()} + onClick={entry.onClick} ariaLabel={entry.label} /> ))} diff --git a/packages/twenty-front/src/modules/action-menu/contexts/ActionMenuContext.ts b/packages/twenty-front/src/modules/action-menu/contexts/ActionMenuContext.ts index 0c1482f40b..65355f11f2 100644 --- a/packages/twenty-front/src/modules/action-menu/contexts/ActionMenuContext.ts +++ b/packages/twenty-front/src/modules/action-menu/contexts/ActionMenuContext.ts @@ -2,10 +2,12 @@ import { createContext } from 'react'; type ActionMenuContextType = { isInRightDrawer: boolean; - onActionExecutedCallback: () => void; + onActionStartedCallback?: (action: { key: string }) => void; + onActionExecutedCallback?: (action: { key: string }) => void; }; export const ActionMenuContext = createContext({ isInRightDrawer: false, + onActionStartedCallback: () => {}, onActionExecutedCallback: () => {}, }); diff --git a/packages/twenty-front/src/modules/apollo/optimistic-effect/utils/triggerUpdateRecordOptimisticEffectByBatch.ts b/packages/twenty-front/src/modules/apollo/optimistic-effect/utils/triggerUpdateRecordOptimisticEffectByBatch.ts new file mode 100644 index 0000000000..0b115f958e --- /dev/null +++ b/packages/twenty-front/src/modules/apollo/optimistic-effect/utils/triggerUpdateRecordOptimisticEffectByBatch.ts @@ -0,0 +1,132 @@ +import { ApolloCache, StoreObject } from '@apollo/client'; + +import { sortCachedObjectEdges } from '@/apollo/optimistic-effect/utils/sortCachedObjectEdges'; +import { triggerUpdateRelationsOptimisticEffect } from '@/apollo/optimistic-effect/utils/triggerUpdateRelationsOptimisticEffect'; +import { CachedObjectRecordQueryVariables } from '@/apollo/types/CachedObjectRecordQueryVariables'; +import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; +import { RecordGqlRefEdge } from '@/object-record/cache/types/RecordGqlRefEdge'; +import { getEdgeTypename } from '@/object-record/cache/utils/getEdgeTypename'; +import { isObjectRecordConnectionWithRefs } from '@/object-record/cache/utils/isObjectRecordConnectionWithRefs'; +import { RecordGqlNode } from '@/object-record/graphql/types/RecordGqlNode'; +import { isRecordMatchingFilter } from '@/object-record/record-filter/utils/isRecordMatchingFilter'; +import { isDefined } from '~/utils/isDefined'; +import { parseApolloStoreFieldName } from '~/utils/parseApolloStoreFieldName'; + +// TODO: add extensive unit tests for this function +// That will also serve as documentation +export const triggerUpdateRecordOptimisticEffectByBatch = ({ + cache, + objectMetadataItem, + currentRecords, + updatedRecords, + objectMetadataItems, +}: { + cache: ApolloCache; + objectMetadataItem: ObjectMetadataItem; + currentRecords: RecordGqlNode[]; + updatedRecords: RecordGqlNode[]; + objectMetadataItems: ObjectMetadataItem[]; +}) => { + for (const [index, currentRecord] of currentRecords.entries()) { + triggerUpdateRelationsOptimisticEffect({ + cache, + sourceObjectMetadataItem: objectMetadataItem, + currentSourceRecord: currentRecord, + updatedSourceRecord: updatedRecords[index], + objectMetadataItems, + }); + } + + cache.modify({ + fields: { + [objectMetadataItem.namePlural]: ( + rootQueryCachedResponse, + { readField, storeFieldName, toReference }, + ) => { + const shouldSkip = !isObjectRecordConnectionWithRefs( + objectMetadataItem.nameSingular, + rootQueryCachedResponse, + ); + + if (shouldSkip) { + return rootQueryCachedResponse; + } + + const rootQueryConnection = rootQueryCachedResponse; + + const { fieldVariables: rootQueryVariables } = + parseApolloStoreFieldName( + storeFieldName, + ); + + const rootQueryCurrentEdges = + readField('edges', rootQueryConnection) ?? []; + + let rootQueryNextEdges = [...rootQueryCurrentEdges]; + + const rootQueryFilter = rootQueryVariables?.filter; + const rootQueryOrderBy = rootQueryVariables?.orderBy; + + for (const updatedRecord of updatedRecords) { + const updatedRecordMatchesThisRootQueryFilter = + isRecordMatchingFilter({ + record: updatedRecord, + filter: rootQueryFilter ?? {}, + objectMetadataItem, + }); + + const updatedRecordIndexInRootQueryEdges = + rootQueryCurrentEdges.findIndex( + (cachedEdge) => + readField('id', cachedEdge.node) === updatedRecord.id, + ); + + const updatedRecordFoundInRootQueryEdges = + updatedRecordIndexInRootQueryEdges > -1; + + const updatedRecordShouldBeAddedToRootQueryEdges = + updatedRecordMatchesThisRootQueryFilter && + !updatedRecordFoundInRootQueryEdges; + + const updatedRecordShouldBeRemovedFromRootQueryEdges = + !updatedRecordMatchesThisRootQueryFilter && + updatedRecordFoundInRootQueryEdges; + + if (updatedRecordShouldBeAddedToRootQueryEdges) { + const updatedRecordNodeReference = toReference(updatedRecord); + + if (isDefined(updatedRecordNodeReference)) { + rootQueryNextEdges.push({ + __typename: getEdgeTypename(objectMetadataItem.nameSingular), + node: updatedRecordNodeReference, + cursor: '', + }); + } + } + + if (updatedRecordShouldBeRemovedFromRootQueryEdges) { + rootQueryNextEdges.splice(updatedRecordIndexInRootQueryEdges, 1); + } + } + + const rootQueryNextEdgesShouldBeSorted = isDefined(rootQueryOrderBy); + + if ( + rootQueryNextEdgesShouldBeSorted && + Object.getOwnPropertyNames(rootQueryOrderBy).length > 0 + ) { + rootQueryNextEdges = sortCachedObjectEdges({ + edges: rootQueryNextEdges, + orderBy: rootQueryOrderBy, + readCacheField: readField, + }); + } + + return { + ...rootQueryConnection, + edges: rootQueryNextEdges, + }; + }, + }, + }); +}; diff --git a/packages/twenty-front/src/modules/object-record/hooks/useDeleteManyRecords.ts b/packages/twenty-front/src/modules/object-record/hooks/useDeleteManyRecords.ts index 404320943e..4de099eb27 100644 --- a/packages/twenty-front/src/modules/object-record/hooks/useDeleteManyRecords.ts +++ b/packages/twenty-front/src/modules/object-record/hooks/useDeleteManyRecords.ts @@ -1,6 +1,7 @@ import { useApolloClient } from '@apollo/client'; import { triggerUpdateRecordOptimisticEffect } from '@/apollo/optimistic-effect/utils/triggerUpdateRecordOptimisticEffect'; +import { triggerUpdateRecordOptimisticEffectByBatch } from '@/apollo/optimistic-effect/utils/triggerUpdateRecordOptimisticEffectByBatch'; import { apiConfigState } from '@/client-config/states/apiConfigState'; import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem'; import { useObjectMetadataItems } from '@/object-metadata/hooks/useObjectMetadataItems'; @@ -8,6 +9,7 @@ import { useGetRecordFromCache } from '@/object-record/cache/hooks/useGetRecordF import { getRecordNodeFromRecord } from '@/object-record/cache/utils/getRecordNodeFromRecord'; import { updateRecordFromCache } from '@/object-record/cache/utils/updateRecordFromCache'; import { DEFAULT_MUTATION_BATCH_SIZE } from '@/object-record/constants/DefaultMutationBatchSize'; +import { RecordGqlNode } from '@/object-record/graphql/types/RecordGqlNode'; import { useDeleteManyRecordsMutation } from '@/object-record/hooks/useDeleteManyRecordsMutation'; import { useRefetchAggregateQueries } from '@/object-record/hooks/useRefetchAggregateQueries'; import { ObjectRecord } from '@/object-record/types/ObjectRecord'; @@ -80,6 +82,9 @@ export const useDeleteManyRecords = ({ .map((idToDelete) => getRecordFromCache(idToDelete, apolloClient.cache)) .filter(isDefined); + const cachedRecordsWithConnection: RecordGqlNode[] = []; + const optimisticRecordsWithConnection: RecordGqlNode[] = []; + if (!options?.skipOptimisticEffect) { cachedRecords.forEach((cachedRecord) => { if (!cachedRecord || !cachedRecord.id) { @@ -112,20 +117,23 @@ export const useDeleteManyRecords = ({ return null; } + cachedRecordsWithConnection.push(cachedRecordWithConnection); + optimisticRecordsWithConnection.push(optimisticRecordWithConnection); + updateRecordFromCache({ objectMetadataItems, objectMetadataItem, cache: apolloClient.cache, record: computedOptimisticRecord, }); + }); - triggerUpdateRecordOptimisticEffect({ - cache: apolloClient.cache, - objectMetadataItem, - currentRecord: cachedRecordWithConnection, - updatedRecord: optimisticRecordWithConnection, - objectMetadataItems, - }); + triggerUpdateRecordOptimisticEffectByBatch({ + cache: apolloClient.cache, + objectMetadataItem, + currentRecords: cachedRecordsWithConnection, + updatedRecords: optimisticRecordsWithConnection, + objectMetadataItems, }); } diff --git a/packages/twenty-front/src/modules/object-record/record-board/record-board-column/components/RecordBoardColumnFetchMoreLoader.tsx b/packages/twenty-front/src/modules/object-record/record-board/record-board-column/components/RecordBoardColumnFetchMoreLoader.tsx index 1143aaf5e9..809237272e 100644 --- a/packages/twenty-front/src/modules/object-record/record-board/record-board-column/components/RecordBoardColumnFetchMoreLoader.tsx +++ b/packages/twenty-front/src/modules/object-record/record-board/record-board-column/components/RecordBoardColumnFetchMoreLoader.tsx @@ -7,6 +7,8 @@ import { GRAY_SCALE } from 'twenty-ui'; import { RecordBoardColumnContext } from '@/object-record/record-board/record-board-column/contexts/RecordBoardColumnContext'; import { isRecordBoardFetchingRecordsByColumnFamilyState } from '@/object-record/record-board/states/isRecordBoardFetchingRecordsByColumnFamilyState'; import { recordBoardShouldFetchMoreInColumnComponentFamilyState } from '@/object-record/record-board/states/recordBoardShouldFetchMoreInColumnComponentFamilyState'; +import { isRecordIndexLoadMoreLockedComponentState } from '@/object-record/record-index/states/isRecordIndexLoadMoreLockedComponentState'; +import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2'; import { useSetRecoilComponentFamilyStateV2 } from '@/ui/utilities/state/component-state/hooks/useSetRecoilComponentFamilyStateV2'; const StyledText = styled.div` @@ -31,11 +33,23 @@ export const RecordBoardColumnFetchMoreLoader = () => { columnDefinition.id, ); + const isLoadMoreLocked = useRecoilComponentValueV2( + isRecordIndexLoadMoreLockedComponentState, + ); + const { ref, inView } = useInView(); useEffect(() => { + if (isLoadMoreLocked) { + return; + } + setShouldFetchMore(inView); - }, [setShouldFetchMore, inView]); + }, [setShouldFetchMore, inView, isLoadMoreLocked]); + + if (isLoadMoreLocked) { + return null; + } return (
diff --git a/packages/twenty-front/src/modules/object-record/record-index/components/RecordIndexContainer.tsx b/packages/twenty-front/src/modules/object-record/record-index/components/RecordIndexContainer.tsx index 8b239017f8..6a9ad6eb71 100644 --- a/packages/twenty-front/src/modules/object-record/record-index/components/RecordIndexContainer.tsx +++ b/packages/twenty-front/src/modules/object-record/record-index/components/RecordIndexContainer.tsx @@ -230,6 +230,7 @@ export const RecordIndexContainer = () => { objectNamePlural={objectNamePlural} viewBarId={recordIndexId} /> + {recordIndexViewType === ViewType.Table && ( @@ -255,7 +256,9 @@ export const RecordIndexContainer = () => { )} - {!isPageHeaderV2Enabled && } + {!isPageHeaderV2Enabled && ( + + )} diff --git a/packages/twenty-front/src/modules/object-record/record-index/components/RecordIndexPageHeader.tsx b/packages/twenty-front/src/modules/object-record/record-index/components/RecordIndexPageHeader.tsx index c2d6584ff3..757eb4715c 100644 --- a/packages/twenty-front/src/modules/object-record/record-index/components/RecordIndexPageHeader.tsx +++ b/packages/twenty-front/src/modules/object-record/record-index/components/RecordIndexPageHeader.tsx @@ -29,6 +29,8 @@ export const RecordIndexPageHeader = () => { const recordIndexViewType = useRecoilValue(recordIndexViewTypeState); + const { recordIndexId } = useRecordIndexContextOrThrow(); + const numberOfSelectedRecords = useRecoilComponentValueV2( contextStoreNumberOfSelectedRecordsComponentState, ); @@ -64,7 +66,7 @@ export const RecordIndexPageHeader = () => { {isPageHeaderV2Enabled && ( <> - + )} diff --git a/packages/twenty-front/src/modules/object-record/record-index/states/isRecordIndexLoadMoreLockedComponentState.ts b/packages/twenty-front/src/modules/object-record/record-index/states/isRecordIndexLoadMoreLockedComponentState.ts new file mode 100644 index 0000000000..dbdb3a0f3b --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-index/states/isRecordIndexLoadMoreLockedComponentState.ts @@ -0,0 +1,9 @@ +import { createComponentStateV2 } from '@/ui/utilities/state/component-state/utils/createComponentStateV2'; +import { ViewComponentInstanceContext } from '@/views/states/contexts/ViewComponentInstanceContext'; + +export const isRecordIndexLoadMoreLockedComponentState = + createComponentStateV2({ + key: 'isRecordIndexLoadMoreLockedComponentState', + componentInstanceContext: ViewComponentInstanceContext, + defaultValue: false, + }); diff --git a/packages/twenty-front/src/modules/object-record/record-table/record-table-body/components/RecordTableBodyFetchMoreLoader.tsx b/packages/twenty-front/src/modules/object-record/record-table/record-table-body/components/RecordTableBodyFetchMoreLoader.tsx index 3ef7983c0f..3a73c095c9 100644 --- a/packages/twenty-front/src/modules/object-record/record-table/record-table-body/components/RecordTableBodyFetchMoreLoader.tsx +++ b/packages/twenty-front/src/modules/object-record/record-table/record-table-body/components/RecordTableBodyFetchMoreLoader.tsx @@ -4,6 +4,7 @@ import { useInView } from 'react-intersection-observer'; import { useRecoilCallback } from 'recoil'; import { GRAY_SCALE } from 'twenty-ui'; +import { isRecordIndexLoadMoreLockedComponentState } from '@/object-record/record-index/states/isRecordIndexLoadMoreLockedComponentState'; import { useRecordTable } from '@/object-record/record-table/hooks/useRecordTable'; import { hasRecordTableFetchedAllRecordsComponentStateV2 } from '@/object-record/record-table/states/hasRecordTableFetchedAllRecordsComponentStateV2'; import { RecordTableWithWrappersScrollWrapperContext } from '@/ui/utilities/scroll/contexts/ScrollWrapperContexts'; @@ -22,11 +23,19 @@ const StyledText = styled.div` export const RecordTableBodyFetchMoreLoader = () => { const { setRecordTableLastRowVisible } = useRecordTable(); + const isRecordTableLoadMoreLocked = useRecoilComponentValueV2( + isRecordIndexLoadMoreLockedComponentState, + ); + const onLastRowVisible = useRecoilCallback( () => async (inView: boolean) => { + if (isRecordTableLoadMoreLocked) { + return; + } + setRecordTableLastRowVisible(inView); }, - [setRecordTableLastRowVisible], + [setRecordTableLastRowVisible, isRecordTableLoadMoreLocked], ); const scrollWrapperRef = useContext( @@ -37,7 +46,8 @@ export const RecordTableBodyFetchMoreLoader = () => { hasRecordTableFetchedAllRecordsComponentStateV2, ); - const showLoadingMoreRow = !hasRecordTableFetchedAllRecordsComponents; + const showLoadingMoreRow = + !hasRecordTableFetchedAllRecordsComponents && !isRecordTableLoadMoreLocked; const { ref: tbodyRef } = useInView({ onChange: onLastRowVisible, diff --git a/packages/twenty-front/src/modules/object-record/record-table/record-table-section/components/RecordTableRecordGroupSectionLoadMore.tsx b/packages/twenty-front/src/modules/object-record/record-table/record-table-section/components/RecordTableRecordGroupSectionLoadMore.tsx index e51745f6b6..4f959e054a 100644 --- a/packages/twenty-front/src/modules/object-record/record-table/record-table-section/components/RecordTableRecordGroupSectionLoadMore.tsx +++ b/packages/twenty-front/src/modules/object-record/record-table/record-table-section/components/RecordTableRecordGroupSectionLoadMore.tsx @@ -1,5 +1,6 @@ import { useCurrentRecordGroupId } from '@/object-record/record-group/hooks/useCurrentRecordGroupId'; import { useLazyLoadRecordIndexTable } from '@/object-record/record-index/hooks/useLazyLoadRecordIndexTable'; +import { isRecordIndexLoadMoreLockedComponentState } from '@/object-record/record-index/states/isRecordIndexLoadMoreLockedComponentState'; import { recordIndexHasFetchedAllRecordsByGroupComponentState } from '@/object-record/record-index/states/recordIndexHasFetchedAllRecordsByGroupComponentState'; import { recordIndexAllRecordIdsComponentSelector } from '@/object-record/record-index/states/selectors/recordIndexAllRecordIdsComponentSelector'; import { useRecordTableContextOrThrow } from '@/object-record/record-table/contexts/RecordTableContext'; @@ -20,6 +21,10 @@ export const RecordTableRecordGroupSectionLoadMore = () => { currentRecordGroupId, ); + const isLoadMoreLocked = useRecoilComponentValueV2( + isRecordIndexLoadMoreLockedComponentState, + ); + const recordIds = useRecoilComponentValueV2( recordIndexAllRecordIdsComponentSelector, ); @@ -28,7 +33,7 @@ export const RecordTableRecordGroupSectionLoadMore = () => { fetchMoreRecords(); }; - if (hasFetchedAllRecords) { + if (hasFetchedAllRecords || isLoadMoreLocked) { return null; } diff --git a/packages/twenty-server/src/engine/core-modules/message-queue/jobs.module.ts b/packages/twenty-server/src/engine/core-modules/message-queue/jobs.module.ts index ea8edafb8f..1707e74eff 100644 --- a/packages/twenty-server/src/engine/core-modules/message-queue/jobs.module.ts +++ b/packages/twenty-server/src/engine/core-modules/message-queue/jobs.module.ts @@ -22,6 +22,7 @@ import { CleanInactiveWorkspaceJob } from 'src/engine/workspace-manager/workspac import { CalendarEventParticipantManagerModule } from 'src/modules/calendar/calendar-event-participant-manager/calendar-event-participant-manager.module'; import { CalendarModule } from 'src/modules/calendar/calendar.module'; import { AutoCompaniesAndContactsCreationJobModule } from 'src/modules/contact-creation-manager/jobs/auto-companies-and-contacts-creation-job.module'; +import { FavoriteModule } from 'src/modules/favorite/favorite.module'; import { MessagingModule } from 'src/modules/messaging/messaging.module'; import { TimelineJobModule } from 'src/modules/timeline/jobs/timeline-job.module'; import { TimelineActivityModule } from 'src/modules/timeline/timeline-activity.module'; @@ -50,6 +51,7 @@ import { WorkflowModule } from 'src/modules/workflow/workflow.module'; TimelineJobModule, WebhookJobModule, WorkflowModule, + FavoriteModule, ], providers: [ CleanInactiveWorkspaceJob, diff --git a/packages/twenty-server/src/engine/core-modules/message-queue/message-queue.constants.ts b/packages/twenty-server/src/engine/core-modules/message-queue/message-queue.constants.ts index 42262c1510..31804805d5 100644 --- a/packages/twenty-server/src/engine/core-modules/message-queue/message-queue.constants.ts +++ b/packages/twenty-server/src/engine/core-modules/message-queue/message-queue.constants.ts @@ -18,4 +18,5 @@ export enum MessageQueue { testQueue = 'test-queue', workflowQueue = 'workflow-queue', serverlessFunctionQueue = 'serverless-function-queue', + favoriteQueue = 'favorite-queue', } diff --git a/packages/twenty-server/src/modules/favorite/constants/favorite-deletion-batch-size.ts b/packages/twenty-server/src/modules/favorite/constants/favorite-deletion-batch-size.ts new file mode 100644 index 0000000000..14e4b40c87 --- /dev/null +++ b/packages/twenty-server/src/modules/favorite/constants/favorite-deletion-batch-size.ts @@ -0,0 +1 @@ +export const FAVORITE_DELETION_BATCH_SIZE = 100; diff --git a/packages/twenty-server/src/modules/favorite/favorite.module.ts b/packages/twenty-server/src/modules/favorite/favorite.module.ts new file mode 100644 index 0000000000..5fac130bcf --- /dev/null +++ b/packages/twenty-server/src/modules/favorite/favorite.module.ts @@ -0,0 +1,24 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; + +import { FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity'; +import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity'; +import { FavoriteDeletionJob } from 'src/modules/favorite/jobs/favorite-deletion.job'; +import { FavoriteDeletionListener } from 'src/modules/favorite/listeners/favorite-deletion.listener'; +import { FavoriteDeletionService } from 'src/modules/favorite/services/favorite-deletion.service'; + +@Module({ + imports: [ + TypeOrmModule.forFeature( + [ObjectMetadataEntity, FieldMetadataEntity], + 'metadata', + ), + ], + providers: [ + FavoriteDeletionService, + FavoriteDeletionListener, + FavoriteDeletionJob, + ], + exports: [], +}) +export class FavoriteModule {} diff --git a/packages/twenty-server/src/modules/favorite/jobs/favorite-deletion.job.ts b/packages/twenty-server/src/modules/favorite/jobs/favorite-deletion.job.ts new file mode 100644 index 0000000000..140466c2f0 --- /dev/null +++ b/packages/twenty-server/src/modules/favorite/jobs/favorite-deletion.job.ts @@ -0,0 +1,29 @@ +import { Scope } from '@nestjs/common'; + +import { Process } from 'src/engine/core-modules/message-queue/decorators/process.decorator'; +import { Processor } from 'src/engine/core-modules/message-queue/decorators/processor.decorator'; +import { MessageQueue } from 'src/engine/core-modules/message-queue/message-queue.constants'; +import { FavoriteDeletionService } from 'src/modules/favorite/services/favorite-deletion.service'; + +export type FavoriteDeletionJobData = { + workspaceId: string; + deletedRecordIds: string[]; +}; + +@Processor({ + queueName: MessageQueue.favoriteQueue, + scope: Scope.REQUEST, +}) +export class FavoriteDeletionJob { + constructor( + private readonly favoriteDeletionService: FavoriteDeletionService, + ) {} + + @Process(FavoriteDeletionJob.name) + async handle(data: FavoriteDeletionJobData): Promise { + await this.favoriteDeletionService.deleteFavoritesForDeletedRecords( + data.deletedRecordIds, + data.workspaceId, + ); + } +} diff --git a/packages/twenty-server/src/modules/favorite/listeners/favorite-deletion.listener.ts b/packages/twenty-server/src/modules/favorite/listeners/favorite-deletion.listener.ts new file mode 100644 index 0000000000..2a41cdc76e --- /dev/null +++ b/packages/twenty-server/src/modules/favorite/listeners/favorite-deletion.listener.ts @@ -0,0 +1,36 @@ +import { Injectable } from '@nestjs/common'; + +import { OnDatabaseBatchEvent } from 'src/engine/api/graphql/graphql-query-runner/decorators/on-database-batch-event.decorator'; +import { DatabaseEventAction } from 'src/engine/api/graphql/graphql-query-runner/enums/database-event-action'; +import { ObjectRecordDeleteEvent } from 'src/engine/core-modules/event-emitter/types/object-record-delete.event'; +import { InjectMessageQueue } from 'src/engine/core-modules/message-queue/decorators/message-queue.decorator'; +import { MessageQueue } from 'src/engine/core-modules/message-queue/message-queue.constants'; +import { MessageQueueService } from 'src/engine/core-modules/message-queue/services/message-queue.service'; +import { WorkspaceEventBatch } from 'src/engine/workspace-event-emitter/types/workspace-event.type'; +import { + FavoriteDeletionJob, + FavoriteDeletionJobData, +} from 'src/modules/favorite/jobs/favorite-deletion.job'; + +@Injectable() +export class FavoriteDeletionListener { + constructor( + @InjectMessageQueue(MessageQueue.favoriteQueue) + private readonly messageQueueService: MessageQueueService, + ) {} + + @OnDatabaseBatchEvent('*', DatabaseEventAction.DELETED) + async handleDeletedEvent( + payload: WorkspaceEventBatch, + ) { + const deletedRecordIds = payload.events.map(({ recordId }) => recordId); + + await this.messageQueueService.add( + FavoriteDeletionJob.name, + { + workspaceId: payload.workspaceId, + deletedRecordIds, + }, + ); + } +} diff --git a/packages/twenty-server/src/modules/favorite/services/favorite-deletion.service.ts b/packages/twenty-server/src/modules/favorite/services/favorite-deletion.service.ts new file mode 100644 index 0000000000..32f62a34b0 --- /dev/null +++ b/packages/twenty-server/src/modules/favorite/services/favorite-deletion.service.ts @@ -0,0 +1,87 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; + +import { In, Repository } from 'typeorm'; + +import { + FieldMetadataEntity, + FieldMetadataType, +} from 'src/engine/metadata-modules/field-metadata/field-metadata.entity'; +import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity'; +import { TwentyORMManager } from 'src/engine/twenty-orm/twenty-orm.manager'; +import { FAVORITE_DELETION_BATCH_SIZE } from 'src/modules/favorite/constants/favorite-deletion-batch-size'; +import { FavoriteWorkspaceEntity } from 'src/modules/favorite/standard-objects/favorite.workspace-entity'; + +@Injectable() +export class FavoriteDeletionService { + constructor( + @InjectRepository(ObjectMetadataEntity, 'metadata') + private readonly objectMetadataRepository: Repository, + + @InjectRepository(FieldMetadataEntity, 'metadata') + private readonly fieldMetadataRepository: Repository, + private readonly twentyORMManager: TwentyORMManager, + ) {} + + async deleteFavoritesForDeletedRecords( + deletedRecordIds: string[], + workspaceId: string, + ): Promise { + const favoriteRepository = + await this.twentyORMManager.getRepository( + 'favorite', + ); + + const favoriteObjectMetadata = await this.objectMetadataRepository.findOne({ + where: { + nameSingular: 'favorite', + workspaceId, + }, + }); + + if (!favoriteObjectMetadata) { + throw new Error('Favorite object metadata not found'); + } + + const favoriteFields = await this.fieldMetadataRepository.find({ + where: { + objectMetadataId: favoriteObjectMetadata.id, + type: FieldMetadataType.RELATION, + }, + }); + + const favoritesToDelete = await favoriteRepository.find({ + select: { + id: true, + }, + where: favoriteFields.map((field) => ({ + [`${field.name}Id`]: In(deletedRecordIds), + })), + withDeleted: true, + }); + + if (favoritesToDelete.length === 0) { + return; + } + + const favoriteIdsToDelete = favoritesToDelete.map( + (favorite) => favorite.id, + ); + + const batches: string[][] = []; + + for ( + let i = 0; + i < favoriteIdsToDelete.length; + i += FAVORITE_DELETION_BATCH_SIZE + ) { + batches.push( + favoriteIdsToDelete.slice(i, i + FAVORITE_DELETION_BATCH_SIZE), + ); + } + + for (const batch of batches) { + await favoriteRepository.delete(batch); + } + } +} diff --git a/packages/twenty-server/src/modules/modules.module.ts b/packages/twenty-server/src/modules/modules.module.ts index d3cc91566a..10bde37efe 100644 --- a/packages/twenty-server/src/modules/modules.module.ts +++ b/packages/twenty-server/src/modules/modules.module.ts @@ -3,6 +3,7 @@ import { Module } from '@nestjs/common'; import { CalendarModule } from 'src/modules/calendar/calendar.module'; import { ConnectedAccountModule } from 'src/modules/connected-account/connected-account.module'; import { FavoriteFolderModule } from 'src/modules/favorite-folder/favorite-folder.module'; +import { FavoriteModule } from 'src/modules/favorite/favorite.module'; import { MessagingModule } from 'src/modules/messaging/messaging.module'; import { ViewModule } from 'src/modules/view/view.module'; import { WorkflowModule } from 'src/modules/workflow/workflow.module'; @@ -15,6 +16,7 @@ import { WorkflowModule } from 'src/modules/workflow/workflow.module'; ViewModule, WorkflowModule, FavoriteFolderModule, + FavoriteModule, ], providers: [], exports: [],