Child folders followup (#15526)

This commit is contained in:
neo773
2025-11-04 19:34:03 +05:30
committed by GitHub
parent 15a6ca64da
commit e3076328dd
22 changed files with 736 additions and 121 deletions
@@ -5,5 +5,7 @@ export type MessageFolder = {
isSentFolder: boolean;
isSynced: boolean;
messageChannelId: string;
parentFolderId: string | null;
externalId: string | null;
__typename: 'MessageFolder';
};
@@ -5,7 +5,8 @@ import { useGenerateDepthRecordGqlFieldsFromObject } from '@/object-record/graph
import { useFindOneRecord } from '@/object-record/hooks/useFindOneRecord';
import { useUpdateOneRecord } from '@/object-record/hooks/useUpdateOneRecord';
import { SettingsMessageFoldersEmptyStateCard } from '@/settings/accounts/components/message-folders/SettingsMessageFoldersEmptyStateCard';
import { SettingsMessageFoldersTableRow } from '@/settings/accounts/components/message-folders/SettingsMessageFoldersTableRow';
import { SettingsMessageFoldersTreeItem } from '@/settings/accounts/components/message-folders/SettingsMessageFoldersTreeItem';
import { computeMessageFolderTree } from '@/settings/accounts/components/message-folders/utils/computeMessageFolderTree';
import { settingsAccountsSelectedMessageChannelState } from '@/settings/accounts/states/settingsAccountsSelectedMessageChannelState';
import { SettingsTextInput } from '@/ui/input/components/SettingsTextInput';
import { Table } from '@/ui/layout/table/components/Table';
@@ -18,8 +19,14 @@ import { Label } from 'twenty-ui/display';
import { Checkbox, CheckboxSize } from 'twenty-ui/input';
import { Section } from 'twenty-ui/layout';
const StyledTableRows = styled.div`
max-height: 300px;
const StyledTreeList = styled.ul`
list-style: none;
margin: 0;
padding: 0;
`;
const StyledFoldersContainer = styled.div`
max-height: 400px;
overflow-y: auto;
padding-bottom: ${({ theme }) => theme.spacing(2)};
padding-top: ${({ theme }) => theme.spacing(2)};
@@ -87,6 +94,10 @@ export const SettingsAccountsMessageFoldersCard = () => {
);
}, [messageFolders, search]);
const folderTreeNodes = useMemo(() => {
return computeMessageFolderTree(filteredMessageFolders);
}, [filteredMessageFolders]);
const allFoldersToggled = useMemo(() => {
return filteredMessageFolders.every((folder) => folder.isSynced);
}, [filteredMessageFolders]);
@@ -142,15 +153,17 @@ export const SettingsAccountsMessageFoldersCard = () => {
</StyledCheckboxCell>
</StyledSectionHeader>
<StyledTableRows>
{filteredMessageFolders?.map((folder) => (
<SettingsMessageFoldersTableRow
key={folder.id}
folder={folder}
onSyncToggle={() => handleToggleFolder(folder)}
/>
))}
</StyledTableRows>
<StyledFoldersContainer>
<StyledTreeList>
{folderTreeNodes.map((rootFolder) => (
<SettingsMessageFoldersTreeItem
key={rootFolder.folder.id}
folderTreeNode={rootFolder}
onToggleFolder={handleToggleFolder}
/>
))}
</StyledTreeList>
</StyledFoldersContainer>
</Table>
</Section>
);
@@ -1,50 +0,0 @@
import { type MessageFolder } from '@/accounts/types/MessageFolder';
import { SettingsAccountsMessageFolderIcon } from '@/settings/accounts/components/message-folders/SettingsAccountsMessageFolderIcon';
import { formatFolderName } from '@/settings/accounts/components/message-folders/utils/formatFolderName.util';
import { TableCell } from '@/ui/layout/table/components/TableCell';
import { TableRow } from '@/ui/layout/table/components/TableRow';
import styled from '@emotion/styled';
import { Checkbox, CheckboxSize } from 'twenty-ui/input';
const StyledFolderNameCell = styled.div`
align-items: center;
display: flex;
gap: ${({ theme }) => theme.spacing(1)};
color: ${({ theme }) => theme.font.color.primary};
`;
const StyledTableRow = styled(TableRow)``;
const StyledCheckboxCell = styled(TableCell)`
display: flex;
justify-content: flex-end;
`;
type SettingsMessageFoldersTableRowProps = {
folder: MessageFolder;
onSyncToggle: () => void;
};
export const SettingsMessageFoldersTableRow = ({
folder,
onSyncToggle,
}: SettingsMessageFoldersTableRowProps) => {
return (
<StyledTableRow gridAutoColumns="1fr 120px">
<TableCell>
<StyledFolderNameCell>
<SettingsAccountsMessageFolderIcon folder={folder} />
{formatFolderName(folder.name)}
</StyledFolderNameCell>
</TableCell>
<StyledCheckboxCell>
<Checkbox
checked={folder.isSynced}
onChange={onSyncToggle}
size={CheckboxSize.Small}
/>
</StyledCheckboxCell>
</StyledTableRow>
);
};
@@ -0,0 +1,166 @@
import { type MessageFolder } from '@/accounts/types/MessageFolder';
import { SettingsAccountsMessageFolderIcon } from '@/settings/accounts/components/message-folders/SettingsAccountsMessageFolderIcon';
import { type MessageFolderTreeNode } from '@/settings/accounts/components/message-folders/utils/computeMessageFolderTree';
import { formatFolderName } from '@/settings/accounts/components/message-folders/utils/formatFolderName';
import styled from '@emotion/styled';
import { useState } from 'react';
import { IconChevronRight } from 'twenty-ui/display';
import { Checkbox, CheckboxSize } from 'twenty-ui/input';
type SettingsMessageFoldersTreeItemProps = {
folderTreeNode: MessageFolderTreeNode;
onToggleFolder: (folder: MessageFolder) => void;
depth?: number;
};
const StyledTreeItem = styled.li<{ hasChildren: boolean; depth: number }>`
position: relative;
margin-left: ${({ hasChildren, depth, theme }) =>
!hasChildren && depth > 0 ? theme.spacing(3) : 0};
&:not(:last-child) {
margin-bottom: ${({ theme }) => theme.spacing(1)};
}
`;
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)};
padding: 0;
padding-left: ${({ theme }) => theme.spacing(2)};
`;
const StyledTreeItemContent = styled.div`
display: flex;
align-items: center;
gap: ${({ theme }) => theme.spacing(2)};
padding: ${({ theme }) => theme.spacing(1)};
border-radius: ${({ theme }) => theme.border.radius.sm};
cursor: pointer;
transition: background-color
${({ theme }) => theme.animation.duration.instant}s;
&: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;
`;
const StyledFolderInfo = styled.div`
display: flex;
align-items: center;
gap: ${({ theme }) => theme.spacing(2)};
flex: 1;
min-width: 0;
`;
const StyledFolderName = styled.span`
color: ${({ theme }) => theme.font.color.primary};
font-size: ${({ theme }) => theme.font.size.md};
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
`;
const StyledCheckboxWrapper = styled.div`
align-items: center;
display: flex;
`;
export const SettingsMessageFoldersTreeItem = ({
folderTreeNode,
onToggleFolder,
depth = 0,
}: SettingsMessageFoldersTreeItemProps) => {
const [isExpanded, setIsExpanded] = useState(true);
const { folder, children, hasChildren } = folderTreeNode;
const handleExpandToggle = (e: React.MouseEvent) => {
e.stopPropagation();
setIsExpanded(!isExpanded);
};
const handleRowClick = () => {
if (hasChildren) {
setIsExpanded(!isExpanded);
}
};
const handleCheckboxClick = (e: React.MouseEvent) => {
e.stopPropagation();
};
return (
<StyledTreeItem hasChildren={hasChildren} depth={depth}>
<StyledTreeItemContent onClick={handleRowClick}>
{hasChildren && (
<StyledExpandButton
isExpanded={isExpanded}
onClick={handleExpandToggle}
aria-label={isExpanded ? 'Collapse folder' : 'Expand folder'}
>
<IconChevronRight size={16} />
</StyledExpandButton>
)}
<StyledFolderContent>
<StyledFolderInfo>
<SettingsAccountsMessageFolderIcon folder={folder} />
<StyledFolderName>{formatFolderName(folder.name)}</StyledFolderName>
</StyledFolderInfo>
<StyledCheckboxWrapper onClick={handleCheckboxClick}>
<Checkbox
checked={folder.isSynced}
onChange={() => onToggleFolder(folder)}
size={CheckboxSize.Small}
/>
</StyledCheckboxWrapper>
</StyledFolderContent>
</StyledTreeItemContent>
{hasChildren && isExpanded && (
<StyledNestedList>
{children.map((child) => (
<SettingsMessageFoldersTreeItem
key={child.folder.id}
folderTreeNode={child}
onToggleFolder={onToggleFolder}
depth={depth + 1}
/>
))}
</StyledNestedList>
)}
</StyledTreeItem>
);
};
@@ -0,0 +1,149 @@
import { type MessageFolder } from '@/accounts/types/MessageFolder';
import { computeMessageFolderTree } from '@/settings/accounts/components/message-folders/utils/computeMessageFolderTree';
describe('computeMessageFolderTree', () => {
const createFolder = (
id: string,
name: string,
parentFolderId: string | null = null,
externalId: string | null = null,
): MessageFolder => ({
id,
name,
parentFolderId,
externalId: externalId || id,
isSentFolder: false,
isSynced: false,
messageChannelId: '20202020-1c25-4d02-bf25-6aeccf7ea419',
__typename: 'MessageFolder',
syncCursor: '',
});
it('should return empty array for empty input', () => {
expect(computeMessageFolderTree([])).toEqual([]);
});
it('should return single root folder without children', () => {
const folder = createFolder(
'20202020-7cf8-40bc-a681-b80b771449b1',
'Inbox',
null,
);
const result = computeMessageFolderTree([folder]);
expect(result).toHaveLength(1);
expect(result[0].folder).toEqual(folder);
expect(result[0].hasChildren).toBe(false);
expect(result[0].children).toEqual([]);
});
it('should organize parent-child relationship', () => {
const parent = createFolder(
'20202020-7cf8-40bc-a681-b80b771449b2',
'Work',
null,
'ext-parent-id',
);
const child = createFolder(
'20202020-7cf8-40bc-a681-b80b771449b3',
'Projects',
'ext-parent-id',
'ext-child-id',
);
const result = computeMessageFolderTree([parent, child]);
expect(result).toHaveLength(1);
expect(result[0].folder.name).toBe('Work');
expect(result[0].hasChildren).toBe(true);
expect(result[0].children).toHaveLength(1);
expect(result[0].children[0].folder.name).toBe('Projects');
});
it('should handle multiple levels of nesting', () => {
const grandparent = createFolder(
'20202020-7cf8-40bc-a681-b80b771449b4',
'Work',
null,
'ext-grandparent-id',
);
const parent = createFolder(
'20202020-7cf8-40bc-a681-b80b771449b5',
'Projects',
'ext-grandparent-id',
'ext-parent-id',
);
const child = createFolder(
'20202020-7cf8-40bc-a681-b80b771449b6',
'2024',
'ext-parent-id',
'ext-child-id',
);
const result = computeMessageFolderTree([grandparent, parent, child]);
expect(result[0].folder.name).toBe('Work');
expect(result[0].children[0].folder.name).toBe('Projects');
expect(result[0].children[0].children[0].folder.name).toBe('2024');
});
it('should sort root folders alphabetically', () => {
const folders = [
createFolder('20202020-7cf8-40bc-a681-b80b771449b7', 'Sent', null),
createFolder('20202020-7cf8-40bc-a681-b80b771449b8', 'Inbox', null),
createFolder('20202020-7cf8-40bc-a681-b80b771449b9', 'Drafts', null),
];
const result = computeMessageFolderTree(folders);
expect(result[0].folder.name).toBe('Drafts');
expect(result[1].folder.name).toBe('Inbox');
expect(result[2].folder.name).toBe('Sent');
});
it('should sort children alphabetically', () => {
const parent = createFolder(
'20202020-7cf8-40bc-a681-b80b771449c0',
'Work',
null,
'ext-parent-id',
);
const child1 = createFolder(
'20202020-7cf8-40bc-a681-b80b771449c1',
'Zebra',
'ext-parent-id',
'ext-child1-id',
);
const child2 = createFolder(
'20202020-7cf8-40bc-a681-b80b771449c2',
'Apple',
'ext-parent-id',
'ext-child2-id',
);
const result = computeMessageFolderTree([parent, child1, child2]);
expect(result[0].children[0].folder.name).toBe('Apple');
expect(result[0].children[1].folder.name).toBe('Zebra');
});
it('should treat orphaned folders as root folders', () => {
const orphan = createFolder('o', 'Orphan', 'non-existent', 'ext-o');
const normal = createFolder('n', 'Normal', null, 'ext-n');
const result = computeMessageFolderTree([orphan, normal]);
expect(result).toHaveLength(2);
expect(result.map((r) => r.folder.name).sort()).toEqual([
'Normal',
'Orphan',
]);
});
it('should handle Gmail-style nested labels', () => {
const work = createFolder('1', 'Work', null, 'ext-work');
const projects = createFolder('2', 'Projects', 'ext-work', 'ext-proj');
const clients = createFolder('3', 'Clients', 'ext-work', 'ext-cli');
const result = computeMessageFolderTree([work, projects, clients]);
expect(result).toHaveLength(1);
expect(result[0].children).toHaveLength(2);
expect(result[0].children[0].folder.name).toBe('Clients');
expect(result[0].children[1].folder.name).toBe('Projects');
});
});
@@ -0,0 +1,55 @@
import { type MessageFolder } from '@/accounts/types/MessageFolder';
import { isDefined } from 'twenty-shared/utils';
export type MessageFolderTreeNode = {
folder: MessageFolder;
children: MessageFolderTreeNode[];
hasChildren: boolean;
};
export const computeMessageFolderTree = (
folders: MessageFolder[],
): MessageFolderTreeNode[] => {
const folderByExternalIdMap = new Map<string, MessageFolder>();
const childrenMap = new Map<string, MessageFolder[]>();
folders.forEach((folder) => {
if (isDefined(folder.externalId)) {
folderByExternalIdMap.set(folder.externalId, folder);
}
});
folders.forEach((folder) => {
if (isDefined(folder.parentFolderId)) {
const parent = folderByExternalIdMap.get(folder.parentFolderId);
if (isDefined(parent)) {
const siblings = childrenMap.get(parent.id) || [];
siblings.push(folder);
childrenMap.set(parent.id, siblings);
}
}
});
const buildTreeNode = (folder: MessageFolder): MessageFolderTreeNode => {
const children = childrenMap.get(folder.id) || [];
const sortedChildren = [...children].sort((a, b) =>
a.name.localeCompare(b.name),
);
return {
folder,
children: sortedChildren.map((child) => buildTreeNode(child)),
hasChildren: children.length > 0,
};
};
const rootFolders = folders.filter((folder) => {
if (!folder.parentFolderId) return true;
return !folderByExternalIdMap.has(folder.parentFolderId);
});
rootFolders.sort((a, b) => a.name.localeCompare(b.name));
return rootFolders.map((folder) => buildTreeNode(folder));
};