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:
+12
@@ -1,50 +1,60 @@
|
||||
import { STANDARD_OBJECTS } from 'twenty-shared/metadata';
|
||||
|
||||
import { NavigationMenuItemType } from 'src/engine/metadata-modules/navigation-menu-item/enums/navigation-menu-item-type.enum';
|
||||
|
||||
export const STANDARD_NAVIGATION_MENU_ITEMS = {
|
||||
allCompanies: {
|
||||
universalIdentifier: '20202020-b001-4b01-8b01-c0aba11c0001',
|
||||
type: NavigationMenuItemType.OBJECT,
|
||||
viewUniversalIdentifier:
|
||||
STANDARD_OBJECTS.company.views.allCompanies.universalIdentifier,
|
||||
position: 0,
|
||||
},
|
||||
allPeople: {
|
||||
universalIdentifier: '20202020-b005-4b05-8b05-c0aba11c0005',
|
||||
type: NavigationMenuItemType.OBJECT,
|
||||
viewUniversalIdentifier:
|
||||
STANDARD_OBJECTS.person.views.allPeople.universalIdentifier,
|
||||
position: 1,
|
||||
},
|
||||
allOpportunities: {
|
||||
universalIdentifier: '20202020-b004-4b04-8b04-c0aba11c0004',
|
||||
type: NavigationMenuItemType.OBJECT,
|
||||
viewUniversalIdentifier:
|
||||
STANDARD_OBJECTS.opportunity.views.allOpportunities.universalIdentifier,
|
||||
position: 2,
|
||||
},
|
||||
allTasks: {
|
||||
universalIdentifier: '20202020-b006-4b06-8b06-c0aba11c0006',
|
||||
type: NavigationMenuItemType.OBJECT,
|
||||
viewUniversalIdentifier:
|
||||
STANDARD_OBJECTS.task.views.allTasks.universalIdentifier,
|
||||
position: 3,
|
||||
},
|
||||
allNotes: {
|
||||
universalIdentifier: '20202020-b003-4b03-8b03-c0aba11c0003',
|
||||
type: NavigationMenuItemType.OBJECT,
|
||||
viewUniversalIdentifier:
|
||||
STANDARD_OBJECTS.note.views.allNotes.universalIdentifier,
|
||||
position: 4,
|
||||
},
|
||||
allDashboards: {
|
||||
universalIdentifier: '20202020-b002-4b02-8b02-c0aba11c0002',
|
||||
type: NavigationMenuItemType.OBJECT,
|
||||
viewUniversalIdentifier:
|
||||
STANDARD_OBJECTS.dashboard.views.allDashboards.universalIdentifier,
|
||||
position: 5,
|
||||
},
|
||||
workflowsFolder: {
|
||||
universalIdentifier: '20202020-b007-4b07-8b07-c0aba11c0007',
|
||||
type: NavigationMenuItemType.FOLDER,
|
||||
name: 'Workflows',
|
||||
icon: 'IconSettingsAutomation',
|
||||
position: 6,
|
||||
},
|
||||
workflowsFolderAllWorkflows: {
|
||||
universalIdentifier: '20202020-b008-4b08-8b08-c0aba11c0008',
|
||||
type: NavigationMenuItemType.OBJECT,
|
||||
viewUniversalIdentifier:
|
||||
STANDARD_OBJECTS.workflow.views.allWorkflows.universalIdentifier,
|
||||
folderUniversalIdentifier: '20202020-b007-4b07-8b07-c0aba11c0007',
|
||||
@@ -52,6 +62,7 @@ export const STANDARD_NAVIGATION_MENU_ITEMS = {
|
||||
},
|
||||
workflowsFolderAllWorkflowRuns: {
|
||||
universalIdentifier: '20202020-b009-4b09-8b09-c0aba11c0009',
|
||||
type: NavigationMenuItemType.OBJECT,
|
||||
viewUniversalIdentifier:
|
||||
STANDARD_OBJECTS.workflowRun.views.allWorkflowRuns.universalIdentifier,
|
||||
folderUniversalIdentifier: '20202020-b007-4b07-8b07-c0aba11c0007',
|
||||
@@ -59,6 +70,7 @@ export const STANDARD_NAVIGATION_MENU_ITEMS = {
|
||||
},
|
||||
workflowsFolderAllWorkflowVersions: {
|
||||
universalIdentifier: '20202020-b00a-4b0a-8b0a-c0aba11c000a',
|
||||
type: NavigationMenuItemType.OBJECT,
|
||||
viewUniversalIdentifier:
|
||||
STANDARD_OBJECTS.workflowVersion.views.allWorkflowVersions
|
||||
.universalIdentifier,
|
||||
|
||||
+11
-4
@@ -2,6 +2,7 @@ import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
import { type FlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/flat-entity-maps.type';
|
||||
import { findFlatEntityByUniversalIdentifier } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-universal-identifier.util';
|
||||
import { NavigationMenuItemType } from 'src/engine/metadata-modules/navigation-menu-item/enums/navigation-menu-item-type.enum';
|
||||
import { type FlatNavigationMenuItem } from 'src/engine/metadata-modules/flat-navigation-menu-item/types/flat-navigation-menu-item.type';
|
||||
import { type FlatView } from 'src/engine/metadata-modules/flat-view/types/flat-view.type';
|
||||
import {
|
||||
@@ -51,8 +52,12 @@ export const createStandardNavigationMenuItemFlatMetadata = ({
|
||||
);
|
||||
}
|
||||
|
||||
const isObjectType =
|
||||
navigationMenuItemDefinition.type === NavigationMenuItemType.OBJECT;
|
||||
|
||||
return {
|
||||
id: navigationMenuItemId,
|
||||
type: navigationMenuItemDefinition.type,
|
||||
universalIdentifier: navigationMenuItemDefinition.universalIdentifier,
|
||||
applicationId: twentyStandardApplicationId,
|
||||
applicationUniversalIdentifier:
|
||||
@@ -60,10 +65,12 @@ export const createStandardNavigationMenuItemFlatMetadata = ({
|
||||
workspaceId,
|
||||
userWorkspaceId: null,
|
||||
targetRecordId: null,
|
||||
targetObjectMetadataId: null,
|
||||
targetObjectMetadataUniversalIdentifier: null,
|
||||
viewId: flatView.id,
|
||||
viewUniversalIdentifier: flatView.universalIdentifier,
|
||||
targetObjectMetadataId: isObjectType ? flatView.objectMetadataId : null,
|
||||
targetObjectMetadataUniversalIdentifier: isObjectType
|
||||
? flatView.objectMetadataUniversalIdentifier
|
||||
: null,
|
||||
viewId: isObjectType ? null : flatView.id,
|
||||
viewUniversalIdentifier: isObjectType ? null : flatView.universalIdentifier,
|
||||
folderId: null,
|
||||
folderUniversalIdentifier: null,
|
||||
name: null,
|
||||
|
||||
+8
-4
@@ -1,4 +1,5 @@
|
||||
import { type FlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/flat-entity-maps.type';
|
||||
import { NavigationMenuItemType } from 'src/engine/metadata-modules/navigation-menu-item/enums/navigation-menu-item-type.enum';
|
||||
import { findFlatEntityByUniversalIdentifier } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-universal-identifier.util';
|
||||
import { type FlatNavigationMenuItem } from 'src/engine/metadata-modules/flat-navigation-menu-item/types/flat-navigation-menu-item.type';
|
||||
import { type FlatView } from 'src/engine/metadata-modules/flat-view/types/flat-view.type';
|
||||
@@ -24,6 +25,7 @@ export const createStandardNavigationMenuItemFolderFlatMetadata = ({
|
||||
now: string;
|
||||
}): FlatNavigationMenuItem => ({
|
||||
id: navigationMenuItemId,
|
||||
type: NavigationMenuItemType.FOLDER,
|
||||
universalIdentifier,
|
||||
applicationId: twentyStandardApplicationId,
|
||||
applicationUniversalIdentifier:
|
||||
@@ -84,6 +86,7 @@ export const createStandardNavigationMenuItemFolderItemFlatMetadata = ({
|
||||
|
||||
return {
|
||||
id: navigationMenuItemId,
|
||||
type: NavigationMenuItemType.OBJECT,
|
||||
universalIdentifier,
|
||||
applicationId: twentyStandardApplicationId,
|
||||
applicationUniversalIdentifier:
|
||||
@@ -91,10 +94,11 @@ export const createStandardNavigationMenuItemFolderItemFlatMetadata = ({
|
||||
workspaceId,
|
||||
userWorkspaceId: null,
|
||||
targetRecordId: null,
|
||||
targetObjectMetadataId: null,
|
||||
targetObjectMetadataUniversalIdentifier: null,
|
||||
viewId: flatView.id,
|
||||
viewUniversalIdentifier: flatView.universalIdentifier,
|
||||
targetObjectMetadataId: flatView.objectMetadataId,
|
||||
targetObjectMetadataUniversalIdentifier:
|
||||
flatView.objectMetadataUniversalIdentifier,
|
||||
viewId: null,
|
||||
viewUniversalIdentifier: null,
|
||||
folderId,
|
||||
folderUniversalIdentifier,
|
||||
name: null,
|
||||
|
||||
+1
@@ -69,6 +69,7 @@ export const createStandardObjectFlatMetadata = <
|
||||
namePlural,
|
||||
labelSingular,
|
||||
labelPlural,
|
||||
color: null,
|
||||
description,
|
||||
icon,
|
||||
isCustom: false,
|
||||
|
||||
Reference in New Issue
Block a user