Ver Fonte

Merge branch 'fix/operate-anyone-with-link' of https://github.com/weseek/growi into fix/operate-anyone-with-link

Taichi Masuyama há 4 anos atrás
pai
commit
a2bbbd6186
43 ficheiros alterados com 549 adições e 527 exclusões
  1. 1 1
      packages/app/package.json
  2. 4 3
      packages/app/resource/locales/en_US/translation.json
  3. 5 4
      packages/app/resource/locales/ja_JP/translation.json
  4. 11 10
      packages/app/resource/locales/zh_CN/translation.json
  5. 2 0
      packages/app/src/client/services/ContextExtractor.tsx
  6. 9 5
      packages/app/src/components/Common/Dropdown/PageItemControl.tsx
  7. 0 54
      packages/app/src/components/ComparePathsTable.jsx
  8. 8 16
      packages/app/src/components/DuplicatedPathsTable.jsx
  9. 3 3
      packages/app/src/components/Navbar/GrowiContextualSubNavigation.tsx
  10. 14 5
      packages/app/src/components/Navbar/SubNavButtons.tsx
  11. 6 5
      packages/app/src/components/Page.jsx
  12. 0 2
      packages/app/src/components/Page/RevisionLoader.jsx
  13. 5 11
      packages/app/src/components/Page/RevisionRenderer.jsx
  14. 5 5
      packages/app/src/components/PageDeleteModal.tsx
  15. 96 70
      packages/app/src/components/PageDuplicateModal.tsx
  16. 8 12
      packages/app/src/components/PageList/PageListItemL.tsx
  17. 3 1
      packages/app/src/components/PageList/PageListItemS.jsx
  18. 0 265
      packages/app/src/components/PageRenameModal.jsx
  19. 305 0
      packages/app/src/components/PageRenameModal.tsx
  20. 0 1
      packages/app/src/components/PageTimeline.jsx
  21. 1 3
      packages/app/src/components/SearchPage/OperateAllControl.tsx
  22. 1 1
      packages/app/src/components/SearchPage/SearchControl.tsx
  23. 2 3
      packages/app/src/components/SearchPage/SearchResultContent.tsx
  24. 2 2
      packages/app/src/components/SearchPage/SortControl.tsx
  25. 1 1
      packages/app/src/components/SearchTypeahead.tsx
  26. 0 1
      packages/app/src/components/Sidebar/CustomSidebar.tsx
  27. 3 1
      packages/app/src/components/Sidebar/PageTree.tsx
  28. 9 5
      packages/app/src/components/Sidebar/PageTree/Item.tsx
  29. 3 1
      packages/app/src/interfaces/page.ts
  30. 4 4
      packages/app/src/server/routes/apiv3/pages.js
  31. 3 0
      packages/app/src/server/service/page.ts
  32. 5 0
      packages/app/src/stores/context.tsx
  33. 10 18
      packages/app/src/stores/modal.tsx
  34. 1 1
      packages/app/src/styles/_layout.scss
  35. 1 1
      packages/app/src/styles/_search.scss
  36. 0 5
      packages/app/src/styles/molecules/compare-paths-table.scss
  37. 0 1
      packages/app/src/styles/style-app.scss
  38. 5 0
      packages/app/src/styles/theme/_apply-colors-dark.scss
  39. 5 0
      packages/app/src/styles/theme/_apply-colors-light.scss
  40. 1 1
      packages/core/src/utils/page-path-utils.ts
  41. 2 1
      packages/plugin-lsx/src/client/js/components/LsxPageList/LsxPage.jsx
  42. 1 1
      packages/ui/src/components/PagePath/PageListMeta.jsx
  43. 4 3
      yarn.lock

+ 1 - 1
packages/app/package.json

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

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

@@ -165,7 +165,7 @@
   "Page Tree": "Page Tree",
   "Page Tree": "Page Tree",
   "original_path":"Original path",
   "original_path":"Original path",
   "new_path":"New path",
   "new_path":"New path",
-  "duplicated_path":"duplicated_path",
+  "duplicated_path":"Duplicated path",
   "Link sharing is disabled": "Link sharing is disabled",
   "Link sharing is disabled": "Link sharing is disabled",
   "successfully_saved_the_page": "Successfully saved the page",
   "successfully_saved_the_page": "Successfully saved the page",
   "you_can_not_create_page_with_this_name": "You can not create page with this name",
   "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",
       "New page name": "New page name",
       "Failed to get subordinated pages": "Failed to get subordinated pages",
       "Failed to get subordinated pages": "Failed to get subordinated pages",
       "Failed to get exist path": "Failed to get exist path",
       "Failed to get exist path": "Failed to get exist path",
-      "Rename without exist path": "Rename without exist path",
       "Current page name": "Current page name",
       "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",
       "Do not update metadata": "Do not update metadata",
       "Redirect": "Redirect"
       "Redirect": "Redirect"
     },
     },

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

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

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

@@ -173,7 +173,7 @@
   "Page Tree": "页面树",
   "Page Tree": "页面树",
   "original_path":"Original path",
   "original_path":"Original path",
   "new_path":"New 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": "您无法使用此名称创建页面",
@@ -384,20 +384,21 @@
   },
   },
 	"modal_rename": {
 	"modal_rename": {
 		"label": {
 		"label": {
-			"Move/Rename page": "页面 移动/重命名",
+      "Move/Rename page": "页面 移动/重命名",
       "New page name": "新建页面名称",
       "New page name": "新建页面名称",
       "Failed to get subordinated pages": "Failed to get subordinated pages",
       "Failed to get subordinated pages": "Failed to get subordinated pages",
       "Failed to get exist path": "Failed to get exist path",
       "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": {
 		"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",
 	"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 { pagePathUtils } from '@growi/core';
 
 
 import {
 import {
+  useSiteUrl,
   useCurrentCreatedAt, useDeleteUsername, useDeletedAt, useHasChildren, useHasDraftOnHackmd,
   useCurrentCreatedAt, useDeleteUsername, useDeletedAt, useHasChildren, useHasDraftOnHackmd,
   useIsDeleted, useIsNotCreatable, useIsTrashPage, useIsUserPage, useLastUpdateUsername,
   useIsDeleted, useIsNotCreatable, useIsTrashPage, useIsUserPage, useLastUpdateUsername,
   useCurrentPageId, usePageIdOnHackmd, usePageUser, useCurrentPagePath, useRevisionCreatedAt, useRevisionId, useRevisionIdHackmdSynced,
   useCurrentPageId, usePageIdOnHackmd, usePageUser, useCurrentPagePath, useRevisionCreatedAt, useRevisionId, useRevisionIdHackmdSynced,
@@ -101,6 +102,7 @@ const ContextExtractorOnce: FC = () => {
   useCurrentProductNavWidth(userUISettings?.currentProductNavWidth);
   useCurrentProductNavWidth(userUISettings?.currentProductNavWidth);
 
 
   // hydrated config
   // hydrated config
+  useSiteUrl(configByContextHydrate.crowi.url);
   useIsAclEnabled(configByContextHydrate.isAclEnabled);
   useIsAclEnabled(configByContextHydrate.isAclEnabled);
   useIsSearchServiceConfigured(configByContextHydrate.isSearchServiceConfigured);
   useIsSearchServiceConfigured(configByContextHydrate.isSearchServiceConfigured);
   useIsSearchServiceReachable(configByContextHydrate.isSearchServiceReachable);
   useIsSearchServiceReachable(configByContextHydrate.isSearchServiceReachable);

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

@@ -35,7 +35,7 @@ type CommonProps = {
 
 
   onClickBookmarkMenuItem?: (pageId: string, newValue?: boolean) => Promise<void>,
   onClickBookmarkMenuItem?: (pageId: string, newValue?: boolean) => Promise<void>,
   onClickDuplicateMenuItem?: (pageId: string) => Promise<void> | void,
   onClickDuplicateMenuItem?: (pageId: string) => Promise<void> | void,
-  onClickRenameMenuItem?: (pageId: string) => Promise<void> | void,
+  onClickRenameMenuItem?: (pageId: string, pageInfo: IPageInfoAll | undefined) => Promise<void> | void,
   onClickDeleteMenuItem?: (pageId: string, pageInfo: IPageInfoAll | undefined) => Promise<void> | void,
   onClickDeleteMenuItem?: (pageId: string, pageInfo: IPageInfoAll | undefined) => Promise<void> | void,
   onClickRevertMenuItem?: (pageId: string) => Promise<void> | void,
   onClickRevertMenuItem?: (pageId: string) => Promise<void> | void,
 
 
@@ -80,8 +80,12 @@ const PageItemControlDropdownMenu = React.memo((props: DropdownMenuProps): JSX.E
     if (onClickRenameMenuItem == null) {
     if (onClickRenameMenuItem == null) {
       return;
       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() => {
   const revertItemClickedHandler = useCallback(async() => {
     if (onClickRevertMenuItem == null) {
     if (onClickRevertMenuItem == null) {
@@ -239,8 +243,8 @@ export const PageItemControlSubstance = (props: PageItemControlSubstanceProps):
     if (onClickRenameMenuItem == null) {
     if (onClickRenameMenuItem == null) {
       return;
       return;
     }
     }
-    await onClickRenameMenuItem(pageId);
-  }, [onClickRenameMenuItem, pageId]);
+    await onClickRenameMenuItem(pageId, fetchedPageInfo ?? presetPageInfo);
+  }, [onClickRenameMenuItem, pageId, fetchedPageInfo, presetPageInfo]);
 
 
   const deleteMenuItemClickHandler = useCallback(async() => {
   const deleteMenuItemClickHandler = useCallback(async() => {
     if (onClickDeleteMenuItem == null) {
     if (onClickDeleteMenuItem == null) {

+ 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 React from 'react';
 import PropTypes from 'prop-types';
 import PropTypes from 'prop-types';
 
 
-import { withTranslation } from 'react-i18next';
+import { useTranslation } from 'react-i18next';
 import { pagePathUtils } from '@growi/core';
 import { pagePathUtils } from '@growi/core';
-import { withUnstatedContainers } from './UnstatedUtils';
-
-import PageContainer from '~/client/services/PageContainer';
 
 
 const { convertToNewAffiliationPath } = pagePathUtils;
 const { convertToNewAffiliationPath } = pagePathUtils;
 
 
 function DuplicatedPathsTable(props) {
 function DuplicatedPathsTable(props) {
+  const { t } = useTranslation();
+
   const {
   const {
-    pageContainer, oldPagePath, existingPaths, t,
+    fromPath, toPath, existingPaths,
   } = props;
   } = props;
-  const { path } = pageContainer.state;
 
 
   return (
   return (
     <table className="table table-bordered grw-duplicated-paths-table">
     <table className="table table-bordered grw-duplicated-paths-table">
@@ -25,7 +23,7 @@ function DuplicatedPathsTable(props) {
       </thead>
       </thead>
       <tbody className="overflow-auto d-block">
       <tbody className="overflow-auto d-block">
         {existingPaths.map((existPath) => {
         {existingPaths.map((existPath) => {
-          const convertedPath = convertToNewAffiliationPath(oldPagePath, path, existPath);
+          const convertedPath = convertToNewAffiliationPath(toPath, fromPath, existPath);
           return (
           return (
             <tr key={existPath} className="d-flex">
             <tr key={existPath} className="d-flex">
               <td className="text-break w-50">
               <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 = {
 DuplicatedPathsTable.propTypes = {
-  t: PropTypes.func.isRequired, //  i18next
-  pageContainer: PropTypes.instanceOf(PageContainer).isRequired,
   existingPaths: PropTypes.array.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 { DropdownItem } from 'reactstrap';
 
 
 import { OnDuplicatedFunction, OnRenamedFunction, OnDeletedFunction } from '~/interfaces/ui';
 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 { withUnstatedContainers } from '../UnstatedUtils';
 import EditorContainer from '~/client/services/EditorContainer';
 import EditorContainer from '~/client/services/EditorContainer';
@@ -16,7 +16,7 @@ import {
 } from '~/stores/ui';
 } from '~/stores/ui';
 import {
 import {
   usePageAccessoriesModal, PageAccessoriesModalContents, IPageForPageDuplicateModal,
   usePageAccessoriesModal, PageAccessoriesModalContents, IPageForPageDuplicateModal,
-  usePageDuplicateModal, usePageRenameModal, IPageForPageRenameModal, usePageDeleteModal, usePagePresentationModal,
+  usePageDuplicateModal, usePageRenameModal, usePageDeleteModal, usePagePresentationModal,
 } from '~/stores/modal';
 } from '~/stores/modal';
 
 
 
 
@@ -190,7 +190,7 @@ const GrowiContextualSubNavigation = (props) => {
     openDuplicateModal(page, { onDuplicated: duplicatedHandler });
     openDuplicateModal(page, { onDuplicated: duplicatedHandler });
   }, [openDuplicateModal]);
   }, [openDuplicateModal]);
 
 
-  const renameItemClickedHandler = useCallback(async(page: IPageForPageRenameModal) => {
+  const renameItemClickedHandler = useCallback(async(page: IPageToRenameWithMeta) => {
     const renamedHandler: OnRenamedFunction = () => {
     const renamedHandler: OnRenamedFunction = () => {
       window.location.reload();
       window.location.reload();
     };
     };

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

@@ -1,14 +1,14 @@
 import React, { useCallback } from 'react';
 import React, { useCallback } from 'react';
 
 
 import {
 import {
-  IPageInfoAll, IPageToDeleteWithMeta, isIPageInfoForEntity, isIPageInfoForOperation,
+  IPageInfoAll, IPageToDeleteWithMeta, IPageToRenameWithMeta, isIPageInfoForEntity, isIPageInfoForOperation,
 } from '~/interfaces/page';
 } from '~/interfaces/page';
 
 
 import { useSWRxPageInfo } from '../../stores/page';
 import { useSWRxPageInfo } from '../../stores/page';
 import { useSWRBookmarkInfo } from '../../stores/bookmark';
 import { useSWRBookmarkInfo } from '../../stores/bookmark';
 import { useSWRxUsersList } from '../../stores/user';
 import { useSWRxUsersList } from '../../stores/user';
 import { useIsGuestUser } from '~/stores/context';
 import { useIsGuestUser } from '~/stores/context';
-import { IPageForPageRenameModal, IPageForPageDuplicateModal } from '~/stores/modal';
+import { IPageForPageDuplicateModal } from '~/stores/modal';
 
 
 import SubscribeButton from '../SubscribeButton';
 import SubscribeButton from '../SubscribeButton';
 import LikeButtons from '../LikeButtons';
 import LikeButtons from '../LikeButtons';
@@ -27,7 +27,7 @@ type CommonProps = {
   forceHideMenuItems?: ForceHideMenuItems,
   forceHideMenuItems?: ForceHideMenuItems,
   additionalMenuItemRenderer?: React.FunctionComponent<AdditionalMenuItemsRendererProps>,
   additionalMenuItemRenderer?: React.FunctionComponent<AdditionalMenuItemsRendererProps>,
   onClickDuplicateMenuItem?: (pageToDuplicate: IPageForPageDuplicateModal) => void,
   onClickDuplicateMenuItem?: (pageToDuplicate: IPageForPageDuplicateModal) => void,
-  onClickRenameMenuItem?: (pageToRename: IPageForPageRenameModal) => void,
+  onClickRenameMenuItem?: (pageToRename: IPageToRenameWithMeta) => void,
   onClickDeleteMenuItem?: (pageToDelete: IPageToDeleteWithMeta) => void,
   onClickDeleteMenuItem?: (pageToDelete: IPageToDeleteWithMeta) => void,
 }
 }
 
 
@@ -111,9 +111,18 @@ const SubNavButtonsSubstance = (props: SubNavButtonsSubstanceProps): JSX.Element
     if (onClickRenameMenuItem == null || path == null) {
     if (onClickRenameMenuItem == null || path == null) {
       return;
       return;
     }
     }
-    const page: IPageForPageRenameModal = { pageId, revisionId, path };
+
+    const page: IPageToRenameWithMeta = {
+      data: {
+        _id: pageId,
+        revision: revisionId,
+        path,
+      },
+      meta: pageInfo,
+    };
+
     onClickRenameMenuItem(page);
     onClickRenameMenuItem(page);
-  }, [onClickRenameMenuItem, pageId, path, revisionId]);
+  }, [onClickRenameMenuItem, pageId, pageInfo, path, revisionId]);
 
 
   const deleteMenuItemClickHandler = useCallback(async(_pageId: string): Promise<void> => {
   const deleteMenuItemClickHandler = useCallback(async(_pageId: string): Promise<void> => {
     if (onClickDeleteMenuItem == null || path == null) {
     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
 // TODO: remove this when omitting unstated is completed
 import {
 import {
-  EditorMode, useEditorMode, useSelectedGrant, useSelectedGrantGroupId, useSelectedGrantGroupName,
+  useEditorMode, useSelectedGrant, useSelectedGrantGroupId, useSelectedGrantGroupName,
 } from '~/stores/ui';
 } from '~/stores/ui';
 import { useIsSlackEnabled } from '~/stores/editor';
 import { useIsSlackEnabled } from '~/stores/editor';
 import { useSlackChannels } from '~/stores/context';
 import { useSlackChannels } from '~/stores/context';
@@ -143,15 +143,17 @@ class Page extends React.Component {
   }
   }
 
 
   render() {
   render() {
-    const { appContainer, pageContainer, editorMode } = this.props;
+    const { appContainer, pageContainer } = this.props;
     const { isMobile } = appContainer;
     const { isMobile } = appContainer;
     const isLoggedIn = appContainer.currentUser != null;
     const isLoggedIn = appContainer.currentUser != null;
     const { markdown, revisionId } = pageContainer.state;
     const { markdown, revisionId } = pageContainer.state;
-    const isRenderable = !(editorMode === EditorMode.View && revisionId == null);
 
 
     return (
     return (
       <div className={`mb-5 ${isMobile ? 'page-mobile' : ''}`}>
       <div className={`mb-5 ${isMobile ? 'page-mobile' : ''}`}>
-        <RevisionRenderer growiRenderer={this.growiRenderer} markdown={markdown} isRenderable={isRenderable} />
+
+        { revisionId != null && (
+          <RevisionRenderer growiRenderer={this.growiRenderer} markdown={markdown} />
+        )}
 
 
         { isLoggedIn && (
         { isLoggedIn && (
           <>
           <>
@@ -189,7 +191,6 @@ const PageWrapper = (props) => {
   const { data: grantGroupId } = useSelectedGrantGroupId();
   const { data: grantGroupId } = useSelectedGrantGroupId();
   const { data: grantGroupName } = useSelectedGrantGroupName();
   const { data: grantGroupName } = useSelectedGrantGroupName();
 
 
-
   if (editorMode == null) {
   if (editorMode == null) {
     return 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}
         growiRenderer={this.props.growiRenderer}
         markdown={markdown}
         markdown={markdown}
         highlightKeywords={this.props.highlightKeywords}
         highlightKeywords={this.props.highlightKeywords}
-        isRenderable={this.props.isRenderable}
       />
       />
     );
     );
   }
   }
@@ -128,7 +127,6 @@ LegacyRevisionLoader.propTypes = {
   lazy: PropTypes.bool,
   lazy: PropTypes.bool,
   onRevisionLoaded: PropTypes.func,
   onRevisionLoaded: PropTypes.func,
   highlightKeywords: PropTypes.arrayOf(PropTypes.string),
   highlightKeywords: PropTypes.arrayOf(PropTypes.string),
-  isRenderable: PropTypes.bool,
 };
 };
 
 
 const RevisionLoader = (props) => {
 const RevisionLoader = (props) => {

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

@@ -33,20 +33,16 @@ class LegacyRevisionRenderer extends React.PureComponent {
   }
   }
 
 
   componentDidMount() {
   componentDidMount() {
-    const { isRenderable } = this.props;
-
-    if (isRenderable) {
-      this.initCurrentRenderingContext();
-      this.renderHtml();
-    }
+    this.initCurrentRenderingContext();
+    this.renderHtml();
   }
   }
 
 
   componentDidUpdate(prevProps) {
   componentDidUpdate(prevProps) {
     const { markdown: prevMarkdown, highlightKeywords: prevHighlightKeywords } = 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
     // render only when props.markdown is updated
-    if ((markdown !== prevMarkdown || highlightKeywords !== prevHighlightKeywords) && isRenderable) {
+    if (markdown !== prevMarkdown || highlightKeywords !== prevHighlightKeywords) {
       this.initCurrentRenderingContext();
       this.initCurrentRenderingContext();
       this.renderHtml();
       this.renderHtml();
       return;
       return;
@@ -173,8 +169,7 @@ LegacyRevisionRenderer.propTypes = {
   appContainer: PropTypes.instanceOf(AppContainer).isRequired,
   appContainer: PropTypes.instanceOf(AppContainer).isRequired,
   growiRenderer: PropTypes.instanceOf(GrowiRenderer).isRequired,
   growiRenderer: PropTypes.instanceOf(GrowiRenderer).isRequired,
   markdown: PropTypes.string.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,
   additionalClassName: PropTypes.string,
 };
 };
 
 
@@ -192,7 +187,6 @@ const RevisionRenderer = (props) => {
 RevisionRenderer.propTypes = {
 RevisionRenderer.propTypes = {
   growiRenderer: PropTypes.instanceOf(GrowiRenderer).isRequired,
   growiRenderer: PropTypes.instanceOf(GrowiRenderer).isRequired,
   markdown: PropTypes.string.isRequired,
   markdown: PropTypes.string.isRequired,
-  isRenderable: PropTypes.bool,
   highlightKeywords: PropTypes.arrayOf(PropTypes.string),
   highlightKeywords: PropTypes.arrayOf(PropTypes.string),
   additionalClassName: 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 loggerFactory from '~/utils/logger';
 
 
 import {
 import {
-  IDeleteSinglePageApiv1Result, IDeleteManyPageApiv3Result, isIPageInfoForOperation, IPageToDeleteWithMeta, IDataWithMeta, IPageInfoForOperation,
+  IDeleteSinglePageApiv1Result, IDeleteManyPageApiv3Result, IPageToDeleteWithMeta, IDataWithMeta, isIPageInfoForEntity, IPageInfoForEntity,
 } from '~/interfaces/page';
 } from '~/interfaces/page';
 import { HasObjectId } from '~/interfaces/has-object-id';
 import { HasObjectId } from '~/interfaces/has-object-id';
 
 
@@ -43,13 +43,13 @@ const PageDeleteModal: FC = () => {
   const isOpened = deleteModalData?.isOpened ?? false;
   const isOpened = deleteModalData?.isOpened ?? false;
 
 
   const notOperatablePages: IPageToDeleteWithMeta[] = (deleteModalData?.pages ?? [])
   const notOperatablePages: IPageToDeleteWithMeta[] = (deleteModalData?.pages ?? [])
-    .filter(p => !isIPageInfoForOperation(p.meta));
+    .filter(p => !isIPageInfoForEntity(p.meta));
   const notOperatablePageIds = notOperatablePages.map(p => p.data._id);
   const notOperatablePageIds = notOperatablePages.map(p => p.data._id);
 
 
   const { injectTo } = useSWRxPageInfoForList(notOperatablePageIds);
   const { injectTo } = useSWRxPageInfoForList(notOperatablePageIds);
 
 
   // inject IPageInfo to operate
   // 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) {
   if (deleteModalData?.pages != null && notOperatablePageIds.length > 0) {
     injectedPages = injectTo(deleteModalData?.pages);
     injectedPages = injectTo(deleteModalData?.pages);
   }
   }
@@ -212,10 +212,10 @@ const PageDeleteModal: FC = () => {
 
 
     if (pages != null) {
     if (pages != null) {
       return pages.map(page => (
       return pages.map(page => (
-        <div key={page.data._id}>
+        <p key={page.data._id} className="mb-1">
           <code>{ page.data.path }</code>
           <code>{ page.data.path }</code>
           { !page.meta?.isDeletable && <span className="ml-3 text-danger"><strong>(CAN NOT TO DELETE)</strong></span> }
           { !page.meta?.isDeletable && <span className="ml-3 text-danger"><strong>(CAN NOT TO DELETE)</strong></span> }
-        </div>
+        </p>
       ));
       ));
     }
     }
     return <></>;
     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 {
 import {
   Modal, ModalHeader, ModalBody, ModalFooter,
   Modal, ModalHeader, ModalBody, ModalFooter,
 } from 'reactstrap';
 } from 'reactstrap';
 
 
-import { withTranslation } from 'react-i18next';
+import { useTranslation } from 'react-i18next';
 import { debounce } from 'throttle-debounce';
 import { debounce } from 'throttle-debounce';
-import { withUnstatedContainers } from './UnstatedUtils';
+
+import { apiv3Get, apiv3Post } from '~/client/util/apiv3-client';
 import { toastError } from '~/client/util/apiNotification';
 import { toastError } from '~/client/util/apiNotification';
+
 import { usePageDuplicateModal } from '~/stores/modal';
 import { usePageDuplicateModal } from '~/stores/modal';
+import { useIsSearchServiceReachable, useSiteUrl } from '~/stores/context';
 
 
-import AppContainer from '~/client/services/AppContainer';
 import PagePathAutoComplete from './PagePathAutoComplete';
 import PagePathAutoComplete from './PagePathAutoComplete';
 import ApiErrorMessageList from './PageManagement/ApiErrorMessageList';
 import ApiErrorMessageList from './PageManagement/ApiErrorMessageList';
-import ComparePathsTable from './ComparePathsTable';
 import DuplicatePathsTable from './DuplicatedPathsTable';
 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 { 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 [errs, setErrs] = useState(null);
 
 
   const [subordinatedPages, setSubordinatedPages] = useState([]);
   const [subordinatedPages, setSubordinatedPages] = useState([]);
+  const [existingPaths, setExistingPaths] = useState<string[]>([]);
   const [isDuplicateRecursively, setIsDuplicateRecursively] = useState(true);
   const [isDuplicateRecursively, setIsDuplicateRecursively] = useState(true);
   const [isDuplicateRecursivelyWithoutExistPath, setIsDuplicateRecursivelyWithoutExistPath] = 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 {
     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;
       const { existPaths } = res.data;
       setExistingPaths(existPaths);
       setExistingPaths(existPaths);
     }
     }
     catch (err) {
     catch (err) {
       setErrs(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]);
   }, [checkExistPaths]);
 
 
   useEffect(() => {
   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
    * change pageNameInput for PagePathAutoComplete
@@ -86,34 +104,24 @@ const PageDuplicateModal = (props) => {
     setIsDuplicateRecursively(!isDuplicateRecursively);
     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(() => {
   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);
     setErrs(null);
 
 
+    const { pageId, path } = page;
     try {
     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 fromPath = path;
       const toPath = data.page.path;
       const toPath = data.page.path;
 
 
@@ -125,12 +133,35 @@ const PageDuplicateModal = (props) => {
     catch (err) {
     catch (err) {
       setErrs(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 (
   return (
     <Modal size="lg" isOpen={isOpened} toggle={closeDuplicateModal} className="grw-duplicate-page" autoFocus={false}>
     <Modal size="lg" isOpen={isOpened} toggle={closeDuplicateModal} className="grw-duplicate-page" autoFocus={false}>
       <ModalHeader tag="h4" toggle={closeDuplicateModal} className="bg-primary text-light">
       <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 />
           <label htmlFor="duplicatePageName">{ t('modal_duplicate.label.New page name') }</label><br />
           <div className="input-group">
           <div className="input-group">
             <div className="input-group-prepend">
             <div className="input-group-prepend">
-              <span className="input-group-text">{crowi.url}</span>
+              <span className="input-group-text">{siteUrl}</span>
             </div>
             </div>
             <div className="flex-fill">
             <div className="flex-fill">
               {isReachable
               {isReachable
                 ? (
                 ? (
                   <PagePathAutoComplete
                   <PagePathAutoComplete
                     initializedPath={path}
                     initializedPath={path}
-                    onSubmit={ppacSubmitHandler}
+                    onSubmit={duplicate}
                     onInputChange={ppacInputChangeHandler}
                     onInputChange={ppacInputChangeHandler}
                     autoFocus
                     autoFocus
                   />
                   />
@@ -168,6 +199,11 @@ const PageDuplicateModal = (props) => {
             </div>
             </div>
           </div>
           </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">
         <div className="custom-control custom-checkbox custom-checkbox-warning mb-3">
           <input
           <input
             className="custom-control-input"
             className="custom-control-input"
@@ -191,7 +227,7 @@ const PageDuplicateModal = (props) => {
                   id="cbDuplicatewithoutExistRecursively"
                   id="cbDuplicatewithoutExistRecursively"
                   type="checkbox"
                   type="checkbox"
                   checked={isDuplicateRecursivelyWithoutExistPath}
                   checked={isDuplicateRecursivelyWithoutExistPath}
-                  onChange={changeIsDuplicateRecursivelyWithoutExistPathHandler}
+                  onChange={() => setIsDuplicateRecursivelyWithoutExistPath(!isDuplicateRecursivelyWithoutExistPath)}
                 />
                 />
                 <label className="custom-control-label" htmlFor="cbDuplicatewithoutExistRecursively">
                 <label className="custom-control-label" htmlFor="cbDuplicatewithoutExistRecursively">
                   { t('modal_duplicate.label.Duplicate without exist path') }
                   { t('modal_duplicate.label.Duplicate without exist path') }
@@ -200,8 +236,9 @@ const PageDuplicateModal = (props) => {
             )}
             )}
           </div>
           </div>
           <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>
         </div>
         </div>
 
 
@@ -212,7 +249,7 @@ const PageDuplicateModal = (props) => {
           type="button"
           type="button"
           className="btn btn-primary"
           className="btn btn-primary"
           onClick={duplicate}
           onClick={duplicate}
-          disabled={(isDuplicateRecursively && !isDuplicateRecursivelyWithoutExistPath && existingPaths.length !== 0)}
+          disabled={!submitButtonEnabled}
         >
         >
           { t('modal_duplicate.label.Duplicate page') }
           { t('modal_duplicate.label.Duplicate page') }
         </button>
         </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;

+ 8 - 12
packages/app/src/components/PageList/PageListItemL.tsx

@@ -113,14 +113,10 @@ const PageListItemLSubstance: ForwardRefRenderFunction<ISelectable, Props> = (pr
     openDuplicateModal(page, { onDuplicated: onPageDuplicated });
     openDuplicateModal(page, { onDuplicated: onPageDuplicated });
   }, [onPageDuplicated, openDuplicateModal, pageData._id, pageData.path]);
   }, [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 });
     openRenameModal(page, { onRenamed: onPageRenamed });
-  }, [onPageRenamed, openRenameModal, pageData._id, pageData.path, pageData.revision]);
+  }, [pageData, onPageRenamed, openRenameModal]);
 
 
 
 
   const deleteMenuItemClickHandler = useCallback((_id: string, pageInfo: IPageInfoAll | undefined) => {
   const deleteMenuItemClickHandler = useCallback((_id: string, pageInfo: IPageInfoAll | undefined) => {
@@ -144,16 +140,16 @@ const PageListItemLSubstance: ForwardRefRenderFunction<ISelectable, Props> = (pr
   return (
   return (
     <li
     <li
       key={pageData._id}
       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
       <div
-        className="text-break"
+        className="text-break w-100"
         onClick={clickHandler}
         onClick={clickHandler}
       >
       >
         <div className="d-flex">
         <div className="d-flex">
           {/* checkbox */}
           {/* checkbox */}
           {onCheckboxChanged != null && (
           {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
               <CustomInput
                 type="checkbox"
                 type="checkbox"
                 id={`cbSelect-${pageData._id}`}
                 id={`cbSelect-${pageData._id}`}
@@ -164,7 +160,7 @@ const PageListItemLSubstance: ForwardRefRenderFunction<ISelectable, Props> = (pr
             </div>
             </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">
             <div className="d-flex justify-content-between">
               {/* page path */}
               {/* page path */}
               <PagePathHierarchicalLink
               <PagePathHierarchicalLink
@@ -202,7 +198,7 @@ const PageListItemLSubstance: ForwardRefRenderFunction<ISelectable, Props> = (pr
               </Clamp>
               </Clamp>
 
 
               {/* page meta */}
               {/* 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 />
                 <PageListMeta page={pageData} bookmarkCount={pageMeta?.bookmarkCount} shouldSpaceOutIcon />
               </div>
               </div>
 
 

+ 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} />
         <UserPicture user={page.lastUpdateUser} noLink={noLink} />
         {pagePathElem}
         {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}
                     growiRenderer={this.growiRenderer}
                     pageId={page._id}
                     pageId={page._id}
                     revisionId={page.revision}
                     revisionId={page.revision}
-                    isRenderable
                   />
                   />
                 </div>
                 </div>
               </div>
               </div>

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

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

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

@@ -83,7 +83,7 @@ const SearchControl: FC <Props> = React.memo((props: Props) => {
       </div>
       </div>
       {/* TODO: replace the following elements deleteAll button , relevance button and include specificPath button component */}
       {/* 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="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}
           {deleteAllControl}
         </div>
         </div>
         {/* sort option: show when screen is smaller than lg */}
         {/* 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 { DropdownItem } from 'reactstrap';
 
 
-import { IPageToDeleteWithMeta, IPageWithMeta } from '~/interfaces/page';
+import { IPageToDeleteWithMeta, IPageToRenameWithMeta, IPageWithMeta } from '~/interfaces/page';
 import { IPageSearchMeta } from '~/interfaces/search';
 import { IPageSearchMeta } from '~/interfaces/search';
 import { OnDuplicatedFunction, OnRenamedFunction, OnDeletedFunction } from '~/interfaces/ui';
 import { OnDuplicatedFunction, OnRenamedFunction, OnDeletedFunction } from '~/interfaces/ui';
 import { usePageTreeTermManager } from '~/stores/page-listing';
 import { usePageTreeTermManager } from '~/stores/page-listing';
@@ -131,7 +131,7 @@ export const SearchResultContent: FC<Props> = (props: Props) => {
     openDuplicateModal(pageToDuplicate, { onDuplicated: duplicatedHandler });
     openDuplicateModal(pageToDuplicate, { onDuplicated: duplicatedHandler });
   }, [advanceDpl, advanceFts, advancePt, openDuplicateModal, t]);
   }, [advanceDpl, advanceFts, advancePt, openDuplicateModal, t]);
 
 
-  const renameItemClickedHandler = useCallback(async(pageToRename) => {
+  const renameItemClickedHandler = useCallback((pageToRename: IPageToRenameWithMeta) => {
     const renamedHandler: OnRenamedFunction = (path) => {
     const renamedHandler: OnRenamedFunction = (path) => {
       toastSuccess(t('renamed_pages', { path }));
       toastSuccess(t('renamed_pages', { path }));
 
 
@@ -214,7 +214,6 @@ export const SearchResultContent: FC<Props> = (props: Props) => {
           pagePath={page.path}
           pagePath={page.path}
           revisionId={page.revision}
           revisionId={page.revision}
           highlightKeywords={highlightKeywords}
           highlightKeywords={highlightKeywords}
-          isRenderable
         />
         />
       </div>
       </div>
     </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">
         <div className="border rounded-right">
           <button
           <button
             type="button"
             type="button"
-            className="btn dropdown-toggle search-sort-option-btn"
+            className="btn dropdown-toggle search-sort-option-btn py-1"
             data-toggle="dropdown"
             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>
           </button>
           <div className="dropdown-menu dropdown-menu-right">
           <div className="dropdown-menu dropdown-menu-right">
             {Object.values(SORT_AXIS).map((sortAxis) => {
             {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 (
     return (
       <span>
       <span>
         <UserPicture user={pageData.lastUpdateUser} size="sm" noLink />
         <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} />
         <PageListMeta page={pageData} />
       </span>
       </span>
     );
     );

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

@@ -63,7 +63,6 @@ const CustomSidebar: FC<Props> = (props: Props) => {
               growiRenderer={renderer}
               growiRenderer={renderer}
               markdown={markdown}
               markdown={markdown}
               additionalClassName="grw-custom-sidebar-content"
               additionalClassName="grw-custom-sidebar-content"
-              isRenderable
             />
             />
           </div>
           </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 && (
       {!isGuestUser && migrationStatus?.migratablePagesCount != null && migrationStatus.migratablePagesCount !== 0 && (
         <div className="grw-pagetree-footer border-top p-3 w-100">
         <div className="grw-pagetree-footer border-top p-3 w-100">
-          <PrivateLegacyPagesLink />
+          <div className="private-legacy-pages-link px-3 py-2">
+            <PrivateLegacyPagesLink />
+          </div>
         </div>
         </div>
       )}
       )}
     </>
     </>

+ 9 - 5
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 { PageItemControl } from '../../Common/Dropdown/PageItemControl';
 import { ItemNode } from './ItemNode';
 import { ItemNode } from './ItemNode';
 import { usePageTreeDescCountMap } from '~/stores/ui';
 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');
 const logger = loggerFactory('growi:cli:Item');
@@ -185,7 +187,7 @@ const Item: FC<ItemProps> = (props: ItemProps) => {
         revisionId: droppedPage.revision,
         revisionId: droppedPage.revision,
         newPagePath,
         newPagePath,
         isRenameRedirect: false,
         isRenameRedirect: false,
-        isRemainMetadata: false,
+        updateMetadata: true,
       });
       });
 
 
       await mutateChildren();
       await mutateChildren();
@@ -290,6 +292,10 @@ const Item: FC<ItemProps> = (props: ItemProps) => {
   };
   };
 
 
   const deleteMenuItemClickHandler = useCallback(async(_pageId: string, pageInfo: IPageInfoAll | undefined): Promise<void> => {
   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) {
     if (page._id == null || page.revision == null || page.path == null) {
       throw Error('Any of _id, revision, and path must not be null.');
       throw Error('Any of _id, revision, and path must not be null.');
     }
     }
@@ -303,9 +309,7 @@ const Item: FC<ItemProps> = (props: ItemProps) => {
       meta: pageInfo,
       meta: pageInfo,
     };
     };
 
 
-    if (onClickDeleteMenuItem != null) {
-      onClickDeleteMenuItem(pageToDelete);
-    }
+    onClickDeleteMenuItem(pageToDelete);
   }, [onClickDeleteMenuItem, page]);
   }, [onClickDeleteMenuItem, page]);
 
 
   const onPressEnterForCreateHandler = async(inputText: string) => {
   const onPressEnterForCreateHandler = async(inputText: string) => {

+ 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 IPageForItem = Partial<IPageHasId & {isTarget?: boolean}>;
 
 
 export type IPageInfo = {
 export type IPageInfo = {
+  isV5Compatible: boolean,
   isEmpty: boolean,
   isEmpty: boolean,
   isMovable: boolean,
   isMovable: boolean,
   isDeletable: boolean,
   isDeletable: boolean,
@@ -104,7 +105,8 @@ export type IDataWithMeta<D = unknown, M = unknown> = {
 
 
 export type IPageWithMeta<M = IPageInfoAll> = IDataWithMeta<IPageHasId, M>;
 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 = {
 export type IDeleteSinglePageApiv1Result = {
   ok: boolean
   ok: boolean

+ 4 - 4
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('newPagePath').isLength({ min: 1 }).withMessage('newPagePath is required'),
       body('isRecursively').if(value => value != null).isBoolean().withMessage('isRecursively must be boolean'),
       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('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'),
       body('isMoveMode').if(value => value != null).isBoolean().withMessage('isMoveMode must be boolean'),
     ],
     ],
     duplicatePage: [
     duplicatePage: [
@@ -445,9 +445,9 @@ module.exports = (crowi) => {
    *                  isRenameRedirect:
    *                  isRenameRedirect:
    *                    type: boolean
    *                    type: boolean
    *                    description: whether redirect page
    *                    description: whether redirect page
-   *                  isRemainMetadata:
+   *                  updateMetadata:
    *                    type: boolean
    *                    type: boolean
-   *                    description: whether remain meta data
+   *                    description: whether update meta data
    *                  isRecursively:
    *                  isRecursively:
    *                    type: boolean
    *                    type: boolean
    *                    description: whether rename page with descendants
    *                    description: whether rename page with descendants
@@ -476,7 +476,7 @@ module.exports = (crowi) => {
     const options = {
     const options = {
       isRecursively: req.body.isRecursively,
       isRecursively: req.body.isRecursively,
       createRedirectPage: req.body.isRenameRedirect,
       createRedirectPage: req.body.isRenameRedirect,
-      updateMetadata: !req.body.isRemainMetadata,
+      updateMetadata: req.body.updateMetadata,
       isMoveMode: req.body.isMoveMode,
       isMoveMode: req.body.isMoveMode,
     };
     };
 
 

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

@@ -249,6 +249,7 @@ class PageService {
       return {
       return {
         data: page,
         data: page,
         meta: {
         meta: {
+          isV5Compatible: isTopPage(page.path) || page.parent != null,
           isEmpty: page.isEmpty,
           isEmpty: page.isEmpty,
           isMovable: false,
           isMovable: false,
           isDeletable: false,
           isDeletable: false,
@@ -2064,6 +2065,7 @@ class PageService {
 
 
     if (page.isEmpty) {
     if (page.isEmpty) {
       return {
       return {
+        isV5Compatible: true,
         isEmpty: true,
         isEmpty: true,
         isMovable,
         isMovable,
         isDeletable: false,
         isDeletable: false,
@@ -2076,6 +2078,7 @@ class PageService {
     const seenUsers = page.seenUsers.slice(0, 15) as Ref<IUserHasId>[];
     const seenUsers = page.seenUsers.slice(0, 15) as Ref<IUserHasId>[];
 
 
     return {
     return {
+      isV5Compatible: isTopPage(page.path) || page.parent != null,
       isEmpty: false,
       isEmpty: false,
       sumOfLikers: page.liker.length,
       sumOfLikers: page.liker.length,
       likerIds: this.extractStringIds(likers),
       likerIds: this.extractStringIds(likers),

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

@@ -11,6 +11,11 @@ import { TargetAndAncestors, NotFoundTargetPathOrId, IsNotFoundPermalink } from
 
 
 type Nullable<T> = T | null;
 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> => {
 export const useCurrentUser = (initialData?: Nullable<IUser>): SWRResponse<Nullable<IUser>, Error> => {
   return useStaticSWR<Nullable<IUser>, Error>('currentUser', initialData);
   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 {
 import {
   OnDuplicatedFunction, OnRenamedFunction, OnDeletedFunction, OnPutBackedFunction,
   OnDuplicatedFunction, OnRenamedFunction, OnDeletedFunction, OnPutBackedFunction,
 } from '~/interfaces/ui';
 } 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 => {
 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 });
   const swrResponse = useStaticSWR<DuplicateModalStatus, Error>('duplicateModalStatus', status, { fallbackData: initialData });
 
 
   return {
   return {
@@ -103,53 +103,45 @@ export const usePageDuplicateModal = (status?: DuplicateModalStatus): SWRRespons
         page?: IPageForPageDuplicateModal,
         page?: IPageForPageDuplicateModal,
         opts?: IDuplicateModalOption,
         opts?: IDuplicateModalOption,
     ) => swrResponse.mutate({ isOpened: true, page, opts }),
     ) => 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 = {
 export type IRenameModalOption = {
   onRenamed?: OnRenamedFunction,
   onRenamed?: OnRenamedFunction,
 }
 }
 
 
 type RenameModalStatus = {
 type RenameModalStatus = {
   isOpened: boolean,
   isOpened: boolean,
-  page?: IPageForPageRenameModal,
+  page?: IPageToRenameWithMeta,
   opts?: IRenameModalOption
   opts?: IRenameModalOption
 }
 }
 
 
 type RenameModalStatusUtils = {
 type RenameModalStatusUtils = {
   open(
   open(
-    page?: IPageForPageRenameModal,
+    page?: IPageToRenameWithMeta,
     opts?: IRenameModalOption
     opts?: IRenameModalOption
     ): Promise<RenameModalStatus | undefined>
     ): Promise<RenameModalStatus | undefined>
   close(): Promise<RenameModalStatus | undefined>
   close(): Promise<RenameModalStatus | undefined>
 }
 }
 
 
 export const usePageRenameModal = (status?: RenameModalStatus): SWRResponse<RenameModalStatus, Error> & RenameModalStatusUtils => {
 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 });
   const swrResponse = useStaticSWR<RenameModalStatus, Error>('renameModalStatus', status, { fallbackData: initialData });
 
 
   return {
   return {
     ...swrResponse,
     ...swrResponse,
     open: (
     open: (
-        page?: IPageForPageRenameModal,
+        page?: IPageToRenameWithMeta,
         opts?: IRenameModalOption,
         opts?: IRenameModalOption,
     ) => swrResponse.mutate({
     ) => swrResponse.mutate({
       isOpened: true, page, opts,
       isOpened: true, page, opts,
     }),
     }),
-    close: () => swrResponse.mutate({ isOpened: false, page: { pageId: '', revisionId: '', path: '' } }),
+    close: () => swrResponse.mutate({ isOpened: false }),
   };
   };
 };
 };
 
 

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

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

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

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

+ 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/page-editor-mode-manager';
 @import 'molecules/slack-notification';
 @import 'molecules/slack-notification';
 @import 'molecules/duplicated-paths-table.scss';
 @import 'molecules/duplicated-paths-table.scss';
-@import 'molecules/compare-paths-table.scss';
 
 
 // growi component
 // growi component
 @import 'admin';
 @import 'admin';

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

@@ -280,6 +280,11 @@ ul.pagination {
       $gray-500
       $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
       $gray-400
     );
     );
   }
   }
+  .private-legacy-pages-link {
+    &:hover {
+      background: $bgcolor-list-hover;
+    }
+  }
 }
 }
 
 
 /*
 /*

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

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

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

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

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

@@ -54,7 +54,7 @@ export class PageListMeta extends React.Component {
     }
     }
 
 
     return (
     return (
-      <span className="page-list-meta ml-2">
+      <span className="page-list-meta">
         {topLabel}
         {topLabel}
         {templateLabel}
         {templateLabel}
         {seenUserCount}
         {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"
   resolved "https://registry.yarnpkg.com/throat/-/throat-6.0.1.tgz#d514fedad95740c12c2d7fc70ea863eb51ade375"
   integrity sha512-8hmiGIJMDlwjg7dlJ4yKGLK8EsYqKgPWbG3b4wjJddKNwc7N7Dpn08Df4szr/sZdMVeOstrdYSsqzX6BYbcB+w==
   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:
 throttleit@^1.0.0:
   version "1.0.0"
   version "1.0.0"