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
@@ -3,6 +3,7 @@ import type { Manifest } from 'twenty-shared/application';
import { PermissionFlagType } from 'twenty-shared/constants';
import {
FieldMetadataType,
NavigationMenuItemType,
RelationOnDeleteAction,
RelationType,
ViewType,
@@ -1569,19 +1570,22 @@ export const EXPECTED_MANIFEST: Manifest = {
],
navigationMenuItems: [
{
type: NavigationMenuItemType.OBJECT,
position: 2,
universalIdentifier: 'c1a2b3c4-0003-4a7b-8c9d-0e1f2a3b4c5d',
viewUniversalIdentifier: 'b1a2b3c4-0003-4a7b-8c9d-0e1f2a3b4c5d',
targetObjectUniversalIdentifier: 'e1a2b3c4-5e6f-4a7b-8c9d-0e1f2a3b4c5e',
},
{
type: NavigationMenuItemType.OBJECT,
position: 0,
universalIdentifier: 'c1a2b3c4-0001-4a7b-8c9d-0e1f2a3b4c5d',
viewUniversalIdentifier: 'b1a2b3c4-0001-4a7b-8c9d-0e1f2a3b4c5d',
targetObjectUniversalIdentifier: '54b589ca-eeed-4950-a176-358418b85c05',
},
{
type: NavigationMenuItemType.OBJECT,
position: 1,
universalIdentifier: 'c1a2b3c4-0002-4a7b-8c9d-0e1f2a3b4c5d',
viewUniversalIdentifier: 'b1a2b3c4-0002-4a7b-8c9d-0e1f2a3b4c5d',
targetObjectUniversalIdentifier: 'd1a2b3c4-5e6f-4a7b-8c9d-0e1f2a3b4c5d',
},
],
logicFunctions: [
@@ -260,7 +260,8 @@ export class EntityAddCommand {
const navFile = getNavigationMenuItemBaseFile({
name: objectName,
viewUniversalIdentifier,
type: 'OBJECT',
targetObjectUniversalIdentifier: this.lastObjectUniversalIdentifier,
});
const navFolderPath = customPath
@@ -19,25 +19,35 @@ describe('getNavigationMenuItemBaseFile', () => {
expect(result).toContain('position: 0');
});
it('should include viewUniversalIdentifier when provided', () => {
it('should include viewUniversalIdentifier when type is VIEW', () => {
const result = getNavigationMenuItemBaseFile({
name: 'linked-item',
type: 'VIEW',
viewUniversalIdentifier: 'view-uuid-123',
});
expect(result).toContain("type: 'VIEW'");
expect(result).toContain("viewUniversalIdentifier: 'view-uuid-123'");
expect(result).not.toContain('// Link to a view:');
});
it('should include commented link options when viewUniversalIdentifier is not provided', () => {
it('should default to VIEW type when no type is provided', () => {
const result = getNavigationMenuItemBaseFile({
name: 'unlinked-item',
});
expect(result).toContain('// Link to a view:');
expect(result).toContain("type: 'VIEW'");
expect(result).toContain('// viewUniversalIdentifier:');
expect(result).toContain('// targetObjectUniversalIdentifier:');
expect(result).toContain('// link:');
});
it('should include targetObjectUniversalIdentifier when type is OBJECT', () => {
const result = getNavigationMenuItemBaseFile({
name: 'object-item',
type: 'OBJECT',
targetObjectUniversalIdentifier: 'obj-uuid-123',
});
expect(result).toContain("type: 'OBJECT'");
expect(result).toContain("targetObjectUniversalIdentifier: 'obj-uuid-123'");
});
it('should generate unique UUID when not provided', () => {
@@ -4,22 +4,35 @@ import { v4 } from 'uuid';
export const getNavigationMenuItemBaseFile = ({
name,
universalIdentifier = v4(),
type,
viewUniversalIdentifier,
targetObjectUniversalIdentifier,
}: {
name: string;
universalIdentifier?: string;
type?: 'OBJECT' | 'VIEW' | 'LINK' | 'FOLDER';
viewUniversalIdentifier?: string;
targetObjectUniversalIdentifier?: string;
}) => {
const kebabCaseName = kebabCase(name);
const linkConfig = viewUniversalIdentifier
? ` viewUniversalIdentifier: '${viewUniversalIdentifier}',`
: ` // Link to a view:
// viewUniversalIdentifier: '...',
// Or link to an object:
// targetObjectUniversalIdentifier: '...',
// Or link to an external URL:
// link: 'https://example.com',`;
let typeAndConfig: string;
if (type === 'OBJECT' && targetObjectUniversalIdentifier) {
typeAndConfig = ` type: 'OBJECT',
targetObjectUniversalIdentifier: '${targetObjectUniversalIdentifier}',`;
} else if (type === 'VIEW' && viewUniversalIdentifier) {
typeAndConfig = ` type: 'VIEW',
viewUniversalIdentifier: '${viewUniversalIdentifier}',`;
} else if (type === 'LINK') {
typeAndConfig = ` type: 'LINK',
link: 'https://example.com',`;
} else if (type === 'FOLDER') {
typeAndConfig = ` type: 'FOLDER',`;
} else {
typeAndConfig = ` type: 'VIEW',
// viewUniversalIdentifier: '...',`;
}
return `import { defineNavigationMenuItem } from 'twenty-sdk';
@@ -28,7 +41,7 @@ export default defineNavigationMenuItem({
name: '${kebabCaseName}',
icon: 'IconList',
position: 0,
${linkConfig}
${typeAndConfig}
});
`;
};
@@ -485,6 +485,7 @@ type ObjectStandardOverrides {
labelPlural: String
description: String
icon: String
color: String
translations: JSON
}
@@ -499,6 +500,7 @@ type Object {
icon: String
standardOverrides: ObjectStandardOverrides
shortcut: String
color: String
isCustom: Boolean!
isRemote: Boolean!
isActive: Boolean!
@@ -1939,6 +1941,7 @@ type NavigationMenuItem {
targetRecordId: UUID
targetObjectMetadataId: UUID
viewId: UUID
type: NavigationMenuItemType!
name: String
link: String
icon: String
@@ -1951,6 +1954,14 @@ type NavigationMenuItem {
targetRecordIdentifier: RecordIdentifier
}
enum NavigationMenuItemType {
VIEW
FOLDER
LINK
OBJECT
RECORD
}
type LogicFunctionExecutionResult {
"""Execution result in JSON format"""
data: JSON
@@ -2245,6 +2256,7 @@ type MinimalObjectMetadata {
labelSingular: String!
labelPlural: String!
icon: String
color: String
isCustom: Boolean!
isActive: Boolean!
isSystem: Boolean!
@@ -3507,6 +3519,7 @@ input CreateObjectInput {
description: String
icon: String
shortcut: String
color: String
skipNameField: Boolean
isRemote: Boolean
primaryKeyColumnType: String
@@ -3534,6 +3547,7 @@ input UpdateObjectPayload {
description: String
icon: String
shortcut: String
color: String
isActive: Boolean
labelIdentifierFieldMetadataId: UUID
imageIdentifierFieldMetadataId: UUID
@@ -3791,6 +3805,7 @@ input CreateNavigationMenuItemInput {
targetRecordId: UUID
targetObjectMetadataId: UUID
viewId: UUID
type: NavigationMenuItemType!
name: String
link: String
icon: String
@@ -344,6 +344,7 @@ export interface ObjectStandardOverrides {
labelPlural?: Scalars['String']
description?: Scalars['String']
icon?: Scalars['String']
color?: Scalars['String']
translations?: Scalars['JSON']
__typename: 'ObjectStandardOverrides'
}
@@ -359,6 +360,7 @@ export interface Object {
icon?: Scalars['String']
standardOverrides?: ObjectStandardOverrides
shortcut?: Scalars['String']
color?: Scalars['String']
isCustom: Scalars['Boolean']
isRemote: Scalars['Boolean']
isActive: Scalars['Boolean']
@@ -1675,6 +1677,7 @@ export interface NavigationMenuItem {
targetRecordId?: Scalars['UUID']
targetObjectMetadataId?: Scalars['UUID']
viewId?: Scalars['UUID']
type: NavigationMenuItemType
name?: Scalars['String']
link?: Scalars['String']
icon?: Scalars['String']
@@ -1688,6 +1691,8 @@ export interface NavigationMenuItem {
__typename: 'NavigationMenuItem'
}
export type NavigationMenuItemType = 'VIEW' | 'FOLDER' | 'LINK' | 'OBJECT' | 'RECORD'
export interface LogicFunctionExecutionResult {
/** Execution result in JSON format */
data?: Scalars['JSON']
@@ -1901,6 +1906,7 @@ export interface MinimalObjectMetadata {
labelSingular: Scalars['String']
labelPlural: Scalars['String']
icon?: Scalars['String']
color?: Scalars['String']
isCustom: Scalars['Boolean']
isActive: Scalars['Boolean']
isSystem: Scalars['Boolean']
@@ -3247,6 +3253,7 @@ export interface ObjectStandardOverridesGenqlSelection{
labelPlural?: boolean | number
description?: boolean | number
icon?: boolean | number
color?: boolean | number
translations?: boolean | number
__typename?: boolean | number
__scalar?: boolean | number
@@ -3263,6 +3270,7 @@ export interface ObjectGenqlSelection{
icon?: boolean | number
standardOverrides?: ObjectStandardOverridesGenqlSelection
shortcut?: boolean | number
color?: boolean | number
isCustom?: boolean | number
isRemote?: boolean | number
isActive?: boolean | number
@@ -4666,6 +4674,7 @@ export interface NavigationMenuItemGenqlSelection{
targetRecordId?: boolean | number
targetObjectMetadataId?: boolean | number
viewId?: boolean | number
type?: boolean | number
name?: boolean | number
link?: boolean | number
icon?: boolean | number
@@ -4900,6 +4909,7 @@ export interface MinimalObjectMetadataGenqlSelection{
labelSingular?: boolean | number
labelPlural?: boolean | number
icon?: boolean | number
color?: boolean | number
isCustom?: boolean | number
isActive?: boolean | number
isSystem?: boolean | number
@@ -6006,7 +6016,7 @@ export interface CreateOneObjectInput {
/** The object to create */
object: CreateObjectInput}
export interface CreateObjectInput {nameSingular: Scalars['String'],namePlural: Scalars['String'],labelSingular: Scalars['String'],labelPlural: Scalars['String'],description?: (Scalars['String'] | null),icon?: (Scalars['String'] | null),shortcut?: (Scalars['String'] | null),skipNameField?: (Scalars['Boolean'] | null),isRemote?: (Scalars['Boolean'] | null),primaryKeyColumnType?: (Scalars['String'] | null),primaryKeyFieldMetadataSettings?: (Scalars['JSON'] | null),isLabelSyncedWithName?: (Scalars['Boolean'] | null)}
export interface CreateObjectInput {nameSingular: Scalars['String'],namePlural: Scalars['String'],labelSingular: Scalars['String'],labelPlural: Scalars['String'],description?: (Scalars['String'] | null),icon?: (Scalars['String'] | null),shortcut?: (Scalars['String'] | null),color?: (Scalars['String'] | null),skipNameField?: (Scalars['Boolean'] | null),isRemote?: (Scalars['Boolean'] | null),primaryKeyColumnType?: (Scalars['String'] | null),primaryKeyFieldMetadataSettings?: (Scalars['JSON'] | null),isLabelSyncedWithName?: (Scalars['Boolean'] | null)}
export interface DeleteOneObjectInput {
/** The id of the record to delete. */
@@ -6016,7 +6026,7 @@ export interface UpdateOneObjectInput {update: UpdateObjectPayload,
/** The id of the object to update */
id: Scalars['UUID']}
export interface UpdateObjectPayload {labelSingular?: (Scalars['String'] | null),labelPlural?: (Scalars['String'] | null),nameSingular?: (Scalars['String'] | null),namePlural?: (Scalars['String'] | null),description?: (Scalars['String'] | null),icon?: (Scalars['String'] | null),shortcut?: (Scalars['String'] | null),isActive?: (Scalars['Boolean'] | null),labelIdentifierFieldMetadataId?: (Scalars['UUID'] | null),imageIdentifierFieldMetadataId?: (Scalars['UUID'] | null),isLabelSyncedWithName?: (Scalars['Boolean'] | null)}
export interface UpdateObjectPayload {labelSingular?: (Scalars['String'] | null),labelPlural?: (Scalars['String'] | null),nameSingular?: (Scalars['String'] | null),namePlural?: (Scalars['String'] | null),description?: (Scalars['String'] | null),icon?: (Scalars['String'] | null),shortcut?: (Scalars['String'] | null),color?: (Scalars['String'] | null),isActive?: (Scalars['Boolean'] | null),labelIdentifierFieldMetadataId?: (Scalars['UUID'] | null),imageIdentifierFieldMetadataId?: (Scalars['UUID'] | null),isLabelSyncedWithName?: (Scalars['Boolean'] | null)}
export interface UpdateViewFieldInput {
/** The id of the view field to update */
@@ -6108,7 +6118,7 @@ export interface CreateAgentInput {name?: (Scalars['String'] | null),label: Scal
export interface UpdateAgentInput {id: Scalars['UUID'],name?: (Scalars['String'] | null),label?: (Scalars['String'] | null),icon?: (Scalars['String'] | null),description?: (Scalars['String'] | null),prompt?: (Scalars['String'] | null),modelId?: (Scalars['String'] | null),roleId?: (Scalars['UUID'] | null),responseFormat?: (Scalars['JSON'] | null),modelConfiguration?: (Scalars['JSON'] | null),evaluationInputs?: (Scalars['String'][] | null)}
export interface CreateNavigationMenuItemInput {userWorkspaceId?: (Scalars['UUID'] | null),targetRecordId?: (Scalars['UUID'] | null),targetObjectMetadataId?: (Scalars['UUID'] | null),viewId?: (Scalars['UUID'] | null),name?: (Scalars['String'] | null),link?: (Scalars['String'] | null),icon?: (Scalars['String'] | null),color?: (Scalars['String'] | null),folderId?: (Scalars['UUID'] | null),position?: (Scalars['Float'] | null)}
export interface CreateNavigationMenuItemInput {userWorkspaceId?: (Scalars['UUID'] | null),targetRecordId?: (Scalars['UUID'] | null),targetObjectMetadataId?: (Scalars['UUID'] | null),viewId?: (Scalars['UUID'] | null),type: NavigationMenuItemType,name?: (Scalars['String'] | null),link?: (Scalars['String'] | null),icon?: (Scalars['String'] | null),color?: (Scalars['String'] | null),folderId?: (Scalars['UUID'] | null),position?: (Scalars['Float'] | null)}
export interface UpdateOneNavigationMenuItemInput {
/** The id of the record to update */
@@ -8694,6 +8704,14 @@ export const enumRelationType = {
MANY_TO_ONE: 'MANY_TO_ONE' as const
}
export const enumNavigationMenuItemType = {
VIEW: 'VIEW' as const,
FOLDER: 'FOLDER' as const,
LINK: 'LINK' as const,
OBJECT: 'OBJECT' as const,
RECORD: 'RECORD' as const
}
export const enumLogicFunctionExecutionStatus = {
IDLE: 'IDLE' as const,
SUCCESS: 'SUCCESS' as const,
File diff suppressed because it is too large Load Diff