Fix unclear metadata validation errors (#20234)
https://github.com/user-attachments/assets/8f8f1122-3de1-4a9b-8bb4-a3c8d31e47ae --------- Co-authored-by: Cursor <cursoragent@cursor.com> Co-authored-by: Charles Bochet <charles@twenty.com>
This commit is contained in:
+9
-6
@@ -32,6 +32,7 @@ import {
|
||||
graphQLErrorCodesToFilter,
|
||||
shouldCaptureException,
|
||||
} from 'src/engine/utils/global-exception-handler.util';
|
||||
import { translateUserFriendlyMessageDescriptors } from 'src/engine/core-modules/i18n/utils/translate-user-friendly-message-descriptors.util';
|
||||
|
||||
const DEFAULT_EVENT_ID_KEY = 'exceptionEventId';
|
||||
const SCHEMA_VERSION_HEADER = 'x-schema-version';
|
||||
@@ -223,13 +224,15 @@ export const useGraphQLErrorHandlerHook = <
|
||||
error instanceof BaseGraphQLError
|
||||
? {
|
||||
...error,
|
||||
extensions: {
|
||||
...error.extensions,
|
||||
userFriendlyMessage: i18n._(
|
||||
error.extensions.userFriendlyMessage ??
|
||||
extensions: translateUserFriendlyMessageDescriptors(
|
||||
{
|
||||
...error.extensions,
|
||||
userFriendlyMessage:
|
||||
error.extensions.userFriendlyMessage ??
|
||||
defaultErrorMessage,
|
||||
),
|
||||
},
|
||||
},
|
||||
i18n,
|
||||
),
|
||||
}
|
||||
: generateGraphQLErrorFromError(error, i18n);
|
||||
|
||||
|
||||
+148
@@ -0,0 +1,148 @@
|
||||
import { type I18n, type MessageDescriptor } from '@lingui/core';
|
||||
|
||||
import { translateUserFriendlyMessageDescriptors } from 'src/engine/core-modules/i18n/utils/translate-user-friendly-message-descriptors.util';
|
||||
|
||||
const buildDescriptor = (id: string): MessageDescriptor => ({
|
||||
id,
|
||||
message: id,
|
||||
});
|
||||
|
||||
const i18nMock = {
|
||||
_: (descriptor: MessageDescriptor) => `tr:${descriptor.id}`,
|
||||
} as unknown as I18n;
|
||||
|
||||
describe('translateUserFriendlyMessageDescriptors', () => {
|
||||
it('translates a top-level userFriendlyMessage descriptor to a string', () => {
|
||||
const result = translateUserFriendlyMessageDescriptors(
|
||||
{ userFriendlyMessage: buildDescriptor('top-level') },
|
||||
i18nMock,
|
||||
);
|
||||
|
||||
expect(result).toEqual({ userFriendlyMessage: 'tr:top-level' });
|
||||
});
|
||||
|
||||
it('leaves a userFriendlyMessage value untouched when it is already a string', () => {
|
||||
const result = translateUserFriendlyMessageDescriptors(
|
||||
{ userFriendlyMessage: 'already a string' },
|
||||
i18nMock,
|
||||
);
|
||||
|
||||
expect(result).toEqual({ userFriendlyMessage: 'already a string' });
|
||||
});
|
||||
|
||||
it('does not translate descriptors stored under other keys', () => {
|
||||
const descriptor = buildDescriptor('not-user-friendly');
|
||||
|
||||
const result = translateUserFriendlyMessageDescriptors(
|
||||
{ someOtherKey: descriptor },
|
||||
i18nMock,
|
||||
);
|
||||
|
||||
expect(result).toEqual({
|
||||
someOtherKey: { id: 'not-user-friendly', message: 'not-user-friendly' },
|
||||
});
|
||||
});
|
||||
|
||||
it('translates userFriendlyMessage descriptors nested inside arrays of objects', () => {
|
||||
const result = translateUserFriendlyMessageDescriptors(
|
||||
{
|
||||
errors: [
|
||||
{ code: 'A', userFriendlyMessage: buildDescriptor('first') },
|
||||
{ code: 'B', userFriendlyMessage: buildDescriptor('second') },
|
||||
],
|
||||
},
|
||||
i18nMock,
|
||||
);
|
||||
|
||||
expect(result).toEqual({
|
||||
errors: [
|
||||
{ code: 'A', userFriendlyMessage: 'tr:first' },
|
||||
{ code: 'B', userFriendlyMessage: 'tr:second' },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('preserves primitives, null and undefined values found along the way', () => {
|
||||
const result = translateUserFriendlyMessageDescriptors(
|
||||
{
|
||||
statusCode: 400,
|
||||
message: 'Validation failed',
|
||||
eventId: null,
|
||||
traceId: undefined,
|
||||
nested: { count: 0, flag: false },
|
||||
},
|
||||
i18nMock,
|
||||
);
|
||||
|
||||
expect(result).toEqual({
|
||||
statusCode: 400,
|
||||
message: 'Validation failed',
|
||||
eventId: null,
|
||||
traceId: undefined,
|
||||
nested: { count: 0, flag: false },
|
||||
});
|
||||
});
|
||||
|
||||
it('handles a realistic metadata-validation payload with both top-level and per-error descriptors', () => {
|
||||
const result = translateUserFriendlyMessageDescriptors(
|
||||
{
|
||||
userFriendlyMessage: buildDescriptor('Field value is required'),
|
||||
summary: { totalErrors: 1, viewGroup: 1 },
|
||||
errors: {
|
||||
viewGroup: [
|
||||
{
|
||||
status: 'fail',
|
||||
type: 'create',
|
||||
metadataName: 'viewGroup',
|
||||
errors: [
|
||||
{
|
||||
code: 'INVALID_VIEW_DATA',
|
||||
message: 'Field value is required',
|
||||
userFriendlyMessage: buildDescriptor(
|
||||
'Field value is required',
|
||||
),
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
i18nMock,
|
||||
);
|
||||
|
||||
expect(result).toEqual({
|
||||
userFriendlyMessage: 'tr:Field value is required',
|
||||
summary: { totalErrors: 1, viewGroup: 1 },
|
||||
errors: {
|
||||
viewGroup: [
|
||||
{
|
||||
status: 'fail',
|
||||
type: 'create',
|
||||
metadataName: 'viewGroup',
|
||||
errors: [
|
||||
{
|
||||
code: 'INVALID_VIEW_DATA',
|
||||
message: 'Field value is required',
|
||||
userFriendlyMessage: 'tr:Field value is required',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('does not mutate the input payload', () => {
|
||||
const descriptor = buildDescriptor('mutation-check');
|
||||
const input = { userFriendlyMessage: descriptor, nested: { value: 1 } };
|
||||
|
||||
const result = translateUserFriendlyMessageDescriptors(input, i18nMock);
|
||||
|
||||
expect(input.userFriendlyMessage).toBe(descriptor);
|
||||
expect(result).not.toBe(input);
|
||||
expect(result).toEqual({
|
||||
userFriendlyMessage: 'tr:mutation-check',
|
||||
nested: { value: 1 },
|
||||
});
|
||||
});
|
||||
});
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
import { type I18n, type MessageDescriptor } from '@lingui/core';
|
||||
import { isArray, isObject, isString } from '@sniptt/guards';
|
||||
|
||||
const USER_FRIENDLY_MESSAGE_KEY = 'userFriendlyMessage';
|
||||
|
||||
const isMessageDescriptor = (value: unknown): value is MessageDescriptor =>
|
||||
isObject(value) && 'id' in value && isString(value.id);
|
||||
|
||||
const translateValueRecursively = (
|
||||
value: unknown,
|
||||
i18n: I18n,
|
||||
parentKey?: string,
|
||||
): unknown => {
|
||||
if (parentKey === USER_FRIENDLY_MESSAGE_KEY && isMessageDescriptor(value)) {
|
||||
return i18n._(value);
|
||||
}
|
||||
|
||||
if (isArray(value)) {
|
||||
return value.map((item) =>
|
||||
translateValueRecursively(item, i18n, parentKey),
|
||||
);
|
||||
}
|
||||
|
||||
if (isObject(value)) {
|
||||
return Object.fromEntries(
|
||||
Object.entries(value).map(([key, nestedValue]) => [
|
||||
key,
|
||||
translateValueRecursively(nestedValue, i18n, key),
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
return value;
|
||||
};
|
||||
|
||||
export const translateUserFriendlyMessageDescriptors = (
|
||||
payload: object,
|
||||
i18n: I18n,
|
||||
): Record<string, unknown> =>
|
||||
translateValueRecursively(payload, i18n) as Record<string, unknown>;
|
||||
Reference in New Issue
Block a user