PageItemControl.tsx 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485
  1. import React, { type JSX, useCallback, useEffect, useState } from 'react';
  2. import {
  3. type IPageInfoExt,
  4. isIPageInfoForEmpty,
  5. isIPageInfoForOperation,
  6. } from '@growi/core/dist/interfaces';
  7. import { LoadingSpinner } from '@growi/ui/dist/components';
  8. import { useTranslation } from 'next-i18next';
  9. import {
  10. Dropdown,
  11. DropdownItem,
  12. DropdownMenu,
  13. DropdownToggle,
  14. } from 'reactstrap';
  15. import { NotAvailableForGuest } from '~/client/components/NotAvailableForGuest';
  16. import type { IPageOperationProcessData } from '~/interfaces/page-operation';
  17. import { useSWRxPageInfo } from '~/stores/page';
  18. import loggerFactory from '~/utils/logger';
  19. import { shouldRecoverPagePaths } from '~/utils/page-operation';
  20. const logger = loggerFactory('growi:cli:PageItemControl');
  21. export const MenuItemType = {
  22. BOOKMARK: 'bookmark',
  23. RENAME: 'rename',
  24. DUPLICATE: 'duplicate',
  25. DELETE: 'delete',
  26. REVERT: 'revert',
  27. PATH_RECOVERY: 'pathRecovery',
  28. SWITCH_CONTENT_WIDTH: 'switch_content_width',
  29. } as const;
  30. export type MenuItemType = (typeof MenuItemType)[keyof typeof MenuItemType];
  31. export type ForceHideMenuItems = MenuItemType[];
  32. export type AdditionalMenuItemsRendererProps = { pageInfo: IPageInfoExt };
  33. type CommonProps = {
  34. pageInfo?: IPageInfoExt;
  35. isEnableActions?: boolean;
  36. isReadOnlyUser?: boolean;
  37. forceHideMenuItems?: ForceHideMenuItems;
  38. onClickBookmarkMenuItem?: (
  39. pageId: string,
  40. newValue?: boolean,
  41. ) => Promise<void>;
  42. onClickRenameMenuItem?: (
  43. pageId: string,
  44. pageInfo: IPageInfoExt | undefined,
  45. ) => Promise<void> | void;
  46. onClickDuplicateMenuItem?: (pageId: string) => Promise<void> | void;
  47. onClickDeleteMenuItem?: (
  48. pageId: string,
  49. pageInfo: IPageInfoExt | undefined,
  50. ) => Promise<void> | void;
  51. onClickRevertMenuItem?: (pageId: string) => Promise<void> | void;
  52. onClickPathRecoveryMenuItem?: (pageId: string) => Promise<void> | void;
  53. additionalMenuItemOnTopRenderer?: React.FunctionComponent<AdditionalMenuItemsRendererProps>;
  54. additionalMenuItemRenderer?: React.FunctionComponent<AdditionalMenuItemsRendererProps>;
  55. isInstantRename?: boolean;
  56. alignEnd?: boolean;
  57. };
  58. type DropdownMenuProps = CommonProps & {
  59. pageId: string;
  60. isLoading?: boolean;
  61. isDataUnavailable?: boolean;
  62. operationProcessData?: IPageOperationProcessData;
  63. };
  64. const PageItemControlDropdownMenu = React.memo(
  65. (props: DropdownMenuProps): JSX.Element => {
  66. const { t } = useTranslation('');
  67. const {
  68. pageId,
  69. isLoading,
  70. isDataUnavailable,
  71. pageInfo,
  72. isEnableActions,
  73. isReadOnlyUser,
  74. forceHideMenuItems,
  75. operationProcessData,
  76. onClickBookmarkMenuItem,
  77. onClickRenameMenuItem,
  78. onClickDuplicateMenuItem,
  79. onClickDeleteMenuItem,
  80. onClickRevertMenuItem,
  81. onClickPathRecoveryMenuItem,
  82. additionalMenuItemOnTopRenderer: AdditionalMenuItemsOnTop,
  83. additionalMenuItemRenderer: AdditionalMenuItems,
  84. isInstantRename,
  85. alignEnd,
  86. } = props;
  87. const bookmarkItemClickedHandler = useCallback(async () => {
  88. if (onClickBookmarkMenuItem == null) return;
  89. if (
  90. !isIPageInfoForEmpty(pageInfo) &&
  91. !isIPageInfoForOperation(pageInfo)
  92. ) {
  93. return;
  94. }
  95. await onClickBookmarkMenuItem(pageId, !pageInfo.isBookmarked);
  96. }, [onClickBookmarkMenuItem, pageId, pageInfo]);
  97. const renameItemClickedHandler = useCallback(async () => {
  98. if (onClickRenameMenuItem == null) return;
  99. if (
  100. !(isIPageInfoForEmpty(pageInfo) || isIPageInfoForOperation(pageInfo)) ||
  101. !pageInfo?.isMovable
  102. ) {
  103. logger.warn('This page could not be renamed.');
  104. return;
  105. }
  106. await onClickRenameMenuItem(pageId, pageInfo);
  107. }, [onClickRenameMenuItem, pageId, pageInfo]);
  108. const duplicateItemClickedHandler = useCallback(async () => {
  109. if (onClickDuplicateMenuItem == null) {
  110. return;
  111. }
  112. await onClickDuplicateMenuItem(pageId);
  113. }, [onClickDuplicateMenuItem, pageId]);
  114. const revertItemClickedHandler = useCallback(async () => {
  115. if (onClickRevertMenuItem == null) {
  116. return;
  117. }
  118. await onClickRevertMenuItem(pageId);
  119. }, [onClickRevertMenuItem, pageId]);
  120. const deleteItemClickedHandler = useCallback(async () => {
  121. if (onClickDeleteMenuItem == null) return;
  122. if (
  123. !(isIPageInfoForEmpty(pageInfo) || isIPageInfoForOperation(pageInfo)) ||
  124. !pageInfo?.isDeletable
  125. ) {
  126. logger.warn('This page could not be deleted.');
  127. return;
  128. }
  129. await onClickDeleteMenuItem(pageId, pageInfo);
  130. }, [onClickDeleteMenuItem, pageId, pageInfo]);
  131. const pathRecoveryItemClickedHandler = useCallback(async () => {
  132. if (onClickPathRecoveryMenuItem == null) {
  133. return;
  134. }
  135. await onClickPathRecoveryMenuItem(pageId);
  136. }, [onClickPathRecoveryMenuItem, pageId]);
  137. let contents = <></>;
  138. if (isDataUnavailable) {
  139. // Show message when data is not available (e.g., fetch error)
  140. contents = (
  141. <div className="text-warning text-center px-3">
  142. <span className="material-symbols-outlined">error_outline</span> No
  143. data available
  144. </div>
  145. );
  146. } else if (isLoading) {
  147. contents = (
  148. <div className="text-muted text-center my-2">
  149. <LoadingSpinner />
  150. </div>
  151. );
  152. } else if (pageId != null && pageInfo != null) {
  153. const showDeviderBeforeAdditionalMenuItems =
  154. (forceHideMenuItems?.length ?? 0) < 3;
  155. const showDeviderBeforeDelete =
  156. AdditionalMenuItems != null || showDeviderBeforeAdditionalMenuItems;
  157. // PathRecovery
  158. // Todo: It is wanted to find a better way to pass operationProcessData to PageItemControl
  159. const shouldShowPathRecoveryButton =
  160. operationProcessData != null
  161. ? shouldRecoverPagePaths(operationProcessData)
  162. : false;
  163. contents = (
  164. <>
  165. {!isEnableActions && (
  166. <DropdownItem>
  167. <p>{t('search_result.currently_not_implemented')}</p>
  168. </DropdownItem>
  169. )}
  170. {AdditionalMenuItemsOnTop && (
  171. <>
  172. <AdditionalMenuItemsOnTop pageInfo={pageInfo} />
  173. <DropdownItem divider />
  174. </>
  175. )}
  176. {/* Bookmark */}
  177. {!forceHideMenuItems?.includes(MenuItemType.BOOKMARK) &&
  178. isEnableActions &&
  179. (isIPageInfoForEmpty(pageInfo) ||
  180. isIPageInfoForOperation(pageInfo)) && (
  181. <DropdownItem
  182. onClick={bookmarkItemClickedHandler}
  183. className="grw-page-control-dropdown-item"
  184. data-testid={
  185. pageInfo.isBookmarked
  186. ? 'remove-bookmark-btn'
  187. : 'add-bookmark-btn'
  188. }
  189. >
  190. <span className="material-symbols-outlined grw-page-control-dropdown-icon">
  191. bookmark
  192. </span>
  193. {pageInfo.isBookmarked
  194. ? t('remove_bookmark')
  195. : t('add_bookmark')}
  196. </DropdownItem>
  197. )}
  198. {/* Move/Rename */}
  199. {!forceHideMenuItems?.includes(MenuItemType.RENAME) &&
  200. isEnableActions &&
  201. !isReadOnlyUser &&
  202. (isIPageInfoForEmpty(pageInfo) ||
  203. isIPageInfoForOperation(pageInfo)) &&
  204. pageInfo.isMovable && (
  205. <DropdownItem
  206. onClick={renameItemClickedHandler}
  207. data-testid="rename-page-btn"
  208. className="grw-page-control-dropdown-item"
  209. >
  210. <span className="material-symbols-outlined me-1 grw-page-control-dropdown-icon">
  211. redo
  212. </span>
  213. {t(isInstantRename ? 'Rename' : 'Move/Rename')}
  214. </DropdownItem>
  215. )}
  216. {/* Duplicate */}
  217. {!forceHideMenuItems?.includes(MenuItemType.DUPLICATE) &&
  218. isEnableActions &&
  219. !isReadOnlyUser && (
  220. <DropdownItem
  221. onClick={duplicateItemClickedHandler}
  222. data-testid="open-page-duplicate-modal-btn"
  223. className="grw-page-control-dropdown-item"
  224. >
  225. <span className="material-symbols-outlined me-1 grw-page-control-dropdown-icon">
  226. file_copy
  227. </span>
  228. {t('Duplicate')}
  229. </DropdownItem>
  230. )}
  231. {/* Revert */}
  232. {!forceHideMenuItems?.includes(MenuItemType.REVERT) &&
  233. isEnableActions &&
  234. !isReadOnlyUser &&
  235. (isIPageInfoForEmpty(pageInfo) ||
  236. isIPageInfoForOperation(pageInfo)) &&
  237. pageInfo.isRevertible && (
  238. <DropdownItem
  239. onClick={revertItemClickedHandler}
  240. className="grw-page-control-dropdown-item"
  241. >
  242. <span className="material-symbols-outlined me-1 grw-page-control-dropdown-icon">
  243. undo
  244. </span>
  245. {t('modal_putback.label.Put Back Page')}
  246. </DropdownItem>
  247. )}
  248. {AdditionalMenuItems && (
  249. <>
  250. {showDeviderBeforeAdditionalMenuItems && <DropdownItem divider />}
  251. <AdditionalMenuItems pageInfo={pageInfo} />
  252. </>
  253. )}
  254. {/* PathRecovery */}
  255. {!forceHideMenuItems?.includes(MenuItemType.PATH_RECOVERY) &&
  256. isEnableActions &&
  257. !isReadOnlyUser &&
  258. shouldShowPathRecoveryButton && (
  259. <DropdownItem
  260. onClick={pathRecoveryItemClickedHandler}
  261. className="grw-page-control-dropdown-item"
  262. >
  263. <span className="material-symbols-outlined me-1 grw-page-control-dropdown-icon">
  264. build
  265. </span>
  266. {t('PathRecovery')}
  267. </DropdownItem>
  268. )}
  269. {/* divider */}
  270. {/* Delete */}
  271. {!forceHideMenuItems?.includes(MenuItemType.DELETE) &&
  272. isEnableActions &&
  273. !isReadOnlyUser &&
  274. (isIPageInfoForEmpty(pageInfo) ||
  275. isIPageInfoForOperation(pageInfo)) &&
  276. pageInfo.isDeletable && (
  277. <>
  278. {showDeviderBeforeDelete && <DropdownItem divider />}
  279. <DropdownItem
  280. className={`pt-2 grw-page-control-dropdown-item ${pageInfo.isDeletable ? 'text-danger' : ''}`}
  281. disabled={!pageInfo.isDeletable}
  282. onClick={deleteItemClickedHandler}
  283. data-testid="open-page-delete-modal-btn"
  284. >
  285. <span className="material-symbols-outlined me-1 grw-page-control-dropdown-icon">
  286. delete
  287. </span>
  288. {t('Delete')}
  289. </DropdownItem>
  290. </>
  291. )}
  292. </>
  293. );
  294. }
  295. return (
  296. <DropdownMenu
  297. className="d-print-none"
  298. data-testid="page-item-control-menu"
  299. end={alignEnd}
  300. container="body"
  301. persist={!!alignEnd}
  302. style={{
  303. zIndex: 1055,
  304. }} /* make it larger than $zindex-modal of bootstrap */
  305. >
  306. {contents}
  307. </DropdownMenu>
  308. );
  309. },
  310. );
  311. PageItemControlDropdownMenu.displayName = 'PageItemControl';
  312. type PageItemControlSubstanceProps = CommonProps & {
  313. pageId: string;
  314. children?: React.ReactNode;
  315. operationProcessData?: IPageOperationProcessData;
  316. };
  317. export const PageItemControlSubstance = (
  318. props: PageItemControlSubstanceProps,
  319. ): JSX.Element => {
  320. const {
  321. pageId,
  322. pageInfo: presetPageInfo,
  323. children,
  324. onClickBookmarkMenuItem,
  325. onClickRenameMenuItem,
  326. onClickDuplicateMenuItem,
  327. onClickDeleteMenuItem,
  328. onClickPathRecoveryMenuItem,
  329. } = props;
  330. const [isOpen, setIsOpen] = useState(false);
  331. const [shouldFetch, setShouldFetch] = useState(false);
  332. const {
  333. data: fetchedPageInfo,
  334. isLoading: isFetchLoading,
  335. mutate: mutatePageInfo,
  336. } = useSWRxPageInfo(shouldFetch ? pageId : null);
  337. // update shouldFetch (and will never be false)
  338. useEffect(() => {
  339. if (shouldFetch) {
  340. return;
  341. }
  342. if (!isIPageInfoForOperation(presetPageInfo) && isOpen) {
  343. setShouldFetch(true);
  344. }
  345. }, [isOpen, presetPageInfo, shouldFetch]);
  346. // mutate after handle event
  347. const bookmarkMenuItemClickHandler = useCallback(
  348. async (_pageId: string, _newValue: boolean) => {
  349. if (onClickBookmarkMenuItem != null) {
  350. await onClickBookmarkMenuItem(_pageId, _newValue);
  351. }
  352. if (shouldFetch) {
  353. mutatePageInfo();
  354. }
  355. },
  356. [mutatePageInfo, onClickBookmarkMenuItem, shouldFetch],
  357. );
  358. // Delegate to SWR's isLoading so that a skipped request (null key) is not treated as loading
  359. const isLoading = shouldFetch && isFetchLoading;
  360. const isDataUnavailable =
  361. !isLoading && fetchedPageInfo == null && presetPageInfo == null;
  362. const renameMenuItemClickHandler = useCallback(async () => {
  363. if (onClickRenameMenuItem == null) {
  364. return;
  365. }
  366. await onClickRenameMenuItem(pageId, fetchedPageInfo ?? presetPageInfo);
  367. }, [onClickRenameMenuItem, pageId, fetchedPageInfo, presetPageInfo]);
  368. const duplicateMenuItemClickHandler = useCallback(async () => {
  369. if (onClickDuplicateMenuItem == null) {
  370. return;
  371. }
  372. await onClickDuplicateMenuItem(pageId);
  373. }, [onClickDuplicateMenuItem, pageId]);
  374. const deleteMenuItemClickHandler = useCallback(async () => {
  375. if (onClickDeleteMenuItem == null) {
  376. return;
  377. }
  378. await onClickDeleteMenuItem(pageId, fetchedPageInfo ?? presetPageInfo);
  379. }, [onClickDeleteMenuItem, pageId, fetchedPageInfo, presetPageInfo]);
  380. const pathRecoveryMenuItemClickHandler = useCallback(async () => {
  381. if (onClickPathRecoveryMenuItem == null) {
  382. return;
  383. }
  384. await onClickPathRecoveryMenuItem(pageId);
  385. }, [onClickPathRecoveryMenuItem, pageId]);
  386. return (
  387. <NotAvailableForGuest>
  388. <Dropdown
  389. isOpen={isOpen}
  390. toggle={() => setIsOpen(!isOpen)}
  391. className="grw-page-item-control"
  392. data-testid="open-page-item-control-btn"
  393. >
  394. {children ?? (
  395. <DropdownToggle
  396. role="button"
  397. color="transparent"
  398. className="border-0 rounded btn-page-item-control d-flex align-items-center justify-content-center"
  399. >
  400. <span className="material-symbols-outlined">more_vert</span>
  401. </DropdownToggle>
  402. )}
  403. {isOpen && (
  404. <PageItemControlDropdownMenu
  405. {...props}
  406. isLoading={isLoading}
  407. isDataUnavailable={isDataUnavailable}
  408. pageInfo={fetchedPageInfo ?? presetPageInfo}
  409. onClickBookmarkMenuItem={bookmarkMenuItemClickHandler}
  410. onClickRenameMenuItem={renameMenuItemClickHandler}
  411. onClickDuplicateMenuItem={duplicateMenuItemClickHandler}
  412. onClickDeleteMenuItem={deleteMenuItemClickHandler}
  413. onClickPathRecoveryMenuItem={pathRecoveryMenuItemClickHandler}
  414. />
  415. )}
  416. </Dropdown>
  417. </NotAvailableForGuest>
  418. );
  419. };
  420. export type PageItemControlProps = CommonProps & {
  421. pageId?: string;
  422. children?: React.ReactNode;
  423. operationProcessData?: IPageOperationProcessData;
  424. };
  425. export const PageItemControl = (props: PageItemControlProps): JSX.Element => {
  426. const { pageId } = props;
  427. if (pageId == null) {
  428. return <></>;
  429. }
  430. return <PageItemControlSubstance pageId={pageId} {...props} />;
  431. };