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:
+13
-10
@@ -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 && (
|
||||
|
||||
+14
-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} />;
|
||||
};
|
||||
|
||||
+33
-14
@@ -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) {
|
||||
|
||||
+94
@@ -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>
|
||||
);
|
||||
};
|
||||
+136
-75
@@ -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>
|
||||
);
|
||||
|
||||
+410
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
+77
@@ -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];
|
||||
};
|
||||
+11
@@ -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;
|
||||
};
|
||||
+27
@@ -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;
|
||||
};
|
||||
+34
@@ -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 };
|
||||
};
|
||||
Reference in New Issue
Block a user