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:
+1
@@ -54,6 +54,7 @@ export const useSaveNavigationMenuItemsDraft = () => {
|
||||
if (!isDefined(objectMetadataItem)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (objectMetadataItem.color === draftItem.color) {
|
||||
continue;
|
||||
}
|
||||
|
||||
+1
@@ -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',
|
||||
|
||||
+15
@@ -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(
|
||||
|
||||
+85
@@ -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 = {
|
||||
|
||||
+7
-5
@@ -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];
|
||||
|
||||
+66
-2
@@ -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,
|
||||
|
||||
+1
-5
@@ -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),
|
||||
);
|
||||
|
||||
+63
-35
@@ -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,
|
||||
},
|
||||
}
|
||||
`;
|
||||
|
||||
+9
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user