Skip to content
Merged
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
import { type ApolloError } from '@apollo/client';
import { t } from '@lingui/core/macro';
import { useCallback } from 'react';

import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import {
type AllMetadataName,
WorkspaceMigrationV2ExceptionCode,
} from 'twenty-shared/metadata';
import { classifyMetadataError } from '../utils/classify-metadata-error.util';

export const useMetadataErrorHandler = () => {
const { enqueueErrorSnackBar } = useSnackBar();
const TRANSLATED_METADATA_NAME = {
objectMetadata: t`object`,
fieldMetadata: t`field`,
view: t`view`,
viewField: t`view field`,
viewGroup: t`view group`,
viewFilter: t`view filter`,
index: t`index`,
serverlessFunction: t`serverless function`,
cronTrigger: t`cron trigger`,
databaseEventTrigger: t`database trigger`,
routeTrigger: t`route trigger`,
} as const satisfies Record<AllMetadataName, string>;

const handleMetadataError = useCallback(
(
error: ApolloError,
options: {
primaryMetadataName: AllMetadataName;
},
) => {
const classification = classifyMetadataError({
error,
primaryMetadataName: options.primaryMetadataName,
});

const translatedMetadataName =
TRANSLATED_METADATA_NAME[options.primaryMetadataName];

switch (classification.type) {
case 'v1':
enqueueErrorSnackBar({ apolloError: classification.error });
break;

case 'v2-validation': {
const {
extensions,
primaryMetadataName,
relatedFailingMetadataNames,
} = classification;

const targetErrors = extensions.errors[primaryMetadataName] || [];
if (targetErrors.length > 0) {
targetErrors.forEach((entityError) => {
entityError.errors.forEach((validationError) =>
enqueueErrorSnackBar({
message:
validationError.userFriendlyMessage ??
validationError.message,
}),
);
});
}

if (
targetErrors.length === 0 &&
relatedFailingMetadataNames.length > 0
) {
const relatedEntityNames = relatedFailingMetadataNames
.map((metadataName) => TRANSLATED_METADATA_NAME[metadataName])
.join(', ');

enqueueErrorSnackBar({
message: t`Failed to create ${translatedMetadataName}. Related ${relatedEntityNames} validation failed. Please check your configuration and try again.`,
});
}

if (
targetErrors.length === 0 &&
relatedFailingMetadataNames.length === 0
) {
enqueueErrorSnackBar({
message: t`Failed to create ${translatedMetadataName}. Please try again.`,
});
}
break;
}

case 'v2-internal': {
const { code } = classification;
const errorMessage =
code ===
WorkspaceMigrationV2ExceptionCode.BUILDER_INTERNAL_SERVER_ERROR
? t`An internal error occurred while validating your changes. Please contact support.`
: t`An internal error occurred while applying your changes. Please contact support and try again later.`;

enqueueErrorSnackBar({ message: errorMessage });
break;
}
}
},
[enqueueErrorSnackBar, TRANSLATED_METADATA_NAME],
);

return {
handleMetadataError,
};
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import { type ApolloError } from '@apollo/client';
import {
type AllMetadataName,
type MetadataValidationErrorResponse,
WorkspaceMigrationV2ExceptionCode,
} from 'twenty-shared/metadata';
import { isDefined } from 'twenty-shared/utils';

export type MetadataErrorClassification =
| { type: 'v1'; error: ApolloError }
| {
type: 'v2-validation';
extensions: MetadataValidationErrorResponse;
primaryMetadataName: AllMetadataName;
relatedFailingMetadataNames: AllMetadataName[];
}
| {
type: 'v2-internal';
code: WorkspaceMigrationV2ExceptionCode;
message: string;
};

const isMetadataValidationError = (
extensions: Record<string, unknown>,
): extensions is MetadataValidationErrorResponse =>
extensions.code === 'METADATA_VALIDATION_FAILED';

const isMetadataInternalError = (
extensions: Record<string, unknown>,
): boolean => {
return (
isDefined(extensions) &&
isDefined(extensions.subCode) &&
(extensions.subCode ===
WorkspaceMigrationV2ExceptionCode.BUILDER_INTERNAL_SERVER_ERROR ||
extensions.subCode ===
WorkspaceMigrationV2ExceptionCode.RUNNER_INTERNAL_SERVER_ERROR)
);
};

type ClassifyMetadataErrorArgs = {
error: ApolloError;
primaryMetadataName: AllMetadataName;
};
export const classifyMetadataError = ({
error,
primaryMetadataName,
}: ClassifyMetadataErrorArgs): MetadataErrorClassification => {
const extensions = error.graphQLErrors?.[0]?.extensions;

if (!isDefined(extensions)) {
return { type: 'v1', error };
}

if (isMetadataValidationError(extensions)) {
const failingMetadataNames = Object.keys(extensions.errors) as [
keyof MetadataValidationErrorResponse['errors'],
];
const relatedFailingMetadataNames = failingMetadataNames.filter(
(metadataName) =>
extensions.errors[metadataName].length > 0 &&
metadataName !== primaryMetadataName,
);

return {
type: 'v2-validation',
extensions,
primaryMetadataName,
relatedFailingMetadataNames,
};
}

if (isMetadataInternalError(extensions)) {
return {
type: 'v2-internal',
code: extensions.subCode as WorkspaceMigrationV2ExceptionCode,
message: (extensions.userFriendlyMessage as string) || error.message,
};
}

return { type: 'v1', error };
};
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,13 @@ import {
variables,
} from '../__mocks__/useCreateOneObjectMetadataItem';

import { jestExpectSuccessfulMetadataRequestResult } from '@/object-metadata/hooks/__tests__/utils/jest-expect-metadata-request-status.util';
import { GET_CURRENT_USER } from '@/users/graphql/queries/getCurrentUser';
import { mockedUserData } from '~/testing/mock-data/users';
import {
query as findManyObjectMetadataItemsQuery,
responseData as findManyObjectMetadataItemsResponseData,
} from '../__mocks__/useFindManyObjectMetadataItems';
import { GET_CURRENT_USER } from '@/users/graphql/queries/getCurrentUser';
import { mockedUserData } from '~/testing/mock-data/users';

const mocks = [
{
Expand Down Expand Up @@ -90,8 +91,8 @@ describe('useCreateOneObjectMetadataItem', () => {
namePlural: 'viewFilters',
nameSingular: 'viewFilter',
});

expect(res.data).toEqual({ createOneObject: responseData });
jestExpectSuccessfulMetadataRequestResult(res);
expect(res.response).toEqual({ data: { createOneObject: responseData } });
});
});
});
Original file line number Diff line number Diff line change
@@ -1,7 +1,4 @@
import { MockedProvider } from '@apollo/client/testing';
import { act, renderHook } from '@testing-library/react';
import { type ReactNode } from 'react';
import { RecoilRoot } from 'recoil';

import { useDeleteOneObjectMetadataItem } from '@/object-metadata/hooks/useDeleteOneObjectMetadataItem';

Expand All @@ -11,8 +8,10 @@ import {
variables,
} from '../__mocks__/useDeleteOneObjectMetadataItem';

import { jestExpectSuccessfulMetadataRequestResult } from '@/object-metadata/hooks/__tests__/utils/jest-expect-metadata-request-status.util';
import { GET_CURRENT_USER } from '@/users/graphql/queries/getCurrentUser';
import { FIND_ALL_CORE_VIEWS } from '@/views/graphql/queries/findAllCoreViews';
import { getJestMetadataAndApolloMocksWrapper } from '~/testing/jest/getJestMetadataAndApolloMocksWrapper';
import { mockedUserData } from '~/testing/mock-data/users';
import { mockedCoreViewsData } from '~/testing/mock-data/views';
import {
Expand Down Expand Up @@ -65,13 +64,9 @@ const mocks = [
},
];

const Wrapper = ({ children }: { children: ReactNode }) => (
<RecoilRoot>
<MockedProvider mocks={mocks} addTypename={false}>
{children}
</MockedProvider>
</RecoilRoot>
);
const Wrapper = getJestMetadataAndApolloMocksWrapper({
apolloMocks: mocks,
});

describe('useDeleteOneObjectMetadataItem', () => {
it('should work as expected', async () => {
Expand All @@ -83,7 +78,8 @@ describe('useDeleteOneObjectMetadataItem', () => {
const res =
await result.current.deleteOneObjectMetadataItem('idToDelete');

expect(res.data).toEqual({ deleteOneObject: responseData });
jestExpectSuccessfulMetadataRequestResult(res);
expect(res.response).toEqual({ data: { deleteOneObject: responseData } });
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
variables,
} from '../__mocks__/useFieldMetadataItem';

import { jestExpectSuccessfulMetadataRequestResult } from '@/object-metadata/hooks/__tests__/utils/jest-expect-metadata-request-status.util';
import { GET_CURRENT_USER } from '@/users/graphql/queries/getCurrentUser';
import { mockedUserData } from '~/testing/mock-data/users';
import {
Expand All @@ -25,8 +26,11 @@ import {
jest.mock('@/object-metadata/hooks/useUpdateOneFieldMetadataItem', () => ({
useUpdateOneFieldMetadataItem: () => ({
updateOneFieldMetadataItem: jest.fn().mockResolvedValue({
data: {
updateOneField: responseData.default,
status: 'successful',
response: {
data: {
updateOneField: responseData.default,
},
},
}),
}),
Expand Down Expand Up @@ -140,13 +144,17 @@ describe('useFieldMetadataItem', () => {
});

await act(async () => {
const res = await result.current.activateMetadataField(
const response = await result.current.activateMetadataField(
fieldMetadataItem.id,
objectMetadataId,
);

expect(res.data).toEqual({
updateOneField: responseData.default,
jestExpectSuccessfulMetadataRequestResult(response);

expect(response.response).toEqual({
data: {
updateOneField: responseData.default,
},
});
});
});
Expand All @@ -164,9 +172,12 @@ describe('useFieldMetadataItem', () => {
name: 'fieldName',
isLabelSyncedWithName: true,
});
jestExpectSuccessfulMetadataRequestResult(res);

expect(res.data).toEqual({
createOneField: responseData.createMetadataField,
expect(res.response).toEqual({
data: {
createOneField: responseData.createMetadataField,
},
});
});
});
Expand All @@ -177,12 +188,13 @@ describe('useFieldMetadataItem', () => {
});

await act(async () => {
const res = await result.current.deactivateMetadataField(
const response = await result.current.deactivateMetadataField(
fieldMetadataItem.id,
objectMetadataId,
);

expect(res.data).toEqual({
jestExpectSuccessfulMetadataRequestResult(response);
expect(response.response.data).toEqual({
updateOneField: responseData.default,
});
});
Expand All @@ -198,9 +210,12 @@ describe('useFieldMetadataItem', () => {
idToDelete: fieldMetadataItem.id,
objectMetadataId,
});
jestExpectSuccessfulMetadataRequestResult(res);

expect(res.data).toEqual({
deleteOneField: responseData.default,
expect(res.response).toEqual({
data: {
deleteOneField: responseData.default,
},
});
});
});
Expand All @@ -215,9 +230,12 @@ describe('useFieldMetadataItem', () => {
idToDelete: fieldRelationMetadataItem.id,
objectMetadataId,
});
jestExpectSuccessfulMetadataRequestResult(res);

expect(res.data).toEqual({
deleteOneField: responseData.fieldRelation,
expect(res.response).toEqual({
data: {
deleteOneField: responseData.fieldRelation,
},
});
});
});
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { expect } from '@jest/globals';

import { type MetadataRequestResult } from '@/object-metadata/types/MetadataRequestResult.type';
import { type SuccessfulMetadataRequestResult } from '@/object-metadata/types/SuccessfulMetadataRequestResult.type';

type AssertIsSuccessfulMetadataRequestResult = <T>(
value: MetadataRequestResult<T>,
) => asserts value is SuccessfulMetadataRequestResult<T>;

export const jestExpectSuccessfulMetadataRequestResult: AssertIsSuccessfulMetadataRequestResult =
(value) => {
expect(value.status).toBe('successful');
};
Loading