PrivateLegacyPages.tsx 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637
  1. import React, {
  2. type JSX,
  3. useCallback,
  4. useEffect,
  5. useMemo,
  6. useRef,
  7. useState,
  8. } from 'react';
  9. import { LoadingSpinner } from '@growi/ui/dist/components';
  10. import { useAtomValue } from 'jotai';
  11. import { useTranslation } from 'next-i18next';
  12. import {
  13. DropdownItem,
  14. DropdownMenu,
  15. DropdownToggle,
  16. Modal,
  17. ModalBody,
  18. ModalFooter,
  19. ModalHeader,
  20. UncontrolledButtonDropdown,
  21. } from 'reactstrap';
  22. import { MenuItemType } from '~/client/components/Common/Dropdown/PageItemControl';
  23. import PaginationWrapper from '~/client/components/PaginationWrapper';
  24. import { PrivateLegacyPagesMigrationModal } from '~/client/components/PrivateLegacyPagesMigrationModal';
  25. import type {
  26. ISelectableAll,
  27. ISelectableAndIndeterminatable,
  28. } from '~/client/interfaces/selectable-all';
  29. import { apiv3Post } from '~/client/util/apiv3-client';
  30. import { toastError, toastSuccess } from '~/client/util/toastr';
  31. import { V5ConversionErrCode } from '~/interfaces/errors/v5-conversion-error';
  32. import type { V5MigrationStatus } from '~/interfaces/page-listing-results';
  33. import type { IFormattedSearchResult } from '~/interfaces/search';
  34. import type { PageMigrationErrorData } from '~/interfaces/websocket';
  35. import { SocketEventName } from '~/interfaces/websocket';
  36. import { useIsAdmin } from '~/states/context';
  37. import { useSearchKeyword, useSetSearchKeyword } from '~/states/search';
  38. import { disableUserPagesAtom } from '~/states/server-configurations';
  39. import { useGlobalSocket } from '~/states/socket-io';
  40. import type { ILegacyPrivatePage } from '~/states/ui/modal/private-legacy-pages-migration';
  41. import { usePrivateLegacyPagesMigrationModalActions } from '~/states/ui/modal/private-legacy-pages-migration';
  42. import {
  43. mutatePageTree,
  44. useSWRxV5MigrationStatus,
  45. } from '~/stores/page-listing';
  46. import { useSWRxSearch } from '~/stores/search';
  47. import { OperateAllControl } from './SearchPage/OperateAllControl';
  48. import SearchControl from './SearchPage/SearchControl';
  49. import {
  50. type IReturnSelectedPageIds,
  51. SearchPageBase,
  52. usePageDeleteModalForBulkDeletion,
  53. } from './SearchPage/SearchPageBase';
  54. // TODO: replace with "customize:showPageLimitationS"
  55. const INITIAL_PAGING_SIZE = 20;
  56. const initQ = '/';
  57. /**
  58. * SearchResultListHead
  59. */
  60. type SearchResultListHeadProps = {
  61. searchResult: IFormattedSearchResult;
  62. offset: number;
  63. pagingSize: number;
  64. onPagingSizeChanged: (size: number) => void;
  65. migrationStatus?: V5MigrationStatus;
  66. };
  67. const SearchResultListHead = React.memo(
  68. (props: SearchResultListHeadProps): JSX.Element => {
  69. const { t } = useTranslation();
  70. const {
  71. searchResult,
  72. offset,
  73. pagingSize,
  74. onPagingSizeChanged,
  75. migrationStatus,
  76. } = props;
  77. if (migrationStatus == null) {
  78. return (
  79. <div className="mw-0 flex-grow-1 flex-basis-0 m-5 text-muted text-center">
  80. <LoadingSpinner className="me-1 fs-3" />
  81. </div>
  82. );
  83. }
  84. const { took, total, hitsCount } = searchResult.meta;
  85. const leftNum = offset + 1;
  86. const rightNum = offset + hitsCount;
  87. const isSuccess = migrationStatus.migratablePagesCount === 0;
  88. if (isSuccess) {
  89. return (
  90. <div
  91. className="card border-success mt-3"
  92. data-testid="search-result-private-legacy-pages"
  93. >
  94. <div className="card-body">
  95. <h2 className="card-title text-success">
  96. {t('private_legacy_pages.nopages_title')}
  97. </h2>
  98. <p className="card-text">
  99. {t('private_legacy_pages.nopages_desc1')}
  100. <br />
  101. <span
  102. // biome-ignore lint/security/noDangerouslySetInnerHtml: ignore
  103. dangerouslySetInnerHTML={{
  104. __html: t('private_legacy_pages.detail_info'),
  105. }}
  106. ></span>
  107. </p>
  108. </div>
  109. </div>
  110. );
  111. }
  112. return (
  113. <>
  114. <div className="d-flex align-items-center justify-content-between">
  115. <div className="text-nowrap">
  116. {t('search_result.result_meta')}
  117. <span className="ms-3">
  118. {`${leftNum}-${rightNum}`} / {total}
  119. </span>
  120. {took != null && (
  121. <span className="ms-3 text-muted">({took}ms)</span>
  122. )}
  123. </div>
  124. <div className="input-group flex-nowrap search-result-select-group ms-auto d-md-flex d-none">
  125. <div>
  126. <label
  127. className="form-label input-group-text text-muted"
  128. htmlFor="inputGroupSelect01"
  129. >
  130. {t('search_result.number_of_list_to_display')}
  131. </label>
  132. </div>
  133. <select
  134. defaultValue={pagingSize}
  135. className="form-select"
  136. id="inputGroupSelect01"
  137. onChange={(e) => onPagingSizeChanged(Number(e.target.value))}
  138. >
  139. {[20, 50, 100, 200].map((limit) => {
  140. return (
  141. <option key={limit} value={limit}>
  142. {limit} {t('search_result.page_number_unit')}
  143. </option>
  144. );
  145. })}
  146. </select>
  147. </div>
  148. </div>
  149. <div className="card border-warning mt-3">
  150. <div className="card-body">
  151. <h2 className="card-title text-warning">
  152. {t('private_legacy_pages.alert_title')}
  153. </h2>
  154. <p className="card-text">
  155. {t('private_legacy_pages.alert_desc1', {
  156. delete_all_selected_page: t(
  157. 'search_result.delete_all_selected_page',
  158. ),
  159. })}
  160. <br />
  161. <span
  162. // biome-ignore lint/security/noDangerouslySetInnerHtml: ignore
  163. dangerouslySetInnerHTML={{
  164. __html: t('private_legacy_pages.detail_info'),
  165. }}
  166. ></span>
  167. </p>
  168. </div>
  169. </div>
  170. </>
  171. );
  172. },
  173. );
  174. SearchResultListHead.displayName = 'SearchResultListHead';
  175. /*
  176. * ConvertByPathModal
  177. */
  178. type ConvertByPathModalProps = {
  179. isOpen: boolean;
  180. close?: () => void;
  181. onSubmit?: (convertPath: string) => Promise<void> | void;
  182. };
  183. const ConvertByPathModal = React.memo(
  184. (props: ConvertByPathModalProps): JSX.Element => {
  185. const { t } = useTranslation();
  186. const [currentInput, setInput] = useState<string>('');
  187. const [checked, setChecked] = useState<boolean>(false);
  188. useEffect(() => {
  189. setChecked(false);
  190. }, []);
  191. return (
  192. <Modal size="lg" isOpen={props.isOpen} toggle={props.close}>
  193. <ModalHeader tag="h4" toggle={props.close}>
  194. {t('private_legacy_pages.by_path_modal.title')}
  195. </ModalHeader>
  196. <ModalBody>
  197. <p>{t('private_legacy_pages.by_path_modal.description')}</p>
  198. <input
  199. type="text"
  200. className="form-control"
  201. placeholder="/"
  202. value={currentInput}
  203. onChange={(e) => setInput(e.target.value)}
  204. />
  205. <div className="alert alert-danger mt-3" role="alert">
  206. {t('private_legacy_pages.by_path_modal.alert')}
  207. </div>
  208. </ModalBody>
  209. <ModalFooter>
  210. <div className="form-check">
  211. <input
  212. className="form-check-input"
  213. type="checkbox"
  214. id="understoodCheckbox"
  215. onChange={(e) => setChecked(e.target.checked)}
  216. />
  217. <label
  218. className="form-label form-check-label"
  219. htmlFor="understoodCheckbox"
  220. >
  221. {t('private_legacy_pages.by_path_modal.checkbox_label')}
  222. </label>
  223. </div>
  224. <button
  225. type="button"
  226. className="btn btn-primary"
  227. disabled={!checked}
  228. onClick={() => props.onSubmit?.(currentInput)}
  229. >
  230. <span className="material-symbols-outlined" aria-hidden="true">
  231. refresh
  232. </span>
  233. {t('private_legacy_pages.by_path_modal.button_label')}
  234. </button>
  235. </ModalFooter>
  236. </Modal>
  237. );
  238. },
  239. );
  240. ConvertByPathModal.displayName = 'ConvertByPathModal';
  241. /**
  242. * LegacyPage
  243. */
  244. const PrivateLegacyPages = (): JSX.Element => {
  245. const { t } = useTranslation();
  246. const isAdmin = useIsAdmin();
  247. const keyword = useSearchKeyword();
  248. const setSearchKeyword = useSetSearchKeyword('/_private-legacy-pages');
  249. const disableUserPages = useAtomValue(disableUserPagesAtom);
  250. const [offset, setOffset] = useState<number>(0);
  251. const [limit, setLimit] = useState<number>(INITIAL_PAGING_SIZE);
  252. const [isOpenConvertModal, setOpenConvertModal] = useState<boolean>(false);
  253. const [isControlEnabled, setControlEnabled] = useState(false);
  254. const selectAllControlRef = useRef<ISelectableAndIndeterminatable | null>(
  255. null,
  256. );
  257. const searchPageBaseRef = useRef<
  258. (ISelectableAll & IReturnSelectedPageIds) | null
  259. >(null);
  260. // biome-ignore lint/correctness/useExhaustiveDependencies: only run on mount
  261. useEffect(() => {
  262. setSearchKeyword(initQ);
  263. }, []);
  264. const { data, conditions, mutate } = useSWRxSearch(
  265. keyword,
  266. 'PrivateLegacyPages',
  267. {
  268. offset,
  269. limit,
  270. includeUserPages: true,
  271. includeTrashPages: false,
  272. },
  273. );
  274. const { data: migrationStatus, mutate: mutateMigrationStatus } =
  275. useSWRxV5MigrationStatus();
  276. const searchInvokedHandler = useCallback(
  277. (_keyword: string) => {
  278. mutateMigrationStatus();
  279. setSearchKeyword(_keyword);
  280. setOffset(0);
  281. },
  282. [mutateMigrationStatus, setSearchKeyword],
  283. );
  284. const { open: openModal, close: closeModal } =
  285. usePrivateLegacyPagesMigrationModalActions();
  286. const socket = useGlobalSocket();
  287. useEffect(() => {
  288. socket?.on(SocketEventName.PageMigrationSuccess, () => {
  289. toastSuccess(t('private_legacy_pages.toaster.page_migration_succeeded'));
  290. });
  291. socket?.on(
  292. SocketEventName.PageMigrationError,
  293. (data?: PageMigrationErrorData) => {
  294. if (data == null || data.paths.length === 0) {
  295. toastError(t('private_legacy_pages.toaster.page_migration_failed'));
  296. } else {
  297. const errorPaths =
  298. data.paths.length > 3
  299. ? `${data.paths.slice(0, 3).join(', ')}...`
  300. : data.paths.join(', ');
  301. toastError(
  302. t('private_legacy_pages.toaster.page_migration_failed_with_paths', {
  303. paths: errorPaths,
  304. }),
  305. );
  306. }
  307. },
  308. );
  309. return () => {
  310. socket?.off(SocketEventName.PageMigrationSuccess);
  311. socket?.off(SocketEventName.PageMigrationError);
  312. };
  313. }, [socket, t]);
  314. const selectAllCheckboxChangedHandler = useCallback((isChecked: boolean) => {
  315. const instance = searchPageBaseRef.current;
  316. if (instance == null) {
  317. return;
  318. }
  319. if (isChecked) {
  320. instance.selectAll();
  321. setControlEnabled(true);
  322. } else {
  323. instance.deselectAll();
  324. setControlEnabled(false);
  325. }
  326. }, []);
  327. const selectedPagesByCheckboxesChangedHandler = useCallback(
  328. (selectedCount: number, totalCount: number) => {
  329. const instance = selectAllControlRef.current;
  330. if (instance == null) {
  331. return;
  332. }
  333. if (selectedCount === 0) {
  334. instance.deselect();
  335. setControlEnabled(false);
  336. } else if (selectedCount === totalCount) {
  337. instance.select();
  338. setControlEnabled(true);
  339. } else {
  340. instance.setIndeterminate();
  341. setControlEnabled(true);
  342. }
  343. },
  344. [],
  345. );
  346. // for bulk deletion
  347. const deleteAllButtonClickedHandler = usePageDeleteModalForBulkDeletion(
  348. data,
  349. searchPageBaseRef,
  350. () => mutate(),
  351. );
  352. const convertMenuItemClickedHandler = useCallback(() => {
  353. if (data == null) {
  354. return;
  355. }
  356. const instance = searchPageBaseRef.current;
  357. if (instance == null || instance.getSelectedPageIds == null) {
  358. return;
  359. }
  360. const selectedPageIds = instance.getSelectedPageIds();
  361. if (selectedPageIds.size === 0) {
  362. return;
  363. }
  364. const selectedPages = data.data
  365. .filter((pageWithMeta) => selectedPageIds.has(pageWithMeta.data._id))
  366. .map(
  367. (pageWithMeta) =>
  368. ({
  369. pageId: pageWithMeta.data._id,
  370. path: pageWithMeta.data.path,
  371. }) as ILegacyPrivatePage,
  372. );
  373. openModal(selectedPages, () => {
  374. toastSuccess(t('Successfully requested'));
  375. closeModal();
  376. mutateMigrationStatus();
  377. mutate();
  378. mutatePageTree();
  379. });
  380. }, [data, openModal, t, closeModal, mutateMigrationStatus, mutate]);
  381. const pagingSizeChangedHandler = useCallback(
  382. (pagingSize: number) => {
  383. setOffset(0);
  384. setLimit(pagingSize);
  385. mutate();
  386. },
  387. [mutate],
  388. );
  389. const pagingNumberChangedHandler = useCallback(
  390. (activePage: number) => {
  391. setOffset((activePage - 1) * limit);
  392. mutate();
  393. },
  394. [limit, mutate],
  395. );
  396. const openConvertModalHandler = useCallback(() => {
  397. if (!isAdmin) {
  398. return;
  399. }
  400. setOpenConvertModal(true);
  401. }, [isAdmin]);
  402. const hitsCount = data?.meta.hitsCount;
  403. const renderOpenModalButton = useCallback(() => {
  404. return (
  405. <div className="d-flex ps-md-2">
  406. <button
  407. type="button"
  408. className="btn btn-light"
  409. onClick={() => openConvertModalHandler()}
  410. >
  411. {t('private_legacy_pages.input_path_to_convert')}
  412. </button>
  413. </div>
  414. );
  415. }, [t, openConvertModalHandler]);
  416. const extraControls = useMemo(() => {
  417. const isCheckboxDisabled = hitsCount === 0;
  418. return (
  419. <div className="d-flex align-items-center">
  420. <div className="d-flex">
  421. <OperateAllControl
  422. inputClassName="me-2"
  423. ref={selectAllControlRef}
  424. isCheckboxDisabled={isCheckboxDisabled}
  425. onCheckboxChanged={selectAllCheckboxChangedHandler}
  426. >
  427. <UncontrolledButtonDropdown>
  428. <DropdownToggle
  429. caret
  430. color="outline-primary"
  431. disabled={!isControlEnabled}
  432. >
  433. {t('private_legacy_pages.bulk_operation')}
  434. </DropdownToggle>
  435. <DropdownMenu>
  436. <DropdownItem onClick={convertMenuItemClickedHandler}>
  437. <span className="material-symbols-outlined">refresh</span>
  438. {t('private_legacy_pages.convert_all_selected_pages')}
  439. </DropdownItem>
  440. <DropdownItem onClick={deleteAllButtonClickedHandler}>
  441. <span className="text-danger">
  442. <span className="material-symbols-outlined">delete</span>
  443. {t('search_result.delete_all_selected_page')}
  444. </span>
  445. </DropdownItem>
  446. </DropdownMenu>
  447. </UncontrolledButtonDropdown>
  448. </OperateAllControl>
  449. </div>
  450. {isAdmin && renderOpenModalButton()}
  451. </div>
  452. );
  453. }, [
  454. convertMenuItemClickedHandler,
  455. deleteAllButtonClickedHandler,
  456. hitsCount,
  457. isAdmin,
  458. isControlEnabled,
  459. renderOpenModalButton,
  460. selectAllCheckboxChangedHandler,
  461. t,
  462. ]);
  463. const searchControl = useMemo(() => {
  464. return (
  465. <SearchControl
  466. disableUserPages={disableUserPages}
  467. isEnableSort={false}
  468. isEnableFilter={false}
  469. initialSearchConditions={{ keyword: initQ, limit: INITIAL_PAGING_SIZE }}
  470. onSearchInvoked={searchInvokedHandler}
  471. extraControls={extraControls}
  472. />
  473. );
  474. }, [searchInvokedHandler, extraControls, disableUserPages]);
  475. const searchResultListHead = useMemo(() => {
  476. if (data == null) {
  477. // biome-ignore lint/complexity/noUselessFragments: ignore
  478. return <></>;
  479. }
  480. return (
  481. <SearchResultListHead
  482. searchResult={data}
  483. offset={offset}
  484. pagingSize={limit}
  485. onPagingSizeChanged={pagingSizeChangedHandler}
  486. migrationStatus={migrationStatus}
  487. />
  488. );
  489. }, [data, limit, offset, pagingSizeChangedHandler, migrationStatus]);
  490. const searchPager = useMemo(() => {
  491. // when pager is not needed
  492. if (data == null || data.meta.hitsCount === data.meta.total) {
  493. // biome-ignore lint/complexity/noUselessFragments: ignore
  494. return <></>;
  495. }
  496. const { total } = data.meta;
  497. const { offset, limit } = conditions;
  498. return (
  499. <PaginationWrapper
  500. activePage={Math.floor(offset / limit) + 1}
  501. totalItemsCount={total}
  502. pagingLimit={limit}
  503. changePage={pagingNumberChangedHandler}
  504. />
  505. );
  506. }, [conditions, data, pagingNumberChangedHandler]);
  507. return (
  508. <>
  509. <SearchPageBase
  510. ref={searchPageBaseRef}
  511. pages={data?.data}
  512. onSelectedPagesByCheckboxesChanged={
  513. selectedPagesByCheckboxesChangedHandler
  514. }
  515. forceHideMenuItems={[
  516. MenuItemType.BOOKMARK,
  517. MenuItemType.RENAME,
  518. MenuItemType.DUPLICATE,
  519. MenuItemType.REVERT,
  520. MenuItemType.PATH_RECOVERY,
  521. ]}
  522. // Components
  523. searchControl={searchControl}
  524. searchResultListHead={searchResultListHead}
  525. searchPager={searchPager}
  526. />
  527. <PrivateLegacyPagesMigrationModal />
  528. <ConvertByPathModal
  529. isOpen={isOpenConvertModal}
  530. close={() => setOpenConvertModal(false)}
  531. onSubmit={async (convertPath: string) => {
  532. try {
  533. await apiv3Post<void>('/pages/convert-pages-by-path', {
  534. convertPath,
  535. });
  536. toastSuccess(t('private_legacy_pages.by_path_modal.success'));
  537. setOpenConvertModal(false);
  538. mutate();
  539. mutatePageTree();
  540. } catch (errs) {
  541. if (errs.length === 1) {
  542. switch (errs[0].code) {
  543. case V5ConversionErrCode.GRANT_INVALID:
  544. toastError(
  545. t('private_legacy_pages.by_path_modal.error_grant_invalid'),
  546. );
  547. break;
  548. case V5ConversionErrCode.PAGE_NOT_FOUND:
  549. toastError(
  550. t(
  551. 'private_legacy_pages.by_path_modal.error_page_not_found',
  552. ),
  553. );
  554. break;
  555. case V5ConversionErrCode.DUPLICATE_PAGES_FOUND:
  556. toastError(
  557. t(
  558. 'private_legacy_pages.by_path_modal.error_duplicate_pages_found',
  559. ),
  560. );
  561. break;
  562. default:
  563. toastError(t('private_legacy_pages.by_path_modal.error'));
  564. }
  565. } else {
  566. toastError(t('private_legacy_pages.by_path_modal.error'));
  567. }
  568. }
  569. }}
  570. />
  571. </>
  572. );
  573. };
  574. export default PrivateLegacyPages;