feat: add color property to ObjectMetadata for object icon customization (#18672)

## Summary

- Adds a `color` column to `ObjectMetadataEntity` with full GraphQL
support so object icon colors are persisted at the metadata level
- Adds a `type` column to `NavigationMenuItemEntity` (enum: `OBJECT`,
`VIEW`, `FOLDER`, `LINK`, `RECORD`) replacing field-based type inference
- Updates frontend to read object colors from `objectMetadata.color`
(falling back to standard defaults) in the sidebar nav, record index
header, and record show breadcrumb
- Simplifies `NavigationMenuItemIcon` color resolution via
`getEffectiveNavigationMenuItemColor` util

## Color rules

| Item type | Color source | Editable in sidebar? |
|-----------|-------------|---------------------|
| **Object** | `objectMetadata.color` | Yes — persisted to
`objectMetadata.color` on Save |
| **Folder** | `navigationMenuItem.color` | Yes |
| **Link** | Fixed default (`DEFAULT_NAVIGATION_MENU_ITEM_COLOR_LINK`) |
No |
| **View** | `objectMetadata.color` (from the parent object) | No |
| **Record** | None | No |

- **Object** items represent the whole object (e.g. "Companies") and
point to the INDEX view. Changing their color updates
`objectMetadata.color` via `useSaveObjectMetadataColorsFromDraft`.
- **View** items represent specific non-INDEX views. Their color comes
from the parent object's metadata (read-only).
- Only **folders** store their color on `navigationMenuItem.color` —
enforced by `hasNavigationMenuItemOwnColor` util.
- `getEffectiveNavigationMenuItemColor` returns `objectColor` for both
OBJECT and VIEW items, folder's own color for folders, and the fixed
default for links.

## NavigationMenuItemType enum

- Shared enum created in `twenty-shared` with values: `OBJECT`, `VIEW`,
`FOLDER`, `LINK`, `RECORD`
- Registered as a GraphQL enum on the backend
- Replaces string literals across entity, DTOs, input, converters, and
frontend hooks
- Migration backfills existing rows: INDEX views → `OBJECT`, non-INDEX
views → `VIEW`, based on join with the view table

## Design decisions

- **OBJECT vs VIEW distinction**: Items pointing to INDEX views are
typed as `OBJECT` (represent the whole object, color editable). Items
pointing to non-INDEX views are typed as `VIEW` (specific view, color
read-only from parent object).
- **Dual color storage**: `navigationMenuItem.color` is preserved for
folders only. Objects use `objectMetadata.color` as their source of
truth.
- **Type discriminator**: The `type` column replaces field-based
inference (checking `viewId`, `link`, `targetRecordId` presence) with an
explicit enum, simplifying `isNavigationMenuItemLink` /
`isNavigationMenuItemFolder` to simple `item.type ===` checks.
- **No settings page color picker**: Object color editing is done from
the sidebar edit panel, not the data model settings page.

## Test plan

- [ ] Verify objects display their default standard colors in the
sidebar
- [ ] Verify object color editing works in the sidebar edit panel
(persists to objectMetadata.color)
- [ ] Verify folder color editing works in the sidebar edit panel
- [ ] Verify views, links, and records do NOT show a color picker in the
sidebar edit panel
- [ ] Run `npx nx typecheck twenty-front` and `npx nx typecheck
twenty-server`
- [ ] Verify the database migrations add `color` to `objectMetadata` and
`type` to `navigationMenuItem`


Made with [Cursor](https://cursor.com)
This commit is contained in:
Charles Bochet
2026-03-16 23:54:56 +01:00
committed by GitHub
parent 087ee19807
commit a121d00ddd
123 changed files with 1176 additions and 742 deletions
@@ -1,50 +1,60 @@
import { STANDARD_OBJECTS } from 'twenty-shared/metadata';
import { NavigationMenuItemType } from 'src/engine/metadata-modules/navigation-menu-item/enums/navigation-menu-item-type.enum';
export const STANDARD_NAVIGATION_MENU_ITEMS = {
allCompanies: {
universalIdentifier: '20202020-b001-4b01-8b01-c0aba11c0001',
type: NavigationMenuItemType.OBJECT,
viewUniversalIdentifier:
STANDARD_OBJECTS.company.views.allCompanies.universalIdentifier,
position: 0,
},
allPeople: {
universalIdentifier: '20202020-b005-4b05-8b05-c0aba11c0005',
type: NavigationMenuItemType.OBJECT,
viewUniversalIdentifier:
STANDARD_OBJECTS.person.views.allPeople.universalIdentifier,
position: 1,
},
allOpportunities: {
universalIdentifier: '20202020-b004-4b04-8b04-c0aba11c0004',
type: NavigationMenuItemType.OBJECT,
viewUniversalIdentifier:
STANDARD_OBJECTS.opportunity.views.allOpportunities.universalIdentifier,
position: 2,
},
allTasks: {
universalIdentifier: '20202020-b006-4b06-8b06-c0aba11c0006',
type: NavigationMenuItemType.OBJECT,
viewUniversalIdentifier:
STANDARD_OBJECTS.task.views.allTasks.universalIdentifier,
position: 3,
},
allNotes: {
universalIdentifier: '20202020-b003-4b03-8b03-c0aba11c0003',
type: NavigationMenuItemType.OBJECT,
viewUniversalIdentifier:
STANDARD_OBJECTS.note.views.allNotes.universalIdentifier,
position: 4,
},
allDashboards: {
universalIdentifier: '20202020-b002-4b02-8b02-c0aba11c0002',
type: NavigationMenuItemType.OBJECT,
viewUniversalIdentifier:
STANDARD_OBJECTS.dashboard.views.allDashboards.universalIdentifier,
position: 5,
},
workflowsFolder: {
universalIdentifier: '20202020-b007-4b07-8b07-c0aba11c0007',
type: NavigationMenuItemType.FOLDER,
name: 'Workflows',
icon: 'IconSettingsAutomation',
position: 6,
},
workflowsFolderAllWorkflows: {
universalIdentifier: '20202020-b008-4b08-8b08-c0aba11c0008',
type: NavigationMenuItemType.OBJECT,
viewUniversalIdentifier:
STANDARD_OBJECTS.workflow.views.allWorkflows.universalIdentifier,
folderUniversalIdentifier: '20202020-b007-4b07-8b07-c0aba11c0007',
@@ -52,6 +62,7 @@ export const STANDARD_NAVIGATION_MENU_ITEMS = {
},
workflowsFolderAllWorkflowRuns: {
universalIdentifier: '20202020-b009-4b09-8b09-c0aba11c0009',
type: NavigationMenuItemType.OBJECT,
viewUniversalIdentifier:
STANDARD_OBJECTS.workflowRun.views.allWorkflowRuns.universalIdentifier,
folderUniversalIdentifier: '20202020-b007-4b07-8b07-c0aba11c0007',
@@ -59,6 +70,7 @@ export const STANDARD_NAVIGATION_MENU_ITEMS = {
},
workflowsFolderAllWorkflowVersions: {
universalIdentifier: '20202020-b00a-4b0a-8b0a-c0aba11c000a',
type: NavigationMenuItemType.OBJECT,
viewUniversalIdentifier:
STANDARD_OBJECTS.workflowVersion.views.allWorkflowVersions
.universalIdentifier,
@@ -2,6 +2,7 @@ import { isDefined } from 'twenty-shared/utils';
import { type FlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/flat-entity-maps.type';
import { findFlatEntityByUniversalIdentifier } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-universal-identifier.util';
import { NavigationMenuItemType } from 'src/engine/metadata-modules/navigation-menu-item/enums/navigation-menu-item-type.enum';
import { type FlatNavigationMenuItem } from 'src/engine/metadata-modules/flat-navigation-menu-item/types/flat-navigation-menu-item.type';
import { type FlatView } from 'src/engine/metadata-modules/flat-view/types/flat-view.type';
import {
@@ -51,8 +52,12 @@ export const createStandardNavigationMenuItemFlatMetadata = ({
);
}
const isObjectType =
navigationMenuItemDefinition.type === NavigationMenuItemType.OBJECT;
return {
id: navigationMenuItemId,
type: navigationMenuItemDefinition.type,
universalIdentifier: navigationMenuItemDefinition.universalIdentifier,
applicationId: twentyStandardApplicationId,
applicationUniversalIdentifier:
@@ -60,10 +65,12 @@ export const createStandardNavigationMenuItemFlatMetadata = ({
workspaceId,
userWorkspaceId: null,
targetRecordId: null,
targetObjectMetadataId: null,
targetObjectMetadataUniversalIdentifier: null,
viewId: flatView.id,
viewUniversalIdentifier: flatView.universalIdentifier,
targetObjectMetadataId: isObjectType ? flatView.objectMetadataId : null,
targetObjectMetadataUniversalIdentifier: isObjectType
? flatView.objectMetadataUniversalIdentifier
: null,
viewId: isObjectType ? null : flatView.id,
viewUniversalIdentifier: isObjectType ? null : flatView.universalIdentifier,
folderId: null,
folderUniversalIdentifier: null,
name: null,
@@ -1,4 +1,5 @@
import { type FlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/flat-entity-maps.type';
import { NavigationMenuItemType } from 'src/engine/metadata-modules/navigation-menu-item/enums/navigation-menu-item-type.enum';
import { findFlatEntityByUniversalIdentifier } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-universal-identifier.util';
import { type FlatNavigationMenuItem } from 'src/engine/metadata-modules/flat-navigation-menu-item/types/flat-navigation-menu-item.type';
import { type FlatView } from 'src/engine/metadata-modules/flat-view/types/flat-view.type';
@@ -24,6 +25,7 @@ export const createStandardNavigationMenuItemFolderFlatMetadata = ({
now: string;
}): FlatNavigationMenuItem => ({
id: navigationMenuItemId,
type: NavigationMenuItemType.FOLDER,
universalIdentifier,
applicationId: twentyStandardApplicationId,
applicationUniversalIdentifier:
@@ -84,6 +86,7 @@ export const createStandardNavigationMenuItemFolderItemFlatMetadata = ({
return {
id: navigationMenuItemId,
type: NavigationMenuItemType.OBJECT,
universalIdentifier,
applicationId: twentyStandardApplicationId,
applicationUniversalIdentifier:
@@ -91,10 +94,11 @@ export const createStandardNavigationMenuItemFolderItemFlatMetadata = ({
workspaceId,
userWorkspaceId: null,
targetRecordId: null,
targetObjectMetadataId: null,
targetObjectMetadataUniversalIdentifier: null,
viewId: flatView.id,
viewUniversalIdentifier: flatView.universalIdentifier,
targetObjectMetadataId: flatView.objectMetadataId,
targetObjectMetadataUniversalIdentifier:
flatView.objectMetadataUniversalIdentifier,
viewId: null,
viewUniversalIdentifier: null,
folderId,
folderUniversalIdentifier,
name: null,
@@ -69,6 +69,7 @@ export const createStandardObjectFlatMetadata = <
namePlural,
labelSingular,
labelPlural,
color: null,
description,
icon,
isCustom: false,
@@ -25,6 +25,7 @@ type Assertions = [
Equal<
keyof UniversalFlatEntityUpdate<'objectMetadata'>,
| 'icon'
| 'color'
| 'description'
| 'isActive'
| 'standardOverrides'
@@ -3,6 +3,7 @@ import { Injectable } from '@nestjs/common';
import { msg, t } from '@lingui/core/macro';
import { isNonEmptyString } from '@sniptt/guards';
import { ALL_METADATA_NAME } from 'twenty-shared/metadata';
import { NavigationMenuItemType } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { findFlatEntityByUniversalIdentifier } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-universal-identifier.util';
@@ -22,88 +23,79 @@ const NAVIGATION_MENU_ITEM_MAX_DEPTH = 2;
@Injectable()
export class FlatNavigationMenuItemValidatorService {
private validateNavigationMenuItemType({
type,
hasTargetRecordId,
hasTargetObjectMetadataId,
hasViewId,
hasLink,
name,
isUpdate = false,
}: {
type: NavigationMenuItemType | null | undefined;
hasTargetRecordId: boolean;
hasTargetObjectMetadataId: boolean;
hasViewId: boolean;
hasLink: boolean;
name: string | null | undefined;
isUpdate?: boolean;
}): FlatEntityValidationError<NavigationMenuItemExceptionCode>[] {
const errors: FlatEntityValidationError<NavigationMenuItemExceptionCode>[] =
[];
if (hasTargetObjectMetadataId && !hasTargetRecordId) {
if (!isDefined(type)) {
errors.push({
code: NavigationMenuItemExceptionCode.INVALID_NAVIGATION_MENU_ITEM_INPUT,
message: t`targetRecordId is required when targetObjectMetadataId is provided`,
userFriendlyMessage: msg`targetRecordId is required when targetObjectMetadataId is provided`,
message: t`Navigation menu item type is required`,
userFriendlyMessage: msg`Navigation menu item type is required`,
});
return errors;
}
if (hasTargetRecordId && !hasTargetObjectMetadataId) {
errors.push({
code: NavigationMenuItemExceptionCode.INVALID_NAVIGATION_MENU_ITEM_INPUT,
message: t`targetObjectMetadataId is required when targetRecordId is provided`,
userFriendlyMessage: msg`targetObjectMetadataId is required when targetRecordId is provided`,
});
}
if (hasViewId && (hasTargetRecordId || hasTargetObjectMetadataId)) {
errors.push({
code: NavigationMenuItemExceptionCode.INVALID_NAVIGATION_MENU_ITEM_INPUT,
message: t`viewId cannot be provided together with targetRecordId or targetObjectMetadataId`,
userFriendlyMessage: msg`viewId cannot be provided together with targetRecordId or targetObjectMetadataId`,
});
}
const isFolder =
!hasTargetRecordId &&
!hasTargetObjectMetadataId &&
!hasViewId &&
!hasLink;
const isViewLink = hasViewId;
const isRecordLink = hasTargetRecordId && hasTargetObjectMetadataId;
const isExternalLink =
!hasTargetRecordId && !hasTargetObjectMetadataId && !hasViewId && hasLink;
const typeCount =
(isFolder ? 1 : 0) +
(isViewLink ? 1 : 0) +
(isRecordLink ? 1 : 0) +
(isExternalLink ? 1 : 0);
if (typeCount === 0) {
errors.push({
code: NavigationMenuItemExceptionCode.INVALID_NAVIGATION_MENU_ITEM_INPUT,
message: t`Navigation menu item must be either a folder (with name), a view link (with viewId), a record link (with targetRecordId and targetObjectMetadataId), or an external link (with link)`,
userFriendlyMessage: msg`Navigation menu item must be either a folder (with name), a view link (with viewId), a record link (with targetRecordId and targetObjectMetadataId), or an external link (with link)`,
});
}
if (typeCount > 1) {
errors.push({
code: NavigationMenuItemExceptionCode.INVALID_NAVIGATION_MENU_ITEM_INPUT,
message: t`Navigation menu item cannot be multiple types simultaneously`,
userFriendlyMessage: msg`Navigation menu item cannot be multiple types simultaneously`,
});
}
if (isFolder && (!isDefined(name) || name.trim() === '')) {
errors.push({
code: NavigationMenuItemExceptionCode.INVALID_NAVIGATION_MENU_ITEM_INPUT,
message: isUpdate
? t`Folder name is required and cannot be empty`
: t`Folder name is required when creating a folder`,
userFriendlyMessage: isUpdate
? msg`Folder name is required and cannot be empty`
: msg`Folder name is required when creating a folder`,
});
switch (type) {
case NavigationMenuItemType.FOLDER:
if (!isDefined(name) || name.trim() === '') {
errors.push({
code: NavigationMenuItemExceptionCode.INVALID_NAVIGATION_MENU_ITEM_INPUT,
message: t`Folder name is required`,
userFriendlyMessage: msg`Folder name is required`,
});
}
break;
case NavigationMenuItemType.OBJECT:
if (!hasTargetObjectMetadataId) {
errors.push({
code: NavigationMenuItemExceptionCode.INVALID_NAVIGATION_MENU_ITEM_INPUT,
message: t`targetObjectMetadataId is required for OBJECT type`,
userFriendlyMessage: msg`targetObjectMetadataId is required for OBJECT type`,
});
}
break;
case NavigationMenuItemType.VIEW:
if (!hasViewId) {
errors.push({
code: NavigationMenuItemExceptionCode.INVALID_NAVIGATION_MENU_ITEM_INPUT,
message: t`viewId is required for VIEW type`,
userFriendlyMessage: msg`viewId is required for VIEW type`,
});
}
break;
case NavigationMenuItemType.RECORD:
if (!hasTargetRecordId || !hasTargetObjectMetadataId) {
errors.push({
code: NavigationMenuItemExceptionCode.INVALID_NAVIGATION_MENU_ITEM_INPUT,
message: t`targetRecordId and targetObjectMetadataId are required for RECORD type`,
userFriendlyMessage: msg`targetRecordId and targetObjectMetadataId are required for RECORD type`,
});
}
break;
case NavigationMenuItemType.LINK:
if (!hasLink) {
errors.push({
code: NavigationMenuItemExceptionCode.INVALID_NAVIGATION_MENU_ITEM_INPUT,
message: t`link is required for LINK type`,
userFriendlyMessage: msg`link is required for LINK type`,
});
}
break;
}
return errors;
@@ -187,6 +179,7 @@ export class FlatNavigationMenuItemValidatorService {
}
const typeValidationErrors = this.validateNavigationMenuItemType({
type: flatNavigationMenuItem.type,
hasTargetRecordId: isDefined(flatNavigationMenuItem.targetRecordId),
hasTargetObjectMetadataId: isDefined(
flatNavigationMenuItem.targetObjectMetadataUniversalIdentifier,
@@ -196,7 +189,6 @@ export class FlatNavigationMenuItemValidatorService {
isDefined(flatNavigationMenuItem.link) &&
isNonEmptyString(flatNavigationMenuItem.link),
name: flatNavigationMenuItem.name,
isUpdate: false,
});
validationResult.errors.push(...typeValidationErrors);
@@ -322,6 +314,7 @@ export class FlatNavigationMenuItemValidatorService {
const nameUpdate = flatEntityUpdate.name;
const typeValidationErrors = this.validateNavigationMenuItemType({
type: toFlatNavigationMenuItem.type,
hasTargetRecordId: isDefined(toFlatNavigationMenuItem.targetRecordId),
hasTargetObjectMetadataId: isDefined(
toFlatNavigationMenuItem.targetObjectMetadataUniversalIdentifier,
@@ -329,7 +322,6 @@ export class FlatNavigationMenuItemValidatorService {
hasViewId: isDefined(toFlatNavigationMenuItem.viewUniversalIdentifier),
hasLink: isNonEmptyString((toFlatNavigationMenuItem.link ?? '').trim()),
name: isDefined(nameUpdate) ? nameUpdate : toFlatNavigationMenuItem.name,
isUpdate: true,
});
validationResult.errors.push(...typeValidationErrors);
@@ -61,11 +61,22 @@ export class FlatObjectMetadataValidatorService {
};
if (!buildOptions.isSystemBuild && existingFlatObjectMetadata.isSystem) {
validationResult.errors.push({
code: ObjectMetadataExceptionCode.INVALID_OBJECT_INPUT,
message: t`System objects cannot be updated`,
userFriendlyMessage: msg`System objects cannot be updated`,
});
const allowedOverrideKeys = new Set([
'standardOverrides',
'isActive',
'color',
]);
const disallowedProperties = Object.keys(flatEntityUpdate).filter(
(property) => !allowedOverrideKeys.has(property),
);
if (disallowedProperties.length > 0) {
validationResult.errors.push({
code: ObjectMetadataExceptionCode.INVALID_OBJECT_INPUT,
message: t`System objects cannot be updated directly. Use standardOverrides for cosmetic changes.`,
userFriendlyMessage: msg`System objects cannot be updated`,
});
}
}
validationResult.errors.push(