diff --git a/packages/twenty-server/src/engine/metadata-modules/field-metadata/field-metadata.resolver.ts b/packages/twenty-server/src/engine/metadata-modules/field-metadata/field-metadata.resolver.ts index 09ad02859c..93615c4de2 100644 --- a/packages/twenty-server/src/engine/metadata-modules/field-metadata/field-metadata.resolver.ts +++ b/packages/twenty-server/src/engine/metadata-modules/field-metadata/field-metadata.resolver.ts @@ -22,9 +22,16 @@ import { RelationDTO } from 'src/engine/metadata-modules/field-metadata/dtos/rel import { UpdateOneFieldMetadataInput } from 'src/engine/metadata-modules/field-metadata/dtos/update-field.input'; import { FieldMetadataService } from 'src/engine/metadata-modules/field-metadata/services/field-metadata.service'; import { fieldMetadataGraphqlApiExceptionHandler } from 'src/engine/metadata-modules/field-metadata/utils/field-metadata-graphql-api-exception-handler.util'; +import { resolveFieldMetadataStandardOverride } from 'src/engine/metadata-modules/field-metadata/utils/resolve-field-metadata-standard-override.util'; import { fromFlatFieldMetadataToFieldMetadataDto } from 'src/engine/metadata-modules/flat-field-metadata/utils/from-flat-field-metadata-to-field-metadata-dto.util'; import { PermissionsGraphqlApiExceptionFilter } from 'src/engine/metadata-modules/permissions/utils/permissions-graphql-api-exception.filter'; +// Keep @Parent() structurally typed so ResolverValidationPipe does not validate +// FieldMetadataDTO date decorators on already-loaded parent records. +type FieldMetadataStandardOverrideParent = Parameters< + typeof resolveFieldMetadataStandardOverride +>[0]; + @UseGuards(WorkspaceAuthGuard) @UsePipes(ResolverValidationPipe) @MetadataResolver(() => FieldMetadataDTO) @@ -38,6 +45,51 @@ export class FieldMetadataResolver { private readonly i18nService: I18nService, ) {} + @ResolveField(() => String, { nullable: true }) + async label( + @Parent() fieldMetadata: FieldMetadataStandardOverrideParent, + @Context() context: I18nContext, + ): Promise { + const i18n = this.i18nService.getI18nInstance(context.req.locale); + + return resolveFieldMetadataStandardOverride( + fieldMetadata, + 'label', + context.req.locale, + i18n, + ); + } + + @ResolveField(() => String, { nullable: true }) + async description( + @Parent() fieldMetadata: FieldMetadataStandardOverrideParent, + @Context() context: I18nContext, + ): Promise { + const i18n = this.i18nService.getI18nInstance(context.req.locale); + + return resolveFieldMetadataStandardOverride( + fieldMetadata, + 'description', + context.req.locale, + i18n, + ); + } + + @ResolveField(() => String, { nullable: true }) + async icon( + @Parent() fieldMetadata: FieldMetadataStandardOverrideParent, + @Context() context: I18nContext, + ): Promise { + const i18n = this.i18nService.getI18nInstance(context.req.locale); + + return resolveFieldMetadataStandardOverride( + fieldMetadata, + 'icon', + context.req.locale, + i18n, + ); + } + @UseGuards(SettingsPermissionGuard(PermissionFlagType.DATA_MODEL)) @Mutation(() => FieldMetadataDTO) async createOneField( diff --git a/packages/twenty-server/src/engine/metadata-modules/field-metadata/utils/__tests__/resolve-field-metadata-standard-override.util.spec.ts b/packages/twenty-server/src/engine/metadata-modules/field-metadata/utils/__tests__/resolve-field-metadata-standard-override.util.spec.ts index 99e324ea5b..3b5e608226 100644 --- a/packages/twenty-server/src/engine/metadata-modules/field-metadata/utils/__tests__/resolve-field-metadata-standard-override.util.spec.ts +++ b/packages/twenty-server/src/engine/metadata-modules/field-metadata/utils/__tests__/resolve-field-metadata-standard-override.util.spec.ts @@ -262,7 +262,7 @@ describe('resolveFieldMetadataStandardOverride', () => { ).toBe('overridden-icon'); }); - it('should not use direct override for non-SOURCE_LOCALE', () => { + it('should use direct override for non-SOURCE_LOCALE when translation override is missing', () => { const fieldMetadata = { label: 'Standard Label', description: 'Standard Description', @@ -283,7 +283,9 @@ describe('resolveFieldMetadataStandardOverride', () => { mockI18n, ); - expect(result).toBe('Standard Label'); + expect(result).toBe('Overridden Label'); + expect(mockGenerateMessageId).not.toHaveBeenCalled(); + expect(mockI18n._).not.toHaveBeenCalled(); }); it('should not use empty string override for SOURCE_LOCALE', () => { diff --git a/packages/twenty-server/src/engine/metadata-modules/field-metadata/utils/resolve-field-metadata-standard-override.util.ts b/packages/twenty-server/src/engine/metadata-modules/field-metadata/utils/resolve-field-metadata-standard-override.util.ts index 807bcc70d2..b854796c31 100644 --- a/packages/twenty-server/src/engine/metadata-modules/field-metadata/utils/resolve-field-metadata-standard-override.util.ts +++ b/packages/twenty-server/src/engine/metadata-modules/field-metadata/utils/resolve-field-metadata-standard-override.util.ts @@ -16,6 +16,8 @@ export const resolveFieldMetadataStandardOverride = ( locale: keyof typeof APP_LOCALES | undefined, i18nInstance: I18n, ): string => { + const safeLocale = locale ?? SOURCE_LOCALE; + if (fieldMetadata.isCustom) { return fieldMetadata[labelKey] ?? ''; } @@ -26,21 +28,17 @@ export const resolveFieldMetadataStandardOverride = ( if ( isDefined(fieldMetadata.standardOverrides?.translations) && - isDefined(locale) && labelKey !== 'icon' ) { const translationValue = - fieldMetadata.standardOverrides.translations[locale]?.[labelKey]; + fieldMetadata.standardOverrides.translations[safeLocale]?.[labelKey]; if (isDefined(translationValue)) { return translationValue; } } - if ( - locale === SOURCE_LOCALE && - isNonEmptyString(fieldMetadata.standardOverrides?.[labelKey]) - ) { + if (isNonEmptyString(fieldMetadata.standardOverrides?.[labelKey])) { return fieldMetadata.standardOverrides[labelKey] ?? ''; } diff --git a/packages/twenty-server/src/engine/subscriptions/metadata-event/metadata-event-publisher.ts b/packages/twenty-server/src/engine/subscriptions/metadata-event/metadata-event-publisher.ts index be2930a23a..bc45209a6f 100644 --- a/packages/twenty-server/src/engine/subscriptions/metadata-event/metadata-event-publisher.ts +++ b/packages/twenty-server/src/engine/subscriptions/metadata-event/metadata-event-publisher.ts @@ -6,6 +6,7 @@ import { isDefined, isNonEmptyArray } from 'twenty-shared/utils'; import { I18nService } from 'src/engine/core-modules/i18n/i18n.service'; import { WorkspaceManyOrAllFlatEntityMapsCacheService } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.service'; import { NavigationMenuItemRecordIdentifierService } from 'src/engine/metadata-modules/navigation-menu-item/services/navigation-menu-item-record-identifier.service'; +import { FIELD_METADATA_STANDARD_OVERRIDES_PROPERTIES } from 'src/engine/metadata-modules/field-metadata/constants/field-metadata-standard-overrides-properties.constant'; import { OBJECT_METADATA_STANDARD_OVERRIDES_PROPERTIES } from 'src/engine/metadata-modules/object-metadata/constants/object-metadata-standard-overrides-properties.constant'; import { type MetadataEventBatch } from 'src/engine/subscriptions/metadata-event/types/metadata-event-batch.type'; import { type FlatCommandMenuItem } from 'src/engine/metadata-modules/flat-command-menu-item/types/flat-command-menu-item.type'; @@ -80,25 +81,37 @@ export class MetadataEventPublisher { ); const enrichedEvents = metadataEventBatch.events.map((event) => { + const enrichedProperties = { ...event.properties }; + if ( - !('after' in event.properties) || - !isDefined(event.properties.after) + 'before' in enrichedProperties && + isDefined(enrichedProperties.before) ) { - return event; + enrichedProperties.before = this.applyStandardOverridesToMetadataRecord( + enrichedProperties.before as Record, + FIELD_METADATA_STANDARD_OVERRIDES_PROPERTIES, + ) as typeof enrichedProperties.before; } - const enrichedAfter = enrichFieldMetadataEventWithRelations({ - record: event.properties.after as Record, - flatFieldMetadataMaps, - flatObjectMetadataMaps, - }); + if ( + 'after' in enrichedProperties && + isDefined(enrichedProperties.after) + ) { + const enrichedAfter = enrichFieldMetadataEventWithRelations({ + record: enrichedProperties.after as Record, + flatFieldMetadataMaps, + flatObjectMetadataMaps, + }); + + enrichedProperties.after = this.applyStandardOverridesToMetadataRecord( + enrichedAfter, + FIELD_METADATA_STANDARD_OVERRIDES_PROPERTIES, + ) as typeof enrichedProperties.after; + } return { ...event, - properties: { - ...event.properties, - after: enrichedAfter, - }, + properties: enrichedProperties, } as typeof event; }); @@ -204,20 +217,20 @@ export class MetadataEventPublisher { 'before' in enrichedProperties && isDefined(enrichedProperties.before) ) { - enrichedProperties.before = - this.applyStandardOverridesToObjectMetadataRecord( - enrichedProperties.before as Record, - ) as typeof enrichedProperties.before; + enrichedProperties.before = this.applyStandardOverridesToMetadataRecord( + enrichedProperties.before as Record, + OBJECT_METADATA_STANDARD_OVERRIDES_PROPERTIES, + ) as typeof enrichedProperties.before; } if ( 'after' in enrichedProperties && isDefined(enrichedProperties.after) ) { - enrichedProperties.after = - this.applyStandardOverridesToObjectMetadataRecord( - enrichedProperties.after as Record, - ) as typeof enrichedProperties.after; + enrichedProperties.after = this.applyStandardOverridesToMetadataRecord( + enrichedProperties.after as Record, + OBJECT_METADATA_STANDARD_OVERRIDES_PROPERTIES, + ) as typeof enrichedProperties.after; } return { ...event, properties: enrichedProperties } as typeof event; @@ -226,8 +239,9 @@ export class MetadataEventPublisher { return { ...metadataEventBatch, events: enrichedEvents }; } - private applyStandardOverridesToObjectMetadataRecord( + private applyStandardOverridesToMetadataRecord( record: Record, + standardOverrideProperties: readonly string[], ): Record { const standardOverrides = record.standardOverrides as | Record @@ -240,7 +254,7 @@ export class MetadataEventPublisher { const resolved = { ...record }; - for (const key of OBJECT_METADATA_STANDARD_OVERRIDES_PROPERTIES) { + for (const key of standardOverrideProperties) { if (isDefined(standardOverrides[key])) { resolved[key] = standardOverrides[key]; } diff --git a/packages/twenty-server/test/integration/metadata/suites/field-metadata/__snapshots__/successful-update-one-standard-field-metadata.integration-spec.ts.snap b/packages/twenty-server/test/integration/metadata/suites/field-metadata/__snapshots__/successful-update-one-standard-field-metadata.integration-spec.ts.snap index 3c1eb37d4d..479344c4f8 100644 --- a/packages/twenty-server/test/integration/metadata/suites/field-metadata/__snapshots__/successful-update-one-standard-field-metadata.integration-spec.ts.snap +++ b/packages/twenty-server/test/integration/metadata/suites/field-metadata/__snapshots__/successful-update-one-standard-field-metadata.integration-spec.ts.snap @@ -105,7 +105,7 @@ exports[`Standard field metadata update should succeed Atomic update test suite exports[`Standard field metadata update should succeed Atomic update test suite when updating description 1`] = ` { "defaultValue": "'NEW'", - "description": "Opportunity stage", + "description": "Updated test description for company name field", "icon": "IconProgressCheck", "id": Any, "isActive": true, @@ -161,7 +161,7 @@ exports[`Standard field metadata update should succeed Atomic update test suite { "defaultValue": "'NEW'", "description": "Opportunity stage", - "icon": "IconProgressCheck", + "icon": "IconBuildingFactory", "id": Any, "isActive": true, "isCustom": false, @@ -220,7 +220,7 @@ exports[`Standard field metadata update should succeed Atomic update test suite "id": Any, "isActive": true, "isCustom": false, - "label": "Stage", + "label": "Business Name", "name": "stage", "options": [ {