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:
+11
-20
@@ -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>
|
||||
|
||||
+3
-1
@@ -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();
|
||||
|
||||
-413
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
+71
@@ -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);
|
||||
});
|
||||
});
|
||||
+102
@@ -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);
|
||||
});
|
||||
});
|
||||
-77
@@ -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];
|
||||
};
|
||||
+12
@@ -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,
|
||||
};
|
||||
};
|
||||
Reference in New Issue
Block a user