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

Merge pull request #5495 from weseek/imprv/modal-for-renaming-and-duplicating

imprv: Modal for renaming and duplicating
Yuki Takei 4 лет назад
Родитель
Сommit
4de6d459da

+ 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) {

+ 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) {

+ 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 <></>;

+ 22 - 8
packages/app/src/components/PageDuplicateModal.jsx

@@ -1,4 +1,6 @@
-import React, { useState, useEffect, useCallback } from 'react';
+import React, {
+  useState, useEffect, useCallback, useMemo,
+} from 'react';
 import PropTypes from 'prop-types';
 import PropTypes from 'prop-types';
 
 
 import {
 import {
@@ -12,6 +14,8 @@ import { toastError } from '~/client/util/apiNotification';
 import { usePageDuplicateModal } from '~/stores/modal';
 import { usePageDuplicateModal } from '~/stores/modal';
 
 
 import AppContainer from '~/client/services/AppContainer';
 import AppContainer from '~/client/services/AppContainer';
+import { apiv3Get } from '~/client/util/apiv3-client';
+
 import PagePathAutoComplete from './PagePathAutoComplete';
 import PagePathAutoComplete from './PagePathAutoComplete';
 import ApiErrorMessageList from './PageManagement/ApiErrorMessageList';
 import ApiErrorMessageList from './PageManagement/ApiErrorMessageList';
 import ComparePathsTable from './ComparePathsTable';
 import ComparePathsTable from './ComparePathsTable';
@@ -42,25 +46,35 @@ const PageDuplicateModal = (props) => {
   const [existingPaths, setExistingPaths] = useState([]);
   const [existingPaths, setExistingPaths] = useState([]);
 
 
   const checkExistPaths = useCallback(async(newParentPath) => {
   const checkExistPaths = useCallback(async(newParentPath) => {
+    if (page == null) {
+      return;
+    }
+
     try {
     try {
-      const res = await appContainer.apiv3Get('/page/exist-paths', { fromPath: path, toPath: newParentPath });
+      const res = await apiv3Get('/page/exist-paths', { fromPath: path, toPath: newParentPath });
       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, path, 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) {
     if (pageId != null && path != null && pageNameInput !== path) {
-      checkExistPathsDebounce(pageNameInput, subordinatedPages);
+      checkExistPathsDebounce(pageNameInput);
+    }
+  }, [pageNameInput, subordinatedPages, checkExistPathsDebounce, pageId, path]);
+
+
+  useEffect(() => {
+    if (pageId != null && path != null && pageNameInput !== path) {
+      checkExistPathsDebounce(pageNameInput);
     }
     }
   }, [pageNameInput, subordinatedPages, path, pageId, checkExistPathsDebounce]);
   }, [pageNameInput, subordinatedPages, path, pageId, checkExistPathsDebounce]);
 
 

+ 3 - 7
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) => {

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

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

@@ -0,0 +1,301 @@
+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);
+
+  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={(!isRenameRecursively && existingPaths.length !== 0)}
+        >Rename
+        </button>
+      </ModalFooter>
+    </Modal>
+  );
+};
+
+export default PageRenameModal;

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

+ 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

@@ -250,6 +250,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,
@@ -2065,6 +2066,7 @@ class PageService {
 
 
     if (page.isEmpty) {
     if (page.isEmpty) {
       return {
       return {
+        isV5Compatible: true,
         isEmpty: true,
         isEmpty: true,
         isMovable,
         isMovable,
         isDeletable: false,
         isDeletable: false,
@@ -2077,6 +2079,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);
 };
 };

+ 8 - 16
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';
 
 
 
 
 /*
 /*
@@ -109,47 +109,39 @@ export const usePageDuplicateModal = (status?: DuplicateModalStatus): SWRRespons
 
 
 
 
 /*
 /*
-* 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/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');

+ 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"