PageRenameModal.tsx 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468
  1. import type React from 'react';
  2. import { useCallback, useEffect, useMemo, useState } from 'react';
  3. import { isIPageInfoForEntity } from '@growi/core';
  4. import { pagePathUtils } from '@growi/core/dist/utils';
  5. import { useAtomValue } from 'jotai';
  6. import { useTranslation } from 'next-i18next';
  7. import {
  8. Collapse,
  9. Modal,
  10. ModalBody,
  11. ModalFooter,
  12. ModalHeader,
  13. } from 'reactstrap';
  14. import { debounce } from 'throttle-debounce';
  15. import { apiv3Get, apiv3Put } from '~/client/util/apiv3-client';
  16. import { toastError } from '~/client/util/toastr';
  17. import { useSiteUrl } from '~/states/global';
  18. import { isSearchServiceReachableAtom } from '~/states/server-configurations';
  19. import {
  20. usePageRenameModalActions,
  21. usePageRenameModalStatus,
  22. } from '~/states/ui/modal/page-rename';
  23. import { useSWRxPageInfo } from '~/stores/page';
  24. import DuplicatedPathsTable from '../DuplicatedPathsTable';
  25. import ApiErrorMessageList from '../PageManagement/ApiErrorMessageList';
  26. import PagePathAutoComplete from '../PagePathAutoComplete';
  27. const isV5Compatible = (meta: unknown): boolean => {
  28. return isIPageInfoForEntity(meta) ? meta.isV5Compatible : true;
  29. };
  30. /**
  31. * PageRenameModalSubstance - Heavy processing component (rendered only when modal is open)
  32. */
  33. const PageRenameModalSubstance: React.FC = () => {
  34. const { t } = useTranslation();
  35. const { isUsersHomepage } = pagePathUtils;
  36. const siteUrl = useSiteUrl();
  37. const { isOpened, page, opts } = usePageRenameModalStatus();
  38. const { close: closeRenameModal } = usePageRenameModalActions();
  39. const isReachable = useAtomValue(isSearchServiceReachableAtom);
  40. const shouldFetch =
  41. isOpened && page != null && !isIPageInfoForEntity(page.meta);
  42. const { data: pageInfo } = useSWRxPageInfo(
  43. shouldFetch ? page?.data._id : null,
  44. );
  45. if (page != null && pageInfo != null) {
  46. page.meta = pageInfo;
  47. }
  48. const [pageNameInput, setPageNameInput] = useState('');
  49. const [errs, setErrs] = useState(null);
  50. const [_subordinatedPages, setSubordinatedPages] = useState([]);
  51. const [existingPaths, setExistingPaths] = useState<string[]>([]);
  52. const [isRenameRecursively, setIsRenameRecursively] = useState(true);
  53. const [isRenameRedirect, setIsRenameRedirect] = useState(false);
  54. const [isRemainMetadata, setIsRemainMetadata] = useState(false);
  55. const [expandOtherOptions, setExpandOtherOptions] = useState(false);
  56. const [subordinatedError] = useState(null);
  57. const [isMatchedWithUserHomepagePath, setIsMatchedWithUserHomepagePath] =
  58. useState(false);
  59. const updateSubordinatedList = useCallback(async () => {
  60. if (page == null) {
  61. return;
  62. }
  63. const { path } = page.data;
  64. try {
  65. const res = await apiv3Get('/pages/subordinated-list', { path });
  66. setSubordinatedPages(res.data.subordinatedPages);
  67. } catch (err) {
  68. setErrs(err);
  69. toastError(t('modal_rename.label.Failed to get subordinated pages'));
  70. }
  71. }, [page, t]);
  72. useEffect(() => {
  73. if (page != null && isOpened) {
  74. updateSubordinatedList();
  75. setPageNameInput(page.data.path);
  76. }
  77. }, [isOpened, page, updateSubordinatedList]);
  78. // Memoize computed values
  79. const isTargetPageDuplicate = useMemo(
  80. () => existingPaths.includes(pageNameInput),
  81. [existingPaths, pageNameInput],
  82. );
  83. const isV5CompatiblePage = useMemo(
  84. () => (page != null ? isV5Compatible(page.meta) : true),
  85. [page],
  86. );
  87. const canRename = useMemo(() => {
  88. if (
  89. page == null ||
  90. isMatchedWithUserHomepagePath ||
  91. page.data.path === pageNameInput
  92. ) {
  93. return false;
  94. }
  95. if (isV5CompatiblePage) {
  96. return existingPaths.length === 0; // v5 data
  97. }
  98. return isRenameRecursively; // v4 data
  99. }, [
  100. existingPaths.length,
  101. isMatchedWithUserHomepagePath,
  102. isRenameRecursively,
  103. page,
  104. pageNameInput,
  105. isV5CompatiblePage,
  106. ]);
  107. const rename = useCallback(async () => {
  108. if (page == null || !canRename) {
  109. return;
  110. }
  111. const _isV5Compatible = isV5Compatible(page.meta);
  112. setErrs(null);
  113. const { _id, path, revision } = page.data;
  114. try {
  115. const response = await apiv3Put('/pages/rename', {
  116. pageId: _id,
  117. revisionId: revision ?? null,
  118. isRecursively: !_isV5Compatible ? isRenameRecursively : undefined,
  119. isRenameRedirect,
  120. updateMetadata: !isRemainMetadata,
  121. newPagePath: pageNameInput,
  122. path,
  123. });
  124. const { page } = response.data;
  125. const url = new URL(page.path, 'https://dummy');
  126. if (isRenameRedirect) {
  127. url.searchParams.append('withRedirect', 'true');
  128. }
  129. const onRenamed = opts?.onRenamed;
  130. if (onRenamed != null) {
  131. onRenamed(path);
  132. }
  133. closeRenameModal();
  134. } catch (err) {
  135. setErrs(err);
  136. }
  137. }, [
  138. closeRenameModal,
  139. canRename,
  140. isRemainMetadata,
  141. isRenameRecursively,
  142. isRenameRedirect,
  143. page,
  144. pageNameInput,
  145. opts?.onRenamed,
  146. ]);
  147. const checkExistPaths = useCallback(
  148. async (fromPath, toPath) => {
  149. if (page == null) {
  150. return;
  151. }
  152. try {
  153. const res = await apiv3Get<{ existPaths: string[] }>(
  154. '/page/exist-paths',
  155. { fromPath, toPath },
  156. );
  157. const { existPaths } = res.data;
  158. setExistingPaths(existPaths);
  159. } catch (err) {
  160. // Do not toast in case of this error because debounce process may be executed after the renaming process is completed.
  161. if (err.length === 1 && err[0].code === 'from-page-is-not-exist') {
  162. return;
  163. }
  164. setErrs(err);
  165. toastError(t('modal_rename.label.Failed to get exist path'));
  166. }
  167. },
  168. [page, t],
  169. );
  170. const checkExistPathsDebounce = useMemo(() => {
  171. return debounce(1000, checkExistPaths);
  172. }, [checkExistPaths]);
  173. const checkIsUsersHomepageDebounce = useMemo(() => {
  174. const checkIsPagePathRenameable = (pageNameInput: string) => {
  175. setIsMatchedWithUserHomepagePath(isUsersHomepage(pageNameInput));
  176. };
  177. return debounce(1000, checkIsPagePathRenameable);
  178. }, []);
  179. useEffect(() => {
  180. if (isOpened && page != null && pageNameInput !== page.data.path) {
  181. checkExistPathsDebounce(page.data.path, pageNameInput);
  182. checkIsUsersHomepageDebounce(pageNameInput);
  183. }
  184. }, [
  185. isOpened,
  186. pageNameInput,
  187. checkExistPathsDebounce,
  188. page,
  189. checkIsUsersHomepageDebounce,
  190. ]);
  191. const ppacInputChangeHandler = useCallback((value: string) => {
  192. setErrs(null);
  193. setPageNameInput(value);
  194. }, []);
  195. /**
  196. * change pageNameInput
  197. * @param {string} value
  198. */
  199. const inputChangeHandler = useCallback((value) => {
  200. setErrs(null);
  201. setPageNameInput(value);
  202. }, []);
  203. useEffect(() => {
  204. if (isOpened || page == null) {
  205. return;
  206. }
  207. // reset states after the modal closed
  208. setTimeout(() => {
  209. setPageNameInput('');
  210. setErrs(null);
  211. setSubordinatedPages([]);
  212. setExistingPaths([]);
  213. setIsRenameRecursively(true);
  214. setIsRenameRedirect(false);
  215. setIsRemainMetadata(false);
  216. setExpandOtherOptions(false);
  217. }, 1000);
  218. }, [isOpened, page]);
  219. const bodyContent = () => {
  220. if (!isOpened || page == null) {
  221. return <></>;
  222. }
  223. const { path } = page.data;
  224. return (
  225. <>
  226. <div className="mb-3">
  227. <span className="form-label w-100">
  228. {t('modal_rename.label.Current page name')}
  229. </span>
  230. <code className="fs-6">{path}</code>
  231. </div>
  232. <div className="mb-3">
  233. <label htmlFor="newPageName" className="form-label w-100">
  234. {t('modal_rename.label.New page name')}
  235. </label>
  236. <div className="input-group">
  237. <div>
  238. <span className="input-group-text">{siteUrl}</span>
  239. </div>
  240. <form
  241. className="flex-fill"
  242. onSubmit={(e) => {
  243. e.preventDefault();
  244. rename();
  245. }}
  246. >
  247. {isReachable ? (
  248. <PagePathAutoComplete
  249. initializedPath={path}
  250. onSubmit={rename}
  251. onInputChange={ppacInputChangeHandler}
  252. />
  253. ) : (
  254. <input
  255. type="text"
  256. value={pageNameInput}
  257. className="form-control"
  258. onChange={(e) => inputChangeHandler(e.target.value)}
  259. required
  260. />
  261. )}
  262. </form>
  263. </div>
  264. {isTargetPageDuplicate && (
  265. <p className="text-danger">Error: Target path is duplicated.</p>
  266. )}
  267. {isMatchedWithUserHomepagePath && (
  268. <p className="text-danger">
  269. Error: Cannot move to directory under /user page.
  270. </p>
  271. )}
  272. </div>
  273. {!isV5Compatible(page.meta) && (
  274. <>
  275. <div className="form-check form-check-warning">
  276. <input
  277. className="form-check-input"
  278. name="withoutExistRecursively"
  279. id="cbRenameThisPageOnly"
  280. type="radio"
  281. checked={!isRenameRecursively}
  282. onChange={() => setIsRenameRecursively(!isRenameRecursively)}
  283. />
  284. <label
  285. className="form-label form-check-label"
  286. htmlFor="cbRenameThisPageOnly"
  287. >
  288. {t('modal_rename.label.Rename this page only')}
  289. </label>
  290. </div>
  291. <div className="form-check form-check-warning mt-1">
  292. <input
  293. className="form-check-input"
  294. name="recursively"
  295. id="cbForceRenameRecursively"
  296. type="radio"
  297. checked={isRenameRecursively}
  298. onChange={() => setIsRenameRecursively(!isRenameRecursively)}
  299. />
  300. <label
  301. className="form-label form-check-label"
  302. htmlFor="cbForceRenameRecursively"
  303. >
  304. {t('modal_rename.label.Force rename all child pages')}
  305. <p className="form-text text-muted mt-0">
  306. {t('modal_rename.help.recursive')}
  307. </p>
  308. </label>
  309. {isRenameRecursively && existingPaths.length !== 0 && (
  310. <DuplicatedPathsTable
  311. existingPaths={existingPaths}
  312. fromPath={path}
  313. toPath={pageNameInput}
  314. />
  315. )}
  316. </div>
  317. </>
  318. )}
  319. <p className="mt-2">
  320. <button
  321. type="button"
  322. className="btn btn-link mt-2 p-0"
  323. aria-expanded="false"
  324. onClick={() => setExpandOtherOptions(!expandOtherOptions)}
  325. >
  326. <span
  327. className={`material-symbols-outlined me-1 ${expandOtherOptions ? 'rotate-90' : ''}`}
  328. >
  329. navigate_next
  330. </span>
  331. {t('modal_rename.label.Other options')}
  332. </button>
  333. </p>
  334. <Collapse isOpen={expandOtherOptions}>
  335. <div className="form-check form-check-success">
  336. <input
  337. className="form-check-input"
  338. name="create_redirect"
  339. id="cbRenameRedirect"
  340. type="checkbox"
  341. checked={isRenameRedirect}
  342. onChange={() => setIsRenameRedirect(!isRenameRedirect)}
  343. />
  344. <label
  345. className="form-label form-check-label"
  346. htmlFor="cbRenameRedirect"
  347. >
  348. {t('modal_rename.label.Redirect')}
  349. <p className="form-text text-muted mt-0">
  350. {t('modal_rename.help.redirect')}
  351. </p>
  352. </label>
  353. </div>
  354. <div className="form-check form-check-success">
  355. <input
  356. className="form-check-input"
  357. name="remain_metadata"
  358. id="cbRemainMetadata"
  359. type="checkbox"
  360. checked={isRemainMetadata}
  361. onChange={() => setIsRemainMetadata(!isRemainMetadata)}
  362. />
  363. <label
  364. className="form-label form-check-label"
  365. htmlFor="cbRemainMetadata"
  366. >
  367. {t('modal_rename.label.Do not update metadata')}
  368. <p className="form-text text-muted mt-0">
  369. {t('modal_rename.help.metadata')}
  370. </p>
  371. </label>
  372. </div>
  373. <div> {subordinatedError} </div>
  374. </Collapse>
  375. </>
  376. );
  377. };
  378. const footerContent = () => {
  379. if (!isOpened || page == null) {
  380. return <></>;
  381. }
  382. const submitButtonDisabled = !canRename;
  383. return (
  384. <>
  385. <ApiErrorMessageList errs={errs} targetPath={pageNameInput} />
  386. <button
  387. data-testid="grw-page-rename-button"
  388. type="button"
  389. className="btn btn-primary"
  390. onClick={rename}
  391. disabled={submitButtonDisabled}
  392. >
  393. Rename
  394. </button>
  395. </>
  396. );
  397. };
  398. return (
  399. <>
  400. <ModalHeader tag="h4" toggle={closeRenameModal}>
  401. {t('modal_rename.label.Move/Rename page')}
  402. </ModalHeader>
  403. <ModalBody>{bodyContent()}</ModalBody>
  404. <ModalFooter>{footerContent()}</ModalFooter>
  405. </>
  406. );
  407. };
  408. /**
  409. * PageRenameModal - Container component (lightweight, always rendered)
  410. */
  411. export const PageRenameModal = (): React.JSX.Element => {
  412. const { isOpened } = usePageRenameModalStatus();
  413. const { close: closeRenameModal } = usePageRenameModalActions();
  414. return (
  415. <Modal
  416. size="lg"
  417. isOpen={isOpened}
  418. toggle={closeRenameModal}
  419. data-testid="page-rename-modal"
  420. autoFocus={false}
  421. >
  422. {isOpened && <PageRenameModalSubstance />}
  423. </Modal>
  424. );
  425. };