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:
Charles Bochet
2026-03-16 23:54:56 +01:00
committed by GitHub
parent 087ee19807
commit a121d00ddd
123 changed files with 1176 additions and 742 deletions
@@ -1,15 +1,13 @@
import { styled } from '@linaria/react';
import { isNonEmptyString } from '@sniptt/guards';
import { useContext, type ReactNode } from 'react';
import { isDefined } from 'twenty-shared/utils';
import { IconGripVertical, type IconComponent } from 'twenty-ui/display';
import { ThemeContext, themeCssVariables } from 'twenty-ui/theme-constants';
import { NavigationMenuItemStyleIcon } from '@/navigation-menu-item/components/NavigationMenuItemStyleIcon';
import { DEFAULT_NAVIGATION_MENU_ITEM_COLOR_FOLDER } from '@/navigation-menu-item/constants/NavigationMenuItemDefaultColorFolder';
import { DEFAULT_NAVIGATION_MENU_ITEM_COLOR_LINK } from '@/navigation-menu-item/constants/NavigationMenuItemDefaultColorLink';
import { NavigationMenuItemType } from '@/navigation-menu-item/constants/NavigationMenuItemType';
import type { AddToNavigationDragPayload } from '@/navigation-menu-item/types/add-to-navigation-drag-payload';
import { getEffectiveNavigationMenuItemColor } from '@/navigation-menu-item/utils/getEffectiveNavigationMenuItemColor';
const StyledIconSlot = styled.div<{
$hasFixedSize: boolean;
@@ -83,14 +81,14 @@ export const AddToNavigationDragHandle = ({
disableDrag = false,
}: AddToNavigationDragHandleProps) => {
const { theme } = useContext(ThemeContext);
const effectiveColor =
payload.type === 'object' && isNonEmptyString(payload.iconColor)
const objectColor =
payload.type === NavigationMenuItemType.OBJECT
? payload.iconColor
: payload.type === 'folder'
? DEFAULT_NAVIGATION_MENU_ITEM_COLOR_FOLDER
: payload.type === 'link'
? DEFAULT_NAVIGATION_MENU_ITEM_COLOR_LINK
: undefined;
: undefined;
const effectiveColor = getEffectiveNavigationMenuItemColor(
{ itemType: payload.type as NavigationMenuItemType },
objectColor,
);
const hasBackgroundColor =
payload.type !== NavigationMenuItemType.RECORD &&
isDefined(effectiveColor) &&
@@ -8,8 +8,8 @@ import { StyledNavigationMenuItemIconContainer } from '@/navigation-menu-item/co
import { ObjectIconWithViewOverlay } from '@/navigation-menu-item/components/ObjectIconWithViewOverlay';
import { NavigationMenuItemType } from '@/navigation-menu-item/constants/NavigationMenuItemType';
import { useObjectNavItemColor } from '@/navigation-menu-item/hooks/useObjectNavItemColor';
import { getNavigationMenuItemIconStyleFromColor } from '@/navigation-menu-item/utils/getNavigationMenuItemIconStyleFromColor';
import { getEffectiveNavigationMenuItemColor } from '@/navigation-menu-item/utils/getEffectiveNavigationMenuItemColor';
import { getNavigationMenuItemIconStyleFromColor } from '@/navigation-menu-item/utils/getNavigationMenuItemIconStyleFromColor';
import { type ProcessedNavigationMenuItem } from '@/navigation-menu-item/utils/sortNavigationMenuItems';
import { useGetStandardObjectIcon } from '@/object-metadata/hooks/useGetStandardObjectIcon';
import { objectMetadataItemsState } from '@/object-metadata/states/objectMetadataItemsState';
@@ -73,8 +73,10 @@ export const NavigationMenuItemIcon = ({
const iconToUse =
StandardIcon ??
(navigationMenuItem.Icon ? getIcon(navigationMenuItem.Icon) : undefined);
const effectiveColor =
getEffectiveNavigationMenuItemColor(navigationMenuItem);
const effectiveColor = getEffectiveNavigationMenuItemColor(
navigationMenuItem,
objectNavItemColor,
);
const useStyledIcon = !isRecord && isNonEmptyString(effectiveColor);
const iconStyle = useStyledIcon
? getNavigationMenuItemIconStyleFromColor(effectiveColor)
@@ -39,6 +39,7 @@ export const WorkspaceNavigationMenuItemFolderSubItem = ({
const objectMetadataItems = useAtomStateValue(objectMetadataItemsState);
const views = useAtomStateValue(viewsSelector);
const objectMetadataItem =
navigationMenuItem.itemType === NavigationMenuItemType.OBJECT ||
navigationMenuItem.itemType === NavigationMenuItemType.VIEW ||
navigationMenuItem.itemType === NavigationMenuItemType.RECORD
? getObjectMetadataForNavigationMenuItem(
@@ -28,7 +28,7 @@ import { filterWorkspaceNavigationMenuItems } from '@/navigation-menu-item/utils
import { preloadWorkspaceDndKit } from '@/navigation/preloadWorkspaceDndKit';
import { NavigationDrawerSectionForWorkspaceItems } from '@/object-metadata/components/NavigationDrawerSectionForWorkspaceItems';
import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { navigationMenuItemsState } from '@/navigation-menu-item/states/navigationMenuItemsState';
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';
@@ -47,7 +47,9 @@ export const WorkspaceNavigationMenuItems = () => {
const { workspaceNavigationMenuItemsSorted } = useSortedNavigationMenuItems();
const store = useStore();
const enterEditMode = () => {
const currentNavigationMenuItems = store.get(navigationMenuItemsState.atom);
const currentNavigationMenuItems = store.get(
navigationMenuItemsSelector.atom,
);
const workspaceNavigationMenuItems = filterWorkspaceNavigationMenuItems(
currentNavigationMenuItems,
);
@@ -106,7 +108,8 @@ export const WorkspaceNavigationMenuItems = () => {
if (objectMetadataItem) {
openNavigationMenuItemInSidePanel({
pageTitle:
item.itemType === NavigationMenuItemType.VIEW
item.itemType === NavigationMenuItemType.VIEW ||
item.itemType === NavigationMenuItemType.OBJECT
? item.labelIdentifier
: objectMetadataItem.labelSingular,
pageIcon: getIcon(objectMetadataItem.icon),
@@ -1,7 +1,7 @@
export enum NavigationMenuItemType {
FOLDER = 'folder',
LINK = 'link',
OBJECT = 'object',
RECORD = 'record',
VIEW = 'view',
FOLDER = 'FOLDER',
LINK = 'LINK',
OBJECT = 'OBJECT',
RECORD = 'RECORD',
VIEW = 'VIEW',
}
@@ -3,6 +3,7 @@ import { gql } from '@apollo/client';
export const NAVIGATION_MENU_ITEM_FRAGMENT = gql`
fragment NavigationMenuItemFields on NavigationMenuItem {
id
type
userWorkspaceId
targetRecordId
targetObjectMetadataId
@@ -1,3 +1,4 @@
import { NavigationMenuItemType } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { v4 } from 'uuid';
import type { NavigationMenuItem } from '~/generated-metadata/graphql';
@@ -37,6 +38,7 @@ export const useAddFolderToNavigationMenuDraft = () => {
const newItem: NavigationMenuItem = {
__typename: 'NavigationMenuItem',
id: newItemId,
type: NavigationMenuItemType.FOLDER,
viewId: undefined,
targetObjectMetadataId: undefined,
targetRecordId: undefined,
@@ -1,3 +1,4 @@
import { NavigationMenuItemType } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { v4 } from 'uuid';
import type { NavigationMenuItem } from '~/generated-metadata/graphql';
@@ -40,6 +41,7 @@ export const useAddLinkToNavigationMenuDraft = () => {
const newItem: NavigationMenuItem = {
__typename: 'NavigationMenuItem',
id: newItemId,
type: NavigationMenuItemType.LINK,
viewId: undefined,
targetObjectMetadataId: undefined,
targetRecordId: undefined,
@@ -1,3 +1,4 @@
import { NavigationMenuItemType } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { v4 } from 'uuid';
import type { NavigationMenuItem } from '~/generated-metadata/graphql';
@@ -13,7 +14,6 @@ export const useAddObjectToNavigationMenuDraft = () => {
const addObjectToDraft = (
objectMetadataId: string,
defaultViewId: string,
currentDraft: NavigationMenuItem[],
targetFolderId?: string | null,
targetIndex?: number,
@@ -38,7 +38,8 @@ export const useAddObjectToNavigationMenuDraft = () => {
const newItem: NavigationMenuItem = {
__typename: 'NavigationMenuItem',
id: newItemId,
viewId: defaultViewId,
type: NavigationMenuItemType.OBJECT,
viewId: undefined,
targetObjectMetadataId: objectMetadataId,
position,
userWorkspaceId: undefined,
@@ -1,4 +1,5 @@
import { v4 } from 'uuid';
import { NavigationMenuItemType } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import type { NavigationMenuItem } from '~/generated-metadata/graphql';
@@ -60,6 +61,7 @@ export const useAddRecordToNavigationMenuDraft = () => {
const newItem: NavigationMenuItem = {
__typename: 'NavigationMenuItem',
id: newItemId,
type: NavigationMenuItemType.RECORD,
viewId: undefined,
targetObjectMetadataId: objectMetadataId,
targetRecordId: searchRecord.recordId,
@@ -1,3 +1,4 @@
import { NavigationMenuItemType } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { v4 } from 'uuid';
import type { NavigationMenuItem } from '~/generated-metadata/graphql';
@@ -37,6 +38,7 @@ export const useAddViewToNavigationMenuDraft = () => {
const newItem: NavigationMenuItem = {
__typename: 'NavigationMenuItem',
id: newItemId,
type: NavigationMenuItemType.VIEW,
viewId,
targetObjectMetadataId: undefined,
position,
@@ -1,3 +1,4 @@
import { NavigationMenuItemType } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { useMutation } from '@apollo/client/react';
import { CreateNavigationMenuItemDocument } from '~/generated-metadata/graphql';
@@ -42,6 +43,7 @@ export const useCreateNavigationMenuItem = () => {
await createNavigationMenuItemMutation({
variables: {
input: {
type: NavigationMenuItemType.VIEW,
viewId: targetRecord.id,
userWorkspaceId: currentWorkspaceMemberId,
folderId,
@@ -75,6 +77,7 @@ export const useCreateNavigationMenuItem = () => {
await createNavigationMenuItemMutation({
variables: {
input: {
type: NavigationMenuItemType.RECORD,
targetRecordId: targetRecord.id,
targetObjectMetadataId: objectMetadataItem.id,
userWorkspaceId: currentWorkspaceMemberId,
@@ -1,3 +1,4 @@
import { NavigationMenuItemType } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { useMutation } from '@apollo/client/react';
import { CreateNavigationMenuItemDocument } from '~/generated-metadata/graphql';
@@ -40,6 +41,7 @@ export const useCreateNavigationMenuItemFolder = () => {
await createNavigationMenuItemMutation({
variables: {
input: {
type: NavigationMenuItemType.FOLDER,
name,
targetRecordId: null,
targetObjectMetadataId: null,
@@ -139,7 +139,6 @@ export const useHandleAddToNavigationDrop = () => {
);
const newItemId = addObjectToDraft(
payload.objectMetadataId,
payload.defaultViewId,
currentDraft,
folderId,
index,
@@ -2,7 +2,7 @@ import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMembe
import { isNavigationMenuInEditModeState } from '@/navigation-menu-item/states/isNavigationMenuInEditModeState';
import { navigationMenuItemsDraftState } from '@/navigation-menu-item/states/navigationMenuItemsDraftState';
import { filterWorkspaceNavigationMenuItems } from '@/navigation-menu-item/utils/filterWorkspaceNavigationMenuItems';
import { navigationMenuItemsState } from '@/navigation-menu-item/states/navigationMenuItemsState';
import { navigationMenuItemsSelector } from '@/navigation-menu-item/states/navigationMenuItemsSelector';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { isDefined } from 'twenty-shared/utils';
@@ -17,7 +17,7 @@ type NavigationMenuItemsData = {
export const useNavigationMenuItemsData = (): NavigationMenuItemsData => {
const currentWorkspaceMember = useAtomStateValue(currentWorkspaceMemberState);
const currentWorkspaceMemberId = currentWorkspaceMember?.id;
const navigationMenuItems = useAtomStateValue(navigationMenuItemsState);
const navigationMenuItems = useAtomStateValue(navigationMenuItemsSelector);
const isNavigationMenuInEditMode = useAtomStateValue(
isNavigationMenuInEditModeState,
);
@@ -4,14 +4,14 @@ import { isDeeplyEqual } from '~/utils/isDeeplyEqual';
import { isNavigationMenuInEditModeState } from '@/navigation-menu-item/states/isNavigationMenuInEditModeState';
import { filterWorkspaceNavigationMenuItems } from '@/navigation-menu-item/utils/filterWorkspaceNavigationMenuItems';
import { navigationMenuItemsDraftState } from '@/navigation-menu-item/states/navigationMenuItemsDraftState';
import { navigationMenuItemsState } from '@/navigation-menu-item/states/navigationMenuItemsState';
import { navigationMenuItemsSelector } from '@/navigation-menu-item/states/navigationMenuItemsSelector';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
export const useNavigationMenuItemsDraftState = () => {
const isNavigationMenuInEditMode = useAtomStateValue(
isNavigationMenuInEditModeState,
);
const navigationMenuItems = useAtomStateValue(navigationMenuItemsState);
const navigationMenuItems = useAtomStateValue(navigationMenuItemsSelector);
const navigationMenuItemsDraft = useAtomStateValue(
navigationMenuItemsDraftState,
);
@@ -1,17 +1,20 @@
import { useWorkspaceSectionItems } from '@/navigation-menu-item/hooks/useWorkspaceSectionItems';
import { isNonEmptyString } from '@sniptt/guards';
import { getStandardObjectIconColor } from '@/navigation-menu-item/utils/getStandardObjectIconColor';
import { ViewKey } from '@/views/types/ViewKey';
import { useObjectMetadataItems } from '@/object-metadata/hooks/useObjectMetadataItems';
export const useObjectNavItemColor = (objectNameSingular: string): string => {
const items = useWorkspaceSectionItems();
const objectNavItem = items.find(
(item) =>
'viewKey' in item &&
item.viewKey === ViewKey.INDEX &&
item.objectNameSingular === objectNameSingular,
);
return (
(objectNavItem && 'color' in objectNavItem ? objectNavItem.color : null) ??
getStandardObjectIconColor(objectNameSingular)
const { objectMetadataItems } = useObjectMetadataItems();
const objectMetadataItem = objectMetadataItems.find(
(item) => item.nameSingular === objectNameSingular,
);
const storedColor = objectMetadataItem?.color;
const fallbackColor = getStandardObjectIconColor(objectNameSingular);
if (isNonEmptyString(storedColor)) {
return storedColor;
}
return fallbackColor;
};
@@ -1,4 +1,5 @@
import { useCallback } from 'react';
import { NavigationMenuItemType } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { useMutation } from '@apollo/client/react';
@@ -12,7 +13,9 @@ import { filterWorkspaceNavigationMenuItems } from '@/navigation-menu-item/utils
import { isNavigationMenuItemFolder } from '@/navigation-menu-item/utils/isNavigationMenuItemFolder';
import { isNavigationMenuItemLink } from '@/navigation-menu-item/utils/isNavigationMenuItemLink';
import { orderFoldersForCreation } from '@/navigation-menu-item/utils/orderFoldersForCreation';
import { navigationMenuItemsState } from '@/navigation-menu-item/states/navigationMenuItemsState';
import { navigationMenuItemsSelector } from '@/navigation-menu-item/states/navigationMenuItemsSelector';
import { useUpdateOneObjectMetadataItem } from '@/object-metadata/hooks/useUpdateOneObjectMetadataItem';
import { objectMetadataItemsState } from '@/object-metadata/states/objectMetadataItemsState';
import { useStore } from 'jotai';
export const useSaveNavigationMenuItemsDraft = () => {
@@ -24,15 +27,46 @@ export const useSaveNavigationMenuItemsDraft = () => {
refetchQueries: ['FindManyNavigationMenuItems'],
},
);
const { updateOneObjectMetadataItem } = useUpdateOneObjectMetadataItem();
const store = useStore();
const saveDraft = useCallback(async () => {
const draft = store.get(navigationMenuItemsDraftState.atom);
const currentItems = store.get(navigationMenuItemsState.atom);
const currentItems = store.get(navigationMenuItemsSelector.atom);
if (!draft) return;
const objectMetadataItems = store.get(objectMetadataItemsState.atom);
for (const draftItem of draft) {
if (draftItem.type !== NavigationMenuItemType.OBJECT) {
continue;
}
if (!isDefined(draftItem.targetObjectMetadataId)) {
continue;
}
if (!isDefined(draftItem.color)) {
continue;
}
const objectMetadataItem = objectMetadataItems.find(
(item) => item.id === draftItem.targetObjectMetadataId,
);
if (!isDefined(objectMetadataItem)) {
continue;
}
if (objectMetadataItem.color === draftItem.color) {
continue;
}
await updateOneObjectMetadataItem({
idToUpdate: draftItem.targetObjectMetadataId,
updatePayload: { color: draftItem.color },
});
}
const workspaceItems = filterWorkspaceNavigationMenuItems(currentItems);
const topLevelWorkspace = workspaceItems.filter(
(item) => !isDefined(item.folderId),
@@ -131,13 +165,17 @@ export const useSaveNavigationMenuItemsDraft = () => {
const iconChanged =
isNavigationMenuItemFolder(draftItem) &&
(original.icon ?? null) !== (draftItem.icon ?? null);
const colorChanged =
isNavigationMenuItemFolder(draftItem) &&
(original.color ?? null) !== (draftItem.color ?? null);
if (
positionChanged ||
folderIdChanged ||
nameChanged ||
linkChanged ||
iconChanged
iconChanged ||
colorChanged
) {
const updateInput: {
id: string;
@@ -146,6 +184,7 @@ export const useSaveNavigationMenuItemsDraft = () => {
name?: string;
link?: string | null;
icon?: string | null;
color?: string | null;
} = { id: draftItem.id };
if (positionChanged) {
@@ -174,6 +213,9 @@ export const useSaveNavigationMenuItemsDraft = () => {
if (iconChanged && isNavigationMenuItemFolder(draftItem)) {
updateInput.icon = draftItem.icon ?? null;
}
if (colorChanged) {
updateInput.color = draftItem.color ?? null;
}
await updateNavigationMenuItem(updateInput);
}
@@ -182,6 +224,7 @@ export const useSaveNavigationMenuItemsDraft = () => {
updateNavigationMenuItem,
deleteNavigationMenuItem,
createNavigationMenuItemMutation,
updateOneObjectMetadataItem,
store,
]);
@@ -12,6 +12,7 @@ const getLabelForItemType = (
return item.name ?? 'Folder';
case NavigationMenuItemType.LINK:
return item.name ?? 'Link';
case NavigationMenuItemType.OBJECT:
case NavigationMenuItemType.VIEW:
return item.labelIdentifier ?? objectLabelSingular ?? '';
default:
@@ -3,6 +3,7 @@ import { isDefined } from 'twenty-shared/utils';
import { isNavigationMenuItemFolder } from '@/navigation-menu-item/utils/isNavigationMenuItemFolder';
import { isNavigationMenuItemLink } from '@/navigation-menu-item/utils/isNavigationMenuItemLink';
import { isNavigationMenuItemObject } from '@/navigation-menu-item/utils/isNavigationMenuItemObject';
import { recordIdentifierToObjectRecordIdentifier } from '@/navigation-menu-item/utils/recordIdentifierToObjectRecordIdentifier';
import { sortNavigationMenuItems } from '@/navigation-menu-item/utils/sortNavigationMenuItems';
import { objectMetadataItemsState } from '@/object-metadata/states/objectMetadataItemsState';
@@ -83,6 +84,9 @@ export const useSortedNavigationMenuItems = () => {
if (isNavigationMenuItemLink(item)) {
return true;
}
if (isNavigationMenuItemObject(item)) {
return isDefined(item.targetObjectMetadataId);
}
if (isDefined(item.viewId)) {
return views.some((view) => view.id === item.viewId);
}
@@ -2,10 +2,10 @@ import { metadataStoreState } from '@/metadata-store/states/metadataStoreState';
import { createAtomSelector } from '@/ui/utilities/state/jotai/utils/createAtomSelector';
import { type NavigationMenuItem } from '~/generated-metadata/graphql';
export const navigationMenuItemsState = createAtomSelector<
export const navigationMenuItemsSelector = createAtomSelector<
NavigationMenuItem[]
>({
key: 'navigationMenuItemsState',
key: 'navigationMenuItemsSelector',
get: ({ get }) => {
const entry = get(metadataStoreState, 'navigationMenuItems');
@@ -1,19 +1,18 @@
export type AddToNavigationDragPayloadObject = {
type: 'object';
type: 'OBJECT';
objectMetadataId: string;
defaultViewId: string;
label: string;
iconColor?: string;
};
export type AddToNavigationDragPayloadView = {
type: 'view';
type: 'VIEW';
viewId: string;
label: string;
};
export type AddToNavigationDragPayloadRecord = {
type: 'record';
type: 'RECORD';
recordId: string;
objectMetadataId: string;
objectNameSingular: string;
@@ -22,13 +21,13 @@ export type AddToNavigationDragPayloadRecord = {
};
export type AddToNavigationDragPayloadFolder = {
type: 'folder';
type: 'FOLDER';
folderId: string;
name: string;
};
export type AddToNavigationDragPayloadLink = {
type: 'link';
type: 'LINK';
linkId: string;
name: string;
link: string;
@@ -1,45 +1,26 @@
import { NavigationMenuItemType } from 'twenty-shared/types';
import { isNavigationMenuItemFolder } from '@/navigation-menu-item/utils/isNavigationMenuItemFolder';
describe('isNavigationMenuItemFolder', () => {
it('should return true only when item has name and no link/view/record metadata', () => {
it('should return true when type is folder', () => {
expect(
isNavigationMenuItemFolder({
name: 'My Folder',
link: null,
viewId: null,
targetRecordId: null,
targetObjectMetadataId: null,
}),
isNavigationMenuItemFolder({ type: NavigationMenuItemType.FOLDER }),
).toBe(true);
});
it('should return false when name is missing or when link/view/record is defined', () => {
it('should return false for other types', () => {
expect(
isNavigationMenuItemFolder({
name: undefined,
link: null,
viewId: null,
targetRecordId: null,
targetObjectMetadataId: null,
}),
isNavigationMenuItemFolder({ type: NavigationMenuItemType.LINK }),
).toBe(false);
expect(
isNavigationMenuItemFolder({
name: 'My Folder',
link: 'https://example.com',
viewId: null,
targetRecordId: null,
targetObjectMetadataId: null,
}),
isNavigationMenuItemFolder({ type: NavigationMenuItemType.VIEW }),
).toBe(false);
expect(
isNavigationMenuItemFolder({
name: 'My Folder',
link: null,
viewId: 'view-1',
targetRecordId: null,
targetObjectMetadataId: null,
}),
isNavigationMenuItemFolder({ type: NavigationMenuItemType.RECORD }),
).toBe(false);
expect(
isNavigationMenuItemFolder({ type: NavigationMenuItemType.OBJECT }),
).toBe(false);
});
});
@@ -1,60 +1,26 @@
import { NavigationMenuItemType } from 'twenty-shared/types';
import { isNavigationMenuItemLink } from '@/navigation-menu-item/utils/isNavigationMenuItemLink';
describe('isNavigationMenuItemLink', () => {
it('should return true only when item has non-empty link and no view/record metadata', () => {
it('should return true when type is link', () => {
expect(
isNavigationMenuItemLink({
link: 'https://example.com',
viewId: null,
targetRecordId: null,
targetObjectMetadataId: null,
}),
isNavigationMenuItemLink({ type: NavigationMenuItemType.LINK }),
).toBe(true);
});
it('should return false when link is missing, empty or only whitespace', () => {
it('should return false for other types', () => {
expect(
isNavigationMenuItemLink({
link: '',
viewId: null,
targetRecordId: null,
targetObjectMetadataId: null,
}),
isNavigationMenuItemLink({ type: NavigationMenuItemType.FOLDER }),
).toBe(false);
expect(
isNavigationMenuItemLink({
link: ' ',
viewId: null,
targetRecordId: null,
targetObjectMetadataId: null,
}),
isNavigationMenuItemLink({ type: NavigationMenuItemType.VIEW }),
).toBe(false);
expect(
isNavigationMenuItemLink({
link: undefined,
viewId: null,
targetRecordId: null,
targetObjectMetadataId: null,
}),
).toBe(false);
});
it('should return false when viewId, targetRecordId or targetObjectMetadataId is defined', () => {
expect(
isNavigationMenuItemLink({
link: 'https://example.com',
viewId: 'view-1',
targetRecordId: null,
targetObjectMetadataId: null,
}),
isNavigationMenuItemLink({ type: NavigationMenuItemType.RECORD }),
).toBe(false);
expect(
isNavigationMenuItemLink({
link: 'https://example.com',
viewId: null,
targetRecordId: 'record-1',
targetObjectMetadataId: null,
}),
isNavigationMenuItemLink({ type: NavigationMenuItemType.OBJECT }),
).toBe(false);
});
});
@@ -1,3 +1,5 @@
import { NavigationMenuItemType } from 'twenty-shared/types';
import { sortNavigationMenuItems } from '@/navigation-menu-item/utils/sortNavigationMenuItems';
import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { type ObjectRecordIdentifier } from '@/object-record/types/ObjectRecordIdentifier';
@@ -324,6 +326,7 @@ describe('sortNavigationMenuItems', () => {
[
{
id: 'link-1',
type: NavigationMenuItemType.LINK,
link: 'https://example.com',
name: 'My Link',
position: 1,
@@ -341,6 +344,7 @@ describe('sortNavigationMenuItems', () => {
[
{
id: 'link-2',
type: NavigationMenuItemType.LINK,
link: 'example.com',
position: 2,
} as NavigationMenuItem,
@@ -7,12 +7,14 @@ import { isDefined } from 'twenty-shared/utils';
import { isNavigationMenuItemFolder } from '@/navigation-menu-item/utils/isNavigationMenuItemFolder';
import { isNavigationMenuItemLink } from '@/navigation-menu-item/utils/isNavigationMenuItemLink';
import { isNavigationMenuItemObject } from '@/navigation-menu-item/utils/isNavigationMenuItemObject';
export const buildCreateNavigationMenuItemInput = (
draftItem: NavigationMenuItem,
resolveFolderId: (draftFolderId: string) => string,
): CreateNavigationMenuItemInput => {
const input: CreateNavigationMenuItemInput = {
type: draftItem.type,
position: draftItem.position,
};
@@ -28,6 +30,9 @@ export const buildCreateNavigationMenuItemInput = (
: linkUrl
? `https://${linkUrl}`
: undefined;
} else if (isNavigationMenuItemObject(draftItem)) {
input.targetObjectMetadataId =
draftItem.targetObjectMetadataId ?? undefined;
} else if (isDefined(draftItem.viewId)) {
input.viewId = draftItem.viewId;
} else if (isDefined(draftItem.targetRecordId)) {
@@ -3,16 +3,24 @@ import { isNonEmptyString } from '@sniptt/guards';
import { DEFAULT_NAVIGATION_MENU_ITEM_COLOR_FOLDER } from '@/navigation-menu-item/constants/NavigationMenuItemDefaultColorFolder';
import { DEFAULT_NAVIGATION_MENU_ITEM_COLOR_LINK } from '@/navigation-menu-item/constants/NavigationMenuItemDefaultColorLink';
import { NavigationMenuItemType } from '@/navigation-menu-item/constants/NavigationMenuItemType';
import { type ProcessedNavigationMenuItem } from '@/navigation-menu-item/types/processed-navigation-menu-item';
export const getEffectiveNavigationMenuItemColor = (
navigationMenuItem: ProcessedNavigationMenuItem,
navigationMenuItem: {
itemType: NavigationMenuItemType;
color?: string | null;
},
objectColor?: string,
): string | undefined => {
if (isNonEmptyString(navigationMenuItem.color)) {
return navigationMenuItem.color;
}
if (navigationMenuItem.itemType === NavigationMenuItemType.FOLDER) {
return DEFAULT_NAVIGATION_MENU_ITEM_COLOR_FOLDER;
return isNonEmptyString(navigationMenuItem.color)
? navigationMenuItem.color
: DEFAULT_NAVIGATION_MENU_ITEM_COLOR_FOLDER;
}
if (navigationMenuItem.itemType === NavigationMenuItemType.OBJECT) {
return objectColor;
}
if (navigationMenuItem.itemType === NavigationMenuItemType.VIEW) {
return objectColor;
}
if (navigationMenuItem.itemType === NavigationMenuItemType.LINK) {
return DEFAULT_NAVIGATION_MENU_ITEM_COLOR_LINK;
@@ -19,6 +19,16 @@ export const getObjectMetadataForNavigationMenuItem = (
return null;
}
if (
navigationMenuItem.itemType === NavigationMenuItemType.OBJECT &&
isDefined(navigationMenuItem.targetObjectMetadataId)
) {
const objectMetadataItem = objectMetadataItems.find(
(meta) => meta.id === navigationMenuItem.targetObjectMetadataId,
);
return objectMetadataItem ?? null;
}
if (
navigationMenuItem.itemType === NavigationMenuItemType.VIEW &&
isDefined(navigationMenuItem.viewId)
@@ -0,0 +1,4 @@
import { NavigationMenuItemType } from 'twenty-shared/types';
export const hasNavigationMenuItemOwnColor = (item: { type?: string | null }) =>
item.type === NavigationMenuItemType.FOLDER;
@@ -6,9 +6,10 @@ export const isLocationMatchingNavigationMenuItem = (
currentViewPath: string,
navigationMenuItem: Pick<ProcessedNavigationMenuItem, 'itemType' | 'link'>,
) => {
const isViewItem =
navigationMenuItem.itemType === NavigationMenuItemType.VIEW;
return isViewItem
const isViewBasedItem =
navigationMenuItem.itemType === NavigationMenuItemType.VIEW ||
navigationMenuItem.itemType === NavigationMenuItemType.OBJECT;
return isViewBasedItem
? navigationMenuItem.link === currentViewPath
: navigationMenuItem.link === currentPath;
};
@@ -1,15 +1,6 @@
import { isDefined } from 'twenty-shared/utils';
import { NavigationMenuItemType } from 'twenty-shared/types';
import { type NavigationMenuItem } from '~/generated-metadata/graphql';
export const isNavigationMenuItemFolder = (item: {
name?: string | null;
link?: string | null;
folderId?: string | null;
viewId?: string | null;
targetRecordId?: string | null;
targetObjectMetadataId?: string | null;
}) =>
isDefined(item.name) &&
!isDefined(item.link) &&
!isDefined(item.targetRecordId) &&
!isDefined(item.targetObjectMetadataId) &&
!isDefined(item.viewId);
export const isNavigationMenuItemFolder = (
item: Pick<NavigationMenuItem, 'type'>,
) => item.type === NavigationMenuItemType.FOLDER;
@@ -1,13 +1,6 @@
import { isDefined } from 'twenty-shared/utils';
import { NavigationMenuItemType } from 'twenty-shared/types';
import { type NavigationMenuItem } from '~/generated-metadata/graphql';
export const isNavigationMenuItemLink = (item: {
link?: string | null;
viewId?: string | null;
targetRecordId?: string | null;
targetObjectMetadataId?: string | null;
}) =>
isDefined(item.link) &&
(item.link ?? '').trim() !== '' &&
!isDefined(item.viewId) &&
!isDefined(item.targetRecordId) &&
!isDefined(item.targetObjectMetadataId);
export const isNavigationMenuItemLink = (
item: Pick<NavigationMenuItem, 'type'>,
) => item.type === NavigationMenuItemType.LINK;
@@ -0,0 +1,6 @@
import { NavigationMenuItemType } from 'twenty-shared/types';
import { type NavigationMenuItem } from '~/generated-metadata/graphql';
export const isNavigationMenuItemObject = (
item: Pick<NavigationMenuItem, 'type'>,
) => item.type === NavigationMenuItemType.OBJECT;
@@ -26,6 +26,45 @@ export const sortNavigationMenuItems = (
): ProcessedNavigationMenuItem[] => {
return navigationMenuItems
.map((navigationMenuItem) => {
if (
navigationMenuItem.type === NavigationMenuItemType.OBJECT &&
isDefined(navigationMenuItem.targetObjectMetadataId)
) {
const objectMetadataItem = objectMetadataItems.find(
(meta) => meta.id === navigationMenuItem.targetObjectMetadataId,
);
if (!isDefined(objectMetadataItem)) {
return null;
}
const indexView = views.find(
(view) =>
view.objectMetadataId === objectMetadataItem.id &&
view.key === ViewKey.INDEX,
);
const displayFields: NavigationMenuItemDisplayFields = {
labelIdentifier: objectMetadataItem.labelPlural,
avatarUrl: '',
avatarType: 'icon',
link: getAppPath(
AppPath.RecordIndexPage,
{ objectNamePlural: objectMetadataItem.namePlural },
indexView ? { viewId: indexView.id } : {},
),
objectNameSingular: objectMetadataItem.nameSingular,
Icon: objectMetadataItem.icon ?? 'IconBox',
};
return {
...navigationMenuItem,
...displayFields,
viewKey: ViewKey.INDEX,
itemType: NavigationMenuItemType.OBJECT,
};
}
if (isDefined(navigationMenuItem.viewId)) {
const view = views.find(
(view) => view.id === navigationMenuItem.viewId,
@@ -94,7 +94,7 @@ export const FavoritesDragDropProviderContent = ({
destination.droppableId,
);
const isFolderOverFolder =
payload?.type === 'folder' && folderId !== null;
payload?.type === 'FOLDER' && folderId !== null;
setForbiddenDropTargetId(isFolderOverFolder ? dropTargetId : null);
} else {
setForbiddenDropTargetId(null);
@@ -229,7 +229,7 @@ export const useWorkspaceDndKit = (): {
resolved.destination.droppableId,
);
const folderDrag =
getPayload()?.type === 'folder' && isDefined(folderId);
getPayload()?.type === 'FOLDER' && isDefined(folderId);
setForbiddenDropTargetId(
folderDrag ? resolved.effectiveDropTargetId : null,
);
@@ -9,5 +9,5 @@ export const isFolderDrag = (
payload: AddToNavPayload,
sourceItem: NavigationMenuItem | undefined,
): boolean =>
payload?.type === 'folder' ||
payload?.type === 'FOLDER' ||
(isDefined(sourceItem) && isNavigationMenuItemFolder(sourceItem));
@@ -2,7 +2,6 @@ import { ObjectIconWithViewOverlay } from '@/navigation-menu-item/components/Obj
import { NavigationMenuItemType } from '@/navigation-menu-item/constants/NavigationMenuItemType';
import { useObjectNavItemColor } from '@/navigation-menu-item/hooks/useObjectNavItemColor';
import { isNavigationMenuInEditModeState } from '@/navigation-menu-item/states/isNavigationMenuInEditModeState';
import { getStandardObjectIconColor } from '@/navigation-menu-item/utils/getStandardObjectIconColor';
import { type ProcessedNavigationMenuItem } from '@/navigation-menu-item/utils/sortNavigationMenuItems';
import { lastVisitedViewPerObjectMetadataItemState } from '@/navigation/states/lastVisitedViewPerObjectMetadataItemState';
import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
@@ -52,7 +51,9 @@ export const NavigationDrawerItemForObjectMetadataItem = ({
const isRecord =
navigationMenuItem?.itemType === NavigationMenuItemType.RECORD;
const isView = navigationMenuItem?.itemType === NavigationMenuItemType.VIEW;
const hasCustomLink = isRecord || isView;
const isObject =
navigationMenuItem?.itemType === NavigationMenuItemType.OBJECT;
const hasCustomLink = isRecord || isView || isObject;
const navigationPath = hasCustomLink
? navigationMenuItem!.link
@@ -63,7 +64,7 @@ export const NavigationDrawerItemForObjectMetadataItem = ({
);
const isActive = hasCustomLink
? (isView ? currentPathWithSearch : currentPath) ===
? (isView || isObject ? currentPathWithSearch : currentPath) ===
navigationMenuItem!.link
: currentPath ===
getAppPath(AppPath.RecordIndexPage, {
@@ -114,11 +115,7 @@ export const NavigationDrawerItemForObjectMetadataItem = ({
)
: getIcon(objectMetadataItem.icon);
const iconThemeColor = !isRecord
? isDefined(navigationMenuItem?.color)
? navigationMenuItem.color
: (getStandardObjectIconColor(objectMetadataItem.nameSingular) ?? 'gray')
: undefined;
const iconThemeColor = !isRecord ? objectNavItemColor : undefined;
const secondaryLabel =
isRecord || isViewWithCustomName
@@ -96,6 +96,7 @@ export const NavigationDrawerSectionForWorkspaceItems = ({
return true;
}
if (
type === NavigationMenuItemType.OBJECT ||
type === NavigationMenuItemType.VIEW ||
type === NavigationMenuItemType.RECORD
) {
@@ -123,7 +124,7 @@ export const NavigationDrawerSectionForWorkspaceItems = ({
? () => {
const type = item.itemType;
const objectMetadataItem =
type === 'view' || type === 'record'
type === 'OBJECT' || type === 'VIEW' || type === 'RECORD'
? getObjectMetadataForNavigationMenuItem(
item as ProcessedNavigationMenuItem,
objectMetadataItems,
@@ -8,6 +8,7 @@ export const OBJECT_METADATA_FRAGMENT = gql`
namePlural
labelSingular
labelPlural
color
description
icon
isCustom
@@ -10,6 +10,7 @@ export const CREATE_ONE_OBJECT_METADATA_ITEM = gql`
labelPlural
description
icon
color
isCustom
isActive
isSearchable
@@ -90,6 +91,7 @@ export const UPDATE_ONE_OBJECT_METADATA_ITEM = gql`
labelPlural
description
icon
color
isCustom
isActive
isSearchable
@@ -112,6 +114,7 @@ export const DELETE_ONE_OBJECT_METADATA_ITEM = gql`
labelPlural
description
icon
color
isCustom
isActive
isSearchable
@@ -27,6 +27,7 @@ export const responseData = {
labelPlural: 'View Filters',
description: '',
icon: '',
color: null,
isCustom: false,
isActive: true,
isSearchable: false,
@@ -10,6 +10,7 @@ export const query = gql`
labelPlural
description
icon
color
isCustom
isActive
isSearchable
@@ -32,6 +33,7 @@ export const responseData = {
labelPlural: '',
description: '',
icon: '',
color: null,
isCustom: false,
isActive: true,
isSearchable: false,
@@ -1,11 +1,8 @@
import { isNonEmptyString } from '@sniptt/guards';
import { NavigationMenuItemStyleIcon } from '@/navigation-menu-item/components/NavigationMenuItemStyleIcon';
import { useNavigationMenuItemsData } from '@/navigation-menu-item/hooks/useNavigationMenuItemsData';
import { getStandardObjectIconColor } from '@/navigation-menu-item/utils/getStandardObjectIconColor';
import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { useAtomFamilySelectorValue } from '@/ui/utilities/state/jotai/hooks/useAtomFamilySelectorValue';
import { indexViewIdFromObjectMetadataItemFamilySelector } from '@/views/states/selectors/indexViewIdFromObjectMetadataItemFamilySelector';
import { isDefined } from 'twenty-shared/utils';
import { useIcons } from 'twenty-ui/display';
@@ -14,11 +11,6 @@ export const RecordIndexPageHeaderIcon = ({
}: {
objectMetadataItem?: ObjectMetadataItem;
}) => {
const { workspaceNavigationMenuItems } = useNavigationMenuItemsData();
const coreIndexViewId = useAtomFamilySelectorValue(
indexViewIdFromObjectMetadataItemFamilySelector,
{ objectMetadataItemId: objectMetadataItem?.id ?? '' },
);
const { getIcon } = useIcons();
const ObjectIcon = getIcon(objectMetadataItem?.icon);
@@ -26,17 +18,9 @@ export const RecordIndexPageHeaderIcon = ({
return null;
}
const navItem = isDefined(coreIndexViewId)
? workspaceNavigationMenuItems.find(
(item) => item.viewId === coreIndexViewId,
)
: undefined;
const navigationMenuItemColor = isNonEmptyString(navItem?.color)
? navItem.color
: undefined;
const iconColor =
navigationMenuItemColor ??
getStandardObjectIconColor(objectMetadataItem?.nameSingular ?? '');
const iconColor = isNonEmptyString(objectMetadataItem?.color)
? objectMetadataItem.color
: getStandardObjectIconColor(objectMetadataItem?.nameSingular ?? '');
return (
<NavigationMenuItemStyleIcon
@@ -1,3 +1,7 @@
import { isNonEmptyString } from '@sniptt/guards';
import { NavigationMenuItemStyleIcon } from '@/navigation-menu-item/components/NavigationMenuItemStyleIcon';
import { getStandardObjectIconColor } from '@/navigation-menu-item/utils/getStandardObjectIconColor';
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { useFindOneRecord } from '@/object-record/hooks/useFindOneRecord';
@@ -9,10 +13,9 @@ import { useRecordShowPagePagination } from '@/object-record/record-show/hooks/u
import { RecordTitleCell } from '@/object-record/record-title-cell/components/RecordTitleCell';
import { RecordTitleCellContainerType } from '@/object-record/record-title-cell/types/RecordTitleCellContainerType';
import { styled } from '@linaria/react';
import { useContext } from 'react';
import { FieldMetadataType } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { ThemeContext, themeCssVariables } from 'twenty-ui/theme-constants';
import { themeCssVariables } from 'twenty-ui/theme-constants';
const StyledEditableTitleContainer = styled.div`
align-items: center;
@@ -52,7 +55,6 @@ export const ObjectRecordShowPageBreadcrumb = ({
objectLabel: string;
labelIdentifierFieldMetadataItem?: FieldMetadataItem;
}) => {
const { theme } = useContext(ThemeContext);
const { loading } = useFindOneRecord({
objectNameSingular,
objectRecordId,
@@ -82,6 +84,11 @@ export const ObjectRecordShowPageBreadcrumb = ({
objectNameSingular,
objectRecordId,
);
const iconColor = isNonEmptyString(objectMetadataItem?.color)
? objectMetadataItem.color
: getStandardObjectIconColor(objectNameSingular);
if (loading) {
return null;
}
@@ -93,7 +100,12 @@ export const ObjectRecordShowPageBreadcrumb = ({
navigateToIndexView();
}}
>
{isDefined(HeaderIcon) && <HeaderIcon size={theme.icon.size.md} />}
{isDefined(HeaderIcon) && (
<NavigationMenuItemStyleIcon
Icon={HeaderIcon}
color={iconColor ?? undefined}
/>
)}
{objectLabel}
<span>{' / '}</span>
</StyledEditableTitlePrefix>
@@ -7,7 +7,6 @@ import { NavigationMenuItemType } from '@/navigation-menu-item/constants/Navigat
import { useSelectedNavigationMenuItemEditItem } from '@/navigation-menu-item/hooks/useSelectedNavigationMenuItemEditItem';
import { useSelectedNavigationMenuItemEditItemLabel } from '@/navigation-menu-item/hooks/useSelectedNavigationMenuItemEditItemLabel';
import { useSelectedNavigationMenuItemEditItemObjectMetadata } from '@/navigation-menu-item/hooks/useSelectedNavigationMenuItemEditItemObjectMetadata';
import { ViewKey } from '@/views/types/ViewKey';
export const SidePanelObjectViewRecordInfo = () => {
const { t } = useLingui();
@@ -25,19 +24,20 @@ export const SidePanelObjectViewRecordInfo = () => {
return null;
}
const isViewOrRecord = [
const isObjectViewOrRecord = [
NavigationMenuItemType.OBJECT,
NavigationMenuItemType.VIEW,
NavigationMenuItemType.RECORD,
].includes(processedItem.itemType);
if (!isViewOrRecord) {
if (!isObjectViewOrRecord) {
return null;
}
const label =
processedItem.itemType === NavigationMenuItemType.RECORD
? selectedItemObjectMetadata?.labelSingular
: processedItem.viewKey === ViewKey.INDEX
: processedItem.itemType === NavigationMenuItemType.OBJECT
? t`Object`
: t`View`;
@@ -62,6 +62,7 @@ export const SidePanelPageInfo = ({ pageChip }: SidePanelPageInfoProps) => {
}
if (
itemType === NavigationMenuItemType.OBJECT ||
itemType === NavigationMenuItemType.VIEW ||
itemType === NavigationMenuItemType.RECORD
) {
@@ -1,3 +1,8 @@
import { useObjectNavItemColor } from '@/navigation-menu-item/hooks/useObjectNavItemColor';
import { navigationMenuItemsSelector } from '@/navigation-menu-item/states/navigationMenuItemsSelector';
import { type ProcessedNavigationMenuItem } from '@/navigation-menu-item/types/processed-navigation-menu-item';
import { getEffectiveNavigationMenuItemColor } from '@/navigation-menu-item/utils/getEffectiveNavigationMenuItemColor';
import { parseThemeColor } from '@/navigation-menu-item/utils/parseThemeColor';
import { SidePanelGroup } from '@/side-panel/components/SidePanelGroup';
import { SidePanelList } from '@/side-panel/components/SidePanelList';
import { SidePanelEditColorOption } from '@/side-panel/pages/navigation-menu-item/components/SidePanelEditColorOption';
@@ -6,13 +11,15 @@ import {
SidePanelEditOrganizeActions,
} from '@/side-panel/pages/navigation-menu-item/components/SidePanelEditOrganizeActions';
import { getOrganizeActionsSelectableItemIds } from '@/side-panel/pages/navigation-menu-item/utils/getOrganizeActionsSelectableItemIds';
import { useSelectedNavigationMenuItemEditItem } from '@/navigation-menu-item/hooks/useSelectedNavigationMenuItemEditItem';
import { parseThemeColor } from '@/navigation-menu-item/utils/parseThemeColor';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { useLingui } from '@lingui/react/macro';
import { isNonEmptyString } from '@sniptt/guards';
import { isDefined } from 'twenty-shared/utils';
type SidePanelEditObjectViewBaseProps = OrganizeActionsProps & {
onOpenFolderPicker: () => void;
showColorOption?: boolean;
selectedItem?: ProcessedNavigationMenuItem | null;
};
export const SidePanelEditObjectViewBase = ({
@@ -25,18 +32,36 @@ export const SidePanelEditObjectViewBase = ({
onAddBefore,
onAddAfter,
showColorOption = false,
selectedItem,
}: SidePanelEditObjectViewBaseProps) => {
const { t } = useLingui();
const { selectedItem } = useSelectedNavigationMenuItemEditItem();
const selectableItemIds = getOrganizeActionsSelectableItemIds(true);
const objectColor = useObjectNavItemColor(
selectedItem?.objectNameSingular ?? '',
);
const navigationMenuItems = useAtomStateValue(navigationMenuItemsSelector);
const persistedNavItem = navigationMenuItems.find(
(item) => item.id === selectedItem?.id,
);
const hasUserChangedColor =
isNonEmptyString(selectedItem?.color) &&
selectedItem.color !== (persistedNavItem?.color ?? undefined);
const effectiveColor = isDefined(selectedItem)
? getEffectiveNavigationMenuItemColor(selectedItem, objectColor)
: undefined;
const displayColor = hasUserChangedColor
? selectedItem.color
: effectiveColor;
return (
<SidePanelList commandGroups={[]} selectableItemIds={selectableItemIds}>
{showColorOption && selectedItem && (
{showColorOption && isDefined(selectedItem) && (
<SidePanelGroup heading={t`Customize`}>
<SidePanelEditColorOption
navigationMenuItemId={selectedItem.id}
color={parseThemeColor(selectedItem.color)}
color={parseThemeColor(displayColor)}
/>
</SidePanelGroup>
)}
@@ -4,9 +4,9 @@ import { useNavigationMenuItemsDraftState } from '@/navigation-menu-item/hooks/u
import { useOpenAddItemToFolderPage } from '@/navigation-menu-item/hooks/useOpenAddItemToFolderPage';
import { useSelectedNavigationMenuItemEditItem } from '@/navigation-menu-item/hooks/useSelectedNavigationMenuItemEditItem';
import { useSelectedNavigationMenuItemEditItemLabel } from '@/navigation-menu-item/hooks/useSelectedNavigationMenuItemEditItemLabel';
import { useSelectedNavigationMenuItemEditItemObjectMetadata } from '@/navigation-menu-item/hooks/useSelectedNavigationMenuItemEditItemObjectMetadata';
import { useUpdateLinkInDraft } from '@/navigation-menu-item/hooks/useUpdateLinkInDraft';
import { selectedNavigationMenuItemInEditModeState } from '@/navigation-menu-item/states/selectedNavigationMenuItemInEditModeState';
import { type ProcessedNavigationMenuItem } from '@/navigation-menu-item/types/processed-navigation-menu-item';
import { parseThemeColor } from '@/navigation-menu-item/utils/parseThemeColor';
import { SidePanelGroup } from '@/side-panel/components/SidePanelGroup';
import { SidePanelList } from '@/side-panel/components/SidePanelList';
@@ -21,7 +21,6 @@ import { getOrganizeActionsSelectableItemIds } from '@/side-panel/pages/navigati
import { SidePanelSubPages } from '@/side-panel/types/SidePanelSubPages';
import { SelectableListItem } from '@/ui/layout/selectable-list/components/SelectableListItem';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { ViewKey } from '@/views/types/ViewKey';
import { styled } from '@linaria/react';
import { useLingui } from '@lingui/react/macro';
import { isDefined } from 'twenty-shared/utils';
@@ -47,8 +46,6 @@ export const SidePanelNavigationMenuItemEditPage = () => {
);
const { selectedItemLabel } = useSelectedNavigationMenuItemEditItemLabel();
const { selectedItem } = useSelectedNavigationMenuItemEditItem();
const { selectedItemObjectMetadata } =
useSelectedNavigationMenuItemEditItemObjectMetadata();
const selectedItemType = selectedItem?.itemType ?? null;
const { navigateToSidePanelSubPage } = useSidePanelSubPageHistory();
@@ -97,8 +94,22 @@ export const SidePanelNavigationMenuItemEditPage = () => {
}
switch (selectedItemType) {
case NavigationMenuItemType.VIEW:
if (!selectedItemObjectMetadata) return null;
case NavigationMenuItemType.OBJECT:
return (
<SidePanelEditObjectViewBase
onOpenFolderPicker={openFolderPicker}
canMoveUp={canMoveUp}
canMoveDown={canMoveDown}
onMoveUp={onMoveUp}
onMoveDown={onMoveDown}
onRemove={onRemove}
onAddBefore={onAddBefore}
onAddAfter={onAddAfter}
showColorOption={isDefined(selectedItem)}
selectedItem={selectedItem as ProcessedNavigationMenuItem | undefined}
/>
);
case NavigationMenuItemType.VIEW:
return (
<SidePanelEditObjectViewBase
onOpenFolderPicker={openFolderPicker}
@@ -109,11 +120,6 @@ export const SidePanelNavigationMenuItemEditPage = () => {
onRemove={onRemove}
onAddBefore={onAddBefore}
onAddAfter={onAddAfter}
showColorOption={
selectedItem &&
'viewKey' in selectedItem &&
selectedItem.viewKey === ViewKey.INDEX
}
/>
);
case NavigationMenuItemType.LINK:
@@ -187,14 +193,6 @@ export const SidePanelNavigationMenuItemEditPage = () => {
commandGroups={[]}
selectableItemIds={getOrganizeActionsSelectableItemIds(true)}
>
{selectedItem && (
<SidePanelGroup heading={t`Customize`}>
<SidePanelEditColorOption
navigationMenuItemId={selectedItem.id}
color={parseThemeColor(selectedItem.color)}
/>
</SidePanelGroup>
)}
<SidePanelEditOrganizeActions
canMoveUp={canMoveUp}
canMoveDown={canMoveDown}
@@ -56,16 +56,12 @@ export const SidePanelNewSidebarItemObjectSubPage = () => {
objectMetadataIdsWithDisplayableViews,
});
const handleSelectObject = (
objectMetadataItem: ObjectMetadataItem,
defaultViewId: string,
) => {
const handleSelectObject = (objectMetadataItem: ObjectMetadataItem) => {
if (objectMetadataIdsInWorkspace.has(objectMetadataItem.id)) {
return;
}
const itemId = addObjectToDraft(
objectMetadataItem.id,
defaultViewId,
currentDraft,
addMenuItemInsertionContext?.targetFolderId,
addMenuItemInsertionContext?.targetIndex,
@@ -55,16 +55,12 @@ export const SidePanelNewSidebarItemObjectSystemPickerSubPage = () => {
objectMetadataIdsWithDisplayableViews,
});
const handleSelectObject = (
objectMetadataItem: ObjectMetadataItem,
defaultViewId: string,
) => {
const handleSelectObject = (objectMetadataItem: ObjectMetadataItem) => {
if (objectMetadataIdsInWorkspace.has(objectMetadataItem.id)) {
return;
}
const itemId = addObjectToDraft(
objectMetadataItem.id,
defaultViewId,
currentDraft,
addMenuItemInsertionContext?.targetFolderId,
addMenuItemInsertionContext?.targetIndex,
@@ -16,10 +16,7 @@ import { indexViewIdFromObjectMetadataItemFamilySelector } from '@/views/states/
type SidePanelObjectMenuItemProps = {
objectMetadataItem: ObjectMetadataItem;
onSelect: (
objectMetadataItem: ObjectMetadataItem,
defaultViewId: string,
) => void;
onSelect: (objectMetadataItem: ObjectMetadataItem) => void;
variant: 'add' | 'edit';
dragIndex?: number;
disableDrag?: boolean;
@@ -52,7 +49,7 @@ export const SidePanelObjectMenuItem = ({
if (isDisabled || !defaultViewId) {
return;
}
onSelect(objectMetadataItem, defaultViewId);
onSelect(objectMetadataItem);
};
const styledIcon = () => (
@@ -72,7 +69,6 @@ export const SidePanelObjectMenuItem = ({
payload={{
type: NavigationMenuItemType.OBJECT,
objectMetadataId: objectMetadataItem.id,
defaultViewId: defaultViewId ?? '',
label: objectMetadataItem.labelPlural,
iconColor,
}}
@@ -12,10 +12,7 @@ type SidePanelObjectPickerItemProps = {
objectMetadataItem: ObjectMetadataItem;
isViewItem: boolean;
onSelectObjectForViewEdit?: (objectMetadataItem: ObjectMetadataItem) => void;
onChangeObject: (
objectMetadataItem: ObjectMetadataItem,
defaultViewId: string,
) => void;
onChangeObject: (objectMetadataItem: ObjectMetadataItem) => void;
objectMenuItemVariant?: 'add' | 'edit';
dragIndex?: number;
disableDrag?: boolean;
@@ -19,10 +19,7 @@ type SidePanelObjectPickerSubViewProps = {
onOpenSystemPicker: () => void;
isViewItem: boolean;
onSelectObjectForViewEdit?: (objectMetadataItem: ObjectMetadataItem) => void;
onChangeObject: (
objectMetadataItem: ObjectMetadataItem,
defaultViewId: string,
) => void;
onChangeObject: (objectMetadataItem: ObjectMetadataItem) => void;
objectMenuItemVariant?: 'add' | 'edit';
emptyNoResultsText?: string;
disableDrag?: boolean;
@@ -14,10 +14,7 @@ type SidePanelSystemObjectPickerSubViewProps = {
onSearchChange: (value: string) => void;
isViewItem: boolean;
onSelectObjectForViewEdit?: (objectMetadataItem: ObjectMetadataItem) => void;
onChangeObject: (
objectMetadataItem: ObjectMetadataItem,
defaultViewId: string,
) => void;
onChangeObject: (objectMetadataItem: ObjectMetadataItem) => void;
objectMenuItemVariant?: 'add' | 'edit';
emptyNoResultsText?: string;
disableDrag?: boolean;