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
@@ -70,6 +70,7 @@ exports[`syncApplication should delete old field and create equivalent one when
{
"flatEntity": {
"applicationUniversalIdentifier": Any<String>,
"color": null,
"createdAt": Any<String>,
"description": "A support ticket",
"duplicateCriteria": null,
@@ -422,6 +423,7 @@ exports[`syncApplication should return workspace migration actions on initial sy
{
"flatEntity": {
"applicationUniversalIdentifier": Any<String>,
"color": null,
"createdAt": Any<String>,
"description": "A support ticket",
"duplicateCriteria": null,
@@ -100,13 +100,8 @@ exports[`NavigationMenuItem creation should fail when creating with missing targ
"errors": [
{
"code": "INVALID_NAVIGATION_MENU_ITEM_INPUT",
"message": "targetObjectMetadataId is required when targetRecordId is provided",
"userFriendlyMessage": "targetObjectMetadataId is required when targetRecordId is provided",
},
{
"code": "INVALID_NAVIGATION_MENU_ITEM_INPUT",
"message": "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": "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)",
"message": "targetRecordId and targetObjectMetadataId are required for RECORD type",
"userFriendlyMessage": "targetRecordId and targetObjectMetadataId are required for RECORD type",
},
],
"flatEntityMinimalInformation": {
@@ -5,6 +5,7 @@ import {
eachTestingContextFilter,
type EachTestingContext,
} from 'twenty-shared/testing';
import { NavigationMenuItemType } from 'twenty-shared/types';
import { type CreateNavigationMenuItemInput } from 'src/engine/metadata-modules/navigation-menu-item/dtos/create-navigation-menu-item.input';
@@ -18,6 +19,7 @@ const failingNavigationMenuItemCreationTestCases: EachTestingContext<TestContext
title: 'when creating with missing targetRecordId',
context: {
input: {
type: NavigationMenuItemType.RECORD,
targetObjectMetadataId: faker.string.uuid(),
} as CreateNavigationMenuItemInput,
},
@@ -26,6 +28,7 @@ const failingNavigationMenuItemCreationTestCases: EachTestingContext<TestContext
title: 'when creating with empty targetRecordId',
context: {
input: {
type: NavigationMenuItemType.RECORD,
targetRecordId: '',
targetObjectMetadataId: faker.string.uuid(),
},
@@ -35,6 +38,7 @@ const failingNavigationMenuItemCreationTestCases: EachTestingContext<TestContext
title: 'when creating with invalid targetRecordId (not a UUID)',
context: {
input: {
type: NavigationMenuItemType.RECORD,
targetRecordId: 'not-a-valid-uuid',
targetObjectMetadataId: faker.string.uuid(),
},
@@ -44,6 +48,7 @@ const failingNavigationMenuItemCreationTestCases: EachTestingContext<TestContext
title: 'when creating with missing targetObjectMetadataId',
context: {
input: {
type: NavigationMenuItemType.RECORD,
targetRecordId: faker.string.uuid(),
} as CreateNavigationMenuItemInput,
},
@@ -52,6 +57,7 @@ const failingNavigationMenuItemCreationTestCases: EachTestingContext<TestContext
title: 'when creating with empty targetObjectMetadataId',
context: {
input: {
type: NavigationMenuItemType.RECORD,
targetRecordId: faker.string.uuid(),
targetObjectMetadataId: '',
},
@@ -61,6 +67,7 @@ const failingNavigationMenuItemCreationTestCases: EachTestingContext<TestContext
title: 'when creating with invalid targetObjectMetadataId (not a UUID)',
context: {
input: {
type: NavigationMenuItemType.RECORD,
targetRecordId: faker.string.uuid(),
targetObjectMetadataId: 'not-a-valid-uuid',
},
@@ -70,6 +77,7 @@ const failingNavigationMenuItemCreationTestCases: EachTestingContext<TestContext
title: 'when creating with invalid userWorkspaceId (not a UUID)',
context: {
input: {
type: NavigationMenuItemType.RECORD,
targetRecordId: faker.string.uuid(),
targetObjectMetadataId: faker.string.uuid(),
userWorkspaceId: 'not-a-valid-uuid',
@@ -80,6 +88,7 @@ const failingNavigationMenuItemCreationTestCases: EachTestingContext<TestContext
title: 'when creating with invalid folderId (not a UUID)',
context: {
input: {
type: NavigationMenuItemType.RECORD,
targetRecordId: faker.string.uuid(),
targetObjectMetadataId: faker.string.uuid(),
folderId: 'not-a-valid-uuid',
@@ -1,3 +1,4 @@
import { NavigationMenuItemType } from 'twenty-shared/types';
import { expectOneNotInternalServerErrorSnapshot } from 'test/integration/graphql/utils/expect-one-not-internal-server-error-snapshot.util';
import { createNavigationMenuItem } from 'test/integration/metadata/suites/navigation-menu-item/utils/create-navigation-menu-item.util';
import { deleteNavigationMenuItem } from 'test/integration/metadata/suites/navigation-menu-item/utils/delete-navigation-menu-item.util';
@@ -13,6 +14,7 @@ describe('Navigation Menu Item update should fail with circular dependency', ()
const { data: folderData } = await createNavigationMenuItem({
expectToFail: false,
input: {
type: NavigationMenuItemType.FOLDER,
name: 'Standalone Folder',
},
});
@@ -23,6 +25,7 @@ describe('Navigation Menu Item update should fail with circular dependency', ()
const { data: parentFolderData } = await createNavigationMenuItem({
expectToFail: false,
input: {
type: NavigationMenuItemType.FOLDER,
name: 'Parent Folder',
},
});
@@ -33,6 +36,7 @@ describe('Navigation Menu Item update should fail with circular dependency', ()
const { data: childFolderData } = await createNavigationMenuItem({
expectToFail: false,
input: {
type: NavigationMenuItemType.FOLDER,
name: 'Child Folder',
folderId: parentFolderId,
},
@@ -9,6 +9,7 @@ import {
eachTestingContextFilter,
type EachTestingContext,
} from 'twenty-shared/testing';
import { NavigationMenuItemType } from 'twenty-shared/types';
import { type UpdateOneNavigationMenuItemInput } from 'src/engine/metadata-modules/navigation-menu-item/dtos/update-navigation-menu-item.input';
@@ -54,6 +55,7 @@ describe('NavigationMenuItem update should fail', () => {
const { data } = await createNavigationMenuItem({
expectToFail: false,
input: {
type: NavigationMenuItemType.RECORD,
targetRecordId,
targetObjectMetadataId: personObjectMetadataId,
position: 1,
@@ -1,4 +1,5 @@
import { faker } from '@faker-js/faker';
import { NavigationMenuItemType } from 'twenty-shared/types';
import { getCurrentUser } from 'test/integration/graphql/utils/get-current-user.util';
import { createNavigationMenuItem } from 'test/integration/metadata/suites/navigation-menu-item/utils/create-navigation-menu-item.util';
import { deleteNavigationMenuItem } from 'test/integration/metadata/suites/navigation-menu-item/utils/delete-navigation-menu-item.util';
@@ -74,6 +75,7 @@ describe('NavigationMenuItem creation should succeed', () => {
const { data } = await createNavigationMenuItem({
expectToFail: false,
input: {
type: NavigationMenuItemType.RECORD,
targetRecordId,
targetObjectMetadataId: personObjectMetadataId,
},
@@ -98,6 +100,7 @@ describe('NavigationMenuItem creation should succeed', () => {
const { data: folderData } = await createNavigationMenuItem({
expectToFail: false,
input: {
type: NavigationMenuItemType.RECORD,
targetRecordId: folderTargetRecordId,
targetObjectMetadataId: companyObjectMetadataId,
userWorkspaceId: validUserWorkspaceId ?? undefined,
@@ -112,6 +115,7 @@ describe('NavigationMenuItem creation should succeed', () => {
const { data } = await createNavigationMenuItem({
expectToFail: false,
input: {
type: NavigationMenuItemType.RECORD,
targetRecordId,
targetObjectMetadataId: companyObjectMetadataId,
userWorkspaceId: validUserWorkspaceId ?? undefined,
@@ -139,6 +143,7 @@ describe('NavigationMenuItem creation should succeed', () => {
const { data: data1 } = await createNavigationMenuItem({
expectToFail: false,
input: {
type: NavigationMenuItemType.RECORD,
targetRecordId: targetRecordId1,
targetObjectMetadataId: personObjectMetadataId,
},
@@ -150,6 +155,7 @@ describe('NavigationMenuItem creation should succeed', () => {
const { data: data2 } = await createNavigationMenuItem({
expectToFail: false,
input: {
type: NavigationMenuItemType.RECORD,
targetRecordId: targetRecordId2,
targetObjectMetadataId: personObjectMetadataId,
},
@@ -1,4 +1,5 @@
import { faker } from '@faker-js/faker';
import { NavigationMenuItemType } from 'twenty-shared/types';
import { createNavigationMenuItem } from 'test/integration/metadata/suites/navigation-menu-item/utils/create-navigation-menu-item.util';
import { deleteNavigationMenuItem } from 'test/integration/metadata/suites/navigation-menu-item/utils/delete-navigation-menu-item.util';
import { findManyObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/find-many-object-metadata.util';
@@ -38,6 +39,7 @@ describe('NavigationMenuItem deletion should succeed', () => {
const { data: createData } = await createNavigationMenuItem({
expectToFail: false,
input: {
type: NavigationMenuItemType.RECORD,
targetRecordId,
targetObjectMetadataId: personObjectMetadataId,
},
@@ -1,4 +1,5 @@
import { faker } from '@faker-js/faker';
import { NavigationMenuItemType } from 'twenty-shared/types';
import { createNavigationMenuItem } from 'test/integration/metadata/suites/navigation-menu-item/utils/create-navigation-menu-item.util';
import { deleteNavigationMenuItem } from 'test/integration/metadata/suites/navigation-menu-item/utils/delete-navigation-menu-item.util';
import { updateNavigationMenuItem } from 'test/integration/metadata/suites/navigation-menu-item/utils/update-navigation-menu-item.util';
@@ -40,6 +41,7 @@ describe('NavigationMenuItem update should succeed', () => {
const { data } = await createNavigationMenuItem({
expectToFail: false,
input: {
type: NavigationMenuItemType.RECORD,
targetRecordId,
targetObjectMetadataId: personObjectMetadataId,
position: 1,
@@ -89,6 +91,7 @@ describe('NavigationMenuItem update should succeed', () => {
const { data: folderData } = await createNavigationMenuItem({
expectToFail: false,
input: {
type: NavigationMenuItemType.RECORD,
targetRecordId: folderTargetRecordId,
targetObjectMetadataId: personObjectMetadataId,
},
@@ -121,6 +124,7 @@ describe('NavigationMenuItem update should succeed', () => {
const { data: folderData } = await createNavigationMenuItem({
expectToFail: false,
input: {
type: NavigationMenuItemType.RECORD,
targetRecordId: folderTargetRecordId,
targetObjectMetadataId: personObjectMetadataId,
},
@@ -163,6 +167,7 @@ describe('NavigationMenuItem update should succeed', () => {
const { data: folderData } = await createNavigationMenuItem({
expectToFail: false,
input: {
type: NavigationMenuItemType.RECORD,
targetRecordId: folderTargetRecordId,
targetObjectMetadataId: personObjectMetadataId,
},