a121d00ddd
## 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)
200 lines
7.3 KiB
TypeScript
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}
|
|
/>
|
|
);
|
|
};
|