BookmarkFolderItem.tsx 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306
  1. import type { FC } from 'react';
  2. import { useCallback, useState } from 'react';
  3. import type { IPageToDeleteWithMeta } from '@growi/core';
  4. import { DropdownToggle } from 'reactstrap';
  5. import {
  6. addBookmarkToFolder, addNewFolder, hasChildren, updateBookmarkFolder,
  7. } from '~/client/util/bookmark-utils';
  8. import { toastError } from '~/client/util/toastr';
  9. import { FolderIcon } from '~/components/Icons/FolderIcon';
  10. import type { BookmarkFolderItems, DragItemDataType, DragItemType } from '~/interfaces/bookmark-info';
  11. import { DRAG_ITEM_TYPE } from '~/interfaces/bookmark-info';
  12. import type { onDeletedBookmarkFolderFunction } from '~/interfaces/ui';
  13. import { useBookmarkFolderDeleteModal } from '~/stores/modal';
  14. import { BookmarkFolderItemControl } from './BookmarkFolderItemControl';
  15. import { BookmarkFolderNameInput } from './BookmarkFolderNameInput';
  16. import { BookmarkItem } from './BookmarkItem';
  17. import { DragAndDropWrapper } from './DragAndDropWrapper';
  18. type BookmarkFolderItemProps = {
  19. isReadOnlyUser: boolean
  20. bookmarkFolder: BookmarkFolderItems
  21. isOpen?: boolean
  22. isOperable: boolean,
  23. level: number
  24. root: string
  25. isUserHomepage?: boolean
  26. onClickDeleteMenuItemHandler: (pageToDelete: IPageToDeleteWithMeta) => void
  27. bookmarkFolderTreeMutation: () => void
  28. }
  29. export const BookmarkFolderItem: FC<BookmarkFolderItemProps> = (props: BookmarkFolderItemProps) => {
  30. const BASE_FOLDER_PADDING = 15;
  31. const acceptedTypes: DragItemType[] = [DRAG_ITEM_TYPE.FOLDER, DRAG_ITEM_TYPE.BOOKMARK];
  32. const {
  33. isReadOnlyUser, bookmarkFolder, isOpen: _isOpen = false, isOperable, level, root, isUserHomepage,
  34. onClickDeleteMenuItemHandler, bookmarkFolderTreeMutation,
  35. } = props;
  36. const {
  37. name, _id: folderId, childFolder, parent, bookmarks,
  38. } = bookmarkFolder;
  39. const [targetFolder, setTargetFolder] = useState<string | null>(folderId);
  40. const [isOpen, setIsOpen] = useState(_isOpen);
  41. const [isRenameAction, setIsRenameAction] = useState<boolean>(false);
  42. const [isCreateAction, setIsCreateAction] = useState<boolean>(false);
  43. const { open: openDeleteBookmarkFolderModal } = useBookmarkFolderDeleteModal();
  44. const childrenExists = hasChildren({ childFolder, bookmarks });
  45. const paddingLeft = BASE_FOLDER_PADDING * level;
  46. const loadChildFolder = useCallback(async() => {
  47. setIsOpen(!isOpen);
  48. setTargetFolder(folderId);
  49. }, [folderId, isOpen]);
  50. // Rename for bookmark folder handler
  51. const onPressEnterHandlerForRename = useCallback(async(folderName: string) => {
  52. try {
  53. // TODO: do not use any type
  54. await updateBookmarkFolder(folderId, folderName, parent as any, childFolder);
  55. bookmarkFolderTreeMutation();
  56. setIsRenameAction(false);
  57. }
  58. catch (err) {
  59. toastError(err);
  60. }
  61. }, [bookmarkFolderTreeMutation, childFolder, folderId, parent]);
  62. // Create new folder / subfolder handler
  63. const onPressEnterHandlerForCreate = useCallback(async(folderName: string) => {
  64. try {
  65. await addNewFolder(folderName, targetFolder);
  66. setIsOpen(true);
  67. setIsCreateAction(false);
  68. bookmarkFolderTreeMutation();
  69. }
  70. catch (err) {
  71. toastError(err);
  72. }
  73. }, [bookmarkFolderTreeMutation, targetFolder]);
  74. const onClickPlusButton = useCallback(async(e) => {
  75. e.stopPropagation();
  76. if (!isOpen && childrenExists) {
  77. setIsOpen(true);
  78. }
  79. setIsCreateAction(true);
  80. }, [childrenExists, isOpen]);
  81. const itemDropHandler = async(item: DragItemDataType, dragItemType: string | symbol | null) => {
  82. if (dragItemType === DRAG_ITEM_TYPE.FOLDER) {
  83. try {
  84. if (item.bookmarkFolder != null) {
  85. await updateBookmarkFolder(item.bookmarkFolder._id, item.bookmarkFolder.name, bookmarkFolder._id, item.bookmarkFolder.childFolder);
  86. bookmarkFolderTreeMutation();
  87. }
  88. }
  89. catch (err) {
  90. toastError(err);
  91. }
  92. }
  93. else {
  94. try {
  95. if (item != null) {
  96. await addBookmarkToFolder(item._id, bookmarkFolder._id);
  97. bookmarkFolderTreeMutation();
  98. }
  99. }
  100. catch (err) {
  101. toastError(err);
  102. }
  103. }
  104. };
  105. const isDropable = (item: DragItemDataType, type: string | null | symbol): boolean => {
  106. if (type === DRAG_ITEM_TYPE.FOLDER) {
  107. if (item.bookmarkFolder.parent === bookmarkFolder._id || item.bookmarkFolder._id === bookmarkFolder._id) {
  108. return false;
  109. }
  110. // Maximum folder hierarchy of 2 levels
  111. // If the drop source folder has child folders, the drop source folder cannot be moved because the drop source folder hierarchy is already 2.
  112. // If the destination folder has a parent, the source folder cannot be moved because the destination folder hierarchy is already 2.
  113. if (item.bookmarkFolder.childFolder.length !== 0 || bookmarkFolder.parent != null) {
  114. return false;
  115. }
  116. return item.root !== root || item.level >= level;
  117. }
  118. if (item.parentFolder != null && item.parentFolder._id === bookmarkFolder._id) {
  119. return false;
  120. }
  121. return true;
  122. };
  123. const triangleBtnClassName = (isOpen: boolean, childrenExists: boolean): string => {
  124. if (!childrenExists) {
  125. return 'grw-foldertree-triangle-btn btn px-0 opacity-25';
  126. }
  127. return `grw-foldertree-triangle-btn btn px-0 ${isOpen ? 'grw-foldertree-open' : ''}`;
  128. };
  129. const renderChildFolder = () => {
  130. return isOpen && childFolder?.map((childFolder) => {
  131. return (
  132. <div key={childFolder._id} className="grw-foldertree-item-children">
  133. <BookmarkFolderItem
  134. key={childFolder._id}
  135. isReadOnlyUser={isReadOnlyUser}
  136. isOperable={props.isOperable}
  137. bookmarkFolder={childFolder}
  138. level={level + 1}
  139. root={root}
  140. isUserHomepage={isUserHomepage}
  141. onClickDeleteMenuItemHandler={onClickDeleteMenuItemHandler}
  142. bookmarkFolderTreeMutation={bookmarkFolderTreeMutation}
  143. />
  144. </div>
  145. );
  146. });
  147. };
  148. const renderBookmarkItem = () => {
  149. return isOpen && bookmarks?.map((bookmark) => {
  150. return (
  151. <BookmarkItem
  152. key={bookmark._id}
  153. isReadOnlyUser={isReadOnlyUser}
  154. isOperable={props.isOperable}
  155. bookmarkedPage={bookmark.page}
  156. level={level + 1}
  157. parentFolder={bookmarkFolder}
  158. canMoveToRoot
  159. onClickDeleteMenuItemHandler={onClickDeleteMenuItemHandler}
  160. bookmarkFolderTreeMutation={bookmarkFolderTreeMutation}
  161. />
  162. );
  163. });
  164. };
  165. const onClickRenameHandler = useCallback(() => {
  166. setIsRenameAction(true);
  167. }, []);
  168. const onClickDeleteHandler = useCallback(() => {
  169. const bookmarkFolderDeleteHandler: onDeletedBookmarkFolderFunction = (folderId) => {
  170. if (typeof folderId !== 'string') {
  171. return;
  172. }
  173. bookmarkFolderTreeMutation();
  174. };
  175. if (bookmarkFolder == null) {
  176. return;
  177. }
  178. openDeleteBookmarkFolderModal(bookmarkFolder, { onDeleted: bookmarkFolderDeleteHandler });
  179. }, [bookmarkFolder, bookmarkFolderTreeMutation, openDeleteBookmarkFolderModal]);
  180. const onClickMoveToRootHandlerForBookmarkFolderItemControl = useCallback(async() => {
  181. try {
  182. await updateBookmarkFolder(bookmarkFolder._id, bookmarkFolder.name, null, bookmarkFolder.childFolder);
  183. bookmarkFolderTreeMutation();
  184. }
  185. catch (err) {
  186. toastError(err);
  187. }
  188. }, [bookmarkFolder._id, bookmarkFolder.childFolder, bookmarkFolder.name, bookmarkFolderTreeMutation]);
  189. return (
  190. <div id={`grw-bookmark-folder-item-${folderId}`} className="grw-foldertree-item-container">
  191. <DragAndDropWrapper
  192. key={folderId}
  193. type={acceptedTypes}
  194. item={props}
  195. useDragMode={isOperable}
  196. useDropMode={isOperable}
  197. onDropItem={itemDropHandler}
  198. isDropable={isDropable}
  199. >
  200. <li
  201. className="list-group-item list-group-item-action border-0 py-2 d-flex align-items-center rounded"
  202. onClick={loadChildFolder}
  203. style={{ paddingLeft }}
  204. >
  205. <div className="grw-triangle-container d-flex justify-content-center">
  206. <button
  207. type="button"
  208. className={triangleBtnClassName(isOpen, childrenExists)}
  209. onClick={loadChildFolder}
  210. >
  211. <div className="d-flex justify-content-center">
  212. <span className="material-symbols-outlined">arrow_right</span>
  213. </div>
  214. </button>
  215. </div>
  216. <div>
  217. <FolderIcon isOpen={isOpen} />
  218. </div>
  219. {isRenameAction ? (
  220. <BookmarkFolderNameInput
  221. onClickOutside={() => setIsRenameAction(false)}
  222. onPressEnter={onPressEnterHandlerForRename}
  223. value={name}
  224. />
  225. ) : (
  226. <>
  227. <div className="grw-foldertree-title-anchor ps-1">
  228. <p className="text-truncate m-auto ">{name}</p>
  229. </div>
  230. </>
  231. )}
  232. {isOperable && (
  233. <div className="grw-foldertree-control d-flex">
  234. <BookmarkFolderItemControl
  235. onClickRename={onClickRenameHandler}
  236. onClickDelete={onClickDeleteHandler}
  237. onClickMoveToRoot={bookmarkFolder.parent != null
  238. ? onClickMoveToRootHandlerForBookmarkFolderItemControl
  239. : undefined
  240. }
  241. >
  242. <div onClick={e => e.stopPropagation()}>
  243. <DropdownToggle color="transparent" className="border-0 rounded btn-page-item-control p-0 grw-visible-on-hover me-1">
  244. <span className="material-symbols-outlined">more_vert</span>
  245. </DropdownToggle>
  246. </div>
  247. </BookmarkFolderItemControl>
  248. {/* Maximum folder hierarchy of 2 levels */}
  249. {!(bookmarkFolder.parent != null) && (
  250. <button
  251. id="create-bookmark-folder-button"
  252. type="button"
  253. className="border-0 rounded btn btn-page-item-control p-0 grw-visible-on-hover"
  254. onClick={onClickPlusButton}
  255. >
  256. <span className="material-symbols-outlined">add_circle</span>
  257. </button>
  258. )}
  259. </div>
  260. )}
  261. </li>
  262. </DragAndDropWrapper>
  263. {isCreateAction && (
  264. <div className="flex-fill">
  265. <BookmarkFolderNameInput
  266. onClickOutside={() => setIsCreateAction(false)}
  267. onPressEnter={onPressEnterHandlerForCreate}
  268. />
  269. </div>
  270. )}
  271. {
  272. renderChildFolder()
  273. }
  274. {
  275. renderBookmarkItem()
  276. }
  277. </div>
  278. );
  279. };