Update user friendly errors for translations (#15000)
Force msg typing instead of string for user friendly errors
This commit is contained in:
+30
-8
@@ -18,6 +18,7 @@ import {
|
||||
ForbiddenError,
|
||||
ValidationError,
|
||||
} from 'src/engine/core-modules/graphql/utils/graphql-errors.util';
|
||||
import { I18nService } from 'src/engine/core-modules/i18n/i18n.service';
|
||||
import { I18nContext } from 'src/engine/core-modules/i18n/types/i18n-context.type';
|
||||
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
import { type IDataloaders } from 'src/engine/dataloaders/dataloader.interface';
|
||||
@@ -52,6 +53,7 @@ export class FieldMetadataResolver {
|
||||
private readonly beforeUpdateOneField: BeforeUpdateOneField<UpdateFieldInput>,
|
||||
private readonly featureFlagService: FeatureFlagService,
|
||||
private readonly fieldMetadataServiceV2: FieldMetadataServiceV2,
|
||||
private readonly i18nService: I18nService,
|
||||
) {}
|
||||
|
||||
@UseGuards(SettingsPermissionsGuard(PermissionFlagType.DATA_MODEL))
|
||||
@@ -59,6 +61,7 @@ export class FieldMetadataResolver {
|
||||
async createOneField(
|
||||
@Args('input') input: CreateOneFieldMetadataInput,
|
||||
@AuthWorkspace() { id: workspaceId }: Workspace,
|
||||
@Context() context: I18nContext,
|
||||
) {
|
||||
try {
|
||||
return await this.fieldMetadataService.createOne({
|
||||
@@ -66,7 +69,10 @@ export class FieldMetadataResolver {
|
||||
workspaceId,
|
||||
});
|
||||
} catch (error) {
|
||||
return fieldMetadataGraphqlApiExceptionHandler(error);
|
||||
return fieldMetadataGraphqlApiExceptionHandler(
|
||||
error,
|
||||
this.i18nService.getI18nInstance(context.req.locale),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -101,7 +107,10 @@ export class FieldMetadataResolver {
|
||||
workspaceId,
|
||||
});
|
||||
} catch (error) {
|
||||
fieldMetadataGraphqlApiExceptionHandler(error);
|
||||
fieldMetadataGraphqlApiExceptionHandler(
|
||||
error,
|
||||
this.i18nService.getI18nInstance(context.req.locale),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -110,6 +119,7 @@ export class FieldMetadataResolver {
|
||||
async deleteOneField(
|
||||
@Args('input') input: DeleteOneFieldInput,
|
||||
@AuthWorkspace() { id: workspaceId }: Workspace,
|
||||
@Context() context: I18nContext,
|
||||
) {
|
||||
if (!isDefined(workspaceId)) {
|
||||
throw new ForbiddenError('Could not retrieve workspace ID');
|
||||
@@ -129,7 +139,10 @@ export class FieldMetadataResolver {
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
fieldMetadataGraphqlApiExceptionHandler(error);
|
||||
fieldMetadataGraphqlApiExceptionHandler(
|
||||
error,
|
||||
this.i18nService.getI18nInstance(context.req.locale),
|
||||
);
|
||||
}
|
||||
|
||||
const fieldMetadata =
|
||||
@@ -154,7 +167,10 @@ export class FieldMetadataResolver {
|
||||
try {
|
||||
return await this.fieldMetadataService.deleteOneField(input, workspaceId);
|
||||
} catch (error) {
|
||||
fieldMetadataGraphqlApiExceptionHandler(error);
|
||||
fieldMetadataGraphqlApiExceptionHandler(
|
||||
error,
|
||||
this.i18nService.getI18nInstance(context.req.locale),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -167,7 +183,7 @@ export class FieldMetadataResolver {
|
||||
id: fieldMetadataId,
|
||||
objectMetadataId,
|
||||
}: Pick<FieldMetadataDTO, 'id' | 'objectMetadataId'>,
|
||||
@Context() context: { loaders: IDataloaders },
|
||||
@Context() context: { loaders: IDataloaders } & I18nContext,
|
||||
): Promise<RelationDTO | null> {
|
||||
try {
|
||||
return await context.loaders.relationLoader.load({
|
||||
@@ -176,7 +192,10 @@ export class FieldMetadataResolver {
|
||||
workspaceId: workspace.id,
|
||||
});
|
||||
} catch (error) {
|
||||
return fieldMetadataGraphqlApiExceptionHandler(error);
|
||||
return fieldMetadataGraphqlApiExceptionHandler(
|
||||
error,
|
||||
this.i18nService.getI18nInstance(context.req.locale),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -188,7 +207,7 @@ export class FieldMetadataResolver {
|
||||
id: fieldMetadataId,
|
||||
objectMetadataId,
|
||||
}: Pick<FieldMetadataDTO, 'id' | 'objectMetadataId'>,
|
||||
@Context() context: { loaders: IDataloaders },
|
||||
@Context() context: { loaders: IDataloaders } & I18nContext,
|
||||
): Promise<RelationDTO[] | null> {
|
||||
try {
|
||||
return await context.loaders.morphRelationLoader.load({
|
||||
@@ -197,7 +216,10 @@ export class FieldMetadataResolver {
|
||||
workspaceId: workspace.id,
|
||||
});
|
||||
} catch (error) {
|
||||
return fieldMetadataGraphqlApiExceptionHandler(error);
|
||||
return fieldMetadataGraphqlApiExceptionHandler(
|
||||
error,
|
||||
this.i18nService.getI18nInstance(context.req.locale),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+16
-2
@@ -1,20 +1,34 @@
|
||||
import {
|
||||
type CallHandler,
|
||||
type ExecutionContext,
|
||||
Injectable,
|
||||
type NestInterceptor,
|
||||
} from '@nestjs/common';
|
||||
import { GqlExecutionContext } from '@nestjs/graphql';
|
||||
|
||||
import { type Observable, catchError } from 'rxjs';
|
||||
import { SOURCE_LOCALE } from 'twenty-shared/translations';
|
||||
|
||||
import { I18nService } from 'src/engine/core-modules/i18n/i18n.service';
|
||||
import { fieldMetadataGraphqlApiExceptionHandler } from 'src/engine/metadata-modules/field-metadata/utils/field-metadata-graphql-api-exception-handler.util';
|
||||
|
||||
@Injectable()
|
||||
export class FieldMetadataGraphqlApiExceptionInterceptor
|
||||
implements NestInterceptor
|
||||
{
|
||||
constructor(private readonly i18nService: I18nService) {}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
intercept(_: ExecutionContext, next: CallHandler): Observable<any> {
|
||||
intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
|
||||
const gqlContext = GqlExecutionContext.create(context);
|
||||
const ctx = gqlContext.getContext();
|
||||
const locale = ctx.req?.locale ?? SOURCE_LOCALE;
|
||||
const i18n = this.i18nService.getI18nInstance(locale);
|
||||
|
||||
return next
|
||||
.handle()
|
||||
.pipe(catchError((err) => fieldMetadataGraphqlApiExceptionHandler(err)));
|
||||
.pipe(
|
||||
catchError((err) => fieldMetadataGraphqlApiExceptionHandler(err, i18n)),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+20
-19
@@ -1,6 +1,7 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
import { t } from '@lingui/core/macro';
|
||||
import { type MessageDescriptor } from '@lingui/core';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { isNonEmptyString } from '@sniptt/guards';
|
||||
import {
|
||||
type EnumFieldMetadataType,
|
||||
@@ -31,7 +32,7 @@ import { isSnakeCaseString } from 'src/utils/is-snake-case-string';
|
||||
|
||||
type Validator<T> = {
|
||||
validator: (str: T) => boolean;
|
||||
message: string;
|
||||
message: MessageDescriptor;
|
||||
};
|
||||
|
||||
type FieldMetadataUpdateCreateInput = CreateFieldInput | UpdateFieldInput;
|
||||
@@ -59,7 +60,7 @@ export class FieldMetadataEnumValidationService {
|
||||
|
||||
if (shouldThrow) {
|
||||
throw new FieldMetadataException(
|
||||
message,
|
||||
message.message ?? 'Invalid field input',
|
||||
FieldMetadataExceptionCode.INVALID_FIELD_INPUT,
|
||||
{
|
||||
userFriendlyMessage: message,
|
||||
@@ -72,11 +73,11 @@ export class FieldMetadataEnumValidationService {
|
||||
const validators: Validator<string>[] = [
|
||||
{
|
||||
validator: (id) => !isDefined(id),
|
||||
message: 'Option id is required',
|
||||
message: msg`Option id is required`,
|
||||
},
|
||||
{
|
||||
validator: (id) => !z.string().uuid().safeParse(id).success,
|
||||
message: 'Option id is invalid',
|
||||
message: msg`Option id is invalid`,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -89,23 +90,23 @@ export class FieldMetadataEnumValidationService {
|
||||
const validators: Validator<string>[] = [
|
||||
{
|
||||
validator: (label) => !isDefined(label),
|
||||
message: t`Option label is required`,
|
||||
message: msg`Option label is required`,
|
||||
},
|
||||
{
|
||||
validator: exceedsDatabaseIdentifierMaximumLength,
|
||||
message: t`Option label exceeds 63 characters`,
|
||||
message: msg`Option label exceeds 63 characters`,
|
||||
},
|
||||
{
|
||||
validator: beneathDatabaseIdentifierMinimumLength,
|
||||
message: t`Option label "${sanitizedLabel}" is beneath 1 character`,
|
||||
message: msg`Option label "${sanitizedLabel}" is beneath 1 character`,
|
||||
},
|
||||
{
|
||||
validator: (label) => label.includes(','),
|
||||
message: t`Label must not contain a comma`,
|
||||
message: msg`Label must not contain a comma`,
|
||||
},
|
||||
{
|
||||
validator: (label) => !isNonEmptyString(label) || label === ' ',
|
||||
message: t`Label must not be empty`,
|
||||
message: msg`Label must not be empty`,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -118,19 +119,19 @@ export class FieldMetadataEnumValidationService {
|
||||
const validators: Validator<string>[] = [
|
||||
{
|
||||
validator: (value) => !isDefined(value),
|
||||
message: t`Option value is required`,
|
||||
message: msg`Option value is required`,
|
||||
},
|
||||
{
|
||||
validator: exceedsDatabaseIdentifierMaximumLength,
|
||||
message: t`Option value exceeds 63 characters`,
|
||||
message: msg`Option value exceeds 63 characters`,
|
||||
},
|
||||
{
|
||||
validator: beneathDatabaseIdentifierMinimumLength,
|
||||
message: t`Option value "${sanitizedValue}" is beneath 1 character`,
|
||||
message: msg`Option value "${sanitizedValue}" is beneath 1 character`,
|
||||
},
|
||||
{
|
||||
validator: (value) => !isSnakeCaseString(value),
|
||||
message: `Value must be in UPPER_CASE and follow snake_case "${sanitizedValue}"`,
|
||||
message: msg`Value must be in UPPER_CASE and follow snake_case "${sanitizedValue}"`,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -153,7 +154,7 @@ export class FieldMetadataEnumValidationService {
|
||||
const duplicatedValidators = fieldsToCheckForDuplicates.map<
|
||||
Validator<FieldMetadataDefaultOption[] | FieldMetadataComplexOption[]>
|
||||
>((field) => ({
|
||||
message: `Duplicated option ${field}`,
|
||||
message: msg`Duplicated option ${field}`,
|
||||
validator: () =>
|
||||
new Set(options.map((option) => option[field])).size !== options.length,
|
||||
}));
|
||||
@@ -198,7 +199,7 @@ export class FieldMetadataEnumValidationService {
|
||||
const validators: Validator<string>[] = [
|
||||
{
|
||||
validator: (value: string) => !QUOTED_STRING_REGEX.test(value),
|
||||
message: 'Default value should be as quoted string',
|
||||
message: msg`Default value should be as quoted string`,
|
||||
},
|
||||
{
|
||||
validator: (value: string) =>
|
||||
@@ -206,7 +207,7 @@ export class FieldMetadataEnumValidationService {
|
||||
(option) =>
|
||||
option.value === value.replace(QUOTED_STRING_REGEX, '$1'),
|
||||
),
|
||||
message: `Default value "${defaultValue}" must be one of the option values`,
|
||||
message: msg`Default value "${defaultValue}" must be one of the option values`,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -229,11 +230,11 @@ export class FieldMetadataEnumValidationService {
|
||||
const validators: Validator<string[]>[] = [
|
||||
{
|
||||
validator: (values) => values.length === 0,
|
||||
message: 'If defined default value must contain at least one value',
|
||||
message: msg`If defined default value must contain at least one value`,
|
||||
},
|
||||
{
|
||||
validator: (values) => new Set(values).size !== values.length,
|
||||
message: 'Default values must be unique',
|
||||
message: msg`Default values must be unique`,
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
import { t } from '@lingui/core/macro';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { IsEnum, IsString, IsUUID } from 'class-validator';
|
||||
import { FieldMetadataType } from 'twenty-shared/types';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
@@ -201,7 +201,7 @@ export class FieldMetadataRelationService {
|
||||
`Name "${computedMetadataNameFromLabel}" cannot be the same on both side of the relation`,
|
||||
FieldMetadataExceptionCode.INVALID_FIELD_INPUT,
|
||||
{
|
||||
userFriendlyMessage: t`Name "${computedMetadataNameFromLabel}" cannot be the same on both side of the relation`,
|
||||
userFriendlyMessage: msg`Name "${computedMetadataNameFromLabel}" cannot be the same on both side of the relation`,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
import { t } from '@lingui/core/macro';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { type ClassConstructor, plainToInstance } from 'class-transformer';
|
||||
import {
|
||||
IsArray,
|
||||
@@ -196,7 +196,7 @@ export class FieldMetadataValidationService {
|
||||
`Name "${fieldMetadataInput.name}" is not available, check that it is not duplicating another field's name.`,
|
||||
FieldMetadataExceptionCode.INVALID_FIELD_INPUT,
|
||||
{
|
||||
userFriendlyMessage: t`Name is not available, it may be duplicating another field's name.`,
|
||||
userFriendlyMessage: msg`Name is not available, it may be duplicating another field's name.`,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
+4
-4
@@ -1,7 +1,7 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectDataSource, InjectRepository } from '@nestjs/typeorm';
|
||||
|
||||
import { t } from '@lingui/core/macro';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { TypeOrmQueryService } from '@ptc-org/nestjs-query-typeorm';
|
||||
import { FieldMetadataType } from 'twenty-shared/types';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
@@ -190,7 +190,7 @@ export class FieldMetadataService extends TypeOrmQueryService<FieldMetadataEntit
|
||||
'Unique field cannot have a default value',
|
||||
FieldMetadataExceptionCode.INVALID_FIELD_INPUT,
|
||||
{
|
||||
userFriendlyMessage: t`Unique field cannot have a default value`,
|
||||
userFriendlyMessage: msg`Unique field cannot have a default value`,
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -469,7 +469,7 @@ export class FieldMetadataService extends TypeOrmQueryService<FieldMetadataEntit
|
||||
'Cannot delete, please update the label identifier field first',
|
||||
FieldMetadataExceptionCode.FIELD_MUTATION_NOT_ALLOWED,
|
||||
{
|
||||
userFriendlyMessage: t`Cannot delete, please update the label identifier field first`,
|
||||
userFriendlyMessage: msg`Cannot delete, please update the label identifier field first`,
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -830,7 +830,7 @@ export class FieldMetadataService extends TypeOrmQueryService<FieldMetadataEntit
|
||||
'Unique field cannot have a default value',
|
||||
FieldMetadataExceptionCode.INVALID_FIELD_INPUT,
|
||||
{
|
||||
userFriendlyMessage: t`Unique field cannot have a default value`,
|
||||
userFriendlyMessage: msg`Unique field cannot have a default value`,
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
+6
-2
@@ -1,3 +1,4 @@
|
||||
import { type I18n } from '@lingui/core';
|
||||
import { assertUnreachable } from 'twenty-shared/utils';
|
||||
|
||||
import {
|
||||
@@ -14,9 +15,12 @@ import { InvalidMetadataException } from 'src/engine/metadata-modules/utils/exce
|
||||
import { WorkspaceMigrationBuilderExceptionV2 } from 'src/engine/workspace-manager/workspace-migration-v2/exceptions/workspace-migration-builder-exception-v2';
|
||||
import { workspaceMigrationBuilderExceptionV2Formatter } from 'src/engine/workspace-manager/workspace-migration-v2/interceptors/workspace-migration-builder-exception-v2-formatter';
|
||||
|
||||
export const fieldMetadataGraphqlApiExceptionHandler = (error: Error) => {
|
||||
export const fieldMetadataGraphqlApiExceptionHandler = (
|
||||
error: Error,
|
||||
i18n: I18n,
|
||||
) => {
|
||||
if (error instanceof WorkspaceMigrationBuilderExceptionV2) {
|
||||
workspaceMigrationBuilderExceptionV2Formatter(error);
|
||||
workspaceMigrationBuilderExceptionV2Formatter(error, i18n);
|
||||
}
|
||||
|
||||
if (error instanceof InvalidMetadataException) {
|
||||
|
||||
Reference in New Issue
Block a user