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
@@ -2,7 +2,6 @@ import { ObjectIconWithViewOverlay } from '@/navigation-menu-item/components/Obj
import { NavigationMenuItemType } from '@/navigation-menu-item/constants/NavigationMenuItemType';
import { useObjectNavItemColor } from '@/navigation-menu-item/hooks/useObjectNavItemColor';
import { isNavigationMenuInEditModeState } from '@/navigation-menu-item/states/isNavigationMenuInEditModeState';
import { getStandardObjectIconColor } from '@/navigation-menu-item/utils/getStandardObjectIconColor';
import { type ProcessedNavigationMenuItem } from '@/navigation-menu-item/utils/sortNavigationMenuItems';
import { lastVisitedViewPerObjectMetadataItemState } from '@/navigation/states/lastVisitedViewPerObjectMetadataItemState';
import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
@@ -52,7 +51,9 @@ export const NavigationDrawerItemForObjectMetadataItem = ({
const isRecord =
navigationMenuItem?.itemType === NavigationMenuItemType.RECORD;
const isView = navigationMenuItem?.itemType === NavigationMenuItemType.VIEW;
const hasCustomLink = isRecord || isView;
const isObject =
navigationMenuItem?.itemType === NavigationMenuItemType.OBJECT;
const hasCustomLink = isRecord || isView || isObject;
const navigationPath = hasCustomLink
? navigationMenuItem!.link
@@ -63,7 +64,7 @@ export const NavigationDrawerItemForObjectMetadataItem = ({
);
const isActive = hasCustomLink
? (isView ? currentPathWithSearch : currentPath) ===
? (isView || isObject ? currentPathWithSearch : currentPath) ===
navigationMenuItem!.link
: currentPath ===
getAppPath(AppPath.RecordIndexPage, {
@@ -114,11 +115,7 @@ export const NavigationDrawerItemForObjectMetadataItem = ({
)
: getIcon(objectMetadataItem.icon);
const iconThemeColor = !isRecord
? isDefined(navigationMenuItem?.color)
? navigationMenuItem.color
: (getStandardObjectIconColor(objectMetadataItem.nameSingular) ?? 'gray')
: undefined;
const iconThemeColor = !isRecord ? objectNavItemColor : undefined;
const secondaryLabel =
isRecord || isViewWithCustomName
@@ -96,6 +96,7 @@ export const NavigationDrawerSectionForWorkspaceItems = ({
return true;
}
if (
type === NavigationMenuItemType.OBJECT ||
type === NavigationMenuItemType.VIEW ||
type === NavigationMenuItemType.RECORD
) {
@@ -123,7 +124,7 @@ export const NavigationDrawerSectionForWorkspaceItems = ({
? () => {
const type = item.itemType;
const objectMetadataItem =
type === 'view' || type === 'record'
type === 'OBJECT' || type === 'VIEW' || type === 'RECORD'
? getObjectMetadataForNavigationMenuItem(
item as ProcessedNavigationMenuItem,
objectMetadataItems,
@@ -8,6 +8,7 @@ export const OBJECT_METADATA_FRAGMENT = gql`
namePlural
labelSingular
labelPlural
color
description
icon
isCustom
@@ -10,6 +10,7 @@ export const CREATE_ONE_OBJECT_METADATA_ITEM = gql`
labelPlural
description
icon
color
isCustom
isActive
isSearchable
@@ -90,6 +91,7 @@ export const UPDATE_ONE_OBJECT_METADATA_ITEM = gql`
labelPlural
description
icon
color
isCustom
isActive
isSearchable
@@ -112,6 +114,7 @@ export const DELETE_ONE_OBJECT_METADATA_ITEM = gql`
labelPlural
description
icon
color
isCustom
isActive
isSearchable
@@ -27,6 +27,7 @@ export const responseData = {
labelPlural: 'View Filters',
description: '',
icon: '',
color: null,
isCustom: false,
isActive: true,
isSearchable: false,
@@ -10,6 +10,7 @@ export const query = gql`
labelPlural
description
icon
color
isCustom
isActive
isSearchable
@@ -32,6 +33,7 @@ export const responseData = {
labelPlural: '',
description: '',
icon: '',
color: null,
isCustom: false,
isActive: true,
isSearchable: false,