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:
+5
-8
@@ -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
|
||||
|
||||
+2
-1
@@ -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
|
||||
|
||||
+1
@@ -27,6 +27,7 @@ export const responseData = {
|
||||
labelPlural: 'View Filters',
|
||||
description: '',
|
||||
icon: '',
|
||||
color: null,
|
||||
isCustom: false,
|
||||
isActive: true,
|
||||
isSearchable: false,
|
||||
|
||||
+2
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user