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
@@ -9,6 +9,7 @@ export const fromObjectMetadataEntityToObjectMetadataDto = (
updatedAt,
description,
icon,
color,
standardOverrides,
shortcut,
duplicateCriteria,
@@ -22,6 +23,7 @@ export const fromObjectMetadataEntityToObjectMetadataDto = (
updatedAt: new Date(updatedAt),
description: description ?? undefined,
icon: icon ?? undefined,
color: color ?? undefined,
standardOverrides: standardOverrides ?? undefined,
shortcut: shortcut ?? undefined,
duplicateCriteria: duplicateCriteria ?? undefined,
@@ -105,6 +105,7 @@ exports[`ALL_UNIVERSAL_FLAT_ENTITY_PROPERTIES_TO_COMPARE_AND_STRINGIFY should ma
},
"navigationMenuItem": {
"propertiesToCompare": [
"type",
"position",
"folderUniversalIdentifier",
"name",
@@ -116,6 +117,7 @@ exports[`ALL_UNIVERSAL_FLAT_ENTITY_PROPERTIES_TO_COMPARE_AND_STRINGIFY should ma
},
"objectMetadata": {
"propertiesToCompare": [
"color",
"description",
"icon",
"isActive",
@@ -149,6 +149,11 @@ export const ALL_ENTITY_PROPERTIES_CONFIGURATION_BY_METADATA_NAME = {
toStringify: false,
universalProperty: undefined,
},
color: {
toCompare: true,
toStringify: false,
universalProperty: undefined,
},
description: {
toCompare: true,
toStringify: false,
@@ -1070,6 +1075,11 @@ export const ALL_ENTITY_PROPERTIES_CONFIGURATION_BY_METADATA_NAME = {
},
},
navigationMenuItem: {
type: {
toCompare: true,
toStringify: false,
universalProperty: undefined,
},
position: {
toCompare: true,
toStringify: false,
@@ -38,6 +38,7 @@ type Assertions = [
Equal<
keyof FlatEntityUpdate<'objectMetadata'>,
| 'icon'
| 'color'
| 'description'
| 'isActive'
| 'standardOverrides'
@@ -69,6 +69,7 @@ export const fromCreateNavigationMenuItemInputToFlatNavigationMenuItemToCreate =
return {
id,
type: createNavigationMenuItemInput.type,
universalIdentifier: id,
userWorkspaceId: createNavigationMenuItemInput.userWorkspaceId ?? null,
targetRecordId: createNavigationMenuItemInput.targetRecordId ?? null,
@@ -5,6 +5,7 @@ export const fromFlatNavigationMenuItemToNavigationMenuItemDto = (
flatNavigationMenuItem: FlatNavigationMenuItem,
): NavigationMenuItemDTO => ({
id: flatNavigationMenuItem.id,
type: flatNavigationMenuItem.type,
userWorkspaceId: flatNavigationMenuItem.userWorkspaceId ?? undefined,
targetRecordId: flatNavigationMenuItem.targetRecordId ?? undefined,
targetObjectMetadataId:
@@ -75,6 +75,7 @@ export const fromNavigationMenuItemEntityToFlatNavigationMenuItem = ({
return {
id: navigationMenuItemEntity.id,
type: navigationMenuItemEntity.type,
userWorkspaceId: navigationMenuItemEntity.userWorkspaceId,
targetRecordId: navigationMenuItemEntity.targetRecordId,
targetObjectMetadataId: navigationMenuItemEntity.targetObjectMetadataId,
@@ -22,6 +22,7 @@ export const getFlatObjectMetadataMock = (
fieldIds: [],
description: 'default flat object metadata description',
icon: 'icon',
color: null,
id: faker.string.uuid(),
imageIdentifierFieldMetadataId,
isActive: true,
@@ -2,6 +2,7 @@ import { type MetadataEntityPropertyName } from 'src/engine/metadata-modules/fla
export const FLAT_OBJECT_METADATA_EDITABLE_PROPERTIES = {
custom: [
'color',
'description',
'icon',
'isActive',
@@ -12,7 +13,14 @@ export const FLAT_OBJECT_METADATA_EDITABLE_PROPERTIES = {
'nameSingular',
'labelIdentifierFieldMetadataId',
],
standard: ['description', 'icon', 'isActive', 'labelPlural', 'labelSingular'],
standard: [
'color',
'description',
'icon',
'isActive',
'labelPlural',
'labelSingular',
],
} as const satisfies Record<
'standard' | 'custom',
MetadataEntityPropertyName<'objectMetadata'>[]
@@ -74,6 +74,7 @@ export const fromCreateObjectInputToFlatObjectMetadataAndFlatFieldMetadatasToCre
createdAt,
updatedAt: createdAt,
duplicateCriteria: null,
color: createObjectInput.color ?? null,
description: createObjectInput.description ?? null,
icon: createObjectInput.icon ?? null,
isActive: true,
@@ -7,6 +7,7 @@ export const fromFlatObjectMetadataToObjectMetadataDto = (
const {
createdAt,
updatedAt,
color,
description,
icon,
standardOverrides,
@@ -50,6 +51,7 @@ export const fromFlatObjectMetadataToObjectMetadataDto = (
labelIdentifierFieldMetadataId,
createdAt: new Date(createdAt),
updatedAt: new Date(updatedAt),
color: color ?? undefined,
description: description ?? undefined,
icon: icon ?? undefined,
standardOverrides: standardOverrides ?? undefined,
@@ -24,6 +24,9 @@ export class MinimalObjectMetadataDTO {
@Field({ nullable: true })
icon?: string;
@Field({ nullable: true })
color?: string;
@Field()
isCustom: boolean;
@@ -1,8 +1,15 @@
import { Field, InputType } from '@nestjs/graphql';
import { IsNumber, IsOptional, IsString, IsUUID } from 'class-validator';
import {
IsEnum,
IsNumber,
IsOptional,
IsString,
IsUUID,
} from 'class-validator';
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
import { NavigationMenuItemType } from 'src/engine/metadata-modules/navigation-menu-item/enums/navigation-menu-item-type.enum';
@InputType()
export class CreateNavigationMenuItemInput {
@@ -26,6 +33,10 @@ export class CreateNavigationMenuItemInput {
@Field(() => UUIDScalarType, { nullable: true })
viewId?: string | null;
@IsEnum(NavigationMenuItemType)
@Field(() => NavigationMenuItemType)
type: NavigationMenuItemType;
@IsOptional()
@IsString()
@Field(() => String, { nullable: true })
@@ -10,6 +10,8 @@ import {
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
import { NavigationMenuItemType } from 'src/engine/metadata-modules/navigation-menu-item/enums/navigation-menu-item-type.enum';
import { RecordIdentifierDTO } from './record-identifier.dto';
@ObjectType('NavigationMenuItem')
@@ -39,6 +41,10 @@ export class NavigationMenuItemDTO {
@Field(() => UUIDScalarType, { nullable: true })
viewId?: string | null;
@IsNotEmpty()
@Field(() => NavigationMenuItemType)
type: NavigationMenuItemType;
@IsOptional()
@Field(() => String, { nullable: true })
name?: string | null;
@@ -14,6 +14,7 @@ import {
import { UserWorkspaceEntity } from 'src/engine/core-modules/user-workspace/user-workspace.entity';
import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity';
import { ViewEntity } from 'src/engine/metadata-modules/view/entities/view.entity';
import { NavigationMenuItemType } from 'src/engine/metadata-modules/navigation-menu-item/enums/navigation-menu-item-type.enum';
import { SyncableEntity } from 'src/engine/workspace-manager/types/syncable-entity.interface';
@Entity({ name: 'navigationMenuItem', schema: 'core' })
@@ -35,8 +36,12 @@ import { SyncableEntity } from 'src/engine/workspace-manager/types/syncable-enti
'workspaceId',
])
@Check(
'CHK_navigation_menu_item_target_fields',
'("targetRecordId" IS NULL AND "targetObjectMetadataId" IS NULL) OR ("targetRecordId" IS NOT NULL AND "targetObjectMetadataId" IS NOT NULL)',
'CHK_navigation_menu_item_type_fields',
`("type" = 'FOLDER')
OR ("type" = 'OBJECT' AND "targetObjectMetadataId" IS NOT NULL)
OR ("type" = 'VIEW')
OR ("type" = 'RECORD' AND "targetRecordId" IS NOT NULL AND "targetObjectMetadataId" IS NOT NULL)
OR ("type" = 'LINK' AND "link" IS NOT NULL)`,
)
export class NavigationMenuItemEntity
extends SyncableEntity
@@ -78,6 +83,14 @@ export class NavigationMenuItemEntity
@JoinColumn({ name: 'targetObjectMetadataId' })
targetObjectMetadata: Relation<ObjectMetadataEntity> | null;
@Column({
nullable: false,
type: 'enum',
enum: NavigationMenuItemType,
default: NavigationMenuItemType.VIEW,
})
type: NavigationMenuItemType;
@Column({ nullable: true, type: 'text' })
name: string | null;
@@ -0,0 +1,9 @@
import { registerEnumType } from '@nestjs/graphql';
import { NavigationMenuItemType } from 'twenty-shared/types';
registerEnumType(NavigationMenuItemType, {
name: 'NavigationMenuItemType',
});
export { NavigationMenuItemType };
@@ -55,6 +55,11 @@ export class CreateObjectInput {
@Field({ nullable: true })
shortcut?: string;
@IsString()
@IsOptional()
@Field({ nullable: true })
color?: string;
@HideField()
dataSourceId: string;
@@ -59,6 +59,9 @@ export class ObjectMetadataDTO {
@Field({ nullable: true })
shortcut?: string;
@Field({ nullable: true })
color?: string;
@FilterableField()
isCustom: boolean;
@@ -26,6 +26,11 @@ export class ObjectStandardOverridesDTO {
@Field(() => String, { nullable: true })
icon?: string | null;
@IsString()
@IsOptional()
@Field(() => String, { nullable: true })
color?: string | null;
@IsJSON()
@IsOptional()
@Field(() => GraphQLJSON, {
@@ -52,6 +52,11 @@ export class UpdateObjectPayload {
@Field({ nullable: true })
shortcut?: string;
@IsString()
@IsOptional()
@Field({ nullable: true })
color?: string;
@IsBoolean()
@IsOptional()
@Field({ nullable: true })
@@ -60,6 +60,9 @@ export class ObjectMetadataEntity
@Column({ nullable: true, type: 'varchar' })
icon: string | null;
@Column({ nullable: true, type: 'text' })
color: string | null;
@Column({ type: 'jsonb', nullable: true })
standardOverrides: JsonbProperty<ObjectStandardOverridesDTO> | null;
@@ -24,6 +24,7 @@ import { findFlatEntityByUniversalIdentifier } from 'src/engine/metadata-modules
import { findManyFlatEntityByIdInFlatEntityMapsOrThrow } from 'src/engine/metadata-modules/flat-entity/utils/find-many-flat-entity-by-id-in-flat-entity-maps-or-throw.util';
import { FlatIndexMetadata } from 'src/engine/metadata-modules/flat-index-metadata/types/flat-index-metadata.type';
import { FlatNavigationMenuItem } from 'src/engine/metadata-modules/flat-navigation-menu-item/types/flat-navigation-menu-item.type';
import { NavigationMenuItemType } from 'src/engine/metadata-modules/navigation-menu-item/enums/navigation-menu-item-type.enum';
import { FlatObjectMetadata } from 'src/engine/metadata-modules/flat-object-metadata/types/flat-object-metadata.type';
import { fromCreateObjectInputToFlatObjectMetadataAndFlatFieldMetadatasToCreate } from 'src/engine/metadata-modules/flat-object-metadata/utils/from-create-object-input-to-flat-object-metadata-and-flat-field-metadatas-to-create.util';
import { fromDeleteObjectInputToFlatFieldMetadatasToDelete } from 'src/engine/metadata-modules/flat-object-metadata/utils/from-delete-object-input-to-flat-field-metadatas-to-delete.util';
@@ -469,7 +470,7 @@ export class ObjectMetadataService extends TypeOrmQueryService<ObjectMetadataEnt
const flatNavigationMenuItemToCreate =
await this.computeFlatNavigationMenuItemToCreate({
view: flatDefaultViewToCreate,
objectMetadata: flatObjectMetadataToCreate,
workspaceId,
workspaceCustomApplicationId: workspaceCustomFlatApplication.id,
workspaceCustomApplicationUniversalIdentifier:
@@ -683,12 +684,12 @@ export class ObjectMetadataService extends TypeOrmQueryService<ObjectMetadataEnt
}
private async computeFlatNavigationMenuItemToCreate({
view,
objectMetadata,
workspaceId,
workspaceCustomApplicationId,
workspaceCustomApplicationUniversalIdentifier,
}: {
view: UniversalFlatView & { id: string };
objectMetadata: { id: string; universalIdentifier: string };
workspaceId: string;
workspaceCustomApplicationId: string;
workspaceCustomApplicationUniversalIdentifier: string;
@@ -714,13 +715,15 @@ export class ObjectMetadataService extends TypeOrmQueryService<ObjectMetadataEnt
return {
id: newId,
type: NavigationMenuItemType.OBJECT,
universalIdentifier: newId,
userWorkspaceId: null,
targetRecordId: null,
targetObjectMetadataId: null,
targetObjectMetadataUniversalIdentifier: null,
viewId: view.id,
viewUniversalIdentifier: view.universalIdentifier,
targetObjectMetadataId: objectMetadata.id,
targetObjectMetadataUniversalIdentifier:
objectMetadata.universalIdentifier,
viewId: null,
viewUniversalIdentifier: null,
folderId: null,
folderUniversalIdentifier: null,
name: null,