Fix select default value not in options (#11622)

Also fixing a bunch of places where validation exceptions were not
properly handled
This commit is contained in:
Weiko
2025-04-17 18:34:31 +02:00
committed by GitHub
parent dd1ac4deee
commit 3fe12cd8b5
6 changed files with 141 additions and 26 deletions

View File

@ -11,6 +11,8 @@ import {
} from 'class-validator';
import { FieldMetadataType } from 'twenty-shared/types';
import { FieldMetadataDefaultValue } from 'src/engine/metadata-modules/field-metadata/interfaces/field-metadata-default-value.interface';
import { FieldMetadataOptions } from 'src/engine/metadata-modules/field-metadata/interfaces/field-metadata-options.interface';
import { FieldMetadataSettings } from 'src/engine/metadata-modules/field-metadata/interfaces/field-metadata-settings.interface';
import {
@ -86,4 +88,47 @@ export class FieldMetadataValidationService<
);
}
}
async validateDefaultValueOrThrow({
fieldType,
defaultValue,
options,
}: {
fieldType: FieldMetadataType;
defaultValue: FieldMetadataDefaultValue<T>;
options: FieldMetadataOptions<T>;
}) {
if (
fieldType === FieldMetadataType.SELECT ||
fieldType === FieldMetadataType.MULTI_SELECT
) {
this.validateEnumDefaultValue(options, defaultValue);
}
}
private validateEnumDefaultValue(
options: FieldMetadataOptions<T>,
defaultValue: FieldMetadataDefaultValue<T>,
) {
if (typeof defaultValue === 'string') {
const formattedDefaultValue = defaultValue.replace(
/^['"](.*)['"]$/,
'$1',
);
const enumOptions = options.map((option) => option.value);
if (
enumOptions &&
(enumOptions.includes(formattedDefaultValue) ||
enumOptions.some((option) => option.to === formattedDefaultValue))
) {
return;
}
}
throw new FieldMetadataException(
`Default value for existing options is invalid: ${defaultValue}`,
FieldMetadataExceptionCode.INVALID_FIELD_INPUT,
);
}
}

View File

@ -1,4 +1,4 @@
import { UnauthorizedException, UseFilters, UseGuards } from '@nestjs/common';
import { UseFilters, UseGuards } from '@nestjs/common';
import {
Args,
Context,
@ -12,7 +12,10 @@ import { FieldMetadataType } from 'twenty-shared/types';
import { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/feature-flag-key.enum';
import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service';
import { ValidationError } from 'src/engine/core-modules/graphql/utils/graphql-errors.util';
import {
ForbiddenError,
ValidationError,
} from 'src/engine/core-modules/graphql/utils/graphql-errors.util';
import { I18nContext } from 'src/engine/core-modules/i18n/types/i18n-context.type';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { IDataloaders } from 'src/engine/dataloaders/dataloader.interface';
@ -132,7 +135,7 @@ export class FieldMetadataResolver {
@AuthWorkspace() { id: workspaceId }: Workspace,
) {
if (!workspaceId) {
throw new UnauthorizedException();
throw new ForbiddenError('Could not retrieve workspace ID');
}
const fieldMetadata =

View File

@ -213,7 +213,7 @@ export class FieldMetadataService extends TypeOrmQueryService<FieldMetadataEntit
: existingFieldMetadata.defaultValue,
};
this.validateFieldMetadata<UpdateFieldInput>(
await this.validateFieldMetadata<UpdateFieldInput>(
existingFieldMetadata.type,
fieldMetadataForUpdate,
objectMetadata,
@ -545,11 +545,13 @@ export class FieldMetadataService extends TypeOrmQueryService<FieldMetadataEntit
}
}
private validateFieldMetadata<T extends UpdateFieldInput | CreateFieldInput>(
private async validateFieldMetadata<
T extends UpdateFieldInput | CreateFieldInput,
>(
fieldMetadataType: FieldMetadataType,
fieldMetadataInput: T,
objectMetadata: ObjectMetadataEntity,
): T {
): Promise<T> {
if (fieldMetadataInput.name) {
try {
validateMetadataNameOrThrow(fieldMetadataInput.name);
@ -599,10 +601,17 @@ export class FieldMetadataService extends TypeOrmQueryService<FieldMetadataEntit
);
}
}
if (isDefined(fieldMetadataInput.defaultValue)) {
await this.fieldMetadataValidationService.validateDefaultValueOrThrow({
fieldType: fieldMetadataType,
options: fieldMetadataInput.options,
defaultValue: fieldMetadataInput.defaultValue ?? null,
});
}
}
if (fieldMetadataInput.settings) {
this.fieldMetadataValidationService.validateSettingsOrThrow({
await this.fieldMetadataValidationService.validateSettingsOrThrow({
fieldType: fieldMetadataType,
settings: fieldMetadataInput.settings,
});
@ -717,7 +726,7 @@ export class FieldMetadataService extends TypeOrmQueryService<FieldMetadataEntit
const fieldMetadataForCreate =
this.prepareCustomFieldMetadata(fieldMetadataInput);
this.validateFieldMetadata<CreateFieldInput>(
await this.validateFieldMetadata<CreateFieldInput>(
fieldMetadataForCreate.type,
fieldMetadataForCreate,
objectMetadata,

View File

@ -1,9 +1,12 @@
import { BadRequestException, UnauthorizedException } from '@nestjs/common';
import { Test, TestingModule } from '@nestjs/testing';
import { i18n } from '@lingui/core';
import { UpdateOneInputType } from '@ptc-org/nestjs-query-graphql';
import {
ForbiddenError,
ValidationError,
} from 'src/engine/core-modules/graphql/utils/graphql-errors.util';
import { UpdateFieldInput } from 'src/engine/metadata-modules/field-metadata/dtos/update-field.input';
import { FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity';
import { FieldMetadataService } from 'src/engine/metadata-modules/field-metadata/field-metadata.service';
@ -48,7 +51,7 @@ describe('BeforeUpdateOneField', () => {
jest.clearAllMocks();
});
it('should throw UnauthorizedException if workspaceId is not provided', async () => {
it('should throw ForbiddenError if workspaceId is not provided', async () => {
const instance: UpdateOneInputType<UpdateFieldInputForTest> = {
id: mockFieldId,
update: {
@ -61,10 +64,10 @@ describe('BeforeUpdateOneField', () => {
workspaceId: '',
locale: undefined,
}),
).rejects.toThrow(UnauthorizedException);
).rejects.toThrow(ForbiddenError);
});
it('should throw BadRequestException if field does not exist', async () => {
it('should throw ValidationError if field does not exist', async () => {
const instance: UpdateOneInputType<UpdateFieldInputForTest> = {
id: mockFieldId,
update: {
@ -81,7 +84,7 @@ describe('BeforeUpdateOneField', () => {
workspaceId: mockWorkspaceId,
locale: undefined,
}),
).rejects.toThrow(BadRequestException);
).rejects.toThrow(ValidationError);
});
it('should not affect custom fields', async () => {
@ -113,7 +116,7 @@ describe('BeforeUpdateOneField', () => {
expect(result).toEqual(instance);
});
it('should throw BadRequestException when trying to update non-updatable fields on standard fields', async () => {
it('should throw ValidationError when trying to update non-updatable fields on standard fields', async () => {
const instance: UpdateOneInputType<UpdateFieldInputForTest> = {
id: mockFieldId,
update: {
@ -135,10 +138,10 @@ describe('BeforeUpdateOneField', () => {
workspaceId: mockWorkspaceId,
locale: undefined,
}),
).rejects.toThrow(BadRequestException);
).rejects.toThrow(ValidationError);
});
it('should throw BadRequestException when trying to update label when it is synced with name', async () => {
it('should throw ValidationError when trying to update label when it is synced with name', async () => {
const instance: UpdateOneInputType<UpdateFieldInputForTest> = {
id: mockFieldId,
update: {
@ -162,7 +165,7 @@ describe('BeforeUpdateOneField', () => {
workspaceId: mockWorkspaceId,
locale: undefined,
}),
).rejects.toThrow(BadRequestException);
).rejects.toThrow(ValidationError);
});
it('should handle isActive updates for standard fields', async () => {

View File

@ -1,8 +1,4 @@
import {
BadRequestException,
Injectable,
UnauthorizedException,
} from '@nestjs/common';
import { Injectable } from '@nestjs/common';
import { i18n } from '@lingui/core';
import {
@ -12,6 +8,10 @@ import {
import { APP_LOCALES, SOURCE_LOCALE } from 'twenty-shared/translations';
import { isDefined } from 'twenty-shared/utils';
import {
ForbiddenError,
ValidationError,
} from 'src/engine/core-modules/graphql/utils/graphql-errors.util';
import { generateMessageId } from 'src/engine/core-modules/i18n/utils/generateMessageId';
import { FieldStandardOverridesDTO } from 'src/engine/metadata-modules/field-metadata/dtos/field-standard-overrides.dto';
import { UpdateFieldInput } from 'src/engine/metadata-modules/field-metadata/dtos/update-field.input';
@ -39,7 +39,7 @@ export class BeforeUpdateOneField<T extends UpdateFieldInput>
},
): Promise<UpdateOneInputType<T>> {
if (!workspaceId) {
throw new UnauthorizedException();
throw new ForbiddenError('Could not retrieve workspace ID');
}
const fieldMetadata = await this.getFieldMetadata(instance, workspaceId);
@ -63,7 +63,7 @@ export class BeforeUpdateOneField<T extends UpdateFieldInput>
});
if (!fieldMetadata) {
throw new BadRequestException('Field does not exist');
throw new ValidationError('Field does not exist');
}
return fieldMetadata;
@ -96,13 +96,13 @@ export class BeforeUpdateOneField<T extends UpdateFieldInput>
instance.update.label !== fieldMetadata.label;
if (isUpdatingLabelWhenSynced) {
throw new BadRequestException(
throw new ValidationError(
'Cannot update label when it is synced with name',
);
}
if (nonUpdatableFields.length > 0) {
throw new BadRequestException(
throw new ValidationError(
`Only isActive, isLabelSyncedWithName, label, icon, description and defaultValue fields can be updated for standard fields. Invalid fields: ${nonUpdatableFields.join(', ')}`,
);
}

View File

@ -116,4 +116,59 @@ describe('updateOne', () => {
);
});
});
describe('FieldMetadataService Enum Default Value Validation', () => {
it('should throw an error if the default value is not in the options', async () => {
const { data: listingObjectMetadata } = await createOneObjectMetadata({
input: {
labelSingular: LISTING_NAME_SINGULAR,
labelPlural: LISTING_NAME_PLURAL,
nameSingular: LISTING_NAME_SINGULAR,
namePlural: LISTING_NAME_PLURAL,
icon: 'IconBuildingSkyscraper',
isLabelSyncedWithName: true,
},
});
const { data: createdFieldMetadata } = await createOneFieldMetadata({
input: {
objectMetadataId: listingObjectMetadata.createOneObject.id,
type: FieldMetadataType.SELECT,
name: 'testName',
label: 'Test name',
isLabelSyncedWithName: true,
options: [
{
label: 'Option 1',
value: 'option1',
color: 'green',
position: 1,
},
],
},
});
const { errors } = await updateOneFieldMetadata({
input: {
idToUpdate: createdFieldMetadata.createOneField.id,
updatePayload: {
defaultValue: 'option2',
},
},
gqlFields: `
id
name
label
isLabelSyncedWithName
`,
expectToFail: true,
});
expect(errors[0].message).toBe('Invalid default value "option2"');
await deleteOneObjectMetadata({
input: { idToDelete: listingObjectMetadata.createOneObject.id },
});
});
});
});