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)
203 lines
8.1 KiB
TypeScript
203 lines
8.1 KiB
TypeScript
import { NavigationDropTargetContext } from '@/navigation-menu-item/contexts/NavigationDropTargetContext';
|
|
import React, { lazy, Suspense, useContext } from 'react';
|
|
import { isDefined } from 'twenty-shared/utils';
|
|
import { AnimatedExpandableContainer } from 'twenty-ui/layout';
|
|
|
|
import { NavigationMenuItemDroppableIds } from '@/navigation-menu-item/constants/NavigationMenuItemDroppableIds';
|
|
import { NavigationMenuItemType } from '@/navigation-menu-item/constants/NavigationMenuItemType';
|
|
import {
|
|
type FlatWorkspaceItem,
|
|
type NavigationMenuItemClickParams,
|
|
} from '@/navigation-menu-item/hooks/useWorkspaceSectionItems';
|
|
import { isNavigationMenuInEditModeState } from '@/navigation-menu-item/states/isNavigationMenuInEditModeState';
|
|
import { getObjectMetadataForNavigationMenuItem } from '@/navigation-menu-item/utils/getObjectMetadataForNavigationMenuItem';
|
|
import { type ProcessedNavigationMenuItem } from '@/navigation-menu-item/utils/sortNavigationMenuItems';
|
|
import type { EditModeProps } from '@/object-metadata/components/EditModeProps';
|
|
import { NavigationDrawerSectionForWorkspaceItemsListReadOnly } from '@/object-metadata/components/NavigationDrawerSectionForWorkspaceItemsListReadOnly';
|
|
import { WorkspaceSectionListEditModeFallback } from '@/object-metadata/components/WorkspaceSectionListEditModeFallback';
|
|
import { objectMetadataItemsState } from '@/object-metadata/states/objectMetadataItemsState';
|
|
import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
|
|
import { getObjectPermissionsForObject } from '@/object-metadata/utils/getObjectPermissionsForObject';
|
|
import { useObjectPermissions } from '@/object-record/hooks/useObjectPermissions';
|
|
import { NavigationDrawerAnimatedCollapseWrapper } from '@/ui/navigation/navigation-drawer/components/NavigationDrawerAnimatedCollapseWrapper';
|
|
import { NavigationDrawerSection } from '@/ui/navigation/navigation-drawer/components/NavigationDrawerSection';
|
|
import { NavigationDrawerSectionTitle } from '@/ui/navigation/navigation-drawer/components/NavigationDrawerSectionTitle';
|
|
import { useNavigationSection } from '@/ui/navigation/navigation-drawer/hooks/useNavigationSection';
|
|
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
|
|
import { viewsSelector } from '@/views/states/selectors/viewsSelector';
|
|
import { styled } from '@linaria/react';
|
|
import { themeCssVariables } from 'twenty-ui/theme-constants';
|
|
|
|
const LazyWorkspaceSectionListDndKit = lazy(() =>
|
|
import(
|
|
'@/object-metadata/components/NavigationDrawerSectionForWorkspaceItemsListDndKit'
|
|
).then((m) => ({ default: m.WorkspaceSectionListDndKit })),
|
|
);
|
|
|
|
const StyledWorkspaceSectionContentGapOffset = styled.div`
|
|
margin-top: calc(-1 * ${themeCssVariables.betweenSiblingsGap});
|
|
`;
|
|
|
|
type NavigationDrawerSectionForWorkspaceItemsProps = {
|
|
sectionTitle: string;
|
|
items: FlatWorkspaceItem[];
|
|
rightIcon?: React.ReactNode;
|
|
selectedNavigationMenuItemId?: string | null;
|
|
onNavigationMenuItemClick?: (params: NavigationMenuItemClickParams) => void;
|
|
onActiveObjectMetadataItemClick?: (
|
|
objectMetadataItem: ObjectMetadataItem,
|
|
navigationMenuItemId: string,
|
|
) => void;
|
|
};
|
|
|
|
export const NavigationDrawerSectionForWorkspaceItems = ({
|
|
sectionTitle,
|
|
items,
|
|
rightIcon,
|
|
selectedNavigationMenuItemId = null,
|
|
onNavigationMenuItemClick,
|
|
onActiveObjectMetadataItemClick,
|
|
}: NavigationDrawerSectionForWorkspaceItemsProps) => {
|
|
const isNavigationMenuInEditMode = useAtomStateValue(
|
|
isNavigationMenuInEditModeState,
|
|
);
|
|
const { toggleNavigationSection, isNavigationSectionOpen } =
|
|
useNavigationSection('Workspace');
|
|
const views = useAtomStateValue(viewsSelector);
|
|
|
|
const { objectPermissionsByObjectMetadataId } = useObjectPermissions();
|
|
const objectMetadataItems = useAtomStateValue(objectMetadataItemsState);
|
|
const { addToNavigationFallbackDestination } = useContext(
|
|
NavigationDropTargetContext,
|
|
);
|
|
|
|
const flatItems = items.filter((item) => !isDefined(item.folderId));
|
|
const isAddToNavigationDropTargetVisible =
|
|
addToNavigationFallbackDestination?.droppableId ===
|
|
NavigationMenuItemDroppableIds.WORKSPACE_ORPHAN_NAVIGATION_MENU_ITEMS;
|
|
const folderChildrenById = items.reduce<
|
|
Map<string, ProcessedNavigationMenuItem[]>
|
|
>((acc, item) => {
|
|
const folderId = item.folderId;
|
|
if (isDefined(folderId)) {
|
|
const children = acc.get(folderId) ?? [];
|
|
children.push(item as ProcessedNavigationMenuItem);
|
|
acc.set(folderId, children);
|
|
}
|
|
return acc;
|
|
}, new Map());
|
|
|
|
const filteredItems = flatItems.filter((item) => {
|
|
const type = item.itemType;
|
|
if (
|
|
type === NavigationMenuItemType.FOLDER ||
|
|
type === NavigationMenuItemType.LINK
|
|
) {
|
|
return true;
|
|
}
|
|
if (
|
|
type === NavigationMenuItemType.OBJECT ||
|
|
type === NavigationMenuItemType.VIEW ||
|
|
type === NavigationMenuItemType.RECORD
|
|
) {
|
|
const objectMetadataItem = getObjectMetadataForNavigationMenuItem(
|
|
item as ProcessedNavigationMenuItem,
|
|
objectMetadataItems,
|
|
views,
|
|
);
|
|
return (
|
|
isDefined(objectMetadataItem) &&
|
|
getObjectPermissionsForObject(
|
|
objectPermissionsByObjectMetadataId,
|
|
objectMetadataItem.id,
|
|
).canReadObjectRecords
|
|
);
|
|
}
|
|
return false;
|
|
});
|
|
|
|
const getEditModeProps = (item: FlatWorkspaceItem): EditModeProps => {
|
|
const itemId = item.id;
|
|
return {
|
|
isSelectedInEditMode: selectedNavigationMenuItemId === itemId,
|
|
onEditModeClick: onNavigationMenuItemClick
|
|
? () => {
|
|
const type = item.itemType;
|
|
const objectMetadataItem =
|
|
type === 'OBJECT' || type === 'VIEW' || type === 'RECORD'
|
|
? getObjectMetadataForNavigationMenuItem(
|
|
item as ProcessedNavigationMenuItem,
|
|
objectMetadataItems,
|
|
views,
|
|
)
|
|
: null;
|
|
onNavigationMenuItemClick({
|
|
item,
|
|
objectMetadataItem: objectMetadataItem ?? undefined,
|
|
});
|
|
}
|
|
: undefined,
|
|
};
|
|
};
|
|
|
|
if (flatItems.length === 0 && !isAddToNavigationDropTargetVisible) {
|
|
return null;
|
|
}
|
|
|
|
return (
|
|
<NavigationDrawerSection>
|
|
<NavigationDrawerAnimatedCollapseWrapper>
|
|
<NavigationDrawerSectionTitle
|
|
label={sectionTitle}
|
|
onClick={() => toggleNavigationSection()}
|
|
rightIcon={rightIcon}
|
|
alwaysShowRightIcon={isNavigationMenuInEditMode}
|
|
isOpen={isNavigationSectionOpen}
|
|
/>
|
|
</NavigationDrawerAnimatedCollapseWrapper>
|
|
<StyledWorkspaceSectionContentGapOffset>
|
|
<AnimatedExpandableContainer
|
|
isExpanded={
|
|
isNavigationSectionOpen || isAddToNavigationDropTargetVisible
|
|
}
|
|
dimension="height"
|
|
mode="fit-content"
|
|
containAnimation
|
|
initial={false}
|
|
>
|
|
{isNavigationMenuInEditMode ? (
|
|
<Suspense
|
|
fallback={
|
|
<WorkspaceSectionListEditModeFallback
|
|
filteredItems={filteredItems}
|
|
folderChildrenById={folderChildrenById}
|
|
onActiveObjectMetadataItemClick={
|
|
onActiveObjectMetadataItemClick
|
|
}
|
|
/>
|
|
}
|
|
>
|
|
<LazyWorkspaceSectionListDndKit
|
|
filteredItems={filteredItems}
|
|
getEditModeProps={getEditModeProps}
|
|
folderChildrenById={folderChildrenById}
|
|
selectedNavigationMenuItemId={selectedNavigationMenuItemId}
|
|
onNavigationMenuItemClick={onNavigationMenuItemClick}
|
|
onActiveObjectMetadataItemClick={
|
|
onActiveObjectMetadataItemClick
|
|
}
|
|
/>
|
|
</Suspense>
|
|
) : (
|
|
<NavigationDrawerSectionForWorkspaceItemsListReadOnly
|
|
filteredItems={filteredItems}
|
|
folderChildrenById={folderChildrenById}
|
|
onActiveObjectMetadataItemClick={onActiveObjectMetadataItemClick}
|
|
/>
|
|
)}
|
|
</AnimatedExpandableContainer>
|
|
</StyledWorkspaceSectionContentGapOffset>
|
|
</NavigationDrawerSection>
|
|
);
|
|
};
|