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

Merge branch 'master' into fix/rebuild-index-with-empty-pages

Haku Mizuki 4 лет назад
Родитель
Сommit
b28732bc63
45 измененных файлов с 796 добавлено и 153 удалено
  1. 4 4
      packages/app/resource/locales/en_US/sandbox.md
  2. 18 0
      packages/app/resource/locales/en_US/translation.json
  3. 3 3
      packages/app/resource/locales/ja_JP/sandbox.md
  4. 19 1
      packages/app/resource/locales/ja_JP/translation.json
  5. 4 4
      packages/app/resource/locales/zh_CN/sandbox.md
  6. 17 1
      packages/app/resource/locales/zh_CN/translation.json
  7. 3 0
      packages/app/src/client/app.jsx
  8. 2 1
      packages/app/src/client/services/ContextExtractor.tsx
  9. 28 9
      packages/app/src/components/Common/Dropdown/PageItemControl.tsx
  10. 99 0
      packages/app/src/components/LegacyPrivatePagesMigrationModal.tsx
  11. 4 4
      packages/app/src/components/Navbar/GrowiContextualSubNavigation.tsx
  12. 9 2
      packages/app/src/components/Navbar/SubNavButtons.tsx
  13. 31 4
      packages/app/src/components/Page/TrashPageAlert.jsx
  14. 7 2
      packages/app/src/components/PageDeleteModal.tsx
  15. 5 2
      packages/app/src/components/PageList/PageListItemL.tsx
  16. 303 0
      packages/app/src/components/PrivateLegacyPages.tsx
  17. 10 29
      packages/app/src/components/SearchPage.tsx
  18. 24 17
      packages/app/src/components/SearchPage/SearchResultContent.tsx
  19. 4 0
      packages/app/src/components/SearchPage/SearchResultList.tsx
  20. 42 13
      packages/app/src/components/SearchPage2/SearchPageBase.tsx
  21. 2 1
      packages/app/src/components/Sidebar.tsx
  22. 2 2
      packages/app/src/components/Sidebar/PageTree.tsx
  23. 8 10
      packages/app/src/components/Sidebar/PageTree/Item.tsx
  24. 4 15
      packages/app/src/components/Sidebar/PageTree/ItemsTree.tsx
  25. 2 4
      packages/app/src/components/Sidebar/PageTree/PrivateLegacyPagesLink.tsx
  26. 7 3
      packages/app/src/server/routes/apiv3/pages.js
  27. 1 1
      packages/app/src/server/routes/index.js
  28. 1 1
      packages/app/src/server/routes/page.js
  29. 6 6
      packages/app/src/server/service/installer.ts
  30. 8 1
      packages/app/src/server/views/private-legacy-pages.html
  31. 3 4
      packages/app/src/server/views/search.html
  32. 4 0
      packages/app/src/stores/context.tsx
  33. 40 0
      packages/app/src/stores/modal.tsx
  34. 13 0
      packages/app/src/stores/search.tsx
  35. 1 1
      packages/app/src/stores/ui.tsx
  36. 0 1
      packages/app/src/styles/_override-bootstrap.scss
  37. 1 2
      packages/app/test/cypress/integration/2-basic-features/access-to-admin-page.spec.ts
  38. 2 3
      packages/app/test/cypress/integration/2-basic-features/access-to-me-page.spec.ts
  39. 6 0
      packages/app/test/cypress/integration/2-basic-features/access-to-page.spec.ts
  40. 30 0
      packages/app/test/cypress/integration/3-search/access-to-private-legacy-pages-directly.spec.ts
  41. 2 2
      packages/app/test/cypress/integration/3-search/access-to-result-page-directly.spec.ts
  42. 10 0
      packages/app/test/cypress/support/commands.ts
  43. 1 0
      packages/app/test/cypress/support/index.ts
  44. 5 0
      packages/core/src/test/util/page-path-utils.test.js
  45. 1 0
      packages/core/src/utils/page-path-utils.ts

+ 4 - 4
packages/app/resource/locales/en_US/sandbox.md

@@ -237,10 +237,10 @@ You can create links using `[Display text](URL)`.
 
 
 ```
 ```
 [/Sandbox]
 [/Sandbox]
-</user/admin1>
+</user/admin1>
 ```
 ```
 
 
-[/Sandbox]
+[/Sandbox]  
 </user/admin1>
 </user/admin1>
 
 
 ## Pukiwiki like linker
 ## Pukiwiki like linker
@@ -253,10 +253,10 @@ Both the page description and link address can be displayed on the page.
 
 
 ```
 ```
 [[./Bootstrap4]]
 [[./Bootstrap4]]
-Example of Bootstrap4 is[[here>./Bootstrap4]]
+Example of Bootstrap4 is [[here>./Bootstrap4]]
 ```
 ```
 
 
-[[../user]]
+[[../Bootstrap4]]  
 Example of Bootstrap4 is[[here>./Bootstrap4]]
 Example of Bootstrap4 is[[here>./Bootstrap4]]
 
 
 # :pencil: Lists
 # :pencil: Lists

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

@@ -441,6 +441,8 @@
     "recursively": "Delete pages under this path recursively.",
     "recursively": "Delete pages under this path recursively.",
     "completely": "Delete completely instead of putting it into trash."
     "completely": "Delete completely instead of putting it into trash."
   },
   },
+  "deleted_pages": "Page(s) has been deleted",
+  "deleted_pages_completely": "Page(s) has been deleted completely",
   "modal_empty":{
   "modal_empty":{
     "empty_the_trash": "Empty The Trash",
     "empty_the_trash": "Empty The Trash",
     "notice": "The pages deleted completely are unrecoverable."
     "notice": "The pages deleted completely are unrecoverable."
@@ -636,6 +638,22 @@
       "updatedAt": "Last update date"
       "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": {
   "security_setting": {
     "Guest Users Access": "Guest users access",
     "Guest Users Access": "Guest users access",
     "Fixed by env var": "This is fixed by the env var <code>%s=%s</code>.",
     "Fixed by env var": "This is fixed by the env var <code>%s=%s</code>.",

+ 3 - 3
packages/app/resource/locales/ja_JP/sandbox.md

@@ -236,10 +236,10 @@ ___
 
 
 ```
 ```
 [/Sandbox]
 [/Sandbox]
-&lt;/user/admin1>
+</user/admin1>
 ```
 ```
 
 
-[/Sandbox]
+[/Sandbox]  
 </user/admin1>
 </user/admin1>
 
 
 ## Pukiwiki like linker
 ## Pukiwiki like linker
@@ -255,7 +255,7 @@ ___
 Bootstrap4のExampleは[[こちら>./Bootstrap4]]
 Bootstrap4のExampleは[[こちら>./Bootstrap4]]
 ```
 ```
 
 
-[[../user]]
+[[../Bootstrap4]]  
 Bootstrap4のExampleは[[こちら>./Bootstrap4]]
 Bootstrap4のExampleは[[こちら>./Bootstrap4]]
 
 
 # :pencil: Lists
 # :pencil: Lists

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

@@ -440,6 +440,8 @@
     "recursively": "配下のページも削除します",
     "recursively": "配下のページも削除します",
     "completely": "ゴミ箱を経由せず、完全に削除します"
     "completely": "ゴミ箱を経由せず、完全に削除します"
   },
   },
+  "deleted_pages": "ページをゴミ箱に入れました",
+  "deleted_pages_completely": "ページを完全に削除しました",
   "modal_empty":{
   "modal_empty":{
     "empty_the_trash": "ゴミ箱を空にする",
     "empty_the_trash": "ゴミ箱を空にする",
     "notice": "完全削除したページは元に戻すことができません"
     "notice": "完全削除したページは元に戻すことができません"
@@ -635,6 +637,22 @@
       "updatedAt": "更新日時"
       "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": {
   "security_setting": {
     "Guest Users Access": "ゲストユーザーのアクセス",
     "Guest Users Access": "ゲストユーザーのアクセス",
     "Fixed by env var": "環境変数 <code>{{forcewikimode}}={{wikimode}}</code> により固定されています。",
     "Fixed by env var": "環境変数 <code>{{forcewikimode}}={{wikimode}}</code> により固定されています。",
@@ -970,7 +988,7 @@
     "password_and_confirm_password_does_not_match": "パスワードと確認パスワードが一致しません"
     "password_and_confirm_password_does_not_match": "パスワードと確認パスワードが一致しません"
   },
   },
   "pagetree": {
   "pagetree": {
-    "private_legacy_pages": "待避所",
+    "private_legacy_pages": "旧形式のプライベートページ",
     "cannot_rename_a_title_that_contains_slash": "`/` が含まれているタイトルにリネームできません",
     "cannot_rename_a_title_that_contains_slash": "`/` が含まれているタイトルにリネームできません",
     "you_cannot_move_this_page_now": "現在、このページを移動することはできません",
     "you_cannot_move_this_page_now": "現在、このページを移動することはできません",
     "something_went_wrong_with_moving_page": "ページの移動に問題が発生しました"
     "something_went_wrong_with_moving_page": "ページの移動に問題が発生しました"

+ 4 - 4
packages/app/resource/locales/zh_CN/sandbox.md

@@ -237,10 +237,10 @@ You can create links using `[Display text](URL)`.
 
 
 ```
 ```
 [/Sandbox]
 [/Sandbox]
-&lt;/user/admin1>
+</user/admin1>
 ```
 ```
 
 
-[/Sandbox]
+[/Sandbox]  
 </user/admin1>
 </user/admin1>
 
 
 ## Pukiwiki like linker
 ## Pukiwiki like linker
@@ -256,8 +256,8 @@ Both the page description and link address can be displayed on the page.
 Example of Bootstrap4 is[[here>./Bootstrap4]]
 Example of Bootstrap4 is[[here>./Bootstrap4]]
 ```
 ```
 
 
-[[../user]]
-Example of Bootstrap4 is[[here>./Bootstrap4]]
+[[../Bootstrap4]]  
+Example of Bootstrap4 is [[here>./Bootstrap4]]
 
 
 # :pencil: Lists
 # :pencil: Lists
 
 

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

@@ -418,7 +418,7 @@
 		"delete_completely_restriction": "You don't have the authority to delete pages completely.",
 		"delete_completely_restriction": "You don't have the authority to delete pages completely.",
 		"recursively": "Delete children of <code>%s</code> recursively.",
 		"recursively": "Delete children of <code>%s</code> recursively.",
 		"completely": "Delete completely instead of putting it into trash."
 		"completely": "Delete completely instead of putting it into trash."
-	},
+  },
 	"modal_empty": {
 	"modal_empty": {
 		"empty_the_trash": "Empty The Trash",
 		"empty_the_trash": "Empty The Trash",
 		"notice": "完全删除的页面是不可恢复的。"
 		"notice": "完全删除的页面是不可恢复的。"
@@ -913,6 +913,22 @@
       "updatedAt": "按更新日期排序"
       "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 的管理界面",
 	"to_cloud_settings": "進入 GROWI.cloud 的管理界面",
 	"login": {
 	"login": {
 		"Sign in error": "登录错误",
 		"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 { appContainer, componentMappings } from './base';
 import { toastError } from './util/apiNotification';
 import { toastError } from './util/apiNotification';
+import { PrivateLegacyPages } from '~/components/PrivateLegacyPages';
 
 
 const logger = loggerFactory('growi:cli:app');
 const logger = loggerFactory('growi:cli:app');
 
 
@@ -86,6 +87,8 @@ Object.assign(componentMappings, {
   'grw-sidebar-wrapper': <Sidebar />,
   'grw-sidebar-wrapper': <Sidebar />,
 
 
   'search-page': <SearchPage appContainer={appContainer} />,
   'search-page': <SearchPage appContainer={appContainer} />,
+  'private-regacy-pages': <PrivateLegacyPages appContainer={appContainer} />,
+
   'all-in-app-notifications': <InAppNotificationPage />,
   'all-in-app-notifications': <InAppNotificationPage />,
   'identical-path-page': <IdenticalPathPage />,
   'identical-path-page': <IdenticalPathPage />,
 
 

+ 2 - 1
packages/app/src/client/services/ContextExtractor.tsx

@@ -7,7 +7,7 @@ import {
   useCurrentPageId, usePageIdOnHackmd, usePageUser, useCurrentPagePath, useRevisionCreatedAt, useRevisionId, useRevisionIdHackmdSynced,
   useCurrentPageId, usePageIdOnHackmd, usePageUser, useCurrentPagePath, useRevisionCreatedAt, useRevisionId, useRevisionIdHackmdSynced,
   useShareLinkId, useShareLinksNumber, useTemplateTagData, useCurrentUpdatedAt, useCreator, useRevisionAuthor, useCurrentUser, useTargetAndAncestors,
   useShareLinkId, useShareLinksNumber, useTemplateTagData, useCurrentUpdatedAt, useCreator, useRevisionAuthor, useCurrentUser, useTargetAndAncestors,
   useSlackChannels, useNotFoundTargetPathOrId, useIsSearchPage, useIsForbidden, useIsIdenticalPath,
   useSlackChannels, useNotFoundTargetPathOrId, useIsSearchPage, useIsForbidden, useIsIdenticalPath,
-  useIsAclEnabled, useIsSearchServiceConfigured, useIsSearchServiceReachable,
+  useIsAclEnabled, useIsSearchServiceConfigured, useIsSearchServiceReachable, useIsEnabledAttachTitleHeader,
 } from '../../stores/context';
 } from '../../stores/context';
 import {
 import {
   useIsDeviceSmallerThanMd, useIsDeviceSmallerThanLg,
   useIsDeviceSmallerThanMd, useIsDeviceSmallerThanLg,
@@ -101,6 +101,7 @@ const ContextExtractorOnce: FC = () => {
   useIsAclEnabled(configByContextHydrate.isAclEnabled);
   useIsAclEnabled(configByContextHydrate.isAclEnabled);
   useIsSearchServiceConfigured(configByContextHydrate.isSearchServiceConfigured);
   useIsSearchServiceConfigured(configByContextHydrate.isSearchServiceConfigured);
   useIsSearchServiceReachable(configByContextHydrate.isSearchServiceReachable);
   useIsSearchServiceReachable(configByContextHydrate.isSearchServiceReachable);
+  useIsEnabledAttachTitleHeader(configByContextHydrate.isEnabledAttachTitleHeader);
 
 
 
 
   // Page
   // Page

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

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

+ 4 - 4
packages/app/src/components/Navbar/GrowiContextualSubNavigation.tsx

@@ -123,8 +123,6 @@ const AdditionalMenuItems = (props: AdditionalMenuItemsProps): JSX.Element => {
 
 
 
 
 const GrowiContextualSubNavigation = (props) => {
 const GrowiContextualSubNavigation = (props) => {
-  const { t } = useTranslation();
-
   const { data: isDeviceSmallerThanMd } = useIsDeviceSmallerThanMd();
   const { data: isDeviceSmallerThanMd } = useIsDeviceSmallerThanMd();
   const { data: isDrawerMode } = useDrawerMode();
   const { data: isDrawerMode } = useDrawerMode();
   const { data: editorMode, mutate: mutateEditorMode } = useEditorMode();
   const { data: editorMode, mutate: mutateEditorMode } = useEditorMode();
@@ -222,9 +220,11 @@ const GrowiContextualSubNavigation = (props) => {
       mutateEditorMode(viewType);
       mutateEditorMode(viewType);
     }
     }
 
 
+    const className = `d-flex flex-column align-items-end justify-content-center ${isViewMode ? ' h-50' : ''}`;
+
     return (
     return (
       <>
       <>
-        <div className="h-50 d-flex flex-column align-items-end justify-content-center">
+        <div className={className}>
           { pageId != null && isViewMode && (
           { pageId != null && isViewMode && (
             <SubNavButtons
             <SubNavButtons
               isCompactMode={isCompactMode}
               isCompactMode={isCompactMode}
@@ -249,7 +249,7 @@ const GrowiContextualSubNavigation = (props) => {
             />
             />
           ) }
           ) }
         </div>
         </div>
-        <div className="h-50 d-flex flex-column align-items-end justify-content-center">
+        <div className={className}>
           {isAbleToShowPageEditorModeManager && (
           {isAbleToShowPageEditorModeManager && (
             <PageEditorModeManager
             <PageEditorModeManager
               onPageEditorModeButtonClicked={onPageEditorModeButtonClicked}
               onPageEditorModeButtonClicked={onPageEditorModeButtonClicked}

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

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

+ 31 - 4
packages/app/src/components/Page/TrashPageAlert.jsx

@@ -1,4 +1,4 @@
-import React, { useState } from 'react';
+import React, { useState, useEffect, useCallback } from 'react';
 import PropTypes from 'prop-types';
 import PropTypes from 'prop-types';
 
 
 import { withTranslation } from 'react-i18next';
 import { withTranslation } from 'react-i18next';
@@ -10,17 +10,34 @@ import PageContainer from '~/client/services/PageContainer';
 import PutbackPageModal from '../PutbackPageModal';
 import PutbackPageModal from '../PutbackPageModal';
 import EmptyTrashModal from '../EmptyTrashModal';
 import EmptyTrashModal from '../EmptyTrashModal';
 
 
-import { useCurrentUpdatedAt } from '~/stores/context';
+import { useCurrentUpdatedAt, useShareLinkId } from '~/stores/context';
 import { usePageDeleteModal } from '~/stores/modal';
 import { usePageDeleteModal } from '~/stores/modal';
+import { useSWRxPageInfo } from '~/stores/page';
 
 
 const TrashPageAlert = (props) => {
 const TrashPageAlert = (props) => {
   const { t, pageContainer } = props;
   const { t, pageContainer } = props;
   const {
   const {
-    pageId, revisionId, path, isDeleted, lastUpdateUsername, deletedUserName, deletedAt, isAbleToDeleteCompletely,
+    pageId, revisionId, path, isDeleted, lastUpdateUsername, deletedUserName, deletedAt,
   } = pageContainer.state;
   } = pageContainer.state;
+  const { data: shareLinkId } = useShareLinkId();
+
+  /*
+  * TODO: Do not use useSWRxPageInfo on this component
+  * Ideal: use useSWRxPageInfo on TrashPage after applying Next.js
+  * Reference: https://github.com/weseek/growi/pull/5359#discussion_r808381329
+  */
+  const { data: pageInfo } = useSWRxPageInfo(pageId ?? null, shareLinkId);
+
   const { data: updatedAt } = useCurrentUpdatedAt();
   const { data: updatedAt } = useCurrentUpdatedAt();
   const [isEmptyTrashModalShown, setIsEmptyTrashModalShown] = useState(false);
   const [isEmptyTrashModalShown, setIsEmptyTrashModalShown] = useState(false);
   const [isPutbackPageModalShown, setIsPutbackPageModalShown] = useState(false);
   const [isPutbackPageModalShown, setIsPutbackPageModalShown] = useState(false);
+  const [isAbleToDeleteCompletely, setIsAbleToDeleteCompletely] = useState(false);
+
+  useEffect(() => {
+    if (pageInfo != null) {
+      setIsAbleToDeleteCompletely(pageInfo.isAbleToDeleteCompletely);
+    }
+  }, [pageInfo]);
 
 
   const { open: openDeleteModal } = usePageDeleteModal();
   const { open: openDeleteModal } = usePageDeleteModal();
 
 
@@ -40,13 +57,23 @@ const TrashPageAlert = (props) => {
     setIsPutbackPageModalShown(false);
     setIsPutbackPageModalShown(false);
   }
   }
 
 
+  const onDeletedHandler = useCallback((pathOrPathsToDelete, isRecursively, isCompletely) => {
+    if (typeof pathOrPathsToDelete !== 'string') {
+      return;
+    }
+
+    const path = pathOrPathsToDelete;
+    window.location.href = path;
+  }, []);
+
   function openPageDeleteModalHandler() {
   function openPageDeleteModalHandler() {
     const pageToDelete = {
     const pageToDelete = {
       pageId,
       pageId,
       revisionId,
       revisionId,
       path,
       path,
     };
     };
-    openDeleteModal([pageToDelete]);
+    const isDeleteCompletelyModal = true;
+    openDeleteModal([pageToDelete], onDeletedHandler, isAbleToDeleteCompletely, isDeleteCompletelyModal);
   }
   }
 
 
   function renderEmptyButton() {
   function renderEmptyButton() {

+ 7 - 2
packages/app/src/components/PageDeleteModal.tsx

@@ -1,4 +1,4 @@
-import React, { useState, FC } from 'react';
+import React, { useState, useEffect, FC } from 'react';
 import {
 import {
   Modal, ModalHeader, ModalBody, ModalFooter,
   Modal, ModalHeader, ModalBody, ModalFooter,
 } from 'reactstrap';
 } from 'reactstrap';
@@ -36,7 +36,7 @@ const PageDeleteModal: FC = () => {
   const isDeleteCompletelyModal = deleteModalData?.isDeleteCompletelyModal ?? false;
   const isDeleteCompletelyModal = deleteModalData?.isDeleteCompletelyModal ?? false;
 
 
   const [isDeleteRecursively, setIsDeleteRecursively] = useState(true);
   const [isDeleteRecursively, setIsDeleteRecursively] = useState(true);
-  const [isDeleteCompletely, setIsDeleteCompletely] = useState(isDeleteCompletelyModal && isAbleToDeleteCompletely);
+  const [isDeleteCompletely, setIsDeleteCompletely] = useState(false);
   const deleteMode = isDeleteCompletely ? 'completely' : 'temporary';
   const deleteMode = isDeleteCompletely ? 'completely' : 'temporary';
 
 
   // eslint-disable-next-line @typescript-eslint/no-unused-vars
   // eslint-disable-next-line @typescript-eslint/no-unused-vars
@@ -46,6 +46,10 @@ const PageDeleteModal: FC = () => {
     setIsDeleteRecursively(!isDeleteRecursively);
     setIsDeleteRecursively(!isDeleteRecursively);
   }
   }
 
 
+  useEffect(() => {
+    setIsDeleteCompletely(isDeleteCompletelyModal && isAbleToDeleteCompletely);
+  }, [isAbleToDeleteCompletely, isDeleteCompletelyModal]);
+
   function changeIsDeleteCompletelyHandler() {
   function changeIsDeleteCompletelyHandler() {
     if (!isAbleToDeleteCompletely) {
     if (!isAbleToDeleteCompletely) {
       return;
       return;
@@ -128,6 +132,7 @@ const PageDeleteModal: FC = () => {
         />
         />
         <label className="custom-control-label" htmlFor="deleteRecursively">
         <label className="custom-control-label" htmlFor="deleteRecursively">
           { t('modal_delete.delete_recursively') }
           { t('modal_delete.delete_recursively') }
+          <p className="form-text text-muted mt-0"> { t('modal_delete.recursively') }</p>
         </label>
         </label>
       </div>
       </div>
     );
     );

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

@@ -18,7 +18,7 @@ import {
 } from '~/interfaces/page';
 } from '~/interfaces/page';
 import { IPageSearchMeta, isIPageSearchMeta } from '~/interfaces/search';
 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 LinkedPagePath from '~/models/linked-page-path';
 import PagePathHierarchicalLink from '../PagePathHierarchicalLink';
 import PagePathHierarchicalLink from '../PagePathHierarchicalLink';
 import { ISelectable } from '~/client/interfaces/selectable-all';
 import { ISelectable } from '~/client/interfaces/selectable-all';
@@ -27,6 +27,7 @@ type Props = {
   page: IPageWithMeta | IPageWithMeta<IPageInfoAll & IPageSearchMeta>,
   page: IPageWithMeta | IPageWithMeta<IPageInfoAll & IPageSearchMeta>,
   isSelected?: boolean, // is item selected(focused)
   isSelected?: boolean, // is item selected(focused)
   isEnableActions?: boolean,
   isEnableActions?: boolean,
+  forceHideMenuItems?: ForceHideMenuItems,
   showPageUpdatedTime?: boolean, // whether to show page's updated time at the top-right corner of item
   showPageUpdatedTime?: boolean, // whether to show page's updated time at the top-right corner of item
   onCheckboxChanged?: (isChecked: boolean, pageId: string) => void,
   onCheckboxChanged?: (isChecked: boolean, pageId: string) => void,
   onClickItem?: (pageId: string) => void,
   onClickItem?: (pageId: string) => void,
@@ -36,6 +37,7 @@ const PageListItemLSubstance: ForwardRefRenderFunction<ISelectable, Props> = (pr
   const {
   const {
     // todo: refactoring variable name to clear what changed
     // todo: refactoring variable name to clear what changed
     page: { pageData, pageMeta }, isSelected, isEnableActions,
     page: { pageData, pageMeta }, isSelected, isEnableActions,
+    forceHideMenuItems,
     showPageUpdatedTime,
     showPageUpdatedTime,
     onClickItem, onCheckboxChanged,
     onClickItem, onCheckboxChanged,
   } = props;
   } = props;
@@ -161,9 +163,10 @@ const PageListItemLSubstance: ForwardRefRenderFunction<ISelectable, Props> = (pr
                 <PageItemControl
                 <PageItemControl
                   pageId={pageData._id}
                   pageId={pageData._id}
                   pageInfo={pageMeta}
                   pageInfo={pageMeta}
+                  isEnableActions={isEnableActions}
+                  forceHideMenuItems={forceHideMenuItems}
                   onClickDeleteMenuItem={deleteMenuItemClickHandler}
                   onClickDeleteMenuItem={deleteMenuItemClickHandler}
                   onClickRenameMenuItem={renameMenuItemClickHandler}
                   onClickRenameMenuItem={renameMenuItemClickHandler}
-                  isEnableActions={isEnableActions}
                   onClickDuplicateMenuItem={duplicateMenuItemClickHandler}
                   onClickDuplicateMenuItem={duplicateMenuItemClickHandler}
                 />
                 />
               </div>
               </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 AppContainer from '~/client/services/AppContainer';
 import { IFormattedSearchResult } from '~/interfaces/search';
 import { IFormattedSearchResult } from '~/interfaces/search';
 import { ISelectableAll, ISelectableAndIndeterminatable } from '~/client/interfaces/selectable-all';
 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 { ISearchConditions, ISearchConfigurations, useSWRxFullTextSearch } from '~/stores/search';
 
 
 import PaginationWrapper from './PaginationWrapper';
 import PaginationWrapper from './PaginationWrapper';
@@ -46,14 +46,20 @@ const SearchResultListHead = React.memo((props: SearchResultListHeadProps): JSX.
   const leftNum = offset + 1;
   const leftNum = offset + 1;
   const rightNum = offset + hitsCount;
   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 (
   return (
     <div className="form-inline d-flex align-items-center justify-content-between">
     <div className="form-inline d-flex align-items-center justify-content-between">
       <div className="text-nowrap">
       <div className="text-nowrap">
         {t('search_result.result_meta')}
         {t('search_result.result_meta')}
         <span className="search-result-keyword">{`${searchingKeyword}`}</span>
         <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 && (
         { took != null && (
           <span className="ml-3 text-muted">({took}ms)</span>
           <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 selectAllControlRef = useRef<ISelectableAndIndeterminatable|null>(null);
   const searchPageBaseRef = useRef<ISelectableAll|null>(null);
   const searchPageBaseRef = useRef<ISelectableAll|null>(null);
 
 
-  const { data: isSearchServiceConfigured } = useIsSearchServiceConfigured();
   const { data: isSearchServiceReachable } = useIsSearchServiceReachable();
   const { data: isSearchServiceReachable } = useIsSearchServiceReachable();
 
 
   const { data, conditions } = useSWRxFullTextSearch(keyword, {
   const { data, conditions } = useSWRxFullTextSearch(keyword, {
@@ -254,30 +259,6 @@ export const SearchPage = (props: Props): JSX.Element => {
     );
     );
   }, [conditions, configurationsByPagination?.limit, data, pagingNumberChangedHandler]);
   }, [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 (
   return (
     <SearchPageBase
     <SearchPageBase
       ref={searchPageBaseRef}
       ref={searchPageBaseRef}

+ 24 - 17
packages/app/src/components/SearchPage/SearchResultContent.tsx

@@ -1,9 +1,9 @@
 import React, {
 import React, {
   FC, useCallback, useEffect, useRef,
   FC, useCallback, useEffect, useRef,
 } from 'react';
 } from 'react';
+import { useTranslation } from 'react-i18next';
 
 
 import { DropdownItem } from 'reactstrap';
 import { DropdownItem } from 'reactstrap';
-import { useTranslation } from 'react-i18next';
 
 
 import { IPageWithMeta } from '~/interfaces/page';
 import { IPageWithMeta } from '~/interfaces/page';
 import { IPageSearchMeta } from '~/interfaces/search';
 import { IPageSearchMeta } from '~/interfaces/search';
@@ -15,9 +15,11 @@ import AppContainer from '../../client/services/AppContainer';
 import { smoothScrollIntoView } from '~/client/util/smooth-scroll';
 import { smoothScrollIntoView } from '~/client/util/smooth-scroll';
 import { GrowiSubNavigation } from '../Navbar/GrowiSubNavigation';
 import { GrowiSubNavigation } from '../Navbar/GrowiSubNavigation';
 import { SubNavButtons } from '../Navbar/SubNavButtons';
 import { SubNavButtons } from '../Navbar/SubNavButtons';
-import { AdditionalMenuItemsRendererProps } from '../Common/Dropdown/PageItemControl';
+import { AdditionalMenuItemsRendererProps, ForceHideMenuItems } from '../Common/Dropdown/PageItemControl';
 
 
-import { usePageDuplicateModal, usePageRenameModal, usePageDeleteModal } from '~/stores/modal';
+import {
+  usePageDuplicateModal, usePageRenameModal, usePageDeleteModal, OnDeletedFunction,
+} from '~/stores/modal';
 
 
 
 
 type AdditionalMenuItemsProps = AdditionalMenuItemsRendererProps & {
 type AdditionalMenuItemsProps = AdditionalMenuItemsRendererProps & {
@@ -31,15 +33,11 @@ const AdditionalMenuItems = (props: AdditionalMenuItemsProps): JSX.Element => {
   const { pageId, revisionId } = props;
   const { pageId, revisionId } = props;
 
 
   return (
   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>
   );
   );
 };
 };
 
 
@@ -51,6 +49,7 @@ type Props ={
   pageWithMeta : IPageWithMeta<IPageSearchMeta>,
   pageWithMeta : IPageWithMeta<IPageSearchMeta>,
   highlightKeywords?: string[],
   highlightKeywords?: string[],
   showPageControlDropdown?: boolean,
   showPageControlDropdown?: boolean,
+  forceHideMenuItems?: ForceHideMenuItems,
 }
 }
 
 
 const scrollTo = (scrollElement:HTMLElement) => {
 const scrollTo = (scrollElement:HTMLElement) => {
@@ -97,14 +96,14 @@ export const SearchResultContent: FC<Props> = (props: Props) => {
     pageWithMeta,
     pageWithMeta,
     highlightKeywords,
     highlightKeywords,
     showPageControlDropdown,
     showPageControlDropdown,
+    forceHideMenuItems,
   } = props;
   } = props;
 
 
+  const page = pageWithMeta?.pageData;
   const { open: openDuplicateModal } = usePageDuplicateModal();
   const { open: openDuplicateModal } = usePageDuplicateModal();
   const { open: openRenameModal } = usePageRenameModal();
   const { open: openRenameModal } = usePageRenameModal();
   const { open: openDeleteModal } = usePageDeleteModal();
   const { open: openDeleteModal } = usePageDeleteModal();
 
 
-  const page = pageWithMeta?.pageData;
-
   const growiRenderer = appContainer.getRenderer('searchresult');
   const growiRenderer = appContainer.getRenderer('searchresult');
 
 
 
 
@@ -116,9 +115,16 @@ export const SearchResultContent: FC<Props> = (props: Props) => {
     openRenameModal(pageId, revisionId, path);
     openRenameModal(pageId, revisionId, path);
   }, [openRenameModal]);
   }, [openRenameModal]);
 
 
-  const deleteItemClickedHandler = useCallback(async(pageToDelete) => {
-    openDeleteModal([pageToDelete]);
-  }, [openDeleteModal]);
+  const onDeletedHandler: OnDeletedFunction = useCallback((pathOrPathsToDelete, isRecursively, isCompletely) => {
+    if (typeof pathOrPathsToDelete !== 'string') {
+      return;
+    }
+    window.location.reload();
+  }, []);
+
+  const deleteItemClickedHandler = useCallback(async(pageToDelete, isAbleToDeleteCompletely) => {
+    openDeleteModal([pageToDelete], onDeletedHandler, isAbleToDeleteCompletely);
+  }, [onDeletedHandler, openDeleteModal]);
 
 
   const ControlComponents = useCallback(() => {
   const ControlComponents = useCallback(() => {
     if (page == null) {
     if (page == null) {
@@ -137,6 +143,7 @@ export const SearchResultContent: FC<Props> = (props: Props) => {
             revisionId={revisionId}
             revisionId={revisionId}
             path={page.path}
             path={page.path}
             showPageControlDropdown={showPageControlDropdown}
             showPageControlDropdown={showPageControlDropdown}
+            forceHideMenuItems={forceHideMenuItems}
             additionalMenuItemRenderer={props => <AdditionalMenuItems {...props} pageId={page._id} revisionId={revisionId} />}
             additionalMenuItemRenderer={props => <AdditionalMenuItems {...props} pageId={page._id} revisionId={revisionId} />}
             isCompactMode
             isCompactMode
             onClickDuplicateMenuItem={duplicateItemClickedHandler}
             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 { IPageSearchMeta } from '~/interfaces/search';
 import { useIsGuestUser } from '~/stores/context';
 import { useIsGuestUser } from '~/stores/context';
 import { useSWRxPageInfoForList } from '~/stores/page';
 import { useSWRxPageInfoForList } from '~/stores/page';
+import { ForceHideMenuItems } from '../Common/Dropdown/PageItemControl';
 
 
 import { PageListItemL } from '../PageList/PageListItemL';
 import { PageListItemL } from '../PageList/PageListItemL';
 
 
@@ -14,6 +15,7 @@ import { PageListItemL } from '../PageList/PageListItemL';
 type Props = {
 type Props = {
   pages: IPageWithMeta<IPageSearchMeta>[],
   pages: IPageWithMeta<IPageSearchMeta>[],
   selectedPageId?: string,
   selectedPageId?: string,
+  forceHideMenuItems?: ForceHideMenuItems,
   onPageSelected?: (page?: IPageWithMeta<IPageSearchMeta>) => void,
   onPageSelected?: (page?: IPageWithMeta<IPageSearchMeta>) => void,
   onCheckboxChanged?: (isChecked: boolean, pageId: string) => void,
   onCheckboxChanged?: (isChecked: boolean, pageId: string) => void,
 }
 }
@@ -21,6 +23,7 @@ type Props = {
 const SearchResultListSubstance: ForwardRefRenderFunction<ISelectableAll, Props> = (props:Props, ref) => {
 const SearchResultListSubstance: ForwardRefRenderFunction<ISelectableAll, Props> = (props:Props, ref) => {
   const {
   const {
     pages, selectedPageId,
     pages, selectedPageId,
+    forceHideMenuItems,
     onPageSelected,
     onPageSelected,
   } = props;
   } = props;
 
 
@@ -89,6 +92,7 @@ const SearchResultListSubstance: ForwardRefRenderFunction<ISelectableAll, Props>
             page={page}
             page={page}
             isEnableActions={!isGuestUser}
             isEnableActions={!isGuestUser}
             isSelected={page.pageData._id === selectedPageId}
             isSelected={page.pageData._id === selectedPageId}
+            forceHideMenuItems={forceHideMenuItems}
             onClickItem={clickItemHandler}
             onClickItem={clickItemHandler}
             onCheckboxChanged={props.onCheckboxChanged}
             onCheckboxChanged={props.onCheckboxChanged}
           />
           />

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

@@ -1,21 +1,28 @@
 import React, {
 import React, {
   forwardRef, ForwardRefRenderFunction, useEffect, useImperativeHandle, useRef, useState,
   forwardRef, ForwardRefRenderFunction, useEffect, useImperativeHandle, useRef, useState,
 } from 'react';
 } from 'react';
-import { useTranslation } from 'react-i18next';
 import { ISelectableAll } from '~/client/interfaces/selectable-all';
 import { ISelectableAll } from '~/client/interfaces/selectable-all';
 import AppContainer from '~/client/services/AppContainer';
 import AppContainer from '~/client/services/AppContainer';
 import { IPageWithMeta } from '~/interfaces/page';
 import { IPageWithMeta } from '~/interfaces/page';
 import { IPageSearchMeta } from '~/interfaces/search';
 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 { SearchResultContent } from '../SearchPage/SearchResultContent';
 import { SearchResultList } from '../SearchPage/SearchResultList';
 import { SearchResultList } from '../SearchPage/SearchResultList';
 
 
+export interface IReturnSelectedPageIds {
+  getSelectedPageIds?: () => Set<string>,
+}
+
+
 type Props = {
 type Props = {
   appContainer: AppContainer,
   appContainer: AppContainer,
 
 
   pages?: IPageWithMeta<IPageSearchMeta>[],
   pages?: IPageWithMeta<IPageSearchMeta>[],
 
 
+  forceHideMenuItems?: ForceHideMenuItems,
+
   onSelectedPagesByCheckboxesChanged?: (selectedCount: number, totalCount: number) => void,
   onSelectedPagesByCheckboxesChanged?: (selectedCount: number, totalCount: number) => void,
 
 
   searchControl: React.ReactNode,
   searchControl: React.ReactNode,
@@ -23,12 +30,11 @@ type Props = {
   searchPager: React.ReactNode,
   searchPager: React.ReactNode,
 }
 }
 
 
-const SearchPageBaseSubstance: ForwardRefRenderFunction<ISelectableAll, Props> = (props:Props, ref) => {
-  const { t } = useTranslation();
-
+const SearchPageBaseSubstance: ForwardRefRenderFunction<ISelectableAll & IReturnSelectedPageIds, Props> = (props:Props, ref) => {
   const {
   const {
     appContainer,
     appContainer,
     pages,
     pages,
+    forceHideMenuItems,
     onSelectedPagesByCheckboxesChanged,
     onSelectedPagesByCheckboxesChanged,
     searchControl, searchResultListHead, searchPager,
     searchControl, searchResultListHead, searchPager,
   } = props;
   } = props;
@@ -36,6 +42,8 @@ const SearchPageBaseSubstance: ForwardRefRenderFunction<ISelectableAll, Props> =
   const searchResultListRef = useRef<ISelectableAll|null>(null);
   const searchResultListRef = useRef<ISelectableAll|null>(null);
 
 
   const { data: isGuestUser } = useIsGuestUser();
   const { data: isGuestUser } = useIsGuestUser();
+  const { data: isSearchServiceConfigured } = useIsSearchServiceConfigured();
+  const { data: isSearchServiceReachable } = useIsSearchServiceReachable();
 
 
   // TODO get search keywords and split
   // TODO get search keywords and split
   // ref: RevisionRenderer
   // ref: RevisionRenderer
@@ -65,6 +73,9 @@ const SearchPageBaseSubstance: ForwardRefRenderFunction<ISelectableAll, Props> =
 
 
       selectedPageIdsByCheckboxes.clear();
       selectedPageIdsByCheckboxes.clear();
     },
     },
+    getSelectedPageIds: () => {
+      return selectedPageIdsByCheckboxes;
+    },
   }));
   }));
 
 
   const checkboxChangedHandler = (isChecked: boolean, pageId: string) => {
   const checkboxChangedHandler = (isChecked: boolean, pageId: string) => {
@@ -106,6 +117,30 @@ const SearchPageBaseSubstance: ForwardRefRenderFunction<ISelectableAll, Props> =
     }
     }
   }, [onSelectedPagesByCheckboxesChanged, pages, selectedPageIdsByCheckboxes]);
   }, [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 (
   return (
     <div className="content-main">
     <div className="content-main">
       <div className="search-result-base d-flex" data-testid="search-result-base">
       <div className="search-result-base d-flex" data-testid="search-result-base">
@@ -130,14 +165,6 @@ const SearchPageBaseSubstance: ForwardRefRenderFunction<ISelectableAll, Props> =
                   {searchResultListHead}
                   {searchResultListHead}
                 </div>
                 </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 && (
                 { pages.length > 0 && (
                   <div className="page-list px-md-4">
                   <div className="page-list px-md-4">
                     <SearchResultList
                     <SearchResultList
@@ -145,6 +172,7 @@ const SearchPageBaseSubstance: ForwardRefRenderFunction<ISelectableAll, Props> =
                       // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
                       // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
                       pages={pages!}
                       pages={pages!}
                       selectedPageId={selectedPageWithMeta?.pageData._id}
                       selectedPageId={selectedPageWithMeta?.pageData._id}
+                      forceHideMenuItems={forceHideMenuItems}
                       onPageSelected={page => setSelectedPageWithMeta(page)}
                       onPageSelected={page => setSelectedPageWithMeta(page)}
                       onCheckboxChanged={checkboxChangedHandler}
                       onCheckboxChanged={checkboxChangedHandler}
                     />
                     />
@@ -167,6 +195,7 @@ const SearchPageBaseSubstance: ForwardRefRenderFunction<ISelectableAll, Props> =
               pageWithMeta={selectedPageWithMeta}
               pageWithMeta={selectedPageWithMeta}
               highlightKeywords={highlightKeywords}
               highlightKeywords={highlightKeywords}
               showPageControlDropdown={!isGuestUser}
               showPageControlDropdown={!isGuestUser}
+              forceHideMenuItems={forceHideMenuItems}
             />
             />
           )}
           )}
         </div>
         </div>

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

@@ -312,7 +312,7 @@ const Sidebar: FC<Props> = (props: Props) => {
                 style={{ width: isCollapsed ? sidebarMinimizeWidth : currentProductNavWidth }}
                 style={{ width: isCollapsed ? sidebarMinimizeWidth : currentProductNavWidth }}
               >
               >
                 <div className="grw-contextual-navigation-child">
                 <div className="grw-contextual-navigation-child">
-                  <div role="group" className={`grw-contextual-navigation-sub ${showContents ? '' : 'd-none'}`}>
+                  <div role="group" data-testid="grw-contextual-navigation-sub" className={`grw-contextual-navigation-sub ${showContents ? '' : 'd-none'}`}>
                     <SidebarContentsWrapper></SidebarContentsWrapper>
                     <SidebarContentsWrapper></SidebarContentsWrapper>
                   </div>
                   </div>
                 </div>
                 </div>
@@ -328,6 +328,7 @@ const Sidebar: FC<Props> = (props: Props) => {
                 </div>
                 </div>
               ) }
               ) }
               <button
               <button
+                data-testid="grw-navigation-resize-button"
                 className={`grw-navigation-resize-button ${!isDrawerMode ? 'resizable' : ''} ${isCollapsed ? 'collapsed' : ''} `}
                 className={`grw-navigation-resize-button ${!isDrawerMode ? 'resizable' : ''} ${isCollapsed ? 'collapsed' : ''} `}
                 type="button"
                 type="button"
                 aria-expanded="true"
                 aria-expanded="true"

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

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

+ 8 - 10
packages/app/src/components/Sidebar/PageTree/Item.tsx

@@ -15,7 +15,7 @@ import { toastWarning, toastError, toastSuccess } from '~/client/util/apiNotific
 import { useSWRxPageChildren } from '~/stores/page-listing';
 import { useSWRxPageChildren } from '~/stores/page-listing';
 import { useSWRxPageInfo } from '~/stores/page';
 import { useSWRxPageInfo } from '~/stores/page';
 import { apiv3Put, apiv3Post } from '~/client/util/apiv3-client';
 import { apiv3Put, apiv3Post } from '~/client/util/apiv3-client';
-import { useShareLinkId } from '~/stores/context';
+import { useShareLinkId, useIsEnabledAttachTitleHeader } from '~/stores/context';
 import { IPageForPageDeleteModal } from '~/stores/modal';
 import { IPageForPageDeleteModal } from '~/stores/modal';
 
 
 import TriangleIcon from '~/components/Icons/TriangleIcon';
 import TriangleIcon from '~/components/Icons/TriangleIcon';
@@ -87,6 +87,7 @@ const Item: FC<ItemProps> = (props: ItemProps) => {
   // const [isRenameInputShown, setRenameInputShown] = useState(false);
   // const [isRenameInputShown, setRenameInputShown] = useState(false);
 
 
   const { data, mutate: mutateChildren } = useSWRxPageChildren(isOpen ? page._id : null);
   const { data, mutate: mutateChildren } = useSWRxPageChildren(isOpen ? page._id : null);
+  const { data: isEnabledAttachTitleHeader } = useIsEnabledAttachTitleHeader();
 
 
   // hasDescendants flag
   // hasDescendants flag
   const isChildrenLoaded = currentChildren?.length > 0;
   const isChildrenLoaded = currentChildren?.length > 0;
@@ -274,17 +275,15 @@ const Item: FC<ItemProps> = (props: ItemProps) => {
       return;
       return;
     }
     }
 
 
-    // TODO 88261: Get the isEnabledAttachTitleHeader by SWR
-    // const initBody = '';
-    // const { isEnabledAttachTitleHeader } = props.appContainer.getConfig();
-    // if (isEnabledAttachTitleHeader) {
-    //   initBody = pathUtils.attachTitleHeader(newPagePath);
-    // }
+    let initBody = '';
+    if (isEnabledAttachTitleHeader) {
+      initBody = pathUtils.attachTitleHeader(newPagePath);
+    }
 
 
     try {
     try {
       await apiv3Post('/pages/', {
       await apiv3Post('/pages/', {
         path: newPagePath,
         path: newPagePath,
-        body: '',
+        body: initBody,
         grant: page.grant,
         grant: page.grant,
         grantUserGroupId: page.grantedGroup,
         grantUserGroupId: page.grantedGroup,
         createFromPageTree: true,
         createFromPageTree: true,
@@ -372,7 +371,7 @@ const Item: FC<ItemProps> = (props: ItemProps) => {
           />
           />
         )}
         )}
         { !isRenameInputShown && ( */}
         { !isRenameInputShown && ( */}
-        <a href={page._id} className="grw-pagetree-title-anchor flex-grow-1">
+        <a href={`/${page._id}`} className="grw-pagetree-title-anchor flex-grow-1">
           <p className={`text-truncate m-auto ${page.isEmpty && 'text-muted'}`}>{nodePath.basename(pageTitle as string) || '/'}</p>
           <p className={`text-truncate m-auto ${page.isEmpty && 'text-muted'}`}>{nodePath.basename(pageTitle as string) || '/'}</p>
         </a>
         </a>
         {/* )} */}
         {/* )} */}
@@ -385,7 +384,6 @@ const Item: FC<ItemProps> = (props: ItemProps) => {
           <PageItemControl
           <PageItemControl
             pageId={page._id}
             pageId={page._id}
             isEnableActions={isEnableActions}
             isEnableActions={isEnableActions}
-            showBookmarkMenuItem
             onClickBookmarkMenuItem={bookmarkMenuItemClickHandler}
             onClickBookmarkMenuItem={bookmarkMenuItemClickHandler}
             onClickDuplicateMenuItem={duplicateMenuItemClickHandler}
             onClickDuplicateMenuItem={duplicateMenuItemClickHandler}
             onClickDeleteMenuItem={deleteMenuItemClickHandler}
             onClickDeleteMenuItem={deleteMenuItemClickHandler}

+ 4 - 15
packages/app/src/components/Sidebar/PageTree/ItemsTree.tsx

@@ -4,7 +4,7 @@ import { useTranslation } from 'react-i18next';
 import { IPageHasId } from '../../../interfaces/page';
 import { IPageHasId } from '../../../interfaces/page';
 import { ItemNode } from './ItemNode';
 import { ItemNode } from './ItemNode';
 import Item from './Item';
 import Item from './Item';
-import { useSWRxPageAncestorsChildren, useSWRxPageChildren, useSWRxRootPage } from '../../../stores/page-listing';
+import { useSWRxPageAncestorsChildren, useSWRxPageChildren, useSWRxRootPage } from '~/stores/page-listing';
 import { TargetAndAncestors } from '~/interfaces/page-listing-results';
 import { TargetAndAncestors } from '~/interfaces/page-listing-results';
 import { toastError, toastSuccess } from '~/client/util/apiNotification';
 import { toastError, toastSuccess } from '~/client/util/apiNotification';
 import {
 import {
@@ -127,22 +127,11 @@ const ItemsTree: FC<ItemsTreeProps> = (props: ItemsTreeProps) => {
 
 
     const path = pathOrPathsToDelete;
     const path = pathOrPathsToDelete;
 
 
-    if (isRecursively) {
-      if (isCompletely) {
-        toastSuccess(t('deleted_single_page_recursively_completely', { path }));
-      }
-      else {
-        toastSuccess(t('deleted_single_page_recursively', { path }));
-      }
+    if (isCompletely) {
+      toastSuccess(t('deleted_pages_completely', { path }));
     }
     }
     else {
     else {
-      // eslint-disable-next-line no-lonely-if
-      if (isCompletely) {
-        toastSuccess(t('deleted_single_page_completely', { path }));
-      }
-      else {
-        toastSuccess(t('deleted_single_page', { path }));
-      }
+      toastSuccess(t('deleted_pages', { path }));
     }
     }
   };
   };
 
 

+ 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 React, { FC, memo } from 'react';
 import { useTranslation } from 'react-i18next';
 import { useTranslation } from 'react-i18next';
 
 
-const PrivateLegacyPages: FC = memo(() => {
+export const PrivateLegacyPagesLink: FC = memo(() => {
   const { t } = useTranslation();
   const { t } = useTranslation();
 
 
   return (
   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')}
       <i className="icon-drawer mr-2"></i> {t('pagetree.private_legacy_pages')}
     </a>
     </a>
   );
   );
 });
 });
-
-export default PrivateLegacyPages;

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

@@ -1,11 +1,10 @@
-import { pagePathUtils } from '@growi/core';
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 
 
 import { subscribeRuleNames } from '~/interfaces/in-app-notification';
 import { subscribeRuleNames } from '~/interfaces/in-app-notification';
 
 
 const logger = loggerFactory('growi:routes:apiv3:pages'); // eslint-disable-line no-unused-vars
 const logger = loggerFactory('growi:routes:apiv3:pages'); // eslint-disable-line no-unused-vars
 const express = require('express');
 const express = require('express');
-const { pathUtils } = require('@growi/core');
+const { pathUtils, pagePathUtils } = require('@growi/core');
 const mongoose = require('mongoose');
 const mongoose = require('mongoose');
 
 
 const { body } = require('express-validator');
 const { body } = require('express-validator');
@@ -637,6 +636,11 @@ module.exports = (crowi) => {
 
 
     const newPagePath = pathUtils.normalizePath(req.body.pageNameInput);
     const newPagePath = pathUtils.normalizePath(req.body.pageNameInput);
 
 
+    const isCreatable = isCreatablePage(newPagePath);
+    if (!isCreatable) {
+      return res.apiv3Err(new ErrorV3('This page path is invalid', 'invalid_path'), 400);
+    }
+
     // check page existence
     // check page existence
     const isExist = (await Page.count({ path: newPagePath })) > 0;
     const isExist = (await Page.count({ path: newPagePath })) > 0;
     if (isExist) {
     if (isExist) {
@@ -787,7 +791,7 @@ module.exports = (crowi) => {
   });
   });
 
 
   // eslint-disable-next-line max-len
   // 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, isRecursively } = req.body;
     const pageIds = _pageIds == null ? [] : _pageIds;
     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)
     .get('/:token', apiLimiter, injectResetOrderByTokenMiddleware, forgotPassword.resetPassword)
     .use(forgotPassword.handleHttpErrosMiddleware));
     .use(forgotPassword.handleHttpErrosMiddleware));
 
 
-  app.use('/private-legacy-pages', express.Router()
+  app.use('/_private-legacy-pages', express.Router()
     .get('/', privateLegacyPages.renderPrivateLegacyPages));
     .get('/', privateLegacyPages.renderPrivateLegacyPages));
   app.use('/user-activation', express.Router()
   app.use('/user-activation', express.Router()
     .get('/:token', apiLimiter, applicationInstalled, injectUserRegistrationOrderByTokenMiddleware, userActivation.form)
     .get('/:token', apiLimiter, applicationInstalled, injectUserRegistrationOrderByTokenMiddleware, userActivation.form)

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

@@ -890,7 +890,7 @@ module.exports = function(crowi, app) {
    * - If revision_id is not specified => force update by the new contents.
    * - If revision_id is not specified => force update by the new contents.
    */
    */
   api.update = async function(req, res) {
   api.update = async function(req, res) {
-    const pageBody = body ?? null;
+    const pageBody = req.body.body ?? null;
     const pageId = req.body.page_id || null;
     const pageId = req.body.page_id || null;
     const revisionId = req.body.revision_id || null;
     const revisionId = req.body.revision_id || null;
     const grant = req.body.grant || null;
     const grant = req.body.grant || null;

+ 6 - 6
packages/app/src/server/service/installer.ts

@@ -62,14 +62,14 @@ export class InstallerService {
     const { localeDir } = this.crowi;
     const { localeDir } = this.crowi;
     // create /Sandbox/*
     // create /Sandbox/*
     /*
     /*
-     * Keep in this order to avoid creating the same pages
+     * Keep in this order to
+     *   1. avoid creating the same pages
+     *   2. avoid difference for order in VRT
      */
      */
     await this.createPage(path.join(localeDir, lang, 'sandbox.md'), '/Sandbox', owner);
     await this.createPage(path.join(localeDir, lang, 'sandbox.md'), '/Sandbox', owner);
-    await Promise.all([
-      this.createPage(path.join(localeDir, lang, 'sandbox-diagrams.md'), '/Sandbox/Diagrams', owner),
-      this.createPage(path.join(localeDir, lang, 'sandbox-bootstrap4.md'), '/Sandbox/Bootstrap4', owner),
-      this.createPage(path.join(localeDir, lang, 'sandbox-math.md'), '/Sandbox/Math', owner),
-    ]);
+    await this.createPage(path.join(localeDir, lang, 'sandbox-bootstrap4.md'), '/Sandbox/Bootstrap4', owner);
+    await this.createPage(path.join(localeDir, lang, 'sandbox-diagrams.md'), '/Sandbox/Diagrams', owner);
+    await this.createPage(path.join(localeDir, lang, 'sandbox-math.md'), '/Sandbox/Math', owner);
 
 
     // update createdAt and updatedAt fields of all pages
     // update createdAt and updatedAt fields of all pages
     if (initialPagesCreatedAt != null) {
     if (initialPagesCreatedAt != null) {

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

@@ -11,6 +11,13 @@
   data-target="#search-result-list"
   data-target="#search-result-list"
 {% endblock %}
 {% endblock %}
 
 
+<!-- add .on-search to body tag class in layout -->
+{% set additionalBodyClass = 'on-search' %}
+
 {% block layout_main %}
 {% 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 #}
 {% endblock %} {# layout_main #}

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

@@ -17,8 +17,7 @@
 {% block layout_main %}
 {% block layout_main %}
 <div id="grw-fav-sticky-trigger" class="sticky-top"></div>
 <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 #}
 {% endblock %} {# layout_main #}

+ 4 - 0
packages/app/src/stores/context.tsx

@@ -143,6 +143,10 @@ export const useIsSearchServiceReachable = (initialData?: boolean) : SWRResponse
   return useStaticSWR<boolean, Error>('isSearchServiceReachable', initialData);
   return useStaticSWR<boolean, Error>('isSearchServiceReachable', initialData);
 };
 };
 
 
+export const useIsEnabledAttachTitleHeader = (initialData?: boolean) : SWRResponse<boolean, Error> => {
+  return useStaticSWR<boolean, Error>('isEnabledAttachTitleHeader', initialData);
+};
+
 
 
 /** **********************************************************
 /** **********************************************************
  *                     Computed contexts
  *                     Computed contexts

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

@@ -1,6 +1,7 @@
 import { SWRResponse } from 'swr';
 import { SWRResponse } from 'swr';
 import { useStaticSWR } from './use-static-swr';
 import { useStaticSWR } from './use-static-swr';
 import { Nullable } from '~/interfaces/common';
 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
 * 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,
+  });
+
+};

+ 1 - 1
packages/app/src/stores/ui.tsx

@@ -299,7 +299,7 @@ export const useIsAbleToShowTagLabel = (): SWRResponse<boolean, Error> => {
   const isNotFoundPage = notFoundTargetPathOrId != null;
   const isNotFoundPage = notFoundTargetPathOrId != null;
 
 
   return useSWRImmutable(
   return useSWRImmutable(
-    includesUndefined ? null : key,
+    includesUndefined ? null : [key, editorMode],
     // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
     // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
     () => !isUserPage && !isSharedPage(currentPagePath!) && !isIdenticalPath && !(isViewMode && isNotFoundPage),
     () => !isUserPage && !isSharedPage(currentPagePath!) && !isIdenticalPath && !(isViewMode && isNotFoundPage),
   );
   );

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

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

+ 1 - 2
packages/app/test/cypress/integration/2-basic-features/access-to-admin-page.spec.ts

@@ -1,5 +1,3 @@
-const ssPrefix = 'access-to-admin-page-';
-
 const adminMenues = [
 const adminMenues = [
   'app', // App
   'app', // App
   'security', // Security
   'security', // Security
@@ -15,6 +13,7 @@ const adminMenues = [
 ];
 ];
 
 
 context('Access to Admin page', () => {
 context('Access to Admin page', () => {
+  const ssPrefix = 'access-to-admin-page-';
 
 
   let connectSid: string | undefined;
   let connectSid: string | undefined;
 
 

+ 2 - 3
packages/app/test/cypress/integration/2-basic-features/access-to-me-page.spec.ts

@@ -1,6 +1,5 @@
-const ssPrefix = 'access-to-page-';
-
-context('Access to page', () => {
+context('Access to /me page', () => {
+  const ssPrefix = 'access-to-me-page-';
 
 
   let connectSid: string | undefined;
   let connectSid: string | undefined;
 
 

+ 6 - 0
packages/app/test/cypress/integration/2-basic-features/access-to-page.spec.ts

@@ -22,6 +22,12 @@ context('Access to page', () => {
 
 
   it('/Sandbox is successfully loaded', () => {
   it('/Sandbox is successfully loaded', () => {
     cy.visit('/Sandbox', {  });
     cy.visit('/Sandbox', {  });
+
+    // collapse sidebar and wait saving
+    cy.collapseSidebar(true);
+    // eslint-disable-next-line cypress/no-unnecessary-waiting
+    cy.wait(1500);
+
     cy.screenshot(`${ssPrefix}-sandbox`, { capture: 'viewport' });
     cy.screenshot(`${ssPrefix}-sandbox`, { capture: 'viewport' });
   });
   });
 
 

+ 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' });
+  });
+
+});

+ 2 - 2
packages/app/test/cypress/integration/3-search/access-to-result-page-directly.spec.ts

@@ -20,7 +20,7 @@ context('Access to search result page directly', () => {
   });
   });
 
 
   it('/_search with "q" param is successfully loaded', () => {
   it('/_search with "q" param is successfully loaded', () => {
-    cy.visit('/_search', { qs: { q: 'bootstrap4' } });
+    cy.visit('/_search', { qs: { q: 'bootstrap4 labels alerts' } });
 
 
     cy.getByTestid('search-result-list').should('be.visible');
     cy.getByTestid('search-result-list').should('be.visible');
     cy.getByTestid('search-result-content').should('be.visible');
     cy.getByTestid('search-result-content').should('be.visible');
@@ -29,7 +29,7 @@ context('Access to search result page directly', () => {
   });
   });
 
 
   it('checkboxes behaviors', () => {
   it('checkboxes behaviors', () => {
-    cy.visit('/_search', { qs: { q: 'bootstrap4' } });
+    cy.visit('/_search', { qs: { q: 'bootstrap4 labels alerts' } });
 
 
     cy.getByTestid('search-result-base').should('be.visible');
     cy.getByTestid('search-result-base').should('be.visible');
     cy.getByTestid('search-result-list').should('be.visible');
     cy.getByTestid('search-result-list').should('be.visible');

+ 10 - 0
packages/app/test/cypress/support/commands.ts

@@ -37,3 +37,13 @@ Cypress.Commands.add('login', (username, password) => {
     cy.getByTestid('btnSubmitForLogin').click();
     cy.getByTestid('btnSubmitForLogin').click();
   });
   });
 });
 });
+
+Cypress.Commands.add('collapseSidebar', (isCollapsed) => {
+  cy.getByTestid('grw-contextual-navigation-sub').then(($contents) => {
+    const isCurrentCollapsed = $contents.hasClass('d-none');
+    // toggle when the current state and isCoolapsed is not match
+    if (isCurrentCollapsed !== isCollapsed) {
+      cy.getByTestid("grw-navigation-resize-button").click();
+    }
+  });
+});

+ 1 - 0
packages/app/test/cypress/support/index.ts

@@ -25,6 +25,7 @@ declare global {
     interface Chainable {
     interface Chainable {
        getByTestid(selector: string, options?: Partial<Loggable & Timeoutable & Withinable & Shadow>): Chainable<JQuery<Element>>,
        getByTestid(selector: string, options?: Partial<Loggable & Timeoutable & Withinable & Shadow>): Chainable<JQuery<Element>>,
        login(username: string, password: string): Chainable<void>,
        login(username: string, password: string): Chainable<void>,
+       collapseSidebar(isCollapsed: boolean): Chainable<void>,
     }
     }
   }
   }
 }
 }

+ 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('http://demo.growi.org/hoge')).toBeFalsy();
     expect(isCreatablePage('https://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();
     expect(isCreatablePage('/ the / path / with / space')).toBeFalsy();
 
 
     const forbidden = ['installer', 'register', 'login', 'logout',
     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$/,
   /.+\.md$/,
   /^(\.\.)$/, // see: https://github.com/weseek/growi/issues/3582
   /^(\.\.)$/, // see: https://github.com/weseek/growi/issues/3582
   /(\/\.\.)\/?/, // 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)(\/.*|$)/,
   /^\/(installer|register|login|logout|admin|me|files|trash|paste|comments|tags|share)(\/.*|$)/,
 ];
 ];
 export const isCreatablePage = (path: string): boolean => {
 export const isCreatablePage = (path: string): boolean => {