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 { activeTabIdComponentState } from '@/ui/layout/tab-list/states/activeTabIdComponentState';
|
||||
import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue';
|
||||
import React from 'react';
|
||||
import React, { useCallback } from 'react';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
const StyledMessageContainer = styled.div`
|
||||
@@ -77,19 +77,22 @@ export const SettingsAccountsMessageChannelsContainer = () => {
|
||||
title: messageChannel.handle,
|
||||
}));
|
||||
|
||||
const handleTabChange = useCallback(
|
||||
(tabId: string) => {
|
||||
const selectedMessageChannel = messageChannels.find(
|
||||
(channel) => channel.id === tabId,
|
||||
);
|
||||
if (isDefined(selectedMessageChannel)) {
|
||||
setSelectedMessageChannel(selectedMessageChannel);
|
||||
}
|
||||
},
|
||||
[messageChannels, setSelectedMessageChannel],
|
||||
);
|
||||
|
||||
if (!messageChannels.length) {
|
||||
return <SettingsNewAccountSection />;
|
||||
}
|
||||
|
||||
const handleTabChange = (tabId: string) => {
|
||||
const selectedMessageChannel = messageChannels.find(
|
||||
(channel) => channel.id === tabId,
|
||||
);
|
||||
if (isDefined(selectedMessageChannel)) {
|
||||
setSelectedMessageChannel(selectedMessageChannel);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{tabs.length > 1 && (
|
||||
|
||||
+14
-1
@@ -1,13 +1,20 @@
|
||||
import { type MessageFolder } from '@/accounts/types/MessageFolder';
|
||||
import { useTheme } from '@emotion/react';
|
||||
import { IconFolder, IconInbox, IconSend } from 'twenty-ui/display';
|
||||
import {
|
||||
IconFolder,
|
||||
IconFolderRoot,
|
||||
IconInbox,
|
||||
IconSend,
|
||||
} from 'twenty-ui/display';
|
||||
|
||||
type SettingsAccountsMessageFolderIconProps = {
|
||||
folder: MessageFolder;
|
||||
isChildFolder?: boolean;
|
||||
};
|
||||
|
||||
export const SettingsAccountsMessageFolderIcon = ({
|
||||
folder,
|
||||
isChildFolder = false,
|
||||
}: SettingsAccountsMessageFolderIconProps) => {
|
||||
const theme = useTheme();
|
||||
if (folder.isSentFolder) {
|
||||
@@ -20,5 +27,11 @@ export const SettingsAccountsMessageFolderIcon = ({
|
||||
);
|
||||
}
|
||||
|
||||
if (isChildFolder) {
|
||||
return (
|
||||
<IconFolderRoot size={theme.icon.size.md} stroke={theme.icon.stroke.sm} />
|
||||
);
|
||||
}
|
||||
|
||||
return <IconFolder size={theme.icon.size.md} stroke={theme.icon.stroke.sm} />;
|
||||
};
|
||||
|
||||
+33
-14
@@ -3,15 +3,18 @@ import { type MessageFolder } from '@/accounts/types/MessageFolder';
|
||||
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
|
||||
import { useGenerateDepthRecordGqlFieldsFromObject } from '@/object-record/graphql/record-gql-fields/hooks/useGenerateDepthRecordGqlFieldsFromObject';
|
||||
import { useFindOneRecord } from '@/object-record/hooks/useFindOneRecord';
|
||||
import { useUpdateOneRecord } from '@/object-record/hooks/useUpdateOneRecord';
|
||||
import { SettingsMessageFoldersEmptyStateCard } from '@/settings/accounts/components/message-folders/SettingsMessageFoldersEmptyStateCard';
|
||||
import { SettingsMessageFoldersSkeletonLoader } from '@/settings/accounts/components/message-folders/SettingsMessageFoldersSkeletonLoader';
|
||||
import { SettingsMessageFoldersTreeItem } from '@/settings/accounts/components/message-folders/SettingsMessageFoldersTreeItem';
|
||||
import { computeFolderIdsForSyncToggle } from '@/settings/accounts/components/message-folders/utils/computeFolderIdsForSyncToggle';
|
||||
import { computeMessageFolderTree } from '@/settings/accounts/components/message-folders/utils/computeMessageFolderTree';
|
||||
import { useUpdateMessageFoldersSyncStatus } from '@/settings/accounts/hooks/useUpdateMessageFoldersSyncStatus';
|
||||
import { settingsAccountsSelectedMessageChannelState } from '@/settings/accounts/states/settingsAccountsSelectedMessageChannelState';
|
||||
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
|
||||
import { SettingsTextInput } from '@/ui/input/components/SettingsTextInput';
|
||||
import { Table } from '@/ui/layout/table/components/Table';
|
||||
import { TableCell } from '@/ui/layout/table/components/TableCell';
|
||||
import { ApolloError } from '@apollo/client';
|
||||
import styled from '@emotion/styled';
|
||||
import { useLingui } from '@lingui/react/macro';
|
||||
import { useMemo, useState } from 'react';
|
||||
@@ -67,13 +70,14 @@ export const SettingsAccountsMessageFoldersCard = () => {
|
||||
const { t } = useLingui();
|
||||
const [search, setSearch] = useState('');
|
||||
|
||||
const { enqueueErrorSnackBar } = useSnackBar();
|
||||
|
||||
const settingsAccountsSelectedMessageChannel = useRecoilValue(
|
||||
settingsAccountsSelectedMessageChannelState,
|
||||
);
|
||||
|
||||
const { updateOneRecord } = useUpdateOneRecord<MessageFolder>({
|
||||
objectNameSingular: CoreObjectNameSingular.MessageFolder,
|
||||
});
|
||||
const { updateMessageFoldersSyncStatus } =
|
||||
useUpdateMessageFoldersSyncStatus();
|
||||
|
||||
const { recordGqlFields } = useGenerateDepthRecordGqlFieldsFromObject({
|
||||
objectNameSingular: CoreObjectNameSingular.MessageChannel,
|
||||
@@ -111,21 +115,36 @@ export const SettingsAccountsMessageFoldersCard = () => {
|
||||
const allSynced = messageFoldersToToggle.every((folder) => folder.isSynced);
|
||||
const targetSyncState = !allSynced;
|
||||
|
||||
for (const folder of messageFoldersToToggle) {
|
||||
await updateOneRecord({
|
||||
idToUpdate: folder.id,
|
||||
updateOneRecordInput: { isSynced: targetSyncState },
|
||||
try {
|
||||
await updateMessageFoldersSyncStatus({
|
||||
messageFolderIds: messageFoldersToToggle.map((folder) => folder.id),
|
||||
isSynced: targetSyncState,
|
||||
});
|
||||
} catch (error) {
|
||||
enqueueErrorSnackBar({
|
||||
...(error instanceof ApolloError ? { apolloError: error } : {}),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggleFolder = async (messageFoldersToToggle: MessageFolder) => {
|
||||
await updateOneRecord({
|
||||
idToUpdate: messageFoldersToToggle.id,
|
||||
updateOneRecordInput: {
|
||||
isSynced: !messageFoldersToToggle.isSynced,
|
||||
},
|
||||
const handleToggleFolder = async (folderToToggle: MessageFolder) => {
|
||||
const isSynced = !folderToToggle.isSynced;
|
||||
const folderIdsToToggle = computeFolderIdsForSyncToggle({
|
||||
folderId: folderToToggle.id,
|
||||
allFolders: messageFolders,
|
||||
isSynced,
|
||||
});
|
||||
|
||||
try {
|
||||
await updateMessageFoldersSyncStatus({
|
||||
messageFolderIds: folderIdsToToggle,
|
||||
isSynced,
|
||||
});
|
||||
} catch (error) {
|
||||
enqueueErrorSnackBar({
|
||||
...(error instanceof ApolloError ? { apolloError: error } : {}),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
|
||||
+94
@@ -0,0 +1,94 @@
|
||||
import styled from '@emotion/styled';
|
||||
|
||||
const BREADCRUMB_WIDTH = 24;
|
||||
const ICON_CENTER_OFFSET = 8;
|
||||
|
||||
export type SettingsMessageFoldersBreadcrumbProps = {
|
||||
depth: number;
|
||||
isLast: boolean;
|
||||
parentsIsLastList: boolean[];
|
||||
};
|
||||
|
||||
const StyledBreadcrumbOverlay = styled.div<{ depth: number }>`
|
||||
height: 28px;
|
||||
left: 0;
|
||||
pointer-events: none;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
width: ${({ depth }) => depth * BREADCRUMB_WIDTH}px;
|
||||
`;
|
||||
|
||||
const StyledAncestorLine = styled.div<{
|
||||
index: number;
|
||||
showLine: boolean;
|
||||
}>`
|
||||
background: ${({ theme, showLine }) =>
|
||||
showLine ? theme.border.color.strong : 'transparent'};
|
||||
height: 28px;
|
||||
left: ${({ index }) => index * BREADCRUMB_WIDTH + ICON_CENTER_OFFSET}px;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
width: 1px;
|
||||
`;
|
||||
|
||||
const StyledBreadcrumbConnector = styled.div<{ depth: number }>`
|
||||
height: 28px;
|
||||
left: ${({ depth }) => (depth - 1) * BREADCRUMB_WIDTH + ICON_CENTER_OFFSET}px;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
width: ${BREADCRUMB_WIDTH - ICON_CENTER_OFFSET}px;
|
||||
`;
|
||||
|
||||
const StyledVerticalLineTop = styled.div`
|
||||
background: ${({ theme }) => theme.border.color.strong};
|
||||
height: 12px;
|
||||
left: 0;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
width: 1px;
|
||||
`;
|
||||
|
||||
const StyledRoundedCorner = styled.div`
|
||||
border-bottom: 1px solid ${({ theme }) => theme.border.color.strong};
|
||||
border-bottom-left-radius: 4px;
|
||||
border-left: 1px solid ${({ theme }) => theme.border.color.strong};
|
||||
height: 8px;
|
||||
left: 0;
|
||||
position: absolute;
|
||||
top: 6px;
|
||||
width: 8px;
|
||||
`;
|
||||
|
||||
const StyledVerticalLineBottom = styled.div`
|
||||
background: ${({ theme }) => theme.border.color.strong};
|
||||
height: 16px;
|
||||
left: 0;
|
||||
position: absolute;
|
||||
top: 12px;
|
||||
width: 1px;
|
||||
`;
|
||||
|
||||
export const SettingsMessageFoldersBreadcrumb = ({
|
||||
depth,
|
||||
isLast,
|
||||
parentsIsLastList,
|
||||
}: SettingsMessageFoldersBreadcrumbProps) => {
|
||||
const showVerticalBar = !isLast;
|
||||
|
||||
return (
|
||||
<StyledBreadcrumbOverlay depth={depth}>
|
||||
{parentsIsLastList.map((parentIsLast, index) => (
|
||||
<StyledAncestorLine
|
||||
key={index}
|
||||
index={index}
|
||||
showLine={!parentIsLast}
|
||||
/>
|
||||
))}
|
||||
<StyledBreadcrumbConnector depth={depth}>
|
||||
<StyledVerticalLineTop />
|
||||
<StyledRoundedCorner />
|
||||
{showVerticalBar && <StyledVerticalLineBottom />}
|
||||
</StyledBreadcrumbConnector>
|
||||
</StyledBreadcrumbOverlay>
|
||||
);
|
||||
};
|
||||
+136
-75
@@ -1,87 +1,78 @@
|
||||
import { t } from '@lingui/core/macro';
|
||||
import { type MessageFolder } from '@/accounts/types/MessageFolder';
|
||||
import { SettingsAccountsMessageFolderIcon } from '@/settings/accounts/components/message-folders/SettingsAccountsMessageFolderIcon';
|
||||
|
||||
import { SettingsMessageFoldersBreadcrumb } from '@/settings/accounts/components/message-folders/SettingsMessageFoldersBreadcrumb';
|
||||
import { type MessageFolderTreeNode } from '@/settings/accounts/components/message-folders/utils/computeMessageFolderTree';
|
||||
import { countNestedFolders } from '@/settings/accounts/components/message-folders/utils/countNestedFolders';
|
||||
import { formatFolderName } from '@/settings/accounts/components/message-folders/utils/formatFolderName';
|
||||
import { isFolderTreePartiallySelected } from '@/settings/accounts/components/message-folders/utils/isFolderTreePartiallySelected';
|
||||
import styled from '@emotion/styled';
|
||||
import { useLingui } from '@lingui/react/macro';
|
||||
import { useState } from 'react';
|
||||
import { IconChevronRight } from 'twenty-ui/display';
|
||||
import { IconChevronDown, IconChevronUp } from 'twenty-ui/display';
|
||||
import { Checkbox, CheckboxSize } from 'twenty-ui/input';
|
||||
|
||||
type SettingsMessageFoldersTreeItemProps = {
|
||||
folderTreeNode: MessageFolderTreeNode;
|
||||
onToggleFolder: (folder: MessageFolder) => void;
|
||||
depth?: number;
|
||||
folderTreeNode: MessageFolderTreeNode;
|
||||
isLast?: boolean;
|
||||
onToggleFolder: (folder: MessageFolder) => void;
|
||||
parentsIsLastList?: boolean[];
|
||||
};
|
||||
|
||||
const StyledTreeItem = styled.li<{ hasChildren: boolean; depth: number }>`
|
||||
position: relative;
|
||||
margin-left: ${({ hasChildren, depth, theme }) =>
|
||||
!hasChildren && depth > 0 ? theme.spacing(3) : 0};
|
||||
const BREADCRUMB_WIDTH = 24;
|
||||
|
||||
&:not(:last-child) {
|
||||
margin-bottom: ${({ theme }) => theme.spacing(1)};
|
||||
}
|
||||
const StyledTreeItem = styled.li`
|
||||
position: relative;
|
||||
`;
|
||||
|
||||
const StyledNestedList = styled.ul`
|
||||
border-left: 1px solid ${({ theme }) => theme.border.color.medium};
|
||||
list-style: none;
|
||||
margin: ${({ theme }) => theme.spacing(1)} 0 0
|
||||
${({ theme }) => theme.spacing(3)};
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
padding-left: ${({ theme }) => theme.spacing(2)};
|
||||
`;
|
||||
|
||||
const StyledTreeItemContent = styled.div`
|
||||
display: flex;
|
||||
const StyledCollapsibleWrapper = styled.div<{ isExpanded: boolean }>`
|
||||
display: grid;
|
||||
grid-template-rows: ${({ isExpanded }) => (isExpanded ? '1fr' : '0fr')};
|
||||
transition: grid-template-rows
|
||||
${({ theme }) => theme.animation.duration.fast}s ease-out;
|
||||
`;
|
||||
|
||||
const StyledCollapsibleContent = styled.div`
|
||||
overflow: hidden;
|
||||
`;
|
||||
|
||||
const StyledTreeItemContent = styled.div<{ depth: number }>`
|
||||
align-items: center;
|
||||
gap: ${({ theme }) => theme.spacing(2)};
|
||||
padding: ${({ theme }) => theme.spacing(1)};
|
||||
border-radius: ${({ theme }) => theme.border.radius.sm};
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
height: 28px;
|
||||
padding-left: ${({ depth }) => depth * BREADCRUMB_WIDTH}px;
|
||||
transition: background-color
|
||||
${({ theme }) => theme.animation.duration.instant}s;
|
||||
user-select: none;
|
||||
|
||||
&:hover {
|
||||
background-color: ${({ theme }) => theme.background.transparent.lighter};
|
||||
}
|
||||
`;
|
||||
|
||||
const StyledExpandButton = styled.button<{ isExpanded: boolean }>`
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: ${({ theme }) => theme.spacing(4)};
|
||||
height: ${({ theme }) => theme.spacing(4)};
|
||||
color: ${({ theme }) => theme.font.color.tertiary};
|
||||
transition: transform ${({ theme }) => theme.animation.duration.instant}s;
|
||||
transform: ${({ isExpanded }) =>
|
||||
isExpanded ? 'rotate(90deg)' : 'rotate(0deg)'};
|
||||
|
||||
&:hover {
|
||||
color: ${({ theme }) => theme.font.color.primary};
|
||||
}
|
||||
`;
|
||||
|
||||
const StyledFolderContent = styled.div`
|
||||
align-items: center;
|
||||
display: flex;
|
||||
flex: 1;
|
||||
gap: ${({ theme }) => theme.spacing(2)};
|
||||
justify-content: space-between;
|
||||
min-width: 0;
|
||||
padding-left: ${({ theme }) => theme.spacing(1)};
|
||||
padding-right: ${({ theme }) => theme.spacing(1)};
|
||||
`;
|
||||
|
||||
const StyledFolderInfo = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: ${({ theme }) => theme.spacing(2)};
|
||||
display: flex;
|
||||
flex: 1;
|
||||
gap: ${({ theme }) => theme.spacing(2)};
|
||||
min-width: 0;
|
||||
`;
|
||||
|
||||
@@ -93,18 +84,57 @@ const StyledFolderName = styled.span`
|
||||
white-space: nowrap;
|
||||
`;
|
||||
|
||||
const StyledRightSection = styled.div`
|
||||
align-items: center;
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
gap: ${({ theme }) => theme.spacing(1)};
|
||||
margin-left: auto;
|
||||
`;
|
||||
|
||||
const StyledChildCount = styled.span`
|
||||
color: ${({ theme }) => theme.font.color.tertiary};
|
||||
font-size: ${({ theme }) => theme.font.size.sm};
|
||||
min-width: ${({ theme }) => theme.spacing(3)};
|
||||
text-align: right;
|
||||
`;
|
||||
|
||||
const StyledExpandButton = styled.button`
|
||||
align-items: center;
|
||||
background: none;
|
||||
border: none;
|
||||
color: ${({ theme }) => theme.font.color.tertiary};
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
height: ${({ theme }) => theme.spacing(4)};
|
||||
justify-content: center;
|
||||
padding: 0;
|
||||
width: ${({ theme }) => theme.spacing(4)};
|
||||
|
||||
&:hover {
|
||||
color: ${({ theme }) => theme.font.color.primary};
|
||||
}
|
||||
`;
|
||||
|
||||
const StyledCheckboxWrapper = styled.div`
|
||||
align-items: center;
|
||||
display: flex;
|
||||
`;
|
||||
|
||||
export const SettingsMessageFoldersTreeItem = ({
|
||||
folderTreeNode,
|
||||
onToggleFolder,
|
||||
depth = 0,
|
||||
folderTreeNode,
|
||||
isLast = false,
|
||||
onToggleFolder,
|
||||
parentsIsLastList = [],
|
||||
}: SettingsMessageFoldersTreeItemProps) => {
|
||||
const { t } = useLingui();
|
||||
const [isExpanded, setIsExpanded] = useState(true);
|
||||
const { folder, children, hasChildren } = folderTreeNode;
|
||||
|
||||
const { children, folder, hasChildren } = folderTreeNode;
|
||||
const childCount = hasChildren ? countNestedFolders(folderTreeNode) : 0;
|
||||
const isIndeterminate =
|
||||
hasChildren && isFolderTreePartiallySelected(folderTreeNode);
|
||||
|
||||
const handleExpandToggle = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
@@ -121,46 +151,77 @@ export const SettingsMessageFoldersTreeItem = ({
|
||||
e.stopPropagation();
|
||||
};
|
||||
|
||||
const childParentsIsLastList =
|
||||
depth > 0 ? [...parentsIsLastList, isLast] : parentsIsLastList;
|
||||
|
||||
return (
|
||||
<StyledTreeItem hasChildren={hasChildren} depth={depth}>
|
||||
<StyledTreeItemContent onClick={handleRowClick}>
|
||||
{hasChildren && (
|
||||
<StyledExpandButton
|
||||
isExpanded={isExpanded}
|
||||
onClick={handleExpandToggle}
|
||||
aria-label={isExpanded ? t`Collapse folder` : t`Expand folder`}
|
||||
>
|
||||
<IconChevronRight size={16} />
|
||||
</StyledExpandButton>
|
||||
<StyledTreeItem>
|
||||
<StyledTreeItemContent depth={depth} onClick={handleRowClick}>
|
||||
{depth > 0 && (
|
||||
<SettingsMessageFoldersBreadcrumb
|
||||
depth={depth}
|
||||
isLast={isLast}
|
||||
parentsIsLastList={parentsIsLastList}
|
||||
/>
|
||||
)}
|
||||
|
||||
<StyledFolderContent>
|
||||
<StyledFolderInfo>
|
||||
<SettingsAccountsMessageFolderIcon folder={folder} />
|
||||
<SettingsAccountsMessageFolderIcon
|
||||
folder={folder}
|
||||
isChildFolder={depth > 0}
|
||||
/>
|
||||
<StyledFolderName>{formatFolderName(folder.name)}</StyledFolderName>
|
||||
</StyledFolderInfo>
|
||||
|
||||
<StyledCheckboxWrapper onClick={handleCheckboxClick}>
|
||||
<Checkbox
|
||||
checked={folder.isSynced}
|
||||
onChange={() => onToggleFolder(folder)}
|
||||
size={CheckboxSize.Small}
|
||||
/>
|
||||
</StyledCheckboxWrapper>
|
||||
<StyledRightSection>
|
||||
{hasChildren && (
|
||||
<>
|
||||
<StyledChildCount>{childCount}</StyledChildCount>
|
||||
<StyledExpandButton
|
||||
aria-label={
|
||||
isExpanded ? t`Collapse folder` : t`Expand folder`
|
||||
}
|
||||
onClick={handleExpandToggle}
|
||||
>
|
||||
{isExpanded ? (
|
||||
<IconChevronUp size={16} />
|
||||
) : (
|
||||
<IconChevronDown size={16} />
|
||||
)}
|
||||
</StyledExpandButton>
|
||||
</>
|
||||
)}
|
||||
|
||||
<StyledCheckboxWrapper onClick={handleCheckboxClick}>
|
||||
<Checkbox
|
||||
checked={folder.isSynced}
|
||||
indeterminate={isIndeterminate}
|
||||
onChange={() => onToggleFolder(folder)}
|
||||
size={CheckboxSize.Small}
|
||||
/>
|
||||
</StyledCheckboxWrapper>
|
||||
</StyledRightSection>
|
||||
</StyledFolderContent>
|
||||
</StyledTreeItemContent>
|
||||
|
||||
{hasChildren && isExpanded && (
|
||||
<StyledNestedList>
|
||||
{children.map((child) => (
|
||||
<SettingsMessageFoldersTreeItem
|
||||
key={child.folder.id}
|
||||
folderTreeNode={child}
|
||||
onToggleFolder={onToggleFolder}
|
||||
depth={depth + 1}
|
||||
/>
|
||||
))}
|
||||
</StyledNestedList>
|
||||
{hasChildren && (
|
||||
<StyledCollapsibleWrapper isExpanded={isExpanded}>
|
||||
<StyledCollapsibleContent>
|
||||
<StyledNestedList>
|
||||
{children.map((child, index) => (
|
||||
<SettingsMessageFoldersTreeItem
|
||||
key={child.folder.id}
|
||||
depth={depth + 1}
|
||||
folderTreeNode={child}
|
||||
isLast={index === children.length - 1}
|
||||
onToggleFolder={onToggleFolder}
|
||||
parentsIsLastList={childParentsIsLastList}
|
||||
/>
|
||||
))}
|
||||
</StyledNestedList>
|
||||
</StyledCollapsibleContent>
|
||||
</StyledCollapsibleWrapper>
|
||||
)}
|
||||
</StyledTreeItem>
|
||||
);
|
||||
|
||||
+410
@@ -0,0 +1,410 @@
|
||||
import { type MessageFolder } from '@/accounts/types/MessageFolder';
|
||||
import { computeFolderIdsForSyncToggle } from '@/settings/accounts/components/message-folders/utils/computeFolderIdsForSyncToggle';
|
||||
|
||||
describe('computeFolderIdsForSyncToggle', () => {
|
||||
const createFolder = ({
|
||||
id,
|
||||
name,
|
||||
parentFolderId = null,
|
||||
externalId = null,
|
||||
isSynced = false,
|
||||
}: {
|
||||
id: string;
|
||||
name: string;
|
||||
parentFolderId?: string | null;
|
||||
externalId?: string | null;
|
||||
isSynced?: boolean;
|
||||
}): MessageFolder => ({
|
||||
id,
|
||||
name,
|
||||
parentFolderId,
|
||||
externalId: externalId || id,
|
||||
isSentFolder: false,
|
||||
isSynced,
|
||||
messageChannelId: 'channel-1',
|
||||
__typename: 'MessageFolder',
|
||||
syncCursor: '',
|
||||
});
|
||||
|
||||
describe('when syncing a folder', () => {
|
||||
it('should include the folder itself for a root folder', () => {
|
||||
const inbox = createFolder({ id: 'inbox', name: 'Inbox' });
|
||||
const result = computeFolderIdsForSyncToggle({
|
||||
folderId: 'inbox',
|
||||
allFolders: [inbox],
|
||||
isSynced: true,
|
||||
});
|
||||
|
||||
expect(result).toEqual(['inbox']);
|
||||
});
|
||||
|
||||
it('should include ancestors when syncing a nested folder', () => {
|
||||
const work = createFolder({
|
||||
id: 'work',
|
||||
name: 'Work',
|
||||
externalId: 'ext-work',
|
||||
});
|
||||
const nested = createFolder({
|
||||
id: 'nested',
|
||||
name: 'Nested',
|
||||
parentFolderId: 'ext-work',
|
||||
externalId: 'ext-nested',
|
||||
});
|
||||
const result = computeFolderIdsForSyncToggle({
|
||||
folderId: 'nested',
|
||||
allFolders: [work, nested],
|
||||
isSynced: true,
|
||||
});
|
||||
|
||||
expect(result).toContain('nested');
|
||||
expect(result).toContain('work');
|
||||
expect(result).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('should NOT include siblings when syncing a child folder', () => {
|
||||
const parent = createFolder({
|
||||
id: 'parent',
|
||||
name: 'Parent',
|
||||
externalId: 'ext-parent',
|
||||
isSynced: false,
|
||||
});
|
||||
const childA = createFolder({
|
||||
id: 'child-a',
|
||||
name: 'Child A',
|
||||
parentFolderId: 'ext-parent',
|
||||
externalId: 'ext-child-a',
|
||||
isSynced: false,
|
||||
});
|
||||
const childB = createFolder({
|
||||
id: 'child-b',
|
||||
name: 'Child B',
|
||||
parentFolderId: 'ext-parent',
|
||||
externalId: 'ext-child-b',
|
||||
isSynced: false,
|
||||
});
|
||||
const childC = createFolder({
|
||||
id: 'child-c',
|
||||
name: 'Child C',
|
||||
parentFolderId: 'ext-parent',
|
||||
externalId: 'ext-child-c',
|
||||
isSynced: false,
|
||||
});
|
||||
const result = computeFolderIdsForSyncToggle({
|
||||
folderId: 'child-a',
|
||||
allFolders: [parent, childA, childB, childC],
|
||||
isSynced: true,
|
||||
});
|
||||
|
||||
expect(result).toContain('child-a');
|
||||
expect(result).toContain('parent');
|
||||
expect(result).not.toContain('child-b');
|
||||
expect(result).not.toContain('child-c');
|
||||
expect(result).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('should include all ancestors up to root', () => {
|
||||
const work = createFolder({
|
||||
id: 'work',
|
||||
name: 'Work',
|
||||
externalId: 'ext-work',
|
||||
});
|
||||
const nested = createFolder({
|
||||
id: 'nested',
|
||||
name: 'Nested',
|
||||
parentFolderId: 'ext-work',
|
||||
externalId: 'ext-nested',
|
||||
});
|
||||
const deep = createFolder({
|
||||
id: 'deep',
|
||||
name: 'Deep',
|
||||
parentFolderId: 'ext-nested',
|
||||
externalId: 'ext-deep',
|
||||
});
|
||||
const result = computeFolderIdsForSyncToggle({
|
||||
folderId: 'deep',
|
||||
allFolders: [work, nested, deep],
|
||||
isSynced: true,
|
||||
});
|
||||
|
||||
expect(result).toContain('deep');
|
||||
expect(result).toContain('nested');
|
||||
expect(result).toContain('work');
|
||||
expect(result).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('should include descendants when syncing a parent folder', () => {
|
||||
const work = createFolder({
|
||||
id: 'work',
|
||||
name: 'Work',
|
||||
externalId: 'ext-work',
|
||||
});
|
||||
const child1 = createFolder({
|
||||
id: 'child1',
|
||||
name: 'Child 1',
|
||||
parentFolderId: 'ext-work',
|
||||
externalId: 'ext-c1',
|
||||
});
|
||||
const child2 = createFolder({
|
||||
id: 'child2',
|
||||
name: 'Child 2',
|
||||
parentFolderId: 'ext-work',
|
||||
externalId: 'ext-c2',
|
||||
});
|
||||
const result = computeFolderIdsForSyncToggle({
|
||||
folderId: 'work',
|
||||
allFolders: [work, child1, child2],
|
||||
isSynced: true,
|
||||
});
|
||||
|
||||
expect(result).toContain('work');
|
||||
expect(result).toContain('child1');
|
||||
expect(result).toContain('child2');
|
||||
expect(result).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('should include both ancestors and descendants', () => {
|
||||
const root = createFolder({
|
||||
id: 'root',
|
||||
name: 'Root',
|
||||
externalId: 'ext-root',
|
||||
});
|
||||
const middle = createFolder({
|
||||
id: 'middle',
|
||||
name: 'Middle',
|
||||
parentFolderId: 'ext-root',
|
||||
externalId: 'ext-middle',
|
||||
});
|
||||
const leaf = createFolder({
|
||||
id: 'leaf',
|
||||
name: 'Leaf',
|
||||
parentFolderId: 'ext-middle',
|
||||
externalId: 'ext-leaf',
|
||||
});
|
||||
const result = computeFolderIdsForSyncToggle({
|
||||
folderId: 'middle',
|
||||
allFolders: [root, middle, leaf],
|
||||
isSynced: true,
|
||||
});
|
||||
|
||||
expect(result).toContain('root');
|
||||
expect(result).toContain('middle');
|
||||
expect(result).toContain('leaf');
|
||||
expect(result).toHaveLength(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe('when unsyncing a folder', () => {
|
||||
it('should include only the folder for a root folder', () => {
|
||||
const inbox = createFolder({
|
||||
id: 'inbox',
|
||||
name: 'Inbox',
|
||||
externalId: 'ext-inbox',
|
||||
isSynced: true,
|
||||
});
|
||||
const result = computeFolderIdsForSyncToggle({
|
||||
folderId: 'inbox',
|
||||
allFolders: [inbox],
|
||||
isSynced: false,
|
||||
});
|
||||
|
||||
expect(result).toEqual(['inbox']);
|
||||
});
|
||||
|
||||
it('should include descendants when unsyncing a parent', () => {
|
||||
const work = createFolder({
|
||||
id: 'work',
|
||||
name: 'Work',
|
||||
externalId: 'ext-work',
|
||||
isSynced: true,
|
||||
});
|
||||
const child1 = createFolder({
|
||||
id: 'child1',
|
||||
name: 'Child 1',
|
||||
parentFolderId: 'ext-work',
|
||||
externalId: 'ext-c1',
|
||||
isSynced: true,
|
||||
});
|
||||
const child2 = createFolder({
|
||||
id: 'child2',
|
||||
name: 'Child 2',
|
||||
parentFolderId: 'ext-work',
|
||||
externalId: 'ext-c2',
|
||||
isSynced: true,
|
||||
});
|
||||
const result = computeFolderIdsForSyncToggle({
|
||||
folderId: 'work',
|
||||
allFolders: [work, child1, child2],
|
||||
isSynced: false,
|
||||
});
|
||||
|
||||
expect(result).toContain('work');
|
||||
expect(result).toContain('child1');
|
||||
expect(result).toContain('child2');
|
||||
expect(result).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('should NOT unsync parent when it has other synced children', () => {
|
||||
const work = createFolder({
|
||||
id: 'work',
|
||||
name: 'Work',
|
||||
externalId: 'ext-work',
|
||||
isSynced: true,
|
||||
});
|
||||
const child1 = createFolder({
|
||||
id: 'child1',
|
||||
name: 'Child 1',
|
||||
parentFolderId: 'ext-work',
|
||||
externalId: 'ext-c1',
|
||||
isSynced: true,
|
||||
});
|
||||
const child2 = createFolder({
|
||||
id: 'child2',
|
||||
name: 'Child 2',
|
||||
parentFolderId: 'ext-work',
|
||||
externalId: 'ext-c2',
|
||||
isSynced: true,
|
||||
});
|
||||
const result = computeFolderIdsForSyncToggle({
|
||||
folderId: 'child1',
|
||||
allFolders: [work, child1, child2],
|
||||
isSynced: false,
|
||||
});
|
||||
|
||||
expect(result).toContain('child1');
|
||||
expect(result).not.toContain('work');
|
||||
expect(result).not.toContain('child2');
|
||||
expect(result).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('should unsync parent when all children are being unsynced', () => {
|
||||
const work = createFolder({
|
||||
id: 'work',
|
||||
name: 'Work',
|
||||
externalId: 'ext-work',
|
||||
isSynced: true,
|
||||
});
|
||||
const nested = createFolder({
|
||||
id: 'nested',
|
||||
name: 'Nested',
|
||||
parentFolderId: 'ext-work',
|
||||
externalId: 'ext-nested',
|
||||
isSynced: true,
|
||||
});
|
||||
const result = computeFolderIdsForSyncToggle({
|
||||
folderId: 'nested',
|
||||
allFolders: [work, nested],
|
||||
isSynced: false,
|
||||
});
|
||||
|
||||
expect(result).toContain('nested');
|
||||
expect(result).toContain('work');
|
||||
expect(result).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('should cascade unsync up to root when no other synced siblings exist', () => {
|
||||
const root = createFolder({
|
||||
id: 'root',
|
||||
name: 'Root',
|
||||
externalId: 'ext-root',
|
||||
isSynced: true,
|
||||
});
|
||||
const middle = createFolder({
|
||||
id: 'middle',
|
||||
name: 'Middle',
|
||||
parentFolderId: 'ext-root',
|
||||
externalId: 'ext-middle',
|
||||
isSynced: true,
|
||||
});
|
||||
const leaf = createFolder({
|
||||
id: 'leaf',
|
||||
name: 'Leaf',
|
||||
parentFolderId: 'ext-middle',
|
||||
externalId: 'ext-leaf',
|
||||
isSynced: true,
|
||||
});
|
||||
const result = computeFolderIdsForSyncToggle({
|
||||
folderId: 'leaf',
|
||||
allFolders: [root, middle, leaf],
|
||||
isSynced: false,
|
||||
});
|
||||
|
||||
expect(result).toContain('leaf');
|
||||
expect(result).toContain('middle');
|
||||
expect(result).toContain('root');
|
||||
expect(result).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('should stop cascading when an ancestor has other synced children', () => {
|
||||
const root = createFolder({
|
||||
id: 'root',
|
||||
name: 'Root',
|
||||
externalId: 'ext-root',
|
||||
isSynced: true,
|
||||
});
|
||||
const branch1 = createFolder({
|
||||
id: 'branch1',
|
||||
name: 'Branch 1',
|
||||
parentFolderId: 'ext-root',
|
||||
externalId: 'ext-b1',
|
||||
isSynced: true,
|
||||
});
|
||||
const branch2 = createFolder({
|
||||
id: 'branch2',
|
||||
name: 'Branch 2',
|
||||
parentFolderId: 'ext-root',
|
||||
externalId: 'ext-b2',
|
||||
isSynced: true,
|
||||
});
|
||||
const leaf = createFolder({
|
||||
id: 'leaf',
|
||||
name: 'Leaf',
|
||||
parentFolderId: 'ext-b1',
|
||||
externalId: 'ext-leaf',
|
||||
isSynced: true,
|
||||
});
|
||||
const result = computeFolderIdsForSyncToggle({
|
||||
folderId: 'leaf',
|
||||
allFolders: [root, branch1, branch2, leaf],
|
||||
isSynced: false,
|
||||
});
|
||||
|
||||
expect(result).toContain('leaf');
|
||||
expect(result).toContain('branch1');
|
||||
expect(result).not.toContain('root');
|
||||
expect(result).not.toContain('branch2');
|
||||
expect(result).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('should handle unsyncing when sibling is already unsynced', () => {
|
||||
const work = createFolder({
|
||||
id: 'work',
|
||||
name: 'Work',
|
||||
externalId: 'ext-work',
|
||||
isSynced: true,
|
||||
});
|
||||
const child1 = createFolder({
|
||||
id: 'child1',
|
||||
name: 'Child 1',
|
||||
parentFolderId: 'ext-work',
|
||||
externalId: 'ext-c1',
|
||||
isSynced: true,
|
||||
});
|
||||
const child2 = createFolder({
|
||||
id: 'child2',
|
||||
name: 'Child 2',
|
||||
parentFolderId: 'ext-work',
|
||||
externalId: 'ext-c2',
|
||||
isSynced: false,
|
||||
});
|
||||
const result = computeFolderIdsForSyncToggle({
|
||||
folderId: 'child1',
|
||||
allFolders: [work, child1, child2],
|
||||
isSynced: false,
|
||||
});
|
||||
|
||||
expect(result).toContain('child1');
|
||||
expect(result).toContain('work');
|
||||
expect(result).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
});
|
||||
+77
@@ -0,0 +1,77 @@
|
||||
import { type MessageFolder } from '@/accounts/types/MessageFolder';
|
||||
|
||||
export const computeFolderIdsForSyncToggle = ({
|
||||
folderId,
|
||||
allFolders,
|
||||
isSynced,
|
||||
}: {
|
||||
folderId: string;
|
||||
allFolders: MessageFolder[];
|
||||
isSynced: boolean;
|
||||
}): string[] => {
|
||||
const folderById = new Map(allFolders.map((folder) => [folder.id, folder]));
|
||||
const folderByExternalId = new Map(
|
||||
allFolders.map((folder) => [folder.externalId, folder]),
|
||||
);
|
||||
|
||||
const collectChildren = (id: string): string[] => {
|
||||
const folder = folderById.get(id);
|
||||
const children = folder?.externalId
|
||||
? allFolders.filter(
|
||||
(childFolder) => childFolder.parentFolderId === folder.externalId,
|
||||
)
|
||||
: [];
|
||||
|
||||
return [id, ...children.flatMap((child) => collectChildren(child.id))];
|
||||
};
|
||||
|
||||
const collectParents = (id: string): MessageFolder[] => {
|
||||
const parents: MessageFolder[] = [];
|
||||
let current = folderById.get(id);
|
||||
|
||||
while (true) {
|
||||
if (!current) {
|
||||
break;
|
||||
}
|
||||
|
||||
if (!current.parentFolderId) {
|
||||
break;
|
||||
}
|
||||
|
||||
const parent = folderByExternalId.get(current.parentFolderId);
|
||||
|
||||
if (!parent) {
|
||||
break;
|
||||
}
|
||||
|
||||
parents.push(parent);
|
||||
current = parent;
|
||||
}
|
||||
|
||||
return parents;
|
||||
};
|
||||
|
||||
const childIds = collectChildren(folderId);
|
||||
|
||||
if (isSynced) {
|
||||
const parentIds = collectParents(folderId).map((folder) => folder.id);
|
||||
|
||||
return [...new Set([...childIds, ...parentIds])];
|
||||
}
|
||||
|
||||
const idsToUnsync = new Set(childIds);
|
||||
|
||||
for (const parent of collectParents(folderId)) {
|
||||
const children = allFolders.filter(
|
||||
(folder) => folder.parentFolderId === parent.externalId,
|
||||
);
|
||||
const hasOtherSyncedChild = children.some(
|
||||
(child) => child.isSynced && !idsToUnsync.has(child.id),
|
||||
);
|
||||
|
||||
if (hasOtherSyncedChild) break;
|
||||
idsToUnsync.add(parent.id);
|
||||
}
|
||||
|
||||
return [...idsToUnsync];
|
||||
};
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
import { type MessageFolderTreeNode } from '@/settings/accounts/components/message-folders/utils/computeMessageFolderTree';
|
||||
|
||||
export const countNestedFolders = (node: MessageFolderTreeNode): number => {
|
||||
let count = node.children.length;
|
||||
|
||||
for (const child of node.children) {
|
||||
count += countNestedFolders(child);
|
||||
}
|
||||
|
||||
return count;
|
||||
};
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
import { type MessageFolderTreeNode } from '@/settings/accounts/components/message-folders/utils/computeMessageFolderTree';
|
||||
|
||||
export const isFolderTreePartiallySelected = (
|
||||
node: MessageFolderTreeNode,
|
||||
): boolean => {
|
||||
const nodes: MessageFolderTreeNode[] = [node];
|
||||
let hasSynced = false;
|
||||
let hasUnsynced = false;
|
||||
|
||||
while (nodes.length > 0) {
|
||||
const current = nodes.pop()!;
|
||||
|
||||
if (current.folder.isSynced) {
|
||||
hasSynced = true;
|
||||
} else {
|
||||
hasUnsynced = true;
|
||||
}
|
||||
|
||||
if (hasSynced && hasUnsynced) {
|
||||
return true;
|
||||
}
|
||||
|
||||
nodes.push(...current.children);
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
import { useCallback } from 'react';
|
||||
|
||||
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
|
||||
import { useUpdateManyRecords } from '@/object-record/hooks/useUpdateManyRecords';
|
||||
|
||||
type UpdateMessageFoldersSyncStatusArgs = {
|
||||
messageFolderIds: string[];
|
||||
isSynced: boolean;
|
||||
};
|
||||
|
||||
export const useUpdateMessageFoldersSyncStatus = () => {
|
||||
const { updateManyRecords } = useUpdateManyRecords({
|
||||
objectNameSingular: CoreObjectNameSingular.MessageFolder,
|
||||
recordGqlFields: {
|
||||
id: true,
|
||||
isSynced: true,
|
||||
},
|
||||
});
|
||||
|
||||
const updateMessageFoldersSyncStatus = useCallback(
|
||||
async ({
|
||||
messageFolderIds,
|
||||
isSynced,
|
||||
}: UpdateMessageFoldersSyncStatusArgs) => {
|
||||
await updateManyRecords({
|
||||
recordIdsToUpdate: messageFolderIds,
|
||||
updateOneRecordInput: { isSynced },
|
||||
});
|
||||
},
|
||||
[updateManyRecords],
|
||||
);
|
||||
|
||||
return { updateMessageFoldersSyncStatus };
|
||||
};
|
||||
-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 { 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],
|
||||
|
||||
Reference in New Issue
Block a user