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

Merge branch 'master' into dependabot/github_actions/actions/cache-3

Luqman Grune 4 лет назад
Родитель
Сommit
753d663c51
82 измененных файлов с 2427 добавлено и 894 удалено
  1. 2 2
      .github/workflows/reusable-app-prod.yml
  2. 1 1
      lerna.json
  3. 1 1
      package.json
  4. 0 1
      packages/app/.env.development
  5. 10 10
      packages/app/package.json
  6. 2 1
      packages/app/resource/locales/en_US/admin/admin.json
  7. 8 7
      packages/app/resource/locales/en_US/translation.json
  8. 2 1
      packages/app/resource/locales/ja_JP/admin/admin.json
  9. 6 5
      packages/app/resource/locales/ja_JP/translation.json
  10. 2 1
      packages/app/resource/locales/zh_CN/admin/admin.json
  11. 6 5
      packages/app/resource/locales/zh_CN/translation.json
  12. 0 123
      packages/app/resource/search/mappings-es6-for-ci.json
  13. 118 0
      packages/app/resource/search/mappings-es7-for-ci.json
  14. 3 1
      packages/app/src/client/interfaces/react-bootstrap-typeahead.ts
  15. 17 1
      packages/app/src/client/services/AdminGeneralSecurityContainer.js
  16. 4 4
      packages/app/src/components/Admin/AdminHome/AdminHome.jsx
  17. 218 70
      packages/app/src/components/Admin/Security/SecuritySetting.jsx
  18. 1 4
      packages/app/src/components/Admin/UserGroup/UserGroupDeleteModal.tsx
  19. 9 0
      packages/app/src/components/Admin/UserGroup/UserGroupForm.tsx
  20. 1 1
      packages/app/src/components/Common/Dropdown/PageItemControl.tsx
  21. 4 1
      packages/app/src/components/LegacyPrivatePagesMigrationModal.tsx
  22. 1 1
      packages/app/src/components/Navbar/GlobalSearch.tsx
  23. 2 7
      packages/app/src/components/Page/TrashPageAlert.jsx
  24. 3 3
      packages/app/src/components/PageCreateModal.jsx
  25. 1 1
      packages/app/src/components/PageDuplicateModal.tsx
  26. 3 3
      packages/app/src/components/PageEditor/LinkEditModal.jsx
  27. 1 1
      packages/app/src/components/PageEditor/PreviewWithSuspense.jsx
  28. 22 2
      packages/app/src/components/PageList/PageListItemL.tsx
  29. 4 23
      packages/app/src/components/PagePathAutoComplete.jsx
  30. 14 32
      packages/app/src/components/SearchForm.tsx
  31. 1 1
      packages/app/src/components/SearchPage/SearchControl.tsx
  32. 142 127
      packages/app/src/components/SearchTypeahead.tsx
  33. 6 6
      packages/app/src/interfaces/page-delete-config.ts
  34. 1 0
      packages/app/src/server/interfaces/page-operation.ts
  35. 1 0
      packages/app/src/server/models/page-operation.ts
  36. 57 18
      packages/app/src/server/models/page.ts
  37. 6 6
      packages/app/src/server/routes/apiv3/index.js
  38. 3 1
      packages/app/src/server/routes/apiv3/pages.js
  39. 13 1
      packages/app/src/server/routes/apiv3/security-setting.js
  40. 6 12
      packages/app/src/server/service/config-loader.ts
  41. 33 16
      packages/app/src/server/service/page-grant.ts
  42. 89 64
      packages/app/src/server/service/page.ts
  43. 8 2
      packages/app/src/server/service/search-delegator/elasticsearch.ts
  44. 4 4
      packages/app/src/stores/search.tsx
  45. 15 14
      packages/app/src/styles/theme/_apply-colors-dark.scss
  46. 11 0
      packages/app/src/styles/theme/_apply-colors-light.scss
  47. 0 27
      packages/app/src/styles/theme/_apply-colors.scss
  48. 35 0
      packages/app/src/styles/theme/_reboot-bootstrap-dropdown.scss
  49. 0 4
      packages/app/src/styles/theme/antarctic.scss
  50. 0 1
      packages/app/src/styles/theme/blackboard.scss
  51. 1 3
      packages/app/src/styles/theme/christmas.scss
  52. 0 7
      packages/app/src/styles/theme/default.scss
  53. 0 5
      packages/app/src/styles/theme/fire-red.scss
  54. 0 5
      packages/app/src/styles/theme/future.scss
  55. 0 1
      packages/app/src/styles/theme/halloween.scss
  56. 2 5
      packages/app/src/styles/theme/hufflepuff.scss
  57. 0 2
      packages/app/src/styles/theme/island.scss
  58. 0 5
      packages/app/src/styles/theme/jade-green.scss
  59. 0 2
      packages/app/src/styles/theme/kibela.scss
  60. 0 9
      packages/app/src/styles/theme/mono-blue.scss
  61. 0 5
      packages/app/src/styles/theme/nature.scss
  62. 0 2
      packages/app/src/styles/theme/spring.scss
  63. 0 2
      packages/app/src/styles/theme/wood.scss
  64. 62 0
      packages/app/src/utils/page-delete-config.ts
  65. 1 2
      packages/app/test/cypress/integration/2-basic-features/open-page-create-modal.spec.ts
  66. 1 2
      packages/app/test/cypress/integration/2-basic-features/open-page-delete-modal.spec.ts
  67. 35 0
      packages/app/test/cypress/integration/2-basic-features/open-page-duplicate-modal.spec.ts
  68. 12 12
      packages/app/test/integration/service/page-grant.test.js
  69. 376 30
      packages/app/test/integration/service/v5.migration.test.js
  70. 750 38
      packages/app/test/integration/service/v5.non-public-page.test.ts
  71. 123 62
      packages/app/test/integration/service/v5.public-page.test.ts
  72. 22 0
      packages/app/test/unit/utils/page-delete-config.test.ts
  73. 1 1
      packages/codemirror-textlint/package.json
  74. 1 1
      packages/core/package.json
  75. 1 1
      packages/plugin-attachment-refs/package.json
  76. 1 1
      packages/plugin-lsx/package.json
  77. 1 1
      packages/plugin-pukiwiki-like-linker/package.json
  78. 1 1
      packages/slack/package.json
  79. 1 1
      packages/slackbot-proxy/package.json
  80. 1 1
      packages/ui/package.json
  81. 12 2
      packages/ui/src/components/PagePath/PageListMeta.jsx
  82. 125 68
      yarn.lock

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

@@ -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:
@@ -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:

+ 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",

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

@@ -12,7 +12,6 @@ MATHJAX=1
 MONGO_URI="mongodb://mongo:27017/growi"
 # REDIS_URI="http://redis:6379"
 # NCHAN_URI="http://nchan"
-USE_ELASTICSEARCH_V6=false
 ELASTICSEARCH_URI="http://elasticsearch:9200/growi"
 ELASTICSEARCH_REQUEST_TIMEOUT=15000
 ELASTICSEARCH_REJECT_UNAUTHORIZED=true

+ 10 - 10
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",
@@ -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",

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

@@ -474,6 +474,7 @@
     "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",
@@ -494,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}}",

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

@@ -473,6 +473,7 @@
     "group_example": "例: Group1",
     "parent_group": "親グループ",
     "select_parent_group": "親グループを選択",
+    "release_parent_group": "親グループの解除",
     "add_modal": {
       "add_user": "グループへのユーザー追加",
       "search_option": "検索オプション",
@@ -493,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}} から設定してください。",

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

@@ -483,6 +483,7 @@
     "group_example": "e.g.:第1组",
     "parent_group": "父母组",
     "select_parent_group": "选择父组",
+    "release_parent_group": "Release parent group",
     "add_modal": {
       "add_user": "将用户添加到创建的组",
       "search_option": "搜索选项",
@@ -503,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,
 };

+ 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>

+ 9 - 0
packages/app/src/components/Admin/UserGroup/UserGroupForm.tsx

@@ -132,6 +132,15 @@ const UserGroupForm: FC<Props> = (props: Props) => {
                   </>
                 )
               }
+
+              <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>

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

@@ -142,7 +142,7 @@ const PageItemControlDropdownMenu = React.memo((props: DropdownMenuProps): JSX.E
 
         {/* Duplicate */}
         { !forceHideMenuItems?.includes(MenuItemType.DUPLICATE) && isEnableActions && (
-          <DropdownItem onClick={duplicateItemClickedHandler}>
+          <DropdownItem onClick={duplicateItemClickedHandler} data-testid="open-page-duplicate-modal-btn">
             <i className="icon-fw icon-docs"></i>
             {t('Duplicate')}
           </DropdownItem>

+ 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/PageDuplicateModal.tsx

@@ -163,7 +163,7 @@ const PageDuplicateModal = (): JSX.Element => {
     || (isDuplicateRecursively && isDuplicateRecursivelyWithoutExistPath);
 
   return (
-    <Modal size="lg" isOpen={isOpened} toggle={closeDuplicateModal} 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,

+ 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}
       />

+ 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>;

+ 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>({

+ 57 - 18
packages/app/src/server/models/page.ts

@@ -45,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[]>
@@ -141,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({
@@ -410,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 });
 
-  const existingPages = await builder
-    .addConditionToListByPathsArray(paths)
-    .query
-    .lean()
-    .exec();
-  const existingPagePaths = existingPages.map(page => page.path);
+  // Run aggregation
+  const existingPages = await this.aggregate(aggregationPipeline);
 
+
+  const existingPagePaths = existingPages.map(page => page.path);
   // paths to create empty pages
   const notExistingPagePaths = paths.filter(path => !existingPagePaths.includes(path));
 
@@ -536,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()
@@ -974,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);
@@ -1078,9 +1119,7 @@ export default (crowi: Crowi): any => {
     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);

+ 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');

+ 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);
     }
 
   }

+ 8 - 2
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');
@@ -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,

+ 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

@@ -26,8 +26,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();

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

@@ -30,8 +30,7 @@ context('Open Page Delete Modal', () => {
        cy.getByTestid('open-page-delete-modal-btn').click();
     });
 
-     cy.getByTestid('page-delete-modal').should('be.visible');
-     cy.screenshot(`${ssPrefix}-open-bootstrap4`,{ capture: 'viewport' });
+     cy.getByTestid('page-delete-modal').should('be.visible').screenshot(`${ssPrefix}-open-bootstrap4`);
   });
 
 });

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

@@ -0,0 +1,35 @@
+context('Open Page Duplicate Modal', () => {
+
+  const ssPrefix = 'access-to-page-duplicate-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('PageDuplicateModal 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-duplicate-modal-btn').click();
+    });
+     cy.getByTestid('page-duplicate-modal').should('be.visible').screenshot(`${ssPrefix}-open-bootstrap4`);
+  });
+
+});

+ 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": [

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

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

+ 1 - 1
packages/slack/package.json

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

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

@@ -25,7 +25,7 @@
   },
   "dependencies": {
     "@godaddy/terminus": "^4.9.0",
-    "@growi/slack": "^5.0.0-RC.9",
+    "@growi/slack": "^5.0.0-RC.11",
     "@slack/oauth": "^2.0.1",
     "@slack/web-api": "^6.2.4",
     "@tsed/common": "^6.43.0",

+ 1 - 1
packages/ui/package.json

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

+ 12 - 2
packages/ui/src/components/PagePath/PageListMeta.jsx

@@ -28,9 +28,18 @@ export class PageListMeta extends React.Component {
       commentCount = <span className={`${shouldSpaceOutIcon ? 'mr-3' : ''}`}><i className="icon-bubble" />{page.commentCount}</span>;
     }
 
+    // liker count section
+    let likedCount;
+    if (this.props.likerCount > 0) {
+      likedCount = this.props.likerCount;
+    }
+    else if (page.liker != null && page.liker.length > 0) {
+      likedCount = page.liker.length;
+    }
+
     let likerCount;
-    if (page.liker != null && page.liker.length > 0) {
-      likerCount = <span className={`${shouldSpaceOutIcon ? 'mr-3' : ''}`}><i className="fa fa-heart-o" />{page.liker.length}</span>;
+    if (likedCount > 0) {
+      likerCount = <span className={`${shouldSpaceOutIcon ? 'mr-3' : ''}`}><i className="fa fa-heart-o" />{likedCount}</span>;
     }
 
     let locked;
@@ -70,6 +79,7 @@ export class PageListMeta extends React.Component {
 
 PageListMeta.propTypes = {
   page: PropTypes.object.isRequired,
+  likerCount: PropTypes.number,
   bookmarkCount: PropTypes.number,
   shouldSpaceOutIcon: PropTypes.bool,
 };

+ 125 - 68
yarn.lock

@@ -616,6 +616,13 @@
   dependencies:
     regenerator-runtime "^0.13.4"
 
+"@babel/runtime@^7.13.8", "@babel/runtime@^7.8.7":
+  version "7.17.7"
+  resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.17.7.tgz#a5f3328dc41ff39d803f311cfe17703418cf9825"
+  integrity sha512-L6rvG9GDxaLgFjg41K+5Yv9OMrU98sWe+Ykmc6FDJW/+vYZMhdOMKkISgzptMaERHvS2Y2lw9MDRm2gHhlQQoA==
+  dependencies:
+    regenerator-runtime "^0.13.4"
+
 "@babel/runtime@^7.14.6", "@babel/runtime@^7.15.4", "@babel/runtime@^7.9.2":
   version "7.16.7"
   resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.16.7.tgz#03ff99f64106588c9c403c6ecb8c3bafbbdff1fa"
@@ -866,7 +873,7 @@
   resolved "https://registry.yarnpkg.com/@discoveryjs/json-ext/-/json-ext-0.5.6.tgz#d5e0706cf8c6acd8c6032f8d54070af261bbbb2f"
   integrity sha512-ws57AidsDvREKrZKYffXddNkyaF14iHNHm8VQnZH6t99E8gczjNN0GpvcGny0imC80yQ0tHz1xVUKk/KFQSUyA==
 
-"@elastic/elasticsearch6@npm:@elastic/elasticsearch@^6.8.7":
+"@elastic/elasticsearch6@npm:@elastic/elasticsearch@^6.8.8":
   version "6.8.8"
   resolved "https://registry.yarnpkg.com/@elastic/elasticsearch/-/elasticsearch-6.8.8.tgz#363d332d4de3a3ee5420ac0ced2eb4bfadf04548"
   integrity sha512-51Jp3ZZ0oPqYPNlPG58XJ773MqJBx91rGNWCgVvy2UtxjxHsExAJooesOyLcoADnW0Dhyxu6yB8tziHnmyl8Vw==
@@ -879,10 +886,10 @@
     pump "^3.0.0"
     secure-json-parse "^2.1.0"
 
-"@elastic/elasticsearch7@npm:@elastic/elasticsearch@^7.16.0":
-  version "7.16.0"
-  resolved "https://registry.yarnpkg.com/@elastic/elasticsearch/-/elasticsearch-7.16.0.tgz#c1c64b6f0343c0f5ca6893fb77ceecd763455024"
-  integrity sha512-lMY2MFZZFG3om7QNHninxZZOXYx3NdIUwEISZxqaI9dXPoL3DNhU31keqjvx1gN6T74lGXAzrRNP4ag8CJ/VXw==
+"@elastic/elasticsearch7@npm:@elastic/elasticsearch@^7.17.0":
+  version "7.17.0"
+  resolved "https://registry.yarnpkg.com/@elastic/elasticsearch/-/elasticsearch-7.17.0.tgz#589fb219234cf1b0da23744e82b1d25e2fe9a797"
+  integrity sha512-5QLPCjd0uLmLj1lSuKSThjNpq39f6NmlTy9ROLFwG5gjyTgpwSqufDeYG/Fm43Xs05uF7WcscoO7eguI3HuuYA==
   dependencies:
     debug "^4.3.1"
     hpagent "^0.1.1"
@@ -2301,6 +2308,11 @@
   dependencies:
     "@octokit/openapi-types" "^10.0.0"
 
+"@popperjs/core@^2.8.6":
+  version "2.11.4"
+  resolved "https://registry.yarnpkg.com/@popperjs/core/-/core-2.11.4.tgz#d8c7b8db9226d2d7664553a0741ad7d0397ee503"
+  integrity sha512-q/ytXxO5NKvyT37pmisQAItCFqA7FD/vNb8dgaJy3/630Fsc+Mz9/9f2SziBoIZ30TJooXyTwZmhi1zjXmObYg==
+
 "@promster/express@^7.0.2":
   version "7.0.2"
   resolved "https://registry.yarnpkg.com/@promster/express/-/express-7.0.2.tgz#d60b10d373fd572275714426dc90a7049fd26d4f"
@@ -2349,6 +2361,20 @@
   resolved "https://registry.yarnpkg.com/@react-dnd/shallowequal/-/shallowequal-2.0.0.tgz#a3031eb54129f2c66b2753f8404266ec7bf67f0a"
   integrity sha512-Pc/AFTdwZwEKJxFJvlxrSmGe/di+aAOBn60sremrpLo6VI/6cmiUYNNwlI5KNYttg7uypzA3ILPMPgxB2GYZEg==
 
+"@restart/hooks@^0.3.26":
+  version "0.3.27"
+  resolved "https://registry.yarnpkg.com/@restart/hooks/-/hooks-0.3.27.tgz#91f356d66d4699a8cd8b3d008402708b6a9dc505"
+  integrity sha512-s984xV/EapUIfkjlf8wz9weP2O9TNKR96C68FfMEy2bE69+H4cNv3RD4Mf97lW7Htt7PjZrYTjSC8f3SB9VCXw==
+  dependencies:
+    dequal "^2.0.2"
+
+"@restart/hooks@^0.4.0":
+  version "0.4.5"
+  resolved "https://registry.yarnpkg.com/@restart/hooks/-/hooks-0.4.5.tgz#e7acbea237bfc9e479970500cf87538b41a1ed02"
+  integrity sha512-tLGtY0aHeIfT7aPwUkvQuhIy3+q3w4iqmUzFLPlOAf/vNUacLaBt1j/S//jv/dQhenRh8jvswyMojCwmLvJw8A==
+  dependencies:
+    dequal "^2.0.2"
+
 "@sematext/gc-stats@1.5.5":
   version "1.5.5"
   resolved "https://registry.yarnpkg.com/@sematext/gc-stats/-/gc-stats-1.5.5.tgz#3461e818454b95de26085b65f0d95417b9f183d6"
@@ -3216,6 +3242,15 @@
     "@types/prop-types" "*"
     csstype "^2.2.0"
 
+"@types/react@>=16.9.11":
+  version "17.0.40"
+  resolved "https://registry.yarnpkg.com/@types/react/-/react-17.0.40.tgz#dc010cee6254d5239a138083f3799a16638e6bad"
+  integrity sha512-UrXhD/JyLH+W70nNSufXqMZNuUD2cXHu6UjCllC6pmOQgBX4SGXOH8fjRka0O0Ee0HrFxapDD8Bwn81Kmiz6jQ==
+  dependencies:
+    "@types/prop-types" "*"
+    "@types/scheduler" "*"
+    csstype "^3.0.2"
+
 "@types/retry@^0.12.0":
   version "0.12.0"
   resolved "https://registry.yarnpkg.com/@types/retry/-/retry-0.12.0.tgz#2b35eccfcee7d38cd72ad99232fbd58bffb3c84d"
@@ -3226,6 +3261,11 @@
   resolved "https://registry.yarnpkg.com/@types/rewire/-/rewire-2.5.28.tgz#ff34de38c4269fe74e2597195d4918c25d42ebad"
   integrity sha512-uD0j/AQOa5le7afuK+u+woi8jNKF1vf3DN0H7LCJhft/lNNibUr7VcAesdgtWfEKveZol3ZG1CJqwx2Bhrnl8w==
 
+"@types/scheduler@*":
+  version "0.16.2"
+  resolved "https://registry.yarnpkg.com/@types/scheduler/-/scheduler-0.16.2.tgz#1a62f89525723dde24ba1b01b092bf5df8ad4d39"
+  integrity sha512-hppQEBDmlwhFAXKJX2KnWLYu5yMfi91yazPb2l+lbJiwW+wdo1gNeRA+3RgNSO39WYX2euey41KEwnqesU2Jew==
+
 "@types/serve-static@*":
   version "1.13.9"
   resolved "https://registry.yarnpkg.com/@types/serve-static/-/serve-static-1.13.9.tgz#aacf28a85a05ee29a11fb7c3ead935ac56f33e4e"
@@ -3276,6 +3316,11 @@
   resolved "https://registry.yarnpkg.com/@types/unist/-/unist-2.0.3.tgz#9c088679876f374eb5983f150d4787aa6fb32d7e"
   integrity sha512-FvUupuM3rlRsRtCN+fDudtmytGO6iHJuuRKS1Ss0pG5z8oX0diNEw94UEL7hgDbpN94rgaK5R7sWm6RrSkZuAQ==
 
+"@types/warning@^3.0.0":
+  version "3.0.0"
+  resolved "https://registry.yarnpkg.com/@types/warning/-/warning-3.0.0.tgz#0d2501268ad8f9962b740d387c4654f5f8e23e52"
+  integrity sha1-DSUBJorY+ZYrdA04fEZU9fjiPlI=
+
 "@types/webidl-conversions@*":
   version "6.1.1"
   resolved "https://registry.yarnpkg.com/@types/webidl-conversions/-/webidl-conversions-6.1.1.tgz#e33bc8ea812a01f63f90481c666334844b12a09e"
@@ -5364,10 +5409,6 @@ center-align@^0.1.1:
     align-text "^0.1.3"
     lazy-cache "^1.0.3"
 
-chain-function@^1.0.0:
-  version "1.0.0"
-  resolved "https://registry.yarnpkg.com/chain-function/-/chain-function-1.0.0.tgz#0d4ab37e7e18ead0bdc47b920764118ce58733dc"
-
 chainsaw@~0.1.0:
   version "0.1.0"
   resolved "https://registry.yarnpkg.com/chainsaw/-/chainsaw-0.1.0.tgz#5eab50b28afe58074d0d58291388828b5e5fbc98"
@@ -5639,7 +5680,7 @@ class-utils@^0.3.5:
     isobject "^3.0.0"
     static-extend "^0.1.1"
 
-classnames@^2.2.0, classnames@^2.2.5:
+classnames@^2.2.0:
   version "2.2.5"
   resolved "https://registry.yarnpkg.com/classnames/-/classnames-2.2.5.tgz#fb3801d453467649ef3603c7d61a02bd129bde6d"
 
@@ -6084,6 +6125,11 @@ compression@^1.7.4:
     safe-buffer "5.1.2"
     vary "~1.1.2"
 
+compute-scroll-into-view@^1.0.17:
+  version "1.0.17"
+  resolved "https://registry.yarnpkg.com/compute-scroll-into-view/-/compute-scroll-into-view-1.0.17.tgz#6a88f18acd9d42e9cf4baa6bec7e0522607ab7ab"
+  integrity sha512-j4dx+Fb0URmzbwwMUrhqWM2BEWHdFGx+qZ9qqASHRPqvTYdqvWnHg0H1hIbcyLnvgnoNAVMlwkepyqM3DaIFUg==
+
 concat-map@0.0.1:
   version "0.0.1"
   resolved "https://registry.yarnpkg.com/concat-map/-/concat-map-0.0.1.tgz#d8a96bd77fd68df7793a73036a3ba0d5405d477b"
@@ -6592,14 +6638,6 @@ create-react-context@^0.1.5:
   resolved "https://registry.yarnpkg.com/create-react-context/-/create-react-context-0.1.6.tgz#0f425931d907741127acc6e31acb4f9015dd9fdc"
   integrity sha512-eCnYYEUEc5i32LHwpE/W7NlddOB9oHwsPaWtWzYtflNkkwa3IfindIcoXdVWs12zCbwaMCavKNu84EXogVIWHw==
 
-create-react-context@^0.3.0:
-  version "0.3.0"
-  resolved "https://registry.yarnpkg.com/create-react-context/-/create-react-context-0.3.0.tgz#546dede9dc422def0d3fc2fe03afe0bc0f4f7d8c"
-  integrity sha512-dNldIoSuNSvlTJ7slIKC/ZFGKexBMBrrcc+TTe1NdmROnaASuLPvqpwj9v4XS4uXZ8+YPu0sNmShX2rXI5LNsw==
-  dependencies:
-    gud "^1.0.0"
-    warning "^4.0.3"
-
 create-require@^1.1.0:
   version "1.1.1"
   resolved "https://registry.yarnpkg.com/create-require/-/create-require-1.1.1.tgz#c1d7e8f1e5f6cfc9ff65f9cd352d37348756c333"
@@ -6869,6 +6907,11 @@ csstype@^2.2.0:
   resolved "https://registry.yarnpkg.com/csstype/-/csstype-2.6.9.tgz#05141d0cd557a56b8891394c1911c40c8a98d098"
   integrity sha512-xz39Sb4+OaTsULgUERcCk+TJj8ylkL4aSVDQiX/ksxbELSqwkgt4d4RD7fovIdgJGSuNYqwZEiVjYY5l0ask+Q==
 
+csstype@^3.0.2:
+  version "3.0.11"
+  resolved "https://registry.yarnpkg.com/csstype/-/csstype-3.0.11.tgz#d66700c5eacfac1940deb4e3ee5642792d85cd33"
+  integrity sha512-sa6P2wJ+CAbgyy4KFssIb/JNMLxFvKF1pCYCSXS8ZMuqZnMsrxqI2E5sPyoTpxoPU/gVZMzr2zjOfg8GIZOMsw==
+
 csv-to-markdown-table@^1.0.1:
   version "1.0.1"
   resolved "https://registry.yarnpkg.com/csv-to-markdown-table/-/csv-to-markdown-table-1.0.1.tgz#43da1b0c0c483faa10a23921abc5e47a48e0daba"
@@ -7220,6 +7263,11 @@ deprecation@^2.0.0, deprecation@^2.3.1:
   resolved "https://registry.yarnpkg.com/deprecation/-/deprecation-2.3.1.tgz#6368cbdb40abf3373b525ac87e4a260c3a700919"
   integrity sha512-xmHIy4F3scKVwMsQ4WnVaS8bHOx0DmVwRywosKhaILI0ywMDWPtBSku2HNxRvF7jtwDRsoEwYQSfbxj8b7RlJQ==
 
+dequal@^2.0.2:
+  version "2.0.2"
+  resolved "https://registry.yarnpkg.com/dequal/-/dequal-2.0.2.tgz#85ca22025e3a87e65ef75a7a437b35284a7e319d"
+  integrity sha512-q9K8BlJVxK7hQYqa6XISGmBZbtQQWVXSrRrWreHC94rMt1QL/Impruc+7p2CYSYuVIUr+YCt6hjrs1kkdJRTug==
+
 des.js@^1.0.0:
   version "1.0.0"
   resolved "https://registry.yarnpkg.com/des.js/-/des.js-1.0.0.tgz#c074d2e2aa6a8a9a07dbd61f9a15c2cd83ec8ecc"
@@ -7372,10 +7420,6 @@ dom-accessibility-api@^0.5.9:
   resolved "https://registry.yarnpkg.com/dom-accessibility-api/-/dom-accessibility-api-0.5.10.tgz#caa6d08f60388d0bb4539dd75fe458a9a1d0014c"
   integrity sha512-Xu9mD0UjrJisTmv7lmVSDMagQcU9R5hwAbxsaAE/35XPnPLJobbuREfV/rraiSaEj/UOvgrzQs66zyTWTlyd+g==
 
-dom-helpers@^3.2.0, dom-helpers@^3.2.1:
-  version "3.3.1"
-  resolved "https://registry.yarnpkg.com/dom-helpers/-/dom-helpers-3.3.1.tgz#fc1a4e15ffdf60ddde03a480a9c0fece821dd4a6"
-
 dom-helpers@^3.4.0:
   version "3.4.0"
   resolved "https://registry.yarnpkg.com/dom-helpers/-/dom-helpers-3.4.0.tgz#e9b369700f959f62ecde5a6babde4bccd9169af8"
@@ -7383,6 +7427,14 @@ dom-helpers@^3.4.0:
   dependencies:
     "@babel/runtime" "^7.1.2"
 
+dom-helpers@^5.2.0:
+  version "5.2.1"
+  resolved "https://registry.yarnpkg.com/dom-helpers/-/dom-helpers-5.2.1.tgz#d9400536b2bf8225ad98fe052e029451ac40e902"
+  integrity sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==
+  dependencies:
+    "@babel/runtime" "^7.8.7"
+    csstype "^3.0.2"
+
 dom-serializer@0:
   version "0.1.0"
   resolved "https://registry.yarnpkg.com/dom-serializer/-/dom-serializer-0.1.0.tgz#073c697546ce0780ce23be4a28e293e40bc30c82"
@@ -10169,9 +10221,9 @@ hoopy@^0.1.2:
   resolved "https://registry.yarnpkg.com/hoopy/-/hoopy-0.1.4.tgz#609207d661100033a9a9402ad3dea677381c1b1d"
 
 hosted-git-info@^2.1.4:
-  version "2.8.8"
-  resolved "https://registry.yarnpkg.com/hosted-git-info/-/hosted-git-info-2.8.8.tgz#7539bd4bc1e0e0a895815a2e0262420b12858488"
-  integrity sha512-f/wzC2QaWBs7t9IYqB4T3sR1xviIViXJRJTWBlx2Gf3g0Xi5vI7Yy4koXQ1c9OYDGHN9sBy1DQ2AB8fqZBWhUg==
+  version "2.8.9"
+  resolved "https://registry.yarnpkg.com/hosted-git-info/-/hosted-git-info-2.8.9.tgz#dffc0bf9a21c02209090f2aa69429e1414daf3f9"
+  integrity sha512-mxIDAb9Lsm6DoOJ7xH+5+X4y1LU/4Hi50L9C5sIswK3JzULS4bwk1FvjdBgvYR4bzT4tuUQiC15FE2f5HbLvYw==
 
 hosted-git-info@^4.0.0, hosted-git-info@^4.0.1:
   version "4.0.2"
@@ -10733,6 +10785,13 @@ invariant@^2.2.1:
   dependencies:
     loose-envify "^1.0.0"
 
+invariant@^2.2.4:
+  version "2.2.4"
+  resolved "https://registry.yarnpkg.com/invariant/-/invariant-2.2.4.tgz#610f3c92c9359ce1db616e538008d23ff35158e6"
+  integrity sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA==
+  dependencies:
+    loose-envify "^1.0.0"
+
 invert-kv@^2.0.0:
   version "2.0.0"
   resolved "https://registry.yarnpkg.com/invert-kv/-/invert-kv-2.0.0.tgz#7393f5afa59ec9ff5f67a27620d11c226e3eec02"
@@ -12745,7 +12804,7 @@ lodash.uniqwith@^4.5.0:
   resolved "https://registry.yarnpkg.com/lodash.uniqwith/-/lodash.uniqwith-4.5.0.tgz#7a0cbf65f43b5928625a9d4d0dc54b18cadc7ef3"
   integrity sha1-egy/ZfQ7WShiWp1NDcVLGMrcfvM=
 
-lodash@4.17.21, lodash@4.x, lodash@>=4.17.15, lodash@^4.15.0, lodash@^4.17.10, lodash@^4.17.11, lodash@^4.17.13, lodash@^4.17.14, lodash@^4.17.15, lodash@^4.17.19, lodash@^4.17.2, lodash@^4.17.20, lodash@^4.17.21, lodash@^4.17.4, lodash@^4.17.5, lodash@^4.7.0, lodash@~4.17.21:
+lodash@4.17.21, lodash@4.x, lodash@>=4.17.15, lodash@^4.15.0, lodash@^4.17.10, lodash@^4.17.11, lodash@^4.17.13, lodash@^4.17.14, lodash@^4.17.15, lodash@^4.17.19, lodash@^4.17.20, lodash@^4.17.21, lodash@^4.17.4, lodash@^4.17.5, lodash@^4.7.0, lodash@~4.17.21:
   version "4.17.21"
   resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.21.tgz#679591c564c3bffaae8454cf0b3df370c3d6911c"
   integrity sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==
@@ -16430,12 +16489,6 @@ promzard@^0.3.0:
   dependencies:
     read "1"
 
-prop-types-extra@^1.0.1:
-  version "1.0.1"
-  resolved "https://registry.yarnpkg.com/prop-types-extra/-/prop-types-extra-1.0.1.tgz#a57bd4810e82d27a3ff4317ecc1b4ad005f79a82"
-  dependencies:
-    warning "^3.0.0"
-
 prop-types@^15.0.0, prop-types@^15.6.2:
   version "15.6.2"
   resolved "https://registry.yarnpkg.com/prop-types/-/prop-types-15.6.2.tgz#05d5ca77b4453e985d60fc7ff8c859094a497102"
@@ -16789,20 +16842,21 @@ rc@>=1.2.8:
     minimist "^1.2.0"
     strip-json-comments "~2.0.1"
 
-react-bootstrap-typeahead@^3.4.7:
-  version "3.4.7"
-  resolved "https://registry.yarnpkg.com/react-bootstrap-typeahead/-/react-bootstrap-typeahead-3.4.7.tgz#27a3f17c6b1351a0c1b321ac133d5e762cf4dc2a"
-  integrity sha512-eUm3hqX12p+iM+1Y0HKF891/ACbKyGep7PsC2pjFGZL48r25Jlv3X2xmV5D8N0wE/YPFZF7iW913tyAlwqjw1Q==
+react-bootstrap-typeahead@^5.2.2:
+  version "5.2.2"
+  resolved "https://registry.yarnpkg.com/react-bootstrap-typeahead/-/react-bootstrap-typeahead-5.2.2.tgz#5f8eeaa9444e622f36dbb0c01e4776ba33b4c99e"
+  integrity sha512-dnN6o+HlDOWy/PyPWI63gFJlojx7qhypj02pvOnBCUgl6XXj9+iIAS5jj5AD76wOnlVRfO5d7pL4Ordjt1rTzQ==
   dependencies:
+    "@babel/runtime" "^7.14.6"
+    "@restart/hooks" "^0.4.0"
     classnames "^2.2.0"
-    create-react-context "^0.3.0"
-    escape-string-regexp "^1.0.5"
+    fast-deep-equal "^3.1.1"
     invariant "^2.2.1"
-    lodash "^4.17.2"
+    lodash.debounce "^4.0.8"
     prop-types "^15.5.8"
-    prop-types-extra "^1.0.1"
-    react-overlays "^0.8.1"
+    react-overlays "^5.1.0"
     react-popper "^1.0.0"
+    scroll-into-view-if-needed "^2.2.20"
     warning "^4.0.1"
 
 react-card-flip@^1.0.10:
@@ -16945,16 +16999,19 @@ react-multiline-clamp@^2.0.0:
   resolved "https://registry.yarnpkg.com/react-multiline-clamp/-/react-multiline-clamp-2.0.0.tgz#913a2092368ef1b52c1c79364d506ba4af27e019"
   integrity sha512-iPm3HxFD6LO63lE5ZnThiqs+6A3c+LW3WbsEM0oa0iNTa0qN4SKx/LK/6ZToSmXundEcQXBFVNzKDvgmExawTw==
 
-react-overlays@^0.8.1:
-  version "0.8.3"
-  resolved "https://registry.yarnpkg.com/react-overlays/-/react-overlays-0.8.3.tgz#fad65eea5b24301cca192a169f5dddb0b20d3ac5"
-  dependencies:
-    classnames "^2.2.5"
-    dom-helpers "^3.2.1"
-    prop-types "^15.5.10"
-    prop-types-extra "^1.0.1"
-    react-transition-group "^2.2.0"
-    warning "^3.0.0"
+react-overlays@^5.1.0:
+  version "5.1.1"
+  resolved "https://registry.yarnpkg.com/react-overlays/-/react-overlays-5.1.1.tgz#2e7cf49744b56537c7828ccb94cfc63dd778ae4f"
+  integrity sha512-eCN2s2/+GVZzpnId4XVWtvDPYYBD2EtOGP74hE+8yDskPzFy9+pV1H3ZZihxuRdEbQzzacySaaDkR7xE0ydl4Q==
+  dependencies:
+    "@babel/runtime" "^7.13.8"
+    "@popperjs/core" "^2.8.6"
+    "@restart/hooks" "^0.3.26"
+    "@types/warning" "^3.0.0"
+    dom-helpers "^5.2.0"
+    prop-types "^15.7.2"
+    uncontrollable "^7.2.1"
+    warning "^4.0.3"
 
 react-popper@^1.0.0:
   version "1.3.3"
@@ -16997,17 +17054,6 @@ react-tagcloud@^2.1.1:
     randomcolor "^0.5.4"
     shuffle-array "^1.0.1"
 
-react-transition-group@^2.2.0:
-  version "2.2.1"
-  resolved "https://registry.yarnpkg.com/react-transition-group/-/react-transition-group-2.2.1.tgz#e9fb677b79e6455fd391b03823afe84849df4a10"
-  dependencies:
-    chain-function "^1.0.0"
-    classnames "^2.2.5"
-    dom-helpers "^3.2.0"
-    loose-envify "^1.3.1"
-    prop-types "^15.5.8"
-    warning "^3.0.0"
-
 react-transition-group@^2.2.1, react-transition-group@^2.3.1:
   version "2.9.0"
   resolved "https://registry.yarnpkg.com/react-transition-group/-/react-transition-group-2.9.0.tgz#df9cdb025796211151a436c69a8f3b97b5b07c8d"
@@ -18090,6 +18136,13 @@ schema-utils@^3.0.0:
     ajv "^6.12.5"
     ajv-keywords "^3.5.2"
 
+scroll-into-view-if-needed@^2.2.20:
+  version "2.2.29"
+  resolved "https://registry.yarnpkg.com/scroll-into-view-if-needed/-/scroll-into-view-if-needed-2.2.29.tgz#551791a84b7e2287706511f8c68161e4990ab885"
+  integrity sha512-hxpAR6AN+Gh53AdAimHM6C8oTN1ppwVZITihix+WqalywBeFcQ6LdQP5ABNl26nX8GTEL7VT+b8lKpdqq65wXg==
+  dependencies:
+    compute-scroll-into-view "^1.0.17"
+
 secure-json-parse@^2.1.0, secure-json-parse@^2.4.0:
   version "2.4.0"
   resolved "https://registry.yarnpkg.com/secure-json-parse/-/secure-json-parse-2.4.0.tgz#5aaeaaef85c7a417f76271a4f5b0cc3315ddca85"
@@ -20795,6 +20848,16 @@ unbox-primitive@^1.0.1:
     has-symbols "^1.0.2"
     which-boxed-primitive "^1.0.2"
 
+uncontrollable@^7.2.1:
+  version "7.2.1"
+  resolved "https://registry.yarnpkg.com/uncontrollable/-/uncontrollable-7.2.1.tgz#1fa70ba0c57a14d5f78905d533cf63916dc75738"
+  integrity sha512-svtcfoTADIB0nT9nltgjujTi7BzVmwjZClOmskKu/E8FW9BXzg9os8OLr4f8Dlnk0rYWJIWr4wv9eKUXiQvQwQ==
+  dependencies:
+    "@babel/runtime" "^7.6.3"
+    "@types/react" ">=16.9.11"
+    invariant "^2.2.4"
+    react-lifecycles-compat "^3.0.4"
+
 unherit@^1.0.4:
   version "1.1.2"
   resolved "https://registry.yarnpkg.com/unherit/-/unherit-1.1.2.tgz#14f1f397253ee4ec95cec167762e77df83678449"
@@ -21329,12 +21392,6 @@ walker@^1.0.7:
   dependencies:
     makeerror "1.0.x"
 
-warning@^3.0.0:
-  version "3.0.0"
-  resolved "https://registry.yarnpkg.com/warning/-/warning-3.0.0.tgz#32e5377cb572de4ab04753bdf8821c01ed605b7c"
-  dependencies:
-    loose-envify "^1.0.0"
-
 warning@^4.0.1, warning@^4.0.2:
   version "4.0.2"
   resolved "https://registry.yarnpkg.com/warning/-/warning-4.0.2.tgz#aa6876480872116fa3e11d434b0d0d8d91e44607"