Child folders followup (#15526)
This commit is contained in:
@@ -5,5 +5,7 @@ export type MessageFolder = {
|
||||
isSentFolder: boolean;
|
||||
isSynced: boolean;
|
||||
messageChannelId: string;
|
||||
parentFolderId: string | null;
|
||||
externalId: string | null;
|
||||
__typename: 'MessageFolder';
|
||||
};
|
||||
|
||||
+25
-12
@@ -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>
|
||||
);
|
||||
|
||||
-50
@@ -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>
|
||||
);
|
||||
};
|
||||
+166
@@ -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>
|
||||
);
|
||||
};
|
||||
+149
@@ -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');
|
||||
});
|
||||
});
|
||||
+55
@@ -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));
|
||||
};
|
||||
Reference in New Issue
Block a user