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

Merge branch 'master' into fix/scroll-to-specific-pagetree-item

Yuki Takei 4 лет назад
Родитель
Сommit
d812dafb90
66 измененных файлов с 936 добавлено и 586 удалено
  1. 1 1
      .github/workflows/reusable-app-prod.yml
  2. 1 1
      packages/app/package.json
  3. 1 0
      packages/app/resource/locales/en_US/admin/admin.json
  4. 4 3
      packages/app/resource/locales/en_US/translation.json
  5. 1 0
      packages/app/resource/locales/ja_JP/admin/admin.json
  6. 5 4
      packages/app/resource/locales/ja_JP/translation.json
  7. 1 0
      packages/app/resource/locales/zh_CN/admin/admin.json
  8. 11 10
      packages/app/resource/locales/zh_CN/translation.json
  9. 2 0
      packages/app/src/client/services/ContextExtractor.tsx
  10. 22 6
      packages/app/src/components/Admin/UserGroupDetail/UserGroupDetailPage.tsx
  11. 11 7
      packages/app/src/components/Common/Dropdown/PageItemControl.tsx
  12. 0 54
      packages/app/src/components/ComparePathsTable.jsx
  13. 8 16
      packages/app/src/components/DuplicatedPathsTable.jsx
  14. 3 3
      packages/app/src/components/Navbar/GrowiContextualSubNavigation.tsx
  15. 14 5
      packages/app/src/components/Navbar/SubNavButtons.tsx
  16. 6 5
      packages/app/src/components/Page.jsx
  17. 0 2
      packages/app/src/components/Page/RevisionLoader.jsx
  18. 5 11
      packages/app/src/components/Page/RevisionRenderer.jsx
  19. 5 5
      packages/app/src/components/PageDeleteModal.tsx
  20. 96 70
      packages/app/src/components/PageDuplicateModal.tsx
  21. 9 13
      packages/app/src/components/PageList/PageListItemL.tsx
  22. 3 1
      packages/app/src/components/PageList/PageListItemS.jsx
  23. 0 265
      packages/app/src/components/PageRenameModal.jsx
  24. 305 0
      packages/app/src/components/PageRenameModal.tsx
  25. 0 1
      packages/app/src/components/PageTimeline.jsx
  26. 1 3
      packages/app/src/components/SearchPage/OperateAllControl.tsx
  27. 1 1
      packages/app/src/components/SearchPage/SearchControl.tsx
  28. 2 3
      packages/app/src/components/SearchPage/SearchResultContent.tsx
  29. 2 2
      packages/app/src/components/SearchPage/SortControl.tsx
  30. 1 1
      packages/app/src/components/SearchTypeahead.tsx
  31. 0 1
      packages/app/src/components/Sidebar/CustomSidebar.tsx
  32. 3 1
      packages/app/src/components/Sidebar/PageTree.tsx
  33. 14 10
      packages/app/src/components/Sidebar/PageTree/Item.tsx
  34. 3 1
      packages/app/src/interfaces/page.ts
  35. 4 0
      packages/app/src/interfaces/user-group-response.ts
  36. 0 10
      packages/app/src/server/models/obsolete-page.js
  37. 12 11
      packages/app/src/server/models/page.ts
  38. 1 1
      packages/app/src/server/models/user-group.ts
  39. 7 7
      packages/app/src/server/routes/apiv3/pages.js
  40. 48 0
      packages/app/src/server/routes/apiv3/user-group.js
  41. 2 2
      packages/app/src/server/routes/page.js
  42. 9 3
      packages/app/src/server/service/page-grant.ts
  43. 12 10
      packages/app/src/server/service/page.ts
  44. 5 0
      packages/app/src/stores/context.tsx
  45. 10 18
      packages/app/src/stores/modal.tsx
  46. 8 1
      packages/app/src/stores/user-group.tsx
  47. 1 1
      packages/app/src/styles/_layout.scss
  48. 3 1
      packages/app/src/styles/_page-tree.scss
  49. 6 0
      packages/app/src/styles/_page_list.scss
  50. 1 1
      packages/app/src/styles/_search.scss
  51. 0 1
      packages/app/src/styles/_subnav.scss
  52. 0 5
      packages/app/src/styles/molecules/compare-paths-table.scss
  53. 0 1
      packages/app/src/styles/style-app.scss
  54. 11 1
      packages/app/src/styles/theme/_apply-colors-dark.scss
  55. 5 0
      packages/app/src/styles/theme/_apply-colors-light.scss
  56. 1 0
      packages/app/src/styles/theme/_apply-colors.scss
  57. 31 0
      packages/app/test/cypress/integration/2-basic-features/switch-sidebar-contents.spec.ts
  58. 33 0
      packages/app/test/cypress/integration/5-switch-sidebar-mode/switching-sidebar-mode.spec.ts
  59. 32 0
      packages/app/test/cypress/integration/6-home-settings/access-home.spec.ts
  60. 85 0
      packages/app/test/cypress/integration/6-home-settings/access-user-settings.spec.ts
  61. 2 0
      packages/core/src/index.js
  62. 1 1
      packages/core/src/utils/page-path-utils.ts
  63. 58 0
      packages/core/src/utils/page-utils.ts
  64. 2 1
      packages/plugin-lsx/src/client/js/components/LsxPageList/LsxPage.jsx
  65. 1 1
      packages/ui/src/components/PagePath/PageListMeta.jsx
  66. 4 3
      yarn.lock

+ 1 - 1
.github/workflows/reusable-app-prod.yml

@@ -185,7 +185,7 @@ jobs:
       fail-fast: false
       matrix:
         # List string expressions that is comma separated ids of tests in "test/cypress/integration"
-        spec-group: ['1', '2', '3']
+        spec-group: ['1', '2', '3', '4', '5', '6']
 
     services:
       mongodb:

+ 1 - 1
packages/app/package.json

@@ -245,7 +245,7 @@
     "swagger2openapi": "^5.3.1",
     "swr": "^1.1.2",
     "terser-webpack-plugin": "^4.1.0",
-    "throttle-debounce": "^2.0.0",
+    "throttle-debounce": "^3.0.1",
     "toastr": "^2.1.2",
     "ts-loader": "^8.3.0",
     "ts-node-dev": "^1.1.6",

+ 1 - 0
packages/app/resource/locales/en_US/admin/admin.json

@@ -469,6 +469,7 @@
     "group_list": "Group list",
     "child_group_list": "Child group list",
     "back_to_list": "Go back to group list",
+    "back_to_ancestors_group": "Go back to ancestors group",
     "basic_info": "Basic info",
     "user_list": "User list",
     "created_group": "Group was created",

+ 4 - 3
packages/app/resource/locales/en_US/translation.json

@@ -165,7 +165,7 @@
   "Page Tree": "Page Tree",
   "original_path":"Original path",
   "new_path":"New path",
-  "duplicated_path":"duplicated_path",
+  "duplicated_path":"Duplicated path",
   "Link sharing is disabled": "Link sharing is disabled",
   "successfully_saved_the_page": "Successfully saved the page",
   "you_can_not_create_page_with_this_name": "You can not create page with this name",
@@ -410,9 +410,10 @@
       "New page name": "New page name",
       "Failed to get subordinated pages": "Failed to get subordinated pages",
       "Failed to get exist path": "Failed to get exist path",
-      "Rename without exist path": "Rename without exist path",
       "Current page name": "Current page name",
-      "Recursively": "Recursively",
+      "Rename this page only": "Rename this page only",
+      "Force rename all child pages": "Force rename all pages",
+      "Other options": "Other options",
       "Do not update metadata": "Do not update metadata",
       "Redirect": "Redirect"
     },

+ 1 - 0
packages/app/resource/locales/ja_JP/admin/admin.json

@@ -468,6 +468,7 @@
     "group_list": "グループ一覧",
     "child_group_list": "子グループ一覧",
     "back_to_list": "グループ一覧に戻る",
+    "back_to_ancestors_group": "祖先グループに戻る",
     "basic_info": "基本情報",
     "user_list": "ユーザー一覧",
     "created_group": "グループを作成しました",

+ 5 - 4
packages/app/resource/locales/ja_JP/translation.json

@@ -409,15 +409,16 @@
       "New page name": "移動先のページ名",
       "Failed to get subordinated pages": "配下ページの取得に失敗しました",
       "Failed to get exist path": "存在するパスの取得に失敗しました",
-      "Rename without exist path": "存在するパス以外を名前変更する",
       "Current page name": "現在のページ名",
-      "Recursively": "再帰的に移動/名前変更",
-      "Do not update metadata": "メタデータを更新しない",
+      "Rename this page only": "このページのみを移動/名前変更",
+      "Force rename all child pages": "全ての配下のページを移動/名前変更する",
+      "Other options": "その他のオプション",
+      "Do not update metadata": "不更新元数据",
       "Redirect": "リダイレクトする"
     },
     "help": {
       "redirect": "アクセスされた際に自動的に新しいページにジャンプします",
-      "metadata": "最終更新ユーザー、最終更新日を更新せず維持します",
+      "metadata": "Remains last update user and updated date as is",
       "recursive": "配下のページも移動/名前変更します"
     }
   },

+ 1 - 0
packages/app/resource/locales/zh_CN/admin/admin.json

@@ -478,6 +478,7 @@
     "group_list": "组列表",
     "child_group_list": "儿童组名单",
     "back_to_list": "返回组列表",
+    "back_to_ancestors_group": "返回到祖先组",
     "basic_info": "基本信息",
     "user_list": "用户列表",
     "created_group": "已创建组",

+ 11 - 10
packages/app/resource/locales/zh_CN/translation.json

@@ -173,7 +173,7 @@
   "Page Tree": "页面树",
   "original_path":"Original path",
   "new_path":"New path",
-  "duplicated_path":"duplicated_path",
+  "duplicated_path":"Duplicated path",
   "Link sharing is disabled": "你不允许分享该链接",
   "successfully_saved_the_page": "成功地保存了该页面",
   "you_can_not_create_page_with_this_name": "您无法使用此名称创建页面",
@@ -384,20 +384,21 @@
   },
 	"modal_rename": {
 		"label": {
-			"Move/Rename page": "页面 移动/重命名",
+      "Move/Rename page": "页面 移动/重命名",
       "New page name": "新建页面名称",
       "Failed to get subordinated pages": "Failed to get subordinated pages",
       "Failed to get exist path": "Failed to get exist path",
-      "Rename without exist path": "Rename without exist path",
-			"Current page name": "当前页面名称",
-			"Recursively": "递归地",
-			"Do not update metadata": "不更新元数据",
-			"Redirect": "重定向"
+      "Current page name": "当前页面名称",
+      "Rename this page only": "仅重命名此页面",
+      "Force rename all child pages": "强制重命名所有子页面 ",
+      "Other options": "其他选项",
+      "Update metadata": "更新元数据",
+      "Redirect": "重定向"
 		},
 		"help": {
-			"redirect": "Redirect to new page if someone accesses <code>%s</code>",
-			"metadata": "Remains last update user and updated date as is",
-			"recursive": "Move/Rename children of under <code>%s</code> recursively"
+      "redirect": "Redirect to new page if someone accesses <code>%s</code>",
+      "metadata": "Update last update user and updated date",
+      "recursive": "Move/Rename children of under <code>%s</code> recursively"
 		}
 	},
 	"Put Back": "Put back",

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

@@ -2,6 +2,7 @@ import React, { FC, useEffect, useState } from 'react';
 import { pagePathUtils } from '@growi/core';
 
 import {
+  useSiteUrl,
   useCurrentCreatedAt, useDeleteUsername, useDeletedAt, useHasChildren, useHasDraftOnHackmd,
   useIsDeleted, useIsNotCreatable, useIsTrashPage, useIsUserPage, useLastUpdateUsername,
   useCurrentPageId, usePageIdOnHackmd, usePageUser, useCurrentPagePath, useRevisionCreatedAt, useRevisionId, useRevisionIdHackmdSynced,
@@ -101,6 +102,7 @@ const ContextExtractorOnce: FC = () => {
   useCurrentProductNavWidth(userUISettings?.currentProductNavWidth);
 
   // hydrated config
+  useSiteUrl(configByContextHydrate.crowi.url);
   useIsAclEnabled(configByContextHydrate.isAclEnabled);
   useIsSearchServiceConfigured(configByContextHydrate.isSearchServiceConfigured);
   useIsSearchServiceReachable(configByContextHydrate.isSearchServiceReachable);

+ 22 - 6
packages/app/src/components/Admin/UserGroupDetail/UserGroupDetailPage.tsx

@@ -21,7 +21,7 @@ import {
   IUserGroup, IUserGroupHasId,
 } from '~/interfaces/user';
 import {
-  useSWRxUserGroupPages, useSWRxUserGroupRelationList, useSWRxChildUserGroupList, useSWRxSelectableUserGroups,
+  useSWRxUserGroupPages, useSWRxUserGroupRelationList, useSWRxChildUserGroupList, useSWRxSelectableUserGroups, useSWRxAncestorUserGroups,
 } from '~/stores/user-group';
 import { useIsAclEnabled } from '~/stores/context';
 
@@ -56,6 +56,8 @@ const UserGroupDetailPage: FC = () => {
 
   const { data: selectableUserGroups, mutate: mutateSelectableUserGroups } = useSWRxSelectableUserGroups(userGroup._id);
 
+  const { data: ancestorUserGroups } = useSWRxAncestorUserGroups(userGroup._id);
+
   const { data: isAclEnabled } = useIsAclEnabled();
 
   /*
@@ -190,11 +192,25 @@ const UserGroupDetailPage: FC = () => {
 
   return (
     <div>
-      <a href="/admin/user-groups" className="btn btn-outline-secondary">
-        <i className="icon-fw ti-arrow-left" aria-hidden="true"></i>
-        {t('admin:user_group_management.back_to_list')}
-      </a>
-      {/* TODO 85062: Link to the ancestors group */}
+      <nav aria-label="breadcrumb">
+        <ol className="breadcrumb">
+          <li className="breadcrumb-item"><a href="/admin/user-groups">{t('admin:user_group_management.group_list')}</a></li>
+          {
+            ancestorUserGroups != null && ancestorUserGroups.length > 0 && (
+              ancestorUserGroups.map((ancestorUserGroup: IUserGroupHasId) => (
+                <li key={ancestorUserGroup._id} className={`breadcrumb-item ${ancestorUserGroup._id === userGroup._id ? 'active' : ''}`} aria-current="page">
+                  { ancestorUserGroup._id === userGroup._id ? (
+                    <>{ancestorUserGroup.name}</>
+                  ) : (
+                    <a href={`/admin/user-group-detail/${ancestorUserGroup._id}`}>{ancestorUserGroup.name}</a>
+                  )}
+                </li>
+              ))
+            )
+          }
+        </ol>
+      </nav>
+
       <div className="mt-4 form-box">
         <UserGroupForm
           userGroup={userGroup}

+ 11 - 7
packages/app/src/components/Common/Dropdown/PageItemControl.tsx

@@ -35,7 +35,7 @@ type CommonProps = {
 
   onClickBookmarkMenuItem?: (pageId: string, newValue?: boolean) => Promise<void>,
   onClickDuplicateMenuItem?: (pageId: string) => Promise<void> | void,
-  onClickRenameMenuItem?: (pageId: string) => Promise<void> | void,
+  onClickRenameMenuItem?: (pageId: string, pageInfo: IPageInfoAll | undefined) => Promise<void> | void,
   onClickDeleteMenuItem?: (pageId: string, pageInfo: IPageInfoAll | undefined) => Promise<void> | void,
   onClickRevertMenuItem?: (pageId: string) => Promise<void> | void,
 
@@ -80,8 +80,12 @@ const PageItemControlDropdownMenu = React.memo((props: DropdownMenuProps): JSX.E
     if (onClickRenameMenuItem == null) {
       return;
     }
-    await onClickRenameMenuItem(pageId);
-  }, [onClickRenameMenuItem, pageId]);
+    if (!pageInfo?.isMovable) {
+      logger.warn('This page could not be renamed.');
+      return;
+    }
+    await onClickRenameMenuItem(pageId, pageInfo);
+  }, [onClickRenameMenuItem, pageId, pageInfo]);
 
   const revertItemClickedHandler = useCallback(async() => {
     if (onClickRevertMenuItem == null) {
@@ -239,8 +243,8 @@ export const PageItemControlSubstance = (props: PageItemControlSubstanceProps):
     if (onClickRenameMenuItem == null) {
       return;
     }
-    await onClickRenameMenuItem(pageId);
-  }, [onClickRenameMenuItem, pageId]);
+    await onClickRenameMenuItem(pageId, fetchedPageInfo ?? presetPageInfo);
+  }, [onClickRenameMenuItem, pageId, fetchedPageInfo, presetPageInfo]);
 
   const deleteMenuItemClickHandler = useCallback(async() => {
     if (onClickDeleteMenuItem == null) {
@@ -252,8 +256,8 @@ export const PageItemControlSubstance = (props: PageItemControlSubstanceProps):
   return (
     <Dropdown isOpen={isOpen} toggle={() => setIsOpen(!isOpen)} data-testid="open-page-item-control-btn">
       { children ?? (
-        <DropdownToggle color="transparent" className="border-0 rounded btn-page-item-control">
-          <i className="icon-options text-muted"></i>
+        <DropdownToggle color="transparent" className="border-0 rounded btn-page-item-control d-flex align-items-center justify-content-center">
+          <i className="icon-options"></i>
         </DropdownToggle>
       ) }
 

+ 0 - 54
packages/app/src/components/ComparePathsTable.jsx

@@ -1,54 +0,0 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-
-import { withTranslation } from 'react-i18next';
-import { pagePathUtils } from '@growi/core';
-
-
-const { convertToNewAffiliationPath } = pagePathUtils;
-
-function ComparePathsTable(props) {
-  const {
-    path, subordinatedPages, newPagePath, t,
-  } = props;
-
-  return (
-    <table className="table table-bordered grw-compare-paths-table">
-      <thead>
-        <tr className="d-flex">
-          <th className="w-50">{t('original_path')}</th>
-          <th className="w-50">{t('new_path')}</th>
-        </tr>
-      </thead>
-      <tbody className="overflow-auto d-block">
-        {subordinatedPages.map((subordinatedPage) => {
-          const convertedPath = convertToNewAffiliationPath(path, newPagePath, subordinatedPage.path);
-          return (
-            <tr key={subordinatedPage._id} className="d-flex">
-              <td className="text-break w-50">
-                <a href={subordinatedPage.path}>
-                  {subordinatedPage.path}
-                </a>
-              </td>
-              <td className="text-break w-50">
-                {convertedPath}
-              </td>
-            </tr>
-          );
-        })}
-      </tbody>
-    </table>
-  );
-}
-
-
-ComparePathsTable.propTypes = {
-  t: PropTypes.func.isRequired, //  i18next
-
-  path: PropTypes.string.isRequired,
-  subordinatedPages: PropTypes.array.isRequired,
-  newPagePath: PropTypes.string.isRequired,
-};
-
-
-export default withTranslation()(ComparePathsTable);

+ 8 - 16
packages/app/src/components/DuplicatedPathsTable.jsx

@@ -1,19 +1,17 @@
 import React from 'react';
 import PropTypes from 'prop-types';
 
-import { withTranslation } from 'react-i18next';
+import { useTranslation } from 'react-i18next';
 import { pagePathUtils } from '@growi/core';
-import { withUnstatedContainers } from './UnstatedUtils';
-
-import PageContainer from '~/client/services/PageContainer';
 
 const { convertToNewAffiliationPath } = pagePathUtils;
 
 function DuplicatedPathsTable(props) {
+  const { t } = useTranslation();
+
   const {
-    pageContainer, oldPagePath, existingPaths, t,
+    fromPath, toPath, existingPaths,
   } = props;
-  const { path } = pageContainer.state;
 
   return (
     <table className="table table-bordered grw-duplicated-paths-table">
@@ -25,7 +23,7 @@ function DuplicatedPathsTable(props) {
       </thead>
       <tbody className="overflow-auto d-block">
         {existingPaths.map((existPath) => {
-          const convertedPath = convertToNewAffiliationPath(oldPagePath, path, existPath);
+          const convertedPath = convertToNewAffiliationPath(toPath, fromPath, existPath);
           return (
             <tr key={existPath} className="d-flex">
               <td className="text-break w-50">
@@ -45,17 +43,11 @@ function DuplicatedPathsTable(props) {
 }
 
 
-/**
- * Wrapper component for using unstated
- */
-const PageDuplicateModallWrapper = withUnstatedContainers(DuplicatedPathsTable, [PageContainer]);
-
 DuplicatedPathsTable.propTypes = {
-  t: PropTypes.func.isRequired, //  i18next
-  pageContainer: PropTypes.instanceOf(PageContainer).isRequired,
   existingPaths: PropTypes.array.isRequired,
-  oldPagePath: PropTypes.string.isRequired,
+  fromPath: PropTypes.string.isRequired,
+  toPath: PropTypes.string.isRequired,
 };
 
 
-export default withTranslation()(PageDuplicateModallWrapper);
+export default DuplicatedPathsTable;

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

@@ -6,7 +6,7 @@ import PropTypes from 'prop-types';
 import { DropdownItem } from 'reactstrap';
 
 import { OnDuplicatedFunction, OnRenamedFunction, OnDeletedFunction } from '~/interfaces/ui';
-import { IPageHasId, IPageWithMeta } from '~/interfaces/page';
+import { IPageHasId, IPageToRenameWithMeta, IPageWithMeta } from '~/interfaces/page';
 
 import { withUnstatedContainers } from '../UnstatedUtils';
 import EditorContainer from '~/client/services/EditorContainer';
@@ -16,7 +16,7 @@ import {
 } from '~/stores/ui';
 import {
   usePageAccessoriesModal, PageAccessoriesModalContents, IPageForPageDuplicateModal,
-  usePageDuplicateModal, usePageRenameModal, IPageForPageRenameModal, usePageDeleteModal, usePagePresentationModal,
+  usePageDuplicateModal, usePageRenameModal, usePageDeleteModal, usePagePresentationModal,
 } from '~/stores/modal';
 
 
@@ -190,7 +190,7 @@ const GrowiContextualSubNavigation = (props) => {
     openDuplicateModal(page, { onDuplicated: duplicatedHandler });
   }, [openDuplicateModal]);
 
-  const renameItemClickedHandler = useCallback(async(page: IPageForPageRenameModal) => {
+  const renameItemClickedHandler = useCallback(async(page: IPageToRenameWithMeta) => {
     const renamedHandler: OnRenamedFunction = () => {
       window.location.reload();
     };

+ 14 - 5
packages/app/src/components/Navbar/SubNavButtons.tsx

@@ -1,14 +1,14 @@
 import React, { useCallback } from 'react';
 
 import {
-  IPageInfoAll, IPageToDeleteWithMeta, isIPageInfoForEntity, isIPageInfoForOperation,
+  IPageInfoAll, IPageToDeleteWithMeta, IPageToRenameWithMeta, isIPageInfoForEntity, isIPageInfoForOperation,
 } from '~/interfaces/page';
 
 import { useSWRxPageInfo } from '../../stores/page';
 import { useSWRBookmarkInfo } from '../../stores/bookmark';
 import { useSWRxUsersList } from '../../stores/user';
 import { useIsGuestUser } from '~/stores/context';
-import { IPageForPageRenameModal, IPageForPageDuplicateModal } from '~/stores/modal';
+import { IPageForPageDuplicateModal } from '~/stores/modal';
 
 import SubscribeButton from '../SubscribeButton';
 import LikeButtons from '../LikeButtons';
@@ -27,7 +27,7 @@ type CommonProps = {
   forceHideMenuItems?: ForceHideMenuItems,
   additionalMenuItemRenderer?: React.FunctionComponent<AdditionalMenuItemsRendererProps>,
   onClickDuplicateMenuItem?: (pageToDuplicate: IPageForPageDuplicateModal) => void,
-  onClickRenameMenuItem?: (pageToRename: IPageForPageRenameModal) => void,
+  onClickRenameMenuItem?: (pageToRename: IPageToRenameWithMeta) => void,
   onClickDeleteMenuItem?: (pageToDelete: IPageToDeleteWithMeta) => void,
 }
 
@@ -111,9 +111,18 @@ const SubNavButtonsSubstance = (props: SubNavButtonsSubstanceProps): JSX.Element
     if (onClickRenameMenuItem == null || path == null) {
       return;
     }
-    const page: IPageForPageRenameModal = { pageId, revisionId, path };
+
+    const page: IPageToRenameWithMeta = {
+      data: {
+        _id: pageId,
+        revision: revisionId,
+        path,
+      },
+      meta: pageInfo,
+    };
+
     onClickRenameMenuItem(page);
-  }, [onClickRenameMenuItem, pageId, path, revisionId]);
+  }, [onClickRenameMenuItem, pageId, pageInfo, path, revisionId]);
 
   const deleteMenuItemClickHandler = useCallback(async(_pageId: string): Promise<void> => {
     if (onClickDeleteMenuItem == null || path == null) {

+ 6 - 5
packages/app/src/components/Page.jsx

@@ -21,7 +21,7 @@ import { getOptionsToSave } from '~/client/util/editor';
 
 // TODO: remove this when omitting unstated is completed
 import {
-  EditorMode, useEditorMode, useSelectedGrant, useSelectedGrantGroupId, useSelectedGrantGroupName,
+  useEditorMode, useSelectedGrant, useSelectedGrantGroupId, useSelectedGrantGroupName,
 } from '~/stores/ui';
 import { useIsSlackEnabled } from '~/stores/editor';
 import { useSlackChannels } from '~/stores/context';
@@ -143,15 +143,17 @@ class Page extends React.Component {
   }
 
   render() {
-    const { appContainer, pageContainer, editorMode } = this.props;
+    const { appContainer, pageContainer } = this.props;
     const { isMobile } = appContainer;
     const isLoggedIn = appContainer.currentUser != null;
     const { markdown, revisionId } = pageContainer.state;
-    const isRenderable = !(editorMode === EditorMode.View && revisionId == null);
 
     return (
       <div className={`mb-5 ${isMobile ? 'page-mobile' : ''}`}>
-        <RevisionRenderer growiRenderer={this.growiRenderer} markdown={markdown} isRenderable={isRenderable} />
+
+        { revisionId != null && (
+          <RevisionRenderer growiRenderer={this.growiRenderer} markdown={markdown} />
+        )}
 
         { isLoggedIn && (
           <>
@@ -189,7 +191,6 @@ const PageWrapper = (props) => {
   const { data: grantGroupId } = useSelectedGrantGroupId();
   const { data: grantGroupName } = useSelectedGrantGroupName();
 
-
   if (editorMode == null) {
     return null;
   }

+ 0 - 2
packages/app/src/components/Page/RevisionLoader.jsx

@@ -107,7 +107,6 @@ class LegacyRevisionLoader extends React.Component {
         growiRenderer={this.props.growiRenderer}
         markdown={markdown}
         highlightKeywords={this.props.highlightKeywords}
-        isRenderable={this.props.isRenderable}
       />
     );
   }
@@ -128,7 +127,6 @@ LegacyRevisionLoader.propTypes = {
   lazy: PropTypes.bool,
   onRevisionLoaded: PropTypes.func,
   highlightKeywords: PropTypes.arrayOf(PropTypes.string),
-  isRenderable: PropTypes.bool,
 };
 
 const RevisionLoader = (props) => {

+ 5 - 11
packages/app/src/components/Page/RevisionRenderer.jsx

@@ -33,20 +33,16 @@ class LegacyRevisionRenderer extends React.PureComponent {
   }
 
   componentDidMount() {
-    const { isRenderable } = this.props;
-
-    if (isRenderable) {
-      this.initCurrentRenderingContext();
-      this.renderHtml();
-    }
+    this.initCurrentRenderingContext();
+    this.renderHtml();
   }
 
   componentDidUpdate(prevProps) {
     const { markdown: prevMarkdown, highlightKeywords: prevHighlightKeywords } = prevProps;
-    const { markdown, isRenderable, highlightKeywords } = this.props;
+    const { markdown, highlightKeywords } = this.props;
 
     // render only when props.markdown is updated
-    if ((markdown !== prevMarkdown || highlightKeywords !== prevHighlightKeywords) && isRenderable) {
+    if (markdown !== prevMarkdown || highlightKeywords !== prevHighlightKeywords) {
       this.initCurrentRenderingContext();
       this.renderHtml();
       return;
@@ -173,8 +169,7 @@ LegacyRevisionRenderer.propTypes = {
   appContainer: PropTypes.instanceOf(AppContainer).isRequired,
   growiRenderer: PropTypes.instanceOf(GrowiRenderer).isRequired,
   markdown: PropTypes.string.isRequired,
-  isRenderable: PropTypes.bool,
-  highlightKeywords: PropTypes.arrayOf(PropTypes.string),
+  highlightKeywords: PropTypes.oneOfType([PropTypes.string, PropTypes.arrayOf(PropTypes.string)]),
   additionalClassName: PropTypes.string,
 };
 
@@ -192,7 +187,6 @@ const RevisionRenderer = (props) => {
 RevisionRenderer.propTypes = {
   growiRenderer: PropTypes.instanceOf(GrowiRenderer).isRequired,
   markdown: PropTypes.string.isRequired,
-  isRenderable: PropTypes.bool,
   highlightKeywords: PropTypes.arrayOf(PropTypes.string),
   additionalClassName: PropTypes.string,
 };

+ 5 - 5
packages/app/src/components/PageDeleteModal.tsx

@@ -10,7 +10,7 @@ import { usePageDeleteModal } from '~/stores/modal';
 import loggerFactory from '~/utils/logger';
 
 import {
-  IDeleteSinglePageApiv1Result, IDeleteManyPageApiv3Result, isIPageInfoForOperation, IPageToDeleteWithMeta, IDataWithMeta, IPageInfoForOperation,
+  IDeleteSinglePageApiv1Result, IDeleteManyPageApiv3Result, IPageToDeleteWithMeta, IDataWithMeta, isIPageInfoForEntity, IPageInfoForEntity,
 } from '~/interfaces/page';
 import { HasObjectId } from '~/interfaces/has-object-id';
 
@@ -43,13 +43,13 @@ const PageDeleteModal: FC = () => {
   const isOpened = deleteModalData?.isOpened ?? false;
 
   const notOperatablePages: IPageToDeleteWithMeta[] = (deleteModalData?.pages ?? [])
-    .filter(p => !isIPageInfoForOperation(p.meta));
+    .filter(p => !isIPageInfoForEntity(p.meta));
   const notOperatablePageIds = notOperatablePages.map(p => p.data._id);
 
   const { injectTo } = useSWRxPageInfoForList(notOperatablePageIds);
 
   // inject IPageInfo to operate
-  let injectedPages: IDataWithMeta<HasObjectId & { path: string }, IPageInfoForOperation>[] | null = null;
+  let injectedPages: IDataWithMeta<HasObjectId & { path: string }, IPageInfoForEntity>[] | null = null;
   if (deleteModalData?.pages != null && notOperatablePageIds.length > 0) {
     injectedPages = injectTo(deleteModalData?.pages);
   }
@@ -212,10 +212,10 @@ const PageDeleteModal: FC = () => {
 
     if (pages != null) {
       return pages.map(page => (
-        <div key={page.data._id}>
+        <p key={page.data._id} className="mb-1">
           <code>{ page.data.path }</code>
           { !page.meta?.isDeletable && <span className="ml-3 text-danger"><strong>(CAN NOT TO DELETE)</strong></span> }
-        </div>
+        </p>
       ));
     }
     return <></>;

+ 96 - 70
packages/app/src/components/PageDuplicateModal.jsx → packages/app/src/components/PageDuplicateModal.tsx

@@ -1,68 +1,86 @@
-import React, { useState, useEffect, useCallback } from 'react';
-import PropTypes from 'prop-types';
+import React, {
+  useState, useEffect, useCallback, useMemo,
+} from 'react';
 
 import {
   Modal, ModalHeader, ModalBody, ModalFooter,
 } from 'reactstrap';
 
-import { withTranslation } from 'react-i18next';
+import { useTranslation } from 'react-i18next';
 import { debounce } from 'throttle-debounce';
-import { withUnstatedContainers } from './UnstatedUtils';
+
+import { apiv3Get, apiv3Post } from '~/client/util/apiv3-client';
 import { toastError } from '~/client/util/apiNotification';
+
 import { usePageDuplicateModal } from '~/stores/modal';
+import { useIsSearchServiceReachable, useSiteUrl } from '~/stores/context';
 
-import AppContainer from '~/client/services/AppContainer';
 import PagePathAutoComplete from './PagePathAutoComplete';
 import ApiErrorMessageList from './PageManagement/ApiErrorMessageList';
-import ComparePathsTable from './ComparePathsTable';
 import DuplicatePathsTable from './DuplicatedPathsTable';
 
-const LIMIT_FOR_LIST = 10;
 
-const PageDuplicateModal = (props) => {
-  const {
-    t, appContainer,
-  } = props;
+const PageDuplicateModal = (): JSX.Element => {
+  const { t } = useTranslation();
+
+  const { data: siteUrl } = useSiteUrl();
+  const { data: isReachable } = useIsSearchServiceReachable();
 
-  const config = appContainer.getConfig();
-  const isReachable = config.isSearchServiceReachable;
-  const { crowi } = appContainer.config;
   const { data: duplicateModalData, close: closeDuplicateModal } = usePageDuplicateModal();
 
-  const { isOpened, page } = duplicateModalData;
-  const { pageId, path } = page;
+  const isOpened = duplicateModalData?.isOpened ?? false;
+  const page = duplicateModalData?.page;
 
-  const [pageNameInput, setPageNameInput] = useState(path);
+  const [pageNameInput, setPageNameInput] = useState('');
 
   const [errs, setErrs] = useState(null);
 
   const [subordinatedPages, setSubordinatedPages] = useState([]);
+  const [existingPaths, setExistingPaths] = useState<string[]>([]);
   const [isDuplicateRecursively, setIsDuplicateRecursively] = useState(true);
   const [isDuplicateRecursivelyWithoutExistPath, setIsDuplicateRecursivelyWithoutExistPath] = useState(true);
-  const [existingPaths, setExistingPaths] = useState([]);
 
-  const checkExistPaths = useCallback(async(newParentPath) => {
+  const updateSubordinatedList = useCallback(async() => {
+    if (page == null) {
+      return;
+    }
+
+    const { path } = page;
+    try {
+      const res = await apiv3Get('/pages/subordinated-list', { path });
+      setSubordinatedPages(res.data.subordinatedPages);
+    }
+    catch (err) {
+      setErrs(err);
+      toastError(t('modal_duplicate.label.Failed to get subordinated pages'));
+    }
+  }, [page, t]);
+
+  const checkExistPaths = useCallback(async(fromPath, toPath) => {
+    if (page == null) {
+      return;
+    }
+
     try {
-      const res = await appContainer.apiv3Get('/page/exist-paths', { fromPath: path, toPath: newParentPath });
+      const res = await apiv3Get<{ existPaths: string[] }>('/page/exist-paths', { fromPath, toPath });
       const { existPaths } = res.data;
       setExistingPaths(existPaths);
     }
     catch (err) {
       setErrs(err);
-      toastError(t('modal_rename.label.Fail to get exist path'));
+      toastError(t('modal_rename.label.Failed to get exist path'));
     }
-  }, [appContainer, path, t]);
-
+  }, [page, t]);
 
-  const checkExistPathsDebounce = useCallback(() => {
-    debounce(1000, checkExistPaths);
+  const checkExistPathsDebounce = useMemo(() => {
+    return debounce(1000, checkExistPaths);
   }, [checkExistPaths]);
 
   useEffect(() => {
-    if (pageId != null && path != null && pageNameInput !== path) {
-      checkExistPathsDebounce(pageNameInput, subordinatedPages);
+    if (page != null && pageNameInput !== page.path) {
+      checkExistPathsDebounce(page.path, pageNameInput);
     }
-  }, [pageNameInput, subordinatedPages, path, pageId, checkExistPathsDebounce]);
+  }, [pageNameInput, subordinatedPages, checkExistPathsDebounce, page]);
 
   /**
    * change pageNameInput for PagePathAutoComplete
@@ -86,34 +104,24 @@ const PageDuplicateModal = (props) => {
     setIsDuplicateRecursively(!isDuplicateRecursively);
   }
 
-  const getSubordinatedList = useCallback(async() => {
-    try {
-      const res = await appContainer.apiv3Get('/pages/subordinated-list', { path, limit: LIMIT_FOR_LIST });
-      setSubordinatedPages(res.data.subordinatedPages);
-    }
-    catch (err) {
-      setErrs(err);
-      toastError(t('modal_duplicate.label.Failed to get subordinated pages'));
-    }
-  }, [appContainer, path, t]);
-
   useEffect(() => {
-    if (isOpened) {
-      getSubordinatedList();
-      setPageNameInput(path);
+    if (page != null && isOpened) {
+      updateSubordinatedList();
+      setPageNameInput(page.path);
     }
-  }, [isOpened, getSubordinatedList, path]);
+  }, [isOpened, page, updateSubordinatedList]);
 
-  function changeIsDuplicateRecursivelyWithoutExistPathHandler() {
-    setIsDuplicateRecursivelyWithoutExistPath(!isDuplicateRecursivelyWithoutExistPath);
-  }
+  const duplicate = useCallback(async() => {
+    if (page == null) {
+      return;
+    }
 
-  async function duplicate() {
     setErrs(null);
 
+    const { pageId, path } = page;
     try {
-      const { data } = await appContainer.apiv3Post('/pages/duplicate', { pageId, pageNameInput, isRecursively: isDuplicateRecursively });
-      const onDuplicated = duplicateModalData.opts?.onDuplicated;
+      const { data } = await apiv3Post('/pages/duplicate', { pageId, pageNameInput, isRecursively: isDuplicateRecursively });
+      const onDuplicated = duplicateModalData?.opts?.onDuplicated;
       const fromPath = path;
       const toPath = data.page.path;
 
@@ -125,12 +133,35 @@ const PageDuplicateModal = (props) => {
     catch (err) {
       setErrs(err);
     }
-  }
+  }, [closeDuplicateModal, duplicateModalData?.opts?.onDuplicated, isDuplicateRecursively, page, pageNameInput]);
+
+  useEffect(() => {
+    if (isOpened) {
+      return;
+    }
+
+    // reset states after the modal closed
+    setTimeout(() => {
+      setPageNameInput('');
+      setErrs(null);
+      setSubordinatedPages([]);
+      setExistingPaths([]);
+      setIsDuplicateRecursively(true);
+      setIsDuplicateRecursivelyWithoutExistPath(false);
+    }, 1000);
 
-  function ppacSubmitHandler() {
-    duplicate();
+  }, [isOpened]);
+
+  if (page == null) {
+    return <></>;
   }
 
+  const { path } = page;
+  const isTargetPageDuplicate = existingPaths.includes(pageNameInput);
+
+  const submitButtonEnabled = existingPaths.length === 0
+    || (isDuplicateRecursively && isDuplicateRecursivelyWithoutExistPath);
+
   return (
     <Modal size="lg" isOpen={isOpened} toggle={closeDuplicateModal} className="grw-duplicate-page" autoFocus={false}>
       <ModalHeader tag="h4" toggle={closeDuplicateModal} className="bg-primary text-light">
@@ -144,14 +175,14 @@ const PageDuplicateModal = (props) => {
           <label htmlFor="duplicatePageName">{ t('modal_duplicate.label.New page name') }</label><br />
           <div className="input-group">
             <div className="input-group-prepend">
-              <span className="input-group-text">{crowi.url}</span>
+              <span className="input-group-text">{siteUrl}</span>
             </div>
             <div className="flex-fill">
               {isReachable
                 ? (
                   <PagePathAutoComplete
                     initializedPath={path}
-                    onSubmit={ppacSubmitHandler}
+                    onSubmit={duplicate}
                     onInputChange={ppacInputChangeHandler}
                     autoFocus
                   />
@@ -168,6 +199,11 @@ const PageDuplicateModal = (props) => {
             </div>
           </div>
         </div>
+
+        { isTargetPageDuplicate && (
+          <p className="text-danger">Error: Target path is duplicated.</p>
+        ) }
+
         <div className="custom-control custom-checkbox custom-checkbox-warning mb-3">
           <input
             className="custom-control-input"
@@ -191,7 +227,7 @@ const PageDuplicateModal = (props) => {
                   id="cbDuplicatewithoutExistRecursively"
                   type="checkbox"
                   checked={isDuplicateRecursivelyWithoutExistPath}
-                  onChange={changeIsDuplicateRecursivelyWithoutExistPathHandler}
+                  onChange={() => setIsDuplicateRecursivelyWithoutExistPath(!isDuplicateRecursivelyWithoutExistPath)}
                 />
                 <label className="custom-control-label" htmlFor="cbDuplicatewithoutExistRecursively">
                   { t('modal_duplicate.label.Duplicate without exist path') }
@@ -200,8 +236,9 @@ const PageDuplicateModal = (props) => {
             )}
           </div>
           <div>
-            {isDuplicateRecursively && path != null && <ComparePathsTable path={path} subordinatedPages={subordinatedPages} newPagePath={pageNameInput} />}
-            {isDuplicateRecursively && existingPaths.length !== 0 && <DuplicatePathsTable existingPaths={existingPaths} oldPagePath={pageNameInput} />}
+            {isDuplicateRecursively && existingPaths.length !== 0 && (
+              <DuplicatePathsTable existingPaths={existingPaths} fromPath={path} toPath={pageNameInput} />
+            ) }
           </div>
         </div>
 
@@ -212,7 +249,7 @@ const PageDuplicateModal = (props) => {
           type="button"
           className="btn btn-primary"
           onClick={duplicate}
-          disabled={(isDuplicateRecursively && !isDuplicateRecursivelyWithoutExistPath && existingPaths.length !== 0)}
+          disabled={!submitButtonEnabled}
         >
           { t('modal_duplicate.label.Duplicate page') }
         </button>
@@ -222,15 +259,4 @@ const PageDuplicateModal = (props) => {
 };
 
 
-/**
- * Wrapper component for using unstated
- */
-const PageDuplicateModallWrapper = withUnstatedContainers(PageDuplicateModal, [AppContainer]);
-
-
-PageDuplicateModal.propTypes = {
-  t: PropTypes.func.isRequired, //  i18next
-  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
-};
-
-export default withTranslation()(PageDuplicateModallWrapper);
+export default PageDuplicateModal;

+ 9 - 13
packages/app/src/components/PageList/PageListItemL.tsx

@@ -113,14 +113,10 @@ const PageListItemLSubstance: ForwardRefRenderFunction<ISelectable, Props> = (pr
     openDuplicateModal(page, { onDuplicated: onPageDuplicated });
   }, [onPageDuplicated, openDuplicateModal, pageData._id, pageData.path]);
 
-  const renameMenuItemClickHandler = useCallback(() => {
-    const page = {
-      pageId: pageData._id,
-      revisionId: pageData.revision as string,
-      path: pageData.path,
-    };
+  const renameMenuItemClickHandler = useCallback((_id: string, pageInfo: IPageInfoAll | undefined) => {
+    const page = { data: pageData, meta: pageInfo };
     openRenameModal(page, { onRenamed: onPageRenamed });
-  }, [onPageRenamed, openRenameModal, pageData._id, pageData.path, pageData.revision]);
+  }, [pageData, onPageRenamed, openRenameModal]);
 
 
   const deleteMenuItemClickHandler = useCallback((_id: string, pageInfo: IPageInfoAll | undefined) => {
@@ -144,16 +140,16 @@ const PageListItemLSubstance: ForwardRefRenderFunction<ISelectable, Props> = (pr
   return (
     <li
       key={pageData._id}
-      className={`list-group-item p-0 ${styleListGroupItem} ${styleActive}`}
+      className={`list-group-item d-flex align-items-center px-3 px-md-1 ${styleListGroupItem} ${styleActive}`}
     >
       <div
-        className="text-break"
+        className="text-break w-100"
         onClick={clickHandler}
       >
         <div className="d-flex">
           {/* checkbox */}
           {onCheckboxChanged != null && (
-            <div className="d-flex align-items-center justify-content-center pl-md-2 pl-3">
+            <div className="d-flex align-items-center justify-content-center">
               <CustomInput
                 type="checkbox"
                 id={`cbSelect-${pageData._id}`}
@@ -164,7 +160,7 @@ const PageListItemLSubstance: ForwardRefRenderFunction<ISelectable, Props> = (pr
             </div>
           )}
 
-          <div className="flex-grow-1 p-md-3 pl-2 py-3 pr-3">
+          <div className="flex-grow-1 px-2 px-md-4">
             <div className="d-flex justify-content-between">
               {/* page path */}
               <PagePathHierarchicalLink
@@ -202,12 +198,12 @@ const PageListItemLSubstance: ForwardRefRenderFunction<ISelectable, Props> = (pr
               </Clamp>
 
               {/* page meta */}
-              <div className="d-none d-md-flex py-0 px-1">
+              <div className="d-none d-md-flex py-0 px-1 ml-2 text-nowrap">
                 <PageListMeta page={pageData} bookmarkCount={pageMeta?.bookmarkCount} shouldSpaceOutIcon />
               </div>
 
               {/* doropdown icon includes page control buttons */}
-              <div className="item-control ml-auto">
+              <div className="ml-auto">
                 <PageItemControl
                   pageId={pageData._id}
                   pageInfo={isIPageInfoForListing(pageMeta) ? pageMeta : undefined}

+ 3 - 1
packages/app/src/components/PageList/PageListItemS.jsx

@@ -20,7 +20,9 @@ export default class PageListItemS extends React.Component {
       <>
         <UserPicture user={page.lastUpdateUser} noLink={noLink} />
         {pagePathElem}
-        <PageListMeta page={page} />
+        <span className="ml-2">
+          <PageListMeta page={page} />
+        </span>
       </>
     );
   }

+ 0 - 265
packages/app/src/components/PageRenameModal.jsx

@@ -1,265 +0,0 @@
-import React, {
-  useState, useEffect, useCallback,
-} from 'react';
-import PropTypes from 'prop-types';
-
-import {
-  Modal, ModalHeader, ModalBody, ModalFooter,
-} from 'reactstrap';
-
-import { withTranslation } from 'react-i18next';
-
-import { debounce } from 'throttle-debounce';
-import { usePageRenameModal } from '~/stores/modal';
-import { withUnstatedContainers } from './UnstatedUtils';
-import { toastError } from '~/client/util/apiNotification';
-
-import AppContainer from '~/client/services/AppContainer';
-
-import { apiv3Get, apiv3Put } from '~/client/util/apiv3-client';
-
-import ApiErrorMessageList from './PageManagement/ApiErrorMessageList';
-import ComparePathsTable from './ComparePathsTable';
-import DuplicatedPathsTable from './DuplicatedPathsTable';
-
-
-const PageRenameModal = (props) => {
-  const {
-    t, appContainer,
-  } = props;
-
-  const { crowi } = appContainer.config;
-  const { data: renameModalData, close: closeRenameModal } = usePageRenameModal();
-
-  const { isOpened, page } = renameModalData;
-  const { pageId, revisionId, path } = page;
-
-  const [pageNameInput, setPageNameInput] = useState('');
-
-  const [errs, setErrs] = useState(null);
-
-  const [subordinatedPages, setSubordinatedPages] = useState([]);
-  const [existingPaths, setExistingPaths] = useState([]);
-  const [isRenameRecursively, SetIsRenameRecursively] = useState(true);
-  const [isRenameRedirect, SetIsRenameRedirect] = useState(false);
-  const [isRemainMetadata, SetIsRemainMetadata] = useState(false);
-  const [subordinatedError] = useState(null);
-  const [isRenameRecursivelyWithoutExistPath, setIsRenameRecursivelyWithoutExistPath] = useState(true);
-
-  function changeIsRenameRecursivelyHandler() {
-    SetIsRenameRecursively(!isRenameRecursively);
-  }
-
-  function changeIsRenameRecursivelyWithoutExistPathHandler() {
-    setIsRenameRecursivelyWithoutExistPath(!isRenameRecursivelyWithoutExistPath);
-  }
-
-  function changeIsRenameRedirectHandler() {
-    SetIsRenameRedirect(!isRenameRedirect);
-  }
-
-  function changeIsRemainMetadataHandler() {
-    SetIsRemainMetadata(!isRemainMetadata);
-  }
-
-  const updateSubordinatedList = useCallback(async() => {
-    try {
-      const res = await apiv3Get('/pages/subordinated-list', { path });
-      setSubordinatedPages(res.data.subordinatedPages);
-    }
-    catch (err) {
-      setErrs(err);
-      toastError(t('modal_rename.label.Failed to get subordinated pages'));
-    }
-  }, [path, t]);
-
-  useEffect(() => {
-    if (isOpened) {
-      updateSubordinatedList();
-      setPageNameInput(path);
-    }
-  }, [isOpened, path, updateSubordinatedList]);
-
-
-  const checkExistPaths = useCallback(async(newParentPath) => {
-    try {
-      const res = await apiv3Get('/page/exist-paths', { fromPath: path, toPath: newParentPath });
-      const { existPaths } = res.data;
-      setExistingPaths(existPaths);
-    }
-    catch (err) {
-      setErrs(err);
-      toastError(t('modal_rename.label.Fail to get exist path'));
-    }
-  }, [path, t]);
-
-  // eslint-disable-next-line react-hooks/exhaustive-deps
-  const checkExistPathsDebounce = useCallback(() => {
-    debounce(1000, checkExistPaths);
-  }, [checkExistPaths]);
-
-  useEffect(() => {
-    if (pageId != null && path != null && pageNameInput !== path) {
-      checkExistPathsDebounce(pageNameInput, subordinatedPages);
-    }
-  }, [pageNameInput, subordinatedPages, pageId, path, checkExistPathsDebounce]);
-
-  /**
-   * change pageNameInput
-   * @param {string} value
-   */
-  function inputChangeHandler(value) {
-    setErrs(null);
-    setPageNameInput(value);
-  }
-
-  async function rename() {
-    setErrs(null);
-
-    try {
-      const response = await apiv3Put('/pages/rename', {
-        revisionId,
-        pageId,
-        isRecursively: isRenameRecursively,
-        isRenameRedirect,
-        isRemainMetadata,
-        newPagePath: pageNameInput,
-        path,
-      });
-
-      const { page } = response.data;
-      const url = new URL(page.path, 'https://dummy');
-      if (isRenameRedirect) {
-        url.searchParams.append('withRedirect', true);
-      }
-
-      const onRenamed = renameModalData.opts?.onRenamed;
-      if (onRenamed != null) {
-        onRenamed(path);
-      }
-      closeRenameModal();
-    }
-    catch (err) {
-      setErrs(err);
-    }
-  }
-
-  return (
-    <Modal size="lg" isOpen={isOpened} toggle={closeRenameModal} autoFocus={false}>
-      <ModalHeader tag="h4" toggle={closeRenameModal} className="bg-primary text-light">
-        { t('modal_rename.label.Move/Rename page') }
-      </ModalHeader>
-      <ModalBody>
-        <div className="form-group">
-          <label>{ t('modal_rename.label.Current page name') }</label><br />
-          <code>{ path }</code>
-        </div>
-        <div className="form-group">
-          <label htmlFor="newPageName">{ t('modal_rename.label.New page name') }</label><br />
-          <div className="input-group">
-            <div className="input-group-prepend">
-              <span className="input-group-text">{crowi.url}</span>
-            </div>
-            <form className="flex-fill" onSubmit={(e) => { e.preventDefault(); rename() }}>
-              <input
-                type="text"
-                value={pageNameInput}
-                className="form-control"
-                onChange={e => inputChangeHandler(e.target.value)}
-                required
-                autoFocus
-              />
-            </form>
-          </div>
-        </div>
-        <div className="custom-control custom-checkbox custom-checkbox-warning">
-          <input
-            className="custom-control-input"
-            name="recursively"
-            id="cbRenameRecursively"
-            type="checkbox"
-            checked={isRenameRecursively}
-            onChange={changeIsRenameRecursivelyHandler}
-          />
-          <label className="custom-control-label" htmlFor="cbRenameRecursively">
-            { t('modal_rename.label.Recursively') }
-            <p className="form-text text-muted mt-0">{ t('modal_rename.help.recursive') }</p>
-          </label>
-          {existingPaths.length !== 0 && (
-            <div
-              className="custom-control custom-checkbox custom-checkbox-warning"
-              style={{ display: isRenameRecursively ? '' : 'none' }}
-            >
-              <input
-                className="custom-control-input"
-                name="withoutExistRecursively"
-                id="cbRenamewithoutExistRecursively"
-                type="checkbox"
-                checked={isRenameRecursivelyWithoutExistPath}
-                onChange={changeIsRenameRecursivelyWithoutExistPathHandler}
-              />
-              <label className="custom-control-label" htmlFor="cbRenamewithoutExistRecursively">
-                { t('modal_rename.label.Rename without exist path') }
-              </label>
-            </div>
-          )}
-          {isRenameRecursively && path != null && <ComparePathsTable path={path} subordinatedPages={subordinatedPages} newPagePath={pageNameInput} />}
-          {isRenameRecursively && existingPaths.length !== 0 && <DuplicatedPathsTable existingPaths={existingPaths} oldPagePath={pageNameInput} />}
-        </div>
-
-        <div className="custom-control custom-checkbox custom-checkbox-success">
-          <input
-            className="custom-control-input"
-            name="create_redirect"
-            id="cbRenameRedirect"
-            type="checkbox"
-            checked={isRenameRedirect}
-            onChange={changeIsRenameRedirectHandler}
-          />
-          <label className="custom-control-label" htmlFor="cbRenameRedirect">
-            { t('modal_rename.label.Redirect') }
-            <p className="form-text text-muted mt-0">{ t('modal_rename.help.redirect') }</p>
-          </label>
-        </div>
-
-        <div className="custom-control custom-checkbox custom-checkbox-primary">
-          <input
-            className="custom-control-input"
-            name="remain_metadata"
-            id="cbRemainMetadata"
-            type="checkbox"
-            checked={isRemainMetadata}
-            onChange={changeIsRemainMetadataHandler}
-          />
-          <label className="custom-control-label" htmlFor="cbRemainMetadata">
-            { t('modal_rename.label.Do not update metadata') }
-            <p className="form-text text-muted mt-0">{ t('modal_rename.help.metadata') }</p>
-          </label>
-        </div>
-        <div> {subordinatedError} </div>
-      </ModalBody>
-      <ModalFooter>
-        <ApiErrorMessageList errs={errs} targetPath={pageNameInput} />
-        <button
-          type="button"
-          className="btn btn-primary"
-          onClick={rename}
-          disabled={(isRenameRecursively && !isRenameRecursivelyWithoutExistPath && existingPaths.length !== 0)}
-        >Rename
-        </button>
-      </ModalFooter>
-    </Modal>
-  );
-};
-
-/**
- * Wrapper component for using unstated
- */
-const PageRenameModalWrapper = withUnstatedContainers(PageRenameModal, [AppContainer]);
-
-PageRenameModal.propTypes = {
-  t: PropTypes.func.isRequired, //  i18next
-  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
-};
-
-export default withTranslation()(PageRenameModalWrapper);

+ 305 - 0
packages/app/src/components/PageRenameModal.tsx

@@ -0,0 +1,305 @@
+import React, {
+  useState, useEffect, useCallback, useMemo,
+} from 'react';
+
+import {
+  Collapse, Modal, ModalHeader, ModalBody, ModalFooter,
+} from 'reactstrap';
+
+import { useTranslation } from 'react-i18next';
+
+import { debounce } from 'throttle-debounce';
+import { usePageRenameModal } from '~/stores/modal';
+import { toastError } from '~/client/util/apiNotification';
+
+import { apiv3Get, apiv3Put } from '~/client/util/apiv3-client';
+
+import ApiErrorMessageList from './PageManagement/ApiErrorMessageList';
+import DuplicatedPathsTable from './DuplicatedPathsTable';
+import { useSiteUrl } from '~/stores/context';
+import { isIPageInfoForEntity } from '~/interfaces/page';
+import { useSWRxPageInfo } from '~/stores/page';
+
+
+const isV5Compatible = (meta: unknown): boolean => {
+  return isIPageInfoForEntity(meta) ? meta.isV5Compatible : true;
+};
+
+
+const PageRenameModal = (): JSX.Element => {
+  const { t } = useTranslation();
+
+  const { data: siteUrl } = useSiteUrl();
+  const { data: renameModalData, close: closeRenameModal } = usePageRenameModal();
+
+  const isOpened = renameModalData?.isOpened ?? false;
+  const page = renameModalData?.page;
+
+  const shouldFetch = isOpened && page != null && !isIPageInfoForEntity(page.meta);
+  const { data: pageInfo } = useSWRxPageInfo(shouldFetch ? page?.data._id : null);
+
+  if (page != null && pageInfo != null) {
+    page.meta = pageInfo;
+  }
+
+  const [pageNameInput, setPageNameInput] = useState('');
+
+  const [errs, setErrs] = useState(null);
+
+  const [subordinatedPages, setSubordinatedPages] = useState([]);
+  const [existingPaths, setExistingPaths] = useState<string[]>([]);
+  const [isRenameRecursively, setIsRenameRecursively] = useState(true);
+  const [isRenameRedirect, setIsRenameRedirect] = useState(false);
+  const [isRemainMetadata, setIsRemainMetadata] = useState(false);
+  const [expandOtherOptions, setExpandOtherOptions] = useState(false);
+  const [subordinatedError] = useState(null);
+
+  const updateSubordinatedList = useCallback(async() => {
+    if (page == null) {
+      return;
+    }
+
+    const { path } = page.data;
+    try {
+      const res = await apiv3Get('/pages/subordinated-list', { path });
+      setSubordinatedPages(res.data.subordinatedPages);
+    }
+    catch (err) {
+      setErrs(err);
+      toastError(t('modal_rename.label.Failed to get subordinated pages'));
+    }
+  }, [page, t]);
+
+  useEffect(() => {
+    if (page != null && isOpened) {
+      updateSubordinatedList();
+      setPageNameInput(page.data.path);
+    }
+  }, [isOpened, page, updateSubordinatedList]);
+
+  const rename = useCallback(async() => {
+    if (page == null) {
+      return;
+    }
+
+    const _isV5Compatible = isV5Compatible(page.meta);
+
+    setErrs(null);
+
+    const { _id, path, revision } = page.data;
+    try {
+      const response = await apiv3Put('/pages/rename', {
+        pageId: _id,
+        revisionId: revision,
+        isRecursively: !_isV5Compatible ? isRenameRecursively : undefined,
+        isRenameRedirect,
+        updateMetadata: !isRemainMetadata,
+        newPagePath: pageNameInput,
+        path,
+      });
+
+      const { page } = response.data;
+      const url = new URL(page.path, 'https://dummy');
+      if (isRenameRedirect) {
+        url.searchParams.append('withRedirect', 'true');
+      }
+
+      const onRenamed = renameModalData?.opts?.onRenamed;
+      if (onRenamed != null) {
+        onRenamed(path);
+      }
+      closeRenameModal();
+    }
+    catch (err) {
+      setErrs(err);
+    }
+  }, [closeRenameModal, isRemainMetadata, isRenameRecursively, isRenameRedirect, page, pageNameInput, renameModalData?.opts?.onRenamed]);
+
+  const checkExistPaths = useCallback(async(fromPath, toPath) => {
+    if (page == null) {
+      return;
+    }
+
+    try {
+      const res = await apiv3Get<{ existPaths: string[] }>('/page/exist-paths', { fromPath, toPath });
+      const { existPaths } = res.data;
+      setExistingPaths(existPaths);
+    }
+    catch (err) {
+      setErrs(err);
+      toastError(t('modal_rename.label.Failed to get exist path'));
+    }
+  }, [page, t]);
+
+  const checkExistPathsDebounce = useMemo(() => {
+    return debounce(1000, checkExistPaths);
+  }, [checkExistPaths]);
+
+  useEffect(() => {
+    if (page != null && pageNameInput !== page.data.path) {
+      checkExistPathsDebounce(page.data.path, pageNameInput);
+    }
+  }, [pageNameInput, subordinatedPages, checkExistPathsDebounce, page]);
+
+  /**
+   * change pageNameInput
+   * @param {string} value
+   */
+  function inputChangeHandler(value) {
+    setErrs(null);
+    setPageNameInput(value);
+  }
+
+  useEffect(() => {
+    if (isOpened) {
+      return;
+    }
+
+    // reset states after the modal closed
+    setTimeout(() => {
+      setPageNameInput('');
+      setErrs(null);
+      setSubordinatedPages([]);
+      setExistingPaths([]);
+      setIsRenameRecursively(true);
+      setIsRenameRedirect(false);
+      setIsRemainMetadata(false);
+      setExpandOtherOptions(false);
+    }, 1000);
+
+  }, [isOpened]);
+
+  if (page == null) {
+    return <></>;
+  }
+
+  const { path } = page.data;
+  const isTargetPageDuplicate = existingPaths.includes(pageNameInput);
+
+  const submitButtonDisabled = isV5Compatible(page.meta)
+    ? existingPaths.length !== 0 // v5 data
+    : !isRenameRecursively; // v4 data
+
+  return (
+    <Modal size="lg" isOpen={isOpened} toggle={closeRenameModal} autoFocus={false}>
+      <ModalHeader tag="h4" toggle={closeRenameModal} className="bg-primary text-light">
+        { t('modal_rename.label.Move/Rename page') }
+      </ModalHeader>
+      <ModalBody>
+        <div className="form-group">
+          <label>{ t('modal_rename.label.Current page name') }</label><br />
+          <code>{ path }</code>
+        </div>
+        <div className="form-group">
+          <label htmlFor="newPageName">{ t('modal_rename.label.New page name') }</label><br />
+          <div className="input-group">
+            <div className="input-group-prepend">
+              <span className="input-group-text">{siteUrl}</span>
+            </div>
+            <form className="flex-fill" onSubmit={(e) => { e.preventDefault(); rename() }}>
+              <input
+                type="text"
+                value={pageNameInput}
+                className="form-control"
+                onChange={e => inputChangeHandler(e.target.value)}
+                required
+                autoFocus
+              />
+            </form>
+          </div>
+        </div>
+
+        { isTargetPageDuplicate && (
+          <p className="text-danger">Error: Target path is duplicated.</p>
+        ) }
+
+        { !isV5Compatible(page.meta) && (
+          <>
+            <div className="custom-control custom-radio custom-radio-warning">
+              <input
+                className="custom-control-input"
+                name="recursively"
+                id="cbRenameThisPageOnly"
+                type="radio"
+                checked={!isRenameRecursively}
+                onChange={() => setIsRenameRecursively(!isRenameRecursively)}
+              />
+              <label className="custom-control-label" htmlFor="cbRenameThisPageOnly">
+                { t('modal_rename.label.Rename this page only') }
+              </label>
+            </div>
+            <div className="custom-control custom-radio custom-radio-warning mt-1">
+              <input
+                className="custom-control-input"
+                name="withoutExistRecursively"
+                id="cbForceRenameRecursively"
+                type="radio"
+                checked={isRenameRecursively}
+                onChange={() => setIsRenameRecursively(!isRenameRecursively)}
+              />
+              <label className="custom-control-label" htmlFor="cbForceRenameRecursively">
+                { t('modal_rename.label.Force rename all child pages') }
+                <p className="form-text text-muted mt-0">{ t('modal_rename.help.recursive') }</p>
+              </label>
+              {isRenameRecursively && existingPaths.length !== 0 && (
+                <DuplicatedPathsTable existingPaths={existingPaths} fromPath={path} toPath={pageNameInput} />
+              ) }
+            </div>
+          </>
+        ) }
+
+        <p className="mt-2">
+          <button type="button" className="btn btn-link mt-2 p-0" aria-expanded="false" onClick={() => setExpandOtherOptions(!expandOtherOptions)}>
+            <i className={`fa fa-fw fa-arrow-right ${expandOtherOptions ? 'fa-rotate-90' : ''}`}></i>
+            { t('modal_rename.label.Other options') }
+          </button>
+        </p>
+        <Collapse isOpen={expandOtherOptions}>
+          <div className="custom-control custom-checkbox custom-checkbox-success">
+            <input
+              className="custom-control-input"
+              name="create_redirect"
+              id="cbRenameRedirect"
+              type="checkbox"
+              checked={isRenameRedirect}
+              onChange={() => setIsRenameRedirect(!isRenameRedirect)}
+            />
+            <label className="custom-control-label" htmlFor="cbRenameRedirect">
+              { t('modal_rename.label.Redirect') }
+              <p className="form-text text-muted mt-0">{ t('modal_rename.help.redirect') }</p>
+            </label>
+          </div>
+
+          <div className="custom-control custom-checkbox custom-checkbox-primary">
+            <input
+              className="custom-control-input"
+              name="remain_metadata"
+              id="cbRemainMetadata"
+              type="checkbox"
+              checked={isRemainMetadata}
+              onChange={() => setIsRemainMetadata(!isRemainMetadata)}
+            />
+            <label className="custom-control-label" htmlFor="cbRemainMetadata">
+              { t('modal_rename.label.Do not update metadata') }
+              <p className="form-text text-muted mt-0">{ t('modal_rename.help.metadata') }</p>
+            </label>
+          </div>
+          <div> {subordinatedError} </div>
+        </Collapse>
+
+      </ModalBody>
+      <ModalFooter>
+        <ApiErrorMessageList errs={errs} targetPath={pageNameInput} />
+        <button
+          type="button"
+          className="btn btn-primary"
+          onClick={rename}
+          disabled={submitButtonDisabled}
+        >Rename
+        </button>
+      </ModalFooter>
+    </Modal>
+  );
+};
+
+export default PageRenameModal;

+ 0 - 1
packages/app/src/components/PageTimeline.jsx

@@ -85,7 +85,6 @@ class PageTimeline extends React.Component {
                     growiRenderer={this.growiRenderer}
                     pageId={page._id}
                     revisionId={page.revision}
-                    isRenderable
                   />
                 </div>
               </div>

+ 1 - 3
packages/app/src/components/SearchPage/OperateAllControl.tsx

@@ -63,9 +63,7 @@ const OperateAllControlSubstance: ForwardRefRenderFunction<ISelectableAndIndeter
         disabled={isCheckboxDisabled}
         onChange={checkboxChangedHandler}
       />
-      <span className="ml-2">
-        {children}
-      </span>
+      {children}
     </div>
   );
 

+ 1 - 1
packages/app/src/components/SearchPage/SearchControl.tsx

@@ -83,7 +83,7 @@ const SearchControl: FC <Props> = React.memo((props: Props) => {
       </div>
       {/* TODO: replace the following elements deleteAll button , relevance button and include specificPath button component */}
       <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">
+        <div className="d-flex">
           {deleteAllControl}
         </div>
         {/* sort option: show when screen is smaller than lg */}

+ 2 - 3
packages/app/src/components/SearchPage/SearchResultContent.tsx

@@ -5,7 +5,7 @@ import { useTranslation } from 'react-i18next';
 
 import { DropdownItem } from 'reactstrap';
 
-import { IPageToDeleteWithMeta, IPageWithMeta } from '~/interfaces/page';
+import { IPageToDeleteWithMeta, IPageToRenameWithMeta, IPageWithMeta } from '~/interfaces/page';
 import { IPageSearchMeta } from '~/interfaces/search';
 import { OnDuplicatedFunction, OnRenamedFunction, OnDeletedFunction } from '~/interfaces/ui';
 import { usePageTreeTermManager } from '~/stores/page-listing';
@@ -131,7 +131,7 @@ export const SearchResultContent: FC<Props> = (props: Props) => {
     openDuplicateModal(pageToDuplicate, { onDuplicated: duplicatedHandler });
   }, [advanceDpl, advanceFts, advancePt, openDuplicateModal, t]);
 
-  const renameItemClickedHandler = useCallback(async(pageToRename) => {
+  const renameItemClickedHandler = useCallback((pageToRename: IPageToRenameWithMeta) => {
     const renamedHandler: OnRenamedFunction = (path) => {
       toastSuccess(t('renamed_pages', { path }));
 
@@ -214,7 +214,6 @@ export const SearchResultContent: FC<Props> = (props: Props) => {
           pagePath={page.path}
           revisionId={page.revision}
           highlightKeywords={highlightKeywords}
-          isRenderable
         />
       </div>
     </div>

+ 2 - 2
packages/app/src/components/SearchPage/SortControl.tsx

@@ -38,10 +38,10 @@ const SortControl: FC <Props> = (props: Props) => {
         <div className="border rounded-right">
           <button
             type="button"
-            className="btn dropdown-toggle search-sort-option-btn"
+            className="btn dropdown-toggle search-sort-option-btn py-1"
             data-toggle="dropdown"
           >
-            <span className="mr-4 text-secondary">{t(`search_result.sort_axis.${sort}`)}</span>
+            <span className="mr-2 text-secondary">{t(`search_result.sort_axis.${sort}`)}</span>
           </button>
           <div className="dropdown-menu dropdown-menu-right">
             {Object.values(SORT_AXIS).map((sortAxis) => {

+ 1 - 1
packages/app/src/components/SearchTypeahead.tsx

@@ -194,7 +194,7 @@ const SearchTypeahead: ForwardRefRenderFunction<IFocusable, Props> = (props: Pro
     return (
       <span>
         <UserPicture user={pageData.lastUpdateUser} size="sm" noLink />
-        <span className="ml-1 text-break text-wrap"><PagePathLabel path={pageData.path} /></span>
+        <span className="ml-1 mr-2 text-break text-wrap"><PagePathLabel path={pageData.path} /></span>
         <PageListMeta page={pageData} />
       </span>
     );

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

@@ -63,7 +63,6 @@ const CustomSidebar: FC<Props> = (props: Props) => {
               growiRenderer={renderer}
               markdown={markdown}
               additionalClassName="grw-custom-sidebar-content"
-              isRenderable
             />
           </div>
         ) : (

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

@@ -76,7 +76,9 @@ const PageTree: FC = memo(() => {
 
       {!isGuestUser && migrationStatus?.migratablePagesCount != null && migrationStatus.migratablePagesCount !== 0 && (
         <div className="grw-pagetree-footer border-top p-3 w-100">
-          <PrivateLegacyPagesLink />
+          <div className="private-legacy-pages-link px-3 py-2">
+            <PrivateLegacyPagesLink />
+          </div>
         </div>
       )}
     </>

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

@@ -24,7 +24,9 @@ import ClosableTextInput, { AlertInfo, AlertType } from '../../Common/ClosableTe
 import { PageItemControl } from '../../Common/Dropdown/PageItemControl';
 import { ItemNode } from './ItemNode';
 import { usePageTreeDescCountMap } from '~/stores/ui';
-import { IPageHasId, IPageInfoAll, IPageToDeleteWithMeta } from '~/interfaces/page';
+import {
+  IPageHasId, IPageInfoAll, IPageToDeleteWithMeta,
+} from '~/interfaces/page';
 
 
 const logger = loggerFactory('growi:cli:Item');
@@ -99,7 +101,7 @@ type ItemCountProps = {
 const ItemCount: FC<ItemCountProps> = (props:ItemCountProps) => {
   return (
     <>
-      <span className="grw-pagetree-count badge badge-pill badge-light text-muted">
+      <span className="grw-pagetree-count px-0 badge badge-pill badge-light text-muted">
         {props.descendantCount}
       </span>
     </>
@@ -184,7 +186,7 @@ const Item: FC<ItemProps> = (props: ItemProps) => {
         revisionId: droppedPage.revision,
         newPagePath,
         isRenameRedirect: false,
-        isRemainMetadata: false,
+        updateMetadata: true,
       });
 
       await mutateChildren();
@@ -215,7 +217,7 @@ const Item: FC<ItemProps> = (props: ItemProps) => {
           if (monitor.isOver()) {
             setIsOpen(true);
           }
-        }, 1000);
+        }, 600);
       }
     },
     canDrop: (item) => {
@@ -289,6 +291,10 @@ const Item: FC<ItemProps> = (props: ItemProps) => {
   };
 
   const deleteMenuItemClickHandler = useCallback(async(_pageId: string, pageInfo: IPageInfoAll | undefined): Promise<void> => {
+    if (onClickDeleteMenuItem == null) {
+      return;
+    }
+
     if (page._id == null || page.revision == null || page.path == null) {
       throw Error('Any of _id, revision, and path must not be null.');
     }
@@ -302,9 +308,7 @@ const Item: FC<ItemProps> = (props: ItemProps) => {
       meta: pageInfo,
     };
 
-    if (onClickDeleteMenuItem != null) {
-      onClickDeleteMenuItem(pageToDelete);
-    }
+    onClickDeleteMenuItem(pageToDelete);
   }, [onClickDeleteMenuItem, page]);
 
   const onPressEnterForCreateHandler = async(inputText: string) => {
@@ -403,7 +407,7 @@ const Item: FC<ItemProps> = (props: ItemProps) => {
               className={`grw-pagetree-button btn ${isOpen ? 'grw-pagetree-open' : ''}`}
               onClick={onClickLoadChildren}
             >
-              <div className="grw-triangle-icon d-flex justify-content-center">
+              <div className="d-flex justify-content-center">
                 <TriangleIcon />
               </div>
             </button>
@@ -440,7 +444,7 @@ const Item: FC<ItemProps> = (props: ItemProps) => {
           >
             {/* pass the color property to reactstrap dropdownToggle props. https://6-4-0--reactstrap.netlify.app/components/dropdowns/  */}
             <DropdownToggle color="transparent" className="border-0 rounded btn-page-item-control p-0 grw-visible-on-hover mr-1">
-              <i className="icon-options fa fa-rotate-90 text-muted p-1"></i>
+              <i className="icon-options fa fa-rotate-90 p-1"></i>
             </DropdownToggle>
           </PageItemControl>
           <button
@@ -448,7 +452,7 @@ const Item: FC<ItemProps> = (props: ItemProps) => {
             className="border-0 rounded btn btn-page-item-control p-0 grw-visible-on-hover"
             onClick={onClickPlusButton}
           >
-            <i className="icon-plus text-muted d-block p-0" />
+            <i className="icon-plus d-block p-0" />
           </button>
         </div>
       </li>

+ 3 - 1
packages/app/src/interfaces/page.ts

@@ -37,6 +37,7 @@ export type IPageHasId = IPage & HasObjectId;
 export type IPageForItem = Partial<IPageHasId & {isTarget?: boolean}>;
 
 export type IPageInfo = {
+  isV5Compatible: boolean,
   isEmpty: boolean,
   isMovable: boolean,
   isDeletable: boolean,
@@ -104,7 +105,8 @@ export type IDataWithMeta<D = unknown, M = unknown> = {
 
 export type IPageWithMeta<M = IPageInfoAll> = IDataWithMeta<IPageHasId, M>;
 
-export type IPageToDeleteWithMeta = IDataWithMeta<HasObjectId & (IPage | { path: string, revision: string }), IPageInfoForOperation | unknown>;
+export type IPageToDeleteWithMeta = IDataWithMeta<HasObjectId & (IPage | { path: string, revision: string }), IPageInfoForEntity | unknown>;
+export type IPageToRenameWithMeta = IPageToDeleteWithMeta;
 
 export type IDeleteSinglePageApiv1Result = {
   ok: boolean

+ 4 - 0
packages/app/src/interfaces/user-group-response.ts

@@ -21,3 +21,7 @@ export type UserGroupPagesResult = {
 export type SelectableUserGroupsResult = {
   selectableUserGroups: IUserGroupHasId[],
 }
+
+export type AncestorUserGroupsResult = {
+  ancestorUserGroups: IUserGroupHasId[],
+}

+ 0 - 10
packages/app/src/server/models/obsolete-page.js

@@ -366,16 +366,6 @@ export const getPageSchema = (crowi) => {
     return queryBuilder.query.exec();
   };
 
-  pageSchema.statics.findByIdAndViewerToEdit = async function(id, user, includeEmpty = false) {
-    const baseQuery = this.findOne({ _id: id });
-    const queryBuilder = new this.PageQueryBuilder(baseQuery, includeEmpty);
-
-    // add grant conditions
-    await addConditionToFilteringByViewerToEdit(queryBuilder, user);
-
-    return queryBuilder.query.exec();
-  };
-
   // find page by path
   pageSchema.statics.findByPath = function(path, includeEmpty = false) {
     if (path == null) {

+ 12 - 11
packages/app/src/server/models/page.ts

@@ -46,7 +46,7 @@ export interface PageModel extends Model<PageDocument> {
   [x: string]: any; // for obsolete methods
   createEmptyPagesByPaths(paths: string[], onlyMigratedAsExistingPages?: boolean, publicOnly?: boolean): Promise<void>
   getParentAndFillAncestors(path: string, user): Promise<PageDocument & { _id: any }>
-  findByIdsAndViewer(pageIds: string[], user, userGroups?, includeEmpty?: boolean): Promise<PageDocument[]>
+  findByIdsAndViewer(pageIds: ObjectIdLike[], user, userGroups?, includeEmpty?: boolean): Promise<PageDocument[]>
   findByPathAndViewer(path: string | null, user, userGroups?, useFindOne?: boolean, includeEmpty?: boolean): Promise<PageDocument[]>
   findTargetAndAncestorsByPathOrId(pathOrId: string): Promise<TargetAndAncestorsResult>
   findChildrenByParentPathOrIdAndViewer(parentPathOrId: string, user, userGroups?): Promise<PageDocument[]>
@@ -372,6 +372,17 @@ class PageQueryBuilder {
     return this;
   }
 
+  addConditionToExcludeByPageIdsArray(pageIds) {
+    this.query = this.query
+      .and({
+        _id: {
+          $nin: pageIds,
+        },
+      });
+
+    return this;
+  }
+
   populateDataToList(userPublicFields) {
     this.query = this.query
       .populate({
@@ -829,16 +840,6 @@ schema.statics.removeLeafEmptyPagesRecursively = async function(pageId: ObjectId
   await this.deleteMany({ _id: { $in: pageIdsToRemove } });
 };
 
-schema.statics.findByPageIdsToEdit = async function(ids, user, shouldIncludeEmpty = false) {
-  const builder = new PageQueryBuilder(this.find({ _id: { $in: ids } }), shouldIncludeEmpty);
-
-  await this.addConditionToFilteringByViewerToEdit(builder, user);
-
-  const pages = await builder.query.exec();
-
-  return pages;
-};
-
 schema.statics.normalizeDescendantCountById = async function(pageId) {
   const children = await this.find({ parent: pageId });
 

+ 1 - 1
packages/app/src/server/models/user-group.ts

@@ -101,7 +101,7 @@ schema.statics.findGroupsWithAncestorsRecursively = async function(group, ancest
     return ancestors;
   }
 
-  ancestors.push(parent);
+  ancestors.unshift(parent);
 
   return this.findGroupsWithAncestorsRecursively(parent, ancestors);
 };

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

@@ -176,7 +176,7 @@ module.exports = (crowi) => {
       body('newPagePath').isLength({ min: 1 }).withMessage('newPagePath is required'),
       body('isRecursively').if(value => value != null).isBoolean().withMessage('isRecursively must be boolean'),
       body('isRenameRedirect').if(value => value != null).isBoolean().withMessage('isRenameRedirect must be boolean'),
-      body('isRemainMetadata').if(value => value != null).isBoolean().withMessage('isRemainMetadata must be boolean'),
+      body('updateMetadata').if(value => value != null).isBoolean().withMessage('updateMetadata must be boolean'),
       body('isMoveMode').if(value => value != null).isBoolean().withMessage('isMoveMode must be boolean'),
     ],
     duplicatePage: [
@@ -445,9 +445,9 @@ module.exports = (crowi) => {
    *                  isRenameRedirect:
    *                    type: boolean
    *                    description: whether redirect page
-   *                  isRemainMetadata:
+   *                  updateMetadata:
    *                    type: boolean
-   *                    description: whether remain meta data
+   *                    description: whether update meta data
    *                  isRecursively:
    *                    type: boolean
    *                    description: whether rename page with descendants
@@ -476,7 +476,7 @@ module.exports = (crowi) => {
     const options = {
       isRecursively: req.body.isRecursively,
       createRedirectPage: req.body.isRenameRedirect,
-      updateMetadata: !req.body.isRemainMetadata,
+      updateMetadata: req.body.updateMetadata,
       isMoveMode: req.body.isMoveMode,
     };
 
@@ -497,7 +497,7 @@ module.exports = (crowi) => {
     let renamedPage;
 
     try {
-      page = await Page.findByIdAndViewerToEdit(pageId, req.user, true);
+      page = await Page.findByIdAndViewer(pageId, req.user, null, true);
 
       if (page == null) {
         return res.apiv3Err(new ErrorV3(`Page '${pageId}' is not found or forbidden`, 'notfound_or_forbidden'), 401);
@@ -653,7 +653,7 @@ module.exports = (crowi) => {
       return res.apiv3Err(new ErrorV3(`Page exists '${newPagePath})'`, 'already_exists'), 409);
     }
 
-    const page = await Page.findByIdAndViewerToEdit(pageId, req.user, true);
+    const page = await Page.findByIdAndViewer(pageId, req.user, null, true);
 
     const isEmptyAndNotRecursively = page?.isEmpty && !isRecursively;
     if (page == null || isEmptyAndNotRecursively) {
@@ -748,7 +748,7 @@ module.exports = (crowi) => {
 
     let pagesToDelete;
     try {
-      pagesToDelete = await Page.findByPageIdsToEdit(pageIds, req.user, true);
+      pagesToDelete = await Page.findByIdsAndViewer(pageIds, req.user, null, true);
     }
     catch (err) {
       logger.error('Failed to find pages to delete.', err);

+ 48 - 0
packages/app/src/server/routes/apiv3/user-group.js

@@ -61,6 +61,9 @@ module.exports = (crowi) => {
       query('parentIds', 'parentIds must be an array').optional().isArray(),
       query('includeGrandChildren', 'parentIds must be boolean').optional().isBoolean(),
     ],
+    ancestorGroup: [
+      query('groupId', 'groupId must be a string').optional().isString(),
+    ],
     selectableGroups: [
       query('groupId', 'groupId must be a string').optional().isString(),
     ],
@@ -127,6 +130,51 @@ module.exports = (crowi) => {
     }
   });
 
+  /**
+   * @swagger
+   *
+   *  paths:
+   *    /ancestors:
+   *      get:
+   *        tags: [UserGroup]
+   *        operationId: getAncestorUserGroups
+   *        summary: /ancestors
+   *        description: Get ancestor user groups.
+   *        parameters:
+   *          - name: groupId
+   *            in: query
+   *            required: true
+   *            description: id of userGroup
+   *            schema:
+   *              type: string
+   *        responses:
+   *          200:
+   *            description: userGroups are fetched
+   *            content:
+   *              application/json:
+   *                schema:
+   *                  properties:
+   *                    userGroups:
+   *                      type: array
+   *                      items:
+   *                        type: object
+   *                      description: userGroup objects
+   */
+  router.get('/ancestors', loginRequiredStrictly, adminRequired, validator.ancestorGroup, async(req, res) => {
+    const { groupId } = req.query;
+
+    try {
+      const userGroup = await UserGroup.findById(groupId);
+      const ancestorUserGroups = await UserGroup.findGroupsWithAncestorsRecursively(userGroup);
+      return res.apiv3({ ancestorUserGroups });
+    }
+    catch (err) {
+      const msg = 'Error occurred while searching user groups';
+      logger.error(msg, err);
+      return res.apiv3Err(new ErrorV3(msg, 'user-groups-search-failed'));
+    }
+  });
+
   // TODO 85062: improve sort
   router.get('/children', loginRequiredStrictly, adminRequired, validator.listChildren, async(req, res) => {
     try {

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

@@ -599,7 +599,7 @@ module.exports = function(crowi, app) {
     const { redirectFrom } = req.query;
 
     const builder = new PageQueryBuilder(Page.find({ path }));
-    await Page.addConditionToFilteringByViewerForList(builder, req.user);
+    await Page.addConditionToFilteringByViewerForList(builder, req.user, true);
 
     const pages = await builder.query.lean().clone().exec('find');
 
@@ -1177,7 +1177,7 @@ module.exports = function(crowi, app) {
 
     const options = {};
 
-    const page = await Page.findByIdAndViewerToEdit(pageId, req.user, true);
+    const page = await Page.findByIdAndViewer(pageId, req.user, null, true);
 
     if (page == null) {
       return res.json(ApiResponse.error(`Page '${pageId}' is not found or forbidden`, 'notfound_or_forbidden'));

+ 9 - 3
packages/app/src/server/service/page-grant.ts

@@ -1,5 +1,5 @@
 import mongoose from 'mongoose';
-import { pagePathUtils, pathUtils } from '@growi/core';
+import { pagePathUtils, pathUtils, pageUtils } from '@growi/core';
 import escapeStringRegexp from 'escape-string-regexp';
 
 import UserGroup from '~/server/models/user-group';
@@ -357,6 +357,8 @@ class PageGrantService {
       throw Error(`The maximum number of pageIds allowed is ${LIMIT_FOR_MULTIPLE_PAGE_OP}.`);
     }
 
+    const Page = mongoose.model('Page') as unknown as PageModel;
+
     const shouldCheckDescendants = true;
     const shouldIncludeNotMigratedPages = true;
 
@@ -368,8 +370,12 @@ class PageGrantService {
         path, grant, grantedUsers: grantedUserIds, grantedGroup: grantedGroupId,
       } = page;
 
-      const isNormalized = await this.isGrantNormalized(path, grant, grantedUserIds, grantedGroupId, shouldCheckDescendants, shouldIncludeNotMigratedPages);
-      if (isNormalized) {
+      if (!pageUtils.isPageNormalized(page)) {
+        nonNormalizable.push(page);
+        continue;
+      }
+
+      if (await this.isGrantNormalized(path, grant, grantedUserIds, grantedGroupId, shouldCheckDescendants, shouldIncludeNotMigratedPages)) {
         normalizable.push(page);
       }
       else {

+ 12 - 10
packages/app/src/server/service/page.ts

@@ -107,7 +107,6 @@ class PageCursorsForDescendantsFactory {
 
     const builder = new PageQueryBuilder(this.Page.find(), this.shouldIncludeEmpty);
     builder.addConditionToFilteringByParentId(page._id);
-    // await this.Page.addConditionToFilteringByViewerToEdit(builder, this.user);
 
     const cursor = builder.query.lean().cursor({ batchSize: BULK_REINDEX_SIZE }) as QueryCursor<any>;
 
@@ -250,6 +249,7 @@ class PageService {
       return {
         data: page,
         meta: {
+          isV5Compatible: isTopPage(page.path) || page.parent != null,
           isEmpty: page.isEmpty,
           isMovable: false,
           isDeletable: false,
@@ -2065,6 +2065,7 @@ class PageService {
 
     if (page.isEmpty) {
       return {
+        isV5Compatible: true,
         isEmpty: true,
         isMovable,
         isDeletable: false,
@@ -2077,6 +2078,7 @@ class PageService {
     const seenUsers = page.seenUsers.slice(0, 15) as Ref<IUserHasId>[];
 
     return {
+      isV5Compatible: isTopPage(page.path) || page.parent != null,
       isEmpty: false,
       sumOfLikers: page.liker.length,
       likerIds: this.extractStringIds(likers),
@@ -2189,7 +2191,7 @@ class PageService {
     const Page = mongoose.model('Page') as unknown as PageModel;
 
     if (isRecursively) {
-      const pages = await Page.findByPageIdsToEdit(pageIds, user, false);
+      const pages = await Page.findByIdsAndViewer(pageIds, user, null);
 
       // DO NOT await !!
       this.normalizeParentRecursivelyByPages(pages, user);
@@ -2552,15 +2554,13 @@ class PageService {
         },
       ]);
 
-    // limit pages to get
+    // Limit pages to get
     const total = await Page.countDocuments(filter);
     if (total > PAGES_LIMIT) {
       baseAggregation = baseAggregation.limit(Math.floor(total * 0.3));
     }
 
     const pagesStream = await baseAggregation.cursor({ batchSize: BATCH_SIZE });
-
-    // use batch stream
     const batchStream = createBatchStream(BATCH_SIZE);
 
     let countPages = 0;
@@ -2571,10 +2571,15 @@ class PageService {
       async write(pages, encoding, callback) {
         const parentPaths = Array.from(new Set<string>(pages.map(p => pathlib.dirname(p.path))));
 
-        // Fill parents with empty pages
+        // 1. Remove unnecessary empty pages
+        const pageIdsToNotDelete = pages.map(p => p._id);
+        const emptyPagePathsToDelete = pages.map(p => p.path);
+        await Page.removeEmptyPages(pageIdsToNotDelete, emptyPagePathsToDelete);
+
+        // 2. Create lacking parents as empty pages
         await Page.createEmptyPagesByPaths(parentPaths, user, false);
 
-        // Find parents
+        // 3. Find parents
         const builder = new PageQueryBuilder(Page.find({}, { _id: 1, path: 1 }), true);
         const parents = await builder
           .addConditionToListByPathsArray(parentPaths)
@@ -2629,9 +2634,6 @@ class PageService {
           throw err;
         }
 
-        // Remove unnecessary empty pages
-        await Page.removeEmptyPages(pages.map(p => p._id), pages.map(p => p.path));
-
         callback();
       },
       final(callback) {

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

@@ -11,6 +11,11 @@ import { TargetAndAncestors, IsNotFoundPermalink } from '../interfaces/page-list
 
 type Nullable<T> = T | null;
 
+
+export const useSiteUrl = (initialData?: string): SWRResponse<string, Error> => {
+  return useStaticSWR<string, Error>('siteUrl', initialData);
+};
+
 export const useCurrentUser = (initialData?: Nullable<IUser>): SWRResponse<Nullable<IUser>, Error> => {
   return useStaticSWR<Nullable<IUser>, Error>('currentUser', initialData);
 };

+ 10 - 18
packages/app/src/stores/modal.tsx

@@ -3,7 +3,7 @@ import { useStaticSWR } from './use-static-swr';
 import {
   OnDuplicatedFunction, OnRenamedFunction, OnDeletedFunction, OnPutBackedFunction,
 } from '~/interfaces/ui';
-import { IPageToDeleteWithMeta } from '~/interfaces/page';
+import { IPageToDeleteWithMeta, IPageToRenameWithMeta } from '~/interfaces/page';
 
 
 /*
@@ -94,7 +94,7 @@ type DuplicateModalStatusUtils = {
 }
 
 export const usePageDuplicateModal = (status?: DuplicateModalStatus): SWRResponse<DuplicateModalStatus, Error> & DuplicateModalStatusUtils => {
-  const initialData: DuplicateModalStatus = { isOpened: false, page: { pageId: '', path: '/' } };
+  const initialData: DuplicateModalStatus = { isOpened: false };
   const swrResponse = useStaticSWR<DuplicateModalStatus, Error>('duplicateModalStatus', status, { fallbackData: initialData });
 
   return {
@@ -103,53 +103,45 @@ export const usePageDuplicateModal = (status?: DuplicateModalStatus): SWRRespons
         page?: IPageForPageDuplicateModal,
         opts?: IDuplicateModalOption,
     ) => swrResponse.mutate({ isOpened: true, page, opts }),
-    close: () => swrResponse.mutate({ isOpened: false, page: { pageId: '', path: '/' } }),
+    close: () => swrResponse.mutate({ isOpened: false }),
   };
 };
 
 
 /*
-* PageRenameModal
-*/
-export type IPageForPageRenameModal = {
-  pageId: string,
-  revisionId: string,
-  path: string
-}
-
+ * PageRenameModal
+ */
 export type IRenameModalOption = {
   onRenamed?: OnRenamedFunction,
 }
 
 type RenameModalStatus = {
   isOpened: boolean,
-  page?: IPageForPageRenameModal,
+  page?: IPageToRenameWithMeta,
   opts?: IRenameModalOption
 }
 
 type RenameModalStatusUtils = {
   open(
-    page?: IPageForPageRenameModal,
+    page?: IPageToRenameWithMeta,
     opts?: IRenameModalOption
     ): Promise<RenameModalStatus | undefined>
   close(): Promise<RenameModalStatus | undefined>
 }
 
 export const usePageRenameModal = (status?: RenameModalStatus): SWRResponse<RenameModalStatus, Error> & RenameModalStatusUtils => {
-  const initialData: RenameModalStatus = {
-    isOpened: false, page: { pageId: '', revisionId: '', path: '' },
-  };
+  const initialData: RenameModalStatus = { isOpened: false };
   const swrResponse = useStaticSWR<RenameModalStatus, Error>('renameModalStatus', status, { fallbackData: initialData });
 
   return {
     ...swrResponse,
     open: (
-        page?: IPageForPageRenameModal,
+        page?: IPageToRenameWithMeta,
         opts?: IRenameModalOption,
     ) => swrResponse.mutate({
       isOpened: true, page, opts,
     }),
-    close: () => swrResponse.mutate({ isOpened: false, page: { pageId: '', revisionId: '', path: '' } }),
+    close: () => swrResponse.mutate({ isOpened: false }),
   };
 };
 

+ 8 - 1
packages/app/src/stores/user-group.tsx

@@ -6,7 +6,7 @@ import { apiv3Get } from '~/client/util/apiv3-client';
 import { IPageHasId } from '~/interfaces/page';
 import { IUserGroupHasId, IUserGroupRelationHasId } from '~/interfaces/user';
 import {
-  UserGroupListResult, ChildUserGroupListResult, UserGroupRelationListResult, UserGroupPagesResult, SelectableUserGroupsResult,
+  UserGroupListResult, ChildUserGroupListResult, UserGroupRelationListResult, UserGroupPagesResult, SelectableUserGroupsResult, AncestorUserGroupsResult,
 } from '~/interfaces/user-group-response';
 
 
@@ -66,3 +66,10 @@ export const useSWRxSelectableUserGroups = (groupId: string | undefined): SWRRes
     endpoint => apiv3Get<SelectableUserGroupsResult>(endpoint, { groupId }).then(result => result.data.selectableUserGroups),
   );
 };
+
+export const useSWRxAncestorUserGroups = (groupId: string | undefined): SWRResponse<IUserGroupHasId[], Error> => {
+  return useSWRImmutable(
+    groupId != null ? ['/user-groups/ancestors'] : null,
+    endpoint => apiv3Get<AncestorUserGroupsResult>(endpoint, { groupId }).then(result => result.data.ancestorUserGroups),
+  );
+};

+ 1 - 1
packages/app/src/styles/_layout.scss

@@ -1,6 +1,6 @@
 body {
   overflow-y: scroll !important;
-  overscroll-behavior: none;
+  overscroll-behavior-y: none;
 }
 
 body:not(.growi-layout-fluid) .grw-container-convertible {

+ 3 - 1
packages/app/src/styles/_page-tree.scss

@@ -50,7 +50,9 @@ $grw-pagetree-item-padding-left: 10px;
       }
 
       .grw-pagetree-count {
-        padding: 0.1rem 0.3rem;
+        width: 26px;
+        padding: 0.1rem 0;
+        font-size: 12px;
       }
     }
   }

+ 6 - 0
packages/app/src/styles/_page_list.scss

@@ -4,6 +4,12 @@ body .page-list {
     line-height: 1.6em;
   }
 
+  .btn-page-item-control {
+    width: 20px;
+    height: 20px;
+    padding: 0px;
+  }
+
   .page-list-ul {
     padding-left: 0;
     margin: 0;

+ 1 - 1
packages/app/src/styles/_search.scss

@@ -144,7 +144,7 @@
 
   // To fix the sort options position
   .search-sort-option-btn {
-    min-width: 12rem;
+    min-width: 150px;
   }
   .search-control-include-options {
     .card-body {

+ 0 - 1
packages/app/src/styles/_subnav.scss

@@ -131,7 +131,6 @@
     .btn-page-item-control {
       width: 32px;
       height: 32px;
-      padding: 4px;
       font-size: 12px;
     }
   }

+ 0 - 5
packages/app/src/styles/molecules/compare-paths-table.scss

@@ -1,5 +0,0 @@
-.grw-compare-paths-table {
-  tbody {
-    max-height: 200px;
-  }
-}

+ 0 - 1
packages/app/src/styles/style-app.scss

@@ -29,7 +29,6 @@
 @import 'molecules/page-editor-mode-manager';
 @import 'molecules/slack-notification';
 @import 'molecules/duplicated-paths-table.scss';
-@import 'molecules/compare-paths-table.scss';
 
 // growi component
 @import 'admin';

+ 11 - 1
packages/app/src/styles/theme/_apply-colors-dark.scss

@@ -38,8 +38,9 @@ $border-color: $border-color-global;
   */
 input.form-control,
 select.form-control,
+select.custom-select,
 textarea.form-control {
-  color: lighten($color-global, 30%);
+  color: $color-global;
   background-color: darken($bgcolor-global, 5%);
   border-color: $border-color-global;
   &:focus {
@@ -69,6 +70,10 @@ textarea.form-control {
   border-color: $border-color-global;
 }
 
+label.custom-control-label::before {
+  background-color: darken($bgcolor-global, 5%);
+}
+
 /*
  * Dropdown
  */
@@ -280,6 +285,11 @@ ul.pagination {
       $gray-500
     );
   }
+  .private-legacy-pages-link {
+    &:hover {
+      background: $bgcolor-list-hover;
+    }
+  }
 }
 
 /*

+ 5 - 0
packages/app/src/styles/theme/_apply-colors-light.scss

@@ -180,6 +180,11 @@ $border-color: $border-color-global;
       $gray-400
     );
   }
+  .private-legacy-pages-link {
+    &:hover {
+      background: $bgcolor-list-hover;
+    }
+  }
 }
 
 /*

+ 1 - 0
packages/app/src/styles/theme/_apply-colors.scss

@@ -712,6 +712,7 @@ mark.rbt-highlight-text {
 
 // Page Management Dropdown icon
 .btn-page-item-control {
+  color: $gray-500;
   &:hover,
   &:focus {
     background-color: rgba($color-link, 0.15);

+ 31 - 0
packages/app/test/cypress/integration/2-basic-features/switch-sidebar-contents.spec.ts

@@ -0,0 +1,31 @@
+context('Access to page', () => {
+  const ssPrefix = 'switch-sidebar-content';
+
+  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('PageTree is successfully shown', () => {
+    cy.visit('/');
+    cy.getByTestid('grw-sidebar-nav-primary-page-tree').click();
+    cy.screenshot(`${ssPrefix}-pagetree-before-load`, { capture: 'viewport' });
+    // eslint-disable-next-line cypress/no-unnecessary-waiting
+    cy.wait(1500);
+    cy.screenshot(`${ssPrefix}-pagetree-after-load`, { capture: 'viewport' });
+  });
+
+});

+ 33 - 0
packages/app/test/cypress/integration/5-switch-sidebar-mode/switching-sidebar-mode.spec.ts

@@ -0,0 +1,33 @@
+context('Switch sidebar mode', () => {
+  const ssPrefix = 'switch-sidebar-mode-';
+
+  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('Switching sidebar mode', () => {
+    cy.visit('/');
+    cy.get('.grw-personal-dropdown').click();
+
+    cy.get('[for="swSidebarModeOnEditor"]').click();
+    cy.screenshot(`${ssPrefix}-switch-sidebar-mode`, { capture: 'viewport' });
+
+    cy.get('[for="swSidebarModeOnEditor"]').click();
+    cy.screenshot(`${ssPrefix}-switch-sidebar-mode-back`, { capture: 'viewport' });
+  });
+
+});

+ 32 - 0
packages/app/test/cypress/integration/6-home-settings/access-home.spec.ts

@@ -0,0 +1,32 @@
+/* eslint-disable cypress/no-unnecessary-waiting */
+context('Access Home', () => {
+  const ssPrefix = 'access-home-';
+
+  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('Visit home', () => {
+    cy.visit('/');
+    cy.get('.grw-personal-dropdown').click();
+    cy.get('.grw-personal-dropdown .dropdown-menu .btn-group > .btn-outline-secondary:eq(0)').click();
+
+    cy.wait(1500);
+    cy.screenshot(`${ssPrefix}-visit-home`, { capture: 'viewport' });
+  });
+
+});

+ 85 - 0
packages/app/test/cypress/integration/6-home-settings/access-user-settings.spec.ts

@@ -0,0 +1,85 @@
+/* eslint-disable cypress/no-unnecessary-waiting */
+context('Access User settings', () => {
+  const ssPrefix = 'access-user-settings-';
+
+  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;
+    });
+
+    cy.visit('/');
+    cy.get('.grw-personal-dropdown').click();
+    cy.get('[href="/me"]').click();
+
+    cy.wait(1500);
+  });
+
+  beforeEach(() => {
+    if (connectSid != null) {
+      cy.setCookie('connect.sid', connectSid);
+    }
+  });
+
+  it('Update settings', () => {
+    // Access User information
+    cy.get('#personal-setting .tab-pane.active > div:first button').click(); // Click basic info update button
+
+    cy.wait(500);
+
+    cy.screenshot(`${ssPrefix}-user-information`, { capture: 'viewport' });
+    cy.get('.toast-close-button').click({ multiple: true }); // close toast alert
+
+    // Access External account
+    cy.get('#personal-setting .nav-title.nav li:eq(1) a').click(); // click
+    cy.get('#personal-setting .tab-pane.active h2 button').click(); // click add button
+    cy.get('.modal-footer button').click(); // click add button in modal form
+    cy.get('.close[aria-label="Close"]').click(); // close modal form
+
+    cy.wait(500);
+
+    cy.screenshot(`${ssPrefix}-external-account`, { capture: 'viewport' });
+    cy.get('.toast-close-button').click({ multiple: true }); // close toast alert
+
+    // Access Password setting
+    cy.get('#personal-setting .nav-title.nav li:eq(2) a').click();
+    cy.get('#personal-setting .tab-pane.active button').click(); // click update button
+
+    cy.wait(500);
+
+    cy.screenshot(`${ssPrefix}-password-setting`, { capture: 'viewport' });
+    cy.get('.toast-close-button').click({ multiple: true }); // close toast alert
+
+    // Access API setting
+    cy.get('#personal-setting .nav-title.nav li:eq(2) a').click();
+    cy.get('#personal-setting .tab-pane.active button').click(); // click update API token button
+
+    cy.wait(500);
+
+    cy.screenshot(`${ssPrefix}-api-setting`, { capture: 'viewport' });
+    cy.get('.toast-close-button').click({ multiple: true }); // close toast alert
+
+    // Access Editor setting
+    cy.get('#personal-setting .nav-title.nav li:eq(3) a').click();
+    cy.get('#personal-setting .tab-pane.active button').click(); // click update button
+
+    cy.wait(500);
+
+    cy.screenshot(`${ssPrefix}-editor-setting`, { capture: 'viewport' });
+    cy.get('.toast-close-button').click({ multiple: true }); // close toast alert
+
+    // Access In-app notification setting
+    cy.get('#personal-setting .nav-title.nav li:eq(4) a').click();
+    cy.get('#personal-setting .tab-pane.active button').click(); // click update button
+
+    cy.wait(500);
+
+    cy.screenshot(`${ssPrefix}-in-app-notification-setting`, { capture: 'viewport' });
+  });
+
+});

+ 2 - 0
packages/core/src/index.js

@@ -1,6 +1,7 @@
 import * as _pathUtils from './utils/path-utils';
 import * as _envUtils from './utils/env-utils';
 import * as _pagePathUtils from './utils/page-path-utils';
+import * as _pageUtils from './utils/page-utils';
 import * as _templateChecker from './utils/template-checker';
 import * as _customTagUtils from './plugin/util/custom-tag-utils';
 
@@ -8,6 +9,7 @@ import * as _customTagUtils from './plugin/util/custom-tag-utils';
 export const pathUtils = _pathUtils;
 export const envUtils = _envUtils;
 export const pagePathUtils = _pagePathUtils;
+export const pageUtils = _pageUtils;
 export const templateChecker = _templateChecker;
 export const customTagUtils = _customTagUtils;
 

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

@@ -124,7 +124,7 @@ export const userPageRoot = (user: any): string => {
  * @param newPath
  */
 export const convertToNewAffiliationPath = (oldPath: string, newPath: string, childPath: string): string => {
-  if (newPath === null) {
+  if (newPath == null) {
     throw new Error('Please input the new page path');
   }
   const pathRegExp = new RegExp(`^${escapeStringRegexp(oldPath)}`, 'i');

+ 58 - 0
packages/core/src/utils/page-utils.ts

@@ -0,0 +1,58 @@
+import { isTopPage } from './page-path-utils';
+
+const GRANT_PUBLIC = 1;
+const GRANT_RESTRICTED = 2;
+const GRANT_SPECIFIED = 3; // DEPRECATED
+const GRANT_OWNER = 4;
+const GRANT_USER_GROUP = 5;
+const PAGE_GRANT_ERROR = 1;
+const STATUS_PUBLISHED = 'published';
+const STATUS_DELETED = 'deleted';
+
+/**
+ * Returns true if the page is on tree including the top page.
+ * @param page Page
+ * @returns boolean
+ */
+// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
+export const isOnTree = (page): boolean => {
+  const { path, parent } = page;
+
+  if (isTopPage(path)) {
+    return true;
+  }
+
+  if (parent != null) {
+    return true;
+  }
+
+  return false;
+};
+
+/**
+ * Returns true if the page meet the condition below.
+ *   - The page is on tree (has parent or the top page)
+ *   - The page's grant is GRANT_RESTRICTED or GRANT_SPECIFIED
+ *   - The page's status is STATUS_DELETED
+ * This does not check grantedUser or grantedGroup.
+ * @param page PageDocument
+ * @returns boolean
+ */
+// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
+export const isPageNormalized = (page): boolean => {
+  const { grant, status } = page;
+
+  if (grant === GRANT_RESTRICTED || grant === GRANT_SPECIFIED) {
+    return true;
+  }
+
+  if (status === STATUS_DELETED) {
+    return true;
+  }
+
+  if (isOnTree(page)) {
+    return true;
+  }
+
+  return true;
+};

+ 2 - 1
packages/plugin-lsx/src/client/js/components/LsxPageList/LsxPage.jsx

@@ -92,7 +92,8 @@ export class LsxPage extends React.Component {
 
     return (
       <li className="page-list-li">
-        <small>{this.getIconElement()}</small> {pagePathNode} {pageListMeta}
+        <small>{this.getIconElement()}</small> {pagePathNode}
+        <span className="ml-2">{pageListMeta}</span>
         {this.getChildPageElement()}
       </li>
     );

+ 1 - 1
packages/ui/src/components/PagePath/PageListMeta.jsx

@@ -54,7 +54,7 @@ export class PageListMeta extends React.Component {
     }
 
     return (
-      <span className="page-list-meta ml-2">
+      <span className="page-list-meta">
         {topLabel}
         {templateLabel}
         {seenUserCount}

+ 4 - 3
yarn.lock

@@ -20105,9 +20105,10 @@ throat@^6.0.1:
   resolved "https://registry.yarnpkg.com/throat/-/throat-6.0.1.tgz#d514fedad95740c12c2d7fc70ea863eb51ade375"
   integrity sha512-8hmiGIJMDlwjg7dlJ4yKGLK8EsYqKgPWbG3b4wjJddKNwc7N7Dpn08Df4szr/sZdMVeOstrdYSsqzX6BYbcB+w==
 
-throttle-debounce@^2.0.0:
-  version "2.0.0"
-  resolved "https://registry.yarnpkg.com/throttle-debounce/-/throttle-debounce-2.0.0.tgz#2d8d24bd8cf3cb0cc7bd1a2dbeb624b4081a1ed4"
+throttle-debounce@^3.0.1:
+  version "3.0.1"
+  resolved "https://registry.yarnpkg.com/throttle-debounce/-/throttle-debounce-3.0.1.tgz#32f94d84dfa894f786c9a1f290e7a645b6a19abb"
+  integrity sha512-dTEWWNu6JmeVXY0ZYoPuH5cRIwc0MeGbJwah9KUNYSJwommQpCzTySTpEe8Gs1J23aeWEuAobe4Ag7EHVt/LOg==
 
 throttleit@^1.0.0:
   version "1.0.0"