From 024ee152a0e1fb782a4dbff9db98740083c021eb Mon Sep 17 00:00:00 2001 From: neo773 <62795688+neo773@users.noreply.github.com> Date: Fri, 19 Dec 2025 21:52:59 +0530 Subject: [PATCH] Add useUpdateManyRecords hook and message folders sync status mutation (#16694) - Added new `useUpdateManyRecords` hook for batch record updates with optimistic cache updates - Added `updateMessageFoldersSyncStatus` hook for managing message folder sync state - Redesigned Message Folders List with BreadCrumb and Animations --- .../generateUpdateManyRecordsMutation.ts | 29 ++ .../hooks/__mocks__/useUpdateManyRecords.ts | 44 ++ .../__tests__/useUpdateManyRecords.test.tsx | 225 ++++++++++ .../useUpdateManyRecordsMutation.test.tsx | 44 ++ .../hooks/useUpdateManyRecords.ts | 268 ++++++++++++ .../hooks/useUpdateManyRecordsMutation.ts | 26 ++ ...tUpdateManyRecordsMutationResponseField.ts | 5 + ...ttingsAccountsMessageChannelsContainer.tsx | 23 +- .../SettingsAccountsMessageFolderIcon.tsx | 15 +- .../SettingsAccountsMessageFoldersCard.tsx | 47 +- .../SettingsMessageFoldersBreadcrumb.tsx | 94 ++++ .../SettingsMessageFoldersTreeItem.tsx | 211 +++++---- .../computeFolderIdsForSyncToggle.test.ts | 410 ++++++++++++++++++ .../utils/computeFolderIdsForSyncToggle.ts | 77 ++++ .../utils/countNestedFolders.ts | 11 + .../utils/isFolderTreePartiallySelected.ts | 27 ++ .../useUpdateMessageFoldersSyncStatus.ts | 34 ++ .../message-folder-query-hook.module.ts | 9 - ...essage-folder-update-one.pre-query.hook.ts | 111 ----- .../src/modules/messaging/messaging.module.ts | 2 - 20 files changed, 1490 insertions(+), 222 deletions(-) create mode 100644 packages/twenty-front/src/modules/object-metadata/utils/generateUpdateManyRecordsMutation.ts create mode 100644 packages/twenty-front/src/modules/object-record/hooks/__mocks__/useUpdateManyRecords.ts create mode 100644 packages/twenty-front/src/modules/object-record/hooks/__tests__/useUpdateManyRecords.test.tsx create mode 100644 packages/twenty-front/src/modules/object-record/hooks/__tests__/useUpdateManyRecordsMutation.test.tsx create mode 100644 packages/twenty-front/src/modules/object-record/hooks/useUpdateManyRecords.ts create mode 100644 packages/twenty-front/src/modules/object-record/hooks/useUpdateManyRecordsMutation.ts create mode 100644 packages/twenty-front/src/modules/object-record/utils/getUpdateManyRecordsMutationResponseField.ts create mode 100644 packages/twenty-front/src/modules/settings/accounts/components/message-folders/SettingsMessageFoldersBreadcrumb.tsx create mode 100644 packages/twenty-front/src/modules/settings/accounts/components/message-folders/utils/__tests__/computeFolderIdsForSyncToggle.test.ts create mode 100644 packages/twenty-front/src/modules/settings/accounts/components/message-folders/utils/computeFolderIdsForSyncToggle.ts create mode 100644 packages/twenty-front/src/modules/settings/accounts/components/message-folders/utils/countNestedFolders.ts create mode 100644 packages/twenty-front/src/modules/settings/accounts/components/message-folders/utils/isFolderTreePartiallySelected.ts create mode 100644 packages/twenty-front/src/modules/settings/accounts/hooks/useUpdateMessageFoldersSyncStatus.ts delete mode 100644 packages/twenty-server/src/modules/messaging/message-folder-manager/query-hooks/message-folder-query-hook.module.ts delete mode 100644 packages/twenty-server/src/modules/messaging/message-folder-manager/query-hooks/message-folder-update-one.pre-query.hook.ts diff --git a/packages/twenty-front/src/modules/object-metadata/utils/generateUpdateManyRecordsMutation.ts b/packages/twenty-front/src/modules/object-metadata/utils/generateUpdateManyRecordsMutation.ts new file mode 100644 index 00000000000..561b79a6c92 --- /dev/null +++ b/packages/twenty-front/src/modules/object-metadata/utils/generateUpdateManyRecordsMutation.ts @@ -0,0 +1,29 @@ +import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; +import { getUpdateManyRecordsMutationResponseField } from '@/object-record/utils/getUpdateManyRecordsMutationResponseField'; +import { gql } from '@apollo/client'; +import { capitalize } from 'twenty-shared/utils'; + +export const generateUpdateManyRecordsMutation = ({ + objectMetadataItem, +}: { + objectMetadataItem: ObjectMetadataItem; +}) => { + const capitalizedObjectNameSingular = capitalize( + objectMetadataItem.nameSingular, + ); + const capitalizedObjectNamePlural = capitalize(objectMetadataItem.namePlural); + + const mutationResponseField = getUpdateManyRecordsMutationResponseField( + objectMetadataItem.namePlural, + ); + + const updateManyRecordsMutation = gql` + mutation UpdateMany${capitalizedObjectNamePlural}($filter: ${capitalizedObjectNameSingular}FilterInput!, $data: ${capitalizedObjectNameSingular}UpdateInput!) { + ${mutationResponseField}(filter: $filter, data: $data) { + id + } + } + `; + + return updateManyRecordsMutation; +}; diff --git a/packages/twenty-front/src/modules/object-record/hooks/__mocks__/useUpdateManyRecords.ts b/packages/twenty-front/src/modules/object-record/hooks/__mocks__/useUpdateManyRecords.ts new file mode 100644 index 00000000000..f40538df814 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/hooks/__mocks__/useUpdateManyRecords.ts @@ -0,0 +1,44 @@ +import { ObjectRecord } from '@/object-record/types/ObjectRecord'; +import { gql } from '@apollo/client'; +import { getMockPersonRecord } from '~/testing/mock-data/people'; + +export const query = gql` + mutation UpdateManyPeople( + $filter: PersonFilterInput! + $data: PersonUpdateInput! + ) { + updatePeople(filter: $filter, data: $data) { + id + __typename + } + } +`; + +export const personIds = [ + 'a7286b9a-c039-4a89-9567-2dfa7953cda9', + '37faabcd-cb39-4a0a-8618-7e3fda9afca0', +]; + +export const personRecords = personIds.map((personId, index) => + getMockPersonRecord({ id: personId }, index), +); + +export const updateInput = { + city: 'Updated City', +}; + +export const variables = { + filter: { + id: { + in: personIds, + }, + }, + data: updateInput, +}; + +export const updatedPersonRecords = personIds.map( + (personId, index) => + getMockPersonRecord({ id: personId, city: 'Updated City' }, index), +); + +export const responseData = personIds.map((personId) => ({ id: personId })); diff --git a/packages/twenty-front/src/modules/object-record/hooks/__tests__/useUpdateManyRecords.test.tsx b/packages/twenty-front/src/modules/object-record/hooks/__tests__/useUpdateManyRecords.test.tsx new file mode 100644 index 00000000000..d61bea04a0a --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/hooks/__tests__/useUpdateManyRecords.test.tsx @@ -0,0 +1,225 @@ +import { renderHook, waitFor } from '@testing-library/react'; + +import { getRecordFromCache } from '@/object-record/cache/utils/getRecordFromCache'; +import { updateRecordFromCache } from '@/object-record/cache/utils/updateRecordFromCache'; +import { generateDepthRecordGqlFieldsFromRecord } from '@/object-record/graphql/record-gql-fields/utils/generateDepthRecordGqlFieldsFromRecord'; +import { + personIds, + personRecords, + query, + responseData, + updateInput, + variables, +} from '@/object-record/hooks/__mocks__/useUpdateManyRecords'; +import { useRefetchAggregateQueries } from '@/object-record/hooks/useRefetchAggregateQueries'; +import { useUpdateManyRecords } from '@/object-record/hooks/useUpdateManyRecords'; +import { type ObjectRecord } from '@/object-record/types/ObjectRecord'; +import { InMemoryCache } from '@apollo/client'; +import { type MockedResponse } from '@apollo/client/testing'; +import { act } from 'react'; +import { getJestMetadataAndApolloMocksWrapper } from '~/testing/jest/getJestMetadataAndApolloMocksWrapper'; +import { getMockPersonObjectMetadataItem } from '~/testing/mock-data/people'; +import { generatedMockObjectMetadataItems } from '~/testing/utils/generatedMockObjectMetadataItems'; + +const getDefaultMocks = ( + overrides?: Partial, +): MockedResponse[] => [ + { + request: { + query, + variables, + }, + result: jest.fn(() => ({ + data: { + updatePeople: responseData, + }, + })), + ...overrides, + }, +]; + +jest.mock('@/object-record/hooks/useRefetchAggregateQueries'); +const mockRefetchAggregateQueries = jest.fn(); +(useRefetchAggregateQueries as jest.Mock).mockReturnValue({ + refetchAggregateQueries: mockRefetchAggregateQueries, +}); + +const objectMetadataItem = getMockPersonObjectMetadataItem(); +const objectMetadataItems = generatedMockObjectMetadataItems; + +const expectedCachedRecordsWithUpdatedCity = personRecords.map( + (personRecord) => ({ + ...personRecord, + city: 'Updated City', + }), +); + +describe('useUpdateManyRecords', () => { + let cache: InMemoryCache; + + const assertCachedRecordsMatch = (expectedRecords: ObjectRecord[]) => { + expectedRecords.forEach((expectedRecord) => { + const cachedRecord = getRecordFromCache({ + cache, + objectMetadataItem, + objectMetadataItems, + recordId: expectedRecord.id, + objectPermissionsByObjectMetadataId: {}, + }); + expect(cachedRecord).not.toBeNull(); + if (cachedRecord === null) throw new Error('Should never occur'); + expect(expectedRecord).toMatchObject(cachedRecord); + }); + }; + + const assertCachedRecordsIsNull = (recordIds: string[]) => + recordIds.forEach((recordId) => + expect( + getRecordFromCache({ + cache, + objectMetadataItem, + objectMetadataItems, + recordId, + objectPermissionsByObjectMetadataId: {}, + }), + ).toBeNull(), + ); + + beforeEach(() => { + jest.clearAllMocks(); + cache = new InMemoryCache(); + }); + + describe('A. Starting from empty cache', () => { + it('1. Should handle update many records when cache is empty', async () => { + const apolloMocks = getDefaultMocks(); + const { result } = renderHook( + () => useUpdateManyRecords({ objectNameSingular: 'person' }), + { + wrapper: getJestMetadataAndApolloMocksWrapper({ + apolloMocks, + cache, + }), + }, + ); + + await act(async () => { + const res = await result.current.updateManyRecords({ + recordIdsToUpdate: personIds, + updateOneRecordInput: updateInput, + }); + expect(res).toEqual(responseData); + assertCachedRecordsIsNull(personIds); + }); + + expect(apolloMocks[0].result).toHaveBeenCalled(); + expect(mockRefetchAggregateQueries).toHaveBeenCalledTimes(1); + }); + }); + + describe('B. Starting from filled cache', () => { + beforeEach(() => { + personRecords.forEach((record) => + updateRecordFromCache({ + cache, + objectMetadataItem, + objectMetadataItems, + record, + recordGqlFields: generateDepthRecordGqlFieldsFromRecord({ + objectMetadataItems: generatedMockObjectMetadataItems, + objectMetadataItem, + record, + depth: 1, + }), + objectPermissionsByObjectMetadataId: {}, + }), + ); + }); + + it('1. Should handle optimistic behavior after many successful records update', async () => { + const apolloMocks = getDefaultMocks(); + const { result } = renderHook( + () => useUpdateManyRecords({ objectNameSingular: 'person' }), + { + wrapper: getJestMetadataAndApolloMocksWrapper({ + apolloMocks, + cache, + }), + }, + ); + + await act(async () => { + const res = await result.current.updateManyRecords({ + recordIdsToUpdate: personIds, + updateOneRecordInput: updateInput, + }); + expect(res).toEqual(responseData); + assertCachedRecordsMatch(expectedCachedRecordsWithUpdatedCity); + }); + + expect(apolloMocks[0].result).toHaveBeenCalled(); + expect(mockRefetchAggregateQueries).toHaveBeenCalledTimes(1); + }); + + it('2. Should handle optimistic behavior before send many record update', async () => { + const apolloMocks = getDefaultMocks(); + const { result } = renderHook( + () => useUpdateManyRecords({ objectNameSingular: 'person' }), + { + wrapper: getJestMetadataAndApolloMocksWrapper({ + apolloMocks: getDefaultMocks({ + delay: Number.POSITIVE_INFINITY, + }), + cache, + }), + }, + ); + + await act(async () => { + result.current.updateManyRecords({ + recordIdsToUpdate: personIds, + updateOneRecordInput: updateInput, + }); + await waitFor(() => + assertCachedRecordsMatch(expectedCachedRecordsWithUpdatedCity), + ); + }); + + expect(apolloMocks[0].result).not.toHaveBeenCalled(); + expect(mockRefetchAggregateQueries).not.toHaveBeenCalled(); + }); + + it('3. Should rollback optimistic behavior after failing to update many records', async () => { + const apolloMocks = getDefaultMocks(); + const { result } = renderHook( + () => useUpdateManyRecords({ objectNameSingular: 'person' }), + { + wrapper: getJestMetadataAndApolloMocksWrapper({ + apolloMocks: getDefaultMocks({ + error: new Error('Internal server error'), + }), + cache, + }), + }, + ); + + await act(async () => { + try { + await result.current.updateManyRecords({ + recordIdsToUpdate: personIds, + updateOneRecordInput: updateInput, + }); + fail('Should have thrown an error'); + } catch (e) { + expect(e).toMatchInlineSnapshot( + `[ApolloError: Internal server error]`, + ); + assertCachedRecordsMatch(personRecords); + } + }); + + expect(apolloMocks[0].result).not.toHaveBeenCalled(); + expect(mockRefetchAggregateQueries).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/twenty-front/src/modules/object-record/hooks/__tests__/useUpdateManyRecordsMutation.test.tsx b/packages/twenty-front/src/modules/object-record/hooks/__tests__/useUpdateManyRecordsMutation.test.tsx new file mode 100644 index 00000000000..c0b506b5629 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/hooks/__tests__/useUpdateManyRecordsMutation.test.tsx @@ -0,0 +1,44 @@ +import { renderHook } from '@testing-library/react'; +import { print } from 'graphql'; + +import { useUpdateManyRecordsMutation } from '@/object-record/hooks/useUpdateManyRecordsMutation'; +import { getJestMetadataAndApolloMocksWrapper } from '~/testing/jest/getJestMetadataAndApolloMocksWrapper'; + +const expectedQueryTemplate = ` + mutation UpdateManyPeople($filter: PersonFilterInput!, $data: PersonUpdateInput!) { + updatePeople(filter: $filter, data: $data) { + id + } + } +`.replace(/\s/g, ''); + +const Wrapper = getJestMetadataAndApolloMocksWrapper({ + apolloMocks: [], +}); + +describe('useUpdateManyRecordsMutation', () => { + it('should return a valid updateManyRecordsMutation', () => { + const objectNameSingular = 'person'; + + const { result } = renderHook( + () => + useUpdateManyRecordsMutation({ + objectNameSingular, + }), + { + wrapper: Wrapper, + }, + ); + + const { updateManyRecordsMutation } = result.current; + + expect(updateManyRecordsMutation).toBeDefined(); + + const printedReceivedQuery = print(updateManyRecordsMutation).replace( + /\s/g, + '', + ); + + expect(printedReceivedQuery).toEqual(expectedQueryTemplate); + }); +}); diff --git a/packages/twenty-front/src/modules/object-record/hooks/useUpdateManyRecords.ts b/packages/twenty-front/src/modules/object-record/hooks/useUpdateManyRecords.ts new file mode 100644 index 00000000000..89135b0530d --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/hooks/useUpdateManyRecords.ts @@ -0,0 +1,268 @@ +import { triggerUpdateRecordOptimisticEffectByBatch } from '@/apollo/optimistic-effect/utils/triggerUpdateRecordOptimisticEffectByBatch'; +import { apiConfigState } from '@/client-config/states/apiConfigState'; +import { useApolloCoreClient } from '@/object-metadata/hooks/useApolloCoreClient'; +import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem'; +import { useObjectMetadataItems } from '@/object-metadata/hooks/useObjectMetadataItems'; +import { useGetRecordFromCache } from '@/object-record/cache/hooks/useGetRecordFromCache'; +import { getObjectTypename } from '@/object-record/cache/utils/getObjectTypename'; +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 { useGenerateDepthRecordGqlFieldsFromObject } from '@/object-record/graphql/record-gql-fields/hooks/useGenerateDepthRecordGqlFieldsFromObject'; +import { generateDepthRecordGqlFieldsFromRecord } from '@/object-record/graphql/record-gql-fields/utils/generateDepthRecordGqlFieldsFromRecord'; +import { type RecordGqlNode } from '@/object-record/graphql/types/RecordGqlNode'; +import { useObjectPermissions } from '@/object-record/hooks/useObjectPermissions'; +import { useRefetchAggregateQueries } from '@/object-record/hooks/useRefetchAggregateQueries'; +import { useRegisterObjectOperation } from '@/object-record/hooks/useRegisterObjectOperation'; +import { useUpdateManyRecordsMutation } from '@/object-record/hooks/useUpdateManyRecordsMutation'; +import { useUpsertRecordsInStore } from '@/object-record/record-store/hooks/useUpsertRecordsInStore'; +import { type ObjectRecord } from '@/object-record/types/ObjectRecord'; +import { getUpdateManyRecordsMutationResponseField } from '@/object-record/utils/getUpdateManyRecordsMutationResponseField'; +import { sanitizeRecordInput } from '@/object-record/utils/sanitizeRecordInput'; +import { useRecoilValue } from 'recoil'; +import { isDefined } from 'twenty-shared/utils'; +import { sleep } from '~/utils/sleep'; + +type UseUpdateManyRecordsProps = { + objectNameSingular: string; + recordGqlFields?: Record; +}; + +export type UpdateManyRecordsProps = { + recordIdsToUpdate: string[]; + updateOneRecordInput: Partial>; + skipOptimisticEffect?: boolean; + delayInMsBetweenRequests?: number; +}; + +export const useUpdateManyRecords = ({ + objectNameSingular, + recordGqlFields, +}: UseUpdateManyRecordsProps) => { + const { registerObjectOperation } = useRegisterObjectOperation(); + const { upsertRecordsInStore } = useUpsertRecordsInStore(); + const apiConfig = useRecoilValue(apiConfigState); + + const mutationPageSize = + apiConfig?.mutationMaximumAffectedRecords ?? DEFAULT_MUTATION_BATCH_SIZE; + + const apolloCoreClient = useApolloCoreClient(); + + const { objectMetadataItem } = useObjectMetadataItem({ + objectNameSingular, + }); + + const { recordGqlFields: depthOneRecordGqlFields } = + useGenerateDepthRecordGqlFieldsFromObject({ + objectNameSingular, + depth: 1, + }); + + const computedRecordGqlFields = recordGqlFields ?? depthOneRecordGqlFields; + + const getRecordFromCache = useGetRecordFromCache({ + objectNameSingular, + }); + + const { updateManyRecordsMutation } = useUpdateManyRecordsMutation({ + objectNameSingular, + }); + + const { objectMetadataItems } = useObjectMetadataItems(); + const { objectPermissionsByObjectMetadataId } = useObjectPermissions(); + const { refetchAggregateQueries } = useRefetchAggregateQueries({ + objectMetadataNamePlural: objectMetadataItem.namePlural, + }); + + const mutationResponseField = getUpdateManyRecordsMutationResponseField( + objectMetadataItem.namePlural, + ); + + const updateManyRecords = async ({ + recordIdsToUpdate, + updateOneRecordInput, + delayInMsBetweenRequests, + skipOptimisticEffect = false, + }: UpdateManyRecordsProps) => { + const numberOfBatches = Math.ceil( + recordIdsToUpdate.length / mutationPageSize, + ); + const updatedRecords: ObjectRecord[] = []; + + for (let batchIndex = 0; batchIndex < numberOfBatches; batchIndex++) { + const batchedIdsToUpdate = recordIdsToUpdate.slice( + batchIndex * mutationPageSize, + (batchIndex + 1) * mutationPageSize, + ); + + const cachedRecords = batchedIdsToUpdate + .map((idToUpdate) => + getRecordFromCache(idToUpdate, apolloCoreClient.cache), + ) + .filter(isDefined); + + const cachedRecordsNode: RecordGqlNode[] = []; + const computedOptimisticRecordsNode: RecordGqlNode[] = []; + const computedOptimisticRecords: ObjectRecord[] = []; + + if (!skipOptimisticEffect) { + cachedRecords.forEach((cachedRecord) => { + const cachedRecordNode = getRecordNodeFromRecord({ + record: cachedRecord, + objectMetadataItem, + objectMetadataItems, + recordGqlFields: computedRecordGqlFields, + computeReferences: false, + }); + + const computedOptimisticRecord = { + ...cachedRecord, + ...updateOneRecordInput, + __typename: getObjectTypename(objectMetadataItem.nameSingular), + }; + + computedOptimisticRecords.push(computedOptimisticRecord); + + const optimisticRecordNode = getRecordNodeFromRecord({ + record: computedOptimisticRecord, + objectMetadataItem, + objectMetadataItems, + recordGqlFields: computedRecordGqlFields, + computeReferences: false, + }); + + if (isDefined(optimisticRecordNode) && isDefined(cachedRecordNode)) { + const recordGqlFieldsFromRecord = + generateDepthRecordGqlFieldsFromRecord({ + objectMetadataItem, + objectMetadataItems, + record: updateOneRecordInput, + depth: 1, + }); + + updateRecordFromCache({ + objectMetadataItems, + objectMetadataItem, + cache: apolloCoreClient.cache, + record: computedOptimisticRecord, + recordGqlFields: recordGqlFieldsFromRecord, + objectPermissionsByObjectMetadataId, + }); + + computedOptimisticRecordsNode.push(optimisticRecordNode); + cachedRecordsNode.push(cachedRecordNode); + } + }); + + triggerUpdateRecordOptimisticEffectByBatch({ + cache: apolloCoreClient.cache, + objectMetadataItem, + currentRecords: cachedRecordsNode, + updatedRecords: computedOptimisticRecordsNode, + objectMetadataItems, + objectPermissionsByObjectMetadataId, + upsertRecordsInStore, + }); + } + + const sanitizedInput = sanitizeRecordInput({ + objectMetadataItem, + recordInput: updateOneRecordInput, + }); + + const updatedRecordsResponse = await apolloCoreClient + .mutate>({ + mutation: updateManyRecordsMutation, + variables: { + filter: { id: { in: batchedIdsToUpdate } }, + data: sanitizedInput, + }, + }) + .catch((error: Error) => { + if (skipOptimisticEffect) { + throw error; + } + + const revertedCachedRecordsNode: RecordGqlNode[] = []; + const revertedOptimisticRecordsNode: RecordGqlNode[] = []; + + cachedRecords.forEach((cachedRecord, index) => { + const recordGqlFieldsFromRecord = + generateDepthRecordGqlFieldsFromRecord({ + objectMetadataItem, + objectMetadataItems, + record: cachedRecord, + depth: 1, + }); + + updateRecordFromCache({ + objectMetadataItems, + objectMetadataItem, + cache: apolloCoreClient.cache, + record: cachedRecord, + recordGqlFields: recordGqlFieldsFromRecord, + objectPermissionsByObjectMetadataId, + }); + + const cachedRecordWithConnection = + getRecordNodeFromRecord({ + record: cachedRecord, + objectMetadataItem, + objectMetadataItems, + recordGqlFields: computedRecordGqlFields, + computeReferences: false, + }); + + const optimisticRecordWithConnection = + computedOptimisticRecordsNode[index]; + + if ( + isDefined(optimisticRecordWithConnection) && + isDefined(cachedRecordWithConnection) + ) { + revertedCachedRecordsNode.push(cachedRecordWithConnection); + revertedOptimisticRecordsNode.push( + optimisticRecordWithConnection, + ); + } + }); + + triggerUpdateRecordOptimisticEffectByBatch({ + cache: apolloCoreClient.cache, + objectMetadataItem, + currentRecords: revertedOptimisticRecordsNode, + updatedRecords: revertedCachedRecordsNode, + objectMetadataItems, + objectPermissionsByObjectMetadataId, + upsertRecordsInStore, + }); + + throw error; + }); + + const updatedRecordsForThisBatch = + updatedRecordsResponse.data?.[mutationResponseField] ?? []; + updatedRecords.push(...updatedRecordsForThisBatch); + + if (isDefined(delayInMsBetweenRequests)) { + await sleep(delayInMsBetweenRequests); + } + } + + await refetchAggregateQueries(); + + registerObjectOperation(objectMetadataItem, { + type: 'update-many', + result: { + updateInputs: recordIdsToUpdate.map((id) => ({ + id, + ...updateOneRecordInput, + })), + }, + }); + + return updatedRecords; + }; + + return { updateManyRecords }; +}; diff --git a/packages/twenty-front/src/modules/object-record/hooks/useUpdateManyRecordsMutation.ts b/packages/twenty-front/src/modules/object-record/hooks/useUpdateManyRecordsMutation.ts new file mode 100644 index 00000000000..29c05ae0ab4 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/hooks/useUpdateManyRecordsMutation.ts @@ -0,0 +1,26 @@ +import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem'; +import { generateUpdateManyRecordsMutation } from '@/object-metadata/utils/generateUpdateManyRecordsMutation'; +import { EMPTY_MUTATION } from '@/object-record/constants/EmptyMutation'; +import { isUndefinedOrNull } from '~/utils/isUndefinedOrNull'; + +export const useUpdateManyRecordsMutation = ({ + objectNameSingular, +}: { + objectNameSingular: string; +}) => { + const { objectMetadataItem } = useObjectMetadataItem({ + objectNameSingular, + }); + + if (isUndefinedOrNull(objectMetadataItem)) { + return { updateManyRecordsMutation: EMPTY_MUTATION }; + } + + const updateManyRecordsMutation = generateUpdateManyRecordsMutation({ + objectMetadataItem, + }); + + return { + updateManyRecordsMutation, + }; +}; diff --git a/packages/twenty-front/src/modules/object-record/utils/getUpdateManyRecordsMutationResponseField.ts b/packages/twenty-front/src/modules/object-record/utils/getUpdateManyRecordsMutationResponseField.ts new file mode 100644 index 00000000000..78690a400fd --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/utils/getUpdateManyRecordsMutationResponseField.ts @@ -0,0 +1,5 @@ +import { capitalize } from 'twenty-shared/utils'; + +export const getUpdateManyRecordsMutationResponseField = ( + objectNamePlural: string, +) => `update${capitalize(objectNamePlural)}`; diff --git a/packages/twenty-front/src/modules/settings/accounts/components/SettingsAccountsMessageChannelsContainer.tsx b/packages/twenty-front/src/modules/settings/accounts/components/SettingsAccountsMessageChannelsContainer.tsx index 5f9244e6d25..48129b88430 100644 --- a/packages/twenty-front/src/modules/settings/accounts/components/SettingsAccountsMessageChannelsContainer.tsx +++ b/packages/twenty-front/src/modules/settings/accounts/components/SettingsAccountsMessageChannelsContainer.tsx @@ -17,7 +17,7 @@ import { settingsAccountsSelectedMessageChannelState } from '@/settings/accounts import { TabList } from '@/ui/layout/tab-list/components/TabList'; import { activeTabIdComponentState } from '@/ui/layout/tab-list/states/activeTabIdComponentState'; import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue'; -import React from 'react'; +import React, { useCallback } from 'react'; import { isDefined } from 'twenty-shared/utils'; const StyledMessageContainer = styled.div` @@ -77,19 +77,22 @@ export const SettingsAccountsMessageChannelsContainer = () => { title: messageChannel.handle, })); + const handleTabChange = useCallback( + (tabId: string) => { + const selectedMessageChannel = messageChannels.find( + (channel) => channel.id === tabId, + ); + if (isDefined(selectedMessageChannel)) { + setSelectedMessageChannel(selectedMessageChannel); + } + }, + [messageChannels, setSelectedMessageChannel], + ); + if (!messageChannels.length) { return ; } - const handleTabChange = (tabId: string) => { - const selectedMessageChannel = messageChannels.find( - (channel) => channel.id === tabId, - ); - if (isDefined(selectedMessageChannel)) { - setSelectedMessageChannel(selectedMessageChannel); - } - }; - return ( <> {tabs.length > 1 && ( diff --git a/packages/twenty-front/src/modules/settings/accounts/components/message-folders/SettingsAccountsMessageFolderIcon.tsx b/packages/twenty-front/src/modules/settings/accounts/components/message-folders/SettingsAccountsMessageFolderIcon.tsx index b73199d8227..0b9f1c134ed 100644 --- a/packages/twenty-front/src/modules/settings/accounts/components/message-folders/SettingsAccountsMessageFolderIcon.tsx +++ b/packages/twenty-front/src/modules/settings/accounts/components/message-folders/SettingsAccountsMessageFolderIcon.tsx @@ -1,13 +1,20 @@ import { type MessageFolder } from '@/accounts/types/MessageFolder'; import { useTheme } from '@emotion/react'; -import { IconFolder, IconInbox, IconSend } from 'twenty-ui/display'; +import { + IconFolder, + IconFolderRoot, + IconInbox, + IconSend, +} from 'twenty-ui/display'; type SettingsAccountsMessageFolderIconProps = { folder: MessageFolder; + isChildFolder?: boolean; }; export const SettingsAccountsMessageFolderIcon = ({ folder, + isChildFolder = false, }: SettingsAccountsMessageFolderIconProps) => { const theme = useTheme(); if (folder.isSentFolder) { @@ -20,5 +27,11 @@ export const SettingsAccountsMessageFolderIcon = ({ ); } + if (isChildFolder) { + return ( + + ); + } + return ; }; diff --git a/packages/twenty-front/src/modules/settings/accounts/components/message-folders/SettingsAccountsMessageFoldersCard.tsx b/packages/twenty-front/src/modules/settings/accounts/components/message-folders/SettingsAccountsMessageFoldersCard.tsx index 2f87673cd2d..f20c98b5343 100644 --- a/packages/twenty-front/src/modules/settings/accounts/components/message-folders/SettingsAccountsMessageFoldersCard.tsx +++ b/packages/twenty-front/src/modules/settings/accounts/components/message-folders/SettingsAccountsMessageFoldersCard.tsx @@ -3,15 +3,18 @@ import { type MessageFolder } from '@/accounts/types/MessageFolder'; import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular'; import { useGenerateDepthRecordGqlFieldsFromObject } from '@/object-record/graphql/record-gql-fields/hooks/useGenerateDepthRecordGqlFieldsFromObject'; import { useFindOneRecord } from '@/object-record/hooks/useFindOneRecord'; -import { useUpdateOneRecord } from '@/object-record/hooks/useUpdateOneRecord'; import { SettingsMessageFoldersEmptyStateCard } from '@/settings/accounts/components/message-folders/SettingsMessageFoldersEmptyStateCard'; import { SettingsMessageFoldersSkeletonLoader } from '@/settings/accounts/components/message-folders/SettingsMessageFoldersSkeletonLoader'; import { SettingsMessageFoldersTreeItem } from '@/settings/accounts/components/message-folders/SettingsMessageFoldersTreeItem'; +import { computeFolderIdsForSyncToggle } from '@/settings/accounts/components/message-folders/utils/computeFolderIdsForSyncToggle'; import { computeMessageFolderTree } from '@/settings/accounts/components/message-folders/utils/computeMessageFolderTree'; +import { useUpdateMessageFoldersSyncStatus } from '@/settings/accounts/hooks/useUpdateMessageFoldersSyncStatus'; import { settingsAccountsSelectedMessageChannelState } from '@/settings/accounts/states/settingsAccountsSelectedMessageChannelState'; +import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar'; import { SettingsTextInput } from '@/ui/input/components/SettingsTextInput'; import { Table } from '@/ui/layout/table/components/Table'; import { TableCell } from '@/ui/layout/table/components/TableCell'; +import { ApolloError } from '@apollo/client'; import styled from '@emotion/styled'; import { useLingui } from '@lingui/react/macro'; import { useMemo, useState } from 'react'; @@ -67,13 +70,14 @@ export const SettingsAccountsMessageFoldersCard = () => { const { t } = useLingui(); const [search, setSearch] = useState(''); + const { enqueueErrorSnackBar } = useSnackBar(); + const settingsAccountsSelectedMessageChannel = useRecoilValue( settingsAccountsSelectedMessageChannelState, ); - const { updateOneRecord } = useUpdateOneRecord({ - objectNameSingular: CoreObjectNameSingular.MessageFolder, - }); + const { updateMessageFoldersSyncStatus } = + useUpdateMessageFoldersSyncStatus(); const { recordGqlFields } = useGenerateDepthRecordGqlFieldsFromObject({ objectNameSingular: CoreObjectNameSingular.MessageChannel, @@ -111,21 +115,36 @@ export const SettingsAccountsMessageFoldersCard = () => { const allSynced = messageFoldersToToggle.every((folder) => folder.isSynced); const targetSyncState = !allSynced; - for (const folder of messageFoldersToToggle) { - await updateOneRecord({ - idToUpdate: folder.id, - updateOneRecordInput: { isSynced: targetSyncState }, + try { + await updateMessageFoldersSyncStatus({ + messageFolderIds: messageFoldersToToggle.map((folder) => folder.id), + isSynced: targetSyncState, + }); + } catch (error) { + enqueueErrorSnackBar({ + ...(error instanceof ApolloError ? { apolloError: error } : {}), }); } }; - const handleToggleFolder = async (messageFoldersToToggle: MessageFolder) => { - await updateOneRecord({ - idToUpdate: messageFoldersToToggle.id, - updateOneRecordInput: { - isSynced: !messageFoldersToToggle.isSynced, - }, + const handleToggleFolder = async (folderToToggle: MessageFolder) => { + const isSynced = !folderToToggle.isSynced; + const folderIdsToToggle = computeFolderIdsForSyncToggle({ + folderId: folderToToggle.id, + allFolders: messageFolders, + isSynced, }); + + try { + await updateMessageFoldersSyncStatus({ + messageFolderIds: folderIdsToToggle, + isSynced, + }); + } catch (error) { + enqueueErrorSnackBar({ + ...(error instanceof ApolloError ? { apolloError: error } : {}), + }); + } }; if (loading) { diff --git a/packages/twenty-front/src/modules/settings/accounts/components/message-folders/SettingsMessageFoldersBreadcrumb.tsx b/packages/twenty-front/src/modules/settings/accounts/components/message-folders/SettingsMessageFoldersBreadcrumb.tsx new file mode 100644 index 00000000000..7b0ded26c2e --- /dev/null +++ b/packages/twenty-front/src/modules/settings/accounts/components/message-folders/SettingsMessageFoldersBreadcrumb.tsx @@ -0,0 +1,94 @@ +import styled from '@emotion/styled'; + +const BREADCRUMB_WIDTH = 24; +const ICON_CENTER_OFFSET = 8; + +export type SettingsMessageFoldersBreadcrumbProps = { + depth: number; + isLast: boolean; + parentsIsLastList: boolean[]; +}; + +const StyledBreadcrumbOverlay = styled.div<{ depth: number }>` + height: 28px; + left: 0; + pointer-events: none; + position: absolute; + top: 0; + width: ${({ depth }) => depth * BREADCRUMB_WIDTH}px; +`; + +const StyledAncestorLine = styled.div<{ + index: number; + showLine: boolean; +}>` + background: ${({ theme, showLine }) => + showLine ? theme.border.color.strong : 'transparent'}; + height: 28px; + left: ${({ index }) => index * BREADCRUMB_WIDTH + ICON_CENTER_OFFSET}px; + position: absolute; + top: 0; + width: 1px; +`; + +const StyledBreadcrumbConnector = styled.div<{ depth: number }>` + height: 28px; + left: ${({ depth }) => (depth - 1) * BREADCRUMB_WIDTH + ICON_CENTER_OFFSET}px; + position: absolute; + top: 0; + width: ${BREADCRUMB_WIDTH - ICON_CENTER_OFFSET}px; +`; + +const StyledVerticalLineTop = styled.div` + background: ${({ theme }) => theme.border.color.strong}; + height: 12px; + left: 0; + position: absolute; + top: 0; + width: 1px; +`; + +const StyledRoundedCorner = styled.div` + border-bottom: 1px solid ${({ theme }) => theme.border.color.strong}; + border-bottom-left-radius: 4px; + border-left: 1px solid ${({ theme }) => theme.border.color.strong}; + height: 8px; + left: 0; + position: absolute; + top: 6px; + width: 8px; +`; + +const StyledVerticalLineBottom = styled.div` + background: ${({ theme }) => theme.border.color.strong}; + height: 16px; + left: 0; + position: absolute; + top: 12px; + width: 1px; +`; + +export const SettingsMessageFoldersBreadcrumb = ({ + depth, + isLast, + parentsIsLastList, +}: SettingsMessageFoldersBreadcrumbProps) => { + const showVerticalBar = !isLast; + + return ( + + {parentsIsLastList.map((parentIsLast, index) => ( + + ))} + + + + {showVerticalBar && } + + + ); +}; diff --git a/packages/twenty-front/src/modules/settings/accounts/components/message-folders/SettingsMessageFoldersTreeItem.tsx b/packages/twenty-front/src/modules/settings/accounts/components/message-folders/SettingsMessageFoldersTreeItem.tsx index c35e0dde899..dbf5e3459d6 100644 --- a/packages/twenty-front/src/modules/settings/accounts/components/message-folders/SettingsMessageFoldersTreeItem.tsx +++ b/packages/twenty-front/src/modules/settings/accounts/components/message-folders/SettingsMessageFoldersTreeItem.tsx @@ -1,87 +1,78 @@ -import { t } from '@lingui/core/macro'; import { type MessageFolder } from '@/accounts/types/MessageFolder'; import { SettingsAccountsMessageFolderIcon } from '@/settings/accounts/components/message-folders/SettingsAccountsMessageFolderIcon'; - +import { SettingsMessageFoldersBreadcrumb } from '@/settings/accounts/components/message-folders/SettingsMessageFoldersBreadcrumb'; import { type MessageFolderTreeNode } from '@/settings/accounts/components/message-folders/utils/computeMessageFolderTree'; +import { countNestedFolders } from '@/settings/accounts/components/message-folders/utils/countNestedFolders'; import { formatFolderName } from '@/settings/accounts/components/message-folders/utils/formatFolderName'; +import { isFolderTreePartiallySelected } from '@/settings/accounts/components/message-folders/utils/isFolderTreePartiallySelected'; import styled from '@emotion/styled'; +import { useLingui } from '@lingui/react/macro'; import { useState } from 'react'; -import { IconChevronRight } from 'twenty-ui/display'; +import { IconChevronDown, IconChevronUp } from 'twenty-ui/display'; import { Checkbox, CheckboxSize } from 'twenty-ui/input'; type SettingsMessageFoldersTreeItemProps = { - folderTreeNode: MessageFolderTreeNode; - onToggleFolder: (folder: MessageFolder) => void; depth?: number; + folderTreeNode: MessageFolderTreeNode; + isLast?: boolean; + onToggleFolder: (folder: MessageFolder) => void; + parentsIsLastList?: boolean[]; }; -const StyledTreeItem = styled.li<{ hasChildren: boolean; depth: number }>` - position: relative; - margin-left: ${({ hasChildren, depth, theme }) => - !hasChildren && depth > 0 ? theme.spacing(3) : 0}; +const BREADCRUMB_WIDTH = 24; - &:not(:last-child) { - margin-bottom: ${({ theme }) => theme.spacing(1)}; - } +const StyledTreeItem = styled.li` + position: relative; `; const StyledNestedList = styled.ul` - border-left: 1px solid ${({ theme }) => theme.border.color.medium}; list-style: none; - margin: ${({ theme }) => theme.spacing(1)} 0 0 - ${({ theme }) => theme.spacing(3)}; + margin: 0; padding: 0; - padding-left: ${({ theme }) => theme.spacing(2)}; `; -const StyledTreeItemContent = styled.div` - display: flex; +const StyledCollapsibleWrapper = styled.div<{ isExpanded: boolean }>` + display: grid; + grid-template-rows: ${({ isExpanded }) => (isExpanded ? '1fr' : '0fr')}; + transition: grid-template-rows + ${({ theme }) => theme.animation.duration.fast}s ease-out; +`; + +const StyledCollapsibleContent = styled.div` + overflow: hidden; +`; + +const StyledTreeItemContent = styled.div<{ depth: number }>` align-items: center; - gap: ${({ theme }) => theme.spacing(2)}; - padding: ${({ theme }) => theme.spacing(1)}; border-radius: ${({ theme }) => theme.border.radius.sm}; cursor: pointer; + display: flex; + height: 28px; + padding-left: ${({ depth }) => depth * BREADCRUMB_WIDTH}px; transition: background-color ${({ theme }) => theme.animation.duration.instant}s; + user-select: none; &:hover { background-color: ${({ theme }) => theme.background.transparent.lighter}; } `; -const StyledExpandButton = styled.button<{ isExpanded: boolean }>` - background: none; - border: none; - padding: 0; - cursor: pointer; - display: flex; - align-items: center; - justify-content: center; - width: ${({ theme }) => theme.spacing(4)}; - height: ${({ theme }) => theme.spacing(4)}; - color: ${({ theme }) => theme.font.color.tertiary}; - transition: transform ${({ theme }) => theme.animation.duration.instant}s; - transform: ${({ isExpanded }) => - isExpanded ? 'rotate(90deg)' : 'rotate(0deg)'}; - - &:hover { - color: ${({ theme }) => theme.font.color.primary}; - } -`; - const StyledFolderContent = styled.div` align-items: center; display: flex; flex: 1; gap: ${({ theme }) => theme.spacing(2)}; - justify-content: space-between; + min-width: 0; + padding-left: ${({ theme }) => theme.spacing(1)}; + padding-right: ${({ theme }) => theme.spacing(1)}; `; const StyledFolderInfo = styled.div` - display: flex; align-items: center; - gap: ${({ theme }) => theme.spacing(2)}; + display: flex; flex: 1; + gap: ${({ theme }) => theme.spacing(2)}; min-width: 0; `; @@ -93,18 +84,57 @@ const StyledFolderName = styled.span` white-space: nowrap; `; +const StyledRightSection = styled.div` + align-items: center; + display: flex; + flex-shrink: 0; + gap: ${({ theme }) => theme.spacing(1)}; + margin-left: auto; +`; + +const StyledChildCount = styled.span` + color: ${({ theme }) => theme.font.color.tertiary}; + font-size: ${({ theme }) => theme.font.size.sm}; + min-width: ${({ theme }) => theme.spacing(3)}; + text-align: right; +`; + +const StyledExpandButton = styled.button` + align-items: center; + background: none; + border: none; + color: ${({ theme }) => theme.font.color.tertiary}; + cursor: pointer; + display: flex; + height: ${({ theme }) => theme.spacing(4)}; + justify-content: center; + padding: 0; + width: ${({ theme }) => theme.spacing(4)}; + + &:hover { + color: ${({ theme }) => theme.font.color.primary}; + } +`; + const StyledCheckboxWrapper = styled.div` align-items: center; display: flex; `; export const SettingsMessageFoldersTreeItem = ({ - folderTreeNode, - onToggleFolder, depth = 0, + folderTreeNode, + isLast = false, + onToggleFolder, + parentsIsLastList = [], }: SettingsMessageFoldersTreeItemProps) => { + const { t } = useLingui(); const [isExpanded, setIsExpanded] = useState(true); - const { folder, children, hasChildren } = folderTreeNode; + + const { children, folder, hasChildren } = folderTreeNode; + const childCount = hasChildren ? countNestedFolders(folderTreeNode) : 0; + const isIndeterminate = + hasChildren && isFolderTreePartiallySelected(folderTreeNode); const handleExpandToggle = (e: React.MouseEvent) => { e.stopPropagation(); @@ -121,46 +151,77 @@ export const SettingsMessageFoldersTreeItem = ({ e.stopPropagation(); }; + const childParentsIsLastList = + depth > 0 ? [...parentsIsLastList, isLast] : parentsIsLastList; + return ( - - - {hasChildren && ( - - - + + + {depth > 0 && ( + )} - + 0} + /> {formatFolderName(folder.name)} - - onToggleFolder(folder)} - size={CheckboxSize.Small} - /> - + + {hasChildren && ( + <> + {childCount} + + {isExpanded ? ( + + ) : ( + + )} + + + )} + + + onToggleFolder(folder)} + size={CheckboxSize.Small} + /> + + - {hasChildren && isExpanded && ( - - {children.map((child) => ( - - ))} - + {hasChildren && ( + + + + {children.map((child, index) => ( + + ))} + + + )} ); diff --git a/packages/twenty-front/src/modules/settings/accounts/components/message-folders/utils/__tests__/computeFolderIdsForSyncToggle.test.ts b/packages/twenty-front/src/modules/settings/accounts/components/message-folders/utils/__tests__/computeFolderIdsForSyncToggle.test.ts new file mode 100644 index 00000000000..d02ff6abe73 --- /dev/null +++ b/packages/twenty-front/src/modules/settings/accounts/components/message-folders/utils/__tests__/computeFolderIdsForSyncToggle.test.ts @@ -0,0 +1,410 @@ +import { type MessageFolder } from '@/accounts/types/MessageFolder'; +import { computeFolderIdsForSyncToggle } from '@/settings/accounts/components/message-folders/utils/computeFolderIdsForSyncToggle'; + +describe('computeFolderIdsForSyncToggle', () => { + const createFolder = ({ + id, + name, + parentFolderId = null, + externalId = null, + isSynced = false, + }: { + id: string; + name: string; + parentFolderId?: string | null; + externalId?: string | null; + isSynced?: boolean; + }): MessageFolder => ({ + id, + name, + parentFolderId, + externalId: externalId || id, + isSentFolder: false, + isSynced, + messageChannelId: 'channel-1', + __typename: 'MessageFolder', + syncCursor: '', + }); + + describe('when syncing a folder', () => { + it('should include the folder itself for a root folder', () => { + const inbox = createFolder({ id: 'inbox', name: 'Inbox' }); + const result = computeFolderIdsForSyncToggle({ + folderId: 'inbox', + allFolders: [inbox], + isSynced: true, + }); + + expect(result).toEqual(['inbox']); + }); + + it('should include ancestors when syncing a nested folder', () => { + const work = createFolder({ + id: 'work', + name: 'Work', + externalId: 'ext-work', + }); + const nested = createFolder({ + id: 'nested', + name: 'Nested', + parentFolderId: 'ext-work', + externalId: 'ext-nested', + }); + const result = computeFolderIdsForSyncToggle({ + folderId: 'nested', + allFolders: [work, nested], + isSynced: true, + }); + + expect(result).toContain('nested'); + expect(result).toContain('work'); + expect(result).toHaveLength(2); + }); + + it('should NOT include siblings when syncing a child folder', () => { + const parent = createFolder({ + id: 'parent', + name: 'Parent', + externalId: 'ext-parent', + isSynced: false, + }); + const childA = createFolder({ + id: 'child-a', + name: 'Child A', + parentFolderId: 'ext-parent', + externalId: 'ext-child-a', + isSynced: false, + }); + const childB = createFolder({ + id: 'child-b', + name: 'Child B', + parentFolderId: 'ext-parent', + externalId: 'ext-child-b', + isSynced: false, + }); + const childC = createFolder({ + id: 'child-c', + name: 'Child C', + parentFolderId: 'ext-parent', + externalId: 'ext-child-c', + isSynced: false, + }); + const result = computeFolderIdsForSyncToggle({ + folderId: 'child-a', + allFolders: [parent, childA, childB, childC], + isSynced: true, + }); + + expect(result).toContain('child-a'); + expect(result).toContain('parent'); + expect(result).not.toContain('child-b'); + expect(result).not.toContain('child-c'); + expect(result).toHaveLength(2); + }); + + it('should include all ancestors up to root', () => { + const work = createFolder({ + id: 'work', + name: 'Work', + externalId: 'ext-work', + }); + const nested = createFolder({ + id: 'nested', + name: 'Nested', + parentFolderId: 'ext-work', + externalId: 'ext-nested', + }); + const deep = createFolder({ + id: 'deep', + name: 'Deep', + parentFolderId: 'ext-nested', + externalId: 'ext-deep', + }); + const result = computeFolderIdsForSyncToggle({ + folderId: 'deep', + allFolders: [work, nested, deep], + isSynced: true, + }); + + expect(result).toContain('deep'); + expect(result).toContain('nested'); + expect(result).toContain('work'); + expect(result).toHaveLength(3); + }); + + it('should include descendants when syncing a parent folder', () => { + const work = createFolder({ + id: 'work', + name: 'Work', + externalId: 'ext-work', + }); + const child1 = createFolder({ + id: 'child1', + name: 'Child 1', + parentFolderId: 'ext-work', + externalId: 'ext-c1', + }); + const child2 = createFolder({ + id: 'child2', + name: 'Child 2', + parentFolderId: 'ext-work', + externalId: 'ext-c2', + }); + const result = computeFolderIdsForSyncToggle({ + folderId: 'work', + allFolders: [work, child1, child2], + isSynced: true, + }); + + expect(result).toContain('work'); + expect(result).toContain('child1'); + expect(result).toContain('child2'); + expect(result).toHaveLength(3); + }); + + it('should include both ancestors and descendants', () => { + const root = createFolder({ + id: 'root', + name: 'Root', + externalId: 'ext-root', + }); + const middle = createFolder({ + id: 'middle', + name: 'Middle', + parentFolderId: 'ext-root', + externalId: 'ext-middle', + }); + const leaf = createFolder({ + id: 'leaf', + name: 'Leaf', + parentFolderId: 'ext-middle', + externalId: 'ext-leaf', + }); + const result = computeFolderIdsForSyncToggle({ + folderId: 'middle', + allFolders: [root, middle, leaf], + isSynced: true, + }); + + expect(result).toContain('root'); + expect(result).toContain('middle'); + expect(result).toContain('leaf'); + expect(result).toHaveLength(3); + }); + }); + + describe('when unsyncing a folder', () => { + it('should include only the folder for a root folder', () => { + const inbox = createFolder({ + id: 'inbox', + name: 'Inbox', + externalId: 'ext-inbox', + isSynced: true, + }); + const result = computeFolderIdsForSyncToggle({ + folderId: 'inbox', + allFolders: [inbox], + isSynced: false, + }); + + expect(result).toEqual(['inbox']); + }); + + it('should include descendants when unsyncing a parent', () => { + const work = createFolder({ + id: 'work', + name: 'Work', + externalId: 'ext-work', + isSynced: true, + }); + const child1 = createFolder({ + id: 'child1', + name: 'Child 1', + parentFolderId: 'ext-work', + externalId: 'ext-c1', + isSynced: true, + }); + const child2 = createFolder({ + id: 'child2', + name: 'Child 2', + parentFolderId: 'ext-work', + externalId: 'ext-c2', + isSynced: true, + }); + const result = computeFolderIdsForSyncToggle({ + folderId: 'work', + allFolders: [work, child1, child2], + isSynced: false, + }); + + expect(result).toContain('work'); + expect(result).toContain('child1'); + expect(result).toContain('child2'); + expect(result).toHaveLength(3); + }); + + it('should NOT unsync parent when it has other synced children', () => { + const work = createFolder({ + id: 'work', + name: 'Work', + externalId: 'ext-work', + isSynced: true, + }); + const child1 = createFolder({ + id: 'child1', + name: 'Child 1', + parentFolderId: 'ext-work', + externalId: 'ext-c1', + isSynced: true, + }); + const child2 = createFolder({ + id: 'child2', + name: 'Child 2', + parentFolderId: 'ext-work', + externalId: 'ext-c2', + isSynced: true, + }); + const result = computeFolderIdsForSyncToggle({ + folderId: 'child1', + allFolders: [work, child1, child2], + isSynced: false, + }); + + expect(result).toContain('child1'); + expect(result).not.toContain('work'); + expect(result).not.toContain('child2'); + expect(result).toHaveLength(1); + }); + + it('should unsync parent when all children are being unsynced', () => { + const work = createFolder({ + id: 'work', + name: 'Work', + externalId: 'ext-work', + isSynced: true, + }); + const nested = createFolder({ + id: 'nested', + name: 'Nested', + parentFolderId: 'ext-work', + externalId: 'ext-nested', + isSynced: true, + }); + const result = computeFolderIdsForSyncToggle({ + folderId: 'nested', + allFolders: [work, nested], + isSynced: false, + }); + + expect(result).toContain('nested'); + expect(result).toContain('work'); + expect(result).toHaveLength(2); + }); + + it('should cascade unsync up to root when no other synced siblings exist', () => { + const root = createFolder({ + id: 'root', + name: 'Root', + externalId: 'ext-root', + isSynced: true, + }); + const middle = createFolder({ + id: 'middle', + name: 'Middle', + parentFolderId: 'ext-root', + externalId: 'ext-middle', + isSynced: true, + }); + const leaf = createFolder({ + id: 'leaf', + name: 'Leaf', + parentFolderId: 'ext-middle', + externalId: 'ext-leaf', + isSynced: true, + }); + const result = computeFolderIdsForSyncToggle({ + folderId: 'leaf', + allFolders: [root, middle, leaf], + isSynced: false, + }); + + expect(result).toContain('leaf'); + expect(result).toContain('middle'); + expect(result).toContain('root'); + expect(result).toHaveLength(3); + }); + + it('should stop cascading when an ancestor has other synced children', () => { + const root = createFolder({ + id: 'root', + name: 'Root', + externalId: 'ext-root', + isSynced: true, + }); + const branch1 = createFolder({ + id: 'branch1', + name: 'Branch 1', + parentFolderId: 'ext-root', + externalId: 'ext-b1', + isSynced: true, + }); + const branch2 = createFolder({ + id: 'branch2', + name: 'Branch 2', + parentFolderId: 'ext-root', + externalId: 'ext-b2', + isSynced: true, + }); + const leaf = createFolder({ + id: 'leaf', + name: 'Leaf', + parentFolderId: 'ext-b1', + externalId: 'ext-leaf', + isSynced: true, + }); + const result = computeFolderIdsForSyncToggle({ + folderId: 'leaf', + allFolders: [root, branch1, branch2, leaf], + isSynced: false, + }); + + expect(result).toContain('leaf'); + expect(result).toContain('branch1'); + expect(result).not.toContain('root'); + expect(result).not.toContain('branch2'); + expect(result).toHaveLength(2); + }); + + it('should handle unsyncing when sibling is already unsynced', () => { + const work = createFolder({ + id: 'work', + name: 'Work', + externalId: 'ext-work', + isSynced: true, + }); + const child1 = createFolder({ + id: 'child1', + name: 'Child 1', + parentFolderId: 'ext-work', + externalId: 'ext-c1', + isSynced: true, + }); + const child2 = createFolder({ + id: 'child2', + name: 'Child 2', + parentFolderId: 'ext-work', + externalId: 'ext-c2', + isSynced: false, + }); + const result = computeFolderIdsForSyncToggle({ + folderId: 'child1', + allFolders: [work, child1, child2], + isSynced: false, + }); + + expect(result).toContain('child1'); + expect(result).toContain('work'); + expect(result).toHaveLength(2); + }); + }); +}); diff --git a/packages/twenty-front/src/modules/settings/accounts/components/message-folders/utils/computeFolderIdsForSyncToggle.ts b/packages/twenty-front/src/modules/settings/accounts/components/message-folders/utils/computeFolderIdsForSyncToggle.ts new file mode 100644 index 00000000000..89e6cef65f4 --- /dev/null +++ b/packages/twenty-front/src/modules/settings/accounts/components/message-folders/utils/computeFolderIdsForSyncToggle.ts @@ -0,0 +1,77 @@ +import { type MessageFolder } from '@/accounts/types/MessageFolder'; + +export const computeFolderIdsForSyncToggle = ({ + folderId, + allFolders, + isSynced, +}: { + folderId: string; + allFolders: MessageFolder[]; + isSynced: boolean; +}): string[] => { + const folderById = new Map(allFolders.map((folder) => [folder.id, folder])); + const folderByExternalId = new Map( + allFolders.map((folder) => [folder.externalId, folder]), + ); + + const collectChildren = (id: string): string[] => { + const folder = folderById.get(id); + const children = folder?.externalId + ? allFolders.filter( + (childFolder) => childFolder.parentFolderId === folder.externalId, + ) + : []; + + return [id, ...children.flatMap((child) => collectChildren(child.id))]; + }; + + const collectParents = (id: string): MessageFolder[] => { + const parents: MessageFolder[] = []; + let current = folderById.get(id); + + while (true) { + if (!current) { + break; + } + + if (!current.parentFolderId) { + break; + } + + const parent = folderByExternalId.get(current.parentFolderId); + + if (!parent) { + break; + } + + parents.push(parent); + current = parent; + } + + return parents; + }; + + const childIds = collectChildren(folderId); + + if (isSynced) { + const parentIds = collectParents(folderId).map((folder) => folder.id); + + return [...new Set([...childIds, ...parentIds])]; + } + + const idsToUnsync = new Set(childIds); + + for (const parent of collectParents(folderId)) { + const children = allFolders.filter( + (folder) => folder.parentFolderId === parent.externalId, + ); + const hasOtherSyncedChild = children.some( + (child) => child.isSynced && !idsToUnsync.has(child.id), + ); + + if (hasOtherSyncedChild) break; + idsToUnsync.add(parent.id); + } + + return [...idsToUnsync]; +}; diff --git a/packages/twenty-front/src/modules/settings/accounts/components/message-folders/utils/countNestedFolders.ts b/packages/twenty-front/src/modules/settings/accounts/components/message-folders/utils/countNestedFolders.ts new file mode 100644 index 00000000000..3b429f172ce --- /dev/null +++ b/packages/twenty-front/src/modules/settings/accounts/components/message-folders/utils/countNestedFolders.ts @@ -0,0 +1,11 @@ +import { type MessageFolderTreeNode } from '@/settings/accounts/components/message-folders/utils/computeMessageFolderTree'; + +export const countNestedFolders = (node: MessageFolderTreeNode): number => { + let count = node.children.length; + + for (const child of node.children) { + count += countNestedFolders(child); + } + + return count; +}; diff --git a/packages/twenty-front/src/modules/settings/accounts/components/message-folders/utils/isFolderTreePartiallySelected.ts b/packages/twenty-front/src/modules/settings/accounts/components/message-folders/utils/isFolderTreePartiallySelected.ts new file mode 100644 index 00000000000..302b8f668f0 --- /dev/null +++ b/packages/twenty-front/src/modules/settings/accounts/components/message-folders/utils/isFolderTreePartiallySelected.ts @@ -0,0 +1,27 @@ +import { type MessageFolderTreeNode } from '@/settings/accounts/components/message-folders/utils/computeMessageFolderTree'; + +export const isFolderTreePartiallySelected = ( + node: MessageFolderTreeNode, +): boolean => { + const nodes: MessageFolderTreeNode[] = [node]; + let hasSynced = false; + let hasUnsynced = false; + + while (nodes.length > 0) { + const current = nodes.pop()!; + + if (current.folder.isSynced) { + hasSynced = true; + } else { + hasUnsynced = true; + } + + if (hasSynced && hasUnsynced) { + return true; + } + + nodes.push(...current.children); + } + + return false; +}; diff --git a/packages/twenty-front/src/modules/settings/accounts/hooks/useUpdateMessageFoldersSyncStatus.ts b/packages/twenty-front/src/modules/settings/accounts/hooks/useUpdateMessageFoldersSyncStatus.ts new file mode 100644 index 00000000000..6680b57a019 --- /dev/null +++ b/packages/twenty-front/src/modules/settings/accounts/hooks/useUpdateMessageFoldersSyncStatus.ts @@ -0,0 +1,34 @@ +import { useCallback } from 'react'; + +import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular'; +import { useUpdateManyRecords } from '@/object-record/hooks/useUpdateManyRecords'; + +type UpdateMessageFoldersSyncStatusArgs = { + messageFolderIds: string[]; + isSynced: boolean; +}; + +export const useUpdateMessageFoldersSyncStatus = () => { + const { updateManyRecords } = useUpdateManyRecords({ + objectNameSingular: CoreObjectNameSingular.MessageFolder, + recordGqlFields: { + id: true, + isSynced: true, + }, + }); + + const updateMessageFoldersSyncStatus = useCallback( + async ({ + messageFolderIds, + isSynced, + }: UpdateMessageFoldersSyncStatusArgs) => { + await updateManyRecords({ + recordIdsToUpdate: messageFolderIds, + updateOneRecordInput: { isSynced }, + }); + }, + [updateManyRecords], + ); + + return { updateMessageFoldersSyncStatus }; +}; diff --git a/packages/twenty-server/src/modules/messaging/message-folder-manager/query-hooks/message-folder-query-hook.module.ts b/packages/twenty-server/src/modules/messaging/message-folder-manager/query-hooks/message-folder-query-hook.module.ts deleted file mode 100644 index a28e074ec4d..00000000000 --- a/packages/twenty-server/src/modules/messaging/message-folder-manager/query-hooks/message-folder-query-hook.module.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { Module } from '@nestjs/common'; - -import { MessageFolderUpdateOnePreQueryHook } from 'src/modules/messaging/message-folder-manager/query-hooks/message-folder-update-one.pre-query.hook'; - -@Module({ - providers: [MessageFolderUpdateOnePreQueryHook], - exports: [MessageFolderUpdateOnePreQueryHook], -}) -export class MessageFolderQueryHookModule {} diff --git a/packages/twenty-server/src/modules/messaging/message-folder-manager/query-hooks/message-folder-update-one.pre-query.hook.ts b/packages/twenty-server/src/modules/messaging/message-folder-manager/query-hooks/message-folder-update-one.pre-query.hook.ts deleted file mode 100644 index 7419f0d624a..00000000000 --- a/packages/twenty-server/src/modules/messaging/message-folder-manager/query-hooks/message-folder-update-one.pre-query.hook.ts +++ /dev/null @@ -1,111 +0,0 @@ -import { msg } from '@lingui/core/macro'; -import { isNumber } from '@sniptt/guards'; -import { assertIsDefinedOrThrow, isDefined } from 'twenty-shared/utils'; - -import { type WorkspacePreQueryHookInstance } from 'src/engine/api/graphql/workspace-query-runner/workspace-query-hook/interfaces/workspace-query-hook.interface'; -import { type UpdateOneResolverArgs } from 'src/engine/api/graphql/workspace-resolver-builder/interfaces/workspace-resolvers-builder.interface'; - -import { WorkspaceQueryHook } from 'src/engine/api/graphql/workspace-query-runner/workspace-query-hook/decorators/workspace-query-hook.decorator'; -import { - WorkspaceQueryRunnerException, - WorkspaceQueryRunnerExceptionCode, -} from 'src/engine/api/graphql/workspace-query-runner/workspace-query-runner.exception'; -import { type AuthContext } from 'src/engine/core-modules/auth/types/auth-context.type'; -import { WorkspaceNotFoundDefaultError } from 'src/engine/core-modules/workspace/workspace.exception'; -import { GlobalWorkspaceOrmManager } from 'src/engine/twenty-orm/global-workspace-datasource/global-workspace-orm.manager'; -import { buildSystemAuthContext } from 'src/engine/twenty-orm/utils/build-system-auth-context.util'; -import { - MessageFolderImportPolicy, - type MessageChannelWorkspaceEntity, -} from 'src/modules/messaging/common/standard-objects/message-channel.workspace-entity'; -import { type MessageFolderWorkspaceEntity } from 'src/modules/messaging/common/standard-objects/message-folder.workspace-entity'; - -@WorkspaceQueryHook(`messageFolder.updateOne`) -export class MessageFolderUpdateOnePreQueryHook - implements WorkspacePreQueryHookInstance -{ - constructor( - private readonly globalWorkspaceOrmManager: GlobalWorkspaceOrmManager, - ) {} - - async execute( - authContext: AuthContext, - _objectName: string, - payload: UpdateOneResolverArgs, - ): Promise> { - const workspace = authContext.workspace; - - assertIsDefinedOrThrow(workspace, WorkspaceNotFoundDefaultError); - - const systemAuthContext = buildSystemAuthContext(workspace.id); - - return this.globalWorkspaceOrmManager.executeInWorkspaceContext( - systemAuthContext, - async () => { - const messageFolderRepository = - await this.globalWorkspaceOrmManager.getRepository( - workspace.id, - 'messageFolder', - ); - - const messageFolder = await messageFolderRepository.findOne({ - where: { id: payload.id }, - }); - - if (!messageFolder) { - throw new WorkspaceQueryRunnerException( - 'Message folder not found', - WorkspaceQueryRunnerExceptionCode.DATA_NOT_FOUND, - { - userFriendlyMessage: msg`Message folder not found`, - }, - ); - } - - if (payload.data.isSynced !== false) { - return payload; - } - - const messageChannelRepository = - await this.globalWorkspaceOrmManager.getRepository( - workspace.id, - 'messageChannel', - ); - - const messageChannel = await messageChannelRepository.findOne({ - where: { id: messageFolder.messageChannelId }, - }); - - if ( - messageChannel?.messageFolderImportPolicy !== - MessageFolderImportPolicy.SELECTED_FOLDERS - ) { - return payload; - } - - const syncedFoldersCount = await messageFolderRepository.count({ - where: { - messageChannelId: messageFolder.messageChannelId, - isSynced: true, - }, - }); - - if ( - isDefined(syncedFoldersCount) && - isNumber(syncedFoldersCount) && - syncedFoldersCount <= 1 - ) { - throw new WorkspaceQueryRunnerException( - 'Cannot unsync the last folder when folder import policy is set to selected folders', - WorkspaceQueryRunnerExceptionCode.INVALID_QUERY_INPUT, - { - userFriendlyMessage: msg`At least one folder must be synced.`, - }, - ); - } - - return payload; - }, - ); - } -} diff --git a/packages/twenty-server/src/modules/messaging/messaging.module.ts b/packages/twenty-server/src/modules/messaging/messaging.module.ts index ed25046ae2d..698f1ef999c 100644 --- a/packages/twenty-server/src/modules/messaging/messaging.module.ts +++ b/packages/twenty-server/src/modules/messaging/messaging.module.ts @@ -2,7 +2,6 @@ import { Module } from '@nestjs/common'; import { MessagingBlocklistManagerModule } from 'src/modules/messaging/blocklist-manager/messaging-blocklist-manager.module'; import { MessagingMessageCleanerModule } from 'src/modules/messaging/message-cleaner/messaging-message-cleaner.module'; -import { MessageFolderQueryHookModule } from 'src/modules/messaging/message-folder-manager/query-hooks/message-folder-query-hook.module'; import { MessagingImportManagerModule } from 'src/modules/messaging/message-import-manager/messaging-import-manager.module'; import { MessageParticipantManagerModule } from 'src/modules/messaging/message-participant-manager/message-participant-manager.module'; import { MessagingMonitoringModule } from 'src/modules/messaging/monitoring/messaging-monitoring.module'; @@ -14,7 +13,6 @@ import { MessagingMonitoringModule } from 'src/modules/messaging/monitoring/mess MessageParticipantManagerModule, MessagingBlocklistManagerModule, MessagingMonitoringModule, - MessageFolderQueryHookModule, ], providers: [], exports: [MessagingImportManagerModule],