Favorite folders (#7998)

closes - #5755

---------

Co-authored-by: martmull <martmull@hotmail.fr>
Co-authored-by: Lucas Bordeau <bordeau.lucas@gmail.com>
Co-authored-by: Charles Bochet <charles@twenty.com>
This commit is contained in:
nitin 2024-11-18 19:52:19 +05:30 committed by GitHub
parent 5115022355
commit 0125d58ba8
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
100 changed files with 24033 additions and 21488 deletions

View File

@ -4,6 +4,7 @@ import { contextStoreFiltersComponentState } from '@/context-store/states/contex
import { contextStoreNumberOfSelectedRecordsComponentState } from '@/context-store/states/contextStoreNumberOfSelectedRecordsComponentState'; import { contextStoreNumberOfSelectedRecordsComponentState } from '@/context-store/states/contextStoreNumberOfSelectedRecordsComponentState';
import { contextStoreTargetedRecordsRuleComponentState } from '@/context-store/states/contextStoreTargetedRecordsRuleComponentState'; import { contextStoreTargetedRecordsRuleComponentState } from '@/context-store/states/contextStoreTargetedRecordsRuleComponentState';
import { computeContextStoreFilters } from '@/context-store/utils/computeContextStoreFilters'; import { computeContextStoreFilters } from '@/context-store/utils/computeContextStoreFilters';
import { useDeleteFavorite } from '@/favorites/hooks/useDeleteFavorite';
import { useFavorites } from '@/favorites/hooks/useFavorites'; import { useFavorites } from '@/favorites/hooks/useFavorites';
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { DELETE_MAX_COUNT } from '@/object-record/constants/DeleteMaxCount'; import { DELETE_MAX_COUNT } from '@/object-record/constants/DeleteMaxCount';
@ -36,7 +37,8 @@ export const DeleteRecordsActionEffect = ({
objectNameSingular: objectMetadataItem.nameSingular, objectNameSingular: objectMetadataItem.nameSingular,
}); });
const { favorites, deleteFavorite } = useFavorites(); const favorites = useFavorites();
const deleteFavorite = useDeleteFavorite();
const contextStoreNumberOfSelectedRecords = useRecoilComponentValueV2( const contextStoreNumberOfSelectedRecords = useRecoilComponentValueV2(
contextStoreNumberOfSelectedRecordsComponentState, contextStoreNumberOfSelectedRecordsComponentState,

View File

@ -1,5 +1,7 @@
import { useActionMenuEntries } from '@/action-menu/hooks/useActionMenuEntries'; import { useActionMenuEntries } from '@/action-menu/hooks/useActionMenuEntries';
import { contextStoreTargetedRecordsRuleComponentState } from '@/context-store/states/contextStoreTargetedRecordsRuleComponentState'; import { contextStoreTargetedRecordsRuleComponentState } from '@/context-store/states/contextStoreTargetedRecordsRuleComponentState';
import { useCreateFavorite } from '@/favorites/hooks/useCreateFavorite';
import { useDeleteFavorite } from '@/favorites/hooks/useDeleteFavorite';
import { useFavorites } from '@/favorites/hooks/useFavorites'; import { useFavorites } from '@/favorites/hooks/useFavorites';
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { recordStoreFamilyState } from '@/object-record/record-store/states/recordStoreFamilyState'; import { recordStoreFamilyState } from '@/object-record/record-store/states/recordStoreFamilyState';
@ -21,7 +23,11 @@ export const ManageFavoritesActionEffect = ({
contextStoreTargetedRecordsRuleComponentState, contextStoreTargetedRecordsRuleComponentState,
); );
const { favorites, createFavorite, deleteFavorite } = useFavorites(); const favorites = useFavorites();
const createFavorite = useCreateFavorite();
const deleteFavorite = useDeleteFavorite();
const selectedRecordId = const selectedRecordId =
contextStoreTargetedRecordsRule.mode === 'selection' contextStoreTargetedRecordsRule.mode === 'selection'

View File

@ -11,16 +11,16 @@ import { RecordShowPageBaseHeader } from '~/pages/object-record/RecordShowPageBa
export const RecordShowActionMenu = ({ export const RecordShowActionMenu = ({
isFavorite, isFavorite,
handleFavoriteButtonClick,
record, record,
objectMetadataItem, objectMetadataItem,
objectNameSingular, objectNameSingular,
handleFavoriteButtonClick,
}: { }: {
isFavorite: boolean; isFavorite: boolean;
handleFavoriteButtonClick: () => void;
record: ObjectRecord | undefined; record: ObjectRecord | undefined;
objectMetadataItem: ObjectMetadataItem; objectMetadataItem: ObjectMetadataItem;
objectNameSingular: string; objectNameSingular: string;
handleFavoriteButtonClick: () => void;
}) => { }) => {
const contextStoreCurrentObjectMetadataId = useRecoilComponentValueV2( const contextStoreCurrentObjectMetadataId = useRecoilComponentValueV2(
contextStoreCurrentObjectMetadataIdComponentState, contextStoreCurrentObjectMetadataIdComponentState,
@ -40,10 +40,10 @@ export const RecordShowActionMenu = ({
<RecordShowPageBaseHeader <RecordShowPageBaseHeader
{...{ {...{
isFavorite, isFavorite,
handleFavoriteButtonClick,
record, record,
objectMetadataItem, objectMetadataItem,
objectNameSingular, objectNameSingular,
handleFavoriteButtonClick,
}} }}
/> />
<ActionMenuConfirmationModals /> <ActionMenuConfirmationModals />

View File

@ -84,6 +84,7 @@ const mocks: MockedResponse[] = [
companyId companyId
createdAt createdAt
deletedAt deletedAt
favoriteFolderId
id id
noteId noteId
opportunityId opportunityId

View File

@ -1,127 +1,197 @@
import styled from '@emotion/styled'; import { FavoriteFolderNavigationDrawerItemDropdown } from '@/favorites/components/FavoriteFolderNavigationDrawerItemDropdown';
import { useRecoilValue } from 'recoil'; import { FavoriteIcon } from '@/favorites/components/FavoriteIcon';
import { Avatar, isDefined } from 'twenty-ui'; import { useDeleteFavorite } from '@/favorites/hooks/useDeleteFavorite';
import { useDeleteFavoriteFolder } from '@/favorites/hooks/useDeleteFavoriteFolder';
import { FavoritesSkeletonLoader } from '@/favorites/components/FavoritesSkeletonLoader'; import { useRenameFavoriteFolder } from '@/favorites/hooks/useRenameFavoriteFolder';
import { useIsPrefetchLoading } from '@/prefetch/hooks/useIsPrefetchLoading'; import { useReorderFavorite } from '@/favorites/hooks/useReorderFavorite';
import { activeFavoriteFolderIdState } from '@/favorites/states/activeFavoriteFolderIdState';
import { isLocationMatchingFavorite } from '@/favorites/utils/isLocationMatchingFavorite';
import { ProcessedFavorite } from '@/favorites/utils/sortFavorites';
import { DraggableItem } from '@/ui/layout/draggable-list/components/DraggableItem'; import { DraggableItem } from '@/ui/layout/draggable-list/components/DraggableItem';
import { DraggableList } from '@/ui/layout/draggable-list/components/DraggableList'; import { DraggableList } from '@/ui/layout/draggable-list/components/DraggableList';
import { useDropdown } from '@/ui/layout/dropdown/hooks/useDropdown';
import { ConfirmationModal } from '@/ui/layout/modal/components/ConfirmationModal';
import { NavigationDrawerInput } from '@/ui/navigation/navigation-drawer/components/NavigationDrawerInput';
import { NavigationDrawerItem } from '@/ui/navigation/navigation-drawer/components/NavigationDrawerItem'; import { NavigationDrawerItem } from '@/ui/navigation/navigation-drawer/components/NavigationDrawerItem';
import { NavigationDrawerSection } from '@/ui/navigation/navigation-drawer/components/NavigationDrawerSection'; import { NavigationDrawerItemsCollapsableContainer } from '@/ui/navigation/navigation-drawer/components/NavigationDrawerItemsCollapsableContainer';
import { NavigationDrawerSectionTitle } from '@/ui/navigation/navigation-drawer/components/NavigationDrawerSectionTitle'; import { NavigationDrawerSubItem } from '@/ui/navigation/navigation-drawer/components/NavigationDrawerSubItem';
import { useNavigationSection } from '@/ui/navigation/navigation-drawer/hooks/useNavigationSection'; import { getNavigationSubItemLeftAdornment } from '@/ui/navigation/navigation-drawer/utils/getNavigationSubItemLeftAdornment';
import { useState } from 'react';
import { useLocation } from 'react-router-dom';
import { useRecoilState } from 'recoil';
import { IconFolder, IconHeartOff, LightIconButton } from 'twenty-ui';
import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMemberState'; type CurrentWorkspaceMemberFavoritesProps = {
import { NavigationDrawerAnimatedCollapseWrapper } from '@/ui/navigation/navigation-drawer/components/NavigationDrawerAnimatedCollapseWrapper'; folder: {
import { NavigationDrawerItemsCollapsedContainer } from '@/ui/navigation/navigation-drawer/components/NavigationDrawerItemsCollapsedContainer'; folderId: string;
import { ScrollWrapper } from '@/ui/utilities/scroll/components/ScrollWrapper'; folderName: string;
import { useFavorites } from '../hooks/useFavorites'; favorites: ProcessedFavorite[];
};
isGroup: boolean;
};
const StyledContainer = styled(NavigationDrawerSection)` export const CurrentWorkspaceMemberFavorites = ({
width: 100%; folder,
`; isGroup,
}: CurrentWorkspaceMemberFavoritesProps) => {
const currentPath = useLocation().pathname;
const currentViewPath = useLocation().pathname + useLocation().search;
const StyledAvatar = styled(Avatar)` const [isFavoriteFolderRenaming, setIsFavoriteFolderRenaming] =
:hover { useState(false);
cursor: grab; const [favoriteFolderName, setFavoriteFolderName] = useState(
} folder.folderName,
`;
const StyledNavigationDrawerItem = styled(NavigationDrawerItem)`
:active {
cursor: grabbing;
.fav-avatar:hover {
cursor: grabbing;
}
}
`;
export const CurrentWorkspaceMemberFavorites = () => {
const currentWorkspaceMember = useRecoilValue(currentWorkspaceMemberState);
const { favorites, handleReorderFavorite } = useFavorites();
const loading = useIsPrefetchLoading();
const { toggleNavigationSection, isNavigationSectionOpenState } =
useNavigationSection('Favorites');
const isNavigationSectionOpen = useRecoilValue(isNavigationSectionOpenState);
if (loading && isDefined(currentWorkspaceMember)) {
return <FavoritesSkeletonLoader />;
}
const currentWorkspaceMemberFavorites = favorites.filter(
(favorite) => favorite.workspaceMemberId === currentWorkspaceMember?.id,
); );
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
const [activeFavoriteFolderId, setActiveFavoriteFolderId] = useRecoilState(
activeFavoriteFolderIdState,
);
const isOpen = activeFavoriteFolderId === folder.folderId;
if ( const handleToggle = () => {
!currentWorkspaceMemberFavorites || setActiveFavoriteFolderId(isOpen ? null : folder.folderId);
currentWorkspaceMemberFavorites.length === 0 };
)
return <></>;
const isGroup = currentWorkspaceMemberFavorites.length > 1; const { renameFavoriteFolder } = useRenameFavoriteFolder();
const { deleteFavoriteFolder } = useDeleteFavoriteFolder();
const {
closeDropdown: closeFavoriteFolderEditDropdown,
isDropdownOpen: isFavoriteFolderEditDropdownOpen,
} = useDropdown(`favorite-folder-edit-${folder.folderId}`);
const selectedFavoriteIndex = folder.favorites.findIndex((favorite) =>
isLocationMatchingFavorite(currentPath, currentViewPath, favorite),
);
const handleReorderFavorite = useReorderFavorite();
const draggableListContent = ( const deleteFavorite = useDeleteFavorite();
<DraggableList
onDragEnd={handleReorderFavorite}
draggableItems={
<>
{currentWorkspaceMemberFavorites.map((favorite, index) => {
const {
id,
labelIdentifier,
avatarUrl,
avatarType,
link,
recordId,
} = favorite;
return ( const favoriteFolderContentLength = folder.favorites.length;
<DraggableItem
key={id} const handleSubmitRename = async (value: string) => {
draggableId={id} if (value === '') return;
index={index} await renameFavoriteFolder(folder.folderId, value);
itemComponent={ setIsFavoriteFolderRenaming(false);
<StyledNavigationDrawerItem return true;
key={id} };
label={labelIdentifier}
Icon={() => ( const handleCancelRename = () => {
<StyledAvatar setFavoriteFolderName(folder.folderName);
placeholderColorSeed={recordId} setIsFavoriteFolderRenaming(false);
avatarUrl={avatarUrl} };
type={avatarType}
placeholder={labelIdentifier} const handleClickOutside = async (
className="fav-avatar" event: MouseEvent | TouchEvent,
/> value: string,
)} ) => {
to={link} if (!value) {
/> setIsFavoriteFolderRenaming(false);
} return;
/> }
);
})} await renameFavoriteFolder(folder.folderId, value);
</> setIsFavoriteFolderRenaming(false);
} };
const handleFavoriteFolderDelete = async () => {
if (folder.favorites.length > 0) {
setIsDeleteModalOpen(true);
closeFavoriteFolderEditDropdown();
} else {
await deleteFavoriteFolder(folder.folderId);
closeFavoriteFolderEditDropdown();
}
};
const handleConfirmDelete = async () => {
await deleteFavoriteFolder(folder.folderId);
setIsDeleteModalOpen(false);
};
const rightOptions = (
<FavoriteFolderNavigationDrawerItemDropdown
folderId={folder.folderId}
onRename={() => setIsFavoriteFolderRenaming(true)}
onDelete={handleFavoriteFolderDelete}
closeDropdown={closeFavoriteFolderEditDropdown}
/> />
); );
return ( return (
<StyledContainer> <>
<NavigationDrawerAnimatedCollapseWrapper> <NavigationDrawerItemsCollapsableContainer
<NavigationDrawerSectionTitle key={folder.folderId}
label="Favorites" isGroup={isGroup}
onClick={() => toggleNavigationSection()} >
/> {isFavoriteFolderRenaming ? (
</NavigationDrawerAnimatedCollapseWrapper> <NavigationDrawerInput
Icon={IconFolder}
value={favoriteFolderName}
onChange={setFavoriteFolderName}
onSubmit={handleSubmitRename}
onCancel={handleCancelRename}
onClickOutside={handleClickOutside}
hotkeyScope="favorites-folder-input"
/>
) : (
<NavigationDrawerItem
key={folder.folderId}
label={folder.folderName}
Icon={IconFolder}
onClick={handleToggle}
rightOptions={rightOptions}
className="navigation-drawer-item"
active={isFavoriteFolderEditDropdownOpen}
/>
)}
{isNavigationSectionOpen && ( {isOpen && (
<ScrollWrapper contextProviderName="navigationDrawer"> <DraggableList
<NavigationDrawerItemsCollapsedContainer isGroup={isGroup}> onDragEnd={handleReorderFavorite}
{draggableListContent} draggableItems={
</NavigationDrawerItemsCollapsedContainer> <>
</ScrollWrapper> {folder.favorites.map((favorite, index) => (
)} <DraggableItem
</StyledContainer> key={favorite.id}
draggableId={favorite.id}
index={index}
itemComponent={
<NavigationDrawerSubItem
key={favorite.id}
label={favorite.labelIdentifier}
Icon={() => <FavoriteIcon favorite={favorite} />}
to={favorite.link}
active={index === selectedFavoriteIndex}
subItemState={getNavigationSubItemLeftAdornment({
index,
arrayLength: favoriteFolderContentLength,
selectedIndex: selectedFavoriteIndex,
})}
rightOptions={
<LightIconButton
Icon={IconHeartOff}
onClick={() => deleteFavorite(favorite.id)}
accent="tertiary"
/>
}
isDraggable
/>
}
/>
))}
</>
}
/>
)}
</NavigationDrawerItemsCollapsableContainer>
<ConfirmationModal
isOpen={isDeleteModalOpen}
setIsOpen={setIsDeleteModalOpen}
title={`Remove ${folder.favorites.length} ${folder.favorites.length > 1 ? 'favorites' : 'favorite'}?`}
subtitle={`This action will delete this favorite folder ${folder.favorites.length > 1 ? `and all ${folder.favorites.length} favorites` : 'and the favorite'} inside. Do you want to continue?`}
onConfirmClick={handleConfirmDelete}
deleteButtonText="Delete Folder"
/>
</>
); );
}; };

View File

@ -0,0 +1,137 @@
import { useTheme } from '@emotion/react';
import { useLocation } from 'react-router-dom';
import { useRecoilState, useRecoilValue } from 'recoil';
import {
IconFolderPlus,
IconHeartOff,
isDefined,
LightIconButton,
} from 'twenty-ui';
import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMemberState';
import { FavoriteIcon } from '@/favorites/components/FavoriteIcon';
import { FavoriteFolders } from '@/favorites/components/FavoritesFolders';
import { FavoritesSkeletonLoader } from '@/favorites/components/FavoritesSkeletonLoader';
import { useDeleteFavorite } from '@/favorites/hooks/useDeleteFavorite';
import { useFavorites } from '@/favorites/hooks/useFavorites';
import { useReorderFavorite } from '@/favorites/hooks/useReorderFavorite';
import { isFavoriteFolderCreatingState } from '@/favorites/states/isFavoriteFolderCreatingState';
import { isLocationMatchingFavorite } from '@/favorites/utils/isLocationMatchingFavorite';
import { useIsPrefetchLoading } from '@/prefetch/hooks/useIsPrefetchLoading';
import { DraggableItem } from '@/ui/layout/draggable-list/components/DraggableItem';
import { DraggableList } from '@/ui/layout/draggable-list/components/DraggableList';
import { NavigationDrawerAnimatedCollapseWrapper } from '@/ui/navigation/navigation-drawer/components/NavigationDrawerAnimatedCollapseWrapper';
import { NavigationDrawerItem } from '@/ui/navigation/navigation-drawer/components/NavigationDrawerItem';
import { NavigationDrawerSection } from '@/ui/navigation/navigation-drawer/components/NavigationDrawerSection';
import { NavigationDrawerSectionTitle } from '@/ui/navigation/navigation-drawer/components/NavigationDrawerSectionTitle';
import { useNavigationSection } from '@/ui/navigation/navigation-drawer/hooks/useNavigationSection';
import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
export const CurrentWorkspaceMemberFavoritesFolders = () => {
const currentPath = useLocation().pathname;
const currentViewPath = useLocation().pathname + useLocation().search;
const theme = useTheme();
const currentWorkspaceMember = useRecoilValue(currentWorkspaceMemberState);
const favorites = useFavorites();
const deleteFavorite = useDeleteFavorite();
const handleReorderFavorite = useReorderFavorite();
const [isFavoriteFolderCreating, setIsFavoriteFolderCreating] =
useRecoilState(isFavoriteFolderCreatingState);
const isFavoriteFolderEnabled = useIsFeatureEnabled(
'IS_FAVORITE_FOLDER_ENABLED',
);
const loading = useIsPrefetchLoading();
const { toggleNavigationSection, isNavigationSectionOpenState } =
useNavigationSection('Favorites');
const isNavigationSectionOpen = useRecoilValue(isNavigationSectionOpenState);
const toggleNewFolder = () => {
setIsFavoriteFolderCreating((current) => !current);
};
if (loading && isDefined(currentWorkspaceMember)) {
return <FavoritesSkeletonLoader />;
}
const currentWorkspaceMemberFavorites = favorites.filter(
(favorite) => favorite.workspaceMemberId === currentWorkspaceMember?.id,
);
const orphanFavorites = currentWorkspaceMemberFavorites.filter(
(favorite) => !favorite.favoriteFolderId,
);
if (
(!currentWorkspaceMemberFavorites ||
currentWorkspaceMemberFavorites.length === 0) &&
!isFavoriteFolderCreating
) {
return null;
}
return (
<NavigationDrawerSection>
<NavigationDrawerAnimatedCollapseWrapper>
<NavigationDrawerSectionTitle
label="Favorites"
onClick={toggleNavigationSection}
rightIcon={
isFavoriteFolderEnabled ? (
<IconFolderPlus size={theme.icon.size.sm} />
) : undefined
}
onRightIconClick={
isFavoriteFolderEnabled ? toggleNewFolder : undefined
}
/>
</NavigationDrawerAnimatedCollapseWrapper>
{isNavigationSectionOpen && (
<>
{isFavoriteFolderEnabled && (
<FavoriteFolders
isNavigationSectionOpen={isNavigationSectionOpen}
/>
)}
{orphanFavorites.length > 0 && (
<DraggableList
onDragEnd={handleReorderFavorite}
draggableItems={orphanFavorites.map((favorite, index) => (
<DraggableItem
key={favorite.id}
draggableId={favorite.id}
index={index}
itemComponent={
<NavigationDrawerItem
key={favorite.id}
className="navigation-drawer-item"
label={favorite.labelIdentifier}
Icon={() => <FavoriteIcon favorite={favorite} />}
active={isLocationMatchingFavorite(
currentPath,
currentViewPath,
favorite,
)}
to={favorite.link}
rightOptions={
<LightIconButton
Icon={IconHeartOff}
onClick={() => deleteFavorite(favorite.id)}
accent="tertiary"
/>
}
isDraggable={true}
/>
}
/>
))}
/>
)}
</>
)}
</NavigationDrawerSection>
);
};

View File

@ -0,0 +1,65 @@
import { FavoriteFolderHotkeyScope } from '@/favorites/constants/FavoriteFolderRightIconDropdownHotkeyScope';
import { Dropdown } from '@/ui/layout/dropdown/components/Dropdown';
import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer';
import {
IconDotsVertical,
IconPencil,
IconTrash,
LightIconButton,
MenuItem,
} from 'twenty-ui';
type FavoriteFolderNavigationDrawerItemDropdownProps = {
folderId: string;
onRename: () => void;
onDelete: () => void;
closeDropdown: () => void;
};
export const FavoriteFolderNavigationDrawerItemDropdown = ({
folderId,
onRename,
onDelete,
closeDropdown,
}: FavoriteFolderNavigationDrawerItemDropdownProps) => {
const handleRename = () => {
onRename();
closeDropdown();
};
const handleDelete = () => {
onDelete();
closeDropdown();
};
return (
<Dropdown
dropdownId={`favorite-folder-edit-${folderId}`}
dropdownHotkeyScope={{
scope: FavoriteFolderHotkeyScope.FavoriteFolderRightIconDropdown,
}}
data-select-disable
clickableComponent={
<LightIconButton Icon={IconDotsVertical} accent="tertiary" />
}
dropdownPlacement="right"
dropdownOffset={{ y: -15 }}
dropdownComponents={
<DropdownMenuItemsContainer>
<MenuItem
LeftIcon={IconPencil}
onClick={handleRename}
accent="default"
text="Rename"
/>
<MenuItem
LeftIcon={IconTrash}
onClick={handleDelete}
accent="danger"
text="Delete"
/>
</DropdownMenuItemsContainer>
}
/>
);
};

View File

@ -0,0 +1,27 @@
import { ProcessedFavorite } from '@/favorites/utils/sortFavorites';
import { useGetStandardObjectIcon } from '@/object-metadata/hooks/useGetStandardObjectIcon';
import { useTheme } from '@emotion/react';
import { Avatar, useIcons } from 'twenty-ui';
export const FavoriteIcon = ({ favorite }: { favorite: ProcessedFavorite }) => {
const theme = useTheme();
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}
/>
);
};

View File

@ -0,0 +1,85 @@
import { useState } from 'react';
import { useRecoilState } from 'recoil';
import { IconFolder } from 'twenty-ui';
import { CurrentWorkspaceMemberFavorites } from '@/favorites/components/CurrentWorkspaceMemberFavorites';
import { FavoriteFolderHotkeyScope } from '@/favorites/constants/FavoriteFolderRightIconDropdownHotkeyScope';
import { useCreateFavoriteFolder } from '@/favorites/hooks/useCreateFavoriteFolder';
import { useFavoritesByFolder } from '@/favorites/hooks/useFavoritesByFolder';
import { isFavoriteFolderCreatingState } from '@/favorites/states/isFavoriteFolderCreatingState';
import { NavigationDrawerInput } from '@/ui/navigation/navigation-drawer/components/NavigationDrawerInput';
type FavoriteFoldersProps = {
isNavigationSectionOpen: boolean;
};
export const FavoriteFolders = ({
isNavigationSectionOpen,
}: FavoriteFoldersProps) => {
const [newFolderName, setNewFolderName] = useState('');
const favoritesByFolder = useFavoritesByFolder();
const createFavoriteFolder = useCreateFavoriteFolder();
const [isFavoriteFolderCreating, setIsFavoriteFolderCreating] =
useRecoilState(isFavoriteFolderCreatingState);
const handleFavoriteFolderNameChange = (value: string) => {
setNewFolderName(value);
};
const handleSubmitFavoriteFolderCreation = async (value: string) => {
if (value === '') return;
setIsFavoriteFolderCreating(false);
setNewFolderName('');
await createFavoriteFolder(value);
return true;
};
const handleClickOutside = async (
event: MouseEvent | TouchEvent,
value: string,
) => {
if (!value) {
setIsFavoriteFolderCreating(false);
return;
}
setIsFavoriteFolderCreating(false);
setNewFolderName('');
await createFavoriteFolder(value);
};
const handleCancelFavoriteFolderCreation = () => {
setNewFolderName('');
setIsFavoriteFolderCreating(false);
};
if (!isNavigationSectionOpen) {
return null;
}
return (
<>
{isFavoriteFolderCreating && (
<NavigationDrawerInput
Icon={IconFolder}
value={newFolderName}
onChange={handleFavoriteFolderNameChange}
onSubmit={handleSubmitFavoriteFolderCreation}
onCancel={handleCancelFavoriteFolderCreation}
onClickOutside={handleClickOutside}
hotkeyScope={FavoriteFolderHotkeyScope.FavoriteFolderNavigationInput}
/>
)}
{favoritesByFolder.map((folder) => (
<CurrentWorkspaceMemberFavorites
key={folder.folderId}
folder={folder}
isGroup={favoritesByFolder.length > 1}
/>
))}
</>
);
};

View File

@ -0,0 +1,4 @@
export enum FavoriteFolderHotkeyScope {
FavoriteFolderRightIconDropdown = 'favorite-folder-right-icon-dropdown',
FavoriteFolderNavigationInput = 'favorite-folder-navigation-input',
}

View File

@ -0,0 +1,10 @@
import styled from '@emotion/styled';
import { MenuItemMultiSelect } from '@ui/navigation/menu-item/components/MenuItemMultiSelect';
const StyledNoGapMenuItem = styled(MenuItemMultiSelect)`
& > div {
gap: 0;
}
`;
export const FavoriteFolderMenuItemMultiSelect = StyledNoGapMenuItem;

View File

@ -0,0 +1,104 @@
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 { ObjectRecord } from '@/object-record/types/ObjectRecord';
import { DropdownMenu } from '@/ui/layout/dropdown/components/DropdownMenu';
import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer';
import { DropdownMenuSeparator } from '@/ui/layout/dropdown/components/DropdownMenuSeparator';
import { useScopedHotkeys } from '@/ui/utilities/hotkey/hooks/useScopedHotkeys';
import { useAvailableComponentInstanceIdOrThrow } from '@/ui/utilities/state/component-state/hooks/useAvailableComponentInstanceIdOrThrow';
import { useRecoilComponentStateV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentStateV2';
import { useRecoilState } from 'recoil';
import { Key } from 'ts-key-enum';
type FavoriteFolderPickerProps = {
onSubmit?: () => void;
record?: ObjectRecord;
objectNameSingular: string;
};
const NO_FOLDER_ID = 'no-folder';
export const FavoriteFolderPicker = ({
onSubmit,
record,
objectNameSingular,
}: FavoriteFolderPickerProps) => {
const [isFavoriteFolderCreating, setIsFavoriteFolderCreating] =
useRecoilState(isFavoriteFolderCreatingState);
const instanceId = useAvailableComponentInstanceIdOrThrow(
FavoriteFolderPickerInstanceContext,
);
const { getFoldersByIds, toggleFolderSelection } = useFavoriteFolderPicker({
record,
objectNameSingular,
});
const [favoriteFoldersSearchFilter] = useRecoilComponentStateV2(
favoriteFolderSearchFilterComponentState,
);
const folders = getFoldersByIds();
const filteredFolders = folders.filter((folder) =>
folder.name
.toLowerCase()
.includes(favoriteFoldersSearchFilter.toLowerCase()),
);
const showNoFolderOption =
!favoriteFoldersSearchFilter ||
'no folder'.includes(favoriteFoldersSearchFilter.toLowerCase());
useScopedHotkeys(
Key.Escape,
() => {
if (isFavoriteFolderCreating) {
setIsFavoriteFolderCreating(false);
return;
}
onSubmit?.();
},
instanceId,
[onSubmit, isFavoriteFolderCreating],
);
useScopedHotkeys(
Key.Enter,
() => {
if (filteredFolders.length === 1 && !showNoFolderOption) {
toggleFolderSelection(filteredFolders[0].id);
onSubmit?.();
return;
}
if (showNoFolderOption && filteredFolders.length === 0) {
toggleFolderSelection(NO_FOLDER_ID);
onSubmit?.();
return;
}
},
instanceId,
[filteredFolders, showNoFolderOption, toggleFolderSelection, onSubmit],
);
return (
<DropdownMenu data-select-disable>
<FavoriteFolderPickerSearchInput />
<DropdownMenuSeparator />
<DropdownMenuItemsContainer hasMaxHeight>
<FavoriteFolderPickerList
folders={folders}
toggleFolderSelection={toggleFolderSelection}
/>
</DropdownMenuItemsContainer>
<FavoriteFolderPickerFooter />
</DropdownMenu>
);
};

View File

@ -0,0 +1,82 @@
import { useEffect } from 'react';
import { useRecoilCallback } from 'recoil';
import { isDefined } from 'twenty-ui';
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 { FavoriteFolder } from '@/favorites/types/FavoriteFolder';
import { ObjectRecord } from '@/object-record/types/ObjectRecord';
import { useRecoilComponentCallbackStateV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentCallbackStateV2';
import { useRecoilComponentStateV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentStateV2';
import { useSetRecoilComponentStateV2 } from '@/ui/utilities/state/component-state/hooks/useSetRecoilComponentStateV2';
type FavoriteFolderPickerEffectProps = {
record?: ObjectRecord;
};
export const FavoriteFolderPickerEffect = ({
record,
}: FavoriteFolderPickerEffectProps) => {
const [favoriteFolderIdsPicker, setFavoriteFolderIdsPicker] =
useRecoilComponentStateV2(favoriteFolderIdsPickerComponentState);
const favoriteFolderPickerFamilyState = useRecoilComponentCallbackStateV2(
favoriteFolderPickerComponentFamilyState,
);
const { favoriteFolders } = usePrefetchedFavoritesFoldersData();
const favorites = useFavorites();
const setCheckedState = useSetRecoilComponentStateV2(
favoriteFolderPickerCheckedComponentState,
);
const updateFolders = useRecoilCallback(
({ snapshot, set }) =>
(folders: FavoriteFolder[]) => {
folders.forEach((folder) => {
const currentFolder = snapshot
.getLoadable(favoriteFolderPickerFamilyState(folder.id))
.getValue();
if (!isDeeplyEqual(folder, currentFolder)) {
set(favoriteFolderPickerFamilyState(folder.id), folder);
}
});
},
[favoriteFolderPickerFamilyState],
);
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 && favorite.workspaceMemberId,
)
.map((favorite) => favorite.favoriteFolderId || 'no-folder');
setCheckedState(checkedFolderIds);
}, [favorites, setCheckedState, record?.id]);
return null;
};

View File

@ -0,0 +1,43 @@
import { FAVORITE_FOLDER_PICKER_DROPDOWN_ID } from '@/favorites/favorite-folder-picker/constants/FavoriteFolderPickerDropdownId';
import { isFavoriteFolderCreatingState } from '@/favorites/states/isFavoriteFolderCreatingState';
import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer';
import { useDropdown } from '@/ui/layout/dropdown/hooks/useDropdown';
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { useRecoilState } from 'recoil';
import { IconPlus, MenuItem } from 'twenty-ui';
const StyledFooter = styled.div`
background: ${({ theme }) => theme.background.primary};
border-bottom-left-radius: ${({ theme }) => theme.border.radius.md};
border-bottom-right-radius: ${({ theme }) => theme.border.radius.md};
border-top: 1px solid ${({ theme }) => theme.border.color.light};
`;
const StyledIconPlus = styled(IconPlus)`
padding-left: ${({ theme }) => theme.spacing(1)};
`;
export const FavoriteFolderPickerFooter = () => {
const [, setIsFavoriteFolderCreating] = useRecoilState(
isFavoriteFolderCreatingState,
);
const theme = useTheme();
const { closeDropdown } = useDropdown(FAVORITE_FOLDER_PICKER_DROPDOWN_ID);
return (
<StyledFooter>
<DropdownMenuItemsContainer>
<MenuItem
className="add-folder"
onClick={() => {
setIsFavoriteFolderCreating(true);
closeDropdown();
}}
text="Add folder"
LeftIcon={() => <StyledIconPlus size={theme.icon.size.md} />}
/>
</DropdownMenuItemsContainer>
</StyledFooter>
);
};

View File

@ -0,0 +1,74 @@
import { favoriteFolderPickerCheckedComponentState } from '@/favorites/favorite-folder-picker/states/favoriteFolderPickerCheckedComponentState';
import { favoriteFolderSearchFilterComponentState } from '@/favorites/favorite-folder-picker/states/favoriteFoldersSearchFilterComponentState';
import { FavoriteFolder } from '@/favorites/types/FavoriteFolder';
import { DropdownMenuSeparator } from '@/ui/layout/dropdown/components/DropdownMenuSeparator';
import { useRecoilComponentStateV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentStateV2';
import styled from '@emotion/styled';
import { MenuItem } from 'twenty-ui';
import { FavoriteFolderMenuItemMultiSelect } from './FavoriteFolderMenuItemMultiSelect';
const StyledItemsContainer = styled.div`
width: 100%;
`;
const StyledDropdownMenuSeparator = styled(DropdownMenuSeparator)`
margin-bottom: ${({ theme }) => theme.spacing(1)};
margin-top: ${({ theme }) => theme.spacing(1)};
`;
type FavoriteFolderPickerListProps = {
folders: FavoriteFolder[];
toggleFolderSelection: (folderId: string) => void;
};
export const NO_FOLDER_ID = 'no-folder';
export const FavoriteFolderPickerList = ({
folders,
toggleFolderSelection,
}: FavoriteFolderPickerListProps) => {
const [favoriteFoldersSearchFilter] = useRecoilComponentStateV2(
favoriteFolderSearchFilterComponentState,
);
const [favoriteFolderPickerChecked] = useRecoilComponentStateV2(
favoriteFolderPickerCheckedComponentState,
);
const filteredFolders = folders.filter((folder) =>
folder.name
.toLowerCase()
.includes(favoriteFoldersSearchFilter.toLowerCase()),
);
const showNoFolderOption =
!favoriteFoldersSearchFilter ||
'no folder'.includes(favoriteFoldersSearchFilter.toLowerCase());
return (
<StyledItemsContainer>
{showNoFolderOption && (
<FavoriteFolderMenuItemMultiSelect
key={`menu-${NO_FOLDER_ID}`}
onSelectChange={() => toggleFolderSelection(NO_FOLDER_ID)}
selected={favoriteFolderPickerChecked.includes(NO_FOLDER_ID)}
text="No folder"
className="no-folder-menu-item-multi-select"
/>
)}
{showNoFolderOption && filteredFolders.length > 0 && (
<StyledDropdownMenuSeparator />
)}
{filteredFolders.length > 0
? filteredFolders.map((folder) => (
<FavoriteFolderMenuItemMultiSelect
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="No folders found" />}
</StyledItemsContainer>
);
};

View File

@ -0,0 +1,31 @@
import { favoriteFolderSearchFilterComponentState } from '@/favorites/favorite-folder-picker/states/favoriteFoldersSearchFilterComponentState';
import { DropdownMenuSearchInput } from '@/ui/layout/dropdown/components/DropdownMenuSearchInput';
import { useRecoilComponentStateV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentStateV2';
import { useCallback } from 'react';
import { useDebouncedCallback } from 'use-debounce';
export const FavoriteFolderPickerSearchInput = () => {
const [favoriteFoldersSearchFilter, setFavoriteFoldersSearchFilter] =
useRecoilComponentStateV2(favoriteFolderSearchFilterComponentState);
const debouncedSetSearchFilter = useDebouncedCallback(
setFavoriteFoldersSearchFilter,
100,
{ leading: true },
);
const handleFilterChange = useCallback(
(event: React.ChangeEvent<HTMLInputElement>) => {
debouncedSetSearchFilter(event.currentTarget.value);
},
[debouncedSetSearchFilter],
);
return (
<DropdownMenuSearchInput
value={favoriteFoldersSearchFilter}
onChange={handleFilterChange}
autoFocus
/>
);
};

View File

@ -0,0 +1,2 @@
export const FAVORITE_FOLDER_PICKER_DROPDOWN_ID =
'favorite-folder-picker-dropdown';

View File

@ -0,0 +1,129 @@
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 { useCreateFavorite } from '@/favorites/hooks/useCreateFavorite';
import { useDeleteFavorite } from '@/favorites/hooks/useDeleteFavorite';
import { useFavorites } from '@/favorites/hooks/useFavorites';
import { FavoriteFolder } from '@/favorites/types/FavoriteFolder';
import { ObjectRecord } from '@/object-record/types/ObjectRecord';
import { useRecoilComponentCallbackStateV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentCallbackStateV2';
import { useRecoilComponentStateV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentStateV2';
import { useRecoilCallback } from 'recoil';
import { isDefined } from 'twenty-ui';
type useFavoriteFolderPickerProps = {
record?: ObjectRecord;
objectNameSingular: string;
};
type FolderOperations = {
getFoldersByIds: () => FavoriteFolder[];
toggleFolderSelection: (folderId: string) => Promise<void>;
};
export const useFavoriteFolderPicker = ({
record,
objectNameSingular,
}: useFavoriteFolderPickerProps): FolderOperations => {
const [favoriteFolderIdsPicker] = useRecoilComponentStateV2(
favoriteFolderIdsPickerComponentState,
);
const favoriteFoldersMultiSelectCheckedState =
useRecoilComponentCallbackStateV2(
favoriteFolderPickerCheckedComponentState,
);
const favoriteFolderPickerFamilyState = useRecoilComponentCallbackStateV2(
favoriteFolderPickerComponentFamilyState,
);
const favorites = useFavorites();
const createFavorite = useCreateFavorite();
const deleteFavorite = useDeleteFavorite();
const getFoldersByIds = useRecoilCallback(
({ snapshot }) =>
(): FavoriteFolder[] => {
return favoriteFolderIdsPicker
.map((folderId) => {
const folderValue = snapshot
.getLoadable(favoriteFolderPickerFamilyState(folderId))
.getValue();
return folderValue;
})
.filter((folder): folder is FavoriteFolder => isDefined(folder));
},
[favoriteFolderIdsPicker, favoriteFolderPickerFamilyState],
);
const toggleFolderSelection = useRecoilCallback(
({ snapshot, set }) =>
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 = snapshot
.getLoadable(favoriteFoldersMultiSelectCheckedState)
.getValue();
const isAlreadyChecked = checkedIds.includes(folderId);
if (isAlreadyChecked) {
await deleteFavoriteForRecord(folderId === 'no-folder');
const newCheckedIds = checkedIds.filter((id) => id !== folderId);
set(favoriteFoldersMultiSelectCheckedState, newCheckedIds);
return;
}
const folderIdToUse = folderId === 'no-folder' ? undefined : folderId;
if (isDefined(record)) {
await createFavorite(record, objectNameSingular, folderIdToUse);
}
const newCheckedIds = [...checkedIds, folderId];
set(favoriteFoldersMultiSelectCheckedState, newCheckedIds);
},
[
favoriteFoldersMultiSelectCheckedState,
createFavorite,
deleteFavorite,
favorites,
record,
objectNameSingular,
],
);
return {
getFoldersByIds,
toggleFolderSelection,
};
};

View File

@ -0,0 +1,20 @@
import { FavoriteFolderPickerInstanceContext } from '@/favorites/favorite-folder-picker/states/context/FavoriteFolderPickerInstanceContext';
import { ReactNode } from 'react';
type FavoriteFolderPickerScopeProps = {
children: ReactNode;
favoriteFoldersScopeId: string;
};
export const FavoriteFolderPickerScope = ({
children,
favoriteFoldersScopeId,
}: FavoriteFolderPickerScopeProps) => {
return (
<FavoriteFolderPickerInstanceContext.Provider
value={{ instanceId: favoriteFoldersScopeId }}
>
{children}
</FavoriteFolderPickerInstanceContext.Provider>
);
};

View File

@ -0,0 +1,4 @@
import { createComponentInstanceContext } from '@/ui/utilities/state/component-state/utils/createComponentInstanceContext';
export const FavoriteFolderPickerInstanceContext =
createComponentInstanceContext();

View File

@ -0,0 +1,10 @@
import { FavoriteFolderPickerInstanceContext } from '@/favorites/favorite-folder-picker/states/context/FavoriteFolderPickerInstanceContext';
import { createComponentStateV2 } from '@/ui/utilities/state/component-state/utils/createComponentStateV2';
export const favoriteFolderIdsPickerComponentState = createComponentStateV2<
string[]
>({
key: 'favoriteFolderIdsPickerComponentState',
defaultValue: [],
componentInstanceContext: FavoriteFolderPickerInstanceContext,
});

View File

@ -0,0 +1,9 @@
import { FavoriteFolderPickerInstanceContext } from '@/favorites/favorite-folder-picker/states/context/FavoriteFolderPickerInstanceContext';
import { createComponentStateV2 } from '@/ui/utilities/state/component-state/utils/createComponentStateV2';
export const favoriteFolderLoadingComponentState =
createComponentStateV2<boolean>({
key: 'favoriteFoldersLoadingComponentState',
defaultValue: false,
componentInstanceContext: FavoriteFolderPickerInstanceContext,
});

View File

@ -0,0 +1,10 @@
import { FavoriteFolderPickerInstanceContext } from '@/favorites/favorite-folder-picker/states/context/FavoriteFolderPickerInstanceContext';
import { createComponentStateV2 } from '@/ui/utilities/state/component-state/utils/createComponentStateV2';
export const favoriteFolderPickerCheckedComponentState = createComponentStateV2<
string[]
>({
key: 'favoriteFolderPickerCheckedComponentState',
defaultValue: [],
componentInstanceContext: FavoriteFolderPickerInstanceContext,
});

View File

@ -0,0 +1,10 @@
import { FavoriteFolderPickerInstanceContext } from '@/favorites/favorite-folder-picker/states/context/FavoriteFolderPickerInstanceContext';
import { FavoriteFolder } from '@/favorites/types/FavoriteFolder';
import { createComponentFamilyStateV2 } from '@/ui/utilities/state/component-state/utils/createComponentFamilyStateV2';
export const favoriteFolderPickerComponentFamilyState =
createComponentFamilyStateV2<FavoriteFolder | undefined, string>({
key: 'favoriteFolderPickerComponentFamilyState',
defaultValue: undefined,
componentInstanceContext: FavoriteFolderPickerInstanceContext,
});

View File

@ -0,0 +1,9 @@
import { FavoriteFolderPickerInstanceContext } from '@/favorites/favorite-folder-picker/states/context/FavoriteFolderPickerInstanceContext';
import { createComponentStateV2 } from '@/ui/utilities/state/component-state/utils/createComponentStateV2';
export const favoriteFolderSearchFilterComponentState =
createComponentStateV2<string>({
key: 'favoriteFolderSearchFilterComponentState',
defaultValue: '',
componentInstanceContext: FavoriteFolderPickerInstanceContext,
});

View File

@ -8,10 +8,12 @@ export const favoriteId = 'f088c8c9-05d2-4276-b065-b863cc7d0b33';
const favoriteTargetObjectId = 'f2d8b9e9-7932-4065-bc09-baf12388b75d'; const favoriteTargetObjectId = 'f2d8b9e9-7932-4065-bc09-baf12388b75d';
export const favoriteTargetObjectRecord = { export const favoriteTargetObjectRecord = {
id: favoriteTargetObjectId, id: favoriteTargetObjectId,
__typename: 'Person',
}; };
export const initialFavorites = [ export const initialFavorites = [
{ {
__typename: 'Favorite',
id: '1', id: '1',
position: 0, position: 0,
key: mockId, key: mockId,
@ -22,8 +24,11 @@ export const initialFavorites = [
recordId: '1', recordId: '1',
person: { id: '1', name: 'John Doe' }, person: { id: '1', name: 'John Doe' },
company: { id: '2', name: 'ABC Corp' }, company: { id: '2', name: 'ABC Corp' },
workspaceMemberId: '1',
favoriteFolderId: undefined,
}, },
{ {
__typename: 'Favorite',
id: '2', id: '2',
position: 1, position: 1,
key: mockId, key: mockId,
@ -34,8 +39,12 @@ export const initialFavorites = [
recordId: '1', recordId: '1',
person: { id: '3', name: 'Jane Doe' }, person: { id: '3', name: 'Jane Doe' },
company: { id: '4', name: 'Company Test' }, company: { id: '4', name: 'Company Test' },
workspaceMemberId: '1',
favoriteFolderId: undefined,
}, },
{ {
__typename: 'Favorite',
id: '3', id: '3',
position: 2, position: 2,
key: mockId, key: mockId,
@ -44,27 +53,37 @@ export const initialFavorites = [
avatarType: 'squared' as AvatarType, avatarType: 'squared' as AvatarType,
link: 'example.com', link: 'example.com',
recordId: '1', recordId: '1',
workspaceMemberId: '1',
favoriteFolderId: undefined,
}, },
]; ];
export const sortedFavorites = [ export const sortedFavorites = [
{ {
id: '1', id: '1',
recordId: '2', recordId: '1',
position: 0, position: 0,
avatarType: 'squared', avatarType: 'rounded',
avatarUrl: undefined, avatarUrl: '',
labelIdentifier: 'ABC Corp', labelIdentifier: ' ',
link: '/object/company/2', link: '/object/person/1',
objectNameSingular: 'person',
workspaceMemberId: '1',
favoriteFolderId: undefined,
__typename: 'Favorite',
}, },
{ {
id: '2', id: '2',
recordId: '4', recordId: '3',
position: 1, position: 1,
avatarType: 'squared', avatarType: 'rounded',
avatarUrl: undefined, avatarUrl: '',
labelIdentifier: 'Company Test', labelIdentifier: ' ',
link: '/object/company/4', link: '/object/person/3',
objectNameSingular: 'person',
workspaceMemberId: '1',
favoriteFolderId: undefined,
__typename: 'Favorite',
}, },
{ {
id: '3', id: '3',
@ -72,9 +91,12 @@ export const sortedFavorites = [
key: '8f3b2121-f194-4ba4-9fbf-2d5a37126806', key: '8f3b2121-f194-4ba4-9fbf-2d5a37126806',
labelIdentifier: 'favoriteLabel', labelIdentifier: 'favoriteLabel',
avatarUrl: 'example.com', avatarUrl: 'example.com',
avatarType: 'squared',
link: 'example.com', link: 'example.com',
recordId: '1', recordId: '1',
avatarType: 'squared',
favoriteFolderId: undefined,
workspaceMemberId: '1',
__typename: 'Favorite',
}, },
]; ];
@ -84,288 +106,301 @@ export const mocks = [
query: gql` query: gql`
mutation CreateOneFavorite($input: FavoriteCreateInput!) { mutation CreateOneFavorite($input: FavoriteCreateInput!) {
createFavorite(data: $input) { createFavorite(data: $input) {
__typename __typename
company { company {
__typename __typename
accountOwnerId accountOwnerId
address { address {
addressStreet1 addressStreet1
addressStreet2 addressStreet2
addressCity addressCity
addressState addressState
addressCountry addressCountry
addressPostcode addressPostcode
addressLat addressLat
addressLng addressLng
} }
annualRecurringRevenue { annualRecurringRevenue {
amountMicros amountMicros
currencyCode currencyCode
} }
createdAt createdAt
createdBy { createdBy {
source source
workspaceMemberId
name
}
deletedAt
domainName {
primaryLinkUrl
primaryLinkLabel
secondaryLinks
}
employees
id
idealCustomerProfile
introVideo {
primaryLinkUrl
primaryLinkLabel
secondaryLinks
}
linkedinLink {
primaryLinkUrl
primaryLinkLabel
secondaryLinks
}
name
position
tagline
updatedAt
visaSponsorship
workPolicy
xLink {
primaryLinkUrl
primaryLinkLabel
secondaryLinks
}
}
companyId
createdAt
deletedAt
favoriteFolder {
__typename
createdAt
deletedAt
id
name
position
updatedAt
}
favoriteFolderId
id
note {
__typename
body
createdAt
createdBy {
source
workspaceMemberId
name
}
deletedAt
id
position
title
updatedAt
}
noteId
opportunity {
__typename
amount {
amountMicros
currencyCode
}
closeDate
companyId
createdAt
createdBy {
source
workspaceMemberId
name
}
deletedAt
id
name
pointOfContactId
position
stage
updatedAt
}
opportunityId
person {
__typename
avatarUrl
city
companyId
createdAt
createdBy {
source
workspaceMemberId
name
}
deletedAt
emails {
primaryEmail
additionalEmails
}
id
intro
jobTitle
linkedinLink {
primaryLinkUrl
primaryLinkLabel
secondaryLinks
}
name {
firstName
lastName
}
performanceRating
phones {
primaryPhoneNumber
primaryPhoneCountryCode
additionalPhones
}
position
updatedAt
whatsapp {
primaryPhoneNumber
primaryPhoneCountryCode
additionalPhones
}
workPreference
xLink {
primaryLinkUrl
primaryLinkLabel
secondaryLinks
}
}
personId
position
rocket {
__typename
createdAt
createdBy {
source
workspaceMemberId
name
}
deletedAt
id
name
position
updatedAt
}
rocketId
task {
__typename
assigneeId
body
createdAt
createdBy {
source
workspaceMemberId
name
}
deletedAt
dueAt
id
position
status
title
updatedAt
}
taskId
updatedAt
view {
__typename
createdAt
deletedAt
icon
id
isCompact
kanbanFieldMetadataId
key
name
objectMetadataId
position
type
updatedAt
}
viewId
workflow {
__typename
createdAt
deletedAt
id
lastPublishedVersionId
name
position
statuses
updatedAt
}
workflowId
workflowRun {
__typename
createdAt
createdBy {
source
workspaceMemberId
name
}
deletedAt
endedAt
id
name
output
position
startedAt
status
updatedAt
workflowId
workflowVersionId
}
workflowRunId
workflowVersion {
__typename
createdAt
deletedAt
id
name
position
status
steps
trigger
updatedAt
workflowId
}
workflowVersionId
workspaceMember {
__typename
avatarUrl
colorScheme
createdAt
dateFormat
deletedAt
id
locale
name {
firstName
lastName
}
timeFormat
timeZone
updatedAt
userEmail
userId
}
workspaceMemberId workspaceMemberId
name
}
deletedAt
domainName {
primaryLinkUrl
primaryLinkLabel
secondaryLinks
}
employees
id
idealCustomerProfile
introVideo {
primaryLinkUrl
primaryLinkLabel
secondaryLinks
}
linkedinLink {
primaryLinkUrl
primaryLinkLabel
secondaryLinks
}
name
position
tagline
updatedAt
visaSponsorship
workPolicy
xLink {
primaryLinkUrl
primaryLinkLabel
secondaryLinks
} }
} }
companyId
createdAt
deletedAt
id
note {
__typename
body
createdAt
createdBy {
source
workspaceMemberId
name
}
deletedAt
id
position
title
updatedAt
}
noteId
opportunity {
__typename
amount {
amountMicros
currencyCode
}
closeDate
companyId
createdAt
createdBy {
source
workspaceMemberId
name
}
deletedAt
id
name
pointOfContactId
position
stage
updatedAt
}
opportunityId
person {
__typename
avatarUrl
city
companyId
createdAt
createdBy {
source
workspaceMemberId
name
}
deletedAt
emails {
primaryEmail
additionalEmails
}
id
intro
jobTitle
linkedinLink {
primaryLinkUrl
primaryLinkLabel
secondaryLinks
}
name {
firstName
lastName
}
performanceRating
phones {
primaryPhoneNumber
primaryPhoneCountryCode
additionalPhones
}
position
updatedAt
whatsapp {
primaryPhoneNumber
primaryPhoneCountryCode
additionalPhones
}
workPreference
xLink {
primaryLinkUrl
primaryLinkLabel
secondaryLinks
}
}
personId
position
rocket {
__typename
createdAt
createdBy {
source
workspaceMemberId
name
}
deletedAt
id
name
position
updatedAt
}
rocketId
task {
__typename
assigneeId
body
createdAt
createdBy {
source
workspaceMemberId
name
}
deletedAt
dueAt
id
position
status
title
updatedAt
}
taskId
updatedAt
view {
__typename
createdAt
deletedAt
icon
id
isCompact
kanbanFieldMetadataId
key
name
objectMetadataId
position
type
updatedAt
}
viewId
workflow {
__typename
createdAt
deletedAt
id
lastPublishedVersionId
name
position
statuses
updatedAt
}
workflowId
workflowRun {
__typename
createdAt
createdBy {
source
workspaceMemberId
name
}
deletedAt
endedAt
id
name
output
position
startedAt
status
updatedAt
workflowId
workflowVersionId
}
workflowRunId
workflowVersion {
__typename
createdAt
deletedAt
id
name
position
status
steps
trigger
updatedAt
workflowId
}
workflowVersionId
workspaceMember {
__typename
avatarUrl
colorScheme
createdAt
dateFormat
deletedAt
id
locale
name {
firstName
lastName
}
timeFormat
timeZone
updatedAt
userEmail
userId
}
workspaceMemberId
}
}
`, `,
variables: { variables: {
input: { input: {
id: mockId,
personId: favoriteTargetObjectId, personId: favoriteTargetObjectId,
position: 4, position: 3,
workspaceMemberId: '1', workspaceMemberId: '1',
favoriteFolderId: undefined,
id: mockId,
}, },
}, },
}, },
result: jest.fn(() => ({ result: jest.fn(() => ({
data: { data: {
createFavorite: { createFavorite: {
__typename: 'Favorite',
id: favoriteId, id: favoriteId,
position: 1,
}, },
}, },
})), })),
@ -386,7 +421,9 @@ export const mocks = [
result: jest.fn(() => ({ result: jest.fn(() => ({
data: { data: {
deleteFavorite: { deleteFavorite: {
__typename: 'Favorite',
id: favoriteId, id: favoriteId,
deletedAt: new Date().toISOString(),
}, },
}, },
})), })),
@ -457,6 +494,16 @@ export const mocks = [
companyId companyId
createdAt createdAt
deletedAt deletedAt
favoriteFolder {
__typename
createdAt
deletedAt
id
name
position
updatedAt
}
favoriteFolderId
id id
note { note {
__typename __typename
@ -678,7 +725,9 @@ export const mocks = [
result: jest.fn(() => ({ result: jest.fn(() => ({
data: { data: {
updateFavorite: { updateFavorite: {
__typename: 'Favorite',
id: favoriteId, id: favoriteId,
position: 2,
}, },
}, },
})), })),

View File

@ -0,0 +1,53 @@
import { renderHook, waitFor } from '@testing-library/react';
import { useSetRecoilState } from 'recoil';
import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMemberState';
import { useCreateFavorite } from '@/favorites/hooks/useCreateFavorite';
import { objectMetadataItemsState } from '@/object-metadata/states/objectMetadataItemsState';
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
import { getJestMetadataAndApolloMocksWrapper } from '~/testing/jest/getJestMetadataAndApolloMocksWrapper';
import { generatedMockObjectMetadataItems } from '~/testing/mock-data/generatedMockObjectMetadataItems';
import {
favoriteTargetObjectRecord,
initialFavorites,
mockId,
mockWorkspaceMember,
mocks,
} from '../__mocks__/useFavorites';
jest.mock('uuid', () => ({
v4: () => mockId,
}));
jest.mock('@/object-record/hooks/useFindManyRecords', () => ({
useFindManyRecords: () => ({ records: initialFavorites }),
}));
const Wrapper = getJestMetadataAndApolloMocksWrapper({
apolloMocks: mocks,
});
describe('useCreateFavorite', () => {
it('should create favorite successfully', async () => {
const { result } = renderHook(
() => {
const setCurrentWorkspaceMember = useSetRecoilState(
currentWorkspaceMemberState,
);
setCurrentWorkspaceMember(mockWorkspaceMember);
const setMetadataItems = useSetRecoilState(objectMetadataItemsState);
setMetadataItems(generatedMockObjectMetadataItems);
return useCreateFavorite();
},
{ wrapper: Wrapper },
);
result.current(favoriteTargetObjectRecord, CoreObjectNameSingular.Person);
await waitFor(() => {
expect(mocks[0].result).toHaveBeenCalled();
});
});
});

View File

@ -0,0 +1,47 @@
import { renderHook, waitFor } from '@testing-library/react';
import { useSetRecoilState } from 'recoil';
import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMemberState';
import { useDeleteFavorite } from '@/favorites/hooks/useDeleteFavorite';
import { objectMetadataItemsState } from '@/object-metadata/states/objectMetadataItemsState';
import { getJestMetadataAndApolloMocksWrapper } from '~/testing/jest/getJestMetadataAndApolloMocksWrapper';
import { generatedMockObjectMetadataItems } from '~/testing/mock-data/generatedMockObjectMetadataItems';
import {
favoriteId,
initialFavorites,
mockWorkspaceMember,
mocks,
} from '../__mocks__/useFavorites';
jest.mock('@/object-record/hooks/useFindManyRecords', () => ({
useFindManyRecords: () => ({ records: initialFavorites }),
}));
const Wrapper = getJestMetadataAndApolloMocksWrapper({
apolloMocks: mocks,
});
describe('useDeleteFavorite', () => {
it('should delete favorite successfully', async () => {
const { result } = renderHook(
() => {
const setCurrentWorkspaceMember = useSetRecoilState(
currentWorkspaceMemberState,
);
setCurrentWorkspaceMember(mockWorkspaceMember);
const setMetadataItems = useSetRecoilState(objectMetadataItemsState);
setMetadataItems(generatedMockObjectMetadataItems);
return useDeleteFavorite();
},
{ wrapper: Wrapper },
);
result.current(favoriteId);
await waitFor(() => {
expect(mocks[1].result).toHaveBeenCalled();
});
});
});

View File

@ -1,29 +1,18 @@
import { DropResult, ResponderProvided } from '@hello-pangea/dnd'; import { renderHook } from '@testing-library/react';
import { renderHook, waitFor } from '@testing-library/react';
import { useSetRecoilState } from 'recoil'; import { useSetRecoilState } from 'recoil';
import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMemberState'; import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMemberState';
import { useFavorites } from '@/favorites/hooks/useFavorites'; import { useFavorites } from '@/favorites/hooks/useFavorites';
import { objectMetadataItemsState } from '@/object-metadata/states/objectMetadataItemsState'; import { objectMetadataItemsState } from '@/object-metadata/states/objectMetadataItemsState';
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
import { act } from 'react';
import { getJestMetadataAndApolloMocksWrapper } from '~/testing/jest/getJestMetadataAndApolloMocksWrapper'; import { getJestMetadataAndApolloMocksWrapper } from '~/testing/jest/getJestMetadataAndApolloMocksWrapper';
import { generatedMockObjectMetadataItems } from '~/testing/mock-data/generatedMockObjectMetadataItems'; import { generatedMockObjectMetadataItems } from '~/testing/mock-data/generatedMockObjectMetadataItems';
import { import {
favoriteId,
favoriteTargetObjectRecord,
initialFavorites, initialFavorites,
mockId,
mocks, mocks,
mockWorkspaceMember, mockWorkspaceMember,
sortedFavorites, sortedFavorites,
} from '../__mocks__/useFavorites'; } from '../__mocks__/useFavorites';
jest.mock('uuid', () => ({
v4: jest.fn(() => mockId),
}));
jest.mock('@/object-record/hooks/useFindManyRecords', () => ({ jest.mock('@/object-record/hooks/useFindManyRecords', () => ({
useFindManyRecords: () => ({ records: initialFavorites }), useFindManyRecords: () => ({ records: initialFavorites }),
})); }));
@ -33,7 +22,7 @@ const Wrapper = getJestMetadataAndApolloMocksWrapper({
}); });
describe('useFavorites', () => { describe('useFavorites', () => {
it('should fetch favorites successfully', async () => { it('should fetch and sort favorites successfully', () => {
const { result } = renderHook( const { result } = renderHook(
() => { () => {
const setCurrentWorkspaceMember = useSetRecoilState( const setCurrentWorkspaceMember = useSetRecoilState(
@ -46,108 +35,9 @@ describe('useFavorites', () => {
return useFavorites(); return useFavorites();
}, },
{ { wrapper: Wrapper },
wrapper: Wrapper,
},
); );
expect(result.current.favorites).toEqual(sortedFavorites); expect(result.current).toEqual(sortedFavorites);
});
it('should createOneFavorite successfully', async () => {
const { result } = renderHook(
() => {
const setCurrentWorkspaceMember = useSetRecoilState(
currentWorkspaceMemberState,
);
setCurrentWorkspaceMember(mockWorkspaceMember);
const setMetadataItems = useSetRecoilState(objectMetadataItemsState);
setMetadataItems(generatedMockObjectMetadataItems);
return useFavorites();
},
{
wrapper: Wrapper,
},
);
result.current.createFavorite(
favoriteTargetObjectRecord,
CoreObjectNameSingular.Person,
);
await waitFor(() => {
expect(mocks[0].result).toHaveBeenCalled();
});
});
it('should deleteOneRecord successfully', async () => {
const { result } = renderHook(
() => {
const setCurrentWorkspaceMember = useSetRecoilState(
currentWorkspaceMemberState,
);
setCurrentWorkspaceMember(mockWorkspaceMember);
const setMetadataItems = useSetRecoilState(objectMetadataItemsState);
setMetadataItems(generatedMockObjectMetadataItems);
return useFavorites();
},
{
wrapper: Wrapper,
},
);
result.current.deleteFavorite(favoriteId);
await waitFor(() => {
expect(mocks[1].result).toHaveBeenCalled();
});
});
it('should handle reordering favorites successfully', async () => {
const { result } = renderHook(
() => {
const setCurrentWorkspaceMember = useSetRecoilState(
currentWorkspaceMemberState,
);
setCurrentWorkspaceMember(mockWorkspaceMember);
const setMetadataItems = useSetRecoilState(objectMetadataItemsState);
setMetadataItems(generatedMockObjectMetadataItems);
return useFavorites();
},
{
wrapper: Wrapper,
},
);
act(() => {
const dragAndDropResult: DropResult = {
source: { index: 0, droppableId: 'droppableId' },
destination: { index: 2, droppableId: 'droppableId' },
combine: null,
mode: 'FLUID',
draggableId: 'draggableId',
type: 'type',
reason: 'DROP',
};
const responderProvided: ResponderProvided = {
announce: () => {},
};
result.current.handleReorderFavorite(
dragAndDropResult,
responderProvided,
);
});
await waitFor(() => {
expect(mocks[2].result).toHaveBeenCalled();
});
}); });
}); });

View File

@ -0,0 +1,64 @@
import { DropResult, ResponderProvided } from '@hello-pangea/dnd';
import { renderHook, waitFor } from '@testing-library/react';
import { act } from 'react';
import { useSetRecoilState } from 'recoil';
import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMemberState';
import { useReorderFavorite } from '@/favorites/hooks/useReorderFavorite';
import { objectMetadataItemsState } from '@/object-metadata/states/objectMetadataItemsState';
import { getJestMetadataAndApolloMocksWrapper } from '~/testing/jest/getJestMetadataAndApolloMocksWrapper';
import { generatedMockObjectMetadataItems } from '~/testing/mock-data/generatedMockObjectMetadataItems';
import {
initialFavorites,
mockWorkspaceMember,
mocks,
} from '../__mocks__/useFavorites';
jest.mock('@/object-record/hooks/useFindManyRecords', () => ({
useFindManyRecords: () => ({ records: initialFavorites }),
}));
const Wrapper = getJestMetadataAndApolloMocksWrapper({
apolloMocks: mocks,
});
describe('useReorderFavorite', () => {
it('should handle reordering favorites successfully', async () => {
const { result } = renderHook(
() => {
const setCurrentWorkspaceMember = useSetRecoilState(
currentWorkspaceMemberState,
);
setCurrentWorkspaceMember(mockWorkspaceMember);
const setMetadataItems = useSetRecoilState(objectMetadataItemsState);
setMetadataItems(generatedMockObjectMetadataItems);
return useReorderFavorite();
},
{ wrapper: Wrapper },
);
act(() => {
const dragAndDropResult: DropResult = {
source: { index: 0, droppableId: 'droppableId' },
destination: { index: 2, droppableId: 'droppableId' },
combine: null,
mode: 'FLUID',
draggableId: '1',
type: 'type',
reason: 'DROP',
};
const responderProvided: ResponderProvided = {
announce: () => {},
};
result.current(dragAndDropResult, responderProvided);
});
await waitFor(() => {
expect(mocks[2].result).toHaveBeenCalled();
});
});
});

View File

@ -0,0 +1,38 @@
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
import { useCreateOneRecord } from '@/object-record/hooks/useCreateOneRecord';
import { ObjectRecord } from '@/object-record/types/ObjectRecord';
import { usePrefetchedFavoritesData } from './usePrefetchedFavoritesData';
export const useCreateFavorite = () => {
const { favorites, currentWorkspaceMemberId } = usePrefetchedFavoritesData();
const { createOneRecord: createOneFavorite } = useCreateOneRecord({
objectNameSingular: CoreObjectNameSingular.Favorite,
});
const createFavorite = (
targetRecord: ObjectRecord,
targetObjectNameSingular: string,
favoriteFolderId?: string,
) => {
const relevantFavorites = favoriteFolderId
? favorites.filter((fav) => fav.favoriteFolderId === favoriteFolderId)
: favorites.filter(
(fav) => !fav.favoriteFolderId && fav.workspaceMemberId,
);
const maxPosition = Math.max(
...relevantFavorites.map((fav) => fav.position),
0,
);
createOneFavorite({
[targetObjectNameSingular]: targetRecord,
[`${targetObjectNameSingular}Id`]: targetRecord.id,
position: maxPosition + 1,
workspaceMemberId: currentWorkspaceMemberId,
favoriteFolderId,
});
};
return createFavorite;
};

View File

@ -0,0 +1,32 @@
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
import { useCreateOneRecord } from '@/object-record/hooks/useCreateOneRecord';
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 createNewFavoriteFolder = async (name: string): Promise<void> => {
if (!name || !currentWorkspaceMemberId) {
return;
}
const maxPosition = Math.max(
...favoriteFolders.map((folder) => folder.position),
0,
);
await createFavoriteFolder({
workspaceMemberId: currentWorkspaceMemberId,
name,
position: maxPosition + 1,
});
};
return createNewFavoriteFolder;
};

View File

@ -0,0 +1,14 @@
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
import { useDeleteOneRecord } from '@/object-record/hooks/useDeleteOneRecord';
export const useDeleteFavorite = () => {
const { deleteOneRecord } = useDeleteOneRecord({
objectNameSingular: CoreObjectNameSingular.Favorite,
});
const deleteFavorite = (favoriteId: string) => {
deleteOneRecord(favoriteId);
};
return deleteFavorite;
};

View File

@ -0,0 +1,26 @@
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
import { useDeleteOneRecord } from '@/object-record/hooks/useDeleteOneRecord';
import { usePrefetchedFavoritesData } from './usePrefetchedFavoritesData';
export const useDeleteFavoriteFolder = () => {
const { deleteOneRecord } = useDeleteOneRecord({
objectNameSingular: CoreObjectNameSingular.FavoriteFolder,
});
const { upsertFavorites, favorites, workspaceFavorites } =
usePrefetchedFavoritesData();
const deleteFavoriteFolder = async (folderId: string): Promise<void> => {
await deleteOneRecord(folderId);
const updatedFavorites = [
...favorites.filter((favorite) => favorite.favoriteFolderId !== folderId),
...workspaceFavorites,
];
upsertFavorites(updatedFavorites);
};
return {
deleteFavoriteFolder,
};
};

View File

@ -1,163 +1,56 @@
import { OnDragEndResponder } from '@hello-pangea/dnd';
import { useMemo } from 'react';
import { useRecoilValue } from 'recoil';
import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMemberState';
import { Favorite } from '@/favorites/types/Favorite';
import { sortFavorites } from '@/favorites/utils/sortFavorites'; import { sortFavorites } from '@/favorites/utils/sortFavorites';
import { useGetObjectRecordIdentifierByNameSingular } from '@/object-metadata/hooks/useGetObjectRecordIdentifierByNameSingular'; import { useGetObjectRecordIdentifierByNameSingular } from '@/object-metadata/hooks/useGetObjectRecordIdentifierByNameSingular';
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem'; import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
import { objectMetadataItemsState } from '@/object-metadata/states/objectMetadataItemsState';
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular'; import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
import { useCreateOneRecord } from '@/object-record/hooks/useCreateOneRecord';
import { useDeleteOneRecord } from '@/object-record/hooks/useDeleteOneRecord';
import { useUpdateOneRecord } from '@/object-record/hooks/useUpdateOneRecord';
import { usePrefetchedData } from '@/prefetch/hooks/usePrefetchedData'; import { usePrefetchedData } from '@/prefetch/hooks/usePrefetchedData';
import { PrefetchKey } from '@/prefetch/types/PrefetchKey'; import { PrefetchKey } from '@/prefetch/types/PrefetchKey';
import { View } from '@/views/types/View';
import { useMemo } from 'react';
import { useRecoilValue } from 'recoil';
import { FieldMetadataType } from '~/generated-metadata/graphql'; import { FieldMetadataType } from '~/generated-metadata/graphql';
import { usePrefetchedFavoritesData } from './usePrefetchedFavoritesData';
export const useFavorites = () => { export const useFavorites = () => {
const currentWorkspaceMember = useRecoilValue(currentWorkspaceMemberState); const { favorites } = usePrefetchedFavoritesData();
const { records: views } = usePrefetchedData<View>(PrefetchKey.AllViews);
const objectMetadataItems = useRecoilValue(objectMetadataItemsState);
const { objectMetadataItem: favoriteObjectMetadataItem } = const { objectMetadataItem: favoriteObjectMetadataItem } =
useObjectMetadataItem({ useObjectMetadataItem({
objectNameSingular: CoreObjectNameSingular.Favorite, objectNameSingular: CoreObjectNameSingular.Favorite,
}); });
const getObjectRecordIdentifierByNameSingular =
const { deleteOneRecord } = useDeleteOneRecord({ useGetObjectRecordIdentifierByNameSingular();
objectNameSingular: CoreObjectNameSingular.Favorite,
});
const { updateOneRecord: updateOneFavorite } = useUpdateOneRecord({
objectNameSingular: CoreObjectNameSingular.Favorite,
});
const { createOneRecord: createOneFavorite } = useCreateOneRecord({
objectNameSingular: CoreObjectNameSingular.Favorite,
});
const { records: favorites } = usePrefetchedData<Favorite>(
PrefetchKey.AllFavorites,
{
workspaceMemberId: {
eq: currentWorkspaceMember?.id ?? '',
},
},
);
const { records: workspaceFavorites } = usePrefetchedData<Favorite>(
PrefetchKey.AllFavorites,
{
workspaceMemberId: {
eq: undefined,
},
},
);
const favoriteRelationFieldMetadataItems = useMemo( const favoriteRelationFieldMetadataItems = useMemo(
() => () =>
favoriteObjectMetadataItem.fields.filter( favoriteObjectMetadataItem.fields.filter(
(fieldMetadataItem) => (fieldMetadataItem) =>
fieldMetadataItem.type === FieldMetadataType.Relation && fieldMetadataItem.type === FieldMetadataType.Relation &&
fieldMetadataItem.name !== 'workspaceMember', fieldMetadataItem.name !== 'workspaceMember' &&
fieldMetadataItem.name !== 'favoriteFolder',
), ),
[favoriteObjectMetadataItem.fields], [favoriteObjectMetadataItem.fields],
); );
const getObjectRecordIdentifierByNameSingular = const sortedFavorites = useMemo(
useGetObjectRecordIdentifierByNameSingular(); () =>
sortFavorites(
const favoritesSorted = useMemo(() => { favorites,
return sortFavorites( favoriteRelationFieldMetadataItems,
getObjectRecordIdentifierByNameSingular,
true,
views,
objectMetadataItems,
),
[
favorites, favorites,
favoriteRelationFieldMetadataItems, favoriteRelationFieldMetadataItems,
getObjectRecordIdentifierByNameSingular, getObjectRecordIdentifierByNameSingular,
true, views,
); objectMetadataItems,
}, [ ],
favoriteRelationFieldMetadataItems, );
favorites,
getObjectRecordIdentifierByNameSingular,
]);
const workspaceFavoritesSorted = useMemo(() => { return sortedFavorites;
return sortFavorites(
workspaceFavorites.filter((favorite) => favorite.viewId),
favoriteRelationFieldMetadataItems,
getObjectRecordIdentifierByNameSingular,
false,
);
}, [
favoriteRelationFieldMetadataItems,
getObjectRecordIdentifierByNameSingular,
workspaceFavorites,
]);
const createFavorite = (
targetRecord: Record<string, any>,
targetObjectNameSingular: string,
) => {
createOneFavorite({
[targetObjectNameSingular]: targetRecord,
[`${targetObjectNameSingular}Id`]: targetRecord.id,
position: favorites.length + 1,
workspaceMemberId: currentWorkspaceMember?.id,
});
};
const deleteFavorite = (favoriteId: string) => {
deleteOneRecord(favoriteId);
};
const computeNewPosition = (destIndex: number, sourceIndex: number) => {
const moveToFirstPosition = destIndex === 0;
const moveToLastPosition = destIndex === favoritesSorted.length - 1;
const moveAfterSource = destIndex > sourceIndex;
if (moveToFirstPosition) {
return favoritesSorted[0].position / 2;
} else if (moveToLastPosition) {
return favoritesSorted[destIndex - 1].position + 1;
} else if (moveAfterSource) {
return (
(favoritesSorted[destIndex + 1].position +
favoritesSorted[destIndex].position) /
2
);
} else {
return (
favoritesSorted[destIndex].position -
(favoritesSorted[destIndex].position -
favoritesSorted[destIndex - 1].position) /
2
);
}
};
const handleReorderFavorite: OnDragEndResponder = (result) => {
if (!result.destination || !favoritesSorted) {
return;
}
const newPosition = computeNewPosition(
result.destination.index,
result.source.index,
);
const updatedFavorite = favoritesSorted[result.source.index];
updateOneFavorite({
idToUpdate: updatedFavorite.id,
updateOneRecordInput: {
position: newPosition,
},
});
};
return {
favorites: favoritesSorted,
workspaceFavorites: workspaceFavoritesSorted,
createFavorite,
handleReorderFavorite,
deleteFavorite,
};
}; };

View File

@ -0,0 +1,62 @@
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 '@/object-metadata/types/CoreObjectNameSingular';
import { usePrefetchedData } from '@/prefetch/hooks/usePrefetchedData';
import { PrefetchKey } from '@/prefetch/types/PrefetchKey';
import { View } from '@/views/types/View';
import { useMemo } from 'react';
import { useRecoilValue } from 'recoil';
import { FieldMetadataType } from '~/generated-metadata/graphql';
import { usePrefetchedFavoritesData } from './usePrefetchedFavoritesData';
import { usePrefetchedFavoritesFoldersData } from './usePrefetchedFavoritesFoldersData';
export const useFavoritesByFolder = () => {
const { favorites } = usePrefetchedFavoritesData();
const { favoriteFolders } = usePrefetchedFavoritesFoldersData();
const { records: views } = usePrefetchedData<View>(PrefetchKey.AllViews);
const objectMetadataItems = useRecoilValue(objectMetadataItemsState);
const getObjectRecordIdentifierByNameSingular =
useGetObjectRecordIdentifierByNameSingular();
const { objectMetadataItem: favoriteObjectMetadataItem } =
useObjectMetadataItem({
objectNameSingular: CoreObjectNameSingular.Favorite,
});
const favoriteRelationFields = useMemo(
() =>
favoriteObjectMetadataItem.fields.filter(
(fieldMetadataItem) =>
fieldMetadataItem.type === FieldMetadataType.Relation &&
fieldMetadataItem.name !== 'workspaceMember' &&
fieldMetadataItem.name !== 'favoriteFolder',
),
[favoriteObjectMetadataItem.fields],
);
const favoritesByFolder = useMemo(() => {
return favoriteFolders.map((folder) => ({
folderId: folder.id,
folderName: folder.name,
favorites: sortFavorites(
favorites.filter((favorite) => favorite.favoriteFolderId === folder.id),
favoriteRelationFields,
getObjectRecordIdentifierByNameSingular,
true,
views,
objectMetadataItems,
),
}));
}, [
favoriteFolders,
favorites,
favoriteRelationFields,
getObjectRecordIdentifierByNameSingular,
views,
objectMetadataItems,
]);
return favoritesByFolder;
};

View File

@ -0,0 +1,46 @@
import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMemberState';
import { Favorite } from '@/favorites/types/Favorite';
import { usePrefetchRunQuery } from '@/prefetch/hooks/internal/usePrefetchRunQuery';
import { usePrefetchedData } from '@/prefetch/hooks/usePrefetchedData';
import { PrefetchKey } from '@/prefetch/types/PrefetchKey';
import { useRecoilValue } from 'recoil';
type PrefetchedFavoritesData = {
favorites: Favorite[];
workspaceFavorites: Favorite[];
upsertFavorites: (records: Favorite[]) => void;
currentWorkspaceMemberId: string | undefined;
};
export const usePrefetchedFavoritesData = (): PrefetchedFavoritesData => {
const currentWorkspaceMember = useRecoilValue(currentWorkspaceMemberState);
const currentWorkspaceMemberId = currentWorkspaceMember?.id;
const { records: _favorites } = usePrefetchedData<Favorite>(
PrefetchKey.AllFavorites,
{
workspaceMemberId: {
eq: currentWorkspaceMemberId,
},
},
);
const favorites = _favorites.filter(
(favorite) => favorite.workspaceMemberId === currentWorkspaceMemberId,
);
const workspaceFavorites = _favorites.filter(
(favorite) => favorite.workspaceMemberId === null,
);
const { upsertRecordsInCache: upsertFavorites } =
usePrefetchRunQuery<Favorite>({
prefetchKey: PrefetchKey.AllFavorites,
});
return {
favorites,
workspaceFavorites,
upsertFavorites,
currentWorkspaceMemberId,
};
};

View File

@ -0,0 +1,38 @@
import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMemberState';
import { FavoriteFolder } from '@/favorites/types/FavoriteFolder';
import { usePrefetchRunQuery } from '@/prefetch/hooks/internal/usePrefetchRunQuery';
import { usePrefetchedData } from '@/prefetch/hooks/usePrefetchedData';
import { PrefetchKey } from '@/prefetch/types/PrefetchKey';
import { useRecoilValue } from 'recoil';
type PrefetchedFavoritesFoldersData = {
favoriteFolders: FavoriteFolder[];
upsertFavoriteFolders: (records: FavoriteFolder[]) => void;
currentWorkspaceMemberId: string | undefined;
};
export const usePrefetchedFavoritesFoldersData =
(): PrefetchedFavoritesFoldersData => {
const currentWorkspaceMember = useRecoilValue(currentWorkspaceMemberState);
const currentWorkspaceMemberId = currentWorkspaceMember?.id;
const { records: favoriteFolders } = usePrefetchedData<FavoriteFolder>(
PrefetchKey.AllFavoritesFolders,
{
workspaceMemberId: {
eq: currentWorkspaceMemberId,
},
},
);
const { upsertRecordsInCache: upsertFavoriteFolders } =
usePrefetchRunQuery<FavoriteFolder>({
prefetchKey: PrefetchKey.AllFavoritesFolders,
});
return {
favoriteFolders,
upsertFavoriteFolders,
currentWorkspaceMemberId,
};
};

View File

@ -0,0 +1,28 @@
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
import { useUpdateOneRecord } from '@/object-record/hooks/useUpdateOneRecord';
export const useRenameFavoriteFolder = () => {
const { updateOneRecord: updateFavoriteFolder } = useUpdateOneRecord({
objectNameSingular: CoreObjectNameSingular.FavoriteFolder,
});
const renameFavoriteFolder = async (
folderId: string,
newName: string,
): Promise<void> => {
if (!newName) {
return;
}
await updateFavoriteFolder({
idToUpdate: folderId,
updateOneRecordInput: {
name: newName,
},
});
};
return {
renameFavoriteFolder,
};
};

View File

@ -0,0 +1,41 @@
import { useSortedFavorites } from '@/favorites/hooks/useSortedFavorites';
import { calculateNewPosition } from '@/favorites/utils/calculateNewPosition';
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
import { useUpdateOneRecord } from '@/object-record/hooks/useUpdateOneRecord';
import { OnDragEndResponder } from '@hello-pangea/dnd';
import { usePrefetchedFavoritesData } from './usePrefetchedFavoritesData';
export const useReorderFavorite = () => {
const { favorites } = usePrefetchedFavoritesData();
const { favoritesSorted } = useSortedFavorites();
const { updateOneRecord: updateOneFavorite } = useUpdateOneRecord({
objectNameSingular: CoreObjectNameSingular.Favorite,
});
const reorderFavorite: OnDragEndResponder = (result) => {
if (!result.destination) return;
const draggedFavoriteId = result.draggableId;
const draggedFavorite = favorites.find((f) => f.id === draggedFavoriteId);
if (!draggedFavorite) return;
const inSameFolderFavorites = favoritesSorted.filter(
(fav) => fav.favoriteFolderId === draggedFavorite.favoriteFolderId,
);
if (!inSameFolderFavorites.length) return;
const newPosition = calculateNewPosition({
destinationIndex: result.destination.index,
sourceIndex: result.source.index,
items: inSameFolderFavorites,
});
updateOneFavorite({
idToUpdate: draggedFavoriteId,
updateOneRecordInput: { position: newPosition },
});
};
return reorderFavorite;
};

View File

@ -0,0 +1,75 @@
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 '@/object-metadata/types/CoreObjectNameSingular';
import { usePrefetchedData } from '@/prefetch/hooks/usePrefetchedData';
import { PrefetchKey } from '@/prefetch/types/PrefetchKey';
import { View } from '@/views/types/View';
import { useMemo } from 'react';
import { useRecoilValue } from 'recoil';
import { FieldMetadataType } from '~/generated-metadata/graphql';
import { usePrefetchedFavoritesData } from './usePrefetchedFavoritesData';
export const useSortedFavorites = () => {
const { favorites, workspaceFavorites } = usePrefetchedFavoritesData();
const { records: views } = usePrefetchedData<View>(PrefetchKey.AllViews);
const objectMetadataItems = useRecoilValue(objectMetadataItemsState);
const { objectMetadataItem: favoriteObjectMetadataItem } =
useObjectMetadataItem({
objectNameSingular: CoreObjectNameSingular.Favorite,
});
const getObjectRecordIdentifierByNameSingular =
useGetObjectRecordIdentifierByNameSingular();
const favoriteRelationFieldMetadataItems = useMemo(
() =>
favoriteObjectMetadataItem.fields.filter(
(fieldMetadataItem) =>
fieldMetadataItem.type === FieldMetadataType.Relation &&
fieldMetadataItem.name !== 'workspaceMember' &&
fieldMetadataItem.name !== 'favoriteFolder',
),
[favoriteObjectMetadataItem.fields],
);
const favoritesSorted = useMemo(() => {
return sortFavorites(
favorites,
favoriteRelationFieldMetadataItems,
getObjectRecordIdentifierByNameSingular,
true,
views,
objectMetadataItems,
);
}, [
favoriteRelationFieldMetadataItems,
favorites,
getObjectRecordIdentifierByNameSingular,
views,
objectMetadataItems,
]);
const workspaceFavoritesSorted = useMemo(() => {
return sortFavorites(
workspaceFavorites.filter((favorite) => favorite.viewId),
favoriteRelationFieldMetadataItems,
getObjectRecordIdentifierByNameSingular,
false,
views,
objectMetadataItems,
);
}, [
favoriteRelationFieldMetadataItems,
getObjectRecordIdentifierByNameSingular,
workspaceFavorites,
views,
objectMetadataItems,
]);
return {
favoritesSorted,
workspaceFavoritesSorted,
};
};

View File

@ -0,0 +1,56 @@
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 '@/object-metadata/types/CoreObjectNameSingular';
import { usePrefetchedData } from '@/prefetch/hooks/usePrefetchedData';
import { PrefetchKey } from '@/prefetch/types/PrefetchKey';
import { View } from '@/views/types/View';
import { useMemo } from 'react';
import { useRecoilValue } from 'recoil';
import { FieldMetadataType } from '~/generated-metadata/graphql';
import { usePrefetchedFavoritesData } from './usePrefetchedFavoritesData';
export const useWorkspaceFavorites = () => {
const { workspaceFavorites } = usePrefetchedFavoritesData();
const { records: views } = usePrefetchedData<View>(PrefetchKey.AllViews);
const objectMetadataItems = useRecoilValue(objectMetadataItemsState);
const { objectMetadataItem: favoriteObjectMetadataItem } =
useObjectMetadataItem({
objectNameSingular: CoreObjectNameSingular.Favorite,
});
const getObjectRecordIdentifierByNameSingular =
useGetObjectRecordIdentifierByNameSingular();
const favoriteRelationFieldMetadataItems = useMemo(
() =>
favoriteObjectMetadataItem.fields.filter(
(fieldMetadataItem) =>
fieldMetadataItem.type === FieldMetadataType.Relation &&
fieldMetadataItem.name !== 'workspaceMember' &&
fieldMetadataItem.name !== 'favoriteFolder',
),
[favoriteObjectMetadataItem.fields],
);
const sortedWorkspaceFavorites = useMemo(
() =>
sortFavorites(
workspaceFavorites.filter((favorite) => favorite.viewId),
favoriteRelationFieldMetadataItems,
getObjectRecordIdentifierByNameSingular,
false,
views,
objectMetadataItems,
),
[
workspaceFavorites,
favoriteRelationFieldMetadataItems,
getObjectRecordIdentifierByNameSingular,
views,
objectMetadataItems,
],
);
return sortedWorkspaceFavorites;
};

View File

@ -0,0 +1,6 @@
import { atom } from 'recoil';
export const activeFavoriteFolderIdState = atom<string | null>({
key: 'activeFavoriteFolderIdState',
default: null,
});

View File

@ -0,0 +1,6 @@
import { atom } from 'recoil';
export const isFavoriteFolderCreatingState = atom<boolean>({
key: 'isFavoriteFolderCreatingState',
default: false,
});

View File

@ -10,5 +10,6 @@ export type Favorite = {
link: string; link: string;
recordId: string; recordId: string;
workspaceMemberId: string; workspaceMemberId: string;
favoriteFolderId?: string;
__typename: 'Favorite'; __typename: 'Favorite';
}; };

View File

@ -0,0 +1,9 @@
export type FavoriteFolder = {
id: string;
name: string;
position: number;
createdAt: string;
updatedAt?: string;
deletedAt?: string | null;
__typename: 'FavoriteFolder';
};

View File

@ -0,0 +1,55 @@
import { isLocationMatchingFavorite } from '../isLocationMatchingFavorite';
describe('isLocationMatchingFavorite', () => {
it('should return true if favorite link matches current path', () => {
const currentPath = '/app/objects/people';
const currentViewPath = '/app/objects/people?view=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?view=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?view=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?view=123';
const favorite = {
objectNameSingular: 'view',
link: '/app/objects/companies/view=246',
};
expect(
isLocationMatchingFavorite(currentPath, currentViewPath, favorite),
).toBe(false);
});
});

View File

@ -0,0 +1,33 @@
type CalculateNewPositionParams = {
destinationIndex: number;
sourceIndex: number;
items: Array<{ position: number }>;
};
export const calculateNewPosition = ({
destinationIndex,
sourceIndex,
items,
}: CalculateNewPositionParams): number => {
if (destinationIndex === 0) {
return items[0].position / 2;
}
if (destinationIndex === items.length - 1) {
return items[destinationIndex - 1].position + 1;
}
if (destinationIndex > sourceIndex) {
return (
(items[destinationIndex + 1].position +
items[destinationIndex].position) /
2
);
}
return (
items[destinationIndex].position -
(items[destinationIndex].position - items[destinationIndex - 1].position) /
2
);
};

View File

@ -0,0 +1,40 @@
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { View } from '@/views/types/View';
import { isDefined } from 'twenty-ui';
type ReturnType = {
labelPlural: string;
view: View | null;
};
export const getObjectMetadataLabelPluralFromViewId = (
views: View[],
objectMetadataItems: ObjectMetadataItem[],
viewId: string,
): ReturnType => {
const view = views.find((view) => view.id === viewId);
if (!view) {
return {
labelPlural: '',
view: null,
};
}
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 { labelPlural } = objectMetadataItem;
return {
labelPlural,
view,
};
};

View File

@ -0,0 +1,12 @@
import { 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;
};

View File

@ -1,19 +1,53 @@
import { Favorite } from '@/favorites/types/Favorite'; import { Favorite } from '@/favorites/types/Favorite';
import { FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem'; import { FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { ObjectRecord } from '@/object-record/types/ObjectRecord';
import { ObjectRecordIdentifier } from '@/object-record/types/ObjectRecordIdentifier'; import { ObjectRecordIdentifier } from '@/object-record/types/ObjectRecordIdentifier';
import { View } from '@/views/types/View';
import { isDefined } from 'twenty-ui'; import { isDefined } from 'twenty-ui';
import { getObjectMetadataLabelPluralFromViewId } from './getObjectMetadataLabelPluralFromViewId';
export type ProcessedFavorite = Favorite & {
Icon?: string;
objectNameSingular?: string;
};
export const sortFavorites = ( export const sortFavorites = (
favorites: Favorite[], favorites: Favorite[],
favoriteRelationFieldMetadataItems: FieldMetadataItem[], favoriteRelationFieldMetadataItems: FieldMetadataItem[],
getObjectRecordIdentifierByNameSingular: ( getObjectRecordIdentifierByNameSingular: (
record: any, record: ObjectRecord,
objectNameSingular: string, objectNameSingular: string,
) => ObjectRecordIdentifier, ) => ObjectRecordIdentifier,
hasLinkToShowPage: boolean, hasLinkToShowPage: boolean,
views: View[],
objectMetadataItems: ObjectMetadataItem[],
) => { ) => {
return favorites return favorites
.map((favorite) => { .map((favorite) => {
if (isDefined(favorite.viewId) && isDefined(favorite.workspaceMemberId)) {
const { labelPlural, view } = getObjectMetadataLabelPluralFromViewId(
views,
objectMetadataItems,
favorite.viewId,
);
return {
__typename: 'Favorite',
id: favorite.id,
recordId: view?.id,
position: favorite.position,
avatarType: 'icon',
avatarUrl: '',
labelIdentifier: view?.name,
link: `/objects/${labelPlural.toLocaleLowerCase()}${favorite.viewId ? `?view=${favorite.viewId}` : ''}`,
workspaceMemberId: favorite.workspaceMemberId,
favoriteFolderId: favorite.favoriteFolderId,
objectNameSingular: 'view',
Icon: view?.icon,
} as ProcessedFavorite;
}
for (const relationField of favoriteRelationFieldMetadataItems) { for (const relationField of favoriteRelationFieldMetadataItems) {
if (isDefined(favorite[relationField.name])) { if (isDefined(favorite[relationField.name])) {
const relationObject = favorite[relationField.name]; const relationObject = favorite[relationField.name];
@ -29,6 +63,7 @@ export const sortFavorites = (
); );
return { return {
__typename: 'Favorite',
id: favorite.id, id: favorite.id,
recordId: objectRecordIdentifier.id, recordId: objectRecordIdentifier.id,
position: favorite.position, position: favorite.position,
@ -39,11 +74,14 @@ export const sortFavorites = (
? objectRecordIdentifier.linkToShowPage ? objectRecordIdentifier.linkToShowPage
: '', : '',
workspaceMemberId: favorite.workspaceMemberId, workspaceMemberId: favorite.workspaceMemberId,
} as Favorite; favoriteFolderId: favorite.favoriteFolderId,
objectNameSingular: relationObjectNameSingular,
} as ProcessedFavorite;
} }
} }
return {
return favorite; ...favorite,
} as ProcessedFavorite;
}) })
.sort((a, b) => a.position - b.position); .sort((a, b) => a.position - b.position);
}; };

View File

@ -3,7 +3,7 @@ import { useRecoilState, useSetRecoilState } from 'recoil';
import { IconSearch, IconSettings } from 'twenty-ui'; import { IconSearch, IconSettings } from 'twenty-ui';
import { useCommandMenu } from '@/command-menu/hooks/useCommandMenu'; import { useCommandMenu } from '@/command-menu/hooks/useCommandMenu';
import { CurrentWorkspaceMemberFavorites } from '@/favorites/components/CurrentWorkspaceMemberFavorites'; import { CurrentWorkspaceMemberFavoritesFolders } from '@/favorites/components/CurrentWorkspaceMemberFavoritesFolders';
import { WorkspaceFavorites } from '@/favorites/components/WorkspaceFavorites'; import { WorkspaceFavorites } from '@/favorites/components/WorkspaceFavorites';
import { NavigationDrawerOpenedSection } from '@/object-metadata/components/NavigationDrawerOpenedSection'; import { NavigationDrawerOpenedSection } from '@/object-metadata/components/NavigationDrawerOpenedSection';
import { NavigationDrawerSectionForObjectMetadataItemsWrapper } from '@/object-metadata/components/NavigationDrawerSectionForObjectMetadataItemsWrapper'; import { NavigationDrawerSectionForObjectMetadataItemsWrapper } from '@/object-metadata/components/NavigationDrawerSectionForObjectMetadataItemsWrapper';
@ -19,6 +19,10 @@ const StyledMainSection = styled(NavigationDrawerSection)`
min-height: fit-content; min-height: fit-content;
`; `;
const StyledContainer = styled.div`
overflow-x: hidden;
overflow-y: auto;
`;
export const MainNavigationDrawerItems = () => { export const MainNavigationDrawerItems = () => {
const isMobile = useIsMobile(); const isMobile = useIsMobile();
const { toggleCommandMenu } = useCommandMenu(); const { toggleCommandMenu } = useCommandMenu();
@ -55,10 +59,15 @@ export const MainNavigationDrawerItems = () => {
/> />
</StyledMainSection> </StyledMainSection>
)} )}
<NavigationDrawerOpenedSection /> <StyledContainer>
<CurrentWorkspaceMemberFavorites /> <NavigationDrawerOpenedSection />
<WorkspaceFavorites />
<NavigationDrawerSectionForObjectMetadataItemsWrapper isRemote={true} /> <CurrentWorkspaceMemberFavoritesFolders />
<WorkspaceFavorites />
<NavigationDrawerSectionForObjectMetadataItemsWrapper isRemote={true} />
</StyledContainer>
</> </>
); );
}; };

View File

@ -1,4 +1,4 @@
import { useFavorites } from '@/favorites/hooks/useFavorites'; import { useWorkspaceFavorites } from '@/favorites/hooks/useWorkspaceFavorites';
import { useFilteredObjectMetadataItems } from '@/object-metadata/hooks/useFilteredObjectMetadataItems'; import { useFilteredObjectMetadataItems } from '@/object-metadata/hooks/useFilteredObjectMetadataItems';
import { usePrefetchedData } from '@/prefetch/hooks/usePrefetchedData'; import { usePrefetchedData } from '@/prefetch/hooks/usePrefetchedData';
import { PrefetchKey } from '@/prefetch/types/PrefetchKey'; import { PrefetchKey } from '@/prefetch/types/PrefetchKey';
@ -7,7 +7,7 @@ import { View } from '@/views/types/View';
export const useFilteredObjectMetadataItemsForWorkspaceFavorites = () => { export const useFilteredObjectMetadataItemsForWorkspaceFavorites = () => {
const { records: views } = usePrefetchedData<View>(PrefetchKey.AllViews); const { records: views } = usePrefetchedData<View>(PrefetchKey.AllViews);
const { workspaceFavorites } = useFavorites(); const workspaceFavorites = useWorkspaceFavorites();
const workspaceFavoriteIds = new Set( const workspaceFavoriteIds = new Set(
workspaceFavorites.map((favorite) => favorite.recordId), workspaceFavorites.map((favorite) => favorite.recordId),

View File

@ -3,9 +3,9 @@ import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { usePrefetchedData } from '@/prefetch/hooks/usePrefetchedData'; import { usePrefetchedData } from '@/prefetch/hooks/usePrefetchedData';
import { PrefetchKey } from '@/prefetch/types/PrefetchKey'; import { PrefetchKey } from '@/prefetch/types/PrefetchKey';
import { NavigationDrawerItem } from '@/ui/navigation/navigation-drawer/components/NavigationDrawerItem'; import { NavigationDrawerItem } from '@/ui/navigation/navigation-drawer/components/NavigationDrawerItem';
import { NavigationDrawerItemsCollapsedContainer } from '@/ui/navigation/navigation-drawer/components/NavigationDrawerItemsCollapsedContainer'; import { NavigationDrawerItemsCollapsableContainer } from '@/ui/navigation/navigation-drawer/components/NavigationDrawerItemsCollapsableContainer';
import { NavigationDrawerSubItem } from '@/ui/navigation/navigation-drawer/components/NavigationDrawerSubItem'; import { NavigationDrawerSubItem } from '@/ui/navigation/navigation-drawer/components/NavigationDrawerSubItem';
import { getNavigationSubItemState } from '@/ui/navigation/navigation-drawer/utils/getNavigationSubItemState'; import { getNavigationSubItemLeftAdornment } from '@/ui/navigation/navigation-drawer/utils/getNavigationSubItemLeftAdornment';
import { View } from '@/views/types/View'; import { View } from '@/views/types/View';
import { getObjectMetadataItemViews } from '@/views/utils/getObjectMetadataItemViews'; import { getObjectMetadataItemViews } from '@/views/utils/getObjectMetadataItemViews';
import { useLocation } from 'react-router-dom'; import { useLocation } from 'react-router-dom';
@ -53,7 +53,7 @@ export const NavigationDrawerItemForObjectMetadataItem = ({
const subItemArrayLength = sortedObjectMetadataViews.length; const subItemArrayLength = sortedObjectMetadataViews.length;
return ( return (
<NavigationDrawerItemsCollapsedContainer <NavigationDrawerItemsCollapsableContainer
isGroup={shouldSubItemsBeDisplayed} isGroup={shouldSubItemsBeDisplayed}
> >
<NavigationDrawerItem <NavigationDrawerItem
@ -69,7 +69,7 @@ export const NavigationDrawerItemForObjectMetadataItem = ({
label={view.name} label={view.name}
to={`/objects/${objectMetadataItem.namePlural}?view=${view.id}`} to={`/objects/${objectMetadataItem.namePlural}?view=${view.id}`}
active={viewId === view.id} active={viewId === view.id}
subItemState={getNavigationSubItemState({ subItemState={getNavigationSubItemLeftAdornment({
index, index,
arrayLength: subItemArrayLength, arrayLength: subItemArrayLength,
selectedIndex: selectedSubItemIndex, selectedIndex: selectedSubItemIndex,
@ -78,6 +78,6 @@ export const NavigationDrawerItemForObjectMetadataItem = ({
key={view.id} key={view.id}
/> />
))} ))}
</NavigationDrawerItemsCollapsedContainer> </NavigationDrawerItemsCollapsableContainer>
); );
}; };

View File

@ -11,6 +11,7 @@ export enum CoreObjectNameSingular {
ConnectedAccount = 'connectedAccount', ConnectedAccount = 'connectedAccount',
TimelineActivity = 'timelineActivity', TimelineActivity = 'timelineActivity',
Favorite = 'favorite', Favorite = 'favorite',
FavoriteFolder = 'favoriteFolder',
Message = 'message', Message = 'message',
MessageChannel = 'messageChannel', MessageChannel = 'messageChannel',
MessageParticipant = 'messageParticipant', MessageParticipant = 'messageParticipant',

View File

@ -163,6 +163,7 @@ export const PERSON_FRAGMENT_WITH_DEPTH_ONE_RELATIONS = `
companyId companyId
createdAt createdAt
deletedAt deletedAt
favoriteFolderId
id id
noteId noteId
opportunityId opportunityId

View File

@ -102,6 +102,7 @@ const mocks: MockedResponse[] = [
companyId companyId
createdAt createdAt
deletedAt deletedAt
favoriteFolderId
id id
noteId noteId
opportunityId opportunityId

View File

@ -1,25 +1,50 @@
import { useRecoilValue } from 'recoil'; import { FAVORITE_FOLDER_PICKER_DROPDOWN_ID } from '@/favorites/favorite-folder-picker/constants/FavoriteFolderPickerDropdownId';
import { useIcons } from 'twenty-ui'; import { useFavorites } from '@/favorites/hooks/useFavorites';
import { useFilteredObjectMetadataItems } from '@/object-metadata/hooks/useFilteredObjectMetadataItems'; import { useFilteredObjectMetadataItems } from '@/object-metadata/hooks/useFilteredObjectMetadataItems';
import { isObjectMetadataReadOnly } from '@/object-metadata/utils/isObjectMetadataReadOnly'; import { isObjectMetadataReadOnly } from '@/object-metadata/utils/isObjectMetadataReadOnly';
import { RecordIndexPageKanbanAddButton } from '@/object-record/record-index/components/RecordIndexPageKanbanAddButton'; import { RecordIndexPageKanbanAddButton } from '@/object-record/record-index/components/RecordIndexPageKanbanAddButton';
import { RecordIndexRootPropsContext } from '@/object-record/record-index/contexts/RecordIndexRootPropsContext'; import { RecordIndexRootPropsContext } from '@/object-record/record-index/contexts/RecordIndexRootPropsContext';
import { recordIndexViewTypeState } from '@/object-record/record-index/states/recordIndexViewTypeState'; import { recordIndexViewTypeState } from '@/object-record/record-index/states/recordIndexViewTypeState';
import { usePrefetchedData } from '@/prefetch/hooks/usePrefetchedData';
import { PrefetchKey } from '@/prefetch/types/PrefetchKey';
import { PageAddButton } from '@/ui/layout/page/components/PageAddButton'; import { PageAddButton } from '@/ui/layout/page/components/PageAddButton';
import { PageFavoriteFoldersDropdown } from '@/ui/layout/page/components/PageFavoriteFolderDropdown';
import { PageHeader } from '@/ui/layout/page/components/PageHeader'; import { PageHeader } from '@/ui/layout/page/components/PageHeader';
import { PageHotkeysEffect } from '@/ui/layout/page/components/PageHotkeysEffect'; import { PageHotkeysEffect } from '@/ui/layout/page/components/PageHotkeysEffect';
import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2';
import { currentViewIdComponentState } from '@/views/states/currentViewIdComponentState';
import { View } from '@/views/types/View';
import { ViewType } from '@/views/types/ViewType'; import { ViewType } from '@/views/types/ViewType';
import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
import { useContext } from 'react'; import { useContext } from 'react';
import { useRecoilValue } from 'recoil';
import { useIcons } from 'twenty-ui';
import { capitalize } from '~/utils/string/capitalize'; import { capitalize } from '~/utils/string/capitalize';
export const RecordIndexPageHeader = () => { export const RecordIndexPageHeader = () => {
const { findObjectMetadataItemByNamePlural } = const { findObjectMetadataItemByNamePlural } =
useFilteredObjectMetadataItems(); useFilteredObjectMetadataItems();
const isFavoriteFolderEnabled = useIsFeatureEnabled(
'IS_FAVORITE_FOLDER_ENABLED',
);
const { objectNamePlural, onCreateRecord } = useContext( const { objectNamePlural, onCreateRecord, recordIndexId } = useContext(
RecordIndexRootPropsContext, RecordIndexRootPropsContext,
); );
const { records: views } = usePrefetchedData<View>(PrefetchKey.AllViews);
const currentViewId = useRecoilComponentValueV2(
currentViewIdComponentState,
recordIndexId,
);
const view = views.find((view) => view.id === currentViewId);
const favorites = useFavorites();
const isFavorite = favorites.some(
(favorite) =>
favorite.recordId === currentViewId && favorite.workspaceMemberId,
);
const objectMetadataItem = const objectMetadataItem =
findObjectMetadataItemByNamePlural(objectNamePlural); findObjectMetadataItemByNamePlural(objectNamePlural);
@ -47,6 +72,14 @@ export const RecordIndexPageHeader = () => {
return ( return (
<PageHeader title={pageHeaderTitle} Icon={Icon}> <PageHeader title={pageHeaderTitle} Icon={Icon}>
<PageHotkeysEffect onAddButtonClick={handleAddButtonClick} /> <PageHotkeysEffect onAddButtonClick={handleAddButtonClick} />
{isFavoriteFolderEnabled && (
<PageFavoriteFoldersDropdown
record={view}
dropdownId={FAVORITE_FOLDER_PICKER_DROPDOWN_ID}
objectNameSingular="view"
isFavorite={isFavorite}
/>
)}
{shouldDisplayAddButton && {shouldDisplayAddButton &&
(isTable ? ( (isTable ? (
<PageAddButton onClick={handleAddButtonClick} /> <PageAddButton onClick={handleAddButtonClick} />

View File

@ -21,7 +21,6 @@ export const useHandleIndexIdentifierClick = ({
recordId, recordId,
currentViewId, currentViewId,
); );
return showPageURL; return showPageURL;
}; };

View File

@ -3,6 +3,8 @@ import { useParams } from 'react-router-dom';
import { useSetRecoilState } from 'recoil'; import { useSetRecoilState } from 'recoil';
import { useIcons } from 'twenty-ui'; import { useIcons } from 'twenty-ui';
import { useCreateFavorite } from '@/favorites/hooks/useCreateFavorite';
import { useDeleteFavorite } from '@/favorites/hooks/useDeleteFavorite';
import { useFavorites } from '@/favorites/hooks/useFavorites'; import { useFavorites } from '@/favorites/hooks/useFavorites';
import { useLabelIdentifierFieldMetadataItem } from '@/object-metadata/hooks/useLabelIdentifierFieldMetadataItem'; import { useLabelIdentifierFieldMetadataItem } from '@/object-metadata/hooks/useLabelIdentifierFieldMetadataItem';
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem'; import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
@ -34,7 +36,9 @@ export const useRecordShowPage = (
const { objectMetadataItems } = useObjectMetadataItems(); const { objectMetadataItems } = useObjectMetadataItems();
const { labelIdentifierFieldMetadataItem } = const { labelIdentifierFieldMetadataItem } =
useLabelIdentifierFieldMetadataItem({ objectNameSingular }); useLabelIdentifierFieldMetadataItem({ objectNameSingular });
const { favorites, createFavorite, deleteFavorite } = useFavorites(); const favorites = useFavorites();
const createFavorite = useCreateFavorite();
const deleteFavorite = useDeleteFavorite();
const setEntityFields = useSetRecoilState( const setEntityFields = useSetRecoilState(
recordStoreFamilyState(objectRecordId), recordStoreFamilyState(objectRecordId),
); );
@ -98,8 +102,8 @@ export const useRecordShowPage = (
pageTitle, pageTitle,
pageName, pageName,
isFavorite, isFavorite,
handleFavoriteButtonClick,
record, record,
objectMetadataItem, objectMetadataItem,
handleFavoriteButtonClick,
}; };
}; };

View File

@ -101,6 +101,7 @@ const companyMocks = [
companyId companyId
createdAt createdAt
deletedAt deletedAt
favoriteFolderId
id id
noteId noteId
opportunityId opportunityId

View File

@ -1,11 +1,17 @@
import React from 'react'; import React from 'react';
import { PrefetchFavoriteFoldersRunQueriesEffect } from '@/prefetch/components/PrefetchFavortiteFoldersRunQueriesEffect';
import { PrefetchRunQueriesEffect } from '@/prefetch/components/PrefetchRunQueriesEffect'; import { PrefetchRunQueriesEffect } from '@/prefetch/components/PrefetchRunQueriesEffect';
import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
export const PrefetchDataProvider = ({ children }: React.PropsWithChildren) => { export const PrefetchDataProvider = ({ children }: React.PropsWithChildren) => {
const isFavoriteFolderEnabled = useIsFeatureEnabled(
'IS_FAVORITE_FOLDER_ENABLED',
);
return ( return (
<> <>
<PrefetchRunQueriesEffect /> <PrefetchRunQueriesEffect />
{isFavoriteFolderEnabled && <PrefetchFavoriteFoldersRunQueriesEffect />}
{children} {children}
</> </>
); );

View File

@ -0,0 +1,45 @@
import { currentUserState } from '@/auth/states/currentUserState';
import { FavoriteFolder } from '@/favorites/types/FavoriteFolder';
import { useObjectMetadataItems } from '@/object-metadata/hooks/useObjectMetadataItems';
import { useCombinedFindManyRecords } from '@/object-record/multiple-objects/hooks/useCombinedFindManyRecords';
import { PREFETCH_CONFIG } from '@/prefetch/constants/PrefetchConfig';
import { usePrefetchRunQuery } from '@/prefetch/hooks/internal/usePrefetchRunQuery';
import { PrefetchKey } from '@/prefetch/types/PrefetchKey';
import { useEffect } from 'react';
import { useRecoilValue } from 'recoil';
import { isDefined } from '~/utils/isDefined';
export const PrefetchFavoriteFoldersRunQueriesEffect = () => {
const currentUser = useRecoilValue(currentUserState);
const { upsertRecordsInCache: upsertFavoritesFoldersInCache } =
usePrefetchRunQuery<FavoriteFolder>({
prefetchKey: PrefetchKey.AllFavoritesFolders,
});
const { objectMetadataItems } = useObjectMetadataItems();
// Only include favorite folders operation
const operationSignatures = Object.values(PREFETCH_CONFIG)
.filter(({ objectNameSingular }) => objectNameSingular === 'favoriteFolder')
.map(({ objectNameSingular, operationSignatureFactory }) => {
const objectMetadataItem = objectMetadataItems.find(
(item) => item.nameSingular === objectNameSingular,
);
return operationSignatureFactory({ objectMetadataItem });
});
const { result } = useCombinedFindManyRecords({
operationSignatures,
skip: !currentUser,
});
useEffect(() => {
if (isDefined(result.favoriteFolders)) {
upsertFavoritesFoldersInCache(result.favoriteFolders as FavoriteFolder[]);
}
}, [result, upsertFavoritesFoldersInCache]);
return null;
};

View File

@ -9,10 +9,14 @@ import { PREFETCH_CONFIG } from '@/prefetch/constants/PrefetchConfig';
import { usePrefetchRunQuery } from '@/prefetch/hooks/internal/usePrefetchRunQuery'; import { usePrefetchRunQuery } from '@/prefetch/hooks/internal/usePrefetchRunQuery';
import { PrefetchKey } from '@/prefetch/types/PrefetchKey'; import { PrefetchKey } from '@/prefetch/types/PrefetchKey';
import { View } from '@/views/types/View'; import { View } from '@/views/types/View';
import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
import { isDefined } from '~/utils/isDefined'; import { isDefined } from '~/utils/isDefined';
export const PrefetchRunQueriesEffect = () => { export const PrefetchRunQueriesEffect = () => {
const currentUser = useRecoilValue(currentUserState); const currentUser = useRecoilValue(currentUserState);
const isFavoriteFolderEnabled = useIsFeatureEnabled(
'IS_FAVORITE_FOLDER_ENABLED',
);
const { upsertRecordsInCache: upsertViewsInCache } = const { upsertRecordsInCache: upsertViewsInCache } =
usePrefetchRunQuery<View>({ usePrefetchRunQuery<View>({
@ -26,15 +30,19 @@ export const PrefetchRunQueriesEffect = () => {
const { objectMetadataItems } = useObjectMetadataItems(); const { objectMetadataItems } = useObjectMetadataItems();
const operationSignatures = Object.values(PREFETCH_CONFIG).map( const operationSignatures = Object.values(PREFETCH_CONFIG)
({ objectNameSingular, operationSignatureFactory }) => { .filter(
({ objectNameSingular }) =>
// Exclude favorite folders as they're handled separately
objectNameSingular !== 'favoriteFolder',
)
.map(({ objectNameSingular, operationSignatureFactory }) => {
const objectMetadataItem = objectMetadataItems.find( const objectMetadataItem = objectMetadataItems.find(
(item) => item.nameSingular === objectNameSingular, (item) => item.nameSingular === objectNameSingular,
); );
return operationSignatureFactory({ objectMetadataItem }); return operationSignatureFactory({ objectMetadataItem });
}, });
);
const { result } = useCombinedFindManyRecords({ const { result } = useCombinedFindManyRecords({
operationSignatures, operationSignatures,
@ -49,7 +57,12 @@ export const PrefetchRunQueriesEffect = () => {
if (isDefined(result.favorites)) { if (isDefined(result.favorites)) {
upsertFavoritesInCache(result.favorites as Favorite[]); upsertFavoritesInCache(result.favorites as Favorite[]);
} }
}, [result, upsertViewsInCache, upsertFavoritesInCache]); }, [
result,
upsertViewsInCache,
upsertFavoritesInCache,
isFavoriteFolderEnabled,
]);
return <></>; return <></>;
}; };

View File

@ -1,5 +1,6 @@
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular'; import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
import { RecordGqlOperationSignatureFactory } from '@/object-record/graphql/types/RecordGqlOperationSignatureFactory'; import { RecordGqlOperationSignatureFactory } from '@/object-record/graphql/types/RecordGqlOperationSignatureFactory';
import { findAllFavoritesFolderOperationSignatureFactory } from '@/prefetch/graphql/operation-signatures/factories/findAllFavoritesFolderOperationSignatureFactory';
import { findAllFavoritesOperationSignatureFactory } from '@/prefetch/graphql/operation-signatures/factories/findAllFavoritesOperationSignatureFactory'; import { findAllFavoritesOperationSignatureFactory } from '@/prefetch/graphql/operation-signatures/factories/findAllFavoritesOperationSignatureFactory';
import { findAllViewsOperationSignatureFactory } from '@/prefetch/graphql/operation-signatures/factories/findAllViewsOperationSignatureFactory'; import { findAllViewsOperationSignatureFactory } from '@/prefetch/graphql/operation-signatures/factories/findAllViewsOperationSignatureFactory';
import { PrefetchKey } from '@/prefetch/types/PrefetchKey'; import { PrefetchKey } from '@/prefetch/types/PrefetchKey';
@ -19,4 +20,8 @@ export const PREFETCH_CONFIG: Record<
objectNameSingular: CoreObjectNameSingular.Favorite, objectNameSingular: CoreObjectNameSingular.Favorite,
operationSignatureFactory: findAllFavoritesOperationSignatureFactory, operationSignatureFactory: findAllFavoritesOperationSignatureFactory,
}, },
ALL_FAVORITES_FOLDERS: {
objectNameSingular: CoreObjectNameSingular.FavoriteFolder,
operationSignatureFactory: findAllFavoritesFolderOperationSignatureFactory,
},
}; };

View File

@ -0,0 +1,17 @@
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
import { RecordGqlOperationSignatureFactory } from '@/object-record/graphql/types/RecordGqlOperationSignatureFactory';
export const findAllFavoritesFolderOperationSignatureFactory: RecordGqlOperationSignatureFactory =
() => ({
objectNameSingular: CoreObjectNameSingular.FavoriteFolder,
variables: {},
fields: {
id: true,
position: true,
createdAt: true,
updatedAt: true,
deletedAt: true,
name: true,
icon: true,
},
});

View File

@ -0,0 +1,11 @@
import { prefetchIsLoadedFamilyState } from '@/prefetch/states/prefetchIsLoadedFamilyState';
import { PrefetchKey } from '@/prefetch/types/PrefetchKey';
import { useRecoilValue } from 'recoil';
export const useIsFavoriteFoldersPrefetchLoading = () => {
const areFavoritesFolderPrefetched = useRecoilValue(
prefetchIsLoadedFamilyState(PrefetchKey.AllFavoritesFolders),
);
return !areFavoritesFolderPrefetched;
};

View File

@ -1,14 +1,25 @@
import { useRecoilValue } from 'recoil';
import { prefetchIsLoadedFamilyState } from '@/prefetch/states/prefetchIsLoadedFamilyState'; import { prefetchIsLoadedFamilyState } from '@/prefetch/states/prefetchIsLoadedFamilyState';
import { PrefetchKey } from '@/prefetch/types/PrefetchKey'; import { PrefetchKey } from '@/prefetch/types/PrefetchKey';
import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
import { useRecoilValue } from 'recoil';
import { useIsFavoriteFoldersPrefetchLoading } from './useIsFavoriteFoldersPrefetchLoading';
export const useIsPrefetchLoading = () => { export const useIsPrefetchLoading = () => {
const isFavoriteFolderEnabled = useIsFeatureEnabled(
'IS_FAVORITE_FOLDER_ENABLED',
);
const isFavoriteFoldersLoading = useIsFavoriteFoldersPrefetchLoading();
const areViewsPrefetched = useRecoilValue( const areViewsPrefetched = useRecoilValue(
prefetchIsLoadedFamilyState(PrefetchKey.AllViews), prefetchIsLoadedFamilyState(PrefetchKey.AllViews),
); );
const areFavoritesPrefetched = useRecoilValue( const areFavoritesPrefetched = useRecoilValue(
prefetchIsLoadedFamilyState(PrefetchKey.AllFavorites), prefetchIsLoadedFamilyState(PrefetchKey.AllFavorites),
); );
return !areViewsPrefetched || !areFavoritesPrefetched;
return (
!areViewsPrefetched ||
!areFavoritesPrefetched ||
(isFavoriteFolderEnabled && isFavoriteFoldersLoading)
);
}; };

View File

@ -1,4 +1,5 @@
export enum PrefetchKey { export enum PrefetchKey {
AllViews = 'ALL_VIEWS', AllViews = 'ALL_VIEWS',
AllFavorites = 'ALL_FAVORITES', AllFavorites = 'ALL_FAVORITES',
AllFavoritesFolders = 'ALL_FAVORITES_FOLDERS',
} }

View File

@ -34,7 +34,7 @@ import { NavigationDrawerItemGroup } from '@/ui/navigation/navigation-drawer/com
import { NavigationDrawerSection } from '@/ui/navigation/navigation-drawer/components/NavigationDrawerSection'; import { NavigationDrawerSection } from '@/ui/navigation/navigation-drawer/components/NavigationDrawerSection';
import { NavigationDrawerSectionTitle } from '@/ui/navigation/navigation-drawer/components/NavigationDrawerSectionTitle'; import { NavigationDrawerSectionTitle } from '@/ui/navigation/navigation-drawer/components/NavigationDrawerSectionTitle';
import { isAdvancedModeEnabledState } from '@/ui/navigation/navigation-drawer/states/isAdvancedModeEnabledState'; import { isAdvancedModeEnabledState } from '@/ui/navigation/navigation-drawer/states/isAdvancedModeEnabledState';
import { getNavigationSubItemState } from '@/ui/navigation/navigation-drawer/utils/getNavigationSubItemState'; import { getNavigationSubItemLeftAdornment } from '@/ui/navigation/navigation-drawer/utils/getNavigationSubItemLeftAdornment';
import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled'; import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
import styled from '@emotion/styled'; import styled from '@emotion/styled';
import { AnimatePresence, motion } from 'framer-motion'; import { AnimatePresence, motion } from 'framer-motion';
@ -143,7 +143,7 @@ export const SettingsNavigationDrawerItems = () => {
path={navigationItem.path} path={navigationItem.path}
Icon={navigationItem.Icon} Icon={navigationItem.Icon}
indentationLevel={navigationItem.indentationLevel} indentationLevel={navigationItem.indentationLevel}
subItemState={getNavigationSubItemState({ subItemState={getNavigationSubItemLeftAdornment({
arrayLength: accountSubSettings.length, arrayLength: accountSubSettings.length,
index, index,
selectedIndex, selectedIndex,

View File

@ -1,4 +1,3 @@
import React from 'react';
import { useTheme } from '@emotion/react'; import { useTheme } from '@emotion/react';
import { Draggable } from '@hello-pangea/dnd'; import { Draggable } from '@hello-pangea/dnd';

View File

@ -2,7 +2,7 @@ import { IconButton, IconHeart } from 'twenty-ui';
type PageFavoriteButtonProps = { type PageFavoriteButtonProps = {
isFavorite: boolean; isFavorite: boolean;
onClick: () => void; onClick?: () => void;
}; };
export const PageFavoriteButton = ({ export const PageFavoriteButton = ({

View File

@ -0,0 +1,49 @@
import { FavoriteFolderPicker } from '@/favorites/favorite-folder-picker/components/FavoriteFolderPicker';
import { FavoriteFolderPickerEffect } from '@/favorites/favorite-folder-picker/components/FavoriteFolderPickerEffect';
import { FavoriteFolderPickerScope } from '@/favorites/favorite-folder-picker/scopes/FavoriteFolderPickerScope';
import { ObjectRecord } from '@/object-record/types/ObjectRecord';
import { Dropdown } from '@/ui/layout/dropdown/components/Dropdown';
import { useDropdown } from '@/ui/layout/dropdown/hooks/useDropdown';
import { DropdownScope } from '@/ui/layout/dropdown/scopes/DropdownScope';
import { PageFavoriteButton } from '@/ui/layout/page/components/PageFavoriteButton';
type PageFavoriteFoldersDropdownProps = {
dropdownId: string;
isFavorite: boolean;
record?: ObjectRecord;
objectNameSingular: string;
};
export const PageFavoriteFoldersDropdown = ({
dropdownId,
isFavorite,
record,
objectNameSingular,
}: PageFavoriteFoldersDropdownProps) => {
const { closeDropdown } = useDropdown(dropdownId);
return (
<FavoriteFolderPickerScope favoriteFoldersScopeId={dropdownId}>
<DropdownScope dropdownScopeId={dropdownId}>
<Dropdown
dropdownId={dropdownId}
dropdownPlacement="bottom-start"
clickableComponent={<PageFavoriteButton isFavorite={isFavorite} />}
dropdownComponents={
<>
<FavoriteFolderPickerEffect record={record} />
<FavoriteFolderPicker
onSubmit={closeDropdown}
record={record}
objectNameSingular={objectNameSingular}
/>
</>
}
dropdownHotkeyScope={{
scope: dropdownId,
}}
/>
</DropdownScope>
</FavoriteFolderPickerScope>
);
};

View File

@ -0,0 +1,136 @@
import { NavigationDrawerAnimatedCollapseWrapper } from '@/ui/navigation/navigation-drawer/components/NavigationDrawerAnimatedCollapseWrapper';
import { isNavigationDrawerExpandedState } from '@/ui/navigation/states/isNavigationDrawerExpanded';
import { useScopedHotkeys } from '@/ui/utilities/hotkey/hooks/useScopedHotkeys';
import { useListenClickOutside } from '@/ui/utilities/pointer-event/hooks/useListenClickOutside';
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { ChangeEvent, FocusEvent, useRef } from 'react';
import { useRecoilState } from 'recoil';
import { Key } from 'ts-key-enum';
import {
IconComponent,
isDefined,
TablerIconsProps,
TEXT_INPUT_STYLE,
} from 'twenty-ui';
import { useHotkeyScopeOnMount } from '~/hooks/useHotkeyScopeOnMount';
type NavigationDrawerInputProps = {
className?: string;
Icon: IconComponent | ((props: TablerIconsProps) => JSX.Element);
value: string;
onChange: (value: string) => void;
onSubmit: (value: string) => void;
onCancel: (value: string) => void;
onClickOutside: (event: MouseEvent | TouchEvent, value: string) => void;
hotkeyScope: string;
};
const StyledItem = styled.div<{ isNavigationDrawerExpanded: boolean }>`
align-items: center;
background-color: ${({ theme }) => theme.background.primary};
border: 1px solid ${({ theme }) => theme.color.blue};
border-radius: ${({ theme }) => theme.border.radius.sm};
box-sizing: content-box;
color: ${({ theme }) => theme.font.color.primary};
display: flex;
font-family: ${({ theme }) => theme.font.family};
font-size: ${({ theme }) => theme.font.size.md};
height: calc(${({ theme }) => theme.spacing(5)} - 2px);
padding: ${({ theme }) => theme.spacing(1)};
text-decoration: none;
user-select: none;
`;
const StyledItemElementsContainer = styled.span`
align-items: center;
display: flex;
width: 100%;
`;
const StyledTextInput = styled.input`
${TEXT_INPUT_STYLE}
margin: 0;
width: 100%;
`;
export const NavigationDrawerInput = ({
className,
Icon,
value,
onChange,
onSubmit,
onCancel,
onClickOutside,
hotkeyScope,
}: NavigationDrawerInputProps) => {
const theme = useTheme();
const [isNavigationDrawerExpanded] = useRecoilState(
isNavigationDrawerExpandedState,
);
const inputRef = useRef<HTMLInputElement>(null);
useHotkeyScopeOnMount(hotkeyScope);
useScopedHotkeys(
[Key.Escape],
() => {
onCancel(value);
},
hotkeyScope,
);
useScopedHotkeys(
[Key.Enter],
() => {
onSubmit(value);
},
hotkeyScope,
);
useListenClickOutside({
refs: [inputRef],
callback: (event) => {
event.stopImmediatePropagation();
onClickOutside(event, value);
},
});
const handleChange = (event: ChangeEvent<HTMLInputElement>) => {
onChange(event.target.value);
};
const handleFocus = (event: FocusEvent<HTMLInputElement>) => {
if (isDefined(value)) {
event.target.select();
}
};
return (
<StyledItem
className={className}
isNavigationDrawerExpanded={isNavigationDrawerExpanded}
>
<StyledItemElementsContainer>
{Icon && (
<Icon
style={{ minWidth: theme.icon.size.md }}
size={theme.icon.size.md}
stroke={theme.icon.stroke.md}
color="currentColor"
/>
)}
<NavigationDrawerAnimatedCollapseWrapper>
<StyledTextInput
ref={inputRef}
value={value}
onChange={handleChange}
onFocus={handleFocus}
autoFocus
/>
</NavigationDrawerAnimatedCollapseWrapper>
</StyledItemElementsContainer>
</StyledItem>
);
};

View File

@ -8,6 +8,7 @@ import { useIsMobile } from '@/ui/utilities/responsive/hooks/useIsMobile';
import isPropValid from '@emotion/is-prop-valid'; import isPropValid from '@emotion/is-prop-valid';
import { useTheme } from '@emotion/react'; import { useTheme } from '@emotion/react';
import styled from '@emotion/styled'; import styled from '@emotion/styled';
import { ReactNode } from 'react';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { useRecoilState } from 'recoil'; import { useRecoilState } from 'recoil';
import { import {
@ -35,16 +36,19 @@ export type NavigationDrawerItemProps = {
soon?: boolean; soon?: boolean;
count?: number; count?: number;
keyboard?: string[]; keyboard?: string[];
rightOptions?: ReactNode;
isDraggable?: boolean;
}; };
type StyledItemProps = Pick< type StyledItemProps = Pick<
NavigationDrawerItemProps, NavigationDrawerItemProps,
'active' | 'danger' | 'indentationLevel' | 'soon' | 'to' 'active' | 'danger' | 'indentationLevel' | 'soon' | 'to' | 'isDraggable'
> & { isNavigationDrawerExpanded: boolean }; > & { isNavigationDrawerExpanded: boolean };
const StyledItem = styled('button', { const StyledItem = styled('button', {
shouldForwardProp: (prop) => shouldForwardProp: (prop) =>
!['active', 'danger', 'soon'].includes(prop) && isPropValid(prop), !['active', 'danger', 'soon', 'isDraggable'].includes(prop) &&
isPropValid(prop),
})<StyledItemProps>` })<StyledItemProps>`
box-sizing: content-box; box-sizing: content-box;
align-items: center; align-items: center;
@ -85,6 +89,15 @@ const StyledItem = styled('button', {
!props.isNavigationDrawerExpanded !props.isNavigationDrawerExpanded
? `${NAV_DRAWER_WIDTHS.menu.desktop.collapsed - 24}px` ? `${NAV_DRAWER_WIDTHS.menu.desktop.collapsed - 24}px`
: '100%'}; : '100%'};
${({ isDraggable }) =>
isDraggable &&
`
cursor: grab;
&:active {
cursor: grabbing;
}
`}
:hover { :hover {
background: ${({ theme }) => theme.background.transparent.light}; background: ${({ theme }) => theme.background.transparent.light};
@ -150,6 +163,27 @@ const StyledSpacer = styled.span`
flex-grow: 1; flex-grow: 1;
`; `;
const StyledRightOptionsContainer = styled.div<{
isMobile: boolean;
active: boolean;
}>`
margin-left: auto;
visibility: ${({ isMobile, active }) =>
isMobile || active ? 'visible' : 'hidden'};
display: flex;
align-items: center;
justify-content: center;
:hover {
background: ${({ theme }) => theme.background.transparent.light};
}
width: ${({ theme }) => theme.spacing(6)};
height: ${({ theme }) => theme.spacing(6)};
border-radius: ${({ theme }) => theme.border.radius.sm};
.navigation-drawer-item:hover & {
visibility: visible;
}
`;
export const NavigationDrawerItem = ({ export const NavigationDrawerItem = ({
className, className,
label, label,
@ -163,6 +197,8 @@ export const NavigationDrawerItem = ({
count, count,
keyboard, keyboard,
subItemState, subItemState,
rightOptions,
isDraggable,
}: NavigationDrawerItemProps) => { }: NavigationDrawerItemProps) => {
const theme = useTheme(); const theme = useTheme();
const isMobile = useIsMobile(); const isMobile = useIsMobile();
@ -185,7 +221,7 @@ export const NavigationDrawerItem = ({
return ( return (
<StyledNavigationDrawerItemContainer> <StyledNavigationDrawerItemContainer>
<StyledItem <StyledItem
className={className} className={`navigation-drawer-item ${className || ''}`}
onClick={handleItemClick} onClick={handleItemClick}
active={active} active={active}
aria-selected={active} aria-selected={active}
@ -195,6 +231,7 @@ export const NavigationDrawerItem = ({
to={to ? to : undefined} to={to ? to : undefined}
indentationLevel={indentationLevel} indentationLevel={indentationLevel}
isNavigationDrawerExpanded={isNavigationDrawerExpanded} isNavigationDrawerExpanded={isNavigationDrawerExpanded}
isDraggable={isDraggable}
> >
{showBreadcrumb && ( {showBreadcrumb && (
<NavigationDrawerAnimatedCollapseWrapper> <NavigationDrawerAnimatedCollapseWrapper>
@ -240,6 +277,20 @@ export const NavigationDrawerItem = ({
</StyledKeyBoardShortcut> </StyledKeyBoardShortcut>
</NavigationDrawerAnimatedCollapseWrapper> </NavigationDrawerAnimatedCollapseWrapper>
)} )}
<NavigationDrawerAnimatedCollapseWrapper>
{rightOptions && (
<StyledRightOptionsContainer
isMobile={isMobile}
active={active || false}
onClick={(e) => {
e.stopPropagation();
e.preventDefault();
}}
>
{rightOptions}
</StyledRightOptionsContainer>
)}
</NavigationDrawerAnimatedCollapseWrapper>
</StyledItemElementsContainer> </StyledItemElementsContainer>
</StyledItem> </StyledItem>
</StyledNavigationDrawerItemContainer> </StyledNavigationDrawerItemContainer>

View File

@ -0,0 +1,5 @@
import { TextInput } from '@/ui/input/components/TextInput';
export const NavigationDrawerItemInput = () => {
return <TextInput />;
};

View File

@ -1,22 +1,22 @@
import { useIsSettingsPage } from '@/navigation/hooks/useIsSettingsPage';
import { isNavigationDrawerExpandedState } from '@/ui/navigation/states/isNavigationDrawerExpanded';
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled'; import styled from '@emotion/styled';
import { AnimationControls, motion, TargetAndTransition } from 'framer-motion';
import { ReactNode } from 'react'; import { ReactNode } from 'react';
import { useRecoilValue } from 'recoil'; import { useRecoilValue } from 'recoil';
import { isNavigationDrawerExpandedState } from '@/ui/navigation/states/isNavigationDrawerExpanded';
import { useIsSettingsPage } from '@/navigation/hooks/useIsSettingsPage';
import { AnimationControls, motion, TargetAndTransition } from 'framer-motion';
import { useTheme } from '@emotion/react';
const StyledAnimationGroupContainer = styled(motion.div)``; const StyledAnimationGroupContainer = styled(motion.div)``;
type NavigationDrawerItemsCollapsedContainerProps = { type NavigationDrawerItemsCollapsableContainerProps = {
isGroup?: boolean; isGroup?: boolean;
children: ReactNode; children: ReactNode;
}; };
export const NavigationDrawerItemsCollapsedContainer = ({ export const NavigationDrawerItemsCollapsableContainer = ({
isGroup = false, isGroup = false,
children, children,
}: NavigationDrawerItemsCollapsedContainerProps) => { }: NavigationDrawerItemsCollapsableContainerProps) => {
const theme = useTheme(); const theme = useTheme();
const isSettingsPage = useIsSettingsPage(); const isSettingsPage = useIsSettingsPage();
const isNavigationDrawerExpanded = useRecoilValue( const isNavigationDrawerExpanded = useRecoilValue(

View File

@ -7,7 +7,6 @@ const StyledSection = styled.div`
width: 100%; width: 100%;
margin-bottom: ${({ theme }) => theme.spacing(3)}; margin-bottom: ${({ theme }) => theme.spacing(3)};
flex-shrink: 1; flex-shrink: 1;
overflow: hidden;
`; `;
export { StyledSection as NavigationDrawerSection }; export { StyledSection as NavigationDrawerSection };

View File

@ -1,20 +1,15 @@
import styled from '@emotion/styled';
import { currentUserState } from '@/auth/states/currentUserState'; import { currentUserState } from '@/auth/states/currentUserState';
import { useIsSettingsPage } from '@/navigation/hooks/useIsSettingsPage'; import { useIsSettingsPage } from '@/navigation/hooks/useIsSettingsPage';
import { useIsPrefetchLoading } from '@/prefetch/hooks/useIsPrefetchLoading'; import { useIsPrefetchLoading } from '@/prefetch/hooks/useIsPrefetchLoading';
import { NavigationDrawerSectionTitleSkeletonLoader } from '@/ui/navigation/navigation-drawer/components/NavigationDrawerSectionTitleSkeletonLoader'; import { NavigationDrawerSectionTitleSkeletonLoader } from '@/ui/navigation/navigation-drawer/components/NavigationDrawerSectionTitleSkeletonLoader';
import { isNavigationDrawerExpandedState } from '@/ui/navigation/states/isNavigationDrawerExpanded'; import { isNavigationDrawerExpandedState } from '@/ui/navigation/states/isNavigationDrawerExpanded';
import { useIsMobile } from '@/ui/utilities/responsive/hooks/useIsMobile';
import styled from '@emotion/styled';
import React from 'react';
import { useRecoilValue } from 'recoil'; import { useRecoilValue } from 'recoil';
import { isDefined } from 'twenty-ui'; import { isDefined } from 'twenty-ui';
import { isUndefinedOrNull } from '~/utils/isUndefinedOrNull';
type NavigationDrawerSectionTitleProps = { const StyledTitle = styled.div`
onClick?: () => void;
label: string;
};
const StyledTitle = styled.div<{ onClick?: () => void }>`
align-items: center; align-items: center;
border-radius: ${({ theme }) => theme.border.radius.sm}; border-radius: ${({ theme }) => theme.border.radius.sm};
color: ${({ theme }) => theme.font.color.light}; color: ${({ theme }) => theme.font.color.light};
@ -23,38 +18,92 @@ const StyledTitle = styled.div<{ onClick?: () => void }>`
font-weight: ${({ theme }) => theme.font.weight.semiBold}; font-weight: ${({ theme }) => theme.font.weight.semiBold};
height: ${({ theme }) => theme.spacing(5)}; height: ${({ theme }) => theme.spacing(5)};
padding: ${({ theme }) => theme.spacing(1)}; padding: ${({ theme }) => theme.spacing(1)};
justify-content: space-between;
${({ onClick, theme }) => &:hover {
!isUndefinedOrNull(onClick) cursor: pointer;
? `&:hover { background-color: ${({ theme }) => theme.background.transparent.light};
cursor: pointer; }
background-color:${theme.background.transparent.light};
}`
: ''}
`; `;
const StyledLabel = styled.div`
flex-grow: 1;
`;
type StyledRightIconProps = {
isMobile: boolean;
};
const StyledRightIcon = styled.div<StyledRightIconProps>`
cursor: pointer;
margin-left: ${({ theme }) => theme.spacing(2)};
transition: opacity 150ms ease-in-out;
opacity: ${({ isMobile }) => (isMobile ? 1 : 0)};
display: flex;
align-items: center;
justify-content: center;
border-radius: ${({ theme }) => theme.border.radius.sm};
width: ${({ theme }) => theme.spacing(5)};
height: ${({ theme }) => theme.spacing(5)};
:hover {
background: ${({ theme }) => theme.background.transparent.light};
}
.section-title-container:hover & {
opacity: 1;
}
&:active {
cursor: pointer;
}
`;
type NavigationDrawerSectionTitleProps = {
onClick?: () => void;
onRightIconClick?: () => void;
label: string;
rightIcon?: React.ReactNode;
};
export const NavigationDrawerSectionTitle = ({ export const NavigationDrawerSectionTitle = ({
onClick, onClick,
onRightIconClick,
label, label,
rightIcon,
}: NavigationDrawerSectionTitleProps) => { }: NavigationDrawerSectionTitleProps) => {
const currentUser = useRecoilValue(currentUserState); const isMobile = useIsMobile();
const loading = useIsPrefetchLoading();
const isNavigationDrawerExpanded = useRecoilValue( const isNavigationDrawerExpanded = useRecoilValue(
isNavigationDrawerExpandedState, isNavigationDrawerExpandedState,
); );
const isSettingsPage = useIsSettingsPage(); const isSettingsPage = useIsSettingsPage();
const currentUser = useRecoilValue(currentUserState);
const loading = useIsPrefetchLoading();
const handleTitleClick = (e: React.MouseEvent<HTMLDivElement>) => {
e.stopPropagation();
if (isDefined(onClick) && (isNavigationDrawerExpanded || isSettingsPage)) {
onClick();
}
};
const handleRightIconClick = (e: React.MouseEvent<HTMLDivElement>) => {
e.stopPropagation();
if (isDefined(onRightIconClick)) {
onRightIconClick();
}
};
if (loading && isDefined(currentUser)) { if (loading && isDefined(currentUser)) {
return <NavigationDrawerSectionTitleSkeletonLoader />; return <NavigationDrawerSectionTitleSkeletonLoader />;
} }
return ( return (
<StyledTitle <StyledTitle className="section-title-container" onClick={handleTitleClick}>
onClick={ <StyledLabel>{label}</StyledLabel>
isNavigationDrawerExpanded || isSettingsPage ? onClick : undefined {rightIcon && (
} <StyledRightIcon isMobile={isMobile} onClick={handleRightIconClick}>
> {rightIcon}
{label} </StyledRightIcon>
)}
</StyledTitle> </StyledTitle>
); );
}; };

View File

@ -17,6 +17,8 @@ export const NavigationDrawerSubItem = ({
count, count,
keyboard, keyboard,
subItemState, subItemState,
rightOptions,
isDraggable,
}: NavigationDrawerSubItemProps) => { }: NavigationDrawerSubItemProps) => {
return ( return (
<NavigationDrawerItem <NavigationDrawerItem
@ -32,6 +34,8 @@ export const NavigationDrawerSubItem = ({
soon={soon} soon={soon}
count={count} count={count}
keyboard={keyboard} keyboard={keyboard}
rightOptions={rightOptions}
isDraggable={isDraggable}
/> />
); );
}; };

View File

@ -22,7 +22,7 @@ import { SettingsPath } from '@/types/SettingsPath';
import { ComponentWithRouterDecorator } from '~/testing/decorators/ComponentWithRouterDecorator'; import { ComponentWithRouterDecorator } from '~/testing/decorators/ComponentWithRouterDecorator';
import { SnackBarDecorator } from '~/testing/decorators/SnackBarDecorator'; import { SnackBarDecorator } from '~/testing/decorators/SnackBarDecorator';
import { CurrentWorkspaceMemberFavorites } from '@/favorites/components/CurrentWorkspaceMemberFavorites'; import { CurrentWorkspaceMemberFavoritesFolders } from '@/favorites/components/CurrentWorkspaceMemberFavoritesFolders';
import { NavigationDrawerSubItem } from '@/ui/navigation/navigation-drawer/components/NavigationDrawerSubItem'; import { NavigationDrawerSubItem } from '@/ui/navigation/navigation-drawer/components/NavigationDrawerSubItem';
import jsonPage from '../../../../../../../package.json'; import jsonPage from '../../../../../../../package.json';
import { NavigationDrawer } from '../NavigationDrawer'; import { NavigationDrawer } from '../NavigationDrawer';
@ -71,7 +71,7 @@ export const Default: Story = {
/> />
</NavigationDrawerSection> </NavigationDrawerSection>
<CurrentWorkspaceMemberFavorites /> <CurrentWorkspaceMemberFavoritesFolders />
<NavigationDrawerSection> <NavigationDrawerSection>
<NavigationDrawerSectionTitle label="Workspace" /> <NavigationDrawerSectionTitle label="Workspace" />

View File

@ -1,6 +1,6 @@
import { NavigationDrawerSubItemState } from '@/ui/navigation/navigation-drawer/types/NavigationDrawerSubItemState'; import { NavigationDrawerSubItemState } from '@/ui/navigation/navigation-drawer/types/NavigationDrawerSubItemState';
export const getNavigationSubItemState = ({ export const getNavigationSubItemLeftAdornment = ({
index, index,
arrayLength, arrayLength,
selectedIndex, selectedIndex,

View File

@ -16,4 +16,5 @@ export type FeatureFlagKey =
| 'IS_JSON_FILTER_ENABLED' | 'IS_JSON_FILTER_ENABLED'
| 'IS_MICROSOFT_SYNC_ENABLED' | 'IS_MICROSOFT_SYNC_ENABLED'
| 'IS_ADVANCED_FILTERS_ENABLED' | 'IS_ADVANCED_FILTERS_ENABLED'
| 'IS_AGGREGATE_QUERY_ENABLED'; | 'IS_AGGREGATE_QUERY_ENABLED'
| 'IS_FAVORITE_FOLDER_ENABLED';

View File

@ -30,9 +30,9 @@ export const RecordShowPage = () => {
pageTitle, pageTitle,
pageName, pageName,
isFavorite, isFavorite,
handleFavoriteButtonClick,
record, record,
objectMetadataItem, objectMetadataItem,
handleFavoriteButtonClick,
} = useRecordShowPage( } = useRecordShowPage(
parameters.objectNameSingular ?? '', parameters.objectNameSingular ?? '',
parameters.objectRecordId ?? '', parameters.objectRecordId ?? '',
@ -65,15 +65,17 @@ export const RecordShowPage = () => {
workflowVersionId={objectRecordId} workflowVersionId={objectRecordId}
/> />
) : ( ) : (
<RecordShowActionMenu <>
{...{ <RecordShowActionMenu
isFavorite, {...{
handleFavoriteButtonClick, isFavorite,
record, record,
objectMetadataItem, handleFavoriteButtonClick,
objectNameSingular, objectMetadataItem,
}} objectNameSingular,
/> }}
/>
</>
)} )}
</> </>
</RecordShowPageHeader> </RecordShowPageHeader>

View File

@ -1,28 +1,47 @@
import { FAVORITE_FOLDER_PICKER_DROPDOWN_ID } from '@/favorites/favorite-folder-picker/constants/FavoriteFolderPickerDropdownId';
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { ObjectRecord } from '@/object-record/types/ObjectRecord'; import { ObjectRecord } from '@/object-record/types/ObjectRecord';
import { PageFavoriteButton } from '@/ui/layout/page/components/PageFavoriteButton'; import { PageFavoriteButton } from '@/ui/layout/page/components/PageFavoriteButton';
import { PageFavoriteFoldersDropdown } from '@/ui/layout/page/components/PageFavoriteFolderDropdown';
import { ShowPageAddButton } from '@/ui/layout/show-page/components/ShowPageAddButton'; import { ShowPageAddButton } from '@/ui/layout/show-page/components/ShowPageAddButton';
import { ShowPageMoreButton } from '@/ui/layout/show-page/components/ShowPageMoreButton'; import { ShowPageMoreButton } from '@/ui/layout/show-page/components/ShowPageMoreButton';
import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
export const RecordShowPageBaseHeader = ({ type RecordShowPageBaseHeaderProps = {
isFavorite,
handleFavoriteButtonClick,
record,
objectMetadataItem,
objectNameSingular,
}: {
isFavorite: boolean; isFavorite: boolean;
handleFavoriteButtonClick: () => void;
record: ObjectRecord | undefined; record: ObjectRecord | undefined;
objectMetadataItem: ObjectMetadataItem; objectMetadataItem: ObjectMetadataItem;
objectNameSingular: string; objectNameSingular: string;
}) => { handleFavoriteButtonClick: () => void;
};
export const RecordShowPageBaseHeader = ({
isFavorite,
record,
objectMetadataItem,
objectNameSingular,
handleFavoriteButtonClick,
}: RecordShowPageBaseHeaderProps) => {
const isFavoriteFolderEnabled = useIsFeatureEnabled(
'IS_FAVORITE_FOLDER_ENABLED',
);
return ( return (
<> <>
<PageFavoriteButton {isFavoriteFolderEnabled ? (
isFavorite={isFavorite} <PageFavoriteFoldersDropdown
onClick={handleFavoriteButtonClick} key={FAVORITE_FOLDER_PICKER_DROPDOWN_ID}
/> dropdownId={FAVORITE_FOLDER_PICKER_DROPDOWN_ID}
isFavorite={isFavorite}
record={record}
objectNameSingular={objectNameSingular}
/>
) : (
<PageFavoriteButton
isFavorite={isFavorite}
onClick={handleFavoriteButtonClick}
/>
)}
<ShowPageAddButton <ShowPageAddButton
key="add" key="add"
activityTargetObject={{ activityTargetObject={{

View File

@ -11,9 +11,13 @@ export const PrefetchLoadedDecorator: Decorator = (Story) => {
const setAreFavoritesPrefetched = useSetRecoilState( const setAreFavoritesPrefetched = useSetRecoilState(
prefetchIsLoadedFamilyState(PrefetchKey.AllFavorites), prefetchIsLoadedFamilyState(PrefetchKey.AllFavorites),
); );
const setAreFavoritesFoldersPrefetched = useSetRecoilState(
prefetchIsLoadedFamilyState(PrefetchKey.AllFavoritesFolders),
);
setAreViewsPrefetched(true); setAreViewsPrefetched(true);
setAreFavoritesPrefetched(true); setAreFavoritesPrefetched(true);
setAreFavoritesFoldersPrefetched(true);
return <Story />; return <Story />;
}; };

View File

@ -225,6 +225,15 @@ export const graphqlMocks = {
endCursor: null, endCursor: null,
}, },
}, },
favoriteFolders: {
edges: [],
pageInfo: {
hasNextPage: false,
hasPreviousPage: false,
startCursor: null,
endCursor: null,
},
},
views: { views: {
edges: mockedViewsData.map((view) => ({ edges: mockedViewsData.map((view) => ({
node: { node: {

View File

@ -85,6 +85,16 @@ export const seedFeatureFlags = async (
workspaceId: workspaceId, workspaceId: workspaceId,
value: false, value: false,
}, },
{
key: FeatureFlagKey.IsFavoriteFolderEnabled,
workspaceId: workspaceId,
value: true,
},
{
key: FeatureFlagKey.IsFavoriteFolderEntityEnabled,
workspaceId: workspaceId,
value: true,
},
]) ])
.execute(); .execute();
}; };

View File

@ -15,4 +15,6 @@ export enum FeatureFlagKey {
IsMicrosoftSyncEnabled = 'IS_MICROSOFT_SYNC_ENABLED', IsMicrosoftSyncEnabled = 'IS_MICROSOFT_SYNC_ENABLED',
IsAdvancedFiltersEnabled = 'IS_ADVANCED_FILTERS_ENABLED', IsAdvancedFiltersEnabled = 'IS_ADVANCED_FILTERS_ENABLED',
IsAggregateQueryEnabled = 'IS_AGGREGATE_QUERY_ENABLED', IsAggregateQueryEnabled = 'IS_AGGREGATE_QUERY_ENABLED',
IsFavoriteFolderEnabled = 'IS_FAVORITE_FOLDER_ENABLED',
IsFavoriteFolderEntityEnabled = 'IS_FAVORITE_FOLDER_ENTITY_ENABLED',
} }

View File

@ -217,6 +217,13 @@ export const FAVORITE_STANDARD_FIELD_IDS = {
note: '20202020-1f25-43fe-8b00-af212fdde824', note: '20202020-1f25-43fe-8b00-af212fdde824',
view: '20202020-5a93-4fa9-acce-e73481a0bbdf', view: '20202020-5a93-4fa9-acce-e73481a0bbdf',
custom: '20202020-855a-4bc8-9861-79deef37011f', custom: '20202020-855a-4bc8-9861-79deef37011f',
favoriteFolder: '20202020-f658-4d12-8b4d-248356aa4bd9',
};
export const FAVORITE_FOLDER_STANDARD_FIELD_IDS = {
position: '20202020-5278-4bde-8909-2cec74d43744',
name: '20202020-82a3-4537-8ff0-dbce7eec35d6',
favorites: '20202020-b5e3-4b42-8af2-03cd4fd2e4d2',
}; };
export const MESSAGE_CHANNEL_MESSAGE_ASSOCIATION_STANDARD_FIELD_IDS = { export const MESSAGE_CHANNEL_MESSAGE_ASSOCIATION_STANDARD_FIELD_IDS = {

View File

@ -21,6 +21,7 @@ export const STANDARD_OBJECT_IDS = {
company: '20202020-b374-4779-a561-80086cb2e17f', company: '20202020-b374-4779-a561-80086cb2e17f',
connectedAccount: '20202020-977e-46b2-890b-c3002ddfd5c5', connectedAccount: '20202020-977e-46b2-890b-c3002ddfd5c5',
favorite: '20202020-ab56-4e05-92a3-e2414a499860', favorite: '20202020-ab56-4e05-92a3-e2414a499860',
favoriteFolder: '20202020-7cf8-401f-8211-a9587d27fd2d',
auditLog: '20202020-0566-476a-b4c4-a0f9781bd80a', auditLog: '20202020-0566-476a-b4c4-a0f9781bd80a',
messageChannelMessageAssociation: '20202020-ad1e-4127-bccb-d83ae04d2ccb', messageChannelMessageAssociation: '20202020-ad1e-4127-bccb-d83ae04d2ccb',
messageChannel: '20202020-fe8c-40bc-a681-b80b771449b7', messageChannel: '20202020-fe8c-40bc-a681-b80b771449b7',

View File

@ -7,6 +7,7 @@ import { CalendarEventParticipantWorkspaceEntity } from 'src/modules/calendar/co
import { CalendarEventWorkspaceEntity } from 'src/modules/calendar/common/standard-objects/calendar-event.workspace-entity'; import { CalendarEventWorkspaceEntity } from 'src/modules/calendar/common/standard-objects/calendar-event.workspace-entity';
import { CompanyWorkspaceEntity } from 'src/modules/company/standard-objects/company.workspace-entity'; import { CompanyWorkspaceEntity } from 'src/modules/company/standard-objects/company.workspace-entity';
import { ConnectedAccountWorkspaceEntity } from 'src/modules/connected-account/standard-objects/connected-account.workspace-entity'; import { ConnectedAccountWorkspaceEntity } from 'src/modules/connected-account/standard-objects/connected-account.workspace-entity';
import { FavoriteFolderWorkspaceEntity } from 'src/modules/favorite-folder/standard-objects/favorite-folder.workspace-entity';
import { FavoriteWorkspaceEntity } from 'src/modules/favorite/standard-objects/favorite.workspace-entity'; import { FavoriteWorkspaceEntity } from 'src/modules/favorite/standard-objects/favorite.workspace-entity';
import { MessageChannelMessageAssociationWorkspaceEntity } from 'src/modules/messaging/common/standard-objects/message-channel-message-association.workspace-entity'; import { MessageChannelMessageAssociationWorkspaceEntity } from 'src/modules/messaging/common/standard-objects/message-channel-message-association.workspace-entity';
import { MessageChannelWorkspaceEntity } from 'src/modules/messaging/common/standard-objects/message-channel.workspace-entity'; import { MessageChannelWorkspaceEntity } from 'src/modules/messaging/common/standard-objects/message-channel.workspace-entity';
@ -50,6 +51,7 @@ export const standardObjectMetadataDefinitions = [
CompanyWorkspaceEntity, CompanyWorkspaceEntity,
ConnectedAccountWorkspaceEntity, ConnectedAccountWorkspaceEntity,
FavoriteWorkspaceEntity, FavoriteWorkspaceEntity,
FavoriteFolderWorkspaceEntity,
TimelineActivityWorkspaceEntity, TimelineActivityWorkspaceEntity,
ViewFieldWorkspaceEntity, ViewFieldWorkspaceEntity,
ViewGroupWorkspaceEntity, ViewGroupWorkspaceEntity,

View File

@ -0,0 +1,11 @@
import { Module } from '@nestjs/common';
import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module';
import { TwentyORMModule } from 'src/engine/twenty-orm/twenty-orm.module';
import { FavoriteFolderDeletionListener } from 'src/modules/favorite-folder/listeners/favorite-folder.listener';
@Module({
imports: [TwentyORMModule, FeatureFlagModule],
providers: [FavoriteFolderDeletionListener],
})
export class FavoriteFolderModule {}

View File

@ -0,0 +1,48 @@
import { Injectable } from '@nestjs/common';
import { OnEvent } from '@nestjs/event-emitter';
import { ObjectRecordDeleteEvent } from 'src/engine/core-modules/event-emitter/types/object-record-delete.event';
import { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/feature-flag-key.enum';
import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service';
import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager';
import { WorkspaceEventBatch } from 'src/engine/workspace-event-emitter/workspace-event.type';
import { FavoriteFolderWorkspaceEntity } from 'src/modules/favorite-folder/standard-objects/favorite-folder.workspace-entity';
import { FavoriteWorkspaceEntity } from 'src/modules/favorite/standard-objects/favorite.workspace-entity';
@Injectable()
export class FavoriteFolderDeletionListener {
constructor(
private readonly twentyORMGlobalManager: TwentyORMGlobalManager,
private readonly featureFlagService: FeatureFlagService,
) {}
@OnEvent('favoriteFolder.deleted')
async handleDelete(
payload: WorkspaceEventBatch<
ObjectRecordDeleteEvent<FavoriteFolderWorkspaceEntity>
>,
) {
const isFavoriteFolderEntityEnabled =
await this.featureFlagService.isFeatureEnabled(
FeatureFlagKey.IsFavoriteFolderEntityEnabled,
payload.workspaceId,
);
if (!isFavoriteFolderEntityEnabled) {
return;
}
for (const eventPayload of payload.events) {
const favoriteRepository =
await this.twentyORMGlobalManager.getRepositoryForWorkspace<FavoriteWorkspaceEntity>(
payload.workspaceId,
'favorite',
);
await favoriteRepository.update(
{ favoriteFolderId: eventPayload.recordId },
{ deletedAt: new Date().toISOString() },
);
}
}
}

View File

@ -0,0 +1,58 @@
import { Relation } from 'src/engine/workspace-manager/workspace-sync-metadata/interfaces/relation.interface';
import { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/feature-flag-key.enum';
import { FieldMetadataType } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity';
import { RelationMetadataType } from 'src/engine/metadata-modules/relation-metadata/relation-metadata.entity';
import { BaseWorkspaceEntity } from 'src/engine/twenty-orm/base.workspace-entity';
import { WorkspaceEntity } from 'src/engine/twenty-orm/decorators/workspace-entity.decorator';
import { WorkspaceField } from 'src/engine/twenty-orm/decorators/workspace-field.decorator';
import { WorkspaceGate } from 'src/engine/twenty-orm/decorators/workspace-gate.decorator';
import { WorkspaceIsSystem } from 'src/engine/twenty-orm/decorators/workspace-is-system.decorator';
import { WorkspaceRelation } from 'src/engine/twenty-orm/decorators/workspace-relation.decorator';
import { FAVORITE_FOLDER_STANDARD_FIELD_IDS } from 'src/engine/workspace-manager/workspace-sync-metadata/constants/standard-field-ids';
import { STANDARD_OBJECT_IDS } from 'src/engine/workspace-manager/workspace-sync-metadata/constants/standard-object-ids';
import { FavoriteWorkspaceEntity } from 'src/modules/favorite/standard-objects/favorite.workspace-entity';
@WorkspaceEntity({
standardId: STANDARD_OBJECT_IDS.favoriteFolder,
namePlural: 'favoriteFolders',
labelSingular: 'Favorite Folder',
labelPlural: 'Favorite Folders',
description: 'A Folder of favorites',
icon: 'IconFolder',
})
@WorkspaceIsSystem()
@WorkspaceGate({
featureFlag: FeatureFlagKey.IsFavoriteFolderEntityEnabled,
})
export class FavoriteFolderWorkspaceEntity extends BaseWorkspaceEntity {
@WorkspaceField({
standardId: FAVORITE_FOLDER_STANDARD_FIELD_IDS.position,
type: FieldMetadataType.NUMBER,
label: 'Position',
description: 'Favorite folder position',
icon: 'IconList',
defaultValue: 0,
})
position: number;
@WorkspaceField({
standardId: FAVORITE_FOLDER_STANDARD_FIELD_IDS.name,
type: FieldMetadataType.TEXT,
label: 'Name',
description: 'Name of the favorite folder',
icon: 'IconText',
})
name: string;
@WorkspaceRelation({
standardId: FAVORITE_FOLDER_STANDARD_FIELD_IDS.favorites,
type: RelationMetadataType.ONE_TO_MANY,
label: 'Favorites',
description: 'Favorites in this folder',
icon: 'IconHeart',
inverseSideFieldKey: 'favoriteFolder',
inverseSideTarget: () => FavoriteWorkspaceEntity,
})
favorites: Relation<FavoriteWorkspaceEntity[]>;
}

View File

@ -18,6 +18,7 @@ import { FAVORITE_STANDARD_FIELD_IDS } from 'src/engine/workspace-manager/worksp
import { STANDARD_OBJECT_ICONS } from 'src/engine/workspace-manager/workspace-sync-metadata/constants/standard-object-icons'; import { STANDARD_OBJECT_ICONS } from 'src/engine/workspace-manager/workspace-sync-metadata/constants/standard-object-icons';
import { STANDARD_OBJECT_IDS } from 'src/engine/workspace-manager/workspace-sync-metadata/constants/standard-object-ids'; import { STANDARD_OBJECT_IDS } from 'src/engine/workspace-manager/workspace-sync-metadata/constants/standard-object-ids';
import { CompanyWorkspaceEntity } from 'src/modules/company/standard-objects/company.workspace-entity'; import { CompanyWorkspaceEntity } from 'src/modules/company/standard-objects/company.workspace-entity';
import { FavoriteFolderWorkspaceEntity } from 'src/modules/favorite-folder/standard-objects/favorite-folder.workspace-entity';
import { NoteWorkspaceEntity } from 'src/modules/note/standard-objects/note.workspace-entity'; import { NoteWorkspaceEntity } from 'src/modules/note/standard-objects/note.workspace-entity';
import { OpportunityWorkspaceEntity } from 'src/modules/opportunity/standard-objects/opportunity.workspace-entity'; import { OpportunityWorkspaceEntity } from 'src/modules/opportunity/standard-objects/opportunity.workspace-entity';
import { PersonWorkspaceEntity } from 'src/modules/person/standard-objects/person.workspace-entity'; import { PersonWorkspaceEntity } from 'src/modules/person/standard-objects/person.workspace-entity';
@ -95,6 +96,27 @@ export class FavoriteWorkspaceEntity extends BaseWorkspaceEntity {
@WorkspaceJoinColumn('company') @WorkspaceJoinColumn('company')
companyId: string; companyId: string;
@WorkspaceRelation({
standardId: FAVORITE_STANDARD_FIELD_IDS.favoriteFolder,
type: RelationMetadataType.MANY_TO_ONE,
label: 'Favorite Folder',
description: 'The folder this favorite belongs to',
icon: 'IconFolder',
inverseSideTarget: () => FavoriteFolderWorkspaceEntity,
inverseSideFieldKey: 'favorites',
})
@WorkspaceGate({
featureFlag: FeatureFlagKey.IsFavoriteFolderEntityEnabled,
})
@WorkspaceIsNullable()
favoriteFolder: Relation<FavoriteFolderWorkspaceEntity> | null;
@WorkspaceJoinColumn('favoriteFolder')
@WorkspaceGate({
featureFlag: FeatureFlagKey.IsFavoriteFolderEntityEnabled,
})
favoriteFolderId: string;
@WorkspaceRelation({ @WorkspaceRelation({
standardId: FAVORITE_STANDARD_FIELD_IDS.opportunity, standardId: FAVORITE_STANDARD_FIELD_IDS.opportunity,
type: RelationMetadataType.MANY_TO_ONE, type: RelationMetadataType.MANY_TO_ONE,

View File

@ -2,6 +2,7 @@ import { Module } from '@nestjs/common';
import { CalendarModule } from 'src/modules/calendar/calendar.module'; import { CalendarModule } from 'src/modules/calendar/calendar.module';
import { ConnectedAccountModule } from 'src/modules/connected-account/connected-account.module'; import { ConnectedAccountModule } from 'src/modules/connected-account/connected-account.module';
import { FavoriteFolderModule } from 'src/modules/favorite-folder/favorite-folder.module';
import { MessagingModule } from 'src/modules/messaging/messaging.module'; import { MessagingModule } from 'src/modules/messaging/messaging.module';
import { ViewModule } from 'src/modules/view/view.module'; import { ViewModule } from 'src/modules/view/view.module';
import { WorkflowModule } from 'src/modules/workflow/workflow.module'; import { WorkflowModule } from 'src/modules/workflow/workflow.module';
@ -13,6 +14,7 @@ import { WorkflowModule } from 'src/modules/workflow/workflow.module';
ConnectedAccountModule, ConnectedAccountModule,
ViewModule, ViewModule,
WorkflowModule, WorkflowModule,
FavoriteFolderModule,
], ],
providers: [], providers: [],
exports: [], exports: [],

View File

@ -110,11 +110,7 @@ export const Avatar = ({
<StyledAvatar <StyledAvatar
size={size} size={size}
backgroundColor={ backgroundColor={
Icon Icon ? 'inherit' : showBackgroundColor ? fixedBackgroundColor : 'none'
? theme.background.tertiary
: showBackgroundColor
? fixedBackgroundColor
: 'none'
} }
color={fixedColor} color={fixedColor}
clickable={!isUndefined(onClick)} clickable={!isUndefined(onClick)}

View File

@ -131,6 +131,8 @@ export {
IconFilterCog, IconFilterCog,
IconFilterOff, IconFilterOff,
IconFocusCentered, IconFocusCentered,
IconFolder,
IconFolderPlus,
IconForbid, IconForbid,
IconFunction, IconFunction,
IconGauge, IconGauge,