BookmarkFolderItem.tsx 11 KB

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