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

Merge branch 'master' into
imprv/88633-do-not-move-under-user-page

kaori 4 лет назад
Родитель
Сommit
96c1ae3b61
100 измененных файлов с 1765 добавлено и 1001 удалено
  1. 6 3
      .github/workflows/ci-app.yml
  2. 5 2
      .github/workflows/reusable-app-prod.yml
  3. 3 2
      .github/workflows/reusable-app-reg-suit.yml
  4. 0 2
      README.md
  5. 0 2
      README_JP.md
  6. 1 1
      lerna.json
  7. 1 1
      package.json
  8. 7 7
      packages/app/package.json
  9. 7 13
      packages/app/resource/locales/en_US/translation.json
  10. 7 13
      packages/app/resource/locales/ja_JP/translation.json
  11. 8 12
      packages/app/resource/locales/zh_CN/translation.json
  12. 0 4
      packages/app/src/client/app.jsx
  13. 6 3
      packages/app/src/client/services/ContextExtractor.tsx
  14. 2 1
      packages/app/src/components/Admin/ElasticsearchManagement/StatusTable.jsx
  15. 1 1
      packages/app/src/components/Admin/Notification/NotificationSetting.jsx
  16. 95 0
      packages/app/src/components/Admin/UserGroup/UserGroupCreateModal.tsx
  17. 35 43
      packages/app/src/components/Admin/UserGroup/UserGroupPage.tsx
  18. 45 31
      packages/app/src/components/Admin/UserGroupDetail/UserGroupDetailPage.tsx
  19. 4 4
      packages/app/src/components/Common/Dropdown/PageItemControl.tsx
  20. 12 32
      packages/app/src/components/DescendantsPageList.tsx
  21. 6 10
      packages/app/src/components/IdenticalPathPage.tsx
  22. 1 1
      packages/app/src/components/Navbar/GlobalSearch.tsx
  23. 20 11
      packages/app/src/components/Navbar/GrowiContextualSubNavigation.tsx
  24. 16 11
      packages/app/src/components/Navbar/SubNavButtons.tsx
  25. 5 4
      packages/app/src/components/Page.jsx
  26. 0 24
      packages/app/src/components/Page/DuplicatedAlert.jsx
  27. 17 12
      packages/app/src/components/Page/NotFoundAlert.tsx
  28. 0 22
      packages/app/src/components/Page/RenamedAlert.jsx
  29. 2 0
      packages/app/src/components/Page/RevisionLoader.jsx
  30. 9 4
      packages/app/src/components/Page/RevisionRenderer.jsx
  31. 17 16
      packages/app/src/components/Page/TrashPageAlert.jsx
  32. 59 17
      packages/app/src/components/PageDeleteModal.tsx
  33. 21 14
      packages/app/src/components/PageDuplicateModal.jsx
  34. 3 4
      packages/app/src/components/PageList/PageList.tsx
  35. 29 22
      packages/app/src/components/PageList/PageListItemL.tsx
  36. 3 0
      packages/app/src/components/PagePathAutoComplete.jsx
  37. 7 5
      packages/app/src/components/PageRenameModal.jsx
  38. 1 0
      packages/app/src/components/PageTimeline.jsx
  39. 22 20
      packages/app/src/components/PrivateLegacyPages.tsx
  40. 13 21
      packages/app/src/components/SearchPage.tsx
  41. 46 8
      packages/app/src/components/SearchPage/SearchResultContent.tsx
  42. 19 16
      packages/app/src/components/SearchPage/SearchResultList.tsx
  43. 5 10
      packages/app/src/components/SearchPage2/SearchPageBase.tsx
  44. 1 1
      packages/app/src/components/SearchTypeahead.tsx
  45. 1 1
      packages/app/src/components/Sidebar.tsx
  46. 87 26
      packages/app/src/components/Sidebar/PageTree/Item.tsx
  47. 60 21
      packages/app/src/components/Sidebar/PageTree/ItemsTree.tsx
  48. 4 1
      packages/app/src/components/Sidebar/SidebarNav.tsx
  49. 2 2
      packages/app/src/components/Sidebar/Tag.tsx
  50. 0 1
      packages/app/src/components/TableOfContents.jsx
  51. 4 0
      packages/app/src/interfaces/page-listing-results.ts
  52. 15 8
      packages/app/src/interfaces/page.ts
  53. 3 2
      packages/app/src/interfaces/search.ts
  54. 2 0
      packages/app/src/interfaces/ui.ts
  55. 4 0
      packages/app/src/server/crowi/index.js
  56. 25 0
      packages/app/src/server/interfaces/page-operation.ts
  57. 2 1
      packages/app/src/server/middlewares/apiv3-form-validator.ts
  58. 118 0
      packages/app/src/server/models/page-operation.ts
  59. 0 3
      packages/app/src/server/models/page-redirect.ts
  60. 54 69
      packages/app/src/server/models/page.ts
  61. 2 1
      packages/app/src/server/routes/apiv3/app-settings.js
  62. 2 1
      packages/app/src/server/routes/apiv3/attachment.js
  63. 31 9
      packages/app/src/server/routes/apiv3/bookmarks.js
  64. 2 1
      packages/app/src/server/routes/apiv3/customize-setting.js
  65. 2 1
      packages/app/src/server/routes/apiv3/export.js
  66. 1 1
      packages/app/src/server/routes/apiv3/forgot-password.js
  67. 2 1
      packages/app/src/server/routes/apiv3/markdown-setting.js
  68. 1 1
      packages/app/src/server/routes/apiv3/notification-setting.js
  69. 27 28
      packages/app/src/server/routes/apiv3/page-listing.ts
  70. 4 3
      packages/app/src/server/routes/apiv3/page.js
  71. 12 14
      packages/app/src/server/routes/apiv3/pages.js
  72. 1 1
      packages/app/src/server/routes/apiv3/personal-setting.js
  73. 2 1
      packages/app/src/server/routes/apiv3/revisions.js
  74. 2 1
      packages/app/src/server/routes/apiv3/search.js
  75. 2 1
      packages/app/src/server/routes/apiv3/security-setting.js
  76. 2 1
      packages/app/src/server/routes/apiv3/share-links.js
  77. 2 1
      packages/app/src/server/routes/apiv3/slack-integration-legacy-settings.js
  78. 2 1
      packages/app/src/server/routes/apiv3/slack-integration-settings.js
  79. 2 1
      packages/app/src/server/routes/apiv3/user-group.js
  80. 2 1
      packages/app/src/server/routes/apiv3/user-ui-settings.ts
  81. 2 1
      packages/app/src/server/routes/apiv3/users.js
  82. 25 13
      packages/app/src/server/routes/page.js
  83. 2 9
      packages/app/src/server/service/page-grant.ts
  84. 66 0
      packages/app/src/server/service/page-operation.ts
  85. 425 206
      packages/app/src/server/service/page.ts
  86. 8 4
      packages/app/src/server/service/search-delegator/private-legacy-pages.ts
  87. 13 6
      packages/app/src/server/service/search.ts
  88. 5 0
      packages/app/src/server/views/layout-growi/not_found.html
  89. 2 9
      packages/app/src/server/views/widget/page_alerts.html
  90. 5 1
      packages/app/src/stores/context.tsx
  91. 22 21
      packages/app/src/stores/modal.tsx
  92. 42 6
      packages/app/src/stores/page.tsx
  93. 4 3
      packages/app/src/stores/ui.tsx
  94. 6 2
      packages/app/src/styles/_page-tree.scss
  95. 68 79
      packages/app/src/styles/_sidebar.scss
  96. 4 0
      packages/app/test/cypress/integration/2-basic-features/access-to-admin-page.spec.ts
  97. 2 0
      packages/app/test/cypress/integration/2-basic-features/access-to-me-page.spec.ts
  98. 2 6
      packages/app/test/cypress/integration/2-basic-features/access-to-page.spec.ts
  99. 9 0
      packages/app/test/cypress/integration/2-basic-features/access-to-special-page.spec.ts
  100. 2 0
      packages/app/test/cypress/integration/2-basic-features/open-page-create-modal.spec.ts

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

@@ -31,8 +31,9 @@ jobs:
         with:
           path: |
             **/node_modules
-          key: node_modules-${{ runner.OS }}-node${{ matrix.node-version }}-${{ hashFiles('**/yarn.lock') }}
+          key: node_modules-${{ runner.OS }}-node${{ matrix.node-version }}-${{ hashFiles('**/yarn.lock') }}-${{ hashFiles('packages/app/package.json') }}
           restore-keys: |
+            node_modules-${{ runner.OS }}-node${{ matrix.node-version }}-${{ hashFiles('**/yarn.lock') }}-
             node_modules-${{ runner.OS }}-node${{ matrix.node-version }}-
 
       - name: lerna bootstrap
@@ -84,8 +85,9 @@ jobs:
         with:
           path: |
             **/node_modules
-          key: node_modules-${{ runner.OS }}-node${{ matrix.node-version }}-${{ hashFiles('**/yarn.lock') }}
+          key: node_modules-${{ runner.OS }}-node${{ matrix.node-version }}-${{ hashFiles('**/yarn.lock') }}-${{ hashFiles('packages/app/package.json') }}
           restore-keys: |
+            node_modules-${{ runner.OS }}-node${{ matrix.node-version }}-${{ hashFiles('**/yarn.lock') }}-
             node_modules-${{ runner.OS }}-node${{ matrix.node-version }}-
 
       - name: lerna bootstrap
@@ -143,8 +145,9 @@ jobs:
         with:
           path: |
             **/node_modules
-          key: node_modules-${{ runner.OS }}-node${{ matrix.node-version }}-${{ hashFiles('**/yarn.lock') }}
+          key: node_modules-${{ runner.OS }}-node${{ matrix.node-version }}-${{ hashFiles('**/yarn.lock') }}-${{ hashFiles('packages/app/package.json') }}
           restore-keys: |
+            node_modules-${{ runner.OS }}-node${{ matrix.node-version }}-${{ hashFiles('**/yarn.lock') }}-
             node_modules-${{ runner.OS }}-node${{ matrix.node-version }}-
 
       - name: lerna bootstrap

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

@@ -37,8 +37,9 @@ jobs:
       with:
         path: |
           **/node_modules
-        key: node_modules-${{ runner.OS }}-node${{ inputs.node-version }}-${{ hashFiles('**/yarn.lock') }}
+        key: node_modules-${{ runner.OS }}-node${{ inputs.node-version }}-${{ hashFiles('**/yarn.lock') }}-${{ hashFiles('packages/app/package.json') }}
         restore-keys: |
+          node_modules-${{ runner.OS }}-node${{ inputs.node-version }}-${{ hashFiles('**/yarn.lock') }}-
           node_modules-${{ runner.OS }}-node${{ inputs.node-version }}-
 
     - name: lerna bootstrap
@@ -124,6 +125,7 @@ jobs:
           **/node_modules
         key: node_modules-build-prod-${{ runner.OS }}-node${{ inputs.node-version }}-${{ steps.get-date.outputs.dateYmdHM }}
         restore-keys: |
+          node_modules-${{ runner.OS }}-node${{ inputs.node-version }}-${{ hashFiles('**/yarn.lock') }}-${{ hashFiles('packages/app/package.json') }}
           node_modules-${{ runner.OS }}-node${{ inputs.node-version }}-${{ hashFiles('**/yarn.lock') }}
           node_modules-${{ runner.OS }}-node${{ inputs.node-version }}-
           node_modules-build-prod-${{ runner.OS }}-node${{ inputs.node-version }}-${{ steps.get-date.outputs.dateYm }}
@@ -212,8 +214,9 @@ jobs:
           **/node_modules
           ~/.cache/Cypress
           ${{ steps.yarn-cache-dir.outputs.value }}
-        key: deps-for-cypress-${{ runner.OS }}-node${{ inputs.node-version }}-${{ hashFiles('**/yarn.lock') }}
+        key: deps-for-cypress-${{ runner.OS }}-node${{ inputs.node-version }}-${{ hashFiles('**/yarn.lock') }}-${{ hashFiles('packages/app/package.json') }}
         restore-keys: |
+          deps-for-cypress-${{ runner.OS }}-node${{ inputs.node-version }}-${{ hashFiles('**/yarn.lock') }}-
           deps-for-cypress-${{ runner.OS }}-node${{ inputs.node-version }}
 
     - name: lerna bootstrap

+ 3 - 2
.github/workflows/reusable-app-reg-suit.yml

@@ -61,9 +61,10 @@ jobs:
       with:
         path: |
           **/node_modules
-        key: node_modules-${{ runner.OS }}-node${{ inputs.node-version }}-${{ hashFiles('**/yarn.lock') }}
+        key: node_modules-${{ runner.OS }}-node${{ matrix.node-version }}-${{ hashFiles('**/yarn.lock') }}-${{ hashFiles('packages/app/package.json') }}
         restore-keys: |
-          node_modules-${{ runner.OS }}-node${{ inputs.node-version }}-
+          node_modules-${{ runner.OS }}-node${{ matrix.node-version }}-${{ hashFiles('**/yarn.lock') }}-
+          node_modules-${{ runner.OS }}-node${{ matrix.node-version }}-
 
     - name: lerna bootstrap
       run: |

+ 0 - 2
README.md

@@ -17,8 +17,6 @@
 # GROWI
 
 [![Actions Status](https://github.com/weseek/growi/workflows/Node%20CI/badge.svg)](https://github.com/weseek/growi/actions)
-[![dependencies status](https://david-dm.org/weseek/growi.svg)](https://david-dm.org/weseek/growi)
-[![devDependencies Status](https://david-dm.org/weseek/growi/dev-status.svg)](https://david-dm.org/weseek/growi?type=dev)
 [![docker pulls](https://img.shields.io/docker/pulls/weseek/growi.svg)](https://hub.docker.com/r/weseek/growi/)
 
 |                                                     demonstration                                                     |

+ 0 - 2
README_JP.md

@@ -16,8 +16,6 @@
 # GROWI
 
 [![Actions Status](https://github.com/weseek/growi/workflows/Node%20CI/badge.svg)](https://github.com/weseek/growi/actions)
-[![dependencies status](https://david-dm.org/weseek/growi.svg)](https://david-dm.org/weseek/growi)
-[![devDependencies Status](https://david-dm.org/weseek/growi/dev-status.svg)](https://david-dm.org/weseek/growi?type=dev)
 [![docker pulls](https://img.shields.io/docker/pulls/weseek/growi.svg)](https://hub.docker.com/r/weseek/growi/)
 
 |                                                 デモンストレーション                                                 |

+ 1 - 1
lerna.json

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

+ 1 - 1
package.json

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

+ 7 - 7
packages/app/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@growi/app",
-  "version": "5.0.0-RC.0",
+  "version": "5.0.0-RC.8",
   "license": "MIT",
   "scripts": {
     "//// for production": "",
@@ -60,11 +60,11 @@
     "@browser-bunyan/console-formatted-stream": "^1.6.2",
     "@godaddy/terminus": "^4.9.0",
     "@google-cloud/storage": "^5.8.5",
-    "@growi/codemirror-textlint": "^5.0.0-RC.0",
-    "@growi/plugin-attachment-refs": "^5.0.0-RC.0",
-    "@growi/plugin-lsx": "^5.0.0-RC.0",
-    "@growi/plugin-pukiwiki-like-linker": "^5.0.0-RC.0",
-    "@growi/slack": "^5.0.0-RC.0",
+    "@growi/codemirror-textlint": "^5.0.0-RC.8",
+    "@growi/plugin-attachment-refs": "^5.0.0-RC.8",
+    "@growi/plugin-lsx": "^5.0.0-RC.8",
+    "@growi/plugin-pukiwiki-like-linker": "^5.0.0-RC.8",
+    "@growi/slack": "^5.0.0-RC.8",
     "@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.0",
+    "@growi/ui": "^5.0.0-RC.8",
     "@handsontable/react": "=2.1.0",
     "@types/compression": "^1.7.0",
     "@types/express": "^4.17.11",

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

@@ -13,7 +13,6 @@
   "Click to copy": "Click to copy",
   "Rename" : "Rename",
   "Move/Rename": "Move/Rename",
-  "Moved": "Moved",
   "Redirected": "Redirected",
   "Unlinked": "Unlinked",
   "Like!": "Like!",
@@ -369,12 +368,8 @@
   "page_page": {
     "notice": {
       "version": "This is not the current version.",
-      "moved": "This page was moved from",
-      "moved_period": ".",
       "redirected": "You are redirected from",
       "redirected_period": ".",
-      "duplicated": "This page was duplicated from",
-      "duplicated_period": ".",
       "unlinked": "Redirect pages to this page have been deleted.",
       "restricted": "Access to this page is restricted",
       "stale": "More than {{count}} year has passed since last update.",
@@ -383,9 +378,6 @@
       "no_deadline":"This page has no expiration date"
     }
   },
-  "page_table_of_contents": {
-    "empty": "Table of Contents is empty"
-  },
   "page_edit": {
     "Show active line": "Show active line",
     "auto_format_table": "Auto format table",
@@ -416,8 +408,8 @@
     "label": {
       "Move/Rename page": "Move/Rename page",
       "New page name": "New page name",
-      "Fail to get subordinated pages": "Fail to get subordinated pages",
-      "Fail to get exist path": "Fail to get exist path",
+      "Failed to get subordinated pages": "Failed to get subordinated pages",
+      "Failed to get exist path": "Failed to get exist path",
       "Rename without exist path": "Rename without exist path",
       "Current page name": "Current page name",
       "Recursively": "Recursively",
@@ -441,8 +433,9 @@
     "recursively": "Delete pages under this path recursively.",
     "completely": "Delete completely instead of putting it into trash."
   },
-  "deleted_pages": "Page(s) has been deleted",
-  "deleted_pages_completely": "Page(s) has been deleted completely",
+  "deleted_pages": "{{path}} has been deleted",
+  "deleted_pages_completely": "{{path}} has been deleted completely",
+  "renamed_pages": "{{path}} has been renamed",
   "modal_empty":{
     "empty_the_trash": "Empty The Trash",
     "notice": "The pages deleted completely are unrecoverable."
@@ -451,7 +444,7 @@
     "label": {
       "Duplicate page": "Duplicate page",
       "New page name": "New page name",
-      "Fail to get subordinated pages": "Fail to get subordinated pages",
+      "Failed to get subordinated pages": "Failed to get subordinated pages",
       "Current page name": "Current page name",
       "Recursively": "Recursively",
       "Duplicate without exist path": "Duplicate without exist path",
@@ -461,6 +454,7 @@
       "recursive": "Duplicate children of under this path recursively"
     }
   },
+  "duplicated_pages": "{{fromPath}} has been duplicated",
   "modal_putback": {
     "label": {
       "Put Back Page": "Put back page",

+ 7 - 13
packages/app/resource/locales/ja_JP/translation.json

@@ -13,7 +13,6 @@
   "Click to copy": "クリックでコピー",
   "Rename": "名前変更",
   "Move/Rename": "移動/名前変更",
-  "Moved": "移動しました",
   "Redirected": "リダイレクトされました",
   "Unlinked": "リダイレクト削除",
   "Like!": "いいね!",
@@ -369,12 +368,8 @@
   "page_page": {
     "notice": {
       "version": "これは現在の版ではありません。",
-      "moved": "このページは",
-      "moved_period":"から移動しました。",
       "redirected": "リダイレクト元 >>",
       "redirected_period":"",
-      "duplicated": "このページは",
-      "duplicated_period": "から複製されました。",
       "unlinked": "このページへのリダイレクトは削除されました。",
       "restricted": "このページの閲覧は制限されています",
       "stale": "このページは最終更新日から{{count}}年以上が経過しています。",
@@ -382,9 +377,6 @@
       "no_deadline": "このページに有効期限は設定されていません。"
     }
   },
-  "page_table_of_contents": {
-    "empty": "目次は空です"
-  },
   "page_edit": {
     "Show active line": "アクティブ行をハイライト",
     "auto_format_table": "表の自動整形",
@@ -415,8 +407,8 @@
     "label": {
       "Move/Rename page": "ページを移動/名前変更する",
       "New page name": "移動先のページ名",
-      "Fail to get subordinated pages": "配下ページの取得に失敗しました",
-      "Fail to get exist path": "存在するパスの取得に失敗しました",
+      "Failed to get subordinated pages": "配下ページの取得に失敗しました",
+      "Failed to get exist path": "存在するパスの取得に失敗しました",
       "Rename without exist path": "存在するパス以外を名前変更する",
       "Current page name": "現在のページ名",
       "Recursively": "再帰的に移動/名前変更",
@@ -440,8 +432,9 @@
     "recursively": "配下のページも削除します",
     "completely": "ゴミ箱を経由せず、完全に削除します"
   },
-  "deleted_pages": "ページをゴミ箱に入れました",
-  "deleted_pages_completely": "ページを完全に削除しました",
+  "deleted_pages": "{{path}} をゴミ箱に入れました",
+  "deleted_pages_completely": "{{path}} を完全に削除しました",
+  "renamed_pages": "{{path}} を移動/名前変更しました",
   "modal_empty":{
     "empty_the_trash": "ゴミ箱を空にする",
     "notice": "完全削除したページは元に戻すことができません"
@@ -450,7 +443,7 @@
     "label": {
       "Duplicate page": "ページを複製する",
       "New page name": "複製後のページ名",
-      "Fail to get subordinated pages": "配下ページの取得に失敗しました",
+      "Failed to get subordinated pages": "配下ページの取得に失敗しました",
       "Current page name": "現在のページ名",
       "Recursively": "再帰的に複製",
       "Duplicate without exist path": "存在するパス以外を複製する",
@@ -460,6 +453,7 @@
       "recursive": "配下のページも複製します"
     }
   },
+  "duplicated_pages": "{{fromPath}} を複製しました",
   "modal_putback": {
     "label": {
       "Put Back Page": "ページを元に戻す",

+ 8 - 12
packages/app/resource/locales/zh_CN/translation.json

@@ -14,7 +14,6 @@
 	"Click to copy": "点击复制",
   "Rename": "重命名",
 	"Move/Rename": "移动/重命名",
-	"Moved": "移动",
 	"Redirected": "重定向",
 	"Unlinked": "Unlinked",
 	"Like!": "Like!",
@@ -348,12 +347,8 @@
 	"page_page": {
 		"notice": {
 			"version": "这不是当前版本。",
-			"moved": "此页已从",
-      "moved_period": "",
 			"redirected": "您将从",
       "redirected_period": "",
-			"duplicated": "此页来自",
-      "duplicated_period": "",
 			"unlinked": "将网页重定向到此网页已被删除。",
 			"restricted": "访问此页受到限制",
 			"stale": "自上次更新以来,已超过{{count}年。",
@@ -369,9 +364,6 @@
 			"conflict": "无法保存您所做的更改,因为其他人正在编辑此页。请在重新加载页面后重新编辑受影响的部分。"
 		}
   },
-  "page_table_of_contents": {
-    "empty": "目录为空"
-  },
   "page_comment": {
     "display_the_page_when_posting_this_comment": "Display the page when posting this comment"
   },
@@ -394,8 +386,8 @@
 		"label": {
 			"Move/Rename page": "页面 移动/重命名",
       "New page name": "新建页面名称",
-      "Fail to get subordinated pages": "Fail to get subordinated pages",
-      "Fail to get exist path": "Fail to get exist path",
+      "Failed to get subordinated pages": "Failed to get subordinated pages",
+      "Failed to get exist path": "Failed to get exist path",
       "Rename without exist path": "Rename without exist path",
 			"Current page name": "当前页面名称",
 			"Recursively": "递归地",
@@ -419,6 +411,9 @@
 		"recursively": "Delete children of <code>%s</code> recursively.",
 		"completely": "Delete completely instead of putting it into trash."
   },
+  "deleted_pages": "将 {{path}} 放入垃圾箱",
+  "deleted_pages_completely": "{{path}} 已被完全删除",
+  "renamed_pages": "移动/重命名 {{path}}",
 	"modal_empty": {
 		"empty_the_trash": "Empty The Trash",
 		"notice": "完全删除的页面是不可恢复的。"
@@ -427,7 +422,7 @@
 		"label": {
 			"Duplicate page": "Duplicate page",
       "New page name": "New page name",
-      "Fail to get subordinated pages": "Fail to get subordinated pages",
+      "Failed to get subordinated pages": "Failed to get subordinated pages",
 			"Current page name": "Current page name",
       "Recursively": "Recursively",
       "Duplicate without exist path": "Duplicate without exist path",
@@ -436,7 +431,8 @@
     "help": {
       "recursive": "Duplicate children of under this path recursively"
     }
-	},
+  },
+  "duplicated_pages": "{{fromPath}} 已重复",
 	"modal_putback": {
 		"label": {
 			"Put Back Page": "Put back page",

+ 0 - 4
packages/app/src/client/app.jsx

@@ -23,9 +23,7 @@ import PageContentFooter from '../components/PageContentFooter';
 import PageTimeline from '../components/PageTimeline';
 import CommentEditorLazyRenderer from '../components/PageComment/CommentEditorLazyRenderer';
 import ShareLinkAlert from '../components/Page/ShareLinkAlert';
-import DuplicatedAlert from '../components/Page/DuplicatedAlert';
 import RedirectedAlert from '../components/Page/RedirectedAlert';
-import RenamedAlert from '../components/Page/RenamedAlert';
 import TrashPageList from '../components/TrashPageList';
 import TrashPageAlert from '../components/Page/TrashPageAlert';
 import NotFoundPage from '../components/NotFoundPage';
@@ -113,9 +111,7 @@ Object.assign(componentMappings, {
   'grw-fab-container': <Fab />,
 
   'share-link-alert': <ShareLinkAlert />,
-  'duplicated-alert': <DuplicatedAlert />,
   'redirected-alert': <RedirectedAlert />,
-  'renamed-alert': <RenamedAlert />,
   'not-found-alert': <NotFoundAlert
     isGuestUserMode={appContainer.isGuestUser}
   />,

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

@@ -7,7 +7,7 @@ import {
   useCurrentPageId, usePageIdOnHackmd, usePageUser, useCurrentPagePath, useRevisionCreatedAt, useRevisionId, useRevisionIdHackmdSynced,
   useShareLinkId, useShareLinksNumber, useTemplateTagData, useCurrentUpdatedAt, useCreator, useRevisionAuthor, useCurrentUser, useTargetAndAncestors,
   useSlackChannels, useNotFoundTargetPathOrId, useIsSearchPage, useIsForbidden, useIsIdenticalPath,
-  useIsAclEnabled, useIsSearchServiceConfigured, useIsSearchServiceReachable, useIsEnabledAttachTitleHeader,
+  useIsAclEnabled, useIsSearchServiceConfigured, useIsSearchServiceReachable, useIsEnabledAttachTitleHeader, useIsNotFoundPermalink,
 } from '../../stores/context';
 import {
   useIsDeviceSmallerThanMd, useIsDeviceSmallerThanLg,
@@ -23,7 +23,8 @@ const jsonNull = 'null';
 const ContextExtractorOnce: FC = () => {
 
   const mainContent = document.querySelector('#content-main');
-  const notFoundContent = document.getElementById('growi-pagetree-not-found-context');
+  const notFoundContentForPt = document.getElementById('growi-pagetree-not-found-context');
+  const notFoundContent = document.getElementById('growi-not-found-context');
   const forbiddenContent = document.getElementById('forbidden-page');
 
   /*
@@ -76,7 +77,8 @@ const ContextExtractorOnce: FC = () => {
   const creator = JSON.parse(mainContent?.getAttribute('data-page-creator') || jsonNull);
   const revisionAuthor = JSON.parse(mainContent?.getAttribute('data-page-revision-author') || jsonNull);
   const targetAndAncestors = JSON.parse(document.getElementById('growi-pagetree-target-and-ancestors')?.textContent || jsonNull);
-  const notFoundTargetPathOrId = JSON.parse(notFoundContent?.getAttribute('data-not-found-target-path-or-id') || jsonNull);
+  const notFoundTargetPathOrId = JSON.parse(notFoundContentForPt?.getAttribute('data-not-found-target-path-or-id') || jsonNull);
+  const isNotFoundPermalink = JSON.parse(notFoundContent?.getAttribute('data-is-not-found-permalink') || jsonNull);
   const slackChannels = mainContent?.getAttribute('data-slack-channels') || '';
   const isSearchPage = document.getElementById('search-page') != null;
 
@@ -132,6 +134,7 @@ const ContextExtractorOnce: FC = () => {
   useRevisionAuthor(revisionAuthor);
   useTargetAndAncestors(targetAndAncestors);
   useNotFoundTargetPathOrId(notFoundTargetPathOrId);
+  useIsNotFoundPermalink(isNotFoundPermalink);
   useIsSearchPage(isSearchPage);
 
   // Navigation

+ 2 - 1
packages/app/src/components/Admin/ElasticsearchManagement/StatusTable.jsx

@@ -27,7 +27,8 @@ class StatusTable extends React.PureComponent {
     }
     else {
       connectionStatusLabel = isConnected
-        ? <span className="badge badge-pill badge-success">{ t('full_text_search_management.connection_status_label_connected') }</span>
+        // eslint-disable-next-line max-len
+        ? <span data-testid="connection-status-badge-connected" className="badge badge-pill badge-success">{ t('full_text_search_management.connection_status_label_connected') }</span>
         : <span className="badge badge-pill badge-danger">{ t('full_text_search_management.connection_status_label_disconnected') }</span>;
     }
 

+ 1 - 1
packages/app/src/components/Admin/Notification/NotificationSetting.jsx

@@ -54,7 +54,7 @@ const SlackIntegrationListItem = ({ isEnabled, currentBotType }) => {
   const isCautionVisible = currentBotType === SlackbotType.OFFICIAL || currentBotType === SlackbotType.CUSTOM_WITH_PROXY;
 
   return (
-    <li className="list-group-item">
+    <li data-testid="slack-integration-list-item" className="list-group-item">
       <h4>
         <Badge isEnabled={isEnabled} />
         <a href="/admin/slack-integration" className="ml-2">{t('slack_integration')}</a>

+ 95 - 0
packages/app/src/components/Admin/UserGroup/UserGroupCreateModal.tsx

@@ -0,0 +1,95 @@
+import React, { FC, useState, useCallback } from 'react';
+import {
+  Modal, ModalHeader, ModalBody, ModalFooter,
+} from 'reactstrap';
+import { useTranslation } from 'react-i18next';
+
+import { IUserGroup, IUserGroupHasId } from '~/interfaces/user';
+import { CustomWindow } from '~/interfaces/global';
+import Xss from '~/services/xss';
+
+type Props = {
+  userGroup?: IUserGroupHasId,
+  onClickCreateButton?: (userGroupData: Partial<IUserGroup>) => Promise<IUserGroupHasId | void>
+  isShow?: boolean
+  onHide?: () => Promise<void> | void
+};
+
+const UserGroupCreateModal: FC<Props> = (props: Props) => {
+  const xss: Xss = (window as CustomWindow).xss;
+
+  const { t } = useTranslation();
+
+  const {
+    userGroup, onClickCreateButton, isShow, onHide,
+  } = props;
+
+  /*
+   * State
+   */
+  const [currentName, setName] = useState(userGroup != null ? userGroup.name : '');
+  const [currentDescription, setDescription] = useState(userGroup != null ? userGroup.description : '');
+
+  /*
+   * Function
+   */
+  const onChangeNameHandler = useCallback((e) => {
+    setName(e.target.value);
+  }, []);
+
+  const onChangeDescriptionHandler = useCallback((e) => {
+    setDescription(e.target.value);
+  }, []);
+
+  const onClickCreateButtonHandler = useCallback(async(e) => {
+    e.preventDefault(); // no reload
+
+    if (onClickCreateButton == null) {
+      return;
+    }
+
+    await onClickCreateButton({ name: currentName, description: currentDescription });
+  }, [currentName, currentDescription, onClickCreateButton]);
+
+  return (
+    <Modal className="modal-md" isOpen={isShow} toggle={onHide}>
+      <ModalHeader tag="h4" toggle={onHide} className="bg-primary text-light">
+        {t('admin:user_group_management.basic_info')}
+      </ModalHeader>
+
+      <ModalBody>
+        <div className="form-group">
+          <label htmlFor="name">
+            {t('admin:user_group_management.group_name')}
+          </label>
+          <input
+            className="form-control"
+            type="text"
+            name="name"
+            placeholder={t('admin:user_group_management.group_example')}
+            value={currentName}
+            onChange={onChangeNameHandler}
+            required
+          />
+        </div>
+
+        <div className="form-group">
+          <label htmlFor="description">
+            {t('Description')}
+          </label>
+          <textarea className="form-control" name="description" value={currentDescription} onChange={onChangeDescriptionHandler} />
+        </div>
+      </ModalBody>
+
+      <ModalFooter>
+        <div className="form-group">
+          <button type="button" className="btn btn-primary" onClick={onClickCreateButtonHandler}>
+            {t('Create')}
+          </button>
+        </div>
+      </ModalFooter>
+    </Modal>
+  );
+};
+
+export default UserGroupCreateModal;

+ 35 - 43
packages/app/src/components/Admin/UserGroup/UserGroupPage.tsx

@@ -1,14 +1,10 @@
-import React, {
-  FC, Fragment, useState, useCallback,
-} from 'react';
+import React, { FC, useState, useCallback } from 'react';
 import { useTranslation } from 'react-i18next';
 
 import UserGroupTable from './UserGroupTable';
-import UserGroupForm from './UserGroupForm';
+import UserGroupCreateModal from './UserGroupCreateModal';
 import UserGroupDeleteModal from './UserGroupDeleteModal';
 
-import { withUnstatedContainers } from '../../UnstatedUtils';
-import AppContainer from '~/client/services/AppContainer';
 import { toastSuccess, toastError } from '~/client/util/apiNotification';
 import { IUserGroup, IUserGroupHasId } from '~/interfaces/user';
 import Xss from '~/services/xss';
@@ -17,11 +13,7 @@ import { apiv3Delete, apiv3Post } from '~/client/util/apiv3-client';
 import { useSWRxUserGroupList, useSWRxChildUserGroupList, useSWRxUserGroupRelationList } from '~/stores/user-group';
 import { useIsAclEnabled } from '~/stores/context';
 
-type Props = {
-  appContainer: AppContainer,
-};
-
-const UserGroupPage: FC<Props> = (props: Props) => {
+const UserGroupPage: FC = () => {
   const xss: Xss = (window as CustomWindow).xss;
   const { t } = useTranslation();
 
@@ -44,11 +36,20 @@ const UserGroupPage: FC<Props> = (props: Props) => {
    * State
    */
   const [selectedUserGroup, setSelectedUserGroup] = useState<IUserGroupHasId | undefined>(undefined); // not null but undefined (to use defaultProps in UserGroupDeleteModal)
+  const [isCreateModalShown, setCreateModalShown] = useState<boolean>(false);
   const [isDeleteModalShown, setDeleteModalShown] = useState<boolean>(false);
 
   /*
    * Functions
    */
+  const showCreateModal = useCallback(() => {
+    setCreateModalShown(true);
+  }, [setCreateModalShown]);
+
+  const hideCreateModal = useCallback(() => {
+    setCreateModalShown(false);
+  }, [setCreateModalShown]);
+
   const syncUserGroupAndRelations = useCallback(async() => {
     try {
       await mutateUserGroups();
@@ -80,7 +81,6 @@ const UserGroupPage: FC<Props> = (props: Props) => {
       await apiv3Post('/user-groups', {
         name: userGroupData.name,
         description: userGroupData.description,
-        parent: userGroupData.parent,
       });
       toastSuccess(t('toaster.update_successed', { target: t('UserGroup') }));
       await mutateUserGroups();
@@ -115,45 +115,37 @@ const UserGroupPage: FC<Props> = (props: Props) => {
       {
         isAclEnabled ? (
           <div className="mb-2">
-            <button type="button" className="btn btn-outline-secondary" data-toggle="collapse" data-target="#createGroupForm">
+            <button type="button" className="btn btn-outline-secondary" onClick={showCreateModal}>
               {t('admin:user_group_management.create_group')}
             </button>
-            <div id="createGroupForm" className="collapse">
-              <UserGroupForm
-                submitButtonLabel={t('Create')}
-                onSubmit={createUserGroup}
-              />
-            </div>
           </div>
         ) : (
           t('admin:user_group_management.deny_create_group')
         )
       }
-      <>
-        <UserGroupTable
-          headerLabel={t('admin:user_group_management.group_list')}
-          userGroups={userGroups}
-          childUserGroups={childUserGroups}
-          isAclEnabled={isAclEnabled ?? false}
-          onDelete={showDeleteModal}
-          userGroupRelations={userGroupRelations}
-        />
-        <UserGroupDeleteModal
-          userGroups={userGroups}
-          deleteUserGroup={selectedUserGroup}
-          onDelete={deleteUserGroupById}
-          isShow={isDeleteModalShown}
-          onShow={showDeleteModal}
-          onHide={hideDeleteModal}
-        />
-      </>
+      <UserGroupCreateModal
+        onClickCreateButton={createUserGroup}
+        isShow={isCreateModalShown}
+        onHide={hideCreateModal}
+      />
+      <UserGroupTable
+        headerLabel={t('admin:user_group_management.group_list')}
+        userGroups={userGroups}
+        childUserGroups={childUserGroups}
+        isAclEnabled={isAclEnabled ?? false}
+        onDelete={showDeleteModal}
+        userGroupRelations={userGroupRelations}
+      />
+      <UserGroupDeleteModal
+        userGroups={userGroups}
+        deleteUserGroup={selectedUserGroup}
+        onDelete={deleteUserGroupById}
+        isShow={isDeleteModalShown}
+        onShow={showDeleteModal}
+        onHide={hideDeleteModal}
+      />
     </div>
   );
 };
 
-/**
- * Wrapper component for using unstated
- */
-const UserGroupPageWrapper = withUnstatedContainers(UserGroupPage, [AppContainer]);
-
-export default UserGroupPageWrapper;
+export default UserGroupPage;

+ 45 - 31
packages/app/src/components/Admin/UserGroupDetail/UserGroupDetailPage.tsx

@@ -5,6 +5,7 @@ import { useTranslation } from 'react-i18next';
 
 import UserGroupForm from '../UserGroup/UserGroupForm';
 import UserGroupTable from '../UserGroup/UserGroupTable';
+import UserGroupCreateModal from '../UserGroup/UserGroupCreateModal';
 import UserGroupDeleteModal from '../UserGroup/UserGroupDeleteModal';
 import UserGroupDropdown from '../UserGroup/UserGroupDropdown';
 import UserGroupUserTable from './UserGroupUserTable';
@@ -33,11 +34,11 @@ const UserGroupDetailPage: FC = () => {
    */
   const [userGroup, setUserGroup] = useState<IUserGroupHasId>(JSON.parse(adminUserGroupDetailElem?.getAttribute('data-user-group') || 'null'));
   const [relatedPages, setRelatedPages] = useState<IPageHasId[]>([]); // For page list
-  const [isUserGroupUserModalOpen, setUserGroupUserModalOpen] = useState<boolean>(false);
   const [searchType, setSearchType] = useState<string>('partial');
   const [isAlsoMailSearched, setAlsoMailSearched] = useState<boolean>(false);
   const [isAlsoNameSearched, setAlsoNameSearched] = useState<boolean>(false);
   const [selectedUserGroup, setSelectedUserGroup] = useState<IUserGroupHasId | undefined>(undefined); // not null but undefined (to use defaultProps in UserGroupDeleteModal)
+  const [isCreateModalShown, setCreateModalShown] = useState<boolean>(false);
   const [isDeleteModalShown, setDeleteModalShown] = useState<boolean>(false);
 
   /*
@@ -86,14 +87,6 @@ const UserGroupDetailPage: FC = () => {
     }
   }, [t, userGroup._id, setUserGroup]);
 
-  const openUserGroupUserModal = useCallback(() => {
-    setUserGroupUserModalOpen(true);
-  }, []);
-
-  const closeUserGroupUserModal = useCallback(() => {
-    setUserGroupUserModalOpen(false);
-  }, []);
-
   const fetchApplicableUsers = useCallback(async(searchWord) => {
     const res = await apiv3Get(`/user-groups/${userGroup._id}/unrelated-users`, {
       searchWord,
@@ -135,10 +128,28 @@ const UserGroupDetailPage: FC = () => {
     }
   };
 
-  // TODO 87614: UserGroup New creation form can be displayed in modal
-  const onClickCreateChildGroupButtonHandler = () => {
-    console.log('button clicked!');
-  };
+  const showCreateModal = useCallback(() => {
+    setCreateModalShown(true);
+  }, [setCreateModalShown]);
+
+  const hideCreateModal = useCallback(() => {
+    setCreateModalShown(false);
+  }, [setCreateModalShown]);
+
+  const createChildUserGroup = useCallback(async(userGroupData: IUserGroup) => {
+    try {
+      await apiv3Post('/user-groups', {
+        name: userGroupData.name,
+        description: userGroupData.description,
+        parentId: userGroup._id,
+      });
+      mutateChildUserGroups();
+      toastSuccess(t('toaster.update_successed', { target: t('UserGroup') }));
+    }
+    catch (err) {
+      toastError(err);
+    }
+  }, [t, userGroup, mutateChildUserGroups]);
 
   const showDeleteModal = useCallback(async(group: IUserGroupHasId) => {
     setSelectedUserGroup(group);
@@ -199,26 +210,29 @@ const UserGroupDetailPage: FC = () => {
       <UserGroupDropdown
         selectableUserGroups={selectableUserGroups}
         onClickAddExistingUserGroupButtonHandler={onClickAddChildButtonHandler}
-        onClickCreateUserGroupButtonHandler={() => onClickCreateChildGroupButtonHandler()}
+        onClickCreateUserGroupButtonHandler={showCreateModal}
+      />
+      <UserGroupCreateModal
+        onClickCreateButton={createChildUserGroup}
+        isShow={isCreateModalShown}
+        onHide={hideCreateModal}
       />
 
-      <>
-        <UserGroupTable
-          userGroups={childUserGroups}
-          childUserGroups={grandChildUserGroups}
-          isAclEnabled={isAclEnabled ?? false}
-          onDelete={showDeleteModal}
-          userGroupRelations={childUserGroupRelations}
-        />
-        <UserGroupDeleteModal
-          userGroups={childUserGroups}
-          deleteUserGroup={selectedUserGroup}
-          onDelete={deleteChildUserGroupById}
-          isShow={isDeleteModalShown}
-          onShow={showDeleteModal}
-          onHide={hideDeleteModal}
-        />
-      </>
+      <UserGroupTable
+        userGroups={childUserGroups}
+        childUserGroups={grandChildUserGroups}
+        isAclEnabled={isAclEnabled ?? false}
+        onDelete={showDeleteModal}
+        userGroupRelations={childUserGroupRelations}
+      />
+      <UserGroupDeleteModal
+        userGroups={childUserGroups}
+        deleteUserGroup={selectedUserGroup}
+        onDelete={deleteChildUserGroupById}
+        isShow={isDeleteModalShown}
+        onShow={showDeleteModal}
+        onHide={hideDeleteModal}
+      />
 
       <h2 className="admin-setting-header mt-4">{t('Page')}</h2>
       <div className="page-list">

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

@@ -88,7 +88,7 @@ const PageItemControlDropdownMenu = React.memo((props: DropdownMenuProps): JSX.E
       return;
     }
     await onClickRevertMenuItem(pageId);
-  }, [onClickRevertMenuItem]);
+  }, [onClickRevertMenuItem, pageId]);
 
 
   // eslint-disable-next-line react-hooks/rules-of-hooks
@@ -212,7 +212,8 @@ export const PageItemControlSubstance = (props: PageItemControlSubstanceProps):
   const shouldFetch = fetchOnInit === true || (!isIPageInfoForOperation(presetPageInfo) && isOpen);
   const shouldMutate = fetchOnInit === true || !isIPageInfoForOperation(presetPageInfo);
 
-  const { data: fetchedPageInfo, mutate: mutatePageInfo } = useSWRxPageInfo(shouldFetch ? pageId : null);
+  const { data: fetchedPageInfo } = useSWRxPageInfo(shouldFetch ? pageId : null);
+  const { mutate: mutatePageInfo } = useSWRxPageInfo(shouldMutate ? pageId : null);
 
   // mutate after handle event
   const bookmarkMenuItemClickHandler = useCallback(async(_pageId: string, _newValue: boolean) => {
@@ -249,8 +250,7 @@ export const PageItemControlSubstance = (props: PageItemControlSubstanceProps):
   }, [onClickDeleteMenuItem, pageId, fetchedPageInfo, presetPageInfo]);
 
   return (
-    <Dropdown isOpen={isOpen} toggle={() => setIsOpen(!isOpen)}>
-
+    <Dropdown isOpen={isOpen} toggle={() => setIsOpen(!isOpen)} data-testid="open-page-item-control-btn">
       { children ?? (
         <DropdownToggle color="transparent" className="border-0 rounded btn-page-item-control">
           <i className="icon-options text-muted"></i>

+ 12 - 32
packages/app/src/components/DescendantsPageList.tsx

@@ -2,7 +2,9 @@ import React, { useCallback, useState } from 'react';
 import { useTranslation } from 'react-i18next';
 import { toastSuccess } from '~/client/util/apiNotification';
 import {
-  IPageHasId, IPageWithMeta,
+  IDataWithMeta,
+  IPageHasId,
+  IPageInfoForOperation,
 } from '~/interfaces/page';
 import { IPagingResult } from '~/interfaces/paging-result';
 import { OnDeletedFunction } from '~/interfaces/ui';
@@ -22,8 +24,8 @@ type SubstanceProps = {
   onPagesDeleted?: OnDeletedFunction,
 }
 
-const convertToIPageWithEmptyMeta = (page: IPageHasId): IPageWithMeta => {
-  return { pageData: page };
+const convertToIDataWithMeta = (page: IPageHasId): IDataWithMeta<IPageHasId> => {
+  return { data: page };
 };
 
 export const DescendantsPageListSubstance = (props: SubstanceProps): JSX.Element => {
@@ -37,41 +39,19 @@ export const DescendantsPageListSubstance = (props: SubstanceProps): JSX.Element
   const { data: isGuestUser } = useIsGuestUser();
 
   const pageIds = pagingResult?.items?.map(page => page._id);
-  const { data: idToPageInfo } = useSWRxPageInfoForList(pageIds);
+  const { injectTo } = useSWRxPageInfoForList(pageIds, true, true);
 
-  let pagingResultWithMeta: IPagingResult<IPageWithMeta> | undefined;
+  let pageWithMetas: IDataWithMeta<IPageHasId, IPageInfoForOperation>[] = [];
 
   // for mutation
   const { advance: advancePt } = usePageTreeTermManager();
 
   // initial data
   if (pagingResult != null) {
-    const pages = pagingResult.items;
-
     // convert without meta at first
-    pagingResultWithMeta = {
-      ...pagingResult,
-      items: pages.map(page => convertToIPageWithEmptyMeta(page)),
-    };
-  }
-
-  // inject data for listing
-  if (pagingResult != null) {
-    const pages = pagingResult.items;
-
-    const pageWithMetas = pages.map((page) => {
-      const pageInfo = (idToPageInfo ?? {})[page._id];
-
-      return {
-        pageData: page,
-        pageMeta: pageInfo,
-      } as IPageWithMeta;
-    });
-
-    pagingResultWithMeta = {
-      ...pagingResult,
-      items: pageWithMetas,
-    };
+    const dataWithMetas = pagingResult.items.map(page => convertToIDataWithMeta(page));
+    // inject data for listing
+    pageWithMetas = injectTo(dataWithMetas);
   }
 
   const pageDeletedHandler: OnDeletedFunction = useCallback((...args) => {
@@ -88,7 +68,7 @@ export const DescendantsPageListSubstance = (props: SubstanceProps): JSX.Element
     setActivePage(selectedPageNumber);
   }
 
-  if (pagingResult == null || pagingResultWithMeta == null) {
+  if (pagingResult == null) {
     return (
       <div className="wiki">
         <div className="text-muted text-center">
@@ -103,7 +83,7 @@ export const DescendantsPageListSubstance = (props: SubstanceProps): JSX.Element
   return (
     <>
       <PageList
-        pages={pagingResultWithMeta}
+        pages={pageWithMetas}
         isEnableActions={!isGuestUser}
         onPagesDeleted={pageDeletedHandler}
       />

+ 6 - 10
packages/app/src/components/IdenticalPathPage.tsx

@@ -3,7 +3,7 @@ import { useTranslation } from 'react-i18next';
 
 import { DevidedPagePath } from '@growi/core';
 
-import { IPageHasId, IPageWithMeta } from '~/interfaces/page';
+import { IPageHasId } from '~/interfaces/page';
 import { useCurrentPagePath, useIsSharedUser } from '~/stores/context';
 import { useDescendantsPageListModal } from '~/stores/modal';
 import { useSWRxPageInfoForList } from '~/stores/page';
@@ -66,10 +66,12 @@ const IdenticalPathPage:FC<IdenticalPathPageProps> = (props: IdenticalPathPagePr
   const { data: currentPath } = useCurrentPagePath();
   const { data: isSharedUser } = useIsSharedUser();
 
-  const { data: idToPageInfoMap } = useSWRxPageInfoForList(pageIds);
+  const { injectTo } = useSWRxPageInfoForList(pageIds, true, true);
 
   const { open: openDescendantPageListModal } = useDescendantsPageListModal();
 
+  const injectedPages = injectTo(pages);
+
   return (
     <div className="d-flex flex-column flex-lg-row-reverse">
 
@@ -95,14 +97,8 @@ const IdenticalPathPage:FC<IdenticalPathPageProps> = (props: IdenticalPathPagePr
 
         <div className="page-list">
           <ul className="page-list-ul list-group list-group-flush">
-            {pages.map((page) => {
-              const pageId = page._id;
-              const pageInfo = (idToPageInfoMap ?? {})[pageId];
-
-              const pageWithMeta: IPageWithMeta = {
-                pageData: page,
-                pageMeta: pageInfo,
-              };
+            {injectedPages.map((pageWithMeta) => {
+              const pageId = pageWithMeta.data._id;
 
               return (
                 <PageListItemL

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

@@ -36,7 +36,7 @@ const GlobalSearch: FC<Props> = (props: Props) => {
   const gotoPage = useCallback((data: IPageWithMeta<IPageSearchMeta>[]) => {
     assert(data.length > 0);
 
-    const page = data[0].pageData; // should be single page selected
+    const page = data[0].data; // should be single page selected
 
     // navigate to page
     if (page != null) {

+ 20 - 11
packages/app/src/components/Navbar/GrowiContextualSubNavigation.tsx

@@ -5,8 +5,8 @@ import PropTypes from 'prop-types';
 
 import { DropdownItem } from 'reactstrap';
 
-import { OnDeletedFunction } from '~/interfaces/ui';
-import { IPageHasId } from '~/interfaces/page';
+import { OnDuplicatedFunction, OnRenamedFunction, OnDeletedFunction } from '~/interfaces/ui';
+import { IPageHasId, IPageWithMeta } from '~/interfaces/page';
 
 import { withUnstatedContainers } from '../UnstatedUtils';
 import EditorContainer from '~/client/services/EditorContainer';
@@ -15,8 +15,8 @@ import {
   useIsAbleToShowPageEditorModeManager, useIsAbleToShowPageAuthors,
 } from '~/stores/ui';
 import {
-  usePageAccessoriesModal, PageAccessoriesModalContents,
-  usePageDuplicateModal, usePageRenameModal, IPageForPageRenameModal, usePageDeleteModal, usePagePresentationModal, IPageForPageDeleteModal,
+  usePageAccessoriesModal, PageAccessoriesModalContents, IPageForPageDuplicateModal,
+  usePageDuplicateModal, usePageRenameModal, IPageForPageRenameModal, usePageDeleteModal, usePagePresentationModal,
 } from '~/stores/modal';
 
 
@@ -67,12 +67,15 @@ const AdditionalMenuItems = (props: AdditionalMenuItemsProps): JSX.Element => {
   const { open: openPresentationModal } = usePagePresentationModal();
   const { open: openAccessoriesModal } = usePageAccessoriesModal();
 
-  const hrefForPresentationModal = '?presentation=1';
+  const hrefForPresentationModal = `${pageId}/?presentation=1`;
 
   return (
     <>
       {/* Presentation */}
-      <DropdownItem onClick={() => openPresentationModal(hrefForPresentationModal)}>
+      <DropdownItem
+        onClick={() => openPresentationModal(hrefForPresentationModal)}
+        data-testid="open-presentation-modal-btn"
+      >
         <i className="icon-fw"><PresentationIcon /></i>
         { t('Presentation Mode') }
       </DropdownItem>
@@ -180,12 +183,18 @@ const GrowiContextualSubNavigation = (props) => {
   // eslint-disable-next-line react-hooks/exhaustive-deps
   }, [pageId]);
 
-  const duplicateItemClickedHandler = useCallback(async(pageId, path) => {
-    openDuplicateModal(pageId, path);
+  const duplicateItemClickedHandler = useCallback(async(page: IPageForPageDuplicateModal) => {
+    const duplicatedHandler: OnDuplicatedFunction = (fromPath, toPath) => {
+      window.location.href = toPath;
+    };
+    openDuplicateModal(page, { onDuplicated: duplicatedHandler });
   }, [openDuplicateModal]);
 
   const renameItemClickedHandler = useCallback(async(page: IPageForPageRenameModal) => {
-    openRenameModal(page);
+    const renamedHandler: OnRenamedFunction = () => {
+      window.location.reload();
+    };
+    openRenameModal(page, { onRenamed: renamedHandler });
   }, [openRenameModal]);
 
   const onDeletedHandler: OnDeletedFunction = useCallback((pathOrPathsToDelete, isRecursively, isCompletely) => {
@@ -204,8 +213,8 @@ const GrowiContextualSubNavigation = (props) => {
     }
   }, []);
 
-  const deleteItemClickedHandler = useCallback((pageToDelete: IPageForPageDeleteModal) => {
-    openDeleteModal([pageToDelete], { onDeleted: onDeletedHandler });
+  const deleteItemClickedHandler = useCallback((pageWithMeta: IPageWithMeta) => {
+    openDeleteModal([pageWithMeta], { onDeleted: onDeletedHandler });
   }, [onDeletedHandler, openDeleteModal]);
 
   const templateMenuItemClickHandler = useCallback(() => {

+ 16 - 11
packages/app/src/components/Navbar/SubNavButtons.tsx

@@ -1,12 +1,14 @@
 import React, { useCallback } from 'react';
 
-import { IPageInfoAll, isIPageInfoForEntity, isIPageInfoForOperation } from '~/interfaces/page';
+import {
+  IPageInfoAll, IPageToDeleteWithMeta, isIPageInfoForEntity, isIPageInfoForOperation,
+} from '~/interfaces/page';
 
 import { useSWRxPageInfo } from '../../stores/page';
 import { useSWRBookmarkInfo } from '../../stores/bookmark';
 import { useSWRxUsersList } from '../../stores/user';
 import { useIsGuestUser } from '~/stores/context';
-import { IPageForPageDeleteModal, IPageForPageRenameModal } from '~/stores/modal';
+import { IPageForPageRenameModal, IPageForPageDuplicateModal } from '~/stores/modal';
 
 import SubscribeButton from '../SubscribeButton';
 import LikeButtons from '../LikeButtons';
@@ -24,9 +26,9 @@ type CommonProps = {
   showPageControlDropdown?: boolean,
   forceHideMenuItems?: ForceHideMenuItems,
   additionalMenuItemRenderer?: React.FunctionComponent<AdditionalMenuItemsRendererProps>,
-  onClickDuplicateMenuItem?: (pageId: string, path: string) => void,
+  onClickDuplicateMenuItem?: (pageToDuplicate: IPageForPageDuplicateModal) => void,
   onClickRenameMenuItem?: (pageToRename: IPageForPageRenameModal) => void,
-  onClickDeleteMenuItem?: (pageToDelete: IPageForPageDeleteModal) => void,
+  onClickDeleteMenuItem?: (pageToDelete: IPageToDeleteWithMeta) => void,
 }
 
 type SubNavButtonsSubstanceProps = CommonProps & {
@@ -100,8 +102,9 @@ const SubNavButtonsSubstance = (props: SubNavButtonsSubstanceProps): JSX.Element
     if (onClickDuplicateMenuItem == null || path == null) {
       return;
     }
+    const page: IPageForPageDuplicateModal = { pageId, path };
 
-    onClickDuplicateMenuItem(pageId, path);
+    onClickDuplicateMenuItem(page);
   }, [onClickDuplicateMenuItem, pageId, path]);
 
   const renameMenuItemClickHandler = useCallback(async(_pageId: string): Promise<void> => {
@@ -117,15 +120,17 @@ const SubNavButtonsSubstance = (props: SubNavButtonsSubstanceProps): JSX.Element
       return;
     }
 
-    const pageToDelete: IPageForPageDeleteModal = {
-      pageId,
-      revisionId,
-      path,
-      isAbleToDeleteCompletely: pageInfo.isAbleToDeleteCompletely,
+    const pageToDelete: IPageToDeleteWithMeta = {
+      data: {
+        _id: pageId,
+        revision: revisionId,
+        path,
+      },
+      meta: pageInfo,
     };
 
     onClickDeleteMenuItem(pageToDelete);
-  }, [onClickDeleteMenuItem, pageId, pageInfo.isAbleToDeleteCompletely, path, revisionId]);
+  }, [onClickDeleteMenuItem, pageId, pageInfo, path, revisionId]);
 
   if (!isIPageInfoForOperation(pageInfo)) {
     return <></>;

+ 5 - 4
packages/app/src/components/Page.jsx

@@ -21,7 +21,7 @@ import { getOptionsToSave } from '~/client/util/editor';
 
 // TODO: remove this when omitting unstated is completed
 import {
-  useEditorMode, useSelectedGrant, useSelectedGrantGroupId, useSelectedGrantGroupName,
+  EditorMode, useEditorMode, useSelectedGrant, useSelectedGrantGroupId, useSelectedGrantGroupName,
 } from '~/stores/ui';
 import { useIsSlackEnabled } from '~/stores/editor';
 import { useSlackChannels } from '~/stores/context';
@@ -143,14 +143,15 @@ class Page extends React.Component {
   }
 
   render() {
-    const { appContainer, pageContainer } = this.props;
+    const { appContainer, pageContainer, editorMode } = this.props;
     const { isMobile } = appContainer;
     const isLoggedIn = appContainer.currentUser != null;
-    const { markdown } = pageContainer.state;
+    const { markdown, revisionId } = pageContainer.state;
+    const isRenderable = !(editorMode === EditorMode.View && revisionId == null);
 
     return (
       <div className={`mb-5 ${isMobile ? 'page-mobile' : ''}`}>
-        <RevisionRenderer growiRenderer={this.growiRenderer} markdown={markdown} />
+        <RevisionRenderer growiRenderer={this.growiRenderer} markdown={markdown} isRenderable={isRenderable} />
 
         { isLoggedIn && (
           <>

+ 0 - 24
packages/app/src/components/Page/DuplicatedAlert.jsx

@@ -1,24 +0,0 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-import { withTranslation } from 'react-i18next';
-
-
-const DuplicatedAlert = (props) => {
-  const { t } = props;
-  const urlParams = new URLSearchParams(window.location.search);
-  const fromPath = urlParams.get('duplicated');
-
-  return (
-    <div className="alert alert-success py-3 px-4">
-      <strong>
-        { t('Duplicated') }: {t('page_page.notice.duplicated')} <code>{fromPath}</code> {t('page_page.notice.duplicated_period')}
-      </strong>
-    </div>
-  );
-};
-
-DuplicatedAlert.propTypes = {
-  t: PropTypes.func.isRequired, // i18next
-};
-
-export default withTranslation()(DuplicatedAlert);

+ 17 - 12
packages/app/src/components/Page/NotFoundAlert.tsx

@@ -1,6 +1,7 @@
 import React, { useCallback } from 'react';
 import { useTranslation } from 'react-i18next';
 import { UncontrolledTooltip } from 'reactstrap';
+import { useIsNotFoundPermalink } from '~/stores/context';
 
 import { EditorMode, useEditorMode } from '~/stores/ui';
 
@@ -14,6 +15,7 @@ const NotFoundAlert = (props: Props): JSX.Element => {
   const { isGuestUserMode } = props;
 
   const { data: editorMode, mutate: mutateEditorMode } = useEditorMode();
+  const { data: isNotFoundPermalink } = useIsNotFoundPermalink(); // TODO: Remove this when renaming on editor is implemented
 
   const isEditorMode = editorMode !== EditorMode.View;
 
@@ -41,19 +43,22 @@ const NotFoundAlert = (props: Props): JSX.Element => {
           <i className="icon-info pr-2 font-weight-bold" aria-hidden="true"></i>
           {t('not_found_page.page_not_exist_alert')}
         </h2>
-        <div id="create-page-btn-wrapper-for-tooltip" className="d-inline-block">
-          <button
-            type="button"
-            className={`pl-3 pr-3 btn bg-info text-white ${isGuestUserMode ? 'disabled' : ''}`}
-            onClick={clickHandler}
-          >
-            <i className="icon-note icon-fw" />
-            {t('not_found_page.Create Page')}
-          </button>
-        </div>
+        {
+          !isNotFoundPermalink && (
+            <div id="create-page-btn-wrapper-for-tooltip" className="d-inline-block">
+              <button
+                type="button"
+                className={`pl-3 pr-3 btn bg-info text-white ${isGuestUserMode ? 'disabled' : ''}`}
+                onClick={clickHandler}
+              >
+                <i className="icon-note icon-fw" />
+                {t('not_found_page.Create Page')}
+              </button>
+            </div>
+          )
+        }
 
-
-        {isGuestUserMode && (
+        {!isNotFoundPermalink && isGuestUserMode && (
           <UncontrolledTooltip placement="bottom" target="create-page-btn-wrapper-for-tooltip" fade={false}>
             {t('Not available for guest')}
           </UncontrolledTooltip>

+ 0 - 22
packages/app/src/components/Page/RenamedAlert.jsx

@@ -1,22 +0,0 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-import { withTranslation } from 'react-i18next';
-
-
-const RenamedAlert = (props) => {
-  const { t } = props;
-  const urlParams = new URLSearchParams(window.location.search);
-  const fromPath = urlParams.get('renamedFrom');
-
-  return (
-    <>
-      <strong>{ t('Moved') }:</strong> {t('page_page.notice.moved')} <code>{fromPath}</code> {t('page_page.notice.moved_period')}
-    </>
-  );
-};
-
-RenamedAlert.propTypes = {
-  t: PropTypes.func.isRequired, // i18next
-};
-
-export default withTranslation()(RenamedAlert);

+ 2 - 0
packages/app/src/components/Page/RevisionLoader.jsx

@@ -107,6 +107,7 @@ class LegacyRevisionLoader extends React.Component {
         growiRenderer={this.props.growiRenderer}
         markdown={markdown}
         highlightKeywords={this.props.highlightKeywords}
+        isRenderable={this.props.isRenderable}
       />
     );
   }
@@ -127,6 +128,7 @@ LegacyRevisionLoader.propTypes = {
   lazy: PropTypes.bool,
   onRevisionLoaded: PropTypes.func,
   highlightKeywords: PropTypes.arrayOf(PropTypes.string),
+  isRenderable: PropTypes.bool,
 };
 
 const RevisionLoader = (props) => {

+ 9 - 4
packages/app/src/components/Page/RevisionRenderer.jsx

@@ -33,16 +33,20 @@ class LegacyRevisionRenderer extends React.PureComponent {
   }
 
   componentDidMount() {
-    this.initCurrentRenderingContext();
-    this.renderHtml();
+    const { isRenderable } = this.props;
+
+    if (isRenderable) {
+      this.initCurrentRenderingContext();
+      this.renderHtml();
+    }
   }
 
   componentDidUpdate(prevProps) {
     const { markdown: prevMarkdown, highlightKeywords: prevHighlightKeywords } = prevProps;
-    const { markdown, highlightKeywords } = this.props;
+    const { markdown, isRenderable, highlightKeywords } = this.props;
 
     // render only when props.markdown is updated
-    if (markdown !== prevMarkdown || highlightKeywords !== prevHighlightKeywords) {
+    if ((markdown !== prevMarkdown || highlightKeywords !== prevHighlightKeywords) && isRenderable) {
       this.initCurrentRenderingContext();
       this.renderHtml();
       return;
@@ -172,6 +176,7 @@ LegacyRevisionRenderer.propTypes = {
   appContainer: PropTypes.instanceOf(AppContainer).isRequired,
   growiRenderer: PropTypes.instanceOf(GrowiRenderer).isRequired,
   markdown: PropTypes.string.isRequired,
+  isRenderable: PropTypes.bool,
   highlightKeywords: PropTypes.oneOfType([PropTypes.string, PropTypes.arrayOf(PropTypes.string)]),
   additionalClassName: PropTypes.string,
 };

+ 17 - 16
packages/app/src/components/Page/TrashPageAlert.jsx

@@ -1,4 +1,4 @@
-import React, { useState, useCallback } from 'react';
+import React, { useState } from 'react';
 import PropTypes from 'prop-types';
 
 import { withTranslation } from 'react-i18next';
@@ -8,12 +8,20 @@ import { withUnstatedContainers } from '../UnstatedUtils';
 import AppContainer from '~/client/services/AppContainer';
 import PageContainer from '~/client/services/PageContainer';
 
-import EmptyTrashModal from '../EmptyTrashModal';
-
 import { useCurrentUpdatedAt, useShareLinkId } from '~/stores/context';
 import { usePageDeleteModal, usePutBackPageModal } from '~/stores/modal';
 import { useSWRxPageInfo } from '~/stores/page';
 
+import EmptyTrashModal from '../EmptyTrashModal';
+
+const onDeletedHandler = (pathOrPathsToDelete, isRecursively, isCompletely) => {
+  if (typeof pathOrPathsToDelete !== 'string') {
+    return;
+  }
+
+  window.location.href = '/';
+};
+
 const TrashPageAlert = (props) => {
   const { t, pageContainer } = props;
   const {
@@ -46,26 +54,19 @@ const TrashPageAlert = (props) => {
     openPutBackPageModal(pageId, path);
   }
 
-  const onDeletedHandler = useCallback((pathOrPathsToDelete, isRecursively, isCompletely) => {
-    if (typeof pathOrPathsToDelete !== 'string') {
-      return;
-    }
-
-    const path = pathOrPathsToDelete;
-    window.location.href = path;
-  }, []);
-
   function openPageDeleteModalHandler() {
     const pageToDelete = {
-      pageId,
-      revisionId,
-      path,
+      pageData: {
+        _id: pageId,
+        revision: revisionId,
+        path,
+      },
     };
     openDeleteModal(
       [pageToDelete],
       {
         isAbleToDeleteCompletely: pageInfo.isAbleToDeleteCompletely,
-        onDeletedHandler,
+        onDeleted: onDeletedHandler,
       },
     );
   }

+ 59 - 17
packages/app/src/components/PageDeleteModal.tsx

@@ -7,11 +7,19 @@ import { useTranslation } from 'react-i18next';
 import { apiPost } from '~/client/util/apiv1-client';
 import { apiv3Post } from '~/client/util/apiv3-client';
 import { usePageDeleteModal } from '~/stores/modal';
+import loggerFactory from '~/utils/logger';
 
-import { IDeleteSinglePageApiv1Result, IDeleteManyPageApiv3Result } from '~/interfaces/page';
+import {
+  IDeleteSinglePageApiv1Result, IDeleteManyPageApiv3Result, isIPageInfoForOperation, IPageToDeleteWithMeta, IDataWithMeta, IPageInfoForOperation,
+} from '~/interfaces/page';
+import { HasObjectId } from '~/interfaces/has-object-id';
 
 import ApiErrorMessageList from './PageManagement/ApiErrorMessageList';
 import { isTrashPage } from '^/../core/src/utils/page-path-utils';
+import { useSWRxPageInfoForList } from '~/stores/page';
+
+
+const logger = loggerFactory('growi:cli:PageDeleteModal');
 
 
 const deleteIconAndKey = {
@@ -34,16 +42,32 @@ const PageDeleteModal: FC = () => {
 
   const isOpened = deleteModalData?.isOpened ?? false;
 
-  const isAbleToDeleteCompletely = useMemo(() => {
-    if (deleteModalData != null && deleteModalData.pages != null && deleteModalData.pages.length > 0) {
-      return deleteModalData.pages.every(page => page.isAbleToDeleteCompletely);
+  const notOperatablePages: IPageToDeleteWithMeta[] = (deleteModalData?.pages ?? [])
+    .filter(p => !isIPageInfoForOperation(p.meta));
+  const notOperatablePageIds = notOperatablePages.map(p => p.data._id);
+
+  const { injectTo } = useSWRxPageInfoForList(notOperatablePageIds);
+
+  // inject IPageInfo to operate
+  let injectedPages: IDataWithMeta<HasObjectId & { path: string }, IPageInfoForOperation>[] | null = null;
+  if (deleteModalData?.pages != null && notOperatablePageIds.length > 0) {
+    injectedPages = injectTo(deleteModalData?.pages);
+  }
+
+  // calculate conditions to delete
+  const [isDeletable, isAbleToDeleteCompletely] = useMemo(() => {
+    if (injectedPages != null && injectedPages.length > 0) {
+      const isDeletable = injectedPages.every(pageWithMeta => pageWithMeta.meta?.isDeletable);
+      const isAbleToDeleteCompletely = injectedPages.every(pageWithMeta => pageWithMeta.meta?.isAbleToDeleteCompletely);
+      return [isDeletable, isAbleToDeleteCompletely];
     }
-    return true;
-  }, [deleteModalData]);
+    return [true, true];
+  }, [injectedPages]);
 
+  // calculate condition to determine modal status
   const forceDeleteCompletelyMode = useMemo(() => {
     if (deleteModalData != null && deleteModalData.pages != null && deleteModalData.pages.length > 0) {
-      return deleteModalData.pages.every(page => isTrashPage(page.path));
+      return deleteModalData.pages.every(pageWithMeta => isTrashPage(pageWithMeta.data?.path ?? ''));
     }
     return false;
   }, [deleteModalData]);
@@ -71,6 +95,11 @@ const PageDeleteModal: FC = () => {
       return;
     }
 
+    if (!isDeletable) {
+      logger.error('At least one page is not deletable.');
+      return;
+    }
+
     /*
      * When multiple pages
      */
@@ -80,7 +109,7 @@ const PageDeleteModal: FC = () => {
         const isCompletely = isDeleteCompletely === true ? true : undefined;
 
         const pageIdToRevisionIdMap = {};
-        deleteModalData.pages.forEach((p) => { pageIdToRevisionIdMap[p.pageId] = p.revisionId });
+        deleteModalData.pages.forEach((p) => { pageIdToRevisionIdMap[p.data._id] = p.data.revision as string });
 
         const { data } = await apiv3Post<IDeleteManyPageApiv3Result>('/pages/delete', {
           pageIdToRevisionIdMap,
@@ -92,6 +121,8 @@ const PageDeleteModal: FC = () => {
         if (onDeleted != null) {
           onDeleted(data.paths, data.isRecursively, data.isCompletely);
         }
+
+        closeDeleteModal();
       }
       catch (err) {
         setErrs([err]);
@@ -105,11 +136,11 @@ const PageDeleteModal: FC = () => {
         const recursively = isDeleteRecursively === true ? true : undefined;
         const completely = forceDeleteCompletelyMode || isDeleteCompletely ? true : undefined;
 
-        const page = deleteModalData.pages[0];
+        const page = deleteModalData.pages[0].data;
 
         const { path, isRecursively, isCompletely } = await apiPost('/pages.remove', {
-          page_id: page.pageId,
-          revision_id: page.revisionId,
+          page_id: page._id,
+          revision_id: page.revision,
           recursively,
           completely,
         }) as IDeleteSinglePageApiv1Result;
@@ -118,6 +149,8 @@ const PageDeleteModal: FC = () => {
         if (onDeleted != null) {
           onDeleted(path, isRecursively, isCompletely);
         }
+
+        closeDeleteModal();
       }
       catch (err) {
         setErrs([err]);
@@ -126,7 +159,6 @@ const PageDeleteModal: FC = () => {
   }
 
   async function deleteButtonHandler() {
-    await closeDeleteModal();
     await deletePage();
   }
 
@@ -176,8 +208,13 @@ const PageDeleteModal: FC = () => {
   }
 
   const renderPagePathsToDelete = () => {
-    if (deleteModalData != null && deleteModalData.pages != null) {
-      return deleteModalData.pages.map(page => <div key={page.pageId}><code>{ page.path }</code></div>);
+    if (injectedPages != null && injectedPages != null) {
+      return injectedPages.map(page => (
+        <div key={page.data._id}>
+          <code>{ page.data.path }</code>
+          { !page.meta?.isDeletable && <span className="ml-3 text-danger"><strong>(CAN NOT TO DELETE)</strong></span> }
+        </div>
+      ));
     }
     return <></>;
   };
@@ -195,12 +232,17 @@ const PageDeleteModal: FC = () => {
           {/* https://redmine.weseek.co.jp/issues/82787 */}
           {renderPagePathsToDelete()}
         </div>
-        {renderDeleteRecursivelyForm()}
-        { !forceDeleteCompletelyMode && renderDeleteCompletelyForm() }
+        { isDeletable && renderDeleteRecursivelyForm()}
+        { isDeletable && !forceDeleteCompletelyMode && renderDeleteCompletelyForm() }
       </ModalBody>
       <ModalFooter>
         <ApiErrorMessageList errs={errs} />
-        <button type="button" className={`btn btn-${deleteIconAndKey[deleteMode].color}`} onClick={deleteButtonHandler}>
+        <button
+          type="button"
+          className={`btn btn-${deleteIconAndKey[deleteMode].color}`}
+          disabled={!isDeletable}
+          onClick={deleteButtonHandler}
+        >
           <i className={`icon-${deleteIconAndKey[deleteMode].icon}`} aria-hidden="true"></i>
           { t(`modal_delete.delete_${deleteIconAndKey[deleteMode].translationKey}`) }
         </button>

+ 21 - 14
packages/app/src/components/PageDuplicateModal.jsx

@@ -27,9 +27,10 @@ const PageDuplicateModal = (props) => {
   const config = appContainer.getConfig();
   const isReachable = config.isSearchServiceReachable;
   const { crowi } = appContainer.config;
-  const { data: pagesDataToDuplicate, close: closeDuplicateModal } = usePageDuplicateModal();
+  const { data: duplicateModalData, close: closeDuplicateModal } = usePageDuplicateModal();
 
-  const { isOpened, path, pageId } = pagesDataToDuplicate;
+  const { isOpened, page } = duplicateModalData;
+  const { pageId, path } = page;
 
   const [pageNameInput, setPageNameInput] = useState(path);
 
@@ -40,7 +41,7 @@ const PageDuplicateModal = (props) => {
   const [isDuplicateRecursivelyWithoutExistPath, setIsDuplicateRecursivelyWithoutExistPath] = useState(true);
   const [existingPaths, setExistingPaths] = useState([]);
 
-  const checkExistPaths = async(newParentPath) => {
+  const checkExistPaths = useCallback(async(newParentPath) => {
     try {
       const res = await appContainer.apiv3Get('/page/exist-paths', { fromPath: path, toPath: newParentPath });
       const { existPaths } = res.data;
@@ -50,15 +51,15 @@ const PageDuplicateModal = (props) => {
       setErrs(err);
       toastError(t('modal_rename.label.Fail to get exist path'));
     }
-  };
+  }, [appContainer, path, t]);
 
-  // eslint-disable-next-line react-hooks/exhaustive-deps
-  const checkExistPathsDebounce = useCallback(
-    debounce(1000, checkExistPaths), [pageId, path],
-  );
+
+  const checkExistPathsDebounce = useCallback(() => {
+    debounce(1000, checkExistPaths);
+  }, [checkExistPaths]);
 
   useEffect(() => {
-    if (pageId != null && pageNameInput !== path) {
+    if (pageId != null && path != null && pageNameInput !== path) {
       checkExistPathsDebounce(pageNameInput, subordinatedPages);
     }
   }, [pageNameInput, subordinatedPages, path, pageId, checkExistPathsDebounce]);
@@ -88,12 +89,11 @@ const PageDuplicateModal = (props) => {
   const getSubordinatedList = useCallback(async() => {
     try {
       const res = await appContainer.apiv3Get('/pages/subordinated-list', { path, limit: LIMIT_FOR_LIST });
-      const { subordinatedPaths } = res.data;
-      setSubordinatedPages(subordinatedPaths);
+      setSubordinatedPages(res.data.subordinatedPages);
     }
     catch (err) {
       setErrs(err);
-      toastError(t('modal_duplicate.label.Fail to get subordinated pages'));
+      toastError(t('modal_duplicate.label.Failed to get subordinated pages'));
     }
   }, [appContainer, path, t]);
 
@@ -112,8 +112,15 @@ const PageDuplicateModal = (props) => {
     setErrs(null);
 
     try {
-      await appContainer.apiv3Post('/pages/duplicate', { pageId, pageNameInput, isRecursively: isDuplicateRecursively });
-      window.location.href = encodeURI(`${pageNameInput}?duplicated=${path}`);
+      const { data } = await appContainer.apiv3Post('/pages/duplicate', { pageId, pageNameInput, isRecursively: isDuplicateRecursively });
+      const onDuplicated = duplicateModalData.opts?.onDuplicated;
+      const fromPath = path;
+      const toPath = data.page.path;
+
+      if (onDuplicated != null) {
+        onDuplicated(fromPath, toPath);
+      }
+      closeDuplicateModal();
     }
     catch (err) {
       setErrs(err);

+ 3 - 4
packages/app/src/components/PageList/PageList.tsx

@@ -2,14 +2,13 @@ import React from 'react';
 import { useTranslation } from 'react-i18next';
 
 import { IPageWithMeta } from '~/interfaces/page';
-import { IPagingResult } from '~/interfaces/paging-result';
 import { OnDeletedFunction } from '~/interfaces/ui';
 
 import { PageListItemL } from './PageListItemL';
 
 
 type Props = {
-  pages: IPagingResult<IPageWithMeta>,
+  pages: IPageWithMeta[],
   isEnableActions?: boolean,
   onPagesDeleted?: OnDeletedFunction,
 }
@@ -28,9 +27,9 @@ const PageList = (props: Props): JSX.Element => {
     );
   }
 
-  const pageList = pages.items.map(page => (
+  const pageList = pages.map(page => (
     <PageListItemL
-      key={page.pageData._id}
+      key={page.data._id}
       page={page}
       isEnableActions={isEnableActions}
       onPageDeleted={onPagesDeleted}

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

@@ -11,39 +11,43 @@ import urljoin from 'url-join';
 
 import { UserPicture, PageListMeta } from '@growi/ui';
 import { DevidedPagePath } from '@growi/core';
+
+
+import { ISelectable } from '~/client/interfaces/selectable-all';
+import { bookmark, unbookmark } from '~/client/services/page-operation';
 import { useIsDeviceSmallerThanLg } from '~/stores/ui';
 import {
   usePageRenameModal, usePageDuplicateModal, usePageDeleteModal, usePutBackPageModal,
 } from '~/stores/modal';
 import {
-  IPageInfoAll, IPageWithMeta, isIPageInfoForEntity, isIPageInfoForListing,
+  IPageInfoAll, IPageInfoForEntity, IPageInfoForListing, IPageWithMeta, isIPageInfoForListing,
 } from '~/interfaces/page';
 import { IPageSearchMeta, isIPageSearchMeta } from '~/interfaces/search';
-import { OnDeletedFunction } from '~/interfaces/ui';
+import { OnDuplicatedFunction, OnDeletedFunction } from '~/interfaces/ui';
+import LinkedPagePath from '~/models/linked-page-path';
 
 import { ForceHideMenuItems, PageItemControl } from '../Common/Dropdown/PageItemControl';
-import LinkedPagePath from '~/models/linked-page-path';
 import PagePathHierarchicalLink from '../PagePathHierarchicalLink';
-import { ISelectable } from '~/client/interfaces/selectable-all';
 
 type Props = {
-  page: IPageWithMeta | IPageWithMeta<IPageInfoAll & IPageSearchMeta>,
+  page: IPageWithMeta<IPageInfoForEntity> | IPageWithMeta<IPageSearchMeta> | IPageWithMeta<IPageInfoForListing & IPageSearchMeta>,
   isSelected?: boolean, // is item selected(focused)
   isEnableActions?: boolean,
   forceHideMenuItems?: ForceHideMenuItems,
   showPageUpdatedTime?: boolean, // whether to show page's updated time at the top-right corner of item
   onCheckboxChanged?: (isChecked: boolean, pageId: string) => void,
   onClickItem?: (pageId: string) => void,
+  onPageDuplicated?: OnDuplicatedFunction,
   onPageDeleted?: OnDeletedFunction,
 }
 
 const PageListItemLSubstance: ForwardRefRenderFunction<ISelectable, Props> = (props: Props, ref): JSX.Element => {
   const {
     // todo: refactoring variable name to clear what changed
-    page: { pageData, pageMeta }, isSelected, isEnableActions,
+    page: { data: pageData, meta: pageMeta }, isSelected, isEnableActions,
     forceHideMenuItems,
     showPageUpdatedTime,
-    onClickItem, onCheckboxChanged, onPageDeleted,
+    onClickItem, onCheckboxChanged, onPageDuplicated, onPageDeleted,
   } = props;
 
   const inputRef = useRef<HTMLInputElement>(null);
@@ -92,10 +96,18 @@ const PageListItemLSubstance: ForwardRefRenderFunction<ISelectable, Props> = (pr
     }
   }, [isDeviceSmallerThanLg, onClickItem, pageData._id]);
 
+  const bookmarkMenuItemClickHandler = async(_pageId: string, _newValue: boolean): Promise<void> => {
+    const bookmarkOperation = _newValue ? bookmark : unbookmark;
+    await bookmarkOperation(_pageId);
+  };
+
   const duplicateMenuItemClickHandler = useCallback(() => {
-    const { _id: pageId, path } = pageData;
-    openDuplicateModal(pageId, path);
-  }, [openDuplicateModal, pageData]);
+    const page = {
+      pageId: pageData._id,
+      path: pageData.path,
+    };
+    openDuplicateModal(page, { onDuplicated: onPageDuplicated });
+  }, [onPageDuplicated, openDuplicateModal, pageData._id, pageData.path]);
 
   const renameMenuItemClickHandler = useCallback(() => {
     const page = {
@@ -107,12 +119,8 @@ const PageListItemLSubstance: ForwardRefRenderFunction<ISelectable, Props> = (pr
   }, [openRenameModal, pageData]);
 
 
-  const deleteMenuItemClickHandler = useCallback((_id, pageInfo) => {
-    const { _id: pageId, revision: revisionId, path } = pageData;
-    const isAbleToDeleteCompletely = pageInfo.isAbleToDeleteCompletely;
-    const pageToDelete = {
-      pageId, revisionId: revisionId as string, path, isAbleToDeleteCompletely,
-    };
+  const deleteMenuItemClickHandler = useCallback((_id: string, pageInfo: IPageInfoAll | undefined) => {
+    const pageToDelete = { data: pageData, meta: pageInfo };
 
     // open modal
     openDeleteModal([pageToDelete], { onDeleted: onPageDeleted });
@@ -190,19 +198,18 @@ const PageListItemLSubstance: ForwardRefRenderFunction<ISelectable, Props> = (pr
               </Clamp>
 
               {/* page meta */}
-              { isIPageInfoForEntity(pageMeta) && (
-                <div className="d-none d-md-flex py-0 px-1">
-                  <PageListMeta page={pageData} bookmarkCount={pageMeta.bookmarkCount} shouldSpaceOutIcon />
-                </div>
-              ) }
+              <div className="d-none d-md-flex py-0 px-1">
+                <PageListMeta page={pageData} bookmarkCount={pageMeta?.bookmarkCount} shouldSpaceOutIcon />
+              </div>
 
               {/* doropdown icon includes page control buttons */}
               <div className="item-control ml-auto">
                 <PageItemControl
                   pageId={pageData._id}
-                  pageInfo={pageMeta}
+                  pageInfo={isIPageInfoForListing(pageMeta) ? pageMeta : undefined}
                   isEnableActions={isEnableActions}
                   forceHideMenuItems={forceHideMenuItems}
+                  onClickBookmarkMenuItem={bookmarkMenuItemClickHandler}
                   onClickRenameMenuItem={renameMenuItemClickHandler}
                   onClickDuplicateMenuItem={duplicateMenuItemClickHandler}
                   onClickDeleteMenuItem={deleteMenuItemClickHandler}

+ 3 - 0
packages/app/src/components/PagePathAutoComplete.jsx

@@ -30,6 +30,9 @@ const PagePathAutoComplete = (props) => {
   }
 
   function getKeywordOnInit(path) {
+    if (path == null) {
+      return;
+    }
     return addTrailingSlash
       ? pathUtils.addTrailingSlash(path)
       : pathUtils.removeTrailingSlash(path);

+ 7 - 5
packages/app/src/components/PageRenameModal.jsx

@@ -65,12 +65,11 @@ const PageRenameModal = (props) => {
   const updateSubordinatedList = useCallback(async() => {
     try {
       const res = await apiv3Get('/pages/subordinated-list', { path });
-      const { subordinatedPaths } = res.data;
-      setSubordinatedPages(subordinatedPaths);
+      setSubordinatedPages(res.data.subordinatedPages);
     }
     catch (err) {
       setErrs(err);
-      toastError(t('modal_rename.label.Fail to get subordinated pages'));
+      toastError(t('modal_rename.label.Failed to get subordinated pages'));
     }
   }, [path, t]);
 
@@ -130,12 +129,15 @@ const PageRenameModal = (props) => {
 
       const { page } = response.data;
       const url = new URL(page.path, 'https://dummy');
-      url.searchParams.append('renamedFrom', path);
       if (isRenameRedirect) {
         url.searchParams.append('withRedirect', true);
       }
 
-      window.location.href = `${url.pathname}${url.search}`;
+      const onRenamed = renameModalData.opts?.onRenamed;
+      if (onRenamed != null) {
+        onRenamed(path);
+      }
+      closeRenameModal();
     }
     catch (err) {
       setErrs(err);

+ 1 - 0
packages/app/src/components/PageTimeline.jsx

@@ -85,6 +85,7 @@ class PageTimeline extends React.Component {
                     growiRenderer={this.growiRenderer}
                     pageId={page._id}
                     revisionId={page.revision}
+                    isRenderable
                   />
                 </div>
               </div>

+ 22 - 20
packages/app/src/components/PrivateLegacyPages.tsx

@@ -12,7 +12,7 @@ import AppContainer from '~/client/services/AppContainer';
 import { ISelectableAll, ISelectableAndIndeterminatable } from '~/client/interfaces/selectable-all';
 import { toastSuccess } from '~/client/util/apiNotification';
 import {
-  ISearchConfigurations, useSWRxNamedQuerySearch,
+  useSWRxNamedQuerySearch,
 } from '~/stores/search';
 import {
   ILegacyPrivatePage, useLegacyPrivatePagesMigrationModal,
@@ -125,17 +125,17 @@ export const PrivateLegacyPages = (props: Props): JSX.Element => {
   } = props;
 
 
-  const [configurationsByPagination, setConfigurationsByPagination] = useState<Partial<ISearchConfigurations>>({
-    limit: INITIAL_PAGIONG_SIZE,
-  });
+  const [offset, setOffset] = useState<number>(0);
+  const [limit, setLimit] = useState<number>(INITIAL_PAGIONG_SIZE);
+
   const [isControlEnabled, setControlEnabled] = useState(false);
 
   const selectAllControlRef = useRef<ISelectableAndIndeterminatable|null>(null);
   const searchPageBaseRef = useRef<ISelectableAll & IReturnSelectedPageIds|null>(null);
 
   const { data, conditions, mutate } = useSWRxNamedQuerySearch('PrivateLegacyPages', {
-    limit: INITIAL_PAGIONG_SIZE,
-    ...configurationsByPagination,
+    offset,
+    limit,
   });
 
   const { open: openModal, close: closeModal } = useLegacyPrivatePagesMigrationModal();
@@ -179,7 +179,7 @@ export const PrivateLegacyPages = (props: Props): JSX.Element => {
   }, []);
 
   // for bulk deletion
-  const deleteAllButtonClickedHandler = usePageDeleteModalForBulkDeletion(data, searchPageBaseRef, () => mutate);
+  const deleteAllButtonClickedHandler = usePageDeleteModalForBulkDeletion(data, searchPageBaseRef, () => mutate());
 
   const convertMenuItemClickedHandler = useCallback(() => {
     if (data == null) {
@@ -198,8 +198,8 @@ export const PrivateLegacyPages = (props: Props): JSX.Element => {
     }
 
     const selectedPages = data.data
-      .filter(pageWithMeta => selectedPageIds.has(pageWithMeta.pageData._id))
-      .map(pageWithMeta => ({ pageId: pageWithMeta.pageData._id, path: pageWithMeta.pageData.path } as ILegacyPrivatePage));
+      .filter(pageWithMeta => selectedPageIds.has(pageWithMeta.data._id))
+      .map(pageWithMeta => ({ pageId: pageWithMeta.data._id, path: pageWithMeta.data.path } as ILegacyPrivatePage));
 
     openModal(
       selectedPages,
@@ -211,16 +211,18 @@ export const PrivateLegacyPages = (props: Props): JSX.Element => {
     );
   }, [data, mutate, openModal, closeModal]);
 
+  const pagingSizeChangedHandler = useCallback((pagingSize: number) => {
+    setOffset(0);
+    setLimit(pagingSize);
+    mutate();
+  }, [mutate]);
+
   const pagingNumberChangedHandler = useCallback((activePage: number) => {
-    const currentLimit = configurationsByPagination.limit ?? INITIAL_PAGIONG_SIZE;
-    setConfigurationsByPagination({
-      ...configurationsByPagination,
-      offset: (activePage - 1) * currentLimit,
-    });
-  }, [configurationsByPagination]);
+    setOffset((activePage - 1) * limit);
+    mutate();
+  }, [limit, mutate]);
 
   const hitsCount = data?.meta.hitsCount;
-  const { offset, limit } = conditions;
 
   const searchControl = useMemo(() => {
     const isCheckboxDisabled = hitsCount === 0;
@@ -267,10 +269,10 @@ export const PrivateLegacyPages = (props: Props): JSX.Element => {
         searchResult={data}
         offset={offset}
         pagingSize={limit}
-        onPagingSizeChanged={() => {}}
+        onPagingSizeChanged={pagingSizeChangedHandler}
       />
     );
-  }, [data, limit, offset]);
+  }, [data, limit, offset, pagingSizeChangedHandler]);
 
   const searchPager = useMemo(() => {
     // when pager is not needed
@@ -285,11 +287,11 @@ export const PrivateLegacyPages = (props: Props): JSX.Element => {
       <PaginationWrapper
         activePage={Math.floor(offset / limit) + 1}
         totalItemsCount={total}
-        pagingLimit={configurationsByPagination?.limit}
+        pagingLimit={limit}
         changePage={pagingNumberChangedHandler}
       />
     );
-  }, [conditions, configurationsByPagination?.limit, data, pagingNumberChangedHandler]);
+  }, [conditions, data, pagingNumberChangedHandler]);
 
   return (
     <>

+ 13 - 21
packages/app/src/components/SearchPage.tsx

@@ -109,10 +109,9 @@ export const SearchPage = (props: Props): JSX.Element => {
   const initQ = (Array.isArray(parsedQueries) ? parsedQueries.join(' ') : parsedQueries) ?? '';
 
   const [keyword, setKeyword] = useState<string>(initQ);
+  const [offset, setOffset] = useState<number>(0);
+  const [limit, setLimit] = useState<number>(INITIAL_PAGIONG_SIZE);
   const [configurationsByControl, setConfigurationsByControl] = useState<Partial<ISearchConfigurations>>({});
-  const [configurationsByPagination, setConfigurationsByPagination] = useState<Partial<ISearchConfigurations>>({
-    limit: INITIAL_PAGIONG_SIZE,
-  });
 
   const selectAllControlRef = useRef<ISelectableAndIndeterminatable|null>(null);
   const searchPageBaseRef = useRef<ISelectableAll & IReturnSelectedPageIds|null>(null);
@@ -120,13 +119,14 @@ export const SearchPage = (props: Props): JSX.Element => {
   const { data: isSearchServiceReachable } = useIsSearchServiceReachable();
 
   const { data, conditions, mutate } = useSWRxFullTextSearch(keyword, {
-    limit: INITIAL_PAGIONG_SIZE,
     ...configurationsByControl,
-    ...configurationsByPagination,
+    offset,
+    limit,
   });
 
   const searchInvokedHandler = useCallback((_keyword: string, newConfigurations: Partial<ISearchConfigurations>) => {
     setKeyword(_keyword);
+    setOffset(0);
     setConfigurationsByControl(newConfigurations);
   }, []);
 
@@ -164,21 +164,15 @@ export const SearchPage = (props: Props): JSX.Element => {
   }, []);
 
   const pagingSizeChangedHandler = useCallback((pagingSize: number) => {
-    setConfigurationsByPagination({
-      ...configurationsByPagination,
-      limit: pagingSize,
-    });
+    setOffset(0);
+    setLimit(pagingSize);
     mutate();
-  }, [configurationsByPagination, mutate]);
+  }, [mutate]);
 
   const pagingNumberChangedHandler = useCallback((activePage: number) => {
-    const currentLimit = configurationsByPagination.limit ?? INITIAL_PAGIONG_SIZE;
-    setConfigurationsByPagination({
-      ...configurationsByPagination,
-      offset: (activePage - 1) * currentLimit,
-    });
+    setOffset((activePage - 1) * limit);
     mutate();
-  }, [configurationsByPagination, mutate]);
+  }, [limit, mutate]);
 
   const initialSearchConditions: Partial<ISearchConditions> = useMemo(() => {
     return {
@@ -188,7 +182,7 @@ export const SearchPage = (props: Props): JSX.Element => {
   }, [initQ]);
 
   // for bulk deletion
-  const deleteAllButtonClickedHandler = usePageDeleteModalForBulkDeletion(data, searchPageBaseRef, () => mutate);
+  const deleteAllButtonClickedHandler = usePageDeleteModalForBulkDeletion(data, searchPageBaseRef, () => mutate());
 
   // push state
   useEffect(() => {
@@ -198,8 +192,6 @@ export const SearchPage = (props: Props): JSX.Element => {
   }, [keyword]);
   const hitsCount = data?.meta.hitsCount;
 
-  const { offset, limit } = conditions;
-
   const deleteAllControl = useMemo(() => {
     const isDisabled = hitsCount === 0;
 
@@ -265,11 +257,11 @@ export const SearchPage = (props: Props): JSX.Element => {
       <PaginationWrapper
         activePage={Math.floor(offset / limit) + 1}
         totalItemsCount={total}
-        pagingLimit={configurationsByPagination?.limit}
+        pagingLimit={limit}
         changePage={pagingNumberChangedHandler}
       />
     );
-  }, [conditions, configurationsByPagination?.limit, data, pagingNumberChangedHandler]);
+  }, [conditions, data, pagingNumberChangedHandler]);
 
   return (
     <SearchPageBase

+ 46 - 8
packages/app/src/components/SearchPage/SearchResultContent.tsx

@@ -5,10 +5,15 @@ import { useTranslation } from 'react-i18next';
 
 import { DropdownItem } from 'reactstrap';
 
-import { IPageWithMeta } from '~/interfaces/page';
+import { IPageToDeleteWithMeta, IPageWithMeta } from '~/interfaces/page';
 import { IPageSearchMeta } from '~/interfaces/search';
+import { OnDuplicatedFunction, OnDeletedFunction } from '~/interfaces/ui';
+import { usePageTreeTermManager } from '~/stores/page-listing';
+import { useFullTextSearchTermManager } from '~/stores/search';
+import { useDescendantsPageListForCurrentPathTermManager } from '~/stores/page';
 
 import { exportAsMarkdown } from '~/client/services/page-operation';
+import { toastSuccess } from '~/client/util/apiNotification';
 
 import RevisionLoader from '../Page/RevisionLoader';
 import AppContainer from '../../client/services/AppContainer';
@@ -74,6 +79,11 @@ const generateObserverCallback = (doScroll: ()=>void) => {
 export const SearchResultContent: FC<Props> = (props: Props) => {
   const scrollElementRef = useRef(null);
 
+  // for mutation
+  const { advance: advancePt } = usePageTreeTermManager();
+  const { advance: advanceFts } = useFullTextSearchTermManager();
+  const { advance: advanceDpl } = useDescendantsPageListForCurrentPathTermManager();
+
   // ***************************  Auto Scroll  ***************************
   useEffect(() => {
     const scrollElement = scrollElementRef.current as HTMLElement | null;
@@ -99,7 +109,9 @@ export const SearchResultContent: FC<Props> = (props: Props) => {
     forceHideMenuItems,
   } = props;
 
-  const page = pageWithMeta?.pageData;
+  const { t } = useTranslation();
+
+  const page = pageWithMeta?.data;
   const { open: openDuplicateModal } = usePageDuplicateModal();
   const { open: openRenameModal } = usePageRenameModal();
   const { open: openDeleteModal } = usePageDeleteModal();
@@ -107,17 +119,42 @@ export const SearchResultContent: FC<Props> = (props: Props) => {
   const growiRenderer = appContainer.getRenderer('searchresult');
 
 
-  const duplicateItemClickedHandler = useCallback(async(pageId, path) => {
-    openDuplicateModal(pageId, path);
-  }, [openDuplicateModal]);
+  const duplicateItemClickedHandler = useCallback(async(pageToDuplicate) => {
+    // eslint-disable-next-line @typescript-eslint/no-unused-vars
+    const duplicatedHandler: OnDuplicatedFunction = (fromPath, toPath) => {
+      toastSuccess(t('duplicated_pages', { fromPath }));
+
+      advancePt();
+      advanceFts();
+      advanceDpl();
+    };
+    openDuplicateModal(pageToDuplicate, { onDuplicated: duplicatedHandler });
+  }, [advanceDpl, advanceFts, advancePt, openDuplicateModal, t]);
 
   const renameItemClickedHandler = useCallback(async(pageToRename) => {
     openRenameModal(pageToRename);
   }, [openRenameModal]);
 
-  const deleteItemClickedHandler = useCallback((pageToDelete) => {
-    openDeleteModal([pageToDelete]);
-  }, [openDeleteModal]);
+  const onDeletedHandler: OnDeletedFunction = useCallback((pathOrPathsToDelete, isRecursively, isCompletely) => {
+    if (typeof pathOrPathsToDelete !== 'string') {
+      return;
+    }
+    const path = pathOrPathsToDelete;
+
+    if (isCompletely) {
+      toastSuccess(t('deleted_pages_completely', { path }));
+    }
+    else {
+      toastSuccess(t('deleted_pages', { path }));
+    }
+    advancePt();
+    advanceFts();
+    advanceDpl();
+  }, [advanceDpl, advanceFts, advancePt, t]);
+
+  const deleteItemClickedHandler = useCallback((pageToDelete: IPageToDeleteWithMeta) => {
+    openDeleteModal([pageToDelete], { onDeleted: onDeletedHandler });
+  }, [onDeletedHandler, openDeleteModal]);
 
   const ControlComponents = useCallback(() => {
     if (page == null) {
@@ -170,6 +207,7 @@ export const SearchResultContent: FC<Props> = (props: Props) => {
           pagePath={page.path}
           revisionId={page.revision}
           highlightKeywords={highlightKeywords}
+          isRenderable
         />
       </div>
     </div>

+ 19 - 16
packages/app/src/components/SearchPage/SearchResultList.tsx

@@ -3,7 +3,9 @@ import React, {
   ForwardRefRenderFunction, useCallback, useImperativeHandle, useRef,
 } from 'react';
 import { ISelectable, ISelectableAll } from '~/client/interfaces/selectable-all';
-import { IPageWithMeta, isIPageInfoForListing } from '~/interfaces/page';
+import {
+  IPageInfoForListing, IPageWithMeta, isIPageInfoForListing,
+} from '~/interfaces/page';
 import { IPageSearchMeta } from '~/interfaces/search';
 import { useIsGuestUser } from '~/stores/context';
 import { useSWRxPageInfoForList } from '~/stores/page';
@@ -30,11 +32,11 @@ const SearchResultListSubstance: ForwardRefRenderFunction<ISelectableAll, Props>
   } = props;
 
   const pageIdsWithNoSnippet = pages
-    .filter(page => (page.pageMeta?.elasticSearchResult?.snippet.length ?? 0) === 0)
-    .map(page => page.pageData._id);
+    .filter(page => (page.meta?.elasticSearchResult?.snippet.length ?? 0) === 0)
+    .map(page => page.data._id);
 
   const { data: isGuestUser } = useIsGuestUser();
-  const { data: idToPageInfo } = useSWRxPageInfoForList(pageIdsWithNoSnippet);
+  const { data: idToPageInfo } = useSWRxPageInfoForList(pageIdsWithNoSnippet, true, true);
 
   // for mutation
   const { advance: advancePt } = usePageTreeTermManager();
@@ -60,16 +62,16 @@ const SearchResultListSubstance: ForwardRefRenderFunction<ISelectableAll, Props>
 
   const clickItemHandler = useCallback((pageId: string) => {
     if (onPageSelected != null) {
-      const selectedPage = pages.find(page => page.pageData._id === pageId);
+      const selectedPage = pages.find(page => page.data._id === pageId);
       onPageSelected(selectedPage);
     }
   }, [onPageSelected, pages]);
 
-  let injectedPage;
+  let injectedPages: (IPageWithMeta<IPageSearchMeta> | IPageWithMeta<IPageInfoForListing & IPageSearchMeta>)[] | undefined;
   // inject data to list
   if (idToPageInfo != null) {
-    injectedPage = pages.map((page) => {
-      const pageInfo = idToPageInfo[page.pageData._id];
+    injectedPages = pages.map((page) => {
+      const pageInfo = idToPageInfo[page.data._id];
 
       if (!isIPageInfoForListing(pageInfo)) {
         // return as is
@@ -77,30 +79,31 @@ const SearchResultListSubstance: ForwardRefRenderFunction<ISelectableAll, Props>
       }
 
       return {
-        pageData: page.pageData,
-        pageMeta: {
-          ...page.pageMeta,
-          revisionShortBody: pageInfo.revisionShortBody,
+        data: page.data,
+        meta: {
+          ...page.meta,
+          ...pageInfo,
         },
-      };
+      } as IPageWithMeta<IPageInfoForListing & IPageSearchMeta>;
     });
   }
 
   return (
     <ul data-testid="search-result-list" className="page-list-ul list-group list-group-flush">
-      { (injectedPage ?? pages).map((page, i) => {
+      { (injectedPages ?? pages).map((page, i) => {
         return (
           <PageListItemL
-            key={page.pageData._id}
+            key={page.data._id}
             // eslint-disable-next-line no-return-assign
             ref={c => itemsRef.current[i] = c}
             page={page}
             isEnableActions={!isGuestUser}
-            isSelected={page.pageData._id === selectedPageId}
+            isSelected={page.data._id === selectedPageId}
             forceHideMenuItems={forceHideMenuItems}
             onClickItem={clickItemHandler}
             onCheckboxChanged={props.onCheckboxChanged}
             onPageDeleted={() => { advancePt(); advanceFts() }}
+            onPageDuplicated={() => { advancePt(); advanceFts() }}
           />
         );
       })}

+ 5 - 10
packages/app/src/components/SearchPage2/SearchPageBase.tsx

@@ -1,5 +1,5 @@
 import React, {
-  forwardRef, ForwardRefRenderFunction, useCallback, useEffect, useImperativeHandle, useRef, useState,
+  forwardRef, ForwardRefRenderFunction, useEffect, useImperativeHandle, useRef, useState,
 } from 'react';
 import { useTranslation } from 'react-i18next';
 import { ISelectableAll } from '~/client/interfaces/selectable-all';
@@ -9,7 +9,7 @@ import { IPageWithMeta } from '~/interfaces/page';
 import { IFormattedSearchResult, IPageSearchMeta } from '~/interfaces/search';
 import { OnDeletedFunction } from '~/interfaces/ui';
 import { useIsGuestUser, useIsSearchServiceConfigured, useIsSearchServiceReachable } from '~/stores/context';
-import { IPageForPageDeleteModal, usePageDeleteModal } from '~/stores/modal';
+import { usePageDeleteModal } from '~/stores/modal';
 import { usePageTreeTermManager } from '~/stores/page-listing';
 import { ForceHideMenuItems } from '../Common/Dropdown/PageItemControl';
 
@@ -68,7 +68,7 @@ const SearchPageBaseSubstance: ForwardRefRenderFunction<ISelectableAll & IReturn
       }
 
       if (pages != null) {
-        pages.forEach(page => selectedPageIdsByCheckboxes.add(page.pageData._id));
+        pages.forEach(page => selectedPageIdsByCheckboxes.add(page.data._id));
       }
     },
     deselectAll: () => {
@@ -182,7 +182,7 @@ const SearchPageBaseSubstance: ForwardRefRenderFunction<ISelectableAll & IReturn
                       ref={searchResultListRef}
                       // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
                       pages={pages!}
-                      selectedPageId={selectedPageWithMeta?.pageData._id}
+                      selectedPageId={selectedPageWithMeta?.data._id}
                       forceHideMenuItems={forceHideMenuItems}
                       onPageSelected={page => setSelectedPageWithMeta(page)}
                       onCheckboxChanged={checkboxChangedHandler}
@@ -249,12 +249,7 @@ export const usePageDeleteModalForBulkDeletion = (
     }
 
     const selectedPages = data.data
-      .filter(pageWithMeta => selectedPageIds.has(pageWithMeta.pageData._id))
-      .map(pageWithMeta => ({
-        pageId: pageWithMeta.pageData._id,
-        path: pageWithMeta.pageData.path,
-        revisionId: pageWithMeta.pageData.revision as string,
-      } as IPageForPageDeleteModal));
+      .filter(pageWithMeta => selectedPageIds.has(pageWithMeta.data._id));
 
     openDeleteModal(selectedPages, {
       onDeleted: (...args) => {

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

@@ -190,7 +190,7 @@ const SearchTypeahead: ForwardRefRenderFunction<IFocusable, Props> = (props: Pro
   }
 
   const renderMenuItemChildren = (option: IPageWithMeta<IPageSearchMeta>) => {
-    const { pageData } = option;
+    const { data: pageData } = option;
     return (
       <span>
         <UserPicture user={pageData.lastUpdateUser} size="sm" noLink />

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

@@ -72,7 +72,7 @@ const SidebarContentsWrapper = () => {
       />
 
       <div id="grw-sidebar-contents-scroll-target" style={{ minHeight: '100%' }}>
-        <div id="grw-sidebar-content-container" onLoad={() => setResetKey(Math.random())}>
+        <div id="grw-sidebar-content-container" className="grw-sidebar-content-container" onLoad={() => setResetKey(Math.random())}>
           <SidebarContents />
         </div>
       </div>

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

@@ -10,28 +10,36 @@ import nodePath from 'path';
 
 import { pathUtils, pagePathUtils } from '@growi/core';
 
+import loggerFactory from '~/utils/logger';
+
 import { toastWarning, toastError, toastSuccess } from '~/client/util/apiNotification';
 
 import { useSWRxPageChildren } from '~/stores/page-listing';
 import { apiv3Put, apiv3Post } from '~/client/util/apiv3-client';
-import { IPageForPageRenameModal, IPageForPageDeleteModal } from '~/stores/modal';
+import { IPageForPageRenameModal, IPageForPageDuplicateModal } from '~/stores/modal';
 
 import TriangleIcon from '~/components/Icons/TriangleIcon';
 import { bookmark, unbookmark } from '~/client/services/page-operation';
 import ClosableTextInput, { AlertInfo, AlertType } from '../../Common/ClosableTextInput';
 import { PageItemControl } from '../../Common/Dropdown/PageItemControl';
 import { ItemNode } from './ItemNode';
+import { IPageHasId, IPageInfoAll, IPageToDeleteWithMeta } from '~/interfaces/page';
+
+
+const logger = loggerFactory('growi:cli:Item');
+
 
 interface ItemProps {
   canDrag?:boolean
   isEnableActions: boolean
   itemNode: ItemNode
   targetPathOrId?: string
+  isScrolled: boolean,
   isOpen?: boolean
   isEnabledAttachTitleHeader?: boolean
-  onClickDuplicateMenuItem?(pageId: string, path: string): void
+  onClickDuplicateMenuItem?(pageToDuplicate: IPageForPageDuplicateModal): void
   onClickRenameMenuItem?(pageToRename: IPageForPageRenameModal): void
-  onClickDeleteMenuItem?(pageToDelete: IPageForPageDeleteModal): void
+  onClickDeleteMenuItem?(pageToDelete: IPageToDeleteWithMeta): void
 }
 
 // Utility to mark target
@@ -55,6 +63,37 @@ const bookmarkMenuItemClickHandler = async(_pageId: string, _newValue: boolean):
 };
 
 
+/**
+ * Return new page path after the droppedPagePath is moved under the newParentPagePath
+ * @param droppedPagePath
+ * @param newParentPagePath
+ * @returns
+ */
+const getNewPathAfterMoved = (droppedPagePath: string, newParentPagePath: string): string => {
+  const pageTitle = nodePath.basename(droppedPagePath);
+  return nodePath.join(newParentPagePath, pageTitle);
+};
+
+/**
+ * Return whether the fromPage could be moved under the newParentPage
+ * @param fromPage
+ * @param newParentPage
+ * @param printLog
+ * @returns
+ */
+const canMoveUnderNewParent = (fromPage?: Partial<IPageHasId>, newParentPage?: Partial<IPageHasId>, printLog = false): boolean => {
+  if (fromPage == null || newParentPage == null || fromPage.path == null || newParentPage.path == null) {
+    if (printLog) {
+      logger.warn('Any of page, page.path or droppedPage.path is null');
+    }
+    return false;
+  }
+
+  const newPathAfterMoved = getNewPathAfterMoved(fromPage.path, newParentPage.path);
+  return pagePathUtils.canMoveByPath(fromPage.path, newPathAfterMoved);
+};
+
+
 type ItemCountProps = {
   descendantCount: number
 }
@@ -127,16 +166,18 @@ const Item: FC<ItemProps> = (props: ItemProps) => {
 
   console.log('canDrag', canDrag);
 
-  const pageItemDropHandler = async(item, monitor) => {
-    if (page == null || page.path == null) {
+  const pageItemDropHandler = async(item: ItemNode) => {
+    const { page: droppedPage } = item;
+
+    if (!canMoveUnderNewParent(droppedPage, page, true)) {
       return;
     }
 
-    const { page: droppedPage } = item;
+    if (droppedPage.path == null || page.path == null) {
+      return;
+    }
 
-    const pageTitle = nodePath.basename(droppedPage.path);
-    const newParentPath = page.path;
-    const newPagePath = nodePath.join(newParentPath, pageTitle);
+    const newPagePath = getNewPathAfterMoved(droppedPage.path, page.path);
 
     try {
       await apiv3Put('/pages/rename', {
@@ -165,7 +206,7 @@ const Item: FC<ItemProps> = (props: ItemProps) => {
     }
   };
 
-  const [{ isOver }, drop] = useDrop(() => ({
+  const [{ isOver }, drop] = useDrop<ItemNode, Promise<void>, { isOver: boolean }>(() => ({
     accept: 'PAGE_TREE',
     drop: pageItemDropHandler,
     hover: (item, monitor) => {
@@ -178,6 +219,10 @@ const Item: FC<ItemProps> = (props: ItemProps) => {
         }, 1000);
       }
     },
+    canDrop: (item) => {
+      const { page: droppedPage } = item;
+      return canMoveUnderNewParent(droppedPage, page);
+    },
     collect: monitor => ({
       isOver: monitor.isOver(),
     }),
@@ -206,7 +251,9 @@ const Item: FC<ItemProps> = (props: ItemProps) => {
       throw Error('Any of _id and path must not be null.');
     }
 
-    onClickDuplicateMenuItem(pageId, path);
+    const pageToDuplicate = { pageId, path };
+
+    onClickDuplicateMenuItem(pageToDuplicate);
   }, [onClickDuplicateMenuItem, page]);
 
 
@@ -242,7 +289,11 @@ const Item: FC<ItemProps> = (props: ItemProps) => {
 
     const { _id: pageId, revision: revisionId, path } = page;
 
-    if (pageId == null || revisionId == null || path == null) {
+    if (!page.isEmpty && revisionId == null) {
+      throw Error('Existing page should have revisionId');
+    }
+
+    if (pageId == null || path == null) {
       throw Error('Any of _id and revisionId and path must not be null.');
     }
 
@@ -255,18 +306,18 @@ const Item: FC<ItemProps> = (props: ItemProps) => {
     onClickRenameMenuItem(pageToRename);
   }, [onClickRenameMenuItem, page]);
 
-  const deleteMenuItemClickHandler = useCallback(async(_pageId: string, pageInfo): Promise<void> => {
-    const { _id: pageId, revision: revisionId, path } = page;
-
-    if (pageId == null || revisionId == null || path == null) {
+  const deleteMenuItemClickHandler = useCallback(async(_pageId: string, pageInfo: IPageInfoAll | undefined): Promise<void> => {
+    if (page._id == null || page.revision == null || page.path == null) {
       throw Error('Any of _id, revision, and path must not be null.');
     }
 
-    const pageToDelete: IPageForPageDeleteModal = {
-      pageId,
-      revisionId: revisionId as string,
-      path,
-      isAbleToDeleteCompletely: pageInfo?.isAbleToDeleteCompletely,
+    const pageToDelete: IPageToDeleteWithMeta = {
+      data: {
+        _id: page._id,
+        revision: page.revision as string,
+        path: page.path,
+      },
+      meta: pageInfo,
     };
 
     if (onClickDeleteMenuItem != null) {
@@ -287,7 +338,8 @@ const Item: FC<ItemProps> = (props: ItemProps) => {
 
     let initBody = '';
     if (isEnabledAttachTitleHeader) {
-      initBody = pathUtils.attachTitleHeader(newPagePath);
+      const pageTitle = pathUtils.addHeadingSlash(nodePath.basename(newPagePath));
+      initBody = pathUtils.attachTitleHeader(pageTitle);
     }
 
     try {
@@ -324,6 +376,12 @@ const Item: FC<ItemProps> = (props: ItemProps) => {
     return null;
   };
 
+  useEffect(() => {
+    if (!props.isScrolled && page.isTarget) {
+      document.dispatchEvent(new CustomEvent('targetItemRendered'));
+    }
+  }, [props.isScrolled, page.isTarget]);
+
   // didMount
   useEffect(() => {
     if (hasChildren()) setIsOpen(true);
@@ -359,7 +417,8 @@ const Item: FC<ItemProps> = (props: ItemProps) => {
     >
       <li
         ref={(c) => { drag(c); drop(c) }}
-        className={`list-group-item list-group-item-action border-0 py-1 d-flex align-items-center ${page.isTarget ? 'grw-pagetree-is-target' : ''}`}
+        className={`list-group-item list-group-item-action border-0 py-0 d-flex align-items-center ${page.isTarget ? 'grw-pagetree-is-target' : ''}`}
+        id={page.isTarget ? 'grw-pagetree-is-target' : `grw-pagetree-list-${page._id}`}
       >
         <div className="grw-triangle-container d-flex justify-content-center">
           {hasDescendants && (
@@ -395,7 +454,7 @@ const Item: FC<ItemProps> = (props: ItemProps) => {
             <ItemCount descendantCount={page.descendantCount} />
           </div>
         )}
-        <div className="grw-pagetree-control d-none">
+        <div className="grw-pagetree-control d-flex">
           <PageItemControl
             pageId={page._id}
             isEnableActions={isEnableActions}
@@ -404,13 +463,14 @@ const Item: FC<ItemProps> = (props: ItemProps) => {
             onClickRenameMenuItem={renameMenuItemClickHandler}
             onClickDeleteMenuItem={deleteMenuItemClickHandler}
           >
-            <DropdownToggle className="btn btn-transparent border-0 rounded btn-page-item-control p-0">
+            {/* pass the color property to reactstrap dropdownToggle props. https://6-4-0--reactstrap.netlify.app/components/dropdowns/  */}
+            <DropdownToggle color="transparent" className="border-0 rounded btn-page-item-control p-0 grw-visible-on-hover">
               <i className="icon-options fa fa-rotate-90 text-muted p-1"></i>
             </DropdownToggle>
           </PageItemControl>
           <button
             type="button"
-            className="border-0 rounded btn btn-transparent btn-page-item-control p-0"
+            className="border-0 rounded btn btn-page-item-control p-0 grw-visible-on-hover"
             onClick={onClickPlusButton}
           >
             <i className="icon-plus text-muted d-block p-1" />
@@ -434,6 +494,7 @@ const Item: FC<ItemProps> = (props: ItemProps) => {
               isEnableActions={isEnableActions}
               itemNode={node}
               isOpen={false}
+              isScrolled={props.isScrolled}
               targetPathOrId={targetPathOrId}
               isEnabledAttachTitleHeader={isEnabledAttachTitleHeader}
               onClickDuplicateMenuItem={onClickDuplicateMenuItem}

+ 60 - 21
packages/app/src/components/Sidebar/PageTree/ItemsTree.tsx

@@ -1,15 +1,13 @@
-import React, { FC, useEffect } from 'react';
+import React, { FC, useEffect, useState } from 'react';
 import { useTranslation } from 'react-i18next';
 
-import { IPageHasId } from '../../../interfaces/page';
-import { ItemNode } from './ItemNode';
-import Item from './Item';
 import { usePageTreeTermManager, useSWRxPageAncestorsChildren, useSWRxRootPage } from '~/stores/page-listing';
 import { TargetAndAncestors } from '~/interfaces/page-listing-results';
-import { OnDeletedFunction } from '~/interfaces/ui';
+import { IPageHasId, IPageToDeleteWithMeta } from '~/interfaces/page';
+import { OnDuplicatedFunction, OnRenamedFunction, OnDeletedFunction } from '~/interfaces/ui';
 import { toastError, toastSuccess } from '~/client/util/apiNotification';
 import {
-  IPageForPageDeleteModal, usePageDuplicateModal, IPageForPageRenameModal, usePageRenameModal, usePageDeleteModal,
+  IPageForPageDuplicateModal, usePageDuplicateModal, IPageForPageRenameModal, usePageRenameModal, usePageDeleteModal,
 } from '~/stores/modal';
 import { smoothScrollIntoView } from '~/client/util/smooth-scroll';
 
@@ -17,6 +15,10 @@ import { useIsEnabledAttachTitleHeader } from '~/stores/context';
 import { useFullTextSearchTermManager } from '~/stores/search';
 import { useDescendantsPageListForCurrentPathTermManager } from '~/stores/page';
 
+import { ItemNode } from './ItemNode';
+import Item from './Item';
+
+
 /*
  * Utility to generate initial node
  */
@@ -66,11 +68,12 @@ type ItemsTreeProps = {
 const renderByInitialNode = (
     initialNode: ItemNode,
     isEnableActions: boolean,
+    isScrolled: boolean,
     targetPathOrId?: string,
     isEnabledAttachTitleHeader?: boolean,
-    onClickDuplicateMenuItem?: (pageId: string, path: string) => void,
+    onClickDuplicateMenuItem?: (pageToDuplicate: IPageForPageDuplicateModal) => void,
     onClickRenameMenuItem?: (pageToRename: IPageForPageRenameModal) => void,
-    onClickDeleteMenuItem?: (pageToDelete: IPageForPageDeleteModal) => void,
+    onClickDeleteMenuItem?: (pageToDelete: IPageToDeleteWithMeta) => void,
 ): JSX.Element => {
 
   return (
@@ -85,11 +88,25 @@ const renderByInitialNode = (
         onClickDuplicateMenuItem={onClickDuplicateMenuItem}
         onClickRenameMenuItem={onClickRenameMenuItem}
         onClickDeleteMenuItem={onClickDeleteMenuItem}
+        isScrolled={isScrolled}
       />
     </ul>
   );
 };
 
+// --- Auto scroll related vars and util ---
+
+const SCROLL_OFFSET_TOP = window.innerHeight / 2;
+
+const scrollTargetItem = () => {
+  const scrollElement = document.getElementById('grw-sidebar-contents-scroll-target');
+  const target = document.getElementById('grw-pagetree-is-target');
+  if (scrollElement != null && target != null) {
+    smoothScrollIntoView(target, SCROLL_OFFSET_TOP, scrollElement);
+  }
+};
+// --- end ---
+
 
 /*
  * ItemsTree
@@ -107,30 +124,50 @@ const ItemsTree: FC<ItemsTreeProps> = (props: ItemsTreeProps) => {
   const { open: openDuplicateModal } = usePageDuplicateModal();
   const { open: openRenameModal } = usePageRenameModal();
   const { open: openDeleteModal } = usePageDeleteModal();
+  const [isScrolled, setIsScrolled] = useState(false);
+
 
   // for mutation
   const { advance: advancePt } = usePageTreeTermManager();
   const { advance: advanceFts } = useFullTextSearchTermManager();
   const { advance: advanceDpl } = useDescendantsPageListForCurrentPathTermManager();
 
+  const scrollItem = () => {
+    scrollTargetItem();
+    setIsScrolled(true);
+  };
+
   useEffect(() => {
-    const startFrom = document.getElementById('grw-sidebar-contents-scroll-target');
-    const targetElem = document.getElementsByClassName('grw-pagetree-is-target');
-    //  targetElem is HTML collection but only one HTML element in it all the time
-    if (targetElem[0] != null && startFrom != null) {
-      smoothScrollIntoView(targetElem[0] as HTMLElement, 0, startFrom);
-    }
-  }, [ancestorsChildrenData]);
+    document.addEventListener('targetItemRendered', scrollItem);
+    return () => {
+      document.removeEventListener('targetItemRendered', scrollItem);
+    };
+  }, []);
+
+  const onClickDuplicateMenuItem = (pageToDuplicate: IPageForPageDuplicateModal) => {
+    // eslint-disable-next-line @typescript-eslint/no-unused-vars
+    const duplicatedHandler: OnDuplicatedFunction = (fromPath, toPath) => {
+      toastSuccess(t('duplicated_pages', { fromPath }));
+
+      advancePt();
+      advanceFts();
+      advanceDpl();
+    };
 
-  const onClickDuplicateMenuItem = (pageId: string, path: string) => {
-    openDuplicateModal(pageId, path);
+    openDuplicateModal(pageToDuplicate, { onDuplicated: duplicatedHandler });
   };
 
   const onClickRenameMenuItem = (pageToRename: IPageForPageRenameModal) => {
-    openRenameModal(pageToRename);
+    const renamedHandler: OnRenamedFunction = (path) => {
+      toastSuccess(t('renamed_pages', { path }));
+
+      // TODO: revalidation by https://redmine.weseek.co.jp/issues/89258
+    };
+
+    openRenameModal(pageToRename, { onRenamed: renamedHandler });
   };
 
-  const onClickDeleteMenuItem = (pageToDelete: IPageForPageDeleteModal) => {
+  const onClickDeleteMenuItem = (pageToDelete: IPageToDeleteWithMeta) => {
     const onDeletedHandler: OnDeletedFunction = (pathOrPathsToDelete, isRecursively, isCompletely) => {
       if (typeof pathOrPathsToDelete !== 'string') {
         return;
@@ -165,7 +202,8 @@ const ItemsTree: FC<ItemsTreeProps> = (props: ItemsTreeProps) => {
   if (ancestorsChildrenData != null && rootPageData != null) {
     const initialNode = generateInitialNodeAfterResponse(ancestorsChildrenData.ancestorsChildren, new ItemNode(rootPageData.rootPage));
     return renderByInitialNode(
-      initialNode, isEnableActions, targetPathOrId, isEnabledAttachTitleHeader, onClickDuplicateMenuItem, onClickRenameMenuItem, onClickDeleteMenuItem,
+      // eslint-disable-next-line max-len
+      initialNode, isEnableActions, isScrolled, targetPathOrId, isEnabledAttachTitleHeader, onClickDuplicateMenuItem, onClickRenameMenuItem, onClickDeleteMenuItem,
     );
   }
 
@@ -175,7 +213,8 @@ const ItemsTree: FC<ItemsTreeProps> = (props: ItemsTreeProps) => {
   if (targetAndAncestorsData != null) {
     const initialNode = generateInitialNodeBeforeResponse(targetAndAncestorsData.targetAndAncestors);
     return renderByInitialNode(
-      initialNode, isEnableActions, targetPathOrId, isEnabledAttachTitleHeader, onClickDuplicateMenuItem, onClickRenameMenuItem, onClickDeleteMenuItem,
+      // eslint-disable-next-line max-len
+      initialNode, isEnableActions, isScrolled, targetPathOrId, isEnabledAttachTitleHeader, onClickDuplicateMenuItem, onClickRenameMenuItem, onClickDeleteMenuItem,
     );
   }
 

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

@@ -15,7 +15,7 @@ type PrimaryItemProps = {
 
 const PrimaryItem: FC<PrimaryItemProps> = (props: PrimaryItemProps) => {
   const {
-    contents, iconName, onItemSelected,
+    contents, label, iconName, onItemSelected,
   } = props;
 
   const { data: currentContents, mutate } = useCurrentSidebarContents();
@@ -31,9 +31,12 @@ const PrimaryItem: FC<PrimaryItemProps> = (props: PrimaryItemProps) => {
     scheduleToPutUserUISettings({ currentSidebarContents: contents });
   }, [contents, mutate, onItemSelected]);
 
+  const labelForTestId = label.toLowerCase().replace(' ', '-');
+
   return (
     <button
       type="button"
+      data-testid={`grw-sidebar-nav-primary-${labelForTestId}`}
       className={`d-block btn btn-primary ${isSelected ? 'active' : ''}`}
       onClick={itemSelectedHandler}
     >

+ 2 - 2
packages/app/src/components/Sidebar/Tag.tsx

@@ -11,7 +11,7 @@ const Tag: FC = () => {
   }, [isOnReload]);
 
   return (
-    <>
+    <div data-testid="grw-sidebar-content-tags">
       <div className="grw-sidebar-content-header p-3 d-flex">
         <h3 className="mb-0">{t('Tags')}</h3>
         <button
@@ -36,7 +36,7 @@ const Tag: FC = () => {
       <div className="grw-container-convertible mb-5 pb-5">
         <TagsList isOnReload={isOnReload} />
       </div>
-    </>
+    </div>
   );
 
 };

+ 0 - 1
packages/app/src/components/TableOfContents.jsx

@@ -74,7 +74,6 @@ const TableOfContents = (props) => {
             id="revision-toc-content"
             className="revision-toc-content mb-2"
           >
-            <span className="text-muted">({t('page_table_of_contents.empty')})</span>
           </div>
         ) }
 

+ 4 - 0
packages/app/src/interfaces/page-listing-results.ts

@@ -27,6 +27,10 @@ export interface NotFoundTargetPathOrId {
   notFoundTargetPathOrId: string
 }
 
+export interface IsNotFoundPermalink {
+  isNotFoundPermalink: boolean
+}
+
 
 export interface V5MigrationStatus {
   isV5Compatible : boolean,

+ 15 - 8
packages/app/src/interfaces/page.ts

@@ -62,17 +62,20 @@ export type IPageInfoForListing = IPageInfoForEntity & HasRevisionShortbody;
 
 export type IPageInfoAll = IPageInfo | IPageInfoForEntity | IPageInfoForOperation | IPageInfoForListing;
 
-export const isIPageInfoForEntity = (pageInfo: IPageInfoAll | undefined): pageInfo is IPageInfoForEntity => {
-  return pageInfo != null && !pageInfo.isEmpty;
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+export const isIPageInfoForEntity = (pageInfo: any | undefined): pageInfo is IPageInfoForEntity => {
+  return pageInfo != null && ('isEmpty' in pageInfo) && pageInfo.isEmpty === false;
 };
 
-export const isIPageInfoForOperation = (pageInfo: IPageInfoAll | undefined): pageInfo is IPageInfoForOperation => {
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+export const isIPageInfoForOperation = (pageInfo: any | undefined): pageInfo is IPageInfoForOperation => {
   return pageInfo != null
     && isIPageInfoForEntity(pageInfo)
     && ('isBookmarked' in pageInfo || 'isLiked' in pageInfo || 'subscriptionStatus' in pageInfo);
 };
 
-export const isIPageInfoForListing = (pageInfo: IPageInfoAll | undefined): pageInfo is IPageInfoForListing => {
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+export const isIPageInfoForListing = (pageInfo: any | undefined): pageInfo is IPageInfoForListing => {
   return pageInfo != null
     && isIPageInfoForEntity(pageInfo)
     && 'revisionShortBody' in pageInfo;
@@ -94,10 +97,14 @@ export const isIPageInfoForListing = (pageInfo: IPageInfoAll | undefined): pageI
 //   return <IPageInfoTypeResolver<T>>pageInfo;
 // };
 
-export type IPageWithMeta<M = IPageInfoAll> = {
-  pageData: IPageHasId,
-  pageMeta?: M,
-};
+export type IDataWithMeta<D = unknown, M = unknown> = {
+  data: D,
+  meta?: M,
+}
+
+export type IPageWithMeta<M = IPageInfoAll> = IDataWithMeta<IPageHasId, M>;
+
+export type IPageToDeleteWithMeta = IDataWithMeta<HasObjectId & (IPage | { path: string, revision: string }), IPageInfoForOperation | unknown>;
 
 export type IDeleteSinglePageApiv1Result = {
   ok: boolean

+ 3 - 2
packages/app/src/interfaces/search.ts

@@ -1,4 +1,4 @@
-import { IPageInfoAll, IPageWithMeta } from './page';
+import { IPageWithMeta } from './page';
 
 export type IPageSearchMeta = {
   bookmarkCount?: number,
@@ -9,7 +9,8 @@ export type IPageSearchMeta = {
   };
 }
 
-export const isIPageSearchMeta = (meta: IPageInfoAll | (IPageInfoAll & IPageSearchMeta) | undefined): meta is IPageInfoAll & IPageSearchMeta => {
+// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/explicit-module-boundary-types
+export const isIPageSearchMeta = (meta: any): meta is IPageSearchMeta => {
   return meta != null && 'elasticSearchResult' in meta;
 };
 

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

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

+ 4 - 0
packages/app/src/server/crowi/index.js

@@ -22,6 +22,7 @@ import SearchService from '../service/search';
 import AttachmentService from '../service/attachment';
 import PageService from '../service/page';
 import PageGrantService from '../service/page-grant';
+import PageOperationService from '../service/page-operation';
 import { SlackIntegrationService } from '../service/slack-integration';
 import { UserNotificationService } from '../service/user-notification';
 import { InstallerService } from '../service/installer';
@@ -679,6 +680,9 @@ Crowi.prototype.setupPageService = async function() {
   if (this.pageGrantService == null) {
     this.pageGrantService = new PageGrantService(this);
   }
+  if (this.pageOperationService == null) {
+    this.pageOperationService = new PageOperationService(this);
+  }
 };
 
 Crowi.prototype.setupInAppNotificationService = async function() {

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

@@ -0,0 +1,25 @@
+import { ObjectIdLike } from './mongoose-utils';
+
+export type IPageForResuming = {
+  _id: ObjectIdLike,
+  path: string,
+  isEmpty: boolean,
+  parent?: ObjectIdLike,
+  grant?: number,
+  grantedUsers?: ObjectIdLike[],
+  grantedGroup?: ObjectIdLike,
+  descendantCount: number,
+  status?: number,
+  revision?: ObjectIdLike,
+  lastUpdateUser?: ObjectIdLike,
+  creator?: ObjectIdLike,
+};
+
+export type IUserForResuming = {
+  _id: ObjectIdLike,
+};
+
+export type IOptionsForResuming = {
+  updateMetadata?: boolean,
+  createRedirectPage?: boolean,
+};

+ 2 - 1
packages/app/src/server/middlewares/apiv3-form-validator.js → packages/app/src/server/middlewares/apiv3-form-validator.ts

@@ -1,3 +1,4 @@
+import { NextFunction, Request, Response } from 'express';
 import loggerFactory from '~/utils/logger';
 
 const logger = loggerFactory('growi:middlewares:ApiV3FormValidator');
@@ -5,7 +6,7 @@ const { validationResult } = require('express-validator');
 
 const ErrorV3 = require('../models/vo/error-apiv3');
 
-module.exports = () => (req, res, next) => {
+export const apiV3FormValidator = (req: Request, res: Response & { apiv3Err }, next: NextFunction): void => {
   logger.debug('req.query', req.query);
   logger.debug('req.params', req.params);
   logger.debug('req.body', req.body);

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

@@ -0,0 +1,118 @@
+import mongoose, {
+  Schema, Model, Document, QueryOptions, FilterQuery,
+} from 'mongoose';
+import { getOrCreateModel } from '@growi/core';
+
+import {
+  IPageForResuming, IUserForResuming, IOptionsForResuming,
+} from '~/server/interfaces/page-operation';
+import { ObjectIdLike } from '../interfaces/mongoose-utils';
+
+type IObjectId = mongoose.Types.ObjectId;
+const ObjectId = mongoose.Schema.Types.ObjectId;
+
+export const PageActionType = {
+  Rename: 'Rename',
+  Duplicate: 'Duplicate',
+  Delete: 'Delete',
+  DeleteCompletely: 'DeleteCompletely',
+  Revert: 'Revert',
+  NormalizeParent: 'NormalizeParent',
+} as const;
+export type PageActionType = typeof PageActionType[keyof typeof PageActionType];
+
+export const PageActionStage = {
+  Main: 'Main',
+  Sub: 'Sub',
+} as const;
+export type PageActionStage = typeof PageActionStage[keyof typeof PageActionStage];
+
+/*
+ * Main Schema
+ */
+export interface IPageOperation {
+  actionType: PageActionType,
+  actionStage: PageActionStage,
+  fromPath: string,
+  toPath?: string,
+  page: IPageForResuming,
+  user: IUserForResuming,
+  options?: IOptionsForResuming,
+  incForUpdatingDescendantCount?: number,
+}
+
+export interface PageOperationDocument extends IPageOperation, Document {}
+
+export type PageOperationDocumentHasId = PageOperationDocument & { _id: ObjectIdLike };
+
+export interface PageOperationModel extends Model<PageOperationDocument> {
+  findByIdAndUpdatePageActionStage(pageOpId: ObjectIdLike, stage: PageActionStage): Promise<PageOperationDocumentHasId | null>
+  findMainOps(filter?: FilterQuery<PageOperationDocument>, projection?: any, options?: QueryOptions): Promise<PageOperationDocumentHasId[]>
+}
+
+const pageSchemaForResuming = new Schema<IPageForResuming>({
+  _id: { type: ObjectId, ref: 'Page', index: true },
+  parent: { type: ObjectId, ref: 'Page' },
+  descendantCount: { type: Number },
+  isEmpty: { type: Boolean },
+  path: { type: String, required: true, index: true },
+  revision: { type: ObjectId, ref: 'Revision' },
+  status: { type: String },
+  grant: { type: Number },
+  grantedUsers: [{ type: ObjectId, ref: 'User' }],
+  grantedGroup: { type: ObjectId, ref: 'UserGroup' },
+  creator: { type: ObjectId, ref: 'User' },
+  lastUpdateUser: { type: ObjectId, ref: 'User' },
+});
+
+const userSchemaForResuming = new Schema<IUserForResuming>({
+  _id: { type: ObjectId, ref: 'User', required: true },
+});
+
+const optionsSchemaForResuming = new Schema<IOptionsForResuming>({
+  createRedirectPage: { type: Boolean },
+  updateMetadata: { type: Boolean },
+}, { _id: false });
+
+const schema = new Schema<PageOperationDocument, PageOperationModel>({
+  actionType: {
+    type: String,
+    enum: PageActionType,
+    required: true,
+    index: true,
+  },
+  actionStage: {
+    type: String,
+    enum: PageActionStage,
+    required: true,
+    index: true,
+  },
+  fromPath: { type: String, required: true, index: true },
+  toPath: { type: String, index: true },
+  page: { type: pageSchemaForResuming, required: true },
+  user: { type: userSchemaForResuming, required: true },
+  options: { type: optionsSchemaForResuming },
+  incForUpdatingDescendantCount: { type: Number },
+});
+
+schema.statics.findByIdAndUpdatePageActionStage = async function(
+    pageOpId: ObjectIdLike, stage: PageActionStage,
+): Promise<PageOperationDocumentHasId | null> {
+
+  return this.findByIdAndUpdate(pageOpId, {
+    $set: { actionStage: stage },
+  }, { new: true });
+};
+
+schema.statics.findMainOps = async function(
+    filter?: FilterQuery<PageOperationDocument>, projection?: any, options?: QueryOptions,
+): Promise<PageOperationDocumentHasId[]> {
+
+  return this.find(
+    { ...filter, actionStage: PageActionStage.Main },
+    projection,
+    options,
+  );
+};
+
+export default getOrCreateModel<PageOperationDocument, PageOperationModel>('PageOperation', schema);

+ 0 - 3
packages/app/src/server/models/page-redirect.ts

@@ -16,9 +16,6 @@ export interface PageRedirectModel extends Model<PageRedirectDocument> {
   [x:string]: any // TODO: improve type
 }
 
-/**
- * This is the setting for notify to 3rd party tool (like Slack).
- */
 const schema = new Schema<PageRedirectDocument, PageRedirectModel>({
   fromPath: {
     type: String, required: true, unique: true, index: true,

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

@@ -43,7 +43,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[], publicOnly?: boolean): Promise<void>
+  createEmptyPagesByPaths(paths: string[], onlyMigratedAsExistingPages?: boolean, publicOnly?: boolean): Promise<void>
   getParentAndFillAncestors(path: string): Promise<PageDocument & { _id: any }>
   findByIdsAndViewer(pageIds: string[], user, userGroups?, includeEmpty?: boolean): Promise<PageDocument[]>
   findByPathAndViewer(path: string | null, user, userGroups?, useFindOne?: boolean, includeEmpty?: boolean): Promise<PageDocument[]>
@@ -121,9 +121,12 @@ const generateChildrenRegExp = (path: string): RegExp => {
 /*
  * Create empty pages if the page in paths didn't exist
  */
-schema.statics.createEmptyPagesByPaths = async function(paths: string[], publicOnly = false): Promise<void> {
+schema.statics.createEmptyPagesByPaths = async function(paths: string[], onlyMigratedAsExistingPages = true, publicOnly = false): Promise<void> {
   // find existing parents
   const builder = new PageQueryBuilder(this.find(publicOnly ? { grant: GRANT_PUBLIC } : {}, { _id: 0, path: 1 }), true);
+  if (onlyMigratedAsExistingPages) {
+    builder.addConditionAsMigrated();
+  }
   const existingPages = await builder
     .addConditionToListByPathsArray(paths)
     .query
@@ -167,7 +170,7 @@ schema.statics.createEmptyPage = async function(
  * @param exPage a page document to be replaced
  * @returns Promise<void>
  */
-schema.statics.replaceTargetWithPage = async function(exPage, pageToReplaceWith?, deleteExPageIfEmpty = false): Promise<void> {
+schema.statics.replaceTargetWithPage = async function(exPage, pageToReplaceWith?, deleteExPageIfEmpty = false) {
   // find parent
   const parent = await this.findOne({ _id: exPage.parent });
   if (parent == null) {
@@ -207,6 +210,8 @@ schema.statics.replaceTargetWithPage = async function(exPage, pageToReplaceWith?
     await this.deleteOne({ _id: exPage._id });
     logger.warn('Deleted empty page since it was replaced with another page.');
   }
+
+  return this.findById(newTarget._id);
 };
 
 /**
@@ -217,9 +222,15 @@ schema.statics.replaceTargetWithPage = async function(exPage, pageToReplaceWith?
  */
 schema.statics.getParentAndFillAncestors = async function(path: string): Promise<PageDocument> {
   const parentPath = nodePath.dirname(path);
-  const parent = await this.findOne({ path: parentPath }); // find the oldest parent which must always be the true parent
-  if (parent != null) {
-    return parent;
+
+  const builder1 = new PageQueryBuilder(this.find({ path: parentPath }), true);
+  const pagesCanBeParent = await builder1
+    .addConditionAsMigrated()
+    .query
+    .exec();
+
+  if (pagesCanBeParent.length >= 1) {
+    return pagesCanBeParent[0]; // the earliest page will be the result
   }
 
   /*
@@ -231,8 +242,8 @@ schema.statics.getParentAndFillAncestors = async function(path: string): Promise
   await this.createEmptyPagesByPaths(ancestorPaths);
 
   // find ancestors
-  const builder = new PageQueryBuilder(this.find(), true);
-  const ancestors = await builder
+  const builder2 = new PageQueryBuilder(this.find(), true);
+  const ancestors = await builder2
     .addConditionToListByPathsArray(ancestorPaths)
     .addConditionToSortPagesByDescPath()
     .query
@@ -244,15 +255,14 @@ schema.statics.getParentAndFillAncestors = async function(path: string): Promise
   // bulkWrite to update ancestors
   const nonRootAncestors = ancestors.filter(page => !isTopPage(page.path));
   const operations = nonRootAncestors.map((page) => {
-    const { path } = page;
-    const parentPath = nodePath.dirname(path);
+    const parentPath = nodePath.dirname(page.path);
     return {
       updateOne: {
         filter: {
-          path,
+          _id: page._id,
         },
         update: {
-          parent: ancestorsMap.get(parentPath),
+          parent: ancestorsMap.get(parentPath)._id,
         },
       },
     };
@@ -315,9 +325,8 @@ schema.statics.findTargetAndAncestorsByPathOrId = async function(pathOrId: strin
   if (!hasSlash(pathOrId)) {
     const _id = pathOrId;
     const page = await this.findOne({ _id });
-    if (page == null) throw new Error('Page not found.');
 
-    path = page.path;
+    path = page == null ? '/' : page.path;
   }
   else {
     path = pathOrId;
@@ -423,40 +432,6 @@ async function pushRevision(pageData, newRevision, user) {
   return pageData.save();
 }
 
-/**
- * return aggregate condition to get following pages
- * - page that has the same path as the provided path
- * - pages that are descendants of the above page
- * pages without parent will be ignored
- */
-schema.statics.getAggrConditionForPageWithProvidedPathAndDescendants = function(path:string) {
-  let match;
-  if (isTopPage(path)) {
-    match = {
-      // https://regex101.com/r/Kip2rV/1
-      $match: { $or: [{ path: { $regex: '^/.*' }, parent: { $ne: null } }, { path: '/' }] },
-    };
-  }
-  else {
-    match = {
-      // https://regex101.com/r/mJvGrG/1
-      $match: { path: { $regex: `^${path}(/.*|$)` }, parent: { $ne: null } },
-    };
-  }
-  return [
-    match,
-    {
-      $project: {
-        path: 1,
-        parent: 1,
-        field_length: { $strLenCP: '$path' },
-      },
-    },
-    { $sort: { field_length: -1 } },
-    { $project: { field_length: 0 } },
-  ];
-};
-
 /**
  * add/subtract descendantCount of pages with provided paths by increment.
  * increment can be negative number
@@ -512,6 +487,9 @@ schema.statics.recountDescendantCount = async function(id: ObjectIdLike):Promise
 schema.statics.findAncestorsUsingParentRecursively = async function(pageId: ObjectIdLike, shouldIncludeTarget: boolean) {
   const self = this;
   const target = await this.findById(pageId);
+  if (target == null) {
+    throw Error('Target not found');
+  }
 
   async function findAncestorsRecursively(target, ancestors = shouldIncludeTarget ? [target] : []) {
     const parent = await self.findOne({ _id: target.parent });
@@ -531,44 +509,41 @@ schema.statics.findAncestorsUsingParentRecursively = async function(pageId: Obje
  * @param pageId ObjectIdLike
  * @returns Promise<void>
  */
-schema.statics.removeLeafEmptyPagesById = async function(pageId: ObjectIdLike): Promise<void> {
+schema.statics.removeLeafEmptyPagesRecursively = async function(pageId: ObjectIdLike): Promise<void> {
   const self = this;
 
-  const initialLeafPage = await this.findById(pageId);
+  const initialPage = await this.findById(pageId);
 
-  if (initialLeafPage == null) {
+  if (initialPage == null) {
     return;
   }
 
-  if (!initialLeafPage.isEmpty) {
+  if (!initialPage.isEmpty) {
     return;
   }
 
-  async function generatePageIdsToRemove(page, pageIds: ObjectIdLike[]): Promise<ObjectIdLike[]> {
-    const nextPage = await self.findById(page.parent);
-
-    if (nextPage == null) {
+  async function generatePageIdsToRemove(childPage, page, pageIds: ObjectIdLike[] = []): Promise<ObjectIdLike[]> {
+    if (!page.isEmpty) {
       return pageIds;
     }
 
-    // delete leaf empty pages
-    const isNextPageEmpty = nextPage.isEmpty;
-
-    if (!isNextPageEmpty) {
+    const isChildrenOtherThanTargetExist = await self.exists({ _id: { $ne: childPage?._id }, parent: page._id });
+    if (isChildrenOtherThanTargetExist) {
       return pageIds;
     }
 
-    const isSiblingsExist = await self.exists({ parent: nextPage.parent, _id: { $ne: nextPage._id } });
-    if (isSiblingsExist) {
+    pageIds.push(page._id);
+
+    const nextPage = await self.findById(page.parent);
+
+    if (nextPage == null) {
       return pageIds;
     }
 
-    return generatePageIdsToRemove(nextPage, [...pageIds, nextPage._id]);
+    return generatePageIdsToRemove(page, nextPage, pageIds);
   }
 
-  const initialPageIdsToRemove = [initialLeafPage._id];
-
-  const pageIdsToRemove = await generatePageIdsToRemove(initialLeafPage, initialPageIdsToRemove);
+  const pageIdsToRemove = await generatePageIdsToRemove(null, initialPage);
 
   await this.deleteMany({ _id: { $in: pageIdsToRemove } });
 };
@@ -592,6 +567,10 @@ schema.statics.normalizeDescendantCountById = async function(pageId) {
   return this.updateOne({ _id: pageId }, { $set: { descendantCount: sumChildrenDescendantCount + sumChildPages } }, { new: true });
 };
 
+schema.statics.takeOffFromTree = async function(pageId: ObjectIdLike) {
+  return this.findByIdAndUpdate(pageId, { $set: { parent: null } });
+};
+
 export type PageCreateOptions = {
   format?: string
   grantUserGroupId?: ObjectIdLike
@@ -608,7 +587,7 @@ export default (crowi: Crowi): any => {
   }
 
   schema.statics.create = async function(path: string, body: string, user, options: PageCreateOptions = {}) {
-    if (crowi.pageGrantService == null || crowi.configManager == null || crowi.pageService == null) {
+    if (crowi.pageGrantService == null || crowi.configManager == null || crowi.pageService == null || crowi.pageOperationService == null) {
       throw Error('Crowi is not setup');
     }
 
@@ -618,6 +597,11 @@ export default (crowi: Crowi): any => {
       return this.createV4(path, body, user, options);
     }
 
+    const canOperate = await crowi.pageOperationService.canOperate(false, null, path);
+    if (!canOperate) {
+      throw Error(`Cannot operate create to path "${path}" right now.`);
+    }
+
     const Page = this;
     const Revision = crowi.model('Revision');
     const {
@@ -699,8 +683,6 @@ export default (crowi: Crowi): any => {
 
     let savedPage = await page.save();
 
-    await crowi.pageService.updateDescendantCountOfAncestors(page._id, 1, false);
-
     /*
      * After save
      */
@@ -721,6 +703,9 @@ export default (crowi: Crowi): any => {
 
     pageEvent.emit('create', savedPage, user);
 
+    // update descendantCount asynchronously
+    await crowi.pageService.updateDescendantCountOfAncestors(savedPage._id, 1, false);
+
     return savedPage;
   };
 

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

@@ -1,6 +1,8 @@
 import { body } from 'express-validator';
 import loggerFactory from '~/utils/logger';
 
+import { apiV3FormValidator } from '../../middlewares/apiv3-form-validator';
+
 const logger = loggerFactory('growi:routes:apiv3:app-settings');
 
 const debug = require('debug')('growi:routes:admin');
@@ -150,7 +152,6 @@ module.exports = (crowi) => {
   const loginRequiredStrictly = require('../../middlewares/login-required')(crowi);
   const adminRequired = require('../../middlewares/admin-required')(crowi);
   const csrf = require('../../middlewares/csrf')(crowi);
-  const apiV3FormValidator = require('../../middlewares/apiv3-form-validator')(crowi);
 
   const validator = {
     appSetting: [

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

@@ -1,5 +1,7 @@
 import loggerFactory from '~/utils/logger';
 
+import { apiV3FormValidator } from '../../middlewares/apiv3-form-validator';
+
 const logger = loggerFactory('growi:routes:apiv3:attachment'); // eslint-disable-line no-unused-vars
 
 const express = require('express');
@@ -22,7 +24,6 @@ module.exports = (crowi) => {
   const Page = crowi.model('Page');
   const User = crowi.model('User');
   const Attachment = crowi.model('Attachment');
-  const apiV3FormValidator = require('../../middlewares/apiv3-form-validator')(crowi);
 
   const validator = {
     retrieveAttachments: [

+ 31 - 9
packages/app/src/server/routes/apiv3/bookmarks.js

@@ -1,5 +1,7 @@
 import loggerFactory from '~/utils/logger';
 
+import { apiV3FormValidator } from '../../middlewares/apiv3-form-validator';
+
 const logger = loggerFactory('growi:routes:apiv3:bookmarks'); // eslint-disable-line no-unused-vars
 
 const express = require('express');
@@ -69,7 +71,6 @@ module.exports = (crowi) => {
   const loginRequiredStrictly = require('../../middlewares/login-required')(crowi);
   const loginRequired = require('../../middlewares/login-required')(crowi, true);
   const csrf = require('../../middlewares/csrf')(crowi);
-  const apiV3FormValidator = require('../../middlewares/apiv3-form-validator')(crowi);
 
   const { Page, Bookmark, User } = crowi.models;
 
@@ -258,6 +259,11 @@ module.exports = (crowi) => {
    */
   router.put('/', accessTokenParser, loginRequiredStrictly, csrf, validator.bookmarks, apiV3FormValidator, async(req, res) => {
     const { pageId, bool } = req.body;
+    const userId = req.user?._id;
+
+    if (userId == null) {
+      return res.apiv3Err('A logged in user is required.');
+    }
 
     let bookmark;
     try {
@@ -265,15 +271,29 @@ module.exports = (crowi) => {
       if (page == null) {
         return res.apiv3Err(`Page '${pageId}' is not found or forbidden`);
       }
-      if (bool) {
-        bookmark = await Bookmark.add(page, req.user);
 
-        const pageEvent = crowi.event('page');
-        // in-app notification
-        pageEvent.emit('bookmark', page, req.user);
+      bookmark = await Bookmark.findByPageIdAndUserId(page._id, req.user._id);
+
+      if (bookmark == null) {
+        if (bool) {
+          bookmark = await Bookmark.add(page, req.user);
+
+          const pageEvent = crowi.event('page');
+          // in-app notification
+          pageEvent.emit('bookmark', page, req.user);
+        }
+        else {
+          logger.warn(`Removing the bookmark for ${page._id} by ${req.user._id} failed because the bookmark does not exist.`);
+        }
       }
       else {
-        bookmark = await Bookmark.removeBookmark(page, req.user);
+        // eslint-disable-next-line no-lonely-if
+        if (bool) {
+          logger.warn(`Adding the bookmark for ${page._id} by ${req.user._id} failed because the bookmark has already exist.`);
+        }
+        else {
+          bookmark = await Bookmark.removeBookmark(page, req.user);
+        }
       }
     }
     catch (err) {
@@ -281,8 +301,10 @@ module.exports = (crowi) => {
       return res.apiv3Err(err, 500);
     }
 
-    bookmark.depopulate('page');
-    bookmark.depopulate('user');
+    if (bookmark != null) {
+      bookmark.depopulate('page');
+      bookmark.depopulate('user');
+    }
 
     return res.apiv3({ bookmark });
   });

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

@@ -1,6 +1,8 @@
 /* eslint-disable no-unused-vars */
 import loggerFactory from '~/utils/logger';
 
+import { apiV3FormValidator } from '../../middlewares/apiv3-form-validator';
+
 const logger = loggerFactory('growi:routes:apiv3:customize-setting');
 
 const express = require('express');
@@ -90,7 +92,6 @@ module.exports = (crowi) => {
   const loginRequiredStrictly = require('../../middlewares/login-required')(crowi);
   const adminRequired = require('../../middlewares/admin-required')(crowi);
   const csrf = require('../../middlewares/csrf')(crowi);
-  const apiV3FormValidator = require('../../middlewares/apiv3-form-validator')(crowi);
 
   const { customizeService } = crowi;
 

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

@@ -1,5 +1,7 @@
 import loggerFactory from '~/utils/logger';
 
+import { apiV3FormValidator } from '../../middlewares/apiv3-form-validator';
+
 const logger = loggerFactory('growi:routes:apiv3:export');
 const fs = require('fs');
 
@@ -42,7 +44,6 @@ module.exports = (crowi) => {
   const accessTokenParser = require('../../middlewares/access-token-parser')(crowi);
   const loginRequired = require('../../middlewares/login-required')(crowi);
   const adminRequired = require('../../middlewares/admin-required')(crowi);
-  const apiV3FormValidator = require('../../middlewares/apiv3-form-validator')(crowi);
   const csrf = require('../../middlewares/csrf')(crowi);
 
   const { exportService, socketIoService } = crowi;

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

@@ -7,6 +7,7 @@ import loggerFactory from '~/utils/logger';
 
 import { checkForgotPasswordEnabledMiddlewareFactory } from '../forgot-password';
 import httpErrorHandler from '../../middlewares/http-error-handler';
+import { apiV3FormValidator } from '../../middlewares/apiv3-form-validator';
 
 const logger = loggerFactory('growi:routes:apiv3:forgotPassword'); // eslint-disable-line no-unused-vars
 
@@ -21,7 +22,6 @@ module.exports = (crowi) => {
   const User = crowi.model('User');
   const path = require('path');
   const csrf = require('../../middlewares/csrf')(crowi);
-  const apiV3FormValidator = require('../../middlewares/apiv3-form-validator')(crowi);
 
   const validator = {
     password: [

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

@@ -1,5 +1,7 @@
 import loggerFactory from '~/utils/logger';
 
+import { apiV3FormValidator } from '../../middlewares/apiv3-form-validator';
+
 const logger = loggerFactory('growi:routes:apiv3:markdown-setting');
 
 const express = require('express');
@@ -89,7 +91,6 @@ module.exports = (crowi) => {
   const loginRequiredStrictly = require('../../middlewares/login-required')(crowi);
   const adminRequired = require('../../middlewares/admin-required')(crowi);
   const csrf = require('../../middlewares/csrf')(crowi);
-  const apiV3FormValidator = require('../../middlewares/apiv3-form-validator')(crowi);
 
   /**
    * @swagger

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

@@ -2,6 +2,7 @@ import loggerFactory from '~/utils/logger';
 import { removeNullPropertyFromObject } from '~/utils/object-utils';
 
 import UpdatePost from '../../models/update-post';
+import { apiV3FormValidator } from '../../middlewares/apiv3-form-validator';
 
 // eslint-disable-next-line no-unused-vars
 const logger = loggerFactory('growi:routes:apiv3:notification-setting');
@@ -90,7 +91,6 @@ module.exports = (crowi) => {
   const loginRequiredStrictly = require('../../middlewares/login-required')(crowi);
   const adminRequired = require('../../middlewares/admin-required')(crowi);
   const csrf = require('../../middlewares/csrf')(crowi);
-  const apiV3FormValidator = require('../../middlewares/apiv3-form-validator')(crowi);
 
   const GlobalNotificationSetting = crowi.model('GlobalNotificationSetting');
 

+ 27 - 28
packages/app/src/server/routes/apiv3/page-listing.ts

@@ -3,13 +3,16 @@ import { query, oneOf } from 'express-validator';
 
 import mongoose from 'mongoose';
 
+import { IPageInfoAll, isIPageInfoForEntity, IPageInfoForListing } from '~/interfaces/page';
+import loggerFactory from '~/utils/logger';
+
 import { PageModel } from '../../models/page';
 import ErrorV3 from '../../models/vo/error-apiv3';
-import loggerFactory from '../../../utils/logger';
 import Crowi from '../../crowi';
 import { ApiV3Response } from './interfaces/apiv3-response';
-import { IPageInfoAll, isIPageInfoForEntity, IPageInfoForListing } from '~/interfaces/page';
 import PageService from '../../service/page';
+import { apiV3FormValidator } from '../../middlewares/apiv3-form-validator';
+import { IUserHasId } from '~/interfaces/user';
 
 const logger = loggerFactory('growi:routes:apiv3:page-tree');
 
@@ -31,8 +34,10 @@ const validator = {
     query('id').isMongoId(),
     query('path').isString(),
   ], 'id or path is required'),
-  pageIdsRequired: [
+  infoParams: [
     query('pageIds').isArray().withMessage('pageIds is required'),
+    query('attachBookmarkCount').isBoolean().optional(),
+    query('attachShortBody').isBoolean().optional(),
   ],
 };
 
@@ -42,7 +47,6 @@ const validator = {
 export default (crowi: Crowi): Router => {
   const accessTokenParser = require('../../middlewares/access-token-parser')(crowi);
   const loginRequired = require('../../middlewares/login-required')(crowi, true);
-  const apiV3FormValidator = require('../../middlewares/apiv3-form-validator')(crowi);
 
   const router = express.Router();
 
@@ -82,7 +86,7 @@ export default (crowi: Crowi): Router => {
    * In most cases, using id should be prioritized
    */
   // eslint-disable-next-line max-len
-  router.get('/children', accessTokenParser, loginRequired, validator.pageIdOrPathRequired, async(req: AuthorizedRequest, res: ApiV3Response) => {
+  router.get('/children', accessTokenParser, loginRequired, validator.pageIdOrPathRequired, apiV3FormValidator, async(req: AuthorizedRequest, res: ApiV3Response) => {
     const { id, path } = req.query;
 
     const Page: PageModel = crowi.model('Page');
@@ -98,8 +102,11 @@ export default (crowi: Crowi): Router => {
   });
 
   // eslint-disable-next-line max-len
-  router.get('/info', accessTokenParser, loginRequired, validator.pageIdsRequired, async(req: AuthorizedRequest, res: ApiV3Response) => {
-    const { pageIds } = req.query;
+  router.get('/info', accessTokenParser, loginRequired, validator.infoParams, apiV3FormValidator, async(req: AuthorizedRequest, res: ApiV3Response) => {
+    const { pageIds, attachBookmarkCount: attachBookmarkCountParam, attachShortBody: attachShortBodyParam } = req.query;
+
+    const attachBookmarkCount: boolean = attachBookmarkCountParam === 'true';
+    const attachShortBody: boolean = attachShortBodyParam === 'true';
 
     const Page = mongoose.model('Page') as unknown as PageModel;
     const Bookmark = crowi.model('Bookmark');
@@ -111,8 +118,15 @@ export default (crowi: Crowi): Router => {
 
       const foundIds = pages.map(page => page._id);
 
-      const shortBodiesMap = await pageService.shortBodiesMapByPageIds(foundIds, req.user);
-      const bookmarkCountMap = await Bookmark.getPageIdToCountMap(foundIds) as Record<string, number>;
+      let shortBodiesMap;
+      if (attachShortBody) {
+        shortBodiesMap = await pageService.shortBodiesMapByPageIds(foundIds, req.user);
+      }
+
+      let bookmarkCountMap;
+      if (attachBookmarkCount) {
+        bookmarkCountMap = await Bookmark.getPageIdToCountMap(foundIds) as Record<string, number>;
+      }
 
       const idToPageInfoMap: Record<string, IPageInfoAll> = {};
 
@@ -122,11 +136,12 @@ export default (crowi: Crowi): Router => {
 
         const pageInfo = (!isIPageInfoForEntity(basicPageInfo))
           ? basicPageInfo
-          // create IPageInfoForList
+          // create IPageInfoForListing
           : {
             ...basicPageInfo,
-            bookmarkCount: bookmarkCountMap[page._id],
-            revisionShortBody: shortBodiesMap[page._id],
+            isAbleToDeleteCompletely: pageService.canDeleteCompletely((page.creator as IUserHasId)?._id, req.user),
+            bookmarkCount: bookmarkCountMap != null ? bookmarkCountMap[page._id] : undefined,
+            revisionShortBody: shortBodiesMap != null ? shortBodiesMap[page._id] : undefined,
           } as IPageInfoForListing;
 
         idToPageInfoMap[page._id] = pageInfo;
@@ -140,21 +155,5 @@ export default (crowi: Crowi): Router => {
     }
   });
 
-  // eslint-disable-next-line max-len
-  router.get('/short-bodies', accessTokenParser, loginRequired, validator.pageIdsRequired, async(req: AuthorizedRequest, res: ApiV3Response) => {
-    const { pageIds } = req.query;
-
-    try {
-      // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
-      // const shortBodiesMap = await crowi.pageService!.shortBodiesMapByPageIds(pageIds as string[], req.user);
-      // return res.apiv3({ shortBodiesMap });
-      return res.apiv3();
-    }
-    catch (err) {
-      logger.error('Error occurred while fetching shortBodiesMap.', err);
-      return res.apiv3Err(new ErrorV3('Error occurred while fetching shortBodiesMap.'));
-    }
-  });
-
   return router;
 };

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

@@ -4,6 +4,8 @@ import loggerFactory from '~/utils/logger';
 import { AllSubscriptionStatusType } from '~/interfaces/subscription';
 import Subscription from '~/server/models/subscription';
 
+import { apiV3FormValidator } from '../../middlewares/apiv3-form-validator';
+
 const logger = loggerFactory('growi:routes:apiv3:page'); // eslint-disable-line no-unused-vars
 
 const express = require('express');
@@ -158,7 +160,6 @@ module.exports = (crowi) => {
   const loginRequired = require('../../middlewares/login-required')(crowi, true);
   const loginRequiredStrictly = require('../../middlewares/login-required')(crowi);
   const csrf = require('../../middlewares/csrf')(crowi);
-  const apiV3FormValidator = require('../../middlewares/apiv3-form-validator')(crowi);
   const certifySharedPage = require('../../middlewares/certify-shared-page')(crowi);
 
   const globalNotificationService = crowi.getGlobalNotificationService();
@@ -363,13 +364,13 @@ module.exports = (crowi) => {
     const { pageId } = req.query;
 
     try {
-      const pageWithMeta = await pageService.findPageAndMetaDataByViewer(pageId, null, user, isSharedPage);
+      const pageWithMeta = await pageService.findPageAndMetaDataByViewer(pageId, null, user, true, isSharedPage);
 
       if (pageWithMeta == null) {
         return res.apiv3Err(`Page '${pageId}' is not found or forbidden`);
       }
 
-      return res.apiv3(pageWithMeta.pageMeta);
+      return res.apiv3(pageWithMeta.meta);
     }
     catch (err) {
       logger.error('get-page-info', err);

+ 12 - 14
packages/app/src/server/routes/apiv3/pages.js

@@ -2,6 +2,8 @@ import loggerFactory from '~/utils/logger';
 
 import { subscribeRuleNames } from '~/interfaces/in-app-notification';
 
+import { apiV3FormValidator } from '../../middlewares/apiv3-form-validator';
+
 const logger = loggerFactory('growi:routes:apiv3:pages'); // eslint-disable-line no-unused-vars
 const express = require('express');
 const { pathUtils, pagePathUtils } = require('@growi/core');
@@ -142,7 +144,6 @@ module.exports = (crowi) => {
   const loginRequiredStrictly = require('../../middlewares/login-required')(crowi);
   const adminRequired = require('../../middlewares/admin-required')(crowi);
   const csrf = require('../../middlewares/csrf')(crowi);
-  const apiV3FormValidator = require('../../middlewares/apiv3-form-validator')(crowi);
 
   const Page = crowi.model('Page');
   const User = crowi.model('User');
@@ -175,6 +176,7 @@ module.exports = (crowi) => {
       body('newPagePath').isLength({ min: 1 }).withMessage('newPagePath is required'),
       body('isRenameRedirect').if(value => value != null).isBoolean().withMessage('isRenameRedirect must be boolean'),
       body('isRemainMetadata').if(value => value != null).isBoolean().withMessage('isRemainMetadata must be boolean'),
+      body('isMoveMode').if(value => value != null).isBoolean().withMessage('isMoveMode must be boolean'),
     ],
     duplicatePage: [
       body('pageId').isMongoId().withMessage('pageId is required'),
@@ -473,6 +475,7 @@ module.exports = (crowi) => {
     const options = {
       createRedirectPage: req.body.isRenameRedirect,
       updateMetadata: !req.body.isRemainMetadata,
+      isMoveMode: req.body.isMoveMode,
     };
 
     if (!isCreatablePage(newPagePath)) {
@@ -718,10 +721,10 @@ module.exports = (crowi) => {
     const limit = parseInt(req.query.limit) || LIMIT_FOR_LIST;
 
     try {
-      const pageData = await Page.findByPath(path);
+      const pageData = await Page.findByPath(path, true);
       const result = await Page.findManageableListWithDescendants(pageData, req.user, { limit });
 
-      return res.apiv3({ subordinatedPaths: result });
+      return res.apiv3({ subordinatedPages: result });
     }
     catch (err) {
       return res.apiv3Err(new ErrorV3('Failed to update page.', 'unknown'), 500);
@@ -769,7 +772,8 @@ module.exports = (crowi) => {
     }
 
     // run delete
-    crowi.pageService.deleteMultiplePages(pagesCanBeDeleted, req.user, isCompletely, isRecursively);
+    const options = { isCompletely, isRecursively };
+    crowi.pageService.deleteMultiplePages(pagesCanBeDeleted, req.user, options);
 
     return res.apiv3({ paths: pagesCanBeDeleted.map(p => p.path), isRecursively, isCompletely });
   });
@@ -799,17 +803,11 @@ module.exports = (crowi) => {
       return res.apiv3Err(new ErrorV3(`The maximum number of pages you can select is ${LIMIT_FOR_MULTIPLE_PAGE_OP}.`, 'exceeded_maximum_number'), 400);
     }
 
-    if (isRecursively) {
-      // this method innerly uses socket to send message
-      crowi.pageService.normalizeParentRecursivelyByPageIds(pageIds, req.user);
+    try {
+      await crowi.pageService.normalizeParentByPageIds(pageIds, req.user, isRecursively);
     }
-    else {
-      try {
-        await crowi.pageService.normalizeParentByPageIds(pageIds, req.user);
-      }
-      catch (err) {
-        return res.apiv3Err(new ErrorV3(`Failed to migrate pages: ${err.message}`), 500);
-      }
+    catch (err) {
+      return res.apiv3Err(new ErrorV3(`Failed to migrate pages: ${err.message}`), 500);
     }
 
     return res.apiv3({});

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

@@ -4,6 +4,7 @@ import loggerFactory from '~/utils/logger';
 
 import { listLocaleIds } from '~/utils/locale-utils';
 
+import { apiV3FormValidator } from '../../middlewares/apiv3-form-validator';
 import EditorSettings from '../../models/editor-settings';
 import InAppNotificationSettings from '../../models/in-app-notification-settings';
 
@@ -69,7 +70,6 @@ module.exports = (crowi) => {
   const accessTokenParser = require('../../middlewares/access-token-parser')(crowi);
   const loginRequiredStrictly = require('../../middlewares/login-required')(crowi);
   const csrf = require('../../middlewares/csrf')(crowi);
-  const apiV3FormValidator = require('../../middlewares/apiv3-form-validator')(crowi);
 
   const { User, ExternalAccount } = crowi.models;
 

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

@@ -1,5 +1,7 @@
 import loggerFactory from '~/utils/logger';
 
+import { apiV3FormValidator } from '../../middlewares/apiv3-form-validator';
+
 const logger = loggerFactory('growi:routes:apiv3:pages');
 
 const express = require('express');
@@ -59,7 +61,6 @@ module.exports = (crowi) => {
   const certifySharedPage = require('../../middlewares/certify-shared-page')(crowi);
   const accessTokenParser = require('../../middlewares/access-token-parser')(crowi);
   const loginRequired = require('../../middlewares/login-required')(crowi, true);
-  const apiV3FormValidator = require('../../middlewares/apiv3-form-validator')(crowi);
 
   const {
     Revision,

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

@@ -1,5 +1,7 @@
 import loggerFactory from '~/utils/logger';
 
+import { apiV3FormValidator } from '../../middlewares/apiv3-form-validator';
+
 const logger = loggerFactory('growi:routes:apiv3:search'); // eslint-disable-line no-unused-vars
 
 const express = require('express');
@@ -21,7 +23,6 @@ module.exports = (crowi) => {
   const loginRequired = require('../../middlewares/login-required')(crowi);
   const adminRequired = require('../../middlewares/admin-required')(crowi);
   const csrf = require('../../middlewares/csrf')(crowi);
-  const apiV3FormValidator = require('../../middlewares/apiv3-form-validator')(crowi);
 
   /**
    * @swagger

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

@@ -1,6 +1,8 @@
 import loggerFactory from '~/utils/logger';
 import { removeNullPropertyFromObject } from '~/utils/object-utils';
 
+import { apiV3FormValidator } from '../../middlewares/apiv3-form-validator';
+
 const logger = loggerFactory('growi:routes:apiv3:security-setting');
 
 const express = require('express');
@@ -332,7 +334,6 @@ module.exports = (crowi) => {
   const loginRequiredStrictly = require('../../middlewares/login-required')(crowi);
   const adminRequired = require('../../middlewares/admin-required')(crowi);
   const csrf = require('../../middlewares/csrf')(crowi);
-  const apiV3FormValidator = require('../../middlewares/apiv3-form-validator')(crowi);
 
   async function updateAndReloadStrategySettings(authId, params) {
     const { configManager, passportService } = crowi;

+ 2 - 1
packages/app/src/server/routes/apiv3/share-links.js

@@ -2,6 +2,8 @@
 /* eslint-disable no-unused-vars */
 import loggerFactory from '~/utils/logger';
 
+import { apiV3FormValidator } from '../../middlewares/apiv3-form-validator';
+
 const logger = loggerFactory('growi:routes:apiv3:share-links');
 
 const express = require('express');
@@ -26,7 +28,6 @@ module.exports = (crowi) => {
   const loginRequired = require('../../middlewares/login-required')(crowi);
   const adminRequired = require('../../middlewares/admin-required')(crowi);
   const csrf = require('../../middlewares/csrf')(crowi);
-  const apiV3FormValidator = require('../../middlewares/apiv3-form-validator')(crowi);
   const ShareLink = crowi.model('ShareLink');
   const Page = crowi.model('Page');
 

+ 2 - 1
packages/app/src/server/routes/apiv3/slack-integration-legacy-settings.js

@@ -1,5 +1,7 @@
 import loggerFactory from '~/utils/logger';
 
+import { apiV3FormValidator } from '../../middlewares/apiv3-form-validator';
+
 // eslint-disable-next-line no-unused-vars
 const logger = loggerFactory('growi:routes:apiv3:slack-integration-legacy-setting');
 
@@ -47,7 +49,6 @@ module.exports = (crowi) => {
   const loginRequiredStrictly = require('../../middlewares/login-required')(crowi);
   const adminRequired = require('../../middlewares/admin-required')(crowi);
   const csrf = require('../../middlewares/csrf')(crowi);
-  const apiV3FormValidator = require('../../middlewares/apiv3-form-validator')(crowi);
 
   /**
    * @swagger

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

@@ -2,6 +2,8 @@ import { SlackbotType, defaultSupportedSlackEventActions } from '@growi/slack';
 
 import loggerFactory from '~/utils/logger';
 
+import { apiV3FormValidator } from '../../middlewares/apiv3-form-validator';
+
 const mongoose = require('mongoose');
 const express = require('express');
 const { body, query, param } = require('express-validator');
@@ -52,7 +54,6 @@ module.exports = (crowi) => {
   const loginRequiredStrictly = require('../../middlewares/login-required')(crowi);
   const adminRequired = require('../../middlewares/admin-required')(crowi);
   const csrf = require('../../middlewares/csrf')(crowi);
-  const apiV3FormValidator = require('../../middlewares/apiv3-form-validator')(crowi);
   const SlackAppIntegration = crowi.model('SlackAppIntegration');
 
   const validator = {

+ 2 - 1
packages/app/src/server/routes/apiv3/user-group.js

@@ -2,6 +2,8 @@ import loggerFactory from '~/utils/logger';
 import { excludeTestIdsFromTargetIds } from '~/server/util/compare-objectId';
 import UserGroup from '~/server/models/user-group';
 
+import { apiV3FormValidator } from '../../middlewares/apiv3-form-validator';
+
 const logger = loggerFactory('growi:routes:apiv3:user-group'); // eslint-disable-line no-unused-vars
 
 const express = require('express');
@@ -31,7 +33,6 @@ module.exports = (crowi) => {
   const loginRequiredStrictly = require('../../middlewares/login-required')(crowi);
   const adminRequired = require('../../middlewares/admin-required')(crowi);
   const csrf = require('../../middlewares/csrf')(crowi);
-  const apiV3FormValidator = require('../../middlewares/apiv3-form-validator')(crowi);
 
   const {
     UserGroupRelation,

+ 2 - 1
packages/app/src/server/routes/apiv3/user-ui-settings.ts

@@ -4,6 +4,8 @@ import { AllSidebarContentsType } from '~/interfaces/ui';
 
 import loggerFactory from '~/utils/logger';
 
+import { apiV3FormValidator } from '../../middlewares/apiv3-form-validator';
+
 import UserUISettings from '../../models/user-ui-settings';
 import ErrorV3 from '../../models/vo/error-apiv3';
 
@@ -14,7 +16,6 @@ const router = express.Router();
 module.exports = (crowi) => {
   const loginRequiredStrictly = require('../../middlewares/login-required')(crowi);
   const csrf = require('../../middlewares/csrf')(crowi);
-  const apiV3FormValidator = require('../../middlewares/apiv3-form-validator')(crowi);
 
   const validatorForPut = [
     body('settings').exists().withMessage('The body param \'settings\' is required'),

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

@@ -1,5 +1,7 @@
 import loggerFactory from '~/utils/logger';
 
+import { apiV3FormValidator } from '../../middlewares/apiv3-form-validator';
+
 const logger = loggerFactory('growi:routes:apiv3:user-group');
 
 const express = require('express');
@@ -74,7 +76,6 @@ module.exports = (crowi) => {
   const loginRequiredStrictly = require('../../middlewares/login-required')(crowi);
   const adminRequired = require('../../middlewares/admin-required')(crowi);
   const csrf = require('../../middlewares/csrf')(crowi);
-  const apiV3FormValidator = require('../../middlewares/apiv3-form-validator')(crowi);
 
   const {
     User,

+ 25 - 13
packages/app/src/server/routes/page.js

@@ -266,12 +266,15 @@ module.exports = function(crowi, app) {
     renderVars.targetAndAncestors = { targetAndAncestors, rootPage };
   }
 
-  function addRenderVarsWhenNotFound(renderVars, pathOrId) {
+  async function addRenderVarsWhenNotFound(renderVars, pathOrId) {
     if (pathOrId == null) {
       return;
     }
 
     renderVars.notFoundTargetPathOrId = pathOrId;
+
+    const isPath = pathOrId.includes('/');
+    renderVars.isNotFoundPermalink = !isPath && !await Page.exists({ _id: pathOrId });
   }
 
   function replacePlaceholdersOfTemplate(template, req) {
@@ -329,7 +332,7 @@ module.exports = function(crowi, app) {
     await addRenderVarsForDescendants(renderVars, path, req.user, offset, limit, true);
     await addRenderVarsForPageTree(renderVars, pathOrId, req.user);
 
-    addRenderVarsWhenNotFound(renderVars, pathOrId);
+    await addRenderVarsWhenNotFound(renderVars, pathOrId);
 
     return res.render(view, renderVars);
   }
@@ -344,9 +347,16 @@ module.exports = function(crowi, app) {
       next();
     }
 
+    // empty page
     if (page.isEmpty) {
-      req.pagePath = page.path;
-      return next();
+      // redirect to page (path) url
+      const url = new URL('https://dummy.origin');
+      url.pathname = page.path;
+      Object.entries(req.query).forEach(([key, value], i) => {
+        url.searchParams.append(key, value);
+      });
+      return res.safeRedirect(urljoin(url.pathname, url.search));
+
     }
 
     const renderVars = {};
@@ -409,8 +419,13 @@ module.exports = function(crowi, app) {
 
     // empty page
     if (page.isEmpty) {
-      req.pagePath = page.path;
-      return _notFound(req, res);
+      // redirect to page (path) url
+      const url = new URL('https://dummy.origin');
+      url.pathname = page.path;
+      Object.entries(req.query).forEach(([key, value], i) => {
+        url.searchParams.append(key, value);
+      });
+      return res.safeRedirect(urljoin(url.pathname, url.search));
     }
 
     const { path } = page; // this must exist
@@ -484,8 +499,8 @@ module.exports = function(crowi, app) {
 
     const shareLink = await ShareLink.findOne({ _id: linkId }).populate('relatedPage');
 
-    if (shareLink == null || shareLink.relatedPage == null) {
-      // page or sharelink are not found
+    if (shareLink == null || shareLink.relatedPage == null || shareLink.relatedPage.isEmpty) {
+      // page or sharelink are not found (or page is empty: abnormaly)
       return res.render('layout-growi/not_found_shared_page');
     }
     if (crowi.configManager.getConfig('crowi', 'security:disableLinkSharing')) {
@@ -601,10 +616,6 @@ module.exports = function(crowi, app) {
     }
 
     if (pages.length === 1) {
-      if (pages[0].isEmpty) {
-        return _notFound(req, res);
-      }
-
       const url = new URL('https://dummy.origin');
       url.pathname = `/${pages[0]._id}`;
       Object.entries(req.query).forEach(([key, value], i) => {
@@ -613,7 +624,8 @@ module.exports = function(crowi, app) {
       return res.safeRedirect(urljoin(url.pathname, url.search));
     }
 
-    const isForbidden = await Page.exists({ path });
+    // Exclude isEmpty page to handle _notFound or forbidden
+    const isForbidden = await Page.exists({ path, isEmpty: false });
     if (isForbidden) {
       req.isForbidden = true;
       return _notFound(req, res);

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

@@ -352,24 +352,17 @@ 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(pageIds: ObjectIdLike[]): Promise<[(PageDocument & { _id: any })[], (PageDocument & { _id: any })[]]> {
-    if (pageIds.length > LIMIT_FOR_MULTIPLE_PAGE_OP) {
+  async separateNormalizableAndNotNormalizablePages(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 { PageQueryBuilder } = Page;
     const shouldCheckDescendants = true;
     const shouldIncludeNotMigratedPages = true;
 
     const normalizable: (PageDocument & { _id: any })[] = [];
     const nonNormalizable: (PageDocument & { _id: any })[] = []; // can be used to tell user which page failed to migrate
 
-    const builder = new PageQueryBuilder(Page.find());
-    builder.addConditionToListByPageIdsArray(pageIds);
-
-    const pages = await builder.query.exec();
-
     for await (const page of pages) {
       const {
         path, grant, grantedUsers: grantedUserIds, grantedGroup: grantedGroupId,

+ 66 - 0
packages/app/src/server/service/page-operation.ts

@@ -0,0 +1,66 @@
+import { pagePathUtils } from '@growi/core';
+
+import PageOperation from '~/server/models/page-operation';
+
+const { isEitherOfPathAreaOverlap, isPathAreaOverlap, isTrashPage } = pagePathUtils;
+
+class PageOperationService {
+
+  crowi: any;
+
+  constructor(crowi) {
+    this.crowi = crowi;
+
+    // TODO: Remove this code when resuming feature is implemented
+    PageOperation.deleteMany();
+  }
+
+  /**
+   * Check if the operation is operatable
+   * @param isRecursively Boolean that determines whether the operation is recursive or not
+   * @param fromPathToOp The path to operate from
+   * @param toPathToOp The path to operate to
+   * @returns boolean
+   */
+  async canOperate(isRecursively: boolean, fromPathToOp: string | null, toPathToOp: string | null): Promise<boolean> {
+    const mainOps = await PageOperation.findMainOps();
+
+    if (mainOps.length === 0) {
+      return true;
+    }
+
+    const toPaths = mainOps.map(op => op.toPath).filter((p): p is string => p != null);
+
+    if (isRecursively) {
+
+      if (fromPathToOp != null && !isTrashPage(fromPathToOp)) {
+        const flag = toPaths.some(p => isEitherOfPathAreaOverlap(p, fromPathToOp));
+        if (flag) return false;
+      }
+
+      if (toPathToOp != null && !isTrashPage(toPathToOp)) {
+        const flag = toPaths.some(p => isPathAreaOverlap(p, toPathToOp));
+        if (flag) return false;
+      }
+
+    }
+    else {
+
+      if (fromPathToOp != null && !isTrashPage(fromPathToOp)) {
+        const flag = toPaths.some(p => isPathAreaOverlap(p, fromPathToOp));
+        if (flag) return false;
+      }
+
+      if (toPathToOp != null && !isTrashPage(toPathToOp)) {
+        const flag = toPaths.some(p => isPathAreaOverlap(p, toPathToOp));
+        if (flag) return false;
+      }
+
+    }
+
+    return true;
+  }
+
+}
+
+export default PageOperationService;

Разница между файлами не показана из-за своего большого размера
+ 425 - 206
packages/app/src/server/service/page.ts


+ 8 - 4
packages/app/src/server/service/search-delegator/private-legacy-pages.ts

@@ -32,10 +32,14 @@ class PrivateLegacyPagesDelegator implements SearchDelegator<IPage> {
     const Page = mongoose.model('Page') as unknown as PageModel;
     const { PageQueryBuilder } = Page;
 
-    const queryBuilder = new PageQueryBuilder(Page.find());
-    await queryBuilder.addConditionAsMigratablePages(user);
+    const countQueryBuilder = new PageQueryBuilder(Page.find());
+    await countQueryBuilder.addConditionAsMigratablePages(user);
+    const findQueryBuilder = new PageQueryBuilder(Page.find());
+    await findQueryBuilder.addConditionAsMigratablePages(user);
 
-    const _pages: PageDocument[] = await queryBuilder
+    const total = await countQueryBuilder.query.count();
+
+    const _pages: PageDocument[] = await findQueryBuilder
       .addConditionToPagenate(offset, limit)
       .query
       .populate('lastUpdateUser')
@@ -49,7 +53,7 @@ class PrivateLegacyPagesDelegator implements SearchDelegator<IPage> {
     return {
       data: pages,
       meta: {
-        total: pages.length,
+        total,
         hitsCount: pages.length,
       },
     };

+ 13 - 6
packages/app/src/server/service/search.ts

@@ -1,7 +1,8 @@
 import xss from 'xss';
 
 import { SearchDelegatorName } from '~/interfaces/named-query';
-import { IFormattedSearchResult, ISearchResult, ISearchResultMeta } from '~/interfaces/search';
+import { IPageWithMeta } from '~/interfaces/page';
+import { IFormattedSearchResult, IPageSearchMeta, ISearchResult } from '~/interfaces/search';
 import loggerFactory from '~/utils/logger';
 
 import NamedQuery from '../models/named-query';
@@ -17,6 +18,8 @@ import { serializeUserSecurely } from '../models/serializers/user-serializer';
 // eslint-disable-next-line no-unused-vars
 const logger = loggerFactory('growi:service:search');
 
+const nonNullable = <T>(value: T): value is NonNullable<T> => value != null;
+
 // options for filtering xss
 const filterXssOptions = {
   whiteList: {
@@ -350,10 +353,9 @@ class SearchService implements SearchQueryParser, SearchResolver {
    */
   async formatSearchResult(searchResult: ISearchResult<any>, delegatorName): Promise<IFormattedSearchResult> {
     if (!this.checkIsFormattable(searchResult, delegatorName)) {
-      const data = searchResult.data.map((page) => {
+      const data: IPageWithMeta<IPageSearchMeta>[] = searchResult.data.map((page) => {
         return {
-          pageData: page,
-          pageMeta: {},
+          data: page,
         };
       });
 
@@ -378,11 +380,15 @@ class SearchService implements SearchQueryParser, SearchResolver {
     result.meta = searchResult.meta;
 
     // set search result page data
-    result.data = searchResult.data.map((data) => {
+    const pages: (IPageWithMeta<IPageSearchMeta> | null)[] = searchResult.data.map((data) => {
       const pageData = findPageResult.pages.find((pageData) => {
         return pageData.id === data._id;
       });
 
+      if (pageData == null) {
+        return null;
+      }
+
       // add tags and seenUserCount to pageData
       pageData._doc.tags = data._source.tag_names;
       pageData._doc.seenUserCount = (pageData.seenUsers && pageData.seenUsers.length) || 0;
@@ -417,9 +423,10 @@ class SearchService implements SearchQueryParser, SearchResolver {
         elasticSearchResult,
       };
 
-      return { pageData, pageMeta };
+      return { data: pageData, meta: pageMeta };
     });
 
+    result.data = pages.filter(nonNullable);
     return result;
   }
 

+ 5 - 0
packages/app/src/server/views/layout-growi/not_found.html

@@ -8,6 +8,11 @@
     data-not-found-target-path-or-id="{% if notFoundTargetPathOrId %}{{notFoundTargetPathOrId|json}}{% endif %}"
   >
   </div>
+  <div
+    id="growi-not-found-context"
+    data-is-not-found-permalink="{% if isNotFoundPermalink %}{{isNotFoundPermalink|json}}{% endif %}"
+  >
+  </div>
   <div class="grw-container-convertible">
     {% include '../widget/page_alerts.html' %}
     <div id="not-found-alert"></div>

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

@@ -28,16 +28,13 @@
       {% endif %}
     {% endif %}
 
-    {% if redirectFrom or req.query.renamedFrom or req.query.redirectFrom %}
+    {% if redirectFrom or req.query.redirectFrom %}
     <div class="alert alert-pink d-edit-none py-3 px-4 d-flex align-items-center justify-content-between">
       <span>
-        {% set fromPath = req.query.renamedFrom or req.query.redirectFrom %}
+        {% set fromPath = req.query.redirectFrom %}
         {% if redirectFrom or req.query.redirectFrom %}
         <div id="redirected-alert"></div>
         {% endif %}
-        {% if req.query.renamedFrom %}
-        <div id="renamed-alert"></div>
-        {% endif %}
       </span>
       {% set hasRedirectLink = redirectFrom or req.query.redirectFrom or req.query.withRedirect %}
       {% if user and not page.isDeleted() and hasRedirectLink %}
@@ -49,10 +46,6 @@
     </div>
     {% endif %}
 
-    {% if req.query.duplicated and not page.isDeleted() %}
-    <div id="duplicated-alert"></div>
-    {% endif %}
-
     {% if req.query.unlinked %}
     <div class="alert alert-info d-edit-none py-3 px-4">
       <strong>{{ t('Unlinked') }}: </strong> {{ t('page_page.notice.unlinked') }}

+ 5 - 1
packages/app/src/stores/context.tsx

@@ -7,7 +7,7 @@ import { IUser } from '../interfaces/user';
 
 import { useStaticSWR } from './use-static-swr';
 
-import { TargetAndAncestors, NotFoundTargetPathOrId } from '../interfaces/page-listing-results';
+import { TargetAndAncestors, NotFoundTargetPathOrId, IsNotFoundPermalink } from '../interfaces/page-listing-results';
 
 type Nullable<T> = T | null;
 
@@ -131,6 +131,10 @@ export const useNotFoundTargetPathOrId = (initialData?: Nullable<NotFoundTargetP
   return useStaticSWR<Nullable<NotFoundTargetPathOrId>, Error>('notFoundTargetPathOrId', initialData);
 };
 
+export const useIsNotFoundPermalink = (initialData?: Nullable<IsNotFoundPermalink>): SWRResponse<Nullable<IsNotFoundPermalink>, Error> => {
+  return useStaticSWR<Nullable<IsNotFoundPermalink>, Error>('isNotFoundPermalink', initialData);
+};
+
 export const useIsAclEnabled = (initialData?: boolean) : SWRResponse<boolean, Error> => {
   return useStaticSWR<boolean, Error>('isAclEnabled', initialData);
 };

+ 22 - 21
packages/app/src/stores/modal.tsx

@@ -1,6 +1,7 @@
 import { SWRResponse } from 'swr';
 import { useStaticSWR } from './use-static-swr';
-import { OnDeletedFunction } from '~/interfaces/ui';
+import { OnDuplicatedFunction, OnRenamedFunction, OnDeletedFunction } from '~/interfaces/ui';
+import { IPageToDeleteWithMeta } from '~/interfaces/page';
 
 
 /*
@@ -27,29 +28,19 @@ export const usePageCreateModal = (status?: CreateModalStatus): SWRResponse<Crea
   };
 };
 
-/*
-* PageDeleteModal
-*/
-export type IPageForPageDeleteModal = {
-  pageId: string,
-  revisionId?: string,
-  path: string
-  isAbleToDeleteCompletely?: boolean,
-}
-
 export type IDeleteModalOption = {
   onDeleted?: OnDeletedFunction,
 }
 
 type DeleteModalStatus = {
   isOpened: boolean,
-  pages?: IPageForPageDeleteModal[],
+  pages?: IPageToDeleteWithMeta[],
   opts?: IDeleteModalOption,
 }
 
 type DeleteModalStatusUtils = {
   open(
-    pages?: IPageForPageDeleteModal[],
+    pages?: IPageToDeleteWithMeta[],
     opts?: IDeleteModalOption,
   ): Promise<DeleteModalStatus | undefined>,
   close(): Promise<DeleteModalStatus | undefined>,
@@ -65,7 +56,7 @@ export const usePageDeleteModal = (status?: DeleteModalStatus): SWRResponse<Dele
   return {
     ...swrResponse,
     open: (
-        pages?: IPageForPageDeleteModal[],
+        pages?: IPageToDeleteWithMeta[],
         opts?: IDeleteModalOption,
     ) => swrResponse.mutate({
       isOpened: true, pages, opts,
@@ -82,25 +73,35 @@ export type IPageForPageDuplicateModal = {
   path: string
 }
 
+export type IDuplicateModalOption = {
+  onDuplicated?: OnDuplicatedFunction,
+}
+
 type DuplicateModalStatus = {
   isOpened: boolean,
-  pageId?: string,
-  path?: string,
+  page?: IPageForPageDuplicateModal,
+  opts?: IDuplicateModalOption,
 }
 
 type DuplicateModalStatusUtils = {
-  open(pageId: string, path: string): Promise<DuplicateModalStatus | undefined>
+  open(
+    page?: IPageForPageDuplicateModal,
+    opts?: IDuplicateModalOption
+  ): Promise<DuplicateModalStatus | undefined>
   close(): Promise<DuplicateModalStatus | undefined>
 }
 
 export const usePageDuplicateModal = (status?: DuplicateModalStatus): SWRResponse<DuplicateModalStatus, Error> & DuplicateModalStatusUtils => {
-  const initialData: DuplicateModalStatus = { isOpened: false, pageId: '', path: '' };
+  const initialData: DuplicateModalStatus = { isOpened: false, page: { pageId: '', path: '/' } };
   const swrResponse = useStaticSWR<DuplicateModalStatus, Error>('duplicateModalStatus', status, { fallbackData: initialData });
 
   return {
     ...swrResponse,
-    open: (pageId: string, path: string) => swrResponse.mutate({ isOpened: true, pageId, path }),
-    close: () => swrResponse.mutate({ isOpened: false }),
+    open: (
+        page?: IPageForPageDuplicateModal,
+        opts?: IDuplicateModalOption,
+    ) => swrResponse.mutate({ isOpened: true, page, opts }),
+    close: () => swrResponse.mutate({ isOpened: false, page: { pageId: '', path: '/' } }),
   };
 };
 
@@ -115,7 +116,7 @@ export type IPageForPageRenameModal = {
 }
 
 export type IRenameModalOption = {
-  onDeleted?: OnDeletedFunction,
+  onRenamed?: OnRenamedFunction,
 }
 
 type RenameModalStatus = {

+ 42 - 6
packages/app/src/stores/page.tsx

@@ -2,9 +2,10 @@ import useSWR, { SWRResponse } from 'swr';
 import useSWRImmutable from 'swr/immutable';
 
 import { apiv3Get } from '~/client/util/apiv3-client';
+import { HasObjectId } from '~/interfaces/has-object-id';
 
 import {
-  IPageInfo, IPageHasId, IPageInfoForOperation, IPageInfoForListing,
+  IPageInfo, IPageHasId, IPageInfoForOperation, IPageInfoForListing, IDataWithMeta,
 } from '~/interfaces/page';
 import { IPagingResult } from '~/interfaces/paging-result';
 import { apiGet } from '../client/util/apiv1-client';
@@ -82,18 +83,53 @@ export const useSWRxPageInfo = (
     shareLinkId?: string | null,
 ): SWRResponse<IPageInfo | IPageInfoForOperation, Error> => {
 
+  // assign null if shareLinkId is undefined in order to identify SWR key only by pageId
+  const fixedShareLinkId = shareLinkId ?? null;
+
   return useSWRImmutable(
-    pageId != null ? ['/page/info', pageId, shareLinkId] : null,
+    pageId != null ? ['/page/info', pageId, fixedShareLinkId] : null,
     (endpoint, pageId, shareLinkId) => apiv3Get(endpoint, { pageId, shareLinkId }).then(response => response.data),
   );
 };
 
-export const useSWRxPageInfoForList = (pageIds: string[] | null | undefined): SWRResponse<Record<string, IPageInfo | IPageInfoForListing>, Error> => {
+type PageInfoInjector = {
+  injectTo: <D extends HasObjectId>(pages: (D | IDataWithMeta<D>)[]) => IDataWithMeta<D, IPageInfoForOperation>[],
+}
+
+const isIDataWithMeta = (item: HasObjectId | IDataWithMeta): item is IDataWithMeta => {
+  return 'data' in item;
+};
+
+export const useSWRxPageInfoForList = (
+    pageIds: string[] | null | undefined,
+    attachBookmarkCount = false,
+    attachShortBody = false,
+): SWRResponse<Record<string, IPageInfoForListing>, Error> & PageInfoInjector => {
 
   const shouldFetch = pageIds != null && pageIds.length > 0;
 
-  return useSWRImmutable(
-    shouldFetch ? ['/page-listing/info', pageIds] : null,
-    (endpoint, pageIds) => apiv3Get(endpoint, { pageIds }).then(response => response.data),
+  const swrResult = useSWRImmutable<Record<string, IPageInfoForListing>>(
+    shouldFetch ? ['/page-listing/info', pageIds, attachBookmarkCount, attachShortBody] : null,
+    (endpoint, pageIds, attachBookmarkCount, attachShortBody) => {
+      return apiv3Get(endpoint, { pageIds, attachBookmarkCount, attachShortBody }).then(response => response.data);
+    },
   );
+
+  return {
+    ...swrResult,
+    injectTo: <D extends HasObjectId>(pages: (D | IDataWithMeta<D>)[]) => {
+      return pages.map((item) => {
+        const page = isIDataWithMeta(item) ? item.data : item;
+        const orgPageMeta = isIDataWithMeta(item) ? item.meta : undefined;
+
+        // get an applicable IPageInfo
+        const applicablePageInfo = (swrResult.data ?? {})[page._id];
+
+        return {
+          data: page,
+          meta: applicablePageInfo ?? orgPageMeta,
+        };
+      });
+    },
+  };
 };

+ 4 - 3
packages/app/src/stores/ui.tsx

@@ -13,7 +13,7 @@ import loggerFactory from '~/utils/logger';
 import { useStaticSWR } from './use-static-swr';
 import {
   useCurrentPageId, useCurrentPagePath, useIsEditable, useIsTrashPage, useIsUserPage,
-  useIsNotCreatable, useIsSharedUser, useNotFoundTargetPathOrId, useIsForbidden, useIsIdenticalPath,
+  useIsNotCreatable, useIsSharedUser, useNotFoundTargetPathOrId, useIsForbidden, useIsIdenticalPath, useIsNotFoundPermalink,
 } from './context';
 import { IFocusable } from '~/client/interfaces/focusable';
 import { Nullable } from '~/interfaces/common';
@@ -311,12 +311,13 @@ export const useIsAbleToShowPageEditorModeManager = (): SWRResponse<boolean, Err
   const { data: isForbidden } = useIsForbidden();
   const { data: isTrashPage } = useIsTrashPage();
   const { data: isSharedUser } = useIsSharedUser();
+  const { data: isNotFoundPermalink } = useIsNotFoundPermalink();
 
-  const includesUndefined = [isNotCreatable, isForbidden, isTrashPage, isSharedUser].some(v => v === undefined);
+  const includesUndefined = [isNotCreatable, isForbidden, isTrashPage, isSharedUser, isNotFoundPermalink].some(v => v === undefined);
 
   return useSWRImmutable(
     includesUndefined ? null : key,
-    () => !isNotCreatable && !isForbidden && !isTrashPage && !isSharedUser,
+    () => !isNotCreatable && !isForbidden && !isTrashPage && !isSharedUser && !isNotFoundPermalink,
   );
 };
 

+ 6 - 2
packages/app/src/styles/_page-tree.scss

@@ -6,9 +6,13 @@ $grw-pagetree-item-padding-left: 10px;
   min-height: calc(100vh - ($grw-navbar-height + $grw-navbar-border-width + $grw-sidebar-content-header-height + $grw-sidebar-content-footer-height));
 
   .list-group-item {
+    .grw-visible-on-hover {
+      display: none;
+    }
+
     &:hover {
-      .grw-pagetree-control {
-        display: flex !important;
+      .grw-visible-on-hover {
+        display: block;
       }
 
       .grw-pagetree-count {

+ 68 - 79
packages/app/src/styles/_sidebar.scss

@@ -25,66 +25,6 @@
   // set the max value that should be taken when sticky
   height: calc(100vh - $grw-navbar-border-width);
 
-  .grw-navigation-resize-button {
-    position: fixed;
-
-    $width: 27.691px;
-    $height: 23.999px;
-
-    @mixin hitarea($size-hitarea) {
-      top: ($width - $size-hitarea) / 2;
-      left: ($height - $size-hitarea) / 2;
-      width: $size-hitarea;
-      height: $size-hitarea;
-    }
-
-    // locate to the center of screen
-    top: calc(50vh - $height/2);
-
-    padding: 0px;
-    background-color: transparent;
-    border: 0;
-    opacity: 0;
-    transition: opacity 300ms cubic-bezier(0.2, 0, 0, 1) 0s;
-    transform: translateX(-50%);
-
-    .hexagon-container {
-      // set transform
-      svg * {
-        transition: fill 100ms linear;
-      }
-      svg {
-        width: $width + 2px; // add 1px for drop-shadow
-        height: $height + 2px; // add 1px for drop-shadow
-        .background {
-          filter: drop-shadow(0px 1px 0px rgba(#999, 60%));
-        }
-      }
-    }
-    .hitarea {
-      @extend .rounded-pill;
-
-      position: absolute;
-      @include hitarea(30px);
-    }
-
-    // reverse and center icon at the time of collapsed
-    &.collapsed {
-      opacity: 1;
-      .hexagon-container svg {
-        transform: rotate(180deg);
-      }
-      .hitarea {
-        @include hitarea(80px);
-      }
-    }
-  }
-  &:hover {
-    .grw-navigation-resize-button {
-      opacity: 1;
-    }
-  }
-
   // override @atlaskit/navigation-next styles
   $navbar-total-height: $grw-navbar-height + $grw-navbar-border-width;
   .data-layout-container {
@@ -104,6 +44,7 @@
       flex-direction: row;
       height: 100%;
       overflow: hidden;
+
       .grw-contextual-navigation {
         position: relative;
         width: 240px;
@@ -137,6 +78,11 @@
           }
         }
       }
+
+      .grw-sidebar-content-container {
+        position: relative;
+        z-index: 110; // greater than the value of .grw-navigation-draggable to fix https://redmine.weseek.co.jp/issues/86678
+      }
     }
     .grw-navigation-draggable {
       position: absolute;
@@ -145,20 +91,6 @@
       left: 100%;
       z-index: 100; // greater than the value of slimScrollBar
       width: 0;
-      transform: unset; // unset for 'position: fixed' of .ak-navigation-resize-button
-      .grw-navigation-draggable-first-child {
-        position: absolute;
-        top: 0px;
-        bottom: 0px;
-        left: -3px;
-        width: 3px;
-        pointer-events: none;
-        background: linear-gradient(to left, rgba(0, 0, 0, 0.1) 0px, rgba(0, 0, 0, 0.1) 1px, rgba(0, 0, 0, 0.1) 1px, rgba(0, 0, 0, 0) 100%);
-        opacity: 0.5;
-        transition-timing-function: cubic-bezier(0.2, 0, 0, 1);
-        transition-duration: 0.22s;
-        transition-property: left, opacity, width;
-      }
       .grw-navigation-draggable-hitarea {
         position: relative;
         left: -4px;
@@ -168,14 +100,71 @@
         .grw-navigation-draggable-hitarea-child {
           position: absolute;
           left: 3px;
+          display: none;
           width: 2px;
           height: 100%;
           background-color: rgb(76, 154, 255);
-          opacity: 0;
-          transition: opacity 200ms ease 0s;
         }
-        &:hover .grw-navigation-draggable-hitarea-child {
-          opacity: 1;
+      }
+      .grw-navigation-resize-button {
+        position: fixed;
+
+        $width: 27.691px;
+        $height: 23.999px;
+
+        @mixin hitarea($size-hitarea) {
+          top: ($width - $size-hitarea) / 2;
+          left: ($height - $size-hitarea) / 2;
+          width: $size-hitarea;
+          height: $size-hitarea;
+        }
+
+        // locate to the center of screen
+        top: calc(50vh - $height/2);
+
+        display: none;
+        padding: 0px;
+        background-color: transparent;
+        border: 0;
+        transform: translateX(-50%);
+
+        .hexagon-container {
+          // set transform
+          svg * {
+            transition: fill 100ms linear;
+          }
+          svg {
+            width: $width + 2px; // add 1px for drop-shadow
+            height: $height + 2px; // add 1px for drop-shadow
+            .background {
+              filter: drop-shadow(0px 1px 0px rgba(#999, 60%));
+            }
+          }
+        }
+        .hitarea {
+          @extend .rounded-pill;
+
+          position: absolute;
+          @include hitarea(30px);
+        }
+
+        // reverse and center icon at the time of collapsed
+        &.collapsed {
+          display: block;
+          .hexagon-container svg {
+            transform: rotate(180deg);
+          }
+          .hitarea {
+            @include hitarea(80px);
+          }
+        }
+      }
+      &:hover {
+        .grw-navigation-draggable-hitarea-child {
+          display: block;
+        }
+        .grw-navigation-resize-button {
+          display: block;
         }
       }
     }
@@ -290,7 +279,7 @@
   }
 
   .grw-navigation-resize-button {
-    display: none;
+    display: none !important;
   }
 
   .grw-drawer-toggler {

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

@@ -79,6 +79,8 @@ context('Access to Admin page', () => {
   it('/admin/notification is successfully loaded', () => {
     cy.visit('/admin/notification');
     cy.getByTestid('admin-notification').should('be.visible');
+    // wait for retrieving slack integration status
+    cy.getByTestid('slack-integration-list-item').should('be.visible');
     cy.screenshot(`${ssPrefix}-admin-notification`, { capture: 'viewport' });
   });
 
@@ -109,6 +111,8 @@ context('Access to Admin page', () => {
   it('/admin/search is successfully loaded', () => {
     cy.visit('/admin/search');
     cy.getByTestid('admin-full-text-search').should('be.visible');
+    // wait for connected
+    cy.getByTestid('connection-status-badge-connected').should('be.visible');
     cy.screenshot(`${ssPrefix}-admin-search`, { capture: 'viewport' });
   });
 

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

@@ -11,6 +11,8 @@ context('Access to /me page', () => {
     cy.getCookie('connect.sid').then(cookie => {
       connectSid = cookie?.value;
     });
+    // collapse sidebar
+    cy.collapseSidebar(true);
   });
 
   beforeEach(() => {

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

@@ -12,6 +12,8 @@ context('Access to page', () => {
     cy.getCookie('connect.sid').then(cookie => {
       connectSid = cookie?.value;
     });
+    // collapse sidebar
+    cy.collapseSidebar(true);
   });
 
   beforeEach(() => {
@@ -22,12 +24,6 @@ context('Access to page', () => {
 
   it('/Sandbox is successfully loaded', () => {
     cy.visit('/Sandbox', {  });
-
-    // collapse sidebar and wait saving
-    cy.collapseSidebar(true);
-    // eslint-disable-next-line cypress/no-unnecessary-waiting
-    cy.wait(1500);
-
     cy.screenshot(`${ssPrefix}-sandbox`, { capture: 'viewport' });
   });
 

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

@@ -12,6 +12,8 @@ context('Access to special pages', () => {
     cy.getCookie('connect.sid').then(cookie => {
       connectSid = cookie?.value;
     });
+    // collapse sidebar
+    cy.collapseSidebar(true);
   });
 
   beforeEach(() => {
@@ -28,6 +30,13 @@ context('Access to special pages', () => {
 
   it('/tags is successfully loaded', () => {
     cy.visit('/tags');
+
+    // open sidebar
+    cy.collapseSidebar(false);
+    // select tags
+    cy.getByTestid('grw-sidebar-nav-primary-tags').click();
+    cy.getByTestid('grw-sidebar-content-tags').should('be.visible');
+
     cy.getByTestid('tags-page').should('be.visible');
     cy.screenshot(`${ssPrefix}-tags`, { capture: 'viewport' });
   });

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

@@ -12,6 +12,8 @@ context('Open PageCreateModal', () => {
     cy.getCookie('connect.sid').then(cookie => {
       connectSid = cookie?.value;
     });
+    // collapse sidebar
+    cy.collapseSidebar(true);
   });
 
   beforeEach(() => {

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