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
This commit is contained in:
neo773
2025-12-19 21:52:59 +05:30
committed by GitHub
parent a19b9e1cb8
commit 024ee152a0
20 changed files with 1490 additions and 222 deletions
@@ -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 <SettingsNewAccountSection />;
}
const handleTabChange = (tabId: string) => {
const selectedMessageChannel = messageChannels.find(
(channel) => channel.id === tabId,
);
if (isDefined(selectedMessageChannel)) {
setSelectedMessageChannel(selectedMessageChannel);
}
};
return (
<>
{tabs.length > 1 && (
@@ -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 (
<IconFolderRoot size={theme.icon.size.md} stroke={theme.icon.stroke.sm} />
);
}
return <IconFolder size={theme.icon.size.md} stroke={theme.icon.stroke.sm} />;
};
@@ -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<MessageFolder>({
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) {
@@ -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 (
<StyledBreadcrumbOverlay depth={depth}>
{parentsIsLastList.map((parentIsLast, index) => (
<StyledAncestorLine
key={index}
index={index}
showLine={!parentIsLast}
/>
))}
<StyledBreadcrumbConnector depth={depth}>
<StyledVerticalLineTop />
<StyledRoundedCorner />
{showVerticalBar && <StyledVerticalLineBottom />}
</StyledBreadcrumbConnector>
</StyledBreadcrumbOverlay>
);
};
@@ -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 (
<StyledTreeItem hasChildren={hasChildren} depth={depth}>
<StyledTreeItemContent onClick={handleRowClick}>
{hasChildren && (
<StyledExpandButton
isExpanded={isExpanded}
onClick={handleExpandToggle}
aria-label={isExpanded ? t`Collapse folder` : t`Expand folder`}
>
<IconChevronRight size={16} />
</StyledExpandButton>
<StyledTreeItem>
<StyledTreeItemContent depth={depth} onClick={handleRowClick}>
{depth > 0 && (
<SettingsMessageFoldersBreadcrumb
depth={depth}
isLast={isLast}
parentsIsLastList={parentsIsLastList}
/>
)}
<StyledFolderContent>
<StyledFolderInfo>
<SettingsAccountsMessageFolderIcon folder={folder} />
<SettingsAccountsMessageFolderIcon
folder={folder}
isChildFolder={depth > 0}
/>
<StyledFolderName>{formatFolderName(folder.name)}</StyledFolderName>
</StyledFolderInfo>
<StyledCheckboxWrapper onClick={handleCheckboxClick}>
<Checkbox
checked={folder.isSynced}
onChange={() => onToggleFolder(folder)}
size={CheckboxSize.Small}
/>
</StyledCheckboxWrapper>
<StyledRightSection>
{hasChildren && (
<>
<StyledChildCount>{childCount}</StyledChildCount>
<StyledExpandButton
aria-label={
isExpanded ? t`Collapse folder` : t`Expand folder`
}
onClick={handleExpandToggle}
>
{isExpanded ? (
<IconChevronUp size={16} />
) : (
<IconChevronDown size={16} />
)}
</StyledExpandButton>
</>
)}
<StyledCheckboxWrapper onClick={handleCheckboxClick}>
<Checkbox
checked={folder.isSynced}
indeterminate={isIndeterminate}
onChange={() => onToggleFolder(folder)}
size={CheckboxSize.Small}
/>
</StyledCheckboxWrapper>
</StyledRightSection>
</StyledFolderContent>
</StyledTreeItemContent>
{hasChildren && isExpanded && (
<StyledNestedList>
{children.map((child) => (
<SettingsMessageFoldersTreeItem
key={child.folder.id}
folderTreeNode={child}
onToggleFolder={onToggleFolder}
depth={depth + 1}
/>
))}
</StyledNestedList>
{hasChildren && (
<StyledCollapsibleWrapper isExpanded={isExpanded}>
<StyledCollapsibleContent>
<StyledNestedList>
{children.map((child, index) => (
<SettingsMessageFoldersTreeItem
key={child.folder.id}
depth={depth + 1}
folderTreeNode={child}
isLast={index === children.length - 1}
onToggleFolder={onToggleFolder}
parentsIsLastList={childParentsIsLastList}
/>
))}
</StyledNestedList>
</StyledCollapsibleContent>
</StyledCollapsibleWrapper>
)}
</StyledTreeItem>
);
@@ -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);
});
});
});
@@ -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];
};
@@ -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;
};
@@ -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;
};
@@ -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 };
};