Просмотр исходного кода

Merge pull request #6934 from weseek/feat/gw7905-implement-action-button-to-rename-and-delete-bookmark-folder

feat : gw7905 implement action button to rename and delete bookmark folder
Kaori Tokashiki 3 лет назад
Родитель
Сommit
1a436f59d9

+ 13 - 1
packages/app/public/static/locales/en_US/translation.json

@@ -540,7 +540,8 @@
     "issue_share_link": "Succeeded to issue new share link",
     "issue_share_link": "Succeeded to issue new share link",
     "remove_share_link": "Succeeded to remove {{count}} share links",
     "remove_share_link": "Succeeded to remove {{count}} share links",
     "switch_disable_link_sharing_success": "Succeeded to update share link setting",
     "switch_disable_link_sharing_success": "Succeeded to update share link setting",
-    "failed_to_reset_password":"Failed to reset password"
+    "failed_to_reset_password":"Failed to reset password",
+    "delete_succeeded": "Succeeded to delete {{target}}"
   },
   },
   "template": {
   "template": {
     "modal_label": {
     "modal_label": {
@@ -866,5 +867,16 @@
   "footer": {
   "footer": {
     "bookmarks": "Bookmarks",
     "bookmarks": "Bookmarks",
     "recently_created": "Recently Created"
     "recently_created": "Recently Created"
+  },
+  "bookmark_folder":{
+    "bookmark_folder": "bookmark folder",
+    "delete_modal": {
+      "modal_header_label": "Delete Bookmark Folder",
+      "modal_body_description": "Delete this bookmark folder and its contents",
+      "modal_body_alert": "Deleted folder and its contents cannot be recovered",
+      "modal_footer_button": "Delete Folder"
+    },
+    "input_placeholder": "Input folder name",
+    "new_folder": "New Folder"
   }
   }
 }
 }

+ 13 - 1
packages/app/public/static/locales/ja_JP/translation.json

@@ -531,7 +531,8 @@
     "issue_share_link": "共有リンクを作成しました",
     "issue_share_link": "共有リンクを作成しました",
     "remove_share_link": "共有リンクを{{count}}件削除しました",
     "remove_share_link": "共有リンクを{{count}}件削除しました",
     "switch_disable_link_sharing_success": "共有リンクの設定を変更しました",
     "switch_disable_link_sharing_success": "共有リンクの設定を変更しました",
-    "failed_to_reset_password":"パスワードのリセットに失敗しました"
+    "failed_to_reset_password":"パスワードのリセットに失敗しました",
+    "delete_succeeded": "{{target}} の削除に成功しました"
   },
   },
   "template": {
   "template": {
     "modal_label": {
     "modal_label": {
@@ -857,5 +858,16 @@
   "footer": {
   "footer": {
     "bookmarks": "ブックマーク",
     "bookmarks": "ブックマーク",
     "recently_created": "最近作成したページ"
     "recently_created": "最近作成したページ"
+  },
+  "bookmark_folder":{
+    "bookmark_folder": "ブックマークフォルダ",
+    "delete_modal": {
+      "modal_header_label": "ブックマークフォルダを削除",
+      "modal_body_description": "このブックマーク フォルダとその内容を削除する",
+      "modal_body_alert": "削除されたフォルダとその内容は復元できません",
+      "modal_footer_button": "フォルダを削除"
+    },
+    "input_placeholder": "フォルダ名を入力してください`",
+    "new_folder": "新しいフォルダ"
   }
   }
 }
 }

+ 13 - 1
packages/app/public/static/locales/zh_CN/translation.json

@@ -510,7 +510,8 @@
 		"remove_user_success": "Succeeded to removing {{username}} ",
 		"remove_user_success": "Succeeded to removing {{username}} ",
     "remove_external_user_success": "Succeeded to remove {{accountId}} ",
     "remove_external_user_success": "Succeeded to remove {{accountId}} ",
     "switch_disable_link_sharing_success": "成功更新分享链接设置",
     "switch_disable_link_sharing_success": "成功更新分享链接设置",
-    "failed_to_reset_password":"Failed to reset password"
+    "failed_to_reset_password":"Failed to reset password",
+    "delete_succeeded": "Succeeded to delete {{target}}"
   },
   },
 	"template": {
 	"template": {
 		"modal_label": {
 		"modal_label": {
@@ -913,5 +914,16 @@
   "footer": {
   "footer": {
     "bookmarks": "书签",
     "bookmarks": "书签",
     "recently_created": "最近创建页面"
     "recently_created": "最近创建页面"
+  },
+  "bookmark_folder":{
+    "bookmark_folder": "书签文件夹",
+    "delete_modal": {
+      "modal_header_label": "删除书签文件夹",
+      "modal_body_description": "删除此书签文件夹及其内容",
+      "modal_body_alert": "已删除的文件夹及其内容无法恢复",
+      "modal_footer_button": "删除文件夹"
+    },
+    "input_placeholder": "输入文件夹名称",
+    "new_folder": "新建文件夹"
   }
   }
 }
 }

+ 8 - 70
packages/app/src/components/Sidebar/Bookmarks.tsx

@@ -3,88 +3,26 @@ import React from 'react';
 
 
 import { useTranslation } from 'react-i18next';
 import { useTranslation } from 'react-i18next';
 
 
-import { toastSuccess } from '~/client/util/apiNotification';
-import { IPageToDeleteWithMeta } from '~/interfaces/page';
-import { OnDeletedFunction } from '~/interfaces/ui';
-import { useSWRxCurrentUserBookmarks } from '~/stores/bookmark';
 import { useIsGuestUser } from '~/stores/context';
 import { useIsGuestUser } from '~/stores/context';
-import { usePageDeleteModal } from '~/stores/modal';
 
 
-
-import BookmarkFolder from './Bookmarks/BookmarkFolder';
-import BookmarkItem from './Bookmarks/BookmarkItem';
+import BookamrkContents from './Bookmarks/BookmarkContents';
 
 
 const Bookmarks = () : JSX.Element => {
 const Bookmarks = () : JSX.Element => {
   const { t } = useTranslation();
   const { t } = useTranslation();
   const { data: isGuestUser } = useIsGuestUser();
   const { data: isGuestUser } = useIsGuestUser();
-  const { data: currentUserBookmarksData, mutate: mutateCurrentUserBookmarks } = useSWRxCurrentUserBookmarks();
-  const { open: openDeleteModal } = usePageDeleteModal();
-
-
-  const deleteMenuItemClickHandler = (pageToDelete: IPageToDeleteWithMeta) => {
-    const pageDeletedHandler : OnDeletedFunction = (pathOrPathsToDelete, _isRecursively, isCompletely) => {
-      if (typeof pathOrPathsToDelete !== 'string') {
-        return;
-      }
-      const path = pathOrPathsToDelete;
-
-      if (isCompletely) {
-        toastSuccess(t('deleted_pages_completely', { path }));
-      }
-      else {
-        toastSuccess(t('deleted_pages', { path }));
-      }
-      mutateCurrentUserBookmarks();
-    };
-    openDeleteModal([pageToDelete], { onDeleted: pageDeletedHandler });
-  };
-
-
-  const renderBookmarkList = () => {
-    if (currentUserBookmarksData?.length === 0) {
-      return (
-        <h4 className="pl-3">
-          { t('No bookmarks yet') }
-        </h4>
-      );
-    }
-    return (
-      <ul className="grw-bookmarks-list list-group p-3">
-        <div className="grw-bookmarks-item-container">
-          { currentUserBookmarksData?.map((currentUserBookmark) => {
-            return (
-              <BookmarkItem
-                key={currentUserBookmark._id}
-                bookmarkedPage={currentUserBookmark}
-                onUnbookmarked={mutateCurrentUserBookmarks}
-                onRenamed={mutateCurrentUserBookmarks}
-                onClickDeleteMenuItem={deleteMenuItemClickHandler}
-              />
-            );
-          })}
-        </div>
-      </ul>
-    );
-  };
 
 
   return (
   return (
     <>
     <>
       <div className="grw-sidebar-content-header p-3">
       <div className="grw-sidebar-content-header p-3">
         <h3 className="mb-0">{t('Bookmarks')}</h3>
         <h3 className="mb-0">{t('Bookmarks')}</h3>
       </div>
       </div>
-      {!isGuestUser && (
-        <>
-          <BookmarkFolder />
-        </>
-      )
-      }
-      { isGuestUser
-        ? (
-          <h4 className="pl-3">
-            { t('Not available for guest') }
-          </h4>
-        ) : renderBookmarkList()
-      }
+      {isGuestUser ? (
+        <h4 className="pl-3">
+          { t('Not available for guest') }
+        </h4>
+      ) : (
+        <BookamrkContents />
+      )}
     </>
     </>
   );
   );
 };
 };

+ 23 - 15
packages/app/src/components/Sidebar/Bookmarks/BookmarkFolder.tsx → packages/app/src/components/Sidebar/Bookmarks/BookmarkContents.tsx

@@ -11,24 +11,24 @@ import BookmarkFolderNameInput from './BookmarkFolderNameInput';
 import BookmarkFolderTree from './BookmarkFolderTree';
 import BookmarkFolderTree from './BookmarkFolderTree';
 
 
 
 
-const BookmarkFolder = (): JSX.Element => {
+const BookmarkContents = (): JSX.Element => {
 
 
   const { t } = useTranslation();
   const { t } = useTranslation();
-  const [isRenameInputShown, setIsRenameInputShown] = useState<boolean>(false);
+  const [isCreateAction, setIsCreateAction] = useState<boolean>(false);
   const { mutate: mutateChildBookmarkData } = useSWRxBookamrkFolderAndChild(null);
   const { mutate: mutateChildBookmarkData } = useSWRxBookamrkFolderAndChild(null);
 
 
 
 
-  const onClickBookmarkFolder = () => {
-    setIsRenameInputShown(true);
-  };
+  const onClickNewBookmarkFolder = useCallback(() => {
+    setIsCreateAction(true);
+  }, []);
 
 
-  const onPressEnterHandler = useCallback(async(folderName: string) => {
+  const onPressEnterHandlerForCreate = useCallback(async(folderName: string) => {
 
 
     try {
     try {
       await apiv3Post('/bookmark-folder', { name: folderName, parent: null });
       await apiv3Post('/bookmark-folder', { name: folderName, parent: null });
       await mutateChildBookmarkData();
       await mutateChildBookmarkData();
-      setIsRenameInputShown(false);
-      toastSuccess(t('Create New Bookmark Folder Success'));
+      setIsCreateAction(false);
+      toastSuccess(t('toaster.create_succeeded', { target: t('bookmark_folder.bookmark_folder') }));
     }
     }
     catch (err) {
     catch (err) {
       toastError(err);
       toastError(err);
@@ -36,30 +36,38 @@ const BookmarkFolder = (): JSX.Element => {
 
 
   }, [mutateChildBookmarkData, t]);
   }, [mutateChildBookmarkData, t]);
 
 
-  return (
+  const renderAddNewBookmarkFolder = useCallback(() => (
     <>
     <>
       <div className="col-8 mb-2 ">
       <div className="col-8 mb-2 ">
         <button
         <button
           className="btn btn-block btn-outline-secondary rounded-pill d-flex justify-content-start align-middle"
           className="btn btn-block btn-outline-secondary rounded-pill d-flex justify-content-start align-middle"
-          onClick={onClickBookmarkFolder}
+          onClick={onClickNewBookmarkFolder}
         >
         >
           <FolderPlusIcon />
           <FolderPlusIcon />
-          <span className="mx-2 ">New Folder</span>
+          <span className="mx-2 ">{t('bookmark_folder.new_folder')}</span>
         </button>
         </button>
       </div>
       </div>
       {
       {
-        isRenameInputShown && (
+        isCreateAction && (
           <div className="col-12 mb-2 ">
           <div className="col-12 mb-2 ">
             <BookmarkFolderNameInput
             <BookmarkFolderNameInput
-              onClickOutside={() => setIsRenameInputShown(false)}
-              onPressEnter={onPressEnterHandler}
+              onClickOutside={() => setIsCreateAction(false)}
+              onPressEnter={onPressEnterHandlerForCreate}
             />
             />
           </div>
           </div>
         )
         )
       }
       }
+    </>
+  ), [isCreateAction, onClickNewBookmarkFolder, onPressEnterHandlerForCreate, t]);
+
+  return (
+    <>
+      {
+        renderAddNewBookmarkFolder()
+      }
       <BookmarkFolderTree />
       <BookmarkFolderTree />
     </>
     </>
   );
   );
 };
 };
 
 
-export default BookmarkFolder;
+export default BookmarkContents;

+ 112 - 33
packages/app/src/components/Sidebar/Bookmarks/BookmarkFolderItem.tsx

@@ -3,16 +3,19 @@ import {
 } from 'react';
 } from 'react';
 
 
 import { useTranslation } from 'next-i18next';
 import { useTranslation } from 'next-i18next';
+import { DropdownToggle } from 'reactstrap';
 
 
 import { toastError, toastSuccess } from '~/client/util/apiNotification';
 import { toastError, toastSuccess } from '~/client/util/apiNotification';
-import { apiv3Post } from '~/client/util/apiv3-client';
+import { apiv3Delete, apiv3Post, apiv3Put } from '~/client/util/apiv3-client';
 import CountBadge from '~/components/Common/CountBadge';
 import CountBadge from '~/components/Common/CountBadge';
 import FolderIcon from '~/components/Icons/FolderIcon';
 import FolderIcon from '~/components/Icons/FolderIcon';
 import TriangleIcon from '~/components/Icons/TriangleIcon';
 import TriangleIcon from '~/components/Icons/TriangleIcon';
-import { BookmarkFolderItems } from '~/server/models/bookmark-folder';
+import { BookmarkFolderItems } from '~/interfaces/bookmark-info';
 import { useSWRxBookamrkFolderAndChild } from '~/stores/bookmark-folder';
 import { useSWRxBookamrkFolderAndChild } from '~/stores/bookmark-folder';
 
 
+import BookmarkFolderItemControl from './BookmarkFolderItemControl';
 import BookmarkFolderNameInput from './BookmarkFolderNameInput';
 import BookmarkFolderNameInput from './BookmarkFolderNameInput';
+import DeleteBookmarkFolderModal from './DeleteBookmarkFolderModal';
 
 
 
 
 type BookmarkFolderItemProps = {
 type BookmarkFolderItemProps = {
@@ -23,13 +26,17 @@ const BookmarkFolderItem: FC<BookmarkFolderItemProps> = (props: BookmarkFolderIt
   const { bookmarkFolder, isOpen: _isOpen = false } = props;
   const { bookmarkFolder, isOpen: _isOpen = false } = props;
 
 
   const { t } = useTranslation();
   const { t } = useTranslation();
-  const { name, _id: parentId, children } = bookmarkFolder;
+  const {
+    name, _id: folderId, children, parent,
+  } = bookmarkFolder;
   const [currentChildren, setCurrentChildren] = useState<BookmarkFolderItems[]>();
   const [currentChildren, setCurrentChildren] = useState<BookmarkFolderItems[]>();
-  const [isRenameInputShown, setIsRenameInputShown] = useState<boolean>(false);
-  const [currentParentFolder, setCurrentParentFolder] = useState<string | null>(parentId);
+  const [targetFolder, setTargetFolder] = useState<string | null>(folderId);
   const [isOpen, setIsOpen] = useState(_isOpen);
   const [isOpen, setIsOpen] = useState(_isOpen);
-  const { data: childBookmarkFolderData, mutate: mutateChildBookmarkData } = useSWRxBookamrkFolderAndChild(isOpen ? currentParentFolder : null);
-
+  const { data: childBookmarkFolderData, mutate: mutateChildBookmarkData } = useSWRxBookamrkFolderAndChild(targetFolder);
+  const { mutate: mutateParentBookmarkFolder } = useSWRxBookamrkFolderAndChild(parent);
+  const [isRenameAction, setIsRenameAction] = useState<boolean>(false);
+  const [isCreateAction, setIsCreateAction] = useState<boolean>(false);
+  const [isDeleteFolderModalShown, setIsDeleteFolderModalShown] = useState<boolean>(false);
 
 
   const childCount = useCallback((): number => {
   const childCount = useCallback((): number => {
     if (currentChildren != null && currentChildren.length > children.length) {
     if (currentChildren != null && currentChildren.length > children.length) {
@@ -39,11 +46,11 @@ const BookmarkFolderItem: FC<BookmarkFolderItemProps> = (props: BookmarkFolderIt
   }, [children.length, currentChildren]);
   }, [children.length, currentChildren]);
 
 
   useEffect(() => {
   useEffect(() => {
-    if (isOpen && childBookmarkFolderData != null) {
+    if (childBookmarkFolderData != null) {
       mutateChildBookmarkData();
       mutateChildBookmarkData();
       setCurrentChildren(childBookmarkFolderData);
       setCurrentChildren(childBookmarkFolderData);
     }
     }
-  }, [childBookmarkFolderData, isOpen, mutateChildBookmarkData]);
+  }, [childBookmarkFolderData, mutateChildBookmarkData]);
 
 
   const hasChildren = useCallback((): boolean => {
   const hasChildren = useCallback((): boolean => {
     if (currentChildren != null && currentChildren.length > children.length) {
     if (currentChildren != null && currentChildren.length > children.length) {
@@ -52,33 +59,72 @@ const BookmarkFolderItem: FC<BookmarkFolderItemProps> = (props: BookmarkFolderIt
     return children.length > 0;
     return children.length > 0;
   }, [children.length, currentChildren]);
   }, [children.length, currentChildren]);
 
 
-
   const loadChildFolder = useCallback(async() => {
   const loadChildFolder = useCallback(async() => {
     setIsOpen(!isOpen);
     setIsOpen(!isOpen);
-    setCurrentParentFolder(bookmarkFolder._id);
-  }, [bookmarkFolder, isOpen]);
+    setTargetFolder(folderId);
+  }, [folderId, isOpen]);
+
+  const loadParent = useCallback(async() => {
+    if (!isRenameAction) {
+      if (parent != null) {
+        await mutateParentBookmarkFolder();
+      }
+      // Reload root folder structure
+      setTargetFolder(null);
+    }
+    else {
+      await mutateParentBookmarkFolder();
+    }
 
 
+  }, [isRenameAction, mutateParentBookmarkFolder, parent]);
 
 
-  const onPressEnterHandler = useCallback(async(folderName: string) => {
+  // Rename  for bookmark folder handler
+  const onPressEnterHandlerForRename = useCallback(async(folderName: string) => {
+    try {
+      await apiv3Put('/bookmark-folder', { bookmarkFolderId: folderId, name: folderName, parent });
+      loadParent();
+      setIsRenameAction(false);
+      toastSuccess(t('toaster.update_successed', { target: t('bookmark_folder.bookmark_folder') }));
+    }
+    catch (err) {
+      toastError(err);
+    }
+  }, [folderId, loadParent, parent, t]);
 
 
+  // Create new folder / subfolder handler
+  const onPressEnterHandlerForCreate = useCallback(async(folderName: string) => {
     try {
     try {
-      await apiv3Post('/bookmark-folder', { name: folderName, parent: currentParentFolder });
+      await apiv3Post('/bookmark-folder', { name: folderName, parent: targetFolder });
       setIsOpen(true);
       setIsOpen(true);
-      setIsRenameInputShown(false);
+      setIsCreateAction(false);
       mutateChildBookmarkData();
       mutateChildBookmarkData();
-      toastSuccess(t('Create New Bookmark Folder Success'));
+      toastSuccess(t('toaster.create_succeeded', { target: t('bookmark_folder.bookmark_folder') }));
+
     }
     }
     catch (err) {
     catch (err) {
       toastError(err);
       toastError(err);
     }
     }
 
 
-  }, [currentParentFolder, mutateChildBookmarkData, t]);
+  }, [mutateChildBookmarkData, t, targetFolder]);
+
+  // Delete Fodler handler
+  const onClickDeleteButtonHandler = useCallback(async() => {
+    try {
+      await apiv3Delete(`/bookmark-folder/${folderId}`);
+      setIsDeleteFolderModalShown(false);
+      loadParent();
+      toastSuccess(t('toaster.delete_succeeded', { target: t('bookmark_folder.bookmark_folder') }));
+    }
+    catch (err) {
+      toastError(err);
+    }
+  }, [folderId, loadParent, t]);
 
 
   const onClickPlusButton = useCallback(async() => {
   const onClickPlusButton = useCallback(async() => {
     if (!isOpen && hasChildren()) {
     if (!isOpen && hasChildren()) {
       setIsOpen(true);
       setIsOpen(true);
     }
     }
-    setIsRenameInputShown(true);
+    setIsCreateAction(true);
   }, [hasChildren, isOpen]);
   }, [hasChildren, isOpen]);
 
 
   const renderChildFolder = () => {
   const renderChildFolder = () => {
@@ -94,9 +140,21 @@ const BookmarkFolderItem: FC<BookmarkFolderItemProps> = (props: BookmarkFolderIt
     });
     });
   };
   };
 
 
+  const onClickRenameHandler = useCallback(() => {
+    setIsRenameAction(true);
+  }, []);
+
+  const onClickDeleteHandler = useCallback(() => {
+    setIsDeleteFolderModalShown(true);
+  }, []);
+
+  const onDeleteFolderModalClose = useCallback(() => {
+    setIsDeleteFolderModalShown(false);
+  }, []);
+
 
 
   return (
   return (
-    <div id={`bookmark-folder-item-${bookmarkFolder._id}`} className="grw-foldertree-item-container"
+    <div id={`bookmark-folder-item-${folderId}`} className="grw-foldertree-item-container"
     >
     >
       <li className="list-group-item list-group-item-action border-0 py-0 pr-3 d-flex align-items-center">
       <li className="list-group-item list-group-item-action border-0 py-0 pr-3 d-flex align-items-center">
         <div className="grw-triangle-container d-flex justify-content-center">
         <div className="grw-triangle-container d-flex justify-content-center">
@@ -117,19 +175,35 @@ const BookmarkFolderItem: FC<BookmarkFolderItemProps> = (props: BookmarkFolderIt
             <FolderIcon isOpen={isOpen} />
             <FolderIcon isOpen={isOpen} />
           </div>
           </div>
         }
         }
-        {
-          <div className='grw-foldertree-title-anchor flex-grow-1 pl-2' onClick={loadChildFolder}>
-            <p className={'text-truncate m-auto '}>{name}</p>
-          </div>
+        { isRenameAction ? (
+          <BookmarkFolderNameInput
+            onClickOutside={() => setIsRenameAction(false)}
+            onPressEnter={onPressEnterHandlerForRename}
+            value={name}
+          />
+        ) : (
+          <>
+            <div className='grw-foldertree-title-anchor flex-grow-1 pl-2' onClick={loadChildFolder}>
+              <p className={'text-truncate m-auto '}>{name}</p>
+            </div>
+            {hasChildren() && (
+              <div className="grw-foldertree-count-wrapper">
+                <CountBadge count={ childCount() } />
+              </div>
+            )}
+          </>
+        )
+
         }
         }
-        {hasChildren() && (
-          <div className="grw-foldertree-count-wrapper">
-            <CountBadge count={ childCount() } />
-          </div>
-        )}
         <div className="grw-foldertree-control d-flex">
         <div className="grw-foldertree-control d-flex">
-
-
+          <BookmarkFolderItemControl
+            onClickRename={onClickRenameHandler}
+            onClickDelete={onClickDeleteHandler}
+          >
+            <DropdownToggle color="transparent" className="border-0 rounded btn-page-item-control p-0 grw-visible-on-hover mr-1">
+              <i className="icon-options fa fa-rotate-90 p-1"></i>
+            </DropdownToggle>
+          </BookmarkFolderItemControl>
           <button
           <button
             type="button"
             type="button"
             className="border-0 rounded btn btn-page-item-control p-0 grw-visible-on-hover"
             className="border-0 rounded btn btn-page-item-control p-0 grw-visible-on-hover"
@@ -141,17 +215,22 @@ const BookmarkFolderItem: FC<BookmarkFolderItemProps> = (props: BookmarkFolderIt
         </div>
         </div>
 
 
       </li>
       </li>
-      {isRenameInputShown && (
+      {isCreateAction && (
         <div className="flex-fill">
         <div className="flex-fill">
           <BookmarkFolderNameInput
           <BookmarkFolderNameInput
-            onClickOutside={() => setIsRenameInputShown(false)}
-            onPressEnter={onPressEnterHandler}
+            onClickOutside={() => setIsCreateAction(false)}
+            onPressEnter={onPressEnterHandlerForCreate}
           />
           />
         </div>
         </div>
       )}
       )}
       {
       {
         renderChildFolder()
         renderChildFolder()
       }
       }
+      <DeleteBookmarkFolderModal
+        bookmarkFolder={bookmarkFolder}
+        isOpen={isDeleteFolderModalShown}
+        onClickDeleteButton={onClickDeleteButtonHandler}
+        onModalClose={onDeleteFolderModalClose}/>
     </div>
     </div>
   );
   );
 };
 };

+ 50 - 0
packages/app/src/components/Sidebar/Bookmarks/BookmarkFolderItemControl.tsx

@@ -0,0 +1,50 @@
+import React, { useState } from 'react';
+
+import { useTranslation } from 'next-i18next';
+import {
+  Dropdown, DropdownItem, DropdownMenu, DropdownToggle,
+} from 'reactstrap';
+
+
+type BookmarkFolderItemControlProps = {
+  children?: React.ReactNode
+  onClickRename: () => void
+  onClickDelete: () => void
+}
+const BookmarkFolderItemControl = (props: BookmarkFolderItemControlProps): JSX.Element => {
+  const { t } = useTranslation();
+  const { children, onClickRename, onClickDelete } = props;
+  const [isOpen, setIsOpen] = useState(false);
+  return (
+    <Dropdown isOpen={isOpen} toggle={() => setIsOpen(!isOpen)}>
+      { children ?? (
+        <DropdownToggle color="transparent" className="border-0 rounded btn-page-item-control d-flex align-items-center justify-content-center">
+          <i className="icon-options"></i>
+        </DropdownToggle>
+      ) }
+      <DropdownMenu
+        positionFixed
+        modifiers={{ preventOverflow: { boundariesElement: undefined } }}
+        right={true}
+      >
+        <DropdownItem
+          onClick={onClickRename}
+        >
+          <i className="icon-fw icon-action-redo grw-page-control-dropdown-icon"></i>
+          {t('Rename')}
+        </DropdownItem>
+
+        <DropdownItem divider/>
+        <DropdownItem
+          className='pt-2 grw-page-control-dropdown-item text-danger'
+          onClick={onClickDelete}
+        >
+          <i className="icon-fw icon-trash grw-page-control-dropdown-icon"></i>
+          {t('Delete')}
+        </DropdownItem>
+      </DropdownMenu>
+    </Dropdown>
+  );
+};
+
+export default BookmarkFolderItemControl;

+ 1 - 1
packages/app/src/components/Sidebar/Bookmarks/BookmarkFolderNameInput.tsx

@@ -30,7 +30,7 @@ const BookmarkFolderNameInput = (props: Props): JSX.Element => {
     <div className="flex-fill">
     <div className="flex-fill">
       <ClosableTextInput
       <ClosableTextInput
         value={ value }
         value={ value }
-        placeholder={t('Input Folder name')}
+        placeholder={t('bookmark_folder.input_placeholder')}
         onClickOutside={onClickOutside}
         onClickOutside={onClickOutside}
         onPressEnter={onPressEnter}
         onPressEnter={onPressEnter}
         inputValidator={inputValidator}
         inputValidator={inputValidator}

+ 4 - 0
packages/app/src/components/Sidebar/Bookmarks/BookmarkFolderTree.module.scss

@@ -58,6 +58,10 @@ $grw-foldertree-item-padding-left: 10px;
         min-width: 35px;
         min-width: 35px;
         height: 40px;
         height: 40px;
       }
       }
+      .bookmark-item-list{
+        min-width: 30px;
+        height: 35px;
+      }
     }
     }
   }
   }
   &:global{
   &:global{

+ 53 - 6
packages/app/src/components/Sidebar/Bookmarks/BookmarkFolderTree.tsx

@@ -1,21 +1,50 @@
 
 
+import { useCallback } from 'react';
+
 import { useTranslation } from 'next-i18next';
 import { useTranslation } from 'next-i18next';
 
 
+import { toastSuccess } from '~/client/util/apiNotification';
+import { IPageToDeleteWithMeta } from '~/interfaces/page';
+import { OnDeletedFunction } from '~/interfaces/ui';
+import { useSWRxCurrentUserBookmarks } from '~/stores/bookmark';
 import { useSWRxBookamrkFolderAndChild } from '~/stores/bookmark-folder';
 import { useSWRxBookamrkFolderAndChild } from '~/stores/bookmark-folder';
+import { usePageDeleteModal } from '~/stores/modal';
 
 
 import BookmarkFolderItem from './BookmarkFolderItem';
 import BookmarkFolderItem from './BookmarkFolderItem';
+import BookmarkItem from './BookmarkItem';
 
 
 import styles from './BookmarkFolderTree.module.scss';
 import styles from './BookmarkFolderTree.module.scss';
 
 
 
 
 const BookmarkFolderTree = (): JSX.Element => {
 const BookmarkFolderTree = (): JSX.Element => {
   const { t } = useTranslation();
   const { t } = useTranslation();
+  const { data: currentUserBookmarksData, mutate: mutateCurrentUserBookmarks } = useSWRxCurrentUserBookmarks();
   const { data: bookmarkFolderData } = useSWRxBookamrkFolderAndChild();
   const { data: bookmarkFolderData } = useSWRxBookamrkFolderAndChild();
-  if (bookmarkFolderData != null) {
-    return (
+  const { open: openDeleteModal } = usePageDeleteModal();
+
+  const deleteMenuItemClickHandler = useCallback((pageToDelete: IPageToDeleteWithMeta) => {
+    const pageDeletedHandler : OnDeletedFunction = (pathOrPathsToDelete, _isRecursively, isCompletely) => {
+      if (typeof pathOrPathsToDelete !== 'string') {
+        return;
+      }
+      const path = pathOrPathsToDelete;
+
+      if (isCompletely) {
+        toastSuccess(t('deleted_pages_completely', { path }));
+      }
+      else {
+        toastSuccess(t('deleted_pages', { path }));
+      }
+      mutateCurrentUserBookmarks();
+    };
+    openDeleteModal([pageToDelete], { onDeleted: pageDeletedHandler });
+  }, [mutateCurrentUserBookmarks, openDeleteModal, t]);
 
 
+
+  return (
+    <>
       <ul className={`grw-foldertree ${styles['grw-foldertree']} list-group p-3`}>
       <ul className={`grw-foldertree ${styles['grw-foldertree']} list-group p-3`}>
-        {bookmarkFolderData.map((item) => {
+        {bookmarkFolderData?.map((item) => {
           return (
           return (
             <BookmarkFolderItem
             <BookmarkFolderItem
               key={item._id}
               key={item._id}
@@ -24,10 +53,28 @@ const BookmarkFolderTree = (): JSX.Element => {
             />
             />
           );
           );
         })}
         })}
+        {currentUserBookmarksData?.length === 0 && (
+          <div className="pt-3">
+            <h5 className="pl-3">
+              { t('No bookmarks yet') }
+            </h5>
+          </div>
+        )}
+        { currentUserBookmarksData?.map((currentUserBookmark) => {
+          return (
+            <BookmarkItem
+              key={currentUserBookmark._id}
+              bookmarkedPage={currentUserBookmark}
+              onUnbookmarked={mutateCurrentUserBookmarks}
+              onRenamed={mutateCurrentUserBookmarks}
+              onClickDeleteMenuItem={deleteMenuItemClickHandler}
+            />
+          );
+        })}
       </ul>
       </ul>
-    );
-  }
-  return <></>;
+    </>
+  );
+
 
 
 };
 };
 
 

+ 4 - 4
packages/app/src/components/Sidebar/Bookmarks/BookmarkItem.tsx

@@ -96,8 +96,8 @@ const BookmarkItem = (props: Props): JSX.Element => {
   }, [bookmarkedPage, onClickDeleteMenuItem]);
   }, [bookmarkedPage, onClickDeleteMenuItem]);
 
 
   return (
   return (
-    <div className="d-flex justify-content-between" key={bookmarkedPage._id}>
-      <li className="list-group-item list-group-item-action border-0 py-0 pr-3 d-flex align-items-center" id={bookmarkItemId}>
+    <div className="grw-foldertree-item-container" key={bookmarkedPage._id}>
+      <li className="bookmark-item-list list-group-item list-group-item-action border-0 py-0 pl-3 d-flex align-items-center" id={bookmarkItemId}>
         { isRenameInputShown ? (
         { isRenameInputShown ? (
           <ClosableTextInput
           <ClosableTextInput
             value={nodePath.basename(bookmarkedPage.path ?? '')}
             value={nodePath.basename(bookmarkedPage.path ?? '')}
@@ -107,7 +107,7 @@ const BookmarkItem = (props: Props): JSX.Element => {
             inputValidator={inputValidator}
             inputValidator={inputValidator}
           />
           />
         ) : (
         ) : (
-          <a href={`/${bookmarkedPage._id}`} className="grw-bookmarks-title-anchor flex-grow-1">
+          <a href={`/${bookmarkedPage._id}`} className="grw-foldertree-title-anchor flex-grow-1 pr-3">
             <p className={`text-truncate m-auto ${bookmarkedPage.isEmpty && 'grw-sidebar-text-muted'}`}>{pageTitle}</p>
             <p className={`text-truncate m-auto ${bookmarkedPage.isEmpty && 'grw-sidebar-text-muted'}`}>{pageTitle}</p>
           </a>
           </a>
         )}
         )}
@@ -130,7 +130,7 @@ const BookmarkItem = (props: Props): JSX.Element => {
           target={bookmarkItemId}
           target={bookmarkItemId}
           fade={false}
           fade={false}
         >
         >
-          { formerPagePath }
+          { formerPagePath !== null ? `${formerPagePath}/` : '/' }
         </UncontrolledTooltip>
         </UncontrolledTooltip>
       </li>
       </li>
     </div>
     </div>

+ 53 - 0
packages/app/src/components/Sidebar/Bookmarks/DeleteBookmarkFolderModal.tsx

@@ -0,0 +1,53 @@
+
+import React from 'react';
+
+import { useTranslation } from 'next-i18next';
+import {
+  Modal, ModalBody, ModalFooter, ModalHeader,
+} from 'reactstrap';
+
+import FolderIcon from '~/components/Icons/FolderIcon';
+import { BookmarkFolderItems } from '~/interfaces/bookmark-info';
+
+type DeleteBookmarkFolderModalProps = {
+  isOpen: boolean
+  bookmarkFolder: BookmarkFolderItems
+  onClickDeleteButton: () => void
+  onModalClose: () => void
+}
+
+const DeleteBookmarkFolderModal = (props: DeleteBookmarkFolderModalProps): JSX.Element => {
+  const { t } = useTranslation();
+  const {
+    isOpen, onClickDeleteButton, bookmarkFolder, onModalClose,
+  } = props;
+
+  return (
+    <Modal size="md" isOpen={isOpen} toggle={onModalClose} data-testid="page-delete-modal" className="grw-create-page">
+      <ModalHeader tag="h4" toggle={onModalClose} className="bg-danger text-light">
+        <i className="icon-fw icon-trash"></i>
+        {t('bookmark_folder.delete_modal.modal_header_label')}
+      </ModalHeader>
+      <ModalBody>
+        <div className="form-group pb-1">
+          <label>{ t('bookmark_folder.delete_modal.modal_body_description') }:</label><br />
+          <FolderIcon isOpen={false}/> {bookmarkFolder.name}
+        </div>
+        {t('bookmark_folder.delete_modal.modal_body_alert')}
+      </ModalBody>
+      <ModalFooter>
+        <button
+          type="button"
+          className="btn btn-danger"
+          onClick={onClickDeleteButton}
+        >
+          <i className="mr-1 icon-trash" aria-hidden="true"></i>
+          {t('bookmark_folder.delete_modal.modal_footer_button')}
+        </button>
+      </ModalFooter>
+    </Modal>
+
+  );
+};
+
+export default DeleteBookmarkFolderModal;

+ 7 - 0
packages/app/src/interfaces/bookmark-info.ts

@@ -23,3 +23,10 @@ export interface IBookmarkFolder {
   owner: Ref<IUser>
   owner: Ref<IUser>
   parent?: Ref<this>
   parent?: Ref<this>
 }
 }
+
+export interface BookmarkFolderItems {
+  _id: string
+  name: string
+  parent: string
+  children: this[]
+}

+ 16 - 12
packages/app/src/server/models/bookmark-folder.ts

@@ -3,7 +3,7 @@ import {
   Types, Document, Model, Schema,
   Types, Document, Model, Schema,
 } from 'mongoose';
 } from 'mongoose';
 
 
-import { IBookmarkFolder } from '~/interfaces/bookmark-info';
+import { IBookmarkFolder, BookmarkFolderItems } from '~/interfaces/bookmark-info';
 
 
 
 
 import loggerFactory from '../../utils/logger';
 import loggerFactory from '../../utils/logger';
@@ -13,11 +13,6 @@ import { InvalidParentBookmarkFolderError } from './errors';
 
 
 const logger = loggerFactory('growi:models:bookmark-folder');
 const logger = loggerFactory('growi:models:bookmark-folder');
 
 
-export interface BookmarkFolderItems {
-  _id: string
-  name: string
-  children: this[]
-}
 export interface BookmarkFolderDocument extends Document {
 export interface BookmarkFolderDocument extends Document {
   _id: Types.ObjectId
   _id: Types.ObjectId
   name: string
   name: string
@@ -29,7 +24,7 @@ export interface BookmarkFolderModel extends Model<BookmarkFolderDocument>{
   createByParameters(params: IBookmarkFolder): BookmarkFolderDocument
   createByParameters(params: IBookmarkFolder): BookmarkFolderDocument
   findFolderAndChildren(user: Types.ObjectId | string, parentId?: Types.ObjectId | string): BookmarkFolderItems[]
   findFolderAndChildren(user: Types.ObjectId | string, parentId?: Types.ObjectId | string): BookmarkFolderItems[]
   findChildFolderById(parentBookmarkFolder: Types.ObjectId | string): Promise<BookmarkFolderDocument[]>
   findChildFolderById(parentBookmarkFolder: Types.ObjectId | string): Promise<BookmarkFolderDocument[]>
-  deleteFolderAndChildren(bookmarkFolderId: string): {deletedCount: number}
+  deleteFolderAndChildren(bookmarkFolderId: Types.ObjectId | string): {deletedCount: number}
   updateBookmarkFolder(bookmarkFolderId: string, name: string, parent: string): BookmarkFolderDocument | null
   updateBookmarkFolder(bookmarkFolderId: string, name: string, parent: string): BookmarkFolderDocument | null
 }
 }
 
 
@@ -37,6 +32,8 @@ const bookmarkFolderSchema = new Schema<BookmarkFolderDocument, BookmarkFolderMo
   name: { type: String },
   name: { type: String },
   owner: { type: Schema.Types.ObjectId, ref: 'User', index: true },
   owner: { type: Schema.Types.ObjectId, ref: 'User', index: true },
   parent: { type: Schema.Types.ObjectId, ref: 'BookmarkFolder', required: false },
   parent: { type: Schema.Types.ObjectId, ref: 'BookmarkFolder', required: false },
+}, {
+  toObject: { virtuals: true },
 });
 });
 
 
 bookmarkFolderSchema.virtual('children', {
 bookmarkFolderSchema.virtual('children', {
@@ -73,9 +70,11 @@ bookmarkFolderSchema.statics.findFolderAndChildren = async function(
     userId: Types.ObjectId | string,
     userId: Types.ObjectId | string,
     parentId?: Types.ObjectId | string,
     parentId?: Types.ObjectId | string,
 ): Promise<BookmarkFolderItems[]> {
 ): Promise<BookmarkFolderItems[]> {
+
   const parentFolder = await this.findById(parentId) as unknown as BookmarkFolderDocument;
   const parentFolder = await this.findById(parentId) as unknown as BookmarkFolderDocument;
-  const bookmarks = await this.find({ owner: userId, parent: parentFolder }).populate({ path: 'children' }).exec() as unknown as BookmarkFolderItems[];
-  return bookmarks;
+  const bookmarkFolders = await this.find({ owner: userId, parent: parentFolder })
+    .populate({ path: 'children' }).exec() as unknown as BookmarkFolderItems[];
+  return bookmarkFolders;
 };
 };
 
 
 bookmarkFolderSchema.statics.findChildFolderById = async function(parentFolderId: Types.ObjectId | string): Promise<BookmarkFolderDocument[]> {
 bookmarkFolderSchema.statics.findChildFolderById = async function(parentFolderId: Types.ObjectId | string): Promise<BookmarkFolderDocument[]> {
@@ -89,8 +88,14 @@ bookmarkFolderSchema.statics.deleteFolderAndChildren = async function(boookmarkF
   const bookmarkFolder = await this.findByIdAndDelete(boookmarkFolderId);
   const bookmarkFolder = await this.findByIdAndDelete(boookmarkFolderId);
   let deletedCount = 0;
   let deletedCount = 0;
   if (bookmarkFolder != null) {
   if (bookmarkFolder != null) {
-    const childFolders = await this.deleteMany({ parent: bookmarkFolder?.id });
-    deletedCount = childFolders.deletedCount + 1;
+    // Delete all child recursively and update deleted count
+    const childFolders = await this.find({ parent: bookmarkFolder });
+    await Promise.all(childFolders.map(async(child) => {
+      const deletedChildFolder = await this.deleteFolderAndChildren(child._id);
+      deletedCount += deletedChildFolder.deletedCount;
+    }));
+    const deletedChild = await this.deleteMany({ parent: bookmarkFolder });
+    deletedCount += deletedChild.deletedCount + 1;
   }
   }
   return { deletedCount };
   return { deletedCount };
 };
 };
@@ -107,6 +112,5 @@ bookmarkFolderSchema.statics.updateBookmarkFolder = async function(bookmarkFolde
 
 
 };
 };
 
 
-bookmarkFolderSchema.set('toObject', { virtuals: true });
 
 
 export default getOrCreateModel<BookmarkFolderDocument, BookmarkFolderModel>('BookmarkFolder', bookmarkFolderSchema);
 export default getOrCreateModel<BookmarkFolderDocument, BookmarkFolderModel>('BookmarkFolder', bookmarkFolderSchema);

+ 1 - 0
packages/app/src/server/routes/apiv3/bookmark-folder.ts

@@ -54,6 +54,7 @@ module.exports = (crowi) => {
       const bookmarkFolderItems = bookmarkFolders.map(bookmarkFolder => ({
       const bookmarkFolderItems = bookmarkFolders.map(bookmarkFolder => ({
         _id: bookmarkFolder._id,
         _id: bookmarkFolder._id,
         name: bookmarkFolder.name,
         name: bookmarkFolder.name,
+        parent: bookmarkFolder.parent,
         children: bookmarkFolder.children,
         children: bookmarkFolder.children,
       }));
       }));
       return res.apiv3({ bookmarkFolderItems });
       return res.apiv3({ bookmarkFolderItems });

+ 1 - 1
packages/app/src/stores/bookmark-folder.ts

@@ -3,7 +3,7 @@ import { SWRResponse } from 'swr';
 import useSWRImmutable from 'swr/immutable';
 import useSWRImmutable from 'swr/immutable';
 
 
 import { apiv3Get } from '~/client/util/apiv3-client';
 import { apiv3Get } from '~/client/util/apiv3-client';
-import { BookmarkFolderItems } from '~/server/models/bookmark-folder';
+import { BookmarkFolderItems } from '~/interfaces/bookmark-info';
 
 
 export const useSWRxBookamrkFolderAndChild = (parentId?: Nullable<string>): SWRResponse<BookmarkFolderItems[], Error> => {
 export const useSWRxBookamrkFolderAndChild = (parentId?: Nullable<string>): SWRResponse<BookmarkFolderItems[], Error> => {
   const _parentId = parentId == null ? '' : parentId;
   const _parentId = parentId == null ? '' : parentId;