Always show Favorites section and add favorites via the side panel (#21087)

## What & why

The left-sidebar **Favorites** section was hidden whenever the user had
no favorites, so it was effectively undiscoverable — and personal
favorites could only be created via the record-level "Add to favorites"
action or drag-drop.

This PR:
- **Always shows the Favorites section**, with an empty-state **"Add a
favorite"** call-to-action.
- Adds a **"+" on the Favorites header that opens the same "New menu
item" side panel** the Workspace section already uses, so users can add
personal **Objects, Views, Records, Links and Folders** directly from
the sidebar.

## How

Favorites and workspace navigation are the same `NavigationMenuItem`
entity (a personal favorite simply has `userWorkspaceId` set). Rather
than build a separate favorites-only flow, the shared add/edit
side-panel subsystem is made **section-aware**
(`NavigationMenuItemSection = 'workspace' | 'favorite'`):

- a new `navigationMenuItemEditSectionState` atom records which section
the panel is operating on;
- a new `useNavigationMenuItemEditController` forks persistence — the
**workspace** section stages changes in the draft (saved on
layout-customization exit), while the **favorite** section
creates/updates/deletes personal items **immediately** with
`userWorkspaceId = current member`. This mirrors the existing
`useHandleNavigationMenuItemDragAndDrop` fork.

The existing add/edit hooks, pickers and title editors were rerouted
through the controller and a section-aware items hook, so they work for
both sections with no behavior change to the workspace flow.

**Backend: no changes** — `canUserCreateNavigationMenuItem` already
authorizes personal navigation menu items of every type for any
authenticated user.

## Decisions & tradeoffs

- **Folder button → unified "+":** the folder-only header button is
replaced by the single "+" (Folder is one of the panel's options),
matching the Workspace section. This removed the inline folder-create
code path.
- **Click-to-add only in v1:** dragging items from the panel directly
into Favorites is deferred — those drag handles are disabled in the
favorite section (the drag path is hardwired to workspace layout mode),
with a defense-in-depth no-op in the drop handler.
- **Persist-on-commit:** favorite title/URL edits hit the network once
on blur/enter, never per keystroke.
- **Personal color edits** change only the favorite's own color, never
the shared object metadata (that remains a workspace-customization
behavior).
- The change touches ~37 files because it generalizes the shared
subsystem rather than duplicating it; net diff is slightly negative
(+605 / −636).

## Testing

- `npx nx typecheck twenty-front` — passes
- `npx nx lint twenty-front` (oxlint + oxfmt) — passes
- `navigation-menu-item` unit tests — pass (incl. an updated
`computeInsertIndexAndPosition` test covering personal items)
- Manual end-to-end walkthrough still recommended before merge.
This commit is contained in:
Félix Malfait
2026-06-02 14:28:10 +02:00
committed by GitHub
parent 431f6ae98f
commit c18f8d6cf7
52 changed files with 932 additions and 1192 deletions
@@ -9,6 +9,7 @@ import { commandMenuItemsDraftState } from '@/command-menu-item/edit/states/comm
import { commandMenuItemsSelector } from '@/command-menu-item/states/commandMenuItemsSelector';
import { activeCustomizationPageLayoutIdsState } from '@/layout-customization/states/activeCustomizationPageLayoutIdsState';
import { isLayoutCustomizationModeEnabledState } from '@/layout-customization/states/isLayoutCustomizationModeEnabledState';
import { navigationMenuItemEditSectionState } from '@/navigation-menu-item/common/states/navigationMenuItemEditSectionState';
import { navigationMenuItemsDraftState } from '@/navigation-menu-item/common/states/navigationMenuItemsDraftState';
import { navigationMenuItemsSelector } from '@/navigation-menu-item/common/states/navigationMenuItemsSelector';
import { filterWorkspaceNavigationMenuItems } from '@/navigation-menu-item/common/utils/filterWorkspaceNavigationMenuItems';
@@ -60,6 +61,7 @@ export const useEnterLayoutCustomizationMode = () => {
prefetchNavigationMenuItems,
);
store.set(navigationMenuItemsDraftState.atom, workspaceNavigationMenuItems);
store.set(navigationMenuItemEditSectionState.atom, 'workspace');
const persistedCommandMenuItems = store.get(commandMenuItemsSelector.atom);
store.set(commandMenuItemsDraftState.atom, persistedCommandMenuItems);
@@ -1,8 +0,0 @@
import { createAtomState } from '@/ui/utilities/state/jotai/utils/createAtomState';
export const isNavigationMenuItemFolderCreatingState = createAtomState<boolean>(
{
key: 'isNavigationMenuItemFolderCreatingState',
defaultValue: false,
},
);
@@ -0,0 +1,9 @@
import type { NavigationMenuItemSection } from '@/navigation-menu-item/common/types/NavigationMenuItemSection';
import { createAtomState } from '@/ui/utilities/state/jotai/utils/createAtomState';
export const navigationMenuItemEditSectionState =
createAtomState<NavigationMenuItemSection>({
key: 'navigationMenuItemEditSectionState',
defaultValue: 'workspace',
});
@@ -18,7 +18,7 @@ describe('computeInsertIndexAndPosition', () => {
expect(between.position).toBe(15);
});
it('should only consider items in target folder and exclude userWorkspaceId', () => {
it('should only consider items in the target folder', () => {
const draft: NavigationMenuItem[] = [
{ id: '1', folderId: 'folder-a', position: 10 } as NavigationMenuItem,
{ id: '2', folderId: 'folder-b', position: 20 } as NavigationMenuItem,
@@ -27,9 +27,16 @@ describe('computeInsertIndexAndPosition', () => {
const result = computeInsertIndexAndPosition(draft, 'folder-b', 1);
expect(result.flatIndex).toBe(2);
expect(result.position).toBe(25);
});
const withWorkspace: NavigationMenuItem[] = [
{ id: '1', folderId: null, position: 10 } as NavigationMenuItem,
it('should consider personal items, as callers pass a section-scoped list', () => {
const personalItems: NavigationMenuItem[] = [
{
id: '1',
folderId: null,
position: 10,
userWorkspaceId: 'ws-1',
} as NavigationMenuItem,
{
id: '2',
folderId: null,
@@ -37,7 +44,8 @@ describe('computeInsertIndexAndPosition', () => {
userWorkspaceId: 'ws-1',
} as NavigationMenuItem,
];
const excluded = computeInsertIndexAndPosition(withWorkspace, null, 1);
expect(excluded.position).toBe(10.5);
const inserted = computeInsertIndexAndPosition(personalItems, null, 1);
expect(inserted.flatIndex).toBe(1);
expect(inserted.position).toBe(15);
});
});
@@ -0,0 +1,20 @@
import { type NavigationMenuItem } from '~/generated-metadata/graphql';
// Normalizes a link rename/url edit: a blank name falls back to "Link", and a
// blank url is ignored so the existing link is kept.
export const buildNavigationMenuLinkUpdate = (updates: {
name?: string;
link?: string;
}): Partial<NavigationMenuItem> => {
const normalized: Partial<NavigationMenuItem> = {};
if (updates.name !== undefined) {
normalized.name = updates.name.trim() || 'Link';
}
if (updates.link !== undefined && updates.link.trim() !== '') {
normalized.link = updates.link.trim();
}
return normalized;
};
@@ -2,24 +2,20 @@ import { isDefined } from 'twenty-shared/utils';
import type { NavigationMenuItem } from '~/generated-metadata/graphql';
export const computeInsertIndexAndPosition = (
currentDraft: NavigationMenuItem[],
items: NavigationMenuItem[],
targetFolderId: string | null,
targetIndex: number,
) => {
const itemsInFolder = currentDraft
.filter(
(item) =>
(item.folderId ?? null) === targetFolderId &&
!isDefined(item.userWorkspaceId),
)
const itemsInFolder = items
.filter((item) => (item.folderId ?? null) === targetFolderId)
.sort((a, b) => a.position - b.position);
const insertRef = itemsInFolder[targetIndex];
const lastInFolder = itemsInFolder[itemsInFolder.length - 1];
const flatIndex = isDefined(insertRef)
? currentDraft.indexOf(insertRef)
? items.indexOf(insertRef)
: isDefined(lastInFolder)
? currentDraft.indexOf(lastInFolder) + 1
: currentDraft.length;
? items.indexOf(lastInFolder) + 1
: items.length;
const prevPosition = itemsInFolder[targetIndex - 1]?.position ?? 0;
const nextPosition = itemsInFolder[targetIndex]?.position ?? prevPosition + 1;
const position = (prevPosition + nextPosition) / 2;
@@ -0,0 +1,28 @@
import { type NavigationMenuItem } from '~/generated-metadata/graphql';
import { isNavigationMenuItemFolder } from '@/navigation-menu-item/common/utils/isNavigationMenuItemFolder';
type FolderWithChildren = {
id: string;
navigationMenuItems: NavigationMenuItem[];
};
// Expand a display-ordered top-level list (orphans + folders) into a flat list
// where each folder is immediately followed by its children.
export const flattenNavigationMenuItemsWithFolderChildren = (
topLevelItems: NavigationMenuItem[],
foldersWithChildren: FolderWithChildren[],
): NavigationMenuItem[] => {
const childrenByFolderId = new Map(
foldersWithChildren.map((folder) => [
folder.id,
folder.navigationMenuItems,
]),
);
return topLevelItems.flatMap((item) =>
isNavigationMenuItemFolder(item)
? [item, ...(childrenByFolderId.get(item.id) ?? [])]
: [item],
);
};
@@ -1,45 +1,37 @@
import { t } from '@lingui/core/macro';
import { useStore } from 'jotai';
import { useCallback } from 'react';
import { isDefined } from 'twenty-shared/utils';
import { NavigationMenuItemType } from 'twenty-shared/types';
import { isDefined, normalizeUrl } from 'twenty-shared/utils';
import { IconFolder, IconLink, useIcons } from 'twenty-ui/display';
import { useEnterLayoutCustomizationMode } from '@/layout-customization/hooks/useEnterLayoutCustomizationMode';
import { isLayoutCustomizationModeEnabledState } from '@/layout-customization/states/isLayoutCustomizationModeEnabledState';
import { ADD_TO_NAV_SOURCE_DROPPABLE_ID } from '@/navigation-menu-item/common/constants/AddToNavSourceDroppableId';
import { DEFAULT_NAVIGATION_MENU_ITEM_COLOR_FOLDER } from '@/navigation-menu-item/common/constants/NavigationMenuItemDefaultColorFolder';
import { DEFAULT_NAVIGATION_MENU_ITEM_COLOR_LINK } from '@/navigation-menu-item/common/constants/NavigationMenuItemDefaultColorLink';
import { addToNavPayloadRegistryState } from '@/navigation-menu-item/common/states/addToNavPayloadRegistryState';
import { navigationMenuItemsDraftState } from '@/navigation-menu-item/common/states/navigationMenuItemsDraftState';
import { navigationMenuItemEditSectionState } from '@/navigation-menu-item/common/states/navigationMenuItemEditSectionState';
import { openNavigationMenuItemFolderIdsState } from '@/navigation-menu-item/common/states/openNavigationMenuItemFolderIdsState';
import { canNavigationMenuItemBeDroppedIn } from '@/navigation-menu-item/common/utils/canNavigationMenuItemBeDroppedIn';
import { getObjectMetadataIdsInDraft } from '@/navigation-menu-item/common/utils/getObjectMetadataIdsInDraft';
import { validateAndExtractWorkspaceFolderId } from '@/navigation-menu-item/common/utils/validateAndExtractWorkspaceFolderId';
import { useAddFolderToNavigationMenuDraft } from '@/navigation-menu-item/edit/folder/hooks/useAddFolderToNavigationMenuDraft';
import { useNavigationMenuItemsDraftState } from '@/navigation-menu-item/edit/hooks/useNavigationMenuItemsDraftState';
import {
type NewNavigationMenuItemInput,
useNavigationMenuItemEditController,
} from '@/navigation-menu-item/edit/hooks/useNavigationMenuItemEditController';
import { useOpenNavigationMenuItemInSidePanel } from '@/navigation-menu-item/edit/hooks/useOpenNavigationMenuItemInSidePanel';
import { useAddLinkToNavigationMenuDraft } from '@/navigation-menu-item/edit/link/hooks/useAddLinkToNavigationMenuDraft';
import { useAddObjectToNavigationMenuDraft } from '@/navigation-menu-item/edit/object/hooks/useAddObjectToNavigationMenuDraft';
import { useAddRecordToNavigationMenuDraft } from '@/navigation-menu-item/edit/record/hooks/useAddRecordToNavigationMenuDraft';
import { useAddViewToNavigationMenuDraft } from '@/navigation-menu-item/edit/view/hooks/useAddViewToNavigationMenuDraft';
import { useObjectMetadataItems } from '@/object-metadata/hooks/useObjectMetadataItems';
import { getObjectColorWithFallback } from '@/object-metadata/utils/getObjectColorWithFallback';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { useSetAtomState } from '@/ui/utilities/state/jotai/hooks/useSetAtomState';
import { viewsSelector } from '@/views/states/selectors/viewsSelector';
import { useStore } from 'jotai';
import { NavigationMenuItemType } from 'twenty-shared/types';
import type { NavigationMenuItemDropResult } from '@/navigation-menu-item/common/types/navigationMenuItemDropResult';
export const useHandleAddToNavigationDrop = () => {
const store = useStore();
const { addObjectToDraft } = useAddObjectToNavigationMenuDraft();
const { addViewToDraft } = useAddViewToNavigationMenuDraft();
const { addRecordToDraft } = useAddRecordToNavigationMenuDraft();
const { addFolderToDraft } = useAddFolderToNavigationMenuDraft();
const { addLinkToDraft } = useAddLinkToNavigationMenuDraft();
const { workspaceNavigationMenuItems } = useNavigationMenuItemsDraftState();
const navigationMenuItemsDraft = useAtomStateValue(
navigationMenuItemsDraftState,
);
const { currentItems, createItem } = useNavigationMenuItemEditController();
const { openNavigationMenuItemInSidePanel } =
useOpenNavigationMenuItemInSidePanel();
const { objectMetadataItems } = useObjectMetadataItems();
@@ -52,6 +44,12 @@ export const useHandleAddToNavigationDrop = () => {
const handleAddToNavigationDrop = useCallback(
(result: NavigationMenuItemDropResult) => {
// Drag-to-add always targets the workspace sidebar; favorites are added
// by click only.
if (store.get(navigationMenuItemEditSectionState.atom) === 'favorite') {
return;
}
const { source, destination, draggableId } = result;
if (
source.droppableId !== ADD_TO_NAV_SOURCE_DROPPABLE_ID ||
@@ -70,9 +68,6 @@ export const useHandleAddToNavigationDrop = () => {
return;
}
const currentDraft = isDefined(navigationMenuItemsDraft)
? navigationMenuItemsDraft
: workspaceNavigationMenuItems;
const folderId = validateAndExtractWorkspaceFolderId(
destination.droppableId,
);
@@ -88,74 +83,93 @@ export const useHandleAddToNavigationDrop = () => {
);
}
const openEditForNewNavItem = (
newItemId: string,
options: Omit<
// Enter customization mode first: it (re)seeds the workspace draft from
// the live items, so creating beforehand would have the new item
// overwritten. It also bails without enabling the mode when a dashboard
// is mid-edit, so skip the create unless the mode is actually on —
// otherwise the item lands in a draft that is neither shown nor saved.
const addToWorkspaceAndOpenEdit = (
input: NewNavigationMenuItemInput,
position: { targetFolderId: string | null; targetIndex: number },
openOptions: Omit<
Parameters<typeof openNavigationMenuItemInSidePanel>[0],
'itemId'
>,
) => {
enterLayoutCustomizationMode();
openNavigationMenuItemInSidePanel({ ...options, itemId: newItemId });
if (!store.get(isLayoutCustomizationModeEnabledState.atom)) {
return;
}
const newItemId = createItem(input, position);
openNavigationMenuItemInSidePanel({
...openOptions,
itemId: newItemId,
});
};
switch (payload.type) {
case NavigationMenuItemType.FOLDER: {
const newFolderId = addFolderToDraft(
payload.name,
currentDraft,
null,
index,
addToWorkspaceAndOpenEdit(
{
type: NavigationMenuItemType.FOLDER,
name: payload.name,
color: DEFAULT_NAVIGATION_MENU_ITEM_COLOR_FOLDER,
},
{ targetFolderId: null, targetIndex: index },
{
pageTitle: t`Edit folder`,
pageIcon: IconFolder,
focusTitleInput: true,
},
);
openEditForNewNavItem(newFolderId, {
pageTitle: t`Edit folder`,
pageIcon: IconFolder,
focusTitleInput: true,
});
return;
}
case NavigationMenuItemType.LINK: {
const newLinkId = addLinkToDraft(
payload.name || t`Link label`,
payload.link,
currentDraft,
folderId,
index,
addToWorkspaceAndOpenEdit(
{
type: NavigationMenuItemType.LINK,
name: payload.name || t`Link label`,
link: normalizeUrl(payload.link),
color: DEFAULT_NAVIGATION_MENU_ITEM_COLOR_LINK,
},
{ targetFolderId: folderId, targetIndex: index },
{
pageTitle: t`Edit link`,
pageIcon: IconLink,
focusTitleInput: true,
},
);
openEditForNewNavItem(newLinkId, {
pageTitle: t`Edit link`,
pageIcon: IconLink,
focusTitleInput: true,
});
return;
}
case NavigationMenuItemType.OBJECT: {
const objectMetadataIdsInWorkspace =
getObjectMetadataIdsInDraft(currentDraft);
if (objectMetadataIdsInWorkspace.has(payload.objectMetadataId)) {
if (
getObjectMetadataIdsInDraft(currentItems).has(
payload.objectMetadataId,
)
) {
return;
}
const objectMetadataItem = objectMetadataItems.find(
(item) => item.id === payload.objectMetadataId,
);
const newItemId = addObjectToDraft({
objectMetadataId: payload.objectMetadataId,
currentDraft,
targetFolderId: folderId,
targetIndex: index,
color:
payload.iconColor ??
(objectMetadataItem
? getObjectColorWithFallback(objectMetadataItem)
: undefined),
});
openEditForNewNavItem(newItemId, {
pageTitle: objectMetadataItem?.labelPlural ?? payload.label,
pageIcon: objectMetadataItem
? getIcon(objectMetadataItem.icon)
: IconFolder,
});
addToWorkspaceAndOpenEdit(
{
type: NavigationMenuItemType.OBJECT,
targetObjectMetadataId: payload.objectMetadataId,
color:
payload.iconColor ??
(objectMetadataItem
? getObjectColorWithFallback(objectMetadataItem)
: undefined),
},
{ targetFolderId: folderId, targetIndex: index },
{
pageTitle: objectMetadataItem?.labelPlural ?? payload.label,
pageIcon: objectMetadataItem
? getIcon(objectMetadataItem.icon)
: IconFolder,
},
);
return;
}
case NavigationMenuItemType.VIEW: {
@@ -165,62 +179,61 @@ export const useHandleAddToNavigationDrop = () => {
(item) => item.id === view.objectMetadataId,
)
: undefined;
const newItemId = addViewToDraft(
payload.viewId,
currentDraft,
folderId,
index,
viewObjectMetadataItem
? getObjectColorWithFallback(viewObjectMetadataItem)
: undefined,
addToWorkspaceAndOpenEdit(
{
type: NavigationMenuItemType.VIEW,
viewId: payload.viewId,
color: viewObjectMetadataItem
? getObjectColorWithFallback(viewObjectMetadataItem)
: undefined,
},
{ targetFolderId: folderId, targetIndex: index },
{
pageTitle: view?.name ?? payload.label,
pageIcon: view ? getIcon(view.icon) : IconFolder,
},
);
openEditForNewNavItem(newItemId, {
pageTitle: view?.name ?? payload.label,
pageIcon: view ? getIcon(view.icon) : IconFolder,
});
return;
}
case NavigationMenuItemType.RECORD: {
const newItemId = addRecordToDraft(
{
recordId: payload.recordId,
objectMetadataId: payload.objectMetadataId,
objectNameSingular: payload.objectNameSingular,
label: payload.label,
imageUrl: payload.imageUrl,
},
currentDraft,
folderId,
index,
);
if (!isDefined(newItemId)) return;
if (!isDefined(payload.objectMetadataId)) {
return;
}
const objectMetadataItem = objectMetadataItems.find(
(item) => item.id === payload.objectMetadataId,
);
openEditForNewNavItem(newItemId, {
pageTitle: payload.label,
pageIcon: objectMetadataItem
? getIcon(objectMetadataItem.icon)
: IconFolder,
});
addToWorkspaceAndOpenEdit(
{
type: NavigationMenuItemType.RECORD,
targetObjectMetadataId: payload.objectMetadataId,
targetRecordId: payload.recordId,
targetRecordIdentifier: {
id: payload.recordId,
labelIdentifier: payload.label,
imageIdentifier: payload.imageUrl ?? null,
},
},
{ targetFolderId: folderId, targetIndex: index },
{
pageTitle: payload.label,
pageIcon: objectMetadataItem
? getIcon(objectMetadataItem.icon)
: IconFolder,
},
);
return;
}
}
},
[
addFolderToDraft,
addLinkToDraft,
addObjectToDraft,
addRecordToDraft,
addViewToDraft,
createItem,
currentItems,
views,
getIcon,
navigationMenuItemsDraft,
objectMetadataItems,
openNavigationMenuItemInSidePanel,
setOpenNavigationMenuItemFolderIds,
enterLayoutCustomizationMode,
workspaceNavigationMenuItems,
store,
],
);
@@ -1,45 +0,0 @@
import { NavigationMenuItemType } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { useCreateManyNavigationMenuItems } from '@/navigation-menu-item/common/hooks/useCreateManyNavigationMenuItems';
import { useNavigationMenuItemsData } from '@/navigation-menu-item/display/hooks/useNavigationMenuItemsData';
export const useCreateNavigationMenuItemFolder = () => {
const { navigationMenuItems, currentWorkspaceMemberId } =
useNavigationMenuItemsData();
const { createManyNavigationMenuItems } = useCreateManyNavigationMenuItems();
const createNewNavigationMenuItemFolder = async (
name: string,
): Promise<void> => {
if (!name || !currentWorkspaceMemberId) {
return;
}
const topLevelItems = navigationMenuItems.filter(
(item) =>
!isDefined(item.folderId) &&
item.userWorkspaceId === currentWorkspaceMemberId,
);
const minPosition =
topLevelItems.length > 0
? Math.min(...topLevelItems.map((item) => item.position))
: 1;
await createManyNavigationMenuItems([
{
type: NavigationMenuItemType.FOLDER,
name,
targetRecordId: null,
targetObjectMetadataId: null,
userWorkspaceId: currentWorkspaceMemberId,
folderId: null,
position: minPosition - 1,
},
]);
};
return { createNewNavigationMenuItemFolder };
};
@@ -1,7 +1,7 @@
import { NavigationMenuItemType } from 'twenty-shared/types';
import { type NavigationMenuItem } from '~/generated-metadata/graphql';
import { isLayoutCustomizationModeEnabledState } from '@/layout-customization/states/isLayoutCustomizationModeEnabledState';
import { flattenNavigationMenuItemsWithFolderChildren } from '@/navigation-menu-item/common/utils/flattenNavigationMenuItemsWithFolderChildren';
import { getWorkspaceSidebarOrphanItemsInDisplayOrder } from '@/navigation-menu-item/display/utils/getWorkspaceSidebarOrphanItemsInDisplayOrder';
import { objectMetadataItemsSelector } from '@/object-metadata/states/objectMetadataItemsSelector';
import { type EnrichedObjectMetadataItem } from '@/object-metadata/types/EnrichedObjectMetadataItem';
@@ -30,13 +30,6 @@ export const useNavigationMenuItemSectionItems = (): NavigationMenuItem[] => {
const objectMetadataItems = useAtomStateValue(objectMetadataItemsSelector);
const { objectPermissionsByObjectMetadataId } = useObjectPermissions();
const folderChildrenById = new Map(
workspaceNavigationMenuItemsByFolder.map((folder) => [
folder.id,
folder.navigationMenuItems,
]),
);
const flatItems = getWorkspaceSidebarOrphanItemsInDisplayOrder({
workspaceNavigationMenuItems,
workspaceNavigationMenuItemsSorted,
@@ -46,9 +39,8 @@ export const useNavigationMenuItemSectionItems = (): NavigationMenuItem[] => {
includeInaccessibleObjectBackedItems: isLayoutCustomizationModeEnabled,
});
return flatItems.flatMap((item) =>
item.type === NavigationMenuItemType.FOLDER
? [item, ...(folderChildrenById.get(item.id) ?? [])]
: [item],
return flattenNavigationMenuItemsWithFolderChildren(
flatItems,
workspaceNavigationMenuItemsByFolder,
);
};
@@ -1,7 +1,8 @@
import { styled } from '@linaria/react';
import { useLingui } from '@lingui/react/macro';
import { useCallback, useContext, useMemo, useState } from 'react';
import { IconFolder, IconFolderPlus, IconHeartOff } from 'twenty-ui/display';
import { useCallback, useContext, useMemo } from 'react';
import { SidePanelPages } from 'twenty-shared/types';
import { IconHeart, IconHeartOff, IconPlus } from 'twenty-ui/display';
import { LightIconButton } from 'twenty-ui/input';
import { themeCssVariables } from 'twenty-ui/theme-constants';
import { type NavigationMenuItem } from '~/generated-metadata/graphql';
@@ -10,23 +11,23 @@ import { NavigationMenuItemDroppableIds } from '@/navigation-menu-item/common/co
import { NavigationSections } from '@/navigation-menu-item/common/constants/NavigationSections.constants';
import { NavigationMenuItemDragContext } from '@/navigation-menu-item/common/contexts/NavigationMenuItemDragContext';
import { useDeleteManyNavigationMenuItems } from '@/navigation-menu-item/common/hooks/useDeleteManyNavigationMenuItems';
import { isNavigationMenuItemFolderCreatingState } from '@/navigation-menu-item/common/states/isNavigationMenuItemFolderCreatingState';
import { navigationMenuItemEditSectionState } from '@/navigation-menu-item/common/states/navigationMenuItemEditSectionState';
import { pendingInsertionNavigationMenuItemState } from '@/navigation-menu-item/common/states/pendingInsertionNavigationMenuItemState';
import { selectedNavigationMenuItemIdInEditModeState } from '@/navigation-menu-item/common/states/selectedNavigationMenuItemIdInEditModeState';
import { isNavigationMenuItemFolder } from '@/navigation-menu-item/common/utils/isNavigationMenuItemFolder';
import { NavigationMenuItemDisplay } from '@/navigation-menu-item/display/components/NavigationMenuItemDisplay';
import { NavigationMenuItemDroppableSlot } from '@/navigation-menu-item/display/dnd/components/NavigationMenuItemDroppableSlot';
import { NavigationMenuItemSortableItem } from '@/navigation-menu-item/display/dnd/components/NavigationMenuItemSortableItem';
import { useIsDropDisabledForSection } from '@/navigation-menu-item/display/dnd/hooks/useIsDropDisabledForSection';
import { useCreateNavigationMenuItemFolder } from '@/navigation-menu-item/display/folder/hooks/useCreateNavigationMenuItemFolder';
import { useNavigationMenuItemsByFolder } from '@/navigation-menu-item/display/folder/hooks/useNavigationMenuItemsByFolder';
import { useSortedNavigationMenuItems } from '@/navigation-menu-item/display/hooks/useSortedNavigationMenuItems';
import { NavigationMenuItemOrphanDropTarget } from '@/navigation-menu-item/display/sections/components/NavigationMenuItemOrphanDropTarget';
import { NavigationMenuItemSection } from '@/navigation-menu-item/display/sections/components/NavigationMenuItemSection';
import { NavigationDrawerAnimatedCollapseWrapper } from '@/ui/navigation/navigation-drawer/components/NavigationDrawerAnimatedCollapseWrapper';
import { NavigationDrawerInput } from '@/ui/navigation/navigation-drawer/components/NavigationDrawerInput';
import { useNavigateSidePanel } from '@/side-panel/hooks/useNavigateSidePanel';
import { useNavigationSection } from '@/ui/navigation/navigation-drawer/hooks/useNavigationSection';
import { isNavigationSectionOpenFamilyState } from '@/ui/navigation/navigation-drawer/states/isNavigationSectionOpenFamilyState';
import { useAtomFamilyStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomFamilyStateValue';
import { useAtomState } from '@/ui/utilities/state/jotai/hooks/useAtomState';
import { useSetAtomState } from '@/ui/utilities/state/jotai/hooks/useSetAtomState';
const StyledList = styled.div`
display: flex;
@@ -40,10 +41,6 @@ const StyledListItemRow = styled.div`
gap: 0;
`;
const StyledEmptyContainer = styled.div`
width: 100%;
`;
const ORPHAN_DROPPABLE_ID =
NavigationMenuItemDroppableIds.FAVORITE_ORPHAN_NAVIGATION_MENU_ITEMS;
@@ -54,14 +51,16 @@ export const FavoritesSection = () => {
const { isDragging } = useContext(NavigationMenuItemDragContext);
const favoritesDropDisabled = useIsDropDisabledForSection(false);
const [newFolderName, setNewFolderName] = useState('');
const { createNewNavigationMenuItemFolder } =
useCreateNavigationMenuItemFolder();
const [
isNavigationMenuItemFolderCreating,
setIsNavigationMenuItemFolderCreating,
] = useAtomState(isNavigationMenuItemFolderCreatingState);
const { navigateSidePanel } = useNavigateSidePanel();
const setNavigationMenuItemEditSection = useSetAtomState(
navigationMenuItemEditSectionState,
);
const setPendingInsertionNavigationMenuItem = useSetAtomState(
pendingInsertionNavigationMenuItemState,
);
const setSelectedNavigationMenuItemIdInEditMode = useSetAtomState(
selectedNavigationMenuItemIdInEditModeState,
);
const { t } = useLingui();
@@ -90,35 +89,20 @@ export const FavoritesSection = () => {
[topLevelItems],
);
const toggleNewFolder = () => {
const handleAddFavorite = (event?: React.MouseEvent) => {
event?.stopPropagation();
// Expansion is gated on items existing, so this stays collapsed if the user
// cancels and reveals the favorite as soon as it is added.
openNavigationSection();
setIsNavigationMenuItemFolderCreating((current) => !current);
};
const handleSubmitFolderCreation = async (value: string) => {
if (value === '') return;
setIsNavigationMenuItemFolderCreating(false);
setNewFolderName('');
await createNewNavigationMenuItemFolder(value);
return true;
};
const handleClickOutside = async (
_event: MouseEvent | TouchEvent,
value: string,
) => {
if (!value) {
setIsNavigationMenuItemFolderCreating(false);
return;
}
setIsNavigationMenuItemFolderCreating(false);
setNewFolderName('');
await createNewNavigationMenuItemFolder(value);
};
const handleCancelFolderCreation = () => {
setNewFolderName('');
setIsNavigationMenuItemFolderCreating(false);
setNavigationMenuItemEditSection('favorite');
setPendingInsertionNavigationMenuItem(null);
setSelectedNavigationMenuItemIdInEditMode(null);
navigateSidePanel({
page: SidePanelPages.NavigationMenuAddItem,
pageTitle: t`New favorite`,
pageIcon: IconHeart,
resetNavigationStack: true,
});
};
const makeRightOptions = useCallback(
@@ -135,40 +119,20 @@ export const FavoritesSection = () => {
[deleteManyNavigationMenuItems],
);
if (
topLevelItems.length === 0 &&
!isNavigationMenuItemFolderCreating &&
userNavigationMenuItemsByFolder.length === 0
) {
return null;
}
return (
<NavigationMenuItemSection
title={t`Favorites`}
isOpen={isNavigationSectionOpen}
isOpen={topLevelItems.length > 0 && isNavigationSectionOpen}
onToggle={toggleNavigationSection}
rightIcon={
<LightIconButton
Icon={IconFolderPlus}
onClick={toggleNewFolder}
Icon={IconPlus}
onClick={handleAddFavorite}
accent="tertiary"
/>
}
>
{isNavigationMenuItemFolderCreating && (
<NavigationDrawerAnimatedCollapseWrapper>
<NavigationDrawerInput
Icon={IconFolder}
value={newFolderName}
onChange={setNewFolderName}
onSubmit={handleSubmitFolderCreation}
onCancel={handleCancelFolderCreation}
onClickOutside={handleClickOutside}
/>
</NavigationDrawerAnimatedCollapseWrapper>
)}
{topLevelItems.length > 0 ? (
{topLevelItems.length > 0 && (
<StyledList>
{topLevelItems.map((item, index) => (
<StyledListItemRow key={item.id}>
@@ -227,8 +191,6 @@ export const FavoritesSection = () => {
/>
</NavigationMenuItemDroppableSlot>
</StyledList>
) : (
<StyledEmptyContainer style={{ height: isDragging ? '24px' : '1px' }} />
)}
</NavigationMenuItemSection>
);
@@ -1,28 +1,14 @@
import { isLayoutCustomizationModeEnabledState } from '@/layout-customization/states/isLayoutCustomizationModeEnabledState';
import { NavigationSections } from '@/navigation-menu-item/common/constants/NavigationSections.constants';
import { isNavigationMenuItemFolderCreatingState } from '@/navigation-menu-item/common/states/isNavigationMenuItemFolderCreatingState';
import { NavigationMenuItemDndKitProvider } from '@/navigation-menu-item/display/dnd/providers/NavigationMenuItemDndKitProvider';
import { useSortedNavigationMenuItems } from '@/navigation-menu-item/display/hooks/useSortedNavigationMenuItems';
import { FavoritesSection } from '@/navigation-menu-item/display/sections/favorites/components/FavoritesSection';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { AnimatedEaseInOut } from 'twenty-ui/utilities';
export const FavoritesSectionDispatcher = () => {
const { navigationMenuItemsSorted } = useSortedNavigationMenuItems();
const isLayoutCustomizationModeEnabled = useAtomStateValue(
isLayoutCustomizationModeEnabledState,
);
const isNavigationMenuItemFolderCreating = useAtomStateValue(
isNavigationMenuItemFolderCreatingState,
);
const hasFavoritesSectionContent =
isNavigationMenuItemFolderCreating ||
navigationMenuItemsSorted.some((item) => !item.folderId);
if (!hasFavoritesSectionContent) {
return null;
}
return (
<AnimatedEaseInOut isOpen={!isLayoutCustomizationModeEnabled} initial>
@@ -1,65 +0,0 @@
import { NavigationMenuItemType } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { v4 } from 'uuid';
import type { NavigationMenuItem } from '~/generated-metadata/graphql';
import { DEFAULT_NAVIGATION_MENU_ITEM_COLOR_FOLDER } from '@/navigation-menu-item/common/constants/NavigationMenuItemDefaultColorFolder';
import { navigationMenuItemsDraftState } from '@/navigation-menu-item/common/states/navigationMenuItemsDraftState';
import { computeInsertIndexAndPosition } from '@/navigation-menu-item/common/utils/computeInsertIndexAndPosition';
import { useSetAtomState } from '@/ui/utilities/state/jotai/hooks/useSetAtomState';
export const useAddFolderToNavigationMenuDraft = () => {
const setNavigationMenuItemsDraft = useSetAtomState(
navigationMenuItemsDraftState,
);
const addFolderToDraft = (
name: string,
currentDraft: NavigationMenuItem[],
targetFolderId?: string | null,
targetIndex?: number,
): string => {
const folderId = targetFolderId ?? null;
const itemsInFolder = currentDraft.filter(
(item) =>
(item.folderId ?? null) === folderId &&
!isDefined(item.userWorkspaceId),
);
const index = targetIndex ?? itemsInFolder.length;
const { flatIndex, position } = computeInsertIndexAndPosition(
currentDraft,
folderId,
index,
);
const newItemId = v4();
const newItem: NavigationMenuItem = {
__typename: 'NavigationMenuItem',
id: newItemId,
type: NavigationMenuItemType.FOLDER,
viewId: undefined,
targetObjectMetadataId: undefined,
targetRecordId: undefined,
folderId: folderId ?? undefined,
position,
userWorkspaceId: undefined,
name: name.trim(),
color: DEFAULT_NAVIGATION_MENU_ITEM_COLOR_FOLDER,
applicationId: undefined,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
const newDraft = [
...currentDraft.slice(0, flatIndex),
newItem,
...currentDraft.slice(flatIndex),
];
setNavigationMenuItemsDraft(newDraft);
return newItemId;
};
return { addFolderToDraft };
};
@@ -1,32 +0,0 @@
import { navigationMenuItemsDraftState } from '@/navigation-menu-item/common/states/navigationMenuItemsDraftState';
import { isNavigationMenuItemFolder } from '@/navigation-menu-item/common/utils/isNavigationMenuItemFolder';
import { useSetAtomState } from '@/ui/utilities/state/jotai/hooks/useSetAtomState';
export type UpdateFolderInDraftUpdates = {
name?: string;
icon?: string;
};
export const useUpdateFolderInDraft = () => {
const setNavigationMenuItemsDraft = useSetAtomState(
navigationMenuItemsDraftState,
);
const updateFolderInDraft = (
folderId: string,
updates: UpdateFolderInDraftUpdates,
) => {
setNavigationMenuItemsDraft((draft) => {
if (!draft) return draft;
return draft.map((item) => {
if (!isNavigationMenuItemFolder(item) || item.id !== folderId) {
return item;
}
return { ...item, ...updates };
});
});
};
return { updateFolderInDraft };
};
@@ -1,14 +0,0 @@
import { useNavigationMenuItemsDraftState } from '@/navigation-menu-item/edit/hooks/useNavigationMenuItemsDraftState';
import { navigationMenuItemsDraftState } from '@/navigation-menu-item/common/states/navigationMenuItemsDraftState';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
export const useDraftNavigationMenuItems = () => {
const { workspaceNavigationMenuItems } = useNavigationMenuItemsDraftState();
const navigationMenuItemsDraft = useAtomStateValue(
navigationMenuItemsDraftState,
);
const currentDraft = navigationMenuItemsDraft ?? workspaceNavigationMenuItems;
return { currentDraft };
};
@@ -1,17 +0,0 @@
import { useDraftNavigationMenuItems } from '@/navigation-menu-item/edit/hooks/useDraftNavigationMenuItems';
import { isNavigationMenuItemFolder } from '@/navigation-menu-item/common/utils/isNavigationMenuItemFolder';
export const useDraftNavigationMenuItemsAllFolders = () => {
const { currentDraft } = useDraftNavigationMenuItems();
const allFolders =
currentDraft?.filter(isNavigationMenuItemFolder).map((item) => ({
id: item.id,
name: item.name ?? 'Folder',
folderId: item.folderId,
icon: item.icon,
color: item.color,
})) ?? [];
return { allFolders };
};
@@ -1,17 +0,0 @@
import { useNavigationMenuItemsByFolder } from '@/navigation-menu-item/display/folder/hooks/useNavigationMenuItemsByFolder';
export const useDraftNavigationMenuItemsWorkspaceFolders = () => {
const { workspaceNavigationMenuItemsByFolder } =
useNavigationMenuItemsByFolder();
const workspaceFolders = workspaceNavigationMenuItemsByFolder.map(
(folder) => ({
id: folder.id,
name: folder.folderName,
icon: folder.icon,
color: folder.color,
}),
);
return { workspaceFolders };
};
@@ -0,0 +1,177 @@
import { useLingui } from '@lingui/react/macro';
import { type NavigationMenuItemType } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { v4 } from 'uuid';
import { type NavigationMenuItem } from '~/generated-metadata/graphql';
import { useCreateManyNavigationMenuItems } from '@/navigation-menu-item/common/hooks/useCreateManyNavigationMenuItems';
import { useDeleteManyNavigationMenuItems } from '@/navigation-menu-item/common/hooks/useDeleteManyNavigationMenuItems';
import { useUpdateManyNavigationMenuItems } from '@/navigation-menu-item/common/hooks/useUpdateManyNavigationMenuItems';
import { navigationMenuItemEditSectionState } from '@/navigation-menu-item/common/states/navigationMenuItemEditSectionState';
import { navigationMenuItemsDraftState } from '@/navigation-menu-item/common/states/navigationMenuItemsDraftState';
import { buildCreateNavigationMenuItemInput } from '@/navigation-menu-item/common/utils/buildCreateNavigationMenuItemInput';
import { computeInsertIndexAndPosition } from '@/navigation-menu-item/common/utils/computeInsertIndexAndPosition';
import { useNavigationMenuItemsData } from '@/navigation-menu-item/display/hooks/useNavigationMenuItemsData';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { useSetAtomState } from '@/ui/utilities/state/jotai/hooks/useSetAtomState';
export type NewNavigationMenuItemInput = {
type: NavigationMenuItemType;
targetObjectMetadataId?: string | null;
viewId?: string | null;
targetRecordId?: string | null;
targetRecordIdentifier?: NavigationMenuItem['targetRecordIdentifier'];
name?: string | null;
link?: string | null;
color?: string | null;
};
type CreateItemOptions = {
targetFolderId?: string | null;
targetIndex?: number;
};
// The single create/update/delete API for the add/edit side panel. It forks on
// the active section (mirroring useHandleNavigationMenuItemDragAndDrop): the
// workspace section stages changes in the draft (saved on layout exit), while
// the favorite section persists personal items immediately and optimistically.
// On the immediate path the mutation hooks roll back on failure, so errors are
// surfaced here and the rejection is swallowed — callers fire-and-forget.
export const useNavigationMenuItemEditController = () => {
const { t } = useLingui();
const { enqueueErrorSnackBar } = useSnackBar();
const navigationMenuItemEditSection = useAtomStateValue(
navigationMenuItemEditSectionState,
);
const {
navigationMenuItems,
workspaceNavigationMenuItems,
currentWorkspaceMemberId,
} = useNavigationMenuItemsData();
const setNavigationMenuItemsDraft = useSetAtomState(
navigationMenuItemsDraftState,
);
const { createManyNavigationMenuItems } = useCreateManyNavigationMenuItems();
const { updateManyNavigationMenuItems } = useUpdateManyNavigationMenuItems();
const { deleteManyNavigationMenuItems } = useDeleteManyNavigationMenuItems();
const isDraftMode = navigationMenuItemEditSection === 'workspace';
const currentItems = isDraftMode
? workspaceNavigationMenuItems
: navigationMenuItems;
const targetUserWorkspaceId = isDraftMode
? undefined
: currentWorkspaceMemberId;
const createItem = (
input: NewNavigationMenuItemInput,
{ targetFolderId, targetIndex }: CreateItemOptions = {},
): string => {
const folderId = targetFolderId ?? null;
const itemsInFolder = currentItems.filter(
(item) => (item.folderId ?? null) === folderId,
);
const index = targetIndex ?? itemsInFolder.length;
const { flatIndex, position } = computeInsertIndexAndPosition(
currentItems,
folderId,
index,
);
const id = v4();
const newItem: NavigationMenuItem = {
__typename: 'NavigationMenuItem',
id,
type: input.type,
position,
userWorkspaceId: targetUserWorkspaceId,
folderId: folderId ?? undefined,
targetObjectMetadataId: input.targetObjectMetadataId ?? undefined,
targetRecordId: input.targetRecordId ?? undefined,
targetRecordIdentifier: input.targetRecordIdentifier,
viewId: input.viewId ?? undefined,
name: input.name ?? undefined,
link: input.link ?? undefined,
color: input.color ?? undefined,
applicationId: undefined,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
if (isDraftMode) {
setNavigationMenuItemsDraft((draft) => {
const current = draft ?? currentItems;
return [
...current.slice(0, flatIndex),
newItem,
...current.slice(flatIndex),
];
});
return id;
}
if (isDefined(targetUserWorkspaceId)) {
void createManyNavigationMenuItems([
{
...buildCreateNavigationMenuItemInput(newItem, (value) => value),
userWorkspaceId: targetUserWorkspaceId,
},
]).catch(() =>
enqueueErrorSnackBar({ message: t`Couldn't add to favorites` }),
);
} else {
enqueueErrorSnackBar({ message: t`Couldn't add to favorites` });
}
return id;
};
const updateItem = async (
id: string,
update: Partial<NavigationMenuItem>,
): Promise<void> => {
if (isDraftMode) {
setNavigationMenuItemsDraft((draft) =>
isDefined(draft)
? draft.map((item) =>
item.id === id ? { ...item, ...update } : item,
)
: draft,
);
return;
}
try {
await updateManyNavigationMenuItems([{ id, update }]);
} catch {
enqueueErrorSnackBar({ message: t`Couldn't update favorite` });
}
};
const deleteItems = async (ids: string[]): Promise<void> => {
if (isDraftMode) {
setNavigationMenuItemsDraft((draft) =>
isDefined(draft)
? draft.filter((item) => !ids.includes(item.id))
: draft,
);
return;
}
try {
await deleteManyNavigationMenuItems(ids);
} catch {
enqueueErrorSnackBar({ message: t`Couldn't remove favorite` });
}
};
return {
currentItems,
isDraftMode,
targetUserWorkspaceId,
createItem,
updateItem,
deleteItems,
};
};
@@ -0,0 +1,36 @@
import { type NavigationMenuItem } from '~/generated-metadata/graphql';
import { navigationMenuItemEditSectionState } from '@/navigation-menu-item/common/states/navigationMenuItemEditSectionState';
import { flattenNavigationMenuItemsWithFolderChildren } from '@/navigation-menu-item/common/utils/flattenNavigationMenuItemsWithFolderChildren';
import { useNavigationMenuItemsByFolder } from '@/navigation-menu-item/display/folder/hooks/useNavigationMenuItemsByFolder';
import { useNavigationMenuItemSectionItems } from '@/navigation-menu-item/display/hooks/useNavigationMenuItemSectionItems';
import { useSortedNavigationMenuItems } from '@/navigation-menu-item/display/hooks/useSortedNavigationMenuItems';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
// Flat ordered item list (orphans + folders followed by their children) for the
// section the add/edit panel is operating on. The edit page resolves the
// selected item and computes move/organize bounds against this list, so it must
// return personal items in the favorite section and workspace items otherwise.
export const useNavigationMenuItemEditSectionItems =
(): NavigationMenuItem[] => {
const navigationMenuItemEditSection = useAtomStateValue(
navigationMenuItemEditSectionState,
);
const workspaceSectionItems = useNavigationMenuItemSectionItems();
const { navigationMenuItemsSorted } = useSortedNavigationMenuItems();
const { userNavigationMenuItemsByFolder } =
useNavigationMenuItemsByFolder();
if (navigationMenuItemEditSection === 'workspace') {
return workspaceSectionItems;
}
const topLevelItems = navigationMenuItemsSorted.filter(
(item) => !item.folderId,
);
return flattenNavigationMenuItemsWithFolderChildren(
topLevelItems,
userNavigationMenuItemsByFolder,
);
};
@@ -1,184 +1,136 @@
import { isDefined } from 'twenty-shared/utils';
import { navigationMenuItemsDraftState } from '@/navigation-menu-item/common/states/navigationMenuItemsDraftState';
import { getPositionBetween } from '@/navigation-menu-item/common/utils/getPositionBetween';
import { isNavigationMenuItemFolder } from '@/navigation-menu-item/common/utils/isNavigationMenuItemFolder';
import { useSetAtomState } from '@/ui/utilities/state/jotai/hooks/useSetAtomState';
import { useNavigationMenuItemSectionItems } from '@/navigation-menu-item/display/hooks/useNavigationMenuItemSectionItems';
import { useNavigationMenuItemEditController } from '@/navigation-menu-item/edit/hooks/useNavigationMenuItemEditController';
import { useNavigationMenuItemEditSectionItems } from '@/navigation-menu-item/edit/hooks/useNavigationMenuItemEditSectionItems';
export const useNavigationMenuItemMoveRemove = () => {
const setNavigationMenuItemsDraft = useSetAtomState(
navigationMenuItemsDraftState,
);
const items = useNavigationMenuItemSectionItems();
const { currentItems, updateItem, deleteItems } =
useNavigationMenuItemEditController();
const items = useNavigationMenuItemEditSectionItems();
const visibleItemIds = new Set(items.map((item) => item.id));
const moveUp = (navigationMenuItemId: string) => {
setNavigationMenuItemsDraft((draft) => {
if (!draft) {
return draft;
}
const getSortedSiblings = (navigationMenuItemId: string) => {
const currentItem = currentItems.find(
(item) => item.id === navigationMenuItemId,
);
if (!isDefined(currentItem)) {
return null;
}
const folderId = currentItem.folderId ?? null;
return currentItems
.filter(
(item) =>
(item.folderId ?? null) === folderId && visibleItemIds.has(item.id),
)
.sort((a, b) => a.position - b.position);
};
const currentItem = draft.find(
(item) => item.id === navigationMenuItemId,
);
if (!currentItem) {
return draft;
}
const folderId = currentItem.folderId ?? null;
const siblings = draft
.filter(
(item) =>
(item.folderId ?? null) === folderId && visibleItemIds.has(item.id),
)
.sort((a, b) => a.position - b.position);
const currentIndex = siblings.findIndex(
(item) => item.id === navigationMenuItemId,
);
if (currentIndex <= 0) {
return draft;
}
const prev = siblings[currentIndex - 1];
const prevPrev = siblings[currentIndex - 2];
const newPosition = getPositionBetween(prevPrev?.position, prev.position);
return draft.map((item) =>
item.id === navigationMenuItemId
? { ...item, position: newPosition }
: item,
);
const moveUp = async (navigationMenuItemId: string) => {
const siblings = getSortedSiblings(navigationMenuItemId);
if (!isDefined(siblings)) {
return;
}
const currentIndex = siblings.findIndex(
(item) => item.id === navigationMenuItemId,
);
if (currentIndex <= 0) {
return;
}
const prev = siblings[currentIndex - 1];
const prevPrev = siblings[currentIndex - 2];
await updateItem(navigationMenuItemId, {
position: getPositionBetween(prevPrev?.position, prev.position),
});
};
const moveDown = (navigationMenuItemId: string) => {
setNavigationMenuItemsDraft((draft) => {
if (!draft) {
return draft;
}
const currentItem = draft.find(
(item) => item.id === navigationMenuItemId,
);
if (!currentItem) {
return draft;
}
const folderId = currentItem.folderId ?? null;
const siblings = draft
.filter(
(item) =>
(item.folderId ?? null) === folderId && visibleItemIds.has(item.id),
)
.sort((a, b) => a.position - b.position);
const currentIndex = siblings.findIndex(
(item) => item.id === navigationMenuItemId,
);
if (currentIndex < 0 || currentIndex >= siblings.length - 1) {
return draft;
}
const next = siblings[currentIndex + 1];
const nextNext = siblings[currentIndex + 2];
const newPosition = getPositionBetween(next.position, nextNext?.position);
return draft.map((item) =>
item.id === navigationMenuItemId
? { ...item, position: newPosition }
: item,
);
const moveDown = async (navigationMenuItemId: string) => {
const siblings = getSortedSiblings(navigationMenuItemId);
if (!isDefined(siblings)) {
return;
}
const currentIndex = siblings.findIndex(
(item) => item.id === navigationMenuItemId,
);
if (currentIndex < 0 || currentIndex >= siblings.length - 1) {
return;
}
const next = siblings[currentIndex + 1];
const nextNext = siblings[currentIndex + 2];
await updateItem(navigationMenuItemId, {
position: getPositionBetween(next.position, nextNext?.position),
});
};
const remove = (navigationMenuItemId: string) => {
setNavigationMenuItemsDraft((draft) => {
if (!draft) {
return draft;
}
const remove = async (navigationMenuItemId: string) => {
const itemToRemove = currentItems.find(
(item) => item.id === navigationMenuItemId,
);
if (!isDefined(itemToRemove)) {
return;
}
const itemToRemove = draft.find(
(item) => item.id === navigationMenuItemId,
);
if (!itemToRemove) {
return draft;
}
if (isNavigationMenuItemFolder(itemToRemove)) {
const childIds = currentItems
.filter((item) => item.folderId === navigationMenuItemId)
.map((item) => item.id);
await deleteItems([navigationMenuItemId, ...childIds]);
return;
}
const isFolder = isNavigationMenuItemFolder(itemToRemove);
if (isFolder) {
return draft.filter(
(item) =>
item.id !== navigationMenuItemId &&
item.folderId !== navigationMenuItemId,
);
}
return draft.filter((item) => item.id !== navigationMenuItemId);
});
await deleteItems([navigationMenuItemId]);
};
const moveToFolder = (
const moveToFolder = async (
navigationMenuItemId: string,
targetFolderId: string | null,
) => {
setNavigationMenuItemsDraft((draft) => {
if (!draft) {
return draft;
const itemToMove = currentItems.find(
(item) => item.id === navigationMenuItemId,
);
if (!isDefined(itemToMove)) {
return;
}
const isFolder = isNavigationMenuItemFolder(itemToMove);
if (isFolder && targetFolderId === navigationMenuItemId) {
return;
}
// Block moving a folder into one of its own descendants (would orphan the subtree).
if (isFolder && isDefined(targetFolderId)) {
const descendantFolderIds = new Set<string>();
const collectDescendants = (folderId: string) => {
currentItems
.filter(
(item) =>
isNavigationMenuItemFolder(item) && item.folderId === folderId,
)
.forEach((item) => {
descendantFolderIds.add(item.id);
collectDescendants(item.id);
});
};
collectDescendants(navigationMenuItemId);
if (descendantFolderIds.has(targetFolderId)) {
return;
}
}
const itemToMove = draft.find((item) => item.id === navigationMenuItemId);
if (!itemToMove) {
return draft;
}
const itemsInTargetFolder = currentItems.filter((item) =>
targetFolderId === null
? !isDefined(item.folderId)
: item.folderId === targetFolderId,
);
const maxPositionInTarget =
itemsInTargetFolder.length > 0
? Math.max(...itemsInTargetFolder.map((item) => item.position))
: -1;
const isFolder = isNavigationMenuItemFolder(itemToMove);
if (isFolder && targetFolderId === navigationMenuItemId) {
return draft;
}
if (isFolder && isDefined(targetFolderId)) {
const descendantFolderIds = new Set<string>();
const collectDescendants = (folderId: string) => {
draft
?.filter(
(item) =>
isNavigationMenuItemFolder(item) && item.folderId === folderId,
)
.forEach((item) => {
descendantFolderIds.add(item.id);
collectDescendants(item.id);
});
};
collectDescendants(navigationMenuItemId);
if (descendantFolderIds.has(targetFolderId)) {
return draft;
}
}
const itemsInTargetFolder = draft.filter((item) =>
targetFolderId === null
? !isDefined(item.folderId)
: item.folderId === targetFolderId,
);
const maxPositionInTarget =
itemsInTargetFolder.length > 0
? Math.max(...itemsInTargetFolder.map((item) => item.position))
: -1;
const newPosition = maxPositionInTarget + 1;
return draft.map((item) =>
item.id === navigationMenuItemId
? {
...item,
folderId: targetFolderId ?? undefined,
position: newPosition,
}
: item,
);
await updateItem(navigationMenuItemId, {
folderId: targetFolderId ?? null,
position: maxPositionInTarget + 1,
});
};
@@ -0,0 +1,18 @@
import { isNavigationMenuItemFolder } from '@/navigation-menu-item/common/utils/isNavigationMenuItemFolder';
import { useNavigationMenuItemEditController } from '@/navigation-menu-item/edit/hooks/useNavigationMenuItemEditController';
export const useNavigationMenuItemSectionAllFolders = () => {
const { currentItems } = useNavigationMenuItemEditController();
const allFolders = currentItems
.filter(isNavigationMenuItemFolder)
.map((item) => ({
id: item.id,
name: item.name ?? 'Folder',
folderId: item.folderId,
icon: item.icon,
color: item.color,
}));
return { allFolders };
};
@@ -0,0 +1,27 @@
import { navigationMenuItemEditSectionState } from '@/navigation-menu-item/common/states/navigationMenuItemEditSectionState';
import { useNavigationMenuItemsByFolder } from '@/navigation-menu-item/display/folder/hooks/useNavigationMenuItemsByFolder';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
export const useNavigationMenuItemSectionFolders = () => {
const navigationMenuItemEditSection = useAtomStateValue(
navigationMenuItemEditSectionState,
);
const {
workspaceNavigationMenuItemsByFolder,
userNavigationMenuItemsByFolder,
} = useNavigationMenuItemsByFolder();
const folders =
navigationMenuItemEditSection === 'workspace'
? workspaceNavigationMenuItemsByFolder
: userNavigationMenuItemsByFolder;
const sectionFolders = folders.map((folder) => ({
id: folder.id,
name: folder.folderName,
icon: folder.icon,
color: folder.color,
}));
return { sectionFolders };
};
@@ -0,0 +1,56 @@
import { useState } from 'react';
import { navigationMenuItemEditSectionState } from '@/navigation-menu-item/common/states/navigationMenuItemEditSectionState';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
type UseNavigationMenuItemTitleEditParams = {
itemId: string | null;
itemName: string;
defaultLabel: string;
persistName: (name: string) => void;
};
// Title editing for a navigation menu item, section-aware: the workspace
// section writes the in-memory draft on every keystroke (live preview), while
// a personal favorite persists immediately, so keystrokes are buffered locally
// (keyed by item id, self-invalidating on item switch) and committed on save.
export const useNavigationMenuItemTitleEdit = ({
itemId,
itemName,
defaultLabel,
persistName,
}: UseNavigationMenuItemTitleEditParams) => {
const navigationMenuItemEditSection = useAtomStateValue(
navigationMenuItemEditSectionState,
);
const isDraftMode = navigationMenuItemEditSection === 'workspace';
const [localNameEdit, setLocalNameEdit] = useState<{
itemId: string;
text: string;
} | null>(null);
const bufferedName =
localNameEdit?.itemId === itemId ? localNameEdit.text : null;
const value = isDraftMode ? itemName : (bufferedName ?? itemName);
const handleChange = (text: string) => {
if (isDraftMode) {
persistName(text);
} else if (itemId !== null) {
setLocalNameEdit({ itemId, text });
}
};
const handleSave = () => {
const trimmed = value.trim();
const finalName = trimmed.length > 0 ? trimmed : defaultLabel;
if (finalName !== itemName) {
persistName(finalName);
}
if (!isDraftMode) {
setLocalNameEdit(null);
}
};
return { value, handleChange, handleSave };
};
@@ -6,18 +6,20 @@ import { viewsSelector } from '@/views/states/selectors/viewsSelector';
import { ViewKey } from '@/views/types/ViewKey';
import { type NavigationMenuItem } from '~/generated-metadata/graphql';
type NavigationMenuItemDraft = Pick<
type NavigationMenuItemForDedup = Pick<
NavigationMenuItem,
'id' | 'type' | 'viewId' | 'targetObjectMetadataId'
>;
export const useNavigationMenuObjectMetadataFromDraft = (
currentDraft: NavigationMenuItemDraft[],
// Derives which objects/views are already present in the given section's items,
// so the add pickers can hide or disable what's already there.
export const useNavigationMenuObjectMetadataForSection = (
sectionItems: NavigationMenuItemForDedup[],
) => {
const views = useAtomStateValue(viewsSelector);
const objectMetadataIdsInWorkspace =
getObjectMetadataIdsInDraft(currentDraft);
const objectMetadataIdsAlreadyAdded =
getObjectMetadataIdsInDraft(sectionItems);
const objectMetadataIdsWithIndexView = new Set(
views
@@ -29,17 +31,17 @@ export const useNavigationMenuObjectMetadataFromDraft = (
views.map((view) => view.objectMetadataId),
);
const viewIdsInWorkspace = new Set(
currentDraft.flatMap((item) =>
const viewIdsAlreadyAdded = new Set(
sectionItems.flatMap((item) =>
isDefined(item.viewId) ? [item.viewId] : [],
),
);
return {
views,
objectMetadataIdsInWorkspace,
objectMetadataIdsAlreadyAdded,
objectMetadataIdsWithIndexView,
objectMetadataIdsWithAnyView,
viewIdsInWorkspace,
viewIdsAlreadyAdded,
};
};
@@ -1,12 +1,12 @@
import { useNavigationMenuItemSectionItems } from '@/navigation-menu-item/display/hooks/useNavigationMenuItemSectionItems';
import { selectedNavigationMenuItemIdInEditModeState } from '@/navigation-menu-item/common/states/selectedNavigationMenuItemIdInEditModeState';
import { useNavigationMenuItemEditSectionItems } from '@/navigation-menu-item/edit/hooks/useNavigationMenuItemEditSectionItems';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
export const useSelectedNavigationMenuItemEditItem = () => {
const selectedNavigationMenuItemIdInEditMode = useAtomStateValue(
selectedNavigationMenuItemIdInEditModeState,
);
const items = useNavigationMenuItemSectionItems();
const items = useNavigationMenuItemEditSectionItems();
const selectedItem = selectedNavigationMenuItemIdInEditMode
? items.find((item) => item.id === selectedNavigationMenuItemIdInEditMode)
@@ -1,30 +0,0 @@
import { navigationMenuItemsDraftState } from '@/navigation-menu-item/common/states/navigationMenuItemsDraftState';
import { useSetAtomState } from '@/ui/utilities/state/jotai/hooks/useSetAtomState';
export type UpdateNavigationMenuItemInDraftUpdates = {
color?: string;
name?: string;
icon?: string;
link?: string;
};
export const useUpdateNavigationMenuItemInDraft = () => {
const setNavigationMenuItemsDraft = useSetAtomState(
navigationMenuItemsDraftState,
);
const updateNavigationMenuItemInDraft = (
navigationMenuItemId: string,
updates: UpdateNavigationMenuItemInDraftUpdates,
) => {
setNavigationMenuItemsDraft((draft) => {
if (!draft) return draft;
return draft.map((item) =>
item.id === navigationMenuItemId ? { ...item, ...updates } : item,
);
});
};
return { updateNavigationMenuItemInDraft };
};
@@ -1,68 +0,0 @@
import { NavigationMenuItemType } from 'twenty-shared/types';
import { isDefined, normalizeUrl } from 'twenty-shared/utils';
import { v4 } from 'uuid';
import type { NavigationMenuItem } from '~/generated-metadata/graphql';
import { DEFAULT_NAVIGATION_MENU_ITEM_COLOR_LINK } from '@/navigation-menu-item/common/constants/NavigationMenuItemDefaultColorLink';
import { navigationMenuItemsDraftState } from '@/navigation-menu-item/common/states/navigationMenuItemsDraftState';
import { computeInsertIndexAndPosition } from '@/navigation-menu-item/common/utils/computeInsertIndexAndPosition';
import { useSetAtomState } from '@/ui/utilities/state/jotai/hooks/useSetAtomState';
export const useAddLinkToNavigationMenuDraft = () => {
const setNavigationMenuItemsDraft = useSetAtomState(
navigationMenuItemsDraftState,
);
const addLinkToDraft = (
label: string,
url: string,
currentDraft: NavigationMenuItem[],
targetFolderId?: string | null,
targetIndex?: number,
): string => {
const normalizedUrl = normalizeUrl(url);
const folderId = targetFolderId ?? null;
const itemsInFolder = currentDraft.filter(
(item) =>
(item.folderId ?? null) === folderId &&
!isDefined(item.userWorkspaceId),
);
const index = targetIndex ?? itemsInFolder.length;
const { flatIndex, position } = computeInsertIndexAndPosition(
currentDraft,
folderId,
index,
);
const newItemId = v4();
const newItem: NavigationMenuItem = {
__typename: 'NavigationMenuItem',
id: newItemId,
type: NavigationMenuItemType.LINK,
viewId: undefined,
targetObjectMetadataId: undefined,
targetRecordId: undefined,
folderId: folderId ?? undefined,
position,
userWorkspaceId: undefined,
name: label.trim() || 'Link',
link: normalizedUrl,
color: DEFAULT_NAVIGATION_MENU_ITEM_COLOR_LINK,
applicationId: undefined,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
const newDraft = [
...currentDraft.slice(0, flatIndex),
newItem,
...currentDraft.slice(flatIndex),
];
setNavigationMenuItemsDraft(newDraft);
return newItemId;
};
return { addLinkToDraft };
};
@@ -1,33 +0,0 @@
import { navigationMenuItemsDraftState } from '@/navigation-menu-item/common/states/navigationMenuItemsDraftState';
import { useSetAtomState } from '@/ui/utilities/state/jotai/hooks/useSetAtomState';
import { isNavigationMenuItemLink } from '@/navigation-menu-item/common/utils/isNavigationMenuItemLink';
export const useUpdateLinkInDraft = () => {
const setNavigationMenuItemsDraft = useSetAtomState(
navigationMenuItemsDraftState,
);
const updateLinkInDraft = (
linkId: string,
updates: { name?: string; link?: string },
) => {
setNavigationMenuItemsDraft((draft) => {
if (!draft) return draft;
return draft.map((item) => {
if (item.id !== linkId || !isNavigationMenuItemLink(item)) return item;
const updated = { ...item };
if (updates.name !== undefined) {
updated.name = updates.name.trim() || 'Link';
}
if (updates.link !== undefined && updates.link.trim() !== '') {
updated.link = updates.link.trim();
}
return updated;
});
});
};
return { updateLinkInDraft };
};
@@ -1,73 +0,0 @@
import { NavigationMenuItemType } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { v4 } from 'uuid';
import type { NavigationMenuItem } from '~/generated-metadata/graphql';
import { navigationMenuItemsDraftState } from '@/navigation-menu-item/common/states/navigationMenuItemsDraftState';
import { computeInsertIndexAndPosition } from '@/navigation-menu-item/common/utils/computeInsertIndexAndPosition';
import { useSetAtomState } from '@/ui/utilities/state/jotai/hooks/useSetAtomState';
export type AddObjectToNavigationMenuDraftParams = {
objectMetadataId: string;
currentDraft: NavigationMenuItem[];
targetFolderId?: string | null;
targetIndex?: number;
color?: string | null;
};
export const useAddObjectToNavigationMenuDraft = () => {
const setNavigationMenuItemsDraft = useSetAtomState(
navigationMenuItemsDraftState,
);
const addObjectToDraft = ({
objectMetadataId,
currentDraft,
targetFolderId,
targetIndex,
color,
}: AddObjectToNavigationMenuDraftParams): string => {
const folderId = targetFolderId ?? null;
const itemsInFolder = currentDraft.filter(
(item) =>
(item.folderId ?? null) === folderId &&
!isDefined(item.userWorkspaceId),
);
const index = targetIndex ?? itemsInFolder.length;
const { flatIndex, position } = computeInsertIndexAndPosition(
currentDraft,
folderId,
index,
);
const newItemId = v4();
const newItem: NavigationMenuItem = {
__typename: 'NavigationMenuItem',
id: newItemId,
type: NavigationMenuItemType.OBJECT,
viewId: undefined,
targetObjectMetadataId: objectMetadataId,
position,
userWorkspaceId: undefined,
targetRecordId: undefined,
folderId: folderId ?? undefined,
name: undefined,
applicationId: undefined,
color,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
const newDraft = [
...currentDraft.slice(0, flatIndex),
newItem,
...currentDraft.slice(flatIndex),
];
setNavigationMenuItemsDraft(newDraft);
return newItemId;
};
return { addObjectToDraft };
};
@@ -1,92 +0,0 @@
import { v4 } from 'uuid';
import { NavigationMenuItemType } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import type { NavigationMenuItem } from '~/generated-metadata/graphql';
import { navigationMenuItemsDraftState } from '@/navigation-menu-item/common/states/navigationMenuItemsDraftState';
import { objectMetadataItemsSelector } from '@/object-metadata/states/objectMetadataItemsSelector';
import { useSetAtomState } from '@/ui/utilities/state/jotai/hooks/useSetAtomState';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { computeInsertIndexAndPosition } from '@/navigation-menu-item/common/utils/computeInsertIndexAndPosition';
type SearchRecord = {
recordId: string;
objectNameSingular: string;
label: string;
imageUrl?: string | null;
};
type SearchRecordWithOptionalMetadataId = SearchRecord & {
objectMetadataId?: string;
};
export const useAddRecordToNavigationMenuDraft = () => {
const setNavigationMenuItemsDraft = useSetAtomState(
navigationMenuItemsDraftState,
);
const objectMetadataItems = useAtomStateValue(objectMetadataItemsSelector);
const addRecordToDraft = (
searchRecord: SearchRecordWithOptionalMetadataId,
currentDraft: NavigationMenuItem[],
targetFolderId?: string | null,
targetIndex?: number,
): string | undefined => {
const objectMetadataId =
searchRecord.objectMetadataId ??
objectMetadataItems.find(
(item) => item.nameSingular === searchRecord.objectNameSingular,
)?.id;
if (!isDefined(objectMetadataId)) {
return undefined;
}
const folderId = targetFolderId ?? null;
const itemsInFolder = currentDraft.filter(
(item) =>
(item.folderId ?? null) === folderId &&
!isDefined(item.userWorkspaceId),
);
const index = targetIndex ?? itemsInFolder.length;
const { flatIndex, position } = computeInsertIndexAndPosition(
currentDraft,
folderId,
index,
);
const newItemId = v4();
const newItem: NavigationMenuItem = {
__typename: 'NavigationMenuItem',
id: newItemId,
type: NavigationMenuItemType.RECORD,
viewId: undefined,
targetObjectMetadataId: objectMetadataId,
targetRecordId: searchRecord.recordId,
targetRecordIdentifier: {
id: searchRecord.recordId,
labelIdentifier: searchRecord.label,
imageIdentifier: searchRecord.imageUrl ?? null,
},
position,
userWorkspaceId: undefined,
folderId: folderId ?? undefined,
name: undefined,
applicationId: undefined,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
const newDraft = [
...currentDraft.slice(0, flatIndex),
newItem,
...currentDraft.slice(flatIndex),
];
setNavigationMenuItemsDraft(newDraft);
return newItemId;
};
return { addRecordToDraft };
};
@@ -2,8 +2,8 @@ import { CommandMenuItemDropdown } from '@/command-menu/components/CommandMenuIt
import { useUpdateMetadataStoreDraft } from '@/metadata-store/hooks/useUpdateMetadataStoreDraft';
import { type FlatObjectMetadataItem } from '@/metadata-store/types/FlatObjectMetadataItem';
import { isValidObjectNavigationMenuItem } from '@/navigation-menu-item/common/utils/isValidObjectNavigationMenuItem';
import { useNavigationMenuItemEditController } from '@/navigation-menu-item/edit/hooks/useNavigationMenuItemEditController';
import { useSelectedNavigationMenuItemEditItem } from '@/navigation-menu-item/edit/hooks/useSelectedNavigationMenuItemEditItem';
import { useUpdateNavigationMenuItemInDraft } from '@/navigation-menu-item/edit/hooks/useUpdateNavigationMenuItemInDraft';
import { ThemeColorPickerMenu } from '@/ui/input/components/ThemeColorPickerMenu';
import { DropdownContent } from '@/ui/layout/dropdown/components/DropdownContent';
import { useCloseDropdown } from '@/ui/layout/dropdown/hooks/useCloseDropdown';
@@ -32,8 +32,7 @@ export const SidePanelEditColorOption = ({
color,
}: SidePanelEditColorOptionProps) => {
const { t } = useLingui();
const { updateNavigationMenuItemInDraft } =
useUpdateNavigationMenuItemInDraft();
const { updateItem, isDraftMode } = useNavigationMenuItemEditController();
const { closeDropdown } = useCloseDropdown();
const { updateInDraft, applyChanges } = useUpdateMetadataStoreDraft();
const { selectedItem } = useSelectedNavigationMenuItemEditItem();
@@ -42,11 +41,11 @@ export const SidePanelEditColorOption = ({
const colorLabel = DEFAULT_COLOR_LABELS[themeColor] ?? capitalize(themeColor);
const handleSelectColor = (selectedColor: ThemeColor) => {
updateNavigationMenuItemInDraft(navigationMenuItemId, {
color: selectedColor,
});
void updateItem(navigationMenuItemId, { color: selectedColor });
if (isValidObjectNavigationMenuItem(selectedItem)) {
// A personal favorite's color is item-local; only workspace customization
// recolors the shared object metadata.
if (isDraftMode && isValidObjectNavigationMenuItem(selectedItem)) {
updateInDraft('objectMetadataItems', [
{
id: selectedItem.targetObjectMetadataId,
@@ -4,6 +4,7 @@ import { useState } from 'react';
import { ensureAbsoluteUrl } from 'twenty-shared/utils';
import { type NavigationMenuItem } from '~/generated-metadata/graphql';
import { navigationMenuItemEditSectionState } from '@/navigation-menu-item/common/states/navigationMenuItemEditSectionState';
import { extractDomainFromUrl } from '@/navigation-menu-item/display/link/utils/extractDomainFromUrl';
import {
type OrganizeActionsProps,
@@ -14,6 +15,7 @@ import { getOrganizeActionsSelectableItemIds } from '@/navigation-menu-item/edit
import { SidePanelGroup } from '@/side-panel/components/SidePanelGroup';
import { SidePanelList } from '@/side-panel/components/SidePanelList';
import { TextInput } from '@/ui/input/components/TextInput';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
type SidePanelEditLinkItemViewProps = OrganizeActionsProps & {
selectedItem: NavigationMenuItem;
@@ -39,6 +41,10 @@ export const SidePanelEditLinkItemView = ({
showMoveToFolder = false,
}: SidePanelEditLinkItemViewProps) => {
const { t } = useLingui();
const navigationMenuItemEditSection = useAtomStateValue(
navigationMenuItemEditSectionState,
);
const isDraftMode = navigationMenuItemEditSection === 'workspace';
const [urlEditInput, setUrlEditInput] = useState('');
const [lastAutoSetName, setLastAutoSetName] = useState<string | null>(null);
@@ -57,7 +63,9 @@ export const SidePanelEditLinkItemView = ({
const handleUrlChange = (value: string) => {
setUrlEditInput(value);
if (!canAutoUpdateName) return;
// A personal favorite persists immediately, so the auto-derived name is
// applied on blur (see handleUrlBlur) rather than on every keystroke.
if (!isDraftMode || !canAutoUpdateName) return;
const trimmed = value.trim();
if (!isNonEmptyString(trimmed)) return;
const domain = extractDomainFromUrl(ensureAbsoluteUrl(trimmed));
@@ -69,10 +77,21 @@ export const SidePanelEditLinkItemView = ({
const handleUrlBlur = (event: React.FocusEvent<HTMLInputElement>) => {
const value = event.target.value.trim();
if (isNonEmptyString(value)) {
onUpdateLink(selectedItem.id, { link: ensureAbsoluteUrl(value) });
setUrlEditInput('');
if (!isNonEmptyString(value)) {
return;
}
const absoluteUrl = ensureAbsoluteUrl(value);
const updates: { link: string; name?: string } = { link: absoluteUrl };
const autoName =
!isDraftMode && canAutoUpdateName
? extractDomainFromUrl(absoluteUrl)
: undefined;
if (autoName !== undefined) {
setLastAutoSetName(autoName);
updates.name = autoName;
}
onUpdateLink(selectedItem.id, updates);
setUrlEditInput('');
};
return (
@@ -4,7 +4,7 @@ import { IconApps } from 'twenty-ui/display';
import { SidePanelGroup } from '@/side-panel/components/SidePanelGroup';
import { CommandMenuItem } from '@/command-menu/components/CommandMenuItem';
import { useDraftNavigationMenuItems } from '@/navigation-menu-item/edit/hooks/useDraftNavigationMenuItems';
import { useNavigationMenuItemEditController } from '@/navigation-menu-item/edit/hooks/useNavigationMenuItemEditController';
import { useSelectedNavigationMenuItemEditItem } from '@/navigation-menu-item/edit/hooks/useSelectedNavigationMenuItemEditItem';
import { SelectableListItem } from '@/ui/layout/selectable-list/components/SelectableListItem';
import { useQuery } from '@apollo/client/react';
@@ -20,14 +20,13 @@ export const SidePanelEditOwnerSection = ({
const { t } = useLingui();
const { selectedItem } = useSelectedNavigationMenuItemEditItem();
const { currentDraft } = useDraftNavigationMenuItems();
const { currentItems } = useNavigationMenuItemEditController();
const applicationIdFromDraft =
isDefined(selectedItem) && isDefined(currentDraft)
? currentDraft.find((item) => item.id === selectedItem.id)?.applicationId
: undefined;
const applicationIdFromSection = isDefined(selectedItem)
? currentItems.find((item) => item.id === selectedItem.id)?.applicationId
: undefined;
const applicationId = applicationIdProp ?? applicationIdFromDraft;
const applicationId = applicationIdProp ?? applicationIdFromSection;
const { data } = useQuery(FindOneApplicationDocument, {
variables: { id: applicationId ?? '' },
@@ -1,13 +1,13 @@
import { CommandMenuItem } from '@/command-menu/components/CommandMenuItem';
import { pendingInsertionNavigationMenuItemState } from '@/navigation-menu-item/common/states/pendingInsertionNavigationMenuItemState';
import { useDraftNavigationMenuItemsAllFolders } from '@/navigation-menu-item/edit/hooks/useDraftNavigationMenuItemsAllFolders';
import { useNavigationMenuItemsDraftState } from '@/navigation-menu-item/edit/hooks/useNavigationMenuItemsDraftState';
import { useNavigationMenuItemSectionAllFolders } from '@/navigation-menu-item/edit/hooks/useNavigationMenuItemSectionAllFolders';
import { useNavigationMenuItemEditController } from '@/navigation-menu-item/edit/hooks/useNavigationMenuItemEditController';
import { useSelectedNavigationMenuItemEditItem } from '@/navigation-menu-item/edit/hooks/useSelectedNavigationMenuItemEditItem';
import { NavigationMenuItemType } from 'twenty-shared/types';
import { selectedNavigationMenuItemIdInEditModeState } from '@/navigation-menu-item/common/states/selectedNavigationMenuItemIdInEditModeState';
import { useSelectedNavigationMenuItemEditItemLabel } from '@/navigation-menu-item/edit/hooks/useSelectedNavigationMenuItemEditItemLabel';
import { useUpdateLinkInDraft } from '@/navigation-menu-item/edit/link/hooks/useUpdateLinkInDraft';
import { buildNavigationMenuLinkUpdate } from '@/navigation-menu-item/common/utils/buildNavigationMenuLinkUpdate';
import { SidePanelEditColorOption } from '@/navigation-menu-item/edit/side-panel/components/SidePanelEditColorOption';
import { SidePanelEditLinkItemView } from '@/navigation-menu-item/edit/side-panel/components/SidePanelEditLinkItemView';
import { SidePanelEditObjectViewBase } from '@/navigation-menu-item/edit/side-panel/components/SidePanelEditObjectViewBase';
@@ -49,7 +49,7 @@ export const SidePanelNavigationMenuItemEditPage = () => {
const { selectedItemLabel } = useSelectedNavigationMenuItemEditItemLabel();
const { selectedItem } = useSelectedNavigationMenuItemEditItem();
const selectedItemType = selectedItem?.type ?? null;
const { allFolders } = useDraftNavigationMenuItemsAllFolders();
const { allFolders } = useNavigationMenuItemSectionAllFolders();
const { navigateToSidePanelSubPage } = useSidePanelSubPageHistory();
const openFolderPicker = () =>
@@ -65,8 +65,7 @@ export const SidePanelNavigationMenuItemEditPage = () => {
onAddAfter,
} = useNavigationMenuItemEditOrganizeActions();
const { updateLinkInDraft } = useUpdateLinkInDraft();
const { workspaceNavigationMenuItems } = useNavigationMenuItemsDraftState();
const { currentItems, updateItem } = useNavigationMenuItemEditController();
const setPendingInsertionNavigationMenuItem = useSetAtomState(
pendingInsertionNavigationMenuItemState,
);
@@ -83,7 +82,7 @@ export const SidePanelNavigationMenuItemEditPage = () => {
if (!selectedItem || selectedItem.type !== NavigationMenuItemType.FOLDER) {
return;
}
const folderItemCount = workspaceNavigationMenuItems.filter(
const folderItemCount = currentItems.filter(
(item) => item.folderId === selectedItem.id,
).length;
setPendingInsertionNavigationMenuItem({
@@ -143,9 +142,9 @@ export const SidePanelNavigationMenuItemEditPage = () => {
<SidePanelEditLinkItemView
key={selectedItem.id}
selectedItem={selectedItem}
onUpdateLink={(linkId, updates) =>
updateLinkInDraft(linkId, updates)
}
onUpdateLink={(linkId, updates) => {
void updateItem(linkId, buildNavigationMenuLinkUpdate(updates));
}}
onOpenFolderPicker={openFolderPicker}
showMoveToFolder={canMoveToOtherFolder}
canMoveUp={canMoveUp}
@@ -1,16 +1,16 @@
import { useState } from 'react';
import { NavigationMenuItemType } from 'twenty-shared/types';
import { getObjectColorWithFallback } from '@/object-metadata/utils/getObjectColorWithFallback';
import { useDraftNavigationMenuItems } from '@/navigation-menu-item/edit/hooks/useDraftNavigationMenuItems';
import { useNavigationMenuObjectMetadataFromDraft } from '@/navigation-menu-item/edit/hooks/useNavigationMenuObjectMetadataFromDraft';
import { useOpenNavigationMenuItemInSidePanel } from '@/navigation-menu-item/edit/hooks/useOpenNavigationMenuItemInSidePanel';
import { pendingInsertionNavigationMenuItemState } from '@/navigation-menu-item/common/states/pendingInsertionNavigationMenuItemState';
import { useAddObjectToNavigationMenuDraft } from '@/navigation-menu-item/edit/object/hooks/useAddObjectToNavigationMenuDraft';
import { useNavigationMenuItemEditController } from '@/navigation-menu-item/edit/hooks/useNavigationMenuItemEditController';
import { useNavigationMenuObjectMetadataForSection } from '@/navigation-menu-item/edit/hooks/useNavigationMenuObjectMetadataForSection';
import { useOpenNavigationMenuItemInSidePanel } from '@/navigation-menu-item/edit/hooks/useOpenNavigationMenuItemInSidePanel';
import { SidePanelObjectPickerSubView } from '@/navigation-menu-item/edit/side-panel/components/SidePanelObjectPickerSubView';
import { getAvailableObjectMetadataForNewSidebarItem } from '@/navigation-menu-item/edit/side-panel/utils/getAvailableObjectMetadataForNewSidebarItem';
import { useFilteredObjectMetadataItems } from '@/object-metadata/hooks/useFilteredObjectMetadataItems';
import { useObjectMetadataItems } from '@/object-metadata/hooks/useObjectMetadataItems';
import { type EnrichedObjectMetadataItem } from '@/object-metadata/types/EnrichedObjectMetadataItem';
import { getObjectColorWithFallback } from '@/object-metadata/utils/getObjectColorWithFallback';
import { useSidePanelSubPageHistory } from '@/side-panel/hooks/useSidePanelSubPageHistory';
import { SidePanelSubPages } from '@/side-panel/types/SidePanelSubPages';
import { useAtomState } from '@/ui/utilities/state/jotai/hooks/useAtomState';
@@ -22,9 +22,8 @@ export const SidePanelNewSidebarItemObjectSubPage = () => {
const { getIcon } = useIcons();
const [objectSearchInput, setObjectSearchInput] = useState('');
const { currentDraft } = useDraftNavigationMenuItems();
const { currentItems, createItem } = useNavigationMenuItemEditController();
const { objectMetadataItems } = useObjectMetadataItems();
const { addObjectToDraft } = useAddObjectToNavigationMenuDraft();
const { openNavigationMenuItemInSidePanel } =
useOpenNavigationMenuItemInSidePanel();
const { activeNonSystemObjectMetadataItems } =
@@ -36,8 +35,8 @@ export const SidePanelNewSidebarItemObjectSubPage = () => {
const {
views,
objectMetadataIdsWithIndexView,
objectMetadataIdsInWorkspace,
} = useNavigationMenuObjectMetadataFromDraft(currentDraft);
objectMetadataIdsAlreadyAdded,
} = useNavigationMenuObjectMetadataForSection(currentItems);
const objectMetadataIdsWithDisplayableViews = new Set(
views
@@ -56,16 +55,20 @@ export const SidePanelNewSidebarItemObjectSubPage = () => {
const handleSelectObject = (
objectMetadataItem: EnrichedObjectMetadataItem,
) => {
if (objectMetadataIdsInWorkspace.has(objectMetadataItem.id)) {
if (objectMetadataIdsAlreadyAdded.has(objectMetadataItem.id)) {
return;
}
const itemId = addObjectToDraft({
objectMetadataId: objectMetadataItem.id,
currentDraft,
targetFolderId: pendingInsertionNavigationMenuItem?.folderId,
targetIndex: pendingInsertionNavigationMenuItem?.position,
color: getObjectColorWithFallback(objectMetadataItem),
});
const itemId = createItem(
{
type: NavigationMenuItemType.OBJECT,
targetObjectMetadataId: objectMetadataItem.id,
color: getObjectColorWithFallback(objectMetadataItem),
},
{
targetFolderId: pendingInsertionNavigationMenuItem?.folderId,
targetIndex: pendingInsertionNavigationMenuItem?.position,
},
);
setPendingInsertionNavigationMenuItem(null);
openNavigationMenuItemInSidePanel({
itemId,
@@ -1,19 +1,19 @@
import { useLingui } from '@lingui/react/macro';
import { useState } from 'react';
import { NavigationMenuItemType } from 'twenty-shared/types';
import { getObjectColorWithFallback } from '@/object-metadata/utils/getObjectColorWithFallback';
import { useDraftNavigationMenuItems } from '@/navigation-menu-item/edit/hooks/useDraftNavigationMenuItems';
import { useNavigationMenuObjectMetadataFromDraft } from '@/navigation-menu-item/edit/hooks/useNavigationMenuObjectMetadataFromDraft';
import { useOpenNavigationMenuItemInSidePanel } from '@/navigation-menu-item/edit/hooks/useOpenNavigationMenuItemInSidePanel';
import { pendingInsertionNavigationMenuItemState } from '@/navigation-menu-item/common/states/pendingInsertionNavigationMenuItemState';
import { useAddObjectToNavigationMenuDraft } from '@/navigation-menu-item/edit/object/hooks/useAddObjectToNavigationMenuDraft';
import { useNavigationMenuItemEditController } from '@/navigation-menu-item/edit/hooks/useNavigationMenuItemEditController';
import { useNavigationMenuObjectMetadataForSection } from '@/navigation-menu-item/edit/hooks/useNavigationMenuObjectMetadataForSection';
import { useOpenNavigationMenuItemInSidePanel } from '@/navigation-menu-item/edit/hooks/useOpenNavigationMenuItemInSidePanel';
import { SidePanelSystemObjectPickerSubView } from '@/navigation-menu-item/edit/side-panel/components/SidePanelSystemObjectPickerSubView';
import { getAvailableObjectMetadataForNewSidebarItem } from '@/navigation-menu-item/edit/side-panel/utils/getAvailableObjectMetadataForNewSidebarItem';
import { useFilteredObjectMetadataItems } from '@/object-metadata/hooks/useFilteredObjectMetadataItems';
import { useObjectMetadataItems } from '@/object-metadata/hooks/useObjectMetadataItems';
import { type EnrichedObjectMetadataItem } from '@/object-metadata/types/EnrichedObjectMetadataItem';
import { getObjectColorWithFallback } from '@/object-metadata/utils/getObjectColorWithFallback';
import { useAtomState } from '@/ui/utilities/state/jotai/hooks/useAtomState';
import { ViewKey } from '@/views/types/ViewKey';
import { useState } from 'react';
import { useIcons } from 'twenty-ui/display';
export const SidePanelNewSidebarItemObjectSystemPickerSubPage = () => {
@@ -21,9 +21,8 @@ export const SidePanelNewSidebarItemObjectSystemPickerSubPage = () => {
const { getIcon } = useIcons();
const [searchValue, setSearchValue] = useState('');
const { currentDraft } = useDraftNavigationMenuItems();
const { currentItems, createItem } = useNavigationMenuItemEditController();
const { objectMetadataItems } = useObjectMetadataItems();
const { addObjectToDraft } = useAddObjectToNavigationMenuDraft();
const { openNavigationMenuItemInSidePanel } =
useOpenNavigationMenuItemInSidePanel();
const { activeNonSystemObjectMetadataItems } =
@@ -35,8 +34,8 @@ export const SidePanelNewSidebarItemObjectSystemPickerSubPage = () => {
const {
views,
objectMetadataIdsWithIndexView,
objectMetadataIdsInWorkspace,
} = useNavigationMenuObjectMetadataFromDraft(currentDraft);
objectMetadataIdsAlreadyAdded,
} = useNavigationMenuObjectMetadataForSection(currentItems);
const objectMetadataIdsWithDisplayableViews = new Set(
views
@@ -55,16 +54,20 @@ export const SidePanelNewSidebarItemObjectSystemPickerSubPage = () => {
const handleSelectObject = (
objectMetadataItem: EnrichedObjectMetadataItem,
) => {
if (objectMetadataIdsInWorkspace.has(objectMetadataItem.id)) {
if (objectMetadataIdsAlreadyAdded.has(objectMetadataItem.id)) {
return;
}
const itemId = addObjectToDraft({
objectMetadataId: objectMetadataItem.id,
currentDraft,
targetFolderId: pendingInsertionNavigationMenuItem?.folderId,
targetIndex: pendingInsertionNavigationMenuItem?.position,
color: getObjectColorWithFallback(objectMetadataItem),
});
const itemId = createItem(
{
type: NavigationMenuItemType.OBJECT,
targetObjectMetadataId: objectMetadataItem.id,
color: getObjectColorWithFallback(objectMetadataItem),
},
{
targetFolderId: pendingInsertionNavigationMenuItem?.folderId,
targetIndex: pendingInsertionNavigationMenuItem?.position,
},
);
setPendingInsertionNavigationMenuItem(null);
openNavigationMenuItemInSidePanel({
itemId,
@@ -1,18 +1,18 @@
import { Avatar, IconFolder, useIcons } from 'twenty-ui/display';
import { useAddRecordToNavigationMenuDraft } from '@/navigation-menu-item/edit/record/hooks/useAddRecordToNavigationMenuDraft';
import { useDraftNavigationMenuItems } from '@/navigation-menu-item/edit/hooks/useDraftNavigationMenuItems';
import { useOpenNavigationMenuItemInSidePanel } from '@/navigation-menu-item/edit/hooks/useOpenNavigationMenuItemInSidePanel';
import { pendingInsertionNavigationMenuItemState } from '@/navigation-menu-item/common/states/pendingInsertionNavigationMenuItemState';
import type { AddToNavigationDragPayload } from '@/navigation-menu-item/common/types/add-to-navigation-drag-payload';
import { useObjectMetadataItems } from '@/object-metadata/hooks/useObjectMetadataItems';
import { SidePanelItemWithAddToNavigationDrag } from '@/side-panel/components/SidePanelItemWithAddToNavigationDrag';
import { SelectableListItem } from '@/ui/layout/selectable-list/components/SelectableListItem';
import { useAtomState } from '@/ui/utilities/state/jotai/hooks/useAtomState';
import { Avatar, useIcons } from 'twenty-ui/display';
import {
CoreObjectNameSingular,
NavigationMenuItemType,
} from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { pendingInsertionNavigationMenuItemState } from '@/navigation-menu-item/common/states/pendingInsertionNavigationMenuItemState';
import type { AddToNavigationDragPayload } from '@/navigation-menu-item/common/types/add-to-navigation-drag-payload';
import { useNavigationMenuItemEditController } from '@/navigation-menu-item/edit/hooks/useNavigationMenuItemEditController';
import { useOpenNavigationMenuItemInSidePanel } from '@/navigation-menu-item/edit/hooks/useOpenNavigationMenuItemInSidePanel';
import { useObjectMetadataItems } from '@/object-metadata/hooks/useObjectMetadataItems';
import { SidePanelItemWithAddToNavigationDrag } from '@/side-panel/components/SidePanelItemWithAddToNavigationDrag';
import { SelectableListItem } from '@/ui/layout/selectable-list/components/SelectableListItem';
import { useAtomState } from '@/ui/utilities/state/jotai/hooks/useAtomState';
type SearchRecord = {
recordId: string;
@@ -31,8 +31,7 @@ export const SidePanelNewSidebarItemRecordItem = ({
dragIndex,
}: SidePanelNewSidebarItemRecordItemProps) => {
const { getIcon } = useIcons();
const { addRecordToDraft } = useAddRecordToNavigationMenuDraft();
const { currentDraft } = useDraftNavigationMenuItems();
const { createItem } = useNavigationMenuItemEditController();
const [
pendingInsertionNavigationMenuItem,
setPendingInsertionNavigationMenuItem,
@@ -53,24 +52,30 @@ export const SidePanelNewSidebarItemRecordItem = ({
};
const handleSelectRecord = () => {
const itemId = addRecordToDraft(
if (!isDefined(objectMetadataItem)) {
return;
}
const itemId = createItem(
{
recordId: record.recordId,
objectNameSingular: record.objectNameSingular,
label: record.label,
imageUrl: record.imageUrl,
type: NavigationMenuItemType.RECORD,
targetObjectMetadataId: objectMetadataItem.id,
targetRecordId: record.recordId,
targetRecordIdentifier: {
id: record.recordId,
labelIdentifier: record.label,
imageIdentifier: record.imageUrl ?? null,
},
},
{
targetFolderId: pendingInsertionNavigationMenuItem?.folderId ?? null,
targetIndex: pendingInsertionNavigationMenuItem?.position,
},
currentDraft,
pendingInsertionNavigationMenuItem?.folderId ?? null,
pendingInsertionNavigationMenuItem?.position,
);
setPendingInsertionNavigationMenuItem(null);
openNavigationMenuItemInSidePanel({
itemId,
pageTitle: record.label,
pageIcon: objectMetadataItem
? getIcon(objectMetadataItem.icon)
: IconFolder,
pageIcon: getIcon(objectMetadataItem.icon),
});
};
@@ -4,7 +4,7 @@ import { isDefined } from 'twenty-shared/utils';
import { useDebounce } from 'use-debounce';
import { MAX_SEARCH_RESULTS } from '@/command-menu/constants/MaxSearchResults';
import { useDraftNavigationMenuItems } from '@/navigation-menu-item/edit/hooks/useDraftNavigationMenuItems';
import { useNavigationMenuItemEditController } from '@/navigation-menu-item/edit/hooks/useNavigationMenuItemEditController';
import { useApolloCoreClient } from '@/object-metadata/hooks/useApolloCoreClient';
import { useReadableObjectMetadataItems } from '@/object-metadata/hooks/useReadableObjectMetadataItems';
import { SidePanelAddToNavigationDroppable } from '@/side-panel/components/SidePanelAddToNavigationDroppable';
@@ -27,7 +27,7 @@ type SearchRecordBase = {
export const SidePanelNewSidebarItemRecordSubPage = () => {
const { t } = useLingui();
const { currentDraft } = useDraftNavigationMenuItems();
const { currentItems } = useNavigationMenuItemEditController();
const [recordSearchInput, setRecordSearchInput] = useState('');
const [deferredRecordSearchInput] = useDebounce(recordSearchInput, 300);
const coreClient = useApolloCoreClient();
@@ -65,8 +65,8 @@ export const SidePanelNewSidebarItemRecordSubPage = () => {
},
);
const workspaceRecordIds = new Set(
currentDraft.flatMap((item) =>
const recordIdsAlreadyAdded = new Set(
currentItems.flatMap((item) =>
isDefined(item.targetRecordId) ? [item.targetRecordId] : [],
),
);
@@ -74,7 +74,7 @@ export const SidePanelNewSidebarItemRecordSubPage = () => {
const searchRecords =
searchData?.search?.edges?.map((edge) => edge.node) ?? [];
const availableSearchRecords = searchRecords.filter(
(record) => !workspaceRecordIds.has(record.recordId),
(record) => !recordIdsAlreadyAdded.has(record.recordId),
) as SearchRecordBase[];
const isEmpty = availableSearchRecords.length === 0 && !recordSearchLoading;
@@ -1,5 +1,5 @@
import { useDraftNavigationMenuItems } from '@/navigation-menu-item/edit/hooks/useDraftNavigationMenuItems';
import { useNavigationMenuObjectMetadataFromDraft } from '@/navigation-menu-item/edit/hooks/useNavigationMenuObjectMetadataFromDraft';
import { useNavigationMenuItemEditController } from '@/navigation-menu-item/edit/hooks/useNavigationMenuItemEditController';
import { useNavigationMenuObjectMetadataForSection } from '@/navigation-menu-item/edit/hooks/useNavigationMenuObjectMetadataForSection';
import { SidePanelNewSidebarItemViewObjectPickerSubView } from '@/navigation-menu-item/edit/side-panel/components/SidePanelNewSidebarItemViewObjectPickerSubView';
import { getAvailableObjectMetadataForNewSidebarItem } from '@/navigation-menu-item/edit/side-panel/utils/getAvailableObjectMetadataForNewSidebarItem';
import { isViewDisplayableInNavigationMenu } from '@/navigation-menu-item/edit/side-panel/utils/isViewDisplayableInNavigationMenu';
@@ -19,19 +19,19 @@ export const SidePanelNewSidebarItemViewObjectPickerSubPage = () => {
selectedObjectMetadataIdForViewFlowState,
);
const { currentDraft } = useDraftNavigationMenuItems();
const { currentItems } = useNavigationMenuItemEditController();
const { objectMetadataItems } = useObjectMetadataItems();
const { activeNonSystemObjectMetadataItems } =
useFilteredObjectMetadataItems();
const { views, objectMetadataIdsWithIndexView, viewIdsInWorkspace } =
useNavigationMenuObjectMetadataFromDraft(currentDraft);
const { views, objectMetadataIdsWithIndexView, viewIdsAlreadyAdded } =
useNavigationMenuObjectMetadataForSection(currentItems);
const objectMetadataIdsWithDisplayableViews = new Set(
views
.filter(
(view) =>
isViewDisplayableInNavigationMenu(view) &&
!viewIdsInWorkspace.has(view.id),
!viewIdsAlreadyAdded.has(view.id),
)
.map((view) => view.objectMetadataId),
);
@@ -1,8 +1,7 @@
import { ObjectIconWithViewOverlay } from '@/navigation-menu-item/display/view/components/ObjectIconWithViewOverlay';
import { NavigationMenuItemType } from 'twenty-shared/types';
import { useAddViewToNavigationMenuDraft } from '@/navigation-menu-item/edit/view/hooks/useAddViewToNavigationMenuDraft';
import { useDraftNavigationMenuItems } from '@/navigation-menu-item/edit/hooks/useDraftNavigationMenuItems';
import { useNavigationMenuObjectMetadataFromDraft } from '@/navigation-menu-item/edit/hooks/useNavigationMenuObjectMetadataFromDraft';
import { useNavigationMenuItemEditController } from '@/navigation-menu-item/edit/hooks/useNavigationMenuItemEditController';
import { useNavigationMenuObjectMetadataForSection } from '@/navigation-menu-item/edit/hooks/useNavigationMenuObjectMetadataForSection';
import { useOpenNavigationMenuItemInSidePanel } from '@/navigation-menu-item/edit/hooks/useOpenNavigationMenuItemInSidePanel';
import { isViewDisplayableInNavigationMenu } from '@/navigation-menu-item/edit/side-panel/utils/isViewDisplayableInNavigationMenu';
import { pendingInsertionNavigationMenuItemState } from '@/navigation-menu-item/common/states/pendingInsertionNavigationMenuItemState';
@@ -32,8 +31,7 @@ export const SidePanelNewSidebarItemViewPickerSubView = ({
const { t } = useLingui();
const { getIcon } = useIcons();
const [searchValue, setSearchValue] = useState('');
const { addViewToDraft } = useAddViewToNavigationMenuDraft();
const { currentDraft } = useDraftNavigationMenuItems();
const { currentItems, createItem } = useNavigationMenuItemEditController();
const [
pendingInsertionNavigationMenuItem,
setPendingInsertionNavigationMenuItem,
@@ -41,7 +39,7 @@ export const SidePanelNewSidebarItemViewPickerSubView = ({
const { openNavigationMenuItemInSidePanel } =
useOpenNavigationMenuItemInSidePanel();
const { objectMetadataItems } = useObjectMetadataItems();
const { views } = useNavigationMenuObjectMetadataFromDraft(currentDraft);
const { views } = useNavigationMenuObjectMetadataForSection(currentItems);
const viewsForSelectedObject = views
.filter(
@@ -74,12 +72,16 @@ export const SidePanelNewSidebarItemViewPickerSubView = ({
: undefined;
const handleSelectView = (view: View) => {
const itemId = addViewToDraft(
view.id,
currentDraft,
pendingInsertionNavigationMenuItem?.folderId ?? null,
pendingInsertionNavigationMenuItem?.position,
selectedObjectIconColor,
const itemId = createItem(
{
type: NavigationMenuItemType.VIEW,
viewId: view.id,
color: selectedObjectIconColor,
},
{
targetFolderId: pendingInsertionNavigationMenuItem?.folderId ?? null,
targetIndex: pendingInsertionNavigationMenuItem?.position,
},
);
setPendingInsertionNavigationMenuItem(null);
openNavigationMenuItemInSidePanel({
@@ -1,5 +1,5 @@
import { useDraftNavigationMenuItems } from '@/navigation-menu-item/edit/hooks/useDraftNavigationMenuItems';
import { useNavigationMenuObjectMetadataFromDraft } from '@/navigation-menu-item/edit/hooks/useNavigationMenuObjectMetadataFromDraft';
import { useNavigationMenuItemEditController } from '@/navigation-menu-item/edit/hooks/useNavigationMenuItemEditController';
import { useNavigationMenuObjectMetadataForSection } from '@/navigation-menu-item/edit/hooks/useNavigationMenuObjectMetadataForSection';
import { SidePanelNewSidebarItemViewSystemSubView } from '@/navigation-menu-item/edit/side-panel/components/SidePanelNewSidebarItemViewSystemSubView';
import { getAvailableObjectMetadataForNewSidebarItem } from '@/navigation-menu-item/edit/side-panel/utils/getAvailableObjectMetadataForNewSidebarItem';
import { isViewDisplayableInNavigationMenu } from '@/navigation-menu-item/edit/side-panel/utils/isViewDisplayableInNavigationMenu';
@@ -19,19 +19,19 @@ export const SidePanelNewSidebarItemViewSystemPickerSubPage = () => {
selectedObjectMetadataIdForViewFlowState,
);
const { currentDraft } = useDraftNavigationMenuItems();
const { currentItems } = useNavigationMenuItemEditController();
const { objectMetadataItems } = useObjectMetadataItems();
const { activeNonSystemObjectMetadataItems } =
useFilteredObjectMetadataItems();
const { views, objectMetadataIdsWithIndexView, viewIdsInWorkspace } =
useNavigationMenuObjectMetadataFromDraft(currentDraft);
const { views, objectMetadataIdsWithIndexView, viewIdsAlreadyAdded } =
useNavigationMenuObjectMetadataForSection(currentItems);
const objectMetadataIdsWithDisplayableViews = new Set(
views
.filter(
(view) =>
isViewDisplayableInNavigationMenu(view) &&
!viewIdsInWorkspace.has(view.id),
!viewIdsAlreadyAdded.has(view.id),
)
.map((view) => view.objectMetadataId),
);
@@ -3,8 +3,8 @@ import { isDefined } from 'twenty-shared/utils';
import { CommandMenuItem } from '@/command-menu/components/CommandMenuItem';
import { ObjectMetadataIcon } from '@/object-metadata/components/ObjectMetadataIcon';
import { NavigationMenuItemType } from 'twenty-shared/types';
import { useDraftNavigationMenuItems } from '@/navigation-menu-item/edit/hooks/useDraftNavigationMenuItems';
import { useNavigationMenuObjectMetadataFromDraft } from '@/navigation-menu-item/edit/hooks/useNavigationMenuObjectMetadataFromDraft';
import { useNavigationMenuItemEditController } from '@/navigation-menu-item/edit/hooks/useNavigationMenuItemEditController';
import { useNavigationMenuObjectMetadataForSection } from '@/navigation-menu-item/edit/hooks/useNavigationMenuObjectMetadataForSection';
import { getObjectColorWithFallback } from '@/object-metadata/utils/getObjectColorWithFallback';
import { type EnrichedObjectMetadataItem } from '@/object-metadata/types/EnrichedObjectMetadataItem';
import { SidePanelItemWithAddToNavigationDrag } from '@/side-panel/components/SidePanelItemWithAddToNavigationDrag';
@@ -28,10 +28,10 @@ export const SidePanelObjectMenuItem = ({
disableDrag = false,
}: SidePanelObjectMenuItemProps) => {
const { t } = useLingui();
const { currentDraft } = useDraftNavigationMenuItems();
const { objectMetadataIdsInWorkspace } =
useNavigationMenuObjectMetadataFromDraft(currentDraft);
const isAlreadyInNavbar = objectMetadataIdsInWorkspace.has(
const { currentItems } = useNavigationMenuItemEditController();
const { objectMetadataIdsAlreadyAdded } =
useNavigationMenuObjectMetadataForSection(currentItems);
const isAlreadyInNavbar = objectMetadataIdsAlreadyAdded.has(
objectMetadataItem.id,
);
const defaultViewId = useAtomFamilySelectorValue(
@@ -1,22 +1,16 @@
import { useLingui } from '@lingui/react/macro';
import { isDefined } from 'twenty-shared/utils';
import { NavigationMenuItemType } from 'twenty-shared/types';
import { IconFolder } from 'twenty-ui/display';
import { useAddFolderToNavigationMenuDraft } from '@/navigation-menu-item/edit/folder/hooks/useAddFolderToNavigationMenuDraft';
import { useNavigationMenuItemsDraftState } from '@/navigation-menu-item/edit/hooks/useNavigationMenuItemsDraftState';
import { useOpenNavigationMenuItemInSidePanel } from '@/navigation-menu-item/edit/hooks/useOpenNavigationMenuItemInSidePanel';
import { DEFAULT_NAVIGATION_MENU_ITEM_COLOR_FOLDER } from '@/navigation-menu-item/common/constants/NavigationMenuItemDefaultColorFolder';
import { pendingInsertionNavigationMenuItemState } from '@/navigation-menu-item/common/states/pendingInsertionNavigationMenuItemState';
import { navigationMenuItemsDraftState } from '@/navigation-menu-item/common/states/navigationMenuItemsDraftState';
import { useNavigationMenuItemEditController } from '@/navigation-menu-item/edit/hooks/useNavigationMenuItemEditController';
import { useOpenNavigationMenuItemInSidePanel } from '@/navigation-menu-item/edit/hooks/useOpenNavigationMenuItemInSidePanel';
import { useAtomState } from '@/ui/utilities/state/jotai/hooks/useAtomState';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
export const useAddFolderToNavigationMenu = () => {
const { t } = useLingui();
const { addFolderToDraft } = useAddFolderToNavigationMenuDraft();
const { workspaceNavigationMenuItems } = useNavigationMenuItemsDraftState();
const navigationMenuItemsDraft = useAtomStateValue(
navigationMenuItemsDraftState,
);
const { createItem } = useNavigationMenuItemEditController();
const { openNavigationMenuItemInSidePanel } =
useOpenNavigationMenuItemInSidePanel();
const [
@@ -24,16 +18,17 @@ export const useAddFolderToNavigationMenu = () => {
setPendingInsertionNavigationMenuItem,
] = useAtomState(pendingInsertionNavigationMenuItemState);
const currentDraft = isDefined(navigationMenuItemsDraft)
? navigationMenuItemsDraft
: workspaceNavigationMenuItems;
const handleAddFolder = () => {
const itemId = addFolderToDraft(
t`New folder`,
currentDraft,
pendingInsertionNavigationMenuItem?.folderId ?? null,
pendingInsertionNavigationMenuItem?.position,
const itemId = createItem(
{
type: NavigationMenuItemType.FOLDER,
name: t`New folder`,
color: DEFAULT_NAVIGATION_MENU_ITEM_COLOR_FOLDER,
},
{
targetFolderId: pendingInsertionNavigationMenuItem?.folderId ?? null,
targetIndex: pendingInsertionNavigationMenuItem?.position,
},
);
setPendingInsertionNavigationMenuItem(null);
@@ -1,22 +1,17 @@
import { useLingui } from '@lingui/react/macro';
import { isDefined } from 'twenty-shared/utils';
import { NavigationMenuItemType } from 'twenty-shared/types';
import { normalizeUrl } from 'twenty-shared/utils';
import { IconLink } from 'twenty-ui/display';
import { useAddLinkToNavigationMenuDraft } from '@/navigation-menu-item/edit/link/hooks/useAddLinkToNavigationMenuDraft';
import { useNavigationMenuItemsDraftState } from '@/navigation-menu-item/edit/hooks/useNavigationMenuItemsDraftState';
import { useOpenNavigationMenuItemInSidePanel } from '@/navigation-menu-item/edit/hooks/useOpenNavigationMenuItemInSidePanel';
import { DEFAULT_NAVIGATION_MENU_ITEM_COLOR_LINK } from '@/navigation-menu-item/common/constants/NavigationMenuItemDefaultColorLink';
import { pendingInsertionNavigationMenuItemState } from '@/navigation-menu-item/common/states/pendingInsertionNavigationMenuItemState';
import { navigationMenuItemsDraftState } from '@/navigation-menu-item/common/states/navigationMenuItemsDraftState';
import { useNavigationMenuItemEditController } from '@/navigation-menu-item/edit/hooks/useNavigationMenuItemEditController';
import { useOpenNavigationMenuItemInSidePanel } from '@/navigation-menu-item/edit/hooks/useOpenNavigationMenuItemInSidePanel';
import { useAtomState } from '@/ui/utilities/state/jotai/hooks/useAtomState';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
export const useAddLinkToNavigationMenu = () => {
const { t } = useLingui();
const { addLinkToDraft } = useAddLinkToNavigationMenuDraft();
const { workspaceNavigationMenuItems } = useNavigationMenuItemsDraftState();
const navigationMenuItemsDraft = useAtomStateValue(
navigationMenuItemsDraftState,
);
const { createItem } = useNavigationMenuItemEditController();
const { openNavigationMenuItemInSidePanel } =
useOpenNavigationMenuItemInSidePanel();
const [
@@ -24,17 +19,18 @@ export const useAddLinkToNavigationMenu = () => {
setPendingInsertionNavigationMenuItem,
] = useAtomState(pendingInsertionNavigationMenuItemState);
const currentDraft = isDefined(navigationMenuItemsDraft)
? navigationMenuItemsDraft
: workspaceNavigationMenuItems;
const handleAddLink = () => {
const itemId = addLinkToDraft(
t`Link label`,
'www.example.com',
currentDraft,
pendingInsertionNavigationMenuItem?.folderId ?? null,
pendingInsertionNavigationMenuItem?.position,
const itemId = createItem(
{
type: NavigationMenuItemType.LINK,
name: t`Link label`,
link: normalizeUrl('www.example.com'),
color: DEFAULT_NAVIGATION_MENU_ITEM_COLOR_LINK,
},
{
targetFolderId: pendingInsertionNavigationMenuItem?.folderId ?? null,
targetIndex: pendingInsertionNavigationMenuItem?.position,
},
);
setPendingInsertionNavigationMenuItem(null);
@@ -1,8 +1,8 @@
import { isDefined } from 'twenty-shared/utils';
import { NavigationMenuItemType } from 'twenty-shared/types';
import { useDraftNavigationMenuItemsAllFolders } from '@/navigation-menu-item/edit/hooks/useDraftNavigationMenuItemsAllFolders';
import { useDraftNavigationMenuItemsWorkspaceFolders } from '@/navigation-menu-item/edit/hooks/useDraftNavigationMenuItemsWorkspaceFolders';
import { useNavigationMenuItemSectionAllFolders } from '@/navigation-menu-item/edit/hooks/useNavigationMenuItemSectionAllFolders';
import { useNavigationMenuItemSectionFolders } from '@/navigation-menu-item/edit/hooks/useNavigationMenuItemSectionFolders';
import { useNavigationMenuItemMoveRemove } from '@/navigation-menu-item/edit/hooks/useNavigationMenuItemMoveRemove';
import { useSelectedNavigationMenuItemEditItem } from '@/navigation-menu-item/edit/hooks/useSelectedNavigationMenuItemEditItem';
import { selectedNavigationMenuItemIdInEditModeState } from '@/navigation-menu-item/common/states/selectedNavigationMenuItemIdInEditModeState';
@@ -46,8 +46,8 @@ export const useFolderPickerSelectionData = () => {
);
const { selectedItem } = useSelectedNavigationMenuItemEditItem();
const selectedItemType = selectedItem?.type ?? null;
const { allFolders } = useDraftNavigationMenuItemsAllFolders();
const { workspaceFolders } = useDraftNavigationMenuItemsWorkspaceFolders();
const { allFolders } = useNavigationMenuItemSectionAllFolders();
const { sectionFolders } = useNavigationMenuItemSectionFolders();
const selectedFolderId =
selectedItemType === NavigationMenuItemType.FOLDER
@@ -83,11 +83,11 @@ export const useFolderPickerSelectionData = () => {
const foldersToShow = includeNoFolderOption
? folders
: excludeCurrentFolder(workspaceFolders, currentFolderId);
: excludeCurrentFolder(sectionFolders, currentFolderId);
const handleSelectFolder = (folderId: string | null) => {
if (isDefined(selectedNavigationMenuItemIdInEditMode)) {
moveToFolder(selectedNavigationMenuItemIdInEditMode, folderId);
void moveToFolder(selectedNavigationMenuItemIdInEditMode, folderId);
closeSidePanelMenu();
}
};
@@ -8,10 +8,9 @@ import { IconDotsVertical } from 'twenty-ui/display';
import { pendingInsertionNavigationMenuItemState } from '@/navigation-menu-item/common/states/pendingInsertionNavigationMenuItemState';
import { selectedNavigationMenuItemIdInEditModeState } from '@/navigation-menu-item/common/states/selectedNavigationMenuItemIdInEditModeState';
import { type PendingInsertionNavigationMenuItem } from '@/navigation-menu-item/common/types/PendingInsertionNavigationMenuItem';
import { useNavigationMenuItemSectionItems } from '@/navigation-menu-item/display/hooks/useNavigationMenuItemSectionItems';
import { getNavigationMenuItemComputedLink } from '@/navigation-menu-item/display/utils/getNavigationMenuItemComputedLink';
import { useNavigationMenuItemEditSectionItems } from '@/navigation-menu-item/edit/hooks/useNavigationMenuItemEditSectionItems';
import { useNavigationMenuItemMoveRemove } from '@/navigation-menu-item/edit/hooks/useNavigationMenuItemMoveRemove';
import { useNavigationMenuItemsDraftState } from '@/navigation-menu-item/edit/hooks/useNavigationMenuItemsDraftState';
import { type OrganizeActionsProps } from '@/navigation-menu-item/edit/side-panel/components/SidePanelEditOrganizeActions';
import { objectMetadataItemsSelector } from '@/object-metadata/states/objectMetadataItemsSelector';
import { useNavigateSidePanel } from '@/side-panel/hooks/useNavigateSidePanel';
@@ -25,16 +24,12 @@ import { type NavigationMenuItem } from '~/generated-metadata/graphql';
const computeInsertionPosition = (
selectedItem: { id: string; folderId?: string | null },
workspaceNavigationMenuItems: NavigationMenuItem[],
sectionItems: NavigationMenuItem[],
offset: 0 | 1,
): PendingInsertionNavigationMenuItem | null => {
const folderId = selectedItem.folderId ?? null;
const itemsInFolderSorted = workspaceNavigationMenuItems
.filter(
(item) =>
(item.folderId ?? null) === folderId &&
!isDefined(item.userWorkspaceId),
)
const itemsInFolderSorted = sectionItems
.filter((item) => (item.folderId ?? null) === folderId)
.sort((a, b) => a.position - b.position);
const selectedIndexSorted = itemsInFolderSorted.findIndex(
(item) => item.id === selectedItem.id,
@@ -66,8 +61,7 @@ export const useNavigationMenuItemEditOrganizeActions =
const setPendingInsertionNavigationMenuItem = useSetAtomState(
pendingInsertionNavigationMenuItemState,
);
const { workspaceNavigationMenuItems } = useNavigationMenuItemsDraftState();
const items = useNavigationMenuItemSectionItems();
const items = useNavigationMenuItemEditSectionItems();
const { moveUp, moveDown, remove } = useNavigationMenuItemMoveRemove();
const objectMetadataItems = useAtomStateValue(objectMetadataItemsSelector);
const views = useAtomStateValue(viewsSelector);
@@ -99,13 +93,13 @@ export const useNavigationMenuItemEditOrganizeActions =
const handleMoveUp = () => {
if (canMoveUp && isDefined(selectedNavigationMenuItemIdInEditMode)) {
moveUp(selectedNavigationMenuItemIdInEditMode);
void moveUp(selectedNavigationMenuItemIdInEditMode);
}
};
const handleMoveDown = () => {
if (canMoveDown && isDefined(selectedNavigationMenuItemIdInEditMode)) {
moveDown(selectedNavigationMenuItemIdInEditMode);
void moveDown(selectedNavigationMenuItemIdInEditMode);
}
};
@@ -118,7 +112,7 @@ export const useNavigationMenuItemEditOrganizeActions =
siblings[selectedIndexInSiblings + 1] ??
siblings[selectedIndexInSiblings - 1];
remove(selectedNavigationMenuItemIdInEditMode);
void remove(selectedNavigationMenuItemIdInEditMode);
if (isDefined(nextItem)) {
setSelectedNavigationMenuItemIdInEditMode(nextItem.id);
@@ -144,11 +138,7 @@ export const useNavigationMenuItemEditOrganizeActions =
const handleAddAtOffset = (offset: 0 | 1) => {
if (!isDefined(selectedItem)) return;
const insertion = computeInsertionPosition(
selectedItem,
workspaceNavigationMenuItems,
offset,
);
const insertion = computeInsertionPosition(selectedItem, items, offset);
if (!insertion) return;
const title =
@@ -1,65 +0,0 @@
import { NavigationMenuItemType } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { v4 } from 'uuid';
import type { NavigationMenuItem } from '~/generated-metadata/graphql';
import { navigationMenuItemsDraftState } from '@/navigation-menu-item/common/states/navigationMenuItemsDraftState';
import { computeInsertIndexAndPosition } from '@/navigation-menu-item/common/utils/computeInsertIndexAndPosition';
import { useSetAtomState } from '@/ui/utilities/state/jotai/hooks/useSetAtomState';
export const useAddViewToNavigationMenuDraft = () => {
const setNavigationMenuItemsDraft = useSetAtomState(
navigationMenuItemsDraftState,
);
const addViewToDraft = (
viewId: string,
currentDraft: NavigationMenuItem[],
targetFolderId?: string | null,
targetIndex?: number,
color?: string | null,
): string => {
const folderId = targetFolderId ?? null;
const itemsInFolder = currentDraft.filter(
(item) =>
(item.folderId ?? null) === folderId &&
!isDefined(item.userWorkspaceId),
);
const index = targetIndex ?? itemsInFolder.length;
const { flatIndex, position } = computeInsertIndexAndPosition(
currentDraft,
folderId,
index,
);
const newItemId = v4();
const newItem: NavigationMenuItem = {
__typename: 'NavigationMenuItem',
id: newItemId,
type: NavigationMenuItemType.VIEW,
viewId,
targetObjectMetadataId: undefined,
position,
userWorkspaceId: undefined,
targetRecordId: undefined,
folderId: folderId ?? undefined,
name: undefined,
applicationId: undefined,
color,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
const newDraft = [
...currentDraft.slice(0, flatIndex),
newItem,
...currentDraft.slice(flatIndex),
];
setNavigationMenuItemsDraft(newDraft);
return newItemId;
};
return { addViewToDraft };
};
@@ -1,12 +1,14 @@
import { styled } from '@linaria/react';
import { useLingui } from '@lingui/react/macro';
import { NavigationMenuItemType } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { TintedIconTile, useIcons } from 'twenty-ui/display';
import { FOLDER_ICON_DEFAULT } from '@/navigation-menu-item/common/constants/FolderIconDefault';
import { selectedNavigationMenuItemIdInEditModeState } from '@/navigation-menu-item/common/states/selectedNavigationMenuItemIdInEditModeState';
import { useNavigationMenuItemSectionItems } from '@/navigation-menu-item/display/hooks/useNavigationMenuItemSectionItems';
import { useUpdateFolderInDraft } from '@/navigation-menu-item/edit/folder/hooks/useUpdateFolderInDraft';
import { useNavigationMenuItemEditController } from '@/navigation-menu-item/edit/hooks/useNavigationMenuItemEditController';
import { useNavigationMenuItemEditSectionItems } from '@/navigation-menu-item/edit/hooks/useNavigationMenuItemEditSectionItems';
import { useNavigationMenuItemTitleEdit } from '@/navigation-menu-item/edit/hooks/useNavigationMenuItemTitleEdit';
import { SidePanelPageInfoLayout } from '@/side-panel/components/SidePanelPageInfoLayout';
import { sidePanelPageInfoState } from '@/side-panel/states/sidePanelPageInfoState';
import { sidePanelShouldFocusTitleInputComponentState } from '@/side-panel/states/sidePanelShouldFocusTitleInputComponentState';
@@ -31,8 +33,8 @@ export const SidePanelFolderInfo = () => {
const selectedNavigationMenuItemIdInEditMode = useAtomStateValue(
selectedNavigationMenuItemIdInEditModeState,
);
const items = useNavigationMenuItemSectionItems();
const { updateFolderInDraft } = useUpdateFolderInDraft();
const items = useNavigationMenuItemEditSectionItems();
const { updateItem } = useNavigationMenuItemEditController();
const defaultLabel = t`New folder`;
const placeholder = t`Folder name`;
@@ -45,23 +47,18 @@ export const SidePanelFolderInfo = () => {
)
: undefined;
if (!selectedItem) return null;
const { value, handleChange, handleSave } = useNavigationMenuItemTitleEdit({
itemId: selectedItem?.id ?? null,
itemName: selectedItem?.name ?? defaultLabel,
defaultLabel,
persistName: (name) => {
if (isDefined(selectedItem)) {
void updateItem(selectedItem.id, { name });
}
},
});
const itemId = selectedItem.id;
const itemName = selectedItem.name ?? defaultLabel;
const handleChange = (text: string) => {
updateFolderInDraft(itemId, { name: text });
};
const handleSave = () => {
const trimmed = itemName.trim();
const finalName = trimmed.length > 0 ? trimmed : defaultLabel;
if (finalName !== itemName) {
updateFolderInDraft(itemId, { name: finalName });
}
};
if (!isDefined(selectedItem)) return null;
const selectedIconKey = selectedItem.icon ?? FOLDER_ICON_DEFAULT;
const FolderIconComponent = getIcon(selectedIconKey);
@@ -73,7 +70,7 @@ export const SidePanelFolderInfo = () => {
dropdownId="side-panel-folder-icon-picker"
selectedIconKey={selectedIconKey}
onChange={({ iconKey }) =>
updateFolderInDraft(itemId, { icon: iconKey })
void updateItem(selectedItem.id, { icon: iconKey })
}
clickableComponent={
<StyledClickableIconWrapper>
@@ -87,9 +84,9 @@ export const SidePanelFolderInfo = () => {
}
title={
<TitleInput
instanceId={`folder-name-${itemId}`}
instanceId={`folder-name-${selectedItem.id}`}
sizeVariant="sm"
value={itemName}
value={value}
onChange={handleChange}
placeholder={placeholder}
onEnter={handleSave}
@@ -5,9 +5,11 @@ import { isDefined } from 'twenty-shared/utils';
import { type IconComponent } from 'twenty-ui/display';
import { CommandMenuItem } from '@/command-menu/components/CommandMenuItem';
import { AddToNavigationDragHandle } from '@/navigation-menu-item/display/dnd/components/AddToNavigationDragHandle';
import { addToNavPayloadRegistryState } from '@/navigation-menu-item/common/states/addToNavPayloadRegistryState';
import { navigationMenuItemEditSectionState } from '@/navigation-menu-item/common/states/navigationMenuItemEditSectionState';
import type { AddToNavigationDragPayload } from '@/navigation-menu-item/common/types/add-to-navigation-drag-payload';
import { AddToNavigationDragHandle } from '@/navigation-menu-item/display/dnd/components/AddToNavigationDragHandle';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { useSetAtomState } from '@/ui/utilities/state/jotai/hooks/useSetAtomState';
const CommandMenuItemWithAddToNavigationDragDndKit = lazy(() =>
@@ -62,9 +64,16 @@ export const SidePanelItemWithAddToNavigationDrag = ({
const setAddToNavPayloadRegistry = useSetAtomState(
addToNavPayloadRegistryState,
);
const navigationMenuItemEditSection = useAtomStateValue(
navigationMenuItemEditSectionState,
);
const [isHovered, setIsHovered] = useState(false);
const showDragAffordance = !disabled && !disableDrag && isHovered;
// Favorites are added by click only; drag-to-add targets the workspace
// sidebar and runs through layout-customization mode.
const effectiveDisableDrag =
disableDrag || navigationMenuItemEditSection === 'favorite';
const showDragAffordance = !disabled && !effectiveDisableDrag && isHovered;
const contextualDescription = showDragAffordance
? t`Drag to add to navbar`
: description;
@@ -76,12 +85,12 @@ export const SidePanelItemWithAddToNavigationDrag = ({
payload={payload}
isHovered={showDragAffordance}
disabled={disabled}
disableDrag={disableDrag}
disableDrag={effectiveDisableDrag}
/>
);
const registerPayload = () => {
if (!disabled && !disableDrag && isDefined(dragIndex)) {
if (!disabled && !effectiveDisableDrag && isDefined(dragIndex)) {
setAddToNavPayloadRegistry((prev) => new Map(prev).set(id, payload));
}
};
@@ -89,15 +98,15 @@ export const SidePanelItemWithAddToNavigationDrag = ({
const menuItemContent = (
<StyledDraggableMenuItem
$disabled={disabled}
$disableDrag={disableDrag}
$disableDrag={effectiveDisableDrag}
onMouseEnter={() => {
if (!disabled && !disableDrag) {
if (!disabled && !effectiveDisableDrag) {
setIsHovered(true);
registerPayload();
}
}}
onMouseLeave={() => {
if (!disabled && !disableDrag) setIsHovered(false);
if (!disabled && !effectiveDisableDrag) setIsHovered(false);
}}
onMouseDown={registerPayload}
>
@@ -112,7 +121,7 @@ export const SidePanelItemWithAddToNavigationDrag = ({
</StyledDraggableMenuItem>
);
if (!isDefined(dragIndex) || disableDrag) {
if (!isDefined(dragIndex) || effectiveDisableDrag) {
return menuItemContent;
}
@@ -1,11 +1,13 @@
import { useLingui } from '@lingui/react/macro';
import { NavigationMenuItemType } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { IconLink, IconWorld } from 'twenty-ui/display';
import { LinkIconWithLinkOverlay } from '@/navigation-menu-item/display/link/components/LinkIconWithLinkOverlay';
import { NavigationMenuItemType } from 'twenty-shared/types';
import { useUpdateLinkInDraft } from '@/navigation-menu-item/edit/link/hooks/useUpdateLinkInDraft';
import { useNavigationMenuItemSectionItems } from '@/navigation-menu-item/display/hooks/useNavigationMenuItemSectionItems';
import { selectedNavigationMenuItemIdInEditModeState } from '@/navigation-menu-item/common/states/selectedNavigationMenuItemIdInEditModeState';
import { LinkIconWithLinkOverlay } from '@/navigation-menu-item/display/link/components/LinkIconWithLinkOverlay';
import { useNavigationMenuItemEditSectionItems } from '@/navigation-menu-item/edit/hooks/useNavigationMenuItemEditSectionItems';
import { useNavigationMenuItemTitleEdit } from '@/navigation-menu-item/edit/hooks/useNavigationMenuItemTitleEdit';
import { useNavigationMenuItemEditController } from '@/navigation-menu-item/edit/hooks/useNavigationMenuItemEditController';
import { SidePanelPageInfoLayout } from '@/side-panel/components/SidePanelPageInfoLayout';
import { sidePanelPageInfoState } from '@/side-panel/states/sidePanelPageInfoState';
import { sidePanelShouldFocusTitleInputComponentState } from '@/side-panel/states/sidePanelShouldFocusTitleInputComponentState';
@@ -24,8 +26,8 @@ export const SidePanelLinkInfo = () => {
const selectedNavigationMenuItemIdInEditMode = useAtomStateValue(
selectedNavigationMenuItemIdInEditModeState,
);
const items = useNavigationMenuItemSectionItems();
const { updateLinkInDraft } = useUpdateLinkInDraft();
const items = useNavigationMenuItemEditSectionItems();
const { updateItem } = useNavigationMenuItemEditController();
const defaultLabel = t`Link label`;
const placeholder = t`Link label`;
@@ -38,23 +40,18 @@ export const SidePanelLinkInfo = () => {
)
: undefined;
if (!selectedItem) return null;
const { value, handleChange, handleSave } = useNavigationMenuItemTitleEdit({
itemId: selectedItem?.id ?? null,
itemName: selectedItem?.name ?? defaultLabel,
defaultLabel,
persistName: (name) => {
if (isDefined(selectedItem)) {
void updateItem(selectedItem.id, { name });
}
},
});
const itemId = selectedItem.id;
const itemName = selectedItem.name ?? defaultLabel;
const handleChange = (text: string) => {
updateLinkInDraft(itemId, { name: text });
};
const handleSave = () => {
const trimmed = itemName.trim();
const finalName = trimmed.length > 0 ? trimmed : defaultLabel;
if (finalName !== itemName) {
updateLinkInDraft(itemId, { name: finalName });
}
};
if (!isDefined(selectedItem)) return null;
return (
<SidePanelPageInfoLayout
@@ -68,9 +65,9 @@ export const SidePanelLinkInfo = () => {
}
title={
<TitleInput
instanceId={`link-label-${itemId}`}
instanceId={`link-label-${selectedItem.id}`}
sizeVariant="sm"
value={itemName}
value={value}
onChange={handleChange}
placeholder={placeholder}
onEnter={handleSave}
@@ -6,7 +6,7 @@ import {
} from 'twenty-ui/display';
import { selectedNavigationMenuItemIdInEditModeState } from '@/navigation-menu-item/common/states/selectedNavigationMenuItemIdInEditModeState';
import { useNavigationMenuItemSectionItems } from '@/navigation-menu-item/display/hooks/useNavigationMenuItemSectionItems';
import { useNavigationMenuItemEditSectionItems } from '@/navigation-menu-item/edit/hooks/useNavigationMenuItemEditSectionItems';
import { SidePanelAskAiInfo } from '@/side-panel/components/SidePanelAskAiInfo';
import { SidePanelFolderInfo } from '@/side-panel/components/SidePanelFolderInfo';
import { SidePanelLinkInfo } from '@/side-panel/components/SidePanelLinkInfo';
@@ -38,7 +38,7 @@ export const SidePanelPageInfo = ({ pageChip }: SidePanelPageInfoProps) => {
const selectedNavigationMenuItemIdInEditMode = useAtomStateValue(
selectedNavigationMenuItemIdInEditModeState,
);
const items = useNavigationMenuItemSectionItems();
const items = useNavigationMenuItemEditSectionItems();
if (!isDefined(pageChip)) {
return null;
@@ -1,6 +1,7 @@
import { MAIN_CONTEXT_STORE_INSTANCE_ID } from '@/context-store/constants/MainContextStoreInstanceId';
import { contextStoreTargetedRecordsRuleComponentState } from '@/context-store/states/contextStoreTargetedRecordsRuleComponentState';
import { addToNavPayloadRegistryState } from '@/navigation-menu-item/common/states/addToNavPayloadRegistryState';
import { navigationMenuItemEditSectionState } from '@/navigation-menu-item/common/states/navigationMenuItemEditSectionState';
import { pendingInsertionNavigationMenuItemState } from '@/navigation-menu-item/common/states/pendingInsertionNavigationMenuItemState';
import { selectedNavigationMenuItemIdInEditModeState } from '@/navigation-menu-item/common/states/selectedNavigationMenuItemIdInEditModeState';
import { viewableRecordIdState } from '@/object-record/record-side-panel/states/viewableRecordIdState';
@@ -110,6 +111,7 @@ export const useSidePanelCloseAnimationCompleteCleanup = () => {
store.set(sidePanelNavigationStackState.atom, []);
store.set(selectedNavigationMenuItemIdInEditModeState.atom, null);
store.set(pendingInsertionNavigationMenuItemState.atom, null);
store.set(navigationMenuItemEditSectionState.atom, 'workspace');
store.set(addToNavPayloadRegistryState.atom, new Map());
resetSelectedItem();
store.set(hasUserSelectedSidePanelListItemState.atom, false);