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:
+1
@@ -135,6 +135,7 @@ export const mockPersonFlatObjectMetadata = (
|
||||
): FlatObjectMetadata => ({
|
||||
id: objectMetadataId,
|
||||
icon: 'Icon123',
|
||||
color: null,
|
||||
nameSingular: 'person',
|
||||
namePlural: 'people',
|
||||
labelSingular: 'Person',
|
||||
|
||||
+3
@@ -1,4 +1,5 @@
|
||||
import { fromNavigationMenuItemManifestToUniversalFlatNavigationMenuItem } from 'src/engine/core-modules/application/application-manifest/converters/from-navigation-menu-item-manifest-to-universal-flat-navigation-menu-item.util';
|
||||
import { NavigationMenuItemType } from 'src/engine/metadata-modules/navigation-menu-item/enums/navigation-menu-item-type.enum';
|
||||
|
||||
describe('fromNavigationMenuItemManifestToUniversalFlatNavigationMenuItem', () => {
|
||||
const now = '2026-01-01T00:00:00.000Z';
|
||||
@@ -9,6 +10,7 @@ describe('fromNavigationMenuItemManifestToUniversalFlatNavigationMenuItem', () =
|
||||
fromNavigationMenuItemManifestToUniversalFlatNavigationMenuItem({
|
||||
navigationMenuItemManifest: {
|
||||
universalIdentifier: 'nav-uuid-1',
|
||||
type: NavigationMenuItemType.VIEW,
|
||||
position: 0,
|
||||
},
|
||||
applicationUniversalIdentifier,
|
||||
@@ -34,6 +36,7 @@ describe('fromNavigationMenuItemManifestToUniversalFlatNavigationMenuItem', () =
|
||||
fromNavigationMenuItemManifestToUniversalFlatNavigationMenuItem({
|
||||
navigationMenuItemManifest: {
|
||||
universalIdentifier: 'nav-uuid-2',
|
||||
type: NavigationMenuItemType.VIEW,
|
||||
name: 'Recipes Board',
|
||||
position: 1,
|
||||
viewUniversalIdentifier: 'view-uuid-1',
|
||||
|
||||
+1
@@ -15,6 +15,7 @@ export const fromNavigationMenuItemManifestToUniversalFlatNavigationMenuItem =
|
||||
return {
|
||||
universalIdentifier: navigationMenuItemManifest.universalIdentifier,
|
||||
applicationUniversalIdentifier,
|
||||
type: navigationMenuItemManifest.type,
|
||||
name: navigationMenuItemManifest.name ?? null,
|
||||
icon: navigationMenuItemManifest.icon ?? null,
|
||||
color: navigationMenuItemManifest.color ?? null,
|
||||
|
||||
+1
@@ -18,6 +18,7 @@ export const fromObjectManifestToUniversalFlatObjectMetadata = ({
|
||||
namePlural: objectManifest.namePlural,
|
||||
labelSingular: objectManifest.labelSingular,
|
||||
labelPlural: objectManifest.labelPlural,
|
||||
color: null,
|
||||
description: objectManifest.description ?? null,
|
||||
icon: objectManifest.icon ?? null,
|
||||
standardOverrides: null,
|
||||
|
||||
+1
@@ -6,6 +6,7 @@ import { type FlatObjectMetadata } from 'src/engine/metadata-modules/flat-object
|
||||
const mockObjectMetadata: FlatObjectMetadata = {
|
||||
id: '1',
|
||||
icon: 'Icon123',
|
||||
color: null,
|
||||
nameSingular: 'Object',
|
||||
namePlural: 'Objects',
|
||||
labelSingular: 'Object',
|
||||
|
||||
+2
@@ -9,6 +9,7 @@ export const fromObjectMetadataEntityToObjectMetadataDto = (
|
||||
updatedAt,
|
||||
description,
|
||||
icon,
|
||||
color,
|
||||
standardOverrides,
|
||||
shortcut,
|
||||
duplicateCriteria,
|
||||
@@ -22,6 +23,7 @@ export const fromObjectMetadataEntityToObjectMetadataDto = (
|
||||
updatedAt: new Date(updatedAt),
|
||||
description: description ?? undefined,
|
||||
icon: icon ?? undefined,
|
||||
color: color ?? undefined,
|
||||
standardOverrides: standardOverrides ?? undefined,
|
||||
shortcut: shortcut ?? undefined,
|
||||
duplicateCriteria: duplicateCriteria ?? undefined,
|
||||
|
||||
+2
@@ -105,6 +105,7 @@ exports[`ALL_UNIVERSAL_FLAT_ENTITY_PROPERTIES_TO_COMPARE_AND_STRINGIFY should ma
|
||||
},
|
||||
"navigationMenuItem": {
|
||||
"propertiesToCompare": [
|
||||
"type",
|
||||
"position",
|
||||
"folderUniversalIdentifier",
|
||||
"name",
|
||||
@@ -116,6 +117,7 @@ exports[`ALL_UNIVERSAL_FLAT_ENTITY_PROPERTIES_TO_COMPARE_AND_STRINGIFY should ma
|
||||
},
|
||||
"objectMetadata": {
|
||||
"propertiesToCompare": [
|
||||
"color",
|
||||
"description",
|
||||
"icon",
|
||||
"isActive",
|
||||
|
||||
+10
@@ -149,6 +149,11 @@ export const ALL_ENTITY_PROPERTIES_CONFIGURATION_BY_METADATA_NAME = {
|
||||
toStringify: false,
|
||||
universalProperty: undefined,
|
||||
},
|
||||
color: {
|
||||
toCompare: true,
|
||||
toStringify: false,
|
||||
universalProperty: undefined,
|
||||
},
|
||||
description: {
|
||||
toCompare: true,
|
||||
toStringify: false,
|
||||
@@ -1070,6 +1075,11 @@ export const ALL_ENTITY_PROPERTIES_CONFIGURATION_BY_METADATA_NAME = {
|
||||
},
|
||||
},
|
||||
navigationMenuItem: {
|
||||
type: {
|
||||
toCompare: true,
|
||||
toStringify: false,
|
||||
universalProperty: undefined,
|
||||
},
|
||||
position: {
|
||||
toCompare: true,
|
||||
toStringify: false,
|
||||
|
||||
+1
@@ -38,6 +38,7 @@ type Assertions = [
|
||||
Equal<
|
||||
keyof FlatEntityUpdate<'objectMetadata'>,
|
||||
| 'icon'
|
||||
| 'color'
|
||||
| 'description'
|
||||
| 'isActive'
|
||||
| 'standardOverrides'
|
||||
|
||||
+1
@@ -69,6 +69,7 @@ export const fromCreateNavigationMenuItemInputToFlatNavigationMenuItemToCreate =
|
||||
|
||||
return {
|
||||
id,
|
||||
type: createNavigationMenuItemInput.type,
|
||||
universalIdentifier: id,
|
||||
userWorkspaceId: createNavigationMenuItemInput.userWorkspaceId ?? null,
|
||||
targetRecordId: createNavigationMenuItemInput.targetRecordId ?? null,
|
||||
|
||||
+1
@@ -5,6 +5,7 @@ export const fromFlatNavigationMenuItemToNavigationMenuItemDto = (
|
||||
flatNavigationMenuItem: FlatNavigationMenuItem,
|
||||
): NavigationMenuItemDTO => ({
|
||||
id: flatNavigationMenuItem.id,
|
||||
type: flatNavigationMenuItem.type,
|
||||
userWorkspaceId: flatNavigationMenuItem.userWorkspaceId ?? undefined,
|
||||
targetRecordId: flatNavigationMenuItem.targetRecordId ?? undefined,
|
||||
targetObjectMetadataId:
|
||||
|
||||
+1
@@ -75,6 +75,7 @@ export const fromNavigationMenuItemEntityToFlatNavigationMenuItem = ({
|
||||
|
||||
return {
|
||||
id: navigationMenuItemEntity.id,
|
||||
type: navigationMenuItemEntity.type,
|
||||
userWorkspaceId: navigationMenuItemEntity.userWorkspaceId,
|
||||
targetRecordId: navigationMenuItemEntity.targetRecordId,
|
||||
targetObjectMetadataId: navigationMenuItemEntity.targetObjectMetadataId,
|
||||
|
||||
+1
@@ -22,6 +22,7 @@ export const getFlatObjectMetadataMock = (
|
||||
fieldIds: [],
|
||||
description: 'default flat object metadata description',
|
||||
icon: 'icon',
|
||||
color: null,
|
||||
id: faker.string.uuid(),
|
||||
imageIdentifierFieldMetadataId,
|
||||
isActive: true,
|
||||
|
||||
+9
-1
@@ -2,6 +2,7 @@ import { type MetadataEntityPropertyName } from 'src/engine/metadata-modules/fla
|
||||
|
||||
export const FLAT_OBJECT_METADATA_EDITABLE_PROPERTIES = {
|
||||
custom: [
|
||||
'color',
|
||||
'description',
|
||||
'icon',
|
||||
'isActive',
|
||||
@@ -12,7 +13,14 @@ export const FLAT_OBJECT_METADATA_EDITABLE_PROPERTIES = {
|
||||
'nameSingular',
|
||||
'labelIdentifierFieldMetadataId',
|
||||
],
|
||||
standard: ['description', 'icon', 'isActive', 'labelPlural', 'labelSingular'],
|
||||
standard: [
|
||||
'color',
|
||||
'description',
|
||||
'icon',
|
||||
'isActive',
|
||||
'labelPlural',
|
||||
'labelSingular',
|
||||
],
|
||||
} as const satisfies Record<
|
||||
'standard' | 'custom',
|
||||
MetadataEntityPropertyName<'objectMetadata'>[]
|
||||
|
||||
+1
@@ -74,6 +74,7 @@ export const fromCreateObjectInputToFlatObjectMetadataAndFlatFieldMetadatasToCre
|
||||
createdAt,
|
||||
updatedAt: createdAt,
|
||||
duplicateCriteria: null,
|
||||
color: createObjectInput.color ?? null,
|
||||
description: createObjectInput.description ?? null,
|
||||
icon: createObjectInput.icon ?? null,
|
||||
isActive: true,
|
||||
|
||||
+2
@@ -7,6 +7,7 @@ export const fromFlatObjectMetadataToObjectMetadataDto = (
|
||||
const {
|
||||
createdAt,
|
||||
updatedAt,
|
||||
color,
|
||||
description,
|
||||
icon,
|
||||
standardOverrides,
|
||||
@@ -50,6 +51,7 @@ export const fromFlatObjectMetadataToObjectMetadataDto = (
|
||||
labelIdentifierFieldMetadataId,
|
||||
createdAt: new Date(createdAt),
|
||||
updatedAt: new Date(updatedAt),
|
||||
color: color ?? undefined,
|
||||
description: description ?? undefined,
|
||||
icon: icon ?? undefined,
|
||||
standardOverrides: standardOverrides ?? undefined,
|
||||
|
||||
+3
@@ -24,6 +24,9 @@ export class MinimalObjectMetadataDTO {
|
||||
@Field({ nullable: true })
|
||||
icon?: string;
|
||||
|
||||
@Field({ nullable: true })
|
||||
color?: string;
|
||||
|
||||
@Field()
|
||||
isCustom: boolean;
|
||||
|
||||
|
||||
+12
-1
@@ -1,8 +1,15 @@
|
||||
import { Field, InputType } from '@nestjs/graphql';
|
||||
|
||||
import { IsNumber, IsOptional, IsString, IsUUID } from 'class-validator';
|
||||
import {
|
||||
IsEnum,
|
||||
IsNumber,
|
||||
IsOptional,
|
||||
IsString,
|
||||
IsUUID,
|
||||
} from 'class-validator';
|
||||
|
||||
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
|
||||
import { NavigationMenuItemType } from 'src/engine/metadata-modules/navigation-menu-item/enums/navigation-menu-item-type.enum';
|
||||
|
||||
@InputType()
|
||||
export class CreateNavigationMenuItemInput {
|
||||
@@ -26,6 +33,10 @@ export class CreateNavigationMenuItemInput {
|
||||
@Field(() => UUIDScalarType, { nullable: true })
|
||||
viewId?: string | null;
|
||||
|
||||
@IsEnum(NavigationMenuItemType)
|
||||
@Field(() => NavigationMenuItemType)
|
||||
type: NavigationMenuItemType;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@Field(() => String, { nullable: true })
|
||||
|
||||
+6
@@ -10,6 +10,8 @@ import {
|
||||
|
||||
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
|
||||
|
||||
import { NavigationMenuItemType } from 'src/engine/metadata-modules/navigation-menu-item/enums/navigation-menu-item-type.enum';
|
||||
|
||||
import { RecordIdentifierDTO } from './record-identifier.dto';
|
||||
|
||||
@ObjectType('NavigationMenuItem')
|
||||
@@ -39,6 +41,10 @@ export class NavigationMenuItemDTO {
|
||||
@Field(() => UUIDScalarType, { nullable: true })
|
||||
viewId?: string | null;
|
||||
|
||||
@IsNotEmpty()
|
||||
@Field(() => NavigationMenuItemType)
|
||||
type: NavigationMenuItemType;
|
||||
|
||||
@IsOptional()
|
||||
@Field(() => String, { nullable: true })
|
||||
name?: string | null;
|
||||
|
||||
+15
-2
@@ -14,6 +14,7 @@ import {
|
||||
import { UserWorkspaceEntity } from 'src/engine/core-modules/user-workspace/user-workspace.entity';
|
||||
import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity';
|
||||
import { ViewEntity } from 'src/engine/metadata-modules/view/entities/view.entity';
|
||||
import { NavigationMenuItemType } from 'src/engine/metadata-modules/navigation-menu-item/enums/navigation-menu-item-type.enum';
|
||||
import { SyncableEntity } from 'src/engine/workspace-manager/types/syncable-entity.interface';
|
||||
|
||||
@Entity({ name: 'navigationMenuItem', schema: 'core' })
|
||||
@@ -35,8 +36,12 @@ import { SyncableEntity } from 'src/engine/workspace-manager/types/syncable-enti
|
||||
'workspaceId',
|
||||
])
|
||||
@Check(
|
||||
'CHK_navigation_menu_item_target_fields',
|
||||
'("targetRecordId" IS NULL AND "targetObjectMetadataId" IS NULL) OR ("targetRecordId" IS NOT NULL AND "targetObjectMetadataId" IS NOT NULL)',
|
||||
'CHK_navigation_menu_item_type_fields',
|
||||
`("type" = 'FOLDER')
|
||||
OR ("type" = 'OBJECT' AND "targetObjectMetadataId" IS NOT NULL)
|
||||
OR ("type" = 'VIEW')
|
||||
OR ("type" = 'RECORD' AND "targetRecordId" IS NOT NULL AND "targetObjectMetadataId" IS NOT NULL)
|
||||
OR ("type" = 'LINK' AND "link" IS NOT NULL)`,
|
||||
)
|
||||
export class NavigationMenuItemEntity
|
||||
extends SyncableEntity
|
||||
@@ -78,6 +83,14 @@ export class NavigationMenuItemEntity
|
||||
@JoinColumn({ name: 'targetObjectMetadataId' })
|
||||
targetObjectMetadata: Relation<ObjectMetadataEntity> | null;
|
||||
|
||||
@Column({
|
||||
nullable: false,
|
||||
type: 'enum',
|
||||
enum: NavigationMenuItemType,
|
||||
default: NavigationMenuItemType.VIEW,
|
||||
})
|
||||
type: NavigationMenuItemType;
|
||||
|
||||
@Column({ nullable: true, type: 'text' })
|
||||
name: string | null;
|
||||
|
||||
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
import { registerEnumType } from '@nestjs/graphql';
|
||||
|
||||
import { NavigationMenuItemType } from 'twenty-shared/types';
|
||||
|
||||
registerEnumType(NavigationMenuItemType, {
|
||||
name: 'NavigationMenuItemType',
|
||||
});
|
||||
|
||||
export { NavigationMenuItemType };
|
||||
+5
@@ -55,6 +55,11 @@ export class CreateObjectInput {
|
||||
@Field({ nullable: true })
|
||||
shortcut?: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
@Field({ nullable: true })
|
||||
color?: string;
|
||||
|
||||
@HideField()
|
||||
dataSourceId: string;
|
||||
|
||||
|
||||
+3
@@ -59,6 +59,9 @@ export class ObjectMetadataDTO {
|
||||
@Field({ nullable: true })
|
||||
shortcut?: string;
|
||||
|
||||
@Field({ nullable: true })
|
||||
color?: string;
|
||||
|
||||
@FilterableField()
|
||||
isCustom: boolean;
|
||||
|
||||
|
||||
+5
@@ -26,6 +26,11 @@ export class ObjectStandardOverridesDTO {
|
||||
@Field(() => String, { nullable: true })
|
||||
icon?: string | null;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
@Field(() => String, { nullable: true })
|
||||
color?: string | null;
|
||||
|
||||
@IsJSON()
|
||||
@IsOptional()
|
||||
@Field(() => GraphQLJSON, {
|
||||
|
||||
+5
@@ -52,6 +52,11 @@ export class UpdateObjectPayload {
|
||||
@Field({ nullable: true })
|
||||
shortcut?: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
@Field({ nullable: true })
|
||||
color?: string;
|
||||
|
||||
@IsBoolean()
|
||||
@IsOptional()
|
||||
@Field({ nullable: true })
|
||||
|
||||
+3
@@ -60,6 +60,9 @@ export class ObjectMetadataEntity
|
||||
@Column({ nullable: true, type: 'varchar' })
|
||||
icon: string | null;
|
||||
|
||||
@Column({ nullable: true, type: 'text' })
|
||||
color: string | null;
|
||||
|
||||
@Column({ type: 'jsonb', nullable: true })
|
||||
standardOverrides: JsonbProperty<ObjectStandardOverridesDTO> | null;
|
||||
|
||||
|
||||
+10
-7
@@ -24,6 +24,7 @@ import { findFlatEntityByUniversalIdentifier } from 'src/engine/metadata-modules
|
||||
import { findManyFlatEntityByIdInFlatEntityMapsOrThrow } from 'src/engine/metadata-modules/flat-entity/utils/find-many-flat-entity-by-id-in-flat-entity-maps-or-throw.util';
|
||||
import { FlatIndexMetadata } from 'src/engine/metadata-modules/flat-index-metadata/types/flat-index-metadata.type';
|
||||
import { FlatNavigationMenuItem } from 'src/engine/metadata-modules/flat-navigation-menu-item/types/flat-navigation-menu-item.type';
|
||||
import { NavigationMenuItemType } from 'src/engine/metadata-modules/navigation-menu-item/enums/navigation-menu-item-type.enum';
|
||||
import { FlatObjectMetadata } from 'src/engine/metadata-modules/flat-object-metadata/types/flat-object-metadata.type';
|
||||
import { fromCreateObjectInputToFlatObjectMetadataAndFlatFieldMetadatasToCreate } from 'src/engine/metadata-modules/flat-object-metadata/utils/from-create-object-input-to-flat-object-metadata-and-flat-field-metadatas-to-create.util';
|
||||
import { fromDeleteObjectInputToFlatFieldMetadatasToDelete } from 'src/engine/metadata-modules/flat-object-metadata/utils/from-delete-object-input-to-flat-field-metadatas-to-delete.util';
|
||||
@@ -469,7 +470,7 @@ export class ObjectMetadataService extends TypeOrmQueryService<ObjectMetadataEnt
|
||||
|
||||
const flatNavigationMenuItemToCreate =
|
||||
await this.computeFlatNavigationMenuItemToCreate({
|
||||
view: flatDefaultViewToCreate,
|
||||
objectMetadata: flatObjectMetadataToCreate,
|
||||
workspaceId,
|
||||
workspaceCustomApplicationId: workspaceCustomFlatApplication.id,
|
||||
workspaceCustomApplicationUniversalIdentifier:
|
||||
@@ -683,12 +684,12 @@ export class ObjectMetadataService extends TypeOrmQueryService<ObjectMetadataEnt
|
||||
}
|
||||
|
||||
private async computeFlatNavigationMenuItemToCreate({
|
||||
view,
|
||||
objectMetadata,
|
||||
workspaceId,
|
||||
workspaceCustomApplicationId,
|
||||
workspaceCustomApplicationUniversalIdentifier,
|
||||
}: {
|
||||
view: UniversalFlatView & { id: string };
|
||||
objectMetadata: { id: string; universalIdentifier: string };
|
||||
workspaceId: string;
|
||||
workspaceCustomApplicationId: string;
|
||||
workspaceCustomApplicationUniversalIdentifier: string;
|
||||
@@ -714,13 +715,15 @@ export class ObjectMetadataService extends TypeOrmQueryService<ObjectMetadataEnt
|
||||
|
||||
return {
|
||||
id: newId,
|
||||
type: NavigationMenuItemType.OBJECT,
|
||||
universalIdentifier: newId,
|
||||
userWorkspaceId: null,
|
||||
targetRecordId: null,
|
||||
targetObjectMetadataId: null,
|
||||
targetObjectMetadataUniversalIdentifier: null,
|
||||
viewId: view.id,
|
||||
viewUniversalIdentifier: view.universalIdentifier,
|
||||
targetObjectMetadataId: objectMetadata.id,
|
||||
targetObjectMetadataUniversalIdentifier:
|
||||
objectMetadata.universalIdentifier,
|
||||
viewId: null,
|
||||
viewUniversalIdentifier: null,
|
||||
folderId: null,
|
||||
folderUniversalIdentifier: null,
|
||||
name: null,
|
||||
|
||||
+1
@@ -100,6 +100,7 @@ describe('WorkspaceEntityManager', () => {
|
||||
labelPlural: 'Test Entities',
|
||||
workspaceId: 'test-workspace-id',
|
||||
icon: 'test-icon',
|
||||
color: null,
|
||||
isCustom: false,
|
||||
isRemote: false,
|
||||
isAuditLogged: false,
|
||||
|
||||
+1
@@ -15,6 +15,7 @@ describe('getColumnNameToFieldMetadataIdMap', () => {
|
||||
labelSingular: 'Test',
|
||||
labelPlural: 'Tests',
|
||||
icon: 'IconTest',
|
||||
color: null,
|
||||
targetTableName: 'test',
|
||||
isCustom: false,
|
||||
isRemote: false,
|
||||
|
||||
+1
@@ -15,6 +15,7 @@ describe('getFieldMetadataIdToColumnNamesMap', () => {
|
||||
labelSingular: 'Test',
|
||||
labelPlural: 'Tests',
|
||||
icon: 'IconTest',
|
||||
color: null,
|
||||
targetTableName: 'test',
|
||||
isCustom: false,
|
||||
isRemote: false,
|
||||
|
||||
+1
@@ -15,6 +15,7 @@ describe('isRecordMatchingRLSRowLevelPermissionPredicate', () => {
|
||||
labelSingular: 'Test',
|
||||
labelPlural: 'Tests',
|
||||
icon: 'IconTest',
|
||||
color: null,
|
||||
targetTableName: 'test',
|
||||
isCustom: false,
|
||||
isRemote: false,
|
||||
|
||||
+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,
|
||||
|
||||
+1
@@ -25,6 +25,7 @@ type Assertions = [
|
||||
Equal<
|
||||
keyof UniversalFlatEntityUpdate<'objectMetadata'>,
|
||||
| 'icon'
|
||||
| 'color'
|
||||
| 'description'
|
||||
| 'isActive'
|
||||
| 'standardOverrides'
|
||||
|
||||
+56
-64
@@ -3,6 +3,7 @@ import { Injectable } from '@nestjs/common';
|
||||
import { msg, t } from '@lingui/core/macro';
|
||||
import { isNonEmptyString } from '@sniptt/guards';
|
||||
import { ALL_METADATA_NAME } from 'twenty-shared/metadata';
|
||||
import { NavigationMenuItemType } from 'twenty-shared/types';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
import { findFlatEntityByUniversalIdentifier } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-universal-identifier.util';
|
||||
@@ -22,88 +23,79 @@ const NAVIGATION_MENU_ITEM_MAX_DEPTH = 2;
|
||||
@Injectable()
|
||||
export class FlatNavigationMenuItemValidatorService {
|
||||
private validateNavigationMenuItemType({
|
||||
type,
|
||||
hasTargetRecordId,
|
||||
hasTargetObjectMetadataId,
|
||||
hasViewId,
|
||||
hasLink,
|
||||
name,
|
||||
isUpdate = false,
|
||||
}: {
|
||||
type: NavigationMenuItemType | null | undefined;
|
||||
hasTargetRecordId: boolean;
|
||||
hasTargetObjectMetadataId: boolean;
|
||||
hasViewId: boolean;
|
||||
hasLink: boolean;
|
||||
name: string | null | undefined;
|
||||
isUpdate?: boolean;
|
||||
}): FlatEntityValidationError<NavigationMenuItemExceptionCode>[] {
|
||||
const errors: FlatEntityValidationError<NavigationMenuItemExceptionCode>[] =
|
||||
[];
|
||||
|
||||
if (hasTargetObjectMetadataId && !hasTargetRecordId) {
|
||||
if (!isDefined(type)) {
|
||||
errors.push({
|
||||
code: NavigationMenuItemExceptionCode.INVALID_NAVIGATION_MENU_ITEM_INPUT,
|
||||
message: t`targetRecordId is required when targetObjectMetadataId is provided`,
|
||||
userFriendlyMessage: msg`targetRecordId is required when targetObjectMetadataId is provided`,
|
||||
message: t`Navigation menu item type is required`,
|
||||
userFriendlyMessage: msg`Navigation menu item type is required`,
|
||||
});
|
||||
|
||||
return errors;
|
||||
}
|
||||
|
||||
if (hasTargetRecordId && !hasTargetObjectMetadataId) {
|
||||
errors.push({
|
||||
code: NavigationMenuItemExceptionCode.INVALID_NAVIGATION_MENU_ITEM_INPUT,
|
||||
message: t`targetObjectMetadataId is required when targetRecordId is provided`,
|
||||
userFriendlyMessage: msg`targetObjectMetadataId is required when targetRecordId is provided`,
|
||||
});
|
||||
}
|
||||
|
||||
if (hasViewId && (hasTargetRecordId || hasTargetObjectMetadataId)) {
|
||||
errors.push({
|
||||
code: NavigationMenuItemExceptionCode.INVALID_NAVIGATION_MENU_ITEM_INPUT,
|
||||
message: t`viewId cannot be provided together with targetRecordId or targetObjectMetadataId`,
|
||||
userFriendlyMessage: msg`viewId cannot be provided together with targetRecordId or targetObjectMetadataId`,
|
||||
});
|
||||
}
|
||||
|
||||
const isFolder =
|
||||
!hasTargetRecordId &&
|
||||
!hasTargetObjectMetadataId &&
|
||||
!hasViewId &&
|
||||
!hasLink;
|
||||
const isViewLink = hasViewId;
|
||||
const isRecordLink = hasTargetRecordId && hasTargetObjectMetadataId;
|
||||
const isExternalLink =
|
||||
!hasTargetRecordId && !hasTargetObjectMetadataId && !hasViewId && hasLink;
|
||||
const typeCount =
|
||||
(isFolder ? 1 : 0) +
|
||||
(isViewLink ? 1 : 0) +
|
||||
(isRecordLink ? 1 : 0) +
|
||||
(isExternalLink ? 1 : 0);
|
||||
|
||||
if (typeCount === 0) {
|
||||
errors.push({
|
||||
code: NavigationMenuItemExceptionCode.INVALID_NAVIGATION_MENU_ITEM_INPUT,
|
||||
message: t`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: msg`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)`,
|
||||
});
|
||||
}
|
||||
|
||||
if (typeCount > 1) {
|
||||
errors.push({
|
||||
code: NavigationMenuItemExceptionCode.INVALID_NAVIGATION_MENU_ITEM_INPUT,
|
||||
message: t`Navigation menu item cannot be multiple types simultaneously`,
|
||||
userFriendlyMessage: msg`Navigation menu item cannot be multiple types simultaneously`,
|
||||
});
|
||||
}
|
||||
|
||||
if (isFolder && (!isDefined(name) || name.trim() === '')) {
|
||||
errors.push({
|
||||
code: NavigationMenuItemExceptionCode.INVALID_NAVIGATION_MENU_ITEM_INPUT,
|
||||
message: isUpdate
|
||||
? t`Folder name is required and cannot be empty`
|
||||
: t`Folder name is required when creating a folder`,
|
||||
userFriendlyMessage: isUpdate
|
||||
? msg`Folder name is required and cannot be empty`
|
||||
: msg`Folder name is required when creating a folder`,
|
||||
});
|
||||
switch (type) {
|
||||
case NavigationMenuItemType.FOLDER:
|
||||
if (!isDefined(name) || name.trim() === '') {
|
||||
errors.push({
|
||||
code: NavigationMenuItemExceptionCode.INVALID_NAVIGATION_MENU_ITEM_INPUT,
|
||||
message: t`Folder name is required`,
|
||||
userFriendlyMessage: msg`Folder name is required`,
|
||||
});
|
||||
}
|
||||
break;
|
||||
case NavigationMenuItemType.OBJECT:
|
||||
if (!hasTargetObjectMetadataId) {
|
||||
errors.push({
|
||||
code: NavigationMenuItemExceptionCode.INVALID_NAVIGATION_MENU_ITEM_INPUT,
|
||||
message: t`targetObjectMetadataId is required for OBJECT type`,
|
||||
userFriendlyMessage: msg`targetObjectMetadataId is required for OBJECT type`,
|
||||
});
|
||||
}
|
||||
break;
|
||||
case NavigationMenuItemType.VIEW:
|
||||
if (!hasViewId) {
|
||||
errors.push({
|
||||
code: NavigationMenuItemExceptionCode.INVALID_NAVIGATION_MENU_ITEM_INPUT,
|
||||
message: t`viewId is required for VIEW type`,
|
||||
userFriendlyMessage: msg`viewId is required for VIEW type`,
|
||||
});
|
||||
}
|
||||
break;
|
||||
case NavigationMenuItemType.RECORD:
|
||||
if (!hasTargetRecordId || !hasTargetObjectMetadataId) {
|
||||
errors.push({
|
||||
code: NavigationMenuItemExceptionCode.INVALID_NAVIGATION_MENU_ITEM_INPUT,
|
||||
message: t`targetRecordId and targetObjectMetadataId are required for RECORD type`,
|
||||
userFriendlyMessage: msg`targetRecordId and targetObjectMetadataId are required for RECORD type`,
|
||||
});
|
||||
}
|
||||
break;
|
||||
case NavigationMenuItemType.LINK:
|
||||
if (!hasLink) {
|
||||
errors.push({
|
||||
code: NavigationMenuItemExceptionCode.INVALID_NAVIGATION_MENU_ITEM_INPUT,
|
||||
message: t`link is required for LINK type`,
|
||||
userFriendlyMessage: msg`link is required for LINK type`,
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
return errors;
|
||||
@@ -187,6 +179,7 @@ export class FlatNavigationMenuItemValidatorService {
|
||||
}
|
||||
|
||||
const typeValidationErrors = this.validateNavigationMenuItemType({
|
||||
type: flatNavigationMenuItem.type,
|
||||
hasTargetRecordId: isDefined(flatNavigationMenuItem.targetRecordId),
|
||||
hasTargetObjectMetadataId: isDefined(
|
||||
flatNavigationMenuItem.targetObjectMetadataUniversalIdentifier,
|
||||
@@ -196,7 +189,6 @@ export class FlatNavigationMenuItemValidatorService {
|
||||
isDefined(flatNavigationMenuItem.link) &&
|
||||
isNonEmptyString(flatNavigationMenuItem.link),
|
||||
name: flatNavigationMenuItem.name,
|
||||
isUpdate: false,
|
||||
});
|
||||
|
||||
validationResult.errors.push(...typeValidationErrors);
|
||||
@@ -322,6 +314,7 @@ export class FlatNavigationMenuItemValidatorService {
|
||||
const nameUpdate = flatEntityUpdate.name;
|
||||
|
||||
const typeValidationErrors = this.validateNavigationMenuItemType({
|
||||
type: toFlatNavigationMenuItem.type,
|
||||
hasTargetRecordId: isDefined(toFlatNavigationMenuItem.targetRecordId),
|
||||
hasTargetObjectMetadataId: isDefined(
|
||||
toFlatNavigationMenuItem.targetObjectMetadataUniversalIdentifier,
|
||||
@@ -329,7 +322,6 @@ export class FlatNavigationMenuItemValidatorService {
|
||||
hasViewId: isDefined(toFlatNavigationMenuItem.viewUniversalIdentifier),
|
||||
hasLink: isNonEmptyString((toFlatNavigationMenuItem.link ?? '').trim()),
|
||||
name: isDefined(nameUpdate) ? nameUpdate : toFlatNavigationMenuItem.name,
|
||||
isUpdate: true,
|
||||
});
|
||||
|
||||
validationResult.errors.push(...typeValidationErrors);
|
||||
|
||||
+16
-5
@@ -61,11 +61,22 @@ export class FlatObjectMetadataValidatorService {
|
||||
};
|
||||
|
||||
if (!buildOptions.isSystemBuild && existingFlatObjectMetadata.isSystem) {
|
||||
validationResult.errors.push({
|
||||
code: ObjectMetadataExceptionCode.INVALID_OBJECT_INPUT,
|
||||
message: t`System objects cannot be updated`,
|
||||
userFriendlyMessage: msg`System objects cannot be updated`,
|
||||
});
|
||||
const allowedOverrideKeys = new Set([
|
||||
'standardOverrides',
|
||||
'isActive',
|
||||
'color',
|
||||
]);
|
||||
const disallowedProperties = Object.keys(flatEntityUpdate).filter(
|
||||
(property) => !allowedOverrideKeys.has(property),
|
||||
);
|
||||
|
||||
if (disallowedProperties.length > 0) {
|
||||
validationResult.errors.push({
|
||||
code: ObjectMetadataExceptionCode.INVALID_OBJECT_INPUT,
|
||||
message: t`System objects cannot be updated directly. Use standardOverrides for cosmetic changes.`,
|
||||
userFriendlyMessage: msg`System objects cannot be updated`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
validationResult.errors.push(
|
||||
|
||||
Reference in New Issue
Block a user