BookmarkFolderItem.tsx 11 KB

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