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

Merge remote-tracking branch 'origin/master' into imprv/vrt-move-rename-bootstrap4

asami-n 4 лет назад
Родитель
Сommit
0a85d69e28
100 измененных файлов с 3114 добавлено и 995 удалено
  1. 3 2
      .devcontainer/docker-compose.yml
  2. 2 0
      .github/workflows/ci-app-prod.yml
  3. 3 3
      .github/workflows/ci-app.yml
  4. 2 2
      .github/workflows/ci-slackbot-proxy.yml
  5. 1 1
      .github/workflows/release-rc.yml
  6. 1 1
      .github/workflows/release-slackbot-proxy.yml
  7. 1 1
      .github/workflows/release.yml
  8. 6 6
      .github/workflows/reusable-app-prod.yml
  9. 1 1
      .github/workflows/reusable-app-reg-suit.yml
  10. 1 1
      lerna.json
  11. 1 1
      package.json
  12. 1 2
      packages/app/.env.development
  13. 14 14
      packages/app/package.json
  14. 4 1
      packages/app/resource/locales/en_US/admin/admin.json
  15. 8 7
      packages/app/resource/locales/en_US/translation.json
  16. 4 1
      packages/app/resource/locales/ja_JP/admin/admin.json
  17. 6 5
      packages/app/resource/locales/ja_JP/translation.json
  18. 4 1
      packages/app/resource/locales/zh_CN/admin/admin.json
  19. 6 5
      packages/app/resource/locales/zh_CN/translation.json
  20. 0 123
      packages/app/resource/search/mappings-es6-for-ci.json
  21. 118 0
      packages/app/resource/search/mappings-es7-for-ci.json
  22. 3 1
      packages/app/src/client/interfaces/react-bootstrap-typeahead.ts
  23. 2 11
      packages/app/src/client/legacy/crowi-presentation.js
  24. 17 1
      packages/app/src/client/services/AdminGeneralSecurityContainer.js
  25. 4 4
      packages/app/src/components/Admin/AdminHome/AdminHome.jsx
  26. 218 70
      packages/app/src/components/Admin/Security/SecuritySetting.jsx
  27. 1 4
      packages/app/src/components/Admin/UserGroup/UserGroupDeleteModal.tsx
  28. 63 7
      packages/app/src/components/Admin/UserGroup/UserGroupForm.tsx
  29. 48 46
      packages/app/src/components/Admin/UserGroup/UserGroupModal.tsx
  30. 14 5
      packages/app/src/components/Admin/UserGroup/UserGroupPage.tsx
  31. 43 14
      packages/app/src/components/Admin/UserGroupDetail/UserGroupDetailPage.tsx
  32. 1 0
      packages/app/src/components/Common/Dropdown/PageItemControl.tsx
  33. 4 1
      packages/app/src/components/LegacyPrivatePagesMigrationModal.tsx
  34. 1 1
      packages/app/src/components/Navbar/GlobalSearch.tsx
  35. 2 7
      packages/app/src/components/Page/TrashPageAlert.jsx
  36. 3 3
      packages/app/src/components/PageCreateModal.jsx
  37. 1 1
      packages/app/src/components/PageDeleteModal.tsx
  38. 1 2
      packages/app/src/components/PageDuplicateModal.tsx
  39. 3 3
      packages/app/src/components/PageEditor/LinkEditModal.jsx
  40. 1 1
      packages/app/src/components/PageEditor/PreviewWithSuspense.jsx
  41. 22 2
      packages/app/src/components/PageList/PageListItemL.tsx
  42. 4 23
      packages/app/src/components/PagePathAutoComplete.jsx
  43. 29 4
      packages/app/src/components/PageRenameModal.tsx
  44. 14 32
      packages/app/src/components/SearchForm.tsx
  45. 1 1
      packages/app/src/components/SearchPage/SearchControl.tsx
  46. 142 127
      packages/app/src/components/SearchTypeahead.tsx
  47. 4 4
      packages/app/src/components/Sidebar/PageTree/Item.tsx
  48. 3 3
      packages/app/src/components/Sidebar/SidebarContents.tsx
  49. 1 1
      packages/app/src/components/Sidebar/SidebarNav.tsx
  50. 6 6
      packages/app/src/interfaces/page-delete-config.ts
  51. 10 2
      packages/app/src/interfaces/user-group-response.ts
  52. 1 0
      packages/app/src/server/interfaces/page-operation.ts
  53. 1 0
      packages/app/src/server/models/page-operation.ts
  54. 129 54
      packages/app/src/server/models/page.ts
  55. 1 1
      packages/app/src/server/routes/admin.js
  56. 6 6
      packages/app/src/server/routes/apiv3/index.js
  57. 3 1
      packages/app/src/server/routes/apiv3/pages.js
  58. 13 1
      packages/app/src/server/routes/apiv3/security-setting.js
  59. 98 7
      packages/app/src/server/routes/apiv3/user-group.js
  60. 6 12
      packages/app/src/server/service/config-loader.ts
  61. 33 16
      packages/app/src/server/service/page-grant.ts
  62. 89 64
      packages/app/src/server/service/page.ts
  63. 9 3
      packages/app/src/server/service/search-delegator/elasticsearch.ts
  64. 4 4
      packages/app/src/stores/search.tsx
  65. 1 1
      packages/app/src/stores/ui.tsx
  66. 19 5
      packages/app/src/stores/user-group.tsx
  67. 2 2
      packages/app/src/styles/_vendor-presentation.scss
  68. 15 14
      packages/app/src/styles/theme/_apply-colors-dark.scss
  69. 11 0
      packages/app/src/styles/theme/_apply-colors-light.scss
  70. 0 27
      packages/app/src/styles/theme/_apply-colors.scss
  71. 35 0
      packages/app/src/styles/theme/_reboot-bootstrap-dropdown.scss
  72. 0 4
      packages/app/src/styles/theme/antarctic.scss
  73. 0 1
      packages/app/src/styles/theme/blackboard.scss
  74. 1 3
      packages/app/src/styles/theme/christmas.scss
  75. 0 7
      packages/app/src/styles/theme/default.scss
  76. 0 5
      packages/app/src/styles/theme/fire-red.scss
  77. 0 5
      packages/app/src/styles/theme/future.scss
  78. 0 1
      packages/app/src/styles/theme/halloween.scss
  79. 2 5
      packages/app/src/styles/theme/hufflepuff.scss
  80. 0 2
      packages/app/src/styles/theme/island.scss
  81. 0 5
      packages/app/src/styles/theme/jade-green.scss
  82. 0 2
      packages/app/src/styles/theme/kibela.scss
  83. 0 9
      packages/app/src/styles/theme/mono-blue.scss
  84. 0 5
      packages/app/src/styles/theme/nature.scss
  85. 0 2
      packages/app/src/styles/theme/spring.scss
  86. 0 2
      packages/app/src/styles/theme/wood.scss
  87. 62 0
      packages/app/src/utils/page-delete-config.ts
  88. 1 2
      packages/app/test/cypress/integration/2-basic-features/open-page-create-modal.spec.ts
  89. 37 0
      packages/app/test/cypress/integration/2-basic-features/open-page-delete-modal.spec.ts
  90. 1 3
      packages/app/test/cypress/integration/2-basic-features/open-page-duplicate-modal.spec.ts
  91. 394 3
      packages/app/test/integration/models/v5.page.test.js
  92. 12 12
      packages/app/test/integration/service/page-grant.test.js
  93. 376 30
      packages/app/test/integration/service/v5.migration.test.js
  94. 750 38
      packages/app/test/integration/service/v5.non-public-page.test.ts
  95. 123 62
      packages/app/test/integration/service/v5.public-page.test.ts
  96. 22 0
      packages/app/test/unit/utils/page-delete-config.test.ts
  97. 1 1
      packages/codemirror-textlint/package.json
  98. 1 1
      packages/core/package.json
  99. 1 1
      packages/plugin-attachment-refs/package.json
  100. 1 1
      packages/plugin-lsx/package.json

+ 3 - 2
.devcontainer/docker-compose.yml

@@ -48,7 +48,7 @@ services:
       context: ../../growi-docker-compose/elasticsearch
       dockerfile: ./Dockerfile
       args:
-        - version=6.8.22
+        - version=7.16.1
     container_name: elasticsearch
     restart: unless-stopped
     ports:
@@ -56,6 +56,7 @@ services:
     environment:
       - bootstrap.memory_lock=true
       - "ES_JAVA_OPTS=-Xms256m -Xmx256m"
+      - LOG4J_FORMAT_MSG_NO_LOOKUPS=true # CVE-2021-44228 mitigation for Elasticsearch <= 6.8.20/7.16.0
     ulimits:
       memlock:
         soft: -1
@@ -66,7 +67,7 @@ services:
 
   #need to adjust kibana version based on elasticsearch version
   kibana:
-    image: docker.elastic.co/kibana/kibana:6.8.22
+    image: docker.elastic.co/kibana/kibana:7.17.1
     restart: unless-stopped
     environment:
       ELASTICSEARCH_HOSTS: 'http://elasticsearch:9200'

+ 2 - 0
.github/workflows/ci-app-prod.yml

@@ -5,6 +5,8 @@ on:
     branches:
       - master
   pull_request:
+    branches:
+        - master
     types: [opened, reopened, synchronize]
 
 jobs:

+ 3 - 3
.github/workflows/ci-app.yml

@@ -27,7 +27,7 @@ jobs:
 
       - name: Cache/Restore node_modules
         id: cache-dependencies
-        uses: actions/cache@v2
+        uses: actions/cache@v3
         with:
           path: |
             **/node_modules
@@ -81,7 +81,7 @@ jobs:
 
       - name: Cache/Restore node_modules
         id: cache-dependencies
-        uses: actions/cache@v2
+        uses: actions/cache@v3
         with:
           path: |
             **/node_modules
@@ -141,7 +141,7 @@ jobs:
 
       - name: Cache/Restore node_modules
         id: cache-dependencies
-        uses: actions/cache@v2
+        uses: actions/cache@v3
         with:
           path: |
             **/node_modules

+ 2 - 2
.github/workflows/ci-slackbot-proxy.yml

@@ -28,7 +28,7 @@ jobs:
 
     - name: Cache/Restore node_modules
       id: cache-dependencies
-      uses: actions/cache@v2
+      uses: actions/cache@v3
       with:
         path: |
           **/node_modules
@@ -86,7 +86,7 @@ jobs:
 
     - name: Cache/Restore node_modules
       id: cache-dependencies
-      uses: actions/cache@v2
+      uses: actions/cache@v3
       with:
         path: |
           **/node_modules

+ 1 - 1
.github/workflows/release-rc.yml

@@ -44,7 +44,7 @@ jobs:
       uses: docker/setup-buildx-action@v1
 
     - name: Cache Docker layers
-      uses: actions/cache@v2
+      uses: actions/cache@v3
       with:
         path: /tmp/.buildx-cache
         key: ${{ runner.os }}-buildx-app-${{ github.sha }}

+ 1 - 1
.github/workflows/release-slackbot-proxy.yml

@@ -57,7 +57,7 @@ jobs:
       uses: docker/setup-buildx-action@v1
 
     - name: Cache Docker layers
-      uses: actions/cache@v2
+      uses: actions/cache@v3
       with:
         path: /tmp/.buildx-cache
         key: ${{ runner.os }}-buildx-slackbot-proxy-${{ github.sha }}

+ 1 - 1
.github/workflows/release.yml

@@ -170,7 +170,7 @@ jobs:
       uses: docker/setup-buildx-action@v1
 
     - name: Cache Docker layers
-      uses: actions/cache@v2
+      uses: actions/cache@v3
       with:
         path: /tmp/.buildx-cache
         key: ${{ runner.os }}-buildx-app-${{ matrix.flavor }}-${{ github.sha }}

+ 6 - 6
.github/workflows/reusable-app-prod.yml

@@ -33,7 +33,7 @@ jobs:
 
     - name: Cache/Restore node_modules
       id: cache-dependencies
-      uses: actions/cache@v2
+      uses: actions/cache@v3
       with:
         path: |
           **/node_modules
@@ -96,7 +96,7 @@ jobs:
         ports:
         - 27017/tcp
       elasticsearch:
-        image: docker.elastic.co/elasticsearch/elasticsearch:6.8.23
+        image: docker.elastic.co/elasticsearch/elasticsearch:7.17.1
         ports:
         - 9200/tcp
         env:
@@ -119,7 +119,7 @@ jobs:
 
     - name: Cache/Restore node_modules (not reused)
       id: cache-dependencies
-      uses: actions/cache@v2
+      uses: actions/cache@v3
       with:
         path: |
           **/node_modules
@@ -185,7 +185,7 @@ jobs:
       fail-fast: false
       matrix:
         # List string expressions that is comma separated ids of tests in "test/cypress/integration"
-        spec-group: ['1', '2', '3', '4', '5', '6']
+        spec-group: ['1', '2', '3']
 
     services:
       mongodb:
@@ -193,7 +193,7 @@ jobs:
         ports:
         - 27017/tcp
       elasticsearch:
-        image: docker.elastic.co/elasticsearch/elasticsearch:6.8.23
+        image: docker.elastic.co/elasticsearch/elasticsearch:7.17.1
         ports:
         - 9200/tcp
         env:
@@ -208,7 +208,7 @@ jobs:
         echo "::set-output name=value::`yarn cache dir --silent`"
 
     - name: Cache/Restore dependencies
-      uses: actions/cache@v2
+      uses: actions/cache@v3
       with:
         path: |
           **/node_modules

+ 1 - 1
.github/workflows/reusable-app-reg-suit.yml

@@ -57,7 +57,7 @@ jobs:
         cache-dependency-path: '**/yarn.lock'
 
     - name: Cache/Restore node_modules
-      uses: actions/cache@v2
+      uses: actions/cache@v3
       with:
         path: |
           **/node_modules

+ 1 - 1
lerna.json

@@ -1,7 +1,7 @@
 {
   "npmClient": "yarn",
   "useWorkspaces": true,
-  "version": "5.0.0-RC.9",
+  "version": "5.0.0-RC.11",
   "packages": [
     "packages/*"
   ]

+ 1 - 1
package.json

@@ -1,6 +1,6 @@
 {
   "name": "growi",
-  "version": "5.0.0-RC.9",
+  "version": "5.0.0-RC.11",
   "description": "Team collaboration software using markdown",
   "tags": [
     "wiki",

+ 1 - 2
packages/app/.env.development

@@ -14,8 +14,7 @@ MONGO_URI="mongodb://mongo:27017/growi"
 # NCHAN_URI="http://nchan"
 ELASTICSEARCH_URI="http://elasticsearch:9200/growi"
 ELASTICSEARCH_REQUEST_TIMEOUT=15000
-#ELASTICSEARCH_REJECT_UNAUTHORIZED=false
-#USE_ELASTICSEARCH_V6=true
+ELASTICSEARCH_REJECT_UNAUTHORIZED=true
 HACKMD_URI="http://localhost:3010"
 HACKMD_URI_FOR_SERVER="http://hackmd:3000"
 OGP_URI="http://ogp:8088"

+ 14 - 14
packages/app/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@growi/app",
-  "version": "5.0.0-RC.9",
+  "version": "5.0.0-RC.11",
   "license": "MIT",
   "scripts": {
     "//// for production": "",
@@ -58,15 +58,15 @@
   },
   "dependencies": {
     "@browser-bunyan/console-formatted-stream": "^1.6.2",
-    "@elastic/elasticsearch6": "npm:@elastic/elasticsearch@^6.8.7",
-    "@elastic/elasticsearch7": "npm:@elastic/elasticsearch@^7.16.0",
+    "@elastic/elasticsearch6": "npm:@elastic/elasticsearch@^6.8.8",
+    "@elastic/elasticsearch7": "npm:@elastic/elasticsearch@^7.17.0",
     "@godaddy/terminus": "^4.9.0",
     "@google-cloud/storage": "^5.8.5",
-    "@growi/codemirror-textlint": "^5.0.0-RC.9",
-    "@growi/plugin-attachment-refs": "^5.0.0-RC.9",
-    "@growi/plugin-lsx": "^5.0.0-RC.9",
-    "@growi/plugin-pukiwiki-like-linker": "^5.0.0-RC.9",
-    "@growi/slack": "^5.0.0-RC.9",
+    "@growi/codemirror-textlint": "^5.0.0-RC.11",
+    "@growi/plugin-attachment-refs": "^5.0.0-RC.11",
+    "@growi/plugin-lsx": "^5.0.0-RC.11",
+    "@growi/plugin-pukiwiki-like-linker": "^5.0.0-RC.11",
+    "@growi/slack": "^5.0.0-RC.11",
     "@promster/express": "^7.0.2",
     "@promster/server": "^7.0.4",
     "@slack/events-api": "^3.0.0",
@@ -102,7 +102,7 @@
     "express-mongo-sanitize": "^2.1.0",
     "express-rate-limit": "^5.3.0",
     "express-session": "^1.16.1",
-    "express-validator": "^6.1.1",
+    "express-validator": "^6.14.0",
     "express-webpack-assets": "^0.1.0",
     "extensible-custom-error": "^0.0.7",
     "graceful-fs": "^4.1.11",
@@ -150,13 +150,13 @@
     "socket.io": "^4.2.0",
     "stream-to-promise": "^3.0.0",
     "string-width": "=4.2.2",
-    "swagger-jsdoc": "^3.4.0",
+    "swagger-jsdoc": "^6.1.0",
     "swig-templates": "^2.0.2",
     "uglifycss": "^0.0.29",
     "universal-bunyan": "^0.9.2",
     "unzipper": "^0.10.5",
     "url-join": "^4.0.0",
-    "validator": "^13.6.0",
+    "validator": "^13.7.0",
     "ws": "^8.3.0",
     "xss": "^1.0.6"
   },
@@ -167,7 +167,7 @@
   },
   "devDependencies": {
     "@alienfast/i18next-loader": "^1.1.4",
-    "@growi/ui": "^5.0.0-RC.9",
+    "@growi/ui": "^5.0.0-RC.11",
     "@handsontable/react": "=2.1.0",
     "@types/compression": "^1.7.0",
     "@types/express": "^4.17.11",
@@ -223,7 +223,7 @@
     "postcss-loader": "^3.0.0",
     "prettier": "^1.19.1",
     "react": "^16.8.3",
-    "react-bootstrap-typeahead": "^3.4.7",
+    "react-bootstrap-typeahead": "^5.2.2",
     "react-codemirror2": "^6.0.0",
     "react-copy-to-clipboard": "^5.0.1",
     "react-dom": "^16.8.3",
@@ -234,7 +234,7 @@
     "react-waypoint": "^10.1.0",
     "reactstrap": "^8.9.0",
     "replacestream": "^4.0.3",
-    "reveal.js": "^3.5.0",
+    "reveal.js": "^4.3.1",
     "sass": "^1.43.4",
     "sass-loader": "^10.1.1",
     "simple-load-script": "^1.0.2",

+ 4 - 1
packages/app/resource/locales/en_US/admin/admin.json

@@ -472,6 +472,9 @@
     "deny_create_group": "You can't create a new group with the current settings.",
     "group_name": "Group name",
     "group_example": "e.g. : Group1",
+    "parent_group": "Parent Group",
+    "select_parent_group": "Select Parent Group",
+    "release_parent_group": "Release parent group",
     "add_modal": {
       "add_user": "Add a user to the created group",
       "search_option": "Search option",
@@ -492,7 +495,7 @@
     "remove_from_group": "Remove this user",
     "delete_modal": {
       "header": "Delete group",
-      "desc": "Once deleted, the deleted group and its private pages cannot be retrieved.",
+      "desc": "All child groups under the group will also be deleted. Once deleted, the deleted group and its private pages cannot be retrieved.",
       "dropdown_desc": "Choose an action for private pages",
       "select_group": "Select a group",
       "no_groups": "No groups to select",

+ 8 - 7
packages/app/resource/locales/en_US/translation.json

@@ -667,14 +667,14 @@
     "page_listing_1_desc": "Show pages that are restricted by 'Only me' option when listing/searching",
     "page_listing_2": "Page listing/searching<br>restricted by User group",
     "page_listing_2_desc": "Show pages that are restricted by User group when listing/searching",
-    "page_access_and_delete_rights": "Page access / Delete rights",
-    "deletion": "Restrict trashing of a selected single page",
-    "deletion_explain": "Restricts users who can trash a selected single page.",
-    "complete_deletion": "Restrict complete deletion of a selected single page",
-    "complete_deletion_explain": "Restricts users who can completely delete a selected single page.",
-    "recursive_deletion": "Restrict trashing of pages including descendants",
+    "page_access_rights": "Page access",
+    "page_delete_rights": "Delete rights",
+    "page_delete": "Page Delete",
+    "page_delete_completely": "Page Delete Completely",
+    "other_options": "Other options",
+    "deletion_explain": "Restricts users who can trash the selected single page.",
+    "complete_deletion_explain": "Restricts users who can completely delete  selected single page.",
     "recursive_deletion_explain": "Restricts users who can trash pages including descendants.",
-    "recursive_complete_deletion": "Restrict complete deletion of pages including descendants",
     "recursive_complete_deletion_explain": "Restricts users who can completely delete pages including descendants.",
     "inherit": "Inherit(Use the same setting as for a single page)",
     "admin_only": "Admin only",
@@ -684,6 +684,7 @@
     "max_age": "Max age (msec)",
     "max_age_desc": "Specifies the number (in milliseconds) to expire users session.<br>Default: 2592000000 (30days)",
     "max_age_caution": "Restarting the server is required after you modify this value.",
+    "page_delete_rights_caution": "The \"operation including the descendants\" setting is forced to be stronger than the \"operation for only the selected page\" setting.",
     "Authentication mechanism settings": "Authentication Mechanism Settings",
     "setup_is_not_yet_complete": "Setup is not yet complete",
     "alert_siteUrl_is_not_set": "'Site URL' is NOT set. Set it from the {{link}}",

+ 4 - 1
packages/app/resource/locales/ja_JP/admin/admin.json

@@ -471,6 +471,9 @@
     "deny_create_group": "新規グループの作成はできません。",
     "group_name": "グループ名",
     "group_example": "例: Group1",
+    "parent_group": "親グループ",
+    "select_parent_group": "親グループを選択",
+    "release_parent_group": "親グループの解除",
     "add_modal": {
       "add_user": "グループへのユーザー追加",
       "search_option": "検索オプション",
@@ -491,7 +494,7 @@
     "remove_from_group": "グループから外す",
     "delete_modal": {
       "header": "グループの削除",
-      "desc": "グループ及び限定公開のページの削除を行うと元に戻すことはできませんのでご注意ください。",
+      "desc": "当該グループ配下に存在する子グループも全て削除されます。また、グループ及び限定公開のページの削除を行うと元に戻すことはできませんのでご注意ください。",
       "dropdown_desc": "削除するグループの限定公開ページの処理を選択してください",
       "select_group": "グループを選択してください",
       "no_groups": "グループがありません",

+ 6 - 5
packages/app/resource/locales/ja_JP/translation.json

@@ -666,14 +666,14 @@
     "page_listing_1_desc": "ページのリスト表示や検索結果において、'自分のみ'に閲覧制限をしているページをアクセス権のないユーザーにも表示します。",
     "page_listing_2": "ページのリスト表示と検索<br>特定グループに閲覧制限しているページ",
     "page_listing_2_desc": "ページのリスト表示や検索結果において、特定グループにのみ閲覧制限をしているページをアクセス権のないユーザーにも表示します。",
-    "page_access_and_delete_rights": "ページの閲覧・削除権限",
-    "deletion": "ページをゴミ箱に入れる(単体のみの操作)",
+    "page_access_rights": "ページの閲覧権限",
+    "page_delete_rights": "ページの削除権限",
+    "page_delete": "ゴミ箱に入れる",
+    "page_delete_completely": "完全に削除する",
+    "other_options": "その他のオプション",
     "deletion_explain": "ページをゴミ箱に入れることができるユーザーを制限します。",
-    "complete_deletion": "ページを完全削除する(単体のみの操作)",
     "complete_deletion_explain": "ページを完全削除することができるユーザーを制限します。",
-    "recursive_deletion": "ページをゴミ箱に入れる(子孫を含む操作)",
     "recursive_deletion_explain": "子孫を含めたページをゴミ箱に入れることができるユーザーを制限します。",
-    "recursive_complete_deletion": "ページを完全削除する(子孫を含む操作)",
     "recursive_complete_deletion_explain": "子孫を含めたページを完全削除することができるユーザーを制限します。",
     "inherit": "単体のみと同じ",
     "admin_only": "管理者のみ可能",
@@ -683,6 +683,7 @@
     "max_age": "有効期間 (ミリ秒)",
     "max_age_desc": "ユーザーのセッション情報の有効期間をミリ秒で指定できます。<br>デフォルト値: 2592000000 (30日間)",
     "max_age_caution": "この値を変更した後は、サーバーを再起動する必要があります。",
+    "page_delete_rights_caution": "「子孫を含む操作」の設定値は、「単体のみの操作」の設定値よりも強いものに強制されます。",
     "Authentication mechanism settings": "認証機構設定",
     "setup_is_not_yet_complete":"セットアップはまだ完了してません",
     "alert_siteUrl_is_not_set": "'サイトURL' が設定されていません。{{link}} から設定してください。",

+ 4 - 1
packages/app/resource/locales/zh_CN/admin/admin.json

@@ -481,6 +481,9 @@
     "deny_create_group": "不能用当前设置创建新组。",
     "group_name": "组名",
     "group_example": "e.g.:第1组",
+    "parent_group": "父母组",
+    "select_parent_group": "选择父组",
+    "release_parent_group": "Release parent group",
     "add_modal": {
       "add_user": "将用户添加到创建的组",
       "search_option": "搜索选项",
@@ -501,7 +504,7 @@
     "remove_from_group": "删除此用户",
     "delete_modal": {
       "header": "删除组",
-      "desc": "删除后,将无法检索已删除的组及其私人页。",
+      "desc": "该组下的所有子组也将被删除。删除后,将无法检索已删除的组及其私人页。",
       "dropdown_desc": "为私人页选择操作",
       "select_group": "选择组",
       "no_groups": "没有可选择的组",

+ 6 - 5
packages/app/resource/locales/zh_CN/translation.json

@@ -625,14 +625,14 @@
 		"page_listing_1_desc": "列出/搜索时显示受“仅限我”选项限制的页面",
 		"page_listing_2": "页面列表/搜索<br>受用户组限制",
 		"page_listing_2_desc": "显示列出/搜索时受用户组限制的页面",
-    "page_access_and_delete_rights": "页面访问/删除权限",
-    "deletion": "限制捣毁一个选定的单一页面",
+    "page_access_rights": "页面访问",
+    "page_delete_rights": "删除权限",
+    "page_delete": "删除",
+    "page_delete_completely": "彻底删除",
+    "other_options": "其他选项",
     "deletion_explain": "限制用户对选定的单一页面进行垃圾处理。",
-    "complete_deletion": "限制完全删除一个选定的单页",
     "complete_deletion_explain": "限制可以完全删除所选单页的用户。",
-    "recursive_deletion": "限制捣毁包括子孙在内的网页",
     "recursive_deletion_explain": "限制用户可以捣毁包括子孙在内的页面。",
-    "recursive_complete_deletion": "限制完全删除包括子孙在内的页面",
     "recursive_complete_deletion_explain": "限制可以完全删除页面的用户,包括子孙。",
     "inherit": "继承(使用与单页相同的设置)。",
 		"admin_only": "仅管理员",
@@ -642,6 +642,7 @@
     "max_age": "有效期间  (msec)",
     "max_age_desc": "指定使用户会话过期的数量(以毫秒为单位)。<br>默认值: 2592000000 (30天)",
     "max_age_caution": "修改该值后需要重启服务器。",
+    "page_delete_rights_caution": "\"包括后代的操作\" 的设置被迫强于 \"只对选定的页面进行操作\" 的设置。",
 		"Authentication mechanism settings": "身份验证机制设置",
 		"setup_is_not_yet_complete": "安装尚未完成",
 		"alert_siteUrl_is_not_set": "主页URL未设置,通过 {{link}} 设置",

+ 0 - 123
packages/app/resource/search/mappings-es6-for-ci.json

@@ -1,123 +0,0 @@
-{
-  "settings": {
-    "analysis": {
-      "filter": {
-        "english_stop": {
-          "type":       "stop",
-          "stopwords":  "_english_"
-        }
-      },
-      "tokenizer": {
-        "edge_ngram_tokenizer": {
-          "type": "edge_ngram",
-          "min_gram": 2,
-          "max_gram": 20,
-          "token_chars": ["letter", "digit"]
-        }
-      },
-      "analyzer": {
-        "japanese": {
-          "tokenizer": "edge_ngram_tokenizer",
-          "filter": [
-            "lowercase",
-            "english_stop"
-          ]
-        },
-        "english_edge_ngram": {
-          "tokenizer": "edge_ngram_tokenizer",
-          "filter": [
-            "lowercase",
-            "english_stop"
-          ]
-        }
-      }
-    }
-  },
-  "mappings": {
-    "pages": {
-      "properties" : {
-        "path": {
-          "type": "text",
-          "fields": {
-            "raw": {
-              "type": "text",
-              "analyzer": "keyword"
-            },
-            "ja": {
-              "type": "text",
-              "analyzer": "japanese"
-            },
-            "en": {
-              "type": "text",
-              "analyzer": "english_edge_ngram",
-              "search_analyzer": "standard"
-            }
-          }
-        },
-        "body": {
-          "type": "text",
-          "fields": {
-            "ja": {
-              "type": "text",
-              "analyzer": "japanese"
-            },
-            "en": {
-              "type": "text",
-              "analyzer": "english_edge_ngram",
-              "search_analyzer": "standard"
-            }
-          }
-        },
-        "comments": {
-          "type": "text",
-          "fields": {
-            "ja": {
-              "type": "text",
-              "analyzer": "japanese"
-            },
-            "en": {
-              "type": "text",
-              "analyzer": "english_edge_ngram",
-              "search_analyzer": "standard"
-            }
-          }
-        },
-        "username": {
-          "type": "keyword"
-        },
-        "comment_count": {
-          "type": "integer"
-        },
-        "bookmark_count": {
-          "type": "integer"
-        },
-        "seenUsers_count":{
-          "type": "integer"
-        },
-        "like_count": {
-          "type": "integer"
-        },
-        "grant": {
-          "type": "integer"
-        },
-        "granted_users": {
-          "type": "keyword"
-        },
-        "granted_group": {
-          "type": "keyword"
-        },
-        "created_at": {
-          "type": "date",
-          "format": "dateOptionalTime"
-        },
-        "updated_at": {
-          "type": "date",
-          "format": "dateOptionalTime"
-        },
-        "tag_names": {
-          "type": "keyword"
-        }
-      }
-    }
-  }
-}

+ 118 - 0
packages/app/resource/search/mappings-es7-for-ci.json

@@ -0,0 +1,118 @@
+{
+  "settings": {
+    "analysis": {
+      "filter": {
+        "english_stop": {
+          "type":       "stop",
+          "stopwords":  "_english_"
+        }
+      },
+      "tokenizer": {
+        "edge_ngram_tokenizer": {
+          "type": "edge_ngram",
+          "min_gram": 2,
+          "max_gram": 20,
+          "token_chars": ["letter", "digit"]
+        }
+      },
+      "analyzer": {
+        "japanese": {
+          "tokenizer": "edge_ngram_tokenizer",
+          "filter": [
+            "lowercase",
+            "english_stop"
+          ]
+        },
+        "english_edge_ngram": {
+          "tokenizer": "edge_ngram_tokenizer",
+          "filter": [
+            "lowercase",
+            "english_stop"
+          ]
+        }
+      }
+    }
+  },
+  "mappings": {
+    "properties" : {
+      "path": {
+        "type": "text",
+        "fields": {
+          "raw": {
+            "type": "text",
+            "analyzer": "keyword"
+          },
+          "ja": {
+            "type": "text",
+            "analyzer": "japanese"
+          },
+          "en": {
+            "type": "text",
+            "analyzer": "english_edge_ngram",
+            "search_analyzer": "standard"
+          }
+        }
+      },
+      "body": {
+        "type": "text",
+        "fields": {
+          "ja": {
+            "type": "text",
+            "analyzer": "japanese"
+          },
+          "en": {
+            "type": "text",
+            "analyzer": "english_edge_ngram",
+            "search_analyzer": "standard"
+          }
+        }
+      },
+      "comments": {
+        "type": "text",
+        "fields": {
+          "ja": {
+            "type": "text",
+            "analyzer": "japanese"
+          },
+          "en": {
+            "type": "text",
+            "analyzer": "english_edge_ngram",
+            "search_analyzer": "standard"
+          }
+        }
+      },
+      "username": {
+        "type": "keyword"
+      },
+      "comment_count": {
+        "type": "integer"
+      },
+      "bookmark_count": {
+        "type": "integer"
+      },
+      "like_count": {
+        "type": "integer"
+      },
+      "grant": {
+        "type": "integer"
+      },
+      "granted_users": {
+        "type": "keyword"
+      },
+      "granted_group": {
+        "type": "keyword"
+      },
+      "created_at": {
+        "type": "date",
+        "format": "dateOptionalTime"
+      },
+      "updated_at": {
+        "type": "date",
+        "format": "dateOptionalTime"
+      },
+      "tag_names": {
+        "type": "keyword"
+      }
+    }
+  }
+}

+ 3 - 1
packages/app/src/client/interfaces/react-bootstrap-typeahead.ts

@@ -1,13 +1,15 @@
-// https://github.com/ericgio/react-bootstrap-typeahead/blob/3.x/docs/Props.md
+// https://github.com/ericgio/react-bootstrap-typeahead/blob/5.x/docs/API.md
 export type TypeaheadProps = {
   dropup?: boolean,
   emptyLabel?: string,
   placeholder?: string,
   autoFocus?: boolean,
+  inputProps?: unknown,
 
   onChange?: (data: unknown[]) => void,
   onBlur?: () => void,
   onFocus?: () => void,
+  onSearch?: (text: string) => void,
   onInputChange?: (text: string) => void,
   onKeyDown?: (input: string) => void,
 };

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

@@ -1,12 +1,4 @@
-const Reveal = require('reveal.js');
-
-require('reveal.js/lib/js/head.min');
-require('reveal.js/lib/js/html5shiv');
-
-if (!window) {
-  window = {};
-}
-window.Reveal = Reveal;
+import Reveal from 'reveal.js';
 
 Reveal.initialize({
   controls: true,
@@ -30,8 +22,7 @@ Reveal.initialize({
 });
 
 require.ensure([], () => {
-  require('reveal.js/lib/js/classList');
-  require('reveal.js/plugin/zoom-js/zoom');
+  require('reveal.js/plugin/zoom/zoom');
   require('reveal.js/plugin/notes/notes');
   require('../util/reveal/plugins/growi-renderer');
 

+ 17 - 1
packages/app/src/client/services/AdminGeneralSecurityContainer.js

@@ -27,9 +27,11 @@ export default class AdminGeneralSecurityContainer extends Container {
       // set dummy value tile for using suspense
       currentRestrictGuestMode: this.dummyCurrentRestrictGuestMode,
       currentPageDeletionAuthority: PageSingleDeleteConfigValue.AdminOnly,
-      currentPageCompleteDeletionAuthority: PageSingleDeleteCompConfigValue.AdminOnly,
       currentPageRecursiveDeletionAuthority: PageRecursiveDeleteConfigValue.Inherit,
+      currentPageCompleteDeletionAuthority: PageSingleDeleteCompConfigValue.AdminOnly,
       currentPageRecursiveCompleteDeletionAuthority: PageRecursiveDeleteCompConfigValue.Inherit,
+      expandOtherOptionsForDeletion: false,
+      expandOtherOptionsForCompleteDeletion: false,
       isShowRestrictedByOwner: false,
       isShowRestrictedByGroup: false,
       appSiteUrl: appContainer.config.crowi.url || '',
@@ -147,6 +149,20 @@ export default class AdminGeneralSecurityContainer extends Container {
     this.setState({ currentPageRecursiveCompleteDeletionAuthority: val });
   }
 
+  /**
+   * Switch ExpandOtherOptionsForDeletion
+   */
+  switchExpandOtherOptionsForDeletion() {
+    this.setState({ expandOtherOptionsForDeletion:  !this.state.expandOtherOptionsForDeletion });
+  }
+
+  /**
+   * Switch ExpandOtherOptionsForDeletion
+   */
+  switchExpandOtherOptionsForCompleteDeletion() {
+    this.setState({ expandOtherOptionsForCompleteDeletion:  !this.state.expandOtherOptionsForCompleteDeletion });
+  }
+
   /**
    * Switch showRestrictedByOwner
    */

+ 4 - 4
packages/app/src/components/Admin/AdminHome/AdminHome.jsx

@@ -43,15 +43,15 @@ const AdminHome = (props) => {
         adminHomeContainer.state.isMaintenanceMode && (
           <div className="alert alert-danger alert-link" role="alert">
             <h3 className="alert-heading">
-              {t('maintenance_mode.maintenance_mode')}
+              {t('admin:maintenance_mode.maintenance_mode')}
             </h3>
             <p>
-              {t('maintenance_mode.description')}
+              {t('admin:maintenance_mode.description')}
             </p>
             <hr />
-            <a className="btn-link" href="#maintenance-mode" rel="noopener noreferrer">
+            <a className="btn-link" href="/admin/app" rel="noopener noreferrer">
               <i className="fa fa-link ml-1" aria-hidden="true"></i>
-              <strong>{t('maintenance_mode.end_maintenance_mode')}</strong>
+              <strong>{t('admin:maintenance_mode.end_maintenance_mode')}</strong>
             </a>
           </div>
         )

+ 218 - 70
packages/app/src/components/Admin/Security/SecuritySetting.jsx

@@ -1,8 +1,10 @@
 /* eslint-disable react/no-danger */
 import React from 'react';
 import PropTypes from 'prop-types';
+import { Collapse } from 'reactstrap';
 import { withTranslation } from 'react-i18next';
 
+import { validateDeleteConfigs } from '~/utils/page-delete-config';
 import { withUnstatedContainers } from '../../UnstatedUtils';
 import { toastSuccess, toastError } from '~/client/util/apiNotification';
 import { PageDeleteConfigValue } from '~/interfaces/page-delete-config';
@@ -10,19 +12,60 @@ import AppContainer from '~/client/services/AppContainer';
 import AdminGeneralSecurityContainer from '~/client/services/AdminGeneralSecurityContainer';
 
 // used as the prefix of translation
-const DeletionType = Object.freeze({
+const DeletionTypeForT = Object.freeze({
   Deletion: 'deletion',
   CompleteDeletion: 'complete_deletion',
   RecursiveDeletion: 'recursive_deletion',
   RecursiveCompleteDeletion: 'recursive_complete_deletion',
 });
 
+const DeletionType = Object.freeze({
+  Deletion: 'deletion',
+  CompleteDeletion: 'completeDeletion',
+  RecursiveDeletion: 'recursiveDeletion',
+  RecursiveCompleteDeletion: 'recursiveCompleteDeletion',
+});
+
+const getDeletionTypeForT = (deletionType) => {
+  switch (deletionType) {
+    case DeletionType.Deletion:
+      return DeletionTypeForT.Deletion;
+    case DeletionType.RecursiveDeletion:
+      return DeletionTypeForT.RecursiveDeletion;
+    case DeletionType.CompleteDeletion:
+      return DeletionTypeForT.CompleteDeletion;
+    case DeletionType.RecursiveCompleteDeletion:
+      return DeletionTypeForT.RecursiveCompleteDeletion;
+  }
+};
+
+/**
+ * Return true if "deletionType" is DeletionType.RecursiveDeletion or DeletionType.RecursiveCompleteDeletion.
+ * @param deletionType Deletion type
+ * @returns boolean
+ */
+const isRecursiveDeletion = (deletionType) => {
+  return deletionType === DeletionType.RecursiveDeletion || deletionType === DeletionType.RecursiveCompleteDeletion;
+};
+
+/**
+ * Return true if "deletionType" is DeletionType.Deletion or DeletionType.RecursiveDeletion.
+ * @param deletionType Deletion type
+ * @returns boolean
+ */
+const isTypeDeletion = (deletionType) => {
+  return deletionType === DeletionType.Deletion || deletionType === DeletionType.RecursiveDeletion;
+};
+
 class SecuritySetting extends React.Component {
 
   constructor(props) {
     super(props);
 
     this.putSecuritySetting = this.putSecuritySetting.bind(this);
+    this.getRecursiveDeletionConfigState = this.getRecursiveDeletionConfigState.bind(this);
+    this.setDeletionConfigState = this.setDeletionConfigState.bind(this);
+    this.renderPageDeletePermission = this.renderPageDeletePermission.bind(this);
     this.renderPageDeletePermissionDropdown = this.renderPageDeletePermissionDropdown.bind(this);
   }
 
@@ -37,72 +80,162 @@ class SecuritySetting extends React.Component {
     }
   }
 
-  renderPageDeletePermissionDropdown(currentState, setState, deletionType, t) {
-    const isRecursiveDeletion = deletionType === DeletionType.RecursiveDeletion || deletionType === DeletionType.RecursiveCompleteDeletion;
+  getRecursiveDeletionConfigState(deletionType) {
+    const { adminGeneralSecurityContainer } = this.props;
+
+    if (isTypeDeletion(deletionType)) {
+      return [
+        adminGeneralSecurityContainer.state.currentPageRecursiveDeletionAuthority,
+        adminGeneralSecurityContainer.changePageRecursiveDeletionAuthority,
+      ];
+    }
+
+    return [
+      adminGeneralSecurityContainer.state.currentPageRecursiveCompleteDeletionAuthority,
+      adminGeneralSecurityContainer.changePageRecursiveCompleteDeletionAuthority,
+    ];
+  }
+
+  /**
+   * Force update deletion config for recursive operation when the deletion config for general operation is updated.
+   * @param deletionType Deletion type
+   */
+  setDeletionConfigState(newState, setState, deletionType) {
+    if (isRecursiveDeletion(deletionType)) {
+      setState(newState);
+
+      return;
+    }
+
+    const [recursiveState, setRecursiveState] = this.getRecursiveDeletionConfigState(deletionType);
+    const shouldForceUpdate = !validateDeleteConfigs(newState, recursiveState);
+    if (shouldForceUpdate) {
+      setState(newState);
+      setRecursiveState(newState);
+    }
+    else {
+      setState(newState);
+    }
+
+    return;
+  }
+
+  renderPageDeletePermissionDropdown(currentState, setState, deletionType, isButtonDisabled) {
+    const { t } = this.props;
+    return (
+      <div className="dropdown">
+        <button
+          className="btn btn-outline-secondary dropdown-toggle text-right"
+          type="button"
+          id="dropdownMenuButton"
+          data-toggle="dropdown"
+          aria-haspopup="true"
+          aria-expanded="true"
+        >
+          <span className="float-left">
+            {currentState === PageDeleteConfigValue.Inherit && t('security_setting.inherit')}
+            {(currentState === PageDeleteConfigValue.Anyone || currentState == null) && t('security_setting.anyone')}
+            {currentState === PageDeleteConfigValue.AdminOnly && t('security_setting.admin_only')}
+            {currentState === PageDeleteConfigValue.AdminAndAuthor && t('security_setting.admin_and_author')}
+          </span>
+        </button>
+        <div className="dropdown-menu" aria-labelledby="dropdownMenuButton">
+          {
+            isRecursiveDeletion(deletionType)
+              ? (
+                <button
+                  className="dropdown-item"
+                  type="button"
+                  onClick={() => { this.setDeletionConfigState(PageDeleteConfigValue.Inherit, setState, deletionType) }}
+                >
+                  {t('security_setting.inherit')}
+                </button>
+              )
+              : (
+                <button
+                  className="dropdown-item"
+                  type="button"
+                  onClick={() => { this.setDeletionConfigState(PageDeleteConfigValue.Anyone, setState, deletionType) }}
+                >
+                  {t('security_setting.anyone')}
+                </button>
+              )
+          }
+          <button
+            className={`dropdown-item ${isButtonDisabled ? 'disabled' : ''}`}
+            type="button"
+            onClick={() => { this.setDeletionConfigState(PageDeleteConfigValue.AdminAndAuthor, setState, deletionType) }}
+          >
+            {t('security_setting.admin_and_author')}
+          </button>
+          <button
+            className="dropdown-item"
+            type="button"
+            onClick={() => { this.setDeletionConfigState(PageDeleteConfigValue.AdminOnly, setState, deletionType) }}
+          >
+            {t('security_setting.admin_only')}
+          </button>
+        </div>
+        <p className="form-text text-muted small">
+          {t(`security_setting.${getDeletionTypeForT(deletionType)}_explain`)}
+        </p>
+      </div>
+    );
+  }
+
+  renderPageDeletePermission(currentState, setState, deletionType, isButtonDisabled) {
+    const { t, adminGeneralSecurityContainer } = this.props;
+
+    const expandOtherOptions = isTypeDeletion(deletionType)
+      ? adminGeneralSecurityContainer.state.expandOtherOptionsForDeletion
+      : adminGeneralSecurityContainer.state.expandOtherOptionsForCompleteDeletion;
+
+    const setExpantOtherOptions = () => {
+      if (isTypeDeletion(deletionType)) {
+        adminGeneralSecurityContainer.switchExpandOtherOptionsForDeletion();
+        return;
+      }
+      adminGeneralSecurityContainer.switchExpandOtherOptionsForCompleteDeletion();
+      return;
+    };
+
     return (
-      <div className="row mb-4">
-        <div className="col-md-3 text-md-right mb-2">
-          <strong>{t(`security_setting.${deletionType}`)}</strong>
+      <div key={`page-delete-permission-dropdown-${deletionType}`} className="row">
+
+        <div className="col-md-3 text-md-right">
+          {!isRecursiveDeletion(deletionType) && isTypeDeletion(deletionType) && (
+            <strong>{t('security_setting.page_delete')}</strong>
+          )}
+          {!isRecursiveDeletion(deletionType) && !isTypeDeletion(deletionType) && (
+            <strong>{t('security_setting.page_delete_completely')}</strong>
+          )}
         </div>
+
         <div className="col-md-6">
-          <div className="dropdown">
-            <button
-              className="btn btn-outline-secondary dropdown-toggle text-right col-12 col-md-auto"
-              type="button"
-              id="dropdownMenuButton"
-              data-toggle="dropdown"
-              aria-haspopup="true"
-              aria-expanded="true"
-            >
-              <span className="float-left">
-                {currentState === PageDeleteConfigValue.Inherit && t('security_setting.inherit')}
-                {(currentState === PageDeleteConfigValue.Anyone || currentState == null)
-                    && t('security_setting.anyone')}
-                {currentState === PageDeleteConfigValue.AdminOnly && t('security_setting.admin_only')}
-                {currentState === PageDeleteConfigValue.AdminAndAuthor && t('security_setting.admin_and_author')}
-              </span>
-            </button>
-            <div className="dropdown-menu" aria-labelledby="dropdownMenuButton">
-              {
-                isRecursiveDeletion
-                  ? (
-                    <button
-                      className="dropdown-item"
-                      type="button"
-                      onClick={() => { setState(PageDeleteConfigValue.Inherit) }}
-                    >
-                      {t('security_setting.inherit')}
-                    </button>
-                  )
-                  : (
-                    <button
-                      className="dropdown-item"
-                      type="button"
-                      onClick={() => { setState(PageDeleteConfigValue.Anyone) }}
-                    >
-                      {t('security_setting.anyone')}
-                    </button>
-                  )
-              }
-              <button
-                className="dropdown-item"
-                type="button"
-                onClick={() => { setState(PageDeleteConfigValue.AdminOnly) }}
-              >
-                {t('security_setting.admin_only')}
-              </button>
-              <button
-                className="dropdown-item"
-                type="button"
-                onClick={() => { setState(PageDeleteConfigValue.AdminAndAuthor) }}
-              >
-                {t('security_setting.admin_and_author')}
-              </button>
-            </div>
-            <p className="form-text text-muted small">
-              {t(`security_setting.${deletionType}_explain`)}
-            </p>
-          </div>
+          {
+            !isRecursiveDeletion(deletionType)
+              ? (
+                <>{this.renderPageDeletePermissionDropdown(currentState, setState, deletionType, isButtonDisabled)}</>
+              )
+              : (
+                <>
+                  <button
+                    type="button"
+                    className="btn btn-link p-0 mb-4"
+                    aria-expanded="false"
+                    onClick={() => setExpantOtherOptions()}
+                  >
+                    <i className={`fa fa-fw fa-arrow-right ${expandOtherOptions ? 'fa-rotate-90' : ''}`}></i>
+                    { t('security_setting.other_options') }
+                  </button>
+                  <Collapse isOpen={expandOtherOptions}>
+                    <div className="pb-4">
+                      {this.renderPageDeletePermissionDropdown(currentState, setState, deletionType, isButtonDisabled)}
+                    </div>
+                  </Collapse>
+                </>
+              )
+          }
         </div>
       </div>
     );
@@ -115,6 +248,14 @@ class SecuritySetting extends React.Component {
       currentPageRecursiveDeletionAuthority, currentPageRecursiveCompleteDeletionAuthority,
     } = adminGeneralSecurityContainer.state;
 
+    const isButtonDisabledForDeletion = !validateDeleteConfigs(
+      adminGeneralSecurityContainer.state.currentPageDeletionAuthority, PageDeleteConfigValue.AdminAndAuthor,
+    );
+
+    const isButtonDisabledForCompleteDeletion = !validateDeleteConfigs(
+      adminGeneralSecurityContainer.state.currentPageCompleteDeletionAuthority, PageDeleteConfigValue.AdminAndAuthor,
+    );
+
     return (
       <React.Fragment>
         <h2 className="alert-anchor border-bottom">
@@ -181,7 +322,7 @@ class SecuritySetting extends React.Component {
           </tbody>
         </table>
 
-        <h4>{t('security_setting.page_access_and_delete_rights')}</h4>
+        <h4>{t('security_setting.page_access_rights')}</h4>
         <div className="row mb-4">
           <div className="col-md-3 text-md-right py-2">
             <strong>{t('security_setting.Guest Users Access')}</strong>
@@ -226,15 +367,22 @@ class SecuritySetting extends React.Component {
           </div>
         </div>
 
+        <h4>{t('security_setting.page_delete_rights')}</h4>
+        <div className="row mb-4"></div>
         {/* Render PageDeletePermissionDropdown */}
         {
           [
-            [currentPageDeletionAuthority, adminGeneralSecurityContainer.changePageDeletionAuthority, DeletionType.Deletion],
-            [currentPageCompleteDeletionAuthority, adminGeneralSecurityContainer.changePageCompleteDeletionAuthority, DeletionType.CompleteDeletion],
-            [currentPageRecursiveDeletionAuthority, adminGeneralSecurityContainer.changePageRecursiveDeletionAuthority, DeletionType.RecursiveDeletion],
+            [currentPageDeletionAuthority, adminGeneralSecurityContainer.changePageDeletionAuthority, DeletionType.Deletion, false],
+            // eslint-disable-next-line max-len
+            [currentPageRecursiveDeletionAuthority, adminGeneralSecurityContainer.changePageRecursiveDeletionAuthority, DeletionType.RecursiveDeletion, isButtonDisabledForDeletion],
+          ].map(arr => this.renderPageDeletePermission(arr[0], arr[1], arr[2], arr[3]))
+        }
+        {
+          [
+            [currentPageCompleteDeletionAuthority, adminGeneralSecurityContainer.changePageCompleteDeletionAuthority, DeletionType.CompleteDeletion, false],
             // eslint-disable-next-line max-len
-            [currentPageRecursiveCompleteDeletionAuthority, adminGeneralSecurityContainer.changePageRecursiveCompleteDeletionAuthority, DeletionType.RecursiveCompleteDeletion],
-          ].map(arr => this.renderPageDeletePermissionDropdown(arr[0], arr[1], arr[2], t))
+            [currentPageRecursiveCompleteDeletionAuthority, adminGeneralSecurityContainer.changePageRecursiveCompleteDeletionAuthority, DeletionType.RecursiveCompleteDeletion, isButtonDisabledForCompleteDeletion],
+          ].map(arr => this.renderPageDeletePermission(arr[0], arr[1], arr[2], arr[3]))
         }
 
         <h4>{t('security_setting.session')}</h4>

+ 1 - 4
packages/app/src/components/Admin/UserGroup/UserGroupDeleteModal.tsx

@@ -194,11 +194,8 @@ const UserGroupDeleteModal: FC<Props> = (props: Props) => {
         <div>
           <span className="font-weight-bold">{t('admin:user_group_management.group_name')}</span> : &quot;{props?.deleteUserGroup?.name || ''}&quot;
         </div>
-        <div className="text-danger mt-5">
+        <div className="text-danger mt-3">
           {t('admin:user_group_management.delete_modal.desc')}
-
-          {/* TODO 85462: Add a note: "All child groups will disappear */}
-
         </div>
       </ModalBody>
       <ModalFooter>

+ 63 - 7
packages/app/src/components/Admin/UserGroup/UserGroupForm.tsx

@@ -9,6 +9,7 @@ import Xss from '~/services/xss';
 
 type Props = {
   userGroup?: IUserGroupHasId,
+  selectableParentUserGroups?: IUserGroupHasId[],
   submitButtonLabel: TFunctionResult;
   onSubmit?: (userGroupData: Partial<IUserGroup>) => Promise<IUserGroupHasId | void>
 };
@@ -18,14 +19,16 @@ const UserGroupForm: FC<Props> = (props: Props) => {
 
   const { t } = useTranslation();
 
-  const { userGroup, submitButtonLabel, onSubmit } = props;
+  const {
+    userGroup, selectableParentUserGroups, submitButtonLabel, onSubmit,
+  } = props;
 
   /*
    * State
    */
   const [currentName, setName] = useState(userGroup != null ? userGroup.name : '');
   const [currentDescription, setDescription] = useState(userGroup != null ? userGroup.description : '');
-  const [currentParent, setParent] = useState(userGroup != null ? userGroup.parent : '');
+  const [selectedParent, setSelectedParent] = useState<IUserGroupHasId | undefined>(userGroup?.parent as IUserGroupHasId);
 
   /*
    * Function
@@ -38,6 +41,12 @@ const UserGroupForm: FC<Props> = (props: Props) => {
     setDescription(e.target.value);
   }, []);
 
+  const onChangeParerentButtonHandler = useCallback((userGroup: IUserGroupHasId) => {
+    if (userGroup._id !== selectedParent?._id) {
+      setSelectedParent(userGroup);
+    }
+  }, [selectedParent, setSelectedParent]);
+
   const onSubmitHandler = useCallback(async(e) => {
     e.preventDefault(); // no reload
 
@@ -45,15 +54,15 @@ const UserGroupForm: FC<Props> = (props: Props) => {
       return;
     }
 
-    await onSubmit({ name: currentName, description: currentDescription, parent: currentParent });
-  }, [currentName, currentDescription, currentParent, onSubmit]);
+    await onSubmit({ name: currentName, description: currentDescription, parent: selectedParent?._id });
+  }, [currentName, currentDescription, selectedParent, onSubmit]);
 
   return (
     <form onSubmit={onSubmitHandler}>
 
       <fieldset>
         <h2 className="admin-setting-header">{t('admin:user_group_management.basic_info')}</h2>
-        {/* TODO 85062: improve style */}
+
         {
           userGroup?.createdAt != null && (
             <div className="form-group row">
@@ -62,6 +71,7 @@ const UserGroupForm: FC<Props> = (props: Props) => {
             </div>
           )
         }
+
         <div className="form-group row">
           <label htmlFor="name" className="col-md-2 col-form-label">
             {t('admin:user_group_management.group_name')}
@@ -78,16 +88,62 @@ const UserGroupForm: FC<Props> = (props: Props) => {
             />
           </div>
         </div>
+
         <div className="form-group row">
           <label htmlFor="description" className="col-md-2 col-form-label">
             {t('Description')}
           </label>
           <div className="col-md-4">
-            <textarea className="form-control" name="description" value={currentDescription} onChange={onChangeDescriptionHandler} required />
+            <textarea className="form-control" name="description" value={currentDescription} onChange={onChangeDescriptionHandler} />
           </div>
         </div>
 
-        {/* TODO 88238: select parent dropdown */}
+        <div className="form-group row">
+          <label htmlFor="parent" className="col-md-2 col-form-label">
+            {t('admin:user_group_management.parent_group')}
+          </label>
+          <div className="dropdown col-md-4">
+            <button
+              type="button"
+              id="dropdownMenuButton"
+              data-toggle="dropdown"
+              className={`
+                btn btn-outline-secondary dropdown-toggle ${selectableParentUserGroups != null && selectableParentUserGroups.length > 0 ? '' : 'disabled'}
+              `}
+            >
+              {selectedParent?.name ?? t('admin:user_group_management.select_parent_group')}
+            </button>
+            <div className="dropdown-menu" aria-labelledby="dropdownMenuButton">
+              {
+                (selectableParentUserGroups != null && selectableParentUserGroups.length > 0) && (
+                  <>
+                    {
+                      selectableParentUserGroups.map(userGroup => (
+                        <button
+                          key={userGroup._id}
+                          type="button"
+                          className={`dropdown-item ${selectedParent?._id === userGroup._id ? 'active' : ''}`}
+                          onClick={() => onChangeParerentButtonHandler(userGroup)}
+                        >
+                          {userGroup.name}
+                        </button>
+                      ))
+                    }
+                  </>
+                )
+              }
+
+              <div className="dropdown-divider" />
+
+              <button
+                className="dropdown-item"
+                type="button"
+                onClick={() => { setSelectedParent(undefined) }}
+              >{t('admin:user_group_management.release_parent_group')}
+              </button>
+            </div>
+          </div>
+        </div>
 
         <div className="form-group row">
           <div className="offset-md-2 col-md-10">

+ 48 - 46
packages/app/src/components/Admin/UserGroup/UserGroupModal.tsx

@@ -15,7 +15,7 @@ import Xss from '~/services/xss';
 type Props = {
   userGroup?: IUserGroupHasId,
   buttonLabel?: TFunctionResult,
-  onClickButton?: (userGroupData: Partial<IUserGroupHasId>) => Promise<IUserGroupHasId | void>
+  onClickSubmit?: (userGroupData: Partial<IUserGroupHasId>) => Promise<IUserGroupHasId | void>
   isShow?: boolean
   onHide?: () => Promise<void> | void
 };
@@ -26,7 +26,7 @@ const UserGroupModal: FC<Props> = (props: Props) => {
   const { t } = useTranslation();
 
   const {
-    userGroup, buttonLabel, onClickButton, isShow, onHide,
+    userGroup, buttonLabel, onClickSubmit, isShow, onHide,
   } = props;
 
   /*
@@ -47,20 +47,20 @@ const UserGroupModal: FC<Props> = (props: Props) => {
     setDescription(e.target.value);
   }, []);
 
-  const onClickButtonHandler = useCallback(async(e) => {
+  const onSubmitHandler = useCallback(async(e) => {
     e.preventDefault(); // no reload
 
-    if (onClickButton == null) {
+    if (onClickSubmit == null) {
       return;
     }
 
-    await onClickButton({
+    await onClickSubmit({
       _id: userGroup?._id,
       name: currentName,
       description: currentDescription,
       parent: currentParent,
     });
-  }, [userGroup, currentName, currentDescription, currentParent, onClickButton]);
+  }, [userGroup, currentName, currentDescription, currentParent, onClickSubmit]);
 
   // componentDidMount
   useEffect(() => {
@@ -73,46 +73,48 @@ const UserGroupModal: FC<Props> = (props: Props) => {
 
   return (
     <Modal className="modal-md" isOpen={isShow} toggle={onHide}>
-      <ModalHeader tag="h4" toggle={onHide} className="bg-primary text-light">
-        {t('admin:user_group_management.basic_info')}
-      </ModalHeader>
-
-      <ModalBody>
-        <div className="form-group">
-          <label htmlFor="name">
-            {t('admin:user_group_management.group_name')}
-          </label>
-          <input
-            className="form-control"
-            type="text"
-            name="name"
-            placeholder={t('admin:user_group_management.group_example')}
-            value={currentName}
-            onChange={onChangeNameHandler}
-            required
-          />
-        </div>
-
-        <div className="form-group">
-          <label htmlFor="description">
-            {t('Description')}
-          </label>
-          <textarea className="form-control" name="description" value={currentDescription} onChange={onChangeDescriptionHandler} />
-        </div>
-
-        {/* TODO 90732: Add a drop-down to show selectable parents */}
-
-        {/* TODO 85462: Add a note that "if you change the parent, the offspring will also be moved together */}
-
-      </ModalBody>
-
-      <ModalFooter>
-        <div className="form-group">
-          <button type="button" className="btn btn-primary" onClick={onClickButtonHandler}>
-            {buttonLabel}
-          </button>
-        </div>
-      </ModalFooter>
+      <form onSubmit={onSubmitHandler}>
+        <ModalHeader tag="h4" toggle={onHide} className="bg-primary text-light">
+          {t('admin:user_group_management.basic_info')}
+        </ModalHeader>
+
+        <ModalBody>
+          <div className="form-group">
+            <label htmlFor="name">
+              {t('admin:user_group_management.group_name')}
+            </label>
+            <input
+              className="form-control"
+              type="text"
+              name="name"
+              placeholder={t('admin:user_group_management.group_example')}
+              value={currentName}
+              onChange={onChangeNameHandler}
+              required
+            />
+          </div>
+
+          <div className="form-group">
+            <label htmlFor="description">
+              {t('Description')}
+            </label>
+            <textarea className="form-control" name="description" value={currentDescription} onChange={onChangeDescriptionHandler} />
+          </div>
+
+          {/* TODO 90732: Add a drop-down to show selectable parents */}
+
+          {/* TODO 85462: Add a note that "if you change the parent, the offspring will also be moved together */}
+
+        </ModalBody>
+
+        <ModalFooter>
+          <div className="form-group">
+            <button type="submit" className="btn btn-primary">
+              {buttonLabel}
+            </button>
+          </div>
+        </ModalFooter>
+      </form>
     </Modal>
   );
 };

+ 14 - 5
packages/app/src/components/Admin/UserGroup/UserGroupPage.tsx

@@ -12,7 +12,6 @@ import { CustomWindow } from '~/interfaces/global';
 import { apiv3Delete, apiv3Post, apiv3Put } from '~/client/util/apiv3-client';
 import { useSWRxUserGroupList, useSWRxChildUserGroupList, useSWRxUserGroupRelationList } from '~/stores/user-group';
 import { useIsAclEnabled } from '~/stores/context';
-import userGroup from '~/server/models/user-group';
 
 const UserGroupPage: FC = () => {
   const xss: Xss = (window as CustomWindow).xss;
@@ -94,13 +93,18 @@ const UserGroupPage: FC = () => {
         name: userGroupData.name,
         description: userGroupData.description,
       });
+
       toastSuccess(t('toaster.update_successed', { target: t('UserGroup') }));
+
+      // mutate
       await mutateUserGroups();
+
+      hideCreateModal();
     }
     catch (err) {
       toastError(err);
     }
-  }, [t, mutateUserGroups]);
+  }, [t, mutateUserGroups, hideCreateModal]);
 
   const updateUserGroup = useCallback(async(userGroupData: IUserGroupHasId) => {
     try {
@@ -108,13 +112,18 @@ const UserGroupPage: FC = () => {
         name: userGroupData.name,
         description: userGroupData.description,
       });
+
       toastSuccess(t('toaster.update_successed', { target: t('UserGroup') }));
+
+      // mutate
       await mutateUserGroups();
+
+      hideUpdateModal();
     }
     catch (err) {
       toastError(err);
     }
-  }, [t, mutateUserGroups]);
+  }, [t, mutateUserGroups, hideUpdateModal]);
 
   const deleteUserGroupById = useCallback(async(deleteGroupId: string, actionName: string, transferToUserGroupId: string) => {
     try {
@@ -152,7 +161,7 @@ const UserGroupPage: FC = () => {
 
       <UserGroupModal
         buttonLabel={t('Create')}
-        onClickButton={createUserGroup}
+        onClickSubmit={createUserGroup}
         isShow={isCreateModalShown}
         onHide={hideCreateModal}
       />
@@ -160,7 +169,7 @@ const UserGroupPage: FC = () => {
       <UserGroupModal
         userGroup={selectedUserGroup}
         buttonLabel={t('Update')}
-        onClickButton={updateUserGroup}
+        onClickSubmit={updateUserGroup}
         isShow={isUpdateModalShown}
         onHide={hideUpdateModal}
       />

+ 43 - 14
packages/app/src/components/Admin/UserGroupDetail/UserGroupDetailPage.tsx

@@ -21,7 +21,8 @@ import {
   IUserGroup, IUserGroupHasId,
 } from '~/interfaces/user';
 import {
-  useSWRxUserGroupPages, useSWRxUserGroupRelationList, useSWRxChildUserGroupList, useSWRxSelectableUserGroups, useSWRxAncestorUserGroups,
+  useSWRxUserGroupPages, useSWRxUserGroupRelationList, useSWRxChildUserGroupList,
+  useSWRxSelectableParentUserGroups, useSWRxSelectableChildUserGroups, useSWRxAncestorUserGroups,
 } from '~/stores/user-group';
 import { useIsAclEnabled } from '~/stores/context';
 
@@ -55,9 +56,10 @@ const UserGroupDetailPage: FC = () => {
   const { data: userGroupRelationList, mutate: mutateUserGroupRelations } = useSWRxUserGroupRelationList(childUserGroupIds);
   const childUserGroupRelations = userGroupRelationList != null ? userGroupRelationList : [];
 
-  const { data: selectableUserGroups, mutate: mutateSelectableUserGroups } = useSWRxSelectableUserGroups(userGroup._id);
+  const { data: selectableParentUserGroups, mutate: mutateSelectableParentUserGroups } = useSWRxSelectableParentUserGroups(userGroup._id);
+  const { data: selectableChildUserGroups, mutate: mutateSelectableChildUserGroups } = useSWRxSelectableChildUserGroups(userGroup._id);
 
-  const { data: ancestorUserGroups } = useSWRxAncestorUserGroups(userGroup._id);
+  const { data: ancestorUserGroups, mutate: mutateAncestorUserGroups } = useSWRxAncestorUserGroups(userGroup._id);
 
   const { data: isAclEnabled } = useIsAclEnabled();
 
@@ -78,17 +80,27 @@ const UserGroupDetailPage: FC = () => {
     setSearchType(searchType);
   }, []);
 
-  const updateUserGroup = useCallback(async(param: Partial<IUserGroup>) => {
+  const updateUserGroup = useCallback(async(UserGroupData: Partial<IUserGroup>) => {
     try {
-      const res = await apiv3Put<{ userGroup: IUserGroupHasId }>(`/user-groups/${userGroup._id}`, param);
+      const res = await apiv3Put<{ userGroup: IUserGroupHasId }>(`/user-groups/${userGroup._id}`, {
+        name: UserGroupData.name,
+        description: UserGroupData.description,
+        parentId: UserGroupData.parent,
+      });
       const { userGroup: newUserGroup } = res.data;
       setUserGroup(newUserGroup);
+
+      // mutate
+      mutateAncestorUserGroups();
+      mutateSelectableChildUserGroups();
+      mutateSelectableParentUserGroups();
+
       toastSuccess(t('toaster.update_successed', { target: t('UserGroup') }));
     }
     catch (err) {
       toastError(err);
     }
-  }, [t, userGroup._id, setUserGroup]);
+  }, [t, userGroup._id, setUserGroup, mutateAncestorUserGroups]);
 
   const fetchApplicableUsers = useCallback(async(searchWord) => {
     const res = await apiv3Get(`/user-groups/${userGroup._id}/unrelated-users`, {
@@ -131,13 +143,18 @@ const UserGroupDetailPage: FC = () => {
         description: userGroupData.description,
         parentId: userGroupData.parent,
       });
-      mutateChildUserGroups();
+
       toastSuccess(t('toaster.update_successed', { target: t('UserGroup') }));
+
+      // mutate
+      mutateChildUserGroups();
+
+      hideUpdateModal();
     }
     catch (err) {
       toastError(err);
     }
-  }, [t, mutateChildUserGroups]);
+  }, [t, mutateChildUserGroups, hideUpdateModal]);
 
   const onClickAddChildButtonHandler = async(selectedUserGroup: IUserGroupHasId) => {
     try {
@@ -147,8 +164,12 @@ const UserGroupDetailPage: FC = () => {
         parentId: userGroup._id,
         forceUpdateParents: false,
       });
-      mutateSelectableUserGroups();
+
+      // mutate
       mutateChildUserGroups();
+      mutateSelectableChildUserGroups();
+      mutateSelectableParentUserGroups();
+
       toastSuccess(t('toaster.update_successed', { target: t('UserGroup') }));
     }
     catch (err) {
@@ -171,13 +192,20 @@ const UserGroupDetailPage: FC = () => {
         description: userGroupData.description,
         parentId: userGroup._id,
       });
-      mutateChildUserGroups();
+
       toastSuccess(t('toaster.update_successed', { target: t('UserGroup') }));
+
+      // mutate
+      mutateChildUserGroups();
+      mutateSelectableChildUserGroups();
+      mutateSelectableParentUserGroups();
+
+      hideCreateModal();
     }
     catch (err) {
       toastError(err);
     }
-  }, [t, userGroup, mutateChildUserGroups]);
+  }, [t, userGroup, mutateChildUserGroups, mutateSelectableChildUserGroups, mutateSelectableParentUserGroups, hideCreateModal]);
 
   const showDeleteModal = useCallback(async(group: IUserGroupHasId) => {
     setSelectedUserGroup(group);
@@ -240,6 +268,7 @@ const UserGroupDetailPage: FC = () => {
       <div className="mt-4 form-box">
         <UserGroupForm
           userGroup={userGroup}
+          selectableParentUserGroups={selectableParentUserGroups}
           submitButtonLabel={t('Update')}
           onSubmit={updateUserGroup}
         />
@@ -250,7 +279,7 @@ const UserGroupDetailPage: FC = () => {
 
       <h2 className="admin-setting-header mt-4">{t('admin:user_group_management.child_group_list')}</h2>
       <UserGroupDropdown
-        selectableUserGroups={selectableUserGroups}
+        selectableUserGroups={selectableChildUserGroups}
         onClickAddExistingUserGroupButtonHandler={onClickAddChildButtonHandler}
         onClickCreateUserGroupButtonHandler={showCreateModal}
       />
@@ -258,14 +287,14 @@ const UserGroupDetailPage: FC = () => {
       <UserGroupModal
         userGroup={selectedUserGroup}
         buttonLabel={t('Update')}
-        onClickButton={updateChildUserGroup}
+        onClickSubmit={updateChildUserGroup}
         isShow={isUpdateModalShown}
         onHide={hideUpdateModal}
       />
 
       <UserGroupModal
         buttonLabel={t('Create')}
-        onClickButton={createChildUserGroup}
+        onClickSubmit={createChildUserGroup}
         isShow={isCreateModalShown}
         onHide={hideCreateModal}
       />

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

@@ -180,6 +180,7 @@ const PageItemControlDropdownMenu = React.memo((props: DropdownMenuProps): JSX.E
               className={`pt-2 ${pageInfo.isDeletable ? 'text-danger' : ''}`}
               disabled={!pageInfo.isDeletable}
               onClick={deleteItemClickedHandler}
+              data-testid="open-page-delete-modal-btn"
             >
               <i className="icon-fw icon-trash"></i>
               {t('Delete')}

+ 4 - 1
packages/app/src/components/LegacyPrivatePagesMigrationModal.tsx

@@ -55,7 +55,10 @@ export const LegacyPrivatePagesMigrationModal = (props: Props): JSX.Element => {
           className="custom-control-input"
           id="convertRecursively"
           type="checkbox"
-          onChange={e => setIsRecursively(e.target.checked)}
+          checked={isRecursively}
+          onChange={(e) => {
+            setIsRecursively(e.target.checked);
+          }}
         />
         <label className="custom-control-label" htmlFor="convertRecursively">
           { t('private_legacy_pages.modal.convert_recursively_label') }

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

@@ -40,7 +40,7 @@ const GlobalSearch: FC<Props> = (props: Props) => {
 
     // navigate to page
     if (page != null) {
-      window.location.href = page._id;
+      window.location.href = `/${page._id}`;
     }
   }, []);
 

+ 2 - 7
packages/app/src/components/Page/TrashPageAlert.jsx

@@ -64,14 +64,9 @@ const TrashPageAlert = (props) => {
         revision: revisionId,
         path,
       },
+      meta: pageInfo,
     };
-    openDeleteModal(
-      [pageToDelete],
-      {
-        isAbleToDeleteCompletely: pageInfo.isAbleToDeleteCompletely,
-        onDeleted: onDeletedHandler,
-      },
-    );
+    openDeleteModal([pageToDelete], { onDeleted: onDeletedHandler });
   }
 
   function renderEmptyButton() {

+ 3 - 3
packages/app/src/components/PageCreateModal.jsx

@@ -1,6 +1,6 @@
 
 import React, {
-  useEffect, useState, useMemo,
+  useEffect, useState, useMemo, useCallback,
 } from 'react';
 import PropTypes from 'prop-types';
 
@@ -135,8 +135,8 @@ const PageCreateModal = (props) => {
     setPageNameInput(value);
   }
 
-  function ppacSubmitHandler() {
-    createInputPage();
+  function ppacSubmitHandler(input) {
+    redirectToEditor(input);
   }
 
   /**

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

@@ -222,7 +222,7 @@ const PageDeleteModal: FC = () => {
   };
 
   return (
-    <Modal size="lg" isOpen={isOpened} toggle={closeDeleteModal} className="grw-create-page">
+    <Modal size="lg" isOpen={isOpened} toggle={closeDeleteModal} data-testid="page-delete-modal" className="grw-create-page">
       <ModalHeader tag="h4" toggle={closeDeleteModal} className={`bg-${deleteIconAndKey[deleteMode].color} text-light`}>
         <i className={`icon-fw icon-${deleteIconAndKey[deleteMode].icon}`}></i>
         { t(`modal_delete.delete_${deleteIconAndKey[deleteMode].translationKey}`) }

+ 1 - 2
packages/app/src/components/PageDuplicateModal.tsx

@@ -163,8 +163,7 @@ const PageDuplicateModal = (): JSX.Element => {
     || (isDuplicateRecursively && isDuplicateRecursivelyWithoutExistPath);
 
   return (
-    // eslint-disable-next-line jsx-quotes
-    <Modal size="lg" isOpen={isOpened} toggle={closeDuplicateModal} data-testid='page-duplicate-modal' className="grw-duplicate-page" autoFocus={false}>
+    <Modal size="lg" isOpen={isOpened} toggle={closeDuplicateModal} data-testid="page-duplicate-modal" className="grw-duplicate-page" autoFocus={false}>
       <ModalHeader tag="h4" toggle={closeDuplicateModal} className="bg-primary text-light">
         { t('modal_duplicate.label.Duplicate page') }
       </ModalHeader>

+ 3 - 3
packages/app/src/components/PageEditor/LinkEditModal.jsx

@@ -37,7 +37,7 @@ class LinkEditModal extends React.PureComponent {
       linkInputValue: '',
       labelInputValue: '',
       linkerType: Linker.types.markdownLink,
-      markdown: '',
+      markdown: null,
       previewError: '',
       permalink: '',
       isPreviewOpen: false,
@@ -152,7 +152,7 @@ class LinkEditModal extends React.PureComponent {
   async setMarkdown() {
     const { t } = this.props;
     const path = this.state.linkInputValue;
-    let markdown = '';
+    let markdown = null;
     let permalink = '';
     let previewError = '';
 
@@ -204,7 +204,7 @@ class LinkEditModal extends React.PureComponent {
   handleChangeTypeahead(selected) {
     const pageWithMeta = selected[0];
     if (pageWithMeta != null) {
-      const page = pageWithMeta.pageData;
+      const page = pageWithMeta.data;
       const permalink = `${window.location.origin}/${page.id}`;
       this.setState({ linkInputValue: page.path, permalink });
     }

+ 1 - 1
packages/app/src/components/PageEditor/PreviewWithSuspense.jsx

@@ -5,7 +5,7 @@ import Preview from './Preview';
 import { withLoadingSppiner } from '../SuspenseUtils';
 
 function PagePreview(props) {
-  if (props.markdown === '') {
+  if (props.markdown == null) {
     if (props.error !== '') {
       return props.error;
     }

+ 22 - 2
packages/app/src/components/PageList/PageListItemL.tsx

@@ -12,6 +12,7 @@ import urljoin from 'url-join';
 import { UserPicture, PageListMeta } from '@growi/ui';
 import { DevidedPagePath } from '@growi/core';
 
+import { useSWRxPageInfo } from '../../stores/page';
 
 import { ISelectable } from '~/client/interfaces/selectable-all';
 import { bookmark, unbookmark } from '~/client/services/page-operation';
@@ -20,7 +21,7 @@ import {
   usePageRenameModal, usePageDuplicateModal, usePageDeleteModal, usePutBackPageModal,
 } from '~/stores/modal';
 import {
-  IPageInfoAll, IPageInfoForEntity, IPageInfoForListing, IPageWithMeta, isIPageInfoForListing,
+  IPageInfoAll, IPageInfoForEntity, IPageInfoForListing, IPageWithMeta, isIPageInfoForListing, isIPageInfoForEntity,
 } from '~/interfaces/page';
 import { IPageSearchMeta, isIPageSearchMeta } from '~/interfaces/search';
 import {
@@ -78,6 +79,9 @@ const PageListItemLSubstance: ForwardRefRenderFunction<ISelectable, Props> = (pr
   const { open: openDeleteModal } = usePageDeleteModal();
   const { open: openPutBackPageModal } = usePutBackPageModal();
 
+  const shouldFetch = isSelected && (pageData != null || pageMeta != null);
+  const { data: pageInfo } = useSWRxPageInfo(shouldFetch ? pageData?._id : null);
+
   const elasticSearchResult = isIPageSearchMeta(pageMeta) ? pageMeta.elasticSearchResult : null;
   const revisionShortBody = isIPageInfoForListing(pageMeta) ? pageMeta.revisionShortBody : null;
 
@@ -137,6 +141,22 @@ const PageListItemLSubstance: ForwardRefRenderFunction<ISelectable, Props> = (pr
 
   const shouldDangerouslySetInnerHTMLForPaths = elasticSearchResult != null && elasticSearchResult.highlightedPath.length > 0;
 
+  let likerCount;
+  if (isSelected && isIPageInfoForEntity(pageInfo)) {
+    likerCount = pageInfo.likerIds?.length;
+  }
+  else {
+    likerCount = pageData.liker.length;
+  }
+
+  let bookmarkCount;
+  if (isSelected && isIPageInfoForEntity(pageInfo)) {
+    bookmarkCount = pageInfo.bookmarkCount;
+  }
+  else {
+    bookmarkCount = pageMeta?.bookmarkCount;
+  }
+
   return (
     <li
       key={pageData._id}
@@ -199,7 +219,7 @@ const PageListItemLSubstance: ForwardRefRenderFunction<ISelectable, Props> = (pr
 
               {/* page meta */}
               <div className="d-none d-md-flex py-0 px-1 ml-2 text-nowrap">
-                <PageListMeta page={pageData} bookmarkCount={pageMeta?.bookmarkCount} shouldSpaceOutIcon />
+                <PageListMeta page={pageData} likerCount={likerCount} bookmarkCount={bookmarkCount} shouldSpaceOutIcon />
               </div>
 
               {/* doropdown icon includes page control buttons */}

+ 4 - 23
packages/app/src/components/PagePathAutoComplete.jsx

@@ -8,27 +8,9 @@ import SearchTypeahead from './SearchTypeahead';
 const PagePathAutoComplete = (props) => {
 
   const {
-    addTrailingSlash, onSubmit, onInputChange, initializedPath,
+    addTrailingSlash, initializedPath,
   } = props;
 
-  function inputChangeHandler(pages) {
-    if (onInputChange == null) {
-      return;
-    }
-    const page = pages[0]; // should be single page selected
-
-    if (page != null) {
-      onInputChange(page.path);
-    }
-  }
-
-  function submitHandler() {
-    if (onSubmit == null) {
-      return;
-    }
-    onSubmit();
-  }
-
   function getKeywordOnInit(path) {
     if (path == null) {
       return;
@@ -40,10 +22,8 @@ const PagePathAutoComplete = (props) => {
 
   return (
     <SearchTypeahead
-      onSubmit={submitHandler}
-      onChange={inputChangeHandler}
-      onInputChange={props.onInputChange}
-      inputName="new_path"
+      {...props}
+      inputProps={{ name: 'new_path' }}
       placeholder="Input page path"
       keywordOnInit={getKeywordOnInit(initializedPath)}
       autoFocus={props.autoFocus}
@@ -56,6 +36,7 @@ PagePathAutoComplete.propTypes = {
   initializedPath:  PropTypes.string,
   addTrailingSlash: PropTypes.bool,
 
+  onChange:         PropTypes.func,
   onSubmit:         PropTypes.func,
   onInputChange:    PropTypes.func,
   autoFocus:        PropTypes.bool,

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

@@ -9,6 +9,7 @@ import {
 import { useTranslation } from 'react-i18next';
 
 import { debounce } from 'throttle-debounce';
+import { pagePathUtils } from '@growi/core';
 import { usePageRenameModal } from '~/stores/modal';
 import { toastError } from '~/client/util/apiNotification';
 
@@ -29,6 +30,7 @@ const isV5Compatible = (meta: unknown): boolean => {
 const PageRenameModal = (): JSX.Element => {
   const { t } = useTranslation();
 
+  const { isUsersHomePage } = pagePathUtils;
   const { data: siteUrl } = useSiteUrl();
   const { data: renameModalData, close: closeRenameModal } = usePageRenameModal();
 
@@ -53,6 +55,7 @@ const PageRenameModal = (): JSX.Element => {
   const [isRemainMetadata, setIsRemainMetadata] = useState(false);
   const [expandOtherOptions, setExpandOtherOptions] = useState(false);
   const [subordinatedError] = useState(null);
+  const [isMatchedWithUserHomePagePath, setIsMatchedWithUserHomePagePath] = useState(false);
 
   const updateSubordinatedList = useCallback(async() => {
     if (page == null) {
@@ -135,11 +138,21 @@ const PageRenameModal = (): JSX.Element => {
     return debounce(1000, checkExistPaths);
   }, [checkExistPaths]);
 
+  const checkIsUsersHomePageDebounce = useMemo(() => {
+    const checkIsPagePathRenameable = () => {
+      setIsMatchedWithUserHomePagePath(isUsersHomePage(pageNameInput));
+    };
+
+    return debounce(1000, checkIsPagePathRenameable);
+  }, [isUsersHomePage, pageNameInput]);
+
   useEffect(() => {
     if (page != null && pageNameInput !== page.data.path) {
       checkExistPathsDebounce(page.data.path, pageNameInput);
+      checkIsUsersHomePageDebounce(pageNameInput);
     }
-  }, [pageNameInput, subordinatedPages, checkExistPathsDebounce, page]);
+  }, [pageNameInput, subordinatedPages, checkExistPathsDebounce, page, checkIsUsersHomePageDebounce]);
+
 
   /**
    * change pageNameInput
@@ -176,9 +189,18 @@ const PageRenameModal = (): JSX.Element => {
   const { path } = page.data;
   const isTargetPageDuplicate = existingPaths.includes(pageNameInput);
 
-  const submitButtonDisabled = isV5Compatible(page.meta)
-    ? existingPaths.length !== 0 // v5 data
-    : !isRenameRecursively; // v4 data
+  let submitButtonDisabled = false;
+
+  if (isMatchedWithUserHomePagePath) {
+    submitButtonDisabled = true;
+  }
+  else if (isV5Compatible(page.meta)) {
+    submitButtonDisabled = existingPaths.length !== 0; // v5 data
+  }
+  else {
+    submitButtonDisabled = !isRenameRecursively; // v4 data
+  }
+
 
   return (
     <Modal size="lg" isOpen={isOpened} toggle={closeRenameModal} data-testid="page-rename-modal" className="grw-pagerename-page" autoFocus={false}>
@@ -212,6 +234,9 @@ const PageRenameModal = (): JSX.Element => {
         { isTargetPageDuplicate && (
           <p className="text-danger">Error: Target path is duplicated.</p>
         ) }
+        { isMatchedWithUserHomePagePath && (
+          <p className="text-danger">Error: Cannot move to directory under /user page.</p>
+        ) }
 
         { !isV5Compatible(page.meta) && (
           <>

+ 14 - 32
packages/app/src/components/SearchForm.tsx

@@ -5,6 +5,7 @@ import React, {
 import { useTranslation } from 'react-i18next';
 
 import { IFocusable } from '~/client/interfaces/focusable';
+import { TypeaheadProps } from '~/client/interfaces/react-bootstrap-typeahead';
 import { IPageWithMeta } from '~/interfaces/page';
 import { IPageSearchMeta } from '~/interfaces/search';
 
@@ -13,13 +14,12 @@ import SearchTypeahead from './SearchTypeahead';
 
 type SearchFormHelpProps = {
   isReachable: boolean,
-  isShownHelp: boolean,
 }
 
-const SearchFormHelp: FC<SearchFormHelpProps> = (props: SearchFormHelpProps) => {
+const SearchFormHelp: FC<SearchFormHelpProps> = React.memo((props: SearchFormHelpProps) => {
   const { t } = useTranslation();
 
-  const { isReachable, isShownHelp } = props;
+  const { isReachable } = props;
 
   if (!isReachable) {
     return (
@@ -30,10 +30,6 @@ const SearchFormHelp: FC<SearchFormHelpProps> = (props: SearchFormHelpProps) =>
     );
   }
 
-  if (!isShownHelp) {
-    return <></>;
-  }
-
   return (
     <table className="table grw-search-table search-help m-0">
       <caption className="text-left text-primary p-2">
@@ -77,33 +73,29 @@ const SearchFormHelp: FC<SearchFormHelpProps> = (props: SearchFormHelpProps) =>
       </tbody>
     </table>
   );
-};
+});
 
 
-type Props = {
+type Props = TypeaheadProps & {
   isSearchServiceReachable: boolean,
 
-  dropup?: boolean,
-  keyword?: string,
+  keywordOnInit?: string,
   disableIncrementalSearch?: boolean,
   onChange?: (data: IPageWithMeta<IPageSearchMeta>[]) => void,
-  onBlur?: () => void,
-  onFocus?: () => void,
   onSubmit?: (input: string) => void,
-  onInputChange?: (text: string) => void,
 };
 
 
 const SearchForm: ForwardRefRenderFunction<IFocusable, Props> = (props: Props, ref) => {
   const { t } = useTranslation();
   const {
-    isSearchServiceReachable, dropup,
+    isSearchServiceReachable,
+    keywordOnInit,
     disableIncrementalSearch,
-    onChange, onBlur, onFocus, onSubmit, onInputChange,
+    dropup, onChange, onBlur, onFocus, onSubmit, onInputChange,
   } = props;
 
   const [searchError, setSearchError] = useState<Error | null>(null);
-  const [isShownHelp, setShownHelp] = useState(false);
 
   const searchTyheaheadRef = useRef<IFocusable>(null);
 
@@ -131,25 +123,15 @@ const SearchForm: ForwardRefRenderFunction<IFocusable, Props> = (props: Props, r
       dropup={dropup}
       emptyLabel={emptyLabel}
       placeholder={placeholder}
-      disableIncrementalSearch={disableIncrementalSearch}
       onChange={onChange}
       onSubmit={onSubmit}
       onInputChange={onInputChange}
       onSearchError={err => setSearchError(err)}
-      onBlur={() => {
-        setShownHelp(false);
-        if (onBlur != null) {
-          onBlur();
-        }
-      }}
-      onFocus={() => {
-        setShownHelp(true);
-        if (onFocus != null) {
-          onFocus();
-        }
-      }}
-      helpElement={<SearchFormHelp isShownHelp={isShownHelp} isReachable={isSearchServiceReachable} />}
-      keywordOnInit={props.keyword}
+      onBlur={onBlur}
+      onFocus={onFocus}
+      keywordOnInit={keywordOnInit}
+      disableIncrementalSearch={disableIncrementalSearch}
+      helpElement={<SearchFormHelp isReachable={isSearchServiceReachable} />}
     />
   );
 };

+ 1 - 1
packages/app/src/components/SearchPage/SearchControl.tsx

@@ -66,7 +66,7 @@ const SearchControl: FC <Props> = React.memo((props: Props) => {
         <div className="flex-grow-1 mx-4">
           <SearchForm
             isSearchServiceReachable={isSearchServiceReachable}
-            keyword={keyword}
+            keywordOnInit={keyword}
             disableIncrementalSearch
             onSubmit={searchFormSubmittedHandler}
           />

+ 142 - 127
packages/app/src/components/SearchTypeahead.tsx

@@ -1,32 +1,33 @@
 import React, {
   FC, ForwardRefRenderFunction, forwardRef, useImperativeHandle,
-  KeyboardEvent, useCallback, useRef, useState, MouseEvent,
+  KeyboardEvent, useCallback, useRef, useState, MouseEvent, useEffect,
 } from 'react';
 
-import { AsyncTypeahead } from 'react-bootstrap-typeahead';
+import { AsyncTypeahead, Menu, MenuItem } from 'react-bootstrap-typeahead';
 
 import { UserPicture, PageListMeta, PagePathLabel } from '@growi/ui';
 
 import { IFocusable } from '~/client/interfaces/focusable';
 import { TypeaheadProps } from '~/client/interfaces/react-bootstrap-typeahead';
-import { apiGet } from '~/client/util/apiv1-client';
-import { IFormattedSearchResult, IPageSearchMeta } from '~/interfaces/search';
+import { IPageSearchMeta } from '~/interfaces/search';
 import { IPageWithMeta } from '~/interfaces/page';
+import { useSWRxFullTextSearch } from '~/stores/search';
 
 
 type ResetFormButtonProps = {
-  keywordOnInit: string,
-  input: string,
+  input?: string,
   onReset: (e: MouseEvent<HTMLButtonElement>) => void,
 }
 
 const ResetFormButton: FC<ResetFormButtonProps> = (props: ResetFormButtonProps) => {
-  const isHidden = props.input.length === 0;
+  const { input, onReset } = props;
+
+  const isHidden = input == null || input.length === 0;
 
   return isHidden ? (
     <span />
   ) : (
-    <button type="button" className="btn btn-outline-secondary search-clear text-muted border-0" onMouseDown={props.onReset}>
+    <button type="button" className="btn btn-outline-secondary search-clear text-muted border-0" onMouseDown={onReset}>
       <i className="icon-close" />
     </button>
   );
@@ -34,117 +35,79 @@ const ResetFormButton: FC<ResetFormButtonProps> = (props: ResetFormButtonProps)
 
 
 type Props = TypeaheadProps & {
-  onSearchSuccess?: (res: IPageWithMeta<IPageSearchMeta>[]) => void,
   onSearchError?: (err: Error) => void,
   onSubmit?: (input: string) => void,
-  inputName?: string,
   keywordOnInit?: string,
   disableIncrementalSearch?: boolean,
-  // eslint-disable-next-line @typescript-eslint/no-explicit-any
-  helpElement?: any,
+  helpElement?: React.ReactNode,
 };
 
 // see https://github.com/ericgio/react-bootstrap-typeahead/issues/266#issuecomment-414987723
 type TypeaheadInstance = {
   clear: () => void,
   focus: () => void,
-  setState: ({ text: string }) => void,
-}
-type TypeaheadInstanceFactory = {
-  getInstance: () => TypeaheadInstance,
+  toggleMenu: () => void,
+  state: { selected: IPageWithMeta<IPageSearchMeta>[] }
 }
 
 const SearchTypeahead: ForwardRefRenderFunction<IFocusable, Props> = (props: Props, ref) => {
   const {
-    onSearchSuccess, onSearchError, onInputChange, onSubmit,
-    emptyLabel, helpElement, keywordOnInit, disableIncrementalSearch,
+    onSearchError, onSearch, onInputChange, onChange, onSubmit,
+    inputProps, keywordOnInit, disableIncrementalSearch, helpElement,
+    onBlur, onFocus,
   } = props;
 
-  // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
-  const [input, setInput] = useState(props.keywordOnInit!);
-  const [pages, setPages] = useState<IPageWithMeta<IPageSearchMeta>[]>();
-  // eslint-disable-next-line @typescript-eslint/no-unused-vars
-  const [searchError, setSearchError] = useState<Error | null>(null);
-  const [isLoading, setLoading] = useState(false);
+  const [input, setInput] = useState(keywordOnInit);
+  const [searchKeyword, setSearchKeyword] = useState('');
+  const [isForcused, setFocused] = useState(false);
+
+  const { data: searchResult, error: searchError } = useSWRxFullTextSearch(
+    disableIncrementalSearch ? null : searchKeyword,
+    { limit: 10 },
+  );
 
-  const typeaheadRef = useRef<TypeaheadInstanceFactory>(null);
+  const typeaheadRef = useRef<TypeaheadInstance>(null);
 
   const focusToTypeahead = () => {
-    const instance = typeaheadRef.current?.getInstance();
+    const instance = typeaheadRef.current;
     if (instance != null) {
       instance.focus();
     }
   };
 
-  // publish focus()
-  useImperativeHandle(ref, () => ({
-    focus: focusToTypeahead,
-  }));
-
-  const changeKeyword = (text: string | undefined) => {
-    const instance = typeaheadRef.current?.getInstance();
+  const clearTypeahead = () => {
+    const instance = typeaheadRef.current;
     if (instance != null) {
       instance.clear();
-      instance.setState({ text });
     }
   };
 
-  const resetForm = (e: MouseEvent<HTMLButtonElement>) => {
+  // publish focus()
+  useImperativeHandle(ref, () => ({
+    focus: focusToTypeahead,
+  }));
+
+  const resetForm = useCallback((e: MouseEvent<HTMLButtonElement>) => {
     e.preventDefault();
 
     setInput('');
-    changeKeyword('');
-    setPages([]);
+    setSearchKeyword('');
 
+    clearTypeahead();
     focusToTypeahead();
 
-    if (onInputChange != null) {
-      onInputChange('');
-    }
-  };
-
-  /**
-   * Callback function which is occured when search is exit successfully
-   */
-  const searchSuccessHandler = useCallback((result: IFormattedSearchResult) => {
-    const searchResultData = result.data;
-    setPages(searchResultData);
-
-    if (onSearchSuccess != null) {
-      onSearchSuccess(searchResultData);
+    if (onSearch != null) {
+      onSearch('');
     }
-  }, [onSearchSuccess]);
-
-  /**
-   * Callback function which is occured when search is exit abnormaly
-   */
-  const searchErrorHandler = useCallback((err: Error) => {
-    setSearchError(err);
+  }, [onSearch]);
 
-    if (onSearchError != null) {
-      onSearchError(err);
-    }
-  }, [onSearchError]);
+  const searchHandler = useCallback((text: string) => {
+    setSearchKeyword(text);
 
-  const search = useCallback(async(keyword: string) => {
-    if (disableIncrementalSearch || keyword === '') {
-      return;
+    if (onSearch != null) {
+      onSearch(text);
     }
-
-    setLoading(true);
-
-    try {
-      const result = await apiGet('/search', { q: keyword }) as IFormattedSearchResult;
-      searchSuccessHandler(result);
-    }
-    catch (err) {
-      searchErrorHandler(err);
-    }
-    finally {
-      setLoading(false);
-    }
-
-  }, [disableIncrementalSearch, searchErrorHandler, searchSuccessHandler]);
+  }, [onSearch]);
 
   const inputChangeHandler = useCallback((text: string) => {
     setInput(text);
@@ -152,53 +115,98 @@ const SearchTypeahead: ForwardRefRenderFunction<IFocusable, Props> = (props: Pro
     if (onInputChange != null) {
       onInputChange(text);
     }
+  }, [onInputChange]);
+
+  /* -------------------------------------------------------------------------------------------------------
+   *
+   * Dirty hack for https://github.com/ericgio/react-bootstrap-typeahead/issues/492 -- 2022.03.22 Yuki Takei
+   *
+   * 1. Schedule to submit with delay when Enter key downed
+   * 2. Fire onChange and cancel the schedule to submit if change event occured
+   * 3. Fire onSubmit if the schedule is not canceled
+   *
+   */
+  const DELAY_FOR_SUBMISSION = 100;
+  const timeoutIdRef = useRef<NodeJS.Timeout>();
+
+  const changeHandler = useCallback((selectedItems: IPageWithMeta<IPageSearchMeta>[]) => {
+    // cancel schedule to submit
+    if (timeoutIdRef.current != null) {
+      clearTimeout(timeoutIdRef.current);
+    }
 
-    if (text === '') {
-      setPages([]);
+    if (selectedItems.length > 0) {
+      setInput(selectedItems[0].data.path);
+
+      if (onChange != null) {
+        onChange(selectedItems);
+      }
     }
-  }, [onInputChange]);
+  }, [onChange]);
 
   const keyDownHandler = useCallback((event: KeyboardEvent) => {
     if (event.keyCode === 13) { // Enter key
-      if (onSubmit != null) {
-        onSubmit(input);
+      if (onSubmit != null && input != null && input.length > 0) {
+        // schedule to submit with 100ms delay
+        timeoutIdRef.current = setTimeout(() => onSubmit(input), DELAY_FOR_SUBMISSION);
       }
     }
   }, [input, onSubmit]);
+  /*
+   * -------------------------------------------------------------------------------------------------------
+   */
 
-  const getEmptyLabel = () => {
-    // show help element if empty
-    if (input.length === 0) {
-      return helpElement;
+  useEffect(() => {
+    if (onSearchError != null && searchError != null) {
+      onSearchError(searchError);
     }
+  }, [onSearchError, searchError]);
+
+  const labelKey = useCallback((option?: IPageWithMeta<IPageSearchMeta>) => {
+    return option?.data.path ?? '';
+  }, []);
 
-    // use props.emptyLabel as is if defined
-    if (emptyLabel !== undefined) {
-      return emptyLabel;
+  const renderMenu = useCallback((options: IPageWithMeta<IPageSearchMeta>[], menuProps) => {
+    if (!isForcused) {
+      return <></>;
     }
 
-    return <></>;
-  };
+    const isEmptyInput = input == null || input.length === 0;
+    if (isEmptyInput) {
+      if (helpElement == null) {
+        return <></>;
+      }
+
+      return (
+        <Menu {...menuProps}>
+          <div className="p-3">
+            {helpElement}
+          </div>
+        </Menu>
+      );
+    }
+
+    if (disableIncrementalSearch) {
+      return <></>;
+    }
 
-  const defaultSelected = (keywordOnInit !== '')
-    ? [{ path: keywordOnInit }]
-    : [];
-  // eslint-disable-next-line @typescript-eslint/no-explicit-any
-  const inputProps: any = { autoComplete: 'off' };
-  if (props.inputName != null) {
-    inputProps.name = props.inputName;
-  }
-
-  const renderMenuItemChildren = (option: IPageWithMeta<IPageSearchMeta>) => {
-    const { data: pageData } = option;
     return (
-      <span>
-        <UserPicture user={pageData.lastUpdateUser} size="sm" noLink />
-        <span className="ml-1 mr-2 text-break text-wrap"><PagePathLabel path={pageData.path} /></span>
-        <PageListMeta page={pageData} />
-      </span>
+      <Menu {...menuProps}>
+        {options.map((pageWithMeta, index) => (
+          <MenuItem key={pageWithMeta.data._id} option={pageWithMeta} position={index}>
+            <span>
+              <UserPicture user={pageWithMeta.data.lastUpdateUser} size="sm" noLink />
+              <span className="ml-1 mr-2 text-break text-wrap"><PagePathLabel path={pageWithMeta.data.path} /></span>
+              <PageListMeta page={pageWithMeta.data} />
+            </span>
+          </MenuItem>
+        ))}
+      </Menu>
     );
-  };
+  }, [disableIncrementalSearch, helpElement, input, isForcused]);
+
+  const isLoading = searchResult == null && searchError == null;
+  const isOpenAlways = helpElement != null;
 
   return (
     <div className="search-typeahead">
@@ -206,28 +214,35 @@ const SearchTypeahead: ForwardRefRenderFunction<IFocusable, Props> = (props: Pro
         {...props}
         id="search-typeahead-asynctypeahead"
         ref={typeaheadRef}
-        inputProps={inputProps}
+        delay={400}
+        // eslint-disable-next-line @typescript-eslint/no-explicit-any
+        inputProps={{ autoComplete: 'off', ...(inputProps as any ?? {}) }}
         isLoading={isLoading}
-        labelKey={data => data?.pageData?.path || keywordOnInit || ''} // https://github.com/ericgio/react-bootstrap-typeahead/blob/master/docs/Rendering.md#labelkey-stringfunction
-        minLength={0}
-        options={pages} // Search result (Some page names)
-        promptText={props.helpElement}
-        emptyLabel={disableIncrementalSearch ? null : getEmptyLabel()}
+        labelKey={labelKey}
+        defaultInputValue={keywordOnInit}
+        options={searchResult?.data} // Search result (Some page names)
         align="left"
-        onSearch={search}
+        open={isOpenAlways || undefined}
+        renderMenu={renderMenu}
+        autoFocus={props.autoFocus}
+        onChange={changeHandler}
+        onSearch={searchHandler}
         onInputChange={inputChangeHandler}
         onKeyDown={keyDownHandler}
-        renderMenuItemChildren={renderMenuItemChildren}
-        caseSensitive={false}
-        defaultSelected={defaultSelected}
-        autoFocus={props.autoFocus}
-        onBlur={props.onBlur}
-        onFocus={props.onFocus}
+        onBlur={() => {
+          setFocused(false);
+          if (onBlur != null) {
+            onBlur();
+          }
+        }}
+        onFocus={() => {
+          setFocused(true);
+          if (onFocus != null) {
+            onFocus();
+          }
+        }}
       />
       <ResetFormButton
-        // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
-        keywordOnInit={props.keywordOnInit!}
-        // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
         input={input}
         onReset={resetForm}
       />

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

@@ -81,7 +81,7 @@ const getNewPathAfterMoved = (droppedPagePath: string, newParentPagePath: string
  * @param printLog
  * @returns
  */
-const canMoveUnderNewParent = (fromPage?: Partial<IPageHasId>, newParentPage?: Partial<IPageHasId>, printLog = false): boolean => {
+const isDroppable = (fromPage?: Partial<IPageHasId>, newParentPage?: Partial<IPageHasId>, printLog = false): boolean => {
   if (fromPage == null || newParentPage == null || fromPage.path == null || newParentPage.path == null) {
     if (printLog) {
       logger.warn('Any of page, page.path or droppedPage.path is null');
@@ -90,7 +90,7 @@ const canMoveUnderNewParent = (fromPage?: Partial<IPageHasId>, newParentPage?: P
   }
 
   const newPathAfterMoved = getNewPathAfterMoved(fromPage.path, newParentPage.path);
-  return pagePathUtils.canMoveByPath(fromPage.path, newPathAfterMoved);
+  return pagePathUtils.canMoveByPath(fromPage.path, newPathAfterMoved) && !pagePathUtils.isUsersTopPage(newParentPage.path);
 };
 
 
@@ -174,7 +174,7 @@ const Item: FC<ItemProps> = (props: ItemProps) => {
   const pageItemDropHandler = async(item: ItemNode) => {
     const { page: droppedPage } = item;
 
-    if (!canMoveUnderNewParent(droppedPage, page, true)) {
+    if (!isDroppable(droppedPage, page, true)) {
       return;
     }
 
@@ -226,7 +226,7 @@ const Item: FC<ItemProps> = (props: ItemProps) => {
     },
     canDrop: (item) => {
       const { page: droppedPage } = item;
-      return canMoveUnderNewParent(droppedPage, page);
+      return isDroppable(droppedPage, page);
     },
     collect: monitor => ({
       isOver: monitor.isOver(),

+ 3 - 3
packages/app/src/components/Sidebar/SidebarContents.tsx

@@ -18,14 +18,14 @@ const SidebarContents: FC<Props> = (props: Props) => {
     case SidebarContentsType.RECENT:
       Contents = RecentChanges;
       break;
-    case SidebarContentsType.TREE:
-      Contents = PageTree;
+    case SidebarContentsType.CUSTOM:
+      Contents = CustomSidebar;
       break;
     case SidebarContentsType.TAG:
       Contents = Tag;
       break;
     default:
-      Contents = CustomSidebar;
+      Contents = PageTree;
   }
 
   return (

+ 1 - 1
packages/app/src/components/Sidebar/SidebarNav.tsx

@@ -81,9 +81,9 @@ const SidebarNav: FC<Props> = (props: Props) => {
     <div className="grw-sidebar-nav">
       <div className="grw-sidebar-nav-primary-container">
         {/* eslint-disable max-len */}
+        <PrimaryItem contents={SidebarContentsType.TREE} label="Page Tree" iconName="format_list_bulleted" onItemSelected={onItemSelected} />
         <PrimaryItem contents={SidebarContentsType.CUSTOM} label="Custom Sidebar" iconName="code" onItemSelected={onItemSelected} />
         <PrimaryItem contents={SidebarContentsType.RECENT} label="Recent Changes" iconName="update" onItemSelected={onItemSelected} />
-        <PrimaryItem contents={SidebarContentsType.TREE} label="Page Tree" iconName="format_list_bulleted" onItemSelected={onItemSelected} />
         {/* <PrimaryItem id="tag" label="Tags" iconName="icon-tag" /> */}
         {/* <PrimaryItem id="favorite" label="Favorite" iconName="fa fa-bookmark-o" /> */}
         <PrimaryItem contents={SidebarContentsType.TAG} label="Tags" iconName="tag" onItemSelected={onItemSelected} />

+ 6 - 6
packages/app/src/interfaces/page-delete-config.ts

@@ -4,34 +4,34 @@ export const PageDeleteConfigValue = {
   AdminOnly: 'adminOnly',
   Inherit: 'inherit',
 } as const;
-export type PageDeleteConfigValue = typeof PageDeleteConfigValue[keyof typeof PageDeleteConfigValue];
+export type IPageDeleteConfigValue = typeof PageDeleteConfigValue[keyof typeof PageDeleteConfigValue];
 
-export type PageDeleteConfigValueToProcessValidation = Exclude<PageDeleteConfigValue, typeof PageDeleteConfigValue.Inherit>;
+export type IPageDeleteConfigValueToProcessValidation = Exclude<IPageDeleteConfigValue, typeof PageDeleteConfigValue.Inherit>;
 
 export const PageSingleDeleteConfigValue = {
   Anyone: 'anyOne', // must be "anyOne" (not "anyone") for backward compatibility
   AdminAndAuthor: 'adminAndAuthor',
   AdminOnly: 'adminOnly',
 } as const;
-export type PageSingleDeleteConfigValue = Exclude<PageDeleteConfigValue, typeof PageDeleteConfigValue.Inherit>;
+export type PageSingleDeleteConfigValue = Exclude<IPageDeleteConfigValue, typeof PageDeleteConfigValue.Inherit>;
 
 export const PageSingleDeleteCompConfigValue = {
   Anyone: 'anyOne', // must be "anyOne" (not "anyone") for backward compatibility
   AdminAndAuthor: 'adminAndAuthor',
   AdminOnly: 'adminOnly',
 } as const;
-export type PageSingleDeleteCompConfigValue = Exclude<PageDeleteConfigValue, typeof PageDeleteConfigValue.Inherit>;
+export type PageSingleDeleteCompConfigValue = Exclude<IPageDeleteConfigValue, typeof PageDeleteConfigValue.Inherit>;
 
 export const PageRecursiveDeleteConfigValue = {
   AdminAndAuthor: 'adminAndAuthor',
   AdminOnly: 'adminOnly',
   Inherit: 'inherit',
 } as const;
-export type PageRecursiveDeleteConfigValue = Exclude<PageDeleteConfigValue, typeof PageDeleteConfigValue.Anyone>;
+export type PageRecursiveDeleteConfigValue = Exclude<IPageDeleteConfigValue, typeof PageDeleteConfigValue.Anyone>;
 
 export const PageRecursiveDeleteCompConfigValue = {
   AdminAndAuthor: 'adminAndAuthor',
   AdminOnly: 'adminOnly',
   Inherit: 'inherit',
 } as const;
-export type PageRecursiveDeleteCompConfigValue = Exclude<PageDeleteConfigValue, typeof PageDeleteConfigValue.Anyone>;
+export type PageRecursiveDeleteCompConfigValue = Exclude<IPageDeleteConfigValue, typeof PageDeleteConfigValue.Anyone>;

+ 10 - 2
packages/app/src/interfaces/user-group-response.ts

@@ -1,6 +1,10 @@
 import { IUserGroupHasId, IUserGroupRelationHasId } from './user';
 import { IPageHasId } from './page';
 
+export type UserGroupResult = {
+  userGroup: IUserGroupHasId,
+}
+
 export type UserGroupListResult = {
   userGroups: IUserGroupHasId[],
 };
@@ -18,8 +22,12 @@ export type UserGroupPagesResult = {
   pages: IPageHasId[],
 }
 
-export type SelectableUserGroupsResult = {
-  selectableUserGroups: IUserGroupHasId[],
+export type SelectableParentUserGroupsResult = {
+  selectableParentGroups: IUserGroupHasId[],
+}
+
+export type SelectableUserChildGroupsResult = {
+  selectableChildGroups: IUserGroupHasId[],
 }
 
 export type AncestorUserGroupsResult = {

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

@@ -22,4 +22,5 @@ export type IUserForResuming = {
 export type IOptionsForResuming = {
   updateMetadata?: boolean,
   createRedirectPage?: boolean,
+  prevDescendantCount?: number,
 };

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

@@ -72,6 +72,7 @@ const userSchemaForResuming = new Schema<IUserForResuming>({
 const optionsSchemaForResuming = new Schema<IOptionsForResuming>({
   createRedirectPage: { type: Boolean },
   updateMetadata: { type: Boolean },
+  prevDescendantCount: { type: Number },
 }, { _id: false });
 
 const schema = new Schema<PageOperationDocument, PageOperationModel>({

+ 129 - 54
packages/app/src/server/models/page.ts

@@ -16,6 +16,7 @@ import { getPageSchema, extractToAncestorsPaths, populateDataToShowRevision } fr
 import { ObjectIdLike } from '~/server/interfaces/mongoose-utils';
 import { PageRedirectModel } from './page-redirect';
 
+const { addTrailingSlash } = pathUtils;
 const { isTopPage, collectAncestorPaths } = pagePathUtils;
 
 const logger = loggerFactory('growi:models:page');
@@ -44,7 +45,7 @@ type TargetAndAncestorsResult = {
 export type CreateMethod = (path: string, body: string, user, options) => Promise<PageDocument & { _id: any }>
 export interface PageModel extends Model<PageDocument> {
   [x: string]: any; // for obsolete methods
-  createEmptyPagesByPaths(paths: string[], onlyMigratedAsExistingPages?: boolean, publicOnly?: boolean): Promise<void>
+  createEmptyPagesByPaths(paths: string[], user: any | null, onlyMigratedAsExistingPages?: boolean, andFilter?): Promise<void>
   getParentAndFillAncestors(path: string, user): Promise<PageDocument & { _id: any }>
   findByIdsAndViewer(pageIds: ObjectIdLike[], user, userGroups?, includeEmpty?: boolean): Promise<PageDocument[]>
   findByPathAndViewer(path: string | null, user, userGroups?, useFindOne?: boolean, includeEmpty?: boolean): Promise<PageDocument[]>
@@ -140,6 +141,27 @@ class PageQueryBuilder {
     }
   }
 
+  /**
+   * Used for filtering the pages at specified paths not to include unintentional pages.
+   * @param pathsToFilter The paths to have additional filters as to be applicable
+   * @returns PageQueryBuilder
+   */
+  addConditionToFilterByApplicableAncestors(pathsToFilter: string[]) {
+    this.query = this.query
+      .and(
+        {
+          $or: [
+            { path: '/' },
+            { path: { $in: pathsToFilter }, grant: GRANT_PUBLIC, status: STATUS_PUBLISHED },
+            { path: { $in: pathsToFilter }, parent: { $ne: null }, status: STATUS_PUBLISHED },
+            { path: { $nin: pathsToFilter }, status: STATUS_PUBLISHED },
+          ],
+        },
+      );
+
+    return this;
+  }
+
   addConditionToExcludeTrashed() {
     this.query = this.query
       .and({
@@ -163,7 +185,7 @@ class PageQueryBuilder {
     }
 
     const pathNormalized = pathUtils.normalizePath(path);
-    const pathWithTrailingSlash = pathUtils.addTrailingSlash(path);
+    const pathWithTrailingSlash = addTrailingSlash(path);
 
     const startsPattern = escapeStringRegexp(pathWithTrailingSlash);
 
@@ -188,7 +210,7 @@ class PageQueryBuilder {
       return this;
     }
 
-    const pathWithTrailingSlash = pathUtils.addTrailingSlash(path);
+    const pathWithTrailingSlash = addTrailingSlash(path);
 
     const startsPattern = escapeStringRegexp(pathWithTrailingSlash);
 
@@ -409,23 +431,39 @@ class PageQueryBuilder {
  * @param onlyMigratedAsExistingPages Determine whether to include non-migrated pages as existing pages. If a page exists,
  * an empty page will not be created at that page's path.
  */
-schema.statics.createEmptyPagesByPaths = async function(paths: string[], user: any | null, onlyMigratedAsExistingPages = true): Promise<void> {
-  // find existing parents
-  const builder = new PageQueryBuilder(this.find({}, { _id: 0, path: 1 }), true);
-
-  await this.addConditionToFilteringByViewerToEdit(builder, user);
-
+schema.statics.createEmptyPagesByPaths = async function(paths: string[], user: any | null, onlyMigratedAsExistingPages = true, filter?): Promise<void> {
+  const aggregationPipeline: any[] = [];
+  // 1. Filter by paths
+  aggregationPipeline.push({ $match: { path: { $in: paths } } });
+  // 2. Normalized condition
   if (onlyMigratedAsExistingPages) {
-    builder.addConditionAsMigrated();
+    aggregationPipeline.push({
+      $match: {
+        $or: [
+          { parent: { $ne: null } },
+          { path: '/' },
+        ],
+      },
+    });
+  }
+  // 3. Add custom pipeline
+  if (filter != null) {
+    aggregationPipeline.push({ $match: filter });
   }
+  // 4. Add grant conditions
+  let userGroups = null;
+  if (user != null) {
+    const UserGroupRelation = mongoose.model('UserGroupRelation') as any;
+    userGroups = await UserGroupRelation.findAllUserGroupIdsRelatedToUser(user);
+  }
+  const grantCondition = this.generateGrantCondition(user, userGroups);
+  aggregationPipeline.push({ $match: grantCondition });
+
+  // Run aggregation
+  const existingPages = await this.aggregate(aggregationPipeline);
 
-  const existingPages = await builder
-    .addConditionToListByPathsArray(paths)
-    .query
-    .lean()
-    .exec();
-  const existingPagePaths = existingPages.map(page => page.path);
 
+  const existingPagePaths = existingPages.map(page => page.path);
   // paths to create empty pages
   const notExistingPagePaths = paths.filter(path => !existingPagePaths.includes(path));
 
@@ -535,6 +573,10 @@ schema.statics.getParentAndFillAncestors = async function(path: string, user): P
 
   // find ancestors
   const builder2 = new PageQueryBuilder(this.find(), true);
+
+  // avoid including not normalized pages
+  builder2.addConditionToFilterByApplicableAncestors(ancestorPaths);
+
   const ancestors = await builder2
     .addConditionToListByPathsArray(ancestorPaths)
     .addConditionToSortPagesByDescPath()
@@ -973,7 +1015,7 @@ export default (crowi: Crowi): any => {
         const shouldCheckDescendants = emptyPage != null;
         const newGrantedUserIds = grant === GRANT_OWNER ? [user._id] as IObjectId[] : undefined;
 
-        isGrantNormalized = await crowi.pageGrantService.isGrantNormalized(path, grant, newGrantedUserIds, grantUserGroupId, shouldCheckDescendants);
+        isGrantNormalized = await crowi.pageGrantService.isGrantNormalized(user, path, grant, newGrantedUserIds, grantUserGroupId, shouldCheckDescendants);
       }
       catch (err) {
         logger.error(`Failed to validate grant of page at "${path}" of grant ${grant}:`, err);
@@ -988,7 +1030,7 @@ export default (crowi: Crowi): any => {
      * update empty page if exists, if not, create a new page
      */
     let page;
-    if (emptyPage != null) {
+    if (emptyPage != null && grant !== GRANT_RESTRICTED) {
       page = emptyPage;
       const descendantCount = await this.recountDescendantCount(page._id);
 
@@ -999,23 +1041,19 @@ export default (crowi: Crowi): any => {
       page = new Page();
     }
 
-    let parentId: IObjectId | string | null = null;
-    const parent = await Page.getParentAndFillAncestors(path, user);
-    if (!isTopPage(path)) {
-      parentId = parent._id;
-    }
-
     page.path = path;
     page.creator = user;
     page.lastUpdateUser = user;
     page.status = STATUS_PUBLISHED;
 
     // set parent to null when GRANT_RESTRICTED
-    if (grant === GRANT_RESTRICTED) {
+    const isGrantRestricted = grant === GRANT_RESTRICTED;
+    if (isTopPage(path) || isGrantRestricted) {
       page.parent = null;
     }
     else {
-      page.parent = parentId;
+      const parent = await Page.getParentAndFillAncestors(path, user);
+      page.parent = parent._id;
     }
 
     page.applyScope(user, grant, grantUserGroupId);
@@ -1048,18 +1086,22 @@ export default (crowi: Crowi): any => {
     return savedPage;
   };
 
+  const shouldUseUpdatePageV4 = (grant:number, isV5Compatible:boolean, isOnTree:boolean): boolean => {
+    const isRestricted = grant === GRANT_RESTRICTED;
+    return !isRestricted && (!isV5Compatible || !isOnTree);
+  };
+
   schema.statics.updatePage = async function(pageData, body, previousBody, user, options = {}) {
-    if (crowi.configManager == null || crowi.pageGrantService == null) {
+    if (crowi.configManager == null || crowi.pageGrantService == null || crowi.pageService == null) {
       throw Error('Crowi is not set up');
     }
 
-    const isExRestricted = pageData.grant === GRANT_RESTRICTED;
-    const isChildrenExist = pageData?.descendantCount > 0;
+    const wasOnTree = pageData.parent != null || isTopPage(pageData.path);
     const exParent = pageData.parent;
-
-    const isPageOnTree = pageData.parent != null || isTopPage(pageData.path);
     const isV5Compatible = crowi.configManager.getConfig('crowi', 'app:isV5Compatible');
-    if (!isExRestricted && (!isV5Compatible || !isPageOnTree)) {
+
+    const shouldUseV4Process = shouldUseUpdatePageV4(pageData.grant, isV5Compatible, wasOnTree);
+    if (shouldUseV4Process) {
       // v4 compatible process
       return this.updatePageV4(pageData, body, previousBody, user, options);
     }
@@ -1069,31 +1111,15 @@ export default (crowi: Crowi): any => {
     const grantUserGroupId: undefined | ObjectIdLike = options.grantUserGroupId ?? pageData.grantedGroup?._id.toString();
     const isSyncRevisionToHackmd = options.isSyncRevisionToHackmd;
     const grantedUserIds = pageData.grantedUserIds || [user._id];
+    const shouldBeOnTree = grant !== GRANT_RESTRICTED;
+    const isChildrenExist = await this.count({ path: new RegExp(`^${escapeStringRegexp(addTrailingSlash(pageData.path))}`), parent: { $ne: null } });
 
     const newPageData = pageData;
 
-    if (grant === GRANT_RESTRICTED) {
-
-      if (isPageOnTree && isChildrenExist) {
-        // Update children's parent with new parent
-        const newParentForChildren = await this.createEmptyPage(pageData.path, pageData.parent, pageData.descendantCount);
-        await this.updateMany(
-          { parent: pageData._id },
-          { parent: newParentForChildren._id },
-        );
-      }
-
-      newPageData.parent = null;
-    }
-    else {
-      /*
-       * UserGroup & Owner validation
-       */
+    if (shouldBeOnTree) {
       let isGrantNormalized = false;
       try {
-        const shouldCheckDescendants = true;
-
-        isGrantNormalized = await crowi.pageGrantService.isGrantNormalized(pageData.path, grant, grantedUserIds, grantUserGroupId, shouldCheckDescendants);
+        isGrantNormalized = await crowi.pageGrantService.isGrantNormalized(user, pageData.path, grant, grantedUserIds, grantUserGroupId, true);
       }
       catch (err) {
         logger.error(`Failed to validate grant of page at "${pageData.path}" of grant ${grant}:`, err);
@@ -1103,11 +1129,24 @@ export default (crowi: Crowi): any => {
         throw Error('The selected grant or grantedGroup is not assignable to this page.');
       }
 
-      if (isExRestricted) {
+      if (!wasOnTree) {
         const newParent = await this.getParentAndFillAncestors(newPageData.path, user);
         newPageData.parent = newParent._id;
       }
     }
+    else {
+      if (wasOnTree && isChildrenExist) {
+        // Update children's parent with new parent
+        const newParentForChildren = await this.createEmptyPage(pageData.path, pageData.parent, pageData.descendantCount);
+        await this.updateMany(
+          { parent: pageData._id },
+          { parent: newParentForChildren._id },
+        );
+      }
+
+      newPageData.parent = null;
+      newPageData.descendantCount = 0;
+    }
 
     newPageData.applyScope(user, grant, grantUserGroupId);
 
@@ -1123,7 +1162,43 @@ export default (crowi: Crowi): any => {
 
     pageEvent.emit('update', savedPage, user);
 
-    if (isPageOnTree && !isChildrenExist) {
+    // Update ex children's parent
+    if (!wasOnTree && shouldBeOnTree) {
+      const emptyPageAtSamePath = await this.findOne({ path: pageData.path, isEmpty: true }); // this page is necessary to find children
+
+      if (isChildrenExist) {
+        if (emptyPageAtSamePath != null) {
+          // Update children's parent with new parent
+          await this.updateMany(
+            { parent: emptyPageAtSamePath._id },
+            { parent: savedPage._id },
+          );
+        }
+      }
+
+      await this.findOneAndDelete({ path: pageData.path, isEmpty: true }); // delete here
+    }
+
+    // Sub operation
+    // 1. Update descendantCount
+    const shouldPlusDescCount = !wasOnTree && shouldBeOnTree;
+    const shouldMinusDescCount = wasOnTree && !shouldBeOnTree;
+    if (shouldPlusDescCount) {
+      await crowi.pageService.updateDescendantCountOfAncestors(newPageData._id, 1, false);
+      const newDescendantCount = await this.recountDescendantCount(newPageData._id);
+      await this.updateOne({ _id: newPageData._id }, { descendantCount: newDescendantCount });
+    }
+    else if (shouldMinusDescCount) {
+      // Update from parent. Parent is null if newPageData.grant is RESTRECTED.
+      if (newPageData.grant === GRANT_RESTRICTED) {
+        await crowi.pageService.updateDescendantCountOfAncestors(exParent, -1, true);
+      }
+    }
+
+    // 2. Delete unnecessary empty pages
+
+    const shouldRemoveLeafEmpPages = wasOnTree && !isChildrenExist;
+    if (shouldRemoveLeafEmpPages) {
       await this.removeLeafEmptyPagesRecursively(exParent);
     }
 

+ 1 - 1
packages/app/src/server/routes/admin.js

@@ -284,7 +284,7 @@ module.exports = function(crowi, app) {
   // グループ詳細
   actions.userGroup.detail = async function(req, res) {
     const userGroupId = req.params.id;
-    const userGroup = await UserGroup.findOne({ _id: userGroupId });
+    const userGroup = await UserGroup.findOne({ _id: userGroupId }).populate('parent');
 
     if (userGroup == null) {
       logger.error('no userGroup is exists. ', userGroupId);

+ 6 - 6
packages/app/src/server/routes/apiv3/index.js

@@ -16,7 +16,7 @@ module.exports = (crowi) => {
   // add custom functions to express response
   require('./response')(express, crowi);
 
-  router.use('/healthcheck', require('./healthcheck')(crowi));
+  routerForAdmin.use('/healthcheck', require('./healthcheck')(crowi));
 
   // admin
   routerForAdmin.use('/admin-home', require('./admin-home')(crowi));
@@ -29,6 +29,10 @@ module.exports = (crowi) => {
   routerForAdmin.use('/export', require('./export')(crowi));
   routerForAdmin.use('/import', require('./import')(crowi));
   routerForAdmin.use('/search', require('./search')(crowi));
+  routerForAdmin.use('/security-setting', require('./security-setting')(crowi));
+  routerForAdmin.use('/mongo', require('./mongo')(crowi));
+  routerForAdmin.use('/slack-integration-settings', require('./slack-integration-settings')(crowi));
+  routerForAdmin.use('/slack-integration-legacy-settings', require('./slack-integration-legacy-settings')(crowi));
 
 
   router.use('/in-app-notification', require('./in-app-notification')(crowi));
@@ -37,11 +41,8 @@ module.exports = (crowi) => {
 
   router.use('/user-group-relations', require('./user-group-relation')(crowi));
 
-  router.use('/mongo', require('./mongo')(crowi));
-
   router.use('/statistics', require('./statistics')(crowi));
 
-  router.use('/security-setting', require('./security-setting')(crowi));
 
   router.use('/search', require('./search')(crowi));
 
@@ -57,8 +58,7 @@ module.exports = (crowi) => {
   router.use('/attachment', require('./attachment')(crowi));
 
   router.use('/slack-integration', require('./slack-integration')(crowi));
-  router.use('/slack-integration-settings', require('./slack-integration-settings')(crowi));
-  router.use('/slack-integration-legacy-settings', require('./slack-integration-legacy-settings')(crowi));
+
   router.use('/staffs', require('./staffs')(crowi));
 
   router.use('/forgot-password', require('./forgot-password')(crowi));

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

@@ -197,7 +197,9 @@ module.exports = (crowi) => {
     ],
     legacyPagesMigration: [
       body('pageIds').isArray().withMessage('pageIds is required'),
-      body('isRecursively').isBoolean().withMessage('isRecursively is required'),
+      body('isRecursively')
+        .custom(v => v === 'true' || v === true || v == null)
+        .withMessage('The body property "isRecursively" must be "true" or true. (Omit param for false)'),
     ],
   };
 

+ 13 - 1
packages/app/src/server/routes/apiv3/security-setting.js

@@ -3,6 +3,7 @@ import { removeNullPropertyFromObject } from '~/utils/object-utils';
 
 import { PageDeleteConfigValue } from '~/interfaces/page-delete-config';
 import { apiV3FormValidator } from '../../middlewares/apiv3-form-validator';
+import { validateDeleteConfigs, prepareDeleteConfigValuesForCalc } from '~/utils/page-delete-config';
 
 const logger = loggerFactory('growi:routes:apiv3:security-setting');
 
@@ -589,12 +590,23 @@ module.exports = (crowi) => {
       'security:sessionMaxAge': parseInt(req.body.sessionMaxAge),
       'security:restrictGuestMode': req.body.restrictGuestMode,
       'security:pageDeletionAuthority': req.body.pageDeletionAuthority,
-      'security:pageCompleteDeletionAuthority': req.body.pageCompleteDeletionAuthority,
       'security:pageRecursiveDeletionAuthority': req.body.pageRecursiveDeletionAuthority,
+      'security:pageCompleteDeletionAuthority': req.body.pageCompleteDeletionAuthority,
       'security:pageRecursiveCompleteDeletionAuthority': req.body.pageRecursiveCompleteDeletionAuthority,
       'security:list-policy:hideRestrictedByOwner': req.body.hideRestrictedByOwner,
       'security:list-policy:hideRestrictedByGroup': req.body.hideRestrictedByGroup,
     };
+
+    // Validate delete config
+    const [singleAuthority1, recursiveAuthority1] = prepareDeleteConfigValuesForCalc(req.body.pageDeletionAuthority, req.body.pageRecursiveDeletionAuthority);
+    // eslint-disable-next-line max-len
+    const [singleAuthority2, recursiveAuthority2] = prepareDeleteConfigValuesForCalc(req.body.pageCompleteDeletionAuthority, req.body.pageRecursiveCompleteDeletionAuthority);
+    const isDeleteConfigNormalized = validateDeleteConfigs(singleAuthority1, recursiveAuthority1)
+      && validateDeleteConfigs(singleAuthority2, recursiveAuthority2);
+    if (!isDeleteConfigNormalized) {
+      return res.apiv3Err(new ErrorV3('Delete config values are not correct.', 'delete_config_not_normalized'));
+    }
+
     const wikiMode = await crowi.configManager.getConfig('crowi', 'security:wikiMode');
     if (wikiMode === 'private' || wikiMode === 'public') {
       logger.debug('security:restrictGuestMode will not be changed because wiki mode is forced to set');

+ 98 - 7
packages/app/src/server/routes/apiv3/user-group.js

@@ -245,12 +245,12 @@ module.exports = (crowi) => {
    * @swagger
    *
    *  paths:
-   *    /selectable-groups:
+   *    /selectable-parent-groups:
    *      get:
    *        tags: [UserGroup]
-   *        operationId: getSelectableGroups
-   *        summary: /selectable-groups
-   *        description: Get selectable user groups.
+   *        operationId: getSelectableParentGroups
+   *        summary: /selectable-parent-groups
+   *        description: Get selectable parent UserGroups
    *        parameters:
    *          - name: groupId
    *            in: query
@@ -271,7 +271,56 @@ module.exports = (crowi) => {
    *                        type: object
    *                      description: userGroup objects
    */
-  router.get('/selectable-groups', loginRequiredStrictly, adminRequired, validator.selectableGroups, async(req, res) => {
+  router.get('/selectable-parent-groups', loginRequiredStrictly, adminRequired, validator.selectableGroups, async(req, res) => {
+    const { groupId } = req.query;
+
+    try {
+      const userGroup = await UserGroup.findById(groupId);
+
+      const descendantGroups = await UserGroup.findGroupsWithDescendantsRecursively([userGroup], []);
+      const descendantGroupIds = descendantGroups.map(userGroups => userGroups._id.toString());
+
+      const selectableParentGroups = await UserGroup.find({ _id: { $nin: [groupId, ...descendantGroupIds] } });
+      return res.apiv3({ selectableParentGroups });
+    }
+    catch (err) {
+      const msg = 'Error occurred while searching user groups';
+      logger.error(msg, err);
+      return res.apiv3Err(new ErrorV3(msg, 'user-groups-search-failed'));
+    }
+  });
+
+  /**
+   * @swagger
+   *
+   *  paths:
+   *    /selectable-child-groups:
+   *      get:
+   *        tags: [UserGroup]
+   *        operationId: getSelectableChildGroups
+   *        summary: /selectable-child-groups
+   *        description: Get selectable child UserGroups
+   *        parameters:
+   *          - name: groupId
+   *            in: query
+   *            required: true
+   *            description: id of userGroup
+   *            schema:
+   *              type: string
+   *        responses:
+   *          200:
+   *            description: userGroups are fetched
+   *            content:
+   *              application/json:
+   *                schema:
+   *                  properties:
+   *                    userGroups:
+   *                      type: array
+   *                      items:
+   *                        type: object
+   *                      description: userGroup objects
+   */
+  router.get('/selectable-child-groups', loginRequiredStrictly, adminRequired, validator.selectableGroups, async(req, res) => {
     const { groupId } = req.query;
 
     try {
@@ -283,8 +332,8 @@ module.exports = (crowi) => {
       ]);
 
       const excludeUserGroupIds = [userGroup, ...ancestorGroups, ...descendantGroups].map(userGroups => userGroups._id.toString());
-      const selectableUserGroups = await UserGroup.find({ _id: { $nin: excludeUserGroupIds } });
-      return res.apiv3({ selectableUserGroups });
+      const selectableChildGroups = await UserGroup.find({ _id: { $nin: excludeUserGroupIds } });
+      return res.apiv3({ selectableChildGroups });
     }
     catch (err) {
       const msg = 'Error occurred while searching user groups';
@@ -293,6 +342,48 @@ module.exports = (crowi) => {
     }
   });
 
+  /**
+   * @swagger
+   *
+   *  paths:
+   *    /user-groups/{id}:
+   *      get:
+   *        tags: [UserGroup]
+   *        operationId: getUserGroupFromGroupId
+   *        summary: /user-groups/{id}
+   *        description: Get UserGroup from Group ID
+   *        parameters:
+   *          - name: id
+   *            in: path
+   *            required: true
+   *            description: id of userGroup
+   *            schema:
+   *             type: string
+   *        responses:
+   *          200:
+   *            description: userGroup are fetched
+   *            content:
+   *              application/json:
+   *                schema:
+   *                  properties:
+   *                    userGroup:
+   *                      type: object
+   *                      description: userGroup object
+   */
+  router.get('/:id', loginRequiredStrictly, adminRequired, validator.selectableGroups, async(req, res) => {
+    const { id: groupId } = req.params;
+
+    try {
+      const userGroup = await UserGroup.findById(groupId);
+      return res.apiv3({ userGroup });
+    }
+    catch (err) {
+      const msg = 'Error occurred while getting user group';
+      logger.error(msg, err);
+      return res.apiv3Err(new ErrorV3(msg, 'user-groups-get-failed'));
+    }
+  });
+
   /**
    * @swagger
    *

+ 6 - 12
packages/app/src/server/service/config-loader.ts

@@ -295,6 +295,12 @@ const ENV_VAR_NAME_TO_CONFIG_INFO = {
     type:    ValueType.STRING,
     default: '/growi-internal/',
   },
+  ELASTICSEARCH_VERSION: {
+    ns:      'crowi',
+    key:     'app:elasticsearchVersion',
+    type:    ValueType.NUMBER,
+    default: 7,
+  },
   ELASTICSEARCH_URI: {
     ns:      'crowi',
     key:     'app:elasticsearchUri',
@@ -307,12 +313,6 @@ const ENV_VAR_NAME_TO_CONFIG_INFO = {
     type:    ValueType.NUMBER,
     default: 8000, // msec
   },
-  SEARCHBOX_SSL_URL: {
-    ns:      'crowi',
-    key:     'app:searchboxSslUrl',
-    type:    ValueType.STRING,
-    default: null,
-  },
   ELASTICSEARCH_REJECT_UNAUTHORIZED: {
     ns:      'crowi',
     key:     'app:elasticsearchRejectUnauthorized',
@@ -325,12 +325,6 @@ const ENV_VAR_NAME_TO_CONFIG_INFO = {
     type:    ValueType.BOOLEAN,
     default: false,
   },
-  USE_ELASTICSEARCH_V6: {
-    ns:      'crowi',
-    key:     'app:useElasticsearchV6',
-    type:    ValueType.BOOLEAN,
-    default: true,
-  },
   MONGO_GRIDFS_TOTAL_LIMIT: {
     ns:      'crowi',
     key:     'gridfs:totalLimit',

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

@@ -259,21 +259,40 @@ class PageGrantService {
    * @param targetPath string of the target path
    * @returns ComparableDescendants
    */
-  private async generateComparableDescendants(targetPath: string, includeNotMigratedPages: boolean): Promise<ComparableDescendants> {
+  private async generateComparableDescendants(targetPath: string, user, includeNotMigratedPages: boolean): Promise<ComparableDescendants> {
     const Page = mongoose.model('Page') as unknown as PageModel;
+    const UserGroupRelation = mongoose.model('UserGroupRelation') as any; // TODO: Typescriptize model
 
-    /*
-     * make granted users list of descendant's
-     */
-    const pathWithTrailingSlash = addTrailingSlash(targetPath);
-    const startsPattern = escapeStringRegexp(pathWithTrailingSlash);
+    // Build conditions
+    const $match: {$or: any} = {
+      $or: [],
+    };
+
+    const commonCondition = {
+      path: new RegExp(`^${escapeStringRegexp(addTrailingSlash(targetPath))}`, 'i'),
+      isEmpty: false,
+    };
 
-    const $match: any = {
-      path: new RegExp(`^${startsPattern}`),
-      isEmpty: { $ne: true },
+    const conditionForNormalizedPages: any = {
+      ...commonCondition,
+      parent: { $ne: null },
     };
+    $match.$or.push(conditionForNormalizedPages);
+
     if (includeNotMigratedPages) {
-      $match.parent = { $ne: null };
+      // Add grantCondition for not normalized pages
+      const userGroups = await UserGroupRelation.findAllUserGroupIdsRelatedToUser(user);
+      const grantCondition = Page.generateGrantCondition(user, userGroups);
+      const conditionForNotNormalizedPages = {
+        $and: [
+          {
+            ...commonCondition,
+            parent: null,
+          },
+          grantCondition,
+        ],
+      };
+      $match.$or.push(conditionForNotNormalizedPages);
     }
 
     const result = await Page.aggregate([
@@ -327,7 +346,7 @@ class PageGrantService {
    */
   async isGrantNormalized(
       // eslint-disable-next-line max-len
-      targetPath: string, grant, grantedUserIds?: ObjectIdLike[], grantedGroupId?: ObjectIdLike, shouldCheckDescendants = false, includeNotMigratedPages = false,
+      user, targetPath: string, grant, grantedUserIds?: ObjectIdLike[], grantedGroupId?: ObjectIdLike, shouldCheckDescendants = false, includeNotMigratedPages = false,
   ): Promise<boolean> {
     if (isTopPage(targetPath)) {
       return true;
@@ -341,7 +360,7 @@ class PageGrantService {
     }
 
     const comparableTarget = await this.generateComparableTarget(grant, grantedUserIds, grantedGroupId, true);
-    const comparableDescendants = await this.generateComparableDescendants(targetPath, includeNotMigratedPages);
+    const comparableDescendants = await this.generateComparableDescendants(targetPath, user, includeNotMigratedPages);
 
     return this.processValidation(comparableTarget, comparableAncestor, comparableDescendants);
   }
@@ -352,13 +371,11 @@ class PageGrantService {
    * @param pageIds pageIds to be tested
    * @returns a tuple with the first element of normalizable pages and the second element of NOT normalizable pages
    */
-  async separateNormalizableAndNotNormalizablePages(pages): Promise<[(PageDocument & { _id: any })[], (PageDocument & { _id: any })[]]> {
+  async separateNormalizableAndNotNormalizablePages(user, pages): Promise<[(PageDocument & { _id: any })[], (PageDocument & { _id: any })[]]> {
     if (pages.length > LIMIT_FOR_MULTIPLE_PAGE_OP) {
       throw Error(`The maximum number of pageIds allowed is ${LIMIT_FOR_MULTIPLE_PAGE_OP}.`);
     }
 
-    const Page = mongoose.model('Page') as unknown as PageModel;
-
     const shouldCheckDescendants = true;
     const shouldIncludeNotMigratedPages = true;
 
@@ -375,7 +392,7 @@ class PageGrantService {
         continue;
       }
 
-      if (await this.isGrantNormalized(path, grant, grantedUserIds, grantedGroupId, shouldCheckDescendants, shouldIncludeNotMigratedPages)) {
+      if (await this.isGrantNormalized(user, path, grant, grantedUserIds, grantedGroupId, shouldCheckDescendants, shouldIncludeNotMigratedPages)) {
         normalizable.push(page);
       }
       else {

+ 89 - 64
packages/app/src/server/service/page.ts

@@ -23,10 +23,11 @@ import { Ref } from '~/interfaces/common';
 import { HasObjectId } from '~/interfaces/has-object-id';
 import { SocketEventName, UpdateDescCountRawData } from '~/interfaces/websocket';
 import {
-  PageDeleteConfigValue, PageDeleteConfigValueToProcessValidation,
+  PageDeleteConfigValue, IPageDeleteConfigValueToProcessValidation,
 } from '~/interfaces/page-delete-config';
 import PageOperation, { PageActionStage, PageActionType } from '../models/page-operation';
 import ActivityDefine from '../util/activityDefine';
+import { prepareDeleteConfigValuesForCalc } from '~/utils/page-delete-config';
 
 const debug = require('debug')('growi:services:page');
 
@@ -215,34 +216,26 @@ class PageService {
     const pageCompleteDeletionAuthority = this.crowi.configManager.getConfig('crowi', 'security:pageCompleteDeletionAuthority');
     const pageRecursiveCompleteDeletionAuthority = this.crowi.configManager.getConfig('crowi', 'security:pageRecursiveCompleteDeletionAuthority');
 
-    const recursiveAuthority = this.calcRecursiveDeleteConfigValue(pageCompleteDeletionAuthority, pageRecursiveCompleteDeletionAuthority);
+    const [singleAuthority, recursiveAuthority] = prepareDeleteConfigValuesForCalc(pageCompleteDeletionAuthority, pageRecursiveCompleteDeletionAuthority);
 
-    return this.canDeleteLogic(creatorId, operator, isRecursively, pageCompleteDeletionAuthority, recursiveAuthority);
+    return this.canDeleteLogic(creatorId, operator, isRecursively, singleAuthority, recursiveAuthority);
   }
 
   canDelete(creatorId: ObjectIdLike, operator, isRecursively: boolean): boolean {
     const pageDeletionAuthority = this.crowi.configManager.getConfig('crowi', 'security:pageDeletionAuthority');
     const pageRecursiveDeletionAuthority = this.crowi.configManager.getConfig('crowi', 'security:pageRecursiveDeletionAuthority');
 
-    const recursiveAuthority = this.calcRecursiveDeleteConfigValue(pageDeletionAuthority, pageRecursiveDeletionAuthority);
+    const [singleAuthority, recursiveAuthority] = prepareDeleteConfigValuesForCalc(pageDeletionAuthority, pageRecursiveDeletionAuthority);
 
-    return this.canDeleteLogic(creatorId, operator, isRecursively, pageDeletionAuthority, recursiveAuthority);
-  }
-
-  private calcRecursiveDeleteConfigValue(confForSingle, confForRecursive) {
-    if (confForRecursive === PageDeleteConfigValue.Inherit) {
-      return confForSingle;
-    }
-
-    return confForRecursive;
+    return this.canDeleteLogic(creatorId, operator, isRecursively, singleAuthority, recursiveAuthority);
   }
 
   private canDeleteLogic(
       creatorId: ObjectIdLike,
       operator,
       isRecursively: boolean,
-      authority: PageDeleteConfigValueToProcessValidation | null,
-      recursiveAuthority: PageDeleteConfigValueToProcessValidation | null,
+      authority: IPageDeleteConfigValueToProcessValidation | null,
+      recursiveAuthority: IPageDeleteConfigValueToProcessValidation | null,
   ): boolean {
     const isAdmin = operator.admin;
     const isOperator = operator?._id == null ? false : operator._id.equals(creatorId);
@@ -254,7 +247,7 @@ class PageService {
     return this.compareDeleteConfig(isAdmin, isOperator, authority);
   }
 
-  private compareDeleteConfig(isAdmin: boolean, isOperator: boolean, authority: PageDeleteConfigValueToProcessValidation | null): boolean {
+  private compareDeleteConfig(isAdmin: boolean, isOperator: boolean, authority: IPageDeleteConfigValueToProcessValidation | null): boolean {
     if (isAdmin) {
       return true;
     }
@@ -486,9 +479,7 @@ class PageService {
     if (grant !== Page.GRANT_RESTRICTED) {
       let isGrantNormalized = false;
       try {
-        const shouldCheckDescendants = false;
-
-        isGrantNormalized = await this.crowi.pageGrantService.isGrantNormalized(newPagePath, grant, grantedUserIds, grantedGroupId, shouldCheckDescendants);
+        isGrantNormalized = await this.crowi.pageGrantService.isGrantNormalized(user, newPagePath, grant, grantedUserIds, grantedGroupId, false);
       }
       catch (err) {
         logger.error(`Failed to validate grant of page at "${newPagePath}" when renaming`, err);
@@ -942,9 +933,7 @@ class PageService {
     if (grant !== Page.GRANT_RESTRICTED) {
       let isGrantNormalized = false;
       try {
-        const shouldCheckDescendants = false;
-
-        isGrantNormalized = await this.crowi.pageGrantService.isGrantNormalized(newPagePath, grant, grantedUserIds, grantedGroupId, shouldCheckDescendants);
+        isGrantNormalized = await this.crowi.pageGrantService.isGrantNormalized(user, newPagePath, grant, grantedUserIds, grantedGroupId, false);
       }
       catch (err) {
         logger.error(`Failed to validate grant of page at "${newPagePath}" when duplicating`, err);
@@ -2257,23 +2246,20 @@ class PageService {
           throw Error(`Cannot operate normalizeParent to path "${page.path}" right now.`);
         }
 
-        const normalizedPage = await this.normalizeParentByPageId(pageId, user);
+        const normalizedPage = await this.normalizeParentByPage(page, user);
 
         if (normalizedPage == null) {
           logger.error(`Failed to update descendantCount of page of id: "${pageId}"`);
         }
-        else {
-          // update descendantCount of ancestors'
-          await this.updateDescendantCountOfAncestors(pageId, normalizedPage.descendantCount, false);
-        }
       }
       catch (err) {
+        logger.error('Something went wrong while normalizing parent.', err);
         // socket.emit('normalizeParentByPageIds', { error: err.message }); TODO: use socket to tell user
       }
     }
   }
 
-  private async normalizeParentByPageId(page, user) {
+  private async normalizeParentByPage(page, user) {
     const Page = mongoose.model('Page') as unknown as PageModel;
 
     const {
@@ -2281,7 +2267,7 @@ class PageService {
     } = page;
 
     // check if any page exists at target path already
-    const existingPage = await Page.findOne({ path });
+    const existingPage = await Page.findOne({ path, parent: { $ne: null } });
     if (existingPage != null && !existingPage.isEmpty) {
       throw Error('Page already exists. Please rename the page to continue.');
     }
@@ -2294,7 +2280,7 @@ class PageService {
       try {
         const shouldCheckDescendants = true;
 
-        isGrantNormalized = await this.crowi.pageGrantService.isGrantNormalized(path, grant, grantedUserIds, grantedGroupId, shouldCheckDescendants);
+        isGrantNormalized = await this.crowi.pageGrantService.isGrantNormalized(user, path, grant, grantedUserIds, grantedGroupId, shouldCheckDescendants);
       }
       catch (err) {
         logger.error(`Failed to validate grant of page at "${path}"`, err);
@@ -2316,11 +2302,14 @@ class PageService {
       updatedPage = await Page.findById(page._id);
     }
     else {
-      // getParentAndFillAncestors
       const parent = await Page.getParentAndFillAncestors(page.path, user);
       updatedPage = await Page.findOneAndUpdate({ _id: page._id }, { parent: parent._id }, { new: true });
     }
 
+    // Update descendantCount
+    const inc = 1;
+    await this.updateDescendantCountOfAncestors(updatedPage.parent, inc, true);
+
     return updatedPage;
   }
 
@@ -2342,7 +2331,7 @@ class PageService {
     let normalizablePages;
     let nonNormalizablePages;
     try {
-      [normalizablePages, nonNormalizablePages] = await this.crowi.pageGrantService.separateNormalizableAndNotNormalizablePages(pagesToNormalize);
+      [normalizablePages, nonNormalizablePages] = await this.crowi.pageGrantService.separateNormalizableAndNotNormalizablePages(user, pagesToNormalize);
     }
     catch (err) {
       throw err;
@@ -2387,7 +2376,13 @@ class PageService {
   }
 
   async normalizeParentRecursivelyMainOperation(page, user, pageOpId: ObjectIdLike): Promise<void> {
-    // TODO: insertOne PageOperationBlock
+    // Save prevDescendantCount for sub-operation
+    const Page = mongoose.model('Page') as unknown as PageModel;
+    const { PageQueryBuilder } = Page;
+    const builder = new PageQueryBuilder(Page.findOne(), true);
+    builder.addConditionAsMigrated();
+    const exPage = await builder.query.exec();
+    const options = { prevDescendantCount: exPage?.descendantCount ?? 0 };
 
     try {
       await this.normalizeParentRecursively([page.path], user);
@@ -2405,10 +2400,10 @@ class PageService {
       throw Error('PageOperation document not found');
     }
 
-    await this.normalizeParentRecursivelySubOperation(page, user, pageOp._id);
+    await this.normalizeParentRecursivelySubOperation(page, user, pageOp._id, options);
   }
 
-  async normalizeParentRecursivelySubOperation(page, user, pageOpId: ObjectIdLike): Promise<void> {
+  async normalizeParentRecursivelySubOperation(page, user, pageOpId: ObjectIdLike, options: {prevDescendantCount: number}): Promise<void> {
     const Page = mongoose.model('Page') as unknown as PageModel;
 
     try {
@@ -2422,9 +2417,9 @@ class PageService {
         throw Error('Page not found after updating descendantCount');
       }
 
-      const exDescendantCount = page.descendantCount;
+      const { prevDescendantCount } = options;
       const newDescendantCount = pageAfterUpdatingDescendantCount.descendantCount;
-      const inc = newDescendantCount - exDescendantCount;
+      const inc = (newDescendantCount - prevDescendantCount) + 1;
       await this.updateDescendantCountOfAncestors(page._id, inc, false);
     }
     catch (err) {
@@ -2543,8 +2538,12 @@ class PageService {
   async normalizeParentRecursively(paths: string[], user: any | null): Promise<void> {
     const Page = mongoose.model('Page') as unknown as PageModel;
 
-    const ancestorPaths = paths.flatMap(p => collectAncestorPaths(p, [p]));
-    const regexps = paths.map(p => new RegExp(`^${escapeStringRegexp(addTrailingSlash(p))}`, 'i'));
+    const ancestorPaths = paths.flatMap(p => collectAncestorPaths(p, []));
+    // targets' descendants
+    const pathAndRegExpsToNormalize: (RegExp | string)[] = paths
+      .map(p => new RegExp(`^${escapeStringRegexp(addTrailingSlash(p))}`, 'i'));
+    // include targets' path
+    pathAndRegExpsToNormalize.push(...paths);
 
     // determine UserGroup condition
     let userGroups = null;
@@ -2555,11 +2554,13 @@ class PageService {
 
     const grantFiltersByUser: { $or: any[] } = Page.generateGrantCondition(user, userGroups);
 
-    return this._normalizeParentRecursively(regexps, ancestorPaths, grantFiltersByUser, user);
+    return this._normalizeParentRecursively(pathAndRegExpsToNormalize, ancestorPaths, grantFiltersByUser, user);
   }
 
   // TODO: use websocket to show progress
-  private async _normalizeParentRecursively(regexps: RegExp[], pathsToInclude: string[], grantFiltersByUser: { $or: any[] }, user): Promise<void> {
+  private async _normalizeParentRecursively(
+      pathOrRegExps: (RegExp | string)[], publicPathsToNormalize: string[], grantFiltersByUser: { $or: any[] }, user,
+  ): Promise<void> {
     const BATCH_SIZE = 100;
     const PAGES_LIMIT = 1000;
 
@@ -2567,7 +2568,7 @@ class PageService {
     const { PageQueryBuilder } = Page;
 
     // Build filter
-    const filter: any = {
+    const andFilter: any = {
       $and: [
         {
           parent: null,
@@ -2576,25 +2577,36 @@ class PageService {
         },
       ],
     };
-    let pathCondition: (RegExp | string)[] = [];
-    if (regexps.length > 0) {
-      pathCondition = [...regexps];
-    }
-    if (pathsToInclude.length > 0) {
-      pathCondition = [...pathCondition, ...pathsToInclude];
-    }
-    if (pathCondition.length > 0) {
-      filter.$and.push({
-        parent: null,
-        status: Page.STATUS_PUBLISHED,
-        path: { $in: pathCondition },
-      });
+    const orFilter: any = { $or: [] };
+    // specified pathOrRegExps
+    if (pathOrRegExps.length > 0) {
+      orFilter.$or.push(
+        {
+          path: { $in: pathOrRegExps },
+        },
+      );
+    }
+    // not specified but ancestors of specified pathOrRegExps
+    if (publicPathsToNormalize.length > 0) {
+      orFilter.$or.push(
+        {
+          path: { $in: publicPathsToNormalize },
+          grant: Page.GRANT_PUBLIC, // use only public pages to complete the tree
+        },
+      );
     }
 
+    // Merge filters
+    const mergedFilter = {
+      $and: [
+        { $and: [grantFiltersByUser, ...andFilter.$and] },
+        { $or: orFilter.$or },
+      ],
+    };
+
     let baseAggregation = Page
       .aggregate([
-        { $match: grantFiltersByUser },
-        { $match: filter },
+        { $match: mergedFilter },
         {
           $project: { // minimize data to fetch
             _id: 1,
@@ -2604,7 +2616,7 @@ class PageService {
       ]);
 
     // Limit pages to get
-    const total = await Page.countDocuments(filter);
+    const total = await Page.countDocuments(mergedFilter);
     if (total > PAGES_LIMIT) {
       baseAggregation = baseAggregation.limit(Math.floor(total * 0.3));
     }
@@ -2643,16 +2655,29 @@ class PageService {
         });
 
         await Page.bulkWrite(resetParentOperations);
-
         await Page.removeEmptyPages(pageIdsToNotDelete, emptyPagePathsToDelete);
 
         // 2. Create lacking parents as empty pages
-        await Page.createEmptyPagesByPaths(parentPaths, user, false);
+        const orFilters = [
+          { path: '/' },
+          { path: { $in: publicPathsToNormalize }, grant: Page.GRANT_PUBLIC, status: Page.STATUS_PUBLISHED },
+          { path: { $in: publicPathsToNormalize }, parent: { $ne: null }, status: Page.STATUS_PUBLISHED },
+          { path: { $nin: publicPathsToNormalize }, status: Page.STATUS_PUBLISHED },
+        ];
+        const filterForApplicableAncestors = { $or: orFilters };
+        await Page.createEmptyPagesByPaths(parentPaths, user, false, filterForApplicableAncestors);
 
         // 3. Find parents
-        const builder2 = new PageQueryBuilder(Page.find({}, { _id: 1, path: 1 }), true);
+        const addGrantCondition = (builder) => {
+          builder.query = builder.query.and(grantFiltersByUser);
+
+          return builder;
+        };
+        const builder2 = new PageQueryBuilder(Page.find(), true);
+        addGrantCondition(builder2);
         const parents = await builder2
           .addConditionToListByPathsArray(parentPaths)
+          .addConditionToFilterByApplicableAncestors(publicPathsToNormalize)
           .query
           .lean()
           .exec();
@@ -2668,6 +2693,7 @@ class PageService {
               {
                 path: { $regex: new RegExp(`^${parentPathEscaped}(\\/[^/]+)\\/?$`, 'i') }, // see: regexr.com/6889f (e.g. /parent/any_child or /any_level1)
               },
+              filterForApplicableAncestors,
               grantFiltersByUser,
             ],
           };
@@ -2717,9 +2743,8 @@ class PageService {
 
     await streamToPromise(migratePagesStream);
 
-    const existsFilter = { $and: [grantFiltersByUser, ...filter.$and] };
-    if (await Page.exists(existsFilter) && shouldContinue) {
-      return this._normalizeParentRecursively(regexps, pathsToInclude, grantFiltersByUser, user);
+    if (await Page.exists(mergedFilter) && shouldContinue) {
+      return this._normalizeParentRecursively(pathOrRegExps, publicPathsToNormalize, grantFiltersByUser, user);
     }
 
   }

+ 9 - 3
packages/app/src/server/service/search-delegator/elasticsearch.ts

@@ -67,7 +67,13 @@ class ElasticsearchDelegator implements SearchDelegator<Data> {
     this.configManager = configManager;
     this.socketIoService = socketIoService;
 
-    this.isElasticsearchV6 = this.configManager.getConfig('crowi', 'app:useElasticsearchV6');
+    const elasticsearchVersion: number = this.configManager.getConfig('crowi', 'app:elasticsearchVersion');
+
+    if (elasticsearchVersion !== 6 && elasticsearchVersion !== 7) {
+      throw new Error('Unsupported Elasticsearch version. Please specify a valid number to \'ELASTICSEARCH_VERSION\'');
+    }
+
+    this.isElasticsearchV6 = elasticsearchVersion === 6;
 
     this.elasticsearch = this.isElasticsearchV6 ? elasticsearch6 : elasticsearch7;
     this.isElasticsearchReindexOnBoot = this.configManager.getConfig('crowi', 'app:elasticsearchReindexOnBoot');
@@ -299,7 +305,7 @@ class ElasticsearchDelegator implements SearchDelegator<Data> {
       throw error;
     }
     finally {
-      logger.warn('Normalize indices anyway.');
+      logger.info('Normalize indices.');
       await this.normalizeIndices();
     }
 
@@ -338,7 +344,7 @@ class ElasticsearchDelegator implements SearchDelegator<Data> {
       : require('^/resource/search/mappings-es7.json');
 
     if (process.env.CI) {
-      mappings = require('^/resource/search/mappings-es6-for-ci.json');
+      mappings = require('^/resource/search/mappings-es7-for-ci.json');
     }
 
     return this.client.indices.create({

+ 4 - 4
packages/app/src/stores/search.tsx

@@ -32,7 +32,7 @@ type ISearchConfigurationsFixed = {
 }
 
 export type ISearchConditions = ISearchConfigurationsFixed & {
-  keyword: string,
+  keyword: string | null,
   rawQuery: string,
 }
 
@@ -51,7 +51,7 @@ const createSearchQuery = (keyword: string, includeTrashPages: boolean, includeU
 };
 
 export const useSWRxFullTextSearch = (
-    keyword: string, configurations: ISearchConfigurations, disableTermManager = false,
+    keyword: string | null, configurations: ISearchConfigurations, disableTermManager = false,
 ): SWRResponse<IFormattedSearchResult, Error> & { conditions: ISearchConditions } => {
   const { data: termNumber } = useFullTextSearchTermManager(disableTermManager);
 
@@ -67,10 +67,10 @@ export const useSWRxFullTextSearch = (
     includeTrashPages: includeTrashPages ?? false,
     includeUserPages: includeUserPages ?? false,
   };
-  const rawQuery = createSearchQuery(keyword, fixedConfigurations.includeTrashPages, fixedConfigurations.includeUserPages);
+  const rawQuery = createSearchQuery(keyword ?? '', fixedConfigurations.includeTrashPages, fixedConfigurations.includeUserPages);
 
   const swrResult = useSWRImmutable(
-    ['/search', keyword, fixedConfigurations, termNumber],
+    keyword == null ? null : ['/search', keyword, fixedConfigurations, termNumber],
     (endpoint, keyword, fixedConfigurations) => {
       const {
         limit, offset, sort, order,

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

@@ -223,7 +223,7 @@ export const useSidebarCollapsed = (initialData?: boolean): SWRResponse<boolean,
 };
 
 export const useCurrentSidebarContents = (initialData?: SidebarContentsType): SWRResponse<SidebarContentsType, Error> => {
-  return useStaticSWR('sidebarContents', initialData, { fallbackData: SidebarContentsType.RECENT });
+  return useStaticSWR('sidebarContents', initialData, { fallbackData: SidebarContentsType.TREE });
 };
 
 export const useCurrentProductNavWidth = (initialData?: number): SWRResponse<number, Error> => {

+ 19 - 5
packages/app/src/stores/user-group.tsx

@@ -6,9 +6,16 @@ import { apiv3Get } from '~/client/util/apiv3-client';
 import { IPageHasId } from '~/interfaces/page';
 import { IUserGroupHasId, IUserGroupRelationHasId } from '~/interfaces/user';
 import {
-  UserGroupListResult, ChildUserGroupListResult, UserGroupRelationListResult, UserGroupPagesResult, SelectableUserGroupsResult, AncestorUserGroupsResult,
+  UserGroupResult, UserGroupListResult, ChildUserGroupListResult, UserGroupRelationListResult,
+  UserGroupPagesResult, SelectableParentUserGroupsResult, SelectableUserChildGroupsResult, AncestorUserGroupsResult,
 } from '~/interfaces/user-group-response';
 
+export const useSWRxUserGroup = (groupId: string | undefined): SWRResponse<IUserGroupHasId, Error> => {
+  return useSWRImmutable(
+    groupId != null ? [`/user-groups/${groupId}`] : null,
+    endpoint => apiv3Get<UserGroupResult>(endpoint).then(result => result.data.userGroup),
+  );
+};
 
 export const useSWRxUserGroupList = (initialData?: IUserGroupHasId[]): SWRResponse<IUserGroupHasId[], Error> => {
   return useSWRImmutable<IUserGroupHasId[], Error>(
@@ -60,16 +67,23 @@ export const useSWRxUserGroupPages = (groupId: string | undefined, limit: number
   );
 };
 
-export const useSWRxSelectableUserGroups = (groupId: string | undefined): SWRResponse<IUserGroupHasId[], Error> => {
+export const useSWRxSelectableParentUserGroups = (groupId: string | undefined): SWRResponse<IUserGroupHasId[], Error> => {
+  return useSWRImmutable(
+    groupId != null ? ['/user-groups/selectable-parent-groups', groupId] : null,
+    endpoint => apiv3Get<SelectableParentUserGroupsResult>(endpoint, { groupId }).then(result => result.data.selectableParentGroups),
+  );
+};
+
+export const useSWRxSelectableChildUserGroups = (groupId: string | undefined): SWRResponse<IUserGroupHasId[], Error> => {
   return useSWRImmutable(
-    groupId != null ? ['/user-groups/selectable-groups'] : null,
-    endpoint => apiv3Get<SelectableUserGroupsResult>(endpoint, { groupId }).then(result => result.data.selectableUserGroups),
+    groupId != null ? ['/user-groups/selectable-child-groups', groupId] : null,
+    endpoint => apiv3Get<SelectableUserChildGroupsResult>(endpoint, { groupId }).then(result => result.data.selectableChildGroups),
   );
 };
 
 export const useSWRxAncestorUserGroups = (groupId: string | undefined): SWRResponse<IUserGroupHasId[], Error> => {
   return useSWRImmutable(
-    groupId != null ? ['/user-groups/ancestors'] : null,
+    groupId != null ? ['/user-groups/ancestors', groupId] : null,
     endpoint => apiv3Get<AncestorUserGroupsResult>(endpoint, { groupId }).then(result => result.data.ancestorUserGroups),
   );
 };

+ 2 - 2
packages/app/src/styles/_vendor-presentation.scss

@@ -4,8 +4,8 @@
 @import '~bootstrap/scss/mixins';
 @import '~bootstrap/scss/utilities';
 
-@import '~reveal.js/css/reveal.css';
-@import '~reveal.js/css/theme/black.css';
+@import '~reveal.js/dist/reveal.css';
+@import '~reveal.js/dist/theme/black.css';
 
 // hljs
 .reveal {

+ 15 - 14
packages/app/src/styles/theme/_apply-colors-dark.scss

@@ -16,6 +16,13 @@ $color-tags: #949494 !default;
 $bgcolor-tags: $dark !default;
 $border-color-global: $gray-500 !default;
 $border-color-toc: $border-color-global !default;
+$color-dropdown: $color-global !default;
+$bgcolor-dropdown: $bgcolor-global !default;
+$color-dropdown-link: $color-global !default;
+$color-dropdown-link-hover: $light !default;
+$bgcolor-dropdown-link-hover: lighten($bgcolor-global, 15%) !default;
+$color-dropdown-link-active: $light !default;
+$bgcolor-dropdown-link-active: $primary !default;
 
 // override bootstrap variables
 $text-muted: $gray-550;
@@ -25,10 +32,18 @@ $table-dark-border-color: $border-color-table;
 $table-dark-hover-color: $color-table-hover;
 $table-dark-hover-bg: $bgcolor-table-hover;
 $border-color: $border-color-global;
+$dropdown-color: $color-dropdown;
+$dropdown-bg: $bgcolor-dropdown;
+$dropdown-link-color: $color-dropdown-link;
+$dropdown-link-hover-color: $color-dropdown-link-hover;
+$dropdown-link-hover-bg: $bgcolor-dropdown-link-hover;
+$dropdown-link-active-color: $color-dropdown-link-active;
+$dropdown-link-active-bg: $bgcolor-dropdown-link-active;
 
 @import 'reboot-bootstrap-text';
 @import 'reboot-bootstrap-border-colors';
 @import 'reboot-bootstrap-tables';
+@import 'reboot-bootstrap-dropdown';
 
 // List Group
 @include override-list-group-item($color-list, $bgcolor-list, $color-list-hover, $bgcolor-list-hover, $color-list-active, $bgcolor-list-active);
@@ -74,20 +89,6 @@ label.custom-control-label::before {
   background-color: darken($bgcolor-global, 5%);
 }
 
-/*
- * Dropdown
- */
-.dropdown-menu {
-  background-color: $bgcolor-global;
-}
-
-.dropdown-item {
-  &:hover {
-    color: $light;
-    background-color: lighten($bgcolor-global, 15%);
-  }
-}
-
 /*
  * Table
  */

+ 11 - 0
packages/app/src/styles/theme/_apply-colors-light.scss

@@ -15,6 +15,11 @@ $color-tags: $secondary !default;
 $bgcolor-tags: $gray-200 !default;
 $border-color-global: $gray-300 !default;
 $border-color-toc: $border-color-global !default;
+$color-dropdown: $color-global !default;
+$color-dropdown-link: $color-global !default;
+$color-dropdown-link-hover: $color-global !default;
+$color-dropdown-link-active: $color-reversal !default;
+$bgcolor-dropdown-link-active: $primary !default;
 
 // override bootstrap variables
 $text-muted: $gray-500;
@@ -24,10 +29,16 @@ $table-border-color: $border-color-table;
 $table-hover-color: $color-table-hover;
 $table-hover-bg: $bgcolor-table-hover;
 $border-color: $border-color-global;
+$dropdown-color: $color-dropdown;
+$dropdown-link-color: $color-dropdown-link;
+$dropdown-link-hover-color: $color-dropdown-link-hover;
+$dropdown-link-active-color: $color-dropdown-link-active;
+$dropdown-link-active-bg: $bgcolor-dropdown-link-active;
 
 @import 'reboot-bootstrap-text';
 @import 'reboot-bootstrap-border-colors';
 @import 'reboot-bootstrap-tables';
+@import 'reboot-bootstrap-dropdown';
 
 // List Group
 @include override-list-group-item($color-list, $bgcolor-list, $color-list-hover, $bgcolor-list-hover, $color-list-active, $bgcolor-list-active);

+ 0 - 27
packages/app/src/styles/theme/_apply-colors.scss

@@ -77,10 +77,6 @@ pre:not(.hljs):not(.CodeMirror-line) {
 }
 
 // Dropdown
-.dropdown-menu {
-  color: $color-global;
-}
-
 .grw-personal-dropdown {
   .grw-icon-container svg {
     fill: $color-global;
@@ -90,29 +86,6 @@ pre:not(.hljs):not(.CodeMirror-line) {
   }
 }
 
-.dropdown-item {
-  color: $color-global;
-
-  svg {
-    fill: $color-global;
-  }
-
-  &:active,
-  &.active,
-  &:active:hover,
-  &.active:hover {
-    color: $color-dropdown-link-active;
-    background-color: $bgcolor-dropdown-link-active;
-
-    svg {
-      fill: $color-dropdown-link-active;
-    }
-  }
-  &:hover {
-    background-color: $light;
-  }
-}
-
 // Form
 .form-control {
   @include form-control-focus();

+ 35 - 0
packages/app/src/styles/theme/_reboot-bootstrap-dropdown.scss

@@ -0,0 +1,35 @@
+.dropdown-menu {
+  color: $dropdown-color;
+  svg {
+    fill: $dropdown-color;
+  }
+
+  background-color: $dropdown-bg;
+}
+
+.dropdown-item {
+  color: $dropdown-link-color;
+  svg {
+    fill: $dropdown-link-color;
+  }
+
+  @include hover-focus() {
+    color: $dropdown-link-hover-color;
+    svg {
+      fill: $dropdown-link-hover-color;
+    }
+
+    @include gradient-bg($dropdown-link-hover-bg);
+  }
+
+  &:active,
+  &.active,
+  &:active:hover,
+  &.active:hover {
+    color: $dropdown-link-active-color;
+    background-color: $dropdown-link-active-bg;
+    svg {
+      fill: $dropdown-link-active-color;
+    }
+  }
+}

+ 0 - 4
packages/app/src/styles/theme/antarctic.scss

@@ -106,8 +106,6 @@ html[dark] {
 
   // Dropdown colors
   $bgcolor-dropdown-link-active: $growi-blue;
-  $color-dropdown-link-active: $color-reversal;
-  $color-dropdown-link-hover: $color-global;
 
   // admin theme box
   $color-theme-color-box: lighten($themecolor, 20%);
@@ -189,8 +187,6 @@ html[dark] {
 
 //   // Dropdown colors
 //   $bgcolor-dropdown-link-active: $primary;
-//   $color-dropdown-link-active: $color-global;
-//   $color-dropdown-link-hover: $color-reversal;
 
 //   // Sidebar
 //   $bgcolor-sidebar: $bgcolor-navbar;

+ 0 - 1
packages/app/src/styles/theme/blackboard.scss

@@ -73,7 +73,6 @@ html[dark] {
   $bordercolor-inline-code: #4d4d4d; // optional
 
   // Dropdown colors
-  $bgcolor-dropdown-link-active: $primary;
   $color-dropdown-link-active: $color-global;
   $color-dropdown-link-hover: $color-reversal;
 

+ 1 - 3
packages/app/src/styles/theme/christmas.scss

@@ -97,9 +97,7 @@ html[dark] {
   $bordercolor-inline-code: #ccc8c8; // optional
 
   // Dropdown colors
-  $bgcolor-dropdown-link-active: $primary;
-  $color-dropdown-link-active: $color-global;
-  $color-dropdown-link-hover: $color-reversal;
+  $bgcolor-dropdown-link-active: $themecolor;
 
   // admin theme box
   $color-theme-color-box: lighten($themecolor, 20%);

+ 0 - 7
packages/app/src/styles/theme/default.scss

@@ -97,8 +97,6 @@ html[light] {
 
   // Dropdown colors
   $bgcolor-dropdown-link-active: $growi-blue;
-  $color-dropdown-link-active: $color-reversal;
-  $color-dropdown-link-hover: $color-reversal;
 
   // admin theme box
   $color-theme-color-box: lighten($primary, 20%);
@@ -197,11 +195,6 @@ html[dark] {
   $border-color-theme: $gray-400;
   $bordercolor-inline-code: $secondary; // optional
 
-  // Dropdown colors
-  $bgcolor-dropdown-link-active: $primary;
-  $color-dropdown-link-active: $color-global;
-  $color-dropdown-link-hover: $color-reversal;
-
   // admin theme box
   $color-theme-color-box: $primary;
 

+ 0 - 5
packages/app/src/styles/theme/fire-red.scss

@@ -67,10 +67,6 @@ html[light] {
   $border-color-theme: $primary;
   $bordercolor-inline-code: #ccc8c8; // optional
 
-  // Dropdown colors
-  $bgcolor-dropdown-link-active: $primary;
-  $color-dropdown-link-active: $color-reversal;
-
   // admin theme box
   $color-theme-color-box: $primary;
 
@@ -169,7 +165,6 @@ html[dark] {
   $bordercolor-inline-code: #4d4d4d; // optional
 
   // Dropdown colors
-  $bgcolor-dropdown-link-active: $primary;
   $color-dropdown-link-active: $color-global;
   $color-dropdown-link-hover: $color-reversal;
 

+ 0 - 5
packages/app/src/styles/theme/future.scss

@@ -80,11 +80,6 @@ html[dark] {
   $border-color-theme: #407483;
   $bordercolor-inline-code: #4d4d4d; // optional
 
-  // Dropdown colors
-  $bgcolor-dropdown-link-active: $primary;
-  $color-dropdown-link-active: $color-reversal;
-  $color-dropdown-link-hover: $color-global;
-
   // admin theme box
   $color-theme-color-box: lighten($primary, 20%);
 

+ 0 - 1
packages/app/src/styles/theme/halloween.scss

@@ -99,7 +99,6 @@ html[dark] {
   $bordercolor-inline-code: #4d4d4d; // optional
 
   // Dropdown colors
-  $bgcolor-dropdown-link-active: $primary;
   $color-dropdown-link-active: $color-reversal;
   $color-dropdown-link-hover: $color-global;
 

+ 2 - 5
packages/app/src/styles/theme/hufflepuff.scss

@@ -87,8 +87,6 @@ html[light] {
 
   // Dropdown colors
   $bgcolor-dropdown-link-active: $growi-blue;
-  $color-dropdown-link-active: $color-reversal;
-  $color-dropdown-link-hover: $color-global;
 
   // admin theme box
   $color-theme-color-box: darken($primary, 5%);
@@ -230,9 +228,8 @@ html[dark] {
   $bordercolor-inline-code: #4d4d4d; // optional
 
   // Dropdown colors
-  $bgcolor-dropdown-link-active: $primary;
-  $color-dropdown-link-active: $color-global;
-  $color-dropdown-link-hover: $color-reversal;
+  $color-dropdown-link-active: $color-reversal;
+  $color-dropdown-link-hover: $color-global;
 
   // admin theme box
   $color-theme-color-box: $primary;

+ 0 - 2
packages/app/src/styles/theme/island.scss

@@ -77,8 +77,6 @@ html[dark] {
 
   // Dropdown colors
   $bgcolor-dropdown-link-active: $growi-blue;
-  $color-dropdown-link-active: $color-reversal;
-  $color-dropdown-link-hover: $color-global;
 
   // admin theme box
   $color-theme-color-box: darken($primary, 15%);

+ 0 - 5
packages/app/src/styles/theme/jade-green.scss

@@ -67,10 +67,6 @@ html[light] {
   $border-color-theme: $primary;
   $bordercolor-inline-code: #ccc8c8; // optional
 
-  // Dropdown colors
-  $bgcolor-dropdown-link-active: $primary;
-  $color-dropdown-link-active: $color-reversal;
-
   // admin theme box
   $color-theme-color-box: $primary;
 
@@ -169,7 +165,6 @@ html[dark] {
   $bordercolor-inline-code: #4d4d4d; // optional
 
   // Dropdown colors
-  $bgcolor-dropdown-link-active: $primary;
   $color-dropdown-link-active: $color-global;
   $color-dropdown-link-hover: $color-reversal;
 

+ 0 - 2
packages/app/src/styles/theme/kibela.scss

@@ -79,8 +79,6 @@ html[dark] {
 
   // dropdown colors
   $bgcolor-dropdown-link-active: $growi-blue;
-  $color-dropdown-link-active: $color-reversal;
-  $color-dropdown-link-hover: $color-global;
 
   // admin theme box
   $color-theme-color-box: lighten($bgcolor-theme, 20%);

+ 0 - 9
packages/app/src/styles/theme/mono-blue.scss

@@ -67,10 +67,6 @@ html[light] {
   $border-color-theme: $gray-300;
   $bordercolor-inline-code: #ccc8c8; // optional
 
-  // Dropdown colors
-  $bgcolor-dropdown-link-active: $primary;
-  $color-dropdown-link-active: $color-reversal;
-
   // admin theme box
   $color-theme-color-box: lighten($primary, 20%);
 
@@ -168,11 +164,6 @@ html[dark] {
   $border-color-theme: #146aa0;
   $bordercolor-inline-code: #4d4d4d; // optional
 
-  // Dropdown colors
-  $bgcolor-dropdown-link-active: $primary;
-  $color-dropdown-link-active: $color-global;
-  $color-dropdown-link-hover: $color-reversal;
-
   // admin theme box
   $color-theme-color-box: $primary;
 

+ 0 - 5
packages/app/src/styles/theme/nature.scss

@@ -84,11 +84,6 @@ html[dark] {
   $border-color-theme: $gray-300;
   $bordercolor-inline-code: #ccc8c8; // optional
 
-  // Dropdown colors
-  $bgcolor-dropdown-link-active: $growi-blue;
-  $color-dropdown-link-active: $color-reversal;
-  $color-dropdown-link-hover: $color-global;
-
   // Table colors
   $border-color-table: $gray-400; // optional
 

+ 0 - 2
packages/app/src/styles/theme/spring.scss

@@ -86,8 +86,6 @@ html[dark] {
 
   // Dropdown colors
   $bgcolor-dropdown-link-active: $growi-blue;
-  $color-dropdown-link-active: $color-reversal;
-  $color-dropdown-link-hover: $color-global;
 
   // admin theme box
   $color-theme-color-box: darken($primary, 20%);

+ 0 - 2
packages/app/src/styles/theme/wood.scss

@@ -104,8 +104,6 @@ html[dark] {
 
   // Dropdown colors
   $bgcolor-dropdown-link-active: $growi-blue;
-  $color-dropdown-link-active: $color-reversal;
-  $color-dropdown-link-hover: $color-global;
 
   // admin theme box
   $color-theme-color-box: lighten($primary, 20%);

+ 62 - 0
packages/app/src/utils/page-delete-config.ts

@@ -0,0 +1,62 @@
+import {
+  PageDeleteConfigValue as Value, IPageDeleteConfigValueToProcessValidation,
+  IPageDeleteConfigValue,
+} from '~/interfaces/page-delete-config';
+
+/**
+ * Return true if "configForRecursive" is stronger than "configForSingle"
+ * Strength: "Admin" > "Admin and author" > "Anyone"
+ * @param configForSingle IPageDeleteConfigValueToProcessValidation
+ * @param configForRecursive IPageDeleteConfigValueToProcessValidation
+ * @returns boolean
+ */
+export const validateDeleteConfigs = (
+    configForSingle: IPageDeleteConfigValueToProcessValidation, configForRecursive: IPageDeleteConfigValueToProcessValidation,
+): boolean => {
+  if (configForSingle === Value.Anyone) {
+    switch (configForRecursive) {
+      case Value.Anyone:
+      case Value.AdminAndAuthor:
+      case Value.AdminOnly:
+        return true;
+    }
+  }
+
+  if (configForSingle === Value.AdminAndAuthor) {
+    switch (configForRecursive) {
+      case Value.Anyone:
+        return false;
+      case Value.AdminAndAuthor:
+      case Value.AdminOnly:
+        return true;
+    }
+  }
+
+  if (configForSingle === Value.AdminOnly) {
+    switch (configForRecursive) {
+      case Value.Anyone:
+      case Value.AdminAndAuthor:
+        return false;
+      case Value.AdminOnly:
+        return true;
+    }
+  }
+
+  return false;
+};
+
+/**
+ * Convert IPageDeleteConfigValue.Inherit to the calculable value
+ * @param confForSingle IPageDeleteConfigValueToProcessValidation
+ * @param confForRecursive IPageDeleteConfigValue
+ * @returns [(value for single), (value for recursive)]
+ */
+export const prepareDeleteConfigValuesForCalc = (
+    confForSingle: IPageDeleteConfigValueToProcessValidation, confForRecursive: IPageDeleteConfigValue,
+): [IPageDeleteConfigValueToProcessValidation, IPageDeleteConfigValueToProcessValidation] => {
+  if (confForRecursive === Value.Inherit) {
+    return [confForSingle, confForSingle];
+  }
+
+  return [confForSingle, confForRecursive];
+};

+ 1 - 2
packages/app/test/cypress/integration/2-basic-features/open-page-create-modal.spec.ts

@@ -25,8 +25,7 @@ context('Open PageCreateModal', () => {
   it("PageCreateModal is shown successfully", () => {
     cy.getByTestid('newPageBtn').click();
 
-    cy.getByTestid('page-create-modal').should('be.visible');
-    cy.screenshot(`${ssPrefix}-open`,{ capture: 'viewport' });
+    cy.getByTestid('page-create-modal').should('be.visible').screenshot(`${ssPrefix}-open`);
 
     cy.getByTestid('row-create-page-under-below').find('input.form-control').clear().type('/new-page');
     cy.getByTestid('btn-create-page-under-below').click();

+ 37 - 0
packages/app/test/cypress/integration/2-basic-features/open-page-delete-modal.spec.ts

@@ -0,0 +1,37 @@
+context('Open Page Delete Modal', () => {
+
+  const ssPrefix = 'access-to-page-delete-modal-';
+
+  let connectSid: string | undefined;
+
+  before(() => {
+    // login
+    cy.fixture("user-admin.json").then(user => {
+      cy.login(user.username, user.password);
+    });
+    cy.getCookie('connect.sid').then(cookie => {
+      connectSid = cookie?.value;
+    });
+    // collapse sidebar
+    cy.collapseSidebar(true);
+  });
+
+  beforeEach(() => {
+    if (connectSid != null) {
+      cy.setCookie('connect.sid', connectSid);
+      cy.visit('/');
+    }
+  });
+
+  it('PageDeleteModal is shown successfully', () => {
+     cy.visit('/Sandbox/Bootstrap4', {  });
+     cy.get('#grw-subnav-container').within(() => {
+       cy.getByTestid('open-page-item-control-btn').click();
+       cy.getByTestid('open-page-delete-modal-btn').click();
+    });
+
+     cy.getByTestid('page-delete-modal').should('be.visible').screenshot(`${ssPrefix}-open-bootstrap4`);
+  });
+
+});
+

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

@@ -28,9 +28,7 @@ context('Open Page Duplicate Modal', () => {
        cy.getByTestid('open-page-item-control-btn').click();
        cy.getByTestid('open-page-duplicate-modal-btn').click();
     });
-
-     cy.getByTestid('page-duplicate-modal').should('be.visible');
-     cy.screenshot(`${ssPrefix}-open-bootstrap4`,{ capture: 'viewport' });
+     cy.getByTestid('page-duplicate-modal').should('be.visible').screenshot(`${ssPrefix}-open-bootstrap4`);
   });
 
 });

+ 394 - 3
packages/app/test/integration/models/v5.page.test.js

@@ -37,11 +37,20 @@ describe('Page', () => {
 
     rootPage = await Page.findOne({ path: '/' });
 
-    const createPageId1 = new mongoose.Types.ObjectId();
+    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: createPageId1,
+        _id: pageIdCreate1,
         path: '/v5_empty_create_4',
         grant: Page.GRANT_PUBLIC,
         parent: rootPage._id,
@@ -52,7 +61,199 @@ describe('Page', () => {
         grant: Page.GRANT_PUBLIC,
         creator: dummyUser1,
         lastUpdateUser: dummyUser1._id,
-        parent: createPageId1,
+        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
+     * emp => empty => page with isEmpty: true
+     * pub => public => GRANT_PUBLIC
+     * awl => Anyone with the link => GRANT_RESTRICTED
+     */
+    const pageIdUpd1 = new mongoose.Types.ObjectId();
+    const pageIdUpd2 = new mongoose.Types.ObjectId();
+    const pageIdUpd3 = new mongoose.Types.ObjectId();
+    const pageIdUpd4 = new mongoose.Types.ObjectId();
+    const pageIdUpd5 = new mongoose.Types.ObjectId();
+    const pageIdUpd6 = new mongoose.Types.ObjectId();
+    const pageIdUpd7 = new mongoose.Types.ObjectId();
+    const pageIdUpd8 = new mongoose.Types.ObjectId();
+    const pageIdUpd9 = new mongoose.Types.ObjectId();
+    const pageIdUpd10 = new mongoose.Types.ObjectId();
+    const pageIdUpd11 = new mongoose.Types.ObjectId();
+    const pageIdUpd12 = new mongoose.Types.ObjectId();
+
+    await Page.insertMany([
+      {
+        _id: pageIdUpd1,
+        path: '/mup13_top/mup1_emp',
+        grant: Page.GRANT_PUBLIC,
+        parent: pageIdUpd8._id,
+        isEmpty: true,
+      },
+      {
+        _id: pageIdUpd2,
+        path: '/mup13_top/mup1_emp/mup2_pub',
+        grant: Page.GRANT_PUBLIC,
+        parent: pageIdUpd1._id,
+        creator: dummyUser1,
+        lastUpdateUser: dummyUser1._id,
+        isEmpty: false,
+      },
+      {
+        _id: pageIdUpd3,
+        path: '/mup14_top/mup6_pub',
+        grant: Page.GRANT_PUBLIC,
+        creator: dummyUser1,
+        lastUpdateUser: dummyUser1._id,
+        parent: pageIdUpd9,
+        isEmpty: false,
+        descendantCount: 1,
+      },
+      {
+        path: '/mup14_top/mup6_pub/mup7_pub',
+        grant: Page.GRANT_PUBLIC,
+        creator: dummyUser1,
+        lastUpdateUser: dummyUser1._id,
+        parent: pageIdUpd3,
+        isEmpty: false,
+        descendantCount: 0,
+      },
+      {
+        _id: pageIdUpd4,
+        path: '/mup15_top/mup8_pub',
+        grant: Page.GRANT_PUBLIC,
+        creator: dummyUser1,
+        lastUpdateUser: dummyUser1._id,
+        parent: pageIdUpd10._id,
+        isEmpty: false,
+      },
+      {
+        _id: pageIdUpd5,
+        path: '/mup16_top/mup9_pub/mup10_pub/mup11_awl',
+        grant: Page.GRANT_RESTRICTED,
+        creator: dummyUser1,
+        lastUpdateUser: dummyUser1._id,
+        isEmpty: false,
+      },
+      {
+        _id: pageIdUpd6,
+        path: '/mup17_top/mup12_emp',
+        isEmpty: true,
+        parent: pageIdUpd12._id,
+        descendantCount: 1,
+      },
+      {
+        _id: pageIdUpd7,
+        path: '/mup17_top/mup12_emp',
+        grant: Page.GRANT_RESTRICTED,
+        creator: dummyUser1,
+        lastUpdateUser: dummyUser1._id,
+        isEmpty: false,
+      },
+      {
+        path: '/mup17_top/mup12_emp/mup18_pub',
+        isEmpty: false,
+        creator: dummyUser1,
+        lastUpdateUser: dummyUser1._id,
+        parent: pageIdUpd6._id,
+      },
+      {
+        _id: pageIdUpd8,
+        path: '/mup13_top',
+        grant: Page.GRANT_PUBLIC,
+        creator: dummyUser1,
+        lastUpdateUser: dummyUser1._id,
+        isEmpty: false,
+        parent: rootPage._id,
+        descendantCount: 2,
+      },
+      {
+        _id: pageIdUpd9,
+        path: '/mup14_top',
+        grant: Page.GRANT_PUBLIC,
+        creator: dummyUser1,
+        lastUpdateUser: dummyUser1._id,
+        isEmpty: false,
+        parent: rootPage._id,
+        descendantCount: 2,
+      },
+      {
+        _id: pageIdUpd10,
+        path: '/mup15_top',
+        grant: Page.GRANT_PUBLIC,
+        creator: dummyUser1,
+        lastUpdateUser: dummyUser1._id,
+        isEmpty: false,
+        parent: rootPage._id,
+        descendantCount: 1,
+      },
+      {
+        _id: pageIdUpd11,
+        path: '/mup16_top',
+        grant: Page.GRANT_PUBLIC,
+        creator: dummyUser1,
+        lastUpdateUser: dummyUser1._id,
+        isEmpty: false,
+        parent: rootPage._id,
+        descendantCount: 0,
+      },
+      {
+        _id: pageIdUpd12,
+        path: '/mup17_top',
+        grant: Page.GRANT_PUBLIC,
+        creator: dummyUser1,
+        lastUpdateUser: dummyUser1._id,
+        isEmpty: false,
+        parent: rootPage._id,
+        descendantCount: 1,
       },
     ]);
 
@@ -91,5 +292,195 @@ describe('Page', () => {
       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 Page.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 Page.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', () => {
+
+    describe('Changing grant from PUBLIC to RESTRICTED of', () => {
+      test('an only-child page will delete its empty parent page', async() => {
+        const pathT = '/mup13_top';
+        const path1 = '/mup13_top/mup1_emp';
+        const path2 = '/mup13_top/mup1_emp/mup2_pub';
+        const pageT = await Page.findOne({ path: pathT, descendantCount: 2 });
+        const page1 = await Page.findOne({ path: path1, isEmpty: true });
+        const page2 = await Page.findOne({ path: path2, grant: Page.GRANT_PUBLIC });
+        expect(pageT).toBeTruthy();
+        expect(page1).toBeTruthy();
+        expect(page2).toBeTruthy();
+
+        const options = { grant: Page.GRANT_RESTRICTED, grantUserGroupId: null };
+        await Page.updatePage(page2, 'newRevisionBody', 'oldRevisionBody', dummyUser1, options);
+
+        const _pageT = await Page.findOne({ path: pathT });
+        const _page1 = await Page.findOne({ path: path1 });
+        const _page2 = await Page.findOne({ path: path2, grant: Page.GRANT_RESTRICTED });
+        expect(_pageT).toBeTruthy();
+        expect(_page1).toBeNull();
+        expect(_page2).toBeTruthy();
+        expect(_pageT.descendantCount).toBe(1);
+      });
+      test('a page that has children will create an empty page with the same path and it becomes a new parent', async() => {
+        const pathT = '/mup14_top';
+        const path1 = '/mup14_top/mup6_pub';
+        const path2 = '/mup14_top/mup6_pub/mup7_pub';
+        const top = await Page.findOne({ path: pathT, descendantCount: 2 });
+        const page1 = await Page.findOne({ path: path1, grant: Page.GRANT_PUBLIC });
+        const page2 = await Page.findOne({ path: path2, grant: Page.GRANT_PUBLIC });
+        expect(top).toBeTruthy();
+        expect(page1).toBeTruthy();
+        expect(page2).toBeTruthy();
+
+        await Page.updatePage(page1, 'newRevisionBody', 'oldRevisionBody', dummyUser1, { grant: Page.GRANT_RESTRICTED });
+
+        const _top = await Page.findOne({ path: pathT });
+        const _page1 = await Page.findOne({ path: path1, grant: Page.GRANT_RESTRICTED });
+        const _page2 = await Page.findOne({ path: path2 });
+        const _pageN = await Page.findOne({ path: path1, grant: Page.GRANT_PUBLIC });
+        expect(_page1).toBeTruthy();
+        expect(_page2).toBeTruthy();
+        expect(_pageN).toBeTruthy();
+
+        expect(_page1.parent).toBeNull();
+        expect(_page2.parent).toStrictEqual(_pageN._id);
+        expect(_pageN.parent).toStrictEqual(top._id);
+        expect(_pageN.isEmpty).toBe(true);
+        expect(_pageN.descendantCount).toBe(1);
+        expect(_top.descendantCount).toBe(1);
+      });
+      test('of a leaf page will NOT have an empty page with the same path', async() => {
+        const pathT = '/mup15_top';
+        const path1 = '/mup15_top/mup8_pub';
+        const pageT = await Page.findOne({ path: pathT, descendantCount: 1 });
+        const page1 = await Page.findOne({ path: path1, grant: Page.GRANT_PUBLIC });
+        const count = await Page.count({ path: path1 });
+        expect(pageT).toBeTruthy();
+        expect(page1).toBeTruthy();
+        expect(count).toBe(1);
+
+        await Page.updatePage(page1, 'newRevisionBody', 'oldRevisionBody', dummyUser1, { grant: Page.GRANT_RESTRICTED });
+
+        const _pageT = await Page.findOne({ path: pathT });
+        const _page1 = await Page.findOne({ path: path1, grant: Page.GRANT_RESTRICTED });
+        const _pageNotExist = await Page.findOne({ path: path1, isEmpty: true });
+        expect(_pageT).toBeTruthy();
+        expect(_page1).toBeTruthy();
+        expect(_pageNotExist).toBeNull();
+        expect(_pageT.descendantCount).toBe(0);
+      });
+    });
+    describe('Changing grant from RESTRICTED to PUBLIC of', () => {
+      test('a page will create ancestors if they do not exist', async() => {
+        const pathT = '/mup16_top';
+        const path1 = '/mup16_top/mup9_pub';
+        const path2 = '/mup16_top/mup9_pub/mup10_pub';
+        const path3 = '/mup16_top/mup9_pub/mup10_pub/mup11_awl';
+        const top = await Page.findOne({ path: pathT });
+        const page1 = await Page.findOne({ path: path1 });
+        const page2 = await Page.findOne({ path: path2 });
+        const page3 = await Page.findOne({ path: path3, grant: Page.GRANT_RESTRICTED });
+        expect(top).toBeTruthy();
+        expect(page3).toBeTruthy();
+        expect(page1).toBeNull();
+        expect(page2).toBeNull();
+
+        await Page.updatePage(page3, 'newRevisionBody', 'oldRevisionBody', dummyUser1, { grant: Page.GRANT_PUBLIC });
+
+        const _pageT = await Page.findOne({ path: pathT });
+        const _page1 = await Page.findOne({ path: path1, isEmpty: true });
+        const _page2 = await Page.findOne({ path: path2, isEmpty: true });
+        const _page3 = await Page.findOne({ path: path3, grant: Page.GRANT_PUBLIC });
+        expect(_page1).toBeTruthy();
+        expect(_page2).toBeTruthy();
+        expect(_page3).toBeTruthy();
+        expect(_page1.parent).toStrictEqual(top._id);
+        expect(_page2.parent).toStrictEqual(_page1._id);
+        expect(_page3.parent).toStrictEqual(_page2._id);
+        expect(_pageT.descendantCount).toBe(1);
+      });
+      test('a page will replace an empty page with the same path if any', async() => {
+        const pathT = '/mup17_top';
+        const path1 = '/mup17_top/mup12_emp';
+        const path2 = '/mup17_top/mup12_emp/mup18_pub';
+        const pageT = await Page.findOne({ path: pathT, descendantCount: 1 });
+        const page1 = await Page.findOne({ path: path1, isEmpty: true });
+        const page2 = await Page.findOne({ path: path1, grant: Page.GRANT_RESTRICTED, isEmpty: false });
+        const page3 = await Page.findOne({ path: path2 });
+        expect(pageT).toBeTruthy();
+        expect(page1).toBeTruthy();
+        expect(page2).toBeTruthy();
+        expect(page3).toBeTruthy();
+
+        await Page.updatePage(page2, 'newRevisionBody', 'oldRevisionBody', dummyUser1, { grant: Page.GRANT_PUBLIC });
+
+        const _pageT = await Page.findOne({ path: pathT });
+        const _page1 = await Page.findOne({ path: path1, isEmpty: true }); // should be replaced
+        const _page2 = await Page.findOne({ path: path1, grant: Page.GRANT_PUBLIC });
+        const _page3 = await Page.findOne({ path: path2 });
+        expect(_pageT).toBeTruthy();
+        expect(_page1).toBeNull();
+        expect(_page2).toBeTruthy();
+        expect(_page3).toBeTruthy();
+        expect(_page2.grant).toBe(Page.GRANT_PUBLIC);
+        expect(_page2.parent).toStrictEqual(_pageT._id);
+        expect(_page3.parent).toStrictEqual(_page2._id);
+        expect(_pageT.descendantCount).toBe(2);
+      });
+    });
+
   });
 });

+ 12 - 12
packages/app/test/integration/service/page-grant.test.js

@@ -220,7 +220,7 @@ describe('PageGrantService', () => {
       const grantedGroupId = null;
       const shouldCheckDescendants = false;
 
-      const result = await pageGrantService.isGrantNormalized(targetPath, grant, grantedUserIds, grantedGroupId, shouldCheckDescendants);
+      const result = await pageGrantService.isGrantNormalized(user1, targetPath, grant, grantedUserIds, grantedGroupId, shouldCheckDescendants);
 
       expect(result).toBe(true);
     });
@@ -232,7 +232,7 @@ describe('PageGrantService', () => {
       const grantedGroupId = groupParent._id;
       const shouldCheckDescendants = false;
 
-      const result = await pageGrantService.isGrantNormalized(targetPath, grant, grantedUserIds, grantedGroupId, shouldCheckDescendants);
+      const result = await pageGrantService.isGrantNormalized(user1, targetPath, grant, grantedUserIds, grantedGroupId, shouldCheckDescendants);
 
       expect(result).toBe(true);
     });
@@ -244,7 +244,7 @@ describe('PageGrantService', () => {
       const grantedGroupId = null;
       const shouldCheckDescendants = false;
 
-      const result = await pageGrantService.isGrantNormalized(targetPath, grant, grantedUserIds, grantedGroupId, shouldCheckDescendants);
+      const result = await pageGrantService.isGrantNormalized(user1, targetPath, grant, grantedUserIds, grantedGroupId, shouldCheckDescendants);
 
       expect(result).toBe(true);
     });
@@ -256,7 +256,7 @@ describe('PageGrantService', () => {
       const grantedGroupId = groupParent._id;
       const shouldCheckDescendants = false;
 
-      const result = await pageGrantService.isGrantNormalized(targetPath, grant, grantedUserIds, grantedGroupId, shouldCheckDescendants);
+      const result = await pageGrantService.isGrantNormalized(user1, targetPath, grant, grantedUserIds, grantedGroupId, shouldCheckDescendants);
 
       expect(result).toBe(true);
     });
@@ -268,7 +268,7 @@ describe('PageGrantService', () => {
       const grantedGroupId = null;
       const shouldCheckDescendants = false;
 
-      const result = await pageGrantService.isGrantNormalized(targetPath, grant, grantedUserIds, grantedGroupId, shouldCheckDescendants);
+      const result = await pageGrantService.isGrantNormalized(user1, targetPath, grant, grantedUserIds, grantedGroupId, shouldCheckDescendants);
 
       expect(result).toBe(true);
     });
@@ -280,7 +280,7 @@ describe('PageGrantService', () => {
       const grantedGroupId = null;
       const shouldCheckDescendants = false;
 
-      const result = await pageGrantService.isGrantNormalized(targetPath, grant, grantedUserIds, grantedGroupId, shouldCheckDescendants);
+      const result = await pageGrantService.isGrantNormalized(user1, targetPath, grant, grantedUserIds, grantedGroupId, shouldCheckDescendants);
 
       expect(result).toBe(true);
     });
@@ -292,7 +292,7 @@ describe('PageGrantService', () => {
       const grantedGroupId = null;
       const shouldCheckDescendants = false;
 
-      const result = await pageGrantService.isGrantNormalized(targetPath, grant, grantedUserIds, grantedGroupId, shouldCheckDescendants);
+      const result = await pageGrantService.isGrantNormalized(user1, targetPath, grant, grantedUserIds, grantedGroupId, shouldCheckDescendants);
 
       expect(result).toBe(false);
     });
@@ -304,7 +304,7 @@ describe('PageGrantService', () => {
       const grantedGroupId = groupParent._id;
       const shouldCheckDescendants = false;
 
-      const result = await pageGrantService.isGrantNormalized(targetPath, grant, grantedUserIds, grantedGroupId, shouldCheckDescendants);
+      const result = await pageGrantService.isGrantNormalized(user1, targetPath, grant, grantedUserIds, grantedGroupId, shouldCheckDescendants);
 
       expect(result).toBe(false);
     });
@@ -318,7 +318,7 @@ describe('PageGrantService', () => {
       const grantedGroupId = null;
       const shouldCheckDescendants = true;
 
-      const result = await pageGrantService.isGrantNormalized(targetPath, grant, grantedUserIds, grantedGroupId, shouldCheckDescendants);
+      const result = await pageGrantService.isGrantNormalized(user1, targetPath, grant, grantedUserIds, grantedGroupId, shouldCheckDescendants);
 
       expect(result).toBe(true);
     });
@@ -330,7 +330,7 @@ describe('PageGrantService', () => {
       const grantedGroupId = null;
       const shouldCheckDescendants = true;
 
-      const result = await pageGrantService.isGrantNormalized(targetPath, grant, grantedUserIds, grantedGroupId, shouldCheckDescendants);
+      const result = await pageGrantService.isGrantNormalized(user1, targetPath, grant, grantedUserIds, grantedGroupId, shouldCheckDescendants);
 
       expect(result).toBe(true);
     });
@@ -342,7 +342,7 @@ describe('PageGrantService', () => {
       const grantedGroupId = groupParent._id;
       const shouldCheckDescendants = true;
 
-      const result = await pageGrantService.isGrantNormalized(targetPath, grant, grantedUserIds, grantedGroupId, shouldCheckDescendants);
+      const result = await pageGrantService.isGrantNormalized(user1, targetPath, grant, grantedUserIds, grantedGroupId, shouldCheckDescendants);
 
       expect(result).toBe(true);
     });
@@ -354,7 +354,7 @@ describe('PageGrantService', () => {
       const grantedGroupId = null;
       const shouldCheckDescendants = true;
 
-      const result = await pageGrantService.isGrantNormalized(targetPath, grant, grantedUserIds, grantedGroupId, shouldCheckDescendants);
+      const result = await pageGrantService.isGrantNormalized(user1, targetPath, grant, grantedUserIds, grantedGroupId, shouldCheckDescendants);
 
       expect(result).toBe(false);
     });

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

@@ -1,3 +1,4 @@
+/* eslint-disable max-len */
 const mongoose = require('mongoose');
 
 const { getInstance } = require('../setup-crowi');
@@ -10,9 +11,12 @@ describe('V5 page migration', () => {
   let UserGroupRelation;
 
   let testUser1;
+  let rootUser;
 
   let rootPage;
 
+  const rootUserGroupId = new mongoose.Types.ObjectId();
+  const testUser1GroupId = new mongoose.Types.ObjectId();
   const groupIdIsolate = new mongoose.Types.ObjectId();
   const groupIdA = new mongoose.Types.ObjectId();
   const groupIdB = new mongoose.Types.ObjectId();
@@ -41,11 +45,23 @@ describe('V5 page migration', () => {
 
     await crowi.configManager.updateConfigsInTheSameNamespace('crowi', { 'app:isV5Compatible': true });
 
-    await User.insertMany([{ name: 'testUser1', username: 'testUser1', email: 'testUser1@example.com' }]);
+    await User.insertMany([
+      { name: 'rootUser', username: 'rootUser', email: 'rootUser@example.com' },
+      { name: 'testUser1', username: 'testUser1', email: 'testUser1@example.com' },
+    ]);
+    rootUser = await User.findOne({ username: 'rootUser' });
     testUser1 = await User.findOne({ username: 'testUser1' });
     rootPage = await Page.findOne({ path: '/' });
 
     await UserGroup.insertMany([
+      {
+        _id: rootUserGroupId,
+        name: 'rootUserGroup',
+      },
+      {
+        _id: testUser1GroupId,
+        name: 'testUser1Group',
+      },
       {
         _id: groupIdIsolate,
         name: 'groupIsolate',
@@ -67,6 +83,14 @@ describe('V5 page migration', () => {
     ]);
 
     await UserGroupRelation.insertMany([
+      {
+        relatedGroup: rootUserGroupId,
+        relatedUser: rootUser._id,
+      },
+      {
+        relatedGroup: testUser1GroupId,
+        relatedUser: testUser1._id,
+      },
       {
         relatedGroup: groupIdIsolate,
         relatedUser: testUser1._id,
@@ -223,20 +247,15 @@ describe('V5 page migration', () => {
 
   });
 
-  // https://github.com/jest-community/eslint-plugin-jest/blob/v24.3.5/docs/rules/expect-expect.md#assertfunctionnames
-  // pass unless the data is one of [false, 0, '', null, undefined, NaN]
-  const expectAllToBeTruthy = (dataList) => {
-    dataList.forEach((data, i) => {
-      if (data == null) { console.log(`index: ${i}`) }
-      expect(data).toBeTruthy();
-    });
+  const normalizeParentRecursivelyByPages = async(pages, user) => {
+    return crowi.pageService.normalizeParentRecursivelyByPages(pages, user);
   };
 
-  describe('normalizeParentRecursivelyByPages()', () => {
+  const normalizeParentByPage = async(page, user) => {
+    return crowi.pageService.normalizeParentByPage(page, user);
+  };
 
-    const normalizeParentRecursivelyByPages = async(pages, user) => {
-      return crowi.pageService.normalizeParentRecursivelyByPages(pages, user);
-    };
+  describe('normalizeParentRecursivelyByPages()', () => {
 
     test('should migrate all pages specified by pageIds', async() => {
       jest.restoreAllMocks();
@@ -262,7 +281,9 @@ describe('V5 page migration', () => {
       const page9 = await Page.findOne({ path: '/normalize_7/normalize_8_gA/normalize_9_gB' });
       const page10 = await Page.findOne({ path: '/normalize_7/normalize_8_gC' });
       const page11 = await Page.findOne({ path: '/normalize_7' });
-      expectAllToBeTruthy([page8, page9, page10]);
+      expect(page8).toBeTruthy();
+      expect(page9).toBeTruthy();
+      expect(page10).toBeTruthy();
       expect(page11).toBeNull();
       await normalizeParentRecursivelyByPages([page8, page9, page10], testUser1);
 
@@ -271,7 +292,10 @@ describe('V5 page migration', () => {
       const page8AM = await Page.findOne({ path: '/normalize_7/normalize_8_gA' });
       const page9AM = await Page.findOne({ path: '/normalize_7/normalize_8_gA/normalize_9_gB' });
       const page10AM = await Page.findOne({ path: '/normalize_7/normalize_8_gC' });
-      expectAllToBeTruthy([page7, page8AM, page9AM, page10AM]);
+      expect(page7).toBeTruthy();
+      expect(page8AM).toBeTruthy();
+      expect(page9AM).toBeTruthy();
+      expect(page10AM).toBeTruthy();
 
       expect(page7.isEmpty).toBe(true);
 
@@ -281,7 +305,7 @@ describe('V5 page migration', () => {
       expect(page10AM.parent).toStrictEqual(page7._id);
     });
 
-    test("should replace empty page with same path with new non-empty page and update all related children's parent", async() => {
+    test('should replace empty page with same path with new non-empty page and update all related children\'s parent', async() => {
       const page1 = await Page.findOne({ path: '/normalize_10', isEmpty: true, parent: { $ne: null } });
       const page2 = await Page.findOne({
         path: '/normalize_10/normalize_11_gA', _id: pageId8, isEmpty: true, parent: { $ne: null },
@@ -289,8 +313,11 @@ describe('V5 page migration', () => {
       const page3 = await Page.findOne({ path: '/normalize_10/normalize_11_gA', _id: pageId9, parent: null }); // not v5
       const page4 = await Page.findOne({ path: '/normalize_10/normalize_11_gA/normalize_11_gB', parent: { $ne: null } });
       const page5 = await Page.findOne({ path: '/normalize_10/normalize_12_gC', parent: { $ne: null } });
-      expectAllToBeTruthy([page1, page2, page3, page4, page5]);
-
+      expect(page1).toBeTruthy();
+      expect(page2).toBeTruthy();
+      expect(page3).toBeTruthy();
+      expect(page4).toBeTruthy();
+      expect(page5).toBeTruthy();
       await normalizeParentRecursivelyByPages([page3], testUser1);
 
       // AM => After Migration
@@ -299,7 +326,10 @@ describe('V5 page migration', () => {
       const page3AM = await Page.findOne({ path: '/normalize_10/normalize_11_gA', _id: pageId9 });
       const page4AM = await Page.findOne({ path: '/normalize_10/normalize_11_gA/normalize_11_gB' });
       const page5AM = await Page.findOne({ path: '/normalize_10/normalize_12_gC' });
-      expectAllToBeTruthy([page1AM, page3AM, page4AM, page5AM]);
+      expect(page1AM).toBeTruthy();
+      expect(page3AM).toBeTruthy();
+      expect(page4AM).toBeTruthy();
+      expect(page5AM).toBeTruthy();
       expect(page2AM).toBeNull();
 
       expect(page1AM.isEmpty).toBeTruthy();
@@ -309,6 +339,320 @@ describe('V5 page migration', () => {
 
       expect(page3AM.isEmpty).toBe(false);
     });
+
+  });
+
+  describe('should normalize only selected pages recursively (especially should NOT normalize non-selected ancestors)', () => {
+    /*
+     * # Test flow
+     * - Existing pages
+     *   - All pages are NOT normalized
+     *   - A, B, C, and D are owned by "testUser1"
+     *   A. /normalize_A_owned
+     *   B. /normalize_A_owned/normalize_B_owned
+     *   C. /normalize_A_owned/normalize_B_owned/normalize_C_owned
+     *   D. /normalize_A_owned/normalize_B_owned/normalize_C_owned/normalize_D_owned
+     *   E. /normalize_A_owned/normalize_B_owned/normalize_C_owned/normalize_D_root
+     *     - Owned by "rootUser"
+     *   F. /normalize_A_owned/normalize_B_owned/normalize_C_owned/normalize_D_group
+     *     - Owned by the userGroup "groupIdIsolate"
+     *
+     * 1. Normalize A and B one by one.
+     *   - Expect
+     *     - A and B are normalized
+     *     - C and D are NOT normalized
+     *     - E and F are NOT normalized
+     * 2. Recursively normalize D.
+     *   - Expect
+     *     - A, B, and D are normalized
+     *     - C is NOT normalized
+     *       - C is substituted by an empty page whose path is "/normalize_A_owned/normalize_B_owned/normalize_C_owned"
+     *     - E and F are NOT normalized
+     * 3. Recursively normalize C.
+     *   - Expect
+     *     - A, B, C, and D are normalized
+     *     - An empty page at "/normalize_A_owned/normalize_B_owned/normalize_C_owned" does NOT exist (removed)
+     *     - E and F are NOT normalized
+     */
+
+    const owned = filter => ({ grantedUsers: [testUser1._id], ...filter });
+    const root = filter => ({ grantedUsers: [rootUser._id], ...filter });
+    const rootUserGroup = filter => ({ grantedGroup: rootUserGroupId, ...filter });
+    const testUser1Group = filter => ({ grantedGroup: testUser1GroupId, ...filter });
+
+    const normalized = { parent: { $ne: null } };
+    const notNormalized = { parent: null };
+    const empty = { isEmpty: true };
+
+    beforeAll(async() => {
+      // Prepare data
+      const id17 = new mongoose.Types.ObjectId();
+      const id21 = new mongoose.Types.ObjectId();
+      const id22 = new mongoose.Types.ObjectId();
+      const id23 = new mongoose.Types.ObjectId();
+
+      await Page.insertMany([
+        // 1
+        {
+          path: '/normalize_13_owned',
+          grant: Page.GRANT_OWNER,
+          grantedUsers: [testUser1._id],
+        },
+        {
+          path: '/normalize_13_owned/normalize_14_owned',
+          grant: Page.GRANT_OWNER,
+          grantedUsers: [testUser1._id],
+        },
+        {
+          path: '/normalize_13_owned/normalize_14_owned/normalize_15_owned',
+          grant: Page.GRANT_OWNER,
+          grantedUsers: [testUser1._id],
+        },
+        {
+          path: '/normalize_13_owned/normalize_14_owned/normalize_15_owned/normalize_16_owned',
+          grant: Page.GRANT_OWNER,
+          grantedUsers: [testUser1._id],
+        },
+        {
+          path: '/normalize_13_owned/normalize_14_owned/normalize_15_owned/normalize_16_root',
+          grant: Page.GRANT_OWNER,
+          grantedUsers: [rootUser._id],
+        },
+        {
+          path: '/normalize_13_owned/normalize_14_owned/normalize_15_owned/normalize_16_group',
+          grant: Page.GRANT_USER_GROUP,
+          grantedGroup: testUser1GroupId,
+        },
+
+        // 2
+        {
+          _id: id17,
+          path: '/normalize_17_owned',
+          grant: Page.GRANT_OWNER,
+          grantedUsers: [testUser1._id],
+          parent: rootPage._id,
+        },
+        {
+          path: '/normalize_17_owned/normalize_18_owned',
+          grant: Page.GRANT_OWNER,
+          grantedUsers: [testUser1._id],
+          parent: id17,
+        },
+        {
+          path: '/normalize_17_owned/normalize_18_owned/normalize_19_owned',
+          grant: Page.GRANT_OWNER,
+          grantedUsers: [testUser1._id],
+        },
+        {
+          path: '/normalize_17_owned/normalize_18_owned/normalize_19_owned/normalize_20_owned',
+          grant: Page.GRANT_OWNER,
+          grantedUsers: [testUser1._id],
+        },
+        {
+          path: '/normalize_17_owned/normalize_18_owned/normalize_19_owned/normalize_20_root',
+          grant: Page.GRANT_OWNER,
+          grantedUsers: [rootUser._id],
+        },
+        {
+          path: '/normalize_17_owned/normalize_18_owned/normalize_19_owned/normalize_20_group',
+          grant: Page.GRANT_USER_GROUP,
+          grantedGroup: rootUserGroupId,
+        },
+
+        // 3
+        {
+          _id: id21,
+          path: '/normalize_21_owned',
+          grant: Page.GRANT_OWNER,
+          grantedUsers: [testUser1._id],
+          parent: rootPage._id,
+        },
+        {
+          _id: id22,
+          path: '/normalize_21_owned/normalize_22_owned',
+          grant: Page.GRANT_OWNER,
+          grantedUsers: [testUser1._id],
+          parent: id21,
+        },
+        {
+          path: '/normalize_21_owned/normalize_22_owned/normalize_23_owned',
+          grant: Page.GRANT_OWNER,
+          grantedUsers: [testUser1._id],
+          parent: null,
+        },
+        {
+          _id: id23,
+          path: '/normalize_21_owned/normalize_22_owned/normalize_23_owned',
+          isEmpty: true,
+          parent: id22,
+        },
+        {
+          path: '/normalize_21_owned/normalize_22_owned/normalize_23_owned/normalize_24_owned',
+          grant: Page.GRANT_OWNER,
+          grantedUsers: [testUser1._id],
+          parent: id23,
+        },
+        {
+          path: '/normalize_21_owned/normalize_22_owned/normalize_23_owned/normalize_24_root',
+          grant: Page.GRANT_OWNER,
+          grantedUsers: [rootUser._id],
+        },
+        {
+          path: '/normalize_21_owned/normalize_22_owned/normalize_23_owned/normalize_24_rootGroup',
+          grant: Page.GRANT_USER_GROUP,
+          grantedGroup: rootUserGroupId,
+        },
+      ]);
+    });
+
+
+    test('Should normalize pages one by one without including other pages', async() => {
+      const _owned13 = await Page.findOne(owned({ path: '/normalize_13_owned', ...notNormalized }));
+      const _owned14 = await Page.findOne(owned({ path: '/normalize_13_owned/normalize_14_owned', ...notNormalized }));
+      const _owned15 = await Page.findOne(owned({ path: '/normalize_13_owned/normalize_14_owned/normalize_15_owned', ...notNormalized }));
+      const _owned16 = await Page.findOne(owned({ path: '/normalize_13_owned/normalize_14_owned/normalize_15_owned/normalize_16_owned', ...notNormalized }));
+      const _root16 = await Page.findOne(root({ path: '/normalize_13_owned/normalize_14_owned/normalize_15_owned/normalize_16_root', ...notNormalized }));
+      const _group16 = await Page.findOne(testUser1Group({ path: '/normalize_13_owned/normalize_14_owned/normalize_15_owned/normalize_16_group', ...notNormalized }));
+
+      expect(_owned13).not.toBeNull();
+      expect(_owned14).not.toBeNull();
+      expect(_owned15).not.toBeNull();
+      expect(_owned16).not.toBeNull();
+      expect(_root16).not.toBeNull();
+      expect(_group16).not.toBeNull();
+
+      // Normalize
+      await normalizeParentByPage(_owned14, testUser1);
+
+      const owned13 = await Page.findOne({ path: '/normalize_13_owned' });
+      const empty13 = await Page.findOne({ path: '/normalize_13_owned', ...empty });
+      const owned14 = await Page.findOne({ path: '/normalize_13_owned/normalize_14_owned' });
+      const owned15 = await Page.findOne({ path: '/normalize_13_owned/normalize_14_owned/normalize_15_owned' });
+      const owned16 = await Page.findOne({ path: '/normalize_13_owned/normalize_14_owned/normalize_15_owned/normalize_16_owned' });
+      const root16 = await Page.findOne(root({ path: '/normalize_13_owned/normalize_14_owned/normalize_15_owned/normalize_16_root' }));
+      const group16 = await Page.findOne(testUser1Group({ path: '/normalize_13_owned/normalize_14_owned/normalize_15_owned/normalize_16_group' }));
+
+      expect(owned13).not.toBeNull();
+      expect(empty13).not.toBeNull();
+      expect(owned14).not.toBeNull();
+      expect(owned15).not.toBeNull();
+      expect(owned16).not.toBeNull();
+      expect(root16).not.toBeNull();
+      expect(group16).not.toBeNull();
+
+      // Check parent
+      expect(owned13.parent).toBeNull();
+      expect(empty13.parent).toStrictEqual(rootPage._id);
+      expect(owned14.parent).toStrictEqual(empty13._id);
+      expect(owned15.parent).toBeNull();
+      expect(owned16.parent).toBeNull();
+      expect(root16.parent).toBeNull();
+      expect(group16.parent).toBeNull();
+
+      // Check descendantCount
+      expect(owned13.descendantCount).toBe(0);
+      expect(empty13.descendantCount).toBe(1);
+      expect(owned14.descendantCount).toBe(0);
+    });
+
+    test('Should normalize pages recursively excluding the pages not selected', async() => {
+      const _owned17 = await Page.findOne(owned({ path: '/normalize_17_owned', ...normalized }));
+      const _owned18 = await Page.findOne(owned({ path: '/normalize_17_owned/normalize_18_owned', ...normalized }));
+      const _owned19 = await Page.findOne(owned({ path: '/normalize_17_owned/normalize_18_owned/normalize_19_owned', ...notNormalized }));
+      const _owned20 = await Page.findOne(owned({ path: '/normalize_17_owned/normalize_18_owned/normalize_19_owned/normalize_20_owned', ...notNormalized }));
+      const _root20 = await Page.findOne(root({ path: '/normalize_17_owned/normalize_18_owned/normalize_19_owned/normalize_20_root', ...notNormalized }));
+      const _group20 = await Page.findOne(rootUserGroup({ path: '/normalize_17_owned/normalize_18_owned/normalize_19_owned/normalize_20_group', ...notNormalized }));
+
+      expect(_owned17).not.toBeNull();
+      expect(_owned18).not.toBeNull();
+      expect(_owned19).not.toBeNull();
+      expect(_owned20).not.toBeNull();
+      expect(_root20).not.toBeNull();
+      expect(_group20).not.toBeNull();
+
+      // Normalize
+      await normalizeParentRecursivelyByPages([_owned20], testUser1);
+
+      const owned17 = await Page.findOne({ path: '/normalize_17_owned' });
+      const owned18 = await Page.findOne({ path: '/normalize_17_owned/normalize_18_owned' });
+      const owned19 = await Page.findOne({ path: '/normalize_17_owned/normalize_18_owned/normalize_19_owned' });
+      const empty19 = await Page.findOne({ path: '/normalize_17_owned/normalize_18_owned/normalize_19_owned', ...empty });
+      const owned20 = await Page.findOne({ path: '/normalize_17_owned/normalize_18_owned/normalize_19_owned/normalize_20_owned' });
+      const root20 = await Page.findOne(root({ path: '/normalize_17_owned/normalize_18_owned/normalize_19_owned/normalize_20_root' }));
+      const group20 = await Page.findOne(rootUserGroup({ path: '/normalize_17_owned/normalize_18_owned/normalize_19_owned/normalize_20_group' }));
+
+      expect(owned17).not.toBeNull();
+      expect(owned18).not.toBeNull();
+      expect(owned19).not.toBeNull();
+      expect(empty19).not.toBeNull();
+      expect(owned20).not.toBeNull();
+      expect(root20).not.toBeNull();
+      expect(group20).not.toBeNull();
+
+      // Check parent
+      expect(owned17.parent).toStrictEqual(rootPage._id);
+      expect(owned18.parent).toStrictEqual(owned17._id);
+      expect(owned19.parent).toBeNull();
+      expect(empty19.parent).toStrictEqual(owned18._id);
+      expect(owned20.parent).toStrictEqual(empty19._id);
+      expect(root20.parent).toBeNull();
+      expect(group20.parent).toBeNull();
+
+      // Check isEmpty
+      expect(owned17.isEmpty).toBe(false);
+      expect(owned18.isEmpty).toBe(false);
+    });
+
+    test('Should normalize pages recursively excluding the pages of not user\'s & Should delete unnecessary empty pages', async() => {
+      const _owned21 = await Page.findOne(owned({ path: '/normalize_21_owned', ...normalized }));
+      const _owned22 = await Page.findOne(owned({ path: '/normalize_21_owned/normalize_22_owned', ...normalized }));
+      const _owned23 = await Page.findOne(owned({ path: '/normalize_21_owned/normalize_22_owned/normalize_23_owned', ...notNormalized }));
+      const _empty23 = await Page.findOne({ path: '/normalize_21_owned/normalize_22_owned/normalize_23_owned', ...normalized, ...empty });
+      const _owned24 = await Page.findOne(owned({ path: '/normalize_21_owned/normalize_22_owned/normalize_23_owned/normalize_24_owned', ...normalized }));
+      const _root24 = await Page.findOne(root({ path: '/normalize_21_owned/normalize_22_owned/normalize_23_owned/normalize_24_root', ...notNormalized }));
+      const _rootGroup24 = await Page.findOne(rootUserGroup({ path: '/normalize_21_owned/normalize_22_owned/normalize_23_owned/normalize_24_rootGroup', ...notNormalized }));
+
+      expect(_owned21).not.toBeNull();
+      expect(_owned22).not.toBeNull();
+      expect(_owned23).not.toBeNull();
+      expect(_empty23).not.toBeNull();
+      expect(_owned24).not.toBeNull();
+      expect(_root24).not.toBeNull();
+      expect(_rootGroup24).not.toBeNull();
+
+      // Normalize
+      await normalizeParentRecursivelyByPages([_owned23], testUser1);
+
+      const owned21 = await Page.findOne({ path: '/normalize_21_owned' });
+      const owned22 = await Page.findOne({ path: '/normalize_21_owned/normalize_22_owned' });
+      const owned23 = await Page.findOne({ path: '/normalize_21_owned/normalize_22_owned/normalize_23_owned' });
+      const empty23 = await Page.findOne({ path: '/normalize_21_owned/normalize_22_owned/normalize_23_owned', ...empty });
+      const owned24 = await Page.findOne({ path: '/normalize_21_owned/normalize_22_owned/normalize_23_owned/normalize_24_owned' });
+      const root24 = await Page.findOne(root({ path: '/normalize_21_owned/normalize_22_owned/normalize_23_owned/normalize_24_root' }));
+      const rootGroup24 = await Page.findOne(rootUserGroup({ path: '/normalize_21_owned/normalize_22_owned/normalize_23_owned/normalize_24_rootGroup' }));
+
+      expect(owned21).not.toBeNull();
+      expect(owned22).not.toBeNull();
+      expect(owned23).not.toBeNull();
+      expect(empty23).toBeNull(); // removed
+      expect(owned24).not.toBeNull();
+      expect(root24).not.toBeNull();
+      expect(rootGroup24).not.toBeNull();
+
+      // Check parent
+      expect(owned21.parent).toStrictEqual(rootPage._id);
+      expect(owned22.parent).toStrictEqual(owned21._id);
+      expect(owned23.parent).toStrictEqual(owned22._id);
+      expect(owned24.parent).toStrictEqual(owned23._id); // not empty23._id
+      expect(root24.parent).toBeNull();
+      expect(rootGroup24.parent).toBeNull(); // excluded from the pages to be normalized
+
+      // Check isEmpty
+      expect(owned21.isEmpty).toBe(false);
+      expect(owned22.isEmpty).toBe(false);
+      expect(owned23.isEmpty).toBe(false);
+    });
+
   });
 
   describe('normalizeAllPublicPages()', () => {
@@ -407,23 +751,23 @@ describe('V5 page migration', () => {
     });
   });
 
-  describe('normalizeParentByPageId()', () => {
-    const normalizeParentByPageId = async(page, user) => {
-      return crowi.pageService.normalizeParentByPageId(page, user);
-    };
+  describe('normalizeParentByPage()', () => {
     test('it should normalize not v5 page with usergroup that has parent group', async() => {
       const page1 = await Page.findOne({ _id: pageId1, path: '/normalize_1', isEmpty: true });
       const page2 = await Page.findOne({ _id: pageId2, path: '/normalize_1/normalize_2', parent: page1._id });
       const page3 = await Page.findOne({ _id: pageId3, path: '/normalize_1' }); // NOT v5
-      expectAllToBeTruthy([page1, page2, page3]);
+      expect(page1).toBeTruthy();
+      expect(page2).toBeTruthy();
+      expect(page3).toBeTruthy();
 
-      await normalizeParentByPageId(page3, testUser1);
+      await normalizeParentByPage(page3, testUser1);
 
       // AM => After Migration
       const page1AM = await Page.findOne({ _id: pageId1, path: '/normalize_1', isEmpty: true });
       const page2AM = await Page.findOne({ _id: pageId2, path: '/normalize_1/normalize_2' });
       const page3AM = await Page.findOne({ _id: pageId3, path: '/normalize_1' }); // v5 compatible
-      expectAllToBeTruthy([page2AM, page3AM]);
+      expect(page2AM).toBeTruthy();
+      expect(page3AM).toBeTruthy();
       expect(page1AM).toBeNull();
 
       expect(page2AM.parent).toStrictEqual(page3AM._id);
@@ -434,12 +778,13 @@ describe('V5 page migration', () => {
       const page4 = await Page.findOne({ _id: pageId4, path: '/normalize_4', isEmpty: true });
       const page5 = await Page.findOne({ _id: pageId5, path: '/normalize_4/normalize_5', parent: page4._id });
       const page6 = await Page.findOne({ _id: pageId6, path: '/normalize_4' }); // NOT v5
-
-      expectAllToBeTruthy([page4, page5, page6]);
+      expect(page4).toBeTruthy();
+      expect(page5).toBeTruthy();
+      expect(page6).toBeTruthy();
 
       let isThrown;
       try {
-        await normalizeParentByPageId(page6, testUser1);
+        await normalizeParentByPage(page6, testUser1);
       }
       catch (err) {
         isThrown = true;
@@ -449,9 +794,10 @@ describe('V5 page migration', () => {
       const page4AM = await Page.findOne({ _id: pageId4, path: '/normalize_4', isEmpty: true });
       const page5AM = await Page.findOne({ _id: pageId5, path: '/normalize_4/normalize_5', parent: page4._id });
       const page6AM = await Page.findOne({ _id: pageId6, path: '/normalize_4' }); // NOT v5
-      expectAllToBeTruthy([page4AM, page5AM, page6AM]);
-
       expect(isThrown).toBe(true);
+      expect(page4AM).toBeTruthy();
+      expect(page5AM).toBeTruthy();
+      expect(page6AM).toBeTruthy();
       expect(page4AM).toStrictEqual(page4);
       expect(page5AM).toStrictEqual(page5);
       expect(page6AM).toStrictEqual(page6);

+ 750 - 38
packages/app/test/integration/service/v5.non-public-page.test.ts

@@ -12,6 +12,7 @@ describe('PageService page operations with non-public pages', () => {
   let npDummyUser1;
   let npDummyUser2;
   let npDummyUser3;
+  let groupIdIsolate;
   let groupIdA;
   let groupIdB;
   let groupIdC;
@@ -31,13 +32,36 @@ describe('PageService page operations with non-public pages', () => {
 
   let rootPage;
 
-  // pass unless the data is one of [false, 0, '', null, undefined, NaN]
-  const expectAllToBeTruthy = (dataList) => {
-    dataList.forEach((data, i) => {
-      if (data == null) { console.log(`index: ${i}`) }
-      expect(data).toBeTruthy();
-    });
-  };
+  /**
+   * Rename
+   */
+  const pageIdRename1 = new mongoose.Types.ObjectId();
+  const pageIdRename2 = new mongoose.Types.ObjectId();
+  const pageIdRename3 = new mongoose.Types.ObjectId();
+  const pageIdRename4 = new mongoose.Types.ObjectId();
+  const pageIdRename5 = new mongoose.Types.ObjectId();
+  const pageIdRename6 = new mongoose.Types.ObjectId();
+  const pageIdRename7 = new mongoose.Types.ObjectId();
+  const pageIdRename8 = new mongoose.Types.ObjectId();
+  const pageIdRename9 = new mongoose.Types.ObjectId();
+
+  /**
+   * Duplicate
+   */
+  // page id
+  const pageIdDuplicate1 = new mongoose.Types.ObjectId();
+  const pageIdDuplicate2 = new mongoose.Types.ObjectId();
+  const pageIdDuplicate3 = new mongoose.Types.ObjectId();
+  const pageIdDuplicate4 = new mongoose.Types.ObjectId();
+  const pageIdDuplicate5 = new mongoose.Types.ObjectId();
+  const pageIdDuplicate6 = new mongoose.Types.ObjectId();
+  // revision id
+  const revisionIdDuplicate1 = new mongoose.Types.ObjectId();
+  const revisionIdDuplicate2 = new mongoose.Types.ObjectId();
+  const revisionIdDuplicate3 = new mongoose.Types.ObjectId();
+  const revisionIdDuplicate4 = new mongoose.Types.ObjectId();
+  const revisionIdDuplicate5 = new mongoose.Types.ObjectId();
+  const revisionIdDuplicate6 = new mongoose.Types.ObjectId();
 
   /**
    * Revert
@@ -97,7 +121,7 @@ describe('PageService page operations with non-public pages', () => {
       },
     ]);
 
-    const groupIdIsolate = new mongoose.Types.ObjectId();
+    groupIdIsolate = new mongoose.Types.ObjectId();
     groupIdA = new mongoose.Types.ObjectId();
     groupIdB = new mongoose.Types.ObjectId();
     groupIdC = new mongoose.Types.ObjectId();
@@ -182,18 +206,303 @@ describe('PageService page operations with non-public pages', () => {
     /*
      * Rename
      */
-
+    await Page.insertMany([
+      {
+        _id: pageIdRename1,
+        path: '/np_rename1_destination',
+        grant: Page.GRANT_PUBLIC,
+        creator: dummyUser1._id,
+        lastUpdateUser: dummyUser1._id,
+        parent: rootPage._id,
+      },
+      {
+        _id: pageIdRename2,
+        path: '/np_rename2',
+        grant: Page.GRANT_USER_GROUP,
+        grantedGroup: groupIdB,
+        creator: npDummyUser2._id,
+        lastUpdateUser: npDummyUser2._id,
+        parent: rootPage._id,
+      },
+      {
+        _id: pageIdRename3,
+        path: '/np_rename2/np_rename3',
+        grant: Page.GRANT_USER_GROUP,
+        grantedGroup: groupIdC,
+        creator: npDummyUser3._id,
+        lastUpdateUser: npDummyUser3._id,
+        parent: pageIdRename2._id,
+      },
+      {
+        _id: pageIdRename4,
+        path: '/np_rename4_destination',
+        grant: Page.GRANT_USER_GROUP,
+        grantedGroup: groupIdIsolate,
+        creator: npDummyUser3._id,
+        lastUpdateUser: npDummyUser3._id,
+        parent: rootPage._id,
+      },
+      {
+        _id: pageIdRename5,
+        path: '/np_rename5',
+        grant: Page.GRANT_USER_GROUP,
+        grantedGroup: groupIdB,
+        creator: npDummyUser2._id,
+        lastUpdateUser: npDummyUser2._id,
+        parent: rootPage._id,
+      },
+      {
+        _id: pageIdRename6,
+        path: '/np_rename5/np_rename6',
+        grant: Page.GRANT_USER_GROUP,
+        grantedGroup: groupIdB,
+        creator: npDummyUser2._id,
+        lastUpdateUser: npDummyUser2._id,
+        parent: pageIdRename5,
+      },
+      {
+        _id: pageIdRename7,
+        path: '/np_rename7_destination',
+        grant: Page.GRANT_USER_GROUP,
+        grantedGroup: groupIdIsolate,
+        creator: npDummyUser2._id,
+        lastUpdateUser: npDummyUser2._id,
+        parent: pageIdRename5,
+      },
+      {
+        _id: pageIdRename8,
+        path: '/np_rename8',
+        grant: Page.GRANT_RESTRICTED,
+        creator: dummyUser1._id,
+        lastUpdateUser: dummyUser1._id,
+      },
+      {
+        _id: pageIdRename9,
+        path: '/np_rename8/np_rename9',
+        grant: Page.GRANT_RESTRICTED,
+        creator: dummyUser2._id,
+        lastUpdateUser: dummyUser2._id,
+      },
+    ]);
     /*
      * Duplicate
      */
+    await Page.insertMany([
+      {
+        _id: pageIdDuplicate1,
+        path: '/np_duplicate1',
+        grant: Page.GRANT_RESTRICTED,
+        creator: dummyUser1._id,
+        lastUpdateUser: dummyUser1._id,
+        revision: revisionIdDuplicate1,
+      },
+      {
+        _id: pageIdDuplicate2,
+        path: '/np_duplicate2',
+        grant: Page.GRANT_USER_GROUP,
+        grantedGroup: groupIdA,
+        creator: npDummyUser1._id,
+        lastUpdateUser: npDummyUser1._id,
+        revision: revisionIdDuplicate2,
+        parent: rootPage._id,
+      },
+      {
+        _id: pageIdDuplicate3,
+        path: '/np_duplicate2/np_duplicate3',
+        grant: Page.GRANT_USER_GROUP,
+        grantedGroup: groupIdB,
+        creator: npDummyUser2._id,
+        lastUpdateUser: npDummyUser2._id,
+        revision: revisionIdDuplicate3,
+        parent: pageIdDuplicate2,
+      },
+      {
+        _id: pageIdDuplicate4,
+        path: '/np_duplicate4',
+        grant: Page.GRANT_PUBLIC,
+        creator: npDummyUser1._id,
+        lastUpdateUser: npDummyUser1._id,
+        revision: revisionIdDuplicate4,
+        parent: rootPage._id,
+      },
+      {
+        _id: pageIdDuplicate5,
+        path: '/np_duplicate4/np_duplicate5',
+        grant: Page.GRANT_RESTRICTED,
+        creator: npDummyUser1._id,
+        lastUpdateUser: npDummyUser1._id,
+        revision: revisionIdDuplicate5,
+      },
+      {
+        _id: pageIdDuplicate6,
+        path: '/np_duplicate4/np_duplicate6',
+        grant: Page.GRANT_PUBLIC,
+        creator: npDummyUser1._id,
+        lastUpdateUser: npDummyUser1._id,
+        parent: pageIdDuplicate4,
+        revision: revisionIdDuplicate6,
+      },
+    ]);
+    await Revision.insertMany([
+      {
+        _id: revisionIdDuplicate1,
+        body: 'np_duplicate1',
+        format: 'markdown',
+        pageId: pageIdDuplicate1,
+        author: npDummyUser1._id,
+      },
+      {
+        _id: revisionIdDuplicate2,
+        body: 'np_duplicate2',
+        format: 'markdown',
+        pageId: pageIdDuplicate2,
+        author: npDummyUser2._id,
+      },
+      {
+        _id: revisionIdDuplicate3,
+        body: 'np_duplicate3',
+        format: 'markdown',
+        pageId: pageIdDuplicate3,
+        author: npDummyUser2._id,
+      },
+      {
+        _id: revisionIdDuplicate4,
+        body: 'np_duplicate4',
+        format: 'markdown',
+        pageId: pageIdDuplicate4,
+        author: npDummyUser2._id,
+      },
+      {
+        _id: revisionIdDuplicate5,
+        body: 'np_duplicate5',
+        format: 'markdown',
+        pageId: pageIdDuplicate5,
+        author: npDummyUser2._id,
+      },
+      {
+        _id: revisionIdDuplicate6,
+        body: 'np_duplicate6',
+        format: 'markdown',
+        pageId: pageIdDuplicate6,
+        author: npDummyUser1._id,
+      },
+    ]);
 
     /**
      * Delete
      */
+    const pageIdDelete1 = new mongoose.Types.ObjectId();
+    const pageIdDelete2 = new mongoose.Types.ObjectId();
+    const pageIdDelete3 = new mongoose.Types.ObjectId();
+    const pageIdDelete4 = new mongoose.Types.ObjectId();
+    await Page.insertMany([
+      {
+        _id: pageIdDelete1,
+        path: '/npdel1_awl',
+        grant: Page.GRANT_RESTRICTED,
+        status: Page.STATUS_PUBLISHED,
+        isEmpty: false,
+      },
+      {
+        _id: pageIdDelete2,
+        path: '/npdel2_ug',
+        grant: Page.GRANT_USER_GROUP,
+        grantedGroup: groupIdA,
+        status: Page.STATUS_PUBLISHED,
+        isEmpty: false,
+        parent: rootPage._id,
+        descendantCount: 0,
+      },
+      {
+        _id: pageIdDelete3,
+        path: '/npdel3_top',
+        grant: Page.GRANT_USER_GROUP,
+        grantedGroup: groupIdA,
+        status: Page.STATUS_PUBLISHED,
+        isEmpty: false,
+        parent: rootPage._id,
+        descendantCount: 2,
+      },
+      {
+        _id: pageIdDelete4,
+        path: '/npdel3_top/npdel4_ug',
+        grant: Page.GRANT_USER_GROUP,
+        grantedGroup: groupIdB,
+        status: Page.STATUS_PUBLISHED,
+        isEmpty: false,
+        parent: pageIdDelete3._id,
+        descendantCount: 1,
+      },
+      {
+        path: '/npdel3_top/npdel4_ug',
+        grant: Page.GRANT_RESTRICTED,
+        status: Page.STATUS_PUBLISHED,
+        isEmpty: false,
+      },
+      {
+        path: '/npdel3_top/npdel4_ug/npdel5_ug',
+        grant: Page.GRANT_USER_GROUP,
+        grantedGroup: groupIdC,
+        status: Page.STATUS_PUBLISHED,
+        isEmpty: false,
+        parent: pageIdDelete4._id,
+        descendantCount: 0,
+      },
+    ]);
 
     /**
      * Delete completely
      */
+    const pageIdDeleteComp1 = new mongoose.Types.ObjectId();
+    const pageIdDeleteComp2 = new mongoose.Types.ObjectId();
+    await Page.insertMany([
+      {
+        path: '/npdc1_awl',
+        grant: Page.GRANT_RESTRICTED,
+        status: Page.STATUS_PUBLISHED,
+        isEmpty: false,
+      },
+      {
+        path: '/npdc2_ug',
+        grant: Page.GRANT_USER_GROUP,
+        grantedGroup: groupIdA,
+        status: Page.STATUS_PUBLISHED,
+        isEmpty: false,
+        parent: rootPage._id,
+      },
+      {
+        _id: pageIdDeleteComp1,
+        path: '/npdc3_ug',
+        grant: Page.GRANT_USER_GROUP,
+        grantedGroup: groupIdA,
+        status: Page.STATUS_PUBLISHED,
+        isEmpty: false,
+        parent: rootPage._id,
+      },
+      {
+        _id: pageIdDeleteComp2,
+        path: '/npdc3_ug/npdc4_ug',
+        grant: Page.GRANT_USER_GROUP,
+        grantedGroup: groupIdB,
+        status: Page.STATUS_PUBLISHED,
+        isEmpty: false,
+        parent: pageIdDeleteComp1,
+      },
+      {
+        path: '/npdc3_ug/npdc4_ug/npdc5_ug',
+        grant: Page.GRANT_USER_GROUP,
+        grantedGroup: groupIdC,
+        status: Page.STATUS_PUBLISHED,
+        isEmpty: false,
+        parent: pageIdDeleteComp2,
+      },
+      {
+        path: '/npdc3_ug/npdc4_ug',
+        grant: Page.GRANT_RESTRICTED,
+        status: Page.STATUS_PUBLISHED,
+        isEmpty: false,
+      },
+    ]);
 
     /**
      * Revert
@@ -310,25 +619,409 @@ describe('PageService page operations with non-public pages', () => {
   });
 
   describe('Rename', () => {
-    test('dummy test to avoid test failure', async() => {
-      // write test code
-      expect(true).toBe(true);
+    const renamePage = async(page, newPagePath, user, options) => {
+      // mock return value
+      const mockedRenameSubOperation = jest.spyOn(crowi.pageService, 'renameSubOperation').mockReturnValue(null);
+      const mockedCreateAndSendNotifications = jest.spyOn(crowi.pageService, 'createAndSendNotifications').mockReturnValue(null);
+      const renamedPage = await crowi.pageService.renamePage(page, newPagePath, user, options);
+
+      // retrieve the arguments passed when calling method renameSubOperation inside renamePage method
+      const argsForRenameSubOperation = mockedRenameSubOperation.mock.calls[0];
+
+      // restores the original implementation
+      mockedRenameSubOperation.mockRestore();
+      mockedCreateAndSendNotifications.mockRestore();
+
+      // rename descendants
+      if (page.grant !== Page.GRANT_RESTRICTED) {
+        await crowi.pageService.renameSubOperation(...argsForRenameSubOperation);
+      }
+
+      return renamedPage;
+    };
+
+    test('Should rename/move with descendants with grant normalized pages', async() => {
+      const _pathD = '/np_rename1_destination';
+      const _path2 = '/np_rename2';
+      const _path3 = '/np_rename2/np_rename3';
+      const _propertiesD = { grant: Page.GRANT_PUBLIC };
+      const _properties2 = { grant: Page.GRANT_USER_GROUP, grantedGroup: groupIdB };
+      const _properties3 = { grant: Page.GRANT_USER_GROUP, grantedGroup: groupIdC };
+      const _pageD = await Page.findOne({ path: _pathD, ..._propertiesD });
+      const _page2 = await Page.findOne({ path: _path2, ..._properties2 });
+      const _page3 = await Page.findOne({ path: _path3, ..._properties3, parent: _page2._id });
+      expect(_pageD).toBeTruthy();
+      expect(_page2).toBeTruthy();
+      expect(_page3).toBeTruthy();
+
+      const newPathForPage2 = '/np_rename1_destination/np_rename2';
+      const newPathForPage3 = '/np_rename1_destination/np_rename2/np_rename3';
+      await renamePage(_page2, newPathForPage2, npDummyUser2, {});
+
+      const pageD = await Page.findOne({ path: _pathD, ..._propertiesD });
+      const page2 = await Page.findOne({ path: _path2, ..._properties2 }); // not exist
+      const page3 = await Page.findOne({ path: _path3, ..._properties3, parent: _page2._id }); // not exist
+      const page2Renamed = await Page.findOne({ path: newPathForPage2 }); // renamed
+      const page3Renamed = await Page.findOne({ path: newPathForPage3 }); // renamed
+      expect(pageD).toBeTruthy();
+      expect(page2).toBeNull();
+      expect(page3).toBeNull();
+      expect(page2Renamed).toBeTruthy();
+      expect(page3Renamed).toBeTruthy();
+      expect(page2Renamed.parent).toStrictEqual(_pageD._id);
+      expect(page3Renamed.parent).toStrictEqual(page2Renamed._id);
+      expect(page2Renamed.grantedGroup).toStrictEqual(_page2.grantedGroup);
+      expect(page3Renamed.grantedGroup).toStrictEqual(_page3.grantedGroup);
+      expect(xssSpy).toHaveBeenCalled();
+    });
+    test('Should throw with NOT grant normalized pages', async() => {
+      const _pathD = '/np_rename4_destination';
+      const _path2 = '/np_rename5';
+      const _path3 = '/np_rename5/np_rename6';
+      const _propertiesD = { grant: Page.GRANT_USER_GROUP, grantedGroup: groupIdIsolate };
+      const _properties2 = { grant: Page.GRANT_USER_GROUP, grantedGroup: groupIdB };
+      const _properties3 = { grant: Page.GRANT_USER_GROUP, grantedGroup: groupIdB };
+      const _pageD = await Page.findOne({ path: _pathD, ..._propertiesD });// isolate
+      const _page2 = await Page.findOne({ path: _path2, ..._properties2 });// groupIdB
+      const _page3 = await Page.findOne({ path: _path3, ..._properties3, parent: _page2 });// groupIdB
+      expect(_pageD).toBeTruthy();
+      expect(_page2).toBeTruthy();
+      expect(_page3).toBeTruthy();
+
+      const newPathForPage2 = '/np_rename4_destination/np_rename5';
+      const newPathForPage3 = '/np_rename4_destination/np_rename5/np_rename6';
+      let isThrown = false;
+      try {
+        await renamePage(_page2, newPathForPage2, dummyUser1, {});
+      }
+      catch (err) {
+        isThrown = true;
+      }
+      expect(isThrown).toBe(true);
+      const page2 = await Page.findOne({ path: _path2 }); // not renamed thus exist
+      const page3 = await Page.findOne({ path: _path3 }); // not renamed thus exist
+      const page2Renamed = await Page.findOne({ path: newPathForPage2 }); // not exist
+      const page3Renamed = await Page.findOne({ path: newPathForPage3 }); // not exist
+      expect(page2).toBeTruthy();
+      expect(page3).toBeTruthy();
+      expect(page2Renamed).toBeNull();
+      expect(page3Renamed).toBeNull();
+    });
+    test('Should rename/move multiple pages: child page with GRANT_RESTRICTED should NOT be renamed.', async() => {
+      const _pathD = '/np_rename7_destination';
+      const _path2 = '/np_rename8';
+      const _path3 = '/np_rename8/np_rename9';
+      const _pageD = await Page.findOne({ path: _pathD, grant: Page.GRANT_USER_GROUP, grantedGroup: groupIdIsolate });
+      const _page2 = await Page.findOne({ path: _path2, grant: Page.GRANT_RESTRICTED });
+      const _page3 = await Page.findOne({ path: _path3, grant: Page.GRANT_RESTRICTED });
+      expect(_pageD).toBeTruthy();
+      expect(_page2).toBeTruthy();
+      expect(_page3).toBeTruthy();
+
+      const newPathForPage2 = '/np_rename7_destination/np_rename8';
+      const newpathForPage3 = '/np_rename7_destination/np_rename8/np_rename9';
+      await renamePage(_page2, newPathForPage2, npDummyUser1, { isRecursively: true });
+
+      const page2 = await Page.findOne({ path: _path2 }); // not exist
+      const page3 = await Page.findOne({ path: _path3 }); // not renamed thus exist
+      const page2Renamed = await Page.findOne({ path: newPathForPage2 }); // exist
+      const page3Renamed = await Page.findOne({ path: newpathForPage3 }); // not exist
+      expect(page2).toBeNull();
+      expect(page3).toBeTruthy();
+      expect(page2Renamed).toBeTruthy();
+      expect(page3Renamed).toBeNull();
+      expect(page2Renamed.parent).toBeNull();
+      expect(xssSpy).toHaveBeenCalled();
     });
   });
   describe('Duplicate', () => {
-    // test('', async() => {
-    //   // write test code
-    // });
+
+    const duplicate = async(page, newPagePath, user, isRecursively) => {
+      // mock return value
+      const mockedDuplicateRecursivelyMainOperation = jest.spyOn(crowi.pageService, 'duplicateRecursivelyMainOperation').mockReturnValue(null);
+      const mockedCreateAndSendNotifications = jest.spyOn(crowi.pageService, 'createAndSendNotifications').mockReturnValue(null);
+      const duplicatedPage = await crowi.pageService.duplicate(page, newPagePath, user, isRecursively);
+
+      // retrieve the arguments passed when calling method duplicateRecursivelyMainOperation inside duplicate method
+      const argsForDuplicateRecursivelyMainOperation = mockedDuplicateRecursivelyMainOperation.mock.calls[0];
+
+      // restores the original implementation
+      mockedDuplicateRecursivelyMainOperation.mockRestore();
+      mockedCreateAndSendNotifications.mockRestore();
+
+      // duplicate descendants
+      if (page.grant !== Page.GRANT_RESTRICTED && isRecursively) {
+        await crowi.pageService.duplicateRecursivelyMainOperation(...argsForDuplicateRecursivelyMainOperation);
+      }
+
+      return duplicatedPage;
+    };
+    test('Duplicate single page with GRANT_RESTRICTED', async() => {
+      const _page = await Page.findOne({ path: '/np_duplicate1', grant: Page.GRANT_RESTRICTED }).populate({ path: 'revision', model: 'Revision' });
+      const _revision = _page.revision;
+      expect(_page).toBeTruthy();
+      expect(_revision).toBeTruthy();
+
+      const newPagePath = '/dup_np_duplicate1';
+      await duplicate(_page, newPagePath, npDummyUser1, false);
+
+      const duplicatedPage = await Page.findOne({ path: newPagePath });
+      const duplicatedRevision = await Revision.findOne({ pageId: duplicatedPage._id });
+      expect(xssSpy).toHaveBeenCalled();
+      expect(duplicatedPage).toBeTruthy();
+      expect(duplicatedPage._id).not.toStrictEqual(_page._id);
+      expect(duplicatedPage.grant).toBe(_page.grant);
+      expect(duplicatedPage.parent).toBeNull();
+      expect(duplicatedPage.parent).toStrictEqual(_page.parent);
+      expect(duplicatedPage.revision).toStrictEqual(duplicatedRevision._id);
+      expect(duplicatedRevision.body).toBe(_revision.body);
+    });
+
+    test('Should duplicate multiple pages with GRANT_USER_GROUP', async() => {
+      const _path1 = '/np_duplicate2';
+      const _path2 = '/np_duplicate2/np_duplicate3';
+      const _page1 = await Page.findOne({ path: _path1, parent: rootPage._id, grantedGroup: groupIdA })
+        .populate({ path: 'revision', model: 'Revision', grantedPage: groupIdA._id });
+      const _page2 = await Page.findOne({ path: _path2, parent: _page1._id, grantedGroup: groupIdB })
+        .populate({ path: 'revision', model: 'Revision', grantedPage: groupIdB._id });
+      const _revision1 = _page1.revision;
+      const _revision2 = _page2.revision;
+      expect(_page1).toBeTruthy();
+      expect(_page2).toBeTruthy();
+      expect(_revision1).toBeTruthy();
+      expect(_revision2).toBeTruthy();
+
+      const newPagePath = '/dup_np_duplicate2';
+      await duplicate(_page1, newPagePath, npDummyUser2, true);
+
+      const duplicatedPage1 = await Page.findOne({ path: newPagePath }).populate({ path: 'revision', model: 'Revision' });
+      const duplicatedPage2 = await Page.findOne({ path: '/dup_np_duplicate2/np_duplicate3' }).populate({ path: 'revision', model: 'Revision' });
+      const duplicatedRevision1 = duplicatedPage1.revision;
+      const duplicatedRevision2 = duplicatedPage2.revision;
+      expect(xssSpy).toHaveBeenCalled();
+      expect(duplicatedPage1).toBeTruthy();
+      expect(duplicatedPage2).toBeTruthy();
+      expect(duplicatedRevision1).toBeTruthy();
+      expect(duplicatedRevision2).toBeTruthy();
+      expect(duplicatedPage1.grantedGroup).toStrictEqual(groupIdA._id);
+      expect(duplicatedPage2.grantedGroup).toStrictEqual(groupIdB._id);
+      expect(duplicatedPage1.parent).toStrictEqual(_page1.parent);
+      expect(duplicatedPage2.parent).toStrictEqual(duplicatedPage1._id);
+      expect(duplicatedRevision1.body).toBe(_revision1.body);
+      expect(duplicatedRevision2.body).toBe(_revision2.body);
+      expect(duplicatedRevision1.pageId).toStrictEqual(duplicatedPage1._id);
+      expect(duplicatedRevision2.pageId).toStrictEqual(duplicatedPage2._id);
+    });
+    test('Should duplicate multiple pages. Page with GRANT_RESTRICTED should NOT be duplicated', async() => {
+      const _path1 = '/np_duplicate4';
+      const _path2 = '/np_duplicate4/np_duplicate5';
+      const _path3 = '/np_duplicate4/np_duplicate6';
+      const _page1 = await Page.findOne({ path: _path1, parent: rootPage._id, grant: Page.GRANT_PUBLIC })
+        .populate({ path: 'revision', model: 'Revision' });
+      const _page2 = await Page.findOne({ path: _path2, grant: Page.GRANT_RESTRICTED }).populate({ path: 'revision', model: 'Revision' });
+      const _page3 = await Page.findOne({ path: _path3, grant: Page.GRANT_PUBLIC }).populate({ path: 'revision', model: 'Revision' });
+      const baseRevision1 = _page1.revision;
+      const baseRevision2 = _page2.revision;
+      const baseRevision3 = _page3.revision;
+      expect(_page1).toBeTruthy();
+      expect(_page2).toBeTruthy();
+      expect(_page3).toBeTruthy();
+      expect(baseRevision1).toBeTruthy();
+      expect(baseRevision2).toBeTruthy();
+
+      const newPagePath = '/dup_np_duplicate4';
+      await duplicate(_page1, newPagePath, npDummyUser1, true);
+
+      const duplicatedPage1 = await Page.findOne({ path: newPagePath }).populate({ path: 'revision', model: 'Revision' });
+      const duplicatedPage2 = await Page.findOne({ path: '/dup_np_duplicate4/np_duplicate5' }).populate({ path: 'revision', model: 'Revision' });
+      const duplicatedPage3 = await Page.findOne({ path: '/dup_np_duplicate4/np_duplicate6' }).populate({ path: 'revision', model: 'Revision' });
+      const duplicatedRevision1 = duplicatedPage1.revision;
+      const duplicatedRevision3 = duplicatedPage3.revision;
+      expect(xssSpy).toHaveBeenCalled();
+      expect(duplicatedPage1).toBeTruthy();
+      expect(duplicatedPage2).toBeNull();
+      expect(duplicatedPage3).toBeTruthy();
+      expect(duplicatedRevision1).toBeTruthy();
+      expect(duplicatedRevision3).toBeTruthy();
+      expect(duplicatedPage1.grant).toStrictEqual(Page.GRANT_PUBLIC);
+      expect(duplicatedPage3.grant).toStrictEqual(Page.GRANT_PUBLIC);
+      expect(duplicatedPage1.parent).toStrictEqual(_page1.parent);
+      expect(duplicatedPage3.parent).toStrictEqual(duplicatedPage1._id);
+      expect(duplicatedRevision1.body).toBe(baseRevision1.body);
+      expect(duplicatedRevision3.body).toBe(baseRevision3.body);
+      expect(duplicatedRevision1.pageId).toStrictEqual(duplicatedPage1._id);
+      expect(duplicatedRevision3.pageId).toStrictEqual(duplicatedPage3._id);
+    });
+
   });
   describe('Delete', () => {
-    // test('', async() => {
-    //   // write test code
-    // });
+
+    const deletePage = async(page, user, options, isRecursively) => {
+      const mockedDeleteRecursivelyMainOperation = jest.spyOn(crowi.pageService, 'deleteRecursivelyMainOperation').mockReturnValue(null);
+      const mockedCreateAndSendNotifications = jest.spyOn(crowi.pageService, 'createAndSendNotifications').mockReturnValue(null);
+
+      const deletedPage = await crowi.pageService.deletePage(page, user, options, isRecursively);
+
+      const argsForDeleteRecursivelyMainOperation = mockedDeleteRecursivelyMainOperation.mock.calls[0];
+
+      mockedDeleteRecursivelyMainOperation.mockRestore();
+      mockedCreateAndSendNotifications.mockRestore();
+
+      if (isRecursively) {
+        await crowi.pageService.deleteRecursivelyMainOperation(...argsForDeleteRecursivelyMainOperation);
+      }
+
+      return deletedPage;
+    };
+    describe('Delete single page with grant RESTRICTED', () => {
+      test('should be able to delete', async() => {
+        const _pathT = '/npdel1_awl';
+        const _pageT = await Page.findOne({ path: _pathT, grant: Page.GRANT_RESTRICTED });
+        expect(_pageT).toBeTruthy();
+
+        const isRecursively = false;
+        await deletePage(_pageT, dummyUser1, {}, isRecursively);
+
+        const pageT = await Page.findOne({ path: `/trash${_pathT}` });
+        const pageN = await Page.findOne({ path: _pathT }); // should not exist
+        expect(pageT).toBeTruthy();
+        expect(pageN).toBeNull();
+        expect(pageT.grant).toBe(Page.GRANT_RESTRICTED);
+        expect(pageT.status).toBe(Page.STATUS_DELETED);
+      });
+    });
+    describe('Delete single page with grant USER_GROUP', () => {
+      test('should be able to delete', async() => {
+        const _path = '/npdel2_ug';
+        const _page1 = await Page.findOne({ path: _path, grantedGroup: groupIdA });
+        expect(_page1).toBeTruthy();
+
+        const isRecursively = false;
+        await deletePage(_page1, npDummyUser1, {}, isRecursively);
+
+        const pageN = await Page.findOne({ path: _path, grantedGroup: groupIdA });
+        const page1 = await Page.findOne({ path: `/trash${_path}`, grantedGroup: groupIdA });
+        expect(pageN).toBeNull();
+        expect(page1).toBeTruthy();
+        expect(page1.status).toBe(Page.STATUS_DELETED);
+        expect(page1.descendantCount).toBe(0);
+        expect(page1.parent).toBeNull();
+      });
+    });
+    describe('Delete multiple pages with grant USER_GROUP', () => {
+      test('should be able to delete all descendants except page with GRANT_RESTRICTED', async() => {
+        const _pathT = '/npdel3_top';
+        const _path1 = '/npdel3_top/npdel4_ug';
+        const _path2 = '/npdel3_top/npdel4_ug/npdel5_ug';
+        const _pageT = await Page.findOne({ path: _pathT, grant: Page.GRANT_USER_GROUP, grantedGroup: groupIdA }); // A
+        const _page1 = await Page.findOne({ path: _path1, grant: Page.GRANT_USER_GROUP, grantedGroup: groupIdB }); // B
+        const _page2 = await Page.findOne({ path: _path2, grant: Page.GRANT_USER_GROUP, grantedGroup: groupIdC }); // C
+        const _pageR = await Page.findOne({ path: _path1, grant: Page.GRANT_RESTRICTED }); // Restricted
+        expect(_pageT).toBeTruthy();
+        expect(_page1).toBeTruthy();
+        expect(_page2).toBeTruthy();
+        expect(_pageR).toBeTruthy();
+
+        const isRecursively = true;
+        await deletePage(_pageT, npDummyUser1, {}, isRecursively);
+
+        const pageTNotExist = await Page.findOne({ path: _pathT, grant: Page.GRANT_USER_GROUP, grantedGroup: groupIdA }); // A should not exist
+        const page1NotExist = await Page.findOne({ path: _path1, grant: Page.GRANT_USER_GROUP, grantedGroup: groupIdB }); // B should not exist
+        const page2NotExist = await Page.findOne({ path: _path2, grant: Page.GRANT_USER_GROUP, grantedGroup: groupIdC }); // C should not exist
+        const pageT = await Page.findOne({ path: `/trash${_pathT}`, grant: Page.GRANT_USER_GROUP, grantedGroup: groupIdA }); // A
+        const page1 = await Page.findOne({ path: `/trash${_path1}`, grant: Page.GRANT_USER_GROUP, grantedGroup: groupIdB }); // B
+        const page2 = await Page.findOne({ path: `/trash${_path2}`, grant: Page.GRANT_USER_GROUP, grantedGroup: groupIdC }); // C
+        const pageR = await Page.findOne({ path: _path1, grant: Page.GRANT_RESTRICTED }); // Restricted
+        expect(page1NotExist).toBeNull();
+        expect(pageTNotExist).toBeNull();
+        expect(page2NotExist).toBeNull();
+        expect(pageT).toBeTruthy();
+        expect(page1).toBeTruthy();
+        expect(page2).toBeTruthy();
+        expect(pageR).toBeTruthy();
+        expect(pageT.status).toBe(Page.STATUS_DELETED);
+        expect(pageT.status).toBe(Page.STATUS_DELETED);
+        expect(page1.status).toBe(Page.STATUS_DELETED);
+        expect(page1.descendantCount).toBe(0);
+        expect(page2.descendantCount).toBe(0);
+        expect(page2.descendantCount).toBe(0);
+        expect(pageT.parent).toBeNull();
+        expect(page1.parent).toBeNull();
+        expect(page2.parent).toBeNull();
+      });
+    });
+
   });
   describe('Delete completely', () => {
-    // test('', async() => {
-    //   // write test code
-    // });
+    const deleteCompletely = async(page, user, options = {}, isRecursively = false, preventEmitting = false) => {
+      const mockedDeleteCompletelyRecursivelyMainOperation = jest.spyOn(crowi.pageService, 'deleteCompletelyRecursivelyMainOperation').mockReturnValue(null);
+      const mockedCreateAndSendNotifications = jest.spyOn(crowi.pageService, 'createAndSendNotifications').mockReturnValue(null);
+
+      await crowi.pageService.deleteCompletely(page, user, options, isRecursively, preventEmitting);
+
+      const argsForDeleteCompletelyRecursivelyMainOperation = mockedDeleteCompletelyRecursivelyMainOperation.mock.calls[0];
+
+      mockedDeleteCompletelyRecursivelyMainOperation.mockRestore();
+      mockedCreateAndSendNotifications.mockRestore();
+
+      if (isRecursively) {
+        await crowi.pageService.deleteCompletelyRecursivelyMainOperation(...argsForDeleteCompletelyRecursivelyMainOperation);
+      }
+
+      return;
+    };
+
+    describe('Delete single page with grant RESTRICTED', () => {
+      test('should be able to delete completely', async() => {
+        const _path = '/npdc1_awl';
+        const _page = await Page.findOne({ path: _path, grant: Page.GRANT_RESTRICTED });
+        expect(_page).toBeTruthy();
+
+        await deleteCompletely(_page, dummyUser1, {}, false);
+
+        const page = await Page.findOne({ path: _path, grant: Page.GRANT_RESTRICTED });
+        expect(page).toBeNull();
+      });
+    });
+    describe('Delete single page with grant USER_GROUP', () => {
+      test('should be able to delete completely', async() => {
+        const _path = '/npdc2_ug';
+        const _page = await Page.findOne({ path: _path, grant: Page.GRANT_USER_GROUP, grantedGroup: groupIdA });
+        expect(_page).toBeTruthy();
+
+        await deleteCompletely(_page, npDummyUser1, {}, false);
+
+        const page = await Page.findOne({ path: _path, grant: Page.GRANT_USER_GROUP, grantedGroup: groupIdA });
+        expect(page).toBeNull();
+      });
+    });
+    describe('Delete multiple pages with grant USER_GROUP', () => {
+      test('should be able to delete all descendants completely except page with GRANT_RESTRICTED', async() => {
+        const _path1 = '/npdc3_ug';
+        const _path2 = '/npdc3_ug/npdc4_ug';
+        const _path3 = '/npdc3_ug/npdc4_ug/npdc5_ug';
+        const _page1 = await Page.findOne({ path: _path1, grant: Page.GRANT_USER_GROUP, grantedGroup: groupIdA });
+        const _page2 = await Page.findOne({ path: _path2, grant: Page.GRANT_USER_GROUP, grantedGroup: groupIdB });
+        const _page3 = await Page.findOne({ path: _path3, grant: Page.GRANT_USER_GROUP, grantedGroup: groupIdC });
+        const _page4 = await Page.findOne({ path: _path2, grant: Page.GRANT_RESTRICTED });
+        expect(_page1).toBeTruthy();
+        expect(_page2).toBeTruthy();
+        expect(_page3).toBeTruthy();
+        expect(_page4).toBeTruthy();
+
+        await deleteCompletely(_page1, npDummyUser1, {}, true);
+
+        const page1 = await Page.findOne({ path: _path1, grant: Page.GRANT_USER_GROUP, grantedGroup: groupIdA });
+        const page2 = await Page.findOne({ path: _path2, grant: Page.GRANT_USER_GROUP, grantedGroup: groupIdB });
+        const page3 = await Page.findOne({ path: _path3, grant: Page.GRANT_USER_GROUP, grantedGroup: groupIdC });
+        const page4 = await Page.findOne({ path: _path2, grant: Page.GRANT_RESTRICTED });
+
+        expect(page1).toBeNull();
+        expect(page2).toBeNull();
+        expect(page3).toBeNull();
+        expect(page4).toBeTruthy();
+      });
+    });
   });
   describe('revert', () => {
     const revertDeletedPage = async(page, user, options = {}, isRecursively = false) => {
@@ -352,14 +1045,18 @@ describe('PageService page operations with non-public pages', () => {
       const revision = await Revision.findOne({ pageId: trashedPage._id });
       const tag = await Tag.findOne({ name: 'np_revertTag1' });
       const deletedPageTagRelation = await PageTagRelation.findOne({ relatedPage: trashedPage._id, relatedTag: tag._id, isPageTrashed: true });
-      expectAllToBeTruthy([trashedPage, revision, tag, deletedPageTagRelation]);
+      expect(trashedPage).toBeTruthy();
+      expect(revision).toBeTruthy();
+      expect(tag).toBeTruthy();
+      expect(deletedPageTagRelation).toBeTruthy();
 
       await revertDeletedPage(trashedPage, dummyUser1, {}, false);
+
       const revertedPage = await Page.findOne({ path: '/np_revert1' });
       const deltedPageBeforeRevert = await Page.findOne({ path: '/trash/np_revert1' });
       const pageTagRelation = await PageTagRelation.findOne({ relatedPage: revertedPage._id, relatedTag: tag._id });
-      expectAllToBeTruthy([revertedPage, pageTagRelation]);
-
+      expect(revertedPage).toBeTruthy();
+      expect(pageTagRelation).toBeTruthy();
       expect(deltedPageBeforeRevert).toBeNull();
 
       // page with GRANT_RESTRICTED does not have parent
@@ -375,13 +1072,18 @@ describe('PageService page operations with non-public pages', () => {
       const revision = await Revision.findOne({ pageId: trashedPage._id });
       const tag = await Tag.findOne({ name: 'np_revertTag2' });
       const deletedPageTagRelation = await PageTagRelation.findOne({ relatedPage: trashedPage._id, relatedTag: tag._id, isPageTrashed: true });
-      expectAllToBeTruthy([trashedPage, revision, tag, deletedPageTagRelation]);
+      expect(trashedPage).toBeTruthy();
+      expect(revision).toBeTruthy();
+      expect(tag).toBeTruthy();
+      expect(deletedPageTagRelation).toBeTruthy();
 
       await revertDeletedPage(trashedPage, user1, {}, false);
+
       const revertedPage = await Page.findOne({ path: '/np_revert2' });
       const trashedPageBR = await Page.findOne({ path: beforeRevertPath });
       const pageTagRelation = await PageTagRelation.findOne({ relatedPage: revertedPage._id, relatedTag: tag._id });
-      expectAllToBeTruthy([revertedPage, pageTagRelation]);
+      expect(revertedPage).toBeTruthy();
+      expect(pageTagRelation).toBeTruthy();
       expect(trashedPageBR).toBeNull();
 
       expect(revertedPage.parent).toStrictEqual(rootPage._id);
@@ -398,9 +1100,13 @@ describe('PageService page operations with non-public pages', () => {
       const trashedPage2 = await Page.findOne({ path: beforeRevertPath2, status: Page.STATUS_DELETED, grant: Page.GRANT_RESTRICTED });
       const revision1 = await Revision.findOne({ pageId: trashedPage1._id });
       const revision2 = await Revision.findOne({ pageId: trashedPage2._id });
-      expectAllToBeTruthy([trashedPage1, trashedPage2, revision1, revision2]);
+      expect(trashedPage1).toBeTruthy();
+      expect(trashedPage2).toBeTruthy();
+      expect(revision1).toBeTruthy();
+      expect(revision2).toBeTruthy();
 
       await revertDeletedPage(trashedPage1, npDummyUser2, {}, true);
+
       const revertedPage = await Page.findOne({ path: '/np_revert3' });
       const middlePage = await Page.findOne({ path: '/np_revert3/middle' });
       const notRestrictedPage = await Page.findOne({ path: '/np_revert3/middle/np_revert4' });
@@ -409,11 +1115,14 @@ describe('PageService page operations with non-public pages', () => {
       const trashedPage2AR = await Page.findOne({ path: beforeRevertPath2 });
       const revision1AR = await Revision.findOne({ pageId: revertedPage._id });
       const revision2AR = await Revision.findOne({ pageId: trashedPage2AR._id });
-      expectAllToBeTruthy([revertedPage, trashedPage2AR, revision1AR, revision2AR]);
+
+      expect(revertedPage).toBeTruthy();
+      expect(trashedPage2AR).toBeTruthy();
+      expect(revision1AR).toBeTruthy();
+      expect(revision2AR).toBeTruthy();
       expect(trashedPage1AR).toBeNull();
       expect(notRestrictedPage).toBeNull();
       expect(middlePage).toBeNull();
-
       expect(revertedPage.parent).toStrictEqual(rootPage._id);
       expect(revertedPage.status).toBe(Page.STATUS_PUBLISHED);
       expect(revertedPage.grant).toBe(Page.GRANT_PUBLIC);
@@ -423,12 +1132,16 @@ describe('PageService page operations with non-public pages', () => {
       const beforeRevertPath1 = '/trash/np_revert5';
       const beforeRevertPath2 = '/trash/np_revert5/middle/np_revert6';
       const beforeRevertPath3 = '/trash/np_revert5/middle';
-      const trashedPage1 = await Page.findOne({ path: beforeRevertPath1, status: Page.STATUS_DELETED, grant: Page.GRANT_USER_GROUP });
-      const trashedPage2 = await Page.findOne({ path: beforeRevertPath2, status: Page.STATUS_DELETED, grant: Page.GRANT_USER_GROUP });
+      const trashedPage1 = await Page.findOne({ path: beforeRevertPath1, status: Page.STATUS_DELETED, grantedGroup: groupIdA });
+      const trashedPage2 = await Page.findOne({ path: beforeRevertPath2, status: Page.STATUS_DELETED, grantedGroup: groupIdB });
       const nonExistantPage3 = await Page.findOne({ path: beforeRevertPath3 }); // not exist
       const revision1 = await Revision.findOne({ pageId: trashedPage1._id });
       const revision2 = await Revision.findOne({ pageId: trashedPage2._id });
-      expectAllToBeTruthy([trashedPage1, trashedPage2, revision1, revision2, user]);
+      expect(trashedPage1).toBeTruthy();
+      expect(trashedPage2).toBeTruthy();
+      expect(revision1).toBeTruthy();
+      expect(revision2).toBeTruthy();
+      expect(user).toBeTruthy();
       expect(nonExistantPage3).toBeNull();
 
       await revertDeletedPage(trashedPage1, user, {}, true);
@@ -439,22 +1152,21 @@ describe('PageService page operations with non-public pages', () => {
       // // AR => After Revert
       const trashedPage1AR = await Page.findOne({ path: beforeRevertPath1 });
       const trashedPage2AR = await Page.findOne({ path: beforeRevertPath2 });
-      expectAllToBeTruthy([revertedPage1, newlyCreatedPage, revertedPage2]);
+      expect(revertedPage1).toBeTruthy();
+      expect(newlyCreatedPage).toBeTruthy();
+      expect(revertedPage2).toBeTruthy();
       expect(trashedPage1AR).toBeNull();
       expect(trashedPage2AR).toBeNull();
 
       expect(newlyCreatedPage.isEmpty).toBe(true);
-
       expect(revertedPage1.parent).toStrictEqual(rootPage._id);
       expect(revertedPage2.parent).toStrictEqual(newlyCreatedPage._id);
       expect(newlyCreatedPage.parent).toStrictEqual(revertedPage1._id);
-
       expect(revertedPage1.status).toBe(Page.STATUS_PUBLISHED);
       expect(revertedPage2.status).toBe(Page.STATUS_PUBLISHED);
       expect(newlyCreatedPage.status).toBe(Page.STATUS_PUBLISHED);
-
-      expect(revertedPage1.grant).toBe(Page.GRANT_USER_GROUP);
-      expect(revertedPage1.grant).toBe(Page.GRANT_USER_GROUP);
+      expect(revertedPage1.grantedGroup).toStrictEqual(groupIdA);
+      expect(revertedPage2.grantedGroup).toStrictEqual(groupIdB);
       expect(newlyCreatedPage.grant).toBe(Page.GRANT_PUBLIC);
 
     });

+ 123 - 62
packages/app/test/integration/service/v5.public-page.test.ts

@@ -24,15 +24,6 @@ describe('PageService page operations with only public pages', () => {
 
   let rootPage;
 
-
-  // pass unless the data is one of [false, 0, '', null, undefined, NaN]
-  const expectAllToBeTruthy = (dataList) => {
-    dataList.forEach((data, i) => {
-      if (data == null) { console.log(`index: ${i}`) }
-      expect(data).toBeTruthy();
-    });
-  };
-
   beforeAll(async() => {
     crowi = await getInstance();
     await crowi.configManager.updateConfigsInTheSameNamespace('crowi', { 'app:isV5Compatible': true });
@@ -918,7 +909,7 @@ describe('PageService page operations with only public pages', () => {
     };
 
     test('Should NOT rename top page', async() => {
-      expectAllToBeTruthy([rootPage]);
+      expect(rootPage).toBeTruthy();
       let isThrown = false;
       try {
         await crowi.pageService.renamePage(rootPage, '/new_root', dummyUser1, {});
@@ -933,7 +924,8 @@ describe('PageService page operations with only public pages', () => {
     test('Should rename/move to under non-empty page', async() => {
       const parentPage = await Page.findOne({ path: '/v5_ParentForRename1' });
       const childPage = await Page.findOne({ path: '/v5_ChildForRename1' });
-      expectAllToBeTruthy([childPage, parentPage]);
+      expect(childPage).toBeTruthy();
+      expect(parentPage).toBeTruthy();
 
       const newPath = '/v5_ParentForRename1/renamedChildForRename1';
       const renamedPage = await renamePage(childPage, newPath, dummyUser1, {});
@@ -949,7 +941,8 @@ describe('PageService page operations with only public pages', () => {
     test('Should rename/move to under empty page', async() => {
       const parentPage = await Page.findOne({ path: '/v5_ParentForRename2' });
       const childPage = await Page.findOne({ path: '/v5_ChildForRename2' });
-      expectAllToBeTruthy([childPage, parentPage]);
+      expect(childPage).toBeTruthy();
+      expect(parentPage).toBeTruthy();
       expect(parentPage.isEmpty).toBe(true);
 
       const newPath = '/v5_ParentForRename2/renamedChildForRename2';
@@ -966,7 +959,8 @@ describe('PageService page operations with only public pages', () => {
     test('Should rename/move with option updateMetadata: true', async() => {
       const parentPage = await Page.findOne({ path: '/v5_ParentForRename3' });
       const childPage = await Page.findOne({ path: '/v5_ChildForRename3' });
-      expectAllToBeTruthy([childPage, parentPage]);
+      expect(childPage).toBeTruthy();
+      expect(parentPage).toBeTruthy();
       expect(childPage.lastUpdateUser).toStrictEqual(dummyUser1._id);
 
       const newPath = '/v5_ParentForRename3/renamedChildForRename3';
@@ -983,7 +977,8 @@ describe('PageService page operations with only public pages', () => {
     test('Should move with option createRedirectPage: true', async() => {
       const parentPage = await Page.findOne({ path: '/v5_ParentForRename4' });
       const childPage = await Page.findOne({ path: '/v5_ChildForRename4' });
-      expectAllToBeTruthy([parentPage, childPage]);
+      expect(parentPage).toBeTruthy();
+      expect(childPage).toBeTruthy();
 
       const oldPath = childPage.path;
       const newPath = '/v5_ParentForRename4/renamedChildForRename4';
@@ -1001,7 +996,9 @@ describe('PageService page operations with only public pages', () => {
       const childPage = await Page.findOne({ path: '/v5_ChildForRename5' });
       const grandchild = await Page.findOne({ parent: childPage._id, path: '/v5_ChildForRename5/v5_GrandchildForRename5' });
 
-      expectAllToBeTruthy([parentPage, childPage, grandchild]);
+      expect(parentPage).toBeTruthy();
+      expect(childPage).toBeTruthy();
+      expect(grandchild).toBeTruthy();
 
       const newPath = '/v5_ParentForRename5/renamedChildForRename5';
       const renamedPage = await renamePage(childPage, newPath, dummyUser1, {});
@@ -1025,7 +1022,9 @@ describe('PageService page operations with only public pages', () => {
       const childPage = await Page.findOne({ path: '/v5_ChildForRename7', isEmpty: true });
       const grandchild = await Page.findOne({ parent: childPage._id, path: '/v5_ChildForRename7/v5_GrandchildForRename7' });
 
-      expectAllToBeTruthy([parentPage, childPage, grandchild]);
+      expect(parentPage).toBeTruthy();
+      expect(childPage).toBeTruthy();
+      expect(grandchild).toBeTruthy();
 
       const newPath = '/v5_ParentForRename7/renamedChildForRename7';
       const renamedPage = await renamePage(childPage, newPath, dummyUser1, {});
@@ -1043,7 +1042,7 @@ describe('PageService page operations with only public pages', () => {
     });
     test('Should NOT rename/move with existing path', async() => {
       const page = await Page.findOne({ path: '/v5_ParentForRename8' });
-      expectAllToBeTruthy([page]);
+      expect(page).toBeTruthy();
 
       const newPath = '/v5_ParentForRename9';
       let isThrown;
@@ -1062,7 +1061,8 @@ describe('PageService page operations with only public pages', () => {
       const page1 = await Page.findOne({ path: initialPathForPage1, isEmpty: false });
       const page2 = await Page.findOne({ path: initialPathForPage2, isEmpty: false, parent: page1._id });
 
-      expectAllToBeTruthy([page1, page2]);
+      expect(page1).toBeTruthy();
+      expect(page2).toBeTruthy();
 
       const newParentalPath = '/v5_pageForRename17/v5_pageForRename18';
       const newPath = newParentalPath + page1.path;
@@ -1073,7 +1073,10 @@ describe('PageService page operations with only public pages', () => {
       const newlyCreatedEmptyPage1 = await Page.findOne({ path: '/v5_pageForRename17' });
       const newlyCreatedEmptyPage2 = await Page.findOne({ path: '/v5_pageForRename17/v5_pageForRename18' });
 
-      expectAllToBeTruthy([renamedPage, renamedPageChild, newlyCreatedEmptyPage1, newlyCreatedEmptyPage2]);
+      expect(renamedPage).toBeTruthy();
+      expect(renamedPageChild).toBeTruthy();
+      expect(newlyCreatedEmptyPage1).toBeTruthy();
+      expect(newlyCreatedEmptyPage2).toBeTruthy();
 
       // check parent
       expect(newlyCreatedEmptyPage1.parent).toStrictEqual(rootPage._id);
@@ -1095,7 +1098,8 @@ describe('PageService page operations with only public pages', () => {
       const page1 = await Page.findOne({ path: initialPathForPage1, isEmpty: true });
       const page2 = await Page.findOne({ path: initialPathForPage2, isEmpty: false, parent: page1._id });
 
-      expectAllToBeTruthy([page1, page2]);
+      expect(page1).toBeTruthy();
+      expect(page2).toBeTruthy();
 
       const newParentalPath = '/v5_pageForRename19/v5_pageForRename20';
       const newPath = newParentalPath + page1.path;
@@ -1106,7 +1110,10 @@ describe('PageService page operations with only public pages', () => {
       const newlyCreatedEmptyPage1 = await Page.findOne({ path: '/v5_pageForRename19' });
       const newlyCreatedEmptyPage2 = await Page.findOne({ path: '/v5_pageForRename19/v5_pageForRename20' });
 
-      expectAllToBeTruthy([renamedPage, renamedPageChild, newlyCreatedEmptyPage1, newlyCreatedEmptyPage2]);
+      expect(renamedPage).toBeTruthy();
+      expect(renamedPageChild).toBeTruthy();
+      expect(newlyCreatedEmptyPage1).toBeTruthy();
+      expect(newlyCreatedEmptyPage2).toBeTruthy();
 
       // check parent
       expect(newlyCreatedEmptyPage1.parent).toStrictEqual(rootPage._id);
@@ -1130,7 +1137,9 @@ describe('PageService page operations with only public pages', () => {
       const page2 = await Page.findOne({ path: initialPathForPage2, isEmpty: true, parent: page1._id });
       const page3 = await Page.findOne({ path: initialPathForPage3, isEmpty: false, parent: page2._id });
 
-      expectAllToBeTruthy([page1, page2, page3]);
+      expect(page1).toBeTruthy();
+      expect(page2).toBeTruthy();
+      expect(page3).toBeTruthy();
 
       const newParentalPath = '/v5_pageForRename21/v5_pageForRename22/v5_pageForRename23';
       const newPath = newParentalPath + page1.path;
@@ -1145,7 +1154,12 @@ describe('PageService page operations with only public pages', () => {
       const newlyCreatedEmptyPage2 = await Page.findOne({ path: '/v5_pageForRename21/v5_pageForRename22' });
       const newlyCreatedEmptyPage3 = await Page.findOne({ path: '/v5_pageForRename21/v5_pageForRename22/v5_pageForRename23' });
 
-      expectAllToBeTruthy([renamedPage, renamedPageChild, renamedPageGrandchild, newlyCreatedEmptyPage1, newlyCreatedEmptyPage2, newlyCreatedEmptyPage3]);
+      expect(renamedPage).toBeTruthy();
+      expect(renamedPageChild).toBeTruthy();
+      expect(renamedPageGrandchild).toBeTruthy();
+      expect(newlyCreatedEmptyPage1).toBeTruthy();
+      expect(newlyCreatedEmptyPage2).toBeTruthy();
+      expect(newlyCreatedEmptyPage3).toBeTruthy();
 
       // check parent
       expect(newlyCreatedEmptyPage1.parent).toStrictEqual(rootPage._id);
@@ -1189,7 +1203,7 @@ describe('PageService page operations with only public pages', () => {
 
     test('Should duplicate single page', async() => {
       const page = await Page.findOne({ path: '/v5_PageForDuplicate1' });
-      expectAllToBeTruthy([page]);
+      expect(page).toBeTruthy();
 
       const newPagePath = '/duplicatedv5PageForDuplicate1';
       const duplicatedPage = await duplicate(page, newPagePath, dummyUser1, false);
@@ -1207,7 +1221,7 @@ describe('PageService page operations with only public pages', () => {
 
     test('Should NOT duplicate single empty page', async() => {
       const page = await Page.findOne({ path: '/v5_PageForDuplicate2' });
-      expectAllToBeTruthy([page]);
+      expect(page).toBeTruthy();
 
       let isThrown;
       let duplicatedPage;
@@ -1230,7 +1244,12 @@ describe('PageService page operations with only public pages', () => {
       const childPage2 = await Page.findOne({ path: '/v5_PageForDuplicate3/v5_Child_2_ForDuplicate3' }).populate({ path: 'revision', model: 'Revision' });
       const revisionForChild1 = childPage1.revision;
       const revisionForChild2 = childPage2.revision;
-      expectAllToBeTruthy([basePage, revision, childPage1, childPage2, revisionForChild1, revisionForChild2]);
+      expect(basePage).toBeTruthy();
+      expect(revision).toBeTruthy();
+      expect(childPage1).toBeTruthy();
+      expect(childPage2).toBeTruthy();
+      expect(revisionForChild1).toBeTruthy();
+      expect(revisionForChild2).toBeTruthy();
 
       const newPagePath = '/duplicatedv5PageForDuplicate3';
       const duplicatedPage = await duplicate(basePage, newPagePath, dummyUser1, true);
@@ -1242,8 +1261,13 @@ describe('PageService page operations with only public pages', () => {
       const revisionBodyForDupChild1 = duplicatedChildPage1.revision;
       const revisionBodyForDupChild2 = duplicatedChildPage2.revision;
 
-      expectAllToBeTruthy([duplicatedPage, duplicatedChildPage1, duplicatedChildPage2,
-                           revisionForDuplicatedPage, revisionBodyForDupChild1, revisionBodyForDupChild2]);
+      expect(duplicatedPage).toBeTruthy();
+      expect(duplicatedChildPage1).toBeTruthy();
+      expect(duplicatedChildPage2).toBeTruthy();
+      expect(revisionForDuplicatedPage).toBeTruthy();
+      expect(revisionBodyForDupChild1).toBeTruthy();
+      expect(revisionBodyForDupChild2).toBeTruthy();
+
       expect(xssSpy).toHaveBeenCalled();
       expect(duplicatedPage.path).toBe(newPagePath);
       expect(duplicatedChildPage1.path).toBe('/duplicatedv5PageForDuplicate3/v5_Child_1_ForDuplicate3');
@@ -1255,7 +1279,9 @@ describe('PageService page operations with only public pages', () => {
       const basePage = await Page.findOne({ path: '/v5_PageForDuplicate4' });
       const baseChild = await Page.findOne({ parent: basePage._id, isEmpty: true });
       const baseGrandchild = await Page.findOne({ parent: baseChild._id });
-      expectAllToBeTruthy([basePage, baseChild, baseGrandchild]);
+      expect(basePage).toBeTruthy();
+      expect(baseChild).toBeTruthy();
+      expect(baseGrandchild).toBeTruthy();
 
       const newPagePath = '/duplicatedv5PageForDuplicate4';
       const duplicatedPage = await duplicate(basePage, newPagePath, dummyUser1, true);
@@ -1263,7 +1289,8 @@ describe('PageService page operations with only public pages', () => {
       const duplicatedGrandchild = await Page.findOne({ parent: duplicatedChild._id });
 
       expect(xssSpy).toHaveBeenCalled();
-      expectAllToBeTruthy([duplicatedPage, duplicatedGrandchild]);
+      expect(duplicatedPage).toBeTruthy();
+      expect(duplicatedGrandchild).toBeTruthy();
       expect(duplicatedPage.path).toBe(newPagePath);
       expect(duplicatedChild.path).toBe('/duplicatedv5PageForDuplicate4/v5_empty_PageForDuplicate4');
       expect(duplicatedGrandchild.path).toBe('/duplicatedv5PageForDuplicate4/v5_empty_PageForDuplicate4/v5_grandchild_PageForDuplicate4');
@@ -1279,7 +1306,11 @@ describe('PageService page operations with only public pages', () => {
       const tag2 = await Tag.findOne({ name:  'duplicate_Tag2' });
       const basePageTagRelation1 = await PageTagRelation.findOne({ relatedTag: tag1._id });
       const basePageTagRelation2 = await PageTagRelation.findOne({ relatedTag: tag2._id });
-      expectAllToBeTruthy([basePage, tag1, tag2, basePageTagRelation1, basePageTagRelation2]);
+      expect(basePage).toBeTruthy();
+      expect(tag1).toBeTruthy();
+      expect(tag2).toBeTruthy();
+      expect(basePageTagRelation1).toBeTruthy();
+      expect(basePageTagRelation2).toBeTruthy();
 
       const newPagePath = '/duplicatedv5PageForDuplicate5';
       const duplicatedPage = await duplicate(basePage, newPagePath, dummyUser1, false);
@@ -1293,7 +1324,8 @@ describe('PageService page operations with only public pages', () => {
     test('Should NOT duplicate comments', async() => {
       const basePage = await Page.findOne({ path: '/v5_PageForDuplicate6' });
       const basePageComments = await Comment.find({ page: basePage._id });
-      expectAllToBeTruthy([basePage, ...basePageComments]);
+      expect(basePage).toBeTruthy();
+      expect(basePageComments.length).toBeGreaterThan(0); // length > 0
 
       const newPagePath = '/duplicatedv5PageForDuplicate6';
       const duplicatedPage = await duplicate(basePage, newPagePath, dummyUser1, false);
@@ -1308,14 +1340,21 @@ describe('PageService page operations with only public pages', () => {
       const basePage = await Page.findOne({ path: '/v5_empty_PageForDuplicate7' });
       const basePageChild = await Page.findOne({ parent: basePage._id }).populate({ path: 'revision', model: 'Revision' });
       const basePageGrandhild = await Page.findOne({ parent: basePageChild._id }).populate({ path: 'revision', model: 'Revision' });
-      expectAllToBeTruthy([basePage, basePageChild, basePageGrandhild, basePageChild.revision, basePageGrandhild.revision]);
-
+      expect(basePage).toBeTruthy();
+      expect(basePageChild).toBeTruthy();
+      expect(basePageGrandhild).toBeTruthy();
+      expect(basePageChild.revision).toBeTruthy();
+      expect(basePageGrandhild.revision).toBeTruthy();
       const newPagePath = '/duplicatedv5EmptyPageForDuplicate7';
       const duplicatedPage = await duplicate(basePage, newPagePath, dummyUser1, true);
       const duplicatedChild = await Page.findOne({ parent: duplicatedPage._id }).populate({ path: 'revision', model: 'Revision' });
       const duplicatedGrandchild = await Page.findOne({ parent: duplicatedChild._id }).populate({ path: 'revision', model: 'Revision' });
 
-      expectAllToBeTruthy([duplicatedPage, duplicatedChild, duplicatedGrandchild, duplicatedChild.revision, duplicatedGrandchild.revision]);
+      expect(duplicatedPage).toBeTruthy();
+      expect(duplicatedChild).toBeTruthy();
+      expect(duplicatedGrandchild).toBeTruthy();
+      expect(duplicatedChild.revision).toBeTruthy();
+      expect(duplicatedGrandchild.revision).toBeTruthy();
       expect(xssSpy).toHaveBeenCalled();
       expect(duplicatedPage.path).toBe(newPagePath);
       expect(duplicatedPage.isEmpty).toBe(true);
@@ -1348,8 +1387,7 @@ describe('PageService page operations with only public pages', () => {
 
     test('Should NOT delete root page', async() => {
       let isThrown;
-      expectAllToBeTruthy([rootPage]);
-
+      expect(rootPage).toBeTruthy();
       try { await deletePage(rootPage, dummyUser1, {}, false) }
       catch (err) { isThrown = true }
 
@@ -1361,7 +1399,7 @@ describe('PageService page operations with only public pages', () => {
 
     test('Should NOT delete trashed page', async() => {
       const trashedPage = await Page.findOne({ path: '/trash/v5_PageForDelete1' });
-      expectAllToBeTruthy([trashedPage]);
+      expect(trashedPage).toBeTruthy();
 
       let isThrown;
       try { await deletePage(trashedPage, dummyUser1, {}, false) }
@@ -1375,8 +1413,7 @@ describe('PageService page operations with only public pages', () => {
 
     test('Should NOT delete /user/hoge page', async() => {
       const dummyUser1Page = await Page.findOne({ path: '/user/v5DummyUser1' });
-      expectAllToBeTruthy([dummyUser1Page]);
-
+      expect(dummyUser1Page).toBeTruthy();
       let isThrown;
       try { await deletePage(dummyUser1Page, dummyUser1, {}, false) }
       catch (err) { isThrown = true }
@@ -1389,8 +1426,7 @@ describe('PageService page operations with only public pages', () => {
 
     test('Should delete single page', async() => {
       const pageToDelete = await Page.findOne({ path: '/v5_PageForDelete2' });
-      expectAllToBeTruthy([pageToDelete]);
-
+      expect(pageToDelete).toBeTruthy();
       const deletedPage = await deletePage(pageToDelete, dummyUser1, {}, false);
       const page = await Page.findOne({ path: '/v5_PageForDelete2' });
 
@@ -1404,8 +1440,9 @@ describe('PageService page operations with only public pages', () => {
       const parentPage = await Page.findOne({ path: '/v5_PageForDelete3' });
       const childPage = await Page.findOne({ path: '/v5_PageForDelete3/v5_PageForDelete4' });
       const grandchildPage = await Page.findOne({ path: '/v5_PageForDelete3/v5_PageForDelete4/v5_PageForDelete5' });
-      expectAllToBeTruthy([parentPage, childPage, grandchildPage]);
-
+      expect(parentPage).toBeTruthy();
+      expect(childPage).toBeTruthy();
+      expect(grandchildPage).toBeTruthy();
       const deletedParentPage = await deletePage(parentPage, dummyUser1, {}, true);
       const deletedChildPage = await Page.findOne({ path: '/trash/v5_PageForDelete3/v5_PageForDelete4' });
       const deletedGrandchildPage = await Page.findOne({ path: '/trash/v5_PageForDelete3/v5_PageForDelete4/v5_PageForDelete5' });
@@ -1428,8 +1465,11 @@ describe('PageService page operations with only public pages', () => {
       const tag2 = await Tag.findOne({ name: 'TagForDelete2' });
       const pageRelation1 = await PageTagRelation.findOne({ relatedTag: tag1._id });
       const pageRelation2 = await PageTagRelation.findOne({ relatedTag: tag2._id });
-      expectAllToBeTruthy([pageToDelete, tag1, tag2, pageRelation1, pageRelation2]);
-
+      expect(pageToDelete).toBeTruthy();
+      expect(tag1).toBeTruthy();
+      expect(tag2).toBeTruthy();
+      expect(pageRelation1).toBeTruthy();
+      expect(pageRelation2).toBeTruthy();
       const deletedPage = await deletePage(pageToDelete, dummyUser1, {}, false);
       const page = await Page.findOne({ path: '/v5_PageForDelete6' });
       const deletedTagRelation1 = await PageTagRelation.findOne({ _id: pageRelation1._id });
@@ -1461,7 +1501,7 @@ describe('PageService page operations with only public pages', () => {
     };
 
     test('Should NOT completely delete root page', async() => {
-      expectAllToBeTruthy([rootPage]);
+      expect(rootPage).toBeTruthy();
       let isThrown;
       try { await deleteCompletely(rootPage, dummyUser1, {}, false) }
       catch (err) { isThrown = true }
@@ -1471,7 +1511,7 @@ describe('PageService page operations with only public pages', () => {
     });
     test('Should completely delete single page', async() => {
       const page = await Page.findOne({ path: '/v5_PageForDeleteCompletely1' });
-      expectAllToBeTruthy([page]);
+      expect(page).toBeTruthy();
 
       await deleteCompletely(page, dummyUser1, {}, false);
       const deletedPage = await Page.findOne({ _id: page._id, path: '/v5_PageForDeleteCompletely1' });
@@ -1492,12 +1532,19 @@ describe('PageService page operations with only public pages', () => {
       const pageRedirect2 = await PageRedirect.findOne({ toPath: grandchildPage.path });
       const shareLink1 = await ShareLink.findOne({ relatedPage: parentPage._id });
       const shareLink2 = await ShareLink.findOne({ relatedPage: grandchildPage._id });
-
-      expectAllToBeTruthy(
-        [parentPage, childPage, grandchildPage, tag1, tag2,
-         pageTagRelation1, pageTagRelation2, bookmark, comment,
-         pageRedirect1, pageRedirect2, shareLink1, shareLink2],
-      );
+      expect(parentPage).toBeTruthy();
+      expect(childPage).toBeTruthy();
+      expect(grandchildPage).toBeTruthy();
+      expect(tag1).toBeTruthy();
+      expect(tag2).toBeTruthy();
+      expect(pageTagRelation1).toBeTruthy();
+      expect(pageTagRelation2).toBeTruthy();
+      expect(bookmark).toBeTruthy();
+      expect(comment).toBeTruthy();
+      expect(pageRedirect1).toBeTruthy();
+      expect(pageRedirect2).toBeTruthy();
+      expect(shareLink1).toBeTruthy();
+      expect(shareLink2).toBeTruthy();
 
       await deleteCompletely(parentPage, dummyUser1, {}, true);
       const deletedPages = await Page.find({ _id: { $in: [parentPage._id, childPage._id, grandchildPage._id] } });
@@ -1514,7 +1561,7 @@ describe('PageService page operations with only public pages', () => {
       // revision should be null
       expect(deletedRevisions.length).toBe(0);
       // tag should be Truthy
-      expectAllToBeTruthy(tags);
+      expect(tags).toBeTruthy();
       // pageTagRelation should be null
       expect(deletedPageTagRelations.length).toBe(0);
       // bookmark should be null
@@ -1529,8 +1576,8 @@ describe('PageService page operations with only public pages', () => {
     test('Should completely delete trashed page', async() => {
       const page = await Page.findOne({ path: '/trash/v5_PageForDeleteCompletely5' });
       const revision = await Revision.findOne({ pageId: page._id });
-      expectAllToBeTruthy([page, revision]);
-
+      expect(page).toBeTruthy();
+      expect(revision).toBeTruthy();
       await deleteCompletely(page, dummyUser1, {}, false);
       const deltedPage = await Page.findOne({ _id: page._id });
       const deltedRevision = await Revision.findOne({ _id: revision._id });
@@ -1542,7 +1589,9 @@ describe('PageService page operations with only public pages', () => {
       const parentPage = await Page.findOne({ path: '/v5_PageForDeleteCompletely6' });
       const childPage = await Page.findOne({ path: '/v5_PageForDeleteCompletely6/v5_PageForDeleteCompletely7' });
       const grandchildPage = await Page.findOne({ path: '/v5_PageForDeleteCompletely6/v5_PageForDeleteCompletely7/v5_PageForDeleteCompletely8' });
-      expectAllToBeTruthy([parentPage, childPage, grandchildPage]);
+      expect(parentPage).toBeTruthy();
+      expect(childPage).toBeTruthy();
+      expect(grandchildPage).toBeTruthy();
 
       await deleteCompletely(childPage, dummyUser1, {}, false);
       const parentPageAfterDelete = await Page.findOne({ path: '/v5_PageForDeleteCompletely6' });
@@ -1550,7 +1599,10 @@ describe('PageService page operations with only public pages', () => {
       const grandchildPageAfterDelete = await Page.findOne({ path: '/v5_PageForDeleteCompletely6/v5_PageForDeleteCompletely7/v5_PageForDeleteCompletely8' });
       const childOfDeletedPage = await Page.findOne({ parent: childPageAfterDelete._id });
 
-      expectAllToBeTruthy([parentPageAfterDelete, childPageAfterDelete, grandchildPageAfterDelete]);
+      expect(parentPageAfterDelete).toBeTruthy();
+      expect(childPageAfterDelete).toBeTruthy();
+      expect(grandchildPageAfterDelete).toBeTruthy();
+
       expect(childPageAfterDelete._id).not.toStrictEqual(childPage._id);
       expect(childPageAfterDelete.isEmpty).toBe(true);
       expect(childPageAfterDelete.parent).toStrictEqual(parentPage._id);
@@ -1581,7 +1633,10 @@ describe('PageService page operations with only public pages', () => {
       const revision = await Revision.findOne({ pageId: deletedPage._id });
       const tag = await Tag.findOne({ name: 'revertTag1' });
       const deletedPageTagRelation = await PageTagRelation.findOne({ relatedPage: deletedPage._id, relatedTag: tag._id, isPageTrashed: true });
-      expectAllToBeTruthy([deletedPage, revision, tag, deletedPageTagRelation]);
+      expect(deletedPage).toBeTruthy();
+      expect(revision).toBeTruthy();
+      expect(tag).toBeTruthy();
+      expect(deletedPageTagRelation).toBeTruthy();
 
       const revertedPage = await revertDeletedPage(deletedPage, dummyUser1, {}, false);
       const pageTagRelation = await PageTagRelation.findOne({ relatedPage: deletedPage._id, relatedTag: tag._id });
@@ -1598,13 +1653,19 @@ describe('PageService page operations with only public pages', () => {
       const deletedPage2 = await Page.findOne({ path: '/trash/v5_revert2/v5_revert3/v5_revert4', status: Page.STATUS_DELETED });
       const revision1 = await Revision.findOne({ pageId: deletedPage1._id });
       const revision2 = await Revision.findOne({ pageId: deletedPage2._id });
-      expectAllToBeTruthy([deletedPage1, deletedPage2, revision1, revision2]);
+      expect(deletedPage1).toBeTruthy();
+      expect(deletedPage2).toBeTruthy();
+      expect(revision1).toBeTruthy();
+      expect(revision2).toBeTruthy();
 
       const revertedPage1 = await revertDeletedPage(deletedPage1, dummyUser1, {}, true);
       const revertedPage2 = await Page.findOne({ _id: deletedPage2._id });
       const newlyCreatedPage = await Page.findOne({ path: '/v5_revert2/v5_revert3' });
 
-      expectAllToBeTruthy([revertedPage1, revertedPage2, newlyCreatedPage]);
+      expect(revertedPage1).toBeTruthy();
+      expect(revertedPage2).toBeTruthy();
+      expect(newlyCreatedPage).toBeTruthy();
+
       expect(revertedPage1.parent).toStrictEqual(rootPage._id);
       expect(revertedPage1.path).toBe('/v5_revert2');
       expect(revertedPage2.path).toBe('/v5_revert2/v5_revert3/v5_revert4');

+ 22 - 0
packages/app/test/unit/utils/page-delete-config.test.ts

@@ -0,0 +1,22 @@
+import { PageDeleteConfigValue } from '../../../src/interfaces/page-delete-config';
+import { validateDeleteConfigs } from '../../../src/utils/page-delete-config';
+
+describe('validateDeleteConfigs utility function', () => {
+  test('Should validate delete configs', () => {
+    const Anyone = PageDeleteConfigValue.Anyone;
+    const AdminAndAuthor = PageDeleteConfigValue.AdminAndAuthor;
+    const AdminOnly = PageDeleteConfigValue.AdminOnly;
+
+    expect(validateDeleteConfigs(Anyone, Anyone)).toBe(true);
+    expect(validateDeleteConfigs(Anyone, AdminAndAuthor)).toBe(true);
+    expect(validateDeleteConfigs(Anyone, AdminOnly)).toBe(true);
+
+    expect(validateDeleteConfigs(AdminAndAuthor, Anyone)).toBe(false);
+    expect(validateDeleteConfigs(AdminAndAuthor, AdminAndAuthor)).toBe(true);
+    expect(validateDeleteConfigs(AdminAndAuthor, AdminOnly)).toBe(true);
+
+    expect(validateDeleteConfigs(AdminOnly, Anyone)).toBe(false);
+    expect(validateDeleteConfigs(AdminOnly, AdminAndAuthor)).toBe(false);
+    expect(validateDeleteConfigs(AdminOnly, AdminOnly)).toBe(true);
+  });
+});

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

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

+ 1 - 1
packages/core/package.json

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

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

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

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

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

Некоторые файлы не были показаны из-за большого количества измененных файлов