Explorar o código

Merge pull request #6150 from weseek/master

Release v5.0.11
Yuki Takei %!s(int64=3) %!d(string=hai) anos
pai
achega
606e62a9bd
Modificáronse 50 ficheiros con 1057 adicións e 831 borrados
  1. 1 1
      lerna.json
  2. 1 1
      package.json
  3. 7 7
      packages/app/package.json
  4. 1 6
      packages/app/src/client/app.jsx
  5. 1 3
      packages/app/src/client/services/ContextExtractor.tsx
  6. 0 1
      packages/app/src/client/services/PageContainer.js
  7. 0 172
      packages/app/src/client/services/PageHistoryContainer.js
  8. 0 113
      packages/app/src/client/services/RevisionComparerContainer.js
  9. 1 0
      packages/app/src/components/Common/Dropdown/PageItemControl.tsx
  10. 2 1
      packages/app/src/components/Me/PersonalSettings.jsx
  11. 8 1
      packages/app/src/components/Navbar/GlobalSearch.tsx
  12. 2 3
      packages/app/src/components/Page/TagsInput.tsx
  13. 4 3
      packages/app/src/components/Page/TrashPageAlert.jsx
  14. 35 59
      packages/app/src/components/PageHistory.jsx
  15. 32 33
      packages/app/src/components/PageHistory/PageRevisionTable.jsx
  16. 2 2
      packages/app/src/components/PageHistory/Revision.jsx
  17. 24 26
      packages/app/src/components/RevisionComparer/RevisionComparer.jsx
  18. 5 0
      packages/app/src/interfaces/revision.ts
  19. 1 2
      packages/app/src/next-i18next.config.ts
  20. 2 2
      packages/app/src/server/crowi/dev.js
  21. 2 9
      packages/app/src/server/crowi/express-init.js
  22. 12 12
      packages/app/src/server/middlewares/login-required.js
  23. 1 12
      packages/app/src/server/models/obsolete-page.js
  24. 2 2
      packages/app/src/server/models/user.js
  25. 2 2
      packages/app/src/server/routes/apiv3/app-settings.js
  26. 3 3
      packages/app/src/server/routes/apiv3/forgot-password.js
  27. 3 2
      packages/app/src/server/routes/apiv3/pages.js
  28. 2 2
      packages/app/src/server/routes/apiv3/personal-setting.js
  29. 3 3
      packages/app/src/server/routes/index.js
  30. 1 2
      packages/app/src/server/routes/login.js
  31. 25 11
      packages/app/src/server/service/page-operation.ts
  32. 41 18
      packages/app/src/server/service/page.ts
  33. 0 1
      packages/app/src/server/views/layout-growi/identical-path-page.html
  34. 2 2
      packages/app/src/server/views/widget/page_alerts.html
  35. 0 2
      packages/app/src/server/views/widget/page_content.html
  36. 0 4
      packages/app/src/stores/context.tsx
  37. 21 0
      packages/app/src/stores/page.tsx
  38. 15 15
      packages/app/src/stores/ui.tsx
  39. 174 0
      packages/app/test/cypress/integration/30-search/search.spec.ts
  40. 142 45
      packages/app/test/integration/middlewares/login-required.test.js
  41. 346 2
      packages/app/test/integration/models/v5.page.test.js
  42. 122 237
      packages/app/test/integration/service/v5.page.test.ts
  43. 1 1
      packages/codemirror-textlint/package.json
  44. 1 1
      packages/core/package.json
  45. 1 1
      packages/plugin-attachment-refs/package.json
  46. 1 1
      packages/plugin-lsx/package.json
  47. 1 1
      packages/plugin-pukiwiki-like-linker/package.json
  48. 1 1
      packages/slack/package.json
  49. 2 2
      packages/slackbot-proxy/package.json
  50. 1 1
      packages/ui/package.json

+ 1 - 1
lerna.json

@@ -1,7 +1,7 @@
 {
   "npmClient": "yarn",
   "useWorkspaces": true,
-  "version": "5.0.10",
+  "version": "5.0.11-RC.0",
   "packages": [
     "packages/*"
   ]

+ 1 - 1
package.json

@@ -1,6 +1,6 @@
 {
   "name": "growi",
-  "version": "5.0.10",
+  "version": "5.0.11-RC.0",
   "description": "Team collaboration software using markdown",
   "tags": [
     "wiki",

+ 7 - 7
packages/app/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@growi/app",
-  "version": "5.0.10",
+  "version": "5.0.11-RC.0",
   "license": "MIT",
   "scripts": {
     "//// for production": "",
@@ -64,11 +64,11 @@
     "@elastic/elasticsearch7": "npm:@elastic/elasticsearch@^7.17.0",
     "@godaddy/terminus": "^4.9.0",
     "@google-cloud/storage": "^5.8.5",
-    "@growi/codemirror-textlint": "^5.0.10",
-    "@growi/plugin-attachment-refs": "^5.0.10",
-    "@growi/plugin-lsx": "^5.0.10",
-    "@growi/plugin-pukiwiki-like-linker": "^5.0.10",
-    "@growi/slack": "^5.0.10",
+    "@growi/codemirror-textlint": "^5.0.11-RC.0",
+    "@growi/plugin-attachment-refs": "^5.0.11-RC.0",
+    "@growi/plugin-lsx": "^5.0.11-RC.0",
+    "@growi/plugin-pukiwiki-like-linker": "^5.0.11-RC.0",
+    "@growi/slack": "^5.0.11-RC.0",
     "@promster/express": "^7.0.2",
     "@promster/server": "^7.0.4",
     "@slack/events-api": "^3.0.0",
@@ -169,7 +169,7 @@
   },
   "devDependencies": {
     "@alienfast/i18next-loader": "^1.1.4",
-    "@growi/ui": "^5.0.10",
+    "@growi/ui": "^5.0.11-RC.0",
     "@handsontable/react": "=2.1.0",
     "@types/compression": "^1.7.0",
     "@types/express": "^4.17.11",

+ 1 - 6
packages/app/src/client/app.jsx

@@ -10,9 +10,7 @@ import { Provider } from 'unstated';
 import ContextExtractor from '~/client/services/ContextExtractor';
 import EditorContainer from '~/client/services/EditorContainer';
 import PageContainer from '~/client/services/PageContainer';
-import PageHistoryContainer from '~/client/services/PageHistoryContainer';
 import PersonalContainer from '~/client/services/PersonalContainer';
-import RevisionComparerContainer from '~/client/services/RevisionComparerContainer';
 import IdenticalPathPage from '~/components/IdenticalPathPage';
 import PrivateLegacyPages from '~/components/PrivateLegacyPages';
 import loggerFactory from '~/utils/logger';
@@ -58,13 +56,10 @@ const socketIoContainer = appContainer.getContainer('SocketIoContainer');
 
 // create unstated container instance
 const pageContainer = new PageContainer(appContainer);
-const pageHistoryContainer = new PageHistoryContainer(appContainer, pageContainer);
-const revisionComparerContainer = new RevisionComparerContainer(appContainer, pageContainer);
 const editorContainer = new EditorContainer(appContainer);
 const personalContainer = new PersonalContainer(appContainer);
 const injectableContainers = [
-  appContainer, socketIoContainer, pageContainer, pageHistoryContainer, revisionComparerContainer,
-  editorContainer, personalContainer,
+  appContainer, socketIoContainer, pageContainer, editorContainer, personalContainer,
 ];
 
 logger.info('unstated containers have been initialized');

+ 1 - 3
packages/app/src/client/services/ContextExtractor.tsx

@@ -13,7 +13,7 @@ import { useSetupGlobalSocket, useSetupGlobalAdminSocket } from '~/stores/websoc
 import {
   useSiteUrl,
   useCurrentCreatedAt, useDeleteUsername, useDeletedAt, useHasChildren, useHasDraftOnHackmd,
-  useIsDeleted, useIsNotCreatable, useIsTrashPage, useIsUserPage, useLastUpdateUsername,
+  useIsNotCreatable, useIsTrashPage, useIsUserPage, useLastUpdateUsername,
   useCurrentPageId, usePageIdOnHackmd, usePageUser, useCurrentPagePath, useRevisionCreatedAt, useRevisionId, useRevisionIdHackmdSynced,
   useShareLinkId, useShareLinksNumber, useTemplateTagData, useCurrentUpdatedAt, useCreator, useRevisionAuthor, useCurrentUser, useTargetAndAncestors,
   useNotFoundTargetPathOrId, useIsSearchPage, useIsForbidden, useIsIdenticalPath, useHasParent,
@@ -74,7 +74,6 @@ const ContextExtractorOnce: FC = () => {
   const isIdenticalPath = JSON.parse(mainContent?.getAttribute('data-identical-path') || jsonNull) ?? false;
   const isUserPage = JSON.parse(mainContent?.getAttribute('data-page-user') || jsonNull) != null;
   const isTrashPage = _isTrashPage(path);
-  const isDeleted = JSON.parse(mainContent?.getAttribute('data-page-is-deleted') || jsonNull) ?? false;
   const isNotCreatable = JSON.parse(mainContent?.getAttribute('data-page-is-not-creatable') || jsonNull) ?? false;
   const isForbidden = forbiddenContent != null;
   const pageUser = JSON.parse(mainContent?.getAttribute('data-page-user') || jsonNull);
@@ -132,7 +131,6 @@ const ContextExtractorOnce: FC = () => {
   useHasChildren(hasChildren);
   useHasDraftOnHackmd(hasDraftOnHackmd);
   useIsIdenticalPath(isIdenticalPath);
-  useIsDeleted(isDeleted);
   useIsNotCreatable(isNotCreatable);
   useIsForbidden(isForbidden);
   useIsTrashPage(isTrashPage);

+ 0 - 1
packages/app/src/client/services/PageContainer.js

@@ -61,7 +61,6 @@ export default class PageContainer extends Container {
 
       isUserPage: JSON.parse(mainContent.getAttribute('data-page-user')) != null,
       isTrashPage: isTrashPage(path),
-      isDeleted: JSON.parse(mainContent.getAttribute('data-page-is-deleted')),
       isNotCreatable: JSON.parse(mainContent.getAttribute('data-page-is-not-creatable')),
       isPageExist: mainContent.getAttribute('data-page-id') != null,
 

+ 0 - 172
packages/app/src/client/services/PageHistoryContainer.js

@@ -1,172 +0,0 @@
-import { Container } from 'unstated';
-
-import loggerFactory from '~/utils/logger';
-
-import { toastError } from '../util/apiNotification';
-import { apiv3Get } from '../util/apiv3-client';
-
-const logger = loggerFactory('growi:PageHistoryContainer');
-
-/**
- * Service container for personal settings page (PageHistory.jsx)
- * @extends {Container} unstated Container
- */
-export default class PageHistoryContainer extends Container {
-
-  constructor(appContainer, pageContainer) {
-    super();
-
-    this.appContainer = appContainer;
-    this.pageContainer = pageContainer;
-    this.dummyRevisions = 0;
-
-    this.state = {
-      errorMessage: null,
-
-      // set dummy rivisions for using suspense
-      revisions: this.dummyRevisions,
-      latestRevision: this.dummyRevisions,
-      oldestRevision: this.dummyRevisions,
-      diffOpened: {},
-
-      totalPages: 0,
-      activePage: 1,
-      pagingLimit: 10,
-    };
-
-    this.retrieveRevisions = this.retrieveRevisions.bind(this);
-    this.getPreviousRevision = this.getPreviousRevision.bind(this);
-    this.fetchPageRevisionBody = this.fetchPageRevisionBody.bind(this);
-  }
-
-  /**
-   * Workaround for the mangling in production build to break constructor.name
-   */
-  static getClassName() {
-    return 'PageHistoryContainer';
-  }
-
-  /**
-   * syncRevisions of selectedPage
-   * @param {number} selectedPage
-   */
-  async retrieveRevisions(selectedPage) {
-    const { pageId, shareLinkId } = this.pageContainer.state;
-    const { pagingLimit } = this.state;
-    const page = selectedPage;
-    const pagingLimitForApiParam = pagingLimit + 1;
-
-    if (!pageId) {
-      return;
-    }
-
-    // Get one more for the bottom display
-    const res = await apiv3Get('/revisions/list', {
-      pageId, shareLinkId, page, limit: pagingLimitForApiParam,
-    });
-    const rev = res.data.docs;
-    // set Pagination state
-    this.setState({
-      activePage: selectedPage,
-      totalPages: res.data.totalDocs,
-      pagingLimit,
-    });
-
-    const diffOpened = {};
-
-    let lastId = rev.length - 1;
-
-    // If the number of rev count is the same, the last rev is for diff display, so exclude it.
-    if (rev.length > pagingLimit) {
-      lastId = rev.length - 2;
-    }
-
-    res.data.docs.forEach((revision, i) => {
-      const user = revision.author;
-      if (user) {
-        rev[i].author = user;
-      }
-
-      if (i === 0 || i === lastId) {
-        diffOpened[revision._id] = true;
-      }
-      else {
-        diffOpened[revision._id] = false;
-      }
-    });
-
-    this.setState({ revisions: rev });
-    this.setState({ diffOpened });
-
-    if (selectedPage === 1) {
-      this.setState({ latestRevision: rev[0] });
-    }
-
-    if (selectedPage === res.data.totalPages) {
-      this.setState({ oldestRevision: rev[lastId] });
-    }
-
-    // load 0, and last default
-    if (rev[0]) {
-      this.fetchPageRevisionBody(rev[0]);
-    }
-    if (rev[1]) {
-      this.fetchPageRevisionBody(rev[1]);
-    }
-    if (lastId !== 0 && lastId !== 1 && rev[lastId]) {
-      this.fetchPageRevisionBody(rev[lastId]);
-    }
-
-    return;
-  }
-
-  getPreviousRevision(currentRevision) {
-    let cursor = null;
-    for (const revision of this.state.revisions) {
-      // comparing ObjectId
-      // eslint-disable-next-line eqeqeq
-      if (cursor && cursor._id == currentRevision._id) {
-        cursor = revision;
-        break;
-      }
-
-      cursor = revision;
-    }
-
-    return cursor;
-  }
-
-  /**
-   * fetch page revision body by revision in argument
-   * @param {object} revision
-   */
-  async fetchPageRevisionBody(revision) {
-    const { pageId, shareLinkId } = this.pageContainer.state;
-
-    if (revision.body) {
-      return;
-    }
-
-    try {
-      const res = await apiv3Get(`/revisions/${revision._id}`, { pageId, shareLinkId });
-      this.setState({
-        revisions: this.state.revisions.map((rev) => {
-          // comparing ObjectId
-          // eslint-disable-next-line eqeqeq
-          if (rev._id == res.data.revision._id) {
-            return res.data.revision;
-          }
-
-          return rev;
-        }),
-      });
-    }
-    catch (err) {
-      toastError(err);
-      this.setState({ errorMessage: err.message });
-      logger.error(err);
-    }
-  }
-
-
-}

+ 0 - 113
packages/app/src/client/services/RevisionComparerContainer.js

@@ -1,113 +0,0 @@
-import { Container } from 'unstated';
-
-import loggerFactory from '~/utils/logger';
-
-import { toastError } from '../util/apiNotification';
-import { apiv3Get } from '../util/apiv3-client';
-
-const logger = loggerFactory('growi:PageHistoryContainer');
-
-/**
- * Service container for personal settings page (RevisionCompare.jsx)
- * @extends {Container} unstated Container
- */
-export default class RevisionComparerContainer extends Container {
-
-  constructor(appContainer, pageContainer) {
-    super();
-
-    this.appContainer = appContainer;
-    this.pageContainer = pageContainer;
-
-    this.state = {
-      errMessage: null,
-
-      sourceRevision: null,
-      targetRevision: null,
-      latestRevision: null,
-    };
-
-    this.initRevisions = this.initRevisions.bind(this);
-  }
-
-  /**
-   * Workaround for the mangling in production build to break constructor.name
-   */
-  static getClassName() {
-    return 'RevisionComparerContainer';
-  }
-
-  /**
-   * Initialize the revisions
-   */
-  async initRevisions() {
-    const latestRevision = await this.fetchLatestRevision();
-
-    const [sourceRevisionId, targetRevisionId] = this.getRevisionIDsToCompareAsParam();
-    const sourceRevision = sourceRevisionId ? await this.fetchRevision(sourceRevisionId) : latestRevision;
-    const targetRevision = targetRevisionId ? await this.fetchRevision(targetRevisionId) : latestRevision;
-    const compareWithLatest = targetRevisionId ? false : this.state.compareWithLatest;
-
-    this.setState({
-      sourceRevision, targetRevision, latestRevision, compareWithLatest,
-    });
-  }
-
-  /**
-   * Get the IDs of the comparison source and target from "window.location" as an array
-   */
-  getRevisionIDsToCompareAsParam() {
-    const searchParams = {};
-    for (const param of window.location.search?.substr(1)?.split('&')) {
-      const [k, v] = param.split('=');
-      searchParams[k] = v;
-    }
-    if (!searchParams.compare) {
-      return [];
-    }
-
-    return searchParams.compare.split('...') || [];
-  }
-
-  /**
-   * Fetch the latest revision
-   */
-  async fetchLatestRevision() {
-    const { pageId, shareLinkId } = this.pageContainer.state;
-
-    try {
-      const res = await apiv3Get('/revisions/list', {
-        pageId, shareLinkId, page: 1, limit: 1,
-      });
-      return res.data.docs[0];
-    }
-    catch (err) {
-      toastError(err);
-      this.setState({ errorMessage: err.message });
-      logger.error(err);
-    }
-    return null;
-  }
-
-  /**
-   * Fetch the revision of the specified ID
-   * @param {string} revision ID
-   */
-  async fetchRevision(revisionId) {
-    const { pageId, shareLinkId } = this.pageContainer.state;
-
-    try {
-      const res = await apiv3Get(`/revisions/${revisionId}`, {
-        pageId, shareLinkId,
-      });
-      return res.data.revision;
-    }
-    catch (err) {
-      toastError(err);
-      this.setState({ errorMessage: err.message });
-      logger.error(err);
-    }
-    return null;
-  }
-
-}

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

@@ -153,6 +153,7 @@ const PageItemControlDropdownMenu = React.memo((props: DropdownMenuProps): JSX.E
           <DropdownItem
             onClick={bookmarkItemClickedHandler}
             className="grw-page-control-dropdown-item"
+            data-testid="add-remove-bookmark-btn"
           >
             <i className="fa fa-fw fa-bookmark-o grw-page-control-dropdown-icon"></i>
             { pageInfo.isBookmarked ? t('remove_bookmark') : t('add_bookmark') }

+ 2 - 1
packages/app/src/components/Me/PersonalSettings.jsx

@@ -58,10 +58,11 @@ const PersonalSettings = () => {
     };
   }, [t]);
 
+  const onPasswordSettings = window.location.hash === '#password';
 
   return (
     <div data-testid="grw-personal-settings">
-      <CustomNavAndContents navTabMapping={navTabMapping} navigationMode="both" tabContentClasses={['px-0']} />
+      <CustomNavAndContents defaultTabIndex={onPasswordSettings && 2} navTabMapping={navTabMapping} navigationMode="both" tabContentClasses={['px-0']} />
     </div>
   );
 

+ 8 - 1
packages/app/src/components/Navbar/GlobalSearch.tsx

@@ -73,7 +73,13 @@ const GlobalSearch: FC<Props> = (props: Props) => {
     <div className={`form-group mb-0 d-print-none ${isSearchServiceReachable ? '' : 'has-error'}`}>
       <div className="input-group flex-nowrap">
         <div className={`input-group-prepend ${dropup ? 'dropup' : ''}`}>
-          <button className="btn btn-secondary dropdown-toggle py-0" type="button" data-toggle="dropdown" aria-haspopup="true">
+          <button
+            className="btn btn-secondary dropdown-toggle py-0"
+            type="button"
+            data-toggle="dropdown"
+            aria-haspopup="true"
+            data-testid="select-search-scope"
+          >
             {scopeLabel}
           </button>
           <div className="dropdown-menu">
@@ -88,6 +94,7 @@ const GlobalSearch: FC<Props> = (props: Props) => {
               { t('header_search_box.item_label.All pages') }
             </button>
             <button
+              data-tesid="search-current-tree"
               className="dropdown-item"
               type="button"
               onClick={() => {

+ 2 - 3
packages/app/src/components/Page/TagsInput.tsx

@@ -38,11 +38,10 @@ const TagsInput: FC<Props> = (props: Props) => {
   const searchHandler = useCallback(async(query: string) => {
     const tagsSearchData = tagsSearch?.tags || [];
     setSearchQuery(query);
-
-    tagsSearchData.unshift(searchQuery);
+    tagsSearchData.unshift(query);
     setResultTags(Array.from(new Set(tagsSearchData)));
 
-  }, [searchQuery, tagsSearch?.tags]);
+  }, [tagsSearch?.tags]);
 
   const keyDownHandler = useCallback((event: React.KeyboardEvent) => {
     if (event.key === ' ') {

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

@@ -5,7 +5,7 @@ import PropTypes from 'prop-types';
 import { useTranslation } from 'react-i18next';
 
 import PageContainer from '~/client/services/PageContainer';
-import { useCurrentUpdatedAt, useShareLinkId } from '~/stores/context';
+import { useCurrentUpdatedAt, useIsTrashPage, useShareLinkId } from '~/stores/context';
 import { usePageDeleteModal, usePutBackPageModal } from '~/stores/modal';
 import { useSWRxPageInfo } from '~/stores/page';
 import { useIsAbleToShowTrashPageManagementButtons } from '~/stores/ui';
@@ -24,7 +24,7 @@ const TrashPageAlert = (props) => {
   const { t } = useTranslation();
   const { pageContainer } = props;
   const {
-    pageId, revisionId, path, isDeleted, lastUpdateUsername, deletedUserName, deletedAt,
+    pageId, revisionId, path, lastUpdateUsername, deletedUserName, deletedAt,
   } = pageContainer.state;
 
   const { data: isAbleToShowTrashPageManagementButtons } = useIsAbleToShowTrashPageManagementButtons();
@@ -38,6 +38,7 @@ const TrashPageAlert = (props) => {
   const { data: pageInfo } = useSWRxPageInfo(pageId ?? null, shareLinkId);
 
   const { data: updatedAt } = useCurrentUpdatedAt();
+  const { data: isTrashPage } = useIsTrashPage();
 
   const { open: openDeleteModal } = usePageDeleteModal();
   const { open: openPutBackPageModal } = usePutBackPageModal();
@@ -89,7 +90,7 @@ const TrashPageAlert = (props) => {
       <div className="alert alert-warning py-3 pl-4 d-flex flex-column flex-lg-row">
         <div className="flex-grow-1">
           This page is in the trash <i className="icon-trash" aria-hidden="true"></i>.
-          {isDeleted && (
+          {isTrashPage && (
             <>
               <br />
               <UserPicture user={{ username: deletedUserName || lastUpdateUsername }} />

+ 35 - 59
packages/app/src/components/PageHistory.jsx

@@ -1,66 +1,45 @@
-import React, { useCallback } from 'react';
-import PropTypes from 'prop-types';
-import loggerFactory from '~/utils/logger';
+import React, { useState, useEffect } from 'react';
 
-import { withUnstatedContainers } from './UnstatedUtils';
-import { toastError } from '~/client/util/apiNotification';
+import { useCurrentPageId } from '~/stores/context';
+import { useSWRxPageRevisions } from '~/stores/page';
+import loggerFactory from '~/utils/logger';
 
-import { withLoadingSppiner } from './SuspenseUtils';
 import PageRevisionTable from './PageHistory/PageRevisionTable';
-
-import PageHistroyContainer from '~/client/services/PageHistoryContainer';
 import PaginationWrapper from './PaginationWrapper';
 import RevisionComparer from './RevisionComparer/RevisionComparer';
-import RevisionComparerContainer from '~/client/services/RevisionComparerContainer';
 
 const logger = loggerFactory('growi:PageHistory');
 
-function PageHistory(props) {
-  const { pageHistoryContainer, revisionComparerContainer } = props;
-  const { getPreviousRevision } = pageHistoryContainer;
-  const {
-    activePage, totalPages, pagingLimit, revisions, diffOpened,
-  } = pageHistoryContainer.state;
+const PageHistory = () => {
+  const [activePage, setActivePage] = useState(1);
+  const { data: currentPageId } = useCurrentPageId();
+  const { data: revisionsData } = useSWRxPageRevisions(currentPageId, activePage, 10);
+  const [sourceRevision, setSourceRevision] = useState(null);
+  const [targetRevision, setTargetRevision] = useState(null);
 
-  const handlePage = useCallback(async(selectedPage) => {
-    try {
-      await props.pageHistoryContainer.retrieveRevisions(selectedPage);
-    }
-    catch (err) {
-      toastError(err);
-      props.pageHistoryContainer.setState({ errorMessage: err.message });
-      logger.error(err);
+  useEffect(() => {
+    if (revisionsData != null) {
+      setSourceRevision(revisionsData.revisions[0]);
+      setTargetRevision(revisionsData.revisions[0]);
     }
-  }, [props.pageHistoryContainer]);
+  }, [revisionsData]);
 
-  if (pageHistoryContainer.state.errorMessage != null) {
+
+  const pagingLimit = 10;
+
+  if (revisionsData == null) {
     return (
-      <div className="my-5">
-        <div className="text-danger">{pageHistoryContainer.state.errorMessage}</div>
+      <div className="text-muted text-center">
+        <i className="fa fa-2x fa-spinner fa-pulse mt-3"></i>
       </div>
     );
   }
-
-  if (pageHistoryContainer.state.revisions === pageHistoryContainer.dummyRevisions) {
-    throw new Promise(async() => {
-      try {
-        await props.pageHistoryContainer.retrieveRevisions(1);
-        await props.revisionComparerContainer.initRevisions();
-      }
-      catch (err) {
-        toastError(err);
-        pageHistoryContainer.setState({ errorMessage: err.message });
-        logger.error(err);
-      }
-    });
-  }
-
   function pager() {
     return (
       <PaginationWrapper
         activePage={activePage}
-        changePage={handlePage}
-        totalItemsCount={totalPages}
+        changePage={setActivePage}
+        totalItemsCount={revisionsData.totalCounts}
         pagingLimit={pagingLimit}
         align="center"
       />
@@ -70,26 +49,23 @@ function PageHistory(props) {
   return (
     <div className="revision-history" data-testid="page-history">
       <PageRevisionTable
-        pageHistoryContainer={pageHistoryContainer}
-        revisionComparerContainer={revisionComparerContainer}
-        revisions={revisions}
-        diffOpened={diffOpened}
-        getPreviousRevision={getPreviousRevision}
+        revisions={revisionsData.revisions}
+        pagingLimit={pagingLimit}
+        sourceRevision={sourceRevision}
+        targetRevision={targetRevision}
+        onChangeSourceInvoked={setSourceRevision}
+        onChangeTargetInvoked={setTargetRevision}
       />
       <div className="my-3">
         {pager()}
       </div>
-      <RevisionComparer />
+      <RevisionComparer
+        sourceRevision={sourceRevision}
+        targetRevision={targetRevision}
+        currentPageId={currentPageId}
+      />
     </div>
   );
-
-}
-
-const RenderPageHistoryWrapper = withUnstatedContainers(withLoadingSppiner(PageHistory), [PageHistroyContainer, RevisionComparerContainer]);
-
-PageHistory.propTypes = {
-  pageHistoryContainer: PropTypes.instanceOf(PageHistroyContainer).isRequired,
-  revisionComparerContainer: PropTypes.instanceOf(RevisionComparerContainer).isRequired,
 };
 
-export default RenderPageHistoryWrapper;
+export default PageHistory;

+ 32 - 33
packages/app/src/components/PageHistory/PageRevisionTable.jsx

@@ -3,9 +3,6 @@ import React from 'react';
 import PropTypes from 'prop-types';
 import { useTranslation } from 'react-i18next';
 
-import PageHistroyContainer from '~/client/services/PageHistoryContainer';
-import RevisionComparerContainer from '~/client/services/RevisionComparerContainer';
-
 import Revision from './Revision';
 
 class PageRevisionTable extends React.Component {
@@ -17,21 +14,20 @@ class PageRevisionTable extends React.Component {
    * @param {boolean} hasDiff whether revision has difference to previousRevision
    * @param {boolean} isContiguousNodiff true if the current 'hasDiff' and one of previous row is both false
    */
-  renderRow(revision, previousRevision, hasDiff, isContiguousNodiff) {
-    const { revisionComparerContainer, t } = this.props;
-    const { latestRevision, oldestRevision } = this.props.pageHistoryContainer.state;
+  renderRow(revision, previousRevision, latestRevision, isOldestRevision, hasDiff) {
+    const {
+      t, sourceRevision, targetRevision, onChangeSourceInvoked, onChangeTargetInvoked,
+    } = this.props;
     const revisionId = revision._id;
-    const revisionDiffOpened = this.props.diffOpened[revisionId] || false;
-    const { sourceRevision, targetRevision } = revisionComparerContainer.state;
 
     const handleCompareLatestRevisionButton = () => {
-      revisionComparerContainer.setState({ sourceRevision: revision });
-      revisionComparerContainer.setState({ targetRevision: latestRevision });
+      onChangeSourceInvoked(revision);
+      onChangeTargetInvoked(latestRevision);
     };
 
     const handleComparePreviousRevisionButton = () => {
-      revisionComparerContainer.setState({ sourceRevision: previousRevision });
-      revisionComparerContainer.setState({ targetRevision: revision });
+      onChangeSourceInvoked(previousRevision);
+      onChangeTargetInvoked(revision);
     };
 
     return (
@@ -42,7 +38,6 @@ class PageRevisionTable extends React.Component {
               t={this.props.t}
               revision={revision}
               isLatestRevision={revision === latestRevision}
-              revisionDiffOpened={revisionDiffOpened}
               hasDiff={hasDiff}
               key={`revision-history-rev-${revisionId}`}
             />
@@ -60,7 +55,7 @@ class PageRevisionTable extends React.Component {
                     type="button"
                     className="btn btn-outline-secondary btn-sm"
                     onClick={handleComparePreviousRevisionButton}
-                    disabled={revision === oldestRevision}
+                    disabled={isOldestRevision}
                   >
                     {t('page_history.compare_previous')}
                   </button>
@@ -70,34 +65,34 @@ class PageRevisionTable extends React.Component {
           </div>
         </td>
         <td className="col-1">
-          {(hasDiff || revision._id === sourceRevision?._id) && (
+          {(hasDiff || revisionId === sourceRevision?._id) && (
             <div className="custom-control custom-radio custom-control-inline mr-0">
               <input
                 type="radio"
                 className="custom-control-input"
-                id={`compareSource-${revision._id}`}
+                id={`compareSource-${revisionId}`}
                 name="compareSource"
-                value={revision._id}
-                checked={revision._id === sourceRevision?._id}
-                onChange={() => revisionComparerContainer.setState({ sourceRevision: revision })}
+                value={revisionId}
+                checked={revisionId === sourceRevision?._id}
+                onChange={() => onChangeSourceInvoked(revision)}
               />
-              <label className="custom-control-label" htmlFor={`compareSource-${revision._id}`} />
+              <label className="custom-control-label" htmlFor={`compareSource-${revisionId}`} />
             </div>
           )}
         </td>
         <td className="col-2">
-          {(hasDiff || revision._id === targetRevision?._id) && (
+          {(hasDiff || revisionId === targetRevision?._id) && (
             <div className="custom-control custom-radio custom-control-inline mr-0">
               <input
                 type="radio"
                 className="custom-control-input"
-                id={`compareTarget-${revision._id}`}
+                id={`compareTarget-${revisionId}`}
                 name="compareTarget"
-                value={revision._id}
-                checked={revision._id === targetRevision?._id}
-                onChange={() => revisionComparerContainer.setState({ targetRevision: revision })}
+                value={revisionId}
+                checked={revisionId === targetRevision?._id}
+                onChange={() => onChangeTargetInvoked(revision)}
               />
-              <label className="custom-control-label" htmlFor={`compareTarget-${revision._id}`} />
+              <label className="custom-control-label" htmlFor={`compareTarget-${revisionId}`} />
             </div>
           )}
         </td>
@@ -106,16 +101,18 @@ class PageRevisionTable extends React.Component {
   }
 
   render() {
-    const { t, pageHistoryContainer } = this.props;
+    const { t, pagingLimit } = this.props;
 
     const revisions = this.props.revisions;
     const revisionCount = this.props.revisions.length;
+    const latestRevision = revisions[0];
+    const oldestRevision = revisions[revisions.length - 1];
 
     let hasDiffPrev;
 
     const revisionList = this.props.revisions.map((revision, idx) => {
       // Returns null because the last revision is for the bottom diff display
-      if (idx === pageHistoryContainer.state.pagingLimit) {
+      if (idx === pagingLimit) {
         return null;
       }
 
@@ -127,13 +124,13 @@ class PageRevisionTable extends React.Component {
         previousRevision = revision; // if it is the first revision, show full text as diff text
       }
 
+      const isOldestRevision = revision === oldestRevision;
 
       const hasDiff = revision.hasDiffToPrev !== false; // set 'true' if undefined for backward compatibility
-      const isContiguousNodiff = !hasDiff && !hasDiffPrev;
 
       hasDiffPrev = hasDiff;
 
-      return this.renderRow(revision, previousRevision, hasDiff, isContiguousNodiff);
+      return this.renderRow(revision, previousRevision, latestRevision, isOldestRevision, hasDiff);
     });
 
     return (
@@ -156,11 +153,13 @@ class PageRevisionTable extends React.Component {
 
 PageRevisionTable.propTypes = {
   t: PropTypes.func.isRequired, // i18next
-  pageHistoryContainer: PropTypes.instanceOf(PageHistroyContainer).isRequired,
-  revisionComparerContainer: PropTypes.instanceOf(RevisionComparerContainer).isRequired,
 
   revisions: PropTypes.array,
-  diffOpened: PropTypes.object,
+  pagingLimit: PropTypes.number,
+  sourceRevision: PropTypes.instanceOf(Object),
+  targetRevision: PropTypes.instanceOf(Object),
+  onChangeSourceInvoked: PropTypes.func.isRequired,
+  onChangeTargetInvoked: PropTypes.func.isRequired,
 };
 
 const PageRevisionTableWrapperFC = (props) => {

+ 2 - 2
packages/app/src/components/PageHistory/Revision.jsx

@@ -1,7 +1,8 @@
 import React from 'react';
-import PropTypes from 'prop-types';
 
 import { UserPicture } from '@growi/ui';
+import PropTypes from 'prop-types';
+
 import UserDate from '../User/UserDate';
 import Username from '../User/Username';
 
@@ -83,6 +84,5 @@ Revision.propTypes = {
   t: PropTypes.func.isRequired, // i18next
   revision: PropTypes.object,
   isLatestRevision: PropTypes.bool.isRequired,
-  revisionDiffOpened: PropTypes.bool.isRequired,
   hasDiff: PropTypes.bool.isRequired,
 };

+ 24 - 26
packages/app/src/components/RevisionComparer/RevisionComparer.jsx

@@ -1,4 +1,4 @@
-import React, { useState } from 'react';
+import React, { useState, useEffect } from 'react';
 
 import { pagePathUtils } from '@growi/core';
 import PropTypes from 'prop-types';
@@ -8,11 +8,9 @@ import {
   Dropdown, DropdownToggle, DropdownMenu, DropdownItem,
 } from 'reactstrap';
 
-
-import RevisionComparerContainer from '~/client/services/RevisionComparerContainer';
+import { useCurrentPagePath } from '~/stores/context';
 
 import RevisionDiff from '../PageHistory/RevisionDiff';
-import { withUnstatedContainers } from '../UnstatedUtils';
 
 
 const { encodeSpaces } = pagePathUtils;
@@ -29,12 +27,13 @@ const DropdownItemContents = ({ title, contents }) => (
 
 const RevisionComparer = (props) => {
 
-  const [dropdownOpen, setDropdownOpen] = useState(false);
-
   const { t } = useTranslation();
-  const { revisionComparerContainer } = props;
-
-  const { path, pageId } = revisionComparerContainer.pageContainer.state;
+  const { data: currentPagePath } = useCurrentPagePath();
+  const [dropdownOpen, setDropdownOpen] = useState(false);
+  const {
+    sourceRevision, targetRevision,
+    currentPageId,
+  } = props;
 
   function toggleDropdown() {
     setDropdownOpen(!dropdownOpen);
@@ -42,7 +41,6 @@ const RevisionComparer = (props) => {
 
   const generateURL = (pathName) => {
     const { origin } = window.location;
-    const { sourceRevision, targetRevision } = revisionComparerContainer.state;
 
     const url = new URL(pathName, origin);
 
@@ -55,13 +53,17 @@ const RevisionComparer = (props) => {
 
   };
 
-  const { sourceRevision, targetRevision } = revisionComparerContainer.state;
-
+  let isNodiff;
   if (sourceRevision == null || targetRevision == null) {
-    return null;
+    isNodiff = true;
+  }
+  else {
+    isNodiff = sourceRevision._id === targetRevision._id;
   }
 
-  const isNodiff = sourceRevision._id === targetRevision._id;
+  if (currentPageId == null || currentPagePath == null) {
+    return <>{ t('not_found_page.page_not_exist')}</>;
+  }
 
   return (
     <div className="revision-compare">
@@ -80,15 +82,15 @@ const RevisionComparer = (props) => {
           </DropdownToggle>
           <DropdownMenu positionFixed right modifiers={{ preventOverflow: { boundariesElement: undefined } }}>
             {/* Page path URL */}
-            <CopyToClipboard text={generateURL(path)}>
+            <CopyToClipboard text={generateURL(currentPagePath)}>
               <DropdownItem className="px-3">
-                <DropdownItemContents title={t('copy_to_clipboard.Page URL')} contents={generateURL(path)} />
+                <DropdownItemContents title={t('copy_to_clipboard.Page URL')} contents={generateURL(currentPagePath)} />
               </DropdownItem>
             </CopyToClipboard>
             {/* Permanent Link URL */}
-            <CopyToClipboard text={generateURL(pageId)}>
+            <CopyToClipboard text={generateURL(currentPageId)}>
               <DropdownItem className="px-3">
-                <DropdownItemContents title={t('copy_to_clipboard.Permanent link')} contents={generateURL(pageId)} />
+                <DropdownItemContents title={t('copy_to_clipboard.Permanent link')} contents={generateURL(currentPageId)} />
               </DropdownItem>
             </CopyToClipboard>
             <DropdownItem divider className="my-0"></DropdownItem>
@@ -115,13 +117,9 @@ const RevisionComparer = (props) => {
 };
 
 RevisionComparer.propTypes = {
-  revisionComparerContainer: PropTypes.instanceOf(RevisionComparerContainer).isRequired,
-  revisions: PropTypes.array,
+  sourceRevision: PropTypes.instanceOf(Object),
+  targetRevision: PropTypes.instanceOf(Object),
+  currentPageId: PropTypes.string,
 };
 
-/**
- * Wrapper component for using unstated
- */
-const RevisionComparerWrapper = withUnstatedContainers(RevisionComparer, [RevisionComparerContainer]);
-
-export default RevisionComparerWrapper;
+export default RevisionComparer;

+ 5 - 0
packages/app/src/interfaces/revision.ts

@@ -8,6 +8,11 @@ export type IRevision = {
   updatedAt: Date,
 }
 
+export type IRevisionsForPagination = {
+  revisions: IRevision[], // revisions in one pagination
+  totalCounts: number // total counts
+}
+
 export type IRevisionOnConflict = {
   revisionId: string,
   revisionBody: string,

+ 1 - 2
packages/app/src/next-i18next.config.ts

@@ -3,8 +3,7 @@ import path from 'path';
 export const
   i18n = {
     defaultLocale: 'en_US',
-    locales: ['ja_JP', 'zh_CN'],
+    locales: ['en_US', 'ja_JP', 'zh_CN'],
   };
 export const defaultNS = 'translation';
 export const localePath = path.resolve('./public/static/locales');
-export const allLocales = [i18n.defaultLocale].concat(i18n.locales);

+ 2 - 2
packages/app/src/server/crowi/dev.js

@@ -1,6 +1,6 @@
 import path from 'path';
 
-import { allLocales } from '~/next-i18next.config';
+import { i18n } from '~/next-i18next.config';
 import loggerFactory from '~/utils/logger';
 
 const onHeaders = require('on-headers');
@@ -42,7 +42,7 @@ class CrowiDev {
    */
   requireForAutoReloadServer() {
     // load all json files for live reloading
-    allLocales
+    i18n.locales
       .forEach((localeId) => {
         require(path.join(this.crowi.publicDir, 'static/locales', localeId, 'translation.json'));
       });

+ 2 - 9
packages/app/src/server/crowi/express-init.js

@@ -1,6 +1,6 @@
 import mongoose from 'mongoose';
 
-import { allLocales, localePath } from '~/next-i18next.config';
+import { i18n, localePath } from '~/next-i18next.config';
 
 module.exports = function(crowi, app) {
   const debug = require('debug')('growi:crowi:express-init');
@@ -42,7 +42,7 @@ module.exports = function(crowi, app) {
     .init({
       // debug: true,
       fallbackLng: ['en_US'],
-      whitelist: allLocales,
+      whitelist: i18n.locales,
       backend: {
         loadPath: `${localePath}/{{lng}}/translation.json`,
       },
@@ -79,13 +79,6 @@ module.exports = function(crowi, app) {
     res.locals.baseUrl = crowi.appService.getSiteUrl();
     res.locals.env = env;
     res.locals.now = now;
-    res.locals.consts = {
-      pageGrants: Page.getGrantLabels(),
-      userStatus: User.getUserStatusLabels(),
-      language:   allLocales,
-      restrictGuestMode: crowi.aclService.getRestrictGuestModeLabels(),
-      registrationMode: crowi.aclService.getRegistrationModeLabels(),
-    };
     res.locals.local_config = Config.getLocalconfig(crowi); // config for browser context
 
     next();

+ 12 - 12
packages/app/src/server/middlewares/login-required.js

@@ -12,18 +12,6 @@ module.exports = (crowi, isGuestAllowed = false, fallback = null) => {
 
   return function(req, res, next) {
 
-    // check the route config and ACL
-    if (isGuestAllowed && crowi.aclService.isGuestAllowedToRead()) {
-      logger.debug('Allowed to read: ', req.path);
-      return next();
-    }
-
-    // check the page is shared
-    if (isGuestAllowed && req.isSharedPage) {
-      logger.debug('Target page is shared page');
-      return next();
-    }
-
     const User = crowi.model('User');
 
     // check the user logged in
@@ -43,6 +31,18 @@ module.exports = (crowi, isGuestAllowed = false, fallback = null) => {
       }
     }
 
+    // check the route config and ACL
+    if (isGuestAllowed && crowi.aclService.isGuestAllowedToRead()) {
+      logger.debug('Allowed to read: ', req.path);
+      return next();
+    }
+
+    // check the page is shared
+    if (isGuestAllowed && req.isSharedPage) {
+      logger.debug('Target page is shared page');
+      return next();
+    }
+
     // is api path
     const baseUrl = req.baseUrl || '';
     if (baseUrl.match(/^\/_api\/.+$/)) {

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

@@ -98,7 +98,7 @@ export const getPageSchema = (crowi) => {
   }
 
   pageSchema.methods.isDeleted = function() {
-    return (this.status === STATUS_DELETED) || isTrashPage(this.path);
+    return isTrashPage(this.path);
   };
 
   pageSchema.methods.isPublic = function() {
@@ -286,17 +286,6 @@ export const getPageSchema = (crowi) => {
       });
   };
 
-  pageSchema.statics.getGrantLabels = function() {
-    const grantLabels = {};
-    grantLabels[GRANT_PUBLIC] = 'Public'; // 公開
-    grantLabels[GRANT_RESTRICTED] = 'Anyone with the link'; // リンクを知っている人のみ
-    // grantLabels[GRANT_SPECIFIED]  = 'Specified users only'; // 特定ユーザーのみ
-    grantLabels[GRANT_USER_GROUP] = 'Only inside the group'; // 特定グループのみ
-    grantLabels[GRANT_OWNER] = 'Only me'; // 自分のみ
-
-    return grantLabels;
-  };
-
   pageSchema.statics.getUserPagePath = function(user) {
     return `/user/${user.username}`;
   };

+ 2 - 2
packages/app/src/server/models/user.js

@@ -1,5 +1,5 @@
 /* eslint-disable no-use-before-define */
-import { allLocales } from '~/next-i18next.config';
+import { i18n } from '~/next-i18next.config';
 import { generateGravatarSrc } from '~/utils/gravatar';
 import loggerFactory from '~/utils/logger';
 
@@ -59,7 +59,7 @@ module.exports = function(crowi) {
     apiToken: { type: String, index: true },
     lang: {
       type: String,
-      enum: allLocales,
+      enum: i18n.locales,
       default: 'en_US',
     },
     status: {

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

@@ -1,6 +1,6 @@
 import { body } from 'express-validator';
 
-import { allLocales } from '~/next-i18next.config';
+import { i18n } from '~/next-i18next.config';
 import loggerFactory from '~/utils/logger';
 
 import { apiV3FormValidator } from '../../middlewares/apiv3-form-validator';
@@ -156,7 +156,7 @@ module.exports = (crowi) => {
     appSetting: [
       body('title').trim(),
       body('confidential'),
-      body('globalLang').isIn(allLocales),
+      body('globalLang').isIn(i18n.locales),
       body('isEmailPublishedForNewUser').isBoolean(),
       body('fileUpload').isBoolean(),
     ],

+ 3 - 3
packages/app/src/server/routes/apiv3/forgot-password.js

@@ -42,10 +42,10 @@ module.exports = (crowi) => {
   };
 
   const apiLimiter = rateLimit({
-    windowMs: 15 * 60 * 1000, // 15 minutes
-    max: 10, // limit each IP to 10 requests per windowMs
+    windowMs: 1 * 60 * 1000, // 1 minutes
+    max: 30, // limit each IP to 30 requests per windowMs
     message:
-      'Too many requests were sent from this IP. Please try a password reset request again on the password reset request form',
+    'Too many requests were sent from this IP. Please try a password reset request again on the password reset request form',
   });
 
   const checkPassportStrategyMiddleware = checkForgotPasswordEnabledMiddlewareFactory(crowi, true);

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

@@ -571,7 +571,8 @@ module.exports = (crowi) => {
     }
 
     try {
-      await crowi.pageService.resumeRenameSubOperation(page);
+      const pageOp = await crowi.pageOperationService.getRenameSubOperationByPageId(page._id);
+      await crowi.pageService.resumeRenameSubOperation(page, pageOp);
     }
     catch (err) {
       logger.error(err);
@@ -594,7 +595,7 @@ module.exports = (crowi) => {
   router.delete('/empty-trash', accessTokenParser, loginRequired, csrf, apiV3FormValidator, async(req, res) => {
     const options = {};
 
-    const pagesInTrash = await Page.findChildrenByParentPathOrIdAndViewer('/trash', req.user);
+    const pagesInTrash = await crowi.pageService.findChildrenByParentPathOrIdAndViewer('/trash', req.user);
 
     const deletablePages = crowi.pageService.filterPagesByCanDeleteCompletely(pagesInTrash, req.user, true);
 

+ 2 - 2
packages/app/src/server/routes/apiv3/personal-setting.js

@@ -1,6 +1,6 @@
 import { body } from 'express-validator';
 
-import { allLocales } from '~/next-i18next.config';
+import { i18n } from '~/next-i18next.config';
 import loggerFactory from '~/utils/logger';
 
 
@@ -83,7 +83,7 @@ module.exports = (crowi) => {
           if (!User.isEmailValid(email)) throw new Error('email is not included in whitelist');
           return true;
         }),
-      body('lang').isString().isIn(allLocales),
+      body('lang').isString().isIn(i18n.locales),
       body('isEmailPublished').isBoolean(),
       body('slackMemberId').optional().isString(),
     ],

+ 3 - 3
packages/app/src/server/routes/index.js

@@ -20,10 +20,10 @@ const multer = require('multer');
 const autoReap = require('multer-autoreap');
 
 const apiLimiter = rateLimit({
-  windowMs: 15 * 60 * 1000, // 15 minutes
-  max: 10, // limit each IP to 10 requests per windowMs
+  windowMs: 1 * 60 * 1000, // 1 minutes
+  max: 60, // limit each IP to 60 requests per windowMs
   message:
-    'Too many requests sent from this IP, please try again after 15 minutes',
+    'Too many requests sent from this IP, please try again after 1 minute',
 });
 
 autoReap.options.reapOnError = true; // continue reaping the file even if an error occurs

+ 1 - 2
packages/app/src/server/routes/login.js

@@ -217,8 +217,7 @@ module.exports = function(crowi, app) {
       }
     }
     else {
-      return res.render('invited', {
-      });
+      return res.render('invited');
     }
   };
 

+ 25 - 11
packages/app/src/server/service/page-operation.ts

@@ -8,7 +8,9 @@ import { ObjectIdLike } from '../interfaces/mongoose-utils';
 
 const logger = loggerFactory('growi:services:page-operation');
 
-const { isEitherOfPathAreaOverlap, isPathAreaOverlap, isTrashPage } = pagePathUtils;
+const {
+  isEitherOfPathAreaOverlap, isPathAreaOverlap, isTrashPage, collectAncestorPaths,
+} = pagePathUtils;
 const AUTO_UPDATE_INTERVAL_SEC = 5;
 
 const {
@@ -34,8 +36,10 @@ class PageOperationService {
    */
   async afterExpressServerReady(): Promise<void> {
     try {
+      const pageOps = await PageOperation.find({ actionType: PageActionType.Rename, actionStage: PageActionStage.Sub })
+        .sort({ createdAt: 'asc' });
       // execute rename operation
-      await this.executeAllRenameOperationBySystem();
+      await this.executeAllRenameOperationBySystem(pageOps);
     }
     catch (err) {
       logger.error(err);
@@ -45,17 +49,12 @@ class PageOperationService {
   /**
    * Execute renameSubOperation on every page operation for rename ordered by createdAt ASC
    */
-  private async executeAllRenameOperationBySystem(): Promise<void> {
-    const Page = this.crowi.model('Page');
-
-    const pageOps = await PageOperation.find({ actionType: PageActionType.Rename, actionStage: PageActionStage.Sub })
-      .sort({ createdAt: 'asc' });
+  private async executeAllRenameOperationBySystem(pageOps: PageOperationDocument[]): Promise<void> {
     if (pageOps.length === 0) return;
 
+    const Page = this.crowi.model('Page');
+
     for await (const pageOp of pageOps) {
-      const {
-        page, toPath, options, user,
-      } = pageOp;
 
       const renamedPage = await Page.findById(pageOp.page._id);
       if (renamedPage == null) {
@@ -64,7 +63,7 @@ class PageOperationService {
       }
 
       // rename
-      await this.crowi.pageService.renameSubOperation(page, toPath, user, options, renamedPage, pageOp._id);
+      await this.crowi.pageService.resumeRenameSubOperation(renamedPage, pageOp);
     }
   }
 
@@ -169,6 +168,21 @@ class PageOperationService {
     clearInterval(timerObj);
   }
 
+  /**
+   * Get ancestor's paths using fromPath and toPath. Merge same paths if any.
+   */
+  getAncestorsPathsByFromAndToPath(fromPath: string, toPath: string): string[] {
+    const fromAncestorsPaths = collectAncestorPaths(fromPath);
+    const toAncestorsPaths = collectAncestorPaths(toPath);
+    // merge duplicate paths and return paths of ancestors
+    return Array.from(new Set(toAncestorsPaths.concat(fromAncestorsPaths)));
+  }
+
+  async getRenameSubOperationByPageId(pageId: ObjectIdLike): Promise<PageOperationDocument | null> {
+    const filter = { actionType: PageActionType.Rename, actionStage: PageActionStage.Sub, 'page._id': pageId };
+    return PageOperation.findOne(filter);
+  }
+
 }
 
 export default PageOperationService;

+ 41 - 18
packages/app/src/server/service/page.ts

@@ -29,7 +29,7 @@ import { prepareDeleteConfigValuesForCalc } from '~/utils/page-delete-config';
 
 import { ObjectIdLike } from '../interfaces/mongoose-utils';
 import { PathAlreadyExistsError } from '../models/errors';
-import PageOperation, { PageActionStage, PageActionType } from '../models/page-operation';
+import PageOperation, { PageActionStage, PageActionType, PageOperationDocument } from '../models/page-operation';
 import { PageRedirectModel } from '../models/page-redirect';
 import { serializePageSecurely } from '../models/serializers/page-serializer';
 import Subscription from '../models/subscription';
@@ -618,30 +618,31 @@ class PageService {
     await PageOperation.findByIdAndDelete(pageOpId);
   }
 
-  async resumeRenameSubOperation(renamedPage: PageDocument): Promise<void> {
-
-    // findOne PageOperation
-    const filter = { actionType: PageActionType.Rename, actionStage: PageActionStage.Sub, 'page._id': renamedPage._id };
-    const pageOp = await PageOperation.findOne(filter);
-    if (pageOp == null) {
-      throw Error('There is nothing to be processed right now');
-    }
+  async resumeRenameSubOperation(renamedPage: PageDocument, pageOp: PageOperationDocument): Promise<void> {
     const isProcessable = pageOp.isProcessable();
     if (!isProcessable) {
       throw Error('This page operation is currently being processed');
     }
+    if (pageOp.toPath == null) {
+      throw Error(`Property toPath is missing which is needed to resume rename operation(${pageOp._id})`);
+    }
 
     const {
-      page, toPath, options, user,
+      page, fromPath, toPath, options, user,
     } = pageOp;
 
-    // check property
-    if (toPath == null) {
-      throw Error(`Property toPath is missing which is needed to resume page operation(${pageOp._id})`);
-    }
-
-    this.renameSubOperation(page, toPath, user, options, renamedPage, pageOp._id);
+    this.fixPathsAndDescendantCountOfAncestors(page, user, options, renamedPage, pageOp._id, fromPath, toPath);
+  }
 
+  /**
+   * Renaming paths and fixing descendantCount of ancestors. It shoud be run synchronously.
+   * `renameSubOperation` to restart rename operation
+   * `updateDescendantCountOfPagesWithPaths` to fix descendantCount of ancestors
+   */
+  private async fixPathsAndDescendantCountOfAncestors(page, user, options, renamedPage, pageOpId, fromPath, toPath): Promise<void> {
+    await this.renameSubOperation(page, toPath, user, options, renamedPage, pageOpId);
+    const ancestorsPaths = this.crowi.pageOperationService.getAncestorsPathsByFromAndToPath(fromPath, toPath);
+    await this.updateDescendantCountOfPagesWithPaths(ancestorsPaths);
   }
 
   private isRenamingToUnderTarget(fromPath: string, toPath: string): boolean {
@@ -3066,8 +3067,30 @@ class PageService {
     builder.addConditionToSortPagesByDescPath();
 
     const aggregatedPages = await builder.query.lean().cursor({ batchSize: BATCH_SIZE });
+    await this.recountAndUpdateDescendantCountOfPages(aggregatedPages, BATCH_SIZE);
+  }
 
+  /**
+   * update descendantCount of the pages sequentially from longer path to shorter path
+   */
+  async updateDescendantCountOfPagesWithPaths(paths: string[]): Promise<void> {
+    const BATCH_SIZE = 200;
+    const Page = this.crowi.model('Page');
+    const { PageQueryBuilder } = Page;
 
+    const builder = new PageQueryBuilder(Page.find(), true);
+    builder.addConditionToListByPathsArray(paths); // find by paths
+    builder.addConditionToSortPagesByDescPath(); // sort in DESC
+
+    const aggregatedPages = await builder.query.lean().cursor({ batchSize: BATCH_SIZE });
+    await this.recountAndUpdateDescendantCountOfPages(aggregatedPages, BATCH_SIZE);
+  }
+
+  /**
+   * Recount descendantCount of pages one by one
+   */
+  async recountAndUpdateDescendantCountOfPages(pageCursor: QueryCursor<any>, batchSize:number): Promise<void> {
+    const Page = this.crowi.model('Page');
     const recountWriteStream = new Writable({
       objectMode: true,
       async write(pageDocuments, encoding, callback) {
@@ -3081,8 +3104,8 @@ class PageService {
         callback();
       },
     });
-    aggregatedPages
-      .pipe(createBatchStream(BATCH_SIZE))
+    pageCursor
+      .pipe(createBatchStream(batchSize))
       .pipe(recountWriteStream);
 
     await streamToPromise(recountWriteStream);

+ 0 - 1
packages/app/src/server/views/layout-growi/identical-path-page.html

@@ -11,7 +11,6 @@
       data-path="{{ encodeURI(path) }}"
       data-current-user="{% if user %}{{ user._id.toString() }}{% endif %}"
       data-page-is-not-creatable="true"
-      data-page-is-deleted="{% if page.isDeleted() %}true{% else %}false{% endif %}"
       data-identical-path="true"
     >
       <div class="flex-grow-1 flex-basis-0 mw-0">

+ 2 - 2
packages/app/src/server/views/widget/page_alerts.html

@@ -4,9 +4,9 @@
 
       <p class="alert alert-primary py-3 px-4">
       {% if page.grant == 2 %}
-        <i class="icon-fw icon-link"></i><strong>{{ consts.pageGrants[page.grant] }}</strong> ({{ t('Browsing of this page is restricted') }})
+        <i class="icon-fw icon-link"></i><strong>{{ t('Anyone with the link') }}</strong> ({{ t('Browsing of this page is restricted') }})
       {% elseif page.grant == 4 %}
-        <i class="icon-fw icon-lock"></i><strong>{{ consts.pageGrants[page.grant] }}</strong> ({{ t('Browsing of this page is restricted') }})
+        <i class="icon-fw icon-lock"></i><strong>{{ t('Only me') }}</strong> ({{ t('Browsing of this page is restricted') }})
       {% elseif page.grant == 5 %}
         <i class="icon-fw icon-organization"></i><strong>'{{ page.grantedGroup.name | preventXss }}' only</strong> ({{ t('Browsing of this page is restricted') }})
       {% endif %}

+ 0 - 2
packages/app/src/server/views/widget/page_content.html

@@ -12,7 +12,6 @@
   data-page-grant="{{ grant }}"
   data-page-grant-group="{{ grantedGroupId }}"
   data-page-grant-group-name="{{ grantedGroupName }}"
-  data-page-is-deleted="{% if page.isDeleted() %}true{% else %}false{% endif %}"
   data-page-is-not-creatable="false"
   data-page-created-at="{{ page.createdAt|datetz('Y/m/d H:i:s') }}"
   data-page-creator="{% if page && page.creator %}{{ page.creator|json }}{% endif %}"
@@ -30,7 +29,6 @@
 <div id="content-main" class="content-main d-flex"
   data-path="{{ encodeURI(path) }}"
   data-current-user="{% if user %}{{ user._id.toString() }}{% endif %}"
-  data-page-is-deleted="{% if page.isDeleted() %}true{% else %}false{% endif %}"
   data-page-has-children="{% if pages.length > 0 %}true{% else %}false{% endif %}"
   >
 {% endif %}

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

@@ -68,10 +68,6 @@ export const useIsTrashPage = (initialData?: boolean): SWRResponse<boolean, Erro
   return useStaticSWR<boolean, Error>('isTrashPage', initialData, { fallbackData: false });
 };
 
-export const useIsDeleted = (initialData?: boolean): SWRResponse<boolean, Error> => {
-  return useStaticSWR<boolean, Error>('isDeleted', initialData, { fallbackData: false });
-};
-
 export const useIsNotCreatable = (initialData?: boolean): SWRResponse<boolean, Error> => {
   return useStaticSWR<boolean, Error>('isNotCreatable', initialData, { fallbackData: false });
 };

+ 21 - 0
packages/app/src/stores/page.tsx

@@ -9,6 +9,7 @@ import {
 } from '~/interfaces/page';
 import { IRecordApplicableGrant, IResIsGrantNormalized } from '~/interfaces/page-grant';
 import { IPagingResult } from '~/interfaces/paging-result';
+import { IRevisionsForPagination } from '~/interfaces/revision';
 
 import { apiGet } from '../client/util/apiv1-client';
 import { Nullable } from '../interfaces/common';
@@ -158,6 +159,26 @@ export const useSWRxPageInfoForList = (
   };
 };
 
+export const useSWRxPageRevisions = (
+    pageId: string,
+    page: number, // page number of pagination
+    limit: number, // max number of pages in one paginate
+): SWRResponse<IRevisionsForPagination, Error> => {
+
+  return useSWRImmutable<IRevisionsForPagination, Error>(
+    ['/revisions/list', pageId, page, limit],
+    (endpoint, pageId, page, limit) => {
+      return apiv3Get(endpoint, { pageId, page, limit }).then((response) => {
+        const revisions = {
+          revisions: response.data.docs,
+          totalCounts: response.data.totalDocs,
+        };
+        return revisions;
+      });
+    },
+  );
+};
+
 /*
  * Grant normalization fetching hooks
  */

+ 15 - 15
packages/app/src/stores/ui.tsx

@@ -19,10 +19,11 @@ import loggerFactory from '~/utils/logger';
 
 import {
   useCurrentPageId, useCurrentPagePath, useIsEditable, useIsTrashPage, useIsUserPage, useIsGuestUser, useEmptyPageId,
-  useIsNotCreatable, useIsSharedUser, useNotFoundTargetPathOrId, useIsForbidden, useIsIdenticalPath, useIsNotFoundPermalink, useCurrentUser, useIsDeleted,
+  useIsNotCreatable, useIsSharedUser, useNotFoundTargetPathOrId, useIsForbidden, useIsIdenticalPath, useIsNotFoundPermalink, useCurrentUser,
 } from './context';
 import { localStorageMiddleware } from './middlewares/sync-to-storage';
 import { useStaticSWR } from './use-static-swr';
+import { constants } from 'zlib';
 
 const { isSharedPage } = pagePathUtils;
 
@@ -70,27 +71,26 @@ export const useIsMobile = (): SWRResponse<boolean, Error> => {
 };
 
 const updateBodyClassesByEditorMode = (newEditorMode: EditorMode, isSidebar = false) => {
+  const bodyElement = document.getElementsByTagName('body')[0];
+  if (bodyElement == null) {
+    logger.warn('The body tag was not successfully obtained');
+    return;
+  }
   switch (newEditorMode) {
     case EditorMode.View:
-      $('body').removeClass('on-edit');
-      $('body').removeClass('builtin-editor');
-      $('body').removeClass('hackmd');
-      $('body').removeClass('editing-sidebar');
+      bodyElement.classList.remove('on-edit', 'builtin-editor', 'hackmd', 'editing-sidebar');
       break;
     case EditorMode.Editor:
-      $('body').addClass('on-edit');
-      $('body').addClass('builtin-editor');
-      $('body').removeClass('hackmd');
+      bodyElement.classList.add('on-edit', 'builtin-editor');
+      bodyElement.classList.remove('hackmd');
       // editing /Sidebar
       if (isSidebar) {
-        $('body').addClass('editing-sidebar');
+        bodyElement.classList.add('editing-sidebar');
       }
       break;
     case EditorMode.HackMD:
-      $('body').addClass('on-edit');
-      $('body').addClass('hackmd');
-      $('body').removeClass('builtin-editor');
-      $('body').removeClass('editing-sidebar');
+      bodyElement.classList.add('on-edit', 'hackmd');
+      bodyElement.classList.remove('builtin-editor', 'editing-sidebar');
       break;
   }
 };
@@ -396,9 +396,9 @@ export const usePageTreeDescCountMap = (initialData?: UpdateDescCountData): SWRR
 
 export const useIsAbleToShowTrashPageManagementButtons = (): SWRResponse<boolean, Error> => {
   const { data: currentUser } = useCurrentUser();
-  const { data: isDeleted } = useIsDeleted();
+  const { data: isTrashPage } = useIsTrashPage();
 
-  return useStaticSWR('isAbleToShowTrashPageManagementButtons', isDeleted && currentUser != null);
+  return useStaticSWR('isAbleToShowTrashPageManagementButtons', isTrashPage && currentUser != null);
 };
 
 export const useIsAbleToShowPageManagement = (): SWRResponse<boolean, Error> => {

+ 174 - 0
packages/app/test/cypress/integration/30-search/search.spec.ts

@@ -68,3 +68,177 @@ context('Access to legacy private pages', () => {
   });
 
 });
+
+context('Search all pages', () => {
+  const ssPrefix = 'search-all-pages-';
+
+  beforeEach(() => {
+    // login
+    cy.fixture("user-admin.json").then(user => {
+      cy.login(user.username, user.password);
+    });
+    // collapse sidebar
+    cy.collapseSidebar(true);
+  });
+
+  it(`Search all pages by word is successfully loaded`, () => {
+    const searchText = 'help';
+
+    cy.visit('/');
+    cy.get('.rbt-input').click();
+    cy.get('.rbt-menu.dropdown-menu.show').should('be.visible').within(() => {
+      cy.screenshot(`${ssPrefix}1-search-input-focused`);
+    })
+
+    cy.get('.rbt-input-main').type(`${searchText}`);
+    cy.screenshot(`${ssPrefix}2-insert-search-text`, { capture: 'viewport'});
+    cy.get('.rbt-input-main').type('{enter}');
+
+
+    cy.getByTestid('search-result-base').should('be.visible');
+    cy.getByTestid('search-result-list').should('be.visible');
+    cy.getByTestid('search-result-content').should('be.visible');
+    cy.screenshot(`${ssPrefix}3-search-page-results`, { capture: 'viewport'});
+
+    cy.getByTestid('open-page-item-control-btn').eq(1).click();
+    cy.screenshot(`${ssPrefix}4-click-three-dots-menu`, {capture: 'viewport'});
+
+    //Add bookmark
+    cy.getByTestid('add-remove-bookmark-btn').click({force: true});
+    cy.get('.btn-bookmark.active').should('be.visible');
+    cy.screenshot(`${ssPrefix}5-add-bookmark`, {capture: 'viewport'});
+
+    // Duplicate page
+    cy.getByTestid('open-page-duplicate-modal-btn').first().click({force: true});
+    cy.getByTestid('page-duplicate-modal').should('be.visible');
+    cy.screenshot(`${ssPrefix}6-duplicate-page`, {capture: 'viewport'});
+
+    // Close Modal
+    cy.get('body').type('{esc}');
+
+    // Move / Rename Page
+    cy.getByTestid('open-page-move-rename-modal-btn').first().click({force: true});
+    cy.getByTestid('page-rename-modal').should('be.visible');
+    cy.screenshot(`${ssPrefix}7-move-rename-page`, {capture: 'viewport'});
+
+    // Close Modal
+    cy.get('body').type('{esc}');
+
+    // Delete page
+    cy.getByTestid('open-page-delete-modal-btn').first().click({ force: true});
+    cy.getByTestid('page-delete-modal').should('be.visible');
+    cy.screenshot(`${ssPrefix}8-delete-page`, {capture: 'viewport'});
+  });
+
+  it(`Search all pages by tag is successfully loaded `, () => {
+    const tag = 'help';
+    const searchText = `tag:${tag}`;
+    cy.visit('/');
+    // Add tag
+    cy.get('#edit-tags-btn-wrapper-for-tooltip > a').click({force: true});
+    cy.get('#edit-tag-modal').should('be.visible');
+
+    cy.get('#edit-tag-modal').within(() => {
+      cy.get('.rbt-input-main').type(tag);
+      cy.get('#tag-typeahead-asynctypeahead').should('be.visible');
+      cy.get('#tag-typeahead-asynctypeahead-item-0').should('be.visible');
+      cy.get('a#tag-typeahead-asynctypeahead-item-0').click({force: true})
+    });
+
+    cy.get('#edit-tag-modal').within(() => {
+      cy.get('div.modal-footer > button').click();
+    });
+
+    cy.visit('/');
+    cy.get('.rbt-input').click();
+    cy.get('.rbt-input-main').type(`${searchText}`);
+    cy.screenshot(`${ssPrefix}1-insert-search-text-with-tag`, { capture: 'viewport'});
+    cy.get('.rbt-input-main').type('{enter}');
+
+    cy.getByTestid('search-result-base').should('be.visible');
+    cy.getByTestid('search-result-list').should('be.visible');
+    cy.getByTestid('search-result-content').should('be.visible');
+
+    cy.screenshot(`${ssPrefix}2-search-with-tag-result`, {capture: 'viewport'});
+    cy.getByTestid('open-page-item-control-btn').first().click();
+    cy.screenshot(`${ssPrefix}3-click-three-dots-menu-search-with-tag`, {capture: 'viewport'});
+
+  });
+  it('Successfully order page search results by tag', () => {
+    const tag = 'help';
+
+    cy.visit('/');
+    cy.get('.grw-taglabels-container > form > a').contains(tag).click();
+    cy.getByTestid('search-result-base').should('be.visible');
+    cy.getByTestid('search-result-list').should('be.visible');
+    cy.getByTestid('search-result-content').should('be.visible');
+    cy.screenshot(`${ssPrefix}1-tag-order-click-tag-name`, {capture: 'viewport'});
+
+    cy.get('.grw-search-page-nav').within(() => {
+      cy.get('button.dropdown-toggle').first().click({force: true});
+      cy.get('.dropdown-menu-right').should('be.visible');
+      cy.get('.dropdown-menu-right > button:nth-child(1)').click({force: true});
+    });
+    cy.getByTestid('search-result-base').should('be.visible');
+    cy.getByTestid('search-result-list').should('be.visible');
+    cy.getByTestid('search-result-content').should('be.visible');
+    cy.screenshot(`${ssPrefix}2-tag-order-by-relevance`);
+
+    cy.get('.grw-search-page-nav').within(() => {
+      cy.get('button.dropdown-toggle').first().click({force: true});
+      cy.get('.dropdown-menu-right').should('be.visible');
+      cy.get('.dropdown-menu-right > button:nth-child(2)').click({force: true});
+    });
+    cy.getByTestid('search-result-base').should('be.visible');
+    cy.getByTestid('search-result-list').should('be.visible');
+    cy.getByTestid('search-result-content').should('be.visible');
+    cy.screenshot(`${ssPrefix}3-tag-order-by-creation-date`);
+
+    cy.get('.grw-search-page-nav').within(() => {
+      cy.get('button.dropdown-toggle').first().click({force: true});
+      cy.get('.dropdown-menu-right').should('be.visible');
+      cy.get('.dropdown-menu-right > button:nth-child(3)').click({force: true});
+    });
+    cy.getByTestid('search-result-base').should('be.visible');
+    cy.getByTestid('search-result-list').should('be.visible');
+    cy.getByTestid('search-result-content').should('be.visible');
+    cy.screenshot(`${ssPrefix}4-tag-order-by-last-update-date`);
+  });
+
+});
+
+context('Search current tree with "prefix":', () => {
+  const ssPrefix = 'search-current-tree-';
+
+  beforeEach(() => {
+    // login
+    cy.fixture("user-admin.json").then(user => {
+      cy.login(user.username, user.password);
+    });
+    // collapse sidebar
+    cy.collapseSidebar(true);
+  });
+
+  it(`Search current tree by word is successfully loaded`, () => {
+    const searchText = 'help';
+    cy.visit('/');
+    cy.getByTestid('select-search-scope').first().click({force: true});
+    cy.get('.input-group-prepend.show > div > button:nth-child(2)').click({force: true});
+    cy.get('.rbt-input').click();
+    cy.get('.rbt-menu.dropdown-menu.show').should('be.visible').within(() => {
+      cy.screenshot(`${ssPrefix}1-search-input-focused`);
+    })
+    cy.get('.rbt-input').type(`${searchText}`);
+    cy.screenshot(`${ssPrefix}2-insert-search-text`, { capture: 'viewport'});
+    cy.get('.rbt-input').type('{enter}');
+
+    cy.getByTestid('search-result-base').should('be.visible');
+    cy.getByTestid('search-result-list').should('be.visible');
+    cy.getByTestid('search-result-content').should('be.visible');
+    cy.screenshot(`${ssPrefix}3-search-page-results`, { capture: 'viewport'});
+
+    cy.getByTestid('open-page-item-control-btn').first().click();
+    cy.screenshot(`${ssPrefix}4-click-three-dots-menu`, {capture: 'viewport'});
+  });
+
+});

+ 142 - 45
packages/app/test/integration/middlewares/login-required.test.js

@@ -18,60 +18,157 @@ describe('loginRequired', () => {
   });
 
   describe('not strict mode', () => {
-    // setup req/res/next
-    const req = {
-      originalUrl: 'original url 1',
-      session: {},
-    };
     const res = {
       redirect: jest.fn().mockReturnValue('redirect'),
+      sendStatus: jest.fn().mockReturnValue('sendStatus'),
     };
     const next = jest.fn().mockReturnValue('next');
 
-    test('pass guest user when aclService.isGuestAllowedToRead() returns true', () => {
-      // prepare spy for AclService.isGuestAllowedToRead
-      const isGuestAllowedToReadSpy = jest.spyOn(crowi.aclService, 'isGuestAllowedToRead')
-        .mockImplementation(() => true);
-
-      const result = loginRequired(req, res, next);
-
-      expect(isGuestAllowedToReadSpy).toHaveBeenCalledTimes(1);
-      expect(fallbackMock).not.toHaveBeenCalled();
-      expect(next).toHaveBeenCalled();
-      expect(res.redirect).not.toHaveBeenCalled();
-      expect(result).toBe('next');
-    });
-
-    test('redirect to \'/login\' when aclService.isGuestAllowedToRead() returns false', () => {
-      // prepare spy for AclService.isGuestAllowedToRead
-      const isGuestAllowedToReadSpy = jest.spyOn(crowi.aclService, 'isGuestAllowedToRead')
-        .mockImplementation(() => false);
+    describe('and when aclService.isGuestAllowedToRead() returns false', () => {
+      let req;
+
+      let isGuestAllowedToReadSpy;
+
+      beforeEach(async() => {
+        // setup req
+        req = {
+          originalUrl: 'original url 1',
+          session: {},
+        };
+        // reset session object
+        req.session = {};
+        // prepare spy for AclService.isGuestAllowedToRead
+        isGuestAllowedToReadSpy = jest.spyOn(crowi.aclService, 'isGuestAllowedToRead')
+          .mockImplementation(() => false);
+      });
+
+      /* eslint-disable indent */
+      test.each`
+        userStatus  | expectedPath
+        ${1}        | ${'/login/error/registered'}
+        ${3}        | ${'/login/error/suspended'}
+        ${5}        | ${'/login/invited'}
+      `('redirect to \'$expectedPath\' when user.status is \'$userStatus\'', ({ userStatus, expectedPath }) => {
+
+        req.user = {
+          _id: 'user id',
+          status: userStatus,
+        };
+
+        const result = loginRequired(req, res, next);
+
+        expect(isGuestAllowedToReadSpy).not.toHaveBeenCalled();
+        expect(next).not.toHaveBeenCalled();
+        expect(fallbackMock).not.toHaveBeenCalled();
+        expect(res.sendStatus).not.toHaveBeenCalled();
+        expect(res.redirect).toHaveBeenCalledTimes(1);
+        expect(res.redirect).toHaveBeenCalledWith(expectedPath);
+        expect(result).toBe('redirect');
+        expect(req.session.redirectTo).toBe(undefined);
+      });
+      /* eslint-disable indent */
+
+      test('redirect to \'/login\' when the user does not loggedin', () => {
+        req.baseUrl = '/path/that/requires/loggedin';
+
+        const result = loginRequired(req, res, next);
+
+        expect(isGuestAllowedToReadSpy).toHaveBeenCalled();
+        expect(next).not.toHaveBeenCalled();
+        expect(fallbackMock).not.toHaveBeenCalled();
+        expect(res.sendStatus).not.toHaveBeenCalled();
+        expect(res.redirect).toHaveBeenCalledTimes(1);
+        expect(res.redirect).toHaveBeenCalledWith('/login');
+        expect(result).toBe('redirect');
+        expect(req.session.redirectTo).toBe('original url 1');
+      });
+
+      test('pass anyone into sharedPage', () => {
+
+        req.isSharedPage = true;
+
+        const result = loginRequired(req, res, next);
+
+        expect(isGuestAllowedToReadSpy).toHaveBeenCalled();
+        expect(fallbackMock).not.toHaveBeenCalled();
+        expect(res.sendStatus).not.toHaveBeenCalled();
+        expect(next).toHaveBeenCalled();
+        expect(res.redirect).not.toHaveBeenCalled();
+        expect(result).toBe('next');
+      });
 
-      const result = loginRequired(req, res, next);
-
-      expect(isGuestAllowedToReadSpy).toHaveBeenCalled();
-      expect(fallbackMock).not.toHaveBeenCalled();
-      expect(next).not.toHaveBeenCalled();
-      expect(res.redirect).toHaveBeenCalledTimes(1);
-      expect(res.redirect).toHaveBeenCalledWith('/login');
-      expect(result).toBe('redirect');
     });
 
-    test('pass anyone into sharedPage when aclService.isGuestAllowedToRead() returns false', () => {
+    describe('and when aclService.isGuestAllowedToRead() returns true', () => {
+      let req;
+
+      let isGuestAllowedToReadSpy;
+
+      beforeEach(async() => {
+        // setup req
+        req = {
+          originalUrl: 'original url 1',
+          session: {},
+        };
+        // reset session object
+        req.session = {};
+        // prepare spy for AclService.isGuestAllowedToRead
+        isGuestAllowedToReadSpy = jest.spyOn(crowi.aclService, 'isGuestAllowedToRead')
+          .mockImplementation(() => true);
+      });
+
+      /* eslint-disable indent */
+      test.each`
+        userStatus  | expectedPath
+        ${1}        | ${'/login/error/registered'}
+        ${3}        | ${'/login/error/suspended'}
+        ${5}        | ${'/login/invited'}
+      `('redirect to \'$expectedPath\' when user.status is \'$userStatus\'', ({ userStatus, expectedPath }) => {
+
+        req.user = {
+          _id: 'user id',
+          status: userStatus,
+        };
+
+        const result = loginRequired(req, res, next);
+
+        expect(isGuestAllowedToReadSpy).not.toHaveBeenCalled();
+        expect(next).not.toHaveBeenCalled();
+        expect(fallbackMock).not.toHaveBeenCalled();
+        expect(res.sendStatus).not.toHaveBeenCalled();
+        expect(res.redirect).toHaveBeenCalledTimes(1);
+        expect(res.redirect).toHaveBeenCalledWith(expectedPath);
+        expect(result).toBe('redirect');
+        expect(req.session.redirectTo).toBe(undefined);
+      });
+      /* eslint-disable indent */
+
+      test('pass guest user', () => {
+
+        const result = loginRequired(req, res, next);
+
+        expect(isGuestAllowedToReadSpy).toHaveBeenCalledTimes(1);
+        expect(fallbackMock).not.toHaveBeenCalled();
+        expect(res.sendStatus).not.toHaveBeenCalled();
+        expect(next).toHaveBeenCalled();
+        expect(res.redirect).not.toHaveBeenCalled();
+        expect(result).toBe('next');
+      });
+
+      test('pass anyone into sharedPage', () => {
+
+        req.isSharedPage = true;
+
+        const result = loginRequired(req, res, next);
+
+        expect(isGuestAllowedToReadSpy).toHaveBeenCalled();
+        expect(fallbackMock).not.toHaveBeenCalled();
+        expect(res.sendStatus).not.toHaveBeenCalled();
+        expect(next).toHaveBeenCalled();
+        expect(res.redirect).not.toHaveBeenCalled();
+        expect(result).toBe('next');
+      });
 
-      req.isSharedPage = true;
-
-      // prepare spy for AclService.isGuestAllowedToRead
-      const isGuestAllowedToReadSpy = jest.spyOn(crowi.aclService, 'isGuestAllowedToRead')
-        .mockImplementation(() => false);
-
-      const result = loginRequired(req, res, next);
-
-      expect(isGuestAllowedToReadSpy).toHaveBeenCalled();
-      expect(fallbackMock).not.toHaveBeenCalled();
-      expect(next).toHaveBeenCalled();
-      expect(res.redirect).not.toHaveBeenCalled();
-      expect(result).toBe('next');
     });
 
   });

+ 346 - 2
packages/app/test/integration/models/v5.page.test.js

@@ -163,6 +163,12 @@ describe('Page', () => {
     const pageIdUpd11 = new mongoose.Types.ObjectId();
     const pageIdUpd12 = new mongoose.Types.ObjectId();
     const pageIdUpd13 = new mongoose.Types.ObjectId();
+    const pageIdUpd14 = new mongoose.Types.ObjectId();
+    const pageIdUpd15 = new mongoose.Types.ObjectId();
+    const pageIdUpd16 = new mongoose.Types.ObjectId();
+    const pageIdUpd17 = new mongoose.Types.ObjectId();
+    const pageIdUpd18 = new mongoose.Types.ObjectId();
+    const pageIdUpd19 = new mongoose.Types.ObjectId();
 
     await Page.insertMany([
       {
@@ -337,7 +343,138 @@ describe('Page', () => {
         descendantCount: 0,
       },
       {
-        path: '/mup24',
+        _id: pageIdUpd14,
+        path: '/mup24_pub',
+        grant: Page.GRANT_PUBLIC,
+        creator: pModelUserId1,
+        lastUpdateUser: pModelUserId1,
+        isEmpty: false,
+        parent: rootPage,
+        descendantCount: 1,
+      },
+      {
+        path: '/mup24_pub/mup25_pub',
+        grant: Page.GRANT_PUBLIC,
+        creator: pModelUserId1,
+        lastUpdateUser: pModelUserId1,
+        isEmpty: false,
+        parent: pageIdUpd14,
+        descendantCount: 0,
+      },
+      {
+        path: '/mup26_awl',
+        grant: Page.GRANT_RESTRICTED,
+        creator: pModelUserId1,
+        lastUpdateUser: pModelUserId1,
+        isEmpty: false,
+        descendantCount: 0,
+      },
+      {
+        _id: pageIdUpd15,
+        path: '/mup27_pub',
+        grant: Page.GRANT_PUBLIC,
+        creator: pModelUserId1,
+        lastUpdateUser: pModelUserId1,
+        isEmpty: false,
+        parent: rootPage,
+        descendantCount: 1,
+      },
+      {
+        path: '/mup27_pub/mup28_owner',
+        grant: Page.GRANT_OWNER,
+        creator: pModelUserId1,
+        lastUpdateUser: pModelUserId1,
+        isEmpty: false,
+        parent: pageIdUpd15,
+        grantedUsers: [pModelUserId1],
+        descendantCount: 0,
+      },
+      {
+        _id: pageIdUpd16,
+        path: '/mup29_A',
+        grant: Page.GRANT_USER_GROUP,
+        grantedGroup: groupIdA,
+        creator: pModelUserId1,
+        lastUpdateUser: pModelUserId1,
+        isEmpty: false,
+        parent: rootPage,
+        descendantCount: 1,
+      },
+      {
+        path: '/mup29_A/mup30_owner',
+        grant: Page.GRANT_OWNER,
+        grantedUsers: [pModelUserId1],
+        creator: pModelUserId1,
+        lastUpdateUser: pModelUserId1,
+        isEmpty: false,
+        parent: pageIdUpd16,
+        descendantCount: 0,
+      },
+      {
+        _id: pageIdUpd17,
+        path: '/mup31_A',
+        grant: Page.GRANT_USER_GROUP,
+        grantedGroup: groupIdA,
+        creator: pModelUserId1,
+        lastUpdateUser: pModelUserId1,
+        isEmpty: false,
+        parent: rootPage,
+        descendantCount: 1,
+      },
+      {
+        path: '/mup31_A/mup32_owner',
+        grant: Page.GRANT_OWNER,
+        grantedUsers: [pModelUserId1],
+        creator: pModelUserId1,
+        lastUpdateUser: pModelUserId1,
+        isEmpty: false,
+        parent: pageIdUpd17,
+        descendantCount: 0,
+      },
+      {
+        _id: pageIdUpd18,
+        path: '/mup33_C',
+        grant: Page.GRANT_USER_GROUP,
+        grantedGroup: groupIdC,
+        creator: pModelUserId3,
+        lastUpdateUser: pModelUserId3,
+        isEmpty: false,
+        parent: rootPage,
+        descendantCount: 1,
+      },
+      {
+        path: '/mup33_C/mup34_owner',
+        grant: Page.GRANT_OWNER,
+        grantedUsers: [pModelUserId3],
+        creator: pModelUserId3,
+        lastUpdateUser: pModelUserId3,
+        isEmpty: false,
+        parent: pageIdUpd18,
+        descendantCount: 0,
+      },
+      {
+        _id: pageIdUpd19,
+        path: '/mup35_owner',
+        grant: Page.GRANT_OWNER,
+        grantedUsers: [pModelUserId1],
+        creator: pModelUserId1,
+        lastUpdateUser: pModelUserId1,
+        isEmpty: false,
+        parent: rootPage,
+        descendantCount: 1,
+      },
+      {
+        path: '/mup35_owner/mup36_owner',
+        grant: Page.GRANT_OWNER,
+        grantedUsers: [pModelUserId1],
+        creator: pModelUserId1,
+        lastUpdateUser: pModelUserId1,
+        isEmpty: false,
+        parent: pageIdUpd19,
+        descendantCount: 0,
+      },
+      {
+        path: '/mup40', // used this number to resolve conflict
         grant: Page.GRANT_OWNER,
         grantedUsers: [dummyUser1._id],
         creator: dummyUser1,
@@ -434,7 +571,7 @@ describe('Page', () => {
 
     describe('Changing grant to GRANT_RESTRICTED', () => {
       test('successfully change to GRANT_RESTRICTED from GRANT_OWNER', async() => {
-        const path = '/mup24';
+        const path = '/mup40';
         const _page = await Page.findOne({ path, grant: Page.GRANT_OWNER, grantedUsers: [dummyUser1._id] });
         expect(_page).toBeTruthy();
 
@@ -559,6 +696,213 @@ describe('Page', () => {
         expect(page1.grantedUsers).not.toStrictEqual([dummyUser1._id]);
       });
     });
+    describe('Changing grant to GRANT_USER_GROUP', () => {
+      describe('update grant of a page under a page with GRANT_PUBLIC', () => {
+        test('successfully change to GRANT_USER_GROUP from GRANT_PUBLIC if parent page is GRANT_PUBLIC', async() => {
+          // path
+          const path1 = '/mup24_pub';
+          const path2 = '/mup24_pub/mup25_pub';
+          // page
+          const _page1 = await Page.findOne({ path: path1, grant: Page.GRANT_PUBLIC }); // out of update scope
+          const _page2 = await Page.findOne({ path: path2, grant: Page.GRANT_PUBLIC, parent: _page1._id }); // update target
+          expect(_page1).toBeTruthy();
+          expect(_page2).toBeTruthy();
+
+          const options = { grant: Page.GRANT_USER_GROUP, grantUserGroupId: groupIdA };
+          const updatedPage = await updatePage(_page2, 'new', 'old', pModelUser1, options); // from GRANT_PUBLIC to GRANT_USER_GROUP(groupIdA)
+
+          const page1 = await Page.findById(_page1._id);
+          const page2 = await Page.findById(_page2._id);
+          expect(page1).toBeTruthy();
+          expect(page2).toBeTruthy();
+          expect(updatedPage).toBeTruthy();
+          expect(updatedPage._id).toStrictEqual(page2._id);
+
+          // check page2 grant and group
+          expect(page2.grant).toBe(Page.GRANT_USER_GROUP);
+          expect(page2.grantedGroup._id).toStrictEqual(groupIdA);
+        });
+
+        test('successfully change to GRANT_USER_GROUP from GRANT_RESTRICTED if parent page is GRANT_PUBLIC', async() => {
+          // path
+          const _path1 = '/mup26_awl';
+          // page
+          const _page1 = await Page.findOne({ path: _path1, grant: Page.GRANT_RESTRICTED });
+          expect(_page1).toBeTruthy();
+
+          const options = { grant: Page.GRANT_USER_GROUP, grantUserGroupId: groupIdA };
+          const updatedPage = await updatePage(_page1, 'new', 'old', pModelUser1, options); // from GRANT_RESTRICTED to GRANT_USER_GROUP(groupIdA)
+
+          const page1 = await Page.findById(_page1._id);
+          expect(page1).toBeTruthy();
+          expect(updatedPage).toBeTruthy();
+          expect(updatedPage._id).toStrictEqual(page1._id);
+
+          // updated page
+          expect(page1.grant).toBe(Page.GRANT_USER_GROUP);
+          expect(page1.grantedGroup._id).toStrictEqual(groupIdA);
+
+          // parent's grant check
+          const parent = await Page.findById(page1.parent);
+          expect(parent.grant).toBe(Page.GRANT_PUBLIC);
+
+        });
+
+        test('successfully change to GRANT_USER_GROUP from GRANT_OWNER if parent page is GRANT_PUBLIC', async() => {
+          // path
+          const path1 = '/mup27_pub';
+          const path2 = '/mup27_pub/mup28_owner';
+          // page
+          const _page1 = await Page.findOne({ path: path1, grant: Page.GRANT_PUBLIC }); // out of update scope
+          const _page2 = await Page.findOne({
+            path: path2, grant: Page.GRANT_OWNER, grantedUsers: [pModelUser1], parent: _page1._id,
+          }); // update target
+          expect(_page1).toBeTruthy();
+          expect(_page2).toBeTruthy();
+
+          const options = { grant: Page.GRANT_USER_GROUP, grantUserGroupId: groupIdA };
+          const updatedPage = await updatePage(_page2, 'new', 'old', pModelUser1, options); // from GRANT_OWNER to GRANT_USER_GROUP(groupIdA)
+
+          const page1 = await Page.findById(_page1._id);
+          const page2 = await Page.findById(_page2._id);
+          expect(page1).toBeTruthy();
+          expect(page2).toBeTruthy();
+          expect(updatedPage).toBeTruthy();
+          expect(updatedPage._id).toStrictEqual(page2._id);
+
+          // grant check
+          expect(page2.grant).toBe(Page.GRANT_USER_GROUP);
+          expect(page2.grantedGroup._id).toStrictEqual(groupIdA);
+          expect(page2.grantedUsers.length).toBe(0);
+        });
+      });
+      describe('update grant of a page under a page with GRANT_USER_GROUP', () => {
+        test('successfully change to GRANT_USER_GROUP if the group to set is the child or descendant of the parent page group', async() => {
+          // path
+          const _path1 = '/mup29_A';
+          const _path2 = '/mup29_A/mup30_owner';
+          // page
+          const _page1 = await Page.findOne({ path: _path1, grant: Page.GRANT_USER_GROUP, grantedGroup: groupIdA }); // out of update scope
+          const _page2 = await Page.findOne({ // update target
+            path: _path2, grant: Page.GRANT_OWNER, grantedUsers: [pModelUser1], parent: _page1._id,
+          });
+          expect(_page1).toBeTruthy();
+          expect(_page2).toBeTruthy();
+
+          const options = { grant: Page.GRANT_USER_GROUP, grantUserGroupId: groupIdB };
+
+          // First round
+          // Group relation(parent -> child): groupIdA -> groupIdB -> groupIdC
+          const updatedPage = await updatePage(_page2, 'new', 'old', pModelUser3, options); // from GRANT_OWNER to GRANT_USER_GROUP(groupIdB)
+
+          const page1 = await Page.findById(_page1._id);
+          const page2 = await Page.findById(_page2._id);
+          expect(page1).toBeTruthy();
+          expect(page2).toBeTruthy();
+          expect(updatedPage).toBeTruthy();
+          expect(updatedPage._id).toStrictEqual(page2._id);
+
+          expect(page2.grant).toBe(Page.GRANT_USER_GROUP);
+          expect(page2.grantedGroup._id).toStrictEqual(groupIdB);
+          expect(page2.grantedUsers.length).toBe(0);
+
+          // Second round
+          // Update group to groupC which is a grandchild from pageA's point of view
+          const secondRoundOptions = { grant: Page.GRANT_USER_GROUP, grantUserGroupId: groupIdC }; // from GRANT_USER_GROUP(groupIdB) to GRANT_USER_GROUP(groupIdC)
+          const secondRoundUpdatedPage = await updatePage(_page2, 'new', 'new', pModelUser3, secondRoundOptions);
+
+          expect(secondRoundUpdatedPage).toBeTruthy();
+          expect(secondRoundUpdatedPage.grant).toBe(Page.GRANT_USER_GROUP);
+          expect(secondRoundUpdatedPage.grantedGroup._id).toStrictEqual(groupIdC);
+        });
+        test('Fail to change to GRANT_USER_GROUP if the group to set is NOT the child or descendant of the parent page group', async() => {
+          // path
+          const _path1 = '/mup31_A';
+          const _path2 = '/mup31_A/mup32_owner';
+          // page
+          const _page1 = await Page.findOne({ path: _path1, grant: Page.GRANT_USER_GROUP, grantedGroup: groupIdA });
+          const _page2 = await Page.findOne({ // update target
+            path: _path2, grant: Page.GRANT_OWNER, grantedUsers: [pModelUser1._id], parent: _page1._id,
+          });
+          expect(_page1).toBeTruthy();
+          expect(_page2).toBeTruthy();
+
+          // group
+          const _groupIsolated = await UserGroup.findById(groupIdIsolate);
+          expect(_groupIsolated).toBeTruthy();
+          // group parent check
+          expect(_groupIsolated.parent).toBeUndefined(); // should have no parent
+
+          const options = { grant: Page.GRANT_USER_GROUP, grantUserGroupId: groupIdIsolate };
+          await expect(updatePage(_page2, 'new', 'old', pModelUser1, options)) // from GRANT_OWNER to GRANT_USER_GROUP(groupIdIsolate)
+            .rejects.toThrow(new Error('The selected grant or grantedGroup is not assignable to this page.'));
+
+          const page1 = await Page.findById(_page1._id);
+          const page2 = await Page.findById(_page2._id);
+          expect(page1).toBeTruthy();
+          expect(page1).toBeTruthy();
+
+          expect(page2.grant).toBe(Page.GRANT_OWNER); // should be the same before the update
+          expect(page2.grantedUsers).toStrictEqual([pModelUser1._id]); // should be the same before the update
+          expect(page2.grantedGroup).toBeUndefined(); // no group should be set
+        });
+        test('Fail to change to GRANT_USER_GROUP if the group to set is an ancestor of the parent page group', async() => {
+          // path
+          const _path1 = '/mup33_C';
+          const _path2 = '/mup33_C/mup34_owner';
+          // page
+          const _page1 = await Page.findOne({ path: _path1, grant: Page.GRANT_USER_GROUP, grantedGroup: groupIdC }); // groupC
+          const _page2 = await Page.findOne({ // update target
+            path: _path2, grant: Page.GRANT_OWNER, grantedUsers: [pModelUser3], parent: _page1._id,
+          });
+          expect(_page1).toBeTruthy();
+          expect(_page2).toBeTruthy();
+
+          const options = { grant: Page.GRANT_USER_GROUP, grantUserGroupId: groupIdA };
+
+          // Group relation(parent -> child): groupIdA -> groupIdB -> groupIdC
+          // this should fail because the groupC is a descendant of groupA
+          await expect(updatePage(_page2, 'new', 'old', pModelUser3, options)) // from GRANT_OWNER to GRANT_USER_GROUP(groupIdA)
+            .rejects.toThrow(new Error('The selected grant or grantedGroup is not assignable to this page.'));
+
+          const page1 = await Page.findById(_page1._id);
+          const page2 = await Page.findById(_page2._id);
+          expect(page1).toBeTruthy();
+          expect(page2).toBeTruthy();
+
+          expect(page2.grant).toBe(Page.GRANT_OWNER); // should be the same before the update
+          expect(page2.grantedUsers).toStrictEqual([pModelUser3._id]); // should be the same before the update
+          expect(page2.grantedGroup).toBeUndefined(); // no group should be set
+        });
+      });
+      describe('update grant of a page under a page with GRANT_OWNER', () => {
+        test('Fail to change from GRNAT_OWNER', async() => {
+          // path
+          const path1 = '/mup35_owner';
+          const path2 = '/mup35_owner/mup36_owner';
+          // page
+          const _page1 = await Page.findOne({ path: path1, grant: Page.GRANT_OWNER, grantedUsers: [pModelUser1] });
+          const _page2 = await Page.findOne({ // update target
+            path: path2, grant: Page.GRANT_OWNER, grantedUsers: [pModelUser1], parent: _page1._id,
+          });
+          expect(_page1).toBeTruthy();
+          expect(_page2).toBeTruthy();
+
+          const options = { grant: Page.GRANT_USER_GROUP, grantUserGroupId: groupIdA };
+          await expect(updatePage(_page2, 'new', 'old', pModelUser1, options)) // from GRANT_OWNER to GRANT_USER_GROUP(groupIdA)
+            .rejects.toThrow(new Error('The selected grant or grantedGroup is not assignable to this page.'));
+
+          const page1 = await Page.findById(_page1.id);
+          const page2 = await Page.findById(_page2.id);
+          expect(page1).toBeTruthy();
+          expect(page2).toBeTruthy();
+          expect(page2.grant).toBe(Page.GRANT_OWNER); // should be the same before the update
+          expect(page2.grantedUsers).toStrictEqual([pModelUser1._id]); // should be the same before the update
+          expect(page2.grantedGroup).toBeUndefined(); // no group should be set
+        });
+      });
+
+    });
 
   });
 });

+ 122 - 237
packages/app/test/integration/service/v5.page.test.ts

@@ -103,6 +103,9 @@ describe('Test page service methods', () => {
     const pageId15 = new mongoose.Types.ObjectId();
     const pageId16 = new mongoose.Types.ObjectId();
     const pageId17 = new mongoose.Types.ObjectId();
+    const pageId18 = new mongoose.Types.ObjectId();
+    const pageId19 = new mongoose.Types.ObjectId();
+    const pageId20 = new mongoose.Types.ObjectId();
 
     await Page.insertMany([
       {
@@ -158,7 +161,7 @@ describe('Test page service methods', () => {
       {
         _id: pageId5,
         path: '/resume_rename_4/resume_rename_5',
-        parent: pageId0,
+        parent: pageId4,
         grant: Page.GRANT_PUBLIC,
         creator: dummyUser1,
         lastUpdateUser: dummyUser1._id,
@@ -303,6 +306,54 @@ describe('Test page service methods', () => {
         descendantCount: 0,
         isEmpty: false,
       },
+      {
+        _id: pageId18,
+        path: '/fix_descendantCount_1',
+        parent: rootPage._id,
+        grant: Page.GRANT_PUBLIC,
+        creator: dummyUser1,
+        lastUpdateUser: dummyUser1._id,
+        descendantCount: 100, // broken
+        isEmpty: false,
+      },
+      {
+        _id: pageId19,
+        path: '/fix_descendantCount_1/fix_descendantCount_2',
+        parent: pageId18,
+        grant: Page.GRANT_PUBLIC,
+        creator: dummyUser1,
+        lastUpdateUser: dummyUser1._id,
+        descendantCount: 100, // broken
+        isEmpty: true,
+      },
+      {
+        path: '/fix_descendantCount_1/fix_descendantCount_2/fix_descendantCount_3',
+        parent: pageId19,
+        grant: Page.GRANT_PUBLIC,
+        creator: dummyUser1,
+        lastUpdateUser: dummyUser1._id,
+        descendantCount: 100, // broken
+        isEmpty: false,
+      },
+      {
+        _id: pageId20,
+        path: '/fix_descendantCount_4',
+        parent: rootPage._id,
+        grant: Page.GRANT_PUBLIC,
+        creator: dummyUser1,
+        lastUpdateUser: dummyUser1._id,
+        descendantCount: 100, // broken
+        isEmpty: false,
+      },
+      {
+        path: '/fix_descendantCount_4/fix_descendantCount_5',
+        parent: pageId20,
+        grant: Page.GRANT_PUBLIC,
+        creator: dummyUser1,
+        lastUpdateUser: dummyUser1._id,
+        descendantCount: 100, // broken
+        isEmpty: false,
+      },
     ]);
 
     /**
@@ -312,14 +363,10 @@ describe('Test page service methods', () => {
     pageOpId2 = new mongoose.Types.ObjectId();
     pageOpId3 = new mongoose.Types.ObjectId();
     pageOpId4 = new mongoose.Types.ObjectId();
-    pageOpId5 = new mongoose.Types.ObjectId();
-    pageOpId6 = new mongoose.Types.ObjectId();
     const pageOpRevisionId1 = new mongoose.Types.ObjectId();
     const pageOpRevisionId2 = new mongoose.Types.ObjectId();
     const pageOpRevisionId3 = new mongoose.Types.ObjectId();
     const pageOpRevisionId4 = new mongoose.Types.ObjectId();
-    const pageOpRevisionId5 = new mongoose.Types.ObjectId();
-    const pageOpRevisionId6 = new mongoose.Types.ObjectId();
 
     await PageOperation.insertMany([
       {
@@ -438,76 +485,18 @@ describe('Test page service methods', () => {
         },
         unprocessableExpiryDate: null,
       },
-      {
-        _id: pageOpId5,
-        actionType: 'Rename',
-        actionStage: 'Sub',
-        fromPath: '/resume_rename_11/resume_rename_13',
-        toPath: '/resume_rename_11/resume_rename_12/resume_rename_13',
-        page: {
-          _id: pageId12,
-          parent: pageId10,
-          descendantCount: 1,
-          isEmpty: false,
-          path: '/resume_rename_11/resume_rename_13',
-          revision: pageOpRevisionId5,
-          status: 'published',
-          grant: Page.GRANT_PUBLIC,
-          grantedUsers: [],
-          grantedGroup: null,
-          creator: dummyUser1._id,
-          lastUpdateUser: dummyUser1._id,
-        },
-        user: {
-          _id: dummyUser1._id,
-        },
-        options: {
-          createRedirectPage: false,
-          updateMetadata: true,
-        },
-        unprocessableExpiryDate: new Date(),
-      },
-      {
-        _id: pageOpId6,
-        actionType: 'Rename',
-        actionStage: 'Sub',
-        fromPath: '/resume_rename_15/resume_rename_16/resume_rename_18',
-        toPath: '/resume_rename_15/resume_rename_17/resume_rename_18',
-        page: {
-          _id: pageId16,
-          parent: pageId14,
-          descendantCount: 1,
-          isEmpty: false,
-          path: '/resume_rename_15/resume_rename_16/resume_rename_18',
-          revision: pageOpRevisionId6,
-          status: 'published',
-          grant: Page.GRANT_PUBLIC,
-          grantedUsers: [],
-          grantedGroup: null,
-          creator: dummyUser1._id,
-          lastUpdateUser: dummyUser1._id,
-        },
-        user: {
-          _id: dummyUser1._id,
-        },
-        options: {
-          createRedirectPage: false,
-          updateMetadata: true,
-        },
-        unprocessableExpiryDate: new Date(),
-      },
     ]);
   });
 
   describe('restart renameOperation', () => {
-    const resumeRenameSubOperation = async(page) => {
-      const mockedRenameSubOperation = jest.spyOn(crowi.pageService, 'renameSubOperation').mockReturnValue(null);
-      await crowi.pageService.resumeRenameSubOperation(page);
+    const resumeRenameSubOperation = async(renamePage, pageOp) => {
+      const mockedPathsAndDescendantCountOfAncestors = jest.spyOn(crowi.pageService, 'fixPathsAndDescendantCountOfAncestors').mockReturnValue(null);
+      await crowi.pageService.resumeRenameSubOperation(renamePage, pageOp);
 
-      const argsForRenameSubOperation = mockedRenameSubOperation.mock.calls[0];
+      const argsForRenameSubOperation = mockedPathsAndDescendantCountOfAncestors.mock.calls[0];
 
-      mockedRenameSubOperation.mockRestore();
-      await crowi.pageService.renameSubOperation(...argsForRenameSubOperation);
+      mockedPathsAndDescendantCountOfAncestors.mockRestore();
+      await crowi.pageService.fixPathsAndDescendantCountOfAncestors(...argsForRenameSubOperation);
     };
 
     test('it should successfully restart rename operation', async() => {
@@ -547,7 +536,7 @@ describe('Test page service methods', () => {
       expect(_pageOperation).toBeTruthy();
 
       // rename
-      await resumeRenameSubOperation(_page1);
+      await resumeRenameSubOperation(_page1, _pageOperation);
 
       // page
       const page0 = await Page.findById(_page0._id);
@@ -605,7 +594,7 @@ describe('Test page service methods', () => {
       expect(_pageOperation).toBeTruthy();
 
       // rename
-      await resumeRenameSubOperation(_page1);
+      await resumeRenameSubOperation(_page1, _pageOperation);
 
       // page
       const page0 = await Page.findById(_page0._id);
@@ -630,43 +619,6 @@ describe('Test page service methods', () => {
       expect(page1.descendantCount).toBe(1);
       expect(page2.descendantCount).toBe(0);
     });
-
-    test('it should fail and throw error if PageOperation is not found', async() => {
-      // create dummy page operation data not stored in DB
-      const notExistPageOp = {
-        _id: new mongoose.Types.ObjectId(),
-        actionType: 'Rename',
-        actionStage: 'Sub',
-        fromPath: '/FROM_NOT_EXIST',
-        toPath: 'TO_NOT_EXIST',
-        page: {
-          _id: new mongoose.Types.ObjectId(),
-          parent: rootPage._id,
-          descendantCount: 2,
-          isEmpty: false,
-          path: '/NOT_EXIST_PAGE',
-          revision: new mongoose.Types.ObjectId(),
-          status: 'published',
-          grant: 1,
-          grantedUsers: [],
-          grantedGroup: null,
-          creator: dummyUser1._id,
-          lastUpdateUser: dummyUser1._id,
-        },
-        user: {
-          _id: dummyUser1._id,
-        },
-        options: {
-          createRedirectPage: false,
-          updateMetadata: false,
-        },
-        unprocessableExpiryDate: new Date(),
-      };
-
-      await expect(resumeRenameSubOperation(notExistPageOp))
-        .rejects.toThrow(new Error('There is nothing to be processed right now'));
-    });
-
     test('it should fail and throw error if the current time is behind unprocessableExpiryDate', async() => {
       // path before renaming
       const _path0 = '/resume_rename_4'; // out of renaming scope
@@ -683,21 +635,21 @@ describe('Test page service methods', () => {
       // page operation
       const fromPath = '/resume_rename_5';
       const toPath = '/resume_rename_4/resume_rename_5';
-      const pageOperation = await PageOperation.findOne({
+      const _pageOperation = await PageOperation.findOne({
         _id: pageOpId2, fromPath, toPath, 'page._id': _page1._id, actionType: PageActionType.Rename, actionStage: PageActionStage.Sub,
       });
-      expect(pageOperation).toBeTruthy();
+      expect(_pageOperation).toBeTruthy();
 
       // Make `unprocessableExpiryDate` 15 seconds ahead of current time.
       // The number 15 seconds has no meaning other than placing time in the furue.
-      await PageOperation.findByIdAndUpdate(pageOperation._id, { unprocessableExpiryDate: addSeconds(new Date(), 15) });
+      const pageOperation = await PageOperation.findByIdAndUpdate(_pageOperation._id, { unprocessableExpiryDate: addSeconds(new Date(), 15) }, { new: true });
+      expect(pageOperation).toBeTruthy();
 
-      await expect(resumeRenameSubOperation(_page1)).rejects.toThrow(new Error('This page operation is currently being processed'));
+      await expect(resumeRenameSubOperation(_page1, pageOperation)).rejects.toThrow(new Error('This page operation is currently being processed'));
 
       // cleanup
       await PageOperation.findByIdAndDelete(pageOperation._id);
     });
-
     test('Missing property(toPath) for PageOperation should throw error', async() => {
       // page
       const _path1 = '/resume_rename_7';
@@ -710,152 +662,85 @@ describe('Test page service methods', () => {
       });
       expect(pageOperation).toBeTruthy();
 
-      const promise = resumeRenameSubOperation(_page1);
-      await expect(promise).rejects.toThrow(new Error(`Property toPath is missing which is needed to resume page operation(${pageOperation._id})`));
+      const promise = resumeRenameSubOperation(_page1, pageOperation);
+      await expect(promise).rejects.toThrow(new Error(`Property toPath is missing which is needed to resume rename operation(${pageOperation._id})`));
 
       // cleanup
       await PageOperation.findByIdAndDelete(pageOperation._id);
     });
-
-    test(`it should succeed but 2 extra descendantCount should be added
-    if the page operation was interrupted right after increasing ancestor's descendantCount in renameSubOperation`, async() => {
-      // paths before renaming
-      const _path0 = '/resume_rename_11'; // out of renaming scope
-      const _path1 = '/resume_rename_11/resume_rename_12'; // out of renaming scope
-      const _path2 = '/resume_rename_11/resume_rename_12/resume_rename_13'; // renamed already
-      const _path3 = '/resume_rename_11/resume_rename_12/resume_rename_13/resume_rename_14'; // renamed already
-
-      // paths after renaming
-      const path0 = '/resume_rename_11';
-      const path1 = '/resume_rename_11/resume_rename_12';
-      const path2 = '/resume_rename_11/resume_rename_12/resume_rename_13';
-      const path3 = '/resume_rename_11/resume_rename_12/resume_rename_13/resume_rename_14';
-
-      // page
-      const _page0 = await Page.findOne({ path: _path0 });
-      const _page1 = await Page.findOne({ path: _path1 });
-      const _page2 = await Page.findOne({ path: _path2 });
-      const _page3 = await Page.findOne({ path: _path3 });
-      expect(_page0).toBeTruthy();
-      expect(_page1).toBeTruthy();
-      expect(_page2).toBeTruthy();
-      expect(_page3).toBeTruthy();
-
-      // descendantCount
-      expect(_page0.descendantCount).toBe(3);
-      expect(_page1.descendantCount).toBe(2);
-      expect(_page2.descendantCount).toBe(1);
-      expect(_page3.descendantCount).toBe(0);
-
-      // page operation
-      const fromPath = '/resume_rename_11/resume_rename_13';
-      const toPath = '/resume_rename_11/resume_rename_12/resume_rename_13';
-      const _pageOperation = await PageOperation.findOne({
-        _id: pageOpId5, fromPath, toPath, 'page._id': _page2._id, actionType: PageActionType.Rename, actionStage: PageActionStage.Sub,
-      });
-      expect(_pageOperation).toBeTruthy();
-
-      // rename
-      await resumeRenameSubOperation(_page2);
-
-      // page
-      const page0 = await Page.findById(_page0._id);
-      const page1 = await Page.findById(_page1._id);
-      const page2 = await Page.findById(_page2._id);
-      const page3 = await Page.findById(_page3._id);
-      expect(page0).toBeTruthy();
-      expect(page1).toBeTruthy();
-      expect(page2).toBeTruthy();
-      expect(page3).toBeTruthy();
-      expect(page0.path).toBe(path0);
-      expect(page1.path).toBe(path1);
-      expect(page2.path).toBe(path2);
-      expect(page3.path).toBe(path3);
-
-      // page operation
-      const pageOperation = await PageOperation.findById(_pageOperation._id);
-      expect(pageOperation).toBeNull(); // should not exist
-
-      // 2 extra descendants should be added to page1
-      expect(page0.descendantCount).toBe(3);
-      expect(page1.descendantCount).toBe(3); // originally 2, +1 in Main, -1 in Sub, +2 for new descendants
-      expect(page2.descendantCount).toBe(1);
-      expect(page3.descendantCount).toBe(0);
-    });
-
-    test(`it should succeed but 2 extra descendantCount should be subtracted from ex parent page
-    if the page operation was interrupted right after reducing ancestor's descendantCount in renameSubOperation`, async() => {
-      // paths before renaming
-      const _path0 = '/resume_rename_15'; // out of renaming scope
-      const _path1 = '/resume_rename_15/resume_rename_16'; // out of renaming scope
-      const _path2 = '/resume_rename_15/resume_rename_17'; // out of renaming scope
-      const _path3 = '/resume_rename_15/resume_rename_17/resume_rename_18'; // renamed already
-      const _path4 = '/resume_rename_15/resume_rename_17/resume_rename_18/resume_rename_19'; // renamed already
-
-      // paths after renaming
-      const path0 = '/resume_rename_15';
-      const path1 = '/resume_rename_15/resume_rename_16';
-      const path2 = '/resume_rename_15/resume_rename_17';
-      const path3 = '/resume_rename_15/resume_rename_17/resume_rename_18';
-      const path4 = '/resume_rename_15/resume_rename_17/resume_rename_18/resume_rename_19';
-
+  });
+  describe('updateDescendantCountOfPagesWithPaths', () => {
+    test('should fix descendantCount of pages with one of the given paths', async() => {
+      // path
+      const _path1 = '/fix_descendantCount_1';
+      const _path2 = '/fix_descendantCount_1/fix_descendantCount_2'; // empty
+      const _path3 = '/fix_descendantCount_1/fix_descendantCount_2/fix_descendantCount_3';
+      const _path4 = '/fix_descendantCount_4';
+      const _path5 = '/fix_descendantCount_4/fix_descendantCount_5';
       // page
-      const _page0 = await Page.findOne({ path: _path0 });
       const _page1 = await Page.findOne({ path: _path1 });
       const _page2 = await Page.findOne({ path: _path2 });
       const _page3 = await Page.findOne({ path: _path3 });
       const _page4 = await Page.findOne({ path: _path4 });
-      expect(_page0).toBeTruthy();
+      const _page5 = await Page.findOne({ path: _path5 });
+      // check existance
       expect(_page1).toBeTruthy();
       expect(_page2).toBeTruthy();
       expect(_page3).toBeTruthy();
       expect(_page4).toBeTruthy();
-
-      // descendantCount
-      expect(_page0.descendantCount).toBe(2);
-      expect(_page1.descendantCount).toBe(0);
-      expect(_page2.descendantCount).toBe(1);
-      expect(_page3.descendantCount).toBe(1);
-      expect(_page4.descendantCount).toBe(0);
-
-      // page operation
-      const fromPath = '/resume_rename_15/resume_rename_16/resume_rename_18';
-      const toPath = '/resume_rename_15/resume_rename_17/resume_rename_18';
-      const _pageOperation = await PageOperation.findOne({
-        _id: pageOpId6, fromPath, toPath, 'page._id': _page3._id, actionType: PageActionType.Rename, actionStage: PageActionStage.Sub,
-      });
-      expect(_pageOperation).toBeTruthy();
-
-      // rename
-      await resumeRenameSubOperation(_page3);
+      expect(_page5).toBeTruthy();
+      // check descendantCount (all broken)
+      expect(_page1.descendantCount).toBe(100);
+      expect(_page2.descendantCount).toBe(100);
+      expect(_page3.descendantCount).toBe(100);
+      expect(_page4.descendantCount).toBe(100);
+      expect(_page5.descendantCount).toBe(100);
+      // check isEmpty
+      expect(_page1.isEmpty).toBe(false);
+      expect(_page2.isEmpty).toBe(true);
+      expect(_page3.isEmpty).toBe(false);
+      expect(_page4.isEmpty).toBe(false);
+      expect(_page5.isEmpty).toBe(false);
+      // check parent
+      expect(_page1.parent).toStrictEqual(rootPage._id);
+      expect(_page2.parent).toStrictEqual(_page1._id);
+      expect(_page3.parent).toStrictEqual(_page2._id);
+      expect(_page4.parent).toStrictEqual(rootPage._id);
+      expect(_page5.parent).toStrictEqual(_page4._id);
+
+      await crowi.pageService.updateDescendantCountOfPagesWithPaths([_path1, _path2, _path3, _path4, _path5]);
 
       // page
-      const page0 = await Page.findById(_page0._id);
       const page1 = await Page.findById(_page1._id);
       const page2 = await Page.findById(_page2._id);
       const page3 = await Page.findById(_page3._id);
       const page4 = await Page.findById(_page4._id);
-      expect(page0).toBeTruthy();
+      const page5 = await Page.findById(_page5._id);
+
+      // check existance
       expect(page1).toBeTruthy();
       expect(page2).toBeTruthy();
       expect(page3).toBeTruthy();
-      expect(page3).toBeTruthy();
-      expect(page0.path).toBe(path0);
-      expect(page1.path).toBe(path1);
-      expect(page2.path).toBe(path2);
-      expect(page3.path).toBe(path3);
-      expect(page4.path).toBe(path4);
-
-      // page operation
-      const pageOperation = await PageOperation.findById(_pageOperation._id);
-      expect(pageOperation).toBeNull(); // should not exist
-
-      // 2 extra descendants should be subtracted from page1
-      expect(page0.descendantCount).toBe(2);
-      expect(page1.descendantCount).toBe(-2); // originally 0, -2 for old descendants
-      expect(page2.descendantCount).toBe(2); // originally 1, -1 in Sub, +2 for new descendants
-      expect(page3.descendantCount).toBe(1);
-      expect(page4.descendantCount).toBe(0);
+      expect(page4).toBeTruthy();
+      expect(page5).toBeTruthy();
+      // check descendantCount (all fixed)
+      expect(page1.descendantCount).toBe(1);
+      expect(page2.descendantCount).toBe(1);
+      expect(page3.descendantCount).toBe(0);
+      expect(page4.descendantCount).toBe(1);
+      expect(page5.descendantCount).toBe(0);
+      // check isEmpty
+      expect(page1.isEmpty).toBe(false);
+      expect(page2.isEmpty).toBe(true);
+      expect(page3.isEmpty).toBe(false);
+      expect(page4.isEmpty).toBe(false);
+      expect(page5.isEmpty).toBe(false);
+      // check parent
+      expect(page1.parent).toStrictEqual(rootPage._id);
+      expect(page2.parent).toStrictEqual(page1._id);
+      expect(page3.parent).toStrictEqual(page2._id);
+      expect(page4.parent).toStrictEqual(rootPage._id);
+      expect(page5.parent).toStrictEqual(page4._id);
     });
   });
 });

+ 1 - 1
packages/codemirror-textlint/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@growi/codemirror-textlint",
-  "version": "5.0.10",
+  "version": "5.0.11-RC.0",
   "license": "MIT",
   "main": "dist/index.js",
   "scripts": {

+ 1 - 1
packages/core/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@growi/core",
-  "version": "5.0.10",
+  "version": "5.0.11-RC.0",
   "description": "GROWI Core Libraries",
   "license": "MIT",
   "keywords": [

+ 1 - 1
packages/plugin-attachment-refs/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@growi/plugin-attachment-refs",
-  "version": "5.0.10",
+  "version": "5.0.11-RC.0",
   "description": "GROWI Plugin to add ref/refimg/refs/refsimg tags",
   "license": "MIT",
   "keywords": [

+ 1 - 1
packages/plugin-lsx/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@growi/plugin-lsx",
-  "version": "5.0.10",
+  "version": "5.0.11-RC.0",
   "description": "GROWI plugin to list pages",
   "license": "MIT",
   "keywords": [

+ 1 - 1
packages/plugin-pukiwiki-like-linker/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@growi/plugin-pukiwiki-like-linker",
-  "version": "5.0.10",
+  "version": "5.0.11-RC.0",
   "description": "GROWI plugin to add PukiwikiLikeLinker",
   "license": "MIT",
   "keywords": [

+ 1 - 1
packages/slack/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@growi/slack",
-  "version": "5.0.10",
+  "version": "5.0.11-RC.0",
   "license": "MIT",
   "main": "dist/index.js",
   "typings": "dist/index.d.ts",

+ 2 - 2
packages/slackbot-proxy/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@growi/slackbot-proxy",
-  "version": "5.0.10",
+  "version": "5.0.11-slackbot-proxy.0",
   "license": "MIT",
   "scripts": {
     "build": "yarn tsc && tsc-alias -p tsconfig.build.json",
@@ -25,7 +25,7 @@
   },
   "dependencies": {
     "@godaddy/terminus": "^4.9.0",
-    "@growi/slack": "^5.0.10",
+    "@growi/slack": "^5.0.11-RC.0",
     "@slack/oauth": "^2.0.1",
     "@slack/web-api": "^6.2.4",
     "@tsed/common": "^6.43.0",

+ 1 - 1
packages/ui/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@growi/ui",
-  "version": "5.0.10",
+  "version": "5.0.11-RC.0",
   "description": "GROWI UI Libraries",
   "license": "MIT",
   "keywords": [