Files
twenty/packages/twenty-front/src/modules/navigation-menu-item/components/WorkspaceNavigationMenuItems.tsx
T
Charles Bochet a121d00ddd 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)
2026-03-16 23:54:56 +01:00

200 lines
7.3 KiB
TypeScript

import { styled } from '@linaria/react';
import { useLingui } from '@lingui/react/macro';
import { isNonEmptyString } from '@sniptt/guards';
import { useNavigate } from 'react-router-dom';
import {
IconColumnInsertRight,
IconLink,
IconPlus,
IconTool,
useIcons,
} from 'twenty-ui/display';
import { LightIconButton } from 'twenty-ui/input';
import { themeCssVariables } from 'twenty-ui/theme-constants';
import { FOLDER_ICON_DEFAULT } from '@/navigation-menu-item/constants/FolderIconDefault';
import { NavigationMenuItemType } from '@/navigation-menu-item/constants/NavigationMenuItemType';
import { useOpenNavigationMenuItemInSidePanel } from '@/navigation-menu-item/hooks/useOpenNavigationMenuItemInSidePanel';
import { useSortedNavigationMenuItems } from '@/navigation-menu-item/hooks/useSortedNavigationMenuItems';
import {
type NavigationMenuItemClickParams,
useWorkspaceSectionItems,
} from '@/navigation-menu-item/hooks/useWorkspaceSectionItems';
import { isNavigationMenuInEditModeState } from '@/navigation-menu-item/states/isNavigationMenuInEditModeState';
import { navigationMenuItemsDraftState } from '@/navigation-menu-item/states/navigationMenuItemsDraftState';
import { openNavigationMenuItemFolderIdsState } from '@/navigation-menu-item/states/openNavigationMenuItemFolderIdsState';
import { selectedNavigationMenuItemInEditModeState } from '@/navigation-menu-item/states/selectedNavigationMenuItemInEditModeState';
import { filterWorkspaceNavigationMenuItems } from '@/navigation-menu-item/utils/filterWorkspaceNavigationMenuItems';
import { preloadWorkspaceDndKit } from '@/navigation/preloadWorkspaceDndKit';
import { NavigationDrawerSectionForWorkspaceItems } from '@/object-metadata/components/NavigationDrawerSectionForWorkspaceItems';
import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { navigationMenuItemsSelector } from '@/navigation-menu-item/states/navigationMenuItemsSelector';
import { useNavigateSidePanel } from '@/side-panel/hooks/useNavigateSidePanel';
import { useAtomState } from '@/ui/utilities/state/jotai/hooks/useAtomState';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { useSetAtomState } from '@/ui/utilities/state/jotai/hooks/useSetAtomState';
import { useStore } from 'jotai';
import { SidePanelPages } from 'twenty-shared/types';
const StyledRightIconsContainer = styled.div`
align-items: center;
display: flex;
gap: ${themeCssVariables.spacing[1]};
`;
export const WorkspaceNavigationMenuItems = () => {
const items = useWorkspaceSectionItems();
const { workspaceNavigationMenuItemsSorted } = useSortedNavigationMenuItems();
const store = useStore();
const enterEditMode = () => {
const currentNavigationMenuItems = store.get(
navigationMenuItemsSelector.atom,
);
const workspaceNavigationMenuItems = filterWorkspaceNavigationMenuItems(
currentNavigationMenuItems,
);
store.set(navigationMenuItemsDraftState.atom, workspaceNavigationMenuItems);
store.set(isNavigationMenuInEditModeState.atom, true);
};
const isNavigationMenuInEditMode = useAtomStateValue(
isNavigationMenuInEditModeState,
);
const [
selectedNavigationMenuItemInEditMode,
setSelectedNavigationMenuItemInEditMode,
] = useAtomState(selectedNavigationMenuItemInEditModeState);
const setOpenNavigationMenuItemFolderIds = useSetAtomState(
openNavigationMenuItemFolderIdsState,
);
const navigate = useNavigate();
const { navigateSidePanel } = useNavigateSidePanel();
const { openNavigationMenuItemInSidePanel } =
useOpenNavigationMenuItemInSidePanel();
const { getIcon } = useIcons();
const { t } = useLingui();
const handleEditClick = (event: React.MouseEvent) => {
event.stopPropagation();
enterEditMode();
};
const openFolderAndNavigateToFirstChild = (
folderId: string,
item: NavigationMenuItemClickParams['item'],
) => {
setOpenNavigationMenuItemFolderIds((current) =>
current.includes(folderId) ? current : [...current, folderId],
);
openNavigationMenuItemInSidePanel({
pageTitle: t`Edit folder`,
pageIcon: getIcon(item.icon ?? item.Icon ?? FOLDER_ICON_DEFAULT),
});
const firstChild = workspaceNavigationMenuItemsSorted.find(
(navItem) =>
navItem.folderId === folderId &&
navItem.itemType !== NavigationMenuItemType.LINK &&
isNonEmptyString(navItem.link),
);
if (firstChild?.link) {
navigate(firstChild.link);
}
};
const openViewOrRecordEditPanelAndNavigate = (
item: NavigationMenuItemClickParams['item'],
objectMetadataItem: ObjectMetadataItem | null | undefined,
) => {
if (objectMetadataItem) {
openNavigationMenuItemInSidePanel({
pageTitle:
item.itemType === NavigationMenuItemType.VIEW ||
item.itemType === NavigationMenuItemType.OBJECT
? item.labelIdentifier
: objectMetadataItem.labelSingular,
pageIcon: getIcon(objectMetadataItem.icon),
});
}
const link = 'link' in item ? item.link : undefined;
if (isNonEmptyString(link)) {
navigate(link);
}
};
const handleNavigationMenuItemClick = (
params: NavigationMenuItemClickParams,
) => {
const { item, objectMetadataItem } = params;
setSelectedNavigationMenuItemInEditMode(item.id);
switch (item.itemType) {
case NavigationMenuItemType.FOLDER:
openFolderAndNavigateToFirstChild(item.id, item);
break;
case NavigationMenuItemType.LINK:
openNavigationMenuItemInSidePanel({
pageTitle: t`Edit link`,
pageIcon: IconLink,
});
break;
default:
openViewOrRecordEditPanelAndNavigate(item, objectMetadataItem);
}
};
const handleActiveObjectMetadataItemClick = (
objectMetadataItem: ObjectMetadataItem,
navigationMenuItemId: string,
) => {
enterEditMode();
setSelectedNavigationMenuItemInEditMode(navigationMenuItemId);
openNavigationMenuItemInSidePanel({
pageTitle: objectMetadataItem.labelSingular,
pageIcon: getIcon(objectMetadataItem.icon),
});
};
const handleAddMenuItem = (event?: React.MouseEvent) => {
event?.stopPropagation();
navigateSidePanel({
page: SidePanelPages.NavigationMenuAddItem,
pageTitle: t`New sidebar item`,
pageIcon: IconColumnInsertRight,
resetNavigationStack: true,
});
};
return (
<NavigationDrawerSectionForWorkspaceItems
sectionTitle={t`Workspace`}
items={items}
rightIcon={
<StyledRightIconsContainer>
{isNavigationMenuInEditMode ? (
<LightIconButton
Icon={IconPlus}
accent="tertiary"
size="small"
onClick={handleAddMenuItem}
/>
) : (
<div onMouseEnter={preloadWorkspaceDndKit}>
<LightIconButton
Icon={IconTool}
accent="tertiary"
size="small"
onClick={handleEditClick}
/>
</div>
)}
</StyledRightIconsContainer>
}
selectedNavigationMenuItemId={selectedNavigationMenuItemInEditMode}
onNavigationMenuItemClick={
isNavigationMenuInEditMode ? handleNavigationMenuItemClick : undefined
}
onActiveObjectMetadataItemClick={handleActiveObjectMetadataItemClick}
/>
);
};