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
@@ -0,0 +1,29 @@
import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { getUpdateManyRecordsMutationResponseField } from '@/object-record/utils/getUpdateManyRecordsMutationResponseField';
import { gql } from '@apollo/client';
import { capitalize } from 'twenty-shared/utils';
export const generateUpdateManyRecordsMutation = ({
objectMetadataItem,
}: {
objectMetadataItem: ObjectMetadataItem;
}) => {
const capitalizedObjectNameSingular = capitalize(
objectMetadataItem.nameSingular,
);
const capitalizedObjectNamePlural = capitalize(objectMetadataItem.namePlural);
const mutationResponseField = getUpdateManyRecordsMutationResponseField(
objectMetadataItem.namePlural,
);
const updateManyRecordsMutation = gql`
mutation UpdateMany${capitalizedObjectNamePlural}($filter: ${capitalizedObjectNameSingular}FilterInput!, $data: ${capitalizedObjectNameSingular}UpdateInput!) {
${mutationResponseField}(filter: $filter, data: $data) {
id
}
}
`;
return updateManyRecordsMutation;
};
@@ -0,0 +1,44 @@
import { ObjectRecord } from '@/object-record/types/ObjectRecord';
import { gql } from '@apollo/client';
import { getMockPersonRecord } from '~/testing/mock-data/people';
export const query = gql`
mutation UpdateManyPeople(
$filter: PersonFilterInput!
$data: PersonUpdateInput!
) {
updatePeople(filter: $filter, data: $data) {
id
__typename
}
}
`;
export const personIds = [
'a7286b9a-c039-4a89-9567-2dfa7953cda9',
'37faabcd-cb39-4a0a-8618-7e3fda9afca0',
];
export const personRecords = personIds.map<ObjectRecord>((personId, index) =>
getMockPersonRecord({ id: personId }, index),
);
export const updateInput = {
city: 'Updated City',
};
export const variables = {
filter: {
id: {
in: personIds,
},
},
data: updateInput,
};
export const updatedPersonRecords = personIds.map<ObjectRecord>(
(personId, index) =>
getMockPersonRecord({ id: personId, city: 'Updated City' }, index),
);
export const responseData = personIds.map((personId) => ({ id: personId }));
@@ -0,0 +1,225 @@
import { renderHook, waitFor } from '@testing-library/react';
import { getRecordFromCache } from '@/object-record/cache/utils/getRecordFromCache';
import { updateRecordFromCache } from '@/object-record/cache/utils/updateRecordFromCache';
import { generateDepthRecordGqlFieldsFromRecord } from '@/object-record/graphql/record-gql-fields/utils/generateDepthRecordGqlFieldsFromRecord';
import {
personIds,
personRecords,
query,
responseData,
updateInput,
variables,
} from '@/object-record/hooks/__mocks__/useUpdateManyRecords';
import { useRefetchAggregateQueries } from '@/object-record/hooks/useRefetchAggregateQueries';
import { useUpdateManyRecords } from '@/object-record/hooks/useUpdateManyRecords';
import { type ObjectRecord } from '@/object-record/types/ObjectRecord';
import { InMemoryCache } from '@apollo/client';
import { type MockedResponse } from '@apollo/client/testing';
import { act } from 'react';
import { getJestMetadataAndApolloMocksWrapper } from '~/testing/jest/getJestMetadataAndApolloMocksWrapper';
import { getMockPersonObjectMetadataItem } from '~/testing/mock-data/people';
import { generatedMockObjectMetadataItems } from '~/testing/utils/generatedMockObjectMetadataItems';
const getDefaultMocks = (
overrides?: Partial<MockedResponse>,
): MockedResponse[] => [
{
request: {
query,
variables,
},
result: jest.fn(() => ({
data: {
updatePeople: responseData,
},
})),
...overrides,
},
];
jest.mock('@/object-record/hooks/useRefetchAggregateQueries');
const mockRefetchAggregateQueries = jest.fn();
(useRefetchAggregateQueries as jest.Mock).mockReturnValue({
refetchAggregateQueries: mockRefetchAggregateQueries,
});
const objectMetadataItem = getMockPersonObjectMetadataItem();
const objectMetadataItems = generatedMockObjectMetadataItems;
const expectedCachedRecordsWithUpdatedCity = personRecords.map(
(personRecord) => ({
...personRecord,
city: 'Updated City',
}),
);
describe('useUpdateManyRecords', () => {
let cache: InMemoryCache;
const assertCachedRecordsMatch = (expectedRecords: ObjectRecord[]) => {
expectedRecords.forEach((expectedRecord) => {
const cachedRecord = getRecordFromCache({
cache,
objectMetadataItem,
objectMetadataItems,
recordId: expectedRecord.id,
objectPermissionsByObjectMetadataId: {},
});
expect(cachedRecord).not.toBeNull();
if (cachedRecord === null) throw new Error('Should never occur');
expect(expectedRecord).toMatchObject(cachedRecord);
});
};
const assertCachedRecordsIsNull = (recordIds: string[]) =>
recordIds.forEach((recordId) =>
expect(
getRecordFromCache({
cache,
objectMetadataItem,
objectMetadataItems,
recordId,
objectPermissionsByObjectMetadataId: {},
}),
).toBeNull(),
);
beforeEach(() => {
jest.clearAllMocks();
cache = new InMemoryCache();
});
describe('A. Starting from empty cache', () => {
it('1. Should handle update many records when cache is empty', async () => {
const apolloMocks = getDefaultMocks();
const { result } = renderHook(
() => useUpdateManyRecords({ objectNameSingular: 'person' }),
{
wrapper: getJestMetadataAndApolloMocksWrapper({
apolloMocks,
cache,
}),
},
);
await act(async () => {
const res = await result.current.updateManyRecords({
recordIdsToUpdate: personIds,
updateOneRecordInput: updateInput,
});
expect(res).toEqual(responseData);
assertCachedRecordsIsNull(personIds);
});
expect(apolloMocks[0].result).toHaveBeenCalled();
expect(mockRefetchAggregateQueries).toHaveBeenCalledTimes(1);
});
});
describe('B. Starting from filled cache', () => {
beforeEach(() => {
personRecords.forEach((record) =>
updateRecordFromCache({
cache,
objectMetadataItem,
objectMetadataItems,
record,
recordGqlFields: generateDepthRecordGqlFieldsFromRecord({
objectMetadataItems: generatedMockObjectMetadataItems,
objectMetadataItem,
record,
depth: 1,
}),
objectPermissionsByObjectMetadataId: {},
}),
);
});
it('1. Should handle optimistic behavior after many successful records update', async () => {
const apolloMocks = getDefaultMocks();
const { result } = renderHook(
() => useUpdateManyRecords({ objectNameSingular: 'person' }),
{
wrapper: getJestMetadataAndApolloMocksWrapper({
apolloMocks,
cache,
}),
},
);
await act(async () => {
const res = await result.current.updateManyRecords({
recordIdsToUpdate: personIds,
updateOneRecordInput: updateInput,
});
expect(res).toEqual(responseData);
assertCachedRecordsMatch(expectedCachedRecordsWithUpdatedCity);
});
expect(apolloMocks[0].result).toHaveBeenCalled();
expect(mockRefetchAggregateQueries).toHaveBeenCalledTimes(1);
});
it('2. Should handle optimistic behavior before send many record update', async () => {
const apolloMocks = getDefaultMocks();
const { result } = renderHook(
() => useUpdateManyRecords({ objectNameSingular: 'person' }),
{
wrapper: getJestMetadataAndApolloMocksWrapper({
apolloMocks: getDefaultMocks({
delay: Number.POSITIVE_INFINITY,
}),
cache,
}),
},
);
await act(async () => {
result.current.updateManyRecords({
recordIdsToUpdate: personIds,
updateOneRecordInput: updateInput,
});
await waitFor(() =>
assertCachedRecordsMatch(expectedCachedRecordsWithUpdatedCity),
);
});
expect(apolloMocks[0].result).not.toHaveBeenCalled();
expect(mockRefetchAggregateQueries).not.toHaveBeenCalled();
});
it('3. Should rollback optimistic behavior after failing to update many records', async () => {
const apolloMocks = getDefaultMocks();
const { result } = renderHook(
() => useUpdateManyRecords({ objectNameSingular: 'person' }),
{
wrapper: getJestMetadataAndApolloMocksWrapper({
apolloMocks: getDefaultMocks({
error: new Error('Internal server error'),
}),
cache,
}),
},
);
await act(async () => {
try {
await result.current.updateManyRecords({
recordIdsToUpdate: personIds,
updateOneRecordInput: updateInput,
});
fail('Should have thrown an error');
} catch (e) {
expect(e).toMatchInlineSnapshot(
`[ApolloError: Internal server error]`,
);
assertCachedRecordsMatch(personRecords);
}
});
expect(apolloMocks[0].result).not.toHaveBeenCalled();
expect(mockRefetchAggregateQueries).not.toHaveBeenCalled();
});
});
});
@@ -0,0 +1,44 @@
import { renderHook } from '@testing-library/react';
import { print } from 'graphql';
import { useUpdateManyRecordsMutation } from '@/object-record/hooks/useUpdateManyRecordsMutation';
import { getJestMetadataAndApolloMocksWrapper } from '~/testing/jest/getJestMetadataAndApolloMocksWrapper';
const expectedQueryTemplate = `
mutation UpdateManyPeople($filter: PersonFilterInput!, $data: PersonUpdateInput!) {
updatePeople(filter: $filter, data: $data) {
id
}
}
`.replace(/\s/g, '');
const Wrapper = getJestMetadataAndApolloMocksWrapper({
apolloMocks: [],
});
describe('useUpdateManyRecordsMutation', () => {
it('should return a valid updateManyRecordsMutation', () => {
const objectNameSingular = 'person';
const { result } = renderHook(
() =>
useUpdateManyRecordsMutation({
objectNameSingular,
}),
{
wrapper: Wrapper,
},
);
const { updateManyRecordsMutation } = result.current;
expect(updateManyRecordsMutation).toBeDefined();
const printedReceivedQuery = print(updateManyRecordsMutation).replace(
/\s/g,
'',
);
expect(printedReceivedQuery).toEqual(expectedQueryTemplate);
});
});
@@ -0,0 +1,268 @@
import { triggerUpdateRecordOptimisticEffectByBatch } from '@/apollo/optimistic-effect/utils/triggerUpdateRecordOptimisticEffectByBatch';
import { apiConfigState } from '@/client-config/states/apiConfigState';
import { useApolloCoreClient } from '@/object-metadata/hooks/useApolloCoreClient';
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
import { useObjectMetadataItems } from '@/object-metadata/hooks/useObjectMetadataItems';
import { useGetRecordFromCache } from '@/object-record/cache/hooks/useGetRecordFromCache';
import { getObjectTypename } from '@/object-record/cache/utils/getObjectTypename';
import { getRecordNodeFromRecord } from '@/object-record/cache/utils/getRecordNodeFromRecord';
import { updateRecordFromCache } from '@/object-record/cache/utils/updateRecordFromCache';
import { DEFAULT_MUTATION_BATCH_SIZE } from '@/object-record/constants/DefaultMutationBatchSize';
import { useGenerateDepthRecordGqlFieldsFromObject } from '@/object-record/graphql/record-gql-fields/hooks/useGenerateDepthRecordGqlFieldsFromObject';
import { generateDepthRecordGqlFieldsFromRecord } from '@/object-record/graphql/record-gql-fields/utils/generateDepthRecordGqlFieldsFromRecord';
import { type RecordGqlNode } from '@/object-record/graphql/types/RecordGqlNode';
import { useObjectPermissions } from '@/object-record/hooks/useObjectPermissions';
import { useRefetchAggregateQueries } from '@/object-record/hooks/useRefetchAggregateQueries';
import { useRegisterObjectOperation } from '@/object-record/hooks/useRegisterObjectOperation';
import { useUpdateManyRecordsMutation } from '@/object-record/hooks/useUpdateManyRecordsMutation';
import { useUpsertRecordsInStore } from '@/object-record/record-store/hooks/useUpsertRecordsInStore';
import { type ObjectRecord } from '@/object-record/types/ObjectRecord';
import { getUpdateManyRecordsMutationResponseField } from '@/object-record/utils/getUpdateManyRecordsMutationResponseField';
import { sanitizeRecordInput } from '@/object-record/utils/sanitizeRecordInput';
import { useRecoilValue } from 'recoil';
import { isDefined } from 'twenty-shared/utils';
import { sleep } from '~/utils/sleep';
type UseUpdateManyRecordsProps = {
objectNameSingular: string;
recordGqlFields?: Record<string, any>;
};
export type UpdateManyRecordsProps<T extends ObjectRecord = ObjectRecord> = {
recordIdsToUpdate: string[];
updateOneRecordInput: Partial<Omit<T, 'id'>>;
skipOptimisticEffect?: boolean;
delayInMsBetweenRequests?: number;
};
export const useUpdateManyRecords = <T extends ObjectRecord = ObjectRecord>({
objectNameSingular,
recordGqlFields,
}: UseUpdateManyRecordsProps) => {
const { registerObjectOperation } = useRegisterObjectOperation();
const { upsertRecordsInStore } = useUpsertRecordsInStore();
const apiConfig = useRecoilValue(apiConfigState);
const mutationPageSize =
apiConfig?.mutationMaximumAffectedRecords ?? DEFAULT_MUTATION_BATCH_SIZE;
const apolloCoreClient = useApolloCoreClient();
const { objectMetadataItem } = useObjectMetadataItem({
objectNameSingular,
});
const { recordGqlFields: depthOneRecordGqlFields } =
useGenerateDepthRecordGqlFieldsFromObject({
objectNameSingular,
depth: 1,
});
const computedRecordGqlFields = recordGqlFields ?? depthOneRecordGqlFields;
const getRecordFromCache = useGetRecordFromCache({
objectNameSingular,
});
const { updateManyRecordsMutation } = useUpdateManyRecordsMutation({
objectNameSingular,
});
const { objectMetadataItems } = useObjectMetadataItems();
const { objectPermissionsByObjectMetadataId } = useObjectPermissions();
const { refetchAggregateQueries } = useRefetchAggregateQueries({
objectMetadataNamePlural: objectMetadataItem.namePlural,
});
const mutationResponseField = getUpdateManyRecordsMutationResponseField(
objectMetadataItem.namePlural,
);
const updateManyRecords = async ({
recordIdsToUpdate,
updateOneRecordInput,
delayInMsBetweenRequests,
skipOptimisticEffect = false,
}: UpdateManyRecordsProps<T>) => {
const numberOfBatches = Math.ceil(
recordIdsToUpdate.length / mutationPageSize,
);
const updatedRecords: ObjectRecord[] = [];
for (let batchIndex = 0; batchIndex < numberOfBatches; batchIndex++) {
const batchedIdsToUpdate = recordIdsToUpdate.slice(
batchIndex * mutationPageSize,
(batchIndex + 1) * mutationPageSize,
);
const cachedRecords = batchedIdsToUpdate
.map((idToUpdate) =>
getRecordFromCache(idToUpdate, apolloCoreClient.cache),
)
.filter(isDefined);
const cachedRecordsNode: RecordGqlNode[] = [];
const computedOptimisticRecordsNode: RecordGqlNode[] = [];
const computedOptimisticRecords: ObjectRecord[] = [];
if (!skipOptimisticEffect) {
cachedRecords.forEach((cachedRecord) => {
const cachedRecordNode = getRecordNodeFromRecord<ObjectRecord>({
record: cachedRecord,
objectMetadataItem,
objectMetadataItems,
recordGqlFields: computedRecordGqlFields,
computeReferences: false,
});
const computedOptimisticRecord = {
...cachedRecord,
...updateOneRecordInput,
__typename: getObjectTypename(objectMetadataItem.nameSingular),
};
computedOptimisticRecords.push(computedOptimisticRecord);
const optimisticRecordNode = getRecordNodeFromRecord<ObjectRecord>({
record: computedOptimisticRecord,
objectMetadataItem,
objectMetadataItems,
recordGqlFields: computedRecordGqlFields,
computeReferences: false,
});
if (isDefined(optimisticRecordNode) && isDefined(cachedRecordNode)) {
const recordGqlFieldsFromRecord =
generateDepthRecordGqlFieldsFromRecord({
objectMetadataItem,
objectMetadataItems,
record: updateOneRecordInput,
depth: 1,
});
updateRecordFromCache({
objectMetadataItems,
objectMetadataItem,
cache: apolloCoreClient.cache,
record: computedOptimisticRecord,
recordGqlFields: recordGqlFieldsFromRecord,
objectPermissionsByObjectMetadataId,
});
computedOptimisticRecordsNode.push(optimisticRecordNode);
cachedRecordsNode.push(cachedRecordNode);
}
});
triggerUpdateRecordOptimisticEffectByBatch({
cache: apolloCoreClient.cache,
objectMetadataItem,
currentRecords: cachedRecordsNode,
updatedRecords: computedOptimisticRecordsNode,
objectMetadataItems,
objectPermissionsByObjectMetadataId,
upsertRecordsInStore,
});
}
const sanitizedInput = sanitizeRecordInput({
objectMetadataItem,
recordInput: updateOneRecordInput,
});
const updatedRecordsResponse = await apolloCoreClient
.mutate<Record<string, ObjectRecord[]>>({
mutation: updateManyRecordsMutation,
variables: {
filter: { id: { in: batchedIdsToUpdate } },
data: sanitizedInput,
},
})
.catch((error: Error) => {
if (skipOptimisticEffect) {
throw error;
}
const revertedCachedRecordsNode: RecordGqlNode[] = [];
const revertedOptimisticRecordsNode: RecordGqlNode[] = [];
cachedRecords.forEach((cachedRecord, index) => {
const recordGqlFieldsFromRecord =
generateDepthRecordGqlFieldsFromRecord({
objectMetadataItem,
objectMetadataItems,
record: cachedRecord,
depth: 1,
});
updateRecordFromCache({
objectMetadataItems,
objectMetadataItem,
cache: apolloCoreClient.cache,
record: cachedRecord,
recordGqlFields: recordGqlFieldsFromRecord,
objectPermissionsByObjectMetadataId,
});
const cachedRecordWithConnection =
getRecordNodeFromRecord<ObjectRecord>({
record: cachedRecord,
objectMetadataItem,
objectMetadataItems,
recordGqlFields: computedRecordGqlFields,
computeReferences: false,
});
const optimisticRecordWithConnection =
computedOptimisticRecordsNode[index];
if (
isDefined(optimisticRecordWithConnection) &&
isDefined(cachedRecordWithConnection)
) {
revertedCachedRecordsNode.push(cachedRecordWithConnection);
revertedOptimisticRecordsNode.push(
optimisticRecordWithConnection,
);
}
});
triggerUpdateRecordOptimisticEffectByBatch({
cache: apolloCoreClient.cache,
objectMetadataItem,
currentRecords: revertedOptimisticRecordsNode,
updatedRecords: revertedCachedRecordsNode,
objectMetadataItems,
objectPermissionsByObjectMetadataId,
upsertRecordsInStore,
});
throw error;
});
const updatedRecordsForThisBatch =
updatedRecordsResponse.data?.[mutationResponseField] ?? [];
updatedRecords.push(...updatedRecordsForThisBatch);
if (isDefined(delayInMsBetweenRequests)) {
await sleep(delayInMsBetweenRequests);
}
}
await refetchAggregateQueries();
registerObjectOperation(objectMetadataItem, {
type: 'update-many',
result: {
updateInputs: recordIdsToUpdate.map((id) => ({
id,
...updateOneRecordInput,
})),
},
});
return updatedRecords;
};
return { updateManyRecords };
};
@@ -0,0 +1,26 @@
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
import { generateUpdateManyRecordsMutation } from '@/object-metadata/utils/generateUpdateManyRecordsMutation';
import { EMPTY_MUTATION } from '@/object-record/constants/EmptyMutation';
import { isUndefinedOrNull } from '~/utils/isUndefinedOrNull';
export const useUpdateManyRecordsMutation = ({
objectNameSingular,
}: {
objectNameSingular: string;
}) => {
const { objectMetadataItem } = useObjectMetadataItem({
objectNameSingular,
});
if (isUndefinedOrNull(objectMetadataItem)) {
return { updateManyRecordsMutation: EMPTY_MUTATION };
}
const updateManyRecordsMutation = generateUpdateManyRecordsMutation({
objectMetadataItem,
});
return {
updateManyRecordsMutation,
};
};
@@ -0,0 +1,5 @@
import { capitalize } from 'twenty-shared/utils';
export const getUpdateManyRecordsMutationResponseField = (
objectNamePlural: string,
) => `update${capitalize(objectNamePlural)}`;
@@ -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 };
};
@@ -1,9 +0,0 @@
import { Module } from '@nestjs/common';
import { MessageFolderUpdateOnePreQueryHook } from 'src/modules/messaging/message-folder-manager/query-hooks/message-folder-update-one.pre-query.hook';
@Module({
providers: [MessageFolderUpdateOnePreQueryHook],
exports: [MessageFolderUpdateOnePreQueryHook],
})
export class MessageFolderQueryHookModule {}
@@ -1,111 +0,0 @@
import { msg } from '@lingui/core/macro';
import { isNumber } from '@sniptt/guards';
import { assertIsDefinedOrThrow, isDefined } from 'twenty-shared/utils';
import { type WorkspacePreQueryHookInstance } from 'src/engine/api/graphql/workspace-query-runner/workspace-query-hook/interfaces/workspace-query-hook.interface';
import { type UpdateOneResolverArgs } from 'src/engine/api/graphql/workspace-resolver-builder/interfaces/workspace-resolvers-builder.interface';
import { WorkspaceQueryHook } from 'src/engine/api/graphql/workspace-query-runner/workspace-query-hook/decorators/workspace-query-hook.decorator';
import {
WorkspaceQueryRunnerException,
WorkspaceQueryRunnerExceptionCode,
} from 'src/engine/api/graphql/workspace-query-runner/workspace-query-runner.exception';
import { type AuthContext } from 'src/engine/core-modules/auth/types/auth-context.type';
import { WorkspaceNotFoundDefaultError } from 'src/engine/core-modules/workspace/workspace.exception';
import { GlobalWorkspaceOrmManager } from 'src/engine/twenty-orm/global-workspace-datasource/global-workspace-orm.manager';
import { buildSystemAuthContext } from 'src/engine/twenty-orm/utils/build-system-auth-context.util';
import {
MessageFolderImportPolicy,
type MessageChannelWorkspaceEntity,
} from 'src/modules/messaging/common/standard-objects/message-channel.workspace-entity';
import { type MessageFolderWorkspaceEntity } from 'src/modules/messaging/common/standard-objects/message-folder.workspace-entity';
@WorkspaceQueryHook(`messageFolder.updateOne`)
export class MessageFolderUpdateOnePreQueryHook
implements WorkspacePreQueryHookInstance
{
constructor(
private readonly globalWorkspaceOrmManager: GlobalWorkspaceOrmManager,
) {}
async execute(
authContext: AuthContext,
_objectName: string,
payload: UpdateOneResolverArgs<MessageFolderWorkspaceEntity>,
): Promise<UpdateOneResolverArgs<MessageFolderWorkspaceEntity>> {
const workspace = authContext.workspace;
assertIsDefinedOrThrow(workspace, WorkspaceNotFoundDefaultError);
const systemAuthContext = buildSystemAuthContext(workspace.id);
return this.globalWorkspaceOrmManager.executeInWorkspaceContext(
systemAuthContext,
async () => {
const messageFolderRepository =
await this.globalWorkspaceOrmManager.getRepository<MessageFolderWorkspaceEntity>(
workspace.id,
'messageFolder',
);
const messageFolder = await messageFolderRepository.findOne({
where: { id: payload.id },
});
if (!messageFolder) {
throw new WorkspaceQueryRunnerException(
'Message folder not found',
WorkspaceQueryRunnerExceptionCode.DATA_NOT_FOUND,
{
userFriendlyMessage: msg`Message folder not found`,
},
);
}
if (payload.data.isSynced !== false) {
return payload;
}
const messageChannelRepository =
await this.globalWorkspaceOrmManager.getRepository<MessageChannelWorkspaceEntity>(
workspace.id,
'messageChannel',
);
const messageChannel = await messageChannelRepository.findOne({
where: { id: messageFolder.messageChannelId },
});
if (
messageChannel?.messageFolderImportPolicy !==
MessageFolderImportPolicy.SELECTED_FOLDERS
) {
return payload;
}
const syncedFoldersCount = await messageFolderRepository.count({
where: {
messageChannelId: messageFolder.messageChannelId,
isSynced: true,
},
});
if (
isDefined(syncedFoldersCount) &&
isNumber(syncedFoldersCount) &&
syncedFoldersCount <= 1
) {
throw new WorkspaceQueryRunnerException(
'Cannot unsync the last folder when folder import policy is set to selected folders',
WorkspaceQueryRunnerExceptionCode.INVALID_QUERY_INPUT,
{
userFriendlyMessage: msg`At least one folder must be synced.`,
},
);
}
return payload;
},
);
}
}
@@ -2,7 +2,6 @@ import { Module } from '@nestjs/common';
import { MessagingBlocklistManagerModule } from 'src/modules/messaging/blocklist-manager/messaging-blocklist-manager.module';
import { MessagingMessageCleanerModule } from 'src/modules/messaging/message-cleaner/messaging-message-cleaner.module';
import { MessageFolderQueryHookModule } from 'src/modules/messaging/message-folder-manager/query-hooks/message-folder-query-hook.module';
import { MessagingImportManagerModule } from 'src/modules/messaging/message-import-manager/messaging-import-manager.module';
import { MessageParticipantManagerModule } from 'src/modules/messaging/message-participant-manager/message-participant-manager.module';
import { MessagingMonitoringModule } from 'src/modules/messaging/monitoring/messaging-monitoring.module';
@@ -14,7 +13,6 @@ import { MessagingMonitoringModule } from 'src/modules/messaging/monitoring/mess
MessageParticipantManagerModule,
MessagingBlocklistManagerModule,
MessagingMonitoringModule,
MessageFolderQueryHookModule,
],
providers: [],
exports: [MessagingImportManagerModule],