diff --git a/packages/twenty-front/src/modules/command-menu/components/CommandMenuFolderInfo.tsx b/packages/twenty-front/src/modules/command-menu/components/CommandMenuFolderInfo.tsx new file mode 100644 index 00000000000..0e2eae70ad2 --- /dev/null +++ b/packages/twenty-front/src/modules/command-menu/components/CommandMenuFolderInfo.tsx @@ -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 ( + + + + } + title={ + setShouldFocusTitleInput(false)} + /> + } + /> + ); +}; diff --git a/packages/twenty-front/src/modules/command-menu/components/CommandMenuPageInfo.tsx b/packages/twenty-front/src/modules/command-menu/components/CommandMenuPageInfo.tsx index 531dea39bf3..9cdb3c6ec1f 100644 --- a/packages/twenty-front/src/modules/command-menu/components/CommandMenuPageInfo.tsx +++ b/packages/twenty-front/src/modules/command-menu/components/CommandMenuPageInfo.tsx @@ -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 ; + } + const isRecordPage = pageChip.page?.page === CommandMenuPages.ViewRecord; if (isRecordPage && isDefined(pageChip.page?.pageId)) { diff --git a/packages/twenty-front/src/modules/command-menu/pages/navigation-menu-item/components/CommandMenuNavigationMenuItemEditPage.tsx b/packages/twenty-front/src/modules/command-menu/pages/navigation-menu-item/components/CommandMenuNavigationMenuItemEditPage.tsx index 6529cf79159..b107013b5cb 100644 --- a/packages/twenty-front/src/modules/command-menu/pages/navigation-menu-item/components/CommandMenuNavigationMenuItemEditPage.tsx +++ b/packages/twenty-front/src/modules/command-menu/pages/navigation-menu-item/components/CommandMenuNavigationMenuItemEditPage.tsx @@ -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(); + 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 ( @@ -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 ( @@ -543,7 +591,19 @@ export const CommandMenuNavigationMenuItemEditPage = () => { selectableItemIds={selectableItemIds} > - {filteredFolders.length === 0 ? ( + {foldersForFolderPicker.includeNoFolderOption && ( + handleMoveToFolder(null)} + > + handleMoveToFolder(null)} + /> + + )} + {filteredFolders.length === 0 && !foldersForFolderPicker.includeNoFolderOption ? ( {}}> { } 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 ( - - { - setFolderRenameInput(selectedItem.folder.folderName); - setEditSubView('folder-rename'); - }} - > - { - setFolderRenameInput(selectedItem.folder.folderName); - setEditSubView('folder-rename'); - }} - /> - - { /> + + {}}> + {}} + /> + + {}}> + {}} + /> + + ); } diff --git a/packages/twenty-front/src/modules/command-menu/pages/navigation-menu-item/components/CommandMenuNewSidebarItemPage.tsx b/packages/twenty-front/src/modules/command-menu/pages/navigation-menu-item/components/CommandMenuNewSidebarItemPage.tsx index de83d3440f2..545a80ba2ff 100644 --- a/packages/twenty-front/src/modules/command-menu/pages/navigation-menu-item/components/CommandMenuNewSidebarItemPage.tsx +++ b/packages/twenty-front/src/modules/command-menu/pages/navigation-menu-item/components/CommandMenuNewSidebarItemPage.tsx @@ -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 = () => { - {}}> + {}}> diff --git a/packages/twenty-front/src/modules/navigation-menu-item/components/WorkspaceNavigationMenuItems.tsx b/packages/twenty-front/src/modules/navigation-menu-item/components/WorkspaceNavigationMenuItems.tsx index a378afbbd57..86c186c0bda 100644 --- a/packages/twenty-front/src/modules/navigation-menu-item/components/WorkspaceNavigationMenuItems.tsx +++ b/packages/twenty-front/src/modules/navigation-menu-item/components/WorkspaceNavigationMenuItems.tsx @@ -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 { diff --git a/packages/twenty-front/src/modules/navigation-menu-item/hooks/useAddToNavigationMenuDraft.ts b/packages/twenty-front/src/modules/navigation-menu-item/hooks/useAddToNavigationMenuDraft.ts index 67835b0f92b..aefa5c6fa7b 100644 --- a/packages/twenty-front/src/modules/navigation-menu-item/hooks/useAddToNavigationMenuDraft.ts +++ b/packages/twenty-front/src/modules/navigation-menu-item/hooks/useAddToNavigationMenuDraft.ts @@ -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, + }; }; diff --git a/packages/twenty-front/src/modules/navigation-menu-item/hooks/useNavigationMenuItemMoveRemove.ts b/packages/twenty-front/src/modules/navigation-menu-item/hooks/useNavigationMenuItemMoveRemove.ts index 2d5e472162e..33224cfaaa4 100644 --- a/packages/twenty-front/src/modules/navigation-menu-item/hooks/useNavigationMenuItemMoveRemove.ts +++ b/packages/twenty-front/src/modules/navigation-menu-item/hooks/useNavigationMenuItemMoveRemove.ts @@ -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(); + 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) diff --git a/packages/twenty-front/src/modules/navigation-menu-item/hooks/useOpenNavigationMenuItemInCommandMenu.ts b/packages/twenty-front/src/modules/navigation-menu-item/hooks/useOpenNavigationMenuItemInCommandMenu.ts index 6aff3842aaa..fef054d5225 100644 --- a/packages/twenty-front/src/modules/navigation-menu-item/hooks/useOpenNavigationMenuItemInCommandMenu.ts +++ b/packages/twenty-front/src/modules/navigation-menu-item/hooks/useOpenNavigationMenuItemInCommandMenu.ts @@ -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, }); }; diff --git a/packages/twenty-front/src/modules/navigation-menu-item/utils/isNavigationMenuItemFolder.ts b/packages/twenty-front/src/modules/navigation-menu-item/utils/isNavigationMenuItemFolder.ts index 7b54793bc62..3fa60c0356e 100644 --- a/packages/twenty-front/src/modules/navigation-menu-item/utils/isNavigationMenuItemFolder.ts +++ b/packages/twenty-front/src/modules/navigation-menu-item/utils/isNavigationMenuItemFolder.ts @@ -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);