From 1cdede89deaeee16b708bcd79f2e28e5fd02e4b0 Mon Sep 17 00:00:00 2001 From: RISHAV DEWAN Date: Wed, 24 Jun 2026 20:02:18 +0530 Subject: [PATCH] fix(email-settings): enable independent message folder and subfolder selection (#21853) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Fixes #21840 Currently, selecting a root folder in **Settings → Accounts → Emails → Folders** automatically selects all of its subfolders, and selecting a subfolder automatically selects all of its ancestor folders. Users have no granular control over individual folder sync. This PR replaces the cascade selection logic with fully independent per-node selection, matching standard tree-select UX patterns used in file explorers and permission trees. ## Changes ### Bug Fix - **`computeFolderIdsForSyncToggle.ts`**: Removed `collectChildren` and `collectParents` cascade helpers. The function now returns only the toggled folder's ID, enabling fully independent selection. - **`SettingsAccountsMessageFoldersCard.tsx`**: Updated call site to match simplified function signature (removed unused `allFolders` and `isSynced` args). ### Tests - **`computeFolderIdsForSyncToggle.test.ts`**: Rewrote tests to reflect new per-node behavior. Removed tests asserting old cascade behavior; replaced with tests verifying only the toggled folder is affected. - **`isFolderTreePartiallySelected.test.ts`** *(new)*: Added 9 tests for `isFolderTreePartiallySelected`, which is now the primary mechanism driving the indeterminate checkbox state on parent folders. ## Behavior Before / After | Action | Before | After | |--------|--------|-------| | Check a root folder | Checks root + all subfolders | Checks root only | | Check a subfolder | Checks subfolder + all ancestors | Checks subfolder only | | Uncheck a root folder | Unchecks root + all subfolders | Unchecks root only | | Parent with partial children | No indeterminate state (broken) | Shows `–` indeterminate correctly | ## What Was Already Correct The indeterminate checkbox UI was already fully implemented: - `isFolderTreePartiallySelected` correctly detects mixed sync states in subtrees - `SettingsMessageFoldersTreeItem` already passes `indeterminate` to the `Checkbox` component - The `Checkbox` component in `twenty-ui` already supports the `indeterminate` prop Only the toggle cascade logic needed fixing. ## Testing ```bash # Unit tests cd packages/twenty-front && yarn jest --testPathPattern="computeFolderIdsForSyncToggle|isFolderTreePartiallySelected" # Lint npx nx lint:diff-with-main twenty-front # Type check npx nx typecheck twenty-front Review in cubic --------- Co-authored-by: Charles Bochet Co-authored-by: neo773 <62795688+neo773@users.noreply.github.com> Co-authored-by: neo773 --- .../SettingsAccountsMessageFoldersCard.tsx | 31 +- .../SettingsMessageFoldersTreeItem.tsx | 4 +- .../computeFolderIdsForSyncToggle.test.ts | 413 ------------------ .../computeToggleAllFoldersState.test.ts | 71 +++ .../isFolderTreePartiallySelected.test.ts | 102 +++++ .../utils/computeFolderIdsForSyncToggle.ts | 77 ---- .../utils/computeToggleAllFoldersState.ts | 12 + 7 files changed, 199 insertions(+), 511 deletions(-) delete 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/__tests__/computeToggleAllFoldersState.test.ts create mode 100644 packages/twenty-front/src/modules/settings/accounts/components/message-folders/utils/__tests__/isFolderTreePartiallySelected.test.ts delete 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/computeToggleAllFoldersState.ts 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 17f2f06be6..ff144dd1db 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 @@ -2,8 +2,8 @@ import { type MessageFolder } from '@/accounts/types/MessageFolder'; 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 { computeToggleAllFoldersState } from '@/settings/accounts/components/message-folders/utils/computeToggleAllFoldersState'; import { useMyMessageFolders } from '@/settings/accounts/hooks/useMyMessageFolders'; import { useUpdateMessageFoldersSyncStatus } from '@/settings/accounts/hooks/useUpdateMessageFoldersSyncStatus'; import { settingsAccountsSelectedMessageChannelState } from '@/settings/accounts/states/settingsAccountsSelectedMessageChannelState'; @@ -86,21 +86,17 @@ export const SettingsAccountsMessageFoldersCard = () => { return computeMessageFolderTree(filteredMessageFolders); }, [filteredMessageFolders]); - const allFoldersToggled = useMemo(() => { - return filteredMessageFolders.every((folder) => folder.isSynced); - }, [filteredMessageFolders]); + const { allSynced, messageFolderIds, targetSyncState } = useMemo( + () => computeToggleAllFoldersState(messageFolders), + [messageFolders], + ); - const handleToggleAllFolders = async ( - messageFoldersToToggle: MessageFolder[], - ) => { - if (messageFoldersToToggle.length === 0) return; - - const allSynced = messageFoldersToToggle.every((folder) => folder.isSynced); - const targetSyncState = !allSynced; + const handleToggleAllFolders = async () => { + if (messageFolderIds.length === 0) return; try { await updateMessageFoldersSyncStatus({ - messageFolderIds: messageFoldersToToggle.map((folder) => folder.id), + messageFolderIds, isSynced: targetSyncState, }); } catch (error) { @@ -112,15 +108,10 @@ export const SettingsAccountsMessageFoldersCard = () => { const handleToggleFolder = async (folderToToggle: MessageFolder) => { const isSynced = !folderToToggle.isSynced; - const folderIdsToToggle = computeFolderIdsForSyncToggle({ - folderId: folderToToggle.id, - allFolders: messageFolders, - isSynced, - }); try { await updateMessageFoldersSyncStatus({ - messageFolderIds: folderIdsToToggle, + messageFolderIds: [folderToToggle.id], isSynced, }); } catch (error) { @@ -166,8 +157,8 @@ export const SettingsAccountsMessageFoldersCard = () => { padding={`0 ${themeCssVariables.spacing[1]} 0 ${themeCssVariables.spacing[2]}`} > handleToggleAllFolders(messageFolders)} + checked={allSynced} + onChange={handleToggleAllFolders} size={CheckboxSize.Small} /> 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 f39ea373da..8baa91c6a5 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 @@ -137,7 +137,9 @@ export const SettingsMessageFoldersTreeItem = ({ const { children, folder, hasChildren } = folderTreeNode; const childCount = hasChildren ? countNestedFolders(folderTreeNode) : 0; const isIndeterminate = - hasChildren && isFolderTreePartiallySelected(folderTreeNode); + hasChildren && + !folder.isSynced && + isFolderTreePartiallySelected(folderTreeNode); const handleExpandToggle = (e: React.MouseEvent) => { e.stopPropagation(); 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 deleted file mode 100644 index ead8f67432..0000000000 --- a/packages/twenty-front/src/modules/settings/accounts/components/message-folders/utils/__tests__/computeFolderIdsForSyncToggle.test.ts +++ /dev/null @@ -1,413 +0,0 @@ -import { type MessageFolder } from '@/accounts/types/MessageFolder'; -import { computeFolderIdsForSyncToggle } from '@/settings/accounts/components/message-folders/utils/computeFolderIdsForSyncToggle'; -import { MessageFolderPendingSyncAction } from 'twenty-shared/types'; - -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 => ({ - __typename: 'MessageFolder', - id, - name, - parentFolderId, - externalId: externalId || id, - isSentFolder: false, - isSynced, - pendingSyncAction: MessageFolderPendingSyncAction.NONE, - messageChannelId: 'channel-1', - createdAt: '2026-01-01T00:00:00Z', - updatedAt: '2026-01-01T00:00:00Z', - }); - - 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/__tests__/computeToggleAllFoldersState.test.ts b/packages/twenty-front/src/modules/settings/accounts/components/message-folders/utils/__tests__/computeToggleAllFoldersState.test.ts new file mode 100644 index 0000000000..cea4001844 --- /dev/null +++ b/packages/twenty-front/src/modules/settings/accounts/components/message-folders/utils/__tests__/computeToggleAllFoldersState.test.ts @@ -0,0 +1,71 @@ +import { type MessageFolder } from '@/accounts/types/MessageFolder'; +import { computeToggleAllFoldersState } from '@/settings/accounts/components/message-folders/utils/computeToggleAllFoldersState'; +import { MessageFolderPendingSyncAction } from 'twenty-shared/types'; + +const createFolder = (id: string, isSynced: boolean): MessageFolder => ({ + __typename: 'MessageFolder', + id, + name: id, + isSynced, + isSentFolder: false, + parentFolderId: null, + externalId: id, + pendingSyncAction: MessageFolderPendingSyncAction.NONE, + messageChannelId: 'channel-1', + createdAt: '2026-01-01T00:00:00Z', + updatedAt: '2026-01-01T00:00:00Z', +}); + +describe('computeToggleAllFoldersState', () => { + it('should target syncing when no folder is synced', () => { + const result = computeToggleAllFoldersState([ + createFolder('a', false), + createFolder('b', false), + ]); + + expect(result.allSynced).toBe(false); + expect(result.targetSyncState).toBe(true); + expect(result.messageFolderIds).toEqual(['a', 'b']); + }); + + it('should target unsyncing when every folder is synced', () => { + const result = computeToggleAllFoldersState([ + createFolder('a', true), + createFolder('b', true), + ]); + + expect(result.allSynced).toBe(true); + expect(result.targetSyncState).toBe(false); + }); + + it('should target syncing when only some folders are synced', () => { + const result = computeToggleAllFoldersState([ + createFolder('a', true), + createFolder('b', false), + ]); + + expect(result.allSynced).toBe(false); + expect(result.targetSyncState).toBe(true); + }); + + // Bug #21840 follow-up: "Toggle all folders" reflects the whole account, so a + // single synced folder among unsynced ones must not report "all synced" — even + // when a search filter hides the unsynced ones from view. + it('should report not-all-synced when one folder is synced among unsynced ones', () => { + const result = computeToggleAllFoldersState([ + createFolder('archive', true), + createFolder('clients', false), + ]); + + expect(result.allSynced).toBe(false); + expect(result.messageFolderIds).toEqual(['archive', 'clients']); + }); + + it('should not report all synced for an empty set', () => { + const result = computeToggleAllFoldersState([]); + + expect(result.allSynced).toBe(false); + expect(result.messageFolderIds).toEqual([]); + expect(result.targetSyncState).toBe(true); + }); +}); diff --git a/packages/twenty-front/src/modules/settings/accounts/components/message-folders/utils/__tests__/isFolderTreePartiallySelected.test.ts b/packages/twenty-front/src/modules/settings/accounts/components/message-folders/utils/__tests__/isFolderTreePartiallySelected.test.ts new file mode 100644 index 0000000000..40d5be4f58 --- /dev/null +++ b/packages/twenty-front/src/modules/settings/accounts/components/message-folders/utils/__tests__/isFolderTreePartiallySelected.test.ts @@ -0,0 +1,102 @@ +import { type MessageFolder } from '@/accounts/types/MessageFolder'; +import { type MessageFolderTreeNode } from '@/settings/accounts/components/message-folders/utils/computeMessageFolderTree'; +import { isFolderTreePartiallySelected } from '@/settings/accounts/components/message-folders/utils/isFolderTreePartiallySelected'; +import { MessageFolderPendingSyncAction } from 'twenty-shared/types'; + +const createFolder = (id: string, isSynced: boolean): MessageFolder => ({ + __typename: 'MessageFolder', + id, + name: id, + isSynced, + isSentFolder: false, + parentFolderId: null, + externalId: id, + pendingSyncAction: MessageFolderPendingSyncAction.NONE, + messageChannelId: 'channel-1', + createdAt: '2026-01-01T00:00:00Z', + updatedAt: '2026-01-01T00:00:00Z', +}); + +const leaf = (id: string, isSynced: boolean): MessageFolderTreeNode => ({ + folder: createFolder(id, isSynced), + children: [], + hasChildren: false, +}); + +const node = ( + id: string, + isSynced: boolean, + children: MessageFolderTreeNode[], +): MessageFolderTreeNode => ({ + folder: createFolder(id, isSynced), + children, + hasChildren: children.length > 0, +}); + +describe('isFolderTreePartiallySelected', () => { + it('should return false when single node is synced', () => { + expect(isFolderTreePartiallySelected(leaf('a', true))).toBe(false); + }); + + it('should return false when single node is unsynced', () => { + expect(isFolderTreePartiallySelected(leaf('a', false))).toBe(false); + }); + + it('should return false when all nodes are synced', () => { + const tree = node('parent', true, [ + leaf('child-a', true), + leaf('child-b', true), + ]); + + expect(isFolderTreePartiallySelected(tree)).toBe(false); + }); + + it('should return false when all nodes are unsynced', () => { + const tree = node('parent', false, [ + leaf('child-a', false), + leaf('child-b', false), + ]); + + expect(isFolderTreePartiallySelected(tree)).toBe(false); + }); + + it('should return true when some children are synced and some are not', () => { + const tree = node('parent', false, [ + leaf('child-a', true), + leaf('child-b', false), + ]); + + expect(isFolderTreePartiallySelected(tree)).toBe(true); + }); + + it('should return true when parent is unsynced but a child is synced', () => { + const tree = node('parent', false, [leaf('child-a', true)]); + + expect(isFolderTreePartiallySelected(tree)).toBe(true); + }); + + it('should return true when parent is synced but a child is unsynced', () => { + const tree = node('parent', true, [leaf('child-a', false)]); + + expect(isFolderTreePartiallySelected(tree)).toBe(true); + }); + + it('should detect mixed state in deeply nested tree', () => { + const tree = node('root', false, [ + node('middle', false, [ + leaf('leaf-synced', true), + leaf('leaf-unsynced', false), + ]), + ]); + + expect(isFolderTreePartiallySelected(tree)).toBe(true); + }); + + it('should return false for uniformly synced deep tree', () => { + const tree = node('root', true, [ + node('middle', true, [leaf('leaf', true)]), + ]); + + expect(isFolderTreePartiallySelected(tree)).toBe(false); + }); +}); 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 deleted file mode 100644 index c21f38d9a6..0000000000 --- a/packages/twenty-front/src/modules/settings/accounts/components/message-folders/utils/computeFolderIdsForSyncToggle.ts +++ /dev/null @@ -1,77 +0,0 @@ -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 - ? 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/computeToggleAllFoldersState.ts b/packages/twenty-front/src/modules/settings/accounts/components/message-folders/utils/computeToggleAllFoldersState.ts new file mode 100644 index 0000000000..2bbc9d0760 --- /dev/null +++ b/packages/twenty-front/src/modules/settings/accounts/components/message-folders/utils/computeToggleAllFoldersState.ts @@ -0,0 +1,12 @@ +import { type MessageFolder } from '@/accounts/types/MessageFolder'; + +export const computeToggleAllFoldersState = (folders: MessageFolder[]) => { + const allSynced = + folders.length > 0 && folders.every((folder) => folder.isSynced); + + return { + allSynced, + messageFolderIds: folders.map((folder) => folder.id), + targetSyncState: !allSynced, + }; +};