Explorar o código

Merge branch 'master' into fix/89241-custom-sidebar

Shun Miyazawa %!s(int64=4) %!d(string=hai) anos
pai
achega
96d6f71951
Modificáronse 32 ficheiros con 824 adicións e 553 borrados
  1. 1 0
      packages/app/resource/locales/en_US/translation.json
  2. 1 0
      packages/app/resource/locales/ja_JP/translation.json
  3. 2 1
      packages/app/resource/locales/zh_CN/translation.json
  4. 3 1
      packages/app/src/components/Common/ClosableTextInput.tsx
  5. 29 4
      packages/app/src/components/DescendantsPageList.tsx
  6. 4 1
      packages/app/src/components/Page/TrashPageAlert.jsx
  7. 4 2
      packages/app/src/components/PageDeleteModal.tsx
  8. 9 2
      packages/app/src/components/PageList/PageList.tsx
  9. 10 6
      packages/app/src/components/PageList/PageListItemL.tsx
  10. 12 19
      packages/app/src/components/PutbackPageModal.jsx
  11. 10 3
      packages/app/src/components/SearchPage/SearchResultContent.tsx
  12. 40 2
      packages/app/src/components/SearchPage/SearchResultList.tsx
  13. 1 0
      packages/app/src/interfaces/ui.ts
  14. 17 319
      packages/app/src/server/models/obsolete-page.js
  15. 348 67
      packages/app/src/server/models/page.ts
  16. 2 0
      packages/app/src/server/routes/apiv3/pages.js
  17. 4 2
      packages/app/src/server/routes/page.js
  18. 1 1
      packages/app/src/server/service/page-grant.ts
  19. 79 59
      packages/app/src/server/service/page.ts
  20. 27 8
      packages/app/src/stores/modal.tsx
  21. 3 1
      packages/app/test/cypress/integration/1-install/install.spec.ts
  22. 13 13
      packages/app/test/cypress/integration/2-basic-features/access-to-admin-page.spec.ts
  23. 2 2
      packages/app/test/cypress/integration/2-basic-features/access-to-me-page.spec.ts
  24. 9 7
      packages/app/test/cypress/integration/2-basic-features/access-to-page.spec.ts
  25. 2 2
      packages/app/test/cypress/integration/2-basic-features/access-to-special-page.spec.ts
  26. 3 3
      packages/app/test/cypress/integration/2-basic-features/open-presentation-modal.spec.ts
  27. 1 1
      packages/app/test/cypress/integration/3-search/access-to-private-legacy-pages-directly.spec.ts
  28. 9 9
      packages/app/test/cypress/integration/3-search/access-to-result-page-directly.spec.ts
  29. 2 1
      packages/app/test/cypress/support/screenshot.ts
  30. 4 7
      packages/app/test/integration/service/page.test.js
  31. 1 6
      packages/app/test/integration/service/v5.migration.test.js
  32. 171 4
      packages/app/test/integration/service/v5.page.test.ts

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

@@ -424,6 +424,7 @@
   },
   "Put Back": "Put back",
   "Delete Completely": "Delete completely",
+  "page_has_been_reverted": "{{path}} has been reverted",
   "modal_delete": {
     "delete_page": "Delete page",
     "deleting_page": "Deleting page",

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

@@ -423,6 +423,7 @@
   },
   "Put Back": "元に戻す",
   "Delete Completely": "完全削除",
+  "page_has_been_reverted": "{{path}} を元に戻しました",
   "modal_delete": {
     "delete_page": "ページを削除する",
     "deleting_page": "ページパス",

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

@@ -401,7 +401,8 @@
 		}
 	},
 	"Put Back": "Put back",
-	"Delete Completely": "Delete completely",
+  "Delete Completely": "Delete completely",
+  "page_has_been_reverted": "{{path}} 已还原",
 	"modal_delete": {
 		"delete_page": "Delete page",
 		"deleting_page": "Deleting page",

+ 3 - 1
packages/app/src/components/Common/ClosableTextInput.tsx

@@ -30,6 +30,7 @@ const ClosableTextInput: FC<ClosableTextInputProps> = memo((props: ClosableTextI
 
   const [inputText, setInputText] = useState(props.value);
   const [currentAlertInfo, setAlertInfo] = useState<AlertInfo | null>(null);
+  const [isAbleToShowAlert, setIsAbleToShowAlert] = useState<boolean>(false);
 
   const createValidation = async(inputText: string) => {
     if (props.inputValidator != null) {
@@ -42,6 +43,7 @@ const ClosableTextInput: FC<ClosableTextInputProps> = memo((props: ClosableTextI
     const inputText = e.target.value;
     createValidation(inputText);
     setInputText(inputText);
+    setIsAbleToShowAlert(true);
   };
 
   const onFocusHandler = async(e: React.ChangeEvent<HTMLInputElement>) => {
@@ -119,7 +121,7 @@ const ClosableTextInput: FC<ClosableTextInputProps> = memo((props: ClosableTextI
         onBlur={onBlurHandler}
         autoFocus={false}
       />
-      <AlertInfo />
+      {isAbleToShowAlert && <AlertInfo />}
     </div>
   );
 });

+ 29 - 4
packages/app/src/components/DescendantsPageList.tsx

@@ -7,11 +7,14 @@ import {
   IPageInfoForOperation,
 } from '~/interfaces/page';
 import { IPagingResult } from '~/interfaces/paging-result';
-import { OnDeletedFunction } from '~/interfaces/ui';
-import { useIsGuestUser, useIsSharedUser } from '~/stores/context';
+import { OnDeletedFunction, OnPutBackedFunction } from '~/interfaces/ui';
+import { useIsGuestUser, useIsSharedUser, useIsTrashPage } from '~/stores/context';
 
-import { useSWRxDescendantsPageListForCurrrentPath, useSWRxPageInfoForList, useSWRxPageList } from '~/stores/page';
+import {
+  useSWRxDescendantsPageListForCurrrentPath, useSWRxPageInfoForList, useSWRxPageList, useDescendantsPageListForCurrentPathTermManager,
+} from '~/stores/page';
 import { usePageTreeTermManager } from '~/stores/page-listing';
+import { ForceHideMenuItems, MenuItemType } from './Common/Dropdown/PageItemControl';
 
 import PageList from './PageList/PageList';
 import PaginationWrapper from './PaginationWrapper';
@@ -21,7 +24,9 @@ type SubstanceProps = {
   pagingResult: IPagingResult<IPageHasId> | undefined,
   activePage: number,
   setActivePage: (activePage: number) => void,
+  forceHideMenuItems?: ForceHideMenuItems,
   onPagesDeleted?: OnDeletedFunction,
+  onPagePutBacked?: OnPutBackedFunction,
 }
 
 const convertToIDataWithMeta = (page: IPageHasId): IDataWithMeta<IPageHasId> => {
@@ -33,7 +38,7 @@ export const DescendantsPageListSubstance = (props: SubstanceProps): JSX.Element
   const { t } = useTranslation();
 
   const {
-    pagingResult, activePage, setActivePage, onPagesDeleted,
+    pagingResult, activePage, setActivePage, forceHideMenuItems, onPagesDeleted, onPagePutBacked,
   } = props;
 
   const { data: isGuestUser } = useIsGuestUser();
@@ -45,6 +50,7 @@ export const DescendantsPageListSubstance = (props: SubstanceProps): JSX.Element
 
   // for mutation
   const { advance: advancePt } = usePageTreeTermManager();
+  const { advance: advanceDpl } = useDescendantsPageListForCurrentPathTermManager();
 
   // initial data
   if (pagingResult != null) {
@@ -64,6 +70,17 @@ export const DescendantsPageListSubstance = (props: SubstanceProps): JSX.Element
     }
   }, [advancePt, onPagesDeleted, t]);
 
+  const pagePutBackedHandler: OnPutBackedFunction = useCallback((path) => {
+    toastSuccess(t('page_has_been_reverted', { path }));
+
+    advancePt();
+    advanceDpl();
+
+    if (onPagePutBacked != null) {
+      onPagePutBacked(path);
+    }
+  }, [advanceDpl, advancePt, onPagePutBacked, t]);
+
   function setPageNumber(selectedPageNumber) {
     setActivePage(selectedPageNumber);
   }
@@ -85,7 +102,9 @@ export const DescendantsPageListSubstance = (props: SubstanceProps): JSX.Element
       <PageList
         pages={pageWithMetas}
         isEnableActions={!isGuestUser}
+        forceHideMenuItems={forceHideMenuItems}
         onPagesDeleted={pageDeletedHandler}
+        onPagePutBacked={pagePutBackedHandler}
       />
 
       { showPager && (
@@ -130,6 +149,7 @@ export const DescendantsPageList = (props: Props): JSX.Element => {
       activePage={activePage}
       setActivePage={setActivePage}
       onPagesDeleted={() => mutate()}
+      onPagePutBacked={() => mutate()}
     />
   );
 };
@@ -137,6 +157,8 @@ export const DescendantsPageList = (props: Props): JSX.Element => {
 export const DescendantsPageListForCurrentPath = (): JSX.Element => {
 
   const [activePage, setActivePage] = useState(1);
+
+  const { data: isTrashPage } = useIsTrashPage();
   const { data: pagingResult, error, mutate } = useSWRxDescendantsPageListForCurrrentPath(activePage);
 
   if (error != null) {
@@ -147,11 +169,14 @@ export const DescendantsPageListForCurrentPath = (): JSX.Element => {
     );
   }
 
+  const forceHideMenuItems = isTrashPage ? [MenuItemType.RENAME] : undefined;
+
   return (
     <DescendantsPageListSubstance
       pagingResult={pagingResult}
       activePage={activePage}
       setActivePage={setActivePage}
+      forceHideMenuItems={forceHideMenuItems}
       onPagesDeleted={() => mutate()}
     />
   );

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

@@ -51,7 +51,10 @@ const TrashPageAlert = (props) => {
   }
 
   function openPutbackPageModalHandler() {
-    openPutBackPageModal(pageId, path);
+    const putBackedHandler = (path) => {
+      window.location.reload();
+    };
+    openPutBackPageModal({ pageId, path }, { onPutBacked: putBackedHandler });
   }
 
   function openPageDeleteModalHandler() {

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

@@ -208,8 +208,10 @@ const PageDeleteModal: FC = () => {
   }
 
   const renderPagePathsToDelete = () => {
-    if (injectedPages != null && injectedPages != null) {
-      return injectedPages.map(page => (
+    const pages = injectedPages != null && injectedPages.length > 0 ? injectedPages : deleteModalData?.pages;
+
+    if (pages != null) {
+      return pages.map(page => (
         <div key={page.data._id}>
           <code>{ page.data.path }</code>
           { !page.meta?.isDeletable && <span className="ml-3 text-danger"><strong>(CAN NOT TO DELETE)</strong></span> }

+ 9 - 2
packages/app/src/components/PageList/PageList.tsx

@@ -2,7 +2,8 @@ import React from 'react';
 import { useTranslation } from 'react-i18next';
 
 import { IPageWithMeta } from '~/interfaces/page';
-import { OnDeletedFunction } from '~/interfaces/ui';
+import { OnDeletedFunction, OnPutBackedFunction } from '~/interfaces/ui';
+import { ForceHideMenuItems } from '../Common/Dropdown/PageItemControl';
 
 import { PageListItemL } from './PageListItemL';
 
@@ -10,12 +11,16 @@ import { PageListItemL } from './PageListItemL';
 type Props = {
   pages: IPageWithMeta[],
   isEnableActions?: boolean,
+  forceHideMenuItems?: ForceHideMenuItems,
   onPagesDeleted?: OnDeletedFunction,
+  onPagePutBacked?: OnPutBackedFunction,
 }
 
 const PageList = (props: Props): JSX.Element => {
   const { t } = useTranslation();
-  const { pages, isEnableActions, onPagesDeleted } = props;
+  const {
+    pages, isEnableActions, forceHideMenuItems, onPagesDeleted, onPagePutBacked,
+  } = props;
 
   if (pages == null) {
     return (
@@ -32,7 +37,9 @@ const PageList = (props: Props): JSX.Element => {
       key={page.data._id}
       page={page}
       isEnableActions={isEnableActions}
+      forceHideMenuItems={forceHideMenuItems}
       onPageDeleted={onPagesDeleted}
+      onPagePutBacked={onPagePutBacked}
     />
   ));
 

+ 10 - 6
packages/app/src/components/PageList/PageListItemL.tsx

@@ -23,7 +23,9 @@ import {
   IPageInfoAll, IPageInfoForEntity, IPageInfoForListing, IPageWithMeta, isIPageInfoForListing,
 } from '~/interfaces/page';
 import { IPageSearchMeta, isIPageSearchMeta } from '~/interfaces/search';
-import { OnDuplicatedFunction, OnDeletedFunction } from '~/interfaces/ui';
+import {
+  OnDuplicatedFunction, OnRenamedFunction, OnDeletedFunction, OnPutBackedFunction,
+} from '~/interfaces/ui';
 import LinkedPagePath from '~/models/linked-page-path';
 
 import { ForceHideMenuItems, PageItemControl } from '../Common/Dropdown/PageItemControl';
@@ -38,7 +40,9 @@ type Props = {
   onCheckboxChanged?: (isChecked: boolean, pageId: string) => void,
   onClickItem?: (pageId: string) => void,
   onPageDuplicated?: OnDuplicatedFunction,
+  onPageRenamed?: OnRenamedFunction,
   onPageDeleted?: OnDeletedFunction,
+  onPagePutBacked?: OnPutBackedFunction,
 }
 
 const PageListItemLSubstance: ForwardRefRenderFunction<ISelectable, Props> = (props: Props, ref): JSX.Element => {
@@ -47,7 +51,7 @@ const PageListItemLSubstance: ForwardRefRenderFunction<ISelectable, Props> = (pr
     page: { data: pageData, meta: pageMeta }, isSelected, isEnableActions,
     forceHideMenuItems,
     showPageUpdatedTime,
-    onClickItem, onCheckboxChanged, onPageDuplicated, onPageDeleted,
+    onClickItem, onCheckboxChanged, onPageDuplicated, onPageRenamed, onPageDeleted, onPagePutBacked,
   } = props;
 
   const inputRef = useRef<HTMLInputElement>(null);
@@ -115,8 +119,8 @@ const PageListItemLSubstance: ForwardRefRenderFunction<ISelectable, Props> = (pr
       revisionId: pageData.revision as string,
       path: pageData.path,
     };
-    openRenameModal(page);
-  }, [openRenameModal, pageData]);
+    openRenameModal(page, { onRenamed: onPageRenamed });
+  }, [onPageRenamed, openRenameModal, pageData._id, pageData.path, pageData.revision]);
 
 
   const deleteMenuItemClickHandler = useCallback((_id: string, pageInfo: IPageInfoAll | undefined) => {
@@ -128,8 +132,8 @@ const PageListItemLSubstance: ForwardRefRenderFunction<ISelectable, Props> = (pr
 
   const revertMenuItemClickHandler = useCallback(() => {
     const { _id: pageId, path } = pageData;
-    openPutBackPageModal(pageId, path);
-  }, [openPutBackPageModal, pageData]);
+    openPutBackPageModal({ pageId, path }, { onPutBacked: onPagePutBacked });
+  }, [onPagePutBacked, openPutBackPageModal, pageData]);
 
   const styleListGroupItem = (!isDeviceSmallerThanLg && onClickItem != null) ? 'list-group-item-action' : '';
   // background color of list item changes when class "active" exists under 'list-group-item'

+ 12 - 19
packages/app/src/components/PutbackPageModal.jsx

@@ -1,24 +1,23 @@
 import React, { useState } from 'react';
-import PropTypes from 'prop-types';
 
 import {
   Modal, ModalHeader, ModalBody, ModalFooter,
 } from 'reactstrap';
 
-import { withTranslation } from 'react-i18next';
+import { useTranslation } from 'react-i18next';
 
 import { usePutBackPageModal } from '~/stores/modal';
 import { apiPost } from '~/client/util/apiv1-client';
 
 import ApiErrorMessageList from './PageManagement/ApiErrorMessageList';
 
-const PutBackPageModal = (props) => {
-  const {
-    t,
-  } = props;
+const PutBackPageModal = () => {
+  const { t } = useTranslation();
 
   const { data: pageDataToRevert, close: closePutBackPageModal } = usePutBackPageModal();
-  const { isOpened, pageId, path } = pageDataToRevert;
+  const { isOpened, page } = pageDataToRevert;
+  const { pageId, path } = page;
+  const onPutBacked = pageDataToRevert.opts?.onPutBacked;
 
   const [errs, setErrs] = useState(null);
 
@@ -28,7 +27,7 @@ const PutBackPageModal = (props) => {
     setIsPutbackRecursively(!isPutbackRecursively);
   }
 
-  async function putbackPage() {
+  async function putbackPageButtonHandler() {
     setErrs(null);
 
     try {
@@ -41,17 +40,16 @@ const PutBackPageModal = (props) => {
         recursively,
       });
 
-      const putbackPagePath = response.page.path;
-      window.location.href = encodeURI(putbackPagePath);
+      if (onPutBacked != null) {
+        onPutBacked(response.page.path);
+      }
+      closePutBackPageModal();
     }
     catch (err) {
       setErrs(err);
     }
   }
 
-  async function putbackPageButtonHandler() {
-    putbackPage();
-  }
 
   return (
     <Modal isOpen={isOpened} toggle={closePutBackPageModal} className="grw-create-page">
@@ -90,9 +88,4 @@ const PutBackPageModal = (props) => {
 
 };
 
-PutBackPageModal.propTypes = {
-  t: PropTypes.func.isRequired, //  i18next
-};
-
-
-export default withTranslation()(PutBackPageModal);
+export default PutBackPageModal;

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

@@ -7,7 +7,7 @@ import { DropdownItem } from 'reactstrap';
 
 import { IPageToDeleteWithMeta, IPageWithMeta } from '~/interfaces/page';
 import { IPageSearchMeta } from '~/interfaces/search';
-import { OnDuplicatedFunction, OnDeletedFunction } from '~/interfaces/ui';
+import { OnDuplicatedFunction, OnRenamedFunction, OnDeletedFunction } from '~/interfaces/ui';
 import { usePageTreeTermManager } from '~/stores/page-listing';
 import { useFullTextSearchTermManager } from '~/stores/search';
 import { useDescendantsPageListForCurrentPathTermManager } from '~/stores/page';
@@ -132,8 +132,15 @@ export const SearchResultContent: FC<Props> = (props: Props) => {
   }, [advanceDpl, advanceFts, advancePt, openDuplicateModal, t]);
 
   const renameItemClickedHandler = useCallback(async(pageToRename) => {
-    openRenameModal(pageToRename);
-  }, [openRenameModal]);
+    const renamedHandler: OnRenamedFunction = (path) => {
+      toastSuccess(t('renamed_pages', { path }));
+
+      advancePt();
+      advanceFts();
+      advanceDpl();
+    };
+    openRenameModal(pageToRename, { onRenamed: renamedHandler });
+  }, [advanceDpl, advanceFts, advancePt, openRenameModal, t]);
 
   const onDeletedHandler: OnDeletedFunction = useCallback((pathOrPathsToDelete, isRecursively, isCompletely) => {
     if (typeof pathOrPathsToDelete !== 'string') {

+ 40 - 2
packages/app/src/components/SearchPage/SearchResultList.tsx

@@ -2,11 +2,14 @@ import React, {
   forwardRef,
   ForwardRefRenderFunction, useCallback, useImperativeHandle, useRef,
 } from 'react';
+import { useTranslation } from 'react-i18next';
 import { ISelectable, ISelectableAll } from '~/client/interfaces/selectable-all';
+import { toastSuccess } from '~/client/util/apiNotification';
 import {
   IPageInfoForListing, IPageWithMeta, isIPageInfoForListing,
 } from '~/interfaces/page';
 import { IPageSearchMeta } from '~/interfaces/search';
+import { OnDuplicatedFunction, OnRenamedFunction, OnDeletedFunction } from '~/interfaces/ui';
 import { useIsGuestUser } from '~/stores/context';
 import { useSWRxPageInfoForList } from '~/stores/page';
 import { usePageTreeTermManager } from '~/stores/page-listing';
@@ -31,6 +34,8 @@ const SearchResultListSubstance: ForwardRefRenderFunction<ISelectableAll, Props>
     onPageSelected,
   } = props;
 
+  const { t } = useTranslation();
+
   const pageIdsWithNoSnippet = pages
     .filter(page => (page.meta?.elasticSearchResult?.snippet.length ?? 0) === 0)
     .map(page => page.data._id);
@@ -88,6 +93,38 @@ const SearchResultListSubstance: ForwardRefRenderFunction<ISelectableAll, Props>
     });
   }
 
+  // eslint-disable-next-line @typescript-eslint/no-unused-vars
+  const duplicatedHandler : OnDuplicatedFunction = (fromPath, toPath) => {
+    toastSuccess(t('duplicated_pages', { fromPath }));
+
+    advancePt();
+    advanceFts();
+  };
+
+  const renamedHandler: OnRenamedFunction = (path) => {
+    toastSuccess(t('renamed_pages', { path }));
+
+    advancePt();
+    advanceFts();
+  };
+  const deletedHandler: OnDeletedFunction = (pathOrPathsToDelete, isRecursively, isCompletely) => {
+    if (typeof pathOrPathsToDelete !== 'string') {
+      return;
+    }
+
+    const path = pathOrPathsToDelete;
+
+    if (isCompletely) {
+      toastSuccess(t('deleted_pages_completely', { path }));
+    }
+    else {
+      toastSuccess(t('deleted_pages', { path }));
+    }
+    advancePt();
+    advanceFts();
+  };
+
+
   return (
     <ul data-testid="search-result-list" className="page-list-ul list-group list-group-flush">
       { (injectedPages ?? pages).map((page, i) => {
@@ -102,8 +139,9 @@ const SearchResultListSubstance: ForwardRefRenderFunction<ISelectableAll, Props>
             forceHideMenuItems={forceHideMenuItems}
             onClickItem={clickItemHandler}
             onCheckboxChanged={props.onCheckboxChanged}
-            onPageDeleted={() => { advancePt(); advanceFts() }}
-            onPageDuplicated={() => { advancePt(); advanceFts() }}
+            onPageDuplicated={duplicatedHandler}
+            onPageRenamed={renamedHandler}
+            onPageDeleted={deletedHandler}
           />
         );
       })}

+ 1 - 0
packages/app/src/interfaces/ui.ts

@@ -24,3 +24,4 @@ export type ICustomNavTabMappings = { [key: string]: ICustomTabContent };
 export type OnDeletedFunction = (idOrPaths: string | string[], isRecursively: Nullable<true>, isCompletely: Nullable<true>) => void;
 export type OnRenamedFunction = (path: string) => void;
 export type OnDuplicatedFunction = (fromPath: string, toPath: string) => void;
+export type OnPutBackedFunction = (path: string) => void;

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

@@ -1,6 +1,8 @@
-import { templateChecker, pagePathUtils } from '@growi/core';
+import { templateChecker, pagePathUtils, pathUtils } from '@growi/core';
+
 import loggerFactory from '~/utils/logger';
 
+
 // disable no-return-await for model functions
 /* eslint-disable no-return-await */
 
@@ -12,7 +14,6 @@ const urljoin = require('url-join');
 const mongoose = require('mongoose');
 const differenceInYears = require('date-fns/differenceInYears');
 
-const { pathUtils } = require('@growi/core');
 const escapeStringRegexp = require('escape-string-regexp');
 
 const { isTopPage, isTrashPage } = pagePathUtils;
@@ -44,7 +45,7 @@ const pageSchema = {
  * @param {string} pagePath
  * @return {string[]} ancestors paths
  */
-const extractToAncestorsPaths = (pagePath) => {
+export const extractToAncestorsPaths = (pagePath) => {
   const ancestorsPaths = [];
 
   let parentPath;
@@ -62,7 +63,7 @@ const extractToAncestorsPaths = (pagePath) => {
  * @param {string} userPublicFields string to set to select
  */
 /* eslint-disable object-curly-newline, object-property-newline */
-const populateDataToShowRevision = (page, userPublicFields) => {
+export const populateDataToShowRevision = (page, userPublicFields) => {
   return page
     .populate([
       { path: 'lastUpdateUser', model: 'User', select: userPublicFields },
@@ -77,307 +78,6 @@ const populateDataToShowRevision = (page, userPublicFields) => {
 /* eslint-enable object-curly-newline, object-property-newline */
 
 
-export class PageQueryBuilder {
-
-  constructor(query, includeEmpty = false) {
-    this.query = query;
-    if (!includeEmpty) {
-      this.query = this.query
-        .and({
-          $or: [
-            { isEmpty: false },
-            { isEmpty: null }, // for v4 compatibility
-          ],
-        });
-    }
-  }
-
-  addConditionToExcludeTrashed() {
-    this.query = this.query
-      .and({
-        $or: [
-          { status: null },
-          { status: STATUS_PUBLISHED },
-        ],
-      });
-
-    return this;
-  }
-
-  /**
-   * generate the query to find the pages '{path}/*' and '{path}' self.
-   * If top page, return without doing anything.
-   */
-  addConditionToListWithDescendants(path, option) {
-    // No request is set for the top page
-    if (isTopPage(path)) {
-      return this;
-    }
-
-    const pathNormalized = pathUtils.normalizePath(path);
-    const pathWithTrailingSlash = pathUtils.addTrailingSlash(path);
-
-    const startsPattern = escapeStringRegexp(pathWithTrailingSlash);
-
-    this.query = this.query
-      .and({
-        $or: [
-          { path: pathNormalized },
-          { path: new RegExp(`^${startsPattern}`) },
-        ],
-      });
-
-    return this;
-  }
-
-  /**
-   * generate the query to find the pages '{path}/*' (exclude '{path}' self).
-   * If top page, return without doing anything.
-   */
-  addConditionToListOnlyDescendants(path, option) {
-    // No request is set for the top page
-    if (isTopPage(path)) {
-      return this;
-    }
-
-    const pathWithTrailingSlash = pathUtils.addTrailingSlash(path);
-
-    const startsPattern = escapeStringRegexp(pathWithTrailingSlash);
-
-    this.query = this.query
-      .and({ path: new RegExp(`^${startsPattern}`) });
-
-    return this;
-
-  }
-
-  addConditionToListOnlyAncestors(path) {
-    const pathNormalized = pathUtils.normalizePath(path);
-    const ancestorsPaths = extractToAncestorsPaths(pathNormalized);
-
-    this.query = this.query
-      .and({
-        path: {
-          $in: ancestorsPaths,
-        },
-      });
-
-    return this;
-
-  }
-
-  /**
-   * generate the query to find pages that start with `path`
-   *
-   * In normal case, returns '{path}/*' and '{path}' self.
-   * If top page, return without doing anything.
-   *
-   * *option*
-   *   Left for backward compatibility
-   */
-  addConditionToListByStartWith(path, option) {
-    // No request is set for the top page
-    if (isTopPage(path)) {
-      return this;
-    }
-
-    const startsPattern = escapeStringRegexp(path);
-
-    this.query = this.query
-      .and({ path: new RegExp(`^${startsPattern}`) });
-
-    return this;
-  }
-
-  async addConditionForParentNormalization(user) {
-    // determine UserGroup condition
-    let userGroups = null;
-    if (user != null) {
-      const UserGroupRelation = mongoose.model('UserGroupRelation');
-      userGroups = await UserGroupRelation.findAllUserGroupIdsRelatedToUser(user);
-    }
-
-    const grantConditions = [
-      { grant: null },
-      { grant: GRANT_PUBLIC },
-    ];
-
-    if (user != null) {
-      grantConditions.push(
-        { grant: GRANT_OWNER, grantedUsers: user._id },
-      );
-    }
-
-    if (userGroups != null && userGroups.length > 0) {
-      grantConditions.push(
-        { grant: GRANT_USER_GROUP, grantedGroup: { $in: userGroups } },
-      );
-    }
-
-    this.query = this.query
-      .and({
-        $or: grantConditions,
-      });
-
-    return this;
-  }
-
-  async addConditionAsMigratablePages(user) {
-    this.query = this.query
-      .and({
-        $or: [
-          { grant: { $ne: GRANT_RESTRICTED } },
-          { grant: { $ne: GRANT_SPECIFIED } },
-        ],
-      });
-    this.addConditionAsNotMigrated();
-    this.addConditionAsNonRootPage();
-    this.addConditionToExcludeTrashed();
-    await this.addConditionForParentNormalization(user);
-
-    return this;
-  }
-
-  addConditionToFilteringByViewer(user, userGroups, showAnyoneKnowsLink = false, showPagesRestrictedByOwner = false, showPagesRestrictedByGroup = false) {
-    const grantConditions = [
-      { grant: null },
-      { grant: GRANT_PUBLIC },
-    ];
-
-    if (showAnyoneKnowsLink) {
-      grantConditions.push({ grant: GRANT_RESTRICTED });
-    }
-
-    if (showPagesRestrictedByOwner) {
-      grantConditions.push(
-        { grant: GRANT_SPECIFIED },
-        { grant: GRANT_OWNER },
-      );
-    }
-    else if (user != null) {
-      grantConditions.push(
-        { grant: GRANT_SPECIFIED, grantedUsers: user._id },
-        { grant: GRANT_OWNER, grantedUsers: user._id },
-      );
-    }
-
-    if (showPagesRestrictedByGroup) {
-      grantConditions.push(
-        { grant: GRANT_USER_GROUP },
-      );
-    }
-    else if (userGroups != null && userGroups.length > 0) {
-      grantConditions.push(
-        { grant: GRANT_USER_GROUP, grantedGroup: { $in: userGroups } },
-      );
-    }
-
-    this.query = this.query
-      .and({
-        $or: grantConditions,
-      });
-
-    return this;
-  }
-
-  addConditionToPagenate(offset, limit, sortOpt) {
-    this.query = this.query
-      .sort(sortOpt).skip(offset).limit(limit); // eslint-disable-line newline-per-chained-call
-
-    return this;
-  }
-
-  addConditionAsNonRootPage() {
-    this.query = this.query.and({ path: { $ne: '/' } });
-
-    return this;
-  }
-
-  addConditionAsNotMigrated() {
-    this.query = this.query
-      .and({ parent: null });
-
-    return this;
-  }
-
-  addConditionAsMigrated() {
-    this.query = this.query
-      .and(
-        {
-          $or: [
-            { parent: { $ne: null } },
-            { path: '/' },
-          ],
-        },
-      );
-
-    return this;
-  }
-
-  /*
-   * Add this condition when get any ancestor pages including the target's parent
-   */
-  addConditionToSortPagesByDescPath() {
-    this.query = this.query.sort('-path');
-
-    return this;
-  }
-
-  addConditionToSortPagesByAscPath() {
-    this.query = this.query.sort('path');
-
-    return this;
-  }
-
-  addConditionToMinimizeDataForRendering() {
-    this.query = this.query.select('_id path isEmpty grant revision descendantCount');
-
-    return this;
-  }
-
-  addConditionToListByPathsArray(paths) {
-    this.query = this.query
-      .and({
-        path: {
-          $in: paths,
-        },
-      });
-
-    return this;
-  }
-
-  addConditionToListByPageIdsArray(pageIds) {
-    this.query = this.query
-      .and({
-        _id: {
-          $in: pageIds,
-        },
-      });
-
-    return this;
-  }
-
-  populateDataToList(userPublicFields) {
-    this.query = this.query
-      .populate({
-        path: 'lastUpdateUser',
-        select: userPublicFields,
-      });
-    return this;
-  }
-
-  populateDataToShowRevision(userPublicFields) {
-    this.query = populateDataToShowRevision(this.query, userPublicFields);
-    return this;
-  }
-
-  addConditionToFilteringByParentId(parentId) {
-    this.query = this.query.and({ parent: parentId });
-    return this;
-  }
-
-}
-
 export const getPageSchema = (crowi) => {
   let pageEvent;
 
@@ -638,7 +338,7 @@ export const getPageSchema = (crowi) => {
       userGroups = await UserGroupRelation.findAllUserGroupIdsRelatedToUser(user);
     }
 
-    const queryBuilder = new PageQueryBuilder(baseQuery);
+    const queryBuilder = new this.PageQueryBuilder(baseQuery);
     queryBuilder.addConditionToFilteringByViewer(user, userGroups, true);
 
     const count = await queryBuilder.query.exec();
@@ -660,7 +360,7 @@ export const getPageSchema = (crowi) => {
       relatedUserGroups = await UserGroupRelation.findAllUserGroupIdsRelatedToUser(user);
     }
 
-    const queryBuilder = new PageQueryBuilder(baseQuery, includeEmpty);
+    const queryBuilder = new this.PageQueryBuilder(baseQuery, includeEmpty);
     queryBuilder.addConditionToFilteringByViewer(user, relatedUserGroups, true);
 
     return queryBuilder.query.exec();
@@ -668,7 +368,7 @@ export const getPageSchema = (crowi) => {
 
   pageSchema.statics.findByIdAndViewerToEdit = async function(id, user, includeEmpty = false) {
     const baseQuery = this.findOne({ _id: id });
-    const queryBuilder = new PageQueryBuilder(baseQuery, includeEmpty);
+    const queryBuilder = new this.PageQueryBuilder(baseQuery, includeEmpty);
 
     // add grant conditions
     await addConditionToFilteringByViewerToEdit(queryBuilder, user);
@@ -682,7 +382,7 @@ export const getPageSchema = (crowi) => {
       return null;
     }
 
-    const builder = new PageQueryBuilder(this.findOne({ path }), includeEmpty);
+    const builder = new this.PageQueryBuilder(this.findOne({ path }), includeEmpty);
 
     return builder.query.exec();
   };
@@ -713,7 +413,7 @@ export const getPageSchema = (crowi) => {
       relatedUserGroups = await UserGroupRelation.findAllUserGroupIdsRelatedToUser(user);
     }
 
-    const queryBuilder = new PageQueryBuilder(baseQuery, includeEmpty);
+    const queryBuilder = new this.PageQueryBuilder(baseQuery, includeEmpty);
     queryBuilder.addConditionToFilteringByViewer(user, relatedUserGroups);
 
     return queryBuilder.query.exec();
@@ -723,7 +423,7 @@ export const getPageSchema = (crowi) => {
    * find pages that is match with `path` and its descendants
    */
   pageSchema.statics.findListWithDescendants = async function(path, user, option = {}, includeEmpty = false) {
-    const builder = new PageQueryBuilder(this.find(), includeEmpty);
+    const builder = new this.PageQueryBuilder(this.find(), includeEmpty);
     builder.addConditionToListWithDescendants(path, option);
 
     return findListFromBuilderAndViewer(builder, user, false, option);
@@ -737,7 +437,7 @@ export const getPageSchema = (crowi) => {
       return null;
     }
 
-    const builder = new PageQueryBuilder(this.find(), includeEmpty);
+    const builder = new this.PageQueryBuilder(this.find(), includeEmpty);
     builder.addConditionToListWithDescendants(page.path, option);
 
     // add grant conditions
@@ -758,7 +458,7 @@ export const getPageSchema = (crowi) => {
    * find pages that start with `path`
    */
   pageSchema.statics.findListByStartWith = async function(path, user, option, includeEmpty = false) {
-    const builder = new PageQueryBuilder(this.find(), includeEmpty);
+    const builder = new this.PageQueryBuilder(this.find(), includeEmpty);
     builder.addConditionToListByStartWith(path, option);
 
     return findListFromBuilderAndViewer(builder, user, false, option);
@@ -773,7 +473,7 @@ export const getPageSchema = (crowi) => {
    */
   pageSchema.statics.findListByCreator = async function(targetUser, currentUser, option) {
     const opt = Object.assign({ sort: 'createdAt', desc: -1 }, option);
-    const builder = new PageQueryBuilder(this.find({ creator: targetUser._id }));
+    const builder = new this.PageQueryBuilder(this.find({ creator: targetUser._id }));
 
     let showAnyoneKnowsLink = null;
     if (targetUser != null && currentUser != null) {
@@ -787,7 +487,7 @@ export const getPageSchema = (crowi) => {
     const User = crowi.model('User');
 
     const opt = Object.assign({}, option);
-    const builder = new PageQueryBuilder(this.find({ _id: { $in: ids } }), shouldIncludeEmpty);
+    const builder = new this.PageQueryBuilder(this.find({ _id: { $in: ids } }), shouldIncludeEmpty);
 
     builder.addConditionToPagenate(opt.offset, opt.limit);
 
@@ -1089,7 +789,7 @@ export const getPageSchema = (crowi) => {
   };
 
   pageSchema.statics.applyScopesToDescendantsAsyncronously = async function(parentPage, user) {
-    const builder = new PageQueryBuilder(this.find());
+    const builder = new this.PageQueryBuilder(this.find());
     builder.addConditionToListWithDescendants(parentPage.path);
 
     // add grant conditions
@@ -1117,7 +817,7 @@ export const getPageSchema = (crowi) => {
   };
 
   pageSchema.statics.findListByPathsArray = async function(paths, includeEmpty = false) {
-    const queryBuilder = new PageQueryBuilder(this.find(), includeEmpty);
+    const queryBuilder = new this.PageQueryBuilder(this.find(), includeEmpty);
     queryBuilder.addConditionToListByPathsArray(paths);
 
     return await queryBuilder.query.exec();
@@ -1215,7 +915,5 @@ export const getPageSchema = (crowi) => {
   pageSchema.statics.GRANT_USER_GROUP = GRANT_USER_GROUP;
   pageSchema.statics.PAGE_GRANT_ERROR = PAGE_GRANT_ERROR;
 
-  pageSchema.statics.PageQueryBuilder = PageQueryBuilder;
-
   return pageSchema;
 };

+ 348 - 67
packages/app/src/server/models/page.ts

@@ -5,13 +5,14 @@ import mongoose, {
 } from 'mongoose';
 import mongoosePaginate from 'mongoose-paginate-v2';
 import uniqueValidator from 'mongoose-unique-validator';
+import escapeStringRegexp from 'escape-string-regexp';
 import nodePath from 'path';
-import { getOrCreateModel, pagePathUtils } from '@growi/core';
+import { getOrCreateModel, pagePathUtils, pathUtils } from '@growi/core';
 
 import loggerFactory from '../../utils/logger';
 import Crowi from '../crowi';
 import { IPage } from '../../interfaces/page';
-import { getPageSchema, PageQueryBuilder } from './obsolete-page';
+import { getPageSchema, extractToAncestorsPaths, populateDataToShowRevision } from './obsolete-page';
 import { ObjectIdLike } from '~/server/interfaces/mongoose-utils';
 import { PageRedirectModel } from './page-redirect';
 
@@ -44,13 +45,17 @@ export type CreateMethod = (path: string, body: string, user, options) => Promis
 export interface PageModel extends Model<PageDocument> {
   [x: string]: any; // for obsolete methods
   createEmptyPagesByPaths(paths: string[], onlyMigratedAsExistingPages?: boolean, publicOnly?: boolean): Promise<void>
-  getParentAndFillAncestors(path: string): Promise<PageDocument & { _id: any }>
+  getParentAndFillAncestors(path: string, user): Promise<PageDocument & { _id: any }>
   findByIdsAndViewer(pageIds: string[], user, userGroups?, includeEmpty?: boolean): Promise<PageDocument[]>
   findByPathAndViewer(path: string | null, user, userGroups?, useFindOne?: boolean, includeEmpty?: boolean): Promise<PageDocument[]>
   findTargetAndAncestorsByPathOrId(pathOrId: string): Promise<TargetAndAncestorsResult>
   findChildrenByParentPathOrIdAndViewer(parentPathOrId: string, user, userGroups?): Promise<PageDocument[]>
   findAncestorsChildrenByPathAndViewer(path: string, user, userGroups?): Promise<Record<string, PageDocument[]>>
 
+  generateGrantCondition(
+    user, userGroups, showAnyoneKnowsLink?: boolean, showPagesRestrictedByOwner?: boolean, showPagesRestrictedByGroup?: boolean,
+  ): { $or: any[] }
+
   PageQueryBuilder: typeof PageQueryBuilder
 
   GRANT_PUBLIC
@@ -118,15 +123,291 @@ const generateChildrenRegExp = (path: string): RegExp => {
   return new RegExp(`^${path}(\\/[^/]+)\\/?$`);
 };
 
-/*
+class PageQueryBuilder {
+
+  query: any;
+
+  constructor(query, includeEmpty = false) {
+    this.query = query;
+    if (!includeEmpty) {
+      this.query = this.query
+        .and({
+          $or: [
+            { isEmpty: false },
+            { isEmpty: null }, // for v4 compatibility
+          ],
+        });
+    }
+  }
+
+  addConditionToExcludeTrashed() {
+    this.query = this.query
+      .and({
+        $or: [
+          { status: null },
+          { status: STATUS_PUBLISHED },
+        ],
+      });
+
+    return this;
+  }
+
+  /**
+   * generate the query to find the pages '{path}/*' and '{path}' self.
+   * If top page, return without doing anything.
+   */
+  addConditionToListWithDescendants(path: string, option?) {
+    // No request is set for the top page
+    if (isTopPage(path)) {
+      return this;
+    }
+
+    const pathNormalized = pathUtils.normalizePath(path);
+    const pathWithTrailingSlash = pathUtils.addTrailingSlash(path);
+
+    const startsPattern = escapeStringRegexp(pathWithTrailingSlash);
+
+    this.query = this.query
+      .and({
+        $or: [
+          { path: pathNormalized },
+          { path: new RegExp(`^${startsPattern}`) },
+        ],
+      });
+
+    return this;
+  }
+
+  /**
+   * generate the query to find the pages '{path}/*' (exclude '{path}' self).
+   * If top page, return without doing anything.
+   */
+  addConditionToListOnlyDescendants(path, option) {
+    // No request is set for the top page
+    if (isTopPage(path)) {
+      return this;
+    }
+
+    const pathWithTrailingSlash = pathUtils.addTrailingSlash(path);
+
+    const startsPattern = escapeStringRegexp(pathWithTrailingSlash);
+
+    this.query = this.query
+      .and({ path: new RegExp(`^${startsPattern}`) });
+
+    return this;
+
+  }
+
+  addConditionToListOnlyAncestors(path) {
+    const pathNormalized = pathUtils.normalizePath(path);
+    const ancestorsPaths = extractToAncestorsPaths(pathNormalized);
+
+    this.query = this.query
+      .and({
+        path: {
+          $in: ancestorsPaths,
+        },
+      });
+
+    return this;
+
+  }
+
+  /**
+   * generate the query to find pages that start with `path`
+   *
+   * In normal case, returns '{path}/*' and '{path}' self.
+   * If top page, return without doing anything.
+   *
+   * *option*
+   *   Left for backward compatibility
+   */
+  addConditionToListByStartWith(path, option?) {
+    // No request is set for the top page
+    if (isTopPage(path)) {
+      return this;
+    }
+
+    const startsPattern = escapeStringRegexp(path);
+
+    this.query = this.query
+      .and({ path: new RegExp(`^${startsPattern}`) });
+
+    return this;
+  }
+
+  async addConditionForParentNormalization(user) {
+    // determine UserGroup condition
+    let userGroups;
+    if (user != null) {
+      const UserGroupRelation = mongoose.model('UserGroupRelation') as any; // TODO: Typescriptize model
+      userGroups = await UserGroupRelation.findAllUserGroupIdsRelatedToUser(user);
+    }
+
+    const grantConditions: any[] = [
+      { grant: null },
+      { grant: GRANT_PUBLIC },
+    ];
+
+    if (user != null) {
+      grantConditions.push(
+        { grant: GRANT_OWNER, grantedUsers: user._id },
+      );
+    }
+
+    if (userGroups != null && userGroups.length > 0) {
+      grantConditions.push(
+        { grant: GRANT_USER_GROUP, grantedGroup: { $in: userGroups } },
+      );
+    }
+
+    this.query = this.query
+      .and({
+        $or: grantConditions,
+      });
+
+    return this;
+  }
+
+  async addConditionAsMigratablePages(user) {
+    this.query = this.query
+      .and({
+        $or: [
+          { grant: { $ne: GRANT_RESTRICTED } },
+          { grant: { $ne: GRANT_SPECIFIED } },
+        ],
+      });
+    this.addConditionAsNotMigrated();
+    this.addConditionAsNonRootPage();
+    this.addConditionToExcludeTrashed();
+    await this.addConditionForParentNormalization(user);
+
+    return this;
+  }
+
+  addConditionToFilteringByViewer(user, userGroups, showAnyoneKnowsLink = false, showPagesRestrictedByOwner = false, showPagesRestrictedByGroup = false) {
+    const condition = generateGrantCondition(user, userGroups, showAnyoneKnowsLink, showPagesRestrictedByOwner, showPagesRestrictedByGroup);
+
+    this.query = this.query
+      .and(condition);
+
+    return this;
+  }
+
+  addConditionToPagenate(offset, limit, sortOpt?) {
+    this.query = this.query
+      .sort(sortOpt).skip(offset).limit(limit); // eslint-disable-line newline-per-chained-call
+
+    return this;
+  }
+
+  addConditionAsNonRootPage() {
+    this.query = this.query.and({ path: { $ne: '/' } });
+
+    return this;
+  }
+
+  addConditionAsNotMigrated() {
+    this.query = this.query
+      .and({ parent: null });
+
+    return this;
+  }
+
+  addConditionAsMigrated() {
+    this.query = this.query
+      .and(
+        {
+          $or: [
+            { parent: { $ne: null } },
+            { path: '/' },
+          ],
+        },
+      );
+
+    return this;
+  }
+
+  /*
+   * Add this condition when get any ancestor pages including the target's parent
+   */
+  addConditionToSortPagesByDescPath() {
+    this.query = this.query.sort('-path');
+
+    return this;
+  }
+
+  addConditionToSortPagesByAscPath() {
+    this.query = this.query.sort('path');
+
+    return this;
+  }
+
+  addConditionToMinimizeDataForRendering() {
+    this.query = this.query.select('_id path isEmpty grant revision descendantCount');
+
+    return this;
+  }
+
+  addConditionToListByPathsArray(paths) {
+    this.query = this.query
+      .and({
+        path: {
+          $in: paths,
+        },
+      });
+
+    return this;
+  }
+
+  addConditionToListByPageIdsArray(pageIds) {
+    this.query = this.query
+      .and({
+        _id: {
+          $in: pageIds,
+        },
+      });
+
+    return this;
+  }
+
+  populateDataToList(userPublicFields) {
+    this.query = this.query
+      .populate({
+        path: 'lastUpdateUser',
+        select: userPublicFields,
+      });
+    return this;
+  }
+
+  populateDataToShowRevision(userPublicFields) {
+    this.query = populateDataToShowRevision(this.query, userPublicFields);
+    return this;
+  }
+
+  addConditionToFilteringByParentId(parentId) {
+    this.query = this.query.and({ parent: parentId });
+    return this;
+  }
+
+}
+
+/**
  * Create empty pages if the page in paths didn't exist
+ * @param onlyMigratedAsExistingPages Determine whether to include non-migrated pages as existing pages. If a page exists,
+ * an empty page will not be created at that page's path.
  */
-schema.statics.createEmptyPagesByPaths = async function(paths: string[], onlyMigratedAsExistingPages = true, publicOnly = false): Promise<void> {
+schema.statics.createEmptyPagesByPaths = async function(paths: string[], user: any | null, onlyMigratedAsExistingPages = true): Promise<void> {
   // find existing parents
-  const builder = new PageQueryBuilder(this.find(publicOnly ? { grant: GRANT_PUBLIC } : {}, { _id: 0, path: 1 }), true);
+  const builder = new PageQueryBuilder(this.find({}, { _id: 0, path: 1 }), true);
+
+  await this.addConditionToFilteringByViewerToEdit(builder, user);
+
   if (onlyMigratedAsExistingPages) {
     builder.addConditionAsMigrated();
   }
+
   const existingPages = await builder
     .addConditionToListByPathsArray(paths)
     .query
@@ -220,7 +501,7 @@ schema.statics.replaceTargetWithPage = async function(exPage, pageToReplaceWith?
  * @param path string
  * @returns Promise<PageDocument>
  */
-schema.statics.getParentAndFillAncestors = async function(path: string): Promise<PageDocument> {
+schema.statics.getParentAndFillAncestors = async function(path: string, user): Promise<PageDocument> {
   const parentPath = nodePath.dirname(path);
 
   const builder1 = new PageQueryBuilder(this.find({ path: parentPath }), true);
@@ -239,7 +520,7 @@ schema.statics.getParentAndFillAncestors = async function(path: string): Promise
   const ancestorPaths = collectAncestorPaths(path); // paths of parents need to be created
 
   // just create ancestors with empty pages
-  await this.createEmptyPagesByPaths(ancestorPaths);
+  await this.createEmptyPagesByPaths(ancestorPaths, user);
 
   // find ancestors
   const builder2 = new PageQueryBuilder(this.find(), true);
@@ -571,6 +852,63 @@ schema.statics.takeOffFromTree = async function(pageId: ObjectIdLike) {
   return this.findByIdAndUpdate(pageId, { $set: { parent: null } });
 };
 
+schema.statics.removeEmptyPages = async function(pageIdsToNotRemove: ObjectIdLike[], paths: string[]): Promise<void> {
+  await this.deleteMany({
+    _id: {
+      $nin: pageIdsToNotRemove,
+    },
+    path: {
+      $in: paths,
+    },
+    isEmpty: true,
+  });
+};
+
+schema.statics.PageQueryBuilder = PageQueryBuilder as any; // mongoose does not support constructor type as statics attrs type
+
+export function generateGrantCondition(
+    user, userGroups, showAnyoneKnowsLink = false, showPagesRestrictedByOwner = false, showPagesRestrictedByGroup = false,
+): { $or: any[] } {
+  const grantConditions: AnyObject[] = [
+    { grant: null },
+    { grant: GRANT_PUBLIC },
+  ];
+
+  if (showAnyoneKnowsLink) {
+    grantConditions.push({ grant: GRANT_RESTRICTED });
+  }
+
+  if (showPagesRestrictedByOwner) {
+    grantConditions.push(
+      { grant: GRANT_SPECIFIED },
+      { grant: GRANT_OWNER },
+    );
+  }
+  else if (user != null) {
+    grantConditions.push(
+      { grant: GRANT_SPECIFIED, grantedUsers: user._id },
+      { grant: GRANT_OWNER, grantedUsers: user._id },
+    );
+  }
+
+  if (showPagesRestrictedByGroup) {
+    grantConditions.push(
+      { grant: GRANT_USER_GROUP },
+    );
+  }
+  else if (userGroups != null && userGroups.length > 0) {
+    grantConditions.push(
+      { grant: GRANT_USER_GROUP, grantedGroup: { $in: userGroups } },
+    );
+  }
+
+  return {
+    $or: grantConditions,
+  };
+}
+
+schema.statics.generateGrantCondition = generateGrantCondition;
+
 export type PageCreateOptions = {
   format?: string
   grantUserGroupId?: ObjectIdLike
@@ -661,7 +999,7 @@ export default (crowi: Crowi): any => {
     }
 
     let parentId: IObjectId | string | null = null;
-    const parent = await Page.getParentAndFillAncestors(path);
+    const parent = await Page.getParentAndFillAncestors(path, user);
     if (!isTopPage(path)) {
       parentId = parent._id;
     }
@@ -723,7 +1061,7 @@ export default (crowi: Crowi): any => {
 
     const Revision = mongoose.model('Revision') as any; // TODO: Typescriptize model
     const grant = options.grant || pageData.grant; // use the previous data if absence
-    const grantUserGroupId = options.grantUserGroupId || pageData.grantUserGroupId; // use the previous data if absence
+    const grantUserGroupId: undefined | ObjectIdLike = options.grantUserGroupId ?? pageData.grantedGroup?._id.toString();
     const isSyncRevisionToHackmd = options.isSyncRevisionToHackmd;
     const grantedUserIds = pageData.grantedUserIds || [user._id];
 
@@ -775,60 +1113,3 @@ export default (crowi: Crowi): any => {
 
   return getOrCreateModel<PageDocument, PageModel>('Page', schema as any); // TODO: improve type
 };
-
-/*
- * Aggregation utilities
- */
-// TODO: use the original type when upgraded https://github.com/Automattic/mongoose/blob/master/index.d.ts#L3090
-type PipelineStageMatch = {
-  $match: AnyObject
-};
-
-export const generateGrantCondition = async(
-    user, _userGroups, showAnyoneKnowsLink = false, showPagesRestrictedByOwner = false, showPagesRestrictedByGroup = false,
-): Promise<PipelineStageMatch> => {
-  let userGroups = _userGroups;
-  if (user != null && userGroups == null) {
-    const UserGroupRelation: any = mongoose.model('UserGroupRelation');
-    userGroups = await UserGroupRelation.findAllUserGroupIdsRelatedToUser(user);
-  }
-
-  const grantConditions: AnyObject[] = [
-    { grant: null },
-    { grant: GRANT_PUBLIC },
-  ];
-
-  if (showAnyoneKnowsLink) {
-    grantConditions.push({ grant: GRANT_RESTRICTED });
-  }
-
-  if (showPagesRestrictedByOwner) {
-    grantConditions.push(
-      { grant: GRANT_SPECIFIED },
-      { grant: GRANT_OWNER },
-    );
-  }
-  else if (user != null) {
-    grantConditions.push(
-      { grant: GRANT_SPECIFIED, grantedUsers: user._id },
-      { grant: GRANT_OWNER, grantedUsers: user._id },
-    );
-  }
-
-  if (showPagesRestrictedByGroup) {
-    grantConditions.push(
-      { grant: GRANT_USER_GROUP },
-    );
-  }
-  else if (userGroups != null && userGroups.length > 0) {
-    grantConditions.push(
-      { grant: GRANT_USER_GROUP, grantedGroup: { $in: userGroups } },
-    );
-  }
-
-  return {
-    $match: {
-      $or: grantConditions,
-    },
-  };
-};

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

@@ -174,6 +174,7 @@ module.exports = (crowi) => {
       body('pageId').isMongoId().withMessage('pageId is required'),
       body('revisionId').optional().isMongoId().withMessage('revisionId is required'), // required when v4
       body('newPagePath').isLength({ min: 1 }).withMessage('newPagePath is required'),
+      body('isRecursively').if(value => value != null).isBoolean().withMessage('isRecursively must be boolean'),
       body('isRenameRedirect').if(value => value != null).isBoolean().withMessage('isRenameRedirect must be boolean'),
       body('isRemainMetadata').if(value => value != null).isBoolean().withMessage('isRemainMetadata must be boolean'),
       body('isMoveMode').if(value => value != null).isBoolean().withMessage('isMoveMode must be boolean'),
@@ -473,6 +474,7 @@ module.exports = (crowi) => {
     let newPagePath = pathUtils.normalizePath(req.body.newPagePath);
 
     const options = {
+      isRecursively: req.body.isRecursively,
       createRedirectPage: req.body.isRenameRedirect,
       updateMetadata: !req.body.isRemainMetadata,
       isMoveMode: req.body.isMoveMode,

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

@@ -4,7 +4,6 @@ import { body } from 'express-validator';
 import mongoose from 'mongoose';
 
 import loggerFactory from '~/utils/logger';
-import { PageQueryBuilder } from '../models/obsolete-page';
 import UpdatePost from '../models/update-post';
 
 const { isCreatablePage, isTopPage, isUsersHomePage } = pagePathUtils;
@@ -146,6 +145,8 @@ module.exports = function(crowi, app) {
   const ShareLink = crowi.model('ShareLink');
   const PageRedirect = mongoose.model('PageRedirect');
 
+  const { PageQueryBuilder } = Page;
+
   const ApiResponse = require('../util/apiResponse');
   const getToday = require('../util/getToday');
 
@@ -1229,7 +1230,8 @@ module.exports = function(crowi, app) {
 
   validator.revertRemove = [
     body('recursively')
-      .custom(v => v === 'true' || v === true || null)
+      .optional()
+      .custom(v => v === 'true' || v === true || v == null)
       .withMessage('The body property "recursively" must be "true" or true. (Omit param for false)'),
   ];
 

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

@@ -4,7 +4,6 @@ import escapeStringRegexp from 'escape-string-regexp';
 
 import UserGroup from '~/server/models/user-group';
 import { PageDocument, PageModel } from '~/server/models/page';
-import { PageQueryBuilder } from '../models/obsolete-page';
 import { isIncludesObjectId, excludeTestIdsFromTargetIds } from '~/server/util/compare-objectId';
 
 const { addTrailingSlash } = pathUtils;
@@ -216,6 +215,7 @@ class PageGrantService {
    */
   private async generateComparableAncestor(targetPath: string, includeNotMigratedPages: boolean): Promise<ComparableAncestor> {
     const Page = mongoose.model('Page') as unknown as PageModel;
+    const { PageQueryBuilder } = Page;
     const UserGroupRelation = mongoose.model('UserGroupRelation') as any; // TODO: Typescriptize model
 
     let applicableUserIds: ObjectIdLike[] | undefined;

+ 79 - 59
packages/app/src/server/service/page.ts

@@ -8,7 +8,7 @@ import { Readable, Writable } from 'stream';
 import { createBatchStream } from '~/server/util/batch-stream';
 import loggerFactory from '~/utils/logger';
 import {
-  CreateMethod, generateGrantCondition, PageCreateOptions, PageModel, PageDocument,
+  CreateMethod, PageCreateOptions, PageModel, PageDocument,
 } from '~/server/models/page';
 import { stringifySnapshot } from '~/models/serializers/in-app-notification-snapshot/page';
 import {
@@ -460,7 +460,7 @@ class PageService {
       newParent = await this.getParentAndforceCreateEmptyTree(page, newPagePath);
     }
     else {
-      newParent = await Page.getParentAndFillAncestors(newPagePath);
+      newParent = await Page.getParentAndFillAncestors(newPagePath, user);
     }
 
     // 3. Put back target page to tree (also update the other attrs)
@@ -585,17 +585,22 @@ class PageService {
     return newParent;
   }
 
-  // !!renaming always include descendant pages!!
   private async renamePageV4(page, newPagePath, user, options) {
     const Page = this.crowi.model('Page');
     const Revision = this.crowi.model('Revision');
-    const updateMetadata = options.updateMetadata || false;
+    const {
+      isRecursively = false,
+      createRedirectPage = false,
+      updateMetadata = false,
+    } = options;
 
     // sanitize path
     newPagePath = this.crowi.xss.process(newPagePath); // eslint-disable-line no-param-reassign
 
     // create descendants first
-    await this.renameDescendantsWithStream(page, newPagePath, user, options);
+    if (isRecursively) {
+      await this.renameDescendantsWithStream(page, newPagePath, user, options);
+    }
 
 
     const update: any = {};
@@ -610,12 +615,16 @@ class PageService {
     // update Rivisions
     await Revision.updateRevisionListByPageId(renamedPage._id, { pageId: renamedPage._id });
 
+    if (createRedirectPage) {
+      const PageRedirect = mongoose.model('PageRedirect') as unknown as PageRedirectModel;
+      await PageRedirect.create({ fromPath: page.path, toPath: newPagePath });
+    }
+
     this.pageEvent.emit('rename', page, user);
 
     return renamedPage;
   }
 
-
   private async renameDescendants(pages, user, options, oldPagePathPrefix, newPagePathPrefix, shouldUseV4Process = true) {
     // v4 compatible process
     if (shouldUseV4Process) {
@@ -910,7 +919,7 @@ class PageService {
     };
     let duplicatedTarget;
     if (page.isEmpty) {
-      const parent = await Page.getParentAndFillAncestors(newPagePath);
+      const parent = await Page.getParentAndFillAncestors(newPagePath, user);
       duplicatedTarget = await Page.createEmptyPage(newPagePath, parent);
     }
     else {
@@ -963,7 +972,7 @@ class PageService {
     const shouldNormalize = this.shouldNormalizeParent(page);
     if (shouldNormalize) {
       try {
-        await this.normalizeParentAndDescendantCountOfDescendants(newPagePath);
+        await this.normalizeParentAndDescendantCountOfDescendants(newPagePath, user);
         logger.info(`Successfully normalized duplicated descendant pages under "${newPagePath}"`);
       }
       catch (err) {
@@ -1839,7 +1848,7 @@ class PageService {
     }
 
     // 2. Revert target
-    const parent = await Page.getParentAndFillAncestors(newPath);
+    const parent = await Page.getParentAndFillAncestors(newPath, user);
     const updatedPage = await Page.findByIdAndUpdate(page._id, {
       $set: {
         path: newPath, status: Page.STATUS_PUBLISHED, lastUpdateUser: user._id, deleteUser: null, deletedAt: null, parent: parent._id, descendantCount: 0,
@@ -1886,7 +1895,7 @@ class PageService {
     const shouldNormalize = this.shouldNormalizeParent(page);
     if (shouldNormalize) {
       try {
-        await this.normalizeParentAndDescendantCountOfDescendants(newPath);
+        await this.normalizeParentAndDescendantCountOfDescendants(newPath, user);
         logger.info(`Successfully normalized reverted descendant pages under "${newPath}"`);
       }
       catch (err) {
@@ -2082,11 +2091,16 @@ class PageService {
   }
 
   async shortBodiesMapByPageIds(pageIds: ObjectId[] = [], user): Promise<Record<string, string | null>> {
-    const Page = mongoose.model('Page');
+    const Page = mongoose.model('Page') as unknown as PageModel;
     const MAX_LENGTH = 350;
 
     // aggregation options
-    const viewerCondition = await generateGrantCondition(user, null);
+    let userGroups;
+    if (user != null && userGroups == null) {
+      const UserGroupRelation = mongoose.model('UserGroupRelation') as any; // Typescriptize model
+      userGroups = await UserGroupRelation.findAllUserGroupIdsRelatedToUser(user);
+    }
+    const viewerCondition = Page.generateGrantCondition(user, userGroups);
     const filterByIds = {
       _id: { $in: pageIds },
     };
@@ -2100,7 +2114,9 @@ class PageService {
             $match: filterByIds,
           },
           // filter by viewer
-          viewerCondition,
+          {
+            $match: viewerCondition,
+          },
           // lookup: https://docs.mongodb.com/v4.4/reference/operator/aggregation/lookup/
           {
             $lookup: {
@@ -2253,7 +2269,7 @@ class PageService {
     }
     else {
       // getParentAndFillAncestors
-      const parent = await Page.getParentAndFillAncestors(page.path);
+      const parent = await Page.getParentAndFillAncestors(page.path, user);
       updatedPage = await Page.findOneAndUpdate({ _id: page._id }, { parent: parent._id }, { new: true });
     }
 
@@ -2326,7 +2342,7 @@ class PageService {
     // TODO: insertOne PageOperationBlock
 
     try {
-      await this.normalizeParentRecursively([page.path]);
+      await this.normalizeParentRecursively([page.path], user);
     }
     catch (err) {
       logger.error('V5 initial miration failed.', err);
@@ -2425,7 +2441,7 @@ class PageService {
 
     // then migrate
     try {
-      await this.normalizeParentRecursively(['/'], true);
+      await this.normalizeParentRecursively(['/'], null);
     }
     catch (err) {
       logger.error('V5 initial miration failed.', err);
@@ -2460,40 +2476,46 @@ class PageService {
     }
   }
 
-  private async normalizeParentAndDescendantCountOfDescendants(path: string): Promise<void> {
-    await this.normalizeParentRecursively([path]);
+  private async normalizeParentAndDescendantCountOfDescendants(path: string, user): Promise<void> {
+    await this.normalizeParentRecursively([path], user);
 
     // update descendantCount of descendant pages
     await this.updateDescendantCountOfSelfAndDescendants(path);
   }
 
-  async normalizeParentRecursively(paths: string[], publicOnly = false): Promise<void> {
+  /**
+   * Normalize parent attribute by passing paths and user.
+   * @param paths Pages under this paths value will be updated.
+   * @param user To be used to filter pages to update. If null, only public pages will be updated.
+   * @returns Promise<void>
+   */
+  async normalizeParentRecursively(paths: string[], user: any | null): Promise<void> {
+    const Page = mongoose.model('Page') as unknown as PageModel;
+
     const ancestorPaths = paths.flatMap(p => collectAncestorPaths(p, [p]));
     const regexps = paths.map(p => new RegExp(`^${escapeStringRegexp(addTrailingSlash(p))}`, 'i'));
 
-    return this._normalizeParentRecursively(regexps, ancestorPaths, publicOnly);
+    // determine UserGroup condition
+    let userGroups = null;
+    if (user != null) {
+      const UserGroupRelation = mongoose.model('UserGroupRelation') as any; // TODO: Typescriptize model
+      userGroups = await UserGroupRelation.findAllUserGroupIdsRelatedToUser(user);
+    }
+
+    const grantFiltersByUser: { $or: any[] } = Page.generateGrantCondition(user, userGroups);
+
+    return this._normalizeParentRecursively(regexps, ancestorPaths, grantFiltersByUser, user);
   }
 
   // TODO: use websocket to show progress
-  private async _normalizeParentRecursively(regexps: RegExp[], pathsToInclude: string[], publicOnly: boolean): Promise<void> {
+  private async _normalizeParentRecursively(regexps: RegExp[], pathsToInclude: string[], grantFiltersByUser: { $or: any[] }, user): Promise<void> {
     const BATCH_SIZE = 100;
     const PAGES_LIMIT = 1000;
+
     const Page = mongoose.model('Page') as unknown as PageModel;
     const { PageQueryBuilder } = Page;
 
-    // GRANT_RESTRICTED and GRANT_SPECIFIED will never have parent
-    const grantFilter: any = {
-      $and: [
-        { grant: { $ne: Page.GRANT_RESTRICTED } },
-        { grant: { $ne: Page.GRANT_SPECIFIED } },
-      ],
-    };
-
-    if (publicOnly) { // add grant condition if not null
-      grantFilter.$and = [...grantFilter.$and, { grant: Page.GRANT_PUBLIC }];
-    }
-
-    // generate filter
+    // Build filter
     const filter: any = {
       $and: [
         {
@@ -2518,11 +2540,9 @@ class PageService {
       });
     }
 
-    const total = await Page.countDocuments(filter);
-
     let baseAggregation = Page
       .aggregate([
-        { $match: grantFilter },
+        { $match: grantFiltersByUser },
         { $match: filter },
         {
           $project: { // minimize data to fetch
@@ -2533,6 +2553,7 @@ class PageService {
       ]);
 
     // limit pages to get
+    const total = await Page.countDocuments(filter);
     if (total > PAGES_LIMIT) {
       baseAggregation = baseAggregation.limit(Math.floor(total * 0.3));
     }
@@ -2545,18 +2566,15 @@ class PageService {
     let countPages = 0;
     let shouldContinue = true;
 
-    // migrate all siblings for each page
     const migratePagesStream = new Writable({
       objectMode: true,
       async write(pages, encoding, callback) {
-        // make list to create empty pages
-        const parentPathsSet = new Set<string>(pages.map(page => pathlib.dirname(page.path)));
-        const parentPaths = Array.from(parentPathsSet);
+        const parentPaths = Array.from(new Set<string>(pages.map(p => pathlib.dirname(p.path))));
 
-        // fill parents with empty pages
-        await Page.createEmptyPagesByPaths(parentPaths, false, publicOnly);
+        // Fill parents with empty pages
+        await Page.createEmptyPagesByPaths(parentPaths, user, false);
 
-        // find parents again
+        // Find parents
         const builder = new PageQueryBuilder(Page.find({}, { _id: 1, path: 1 }), true);
         const parents = await builder
           .addConditionToListByPathsArray(parentPaths)
@@ -2564,22 +2582,20 @@ class PageService {
           .lean()
           .exec();
 
-        // bulkWrite to update parent
+        // Normalize all siblings for each page
         const updateManyOperations = parents.map((parent) => {
           const parentId = parent._id;
 
-          // modify to adjust for RegExp
-          let parentPath = parent.path === '/' ? '' : parent.path;
-          parentPath = escapeStringRegexp(parentPath);
-
+          // Build filter
+          const parentPathEscaped = escapeStringRegexp(parent.path === '/' ? '' : parent.path); // adjust the path for RegExp
           const filter: any = {
-            // regexr.com/6889f
-            // ex. /parent/any_child OR /any_level1
-            path: { $regex: new RegExp(`^${parentPath}(\\/[^/]+)\\/?$`, 'i') },
+            $and: [
+              {
+                path: { $regex: new RegExp(`^${parentPathEscaped}(\\/[^/]+)\\/?$`, 'i') }, // see: regexr.com/6889f (e.g. /parent/any_child or /any_level1)
+              },
+              grantFiltersByUser,
+            ],
           };
-          if (publicOnly) {
-            filter.grant = Page.GRANT_PUBLIC;
-          }
 
           return {
             updateMany: {
@@ -2592,16 +2608,17 @@ class PageService {
         });
         try {
           const res = await Page.bulkWrite(updateManyOperations);
+
           countPages += res.result.nModified;
           logger.info(`Page migration processing: (count=${countPages})`);
 
-          // throw
+          // Throw if any error is found
           if (res.result.writeErrors.length > 0) {
             logger.error('Failed to migrate some pages', res.result.writeErrors);
             throw Error('Failed to migrate some pages');
           }
 
-          // finish migration
+          // Finish migration if no modification occurred
           if (res.result.nModified === 0 && res.result.nMatched === 0) {
             shouldContinue = false;
             logger.error('Migration is unable to continue', 'parentPaths:', parentPaths, 'bulkWriteResult:', res);
@@ -2612,6 +2629,9 @@ class PageService {
           throw err;
         }
 
+        // Remove unnecessary empty pages
+        await Page.removeEmptyPages(pages.map(p => p._id), pages.map(p => p.path));
+
         callback();
       },
       final(callback) {
@@ -2625,9 +2645,9 @@ class PageService {
 
     await streamToPromise(migratePagesStream);
 
-    const existsFilter = { $and: [...grantFilter.$and, ...filter.$and] };
+    const existsFilter = { $and: [grantFiltersByUser, ...filter.$and] };
     if (await Page.exists(existsFilter) && shouldContinue) {
-      return this._normalizeParentRecursively(regexps, pathsToInclude, publicOnly);
+      return this._normalizeParentRecursively(regexps, pathsToInclude, grantFiltersByUser, user);
     }
 
   }

+ 27 - 8
packages/app/src/stores/modal.tsx

@@ -1,6 +1,8 @@
 import { SWRResponse } from 'swr';
 import { useStaticSWR } from './use-static-swr';
-import { OnDuplicatedFunction, OnRenamedFunction, OnDeletedFunction } from '~/interfaces/ui';
+import {
+  OnDuplicatedFunction, OnRenamedFunction, OnDeletedFunction, OnPutBackedFunction,
+} from '~/interfaces/ui';
 import { IPageToDeleteWithMeta } from '~/interfaces/page';
 
 
@@ -155,27 +157,44 @@ export const usePageRenameModal = (status?: RenameModalStatus): SWRResponse<Rena
 /*
 * PutBackPageModal
 */
+export type IPageForPagePutBackModal = {
+  pageId: string,
+  path: string
+}
+
+export type IPutBackPageModalOption = {
+  onPutBacked?: OnPutBackedFunction,
+}
+
 type PutBackPageModalStatus = {
   isOpened: boolean,
-  pageId?: string,
-  path?: string,
+  page?: IPageForPagePutBackModal,
+  opts?: IPutBackPageModalOption,
 }
 
 type PutBackPageModalUtils = {
-  open(pageId: string, path: string): Promise<PutBackPageModalStatus | undefined>
+  open(
+    page?: IPageForPagePutBackModal,
+    opts?: IPutBackPageModalOption,
+    ): Promise<PutBackPageModalStatus | undefined>
   close():Promise<PutBackPageModalStatus | undefined>
 }
 
 export const usePutBackPageModal = (status?: PutBackPageModalStatus): SWRResponse<PutBackPageModalStatus, Error> & PutBackPageModalUtils => {
-  const initialData = { isOpened: false, pageId: '', path: '' };
+  const initialData: PutBackPageModalStatus = {
+    isOpened: false,
+    page: { pageId: '', path: '' },
+  };
   const swrResponse = useStaticSWR<PutBackPageModalStatus, Error>('putBackPageModalStatus', status, { fallbackData: initialData });
 
   return {
     ...swrResponse,
-    open: (pageId: string, path: string) => swrResponse.mutate({
-      isOpened: true, pageId, path,
+    open: (
+        page: IPageForPagePutBackModal, opts?: IPutBackPageModalOption,
+    ) => swrResponse.mutate({
+      isOpened: true, page, opts,
     }),
-    close: () => swrResponse.mutate({ isOpened: false }),
+    close: () => swrResponse.mutate({ isOpened: false, page: { pageId: '', path: '' } }),
   };
 };
 

+ 3 - 1
packages/app/test/cypress/integration/1-install/install.spec.ts

@@ -52,7 +52,9 @@ context('Installing', () => {
 
     cy.getByTestid('btnSubmit').click();
 
-    cy.screenshot(`${ssPrefix}-installed`, { capture: 'viewport' });
+    cy.screenshot(`${ssPrefix}-installed`, {
+      blackout: ['.grw-sidebar-content-container'],
+    });
   });
 
 });

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

@@ -37,43 +37,43 @@ context('Access to Admin page', () => {
   it('/admin is successfully loaded', () => {
     cy.visit('/admin');
     cy.getByTestid('admin-home').should('be.visible');
-    cy.screenshot(`${ssPrefix}-admin`, { capture: 'viewport' });
+    cy.screenshot(`${ssPrefix}-admin`);
   });
 
   it('/admin/app is successfully loaded', () => {
     cy.visit('/admin/app');
     cy.getByTestid('admin-app-settings').should('be.visible');
-    cy.screenshot(`${ssPrefix}-admin-app`, { capture: 'viewport' });
+    cy.screenshot(`${ssPrefix}-admin-app`);
   });
 
   it('/admin/security is successfully loaded', () => {
     cy.visit('/admin/security');
     cy.getByTestid('admin-security').should('be.visible');
-    cy.screenshot(`${ssPrefix}-admin-security`, { capture: 'viewport' });
+    cy.screenshot(`${ssPrefix}-admin-security`);
   });
 
   it('/admin/markdown is successfully loaded', () => {
     cy.visit('/admin/markdown');
     cy.getByTestid('admin-markdown').should('be.visible');
-    cy.screenshot(`${ssPrefix}-admin-markdown`, { capture: 'viewport' });
+    cy.screenshot(`${ssPrefix}-admin-markdown`);
   });
 
   it('/admin/customize is successfully loaded', () => {
     cy.visit('/admin/customize');
     cy.getByTestid('admin-customize').should('be.visible');
-    cy.screenshot(`${ssPrefix}-admin-customize`, { capture: 'viewport' });
+    cy.screenshot(`${ssPrefix}-admin-customize`);
   });
 
   it('/admin/importer is successfully loaded', () => {
     cy.visit('/admin/importer');
     cy.getByTestid('admin-import-data').should('be.visible');
-    cy.screenshot(`${ssPrefix}-admin-importer`, { capture: 'viewport' });
+    cy.screenshot(`${ssPrefix}-admin-importer`);
   });
 
   it('/admin/export is successfully loaded', () => {
     cy.visit('/admin/export');
     cy.getByTestid('admin-export-archive-data').should('be.visible');
-    cy.screenshot(`${ssPrefix}-admin-export`, { capture: 'viewport' });
+    cy.screenshot(`${ssPrefix}-admin-export`);
   });
 
   it('/admin/notification is successfully loaded', () => {
@@ -81,32 +81,32 @@ context('Access to Admin page', () => {
     cy.getByTestid('admin-notification').should('be.visible');
     // wait for retrieving slack integration status
     cy.getByTestid('slack-integration-list-item').should('be.visible');
-    cy.screenshot(`${ssPrefix}-admin-notification`, { capture: 'viewport' });
+    cy.screenshot(`${ssPrefix}-admin-notification`);
   });
 
   it('/admin/slack-integration is successfully loaded', () => {
     cy.visit('/admin/slack-integration');
     cy.getByTestid('admin-slack-integration').should('be.visible');
-    cy.screenshot(`${ssPrefix}-admin-slack-integration`, { capture: 'viewport' });
+    cy.screenshot(`${ssPrefix}-admin-slack-integration`);
   });
 
   it('/admin/slack-integration-legacy is successfully loaded', () => {
     cy.visit('/admin/slack-integration-legacy');
     cy.getByTestid('admin-slack-integration-legacy').should('be.visible');
-    cy.screenshot(`${ssPrefix}-admin-slack-integration-legacy`, { capture: 'viewport' });
+    cy.screenshot(`${ssPrefix}-admin-slack-integration-legacy`);
   });
 
   it('/admin/users is successfully loaded', () => {
     cy.visit('/admin/users');
     cy.getByTestid('admin-users').should('be.visible');
     cy.getByTestid('user-table-tr').first().should('be.visible');
-    cy.screenshot(`${ssPrefix}-admin-users`, { capture: 'viewport' });
+    cy.screenshot(`${ssPrefix}-admin-users`);
   });
 
   it('/admin/user-groups is successfully loaded', () => {
     cy.visit('/admin/user-groups');
     cy.getByTestid('admin-user-groups').should('be.visible');
-    cy.screenshot(`${ssPrefix}-admin-user-groups`, { capture: 'viewport' });
+    cy.screenshot(`${ssPrefix}-admin-user-groups`);
   });
 
   it('/admin/search is successfully loaded', () => {
@@ -114,7 +114,7 @@ context('Access to Admin page', () => {
     cy.getByTestid('admin-full-text-search').should('be.visible');
     // wait for connected
     cy.getByTestid('connection-status-badge-connected').should('be.visible');
-    cy.screenshot(`${ssPrefix}-admin-search`, { capture: 'viewport' });
+    cy.screenshot(`${ssPrefix}-admin-search`);
   });
 
 });

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

@@ -23,12 +23,12 @@ context('Access to /me page', () => {
 
   it('/me is successfully loaded', () => {
     cy.visit('/me', {  });
-    cy.screenshot(`${ssPrefix}-me`, { capture: 'viewport' });
+    cy.screenshot(`${ssPrefix}-me`);
   });
 
   it('Draft page is successfully shown', () => {
     cy.visit('/me/drafts');
-    cy.screenshot(`${ssPrefix}-draft-page`, { capture: 'viewport' });
+    cy.screenshot(`${ssPrefix}-draft-page`);
   });
 
 });

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

@@ -24,29 +24,31 @@ context('Access to page', () => {
 
   it('/Sandbox is successfully loaded', () => {
     cy.visit('/Sandbox', {  });
-    cy.screenshot(`${ssPrefix}-sandbox`, { capture: 'viewport' });
+    cy.screenshot(`${ssPrefix}-sandbox`);
   });
 
   it('/Sandbox with anchor hash is successfully loaded', () => {
     cy.visit('/Sandbox#Headers');
-    cy.getByTestid('grw-fab-create-page').should('be.visible');
-    cy.getByTestid('grw-fab-return-to-top').should('be.visible');
-    cy.screenshot(`${ssPrefix}-sandbox-headers`, { capture: 'viewport' });
+    cy.getByTestid('grw-fab-create-page').should('have.class', 'fadeInUp').should('be.visible');
+    cy.getByTestid('grw-fab-return-to-top').should('have.class', 'fadeInUp').should('be.visible');
+    cy.screenshot(`${ssPrefix}-sandbox-headers`, {
+      disableTimersAndAnimations: false,
+    });
   });
 
   it('/Sandbox/Math is successfully loaded', () => {
     cy.visit('/Sandbox/Math');
-    cy.screenshot(`${ssPrefix}-sandbox-math`, { capture: 'viewport' });
+    cy.screenshot(`${ssPrefix}-sandbox-math`);
   });
 
   it('/Sandbox with edit is successfully loaded', () => {
     cy.visit('/Sandbox#edit');
-    cy.screenshot(`${ssPrefix}-sandbox-edit-page`, { capture: 'viewport' });
+    cy.screenshot(`${ssPrefix}-sandbox-edit-page`);
   })
 
   it('/user/admin is successfully loaded', () => {
     cy.visit('/user/admin', {  });
-    cy.screenshot(`${ssPrefix}-user-admin`, { capture: 'viewport' });
+    cy.screenshot(`${ssPrefix}-user-admin`);
   });
 
 });

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

@@ -25,7 +25,7 @@ context('Access to special pages', () => {
   it('/trash is successfully loaded', () => {
     cy.visit('/trash', {  });
     cy.getByTestid('trash-page-list').should('be.visible');
-    cy.screenshot(`${ssPrefix}-trash`, { capture: 'viewport' });
+    cy.screenshot(`${ssPrefix}-trash`);
   });
 
   it('/tags is successfully loaded', () => {
@@ -38,7 +38,7 @@ context('Access to special pages', () => {
     cy.getByTestid('grw-sidebar-content-tags').should('be.visible');
 
     cy.getByTestid('tags-page').should('be.visible');
-    cy.screenshot(`${ssPrefix}-tags`, { capture: 'viewport' });
+    cy.screenshot(`${ssPrefix}-tags`);
   });
 
 });

+ 3 - 3
packages/app/test/cypress/integration/2-basic-features/open-presentation-modal.spec.ts

@@ -30,7 +30,7 @@ context('Open presentation modal', () => {
 
     // eslint-disable-next-line cypress/no-unnecessary-waiting
     cy.wait(1500);
-    cy.screenshot(`${ssPrefix}-opne-top`, { capture: 'viewport' });
+    cy.screenshot(`${ssPrefix}-opne-top`);
   });
 
   it('PageCreateModal for "/Sandbox/Bootstrap4" is shown successfully', () => {
@@ -43,7 +43,7 @@ context('Open presentation modal', () => {
 
     // eslint-disable-next-line cypress/no-unnecessary-waiting
     cy.wait(1500);
-    cy.screenshot(`${ssPrefix}-open-bootstrap4`, { capture: 'viewport' });
+    cy.screenshot(`${ssPrefix}-open-bootstrap4`);
   });
 
   it('PageCreateModal for /Sandbox/Bootstrap4#Cards" is shown successfully', () => {
@@ -56,6 +56,6 @@ context('Open presentation modal', () => {
 
     // eslint-disable-next-line cypress/no-unnecessary-waiting
     cy.wait(1500);
-    cy.screenshot(`${ssPrefix}-open-bootstrap4-with-ancker-link`, { capture: 'viewport' });
+    cy.screenshot(`${ssPrefix}-open-bootstrap4-with-ancker-link`);
   });
 });

+ 1 - 1
packages/app/test/cypress/integration/3-search/access-to-private-legacy-pages-directly.spec.ts

@@ -26,7 +26,7 @@ context('Access to legacy private pages directly', () => {
 
     cy.getByTestid('search-result-base').should('be.visible');
 
-    cy.screenshot(`${ssPrefix}-shown`, { capture: 'viewport' });
+    cy.screenshot(`${ssPrefix}-shown`);
   });
 
 });

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

@@ -22,34 +22,34 @@ context('Access to search result page directly', () => {
   });
 
   it('/_search with "q" param is successfully loaded', () => {
-    cy.visit('/_search', { qs: { q: 'block labels alerts cards' } });
+    cy.visit('/_search', { qs: { q: 'labels alerts cards blocks' } });
 
     cy.getByTestid('search-result-list').should('be.visible');
     cy.getByTestid('search-result-content').should('be.visible');
 
-    cy.screenshot(`${ssPrefix}-with-q`, { capture: 'viewport' });
+    cy.screenshot(`${ssPrefix}-with-q`);
   });
 
   it('checkboxes behaviors', () => {
-    cy.visit('/_search', { qs: { q: 'block labels alerts cards' } });
+    cy.visit('/_search', { qs: { q: 'labels alerts cards blocks' } });
 
     cy.getByTestid('search-result-base').should('be.visible');
     cy.getByTestid('search-result-list').should('be.visible');
 
     cy.getByTestid('cb-select').first().click({force: true});
-    cy.screenshot(`${ssPrefix}-the-first-checkbox-on`, { capture: 'viewport' });
+    cy.screenshot(`${ssPrefix}-the-first-checkbox-on`);
     cy.getByTestid('cb-select').first().click({force: true});
-    cy.screenshot(`${ssPrefix}-the-first-checkbox-off`, { capture: 'viewport' });
+    cy.screenshot(`${ssPrefix}-the-first-checkbox-off`);
 
     // click select all checkbox
     cy.getByTestid('cb-select-all').click({force: true});
-    cy.screenshot(`${ssPrefix}-the-select-all-checkbox-1`, { capture: 'viewport' });
+    cy.screenshot(`${ssPrefix}-the-select-all-checkbox-1`);
     cy.getByTestid('cb-select').first().click({force: true});
-    cy.screenshot(`${ssPrefix}-the-select-all-checkbox-2`, { capture: 'viewport' });
+    cy.screenshot(`${ssPrefix}-the-select-all-checkbox-2`);
     cy.getByTestid('cb-select').first().click({force: true});
-    cy.screenshot(`${ssPrefix}-the-select-all-checkbox-3`, { capture: 'viewport' });
+    cy.screenshot(`${ssPrefix}-the-select-all-checkbox-3`);
     cy.getByTestid('cb-select-all').click({force: true});
-    cy.screenshot(`${ssPrefix}-the-select-all-checkbox-4`, { capture: 'viewport' });
+    cy.screenshot(`${ssPrefix}-the-select-all-checkbox-4`);
   });
 
 });

+ 2 - 1
packages/app/test/cypress/support/screenshot.ts

@@ -1,3 +1,4 @@
 Cypress.Screenshot.defaults({
-  blackout: ['[data-hide-in-vrt=true]']
+  blackout: ['[data-hide-in-vrt=true]'],
+  capture: 'viewport',
 })

+ 4 - 7
packages/app/test/integration/service/page.test.js

@@ -293,7 +293,7 @@ describe('PageService', () => {
 
   describe('rename page without using renameDescendantsWithStreamSpy', () => {
     test('rename page with different tree with isRecursively [deeper]', async() => {
-      const resultPage = await crowi.pageService.renamePage(parentForRename6, '/parentForRename6/renamedChild', testUser1, {});
+      const resultPage = await crowi.pageService.renamePage(parentForRename6, '/parentForRename6/renamedChild', testUser1, { isRecursively: true });
       const wrongPage = await Page.findOne({ path: '/parentForRename6/renamedChild/renamedChild' });
       const expectPage1 = await Page.findOne({ path: '/parentForRename6/renamedChild' });
       const expectPage2 = await Page.findOne({ path: '/parentForRename6-2021H1' });
@@ -315,7 +315,7 @@ describe('PageService', () => {
 
       // when
       //   rename /level1/level2 --> /level1
-      await crowi.pageService.renamePage(parentForRename7, '/level1', testUser1, {});
+      await crowi.pageService.renamePage(parentForRename7, '/level1', testUser1, { isRecursively: true });
 
       // then
       expect(await Page.findOne({ path: '/level1' })).not.toBeNull();
@@ -348,7 +348,6 @@ describe('PageService', () => {
         const resultPage = await crowi.pageService.renamePage(parentForRename1, '/renamed1', testUser2, {});
 
         expect(xssSpy).toHaveBeenCalled();
-        expect(renameDescendantsWithStreamSpy).toHaveBeenCalled(); // single rename is deprecated
 
         expect(pageEventSpy).toHaveBeenCalledWith('rename', parentForRename1, testUser2);
 
@@ -362,7 +361,6 @@ describe('PageService', () => {
         const resultPage = await crowi.pageService.renamePage(parentForRename2, '/renamed2', testUser2, { updateMetadata: true });
 
         expect(xssSpy).toHaveBeenCalled();
-        expect(renameDescendantsWithStreamSpy).toHaveBeenCalled();
 
         expect(pageEventSpy).toHaveBeenCalledWith('rename', parentForRename2, testUser2);
 
@@ -376,7 +374,6 @@ describe('PageService', () => {
         const resultPage = await crowi.pageService.renamePage(parentForRename3, '/renamed3', testUser2, { createRedirectPage: true });
 
         expect(xssSpy).toHaveBeenCalled();
-        expect(renameDescendantsWithStreamSpy).toHaveBeenCalled();
         expect(pageEventSpy).toHaveBeenCalledWith('rename', parentForRename3, testUser2);
 
         expect(resultPage.path).toBe('/renamed3');
@@ -386,7 +383,7 @@ describe('PageService', () => {
 
       test('rename page with isRecursively', async() => {
 
-        const resultPage = await crowi.pageService.renamePage(parentForRename4, '/renamed4', testUser2, { }, true);
+        const resultPage = await crowi.pageService.renamePage(parentForRename4, '/renamed4', testUser2, { isRecursively: true });
 
         expect(xssSpy).toHaveBeenCalled();
         expect(renameDescendantsWithStreamSpy).toHaveBeenCalled();
@@ -399,7 +396,7 @@ describe('PageService', () => {
 
       test('rename page with different tree with isRecursively', async() => {
 
-        const resultPage = await crowi.pageService.renamePage(parentForRename5, '/parentForRename5/renamedChild', testUser1, {}, true);
+        const resultPage = await crowi.pageService.renamePage(parentForRename5, '/parentForRename5/renamedChild', testUser1, { isRecursively: true });
         const wrongPage = await Page.findOne({ path: '/parentForRename5/renamedChild/renamedChild' });
         const expectPage = await Page.findOne({ path: '/parentForRename5/renamedChild' });
 

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

@@ -28,7 +28,7 @@ describe('V5 page migration', () => {
       jest.restoreAllMocks();
 
       // initialize pages for test
-      let pages = await Page.insertMany([
+      await Page.insertMany([
         {
           path: '/private1',
           grant: Page.GRANT_OWNER,
@@ -59,11 +59,6 @@ describe('V5 page migration', () => {
         },
       ]);
 
-      if (!await Page.exists({ path: '/' })) {
-        const additionalPages = await Page.insertMany([{ path: '/', grant: Page.GRANT_PUBLIC }]);
-        pages = [...additionalPages, ...pages];
-      }
-
       const pagesToRun = await Page.find({ path: { $in: ['/private1', '/dummyParent/private1'] } });
 
       // migrate

+ 171 - 4
packages/app/test/integration/service/v5.page.test.ts

@@ -29,7 +29,8 @@ describe('PageService page operations with only public pages', () => {
 
   // pass unless the data is one of [false, 0, '', null, undefined, NaN]
   const expectAllToBeTruthy = (dataList) => {
-    dataList.forEach((data) => {
+    dataList.forEach((data, i) => {
+      if (data == null) { console.log(`index: ${i}`) }
       expect(data).toBeTruthy();
     });
   };
@@ -83,9 +84,16 @@ describe('PageService page operations with only public pages', () => {
 
     const pageIdForRename16 = new mongoose.Types.ObjectId();
 
+    const pageIdForRename17 = new mongoose.Types.ObjectId();
+    const pageIdForRename18 = new mongoose.Types.ObjectId();
+    const pageIdForRename19 = new mongoose.Types.ObjectId();
+    const pageIdForRename20 = new mongoose.Types.ObjectId();
+    const pageIdForRename21 = new mongoose.Types.ObjectId();
+    const pageIdForRename22 = new mongoose.Types.ObjectId();
+    const pageIdForRename23 = new mongoose.Types.ObjectId();
+
     // Create Pages
     await Page.insertMany([
-      // parents
       {
         _id: pageIdForRename1,
         path: '/v5_ParentForRename1',
@@ -157,7 +165,6 @@ describe('PageService page operations with only public pages', () => {
         lastUpdateUser: dummyUser1._id,
         parent: rootPage._id,
       },
-      // children
       {
         _id: pageIdForRename10,
         path: '/v5_ChildForRename1',
@@ -206,7 +213,6 @@ describe('PageService page operations with only public pages', () => {
         parent: rootPage._id,
         isEmpty: true,
       },
-      // Grandchild
       {
         path: '/v5_ChildForRename5/v5_GrandchildForRename5',
         grant: Page.GRANT_PUBLIC,
@@ -222,6 +228,60 @@ describe('PageService page operations with only public pages', () => {
         lastUpdateUser: dummyUser1._id,
         parent: pageIdForRename16,
       },
+      {
+        _id: pageIdForRename17,
+        path: '/v5_pageForRename17',
+        grant: Page.GRANT_PUBLIC,
+        creator: dummyUser1,
+        lastUpdateUser: dummyUser1._id,
+        parent: rootPage._id,
+      },
+      {
+        _id: pageIdForRename18,
+        path: '/v5_pageForRename17/v5_pageForRename18',
+        grant: Page.GRANT_PUBLIC,
+        creator: dummyUser1,
+        lastUpdateUser: dummyUser1._id,
+        parent: pageIdForRename17,
+      },
+      {
+        _id: pageIdForRename19,
+        path: '/v5_pageForRename19',
+        grant: Page.GRANT_PUBLIC,
+        parent: rootPage._id,
+        isEmpty: true,
+      },
+      {
+        _id: pageIdForRename20,
+        path: '/v5_pageForRename19/v5_pageForRename20',
+        grant: Page.GRANT_PUBLIC,
+        creator: dummyUser1,
+        lastUpdateUser: dummyUser1._id,
+        parent: pageIdForRename19,
+      },
+      {
+        _id: pageIdForRename21,
+        path: '/v5_pageForRename21',
+        grant: Page.GRANT_PUBLIC,
+        creator: dummyUser1,
+        lastUpdateUser: dummyUser1._id,
+        parent: rootPage._id,
+      },
+      {
+        _id: pageIdForRename22,
+        path: '/v5_pageForRename21/v5_pageForRename22',
+        grant: Page.GRANT_PUBLIC,
+        isEmpty: true,
+        parent: pageIdForRename21,
+      },
+      {
+        _id: pageIdForRename23,
+        path: '/v5_pageForRename21/v5_pageForRename22/v5_pageForRename23',
+        grant: Page.GRANT_PUBLIC,
+        creator: dummyUser1,
+        lastUpdateUser: dummyUser1._id,
+        parent: pageIdForRename22,
+      },
     ]);
 
     /*
@@ -998,6 +1058,113 @@ describe('PageService page operations with only public pages', () => {
 
       expect(isThrown).toBe(true);
     });
+    test('Rename non-empty page path to its descendant non-empty page path', async() => {
+      const initialPathForPage1 = '/v5_pageForRename17';
+      const initialPathForPage2 = '/v5_pageForRename17/v5_pageForRename18';
+      const page1 = await Page.findOne({ path: initialPathForPage1, isEmpty: false });
+      const page2 = await Page.findOne({ path: initialPathForPage2, isEmpty: false, parent: page1._id });
+
+      expectAllToBeTruthy([page1, page2]);
+
+      const newParentalPath = '/v5_pageForRename17/v5_pageForRename18';
+      const newPath = newParentalPath + page1.path;
+      await renamePage(page1, newPath, dummyUser1, {});
+
+      const renamedPage = await Page.findOne({ path: newParentalPath + initialPathForPage1 });
+      const renamedPageChild = await Page.findOne({ path: newParentalPath + initialPathForPage2 });
+      const newlyCreatedEmptyPage1 = await Page.findOne({ path: '/v5_pageForRename17' });
+      const newlyCreatedEmptyPage2 = await Page.findOne({ path: '/v5_pageForRename17/v5_pageForRename18' });
+
+      expectAllToBeTruthy([renamedPage, renamedPageChild, newlyCreatedEmptyPage1, newlyCreatedEmptyPage2]);
+
+      // check parent
+      expect(newlyCreatedEmptyPage1.parent).toStrictEqual(rootPage._id);
+      expect(newlyCreatedEmptyPage2.parent).toStrictEqual(newlyCreatedEmptyPage1._id);
+      expect(renamedPage.parent).toStrictEqual(newlyCreatedEmptyPage2._id);
+      expect(renamedPageChild.parent).toStrictEqual(renamedPage._id);
+
+      // check isEmpty
+      expect(newlyCreatedEmptyPage1.isEmpty).toBeTruthy();
+      expect(newlyCreatedEmptyPage2.isEmpty).toBeTruthy();
+      expect(renamedPage.isEmpty).toBe(false);
+      expect(renamedPageChild.isEmpty).toBe(false);
+
+    });
+
+    test('Rename empty page path to its descendant non-empty page path', async() => {
+      const initialPathForPage1 = '/v5_pageForRename19';
+      const initialPathForPage2 = '/v5_pageForRename19/v5_pageForRename20';
+      const page1 = await Page.findOne({ path: initialPathForPage1, isEmpty: true });
+      const page2 = await Page.findOne({ path: initialPathForPage2, isEmpty: false, parent: page1._id });
+
+      expectAllToBeTruthy([page1, page2]);
+
+      const newParentalPath = '/v5_pageForRename19/v5_pageForRename20';
+      const newPath = newParentalPath + page1.path;
+      await renamePage(page1, newPath, dummyUser1, {});
+
+      const renamedPage = await Page.findOne({ path: newParentalPath + initialPathForPage1 });
+      const renamedPageChild = await Page.findOne({ path: newParentalPath + initialPathForPage2 });
+      const newlyCreatedEmptyPage1 = await Page.findOne({ path: '/v5_pageForRename19' });
+      const newlyCreatedEmptyPage2 = await Page.findOne({ path: '/v5_pageForRename19/v5_pageForRename20' });
+
+      expectAllToBeTruthy([renamedPage, renamedPageChild, newlyCreatedEmptyPage1, newlyCreatedEmptyPage2]);
+
+      // check parent
+      expect(newlyCreatedEmptyPage1.parent).toStrictEqual(rootPage._id);
+      expect(newlyCreatedEmptyPage2.parent).toStrictEqual(newlyCreatedEmptyPage1._id);
+      expect(renamedPage.parent).toStrictEqual(newlyCreatedEmptyPage2._id);
+      expect(renamedPageChild.parent).toStrictEqual(renamedPage._id);
+
+      // check isEmpty
+      expect(newlyCreatedEmptyPage1.isEmpty).toBeTruthy();
+      expect(newlyCreatedEmptyPage2.isEmpty).toBeTruthy();
+      expect(renamedPage.isEmpty).toBeTruthy();
+      expect(renamedPageChild.isEmpty).toBe(false);
+
+    });
+
+    test('Rename the path of a non-empty page to its grandchild page path that has an empty parent', async() => {
+      const initialPathForPage1 = '/v5_pageForRename21';
+      const initialPathForPage2 = '/v5_pageForRename21/v5_pageForRename22';
+      const initialPathForPage3 = '/v5_pageForRename21/v5_pageForRename22/v5_pageForRename23';
+      const page1 = await Page.findOne({ path: initialPathForPage1, isEmpty: false });
+      const page2 = await Page.findOne({ path: initialPathForPage2, isEmpty: true, parent: page1._id });
+      const page3 = await Page.findOne({ path: initialPathForPage3, isEmpty: false, parent: page2._id });
+
+      expectAllToBeTruthy([page1, page2, page3]);
+
+      const newParentalPath = '/v5_pageForRename21/v5_pageForRename22/v5_pageForRename23';
+      const newPath = newParentalPath + page1.path;
+
+      await renamePage(page1, newPath, dummyUser1, {});
+
+      const renamedPage = await Page.findOne({ path: newParentalPath + initialPathForPage1 });
+      const renamedPageChild = await Page.findOne({ path: newParentalPath + initialPathForPage2 });
+      const renamedPageGrandchild = await Page.findOne({ path: newParentalPath + initialPathForPage3 });
+
+      const newlyCreatedEmptyPage1 = await Page.findOne({ path: '/v5_pageForRename21' });
+      const newlyCreatedEmptyPage2 = await Page.findOne({ path: '/v5_pageForRename21/v5_pageForRename22' });
+      const newlyCreatedEmptyPage3 = await Page.findOne({ path: '/v5_pageForRename21/v5_pageForRename22/v5_pageForRename23' });
+
+      expectAllToBeTruthy([renamedPage, renamedPageChild, renamedPageGrandchild, newlyCreatedEmptyPage1, newlyCreatedEmptyPage2, newlyCreatedEmptyPage3]);
+
+      // check parent
+      expect(newlyCreatedEmptyPage1.parent).toStrictEqual(rootPage._id);
+      expect(newlyCreatedEmptyPage2.parent).toStrictEqual(newlyCreatedEmptyPage1._id);
+      expect(newlyCreatedEmptyPage3.parent).toStrictEqual(newlyCreatedEmptyPage2._id);
+      expect(renamedPage.parent).toStrictEqual(newlyCreatedEmptyPage3._id);
+      expect(renamedPageChild.parent).toStrictEqual(renamedPage._id);
+      expect(renamedPageGrandchild.parent).toStrictEqual(renamedPageChild._id);
+
+      // check isEmpty
+      expect(newlyCreatedEmptyPage1.isEmpty).toBeTruthy();
+      expect(newlyCreatedEmptyPage2.isEmpty).toBeTruthy();
+      expect(newlyCreatedEmptyPage3.isEmpty).toBeTruthy();
+      expect(renamedPage.isEmpty).toBe(false);
+      expect(renamedPageChild.isEmpty).toBeTruthy();
+      expect(renamedPageGrandchild.isEmpty).toBe(false);
+    });
   });
 
   describe('Duplicate', () => {