fix: route object color through standardOverrides for standard objects (#18717)

## 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)
This commit is contained in:
Charles Bochet
2026-03-18 10:40:57 +01:00
committed by GitHub
parent 0ae62898a3
commit a74edbf715
9 changed files with 248 additions and 47 deletions
@@ -54,6 +54,7 @@ export const useSaveNavigationMenuItemsDraft = () => {
if (!isDefined(objectMetadataItem)) {
continue;
}
if (objectMetadataItem.color === draftItem.color) {
continue;
}
@@ -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',
@@ -117,6 +117,21 @@ export class ObjectMetadataResolver {
);
}
@ResolveField(() => String, { nullable: true })
async color(
@Parent() objectMetadata: ObjectMetadataDTO,
@Context() context: I18nContext,
): Promise<string> {
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(
@@ -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 = {
@@ -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];
@@ -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<string, unknown>,
) as typeof enrichedProperties.before;
}
if (
'after' in enrichedProperties &&
isDefined(enrichedProperties.after)
) {
enrichedProperties.after =
this.applyStandardOverridesToObjectMetadataRecord(
enrichedProperties.after as Record<string, unknown>,
) as typeof enrichedProperties.after;
}
return { ...event, properties: enrichedProperties } as typeof event;
});
return { ...metadataEventBatch, events: enrichedEvents };
}
private applyStandardOverridesToObjectMetadataRecord(
record: Record<string, unknown>,
): Record<string, unknown> {
const standardOverrides = record.standardOverrides as
| Record<string, unknown>
| 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,
@@ -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),
);
@@ -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<String>,
"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<String>,
"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<String>,
@@ -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<String>,
"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<String>,
"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<String>,
@@ -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<String>,
"isActive": true,
"labelPlural": "Companies",
"labelSingular": "Company",
"namePlural": "companies",
"nameSingular": "company",
"shortcut": "C",
"standardOverrides": {
"color": "red",
"description": null,
"icon": null,
"labelPlural": null,
"labelSingular": null,
},
}
`;
@@ -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
}