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:
Abdul Rahman
2026-05-05 23:48:05 +05:30
committed by GitHub
parent a03c2647cf
commit e0563377b5
70 changed files with 935 additions and 771 deletions
@@ -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);
@@ -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 },
});
});
});
@@ -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>;