ソースを参照

Merge branch 'master' into imprv/testcode-for-page-model-user-group

Yohei Shiina 3 年 前
コミット
4eee53e0e0
95 ファイル変更2943 行追加1218 行削除
  1. 15 2
      CHANGELOG.md
  2. 1 1
      lerna.json
  3. 1 1
      package.json
  4. 1 0
      packages/app/config/webpack.common.js
  5. 2 2
      packages/app/docker/README.md
  6. 7 7
      packages/app/package.json
  7. 0 0
      packages/app/public/static/locales/en_US/admin/admin.json
  8. 0 0
      packages/app/public/static/locales/en_US/meta.json
  9. 11 1
      packages/app/public/static/locales/en_US/translation.json
  10. 0 0
      packages/app/public/static/locales/index.js
  11. 0 0
      packages/app/public/static/locales/ja_JP/admin/admin.json
  12. 0 0
      packages/app/public/static/locales/ja_JP/meta.json
  13. 11 1
      packages/app/public/static/locales/ja_JP/translation.json
  14. 0 0
      packages/app/public/static/locales/zh_CN/admin/admin.json
  15. 0 0
      packages/app/public/static/locales/zh_CN/meta.json
  16. 11 1
      packages/app/public/static/locales/zh_CN/translation.json
  17. 36 40
      packages/app/src/client/admin.jsx
  18. 2 0
      packages/app/src/client/base.jsx
  19. 60 0
      packages/app/src/client/installer.jsx
  20. 2 1
      packages/app/src/client/legacy/crowi.js
  21. 67 65
      packages/app/src/client/nologin.jsx
  22. 4 32
      packages/app/src/client/services/AppContainer.js
  23. 2 3
      packages/app/src/client/services/CommentContainer.js
  24. 10 2
      packages/app/src/client/services/ContextExtractor.tsx
  25. 5 26
      packages/app/src/client/services/PageContainer.js
  26. 9 1
      packages/app/src/client/services/page-operation.ts
  27. 7 0
      packages/app/src/client/util/apiv1-client.ts
  28. 12 3
      packages/app/src/client/util/apiv3-client.ts
  29. 3 2
      packages/app/src/client/util/i18n.js
  30. 2 1
      packages/app/src/client/util/markdown-it/toc-and-anchor.js
  31. 2 2
      packages/app/src/client/util/reveal/plugins/growi-renderer.js
  32. 1 4
      packages/app/src/components/Admin/App/AwsSetting.jsx
  33. 3 13
      packages/app/src/components/Admin/ImportData/GrowiArchive/UploadForm.jsx
  34. 0 79
      packages/app/src/components/Admin/Users/RemoveAdminButton.jsx
  35. 62 0
      packages/app/src/components/Admin/Users/RemoveAdminMenuItem.tsx
  36. 60 0
      packages/app/src/components/Admin/Users/StatusSuspendMenuItem.tsx
  37. 0 78
      packages/app/src/components/Admin/Users/StatusSuspendedButton.jsx
  38. 12 9
      packages/app/src/components/Admin/Users/UserMenu.jsx
  39. 40 3
      packages/app/src/components/Common/Dropdown/PageItemControl.tsx
  40. 6 9
      packages/app/src/components/Drawio.tsx
  41. 6 3
      packages/app/src/components/InstallerForm.jsx
  42. 11 6
      packages/app/src/components/LoginForm.jsx
  43. 5 16
      packages/app/src/components/Me/ProfileImageSettings.tsx
  44. 1 1
      packages/app/src/components/MyDraftList/Draft.jsx
  45. 2 2
      packages/app/src/components/Page/RevisionRenderer.jsx
  46. 5 40
      packages/app/src/components/Page/TrashPageAlert.jsx
  47. 2 2
      packages/app/src/components/PageComment/Comment.jsx
  48. 1 1
      packages/app/src/components/PageComment/CommentEditor.jsx
  49. 9 12
      packages/app/src/components/PageEditor.tsx
  50. 5 2
      packages/app/src/components/PageEditor/Preview.tsx
  51. 1 9
      packages/app/src/components/PasswordResetExecutionForm.jsx
  52. 1 12
      packages/app/src/components/PasswordResetRequestForm.jsx
  53. 1 1
      packages/app/src/components/PrivateLegacyPages.tsx
  54. 35 12
      packages/app/src/components/Sidebar/PageTree/Item.tsx
  55. 1 1
      packages/app/src/components/TrashPageList.jsx
  56. 15 0
      packages/app/src/interfaces/page-operation.ts
  57. 2 1
      packages/app/src/interfaces/page.ts
  58. 10 0
      packages/app/src/next-i18next.config.ts
  59. 5 4
      packages/app/src/server/crowi/dev.js
  60. 5 4
      packages/app/src/server/crowi/express-init.js
  61. 14 7
      packages/app/src/server/crowi/index.js
  62. 35 1
      packages/app/src/server/models/page-operation.ts
  63. 19 96
      packages/app/src/server/models/page.ts
  64. 2 8
      packages/app/src/server/models/user.js
  65. 3 4
      packages/app/src/server/routes/apiv3/app-settings.js
  66. 9 10
      packages/app/src/server/routes/apiv3/page-listing.ts
  67. 26 0
      packages/app/src/server/routes/apiv3/pages.js
  68. 2 2
      packages/app/src/server/routes/apiv3/personal-setting.js
  69. 0 4
      packages/app/src/server/routes/page.js
  70. 124 18
      packages/app/src/server/service/page-operation.ts
  71. 170 21
      packages/app/src/server/service/page.ts
  72. 4 3
      packages/app/src/server/views/installer.html
  73. 4 0
      packages/app/src/stores/context.tsx
  74. 31 18
      packages/app/src/stores/ui.tsx
  75. 11 7
      packages/app/src/stores/websocket.tsx
  76. 0 50
      packages/app/src/utils/locale-utils.ts
  77. 82 2
      packages/app/test/integration/global-setup.js
  78. 0 427
      packages/app/test/integration/models/v5.page.test.js
  79. 270 0
      packages/app/test/integration/service/page.test.js
  80. 242 0
      packages/app/test/integration/service/v5.non-public-page.test.ts
  81. 861 0
      packages/app/test/integration/service/v5.page.test.ts
  82. 407 0
      packages/app/test/integration/service/v5.public-page.test.ts
  83. 1 1
      packages/codemirror-textlint/package.json
  84. 1 1
      packages/core/package.json
  85. 20 0
      packages/core/src/utils/page-path-utils.ts
  86. 1 1
      packages/plugin-attachment-refs/package.json
  87. 2 2
      packages/plugin-attachment-refs/src/client-entry.js
  88. 1 1
      packages/plugin-lsx/package.json
  89. 3 3
      packages/plugin-lsx/src/client-entry.js
  90. 0 1
      packages/plugin-lsx/src/client/js/components/Lsx.jsx
  91. 3 7
      packages/plugin-lsx/src/client/js/util/Interceptor/LsxPostRenderInterceptor.js
  92. 1 1
      packages/plugin-pukiwiki-like-linker/package.json
  93. 1 1
      packages/slack/package.json
  94. 2 2
      packages/slackbot-proxy/package.json
  95. 1 1
      packages/ui/package.json

+ 15 - 2
CHANGELOG.md

@@ -1,9 +1,23 @@
 # Changelog
 
-## [Unreleased](https://github.com/weseek/growi/compare/v5.0.8...HEAD)
+## [Unreleased](https://github.com/weseek/growi/compare/v5.0.9...HEAD)
 
 *Please do not manually update this file. We've automated the process.*
 
+## [v5.0.9](https://github.com/weseek/growi/compare/v5.0.8...v5.0.9) - 2022-06-13
+
+### 🚀 Improvement
+
+- imprv: Render MathJax in Preview tab of comment (#6025) @yuki-takei
+- imprv: Exception handling for user authentication (#6019) @kaoritokashiki
+- imprv: Sidebar background color on light theme and add shadow on dark theme (#6012) @shukmos
+- imprv: Limit display of notification paths (#5991) @jam411
+
+### 🐛 Bug Fixes
+
+- fix: Getting page API is broken (#6023) @yuki-takei
+- fix: MathJax does not working (#6020) @yuki-takei
+
 ## [v5.0.8](https://github.com/weseek/growi/compare/v5.0.7...v5.0.8) - 2022-06-07
 
 ### 🚀 Improvement
@@ -112,7 +126,6 @@
 
 - support: Typescriptize tag model (#5778) @kaoritokashiki
 
-
 ## [v4.5.20](https://github.com/weseek/growi/compare/v4.5.19...v4.5.20) - 2022-05-12
 
 ### 🐛 Bug Fixes

+ 1 - 1
lerna.json

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

+ 1 - 1
package.json

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

+ 1 - 0
packages/app/config/webpack.common.js

@@ -25,6 +25,7 @@ module.exports = (options) => {
       'js/app':                       './src/client/app',
       'js/admin':                     './src/client/admin',
       'js/nologin':                   './src/client/nologin',
+      'js/installer':                   './src/client/installer',
       'js/legacy':                    './src/client/legacy/crowi',
       'js/legacy-presentation':       './src/client/legacy/crowi-presentation',
       'js/plugin':                    './src/client/plugin',

+ 2 - 2
packages/app/docker/README.md

@@ -10,8 +10,8 @@ GROWI Official docker image
 Supported tags and respective Dockerfile links
 ------------------------------------------------
 
-* [`5.0.8`, `5.0`, `5`, `latest` (Dockerfile)](https://github.com/weseek/growi/blob/v5.0.8/docker/Dockerfile)
-* [`5.0.8-nocdn`, `5.0-nocdn`, `5-nocdn`, `latest-nocdn` (Dockerfile)](https://github.com/weseek/growi/blob/v5.0.8/docker/Dockerfile)
+* [`5.0.9`, `5.0`, `5`, `latest` (Dockerfile)](https://github.com/weseek/growi/blob/v5.0.9/docker/Dockerfile)
+* [`5.0.9-nocdn`, `5.0-nocdn`, `5-nocdn`, `latest-nocdn` (Dockerfile)](https://github.com/weseek/growi/blob/v5.0.9/docker/Dockerfile)
 * [`4.5.22`, `4.5`, `4`, `latest` (Dockerfile)](https://github.com/weseek/growi/blob/v4.5.22/docker/Dockerfile)
 * [`4.5.22-nocdn`, `4.5-nocdn`, `4-nocdn`, `latest-nocdn` (Dockerfile)](https://github.com/weseek/growi/blob/v4.5.22/docker/Dockerfile)
 * [`4.4.13`, `4.4` (Dockerfile)](https://github.com/weseek/growi/blob/v4.4.13/docker/Dockerfile)

+ 7 - 7
packages/app/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@growi/app",
-  "version": "5.0.9-RC.0",
+  "version": "5.0.10-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.9-RC.0",
-    "@growi/plugin-attachment-refs": "^5.0.9-RC.0",
-    "@growi/plugin-lsx": "^5.0.9-RC.0",
-    "@growi/plugin-pukiwiki-like-linker": "^5.0.9-RC.0",
-    "@growi/slack": "^5.0.9-RC.0",
+    "@growi/codemirror-textlint": "^5.0.10-RC.0",
+    "@growi/plugin-attachment-refs": "^5.0.10-RC.0",
+    "@growi/plugin-lsx": "^5.0.10-RC.0",
+    "@growi/plugin-pukiwiki-like-linker": "^5.0.10-RC.0",
+    "@growi/slack": "^5.0.10-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.9-RC.0",
+    "@growi/ui": "^5.0.10-RC.0",
     "@handsontable/react": "=2.1.0",
     "@types/compression": "^1.7.0",
     "@types/express": "^4.17.11",

+ 0 - 0
packages/app/resource/locales/en_US/admin/admin.json → packages/app/public/static/locales/en_US/admin/admin.json


+ 0 - 0
packages/app/resource/locales/en_US/meta.json → packages/app/public/static/locales/en_US/meta.json


+ 11 - 1
packages/app/resource/locales/en_US/translation.json → packages/app/public/static/locales/en_US/translation.json

@@ -5,6 +5,7 @@
   "Delete": "Delete",
   "delete_all": "Delete all",
   "Duplicate": "Duplicate",
+  "PathRecovery": "Path recovery",
   "Copy": "Copy",
   "preview":"Preview",
   "desktop":"Desktop",
@@ -1111,6 +1112,15 @@
     "cancel_bookmark": "Cancel Bookmark",
     "receive_notifications": "Receive Notifications",
     "stop_notification": "Stop Notification",
-    "footprints": "Footprints"
+    "footprints": "Footprints",
+    "operation": {
+      "attention": {
+        "rename": "Renaming paths of descendant pages was not successful, please open the menu from the 3-point reader and select 'Path recovery'"
+      }
+    }
+  },
+  "page_operation":{
+    "paths_recovered": "Paths recovered successfully",
+    "path_recovery_failed":"Path recovery failed"
   }
 }

+ 0 - 0
packages/app/resource/locales/index.js → packages/app/public/static/locales/index.js


+ 0 - 0
packages/app/resource/locales/ja_JP/admin/admin.json → packages/app/public/static/locales/ja_JP/admin/admin.json


+ 0 - 0
packages/app/resource/locales/ja_JP/meta.json → packages/app/public/static/locales/ja_JP/meta.json


+ 11 - 1
packages/app/resource/locales/ja_JP/translation.json → packages/app/public/static/locales/ja_JP/translation.json

@@ -5,6 +5,7 @@
   "Delete": "削除",
   "delete_all": "全て削除",
   "Duplicate": "複製",
+  "PathRecovery": "パスを修復",
   "Copy": "コピー",
   "preview":"プレビュー",
   "desktop":"パソコン",
@@ -1104,6 +1105,15 @@
     "cancel_bookmark": "ブックマークを取り消す",
     "receive_notifications": "通知を受け取る",
     "stop_notification": "通知を止める",
-    "footprints": "足跡"
+    "footprints": "足跡",
+    "operation": {
+      "attention": {
+        "rename": "配下のページパスの更新が正常に行われませんでした。3点リーダーからメニューを開き、「パスを修復」を選択してしてください。"
+      }
+    }
+  },
+  "page_operation":{
+    "paths_recovered": "パスを修復しました",
+    "path_recovery_failed":"パスを修復できませんでした"
   }
 }

+ 0 - 0
packages/app/resource/locales/zh_CN/admin/admin.json → packages/app/public/static/locales/zh_CN/admin/admin.json


+ 0 - 0
packages/app/resource/locales/zh_CN/meta.json → packages/app/public/static/locales/zh_CN/meta.json


+ 11 - 1
packages/app/resource/locales/zh_CN/translation.json → packages/app/public/static/locales/zh_CN/translation.json

@@ -5,6 +5,7 @@
 	"Delete": "删除",
 	"delete_all": "删除所有",
 	"Duplicate": "复制",
+  "PathRecovery": "路径恢复",
 	"Copy": "复制",
   "preview":"预览",
   "desktop":"电脑",
@@ -1114,6 +1115,15 @@
     "cancel_bookmark": "取消书签",
     "receive_notifications": "接收通知",
     "stop_notification": "停止通知",
-    "footprints": "脚印"
+    "footprints": "脚印",
+    "operation": {
+      "attention": {
+        "rename": "重命名子孙页的路径没有成功,请从三点式阅读器上打开菜单,选择 '路径恢复'。"
+      }
+    }
+  },
+  "page_operation":{
+    "paths_recovered": "成功恢复了页面路径",
+    "path_recovery_failed":"路径恢复失败"
   }
 }

+ 36 - 40
packages/app/src/client/admin.jsx

@@ -1,55 +1,52 @@
 import React from 'react';
+
 import ReactDOM from 'react-dom';
-import { Provider } from 'unstated';
 import { I18nextProvider } from 'react-i18next';
-
 import { SWRConfig } from 'swr';
+import { Provider } from 'unstated';
 
-import loggerFactory from '~/utils/logger';
-import { swrGlobalConfiguration } from '~/utils/swr-utils';
-
-import ErrorBoundary from '../components/ErrorBoudary';
-
-import AdminHome from '../components/Admin/AdminHome/AdminHome';
-import UserGroupDetailPage from '../components/Admin/UserGroupDetail/UserGroupDetailPage';
-import NotificationSetting from '../components/Admin/Notification/NotificationSetting';
-import LegacySlackIntegration from '../components/Admin/LegacySlackIntegration/LegacySlackIntegration';
-import SlackIntegration from '../components/Admin/SlackIntegration/SlackIntegration';
-import ManageGlobalNotification from '../components/Admin/Notification/ManageGlobalNotification';
-import MarkdownSetting from '../components/Admin/MarkdownSetting/MarkDownSetting';
-import UserManagement from '../components/Admin/UserManagement';
-import AppSettingsPage from '../components/Admin/App/AppSettingsPage';
-import SecurityManagement from '../components/Admin/Security/SecurityManagement';
-import ManageExternalAccount from '../components/Admin/ManageExternalAccount';
-import UserGroupPage from '../components/Admin/UserGroup/UserGroupPage';
-import Customize from '../components/Admin/Customize/Customize';
-import ImportDataPage from '../components/Admin/ImportDataPage';
-import ExportArchiveDataPage from '../components/Admin/ExportArchiveDataPage';
-import FullTextSearchManagement from '../components/Admin/FullTextSearchManagement';
-import AdminNavigation from '../components/Admin/Common/AdminNavigation';
-
-import AdminSocketIoContainer from '~/client/services/AdminSocketIoContainer';
-import AdminHomeContainer from '~/client/services/AdminHomeContainer';
-import AdminCustomizeContainer from '~/client/services/AdminCustomizeContainer';
-import AdminUserGroupDetailContainer from '~/client/services/AdminUserGroupDetailContainer';
-import AdminUsersContainer from '~/client/services/AdminUsersContainer';
 import AdminAppContainer from '~/client/services/AdminAppContainer';
-import AdminImportContainer from '~/client/services/AdminImportContainer';
-import AdminMarkDownContainer from '~/client/services/AdminMarkDownContainer';
+import AdminBasicSecurityContainer from '~/client/services/AdminBasicSecurityContainer';
+import AdminCustomizeContainer from '~/client/services/AdminCustomizeContainer';
 import AdminExternalAccountsContainer from '~/client/services/AdminExternalAccountsContainer';
 import AdminGeneralSecurityContainer from '~/client/services/AdminGeneralSecurityContainer';
+import AdminGitHubSecurityContainer from '~/client/services/AdminGitHubSecurityContainer';
+import AdminGoogleSecurityContainer from '~/client/services/AdminGoogleSecurityContainer';
+import AdminHomeContainer from '~/client/services/AdminHomeContainer';
+import AdminImportContainer from '~/client/services/AdminImportContainer';
 import AdminLdapSecurityContainer from '~/client/services/AdminLdapSecurityContainer';
 import AdminLocalSecurityContainer from '~/client/services/AdminLocalSecurityContainer';
-import AdminSamlSecurityContainer from '~/client/services/AdminSamlSecurityContainer';
-import AdminOidcSecurityContainer from '~/client/services/AdminOidcSecurityContainer';
-import AdminBasicSecurityContainer from '~/client/services/AdminBasicSecurityContainer';
-import AdminGoogleSecurityContainer from '~/client/services/AdminGoogleSecurityContainer';
-import AdminGitHubSecurityContainer from '~/client/services/AdminGitHubSecurityContainer';
-import AdminTwitterSecurityContainer from '~/client/services/AdminTwitterSecurityContainer';
+import AdminMarkDownContainer from '~/client/services/AdminMarkDownContainer';
 import AdminNotificationContainer from '~/client/services/AdminNotificationContainer';
+import AdminOidcSecurityContainer from '~/client/services/AdminOidcSecurityContainer';
+import AdminSamlSecurityContainer from '~/client/services/AdminSamlSecurityContainer';
 import AdminSlackIntegrationLegacyContainer from '~/client/services/AdminSlackIntegrationLegacyContainer';
-
+import AdminSocketIoContainer from '~/client/services/AdminSocketIoContainer';
+import AdminTwitterSecurityContainer from '~/client/services/AdminTwitterSecurityContainer';
+import AdminUserGroupDetailContainer from '~/client/services/AdminUserGroupDetailContainer';
+import AdminUsersContainer from '~/client/services/AdminUsersContainer';
 import ContextExtractor from '~/client/services/ContextExtractor';
+import loggerFactory from '~/utils/logger';
+import { swrGlobalConfiguration } from '~/utils/swr-utils';
+
+import AdminHome from '../components/Admin/AdminHome/AdminHome';
+import AppSettingsPage from '../components/Admin/App/AppSettingsPage';
+import AdminNavigation from '../components/Admin/Common/AdminNavigation';
+import Customize from '../components/Admin/Customize/Customize';
+import ExportArchiveDataPage from '../components/Admin/ExportArchiveDataPage';
+import FullTextSearchManagement from '../components/Admin/FullTextSearchManagement';
+import ImportDataPage from '../components/Admin/ImportDataPage';
+import LegacySlackIntegration from '../components/Admin/LegacySlackIntegration/LegacySlackIntegration';
+import ManageExternalAccount from '../components/Admin/ManageExternalAccount';
+import MarkdownSetting from '../components/Admin/MarkdownSetting/MarkDownSetting';
+import ManageGlobalNotification from '../components/Admin/Notification/ManageGlobalNotification';
+import NotificationSetting from '../components/Admin/Notification/NotificationSetting';
+import SecurityManagement from '../components/Admin/Security/SecurityManagement';
+import SlackIntegration from '../components/Admin/SlackIntegration/SlackIntegration';
+import UserGroupPage from '../components/Admin/UserGroup/UserGroupPage';
+import UserGroupDetailPage from '../components/Admin/UserGroupDetail/UserGroupDetailPage';
+import UserManagement from '../components/Admin/UserManagement';
+import ErrorBoundary from '../components/ErrorBoudary';
 
 import { appContainer, componentMappings } from './base';
 
@@ -58,7 +55,6 @@ const logger = loggerFactory('growi:admin');
 appContainer.initContents();
 
 const { i18n } = appContainer;
-
 // create unstated container instance
 const adminAppContainer = new AdminAppContainer(appContainer);
 const adminImportContainer = new AdminImportContainer(appContainer);

+ 2 - 0
packages/app/src/client/base.jsx

@@ -6,6 +6,7 @@ import AppContainer from '~/client/services/AppContainer';
 import SocketIoContainer from '~/client/services/SocketIoContainer';
 import { DescendantsPageListModal } from '~/components/DescendantsPageListModal';
 import PutbackPageModal from '~/components/PutbackPageModal';
+import InterceptorManager from '~/services/interceptor-manager';
 import Xss from '~/services/xss';
 import loggerFactory from '~/utils/logger';
 
@@ -31,6 +32,7 @@ const xss = new Xss();
 window.xss = xss;
 
 window.globalEmitter = new EventEmitter();
+window.interceptorManager = new InterceptorManager();
 
 // create unstated container instance
 const appContainer = new AppContainer();

+ 60 - 0
packages/app/src/client/installer.jsx

@@ -0,0 +1,60 @@
+import React from 'react';
+
+import ReactDOM from 'react-dom';
+import { I18nextProvider } from 'react-i18next';
+import { SWRConfig } from 'swr';
+
+
+import { swrGlobalConfiguration } from '~/utils/swr-utils';
+
+import InstallerForm from '../components/InstallerForm';
+
+import ContextExtractor from './services/ContextExtractor';
+import { i18nFactory } from './util/i18n';
+
+const i18n = i18nFactory();
+
+const componentMappings = {};
+
+// render InstallerForm
+const installerFormContainerElem = document.getElementById('installer-form-container');
+if (installerFormContainerElem) {
+  const userName = installerFormContainerElem.dataset.userName;
+  const name = installerFormContainerElem.dataset.name;
+  const email = installerFormContainerElem.dataset.email;
+
+  Object.assign(componentMappings, {
+    'installer-form-container': <InstallerForm userName={userName} name={name} email={email} />,
+  });
+}
+
+const renderMainComponents = () => {
+  Object.keys(componentMappings).forEach((key) => {
+    const elem = document.getElementById(key);
+    if (elem) {
+      ReactDOM.render(
+        <I18nextProvider i18n={i18n}>
+          <SWRConfig value={swrGlobalConfiguration}>
+            {componentMappings[key]}
+          </SWRConfig>
+        </I18nextProvider>,
+        elem,
+      );
+    }
+  });
+};
+
+// extract context before rendering main components
+const elem = document.getElementById('growi-context-extractor');
+if (elem != null) {
+  ReactDOM.render(
+    <SWRConfig value={swrGlobalConfiguration}>
+      <ContextExtractor></ContextExtractor>
+    </SWRConfig>,
+    elem,
+    renderMainComponents,
+  );
+}
+else {
+  renderMainComponents();
+}

+ 2 - 1
packages/app/src/client/legacy/crowi.js

@@ -13,7 +13,8 @@ if (!window) {
 window.Crowi = Crowi;
 
 Crowi.setCaretLine = function(line) {
-  window.globalEmitter.emit('setCaretLine', line);
+  // eslint-disable-next-line no-undef
+  globalEmitter.emit('setCaretLine', line);
 };
 
 // original: middleware.swigFilter

+ 67 - 65
packages/app/src/client/nologin.jsx

@@ -2,42 +2,32 @@ import React from 'react';
 
 import ReactDOM from 'react-dom';
 import { I18nextProvider } from 'react-i18next';
+import { SWRConfig } from 'swr';
 import { Provider } from 'unstated';
 
 
 import AppContainer from '~/client/services/AppContainer';
 import CompleteUserRegistrationForm from '~/components/CompleteUserRegistrationForm';
+import { swrGlobalConfiguration } from '~/utils/swr-utils';
 
-import InstallerForm from '../components/InstallerForm';
 import LoginForm from '../components/LoginForm';
 import PasswordResetExecutionForm from '../components/PasswordResetExecutionForm';
 import PasswordResetRequestForm from '../components/PasswordResetRequestForm';
 
+import ContextExtractor from './services/ContextExtractor';
 import { i18nFactory } from './util/i18n';
 
 const i18n = i18nFactory();
 
-// render InstallerForm
-const installerFormContainerElem = document.getElementById('installer-form-container');
-if (installerFormContainerElem) {
-  const userName = installerFormContainerElem.dataset.userName;
-  const name = installerFormContainerElem.dataset.name;
-  const email = installerFormContainerElem.dataset.email;
-  const csrf = installerFormContainerElem.dataset.csrf;
-  ReactDOM.render(
-    <I18nextProvider i18n={i18n}>
-      <InstallerForm userName={userName} name={name} email={email} csrf={csrf} />
-    </I18nextProvider>,
-    installerFormContainerElem,
-  );
-}
+
+const componentMappings = {};
+
+const appContainer = new AppContainer();
+appContainer.initApp();
 
 // render loginForm
 const loginFormElem = document.getElementById('login-form');
 if (loginFormElem) {
-  const appContainer = new AppContainer();
-  appContainer.initApp();
-
   const username = loginFormElem.dataset.username;
   const name = loginFormElem.dataset.name;
   const email = loginFormElem.dataset.email;
@@ -65,78 +55,90 @@ if (loginFormElem) {
     basic: loginFormElem.dataset.isBasicAuthEnabled === 'true',
   };
 
-  ReactDOM.render(
-    <I18nextProvider i18n={i18n}>
-      <Provider inject={[appContainer]}>
-        <LoginForm
-          username={username}
-          name={name}
-          email={email}
-          isRegistrationEnabled={isRegistrationEnabled}
-          isEmailAuthenticationEnabled={isEmailAuthenticationEnabled}
-          registrationMode={registrationMode}
-          registrationWhiteList={registrationWhiteList}
-          isPasswordResetEnabled={isPasswordResetEnabled}
-          isLocalStrategySetup={isLocalStrategySetup}
-          isLdapStrategySetup={isLdapStrategySetup}
-          objOfIsExternalAuthEnableds={objOfIsExternalAuthEnableds}
-        />
-      </Provider>
-    </I18nextProvider>,
-    loginFormElem,
-  );
+  Object.assign(componentMappings, {
+    [loginFormElem.id]: (
+      <LoginForm
+        username={username}
+        name={name}
+        email={email}
+        isRegistrationEnabled={isRegistrationEnabled}
+        isEmailAuthenticationEnabled={isEmailAuthenticationEnabled}
+        registrationMode={registrationMode}
+        registrationWhiteList={registrationWhiteList}
+        isPasswordResetEnabled={isPasswordResetEnabled}
+        isLocalStrategySetup={isLocalStrategySetup}
+        isLdapStrategySetup={isLdapStrategySetup}
+        objOfIsExternalAuthEnableds={objOfIsExternalAuthEnableds}
+      />
+    ),
+  });
 }
 
-const appContainer = new AppContainer();
-appContainer.initApp();
-
-
 // render PasswordResetRequestForm
 const passwordResetRequestFormElem = document.getElementById('password-reset-request-form');
 if (passwordResetRequestFormElem) {
-
-  ReactDOM.render(
-    <I18nextProvider i18n={i18n}>
-      <Provider inject={[appContainer]}>
-        <PasswordResetRequestForm />
-      </Provider>
-    </I18nextProvider>,
-    passwordResetRequestFormElem,
-  );
+  Object.assign(componentMappings, {
+    [passwordResetRequestFormElem.id]: <PasswordResetRequestForm />,
+  });
 }
 
 // render PasswordResetExecutionForm
 const passwordResetExecutionFormElem = document.getElementById('password-reset-execution-form');
 if (passwordResetExecutionFormElem) {
-
-  ReactDOM.render(
-    <I18nextProvider i18n={i18n}>
-      <Provider inject={[appContainer]}>
-        <PasswordResetExecutionForm />
-      </Provider>
-    </I18nextProvider>,
-    passwordResetExecutionFormElem,
-  );
+  Object.assign(componentMappings, {
+    [passwordResetExecutionFormElem.id]: <PasswordResetExecutionForm />,
+  });
 }
 
 // render UserActivationForm
 const UserActivationForm = document.getElementById('user-activation-form');
 if (UserActivationForm) {
-
   const messageErrors = UserActivationForm.dataset.messageErrors;
   const inputs = UserActivationForm.dataset.inputs;
   const email = UserActivationForm.dataset.email;
   const token = UserActivationForm.dataset.token;
 
-  ReactDOM.render(
-    <I18nextProvider i18n={i18n}>
+  Object.assign(componentMappings, {
+    [UserActivationForm.id]: (
       <CompleteUserRegistrationForm
         messageErrors={messageErrors}
         inputs={inputs}
         email={email}
         token={token}
       />
-    </I18nextProvider>,
-    UserActivationForm,
+    ),
+  });
+}
+
+const renderMainComponents = () => {
+  Object.keys(componentMappings).forEach((key) => {
+    const elem = document.getElementById(key);
+    if (elem) {
+      ReactDOM.render(
+        <I18nextProvider i18n={i18n}>
+          <SWRConfig value={swrGlobalConfiguration}>
+            <Provider inject={[appContainer]}>
+              {componentMappings[key]}
+            </Provider>
+          </SWRConfig>
+        </I18nextProvider>,
+        elem,
+      );
+    }
+  });
+};
+
+// extract context before rendering main components
+const elem = document.getElementById('growi-context-extractor');
+if (elem != null) {
+  ReactDOM.render(
+    <SWRConfig value={swrGlobalConfiguration}>
+      <ContextExtractor></ContextExtractor>
+    </SWRConfig>,
+    elem,
+    renderMainComponents,
   );
 }
+else {
+  renderMainComponents();
+}

+ 4 - 32
packages/app/src/client/services/AppContainer.js

@@ -1,6 +1,5 @@
 import { Container } from 'unstated';
 
-import InterceptorManager from '~/services/interceptor-manager';
 
 import GrowiRenderer from '../util/GrowiRenderer';
 import { i18nFactory } from '../util/i18n';
@@ -14,19 +13,15 @@ export default class AppContainer extends Container {
   constructor() {
     super();
 
-    // get csrf token from body element
-    // DO NOT REMOVE: uploading attachment data requires appContainer.csrfToken
-    const body = document.querySelector('body');
-    this.csrfToken = body.dataset.csrftoken;
-
     this.config = JSON.parse(document.getElementById('growi-context-hydrate').textContent || '{}');
 
+    // init i18n
     const currentUserElem = document.getElementById('growi-current-user');
+    let userLocaleId;
     if (currentUserElem != null) {
-      this.currentUser = JSON.parse(currentUserElem.textContent);
+      const currentUser = JSON.parse(currentUserElem.textContent);
+      userLocaleId = currentUser?.lang;
     }
-
-    const userLocaleId = this.currentUser?.lang;
     this.i18n = i18nFactory(userLocaleId);
 
     this.containerInstances = {};
@@ -52,8 +47,6 @@ export default class AppContainer extends Container {
 
     this.originRenderer = new GrowiRenderer(this);
 
-    this.interceptorManager = new InterceptorManager();
-
     const isPluginEnabled = body.dataset.pluginEnabled === 'true';
     if (isPluginEnabled) {
       this.initPlugins();
@@ -79,27 +72,6 @@ export default class AppContainer extends Container {
     window.crowiPlugin = window.growiPlugin;
   }
 
-  get currentUserId() {
-    if (this.currentUser == null) {
-      return null;
-    }
-    return this.currentUser._id;
-  }
-
-  get currentUsername() {
-    if (this.currentUser == null) {
-      return null;
-    }
-    return this.currentUser.username;
-  }
-
-  /**
-   * @return {Object} window.Crowi (js/legacy/crowi.js)
-   */
-  getCrowiForJquery() {
-    return window.Crowi;
-  }
-
   getConfig() {
     return this.config;
   }

+ 2 - 3
packages/app/src/client/services/CommentContainer.js

@@ -2,7 +2,7 @@ import { Container } from 'unstated';
 
 import loggerFactory from '~/utils/logger';
 
-import { apiGet, apiPost } from '../util/apiv1-client';
+import { apiGet, apiPost, apiPostForm } from '../util/apiv1-client';
 import { apiv3Put } from '../util/apiv3-client';
 
 const logger = loggerFactory('growi:services:CommentContainer');
@@ -157,12 +157,11 @@ export default class CommentContainer extends Container {
 
     const endpoint = '/attachments.add';
     const formData = new FormData();
-    formData.append('_csrf', this.appContainer.csrfToken);
     formData.append('file', file);
     formData.append('path', pagePath);
     formData.append('page_id', pageId);
 
-    return apiPost(endpoint, formData);
+    return apiPostForm(endpoint, formData);
   }
 
 }

+ 10 - 2
packages/app/src/client/services/ContextExtractor.tsx

@@ -18,7 +18,7 @@ import {
   useShareLinkId, useShareLinksNumber, useTemplateTagData, useCurrentUpdatedAt, useCreator, useRevisionAuthor, useCurrentUser, useTargetAndAncestors,
   useNotFoundTargetPathOrId, useIsSearchPage, useIsForbidden, useIsIdenticalPath, useHasParent,
   useIsAclEnabled, useIsSearchServiceConfigured, useIsSearchServiceReachable, useIsEnabledAttachTitleHeader, useIsNotFoundPermalink,
-  useDefaultIndentSize, useIsIndentSizeForced,
+  useDefaultIndentSize, useIsIndentSizeForced, useCsrfToken,
 } from '../../stores/context';
 
 const { isTrashPage: _isTrashPage } = pagePathUtils;
@@ -32,6 +32,11 @@ const ContextExtractorOnce: FC = () => {
   const notFoundContent = document.getElementById('growi-not-found-context');
   const forbiddenContent = document.getElementById('forbidden-page');
 
+  // get csrf token from body element
+  // DO NOT REMOVE: uploading attachment data requires appContainer.csrfToken
+  const body = document.querySelector('body');
+  const csrfToken = body?.dataset.csrftoken;
+
   /*
    * App Context from DOM
    */
@@ -94,6 +99,8 @@ const ContextExtractorOnce: FC = () => {
   /*
    * use static swr
    */
+  useCsrfToken(csrfToken);
+
   // App
   useCurrentUser(currentUser);
 
@@ -166,7 +173,8 @@ const ContextExtractorOnce: FC = () => {
 
   // Global Socket
   useSetupGlobalSocket();
-  useSetupGlobalAdminSocket();
+  const shouldInitAdminSock = !!currentUser?.isAdmin;
+  useSetupGlobalAdminSocket(shouldInitAdminSock);
 
   return null;
 };

+ 5 - 26
packages/app/src/client/services/PageContainer.js

@@ -99,7 +99,7 @@ export default class PageContainer extends Container {
       logger.warn('The data of \'data-page-revision-author\' is invalid', e);
     }
 
-    const { interceptorManager } = this.appContainer;
+    const { interceptorManager } = window;
     interceptorManager.addInterceptor(new DetachCodeBlockInterceptor(), 10); // process as soon as possible
     interceptorManager.addInterceptor(new DrawioInterceptor(), 20);
     interceptorManager.addInterceptor(new RestoreCodeBlockInterceptor(), 900); // process as late as possible
@@ -136,29 +136,6 @@ export default class PageContainer extends Container {
     return 'PageContainer';
   }
 
-  /**
-   * whether to Empty Trash Page
-   * not displayed when guest user and not on trash page
-   */
-  get isAbleToShowEmptyTrashButton() {
-    const { currentUser } = this.appContainer;
-    const { path, hasChildren } = this.state;
-
-    return (currentUser != null && currentUser.admin && path === '/trash' && hasChildren);
-  }
-
-  /**
-   * whether to display trash management buttons
-   * ex.) undo, delete completly
-   * not displayed when guest user
-   */
-  get isAbleToShowTrashPageManagementButtons() {
-    const { currentUser } = this.appContainer;
-    const { isDeleted } = this.state;
-
-    return (isDeleted && currentUser != null);
-  }
-
   /**
    * initialize state for markdown data
    */
@@ -219,7 +196,8 @@ export default class PageContainer extends Container {
 
     // Update PageEditor component
     if (editorMode !== EditorMode.Editor) {
-      window.globalEmitter.emit('updateEditorValue', newState.markdown);
+      // eslint-disable-next-line no-undef
+      globalEmitter.emit('updateEditorValue', newState.markdown);
     }
 
     // PageEditorByHackmd component
@@ -459,7 +437,8 @@ export default class PageContainer extends Container {
 
     // Update PageEditor component
     if (editorMode !== EditorMode.Editor) {
-      window.globalEmitter.emit('updateEditorValue', markdown);
+      // eslint-disable-next-line no-undef
+      globalEmitter.emit('updateEditorValue', markdown);
     }
 
     editorContainer.setState({ tags: res.tags });

+ 9 - 1
packages/app/src/client/services/page-operation.ts

@@ -3,7 +3,8 @@ import urljoin from 'url-join';
 import { SubscriptionStatusType } from '~/interfaces/subscription';
 
 import { toastError } from '../util/apiNotification';
-import { apiv3Put } from '../util/apiv3-client';
+import { apiv3Post, apiv3Put } from '../util/apiv3-client';
+
 
 export const toggleSubscribe = async(pageId: string, currentStatus: SubscriptionStatusType | undefined): Promise<void> => {
   try {
@@ -60,3 +61,10 @@ export const exportAsMarkdown = (pageId: string, revisionId: string, format: str
   url.searchParams.append('revisionId', revisionId);
   window.location.href = url.href;
 };
+
+/**
+ * send request to fix broken paths caused by unexpected events such as server shutdown while renaming page paths
+ */
+export const resumeRenameOperation = async(pageId: string): Promise<void> => {
+  await apiv3Post('/pages/resume-rename', { pageId });
+};

+ 7 - 0
packages/app/src/client/util/apiv1-client.ts

@@ -58,6 +58,13 @@ export async function apiPost(path: string, params: any & ParamWithCsrfKey = {})
   return apiRequest('post', path, params);
 }
 
+export async function apiPostForm(path: string, formData: FormData): Promise<unknown> {
+  if (formData.get('_csrf') == null && csrfToken != null) {
+    formData.append('_csrf', csrfToken);
+  }
+  return apiPost(path, formData);
+}
+
 // eslint-disable-next-line @typescript-eslint/no-explicit-any
 export async function apiDelete(path: string, params: any & ParamWithCsrfKey = {}): Promise<unknown> {
   if (params._csrf == null) {

+ 12 - 3
packages/app/src/client/util/apiv3-client.ts

@@ -1,11 +1,12 @@
+// eslint-disable-next-line no-restricted-imports
+import { AxiosResponse } from 'axios';
 import * as urljoin from 'url-join';
 
 // eslint-disable-next-line no-restricted-imports
-import { AxiosResponse } from 'axios';
 
-import loggerFactory from '~/utils/logger';
-import axios from '~/utils/axios';
 import { toArrayIfNot } from '~/utils/array-utils';
+import axios from '~/utils/axios';
+import loggerFactory from '~/utils/logger';
 
 const apiv3Root = '/_api/v3';
 
@@ -57,6 +58,14 @@ export async function apiv3Post<T = any>(path: string, params: any & ParamWithCs
   return apiv3Request('post', path, params);
 }
 
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+export async function apiv3PostForm<T = any>(path: string, formData: FormData): Promise<AxiosResponse<T>> {
+  if (formData.get('_csrf') == null && csrfToken != null) {
+    formData.append('_csrf', csrfToken);
+  }
+  return apiv3Post<T>(path, formData);
+}
+
 // eslint-disable-next-line @typescript-eslint/no-explicit-any
 export async function apiv3Put<T = any>(path: string, params: any & ParamWithCsrfKey = {}): Promise<AxiosResponse<T>> {
   if (params._csrf == null) {

+ 3 - 2
packages/app/src/client/util/i18n.js

@@ -1,7 +1,8 @@
 import i18n from 'i18next';
 import LanguageDetector from 'i18next-browser-languagedetector';
 import { initReactI18next } from 'react-i18next';
-import locales from '^/resource/locales';
+
+import locales from '^/public/static/locales';
 
 const aliasesMapping = {};
 Object.values(locales).forEach((locale) => {
@@ -13,7 +14,7 @@ Object.values(locales).forEach((locale) => {
   });
 });
 
-// extract metadata list from 'resource/locales/${locale}/meta.json'
+// extract metadata list from 'public/static/locales/${locale}/meta.json'
 export const localeMetadatas = Object.values(locales).map(locale => locale.meta);
 
 export const i18nFactory = (userLocaleId) => {

+ 2 - 1
packages/app/src/client/util/markdown-it/toc-and-anchor.js

@@ -18,7 +18,8 @@ export default class TocAndAnchorConfigurer {
     // set toc render function
     md.set({
       tocCallback: (tocMarkdown, tocArray, tocHtml) => {
-        window.globalEmitter.emit('renderTocHtml', tocHtml);
+        // eslint-disable-next-line no-undef
+        globalEmitter.emit('renderTocHtml', tocHtml);
       },
     });
   }

+ 2 - 2
packages/app/src/client/util/reveal/plugins/growi-renderer.js

@@ -28,7 +28,7 @@
       const section = sections[i];
       const markdown = marked.getMarkdownFromSlide(section);
       const context = { markdown };
-      const interceptorManager = appContainer.interceptorManager;
+      const { interceptorManager } = window.parent;
       let dataSeparator = section.getAttribute('data-separator') || DEFAULT_SLIDE_SEPARATOR;
       // replace string '\n' to LF code.
       dataSeparator = dataSeparator.replace(/\\n/g, '\n');
@@ -51,7 +51,7 @@
   function convertSlides() {
     const sections = document.querySelectorAll('[data-markdown]');
     let markdown;
-    const interceptorManager = appContainer.interceptorManager;
+    const { interceptorManager } = window.parent;
 
     for (let i = 0, len = sections.length; i < len; i++) {
       const section = sections[i];

+ 1 - 4
packages/app/src/components/Admin/App/AwsSetting.jsx

@@ -4,7 +4,6 @@ import PropTypes from 'prop-types';
 import { useTranslation } from 'react-i18next';
 
 import AdminAppContainer from '~/client/services/AdminAppContainer';
-import AppContainer from '~/client/services/AppContainer';
 
 import { withUnstatedContainers } from '../../UnstatedUtils';
 
@@ -153,11 +152,9 @@ function AwsSetting(props) {
 /**
  * Wrapper component for using unstated
  */
-const AwsSettingWrapper = withUnstatedContainers(AwsSetting, [AppContainer, AdminAppContainer]);
+const AwsSettingWrapper = withUnstatedContainers(AwsSetting, [AdminAppContainer]);
 
 AwsSetting.propTypes = {
-  t: PropTypes.func.isRequired, // i18next
-  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
   adminAppContainer: PropTypes.instanceOf(AdminAppContainer).isRequired,
 };
 

+ 3 - 13
packages/app/src/components/Admin/ImportData/GrowiArchive/UploadForm.jsx

@@ -3,11 +3,8 @@ import React from 'react';
 import PropTypes from 'prop-types';
 import { withTranslation } from 'react-i18next';
 
-import AppContainer from '~/client/services/AppContainer';
 import { toastError } from '~/client/util/apiNotification';
-import { apiv3Post } from '~/client/util/apiv3-client';
-
-import { withUnstatedContainers } from '../../../UnstatedUtils';
+import { apiv3PostForm } from '~/client/util/apiv3-client';
 
 class UploadForm extends React.Component {
 
@@ -31,11 +28,10 @@ class UploadForm extends React.Component {
     e.preventDefault();
 
     const formData = new FormData();
-    formData.append('_csrf', this.props.appContainer.csrfToken);
     formData.append('file', this.inputRef.current.files[0]);
 
     try {
-      const { data } = await apiv3Post('/import/upload', formData);
+      const { data } = await apiv3PostForm('/import/upload', formData);
       // TODO: toastSuccess, toastError
       this.props.onUpload(data);
     }
@@ -96,15 +92,9 @@ class UploadForm extends React.Component {
 
 UploadForm.propTypes = {
   t: PropTypes.func.isRequired, // i18next
-  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
   onUpload: PropTypes.func.isRequired,
   isTheSameVersion: PropTypes.bool,
   onVersionMismatch: PropTypes.func,
 };
 
-/**
- * Wrapper component for using unstated
- */
-const UploadFormWrapper = withUnstatedContainers(UploadForm, [AppContainer]);
-
-export default withTranslation()(UploadFormWrapper);
+export default withTranslation()(UploadForm);

+ 0 - 79
packages/app/src/components/Admin/Users/RemoveAdminButton.jsx

@@ -1,79 +0,0 @@
-import React, { Fragment } from 'react';
-import PropTypes from 'prop-types';
-import { withTranslation } from 'react-i18next';
-
-import { withUnstatedContainers } from '../../UnstatedUtils';
-import AppContainer from '~/client/services/AppContainer';
-import { toastSuccess, toastError } from '~/client/util/apiNotification';
-import AdminUsersContainer from '~/client/services/AdminUsersContainer';
-
-class RemoveAdminButton extends React.Component {
-
-  constructor(props) {
-    super(props);
-
-    this.onClickRemoveAdminBtn = this.onClickRemoveAdminBtn.bind(this);
-  }
-
-  async onClickRemoveAdminBtn() {
-    const { t } = this.props;
-
-    try {
-      const username = await this.props.adminUsersContainer.removeUserAdmin(this.props.user._id);
-      toastSuccess(t('toaster.remove_user_admin', { username }));
-    }
-    catch (err) {
-      toastError(err);
-    }
-  }
-
-
-  renderRemoveAdminBtn() {
-    const { t } = this.props;
-
-    return (
-      <button className="dropdown-item" type="button" onClick={() => { this.onClickRemoveAdminBtn() }}>
-        <i className="icon-fw icon-user-unfollow"></i> {t('admin:user_management.user_table.remove_admin_access')}
-      </button>
-    );
-  }
-
-  renderRemoveAdminAlert() {
-    const { t } = this.props;
-
-    return (
-      <div className="px-4">
-        <i className="icon-fw icon-user-unfollow mb-2"></i>{t('admin:user_management.user_table.remove_admin_access')}
-        <p className="alert alert-danger">{t('admin:user_management.user_table.cannot_remove')}</p>
-      </div>
-    );
-  }
-
-  render() {
-    const { user } = this.props;
-    const { currentUsername } = this.props.appContainer;
-
-    return (
-      <Fragment>
-        {user.username !== currentUsername ? this.renderRemoveAdminBtn()
-          : this.renderRemoveAdminAlert()}
-      </Fragment>
-    );
-  }
-
-}
-
-/**
-* Wrapper component for using unstated
-*/
-const RemoveAdminButtonWrapper = withUnstatedContainers(RemoveAdminButton, [AppContainer, AdminUsersContainer]);
-
-RemoveAdminButton.propTypes = {
-  t: PropTypes.func.isRequired, // i18next
-  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
-  adminUsersContainer: PropTypes.instanceOf(AdminUsersContainer).isRequired,
-
-  user: PropTypes.object.isRequired,
-};
-
-export default withTranslation()(RemoveAdminButtonWrapper);

+ 62 - 0
packages/app/src/components/Admin/Users/RemoveAdminMenuItem.tsx

@@ -0,0 +1,62 @@
+import React, { useCallback, useMemo } from 'react';
+
+import { useTranslation } from 'react-i18next';
+
+import AdminUsersContainer from '~/client/services/AdminUsersContainer';
+import { toastSuccess, toastError } from '~/client/util/apiNotification';
+import { IUserHasId } from '~/interfaces/user';
+import { useCurrentUser } from '~/stores/context';
+
+import { withUnstatedContainers } from '../../UnstatedUtils';
+
+
+const RemoveAdminAlert = React.memo((): JSX.Element => {
+  const { t } = useTranslation();
+
+  return (
+    <div className="px-4">
+      <i className="icon-fw icon-user-unfollow mb-2"></i>{t('admin:user_management.user_table.remove_admin_access')}
+      <p className="alert alert-danger">{t('admin:user_management.user_table.cannot_remove')}</p>
+    </div>
+  );
+});
+
+
+type Props = {
+  adminUsersContainer: AdminUsersContainer,
+  user: IUserHasId,
+}
+
+const RemoveAdminMenuItem = (props: Props): JSX.Element => {
+  const { t } = useTranslation();
+
+  const { adminUsersContainer, user } = props;
+
+  const { data: currentUser } = useCurrentUser();
+
+  const clickRemoveAdminBtnHandler = useCallback(async() => {
+    try {
+      const username = await adminUsersContainer.removeUserAdmin(user._id);
+      toastSuccess(t('toaster.remove_user_admin', { username }));
+    }
+    catch (err) {
+      toastError(err);
+    }
+  }, [adminUsersContainer, t, user._id]);
+
+
+  return user.username !== currentUser?.username
+    ? (
+      <button className="dropdown-item" type="button" onClick={clickRemoveAdminBtnHandler}>
+        <i className="icon-fw icon-user-unfollow"></i> {t('admin:user_management.user_table.remove_admin_access')}
+      </button>
+    )
+    : <RemoveAdminAlert />;
+};
+
+/**
+* Wrapper component for using unstated
+*/
+const RemoveAdminMenuItemWrapper = withUnstatedContainers(RemoveAdminMenuItem, [AdminUsersContainer]);
+
+export default RemoveAdminMenuItemWrapper;

+ 60 - 0
packages/app/src/components/Admin/Users/StatusSuspendMenuItem.tsx

@@ -0,0 +1,60 @@
+import React, { useCallback } from 'react';
+
+import { useTranslation } from 'react-i18next';
+
+import AdminUsersContainer from '~/client/services/AdminUsersContainer';
+import { toastSuccess, toastError } from '~/client/util/apiNotification';
+import { withUnstatedContainers } from '~/components/UnstatedUtils';
+import { IUserHasId } from '~/interfaces/user';
+import { useCurrentUser } from '~/stores/context';
+
+
+const SuspendAlert = React.memo((): JSX.Element => {
+  const { t } = useTranslation();
+
+  return (
+    <div className="px-4">
+      <i className="icon-fw icon-ban mb-2"></i>{t('admin:user_management.user_table.deactivate_account')}
+      <p className="alert alert-danger">{t('admin:user_management.user_table.your_own')}</p>
+    </div>
+  );
+});
+
+
+type Props = {
+  adminUsersContainer: AdminUsersContainer,
+  user: IUserHasId,
+}
+
+const StatusSuspendMenuItem = (props: Props): JSX.Element => {
+  const { t } = useTranslation();
+
+  const { adminUsersContainer, user } = props;
+
+  const { data: currentUser } = useCurrentUser();
+
+  const clickDeactiveBtnHandler = useCallback(async() => {
+    try {
+      const username = await adminUsersContainer.deactivateUser(user._id);
+      toastSuccess(t('toaster.deactivate_user_success', { username }));
+    }
+    catch (err) {
+      toastError(err);
+    }
+  }, [adminUsersContainer, t, user._id]);
+
+  return user.username !== currentUser?.username
+    ? (
+      <button className="dropdown-item" type="button" onClick={clickDeactiveBtnHandler}>
+        <i className="icon-fw icon-ban"></i> {t('admin:user_management.user_table.deactivate_account')}
+      </button>
+    )
+    : <SuspendAlert />;
+};
+
+/**
+ * Wrapper component for using unstated
+ */
+const StatusSuspendMenuItemWrapper = withUnstatedContainers(StatusSuspendMenuItem, [AdminUsersContainer]);
+
+export default StatusSuspendMenuItemWrapper;

+ 0 - 78
packages/app/src/components/Admin/Users/StatusSuspendedButton.jsx

@@ -1,78 +0,0 @@
-import React, { Fragment } from 'react';
-import PropTypes from 'prop-types';
-import { withTranslation } from 'react-i18next';
-
-import { withUnstatedContainers } from '../../UnstatedUtils';
-import AppContainer from '~/client/services/AppContainer';
-import { toastSuccess, toastError } from '~/client/util/apiNotification';
-import AdminUsersContainer from '~/client/services/AdminUsersContainer';
-
-class StatusSuspendedButton extends React.Component {
-
-  constructor(props) {
-    super(props);
-
-    this.onClickDeactiveBtn = this.onClickDeactiveBtn.bind(this);
-  }
-
-  async onClickDeactiveBtn() {
-    const { t } = this.props;
-
-    try {
-      const username = await this.props.adminUsersContainer.deactivateUser(this.props.user._id);
-      toastSuccess(t('toaster.deactivate_user_success', { username }));
-    }
-    catch (err) {
-      toastError(err);
-    }
-  }
-
-  renderSuspendedBtn() {
-    const { t } = this.props;
-
-    return (
-      <button className="dropdown-item" type="button" onClick={() => { this.onClickDeactiveBtn() }}>
-        <i className="icon-fw icon-ban"></i> {t('admin:user_management.user_table.deactivate_account')}
-      </button>
-    );
-  }
-
-  renderSuspendedAlert() {
-    const { t } = this.props;
-
-    return (
-      <div className="px-4">
-        <i className="icon-fw icon-ban mb-2"></i>{t('admin:user_management.user_table.deactivate_account')}
-        <p className="alert alert-danger">{t('admin:user_management.user_table.your_own')}</p>
-      </div>
-    );
-  }
-
-  render() {
-    const { user } = this.props;
-    const { currentUsername } = this.props.appContainer;
-
-    return (
-      <Fragment>
-        {user.username !== currentUsername ? this.renderSuspendedBtn()
-          : this.renderSuspendedAlert()}
-      </Fragment>
-    );
-  }
-
-}
-
-/**
- * Wrapper component for using unstated
- */
-const StatusSuspendedFormWrapper = withUnstatedContainers(StatusSuspendedButton, [AppContainer, AdminUsersContainer]);
-
-StatusSuspendedButton.propTypes = {
-  t: PropTypes.func.isRequired, // i18next
-  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
-  adminUsersContainer: PropTypes.instanceOf(AdminUsersContainer).isRequired,
-
-  user: PropTypes.object.isRequired,
-};
-
-export default withTranslation()(StatusSuspendedFormWrapper);

+ 12 - 9
packages/app/src/components/Admin/Users/UserMenu.jsx

@@ -1,20 +1,23 @@
 import React, { Fragment } from 'react';
+
 import PropTypes from 'prop-types';
 import { withTranslation } from 'react-i18next';
 import {
   UncontrolledDropdown, DropdownToggle, DropdownMenu,
 } from 'reactstrap';
 
-import StatusActivateButton from './StatusActivateButton';
-import StatusSuspendedButton from './StatusSuspendedButton';
-import UserRemoveButton from './UserRemoveButton';
-import RemoveAdminButton from './RemoveAdminButton';
+import AdminUsersContainer from '~/client/services/AdminUsersContainer';
+import AppContainer from '~/client/services/AppContainer';
+
+import { withUnstatedContainers } from '../../UnstatedUtils';
+
 import GiveAdminButton from './GiveAdminButton';
+import RemoveAdminMenuItem from './RemoveAdminMenuItem';
 import SendInvitationEmailButton from './SendInvitationEmailButton';
+import StatusActivateButton from './StatusActivateButton';
+import StatusSuspendedMenuItem from './StatusSuspendMenuItem';
+import UserRemoveButton from './UserRemoveButton';
 
-import { withUnstatedContainers } from '../../UnstatedUtils';
-import AppContainer from '~/client/services/AppContainer';
-import AdminUsersContainer from '~/client/services/AdminUsersContainer';
 
 class UserMenu extends React.Component {
 
@@ -63,7 +66,7 @@ class UserMenu extends React.Component {
         <li className="dropdown-header">{t('status')}</li>
         <li>
           {(user.status === 1 || user.status === 3) && <StatusActivateButton user={user} />}
-          {user.status === 2 && <StatusSuspendedButton user={user} />}
+          {user.status === 2 && <StatusSuspendedMenuItem user={user} />}
           {user.status === 5 && (
             <SendInvitationEmailButton
               user={user}
@@ -85,7 +88,7 @@ class UserMenu extends React.Component {
         <li className="dropdown-divider pl-0"></li>
         <li className="dropdown-header">{t('admin:user_management.user_table.administrator_menu')}</li>
         <li>
-          {user.admin === true && <RemoveAdminButton user={user} />}
+          {user.admin === true && <RemoveAdminMenuItem user={user} />}
           {user.admin === false && <GiveAdminButton user={user} />}
         </li>
       </Fragment>

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

@@ -8,6 +8,7 @@ import {
 import {
   IPageInfoAll, isIPageInfoForOperation,
 } from '~/interfaces/page';
+import { IPageOperationProcessData } from '~/interfaces/page-operation';
 import { useSWRxPageInfo } from '~/stores/page';
 import loggerFactory from '~/utils/logger';
 
@@ -20,6 +21,7 @@ export const MenuItemType = {
   DUPLICATE: 'duplicate',
   DELETE: 'delete',
   REVERT: 'revert',
+  PATH_RECOVERY: 'pathRecovery',
 } as const;
 export type MenuItemType = typeof MenuItemType[keyof typeof MenuItemType];
 
@@ -37,6 +39,7 @@ type CommonProps = {
   onClickDuplicateMenuItem?: (pageId: string) => Promise<void> | void,
   onClickDeleteMenuItem?: (pageId: string, pageInfo: IPageInfoAll | undefined) => Promise<void> | void,
   onClickRevertMenuItem?: (pageId: string) => Promise<void> | void,
+  onClickPathRecoveryMenuItem?: (pageId: string) => Promise<void> | void,
 
   additionalMenuItemRenderer?: React.FunctionComponent<AdditionalMenuItemsRendererProps>,
   isInstantRename?: boolean,
@@ -47,6 +50,7 @@ type CommonProps = {
 type DropdownMenuProps = CommonProps & {
   pageId: string,
   isLoading?: boolean,
+  operationProcessData?: IPageOperationProcessData,
 }
 
 const PageItemControlDropdownMenu = React.memo((props: DropdownMenuProps): JSX.Element => {
@@ -54,8 +58,8 @@ const PageItemControlDropdownMenu = React.memo((props: DropdownMenuProps): JSX.E
 
   const {
     pageId, isLoading,
-    pageInfo, isEnableActions, forceHideMenuItems,
-    onClickBookmarkMenuItem, onClickRenameMenuItem, onClickDuplicateMenuItem, onClickDeleteMenuItem, onClickRevertMenuItem,
+    pageInfo, isEnableActions, forceHideMenuItems, operationProcessData,
+    onClickBookmarkMenuItem, onClickRenameMenuItem, onClickDuplicateMenuItem, onClickDeleteMenuItem, onClickRevertMenuItem, onClickPathRecoveryMenuItem,
     additionalMenuItemRenderer: AdditionalMenuItems, isInstantRename, alignRight,
   } = props;
 
@@ -108,6 +112,14 @@ const PageItemControlDropdownMenu = React.memo((props: DropdownMenuProps): JSX.E
     await onClickDeleteMenuItem(pageId, pageInfo);
   }, [onClickDeleteMenuItem, pageId, pageInfo]);
 
+  // eslint-disable-next-line react-hooks/rules-of-hooks
+  const pathRecoveryItemClickedHandler = useCallback(async() => {
+    if (onClickPathRecoveryMenuItem == null) {
+      return;
+    }
+    await onClickPathRecoveryMenuItem(pageId);
+  }, [onClickPathRecoveryMenuItem, pageId]);
+
   let contents = <></>;
 
   if (isLoading) {
@@ -122,6 +134,10 @@ const PageItemControlDropdownMenu = React.memo((props: DropdownMenuProps): JSX.E
     const showDeviderBeforeAdditionalMenuItems = (forceHideMenuItems?.length ?? 0) < 3;
     const showDeviderBeforeDelete = AdditionalMenuItems != null || showDeviderBeforeAdditionalMenuItems;
 
+    // PathRecovery
+    // Todo: It is wanted to find a better way to pass operationProcessData to PageItemControl
+    const shouldShowPathRecoveryButton = operationProcessData?.Rename != null ? operationProcessData?.Rename.isProcessable : false;
+
     contents = (
       <>
         { !isEnableActions && (
@@ -185,6 +201,17 @@ const PageItemControlDropdownMenu = React.memo((props: DropdownMenuProps): JSX.E
           </>
         ) }
 
+        {/* PathRecovery */}
+        { !forceHideMenuItems?.includes(MenuItemType.PATH_RECOVERY) && isEnableActions && shouldShowPathRecoveryButton && (
+          <DropdownItem
+            onClick={pathRecoveryItemClickedHandler}
+            className="grw-page-control-dropdown-item"
+          >
+            <i className="icon-fw icon-wrench grw-page-control-dropdown-icon"></i>
+            {t('PathRecovery')}
+          </DropdownItem>
+        ) }
+
         {/* divider */}
         {/* Delete */}
         { !forceHideMenuItems?.includes(MenuItemType.DELETE) && isEnableActions && pageInfo.isMovable && (
@@ -217,6 +244,7 @@ type PageItemControlSubstanceProps = CommonProps & {
   pageId: string,
   fetchOnInit?: boolean,
   children?: React.ReactNode,
+  operationProcessData?: IPageOperationProcessData,
 }
 
 export const PageItemControlSubstance = (props: PageItemControlSubstanceProps): JSX.Element => {
@@ -224,7 +252,7 @@ export const PageItemControlSubstance = (props: PageItemControlSubstanceProps):
   const {
     pageId, pageInfo: presetPageInfo, fetchOnInit,
     children,
-    onClickBookmarkMenuItem, onClickRenameMenuItem, onClickDuplicateMenuItem, onClickDeleteMenuItem,
+    onClickBookmarkMenuItem, onClickRenameMenuItem, onClickDuplicateMenuItem, onClickDeleteMenuItem, onClickPathRecoveryMenuItem,
   } = props;
 
   const [isOpen, setIsOpen] = useState(false);
@@ -276,6 +304,13 @@ export const PageItemControlSubstance = (props: PageItemControlSubstanceProps):
     await onClickDeleteMenuItem(pageId, fetchedPageInfo ?? presetPageInfo);
   }, [onClickDeleteMenuItem, pageId, fetchedPageInfo, presetPageInfo]);
 
+  const pathRecoveryMenuItemClickHandler = useCallback(async() => {
+    if (onClickPathRecoveryMenuItem == null) {
+      return;
+    }
+    await onClickPathRecoveryMenuItem(pageId);
+  }, [onClickPathRecoveryMenuItem, pageId]);
+
   return (
     <Dropdown isOpen={isOpen} toggle={() => setIsOpen(!isOpen)} data-testid="open-page-item-control-btn">
       { children ?? (
@@ -292,6 +327,7 @@ export const PageItemControlSubstance = (props: PageItemControlSubstanceProps):
         onClickRenameMenuItem={renameMenuItemClickHandler}
         onClickDuplicateMenuItem={duplicateMenuItemClickHandler}
         onClickDeleteMenuItem={deleteMenuItemClickHandler}
+        onClickPathRecoveryMenuItem={pathRecoveryMenuItemClickHandler}
       />
     </Dropdown>
   );
@@ -302,6 +338,7 @@ export const PageItemControlSubstance = (props: PageItemControlSubstanceProps):
 type PageItemControlProps = CommonProps & {
   pageId?: string,
   children?: React.ReactNode,
+  operationProcessData?: IPageOperationProcessData,
 }
 
 export const PageItemControl = (props: PageItemControlProps): JSX.Element => {

+ 6 - 9
packages/app/src/components/Drawio.tsx

@@ -10,11 +10,9 @@ import { debounce } from 'throttle-debounce';
 import NotAvailableForGuest from './NotAvailableForGuest';
 
 
-declare let window: {
-  globalEmitter: EventEmitter,
-  GraphViewer: {
-    createViewerForElement: (Element) => void,
-  };
+declare const globalEmitter: EventEmitter;
+declare const GraphViewer: {
+  createViewerForElement: (Element) => void,
 };
 
 type Props = {
@@ -35,7 +33,7 @@ const Drawio = (props: Props): JSX.Element => {
 
   const editButtonClickHandler = useCallback(() => {
     const { beginLineNumber, endLineNumber } = rangeLineNumberOfMarkdown;
-    window.globalEmitter.emit('launchDrawioModal', beginLineNumber, endLineNumber);
+    globalEmitter.emit('launchDrawioModal', beginLineNumber, endLineNumber);
   }, [rangeLineNumberOfMarkdown]);
 
   const renderDrawio = useCallback(() => {
@@ -50,21 +48,20 @@ const Drawio = (props: Props): JSX.Element => {
 
       if (div != null) {
         div.innerHTML = '';
-        window.GraphViewer.createViewerForElement(div);
+        GraphViewer.createViewerForElement(div);
       }
     }
   }, []);
 
   const renderDrawioWithDebounce = useMemo(() => debounce(200, renderDrawio), [renderDrawio]);
 
-  const { GraphViewer } = window;
   useEffect(() => {
     if (GraphViewer == null) {
       return;
     }
 
     renderDrawioWithDebounce();
-  }, [GraphViewer, renderDrawioWithDebounce]);
+  }, [renderDrawioWithDebounce]);
 
   return (
     <div className="editable-with-drawio position-relative">

+ 6 - 3
packages/app/src/components/InstallerForm.jsx

@@ -5,6 +5,7 @@ import PropTypes from 'prop-types';
 import { useTranslation } from 'react-i18next';
 
 import { localeMetadatas } from '~/client/util/i18n';
+import { useCsrfToken } from '~/stores/context';
 
 class InstallerForm extends React.Component {
 
@@ -175,7 +176,7 @@ class InstallerForm extends React.Component {
               />
             </div>
 
-            <input type="hidden" name="_csrf" value={this.props.csrf} />
+            <input type="hidden" name="_csrf" value={this.props.csrfToken} />
 
             <div className="input-group mt-4 mb-3 d-flex justify-content-center">
               <button
@@ -211,12 +212,14 @@ InstallerForm.propTypes = {
   userName: PropTypes.string,
   name: PropTypes.string,
   email: PropTypes.string,
-  csrf: PropTypes.string,
+  csrfToken: PropTypes.string,
 };
 
 const InstallerFormWrapperFC = (props) => {
   const { t } = useTranslation();
-  return <InstallerForm t={t} {...props} />;
+  const { data: csrfToken } = useCsrfToken();
+
+  return <InstallerForm t={t} csrfToken={csrfToken} {...props} />;
 };
 
 export default InstallerFormWrapperFC;

+ 11 - 6
packages/app/src/components/LoginForm.jsx

@@ -5,6 +5,7 @@ import ReactCardFlip from 'react-card-flip';
 import { useTranslation } from 'react-i18next';
 
 import AppContainer from '~/client/services/AppContainer';
+import { useCsrfToken } from '~/stores/context';
 
 import { withUnstatedContainers } from './UnstatedUtils';
 
@@ -36,12 +37,12 @@ class LoginForm extends React.Component {
 
   handleLoginWithExternalAuth(e) {
     const auth = e.currentTarget.id;
-    const { csrf } = this.props.appContainer;
-    window.location.href = `/passport/${auth}?_csrf=${csrf}`;
+    const { csrfToken } = this.props;
+    window.location.href = `/passport/${auth}?_csrf=${csrfToken}`;
   }
 
   renderLocalOrLdapLoginForm() {
-    const { t, appContainer, isLdapStrategySetup } = this.props;
+    const { t, csrfToken, isLdapStrategySetup } = this.props;
 
     return (
       <form role="form" action="/login" method="post">
@@ -71,7 +72,7 @@ class LoginForm extends React.Component {
         </div>
 
         <div className="input-group my-4">
-          <input type="hidden" name="_csrf" value={appContainer.csrfToken} />
+          <input type="hidden" name="_csrf" value={csrfToken} />
           <button type="submit" id="login" className="btn btn-fill rounded-0 login mx-auto" data-testid="btnSubmitForLogin">
             <div className="eff"></div>
             <span className="btn-label">
@@ -149,6 +150,7 @@ class LoginForm extends React.Component {
     const {
       t,
       appContainer,
+      csrfToken,
       isEmailAuthenticationEnabled,
       username,
       name,
@@ -252,7 +254,7 @@ class LoginForm extends React.Component {
           )}
 
           <div className="input-group justify-content-center my-4">
-            <input type="hidden" name="_csrf" value={appContainer.csrfToken} />
+            <input type="hidden" name="_csrf" value={csrfToken} />
             <button type="submit" className="btn btn-fill rounded-0" id="register" disabled={(!isMailerSetup && isEmailAuthenticationEnabled)}>
               <div className="eff"></div>
               <span className="btn-label">
@@ -333,6 +335,7 @@ LoginForm.propTypes = {
   t: PropTypes.func.isRequired,
   appContainer: PropTypes.instanceOf(AppContainer).isRequired,
 
+  csrfToken: PropTypes.string,
   isRegistering: PropTypes.bool,
   username: PropTypes.string,
   name: PropTypes.string,
@@ -349,7 +352,9 @@ LoginForm.propTypes = {
 
 const LoginFormWrapperFC = (props) => {
   const { t } = useTranslation();
-  return <LoginForm t={t} {...props} />;
+  const { data: csrfToken } = useCsrfToken();
+
+  return <LoginForm t={t} csrfToken={csrfToken} {...props} />;
 };
 
 /**

+ 5 - 16
packages/app/src/components/Me/ProfileImageSettings.tsx

@@ -3,29 +3,20 @@ import React, { useCallback, useState } from 'react';
 
 import { useTranslation } from 'react-i18next';
 
-import AppContainer from '~/client/services/AppContainer';
 import { toastSuccess, toastError } from '~/client/util/apiNotification';
-import { apiPost } from '~/client/util/apiv1-client';
+import { apiPost, apiPostForm } from '~/client/util/apiv1-client';
 import { apiv3Put } from '~/client/util/apiv3-client';
 import { useCurrentUser } from '~/stores/context';
 import { generateGravatarSrc, GRAVATAR_DEFAULT } from '~/utils/gravatar';
 
-import { withUnstatedContainers } from '../UnstatedUtils';
-
 import ImageCropModal from './ImageCropModal';
 
 const DEFAULT_IMAGE = '/images/icons/user.svg';
 
 
-type Props = {
-  appContainer: AppContainer,
-}
-
-const ProfileImageSettings = (props: Props): JSX.Element => {
+const ProfileImageSettings = (): JSX.Element => {
   const { t } = useTranslation();
 
-  const { appContainer } = props;
-
   const { data: currentUser } = useCurrentUser();
 
   const [isGravatarEnabled, setGravatarEnabled] = useState(currentUser?.isGravatarEnabled);
@@ -55,9 +46,7 @@ const ProfileImageSettings = (props: Props): JSX.Element => {
     try {
       const formData = new FormData();
       formData.append('file', croppedImage);
-      // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
-      formData.append('_csrf', appContainer.csrfToken!);
-      const response = await apiPost('/attachments.uploadProfileImage', formData);
+      const response = await apiPostForm('/attachments.uploadProfileImage', formData);
 
       toastSuccess(t('toaster.update_successed', { target: t('Current Image') }));
 
@@ -70,7 +59,7 @@ const ProfileImageSettings = (props: Props): JSX.Element => {
     catch (err) {
       toastError(err);
     }
-  }, [appContainer.csrfToken, t]);
+  }, [t]);
 
   const deleteImageHandler = useCallback(async() => {
     try {
@@ -185,4 +174,4 @@ const ProfileImageSettings = (props: Props): JSX.Element => {
 
 };
 
-export default withUnstatedContainers(ProfileImageSettings, [AppContainer]);
+export default ProfileImageSettings;

+ 1 - 1
packages/app/src/components/MyDraftList/Draft.jsx

@@ -59,7 +59,7 @@ class Draft extends React.Component {
     };
 
     const growiRenderer = this.growiRenderer;
-    const interceptorManager = this.props.appContainer.interceptorManager;
+    const { interceptorManager } = window;
     await interceptorManager.process('prePreProcess', context)
       .then(() => {
         context.markdown = growiRenderer.preProcess(context.markdown, context);

+ 2 - 2
packages/app/src/components/Page/RevisionRenderer.jsx

@@ -58,7 +58,7 @@ class LegacyRevisionRenderer extends React.PureComponent {
     const HeaderLinkArray = Array.from(HeaderLink);
     addSmoothScrollEvent(HeaderLinkArray, blinkElem);
 
-    const { interceptorManager } = this.props.appContainer;
+    const { interceptorManager } = window;
 
     interceptorManager.process('postRenderHtml', this.currentRenderingContext);
   }
@@ -134,7 +134,7 @@ class LegacyRevisionRenderer extends React.PureComponent {
       highlightKeywords,
     } = this.props;
 
-    const { interceptorManager } = appContainer;
+    const { interceptorManager } = window;
     const context = this.currentRenderingContext;
 
     await interceptorManager.process('preRender', context);

+ 5 - 40
packages/app/src/components/Page/TrashPageAlert.jsx

@@ -1,5 +1,4 @@
-import React, { useState } from 'react';
-
+import React from 'react';
 
 import { UserPicture } from '@growi/ui';
 import PropTypes from 'prop-types';
@@ -9,8 +8,8 @@ import PageContainer from '~/client/services/PageContainer';
 import { useCurrentUpdatedAt, useShareLinkId } from '~/stores/context';
 import { usePageDeleteModal, usePutBackPageModal } from '~/stores/modal';
 import { useSWRxPageInfo } from '~/stores/page';
+import { useIsAbleToShowTrashPageManagementButtons } from '~/stores/ui';
 
-import EmptyTrashModal from '../EmptyTrashModal';
 import { withUnstatedContainers } from '../UnstatedUtils';
 
 const onDeletedHandler = (pathOrPathsToDelete, isRecursively, isCompletely) => {
@@ -27,6 +26,8 @@ const TrashPageAlert = (props) => {
   const {
     pageId, revisionId, path, isDeleted, lastUpdateUsername, deletedUserName, deletedAt,
   } = pageContainer.state;
+
+  const { data: isAbleToShowTrashPageManagementButtons } = useIsAbleToShowTrashPageManagementButtons();
   const { data: shareLinkId } = useShareLinkId();
 
   /*
@@ -37,19 +38,10 @@ const TrashPageAlert = (props) => {
   const { data: pageInfo } = useSWRxPageInfo(pageId ?? null, shareLinkId);
 
   const { data: updatedAt } = useCurrentUpdatedAt();
-  const [isEmptyTrashModalShown, setIsEmptyTrashModalShown] = useState(false);
 
   const { open: openDeleteModal } = usePageDeleteModal();
   const { open: openPutBackPageModal } = usePutBackPageModal();
 
-  function openEmptyTrashModalHandler() {
-    setIsEmptyTrashModalShown(true);
-  }
-
-  function closeEmptyTrashModalHandler() {
-    setIsEmptyTrashModalShown(false);
-  }
-
   function openPutbackPageModalHandler() {
     const putBackedHandler = (path) => {
       window.location.reload();
@@ -69,20 +61,6 @@ const TrashPageAlert = (props) => {
     openDeleteModal([pageToDelete], { onDeleted: onDeletedHandler });
   }
 
-  function renderEmptyButton() {
-    return (
-      <button
-        href="#"
-        type="button"
-        className="btn btn-danger rounded-pill btn-sm ml-auto"
-        data-target="#emptyTrash"
-        onClick={openEmptyTrashModalHandler}
-      >
-        <i className="icon-trash" aria-hidden="true"></i>{ t('modal_empty.empty_the_trash') }
-      </button>
-    );
-  }
-
   function renderTrashPageManagementButtons() {
     return (
       <>
@@ -106,17 +84,6 @@ const TrashPageAlert = (props) => {
     );
   }
 
-  function renderModals() {
-    return (
-      <>
-        <EmptyTrashModal
-          isOpen={isEmptyTrashModalShown}
-          onClose={closeEmptyTrashModalHandler}
-        />
-      </>
-    );
-  }
-
   return (
     <>
       <div className="alert alert-warning py-3 pl-4 d-flex flex-column flex-lg-row">
@@ -133,11 +100,9 @@ const TrashPageAlert = (props) => {
           )}
         </div>
         <div className="pt-1 d-flex align-items-end align-items-lg-center">
-          <span>{ pageContainer.isAbleToShowEmptyTrashButton && renderEmptyButton()}</span>
-          { pageContainer.isAbleToShowTrashPageManagementButtons && renderTrashPageManagementButtons()}
+          { isAbleToShowTrashPageManagementButtons && renderTrashPageManagementButtons()}
         </div>
       </div>
-      {renderModals()}
     </>
   );
 };

+ 2 - 2
packages/app/src/components/PageComment/Comment.jsx

@@ -69,7 +69,7 @@ class Comment extends React.PureComponent {
       return;
     }
 
-    const { interceptorManager } = this.props.appContainer;
+    const { interceptorManager } = window;
 
     interceptorManager.process('postRenderCommentHtml', this.currentRenderingContext);
   }
@@ -133,7 +133,7 @@ class Comment extends React.PureComponent {
   async renderHtml() {
 
     const { growiRenderer, appContainer } = this.props;
-    const { interceptorManager } = appContainer;
+    const { interceptorManager } = window;
     const context = this.currentRenderingContext;
 
     await interceptorManager.process('preRenderComment', context);

+ 1 - 1
packages/app/src/components/PageComment/CommentEditor.jsx

@@ -235,7 +235,7 @@ class CommentEditor extends React.Component {
     };
 
     const { growiRenderer } = this.props;
-    const interceptorManager = this.props.appContainer.interceptorManager;
+    const { interceptorManager } = window;
     interceptorManager.process('preRenderCommnetPreview', context)
       .then(() => { return interceptorManager.process('prePreProcess', context) })
       .then(() => {

+ 9 - 12
packages/app/src/components/PageEditor.tsx

@@ -11,7 +11,7 @@ import { throttle, debounce } from 'throttle-debounce';
 import AppContainer from '~/client/services/AppContainer';
 import EditorContainer from '~/client/services/EditorContainer';
 import PageContainer from '~/client/services/PageContainer';
-import { apiGet, apiPost } from '~/client/util/apiv1-client';
+import { apiGet, apiPostForm } from '~/client/util/apiv1-client';
 import { getOptionsToSave } from '~/client/util/editor';
 import { useIsEditable, useIsIndentSizeForced, useCurrentPagePath } from '~/stores/context';
 import {
@@ -36,9 +36,8 @@ import { withUnstatedContainers } from './UnstatedUtils';
 const logger = loggerFactory('growi:PageEditor');
 
 
-declare let window: {
-  globalEmitter: EventEmitter,
-};
+declare const globalEmitter: EventEmitter;
+
 
 type EditorRef = {
   setValue: (markdown: string) => void,
@@ -165,8 +164,6 @@ const PageEditor = (props: Props): JSX.Element => {
 
       const formData = new FormData();
       const { pageId, path } = pageContainer.state;
-      // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
-      formData.append('_csrf', appContainer.csrfToken!);
       formData.append('file', file);
       if (path != null) {
         formData.append('path', path);
@@ -175,7 +172,7 @@ const PageEditor = (props: Props): JSX.Element => {
         formData.append('page_id', pageId);
       }
 
-      res = await apiPost('/attachments.add', formData);
+      res = await apiPostForm('/attachments.add', formData);
       const attachment = res.attachment;
       const fileName = attachment.originalName;
 
@@ -201,7 +198,7 @@ const PageEditor = (props: Props): JSX.Element => {
     finally {
       editorRef.current.terminateUploadingState();
     }
-  }, [appContainer.csrfToken, editorMode, mutateGrant, pageContainer]);
+  }, [editorMode, mutateGrant, pageContainer]);
 
 
   const scrollPreviewByEditorLine = useCallback((line: number) => {
@@ -322,10 +319,10 @@ const PageEditor = (props: Props): JSX.Element => {
         scrollSyncHelper.scrollPreview(previewRef.current, line);
       }
     };
-    window.globalEmitter.on('setCaretLine', handler);
+    globalEmitter.on('setCaretLine', handler);
 
     return function cleanup() {
-      window.globalEmitter.removeListener('setCaretLine', handler);
+      globalEmitter.removeListener('setCaretLine', handler);
     };
   }, []);
 
@@ -343,10 +340,10 @@ const PageEditor = (props: Props): JSX.Element => {
         editorRef.current.setValue(markdown);
       }
     };
-    window.globalEmitter.on('updateEditorValue', handler);
+    globalEmitter.on('updateEditorValue', handler);
 
     return function cleanup() {
-      window.globalEmitter.removeListener('updateEditorValue', handler);
+      globalEmitter.removeListener('updateEditorValue', handler);
     };
   }, []);
 

+ 5 - 2
packages/app/src/components/PageEditor/Preview.tsx

@@ -4,12 +4,16 @@ import React, {
 
 
 import AppContainer from '~/client/services/AppContainer';
+import InterceptorManager from '~/services/interceptor-manager';
 import { useEditorSettings } from '~/stores/editor';
 
 import RevisionBody from '../Page/RevisionBody';
 import { withUnstatedContainers } from '../UnstatedUtils';
 
 
+declare const interceptorManager: InterceptorManager;
+
+
 type Props = {
   appContainer: AppContainer,
 
@@ -34,8 +38,7 @@ const Preview = (props: Props): JSX.Element => {
 
   const { data: editorSettings } = useEditorSettings();
 
-  const { interceptorManager } = appContainer;
-  const growiRenderer = props.appContainer.getRenderer('editor');
+  const growiRenderer = appContainer.getRenderer('editor');
 
   const context = useMemo(() => {
     return {

+ 1 - 9
packages/app/src/components/PasswordResetExecutionForm.jsx

@@ -1,21 +1,16 @@
 import React, { useState } from 'react';
 
-import PropTypes from 'prop-types';
 import { useTranslation } from 'react-i18next';
 
-import AppContainer from '~/client/services/AppContainer';
 import { toastSuccess, toastError } from '~/client/util/apiNotification';
 import { apiv3Put } from '~/client/util/apiv3-client';
 import loggerFactory from '~/utils/logger';
 
-import { withUnstatedContainers } from './UnstatedUtils';
-
 const logger = loggerFactory('growi:passwordReset');
 
 
 const PasswordResetExecutionForm = (props) => {
   const { t } = useTranslation();
-  const { appContainer } = props;
 
   const [newPassword, setNewPassword] = useState('');
   const [newPasswordConfirm, setNewPasswordConfirm] = useState('');
@@ -91,10 +86,7 @@ const PasswordResetExecutionForm = (props) => {
   );
 };
 
-const PasswordResetExecutionFormWrapper = withUnstatedContainers(PasswordResetExecutionForm, [AppContainer]);
-
 PasswordResetExecutionForm.propTypes = {
-  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
 };
 
-export default PasswordResetExecutionFormWrapper;
+export default PasswordResetExecutionForm;

+ 1 - 12
packages/app/src/components/PasswordResetRequestForm.jsx

@@ -1,18 +1,13 @@
 import React, { useState } from 'react';
 
-import PropTypes from 'prop-types';
 import { useTranslation } from 'react-i18next';
 
-import AppContainer from '~/client/services/AppContainer';
 import { toastSuccess, toastError } from '~/client/util/apiNotification';
 import { apiv3Post } from '~/client/util/apiv3-client';
 
-import { withUnstatedContainers } from './UnstatedUtils';
-
 
 const PasswordResetRequestForm = (props) => {
   const { t } = useTranslation();
-  const { appContainer } = props;
   const [email, setEmail] = useState('');
 
   const changeEmail = (inputValue) => {
@@ -57,13 +52,7 @@ const PasswordResetRequestForm = (props) => {
   );
 };
 
-/**
- * Wrapper component for using unstated
- */
-const PasswordResetRequestFormWrapper = withUnstatedContainers(PasswordResetRequestForm, [AppContainer]);
-
 PasswordResetRequestForm.propTypes = {
-  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
 };
 
-export default PasswordResetRequestFormWrapper;
+export default PasswordResetRequestForm;

+ 1 - 1
packages/app/src/components/PrivateLegacyPages.tsx

@@ -441,7 +441,7 @@ const PrivateLegacyPages = (props: Props): JSX.Element => {
         appContainer={appContainer}
         pages={data?.data}
         onSelectedPagesByCheckboxesChanged={selectedPagesByCheckboxesChangedHandler}
-        forceHideMenuItems={[MenuItemType.BOOKMARK, MenuItemType.RENAME, MenuItemType.DUPLICATE, MenuItemType.REVERT]}
+        forceHideMenuItems={[MenuItemType.BOOKMARK, MenuItemType.RENAME, MenuItemType.DUPLICATE, MenuItemType.REVERT, MenuItemType.PATH_RECOVERY]}
         // Components
         searchControl={searchControl}
         searchResultListHead={searchResultListHead}

+ 35 - 12
packages/app/src/components/Sidebar/PageTree/Item.tsx

@@ -7,10 +7,9 @@ import nodePath from 'path';
 import { pathUtils, pagePathUtils } from '@growi/core';
 import { useDrag, useDrop } from 'react-dnd';
 import { useTranslation } from 'react-i18next';
-import { DropdownToggle } from 'reactstrap';
+import { UncontrolledTooltip, DropdownToggle } from 'reactstrap';
 
-
-import { bookmark, unbookmark } from '~/client/services/page-operation';
+import { bookmark, unbookmark, resumeRenameOperation } from '~/client/services/page-operation';
 import { toastWarning, toastError, toastSuccess } from '~/client/util/apiNotification';
 import { apiv3Put, apiv3Post } from '~/client/util/apiv3-client';
 import TriangleIcon from '~/components/Icons/TriangleIcon';
@@ -109,7 +108,6 @@ const Item: FC<ItemProps> = (props: ItemProps) => {
   const [isNewPageInputShown, setNewPageInputShown] = useState(false);
   const [shouldHide, setShouldHide] = useState(false);
   const [isRenameInputShown, setRenameInputShown] = useState(false);
-  const [isRenaming, setRenaming] = useState(false);
   const [isCreating, setCreating] = useState(false);
 
   const { data, mutate: mutateChildren } = useSWRxPageChildren(isOpen ? page._id : null);
@@ -271,7 +269,6 @@ const Item: FC<ItemProps> = (props: ItemProps) => {
 
     try {
       setRenameInputShown(false);
-      setRenaming(true);
       await apiv3Put('/pages/rename', {
         pageId: page._id,
         revisionId: page.revision,
@@ -288,11 +285,6 @@ const Item: FC<ItemProps> = (props: ItemProps) => {
       setRenameInputShown(true);
       toastError(err);
     }
-    finally {
-      setTimeout(() => {
-        setRenaming(false);
-      }, 1000);
-    }
   };
 
   const deleteMenuItemClickHandler = useCallback(async(_pageId: string, pageInfo: IPageInfoAll | undefined): Promise<void> => {
@@ -371,6 +363,24 @@ const Item: FC<ItemProps> = (props: ItemProps) => {
     return null;
   };
 
+  /**
+   * Users do not need to know if all pages have been renamed.
+   * Make resuming rename operation appears to be working fine to allow users for a seamless operation.
+   */
+  const pathRecoveryMenuItemClickHandler = async(pageId: string): Promise<void> => {
+    try {
+      await resumeRenameOperation(pageId);
+
+      if (onRenamed != null) {
+        onRenamed();
+      }
+
+      toastSuccess(t('page_operation.paths_recovered'));
+    }
+    catch {
+      toastError(t('page_operation.path_recovery_failed'));
+    }
+  };
 
   // didMount
   useEffect(() => {
@@ -398,6 +408,10 @@ const Item: FC<ItemProps> = (props: ItemProps) => {
     }
   }, [data, isOpen, targetPathOrId]);
 
+  // Rename process
+  // Icon that draw attention from users for some actions
+  const shouldShowAttentionIcon = !!page.processData?.Rename?.isProcessable;
+
   return (
     <div
       id={`pagetree-item-${page._id}`}
@@ -435,9 +449,15 @@ const Item: FC<ItemProps> = (props: ItemProps) => {
           )
           : (
             <>
-              { isRenaming && (
-                <i className="fa fa-spinner fa-pulse mr-2 text-muted"></i>
+              { shouldShowAttentionIcon && (
+                <>
+                  <i id="path-recovery" className="fa fa-warning mr-2 text-warning"></i>
+                  <UncontrolledTooltip placement="top" target="path-recovery" fade={false}>
+                    {t('tooltip.operation.attention.rename')}
+                  </UncontrolledTooltip>
+                </>
               )}
+
               <a href={`/${page._id}`} className="grw-pagetree-title-anchor flex-grow-1">
                 <p className={`text-truncate m-auto ${page.isEmpty && 'grw-sidebar-text-muted'}`}>{nodePath.basename(page.path ?? '') || '/'}</p>
               </a>
@@ -456,7 +476,10 @@ const Item: FC<ItemProps> = (props: ItemProps) => {
             onClickDuplicateMenuItem={duplicateMenuItemClickHandler}
             onClickRenameMenuItem={renameMenuItemClickHandler}
             onClickDeleteMenuItem={deleteMenuItemClickHandler}
+            onClickPathRecoveryMenuItem={pathRecoveryMenuItemClickHandler}
             isInstantRename
+            // Todo: It is wanted to find a better way to pass operationProcessData to PageItemControl
+            operationProcessData={page.processData}
           >
             {/* pass the color property to reactstrap dropdownToggle props. https://6-4-0--reactstrap.netlify.app/components/dropdowns/  */}
             <DropdownToggle color="transparent" className="border-0 rounded btn-page-item-control p-0 grw-visible-on-hover mr-1">

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

@@ -24,7 +24,7 @@ const TrashPageList = () => {
 
   const emptyTrashButton = useMemo(() => {
     return <EmptyTrashButton />;
-  }, [t]);
+  }, []);
 
   return (
     <div data-testid="trash-page-list" className="mt-5 d-edit-none">

+ 15 - 0
packages/app/src/interfaces/page-operation.ts

@@ -0,0 +1,15 @@
+export const PageActionType = {
+  Rename: 'Rename',
+  Duplicate: 'Duplicate',
+  Delete: 'Delete',
+  DeleteCompletely: 'DeleteCompletely',
+  Revert: 'Revert',
+  NormalizeParent: 'NormalizeParent',
+} as const;
+export type PageActionType = typeof PageActionType[keyof typeof PageActionType]
+export type IPageOperationProcessData = Partial<{
+  [key in PageActionType]: {isProcessable: boolean}
+}>
+export type IPageOperationProcessInfo = {
+  [pageId: string]: IPageOperationProcessData,
+}

+ 2 - 1
packages/app/src/interfaces/page.ts

@@ -1,5 +1,6 @@
 import { Ref, Nullable } from './common';
 import { HasObjectId } from './has-object-id';
+import { IPageOperationProcessData } from './page-operation';
 import { IRevision, HasRevisionShortbody } from './revision';
 import { SubscriptionStatusType } from './subscription';
 import { ITag } from './tag';
@@ -43,7 +44,7 @@ export type PageGrant = typeof PageGrant[keyof typeof PageGrant];
 
 export type IPageHasId = IPage & HasObjectId;
 
-export type IPageForItem = Partial<IPageHasId & {isTarget?: boolean}>;
+export type IPageForItem = Partial<IPageHasId & {isTarget?: boolean, processData?: IPageOperationProcessData}>;
 
 export type IPageInfo = {
   isV5Compatible: boolean,

+ 10 - 0
packages/app/src/next-i18next.config.ts

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

+ 5 - 4
packages/app/src/server/crowi/dev.js

@@ -1,9 +1,10 @@
 import path from 'path';
-import { listLocaleIds } from '~/utils/locale-utils';
+
+import { allLocales } from '~/next-i18next.config';
 import loggerFactory from '~/utils/logger';
 
-const swig = require('swig-templates');
 const onHeaders = require('on-headers');
+const swig = require('swig-templates');
 
 const logger = loggerFactory('growi:crowi:dev');
 
@@ -41,9 +42,9 @@ class CrowiDev {
    */
   requireForAutoReloadServer() {
     // load all json files for live reloading
-    listLocaleIds()
+    allLocales
       .forEach((localeId) => {
-        require(path.join(this.crowi.localeDir, localeId, 'translation.json'));
+        require(path.join(this.crowi.publicDir, 'static/locales', localeId, 'translation.json'));
       });
   }
 

+ 5 - 4
packages/app/src/server/crowi/express-init.js

@@ -1,5 +1,7 @@
 import mongoose from 'mongoose';
 
+import { allLocales, localePath } from '~/next-i18next.config';
+
 module.exports = function(crowi, app) {
   const debug = require('debug')('growi:crowi:express-init');
   const path = require('path');
@@ -24,7 +26,6 @@ module.exports = function(crowi, app) {
   const registerSafeRedirect = require('../middlewares/safe-redirect')();
   const injectCurrentuserToLocalvars = require('../middlewares/inject-currentuser-to-localvars')();
   const autoReconnectToS2sMsgServer = require('../middlewares/auto-reconnect-to-s2s-msg-server')(crowi);
-  const { listLocaleIds } = require('~/utils/locale-utils');
 
   const avoidSessionRoutes = require('../routes/avoid-session-routes');
   const i18nUserSettingDetector = require('../util/i18nUserSettingDetector');
@@ -41,9 +42,9 @@ module.exports = function(crowi, app) {
     .init({
       // debug: true,
       fallbackLng: ['en_US'],
-      whitelist: listLocaleIds(),
+      whitelist: allLocales,
       backend: {
-        loadPath: `${crowi.localeDir}{{lng}}/translation.json`,
+        loadPath: `${localePath}/{{lng}}/translation.json`,
       },
       detection: {
         order: ['userSettingDetector', 'header', 'navigator'],
@@ -81,7 +82,7 @@ module.exports = function(crowi, app) {
     res.locals.consts = {
       pageGrants: Page.getGrantLabels(),
       userStatus: User.getUserStatusLabels(),
-      language:   listLocaleIds(),
+      language:   allLocales,
       restrictGuestMode: crowi.aclService.getRestrictGuestModeLabels(),
       registrationMode: crowi.aclService.getRegistrationModeLabels(),
     };

+ 14 - 7
packages/app/src/server/crowi/index.js

@@ -11,12 +11,12 @@ import mongoose from 'mongoose';
 import pkg from '^/package.json';
 
 import CdnResourcesService from '~/services/cdn-resources-service';
-import InterceptorManager from '~/services/interceptor-manager';
 import Xss from '~/services/xss';
 import loggerFactory from '~/utils/logger';
 import { projectRoot } from '~/utils/project-dir-utils';
 
 import Activity from '../models/activity';
+import PageOperation, { PageActionType } from '../models/page-operation';
 import PageRedirect from '../models/page-redirect';
 import Tag from '../models/tag';
 import UserGroup from '../models/user-group';
@@ -72,7 +72,6 @@ function Crowi() {
   this.pageService = null;
   this.syncPageStatusService = null;
   this.cdnResourcesService = new CdnResourcesService();
-  this.interceptorManager = new InterceptorManager();
   this.slackIntegrationService = null;
   this.inAppNotificationService = null;
   this.activityService = null;
@@ -149,6 +148,16 @@ Crowi.prototype.init = async function() {
   await this.autoInstall();
 };
 
+/**
+ * Execute functions that should be run after the express server is ready.
+ */
+Crowi.prototype.asyncAfterExpressServerReady = async function() {
+  if (this.pageOperationService != null) {
+    await this.pageOperationService.afterExpressServerReady();
+  }
+};
+
+
 Crowi.prototype.isPageId = function(pageId) {
   if (!pageId) {
     return false;
@@ -313,10 +322,6 @@ Crowi.prototype.getSlackLegacy = function() {
   return this.slackLegacy;
 };
 
-Crowi.prototype.getInterceptorManager = function() {
-  return this.interceptorManager;
-};
-
 Crowi.prototype.getGlobalNotificationService = function() {
   return this.globalNotificationService;
 };
@@ -469,6 +474,9 @@ Crowi.prototype.start = async function() {
   // setup Global Error Handlers
   this.setupGlobalErrorHandlers();
 
+  // Execute this asynchronously after the express server is ready so it does not block the ongoing process
+  this.asyncAfterExpressServerReady();
+
   return serverListening;
 };
 
@@ -687,7 +695,6 @@ Crowi.prototype.setupPageService = async function() {
   }
   if (this.pageOperationService == null) {
     this.pageOperationService = new PageOperationService(this);
-    // TODO: Remove this code when resuming feature is implemented
     await this.pageOperationService.init();
   }
 };

+ 35 - 1
packages/app/src/server/models/page-operation.ts

@@ -1,13 +1,20 @@
+import { getOrCreateModel } from '@growi/core';
+import { addSeconds } from 'date-fns';
 import mongoose, {
   Schema, Model, Document, QueryOptions, FilterQuery,
 } from 'mongoose';
-import { getOrCreateModel } from '@growi/core';
 
 import {
   IPageForResuming, IUserForResuming, IOptionsForResuming,
 } from '~/server/interfaces/page-operation';
+
+import loggerFactory from '../../utils/logger';
 import { ObjectIdLike } from '../interfaces/mongoose-utils';
 
+const TIME_TO_ADD_SEC = 10;
+
+const logger = loggerFactory('growi:models:page-operation');
+
 type IObjectId = mongoose.Types.ObjectId;
 const ObjectId = mongoose.Schema.Types.ObjectId;
 
@@ -39,6 +46,9 @@ export interface IPageOperation {
   user: IUserForResuming,
   options?: IOptionsForResuming,
   incForUpdatingDescendantCount?: number,
+  unprocessableExpiryDate: Date,
+
+  isProcessable(): boolean
 }
 
 export interface PageOperationDocument extends IPageOperation, Document {}
@@ -48,6 +58,8 @@ export type PageOperationDocumentHasId = PageOperationDocument & { _id: ObjectId
 export interface PageOperationModel extends Model<PageOperationDocument> {
   findByIdAndUpdatePageActionStage(pageOpId: ObjectIdLike, stage: PageActionStage): Promise<PageOperationDocumentHasId | null>
   findMainOps(filter?: FilterQuery<PageOperationDocument>, projection?: any, options?: QueryOptions): Promise<PageOperationDocumentHasId[]>
+  deleteByActionTypes(deleteTypeList: PageActionType[]): Promise<void>
+  extendExpiryDate(operationId: ObjectIdLike): Promise<void>
 }
 
 const pageSchemaForResuming = new Schema<IPageForResuming>({
@@ -94,6 +106,7 @@ const schema = new Schema<PageOperationDocument, PageOperationModel>({
   user: { type: userSchemaForResuming, required: true },
   options: { type: optionsSchemaForResuming },
   incForUpdatingDescendantCount: { type: Number },
+  unprocessableExpiryDate: { type: Date, default: () => addSeconds(new Date(), 10) },
 });
 
 schema.statics.findByIdAndUpdatePageActionStage = async function(
@@ -116,4 +129,25 @@ schema.statics.findMainOps = async function(
   );
 };
 
+schema.statics.deleteByActionTypes = async function(
+    actionTypes: PageActionType[],
+): Promise<void> {
+
+  await this.deleteMany({ actionType: { $in: actionTypes } });
+  logger.info(`Deleted all PageOperation documents with actionType: [${actionTypes}]`);
+};
+
+/**
+ * add TIME_TO_ADD_SEC to current time and update unprocessableExpiryDate with it
+ */
+schema.statics.extendExpiryDate = async function(operationId: ObjectIdLike): Promise<void> {
+  const date = addSeconds(new Date(), TIME_TO_ADD_SEC);
+  await this.findByIdAndUpdate(operationId, { unprocessableExpiryDate: date });
+};
+
+schema.methods.isProcessable = function(): boolean {
+  const { unprocessableExpiryDate } = this;
+  return unprocessableExpiryDate == null || (unprocessableExpiryDate != null && new Date() > unprocessableExpiryDate);
+};
+
 export default getOrCreateModel<PageOperationDocument, PageOperationModel>('PageOperation', schema);

+ 19 - 96
packages/app/src/server/models/page.ts

@@ -20,7 +20,9 @@ import Crowi from '../crowi';
 import { getPageSchema, extractToAncestorsPaths, populateDataToShowRevision } from './obsolete-page';
 
 const { addTrailingSlash, normalizePath } = pathUtils;
-const { isTopPage, collectAncestorPaths } = pagePathUtils;
+const {
+  isTopPage, collectAncestorPaths, hasSlash,
+} = pagePathUtils;
 
 const logger = loggerFactory('growi:models:page');
 /*
@@ -58,8 +60,6 @@ export interface PageModel extends Model<PageDocument> {
   findByIdsAndViewer(pageIds: ObjectIdLike[], user, userGroups?, includeEmpty?: boolean): Promise<PageDocument[]>
   findByPathAndViewer(path: string | null, user, userGroups?, useFindOne?: boolean, includeEmpty?: boolean): Promise<PageDocument | PageDocument[] | null>
   findTargetAndAncestorsByPathOrId(pathOrId: string): Promise<TargetAndAncestorsResult>
-  findChildrenByParentPathOrIdAndViewer(parentPathOrId: string, user, userGroups?): Promise<PageDocument[]>
-  findAncestorsChildrenByPathAndViewer(path: string, user, userGroups?): Promise<Record<string, PageDocument[]>>
   findRecentUpdatedPages(path: string, user, option, includeEmpty?: boolean): Promise<PaginatedPages>
   generateGrantCondition(
     user, userGroups, showAnyoneKnowsLink?: boolean, showPagesRestrictedByOwner?: boolean, showPagesRestrictedByGroup?: boolean,
@@ -114,22 +114,6 @@ const schema = new Schema<PageDocument, PageModel>({
 schema.plugin(mongoosePaginate);
 schema.plugin(uniqueValidator);
 
-const hasSlash = (str: string): boolean => {
-  return str.includes('/');
-};
-
-/*
- * Generate RegExp instance for one level lower path
- */
-const generateChildrenRegExp = (path: string): RegExp => {
-  // https://regex101.com/r/laJGzj/1
-  // ex. /any_level1
-  if (isTopPage(path)) return new RegExp(/^\/[^/]+$/);
-
-  // https://regex101.com/r/mrDJrx/1
-  // ex. /parent/any_child OR /any_level1
-  return new RegExp(`^${path}(\\/[^/]+)\\/?$`);
-};
 
 export class PageQueryBuilder {
 
@@ -361,6 +345,18 @@ export class PageQueryBuilder {
     return this;
   }
 
+  // add viewer condition to PageQueryBuilder instance
+  async addViewerCondition(user, userGroups = null): Promise<PageQueryBuilder> {
+    let relatedUserGroups = userGroups;
+    if (user != null && relatedUserGroups == null) {
+      const UserGroupRelation: any = mongoose.model('UserGroupRelation');
+      relatedUserGroups = await UserGroupRelation.findAllUserGroupIdsRelatedToUser(user);
+    }
+
+    this.addConditionToFilteringByViewer(user, relatedUserGroups, false);
+    return this;
+  }
+
   addConditionToFilteringByViewer(user, userGroups, showAnyoneKnowsLink = false, showPagesRestrictedByOwner = false, showPagesRestrictedByGroup = false) {
     const condition = generateGrantCondition(user, userGroups, showAnyoneKnowsLink, showPagesRestrictedByOwner, showPagesRestrictedByGroup);
 
@@ -546,17 +542,6 @@ schema.statics.replaceTargetWithPage = async function(exPage, pageToReplaceWith?
   return this.findById(newTarget._id);
 };
 
-// Utility function to add viewer condition to PageQueryBuilder instance
-const addViewerCondition = async(queryBuilder: PageQueryBuilder, user, userGroups = null): Promise<void> => {
-  let relatedUserGroups = userGroups;
-  if (user != null && relatedUserGroups == null) {
-    const UserGroupRelation: any = mongoose.model('UserGroupRelation');
-    relatedUserGroups = await UserGroupRelation.findAllUserGroupIdsRelatedToUser(user);
-  }
-
-  queryBuilder.addConditionToFilteringByViewer(user, relatedUserGroups, false);
-};
-
 /*
  * Find pages by ID and viewer.
  */
@@ -564,7 +549,7 @@ schema.statics.findByIdsAndViewer = async function(pageIds: string[], user, user
   const baseQuery = this.find({ _id: { $in: pageIds } });
   const queryBuilder = new PageQueryBuilder(baseQuery, includeEmpty);
 
-  await addViewerCondition(queryBuilder, user, userGroups);
+  await queryBuilder.addViewerCondition(user, userGroups);
 
   return queryBuilder.query.exec();
 };
@@ -582,7 +567,7 @@ schema.statics.findByPathAndViewer = async function(
   const baseQuery = useFindOne ? this.findOne({ path }) : this.find({ path });
   const queryBuilder = new PageQueryBuilder(baseQuery, includeEmpty);
 
-  await addViewerCondition(queryBuilder, user, userGroups);
+  await queryBuilder.addViewerCondition(user, userGroups);
 
   return queryBuilder.query.exec();
 };
@@ -609,7 +594,7 @@ schema.statics.findRecentUpdatedPages = async function(
 
   queryBuilder.addConditionToListWithDescendants(path, options);
   queryBuilder.populateDataToList(User.USER_FIELDS_EXCEPT_CONFIDENTIAL);
-  await addViewerCondition(queryBuilder, user);
+  await queryBuilder.addViewerCondition(user);
   const pages = await Page.paginate(queryBuilder.query.clone(), {
     lean: true, sort: sortOpt, offset: options.offset, limit: options.limit,
   });
@@ -642,7 +627,7 @@ schema.statics.findTargetAndAncestorsByPathOrId = async function(pathOrId: strin
 
   // Do not populate
   const queryBuilder = new PageQueryBuilder(this.find(), true);
-  await addViewerCondition(queryBuilder, user, userGroups);
+  await queryBuilder.addViewerCondition(user, userGroups);
 
   const _targetAndAncestors: PageDocument[] = await queryBuilder
     .addConditionAsOnTree()
@@ -662,68 +647,6 @@ schema.statics.findTargetAndAncestorsByPathOrId = async function(pathOrId: strin
   return { targetAndAncestors, rootPage };
 };
 
-/*
- * Find all children by parent's path or id. Using id should be prioritized
- */
-schema.statics.findChildrenByParentPathOrIdAndViewer = async function(parentPathOrId: string, user, userGroups = null): Promise<PageDocument[]> {
-  let queryBuilder: PageQueryBuilder;
-  if (hasSlash(parentPathOrId)) {
-    const path = parentPathOrId;
-    const regexp = generateChildrenRegExp(path);
-    queryBuilder = new PageQueryBuilder(this.find({ path: { $regex: regexp } }), true);
-  }
-  else {
-    const parentId = parentPathOrId;
-    queryBuilder = new PageQueryBuilder(this.find({ parent: parentId } as any), true); // TODO: improve type
-  }
-  await addViewerCondition(queryBuilder, user, userGroups);
-
-  return queryBuilder
-    .addConditionToSortPagesByAscPath()
-    .query
-    .lean()
-    .exec();
-};
-
-schema.statics.findAncestorsChildrenByPathAndViewer = async function(path: string, user, userGroups = null): Promise<Record<string, PageDocument[]>> {
-  const ancestorPaths = isTopPage(path) ? ['/'] : collectAncestorPaths(path); // root path is necessary for rendering
-  const regexps = ancestorPaths.map(path => new RegExp(generateChildrenRegExp(path))); // cannot use re2
-
-  // get pages at once
-  const queryBuilder = new PageQueryBuilder(this.find({ path: { $in: regexps } }), true);
-  await addViewerCondition(queryBuilder, user, userGroups);
-  const _pages = await queryBuilder
-    .addConditionAsOnTree()
-    .addConditionToMinimizeDataForRendering()
-    .addConditionToSortPagesByAscPath()
-    .query
-    .lean()
-    .exec();
-  // mark target
-  const pages = _pages.map((page: PageDocument & { isTarget?: boolean }) => {
-    if (page.path === path) {
-      page.isTarget = true;
-    }
-    return page;
-  });
-
-  /*
-   * If any non-migrated page is found during creating the pathToChildren map, it will stop incrementing at that moment
-   */
-  const pathToChildren: Record<string, PageDocument[]> = {};
-  const sortedPaths = ancestorPaths.sort((a, b) => a.length - b.length); // sort paths by path.length
-  sortedPaths.every((path) => {
-    const children = pages.filter(page => nodePath.dirname(page.path) === path);
-    if (children.length === 0) {
-      return false; // break when children do not exist
-    }
-    pathToChildren[path] = children;
-    return true;
-  });
-
-  return pathToChildren;
-};
-
 /**
  * Create empty pages at paths at which no pages exist
  * @param paths Page paths

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

@@ -1,4 +1,5 @@
 /* eslint-disable no-use-before-define */
+import { allLocales } from '~/next-i18next.config';
 import { generateGravatarSrc } from '~/utils/gravatar';
 import loggerFactory from '~/utils/logger';
 
@@ -6,15 +7,12 @@ import loggerFactory from '~/utils/logger';
 const crypto = require('crypto');
 
 const debug = require('debug')('growi:models:user');
-const md5 = require('md5');
 const mongoose = require('mongoose');
 const mongoosePaginate = require('mongoose-paginate-v2');
 const uniqueValidator = require('mongoose-unique-validator');
 
 const ObjectId = mongoose.Schema.Types.ObjectId;
 
-const { listLocaleIds, migrateDeprecatedLocaleId } = require('~/utils/locale-utils');
-
 const { omitInsecureAttributes } = require('./serializers/user-serializer');
 
 const logger = loggerFactory('growi:models:user');
@@ -61,7 +59,7 @@ module.exports = function(crowi) {
     apiToken: { type: String, index: true },
     lang: {
       type: String,
-      enum: listLocaleIds(),
+      enum: allLocales,
       default: 'en_US',
     },
     status: {
@@ -78,10 +76,6 @@ module.exports = function(crowi) {
       },
     },
   });
-  // eslint-disable-next-line prefer-arrow-callback
-  userSchema.pre('validate', function() {
-    this.lang = migrateDeprecatedLocaleId(this.lang);
-  });
   userSchema.plugin(mongoosePaginate);
   userSchema.plugin(uniqueValidator);
 

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

@@ -1,4 +1,6 @@
 import { body } from 'express-validator';
+
+import { allLocales } from '~/next-i18next.config';
 import loggerFactory from '~/utils/logger';
 
 import { apiV3FormValidator } from '../../middlewares/apiv3-form-validator';
@@ -6,11 +8,8 @@ import { apiV3FormValidator } from '../../middlewares/apiv3-form-validator';
 const logger = loggerFactory('growi:routes:apiv3:app-settings');
 
 const debug = require('debug')('growi:routes:admin');
-
 const express = require('express');
-
 const { pathUtils } = require('@growi/core');
-const { listLocaleIds } = require('~/utils/locale-utils');
 
 const router = express.Router();
 
@@ -157,7 +156,7 @@ module.exports = (crowi) => {
     appSetting: [
       body('title').trim(),
       body('confidential'),
-      body('globalLang').isIn(listLocaleIds()),
+      body('globalLang').isIn(allLocales),
       body('isEmailPublishedForNewUser').isBoolean(),
       body('fileUpload').isBoolean(),
     ],

+ 9 - 10
packages/app/src/server/routes/apiv3/page-listing.ts

@@ -1,18 +1,18 @@
 import express, { Request, Router } from 'express';
 import { query, oneOf } from 'express-validator';
-
 import mongoose from 'mongoose';
 
 import { IPageInfoAll, isIPageInfoForEntity, IPageInfoForListing } from '~/interfaces/page';
+import { IUserHasId } from '~/interfaces/user';
 import loggerFactory from '~/utils/logger';
 
+import Crowi from '../../crowi';
+import { apiV3FormValidator } from '../../middlewares/apiv3-form-validator';
 import { PageModel } from '../../models/page';
 import ErrorV3 from '../../models/vo/error-apiv3';
-import Crowi from '../../crowi';
-import { ApiV3Response } from './interfaces/apiv3-response';
 import PageService from '../../service/page';
-import { apiV3FormValidator } from '../../middlewares/apiv3-form-validator';
-import { IUserHasId } from '~/interfaces/user';
+
+import { ApiV3Response } from './interfaces/apiv3-response';
 
 const logger = loggerFactory('growi:routes:apiv3:page-tree');
 
@@ -69,10 +69,9 @@ export default (crowi: Crowi): Router => {
   router.get('/ancestors-children', accessTokenParser, loginRequired, ...validator.pagePathRequired, apiV3FormValidator, async(req: AuthorizedRequest, res: ApiV3Response): Promise<any> => {
     const { path } = req.query;
 
-    const Page: PageModel = crowi.model('Page');
-
+    const pageService: PageService = crowi.pageService!;
     try {
-      const ancestorsChildren = await Page.findAncestorsChildrenByPathAndViewer(path as string, req.user);
+      const ancestorsChildren = await pageService.findAncestorsChildrenByPathAndViewer(path as string, req.user);
       return res.apiv3({ ancestorsChildren });
     }
     catch (err) {
@@ -89,10 +88,10 @@ export default (crowi: Crowi): Router => {
   router.get('/children', accessTokenParser, loginRequired, validator.pageIdOrPathRequired, apiV3FormValidator, async(req: AuthorizedRequest, res: ApiV3Response) => {
     const { id, path } = req.query;
 
-    const Page: PageModel = crowi.model('Page');
+    const pageService: PageService = crowi.pageService!;
 
     try {
-      const pages = await Page.findChildrenByParentPathOrIdAndViewer((id || path)as string, req.user);
+      const pages = await pageService.findChildrenByParentPathOrIdAndViewer((id || path)as string, req.user);
       return res.apiv3({ children: pages });
     }
     catch (err) {

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

@@ -180,6 +180,9 @@ module.exports = (crowi) => {
       body('updateMetadata').if(value => value != null).isBoolean().withMessage('updateMetadata must be boolean'),
       body('isMoveMode').if(value => value != null).isBoolean().withMessage('isMoveMode must be boolean'),
     ],
+    resumeRenamePage: [
+      body('pageId').isMongoId().withMessage('pageId is required'),
+    ],
     duplicatePage: [
       body('pageId').isMongoId().withMessage('pageId is required'),
       body('pageNameInput').trim().isLength({ min: 1 }).withMessage('pageNameInput is required'),
@@ -554,6 +557,29 @@ module.exports = (crowi) => {
     return res.apiv3(result);
   });
 
+  router.post('/resume-rename', accessTokenParser, loginRequiredStrictly, csrf, validator.resumeRenamePage, apiV3FormValidator, async(req, res) => {
+
+    const { pageId } = req.body;
+    const { user } = req;
+
+    // The user has permission to resume rename operation if page is returned.
+    const page = await Page.findByIdAndViewer(pageId, user, null, true);
+    if (page == null) {
+      const msg = 'The operation is forbidden for this user';
+      const code = 'forbidden-user';
+      return res.apiv3Err(new ErrorV3(msg, code), 403);
+    }
+
+    try {
+      await crowi.pageService.resumeRenameSubOperation(page);
+    }
+    catch (err) {
+      logger.error(err);
+      return res.apiv3Err(err, 500);
+    }
+    return res.apiv3();
+  });
+
   /**
    * @swagger
    *

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

@@ -1,6 +1,6 @@
 import { body } from 'express-validator';
 
-import { listLocaleIds } from '~/utils/locale-utils';
+import { allLocales } 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(listLocaleIds()),
+      body('lang').isString().isIn(allLocales),
       body('isEmailPublished').isBoolean(),
       body('slackMemberId').optional().isString(),
     ],

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

@@ -153,7 +153,6 @@ module.exports = function(crowi, app) {
   const getToday = require('../util/getToday');
 
   const { configManager, xssService } = crowi;
-  const interceptorManager = crowi.getInterceptorManager();
   const globalNotificationService = crowi.getGlobalNotificationService();
   const userNotificationService = crowi.getUserNotificationService();
 
@@ -404,7 +403,6 @@ module.exports = function(crowi, app) {
 
     await addRenderVarsForPageTree(renderVars, portalPath, req.user);
 
-    await interceptorManager.process('beforeRenderPage', req, res, renderVars);
     return res.render(view, renderVars);
   }
 
@@ -466,7 +464,6 @@ module.exports = function(crowi, app) {
 
     await addRenderVarsForPageTree(renderVars, path, req.user);
 
-    await interceptorManager.process('beforeRenderPage', req, res, renderVars);
     return res.render(view, renderVars);
   }
 
@@ -536,7 +533,6 @@ module.exports = function(crowi, app) {
     addRenderVarsForPage(renderVars, page);
     addRenderVarsForScope(renderVars, page);
 
-    await interceptorManager.process('beforeRenderPage', req, res, renderVars);
     return res.render('layout-growi/shared_page', renderVars);
   };
 

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

@@ -1,8 +1,19 @@
 import { pagePathUtils } from '@growi/core';
 
-import PageOperation from '~/server/models/page-operation';
+import { IPageOperationProcessInfo, IPageOperationProcessData } from '~/interfaces/page-operation';
+import PageOperation, { PageActionType, PageActionStage, PageOperationDocument } from '~/server/models/page-operation';
+import loggerFactory from '~/utils/logger';
+
+import { ObjectIdLike } from '../interfaces/mongoose-utils';
+
+const logger = loggerFactory('growi:services:page-operation');
 
 const { isEitherOfPathAreaOverlap, isPathAreaOverlap, isTrashPage } = pagePathUtils;
+const AUTO_UPDATE_INTERVAL_SEC = 5;
+
+const {
+  Duplicate, Delete, DeleteCompletely, Revert, NormalizeParent,
+} = PageActionType;
 
 class PageOperationService {
 
@@ -12,9 +23,49 @@ class PageOperationService {
     this.crowi = crowi;
   }
 
-  // TODO: Remove this code when resuming feature is implemented
-  async init():Promise<void> {
-    await PageOperation.deleteMany({});
+  async init(): Promise<void> {
+    // cleanup PageOperation documents except ones with actionType: Rename
+    const types = [Duplicate, Delete, DeleteCompletely, Revert, NormalizeParent];
+    await PageOperation.deleteByActionTypes(types);
+  }
+
+  /**
+   * Execute functions that should be run after the express server is ready.
+   */
+  async afterExpressServerReady(): Promise<void> {
+    try {
+      // execute rename operation
+      await this.executeAllRenameOperationBySystem();
+    }
+    catch (err) {
+      logger.error(err);
+    }
+  }
+
+  /**
+   * 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' });
+    if (pageOps.length === 0) return;
+
+    for await (const pageOp of pageOps) {
+      const {
+        page, toPath, options, user,
+      } = pageOp;
+
+      const renamedPage = await Page.findById(pageOp.page._id);
+      if (renamedPage == null) {
+        logger.warn('operating page is not found');
+        continue;
+      }
+
+      // rename
+      await this.crowi.pageService.renameSubOperation(page, toPath, user, options, renamedPage, pageOp._id);
+    }
   }
 
   /**
@@ -25,44 +76,99 @@ class PageOperationService {
    * @returns boolean
    */
   async canOperate(isRecursively: boolean, fromPathToOp: string | null, toPathToOp: string | null): Promise<boolean> {
-    const mainOps = await PageOperation.findMainOps();
+    const pageOperations = await PageOperation.find();
 
-    if (mainOps.length === 0) {
+    if (pageOperations.length === 0) {
       return true;
     }
 
-    const toPaths = mainOps.map(op => op.toPath).filter((p): p is string => p != null);
+    const fromPaths = pageOperations.map(op => op.fromPath).filter((p): p is string => p != null);
+    const toPaths = pageOperations.map(op => op.toPath).filter((p): p is string => p != null);
 
     if (isRecursively) {
-
       if (fromPathToOp != null && !isTrashPage(fromPathToOp)) {
-        const flag = toPaths.some(p => isEitherOfPathAreaOverlap(p, fromPathToOp));
-        if (flag) return false;
+        const fromFlag = fromPaths.some(p => isEitherOfPathAreaOverlap(p, fromPathToOp));
+        if (fromFlag) return false;
+
+        const toFlag = toPaths.some(p => isEitherOfPathAreaOverlap(p, fromPathToOp));
+        if (toFlag) return false;
       }
 
       if (toPathToOp != null && !isTrashPage(toPathToOp)) {
-        const flag = toPaths.some(p => isPathAreaOverlap(p, toPathToOp));
-        if (flag) return false;
+        const fromFlag = fromPaths.some(p => isPathAreaOverlap(p, toPathToOp));
+        if (fromFlag) return false;
+
+        const toFlag = toPaths.some(p => isPathAreaOverlap(p, toPathToOp));
+        if (toFlag) return false;
       }
 
     }
     else {
-
       if (fromPathToOp != null && !isTrashPage(fromPathToOp)) {
-        const flag = toPaths.some(p => isPathAreaOverlap(p, fromPathToOp));
-        if (flag) return false;
+        const fromFlag = fromPaths.some(p => isPathAreaOverlap(p, fromPathToOp));
+        if (fromFlag) return false;
+
+        const toFlag = toPaths.some(p => isPathAreaOverlap(p, fromPathToOp));
+        if (toFlag) return false;
       }
 
       if (toPathToOp != null && !isTrashPage(toPathToOp)) {
-        const flag = toPaths.some(p => isPathAreaOverlap(p, toPathToOp));
-        if (flag) return false;
-      }
+        const fromFlag = fromPaths.some(p => isPathAreaOverlap(p, toPathToOp));
+        if (fromFlag) return false;
 
+        const toFlag = toPaths.some(p => isPathAreaOverlap(p, toPathToOp));
+        if (toFlag) return false;
+      }
     }
 
     return true;
   }
 
+  /**
+   * Generate object that connects page id with processData of PageOperation.
+   * The processData is a combination of actionType as a key and information on whether the action is processable as a value.
+   */
+  generateProcessInfo(pageOps: PageOperationDocument[]): IPageOperationProcessInfo {
+    const processInfo: IPageOperationProcessInfo = {};
+
+    pageOps.forEach((pageOp) => {
+      const pageId = pageOp.page._id.toString();
+
+      const actionType = pageOp.actionType;
+      const isProcessable = pageOp.isProcessable();
+
+      // processData for processInfo
+      const processData: IPageOperationProcessData = { [actionType]: { isProcessable } };
+
+      // Merge processData if other processData exist
+      if (processInfo[pageId] != null) {
+        const otherProcessData = processInfo[pageId];
+        processInfo[pageId] = { ...otherProcessData, ...processData };
+        return;
+      }
+      // add new process data to processInfo
+      processInfo[pageId] = processData;
+    });
+
+    return processInfo;
+  }
+
+  /**
+   * Set interval to update unprocessableExpiryDate every AUTO_UPDATE_INTERVAL_SEC seconds.
+   * This is used to prevent the same page operation from being processed multiple times at once
+   */
+  autoUpdateExpiryDate(operationId: ObjectIdLike): NodeJS.Timeout {
+    // https://github.com/Microsoft/TypeScript/issues/30128#issuecomment-651877225
+    const timerObj = global.setInterval(async() => {
+      await PageOperation.extendExpiryDate(operationId);
+    }, AUTO_UPDATE_INTERVAL_SEC * 1000);
+    return timerObj;
+  }
+
+  clearAutoUpdateInterval(timerObj: NodeJS.Timeout): void {
+    clearInterval(timerObj);
+  }
+
 }
 
 export default PageOperationService;

+ 170 - 21
packages/app/src/server/service/page.ts

@@ -16,11 +16,12 @@ import {
 import {
   PageDeleteConfigValue, IPageDeleteConfigValueToProcessValidation,
 } from '~/interfaces/page-delete-config';
+import { IPageOperationProcessInfo, IPageOperationProcessData } from '~/interfaces/page-operation';
 import { IUserHasId } from '~/interfaces/user';
 import { PageMigrationErrorData, SocketEventName, UpdateDescCountRawData } from '~/interfaces/websocket';
 import { stringifySnapshot } from '~/models/serializers/in-app-notification-snapshot/page';
 import {
-  CreateMethod, PageCreateOptions, PageModel, PageDocument, pushRevision,
+  CreateMethod, PageCreateOptions, PageModel, PageDocument, pushRevision, PageQueryBuilder,
 } from '~/server/models/page';
 import { createBatchStream } from '~/server/util/batch-stream';
 import loggerFactory from '~/utils/logger';
@@ -39,7 +40,7 @@ const debug = require('debug')('growi:services:page');
 const logger = loggerFactory('growi:services:page');
 const {
   isTrashPage, isTopPage, omitDuplicateAreaPageFromPages,
-  collectAncestorPaths, isMovablePage, canMoveByPath,
+  collectAncestorPaths, isMovablePage, canMoveByPath, hasSlash, generateChildrenRegExp,
 } = pagePathUtils;
 
 const { addTrailingSlash } = pathUtils;
@@ -538,6 +539,11 @@ class PageService {
     }
     const renamedPage = await Page.findByIdAndUpdate(page._id, { $set: update }, { new: true });
 
+    // 5.increase parent's descendantCount.
+    // see: https://dev.growi.org/62149d019311629d4ecd91cf#Handling%20of%20descendantCount%20in%20case%20of%20unexpected%20process%20interruption
+    const nToIncreaseForOperationInterruption = 1;
+    await Page.incrementDescendantCountOfPageIds([newParent._id], nToIncreaseForOperationInterruption);
+
     // create page redirect
     if (options.createRedirectPage) {
       const PageRedirect = mongoose.model('PageRedirect') as unknown as PageRedirectModel;
@@ -564,10 +570,24 @@ class PageService {
 
     const exParentId = page.parent;
 
+    const timerObj = this.crowi.pageOperationService.autoUpdateExpiryDate(pageOpId);
+    try {
     // update descendants first
-    await this.renameDescendantsWithStream(page, newPagePath, user, options, false);
+      await this.renameDescendantsWithStream(page, newPagePath, user, options, false);
+    }
+    catch (err) {
+      logger.warn(err);
+      throw Error(err);
+    }
+    finally {
+      this.crowi.pageOperationService.clearAutoUpdateInterval(timerObj);
+    }
+
+    // reduce parent's descendantCount
+    // see: https://dev.growi.org/62149d019311629d4ecd91cf#Handling%20of%20descendantCount%20in%20case%20of%20unexpected%20process%20interruption
+    const nToReduceForOperationInterruption = -1;
+    await Page.incrementDescendantCountOfPageIds([renamedPage.parent], nToReduceForOperationInterruption);
 
-    // reduce ancestore's descendantCount
     const nToReduce = -1 * ((page.isEmpty ? 0 : 1) + page.descendantCount);
     await this.updateDescendantCountOfAncestors(exParentId, nToReduce, true);
 
@@ -577,13 +597,39 @@ class PageService {
 
     // Remove leaf empty pages if not moving to under the ex-target position
     if (!this.isRenamingToUnderTarget(page.path, newPagePath)) {
-      // remove empty pages at leaf position
+    // remove empty pages at leaf position
       await Page.removeLeafEmptyPagesRecursively(page.parent);
     }
 
     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');
+    }
+    const isProcessable = pageOp.isProcessable();
+    if (!isProcessable) {
+      throw Error('This page operation is currently being processed');
+    }
+
+    const {
+      page, 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);
+
+  }
+
   private isRenamingToUnderTarget(fromPath: string, toPath: string): boolean {
     const pathToTest = escapeStringRegexp(addTrailingSlash(fromPath));
     const pathToBeTested = toPath;
@@ -2638,10 +2684,7 @@ class PageService {
     return isUnique;
   }
 
-  // TODO: use socket to send status to the client
   async normalizeAllPublicPages() {
-    // const socket = this.crowi.socketIoService.getAdminSocket();
-
     let isUnique;
     try {
       isUnique = await this._isPagePathIndexUnique();
@@ -2658,7 +2701,6 @@ class PageService {
       }
       catch (err) {
         logger.error('V5 index normalization failed.', err);
-        // socket.emit('v5IndexNormalizationFailed', { error: err.message });
         throw err;
       }
     }
@@ -2669,7 +2711,6 @@ class PageService {
     }
     catch (err) {
       logger.error('V5 initial miration failed.', err);
-      // socket.emit('v5InitialMirationFailed', { error: err.message });
 
       throw err;
     }
@@ -2713,7 +2754,7 @@ class PageService {
    * @param user To be used to filter pages to update. If null, only public pages will be updated.
    * @returns Promise<void>
    */
-  async normalizeParentRecursively(paths: string[], user: any | null): Promise<number> {
+  async normalizeParentRecursively(paths: string[], user: any | null, shouldEmit = false): Promise<number> {
     const Page = mongoose.model('Page') as unknown as PageModel;
 
     const ancestorPaths = paths.flatMap(p => collectAncestorPaths(p, []));
@@ -2732,7 +2773,7 @@ class PageService {
 
     const grantFiltersByUser: { $or: any[] } = Page.generateGrantCondition(user, userGroups);
 
-    return this._normalizeParentRecursively(pathAndRegExpsToNormalize, ancestorPaths, grantFiltersByUser, user);
+    return this._normalizeParentRecursively(pathAndRegExpsToNormalize, ancestorPaths, grantFiltersByUser, user, shouldEmit);
   }
 
   private buildFilterForNormalizeParentRecursively(pathOrRegExps: (RegExp | string)[], publicPathsToNormalize: string[], grantFiltersByUser: { $or: any[] }) {
@@ -2778,12 +2819,19 @@ class PageService {
   }
 
   private async _normalizeParentRecursively(
-      pathOrRegExps: (RegExp | string)[], publicPathsToNormalize: string[], grantFiltersByUser: { $or: any[] }, user, count = 0, skiped = 0, isFirst = true,
+      pathOrRegExps: (RegExp | string)[],
+      publicPathsToNormalize: string[],
+      grantFiltersByUser: { $or: any[] },
+      user,
+      shouldEmit = false,
+      count = 0,
+      skiped = 0,
+      isFirst = true,
   ): Promise<number> {
     const BATCH_SIZE = 100;
     const PAGES_LIMIT = 1000;
 
-    const socket = this.crowi.socketIoService.getAdminSocket();
+    const socket = shouldEmit ? this.crowi.socketIoService.getAdminSocket() : null;
 
     const Page = mongoose.model('Page') as unknown as PageModel;
     const { PageQueryBuilder } = Page;
@@ -2805,7 +2853,7 @@ class PageService {
     // Limit pages to get
     const total = await Page.countDocuments(matchFilter);
     if (isFirst) {
-      socket.emit(SocketEventName.PMStarted, { total });
+      socket?.emit(SocketEventName.PMStarted, { total });
     }
     if (total > PAGES_LIMIT) {
       baseAggregation = baseAggregation.limit(Math.floor(total * 0.3));
@@ -2912,13 +2960,13 @@ class PageService {
           nextSkiped += res.result.writeErrors.length;
           logger.info(`Page migration processing: (migratedPages=${res.result.nModified})`);
 
-          socket.emit(SocketEventName.PMMigrating, { count: nextCount });
-          socket.emit(SocketEventName.PMErrorCount, { skip: nextSkiped });
+          socket?.emit(SocketEventName.PMMigrating, { count: nextCount });
+          socket?.emit(SocketEventName.PMErrorCount, { skip: nextSkiped });
 
           // Throw if any error is found
           if (res.result.writeErrors.length > 0) {
             logger.error('Failed to migrate some pages', res.result.writeErrors);
-            socket.emit(SocketEventName.PMEnded, { isSucceeded: false });
+            socket?.emit(SocketEventName.PMEnded, { isSucceeded: false });
             throw Error('Failed to migrate some pages');
           }
 
@@ -2926,7 +2974,7 @@ class PageService {
           if (res.result.nModified === 0 && res.result.nMatched === 0) {
             shouldContinue = false;
             logger.error('Migration is unable to continue', 'parentPaths:', parentPaths, 'bulkWriteResult:', res);
-            socket.emit(SocketEventName.PMEnded, { isSucceeded: false });
+            socket?.emit(SocketEventName.PMEnded, { isSucceeded: false });
           }
         }
         catch (err) {
@@ -2948,11 +2996,11 @@ class PageService {
     await streamToPromise(migratePagesStream);
 
     if (await Page.exists(matchFilter) && shouldContinue) {
-      return this._normalizeParentRecursively(pathOrRegExps, publicPathsToNormalize, grantFiltersByUser, user, nextCount, nextSkiped, false);
+      return this._normalizeParentRecursively(pathOrRegExps, publicPathsToNormalize, grantFiltersByUser, user, shouldEmit, nextCount, nextSkiped, false);
     }
 
     // End
-    socket.emit(SocketEventName.PMEnded, { isSucceeded: true });
+    socket?.emit(SocketEventName.PMEnded, { isSucceeded: true });
 
     return nextCount;
   }
@@ -3462,6 +3510,107 @@ class PageService {
     return savedPage;
   }
 
+  /*
+   * Find all children by parent's path or id. Using id should be prioritized
+   */
+  async findChildrenByParentPathOrIdAndViewer(parentPathOrId: string, user, userGroups = null): Promise<PageDocument[]> {
+    const Page = mongoose.model('Page') as unknown as PageModel;
+    let queryBuilder: PageQueryBuilder;
+    if (hasSlash(parentPathOrId)) {
+      const path = parentPathOrId;
+      const regexp = generateChildrenRegExp(path);
+      queryBuilder = new PageQueryBuilder(Page.find({ path: { $regex: regexp } }), true);
+    }
+    else {
+      const parentId = parentPathOrId;
+      // Use $eq for user-controlled sources. see: https://codeql.github.com/codeql-query-help/javascript/js-sql-injection/#recommendation
+      queryBuilder = new PageQueryBuilder(Page.find({ parent: { $eq: parentId } } as any), true); // TODO: improve type
+    }
+    await queryBuilder.addViewerCondition(user, userGroups);
+
+    const pages = await queryBuilder
+      .addConditionToSortPagesByAscPath()
+      .query
+      .lean()
+      .exec();
+
+    await this.injectProcessDataIntoPagesByActionTypes(pages, [PageActionType.Rename]);
+
+    return pages;
+  }
+
+  async findAncestorsChildrenByPathAndViewer(path: string, user, userGroups = null): Promise<Record<string, PageDocument[]>> {
+    const Page = mongoose.model('Page') as unknown as PageModel;
+
+    const ancestorPaths = isTopPage(path) ? ['/'] : collectAncestorPaths(path); // root path is necessary for rendering
+    const regexps = ancestorPaths.map(path => new RegExp(generateChildrenRegExp(path))); // cannot use re2
+
+    // get pages at once
+    const queryBuilder = new PageQueryBuilder(Page.find({ path: { $in: regexps } }), true);
+    await queryBuilder.addViewerCondition(user, userGroups);
+    const pages = await queryBuilder
+      .addConditionAsOnTree()
+      .addConditionToMinimizeDataForRendering()
+      .addConditionToSortPagesByAscPath()
+      .query
+      .lean()
+      .exec();
+
+    this.injectIsTargetIntoPages(pages, path);
+    await this.injectProcessDataIntoPagesByActionTypes(pages, [PageActionType.Rename]);
+
+    /*
+     * If any non-migrated page is found during creating the pathToChildren map, it will stop incrementing at that moment
+     */
+    const pathToChildren: Record<string, PageDocument[]> = {};
+    const sortedPaths = ancestorPaths.sort((a, b) => a.length - b.length); // sort paths by path.length
+    sortedPaths.every((path) => {
+      const children = pages.filter(page => pathlib.dirname(page.path) === path);
+      if (children.length === 0) {
+        return false; // break when children do not exist
+      }
+      pathToChildren[path] = children;
+      return true;
+    });
+
+    return pathToChildren;
+  }
+
+  private injectIsTargetIntoPages(pages: (PageDocument & {isTarget?: boolean})[], path): void {
+    pages.forEach((page) => {
+      if (page.path === path) {
+        page.isTarget = true;
+      }
+    });
+  }
+
+  /**
+   * Inject processData into page docuements
+   * The processData is a combination of actionType as a key and information on whether the action is processable as a value.
+   */
+  private async injectProcessDataIntoPagesByActionTypes(
+      pages: (PageDocument & { processData?: IPageOperationProcessData })[],
+      actionTypes: PageActionType[],
+  ): Promise<void> {
+
+    const pageOperations = await PageOperation.find({ actionType: { $in: actionTypes } });
+    if (pageOperations == null || pageOperations.length === 0) {
+      return;
+    }
+
+    const processInfo: IPageOperationProcessInfo = this.crowi.pageOperationService.generateProcessInfo(pageOperations);
+    const operatingPageIds: string[] = Object.keys(processInfo);
+
+    // inject processData into pages
+    pages.forEach((page) => {
+      const pageId = page._id.toString();
+      if (operatingPageIds.includes(pageId)) {
+        const processData: IPageOperationProcessData = processInfo[pageId];
+        page.processData = processData;
+      }
+    });
+  }
+
 }
 
 export default PageService;

+ 4 - 3
packages/app/src/server/views/installer.html

@@ -31,7 +31,7 @@
 
   {{ cdnStyleTagsByGroup('basis') }}
 
-  <script src="{{ webpack_asset('js/nologin.js') }}" defer></script>
+  <script src="{{ webpack_asset('js/installer.js') }}" defer></script>
 
 </head>
 {% endblock %}
@@ -43,6 +43,8 @@
   data-csrftoken="{{ csrf() }}"
  >
 
+<div id="growi-context-extractor"></div>
+
 <div id="wrapper">
 
   <!-- Page Content -->
@@ -72,8 +74,7 @@
           <div id="installer-form-container"
             data-user-name="{{ req.body.registerForm.username }}"
             data-name="{{ req.body.registerForm.name }}"
-            data-email="{{ req.body.registerForm.email }}"
-            data-csrf="{{ csrf() }}">
+            data-email="{{ req.body.registerForm.email }}">
           </div>
         </div>
       </div>{# /.row #}

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

@@ -12,6 +12,10 @@ import { useStaticSWR } from './use-static-swr';
 type Nullable<T> = T | null;
 
 
+export const useCsrfToken = (initialData?: string): SWRResponse<string, Error> => {
+  return useStaticSWR<string, Error>('csrfToken', initialData);
+};
+
 export const useSiteUrl = (initialData?: string): SWRResponse<string, Error> => {
   return useStaticSWR<string, Error>('siteUrl', initialData);
 };

+ 31 - 18
packages/app/src/stores/ui.tsx

@@ -17,7 +17,7 @@ import loggerFactory from '~/utils/logger';
 
 import {
   useCurrentPageId, useCurrentPagePath, useIsEditable, useIsTrashPage, useIsUserPage, useIsGuestUser,
-  useIsNotCreatable, useIsSharedUser, useNotFoundTargetPathOrId, useIsForbidden, useIsIdenticalPath, useIsNotFoundPermalink,
+  useIsNotCreatable, useIsSharedUser, useNotFoundTargetPathOrId, useIsForbidden, useIsIdenticalPath, useIsNotFoundPermalink, useCurrentUser, useIsDeleted,
 } from './context';
 import { localStorageMiddleware } from './middlewares/sync-to-storage';
 import { useStaticSWR } from './use-static-swr';
@@ -303,6 +303,36 @@ export const useGlobalSearchFormRef = (initialData?: RefObject<IFocusable>): SWR
   return useStaticSWR('globalSearchTypeahead', initialData);
 };
 
+type PageTreeDescCountMapUtils = {
+  update(newData?: UpdateDescCountData): Promise<UpdateDescCountData | undefined>
+  getDescCount(pageId?: string): number | null | undefined
+}
+
+export const usePageTreeDescCountMap = (initialData?: UpdateDescCountData): SWRResponse<UpdateDescCountData, Error> & PageTreeDescCountMapUtils => {
+  const key = 'pageTreeDescCountMap';
+
+  const swrResponse = useStaticSWR<UpdateDescCountData, Error>(key, initialData, { fallbackData: new Map() });
+
+  return {
+    ...swrResponse,
+    getDescCount: (pageId?: string) => (pageId != null ? swrResponse.data?.get(pageId) : null),
+    update: (newData: UpdateDescCountData) => swrResponse.mutate(new Map([...(swrResponse.data || new Map()), ...newData])),
+  };
+};
+
+
+/** **********************************************************
+ *                          SWR Hooks
+ *                Determined value by context
+ *********************************************************** */
+
+export const useIsAbleToShowTrashPageManagementButtons = (): SWRResponse<boolean, Error> => {
+  const { data: currentUser } = useCurrentUser();
+  const { data: isDeleted } = useIsDeleted();
+
+  return useStaticSWR('isAbleToShowTrashPageManagementButtons', isDeleted && currentUser != null);
+};
+
 export const useIsAbleToShowPageManagement = (): SWRResponse<boolean, Error> => {
   const key = 'isAbleToShowPageManagement';
   const { data: currentPageId } = useCurrentPageId();
@@ -367,20 +397,3 @@ export const useIsAbleToShowPageAuthors = (): SWRResponse<boolean, Error> => {
     () => isPageExist && !isUserPage,
   );
 };
-
-type PageTreeDescCountMapUtils = {
-  update(newData?: UpdateDescCountData): Promise<UpdateDescCountData | undefined>
-  getDescCount(pageId?: string): number | null | undefined
-}
-
-export const usePageTreeDescCountMap = (initialData?: UpdateDescCountData): SWRResponse<UpdateDescCountData, Error> & PageTreeDescCountMapUtils => {
-  const key = 'pageTreeDescCountMap';
-
-  const swrResponse = useStaticSWR<UpdateDescCountData, Error>(key, initialData, { fallbackData: new Map() });
-
-  return {
-    ...swrResponse,
-    getDescCount: (pageId?: string) => (pageId != null ? swrResponse.data?.get(pageId) : null),
-    update: (newData: UpdateDescCountData) => swrResponse.mutate(new Map([...(swrResponse.data || new Map()), ...newData])),
-  };
-};

+ 11 - 7
packages/app/src/stores/websocket.tsx

@@ -33,15 +33,19 @@ export const useGlobalSocket = (): SWRResponse<Socket, Error> => {
 /*
  * Global Admin Socket
  */
-export const useSetupGlobalAdminSocket = (): SWRResponse<Socket, Error> => {
-  const socket = io(GLOBAL_ADMIN_SOCKET_NS, {
-    transports: ['websocket'],
-  });
+export const useSetupGlobalAdminSocket = (shouldInit: boolean): SWRResponse<Socket, Error> => {
+  let socket: Socket | undefined;
 
-  socket.on('error', (err) => { logger.error(err) });
-  socket.on('connect_error', (err) => { logger.error('Failed to connect with websocket.', err) });
+  if (shouldInit) {
+    socket = io(GLOBAL_ADMIN_SOCKET_NS, {
+      transports: ['websocket'],
+    });
+
+    socket.on('error', (err) => { logger.error(err) });
+    socket.on('connect_error', (err) => { logger.error('Failed to connect with websocket.', err) });
+  }
 
-  return useStaticSWR(GLOBAL_ADMIN_SOCKET_KEY, socket);
+  return useStaticSWR(shouldInit ? GLOBAL_ADMIN_SOCKET_KEY : null, socket);
 };
 
 export const useGlobalAdminSocket = (): SWRResponse<Socket, Error> => {

+ 0 - 50
packages/app/src/utils/locale-utils.ts

@@ -1,50 +0,0 @@
-import fs from 'fs';
-
-import { resolveFromRoot } from '~/utils/project-dir-utils';
-
-const MIGRATE_LOCALE_MAP = {
-  en: 'en_US',
-  ja: 'ja_JP',
-};
-
-/**
- * List locales dirents
- */
-function listLocaleDirents() {
-  const allDirents = fs.readdirSync(resolveFromRoot('./resource/locales'), { withFileTypes: true });
-  return allDirents
-    .filter(dirent => dirent.isDirectory());
-}
-
-/**
- * List locales aliases
- */
-function listLocaleMetadatas() {
-  return listLocaleDirents()
-    .map(dir => dir.name)
-    .map(localeDirName => require(`../../resource/locales/${localeDirName}/meta.json`));
-}
-
-/**
- * List locales IDs (=subdir names)
- */
-function listLocaleIds() {
-  return listLocaleMetadatas()
-    .map(meta => meta.id);
-}
-
-function migrateDeprecatedLocaleId(localeId) {
-  const toValue = MIGRATE_LOCALE_MAP[localeId];
-
-  if (toValue != null) {
-    return toValue;
-  }
-
-  return localeId;
-}
-
-module.exports = {
-  listLocaleMetadatas,
-  listLocaleIds,
-  migrateDeprecatedLocaleId,
-};

+ 82 - 2
packages/app/test/integration/global-setup.js

@@ -7,9 +7,8 @@
 
 import 'tsconfig-paths/register';
 
-import mongoose from 'mongoose';
-
 import { initMongooseGlobalSettings, getMongoUri, mongoOptions } from '@growi/core';
+import mongoose from 'mongoose';
 
 // check env
 if (process.env.NODE_ENV !== 'test') {
@@ -27,12 +26,93 @@ module.exports = async() => {
   // init DB
   const pageCollection = mongoose.connection.collection('pages');
   const userCollection = mongoose.connection.collection('users');
+  const userGroupCollection = mongoose.connection.collection('usergroups');
+  const userGroupRelationsCollection = mongoose.connection.collection('usergrouprelations');
 
   // create global user & rootPage
   const globalUser = (await userCollection.insertMany([{ name: 'globalUser', username: 'globalUser', email: 'globalUser@example.com' }]))[0];
+  const gGroupUserId1 = new mongoose.Types.ObjectId();
+  const gGroupUserId2 = new mongoose.Types.ObjectId();
+  const gGroupUserId3 = new mongoose.Types.ObjectId();
+
   await userCollection.insertMany([
     { name: 'v5DummyUser1', username: 'v5DummyUser1', email: 'v5DummyUser1@example.com' },
     { name: 'v5DummyUser2', username: 'v5DummyUser2', email: 'v5DummyUser2@example.com' },
+    {
+      _id: gGroupUserId1, name: 'gGroupUser1', username: 'gGroupUser1', email: 'gGroupUser1@example.com',
+    },
+    {
+      _id: gGroupUserId2, name: 'gGroupUser2', username: 'gGroupUser2', email: 'gGroupUser2@example.com',
+    },
+    {
+      _id: gGroupUserId3, name: 'gGroupUser3', username: 'gGroupUser3', email: 'gGroupUser3@example.com',
+    },
+  ]);
+  const gGroupIdIsolate = new mongoose.Types.ObjectId();
+  const gGroupIdA = new mongoose.Types.ObjectId();
+  const gGroupIdB = new mongoose.Types.ObjectId();
+  const gGroupIdC = new mongoose.Types.ObjectId();
+  await userGroupCollection.insertMany([
+    {
+      _id: gGroupIdIsolate,
+      name: 'globalGroupIsolate',
+    },
+    {
+      _id: gGroupIdA,
+      name: 'globalGroupA',
+    },
+    {
+      _id: gGroupIdB,
+      name: 'globalGroupB',
+      parent: gGroupIdA,
+    },
+    {
+      _id: gGroupIdC,
+      name: 'globalGroupC',
+      parent: gGroupIdB,
+    },
+  ]);
+  await userGroupRelationsCollection.insertMany([
+    {
+      relatedGroup: gGroupIdIsolate,
+      relatedUser: gGroupUserId1,
+      createdAt: new Date(),
+    },
+    {
+      relatedGroup: gGroupIdIsolate,
+      relatedUser: gGroupUserId2,
+      createdAt: new Date(),
+    },
+    {
+      relatedGroup: gGroupIdA,
+      relatedUser: gGroupUserId1,
+      createdAt: new Date(),
+    },
+    {
+      relatedGroup: gGroupIdA,
+      relatedUser: gGroupUserId2,
+      createdAt: new Date(),
+    },
+    {
+      relatedGroup: gGroupIdA,
+      relatedUser: gGroupUserId3,
+      createdAt: new Date(),
+    },
+    {
+      relatedGroup: gGroupIdB,
+      relatedUser: gGroupUserId2,
+      createdAt: new Date(),
+    },
+    {
+      relatedGroup: gGroupIdB,
+      relatedUser: gGroupUserId3,
+      createdAt: new Date(),
+    },
+    {
+      relatedGroup: gGroupIdC,
+      relatedUser: gGroupUserId3,
+      createdAt: new Date(),
+    },
   ]);
   await pageCollection.insertMany([{
     path: '/',

+ 0 - 427
packages/app/test/integration/models/v5.page.test.js

@@ -143,79 +143,6 @@ describe('Page', () => {
       },
     ]);
 
-    const pageIdCreate1 = new mongoose.Types.ObjectId();
-    const pageIdCreate2 = new mongoose.Types.ObjectId();
-    const pageIdCreate3 = new mongoose.Types.ObjectId();
-    const pageIdCreate4 = new mongoose.Types.ObjectId();
-
-    /**
-     * create
-     * mc_ => model create
-     * emp => empty => page with isEmpty: true
-     * pub => public => GRANT_PUBLIC
-     */
-    await Page.insertMany([
-      {
-        _id: pageIdCreate1,
-        path: '/v5_empty_create_4',
-        grant: Page.GRANT_PUBLIC,
-        parent: rootPage._id,
-        isEmpty: true,
-      },
-      {
-        path: '/v5_empty_create_4/v5_create_5',
-        grant: Page.GRANT_PUBLIC,
-        creator: dummyUser1,
-        lastUpdateUser: dummyUser1._id,
-        parent: pageIdCreate1,
-        isEmpty: false,
-      },
-      {
-        _id: pageIdCreate2,
-        path: '/mc4_top/mc1_emp',
-        grant: Page.GRANT_PUBLIC,
-        creator: dummyUser1,
-        lastUpdateUser: dummyUser1._id,
-        parent: rootPage._id,
-        isEmpty: true,
-      },
-      {
-        path: '/mc4_top/mc1_emp/mc2_pub',
-        grant: Page.GRANT_PUBLIC,
-        creator: dummyUser1,
-        lastUpdateUser: dummyUser1._id,
-        parent: pageIdCreate2,
-        isEmpty: false,
-      },
-      {
-        path: '/mc5_top/mc3_awl',
-        grant: Page.GRANT_RESTRICTED,
-        creator: dummyUser1,
-        lastUpdateUser: dummyUser1._id,
-        isEmpty: false,
-      },
-      {
-        _id: pageIdCreate3,
-        path: '/mc4_top',
-        grant: Page.GRANT_PUBLIC,
-        creator: dummyUser1,
-        lastUpdateUser: dummyUser1._id,
-        isEmpty: false,
-        parent: rootPage._id,
-        descendantCount: 1,
-      },
-      {
-        _id: pageIdCreate4,
-        path: '/mc5_top',
-        grant: Page.GRANT_PUBLIC,
-        creator: dummyUser1,
-        lastUpdateUser: dummyUser1._id,
-        isEmpty: false,
-        parent: rootPage._id,
-        descendantCount: 0,
-      },
-    ]);
-
     /**
      * update
      * mup_ => model update
@@ -558,182 +485,6 @@ describe('Page', () => {
       },
     ]);
 
-    /**
-     * getParentAndFillAncestors
-     */
-    const pageIdPAF1 = new mongoose.Types.ObjectId();
-    const pageIdPAF2 = new mongoose.Types.ObjectId();
-    const pageIdPAF3 = new mongoose.Types.ObjectId();
-
-    await Page.insertMany([
-      {
-        _id: pageIdPAF1,
-        path: '/PAF1',
-        grant: Page.GRANT_PUBLIC,
-        creator: dummyUser1,
-        lastUpdateUser: dummyUser1._id,
-        isEmpty: false,
-        parent: rootPage._id,
-        descendantCount: 0,
-      },
-      {
-        _id: pageIdPAF2,
-        path: '/emp_anc3',
-        grant: Page.GRANT_PUBLIC,
-        isEmpty: true,
-        descendantCount: 1,
-        parent: rootPage._id,
-      },
-      {
-        path: '/emp_anc3/PAF3',
-        grant: Page.GRANT_PUBLIC,
-        creator: dummyUser1,
-        lastUpdateUser: dummyUser1._id,
-        isEmpty: false,
-        descendantCount: 0,
-        parent: pageIdPAF2,
-      },
-      {
-        _id: pageIdPAF3,
-        path: '/emp_anc4',
-        grant: Page.GRANT_PUBLIC,
-        isEmpty: true,
-        descendantCount: 1,
-        parent: rootPage._id,
-      },
-      {
-        path: '/emp_anc4/PAF4',
-        grant: Page.GRANT_PUBLIC,
-        creator: dummyUser1,
-        lastUpdateUser: dummyUser1._id,
-        isEmpty: false,
-        descendantCount: 0,
-        parent: pageIdPAF3,
-      },
-      {
-        path: '/emp_anc4',
-        grant: Page.GRANT_OWNER,
-        grantedUsers: [dummyUser1._id],
-        creator: dummyUser1,
-        lastUpdateUser: dummyUser1._id,
-        isEmpty: false,
-      },
-      {
-        path: '/get_parent_A',
-        creator: dummyUser1,
-        lastUpdateUser: dummyUser1,
-        parent: null,
-      },
-      {
-        path: '/get_parent_A/get_parent_B',
-        creator: dummyUser1,
-        lastUpdateUser: dummyUser1,
-        parent: null,
-      },
-      {
-        path: '/get_parent_C',
-        creator: dummyUser1,
-        lastUpdateUser: dummyUser1,
-        parent: rootPage._id,
-      },
-      {
-        path: '/get_parent_C/get_parent_D',
-        creator: dummyUser1,
-        lastUpdateUser: dummyUser1,
-        parent: null,
-      },
-    ]);
-
-  });
-  describe('create', () => {
-
-    test('Should create single page', async() => {
-      const page = await crowi.pageService.create('/v5_create1', 'create1', dummyUser1, {});
-      expect(page).toBeTruthy();
-      expect(page.parent).toStrictEqual(rootPage._id);
-    });
-
-    test('Should create empty-child and non-empty grandchild', async() => {
-      const grandchildPage = await crowi.pageService.create('/v5_empty_create2/v5_create_3', 'grandchild', dummyUser1, {});
-      const childPage = await Page.findOne({ path: '/v5_empty_create2' });
-
-      expect(childPage.isEmpty).toBe(true);
-      expect(grandchildPage).toBeTruthy();
-      expect(childPage).toBeTruthy();
-      expect(childPage.parent).toStrictEqual(rootPage._id);
-      expect(grandchildPage.parent).toStrictEqual(childPage._id);
-    });
-
-    test('Should create on empty page', async() => {
-      const beforeCreatePage = await Page.findOne({ path: '/v5_empty_create_4' });
-      expect(beforeCreatePage.isEmpty).toBe(true);
-
-      const childPage = await crowi.pageService.create('/v5_empty_create_4', 'body', dummyUser1, {});
-      const grandchildPage = await Page.findOne({ parent: childPage._id });
-
-      expect(childPage).toBeTruthy();
-      expect(childPage.isEmpty).toBe(false);
-      expect(childPage.revision.body).toBe('body');
-      expect(grandchildPage).toBeTruthy();
-      expect(childPage.parent).toStrictEqual(rootPage._id);
-      expect(grandchildPage.parent).toStrictEqual(childPage._id);
-    });
-
-    describe('Creating a page using existing path', () => {
-      test('with grant RESTRICTED should only create the page and change nothing else', async() => {
-        const pathT = '/mc4_top';
-        const path1 = '/mc4_top/mc1_emp';
-        const path2 = '/mc4_top/mc1_emp/mc2_pub';
-        const pageT = await Page.findOne({ path: pathT, descendantCount: 1 });
-        const page1 = await Page.findOne({ path: path1, grant: Page.GRANT_PUBLIC });
-        const page2 = await Page.findOne({ path: path2 });
-        const page3 = await Page.findOne({ path: path1, grant: Page.GRANT_RESTRICTED });
-        expect(pageT).toBeTruthy();
-        expect(page1).toBeTruthy();
-        expect(page2).toBeTruthy();
-        expect(page3).toBeNull();
-
-        // use existing path
-        await crowi.pageService.create(path1, 'new body', dummyUser1, { grant: Page.GRANT_RESTRICTED });
-
-        const _pageT = await Page.findOne({ path: pathT });
-        const _page1 = await Page.findOne({ path: path1, grant: Page.GRANT_PUBLIC });
-        const _page2 = await Page.findOne({ path: path2 });
-        const _page3 = await Page.findOne({ path: path1, grant: Page.GRANT_RESTRICTED });
-        expect(_pageT).toBeTruthy();
-        expect(_page1).toBeTruthy();
-        expect(_page2).toBeTruthy();
-        expect(_page3).toBeTruthy();
-        expect(_pageT.descendantCount).toBe(1);
-      });
-    });
-    describe('Creating a page under a page with grant RESTRICTED', () => {
-      test('will create a new empty page with the same path as the grant RESTRECTED page and become a parent', async() => {
-        const pathT = '/mc5_top';
-        const path1 = '/mc5_top/mc3_awl';
-        const pathN = '/mc5_top/mc3_awl/mc4_pub'; // used to create
-        const pageT = await Page.findOne({ path: pathT });
-        const page1 = await Page.findOne({ path: path1, grant: Page.GRANT_RESTRICTED });
-        const page2 = await Page.findOne({ path: path1, grant: Page.GRANT_PUBLIC });
-        expect(pageT).toBeTruthy();
-        expect(page1).toBeTruthy();
-        expect(page2).toBeNull();
-
-        await crowi.pageService.create(pathN, 'new body', dummyUser1, { grant: Page.GRANT_PUBLIC });
-
-        const _pageT = await Page.findOne({ path: pathT });
-        const _page1 = await Page.findOne({ path: path1, grant: Page.GRANT_RESTRICTED });
-        const _page2 = await Page.findOne({ path: path1, grant: Page.GRANT_PUBLIC, isEmpty: true });
-        const _pageN = await Page.findOne({ path: pathN, grant: Page.GRANT_PUBLIC }); // newly crated
-        expect(_pageT).toBeTruthy();
-        expect(_page1).toBeTruthy();
-        expect(_page2).toBeTruthy();
-        expect(_pageN).toBeTruthy();
-        expect(_pageN.parent).toStrictEqual(_page2._id);
-        expect(_pageT.descendantCount).toStrictEqual(1);
-      });
-    });
-
   });
 
   describe('update', () => {
@@ -1102,182 +853,4 @@ describe('Page', () => {
     });
 
   });
-
-  describe('getParentAndFillAncestors', () => {
-    test('return parent if exist', async() => {
-      const page1 = await Page.findOne({ path: '/PAF1' });
-      const parent = await crowi.pageService.getParentAndFillAncestorsByUser(dummyUser1, page1.path);
-      expect(parent).toBeTruthy();
-      expect(page1.parent).toStrictEqual(parent._id);
-    });
-    test('create parent and ancestors when they do not exist, and return the new parent', async() => {
-      const path1 = '/emp_anc1';
-      const path2 = '/emp_anc1/emp_anc2';
-      const path3 = '/emp_anc1/emp_anc2/PAF2';
-      const _page1 = await Page.findOne({ path: path1 }); // not exist
-      const _page2 = await Page.findOne({ path: path2 }); // not exist
-      const _page3 = await Page.findOne({ path: path3 }); // not exist
-      expect(_page1).toBeNull();
-      expect(_page2).toBeNull();
-      expect(_page3).toBeNull();
-
-      const parent = await crowi.pageService.getParentAndFillAncestorsByUser(dummyUser1, path3);
-      const page1 = await Page.findOne({ path: path1 });
-      const page2 = await Page.findOne({ path: path2 });
-      const page3 = await Page.findOne({ path: path3 });
-
-      expect(parent._id).toStrictEqual(page2._id);
-      expect(parent.path).toStrictEqual(page2.path);
-      expect(parent.parent).toStrictEqual(page2.parent);
-
-      expect(parent).toBeTruthy();
-      expect(page1).toBeTruthy();
-      expect(page2).toBeTruthy();
-      expect(page3).toBeNull();
-
-      expect(page1.parent).toStrictEqual(rootPage._id);
-      expect(page2.parent).toStrictEqual(page1._id);
-    });
-    test('return parent even if the parent page is empty', async() => {
-      const path1 = '/emp_anc3';
-      const path2 = '/emp_anc3/PAF3';
-      const _page1 = await Page.findOne({ path: path1, isEmpty: true });
-      const _page2 = await Page.findOne({ path: path2, isEmpty: false });
-      expect(_page1).toBeTruthy();
-      expect(_page2).toBeTruthy();
-
-      const parent = await crowi.pageService.getParentAndFillAncestorsByUser(dummyUser1, _page2.path);
-      const page1 = await Page.findOne({ path: path1, isEmpty: true }); // parent
-      const page2 = await Page.findOne({ path: path2, isEmpty: false });
-
-      // check for the parent (should be the same as page1)
-      expect(parent._id).toStrictEqual(page1._id);
-      expect(parent.path).toStrictEqual(page1.path);
-      expect(parent.parent).toStrictEqual(page1.parent);
-
-      expect(page1.parent).toStrictEqual(rootPage._id);
-      expect(page2.parent).toStrictEqual(page1._id);
-    });
-    test('should find parent while NOT updating private legacy page\'s parent', async() => {
-      const path1 = '/emp_anc4';
-      const path2 = '/emp_anc4/PAF4';
-      const _page1 = await Page.findOne({ path: path1, isEmpty: true, grant: Page.GRANT_PUBLIC });
-      const _page2 = await Page.findOne({ path: path2, isEmpty: false, grant: Page.GRANT_PUBLIC });
-      const _page3 = await Page.findOne({ path: path1, isEmpty: false, grant: Page.GRANT_OWNER });
-      expect(_page1).toBeTruthy();
-      expect(_page2).toBeTruthy();
-      expect(_page3).toBeTruthy();
-      expect(_page3.parent).toBeNull();
-
-      const parent = await crowi.pageService.getParentAndFillAncestorsByUser(dummyUser1, _page2.path);
-      const page1 = await Page.findOne({ path: path1, isEmpty: true, grant: Page.GRANT_PUBLIC });
-      const page2 = await Page.findOne({ path: path2, isEmpty: false, grant: Page.GRANT_PUBLIC });
-      const page3 = await Page.findOne({ path: path1, isEmpty: false, grant: Page.GRANT_OWNER });
-      expect(page1).toBeTruthy();
-      expect(page2).toBeTruthy();
-      expect(page3).toBeTruthy();
-      expect(page3.parent).toBeNull(); // parent property of page in private legacy pages should be null
-
-      expect(page1._id).toStrictEqual(parent._id);
-      expect(page2.parent).toStrictEqual(parent._id);
-
-    });
-    test('should find parent while NOT creating unnecessary empty pages with all v4 public pages', async() => {
-      // All pages does not have parent (v4 schema)
-      const _pageA = await Page.findOne({
-        path: '/get_parent_A',
-        grant: Page.GRANT_PUBLIC,
-        isEmpty: false,
-        parent: null,
-      });
-      const _pageAB = await Page.findOne({
-        path: '/get_parent_A/get_parent_B',
-        grant: Page.GRANT_PUBLIC,
-        isEmpty: false,
-        parent: null,
-      });
-      const _emptyA = await Page.findOne({
-        path: '/get_parent_A',
-        grant: Page.GRANT_PUBLIC,
-        isEmpty: true,
-      });
-      const _emptyAB = await Page.findOne({
-        path: '/get_parent_A/get_parent_B',
-        grant: Page.GRANT_PUBLIC,
-        isEmpty: true,
-      });
-
-      expect(_pageA).not.toBeNull();
-      expect(_pageAB).not.toBeNull();
-      expect(_emptyA).toBeNull();
-      expect(_emptyAB).toBeNull();
-
-      const parent = await crowi.pageService.getParentAndFillAncestorsByUser(dummyUser1, '/get_parent_A/get_parent_B/get_parent_C');
-
-      const pageA = await Page.findOne({ path: '/get_parent_A', grant: Page.GRANT_PUBLIC, isEmpty: false });
-      const pageAB = await Page.findOne({ path: '/get_parent_A/get_parent_B', grant: Page.GRANT_PUBLIC, isEmpty: false });
-      const emptyA = await Page.findOne({ path: '/get_parent_A', grant: Page.GRANT_PUBLIC, isEmpty: true });
-      const emptyAB = await Page.findOne({ path: '/get_parent_A/get_parent_B', grant: Page.GRANT_PUBLIC, isEmpty: true });
-
-      // -- Check existance
-      expect(parent).not.toBeNull();
-      expect(pageA).not.toBeNull();
-      expect(pageAB).not.toBeNull();
-      expect(emptyA).toBeNull();
-      expect(emptyAB).toBeNull();
-
-      // -- Check parent
-      expect(pageA.parent).not.toBeNull();
-      expect(pageAB.parent).not.toBeNull();
-    });
-    test('should find parent while NOT creating unnecessary empty pages with some v5 public pages', async() => {
-      const _pageC = await Page.findOne({
-        path: '/get_parent_C',
-        grant: Page.GRANT_PUBLIC,
-        isEmpty: false,
-        parent: { $ne: null },
-      });
-      const _pageCD = await Page.findOne({
-        path: '/get_parent_C/get_parent_D',
-        grant: Page.GRANT_PUBLIC,
-        isEmpty: false,
-      });
-      const _emptyC = await Page.findOne({
-        path: '/get_parent_C',
-        grant: Page.GRANT_PUBLIC,
-        isEmpty: true,
-      });
-      const _emptyCD = await Page.findOne({
-        path: '/get_parent_C/get_parent_D',
-        grant: Page.GRANT_PUBLIC,
-        isEmpty: true,
-      });
-
-      expect(_pageC).not.toBeNull();
-      expect(_pageCD).not.toBeNull();
-      expect(_emptyC).toBeNull();
-      expect(_emptyCD).toBeNull();
-
-      const parent = await crowi.pageService.getParentAndFillAncestorsByUser(dummyUser1, '/get_parent_C/get_parent_D/get_parent_E');
-
-      const pageC = await Page.findOne({ path: '/get_parent_C', grant: Page.GRANT_PUBLIC, isEmpty: false });
-      const pageCD = await Page.findOne({ path: '/get_parent_C/get_parent_D', grant: Page.GRANT_PUBLIC, isEmpty: false });
-      const emptyC = await Page.findOne({ path: '/get_parent_C', grant: Page.GRANT_PUBLIC, isEmpty: true });
-      const emptyCD = await Page.findOne({ path: '/get_parent_C/get_parent_D', grant: Page.GRANT_PUBLIC, isEmpty: true });
-
-      // -- Check existance
-      expect(parent).not.toBeNull();
-      expect(pageC).not.toBeNull();
-      expect(pageCD).not.toBeNull();
-      expect(emptyC).toBeNull();
-      expect(emptyCD).toBeNull();
-
-      // -- Check parent attribute
-      expect(pageC.parent).toStrictEqual(rootPage._id);
-      expect(pageCD.parent).toStrictEqual(pageC._id);
-
-      // -- Check the found parent
-      expect(parent.toObject()).toStrictEqual(pageCD.toObject());
-    });
-  });
 });

+ 270 - 0
packages/app/test/integration/service/page.test.js

@@ -8,6 +8,8 @@ const mongoose = require('mongoose');
 
 const { getInstance } = require('../setup-crowi');
 
+let rootPage;
+let dummyUser1;
 let testUser1;
 let testUser2;
 let parentTag;
@@ -79,6 +81,10 @@ describe('PageService', () => {
     testUser1 = await User.findOne({ username: 'someone1' });
     testUser2 = await User.findOne({ username: 'someone2' });
 
+    dummyUser1 = await User.findOne({ username: 'v5DummyUser1' });
+
+    rootPage = await Page.findOne({ path: '/' });
+
     await Page.insertMany([
       {
         path: '/parentForRename1',
@@ -290,6 +296,92 @@ describe('PageService', () => {
     ]);
 
     xssSpy = jest.spyOn(crowi.xss, 'process').mockImplementation(path => path);
+
+    /**
+     * getParentAndFillAncestors
+     */
+    const pageIdPAF1 = new mongoose.Types.ObjectId();
+    const pageIdPAF2 = new mongoose.Types.ObjectId();
+    const pageIdPAF3 = new mongoose.Types.ObjectId();
+
+    await Page.insertMany([
+      {
+        _id: pageIdPAF1,
+        path: '/PAF1',
+        grant: Page.GRANT_PUBLIC,
+        creator: dummyUser1,
+        lastUpdateUser: dummyUser1._id,
+        isEmpty: false,
+        parent: rootPage._id,
+        descendantCount: 0,
+      },
+      {
+        _id: pageIdPAF2,
+        path: '/emp_anc3',
+        grant: Page.GRANT_PUBLIC,
+        isEmpty: true,
+        descendantCount: 1,
+        parent: rootPage._id,
+      },
+      {
+        path: '/emp_anc3/PAF3',
+        grant: Page.GRANT_PUBLIC,
+        creator: dummyUser1,
+        lastUpdateUser: dummyUser1._id,
+        isEmpty: false,
+        descendantCount: 0,
+        parent: pageIdPAF2,
+      },
+      {
+        _id: pageIdPAF3,
+        path: '/emp_anc4',
+        grant: Page.GRANT_PUBLIC,
+        isEmpty: true,
+        descendantCount: 1,
+        parent: rootPage._id,
+      },
+      {
+        path: '/emp_anc4/PAF4',
+        grant: Page.GRANT_PUBLIC,
+        creator: dummyUser1,
+        lastUpdateUser: dummyUser1._id,
+        isEmpty: false,
+        descendantCount: 0,
+        parent: pageIdPAF3,
+      },
+      {
+        path: '/emp_anc4',
+        grant: Page.GRANT_OWNER,
+        grantedUsers: [dummyUser1._id],
+        creator: dummyUser1,
+        lastUpdateUser: dummyUser1._id,
+        isEmpty: false,
+      },
+      {
+        path: '/get_parent_A',
+        creator: dummyUser1,
+        lastUpdateUser: dummyUser1,
+        parent: null,
+      },
+      {
+        path: '/get_parent_A/get_parent_B',
+        creator: dummyUser1,
+        lastUpdateUser: dummyUser1,
+        parent: null,
+      },
+      {
+        path: '/get_parent_C',
+        creator: dummyUser1,
+        lastUpdateUser: dummyUser1,
+        parent: rootPage._id,
+      },
+      {
+        path: '/get_parent_C/get_parent_D',
+        creator: dummyUser1,
+        lastUpdateUser: dummyUser1,
+        parent: null,
+      },
+    ]);
   });
 
   describe('rename page without using renameDescendantsWithStreamSpy', () => {
@@ -723,4 +815,182 @@ describe('PageService', () => {
     });
   });
 
+  describe('getParentAndFillAncestors', () => {
+    test('return parent if exist', async() => {
+      const page1 = await Page.findOne({ path: '/PAF1' });
+      const parent = await crowi.pageService.getParentAndFillAncestorsByUser(dummyUser1, page1.path);
+      expect(parent).toBeTruthy();
+      expect(page1.parent).toStrictEqual(parent._id);
+    });
+    test('create parent and ancestors when they do not exist, and return the new parent', async() => {
+      const path1 = '/emp_anc1';
+      const path2 = '/emp_anc1/emp_anc2';
+      const path3 = '/emp_anc1/emp_anc2/PAF2';
+      const _page1 = await Page.findOne({ path: path1 }); // not exist
+      const _page2 = await Page.findOne({ path: path2 }); // not exist
+      const _page3 = await Page.findOne({ path: path3 }); // not exist
+      expect(_page1).toBeNull();
+      expect(_page2).toBeNull();
+      expect(_page3).toBeNull();
+
+      const parent = await crowi.pageService.getParentAndFillAncestorsByUser(dummyUser1, path3);
+      const page1 = await Page.findOne({ path: path1 });
+      const page2 = await Page.findOne({ path: path2 });
+      const page3 = await Page.findOne({ path: path3 });
+
+      expect(parent._id).toStrictEqual(page2._id);
+      expect(parent.path).toStrictEqual(page2.path);
+      expect(parent.parent).toStrictEqual(page2.parent);
+
+      expect(parent).toBeTruthy();
+      expect(page1).toBeTruthy();
+      expect(page2).toBeTruthy();
+      expect(page3).toBeNull();
+
+      expect(page1.parent).toStrictEqual(rootPage._id);
+      expect(page2.parent).toStrictEqual(page1._id);
+    });
+    test('return parent even if the parent page is empty', async() => {
+      const path1 = '/emp_anc3';
+      const path2 = '/emp_anc3/PAF3';
+      const _page1 = await Page.findOne({ path: path1, isEmpty: true });
+      const _page2 = await Page.findOne({ path: path2, isEmpty: false });
+      expect(_page1).toBeTruthy();
+      expect(_page2).toBeTruthy();
+
+      const parent = await crowi.pageService.getParentAndFillAncestorsByUser(dummyUser1, _page2.path);
+      const page1 = await Page.findOne({ path: path1, isEmpty: true }); // parent
+      const page2 = await Page.findOne({ path: path2, isEmpty: false });
+
+      // check for the parent (should be the same as page1)
+      expect(parent._id).toStrictEqual(page1._id);
+      expect(parent.path).toStrictEqual(page1.path);
+      expect(parent.parent).toStrictEqual(page1.parent);
+
+      expect(page1.parent).toStrictEqual(rootPage._id);
+      expect(page2.parent).toStrictEqual(page1._id);
+    });
+    test('should find parent while NOT updating private legacy page\'s parent', async() => {
+      const path1 = '/emp_anc4';
+      const path2 = '/emp_anc4/PAF4';
+      const _page1 = await Page.findOne({ path: path1, isEmpty: true, grant: Page.GRANT_PUBLIC });
+      const _page2 = await Page.findOne({ path: path2, isEmpty: false, grant: Page.GRANT_PUBLIC });
+      const _page3 = await Page.findOne({ path: path1, isEmpty: false, grant: Page.GRANT_OWNER });
+      expect(_page1).toBeTruthy();
+      expect(_page2).toBeTruthy();
+      expect(_page3).toBeTruthy();
+      expect(_page3.parent).toBeNull();
+
+      const parent = await crowi.pageService.getParentAndFillAncestorsByUser(dummyUser1, _page2.path);
+      const page1 = await Page.findOne({ path: path1, isEmpty: true, grant: Page.GRANT_PUBLIC });
+      const page2 = await Page.findOne({ path: path2, isEmpty: false, grant: Page.GRANT_PUBLIC });
+      const page3 = await Page.findOne({ path: path1, isEmpty: false, grant: Page.GRANT_OWNER });
+      expect(page1).toBeTruthy();
+      expect(page2).toBeTruthy();
+      expect(page3).toBeTruthy();
+      expect(page3.parent).toBeNull(); // parent property of page in private legacy pages should be null
+
+      expect(page1._id).toStrictEqual(parent._id);
+      expect(page2.parent).toStrictEqual(parent._id);
+
+    });
+    test('should find parent while NOT creating unnecessary empty pages with all v4 public pages', async() => {
+      // All pages does not have parent (v4 schema)
+      const _pageA = await Page.findOne({
+        path: '/get_parent_A',
+        grant: Page.GRANT_PUBLIC,
+        isEmpty: false,
+        parent: null,
+      });
+      const _pageAB = await Page.findOne({
+        path: '/get_parent_A/get_parent_B',
+        grant: Page.GRANT_PUBLIC,
+        isEmpty: false,
+        parent: null,
+      });
+      const _emptyA = await Page.findOne({
+        path: '/get_parent_A',
+        grant: Page.GRANT_PUBLIC,
+        isEmpty: true,
+      });
+      const _emptyAB = await Page.findOne({
+        path: '/get_parent_A/get_parent_B',
+        grant: Page.GRANT_PUBLIC,
+        isEmpty: true,
+      });
+
+      expect(_pageA).not.toBeNull();
+      expect(_pageAB).not.toBeNull();
+      expect(_emptyA).toBeNull();
+      expect(_emptyAB).toBeNull();
+
+      const parent = await crowi.pageService.getParentAndFillAncestorsByUser(dummyUser1, '/get_parent_A/get_parent_B/get_parent_C');
+
+      const pageA = await Page.findOne({ path: '/get_parent_A', grant: Page.GRANT_PUBLIC, isEmpty: false });
+      const pageAB = await Page.findOne({ path: '/get_parent_A/get_parent_B', grant: Page.GRANT_PUBLIC, isEmpty: false });
+      const emptyA = await Page.findOne({ path: '/get_parent_A', grant: Page.GRANT_PUBLIC, isEmpty: true });
+      const emptyAB = await Page.findOne({ path: '/get_parent_A/get_parent_B', grant: Page.GRANT_PUBLIC, isEmpty: true });
+
+      // -- Check existance
+      expect(parent).not.toBeNull();
+      expect(pageA).not.toBeNull();
+      expect(pageAB).not.toBeNull();
+      expect(emptyA).toBeNull();
+      expect(emptyAB).toBeNull();
+
+      // -- Check parent
+      expect(pageA.parent).not.toBeNull();
+      expect(pageAB.parent).not.toBeNull();
+    });
+    test('should find parent while NOT creating unnecessary empty pages with some v5 public pages', async() => {
+      const _pageC = await Page.findOne({
+        path: '/get_parent_C',
+        grant: Page.GRANT_PUBLIC,
+        isEmpty: false,
+        parent: { $ne: null },
+      });
+      const _pageCD = await Page.findOne({
+        path: '/get_parent_C/get_parent_D',
+        grant: Page.GRANT_PUBLIC,
+        isEmpty: false,
+      });
+      const _emptyC = await Page.findOne({
+        path: '/get_parent_C',
+        grant: Page.GRANT_PUBLIC,
+        isEmpty: true,
+      });
+      const _emptyCD = await Page.findOne({
+        path: '/get_parent_C/get_parent_D',
+        grant: Page.GRANT_PUBLIC,
+        isEmpty: true,
+      });
+
+      expect(_pageC).not.toBeNull();
+      expect(_pageCD).not.toBeNull();
+      expect(_emptyC).toBeNull();
+      expect(_emptyCD).toBeNull();
+
+      const parent = await crowi.pageService.getParentAndFillAncestorsByUser(dummyUser1, '/get_parent_C/get_parent_D/get_parent_E');
+
+      const pageC = await Page.findOne({ path: '/get_parent_C', grant: Page.GRANT_PUBLIC, isEmpty: false });
+      const pageCD = await Page.findOne({ path: '/get_parent_C/get_parent_D', grant: Page.GRANT_PUBLIC, isEmpty: false });
+      const emptyC = await Page.findOne({ path: '/get_parent_C', grant: Page.GRANT_PUBLIC, isEmpty: true });
+      const emptyCD = await Page.findOne({ path: '/get_parent_C/get_parent_D', grant: Page.GRANT_PUBLIC, isEmpty: true });
+
+      // -- Check existance
+      expect(parent).not.toBeNull();
+      expect(pageC).not.toBeNull();
+      expect(pageCD).not.toBeNull();
+      expect(emptyC).toBeNull();
+      expect(emptyCD).toBeNull();
+
+      // -- Check parent attribute
+      expect(pageC.parent).toStrictEqual(rootPage._id);
+      expect(pageCD.parent).toStrictEqual(pageC._id);
+
+      // -- Check the found parent
+      expect(parent.toObject()).toStrictEqual(pageCD.toObject());
+    });
+  });
+
 });

+ 242 - 0
packages/app/test/integration/service/v5.non-public-page.test.ts

@@ -201,6 +201,118 @@ describe('PageService page operations with non-public pages', () => {
       rootPage = pages[0];
     }
 
+    /**
+     * create
+     * mc_ => model create
+     * emp => empty => page with isEmpty: true
+     * pub => public => GRANT_PUBLIC
+     */
+    const pageIdCreate1 = new mongoose.Types.ObjectId();
+    const pageIdCreate2 = new mongoose.Types.ObjectId();
+    const pageIdCreate3 = new mongoose.Types.ObjectId();
+    await Page.insertMany([
+      {
+        _id: pageIdCreate1,
+        path: '/mc4_top/mc1_emp',
+        grant: Page.GRANT_PUBLIC,
+        creator: dummyUser1,
+        lastUpdateUser: dummyUser1._id,
+        parent: rootPage._id,
+        isEmpty: true,
+      },
+      {
+        path: '/mc4_top/mc1_emp/mc2_pub',
+        grant: Page.GRANT_PUBLIC,
+        creator: dummyUser1,
+        lastUpdateUser: dummyUser1._id,
+        parent: pageIdCreate1,
+        isEmpty: false,
+      },
+      {
+        path: '/mc5_top/mc3_awl',
+        grant: Page.GRANT_RESTRICTED,
+        creator: dummyUser1,
+        lastUpdateUser: dummyUser1._id,
+        isEmpty: false,
+      },
+      {
+        _id: pageIdCreate2,
+        path: '/mc4_top',
+        grant: Page.GRANT_PUBLIC,
+        creator: dummyUser1,
+        lastUpdateUser: dummyUser1._id,
+        isEmpty: false,
+        parent: rootPage._id,
+        descendantCount: 1,
+      },
+      {
+        _id: pageIdCreate3,
+        path: '/mc5_top',
+        grant: Page.GRANT_PUBLIC,
+        creator: dummyUser1,
+        lastUpdateUser: dummyUser1._id,
+        isEmpty: false,
+        parent: rootPage._id,
+        descendantCount: 0,
+      },
+    ]);
+
+    /**
+     * create
+     * mc_ => model create
+     * emp => empty => page with isEmpty: true
+     * pub => public => GRANT_PUBLIC
+     */
+    const pageIdCreateBySystem1 = new mongoose.Types.ObjectId();
+    const pageIdCreateBySystem2 = new mongoose.Types.ObjectId();
+    const pageIdCreateBySystem3 = new mongoose.Types.ObjectId();
+    await Page.insertMany([
+      {
+        _id: pageIdCreateBySystem1,
+        path: '/mc4_top_by_system/mc1_emp_by_system',
+        grant: Page.GRANT_PUBLIC,
+        creator: dummyUser1,
+        lastUpdateUser: dummyUser1._id,
+        parent: rootPage._id,
+        isEmpty: true,
+      },
+      {
+        path: '/mc4_top_by_system/mc1_emp_by_system/mc2_pub_by_system',
+        grant: Page.GRANT_PUBLIC,
+        creator: dummyUser1,
+        lastUpdateUser: dummyUser1._id,
+        parent: pageIdCreateBySystem1,
+        isEmpty: false,
+      },
+      {
+        path: '/mc5_top_by_system/mc3_awl_by_system',
+        grant: Page.GRANT_RESTRICTED,
+        creator: dummyUser1,
+        lastUpdateUser: dummyUser1._id,
+        isEmpty: false,
+      },
+      {
+        _id: pageIdCreateBySystem2,
+        path: '/mc4_top_by_system',
+        grant: Page.GRANT_PUBLIC,
+        creator: dummyUser1,
+        lastUpdateUser: dummyUser1._id,
+        isEmpty: false,
+        parent: rootPage._id,
+        descendantCount: 1,
+      },
+      {
+        _id: pageIdCreateBySystem3,
+        path: '/mc5_top_by_system',
+        grant: Page.GRANT_PUBLIC,
+        creator: dummyUser1,
+        lastUpdateUser: dummyUser1._id,
+        isEmpty: false,
+        parent: rootPage._id,
+        descendantCount: 0,
+      },
+    ]);
+
     /*
      * Rename
      */
@@ -616,6 +728,136 @@ describe('PageService page operations with non-public pages', () => {
     ]);
   });
 
+  describe('create', () => {
+
+    describe('Creating a page using existing path', () => {
+      test('with grant RESTRICTED should only create the page and change nothing else', async() => {
+        const isGrantNormalizedSpy = jest.spyOn(crowi.pageGrantService, 'isGrantNormalized');
+        const pathT = '/mc4_top';
+        const path1 = '/mc4_top/mc1_emp';
+        const path2 = '/mc4_top/mc1_emp/mc2_pub';
+        const pageT = await Page.findOne({ path: pathT, descendantCount: 1 });
+        const page1 = await Page.findOne({ path: path1, grant: Page.GRANT_PUBLIC });
+        const page2 = await Page.findOne({ path: path2 });
+        const page3 = await Page.findOne({ path: path1, grant: Page.GRANT_RESTRICTED });
+        expect(pageT).toBeTruthy();
+        expect(page1).toBeTruthy();
+        expect(page2).toBeTruthy();
+        expect(page3).toBeNull();
+
+        // use existing path
+        await crowi.pageService.create(path1, 'new body', dummyUser1, { grant: Page.GRANT_RESTRICTED });
+
+        const _pageT = await Page.findOne({ path: pathT });
+        const _page1 = await Page.findOne({ path: path1, grant: Page.GRANT_PUBLIC });
+        const _page2 = await Page.findOne({ path: path2 });
+        const _page3 = await Page.findOne({ path: path1, grant: Page.GRANT_RESTRICTED });
+        expect(_pageT).toBeTruthy();
+        expect(_page1).toBeTruthy();
+        expect(_page2).toBeTruthy();
+        expect(_page3).toBeTruthy();
+        expect(_pageT.descendantCount).toBe(1);
+        // isGrantNormalized is not called when GRANT RESTRICTED
+        expect(isGrantNormalizedSpy).toBeCalledTimes(0);
+      });
+    });
+    describe('Creating a page under a page with grant RESTRICTED', () => {
+      test('will create a new empty page with the same path as the grant RESTRECTED page and become a parent', async() => {
+        const isGrantNormalizedSpy = jest.spyOn(crowi.pageGrantService, 'isGrantNormalized');
+        const pathT = '/mc5_top';
+        const path1 = '/mc5_top/mc3_awl';
+        const pathN = '/mc5_top/mc3_awl/mc4_pub'; // used to create
+        const pageT = await Page.findOne({ path: pathT });
+        const page1 = await Page.findOne({ path: path1, grant: Page.GRANT_RESTRICTED });
+        const page2 = await Page.findOne({ path: path1, grant: Page.GRANT_PUBLIC });
+        expect(pageT).toBeTruthy();
+        expect(page1).toBeTruthy();
+        expect(page2).toBeNull();
+
+        await crowi.pageService.create(pathN, 'new body', dummyUser1, { grant: Page.GRANT_PUBLIC });
+
+        const _pageT = await Page.findOne({ path: pathT });
+        const _page1 = await Page.findOne({ path: path1, grant: Page.GRANT_RESTRICTED });
+        const _page2 = await Page.findOne({ path: path1, grant: Page.GRANT_PUBLIC, isEmpty: true });
+        const _pageN = await Page.findOne({ path: pathN, grant: Page.GRANT_PUBLIC }); // newly crated
+        expect(_pageT).toBeTruthy();
+        expect(_page1).toBeTruthy();
+        expect(_page2).toBeTruthy();
+        expect(_pageN).toBeTruthy();
+        expect(_pageN.parent).toStrictEqual(_page2._id);
+        expect(_pageT.descendantCount).toStrictEqual(1);
+        // isGrantNormalized is called when GRANT PUBLIC
+        expect(isGrantNormalizedSpy).toBeCalledTimes(1);
+      });
+    });
+
+  });
+
+  describe('create by system', () => {
+
+    describe('Creating a page using existing path', () => {
+      test('with grant RESTRICTED should only create the page and change nothing else', async() => {
+        const isGrantNormalizedSpy = jest.spyOn(crowi.pageGrantService, 'isGrantNormalized');
+        const pathT = '/mc4_top_by_system';
+        const path1 = '/mc4_top_by_system/mc1_emp_by_system';
+        const path2 = '/mc4_top_by_system/mc1_emp_by_system/mc2_pub_by_system';
+        const pageT = await Page.findOne({ path: pathT, descendantCount: 1 });
+        const page1 = await Page.findOne({ path: path1, grant: Page.GRANT_PUBLIC });
+        const page2 = await Page.findOne({ path: path2 });
+        const page3 = await Page.findOne({ path: path1, grant: Page.GRANT_RESTRICTED });
+        expect(pageT).toBeTruthy();
+        expect(page1).toBeTruthy();
+        expect(page2).toBeTruthy();
+        expect(page3).toBeNull();
+
+        // use existing path
+        await crowi.pageService.forceCreateBySystem(path1, 'new body', { grant: Page.GRANT_RESTRICTED });
+
+        const _pageT = await Page.findOne({ path: pathT });
+        const _page1 = await Page.findOne({ path: path1, grant: Page.GRANT_PUBLIC });
+        const _page2 = await Page.findOne({ path: path2 });
+        const _page3 = await Page.findOne({ path: path1, grant: Page.GRANT_RESTRICTED });
+        expect(_pageT).toBeTruthy();
+        expect(_page1).toBeTruthy();
+        expect(_page2).toBeTruthy();
+        expect(_page3).toBeTruthy();
+        expect(_pageT.descendantCount).toBe(1);
+        // isGrantNormalized is not called when create by ststem
+        expect(isGrantNormalizedSpy).toBeCalledTimes(0);
+      });
+    });
+    describe('Creating a page under a page with grant RESTRICTED', () => {
+      test('will create a new empty page with the same path as the grant RESTRECTED page and become a parent', async() => {
+        const isGrantNormalizedSpy = jest.spyOn(crowi.pageGrantService, 'isGrantNormalized');
+        const pathT = '/mc5_top_by_system';
+        const path1 = '/mc5_top_by_system/mc3_awl_by_system';
+        const pathN = '/mc5_top_by_system/mc3_awl_by_system/mc4_pub_by_system'; // used to create
+        const pageT = await Page.findOne({ path: pathT });
+        const page1 = await Page.findOne({ path: path1, grant: Page.GRANT_RESTRICTED });
+        const page2 = await Page.findOne({ path: path1, grant: Page.GRANT_PUBLIC });
+        expect(pageT).toBeTruthy();
+        expect(page1).toBeTruthy();
+        expect(page2).toBeNull();
+
+        await crowi.pageService.forceCreateBySystem(pathN, 'new body', { grant: Page.GRANT_PUBLIC });
+
+        const _pageT = await Page.findOne({ path: pathT });
+        const _page1 = await Page.findOne({ path: path1, grant: Page.GRANT_RESTRICTED });
+        const _page2 = await Page.findOne({ path: path1, grant: Page.GRANT_PUBLIC, isEmpty: true });
+        const _pageN = await Page.findOne({ path: pathN, grant: Page.GRANT_PUBLIC }); // newly crated
+        expect(_pageT).toBeTruthy();
+        expect(_page1).toBeTruthy();
+        expect(_page2).toBeTruthy();
+        expect(_pageN).toBeTruthy();
+        expect(_pageN.parent).toStrictEqual(_page2._id);
+        expect(_pageT.descendantCount).toStrictEqual(1);
+        // isGrantNormalized is not called when create by ststem
+        expect(isGrantNormalizedSpy).toBeCalledTimes(0);
+      });
+    });
+
+  });
+
   describe('Rename', () => {
     const renamePage = async(page, newPagePath, user, options) => {
       // mock return value

+ 861 - 0
packages/app/test/integration/service/v5.page.test.ts

@@ -0,0 +1,861 @@
+import { addSeconds } from 'date-fns';
+import mongoose from 'mongoose';
+
+import { PageActionStage, PageActionType } from '../../../src/server/models/page-operation';
+import { getInstance } from '../setup-crowi';
+
+
+describe('Test page service methods', () => {
+  let crowi;
+  let Page;
+  let Revision;
+  let User;
+  let UserGroup;
+  let UserGroupRelation;
+  let Tag;
+  let PageTagRelation;
+  let Bookmark;
+  let Comment;
+  let ShareLink;
+  let PageRedirect;
+  let PageOperation;
+  let xssSpy;
+
+  let rootPage;
+
+  let dummyUser1;
+  let dummyUser2;
+  let globalGroupUser1;
+  let globalGroupUser2;
+  let globalGroupUser3;
+  let globalGroupIsolate;
+  let globalGroupA;
+  let globalGroupB;
+  let globalGroupC;
+
+  let pageOpId1;
+  let pageOpId2;
+  let pageOpId3;
+  let pageOpId4;
+  let pageOpId5;
+  let pageOpId6;
+
+  beforeAll(async() => {
+    crowi = await getInstance();
+    await crowi.configManager.updateConfigsInTheSameNamespace('crowi', { 'app:isV5Compatible': true });
+
+    User = mongoose.model('User');
+    UserGroup = mongoose.model('UserGroup');
+    UserGroupRelation = mongoose.model('UserGroupRelation');
+    Page = mongoose.model('Page');
+    Revision = mongoose.model('Revision');
+    Tag = mongoose.model('Tag');
+    PageTagRelation = mongoose.model('PageTagRelation');
+    Bookmark = mongoose.model('Bookmark');
+    Comment = mongoose.model('Comment');
+    ShareLink = mongoose.model('ShareLink');
+    PageRedirect = mongoose.model('PageRedirect');
+    UserGroup = mongoose.model('UserGroup');
+    UserGroupRelation = mongoose.model('UserGroupRelation');
+    PageOperation = mongoose.model('PageOperation');
+
+    /*
+     * Common
+     */
+    xssSpy = jest.spyOn(crowi.xss, 'process').mockImplementation(path => path);
+
+    // ***********************************************************************************************************
+    // * Do NOT change properties of globally used documents. Otherwise, it might cause some errors in other tests
+    // ***********************************************************************************************************
+    // users
+    dummyUser1 = await User.findOne({ username: 'v5DummyUser1' });
+    dummyUser2 = await User.findOne({ username: 'v5DummyUser2' });
+    globalGroupUser1 = await User.findOne({ username: 'gGroupUser1' });
+    globalGroupUser2 = await User.findOne({ username: 'gGroupUser2' });
+    globalGroupUser3 = await User.findOne({ username: 'gGroupUser3' });
+    // groups
+    globalGroupIsolate = await UserGroup.findOne({ name: 'globalGroupIsolate' });
+    globalGroupA = await UserGroup.findOne({ name: 'globalGroupA' });
+    globalGroupB = await UserGroup.findOne({ name: 'globalGroupB' });
+    globalGroupC = await UserGroup.findOne({ name: 'globalGroupC' });
+    // page
+    rootPage = await Page.findOne({ path: '/' });
+
+
+    /**
+     * pages
+     */
+    const pageId0 = new mongoose.Types.ObjectId();
+    const pageId1 = new mongoose.Types.ObjectId();
+    const pageId2 = new mongoose.Types.ObjectId();
+    const pageId3 = new mongoose.Types.ObjectId();
+    const pageId4 = new mongoose.Types.ObjectId();
+    const pageId5 = new mongoose.Types.ObjectId();
+    const pageId6 = new mongoose.Types.ObjectId();
+    const pageId7 = new mongoose.Types.ObjectId();
+    const pageId8 = new mongoose.Types.ObjectId();
+    const pageId9 = new mongoose.Types.ObjectId();
+    const pageId10 = new mongoose.Types.ObjectId();
+    const pageId11 = new mongoose.Types.ObjectId();
+    const pageId12 = new mongoose.Types.ObjectId();
+    const pageId13 = new mongoose.Types.ObjectId();
+    const pageId14 = new mongoose.Types.ObjectId();
+    const pageId15 = new mongoose.Types.ObjectId();
+    const pageId16 = new mongoose.Types.ObjectId();
+    const pageId17 = new mongoose.Types.ObjectId();
+
+    await Page.insertMany([
+      {
+        _id: pageId0,
+        path: '/resume_rename_0',
+        parent: rootPage._id,
+        grant: Page.GRANT_PUBLIC,
+        creator: dummyUser1,
+        lastUpdateUser: dummyUser1._id,
+        descendantCount: 1,
+        isEmpty: false,
+      },
+      {
+        _id: pageId1,
+        path: '/resume_rename_0/resume_rename_1',
+        parent: pageId0,
+        grant: Page.GRANT_PUBLIC,
+        creator: dummyUser1,
+        lastUpdateUser: dummyUser1._id,
+        descendantCount: 2,
+        isEmpty: false,
+      },
+      {
+        _id: pageId2,
+        path: '/resume_rename_1/resume_rename_2',
+        parent: pageId1,
+        grant: Page.GRANT_PUBLIC,
+        creator: dummyUser1,
+        lastUpdateUser: dummyUser1._id,
+        descendantCount: 1,
+        isEmpty: false,
+      },
+      {
+        _id: pageId3,
+        path: '/resume_rename_1/resume_rename_2/resume_rename_3',
+        parent: pageId2,
+        grant: Page.GRANT_PUBLIC,
+        creator: dummyUser1,
+        lastUpdateUser: dummyUser1._id,
+        descendantCount: 0,
+        isEmpty: false,
+      },
+      {
+        _id: pageId4,
+        path: '/resume_rename_4',
+        parent: rootPage._id,
+        grant: Page.GRANT_PUBLIC,
+        creator: dummyUser1,
+        lastUpdateUser: dummyUser1._id,
+        descendantCount: 0,
+        isEmpty: false,
+      },
+      {
+        _id: pageId5,
+        path: '/resume_rename_4/resume_rename_5',
+        parent: pageId0,
+        grant: Page.GRANT_PUBLIC,
+        creator: dummyUser1,
+        lastUpdateUser: dummyUser1._id,
+        descendantCount: 1,
+        isEmpty: false,
+      },
+      {
+        _id: pageId6,
+        path: '/resume_rename_5/resume_rename_6',
+        parent: pageId5,
+        grant: Page.GRANT_PUBLIC,
+        creator: dummyUser1,
+        lastUpdateUser: dummyUser1._id,
+        descendantCount: 0,
+        isEmpty: false,
+      },
+      {
+        _id: pageId7,
+        path: '/resume_rename_7',
+        parent: rootPage._id,
+        grant: Page.GRANT_PUBLIC,
+        creator: dummyUser1,
+        lastUpdateUser: dummyUser1._id,
+        descendantCount: 0,
+        isEmpty: false,
+      },
+      {
+        _id: pageId8,
+        path: '/resume_rename_8',
+        parent: rootPage._id,
+        grant: Page.GRANT_PUBLIC,
+        creator: dummyUser1,
+        lastUpdateUser: dummyUser1._id,
+        descendantCount: 1,
+        isEmpty: false,
+      },
+      {
+        _id: pageId9,
+        path: '/resume_rename_8/resume_rename_9',
+        parent: pageId8,
+        grant: Page.GRANT_PUBLIC,
+        creator: dummyUser1,
+        lastUpdateUser: dummyUser1._id,
+        descendantCount: 1,
+        isEmpty: false,
+      },
+      {
+        path: '/resume_rename_9/resume_rename_10',
+        parent: pageId9,
+        grant: Page.GRANT_PUBLIC,
+        creator: dummyUser1,
+        lastUpdateUser: dummyUser1._id,
+        descendantCount: 0,
+        isEmpty: false,
+      },
+      {
+        _id: pageId10,
+        path: '/resume_rename_11',
+        parent: rootPage._id,
+        grant: Page.GRANT_PUBLIC,
+        creator: dummyUser1,
+        lastUpdateUser: dummyUser1._id,
+        descendantCount: 3,
+        isEmpty: false,
+      },
+      {
+        _id: pageId11,
+        path: '/resume_rename_11/resume_rename_12',
+        parent: pageId10,
+        grant: Page.GRANT_PUBLIC,
+        creator: dummyUser1,
+        lastUpdateUser: dummyUser1._id,
+        descendantCount: 2,
+        isEmpty: false,
+      },
+      {
+        _id: pageId12,
+        path: '/resume_rename_11/resume_rename_12/resume_rename_13',
+        parent: pageId11,
+        grant: Page.GRANT_PUBLIC,
+        creator: dummyUser1,
+        lastUpdateUser: dummyUser1._id,
+        descendantCount: 1,
+        isEmpty: false,
+      },
+      {
+        path: '/resume_rename_11/resume_rename_12/resume_rename_13/resume_rename_14',
+        parent: pageId12,
+        grant: Page.GRANT_PUBLIC,
+        creator: dummyUser1,
+        lastUpdateUser: dummyUser1._id,
+        descendantCount: 0,
+        isEmpty: false,
+      },
+      {
+        _id: pageId13,
+        path: '/resume_rename_15',
+        parent: rootPage._id,
+        grant: Page.GRANT_PUBLIC,
+        creator: dummyUser1,
+        lastUpdateUser: dummyUser1._id,
+        descendantCount: 2,
+        isEmpty: false,
+      },
+      {
+        _id: pageId14,
+        path: '/resume_rename_15/resume_rename_16',
+        parent: pageId13,
+        grant: Page.GRANT_PUBLIC,
+        creator: dummyUser1,
+        lastUpdateUser: dummyUser1._id,
+        descendantCount: 0,
+        isEmpty: false,
+      },
+      {
+        _id: pageId15,
+        path: '/resume_rename_15/resume_rename_17',
+        parent: pageId13,
+        grant: Page.GRANT_PUBLIC,
+        creator: dummyUser1,
+        lastUpdateUser: dummyUser1._id,
+        descendantCount: 1,
+        isEmpty: false,
+      },
+      {
+        _id: pageId16,
+        path: '/resume_rename_15/resume_rename_17/resume_rename_18',
+        parent: pageId15,
+        grant: Page.GRANT_PUBLIC,
+        creator: dummyUser1,
+        lastUpdateUser: dummyUser1._id,
+        descendantCount: 1,
+        isEmpty: false,
+      },
+      {
+        _id: pageId17,
+        path: '/resume_rename_15/resume_rename_17/resume_rename_18/resume_rename_19',
+        parent: pageId16,
+        grant: Page.GRANT_PUBLIC,
+        creator: dummyUser1,
+        lastUpdateUser: dummyUser1._id,
+        descendantCount: 0,
+        isEmpty: false,
+      },
+    ]);
+
+    /**
+     * PageOperation
+     */
+    pageOpId1 = new mongoose.Types.ObjectId();
+    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([
+      {
+        _id: pageOpId1,
+        actionType: 'Rename',
+        actionStage: 'Sub',
+        fromPath: '/resume_rename_1',
+        toPath: '/resume_rename_0/resume_rename_1',
+        page: {
+          _id: pageId1,
+          parent: rootPage._id,
+          descendantCount: 2,
+          isEmpty: false,
+          path: '/resume_rename_1',
+          revision: pageOpRevisionId1,
+          status: 'published',
+          grant: 1,
+          grantedUsers: [],
+          grantedGroup: null,
+          creator: dummyUser1._id,
+          lastUpdateUser: dummyUser1._id,
+        },
+        user: {
+          _id: dummyUser1._id,
+        },
+        options: {
+          createRedirectPage: false,
+          updateMetadata: true,
+        },
+        unprocessableExpiryDate: null,
+      },
+      {
+        _id: pageOpId2,
+        actionType: 'Rename',
+        actionStage: 'Sub',
+        fromPath: '/resume_rename_5',
+        toPath: '/resume_rename_4/resume_rename_5',
+        page: {
+          _id: pageId5,
+          parent: rootPage._id,
+          descendantCount: 2,
+          isEmpty: false,
+          path: '/resume_rename_5',
+          revision: pageOpRevisionId2,
+          status: 'published',
+          grant: 1,
+          grantedUsers: [],
+          grantedGroup: null,
+          creator: dummyUser1._id,
+          lastUpdateUser: dummyUser1._id,
+        },
+        user: {
+          _id: dummyUser1._id,
+        },
+        options: {
+          createRedirectPage: false,
+          updateMetadata: true,
+        },
+        unprocessableExpiryDate: new Date(),
+      },
+      {
+        _id: pageOpId3,
+        actionType: 'Rename',
+        actionStage: 'Sub',
+        fromPath: '/resume_rename_7',
+        // toPath NOT exist
+        page: {
+          _id: pageId7,
+          parent: rootPage._id,
+          descendantCount: 2,
+          isEmpty: false,
+          path: '/resume_rename_7',
+          revision: pageOpRevisionId3,
+          status: 'published',
+          grant: 1,
+          grantedUsers: [],
+          grantedGroup: null,
+          creator: dummyUser1._id,
+          lastUpdateUser: dummyUser1._id,
+        },
+        user: {
+          _id: dummyUser1._id,
+        },
+        options: {
+          createRedirectPage: false,
+          updateMetadata: true,
+        },
+        unprocessableExpiryDate: new Date(),
+      },
+      {
+        _id: pageOpId4,
+        actionType: 'Rename',
+        actionStage: 'Sub',
+        fromPath: '/resume_rename_9',
+        toPath: '/resume_rename_8/resume_rename_9',
+        page: {
+          _id: pageId9,
+          parent: rootPage._id,
+          descendantCount: 1,
+          isEmpty: false,
+          path: '/resume_rename_9',
+          revision: pageOpRevisionId4,
+          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: 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 argsForRenameSubOperation = mockedRenameSubOperation.mock.calls[0];
+
+      mockedRenameSubOperation.mockRestore();
+      await crowi.pageService.renameSubOperation(...argsForRenameSubOperation);
+    };
+
+    test('it should successfully restart rename operation', async() => {
+      // paths before renaming
+      const _path0 = '/resume_rename_0'; // out of renaming scope
+      const _path1 = '/resume_rename_0/resume_rename_1'; // renamed already
+      const _path2 = '/resume_rename_1/resume_rename_2'; // not renamed yet
+      const _path3 = '/resume_rename_1/resume_rename_2/resume_rename_3'; // not renamed yet
+
+      // paths after renaming
+      const path0 = '/resume_rename_0';
+      const path1 = '/resume_rename_0/resume_rename_1';
+      const path2 = '/resume_rename_0/resume_rename_1/resume_rename_2';
+      const path3 = '/resume_rename_0/resume_rename_1/resume_rename_2/resume_rename_3';
+
+      // 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();
+
+      expect(_page0.descendantCount).toBe(1);
+      expect(_page1.descendantCount).toBe(2);
+      expect(_page2.descendantCount).toBe(1);
+      expect(_page3.descendantCount).toBe(0);
+
+      // page operation
+      const fromPath = '/resume_rename_1';
+      const toPath = '/resume_rename_0/resume_rename_1';
+      const _pageOperation = await PageOperation.findOne({
+        _id: pageOpId1, fromPath, toPath, 'page._id': _page1._id, actionType: PageActionType.Rename, actionStage: PageActionStage.Sub,
+      });
+      expect(_pageOperation).toBeTruthy();
+
+      // rename
+      await resumeRenameSubOperation(_page1);
+
+      // 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();
+      // check paths after renaming
+      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
+
+      expect(page0.descendantCount).toBe(3);
+      expect(page1.descendantCount).toBe(2);
+      expect(page2.descendantCount).toBe(1);
+      expect(page3.descendantCount).toBe(0);
+    });
+    test('it should successfully restart rename operation when unprocessableExpiryDate is null', async() => {
+      // paths before renaming
+      const _path0 = '/resume_rename_8'; // out of renaming scope
+      const _path1 = '/resume_rename_8/resume_rename_9'; // renamed already
+      const _path2 = '/resume_rename_9/resume_rename_10'; // not renamed yet
+
+      // paths after renaming
+      const path0 = '/resume_rename_8';
+      const path1 = '/resume_rename_8/resume_rename_9';
+      const path2 = '/resume_rename_8/resume_rename_9/resume_rename_10';
+
+      // page
+      const _page0 = await Page.findOne({ path: _path0 });
+      const _page1 = await Page.findOne({ path: _path1 });
+      const _page2 = await Page.findOne({ path: _path2 });
+      expect(_page0).toBeTruthy();
+      expect(_page1).toBeTruthy();
+      expect(_page2).toBeTruthy();
+
+      expect(_page0.descendantCount).toBe(1);
+      expect(_page1.descendantCount).toBe(1);
+      expect(_page2.descendantCount).toBe(0);
+
+      // page operation
+      const fromPath = '/resume_rename_9';
+      const toPath = '/resume_rename_8/resume_rename_9';
+      const _pageOperation = await PageOperation.findOne({
+        _id: pageOpId4, fromPath, toPath, 'page._id': _page1._id, actionType: PageActionType.Rename, actionStage: PageActionStage.Sub,
+      });
+      expect(_pageOperation).toBeTruthy();
+
+      // rename
+      await resumeRenameSubOperation(_page1);
+
+      // page
+      const page0 = await Page.findById(_page0._id);
+      const page1 = await Page.findById(_page1._id);
+      const page2 = await Page.findById(_page2._id);
+      expect(page0).toBeTruthy();
+      expect(page1).toBeTruthy();
+      expect(page2).toBeTruthy();
+      // check paths after renaming
+      expect(page0.path).toBe(path0);
+      expect(page1.path).toBe(path1);
+      expect(page2.path).toBe(path2);
+
+      // page operation
+      const pageOperation = await PageOperation.findById(_pageOperation._id);
+      expect(pageOperation).toBeNull(); // should not exist
+
+      // others
+      expect(page1.parent).toStrictEqual(page0._id);
+      expect(page2.parent).toStrictEqual(page1._id);
+      expect(page0.descendantCount).toBe(2);
+      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
+      const _path1 = '/resume_rename_4/resume_rename_5'; // renamed already
+      const _path2 = '/resume_rename_5/resume_rename_6'; // not renamed yet
+      // page
+      const _page0 = await Page.findOne({ path: _path0 });
+      const _page1 = await Page.findOne({ path: _path1 });
+      const _page2 = await Page.findOne({ path: _path2 });
+      expect(_page0).toBeTruthy();
+      expect(_page1).toBeTruthy();
+      expect(_page2).toBeTruthy();
+
+      // page operation
+      const fromPath = '/resume_rename_5';
+      const toPath = '/resume_rename_4/resume_rename_5';
+      const pageOperation = await PageOperation.findOne({
+        _id: pageOpId2, fromPath, toPath, 'page._id': _page1._id, actionType: PageActionType.Rename, actionStage: PageActionStage.Sub,
+      });
+      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) });
+
+      await expect(resumeRenameSubOperation(_page1)).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';
+      const _page1 = await Page.findOne({ path: _path1 });
+      expect(_page1).toBeTruthy();
+
+      // page operation
+      const pageOperation = await PageOperation.findOne({
+        _id: pageOpId3, 'page._id': _page1._id, actionType: PageActionType.Rename, actionStage: PageActionStage.Sub,
+      });
+      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})`));
+
+      // 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';
+
+      // 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();
+      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);
+
+      // 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();
+      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);
+    });
+  });
+});

+ 407 - 0
packages/app/test/integration/service/v5.public-page.test.ts

@@ -2,6 +2,7 @@
 import { advanceTo } from 'jest-date-mock';
 import mongoose from 'mongoose';
 
+import { PageActionType, PageActionStage } from '../../../src/server/models/page-operation';
 import Tag from '../../../src/server/models/tag';
 import { getInstance } from '../setup-crowi';
 
@@ -19,10 +20,14 @@ describe('PageService page operations with only public pages', () => {
   let Comment;
   let ShareLink;
   let PageRedirect;
+  let PageOperation;
   let xssSpy;
 
   let rootPage;
 
+  // page operation ids
+  let pageOpId1;
+
   beforeAll(async() => {
     crowi = await getInstance();
     await crowi.configManager.updateConfigsInTheSameNamespace('crowi', { 'app:isV5Compatible': true });
@@ -35,6 +40,7 @@ describe('PageService page operations with only public pages', () => {
     Comment = mongoose.model('Comment');
     ShareLink = mongoose.model('ShareLink');
     PageRedirect = mongoose.model('PageRedirect');
+    PageOperation = mongoose.model('PageOperation');
 
     /*
      * Common
@@ -51,6 +57,56 @@ describe('PageService page operations with only public pages', () => {
       rootPage = pages[0];
     }
 
+    /**
+     * create
+     * mc_ => model create
+     * emp => empty => page with isEmpty: true
+     * pub => public => GRANT_PUBLIC
+     */
+    const pageIdCreate1 = new mongoose.Types.ObjectId();
+    await Page.insertMany([
+      {
+        _id: pageIdCreate1,
+        path: '/v5_empty_create_4',
+        grant: Page.GRANT_PUBLIC,
+        parent: rootPage._id,
+        isEmpty: true,
+      },
+      {
+        path: '/v5_empty_create_4/v5_create_5',
+        grant: Page.GRANT_PUBLIC,
+        creator: dummyUser1,
+        lastUpdateUser: dummyUser1._id,
+        parent: pageIdCreate1,
+        isEmpty: false,
+      },
+    ]);
+
+    /**
+     * create by system
+     * mc_ => model create
+     * emp => empty => page with isEmpty: true
+     * pub => public => GRANT_PUBLIC
+     */
+    const pageIdCreateBySystem1 = new mongoose.Types.ObjectId();
+    await Page.insertMany([
+      {
+        _id: pageIdCreateBySystem1,
+        path: '/v5_empty_create_by_system4',
+        grant: Page.GRANT_PUBLIC,
+        parent: rootPage._id,
+        isEmpty: true,
+      },
+      {
+        path: '/v5_empty_create_by_system4/v5_create_by_system5',
+        grant: Page.GRANT_PUBLIC,
+        creator: dummyUser1,
+        lastUpdateUser: dummyUser1._id,
+        parent: pageIdCreateBySystem1,
+        isEmpty: false,
+      },
+    ]);
+
     /*
      * Rename
      */
@@ -78,6 +134,17 @@ describe('PageService page operations with only public pages', () => {
     const pageIdForRename21 = new mongoose.Types.ObjectId();
     const pageIdForRename22 = new mongoose.Types.ObjectId();
     const pageIdForRename23 = new mongoose.Types.ObjectId();
+    const pageIdForRename24 = new mongoose.Types.ObjectId();
+    const pageIdForRename25 = new mongoose.Types.ObjectId();
+    const pageIdForRename26 = new mongoose.Types.ObjectId();
+    const pageIdForRename27 = new mongoose.Types.ObjectId();
+    const pageIdForRename28 = new mongoose.Types.ObjectId();
+
+    const pageIdForRename29 = new mongoose.Types.ObjectId();
+    const pageIdForRename30 = new mongoose.Types.ObjectId();
+
+    pageOpId1 = new mongoose.Types.ObjectId();
+    const pageOpRevisionId1 = new mongoose.Types.ObjectId();
 
     // Create Pages
     await Page.insertMany([
@@ -269,6 +336,101 @@ describe('PageService page operations with only public pages', () => {
         lastUpdateUser: dummyUser1._id,
         parent: pageIdForRename22,
       },
+      {
+        _id: pageIdForRename24,
+        path: '/v5_pageForRename24',
+        grant: Page.GRANT_PUBLIC,
+        creator: dummyUser1,
+        lastUpdateUser: dummyUser1._id,
+        parent: rootPage._id,
+        descendantCount: 0,
+      },
+      {
+        _id: pageIdForRename25,
+        path: '/v5_pageForRename25',
+        grant: Page.GRANT_PUBLIC,
+        creator: dummyUser1,
+        lastUpdateUser: dummyUser1._id,
+        parent: rootPage._id,
+        descendantCount: 0,
+      },
+      {
+        _id: pageIdForRename26,
+        path: '/v5_pageForRename26',
+        grant: Page.GRANT_PUBLIC,
+        creator: dummyUser1,
+        lastUpdateUser: dummyUser1._id,
+        parent: rootPage._id,
+        descendantCount: 0,
+      },
+      {
+        _id: pageIdForRename27,
+        path: '/v5_pageForRename27',
+        grant: Page.GRANT_PUBLIC,
+        creator: dummyUser1,
+        lastUpdateUser: dummyUser1._id,
+        parent: rootPage._id,
+        descendantCount: 1,
+      },
+      {
+        _id: pageIdForRename28,
+        path: '/v5_pageForRename27/v5_pageForRename28',
+        grant: Page.GRANT_PUBLIC,
+        creator: dummyUser1,
+        lastUpdateUser: dummyUser1._id,
+        parent: pageIdForRename27,
+        descendantCount: 0,
+      },
+      {
+        _id: pageIdForRename29,
+        path: '/v5_pageForRename29',
+        grant: Page.GRANT_PUBLIC,
+        creator: dummyUser1,
+        lastUpdateUser: dummyUser1._id,
+        parent: rootPage._id,
+        descendantCount: 1,
+      },
+      {
+        _id: pageIdForRename30,
+        path: '/v5_pageForRename29/v5_pageForRename30',
+        grant: Page.GRANT_PUBLIC,
+        creator: dummyUser1,
+        lastUpdateUser: dummyUser1._id,
+        parent: pageIdForRename29,
+        descendantCount: 0,
+      },
+    ]);
+
+    await PageOperation.insertMany([
+      {
+        _id: pageOpId1,
+        actionType: 'Rename',
+        actionStage: 'Sub',
+        fromPath: '/v5_pageForRename30',
+        toPath: '/v5_pageForRename29/v5_pageForRename30',
+        page: {
+          _id: pageIdForRename30,
+          parent: rootPage._id,
+          descendantCount: 0,
+          isEmpty: false,
+          path: '/v5_pageForRename30',
+          revision: pageOpRevisionId1,
+          status: 'published',
+          grant: 1,
+          grantedUsers: [],
+          grantedGroup: null,
+          creator: dummyUser1._id,
+          lastUpdateUser: dummyUser1._id,
+        },
+        user: {
+          _id: dummyUser1._id,
+        },
+        options: {
+          createRedirectPage: false,
+          updateMetadata: true,
+        },
+        unprocessableExpiryDate: null,
+      },
     ]);
 
     /*
@@ -885,6 +1047,96 @@ describe('PageService page operations with only public pages', () => {
 
   });
 
+  describe('create', () => {
+
+    test('Should create single page', async() => {
+      const isGrantNormalizedSpy = jest.spyOn(crowi.pageGrantService, 'isGrantNormalized');
+      const page = await crowi.pageService.create('/v5_create1', 'create1', dummyUser1, {});
+      expect(page).toBeTruthy();
+      expect(page.parent).toStrictEqual(rootPage._id);
+      // isGrantNormalized is called when GRANT PUBLIC
+      expect(isGrantNormalizedSpy).toBeCalledTimes(1);
+    });
+
+    test('Should create empty-child and non-empty grandchild', async() => {
+      const isGrantNormalizedSpy = jest.spyOn(crowi.pageGrantService, 'isGrantNormalized');
+      const grandchildPage = await crowi.pageService.create('/v5_empty_create2/v5_create_3', 'grandchild', dummyUser1, {});
+      const childPage = await Page.findOne({ path: '/v5_empty_create2' });
+
+      expect(childPage.isEmpty).toBe(true);
+      expect(grandchildPage).toBeTruthy();
+      expect(childPage).toBeTruthy();
+      expect(childPage.parent).toStrictEqual(rootPage._id);
+      expect(grandchildPage.parent).toStrictEqual(childPage._id);
+      // isGrantNormalized is called when GRANT PUBLIC
+      expect(isGrantNormalizedSpy).toBeCalledTimes(1);
+    });
+
+    test('Should create on empty page', async() => {
+      const isGrantNormalizedSpy = jest.spyOn(crowi.pageGrantService, 'isGrantNormalized');
+      const beforeCreatePage = await Page.findOne({ path: '/v5_empty_create_4' });
+      expect(beforeCreatePage.isEmpty).toBe(true);
+
+      const childPage = await crowi.pageService.create('/v5_empty_create_4', 'body', dummyUser1, {});
+      const grandchildPage = await Page.findOne({ parent: childPage._id });
+
+      expect(childPage).toBeTruthy();
+      expect(childPage.isEmpty).toBe(false);
+      expect(childPage.revision.body).toBe('body');
+      expect(grandchildPage).toBeTruthy();
+      expect(childPage.parent).toStrictEqual(rootPage._id);
+      expect(grandchildPage.parent).toStrictEqual(childPage._id);
+      // isGrantNormalized is called when GRANT PUBLIC
+      expect(isGrantNormalizedSpy).toBeCalledTimes(1);
+    });
+
+  });
+
+  describe('create by system', () => {
+
+    test('Should create single page by system', async() => {
+      const isGrantNormalizedSpy = jest.spyOn(crowi.pageGrantService, 'isGrantNormalized');
+      const page = await crowi.pageService.forceCreateBySystem('/v5_create_by_system1', 'create_by_system1', {});
+      expect(page).toBeTruthy();
+      expect(page.parent).toStrictEqual(rootPage._id);
+      // isGrantNormalized is not called when create by system
+      expect(isGrantNormalizedSpy).toBeCalledTimes(0);
+    });
+
+    test('Should create empty-child and non-empty grandchild', async() => {
+      const isGrantNormalizedSpy = jest.spyOn(crowi.pageGrantService, 'isGrantNormalized');
+      const grandchildPage = await crowi.pageService.forceCreateBySystem('/v5_empty_create_by_system2/v5_create_by_system3', 'grandchild', {});
+      const childPage = await Page.findOne({ path: '/v5_empty_create_by_system2' });
+
+      expect(childPage.isEmpty).toBe(true);
+      expect(grandchildPage).toBeTruthy();
+      expect(childPage).toBeTruthy();
+      expect(childPage.parent).toStrictEqual(rootPage._id);
+      expect(grandchildPage.parent).toStrictEqual(childPage._id);
+      // isGrantNormalized is not called when create by system
+      expect(isGrantNormalizedSpy).toBeCalledTimes(0);
+    });
+
+    test('Should create on empty page', async() => {
+      const isGrantNormalizedSpy = jest.spyOn(crowi.pageGrantService, 'isGrantNormalized');
+      const beforeCreatePage = await Page.findOne({ path: '/v5_empty_create_by_system4' });
+      expect(beforeCreatePage.isEmpty).toBe(true);
+
+      const childPage = await crowi.pageService.forceCreateBySystem('/v5_empty_create_by_system4', 'body', {});
+      const grandchildPage = await Page.findOne({ parent: childPage._id });
+
+      expect(childPage).toBeTruthy();
+      expect(childPage.isEmpty).toBe(false);
+      expect(childPage.revision.body).toBe('body');
+      expect(grandchildPage).toBeTruthy();
+      expect(childPage.parent).toStrictEqual(rootPage._id);
+      expect(grandchildPage.parent).toStrictEqual(childPage._id);
+      // isGrantNormalized is not called when create by system
+      expect(isGrantNormalizedSpy).toBeCalledTimes(0);
+    });
+
+  });
+
   describe('Rename', () => {
 
     const renamePage = async(page, newPagePath, user, options) => {
@@ -906,6 +1158,31 @@ describe('PageService page operations with only public pages', () => {
       return renamedPage;
     };
 
+    /**
+     * This function only execute renameMainOperation. renameSubOperation is basically omitted(only return null)
+     */
+    const renameMainOperation = async(page, newPagePath, user, options) => {
+      // create page operation from target page
+      const pageOp = await PageOperation.create({
+        actionType: PageActionType.Rename,
+        actionStage: PageActionStage.Main,
+        page,
+        user,
+        fromPath: page.path,
+        toPath: newPagePath,
+        options,
+      });
+
+      // mock return value
+      const mockedRenameSubOperation = jest.spyOn(crowi.pageService, 'renameSubOperation').mockReturnValue(null);
+      const renamedPage = await crowi.pageService.renameMainOperation(page, newPagePath, user, options, pageOp._id);
+
+      // restores the original implementation
+      mockedRenameSubOperation.mockRestore();
+
+      return renamedPage;
+    };
+
     test('Should NOT rename top page', async() => {
       expect(rootPage).toBeTruthy();
       let isThrown = false;
@@ -1175,6 +1452,136 @@ describe('PageService page operations with only public pages', () => {
       expect(renamedPageChild.isEmpty).toBeTruthy();
       expect(renamedPageGrandchild.isEmpty).toBe(false);
     });
+
+    test('should add 1 descendantCount to parent page in MainOperation', async() => {
+      // paths before renaming
+      const _path0 = '/v5_pageForRename24'; // out of renaming scope
+      const _path1 = '/v5_pageForRename25'; // not renamed yet
+
+      // paths after renaming
+      const path0 = '/v5_pageForRename24';
+      const path1 = '/v5_pageForRename24/v5_pageForRename25';
+
+      // new path:  same as path1
+      const newPath = '/v5_pageForRename24/v5_pageForRename25';
+
+      // pages
+      const _page0 = await Page.findOne({ path: _path0 });
+      const _page1 = await Page.findOne({ path: _path1 });
+
+      expect(_page0).toBeTruthy();
+      expect(_page1).toBeTruthy();
+      expect(_page0.descendantCount).toBe(0);
+      expect(_page1.descendantCount).toBe(0);
+
+      await renameMainOperation(_page1, newPath, dummyUser1, {});
+
+      const page0 = await Page.findById(_page0._id); // new parent
+      const page1 = await Page.findById(_page1._id); // renamed one
+      expect(page0).toBeTruthy();
+      expect(page1).toBeTruthy();
+
+      expect(page0.path).toBe(path0);
+      expect(page1.path).toBe(path1); // renamed
+      expect(page0.descendantCount).toBe(1); // originally 0, +1 in Main.
+      expect(page1.descendantCount).toBe(0);
+
+      // cleanup
+      await PageOperation.findOneAndDelete({ fromPath: _path1 });
+    });
+
+    test('should subtract 1 descendantCount from a new parent page in renameSubOperation', async() => {
+      // paths before renaming
+      const _path0 = '/v5_pageForRename29'; // out of renaming scope
+      const _path1 = '/v5_pageForRename29/v5_pageForRename30'; // already renamed
+
+      // paths after renaming
+      const path0 = '/v5_pageForRename29';
+      const path1 = '/v5_pageForRename29/v5_pageForRename30';
+
+      // new path:  same as path1
+      const newPath = '/v5_pageForRename29/v5_pageForRename30';
+
+      // page
+      const _page0 = await Page.findOne({ path: _path0 });
+      const _page1 = await Page.findOne({ path: _path1 });
+      expect(_page0).toBeTruthy();
+      expect(_page1).toBeTruthy();
+
+      // page operation
+      const fromPath = '/v5_pageForRename30';
+      const toPath = newPath;
+      const pageOperation = await PageOperation.findOne({
+        _id: pageOpId1, fromPath, toPath, actionType: PageActionType.Rename, actionStage: PageActionStage.Sub,
+      });
+      expect(pageOperation).toBeTruthy();
+
+      // descendantCount
+      expect(_page0.descendantCount).toBe(1);
+      expect(_page1.descendantCount).toBe(0);
+
+      // renameSubOperation only
+      await crowi.pageService.renameSubOperation(_page1, newPath, dummyUser1, {}, _page1, pageOperation._id);
+
+      // page
+      const page0 = await Page.findById(_page0._id); // new parent
+      const page1 = await Page.findById(_page1._id); // renamed one
+      expect(page0).toBeTruthy();
+      expect(page1).toBeTruthy();
+      expect(page0.path).toBe(path0);
+      expect(page1.path).toBe(path1); // renamed
+
+      // descendantCount
+      expect(page0.descendantCount).toBe(0); // originally 1, -1 in Sub.
+      expect(page1.descendantCount).toBe(0);
+    });
+
+    test(`should add 1 descendantCount to the a parent page in rename(Main)Operation
+    and subtract 1 descendantCount from the the parent page in rename(Sub)Operation`, async() => {
+      // paths before renaming
+      const _path0 = '/v5_pageForRename26'; // out of renaming scope
+      const _path1 = '/v5_pageForRename27'; // not renamed yet
+      const _path2 = '/v5_pageForRename27/v5_pageForRename28'; // not renamed yet
+
+      // paths after renaming
+      const path0 = '/v5_pageForRename26';
+      const path1 = '/v5_pageForRename26/v5_pageForRename27';
+      const path2 = '/v5_pageForRename26/v5_pageForRename27/v5_pageForRename28';
+
+      // new path: same as path1
+      const newPath = '/v5_pageForRename26/v5_pageForRename27';
+
+      // page
+      const _page0 = await Page.findOne({ path: _path0 });
+      const _page1 = await Page.findOne({ path: _path1 });
+      const _page2 = await Page.findOne({ path: _path2 });
+
+      expect(_page0).toBeTruthy();
+      expect(_page1).toBeTruthy();
+      expect(_page2).toBeTruthy();
+      expect(_page0.descendantCount).toBe(0);
+      expect(_page1.descendantCount).toBe(1);
+      expect(_page2.descendantCount).toBe(0);
+
+      await renamePage(_page1, newPath, dummyUser1, {});
+
+      const page0 = await Page.findById(_page0._id); // new parent
+      const page1 = await Page.findById(_page1._id); // renamed
+      const page2 = await Page.findById(_page2._id); // renamed
+      expect(page0).toBeTruthy();
+      expect(page1).toBeTruthy();
+      expect(page2).toBeTruthy();
+
+      expect(page0.path).toBe(path0);
+      expect(page1.path).toBe(path1);
+      expect(page2.path).toBe(path2);
+      expect(page0.descendantCount).toBe(2); // originally 0, +1 in Main, -1 in Sub, +2 for descendants.
+      expect(page1.descendantCount).toBe(1);
+      expect(page2.descendantCount).toBe(0);
+
+      // cleanup
+      await PageOperation.findOneAndDelete({ fromPath: _path1 });
+    });
   });
   describe('Duplicate', () => {
 

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

@@ -1,6 +1,6 @@
 {
   "name": "@growi/codemirror-textlint",
-  "version": "5.0.9-RC.0",
+  "version": "5.0.10-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.9-RC.0",
+  "version": "5.0.10-RC.0",
   "description": "GROWI Core Libraries",
   "license": "MIT",
   "keywords": [

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

@@ -267,3 +267,23 @@ export const isPathAreaOverlap = (pathToTest: string, pathToBeTested: string): b
 export const canMoveByPath = (fromPath: string, toPath: string): boolean => {
   return !isPathAreaOverlap(fromPath, toPath);
 };
+
+/**
+ * check if string has '/' in it
+ */
+export const hasSlash = (str: string): boolean => {
+  return str.includes('/');
+};
+
+/**
+ * Generate RegExp instance for one level lower path
+ */
+export const generateChildrenRegExp = (path: string): RegExp => {
+  // https://regex101.com/r/laJGzj/1
+  // ex. /any_level1
+  if (isTopPage(path)) return new RegExp(/^\/[^/]+$/);
+
+  // https://regex101.com/r/mrDJrx/1
+  // ex. /parent/any_child OR /any_level1
+  return new RegExp(`^${path}(\\/[^/]+)\\/?$`);
+};

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

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

+ 2 - 2
packages/plugin-attachment-refs/src/client-entry.js

@@ -1,9 +1,9 @@
 import RefsPostRenderInterceptor from './client/js/util/Interceptor/RefsPostRenderInterceptor';
 import RefsPreRenderInterceptor from './client/js/util/Interceptor/RefsPreRenderInterceptor';
 
-export default (appContainer) => {
+export default () => {
   // add interceptors
-  appContainer.interceptorManager.addInterceptors([
+  global.interceptorManager.addInterceptors([
     new RefsPreRenderInterceptor(),
     new RefsPostRenderInterceptor(),
   ]);

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

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

+ 3 - 3
packages/plugin-lsx/src/client-entry.js

@@ -2,11 +2,11 @@ import { LsxLogoutInterceptor } from './client/js/util/Interceptor/LsxLogoutInte
 import { LsxPostRenderInterceptor } from './client/js/util/Interceptor/LsxPostRenderInterceptor';
 import { LsxPreRenderInterceptor } from './client/js/util/Interceptor/LsxPreRenderInterceptor';
 
-export default (appContainer) => {
+export default () => {
   // add interceptors
-  appContainer.interceptorManager.addInterceptors([
+  global.interceptorManager.addInterceptors([
     new LsxLogoutInterceptor(),
     new LsxPreRenderInterceptor(),
-    new LsxPostRenderInterceptor(appContainer),
+    new LsxPostRenderInterceptor(),
   ]);
 };

+ 0 - 1
packages/plugin-lsx/src/client/js/components/Lsx.jsx

@@ -239,7 +239,6 @@ export class Lsx extends React.Component {
 }
 
 Lsx.propTypes = {
-  appContainer: PropTypes.object.isRequired,
   lsxContext: PropTypes.instanceOf(LsxContext).isRequired,
 
   forceToFetchData: PropTypes.bool,

+ 3 - 7
packages/plugin-lsx/src/client/js/util/Interceptor/LsxPostRenderInterceptor.js

@@ -1,5 +1,6 @@
-import { BasicInterceptor } from '@growi/core';
 import React from 'react';
+
+import { BasicInterceptor } from '@growi/core';
 import ReactDOM from 'react-dom';
 
 
@@ -13,11 +14,6 @@ import { LsxContext } from '../LsxContext';
  */
 export class LsxPostRenderInterceptor extends BasicInterceptor {
 
-  constructor(appContainer) {
-    super();
-    this.appContainer = appContainer;
-  }
-
   /**
    * @inheritdoc
    */
@@ -54,7 +50,7 @@ export class LsxPostRenderInterceptor extends BasicInterceptor {
 
   renderReactDOM(lsxContext, elem, isPreview) {
     ReactDOM.render(
-      <Lsx appContainer={this.appContainer} lsxContext={lsxContext} forceToFetchData={!isPreview} />,
+      <Lsx lsxContext={lsxContext} forceToFetchData={!isPreview} />,
       elem,
     );
   }

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

@@ -1,6 +1,6 @@
 {
   "name": "@growi/plugin-pukiwiki-like-linker",
-  "version": "5.0.9-RC.0",
+  "version": "5.0.10-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.9-RC.0",
+  "version": "5.0.10-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.9-slackbot-proxy.0",
+  "version": "5.0.10-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.9-RC.0",
+    "@growi/slack": "^5.0.10-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.9-RC.0",
+  "version": "5.0.10-RC.0",
   "description": "GROWI UI Libraries",
   "license": "MIT",
   "keywords": [