feat: deprecate WorkspaceFavorite in favor of NavigationMenuItem (#18624)

## Summary

- **Removes the entire `modules/favorites/` directory** (~66 files,
~5000 lines deleted) — components, hooks, states, types, utils, tests,
and the favorite-folder-picker sub-module
- **Eliminates the dual-write pattern** where creating a favorite also
created a NavigationMenuItem — all consumers now use
`useCreateNavigationMenuItem` directly
- **Removes `IS_NAVIGATION_MENU_ITEM_EDITING_ENABLED` feature flag
checks** from ~12 files, always taking the NavigationMenuItem code path
- **Cleans up backend dual-writes** in `object-metadata.service.ts` and
`twenty-standard-application.service.ts` that were creating Favorite
records alongside NavigationMenuItems
- **Updates prefetch system** to only load NavigationMenuItems (removes
favorites prefetch effects and states)
- **Cleans up test infrastructure** — updates Storybook decorators, mock
data, and graphql mocks to remove favorites references

### What was intentionally kept
- **Backend entity definitions** (`FavoriteWorkspaceEntity`,
`FavoriteFolderWorkspaceEntity`) — these define the database schema and
need a proper database migration to remove
- **Cascade deletion listeners** — still needed to clean up existing
Favorite data in workspaces that haven't been fully migrated
- **v1.18 migration commands** — needed for workspaces upgrading from
older versions

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Charles Bochet
2026-03-13 19:45:40 +01:00
committed by GitHub
parent 2a8912b17a
commit 6b48f197d4
152 changed files with 444 additions and 5575 deletions
+2 -2
View File
@@ -61,8 +61,8 @@ const jestConfig = {
extensionsToTreatAsEsm: ['.ts', '.tsx'],
coverageThreshold: {
global: {
statements: 49,
lines: 47.6,
statements: 48.8,
lines: 47.5,
functions: 39.5,
},
},
@@ -6,11 +6,11 @@ import {
PageDecorator,
type PageDecoratorArgs,
} from '~/testing/decorators/PageDecorator';
import { PrefetchLoadingDecorator } from '~/testing/decorators/PrefetchLoadingDecorator';
import { LoadingDecorator } from '~/testing/decorators/LoadingDecorator';
import { graphqlMocks } from '~/testing/graphqlMocks';
const meta: Meta<PageDecoratorArgs> = {
title: 'App/Loading/PrefetchLoading',
title: 'App/Loading',
component: RecordIndexPage,
args: {
routePath: '/objects/:objectNamePlural',
@@ -20,7 +20,7 @@ const meta: Meta<PageDecoratorArgs> = {
},
parameters: {
msw: graphqlMocks,
prefetchLoadingSetDelay: 1000,
loadingSetDelay: 1000,
},
tags: ['no-tests'],
};
@@ -32,7 +32,7 @@ export type Story = StoryObj<typeof RecordIndexPage>;
export const Default: Story = {
// oxlint-disable-next-line @typescripttypescript/ban-ts-comment
// @ts-ignore
decorators: [PrefetchLoadingDecorator, PageDecorator],
decorators: [LoadingDecorator, PageDecorator],
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
@@ -18,7 +18,7 @@ import { PromiseRejectionEffect } from '@/error-handler/components/PromiseReject
import { HeadlessFrontComponentMountRoot } from '@/front-components/components/HeadlessFrontComponentMountRoot';
import { ApolloCoreProvider } from '@/object-metadata/components/ApolloCoreProvider';
import { PreComputedChipGeneratorsProvider } from '@/object-metadata/components/PreComputedChipGeneratorsProvider';
import { PrefetchDataProvider } from '@/prefetch/components/PrefetchDataProvider';
import { SSEProvider } from '@/sse-db-event/components/SSEProvider';
import { SupportChatEffect } from '@/support/components/SupportChatEffect';
import { DialogManager } from '@/ui/feedback/dialog-manager/components/DialogManager';
@@ -56,32 +56,30 @@ export const AppRouterProviders = () => {
<ApolloCoreProvider>
<SSEProvider>
<PreComputedChipGeneratorsProvider>
<PrefetchDataProvider>
<UserThemeProviderEffect />
<SnackBarProvider>
<ErrorMessageEffect />
<AgentChatProvider>
<DialogComponentInstanceContext.Provider
value={{ instanceId: 'dialog-manager' }}
>
<DialogManager>
<StrictMode>
<PromiseRejectionEffect />
<GotoHotkeysEffectsProvider />
<PageTitle title={pageTitle} />
<PageFavicon />
<Outlet />
<GlobalFilePreviewModal />
<CommandMenuConfirmationModalManager />
<HeadlessFrontComponentMountRoot />
</StrictMode>
</DialogManager>
</DialogComponentInstanceContext.Provider>
</AgentChatProvider>
</SnackBarProvider>
<MainContextStoreProvider />
<SupportChatEffect />
</PrefetchDataProvider>
<UserThemeProviderEffect />
<SnackBarProvider>
<ErrorMessageEffect />
<AgentChatProvider>
<DialogComponentInstanceContext.Provider
value={{ instanceId: 'dialog-manager' }}
>
<DialogManager>
<StrictMode>
<PromiseRejectionEffect />
<GotoHotkeysEffectsProvider />
<PageTitle title={pageTitle} />
<PageFavicon />
<Outlet />
<GlobalFilePreviewModal />
<CommandMenuConfirmationModalManager />
<HeadlessFrontComponentMountRoot />
</StrictMode>
</DialogManager>
</DialogComponentInstanceContext.Provider>
</AgentChatProvider>
</SnackBarProvider>
<MainContextStoreProvider />
<SupportChatEffect />
<PageChangeEffect />
</PreComputedChipGeneratorsProvider>
</SSEProvider>
@@ -7,8 +7,7 @@ import { contextStoreCurrentViewTypeComponentState } from '@/context-store/state
import { contextStoreNumberOfSelectedRecordsComponentState } from '@/context-store/states/contextStoreNumberOfSelectedRecordsComponentState';
import { contextStoreTargetedRecordsRuleComponentState } from '@/context-store/states/contextStoreTargetedRecordsRuleComponentState';
import { ContextStoreViewType } from '@/context-store/types/ContextStoreViewType';
import { useFavorites } from '@/favorites/hooks/useFavorites';
import { usePrefetchedNavigationMenuItemsData } from '@/navigation-menu-item/hooks/usePrefetchedNavigationMenuItemsData';
import { useNavigationMenuItemsData } from '@/navigation-menu-item/hooks/useNavigationMenuItemsData';
import { CoreObjectNameSingular } from 'twenty-shared/types';
import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { useObjectPermissionsForObject } from '@/object-record/hooks/useObjectPermissionsForObject';
@@ -19,11 +18,10 @@ import { useAtomComponentSelectorValue } from '@/ui/utilities/state/jotai/hooks/
import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue';
import { useAtomFamilyStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomFamilyStateValue';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
import { useStore } from 'jotai';
import { useCallback, useContext, useMemo } from 'react';
import { isDefined } from 'twenty-shared/utils';
import { FeatureFlagKey } from '~/generated-metadata/graphql';
import type { FeatureFlagKey } from '~/generated-metadata/graphql';
export const useShouldCommandMenuItemBeRegisteredParams = ({
objectMetadataItem,
@@ -31,11 +29,7 @@ export const useShouldCommandMenuItemBeRegisteredParams = ({
objectMetadataItem?: ObjectMetadataItem;
}): ShouldBeRegisteredFunctionParams => {
const store = useStore();
const { sortedFavorites: favorites } = useFavorites();
const { navigationMenuItems } = usePrefetchedNavigationMenuItemsData();
const isNavigationMenuItemEditingEnabled = useIsFeatureEnabled(
FeatureFlagKey.IS_NAVIGATION_MENU_ITEM_EDITING_ENABLED,
);
const { navigationMenuItems } = useNavigationMenuItemsData();
const contextStoreTargetedRecordsRule = useAtomComponentStateValue(
contextStoreTargetedRecordsRuleComponentState,
@@ -47,30 +41,17 @@ export const useShouldCommandMenuItemBeRegisteredParams = ({
: undefined;
const isFavorite = useMemo(() => {
if (!isDefined(recordId)) {
if (!isDefined(recordId) || !isDefined(objectMetadataItem)) {
return false;
}
if (isNavigationMenuItemEditingEnabled && isDefined(objectMetadataItem)) {
const foundNavigationMenuItem = navigationMenuItems?.find(
(item) =>
item.targetRecordId === recordId &&
item.targetObjectMetadataId === objectMetadataItem.id,
);
return !!foundNavigationMenuItem;
}
const foundFavorite = favorites?.find(
(favorite) => favorite.recordId === recordId,
const foundNavigationMenuItem = navigationMenuItems?.find(
(item) =>
item.targetRecordId === recordId &&
item.targetObjectMetadataId === objectMetadataItem.id,
);
return !!foundFavorite;
}, [
recordId,
isNavigationMenuItemEditingEnabled,
objectMetadataItem,
navigationMenuItems,
favorites,
]);
return !!foundNavigationMenuItem;
}, [recordId, objectMetadataItem, navigationMenuItems]);
const selectedRecord =
useAtomFamilyStateValue(recordStoreFamilyState, recordId ?? '') ||
@@ -12,7 +12,6 @@ import {
IconSearch,
IconSparkles,
} from 'twenty-ui/display';
import { FeatureFlagKey } from '~/generated-metadata/graphql';
export const RECORD_AGNOSTIC_COMMAND_MENU_ITEMS_CONFIG: Record<
string,
@@ -108,10 +107,7 @@ export const RECORD_AGNOSTIC_COMMAND_MENU_ITEMS_CONFIG: Record<
Icon: IconLayout,
isPinned: false,
availableOn: [CommandMenuItemViewType.GLOBAL],
shouldBeRegistered: ({ isFeatureFlagEnabled }) =>
isFeatureFlagEnabled(
FeatureFlagKey.IS_NAVIGATION_MENU_ITEM_EDITING_ENABLED,
),
shouldBeRegistered: () => true,
component: <EditNavigationSidebarNoSelectionRecordCommand />,
},
};
@@ -1,7 +1,7 @@
import { Command } from '@/command-menu-item/display/components/Command';
import { useSelectedRecordIdOrThrow } from '@/command-menu-item/record/single-record/hooks/useSelectedRecordIdOrThrow';
import { useContextStoreObjectMetadataItemOrThrow } from '@/context-store/hooks/useContextStoreObjectMetadataItemOrThrow';
import { useCreateFavorite } from '@/favorites/hooks/useCreateFavorite';
import { useCreateNavigationMenuItem } from '@/navigation-menu-item/hooks/useCreateNavigationMenuItem';
import { recordStoreFamilyState } from '@/object-record/record-store/states/recordStoreFamilyState';
import { useAtomFamilyStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomFamilyStateValue';
import { isDefined } from 'twenty-shared/utils';
@@ -11,7 +11,7 @@ export const AddToFavoritesSingleRecordCommand = () => {
const recordId = useSelectedRecordIdOrThrow();
const { createFavorite } = useCreateFavorite();
const { createNavigationMenuItem } = useCreateNavigationMenuItem();
const recordStore = useAtomFamilyStateValue(recordStoreFamilyState, recordId);
@@ -20,7 +20,7 @@ export const AddToFavoritesSingleRecordCommand = () => {
return;
}
createFavorite(recordStore, objectMetadataItem.nameSingular);
createNavigationMenuItem(recordStore, objectMetadataItem.nameSingular);
};
return <Command onClick={handleClick} />;
@@ -1,8 +1,6 @@
import { Command } from '@/command-menu-item/display/components/Command';
import { useSelectedRecordIdOrThrow } from '@/command-menu-item/record/single-record/hooks/useSelectedRecordIdOrThrow';
import { useDeleteFavorite } from '@/favorites/hooks/useDeleteFavorite';
import { useFavorites } from '@/favorites/hooks/useFavorites';
import { usePrefetchedNavigationMenuItemsData } from '@/navigation-menu-item/hooks/usePrefetchedNavigationMenuItemsData';
import { useNavigationMenuItemsData } from '@/navigation-menu-item/hooks/useNavigationMenuItemsData';
import { useRemoveNavigationMenuItemByTargetRecordId } from '@/navigation-menu-item/hooks/useRemoveNavigationMenuItemByTargetRecordId';
import { useDeleteOneRecord } from '@/object-record/hooks/useDeleteOneRecord';
import { useRemoveSelectedRecordsFromRecordBoard } from '@/object-record/record-board/hooks/useRemoveSelectedRecordsFromRecordBoard';
@@ -25,11 +23,8 @@ export const DeleteSingleRecordCommand = () => {
objectNameSingular: objectMetadataItem.nameSingular,
});
const { sortedFavorites: favorites } = useFavorites();
const { deleteFavorite } = useDeleteFavorite();
const { navigationMenuItems, workspaceNavigationMenuItems } =
usePrefetchedNavigationMenuItemsData();
useNavigationMenuItemsData();
const { removeNavigationMenuItemsByTargetRecordIds } =
useRemoveNavigationMenuItemByTargetRecordId();
@@ -38,14 +33,6 @@ export const DeleteSingleRecordCommand = () => {
resetTableRowSelection();
const foundFavorite = favorites?.find(
(favorite) => favorite.recordId === recordId,
);
if (isDefined(foundFavorite)) {
deleteFavorite(foundFavorite.id);
}
const foundNavigationMenuItem = [
...navigationMenuItems,
...workspaceNavigationMenuItems,
@@ -1,27 +1,19 @@
import { Command } from '@/command-menu-item/display/components/Command';
import { useSelectedRecordIdOrThrow } from '@/command-menu-item/record/single-record/hooks/useSelectedRecordIdOrThrow';
import { useContextStoreObjectMetadataItemOrThrow } from '@/context-store/hooks/useContextStoreObjectMetadataItemOrThrow';
import { useDeleteFavorite } from '@/favorites/hooks/useDeleteFavorite';
import { useFavorites } from '@/favorites/hooks/useFavorites';
import { useDeleteNavigationMenuItem } from '@/navigation-menu-item/hooks/useDeleteNavigationMenuItem';
import { usePrefetchedNavigationMenuItemsData } from '@/navigation-menu-item/hooks/usePrefetchedNavigationMenuItemsData';
import { useNavigationMenuItemsData } from '@/navigation-menu-item/hooks/useNavigationMenuItemsData';
import { isDefined } from 'twenty-shared/utils';
export const RemoveFromFavoritesSingleRecordCommand = () => {
const recordId = useSelectedRecordIdOrThrow();
const { objectMetadataItem } = useContextStoreObjectMetadataItemOrThrow();
const { sortedFavorites: favorites } = useFavorites();
const { navigationMenuItems, workspaceNavigationMenuItems } =
usePrefetchedNavigationMenuItemsData();
useNavigationMenuItemsData();
const { deleteFavorite } = useDeleteFavorite();
const { deleteNavigationMenuItem } = useDeleteNavigationMenuItem();
const foundFavorite = favorites?.find(
(favorite) => favorite.recordId === recordId,
);
const foundNavigationMenuItem = [
...navigationMenuItems,
...workspaceNavigationMenuItems,
@@ -32,12 +24,11 @@ export const RemoveFromFavoritesSingleRecordCommand = () => {
);
const handleClick = () => {
if (!isDefined(foundNavigationMenuItem) || !isDefined(foundFavorite)) {
if (!isDefined(foundNavigationMenuItem)) {
return;
}
deleteNavigationMenuItem(foundNavigationMenuItem.id);
deleteFavorite(foundFavorite.id);
};
return <Command onClick={handleClick} />;
@@ -7,8 +7,7 @@ import { contextStoreIsPageInEditModeComponentState } from '@/context-store/stat
import { contextStoreNumberOfSelectedRecordsComponentState } from '@/context-store/states/contextStoreNumberOfSelectedRecordsComponentState';
import { contextStoreTargetedRecordsRuleComponentState } from '@/context-store/states/contextStoreTargetedRecordsRuleComponentState';
import { ContextStoreViewType } from '@/context-store/types/ContextStoreViewType';
import { useFavorites } from '@/favorites/hooks/useFavorites';
import { usePrefetchedNavigationMenuItemsData } from '@/navigation-menu-item/hooks/usePrefetchedNavigationMenuItemsData';
import { useNavigationMenuItemsData } from '@/navigation-menu-item/hooks/useNavigationMenuItemsData';
import { useObjectMetadataItems } from '@/object-metadata/hooks/useObjectMetadataItems';
import { useObjectPermissionsForObject } from '@/object-record/hooks/useObjectPermissionsForObject';
import { hasAnySoftDeleteFilterOnViewComponentSelector } from '@/object-record/record-filter/states/hasAnySoftDeleteFilterOnView';
@@ -18,7 +17,6 @@ import { useAtomComponentSelectorValue } from '@/ui/utilities/state/jotai/hooks/
import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue';
import { useAtomFamilySelectorValue } from '@/ui/utilities/state/jotai/hooks/useAtomFamilySelectorValue';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
import { isNonEmptyArray } from '@sniptt/guards';
import { useStore } from 'jotai';
import { useContext } from 'react';
@@ -27,7 +25,6 @@ import {
type CommandMenuContextApi,
} from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { FeatureFlagKey } from '~/generated-metadata/graphql';
export const useCommandMenuContextApi = (): CommandMenuContextApi => {
const store = useStore();
@@ -42,18 +39,13 @@ export const useCommandMenuContextApi = (): CommandMenuContextApi => {
contextStoreTargetedRecordsRuleComponentState,
);
const isNavigationMenuItemEditingEnabled = useIsFeatureEnabled(
FeatureFlagKey.IS_NAVIGATION_MENU_ITEM_EDITING_ENABLED,
);
const { objectMetadataItems } = useObjectMetadataItems();
const objectMetadataItem = objectMetadataItems.find(
(item) => item.id === contextStoreCurrentObjectMetadataItemId,
);
const { sortedFavorites: favorites } = useFavorites();
const { navigationMenuItems } = usePrefetchedNavigationMenuItemsData();
const { navigationMenuItems } = useNavigationMenuItemsData();
const recordIds =
contextStoreTargetedRecordsRule.mode === 'selection'
@@ -61,22 +53,16 @@ export const useCommandMenuContextApi = (): CommandMenuContextApi => {
: undefined;
const favoriteRecordIds = (() => {
if (!isNonEmptyArray(recordIds)) {
if (!isNonEmptyArray(recordIds) || !isDefined(objectMetadataItem)) {
return [];
}
if (isNavigationMenuItemEditingEnabled && isDefined(objectMetadataItem)) {
return recordIds.filter((recordId) =>
navigationMenuItems?.some(
(item) =>
item.targetRecordId === recordId &&
item.targetObjectMetadataId === objectMetadataItem.id,
),
);
}
return recordIds.filter((recordId) =>
favorites?.some((favorite) => favorite.recordId === recordId),
navigationMenuItems?.some(
(item) =>
item.targetRecordId === recordId &&
item.targetObjectMetadataId === objectMetadataItem.id,
),
);
})();
@@ -1,283 +0,0 @@
import { FavoriteFolderNavigationDrawerItemDropdown } from '@/favorites/components/FavoriteFolderNavigationDrawerItemDropdown';
import { FavoriteIcon } from '@/favorites/components/FavoriteIcon';
import { FavoritesDroppable } from '@/favorites/components/FavoritesDroppable';
import { FAVORITE_FOLDER_DELETE_MODAL_ID } from '@/favorites/constants/FavoriteFolderDeleteModalId';
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 { openFavoriteFolderIdsState } from '@/favorites/states/openFavoriteFolderIdsState';
import { getFavoriteSecondaryLabel } from '@/favorites/utils/getFavoriteSecondaryLabel';
import { isLocationMatchingFavorite } from '@/favorites/utils/isLocationMatchingFavorite';
import { type ProcessedFavorite } from '@/favorites/utils/sortFavorites';
import { objectMetadataItemsState } from '@/object-metadata/states/objectMetadataItemsState';
import { DraggableItem } from '@/ui/layout/draggable-list/components/DraggableItem';
import { useCloseDropdown } from '@/ui/layout/dropdown/hooks/useCloseDropdown';
import { isDropdownOpenComponentState } from '@/ui/layout/dropdown/states/isDropdownOpenComponentState';
import { ConfirmationModal } from '@/ui/layout/modal/components/ConfirmationModal';
import { useModal } from '@/ui/layout/modal/hooks/useModal';
import { isModalOpenedComponentState } from '@/ui/layout/modal/states/isModalOpenedComponentState';
import { NavigationDrawerInput } from '@/ui/navigation/navigation-drawer/components/NavigationDrawerInput';
import { NavigationDrawerItem } from '@/ui/navigation/navigation-drawer/components/NavigationDrawerItem';
import { NavigationDrawerItemsCollapsableContainer } from '@/ui/navigation/navigation-drawer/components/NavigationDrawerItemsCollapsableContainer';
import { NavigationDrawerSubItem } from '@/ui/navigation/navigation-drawer/components/NavigationDrawerSubItem';
import { currentFavoriteFolderIdState } from '@/ui/navigation/navigation-drawer/states/currentFavoriteFolderIdState';
import { getNavigationSubItemLeftAdornment } from '@/ui/navigation/navigation-drawer/utils/getNavigationSubItemLeftAdornment';
import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue';
import { useAtomState } from '@/ui/utilities/state/jotai/hooks/useAtomState';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { useSetAtomState } from '@/ui/utilities/state/jotai/hooks/useSetAtomState';
import { Droppable } from '@hello-pangea/dnd';
import { useLingui } from '@lingui/react/macro';
import { useContext, useState } from 'react';
import { createPortal } from 'react-dom';
import { useLocation } from 'react-router-dom';
import { IconFolder, IconFolderOpen, IconHeartOff } from 'twenty-ui/display';
import { LightIconButton } from 'twenty-ui/input';
import { AnimatedExpandableContainer } from 'twenty-ui/layout';
import { useIsMobile } from 'twenty-ui/utilities';
type CurrentWorkspaceMemberFavoritesProps = {
folder: {
folderId: string;
folderName: string;
favorites: ProcessedFavorite[];
};
isGroup: boolean;
};
export const CurrentWorkspaceMemberFavorites = ({
folder,
isGroup,
}: CurrentWorkspaceMemberFavoritesProps) => {
const { t } = useLingui();
const objectMetadataItems = useAtomStateValue(objectMetadataItemsState);
const currentPath = useLocation().pathname;
const currentViewPath = useLocation().pathname + useLocation().search;
const { isDragging } = useContext(FavoritesDragContext);
const [isFavoriteFolderRenaming, setIsFavoriteFolderRenaming] =
useState(false);
const [favoriteFolderName, setFavoriteFolderName] = useState(
folder.folderName,
);
const { openModal } = useModal();
const isMobile = useIsMobile();
const [openFavoriteFolderIds, setOpenFavoriteFolderIds] = useAtomState(
openFavoriteFolderIdsState,
);
const setCurrentFavoriteFolderId = useSetAtomState(
currentFavoriteFolderIdState,
);
const isOpen = openFavoriteFolderIds.includes(folder.folderId);
const handleToggle = () => {
if (isMobile) {
setCurrentFavoriteFolderId((prev) =>
prev === folder.folderId ? null : folder.folderId,
);
} else {
setOpenFavoriteFolderIds((currentOpenFolders) => {
if (isOpen) {
return currentOpenFolders.filter((id) => id !== folder.folderId);
} else {
return [...currentOpenFolders, folder.folderId];
}
});
}
};
const { renameFavoriteFolder } = useRenameFavoriteFolder();
const { deleteFavoriteFolder } = useDeleteFavoriteFolder();
const dropdownId = `favorite-folder-edit-${folder.folderId}`;
const isDropdownOpen = useAtomComponentStateValue(
isDropdownOpenComponentState,
dropdownId,
);
const { closeDropdown } = useCloseDropdown();
const selectedFavoriteIndex = folder.favorites.findIndex((favorite) =>
isLocationMatchingFavorite(currentPath, currentViewPath, favorite),
);
const { deleteFavorite } = useDeleteFavorite();
const favoriteFolderContentLength = folder.favorites.length;
const handleSubmitRename = async (value: string) => {
if (value === '') return;
await renameFavoriteFolder(folder.folderId, value);
setIsFavoriteFolderRenaming(false);
return true;
};
const handleCancelRename = () => {
setFavoriteFolderName(folder.folderName);
setIsFavoriteFolderRenaming(false);
};
const handleClickOutside = async (
event: MouseEvent | TouchEvent,
value: string,
) => {
if (!value) {
setIsFavoriteFolderRenaming(false);
return;
}
await renameFavoriteFolder(folder.folderId, value);
setIsFavoriteFolderRenaming(false);
};
const modalId = `${FAVORITE_FOLDER_DELETE_MODAL_ID}-${folder.folderId}`;
const handleFavoriteFolderDelete = async () => {
if (folder.favorites.length > 0) {
openModal(modalId);
closeDropdown(dropdownId);
} else {
await deleteFavoriteFolder(folder.folderId);
closeDropdown(dropdownId);
}
};
const handleConfirmDelete = async () => {
await deleteFavoriteFolder(folder.folderId);
};
const rightOptions = (
<FavoriteFolderNavigationDrawerItemDropdown
folderId={folder.folderId}
onRename={() => setIsFavoriteFolderRenaming(true)}
onDelete={handleFavoriteFolderDelete}
closeDropdown={() => {
closeDropdown(dropdownId);
}}
/>
);
const isModalOpened = useAtomComponentStateValue(
isModalOpenedComponentState,
modalId,
);
const favoriteCount = folder.favorites.length;
return (
<>
<NavigationDrawerItemsCollapsableContainer
key={folder.folderId}
isGroup={isGroup}
>
{isFavoriteFolderRenaming ? (
<NavigationDrawerInput
Icon={IconFolder}
value={favoriteFolderName}
onChange={setFavoriteFolderName}
onSubmit={handleSubmitRename}
onCancel={handleCancelRename}
onClickOutside={handleClickOutside}
/>
) : (
<FavoritesDroppable droppableId={`folder-header-${folder.folderId}`}>
<NavigationDrawerItem
label={folder.folderName}
Icon={isOpen ? IconFolderOpen : IconFolder}
onClick={handleToggle}
rightOptions={rightOptions}
className="navigation-drawer-item"
isRightOptionsDropdownOpen={isDropdownOpen}
triggerEvent="CLICK"
preventCollapseOnMobile={isMobile}
/>
</FavoritesDroppable>
)}
<AnimatedExpandableContainer
isExpanded={isOpen}
dimension="height"
mode="fit-content"
containAnimation
>
<Droppable droppableId={`folder-${folder.folderId}`}>
{(provided) => (
<div
ref={provided.innerRef}
// oxlint-disable-next-line react/jsx-props-no-spreading
{...provided.droppableProps}
// TODO: (Drag Drop Bug) Adding bottom margin to ensure drag-to-last-position works. Need to find better solution that doesn't affect spacing.
// Issue: Without margin, dragging to last position triggers next folder drop area
>
{folder.favorites.map((favorite, index) => (
<DraggableItem
key={favorite.id}
draggableId={favorite.id}
index={index}
isInsideScrollableContainer
itemComponent={
<NavigationDrawerSubItem
secondaryLabel={getFavoriteSecondaryLabel({
objectMetadataItems,
favoriteObjectNameSingular:
favorite.objectNameSingular,
})}
label={favorite.labelIdentifier}
Icon={() => <FavoriteIcon favorite={favorite} />}
to={isDragging ? undefined : favorite.link}
active={index === selectedFavoriteIndex}
subItemState={getNavigationSubItemLeftAdornment({
index,
arrayLength: favoriteFolderContentLength,
selectedIndex: selectedFavoriteIndex,
})}
rightOptions={
<LightIconButton
Icon={IconHeartOff}
onClick={(e) => {
e.stopPropagation();
deleteFavorite(favorite.id);
}}
accent="tertiary"
/>
}
isDragging={isDragging}
triggerEvent="CLICK"
/>
}
/>
))}
{provided.placeholder}
</div>
)}
</Droppable>
</AnimatedExpandableContainer>
</NavigationDrawerItemsCollapsableContainer>
{isModalOpened &&
createPortal(
<ConfirmationModal
modalInstanceId={modalId}
title={
folder.favorites.length > 1
? t`Remove ${favoriteCount} favorites?`
: t`Remove ${favoriteCount} favorite?`
}
subtitle={
folder.favorites.length > 1
? t`This action will delete this favorite folder and all ${favoriteCount} favorites inside. Do you want to continue?`
: t`This action will delete this favorite folder and the favorite inside. Do you want to continue?`
}
onConfirmClick={handleConfirmDelete}
confirmButtonText={t`Delete Folder`}
/>,
document.body,
)}
</>
);
};
@@ -1,91 +0,0 @@
import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMemberState';
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 { useFavorites } from '@/favorites/hooks/useFavorites';
import { useFavoritesByFolder } from '@/favorites/hooks/useFavoritesByFolder';
import { isFavoriteFolderCreatingState } from '@/favorites/states/isFavoriteFolderCreatingState';
import { useIsPrefetchLoading } from '@/prefetch/hooks/useIsPrefetchLoading';
import { NavigationDrawerAnimatedCollapseWrapper } from '@/ui/navigation/navigation-drawer/components/NavigationDrawerAnimatedCollapseWrapper';
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 { 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 { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { useLingui } from '@lingui/react/macro';
import { isDefined } from 'twenty-shared/utils';
import { IconFolderPlus } from 'twenty-ui/display';
import { LightIconButton } from 'twenty-ui/input';
import { AnimatedExpandableContainer } from 'twenty-ui/layout';
export const CurrentWorkspaceMemberFavoritesFolders = () => {
const currentWorkspaceMember = useAtomStateValue(currentWorkspaceMemberState);
const { sortedFavorites: favorites } = useFavorites();
const { favoritesByFolder } = useFavoritesByFolder();
const [isFavoriteFolderCreating, setIsFavoriteFolderCreating] = useAtomState(
isFavoriteFolderCreatingState,
);
const loading = useIsPrefetchLoading();
const { t } = useLingui();
const { toggleNavigationSection, openNavigationSection } =
useNavigationSection('Favorites');
const isNavigationSectionOpen = useAtomFamilyStateValue(
isNavigationSectionOpenFamilyState,
'Favorites',
);
const toggleNewFolder = () => {
openNavigationSection();
setIsFavoriteFolderCreating((current) => !current);
};
if (loading && isDefined(currentWorkspaceMember)) {
return <FavoritesSkeletonLoader />;
}
if (
favorites.length === 0 &&
!isFavoriteFolderCreating &&
favoritesByFolder.length === 0
) {
return null;
}
return (
<NavigationDrawerSection>
<NavigationDrawerAnimatedCollapseWrapper>
<NavigationDrawerSectionTitle
label={t`Favorites`}
onClick={toggleNavigationSection}
rightIcon={
<LightIconButton
Icon={IconFolderPlus}
onClick={toggleNewFolder}
accent="tertiary"
/>
}
isOpen={isNavigationSectionOpen}
/>
</NavigationDrawerAnimatedCollapseWrapper>
<AnimatedExpandableContainer
isExpanded={isNavigationSectionOpen}
dimension="height"
mode="fit-content"
containAnimation
initial={false}
>
<FavoritesDragProvider>
<FavoriteFolders />
<CurrentWorkspaceMemberOrphanFavorites />
</FavoritesDragProvider>
</AnimatedExpandableContainer>
</NavigationDrawerSection>
);
};
@@ -1,85 +0,0 @@
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 { getFavoriteSecondaryLabel } from '@/favorites/utils/getFavoriteSecondaryLabel';
import { isLocationMatchingFavorite } from '@/favorites/utils/isLocationMatchingFavorite';
import { objectMetadataItemsState } from '@/object-metadata/states/objectMetadataItemsState';
import { DraggableItem } from '@/ui/layout/draggable-list/components/DraggableItem';
import { NavigationDrawerItem } from '@/ui/navigation/navigation-drawer/components/NavigationDrawerItem';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { styled } from '@linaria/react';
import { useContext } from 'react';
import { useLocation } from 'react-router-dom';
import { IconHeartOff } from 'twenty-ui/display';
import { LightIconButton } from 'twenty-ui/input';
import { themeCssVariables } from 'twenty-ui/theme-constants';
const StyledEmptyContainer = styled.div`
width: 100%;
`;
const StyledOrphanFavoritesContainer = styled.div`
margin-bottom: ${themeCssVariables.betweenSiblingsGap};
`;
export const CurrentWorkspaceMemberOrphanFavorites = () => {
const objectMetadataItems = useAtomStateValue(objectMetadataItemsState);
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 (
<FavoritesDroppable droppableId="orphan-favorites">
{orphanFavorites.length > 0 ? (
orphanFavorites.map((favorite, index) => (
<DraggableItem
key={favorite.id}
draggableId={favorite.id}
index={index}
isInsideScrollableContainer={true}
itemComponent={
<StyledOrphanFavoritesContainer>
<NavigationDrawerItem
secondaryLabel={getFavoriteSecondaryLabel({
objectMetadataItems,
favoriteObjectNameSingular: favorite.objectNameSingular,
})}
label={favorite.labelIdentifier}
Icon={() => <FavoriteIcon favorite={favorite} />}
active={isLocationMatchingFavorite(
currentPath,
currentViewPath,
favorite,
)}
to={isDragging ? undefined : favorite.link}
rightOptions={
<LightIconButton
Icon={IconHeartOff}
onClick={(e) => {
e.stopPropagation();
deleteFavorite(favorite.id);
}}
accent="tertiary"
/>
}
isDragging={isDragging}
triggerEvent="CLICK"
/>
</StyledOrphanFavoritesContainer>
}
/>
))
) : (
<StyledEmptyContainer style={{ height: isDragging ? '24px' : '1px' }} />
)}
</FavoritesDroppable>
);
};
@@ -1,62 +0,0 @@
import { Dropdown } from '@/ui/layout/dropdown/components/Dropdown';
import { DropdownContent } from '@/ui/layout/dropdown/components/DropdownContent';
import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer';
import { GenericDropdownContentWidth } from '@/ui/layout/dropdown/constants/GenericDropdownContentWidth';
import { useLingui } from '@lingui/react/macro';
import { IconDotsVertical, IconPencil, IconTrash } from 'twenty-ui/display';
import { LightIconButton } from 'twenty-ui/input';
import { MenuItem } from 'twenty-ui/navigation';
type FavoriteFolderNavigationDrawerItemDropdownProps = {
folderId: string;
onRename: () => void;
onDelete: () => void;
closeDropdown: () => void;
};
export const FavoriteFolderNavigationDrawerItemDropdown = ({
folderId,
onRename,
onDelete,
closeDropdown,
}: FavoriteFolderNavigationDrawerItemDropdownProps) => {
const { t } = useLingui();
const handleRename = () => {
closeDropdown();
onRename();
};
const handleDelete = () => {
closeDropdown();
onDelete();
};
return (
<Dropdown
dropdownId={`favorite-folder-edit-${folderId}`}
data-select-disable
clickableComponent={
<LightIconButton Icon={IconDotsVertical} accent="tertiary" />
}
dropdownPlacement="bottom-start"
dropdownComponents={
<DropdownContent widthInPixels={GenericDropdownContentWidth.Narrow}>
<DropdownMenuItemsContainer>
<MenuItem
LeftIcon={IconPencil}
onClick={handleRename}
accent="default"
text={t`Rename`}
/>
<MenuItem
LeftIcon={IconTrash}
onClick={handleDelete}
accent="danger"
text={t`Delete`}
/>
</DropdownMenuItemsContainer>
</DropdownContent>
}
/>
);
};
@@ -1,28 +0,0 @@
import { type ProcessedFavorite } from '@/favorites/utils/sortFavorites';
import { useGetStandardObjectIcon } from '@/object-metadata/hooks/useGetStandardObjectIcon';
import { useContext } from 'react';
import { Avatar, useIcons } from 'twenty-ui/display';
import { ThemeContext } from 'twenty-ui/theme-constants';
export const FavoriteIcon = ({ favorite }: { favorite: ProcessedFavorite }) => {
const { theme } = useContext(ThemeContext);
const { getIcon } = useIcons();
const { Icon: StandardIcon, IconColor } = useGetStandardObjectIcon(
favorite.objectNameSingular || '',
);
const IconToUse =
StandardIcon || (favorite.Icon ? getIcon(favorite.Icon) : undefined);
const iconColorToUse = StandardIcon ? IconColor : theme.font.color.secondary;
return (
<Avatar
size="md"
type={favorite.avatarType}
Icon={IconToUse}
iconColor={iconColorToUse}
avatarUrl={favorite.avatarUrl}
placeholder={favorite.labelIdentifier}
placeholderColorSeed={favorite.recordId}
/>
);
};
@@ -1,59 +0,0 @@
import { currentFavoriteFolderIdState } from '@/ui/navigation/navigation-drawer/states/currentFavoriteFolderIdState';
import { useSetAtomState } from '@/ui/utilities/state/jotai/hooks/useSetAtomState';
import { styled } from '@linaria/react';
import { IconX } from 'twenty-ui/display';
import { useContext } from 'react';
import { ThemeContext, themeCssVariables } from 'twenty-ui/theme-constants';
const StyledBackButton = styled.button`
align-items: center;
background: transparent;
border: none;
color: ${themeCssVariables.font.color.secondary};
cursor: pointer;
display: flex;
font-family: ${themeCssVariables.font.family};
font-weight: ${themeCssVariables.font.weight.medium};
gap: ${themeCssVariables.spacing[2]};
padding-bottom: ${themeCssVariables.spacing[1]};
padding-left: ${themeCssVariables.spacing[1]};
padding-right: ${themeCssVariables.spacing[0.5]};
padding-top: ${themeCssVariables.spacing[1]};
text-align: left;
width: 100%;
&:hover {
background: ${themeCssVariables.background.transparent.light};
border-radius: ${themeCssVariables.border.radius.sm};
}
`;
type FavoritesBackButtonProps = {
folderName: string;
};
export const FavoritesBackButton = ({
folderName,
}: FavoritesBackButtonProps) => {
const { theme } = useContext(ThemeContext);
const setCurrentFavoriteFolderId = useSetAtomState(
currentFavoriteFolderIdState,
);
const handleClick = (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
setCurrentFavoriteFolderId(null);
};
return (
<StyledBackButton onClick={handleClick}>
<IconX
size={theme.icon.size.md}
stroke={theme.icon.stroke.lg}
color={theme.font.color.tertiary}
/>
<span>{folderName}</span>
</StyledBackButton>
);
};
@@ -1,37 +0,0 @@
import { FavoritesDragContext } from '@/favorites/contexts/FavoritesDragContext';
import { useHandleFavoriteDragAndDrop } from '@/favorites/hooks/useHandleFavoriteDragAndDrop';
import {
DragDropContext,
type DragStart,
type DropResult,
type ResponderProvided,
} from '@hello-pangea/dnd';
import { type 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 (
<FavoritesDragContext.Provider value={{ isDragging }}>
<DragDropContext onDragEnd={handleDragEnd} onDragStart={handleDragStart}>
{children}
</DragDropContext>
</FavoritesDragContext.Provider>
);
};
@@ -1,72 +0,0 @@
import { styled } from '@linaria/react';
import { Droppable } from '@hello-pangea/dnd';
import { themeCssVariables } from 'twenty-ui/theme-constants';
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 }) =>
isDraggingOver && isDragIndicatorVisible
? `
background-color: ${themeCssVariables.background.transparent.blue};
${
showDropLine
? `
&::before {
content: '';
position: absolute;
bottom: 0;
left: 0;
width: 100%;
height: 2px;
background-color: ${themeCssVariables.color.blue};
border-radius: ${themeCssVariables.border.radius.sm} ${themeCssVariables.border.radius.sm} 0 0;
}
`
: ''
}
`
: ''}
`;
export const FavoritesDroppable = ({
droppableId,
children,
isDragIndicatorVisible = true,
showDropLine = true,
}: FavoritesDroppableProps) => {
return (
<Droppable droppableId={droppableId}>
{(provided, snapshot) => (
<StyledDroppableWrapper
isDraggingOver={snapshot.isDraggingOver}
isDragIndicatorVisible={isDragIndicatorVisible}
showDropLine={showDropLine}
>
<div
ref={provided.innerRef}
// oxlint-disable-next-line react/jsx-props-no-spreading
{...provided.droppableProps}
>
{children}
{provided.placeholder}
</div>
</StyledDroppableWrapper>
)}
</Droppable>
);
};
@@ -1,77 +0,0 @@
import { FavoriteIcon } from '@/favorites/components/FavoriteIcon';
import { FavoritesBackButton } from '@/favorites/components/FavoritesBackButton';
import { FavoritesDragProvider } from '@/favorites/components/FavoritesDragProvider';
import { useDeleteFavorite } from '@/favorites/hooks/useDeleteFavorite';
import { getFavoriteSecondaryLabel } from '@/favorites/utils/getFavoriteSecondaryLabel';
import { type ProcessedFavorite } from '@/favorites/utils/sortFavorites';
import { objectMetadataItemsState } from '@/object-metadata/states/objectMetadataItemsState';
import { DraggableItem } from '@/ui/layout/draggable-list/components/DraggableItem';
import { NavigationDrawerItem } from '@/ui/navigation/navigation-drawer/components/NavigationDrawerItem';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { Droppable } from '@hello-pangea/dnd';
import { IconHeartOff } from 'twenty-ui/display';
import { LightIconButton } from 'twenty-ui/input';
type FavoritesFolderContentProps = {
folderId: string;
folderName: string;
favorites: ProcessedFavorite[];
};
export const FavoritesFolderContent = ({
folderName,
folderId,
favorites,
}: FavoritesFolderContentProps) => {
const objectMetadataItems = useAtomStateValue(objectMetadataItemsState);
const { deleteFavorite } = useDeleteFavorite();
return (
<>
<FavoritesBackButton folderName={folderName} />
<FavoritesDragProvider>
<Droppable droppableId={`folder-${folderId}`}>
{(provided) => (
<div
ref={provided.innerRef}
// oxlint-disable-next-line react/jsx-props-no-spreading
{...provided.droppableProps}
>
{favorites.map((favorite, index) => (
<DraggableItem
key={favorite.id}
draggableId={favorite.id}
index={index}
isInsideScrollableContainer
itemComponent={
<NavigationDrawerItem
secondaryLabel={getFavoriteSecondaryLabel({
objectMetadataItems,
favoriteObjectNameSingular: favorite.objectNameSingular,
})}
label={favorite.labelIdentifier}
Icon={() => <FavoriteIcon favorite={favorite} />}
rightOptions={
<LightIconButton
Icon={IconHeartOff}
onClick={(e) => {
e.stopPropagation();
deleteFavorite(favorite.id);
}}
accent="tertiary"
/>
}
triggerEvent="CLICK"
to={favorite.link}
/>
}
/>
))}
{provided.placeholder}
</div>
)}
</Droppable>
</FavoritesDragProvider>
</>
);
};
@@ -1,76 +0,0 @@
import { CurrentWorkspaceMemberFavorites } from '@/favorites/components/CurrentWorkspaceMemberFavorites';
import { useCreateFavoriteFolder } from '@/favorites/hooks/useCreateFavoriteFolder';
import { useFavoritesByFolder } from '@/favorites/hooks/useFavoritesByFolder';
import { isFavoriteFolderCreatingState } from '@/favorites/states/isFavoriteFolderCreatingState';
import { NavigationDrawerAnimatedCollapseWrapper } from '@/ui/navigation/navigation-drawer/components/NavigationDrawerAnimatedCollapseWrapper';
import { NavigationDrawerInput } from '@/ui/navigation/navigation-drawer/components/NavigationDrawerInput';
import { useAtomState } from '@/ui/utilities/state/jotai/hooks/useAtomState';
import { useState } from 'react';
import { IconFolder } from 'twenty-ui/display';
export const FavoriteFolders = () => {
const [newFolderName, setNewFolderName] = useState('');
const { favoritesByFolder } = useFavoritesByFolder();
const { createNewFavoriteFolder } = useCreateFavoriteFolder();
const [isFavoriteFolderCreating, setIsFavoriteFolderCreating] = useAtomState(
isFavoriteFolderCreatingState,
);
const handleFavoriteFolderNameChange = (value: string) => {
setNewFolderName(value);
};
const handleSubmitFavoriteFolderCreation = async (value: string) => {
if (value === '') return;
setIsFavoriteFolderCreating(false);
setNewFolderName('');
await createNewFavoriteFolder(value);
return true;
};
const handleClickOutside = async (
_event: MouseEvent | TouchEvent,
value: string,
) => {
if (!value) {
setIsFavoriteFolderCreating(false);
return;
}
setIsFavoriteFolderCreating(false);
setNewFolderName('');
await createNewFavoriteFolder(value);
};
const handleCancelFavoriteFolderCreation = () => {
setNewFolderName('');
setIsFavoriteFolderCreating(false);
};
return (
<>
{isFavoriteFolderCreating && (
<NavigationDrawerAnimatedCollapseWrapper>
<NavigationDrawerInput
Icon={IconFolder}
value={newFolderName}
onChange={handleFavoriteFolderNameChange}
onSubmit={handleSubmitFavoriteFolderCreation}
onCancel={handleCancelFavoriteFolderCreation}
onClickOutside={handleClickOutside}
/>
</NavigationDrawerAnimatedCollapseWrapper>
)}
{favoritesByFolder.map((folder) => (
<CurrentWorkspaceMemberFavorites
key={folder.folderId}
folder={folder}
isGroup={favoritesByFolder.length > 1}
/>
))}
</>
);
};
@@ -1,47 +0,0 @@
import { SKELETON_LOADER_HEIGHT_SIZES } from '@/activities/components/SkeletonLoader';
import { styled } from '@linaria/react';
import { useContext } from 'react';
import Skeleton, { SkeletonTheme } from 'react-loading-skeleton';
import { ThemeContext, themeCssVariables } from 'twenty-ui/theme-constants';
const StyledSkeletonContainer = styled.div`
display: flex;
flex-direction: column;
gap: ${themeCssVariables.spacing[2]};
height: 71px;
padding-left: ${themeCssVariables.spacing[1]};
`;
const StyledSkeletonColumn = styled.div`
display: flex;
flex-direction: column;
gap: ${themeCssVariables.spacing[1]};
`;
export const FavoritesSkeletonLoader = () => {
const { theme } = useContext(ThemeContext);
return (
<SkeletonTheme
baseColor={theme.background.tertiary}
highlightColor={theme.background.transparent.lighter}
borderRadius={4}
>
<StyledSkeletonContainer>
<Skeleton
width={56}
height={SKELETON_LOADER_HEIGHT_SIZES.standard.xs}
/>
<StyledSkeletonColumn>
<Skeleton
width={196}
height={SKELETON_LOADER_HEIGHT_SIZES.standard.s}
/>
<Skeleton
width={196}
height={SKELETON_LOADER_HEIGHT_SIZES.standard.s}
/>
</StyledSkeletonColumn>
</StyledSkeletonContainer>
</SkeletonTheme>
);
};
@@ -1,28 +0,0 @@
import { t } from '@lingui/core/macro';
import { IconHeart, IconHeartOff } from 'twenty-ui/display';
import { Button } from 'twenty-ui/input';
type PageFavoriteButtonProps = {
isFavorite: boolean;
onClick?: () => void;
};
export const PageFavoriteButton = ({
isFavorite,
onClick,
}: PageFavoriteButtonProps) => {
const title = isFavorite ? t`Remove from favorites` : t`Add to favorites`;
return (
<Button
Icon={isFavorite ? IconHeartOff : IconHeart}
dataTestId="favorite-button"
size="small"
variant="secondary"
accent="default"
title={title}
onClick={onClick}
ariaLabel={title}
/>
);
};
@@ -1,24 +0,0 @@
import { useWorkspaceFavorites } from '@/favorites/hooks/useWorkspaceFavorites';
import { NavigationDrawerSectionForObjectMetadataItems } from '@/object-metadata/components/NavigationDrawerSectionForObjectMetadataItems';
import { NavigationDrawerSectionForObjectMetadataItemsSkeletonLoader } from '@/object-metadata/components/NavigationDrawerSectionForObjectMetadataItemsSkeletonLoader';
import { useIsPrefetchLoading } from '@/prefetch/hooks/useIsPrefetchLoading';
import { useLingui } from '@lingui/react/macro';
export const WorkspaceFavorites = () => {
const { workspaceFavoritesObjectMetadataItems } = useWorkspaceFavorites();
const loading = useIsPrefetchLoading();
const { t } = useLingui();
if (loading) {
return <NavigationDrawerSectionForObjectMetadataItemsSkeletonLoader />;
}
return (
<NavigationDrawerSectionForObjectMetadataItems
sectionTitle={t`Workspace`}
objectMetadataItems={workspaceFavoritesObjectMetadataItems}
isRemote={false}
/>
);
};
@@ -1,7 +0,0 @@
import { FOLDER_DROPPABLE_IDS } from '@/ui/layout/draggable-list/utils/folderDroppableIds';
export const FAVORITE_DROPPABLE_IDS = {
ORPHAN_FAVORITES: 'orphan-favorites',
FOLDER_PREFIX: FOLDER_DROPPABLE_IDS.FOLDER_PREFIX,
FOLDER_HEADER_PREFIX: FOLDER_DROPPABLE_IDS.FOLDER_HEADER_PREFIX,
} as const;
@@ -1 +0,0 @@
export const FAVORITE_FOLDER_DELETE_MODAL_ID = 'favorite-folder-delete-modal';
@@ -1,9 +0,0 @@
import { createContext } from 'react';
type FavoritesDragContextType = {
isDragging: boolean;
};
export const FavoritesDragContext = createContext<FavoritesDragContextType>({
isDragging: false,
});
@@ -1,111 +0,0 @@
import { FavoriteFolderPickerFooter } from '@/favorites/favorite-folder-picker/components/FavoriteFolderPickerFooter';
import { FavoriteFolderPickerList } from '@/favorites/favorite-folder-picker/components/FavoriteFolderPickerList';
import { FavoriteFolderPickerSearchInput } from '@/favorites/favorite-folder-picker/components/FavoriteFolderPickerSearchInput';
import { useFavoriteFolderPicker } from '@/favorites/favorite-folder-picker/hooks/useFavoriteFolderPicker';
import { FavoriteFolderPickerInstanceContext } from '@/favorites/favorite-folder-picker/states/context/FavoriteFolderPickerInstanceContext';
import { favoriteFolderSearchFilterComponentState } from '@/favorites/favorite-folder-picker/states/favoriteFoldersSearchFilterComponentState';
import { isFavoriteFolderCreatingState } from '@/favorites/states/isFavoriteFolderCreatingState';
import { useAtomState } from '@/ui/utilities/state/jotai/hooks/useAtomState';
import { type ObjectRecord } from '@/object-record/types/ObjectRecord';
import { DropdownContent } from '@/ui/layout/dropdown/components/DropdownContent';
import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer';
import { DropdownMenuSeparator } from '@/ui/layout/dropdown/components/DropdownMenuSeparator';
import { useHotkeysOnFocusedElement } from '@/ui/utilities/hotkey/hooks/useHotkeysOnFocusedElement';
import { useAvailableComponentInstanceIdOrThrow } from '@/ui/utilities/state/component-state/hooks/useAvailableComponentInstanceIdOrThrow';
import { useAtomComponentState } from '@/ui/utilities/state/jotai/hooks/useAtomComponentState';
import { Key } from 'ts-key-enum';
type FavoriteFolderPickerProps = {
onSubmit?: () => void;
record?: ObjectRecord;
objectNameSingular: string;
dropdownId: string;
};
const NO_FOLDER_ID = 'no-folder';
export const FavoriteFolderPicker = ({
onSubmit,
record,
objectNameSingular,
dropdownId,
}: FavoriteFolderPickerProps) => {
const [isFavoriteFolderCreating, setIsFavoriteFolderCreating] = useAtomState(
isFavoriteFolderCreatingState,
);
const instanceId = useAvailableComponentInstanceIdOrThrow(
FavoriteFolderPickerInstanceContext,
);
const { favoriteFolders, toggleFolderSelection } = useFavoriteFolderPicker({
record,
objectNameSingular,
});
const [favoriteFolderSearchFilter] = useAtomComponentState(
favoriteFolderSearchFilterComponentState,
);
const filteredFolders = favoriteFolders.filter((folder) =>
folder.name
.toLowerCase()
.includes(favoriteFolderSearchFilter.toLowerCase()),
);
const showNoFolderOption =
!favoriteFolderSearchFilter ||
'no folder'.includes(favoriteFolderSearchFilter.toLowerCase());
useHotkeysOnFocusedElement({
keys: [Key.Escape],
callback: () => {
if (isFavoriteFolderCreating) {
setIsFavoriteFolderCreating(false);
return;
}
onSubmit?.();
},
focusId: dropdownId,
dependencies: [onSubmit, isFavoriteFolderCreating],
});
useHotkeysOnFocusedElement({
keys: [Key.Enter],
callback: () => {
if (filteredFolders.length === 1 && !showNoFolderOption) {
toggleFolderSelection(filteredFolders[0].id);
onSubmit?.();
return;
}
if (showNoFolderOption && filteredFolders.length === 0) {
toggleFolderSelection(NO_FOLDER_ID);
onSubmit?.();
return;
}
},
focusId: instanceId,
dependencies: [
filteredFolders,
showNoFolderOption,
toggleFolderSelection,
onSubmit,
],
});
return (
<DropdownContent>
<FavoriteFolderPickerSearchInput />
<DropdownMenuSeparator />
<DropdownMenuItemsContainer hasMaxHeight>
<FavoriteFolderPickerList
folders={favoriteFolders}
toggleFolderSelection={toggleFolderSelection}
/>
</DropdownMenuItemsContainer>
<DropdownMenuSeparator />
<FavoriteFolderPickerFooter dropdownId={dropdownId} />
</DropdownContent>
);
};
@@ -1,82 +0,0 @@
import { useCallback, useEffect } from 'react';
import { isDeeplyEqual } from '~/utils/isDeeplyEqual';
import { favoriteFolderIdsPickerComponentState } from '@/favorites/favorite-folder-picker/states/favoriteFolderIdPickerComponentState';
import { favoriteFolderPickerCheckedComponentState } from '@/favorites/favorite-folder-picker/states/favoriteFolderPickerCheckedComponentState';
import { favoriteFolderPickerComponentFamilyState } from '@/favorites/favorite-folder-picker/states/favoriteFolderPickerComponentFamilyState';
import { useFavorites } from '@/favorites/hooks/useFavorites';
import { usePrefetchedFavoritesFoldersData } from '@/favorites/hooks/usePrefetchedFavoritesFoldersData';
import { type FavoriteFolder } from '@/favorites/types/FavoriteFolder';
import { type ObjectRecord } from '@/object-record/types/ObjectRecord';
import { useAtomComponentFamilyStateCallbackState } from '@/ui/utilities/state/jotai/hooks/useAtomComponentFamilyStateCallbackState';
import { useAtomComponentState } from '@/ui/utilities/state/jotai/hooks/useAtomComponentState';
import { useSetAtomComponentState } from '@/ui/utilities/state/jotai/hooks/useSetAtomComponentState';
import { isDefined } from 'twenty-shared/utils';
import { useStore } from 'jotai';
type FavoriteFolderPickerEffectProps = {
record?: ObjectRecord;
};
export const FavoriteFolderPickerEffect = ({
record,
}: FavoriteFolderPickerEffectProps) => {
const store = useStore();
const [favoriteFolderIdsPicker, setFavoriteFolderIdsPicker] =
useAtomComponentState(favoriteFolderIdsPickerComponentState);
const favoriteFolderPickerFamily = useAtomComponentFamilyStateCallbackState(
favoriteFolderPickerComponentFamilyState,
);
const { favoriteFolders } = usePrefetchedFavoritesFoldersData();
const { sortedFavorites: favorites } = useFavorites();
const setFavoriteFolderPickerChecked = useSetAtomComponentState(
favoriteFolderPickerCheckedComponentState,
);
const updateFolders = useCallback(
(folders: FavoriteFolder[]) => {
folders.forEach((folder) => {
const atom = favoriteFolderPickerFamily(folder.id);
const currentFolder = store.get(atom);
if (!isDeeplyEqual(folder, currentFolder)) {
store.set(atom, folder);
}
});
},
[favoriteFolderPickerFamily, store],
);
useEffect(() => {
if (isDefined(favoriteFolders)) {
updateFolders(favoriteFolders);
const folderIds = favoriteFolders.map((folder) => folder.id);
if (!isDeeplyEqual(folderIds, favoriteFolderIdsPicker)) {
setFavoriteFolderIdsPicker(folderIds);
}
}
}, [
favoriteFolders,
favoriteFolderIdsPicker,
setFavoriteFolderIdsPicker,
updateFolders,
]);
useEffect(() => {
const targetId = record?.id;
const checkedFolderIds = favorites
.filter(
(favorite) =>
favorite.recordId === targetId &&
isDefined(favorite.forWorkspaceMemberId),
)
.map((favorite) => favorite.favoriteFolderId ?? 'no-folder');
setFavoriteFolderPickerChecked(checkedFolderIds);
}, [favorites, setFavoriteFolderPickerChecked, record?.id]);
return null;
};
@@ -1,44 +0,0 @@
import { isFavoriteFolderCreatingState } from '@/favorites/states/isFavoriteFolderCreatingState';
import { useAtomState } from '@/ui/utilities/state/jotai/hooks/useAtomState';
import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer';
import { useCloseDropdown } from '@/ui/layout/dropdown/hooks/useCloseDropdown';
import { useNavigationSection } from '@/ui/navigation/navigation-drawer/hooks/useNavigationSection';
import { isNavigationDrawerExpandedState } from '@/ui/navigation/states/isNavigationDrawerExpanded';
import { useSetAtomState } from '@/ui/utilities/state/jotai/hooks/useSetAtomState';
import { useLingui } from '@lingui/react/macro';
import { useContext } from 'react';
import { IconPlus } from 'twenty-ui/display';
import { MenuItem } from 'twenty-ui/navigation';
import { ThemeContext } from 'twenty-ui/theme-constants';
export const FavoriteFolderPickerFooter = ({
dropdownId,
}: {
dropdownId: string;
}) => {
const { theme } = useContext(ThemeContext);
const { t } = useLingui();
const [, setIsFavoriteFolderCreating] = useAtomState(
isFavoriteFolderCreatingState,
);
const setIsNavigationDrawerExpanded = useSetAtomState(
isNavigationDrawerExpandedState,
);
const { openNavigationSection } = useNavigationSection('Favorites');
const { closeDropdown } = useCloseDropdown();
return (
<DropdownMenuItemsContainer scrollable={false}>
<MenuItem
className="add-folder"
onClick={() => {
setIsNavigationDrawerExpanded(true);
openNavigationSection();
setIsFavoriteFolderCreating(true);
closeDropdown(dropdownId);
}}
text={t`Add folder`}
LeftIcon={() => <IconPlus size={theme.icon.size.md} />}
/>
</DropdownMenuItemsContainer>
);
};
@@ -1,79 +0,0 @@
import { favoriteFolderPickerCheckedComponentState } from '@/favorites/favorite-folder-picker/states/favoriteFolderPickerCheckedComponentState';
import { favoriteFolderSearchFilterComponentState } from '@/favorites/favorite-folder-picker/states/favoriteFoldersSearchFilterComponentState';
import { type FavoriteFolder } from '@/favorites/types/FavoriteFolder';
import { DropdownMenuSeparator } from '@/ui/layout/dropdown/components/DropdownMenuSeparator';
import { useAtomComponentState } from '@/ui/utilities/state/jotai/hooks/useAtomComponentState';
import { styled } from '@linaria/react';
import { useLingui } from '@lingui/react/macro';
import { MenuItem, MenuItemMultiSelect } from 'twenty-ui/navigation';
import { themeCssVariables } from 'twenty-ui/theme-constants';
const StyledItemsContainer = styled.div`
width: 100%;
`;
const StyledSeparatorContainer = styled.div`
margin-bottom: ${themeCssVariables.spacing[1]};
margin-top: ${themeCssVariables.spacing[1]};
`;
type FavoriteFolderPickerListProps = {
folders: FavoriteFolder[];
toggleFolderSelection: (folderId: string) => void;
};
export const NO_FOLDER_ID = 'no-folder';
export const FavoriteFolderPickerList = ({
folders,
toggleFolderSelection,
}: FavoriteFolderPickerListProps) => {
const { t } = useLingui();
const [favoriteFolderSearchFilter] = useAtomComponentState(
favoriteFolderSearchFilterComponentState,
);
const [favoriteFolderPickerChecked] = useAtomComponentState(
favoriteFolderPickerCheckedComponentState,
);
const filteredFolders = folders.filter((folder) =>
folder.name
.toLowerCase()
.includes(favoriteFolderSearchFilter.toLowerCase()),
);
const showNoFolderOption =
!favoriteFolderSearchFilter ||
'no folder'.includes(favoriteFolderSearchFilter.toLowerCase());
return (
<StyledItemsContainer>
{showNoFolderOption && (
<MenuItemMultiSelect
key={`menu-${NO_FOLDER_ID}`}
onSelectChange={() => toggleFolderSelection(NO_FOLDER_ID)}
selected={favoriteFolderPickerChecked.includes(NO_FOLDER_ID)}
text={t`No folder`}
className="no-folder-menu-item-multi-select"
/>
)}
{showNoFolderOption && filteredFolders.length > 0 && (
<StyledSeparatorContainer>
<DropdownMenuSeparator />
</StyledSeparatorContainer>
)}
{filteredFolders.length > 0
? filteredFolders.map((folder) => (
<MenuItemMultiSelect
key={`menu-${folder.id}`}
onSelectChange={() => toggleFolderSelection(folder.id)}
selected={favoriteFolderPickerChecked.includes(folder.id)}
text={folder.name}
className="folder-menu-item-multi-select"
/>
))
: !showNoFolderOption && <MenuItem text={t`No folders found`} />}
</StyledItemsContainer>
);
};
@@ -1,31 +0,0 @@
import { favoriteFolderSearchFilterComponentState } from '@/favorites/favorite-folder-picker/states/favoriteFoldersSearchFilterComponentState';
import { DropdownMenuSearchInput } from '@/ui/layout/dropdown/components/DropdownMenuSearchInput';
import { useAtomComponentState } from '@/ui/utilities/state/jotai/hooks/useAtomComponentState';
import { useCallback } from 'react';
import { useDebouncedCallback } from 'use-debounce';
export const FavoriteFolderPickerSearchInput = () => {
const [favoriteFolderSearchFilter, setFavoriteFolderSearchFilter] =
useAtomComponentState(favoriteFolderSearchFilterComponentState);
const debouncedSetSearchFilter = useDebouncedCallback(
setFavoriteFolderSearchFilter,
100,
{ leading: true },
);
const handleFilterChange = useCallback(
(event: React.ChangeEvent<HTMLInputElement>) => {
debouncedSetSearchFilter(event.currentTarget.value);
},
[debouncedSetSearchFilter],
);
return (
<DropdownMenuSearchInput
value={favoriteFolderSearchFilter}
onChange={handleFilterChange}
autoFocus
/>
);
};
@@ -1,2 +0,0 @@
export const FAVORITE_FOLDER_PICKER_DROPDOWN_ID =
'favorite-folder-picker-dropdown';
@@ -1,108 +0,0 @@
import { favoriteFolderPickerCheckedComponentState } from '@/favorites/favorite-folder-picker/states/favoriteFolderPickerCheckedComponentState';
import { favoriteFoldersComponentSelector } from '@/favorites/favorite-folder-picker/states/selectors/favoriteFoldersComponentSelector';
import { useCreateFavorite } from '@/favorites/hooks/useCreateFavorite';
import { useDeleteFavorite } from '@/favorites/hooks/useDeleteFavorite';
import { useFavorites } from '@/favorites/hooks/useFavorites';
import { type FavoriteFolder } from '@/favorites/types/FavoriteFolder';
import { type ObjectRecord } from '@/object-record/types/ObjectRecord';
import { useAtomComponentStateCallbackState } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateCallbackState';
import { useAtomComponentSelectorValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentSelectorValue';
import { useStore } from 'jotai';
import { useCallback } from 'react';
import { isDefined } from 'twenty-shared/utils';
type useFavoriteFolderPickerProps = {
record?: ObjectRecord;
objectNameSingular: string;
};
type useFavoriteFolderPickerReturnType = {
favoriteFolders: FavoriteFolder[];
toggleFolderSelection: (folderId: string) => Promise<void>;
};
export const useFavoriteFolderPicker = ({
record,
objectNameSingular,
}: useFavoriteFolderPickerProps): useFavoriteFolderPickerReturnType => {
const favoriteFoldersMultiSelectChecked = useAtomComponentStateCallbackState(
favoriteFolderPickerCheckedComponentState,
);
const { sortedFavorites: favorites } = useFavorites();
const { createFavorite } = useCreateFavorite();
const { deleteFavorite } = useDeleteFavorite();
const favoriteFolders = useAtomComponentSelectorValue(
favoriteFoldersComponentSelector,
);
const store = useStore();
const toggleFolderSelection = useCallback(
async (folderId: string) => {
const targetId = record?.id;
const targetObject = record;
if (!isDefined(targetObject) || !isDefined(targetId)) {
throw new Error(
`Cannot toggle folder selection: record ${
!isDefined(targetObject) ? 'object' : 'id'
} is not defined`,
);
}
const deleteFavoriteForRecord = async (isUnorganized: boolean) => {
const favoriteToDelete = favorites.find(
(favorite) =>
favorite.recordId === targetId &&
(isUnorganized
? !favorite.favoriteFolderId
: favorite.favoriteFolderId === folderId),
);
if (!isDefined(favoriteToDelete)) {
return;
}
await deleteFavorite(favoriteToDelete.id);
};
const checkedIds = store.get(favoriteFoldersMultiSelectChecked);
const isAlreadyChecked = checkedIds.includes(folderId);
if (isAlreadyChecked) {
await deleteFavoriteForRecord(folderId === 'no-folder');
const newCheckedIds = checkedIds.filter((id) => id !== folderId);
store.set(favoriteFoldersMultiSelectChecked, newCheckedIds);
return;
}
const folderIdToUse = folderId === 'no-folder' ? undefined : folderId;
if (isDefined(record)) {
await createFavorite(record, objectNameSingular, folderIdToUse);
}
const newCheckedIds = [...checkedIds, folderId];
store.set(favoriteFoldersMultiSelectChecked, newCheckedIds);
},
[
favoriteFoldersMultiSelectChecked,
createFavorite,
deleteFavorite,
favorites,
record,
objectNameSingular,
store,
],
);
return {
favoriteFolders,
toggleFolderSelection,
};
};
@@ -1,4 +0,0 @@
import { createComponentInstanceContext } from '@/ui/utilities/state/component-state/utils/createComponentInstanceContext';
export const FavoriteFolderPickerInstanceContext =
createComponentInstanceContext();
@@ -1,10 +0,0 @@
import { FavoriteFolderPickerInstanceContext } from '@/favorites/favorite-folder-picker/states/context/FavoriteFolderPickerInstanceContext';
import { createAtomComponentState } from '@/ui/utilities/state/jotai/utils/createAtomComponentState';
export const favoriteFolderIdsPickerComponentState = createAtomComponentState<
string[]
>({
key: 'favoriteFolderIdsPickerComponentState',
defaultValue: [],
componentInstanceContext: FavoriteFolderPickerInstanceContext,
});
@@ -1,9 +0,0 @@
import { FavoriteFolderPickerInstanceContext } from '@/favorites/favorite-folder-picker/states/context/FavoriteFolderPickerInstanceContext';
import { createAtomComponentState } from '@/ui/utilities/state/jotai/utils/createAtomComponentState';
export const favoriteFolderPickerCheckedComponentState =
createAtomComponentState<string[]>({
key: 'favoriteFolderPickerCheckedComponentState',
defaultValue: [],
componentInstanceContext: FavoriteFolderPickerInstanceContext,
});
@@ -1,10 +0,0 @@
import { FavoriteFolderPickerInstanceContext } from '@/favorites/favorite-folder-picker/states/context/FavoriteFolderPickerInstanceContext';
import { type FavoriteFolder } from '@/favorites/types/FavoriteFolder';
import { createAtomComponentFamilyState } from '@/ui/utilities/state/jotai/utils/createAtomComponentFamilyState';
export const favoriteFolderPickerComponentFamilyState =
createAtomComponentFamilyState<FavoriteFolder | undefined, string>({
key: 'favoriteFolderPickerComponentFamilyState',
defaultValue: undefined,
componentInstanceContext: FavoriteFolderPickerInstanceContext,
});
@@ -1,9 +0,0 @@
import { FavoriteFolderPickerInstanceContext } from '@/favorites/favorite-folder-picker/states/context/FavoriteFolderPickerInstanceContext';
import { createAtomComponentState } from '@/ui/utilities/state/jotai/utils/createAtomComponentState';
export const favoriteFolderSearchFilterComponentState =
createAtomComponentState<string>({
key: 'favoriteFolderSearchFilterComponentState',
defaultValue: '',
componentInstanceContext: FavoriteFolderPickerInstanceContext,
});
@@ -1,30 +0,0 @@
import { FavoriteFolderPickerInstanceContext } from '@/favorites/favorite-folder-picker/states/context/FavoriteFolderPickerInstanceContext';
import { favoriteFolderIdsPickerComponentState } from '@/favorites/favorite-folder-picker/states/favoriteFolderIdPickerComponentState';
import { favoriteFolderPickerComponentFamilyState } from '@/favorites/favorite-folder-picker/states/favoriteFolderPickerComponentFamilyState';
import { type FavoriteFolder } from '@/favorites/types/FavoriteFolder';
import { createAtomComponentSelector } from '@/ui/utilities/state/jotai/utils/createAtomComponentSelector';
import { isDefined } from 'twenty-shared/utils';
export const favoriteFoldersComponentSelector = createAtomComponentSelector<
FavoriteFolder[]
>({
key: 'favoriteFoldersComponentSelector',
componentInstanceContext: FavoriteFolderPickerInstanceContext,
get:
(componentStateKey) =>
({ get }) => {
const folderIds = get(
favoriteFolderIdsPickerComponentState,
componentStateKey,
) as string[];
return folderIds
.map((folderId: string) =>
get(favoriteFolderPickerComponentFamilyState, {
instanceId: componentStateKey.instanceId,
familyKey: folderId,
}),
)
.filter((folder): folder is FavoriteFolder => isDefined(folder));
},
});
@@ -1,290 +0,0 @@
import { gql } from '@apollo/client';
import { Favorite } from '@/favorites/types/Favorite';
import { generateCreateOneRecordMutation } from '@/object-metadata/utils/generateCreateOneRecordMutation';
import { generateUpdateOneRecordMutation } from '@/object-metadata/utils/generateUpdateOneRecordMutation';
import { ColorScheme } from '@/workspace-member/types/WorkspaceMember';
import { AvatarType } from 'twenty-ui/display';
import { CreateNavigationMenuItemDocument } from '~/generated-metadata/graphql';
import { generatedMockObjectMetadataItems } from '~/testing/utils/generatedMockObjectMetadataItems';
import { getMockObjectMetadataItemOrThrow } from '~/testing/utils/getMockObjectMetadataItemOrThrow';
export const mockId = '8f3b2121-f194-4ba4-9fbf-2d5a37126806';
export const favoriteId = 'f088c8c9-05d2-4276-b065-b863cc7d0b33';
const favoriteTargetObjectId = 'f2d8b9e9-7932-4065-bc09-baf12388b75d';
export const favoriteTargetObjectRecord = {
id: favoriteTargetObjectId,
__typename: 'Person',
};
export const initialFavorites: Favorite[] = [
{
__typename: 'Favorite',
id: '1',
position: 0,
key: mockId,
labelIdentifier: 'favoriteLabel',
avatarUrl: 'example.com',
avatarType: 'squared' as AvatarType,
link: 'example.com',
recordId: '1',
person: { id: '1', name: 'John Doe' },
company: { id: '2', name: 'ABC Corp' },
forWorkspaceMemberId: '1',
favoriteFolderId: '1',
},
{
__typename: 'Favorite',
id: '2',
position: 1,
key: mockId,
labelIdentifier: 'favoriteLabel',
avatarUrl: 'example.com',
avatarType: 'squared' as AvatarType,
link: 'example.com',
recordId: '1',
person: { id: '3', name: 'Jane Doe' },
company: { id: '4', name: 'Company Test' },
forWorkspaceMemberId: '1',
favoriteFolderId: '1',
},
{
__typename: 'Favorite',
id: '3',
position: 2,
key: mockId,
labelIdentifier: 'favoriteLabel',
avatarUrl: 'example.com',
company: { id: '5', name: 'Company Test 2' },
avatarType: 'squared' as AvatarType,
link: 'example.com',
recordId: '1',
forWorkspaceMemberId: '1',
favoriteFolderId: '1',
},
];
export const sortedFavorites = [
{
__typename: 'Favorite',
avatarType: 'squared',
avatarUrl: undefined,
favoriteFolderId: '1',
forWorkspaceMemberId: '1',
id: '1',
labelIdentifier: 'ABC Corp',
link: '/object/company/2',
objectNameSingular: 'company',
position: 0,
recordId: '2',
},
{
__typename: 'Favorite',
avatarType: 'squared',
avatarUrl: undefined,
favoriteFolderId: '1',
forWorkspaceMemberId: '1',
id: '2',
labelIdentifier: 'Company Test',
link: '/object/company/4',
objectNameSingular: 'company',
position: 1,
recordId: '4',
},
{
__typename: 'Favorite',
avatarType: 'squared',
avatarUrl: undefined,
favoriteFolderId: '1',
forWorkspaceMemberId: '1',
id: '3',
labelIdentifier: 'Company Test 2',
link: '/object/company/5',
objectNameSingular: 'company',
position: 2,
recordId: '5',
},
];
const favoriteObjectMetadataItem = getMockObjectMetadataItemOrThrow('favorite');
const personObjectMetadataItem = getMockObjectMetadataItemOrThrow('person');
const UPDATE_ONE_FAVORITE_MUTATION = generateUpdateOneRecordMutation({
objectMetadataItem: favoriteObjectMetadataItem,
objectMetadataItems: generatedMockObjectMetadataItems,
computeReferences: false,
objectPermissionsByObjectMetadataId: {},
});
const CREATE_ONE_FAVORITE_MUTATION = generateCreateOneRecordMutation({
objectMetadataItem: favoriteObjectMetadataItem,
objectMetadataItems: generatedMockObjectMetadataItems,
objectPermissionsByObjectMetadataId: {},
});
export const mocks = [
{
request: {
query: CREATE_ONE_FAVORITE_MUTATION,
variables: {
input: {
personId: favoriteTargetObjectId,
position: 1,
forWorkspaceMemberId: '1',
favoriteFolderId: undefined,
id: mockId,
},
},
},
result: jest.fn(() => ({
data: {
createFavorite: {
__typename: 'Favorite',
id: favoriteId,
position: 1,
},
},
})),
},
{
request: {
query: gql`
mutation DeleteOneFavorite($idToDelete: UUID!) {
deleteFavorite(id: $idToDelete) {
__typename
deletedAt
id
}
}
`,
variables: { idToDelete: favoriteId },
},
result: jest.fn(() => ({
data: {
deleteFavorite: {
__typename: 'Favorite',
id: favoriteId,
deletedAt: new Date().toISOString(),
},
},
})),
},
{
request: {
query: UPDATE_ONE_FAVORITE_MUTATION,
variables: {
idToUpdate: '1',
input: {
position: 3,
},
},
},
result: jest.fn(() => ({
data: {
updateFavorite: {
__typename: 'Favorite',
id: favoriteId,
position: 3,
},
},
})),
},
// Mock for folder move
{
request: {
query: UPDATE_ONE_FAVORITE_MUTATION,
variables: {
idToUpdate: '1',
input: {
position: 1,
favoriteFolderId: '2',
},
},
},
result: jest.fn(() => ({
data: {
updateFavorite: {
__typename: 'Favorite',
id: favoriteId,
position: 1,
favoriteFolderId: '2',
},
},
})),
},
// Mock for orphan favorites
{
request: {
query: UPDATE_ONE_FAVORITE_MUTATION,
variables: {
idToUpdate: '1',
input: {
position: 1,
favoriteFolderId: null,
},
},
},
result: jest.fn(() => ({
data: {
updateFavorite: {
__typename: 'Favorite',
id: favoriteId,
position: 1,
favoriteFolderId: null,
},
},
})),
},
{
request: {
query: CreateNavigationMenuItemDocument,
variables: {
input: {
targetRecordId: favoriteTargetObjectId,
targetObjectMetadataId: personObjectMetadataItem.id,
userWorkspaceId: '1',
folderId: undefined,
position: 1,
},
},
},
result: jest.fn(() => ({
data: {
createNavigationMenuItem: {
__typename: 'NavigationMenuItem',
id: mockId,
userWorkspaceId: '1',
targetRecordId: favoriteTargetObjectId,
targetObjectMetadataId: personObjectMetadataItem.id,
viewId: null,
folderId: null,
name: null,
link: null,
icon: null,
position: 1,
applicationId: null,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
},
},
})),
},
];
export const mockWorkspaceMember = {
id: '1',
name: {
firstName: 'First',
lastName: 'Last',
},
avatarUrl: '',
locale: 'en-US',
colorScheme: 'Dark' as ColorScheme,
createdAt: '',
updatedAt: '',
userId: '1',
userEmail: 'userEmail',
};
@@ -1,71 +0,0 @@
import { renderHook, waitFor } from '@testing-library/react';
import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMemberState';
import { useCreateFavorite } from '@/favorites/hooks/useCreateFavorite';
import { objectMetadataItemsState } from '@/object-metadata/states/objectMetadataItemsState';
import { CoreObjectNameSingular } from 'twenty-shared/types';
import { jotaiStore } from '@/ui/utilities/state/jotai/jotaiStore';
import { getJestMetadataAndApolloMocksWrapper } from '~/testing/jest/getJestMetadataAndApolloMocksWrapper';
import { generatedMockObjectMetadataItems } from '~/testing/utils/generatedMockObjectMetadataItems';
import {
favoriteTargetObjectRecord,
initialFavorites,
mockId,
mockWorkspaceMember,
mocks,
} from '@/favorites/hooks/__mocks__/useFavorites';
jest.mock('uuid', () => ({
v4: () => mockId,
}));
jest.mock('@/object-record/hooks/useFindManyRecords', () => ({
useFindManyRecords: () => ({ records: initialFavorites }),
}));
jest.mock(
'@/navigation-menu-item/hooks/usePrefetchedNavigationMenuItemsData',
() => ({
usePrefetchedNavigationMenuItemsData: () => ({
navigationMenuItems: [],
workspaceNavigationMenuItems: [],
currentWorkspaceMemberId: '1',
}),
}),
);
jest.mock('@/favorites/hooks/usePrefetchedFavoritesFoldersData', () => ({
usePrefetchedFavoritesFoldersData: () => ({
favoriteFolders: [],
}),
}));
const Wrapper = getJestMetadataAndApolloMocksWrapper({
apolloMocks: mocks,
});
describe('useCreateFavorite', () => {
beforeEach(() => {
jotaiStore.set(
objectMetadataItemsState.atom,
generatedMockObjectMetadataItems,
);
});
it('should create favorite successfully', async () => {
jotaiStore.set(currentWorkspaceMemberState.atom, mockWorkspaceMember);
const { result } = renderHook(() => useCreateFavorite(), {
wrapper: Wrapper,
});
result.current.createFavorite(
favoriteTargetObjectRecord,
CoreObjectNameSingular.Person,
);
await waitFor(() => {
expect(mocks[0].result).toHaveBeenCalled();
});
});
});
@@ -1,45 +0,0 @@
import { renderHook, waitFor } from '@testing-library/react';
import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMemberState';
import { useDeleteFavorite } from '@/favorites/hooks/useDeleteFavorite';
import { objectMetadataItemsState } from '@/object-metadata/states/objectMetadataItemsState';
import { jotaiStore } from '@/ui/utilities/state/jotai/jotaiStore';
import { getJestMetadataAndApolloMocksWrapper } from '~/testing/jest/getJestMetadataAndApolloMocksWrapper';
import { generatedMockObjectMetadataItems } from '~/testing/utils/generatedMockObjectMetadataItems';
import {
favoriteId,
initialFavorites,
mockWorkspaceMember,
mocks,
} from '@/favorites/hooks/__mocks__/useFavorites';
jest.mock('@/object-record/hooks/useFindManyRecords', () => ({
useFindManyRecords: () => ({ records: initialFavorites }),
}));
const Wrapper = getJestMetadataAndApolloMocksWrapper({
apolloMocks: mocks,
});
describe('useDeleteFavorite', () => {
beforeEach(() => {
jotaiStore.set(
objectMetadataItemsState.atom,
generatedMockObjectMetadataItems,
);
});
it('should delete favorite successfully', async () => {
jotaiStore.set(currentWorkspaceMemberState.atom, mockWorkspaceMember);
const { result } = renderHook(() => useDeleteFavorite(), {
wrapper: Wrapper,
});
result.current.deleteFavorite(favoriteId);
await waitFor(() => {
expect(mocks[1].result).toHaveBeenCalled();
});
});
});
@@ -1,39 +0,0 @@
import { renderHook } from '@testing-library/react';
import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMemberState';
import { useFavorites } from '@/favorites/hooks/useFavorites';
import { objectMetadataItemsState } from '@/object-metadata/states/objectMetadataItemsState';
import { prefetchFavoritesState } from '@/prefetch/states/prefetchFavoritesState';
import { jotaiStore } from '@/ui/utilities/state/jotai/jotaiStore';
import { getJestMetadataAndApolloMocksWrapper } from '~/testing/jest/getJestMetadataAndApolloMocksWrapper';
import { generatedMockObjectMetadataItems } from '~/testing/utils/generatedMockObjectMetadataItems';
import {
initialFavorites,
mockWorkspaceMember,
sortedFavorites,
} from '@/favorites/hooks/__mocks__/useFavorites';
const Wrapper = getJestMetadataAndApolloMocksWrapper({
apolloMocks: [],
});
describe('useFavorites', () => {
beforeEach(() => {
jotaiStore.set(
objectMetadataItemsState.atom,
generatedMockObjectMetadataItems,
);
jotaiStore.set(prefetchFavoritesState.atom, initialFavorites);
});
it('should fetch and sort favorites successfully', () => {
jotaiStore.set(currentWorkspaceMemberState.atom, mockWorkspaceMember);
const { result } = renderHook(() => useFavorites(), {
wrapper: Wrapper,
});
expect(result.current.sortedFavorites).toEqual(sortedFavorites);
});
});
@@ -1,172 +0,0 @@
import { type DropResult, type ResponderProvided } from '@hello-pangea/dnd';
import { renderHook, waitFor } from '@testing-library/react';
import { act } from 'react';
import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMemberState';
import { FAVORITE_DROPPABLE_IDS } from '@/favorites/constants/FavoriteDroppableIds';
import { useHandleFavoriteDragAndDrop } from '@/favorites/hooks/useHandleFavoriteDragAndDrop';
import { type Favorite } from '@/favorites/types/Favorite';
import { createFolderDroppableId } from '@/favorites/utils/createFolderDroppableId';
import { createFolderHeaderDroppableId } from '@/favorites/utils/createFolderHeaderDroppableId';
import { objectMetadataItemsState } from '@/object-metadata/states/objectMetadataItemsState';
import { prefetchFavoritesState } from '@/prefetch/states/prefetchFavoritesState';
import { jotaiStore } from '@/ui/utilities/state/jotai/jotaiStore';
import { getJestMetadataAndApolloMocksWrapper } from '~/testing/jest/getJestMetadataAndApolloMocksWrapper';
import { generatedMockObjectMetadataItems } from '~/testing/utils/generatedMockObjectMetadataItems';
import {
initialFavorites,
mockWorkspaceMember,
mocks,
} from '@/favorites/hooks/__mocks__/useFavorites';
jest.mock('@/object-record/hooks/useFindManyRecords', () => ({
useFindManyRecords: () => ({ records: initialFavorites }),
}));
const Wrapper = getJestMetadataAndApolloMocksWrapper({
apolloMocks: mocks,
});
describe('useHandleFavoriteDragAndDrop', () => {
const mockResponderProvided: ResponderProvided = {
announce: jest.fn(),
};
beforeEach(() => {
jotaiStore.set(
objectMetadataItemsState.atom,
generatedMockObjectMetadataItems,
);
jotaiStore.set(prefetchFavoritesState.atom, initialFavorites as Favorite[]);
});
const setupHook = () => {
jotaiStore.set(currentWorkspaceMemberState.atom, mockWorkspaceMember);
return renderHook(() => useHandleFavoriteDragAndDrop(), {
wrapper: Wrapper,
});
};
it('should not update when destination is null', () => {
const { result } = setupHook();
act(() => {
result.current.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.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.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.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.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.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();
});
});
});
@@ -1,124 +0,0 @@
import { usePrefetchedNavigationMenuItemsData } from '@/navigation-menu-item/hooks/usePrefetchedNavigationMenuItemsData';
import { isNavigationMenuItemFolder } from '@/navigation-menu-item/utils/isNavigationMenuItemFolder';
import { CoreObjectNameSingular } from 'twenty-shared/types';
import { useCreateOneRecord } from '@/object-record/hooks/useCreateOneRecord';
import { type ObjectRecord } from '@/object-record/types/ObjectRecord';
import { isDefined } from 'twenty-shared/utils';
import { useMutation } from '@apollo/client/react';
import { CreateNavigationMenuItemDocument } from '~/generated-metadata/graphql';
import { objectMetadataItemsState } from '@/object-metadata/states/objectMetadataItemsState';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { usePrefetchedFavoritesData } from './usePrefetchedFavoritesData';
import { usePrefetchedFavoritesFoldersData } from './usePrefetchedFavoritesFoldersData';
export const useCreateFavorite = () => {
const { favorites, currentWorkspaceMemberId } = usePrefetchedFavoritesData();
const { favoriteFolders } = usePrefetchedFavoritesFoldersData();
const { navigationMenuItems } = usePrefetchedNavigationMenuItemsData();
const objectMetadataItems = useAtomStateValue(objectMetadataItemsState);
const { createOneRecord: createOneFavorite } = useCreateOneRecord({
objectNameSingular: CoreObjectNameSingular.Favorite,
});
const [createNavigationMenuItemMutation] = useMutation(
CreateNavigationMenuItemDocument,
{
refetchQueries: ['FindManyNavigationMenuItems'],
},
);
const createFavorite = async (
targetRecord: ObjectRecord,
targetObjectNameSingular: string,
favoriteFolderId?: string,
) => {
const relevantFavorites = favoriteFolderId
? favorites.filter((fav) => fav.favoriteFolderId === favoriteFolderId)
: favorites.filter(
(fav) =>
!isDefined(fav.favoriteFolderId) &&
isDefined(fav.forWorkspaceMemberId),
);
const maxPosition = Math.max(
...relevantFavorites.map((fav) => fav.position),
0,
);
await createOneFavorite({
[`${targetObjectNameSingular}Id`]: targetRecord.id,
position: maxPosition + 1,
forWorkspaceMemberId: currentWorkspaceMemberId,
favoriteFolderId,
});
let navigationMenuItemFolderId: string | undefined;
if (isDefined(favoriteFolderId)) {
const favoriteFolder = favoriteFolders.find(
(folder) => folder.id === favoriteFolderId,
);
if (isDefined(favoriteFolder)) {
const matchingNavigationMenuItemFolder = navigationMenuItems.find(
(item) =>
isNavigationMenuItemFolder(item) &&
item.name === favoriteFolder.name &&
item.userWorkspaceId === currentWorkspaceMemberId,
);
navigationMenuItemFolderId = matchingNavigationMenuItemFolder?.id;
}
}
const relevantNavigationMenuItems = navigationMenuItemFolderId
? navigationMenuItems.filter(
(item) => item.folderId === navigationMenuItemFolderId,
)
: navigationMenuItems.filter(
(item) =>
!item.folderId && item.userWorkspaceId === currentWorkspaceMemberId,
);
const maxNavigationMenuItemPosition = Math.max(
...relevantNavigationMenuItems.map((item) => item.position),
0,
);
const isView = targetObjectNameSingular === 'view';
if (isView) {
await createNavigationMenuItemMutation({
variables: {
input: {
viewId: targetRecord.id,
userWorkspaceId: currentWorkspaceMemberId,
folderId: navigationMenuItemFolderId,
position: maxNavigationMenuItemPosition + 1,
},
},
});
} else {
const objectMetadataItem = objectMetadataItems.find(
(item) => item.nameSingular === targetObjectNameSingular,
);
if (isDefined(objectMetadataItem)) {
await createNavigationMenuItemMutation({
variables: {
input: {
targetRecordId: targetRecord.id,
targetObjectMetadataId: objectMetadataItem.id,
userWorkspaceId: currentWorkspaceMemberId,
folderId: navigationMenuItemFolderId,
position: maxNavigationMenuItemPosition + 1,
},
},
});
}
}
};
return { createFavorite };
};
@@ -1,71 +0,0 @@
import { usePrefetchedNavigationMenuItemsData } from '@/navigation-menu-item/hooks/usePrefetchedNavigationMenuItemsData';
import { CoreObjectNameSingular } from 'twenty-shared/types';
import { useCreateOneRecord } from '@/object-record/hooks/useCreateOneRecord';
import { isDefined } from 'twenty-shared/utils';
import { useMutation } from '@apollo/client/react';
import { CreateNavigationMenuItemDocument } from '~/generated-metadata/graphql';
import { usePrefetchedFavoritesData } from './usePrefetchedFavoritesData';
import { usePrefetchedFavoritesFoldersData } from './usePrefetchedFavoritesFoldersData';
export const useCreateFavoriteFolder = () => {
const { createOneRecord: createFavoriteFolder } = useCreateOneRecord({
objectNameSingular: CoreObjectNameSingular.FavoriteFolder,
});
const { currentWorkspaceMemberId } = usePrefetchedFavoritesData();
const { favoriteFolders } = usePrefetchedFavoritesFoldersData();
const { navigationMenuItems } = usePrefetchedNavigationMenuItemsData();
const [createNavigationMenuItemMutation] = useMutation(
CreateNavigationMenuItemDocument,
{
refetchQueries: ['FindManyNavigationMenuItems'],
},
);
const createNewFavoriteFolder = async (name: string): Promise<void> => {
if (!name || !currentWorkspaceMemberId) {
return;
}
const maxPosition = Math.max(
...favoriteFolders.map((folder) => folder.position),
0,
);
await createFavoriteFolder({
name,
position: maxPosition + 1,
});
const folderNavigationMenuItems = navigationMenuItems.filter(
(item) =>
isDefined(item.name) &&
!item.folderId &&
!item.targetRecordId &&
!item.targetObjectMetadataId &&
!item.viewId &&
item.userWorkspaceId === currentWorkspaceMemberId,
);
const maxNavigationMenuItemPosition = Math.max(
...folderNavigationMenuItems.map((item) => item.position),
0,
);
await createNavigationMenuItemMutation({
variables: {
input: {
name,
targetRecordId: null,
targetObjectMetadataId: null,
userWorkspaceId: currentWorkspaceMemberId,
folderId: null,
position: maxNavigationMenuItemPosition + 1,
},
},
});
};
return { createNewFavoriteFolder };
};
@@ -1,14 +0,0 @@
import { CoreObjectNameSingular } from 'twenty-shared/types';
import { useDeleteOneRecord } from '@/object-record/hooks/useDeleteOneRecord';
export const useDeleteFavorite = () => {
const { deleteOneRecord } = useDeleteOneRecord({
objectNameSingular: CoreObjectNameSingular.Favorite,
});
const deleteFavorite = (favoriteId: string) => {
deleteOneRecord(favoriteId);
};
return { deleteFavorite };
};
@@ -1,16 +0,0 @@
import { CoreObjectNameSingular } from 'twenty-shared/types';
import { useDeleteOneRecord } from '@/object-record/hooks/useDeleteOneRecord';
export const useDeleteFavoriteFolder = () => {
const { deleteOneRecord } = useDeleteOneRecord({
objectNameSingular: CoreObjectNameSingular.FavoriteFolder,
});
const deleteFavoriteFolder = async (folderId: string): Promise<void> => {
await deleteOneRecord(folderId);
};
return {
deleteFavoriteFolder,
};
};
@@ -1,60 +0,0 @@
import { favoriteViewsWithMinimalDataSelector } from '@/favorites/states/selectors/favoriteViewsWithMinimalDataSelector';
import { sortFavorites } from '@/favorites/utils/sortFavorites';
import { useGetObjectRecordIdentifierByNameSingular } from '@/object-metadata/hooks/useGetObjectRecordIdentifierByNameSingular';
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
import { objectMetadataItemsState } from '@/object-metadata/states/objectMetadataItemsState';
import { CoreObjectNameSingular } from 'twenty-shared/types';
import { useMemo } from 'react';
import { FieldMetadataType } from '~/generated-metadata/graphql';
import { usePrefetchedFavoritesData } from './usePrefetchedFavoritesData';
import { allowRequestsToTwentyIconsState } from '@/client-config/states/allowRequestsToTwentyIcons';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
export const useFavorites = () => {
const { favorites } = usePrefetchedFavoritesData();
const favoriteViewsWithMinimalData = useAtomStateValue(
favoriteViewsWithMinimalDataSelector,
);
const objectMetadataItems = useAtomStateValue(objectMetadataItemsState);
const { objectMetadataItem: favoriteObjectMetadataItem } =
useObjectMetadataItem({
objectNameSingular: CoreObjectNameSingular.Favorite,
});
const allowRequestsToTwentyIcons = useAtomStateValue(
allowRequestsToTwentyIconsState,
);
const getObjectRecordIdentifierByNameSingular =
useGetObjectRecordIdentifierByNameSingular(allowRequestsToTwentyIcons);
const favoriteRelationFieldMetadataItems = useMemo(
() =>
favoriteObjectMetadataItem.readableFields.filter(
(fieldMetadataItem) =>
fieldMetadataItem.type === FieldMetadataType.RELATION &&
fieldMetadataItem.name !== 'forWorkspaceMember' &&
fieldMetadataItem.name !== 'favoriteFolder',
),
[favoriteObjectMetadataItem.readableFields],
);
const sortedFavorites = useMemo(
() =>
sortFavorites(
favorites,
favoriteRelationFieldMetadataItems,
getObjectRecordIdentifierByNameSingular,
true,
favoriteViewsWithMinimalData,
objectMetadataItems,
),
[
favorites,
favoriteRelationFieldMetadataItems,
getObjectRecordIdentifierByNameSingular,
favoriteViewsWithMinimalData,
objectMetadataItems,
],
);
return { sortedFavorites };
};
@@ -1,36 +0,0 @@
import { sortFavorites } from '@/favorites/utils/sortFavorites';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { coreViewsState } from '@/views/states/coreViewState';
import { convertCoreViewToView } from '@/views/utils/convertCoreViewToView';
import { useFavoritesMetadata } from './useFavoritesMetadata';
import { usePrefetchedFavoritesData } from './usePrefetchedFavoritesData';
import { usePrefetchedFavoritesFoldersData } from './usePrefetchedFavoritesFoldersData';
export const useFavoritesByFolder = () => {
const { favorites } = usePrefetchedFavoritesData();
const { favoriteFolders } = usePrefetchedFavoritesFoldersData();
const {
objectMetadataItems,
getObjectRecordIdentifierByNameSingular,
favoriteRelationFields,
} = useFavoritesMetadata();
const coreViews = useAtomStateValue(coreViewsState);
const views = coreViews.map(convertCoreViewToView);
const favoritesByFolder = favoriteFolders.map((folder) => ({
folderId: folder.id,
folderName: folder.name,
favorites: sortFavorites(
favorites.filter((favorite) => favorite.favoriteFolderId === folder.id),
favoriteRelationFields,
getObjectRecordIdentifierByNameSingular,
true,
views,
objectMetadataItems,
),
}));
return { favoritesByFolder };
};
@@ -1,34 +0,0 @@
import { useGetObjectRecordIdentifierByNameSingular } from '@/object-metadata/hooks/useGetObjectRecordIdentifierByNameSingular';
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
import { objectMetadataItemsState } from '@/object-metadata/states/objectMetadataItemsState';
import { CoreObjectNameSingular } from 'twenty-shared/types';
import { FieldMetadataType } from '~/generated-metadata/graphql';
import { allowRequestsToTwentyIconsState } from '@/client-config/states/allowRequestsToTwentyIcons';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
export const useFavoritesMetadata = () => {
const objectMetadataItems = useAtomStateValue(objectMetadataItemsState);
const allowRequestsToTwentyIcons = useAtomStateValue(
allowRequestsToTwentyIconsState,
);
const getObjectRecordIdentifierByNameSingular =
useGetObjectRecordIdentifierByNameSingular(allowRequestsToTwentyIcons);
const { objectMetadataItem: favoriteObjectMetadataItem } =
useObjectMetadataItem({
objectNameSingular: CoreObjectNameSingular.Favorite,
});
const favoriteRelationFields = favoriteObjectMetadataItem.fields.filter(
(fieldMetadataItem) =>
fieldMetadataItem.type === FieldMetadataType.RELATION &&
fieldMetadataItem.name !== 'forWorkspaceMember' &&
fieldMetadataItem.name !== 'favoriteFolder',
);
return {
objectMetadataItems,
getObjectRecordIdentifierByNameSingular,
favoriteRelationFields,
};
};
@@ -1,133 +0,0 @@
import { FAVORITE_DROPPABLE_IDS } from '@/favorites/constants/FavoriteDroppableIds';
import { useSortedFavorites } from '@/favorites/hooks/useSortedFavorites';
import { openFavoriteFolderIdsState } from '@/favorites/states/openFavoriteFolderIdsState';
import { useUpdateOneRecord } from '@/object-record/hooks/useUpdateOneRecord';
import { calculateNewPosition } from '@/ui/layout/draggable-list/utils/calculateNewPosition';
import { validateAndExtractFolderId } from '@/ui/layout/draggable-list/utils/validateAndExtractFolderId';
import { useSetAtomState } from '@/ui/utilities/state/jotai/hooks/useSetAtomState';
import { type OnDragEndResponder } from '@hello-pangea/dnd';
import { CoreObjectNameSingular } from 'twenty-shared/types';
import { usePrefetchedFavoritesData } from './usePrefetchedFavoritesData';
export const useHandleFavoriteDragAndDrop = () => {
const { favorites } = usePrefetchedFavoritesData();
const { favoritesSorted } = useSortedFavorites();
const { updateOneRecord } = useUpdateOneRecord();
const setOpenFavoriteFolderIds = useSetAtomState(openFavoriteFolderIdsState);
const openDestinationFolder = (folderId: string | null) => {
if (!folderId) return;
setOpenFavoriteFolderIds((current) => {
if (!current.includes(folderId)) {
return [...current, folderId];
}
return current;
});
};
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({
droppableId: destination.droppableId,
orphanDroppableId: FAVORITE_DROPPABLE_IDS.ORPHAN_FAVORITES,
});
const sourceFolderId = validateAndExtractFolderId({
droppableId: source.droppableId,
orphanDroppableId: FAVORITE_DROPPABLE_IDS.ORPHAN_FAVORITES,
});
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
? 1
: folderFavorites[folderFavorites.length - 1].position + 1;
updateOneRecord({
objectNameSingular: CoreObjectNameSingular.Favorite,
idToUpdate: draggableId,
updateOneRecordInput: {
favoriteFolderId: destinationFolderId,
position: newPosition,
},
});
openDestinationFolder(destinationFolderId);
return;
}
if (destination.droppableId !== source.droppableId) {
const destinationFavorites = favoritesSorted.filter(
(favorite) => favorite.favoriteFolderId === destinationFolderId,
);
let newPosition;
if (destinationFavorites.length === 0) {
newPosition = 1;
} else if (destination.index === 0) {
newPosition = destinationFavorites[0].position - 1;
} else if (destination.index >= destinationFavorites.length) {
newPosition =
destinationFavorites[destinationFavorites.length - 1].position + 1;
} else {
newPosition = calculateNewPosition({
destinationIndex: destination.index,
sourceIndex: -1,
items: destinationFavorites,
});
}
updateOneRecord({
objectNameSingular: CoreObjectNameSingular.Favorite,
idToUpdate: draggableId,
updateOneRecordInput: {
favoriteFolderId: destinationFolderId,
position: newPosition,
},
});
return;
}
const favoritesInSameList = favoritesSorted
.filter((favorite) => favorite.favoriteFolderId === sourceFolderId)
.filter((favorite) => favorite.id !== draggableId);
const newPosition = calculateNewPosition({
destinationIndex: destination.index,
sourceIndex: source.index,
items: favoritesInSameList,
});
updateOneRecord({
objectNameSingular: CoreObjectNameSingular.Favorite,
idToUpdate: draggableId,
updateOneRecordInput: { position: newPosition },
});
};
return { handleFavoriteDragAndDrop };
};
@@ -1,30 +0,0 @@
import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMemberState';
import { type Favorite } from '@/favorites/types/Favorite';
import { prefetchFavoritesState } from '@/prefetch/states/prefetchFavoritesState';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
type PrefetchedFavoritesData = {
favorites: Favorite[];
workspaceFavorites: Favorite[];
currentWorkspaceMemberId: string | undefined;
};
export const usePrefetchedFavoritesData = (): PrefetchedFavoritesData => {
const currentWorkspaceMember = useAtomStateValue(currentWorkspaceMemberState);
const currentWorkspaceMemberId = currentWorkspaceMember?.id;
const prefetchFavorites = useAtomStateValue(prefetchFavoritesState);
const favorites = prefetchFavorites.filter(
(favorite) => favorite.forWorkspaceMemberId === currentWorkspaceMemberId,
);
const workspaceFavorites = prefetchFavorites.filter(
(favorite) => favorite.forWorkspaceMemberId === null,
);
return {
favorites,
workspaceFavorites,
currentWorkspaceMemberId,
};
};
@@ -1,26 +0,0 @@
import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMemberState';
import { type FavoriteFolder } from '@/favorites/types/FavoriteFolder';
import { prefetchFavoriteFoldersState } from '@/prefetch/states/prefetchFavoriteFoldersState';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
type PrefetchedFavoritesFoldersData = {
favoriteFolders: FavoriteFolder[];
currentWorkspaceMemberId: string | undefined;
};
export const usePrefetchedFavoritesFoldersData =
(): PrefetchedFavoritesFoldersData => {
const currentWorkspaceMember = useAtomStateValue(
currentWorkspaceMemberState,
);
const currentWorkspaceMemberId = currentWorkspaceMember?.id;
const prefetchFavoriteFolders = useAtomStateValue(
prefetchFavoriteFoldersState,
);
return {
favoriteFolders: prefetchFavoriteFolders,
currentWorkspaceMemberId,
};
};
@@ -1,27 +0,0 @@
import { CoreObjectNameSingular } from 'twenty-shared/types';
import { useUpdateOneRecord } from '@/object-record/hooks/useUpdateOneRecord';
export const useRenameFavoriteFolder = () => {
const { updateOneRecord } = useUpdateOneRecord();
const renameFavoriteFolder = async (
folderId: string,
newName: string,
): Promise<void> => {
if (!newName) {
return;
}
await updateOneRecord({
objectNameSingular: CoreObjectNameSingular.FavoriteFolder,
idToUpdate: folderId,
updateOneRecordInput: {
name: newName,
},
});
};
return {
renameFavoriteFolder,
};
};
@@ -1,59 +0,0 @@
import { sortFavorites } from '@/favorites/utils/sortFavorites';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { coreViewsState } from '@/views/states/coreViewState';
import { convertCoreViewToView } from '@/views/utils/convertCoreViewToView';
import { useMemo } from 'react';
import { useFavoritesMetadata } from './useFavoritesMetadata';
import { usePrefetchedFavoritesData } from './usePrefetchedFavoritesData';
export const useSortedFavorites = () => {
const { favorites, workspaceFavorites } = usePrefetchedFavoritesData();
const {
objectMetadataItems,
getObjectRecordIdentifierByNameSingular,
favoriteRelationFields,
} = useFavoritesMetadata();
const coreViews = useAtomStateValue(coreViewsState).map(
convertCoreViewToView,
);
const favoritesSorted = useMemo(() => {
return sortFavorites(
favorites,
favoriteRelationFields,
getObjectRecordIdentifierByNameSingular,
true,
coreViews,
objectMetadataItems,
);
}, [
coreViews,
favoriteRelationFields,
favorites,
getObjectRecordIdentifierByNameSingular,
objectMetadataItems,
]);
const workspaceFavoritesSorted = useMemo(() => {
return sortFavorites(
workspaceFavorites.filter((favorite) => favorite.viewId),
favoriteRelationFields,
getObjectRecordIdentifierByNameSingular,
false,
coreViews,
objectMetadataItems,
);
}, [
workspaceFavorites,
favoriteRelationFields,
getObjectRecordIdentifierByNameSingular,
coreViews,
objectMetadataItems,
]);
return {
favoritesSorted,
workspaceFavoritesSorted,
};
};
@@ -1,86 +0,0 @@
import { allowRequestsToTwentyIconsState } from '@/client-config/states/allowRequestsToTwentyIcons';
import { sortFavorites } from '@/favorites/utils/sortFavorites';
import { useFilteredObjectMetadataItems } from '@/object-metadata/hooks/useFilteredObjectMetadataItems';
import { useGetObjectRecordIdentifierByNameSingular } from '@/object-metadata/hooks/useGetObjectRecordIdentifierByNameSingular';
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
import { objectMetadataItemsState } from '@/object-metadata/states/objectMetadataItemsState';
import { CoreObjectNameSingular } from 'twenty-shared/types';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { coreViewsState } from '@/views/states/coreViewState';
import { convertCoreViewToView } from '@/views/utils/convertCoreViewToView';
import { useMemo } from 'react';
import { FieldMetadataType } from '~/generated-metadata/graphql';
import { usePrefetchedFavoritesData } from './usePrefetchedFavoritesData';
export const useWorkspaceFavorites = () => {
const { workspaceFavorites } = usePrefetchedFavoritesData();
const coreViews = useAtomStateValue(coreViewsState);
const objectMetadataItems = useAtomStateValue(objectMetadataItemsState);
const { objectMetadataItem: favoriteObjectMetadataItem } =
useObjectMetadataItem({
objectNameSingular: CoreObjectNameSingular.Favorite,
});
const allowRequestsToTwentyIcons = useAtomStateValue(
allowRequestsToTwentyIconsState,
);
const getObjectRecordIdentifierByNameSingular =
useGetObjectRecordIdentifierByNameSingular(allowRequestsToTwentyIcons);
const favoriteRelationFieldMetadataItems = useMemo(
() =>
favoriteObjectMetadataItem.fields.filter(
(fieldMetadataItem) =>
fieldMetadataItem.type === FieldMetadataType.RELATION &&
fieldMetadataItem.name !== 'forWorkspaceMember' &&
fieldMetadataItem.name !== 'favoriteFolder',
),
[favoriteObjectMetadataItem.fields],
);
const views = coreViews.map(convertCoreViewToView);
const sortedWorkspaceFavorites = useMemo(
() =>
sortFavorites(
workspaceFavorites.filter((favorite) => favorite.viewId),
favoriteRelationFieldMetadataItems,
getObjectRecordIdentifierByNameSingular,
false,
views,
objectMetadataItems,
),
[
workspaceFavorites,
favoriteRelationFieldMetadataItems,
getObjectRecordIdentifierByNameSingular,
views,
objectMetadataItems,
],
);
const workspaceFavoriteIds = new Set(
sortedWorkspaceFavorites.map((favorite) => favorite.recordId),
);
const favoriteViewObjectMetadataIds = new Set(
views.reduce<string[]>((acc, view) => {
if (workspaceFavoriteIds.has(view.id)) {
acc.push(view.objectMetadataId);
}
return acc;
}, []),
);
const { activeNonSystemObjectMetadataItems } =
useFilteredObjectMetadataItems();
const activeNonSystemObjectMetadataItemsInWorkspaceFavorites =
activeNonSystemObjectMetadataItems.filter((item) =>
favoriteViewObjectMetadataIds.has(item.id),
);
return {
workspaceFavoritesObjectMetadataItems:
activeNonSystemObjectMetadataItemsInWorkspaceFavorites,
};
};
@@ -1,6 +0,0 @@
import { createAtomState } from '@/ui/utilities/state/jotai/utils/createAtomState';
export const isFavoriteFolderCreatingState = createAtomState<boolean>({
key: 'isFavoriteFolderCreatingState',
defaultValue: false,
});
@@ -1,6 +0,0 @@
import { createAtomState } from '@/ui/utilities/state/jotai/utils/createAtomState';
export const openFavoriteFolderIdsState = createAtomState<string[]>({
key: 'openFavoriteFolderIdsState',
defaultValue: [],
});
@@ -1,20 +0,0 @@
import { coreViewsState } from '@/views/states/coreViewState';
import { type View } from '@/views/types/View';
import { convertCoreViewToView } from '@/views/utils/convertCoreViewToView';
import { createAtomSelector } from '@/ui/utilities/state/jotai/utils/createAtomSelector';
export const favoriteViewsWithMinimalDataSelector = createAtomSelector<
Pick<View, 'id' | 'name' | 'objectMetadataId' | 'icon'>[]
>({
key: 'favoriteViewsWithMinimalDataSelector',
get: ({ get }) => {
const coreViews = get(coreViewsState);
const views = coreViews.map(convertCoreViewToView);
return views.map((view) => ({
id: view.id,
name: view.name,
objectMetadataId: view.objectMetadataId,
icon: view.icon,
}));
},
});
@@ -1,14 +0,0 @@
import { type AvatarType } from 'twenty-ui/display';
export type Favorite = {
id: string;
position: number;
[key: string]: any;
labelIdentifier: string;
avatarUrl: string;
avatarType: AvatarType;
link: string;
recordId: string;
forWorkspaceMemberId: string;
favoriteFolderId?: string;
__typename: 'Favorite';
};
@@ -1,6 +0,0 @@
import { type 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}`;
@@ -1,9 +0,0 @@
export type FavoriteFolder = {
id: string;
name: string;
position: number;
createdAt: string;
updatedAt?: string;
deletedAt?: string | null;
__typename: 'FavoriteFolder';
};
@@ -1,17 +0,0 @@
import { FAVORITE_DROPPABLE_IDS } from '@/favorites/constants/FavoriteDroppableIds';
import { createFolderDroppableId } from '@/favorites/utils/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);
});
});
@@ -1,19 +0,0 @@
import { FAVORITE_DROPPABLE_IDS } from '@/favorites/constants/FavoriteDroppableIds';
import { createFolderHeaderDroppableId } from '@/favorites/utils/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);
});
});
@@ -1,31 +0,0 @@
import { generatedMockObjectMetadataItems } from '~/testing/utils/generatedMockObjectMetadataItems';
import { getFavoriteSecondaryLabel } from '@/favorites/utils/getFavoriteSecondaryLabel';
describe('getFavoriteSecondaryLabel', () => {
it('should return "View" for view object', () => {
const result = getFavoriteSecondaryLabel({
objectMetadataItems: generatedMockObjectMetadataItems,
favoriteObjectNameSingular: 'view',
});
expect(result).toBe('View');
});
it('should return labelSingular for matching object metadata item', () => {
const result = getFavoriteSecondaryLabel({
objectMetadataItems: generatedMockObjectMetadataItems,
favoriteObjectNameSingular: 'person',
});
expect(result).toBe('Person');
});
it('should return undefined when object metadata item is not found', () => {
const result = getFavoriteSecondaryLabel({
objectMetadataItems: generatedMockObjectMetadataItems,
favoriteObjectNameSingular: 'nonexistent',
});
expect(result).toBeUndefined();
});
});
@@ -1,37 +0,0 @@
import { getObjectMetadataNamePluralFromViewId } from '@/favorites/utils/getObjectMetadataNamePluralFromViewId';
import { type View } from '@/views/types/View';
import { generatedMockObjectMetadataItems } from '~/testing/utils/generatedMockObjectMetadataItems';
describe('getObjectMetadataNamePluralFromViewId', () => {
it('should return namePlural and view for matching objectMetadataId', () => {
const view: Pick<View, 'id' | 'name' | 'objectMetadataId'> = {
id: 'view-id',
name: 'All People',
objectMetadataId:
generatedMockObjectMetadataItems[0]?.id ?? 'metadata-id',
};
const result = getObjectMetadataNamePluralFromViewId(
view,
generatedMockObjectMetadataItems,
);
expect(result.namePlural).toBeDefined();
expect(result.view).toEqual(view);
});
it('should throw error when objectMetadataItem is not found', () => {
const view: Pick<View, 'id' | 'name' | 'objectMetadataId'> = {
id: 'view-id',
name: 'All People',
objectMetadataId: 'non-existent-id',
};
expect(() => {
getObjectMetadataNamePluralFromViewId(
view,
generatedMockObjectMetadataItems,
);
}).toThrow('Object metadata item not found for id non-existent-id');
});
});
@@ -1,55 +0,0 @@
import { isLocationMatchingFavorite } from '@/favorites/utils/isLocationMatchingFavorite';
describe('isLocationMatchingFavorite', () => {
it('should return true if favorite link matches current path', () => {
const currentPath = '/app/objects/people';
const currentViewPath = '/app/objects/people?viewId=123';
const favorite = {
objectNameSingular: 'object',
link: '/app/objects/people',
};
expect(
isLocationMatchingFavorite(currentPath, currentViewPath, favorite),
).toBe(true);
});
it('should return true if favorite link matches current view path', () => {
const currentPath = '/app/object/company/12';
const currentViewPath = '/app/object/company/12?viewId=123';
const favorite = {
objectNameSingular: 'company',
link: '/app/object/company/12',
};
expect(
isLocationMatchingFavorite(currentPath, currentViewPath, favorite),
).toBe(true);
});
it('should return false if favorite link does not match current path', () => {
const currentPath = '/app/objects/people';
const currentViewPath = '/app/objects/people?viewId=123';
const favorite = {
objectNameSingular: 'object',
link: '/app/objects/company',
};
expect(
isLocationMatchingFavorite(currentPath, currentViewPath, favorite),
).toBe(false);
});
it('should return false if favorite link does not match current view path', () => {
const currentPath = '/app/objects/companies';
const currentViewPath = '/app/objects/companies?viewId=123';
const favorite = {
objectNameSingular: 'view',
link: '/app/objects/companies?viewId=246',
};
expect(
isLocationMatchingFavorite(currentPath, currentViewPath, favorite),
).toBe(false);
});
});
@@ -1,233 +0,0 @@
import { type Favorite } from '@/favorites/types/Favorite';
import { sortFavorites } from '@/favorites/utils/sortFavorites';
import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { type ObjectRecord } from '@/object-record/types/ObjectRecord';
import { type ObjectRecordIdentifier } from '@/object-record/types/ObjectRecordIdentifier';
import { type View } from '@/views/types/View';
import { FieldMetadataType } from '~/generated-metadata/graphql';
jest.mock('@/favorites/utils/getObjectMetadataNamePluralFromViewId', () => ({
getObjectMetadataNamePluralFromViewId: jest.fn(
(
view: Pick<View, 'id' | 'name' | 'objectMetadataId'>,
items: ObjectMetadataItem[],
) => {
const item = items.find((item) => item.id === view.objectMetadataId);
return { namePlural: item?.namePlural ?? 'items' };
},
),
}));
jest.mock('twenty-shared/utils', () => {
const actual = jest.requireActual('twenty-shared/utils');
return {
...actual,
getAppPath: jest.fn((path, params, query) => {
const basePath = `/app/objects/${params.objectNamePlural}`;
const viewId = query?.viewId;
if (viewId !== undefined && viewId !== null) {
return `${basePath}?viewId=${viewId}`;
}
return basePath;
}),
};
});
describe('sortFavorites', () => {
const mockView: Pick<View, 'id' | 'name' | 'objectMetadataId' | 'icon'> = {
id: 'view-id',
name: 'All People',
objectMetadataId: 'metadata-id',
icon: 'IconUser',
};
const mockObjectMetadataItem: ObjectMetadataItem = {
id: 'metadata-id',
nameSingular: 'person',
namePlural: 'people',
} as ObjectMetadataItem;
const mockObjectRecord: ObjectRecord = {
__typename: 'ObjectRecord',
id: 'record-id',
name: 'John Doe',
} as ObjectRecord;
const mockObjectRecordIdentifier: ObjectRecordIdentifier = {
id: 'record-id',
name: 'John Doe',
avatarUrl: 'https://example.com/avatar.jpg',
avatarType: 'rounded',
linkToShowPage: '/app/objects/people/record-id',
};
const mockRelationField: FieldMetadataItem = {
name: 'person',
type: FieldMetadataType.RELATION,
relation: {
targetObjectMetadata: {
nameSingular: 'person',
},
},
} as FieldMetadataItem;
const getObjectRecordIdentifierByNameSingular = jest.fn(
(
record: ObjectRecord,
objectNameSingular: string,
): ObjectRecordIdentifier => {
if (objectNameSingular === 'person') {
return mockObjectRecordIdentifier;
}
return {
id: record.id,
name: 'Unknown',
};
},
);
beforeEach(() => {
jest.clearAllMocks();
});
it('should process favorite with viewId', () => {
const favorite = {
id: 'favorite-id',
viewId: 'view-id',
position: 1,
} as unknown as Favorite;
const result = sortFavorites(
[favorite],
[],
getObjectRecordIdentifierByNameSingular,
true,
[mockView],
[mockObjectMetadataItem],
);
expect(result).toHaveLength(1);
expect(result[0]).toMatchObject({
id: 'favorite-id',
objectNameSingular: 'view',
Icon: 'IconUser',
});
});
it('should handle favorite with viewId when view is not found', () => {
const favorite = {
id: 'favorite-id',
viewId: 'non-existent-view-id',
position: 1,
} as unknown as Favorite;
const result = sortFavorites(
[favorite],
[],
getObjectRecordIdentifierByNameSingular,
true,
[],
[mockObjectMetadataItem],
);
expect(result).toHaveLength(1);
expect(result[0].objectNameSingular).toBe('view');
});
it('should process favorite with relation field', () => {
const favorite = {
id: 'favorite-id',
person: mockObjectRecord,
position: 2,
} as unknown as Favorite;
const result = sortFavorites(
[favorite],
[mockRelationField],
getObjectRecordIdentifierByNameSingular,
true,
[],
[],
);
expect(result).toHaveLength(1);
expect(result[0]).toMatchObject({
id: 'favorite-id',
objectNameSingular: 'person',
labelIdentifier: 'John Doe',
});
});
it('should return empty link when hasLinkToShowPage is false', () => {
const favorite = {
id: 'favorite-id',
person: mockObjectRecord,
position: 2,
} as unknown as Favorite;
const result = sortFavorites(
[favorite],
[mockRelationField],
getObjectRecordIdentifierByNameSingular,
false,
[],
[],
);
expect(result).toHaveLength(1);
expect(result[0].link).toBe('');
});
it('should sort favorites by position', () => {
const favorites = [
{
id: 'favorite-3',
viewId: 'view-id',
position: 3,
},
{
id: 'favorite-1',
viewId: 'view-id',
position: 1,
},
{
id: 'favorite-2',
viewId: 'view-id',
position: 2,
},
] as unknown as Favorite[];
const result = sortFavorites(
favorites,
[],
getObjectRecordIdentifierByNameSingular,
true,
[mockView],
[mockObjectMetadataItem],
);
expect(result).toHaveLength(3);
expect(result[0].id).toBe('favorite-1');
expect(result[1].id).toBe('favorite-2');
expect(result[2].id).toBe('favorite-3');
});
it('should filter out favorites with no viewId and no relation fields', () => {
const favorite = {
id: 'favorite-id',
position: 1,
} as unknown as Favorite;
const result = sortFavorites(
[favorite],
[],
getObjectRecordIdentifierByNameSingular,
true,
[],
[],
);
expect(result).toHaveLength(0);
});
});
@@ -1,63 +0,0 @@
import { FAVORITE_DROPPABLE_IDS } from '@/favorites/constants/FavoriteDroppableIds';
import { validateAndExtractFolderId } from '@/ui/layout/draggable-list/utils/validateAndExtractFolderId';
describe('validateAndExtractFolderId', () => {
it('should return null for orphan favorites', () => {
const result = validateAndExtractFolderId({
droppableId: FAVORITE_DROPPABLE_IDS.ORPHAN_FAVORITES,
orphanDroppableId: 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,
orphanDroppableId: FAVORITE_DROPPABLE_IDS.ORPHAN_FAVORITES,
});
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,
orphanDroppableId: FAVORITE_DROPPABLE_IDS.ORPHAN_FAVORITES,
});
expect(result).toBe(folderId);
});
it('should throw error for invalid droppable id format', () => {
expect(() => {
validateAndExtractFolderId({
droppableId: 'invalid-id',
orphanDroppableId: FAVORITE_DROPPABLE_IDS.ORPHAN_FAVORITES,
});
}).toThrow('Invalid droppable ID format: invalid-id');
});
it('should throw error for empty folder id in folder format', () => {
expect(() => {
validateAndExtractFolderId({
droppableId: FAVORITE_DROPPABLE_IDS.FOLDER_PREFIX,
orphanDroppableId: FAVORITE_DROPPABLE_IDS.ORPHAN_FAVORITES,
});
}).toThrow(`Invalid folder ID: ${FAVORITE_DROPPABLE_IDS.FOLDER_PREFIX}`);
});
it('should throw error for empty folder id in folder header format', () => {
expect(() => {
validateAndExtractFolderId({
droppableId: FAVORITE_DROPPABLE_IDS.FOLDER_HEADER_PREFIX,
orphanDroppableId: FAVORITE_DROPPABLE_IDS.ORPHAN_FAVORITES,
});
}).toThrow(
`Invalid folder header ID: ${FAVORITE_DROPPABLE_IDS.FOLDER_HEADER_PREFIX}`,
);
});
});
@@ -1,6 +0,0 @@
import { FAVORITE_DROPPABLE_IDS } from '@/favorites/constants/FavoriteDroppableIds';
import { type FavoriteDroppableId } from '@/favorites/types/FavoriteDroppableId';
export const createFolderDroppableId = (
folderId: string,
): FavoriteDroppableId => `${FAVORITE_DROPPABLE_IDS.FOLDER_PREFIX}${folderId}`;
@@ -1,7 +0,0 @@
import { FAVORITE_DROPPABLE_IDS } from '@/favorites/constants/FavoriteDroppableIds';
import { type FavoriteDroppableId } from '@/favorites/types/FavoriteDroppableId';
export const createFolderHeaderDroppableId = (
folderId: string,
): FavoriteDroppableId =>
`${FAVORITE_DROPPABLE_IDS.FOLDER_HEADER_PREFIX}${folderId}`;
@@ -1,23 +0,0 @@
import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
type GetFavoriteSecondaryLabelProps = {
objectMetadataItems: Pick<
ObjectMetadataItem,
'nameSingular' | 'labelSingular'
>[];
favoriteObjectNameSingular: string;
};
export const getFavoriteSecondaryLabel = ({
objectMetadataItems,
favoriteObjectNameSingular,
}: GetFavoriteSecondaryLabelProps) => {
if (favoriteObjectNameSingular === 'view') {
return 'View';
}
return objectMetadataItems.find(
(objectMetadataItem) =>
objectMetadataItem.nameSingular === favoriteObjectNameSingular,
)?.labelSingular;
};
@@ -1,30 +0,0 @@
import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { type View } from '@/views/types/View';
import { isDefined } from 'twenty-shared/utils';
type ReturnType = {
namePlural: string;
view: Pick<View, 'id' | 'name' | 'objectMetadataId'>;
};
export const getObjectMetadataNamePluralFromViewId = (
view: Pick<View, 'id' | 'name' | 'objectMetadataId'>,
objectMetadataItems: ObjectMetadataItem[],
): ReturnType => {
const objectMetadataItem = objectMetadataItems.find(
(objectMetadataItem) => objectMetadataItem.id === view.objectMetadataId,
);
if (!isDefined(objectMetadataItem)) {
throw new Error(
`Object metadata item not found for id ${view.objectMetadataId}`,
);
}
const { namePlural } = objectMetadataItem;
return {
namePlural,
view,
};
};
@@ -1,12 +0,0 @@
import { type ProcessedFavorite } from '@/favorites/utils/sortFavorites';
// Todo: we could only path the fullPath here (which is currentViewPath) and then split it in the function
export const isLocationMatchingFavorite = (
currentPath: string,
currentViewPath: string,
favorite: Pick<ProcessedFavorite, 'objectNameSingular' | 'link'>,
) => {
return favorite.objectNameSingular === 'view'
? favorite.link === currentViewPath
: favorite.link === currentPath;
};
@@ -1,98 +0,0 @@
import { type Favorite } from '@/favorites/types/Favorite';
import { getObjectMetadataNamePluralFromViewId } from '@/favorites/utils/getObjectMetadataNamePluralFromViewId';
import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { type ObjectRecord } from '@/object-record/types/ObjectRecord';
import { type ObjectRecordIdentifier } from '@/object-record/types/ObjectRecordIdentifier';
import { type View } from '@/views/types/View';
import { AppPath } from 'twenty-shared/types';
import { getAppPath, isDefined } from 'twenty-shared/utils';
export type ProcessedFavorite = Favorite & {
Icon?: string;
objectNameSingular: string;
};
export const sortFavorites = (
favorites: Favorite[],
favoriteRelationFieldMetadataItems: FieldMetadataItem[],
getObjectRecordIdentifierByNameSingular: (
record: ObjectRecord,
objectNameSingular: string,
) => ObjectRecordIdentifier,
hasLinkToShowPage: boolean,
views: Pick<View, 'id' | 'name' | 'objectMetadataId' | 'icon'>[],
objectMetadataItems: ObjectMetadataItem[],
): ProcessedFavorite[] => {
return favorites
.map((favorite) => {
if (isDefined(favorite.viewId)) {
const view = views.find((view) => view.id === favorite.viewId);
if (!isDefined(view)) {
return {
...favorite,
objectNameSingular: 'view',
};
}
const { namePlural } = getObjectMetadataNamePluralFromViewId(
view,
objectMetadataItems,
);
return {
__typename: 'Favorite',
id: favorite.id,
recordId: view?.id,
position: favorite.position,
avatarType: 'icon',
avatarUrl: '',
labelIdentifier: view?.name,
link: getAppPath(
AppPath.RecordIndexPage,
{ objectNamePlural: namePlural },
favorite.viewId ? { viewId: favorite.viewId } : undefined,
),
forWorkspaceMemberId: favorite.forWorkspaceMemberId,
favoriteFolderId: favorite.favoriteFolderId,
objectNameSingular: 'view',
Icon: view?.icon,
} as ProcessedFavorite;
}
for (const relationField of favoriteRelationFieldMetadataItems) {
if (isDefined(favorite[relationField.name])) {
const relationObject = favorite[relationField.name];
const objectNameSingular =
relationField.relation?.targetObjectMetadata.nameSingular ?? '';
const objectRecordIdentifier =
getObjectRecordIdentifierByNameSingular(
relationObject,
objectNameSingular,
);
return {
__typename: 'Favorite',
id: favorite.id,
recordId: objectRecordIdentifier.id,
position: favorite.position,
avatarType: objectRecordIdentifier.avatarType,
avatarUrl: objectRecordIdentifier.avatarUrl,
labelIdentifier: objectRecordIdentifier.name,
link: hasLinkToShowPage
? objectRecordIdentifier.linkToShowPage
: '',
forWorkspaceMemberId: favorite.forWorkspaceMemberId,
favoriteFolderId: favorite.favoriteFolderId,
objectNameSingular: objectNameSingular,
} as ProcessedFavorite;
}
}
return null;
})
.filter(isDefined)
.sort((a, b) => a.position - b.position);
};
@@ -1,6 +1,6 @@
import { useListenToMetadataOperationBrowserEvent } from '@/browser-event/hooks/useListenToMetadataOperationBrowserEvent';
import { useMetadataStore } from '@/metadata-store/hooks/useMetadataStore';
import { prefetchNavigationMenuItemsState } from '@/prefetch/states/prefetchNavigationMenuItemsState';
import { navigationMenuItemsState } from '@/navigation-menu-item/states/navigationMenuItemsState';
import { useListenToEventsForQuery } from '@/sse-db-event/hooks/useListenToEventsForQuery';
import { useStore } from 'jotai';
import { isDefined } from 'twenty-shared/utils';
@@ -39,7 +39,7 @@ export const NavigationMenuItemSSEEffect = () => {
}
const existingNavigationMenuItems = store.get(
prefetchNavigationMenuItemsState.atom,
navigationMenuItemsState.atom,
);
if (
@@ -49,7 +49,7 @@ export const NavigationMenuItemSSEEffect = () => {
)
) {
store.set(
prefetchNavigationMenuItemsState.atom,
navigationMenuItemsState.atom,
result.data.navigationMenuItems,
);
@@ -2,7 +2,7 @@ import { useListenToMetadataOperationBrowserEvent } from '@/browser-event/hooks/
import { useMetadataStore } from '@/metadata-store/hooks/useMetadataStore';
import { useRefreshObjectMetadataItems } from '@/object-metadata/hooks/useRefreshObjectMetadataItems';
import { objectMetadataItemsState } from '@/object-metadata/states/objectMetadataItemsState';
import { prefetchNavigationMenuItemsState } from '@/prefetch/states/prefetchNavigationMenuItemsState';
import { navigationMenuItemsState } from '@/navigation-menu-item/states/navigationMenuItemsState';
import { useListenToEventsForQuery } from '@/sse-db-event/hooks/useListenToEventsForQuery';
import { useStore } from 'jotai';
import { useApolloClient } from '@apollo/client/react';
@@ -44,7 +44,7 @@ export const ObjectMetadataItemSSEEffect = () => {
});
const existingNavigationMenuItems = store.get(
prefetchNavigationMenuItemsState.atom,
navigationMenuItemsState.atom,
);
if (
@@ -54,7 +54,7 @@ export const ObjectMetadataItemSSEEffect = () => {
)
) {
store.set(
prefetchNavigationMenuItemsState.atom,
navigationMenuItemsState.atom,
navigationMenuItemsResult.data?.navigationMenuItems ?? [],
);
}
@@ -1,27 +1,21 @@
import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMemberState';
import { useLingui } from '@lingui/react/macro';
import { isDefined } from 'twenty-shared/utils';
import { IconFolderPlus } from 'twenty-ui/display';
import { LightIconButton } from 'twenty-ui/input';
import { AnimatedExpandableContainer } from 'twenty-ui/layout';
import { CurrentWorkspaceMemberOrphanNavigationMenuItems } from '@/navigation-menu-item/components/CurrentWorkspaceMemberOrphanNavigationMenuItems';
import { NavigationMenuItemFolders } from '@/navigation-menu-item/components/NavigationMenuItemFolders';
import { NavigationMenuItemSkeletonLoader } from '@/navigation-menu-item/components/NavigationMenuItemSkeletonLoader';
import { useNavigationMenuItemsByFolder } from '@/navigation-menu-item/hooks/useNavigationMenuItemsByFolder';
import { useSortedNavigationMenuItems } from '@/navigation-menu-item/hooks/useSortedNavigationMenuItems';
import { isNavigationMenuItemFolderCreatingState } from '@/navigation-menu-item/states/isNavigationMenuItemFolderCreatingState';
import { useAtomState } from '@/ui/utilities/state/jotai/hooks/useAtomState';
import { useIsPrefetchLoading } from '@/prefetch/hooks/useIsPrefetchLoading';
import { NavigationDrawerAnimatedCollapseWrapper } from '@/ui/navigation/navigation-drawer/components/NavigationDrawerAnimatedCollapseWrapper';
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 { isNavigationSectionOpenFamilyState } from '@/ui/navigation/navigation-drawer/states/isNavigationSectionOpenFamilyState';
import { useAtomFamilyStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomFamilyStateValue';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
export const CurrentWorkspaceMemberNavigationMenuItemFolders = () => {
const currentWorkspaceMember = useAtomStateValue(currentWorkspaceMemberState);
const { navigationMenuItemsSorted } = useSortedNavigationMenuItems();
const { userNavigationMenuItemsByFolder } = useNavigationMenuItemsByFolder();
@@ -30,8 +24,6 @@ export const CurrentWorkspaceMemberNavigationMenuItemFolders = () => {
setIsNavigationMenuItemFolderCreating,
] = useAtomState(isNavigationMenuItemFolderCreatingState);
const loading = useIsPrefetchLoading();
const { t } = useLingui();
const { toggleNavigationSection, openNavigationSection } =
@@ -46,10 +38,6 @@ export const CurrentWorkspaceMemberNavigationMenuItemFolders = () => {
setIsNavigationMenuItemFolderCreating((current) => !current);
};
if (loading && isDefined(currentWorkspaceMember)) {
return <NavigationMenuItemSkeletonLoader />;
}
if (
navigationMenuItemsSorted.length === 0 &&
!isNavigationMenuItemFolderCreating &&
@@ -1,32 +1,19 @@
import { FeatureFlagKey } from '~/generated-metadata/graphql';
import { CurrentWorkspaceMemberFavoritesFolders } from '@/favorites/components/CurrentWorkspaceMemberFavoritesFolders';
import { CurrentWorkspaceMemberNavigationMenuItemFolders } from '@/navigation-menu-item/components/CurrentWorkspaceMemberNavigationMenuItemFolders';
import { isNavigationMenuInEditModeState } from '@/navigation-menu-item/states/isNavigationMenuInEditModeState';
import { FavoritesDragDropProviderContent } from '@/navigation/components/FavoritesDragDropProviderContent';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
import { AnimatedEaseInOut } from 'twenty-ui/utilities';
export const CurrentWorkspaceMemberNavigationMenuItemFoldersDispatcher = () => {
const isNavigationMenuItemEditingEnabled = useIsFeatureEnabled(
FeatureFlagKey.IS_NAVIGATION_MENU_ITEM_EDITING_ENABLED,
);
const isNavigationMenuInEditMode = useAtomStateValue(
isNavigationMenuInEditModeState,
);
return (
<AnimatedEaseInOut isOpen={!isNavigationMenuInEditMode} initial>
{isNavigationMenuItemEditingEnabled ? (
<FavoritesDragDropProviderContent>
<CurrentWorkspaceMemberNavigationMenuItemFolders />
</FavoritesDragDropProviderContent>
) : (
<FavoritesDragDropProviderContent>
<CurrentWorkspaceMemberFavoritesFolders />
</FavoritesDragDropProviderContent>
)}
<FavoritesDragDropProviderContent>
<CurrentWorkspaceMemberNavigationMenuItemFolders />
</FavoritesDragDropProviderContent>
</AnimatedEaseInOut>
);
};
@@ -9,7 +9,6 @@ import { sidePanelPageState } from '@/side-panel/states/sidePanelPageState';
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';
import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
import { styled } from '@linaria/react';
import { useLingui } from '@lingui/react/macro';
import { AnimatePresence, motion } from 'framer-motion';
@@ -17,7 +16,6 @@ import { useContext, useState } from 'react';
import { SidePanelPages } from 'twenty-shared/types';
import { IconCheck, useIcons } from 'twenty-ui/display';
import { ThemeContext, themeCssVariables } from 'twenty-ui/theme-constants';
import { FeatureFlagKey } from '~/generated-metadata/graphql';
const StyledContainer = styled.div`
align-items: center;
@@ -71,12 +69,8 @@ export const NavigationMenuEditModeBar = () => {
const isNavigationMenuInEditMode = useAtomStateValue(
isNavigationMenuInEditModeState,
);
const isNavigationMenuItemEditingEnabled = useIsFeatureEnabled(
FeatureFlagKey.IS_NAVIGATION_MENU_ITEM_EDITING_ENABLED,
);
const showNavigationMenuEditModeBar =
isNavigationMenuItemEditingEnabled && isNavigationMenuInEditMode;
const showNavigationMenuEditModeBar = isNavigationMenuInEditMode;
const handleSave = async () => {
if (!isDirty) return;
@@ -1,30 +1,20 @@
import { isDefined } from 'twenty-shared/utils';
import { FeatureFlagKey } from '~/generated-metadata/graphql';
import { FavoritesFolderContent } from '@/favorites/components/FavoritesFolderContent';
import { type ProcessedFavorite } from '@/favorites/utils/sortFavorites';
import { NavigationMenuItemFolderContent } from '@/navigation-menu-item/components/NavigationMenuItemFolderContent';
import { type ProcessedNavigationMenuItem } from '@/navigation-menu-item/utils/sortNavigationMenuItems';
import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
type NavigationMenuItemFolderContentDispatcherEffectProps = {
folderName: string;
folderId: string;
favorites?: ProcessedFavorite[];
navigationMenuItems?: ProcessedNavigationMenuItem[];
};
export const NavigationMenuItemFolderContentDispatcherEffect = ({
folderName,
folderId,
favorites,
navigationMenuItems,
}: NavigationMenuItemFolderContentDispatcherEffectProps) => {
const isNavigationMenuItemEditingEnabled = useIsFeatureEnabled(
FeatureFlagKey.IS_NAVIGATION_MENU_ITEM_EDITING_ENABLED,
);
if (isNavigationMenuItemEditingEnabled && isDefined(navigationMenuItems)) {
if (isDefined(navigationMenuItems)) {
return (
<NavigationMenuItemFolderContent
folderId={folderId}
@@ -34,15 +24,5 @@ export const NavigationMenuItemFolderContentDispatcherEffect = ({
);
}
if (isDefined(favorites)) {
return (
<FavoritesFolderContent
folderName={folderName}
folderId={folderId}
favorites={favorites}
/>
);
}
return null;
};
@@ -2,7 +2,6 @@ import { isNonEmptyString } from '@sniptt/guards';
import { isDefined } from 'twenty-shared/utils';
import { Avatar, IconLink, IconWorld, useIcons } from 'twenty-ui/display';
import { themeCssVariables } from 'twenty-ui/theme-constants';
import { FeatureFlagKey } from '~/generated-metadata/graphql';
import { LinkIconWithLinkOverlay } from '@/navigation-menu-item/components/LinkIconWithLinkOverlay';
import { StyledNavigationMenuItemIconContainer } from '@/navigation-menu-item/components/NavigationMenuItemIconContainer';
@@ -16,7 +15,6 @@ import { useGetStandardObjectIcon } from '@/object-metadata/hooks/useGetStandard
import { objectMetadataItemsState } from '@/object-metadata/states/objectMetadataItemsState';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { ViewKey } from '@/views/types/ViewKey';
import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
export const NavigationMenuItemIcon = ({
navigationMenuItem,
@@ -24,9 +22,6 @@ export const NavigationMenuItemIcon = ({
navigationMenuItem: ProcessedNavigationMenuItem;
}) => {
const { getIcon } = useIcons();
const isNavigationMenuItemEditingEnabled = useIsFeatureEnabled(
FeatureFlagKey.IS_NAVIGATION_MENU_ITEM_EDITING_ENABLED,
);
const objectMetadataItems = useAtomStateValue(objectMetadataItemsState);
const { Icon: StandardIcon, IconColor } = useGetStandardObjectIcon(
navigationMenuItem.objectNameSingular ?? '',
@@ -80,10 +75,7 @@ export const NavigationMenuItemIcon = ({
(navigationMenuItem.Icon ? getIcon(navigationMenuItem.Icon) : undefined);
const effectiveColor =
getEffectiveNavigationMenuItemColor(navigationMenuItem);
const useStyledIcon =
isNavigationMenuItemEditingEnabled &&
!isRecord &&
isNonEmptyString(effectiveColor);
const useStyledIcon = !isRecord && isNonEmptyString(effectiveColor);
const iconStyle = useStyledIcon
? getNavigationMenuItemIconStyleFromColor(effectiveColor)
: null;
@@ -11,7 +11,6 @@ import {
} from 'twenty-ui/display';
import { LightIconButton } from 'twenty-ui/input';
import { themeCssVariables } from 'twenty-ui/theme-constants';
import { FeatureFlagKey } from '~/generated-metadata/graphql';
import { FOLDER_ICON_DEFAULT } from '@/navigation-menu-item/constants/FolderIconDefault';
import { NavigationMenuItemType } from '@/navigation-menu-item/constants/NavigationMenuItemType';
@@ -27,16 +26,13 @@ import { openNavigationMenuItemFolderIdsState } from '@/navigation-menu-item/sta
import { selectedNavigationMenuItemInEditModeState } from '@/navigation-menu-item/states/selectedNavigationMenuItemInEditModeState';
import { filterWorkspaceNavigationMenuItems } from '@/navigation-menu-item/utils/filterWorkspaceNavigationMenuItems';
import { preloadWorkspaceDndKit } from '@/navigation/preloadWorkspaceDndKit';
import { NavigationDrawerSectionForObjectMetadataItemsSkeletonLoader } from '@/object-metadata/components/NavigationDrawerSectionForObjectMetadataItemsSkeletonLoader';
import { NavigationDrawerSectionForWorkspaceItems } from '@/object-metadata/components/NavigationDrawerSectionForWorkspaceItems';
import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { useIsPrefetchLoading } from '@/prefetch/hooks/useIsPrefetchLoading';
import { prefetchNavigationMenuItemsState } from '@/prefetch/states/prefetchNavigationMenuItemsState';
import { navigationMenuItemsState } from '@/navigation-menu-item/states/navigationMenuItemsState';
import { useNavigateSidePanel } from '@/side-panel/hooks/useNavigateSidePanel';
import { useAtomState } from '@/ui/utilities/state/jotai/hooks/useAtomState';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { useSetAtomState } from '@/ui/utilities/state/jotai/hooks/useSetAtomState';
import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
import { useStore } from 'jotai';
import { SidePanelPages } from 'twenty-shared/types';
@@ -51,18 +47,13 @@ export const WorkspaceNavigationMenuItems = () => {
const { workspaceNavigationMenuItemsSorted } = useSortedNavigationMenuItems();
const store = useStore();
const enterEditMode = () => {
const prefetchNavigationMenuItems = store.get(
prefetchNavigationMenuItemsState.atom,
);
const currentNavigationMenuItems = store.get(navigationMenuItemsState.atom);
const workspaceNavigationMenuItems = filterWorkspaceNavigationMenuItems(
prefetchNavigationMenuItems,
currentNavigationMenuItems,
);
store.set(navigationMenuItemsDraftState.atom, workspaceNavigationMenuItems);
store.set(isNavigationMenuInEditModeState.atom, true);
};
const isNavigationMenuItemEditingEnabled = useIsFeatureEnabled(
FeatureFlagKey.IS_NAVIGATION_MENU_ITEM_EDITING_ENABLED,
);
const isNavigationMenuInEditMode = useAtomStateValue(
isNavigationMenuInEditModeState,
);
@@ -79,7 +70,6 @@ export const WorkspaceNavigationMenuItems = () => {
useOpenNavigationMenuItemInSidePanel();
const { getIcon } = useIcons();
const loading = useIsPrefetchLoading();
const { t } = useLingui();
const handleEditClick = (event: React.MouseEvent) => {
@@ -171,46 +161,36 @@ export const WorkspaceNavigationMenuItems = () => {
});
};
if (loading) {
return <NavigationDrawerSectionForObjectMetadataItemsSkeletonLoader />;
}
return (
<NavigationDrawerSectionForWorkspaceItems
sectionTitle={t`Workspace`}
items={items}
rightIcon={
isNavigationMenuItemEditingEnabled ? (
<StyledRightIconsContainer>
{isNavigationMenuInEditMode ? (
<StyledRightIconsContainer>
{isNavigationMenuInEditMode ? (
<LightIconButton
Icon={IconPlus}
accent="tertiary"
size="small"
onClick={handleAddMenuItem}
/>
) : (
<div onMouseEnter={preloadWorkspaceDndKit}>
<LightIconButton
Icon={IconPlus}
Icon={IconTool}
accent="tertiary"
size="small"
onClick={handleAddMenuItem}
onClick={handleEditClick}
/>
) : (
<div onMouseEnter={preloadWorkspaceDndKit}>
<LightIconButton
Icon={IconTool}
accent="tertiary"
size="small"
onClick={handleEditClick}
/>
</div>
)}
</StyledRightIconsContainer>
) : undefined
</div>
)}
</StyledRightIconsContainer>
}
selectedNavigationMenuItemId={selectedNavigationMenuItemInEditMode}
onNavigationMenuItemClick={
isNavigationMenuInEditMode ? handleNavigationMenuItemClick : undefined
}
onActiveObjectMetadataItemClick={
isNavigationMenuItemEditingEnabled
? handleActiveObjectMetadataItemClick
: undefined
}
onActiveObjectMetadataItemClick={handleActiveObjectMetadataItemClick}
/>
);
};
@@ -1,17 +1,5 @@
import { FeatureFlagKey } from '~/generated-metadata/graphql';
import { WorkspaceFavorites } from '@/favorites/components/WorkspaceFavorites';
import { WorkspaceNavigationMenuItems } from '@/navigation-menu-item/components/WorkspaceNavigationMenuItems';
import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
export const WorkspaceNavigationMenuItemsDispatcher = () => {
const isNavigationMenuItemEditingEnabled = useIsFeatureEnabled(
FeatureFlagKey.IS_NAVIGATION_MENU_ITEM_EDITING_ENABLED,
);
if (isNavigationMenuItemEditingEnabled) {
return <WorkspaceNavigationMenuItems />;
}
return <WorkspaceFavorites />;
return <WorkspaceNavigationMenuItems />;
};
@@ -2,14 +2,14 @@ import { isDefined } from 'twenty-shared/utils';
import { useMutation } from '@apollo/client/react';
import { CreateNavigationMenuItemDocument } from '~/generated-metadata/graphql';
import { usePrefetchedNavigationMenuItemsData } from '@/navigation-menu-item/hooks/usePrefetchedNavigationMenuItemsData';
import { useNavigationMenuItemsData } from '@/navigation-menu-item/hooks/useNavigationMenuItemsData';
import { objectMetadataItemsState } from '@/object-metadata/states/objectMetadataItemsState';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { type ObjectRecord } from '@/object-record/types/ObjectRecord';
export const useCreateNavigationMenuItem = () => {
const { navigationMenuItems, currentWorkspaceMemberId } =
usePrefetchedNavigationMenuItemsData();
useNavigationMenuItemsData();
const objectMetadataItems = useAtomStateValue(objectMetadataItemsState);
const [createNavigationMenuItemMutation] = useMutation(
@@ -2,11 +2,11 @@ import { isDefined } from 'twenty-shared/utils';
import { useMutation } from '@apollo/client/react';
import { CreateNavigationMenuItemDocument } from '~/generated-metadata/graphql';
import { usePrefetchedNavigationMenuItemsData } from '@/navigation-menu-item/hooks/usePrefetchedNavigationMenuItemsData';
import { useNavigationMenuItemsData } from '@/navigation-menu-item/hooks/useNavigationMenuItemsData';
export const useCreateNavigationMenuItemFolder = () => {
const { navigationMenuItems, currentWorkspaceMemberId } =
usePrefetchedNavigationMenuItemsData();
useNavigationMenuItemsData();
const [createNavigationMenuItemMutation] = useMutation(
CreateNavigationMenuItemDocument,
@@ -10,10 +10,10 @@ import { calculateNewPosition } from '@/ui/layout/draggable-list/utils/calculate
import { FOLDER_DROPPABLE_IDS } from '@/ui/layout/draggable-list/utils/folderDroppableIds';
import { validateAndExtractFolderId } from '@/ui/layout/draggable-list/utils/validateAndExtractFolderId';
import { usePrefetchedNavigationMenuItemsData } from './usePrefetchedNavigationMenuItemsData';
import { useNavigationMenuItemsData } from './useNavigationMenuItemsData';
export const useHandleNavigationMenuItemDragAndDrop = () => {
const { navigationMenuItems } = usePrefetchedNavigationMenuItemsData();
const { navigationMenuItems } = useNavigationMenuItemsData();
const { navigationMenuItemsSorted } = useSortedNavigationMenuItems();
const { updateNavigationMenuItem } = useUpdateNavigationMenuItem();
const setOpenNavigationMenuItemFolderIds = useSetAtomState(
@@ -15,12 +15,11 @@ import {
import { useSetAtomState } from '@/ui/utilities/state/jotai/hooks/useSetAtomState';
import { isDefined } from 'twenty-shared/utils';
import { usePrefetchedNavigationMenuItemsData } from './usePrefetchedNavigationMenuItemsData';
import { useNavigationMenuItemsData } from './useNavigationMenuItemsData';
export const useHandleWorkspaceNavigationMenuItemDragAndDrop = () => {
const store = useStore();
const { workspaceNavigationMenuItems } =
usePrefetchedNavigationMenuItemsData();
const { workspaceNavigationMenuItems } = useNavigationMenuItemsData();
const setNavigationMenuItemsDraft = useSetAtomState(
navigationMenuItemsDraftState,
);
@@ -10,7 +10,7 @@ import { coreViewsState } from '@/views/states/coreViewState';
import { convertCoreViewToView } from '@/views/utils/convertCoreViewToView';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { usePrefetchedNavigationMenuItemsData } from './usePrefetchedNavigationMenuItemsData';
import { useNavigationMenuItemsData } from './useNavigationMenuItemsData';
type NavigationMenuItemFolder = {
id: string;
@@ -30,7 +30,7 @@ export const useNavigationMenuItemsByFolder = () => {
const objectMetadataItems = useAtomStateValue(objectMetadataItemsState);
const { navigationMenuItems, workspaceNavigationMenuItems } =
usePrefetchedNavigationMenuItemsData();
useNavigationMenuItemsData();
const views = coreViews.map(convertCoreViewToView);
@@ -0,0 +1,45 @@
import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMemberState';
import { isNavigationMenuInEditModeState } from '@/navigation-menu-item/states/isNavigationMenuInEditModeState';
import { navigationMenuItemsDraftState } from '@/navigation-menu-item/states/navigationMenuItemsDraftState';
import { filterWorkspaceNavigationMenuItems } from '@/navigation-menu-item/utils/filterWorkspaceNavigationMenuItems';
import { navigationMenuItemsState } from '@/navigation-menu-item/states/navigationMenuItemsState';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { isDefined } from 'twenty-shared/utils';
import { type NavigationMenuItem } from '~/generated-metadata/graphql';
type NavigationMenuItemsData = {
navigationMenuItems: NavigationMenuItem[];
workspaceNavigationMenuItems: NavigationMenuItem[];
currentWorkspaceMemberId: string | undefined;
};
export const useNavigationMenuItemsData = (): NavigationMenuItemsData => {
const currentWorkspaceMember = useAtomStateValue(currentWorkspaceMemberState);
const currentWorkspaceMemberId = currentWorkspaceMember?.id;
const navigationMenuItems = useAtomStateValue(navigationMenuItemsState);
const isNavigationMenuInEditMode = useAtomStateValue(
isNavigationMenuInEditModeState,
);
const navigationMenuItemsDraft = useAtomStateValue(
navigationMenuItemsDraftState,
);
const userNavigationMenuItems = navigationMenuItems.filter((item) =>
isDefined(item.userWorkspaceId),
);
const workspaceNavigationMenuItemsFromState =
filterWorkspaceNavigationMenuItems(navigationMenuItems);
const workspaceNavigationMenuItems =
isNavigationMenuInEditMode && isDefined(navigationMenuItemsDraft)
? navigationMenuItemsDraft
: workspaceNavigationMenuItemsFromState;
return {
navigationMenuItems: userNavigationMenuItems,
workspaceNavigationMenuItems,
currentWorkspaceMemberId,
};
};
@@ -4,34 +4,32 @@ import { isDeeplyEqual } from '~/utils/isDeeplyEqual';
import { isNavigationMenuInEditModeState } from '@/navigation-menu-item/states/isNavigationMenuInEditModeState';
import { filterWorkspaceNavigationMenuItems } from '@/navigation-menu-item/utils/filterWorkspaceNavigationMenuItems';
import { navigationMenuItemsDraftState } from '@/navigation-menu-item/states/navigationMenuItemsDraftState';
import { prefetchNavigationMenuItemsState } from '@/prefetch/states/prefetchNavigationMenuItemsState';
import { navigationMenuItemsState } from '@/navigation-menu-item/states/navigationMenuItemsState';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
export const useNavigationMenuItemsDraftState = () => {
const isNavigationMenuInEditMode = useAtomStateValue(
isNavigationMenuInEditModeState,
);
const prefetchNavigationMenuItems = useAtomStateValue(
prefetchNavigationMenuItemsState,
);
const navigationMenuItems = useAtomStateValue(navigationMenuItemsState);
const navigationMenuItemsDraft = useAtomStateValue(
navigationMenuItemsDraftState,
);
const workspaceNavigationMenuItemsFromPrefetch =
filterWorkspaceNavigationMenuItems(prefetchNavigationMenuItems);
const workspaceNavigationMenuItemsFromState =
filterWorkspaceNavigationMenuItems(navigationMenuItems);
const workspaceNavigationMenuItems =
isNavigationMenuInEditMode && isDefined(navigationMenuItemsDraft)
? navigationMenuItemsDraft
: workspaceNavigationMenuItemsFromPrefetch;
: workspaceNavigationMenuItemsFromState;
const isDirty =
isNavigationMenuInEditMode &&
isDefined(navigationMenuItemsDraft) &&
!isDeeplyEqual(
navigationMenuItemsDraft,
workspaceNavigationMenuItemsFromPrefetch,
workspaceNavigationMenuItemsFromState,
);
return {
@@ -1,50 +0,0 @@
import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMemberState';
import { isNavigationMenuInEditModeState } from '@/navigation-menu-item/states/isNavigationMenuInEditModeState';
import { navigationMenuItemsDraftState } from '@/navigation-menu-item/states/navigationMenuItemsDraftState';
import { filterWorkspaceNavigationMenuItems } from '@/navigation-menu-item/utils/filterWorkspaceNavigationMenuItems';
import { prefetchNavigationMenuItemsState } from '@/prefetch/states/prefetchNavigationMenuItemsState';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { isDefined } from 'twenty-shared/utils';
import { type NavigationMenuItem } from '~/generated-metadata/graphql';
type PrefetchedNavigationMenuItemsData = {
navigationMenuItems: NavigationMenuItem[];
workspaceNavigationMenuItems: NavigationMenuItem[];
currentWorkspaceMemberId: string | undefined;
};
export const usePrefetchedNavigationMenuItemsData =
(): PrefetchedNavigationMenuItemsData => {
const currentWorkspaceMember = useAtomStateValue(
currentWorkspaceMemberState,
);
const currentWorkspaceMemberId = currentWorkspaceMember?.id;
const prefetchNavigationMenuItems = useAtomStateValue(
prefetchNavigationMenuItemsState,
);
const isNavigationMenuInEditMode = useAtomStateValue(
isNavigationMenuInEditModeState,
);
const navigationMenuItemsDraft = useAtomStateValue(
navigationMenuItemsDraftState,
);
const navigationMenuItems = prefetchNavigationMenuItems.filter((item) =>
isDefined(item.userWorkspaceId),
);
const workspaceNavigationMenuItemsFromPrefetch =
filterWorkspaceNavigationMenuItems(prefetchNavigationMenuItems);
const workspaceNavigationMenuItems =
isNavigationMenuInEditMode && isDefined(navigationMenuItemsDraft)
? navigationMenuItemsDraft
: workspaceNavigationMenuItemsFromPrefetch;
return {
navigationMenuItems,
workspaceNavigationMenuItems,
currentWorkspaceMemberId,
};
};
@@ -2,7 +2,7 @@ import { useCallback } from 'react';
import { useApolloCoreClient } from '@/object-metadata/hooks/useApolloCoreClient';
import { FindManyNavigationMenuItemsDocument } from '~/generated-metadata/graphql';
import { prefetchNavigationMenuItemsState } from '@/prefetch/states/prefetchNavigationMenuItemsState';
import { navigationMenuItemsState } from '@/navigation-menu-item/states/navigationMenuItemsState';
import { useSetAtomState } from '@/ui/utilities/state/jotai/hooks/useSetAtomState';
import { isDefined } from 'twenty-shared/utils';
import { useStore } from 'jotai';
@@ -12,15 +12,13 @@ export const useRemoveNavigationMenuItemByTargetRecordId = () => {
const apolloCoreClient = useApolloCoreClient();
const cache = apolloCoreClient.cache;
const setPrefetchNavigationMenuItems = useSetAtomState(
prefetchNavigationMenuItemsState,
);
const setNavigationMenuItems = useSetAtomState(navigationMenuItemsState);
const removeNavigationMenuItemsByTargetRecordIds = useCallback(
(targetRecordIds: string[]) => {
const targetRecordIdsSet = new Set(targetRecordIds);
const currentNavigationMenuItems = store.get(
prefetchNavigationMenuItemsState.atom,
navigationMenuItemsState.atom,
);
const updatedNavigationMenuItems = currentNavigationMenuItems.filter(
@@ -29,7 +27,7 @@ export const useRemoveNavigationMenuItemByTargetRecordId = () => {
!targetRecordIdsSet.has(item.targetRecordId),
);
setPrefetchNavigationMenuItems(updatedNavigationMenuItems);
setNavigationMenuItems(updatedNavigationMenuItems);
cache.updateQuery(
{ query: FindManyNavigationMenuItemsDocument },
@@ -45,7 +43,7 @@ export const useRemoveNavigationMenuItemByTargetRecordId = () => {
},
);
},
[cache, setPrefetchNavigationMenuItems, store],
[cache, setNavigationMenuItems, store],
);
return {
@@ -12,7 +12,7 @@ import { filterWorkspaceNavigationMenuItems } from '@/navigation-menu-item/utils
import { isNavigationMenuItemFolder } from '@/navigation-menu-item/utils/isNavigationMenuItemFolder';
import { isNavigationMenuItemLink } from '@/navigation-menu-item/utils/isNavigationMenuItemLink';
import { orderFoldersForCreation } from '@/navigation-menu-item/utils/orderFoldersForCreation';
import { prefetchNavigationMenuItemsState } from '@/prefetch/states/prefetchNavigationMenuItemsState';
import { navigationMenuItemsState } from '@/navigation-menu-item/states/navigationMenuItemsState';
import { useStore } from 'jotai';
export const useSaveNavigationMenuItemsDraft = () => {
@@ -29,12 +29,12 @@ export const useSaveNavigationMenuItemsDraft = () => {
const saveDraft = useCallback(async () => {
const draft = store.get(navigationMenuItemsDraftState.atom);
const prefetch = store.get(prefetchNavigationMenuItemsState.atom);
const currentItems = store.get(navigationMenuItemsState.atom);
if (!draft) return;
const workspacePrefetch = filterWorkspaceNavigationMenuItems(prefetch);
const topLevelWorkspace = workspacePrefetch.filter(
const workspaceItems = filterWorkspaceNavigationMenuItems(currentItems);
const topLevelWorkspace = workspaceItems.filter(
(item) => !isDefined(item.folderId),
);
const draftIds = new Set(draft.map((i) => i.id));
@@ -47,7 +47,7 @@ export const useSaveNavigationMenuItemsDraft = () => {
.filter(isNavigationMenuItemFolder)
.map((item) => item.id),
);
const folderChildrenToDelete = prefetch.filter(
const folderChildrenToDelete = currentItems.filter(
(item) =>
isDefined(item.folderId) && folderIdsToDelete.has(item.folderId),
);
@@ -59,13 +59,11 @@ export const useSaveNavigationMenuItemsDraft = () => {
await deleteNavigationMenuItem(item.id);
}
const prefetchIds = new Set(workspacePrefetch.map((i) => i.id));
const workspacePrefetchById = new Map(
workspacePrefetch.map((i) => [i.id, i]),
);
const idsToCreate = draft.filter((item) => !prefetchIds.has(item.id));
const currentIds = new Set(workspaceItems.map((i) => i.id));
const workspaceItemsById = new Map(workspaceItems.map((i) => [i.id, i]));
const idsToCreate = draft.filter((item) => !currentIds.has(item.id));
const idsToRecreate = draft.filter((item) => {
const original = workspacePrefetchById.get(item.id);
const original = workspaceItemsById.get(item.id);
if (!original) return false;
return (
original.viewId !== item.viewId ||
@@ -88,10 +86,7 @@ export const useSaveNavigationMenuItemsDraft = () => {
const resolveFolderId = (draftFolderId: string): string =>
createdFolderIdByDraftId.get(draftFolderId) ?? draftFolderId;
const orderedFolders = orderFoldersForCreation(
foldersToCreate,
prefetchIds,
);
const orderedFolders = orderFoldersForCreation(foldersToCreate, currentIds);
for (const draftItem of orderedFolders) {
const input = buildCreateNavigationMenuItemInput(
draftItem,
@@ -120,7 +115,7 @@ export const useSaveNavigationMenuItemsDraft = () => {
for (const draftItem of draft) {
if (idsToRecreateSet.has(draftItem.id)) continue;
const original = workspacePrefetchById.get(draftItem.id);
const original = workspaceItemsById.get(draftItem.id);
if (!original) continue;
const positionChanged = original.position !== draftItem.position;
@@ -11,11 +11,11 @@ import { coreViewsState } from '@/views/states/coreViewState';
import { convertCoreViewToView } from '@/views/utils/convertCoreViewToView';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { usePrefetchedNavigationMenuItemsData } from './usePrefetchedNavigationMenuItemsData';
import { useNavigationMenuItemsData } from './useNavigationMenuItemsData';
export const useSortedNavigationMenuItems = () => {
const { navigationMenuItems, workspaceNavigationMenuItems } =
usePrefetchedNavigationMenuItemsData();
useNavigationMenuItemsData();
const coreViews = useAtomStateValue(coreViewsState).map(
convertCoreViewToView,
);
@@ -8,13 +8,13 @@ import { coreViewsState } from '@/views/states/coreViewState';
import { convertCoreViewToView } from '@/views/utils/convertCoreViewToView';
import { isDefined } from 'twenty-shared/utils';
import { usePrefetchedNavigationMenuItemsData } from './usePrefetchedNavigationMenuItemsData';
import { useNavigationMenuItemsData } from './useNavigationMenuItemsData';
export const useWorkspaceNavigationMenuItems = (): {
workspaceNavigationMenuItemsObjectMetadataItems: ObjectMetadataItem[];
} => {
const { workspaceNavigationMenuItems: rawWorkspaceNavigationMenuItems } =
usePrefetchedNavigationMenuItemsData();
useNavigationMenuItemsData();
const coreViews = useAtomStateValue(coreViewsState);
const views = coreViews.map(convertCoreViewToView);
@@ -15,7 +15,7 @@ import { convertCoreViewToView } from '@/views/utils/convertCoreViewToView';
import { isDefined } from 'twenty-shared/utils';
import { useNavigationMenuItemsByFolder } from './useNavigationMenuItemsByFolder';
import { usePrefetchedNavigationMenuItemsData } from './usePrefetchedNavigationMenuItemsData';
import { useNavigationMenuItemsData } from './useNavigationMenuItemsData';
import { useSortedNavigationMenuItems } from './useSortedNavigationMenuItems';
export type FlatWorkspaceItem =
@@ -31,8 +31,7 @@ export type NavigationMenuItemClickParams = {
};
export const useWorkspaceSectionItems = (): FlatWorkspaceItem[] => {
const { workspaceNavigationMenuItems } =
usePrefetchedNavigationMenuItemsData();
const { workspaceNavigationMenuItems } = useNavigationMenuItemsData();
const { workspaceNavigationMenuItemsSorted } = useSortedNavigationMenuItems();
const { workspaceNavigationMenuItemsByFolder } =
useNavigationMenuItemsByFolder();
@@ -1,9 +1,7 @@
import { createAtomState } from '@/ui/utilities/state/jotai/utils/createAtomState';
import { type NavigationMenuItem } from '~/generated-metadata/graphql';
export const prefetchNavigationMenuItemsState = createAtomState<
NavigationMenuItem[]
>({
key: 'prefetchNavigationMenuItemsState',
export const navigationMenuItemsState = createAtomState<NavigationMenuItem[]>({
key: 'navigationMenuItemsState',
defaultValue: [],
});
@@ -5,18 +5,6 @@ import { type View } from '@/views/types/View';
import { ViewKey } from '@/views/types/ViewKey';
import { type NavigationMenuItem } from '~/generated-metadata/graphql';
jest.mock('@/favorites/utils/getObjectMetadataNamePluralFromViewId', () => ({
getObjectMetadataNamePluralFromViewId: jest.fn(
(
view: Pick<View, 'id' | 'name' | 'objectMetadataId'>,
items: ObjectMetadataItem[],
) => {
const item = items.find((item) => item.id === view.objectMetadataId);
return { namePlural: item?.namePlural ?? 'items' };
},
),
}));
jest.mock('twenty-shared/utils', () => {
const actual = jest.requireActual('twenty-shared/utils');
return {
@@ -7,8 +7,6 @@ import {
} from '@hello-pangea/dnd';
import { useState, type ReactNode } from 'react';
import { FavoritesDragContext } from '@/favorites/contexts/FavoritesDragContext';
import { useHandleFavoriteDragAndDrop } from '@/favorites/hooks/useHandleFavoriteDragAndDrop';
import { ADD_TO_NAV_SOURCE_DROPPABLE_ID } from '@/navigation-menu-item/constants/AddToNavSourceDroppableId';
import { NavigationMenuItemDroppableIds } from '@/navigation-menu-item/constants/NavigationMenuItemDroppableIds';
import { NavigationDragSourceContext } from '@/navigation-menu-item/contexts/NavigationDragSourceContext';
@@ -50,11 +48,10 @@ export const FavoritesDragDropProviderContent = ({
const store = useStore();
const { workspaceNavigationMenuItems } = useNavigationMenuItemsDraftState();
const { handleAddToNavigationDrop } = useHandleAddToNavigationDrop();
const { handleFavoriteDragAndDrop } = useHandleFavoriteDragAndDrop();
const { handleNavigationMenuItemDragAndDrop } =
useHandleNavigationMenuItemDragAndDrop();
const isFavoritesDroppableId = (droppableId: string) =>
const isNavigationMenuItemDroppableId = (droppableId: string) =>
droppableId ===
NavigationMenuItemDroppableIds.ORPHAN_NAVIGATION_MENU_ITEMS ||
droppableId.startsWith('folder-');
@@ -109,7 +106,7 @@ export const FavoritesDragDropProviderContent = ({
return;
}
if (isFavoritesDroppableId(source.droppableId)) {
if (isNavigationMenuItemDroppableId(source.droppableId)) {
if (isDefined(destination)) {
const dropTargetId =
getFavoritesDropTargetIdFromDestination(destination);
@@ -141,36 +138,29 @@ export const FavoritesDragDropProviderContent = ({
return;
}
if (isFavoritesDroppableId(result.source.droppableId)) {
handleNavigationMenuItemDragAndDrop(result, provided);
return;
}
handleFavoriteDragAndDrop(result, provided);
handleNavigationMenuItemDragAndDrop(result, provided);
};
return (
<NavigationDragSourceContext.Provider value={{ sourceDroppableId }}>
<NavigationMenuItemDragContext.Provider value={{ isDragging }}>
<FavoritesDragContext.Provider value={{ isDragging }}>
<NavigationDropTargetContext.Provider
value={{
activeDropTargetId,
setActiveDropTargetId,
forbiddenDropTargetId,
setForbiddenDropTargetId,
addToNavigationFallbackDestination,
}}
<NavigationDropTargetContext.Provider
value={{
activeDropTargetId,
setActiveDropTargetId,
forbiddenDropTargetId,
setForbiddenDropTargetId,
addToNavigationFallbackDestination,
}}
>
<DragDropContext
onDragStart={handleDragStart}
onDragUpdate={handleDragUpdate}
onDragEnd={handleDragEnd}
>
<DragDropContext
onDragStart={handleDragStart}
onDragUpdate={handleDragUpdate}
onDragEnd={handleDragEnd}
>
{children}
</DragDropContext>
</NavigationDropTargetContext.Provider>
</FavoritesDragContext.Provider>
{children}
</DragDropContext>
</NavigationDropTargetContext.Provider>
</NavigationMenuItemDragContext.Provider>
</NavigationDragSourceContext.Provider>
);

Some files were not shown because too many files have changed in this diff Show More