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
100 changed files with 24033 additions and 21488 deletions

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,
});