Просмотр исходного кода

Merge branch 'master' of https://github.com/weseek/growi

kaori 4 лет назад
Родитель
Сommit
2f9c0f8310
26 измененных файлов с 667 добавлено и 81 удалено
  1. 16 0
      packages/app/resource/locales/en_US/translation.json
  2. 17 1
      packages/app/resource/locales/ja_JP/translation.json
  3. 16 0
      packages/app/resource/locales/zh_CN/translation.json
  4. 3 0
      packages/app/src/client/app.jsx
  5. 28 9
      packages/app/src/components/Common/Dropdown/PageItemControl.tsx
  6. 99 0
      packages/app/src/components/LegacyPrivatePagesMigrationModal.tsx
  7. 9 2
      packages/app/src/components/Navbar/SubNavButtons.tsx
  8. 5 2
      packages/app/src/components/PageList/PageListItemL.tsx
  9. 303 0
      packages/app/src/components/PrivateLegacyPages.tsx
  10. 10 29
      packages/app/src/components/SearchPage.tsx
  11. 9 10
      packages/app/src/components/SearchPage/SearchResultContent.tsx
  12. 4 0
      packages/app/src/components/SearchPage/SearchResultList.tsx
  13. 42 13
      packages/app/src/components/SearchPage2/SearchPageBase.tsx
  14. 2 2
      packages/app/src/components/Sidebar/PageTree.tsx
  15. 0 1
      packages/app/src/components/Sidebar/PageTree/Item.tsx
  16. 2 4
      packages/app/src/components/Sidebar/PageTree/PrivateLegacyPagesLink.tsx
  17. 1 1
      packages/app/src/server/routes/apiv3/pages.js
  18. 1 1
      packages/app/src/server/routes/index.js
  19. 8 1
      packages/app/src/server/views/private-legacy-pages.html
  20. 3 4
      packages/app/src/server/views/search.html
  21. 40 0
      packages/app/src/stores/modal.tsx
  22. 13 0
      packages/app/src/stores/search.tsx
  23. 0 1
      packages/app/src/styles/_override-bootstrap.scss
  24. 30 0
      packages/app/test/cypress/integration/3-search/access-to-private-legacy-pages-directly.spec.ts
  25. 5 0
      packages/core/src/test/util/page-path-utils.test.js
  26. 1 0
      packages/core/src/utils/page-path-utils.ts

+ 16 - 0
packages/app/resource/locales/en_US/translation.json

@@ -638,6 +638,22 @@
       "updatedAt": "Last update date"
     }
   },
+  "private_legacy_pages": {
+    "bulk_operation": "Bulk operation",
+    "convert_all_selected_pages": "Convert all to new v5 compatible format",
+    "alert_title": "You are viewing old v4 compatible private pages.",
+    "alert_desc1": "On this page, you can select pages with the checkbox and batch convert to the new v5 compatible format from the \"Bulk operation\" button at the top of the screen.",
+    "nopages_title": "Congratulations. Ready to use GROWI v5!",
+    "nopages_desc1": "Now all the pages you can manage seem to be in v5 compatible format.",
+    "detail_info": "See the detail information from <a href='https://docs.growi.org/en/admin-guide/upgrading/50x.html' target='_blank' class='alert-link'>Upgrading GROWI to v5.0.x <i class='icon-share-alt'></i></a>.",
+    "modal": {
+      "title": "Convert to new v5 compatible format",
+      "converting_pages": "Converting pages",
+      "convert_recursively_label": "Convert child pages recursively.",
+      "convert_recursively_desc": "Convert pages under this path recursively.",
+      "button_label": "Convert"
+    }
+  },
   "security_setting": {
     "Guest Users Access": "Guest users access",
     "Fixed by env var": "This is fixed by the env var <code>%s=%s</code>.",

+ 17 - 1
packages/app/resource/locales/ja_JP/translation.json

@@ -637,6 +637,22 @@
       "updatedAt": "更新日時"
     }
   },
+  "private_legacy_pages": {
+    "bulk_operation": "一括操作",
+    "convert_all_selected_pages": "新しい v5 互換形式に一括変換",
+    "alert_title": "古い v4 互換形式のプライベートページを表示しています",
+    "alert_desc1": "このページでは、チェックボックスでページを選択し、画面上部の「一括操作」ボタンから新しい v5 互換形式に一括変換できます。",
+    "nopages_title": "おめでとうございます。GROWI v5 を使う準備が完了しました!",
+    "nopages_desc1": "今あなたが管理可能なページはすべて v5 互換形式になっているようです。",
+    "detail_info": "詳しくは <a href='https://docs.growi.org/ja/admin-guide/upgrading/50x.html' target='_blank' class='alert-link'>GROWI v5.0.x へのアップグレード  <i class='icon-share-alt'></i></a> を参照ください。",
+    "modal": {
+      "title": "新しい v5 互換形式への変換",
+      "converting_pages": "以下のページを変換します",
+      "convert_recursively_label": "再起的に変換",
+      "convert_recursively_desc": "このページの配下のページを再起的に変換します",
+      "button_label": "変換"
+    }
+  },
   "security_setting": {
     "Guest Users Access": "ゲストユーザーのアクセス",
     "Fixed by env var": "環境変数 <code>{{forcewikimode}}={{wikimode}}</code> により固定されています。",
@@ -972,7 +988,7 @@
     "password_and_confirm_password_does_not_match": "パスワードと確認パスワードが一致しません"
   },
   "pagetree": {
-    "private_legacy_pages": "待避所",
+    "private_legacy_pages": "旧形式のプライベートページ",
     "cannot_rename_a_title_that_contains_slash": "`/` が含まれているタイトルにリネームできません",
     "you_cannot_move_this_page_now": "現在、このページを移動することはできません",
     "something_went_wrong_with_moving_page": "ページの移動に問題が発生しました"

+ 16 - 0
packages/app/resource/locales/zh_CN/translation.json

@@ -913,6 +913,22 @@
       "updatedAt": "按更新日期排序"
     }
 	},
+  "private_legacy_pages": {
+    "bulk_operation": "Bulk operation",
+    "convert_all_selected_pages": "Convert all to new v5 compatible format",
+    "alert_title": "You are viewing old v4 compatible private pages.",
+    "alert_desc1": "On this page, you can select pages with the checkbox and batch convert to the new v5 compatible format from the \"Bulk operation\" button at the top of the screen.",
+    "nopages_title": "Congratulations. Ready to use GROWI v5!",
+    "nopages_desc1": "Now all the pages you can manage seem to be in v5 compatible format.",
+    "detail_info": "See the detail information from <a href='https://docs.growi.org/en/admin-guide/upgrading/50x.html' target='_blank' class='alert-link'>Upgrading GROWI to v5.0.x <i class='icon-share-alt'></i></a>.",
+    "modal": {
+      "title": "Convert to new v5 compatible format",
+      "converting_pages": "Converting pages",
+      "convert_recursively_label": "Convert child pages recursively.",
+      "convert_recursively_desc": "Convert pages under this path recursively.",
+      "button_label": "Convert"
+    }
+  },
 	"to_cloud_settings": "進入 GROWI.cloud 的管理界面",
 	"login": {
 		"Sign in error": "登录错误",

+ 3 - 0
packages/app/src/client/app.jsx

@@ -54,6 +54,7 @@ import PersonalContainer from '~/client/services/PersonalContainer';
 
 import { appContainer, componentMappings } from './base';
 import { toastError } from './util/apiNotification';
+import { PrivateLegacyPages } from '~/components/PrivateLegacyPages';
 
 const logger = loggerFactory('growi:cli:app');
 
@@ -86,6 +87,8 @@ Object.assign(componentMappings, {
   'grw-sidebar-wrapper': <Sidebar />,
 
   'search-page': <SearchPage appContainer={appContainer} />,
+  'private-regacy-pages': <PrivateLegacyPages appContainer={appContainer} />,
+
   'all-in-app-notifications': <InAppNotificationPage />,
   'identical-path-page': <IdenticalPathPage />,
 

+ 28 - 9
packages/app/src/components/Common/Dropdown/PageItemControl.tsx

@@ -3,7 +3,6 @@ import {
   Dropdown, DropdownMenu, DropdownToggle, DropdownItem,
 } from 'reactstrap';
 
-import toastr from 'toastr';
 import { useTranslation } from 'react-i18next';
 
 import loggerFactory from '~/utils/logger';
@@ -16,12 +15,23 @@ import { useSWRxPageInfo } from '~/stores/page';
 const logger = loggerFactory('growi:cli:PageItemControl');
 
 
+export const MenuItemType = {
+  BOOKMARK: 'bookmark',
+  DUPLICATE: 'duplicate',
+  RENAME: 'rename',
+  DELETE: 'delete',
+} as const;
+export type MenuItemType = typeof MenuItemType[keyof typeof MenuItemType];
+
+export type ForceHideMenuItems = MenuItemType[];
+
 export type AdditionalMenuItemsRendererProps = { pageInfo: IPageInfoAll };
 
 type CommonProps = {
   pageInfo?: IPageInfoAll,
   isEnableActions?: boolean,
-  showBookmarkMenuItem?: boolean,
+  forceHideMenuItems?: ForceHideMenuItems,
+
   onClickBookmarkMenuItem?: (pageId: string, newValue?: boolean) => Promise<void>,
   onClickDuplicateMenuItem?: (pageId: string) => Promise<void> | void,
   onClickRenameMenuItem?: (pageId: string) => Promise<void> | void,
@@ -41,7 +51,7 @@ const PageItemControlDropdownMenu = React.memo((props: DropdownMenuProps): JSX.E
 
   const {
     pageId, isLoading,
-    pageInfo, isEnableActions, showBookmarkMenuItem,
+    pageInfo, isEnableActions, forceHideMenuItems,
     onClickBookmarkMenuItem, onClickDuplicateMenuItem, onClickRenameMenuItem, onClickDeleteMenuItem,
     additionalMenuItemRenderer: AdditionalMenuItems,
   } = props;
@@ -93,6 +103,10 @@ const PageItemControlDropdownMenu = React.memo((props: DropdownMenuProps): JSX.E
     );
   }
   else if (pageId != null && pageInfo != null) {
+
+    const showDeviderBeforeAdditionalMenuItems = (forceHideMenuItems?.length ?? 0) < 3;
+    const showDeviderBeforeDelete = AdditionalMenuItems != null || showDeviderBeforeAdditionalMenuItems;
+
     contents = (
       <>
         { !isEnableActions && (
@@ -104,7 +118,7 @@ const PageItemControlDropdownMenu = React.memo((props: DropdownMenuProps): JSX.E
         ) }
 
         {/* Bookmark */}
-        { showBookmarkMenuItem && isEnableActions && !pageInfo.isEmpty && isIPageInfoForOperation(pageInfo) && (
+        { !forceHideMenuItems?.includes(MenuItemType.BOOKMARK) && isEnableActions && !pageInfo.isEmpty && isIPageInfoForOperation(pageInfo) && (
           <DropdownItem onClick={bookmarkItemClickedHandler}>
             <i className="fa fa-fw fa-bookmark-o"></i>
             { pageInfo.isBookmarked ? t('remove_bookmark') : t('add_bookmark') }
@@ -112,7 +126,7 @@ const PageItemControlDropdownMenu = React.memo((props: DropdownMenuProps): JSX.E
         ) }
 
         {/* Duplicate */}
-        { isEnableActions && (
+        { !forceHideMenuItems?.includes(MenuItemType.DUPLICATE) && isEnableActions && (
           <DropdownItem onClick={duplicateItemClickedHandler}>
             <i className="icon-fw icon-docs"></i>
             {t('Duplicate')}
@@ -120,20 +134,25 @@ const PageItemControlDropdownMenu = React.memo((props: DropdownMenuProps): JSX.E
         ) }
 
         {/* Move/Rename */}
-        { isEnableActions && pageInfo.isMovable && (
+        { !forceHideMenuItems?.includes(MenuItemType.RENAME) && isEnableActions && pageInfo.isMovable && (
           <DropdownItem onClick={renameItemClickedHandler}>
             <i className="icon-fw  icon-action-redo"></i>
             {t('Move/Rename')}
           </DropdownItem>
         ) }
 
-        { AdditionalMenuItems && <AdditionalMenuItems pageInfo={pageInfo} /> }
+        { AdditionalMenuItems && (
+          <>
+            { showDeviderBeforeAdditionalMenuItems && <DropdownItem divider /> }
+            <AdditionalMenuItems pageInfo={pageInfo} />
+          </>
+        ) }
 
         {/* divider */}
         {/* Delete */}
-        { isEnableActions && pageInfo.isMovable && (
+        { !forceHideMenuItems?.includes(MenuItemType.DELETE) && isEnableActions && pageInfo.isMovable && (
           <>
-            <DropdownItem divider />
+            { showDeviderBeforeDelete && <DropdownItem divider /> }
             <DropdownItem
               className={`pt-2 ${pageInfo.isDeletable ? 'text-danger' : ''}`}
               disabled={!pageInfo.isDeletable}

+ 99 - 0
packages/app/src/components/LegacyPrivatePagesMigrationModal.tsx

@@ -0,0 +1,99 @@
+import React, { useState } from 'react';
+import {
+  Modal, ModalHeader, ModalBody, ModalFooter,
+} from 'reactstrap';
+import { useTranslation } from 'react-i18next';
+
+import { apiv3Post } from '~/client/util/apiv3-client';
+import { useLegacyPrivatePagesMigrationModal } from '~/stores/modal';
+
+import ApiErrorMessageList from './PageManagement/ApiErrorMessageList';
+
+
+type Props = {
+
+}
+
+export const LegacyPrivatePagesMigrationModal = (props: Props): JSX.Element => {
+  const { t } = useTranslation();
+
+  const { data: status, close } = useLegacyPrivatePagesMigrationModal();
+
+  const isOpened = status?.isOpened ?? false;
+
+  const [isRecursively, setIsRecursively] = useState(true);
+
+  // eslint-disable-next-line @typescript-eslint/no-unused-vars
+  const [errs, setErrs] = useState<Error[] | null>(null);
+
+  async function submit() {
+    if (status == null || status.pages == null || status.pages.length === 0) {
+      return;
+    }
+
+    const { pages, onSubmited } = status;
+    const pageIds = pages.map(page => page.pageId);
+    try {
+      await apiv3Post<void>('/pages/legacy-pages-migration', {
+        pageIds,
+        isRecursively: isRecursively ? true : undefined,
+      });
+
+      if (onSubmited != null) {
+        onSubmited(pages, isRecursively);
+      }
+    }
+    catch (err) {
+      setErrs([err]);
+    }
+  }
+
+  function renderForm() {
+    return (
+      <div className="custom-control custom-checkbox custom-checkbox-warning">
+        <input
+          className="custom-control-input"
+          id="convertRecursively"
+          type="checkbox"
+          onChange={e => setIsRecursively(e.target.checked)}
+        />
+        <label className="custom-control-label" htmlFor="convertRecursively">
+          { t('private_legacy_pages.modal.convert_recursively_label') }
+          <p className="form-text text-muted mt-0"> { t('private_legacy_pages.modal.convert_recursively_desc') }</p>
+        </label>
+      </div>
+    );
+  }
+
+  const renderPageIds = () => {
+    if (status != null && status.pages != null) {
+      return status.pages.map(page => <div key={page.pageId}><code>{ page.path }</code></div>);
+    }
+    return <></>;
+  };
+
+  return (
+    <Modal size="lg" isOpen={isOpened} toggle={close} className="grw-create-page">
+      <ModalHeader tag="h4" toggle={close} className="bg-primary text-light">
+        { t('private_legacy_pages.modal.title') }
+      </ModalHeader>
+      <ModalBody>
+        <div className="form-group grw-scrollable-modal-body pb-1">
+          <label>{ t('private_legacy_pages.modal.converting_pages') }:</label><br />
+          {/* Todo: change the way to show path on modal when too many pages are selected */}
+          {/* https://redmine.weseek.co.jp/issues/82787 */}
+          {renderPageIds()}
+        </div>
+        {renderForm()}
+      </ModalBody>
+      <ModalFooter>
+        <ApiErrorMessageList errs={errs} />
+        <button type="button" className="btn btn-primary" onClick={submit}>
+          <i className="icon-fw icon-refresh" aria-hidden="true"></i>
+          { t('private_legacy_pages.modal.button_label') }
+        </button>
+      </ModalFooter>
+    </Modal>
+
+  );
+};

+ 9 - 2
packages/app/src/components/Navbar/SubNavButtons.tsx

@@ -13,13 +13,16 @@ import LikeButtons from '../LikeButtons';
 import BookmarkButtons from '../BookmarkButtons';
 import SeenUserInfo from '../User/SeenUserInfo';
 import { toggleBookmark, toggleLike, toggleSubscribe } from '~/client/services/page-operation';
-import { AdditionalMenuItemsRendererProps, PageItemControl } from '../Common/Dropdown/PageItemControl';
+import {
+  AdditionalMenuItemsRendererProps, ForceHideMenuItems, MenuItemType, PageItemControl,
+} from '../Common/Dropdown/PageItemControl';
 
 
 type CommonProps = {
   isCompactMode?: boolean,
   disableSeenUserInfoPopover?: boolean,
   showPageControlDropdown?: boolean,
+  forceHideMenuItems?: ForceHideMenuItems,
   additionalMenuItemRenderer?: React.FunctionComponent<AdditionalMenuItemsRendererProps>,
   onClickDuplicateMenuItem?: (pageId: string, path: string) => void,
   onClickRenameMenuItem?: (pageId: string, revisionId: string, path: string) => void,
@@ -38,7 +41,7 @@ const SubNavButtonsSubstance = (props: SubNavButtonsSubstanceProps): JSX.Element
   const {
     pageInfo,
     pageId, revisionId, path, shareLinkId,
-    isCompactMode, disableSeenUserInfoPopover, showPageControlDropdown, additionalMenuItemRenderer,
+    isCompactMode, disableSeenUserInfoPopover, showPageControlDropdown, forceHideMenuItems, additionalMenuItemRenderer,
     onClickDuplicateMenuItem, onClickRenameMenuItem, onClickDeleteMenuItem,
   } = props;
 
@@ -132,6 +135,9 @@ const SubNavButtonsSubstance = (props: SubNavButtonsSubstanceProps): JSX.Element
     sumOfLikers, isLiked, bookmarkCount, isBookmarked,
   } = pageInfo;
 
+  const forceHideMenuItemsWithBookmark = forceHideMenuItems ?? [];
+  forceHideMenuItemsWithBookmark.push(MenuItemType.BOOKMARK);
+
   return (
     <div className="d-flex" style={{ gap: '2px' }}>
       <span>
@@ -162,6 +168,7 @@ const SubNavButtonsSubstance = (props: SubNavButtonsSubstanceProps): JSX.Element
           pageId={pageId}
           pageInfo={pageInfo}
           isEnableActions={!isGuestUser}
+          forceHideMenuItems={forceHideMenuItemsWithBookmark}
           additionalMenuItemRenderer={additionalMenuItemRenderer}
           onClickRenameMenuItem={renameMenuItemClickHandler}
           onClickDuplicateMenuItem={duplicateMenuItemClickHandler}

+ 5 - 2
packages/app/src/components/PageList/PageListItemL.tsx

@@ -18,7 +18,7 @@ import {
 } from '~/interfaces/page';
 import { IPageSearchMeta, isIPageSearchMeta } from '~/interfaces/search';
 
-import { PageItemControl } from '../Common/Dropdown/PageItemControl';
+import { ForceHideMenuItems, PageItemControl } from '../Common/Dropdown/PageItemControl';
 import LinkedPagePath from '~/models/linked-page-path';
 import PagePathHierarchicalLink from '../PagePathHierarchicalLink';
 import { ISelectable } from '~/client/interfaces/selectable-all';
@@ -27,6 +27,7 @@ type Props = {
   page: IPageWithMeta | IPageWithMeta<IPageInfoAll & IPageSearchMeta>,
   isSelected?: boolean, // is item selected(focused)
   isEnableActions?: boolean,
+  forceHideMenuItems?: ForceHideMenuItems,
   showPageUpdatedTime?: boolean, // whether to show page's updated time at the top-right corner of item
   onCheckboxChanged?: (isChecked: boolean, pageId: string) => void,
   onClickItem?: (pageId: string) => void,
@@ -36,6 +37,7 @@ const PageListItemLSubstance: ForwardRefRenderFunction<ISelectable, Props> = (pr
   const {
     // todo: refactoring variable name to clear what changed
     page: { pageData, pageMeta }, isSelected, isEnableActions,
+    forceHideMenuItems,
     showPageUpdatedTime,
     onClickItem, onCheckboxChanged,
   } = props;
@@ -161,9 +163,10 @@ const PageListItemLSubstance: ForwardRefRenderFunction<ISelectable, Props> = (pr
                 <PageItemControl
                   pageId={pageData._id}
                   pageInfo={pageMeta}
+                  isEnableActions={isEnableActions}
+                  forceHideMenuItems={forceHideMenuItems}
                   onClickDeleteMenuItem={deleteMenuItemClickHandler}
                   onClickRenameMenuItem={renameMenuItemClickHandler}
-                  isEnableActions={isEnableActions}
                   onClickDuplicateMenuItem={duplicateMenuItemClickHandler}
                 />
               </div>

+ 303 - 0
packages/app/src/components/PrivateLegacyPages.tsx

@@ -0,0 +1,303 @@
+import React, {
+  useCallback, useMemo, useRef, useState,
+} from 'react';
+import { useTranslation } from 'react-i18next';
+
+import {
+  UncontrolledButtonDropdown, DropdownToggle, DropdownMenu, DropdownItem,
+} from 'reactstrap';
+
+import { IFormattedSearchResult } from '~/interfaces/search';
+import AppContainer from '~/client/services/AppContainer';
+import { ISelectableAll, ISelectableAndIndeterminatable } from '~/client/interfaces/selectable-all';
+import { toastSuccess } from '~/client/util/apiNotification';
+import {
+  ISearchConfigurations, useSWRxNamedQuerySearch,
+} from '~/stores/search';
+import { ILegacyPrivatePage, useLegacyPrivatePagesMigrationModal } from '~/stores/modal';
+
+import PaginationWrapper from './PaginationWrapper';
+import { OperateAllControl } from './SearchPage/OperateAllControl';
+
+import { IReturnSelectedPageIds, SearchPageBase } from './SearchPage2/SearchPageBase';
+import { MenuItemType } from './Common/Dropdown/PageItemControl';
+import { LegacyPrivatePagesMigrationModal } from './LegacyPrivatePagesMigrationModal';
+
+
+// TODO: replace with "customize:showPageLimitationS"
+const INITIAL_PAGIONG_SIZE = 20;
+
+
+/**
+ * SearchResultListHead
+ */
+
+type SearchResultListHeadProps = {
+  searchResult: IFormattedSearchResult,
+  offset: number,
+  pagingSize: number,
+  onPagingSizeChanged: (size: number) => void,
+}
+
+const SearchResultListHead = React.memo((props: SearchResultListHeadProps): JSX.Element => {
+  const { t } = useTranslation();
+
+  const {
+    searchResult, offset, pagingSize,
+    onPagingSizeChanged,
+  } = props;
+
+  const { took, total, hitsCount } = searchResult.meta;
+  const leftNum = offset + 1;
+  const rightNum = offset + hitsCount;
+
+  if (total === 0) {
+    return (
+      <div className="card border-success mt-3">
+        <div className="card-body">
+          <h2 className="card-title text-success">{t('private_legacy_pages.nopages_title')}</h2>
+          <p className="card-text">
+            {t('private_legacy_pages.nopages_desc1')}<br />
+            {/* eslint-disable-next-line react/no-danger */}
+            <span dangerouslySetInnerHTML={{ __html: t('private_legacy_pages.detail_info') }}></span>
+          </p>
+        </div>
+      </div>
+    );
+  }
+
+  return (
+    <>
+      <div className="form-inline d-flex align-items-center justify-content-between">
+        <div className="text-nowrap">
+          {t('search_result.result_meta')}
+          <span className="ml-3">{`${leftNum}-${rightNum}`} / {total}</span>
+          { took != null && (
+            <span className="ml-3 text-muted">({took}ms)</span>
+          ) }
+        </div>
+        <div className="input-group flex-nowrap search-result-select-group ml-auto d-md-flex d-none">
+          <div className="input-group-prepend">
+            <label className="input-group-text text-muted" htmlFor="inputGroupSelect01">{t('search_result.number_of_list_to_display')}</label>
+          </div>
+          <select
+            defaultValue={pagingSize}
+            className="custom-select"
+            id="inputGroupSelect01"
+            onChange={e => onPagingSizeChanged(Number(e.target.value))}
+          >
+            {[20, 50, 100, 200].map((limit) => {
+              return <option key={limit} value={limit}>{limit} {t('search_result.page_number_unit')}</option>;
+            })}
+          </select>
+        </div>
+      </div>
+      <div className="card border-warning mt-3">
+        <div className="card-body">
+          <h2 className="card-title text-warning">{t('private_legacy_pages.alert_title')}</h2>
+          <p className="card-text">
+            {t('private_legacy_pages.alert_desc1', { delete_all_selected_page: t('search_result.delete_all_selected_page') })}<br />
+            {/* eslint-disable-next-line react/no-danger */}
+            <span dangerouslySetInnerHTML={{ __html: t('private_legacy_pages.detail_info') }}></span>
+          </p>
+        </div>
+      </div>
+    </>
+  );
+});
+
+
+/**
+ * LegacyPage
+ */
+
+type Props = {
+  appContainer: AppContainer,
+}
+
+export const PrivateLegacyPages = (props: Props): JSX.Element => {
+  const { t } = useTranslation();
+
+  const {
+    appContainer,
+  } = props;
+
+
+  const [configurationsByPagination, setConfigurationsByPagination] = useState<Partial<ISearchConfigurations>>({
+    limit: INITIAL_PAGIONG_SIZE,
+  });
+  const [isControlEnabled, setControlEnabled] = useState(false);
+
+  const selectAllControlRef = useRef<ISelectableAndIndeterminatable|null>(null);
+  const searchPageBaseRef = useRef<ISelectableAll & IReturnSelectedPageIds|null>(null);
+
+  const { data, conditions, mutate } = useSWRxNamedQuerySearch('PrivateLegacyPages', {
+    limit: INITIAL_PAGIONG_SIZE,
+    ...configurationsByPagination,
+  });
+
+  const { open: openModal, close: closeModal } = useLegacyPrivatePagesMigrationModal();
+
+  const selectAllCheckboxChangedHandler = useCallback((isChecked: boolean) => {
+    const instance = searchPageBaseRef.current;
+
+    if (instance == null) {
+      return;
+    }
+
+    if (isChecked) {
+      instance.selectAll();
+      setControlEnabled(true);
+    }
+    else {
+      instance.deselectAll();
+      setControlEnabled(false);
+    }
+  }, []);
+
+  const selectedPagesByCheckboxesChangedHandler = useCallback((selectedCount: number, totalCount: number) => {
+    const instance = selectAllControlRef.current;
+
+    if (instance == null) {
+      return;
+    }
+
+    if (selectedCount === 0) {
+      instance.deselect();
+      setControlEnabled(false);
+    }
+    else if (selectedCount === totalCount) {
+      instance.select();
+      setControlEnabled(true);
+    }
+    else {
+      instance.setIndeterminate();
+      setControlEnabled(true);
+    }
+  }, []);
+
+  const convertMenuItemClickedHandler = useCallback(() => {
+    if (data == null) {
+      return;
+    }
+
+    const instance = searchPageBaseRef.current;
+    if (instance == null || instance.getSelectedPageIds == null) {
+      return;
+    }
+
+    const selectedPageIds = instance.getSelectedPageIds();
+
+    if (selectedPageIds.size === 0) {
+      return;
+    }
+
+    const selectedPages = data.data
+      .filter(pageWithMeta => selectedPageIds.has(pageWithMeta.pageData._id))
+      .map(pageWithMeta => ({ pageId: pageWithMeta.pageData._id, path: pageWithMeta.pageData.path } as ILegacyPrivatePage));
+
+    openModal(
+      selectedPages,
+      () => {
+        toastSuccess('success');
+        closeModal();
+        mutate();
+      },
+    );
+  }, [data, mutate, openModal, closeModal]);
+
+  const pagingNumberChangedHandler = useCallback((activePage: number) => {
+    const currentLimit = configurationsByPagination.limit ?? INITIAL_PAGIONG_SIZE;
+    setConfigurationsByPagination({
+      ...configurationsByPagination,
+      offset: (activePage - 1) * currentLimit,
+    });
+  }, [configurationsByPagination]);
+
+  const { offset, limit } = conditions;
+
+  const searchControl = useMemo(() => {
+    return (
+      <div className="position-sticky fixed-top shadow-sm">
+        <div className="search-control d-flex align-items-center py-md-2 py-3 px-md-4 px-3 border-bottom border-gray">
+          <div className="d-flex pl-md-2">
+            <OperateAllControl
+              ref={selectAllControlRef}
+              isCheckboxDisabled={!isControlEnabled}
+              onCheckboxChanged={selectAllCheckboxChangedHandler}
+            >
+              <UncontrolledButtonDropdown>
+                <DropdownToggle caret color="outline-primary" disabled={!isControlEnabled}>
+                  {t('private_legacy_pages.bulk_operation')}
+                </DropdownToggle>
+                <DropdownMenu>
+                  <DropdownItem onClick={convertMenuItemClickedHandler}>
+                    <i className="icon-fw icon-refresh"></i>
+                    {t('private_legacy_pages.convert_all_selected_pages')}
+                  </DropdownItem>
+                  <DropdownItem onClick={() => { /* TODO: implement */ }}>
+                    <span className="text-danger">
+                      <i className="icon-fw icon-trash"></i>
+                      {t('search_result.delete_all_selected_page')}
+                    </span>
+                  </DropdownItem>
+                </DropdownMenu>
+              </UncontrolledButtonDropdown>
+            </OperateAllControl>
+          </div>
+        </div>
+      </div>
+    );
+  }, [convertMenuItemClickedHandler, isControlEnabled, selectAllCheckboxChangedHandler, t]);
+
+  const searchResultListHead = useMemo(() => {
+    if (data == null) {
+      return <></>;
+    }
+    return (
+      <SearchResultListHead
+        searchResult={data}
+        offset={offset}
+        pagingSize={limit}
+        onPagingSizeChanged={() => {}}
+      />
+    );
+  }, [data, limit, offset]);
+
+  const searchPager = useMemo(() => {
+    // when pager is not needed
+    if (data == null || data.meta.hitsCount === data.meta.total) {
+      return <></>;
+    }
+
+    const { total } = data.meta;
+    const { offset, limit } = conditions;
+
+    return (
+      <PaginationWrapper
+        activePage={Math.floor(offset / limit) + 1}
+        totalItemsCount={total}
+        pagingLimit={configurationsByPagination?.limit}
+        changePage={pagingNumberChangedHandler}
+      />
+    );
+  }, [conditions, configurationsByPagination?.limit, data, pagingNumberChangedHandler]);
+
+  return (
+    <>
+      <SearchPageBase
+        ref={searchPageBaseRef}
+        appContainer={appContainer}
+        pages={data?.data}
+        onSelectedPagesByCheckboxesChanged={selectedPagesByCheckboxesChangedHandler}
+        forceHideMenuItems={[MenuItemType.BOOKMARK, MenuItemType.RENAME, MenuItemType.DUPLICATE]}
+        // Components
+        searchControl={searchControl}
+        searchResultListHead={searchResultListHead}
+        searchPager={searchPager}
+      />
+
+      <LegacyPrivatePagesMigrationModal />
+    </>
+  );
+};

+ 10 - 29
packages/app/src/components/SearchPage.tsx

@@ -8,7 +8,7 @@ import { parse as parseQuerystring } from 'querystring';
 import AppContainer from '~/client/services/AppContainer';
 import { IFormattedSearchResult } from '~/interfaces/search';
 import { ISelectableAll, ISelectableAndIndeterminatable } from '~/client/interfaces/selectable-all';
-import { useIsSearchServiceConfigured, useIsSearchServiceReachable } from '~/stores/context';
+import { useIsSearchServiceReachable } from '~/stores/context';
 import { ISearchConditions, ISearchConfigurations, useSWRxFullTextSearch } from '~/stores/search';
 
 import PaginationWrapper from './PaginationWrapper';
@@ -46,14 +46,20 @@ const SearchResultListHead = React.memo((props: SearchResultListHeadProps): JSX.
   const leftNum = offset + 1;
   const rightNum = offset + hitsCount;
 
+  if (total === 0) {
+    return (
+      <div className="d-flex justify-content-center h2 text-muted my-5">
+        0 {t('search_result.page_number_unit')}
+      </div>
+    );
+  }
+
   return (
     <div className="form-inline d-flex align-items-center justify-content-between">
       <div className="text-nowrap">
         {t('search_result.result_meta')}
         <span className="search-result-keyword">{`${searchingKeyword}`}</span>
-        { total > 0 && (
-          <span className="ml-3">{`${leftNum}-${rightNum}`} / {total}</span>
-        ) }
+        <span className="ml-3">{`${leftNum}-${rightNum}`} / {total}</span>
         { took != null && (
           <span className="ml-3 text-muted">({took}ms)</span>
         ) }
@@ -111,7 +117,6 @@ export const SearchPage = (props: Props): JSX.Element => {
   const selectAllControlRef = useRef<ISelectableAndIndeterminatable|null>(null);
   const searchPageBaseRef = useRef<ISelectableAll|null>(null);
 
-  const { data: isSearchServiceConfigured } = useIsSearchServiceConfigured();
   const { data: isSearchServiceReachable } = useIsSearchServiceReachable();
 
   const { data, conditions } = useSWRxFullTextSearch(keyword, {
@@ -254,30 +259,6 @@ export const SearchPage = (props: Props): JSX.Element => {
     );
   }, [conditions, configurationsByPagination?.limit, data, pagingNumberChangedHandler]);
 
-  if (!isSearchServiceConfigured) {
-    return (
-      <div className="grw-container-convertible">
-        <div className="row mt-5">
-          <div className="col text-muted">
-            <h1>Search service is not configured in this system.</h1>
-          </div>
-        </div>
-      </div>
-    );
-  }
-
-  if (!isSearchServiceReachable) {
-    return (
-      <div className="grw-container-convertible">
-        <div className="row mt-5">
-          <div className="col text-muted">
-            <h1>Search service occures errors. Please contact to administrators of this system.</h1>
-          </div>
-        </div>
-      </div>
-    );
-  }
-
   return (
     <SearchPageBase
       ref={searchPageBaseRef}

+ 9 - 10
packages/app/src/components/SearchPage/SearchResultContent.tsx

@@ -15,7 +15,7 @@ import AppContainer from '../../client/services/AppContainer';
 import { smoothScrollIntoView } from '~/client/util/smooth-scroll';
 import { GrowiSubNavigation } from '../Navbar/GrowiSubNavigation';
 import { SubNavButtons } from '../Navbar/SubNavButtons';
-import { AdditionalMenuItemsRendererProps } from '../Common/Dropdown/PageItemControl';
+import { AdditionalMenuItemsRendererProps, ForceHideMenuItems } from '../Common/Dropdown/PageItemControl';
 
 import {
   usePageDuplicateModal, usePageRenameModal, usePageDeleteModal, OnDeletedFunction,
@@ -33,15 +33,11 @@ const AdditionalMenuItems = (props: AdditionalMenuItemsProps): JSX.Element => {
   const { pageId, revisionId } = props;
 
   return (
-    <>
-      <DropdownItem divider />
-
-      {/* Export markdown */}
-      <DropdownItem onClick={() => exportAsMarkdown(pageId, revisionId, 'md')}>
-        <i className="icon-fw icon-cloud-download"></i>
-        {t('export_bulk.export_page_markdown')}
-      </DropdownItem>
-    </>
+    // Export markdown
+    <DropdownItem onClick={() => exportAsMarkdown(pageId, revisionId, 'md')}>
+      <i className="icon-fw icon-cloud-download"></i>
+      {t('export_bulk.export_page_markdown')}
+    </DropdownItem>
   );
 };
 
@@ -53,6 +49,7 @@ type Props ={
   pageWithMeta : IPageWithMeta<IPageSearchMeta>,
   highlightKeywords?: string[],
   showPageControlDropdown?: boolean,
+  forceHideMenuItems?: ForceHideMenuItems,
 }
 
 const scrollTo = (scrollElement:HTMLElement) => {
@@ -99,6 +96,7 @@ export const SearchResultContent: FC<Props> = (props: Props) => {
     pageWithMeta,
     highlightKeywords,
     showPageControlDropdown,
+    forceHideMenuItems,
   } = props;
 
   const page = pageWithMeta?.pageData;
@@ -145,6 +143,7 @@ export const SearchResultContent: FC<Props> = (props: Props) => {
             revisionId={revisionId}
             path={page.path}
             showPageControlDropdown={showPageControlDropdown}
+            forceHideMenuItems={forceHideMenuItems}
             additionalMenuItemRenderer={props => <AdditionalMenuItems {...props} pageId={page._id} revisionId={revisionId} />}
             isCompactMode
             onClickDuplicateMenuItem={duplicateItemClickedHandler}

+ 4 - 0
packages/app/src/components/SearchPage/SearchResultList.tsx

@@ -7,6 +7,7 @@ import { IPageWithMeta, isIPageInfoForListing } from '~/interfaces/page';
 import { IPageSearchMeta } from '~/interfaces/search';
 import { useIsGuestUser } from '~/stores/context';
 import { useSWRxPageInfoForList } from '~/stores/page';
+import { ForceHideMenuItems } from '../Common/Dropdown/PageItemControl';
 
 import { PageListItemL } from '../PageList/PageListItemL';
 
@@ -14,6 +15,7 @@ import { PageListItemL } from '../PageList/PageListItemL';
 type Props = {
   pages: IPageWithMeta<IPageSearchMeta>[],
   selectedPageId?: string,
+  forceHideMenuItems?: ForceHideMenuItems,
   onPageSelected?: (page?: IPageWithMeta<IPageSearchMeta>) => void,
   onCheckboxChanged?: (isChecked: boolean, pageId: string) => void,
 }
@@ -21,6 +23,7 @@ type Props = {
 const SearchResultListSubstance: ForwardRefRenderFunction<ISelectableAll, Props> = (props:Props, ref) => {
   const {
     pages, selectedPageId,
+    forceHideMenuItems,
     onPageSelected,
   } = props;
 
@@ -89,6 +92,7 @@ const SearchResultListSubstance: ForwardRefRenderFunction<ISelectableAll, Props>
             page={page}
             isEnableActions={!isGuestUser}
             isSelected={page.pageData._id === selectedPageId}
+            forceHideMenuItems={forceHideMenuItems}
             onClickItem={clickItemHandler}
             onCheckboxChanged={props.onCheckboxChanged}
           />

+ 42 - 13
packages/app/src/components/SearchPage2/SearchPageBase.tsx

@@ -1,21 +1,28 @@
 import React, {
   forwardRef, ForwardRefRenderFunction, useEffect, useImperativeHandle, useRef, useState,
 } from 'react';
-import { useTranslation } from 'react-i18next';
 import { ISelectableAll } from '~/client/interfaces/selectable-all';
 import AppContainer from '~/client/services/AppContainer';
 import { IPageWithMeta } from '~/interfaces/page';
 import { IPageSearchMeta } from '~/interfaces/search';
-import { useIsGuestUser } from '~/stores/context';
+import { useIsGuestUser, useIsSearchServiceConfigured, useIsSearchServiceReachable } from '~/stores/context';
+import { ForceHideMenuItems } from '../Common/Dropdown/PageItemControl';
 
 import { SearchResultContent } from '../SearchPage/SearchResultContent';
 import { SearchResultList } from '../SearchPage/SearchResultList';
 
+export interface IReturnSelectedPageIds {
+  getSelectedPageIds?: () => Set<string>,
+}
+
+
 type Props = {
   appContainer: AppContainer,
 
   pages?: IPageWithMeta<IPageSearchMeta>[],
 
+  forceHideMenuItems?: ForceHideMenuItems,
+
   onSelectedPagesByCheckboxesChanged?: (selectedCount: number, totalCount: number) => void,
 
   searchControl: React.ReactNode,
@@ -23,12 +30,11 @@ type Props = {
   searchPager: React.ReactNode,
 }
 
-const SearchPageBaseSubstance: ForwardRefRenderFunction<ISelectableAll, Props> = (props:Props, ref) => {
-  const { t } = useTranslation();
-
+const SearchPageBaseSubstance: ForwardRefRenderFunction<ISelectableAll & IReturnSelectedPageIds, Props> = (props:Props, ref) => {
   const {
     appContainer,
     pages,
+    forceHideMenuItems,
     onSelectedPagesByCheckboxesChanged,
     searchControl, searchResultListHead, searchPager,
   } = props;
@@ -36,6 +42,8 @@ const SearchPageBaseSubstance: ForwardRefRenderFunction<ISelectableAll, Props> =
   const searchResultListRef = useRef<ISelectableAll|null>(null);
 
   const { data: isGuestUser } = useIsGuestUser();
+  const { data: isSearchServiceConfigured } = useIsSearchServiceConfigured();
+  const { data: isSearchServiceReachable } = useIsSearchServiceReachable();
 
   // TODO get search keywords and split
   // ref: RevisionRenderer
@@ -65,6 +73,9 @@ const SearchPageBaseSubstance: ForwardRefRenderFunction<ISelectableAll, Props> =
 
       selectedPageIdsByCheckboxes.clear();
     },
+    getSelectedPageIds: () => {
+      return selectedPageIdsByCheckboxes;
+    },
   }));
 
   const checkboxChangedHandler = (isChecked: boolean, pageId: string) => {
@@ -106,6 +117,30 @@ const SearchPageBaseSubstance: ForwardRefRenderFunction<ISelectableAll, Props> =
     }
   }, [onSelectedPagesByCheckboxesChanged, pages, selectedPageIdsByCheckboxes]);
 
+  if (!isSearchServiceConfigured) {
+    return (
+      <div className="grw-container-convertible">
+        <div className="row mt-5">
+          <div className="col text-muted">
+            <h1>Search service is not configured in this system.</h1>
+          </div>
+        </div>
+      </div>
+    );
+  }
+
+  if (!isSearchServiceReachable) {
+    return (
+      <div className="grw-container-convertible">
+        <div className="row mt-5">
+          <div className="col text-muted">
+            <h1>Search service occures errors. Please contact to administrators of this system.</h1>
+          </div>
+        </div>
+      </div>
+    );
+  }
+
   return (
     <div className="content-main">
       <div className="search-result-base d-flex" data-testid="search-result-base">
@@ -130,14 +165,6 @@ const SearchPageBaseSubstance: ForwardRefRenderFunction<ISelectableAll, Props> =
                   {searchResultListHead}
                 </div>
 
-                {/* eslint-disable-next-line @typescript-eslint/no-non-null-assertion */}
-                { pages.length === 0 && (
-                  <div className="d-flex justify-content-center h2 text-muted my-5">
-                    0 {t('search_result.page_number_unit')}
-                  </div>
-                ) }
-
-                {/* eslint-disable-next-line @typescript-eslint/no-non-null-assertion */}
                 { pages.length > 0 && (
                   <div className="page-list px-md-4">
                     <SearchResultList
@@ -145,6 +172,7 @@ const SearchPageBaseSubstance: ForwardRefRenderFunction<ISelectableAll, Props> =
                       // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
                       pages={pages!}
                       selectedPageId={selectedPageWithMeta?.pageData._id}
+                      forceHideMenuItems={forceHideMenuItems}
                       onPageSelected={page => setSelectedPageWithMeta(page)}
                       onCheckboxChanged={checkboxChangedHandler}
                     />
@@ -167,6 +195,7 @@ const SearchPageBaseSubstance: ForwardRefRenderFunction<ISelectableAll, Props> =
               pageWithMeta={selectedPageWithMeta}
               highlightKeywords={highlightKeywords}
               showPageControlDropdown={!isGuestUser}
+              forceHideMenuItems={forceHideMenuItems}
             />
           )}
         </div>

+ 2 - 2
packages/app/src/components/Sidebar/PageTree.tsx

@@ -7,7 +7,7 @@ import {
 } from '~/stores/context';
 
 import ItemsTree from './PageTree/ItemsTree';
-import PrivateLegacyPages from './PageTree/PrivateLegacyPages';
+import { PrivateLegacyPagesLink } from './PageTree/PrivateLegacyPagesLink';
 
 const PageTree: FC = memo(() => {
   const { t } = useTranslation();
@@ -76,7 +76,7 @@ const PageTree: FC = memo(() => {
 
       {!isGuestUser && migrationStatus?.migratablePagesCount != null && migrationStatus.migratablePagesCount !== 0 && (
         <div className="grw-pagetree-footer border-top p-3 w-100">
-          <PrivateLegacyPages />
+          <PrivateLegacyPagesLink />
         </div>
       )}
     </>

+ 0 - 1
packages/app/src/components/Sidebar/PageTree/Item.tsx

@@ -385,7 +385,6 @@ const Item: FC<ItemProps> = (props: ItemProps) => {
           <PageItemControl
             pageId={page._id}
             isEnableActions={isEnableActions}
-            showBookmarkMenuItem
             onClickBookmarkMenuItem={bookmarkMenuItemClickHandler}
             onClickDuplicateMenuItem={duplicateMenuItemClickHandler}
             onClickDeleteMenuItem={deleteMenuItemClickHandler}

+ 2 - 4
packages/app/src/components/Sidebar/PageTree/PrivateLegacyPages.tsx → packages/app/src/components/Sidebar/PageTree/PrivateLegacyPagesLink.tsx

@@ -1,14 +1,12 @@
 import React, { FC, memo } from 'react';
 import { useTranslation } from 'react-i18next';
 
-const PrivateLegacyPages: FC = memo(() => {
+export const PrivateLegacyPagesLink: FC = memo(() => {
   const { t } = useTranslation();
 
   return (
-    <a href="/private-legacy-pages?q=[nq:PrivateLegacyPages]" className="h5 grw-private-legacy-pages-anchor text-decoration-none">
+    <a href="/_private-legacy-pages" className="h5 grw-private-legacy-pages-anchor text-decoration-none">
       <i className="icon-drawer mr-2"></i> {t('pagetree.private_legacy_pages')}
     </a>
   );
 });
-
-export default PrivateLegacyPages;

+ 1 - 1
packages/app/src/server/routes/apiv3/pages.js

@@ -787,7 +787,7 @@ module.exports = (crowi) => {
   });
 
   // eslint-disable-next-line max-len
-  router.post('/legacy-pages-migration', accessTokenParser, loginRequired, adminRequired, csrf, validator.legacyPagesMigration, apiV3FormValidator, async(req, res) => {
+  router.post('/legacy-pages-migration', accessTokenParser, loginRequired, csrf, validator.legacyPagesMigration, apiV3FormValidator, async(req, res) => {
     const { pageIds: _pageIds, isRecursively } = req.body;
     const pageIds = _pageIds == null ? [] : _pageIds;
 

+ 1 - 1
packages/app/src/server/routes/index.js

@@ -200,7 +200,7 @@ module.exports = function(crowi, app) {
     .get('/:token', apiLimiter, injectResetOrderByTokenMiddleware, forgotPassword.resetPassword)
     .use(forgotPassword.handleHttpErrosMiddleware));
 
-  app.use('/private-legacy-pages', express.Router()
+  app.use('/_private-legacy-pages', express.Router()
     .get('/', privateLegacyPages.renderPrivateLegacyPages));
   app.use('/user-activation', express.Router()
     .get('/:token', apiLimiter, applicationInstalled, injectUserRegistrationOrderByTokenMiddleware, userActivation.form)

+ 8 - 1
packages/app/src/server/views/private-legacy-pages.html

@@ -11,6 +11,13 @@
   data-target="#search-result-list"
 {% endblock %}
 
+<!-- add .on-search to body tag class in layout -->
+{% set additionalBodyClass = 'on-search' %}
+
 {% block layout_main %}
-<p>This page is not implemented.</p>
+<div id="grw-fav-sticky-trigger" class="sticky-top"></div>
+
+<div id="main" class="main search-page mt-0">
+  <div id="private-regacy-pages"></div>
+</div>
 {% endblock %} {# layout_main #}

+ 3 - 4
packages/app/src/server/views/search.html

@@ -17,8 +17,7 @@
 {% block layout_main %}
 <div id="grw-fav-sticky-trigger" class="sticky-top"></div>
 
-  <div id="main" class="main search-page mt-0">
-    <div id="search-page"></div>
-  </div>
-
+<div id="main" class="main search-page mt-0">
+  <div id="search-page"></div>
+</div>
 {% endblock %} {# layout_main #}

+ 40 - 0
packages/app/src/stores/modal.tsx

@@ -1,6 +1,7 @@
 import { SWRResponse } from 'swr';
 import { useStaticSWR } from './use-static-swr';
 import { Nullable } from '~/interfaces/common';
+import { IPageInfo } from '~/interfaces/page';
 
 
 /*
@@ -177,6 +178,45 @@ export const usePagePresentationModal = (
   };
 };
 
+
+/*
+ * LegacyPrivatePagesMigrationModal
+ */
+
+export type ILegacyPrivatePage = { pageId: string, path: string };
+
+export type LegacyPrivatePagesMigrationModalSubmitedHandler = (pages: ILegacyPrivatePage[], isRecursively?: boolean) => void;
+
+type LegacyPrivatePagesMigrationModalStatus = {
+  isOpened: boolean,
+  pages?: ILegacyPrivatePage[],
+  onSubmited?: LegacyPrivatePagesMigrationModalSubmitedHandler,
+}
+
+type LegacyPrivatePagesMigrationModalStatusUtils = {
+  open(pages: ILegacyPrivatePage[], onSubmited?: LegacyPrivatePagesMigrationModalSubmitedHandler): Promise<LegacyPrivatePagesMigrationModalStatus | undefined>,
+  close(): Promise<LegacyPrivatePagesMigrationModalStatus | undefined>,
+}
+
+export const useLegacyPrivatePagesMigrationModal = (
+    status?: LegacyPrivatePagesMigrationModalStatus,
+): SWRResponse<LegacyPrivatePagesMigrationModalStatus, Error> & LegacyPrivatePagesMigrationModalStatusUtils => {
+  const initialData: LegacyPrivatePagesMigrationModalStatus = {
+    isOpened: false,
+    pages: [],
+  };
+  const swrResponse = useStaticSWR<LegacyPrivatePagesMigrationModalStatus, Error>('legacyPrivatePagesMigrationModal', status, { fallbackData: initialData });
+
+  return {
+    ...swrResponse,
+    open: (pages, onSubmited?) => swrResponse.mutate({
+      isOpened: true, pages, onSubmited,
+    }),
+    close: () => swrResponse.mutate({ isOpened: false, pages: [], onSubmited: undefined }),
+  };
+};
+
+
 /*
 * DescendantsPageListModal
 */

+ 13 - 0
packages/app/src/stores/search.tsx

@@ -90,3 +90,16 @@ export const useSWRxFullTextSearch = (
     },
   };
 };
+
+export const useSWRxNamedQuerySearch = (
+    namedQuery: string, configurations: ISearchConfigurations,
+): SWRResponse<IFormattedSearchResult, Error> & { conditions: ISearchConditions } => {
+
+  const keyword = `[nq:${namedQuery}]`;
+  return useSWRxFullTextSearch(keyword, {
+    ...configurations,
+    includeTrashPages: true,
+    includeUserPages: true,
+  });
+
+};

+ 0 - 1
packages/app/src/styles/_override-bootstrap.scss

@@ -100,7 +100,6 @@
     &.btn.disabled {
       pointer-events: auto;
       cursor: not-allowed;
-      opacity: unset;
     }
 
     // hide caret

+ 30 - 0
packages/app/test/cypress/integration/3-search/access-to-private-legacy-pages-directly.spec.ts

@@ -0,0 +1,30 @@
+context('Access to legacy private pages directly', () => {
+  const ssPrefix = 'access-to-legacy-private-pages-directly-';
+
+  let connectSid: string | undefined;
+
+  before(() => {
+    // login
+    cy.fixture("user-admin.json").then(user => {
+      cy.login(user.username, user.password);
+    });
+    cy.getCookie('connect.sid').then(cookie => {
+      connectSid = cookie?.value;
+    });
+  });
+
+  beforeEach(() => {
+    if (connectSid != null) {
+      cy.setCookie('connect.sid', connectSid);
+    }
+  });
+
+  it('/_private-legacy-pages is successfully loaded', () => {
+    cy.visit('/_private-legacy-pages');
+
+    cy.getByTestid('search-result-base').should('be.visible');
+
+    cy.screenshot(`${ssPrefix}-shown`, { capture: 'viewport' });
+  });
+
+});

+ 5 - 0
packages/core/src/test/util/page-path-utils.test.js

@@ -94,6 +94,11 @@ describe('isCreatablePage test', () => {
     expect(isCreatablePage('http://demo.growi.org/hoge')).toBeFalsy();
     expect(isCreatablePage('https://demo.growi.org/hoge')).toBeFalsy();
 
+    expect(isCreatablePage('/_search')).toBeFalsy();
+    expect(isCreatablePage('/_search/foo')).toBeFalsy();
+    expect(isCreatablePage('/_private-legacy-pages')).toBeFalsy();
+    expect(isCreatablePage('/_private-legacy-pages/foo')).toBeFalsy();
+
     expect(isCreatablePage('/ the / path / with / space')).toBeFalsy();
 
     const forbidden = ['installer', 'register', 'login', 'logout',

+ 1 - 0
packages/core/src/utils/page-path-utils.ts

@@ -81,6 +81,7 @@ const restrictedPatternsToCreate: Array<RegExp> = [
   /.+\.md$/,
   /^(\.\.)$/, // see: https://github.com/weseek/growi/issues/3582
   /(\/\.\.)\/?/, // see: https://github.com/weseek/growi/issues/3582
+  /^\/(_search|_private-legacy-pages)(\/.*|$)/,
   /^\/(installer|register|login|logout|admin|me|files|trash|paste|comments|tags|share)(\/.*|$)/,
 ];
 export const isCreatablePage = (path: string): boolean => {