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

Merge branch 'master' into feat/87755-re-use-swr-to-display-duplicate-modal

Shun Miyazawa 4 лет назад
Родитель
Сommit
53913fa0ca

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

@@ -23,9 +23,9 @@ type CommonProps = {
   isEnableActions?: boolean,
   showBookmarkMenuItem?: boolean,
   onClickBookmarkMenuItem?: (pageId: string, newValue?: boolean) => Promise<void>,
-  onClickDuplicateMenuItem?: () => Promise<void> | void,
+  onClickDuplicateMenuItem?: (pageId: string) => Promise<void> | void,
   onClickRenameMenuItem?: (pageId: string) => Promise<void> | void,
-  onClickDeleteMenuItem?: (pageId: string) => void,
+  onClickDeleteMenuItem?: (pageId: string) => Promise<void> | void,
 
   additionalMenuItemRenderer?: React.FunctionComponent<AdditionalMenuItemsRendererProps>,
 }
@@ -60,8 +60,8 @@ const PageItemControlDropdownMenu = React.memo((props: DropdownMenuProps): JSX.E
     if (onClickDuplicateMenuItem == null) {
       return;
     }
-    await onClickDuplicateMenuItem();
-  }, [onClickDuplicateMenuItem]);
+    await onClickDuplicateMenuItem(pageId);
+  }, [onClickDuplicateMenuItem, pageId]);
 
   // eslint-disable-next-line react-hooks/rules-of-hooks
   const renameItemClickedHandler = useCallback(async() => {
@@ -194,8 +194,8 @@ export const PageItemControlSubstance = (props: PageItemControlSubstanceProps):
     if (onClickDuplicateMenuItem == null) {
       return;
     }
-    await onClickDuplicateMenuItem();
-  }, [onClickDuplicateMenuItem]);
+    await onClickDuplicateMenuItem(pageId);
+  }, [onClickDuplicateMenuItem, pageId]);
 
   const renameMenuItemClickHandler = useCallback(async() => {
     if (onClickRenameMenuItem == null) {

+ 60 - 9
packages/app/src/components/Navbar/GrowiContextualSubNavigation.tsx

@@ -1,4 +1,4 @@
-import React, { useCallback } from 'react';
+import React, { useState, useCallback } from 'react';
 import PropTypes from 'prop-types';
 
 import { useTranslation } from 'react-i18next';
@@ -10,10 +10,11 @@ import EditorContainer from '~/client/services/EditorContainer';
 import {
   EditorMode, useDrawerMode, useEditorMode, useIsDeviceSmallerThanMd, useIsAbleToShowPageManagement, useIsAbleToShowTagLabel,
   useIsAbleToShowPageEditorModeManager, useIsAbleToShowPageAuthors, usePageAccessoriesModal, PageAccessoriesModalContents,
+  usePageDuplicateModalStatus, usePageRenameModalStatus, usePageDeleteModal,
 } from '~/stores/ui';
 import {
   useCurrentCreatedAt, useCurrentUpdatedAt, useCurrentPageId, useRevisionId, useCurrentPagePath,
-  useCreator, useRevisionAuthor, useIsGuestUser, useIsSharedUser, useShareLinkId,
+  useCreator, useRevisionAuthor, useCurrentUser, useIsGuestUser, useIsSharedUser, useShareLinkId,
 } from '~/stores/context';
 import { useSWRTagsInfo } from '~/stores/page';
 
@@ -30,6 +31,7 @@ import { SubNavButtons } from './SubNavButtons';
 import PageEditorModeManager from './PageEditorModeManager';
 import { GrowiSubNavigation } from './GrowiSubNavigation';
 import PresentationIcon from '../Icons/PresentationIcon';
+import CreateTemplateModal from '../CreateTemplateModal';
 import { exportAsMarkdown } from '~/client/services/page-operation';
 
 
@@ -37,12 +39,20 @@ type AdditionalMenuItemsProps = AdditionalMenuItemsRendererProps & {
   pageId: string,
   revisionId: string,
   isLinkSharingDisabled?: boolean,
+  onClickTemplateMenuItem: (isPageTemplateModalShown: boolean) => void,
+
 }
 
 const AdditionalMenuItems = (props: AdditionalMenuItemsProps): JSX.Element => {
   const { t } = useTranslation();
 
-  const { pageId, revisionId, isLinkSharingDisabled } = props;
+  const {
+    pageId, revisionId, isLinkSharingDisabled, onClickTemplateMenuItem,
+  } = props;
+
+  const openPageTemplateModalHandler = () => {
+    onClickTemplateMenuItem(true);
+  };
 
   const { data: isGuestUser } = useIsGuestUser();
   const { data: isSharedUser } = useIsSharedUser();
@@ -95,7 +105,7 @@ const AdditionalMenuItems = (props: AdditionalMenuItemsProps): JSX.Element => {
       <DropdownItem divider />
 
       {/* Create template */}
-      <DropdownItem onClick={() => { /* TODO: implement in https://redmine.weseek.co.jp/issues/87673 */ }}>
+      <DropdownItem onClick={openPageTemplateModalHandler}>
         <i className="icon-fw icon-magic-wand"></i> { t('template.option_label.create/edit') }
       </DropdownItem>
     </>
@@ -114,6 +124,7 @@ const GrowiContextualSubNavigation = (props) => {
   const { data: path } = useCurrentPagePath();
   const { data: creator } = useCreator();
   const { data: revisionAuthor } = useRevisionAuthor();
+  const { data: currentUser } = useCurrentUser();
   const { data: isGuestUser } = useIsGuestUser();
   const { data: isSharedUser } = useIsSharedUser();
   const { data: shareLinkId } = useShareLinkId();
@@ -125,6 +136,12 @@ const GrowiContextualSubNavigation = (props) => {
 
   const { mutate: mutateSWRTagsInfo, data: tagsInfoData } = useSWRTagsInfo(pageId);
 
+  const { open: openDuplicateModal } = usePageDuplicateModalStatus();
+  const { open: openRenameModal } = usePageRenameModalStatus();
+  const { open: openDeleteModal } = usePageDeleteModal();
+
+  const [isPageTemplateModalShown, setIsPageTempleteModalShown] = useState(false);
+
   const {
     editorContainer, isCompactMode, isLinkSharingDisabled,
   } = props;
@@ -153,6 +170,23 @@ const GrowiContextualSubNavigation = (props) => {
   // eslint-disable-next-line react-hooks/exhaustive-deps
   }, [pageId]);
 
+  const duplicateItemClickedHandler = useCallback(async(pageId, path) => {
+    openDuplicateModal(pageId, path);
+  }, [openDuplicateModal]);
+
+  const renameItemClickedHandler = useCallback(async(pageId, revisionId, path) => {
+    openRenameModal(pageId, revisionId, path);
+  }, [openRenameModal]);
+
+  const deleteItemClickedHandler = useCallback(async(pageToDelete) => {
+    openDeleteModal([pageToDelete]);
+  }, [openDeleteModal]);
+
+  const templateMenuItemClickHandler = useCallback(() => {
+    setIsPageTempleteModalShown(true);
+  }, []);
+
+
   const ControlComponents = useCallback(() => {
     function onPageEditorModeButtonClicked(viewType) {
       mutateEditorMode(viewType);
@@ -167,11 +201,21 @@ const GrowiContextualSubNavigation = (props) => {
               pageId={pageId}
               shareLinkId={shareLinkId}
               revisionId={revisionId}
+              path={path}
               disableSeenUserInfoPopover={isSharedUser}
               showPageControlDropdown={isAbleToShowPageManagement}
               additionalMenuItemRenderer={props => (
-                <AdditionalMenuItems {...props} pageId={pageId} revisionId={revisionId} isLinkSharingDisabled={isLinkSharingDisabled} />
+                <AdditionalMenuItems
+                  {...props}
+                  pageId={pageId}
+                  revisionId={revisionId}
+                  isLinkSharingDisabled={isLinkSharingDisabled}
+                  onClickTemplateMenuItem={templateMenuItemClickHandler}
+                />
               )}
+              onClickDuplicateMenuItem={duplicateItemClickedHandler}
+              onClickRenameMenuItem={renameItemClickedHandler}
+              onClickDeleteMenuItem={deleteItemClickedHandler}
             />
           ) }
         </div>
@@ -185,14 +229,21 @@ const GrowiContextualSubNavigation = (props) => {
             />
           )}
         </div>
+        {currentUser != null && (
+          <CreateTemplateModal
+            path={path}
+            isOpen={isPageTemplateModalShown}
+            onClose={() => setIsPageTempleteModalShown(false)}
+          />
+        )}
       </>
     );
   }, [
-    pageId, revisionId, shareLinkId,
-    editorMode, mutateEditorMode,
-    isCompactMode, isLinkSharingDisabled,
-    isDeviceSmallerThanMd, isGuestUser, isSharedUser,
+    pageId, revisionId, shareLinkId, editorMode, mutateEditorMode, isCompactMode,
+    isLinkSharingDisabled, isDeviceSmallerThanMd, isGuestUser, isSharedUser, currentUser,
     isViewMode, isAbleToShowPageEditorModeManager, isAbleToShowPageManagement,
+    duplicateItemClickedHandler, renameItemClickedHandler, deleteItemClickedHandler,
+    path, templateMenuItemClickHandler, isPageTemplateModalShown,
   ]);
 
 

+ 58 - 3
packages/app/src/components/Navbar/SubNavButtons.tsx

@@ -6,6 +6,7 @@ import { useSWRxPageInfo } from '../../stores/page';
 import { useSWRBookmarkInfo } from '../../stores/bookmark';
 import { useSWRxUsersList } from '../../stores/user';
 import { useIsGuestUser } from '~/stores/context';
+import { IPageForPageDeleteModal } from '~/stores/ui';
 
 import SubscribeButton from '../SubscribeButton';
 import LikeButtons from '../LikeButtons';
@@ -20,20 +21,25 @@ type CommonProps = {
   disableSeenUserInfoPopover?: boolean,
   showPageControlDropdown?: boolean,
   additionalMenuItemRenderer?: React.FunctionComponent<AdditionalMenuItemsRendererProps>,
+  onClickDuplicateMenuItem?: (pageId: string, path: string) => void,
+  onClickRenameMenuItem?: (pageId: string, revisionId: string, path: string) => void,
+  onClickDeleteMenuItem?: (pageToDelete: IPageForPageDeleteModal | null) => void,
 }
 
 type SubNavButtonsSubstanceProps= CommonProps & {
   pageId: string,
   shareLinkId?: string | null,
   revisionId: string,
+  path?: string | null,
   pageInfo: IPageInfoAll,
 }
 
 const SubNavButtonsSubstance = (props: SubNavButtonsSubstanceProps): JSX.Element => {
   const {
     pageInfo,
-    pageId, shareLinkId,
+    pageId, revisionId, path, shareLinkId,
     isCompactMode, disableSeenUserInfoPopover, showPageControlDropdown, additionalMenuItemRenderer,
+    onClickDuplicateMenuItem, onClickRenameMenuItem, onClickDeleteMenuItem,
   } = props;
 
   const { data: isGuestUser } = useIsGuestUser();
@@ -87,10 +93,41 @@ const SubNavButtonsSubstance = (props: SubNavButtonsSubstanceProps): JSX.Element
     mutateBookmarkInfo();
   }, [isGuestUser, mutateBookmarkInfo, mutatePageInfo, pageId, pageInfo]);
 
+  const duplicateMenuItemClickHandler = useCallback(async(_pageId: string): Promise<void> => {
+    if (onClickDuplicateMenuItem == null || path == null) {
+      return;
+    }
+
+    onClickDuplicateMenuItem(pageId, path);
+  }, [onClickDuplicateMenuItem, pageId, path]);
+
+  const renameMenuItemClickHandler = useCallback(async(_pageId: string): Promise<void> => {
+    if (onClickRenameMenuItem == null || path == null) {
+      return;
+    }
+
+    onClickRenameMenuItem(pageId, revisionId, path);
+  }, [onClickRenameMenuItem, pageId, path, revisionId]);
+
+  const deleteMenuItemClickHandler = useCallback(async(_pageId: string): Promise<void> => {
+    if (onClickDeleteMenuItem == null || path == null) {
+      return;
+    }
+
+    const pageToDelete: IPageForPageDeleteModal = {
+      pageId,
+      revisionId,
+      path,
+    };
+
+    onClickDeleteMenuItem(pageToDelete);
+  }, [onClickDeleteMenuItem, pageId, path, revisionId]);
+
   if (!isIPageInfoForOperation(pageInfo)) {
     return <></>;
   }
 
+
   const {
     sumOfLikers, isLiked, bookmarkCount, isBookmarked,
   } = pageInfo;
@@ -124,6 +161,9 @@ const SubNavButtonsSubstance = (props: SubNavButtonsSubstanceProps): JSX.Element
           pageInfo={pageInfo}
           isEnableActions={!isGuestUser}
           additionalMenuItemRenderer={additionalMenuItemRenderer}
+          onClickRenameMenuItem={renameMenuItemClickHandler}
+          onClickDuplicateMenuItem={duplicateMenuItemClickHandler}
+          onClickDeleteMenuItem={deleteMenuItemClickHandler}
         />
       )}
     </div>
@@ -134,10 +174,13 @@ type SubNavButtonsProps= CommonProps & {
   pageId: string,
   shareLinkId?: string | null,
   revisionId?: string | null,
+  path?: string | null
 };
 
 export const SubNavButtons = (props: SubNavButtonsProps): JSX.Element => {
-  const { pageId, shareLinkId, revisionId } = props;
+  const {
+    pageId, revisionId, path, shareLinkId, onClickDuplicateMenuItem, onClickRenameMenuItem, onClickDeleteMenuItem,
+  } = props;
 
   const { data: pageInfo, error } = useSWRxPageInfo(pageId ?? null, shareLinkId);
 
@@ -149,5 +192,17 @@ export const SubNavButtons = (props: SubNavButtonsProps): JSX.Element => {
     return <></>;
   }
 
-  return <SubNavButtonsSubstance {...props} pageInfo={pageInfo} pageId={pageId} revisionId={revisionId} />;
+
+  return (
+    <SubNavButtonsSubstance
+      {...props}
+      pageInfo={pageInfo}
+      pageId={pageId}
+      revisionId={revisionId}
+      path={path}
+      onClickDuplicateMenuItem={onClickDuplicateMenuItem}
+      onClickRenameMenuItem={onClickRenameMenuItem}
+      onClickDeleteMenuItem={onClickDeleteMenuItem}
+    />
+  );
 };

+ 49 - 19
packages/app/src/components/PageDeleteModal.tsx

@@ -5,9 +5,10 @@ import {
 import { useTranslation } from 'react-i18next';
 
 import { apiPost } from '~/client/util/apiv1-client';
+import { apiv3Post } from '~/client/util/apiv3-client';
 import { usePageDeleteModal, usePageDeleteModalOpened } from '~/stores/ui';
 
-import { IPageApiv1Result } from '~/interfaces/page';
+import { IDeleteSinglePageApiv1Result, IDeleteManyPageApiv3Result } from '~/interfaces/page';
 
 import ApiErrorMessageList from './PageManagement/ApiErrorMessageList';
 
@@ -37,7 +38,7 @@ const PageDeleteModal: FC<Props> = (props: Props) => {
     isDeleteCompletelyModal, isAbleToDeleteCompletely,
   } = props;
 
-  const { data: pagesDataToDelete, close: closeDeleteModal } = usePageDeleteModal();
+  const { data: deleteModalStatus, close: closeDeleteModal } = usePageDeleteModal();
   const { data: pageDeleteModalOpened } = usePageDeleteModalOpened();
 
   const isOpened = pageDeleteModalOpened?.isOpend != null ? pageDeleteModalOpened.isOpend : false;
@@ -47,7 +48,7 @@ const PageDeleteModal: FC<Props> = (props: Props) => {
   const deleteMode = isDeleteCompletely ? 'completely' : 'temporary';
 
   // eslint-disable-next-line @typescript-eslint/no-unused-vars
-  const [errs, setErrs] = useState(null);
+  const [errs, setErrs] = useState<Error[] | null>(null);
 
   function changeIsDeleteRecursivelyHandler() {
     setIsDeleteRecursively(!isDeleteRecursively);
@@ -61,36 +62,65 @@ const PageDeleteModal: FC<Props> = (props: Props) => {
   }
 
   async function deletePage() {
-    // toastr.warning(t('search_result.currently_not_implemented'));
-    // Todo implement page delete function at https://redmine.weseek.co.jp/issues/82222
-    // setErrs(null);
+    if (deleteModalStatus == null || deleteModalStatus.pages == null) {
+      return;
+    }
 
-    if (pagesDataToDelete?.pages != null && (pagesDataToDelete.pages.length > 0)) {
+    /*
+     * When multiple pages
+     */
+    if (deleteModalStatus.pages.length > 1) {
+      try {
+        const isRecursively = isDeleteRecursively === true ? true : undefined;
+        const isCompletely = isDeleteCompletely === true ? true : undefined;
+
+        const pageIdToRevisionIdMap = {};
+        deleteModalStatus.pages.forEach((p) => { pageIdToRevisionIdMap[p.pageId] = p.revisionId });
+
+        const { data } = await apiv3Post<IDeleteManyPageApiv3Result>('/pages/delete', {
+          pageIdToRevisionIdMap,
+          isRecursively,
+          isCompletely,
+        });
+
+        if (pageDeleteModalOpened != null && pageDeleteModalOpened.onDeleted != null) {
+          pageDeleteModalOpened.onDeleted(data.paths, data.isRecursively, data.isCompletely);
+        }
+      }
+      catch (err) {
+        setErrs([err]);
+      }
+    }
+    /*
+     * When single page
+     */
+    else {
       try {
         const recursively = isDeleteRecursively === true ? true : undefined;
         const completely = isDeleteCompletely === true ? true : undefined;
 
-        // TODO: Create an endpoint (pages.removeMany)
-        const result = await apiPost('/pages.removeMany', {
-          pages: pagesDataToDelete.pages,
+        const page = deleteModalStatus.pages[0];
+
+        const { path, isRecursively, isCompletely } = await apiPost('/pages.remove', {
+          page_id: page.pageId,
+          revision_id: page.revisionId,
           recursively,
           completely,
-        }) as IPageApiv1Result;
-
-        const redirectPagePath = result.page.path;
+        }) as IDeleteSinglePageApiv1Result;
 
-        if (pageDeleteModalOpened?.onDeleted) {
-          pageDeleteModalOpened.onDeleted(redirectPagePath);
+        if (pageDeleteModalOpened != null && pageDeleteModalOpened.onDeleted != null) {
+          pageDeleteModalOpened.onDeleted(path, isRecursively, isCompletely);
         }
       }
       catch (err) {
-        setErrs(err);
+        setErrs([err]);
       }
     }
   }
 
   async function deleteButtonHandler() {
-    deletePage();
+    await closeDeleteModal();
+    await deletePage();
   }
 
   function renderDeleteRecursivelyForm() {
@@ -147,8 +177,8 @@ const PageDeleteModal: FC<Props> = (props: Props) => {
   }
 
   const renderPagePathsToDelete = () => {
-    if (pagesDataToDelete != null && pagesDataToDelete.pages != null) {
-      return pagesDataToDelete.pages.map(page => <div key={page.pageId}><code>{ page.path }</code></div>);
+    if (deleteModalStatus != null && deleteModalStatus.pages != null) {
+      return deleteModalStatus.pages.map(page => <div key={page.pageId}><code>{ page.path }</code></div>);
     }
     return <></>;
   };

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

@@ -5,7 +5,7 @@ import { format } from 'date-fns';
 
 import { UserPicture, PageListMeta } from '@growi/ui';
 import { DevidedPagePath } from '@growi/core';
-import { useIsDeviceSmallerThanLg, usePageDuplicateModalStatus } from '~/stores/ui';
+import { useIsDeviceSmallerThanLg, usePageRenameModalStatus, usePageDuplicateModalStatus } from '~/stores/ui';
 import {
   IPageInfoAll, IPageWithMeta, isIPageInfoForEntity, isIPageInfoForListing,
 } from '~/interfaces/page';
@@ -35,6 +35,7 @@ export const PageListItemL = memo((props: Props): JSX.Element => {
 
   const { data: isDeviceSmallerThanLg } = useIsDeviceSmallerThanLg();
   const { open: openDuplicateModal } = usePageDuplicateModalStatus();
+  const { open: openRenameModal } = usePageRenameModalStatus();
 
   const elasticSearchResult = isIPageSearchMeta(pageMeta) ? pageMeta.elasticSearchResult : null;
   const revisionShortBody = isIPageInfoForListing(pageMeta) ? pageMeta.revisionShortBody : null;
@@ -63,6 +64,11 @@ export const PageListItemL = memo((props: Props): JSX.Element => {
     }
   };
 
+  const renameMenuItemClickHandler = useCallback(() => {
+    const { _id: pageId, revision: revisionId, path } = pageData;
+    openRenameModal(pageId, revisionId as string, path);
+  }, [openRenameModal, pageData]);
+
   const styleListGroupItem = (!isDeviceSmallerThanLg && onClickCheckbox != null) ? 'list-group-item-action' : '';
   // background color of list item changes when class "active" exists under 'list-group-item'
   const styleActive = !isDeviceSmallerThanLg && isSelected ? 'active' : '';
@@ -123,6 +129,7 @@ export const PageListItemL = memo((props: Props): JSX.Element => {
                   pageId={pageData._id}
                   pageInfo={pageMeta}
                   onClickDeleteMenuItem={props.onClickDeleteButton}
+                  onClickRenameMenuItem={renameMenuItemClickHandler}
                   isEnableActions={isEnableActions}
                   onClickDuplicateMenuItem={onClickDuplicateMenuItem}
                 />

+ 1 - 1
packages/app/src/components/SearchPage.jsx

@@ -352,7 +352,7 @@ class SearchPage extends React.Component {
           activePage={this.state.activePage}
         >
         </SearchPageLayout>
-        {/* TODO: show PageDeleteModal with usePageDeleteModalStatus by 87569  */}
+        {/* TODO: show PageDeleteModal with usePageDeleteModal by 87569  */}
         <PageDeleteModal
           isOpen={this.state.isDeleteConfirmModalShown}
           onClose={this.closeDeleteConfirmModalHandler}

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

@@ -14,6 +14,8 @@ import { GrowiSubNavigation } from '../Navbar/GrowiSubNavigation';
 import { SubNavButtons } from '../Navbar/SubNavButtons';
 import { AdditionalMenuItemsRendererProps } from '../Common/Dropdown/PageItemControl';
 
+import { usePageDuplicateModalStatus, usePageRenameModalStatus, usePageDeleteModal } from '~/stores/ui';
+
 
 type AdditionalMenuItemsProps = AdditionalMenuItemsRendererProps & {
   pageId: string,
@@ -53,10 +55,27 @@ const SearchResultContent: FC<Props> = (props: Props) => {
     showPageControlDropdown,
   } = props;
 
+  const { open: openDuplicateModal } = usePageDuplicateModalStatus();
+  const { open: openRenameModal } = usePageRenameModalStatus();
+  const { open: openDeleteModal } = usePageDeleteModal();
+
   const page = focusedSearchResultData?.pageData;
 
   const growiRenderer = appContainer.getRenderer('searchresult');
 
+
+  const duplicateItemClickedHandler = useCallback(async(pageId, path) => {
+    openDuplicateModal(pageId, path);
+  }, [openDuplicateModal]);
+
+  const renameItemClickedHandler = useCallback(async(pageId, revisionId, path) => {
+    openRenameModal(pageId, revisionId, path);
+  }, [openRenameModal]);
+
+  const deleteItemClickedHandler = useCallback(async(pageToDelete) => {
+    openDeleteModal([pageToDelete]);
+  }, [openDeleteModal]);
+
   const ControlComponents = useCallback(() => {
     if (page == null) {
       return <></>;
@@ -72,15 +91,19 @@ const SearchResultContent: FC<Props> = (props: Props) => {
           <SubNavButtons
             pageId={page._id}
             revisionId={revisionId}
+            path={page.path}
             showPageControlDropdown={showPageControlDropdown}
             additionalMenuItemRenderer={props => <AdditionalMenuItems {...props} pageId={page._id} revisionId={revisionId} />}
+            onClickDuplicateMenuItem={duplicateItemClickedHandler}
+            onClickRenameMenuItem={renameItemClickedHandler}
+            onClickDeleteMenuItem={deleteItemClickedHandler}
           />
         </div>
         <div className="h-50 d-flex flex-column align-items-end justify-content-center">
         </div>
       </>
     );
-  }, [page, showPageControlDropdown]);
+  }, [page, showPageControlDropdown, renameItemClickedHandler, deleteItemClickedHandler]);
 
   // return if page is null
   if (page == null) return <></>;

+ 67 - 13
packages/app/src/components/Sidebar/PageTree/Item.tsx

@@ -10,7 +10,7 @@ import nodePath from 'path';
 
 import { pathUtils } from '@growi/core';
 
-import { toastWarning, toastError } from '~/client/util/apiNotification';
+import { toastWarning, toastError, toastSuccess } from '~/client/util/apiNotification';
 
 import { useSWRxPageChildren } from '~/stores/page-listing';
 import { IPageForPageDeleteModal } from '~/stores/ui';
@@ -19,7 +19,7 @@ import { apiv3Put } from '~/client/util/apiv3-client';
 import TriangleIcon from '~/components/Icons/TriangleIcon';
 import { bookmark, unbookmark } from '~/client/services/page-operation';
 import ClosableTextInput, { AlertInfo, AlertType } from '../../Common/ClosableTextInput';
-import { AsyncPageItemControl } from '../../Common/Dropdown/PageItemControl';
+import { PageItemControl } from '../../Common/Dropdown/PageItemControl';
 import { ItemNode } from './ItemNode';
 
 interface ItemProps {
@@ -79,24 +79,78 @@ const Item: FC<ItemProps> = (props: ItemProps) => {
   const [currentChildren, setCurrentChildren] = useState(children);
   const [isOpen, setIsOpen] = useState(_isOpen);
   const [isNewPageInputShown, setNewPageInputShown] = useState(false);
+  const [shouldHide, setShouldHide] = useState(false);
   // const [isRenameInputShown, setRenameInputShown] = useState(false);
 
-  const { data, error } = useSWRxPageChildren(isOpen ? page._id : null);
+  const { data, mutate: mutateChildren } = useSWRxPageChildren(isOpen ? page._id : null);
 
-  const hasDescendants = (page.descendantCount != null && page?.descendantCount > 0);
+  // hasDescendants flag
+  const isChildrenLoaded = currentChildren?.length > 0;
+  const hasDescendants = (page.descendantCount != null && page?.descendantCount > 0) || isChildrenLoaded;
+
+  // to re-show hidden item when useDrag end() callback
+  const displayDroppedItemByPageId = useCallback((pageId) => {
+    const target = document.getElementById(`pagetree-item-${pageId}`);
+    if (target == null) {
+      return;
+    }
+
+    // wait 500ms to avoid removing before d-none is set by useDrag end() callback
+    setTimeout(() => {
+      target.classList.remove('d-none');
+    }, 500);
+  }, []);
 
   const [{ isDragging }, drag] = useDrag(() => ({
     type: 'PAGE_TREE',
     item: { page },
+    end: () => {
+      // in order to set d-none to dropped Item
+      setShouldHide(true);
+    },
     collect: monitor => ({
       isDragging: monitor.isDragging(),
     }),
   }));
 
-  const pageItemDropHandler = () => {
-    // TODO: hit an api to rename the page by 85175
-    // eslint-disable-next-line no-console
-    console.log('pageItem was droped!!');
+  const pageItemDropHandler = async(item, monitor) => {
+    if (page == null || page.path == null) {
+      return;
+    }
+
+    const { page: droppedPage } = item;
+
+    const pageTitle = nodePath.basename(droppedPage.path);
+    const newParentPath = page.path;
+    const newPagePath = nodePath.join(newParentPath, pageTitle);
+
+    try {
+      await apiv3Put('/pages/rename', {
+        pageId: droppedPage._id,
+        revisionId: droppedPage.revision,
+        newPagePath,
+        isRenameRedirect: false,
+        isRemainMetadata: false,
+      });
+
+      await mutateChildren();
+
+      // force open
+      setIsOpen(true);
+
+      toastSuccess('TODO: i18n Successfully moved pages.');
+    }
+    catch (err) {
+      // display the dropped item
+      displayDroppedItemByPageId(droppedPage._id);
+
+      if (err.code === 'operation__blocked') {
+        toastWarning('TODO: i18n You cannot move this page now.');
+      }
+      else {
+        toastError('TODO: i18n Something went wrong with moving page.');
+      }
+    }
   };
 
   const [{ isOver }, drop] = useDrop(() => ({
@@ -248,15 +302,15 @@ const Item: FC<ItemProps> = (props: ItemProps) => {
    * When swr fetch succeeded
    */
   useEffect(() => {
-    if (isOpen && error == null && data != null) {
+    if (isOpen && data != null) {
       const newChildren = ItemNode.generateNodesFromPages(data.children);
       markTarget(newChildren, targetPathOrId);
       setCurrentChildren(newChildren);
     }
-  }, [data, error, isOpen, targetPathOrId]);
+  }, [data, isOpen, targetPathOrId]);
 
   return (
-    <div className={`grw-pagetree-item-container ${isOver ? 'grw-pagetree-is-over' : ''}`}>
+    <div id={`pagetree-item-${page._id}`} className={`grw-pagetree-item-container ${isOver ? 'grw-pagetree-is-over' : ''} ${shouldHide ? 'd-none' : ''}`}>
       <li
         ref={(c) => { drag(c); drop(c) }}
         className={`list-group-item list-group-item-action border-0 py-1 d-flex align-items-center ${page.isTarget ? 'grw-pagetree-is-target' : ''}`}
@@ -296,7 +350,7 @@ const Item: FC<ItemProps> = (props: ItemProps) => {
           </div>
         )}
         <div className="grw-pagetree-control d-none">
-          <AsyncPageItemControl
+          <PageItemControl
             pageId={page._id}
             isEnableActions={isEnableActions}
             showBookmarkMenuItem
@@ -308,7 +362,7 @@ const Item: FC<ItemProps> = (props: ItemProps) => {
             <DropdownToggle color="transparent" className="border-0 rounded btn-page-item-control p-0">
               <i className="icon-options fa fa-rotate-90 text-muted p-1"></i>
             </DropdownToggle>
-          </AsyncPageItemControl>
+          </PageItemControl>
           <button
             type="button"
             className="border-0 rounded btn-page-item-control p-0"

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

@@ -1,13 +1,15 @@
 import React, { FC, useEffect } from 'react';
+import { useTranslation } from 'react-i18next';
 
 import { IPageHasId } from '../../../interfaces/page';
 import { ItemNode } from './ItemNode';
 import Item from './Item';
 import { useSWRxPageAncestorsChildren, useSWRxRootPage } from '../../../stores/page-listing';
 import { TargetAndAncestors } from '~/interfaces/page-listing-results';
-import { toastError } from '~/client/util/apiNotification';
+import { toastError, toastSuccess } from '~/client/util/apiNotification';
 import {
   IPageForPageDeleteModal, usePageDuplicateModalStatus, usePageRenameModalStatus, usePageDeleteModal,
+  OnDeletedFunction,
 } from '~/stores/ui';
 import { smoothScrollIntoView } from '~/client/util/smooth-scroll';
 
@@ -91,6 +93,8 @@ const ItemsTree: FC<ItemsTreeProps> = (props: ItemsTreeProps) => {
     targetPath, targetPathOrId, targetAndAncestorsData, isEnableActions,
   } = props;
 
+  const { t } = useTranslation();
+
   const { data: ancestorsChildrenData, error: error1 } = useSWRxPageAncestorsChildren(targetPath);
   const { data: rootPageData, error: error2 } = useSWRxRootPage();
   const { open: openDuplicateModal } = usePageDuplicateModalStatus();
@@ -114,8 +118,30 @@ const ItemsTree: FC<ItemsTreeProps> = (props: ItemsTreeProps) => {
     openRenameModal(pageId, revisionId, path);
   };
 
-  const onDeletedHandler = (pagePath) => {
-    window.location.href = encodeURI(pagePath);
+  const onDeletedHandler: OnDeletedFunction = (pathOrPathsToDelete, isRecursively, isCompletely) => {
+    if (typeof pathOrPathsToDelete !== 'string') {
+      return;
+    }
+
+    const path = pathOrPathsToDelete;
+
+    if (isRecursively) {
+      if (isCompletely) {
+        toastSuccess(t('deleted_single_page_recursively_completely', { path }));
+      }
+      else {
+        toastSuccess(t('deleted_single_page_recursively', { path }));
+      }
+    }
+    else {
+      // eslint-disable-next-line no-lonely-if
+      if (isCompletely) {
+        toastSuccess(t('deleted_single_page_completely', { path }));
+      }
+      else {
+        toastSuccess(t('deleted_single_page', { path }));
+      }
+    }
   };
 
   const onClickDeleteMenuItem = (pageToDelete: IPageForPageDeleteModal) => {

+ 2 - 0
packages/app/src/interfaces/common.ts

@@ -7,3 +7,5 @@ import { HasObjectId } from './has-object-id';
 
 // Foreign key field
 export type Ref<T> = string | T & HasObjectId;
+
+export type Nullable<T> = T | null | undefined;

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

@@ -1,4 +1,4 @@
-import { Ref } from './common';
+import { Ref, Nullable } from './common';
 import { IUser } from './user';
 import { IRevision, HasRevisionShortbody } from './revision';
 import { ITag } from './tag';
@@ -98,7 +98,15 @@ export type IPageWithMeta<M = IPageInfoAll> = {
   pageMeta?: M,
 };
 
-export type IPageApiv1Result = {
+export type IDeleteSinglePageApiv1Result = {
   ok: boolean
-  page: IPageHasId,
+  path: string,
+  isRecursively: Nullable<true>,
+  isCompletely: Nullable<true>,
+};
+
+export type IDeleteManyPageApiv3Result = {
+  paths: string[],
+  isRecursively: Nullable<true>,
+  isCompletely: Nullable<true>,
 };

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

@@ -738,11 +738,11 @@ export const getPageSchema = (crowi) => {
     return await findListFromBuilderAndViewer(builder, currentUser, showAnyoneKnowsLink, opt);
   };
 
-  pageSchema.statics.findListByPageIds = async function(ids, option) {
+  pageSchema.statics.findListByPageIds = async function(ids, option = {}, shouldIncludeEmpty = false) {
     const User = crowi.model('User');
 
     const opt = Object.assign({}, option);
-    const builder = new PageQueryBuilder(this.find({ _id: { $in: ids } }));
+    const builder = new PageQueryBuilder(this.find({ _id: { $in: ids } }), shouldIncludeEmpty);
 
     builder.addConditionToPagenate(opt.offset, opt.limit);
 

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

@@ -567,6 +567,16 @@ schema.statics.removeLeafEmptyPagesById = async function(pageId: ObjectIdLike):
   await this.deleteMany({ _id: { $in: pageIdsToRemove } });
 };
 
+schema.statics.findByPageIdsToEdit = async function(ids, user, shouldIncludeEmpty = false) {
+  const builder = new PageQueryBuilder(this.find({ _id: { $in: ids } }), shouldIncludeEmpty);
+
+  await this.addConditionToFilteringByViewerToEdit(builder, user);
+
+  const pages = await builder.query.lean().exec();
+
+  return pages;
+};
+
 export type PageCreateOptions = {
   format?: string
   grantUserGroupId?: ObjectIdLike

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

@@ -18,6 +18,7 @@ const { isCreatablePage } = pagePathUtils;
 const router = express.Router();
 
 const LIMIT_FOR_LIST = 10;
+const LIMIT_FOR_MULTIPLE_PAGE_OP = 20;
 
 /**
  * @swagger
@@ -180,6 +181,17 @@ module.exports = (crowi) => {
       body('pageNameInput').trim().isLength({ min: 1 }).withMessage('pageNameInput is required'),
       body('isRecursively').if(value => value != null).isBoolean().withMessage('isRecursively must be boolean'),
     ],
+    deletePages: [
+      body('pageIdToRevisionIdMap')
+        .exists()
+        .withMessage('The body property "pageIdToRevisionIdMap" must be an json map with pageId as key and revisionId as value.'),
+      body('isCompletely')
+        .custom(v => v === 'true' || v === true || v == null)
+        .withMessage('The body property "isCompletely" must be "true" or true. (Omit param for false)'),
+      body('isRecursively')
+        .custom(v => v === 'true' || v === true || v == null)
+        .withMessage('The body property "isRecursively" must be "true" or true. (Omit param for false)'),
+    ],
     legacyPagesMigration: [
       body('pageIds').isArray().withMessage('pageIds is required'),
       body('isRecursively').isBoolean().withMessage('isRecursively is required'),
@@ -629,7 +641,8 @@ module.exports = (crowi) => {
 
     const page = await Page.findByIdAndViewerToEdit(pageId, req.user, true);
 
-    if (page == null) {
+    const isEmptyAndNotRecursively = page?.isEmpty && isRecursively;
+    if (page == null || isEmptyAndNotRecursively) {
       res.code = 'Page is not found';
       logger.error('Failed to find the pages');
       return res.apiv3Err(new ErrorV3(`Page '${pageId}' is not found or forbidden`, 'notfound_or_forbidden'), 401);
@@ -708,6 +721,51 @@ module.exports = (crowi) => {
 
   });
 
+  router.post('/delete', accessTokenParser, loginRequiredStrictly, csrf, validator.deletePages, apiV3FormValidator, async(req, res) => {
+    const { pageIdToRevisionIdMap, isCompletely, isRecursively } = req.body;
+    const pageIds = Object.keys(pageIdToRevisionIdMap);
+
+    if (pageIds.length === 0) {
+      return res.apiv3Err(new ErrorV3('Select pages to delete.', 'no_page_selected'), 400);
+    }
+    if (pageIds.length > LIMIT_FOR_MULTIPLE_PAGE_OP) {
+      return res.apiv3Err(new ErrorV3(`The maximum number of pages you can select is ${LIMIT_FOR_MULTIPLE_PAGE_OP}.`, 'exceeded_maximum_number'), 400);
+    }
+
+    let pagesToDelete;
+    try {
+      pagesToDelete = await Page.findByPageIdsToEdit(pageIds, req.user, true);
+    }
+    catch (err) {
+      logger.error('Failed to find pages to delete.', err);
+      return res.apiv3Err(new ErrorV3('Failed to find pages to delete.'));
+    }
+
+    let pagesCanBeDeleted;
+    /*
+     * Delete Completely
+     */
+    if (isCompletely) {
+      pagesCanBeDeleted = crowi.pageService.filterPagesByCanDeleteCompletely(pagesToDelete, req.user);
+    }
+    /*
+     * Trash
+     */
+    else {
+      pagesCanBeDeleted = pagesToDelete.filter(p => p.isEmpty || p.isUpdatable(pageIdToRevisionIdMap[p._id].toString()));
+    }
+
+    if (pagesCanBeDeleted.length === 0) {
+      const msg = 'No pages can be deleted.';
+      return res.apiv3Err(new ErrorV3(msg), 500);
+    }
+
+    // run delete
+    crowi.pageService.deleteMultiplePages(pagesCanBeDeleted, req.user, isCompletely, isRecursively);
+
+    return res.apiv3({ paths: pagesCanBeDeleted.map(p => p.path), isRecursively, isCompletely });
+  });
+
   router.post('/v5-schema-migration', accessTokenParser, loginRequired, adminRequired, csrf, async(req, res) => {
     const isV5Compatible = crowi.configManager.getConfig('crowi', 'app:isV5Compatible');
 
@@ -726,11 +784,16 @@ module.exports = (crowi) => {
 
   // eslint-disable-next-line max-len
   router.post('/legacy-pages-migration', accessTokenParser, loginRequired, adminRequired, csrf, validator.legacyPagesMigration, apiV3FormValidator, async(req, res) => {
-    const { pageIds, isRecursively } = req.body;
+    const { pageIds: _pageIds, isRecursively } = req.body;
+    const pageIds = _pageIds == null ? [] : _pageIds;
+
+    if (pageIds.length > LIMIT_FOR_MULTIPLE_PAGE_OP) {
+      return res.apiv3Err(new ErrorV3(`The maximum number of pages you can select is ${LIMIT_FOR_MULTIPLE_PAGE_OP}.`, 'exceeded_maximum_number'), 400);
+    }
 
     if (isRecursively) {
       // this method innerly uses socket to send message
-      crowi.pageService.normalizeParentRecursivelyByPageIds(pageIds);
+      crowi.pageService.normalizeParentRecursivelyByPageIds(pageIds, req.user);
     }
     else {
       try {

+ 13 - 8
packages/app/src/server/routes/page.js

@@ -1160,8 +1160,12 @@ module.exports = function(crowi, app) {
   };
 
   validator.remove = [
-    body('completely').optional().custom(v => v === 'true' || v === true).withMessage('The body property "completely" must be "true" or true.'),
-    body('recursively').optional().custom(v => v === 'true' || v === true).withMessage('The body property "recursively" must be "true" or true.'),
+    body('completely')
+      .custom(v => v === 'true' || v === true || v == null)
+      .withMessage('The body property "completely" must be "true" or true. (Omit param for false)'),
+    body('recursively')
+      .custom(v => v === 'true' || v === true || v == null)
+      .withMessage('The body property "recursively" must be "true" or true. (Omit param for false)'),
   ];
 
   /**
@@ -1176,10 +1180,7 @@ module.exports = function(crowi, app) {
     const pageId = req.body.page_id;
     const previousRevision = req.body.revision_id || null;
 
-    // get completely flag
-    const isCompletely = req.body.completely;
-    // get recursively flag
-    const isRecursively = req.body.recursively;
+    const { recursively: isRecursively, completely: isCompletely } = req.body;
 
     const options = {};
 
@@ -1219,7 +1220,9 @@ module.exports = function(crowi, app) {
 
     debug('Page deleted', page.path);
     const result = {};
-    result.page = page; // TODO consider to use serializePageSecurely method -- 2018.08.06 Yuki Takei
+    result.path = page.path;
+    result.isRecursively = isRecursively;
+    result.isCompletely = isCompletely;
 
     res.json(ApiResponse.success(result));
 
@@ -1233,7 +1236,9 @@ module.exports = function(crowi, app) {
   };
 
   validator.revertRemove = [
-    body('recursively').optional().custom(v => v === 'true' || v === true).withMessage('The body property "recursively" must be "true" or true.'),
+    body('recursively')
+      .custom(v => v === 'true' || v === true || null)
+      .withMessage('The body property "recursively" must be "true" or true. (Omit param for false)'),
   ];
 
   /**

+ 31 - 5
packages/app/src/server/service/page.ts

@@ -26,10 +26,11 @@ const debug = require('debug')('growi:services:page');
 
 const logger = loggerFactory('growi:services:page');
 const {
-  isCreatablePage, isTrashPage, isTopPage, isDeletablePage, omitDuplicateAreaPathFromPaths,
+  isCreatablePage, isTrashPage, isTopPage, isDeletablePage, omitDuplicateAreaPathFromPaths, omitDuplicateAreaPageFromPages,
 } = pagePathUtils;
 
 const BULK_REINDEX_SIZE = 100;
+const LIMIT_FOR_MULTIPLE_PAGE_OP = 20;
 
 // TODO: improve type
 class PageCursorsForDescendantsFactory {
@@ -208,6 +209,10 @@ class PageService {
     return false;
   }
 
+  filterPagesByCanDeleteCompletely(pages, user) {
+    return pages.filter(p => p.isEmpty || this.canDeleteCompletely(p.creator, user));
+  }
+
   async findPageAndMetaDataByViewer({ pageId, path, user }) {
 
     const Page = this.crowi.model('Page');
@@ -1430,6 +1435,28 @@ class PageService {
     return nDeletedNonEmptyPages;
   }
 
+  async deleteMultiplePages(pagesToDelete, user, isCompletely: boolean, isRecursively: boolean): Promise<void> {
+    if (pagesToDelete.length > LIMIT_FOR_MULTIPLE_PAGE_OP) {
+      throw Error(`The maximum number of pages is ${LIMIT_FOR_MULTIPLE_PAGE_OP}.`);
+    }
+
+    // omit duplicate paths if isRecursively true, omit empty pages if isRecursively false
+    const pages = isRecursively ? omitDuplicateAreaPageFromPages(pagesToDelete) : pagesToDelete.filter(p => !p.isEmpty);
+
+    // TODO: insertMany PageOperationBlock if isRecursively true
+
+    if (isCompletely) {
+      for await (const page of pages) {
+        await this.deleteCompletely(page, user, {}, isRecursively);
+      }
+    }
+    else {
+      for await (const page of pages) {
+        await this.deletePage(page, user, {}, isRecursively);
+      }
+    }
+  }
+
   // use the same process in both v4 and v5
   private async revertDeletedDescendants(pages, user) {
     const Page = this.crowi.model('Page');
@@ -1831,7 +1858,7 @@ class PageService {
     return Page.updateOne({ _id: pageId }, { parent: parent._id });
   }
 
-  async normalizeParentRecursivelyByPageIds(pageIds) {
+  async normalizeParentRecursivelyByPageIds(pageIds, user) {
     if (pageIds == null || pageIds.length === 0) {
       logger.error('pageIds is null or 0 length.');
       return;
@@ -1854,15 +1881,14 @@ class PageService {
      */
     const Page = mongoose.model('Page') as unknown as PageModel;
 
-    let result;
+    let pages;
     try {
-      result = await Page.findListByPageIds(pageIds, null, false);
+      pages = await Page.findByPageIdsToEdit(pageIds, user, false);
     }
     catch (err) {
       logger.error('Failed to find pages by ids', err);
       throw err;
     }
-    const { pages } = result;
 
     // prepare no duplicated area paths
     let paths = pages.map(p => p.path);

+ 12 - 9
packages/app/src/stores/ui.tsx

@@ -4,6 +4,7 @@ import useSWR, {
 import useSWRImmutable from 'swr/immutable';
 
 import { Breakpoint, addBreakpointListener } from '@growi/ui';
+import { pagePathUtils } from '@growi/core';
 
 import { RefObject } from 'react';
 import { SidebarContentsType } from '~/interfaces/ui';
@@ -15,14 +16,14 @@ import {
   useIsNotCreatable, useIsSharedUser, useNotFoundTargetPathOrId, useIsForbidden, useIsIdenticalPath,
 } from './context';
 import { IFocusable } from '~/client/interfaces/focusable';
-import { isSharedPage } from '^/../core/src/utils/page-path-utils';
+import { Nullable } from '~/interfaces/common';
+
+const { isSharedPage } = pagePathUtils;
 
 const logger = loggerFactory('growi:stores:ui');
 
 const isServer = typeof window === 'undefined';
 
-type Nullable<T> = T | null;
-
 
 /** **********************************************************
  *                          Unions
@@ -299,24 +300,26 @@ export const useCreateModalPath = (): SWRResponse<string | null | undefined, Err
 // PageDeleteModal
 export type IPageForPageDeleteModal = {
   pageId: string,
-  revisionId: string,
+  revisionId?: string,
   path: string
 }
 
+export type OnDeletedFunction = (pathOrPaths: string | string[], isRecursively: Nullable<true>, isCompletely: Nullable<true>) => void;
+
 type DeleteModalStatus = {
   isOpened: boolean,
   pages?: IPageForPageDeleteModal[],
-  onDeleted?: (pagePath: string) => void,
+  onDeleted?: OnDeletedFunction,
 }
 
 type DeleteModalOpened = {
   isOpend: boolean,
-  onDeleted?: (pagePath: string) => void,
+  onDeleted?: OnDeletedFunction,
 }
 
 type DeleteModalStatusUtils = {
-  open(pages?: IPageForPageDeleteModal[], onDeleted?: (pagePath: string) => void): Promise<DeleteModalStatus | undefined>
-  close(): Promise<DeleteModalStatus | undefined>
+  open(pages?: IPageForPageDeleteModal[], onDeleted?: OnDeletedFunction): Promise<DeleteModalStatus | undefined>,
+  close(): Promise<DeleteModalStatus | undefined>,
 }
 
 export const usePageDeleteModal = (status?: DeleteModalStatus): SWRResponse<DeleteModalStatus, Error> & DeleteModalStatusUtils => {
@@ -325,7 +328,7 @@ export const usePageDeleteModal = (status?: DeleteModalStatus): SWRResponse<Dele
 
   return {
     ...swrResponse,
-    open: (pages?: IPageForPageDeleteModal[], onDeleted?:(pagePath: string) => void) => swrResponse.mutate({ isOpened: true, pages, onDeleted }),
+    open: (pages?: IPageForPageDeleteModal[], onDeleted?: OnDeletedFunction) => swrResponse.mutate({ isOpened: true, pages, onDeleted }),
     close: () => swrResponse.mutate({ isOpened: false }),
   };
 };

+ 1 - 1
packages/app/test/integration/service/v5-migration.test.js

@@ -59,7 +59,7 @@ describe('V5 page migration', () => {
 
       const pageIds = pages.map(page => page._id);
       // migrate
-      await crowi.pageService.normalizeParentRecursivelyByPageIds(pageIds);
+      await crowi.pageService.normalizeParentRecursivelyByPageIds(pageIds, testUser1);
 
       const migratedPages = await Page.find({
         path: {

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

@@ -175,3 +175,16 @@ export const omitDuplicateAreaPathFromPaths = (paths: string[]): string[] => {
     return !isDuplicate;
   });
 };
+
+/**
+ * return pages with path without duplicate area of regexp /^${path}\/.+/i
+ * @param paths paths to be tested
+ * @returns omitted paths
+ */
+export const omitDuplicateAreaPageFromPages = (pages: any[]): any[] => {
+  return pages.filter((page) => {
+    const isDuplicate = pages.filter(p => (new RegExp(`^${p.path}\\/.+`, 'i')).test(page.path)).length > 0;
+
+    return !isDuplicate;
+  });
+};