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:
+29
@@ -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;
|
||||||
|
};
|
||||||
+44
@@ -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 }));
|
||||||
+225
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
+44
@@ -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,
|
||||||
|
};
|
||||||
|
};
|
||||||
+5
@@ -0,0 +1,5 @@
|
|||||||
|
import { capitalize } from 'twenty-shared/utils';
|
||||||
|
|
||||||
|
export const getUpdateManyRecordsMutationResponseField = (
|
||||||
|
objectNamePlural: string,
|
||||||
|
) => `update${capitalize(objectNamePlural)}`;
|
||||||
+13
-10
@@ -17,7 +17,7 @@ import { settingsAccountsSelectedMessageChannelState } from '@/settings/accounts
|
|||||||
import { TabList } from '@/ui/layout/tab-list/components/TabList';
|
import { TabList } from '@/ui/layout/tab-list/components/TabList';
|
||||||
import { activeTabIdComponentState } from '@/ui/layout/tab-list/states/activeTabIdComponentState';
|
import { activeTabIdComponentState } from '@/ui/layout/tab-list/states/activeTabIdComponentState';
|
||||||
import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue';
|
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';
|
import { isDefined } from 'twenty-shared/utils';
|
||||||
|
|
||||||
const StyledMessageContainer = styled.div`
|
const StyledMessageContainer = styled.div`
|
||||||
@@ -77,19 +77,22 @@ export const SettingsAccountsMessageChannelsContainer = () => {
|
|||||||
title: messageChannel.handle,
|
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) {
|
if (!messageChannels.length) {
|
||||||
return <SettingsNewAccountSection />;
|
return <SettingsNewAccountSection />;
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleTabChange = (tabId: string) => {
|
|
||||||
const selectedMessageChannel = messageChannels.find(
|
|
||||||
(channel) => channel.id === tabId,
|
|
||||||
);
|
|
||||||
if (isDefined(selectedMessageChannel)) {
|
|
||||||
setSelectedMessageChannel(selectedMessageChannel);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{tabs.length > 1 && (
|
{tabs.length > 1 && (
|
||||||
|
|||||||
+14
-1
@@ -1,13 +1,20 @@
|
|||||||
import { type MessageFolder } from '@/accounts/types/MessageFolder';
|
import { type MessageFolder } from '@/accounts/types/MessageFolder';
|
||||||
import { useTheme } from '@emotion/react';
|
import { useTheme } from '@emotion/react';
|
||||||
import { IconFolder, IconInbox, IconSend } from 'twenty-ui/display';
|
import {
|
||||||
|
IconFolder,
|
||||||
|
IconFolderRoot,
|
||||||
|
IconInbox,
|
||||||
|
IconSend,
|
||||||
|
} from 'twenty-ui/display';
|
||||||
|
|
||||||
type SettingsAccountsMessageFolderIconProps = {
|
type SettingsAccountsMessageFolderIconProps = {
|
||||||
folder: MessageFolder;
|
folder: MessageFolder;
|
||||||
|
isChildFolder?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const SettingsAccountsMessageFolderIcon = ({
|
export const SettingsAccountsMessageFolderIcon = ({
|
||||||
folder,
|
folder,
|
||||||
|
isChildFolder = false,
|
||||||
}: SettingsAccountsMessageFolderIconProps) => {
|
}: SettingsAccountsMessageFolderIconProps) => {
|
||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
if (folder.isSentFolder) {
|
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} />;
|
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 { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
|
||||||
import { useGenerateDepthRecordGqlFieldsFromObject } from '@/object-record/graphql/record-gql-fields/hooks/useGenerateDepthRecordGqlFieldsFromObject';
|
import { useGenerateDepthRecordGqlFieldsFromObject } from '@/object-record/graphql/record-gql-fields/hooks/useGenerateDepthRecordGqlFieldsFromObject';
|
||||||
import { useFindOneRecord } from '@/object-record/hooks/useFindOneRecord';
|
import { useFindOneRecord } from '@/object-record/hooks/useFindOneRecord';
|
||||||
import { useUpdateOneRecord } from '@/object-record/hooks/useUpdateOneRecord';
|
|
||||||
import { SettingsMessageFoldersEmptyStateCard } from '@/settings/accounts/components/message-folders/SettingsMessageFoldersEmptyStateCard';
|
import { SettingsMessageFoldersEmptyStateCard } from '@/settings/accounts/components/message-folders/SettingsMessageFoldersEmptyStateCard';
|
||||||
import { SettingsMessageFoldersSkeletonLoader } from '@/settings/accounts/components/message-folders/SettingsMessageFoldersSkeletonLoader';
|
import { SettingsMessageFoldersSkeletonLoader } from '@/settings/accounts/components/message-folders/SettingsMessageFoldersSkeletonLoader';
|
||||||
import { SettingsMessageFoldersTreeItem } from '@/settings/accounts/components/message-folders/SettingsMessageFoldersTreeItem';
|
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 { computeMessageFolderTree } from '@/settings/accounts/components/message-folders/utils/computeMessageFolderTree';
|
||||||
|
import { useUpdateMessageFoldersSyncStatus } from '@/settings/accounts/hooks/useUpdateMessageFoldersSyncStatus';
|
||||||
import { settingsAccountsSelectedMessageChannelState } from '@/settings/accounts/states/settingsAccountsSelectedMessageChannelState';
|
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 { SettingsTextInput } from '@/ui/input/components/SettingsTextInput';
|
||||||
import { Table } from '@/ui/layout/table/components/Table';
|
import { Table } from '@/ui/layout/table/components/Table';
|
||||||
import { TableCell } from '@/ui/layout/table/components/TableCell';
|
import { TableCell } from '@/ui/layout/table/components/TableCell';
|
||||||
|
import { ApolloError } from '@apollo/client';
|
||||||
import styled from '@emotion/styled';
|
import styled from '@emotion/styled';
|
||||||
import { useLingui } from '@lingui/react/macro';
|
import { useLingui } from '@lingui/react/macro';
|
||||||
import { useMemo, useState } from 'react';
|
import { useMemo, useState } from 'react';
|
||||||
@@ -67,13 +70,14 @@ export const SettingsAccountsMessageFoldersCard = () => {
|
|||||||
const { t } = useLingui();
|
const { t } = useLingui();
|
||||||
const [search, setSearch] = useState('');
|
const [search, setSearch] = useState('');
|
||||||
|
|
||||||
|
const { enqueueErrorSnackBar } = useSnackBar();
|
||||||
|
|
||||||
const settingsAccountsSelectedMessageChannel = useRecoilValue(
|
const settingsAccountsSelectedMessageChannel = useRecoilValue(
|
||||||
settingsAccountsSelectedMessageChannelState,
|
settingsAccountsSelectedMessageChannelState,
|
||||||
);
|
);
|
||||||
|
|
||||||
const { updateOneRecord } = useUpdateOneRecord<MessageFolder>({
|
const { updateMessageFoldersSyncStatus } =
|
||||||
objectNameSingular: CoreObjectNameSingular.MessageFolder,
|
useUpdateMessageFoldersSyncStatus();
|
||||||
});
|
|
||||||
|
|
||||||
const { recordGqlFields } = useGenerateDepthRecordGqlFieldsFromObject({
|
const { recordGqlFields } = useGenerateDepthRecordGqlFieldsFromObject({
|
||||||
objectNameSingular: CoreObjectNameSingular.MessageChannel,
|
objectNameSingular: CoreObjectNameSingular.MessageChannel,
|
||||||
@@ -111,21 +115,36 @@ export const SettingsAccountsMessageFoldersCard = () => {
|
|||||||
const allSynced = messageFoldersToToggle.every((folder) => folder.isSynced);
|
const allSynced = messageFoldersToToggle.every((folder) => folder.isSynced);
|
||||||
const targetSyncState = !allSynced;
|
const targetSyncState = !allSynced;
|
||||||
|
|
||||||
for (const folder of messageFoldersToToggle) {
|
try {
|
||||||
await updateOneRecord({
|
await updateMessageFoldersSyncStatus({
|
||||||
idToUpdate: folder.id,
|
messageFolderIds: messageFoldersToToggle.map((folder) => folder.id),
|
||||||
updateOneRecordInput: { isSynced: targetSyncState },
|
isSynced: targetSyncState,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
enqueueErrorSnackBar({
|
||||||
|
...(error instanceof ApolloError ? { apolloError: error } : {}),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleToggleFolder = async (messageFoldersToToggle: MessageFolder) => {
|
const handleToggleFolder = async (folderToToggle: MessageFolder) => {
|
||||||
await updateOneRecord({
|
const isSynced = !folderToToggle.isSynced;
|
||||||
idToUpdate: messageFoldersToToggle.id,
|
const folderIdsToToggle = computeFolderIdsForSyncToggle({
|
||||||
updateOneRecordInput: {
|
folderId: folderToToggle.id,
|
||||||
isSynced: !messageFoldersToToggle.isSynced,
|
allFolders: messageFolders,
|
||||||
},
|
isSynced,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
await updateMessageFoldersSyncStatus({
|
||||||
|
messageFolderIds: folderIdsToToggle,
|
||||||
|
isSynced,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
enqueueErrorSnackBar({
|
||||||
|
...(error instanceof ApolloError ? { apolloError: error } : {}),
|
||||||
|
});
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if (loading) {
|
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 { type MessageFolder } from '@/accounts/types/MessageFolder';
|
||||||
import { SettingsAccountsMessageFolderIcon } from '@/settings/accounts/components/message-folders/SettingsAccountsMessageFolderIcon';
|
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 { 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 { 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 styled from '@emotion/styled';
|
||||||
|
import { useLingui } from '@lingui/react/macro';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { IconChevronRight } from 'twenty-ui/display';
|
import { IconChevronDown, IconChevronUp } from 'twenty-ui/display';
|
||||||
import { Checkbox, CheckboxSize } from 'twenty-ui/input';
|
import { Checkbox, CheckboxSize } from 'twenty-ui/input';
|
||||||
|
|
||||||
type SettingsMessageFoldersTreeItemProps = {
|
type SettingsMessageFoldersTreeItemProps = {
|
||||||
folderTreeNode: MessageFolderTreeNode;
|
|
||||||
onToggleFolder: (folder: MessageFolder) => void;
|
|
||||||
depth?: number;
|
depth?: number;
|
||||||
|
folderTreeNode: MessageFolderTreeNode;
|
||||||
|
isLast?: boolean;
|
||||||
|
onToggleFolder: (folder: MessageFolder) => void;
|
||||||
|
parentsIsLastList?: boolean[];
|
||||||
};
|
};
|
||||||
|
|
||||||
const StyledTreeItem = styled.li<{ hasChildren: boolean; depth: number }>`
|
const BREADCRUMB_WIDTH = 24;
|
||||||
position: relative;
|
|
||||||
margin-left: ${({ hasChildren, depth, theme }) =>
|
|
||||||
!hasChildren && depth > 0 ? theme.spacing(3) : 0};
|
|
||||||
|
|
||||||
&:not(:last-child) {
|
const StyledTreeItem = styled.li`
|
||||||
margin-bottom: ${({ theme }) => theme.spacing(1)};
|
position: relative;
|
||||||
}
|
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const StyledNestedList = styled.ul`
|
const StyledNestedList = styled.ul`
|
||||||
border-left: 1px solid ${({ theme }) => theme.border.color.medium};
|
|
||||||
list-style: none;
|
list-style: none;
|
||||||
margin: ${({ theme }) => theme.spacing(1)} 0 0
|
margin: 0;
|
||||||
${({ theme }) => theme.spacing(3)};
|
|
||||||
padding: 0;
|
padding: 0;
|
||||||
padding-left: ${({ theme }) => theme.spacing(2)};
|
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const StyledTreeItemContent = styled.div`
|
const StyledCollapsibleWrapper = styled.div<{ isExpanded: boolean }>`
|
||||||
display: flex;
|
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;
|
align-items: center;
|
||||||
gap: ${({ theme }) => theme.spacing(2)};
|
|
||||||
padding: ${({ theme }) => theme.spacing(1)};
|
|
||||||
border-radius: ${({ theme }) => theme.border.radius.sm};
|
border-radius: ${({ theme }) => theme.border.radius.sm};
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
height: 28px;
|
||||||
|
padding-left: ${({ depth }) => depth * BREADCRUMB_WIDTH}px;
|
||||||
transition: background-color
|
transition: background-color
|
||||||
${({ theme }) => theme.animation.duration.instant}s;
|
${({ theme }) => theme.animation.duration.instant}s;
|
||||||
|
user-select: none;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
background-color: ${({ theme }) => theme.background.transparent.lighter};
|
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`
|
const StyledFolderContent = styled.div`
|
||||||
align-items: center;
|
align-items: center;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
gap: ${({ theme }) => theme.spacing(2)};
|
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`
|
const StyledFolderInfo = styled.div`
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: ${({ theme }) => theme.spacing(2)};
|
display: flex;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
|
gap: ${({ theme }) => theme.spacing(2)};
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
@@ -93,18 +84,57 @@ const StyledFolderName = styled.span`
|
|||||||
white-space: nowrap;
|
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`
|
const StyledCheckboxWrapper = styled.div`
|
||||||
align-items: center;
|
align-items: center;
|
||||||
display: flex;
|
display: flex;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
export const SettingsMessageFoldersTreeItem = ({
|
export const SettingsMessageFoldersTreeItem = ({
|
||||||
folderTreeNode,
|
|
||||||
onToggleFolder,
|
|
||||||
depth = 0,
|
depth = 0,
|
||||||
|
folderTreeNode,
|
||||||
|
isLast = false,
|
||||||
|
onToggleFolder,
|
||||||
|
parentsIsLastList = [],
|
||||||
}: SettingsMessageFoldersTreeItemProps) => {
|
}: SettingsMessageFoldersTreeItemProps) => {
|
||||||
|
const { t } = useLingui();
|
||||||
const [isExpanded, setIsExpanded] = useState(true);
|
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) => {
|
const handleExpandToggle = (e: React.MouseEvent) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
@@ -121,46 +151,77 @@ export const SettingsMessageFoldersTreeItem = ({
|
|||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const childParentsIsLastList =
|
||||||
|
depth > 0 ? [...parentsIsLastList, isLast] : parentsIsLastList;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<StyledTreeItem hasChildren={hasChildren} depth={depth}>
|
<StyledTreeItem>
|
||||||
<StyledTreeItemContent onClick={handleRowClick}>
|
<StyledTreeItemContent depth={depth} onClick={handleRowClick}>
|
||||||
{hasChildren && (
|
{depth > 0 && (
|
||||||
<StyledExpandButton
|
<SettingsMessageFoldersBreadcrumb
|
||||||
isExpanded={isExpanded}
|
depth={depth}
|
||||||
onClick={handleExpandToggle}
|
isLast={isLast}
|
||||||
aria-label={isExpanded ? t`Collapse folder` : t`Expand folder`}
|
parentsIsLastList={parentsIsLastList}
|
||||||
>
|
/>
|
||||||
<IconChevronRight size={16} />
|
|
||||||
</StyledExpandButton>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<StyledFolderContent>
|
<StyledFolderContent>
|
||||||
<StyledFolderInfo>
|
<StyledFolderInfo>
|
||||||
<SettingsAccountsMessageFolderIcon folder={folder} />
|
<SettingsAccountsMessageFolderIcon
|
||||||
|
folder={folder}
|
||||||
|
isChildFolder={depth > 0}
|
||||||
|
/>
|
||||||
<StyledFolderName>{formatFolderName(folder.name)}</StyledFolderName>
|
<StyledFolderName>{formatFolderName(folder.name)}</StyledFolderName>
|
||||||
</StyledFolderInfo>
|
</StyledFolderInfo>
|
||||||
|
|
||||||
<StyledCheckboxWrapper onClick={handleCheckboxClick}>
|
<StyledRightSection>
|
||||||
<Checkbox
|
{hasChildren && (
|
||||||
checked={folder.isSynced}
|
<>
|
||||||
onChange={() => onToggleFolder(folder)}
|
<StyledChildCount>{childCount}</StyledChildCount>
|
||||||
size={CheckboxSize.Small}
|
<StyledExpandButton
|
||||||
/>
|
aria-label={
|
||||||
</StyledCheckboxWrapper>
|
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>
|
</StyledFolderContent>
|
||||||
</StyledTreeItemContent>
|
</StyledTreeItemContent>
|
||||||
|
|
||||||
{hasChildren && isExpanded && (
|
{hasChildren && (
|
||||||
<StyledNestedList>
|
<StyledCollapsibleWrapper isExpanded={isExpanded}>
|
||||||
{children.map((child) => (
|
<StyledCollapsibleContent>
|
||||||
<SettingsMessageFoldersTreeItem
|
<StyledNestedList>
|
||||||
key={child.folder.id}
|
{children.map((child, index) => (
|
||||||
folderTreeNode={child}
|
<SettingsMessageFoldersTreeItem
|
||||||
onToggleFolder={onToggleFolder}
|
key={child.folder.id}
|
||||||
depth={depth + 1}
|
depth={depth + 1}
|
||||||
/>
|
folderTreeNode={child}
|
||||||
))}
|
isLast={index === children.length - 1}
|
||||||
</StyledNestedList>
|
onToggleFolder={onToggleFolder}
|
||||||
|
parentsIsLastList={childParentsIsLastList}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</StyledNestedList>
|
||||||
|
</StyledCollapsibleContent>
|
||||||
|
</StyledCollapsibleWrapper>
|
||||||
)}
|
)}
|
||||||
</StyledTreeItem>
|
</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 };
|
||||||
|
};
|
||||||
-9
@@ -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 {}
|
|
||||||
-111
@@ -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 { MessagingBlocklistManagerModule } from 'src/modules/messaging/blocklist-manager/messaging-blocklist-manager.module';
|
||||||
import { MessagingMessageCleanerModule } from 'src/modules/messaging/message-cleaner/messaging-message-cleaner.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 { 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 { MessageParticipantManagerModule } from 'src/modules/messaging/message-participant-manager/message-participant-manager.module';
|
||||||
import { MessagingMonitoringModule } from 'src/modules/messaging/monitoring/messaging-monitoring.module';
|
import { MessagingMonitoringModule } from 'src/modules/messaging/monitoring/messaging-monitoring.module';
|
||||||
@@ -14,7 +13,6 @@ import { MessagingMonitoringModule } from 'src/modules/messaging/monitoring/mess
|
|||||||
MessageParticipantManagerModule,
|
MessageParticipantManagerModule,
|
||||||
MessagingBlocklistManagerModule,
|
MessagingBlocklistManagerModule,
|
||||||
MessagingMonitoringModule,
|
MessagingMonitoringModule,
|
||||||
MessageFolderQueryHookModule,
|
|
||||||
],
|
],
|
||||||
providers: [],
|
providers: [],
|
||||||
exports: [MessagingImportManagerModule],
|
exports: [MessagingImportManagerModule],
|
||||||
|
|||||||
Reference in New Issue
Block a user