PageDeleteModal.tsx 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416
  1. import type { FC } from 'react';
  2. import { useCallback, useEffect, useMemo, useState } from 'react';
  3. import type { IPageInfoForEntity, IPageToDeleteWithMeta } from '@growi/core';
  4. import { isIPageInfoForEntity } from '@growi/core';
  5. import { pagePathUtils } from '@growi/core/dist/utils';
  6. import { useTranslation } from 'next-i18next';
  7. import { Modal, ModalBody, ModalFooter, ModalHeader } from 'reactstrap';
  8. import { apiPost } from '~/client/util/apiv1-client';
  9. import { apiv3Post } from '~/client/util/apiv3-client';
  10. import type {
  11. IDeleteManyPageApiv3Result,
  12. IDeleteSinglePageApiv1Result,
  13. } from '~/interfaces/page';
  14. import {
  15. usePageDeleteModalActions,
  16. usePageDeleteModalStatus,
  17. } from '~/states/ui/modal/page-delete';
  18. import { useSWRxPageInfoForList } from '~/stores/page-listing';
  19. import loggerFactory from '~/utils/logger';
  20. import ApiErrorMessageList from '../PageManagement/ApiErrorMessageList';
  21. const { isTrashPage } = pagePathUtils;
  22. const logger = loggerFactory('growi:cli:PageDeleteModal');
  23. const deleteIconAndKey = {
  24. completely: {
  25. color: 'danger',
  26. icon: 'delete_forever',
  27. translationKey: 'completely',
  28. },
  29. temporary: {
  30. color: 'warning',
  31. icon: 'delete',
  32. translationKey: 'page',
  33. },
  34. };
  35. const isIPageInfoForEntityForDeleteModal = (
  36. pageInfo: any | undefined,
  37. ): pageInfo is IPageInfoForEntity => {
  38. return (
  39. pageInfo != null &&
  40. 'isDeletable' in pageInfo &&
  41. 'isAbleToDeleteCompletely' in pageInfo
  42. );
  43. };
  44. export const PageDeleteModal: FC = () => {
  45. const { t } = useTranslation();
  46. const { isOpened, pages, opts } = usePageDeleteModalStatus() ?? {};
  47. const { close: closeDeleteModal } = usePageDeleteModalActions();
  48. // Optimize deps: use page IDs and length instead of pages array reference
  49. const pageIds = useMemo(() => pages?.map((p) => p.data._id) ?? [], [pages]);
  50. const pagesLength = pages?.length ?? 0;
  51. // biome-ignore lint/correctness/useExhaustiveDependencies: keep optimized deps
  52. const notOperatablePages: IPageToDeleteWithMeta[] = useMemo(
  53. () =>
  54. (pages ?? []).filter((p) => !isIPageInfoForEntityForDeleteModal(p.meta)),
  55. // Optimization: Use pageIds and pagesLength instead of pages array reference to avoid unnecessary re-computation
  56. [pageIds, pagesLength],
  57. );
  58. const notOperatablePageIds = useMemo(
  59. () => notOperatablePages.map((p) => p.data._id),
  60. [notOperatablePages],
  61. );
  62. const { injectTo } = useSWRxPageInfoForList(notOperatablePageIds);
  63. // inject IPageInfo to operate
  64. // biome-ignore lint/correctness/useExhaustiveDependencies: keep optimized deps
  65. const injectedPages = useMemo(
  66. () => {
  67. if (pages != null) {
  68. return injectTo(pages);
  69. }
  70. return null;
  71. },
  72. // Optimization: Use pageIds and pagesLength instead of pages array reference to avoid unnecessary re-computation
  73. [pageIds, pagesLength, injectTo],
  74. );
  75. // calculate conditions to delete
  76. const [isDeletable, isAbleToDeleteCompletely] = useMemo(() => {
  77. if (injectedPages != null && injectedPages.length > 0) {
  78. const isDeletable = injectedPages.every(
  79. (pageWithMeta) => pageWithMeta.meta?.isDeletable,
  80. );
  81. const isAbleToDeleteCompletely = injectedPages.every(
  82. (pageWithMeta) => pageWithMeta.meta?.isAbleToDeleteCompletely,
  83. );
  84. return [isDeletable, isAbleToDeleteCompletely];
  85. }
  86. return [true, true];
  87. }, [injectedPages]);
  88. // Optimize deps: use page paths for trash detection
  89. // biome-ignore lint/correctness/useExhaustiveDependencies: keep optimized deps
  90. const pagePaths = useMemo(
  91. () => pages?.map((p) => p.data?.path ?? '') ?? [],
  92. // Optimization: Use pageIds and pagesLength instead of pages array reference to avoid unnecessary re-computation
  93. [pageIds, pagesLength],
  94. );
  95. // calculate condition to determine modal status
  96. const forceDeleteCompletelyMode = useMemo(() => {
  97. if (pagesLength > 0) {
  98. return pagePaths.every((path) => isTrashPage(path));
  99. }
  100. return false;
  101. }, [pagePaths, pagesLength]);
  102. const [isDeleteRecursively, setIsDeleteRecursively] = useState(true);
  103. const [isDeleteCompletely, setIsDeleteCompletely] = useState(
  104. forceDeleteCompletelyMode,
  105. );
  106. const deleteMode =
  107. forceDeleteCompletelyMode || isDeleteCompletely
  108. ? 'completely'
  109. : 'temporary';
  110. const [errs, setErrs] = useState<Error[] | null>(null);
  111. // initialize when opening modal
  112. useEffect(() => {
  113. if (isOpened) {
  114. setIsDeleteRecursively(true);
  115. setIsDeleteCompletely(forceDeleteCompletelyMode);
  116. }
  117. }, [forceDeleteCompletelyMode, isOpened]);
  118. useEffect(() => {
  119. setIsDeleteCompletely(forceDeleteCompletelyMode);
  120. }, [forceDeleteCompletelyMode]);
  121. const changeIsDeleteRecursivelyHandler = useCallback(() => {
  122. setIsDeleteRecursively(!isDeleteRecursively);
  123. }, [isDeleteRecursively]);
  124. const changeIsDeleteCompletelyHandler = useCallback(() => {
  125. if (forceDeleteCompletelyMode) {
  126. return;
  127. }
  128. setIsDeleteCompletely(!isDeleteCompletely);
  129. }, [forceDeleteCompletelyMode, isDeleteCompletely]);
  130. // biome-ignore lint/correctness/useExhaustiveDependencies: keep optimized deps
  131. const deletePage = useCallback(
  132. async () => {
  133. if (pages == null) {
  134. return;
  135. }
  136. if (!isDeletable) {
  137. logger.error('At least one page is not deletable.');
  138. return;
  139. }
  140. /*
  141. * When multiple pages
  142. */
  143. if (pages.length > 1) {
  144. try {
  145. const isRecursively = isDeleteRecursively === true ? true : undefined;
  146. const isCompletely = isDeleteCompletely === true ? true : undefined;
  147. const pageIdToRevisionIdMap = {};
  148. pages.forEach((p) => {
  149. pageIdToRevisionIdMap[p.data._id] = p.data.revision as string;
  150. });
  151. const { data } = await apiv3Post<IDeleteManyPageApiv3Result>(
  152. '/pages/delete',
  153. {
  154. pageIdToRevisionIdMap,
  155. isRecursively,
  156. isCompletely,
  157. },
  158. );
  159. const onDeleted = opts?.onDeleted;
  160. if (onDeleted != null) {
  161. onDeleted(data.paths, data.isRecursively, data.isCompletely);
  162. }
  163. closeDeleteModal();
  164. } catch (err) {
  165. setErrs([err]);
  166. }
  167. } else {
  168. /*
  169. * When single page
  170. */
  171. try {
  172. const recursively = isDeleteRecursively === true ? true : undefined;
  173. const completely =
  174. forceDeleteCompletelyMode || isDeleteCompletely ? true : undefined;
  175. const page = pages[0].data;
  176. const { path, isRecursively, isCompletely } = (await apiPost(
  177. '/pages.remove',
  178. {
  179. page_id: page._id,
  180. revision_id: page.revision,
  181. recursively,
  182. completely,
  183. },
  184. )) as IDeleteSinglePageApiv1Result;
  185. const onDeleted = opts?.onDeleted;
  186. if (onDeleted != null) {
  187. onDeleted(path, isRecursively, isCompletely);
  188. }
  189. closeDeleteModal();
  190. } catch (err) {
  191. setErrs([err]);
  192. }
  193. }
  194. },
  195. // Optimization: Use pageIds and pagesLength instead of pages array reference to avoid unnecessary re-computation
  196. [
  197. pageIds,
  198. pagesLength,
  199. isDeletable,
  200. isDeleteRecursively,
  201. isDeleteCompletely,
  202. forceDeleteCompletelyMode,
  203. opts?.onDeleted,
  204. closeDeleteModal,
  205. ],
  206. );
  207. const deleteButtonHandler = useCallback(async () => {
  208. await deletePage();
  209. }, [deletePage]);
  210. const renderDeleteRecursivelyForm = useCallback(() => {
  211. return (
  212. <div className="form-check form-check-warning">
  213. <input
  214. className="form-check-input"
  215. id="deleteRecursively"
  216. type="checkbox"
  217. checked={isDeleteRecursively}
  218. onChange={changeIsDeleteRecursivelyHandler}
  219. // disabled // Todo: enable this at https://redmine.weseek.co.jp/issues/82222
  220. />
  221. <label
  222. className="form-label form-check-label"
  223. htmlFor="deleteRecursively"
  224. >
  225. {t('modal_delete.delete_recursively')}
  226. <p className="form-text text-muted mt-0">
  227. {' '}
  228. {t('modal_delete.recursively')}
  229. </p>
  230. </label>
  231. </div>
  232. );
  233. }, [isDeleteRecursively, changeIsDeleteRecursivelyHandler, t]);
  234. const renderDeleteCompletelyForm = useCallback(() => {
  235. return (
  236. <div className="form-check form-check-danger">
  237. <input
  238. className="form-check-input"
  239. name="completely"
  240. id="deleteCompletely"
  241. type="checkbox"
  242. disabled={!isAbleToDeleteCompletely}
  243. checked={isDeleteCompletely}
  244. onChange={changeIsDeleteCompletelyHandler}
  245. />
  246. <label
  247. className="form-label form-check-label"
  248. htmlFor="deleteCompletely"
  249. >
  250. {t('modal_delete.delete_completely')}
  251. <p className="form-text text-muted mt-0">
  252. {' '}
  253. {t('modal_delete.completely')}
  254. </p>
  255. </label>
  256. {!isAbleToDeleteCompletely && (
  257. <p className="alert alert-warning p-2 my-0">
  258. <span className="material-symbols-outlined">block</span>
  259. {t('modal_delete.delete_completely_restriction')}
  260. </p>
  261. )}
  262. </div>
  263. );
  264. }, [
  265. isAbleToDeleteCompletely,
  266. isDeleteCompletely,
  267. changeIsDeleteCompletelyHandler,
  268. t,
  269. ]);
  270. const headerContent = useMemo(() => {
  271. if (!isOpened) {
  272. return <></>;
  273. }
  274. return (
  275. <span
  276. className={`text-${deleteIconAndKey[deleteMode].color} d-flex align-items-center`}
  277. >
  278. <span className="material-symbols-outlined me-1">
  279. {deleteIconAndKey[deleteMode].icon}
  280. </span>
  281. <b>
  282. {t(
  283. `modal_delete.delete_${deleteIconAndKey[deleteMode].translationKey}`,
  284. )}
  285. </b>
  286. </span>
  287. );
  288. }, [isOpened, deleteMode, t]);
  289. // biome-ignore lint/correctness/useExhaustiveDependencies: keep optimized deps
  290. const bodyContent = useMemo(() => {
  291. if (!isOpened) {
  292. return <></>;
  293. }
  294. // Render page paths to delete inline for better performance
  295. const renderingPages =
  296. injectedPages != null && injectedPages.length > 0 ? injectedPages : pages;
  297. const pagePathsElements =
  298. renderingPages != null ? (
  299. renderingPages.map((page) => (
  300. <p key={page.data._id} className="mb-1">
  301. <code>{page.data.path}</code>
  302. {isIPageInfoForEntity(page.meta) && !page.meta.isDeletable && (
  303. <span className="ms-3 text-danger">
  304. <strong>(CAN NOT TO DELETE)</strong>
  305. </span>
  306. )}
  307. </p>
  308. ))
  309. ) : (
  310. <></>
  311. );
  312. return (
  313. <>
  314. <div className="grw-scrollable-modal-body pb-1">
  315. <span className="form-label">{t('modal_delete.deleting_page')}:</span>
  316. <br />
  317. {/* Todo: change the way to show path on modal when too many pages are selected */}
  318. {pagePathsElements}
  319. </div>
  320. {isDeletable && renderDeleteRecursivelyForm()}
  321. {isDeletable &&
  322. !forceDeleteCompletelyMode &&
  323. renderDeleteCompletelyForm()}
  324. </>
  325. );
  326. // Optimization: Use direct dependencies instead of JSX.Element reference for better performance
  327. }, [
  328. isOpened,
  329. t,
  330. pageIds,
  331. pagesLength,
  332. injectedPages,
  333. isDeletable,
  334. renderDeleteRecursivelyForm,
  335. forceDeleteCompletelyMode,
  336. renderDeleteCompletelyForm,
  337. ]);
  338. const footerContent = useMemo(() => {
  339. if (!isOpened) {
  340. return <></>;
  341. }
  342. return (
  343. <>
  344. <ApiErrorMessageList errs={errs} />
  345. <button
  346. type="button"
  347. className={`btn btn-outline-${deleteIconAndKey[deleteMode].color}`}
  348. disabled={!isDeletable}
  349. onClick={deleteButtonHandler}
  350. data-testid="delete-page-button"
  351. >
  352. <span className="material-symbols-outlined me-1" aria-hidden="true">
  353. {deleteIconAndKey[deleteMode].icon}
  354. </span>
  355. {t(
  356. `modal_delete.delete_${deleteIconAndKey[deleteMode].translationKey}`,
  357. )}
  358. </button>
  359. </>
  360. );
  361. }, [isOpened, errs, deleteMode, isDeletable, deleteButtonHandler, t]);
  362. return (
  363. <Modal
  364. size="lg"
  365. isOpen={isOpened}
  366. toggle={closeDeleteModal}
  367. data-testid="page-delete-modal"
  368. >
  369. <ModalHeader toggle={closeDeleteModal}>{headerContent}</ModalHeader>
  370. <ModalBody>{bodyContent}</ModalBody>
  371. <ModalFooter>{footerContent}</ModalFooter>
  372. </Modal>
  373. );
  374. };