From 582530ef1eddf24a0e96d958fb95b8f07085d501 Mon Sep 17 00:00:00 2001 From: nitin <142569587+ehconitin@users.noreply.github.com> Date: Tue, 17 Dec 2024 17:16:58 +0530 Subject: [PATCH] Favorites Drag and Drop Implementation (#8979) Adds drag and drop functionality for favorites management, allowing users to: - Move favorites between folders - Move favorites from folders to orphan section - Move orphan favorites into folders Known Issues: Drop detection at folder boundaries requires spacing workaround --- .../CurrentWorkspaceMemberFavorites.tsx | 65 +- ...CurrentWorkspaceMemberFavoritesFolders.tsx | 87 +-- .../CurrentWorkspaceMemberOrphanFavorites.tsx | 67 ++ .../components/FavoritesDragProvider.tsx | 37 + .../components/FavoritesDroppable.tsx | 70 ++ .../constants/FavoriteDroppableIds.ts | 5 + .../contexts/FavoritesDragContext.tsx | 9 + .../favorites/hooks/__mocks__/useFavorites.ts | 636 ++++++++++-------- .../useHandleFavoriteDragAndDrop.test.tsx | 174 +++++ .../__tests__/useReorderFavorite.test.tsx | 67 -- .../hooks/useHandleFavoriteDragAndDrop.ts | 119 ++++ .../favorites/hooks/useReorderFavorite.ts | 41 -- .../favorites/types/FavoriteDroppableId.ts | 6 + .../__tests__/createFolderDroppableId.test.ts | 17 + .../createFolderHeaderDroppableId.test.ts | 19 + .../validateAndExtractFolderId.test.ts | 47 ++ .../utils/createFolderDroppableId.ts | 6 + .../utils/createFolderHeaderDroppableId.ts | 7 + .../utils/validateAndExtractFolderId.ts | 29 + 19 files changed, 992 insertions(+), 516 deletions(-) create mode 100644 packages/twenty-front/src/modules/favorites/components/CurrentWorkspaceMemberOrphanFavorites.tsx create mode 100644 packages/twenty-front/src/modules/favorites/components/FavoritesDragProvider.tsx create mode 100644 packages/twenty-front/src/modules/favorites/components/FavoritesDroppable.tsx create mode 100644 packages/twenty-front/src/modules/favorites/constants/FavoriteDroppableIds.ts create mode 100644 packages/twenty-front/src/modules/favorites/contexts/FavoritesDragContext.tsx create mode 100644 packages/twenty-front/src/modules/favorites/hooks/__tests__/useHandleFavoriteDragAndDrop.test.tsx delete mode 100644 packages/twenty-front/src/modules/favorites/hooks/__tests__/useReorderFavorite.test.tsx create mode 100644 packages/twenty-front/src/modules/favorites/hooks/useHandleFavoriteDragAndDrop.ts delete mode 100644 packages/twenty-front/src/modules/favorites/hooks/useReorderFavorite.ts create mode 100644 packages/twenty-front/src/modules/favorites/types/FavoriteDroppableId.ts create mode 100644 packages/twenty-front/src/modules/favorites/utils/__tests__/createFolderDroppableId.test.ts create mode 100644 packages/twenty-front/src/modules/favorites/utils/__tests__/createFolderHeaderDroppableId.test.ts create mode 100644 packages/twenty-front/src/modules/favorites/utils/__tests__/validateAndExtractFolderId.test.ts create mode 100644 packages/twenty-front/src/modules/favorites/utils/createFolderDroppableId.ts create mode 100644 packages/twenty-front/src/modules/favorites/utils/createFolderHeaderDroppableId.ts create mode 100644 packages/twenty-front/src/modules/favorites/utils/validateAndExtractFolderId.ts diff --git a/packages/twenty-front/src/modules/favorites/components/CurrentWorkspaceMemberFavorites.tsx b/packages/twenty-front/src/modules/favorites/components/CurrentWorkspaceMemberFavorites.tsx index c6676e033..66219672e 100644 --- a/packages/twenty-front/src/modules/favorites/components/CurrentWorkspaceMemberFavorites.tsx +++ b/packages/twenty-front/src/modules/favorites/components/CurrentWorkspaceMemberFavorites.tsx @@ -1,14 +1,14 @@ import { FavoriteFolderNavigationDrawerItemDropdown } from '@/favorites/components/FavoriteFolderNavigationDrawerItemDropdown'; import { FavoriteIcon } from '@/favorites/components/FavoriteIcon'; +import { FavoritesDroppable } from '@/favorites/components/FavoritesDroppable'; +import { FavoritesDragContext } from '@/favorites/contexts/FavoritesDragContext'; import { useDeleteFavorite } from '@/favorites/hooks/useDeleteFavorite'; import { useDeleteFavoriteFolder } from '@/favorites/hooks/useDeleteFavoriteFolder'; import { useRenameFavoriteFolder } from '@/favorites/hooks/useRenameFavoriteFolder'; -import { useReorderFavorite } from '@/favorites/hooks/useReorderFavorite'; import { activeFavoriteFolderIdState } from '@/favorites/states/activeFavoriteFolderIdState'; import { isLocationMatchingFavorite } from '@/favorites/utils/isLocationMatchingFavorite'; import { ProcessedFavorite } from '@/favorites/utils/sortFavorites'; import { DraggableItem } from '@/ui/layout/draggable-list/components/DraggableItem'; -import { DraggableList } from '@/ui/layout/draggable-list/components/DraggableList'; import { useDropdown } from '@/ui/layout/dropdown/hooks/useDropdown'; import { ConfirmationModal } from '@/ui/layout/modal/components/ConfirmationModal'; import { NavigationDrawerInput } from '@/ui/navigation/navigation-drawer/components/NavigationDrawerInput'; @@ -16,8 +16,8 @@ import { NavigationDrawerItem } from '@/ui/navigation/navigation-drawer/componen import { NavigationDrawerItemsCollapsableContainer } from '@/ui/navigation/navigation-drawer/components/NavigationDrawerItemsCollapsableContainer'; import { NavigationDrawerSubItem } from '@/ui/navigation/navigation-drawer/components/NavigationDrawerSubItem'; import { getNavigationSubItemLeftAdornment } from '@/ui/navigation/navigation-drawer/utils/getNavigationSubItemLeftAdornment'; -import { DragStart, DropResult, ResponderProvided } from '@hello-pangea/dnd'; -import { useState } from 'react'; +import { Droppable } from '@hello-pangea/dnd'; +import { useContext, useState } from 'react'; import { createPortal } from 'react-dom'; import { useLocation } from 'react-router-dom'; import { useRecoilState } from 'recoil'; @@ -43,8 +43,7 @@ export const CurrentWorkspaceMemberFavorites = ({ }: CurrentWorkspaceMemberFavoritesProps) => { const currentPath = useLocation().pathname; const currentViewPath = useLocation().pathname + useLocation().search; - const [isDragging, setIsDragging] = useState(false); - + const { isDragging } = useContext(FavoritesDragContext); const [isFavoriteFolderRenaming, setIsFavoriteFolderRenaming] = useState(false); const [favoriteFolderName, setFavoriteFolderName] = useState( @@ -69,7 +68,6 @@ export const CurrentWorkspaceMemberFavorites = ({ const selectedFavoriteIndex = folder.favorites.findIndex((favorite) => isLocationMatchingFavorite(currentPath, currentViewPath, favorite), ); - const { handleReorderFavorite } = useReorderFavorite(); const { deleteFavorite } = useDeleteFavorite(); @@ -115,15 +113,6 @@ export const CurrentWorkspaceMemberFavorites = ({ setIsDeleteModalOpen(false); }; - const handleDragStart = (_: DragStart) => { - setIsDragging(true); - }; - - const handleDragEnd = (result: DropResult, provided: ResponderProvided) => { - setIsDragging(false); - handleReorderFavorite(result, provided); - }; - const rightOptions = ( ) : ( - + + + )} {isOpen && ( - + + {(provided) => ( +
{folder.favorites.map((favorite, index) => ( } to={favorite.link} @@ -197,9 +193,10 @@ export const CurrentWorkspaceMemberFavorites = ({ } /> ))} - - } - /> + {provided.placeholder} +
+ )} +
)} diff --git a/packages/twenty-front/src/modules/favorites/components/CurrentWorkspaceMemberFavoritesFolders.tsx b/packages/twenty-front/src/modules/favorites/components/CurrentWorkspaceMemberFavoritesFolders.tsx index 4dfdc0910..880569245 100644 --- a/packages/twenty-front/src/modules/favorites/components/CurrentWorkspaceMemberFavoritesFolders.tsx +++ b/packages/twenty-front/src/modules/favorites/components/CurrentWorkspaceMemberFavoritesFolders.tsx @@ -1,47 +1,23 @@ -import { useLocation } from 'react-router-dom'; import { useRecoilState, useRecoilValue } from 'recoil'; -import { - IconFolderPlus, - IconHeartOff, - LightIconButton, - isDefined, -} from 'twenty-ui'; +import { IconFolderPlus, LightIconButton, isDefined } from 'twenty-ui'; import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMemberState'; -import { FavoriteIcon } from '@/favorites/components/FavoriteIcon'; +import { CurrentWorkspaceMemberOrphanFavorites } from '@/favorites/components/CurrentWorkspaceMemberOrphanFavorites'; +import { FavoritesDragProvider } from '@/favorites/components/FavoritesDragProvider'; import { FavoriteFolders } from '@/favorites/components/FavoritesFolders'; import { FavoritesSkeletonLoader } from '@/favorites/components/FavoritesSkeletonLoader'; -import { useDeleteFavorite } from '@/favorites/hooks/useDeleteFavorite'; import { useFavorites } from '@/favorites/hooks/useFavorites'; -import { useReorderFavorite } from '@/favorites/hooks/useReorderFavorite'; import { isFavoriteFolderCreatingState } from '@/favorites/states/isFavoriteFolderCreatingState'; -import { isLocationMatchingFavorite } from '@/favorites/utils/isLocationMatchingFavorite'; import { useIsPrefetchLoading } from '@/prefetch/hooks/useIsPrefetchLoading'; -import { DraggableItem } from '@/ui/layout/draggable-list/components/DraggableItem'; -import { DraggableList } from '@/ui/layout/draggable-list/components/DraggableList'; import { NavigationDrawerAnimatedCollapseWrapper } from '@/ui/navigation/navigation-drawer/components/NavigationDrawerAnimatedCollapseWrapper'; -import { NavigationDrawerItem } from '@/ui/navigation/navigation-drawer/components/NavigationDrawerItem'; import { NavigationDrawerSection } from '@/ui/navigation/navigation-drawer/components/NavigationDrawerSection'; import { NavigationDrawerSectionTitle } from '@/ui/navigation/navigation-drawer/components/NavigationDrawerSectionTitle'; import { useNavigationSection } from '@/ui/navigation/navigation-drawer/hooks/useNavigationSection'; import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled'; -import styled from '@emotion/styled'; -import { DragStart, DropResult, ResponderProvided } from '@hello-pangea/dnd'; -import { useState } from 'react'; - -const StyledOrphanFavoritesContainer = styled.div` - margin-bottom: ${({ theme }) => theme.betweenSiblingsGap}; -`; export const CurrentWorkspaceMemberFavoritesFolders = () => { - const [isDragging, setIsDragging] = useState(false); - - const currentPath = useLocation().pathname; - const currentViewPath = useLocation().pathname + useLocation().search; const currentWorkspaceMember = useRecoilValue(currentWorkspaceMemberState); const { sortedFavorites: favorites } = useFavorites(); - const { deleteFavorite } = useDeleteFavorite(); - const { handleReorderFavorite } = useReorderFavorite(); const [isFavoriteFolderCreating, setIsFavoriteFolderCreating] = useRecoilState(isFavoriteFolderCreatingState); @@ -62,15 +38,6 @@ export const CurrentWorkspaceMemberFavoritesFolders = () => { setIsFavoriteFolderCreating((current) => !current); }; - const handleDragStart = (_: DragStart) => { - setIsDragging(true); - }; - - const handleDragEnd = (result: DropResult, provided: ResponderProvided) => { - setIsDragging(false); - handleReorderFavorite(result, provided); - }; - const shouldDisplayFavoritesWithFeatureFlagEnabled = true; //todo: remove this logic once feature flag gating is removed @@ -85,10 +52,6 @@ export const CurrentWorkspaceMemberFavoritesFolders = () => { return ; } - const orphanFavorites = favorites.filter( - (favorite) => !favorite.favoriteFolderId, - ); - if (!shouldDisplayFavorites) { return null; } @@ -112,52 +75,14 @@ export const CurrentWorkspaceMemberFavoritesFolders = () => { {isNavigationSectionOpen && ( - <> + {isFavoriteFolderEnabled && ( )} - - {orphanFavorites.length > 0 && ( - ( - - } - active={isLocationMatchingFavorite( - currentPath, - currentViewPath, - favorite, - )} - to={favorite.link} - rightOptions={ - deleteFavorite(favorite.id)} - accent="tertiary" - /> - } - isDragging={isDragging} - /> - - } - /> - ))} - /> - )} - + + )} ); diff --git a/packages/twenty-front/src/modules/favorites/components/CurrentWorkspaceMemberOrphanFavorites.tsx b/packages/twenty-front/src/modules/favorites/components/CurrentWorkspaceMemberOrphanFavorites.tsx new file mode 100644 index 000000000..de619a560 --- /dev/null +++ b/packages/twenty-front/src/modules/favorites/components/CurrentWorkspaceMemberOrphanFavorites.tsx @@ -0,0 +1,67 @@ +import { FavoriteIcon } from '@/favorites/components/FavoriteIcon'; +import { FavoritesDroppable } from '@/favorites/components/FavoritesDroppable'; +import { FavoritesDragContext } from '@/favorites/contexts/FavoritesDragContext'; +import { useDeleteFavorite } from '@/favorites/hooks/useDeleteFavorite'; +import { useFavorites } from '@/favorites/hooks/useFavorites'; +import { isLocationMatchingFavorite } from '@/favorites/utils/isLocationMatchingFavorite'; +import { DraggableItem } from '@/ui/layout/draggable-list/components/DraggableItem'; +import { NavigationDrawerItem } from '@/ui/navigation/navigation-drawer/components/NavigationDrawerItem'; +import styled from '@emotion/styled'; +import { LightIconButton } from '@ui/input/button/components/LightIconButton'; +import { useContext } from 'react'; +import { useLocation } from 'react-router-dom'; +import { IconHeartOff } from 'twenty-ui'; + +const StyledEmptyContainer = styled.div` + height: ${({ theme }) => theme.spacing(2.5)}; + width: 100%; +`; + +export const CurrentWorkspaceMemberOrphanFavorites = () => { + const { sortedFavorites: favorites } = useFavorites(); + const { deleteFavorite } = useDeleteFavorite(); + const currentPath = useLocation().pathname; + const currentViewPath = useLocation().pathname + useLocation().search; + const { isDragging } = useContext(FavoritesDragContext); + + const orphanFavorites = favorites.filter( + (favorite) => !favorite.favoriteFolderId, + ); + + return ( + + {orphanFavorites.length > 0 ? ( + orphanFavorites.map((favorite, index) => ( + } + active={isLocationMatchingFavorite( + currentPath, + currentViewPath, + favorite, + )} + to={favorite.link} + rightOptions={ + deleteFavorite(favorite.id)} + accent="tertiary" + /> + } + isDragging={isDragging} + /> + } + /> + )) + ) : ( + + )} + + ); +}; diff --git a/packages/twenty-front/src/modules/favorites/components/FavoritesDragProvider.tsx b/packages/twenty-front/src/modules/favorites/components/FavoritesDragProvider.tsx new file mode 100644 index 000000000..db3f1700f --- /dev/null +++ b/packages/twenty-front/src/modules/favorites/components/FavoritesDragProvider.tsx @@ -0,0 +1,37 @@ +import { FavoritesDragContext } from '@/favorites/contexts/FavoritesDragContext'; +import { useHandleFavoriteDragAndDrop } from '@/favorites/hooks/useHandleFavoriteDragAndDrop'; +import { + DragDropContext, + DragStart, + DropResult, + ResponderProvided, +} from '@hello-pangea/dnd'; +import { ReactNode, useState } from 'react'; + +type FavoritesDragProviderProps = { + children: ReactNode; +}; + +export const FavoritesDragProvider = ({ + children, +}: FavoritesDragProviderProps) => { + const [isDragging, setIsDragging] = useState(false); + const { handleFavoriteDragAndDrop } = useHandleFavoriteDragAndDrop(); + + const handleDragStart = (_: DragStart) => { + setIsDragging(true); + }; + + const handleDragEnd = (result: DropResult, provided: ResponderProvided) => { + setIsDragging(false); + handleFavoriteDragAndDrop(result, provided); + }; + + return ( + + + {children} + + + ); +}; diff --git a/packages/twenty-front/src/modules/favorites/components/FavoritesDroppable.tsx b/packages/twenty-front/src/modules/favorites/components/FavoritesDroppable.tsx new file mode 100644 index 000000000..e4d652dff --- /dev/null +++ b/packages/twenty-front/src/modules/favorites/components/FavoritesDroppable.tsx @@ -0,0 +1,70 @@ +import styled from '@emotion/styled'; +import { Droppable } from '@hello-pangea/dnd'; + +type FavoritesDroppableProps = { + droppableId: string; + children: React.ReactNode; + isDragIndicatorVisible?: boolean; + showDropLine?: boolean; +}; + +const StyledDroppableWrapper = styled.div<{ + isDraggingOver: boolean; + isDragIndicatorVisible: boolean; + showDropLine: boolean; +}>` + position: relative; + transition: all 150ms ease-in-out; + width: 100%; + + ${({ isDraggingOver, isDragIndicatorVisible, showDropLine, theme }) => + isDraggingOver && + isDragIndicatorVisible && + ` + background-color: ${theme.background.transparent.blue}; + + ${ + showDropLine && + ` + &::before { + content: ''; + position: absolute; + bottom: 0; + left: 0; + width: 100%; + height: 2px; + background-color: ${theme.color.blue}; + border-radius: ${theme.border.radius.sm} ${theme.border.radius.sm} 0 0; + } + ` + } + `} +`; + +export const FavoritesDroppable = ({ + droppableId, + children, + isDragIndicatorVisible = true, + showDropLine = true, +}: FavoritesDroppableProps) => { + return ( + + {(provided, snapshot) => ( + +
+ {children} + {provided.placeholder} +
+
+ )} +
+ ); +}; diff --git a/packages/twenty-front/src/modules/favorites/constants/FavoriteDroppableIds.ts b/packages/twenty-front/src/modules/favorites/constants/FavoriteDroppableIds.ts new file mode 100644 index 000000000..fbb63fa66 --- /dev/null +++ b/packages/twenty-front/src/modules/favorites/constants/FavoriteDroppableIds.ts @@ -0,0 +1,5 @@ +export const FAVORITE_DROPPABLE_IDS = { + ORPHAN_FAVORITES: 'orphan-favorites', + FOLDER_PREFIX: 'folder-', + FOLDER_HEADER_PREFIX: 'folder-header-', +}; diff --git a/packages/twenty-front/src/modules/favorites/contexts/FavoritesDragContext.tsx b/packages/twenty-front/src/modules/favorites/contexts/FavoritesDragContext.tsx new file mode 100644 index 000000000..0bdaa1ea8 --- /dev/null +++ b/packages/twenty-front/src/modules/favorites/contexts/FavoritesDragContext.tsx @@ -0,0 +1,9 @@ +import { createContext } from 'react'; + +type FavoritesDragContextType = { + isDragging: boolean; +}; + +export const FavoritesDragContext = createContext({ + isDragging: false, +}); diff --git a/packages/twenty-front/src/modules/favorites/hooks/__mocks__/useFavorites.ts b/packages/twenty-front/src/modules/favorites/hooks/__mocks__/useFavorites.ts index e5d3e601a..154bad46c 100644 --- a/packages/twenty-front/src/modules/favorites/hooks/__mocks__/useFavorites.ts +++ b/packages/twenty-front/src/modules/favorites/hooks/__mocks__/useFavorites.ts @@ -25,7 +25,7 @@ export const initialFavorites = [ person: { id: '1', name: 'John Doe' }, company: { id: '2', name: 'ABC Corp' }, workspaceMemberId: '1', - favoriteFolderId: undefined, + favoriteFolderId: '1', }, { __typename: 'Favorite', @@ -40,7 +40,7 @@ export const initialFavorites = [ person: { id: '3', name: 'Jane Doe' }, company: { id: '4', name: 'Company Test' }, workspaceMemberId: '1', - favoriteFolderId: undefined, + favoriteFolderId: '1', }, { @@ -54,7 +54,7 @@ export const initialFavorites = [ link: 'example.com', recordId: '1', workspaceMemberId: '1', - favoriteFolderId: undefined, + favoriteFolderId: '1', }, ]; @@ -69,7 +69,7 @@ export const sortedFavorites = [ link: '/object/person/1', objectNameSingular: 'person', workspaceMemberId: '1', - favoriteFolderId: undefined, + favoriteFolderId: '1', __typename: 'Favorite', }, { @@ -82,7 +82,7 @@ export const sortedFavorites = [ link: '/object/person/3', objectNameSingular: 'person', workspaceMemberId: '1', - favoriteFolderId: undefined, + favoriteFolderId: '1', __typename: 'Favorite', }, { @@ -94,12 +94,298 @@ export const sortedFavorites = [ link: 'example.com', recordId: '1', avatarType: 'squared', - favoriteFolderId: undefined, + favoriteFolderId: '1', workspaceMemberId: '1', __typename: 'Favorite', }, ]; +const UPDATE_ONE_FAVORITE_MUTATION = gql` +mutation UpdateOneFavorite( + $idToUpdate: ID! + $input: FavoriteUpdateInput! +) { + updateFavorite(id: $idToUpdate, data: $input) { + __typename + company { + __typename + accountOwnerId + address { + addressStreet1 + addressStreet2 + addressCity + addressState + addressCountry + addressPostcode + addressLat + addressLng + } + annualRecurringRevenue { + amountMicros + currencyCode + } + createdAt + createdBy { + source + workspaceMemberId + name + } + deletedAt + domainName { + primaryLinkUrl + primaryLinkLabel + secondaryLinks + } + employees + id + idealCustomerProfile + introVideo { + primaryLinkUrl + primaryLinkLabel + secondaryLinks + } + linkedinLink { + primaryLinkUrl + primaryLinkLabel + secondaryLinks + } + name + position + tagline + updatedAt + visaSponsorship + workPolicy + xLink { + primaryLinkUrl + primaryLinkLabel + secondaryLinks + } + } + companyId + createdAt + deletedAt + favoriteFolder { + __typename + createdAt + deletedAt + id + name + position + updatedAt + } + favoriteFolderId + id + note { + __typename + body + createdAt + createdBy { + source + workspaceMemberId + name + } + deletedAt + id + position + title + updatedAt + } + noteId + opportunity { + __typename + amount { + amountMicros + currencyCode + } + closeDate + companyId + createdAt + createdBy { + source + workspaceMemberId + name + } + deletedAt + id + name + pointOfContactId + position + stage + updatedAt + } + opportunityId + person { + __typename + avatarUrl + city + companyId + createdAt + createdBy { + source + workspaceMemberId + name + } + deletedAt + emails { + primaryEmail + additionalEmails + } + id + intro + jobTitle + linkedinLink { + primaryLinkUrl + primaryLinkLabel + secondaryLinks + } + name { + firstName + lastName + } + performanceRating + phones { + primaryPhoneNumber + primaryPhoneCountryCode + additionalPhones + } + position + updatedAt + whatsapp { + primaryPhoneNumber + primaryPhoneCountryCode + additionalPhones + } + workPreference + xLink { + primaryLinkUrl + primaryLinkLabel + secondaryLinks + } + } + personId + position + rocket { + __typename + createdAt + createdBy { + source + workspaceMemberId + name + } + deletedAt + id + name + position + updatedAt + } + rocketId + task { + __typename + assigneeId + body + createdAt + createdBy { + source + workspaceMemberId + name + } + deletedAt + dueAt + id + position + status + title + updatedAt + } + taskId + updatedAt + view { + __typename + createdAt + deletedAt + icon + id + isCompact + kanbanFieldMetadataId + key + name + objectMetadataId + position + type + updatedAt + } + viewId + workflow { + __typename + createdAt + deletedAt + id + lastPublishedVersionId + name + position + statuses + updatedAt + } + workflowId + workflowRun { + __typename + createdAt + createdBy { + source + workspaceMemberId + name + } + deletedAt + endedAt + id + name + output + position + startedAt + status + updatedAt + workflowId + workflowVersionId + } + workflowRunId + workflowVersion { + __typename + createdAt + deletedAt + id + name + position + status + steps + trigger + updatedAt + workflowId + } + workflowVersionId + workspaceMember { + __typename + avatarUrl + colorScheme + createdAt + dateFormat + deletedAt + id + locale + name { + firstName + lastName + } + timeFormat + timeZone + updatedAt + userEmail + userId + } + workspaceMemberId + } +} +`; + export const mocks = [ { request: { @@ -388,7 +674,7 @@ export const mocks = [ variables: { input: { personId: favoriteTargetObjectId, - position: 3, + position: 1, workspaceMemberId: '1', favoriteFolderId: undefined, id: mockId, @@ -430,291 +716,7 @@ export const mocks = [ }, { request: { - query: gql` - mutation UpdateOneFavorite( - $idToUpdate: ID! - $input: FavoriteUpdateInput! - ) { - updateFavorite(id: $idToUpdate, data: $input) { - __typename - company { - __typename - accountOwnerId - address { - addressStreet1 - addressStreet2 - addressCity - addressState - addressCountry - addressPostcode - addressLat - addressLng - } - annualRecurringRevenue { - amountMicros - currencyCode - } - createdAt - createdBy { - source - workspaceMemberId - name - } - deletedAt - domainName { - primaryLinkUrl - primaryLinkLabel - secondaryLinks - } - employees - id - idealCustomerProfile - introVideo { - primaryLinkUrl - primaryLinkLabel - secondaryLinks - } - linkedinLink { - primaryLinkUrl - primaryLinkLabel - secondaryLinks - } - name - position - tagline - updatedAt - visaSponsorship - workPolicy - xLink { - primaryLinkUrl - primaryLinkLabel - secondaryLinks - } - } - companyId - createdAt - deletedAt - favoriteFolder { - __typename - createdAt - deletedAt - id - name - position - updatedAt - } - favoriteFolderId - id - note { - __typename - body - createdAt - createdBy { - source - workspaceMemberId - name - } - deletedAt - id - position - title - updatedAt - } - noteId - opportunity { - __typename - amount { - amountMicros - currencyCode - } - closeDate - companyId - createdAt - createdBy { - source - workspaceMemberId - name - } - deletedAt - id - name - pointOfContactId - position - stage - updatedAt - } - opportunityId - person { - __typename - avatarUrl - city - companyId - createdAt - createdBy { - source - workspaceMemberId - name - } - deletedAt - emails { - primaryEmail - additionalEmails - } - id - intro - jobTitle - linkedinLink { - primaryLinkUrl - primaryLinkLabel - secondaryLinks - } - name { - firstName - lastName - } - performanceRating - phones { - primaryPhoneNumber - primaryPhoneCountryCode - additionalPhones - } - position - updatedAt - whatsapp { - primaryPhoneNumber - primaryPhoneCountryCode - additionalPhones - } - workPreference - xLink { - primaryLinkUrl - primaryLinkLabel - secondaryLinks - } - } - personId - position - rocket { - __typename - createdAt - createdBy { - source - workspaceMemberId - name - } - deletedAt - id - name - position - updatedAt - } - rocketId - task { - __typename - assigneeId - body - createdAt - createdBy { - source - workspaceMemberId - name - } - deletedAt - dueAt - id - position - status - title - updatedAt - } - taskId - updatedAt - view { - __typename - createdAt - deletedAt - icon - id - isCompact - kanbanFieldMetadataId - key - name - objectMetadataId - position - type - updatedAt - } - viewId - workflow { - __typename - createdAt - deletedAt - id - lastPublishedVersionId - name - position - statuses - updatedAt - } - workflowId - workflowRun { - __typename - createdAt - createdBy { - source - workspaceMemberId - name - } - deletedAt - endedAt - id - name - output - position - startedAt - status - updatedAt - workflowId - workflowVersionId - } - workflowRunId - workflowVersion { - __typename - createdAt - deletedAt - id - name - position - status - steps - trigger - updatedAt - workflowId - } - workflowVersionId - workspaceMember { - __typename - avatarUrl - colorScheme - createdAt - dateFormat - deletedAt - id - locale - name { - firstName - lastName - } - timeFormat - timeZone - updatedAt - userEmail - userId - } - workspaceMemberId - } - } - `, + query: UPDATE_ONE_FAVORITE_MUTATION, variables: { idToUpdate: '1', input: { @@ -726,12 +728,60 @@ export const mocks = [ data: { updateFavorite: { __typename: 'Favorite', - id: favoriteId, + id: favoriteId, position: 2, }, }, })), }, + + // Mock for folder move + { + request: { + query: UPDATE_ONE_FAVORITE_MUTATION, + variables: { + idToUpdate: '1', + input: { + position: 0, + favoriteFolderId: '2', + }, + }, + }, + result: jest.fn(() => ({ + data: { + updateFavorite: { + __typename: 'Favorite', + id: favoriteId, + position: 0, + favoriteFolderId: '2', + }, + }, + })), + }, + + // Mock for orphan favorites + { + request: { + query: UPDATE_ONE_FAVORITE_MUTATION, + variables: { + idToUpdate: '1', + input: { + position: 0, + favoriteFolderId: null, + }, + }, + }, + result: jest.fn(() => ({ + data: { + updateFavorite: { + __typename: 'Favorite', + id: favoriteId, + position: 0, + favoriteFolderId: null, + }, + }, + })), + }, ]; export const mockWorkspaceMember = { diff --git a/packages/twenty-front/src/modules/favorites/hooks/__tests__/useHandleFavoriteDragAndDrop.test.tsx b/packages/twenty-front/src/modules/favorites/hooks/__tests__/useHandleFavoriteDragAndDrop.test.tsx new file mode 100644 index 000000000..486504d54 --- /dev/null +++ b/packages/twenty-front/src/modules/favorites/hooks/__tests__/useHandleFavoriteDragAndDrop.test.tsx @@ -0,0 +1,174 @@ +import { DropResult, ResponderProvided } from '@hello-pangea/dnd'; +import { renderHook, waitFor } from '@testing-library/react'; +import { act } from 'react'; +import { useSetRecoilState } from 'recoil'; + +import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMemberState'; +import { FAVORITE_DROPPABLE_IDS } from '@/favorites/constants/FavoriteDroppableIds'; +import { useHandleFavoriteDragAndDrop } from '@/favorites/hooks/useHandleFavoriteDragAndDrop'; +import { createFolderDroppableId } from '@/favorites/utils/createFolderDroppableId'; +import { createFolderHeaderDroppableId } from '@/favorites/utils/createFolderHeaderDroppableId'; +import { objectMetadataItemsState } from '@/object-metadata/states/objectMetadataItemsState'; +import { getJestMetadataAndApolloMocksWrapper } from '~/testing/jest/getJestMetadataAndApolloMocksWrapper'; +import { generatedMockObjectMetadataItems } from '~/testing/mock-data/generatedMockObjectMetadataItems'; + +import { + initialFavorites, + mockWorkspaceMember, + mocks, +} from '../__mocks__/useFavorites'; + +jest.mock('@/object-record/hooks/useFindManyRecords', () => ({ + useFindManyRecords: () => ({ records: initialFavorites }), +})); + +const Wrapper = getJestMetadataAndApolloMocksWrapper({ + apolloMocks: mocks, +}); + +describe('useHandleFavoriteDragAndDrop', () => { + const mockResponderProvided: ResponderProvided = { + announce: jest.fn(), + }; + + const setupHook = () => { + return renderHook( + () => { + const setCurrentWorkspaceMember = useSetRecoilState( + currentWorkspaceMemberState, + ); + const setMetadataItems = useSetRecoilState(objectMetadataItemsState); + + setCurrentWorkspaceMember(mockWorkspaceMember); + setMetadataItems(generatedMockObjectMetadataItems); + + return { + hook: useHandleFavoriteDragAndDrop(), + }; + }, + { wrapper: Wrapper }, + ); + }; + + it('should not update when destination is null', () => { + const { result } = setupHook(); + + act(() => { + result.current.hook.handleFavoriteDragAndDrop( + { + source: { index: 0, droppableId: createFolderDroppableId('1') }, + destination: null, + draggableId: '1', + } as DropResult, + mockResponderProvided, + ); + }); + + expect(mocks[2].result).not.toHaveBeenCalled(); + expect(mocks[3].result).not.toHaveBeenCalled(); + expect(mocks[4].result).not.toHaveBeenCalled(); + }); + + it('should not update when destination is same as source', () => { + const { result } = setupHook(); + const folderOneId = createFolderDroppableId('1'); + + act(() => { + result.current.hook.handleFavoriteDragAndDrop( + { + source: { index: 0, droppableId: folderOneId }, + destination: { index: 0, droppableId: folderOneId }, + draggableId: '1', + } as DropResult, + mockResponderProvided, + ); + }); + + expect(mocks[2].result).not.toHaveBeenCalled(); + expect(mocks[3].result).not.toHaveBeenCalled(); + expect(mocks[4].result).not.toHaveBeenCalled(); + }); + + it('should reorder within same folder', async () => { + const { result } = setupHook(); + const folderOneId = createFolderDroppableId('1'); + + act(() => { + result.current.hook.handleFavoriteDragAndDrop( + { + source: { index: 0, droppableId: folderOneId }, + destination: { index: 2, droppableId: folderOneId }, + draggableId: '1', + } as DropResult, + mockResponderProvided, + ); + }); + + await waitFor(() => { + expect(mocks[2].result).toHaveBeenCalled(); + }); + }); + + it('should move to another folder', async () => { + const { result } = setupHook(); + + act(() => { + result.current.hook.handleFavoriteDragAndDrop( + { + source: { index: 0, droppableId: createFolderDroppableId('1') }, + destination: { index: 0, droppableId: createFolderDroppableId('2') }, + draggableId: '1', + } as DropResult, + mockResponderProvided, + ); + }); + + await waitFor(() => { + expect(mocks[3].result).toHaveBeenCalled(); + }); + }); + + it('should move to orphan favorites', async () => { + const { result } = setupHook(); + + act(() => { + result.current.hook.handleFavoriteDragAndDrop( + { + source: { index: 0, droppableId: createFolderDroppableId('1') }, + destination: { + index: 0, + droppableId: FAVORITE_DROPPABLE_IDS.ORPHAN_FAVORITES, + }, + draggableId: '1', + } as DropResult, + mockResponderProvided, + ); + }); + + await waitFor(() => { + expect(mocks[4].result).toHaveBeenCalled(); + }); + }); + + it('should handle dropping into folder header', async () => { + const { result } = setupHook(); + + act(() => { + result.current.hook.handleFavoriteDragAndDrop( + { + source: { index: 0, droppableId: createFolderDroppableId('1') }, + destination: { + index: 0, + droppableId: createFolderHeaderDroppableId('2'), + }, + draggableId: '1', + } as DropResult, + mockResponderProvided, + ); + }); + + await waitFor(() => { + expect(mocks[3].result).toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/twenty-front/src/modules/favorites/hooks/__tests__/useReorderFavorite.test.tsx b/packages/twenty-front/src/modules/favorites/hooks/__tests__/useReorderFavorite.test.tsx deleted file mode 100644 index c43c8e1ab..000000000 --- a/packages/twenty-front/src/modules/favorites/hooks/__tests__/useReorderFavorite.test.tsx +++ /dev/null @@ -1,67 +0,0 @@ -import { DropResult, ResponderProvided } from '@hello-pangea/dnd'; -import { renderHook, waitFor } from '@testing-library/react'; -import { act } from 'react'; -import { useSetRecoilState } from 'recoil'; - -import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMemberState'; -import { useReorderFavorite } from '@/favorites/hooks/useReorderFavorite'; -import { objectMetadataItemsState } from '@/object-metadata/states/objectMetadataItemsState'; -import { getJestMetadataAndApolloMocksWrapper } from '~/testing/jest/getJestMetadataAndApolloMocksWrapper'; -import { generatedMockObjectMetadataItems } from '~/testing/mock-data/generatedMockObjectMetadataItems'; -import { - initialFavorites, - mockWorkspaceMember, - mocks, -} from '../__mocks__/useFavorites'; - -jest.mock('@/object-record/hooks/useFindManyRecords', () => ({ - useFindManyRecords: () => ({ records: initialFavorites }), -})); - -const Wrapper = getJestMetadataAndApolloMocksWrapper({ - apolloMocks: mocks, -}); - -describe('useReorderFavorite', () => { - it('should handle reordering favorites successfully', async () => { - const { result } = renderHook( - () => { - const setCurrentWorkspaceMember = useSetRecoilState( - currentWorkspaceMemberState, - ); - setCurrentWorkspaceMember(mockWorkspaceMember); - - const setMetadataItems = useSetRecoilState(objectMetadataItemsState); - setMetadataItems(generatedMockObjectMetadataItems); - - return useReorderFavorite(); - }, - { wrapper: Wrapper }, - ); - - act(() => { - const dragAndDropResult: DropResult = { - source: { index: 0, droppableId: 'droppableId' }, - destination: { index: 2, droppableId: 'droppableId' }, - combine: null, - mode: 'FLUID', - draggableId: '1', - type: 'type', - reason: 'DROP', - }; - - const responderProvided: ResponderProvided = { - announce: () => {}, - }; - - result.current.handleReorderFavorite( - dragAndDropResult, - responderProvided, - ); - }); - - await waitFor(() => { - expect(mocks[2].result).toHaveBeenCalled(); - }); - }); -}); diff --git a/packages/twenty-front/src/modules/favorites/hooks/useHandleFavoriteDragAndDrop.ts b/packages/twenty-front/src/modules/favorites/hooks/useHandleFavoriteDragAndDrop.ts new file mode 100644 index 000000000..6cf50bfa3 --- /dev/null +++ b/packages/twenty-front/src/modules/favorites/hooks/useHandleFavoriteDragAndDrop.ts @@ -0,0 +1,119 @@ +import { FAVORITE_DROPPABLE_IDS } from '@/favorites/constants/FavoriteDroppableIds'; +import { useSortedFavorites } from '@/favorites/hooks/useSortedFavorites'; +import { activeFavoriteFolderIdState } from '@/favorites/states/activeFavoriteFolderIdState'; +import { calculateNewPosition } from '@/favorites/utils/calculateNewPosition'; +import { validateAndExtractFolderId } from '@/favorites/utils/validateAndExtractFolderId'; +import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular'; +import { useUpdateOneRecord } from '@/object-record/hooks/useUpdateOneRecord'; +import { OnDragEndResponder } from '@hello-pangea/dnd'; +import { useSetRecoilState } from 'recoil'; +import { usePrefetchedFavoritesData } from './usePrefetchedFavoritesData'; + +export const useHandleFavoriteDragAndDrop = () => { + const { favorites } = usePrefetchedFavoritesData(); + const { favoritesSorted } = useSortedFavorites(); + const { updateOneRecord: updateOneFavorite } = useUpdateOneRecord({ + objectNameSingular: CoreObjectNameSingular.Favorite, + }); + const setActiveFavoriteFolderId = useSetRecoilState( + activeFavoriteFolderIdState, + ); + + const handleFavoriteDragAndDrop: OnDragEndResponder = (result) => { + const { destination, source, draggableId } = result; + + if (!destination) return; + + if ( + destination.droppableId === source.droppableId && + destination.index === source.index + ) { + return; + } + + const draggedFavorite = favorites.find((f) => f.id === draggableId); + if (!draggedFavorite) return; + + const destinationFolderId = validateAndExtractFolderId( + destination.droppableId, + ); + const sourceFolderId = validateAndExtractFolderId(source.droppableId); + + if ( + destination.droppableId.startsWith( + FAVORITE_DROPPABLE_IDS.FOLDER_HEADER_PREFIX, + ) + ) { + if (destinationFolderId === null) + throw new Error('Invalid folder header ID'); + + const folderFavorites = favoritesSorted.filter( + (favorite) => favorite.favoriteFolderId === destinationFolderId, + ); + + const newPosition = + folderFavorites.length === 0 + ? 0 + : folderFavorites[folderFavorites.length - 1].position + 1; + + updateOneFavorite({ + idToUpdate: draggableId, + updateOneRecordInput: { + favoriteFolderId: destinationFolderId, + position: newPosition, + }, + }); + + setActiveFavoriteFolderId(destinationFolderId); + return; + } + + if (destination.droppableId !== source.droppableId) { + const destinationFavorites = favoritesSorted.filter( + (favorite) => favorite.favoriteFolderId === destinationFolderId, + ); + + let newPosition; + if (destinationFavorites.length === 0) { + newPosition = 0; + } else if (destination.index === 0) { + newPosition = destinationFavorites[0].position / 2; + } else if (destination.index >= destinationFavorites.length) { + newPosition = + destinationFavorites[destinationFavorites.length - 1].position + 1; + } else { + newPosition = calculateNewPosition({ + destinationIndex: destination.index, + sourceIndex: -1, + items: destinationFavorites, + }); + } + + updateOneFavorite({ + idToUpdate: draggableId, + updateOneRecordInput: { + favoriteFolderId: destinationFolderId, + position: newPosition, + }, + }); + return; + } + + const favoritesInSameList = favoritesSorted.filter( + (favorite) => favorite.favoriteFolderId === sourceFolderId, + ); + + const newPosition = calculateNewPosition({ + destinationIndex: destination.index, + sourceIndex: source.index, + items: favoritesInSameList, + }); + + updateOneFavorite({ + idToUpdate: draggableId, + updateOneRecordInput: { position: newPosition }, + }); + }; + + return { handleFavoriteDragAndDrop }; +}; diff --git a/packages/twenty-front/src/modules/favorites/hooks/useReorderFavorite.ts b/packages/twenty-front/src/modules/favorites/hooks/useReorderFavorite.ts deleted file mode 100644 index bbbbf1285..000000000 --- a/packages/twenty-front/src/modules/favorites/hooks/useReorderFavorite.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { useSortedFavorites } from '@/favorites/hooks/useSortedFavorites'; -import { calculateNewPosition } from '@/favorites/utils/calculateNewPosition'; -import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular'; -import { useUpdateOneRecord } from '@/object-record/hooks/useUpdateOneRecord'; -import { OnDragEndResponder } from '@hello-pangea/dnd'; -import { usePrefetchedFavoritesData } from './usePrefetchedFavoritesData'; - -export const useReorderFavorite = () => { - const { favorites } = usePrefetchedFavoritesData(); - const { favoritesSorted } = useSortedFavorites(); - const { updateOneRecord: updateOneFavorite } = useUpdateOneRecord({ - objectNameSingular: CoreObjectNameSingular.Favorite, - }); - - const handleReorderFavorite: OnDragEndResponder = (result) => { - if (!result.destination) return; - - const draggedFavoriteId = result.draggableId; - const draggedFavorite = favorites.find((f) => f.id === draggedFavoriteId); - - if (!draggedFavorite) return; - - const inSameFolderFavorites = favoritesSorted.filter( - (fav) => fav.favoriteFolderId === draggedFavorite.favoriteFolderId, - ); - if (!inSameFolderFavorites.length) return; - - const newPosition = calculateNewPosition({ - destinationIndex: result.destination.index, - sourceIndex: result.source.index, - items: inSameFolderFavorites, - }); - - updateOneFavorite({ - idToUpdate: draggedFavoriteId, - updateOneRecordInput: { position: newPosition }, - }); - }; - - return { handleReorderFavorite }; -}; diff --git a/packages/twenty-front/src/modules/favorites/types/FavoriteDroppableId.ts b/packages/twenty-front/src/modules/favorites/types/FavoriteDroppableId.ts new file mode 100644 index 000000000..93107bcfe --- /dev/null +++ b/packages/twenty-front/src/modules/favorites/types/FavoriteDroppableId.ts @@ -0,0 +1,6 @@ +import { FAVORITE_DROPPABLE_IDS } from '@/favorites/constants/FavoriteDroppableIds'; + +export type FavoriteDroppableId = + | typeof FAVORITE_DROPPABLE_IDS.ORPHAN_FAVORITES + | `${typeof FAVORITE_DROPPABLE_IDS.FOLDER_PREFIX}${string}` + | `${typeof FAVORITE_DROPPABLE_IDS.FOLDER_HEADER_PREFIX}${string}`; diff --git a/packages/twenty-front/src/modules/favorites/utils/__tests__/createFolderDroppableId.test.ts b/packages/twenty-front/src/modules/favorites/utils/__tests__/createFolderDroppableId.test.ts new file mode 100644 index 000000000..3b43e162e --- /dev/null +++ b/packages/twenty-front/src/modules/favorites/utils/__tests__/createFolderDroppableId.test.ts @@ -0,0 +1,17 @@ +import { FAVORITE_DROPPABLE_IDS } from '@/favorites/constants/FavoriteDroppableIds'; +import { createFolderDroppableId } from '../createFolderDroppableId'; + +describe('createFolderDroppableId', () => { + it('should create a valid folder droppable id', () => { + const folderId = '123-456'; + const result = createFolderDroppableId(folderId); + + expect(result).toBe(`${FAVORITE_DROPPABLE_IDS.FOLDER_PREFIX}${folderId}`); + }); + + it('should work with empty string', () => { + const result = createFolderDroppableId(''); + + expect(result).toBe(FAVORITE_DROPPABLE_IDS.FOLDER_PREFIX); + }); +}); diff --git a/packages/twenty-front/src/modules/favorites/utils/__tests__/createFolderHeaderDroppableId.test.ts b/packages/twenty-front/src/modules/favorites/utils/__tests__/createFolderHeaderDroppableId.test.ts new file mode 100644 index 000000000..997d997ca --- /dev/null +++ b/packages/twenty-front/src/modules/favorites/utils/__tests__/createFolderHeaderDroppableId.test.ts @@ -0,0 +1,19 @@ +import { FAVORITE_DROPPABLE_IDS } from '@/favorites/constants/FavoriteDroppableIds'; +import { createFolderHeaderDroppableId } from '../createFolderHeaderDroppableId'; + +describe('createFolderHeaderDroppableId', () => { + it('should create a valid folder header droppable id', () => { + const folderId = '123-456'; + const result = createFolderHeaderDroppableId(folderId); + + expect(result).toBe( + `${FAVORITE_DROPPABLE_IDS.FOLDER_HEADER_PREFIX}${folderId}`, + ); + }); + + it('should work with empty string', () => { + const result = createFolderHeaderDroppableId(''); + + expect(result).toBe(FAVORITE_DROPPABLE_IDS.FOLDER_HEADER_PREFIX); + }); +}); diff --git a/packages/twenty-front/src/modules/favorites/utils/__tests__/validateAndExtractFolderId.test.ts b/packages/twenty-front/src/modules/favorites/utils/__tests__/validateAndExtractFolderId.test.ts new file mode 100644 index 000000000..dc579e6e6 --- /dev/null +++ b/packages/twenty-front/src/modules/favorites/utils/__tests__/validateAndExtractFolderId.test.ts @@ -0,0 +1,47 @@ +import { FAVORITE_DROPPABLE_IDS } from '@/favorites/constants/FavoriteDroppableIds'; +import { validateAndExtractFolderId } from '../validateAndExtractFolderId'; + +describe('validateAndExtractFolderId', () => { + it('should return null for orphan favorites', () => { + const result = validateAndExtractFolderId( + FAVORITE_DROPPABLE_IDS.ORPHAN_FAVORITES, + ); + expect(result).toBeNull(); + }); + + it('should extract folder id from folder droppable id', () => { + const folderId = '123-456'; + const droppableId = `${FAVORITE_DROPPABLE_IDS.FOLDER_PREFIX}${folderId}`; + + const result = validateAndExtractFolderId(droppableId); + expect(result).toBe(folderId); + }); + + it('should extract folder id from folder header droppable id', () => { + const folderId = '123-456'; + const droppableId = `${FAVORITE_DROPPABLE_IDS.FOLDER_HEADER_PREFIX}${folderId}`; + + const result = validateAndExtractFolderId(droppableId); + expect(result).toBe(folderId); + }); + + it('should throw error for invalid droppable id format', () => { + expect(() => { + validateAndExtractFolderId('invalid-id'); + }).toThrow('Invalid droppable ID format: invalid-id'); + }); + + it('should throw error for empty folder id in folder format', () => { + expect(() => { + validateAndExtractFolderId(FAVORITE_DROPPABLE_IDS.FOLDER_PREFIX); + }).toThrow(`Invalid folder ID: ${FAVORITE_DROPPABLE_IDS.FOLDER_PREFIX}`); + }); + + it('should throw error for empty folder id in folder header format', () => { + expect(() => { + validateAndExtractFolderId(FAVORITE_DROPPABLE_IDS.FOLDER_HEADER_PREFIX); + }).toThrow( + `Invalid folder header ID: ${FAVORITE_DROPPABLE_IDS.FOLDER_HEADER_PREFIX}`, + ); + }); +}); diff --git a/packages/twenty-front/src/modules/favorites/utils/createFolderDroppableId.ts b/packages/twenty-front/src/modules/favorites/utils/createFolderDroppableId.ts new file mode 100644 index 000000000..52bf835a4 --- /dev/null +++ b/packages/twenty-front/src/modules/favorites/utils/createFolderDroppableId.ts @@ -0,0 +1,6 @@ +import { FAVORITE_DROPPABLE_IDS } from '@/favorites/constants/FavoriteDroppableIds'; +import { FavoriteDroppableId } from '@/favorites/types/FavoriteDroppableId'; + +export const createFolderDroppableId = ( + folderId: string, +): FavoriteDroppableId => `${FAVORITE_DROPPABLE_IDS.FOLDER_PREFIX}${folderId}`; diff --git a/packages/twenty-front/src/modules/favorites/utils/createFolderHeaderDroppableId.ts b/packages/twenty-front/src/modules/favorites/utils/createFolderHeaderDroppableId.ts new file mode 100644 index 000000000..ed2531758 --- /dev/null +++ b/packages/twenty-front/src/modules/favorites/utils/createFolderHeaderDroppableId.ts @@ -0,0 +1,7 @@ +import { FAVORITE_DROPPABLE_IDS } from '@/favorites/constants/FavoriteDroppableIds'; +import { FavoriteDroppableId } from '@/favorites/types/FavoriteDroppableId'; + +export const createFolderHeaderDroppableId = ( + folderId: string, +): FavoriteDroppableId => + `${FAVORITE_DROPPABLE_IDS.FOLDER_HEADER_PREFIX}${folderId}`; diff --git a/packages/twenty-front/src/modules/favorites/utils/validateAndExtractFolderId.ts b/packages/twenty-front/src/modules/favorites/utils/validateAndExtractFolderId.ts new file mode 100644 index 000000000..1c163b63a --- /dev/null +++ b/packages/twenty-front/src/modules/favorites/utils/validateAndExtractFolderId.ts @@ -0,0 +1,29 @@ +import { FAVORITE_DROPPABLE_IDS } from '@/favorites/constants/FavoriteDroppableIds'; + +export const validateAndExtractFolderId = ( + droppableId: string, +): string | null => { + if (droppableId === FAVORITE_DROPPABLE_IDS.ORPHAN_FAVORITES) { + return null; + } + + if (droppableId.startsWith(FAVORITE_DROPPABLE_IDS.FOLDER_HEADER_PREFIX)) { + const folderId = droppableId.replace( + FAVORITE_DROPPABLE_IDS.FOLDER_HEADER_PREFIX, + '', + ); + if (!folderId) throw new Error(`Invalid folder header ID: ${droppableId}`); + return folderId; + } + + if (droppableId.startsWith(FAVORITE_DROPPABLE_IDS.FOLDER_PREFIX)) { + const folderId = droppableId.replace( + FAVORITE_DROPPABLE_IDS.FOLDER_PREFIX, + '', + ); + if (!folderId) throw new Error(`Invalid folder ID: ${droppableId}`); + return folderId; + } + + throw new Error(`Invalid droppable ID format: ${droppableId}`); +};