fix(email-settings): enable independent message folder and subfolder selection (#21853)

## 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

<!-- This is an auto-generated description by cubic. -->
<a href="https://cubic.dev/pr/twentyhq/twenty/pull/21853?utm_source=github" target="_blank" rel="noopener noreferrer" data-no-image-dialog="true"><picture><source media="(prefers-color-scheme: dark)" srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source media="(prefers-color-scheme: light)" srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img alt="Review in cubic" src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->

---------

Co-authored-by: Charles Bochet <charles@twenty.com>
Co-authored-by: neo773 <62795688+neo773@users.noreply.github.com>
Co-authored-by: neo773 <neo773@protonmail.com>
This commit is contained in:
RISHAV DEWAN
2026-06-24 20:02:18 +05:30
committed by GitHub
parent 90acecfbd9
commit 1cdede89de
7 changed files with 199 additions and 511 deletions
@@ -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]}`}
>
<Checkbox
checked={allFoldersToggled}
onChange={() => handleToggleAllFolders(messageFolders)}
checked={allSynced}
onChange={handleToggleAllFolders}
size={CheckboxSize.Small}
/>
</TableCell>
@@ -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();
@@ -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);
});
});
});
@@ -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);
});
});
@@ -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);
});
});
@@ -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];
};
@@ -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,
};
};