From a74edbf7153c02ac35560163519eeba60c2f96ed Mon Sep 17 00:00:00 2001 From: Charles Bochet Date: Wed, 18 Mar 2026 10:40:57 +0100 Subject: [PATCH] fix: route object color through standardOverrides for standard objects (#18717) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - Fixes object `color` to use the `standardOverrides` mechanism for standard objects, matching how `label`, `description`, and `icon` already work - Previously, color was written directly to the `objectMetadata.color` column for **both** standard and custom objects, which meant user color customizations on standard objects could be overwritten during metadata syncs - Custom objects continue to have `color` updated directly on the entity (no change) ## Changes | File | What changed | |------|-------------| | `object-metadata-standard-overrides-properties.constant.ts` | Added `'color'` to `OBJECT_METADATA_STANDARD_OVERRIDES_PROPERTIES` so `sanitizeRawUpdateObjectInput` routes color into `standardOverrides` for standard objects | | `resolve-object-metadata-standard-override.util.ts` | Extended to support `'color'` as a key — handled like `icon` (no i18n/translation, just direct override check) | | `object-metadata.resolver.ts` | Added `@ResolveField` for `color` that resolves through `resolveObjectMetadataStandardOverride`, matching the existing `labelSingular`/`labelPlural`/`description`/`icon` resolve fields | | `flat-object-metadata-validator.service.ts` | Removed `'color'` from `allowedOverrideKeys` for system objects since it now flows through `standardOverrides` | | `resolve-object-metadata-standard-override.util.spec.ts` | Added test cases for custom object color, standard object color override, and standard object color fallback | | `successful-update-one-standard-object-metadata.integration-spec.ts` | Added `'when updating color'` test case, included `color` in GraphQL queries and `standardOverrides` fragment, reset color in `afterEach` cleanup | ## How it works now | Object type | Color update flow | |---|---| | **Custom** | Written directly to `objectMetadata.color` column | | **Standard** | Stored in `objectMetadata.standardOverrides.color`, resolved via `@ResolveField` at query time | This is identical to how `label`, `description`, and `icon` have always worked. ## Test plan - [x] Unit tests pass (`resolve-object-metadata-standard-override.util.spec.ts` — 21 tests) - [x] Typecheck passes (`npx nx typecheck twenty-server`) - [x] Lint passes (`npx nx lint:diff-with-main twenty-server`) - [ ] Integration test snapshot regenerates correctly (`successful-update-one-standard-object-metadata`) - [ ] Verify standard object color editing from sidebar persists via `standardOverrides` - [ ] Verify custom object color editing from sidebar persists directly on entity Made with [Cursor](https://cursor.com) --- .../hooks/useSaveNavigationMenuItemsDraft.ts | 1 + ...-standard-overrides-properties.constant.ts | 1 + .../object-metadata.resolver.ts | 15 +++ ...ct-metadata-standard-override.util.spec.ts | 85 ++++++++++++++++ ...-object-metadata-standard-override.util.ts | 12 ++- .../workspace-event-emitter.service.ts | 68 ++++++++++++- .../flat-object-metadata-validator.service.ts | 6 +- ...d-object-metadata.integration-spec.ts.snap | 98 ++++++++++++------- ...andard-object-metadata.integration-spec.ts | 9 ++ 9 files changed, 248 insertions(+), 47 deletions(-) diff --git a/packages/twenty-front/src/modules/navigation-menu-item/edit/hooks/useSaveNavigationMenuItemsDraft.ts b/packages/twenty-front/src/modules/navigation-menu-item/edit/hooks/useSaveNavigationMenuItemsDraft.ts index c217dc01e9..ebe49fd91e 100644 --- a/packages/twenty-front/src/modules/navigation-menu-item/edit/hooks/useSaveNavigationMenuItemsDraft.ts +++ b/packages/twenty-front/src/modules/navigation-menu-item/edit/hooks/useSaveNavigationMenuItemsDraft.ts @@ -54,6 +54,7 @@ export const useSaveNavigationMenuItemsDraft = () => { if (!isDefined(objectMetadataItem)) { continue; } + if (objectMetadataItem.color === draftItem.color) { continue; } diff --git a/packages/twenty-server/src/engine/metadata-modules/object-metadata/constants/object-metadata-standard-overrides-properties.constant.ts b/packages/twenty-server/src/engine/metadata-modules/object-metadata/constants/object-metadata-standard-overrides-properties.constant.ts index 49e305a6f4..5de1b8526b 100644 --- a/packages/twenty-server/src/engine/metadata-modules/object-metadata/constants/object-metadata-standard-overrides-properties.constant.ts +++ b/packages/twenty-server/src/engine/metadata-modules/object-metadata/constants/object-metadata-standard-overrides-properties.constant.ts @@ -1,6 +1,7 @@ import { type MetadataUniversalFlatEntityPropertiesToCompare } from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/metadata-universal-flat-entity-properties-to-compare.type'; export const OBJECT_METADATA_STANDARD_OVERRIDES_PROPERTIES = [ + 'color', 'labelSingular', 'labelPlural', 'description', diff --git a/packages/twenty-server/src/engine/metadata-modules/object-metadata/object-metadata.resolver.ts b/packages/twenty-server/src/engine/metadata-modules/object-metadata/object-metadata.resolver.ts index 28801f9ecd..5e8e382264 100644 --- a/packages/twenty-server/src/engine/metadata-modules/object-metadata/object-metadata.resolver.ts +++ b/packages/twenty-server/src/engine/metadata-modules/object-metadata/object-metadata.resolver.ts @@ -117,6 +117,21 @@ export class ObjectMetadataResolver { ); } + @ResolveField(() => String, { nullable: true }) + async color( + @Parent() objectMetadata: ObjectMetadataDTO, + @Context() context: I18nContext, + ): Promise { + const i18n = this.i18nService.getI18nInstance(context.req.locale); + + return resolveObjectMetadataStandardOverride( + objectMetadata, + 'color', + context.req.locale, + i18n, + ); + } + @UseGuards(SettingsPermissionGuard(PermissionFlagType.DATA_MODEL)) @Mutation(() => ObjectMetadataDTO) async createOneObject( diff --git a/packages/twenty-server/src/engine/metadata-modules/object-metadata/utils/__tests__/resolve-object-metadata-standard-override.util.spec.ts b/packages/twenty-server/src/engine/metadata-modules/object-metadata/utils/__tests__/resolve-object-metadata-standard-override.util.spec.ts index 21aa6aa5b1..745d13a262 100644 --- a/packages/twenty-server/src/engine/metadata-modules/object-metadata/utils/__tests__/resolve-object-metadata-standard-override.util.spec.ts +++ b/packages/twenty-server/src/engine/metadata-modules/object-metadata/utils/__tests__/resolve-object-metadata-standard-override.util.spec.ts @@ -28,10 +28,12 @@ describe('resolveObjectMetadataStandardOverride', () => { labelPlural: 'My Customs', description: 'Custom Description', icon: 'custom-icon', + color: 'blue', isCustom: true, standardOverrides: undefined, } satisfies Pick< ObjectMetadataDTO, + | 'color' | 'labelPlural' | 'labelSingular' | 'description' @@ -56,10 +58,12 @@ describe('resolveObjectMetadataStandardOverride', () => { labelPlural: 'My Customs', description: 'Custom Description', icon: 'custom-icon', + color: 'blue', isCustom: true, standardOverrides: undefined, } satisfies Pick< ObjectMetadataDTO, + | 'color' | 'labelPlural' | 'labelSingular' | 'description' @@ -84,10 +88,12 @@ describe('resolveObjectMetadataStandardOverride', () => { labelPlural: 'My Customs', description: 'Custom Description', icon: 'custom-icon', + color: 'blue', isCustom: true, standardOverrides: undefined, } satisfies Pick< ObjectMetadataDTO, + | 'color' | 'labelPlural' | 'labelSingular' | 'description' @@ -105,6 +111,36 @@ describe('resolveObjectMetadataStandardOverride', () => { expect(result).toBe('custom-icon'); }); + + it('should return the object value for custom color object', () => { + const objectMetadata = { + labelSingular: 'My Custom', + labelPlural: 'My Customs', + description: 'Custom Description', + icon: 'custom-icon', + color: 'green', + isCustom: true, + standardOverrides: undefined, + } satisfies Pick< + ObjectMetadataDTO, + | 'color' + | 'labelPlural' + | 'labelSingular' + | 'description' + | 'icon' + | 'isCustom' + | 'standardOverrides' + >; + + const result = resolveObjectMetadataStandardOverride( + objectMetadata, + 'color', + SOURCE_LOCALE, + mockI18n, + ); + + expect(result).toBe('green'); + }); }); describe('Standard objects - Icon overrides', () => { @@ -131,6 +167,55 @@ describe('resolveObjectMetadataStandardOverride', () => { }); }); + describe('Standard objects - Color overrides', () => { + it('should return override color when available for standard object', () => { + const objectMetadata = { + labelSingular: 'Company', + labelPlural: 'Companies', + description: 'Standard Description', + icon: 'default-icon', + color: 'blue', + isCustom: false, + standardOverrides: { + color: 'red', + }, + }; + + const result = resolveObjectMetadataStandardOverride( + objectMetadata, + 'color', + 'fr-FR', + mockI18n, + ); + + expect(result).toBe('red'); + }); + + it('should return base color when no override exists for standard object', () => { + const objectMetadata = { + labelSingular: 'Company', + labelPlural: 'Companies', + description: 'Standard Description', + icon: 'default-icon', + color: 'blue', + isCustom: false, + standardOverrides: undefined, + }; + + mockGenerateMessageId.mockReturnValue('generated-message-id'); + mockI18n._.mockReturnValue('generated-message-id'); + + const result = resolveObjectMetadataStandardOverride( + objectMetadata, + 'color', + SOURCE_LOCALE, + mockI18n, + ); + + expect(result).toBe('blue'); + }); + }); + describe('Standard objects - Translation overrides', () => { it('should return translation override when available for non-icon objects', () => { const objectMetadata = { diff --git a/packages/twenty-server/src/engine/metadata-modules/object-metadata/utils/resolve-object-metadata-standard-override.util.ts b/packages/twenty-server/src/engine/metadata-modules/object-metadata/utils/resolve-object-metadata-standard-override.util.ts index 1522768ec5..22440b5c68 100644 --- a/packages/twenty-server/src/engine/metadata-modules/object-metadata/utils/resolve-object-metadata-standard-override.util.ts +++ b/packages/twenty-server/src/engine/metadata-modules/object-metadata/utils/resolve-object-metadata-standard-override.util.ts @@ -9,6 +9,7 @@ import { type ObjectMetadataDTO } from 'src/engine/metadata-modules/object-metad export const resolveObjectMetadataStandardOverride = ( objectMetadata: Pick< ObjectMetadataDTO, + | 'color' | 'labelPlural' | 'labelSingular' | 'description' @@ -16,7 +17,7 @@ export const resolveObjectMetadataStandardOverride = ( | 'isCustom' | 'standardOverrides' >, - labelKey: 'labelPlural' | 'labelSingular' | 'description' | 'icon', + labelKey: 'color' | 'labelPlural' | 'labelSingular' | 'description' | 'icon', locale: keyof typeof APP_LOCALES | undefined, i18nInstance: I18n, ): string => { @@ -27,15 +28,16 @@ export const resolveObjectMetadataStandardOverride = ( } if ( - labelKey === 'icon' && - isDefined(objectMetadata.standardOverrides?.icon) + (labelKey === 'icon' || labelKey === 'color') && + isDefined(objectMetadata.standardOverrides?.[labelKey]) ) { - return objectMetadata.standardOverrides.icon; + return objectMetadata.standardOverrides[labelKey]; } if ( isDefined(objectMetadata.standardOverrides?.translations) && - labelKey !== 'icon' + labelKey !== 'icon' && + labelKey !== 'color' ) { const translationValue = objectMetadata.standardOverrides.translations[safeLocale]?.[labelKey]; diff --git a/packages/twenty-server/src/engine/workspace-event-emitter/workspace-event-emitter.service.ts b/packages/twenty-server/src/engine/workspace-event-emitter/workspace-event-emitter.service.ts index 06dacc2670..21ea192017 100644 --- a/packages/twenty-server/src/engine/workspace-event-emitter/workspace-event-emitter.service.ts +++ b/packages/twenty-server/src/engine/workspace-event-emitter/workspace-event-emitter.service.ts @@ -26,6 +26,7 @@ import { type SerializableAuthContext } from 'src/engine/core-modules/auth/types import { type WorkspaceAuthContext } from 'src/engine/core-modules/auth/types/workspace-auth-context.type'; import { type FlatWorkspaceMemberMaps } from 'src/engine/core-modules/user/types/flat-workspace-member-maps.type'; import { type MetadataEventBatch } from 'src/engine/metadata-event-emitter/types/metadata-event-batch.type'; +import { OBJECT_METADATA_STANDARD_OVERRIDES_PROPERTIES } from 'src/engine/metadata-modules/object-metadata/constants/object-metadata-standard-overrides-properties.constant'; import { WorkspaceManyOrAllFlatEntityMapsCacheService } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.service'; import { type FlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/flat-entity-maps.type'; import { findFlatEntityByIdInFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-id-in-flat-entity-maps.util'; @@ -136,9 +137,13 @@ export class WorkspaceEventEmitterService { const enrichedMetadataEventBatch = isMetadata ? await this.enrichFieldMetadataEventsWithRelations( eventBatch as MetadataEventBatch, - ).then((batch) => - this.enrichNavigationMenuItemEventsWithTargetRecordIdentifier(batch), ) + .then((batch) => + this.enrichNavigationMenuItemEventsWithTargetRecordIdentifier( + batch, + ), + ) + .then((batch) => this.resolveObjectMetadataStandardOverrides(batch)) : undefined; for (const [streamChannelId, streamData] of streamsData) { @@ -308,6 +313,65 @@ export class WorkspaceEventEmitterService { return { ...metadataEventBatch, events: enrichedEvents }; } + private resolveObjectMetadataStandardOverrides( + metadataEventBatch: MetadataEventBatch, + ): MetadataEventBatch { + if (metadataEventBatch.metadataName !== 'objectMetadata') { + return metadataEventBatch; + } + + const enrichedEvents = metadataEventBatch.events.map((event) => { + const enrichedProperties = { ...event.properties }; + + if ( + 'before' in enrichedProperties && + isDefined(enrichedProperties.before) + ) { + enrichedProperties.before = + this.applyStandardOverridesToObjectMetadataRecord( + enrichedProperties.before as Record, + ) as typeof enrichedProperties.before; + } + + if ( + 'after' in enrichedProperties && + isDefined(enrichedProperties.after) + ) { + enrichedProperties.after = + this.applyStandardOverridesToObjectMetadataRecord( + enrichedProperties.after as Record, + ) as typeof enrichedProperties.after; + } + + return { ...event, properties: enrichedProperties } as typeof event; + }); + + return { ...metadataEventBatch, events: enrichedEvents }; + } + + private applyStandardOverridesToObjectMetadataRecord( + record: Record, + ): Record { + const standardOverrides = record.standardOverrides as + | Record + | null + | undefined; + + if (!isDefined(standardOverrides)) { + return record; + } + + const resolved = { ...record }; + + for (const key of OBJECT_METADATA_STANDARD_OVERRIDES_PROPERTIES) { + if (isDefined(standardOverrides[key])) { + resolved[key] = standardOverrides[key]; + } + } + + return resolved; + } + private async processObjectRecordStreamEvents( streamChannelId: string, streamData: EventStreamData, diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-builder/validators/services/flat-object-metadata-validator.service.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-builder/validators/services/flat-object-metadata-validator.service.ts index 5e577429dc..d0ce6d3b9f 100644 --- a/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-builder/validators/services/flat-object-metadata-validator.service.ts +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-builder/validators/services/flat-object-metadata-validator.service.ts @@ -61,11 +61,7 @@ export class FlatObjectMetadataValidatorService { }; if (!buildOptions.isSystemBuild && existingFlatObjectMetadata.isSystem) { - const allowedOverrideKeys = new Set([ - 'standardOverrides', - 'isActive', - 'color', - ]); + const allowedOverrideKeys = new Set(['standardOverrides', 'isActive']); const disallowedProperties = Object.keys(flatEntityUpdate).filter( (property) => !allowedOverrideKeys.has(property), ); diff --git a/packages/twenty-server/test/integration/metadata/suites/object-metadata/__snapshots__/successful-update-one-standard-object-metadata.integration-spec.ts.snap b/packages/twenty-server/test/integration/metadata/suites/object-metadata/__snapshots__/successful-update-one-standard-object-metadata.integration-spec.ts.snap index 795665496f..36e8f78960 100644 --- a/packages/twenty-server/test/integration/metadata/suites/object-metadata/__snapshots__/successful-update-one-standard-object-metadata.integration-spec.ts.snap +++ b/packages/twenty-server/test/integration/metadata/suites/object-metadata/__snapshots__/successful-update-one-standard-object-metadata.integration-spec.ts.snap @@ -1,7 +1,46 @@ // Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing +exports[`Standard object metadata update should succeed when updating description 1`] = ` +{ + "color": "", + "description": "Updated test description for company", + "icon": "IconBuildingSkyscraper", + "id": Any, + "isActive": true, + "labelPlural": "Companies", + "labelSingular": "Company", + "namePlural": "companies", + "nameSingular": "company", + "shortcut": "C", + "standardOverrides": { + "color": null, + "description": "Updated test description for company", + "icon": null, + "labelPlural": null, + "labelSingular": null, + }, +} +`; + +exports[`Standard object metadata update should succeed when updating icon 1`] = ` +{ + "color": "", + "description": "A company", + "icon": "IconBuildingSkyscraper", + "id": Any, + "isActive": true, + "labelPlural": "Companies", + "labelSingular": "Company", + "namePlural": "companies", + "nameSingular": "company", + "shortcut": "C", + "standardOverrides": null, +} +`; + exports[`Standard object metadata update should succeed when setting isActive to false 1`] = ` { + "color": "", "description": "A company", "icon": "IconBuildingSkyscraper", "id": Any, @@ -15,43 +54,9 @@ exports[`Standard object metadata update should succeed when setting isActive to } `; -exports[`Standard object metadata update should succeed when updating description 1`] = ` -{ - "description": "Updated test description for company", - "icon": "IconBuildingSkyscraper", - "id": Any, - "isActive": true, - "labelPlural": "Companies", - "labelSingular": "Company", - "namePlural": "companies", - "nameSingular": "company", - "shortcut": "C", - "standardOverrides": { - "description": "Updated test description for company", - "icon": null, - "labelPlural": null, - "labelSingular": null, - }, -} -`; - -exports[`Standard object metadata update should succeed when updating icon 1`] = ` -{ - "description": "A company", - "icon": "IconBuildingSkyscraper", - "id": Any, - "isActive": true, - "labelPlural": "Companies", - "labelSingular": "Company", - "namePlural": "companies", - "nameSingular": "company", - "shortcut": "C", - "standardOverrides": null, -} -`; - exports[`Standard object metadata update should succeed when updating labelSingular and labelPlural 1`] = ` { + "color": "", "description": "A company", "icon": "IconBuildingSkyscraper", "id": Any, @@ -62,6 +67,7 @@ exports[`Standard object metadata update should succeed when updating labelSingu "nameSingular": "company", "shortcut": "C", "standardOverrides": { + "color": null, "description": null, "icon": null, "labelPlural": "Businesses", @@ -69,3 +75,25 @@ exports[`Standard object metadata update should succeed when updating labelSingu }, } `; + +exports[`Standard object metadata update should succeed when updating color 1`] = ` +{ + "color": "red", + "description": "A company", + "icon": "IconBuildingSkyscraper", + "id": Any, + "isActive": true, + "labelPlural": "Companies", + "labelSingular": "Company", + "namePlural": "companies", + "nameSingular": "company", + "shortcut": "C", + "standardOverrides": { + "color": "red", + "description": null, + "icon": null, + "labelPlural": null, + "labelSingular": null, + }, +} +`; diff --git a/packages/twenty-server/test/integration/metadata/suites/object-metadata/successful-update-one-standard-object-metadata.integration-spec.ts b/packages/twenty-server/test/integration/metadata/suites/object-metadata/successful-update-one-standard-object-metadata.integration-spec.ts index a6d59c6156..cac76d0a0b 100644 --- a/packages/twenty-server/test/integration/metadata/suites/object-metadata/successful-update-one-standard-object-metadata.integration-spec.ts +++ b/packages/twenty-server/test/integration/metadata/suites/object-metadata/successful-update-one-standard-object-metadata.integration-spec.ts @@ -46,6 +46,12 @@ const successfulUpdateTestsUseCase: UpdateOneStandardObjectMetadataTestingContex labelPlural: 'Businesses', }, }, + { + title: 'when updating color', + context: { + color: 'red', + }, + }, ]; const allTestsUseCases = [...successfulUpdateTestsUseCase]; @@ -67,6 +73,7 @@ describe('Standard object metadata update should succeed', () => { namePlural labelSingular labelPlural + color description icon isActive @@ -121,6 +128,7 @@ describe('Standard object metadata update should succeed', () => { namePlural labelSingular labelPlural + color description icon isActive @@ -128,6 +136,7 @@ describe('Standard object metadata update should succeed', () => { standardOverrides { labelSingular labelPlural + color description icon }