Add CommandMenuFolderInfo component and integrate into CommandMenuPageInfo

- Introduced `CommandMenuFolderInfo` component to manage folder-specific interactions within the command menu.
- Updated `CommandMenuPageInfo` to conditionally render `CommandMenuFolderInfo` when editing a navigation menu item in folder mode.
- Enhanced folder management capabilities by adding folder creation and editing functionalities in the command menu.

These changes improve the user experience by providing a dedicated interface for folder management within the command menu, streamlining navigation and editing processes.
This commit is contained in:
Abdul Rahman
2026-02-05 01:08:55 +05:30
parent 41aa60da2e
commit fc592d3e7d
9 changed files with 315 additions and 38 deletions
@@ -0,0 +1,107 @@
import styled from '@emotion/styled';
import { useTheme } from '@emotion/react';
import { useLingui } from '@lingui/react/macro';
import { useRecoilValue } from 'recoil';
import { IconFolder } from 'twenty-ui/display';
import { commandMenuPageInfoState } from '@/command-menu/states/commandMenuPageInfoState';
import { commandMenuShouldFocusTitleInputComponentState } from '@/command-menu/states/commandMenuShouldFocusTitleInputComponentState';
import { useUpdateNavigationMenuItemsDraft } from '@/navigation-menu-item/hooks/useUpdateNavigationMenuItemsDraft';
import { getNavigationMenuItemIconColors } from '@/navigation-menu-item/utils/getNavigationMenuItemIconColors';
import {
type WorkspaceSectionItem,
useWorkspaceSectionItems,
} from '@/navigation-menu-item/hooks/useWorkspaceSectionItems';
import { selectedNavigationMenuItemInEditModeState } from '@/navigation-menu-item/states/selectedNavigationMenuItemInEditModeState';
import { TitleInput } from '@/ui/input/components/TitleInput';
import { useRecoilComponentState } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentState';
import { CommandMenuPageInfoLayout } from '@/command-menu/components/CommandMenuPageInfoLayout';
const StyledFolderIcon = styled.div`
align-items: center;
background-color: ${({ theme }) =>
getNavigationMenuItemIconColors(theme).folder};
border-radius: ${({ theme }) => theme.border.radius.xs};
display: flex;
height: ${({ theme }) => theme.spacing(4)};
justify-content: center;
width: ${({ theme }) => theme.spacing(4)};
`;
const getWorkspaceSectionItemId = (item: WorkspaceSectionItem): string =>
item.type === 'folder' ? item.folder.folderId : item.navigationMenuItem.id;
export const CommandMenuFolderInfo = () => {
const theme = useTheme();
const { t } = useLingui();
const commandMenuPageInfo = useRecoilValue(commandMenuPageInfoState);
const [shouldFocusTitleInput, setShouldFocusTitleInput] =
useRecoilComponentState(
commandMenuShouldFocusTitleInputComponentState,
commandMenuPageInfo.instanceId,
);
const selectedNavigationMenuItemInEditMode = useRecoilValue(
selectedNavigationMenuItemInEditModeState,
);
const workspaceSectionItems = useWorkspaceSectionItems();
const { updateFolderNameInDraft } = useUpdateNavigationMenuItemsDraft();
const selectedFolder = workspaceSectionItems.find(
(item) =>
item.type === 'folder' &&
getWorkspaceSectionItemId(item) === selectedNavigationMenuItemInEditMode,
);
if (
!selectedFolder ||
selectedFolder.type !== 'folder' ||
!selectedNavigationMenuItemInEditMode
) {
return null;
}
const folderName = selectedFolder.folder.folderName;
const folderId = selectedFolder.folder.folderId;
const handleChange = (text: string) => {
updateFolderNameInDraft(folderId, text);
};
const saveFolderName = () => {
const trimmed = folderName.trim();
const finalName = trimmed.length > 0 ? trimmed : t`New folder`;
if (finalName !== folderName) {
updateFolderNameInDraft(folderId, finalName);
}
};
return (
<CommandMenuPageInfoLayout
icon={
<StyledFolderIcon>
<IconFolder
size={theme.spacing(3)}
color={theme.grayScale.gray1}
stroke={theme.icon.stroke.md}
/>
</StyledFolderIcon>
}
title={
<TitleInput
instanceId={`folder-name-${folderId}`}
sizeVariant="sm"
value={folderName}
onChange={handleChange}
placeholder={t`Folder name`}
onEnter={saveFolderName}
onEscape={saveFolderName}
onClickOutside={saveFolderName}
onTab={saveFolderName}
onShiftTab={saveFolderName}
shouldFocus={shouldFocusTitleInput}
onFocus={() => setShouldFocusTitleInput(false)}
/>
}
/>
);
};
@@ -1,10 +1,17 @@
import { CommandMenuFolderInfo } from '@/command-menu/components/CommandMenuFolderInfo';
import { CommandMenuMultipleRecordsInfo } from '@/command-menu/components/CommandMenuMultipleRecordsInfo';
import { CommandMenuPageLayoutInfo } from '@/command-menu/components/CommandMenuPageLayoutInfo';
import { CommandMenuRecordInfo } from '@/command-menu/components/CommandMenuRecordInfo';
import { CommandMenuWorkflowStepInfo } from '@/command-menu/components/CommandMenuWorkflowStepInfo';
import { CommandMenuPages } from '@/command-menu/types/CommandMenuPages';
import {
type WorkspaceSectionItem,
useWorkspaceSectionItems,
} from '@/navigation-menu-item/hooks/useWorkspaceSectionItems';
import { selectedNavigationMenuItemInEditModeState } from '@/navigation-menu-item/states/selectedNavigationMenuItemInEditModeState';
import styled from '@emotion/styled';
import { isDefined } from 'twenty-shared/utils';
import { useRecoilValue } from 'recoil';
import { OverflowingTextWithTooltip } from 'twenty-ui/display';
import { type CommandMenuContextChipProps } from './CommandMenuContextChip';
@@ -18,11 +25,34 @@ type CommandMenuPageInfoProps = {
pageChip: CommandMenuContextChipProps | undefined;
};
const getWorkspaceSectionItemId = (item: WorkspaceSectionItem): string =>
item.type === 'folder' ? item.folder.folderId : item.navigationMenuItem.id;
export const CommandMenuPageInfo = ({ pageChip }: CommandMenuPageInfoProps) => {
const selectedNavigationMenuItemInEditMode = useRecoilValue(
selectedNavigationMenuItemInEditModeState,
);
const workspaceSectionItems = useWorkspaceSectionItems();
if (!isDefined(pageChip)) {
return null;
}
const isNavigationMenuItemEditPage =
pageChip.page?.page === CommandMenuPages.NavigationMenuItemEdit;
const selectedFolder = isNavigationMenuItemEditPage
? workspaceSectionItems.find(
(item) =>
item.type === 'folder' &&
getWorkspaceSectionItemId(item) ===
selectedNavigationMenuItemInEditMode,
)
: undefined;
if (isNavigationMenuItemEditPage && selectedFolder?.type === 'folder') {
return <CommandMenuFolderInfo />;
}
const isRecordPage = pageChip.page?.page === CommandMenuPages.ViewRecord;
if (isRecordPage && isDefined(pageChip.page?.pageId)) {
@@ -4,10 +4,12 @@ import { useMemo, useState } from 'react';
import { useRecoilValue, useSetRecoilState } from 'recoil';
import { isDefined } from 'twenty-shared/utils';
import {
IconApps,
IconChevronDown,
IconChevronLeft,
IconChevronUp,
IconFolder,
IconRefresh,
IconSettings,
IconTrash,
useIcons,
@@ -26,6 +28,7 @@ import {
} from '@/navigation-menu-item/hooks/useWorkspaceSectionItems';
import { useUpdateNavigationMenuItemsDraft } from '@/navigation-menu-item/hooks/useUpdateNavigationMenuItemsDraft';
import { selectedNavigationMenuItemInEditModeState } from '@/navigation-menu-item/states/selectedNavigationMenuItemInEditModeState';
import { isNavigationMenuItemFolder } from '@/navigation-menu-item/utils/isNavigationMenuItemFolder';
import { useFilteredObjectMetadataItems } from '@/object-metadata/hooks/useFilteredObjectMetadataItems';
import { useGetStandardObjectIcon } from '@/object-metadata/hooks/useGetStandardObjectIcon';
import { useObjectMetadataItems } from '@/object-metadata/hooks/useObjectMetadataItems';
@@ -436,6 +439,45 @@ export const CommandMenuNavigationMenuItemEditPage = () => {
}),
);
const foldersForFolderPicker = useMemo(() => {
const folders =
currentDraft?.filter(isNavigationMenuItemFolder).map((item) => ({
id: item.id,
name: item.name ?? 'Folder',
folderId: item.folderId ?? undefined,
})) ?? [];
if (!isFolderItem || !selectedNavigationMenuItemInEditMode) {
return { folders, includeNoFolderOption: false };
}
const currentFolderId = selectedNavigationMenuItemInEditMode;
const descendantFolderIds = new Set<string>();
const collectDescendants = (folderId: string) => {
folders
.filter((folder) => folder.folderId === folderId)
.forEach((folder) => {
descendantFolderIds.add(folder.id);
collectDescendants(folder.id);
});
};
collectDescendants(currentFolderId);
const availableFolders = folders.filter(
(folder) =>
folder.id !== currentFolderId && !descendantFolderIds.has(folder.id),
);
return {
folders: availableFolders,
includeNoFolderOption: true,
};
}, [
currentDraft,
isFolderItem,
selectedNavigationMenuItemInEditMode,
]);
if (!selectedNavigationMenuItemInEditMode || !selectedItemLabel) {
return (
<StyledContainer>
@@ -513,15 +555,21 @@ export const CommandMenuNavigationMenuItemEditPage = () => {
};
if (editSubView === 'folder-picker') {
const filteredFolders = workspaceFolders.filter((folder) =>
const foldersToShow = foldersForFolderPicker.includeNoFolderOption
? foldersForFolderPicker.folders
: workspaceFolders;
const filteredFolders = foldersToShow.filter((folder) =>
folder.name
.toLowerCase()
.includes(folderSearchInput.toLowerCase().trim()),
);
const selectableItemIds =
filteredFolders.length > 0
? filteredFolders.map((f) => f.id)
: ['empty'];
const selectableItemIds = [
...(foldersForFolderPicker.includeNoFolderOption ? ['no-folder'] : []),
...(filteredFolders.length > 0 ? filteredFolders.map((f) => f.id) : []),
];
if (selectableItemIds.length === 0) {
selectableItemIds.push('empty');
}
return (
<StyledSubViewContainer>
@@ -543,7 +591,19 @@ export const CommandMenuNavigationMenuItemEditPage = () => {
selectableItemIds={selectableItemIds}
>
<CommandGroup heading={t`Folders`}>
{filteredFolders.length === 0 ? (
{foldersForFolderPicker.includeNoFolderOption && (
<SelectableListItem
itemId="no-folder"
onEnter={() => handleMoveToFolder(null)}
>
<CommandMenuItem
label={t`No folder`}
id="no-folder"
onClick={() => handleMoveToFolder(null)}
/>
</SelectableListItem>
)}
{filteredFolders.length === 0 && !foldersForFolderPicker.includeNoFolderOption ? (
<SelectableListItem itemId="empty" onEnter={() => {}}>
<CommandMenuItem
label={
@@ -979,32 +1039,16 @@ export const CommandMenuNavigationMenuItemEditPage = () => {
}
if (isFolderItem && selectedItem?.type === 'folder') {
const selectableItemIds = ['rename', 'move-up', 'move-down', 'remove'];
const selectableItemIds = [
'move-up',
'move-down',
'remove',
'standard-app',
'reset-to-default',
];
return (
<CommandMenuList commandGroups={[]} selectableItemIds={selectableItemIds}>
<CommandGroup heading={t`Customize`}>
<SelectableListItem
itemId="rename"
onEnter={() => {
setFolderRenameInput(selectedItem.folder.folderName);
setEditSubView('folder-rename');
}}
>
<CommandMenuItem
Icon={IconFolder}
label={t`Rename`}
description={selectedItem.folder.folderName}
contextualTextPosition="right"
hasSubMenu={true}
id="rename"
onClick={() => {
setFolderRenameInput(selectedItem.folder.folderName);
setEditSubView('folder-rename');
}}
/>
</SelectableListItem>
</CommandGroup>
<CommandGroup heading={t`Organize`}>
<SelectableListItem
itemId="move-up"
@@ -1039,6 +1083,26 @@ export const CommandMenuNavigationMenuItemEditPage = () => {
/>
</SelectableListItem>
</CommandGroup>
<CommandGroup heading={t`Owner`}>
<SelectableListItem itemId="standard-app" onEnter={() => {}}>
<CommandMenuItem
Icon={IconApps}
label={t`Standard app`}
id="standard-app"
disabled={true}
onClick={() => {}}
/>
</SelectableListItem>
<SelectableListItem itemId="reset-to-default" onEnter={() => {}}>
<CommandMenuItem
Icon={IconRefresh}
label={t`Reset to default`}
id="reset-to-default"
disabled={true}
onClick={() => {}}
/>
</SelectableListItem>
</CommandGroup>
</CommandMenuList>
);
}
@@ -1,7 +1,7 @@
import styled from '@emotion/styled';
import { useLingui } from '@lingui/react/macro';
import { useMemo, useState } from 'react';
import { useRecoilValue } from 'recoil';
import { useRecoilValue, useSetRecoilState } from 'recoil';
import { isDefined } from 'twenty-shared/utils';
import {
Avatar,
@@ -22,7 +22,9 @@ import { CommandMenuList } from '@/command-menu/components/CommandMenuList';
import { MAX_SEARCH_RESULTS } from '@/command-menu/constants/MaxSearchResults';
import { useCommandMenu } from '@/command-menu/hooks/useCommandMenu';
import { useAddToNavigationMenuDraft } from '@/navigation-menu-item/hooks/useAddToNavigationMenuDraft';
import { useOpenNavigationMenuItemInCommandMenu } from '@/navigation-menu-item/hooks/useOpenNavigationMenuItemInCommandMenu';
import { useNavigationMenuItemsDraftState } from '@/navigation-menu-item/hooks/useNavigationMenuItemsDraftState';
import { selectedNavigationMenuItemInEditModeState } from '@/navigation-menu-item/states/selectedNavigationMenuItemInEditModeState';
import { useWorkspaceNavigationMenuItems } from '@/navigation-menu-item/hooks/useWorkspaceNavigationMenuItems';
import { useApolloCoreClient } from '@/object-metadata/hooks/useApolloCoreClient';
import { useFilteredObjectMetadataItems } from '@/object-metadata/hooks/useFilteredObjectMetadataItems';
@@ -190,10 +192,15 @@ export const CommandMenuNewSidebarItemPage = () => {
const coreViews = useRecoilValue(coreViewsState);
const { objectMetadataItems } = useObjectMetadataItems();
const { objectPermissionsByObjectMetadataId } = useObjectPermissions();
const { addObjectToDraft, addViewToDraft, addRecordToDraft } =
const { addObjectToDraft, addViewToDraft, addRecordToDraft, addFolderToDraft } =
useAddToNavigationMenuDraft();
const { workspaceNavigationMenuItems, navigationMenuItemsDraft } =
useNavigationMenuItemsDraftState();
const setSelectedNavigationMenuItemInEditMode = useSetRecoilState(
selectedNavigationMenuItemInEditModeState,
);
const { openNavigationMenuItemInCommandMenu } =
useOpenNavigationMenuItemInCommandMenu();
const { workspaceNavigationMenuItemsObjectMetadataItems } =
useWorkspaceNavigationMenuItems();
const { activeNonSystemObjectMetadataItems } =
@@ -393,6 +400,16 @@ export const CommandMenuNewSidebarItemPage = () => {
closeCommandMenu();
};
const handleAddFolderAndOpenEdit = () => {
const newFolderId = addFolderToDraft(t`New folder`, currentDraft);
setSelectedNavigationMenuItemInEditMode(newFolderId);
openNavigationMenuItemInCommandMenu({
pageTitle: t`Edit folder`,
pageIcon: IconFolder,
focusTitleInput: true,
});
};
if (selectedOption === 'view' && isDefined(selectedObjectMetadataIdForView)) {
const selectedObjectMetadataItem = objectMetadataItems.find(
(item) => item.id === selectedObjectMetadataIdForView,
@@ -851,13 +868,15 @@ export const CommandMenuNewSidebarItemPage = () => {
</SelectableListItem>
</CommandGroup>
<CommandGroup heading={t`Other`}>
<SelectableListItem itemId="folder" onEnter={() => {}}>
<SelectableListItem
itemId="folder"
onEnter={handleAddFolderAndOpenEdit}
>
<CommandMenuItem
Icon={IconFolder}
label={t`Folder`}
id="folder"
hasSubMenu={true}
disabled={true}
onClick={handleAddFolderAndOpenEdit}
/>
</SelectableListItem>
<SelectableListItem itemId="link" onEnter={() => {}}>
@@ -63,7 +63,7 @@ export const WorkspaceNavigationMenuItems = () => {
setSelectedNavigationMenuItemInEditMode(id);
if (item.type === 'folder') {
openNavigationMenuItemInCommandMenu({
pageTitle: item.folder.folderName,
pageTitle: t`Edit folder`,
pageIcon: IconFolder,
});
} else {
@@ -73,6 +73,35 @@ export const useAddToNavigationMenuDraft = () => {
setNavigationMenuItemsDraft([...currentDraft, newItem]);
};
const addFolderToDraft = (
name: string,
currentDraft: NavigationMenuItem[],
): string => {
const maxPosition = Math.max(
...currentDraft.map((item) => item.position),
0,
);
const newItemId = v4();
const newItem: NavigationMenuItem = {
__typename: 'NavigationMenuItem',
id: newItemId,
viewId: undefined,
targetObjectMetadataId: undefined,
targetRecordId: undefined,
folderId: undefined,
position: maxPosition + 1,
userWorkspaceId: undefined,
name: name.trim(),
applicationId: undefined,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
setNavigationMenuItemsDraft([...currentDraft, newItem]);
return newItemId;
};
const addRecordToDraft = (
searchRecord: SearchRecord,
currentDraft: NavigationMenuItem[],
@@ -113,5 +142,10 @@ export const useAddToNavigationMenuDraft = () => {
setNavigationMenuItemsDraft([...currentDraft, newItem]);
};
return { addObjectToDraft, addViewToDraft, addRecordToDraft };
return {
addObjectToDraft,
addViewToDraft,
addRecordToDraft,
addFolderToDraft,
};
};
@@ -104,10 +104,31 @@ export const useNavigationMenuItemMoveRemove = () => {
);
if (!itemToMove) return draft;
if (isNavigationMenuItemFolder(itemToMove)) {
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)
@@ -8,15 +8,18 @@ export const useOpenNavigationMenuItemInCommandMenu = () => {
const openNavigationMenuItemInCommandMenu = ({
pageTitle,
pageIcon,
focusTitleInput = false,
}: {
pageTitle: string;
pageIcon: IconComponent;
focusTitleInput?: boolean;
}) => {
navigateCommandMenu({
page: CommandMenuPages.NavigationMenuItemEdit,
pageTitle,
pageIcon,
resetNavigationStack: true,
focusTitleInput,
});
};
@@ -8,7 +8,6 @@ export const isNavigationMenuItemFolder = (item: {
targetObjectMetadataId?: string | null;
}) =>
isDefined(item.name) &&
!isDefined(item.folderId) &&
!isDefined(item.targetRecordId) &&
!isDefined(item.targetObjectMetadataId) &&
!isDefined(item.viewId);