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:
+7
-3
@@ -3,6 +3,7 @@ import type { Manifest } from 'twenty-shared/application';
|
||||
import { PermissionFlagType } from 'twenty-shared/constants';
|
||||
import {
|
||||
FieldMetadataType,
|
||||
NavigationMenuItemType,
|
||||
RelationOnDeleteAction,
|
||||
RelationType,
|
||||
ViewType,
|
||||
@@ -1569,19 +1570,22 @@ export const EXPECTED_MANIFEST: Manifest = {
|
||||
],
|
||||
navigationMenuItems: [
|
||||
{
|
||||
type: NavigationMenuItemType.OBJECT,
|
||||
position: 2,
|
||||
universalIdentifier: 'c1a2b3c4-0003-4a7b-8c9d-0e1f2a3b4c5d',
|
||||
viewUniversalIdentifier: 'b1a2b3c4-0003-4a7b-8c9d-0e1f2a3b4c5d',
|
||||
targetObjectUniversalIdentifier: 'e1a2b3c4-5e6f-4a7b-8c9d-0e1f2a3b4c5e',
|
||||
},
|
||||
{
|
||||
type: NavigationMenuItemType.OBJECT,
|
||||
position: 0,
|
||||
universalIdentifier: 'c1a2b3c4-0001-4a7b-8c9d-0e1f2a3b4c5d',
|
||||
viewUniversalIdentifier: 'b1a2b3c4-0001-4a7b-8c9d-0e1f2a3b4c5d',
|
||||
targetObjectUniversalIdentifier: '54b589ca-eeed-4950-a176-358418b85c05',
|
||||
},
|
||||
{
|
||||
type: NavigationMenuItemType.OBJECT,
|
||||
position: 1,
|
||||
universalIdentifier: 'c1a2b3c4-0002-4a7b-8c9d-0e1f2a3b4c5d',
|
||||
viewUniversalIdentifier: 'b1a2b3c4-0002-4a7b-8c9d-0e1f2a3b4c5d',
|
||||
targetObjectUniversalIdentifier: 'd1a2b3c4-5e6f-4a7b-8c9d-0e1f2a3b4c5d',
|
||||
},
|
||||
],
|
||||
logicFunctions: [
|
||||
|
||||
@@ -260,7 +260,8 @@ export class EntityAddCommand {
|
||||
|
||||
const navFile = getNavigationMenuItemBaseFile({
|
||||
name: objectName,
|
||||
viewUniversalIdentifier,
|
||||
type: 'OBJECT',
|
||||
targetObjectUniversalIdentifier: this.lastObjectUniversalIdentifier,
|
||||
});
|
||||
|
||||
const navFolderPath = customPath
|
||||
|
||||
+16
-6
@@ -19,25 +19,35 @@ describe('getNavigationMenuItemBaseFile', () => {
|
||||
expect(result).toContain('position: 0');
|
||||
});
|
||||
|
||||
it('should include viewUniversalIdentifier when provided', () => {
|
||||
it('should include viewUniversalIdentifier when type is VIEW', () => {
|
||||
const result = getNavigationMenuItemBaseFile({
|
||||
name: 'linked-item',
|
||||
type: 'VIEW',
|
||||
viewUniversalIdentifier: 'view-uuid-123',
|
||||
});
|
||||
|
||||
expect(result).toContain("type: 'VIEW'");
|
||||
expect(result).toContain("viewUniversalIdentifier: 'view-uuid-123'");
|
||||
expect(result).not.toContain('// Link to a view:');
|
||||
});
|
||||
|
||||
it('should include commented link options when viewUniversalIdentifier is not provided', () => {
|
||||
it('should default to VIEW type when no type is provided', () => {
|
||||
const result = getNavigationMenuItemBaseFile({
|
||||
name: 'unlinked-item',
|
||||
});
|
||||
|
||||
expect(result).toContain('// Link to a view:');
|
||||
expect(result).toContain("type: 'VIEW'");
|
||||
expect(result).toContain('// viewUniversalIdentifier:');
|
||||
expect(result).toContain('// targetObjectUniversalIdentifier:');
|
||||
expect(result).toContain('// link:');
|
||||
});
|
||||
|
||||
it('should include targetObjectUniversalIdentifier when type is OBJECT', () => {
|
||||
const result = getNavigationMenuItemBaseFile({
|
||||
name: 'object-item',
|
||||
type: 'OBJECT',
|
||||
targetObjectUniversalIdentifier: 'obj-uuid-123',
|
||||
});
|
||||
|
||||
expect(result).toContain("type: 'OBJECT'");
|
||||
expect(result).toContain("targetObjectUniversalIdentifier: 'obj-uuid-123'");
|
||||
});
|
||||
|
||||
it('should generate unique UUID when not provided', () => {
|
||||
|
||||
+22
-9
@@ -4,22 +4,35 @@ import { v4 } from 'uuid';
|
||||
export const getNavigationMenuItemBaseFile = ({
|
||||
name,
|
||||
universalIdentifier = v4(),
|
||||
type,
|
||||
viewUniversalIdentifier,
|
||||
targetObjectUniversalIdentifier,
|
||||
}: {
|
||||
name: string;
|
||||
universalIdentifier?: string;
|
||||
type?: 'OBJECT' | 'VIEW' | 'LINK' | 'FOLDER';
|
||||
viewUniversalIdentifier?: string;
|
||||
targetObjectUniversalIdentifier?: string;
|
||||
}) => {
|
||||
const kebabCaseName = kebabCase(name);
|
||||
|
||||
const linkConfig = viewUniversalIdentifier
|
||||
? ` viewUniversalIdentifier: '${viewUniversalIdentifier}',`
|
||||
: ` // Link to a view:
|
||||
// viewUniversalIdentifier: '...',
|
||||
// Or link to an object:
|
||||
// targetObjectUniversalIdentifier: '...',
|
||||
// Or link to an external URL:
|
||||
// link: 'https://example.com',`;
|
||||
let typeAndConfig: string;
|
||||
|
||||
if (type === 'OBJECT' && targetObjectUniversalIdentifier) {
|
||||
typeAndConfig = ` type: 'OBJECT',
|
||||
targetObjectUniversalIdentifier: '${targetObjectUniversalIdentifier}',`;
|
||||
} else if (type === 'VIEW' && viewUniversalIdentifier) {
|
||||
typeAndConfig = ` type: 'VIEW',
|
||||
viewUniversalIdentifier: '${viewUniversalIdentifier}',`;
|
||||
} else if (type === 'LINK') {
|
||||
typeAndConfig = ` type: 'LINK',
|
||||
link: 'https://example.com',`;
|
||||
} else if (type === 'FOLDER') {
|
||||
typeAndConfig = ` type: 'FOLDER',`;
|
||||
} else {
|
||||
typeAndConfig = ` type: 'VIEW',
|
||||
// viewUniversalIdentifier: '...',`;
|
||||
}
|
||||
|
||||
return `import { defineNavigationMenuItem } from 'twenty-sdk';
|
||||
|
||||
@@ -28,7 +41,7 @@ export default defineNavigationMenuItem({
|
||||
name: '${kebabCaseName}',
|
||||
icon: 'IconList',
|
||||
position: 0,
|
||||
${linkConfig}
|
||||
${typeAndConfig}
|
||||
});
|
||||
`;
|
||||
};
|
||||
|
||||
@@ -485,6 +485,7 @@ type ObjectStandardOverrides {
|
||||
labelPlural: String
|
||||
description: String
|
||||
icon: String
|
||||
color: String
|
||||
translations: JSON
|
||||
}
|
||||
|
||||
@@ -499,6 +500,7 @@ type Object {
|
||||
icon: String
|
||||
standardOverrides: ObjectStandardOverrides
|
||||
shortcut: String
|
||||
color: String
|
||||
isCustom: Boolean!
|
||||
isRemote: Boolean!
|
||||
isActive: Boolean!
|
||||
@@ -1939,6 +1941,7 @@ type NavigationMenuItem {
|
||||
targetRecordId: UUID
|
||||
targetObjectMetadataId: UUID
|
||||
viewId: UUID
|
||||
type: NavigationMenuItemType!
|
||||
name: String
|
||||
link: String
|
||||
icon: String
|
||||
@@ -1951,6 +1954,14 @@ type NavigationMenuItem {
|
||||
targetRecordIdentifier: RecordIdentifier
|
||||
}
|
||||
|
||||
enum NavigationMenuItemType {
|
||||
VIEW
|
||||
FOLDER
|
||||
LINK
|
||||
OBJECT
|
||||
RECORD
|
||||
}
|
||||
|
||||
type LogicFunctionExecutionResult {
|
||||
"""Execution result in JSON format"""
|
||||
data: JSON
|
||||
@@ -2245,6 +2256,7 @@ type MinimalObjectMetadata {
|
||||
labelSingular: String!
|
||||
labelPlural: String!
|
||||
icon: String
|
||||
color: String
|
||||
isCustom: Boolean!
|
||||
isActive: Boolean!
|
||||
isSystem: Boolean!
|
||||
@@ -3507,6 +3519,7 @@ input CreateObjectInput {
|
||||
description: String
|
||||
icon: String
|
||||
shortcut: String
|
||||
color: String
|
||||
skipNameField: Boolean
|
||||
isRemote: Boolean
|
||||
primaryKeyColumnType: String
|
||||
@@ -3534,6 +3547,7 @@ input UpdateObjectPayload {
|
||||
description: String
|
||||
icon: String
|
||||
shortcut: String
|
||||
color: String
|
||||
isActive: Boolean
|
||||
labelIdentifierFieldMetadataId: UUID
|
||||
imageIdentifierFieldMetadataId: UUID
|
||||
@@ -3791,6 +3805,7 @@ input CreateNavigationMenuItemInput {
|
||||
targetRecordId: UUID
|
||||
targetObjectMetadataId: UUID
|
||||
viewId: UUID
|
||||
type: NavigationMenuItemType!
|
||||
name: String
|
||||
link: String
|
||||
icon: String
|
||||
|
||||
@@ -344,6 +344,7 @@ export interface ObjectStandardOverrides {
|
||||
labelPlural?: Scalars['String']
|
||||
description?: Scalars['String']
|
||||
icon?: Scalars['String']
|
||||
color?: Scalars['String']
|
||||
translations?: Scalars['JSON']
|
||||
__typename: 'ObjectStandardOverrides'
|
||||
}
|
||||
@@ -359,6 +360,7 @@ export interface Object {
|
||||
icon?: Scalars['String']
|
||||
standardOverrides?: ObjectStandardOverrides
|
||||
shortcut?: Scalars['String']
|
||||
color?: Scalars['String']
|
||||
isCustom: Scalars['Boolean']
|
||||
isRemote: Scalars['Boolean']
|
||||
isActive: Scalars['Boolean']
|
||||
@@ -1675,6 +1677,7 @@ export interface NavigationMenuItem {
|
||||
targetRecordId?: Scalars['UUID']
|
||||
targetObjectMetadataId?: Scalars['UUID']
|
||||
viewId?: Scalars['UUID']
|
||||
type: NavigationMenuItemType
|
||||
name?: Scalars['String']
|
||||
link?: Scalars['String']
|
||||
icon?: Scalars['String']
|
||||
@@ -1688,6 +1691,8 @@ export interface NavigationMenuItem {
|
||||
__typename: 'NavigationMenuItem'
|
||||
}
|
||||
|
||||
export type NavigationMenuItemType = 'VIEW' | 'FOLDER' | 'LINK' | 'OBJECT' | 'RECORD'
|
||||
|
||||
export interface LogicFunctionExecutionResult {
|
||||
/** Execution result in JSON format */
|
||||
data?: Scalars['JSON']
|
||||
@@ -1901,6 +1906,7 @@ export interface MinimalObjectMetadata {
|
||||
labelSingular: Scalars['String']
|
||||
labelPlural: Scalars['String']
|
||||
icon?: Scalars['String']
|
||||
color?: Scalars['String']
|
||||
isCustom: Scalars['Boolean']
|
||||
isActive: Scalars['Boolean']
|
||||
isSystem: Scalars['Boolean']
|
||||
@@ -3247,6 +3253,7 @@ export interface ObjectStandardOverridesGenqlSelection{
|
||||
labelPlural?: boolean | number
|
||||
description?: boolean | number
|
||||
icon?: boolean | number
|
||||
color?: boolean | number
|
||||
translations?: boolean | number
|
||||
__typename?: boolean | number
|
||||
__scalar?: boolean | number
|
||||
@@ -3263,6 +3270,7 @@ export interface ObjectGenqlSelection{
|
||||
icon?: boolean | number
|
||||
standardOverrides?: ObjectStandardOverridesGenqlSelection
|
||||
shortcut?: boolean | number
|
||||
color?: boolean | number
|
||||
isCustom?: boolean | number
|
||||
isRemote?: boolean | number
|
||||
isActive?: boolean | number
|
||||
@@ -4666,6 +4674,7 @@ export interface NavigationMenuItemGenqlSelection{
|
||||
targetRecordId?: boolean | number
|
||||
targetObjectMetadataId?: boolean | number
|
||||
viewId?: boolean | number
|
||||
type?: boolean | number
|
||||
name?: boolean | number
|
||||
link?: boolean | number
|
||||
icon?: boolean | number
|
||||
@@ -4900,6 +4909,7 @@ export interface MinimalObjectMetadataGenqlSelection{
|
||||
labelSingular?: boolean | number
|
||||
labelPlural?: boolean | number
|
||||
icon?: boolean | number
|
||||
color?: boolean | number
|
||||
isCustom?: boolean | number
|
||||
isActive?: boolean | number
|
||||
isSystem?: boolean | number
|
||||
@@ -6006,7 +6016,7 @@ export interface CreateOneObjectInput {
|
||||
/** The object to create */
|
||||
object: CreateObjectInput}
|
||||
|
||||
export interface CreateObjectInput {nameSingular: Scalars['String'],namePlural: Scalars['String'],labelSingular: Scalars['String'],labelPlural: Scalars['String'],description?: (Scalars['String'] | null),icon?: (Scalars['String'] | null),shortcut?: (Scalars['String'] | null),skipNameField?: (Scalars['Boolean'] | null),isRemote?: (Scalars['Boolean'] | null),primaryKeyColumnType?: (Scalars['String'] | null),primaryKeyFieldMetadataSettings?: (Scalars['JSON'] | null),isLabelSyncedWithName?: (Scalars['Boolean'] | null)}
|
||||
export interface CreateObjectInput {nameSingular: Scalars['String'],namePlural: Scalars['String'],labelSingular: Scalars['String'],labelPlural: Scalars['String'],description?: (Scalars['String'] | null),icon?: (Scalars['String'] | null),shortcut?: (Scalars['String'] | null),color?: (Scalars['String'] | null),skipNameField?: (Scalars['Boolean'] | null),isRemote?: (Scalars['Boolean'] | null),primaryKeyColumnType?: (Scalars['String'] | null),primaryKeyFieldMetadataSettings?: (Scalars['JSON'] | null),isLabelSyncedWithName?: (Scalars['Boolean'] | null)}
|
||||
|
||||
export interface DeleteOneObjectInput {
|
||||
/** The id of the record to delete. */
|
||||
@@ -6016,7 +6026,7 @@ export interface UpdateOneObjectInput {update: UpdateObjectPayload,
|
||||
/** The id of the object to update */
|
||||
id: Scalars['UUID']}
|
||||
|
||||
export interface UpdateObjectPayload {labelSingular?: (Scalars['String'] | null),labelPlural?: (Scalars['String'] | null),nameSingular?: (Scalars['String'] | null),namePlural?: (Scalars['String'] | null),description?: (Scalars['String'] | null),icon?: (Scalars['String'] | null),shortcut?: (Scalars['String'] | null),isActive?: (Scalars['Boolean'] | null),labelIdentifierFieldMetadataId?: (Scalars['UUID'] | null),imageIdentifierFieldMetadataId?: (Scalars['UUID'] | null),isLabelSyncedWithName?: (Scalars['Boolean'] | null)}
|
||||
export interface UpdateObjectPayload {labelSingular?: (Scalars['String'] | null),labelPlural?: (Scalars['String'] | null),nameSingular?: (Scalars['String'] | null),namePlural?: (Scalars['String'] | null),description?: (Scalars['String'] | null),icon?: (Scalars['String'] | null),shortcut?: (Scalars['String'] | null),color?: (Scalars['String'] | null),isActive?: (Scalars['Boolean'] | null),labelIdentifierFieldMetadataId?: (Scalars['UUID'] | null),imageIdentifierFieldMetadataId?: (Scalars['UUID'] | null),isLabelSyncedWithName?: (Scalars['Boolean'] | null)}
|
||||
|
||||
export interface UpdateViewFieldInput {
|
||||
/** The id of the view field to update */
|
||||
@@ -6108,7 +6118,7 @@ export interface CreateAgentInput {name?: (Scalars['String'] | null),label: Scal
|
||||
|
||||
export interface UpdateAgentInput {id: Scalars['UUID'],name?: (Scalars['String'] | null),label?: (Scalars['String'] | null),icon?: (Scalars['String'] | null),description?: (Scalars['String'] | null),prompt?: (Scalars['String'] | null),modelId?: (Scalars['String'] | null),roleId?: (Scalars['UUID'] | null),responseFormat?: (Scalars['JSON'] | null),modelConfiguration?: (Scalars['JSON'] | null),evaluationInputs?: (Scalars['String'][] | null)}
|
||||
|
||||
export interface CreateNavigationMenuItemInput {userWorkspaceId?: (Scalars['UUID'] | null),targetRecordId?: (Scalars['UUID'] | null),targetObjectMetadataId?: (Scalars['UUID'] | null),viewId?: (Scalars['UUID'] | null),name?: (Scalars['String'] | null),link?: (Scalars['String'] | null),icon?: (Scalars['String'] | null),color?: (Scalars['String'] | null),folderId?: (Scalars['UUID'] | null),position?: (Scalars['Float'] | null)}
|
||||
export interface CreateNavigationMenuItemInput {userWorkspaceId?: (Scalars['UUID'] | null),targetRecordId?: (Scalars['UUID'] | null),targetObjectMetadataId?: (Scalars['UUID'] | null),viewId?: (Scalars['UUID'] | null),type: NavigationMenuItemType,name?: (Scalars['String'] | null),link?: (Scalars['String'] | null),icon?: (Scalars['String'] | null),color?: (Scalars['String'] | null),folderId?: (Scalars['UUID'] | null),position?: (Scalars['Float'] | null)}
|
||||
|
||||
export interface UpdateOneNavigationMenuItemInput {
|
||||
/** The id of the record to update */
|
||||
@@ -8694,6 +8704,14 @@ export const enumRelationType = {
|
||||
MANY_TO_ONE: 'MANY_TO_ONE' as const
|
||||
}
|
||||
|
||||
export const enumNavigationMenuItemType = {
|
||||
VIEW: 'VIEW' as const,
|
||||
FOLDER: 'FOLDER' as const,
|
||||
LINK: 'LINK' as const,
|
||||
OBJECT: 'OBJECT' as const,
|
||||
RECORD: 'RECORD' as const
|
||||
}
|
||||
|
||||
export const enumLogicFunctionExecutionStatus = {
|
||||
IDLE: 'IDLE' as const,
|
||||
SUCCESS: 'SUCCESS' as const,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user