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:
+2
@@ -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,
|
||||
|
||||
+2
-7
@@ -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": {
|
||||
|
||||
+9
@@ -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',
|
||||
|
||||
+4
@@ -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,
|
||||
},
|
||||
|
||||
+2
@@ -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,
|
||||
|
||||
+6
@@ -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,
|
||||
},
|
||||
|
||||
+2
@@ -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,
|
||||
},
|
||||
|
||||
+5
@@ -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,
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user