Browse Source

Merged master2

Taichi Masuyama 4 years ago
parent
commit
80ac57b40d
100 changed files with 2111 additions and 1576 deletions
  1. 6 3
      .github/workflows/ci-app.yml
  2. 6 3
      .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. 0 5
      packages/app/docker/README.md
  9. 8 8
      packages/app/package.json
  10. 1 0
      packages/app/resource/locales/en_US/admin/admin.json
  11. 12 16
      packages/app/resource/locales/en_US/translation.json
  12. 1 0
      packages/app/resource/locales/ja_JP/admin/admin.json
  13. 13 17
      packages/app/resource/locales/ja_JP/translation.json
  14. 1 0
      packages/app/resource/locales/zh_CN/admin/admin.json
  15. 21 23
      packages/app/resource/locales/zh_CN/translation.json
  16. 0 4
      packages/app/src/client/app.jsx
  17. 5 5
      packages/app/src/client/services/AdminHomeContainer.js
  18. 12 3
      packages/app/src/client/services/ContextExtractor.tsx
  19. 9 3
      packages/app/src/components/Admin/AdminHome/InstalledPluginTable.jsx
  20. 13 5
      packages/app/src/components/Admin/AdminHome/SystemInfomationTable.jsx
  21. 2 1
      packages/app/src/components/Admin/ElasticsearchManagement/StatusTable.jsx
  22. 1 1
      packages/app/src/components/Admin/Notification/NotificationSetting.jsx
  23. 95 0
      packages/app/src/components/Admin/UserGroup/UserGroupCreateModal.tsx
  24. 35 43
      packages/app/src/components/Admin/UserGroup/UserGroupPage.tsx
  25. 75 33
      packages/app/src/components/Admin/UserGroupDetail/UserGroupDetailPage.tsx
  26. 1 1
      packages/app/src/components/Admin/Users/UserTable.jsx
  27. 3 1
      packages/app/src/components/Common/ClosableTextInput.tsx
  28. 14 10
      packages/app/src/components/Common/Dropdown/PageItemControl.tsx
  29. 0 54
      packages/app/src/components/ComparePathsTable.jsx
  30. 41 36
      packages/app/src/components/DescendantsPageList.tsx
  31. 8 16
      packages/app/src/components/DuplicatedPathsTable.jsx
  32. 2 2
      packages/app/src/components/Fab.jsx
  33. 6 10
      packages/app/src/components/IdenticalPathPage.tsx
  34. 1 1
      packages/app/src/components/Navbar/GlobalSearch.tsx
  35. 19 10
      packages/app/src/components/Navbar/GrowiContextualSubNavigation.tsx
  36. 25 12
      packages/app/src/components/Navbar/SubNavButtons.tsx
  37. 5 3
      packages/app/src/components/Page.jsx
  38. 0 24
      packages/app/src/components/Page/DuplicatedAlert.jsx
  39. 17 12
      packages/app/src/components/Page/NotFoundAlert.tsx
  40. 0 22
      packages/app/src/components/Page/RenamedAlert.jsx
  41. 4 7
      packages/app/src/components/Page/RevisionRenderer.jsx
  42. 21 17
      packages/app/src/components/Page/TrashPageAlert.jsx
  43. 61 17
      packages/app/src/components/PageDeleteModal.tsx
  44. 102 70
      packages/app/src/components/PageDuplicateModal.tsx
  45. 12 6
      packages/app/src/components/PageList/PageList.tsx
  46. 40 36
      packages/app/src/components/PageList/PageListItemL.tsx
  47. 3 1
      packages/app/src/components/PageList/PageListItemS.jsx
  48. 0 263
      packages/app/src/components/PageRenameModal.jsx
  49. 305 0
      packages/app/src/components/PageRenameModal.tsx
  50. 22 20
      packages/app/src/components/PrivateLegacyPages.tsx
  51. 12 19
      packages/app/src/components/PutbackPageModal.jsx
  52. 17 23
      packages/app/src/components/SearchPage.tsx
  53. 1 3
      packages/app/src/components/SearchPage/OperateAllControl.tsx
  54. 25 21
      packages/app/src/components/SearchPage/SearchControl.tsx
  55. 54 10
      packages/app/src/components/SearchPage/SearchResultContent.tsx
  56. 58 17
      packages/app/src/components/SearchPage/SearchResultList.tsx
  57. 2 2
      packages/app/src/components/SearchPage/SortControl.tsx
  58. 15 19
      packages/app/src/components/SearchPage2/SearchPageBase.tsx
  59. 2 2
      packages/app/src/components/SearchTypeahead.tsx
  60. 1 1
      packages/app/src/components/Sidebar.tsx
  61. 24 14
      packages/app/src/components/Sidebar/CustomSidebar.tsx
  62. 3 1
      packages/app/src/components/Sidebar/PageTree.tsx
  63. 147 88
      packages/app/src/components/Sidebar/PageTree/Item.tsx
  64. 79 23
      packages/app/src/components/Sidebar/PageTree/ItemsTree.tsx
  65. 4 2
      packages/app/src/components/Sidebar/SidebarNav.tsx
  66. 2 2
      packages/app/src/components/Sidebar/Tag.tsx
  67. 0 1
      packages/app/src/components/TableOfContents.jsx
  68. 4 0
      packages/app/src/interfaces/page-listing-results.ts
  69. 17 8
      packages/app/src/interfaces/page.ts
  70. 3 2
      packages/app/src/interfaces/search.ts
  71. 3 0
      packages/app/src/interfaces/ui.ts
  72. 4 0
      packages/app/src/interfaces/user-group-response.ts
  73. 12 0
      packages/app/src/interfaces/websocket.ts
  74. 2 1
      packages/app/src/server/middlewares/apiv3-form-validator.ts
  75. 17 319
      packages/app/src/server/models/obsolete-page.js
  76. 377 82
      packages/app/src/server/models/page.ts
  77. 2 1
      packages/app/src/server/routes/apiv3/app-settings.js
  78. 2 1
      packages/app/src/server/routes/apiv3/attachment.js
  79. 31 9
      packages/app/src/server/routes/apiv3/bookmarks.js
  80. 2 1
      packages/app/src/server/routes/apiv3/customize-setting.js
  81. 2 1
      packages/app/src/server/routes/apiv3/export.js
  82. 1 1
      packages/app/src/server/routes/apiv3/forgot-password.js
  83. 2 1
      packages/app/src/server/routes/apiv3/markdown-setting.js
  84. 1 1
      packages/app/src/server/routes/apiv3/notification-setting.js
  85. 27 28
      packages/app/src/server/routes/apiv3/page-listing.ts
  86. 3 2
      packages/app/src/server/routes/apiv3/page.js
  87. 15 9
      packages/app/src/server/routes/apiv3/pages.js
  88. 7 2
      packages/app/src/server/routes/apiv3/personal-setting.js
  89. 2 1
      packages/app/src/server/routes/apiv3/revisions.js
  90. 2 1
      packages/app/src/server/routes/apiv3/search.js
  91. 2 1
      packages/app/src/server/routes/apiv3/security-setting.js
  92. 2 1
      packages/app/src/server/routes/apiv3/share-links.js
  93. 2 1
      packages/app/src/server/routes/apiv3/slack-integration-legacy-settings.js
  94. 2 1
      packages/app/src/server/routes/apiv3/slack-integration-settings.js
  95. 50 1
      packages/app/src/server/routes/apiv3/user-group.js
  96. 2 1
      packages/app/src/server/routes/apiv3/user-ui-settings.ts
  97. 2 1
      packages/app/src/server/routes/apiv3/users.js
  98. 1 1
      packages/app/src/server/routes/index.js
  99. 30 16
      packages/app/src/server/routes/page.js
  100. 1 1
      packages/app/src/server/service/page-grant.ts

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

@@ -31,8 +31,9 @@ jobs:
         with:
         with:
           path: |
           path: |
             **/node_modules
             **/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: |
           restore-keys: |
+            node_modules-${{ runner.OS }}-node${{ matrix.node-version }}-${{ hashFiles('**/yarn.lock') }}-
             node_modules-${{ runner.OS }}-node${{ matrix.node-version }}-
             node_modules-${{ runner.OS }}-node${{ matrix.node-version }}-
 
 
       - name: lerna bootstrap
       - name: lerna bootstrap
@@ -84,8 +85,9 @@ jobs:
         with:
         with:
           path: |
           path: |
             **/node_modules
             **/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: |
           restore-keys: |
+            node_modules-${{ runner.OS }}-node${{ matrix.node-version }}-${{ hashFiles('**/yarn.lock') }}-
             node_modules-${{ runner.OS }}-node${{ matrix.node-version }}-
             node_modules-${{ runner.OS }}-node${{ matrix.node-version }}-
 
 
       - name: lerna bootstrap
       - name: lerna bootstrap
@@ -143,8 +145,9 @@ jobs:
         with:
         with:
           path: |
           path: |
             **/node_modules
             **/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: |
           restore-keys: |
+            node_modules-${{ runner.OS }}-node${{ matrix.node-version }}-${{ hashFiles('**/yarn.lock') }}-
             node_modules-${{ runner.OS }}-node${{ matrix.node-version }}-
             node_modules-${{ runner.OS }}-node${{ matrix.node-version }}-
 
 
       - name: lerna bootstrap
       - name: lerna bootstrap

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

@@ -37,8 +37,9 @@ jobs:
       with:
       with:
         path: |
         path: |
           **/node_modules
           **/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: |
         restore-keys: |
+          node_modules-${{ runner.OS }}-node${{ inputs.node-version }}-${{ hashFiles('**/yarn.lock') }}-
           node_modules-${{ runner.OS }}-node${{ inputs.node-version }}-
           node_modules-${{ runner.OS }}-node${{ inputs.node-version }}-
 
 
     - name: lerna bootstrap
     - name: lerna bootstrap
@@ -124,6 +125,7 @@ jobs:
           **/node_modules
           **/node_modules
         key: node_modules-build-prod-${{ runner.OS }}-node${{ inputs.node-version }}-${{ steps.get-date.outputs.dateYmdHM }}
         key: node_modules-build-prod-${{ runner.OS }}-node${{ inputs.node-version }}-${{ steps.get-date.outputs.dateYmdHM }}
         restore-keys: |
         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 }}-${{ hashFiles('**/yarn.lock') }}
           node_modules-${{ runner.OS }}-node${{ inputs.node-version }}-
           node_modules-${{ runner.OS }}-node${{ inputs.node-version }}-
           node_modules-build-prod-${{ runner.OS }}-node${{ inputs.node-version }}-${{ steps.get-date.outputs.dateYm }}
           node_modules-build-prod-${{ runner.OS }}-node${{ inputs.node-version }}-${{ steps.get-date.outputs.dateYm }}
@@ -183,7 +185,7 @@ jobs:
       fail-fast: false
       fail-fast: false
       matrix:
       matrix:
         # List string expressions that is comma separated ids of tests in "test/cypress/integration"
         # List string expressions that is comma separated ids of tests in "test/cypress/integration"
-        spec-group: ['1', '2', '3']
+        spec-group: ['1', '2', '3', '4', '5', '6']
 
 
     services:
     services:
       mongodb:
       mongodb:
@@ -212,8 +214,9 @@ jobs:
           **/node_modules
           **/node_modules
           ~/.cache/Cypress
           ~/.cache/Cypress
           ${{ steps.yarn-cache-dir.outputs.value }}
           ${{ 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: |
         restore-keys: |
+          deps-for-cypress-${{ runner.OS }}-node${{ inputs.node-version }}-${{ hashFiles('**/yarn.lock') }}-
           deps-for-cypress-${{ runner.OS }}-node${{ inputs.node-version }}
           deps-for-cypress-${{ runner.OS }}-node${{ inputs.node-version }}
 
 
     - name: lerna bootstrap
     - name: lerna bootstrap

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

@@ -61,9 +61,10 @@ jobs:
       with:
       with:
         path: |
         path: |
           **/node_modules
           **/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: |
         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
     - name: lerna bootstrap
       run: |
       run: |

+ 0 - 2
README.md

@@ -17,8 +17,6 @@
 # GROWI
 # GROWI
 
 
 [![Actions Status](https://github.com/weseek/growi/workflows/Node%20CI/badge.svg)](https://github.com/weseek/growi/actions)
 [![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/)
 [![docker pulls](https://img.shields.io/docker/pulls/weseek/growi.svg)](https://hub.docker.com/r/weseek/growi/)
 
 
 |                                                     demonstration                                                     |
 |                                                     demonstration                                                     |

+ 0 - 2
README_JP.md

@@ -16,8 +16,6 @@
 # GROWI
 # GROWI
 
 
 [![Actions Status](https://github.com/weseek/growi/workflows/Node%20CI/badge.svg)](https://github.com/weseek/growi/actions)
 [![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/)
 [![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",
   "npmClient": "yarn",
   "useWorkspaces": true,
   "useWorkspaces": true,
-  "version": "5.0.0-RC.0",
+  "version": "5.0.0-RC.8",
   "packages": [
   "packages": [
     "packages/*"
     "packages/*"
   ]
   ]

+ 1 - 1
package.json

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

+ 0 - 5
packages/app/docker/README.md

@@ -10,15 +10,10 @@ GROWI Official docker image
 Supported tags and respective Dockerfile links
 Supported tags and respective Dockerfile links
 ------------------------------------------------
 ------------------------------------------------
 
 
-<<<<<<< HEAD
 * [`5.0.0`, `5.0`, `5`, `latest` (Dockerfile)](https://github.com/weseek/growi/blob/v5.0.0/docker/Dockerfile)
 * [`5.0.0`, `5.0`, `5`, `latest` (Dockerfile)](https://github.com/weseek/growi/blob/v5.0.0/docker/Dockerfile)
 * [`5.0.0-nocdn`, `5.0-nocdn`, `5-nocdn`, `latest-nocdn` (Dockerfile)](https://github.com/weseek/growi/blob/v5.0.0/docker/Dockerfile)
 * [`5.0.0-nocdn`, `5.0-nocdn`, `5-nocdn`, `latest-nocdn` (Dockerfile)](https://github.com/weseek/growi/blob/v5.0.0/docker/Dockerfile)
-* [`4.5.13`, `4.5`, `4`, (Dockerfile)](https://github.com/weseek/growi/blob/v4.5.13/docker/Dockerfile)
-* [`4.5.13-nocdn`, `4.5-nocdn`, `4-nocdn`, (Dockerfile)](https://github.com/weseek/growi/blob/v4.5.13/docker/Dockerfile)
-=======
 * [`4.5.14`, `4.5`, `4`, `latest` (Dockerfile)](https://github.com/weseek/growi/blob/v4.5.14/docker/Dockerfile)
 * [`4.5.14`, `4.5`, `4`, `latest` (Dockerfile)](https://github.com/weseek/growi/blob/v4.5.14/docker/Dockerfile)
 * [`4.5.14-nocdn`, `4.5-nocdn`, `4-nocdn`, `latest-nocdn` (Dockerfile)](https://github.com/weseek/growi/blob/v4.5.14/docker/Dockerfile)
 * [`4.5.14-nocdn`, `4.5-nocdn`, `4-nocdn`, `latest-nocdn` (Dockerfile)](https://github.com/weseek/growi/blob/v4.5.14/docker/Dockerfile)
->>>>>>> origin/release/current
 * [`4.4.13`, `4.4` (Dockerfile)](https://github.com/weseek/growi/blob/v4.4.13/docker/Dockerfile)
 * [`4.4.13`, `4.4` (Dockerfile)](https://github.com/weseek/growi/blob/v4.4.13/docker/Dockerfile)
 * [`4.4.13-nocdn`, `4.4-nocdn` (Dockerfile)](https://github.com/weseek/growi/blob/v4.4.13/docker/Dockerfile)
 * [`4.4.13-nocdn`, `4.4-nocdn` (Dockerfile)](https://github.com/weseek/growi/blob/v4.4.13/docker/Dockerfile)
 
 

+ 8 - 8
packages/app/package.json

@@ -1,6 +1,6 @@
 {
 {
   "name": "@growi/app",
   "name": "@growi/app",
-  "version": "5.0.0-RC.0",
+  "version": "5.0.0-RC.8",
   "license": "MIT",
   "license": "MIT",
   "scripts": {
   "scripts": {
     "//// for production": "",
     "//// for production": "",
@@ -60,11 +60,11 @@
     "@browser-bunyan/console-formatted-stream": "^1.6.2",
     "@browser-bunyan/console-formatted-stream": "^1.6.2",
     "@godaddy/terminus": "^4.9.0",
     "@godaddy/terminus": "^4.9.0",
     "@google-cloud/storage": "^5.8.5",
     "@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/express": "^7.0.2",
     "@promster/server": "^7.0.4",
     "@promster/server": "^7.0.4",
     "@slack/events-api": "^3.0.0",
     "@slack/events-api": "^3.0.0",
@@ -167,7 +167,7 @@
   },
   },
   "devDependencies": {
   "devDependencies": {
     "@alienfast/i18next-loader": "^1.1.4",
     "@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",
     "@handsontable/react": "=2.1.0",
     "@types/compression": "^1.7.0",
     "@types/compression": "^1.7.0",
     "@types/express": "^4.17.11",
     "@types/express": "^4.17.11",
@@ -245,7 +245,7 @@
     "swagger2openapi": "^5.3.1",
     "swagger2openapi": "^5.3.1",
     "swr": "^1.1.2",
     "swr": "^1.1.2",
     "terser-webpack-plugin": "^4.1.0",
     "terser-webpack-plugin": "^4.1.0",
-    "throttle-debounce": "^2.0.0",
+    "throttle-debounce": "^3.0.1",
     "toastr": "^2.1.2",
     "toastr": "^2.1.2",
     "ts-loader": "^8.3.0",
     "ts-loader": "^8.3.0",
     "ts-node-dev": "^1.1.6",
     "ts-node-dev": "^1.1.6",

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

@@ -469,6 +469,7 @@
     "group_list": "Group list",
     "group_list": "Group list",
     "child_group_list": "Child group list",
     "child_group_list": "Child group list",
     "back_to_list": "Go back to group list",
     "back_to_list": "Go back to group list",
+    "back_to_ancestors_group": "Go back to ancestors group",
     "basic_info": "Basic info",
     "basic_info": "Basic info",
     "user_list": "User list",
     "user_list": "User list",
     "created_group": "Group was created",
     "created_group": "Group was created",

+ 12 - 16
packages/app/resource/locales/en_US/translation.json

@@ -13,7 +13,6 @@
   "Click to copy": "Click to copy",
   "Click to copy": "Click to copy",
   "Rename" : "Rename",
   "Rename" : "Rename",
   "Move/Rename": "Move/Rename",
   "Move/Rename": "Move/Rename",
-  "Moved": "Moved",
   "Redirected": "Redirected",
   "Redirected": "Redirected",
   "Unlinked": "Unlinked",
   "Unlinked": "Unlinked",
   "Like!": "Like!",
   "Like!": "Like!",
@@ -166,7 +165,7 @@
   "Page Tree": "Page Tree",
   "Page Tree": "Page Tree",
   "original_path":"Original path",
   "original_path":"Original path",
   "new_path":"New path",
   "new_path":"New path",
-  "duplicated_path":"duplicated_path",
+  "duplicated_path":"Duplicated path",
   "Link sharing is disabled": "Link sharing is disabled",
   "Link sharing is disabled": "Link sharing is disabled",
   "successfully_saved_the_page": "Successfully saved the page",
   "successfully_saved_the_page": "Successfully saved the page",
   "you_can_not_create_page_with_this_name": "You can not create page with this name",
   "you_can_not_create_page_with_this_name": "You can not create page with this name",
@@ -369,12 +368,8 @@
   "page_page": {
   "page_page": {
     "notice": {
     "notice": {
       "version": "This is not the current version.",
       "version": "This is not the current version.",
-      "moved": "This page was moved from",
-      "moved_period": ".",
       "redirected": "You are redirected from",
       "redirected": "You are redirected from",
       "redirected_period": ".",
       "redirected_period": ".",
-      "duplicated": "This page was duplicated from",
-      "duplicated_period": ".",
       "unlinked": "Redirect pages to this page have been deleted.",
       "unlinked": "Redirect pages to this page have been deleted.",
       "restricted": "Access to this page is restricted",
       "restricted": "Access to this page is restricted",
       "stale": "More than {{count}} year has passed since last update.",
       "stale": "More than {{count}} year has passed since last update.",
@@ -383,9 +378,6 @@
       "no_deadline":"This page has no expiration date"
       "no_deadline":"This page has no expiration date"
     }
     }
   },
   },
-  "page_table_of_contents": {
-    "empty": "Table of Contents is empty"
-  },
   "page_edit": {
   "page_edit": {
     "Show active line": "Show active line",
     "Show active line": "Show active line",
     "auto_format_table": "Auto format table",
     "auto_format_table": "Auto format table",
@@ -416,11 +408,12 @@
     "label": {
     "label": {
       "Move/Rename page": "Move/Rename page",
       "Move/Rename page": "Move/Rename page",
       "New page name": "New page name",
       "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",
-      "Rename without exist path": "Rename without exist path",
+      "Failed to get subordinated pages": "Failed to get subordinated pages",
+      "Failed to get exist path": "Failed to get exist path",
       "Current page name": "Current page name",
       "Current page name": "Current page name",
-      "Recursively": "Recursively",
+      "Rename this page only": "Rename this page only",
+      "Force rename all child pages": "Force rename all pages",
+      "Other options": "Other options",
       "Do not update metadata": "Do not update metadata",
       "Do not update metadata": "Do not update metadata",
       "Redirect": "Redirect"
       "Redirect": "Redirect"
     },
     },
@@ -432,6 +425,7 @@
   },
   },
   "Put Back": "Put back",
   "Put Back": "Put back",
   "Delete Completely": "Delete completely",
   "Delete Completely": "Delete completely",
+  "page_has_been_reverted": "{{path}} has been reverted",
   "modal_delete": {
   "modal_delete": {
     "delete_page": "Delete page",
     "delete_page": "Delete page",
     "deleting_page": "Deleting page",
     "deleting_page": "Deleting page",
@@ -441,8 +435,9 @@
     "recursively": "Delete pages under this path recursively.",
     "recursively": "Delete pages under this path recursively.",
     "completely": "Delete completely instead of putting it into trash."
     "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":{
   "modal_empty":{
     "empty_the_trash": "Empty The Trash",
     "empty_the_trash": "Empty The Trash",
     "notice": "The pages deleted completely are unrecoverable."
     "notice": "The pages deleted completely are unrecoverable."
@@ -451,7 +446,7 @@
     "label": {
     "label": {
       "Duplicate page": "Duplicate page",
       "Duplicate page": "Duplicate page",
       "New page name": "New page name",
       "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",
       "Current page name": "Current page name",
       "Recursively": "Recursively",
       "Recursively": "Recursively",
       "Duplicate without exist path": "Duplicate without exist path",
       "Duplicate without exist path": "Duplicate without exist path",
@@ -461,6 +456,7 @@
       "recursive": "Duplicate children of under this path recursively"
       "recursive": "Duplicate children of under this path recursively"
     }
     }
   },
   },
+  "duplicated_pages": "{{fromPath}} has been duplicated",
   "modal_putback": {
   "modal_putback": {
     "label": {
     "label": {
       "Put Back Page": "Put back page",
       "Put Back Page": "Put back page",

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

@@ -468,6 +468,7 @@
     "group_list": "グループ一覧",
     "group_list": "グループ一覧",
     "child_group_list": "子グループ一覧",
     "child_group_list": "子グループ一覧",
     "back_to_list": "グループ一覧に戻る",
     "back_to_list": "グループ一覧に戻る",
+    "back_to_ancestors_group": "祖先グループに戻る",
     "basic_info": "基本情報",
     "basic_info": "基本情報",
     "user_list": "ユーザー一覧",
     "user_list": "ユーザー一覧",
     "created_group": "グループを作成しました",
     "created_group": "グループを作成しました",

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

@@ -13,7 +13,6 @@
   "Click to copy": "クリックでコピー",
   "Click to copy": "クリックでコピー",
   "Rename": "名前変更",
   "Rename": "名前変更",
   "Move/Rename": "移動/名前変更",
   "Move/Rename": "移動/名前変更",
-  "Moved": "移動しました",
   "Redirected": "リダイレクトされました",
   "Redirected": "リダイレクトされました",
   "Unlinked": "リダイレクト削除",
   "Unlinked": "リダイレクト削除",
   "Like!": "いいね!",
   "Like!": "いいね!",
@@ -369,12 +368,8 @@
   "page_page": {
   "page_page": {
     "notice": {
     "notice": {
       "version": "これは現在の版ではありません。",
       "version": "これは現在の版ではありません。",
-      "moved": "このページは",
-      "moved_period":"から移動しました。",
       "redirected": "リダイレクト元 >>",
       "redirected": "リダイレクト元 >>",
       "redirected_period":"",
       "redirected_period":"",
-      "duplicated": "このページは",
-      "duplicated_period": "から複製されました。",
       "unlinked": "このページへのリダイレクトは削除されました。",
       "unlinked": "このページへのリダイレクトは削除されました。",
       "restricted": "このページの閲覧は制限されています",
       "restricted": "このページの閲覧は制限されています",
       "stale": "このページは最終更新日から{{count}}年以上が経過しています。",
       "stale": "このページは最終更新日から{{count}}年以上が経過しています。",
@@ -382,9 +377,6 @@
       "no_deadline": "このページに有効期限は設定されていません。"
       "no_deadline": "このページに有効期限は設定されていません。"
     }
     }
   },
   },
-  "page_table_of_contents": {
-    "empty": "目次は空です"
-  },
   "page_edit": {
   "page_edit": {
     "Show active line": "アクティブ行をハイライト",
     "Show active line": "アクティブ行をハイライト",
     "auto_format_table": "表の自動整形",
     "auto_format_table": "表の自動整形",
@@ -415,22 +407,24 @@
     "label": {
     "label": {
       "Move/Rename page": "ページを移動/名前変更する",
       "Move/Rename page": "ページを移動/名前変更する",
       "New page name": "移動先のページ名",
       "New page name": "移動先のページ名",
-      "Fail to get subordinated pages": "配下ページの取得に失敗しました",
-      "Fail to get exist path": "存在するパスの取得に失敗しました",
-      "Rename without exist path": "存在するパス以外を名前変更する",
+      "Failed to get subordinated pages": "配下ページの取得に失敗しました",
+      "Failed to get exist path": "存在するパスの取得に失敗しました",
       "Current page name": "現在のページ名",
       "Current page name": "現在のページ名",
-      "Recursively": "再帰的に移動/名前変更",
-      "Do not update metadata": "メタデータを更新しない",
+      "Rename this page only": "このページのみを移動/名前変更",
+      "Force rename all child pages": "全ての配下のページを移動/名前変更する",
+      "Other options": "その他のオプション",
+      "Do not update metadata": "不更新元数据",
       "Redirect": "リダイレクトする"
       "Redirect": "リダイレクトする"
     },
     },
     "help": {
     "help": {
       "redirect": "アクセスされた際に自動的に新しいページにジャンプします",
       "redirect": "アクセスされた際に自動的に新しいページにジャンプします",
-      "metadata": "最終更新ユーザー、最終更新日を更新せず維持します",
+      "metadata": "Remains last update user and updated date as is",
       "recursive": "配下のページも移動/名前変更します"
       "recursive": "配下のページも移動/名前変更します"
     }
     }
   },
   },
   "Put Back": "元に戻す",
   "Put Back": "元に戻す",
   "Delete Completely": "完全削除",
   "Delete Completely": "完全削除",
+  "page_has_been_reverted": "{{path}} を元に戻しました",
   "modal_delete": {
   "modal_delete": {
     "delete_page": "ページを削除する",
     "delete_page": "ページを削除する",
     "deleting_page": "ページパス",
     "deleting_page": "ページパス",
@@ -440,8 +434,9 @@
     "recursively": "配下のページも削除します",
     "recursively": "配下のページも削除します",
     "completely": "ゴミ箱を経由せず、完全に削除します"
     "completely": "ゴミ箱を経由せず、完全に削除します"
   },
   },
-  "deleted_pages": "ページをゴミ箱に入れました",
-  "deleted_pages_completely": "ページを完全に削除しました",
+  "deleted_pages": "{{path}} をゴミ箱に入れました",
+  "deleted_pages_completely": "{{path}} を完全に削除しました",
+  "renamed_pages": "{{path}} を移動/名前変更しました",
   "modal_empty":{
   "modal_empty":{
     "empty_the_trash": "ゴミ箱を空にする",
     "empty_the_trash": "ゴミ箱を空にする",
     "notice": "完全削除したページは元に戻すことができません"
     "notice": "完全削除したページは元に戻すことができません"
@@ -450,7 +445,7 @@
     "label": {
     "label": {
       "Duplicate page": "ページを複製する",
       "Duplicate page": "ページを複製する",
       "New page name": "複製後のページ名",
       "New page name": "複製後のページ名",
-      "Fail 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": "存在するパス以外を複製する",
@@ -460,6 +455,7 @@
       "recursive": "配下のページも複製します"
       "recursive": "配下のページも複製します"
     }
     }
   },
   },
+  "duplicated_pages": "{{fromPath}} を複製しました",
   "modal_putback": {
   "modal_putback": {
     "label": {
     "label": {
       "Put Back Page": "ページを元に戻す",
       "Put Back Page": "ページを元に戻す",

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

@@ -478,6 +478,7 @@
     "group_list": "组列表",
     "group_list": "组列表",
     "child_group_list": "儿童组名单",
     "child_group_list": "儿童组名单",
     "back_to_list": "返回组列表",
     "back_to_list": "返回组列表",
+    "back_to_ancestors_group": "返回到祖先组",
     "basic_info": "基本信息",
     "basic_info": "基本信息",
     "user_list": "用户列表",
     "user_list": "用户列表",
     "created_group": "已创建组",
     "created_group": "已创建组",

+ 21 - 23
packages/app/resource/locales/zh_CN/translation.json

@@ -14,7 +14,6 @@
 	"Click to copy": "点击复制",
 	"Click to copy": "点击复制",
   "Rename": "重命名",
   "Rename": "重命名",
 	"Move/Rename": "移动/重命名",
 	"Move/Rename": "移动/重命名",
-	"Moved": "移动",
 	"Redirected": "重定向",
 	"Redirected": "重定向",
 	"Unlinked": "Unlinked",
 	"Unlinked": "Unlinked",
 	"Like!": "Like!",
 	"Like!": "Like!",
@@ -174,7 +173,7 @@
   "Page Tree": "页面树",
   "Page Tree": "页面树",
   "original_path":"Original path",
   "original_path":"Original path",
   "new_path":"New path",
   "new_path":"New path",
-  "duplicated_path":"duplicated_path",
+  "duplicated_path":"Duplicated path",
   "Link sharing is disabled": "你不允许分享该链接",
   "Link sharing is disabled": "你不允许分享该链接",
   "successfully_saved_the_page": "成功地保存了该页面",
   "successfully_saved_the_page": "成功地保存了该页面",
   "you_can_not_create_page_with_this_name": "您无法使用此名称创建页面",
   "you_can_not_create_page_with_this_name": "您无法使用此名称创建页面",
@@ -348,12 +347,8 @@
 	"page_page": {
 	"page_page": {
 		"notice": {
 		"notice": {
 			"version": "这不是当前版本。",
 			"version": "这不是当前版本。",
-			"moved": "此页已从",
-      "moved_period": "",
 			"redirected": "您将从",
 			"redirected": "您将从",
       "redirected_period": "",
       "redirected_period": "",
-			"duplicated": "此页来自",
-      "duplicated_period": "",
 			"unlinked": "将网页重定向到此网页已被删除。",
 			"unlinked": "将网页重定向到此网页已被删除。",
 			"restricted": "访问此页受到限制",
 			"restricted": "访问此页受到限制",
 			"stale": "自上次更新以来,已超过{{count}年。",
 			"stale": "自上次更新以来,已超过{{count}年。",
@@ -369,9 +364,6 @@
 			"conflict": "无法保存您所做的更改,因为其他人正在编辑此页。请在重新加载页面后重新编辑受影响的部分。"
 			"conflict": "无法保存您所做的更改,因为其他人正在编辑此页。请在重新加载页面后重新编辑受影响的部分。"
 		}
 		}
   },
   },
-  "page_table_of_contents": {
-    "empty": "目录为空"
-  },
   "page_comment": {
   "page_comment": {
     "display_the_page_when_posting_this_comment": "Display the page when posting this comment"
     "display_the_page_when_posting_this_comment": "Display the page when posting this comment"
   },
   },
@@ -392,24 +384,26 @@
   },
   },
 	"modal_rename": {
 	"modal_rename": {
 		"label": {
 		"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",
-      "Rename without exist path": "Rename without exist path",
-			"Current page name": "当前页面名称",
-			"Recursively": "递归地",
-			"Do not update metadata": "不更新元数据",
-			"Redirect": "重定向"
+      "Failed to get subordinated pages": "Failed to get subordinated pages",
+      "Failed to get exist path": "Failed to get exist path",
+      "Current page name": "当前页面名称",
+      "Rename this page only": "仅重命名此页面",
+      "Force rename all child pages": "强制重命名所有子页面 ",
+      "Other options": "其他选项",
+      "Update metadata": "更新元数据",
+      "Redirect": "重定向"
 		},
 		},
 		"help": {
 		"help": {
-			"redirect": "Redirect to new page if someone accesses <code>%s</code>",
-			"metadata": "Remains last update user and updated date as is",
-			"recursive": "Move/Rename children of under <code>%s</code> recursively"
+      "redirect": "Redirect to new page if someone accesses <code>%s</code>",
+      "metadata": "Update last update user and updated date",
+      "recursive": "Move/Rename children of under <code>%s</code> recursively"
 		}
 		}
 	},
 	},
 	"Put Back": "Put back",
 	"Put Back": "Put back",
-	"Delete Completely": "Delete completely",
+  "Delete Completely": "Delete completely",
+  "page_has_been_reverted": "{{path}} 已还原",
 	"modal_delete": {
 	"modal_delete": {
 		"delete_page": "Delete page",
 		"delete_page": "Delete page",
 		"deleting_page": "Deleting page",
 		"deleting_page": "Deleting page",
@@ -419,6 +413,9 @@
 		"recursively": "Delete children of <code>%s</code> recursively.",
 		"recursively": "Delete children of <code>%s</code> recursively.",
 		"completely": "Delete completely instead of putting it into trash."
 		"completely": "Delete completely instead of putting it into trash."
   },
   },
+  "deleted_pages": "将 {{path}} 放入垃圾箱",
+  "deleted_pages_completely": "{{path}} 已被完全删除",
+  "renamed_pages": "移动/重命名 {{path}}",
 	"modal_empty": {
 	"modal_empty": {
 		"empty_the_trash": "Empty The Trash",
 		"empty_the_trash": "Empty The Trash",
 		"notice": "完全删除的页面是不可恢复的。"
 		"notice": "完全删除的页面是不可恢复的。"
@@ -427,7 +424,7 @@
 		"label": {
 		"label": {
 			"Duplicate page": "Duplicate page",
 			"Duplicate page": "Duplicate page",
       "New page name": "New page name",
       "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",
 			"Current page name": "Current page name",
       "Recursively": "Recursively",
       "Recursively": "Recursively",
       "Duplicate without exist path": "Duplicate without exist path",
       "Duplicate without exist path": "Duplicate without exist path",
@@ -436,7 +433,8 @@
     "help": {
     "help": {
       "recursive": "Duplicate children of under this path recursively"
       "recursive": "Duplicate children of under this path recursively"
     }
     }
-	},
+  },
+  "duplicated_pages": "{{fromPath}} 已重复",
 	"modal_putback": {
 	"modal_putback": {
 		"label": {
 		"label": {
 			"Put Back Page": "Put back page",
 			"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 PageTimeline from '../components/PageTimeline';
 import CommentEditorLazyRenderer from '../components/PageComment/CommentEditorLazyRenderer';
 import CommentEditorLazyRenderer from '../components/PageComment/CommentEditorLazyRenderer';
 import ShareLinkAlert from '../components/Page/ShareLinkAlert';
 import ShareLinkAlert from '../components/Page/ShareLinkAlert';
-import DuplicatedAlert from '../components/Page/DuplicatedAlert';
 import RedirectedAlert from '../components/Page/RedirectedAlert';
 import RedirectedAlert from '../components/Page/RedirectedAlert';
-import RenamedAlert from '../components/Page/RenamedAlert';
 import TrashPageList from '../components/TrashPageList';
 import TrashPageList from '../components/TrashPageList';
 import TrashPageAlert from '../components/Page/TrashPageAlert';
 import TrashPageAlert from '../components/Page/TrashPageAlert';
 import NotFoundPage from '../components/NotFoundPage';
 import NotFoundPage from '../components/NotFoundPage';
@@ -113,9 +111,7 @@ Object.assign(componentMappings, {
   'grw-fab-container': <Fab />,
   'grw-fab-container': <Fab />,
 
 
   'share-link-alert': <ShareLinkAlert />,
   'share-link-alert': <ShareLinkAlert />,
-  'duplicated-alert': <DuplicatedAlert />,
   'redirected-alert': <RedirectedAlert />,
   'redirected-alert': <RedirectedAlert />,
-  'renamed-alert': <RenamedAlert />,
   'not-found-alert': <NotFoundAlert
   'not-found-alert': <NotFoundAlert
     isGuestUserMode={appContainer.isGuestUser}
     isGuestUserMode={appContainer.isGuestUser}
   />,
   />,

+ 5 - 5
packages/app/src/client/services/AdminHomeContainer.js

@@ -25,12 +25,12 @@ export default class AdminHomeContainer extends Container {
     this.timer = null;
     this.timer = null;
 
 
     this.state = {
     this.state = {
-      growiVersion: '',
-      nodeVersion: '',
-      npmVersion: '',
-      yarnVersion: '',
+      growiVersion: null,
+      nodeVersion: null,
+      npmVersion: null,
+      yarnVersion: null,
       copyState: this.copyStateValues.DEFAULT,
       copyState: this.copyStateValues.DEFAULT,
-      installedPlugins: [],
+      installedPlugins: null,
       isV5Compatible: null,
       isV5Compatible: null,
     };
     };
 
 

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

@@ -2,18 +2,20 @@ import React, { FC, useEffect, useState } from 'react';
 import { pagePathUtils } from '@growi/core';
 import { pagePathUtils } from '@growi/core';
 
 
 import {
 import {
+  useSiteUrl,
   useCurrentCreatedAt, useDeleteUsername, useDeletedAt, useHasChildren, useHasDraftOnHackmd,
   useCurrentCreatedAt, useDeleteUsername, useDeletedAt, useHasChildren, useHasDraftOnHackmd,
   useIsDeleted, useIsNotCreatable, useIsTrashPage, useIsUserPage, useLastUpdateUsername,
   useIsDeleted, useIsNotCreatable, useIsTrashPage, useIsUserPage, useLastUpdateUsername,
   useCurrentPageId, usePageIdOnHackmd, usePageUser, useCurrentPagePath, useRevisionCreatedAt, useRevisionId, useRevisionIdHackmdSynced,
   useCurrentPageId, usePageIdOnHackmd, usePageUser, useCurrentPagePath, useRevisionCreatedAt, useRevisionId, useRevisionIdHackmdSynced,
   useShareLinkId, useShareLinksNumber, useTemplateTagData, useCurrentUpdatedAt, useCreator, useRevisionAuthor, useCurrentUser, useTargetAndAncestors,
   useShareLinkId, useShareLinksNumber, useTemplateTagData, useCurrentUpdatedAt, useCreator, useRevisionAuthor, useCurrentUser, useTargetAndAncestors,
   useSlackChannels, useNotFoundTargetPathOrId, useIsSearchPage, useIsForbidden, useIsIdenticalPath,
   useSlackChannels, useNotFoundTargetPathOrId, useIsSearchPage, useIsForbidden, useIsIdenticalPath,
-  useIsAclEnabled, useIsSearchServiceConfigured, useIsSearchServiceReachable, useIsEnabledAttachTitleHeader,
+  useIsAclEnabled, useIsSearchServiceConfigured, useIsSearchServiceReachable, useIsEnabledAttachTitleHeader, useIsNotFoundPermalink,
 } from '../../stores/context';
 } from '../../stores/context';
 import {
 import {
   useIsDeviceSmallerThanMd, useIsDeviceSmallerThanLg,
   useIsDeviceSmallerThanMd, useIsDeviceSmallerThanLg,
   usePreferDrawerModeByUser, usePreferDrawerModeOnEditByUser, useSidebarCollapsed, useCurrentSidebarContents, useCurrentProductNavWidth,
   usePreferDrawerModeByUser, usePreferDrawerModeOnEditByUser, useSidebarCollapsed, useCurrentSidebarContents, useCurrentProductNavWidth,
   useSelectedGrant, useSelectedGrantGroupId, useSelectedGrantGroupName,
   useSelectedGrant, useSelectedGrantGroupId, useSelectedGrantGroupName,
 } from '~/stores/ui';
 } from '~/stores/ui';
+import { useSetupGlobalSocket } from '~/stores/websocket';
 import { IUserUISettings } from '~/interfaces/user-ui-settings';
 import { IUserUISettings } from '~/interfaces/user-ui-settings';
 
 
 const { isTrashPage: _isTrashPage } = pagePathUtils;
 const { isTrashPage: _isTrashPage } = pagePathUtils;
@@ -23,7 +25,8 @@ const jsonNull = 'null';
 const ContextExtractorOnce: FC = () => {
 const ContextExtractorOnce: FC = () => {
 
 
   const mainContent = document.querySelector('#content-main');
   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');
   const forbiddenContent = document.getElementById('forbidden-page');
 
 
   /*
   /*
@@ -76,7 +79,8 @@ const ContextExtractorOnce: FC = () => {
   const creator = JSON.parse(mainContent?.getAttribute('data-page-creator') || jsonNull);
   const creator = JSON.parse(mainContent?.getAttribute('data-page-creator') || jsonNull);
   const revisionAuthor = JSON.parse(mainContent?.getAttribute('data-page-revision-author') || 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 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 slackChannels = mainContent?.getAttribute('data-slack-channels') || '';
   const isSearchPage = document.getElementById('search-page') != null;
   const isSearchPage = document.getElementById('search-page') != null;
 
 
@@ -98,6 +102,7 @@ const ContextExtractorOnce: FC = () => {
   useCurrentProductNavWidth(userUISettings?.currentProductNavWidth);
   useCurrentProductNavWidth(userUISettings?.currentProductNavWidth);
 
 
   // hydrated config
   // hydrated config
+  useSiteUrl(configByContextHydrate.crowi.url);
   useIsAclEnabled(configByContextHydrate.isAclEnabled);
   useIsAclEnabled(configByContextHydrate.isAclEnabled);
   useIsSearchServiceConfigured(configByContextHydrate.isSearchServiceConfigured);
   useIsSearchServiceConfigured(configByContextHydrate.isSearchServiceConfigured);
   useIsSearchServiceReachable(configByContextHydrate.isSearchServiceReachable);
   useIsSearchServiceReachable(configByContextHydrate.isSearchServiceReachable);
@@ -132,6 +137,7 @@ const ContextExtractorOnce: FC = () => {
   useRevisionAuthor(revisionAuthor);
   useRevisionAuthor(revisionAuthor);
   useTargetAndAncestors(targetAndAncestors);
   useTargetAndAncestors(targetAndAncestors);
   useNotFoundTargetPathOrId(notFoundTargetPathOrId);
   useNotFoundTargetPathOrId(notFoundTargetPathOrId);
+  useIsNotFoundPermalink(isNotFoundPermalink);
   useIsSearchPage(isSearchPage);
   useIsSearchPage(isSearchPage);
 
 
   // Navigation
   // Navigation
@@ -153,6 +159,9 @@ const ContextExtractorOnce: FC = () => {
   // SearchResult
   // SearchResult
   useIsDeviceSmallerThanLg();
   useIsDeviceSmallerThanLg();
 
 
+  // Global Socket
+  useSetupGlobalSocket();
+
   return null;
   return null;
 };
 };
 
 

+ 9 - 3
packages/app/src/components/Admin/AdminHome/InstalledPluginTable.jsx

@@ -11,8 +11,14 @@ class InstalledPluginTable extends React.Component {
   render() {
   render() {
     const { t, adminHomeContainer } = this.props;
     const { t, adminHomeContainer } = this.props;
 
 
+    const { installedPlugins } = adminHomeContainer.state;
+
+    if (installedPlugins == null) {
+      return <></>;
+    }
+
     return (
     return (
-      <table className="table table-bordered">
+      <table data-testid="admin-installed-plugin-table" className="table table-bordered">
         <thead>
         <thead>
           <tr>
           <tr>
             <th className="text-center">{t('admin:admin_top.package_name')}</th>
             <th className="text-center">{t('admin:admin_top.package_name')}</th>
@@ -25,8 +31,8 @@ class InstalledPluginTable extends React.Component {
             return (
             return (
               <tr key={plugin.name}>
               <tr key={plugin.name}>
                 <td>{plugin.name}</td>
                 <td>{plugin.name}</td>
-                <td className="text-center">{plugin.requiredVersion}</td>
-                <td className="text-center">{plugin.installedVersion}</td>
+                <td data-hide-in-vrt className="text-center">{plugin.requiredVersion}</td>
+                <td data-hide-in-vrt className="text-center">{plugin.installedVersion}</td>
               </tr>
               </tr>
             );
             );
           })}
           })}

+ 13 - 5
packages/app/src/components/Admin/AdminHome/SystemInfomationTable.jsx

@@ -11,24 +11,32 @@ class SystemInformationTable extends React.Component {
   render() {
   render() {
     const { adminHomeContainer } = this.props;
     const { adminHomeContainer } = this.props;
 
 
+    const {
+      growiVersion, nodeVersion, npmVersion, yarnVersion,
+    } = adminHomeContainer.state;
+
+    if (growiVersion == null || nodeVersion == null || npmVersion == null || yarnVersion == null) {
+      return <></>;
+    }
+
     return (
     return (
-      <table className="table table-bordered">
+      <table data-testid="admin-system-information-table" className="table table-bordered">
         <tbody>
         <tbody>
           <tr>
           <tr>
             <th>GROWI</th>
             <th>GROWI</th>
-            <td>{ adminHomeContainer.state.growiVersion }</td>
+            <td data-hide-in-vrt>{ growiVersion }</td>
           </tr>
           </tr>
           <tr>
           <tr>
             <th>node.js</th>
             <th>node.js</th>
-            <td>{ adminHomeContainer.state.nodeVersion }</td>
+            <td>{ nodeVersion }</td>
           </tr>
           </tr>
           <tr>
           <tr>
             <th>npm</th>
             <th>npm</th>
-            <td>{ adminHomeContainer.state.npmVersion }</td>
+            <td>{ npmVersion }</td>
           </tr>
           </tr>
           <tr>
           <tr>
             <th>yarn</th>
             <th>yarn</th>
-            <td>{ adminHomeContainer.state.yarnVersion }</td>
+            <td>{ yarnVersion }</td>
           </tr>
           </tr>
         </tbody>
         </tbody>
       </table>
       </table>

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

@@ -27,7 +27,8 @@ class StatusTable extends React.PureComponent {
     }
     }
     else {
     else {
       connectionStatusLabel = isConnected
       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>;
         : <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;
   const isCautionVisible = currentBotType === SlackbotType.OFFICIAL || currentBotType === SlackbotType.CUSTOM_WITH_PROXY;
 
 
   return (
   return (
-    <li className="list-group-item">
+    <li data-testid="slack-integration-list-item" className="list-group-item">
       <h4>
       <h4>
         <Badge isEnabled={isEnabled} />
         <Badge isEnabled={isEnabled} />
         <a href="/admin/slack-integration" className="ml-2">{t('slack_integration')}</a>
         <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 { useTranslation } from 'react-i18next';
 
 
 import UserGroupTable from './UserGroupTable';
 import UserGroupTable from './UserGroupTable';
-import UserGroupForm from './UserGroupForm';
+import UserGroupCreateModal from './UserGroupCreateModal';
 import UserGroupDeleteModal from './UserGroupDeleteModal';
 import UserGroupDeleteModal from './UserGroupDeleteModal';
 
 
-import { withUnstatedContainers } from '../../UnstatedUtils';
-import AppContainer from '~/client/services/AppContainer';
 import { toastSuccess, toastError } from '~/client/util/apiNotification';
 import { toastSuccess, toastError } from '~/client/util/apiNotification';
 import { IUserGroup, IUserGroupHasId } from '~/interfaces/user';
 import { IUserGroup, IUserGroupHasId } from '~/interfaces/user';
 import Xss from '~/services/xss';
 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 { useSWRxUserGroupList, useSWRxChildUserGroupList, useSWRxUserGroupRelationList } from '~/stores/user-group';
 import { useIsAclEnabled } from '~/stores/context';
 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 xss: Xss = (window as CustomWindow).xss;
   const { t } = useTranslation();
   const { t } = useTranslation();
 
 
@@ -44,11 +36,20 @@ const UserGroupPage: FC<Props> = (props: Props) => {
    * State
    * State
    */
    */
   const [selectedUserGroup, setSelectedUserGroup] = useState<IUserGroupHasId | undefined>(undefined); // not null but undefined (to use defaultProps in UserGroupDeleteModal)
   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);
   const [isDeleteModalShown, setDeleteModalShown] = useState<boolean>(false);
 
 
   /*
   /*
    * Functions
    * Functions
    */
    */
+  const showCreateModal = useCallback(() => {
+    setCreateModalShown(true);
+  }, [setCreateModalShown]);
+
+  const hideCreateModal = useCallback(() => {
+    setCreateModalShown(false);
+  }, [setCreateModalShown]);
+
   const syncUserGroupAndRelations = useCallback(async() => {
   const syncUserGroupAndRelations = useCallback(async() => {
     try {
     try {
       await mutateUserGroups();
       await mutateUserGroups();
@@ -80,7 +81,6 @@ const UserGroupPage: FC<Props> = (props: Props) => {
       await apiv3Post('/user-groups', {
       await apiv3Post('/user-groups', {
         name: userGroupData.name,
         name: userGroupData.name,
         description: userGroupData.description,
         description: userGroupData.description,
-        parent: userGroupData.parent,
       });
       });
       toastSuccess(t('toaster.update_successed', { target: t('UserGroup') }));
       toastSuccess(t('toaster.update_successed', { target: t('UserGroup') }));
       await mutateUserGroups();
       await mutateUserGroups();
@@ -115,45 +115,37 @@ const UserGroupPage: FC<Props> = (props: Props) => {
       {
       {
         isAclEnabled ? (
         isAclEnabled ? (
           <div className="mb-2">
           <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')}
               {t('admin:user_group_management.create_group')}
             </button>
             </button>
-            <div id="createGroupForm" className="collapse">
-              <UserGroupForm
-                submitButtonLabel={t('Create')}
-                onSubmit={createUserGroup}
-              />
-            </div>
           </div>
           </div>
         ) : (
         ) : (
           t('admin:user_group_management.deny_create_group')
           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>
     </div>
   );
   );
 };
 };
 
 
-/**
- * Wrapper component for using unstated
- */
-const UserGroupPageWrapper = withUnstatedContainers(UserGroupPage, [AppContainer]);
-
-export default UserGroupPageWrapper;
+export default UserGroupPage;

+ 75 - 33
packages/app/src/components/Admin/UserGroupDetail/UserGroupDetailPage.tsx

@@ -5,6 +5,7 @@ import { useTranslation } from 'react-i18next';
 
 
 import UserGroupForm from '../UserGroup/UserGroupForm';
 import UserGroupForm from '../UserGroup/UserGroupForm';
 import UserGroupTable from '../UserGroup/UserGroupTable';
 import UserGroupTable from '../UserGroup/UserGroupTable';
+import UserGroupCreateModal from '../UserGroup/UserGroupCreateModal';
 import UserGroupDeleteModal from '../UserGroup/UserGroupDeleteModal';
 import UserGroupDeleteModal from '../UserGroup/UserGroupDeleteModal';
 import UserGroupDropdown from '../UserGroup/UserGroupDropdown';
 import UserGroupDropdown from '../UserGroup/UserGroupDropdown';
 import UserGroupUserTable from './UserGroupUserTable';
 import UserGroupUserTable from './UserGroupUserTable';
@@ -20,7 +21,7 @@ import {
   IUserGroup, IUserGroupHasId,
   IUserGroup, IUserGroupHasId,
 } from '~/interfaces/user';
 } from '~/interfaces/user';
 import {
 import {
-  useSWRxUserGroupPages, useSWRxUserGroupRelationList, useSWRxChildUserGroupList, useSWRxSelectableUserGroups,
+  useSWRxUserGroupPages, useSWRxUserGroupRelationList, useSWRxChildUserGroupList, useSWRxSelectableUserGroups, useSWRxAncestorUserGroups,
 } from '~/stores/user-group';
 } from '~/stores/user-group';
 import { useIsAclEnabled } from '~/stores/context';
 import { useIsAclEnabled } from '~/stores/context';
 
 
@@ -33,11 +34,11 @@ const UserGroupDetailPage: FC = () => {
    */
    */
   const [userGroup, setUserGroup] = useState<IUserGroupHasId>(JSON.parse(adminUserGroupDetailElem?.getAttribute('data-user-group') || 'null'));
   const [userGroup, setUserGroup] = useState<IUserGroupHasId>(JSON.parse(adminUserGroupDetailElem?.getAttribute('data-user-group') || 'null'));
   const [relatedPages, setRelatedPages] = useState<IPageHasId[]>([]); // For page list
   const [relatedPages, setRelatedPages] = useState<IPageHasId[]>([]); // For page list
-  const [isUserGroupUserModalOpen, setUserGroupUserModalOpen] = useState<boolean>(false);
   const [searchType, setSearchType] = useState<string>('partial');
   const [searchType, setSearchType] = useState<string>('partial');
   const [isAlsoMailSearched, setAlsoMailSearched] = useState<boolean>(false);
   const [isAlsoMailSearched, setAlsoMailSearched] = useState<boolean>(false);
   const [isAlsoNameSearched, setAlsoNameSearched] = 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 [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);
   const [isDeleteModalShown, setDeleteModalShown] = useState<boolean>(false);
 
 
   /*
   /*
@@ -55,6 +56,8 @@ const UserGroupDetailPage: FC = () => {
 
 
   const { data: selectableUserGroups, mutate: mutateSelectableUserGroups } = useSWRxSelectableUserGroups(userGroup._id);
   const { data: selectableUserGroups, mutate: mutateSelectableUserGroups } = useSWRxSelectableUserGroups(userGroup._id);
 
 
+  const { data: ancestorUserGroups } = useSWRxAncestorUserGroups(userGroup._id);
+
   const { data: isAclEnabled } = useIsAclEnabled();
   const { data: isAclEnabled } = useIsAclEnabled();
 
 
   /*
   /*
@@ -86,14 +89,6 @@ const UserGroupDetailPage: FC = () => {
     }
     }
   }, [t, userGroup._id, setUserGroup]);
   }, [t, userGroup._id, setUserGroup]);
 
 
-  const openUserGroupUserModal = useCallback(() => {
-    setUserGroupUserModalOpen(true);
-  }, []);
-
-  const closeUserGroupUserModal = useCallback(() => {
-    setUserGroupUserModalOpen(false);
-  }, []);
-
   const fetchApplicableUsers = useCallback(async(searchWord) => {
   const fetchApplicableUsers = useCallback(async(searchWord) => {
     const res = await apiv3Get(`/user-groups/${userGroup._id}/unrelated-users`, {
     const res = await apiv3Get(`/user-groups/${userGroup._id}/unrelated-users`, {
       searchWord,
       searchWord,
@@ -135,10 +130,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) => {
   const showDeleteModal = useCallback(async(group: IUserGroupHasId) => {
     setSelectedUserGroup(group);
     setSelectedUserGroup(group);
@@ -183,7 +196,33 @@ const UserGroupDetailPage: FC = () => {
         <i className="icon-fw ti-arrow-left" aria-hidden="true"></i>
         <i className="icon-fw ti-arrow-left" aria-hidden="true"></i>
         {t('admin:user_group_management.back_to_list')}
         {t('admin:user_group_management.back_to_list')}
       </a>
       </a>
-      {/* TODO 85062: Link to the ancestors group */}
+
+      {
+        userGroup?.parent != null && ancestorUserGroups != null && ancestorUserGroups.length > 0 && (
+          <div className="btn-group ml-2">
+            <a className="btn btn-outline-secondary" href={`/admin/user-group-detail/${userGroup.parent}`}>
+              <i className="icon-fw ti-arrow-left" aria-hidden="true"></i>
+              {t('admin:user_group_management.back_to_ancestors_group')}
+            </a>
+            <button
+              type="button"
+              className="btn btn-outline-secondary dropdown-toggle dropdown-toggle-split"
+              data-toggle="dropdown"
+              aria-haspopup="true"
+              ria-expanded="false"
+            >
+            </button>
+            <div className="dropdown-menu">
+              {
+                ancestorUserGroups.map(userGroup => (
+                  <a className="dropdown-item" key={userGroup._id} href={`/admin/user-group-detail/${userGroup._id}`}>{userGroup.name}</a>
+                ))
+              }
+            </div>
+          </div>
+        )
+      }
+
       <div className="mt-4 form-box">
       <div className="mt-4 form-box">
         <UserGroupForm
         <UserGroupForm
           userGroup={userGroup}
           userGroup={userGroup}
@@ -199,26 +238,29 @@ const UserGroupDetailPage: FC = () => {
       <UserGroupDropdown
       <UserGroupDropdown
         selectableUserGroups={selectableUserGroups}
         selectableUserGroups={selectableUserGroups}
         onClickAddExistingUserGroupButtonHandler={onClickAddChildButtonHandler}
         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>
       <h2 className="admin-setting-header mt-4">{t('Page')}</h2>
       <div className="page-list">
       <div className="page-list">

+ 1 - 1
packages/app/src/components/Admin/Users/UserTable.jsx

@@ -184,7 +184,7 @@ class UserTable extends React.Component {
             <tbody>
             <tbody>
               {adminUsersContainer.state.users.map((user) => {
               {adminUsersContainer.state.users.map((user) => {
                 return (
                 return (
-                  <tr key={user._id}>
+                  <tr data-testid="user-table-tr" key={user._id}>
                     <td>
                     <td>
                       <UserPicture user={user} className="picture rounded-circle" />
                       <UserPicture user={user} className="picture rounded-circle" />
                     </td>
                     </td>

+ 3 - 1
packages/app/src/components/Common/ClosableTextInput.tsx

@@ -30,6 +30,7 @@ const ClosableTextInput: FC<ClosableTextInputProps> = memo((props: ClosableTextI
 
 
   const [inputText, setInputText] = useState(props.value);
   const [inputText, setInputText] = useState(props.value);
   const [currentAlertInfo, setAlertInfo] = useState<AlertInfo | null>(null);
   const [currentAlertInfo, setAlertInfo] = useState<AlertInfo | null>(null);
+  const [isAbleToShowAlert, setIsAbleToShowAlert] = useState<boolean>(false);
 
 
   const createValidation = async(inputText: string) => {
   const createValidation = async(inputText: string) => {
     if (props.inputValidator != null) {
     if (props.inputValidator != null) {
@@ -42,6 +43,7 @@ const ClosableTextInput: FC<ClosableTextInputProps> = memo((props: ClosableTextI
     const inputText = e.target.value;
     const inputText = e.target.value;
     createValidation(inputText);
     createValidation(inputText);
     setInputText(inputText);
     setInputText(inputText);
+    setIsAbleToShowAlert(true);
   };
   };
 
 
   const onFocusHandler = async(e: React.ChangeEvent<HTMLInputElement>) => {
   const onFocusHandler = async(e: React.ChangeEvent<HTMLInputElement>) => {
@@ -119,7 +121,7 @@ const ClosableTextInput: FC<ClosableTextInputProps> = memo((props: ClosableTextI
         onBlur={onBlurHandler}
         onBlur={onBlurHandler}
         autoFocus={false}
         autoFocus={false}
       />
       />
-      <AlertInfo />
+      {isAbleToShowAlert && <AlertInfo />}
     </div>
     </div>
   );
   );
 });
 });

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

@@ -35,7 +35,7 @@ type CommonProps = {
 
 
   onClickBookmarkMenuItem?: (pageId: string, newValue?: boolean) => Promise<void>,
   onClickBookmarkMenuItem?: (pageId: string, newValue?: boolean) => Promise<void>,
   onClickDuplicateMenuItem?: (pageId: string) => Promise<void> | void,
   onClickDuplicateMenuItem?: (pageId: string) => Promise<void> | void,
-  onClickRenameMenuItem?: (pageId: string) => Promise<void> | void,
+  onClickRenameMenuItem?: (pageId: string, pageInfo: IPageInfoAll | undefined) => Promise<void> | void,
   onClickDeleteMenuItem?: (pageId: string, pageInfo: IPageInfoAll | undefined) => Promise<void> | void,
   onClickDeleteMenuItem?: (pageId: string, pageInfo: IPageInfoAll | undefined) => Promise<void> | void,
   onClickRevertMenuItem?: (pageId: string) => Promise<void> | void,
   onClickRevertMenuItem?: (pageId: string) => Promise<void> | void,
 
 
@@ -80,15 +80,19 @@ const PageItemControlDropdownMenu = React.memo((props: DropdownMenuProps): JSX.E
     if (onClickRenameMenuItem == null) {
     if (onClickRenameMenuItem == null) {
       return;
       return;
     }
     }
-    await onClickRenameMenuItem(pageId);
-  }, [onClickRenameMenuItem, pageId]);
+    if (!pageInfo?.isMovable) {
+      logger.warn('This page could not be renamed.');
+      return;
+    }
+    await onClickRenameMenuItem(pageId, pageInfo);
+  }, [onClickRenameMenuItem, pageId, pageInfo]);
 
 
   const revertItemClickedHandler = useCallback(async() => {
   const revertItemClickedHandler = useCallback(async() => {
     if (onClickRevertMenuItem == null) {
     if (onClickRevertMenuItem == null) {
       return;
       return;
     }
     }
     await onClickRevertMenuItem(pageId);
     await onClickRevertMenuItem(pageId);
-  }, [onClickRevertMenuItem]);
+  }, [onClickRevertMenuItem, pageId]);
 
 
 
 
   // eslint-disable-next-line react-hooks/rules-of-hooks
   // eslint-disable-next-line react-hooks/rules-of-hooks
@@ -212,7 +216,8 @@ export const PageItemControlSubstance = (props: PageItemControlSubstanceProps):
   const shouldFetch = fetchOnInit === true || (!isIPageInfoForOperation(presetPageInfo) && isOpen);
   const shouldFetch = fetchOnInit === true || (!isIPageInfoForOperation(presetPageInfo) && isOpen);
   const shouldMutate = fetchOnInit === true || !isIPageInfoForOperation(presetPageInfo);
   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
   // mutate after handle event
   const bookmarkMenuItemClickHandler = useCallback(async(_pageId: string, _newValue: boolean) => {
   const bookmarkMenuItemClickHandler = useCallback(async(_pageId: string, _newValue: boolean) => {
@@ -238,8 +243,8 @@ export const PageItemControlSubstance = (props: PageItemControlSubstanceProps):
     if (onClickRenameMenuItem == null) {
     if (onClickRenameMenuItem == null) {
       return;
       return;
     }
     }
-    await onClickRenameMenuItem(pageId);
-  }, [onClickRenameMenuItem, pageId]);
+    await onClickRenameMenuItem(pageId, fetchedPageInfo ?? presetPageInfo);
+  }, [onClickRenameMenuItem, pageId, fetchedPageInfo, presetPageInfo]);
 
 
   const deleteMenuItemClickHandler = useCallback(async() => {
   const deleteMenuItemClickHandler = useCallback(async() => {
     if (onClickDeleteMenuItem == null) {
     if (onClickDeleteMenuItem == null) {
@@ -249,10 +254,9 @@ export const PageItemControlSubstance = (props: PageItemControlSubstanceProps):
   }, [onClickDeleteMenuItem, pageId, fetchedPageInfo, presetPageInfo]);
   }, [onClickDeleteMenuItem, pageId, fetchedPageInfo, presetPageInfo]);
 
 
   return (
   return (
-    <Dropdown isOpen={isOpen} toggle={() => setIsOpen(!isOpen)}>
-
+    <Dropdown isOpen={isOpen} toggle={() => setIsOpen(!isOpen)} data-testid="open-page-item-control-btn">
       { children ?? (
       { children ?? (
-        <DropdownToggle color="transparent" className="border-0 rounded btn-page-item-control">
+        <DropdownToggle color="transparent" className="border-0 rounded btn-page-item-control d-flex align-items-center justify-content-center">
           <i className="icon-options text-muted"></i>
           <i className="icon-options text-muted"></i>
         </DropdownToggle>
         </DropdownToggle>
       ) }
       ) }

+ 0 - 54
packages/app/src/components/ComparePathsTable.jsx

@@ -1,54 +0,0 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-
-import { withTranslation } from 'react-i18next';
-import { pagePathUtils } from '@growi/core';
-
-
-const { convertToNewAffiliationPath } = pagePathUtils;
-
-function ComparePathsTable(props) {
-  const {
-    path, subordinatedPages, newPagePath, t,
-  } = props;
-
-  return (
-    <table className="table table-bordered grw-compare-paths-table">
-      <thead>
-        <tr className="d-flex">
-          <th className="w-50">{t('original_path')}</th>
-          <th className="w-50">{t('new_path')}</th>
-        </tr>
-      </thead>
-      <tbody className="overflow-auto d-block">
-        {subordinatedPages.map((subordinatedPage) => {
-          const convertedPath = convertToNewAffiliationPath(path, newPagePath, subordinatedPage.path);
-          return (
-            <tr key={subordinatedPage._id} className="d-flex">
-              <td className="text-break w-50">
-                <a href={subordinatedPage.path}>
-                  {subordinatedPage.path}
-                </a>
-              </td>
-              <td className="text-break w-50">
-                {convertedPath}
-              </td>
-            </tr>
-          );
-        })}
-      </tbody>
-    </table>
-  );
-}
-
-
-ComparePathsTable.propTypes = {
-  t: PropTypes.func.isRequired, //  i18next
-
-  path: PropTypes.string.isRequired,
-  subordinatedPages: PropTypes.array.isRequired,
-  newPagePath: PropTypes.string.isRequired,
-};
-
-
-export default withTranslation()(ComparePathsTable);

+ 41 - 36
packages/app/src/components/DescendantsPageList.tsx

@@ -2,14 +2,19 @@ import React, { useCallback, useState } from 'react';
 import { useTranslation } from 'react-i18next';
 import { useTranslation } from 'react-i18next';
 import { toastSuccess } from '~/client/util/apiNotification';
 import { toastSuccess } from '~/client/util/apiNotification';
 import {
 import {
-  IPageHasId, IPageWithMeta,
+  IDataWithMeta,
+  IPageHasId,
+  IPageInfoForOperation,
 } from '~/interfaces/page';
 } from '~/interfaces/page';
 import { IPagingResult } from '~/interfaces/paging-result';
 import { IPagingResult } from '~/interfaces/paging-result';
-import { OnDeletedFunction } from '~/interfaces/ui';
-import { useIsGuestUser, useIsSharedUser } from '~/stores/context';
+import { OnDeletedFunction, OnPutBackedFunction } from '~/interfaces/ui';
+import { useIsGuestUser, useIsSharedUser, useIsTrashPage } from '~/stores/context';
 
 
-import { useSWRxDescendantsPageListForCurrrentPath, useSWRxPageInfoForList, useSWRxPageList } from '~/stores/page';
+import {
+  useSWRxDescendantsPageListForCurrrentPath, useSWRxPageInfoForList, useSWRxPageList, useDescendantsPageListForCurrentPathTermManager,
+} from '~/stores/page';
 import { usePageTreeTermManager } from '~/stores/page-listing';
 import { usePageTreeTermManager } from '~/stores/page-listing';
+import { ForceHideMenuItems, MenuItemType } from './Common/Dropdown/PageItemControl';
 
 
 import PageList from './PageList/PageList';
 import PageList from './PageList/PageList';
 import PaginationWrapper from './PaginationWrapper';
 import PaginationWrapper from './PaginationWrapper';
@@ -19,11 +24,13 @@ type SubstanceProps = {
   pagingResult: IPagingResult<IPageHasId> | undefined,
   pagingResult: IPagingResult<IPageHasId> | undefined,
   activePage: number,
   activePage: number,
   setActivePage: (activePage: number) => void,
   setActivePage: (activePage: number) => void,
+  forceHideMenuItems?: ForceHideMenuItems,
   onPagesDeleted?: OnDeletedFunction,
   onPagesDeleted?: OnDeletedFunction,
+  onPagePutBacked?: OnPutBackedFunction,
 }
 }
 
 
-const convertToIPageWithEmptyMeta = (page: IPageHasId): IPageWithMeta => {
-  return { pageData: page };
+const convertToIDataWithMeta = (page: IPageHasId): IDataWithMeta<IPageHasId> => {
+  return { data: page };
 };
 };
 
 
 export const DescendantsPageListSubstance = (props: SubstanceProps): JSX.Element => {
 export const DescendantsPageListSubstance = (props: SubstanceProps): JSX.Element => {
@@ -31,47 +38,26 @@ export const DescendantsPageListSubstance = (props: SubstanceProps): JSX.Element
   const { t } = useTranslation();
   const { t } = useTranslation();
 
 
   const {
   const {
-    pagingResult, activePage, setActivePage, onPagesDeleted,
+    pagingResult, activePage, setActivePage, forceHideMenuItems, onPagesDeleted, onPagePutBacked,
   } = props;
   } = props;
 
 
   const { data: isGuestUser } = useIsGuestUser();
   const { data: isGuestUser } = useIsGuestUser();
 
 
   const pageIds = pagingResult?.items?.map(page => page._id);
   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
   // for mutation
   const { advance: advancePt } = usePageTreeTermManager();
   const { advance: advancePt } = usePageTreeTermManager();
+  const { advance: advanceDpl } = useDescendantsPageListForCurrentPathTermManager();
 
 
   // initial data
   // initial data
   if (pagingResult != null) {
   if (pagingResult != null) {
-    const pages = pagingResult.items;
-
     // convert without meta at first
     // 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) => {
   const pageDeletedHandler: OnDeletedFunction = useCallback((...args) => {
@@ -84,11 +70,22 @@ export const DescendantsPageListSubstance = (props: SubstanceProps): JSX.Element
     }
     }
   }, [advancePt, onPagesDeleted, t]);
   }, [advancePt, onPagesDeleted, t]);
 
 
+  const pagePutBackedHandler: OnPutBackedFunction = useCallback((path) => {
+    toastSuccess(t('page_has_been_reverted', { path }));
+
+    advancePt();
+    advanceDpl();
+
+    if (onPagePutBacked != null) {
+      onPagePutBacked(path);
+    }
+  }, [advanceDpl, advancePt, onPagePutBacked, t]);
+
   function setPageNumber(selectedPageNumber) {
   function setPageNumber(selectedPageNumber) {
     setActivePage(selectedPageNumber);
     setActivePage(selectedPageNumber);
   }
   }
 
 
-  if (pagingResult == null || pagingResultWithMeta == null) {
+  if (pagingResult == null) {
     return (
     return (
       <div className="wiki">
       <div className="wiki">
         <div className="text-muted text-center">
         <div className="text-muted text-center">
@@ -103,9 +100,11 @@ export const DescendantsPageListSubstance = (props: SubstanceProps): JSX.Element
   return (
   return (
     <>
     <>
       <PageList
       <PageList
-        pages={pagingResultWithMeta}
+        pages={pageWithMetas}
         isEnableActions={!isGuestUser}
         isEnableActions={!isGuestUser}
+        forceHideMenuItems={forceHideMenuItems}
         onPagesDeleted={pageDeletedHandler}
         onPagesDeleted={pageDeletedHandler}
+        onPagePutBacked={pagePutBackedHandler}
       />
       />
 
 
       { showPager && (
       { showPager && (
@@ -150,6 +149,7 @@ export const DescendantsPageList = (props: Props): JSX.Element => {
       activePage={activePage}
       activePage={activePage}
       setActivePage={setActivePage}
       setActivePage={setActivePage}
       onPagesDeleted={() => mutate()}
       onPagesDeleted={() => mutate()}
+      onPagePutBacked={() => mutate()}
     />
     />
   );
   );
 };
 };
@@ -157,6 +157,8 @@ export const DescendantsPageList = (props: Props): JSX.Element => {
 export const DescendantsPageListForCurrentPath = (): JSX.Element => {
 export const DescendantsPageListForCurrentPath = (): JSX.Element => {
 
 
   const [activePage, setActivePage] = useState(1);
   const [activePage, setActivePage] = useState(1);
+
+  const { data: isTrashPage } = useIsTrashPage();
   const { data: pagingResult, error, mutate } = useSWRxDescendantsPageListForCurrrentPath(activePage);
   const { data: pagingResult, error, mutate } = useSWRxDescendantsPageListForCurrrentPath(activePage);
 
 
   if (error != null) {
   if (error != null) {
@@ -167,11 +169,14 @@ export const DescendantsPageListForCurrentPath = (): JSX.Element => {
     );
     );
   }
   }
 
 
+  const forceHideMenuItems = isTrashPage ? [MenuItemType.RENAME] : undefined;
+
   return (
   return (
     <DescendantsPageListSubstance
     <DescendantsPageListSubstance
       pagingResult={pagingResult}
       pagingResult={pagingResult}
       activePage={activePage}
       activePage={activePage}
       setActivePage={setActivePage}
       setActivePage={setActivePage}
+      forceHideMenuItems={forceHideMenuItems}
       onPagesDeleted={() => mutate()}
       onPagesDeleted={() => mutate()}
     />
     />
   );
   );

+ 8 - 16
packages/app/src/components/DuplicatedPathsTable.jsx

@@ -1,19 +1,17 @@
 import React from 'react';
 import React from 'react';
 import PropTypes from 'prop-types';
 import PropTypes from 'prop-types';
 
 
-import { withTranslation } from 'react-i18next';
+import { useTranslation } from 'react-i18next';
 import { pagePathUtils } from '@growi/core';
 import { pagePathUtils } from '@growi/core';
-import { withUnstatedContainers } from './UnstatedUtils';
-
-import PageContainer from '~/client/services/PageContainer';
 
 
 const { convertToNewAffiliationPath } = pagePathUtils;
 const { convertToNewAffiliationPath } = pagePathUtils;
 
 
 function DuplicatedPathsTable(props) {
 function DuplicatedPathsTable(props) {
+  const { t } = useTranslation();
+
   const {
   const {
-    pageContainer, oldPagePath, existingPaths, t,
+    fromPath, toPath, existingPaths,
   } = props;
   } = props;
-  const { path } = pageContainer.state;
 
 
   return (
   return (
     <table className="table table-bordered grw-duplicated-paths-table">
     <table className="table table-bordered grw-duplicated-paths-table">
@@ -25,7 +23,7 @@ function DuplicatedPathsTable(props) {
       </thead>
       </thead>
       <tbody className="overflow-auto d-block">
       <tbody className="overflow-auto d-block">
         {existingPaths.map((existPath) => {
         {existingPaths.map((existPath) => {
-          const convertedPath = convertToNewAffiliationPath(oldPagePath, path, existPath);
+          const convertedPath = convertToNewAffiliationPath(toPath, fromPath, existPath);
           return (
           return (
             <tr key={existPath} className="d-flex">
             <tr key={existPath} className="d-flex">
               <td className="text-break w-50">
               <td className="text-break w-50">
@@ -45,17 +43,11 @@ function DuplicatedPathsTable(props) {
 }
 }
 
 
 
 
-/**
- * Wrapper component for using unstated
- */
-const PageDuplicateModallWrapper = withUnstatedContainers(DuplicatedPathsTable, [PageContainer]);
-
 DuplicatedPathsTable.propTypes = {
 DuplicatedPathsTable.propTypes = {
-  t: PropTypes.func.isRequired, //  i18next
-  pageContainer: PropTypes.instanceOf(PageContainer).isRequired,
   existingPaths: PropTypes.array.isRequired,
   existingPaths: PropTypes.array.isRequired,
-  oldPagePath: PropTypes.string.isRequired,
+  fromPath: PropTypes.string.isRequired,
+  toPath: PropTypes.string.isRequired,
 };
 };
 
 
 
 
-export default withTranslation()(PageDuplicateModallWrapper);
+export default DuplicatedPathsTable;

+ 2 - 2
packages/app/src/components/Fab.jsx

@@ -53,7 +53,7 @@ const Fab = (props) => {
   function renderPageCreateButton() {
   function renderPageCreateButton() {
     return (
     return (
       <>
       <>
-        <div className={`rounded-circle position-absolute ${animateClasses}`} style={{ bottom: '2.3rem', right: '4rem' }}>
+        <div data-testid="grw-fab-create-page" className={`rounded-circle position-absolute ${animateClasses}`} style={{ bottom: '2.3rem', right: '4rem' }}>
           <button
           <button
             type="button"
             type="button"
             className={`btn btn-lg btn-create-page btn-primary rounded-circle p-0 waves-effect waves-light ${buttonClasses}`}
             className={`btn btn-lg btn-create-page btn-primary rounded-circle p-0 waves-effect waves-light ${buttonClasses}`}
@@ -69,7 +69,7 @@ const Fab = (props) => {
   return (
   return (
     <div className="grw-fab d-none d-md-block d-edit-none">
     <div className="grw-fab d-none d-md-block d-edit-none">
       {currentUser != null && renderPageCreateButton()}
       {currentUser != null && renderPageCreateButton()}
-      <div className={`rounded-circle position-absolute ${animateClasses}`} style={{ bottom: 0, right: 0 }}>
+      <div data-testid="grw-fab-return-to-top" className={`rounded-circle position-absolute ${animateClasses}`} style={{ bottom: 0, right: 0 }}>
         <button
         <button
           type="button"
           type="button"
           className={`btn btn-light btn-scroll-to-top rounded-circle p-0 ${buttonClasses}`}
           className={`btn btn-light btn-scroll-to-top rounded-circle p-0 ${buttonClasses}`}

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

@@ -3,7 +3,7 @@ import { useTranslation } from 'react-i18next';
 
 
 import { DevidedPagePath } from '@growi/core';
 import { DevidedPagePath } from '@growi/core';
 
 
-import { IPageHasId, IPageWithMeta } from '~/interfaces/page';
+import { IPageHasId } from '~/interfaces/page';
 import { useCurrentPagePath, useIsSharedUser } from '~/stores/context';
 import { useCurrentPagePath, useIsSharedUser } from '~/stores/context';
 import { useDescendantsPageListModal } from '~/stores/modal';
 import { useDescendantsPageListModal } from '~/stores/modal';
 import { useSWRxPageInfoForList } from '~/stores/page';
 import { useSWRxPageInfoForList } from '~/stores/page';
@@ -66,10 +66,12 @@ const IdenticalPathPage:FC<IdenticalPathPageProps> = (props: IdenticalPathPagePr
   const { data: currentPath } = useCurrentPagePath();
   const { data: currentPath } = useCurrentPagePath();
   const { data: isSharedUser } = useIsSharedUser();
   const { data: isSharedUser } = useIsSharedUser();
 
 
-  const { data: idToPageInfoMap } = useSWRxPageInfoForList(pageIds);
+  const { injectTo } = useSWRxPageInfoForList(pageIds, true, true);
 
 
   const { open: openDescendantPageListModal } = useDescendantsPageListModal();
   const { open: openDescendantPageListModal } = useDescendantsPageListModal();
 
 
+  const injectedPages = injectTo(pages);
+
   return (
   return (
     <div className="d-flex flex-column flex-lg-row-reverse">
     <div className="d-flex flex-column flex-lg-row-reverse">
 
 
@@ -95,14 +97,8 @@ const IdenticalPathPage:FC<IdenticalPathPageProps> = (props: IdenticalPathPagePr
 
 
         <div className="page-list">
         <div className="page-list">
           <ul className="page-list-ul list-group list-group-flush">
           <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 (
               return (
                 <PageListItemL
                 <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>[]) => {
   const gotoPage = useCallback((data: IPageWithMeta<IPageSearchMeta>[]) => {
     assert(data.length > 0);
     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
     // navigate to page
     if (page != null) {
     if (page != null) {

+ 19 - 10
packages/app/src/components/Navbar/GrowiContextualSubNavigation.tsx

@@ -5,8 +5,8 @@ import PropTypes from 'prop-types';
 
 
 import { DropdownItem } from 'reactstrap';
 import { DropdownItem } from 'reactstrap';
 
 
-import { OnDeletedFunction } from '~/interfaces/ui';
-import { IPageHasId } from '~/interfaces/page';
+import { OnDuplicatedFunction, OnRenamedFunction, OnDeletedFunction } from '~/interfaces/ui';
+import { IPageHasId, IPageToRenameWithMeta, IPageWithMeta } from '~/interfaces/page';
 
 
 import { withUnstatedContainers } from '../UnstatedUtils';
 import { withUnstatedContainers } from '../UnstatedUtils';
 import EditorContainer from '~/client/services/EditorContainer';
 import EditorContainer from '~/client/services/EditorContainer';
@@ -16,7 +16,7 @@ import {
 } from '~/stores/ui';
 } from '~/stores/ui';
 import {
 import {
   usePageAccessoriesModal, PageAccessoriesModalContents, IPageForPageDuplicateModal,
   usePageAccessoriesModal, PageAccessoriesModalContents, IPageForPageDuplicateModal,
-  usePageDuplicateModal, usePageRenameModal, IPageForPageRenameModal, usePageDeleteModal, usePagePresentationModal, IPageForPageDeleteModal,
+  usePageDuplicateModal, usePageRenameModal, usePageDeleteModal, usePagePresentationModal,
 } from '~/stores/modal';
 } from '~/stores/modal';
 
 
 
 
@@ -67,12 +67,15 @@ const AdditionalMenuItems = (props: AdditionalMenuItemsProps): JSX.Element => {
   const { open: openPresentationModal } = usePagePresentationModal();
   const { open: openPresentationModal } = usePagePresentationModal();
   const { open: openAccessoriesModal } = usePageAccessoriesModal();
   const { open: openAccessoriesModal } = usePageAccessoriesModal();
 
 
-  const hrefForPresentationModal = '?presentation=1';
+  const hrefForPresentationModal = `${pageId}/?presentation=1`;
 
 
   return (
   return (
     <>
     <>
       {/* Presentation */}
       {/* Presentation */}
-      <DropdownItem onClick={() => openPresentationModal(hrefForPresentationModal)}>
+      <DropdownItem
+        onClick={() => openPresentationModal(hrefForPresentationModal)}
+        data-testid="open-presentation-modal-btn"
+      >
         <i className="icon-fw"><PresentationIcon /></i>
         <i className="icon-fw"><PresentationIcon /></i>
         { t('Presentation Mode') }
         { t('Presentation Mode') }
       </DropdownItem>
       </DropdownItem>
@@ -181,11 +184,17 @@ const GrowiContextualSubNavigation = (props) => {
   }, [pageId]);
   }, [pageId]);
 
 
   const duplicateItemClickedHandler = useCallback(async(page: IPageForPageDuplicateModal) => {
   const duplicateItemClickedHandler = useCallback(async(page: IPageForPageDuplicateModal) => {
-    openDuplicateModal(page);
+    const duplicatedHandler: OnDuplicatedFunction = (fromPath, toPath) => {
+      window.location.href = toPath;
+    };
+    openDuplicateModal(page, { onDuplicated: duplicatedHandler });
   }, [openDuplicateModal]);
   }, [openDuplicateModal]);
 
 
-  const renameItemClickedHandler = useCallback(async(page: IPageForPageRenameModal) => {
-    openRenameModal(page);
+  const renameItemClickedHandler = useCallback(async(page: IPageToRenameWithMeta) => {
+    const renamedHandler: OnRenamedFunction = () => {
+      window.location.reload();
+    };
+    openRenameModal(page, { onRenamed: renamedHandler });
   }, [openRenameModal]);
   }, [openRenameModal]);
 
 
   const onDeletedHandler: OnDeletedFunction = useCallback((pathOrPathsToDelete, isRecursively, isCompletely) => {
   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]);
   }, [onDeletedHandler, openDeleteModal]);
 
 
   const templateMenuItemClickHandler = useCallback(() => {
   const templateMenuItemClickHandler = useCallback(() => {

+ 25 - 12
packages/app/src/components/Navbar/SubNavButtons.tsx

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

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

@@ -146,11 +146,14 @@ class Page extends React.Component {
     const { appContainer, pageContainer } = this.props;
     const { appContainer, pageContainer } = this.props;
     const { isMobile } = appContainer;
     const { isMobile } = appContainer;
     const isLoggedIn = appContainer.currentUser != null;
     const isLoggedIn = appContainer.currentUser != null;
-    const { markdown } = pageContainer.state;
+    const { markdown, revisionId } = pageContainer.state;
 
 
     return (
     return (
       <div className={`mb-5 ${isMobile ? 'page-mobile' : ''}`}>
       <div className={`mb-5 ${isMobile ? 'page-mobile' : ''}`}>
-        <RevisionRenderer growiRenderer={this.growiRenderer} markdown={markdown} />
+
+        { revisionId != null && (
+          <RevisionRenderer growiRenderer={this.growiRenderer} markdown={markdown} />
+        )}
 
 
         { isLoggedIn && (
         { isLoggedIn && (
           <>
           <>
@@ -188,7 +191,6 @@ const PageWrapper = (props) => {
   const { data: grantGroupId } = useSelectedGrantGroupId();
   const { data: grantGroupId } = useSelectedGrantGroupId();
   const { data: grantGroupName } = useSelectedGrantGroupName();
   const { data: grantGroupName } = useSelectedGrantGroupName();
 
 
-
   if (editorMode == null) {
   if (editorMode == null) {
     return null;
     return null;
   }
   }

+ 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 React, { useCallback } from 'react';
 import { useTranslation } from 'react-i18next';
 import { useTranslation } from 'react-i18next';
 import { UncontrolledTooltip } from 'reactstrap';
 import { UncontrolledTooltip } from 'reactstrap';
+import { useIsNotFoundPermalink } from '~/stores/context';
 
 
 import { EditorMode, useEditorMode } from '~/stores/ui';
 import { EditorMode, useEditorMode } from '~/stores/ui';
 
 
@@ -14,6 +15,7 @@ const NotFoundAlert = (props: Props): JSX.Element => {
   const { isGuestUserMode } = props;
   const { isGuestUserMode } = props;
 
 
   const { data: editorMode, mutate: mutateEditorMode } = useEditorMode();
   const { data: editorMode, mutate: mutateEditorMode } = useEditorMode();
+  const { data: isNotFoundPermalink } = useIsNotFoundPermalink(); // TODO: Remove this when renaming on editor is implemented
 
 
   const isEditorMode = editorMode !== EditorMode.View;
   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>
           <i className="icon-info pr-2 font-weight-bold" aria-hidden="true"></i>
           {t('not_found_page.page_not_exist_alert')}
           {t('not_found_page.page_not_exist_alert')}
         </h2>
         </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}>
           <UncontrolledTooltip placement="bottom" target="create-page-btn-wrapper-for-tooltip" fade={false}>
             {t('Not available for guest')}
             {t('Not available for guest')}
           </UncontrolledTooltip>
           </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);

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

@@ -62,11 +62,7 @@ class LegacyRevisionRenderer extends React.PureComponent {
    * @param {string} body html strings
    * @param {string} body html strings
    * @param {string} keywords
    * @param {string} keywords
    */
    */
-  getHighlightedBody(body, _keywords) {
-    const keywords = Array.isArray(_keywords)
-      ? _keywords
-      : [_keywords];
-
+  getHighlightedBody(body, keywords) {
     const normalizedKeywordsArray = [];
     const normalizedKeywordsArray = [];
     // !!TODO!!: add test code refs: https://redmine.weseek.co.jp/issues/86841
     // !!TODO!!: add test code refs: https://redmine.weseek.co.jp/issues/86841
     // Separate keywords
     // Separate keywords
@@ -143,7 +139,8 @@ class LegacyRevisionRenderer extends React.PureComponent {
     await interceptorManager.process('prePostProcess', context);
     await interceptorManager.process('prePostProcess', context);
     context.parsedHTML = growiRenderer.postProcess(context.parsedHTML);
     context.parsedHTML = growiRenderer.postProcess(context.parsedHTML);
 
 
-    if (highlightKeywords != null && highlightKeywords.length > 0) {
+    const isMarkdownEmpty = context.markdown.trim().length === 0;
+    if (highlightKeywords != null && highlightKeywords.length > 0 && !isMarkdownEmpty) {
       context.parsedHTML = this.getHighlightedBody(context.parsedHTML, highlightKeywords);
       context.parsedHTML = this.getHighlightedBody(context.parsedHTML, highlightKeywords);
     }
     }
     await interceptorManager.process('postPostProcess', context);
     await interceptorManager.process('postPostProcess', context);
@@ -190,7 +187,7 @@ const RevisionRenderer = (props) => {
 RevisionRenderer.propTypes = {
 RevisionRenderer.propTypes = {
   growiRenderer: PropTypes.instanceOf(GrowiRenderer).isRequired,
   growiRenderer: PropTypes.instanceOf(GrowiRenderer).isRequired,
   markdown: PropTypes.string.isRequired,
   markdown: PropTypes.string.isRequired,
-  highlightKeywords: PropTypes.oneOfType([PropTypes.string, PropTypes.arrayOf(PropTypes.string)]),
+  highlightKeywords: PropTypes.arrayOf(PropTypes.string),
   additionalClassName: PropTypes.string,
   additionalClassName: PropTypes.string,
 };
 };
 
 

+ 21 - 17
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 PropTypes from 'prop-types';
 
 
 import { withTranslation } from 'react-i18next';
 import { withTranslation } from 'react-i18next';
@@ -8,12 +8,20 @@ import { withUnstatedContainers } from '../UnstatedUtils';
 import AppContainer from '~/client/services/AppContainer';
 import AppContainer from '~/client/services/AppContainer';
 import PageContainer from '~/client/services/PageContainer';
 import PageContainer from '~/client/services/PageContainer';
 
 
-import EmptyTrashModal from '../EmptyTrashModal';
-
 import { useCurrentUpdatedAt, useShareLinkId } from '~/stores/context';
 import { useCurrentUpdatedAt, useShareLinkId } from '~/stores/context';
 import { usePageDeleteModal, usePutBackPageModal } from '~/stores/modal';
 import { usePageDeleteModal, usePutBackPageModal } from '~/stores/modal';
 import { useSWRxPageInfo } from '~/stores/page';
 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 TrashPageAlert = (props) => {
   const { t, pageContainer } = props;
   const { t, pageContainer } = props;
   const {
   const {
@@ -43,29 +51,25 @@ const TrashPageAlert = (props) => {
   }
   }
 
 
   function openPutbackPageModalHandler() {
   function openPutbackPageModalHandler() {
-    openPutBackPageModal(pageId, path);
+    const putBackedHandler = (path) => {
+      window.location.reload();
+    };
+    openPutBackPageModal({ pageId, path }, { onPutBacked: putBackedHandler });
   }
   }
 
 
-  const onDeletedHandler = useCallback((pathOrPathsToDelete, isRecursively, isCompletely) => {
-    if (typeof pathOrPathsToDelete !== 'string') {
-      return;
-    }
-
-    const path = pathOrPathsToDelete;
-    window.location.href = path;
-  }, []);
-
   function openPageDeleteModalHandler() {
   function openPageDeleteModalHandler() {
     const pageToDelete = {
     const pageToDelete = {
-      pageId,
-      revisionId,
-      path,
+      data: {
+        _id: pageId,
+        revision: revisionId,
+        path,
+      },
     };
     };
     openDeleteModal(
     openDeleteModal(
       [pageToDelete],
       [pageToDelete],
       {
       {
         isAbleToDeleteCompletely: pageInfo.isAbleToDeleteCompletely,
         isAbleToDeleteCompletely: pageInfo.isAbleToDeleteCompletely,
-        onDeletedHandler,
+        onDeleted: onDeletedHandler,
       },
       },
     );
     );
   }
   }

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

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

+ 102 - 70
packages/app/src/components/PageDuplicateModal.jsx → packages/app/src/components/PageDuplicateModal.tsx

@@ -1,68 +1,86 @@
-import React, { useState, useEffect, useCallback } from 'react';
-import PropTypes from 'prop-types';
+import React, {
+  useState, useEffect, useCallback, useMemo,
+} from 'react';
 
 
 import {
 import {
   Modal, ModalHeader, ModalBody, ModalFooter,
   Modal, ModalHeader, ModalBody, ModalFooter,
 } from 'reactstrap';
 } from 'reactstrap';
 
 
-import { withTranslation } from 'react-i18next';
+import { useTranslation } from 'react-i18next';
 import { debounce } from 'throttle-debounce';
 import { debounce } from 'throttle-debounce';
-import { withUnstatedContainers } from './UnstatedUtils';
+
+import { apiv3Get, apiv3Post } from '~/client/util/apiv3-client';
 import { toastError } from '~/client/util/apiNotification';
 import { toastError } from '~/client/util/apiNotification';
+
 import { usePageDuplicateModal } from '~/stores/modal';
 import { usePageDuplicateModal } from '~/stores/modal';
+import { useIsSearchServiceReachable, useSiteUrl } from '~/stores/context';
 
 
-import AppContainer from '~/client/services/AppContainer';
 import PagePathAutoComplete from './PagePathAutoComplete';
 import PagePathAutoComplete from './PagePathAutoComplete';
 import ApiErrorMessageList from './PageManagement/ApiErrorMessageList';
 import ApiErrorMessageList from './PageManagement/ApiErrorMessageList';
-import ComparePathsTable from './ComparePathsTable';
 import DuplicatePathsTable from './DuplicatedPathsTable';
 import DuplicatePathsTable from './DuplicatedPathsTable';
 
 
-const LIMIT_FOR_LIST = 10;
 
 
-const PageDuplicateModal = (props) => {
-  const {
-    t, appContainer,
-  } = props;
+const PageDuplicateModal = (): JSX.Element => {
+  const { t } = useTranslation();
+
+  const { data: siteUrl } = useSiteUrl();
+  const { data: isReachable } = useIsSearchServiceReachable();
 
 
-  const config = appContainer.getConfig();
-  const isReachable = config.isSearchServiceReachable;
-  const { crowi } = appContainer.config;
   const { data: duplicateModalData, close: closeDuplicateModal } = usePageDuplicateModal();
   const { data: duplicateModalData, close: closeDuplicateModal } = usePageDuplicateModal();
 
 
-  const { isOpened, page } = duplicateModalData;
-  const { pageId, path } = page;
+  const isOpened = duplicateModalData?.isOpened ?? false;
+  const page = duplicateModalData?.page;
 
 
-  const [pageNameInput, setPageNameInput] = useState(path);
+  const [pageNameInput, setPageNameInput] = useState('');
 
 
   const [errs, setErrs] = useState(null);
   const [errs, setErrs] = useState(null);
 
 
   const [subordinatedPages, setSubordinatedPages] = useState([]);
   const [subordinatedPages, setSubordinatedPages] = useState([]);
+  const [existingPaths, setExistingPaths] = useState<string[]>([]);
   const [isDuplicateRecursively, setIsDuplicateRecursively] = useState(true);
   const [isDuplicateRecursively, setIsDuplicateRecursively] = useState(true);
   const [isDuplicateRecursivelyWithoutExistPath, setIsDuplicateRecursivelyWithoutExistPath] = useState(true);
   const [isDuplicateRecursivelyWithoutExistPath, setIsDuplicateRecursivelyWithoutExistPath] = useState(true);
-  const [existingPaths, setExistingPaths] = useState([]);
 
 
-  const checkExistPaths = useCallback(async(newParentPath) => {
+  const updateSubordinatedList = useCallback(async() => {
+    if (page == null) {
+      return;
+    }
+
+    const { path } = page;
     try {
     try {
-      const res = await appContainer.apiv3Get('/page/exist-paths', { fromPath: path, toPath: newParentPath });
+      const res = await apiv3Get('/pages/subordinated-list', { path });
+      setSubordinatedPages(res.data.subordinatedPages);
+    }
+    catch (err) {
+      setErrs(err);
+      toastError(t('modal_duplicate.label.Failed to get subordinated pages'));
+    }
+  }, [page, t]);
+
+  const checkExistPaths = useCallback(async(fromPath, toPath) => {
+    if (page == null) {
+      return;
+    }
+
+    try {
+      const res = await apiv3Get<{ existPaths: string[] }>('/page/exist-paths', { fromPath, toPath });
       const { existPaths } = res.data;
       const { existPaths } = res.data;
       setExistingPaths(existPaths);
       setExistingPaths(existPaths);
     }
     }
     catch (err) {
     catch (err) {
       setErrs(err);
       setErrs(err);
-      toastError(t('modal_rename.label.Fail to get exist path'));
+      toastError(t('modal_rename.label.Failed to get exist path'));
     }
     }
-  }, [appContainer, path, t]);
+  }, [page, t]);
 
 
-
-  const checkExistPathsDebounce = useCallback(() => {
-    debounce(1000, checkExistPaths);
+  const checkExistPathsDebounce = useMemo(() => {
+    return debounce(1000, checkExistPaths);
   }, [checkExistPaths]);
   }, [checkExistPaths]);
 
 
   useEffect(() => {
   useEffect(() => {
-    if (pageId != null && path != null && pageNameInput !== path) {
-      checkExistPathsDebounce(pageNameInput, subordinatedPages);
+    if (page != null && pageNameInput !== page.path) {
+      checkExistPathsDebounce(page.path, pageNameInput);
     }
     }
-  }, [pageNameInput, subordinatedPages, path, pageId, checkExistPathsDebounce]);
+  }, [pageNameInput, subordinatedPages, checkExistPathsDebounce, page]);
 
 
   /**
   /**
    * change pageNameInput for PagePathAutoComplete
    * change pageNameInput for PagePathAutoComplete
@@ -86,44 +104,63 @@ const PageDuplicateModal = (props) => {
     setIsDuplicateRecursively(!isDuplicateRecursively);
     setIsDuplicateRecursively(!isDuplicateRecursively);
   }
   }
 
 
-  const getSubordinatedList = useCallback(async() => {
+  useEffect(() => {
+    if (page != null && isOpened) {
+      updateSubordinatedList();
+      setPageNameInput(page.path);
+    }
+  }, [isOpened, page, updateSubordinatedList]);
+
+  const duplicate = useCallback(async() => {
+    if (page == null) {
+      return;
+    }
+
+    setErrs(null);
+
+    const { pageId, path } = page;
     try {
     try {
-      const res = await appContainer.apiv3Get('/pages/subordinated-list', { path, limit: LIMIT_FOR_LIST });
-      const { subordinatedPaths } = res.data;
-      setSubordinatedPages(subordinatedPaths);
+      const { data } = await 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) {
     catch (err) {
       setErrs(err);
       setErrs(err);
-      toastError(t('modal_duplicate.label.Fail to get subordinated pages'));
     }
     }
-  }, [appContainer, path, t]);
+  }, [closeDuplicateModal, duplicateModalData?.opts?.onDuplicated, isDuplicateRecursively, page, pageNameInput]);
 
 
   useEffect(() => {
   useEffect(() => {
     if (isOpened) {
     if (isOpened) {
-      getSubordinatedList();
-      setPageNameInput(path);
+      return;
     }
     }
-  }, [isOpened, getSubordinatedList, path]);
 
 
-  function changeIsDuplicateRecursivelyWithoutExistPathHandler() {
-    setIsDuplicateRecursivelyWithoutExistPath(!isDuplicateRecursivelyWithoutExistPath);
-  }
+    // reset states after the modal closed
+    setTimeout(() => {
+      setPageNameInput('');
+      setErrs(null);
+      setSubordinatedPages([]);
+      setExistingPaths([]);
+      setIsDuplicateRecursively(true);
+      setIsDuplicateRecursivelyWithoutExistPath(false);
+    }, 1000);
 
 
-  async function duplicate() {
-    setErrs(null);
+  }, [isOpened]);
 
 
-    try {
-      await appContainer.apiv3Post('/pages/duplicate', { pageId, pageNameInput, isRecursively: isDuplicateRecursively });
-      window.location.href = encodeURI(`${pageNameInput}?duplicated=${path}`);
-    }
-    catch (err) {
-      setErrs(err);
-    }
+  if (page == null) {
+    return <></>;
   }
   }
 
 
-  function ppacSubmitHandler() {
-    duplicate();
-  }
+  const { path } = page;
+  const isTargetPageDuplicate = existingPaths.includes(pageNameInput);
+
+  const submitButtonEnabled = existingPaths.length === 0
+    || (isDuplicateRecursively && isDuplicateRecursivelyWithoutExistPath);
 
 
   return (
   return (
     <Modal size="lg" isOpen={isOpened} toggle={closeDuplicateModal} className="grw-duplicate-page" autoFocus={false}>
     <Modal size="lg" isOpen={isOpened} toggle={closeDuplicateModal} className="grw-duplicate-page" autoFocus={false}>
@@ -138,14 +175,14 @@ const PageDuplicateModal = (props) => {
           <label htmlFor="duplicatePageName">{ t('modal_duplicate.label.New page name') }</label><br />
           <label htmlFor="duplicatePageName">{ t('modal_duplicate.label.New page name') }</label><br />
           <div className="input-group">
           <div className="input-group">
             <div className="input-group-prepend">
             <div className="input-group-prepend">
-              <span className="input-group-text">{crowi.url}</span>
+              <span className="input-group-text">{siteUrl}</span>
             </div>
             </div>
             <div className="flex-fill">
             <div className="flex-fill">
               {isReachable
               {isReachable
                 ? (
                 ? (
                   <PagePathAutoComplete
                   <PagePathAutoComplete
                     initializedPath={path}
                     initializedPath={path}
-                    onSubmit={ppacSubmitHandler}
+                    onSubmit={duplicate}
                     onInputChange={ppacInputChangeHandler}
                     onInputChange={ppacInputChangeHandler}
                     autoFocus
                     autoFocus
                   />
                   />
@@ -162,6 +199,11 @@ const PageDuplicateModal = (props) => {
             </div>
             </div>
           </div>
           </div>
         </div>
         </div>
+
+        { isTargetPageDuplicate && (
+          <p className="text-danger">Error: Target path is duplicated.</p>
+        ) }
+
         <div className="custom-control custom-checkbox custom-checkbox-warning mb-3">
         <div className="custom-control custom-checkbox custom-checkbox-warning mb-3">
           <input
           <input
             className="custom-control-input"
             className="custom-control-input"
@@ -185,7 +227,7 @@ const PageDuplicateModal = (props) => {
                   id="cbDuplicatewithoutExistRecursively"
                   id="cbDuplicatewithoutExistRecursively"
                   type="checkbox"
                   type="checkbox"
                   checked={isDuplicateRecursivelyWithoutExistPath}
                   checked={isDuplicateRecursivelyWithoutExistPath}
-                  onChange={changeIsDuplicateRecursivelyWithoutExistPathHandler}
+                  onChange={() => setIsDuplicateRecursivelyWithoutExistPath(!isDuplicateRecursivelyWithoutExistPath)}
                 />
                 />
                 <label className="custom-control-label" htmlFor="cbDuplicatewithoutExistRecursively">
                 <label className="custom-control-label" htmlFor="cbDuplicatewithoutExistRecursively">
                   { t('modal_duplicate.label.Duplicate without exist path') }
                   { t('modal_duplicate.label.Duplicate without exist path') }
@@ -194,8 +236,9 @@ const PageDuplicateModal = (props) => {
             )}
             )}
           </div>
           </div>
           <div>
           <div>
-            {isDuplicateRecursively && path != null && <ComparePathsTable path={path} subordinatedPages={subordinatedPages} newPagePath={pageNameInput} />}
-            {isDuplicateRecursively && existingPaths.length !== 0 && <DuplicatePathsTable existingPaths={existingPaths} oldPagePath={pageNameInput} />}
+            {isDuplicateRecursively && existingPaths.length !== 0 && (
+              <DuplicatePathsTable existingPaths={existingPaths} fromPath={path} toPath={pageNameInput} />
+            ) }
           </div>
           </div>
         </div>
         </div>
 
 
@@ -206,7 +249,7 @@ const PageDuplicateModal = (props) => {
           type="button"
           type="button"
           className="btn btn-primary"
           className="btn btn-primary"
           onClick={duplicate}
           onClick={duplicate}
-          disabled={(isDuplicateRecursively && !isDuplicateRecursivelyWithoutExistPath && existingPaths.length !== 0)}
+          disabled={!submitButtonEnabled}
         >
         >
           { t('modal_duplicate.label.Duplicate page') }
           { t('modal_duplicate.label.Duplicate page') }
         </button>
         </button>
@@ -216,15 +259,4 @@ const PageDuplicateModal = (props) => {
 };
 };
 
 
 
 
-/**
- * Wrapper component for using unstated
- */
-const PageDuplicateModallWrapper = withUnstatedContainers(PageDuplicateModal, [AppContainer]);
-
-
-PageDuplicateModal.propTypes = {
-  t: PropTypes.func.isRequired, //  i18next
-  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
-};
-
-export default withTranslation()(PageDuplicateModallWrapper);
+export default PageDuplicateModal;

+ 12 - 6
packages/app/src/components/PageList/PageList.tsx

@@ -2,21 +2,25 @@ import React from 'react';
 import { useTranslation } from 'react-i18next';
 import { useTranslation } from 'react-i18next';
 
 
 import { IPageWithMeta } from '~/interfaces/page';
 import { IPageWithMeta } from '~/interfaces/page';
-import { IPagingResult } from '~/interfaces/paging-result';
-import { OnDeletedFunction } from '~/interfaces/ui';
+import { OnDeletedFunction, OnPutBackedFunction } from '~/interfaces/ui';
+import { ForceHideMenuItems } from '../Common/Dropdown/PageItemControl';
 
 
 import { PageListItemL } from './PageListItemL';
 import { PageListItemL } from './PageListItemL';
 
 
 
 
 type Props = {
 type Props = {
-  pages: IPagingResult<IPageWithMeta>,
+  pages: IPageWithMeta[],
   isEnableActions?: boolean,
   isEnableActions?: boolean,
+  forceHideMenuItems?: ForceHideMenuItems,
   onPagesDeleted?: OnDeletedFunction,
   onPagesDeleted?: OnDeletedFunction,
+  onPagePutBacked?: OnPutBackedFunction,
 }
 }
 
 
 const PageList = (props: Props): JSX.Element => {
 const PageList = (props: Props): JSX.Element => {
   const { t } = useTranslation();
   const { t } = useTranslation();
-  const { pages, isEnableActions, onPagesDeleted } = props;
+  const {
+    pages, isEnableActions, forceHideMenuItems, onPagesDeleted, onPagePutBacked,
+  } = props;
 
 
   if (pages == null) {
   if (pages == null) {
     return (
     return (
@@ -28,12 +32,14 @@ const PageList = (props: Props): JSX.Element => {
     );
     );
   }
   }
 
 
-  const pageList = pages.items.map(page => (
+  const pageList = pages.map(page => (
     <PageListItemL
     <PageListItemL
-      key={page.pageData._id}
+      key={page.data._id}
       page={page}
       page={page}
       isEnableActions={isEnableActions}
       isEnableActions={isEnableActions}
+      forceHideMenuItems={forceHideMenuItems}
       onPageDeleted={onPagesDeleted}
       onPageDeleted={onPagesDeleted}
+      onPagePutBacked={onPagePutBacked}
     />
     />
   ));
   ));
 
 

+ 40 - 36
packages/app/src/components/PageList/PageListItemL.tsx

@@ -11,39 +11,47 @@ import urljoin from 'url-join';
 
 
 import { UserPicture, PageListMeta } from '@growi/ui';
 import { UserPicture, PageListMeta } from '@growi/ui';
 import { DevidedPagePath } from '@growi/core';
 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 { useIsDeviceSmallerThanLg } from '~/stores/ui';
 import {
 import {
   usePageRenameModal, usePageDuplicateModal, usePageDeleteModal, usePutBackPageModal,
   usePageRenameModal, usePageDuplicateModal, usePageDeleteModal, usePutBackPageModal,
 } from '~/stores/modal';
 } from '~/stores/modal';
 import {
 import {
-  IPageInfoAll, IPageWithMeta, isIPageInfoForEntity, isIPageInfoForListing,
+  IPageInfoAll, IPageInfoForEntity, IPageInfoForListing, IPageWithMeta, isIPageInfoForListing,
 } from '~/interfaces/page';
 } from '~/interfaces/page';
 import { IPageSearchMeta, isIPageSearchMeta } from '~/interfaces/search';
 import { IPageSearchMeta, isIPageSearchMeta } from '~/interfaces/search';
-import { OnDeletedFunction } from '~/interfaces/ui';
+import {
+  OnDuplicatedFunction, OnRenamedFunction, OnDeletedFunction, OnPutBackedFunction,
+} from '~/interfaces/ui';
+import LinkedPagePath from '~/models/linked-page-path';
 
 
 import { ForceHideMenuItems, PageItemControl } from '../Common/Dropdown/PageItemControl';
 import { ForceHideMenuItems, PageItemControl } from '../Common/Dropdown/PageItemControl';
-import LinkedPagePath from '~/models/linked-page-path';
 import PagePathHierarchicalLink from '../PagePathHierarchicalLink';
 import PagePathHierarchicalLink from '../PagePathHierarchicalLink';
-import { ISelectable } from '~/client/interfaces/selectable-all';
 
 
 type Props = {
 type Props = {
-  page: IPageWithMeta | IPageWithMeta<IPageInfoAll & IPageSearchMeta>,
+  page: IPageWithMeta<IPageInfoForEntity> | IPageWithMeta<IPageSearchMeta> | IPageWithMeta<IPageInfoForListing & IPageSearchMeta>,
   isSelected?: boolean, // is item selected(focused)
   isSelected?: boolean, // is item selected(focused)
   isEnableActions?: boolean,
   isEnableActions?: boolean,
   forceHideMenuItems?: ForceHideMenuItems,
   forceHideMenuItems?: ForceHideMenuItems,
   showPageUpdatedTime?: boolean, // whether to show page's updated time at the top-right corner of item
   showPageUpdatedTime?: boolean, // whether to show page's updated time at the top-right corner of item
   onCheckboxChanged?: (isChecked: boolean, pageId: string) => void,
   onCheckboxChanged?: (isChecked: boolean, pageId: string) => void,
   onClickItem?: (pageId: string) => void,
   onClickItem?: (pageId: string) => void,
+  onPageDuplicated?: OnDuplicatedFunction,
+  onPageRenamed?: OnRenamedFunction,
   onPageDeleted?: OnDeletedFunction,
   onPageDeleted?: OnDeletedFunction,
+  onPagePutBacked?: OnPutBackedFunction,
 }
 }
 
 
 const PageListItemLSubstance: ForwardRefRenderFunction<ISelectable, Props> = (props: Props, ref): JSX.Element => {
 const PageListItemLSubstance: ForwardRefRenderFunction<ISelectable, Props> = (props: Props, ref): JSX.Element => {
   const {
   const {
     // todo: refactoring variable name to clear what changed
     // todo: refactoring variable name to clear what changed
-    page: { pageData, pageMeta }, isSelected, isEnableActions,
+    page: { data: pageData, meta: pageMeta }, isSelected, isEnableActions,
     forceHideMenuItems,
     forceHideMenuItems,
     showPageUpdatedTime,
     showPageUpdatedTime,
-    onClickItem, onCheckboxChanged, onPageDeleted,
+    onClickItem, onCheckboxChanged, onPageDuplicated, onPageRenamed, onPageDeleted, onPagePutBacked,
   } = props;
   } = props;
 
 
   const inputRef = useRef<HTMLInputElement>(null);
   const inputRef = useRef<HTMLInputElement>(null);
@@ -92,30 +100,27 @@ const PageListItemLSubstance: ForwardRefRenderFunction<ISelectable, Props> = (pr
     }
     }
   }, [isDeviceSmallerThanLg, onClickItem, pageData._id]);
   }, [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 duplicateMenuItemClickHandler = useCallback(() => {
     const page = {
     const page = {
       pageId: pageData._id,
       pageId: pageData._id,
       path: pageData.path,
       path: pageData.path,
     };
     };
-    openDuplicateModal(page);
-  }, [openDuplicateModal, pageData]);
+    openDuplicateModal(page, { onDuplicated: onPageDuplicated });
+  }, [onPageDuplicated, openDuplicateModal, pageData._id, pageData.path]);
 
 
-  const renameMenuItemClickHandler = useCallback(() => {
-    const page = {
-      pageId: pageData._id,
-      revisionId: pageData.revision as string,
-      path: pageData.path,
-    };
-    openRenameModal(page);
-  }, [openRenameModal, pageData]);
+  const renameMenuItemClickHandler = useCallback((_id: string, pageInfo: IPageInfoAll | undefined) => {
+    const page = { data: pageData, meta: pageInfo };
+    openRenameModal(page, { onRenamed: onPageRenamed });
+  }, [pageData, onPageRenamed, openRenameModal]);
 
 
 
 
-  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
     // open modal
     openDeleteModal([pageToDelete], { onDeleted: onPageDeleted });
     openDeleteModal([pageToDelete], { onDeleted: onPageDeleted });
@@ -123,8 +128,8 @@ const PageListItemLSubstance: ForwardRefRenderFunction<ISelectable, Props> = (pr
 
 
   const revertMenuItemClickHandler = useCallback(() => {
   const revertMenuItemClickHandler = useCallback(() => {
     const { _id: pageId, path } = pageData;
     const { _id: pageId, path } = pageData;
-    openPutBackPageModal(pageId, path);
-  }, [openPutBackPageModal, pageData]);
+    openPutBackPageModal({ pageId, path }, { onPutBacked: onPagePutBacked });
+  }, [onPagePutBacked, openPutBackPageModal, pageData]);
 
 
   const styleListGroupItem = (!isDeviceSmallerThanLg && onClickItem != null) ? 'list-group-item-action' : '';
   const styleListGroupItem = (!isDeviceSmallerThanLg && onClickItem != null) ? 'list-group-item-action' : '';
   // background color of list item changes when class "active" exists under 'list-group-item'
   // background color of list item changes when class "active" exists under 'list-group-item'
@@ -135,16 +140,16 @@ const PageListItemLSubstance: ForwardRefRenderFunction<ISelectable, Props> = (pr
   return (
   return (
     <li
     <li
       key={pageData._id}
       key={pageData._id}
-      className={`list-group-item p-0 ${styleListGroupItem} ${styleActive}`}
+      className={`list-group-item d-flex align-items-center px-3 px-md-1 ${styleListGroupItem} ${styleActive}`}
     >
     >
       <div
       <div
-        className="text-break"
+        className="text-break w-100"
         onClick={clickHandler}
         onClick={clickHandler}
       >
       >
         <div className="d-flex">
         <div className="d-flex">
           {/* checkbox */}
           {/* checkbox */}
           {onCheckboxChanged != null && (
           {onCheckboxChanged != null && (
-            <div className="d-flex align-items-center justify-content-center pl-md-2 pl-3">
+            <div className="d-flex align-items-center justify-content-center">
               <CustomInput
               <CustomInput
                 type="checkbox"
                 type="checkbox"
                 id={`cbSelect-${pageData._id}`}
                 id={`cbSelect-${pageData._id}`}
@@ -155,7 +160,7 @@ const PageListItemLSubstance: ForwardRefRenderFunction<ISelectable, Props> = (pr
             </div>
             </div>
           )}
           )}
 
 
-          <div className="flex-grow-1 p-md-3 pl-2 py-3 pr-3">
+          <div className="flex-grow-1 px-2 px-md-4">
             <div className="d-flex justify-content-between">
             <div className="d-flex justify-content-between">
               {/* page path */}
               {/* page path */}
               <PagePathHierarchicalLink
               <PagePathHierarchicalLink
@@ -193,19 +198,18 @@ const PageListItemLSubstance: ForwardRefRenderFunction<ISelectable, Props> = (pr
               </Clamp>
               </Clamp>
 
 
               {/* page meta */}
               {/* 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 ml-2 text-nowrap">
+                <PageListMeta page={pageData} bookmarkCount={pageMeta?.bookmarkCount} shouldSpaceOutIcon />
+              </div>
 
 
               {/* doropdown icon includes page control buttons */}
               {/* doropdown icon includes page control buttons */}
-              <div className="item-control ml-auto">
+              <div className="ml-auto">
                 <PageItemControl
                 <PageItemControl
                   pageId={pageData._id}
                   pageId={pageData._id}
-                  pageInfo={pageMeta}
+                  pageInfo={isIPageInfoForListing(pageMeta) ? pageMeta : undefined}
                   isEnableActions={isEnableActions}
                   isEnableActions={isEnableActions}
                   forceHideMenuItems={forceHideMenuItems}
                   forceHideMenuItems={forceHideMenuItems}
+                  onClickBookmarkMenuItem={bookmarkMenuItemClickHandler}
                   onClickRenameMenuItem={renameMenuItemClickHandler}
                   onClickRenameMenuItem={renameMenuItemClickHandler}
                   onClickDuplicateMenuItem={duplicateMenuItemClickHandler}
                   onClickDuplicateMenuItem={duplicateMenuItemClickHandler}
                   onClickDeleteMenuItem={deleteMenuItemClickHandler}
                   onClickDeleteMenuItem={deleteMenuItemClickHandler}

+ 3 - 1
packages/app/src/components/PageList/PageListItemS.jsx

@@ -20,7 +20,9 @@ export default class PageListItemS extends React.Component {
       <>
       <>
         <UserPicture user={page.lastUpdateUser} noLink={noLink} />
         <UserPicture user={page.lastUpdateUser} noLink={noLink} />
         {pagePathElem}
         {pagePathElem}
-        <PageListMeta page={page} />
+        <span className="ml-2">
+          <PageListMeta page={page} />
+        </span>
       </>
       </>
     );
     );
   }
   }

+ 0 - 263
packages/app/src/components/PageRenameModal.jsx

@@ -1,263 +0,0 @@
-import React, {
-  useState, useEffect, useCallback,
-} from 'react';
-import PropTypes from 'prop-types';
-
-import {
-  Modal, ModalHeader, ModalBody, ModalFooter,
-} from 'reactstrap';
-
-import { withTranslation } from 'react-i18next';
-
-import { debounce } from 'throttle-debounce';
-import { usePageRenameModal } from '~/stores/modal';
-import { withUnstatedContainers } from './UnstatedUtils';
-import { toastError } from '~/client/util/apiNotification';
-
-import AppContainer from '~/client/services/AppContainer';
-
-import { apiv3Get, apiv3Put } from '~/client/util/apiv3-client';
-
-import ApiErrorMessageList from './PageManagement/ApiErrorMessageList';
-import ComparePathsTable from './ComparePathsTable';
-import DuplicatedPathsTable from './DuplicatedPathsTable';
-
-
-const PageRenameModal = (props) => {
-  const {
-    t, appContainer,
-  } = props;
-
-  const { crowi } = appContainer.config;
-  const { data: renameModalData, close: closeRenameModal } = usePageRenameModal();
-
-  const { isOpened, page } = renameModalData;
-  const { pageId, revisionId, path } = page;
-
-  const [pageNameInput, setPageNameInput] = useState('');
-
-  const [errs, setErrs] = useState(null);
-
-  const [subordinatedPages, setSubordinatedPages] = useState([]);
-  const [existingPaths, setExistingPaths] = useState([]);
-  const [isRenameRecursively, SetIsRenameRecursively] = useState(true);
-  const [isRenameRedirect, SetIsRenameRedirect] = useState(false);
-  const [isRemainMetadata, SetIsRemainMetadata] = useState(false);
-  const [subordinatedError] = useState(null);
-  const [isRenameRecursivelyWithoutExistPath, setIsRenameRecursivelyWithoutExistPath] = useState(true);
-
-  function changeIsRenameRecursivelyHandler() {
-    SetIsRenameRecursively(!isRenameRecursively);
-  }
-
-  function changeIsRenameRecursivelyWithoutExistPathHandler() {
-    setIsRenameRecursivelyWithoutExistPath(!isRenameRecursivelyWithoutExistPath);
-  }
-
-  function changeIsRenameRedirectHandler() {
-    SetIsRenameRedirect(!isRenameRedirect);
-  }
-
-  function changeIsRemainMetadataHandler() {
-    SetIsRemainMetadata(!isRemainMetadata);
-  }
-
-  const updateSubordinatedList = useCallback(async() => {
-    try {
-      const res = await apiv3Get('/pages/subordinated-list', { path });
-      const { subordinatedPaths } = res.data;
-      setSubordinatedPages(subordinatedPaths);
-    }
-    catch (err) {
-      setErrs(err);
-      toastError(t('modal_rename.label.Fail to get subordinated pages'));
-    }
-  }, [path, t]);
-
-  useEffect(() => {
-    if (isOpened) {
-      updateSubordinatedList();
-      setPageNameInput(path);
-    }
-  }, [isOpened, path, updateSubordinatedList]);
-
-
-  const checkExistPaths = useCallback(async(newParentPath) => {
-    try {
-      const res = await apiv3Get('/page/exist-paths', { fromPath: path, toPath: newParentPath });
-      const { existPaths } = res.data;
-      setExistingPaths(existPaths);
-    }
-    catch (err) {
-      setErrs(err);
-      toastError(t('modal_rename.label.Fail to get exist path'));
-    }
-  }, [path, t]);
-
-  // eslint-disable-next-line react-hooks/exhaustive-deps
-  const checkExistPathsDebounce = useCallback(() => {
-    debounce(1000, checkExistPaths);
-  }, [checkExistPaths]);
-
-  useEffect(() => {
-    if (pageId != null && path != null && pageNameInput !== path) {
-      checkExistPathsDebounce(pageNameInput, subordinatedPages);
-    }
-  }, [pageNameInput, subordinatedPages, pageId, path, checkExistPathsDebounce]);
-
-  /**
-   * change pageNameInput
-   * @param {string} value
-   */
-  function inputChangeHandler(value) {
-    setErrs(null);
-    setPageNameInput(value);
-  }
-
-  async function rename() {
-    setErrs(null);
-
-    try {
-      const response = await apiv3Put('/pages/rename', {
-        revisionId,
-        pageId,
-        isRecursively: isRenameRecursively,
-        isRenameRedirect,
-        isRemainMetadata,
-        newPagePath: pageNameInput,
-        path,
-      });
-
-      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}`;
-    }
-    catch (err) {
-      setErrs(err);
-    }
-  }
-
-  return (
-    <Modal size="lg" isOpen={isOpened} toggle={closeRenameModal} autoFocus={false}>
-      <ModalHeader tag="h4" toggle={closeRenameModal} className="bg-primary text-light">
-        { t('modal_rename.label.Move/Rename page') }
-      </ModalHeader>
-      <ModalBody>
-        <div className="form-group">
-          <label>{ t('modal_rename.label.Current page name') }</label><br />
-          <code>{ path }</code>
-        </div>
-        <div className="form-group">
-          <label htmlFor="newPageName">{ t('modal_rename.label.New page name') }</label><br />
-          <div className="input-group">
-            <div className="input-group-prepend">
-              <span className="input-group-text">{crowi.url}</span>
-            </div>
-            <form className="flex-fill" onSubmit={(e) => { e.preventDefault(); rename() }}>
-              <input
-                type="text"
-                value={pageNameInput}
-                className="form-control"
-                onChange={e => inputChangeHandler(e.target.value)}
-                required
-                autoFocus
-              />
-            </form>
-          </div>
-        </div>
-        <div className="custom-control custom-checkbox custom-checkbox-warning">
-          <input
-            className="custom-control-input"
-            name="recursively"
-            id="cbRenameRecursively"
-            type="checkbox"
-            checked={isRenameRecursively}
-            onChange={changeIsRenameRecursivelyHandler}
-          />
-          <label className="custom-control-label" htmlFor="cbRenameRecursively">
-            { t('modal_rename.label.Recursively') }
-            <p className="form-text text-muted mt-0">{ t('modal_rename.help.recursive') }</p>
-          </label>
-          {existingPaths.length !== 0 && (
-            <div
-              className="custom-control custom-checkbox custom-checkbox-warning"
-              style={{ display: isRenameRecursively ? '' : 'none' }}
-            >
-              <input
-                className="custom-control-input"
-                name="withoutExistRecursively"
-                id="cbRenamewithoutExistRecursively"
-                type="checkbox"
-                checked={isRenameRecursivelyWithoutExistPath}
-                onChange={changeIsRenameRecursivelyWithoutExistPathHandler}
-              />
-              <label className="custom-control-label" htmlFor="cbRenamewithoutExistRecursively">
-                { t('modal_rename.label.Rename without exist path') }
-              </label>
-            </div>
-          )}
-          {isRenameRecursively && path != null && <ComparePathsTable path={path} subordinatedPages={subordinatedPages} newPagePath={pageNameInput} />}
-          {isRenameRecursively && existingPaths.length !== 0 && <DuplicatedPathsTable existingPaths={existingPaths} oldPagePath={pageNameInput} />}
-        </div>
-
-        <div className="custom-control custom-checkbox custom-checkbox-success">
-          <input
-            className="custom-control-input"
-            name="create_redirect"
-            id="cbRenameRedirect"
-            type="checkbox"
-            checked={isRenameRedirect}
-            onChange={changeIsRenameRedirectHandler}
-          />
-          <label className="custom-control-label" htmlFor="cbRenameRedirect">
-            { t('modal_rename.label.Redirect') }
-            <p className="form-text text-muted mt-0">{ t('modal_rename.help.redirect') }</p>
-          </label>
-        </div>
-
-        <div className="custom-control custom-checkbox custom-checkbox-primary">
-          <input
-            className="custom-control-input"
-            name="remain_metadata"
-            id="cbRemainMetadata"
-            type="checkbox"
-            checked={isRemainMetadata}
-            onChange={changeIsRemainMetadataHandler}
-          />
-          <label className="custom-control-label" htmlFor="cbRemainMetadata">
-            { t('modal_rename.label.Do not update metadata') }
-            <p className="form-text text-muted mt-0">{ t('modal_rename.help.metadata') }</p>
-          </label>
-        </div>
-        <div> {subordinatedError} </div>
-      </ModalBody>
-      <ModalFooter>
-        <ApiErrorMessageList errs={errs} targetPath={pageNameInput} />
-        <button
-          type="button"
-          className="btn btn-primary"
-          onClick={rename}
-          disabled={(isRenameRecursively && !isRenameRecursivelyWithoutExistPath && existingPaths.length !== 0)}
-        >Rename
-        </button>
-      </ModalFooter>
-    </Modal>
-  );
-};
-
-/**
- * Wrapper component for using unstated
- */
-const PageRenameModalWrapper = withUnstatedContainers(PageRenameModal, [AppContainer]);
-
-PageRenameModal.propTypes = {
-  t: PropTypes.func.isRequired, //  i18next
-  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
-};
-
-export default withTranslation()(PageRenameModalWrapper);

+ 305 - 0
packages/app/src/components/PageRenameModal.tsx

@@ -0,0 +1,305 @@
+import React, {
+  useState, useEffect, useCallback, useMemo,
+} from 'react';
+
+import {
+  Collapse, Modal, ModalHeader, ModalBody, ModalFooter,
+} from 'reactstrap';
+
+import { useTranslation } from 'react-i18next';
+
+import { debounce } from 'throttle-debounce';
+import { usePageRenameModal } from '~/stores/modal';
+import { toastError } from '~/client/util/apiNotification';
+
+import { apiv3Get, apiv3Put } from '~/client/util/apiv3-client';
+
+import ApiErrorMessageList from './PageManagement/ApiErrorMessageList';
+import DuplicatedPathsTable from './DuplicatedPathsTable';
+import { useSiteUrl } from '~/stores/context';
+import { isIPageInfoForEntity } from '~/interfaces/page';
+import { useSWRxPageInfo } from '~/stores/page';
+
+
+const isV5Compatible = (meta: unknown): boolean => {
+  return isIPageInfoForEntity(meta) ? meta.isV5Compatible : true;
+};
+
+
+const PageRenameModal = (): JSX.Element => {
+  const { t } = useTranslation();
+
+  const { data: siteUrl } = useSiteUrl();
+  const { data: renameModalData, close: closeRenameModal } = usePageRenameModal();
+
+  const isOpened = renameModalData?.isOpened ?? false;
+  const page = renameModalData?.page;
+
+  const shouldFetch = isOpened && page != null && !isIPageInfoForEntity(page.meta);
+  const { data: pageInfo } = useSWRxPageInfo(shouldFetch ? page?.data._id : null);
+
+  if (page != null && pageInfo != null) {
+    page.meta = pageInfo;
+  }
+
+  const [pageNameInput, setPageNameInput] = useState('');
+
+  const [errs, setErrs] = useState(null);
+
+  const [subordinatedPages, setSubordinatedPages] = useState([]);
+  const [existingPaths, setExistingPaths] = useState<string[]>([]);
+  const [isRenameRecursively, setIsRenameRecursively] = useState(true);
+  const [isRenameRedirect, setIsRenameRedirect] = useState(false);
+  const [isRemainMetadata, setIsRemainMetadata] = useState(false);
+  const [expandOtherOptions, setExpandOtherOptions] = useState(false);
+  const [subordinatedError] = useState(null);
+
+  const updateSubordinatedList = useCallback(async() => {
+    if (page == null) {
+      return;
+    }
+
+    const { path } = page.data;
+    try {
+      const res = await apiv3Get('/pages/subordinated-list', { path });
+      setSubordinatedPages(res.data.subordinatedPages);
+    }
+    catch (err) {
+      setErrs(err);
+      toastError(t('modal_rename.label.Failed to get subordinated pages'));
+    }
+  }, [page, t]);
+
+  useEffect(() => {
+    if (page != null && isOpened) {
+      updateSubordinatedList();
+      setPageNameInput(page.data.path);
+    }
+  }, [isOpened, page, updateSubordinatedList]);
+
+  const rename = useCallback(async() => {
+    if (page == null) {
+      return;
+    }
+
+    const _isV5Compatible = isV5Compatible(page.meta);
+
+    setErrs(null);
+
+    const { _id, path, revision } = page.data;
+    try {
+      const response = await apiv3Put('/pages/rename', {
+        pageId: _id,
+        revisionId: revision,
+        isRecursively: !_isV5Compatible ? isRenameRecursively : undefined,
+        isRenameRedirect,
+        updateMetadata: !isRemainMetadata,
+        newPagePath: pageNameInput,
+        path,
+      });
+
+      const { page } = response.data;
+      const url = new URL(page.path, 'https://dummy');
+      if (isRenameRedirect) {
+        url.searchParams.append('withRedirect', 'true');
+      }
+
+      const onRenamed = renameModalData?.opts?.onRenamed;
+      if (onRenamed != null) {
+        onRenamed(path);
+      }
+      closeRenameModal();
+    }
+    catch (err) {
+      setErrs(err);
+    }
+  }, [closeRenameModal, isRemainMetadata, isRenameRecursively, isRenameRedirect, page, pageNameInput, renameModalData?.opts?.onRenamed]);
+
+  const checkExistPaths = useCallback(async(fromPath, toPath) => {
+    if (page == null) {
+      return;
+    }
+
+    try {
+      const res = await apiv3Get<{ existPaths: string[] }>('/page/exist-paths', { fromPath, toPath });
+      const { existPaths } = res.data;
+      setExistingPaths(existPaths);
+    }
+    catch (err) {
+      setErrs(err);
+      toastError(t('modal_rename.label.Failed to get exist path'));
+    }
+  }, [page, t]);
+
+  const checkExistPathsDebounce = useMemo(() => {
+    return debounce(1000, checkExistPaths);
+  }, [checkExistPaths]);
+
+  useEffect(() => {
+    if (page != null && pageNameInput !== page.data.path) {
+      checkExistPathsDebounce(page.data.path, pageNameInput);
+    }
+  }, [pageNameInput, subordinatedPages, checkExistPathsDebounce, page]);
+
+  /**
+   * change pageNameInput
+   * @param {string} value
+   */
+  function inputChangeHandler(value) {
+    setErrs(null);
+    setPageNameInput(value);
+  }
+
+  useEffect(() => {
+    if (isOpened) {
+      return;
+    }
+
+    // reset states after the modal closed
+    setTimeout(() => {
+      setPageNameInput('');
+      setErrs(null);
+      setSubordinatedPages([]);
+      setExistingPaths([]);
+      setIsRenameRecursively(true);
+      setIsRenameRedirect(false);
+      setIsRemainMetadata(false);
+      setExpandOtherOptions(false);
+    }, 1000);
+
+  }, [isOpened]);
+
+  if (page == null) {
+    return <></>;
+  }
+
+  const { path } = page.data;
+  const isTargetPageDuplicate = existingPaths.includes(pageNameInput);
+
+  const submitButtonDisabled = isV5Compatible(page.meta)
+    ? existingPaths.length !== 0 // v5 data
+    : !isRenameRecursively; // v4 data
+
+  return (
+    <Modal size="lg" isOpen={isOpened} toggle={closeRenameModal} autoFocus={false}>
+      <ModalHeader tag="h4" toggle={closeRenameModal} className="bg-primary text-light">
+        { t('modal_rename.label.Move/Rename page') }
+      </ModalHeader>
+      <ModalBody>
+        <div className="form-group">
+          <label>{ t('modal_rename.label.Current page name') }</label><br />
+          <code>{ path }</code>
+        </div>
+        <div className="form-group">
+          <label htmlFor="newPageName">{ t('modal_rename.label.New page name') }</label><br />
+          <div className="input-group">
+            <div className="input-group-prepend">
+              <span className="input-group-text">{siteUrl}</span>
+            </div>
+            <form className="flex-fill" onSubmit={(e) => { e.preventDefault(); rename() }}>
+              <input
+                type="text"
+                value={pageNameInput}
+                className="form-control"
+                onChange={e => inputChangeHandler(e.target.value)}
+                required
+                autoFocus
+              />
+            </form>
+          </div>
+        </div>
+
+        { isTargetPageDuplicate && (
+          <p className="text-danger">Error: Target path is duplicated.</p>
+        ) }
+
+        { !isV5Compatible(page.meta) && (
+          <>
+            <div className="custom-control custom-radio custom-radio-warning">
+              <input
+                className="custom-control-input"
+                name="recursively"
+                id="cbRenameThisPageOnly"
+                type="radio"
+                checked={!isRenameRecursively}
+                onChange={() => setIsRenameRecursively(!isRenameRecursively)}
+              />
+              <label className="custom-control-label" htmlFor="cbRenameThisPageOnly">
+                { t('modal_rename.label.Rename this page only') }
+              </label>
+            </div>
+            <div className="custom-control custom-radio custom-radio-warning mt-1">
+              <input
+                className="custom-control-input"
+                name="withoutExistRecursively"
+                id="cbForceRenameRecursively"
+                type="radio"
+                checked={isRenameRecursively}
+                onChange={() => setIsRenameRecursively(!isRenameRecursively)}
+              />
+              <label className="custom-control-label" htmlFor="cbForceRenameRecursively">
+                { t('modal_rename.label.Force rename all child pages') }
+                <p className="form-text text-muted mt-0">{ t('modal_rename.help.recursive') }</p>
+              </label>
+              {isRenameRecursively && existingPaths.length !== 0 && (
+                <DuplicatedPathsTable existingPaths={existingPaths} fromPath={path} toPath={pageNameInput} />
+              ) }
+            </div>
+          </>
+        ) }
+
+        <p className="mt-2">
+          <button type="button" className="btn btn-link mt-2 p-0" aria-expanded="false" onClick={() => setExpandOtherOptions(!expandOtherOptions)}>
+            <i className={`fa fa-fw fa-arrow-right ${expandOtherOptions ? 'fa-rotate-90' : ''}`}></i>
+            { t('modal_rename.label.Other options') }
+          </button>
+        </p>
+        <Collapse isOpen={expandOtherOptions}>
+          <div className="custom-control custom-checkbox custom-checkbox-success">
+            <input
+              className="custom-control-input"
+              name="create_redirect"
+              id="cbRenameRedirect"
+              type="checkbox"
+              checked={isRenameRedirect}
+              onChange={() => setIsRenameRedirect(!isRenameRedirect)}
+            />
+            <label className="custom-control-label" htmlFor="cbRenameRedirect">
+              { t('modal_rename.label.Redirect') }
+              <p className="form-text text-muted mt-0">{ t('modal_rename.help.redirect') }</p>
+            </label>
+          </div>
+
+          <div className="custom-control custom-checkbox custom-checkbox-primary">
+            <input
+              className="custom-control-input"
+              name="remain_metadata"
+              id="cbRemainMetadata"
+              type="checkbox"
+              checked={isRemainMetadata}
+              onChange={() => setIsRemainMetadata(!isRemainMetadata)}
+            />
+            <label className="custom-control-label" htmlFor="cbRemainMetadata">
+              { t('modal_rename.label.Do not update metadata') }
+              <p className="form-text text-muted mt-0">{ t('modal_rename.help.metadata') }</p>
+            </label>
+          </div>
+          <div> {subordinatedError} </div>
+        </Collapse>
+
+      </ModalBody>
+      <ModalFooter>
+        <ApiErrorMessageList errs={errs} targetPath={pageNameInput} />
+        <button
+          type="button"
+          className="btn btn-primary"
+          onClick={rename}
+          disabled={submitButtonDisabled}
+        >Rename
+        </button>
+      </ModalFooter>
+    </Modal>
+  );
+};
+
+export default PageRenameModal;

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

+ 12 - 19
packages/app/src/components/PutbackPageModal.jsx

@@ -1,24 +1,23 @@
 import React, { useState } from 'react';
 import React, { useState } from 'react';
-import PropTypes from 'prop-types';
 
 
 import {
 import {
   Modal, ModalHeader, ModalBody, ModalFooter,
   Modal, ModalHeader, ModalBody, ModalFooter,
 } from 'reactstrap';
 } from 'reactstrap';
 
 
-import { withTranslation } from 'react-i18next';
+import { useTranslation } from 'react-i18next';
 
 
 import { usePutBackPageModal } from '~/stores/modal';
 import { usePutBackPageModal } from '~/stores/modal';
 import { apiPost } from '~/client/util/apiv1-client';
 import { apiPost } from '~/client/util/apiv1-client';
 
 
 import ApiErrorMessageList from './PageManagement/ApiErrorMessageList';
 import ApiErrorMessageList from './PageManagement/ApiErrorMessageList';
 
 
-const PutBackPageModal = (props) => {
-  const {
-    t,
-  } = props;
+const PutBackPageModal = () => {
+  const { t } = useTranslation();
 
 
   const { data: pageDataToRevert, close: closePutBackPageModal } = usePutBackPageModal();
   const { data: pageDataToRevert, close: closePutBackPageModal } = usePutBackPageModal();
-  const { isOpened, pageId, path } = pageDataToRevert;
+  const { isOpened, page } = pageDataToRevert;
+  const { pageId, path } = page;
+  const onPutBacked = pageDataToRevert.opts?.onPutBacked;
 
 
   const [errs, setErrs] = useState(null);
   const [errs, setErrs] = useState(null);
 
 
@@ -28,7 +27,7 @@ const PutBackPageModal = (props) => {
     setIsPutbackRecursively(!isPutbackRecursively);
     setIsPutbackRecursively(!isPutbackRecursively);
   }
   }
 
 
-  async function putbackPage() {
+  async function putbackPageButtonHandler() {
     setErrs(null);
     setErrs(null);
 
 
     try {
     try {
@@ -41,17 +40,16 @@ const PutBackPageModal = (props) => {
         recursively,
         recursively,
       });
       });
 
 
-      const putbackPagePath = response.page.path;
-      window.location.href = encodeURI(putbackPagePath);
+      if (onPutBacked != null) {
+        onPutBacked(response.page.path);
+      }
+      closePutBackPageModal();
     }
     }
     catch (err) {
     catch (err) {
       setErrs(err);
       setErrs(err);
     }
     }
   }
   }
 
 
-  async function putbackPageButtonHandler() {
-    putbackPage();
-  }
 
 
   return (
   return (
     <Modal isOpen={isOpened} toggle={closePutBackPageModal} className="grw-create-page">
     <Modal isOpen={isOpened} toggle={closePutBackPageModal} className="grw-create-page">
@@ -90,9 +88,4 @@ const PutBackPageModal = (props) => {
 
 
 };
 };
 
 
-PutBackPageModal.propTypes = {
-  t: PropTypes.func.isRequired, //  i18next
-};
-
-
-export default withTranslation()(PutBackPageModal);
+export default PutBackPageModal;

+ 17 - 23
packages/app/src/components/SearchPage.tsx

@@ -58,10 +58,11 @@ const SearchResultListHead = React.memo((props: SearchResultListHeadProps): JSX.
     <div className="form-inline d-flex align-items-center justify-content-between">
     <div className="form-inline d-flex align-items-center justify-content-between">
       <div className="text-nowrap">
       <div className="text-nowrap">
         {t('search_result.result_meta')}
         {t('search_result.result_meta')}
-        <span className="search-result-keyword">{`${searchingKeyword}`}</span>
+        <span className="search-result-keyword ml-2">{`${searchingKeyword}`}</span>
         <span className="ml-3">{`${leftNum}-${rightNum}`} / {total}</span>
         <span className="ml-3">{`${leftNum}-${rightNum}`} / {total}</span>
         { took != null && (
         { took != null && (
-          <span className="ml-3 text-muted">({took}ms)</span>
+          // blackout 70px rectangle in VRT
+          <span data-hide-in-vrt className="ml-3 text-muted d-inline-block" style={{ minWidth: '70px' }}>({took}ms)</span>
         ) }
         ) }
       </div>
       </div>
       <div className="input-group flex-nowrap search-result-select-group ml-auto d-md-flex d-none">
       <div className="input-group flex-nowrap search-result-select-group ml-auto d-md-flex d-none">
@@ -109,10 +110,9 @@ export const SearchPage = (props: Props): JSX.Element => {
   const initQ = (Array.isArray(parsedQueries) ? parsedQueries.join(' ') : parsedQueries) ?? '';
   const initQ = (Array.isArray(parsedQueries) ? parsedQueries.join(' ') : parsedQueries) ?? '';
 
 
   const [keyword, setKeyword] = useState<string>(initQ);
   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 [configurationsByControl, setConfigurationsByControl] = useState<Partial<ISearchConfigurations>>({});
-  const [configurationsByPagination, setConfigurationsByPagination] = useState<Partial<ISearchConfigurations>>({
-    limit: INITIAL_PAGIONG_SIZE,
-  });
 
 
   const selectAllControlRef = useRef<ISelectableAndIndeterminatable|null>(null);
   const selectAllControlRef = useRef<ISelectableAndIndeterminatable|null>(null);
   const searchPageBaseRef = useRef<ISelectableAll & IReturnSelectedPageIds|null>(null);
   const searchPageBaseRef = useRef<ISelectableAll & IReturnSelectedPageIds|null>(null);
@@ -120,13 +120,14 @@ export const SearchPage = (props: Props): JSX.Element => {
   const { data: isSearchServiceReachable } = useIsSearchServiceReachable();
   const { data: isSearchServiceReachable } = useIsSearchServiceReachable();
 
 
   const { data, conditions, mutate } = useSWRxFullTextSearch(keyword, {
   const { data, conditions, mutate } = useSWRxFullTextSearch(keyword, {
-    limit: INITIAL_PAGIONG_SIZE,
     ...configurationsByControl,
     ...configurationsByControl,
-    ...configurationsByPagination,
+    offset,
+    limit,
   });
   });
 
 
   const searchInvokedHandler = useCallback((_keyword: string, newConfigurations: Partial<ISearchConfigurations>) => {
   const searchInvokedHandler = useCallback((_keyword: string, newConfigurations: Partial<ISearchConfigurations>) => {
     setKeyword(_keyword);
     setKeyword(_keyword);
+    setOffset(0);
     setConfigurationsByControl(newConfigurations);
     setConfigurationsByControl(newConfigurations);
   }, []);
   }, []);
 
 
@@ -164,21 +165,15 @@ export const SearchPage = (props: Props): JSX.Element => {
   }, []);
   }, []);
 
 
   const pagingSizeChangedHandler = useCallback((pagingSize: number) => {
   const pagingSizeChangedHandler = useCallback((pagingSize: number) => {
-    setConfigurationsByPagination({
-      ...configurationsByPagination,
-      limit: pagingSize,
-    });
+    setOffset(0);
+    setLimit(pagingSize);
     mutate();
     mutate();
-  }, [configurationsByPagination, mutate]);
+  }, [mutate]);
 
 
   const pagingNumberChangedHandler = useCallback((activePage: number) => {
   const pagingNumberChangedHandler = useCallback((activePage: number) => {
-    const currentLimit = configurationsByPagination.limit ?? INITIAL_PAGIONG_SIZE;
-    setConfigurationsByPagination({
-      ...configurationsByPagination,
-      offset: (activePage - 1) * currentLimit,
-    });
+    setOffset((activePage - 1) * limit);
     mutate();
     mutate();
-  }, [configurationsByPagination, mutate]);
+  }, [limit, mutate]);
 
 
   const initialSearchConditions: Partial<ISearchConditions> = useMemo(() => {
   const initialSearchConditions: Partial<ISearchConditions> = useMemo(() => {
     return {
     return {
@@ -188,7 +183,7 @@ export const SearchPage = (props: Props): JSX.Element => {
   }, [initQ]);
   }, [initQ]);
 
 
   // for bulk deletion
   // for bulk deletion
-  const deleteAllButtonClickedHandler = usePageDeleteModalForBulkDeletion(data, searchPageBaseRef, () => mutate);
+  const deleteAllButtonClickedHandler = usePageDeleteModalForBulkDeletion(data, searchPageBaseRef, () => mutate());
 
 
   // push state
   // push state
   useEffect(() => {
   useEffect(() => {
@@ -198,8 +193,6 @@ export const SearchPage = (props: Props): JSX.Element => {
   }, [keyword]);
   }, [keyword]);
   const hitsCount = data?.meta.hitsCount;
   const hitsCount = data?.meta.hitsCount;
 
 
-  const { offset, limit } = conditions;
-
   const deleteAllControl = useMemo(() => {
   const deleteAllControl = useMemo(() => {
     const isDisabled = hitsCount === 0;
     const isDisabled = hitsCount === 0;
 
 
@@ -265,17 +258,18 @@ export const SearchPage = (props: Props): JSX.Element => {
       <PaginationWrapper
       <PaginationWrapper
         activePage={Math.floor(offset / limit) + 1}
         activePage={Math.floor(offset / limit) + 1}
         totalItemsCount={total}
         totalItemsCount={total}
-        pagingLimit={configurationsByPagination?.limit}
+        pagingLimit={limit}
         changePage={pagingNumberChangedHandler}
         changePage={pagingNumberChangedHandler}
       />
       />
     );
     );
-  }, [conditions, configurationsByPagination?.limit, data, pagingNumberChangedHandler]);
+  }, [conditions, data, pagingNumberChangedHandler]);
 
 
   return (
   return (
     <SearchPageBase
     <SearchPageBase
       ref={searchPageBaseRef}
       ref={searchPageBaseRef}
       appContainer={appContainer}
       appContainer={appContainer}
       pages={data?.data}
       pages={data?.data}
+      searchingKeyword={keyword}
       onSelectedPagesByCheckboxesChanged={selectedPagesByCheckboxesChangedHandler}
       onSelectedPagesByCheckboxesChanged={selectedPagesByCheckboxesChangedHandler}
       // Components
       // Components
       searchControl={searchControl}
       searchControl={searchControl}

+ 1 - 3
packages/app/src/components/SearchPage/OperateAllControl.tsx

@@ -63,9 +63,7 @@ const OperateAllControlSubstance: ForwardRefRenderFunction<ISelectableAndIndeter
         disabled={isCheckboxDisabled}
         disabled={isCheckboxDisabled}
         onChange={checkboxChangedHandler}
         onChange={checkboxChangedHandler}
       />
       />
-      <span className="ml-2">
-        {children}
-      </span>
+      {children}
     </div>
     </div>
   );
   );
 
 

+ 25 - 21
packages/app/src/components/SearchPage/SearchControl.tsx

@@ -83,7 +83,7 @@ const SearchControl: FC <Props> = React.memo((props: Props) => {
       </div>
       </div>
       {/* TODO: replace the following elements deleteAll button , relevance button and include specificPath button component */}
       {/* TODO: replace the following elements deleteAll button , relevance button and include specificPath button component */}
       <div className="search-control d-flex align-items-center py-md-2 py-3 px-md-4 px-3 border-bottom border-gray">
       <div className="search-control d-flex align-items-center py-md-2 py-3 px-md-4 px-3 border-bottom border-gray">
-        <div className="d-flex pl-md-2">
+        <div className="d-flex">
           {deleteAllControl}
           {deleteAllControl}
         </div>
         </div>
         {/* sort option: show when screen is smaller than lg */}
         {/* sort option: show when screen is smaller than lg */}
@@ -105,30 +105,34 @@ const SearchControl: FC <Props> = React.memo((props: Props) => {
           </button>
           </button>
         </div>
         </div>
         <div className="d-none d-lg-flex align-items-center ml-auto search-control-include-options">
         <div className="d-none d-lg-flex align-items-center ml-auto search-control-include-options">
-          <div className="card mr-3 mb-0">
-            <div className="card-body">
-              <label className="search-include-label mb-0 d-flex align-items-center text-secondary with-no-font-weight" htmlFor="flexCheckDefault">
-                <input
-                  className="mr-2"
-                  type="checkbox"
-                  id="flexCheckDefault"
-                  defaultChecked={includeUserPages}
-                  onChange={e => setIncludeUserPages(e.target.checked)}
-                />
+          <div className="border rounded px-2 py-1 mr-3">
+            <div className="custom-control custom-checkbox custom-checkbox-primary">
+              <input
+                className="custom-control-input mr-2"
+                type="checkbox"
+                id="flexCheckDefault"
+                defaultChecked={includeUserPages}
+                onChange={e => setIncludeUserPages(e.target.checked)}
+              />
+              <label className="custom-control-label mb-0 d-flex align-items-center text-secondary with-no-font-weight" htmlFor="flexCheckDefault">
                 {t('Include Subordinated Target Page', { target: '/user' })}
                 {t('Include Subordinated Target Page', { target: '/user' })}
               </label>
               </label>
             </div>
             </div>
           </div>
           </div>
-          <div className="card mb-0">
-            <div className="card-body">
-              <label className="search-include-label mb-0 d-flex align-items-center text-secondary with-no-font-weight" htmlFor="flexCheckChecked">
-                <input
-                  className="mr-2"
-                  type="checkbox"
-                  id="flexCheckChecked"
-                  defaultChecked={includeTrashPages}
-                  onChange={e => setIncludeTrashPages(e.target.checked)}
-                />
+          <div className="border rounded px-2 py-1">
+            <div className="custom-control custom-checkbox custom-checkbox-primary">
+              <input
+                className="custom-control-input mr-2"
+                type="checkbox"
+                id="flexCheckChecked"
+                checked={includeTrashPages}
+                onChange={e => setIncludeTrashPages(e.target.checked)}
+              />
+              <label
+                className="custom-control-label
+              d-flex align-items-center text-secondary with-no-font-weight"
+                htmlFor="flexCheckChecked"
+              >
                 {t('Include Subordinated Target Page', { target: '/trash' })}
                 {t('Include Subordinated Target Page', { target: '/trash' })}
               </label>
               </label>
             </div>
             </div>

+ 54 - 10
packages/app/src/components/SearchPage/SearchResultContent.tsx

@@ -5,10 +5,15 @@ import { useTranslation } from 'react-i18next';
 
 
 import { DropdownItem } from 'reactstrap';
 import { DropdownItem } from 'reactstrap';
 
 
-import { IPageWithMeta } from '~/interfaces/page';
+import { IPageToDeleteWithMeta, IPageToRenameWithMeta, IPageWithMeta } from '~/interfaces/page';
 import { IPageSearchMeta } from '~/interfaces/search';
 import { IPageSearchMeta } from '~/interfaces/search';
+import { OnDuplicatedFunction, OnRenamedFunction, 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 { exportAsMarkdown } from '~/client/services/page-operation';
+import { toastSuccess } from '~/client/util/apiNotification';
 
 
 import RevisionLoader from '../Page/RevisionLoader';
 import RevisionLoader from '../Page/RevisionLoader';
 import AppContainer from '../../client/services/AppContainer';
 import AppContainer from '../../client/services/AppContainer';
@@ -74,6 +79,11 @@ const generateObserverCallback = (doScroll: ()=>void) => {
 export const SearchResultContent: FC<Props> = (props: Props) => {
 export const SearchResultContent: FC<Props> = (props: Props) => {
   const scrollElementRef = useRef(null);
   const scrollElementRef = useRef(null);
 
 
+  // for mutation
+  const { advance: advancePt } = usePageTreeTermManager();
+  const { advance: advanceFts } = useFullTextSearchTermManager();
+  const { advance: advanceDpl } = useDescendantsPageListForCurrentPathTermManager();
+
   // ***************************  Auto Scroll  ***************************
   // ***************************  Auto Scroll  ***************************
   useEffect(() => {
   useEffect(() => {
     const scrollElement = scrollElementRef.current as HTMLElement | null;
     const scrollElement = scrollElementRef.current as HTMLElement | null;
@@ -99,7 +109,9 @@ export const SearchResultContent: FC<Props> = (props: Props) => {
     forceHideMenuItems,
     forceHideMenuItems,
   } = props;
   } = props;
 
 
-  const page = pageWithMeta?.pageData;
+  const { t } = useTranslation();
+
+  const page = pageWithMeta?.data;
   const { open: openDuplicateModal } = usePageDuplicateModal();
   const { open: openDuplicateModal } = usePageDuplicateModal();
   const { open: openRenameModal } = usePageRenameModal();
   const { open: openRenameModal } = usePageRenameModal();
   const { open: openDeleteModal } = usePageDeleteModal();
   const { open: openDeleteModal } = usePageDeleteModal();
@@ -108,16 +120,48 @@ export const SearchResultContent: FC<Props> = (props: Props) => {
 
 
 
 
   const duplicateItemClickedHandler = useCallback(async(pageToDuplicate) => {
   const duplicateItemClickedHandler = useCallback(async(pageToDuplicate) => {
-    openDuplicateModal(pageToDuplicate);
-  }, [openDuplicateModal]);
+    // eslint-disable-next-line @typescript-eslint/no-unused-vars
+    const duplicatedHandler: OnDuplicatedFunction = (fromPath, toPath) => {
+      toastSuccess(t('duplicated_pages', { fromPath }));
 
 
-  const renameItemClickedHandler = useCallback(async(pageToRename) => {
-    openRenameModal(pageToRename);
-  }, [openRenameModal]);
+      advancePt();
+      advanceFts();
+      advanceDpl();
+    };
+    openDuplicateModal(pageToDuplicate, { onDuplicated: duplicatedHandler });
+  }, [advanceDpl, advanceFts, advancePt, openDuplicateModal, t]);
+
+  const renameItemClickedHandler = useCallback((pageToRename: IPageToRenameWithMeta) => {
+    const renamedHandler: OnRenamedFunction = (path) => {
+      toastSuccess(t('renamed_pages', { path }));
 
 
-  const deleteItemClickedHandler = useCallback((pageToDelete) => {
-    openDeleteModal([pageToDelete]);
-  }, [openDeleteModal]);
+      advancePt();
+      advanceFts();
+      advanceDpl();
+    };
+    openRenameModal(pageToRename, { onRenamed: renamedHandler });
+  }, [advanceDpl, advanceFts, advancePt, openRenameModal, t]);
+
+  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(() => {
   const ControlComponents = useCallback(() => {
     if (page == null) {
     if (page == null) {

+ 58 - 17
packages/app/src/components/SearchPage/SearchResultList.tsx

@@ -2,9 +2,14 @@ import React, {
   forwardRef,
   forwardRef,
   ForwardRefRenderFunction, useCallback, useImperativeHandle, useRef,
   ForwardRefRenderFunction, useCallback, useImperativeHandle, useRef,
 } from 'react';
 } from 'react';
+import { useTranslation } from 'react-i18next';
 import { ISelectable, ISelectableAll } from '~/client/interfaces/selectable-all';
 import { ISelectable, ISelectableAll } from '~/client/interfaces/selectable-all';
-import { IPageWithMeta, isIPageInfoForListing } from '~/interfaces/page';
+import { toastSuccess } from '~/client/util/apiNotification';
+import {
+  IPageInfoForListing, IPageWithMeta, isIPageInfoForListing,
+} from '~/interfaces/page';
 import { IPageSearchMeta } from '~/interfaces/search';
 import { IPageSearchMeta } from '~/interfaces/search';
+import { OnDuplicatedFunction, OnRenamedFunction, OnDeletedFunction } from '~/interfaces/ui';
 import { useIsGuestUser } from '~/stores/context';
 import { useIsGuestUser } from '~/stores/context';
 import { useSWRxPageInfoForList } from '~/stores/page';
 import { useSWRxPageInfoForList } from '~/stores/page';
 import { usePageTreeTermManager } from '~/stores/page-listing';
 import { usePageTreeTermManager } from '~/stores/page-listing';
@@ -29,12 +34,14 @@ const SearchResultListSubstance: ForwardRefRenderFunction<ISelectableAll, Props>
     onPageSelected,
     onPageSelected,
   } = props;
   } = props;
 
 
+  const { t } = useTranslation();
+
   const pageIdsWithNoSnippet = pages
   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: isGuestUser } = useIsGuestUser();
-  const { data: idToPageInfo } = useSWRxPageInfoForList(pageIdsWithNoSnippet);
+  const { data: idToPageInfo } = useSWRxPageInfoForList(pageIdsWithNoSnippet, true, true);
 
 
   // for mutation
   // for mutation
   const { advance: advancePt } = usePageTreeTermManager();
   const { advance: advancePt } = usePageTreeTermManager();
@@ -60,16 +67,16 @@ const SearchResultListSubstance: ForwardRefRenderFunction<ISelectableAll, Props>
 
 
   const clickItemHandler = useCallback((pageId: string) => {
   const clickItemHandler = useCallback((pageId: string) => {
     if (onPageSelected != null) {
     if (onPageSelected != null) {
-      const selectedPage = pages.find(page => page.pageData._id === pageId);
+      const selectedPage = pages.find(page => page.data._id === pageId);
       onPageSelected(selectedPage);
       onPageSelected(selectedPage);
     }
     }
   }, [onPageSelected, pages]);
   }, [onPageSelected, pages]);
 
 
-  let injectedPage;
+  let injectedPages: (IPageWithMeta<IPageSearchMeta> | IPageWithMeta<IPageInfoForListing & IPageSearchMeta>)[] | undefined;
   // inject data to list
   // inject data to list
   if (idToPageInfo != null) {
   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)) {
       if (!isIPageInfoForListing(pageInfo)) {
         // return as is
         // return as is
@@ -77,30 +84,64 @@ const SearchResultListSubstance: ForwardRefRenderFunction<ISelectableAll, Props>
       }
       }
 
 
       return {
       return {
-        pageData: page.pageData,
-        pageMeta: {
-          ...page.pageMeta,
-          revisionShortBody: pageInfo.revisionShortBody,
+        data: page.data,
+        meta: {
+          ...page.meta,
+          ...pageInfo,
         },
         },
-      };
+      } as IPageWithMeta<IPageInfoForListing & IPageSearchMeta>;
     });
     });
   }
   }
 
 
+  // eslint-disable-next-line @typescript-eslint/no-unused-vars
+  const duplicatedHandler : OnDuplicatedFunction = (fromPath, toPath) => {
+    toastSuccess(t('duplicated_pages', { fromPath }));
+
+    advancePt();
+    advanceFts();
+  };
+
+  const renamedHandler: OnRenamedFunction = (path) => {
+    toastSuccess(t('renamed_pages', { path }));
+
+    advancePt();
+    advanceFts();
+  };
+  const deletedHandler: OnDeletedFunction = (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();
+  };
+
+
   return (
   return (
     <ul data-testid="search-result-list" className="page-list-ul list-group list-group-flush">
     <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 (
         return (
           <PageListItemL
           <PageListItemL
-            key={page.pageData._id}
+            key={page.data._id}
             // eslint-disable-next-line no-return-assign
             // eslint-disable-next-line no-return-assign
             ref={c => itemsRef.current[i] = c}
             ref={c => itemsRef.current[i] = c}
             page={page}
             page={page}
             isEnableActions={!isGuestUser}
             isEnableActions={!isGuestUser}
-            isSelected={page.pageData._id === selectedPageId}
+            isSelected={page.data._id === selectedPageId}
             forceHideMenuItems={forceHideMenuItems}
             forceHideMenuItems={forceHideMenuItems}
             onClickItem={clickItemHandler}
             onClickItem={clickItemHandler}
             onCheckboxChanged={props.onCheckboxChanged}
             onCheckboxChanged={props.onCheckboxChanged}
-            onPageDeleted={() => { advancePt(); advanceFts() }}
+            onPageDuplicated={duplicatedHandler}
+            onPageRenamed={renamedHandler}
+            onPageDeleted={deletedHandler}
           />
           />
         );
         );
       })}
       })}

+ 2 - 2
packages/app/src/components/SearchPage/SortControl.tsx

@@ -38,10 +38,10 @@ const SortControl: FC <Props> = (props: Props) => {
         <div className="border rounded-right">
         <div className="border rounded-right">
           <button
           <button
             type="button"
             type="button"
-            className="btn dropdown-toggle search-sort-option-btn"
+            className="btn dropdown-toggle search-sort-option-btn py-1"
             data-toggle="dropdown"
             data-toggle="dropdown"
           >
           >
-            <span className="mr-4 text-secondary">{t(`search_result.sort_axis.${sort}`)}</span>
+            <span className="mr-2 text-secondary">{t(`search_result.sort_axis.${sort}`)}</span>
           </button>
           </button>
           <div className="dropdown-menu dropdown-menu-right">
           <div className="dropdown-menu dropdown-menu-right">
             {Object.values(SORT_AXIS).map((sortAxis) => {
             {Object.values(SORT_AXIS).map((sortAxis) => {

+ 15 - 19
packages/app/src/components/SearchPage2/SearchPageBase.tsx

@@ -1,5 +1,5 @@
 import React, {
 import React, {
-  forwardRef, ForwardRefRenderFunction, useCallback, useEffect, useImperativeHandle, useRef, useState,
+  forwardRef, ForwardRefRenderFunction, useEffect, useImperativeHandle, useRef, useState,
 } from 'react';
 } from 'react';
 import { useTranslation } from 'react-i18next';
 import { useTranslation } from 'react-i18next';
 import { ISelectableAll } from '~/client/interfaces/selectable-all';
 import { ISelectableAll } from '~/client/interfaces/selectable-all';
@@ -9,7 +9,7 @@ import { IPageWithMeta } from '~/interfaces/page';
 import { IFormattedSearchResult, IPageSearchMeta } from '~/interfaces/search';
 import { IFormattedSearchResult, IPageSearchMeta } from '~/interfaces/search';
 import { OnDeletedFunction } from '~/interfaces/ui';
 import { OnDeletedFunction } from '~/interfaces/ui';
 import { useIsGuestUser, useIsSearchServiceConfigured, useIsSearchServiceReachable } from '~/stores/context';
 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 { usePageTreeTermManager } from '~/stores/page-listing';
 import { ForceHideMenuItems } from '../Common/Dropdown/PageItemControl';
 import { ForceHideMenuItems } from '../Common/Dropdown/PageItemControl';
 
 
@@ -17,6 +17,10 @@ import { SearchResultContent } from '../SearchPage/SearchResultContent';
 import { SearchResultList } from '../SearchPage/SearchResultList';
 import { SearchResultList } from '../SearchPage/SearchResultList';
 
 
 
 
+// https://regex101.com/r/brrkBu/1
+const highlightKeywordsSplitter = new RegExp('"[^"]+"|[^\u{20}\u{3000}]+', 'ug');
+
+
 export interface IReturnSelectedPageIds {
 export interface IReturnSelectedPageIds {
   getSelectedPageIds?: () => Set<string>,
   getSelectedPageIds?: () => Set<string>,
 }
 }
@@ -26,6 +30,7 @@ type Props = {
   appContainer: AppContainer,
   appContainer: AppContainer,
 
 
   pages?: IPageWithMeta<IPageSearchMeta>[],
   pages?: IPageWithMeta<IPageSearchMeta>[],
+  searchingKeyword?: string,
 
 
   forceHideMenuItems?: ForceHideMenuItems,
   forceHideMenuItems?: ForceHideMenuItems,
 
 
@@ -40,6 +45,7 @@ const SearchPageBaseSubstance: ForwardRefRenderFunction<ISelectableAll & IReturn
   const {
   const {
     appContainer,
     appContainer,
     pages,
     pages,
+    searchingKeyword,
     forceHideMenuItems,
     forceHideMenuItems,
     onSelectedPagesByCheckboxesChanged,
     onSelectedPagesByCheckboxesChanged,
     searchControl, searchResultListHead, searchPager,
     searchControl, searchResultListHead, searchPager,
@@ -51,10 +57,6 @@ const SearchPageBaseSubstance: ForwardRefRenderFunction<ISelectableAll & IReturn
   const { data: isSearchServiceConfigured } = useIsSearchServiceConfigured();
   const { data: isSearchServiceConfigured } = useIsSearchServiceConfigured();
   const { data: isSearchServiceReachable } = useIsSearchServiceReachable();
   const { data: isSearchServiceReachable } = useIsSearchServiceReachable();
 
 
-  // TODO get search keywords and split
-  // ref: RevisionRenderer
-  //   [...keywords.match(/"[^"]+"|[^\u{20}\u{3000}]+/ug)].forEach((keyword, i) => {
-  const [highlightKeywords, setHightlightKeywords] = useState<string[]>([]);
   const [selectedPageIdsByCheckboxes] = useState<Set<string>>(new Set());
   const [selectedPageIdsByCheckboxes] = useState<Set<string>>(new Set());
   // const [allPageIds] = useState<Set<string>>(new Set());
   // const [allPageIds] = useState<Set<string>>(new Set());
   const [selectedPageWithMeta, setSelectedPageWithMeta] = useState<IPageWithMeta<IPageSearchMeta> | undefined>();
   const [selectedPageWithMeta, setSelectedPageWithMeta] = useState<IPageWithMeta<IPageSearchMeta> | undefined>();
@@ -68,7 +70,7 @@ const SearchPageBaseSubstance: ForwardRefRenderFunction<ISelectableAll & IReturn
       }
       }
 
 
       if (pages != null) {
       if (pages != null) {
-        pages.forEach(page => selectedPageIdsByCheckboxes.add(page.pageData._id));
+        pages.forEach(page => selectedPageIdsByCheckboxes.add(page.data._id));
       }
       }
     },
     },
     deselectAll: () => {
     deselectAll: () => {
@@ -123,11 +125,6 @@ const SearchPageBaseSubstance: ForwardRefRenderFunction<ISelectableAll & IReturn
     }
     }
   }, [onSelectedPagesByCheckboxesChanged, pages, selectedPageIdsByCheckboxes]);
   }, [onSelectedPagesByCheckboxesChanged, pages, selectedPageIdsByCheckboxes]);
 
 
-  useEffect(() => {
-    if (searchResultListHead != null && searchResultListHead.props != null) {
-      setHightlightKeywords(searchResultListHead.props.searchingKeyword);
-    }
-  }, [searchResultListHead]);
   if (!isSearchServiceConfigured) {
   if (!isSearchServiceConfigured) {
     return (
     return (
       <div className="grw-container-convertible">
       <div className="grw-container-convertible">
@@ -152,6 +149,10 @@ const SearchPageBaseSubstance: ForwardRefRenderFunction<ISelectableAll & IReturn
     );
     );
   }
   }
 
 
+  const highlightKeywords = searchingKeyword != null
+    ? searchingKeyword.match(highlightKeywordsSplitter) ?? undefined
+    : undefined;
+
   return (
   return (
     <div className="content-main">
     <div className="content-main">
       <div className="search-result-base d-flex" data-testid="search-result-base">
       <div className="search-result-base d-flex" data-testid="search-result-base">
@@ -182,7 +183,7 @@ const SearchPageBaseSubstance: ForwardRefRenderFunction<ISelectableAll & IReturn
                       ref={searchResultListRef}
                       ref={searchResultListRef}
                       // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
                       // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
                       pages={pages!}
                       pages={pages!}
-                      selectedPageId={selectedPageWithMeta?.pageData._id}
+                      selectedPageId={selectedPageWithMeta?.data._id}
                       forceHideMenuItems={forceHideMenuItems}
                       forceHideMenuItems={forceHideMenuItems}
                       onPageSelected={page => setSelectedPageWithMeta(page)}
                       onPageSelected={page => setSelectedPageWithMeta(page)}
                       onCheckboxChanged={checkboxChangedHandler}
                       onCheckboxChanged={checkboxChangedHandler}
@@ -249,12 +250,7 @@ export const usePageDeleteModalForBulkDeletion = (
     }
     }
 
 
     const selectedPages = data.data
     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, {
     openDeleteModal(selectedPages, {
       onDeleted: (...args) => {
       onDeleted: (...args) => {

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

@@ -190,11 +190,11 @@ const SearchTypeahead: ForwardRefRenderFunction<IFocusable, Props> = (props: Pro
   }
   }
 
 
   const renderMenuItemChildren = (option: IPageWithMeta<IPageSearchMeta>) => {
   const renderMenuItemChildren = (option: IPageWithMeta<IPageSearchMeta>) => {
-    const { pageData } = option;
+    const { data: pageData } = option;
     return (
     return (
       <span>
       <span>
         <UserPicture user={pageData.lastUpdateUser} size="sm" noLink />
         <UserPicture user={pageData.lastUpdateUser} size="sm" noLink />
-        <span className="ml-1 text-break text-wrap"><PagePathLabel path={pageData.path} /></span>
+        <span className="ml-1 mr-2 text-break text-wrap"><PagePathLabel path={pageData.path} /></span>
         <PageListMeta page={pageData} />
         <PageListMeta page={pageData} />
       </span>
       </span>
     );
     );

+ 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-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 />
           <SidebarContents />
         </div>
         </div>
       </div>
       </div>

+ 24 - 14
packages/app/src/components/Sidebar/CustomSidebar.tsx

@@ -31,9 +31,9 @@ const CustomSidebar: FC<Props> = (props: Props) => {
 
 
   const renderer = appContainer.getRenderer('sidebar');
   const renderer = appContainer.getRenderer('sidebar');
 
 
-  const { data: page, mutate } = useSWRxPageByPath('/Sidebar');
+  const { data: page, error, mutate } = useSWRxPageByPath('/Sidebar');
 
 
-  const isLoading = page === undefined;
+  const isLoading = page === undefined && error == null;
   const markdown = (page?.revision as IRevision | undefined)?.body;
   const markdown = (page?.revision as IRevision | undefined)?.body;
 
 
   return (
   return (
@@ -47,20 +47,30 @@ const CustomSidebar: FC<Props> = (props: Props) => {
           <i className="icon icon-reload"></i>
           <i className="icon icon-reload"></i>
         </button>
         </button>
       </div>
       </div>
-      { !isLoading && markdown == null && <SidebarNotFound /> }
-      {/* eslint-disable-next-line react/no-danger */}
-      { markdown != null && (
-        <div className="p-3">
-          <RevisionRenderer
-            growiRenderer={renderer}
-            markdown={markdown}
-            additionalClassName="grw-custom-sidebar-content"
-          />
-        </div>
-      ) }
+
+      {
+        isLoading && (
+          <div className="text-muted text-center">
+            <i className="fa fa-2x fa-spinner fa-pulse mr-1"></i>
+          </div>
+        )
+      }
+
+      {
+        !isLoading && markdown != null ? (
+          <div className="p-3">
+            <RevisionRenderer
+              growiRenderer={renderer}
+              markdown={markdown}
+              additionalClassName="grw-custom-sidebar-content"
+            />
+          </div>
+        ) : (
+          <SidebarNotFound />
+        )
+      }
     </>
     </>
   );
   );
-
 };
 };
 
 
 /**
 /**

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

@@ -76,7 +76,9 @@ const PageTree: FC = memo(() => {
 
 
       {!isGuestUser && migrationStatus?.migratablePagesCount != null && migrationStatus.migratablePagesCount !== 0 && (
       {!isGuestUser && migrationStatus?.migratablePagesCount != null && migrationStatus.migratablePagesCount !== 0 && (
         <div className="grw-pagetree-footer border-top p-3 w-100">
         <div className="grw-pagetree-footer border-top p-3 w-100">
-          <PrivateLegacyPagesLink />
+          <div className="private-legacy-pages-link px-3 py-2">
+            <PrivateLegacyPagesLink />
+          </div>
         </div>
         </div>
       )}
       )}
     </>
     </>

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

@@ -10,27 +10,38 @@ import nodePath from 'path';
 
 
 import { pathUtils, pagePathUtils } from '@growi/core';
 import { pathUtils, pagePathUtils } from '@growi/core';
 
 
+import loggerFactory from '~/utils/logger';
+
 import { toastWarning, toastError, toastSuccess } from '~/client/util/apiNotification';
 import { toastWarning, toastError, toastSuccess } from '~/client/util/apiNotification';
 
 
 import { useSWRxPageChildren } from '~/stores/page-listing';
 import { useSWRxPageChildren } from '~/stores/page-listing';
 import { apiv3Put, apiv3Post } from '~/client/util/apiv3-client';
 import { apiv3Put, apiv3Post } from '~/client/util/apiv3-client';
-import { IPageForPageRenameModal, IPageForPageDuplicateModal, IPageForPageDeleteModal } from '~/stores/modal';
+import { IPageForPageDuplicateModal } from '~/stores/modal';
 
 
 import TriangleIcon from '~/components/Icons/TriangleIcon';
 import TriangleIcon from '~/components/Icons/TriangleIcon';
 import { bookmark, unbookmark } from '~/client/services/page-operation';
 import { bookmark, unbookmark } from '~/client/services/page-operation';
 import ClosableTextInput, { AlertInfo, AlertType } from '../../Common/ClosableTextInput';
 import ClosableTextInput, { AlertInfo, AlertType } from '../../Common/ClosableTextInput';
 import { PageItemControl } from '../../Common/Dropdown/PageItemControl';
 import { PageItemControl } from '../../Common/Dropdown/PageItemControl';
 import { ItemNode } from './ItemNode';
 import { ItemNode } from './ItemNode';
+import { usePageTreeDescCountMap } from '~/stores/ui';
+import {
+  IPageHasId, IPageInfoAll, IPageToDeleteWithMeta,
+} from '~/interfaces/page';
+
+
+const logger = loggerFactory('growi:cli:Item');
+
 
 
 interface ItemProps {
 interface ItemProps {
   isEnableActions: boolean
   isEnableActions: boolean
   itemNode: ItemNode
   itemNode: ItemNode
   targetPathOrId?: string
   targetPathOrId?: string
+  isScrolled: boolean,
   isOpen?: boolean
   isOpen?: boolean
   isEnabledAttachTitleHeader?: boolean
   isEnabledAttachTitleHeader?: boolean
+  onRenamed?(): void
   onClickDuplicateMenuItem?(pageToDuplicate: IPageForPageDuplicateModal): void
   onClickDuplicateMenuItem?(pageToDuplicate: IPageForPageDuplicateModal): void
-  onClickRenameMenuItem?(pageToRename: IPageForPageRenameModal): void
-  onClickDeleteMenuItem?(pageToDelete: IPageForPageDeleteModal): void
+  onClickDeleteMenuItem?(pageToDelete: IPageToDeleteWithMeta): void
 }
 }
 
 
 // Utility to mark target
 // Utility to mark target
@@ -53,6 +64,36 @@ const bookmarkMenuItemClickHandler = async(_pageId: string, _newValue: boolean):
   await bookmarkOperation(_pageId);
   await bookmarkOperation(_pageId);
 };
 };
 
 
+/**
+ * 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 = {
 type ItemCountProps = {
   descendantCount: number
   descendantCount: number
@@ -61,7 +102,7 @@ type ItemCountProps = {
 const ItemCount: FC<ItemCountProps> = (props:ItemCountProps) => {
 const ItemCount: FC<ItemCountProps> = (props:ItemCountProps) => {
   return (
   return (
     <>
     <>
-      <span className="grw-pagetree-count badge badge-pill badge-light text-muted">
+      <span className="grw-pagetree-count px-0 badge badge-pill badge-light text-muted">
         {props.descendantCount}
         {props.descendantCount}
       </span>
       </span>
     </>
     </>
@@ -72,23 +113,27 @@ const Item: FC<ItemProps> = (props: ItemProps) => {
   const { t } = useTranslation();
   const { t } = useTranslation();
   const {
   const {
     itemNode, targetPathOrId, isOpen: _isOpen = false, isEnabledAttachTitleHeader,
     itemNode, targetPathOrId, isOpen: _isOpen = false, isEnabledAttachTitleHeader,
-    onClickDuplicateMenuItem, onClickRenameMenuItem, onClickDeleteMenuItem, isEnableActions,
+    onRenamed, onClickDuplicateMenuItem, onClickDeleteMenuItem, isEnableActions,
   } = props;
   } = props;
 
 
   const { page, children } = itemNode;
   const { page, children } = itemNode;
 
 
-  const [pageTitle, setPageTitle] = useState(page.path);
   const [currentChildren, setCurrentChildren] = useState(children);
   const [currentChildren, setCurrentChildren] = useState(children);
   const [isOpen, setIsOpen] = useState(_isOpen);
   const [isOpen, setIsOpen] = useState(_isOpen);
   const [isNewPageInputShown, setNewPageInputShown] = useState(false);
   const [isNewPageInputShown, setNewPageInputShown] = useState(false);
   const [shouldHide, setShouldHide] = useState(false);
   const [shouldHide, setShouldHide] = useState(false);
-  // const [isRenameInputShown, setRenameInputShown] = useState(false);
+  const [isRenameInputShown, setRenameInputShown] = useState(false);
 
 
   const { data, mutate: mutateChildren } = useSWRxPageChildren(isOpen ? page._id : null);
   const { data, mutate: mutateChildren } = useSWRxPageChildren(isOpen ? page._id : null);
 
 
+  // descendantCount
+  const { getDescCount } = usePageTreeDescCountMap();
+  const descendantCount = getDescCount(page._id) || page.descendantCount || 0;
+
+
   // hasDescendants flag
   // hasDescendants flag
   const isChildrenLoaded = currentChildren?.length > 0;
   const isChildrenLoaded = currentChildren?.length > 0;
-  const hasDescendants = (page.descendantCount != null && page?.descendantCount > 0) || isChildrenLoaded;
+  const hasDescendants = descendantCount > 0 || isChildrenLoaded;
 
 
   // to re-show hidden item when useDrag end() callback
   // to re-show hidden item when useDrag end() callback
   const displayDroppedItemByPageId = useCallback((pageId) => {
   const displayDroppedItemByPageId = useCallback((pageId) => {
@@ -103,9 +148,13 @@ const Item: FC<ItemProps> = (props: ItemProps) => {
     }, 500);
     }, 500);
   }, []);
   }, []);
 
 
-  const [{ isDragging }, drag] = useDrag(() => ({
+  const [, drag] = useDrag({
     type: 'PAGE_TREE',
     type: 'PAGE_TREE',
     item: { page },
     item: { page },
+    canDrag: () => {
+      const isDraggable = !pagePathUtils.isUserPage(page.path || '/');
+      return isDraggable;
+    },
     end: (item, monitor) => {
     end: (item, monitor) => {
       // in order to set d-none to dropped Item
       // in order to set d-none to dropped Item
       const dropResult = monitor.getDropResult();
       const dropResult = monitor.getDropResult();
@@ -115,19 +164,22 @@ const Item: FC<ItemProps> = (props: ItemProps) => {
     },
     },
     collect: monitor => ({
     collect: monitor => ({
       isDragging: monitor.isDragging(),
       isDragging: monitor.isDragging(),
+      canDrag: monitor.canDrag(),
     }),
     }),
-  }));
+  });
+
+  const pageItemDropHandler = async(item: ItemNode) => {
+    const { page: droppedPage } = item;
 
 
-  const pageItemDropHandler = async(item, monitor) => {
-    if (page == null || page.path == null) {
+    if (!canMoveUnderNewParent(droppedPage, page, true)) {
       return;
       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 {
     try {
       await apiv3Put('/pages/rename', {
       await apiv3Put('/pages/rename', {
@@ -135,7 +187,7 @@ const Item: FC<ItemProps> = (props: ItemProps) => {
         revisionId: droppedPage.revision,
         revisionId: droppedPage.revision,
         newPagePath,
         newPagePath,
         isRenameRedirect: false,
         isRenameRedirect: false,
-        isRemainMetadata: false,
+        updateMetadata: true,
       });
       });
 
 
       await mutateChildren();
       await mutateChildren();
@@ -156,7 +208,7 @@ const Item: FC<ItemProps> = (props: ItemProps) => {
     }
     }
   };
   };
 
 
-  const [{ isOver }, drop] = useDrop(() => ({
+  const [{ isOver }, drop] = useDrop<ItemNode, Promise<void>, { isOver: boolean }>(() => ({
     accept: 'PAGE_TREE',
     accept: 'PAGE_TREE',
     drop: pageItemDropHandler,
     drop: pageItemDropHandler,
     hover: (item, monitor) => {
     hover: (item, monitor) => {
@@ -166,9 +218,13 @@ const Item: FC<ItemProps> = (props: ItemProps) => {
           if (monitor.isOver()) {
           if (monitor.isOver()) {
             setIsOpen(true);
             setIsOpen(true);
           }
           }
-        }, 1000);
+        }, 600);
       }
       }
     },
     },
+    canDrop: (item) => {
+      const { page: droppedPage } = item;
+      return canMoveUnderNewParent(droppedPage, page);
+    },
     collect: monitor => ({
     collect: monitor => ({
       isOver: monitor.isOver(),
       isOver: monitor.isOver(),
     }),
     }),
@@ -202,69 +258,58 @@ const Item: FC<ItemProps> = (props: ItemProps) => {
     onClickDuplicateMenuItem(pageToDuplicate);
     onClickDuplicateMenuItem(pageToDuplicate);
   }, [onClickDuplicateMenuItem, page]);
   }, [onClickDuplicateMenuItem, page]);
 
 
+  const renameMenuItemClickHandler = useCallback(() => {
+    setRenameInputShown(true);
+  }, []);
+
+  const onPressEnterForRenameHandler = async(inputText: string) => {
+    const parentPath = pathUtils.addTrailingSlash(nodePath.dirname(page.path ?? ''));
+    const newPagePath = nodePath.resolve(parentPath, inputText);
 
 
-  /*
-  * Rename: TODO: rename page title on input form by #87757
-  */
-
-  // const onClickRenameButton = useCallback(async(_pageId: string): Promise<void> => {
-  //   setRenameInputShown(true);
-  // }, []);
-
-  // const onPressEnterForRenameHandler = async(inputText: string) => {
-  //   const parentPath = getParentPagePath(page.path as string)
-  //   const newPagePath = `${parentPath}/${inputText}`;
-
-  //   try {
-  //     setPageTitle(inputText);
-  //     setRenameInputShown(false);
-  //     await apiv3Put('/pages/rename', { newPagePath, pageId: page._id, revisionId: page.revision });
-  //   }
-  //   catch (err) {
-  //     // open ClosableInput and set pageTitle back to the previous title
-  //     setPageTitle(nodePath.basename(pageTitle as string));
-  //     setRenameInputShown(true);
-  //     toastError(err);
-  //   }
-  // };
-
-  const renameMenuItemClickHandler = useCallback((): void => {
-    if (onClickRenameMenuItem == null) {
+    if (newPagePath === page.path) {
+      setRenameInputShown(false);
       return;
       return;
     }
     }
 
 
-    const { _id: pageId, revision: revisionId, path } = page;
-
-    if (pageId == null || revisionId == null || path == null) {
-      throw Error('Any of _id and revisionId and path must not be null.');
-    }
+    try {
+      setRenameInputShown(false);
+      await apiv3Put('/pages/rename', {
+        pageId: page._id,
+        revisionId: page.revision,
+        newPagePath,
+      });
 
 
-    const pageToRename: IPageForPageRenameModal = {
-      pageId,
-      revisionId: revisionId as string,
-      path,
-    };
+      if (onRenamed != null) {
+        onRenamed();
+      }
 
 
-    onClickRenameMenuItem(pageToRename);
-  }, [onClickRenameMenuItem, page]);
+      toastSuccess(t('renamed_pages', { path: page.path }));
+    }
+    catch (err) {
+      setRenameInputShown(true);
+      toastError(err);
+    }
+  };
 
 
-  const deleteMenuItemClickHandler = useCallback(async(_pageId: string, pageInfo): Promise<void> => {
-    const { _id: pageId, revision: revisionId, path } = page;
+  const deleteMenuItemClickHandler = useCallback(async(_pageId: string, pageInfo: IPageInfoAll | undefined): Promise<void> => {
+    if (onClickDeleteMenuItem == null) {
+      return;
+    }
 
 
-    if (pageId == null || revisionId == null || path == null) {
+    if (page._id == null || page.revision == null || page.path == null) {
       throw Error('Any of _id, revision, and path must not be 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) {
-      onClickDeleteMenuItem(pageToDelete);
-    }
+    onClickDeleteMenuItem(pageToDelete);
   }, [onClickDeleteMenuItem, page]);
   }, [onClickDeleteMenuItem, page]);
 
 
   const onPressEnterForCreateHandler = async(inputText: string) => {
   const onPressEnterForCreateHandler = async(inputText: string) => {
@@ -280,7 +325,8 @@ const Item: FC<ItemProps> = (props: ItemProps) => {
 
 
     let initBody = '';
     let initBody = '';
     if (isEnabledAttachTitleHeader) {
     if (isEnabledAttachTitleHeader) {
-      initBody = pathUtils.attachTitleHeader(newPagePath);
+      const pageTitle = pathUtils.addHeadingSlash(nodePath.basename(newPagePath));
+      initBody = pathUtils.attachTitleHeader(pageTitle);
     }
     }
 
 
     try {
     try {
@@ -317,6 +363,13 @@ const Item: FC<ItemProps> = (props: ItemProps) => {
     return null;
     return null;
   };
   };
 
 
+
+  useEffect(() => {
+    if (!props.isScrolled && page.isTarget) {
+      document.dispatchEvent(new CustomEvent('targetItemRendered'));
+    }
+  }, [props.isScrolled, page.isTarget]);
+
   // didMount
   // didMount
   useEffect(() => {
   useEffect(() => {
     if (hasChildren()) setIsOpen(true);
     if (hasChildren()) setIsOpen(true);
@@ -344,10 +397,15 @@ const Item: FC<ItemProps> = (props: ItemProps) => {
   }, [data, isOpen, targetPathOrId]);
   }, [data, isOpen, targetPathOrId]);
 
 
   return (
   return (
-    <div id={`pagetree-item-${page._id}`} className={`grw-pagetree-item-container ${isOver ? 'grw-pagetree-is-over' : ''} ${shouldHide ? 'd-none' : ''}`}>
+    <div
+      id={`pagetree-item-${page._id}`}
+      className={`grw-pagetree-item-container ${isOver ? 'grw-pagetree-is-over' : ''}
+    ${shouldHide ? 'd-none' : ''}`}
+    >
       <li
       <li
         ref={(c) => { drag(c); drop(c) }}
         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 pr-3 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">
         <div className="grw-triangle-container d-flex justify-content-center">
           {hasDescendants && (
           {hasDescendants && (
@@ -356,34 +414,33 @@ const Item: FC<ItemProps> = (props: ItemProps) => {
               className={`grw-pagetree-button btn ${isOpen ? 'grw-pagetree-open' : ''}`}
               className={`grw-pagetree-button btn ${isOpen ? 'grw-pagetree-open' : ''}`}
               onClick={onClickLoadChildren}
               onClick={onClickLoadChildren}
             >
             >
-              <div className="grw-triangle-icon d-flex justify-content-center">
+              <div className="d-flex justify-content-center">
                 <TriangleIcon />
                 <TriangleIcon />
               </div>
               </div>
             </button>
             </button>
           )}
           )}
         </div>
         </div>
-        {/* TODO: rename page title on input form by 87757 */}
-        {/* { isRenameInputShown && (
+        { isRenameInputShown && (
           <ClosableTextInput
           <ClosableTextInput
             isShown
             isShown
-            value={nodePath.basename(pageTitle as string)}
+            value={nodePath.basename(page.path ?? '')}
             placeholder={t('Input page name')}
             placeholder={t('Input page name')}
             onClickOutside={() => { setRenameInputShown(false) }}
             onClickOutside={() => { setRenameInputShown(false) }}
             onPressEnter={onPressEnterForRenameHandler}
             onPressEnter={onPressEnterForRenameHandler}
             inputValidator={inputValidator}
             inputValidator={inputValidator}
           />
           />
         )}
         )}
-        { !isRenameInputShown && ( */}
-        <a href={`/${page._id}`} className="grw-pagetree-title-anchor flex-grow-1">
-          <p className={`text-truncate m-auto ${page.isEmpty && 'text-muted'}`}>{nodePath.basename(pageTitle as string) || '/'}</p>
-        </a>
-        {/* )} */}
-        {(page.descendantCount != null && page.descendantCount > 0) && (
+        { !isRenameInputShown && (
+          <a href={`/${page._id}`} className="grw-pagetree-title-anchor flex-grow-1">
+            <p className={`text-truncate m-auto ${page.isEmpty && 'text-muted'}`}>{nodePath.basename(page.path ?? '') || '/'}</p>
+          </a>
+        )}
+        {(descendantCount > 0) && (
           <div className="grw-pagetree-count-wrapper">
           <div className="grw-pagetree-count-wrapper">
-            <ItemCount descendantCount={page.descendantCount} />
+            <ItemCount descendantCount={descendantCount} />
           </div>
           </div>
         )}
         )}
-        <div className="grw-pagetree-control d-none">
+        <div className="grw-pagetree-control d-flex">
           <PageItemControl
           <PageItemControl
             pageId={page._id}
             pageId={page._id}
             isEnableActions={isEnableActions}
             isEnableActions={isEnableActions}
@@ -392,16 +449,17 @@ const Item: FC<ItemProps> = (props: ItemProps) => {
             onClickRenameMenuItem={renameMenuItemClickHandler}
             onClickRenameMenuItem={renameMenuItemClickHandler}
             onClickDeleteMenuItem={deleteMenuItemClickHandler}
             onClickDeleteMenuItem={deleteMenuItemClickHandler}
           >
           >
-            <DropdownToggle color="transparent" className="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 mr-1">
               <i className="icon-options fa fa-rotate-90 text-muted p-1"></i>
               <i className="icon-options fa fa-rotate-90 text-muted p-1"></i>
             </DropdownToggle>
             </DropdownToggle>
           </PageItemControl>
           </PageItemControl>
           <button
           <button
             type="button"
             type="button"
-            className="border-0 rounded btn-page-item-control p-0"
+            className="border-0 rounded btn btn-page-item-control p-0 grw-visible-on-hover"
             onClick={onClickPlusButton}
             onClick={onClickPlusButton}
           >
           >
-            <i className="icon-plus text-muted d-block p-1" />
+            <i className="icon-plus text-muted d-block p-0" />
           </button>
           </button>
         </div>
         </div>
       </li>
       </li>
@@ -422,10 +480,11 @@ const Item: FC<ItemProps> = (props: ItemProps) => {
               isEnableActions={isEnableActions}
               isEnableActions={isEnableActions}
               itemNode={node}
               itemNode={node}
               isOpen={false}
               isOpen={false}
+              isScrolled={props.isScrolled}
               targetPathOrId={targetPathOrId}
               targetPathOrId={targetPathOrId}
               isEnabledAttachTitleHeader={isEnabledAttachTitleHeader}
               isEnabledAttachTitleHeader={isEnabledAttachTitleHeader}
+              onRenamed={onRenamed}
               onClickDuplicateMenuItem={onClickDuplicateMenuItem}
               onClickDuplicateMenuItem={onClickDuplicateMenuItem}
-              onClickRenameMenuItem={onClickRenameMenuItem}
               onClickDeleteMenuItem={onClickDeleteMenuItem}
               onClickDeleteMenuItem={onClickDeleteMenuItem}
             />
             />
           </div>
           </div>

+ 79 - 23
packages/app/src/components/Sidebar/PageTree/ItemsTree.tsx

@@ -1,21 +1,26 @@
-import React, { FC, useEffect } from 'react';
+import React, { FC, useEffect, useState } from 'react';
 import { useTranslation } from 'react-i18next';
 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 { usePageTreeTermManager, useSWRxPageAncestorsChildren, useSWRxRootPage } from '~/stores/page-listing';
 import { TargetAndAncestors } from '~/interfaces/page-listing-results';
 import { TargetAndAncestors } from '~/interfaces/page-listing-results';
-import { OnDeletedFunction } from '~/interfaces/ui';
+import { IPageHasId, IPageToDeleteWithMeta } from '~/interfaces/page';
+import { OnDuplicatedFunction, OnDeletedFunction } from '~/interfaces/ui';
+import { SocketEventName, UpdateDescCountData, UpdateDescCountRawData } from '~/interfaces/websocket';
 import { toastError, toastSuccess } from '~/client/util/apiNotification';
 import { toastError, toastSuccess } from '~/client/util/apiNotification';
 import {
 import {
-  IPageForPageDeleteModal, IPageForPageDuplicateModal, usePageDuplicateModal, IPageForPageRenameModal, usePageRenameModal, usePageDeleteModal,
+  IPageForPageDuplicateModal, usePageDuplicateModal, usePageDeleteModal,
 } from '~/stores/modal';
 } from '~/stores/modal';
 import { smoothScrollIntoView } from '~/client/util/smooth-scroll';
 import { smoothScrollIntoView } from '~/client/util/smooth-scroll';
 
 
 import { useIsEnabledAttachTitleHeader } from '~/stores/context';
 import { useIsEnabledAttachTitleHeader } from '~/stores/context';
 import { useFullTextSearchTermManager } from '~/stores/search';
 import { useFullTextSearchTermManager } from '~/stores/search';
 import { useDescendantsPageListForCurrentPathTermManager } from '~/stores/page';
 import { useDescendantsPageListForCurrentPathTermManager } from '~/stores/page';
+import { useGlobalSocket } from '~/stores/websocket';
+import { usePageTreeDescCountMap } from '~/stores/ui';
+
+import { ItemNode } from './ItemNode';
+import Item from './Item';
+
 
 
 /*
 /*
  * Utility to generate initial node
  * Utility to generate initial node
@@ -66,11 +71,12 @@ type ItemsTreeProps = {
 const renderByInitialNode = (
 const renderByInitialNode = (
     initialNode: ItemNode,
     initialNode: ItemNode,
     isEnableActions: boolean,
     isEnableActions: boolean,
+    isScrolled: boolean,
     targetPathOrId?: string,
     targetPathOrId?: string,
     isEnabledAttachTitleHeader?: boolean,
     isEnabledAttachTitleHeader?: boolean,
+    onRenamed?: () => void,
     onClickDuplicateMenuItem?: (pageToDuplicate: IPageForPageDuplicateModal) => void,
     onClickDuplicateMenuItem?: (pageToDuplicate: IPageForPageDuplicateModal) => void,
-    onClickRenameMenuItem?: (pageToRename: IPageForPageRenameModal) => void,
-    onClickDeleteMenuItem?: (pageToDelete: IPageForPageDeleteModal) => void,
+    onClickDeleteMenuItem?: (pageToDelete: IPageToDeleteWithMeta) => void,
 ): JSX.Element => {
 ): JSX.Element => {
 
 
   return (
   return (
@@ -82,14 +88,28 @@ const renderByInitialNode = (
         isOpen
         isOpen
         isEnabledAttachTitleHeader={isEnabledAttachTitleHeader}
         isEnabledAttachTitleHeader={isEnabledAttachTitleHeader}
         isEnableActions={isEnableActions}
         isEnableActions={isEnableActions}
+        onRenamed={onRenamed}
         onClickDuplicateMenuItem={onClickDuplicateMenuItem}
         onClickDuplicateMenuItem={onClickDuplicateMenuItem}
-        onClickRenameMenuItem={onClickRenameMenuItem}
         onClickDeleteMenuItem={onClickDeleteMenuItem}
         onClickDeleteMenuItem={onClickDeleteMenuItem}
+        isScrolled={isScrolled}
       />
       />
     </ul>
     </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
  * ItemsTree
@@ -105,32 +125,66 @@ const ItemsTree: FC<ItemsTreeProps> = (props: ItemsTreeProps) => {
   const { data: rootPageData, error: error2 } = useSWRxRootPage();
   const { data: rootPageData, error: error2 } = useSWRxRootPage();
   const { data: isEnabledAttachTitleHeader } = useIsEnabledAttachTitleHeader();
   const { data: isEnabledAttachTitleHeader } = useIsEnabledAttachTitleHeader();
   const { open: openDuplicateModal } = usePageDuplicateModal();
   const { open: openDuplicateModal } = usePageDuplicateModal();
-  const { open: openRenameModal } = usePageRenameModal();
   const { open: openDeleteModal } = usePageDeleteModal();
   const { open: openDeleteModal } = usePageDeleteModal();
+  const [isScrolled, setIsScrolled] = useState(false);
+
+  const { data: socket } = useGlobalSocket();
+  const { data: ptDescCountMap, update: updatePtDescCountMap } = usePageTreeDescCountMap();
+
 
 
   // for mutation
   // for mutation
   const { advance: advancePt } = usePageTreeTermManager();
   const { advance: advancePt } = usePageTreeTermManager();
   const { advance: advanceFts } = useFullTextSearchTermManager();
   const { advance: advanceFts } = useFullTextSearchTermManager();
   const { advance: advanceDpl } = useDescendantsPageListForCurrentPathTermManager();
   const { advance: advanceDpl } = useDescendantsPageListForCurrentPathTermManager();
 
 
+  const scrollItem = () => {
+    scrollTargetItem();
+    setIsScrolled(true);
+  };
+
   useEffect(() => {
   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);
+    document.addEventListener('targetItemRendered', scrollItem);
+    return () => {
+      document.removeEventListener('targetItemRendered', scrollItem);
+    };
+  }, []);
+
+  useEffect(() => {
+    if (socket == null) {
+      return;
     }
     }
-  }, [ancestorsChildrenData]);
 
 
-  const onClickDuplicateMenuItem = (pageToDuplicate: IPageForPageDuplicateModal) => {
-    openDuplicateModal(pageToDuplicate);
+    socket.on(SocketEventName.UpdateDescCount, (data: UpdateDescCountRawData) => {
+      // save to global state
+      const newData: UpdateDescCountData = new Map(Object.entries(data));
+
+      updatePtDescCountMap(newData);
+    });
+
+    return () => { socket.off(SocketEventName.UpdateDescCount) };
+
+  }, [socket, ptDescCountMap, updatePtDescCountMap]);
+
+  const onRenamed = () => {
+    advancePt();
+    advanceFts();
+    advanceDpl();
   };
   };
 
 
-  const onClickRenameMenuItem = (pageToRename: IPageForPageRenameModal) => {
-    openRenameModal(pageToRename);
+  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();
+    };
+
+    openDuplicateModal(pageToDuplicate, { onDuplicated: duplicatedHandler });
   };
   };
 
 
-  const onClickDeleteMenuItem = (pageToDelete: IPageForPageDeleteModal) => {
+  const onClickDeleteMenuItem = (pageToDelete: IPageToDeleteWithMeta) => {
     const onDeletedHandler: OnDeletedFunction = (pathOrPathsToDelete, isRecursively, isCompletely) => {
     const onDeletedHandler: OnDeletedFunction = (pathOrPathsToDelete, isRecursively, isCompletely) => {
       if (typeof pathOrPathsToDelete !== 'string') {
       if (typeof pathOrPathsToDelete !== 'string') {
         return;
         return;
@@ -165,7 +219,8 @@ const ItemsTree: FC<ItemsTreeProps> = (props: ItemsTreeProps) => {
   if (ancestorsChildrenData != null && rootPageData != null) {
   if (ancestorsChildrenData != null && rootPageData != null) {
     const initialNode = generateInitialNodeAfterResponse(ancestorsChildrenData.ancestorsChildren, new ItemNode(rootPageData.rootPage));
     const initialNode = generateInitialNodeAfterResponse(ancestorsChildrenData.ancestorsChildren, new ItemNode(rootPageData.rootPage));
     return renderByInitialNode(
     return renderByInitialNode(
-      initialNode, isEnableActions, targetPathOrId, isEnabledAttachTitleHeader, onClickDuplicateMenuItem, onClickRenameMenuItem, onClickDeleteMenuItem,
+      // eslint-disable-next-line max-len
+      initialNode, isEnableActions, isScrolled, targetPathOrId, isEnabledAttachTitleHeader, onRenamed, onClickDuplicateMenuItem, onClickDeleteMenuItem,
     );
     );
   }
   }
 
 
@@ -175,7 +230,8 @@ const ItemsTree: FC<ItemsTreeProps> = (props: ItemsTreeProps) => {
   if (targetAndAncestorsData != null) {
   if (targetAndAncestorsData != null) {
     const initialNode = generateInitialNodeBeforeResponse(targetAndAncestorsData.targetAndAncestors);
     const initialNode = generateInitialNodeBeforeResponse(targetAndAncestorsData.targetAndAncestors);
     return renderByInitialNode(
     return renderByInitialNode(
-      initialNode, isEnableActions, targetPathOrId, isEnabledAttachTitleHeader, onClickDuplicateMenuItem, onClickRenameMenuItem, onClickDeleteMenuItem,
+      // eslint-disable-next-line max-len
+      initialNode, isEnableActions, isScrolled, targetPathOrId, isEnabledAttachTitleHeader, onRenamed, onClickDuplicateMenuItem, onClickDeleteMenuItem,
     );
     );
   }
   }
 
 

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

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

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

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

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

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

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

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

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

@@ -37,6 +37,7 @@ export type IPageHasId = IPage & HasObjectId;
 export type IPageForItem = Partial<IPageHasId & {isTarget?: boolean}>;
 export type IPageForItem = Partial<IPageHasId & {isTarget?: boolean}>;
 
 
 export type IPageInfo = {
 export type IPageInfo = {
+  isV5Compatible: boolean,
   isEmpty: boolean,
   isEmpty: boolean,
   isMovable: boolean,
   isMovable: boolean,
   isDeletable: boolean,
   isDeletable: boolean,
@@ -62,17 +63,20 @@ export type IPageInfoForListing = IPageInfoForEntity & HasRevisionShortbody;
 
 
 export type IPageInfoAll = IPageInfo | IPageInfoForEntity | IPageInfoForOperation | IPageInfoForListing;
 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
   return pageInfo != null
     && isIPageInfoForEntity(pageInfo)
     && isIPageInfoForEntity(pageInfo)
     && ('isBookmarked' in pageInfo || 'isLiked' in pageInfo || 'subscriptionStatus' in 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
   return pageInfo != null
     && isIPageInfoForEntity(pageInfo)
     && isIPageInfoForEntity(pageInfo)
     && 'revisionShortBody' in pageInfo;
     && 'revisionShortBody' in pageInfo;
@@ -94,10 +98,15 @@ export const isIPageInfoForListing = (pageInfo: IPageInfoAll | undefined): pageI
 //   return <IPageInfoTypeResolver<T>>pageInfo;
 //   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 }), IPageInfoForEntity | unknown>;
+export type IPageToRenameWithMeta = IPageToDeleteWithMeta;
 
 
 export type IDeleteSinglePageApiv1Result = {
 export type IDeleteSinglePageApiv1Result = {
   ok: boolean
   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 = {
 export type IPageSearchMeta = {
   bookmarkCount?: number,
   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;
   return meta != null && 'elasticSearchResult' in meta;
 };
 };
 
 

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

@@ -22,3 +22,6 @@ export type ICustomNavTabMappings = { [key: string]: ICustomTabContent };
 
 
 
 
 export type OnDeletedFunction = (idOrPaths: string | string[], isRecursively: Nullable<true>, isCompletely: Nullable<true>) => void;
 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;
+export type OnPutBackedFunction = (path: string) => void;

+ 4 - 0
packages/app/src/interfaces/user-group-response.ts

@@ -21,3 +21,7 @@ export type UserGroupPagesResult = {
 export type SelectableUserGroupsResult = {
 export type SelectableUserGroupsResult = {
   selectableUserGroups: IUserGroupHasId[],
   selectableUserGroups: IUserGroupHasId[],
 }
 }
+
+export type AncestorUserGroupsResult = {
+  ancestorUserGroups: IUserGroupHasId[],
+}

+ 12 - 0
packages/app/src/interfaces/websocket.ts

@@ -0,0 +1,12 @@
+export const SocketEventName = {
+  UpdateDescCount: 'UpdateDsecCount',
+} as const;
+export type SocketEventName = typeof SocketEventName[keyof typeof SocketEventName];
+
+type PageId = string;
+type DescendantCount = number;
+/**
+ * Data of updateDescCount when used through socket.io. Convert to UpdateDescCountData type when use with swr cache.
+ */
+export type UpdateDescCountRawData = Record<PageId, DescendantCount>;
+export type UpdateDescCountData = Map<PageId, DescendantCount>;

+ 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';
 import loggerFactory from '~/utils/logger';
 
 
 const logger = loggerFactory('growi:middlewares:ApiV3FormValidator');
 const logger = loggerFactory('growi:middlewares:ApiV3FormValidator');
@@ -5,7 +6,7 @@ const { validationResult } = require('express-validator');
 
 
 const ErrorV3 = require('../models/vo/error-apiv3');
 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.query', req.query);
   logger.debug('req.params', req.params);
   logger.debug('req.params', req.params);
   logger.debug('req.body', req.body);
   logger.debug('req.body', req.body);

+ 17 - 319
packages/app/src/server/models/obsolete-page.js

@@ -1,6 +1,8 @@
-import { templateChecker, pagePathUtils } from '@growi/core';
+import { templateChecker, pagePathUtils, pathUtils } from '@growi/core';
+
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 
 
+
 // disable no-return-await for model functions
 // disable no-return-await for model functions
 /* eslint-disable no-return-await */
 /* eslint-disable no-return-await */
 
 
@@ -12,7 +14,6 @@ const urljoin = require('url-join');
 const mongoose = require('mongoose');
 const mongoose = require('mongoose');
 const differenceInYears = require('date-fns/differenceInYears');
 const differenceInYears = require('date-fns/differenceInYears');
 
 
-const { pathUtils } = require('@growi/core');
 const escapeStringRegexp = require('escape-string-regexp');
 const escapeStringRegexp = require('escape-string-regexp');
 
 
 const { isTopPage, isTrashPage } = pagePathUtils;
 const { isTopPage, isTrashPage } = pagePathUtils;
@@ -44,7 +45,7 @@ const pageSchema = {
  * @param {string} pagePath
  * @param {string} pagePath
  * @return {string[]} ancestors paths
  * @return {string[]} ancestors paths
  */
  */
-const extractToAncestorsPaths = (pagePath) => {
+export const extractToAncestorsPaths = (pagePath) => {
   const ancestorsPaths = [];
   const ancestorsPaths = [];
 
 
   let parentPath;
   let parentPath;
@@ -62,7 +63,7 @@ const extractToAncestorsPaths = (pagePath) => {
  * @param {string} userPublicFields string to set to select
  * @param {string} userPublicFields string to set to select
  */
  */
 /* eslint-disable object-curly-newline, object-property-newline */
 /* eslint-disable object-curly-newline, object-property-newline */
-const populateDataToShowRevision = (page, userPublicFields) => {
+export const populateDataToShowRevision = (page, userPublicFields) => {
   return page
   return page
     .populate([
     .populate([
       { path: 'lastUpdateUser', model: 'User', select: userPublicFields },
       { path: 'lastUpdateUser', model: 'User', select: userPublicFields },
@@ -77,307 +78,6 @@ const populateDataToShowRevision = (page, userPublicFields) => {
 /* eslint-enable object-curly-newline, object-property-newline */
 /* eslint-enable object-curly-newline, object-property-newline */
 
 
 
 
-export class PageQueryBuilder {
-
-  constructor(query, includeEmpty = false) {
-    this.query = query;
-    if (!includeEmpty) {
-      this.query = this.query
-        .and({
-          $or: [
-            { isEmpty: false },
-            { isEmpty: null }, // for v4 compatibility
-          ],
-        });
-    }
-  }
-
-  addConditionToExcludeTrashed() {
-    this.query = this.query
-      .and({
-        $or: [
-          { status: null },
-          { status: STATUS_PUBLISHED },
-        ],
-      });
-
-    return this;
-  }
-
-  /**
-   * generate the query to find the pages '{path}/*' and '{path}' self.
-   * If top page, return without doing anything.
-   */
-  addConditionToListWithDescendants(path, option) {
-    // No request is set for the top page
-    if (isTopPage(path)) {
-      return this;
-    }
-
-    const pathNormalized = pathUtils.normalizePath(path);
-    const pathWithTrailingSlash = pathUtils.addTrailingSlash(path);
-
-    const startsPattern = escapeStringRegexp(pathWithTrailingSlash);
-
-    this.query = this.query
-      .and({
-        $or: [
-          { path: pathNormalized },
-          { path: new RegExp(`^${startsPattern}`) },
-        ],
-      });
-
-    return this;
-  }
-
-  /**
-   * generate the query to find the pages '{path}/*' (exclude '{path}' self).
-   * If top page, return without doing anything.
-   */
-  addConditionToListOnlyDescendants(path, option) {
-    // No request is set for the top page
-    if (isTopPage(path)) {
-      return this;
-    }
-
-    const pathWithTrailingSlash = pathUtils.addTrailingSlash(path);
-
-    const startsPattern = escapeStringRegexp(pathWithTrailingSlash);
-
-    this.query = this.query
-      .and({ path: new RegExp(`^${startsPattern}`) });
-
-    return this;
-
-  }
-
-  addConditionToListOnlyAncestors(path) {
-    const pathNormalized = pathUtils.normalizePath(path);
-    const ancestorsPaths = extractToAncestorsPaths(pathNormalized);
-
-    this.query = this.query
-      .and({
-        path: {
-          $in: ancestorsPaths,
-        },
-      });
-
-    return this;
-
-  }
-
-  /**
-   * generate the query to find pages that start with `path`
-   *
-   * In normal case, returns '{path}/*' and '{path}' self.
-   * If top page, return without doing anything.
-   *
-   * *option*
-   *   Left for backward compatibility
-   */
-  addConditionToListByStartWith(path, option) {
-    // No request is set for the top page
-    if (isTopPage(path)) {
-      return this;
-    }
-
-    const startsPattern = escapeStringRegexp(path);
-
-    this.query = this.query
-      .and({ path: new RegExp(`^${startsPattern}`) });
-
-    return this;
-  }
-
-  async addConditionForParentNormalization(user) {
-    // determine UserGroup condition
-    let userGroups = null;
-    if (user != null) {
-      const UserGroupRelation = mongoose.model('UserGroupRelation');
-      userGroups = await UserGroupRelation.findAllUserGroupIdsRelatedToUser(user);
-    }
-
-    const grantConditions = [
-      { grant: null },
-      { grant: GRANT_PUBLIC },
-    ];
-
-    if (user != null) {
-      grantConditions.push(
-        { grant: GRANT_OWNER, grantedUsers: user._id },
-      );
-    }
-
-    if (userGroups != null && userGroups.length > 0) {
-      grantConditions.push(
-        { grant: GRANT_USER_GROUP, grantedGroup: { $in: userGroups } },
-      );
-    }
-
-    this.query = this.query
-      .and({
-        $or: grantConditions,
-      });
-
-    return this;
-  }
-
-  async addConditionAsMigratablePages(user) {
-    this.query = this.query
-      .and({
-        $or: [
-          { grant: { $ne: GRANT_RESTRICTED } },
-          { grant: { $ne: GRANT_SPECIFIED } },
-        ],
-      });
-    this.addConditionAsNotMigrated();
-    this.addConditionAsNonRootPage();
-    this.addConditionToExcludeTrashed();
-    await this.addConditionForParentNormalization(user);
-
-    return this;
-  }
-
-  addConditionToFilteringByViewer(user, userGroups, showAnyoneKnowsLink = false, showPagesRestrictedByOwner = false, showPagesRestrictedByGroup = false) {
-    const grantConditions = [
-      { grant: null },
-      { grant: GRANT_PUBLIC },
-    ];
-
-    if (showAnyoneKnowsLink) {
-      grantConditions.push({ grant: GRANT_RESTRICTED });
-    }
-
-    if (showPagesRestrictedByOwner) {
-      grantConditions.push(
-        { grant: GRANT_SPECIFIED },
-        { grant: GRANT_OWNER },
-      );
-    }
-    else if (user != null) {
-      grantConditions.push(
-        { grant: GRANT_SPECIFIED, grantedUsers: user._id },
-        { grant: GRANT_OWNER, grantedUsers: user._id },
-      );
-    }
-
-    if (showPagesRestrictedByGroup) {
-      grantConditions.push(
-        { grant: GRANT_USER_GROUP },
-      );
-    }
-    else if (userGroups != null && userGroups.length > 0) {
-      grantConditions.push(
-        { grant: GRANT_USER_GROUP, grantedGroup: { $in: userGroups } },
-      );
-    }
-
-    this.query = this.query
-      .and({
-        $or: grantConditions,
-      });
-
-    return this;
-  }
-
-  addConditionToPagenate(offset, limit, sortOpt) {
-    this.query = this.query
-      .sort(sortOpt).skip(offset).limit(limit); // eslint-disable-line newline-per-chained-call
-
-    return this;
-  }
-
-  addConditionAsNonRootPage() {
-    this.query = this.query.and({ path: { $ne: '/' } });
-
-    return this;
-  }
-
-  addConditionAsNotMigrated() {
-    this.query = this.query
-      .and({ parent: null });
-
-    return this;
-  }
-
-  addConditionAsMigrated() {
-    this.query = this.query
-      .and(
-        {
-          $or: [
-            { parent: { $ne: null } },
-            { path: '/' },
-          ],
-        },
-      );
-
-    return this;
-  }
-
-  /*
-   * Add this condition when get any ancestor pages including the target's parent
-   */
-  addConditionToSortPagesByDescPath() {
-    this.query = this.query.sort('-path');
-
-    return this;
-  }
-
-  addConditionToSortPagesByAscPath() {
-    this.query = this.query.sort('path');
-
-    return this;
-  }
-
-  addConditionToMinimizeDataForRendering() {
-    this.query = this.query.select('_id path isEmpty grant revision');
-
-    return this;
-  }
-
-  addConditionToListByPathsArray(paths) {
-    this.query = this.query
-      .and({
-        path: {
-          $in: paths,
-        },
-      });
-
-    return this;
-  }
-
-  addConditionToListByPageIdsArray(pageIds) {
-    this.query = this.query
-      .and({
-        _id: {
-          $in: pageIds,
-        },
-      });
-
-    return this;
-  }
-
-  populateDataToList(userPublicFields) {
-    this.query = this.query
-      .populate({
-        path: 'lastUpdateUser',
-        select: userPublicFields,
-      });
-    return this;
-  }
-
-  populateDataToShowRevision(userPublicFields) {
-    this.query = populateDataToShowRevision(this.query, userPublicFields);
-    return this;
-  }
-
-  addConditionToFilteringByParentId(parentId) {
-    this.query = this.query.and({ parent: parentId });
-    return this;
-  }
-
-}
-
 export const getPageSchema = (crowi) => {
 export const getPageSchema = (crowi) => {
   let pageEvent;
   let pageEvent;
 
 
@@ -638,7 +338,7 @@ export const getPageSchema = (crowi) => {
       userGroups = await UserGroupRelation.findAllUserGroupIdsRelatedToUser(user);
       userGroups = await UserGroupRelation.findAllUserGroupIdsRelatedToUser(user);
     }
     }
 
 
-    const queryBuilder = new PageQueryBuilder(baseQuery);
+    const queryBuilder = new this.PageQueryBuilder(baseQuery);
     queryBuilder.addConditionToFilteringByViewer(user, userGroups, true);
     queryBuilder.addConditionToFilteringByViewer(user, userGroups, true);
 
 
     const count = await queryBuilder.query.exec();
     const count = await queryBuilder.query.exec();
@@ -660,7 +360,7 @@ export const getPageSchema = (crowi) => {
       relatedUserGroups = await UserGroupRelation.findAllUserGroupIdsRelatedToUser(user);
       relatedUserGroups = await UserGroupRelation.findAllUserGroupIdsRelatedToUser(user);
     }
     }
 
 
-    const queryBuilder = new PageQueryBuilder(baseQuery, includeEmpty);
+    const queryBuilder = new this.PageQueryBuilder(baseQuery, includeEmpty);
     queryBuilder.addConditionToFilteringByViewer(user, relatedUserGroups, true);
     queryBuilder.addConditionToFilteringByViewer(user, relatedUserGroups, true);
 
 
     return queryBuilder.query.exec();
     return queryBuilder.query.exec();
@@ -668,7 +368,7 @@ export const getPageSchema = (crowi) => {
 
 
   pageSchema.statics.findByIdAndViewerToEdit = async function(id, user, includeEmpty = false) {
   pageSchema.statics.findByIdAndViewerToEdit = async function(id, user, includeEmpty = false) {
     const baseQuery = this.findOne({ _id: id });
     const baseQuery = this.findOne({ _id: id });
-    const queryBuilder = new PageQueryBuilder(baseQuery, includeEmpty);
+    const queryBuilder = new this.PageQueryBuilder(baseQuery, includeEmpty);
 
 
     // add grant conditions
     // add grant conditions
     await addConditionToFilteringByViewerToEdit(queryBuilder, user);
     await addConditionToFilteringByViewerToEdit(queryBuilder, user);
@@ -682,7 +382,7 @@ export const getPageSchema = (crowi) => {
       return null;
       return null;
     }
     }
 
 
-    const builder = new PageQueryBuilder(this.findOne({ path }), includeEmpty);
+    const builder = new this.PageQueryBuilder(this.findOne({ path }), includeEmpty);
 
 
     return builder.query.exec();
     return builder.query.exec();
   };
   };
@@ -713,7 +413,7 @@ export const getPageSchema = (crowi) => {
       relatedUserGroups = await UserGroupRelation.findAllUserGroupIdsRelatedToUser(user);
       relatedUserGroups = await UserGroupRelation.findAllUserGroupIdsRelatedToUser(user);
     }
     }
 
 
-    const queryBuilder = new PageQueryBuilder(baseQuery, includeEmpty);
+    const queryBuilder = new this.PageQueryBuilder(baseQuery, includeEmpty);
     queryBuilder.addConditionToFilteringByViewer(user, relatedUserGroups);
     queryBuilder.addConditionToFilteringByViewer(user, relatedUserGroups);
 
 
     return queryBuilder.query.exec();
     return queryBuilder.query.exec();
@@ -723,7 +423,7 @@ export const getPageSchema = (crowi) => {
    * find pages that is match with `path` and its descendants
    * find pages that is match with `path` and its descendants
    */
    */
   pageSchema.statics.findListWithDescendants = async function(path, user, option = {}, includeEmpty = false) {
   pageSchema.statics.findListWithDescendants = async function(path, user, option = {}, includeEmpty = false) {
-    const builder = new PageQueryBuilder(this.find(), includeEmpty);
+    const builder = new this.PageQueryBuilder(this.find(), includeEmpty);
     builder.addConditionToListWithDescendants(path, option);
     builder.addConditionToListWithDescendants(path, option);
 
 
     return findListFromBuilderAndViewer(builder, user, false, option);
     return findListFromBuilderAndViewer(builder, user, false, option);
@@ -737,7 +437,7 @@ export const getPageSchema = (crowi) => {
       return null;
       return null;
     }
     }
 
 
-    const builder = new PageQueryBuilder(this.find(), includeEmpty);
+    const builder = new this.PageQueryBuilder(this.find(), includeEmpty);
     builder.addConditionToListWithDescendants(page.path, option);
     builder.addConditionToListWithDescendants(page.path, option);
 
 
     // add grant conditions
     // add grant conditions
@@ -758,7 +458,7 @@ export const getPageSchema = (crowi) => {
    * find pages that start with `path`
    * find pages that start with `path`
    */
    */
   pageSchema.statics.findListByStartWith = async function(path, user, option, includeEmpty = false) {
   pageSchema.statics.findListByStartWith = async function(path, user, option, includeEmpty = false) {
-    const builder = new PageQueryBuilder(this.find(), includeEmpty);
+    const builder = new this.PageQueryBuilder(this.find(), includeEmpty);
     builder.addConditionToListByStartWith(path, option);
     builder.addConditionToListByStartWith(path, option);
 
 
     return findListFromBuilderAndViewer(builder, user, false, option);
     return findListFromBuilderAndViewer(builder, user, false, option);
@@ -773,7 +473,7 @@ export const getPageSchema = (crowi) => {
    */
    */
   pageSchema.statics.findListByCreator = async function(targetUser, currentUser, option) {
   pageSchema.statics.findListByCreator = async function(targetUser, currentUser, option) {
     const opt = Object.assign({ sort: 'createdAt', desc: -1 }, option);
     const opt = Object.assign({ sort: 'createdAt', desc: -1 }, option);
-    const builder = new PageQueryBuilder(this.find({ creator: targetUser._id }));
+    const builder = new this.PageQueryBuilder(this.find({ creator: targetUser._id }));
 
 
     let showAnyoneKnowsLink = null;
     let showAnyoneKnowsLink = null;
     if (targetUser != null && currentUser != null) {
     if (targetUser != null && currentUser != null) {
@@ -787,7 +487,7 @@ export const getPageSchema = (crowi) => {
     const User = crowi.model('User');
     const User = crowi.model('User');
 
 
     const opt = Object.assign({}, option);
     const opt = Object.assign({}, option);
-    const builder = new PageQueryBuilder(this.find({ _id: { $in: ids } }), shouldIncludeEmpty);
+    const builder = new this.PageQueryBuilder(this.find({ _id: { $in: ids } }), shouldIncludeEmpty);
 
 
     builder.addConditionToPagenate(opt.offset, opt.limit);
     builder.addConditionToPagenate(opt.offset, opt.limit);
 
 
@@ -1089,7 +789,7 @@ export const getPageSchema = (crowi) => {
   };
   };
 
 
   pageSchema.statics.applyScopesToDescendantsAsyncronously = async function(parentPage, user) {
   pageSchema.statics.applyScopesToDescendantsAsyncronously = async function(parentPage, user) {
-    const builder = new PageQueryBuilder(this.find());
+    const builder = new this.PageQueryBuilder(this.find());
     builder.addConditionToListWithDescendants(parentPage.path);
     builder.addConditionToListWithDescendants(parentPage.path);
 
 
     // add grant conditions
     // add grant conditions
@@ -1117,7 +817,7 @@ export const getPageSchema = (crowi) => {
   };
   };
 
 
   pageSchema.statics.findListByPathsArray = async function(paths, includeEmpty = false) {
   pageSchema.statics.findListByPathsArray = async function(paths, includeEmpty = false) {
-    const queryBuilder = new PageQueryBuilder(this.find(), includeEmpty);
+    const queryBuilder = new this.PageQueryBuilder(this.find(), includeEmpty);
     queryBuilder.addConditionToListByPathsArray(paths);
     queryBuilder.addConditionToListByPathsArray(paths);
 
 
     return await queryBuilder.query.exec();
     return await queryBuilder.query.exec();
@@ -1215,7 +915,5 @@ export const getPageSchema = (crowi) => {
   pageSchema.statics.GRANT_USER_GROUP = GRANT_USER_GROUP;
   pageSchema.statics.GRANT_USER_GROUP = GRANT_USER_GROUP;
   pageSchema.statics.PAGE_GRANT_ERROR = PAGE_GRANT_ERROR;
   pageSchema.statics.PAGE_GRANT_ERROR = PAGE_GRANT_ERROR;
 
 
-  pageSchema.statics.PageQueryBuilder = PageQueryBuilder;
-
   return pageSchema;
   return pageSchema;
 };
 };

+ 377 - 82
packages/app/src/server/models/page.ts

@@ -5,13 +5,14 @@ import mongoose, {
 } from 'mongoose';
 } from 'mongoose';
 import mongoosePaginate from 'mongoose-paginate-v2';
 import mongoosePaginate from 'mongoose-paginate-v2';
 import uniqueValidator from 'mongoose-unique-validator';
 import uniqueValidator from 'mongoose-unique-validator';
+import escapeStringRegexp from 'escape-string-regexp';
 import nodePath from 'path';
 import nodePath from 'path';
-import { getOrCreateModel, pagePathUtils } from '@growi/core';
+import { getOrCreateModel, pagePathUtils, pathUtils } from '@growi/core';
 
 
 import loggerFactory from '../../utils/logger';
 import loggerFactory from '../../utils/logger';
 import Crowi from '../crowi';
 import Crowi from '../crowi';
 import { IPage } from '../../interfaces/page';
 import { IPage } from '../../interfaces/page';
-import { getPageSchema, PageQueryBuilder } from './obsolete-page';
+import { getPageSchema, extractToAncestorsPaths, populateDataToShowRevision } from './obsolete-page';
 import { ObjectIdLike } from '~/server/interfaces/mongoose-utils';
 import { ObjectIdLike } from '~/server/interfaces/mongoose-utils';
 import { PageRedirectModel } from './page-redirect';
 import { PageRedirectModel } from './page-redirect';
 
 
@@ -43,14 +44,18 @@ type TargetAndAncestorsResult = {
 export type CreateMethod = (path: string, body: string, user, options) => Promise<PageDocument & { _id: any }>
 export type CreateMethod = (path: string, body: string, user, options) => Promise<PageDocument & { _id: any }>
 export interface PageModel extends Model<PageDocument> {
 export interface PageModel extends Model<PageDocument> {
   [x: string]: any; // for obsolete methods
   [x: string]: any; // for obsolete methods
-  createEmptyPagesByPaths(paths: string[], publicOnly?: boolean): Promise<void>
-  getParentAndFillAncestors(path: string): Promise<PageDocument & { _id: any }>
+  createEmptyPagesByPaths(paths: string[], onlyMigratedAsExistingPages?: boolean, publicOnly?: boolean): Promise<void>
+  getParentAndFillAncestors(path: string, user): Promise<PageDocument & { _id: any }>
   findByIdsAndViewer(pageIds: string[], user, userGroups?, includeEmpty?: boolean): Promise<PageDocument[]>
   findByIdsAndViewer(pageIds: string[], user, userGroups?, includeEmpty?: boolean): Promise<PageDocument[]>
   findByPathAndViewer(path: string | null, user, userGroups?, useFindOne?: boolean, includeEmpty?: boolean): Promise<PageDocument[]>
   findByPathAndViewer(path: string | null, user, userGroups?, useFindOne?: boolean, includeEmpty?: boolean): Promise<PageDocument[]>
   findTargetAndAncestorsByPathOrId(pathOrId: string): Promise<TargetAndAncestorsResult>
   findTargetAndAncestorsByPathOrId(pathOrId: string): Promise<TargetAndAncestorsResult>
   findChildrenByParentPathOrIdAndViewer(parentPathOrId: string, user, userGroups?): Promise<PageDocument[]>
   findChildrenByParentPathOrIdAndViewer(parentPathOrId: string, user, userGroups?): Promise<PageDocument[]>
   findAncestorsChildrenByPathAndViewer(path: string, user, userGroups?): Promise<Record<string, PageDocument[]>>
   findAncestorsChildrenByPathAndViewer(path: string, user, userGroups?): Promise<Record<string, PageDocument[]>>
 
 
+  generateGrantCondition(
+    user, userGroups, showAnyoneKnowsLink?: boolean, showPagesRestrictedByOwner?: boolean, showPagesRestrictedByGroup?: boolean,
+  ): { $or: any[] }
+
   PageQueryBuilder: typeof PageQueryBuilder
   PageQueryBuilder: typeof PageQueryBuilder
 
 
   GRANT_PUBLIC
   GRANT_PUBLIC
@@ -118,12 +123,291 @@ const generateChildrenRegExp = (path: string): RegExp => {
   return new RegExp(`^${path}(\\/[^/]+)\\/?$`);
   return new RegExp(`^${path}(\\/[^/]+)\\/?$`);
 };
 };
 
 
-/*
+class PageQueryBuilder {
+
+  query: any;
+
+  constructor(query, includeEmpty = false) {
+    this.query = query;
+    if (!includeEmpty) {
+      this.query = this.query
+        .and({
+          $or: [
+            { isEmpty: false },
+            { isEmpty: null }, // for v4 compatibility
+          ],
+        });
+    }
+  }
+
+  addConditionToExcludeTrashed() {
+    this.query = this.query
+      .and({
+        $or: [
+          { status: null },
+          { status: STATUS_PUBLISHED },
+        ],
+      });
+
+    return this;
+  }
+
+  /**
+   * generate the query to find the pages '{path}/*' and '{path}' self.
+   * If top page, return without doing anything.
+   */
+  addConditionToListWithDescendants(path: string, option?) {
+    // No request is set for the top page
+    if (isTopPage(path)) {
+      return this;
+    }
+
+    const pathNormalized = pathUtils.normalizePath(path);
+    const pathWithTrailingSlash = pathUtils.addTrailingSlash(path);
+
+    const startsPattern = escapeStringRegexp(pathWithTrailingSlash);
+
+    this.query = this.query
+      .and({
+        $or: [
+          { path: pathNormalized },
+          { path: new RegExp(`^${startsPattern}`) },
+        ],
+      });
+
+    return this;
+  }
+
+  /**
+   * generate the query to find the pages '{path}/*' (exclude '{path}' self).
+   * If top page, return without doing anything.
+   */
+  addConditionToListOnlyDescendants(path, option) {
+    // No request is set for the top page
+    if (isTopPage(path)) {
+      return this;
+    }
+
+    const pathWithTrailingSlash = pathUtils.addTrailingSlash(path);
+
+    const startsPattern = escapeStringRegexp(pathWithTrailingSlash);
+
+    this.query = this.query
+      .and({ path: new RegExp(`^${startsPattern}`) });
+
+    return this;
+
+  }
+
+  addConditionToListOnlyAncestors(path) {
+    const pathNormalized = pathUtils.normalizePath(path);
+    const ancestorsPaths = extractToAncestorsPaths(pathNormalized);
+
+    this.query = this.query
+      .and({
+        path: {
+          $in: ancestorsPaths,
+        },
+      });
+
+    return this;
+
+  }
+
+  /**
+   * generate the query to find pages that start with `path`
+   *
+   * In normal case, returns '{path}/*' and '{path}' self.
+   * If top page, return without doing anything.
+   *
+   * *option*
+   *   Left for backward compatibility
+   */
+  addConditionToListByStartWith(path, option?) {
+    // No request is set for the top page
+    if (isTopPage(path)) {
+      return this;
+    }
+
+    const startsPattern = escapeStringRegexp(path);
+
+    this.query = this.query
+      .and({ path: new RegExp(`^${startsPattern}`) });
+
+    return this;
+  }
+
+  async addConditionForParentNormalization(user) {
+    // determine UserGroup condition
+    let userGroups;
+    if (user != null) {
+      const UserGroupRelation = mongoose.model('UserGroupRelation') as any; // TODO: Typescriptize model
+      userGroups = await UserGroupRelation.findAllUserGroupIdsRelatedToUser(user);
+    }
+
+    const grantConditions: any[] = [
+      { grant: null },
+      { grant: GRANT_PUBLIC },
+    ];
+
+    if (user != null) {
+      grantConditions.push(
+        { grant: GRANT_OWNER, grantedUsers: user._id },
+      );
+    }
+
+    if (userGroups != null && userGroups.length > 0) {
+      grantConditions.push(
+        { grant: GRANT_USER_GROUP, grantedGroup: { $in: userGroups } },
+      );
+    }
+
+    this.query = this.query
+      .and({
+        $or: grantConditions,
+      });
+
+    return this;
+  }
+
+  async addConditionAsMigratablePages(user) {
+    this.query = this.query
+      .and({
+        $or: [
+          { grant: { $ne: GRANT_RESTRICTED } },
+          { grant: { $ne: GRANT_SPECIFIED } },
+        ],
+      });
+    this.addConditionAsNotMigrated();
+    this.addConditionAsNonRootPage();
+    this.addConditionToExcludeTrashed();
+    await this.addConditionForParentNormalization(user);
+
+    return this;
+  }
+
+  addConditionToFilteringByViewer(user, userGroups, showAnyoneKnowsLink = false, showPagesRestrictedByOwner = false, showPagesRestrictedByGroup = false) {
+    const condition = generateGrantCondition(user, userGroups, showAnyoneKnowsLink, showPagesRestrictedByOwner, showPagesRestrictedByGroup);
+
+    this.query = this.query
+      .and(condition);
+
+    return this;
+  }
+
+  addConditionToPagenate(offset, limit, sortOpt?) {
+    this.query = this.query
+      .sort(sortOpt).skip(offset).limit(limit); // eslint-disable-line newline-per-chained-call
+
+    return this;
+  }
+
+  addConditionAsNonRootPage() {
+    this.query = this.query.and({ path: { $ne: '/' } });
+
+    return this;
+  }
+
+  addConditionAsNotMigrated() {
+    this.query = this.query
+      .and({ parent: null });
+
+    return this;
+  }
+
+  addConditionAsMigrated() {
+    this.query = this.query
+      .and(
+        {
+          $or: [
+            { parent: { $ne: null } },
+            { path: '/' },
+          ],
+        },
+      );
+
+    return this;
+  }
+
+  /*
+   * Add this condition when get any ancestor pages including the target's parent
+   */
+  addConditionToSortPagesByDescPath() {
+    this.query = this.query.sort('-path');
+
+    return this;
+  }
+
+  addConditionToSortPagesByAscPath() {
+    this.query = this.query.sort('path');
+
+    return this;
+  }
+
+  addConditionToMinimizeDataForRendering() {
+    this.query = this.query.select('_id path isEmpty grant revision descendantCount');
+
+    return this;
+  }
+
+  addConditionToListByPathsArray(paths) {
+    this.query = this.query
+      .and({
+        path: {
+          $in: paths,
+        },
+      });
+
+    return this;
+  }
+
+  addConditionToListByPageIdsArray(pageIds) {
+    this.query = this.query
+      .and({
+        _id: {
+          $in: pageIds,
+        },
+      });
+
+    return this;
+  }
+
+  populateDataToList(userPublicFields) {
+    this.query = this.query
+      .populate({
+        path: 'lastUpdateUser',
+        select: userPublicFields,
+      });
+    return this;
+  }
+
+  populateDataToShowRevision(userPublicFields) {
+    this.query = populateDataToShowRevision(this.query, userPublicFields);
+    return this;
+  }
+
+  addConditionToFilteringByParentId(parentId) {
+    this.query = this.query.and({ parent: parentId });
+    return this;
+  }
+
+}
+
+/**
  * Create empty pages if the page in paths didn't exist
  * Create empty pages if the page in paths didn't exist
+ * @param onlyMigratedAsExistingPages Determine whether to include non-migrated pages as existing pages. If a page exists,
+ * an empty page will not be created at that page's path.
  */
  */
-schema.statics.createEmptyPagesByPaths = async function(paths: string[], publicOnly = false): Promise<void> {
+schema.statics.createEmptyPagesByPaths = async function(paths: string[], user: any | null, onlyMigratedAsExistingPages = true): Promise<void> {
   // find existing parents
   // find existing parents
-  const builder = new PageQueryBuilder(this.find(publicOnly ? { grant: GRANT_PUBLIC } : {}, { _id: 0, path: 1 }), true);
+  const builder = new PageQueryBuilder(this.find({}, { _id: 0, path: 1 }), true);
+
+  await this.addConditionToFilteringByViewerToEdit(builder, user);
+
+  if (onlyMigratedAsExistingPages) {
+    builder.addConditionAsMigrated();
+  }
+
   const existingPages = await builder
   const existingPages = await builder
     .addConditionToListByPathsArray(paths)
     .addConditionToListByPathsArray(paths)
     .query
     .query
@@ -167,7 +451,7 @@ schema.statics.createEmptyPage = async function(
  * @param exPage a page document to be replaced
  * @param exPage a page document to be replaced
  * @returns Promise<void>
  * @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
   // find parent
   const parent = await this.findOne({ _id: exPage.parent });
   const parent = await this.findOne({ _id: exPage.parent });
   if (parent == null) {
   if (parent == null) {
@@ -207,6 +491,8 @@ schema.statics.replaceTargetWithPage = async function(exPage, pageToReplaceWith?
     await this.deleteOne({ _id: exPage._id });
     await this.deleteOne({ _id: exPage._id });
     logger.warn('Deleted empty page since it was replaced with another page.');
     logger.warn('Deleted empty page since it was replaced with another page.');
   }
   }
+
+  return this.findById(newTarget._id);
 };
 };
 
 
 /**
 /**
@@ -215,11 +501,17 @@ schema.statics.replaceTargetWithPage = async function(exPage, pageToReplaceWith?
  * @param path string
  * @param path string
  * @returns Promise<PageDocument>
  * @returns Promise<PageDocument>
  */
  */
-schema.statics.getParentAndFillAncestors = async function(path: string): Promise<PageDocument> {
+schema.statics.getParentAndFillAncestors = async function(path: string, user): Promise<PageDocument> {
   const parentPath = nodePath.dirname(path);
   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
   }
   }
 
 
   /*
   /*
@@ -228,11 +520,11 @@ schema.statics.getParentAndFillAncestors = async function(path: string): Promise
   const ancestorPaths = collectAncestorPaths(path); // paths of parents need to be created
   const ancestorPaths = collectAncestorPaths(path); // paths of parents need to be created
 
 
   // just create ancestors with empty pages
   // just create ancestors with empty pages
-  await this.createEmptyPagesByPaths(ancestorPaths);
+  await this.createEmptyPagesByPaths(ancestorPaths, user);
 
 
   // find ancestors
   // 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)
     .addConditionToListByPathsArray(ancestorPaths)
     .addConditionToSortPagesByDescPath()
     .addConditionToSortPagesByDescPath()
     .query
     .query
@@ -244,15 +536,14 @@ schema.statics.getParentAndFillAncestors = async function(path: string): Promise
   // bulkWrite to update ancestors
   // bulkWrite to update ancestors
   const nonRootAncestors = ancestors.filter(page => !isTopPage(page.path));
   const nonRootAncestors = ancestors.filter(page => !isTopPage(page.path));
   const operations = nonRootAncestors.map((page) => {
   const operations = nonRootAncestors.map((page) => {
-    const { path } = page;
-    const parentPath = nodePath.dirname(path);
+    const parentPath = nodePath.dirname(page.path);
     return {
     return {
       updateOne: {
       updateOne: {
         filter: {
         filter: {
-          path,
+          _id: page._id,
         },
         },
         update: {
         update: {
-          parent: ancestorsMap.get(parentPath),
+          parent: ancestorsMap.get(parentPath)._id,
         },
         },
       },
       },
     };
     };
@@ -315,9 +606,8 @@ schema.statics.findTargetAndAncestorsByPathOrId = async function(pathOrId: strin
   if (!hasSlash(pathOrId)) {
   if (!hasSlash(pathOrId)) {
     const _id = pathOrId;
     const _id = pathOrId;
     const page = await this.findOne({ _id });
     const page = await this.findOne({ _id });
-    if (page == null) throw new Error('Page not found.');
 
 
-    path = page.path;
+    path = page == null ? '/' : page.path;
   }
   }
   else {
   else {
     path = pathOrId;
     path = pathOrId;
@@ -558,6 +848,67 @@ schema.statics.normalizeDescendantCountById = async function(pageId) {
   return this.updateOne({ _id: pageId }, { $set: { descendantCount: sumChildrenDescendantCount + sumChildPages } }, { new: true });
   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 } });
+};
+
+schema.statics.removeEmptyPages = async function(pageIdsToNotRemove: ObjectIdLike[], paths: string[]): Promise<void> {
+  await this.deleteMany({
+    _id: {
+      $nin: pageIdsToNotRemove,
+    },
+    path: {
+      $in: paths,
+    },
+    isEmpty: true,
+  });
+};
+
+schema.statics.PageQueryBuilder = PageQueryBuilder as any; // mongoose does not support constructor type as statics attrs type
+
+export function generateGrantCondition(
+    user, userGroups, showAnyoneKnowsLink = false, showPagesRestrictedByOwner = false, showPagesRestrictedByGroup = false,
+): { $or: any[] } {
+  const grantConditions: AnyObject[] = [
+    { grant: null },
+    { grant: GRANT_PUBLIC },
+  ];
+
+  if (showAnyoneKnowsLink) {
+    grantConditions.push({ grant: GRANT_RESTRICTED });
+  }
+
+  if (showPagesRestrictedByOwner) {
+    grantConditions.push(
+      { grant: GRANT_SPECIFIED },
+      { grant: GRANT_OWNER },
+    );
+  }
+  else if (user != null) {
+    grantConditions.push(
+      { grant: GRANT_SPECIFIED, grantedUsers: user._id },
+      { grant: GRANT_OWNER, grantedUsers: user._id },
+    );
+  }
+
+  if (showPagesRestrictedByGroup) {
+    grantConditions.push(
+      { grant: GRANT_USER_GROUP },
+    );
+  }
+  else if (userGroups != null && userGroups.length > 0) {
+    grantConditions.push(
+      { grant: GRANT_USER_GROUP, grantedGroup: { $in: userGroups } },
+    );
+  }
+
+  return {
+    $or: grantConditions,
+  };
+}
+
+schema.statics.generateGrantCondition = generateGrantCondition;
+
 export type PageCreateOptions = {
 export type PageCreateOptions = {
   format?: string
   format?: string
   grantUserGroupId?: ObjectIdLike
   grantUserGroupId?: ObjectIdLike
@@ -648,7 +999,7 @@ export default (crowi: Crowi): any => {
     }
     }
 
 
     let parentId: IObjectId | string | null = null;
     let parentId: IObjectId | string | null = null;
-    const parent = await Page.getParentAndFillAncestors(path);
+    const parent = await Page.getParentAndFillAncestors(path, user);
     if (!isTopPage(path)) {
     if (!isTopPage(path)) {
       parentId = parent._id;
       parentId = parent._id;
     }
     }
@@ -670,8 +1021,6 @@ export default (crowi: Crowi): any => {
 
 
     let savedPage = await page.save();
     let savedPage = await page.save();
 
 
-    await crowi.pageService.updateDescendantCountOfAncestors(page._id, 1, false);
-
     /*
     /*
      * After save
      * After save
      */
      */
@@ -692,6 +1041,9 @@ export default (crowi: Crowi): any => {
 
 
     pageEvent.emit('create', savedPage, user);
     pageEvent.emit('create', savedPage, user);
 
 
+    // update descendantCount asynchronously
+    await crowi.pageService.updateDescendantCountOfAncestors(savedPage._id, 1, false);
+
     return savedPage;
     return savedPage;
   };
   };
 
 
@@ -709,7 +1061,7 @@ export default (crowi: Crowi): any => {
 
 
     const Revision = mongoose.model('Revision') as any; // TODO: Typescriptize model
     const Revision = mongoose.model('Revision') as any; // TODO: Typescriptize model
     const grant = options.grant || pageData.grant; // use the previous data if absence
     const grant = options.grant || pageData.grant; // use the previous data if absence
-    const grantUserGroupId = options.grantUserGroupId || pageData.grantUserGroupId; // use the previous data if absence
+    const grantUserGroupId: undefined | ObjectIdLike = options.grantUserGroupId ?? pageData.grantedGroup?._id.toString();
     const isSyncRevisionToHackmd = options.isSyncRevisionToHackmd;
     const isSyncRevisionToHackmd = options.isSyncRevisionToHackmd;
     const grantedUserIds = pageData.grantedUserIds || [user._id];
     const grantedUserIds = pageData.grantedUserIds || [user._id];
 
 
@@ -761,60 +1113,3 @@ export default (crowi: Crowi): any => {
 
 
   return getOrCreateModel<PageDocument, PageModel>('Page', schema as any); // TODO: improve type
   return getOrCreateModel<PageDocument, PageModel>('Page', schema as any); // TODO: improve type
 };
 };
-
-/*
- * Aggregation utilities
- */
-// TODO: use the original type when upgraded https://github.com/Automattic/mongoose/blob/master/index.d.ts#L3090
-type PipelineStageMatch = {
-  $match: AnyObject
-};
-
-export const generateGrantCondition = async(
-    user, _userGroups, showAnyoneKnowsLink = false, showPagesRestrictedByOwner = false, showPagesRestrictedByGroup = false,
-): Promise<PipelineStageMatch> => {
-  let userGroups = _userGroups;
-  if (user != null && userGroups == null) {
-    const UserGroupRelation: any = mongoose.model('UserGroupRelation');
-    userGroups = await UserGroupRelation.findAllUserGroupIdsRelatedToUser(user);
-  }
-
-  const grantConditions: AnyObject[] = [
-    { grant: null },
-    { grant: GRANT_PUBLIC },
-  ];
-
-  if (showAnyoneKnowsLink) {
-    grantConditions.push({ grant: GRANT_RESTRICTED });
-  }
-
-  if (showPagesRestrictedByOwner) {
-    grantConditions.push(
-      { grant: GRANT_SPECIFIED },
-      { grant: GRANT_OWNER },
-    );
-  }
-  else if (user != null) {
-    grantConditions.push(
-      { grant: GRANT_SPECIFIED, grantedUsers: user._id },
-      { grant: GRANT_OWNER, grantedUsers: user._id },
-    );
-  }
-
-  if (showPagesRestrictedByGroup) {
-    grantConditions.push(
-      { grant: GRANT_USER_GROUP },
-    );
-  }
-  else if (userGroups != null && userGroups.length > 0) {
-    grantConditions.push(
-      { grant: GRANT_USER_GROUP, grantedGroup: { $in: userGroups } },
-    );
-  }
-
-  return {
-    $match: {
-      $or: grantConditions,
-    },
-  };
-};

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

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

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

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

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

@@ -1,5 +1,7 @@
 import loggerFactory from '~/utils/logger';
 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 logger = loggerFactory('growi:routes:apiv3:bookmarks'); // eslint-disable-line no-unused-vars
 
 
 const express = require('express');
 const express = require('express');
@@ -69,7 +71,6 @@ module.exports = (crowi) => {
   const loginRequiredStrictly = require('../../middlewares/login-required')(crowi);
   const loginRequiredStrictly = require('../../middlewares/login-required')(crowi);
   const loginRequired = require('../../middlewares/login-required')(crowi, true);
   const loginRequired = require('../../middlewares/login-required')(crowi, true);
   const csrf = require('../../middlewares/csrf')(crowi);
   const csrf = require('../../middlewares/csrf')(crowi);
-  const apiV3FormValidator = require('../../middlewares/apiv3-form-validator')(crowi);
 
 
   const { Page, Bookmark, User } = crowi.models;
   const { Page, Bookmark, User } = crowi.models;
 
 
@@ -258,6 +259,11 @@ module.exports = (crowi) => {
    */
    */
   router.put('/', accessTokenParser, loginRequiredStrictly, csrf, validator.bookmarks, apiV3FormValidator, async(req, res) => {
   router.put('/', accessTokenParser, loginRequiredStrictly, csrf, validator.bookmarks, apiV3FormValidator, async(req, res) => {
     const { pageId, bool } = req.body;
     const { pageId, bool } = req.body;
+    const userId = req.user?._id;
+
+    if (userId == null) {
+      return res.apiv3Err('A logged in user is required.');
+    }
 
 
     let bookmark;
     let bookmark;
     try {
     try {
@@ -265,15 +271,29 @@ module.exports = (crowi) => {
       if (page == null) {
       if (page == null) {
         return res.apiv3Err(`Page '${pageId}' is not found or forbidden`);
         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 {
       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) {
     catch (err) {
@@ -281,8 +301,10 @@ module.exports = (crowi) => {
       return res.apiv3Err(err, 500);
       return res.apiv3Err(err, 500);
     }
     }
 
 
-    bookmark.depopulate('page');
-    bookmark.depopulate('user');
+    if (bookmark != null) {
+      bookmark.depopulate('page');
+      bookmark.depopulate('user');
+    }
 
 
     return res.apiv3({ bookmark });
     return res.apiv3({ bookmark });
   });
   });

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

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

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

@@ -1,5 +1,7 @@
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 
 
+import { apiV3FormValidator } from '../../middlewares/apiv3-form-validator';
+
 const logger = loggerFactory('growi:routes:apiv3:export');
 const logger = loggerFactory('growi:routes:apiv3:export');
 const fs = require('fs');
 const fs = require('fs');
 
 
@@ -42,7 +44,6 @@ module.exports = (crowi) => {
   const accessTokenParser = require('../../middlewares/access-token-parser')(crowi);
   const accessTokenParser = require('../../middlewares/access-token-parser')(crowi);
   const loginRequired = require('../../middlewares/login-required')(crowi);
   const loginRequired = require('../../middlewares/login-required')(crowi);
   const adminRequired = require('../../middlewares/admin-required')(crowi);
   const adminRequired = require('../../middlewares/admin-required')(crowi);
-  const apiV3FormValidator = require('../../middlewares/apiv3-form-validator')(crowi);
   const csrf = require('../../middlewares/csrf')(crowi);
   const csrf = require('../../middlewares/csrf')(crowi);
 
 
   const { exportService, socketIoService } = 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 { checkForgotPasswordEnabledMiddlewareFactory } from '../forgot-password';
 import httpErrorHandler from '../../middlewares/http-error-handler';
 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
 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 User = crowi.model('User');
   const path = require('path');
   const path = require('path');
   const csrf = require('../../middlewares/csrf')(crowi);
   const csrf = require('../../middlewares/csrf')(crowi);
-  const apiV3FormValidator = require('../../middlewares/apiv3-form-validator')(crowi);
 
 
   const validator = {
   const validator = {
     password: [
     password: [

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

@@ -1,5 +1,7 @@
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 
 
+import { apiV3FormValidator } from '../../middlewares/apiv3-form-validator';
+
 const logger = loggerFactory('growi:routes:apiv3:markdown-setting');
 const logger = loggerFactory('growi:routes:apiv3:markdown-setting');
 
 
 const express = require('express');
 const express = require('express');
@@ -89,7 +91,6 @@ module.exports = (crowi) => {
   const loginRequiredStrictly = require('../../middlewares/login-required')(crowi);
   const loginRequiredStrictly = require('../../middlewares/login-required')(crowi);
   const adminRequired = require('../../middlewares/admin-required')(crowi);
   const adminRequired = require('../../middlewares/admin-required')(crowi);
   const csrf = require('../../middlewares/csrf')(crowi);
   const csrf = require('../../middlewares/csrf')(crowi);
-  const apiV3FormValidator = require('../../middlewares/apiv3-form-validator')(crowi);
 
 
   /**
   /**
    * @swagger
    * @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 { removeNullPropertyFromObject } from '~/utils/object-utils';
 
 
 import UpdatePost from '../../models/update-post';
 import UpdatePost from '../../models/update-post';
+import { apiV3FormValidator } from '../../middlewares/apiv3-form-validator';
 
 
 // eslint-disable-next-line no-unused-vars
 // eslint-disable-next-line no-unused-vars
 const logger = loggerFactory('growi:routes:apiv3:notification-setting');
 const logger = loggerFactory('growi:routes:apiv3:notification-setting');
@@ -90,7 +91,6 @@ module.exports = (crowi) => {
   const loginRequiredStrictly = require('../../middlewares/login-required')(crowi);
   const loginRequiredStrictly = require('../../middlewares/login-required')(crowi);
   const adminRequired = require('../../middlewares/admin-required')(crowi);
   const adminRequired = require('../../middlewares/admin-required')(crowi);
   const csrf = require('../../middlewares/csrf')(crowi);
   const csrf = require('../../middlewares/csrf')(crowi);
-  const apiV3FormValidator = require('../../middlewares/apiv3-form-validator')(crowi);
 
 
   const GlobalNotificationSetting = crowi.model('GlobalNotificationSetting');
   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 mongoose from 'mongoose';
 
 
+import { IPageInfoAll, isIPageInfoForEntity, IPageInfoForListing } from '~/interfaces/page';
+import loggerFactory from '~/utils/logger';
+
 import { PageModel } from '../../models/page';
 import { PageModel } from '../../models/page';
 import ErrorV3 from '../../models/vo/error-apiv3';
 import ErrorV3 from '../../models/vo/error-apiv3';
-import loggerFactory from '../../../utils/logger';
 import Crowi from '../../crowi';
 import Crowi from '../../crowi';
 import { ApiV3Response } from './interfaces/apiv3-response';
 import { ApiV3Response } from './interfaces/apiv3-response';
-import { IPageInfoAll, isIPageInfoForEntity, IPageInfoForListing } from '~/interfaces/page';
 import PageService from '../../service/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');
 const logger = loggerFactory('growi:routes:apiv3:page-tree');
 
 
@@ -31,8 +34,10 @@ const validator = {
     query('id').isMongoId(),
     query('id').isMongoId(),
     query('path').isString(),
     query('path').isString(),
   ], 'id or path is required'),
   ], 'id or path is required'),
-  pageIdsRequired: [
+  infoParams: [
     query('pageIds').isArray().withMessage('pageIds is required'),
     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 => {
 export default (crowi: Crowi): Router => {
   const accessTokenParser = require('../../middlewares/access-token-parser')(crowi);
   const accessTokenParser = require('../../middlewares/access-token-parser')(crowi);
   const loginRequired = require('../../middlewares/login-required')(crowi, true);
   const loginRequired = require('../../middlewares/login-required')(crowi, true);
-  const apiV3FormValidator = require('../../middlewares/apiv3-form-validator')(crowi);
 
 
   const router = express.Router();
   const router = express.Router();
 
 
@@ -82,7 +86,7 @@ export default (crowi: Crowi): Router => {
    * In most cases, using id should be prioritized
    * In most cases, using id should be prioritized
    */
    */
   // eslint-disable-next-line max-len
   // 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 { id, path } = req.query;
 
 
     const Page: PageModel = crowi.model('Page');
     const Page: PageModel = crowi.model('Page');
@@ -98,8 +102,11 @@ export default (crowi: Crowi): Router => {
   });
   });
 
 
   // eslint-disable-next-line max-len
   // 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 Page = mongoose.model('Page') as unknown as PageModel;
     const Bookmark = crowi.model('Bookmark');
     const Bookmark = crowi.model('Bookmark');
@@ -111,8 +118,15 @@ export default (crowi: Crowi): Router => {
 
 
       const foundIds = pages.map(page => page._id);
       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> = {};
       const idToPageInfoMap: Record<string, IPageInfoAll> = {};
 
 
@@ -122,11 +136,12 @@ export default (crowi: Crowi): Router => {
 
 
         const pageInfo = (!isIPageInfoForEntity(basicPageInfo))
         const pageInfo = (!isIPageInfoForEntity(basicPageInfo))
           ? basicPageInfo
           ? basicPageInfo
-          // create IPageInfoForList
+          // create IPageInfoForListing
           : {
           : {
             ...basicPageInfo,
             ...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;
           } as IPageInfoForListing;
 
 
         idToPageInfoMap[page._id] = pageInfo;
         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;
   return router;
 };
 };

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

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

+ 15 - 9
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 { 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 logger = loggerFactory('growi:routes:apiv3:pages'); // eslint-disable-line no-unused-vars
 const express = require('express');
 const express = require('express');
 const { pathUtils, pagePathUtils } = require('@growi/core');
 const { pathUtils, pagePathUtils } = require('@growi/core');
@@ -142,7 +144,6 @@ module.exports = (crowi) => {
   const loginRequiredStrictly = require('../../middlewares/login-required')(crowi);
   const loginRequiredStrictly = require('../../middlewares/login-required')(crowi);
   const adminRequired = require('../../middlewares/admin-required')(crowi);
   const adminRequired = require('../../middlewares/admin-required')(crowi);
   const csrf = require('../../middlewares/csrf')(crowi);
   const csrf = require('../../middlewares/csrf')(crowi);
-  const apiV3FormValidator = require('../../middlewares/apiv3-form-validator')(crowi);
 
 
   const Page = crowi.model('Page');
   const Page = crowi.model('Page');
   const User = crowi.model('User');
   const User = crowi.model('User');
@@ -173,8 +174,10 @@ module.exports = (crowi) => {
       body('pageId').isMongoId().withMessage('pageId is required'),
       body('pageId').isMongoId().withMessage('pageId is required'),
       body('revisionId').optional().isMongoId().withMessage('revisionId is required'), // required when v4
       body('revisionId').optional().isMongoId().withMessage('revisionId is required'), // required when v4
       body('newPagePath').isLength({ min: 1 }).withMessage('newPagePath is required'),
       body('newPagePath').isLength({ min: 1 }).withMessage('newPagePath is required'),
+      body('isRecursively').if(value => value != null).isBoolean().withMessage('isRecursively must be boolean'),
       body('isRenameRedirect').if(value => value != null).isBoolean().withMessage('isRenameRedirect must be boolean'),
       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('updateMetadata').if(value => value != null).isBoolean().withMessage('updateMetadata must be boolean'),
+      body('isMoveMode').if(value => value != null).isBoolean().withMessage('isMoveMode must be boolean'),
     ],
     ],
     duplicatePage: [
     duplicatePage: [
       body('pageId').isMongoId().withMessage('pageId is required'),
       body('pageId').isMongoId().withMessage('pageId is required'),
@@ -442,9 +445,9 @@ module.exports = (crowi) => {
    *                  isRenameRedirect:
    *                  isRenameRedirect:
    *                    type: boolean
    *                    type: boolean
    *                    description: whether redirect page
    *                    description: whether redirect page
-   *                  isRemainMetadata:
+   *                  updateMetadata:
    *                    type: boolean
    *                    type: boolean
-   *                    description: whether remain meta data
+   *                    description: whether update meta data
    *                  isRecursively:
    *                  isRecursively:
    *                    type: boolean
    *                    type: boolean
    *                    description: whether rename page with descendants
    *                    description: whether rename page with descendants
@@ -471,8 +474,10 @@ module.exports = (crowi) => {
     let newPagePath = pathUtils.normalizePath(req.body.newPagePath);
     let newPagePath = pathUtils.normalizePath(req.body.newPagePath);
 
 
     const options = {
     const options = {
+      isRecursively: req.body.isRecursively,
       createRedirectPage: req.body.isRenameRedirect,
       createRedirectPage: req.body.isRenameRedirect,
-      updateMetadata: !req.body.isRemainMetadata,
+      updateMetadata: req.body.updateMetadata,
+      isMoveMode: req.body.isMoveMode,
     };
     };
 
 
     if (!isCreatablePage(newPagePath)) {
     if (!isCreatablePage(newPagePath)) {
@@ -489,6 +494,7 @@ module.exports = (crowi) => {
     }
     }
 
 
     let page;
     let page;
+    let renamedPage;
 
 
     try {
     try {
       page = await Page.findByIdAndViewerToEdit(pageId, req.user, true);
       page = await Page.findByIdAndViewerToEdit(pageId, req.user, true);
@@ -505,14 +511,14 @@ module.exports = (crowi) => {
       if (!page.isEmpty && !page.isUpdatable(revisionId)) {
       if (!page.isEmpty && !page.isUpdatable(revisionId)) {
         return res.apiv3Err(new ErrorV3('Someone could update this page, so couldn\'t delete.', 'notfound_or_forbidden'), 409);
         return res.apiv3Err(new ErrorV3('Someone could update this page, so couldn\'t delete.', 'notfound_or_forbidden'), 409);
       }
       }
-      page = await crowi.pageService.renamePage(page, newPagePath, req.user, options);
+      renamedPage = await crowi.pageService.renamePage(page, newPagePath, req.user, options);
     }
     }
     catch (err) {
     catch (err) {
       logger.error(err);
       logger.error(err);
       return res.apiv3Err(new ErrorV3('Failed to update page.', 'unknown'), 500);
       return res.apiv3Err(new ErrorV3('Failed to update page.', 'unknown'), 500);
     }
     }
 
 
-    const result = { page: serializePageSecurely(page) };
+    const result = { page: serializePageSecurely(renamedPage ?? page) };
 
 
     try {
     try {
       // global notification
       // global notification
@@ -718,10 +724,10 @@ module.exports = (crowi) => {
     const limit = parseInt(req.query.limit) || LIMIT_FOR_LIST;
     const limit = parseInt(req.query.limit) || LIMIT_FOR_LIST;
 
 
     try {
     try {
-      const pageData = await Page.findByPath(path);
+      const pageData = await Page.findByPath(path, true);
       const result = await Page.findManageableListWithDescendants(pageData, req.user, { limit });
       const result = await Page.findManageableListWithDescendants(pageData, req.user, { limit });
 
 
-      return res.apiv3({ subordinatedPaths: result });
+      return res.apiv3({ subordinatedPages: result });
     }
     }
     catch (err) {
     catch (err) {
       return res.apiv3Err(new ErrorV3('Failed to update page.', 'unknown'), 500);
       return res.apiv3Err(new ErrorV3('Failed to update page.', 'unknown'), 500);

+ 7 - 2
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 { listLocaleIds } from '~/utils/locale-utils';
 
 
+import { apiV3FormValidator } from '../../middlewares/apiv3-form-validator';
 import EditorSettings from '../../models/editor-settings';
 import EditorSettings from '../../models/editor-settings';
 import InAppNotificationSettings from '../../models/in-app-notification-settings';
 import InAppNotificationSettings from '../../models/in-app-notification-settings';
 
 
@@ -69,14 +70,18 @@ module.exports = (crowi) => {
   const accessTokenParser = require('../../middlewares/access-token-parser')(crowi);
   const accessTokenParser = require('../../middlewares/access-token-parser')(crowi);
   const loginRequiredStrictly = require('../../middlewares/login-required')(crowi);
   const loginRequiredStrictly = require('../../middlewares/login-required')(crowi);
   const csrf = require('../../middlewares/csrf')(crowi);
   const csrf = require('../../middlewares/csrf')(crowi);
-  const apiV3FormValidator = require('../../middlewares/apiv3-form-validator')(crowi);
 
 
   const { User, ExternalAccount } = crowi.models;
   const { User, ExternalAccount } = crowi.models;
 
 
   const validator = {
   const validator = {
     personal: [
     personal: [
       body('name').isString().not().isEmpty(),
       body('name').isString().not().isEmpty(),
-      body('email').isEmail(),
+      body('email')
+        .isEmail()
+        .custom((email) => {
+          if (!User.isEmailValid(email)) throw new Error('email is not included in whitelist');
+          return true;
+        }),
       body('lang').isString().isIn(listLocaleIds()),
       body('lang').isString().isIn(listLocaleIds()),
       body('isEmailPublished').isBoolean(),
       body('isEmailPublished').isBoolean(),
     ],
     ],

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

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

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

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

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

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

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

@@ -2,6 +2,8 @@
 /* eslint-disable no-unused-vars */
 /* eslint-disable no-unused-vars */
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 
 
+import { apiV3FormValidator } from '../../middlewares/apiv3-form-validator';
+
 const logger = loggerFactory('growi:routes:apiv3:share-links');
 const logger = loggerFactory('growi:routes:apiv3:share-links');
 
 
 const express = require('express');
 const express = require('express');
@@ -26,7 +28,6 @@ module.exports = (crowi) => {
   const loginRequired = require('../../middlewares/login-required')(crowi);
   const loginRequired = require('../../middlewares/login-required')(crowi);
   const adminRequired = require('../../middlewares/admin-required')(crowi);
   const adminRequired = require('../../middlewares/admin-required')(crowi);
   const csrf = require('../../middlewares/csrf')(crowi);
   const csrf = require('../../middlewares/csrf')(crowi);
-  const apiV3FormValidator = require('../../middlewares/apiv3-form-validator')(crowi);
   const ShareLink = crowi.model('ShareLink');
   const ShareLink = crowi.model('ShareLink');
   const Page = crowi.model('Page');
   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 loggerFactory from '~/utils/logger';
 
 
+import { apiV3FormValidator } from '../../middlewares/apiv3-form-validator';
+
 // eslint-disable-next-line no-unused-vars
 // eslint-disable-next-line no-unused-vars
 const logger = loggerFactory('growi:routes:apiv3:slack-integration-legacy-setting');
 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 loginRequiredStrictly = require('../../middlewares/login-required')(crowi);
   const adminRequired = require('../../middlewares/admin-required')(crowi);
   const adminRequired = require('../../middlewares/admin-required')(crowi);
   const csrf = require('../../middlewares/csrf')(crowi);
   const csrf = require('../../middlewares/csrf')(crowi);
-  const apiV3FormValidator = require('../../middlewares/apiv3-form-validator')(crowi);
 
 
   /**
   /**
    * @swagger
    * @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 loggerFactory from '~/utils/logger';
 
 
+import { apiV3FormValidator } from '../../middlewares/apiv3-form-validator';
+
 const mongoose = require('mongoose');
 const mongoose = require('mongoose');
 const express = require('express');
 const express = require('express');
 const { body, query, param } = require('express-validator');
 const { body, query, param } = require('express-validator');
@@ -52,7 +54,6 @@ module.exports = (crowi) => {
   const loginRequiredStrictly = require('../../middlewares/login-required')(crowi);
   const loginRequiredStrictly = require('../../middlewares/login-required')(crowi);
   const adminRequired = require('../../middlewares/admin-required')(crowi);
   const adminRequired = require('../../middlewares/admin-required')(crowi);
   const csrf = require('../../middlewares/csrf')(crowi);
   const csrf = require('../../middlewares/csrf')(crowi);
-  const apiV3FormValidator = require('../../middlewares/apiv3-form-validator')(crowi);
   const SlackAppIntegration = crowi.model('SlackAppIntegration');
   const SlackAppIntegration = crowi.model('SlackAppIntegration');
 
 
   const validator = {
   const validator = {

+ 50 - 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 { excludeTestIdsFromTargetIds } from '~/server/util/compare-objectId';
 import UserGroup from '~/server/models/user-group';
 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 logger = loggerFactory('growi:routes:apiv3:user-group'); // eslint-disable-line no-unused-vars
 
 
 const express = require('express');
 const express = require('express');
@@ -31,7 +33,6 @@ module.exports = (crowi) => {
   const loginRequiredStrictly = require('../../middlewares/login-required')(crowi);
   const loginRequiredStrictly = require('../../middlewares/login-required')(crowi);
   const adminRequired = require('../../middlewares/admin-required')(crowi);
   const adminRequired = require('../../middlewares/admin-required')(crowi);
   const csrf = require('../../middlewares/csrf')(crowi);
   const csrf = require('../../middlewares/csrf')(crowi);
-  const apiV3FormValidator = require('../../middlewares/apiv3-form-validator')(crowi);
 
 
   const {
   const {
     UserGroupRelation,
     UserGroupRelation,
@@ -60,6 +61,9 @@ module.exports = (crowi) => {
       query('parentIds', 'parentIds must be an array').optional().isArray(),
       query('parentIds', 'parentIds must be an array').optional().isArray(),
       query('includeGrandChildren', 'parentIds must be boolean').optional().isBoolean(),
       query('includeGrandChildren', 'parentIds must be boolean').optional().isBoolean(),
     ],
     ],
+    ancestorGroup: [
+      query('groupId', 'groupId must be a string').optional().isString(),
+    ],
     selectableGroups: [
     selectableGroups: [
       query('groupId', 'groupId must be a string').optional().isString(),
       query('groupId', 'groupId must be a string').optional().isString(),
     ],
     ],
@@ -126,6 +130,51 @@ module.exports = (crowi) => {
     }
     }
   });
   });
 
 
+  /**
+   * @swagger
+   *
+   *  paths:
+   *    /ancestors:
+   *      get:
+   *        tags: [UserGroup]
+   *        operationId: getAncestorUserGroups
+   *        summary: /ancestors
+   *        description: Get ancestor user groups.
+   *        parameters:
+   *          - name: groupId
+   *            in: query
+   *            required: true
+   *            description: id of userGroup
+   *            schema:
+   *              type: string
+   *        responses:
+   *          200:
+   *            description: userGroups are fetched
+   *            content:
+   *              application/json:
+   *                schema:
+   *                  properties:
+   *                    userGroups:
+   *                      type: array
+   *                      items:
+   *                        type: object
+   *                      description: userGroup objects
+   */
+  router.get('/ancestors', loginRequiredStrictly, adminRequired, validator.ancestorGroup, async(req, res) => {
+    const { groupId } = req.query;
+
+    try {
+      const userGroup = await UserGroup.findById(groupId);
+      const ancestorUserGroups = await UserGroup.findGroupsWithAncestorsRecursively(userGroup, []);
+      return res.apiv3({ ancestorUserGroups });
+    }
+    catch (err) {
+      const msg = 'Error occurred while searching user groups';
+      logger.error(msg, err);
+      return res.apiv3Err(new ErrorV3(msg, 'user-groups-search-failed'));
+    }
+  });
+
   // TODO 85062: improve sort
   // TODO 85062: improve sort
   router.get('/children', loginRequiredStrictly, adminRequired, validator.listChildren, async(req, res) => {
   router.get('/children', loginRequiredStrictly, adminRequired, validator.listChildren, async(req, res) => {
     try {
     try {

+ 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 loggerFactory from '~/utils/logger';
 
 
+import { apiV3FormValidator } from '../../middlewares/apiv3-form-validator';
+
 import UserUISettings from '../../models/user-ui-settings';
 import UserUISettings from '../../models/user-ui-settings';
 import ErrorV3 from '../../models/vo/error-apiv3';
 import ErrorV3 from '../../models/vo/error-apiv3';
 
 
@@ -14,7 +16,6 @@ const router = express.Router();
 module.exports = (crowi) => {
 module.exports = (crowi) => {
   const loginRequiredStrictly = require('../../middlewares/login-required')(crowi);
   const loginRequiredStrictly = require('../../middlewares/login-required')(crowi);
   const csrf = require('../../middlewares/csrf')(crowi);
   const csrf = require('../../middlewares/csrf')(crowi);
-  const apiV3FormValidator = require('../../middlewares/apiv3-form-validator')(crowi);
 
 
   const validatorForPut = [
   const validatorForPut = [
     body('settings').exists().withMessage('The body param \'settings\' is required'),
     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 loggerFactory from '~/utils/logger';
 
 
+import { apiV3FormValidator } from '../../middlewares/apiv3-form-validator';
+
 const logger = loggerFactory('growi:routes:apiv3:user-group');
 const logger = loggerFactory('growi:routes:apiv3:user-group');
 
 
 const express = require('express');
 const express = require('express');
@@ -74,7 +76,6 @@ module.exports = (crowi) => {
   const loginRequiredStrictly = require('../../middlewares/login-required')(crowi);
   const loginRequiredStrictly = require('../../middlewares/login-required')(crowi);
   const adminRequired = require('../../middlewares/admin-required')(crowi);
   const adminRequired = require('../../middlewares/admin-required')(crowi);
   const csrf = require('../../middlewares/csrf')(crowi);
   const csrf = require('../../middlewares/csrf')(crowi);
-  const apiV3FormValidator = require('../../middlewares/apiv3-form-validator')(crowi);
 
 
   const {
   const {
     User,
     User,

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

@@ -203,7 +203,7 @@ module.exports = function(crowi, app) {
     .use(forgotPassword.handleErrosMiddleware));
     .use(forgotPassword.handleErrosMiddleware));
 
 
   app.use('/_private-legacy-pages', express.Router()
   app.use('/_private-legacy-pages', express.Router()
-    .get('/', privateLegacyPages.renderPrivateLegacyPages));
+    .get('/', injectUserUISettings, privateLegacyPages.renderPrivateLegacyPages));
   app.use('/user-activation', express.Router()
   app.use('/user-activation', express.Router()
     .get('/:token', apiLimiter, applicationInstalled, injectUserRegistrationOrderByTokenMiddleware, userActivation.form)
     .get('/:token', apiLimiter, applicationInstalled, injectUserRegistrationOrderByTokenMiddleware, userActivation.form)
     .use(userActivation.tokenErrorHandlerMiddeware));
     .use(userActivation.tokenErrorHandlerMiddeware));

+ 30 - 16
packages/app/src/server/routes/page.js

@@ -4,7 +4,6 @@ import { body } from 'express-validator';
 import mongoose from 'mongoose';
 import mongoose from 'mongoose';
 
 
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
-import { PageQueryBuilder } from '../models/obsolete-page';
 import UpdatePost from '../models/update-post';
 import UpdatePost from '../models/update-post';
 
 
 const { isCreatablePage, isTopPage, isUsersHomePage } = pagePathUtils;
 const { isCreatablePage, isTopPage, isUsersHomePage } = pagePathUtils;
@@ -146,6 +145,8 @@ module.exports = function(crowi, app) {
   const ShareLink = crowi.model('ShareLink');
   const ShareLink = crowi.model('ShareLink');
   const PageRedirect = mongoose.model('PageRedirect');
   const PageRedirect = mongoose.model('PageRedirect');
 
 
+  const { PageQueryBuilder } = Page;
+
   const ApiResponse = require('../util/apiResponse');
   const ApiResponse = require('../util/apiResponse');
   const getToday = require('../util/getToday');
   const getToday = require('../util/getToday');
 
 
@@ -266,12 +267,15 @@ module.exports = function(crowi, app) {
     renderVars.targetAndAncestors = { targetAndAncestors, rootPage };
     renderVars.targetAndAncestors = { targetAndAncestors, rootPage };
   }
   }
 
 
-  function addRenderVarsWhenNotFound(renderVars, pathOrId) {
+  async function addRenderVarsWhenNotFound(renderVars, pathOrId) {
     if (pathOrId == null) {
     if (pathOrId == null) {
       return;
       return;
     }
     }
 
 
     renderVars.notFoundTargetPathOrId = pathOrId;
     renderVars.notFoundTargetPathOrId = pathOrId;
+
+    const isPath = pathOrId.includes('/');
+    renderVars.isNotFoundPermalink = !isPath && !await Page.exists({ _id: pathOrId });
   }
   }
 
 
   function replacePlaceholdersOfTemplate(template, req) {
   function replacePlaceholdersOfTemplate(template, req) {
@@ -329,7 +333,7 @@ module.exports = function(crowi, app) {
     await addRenderVarsForDescendants(renderVars, path, req.user, offset, limit, true);
     await addRenderVarsForDescendants(renderVars, path, req.user, offset, limit, true);
     await addRenderVarsForPageTree(renderVars, pathOrId, req.user);
     await addRenderVarsForPageTree(renderVars, pathOrId, req.user);
 
 
-    addRenderVarsWhenNotFound(renderVars, pathOrId);
+    await addRenderVarsWhenNotFound(renderVars, pathOrId);
 
 
     return res.render(view, renderVars);
     return res.render(view, renderVars);
   }
   }
@@ -344,9 +348,16 @@ module.exports = function(crowi, app) {
       next();
       next();
     }
     }
 
 
+    // empty page
     if (page.isEmpty) {
     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 = {};
     const renderVars = {};
@@ -409,8 +420,13 @@ module.exports = function(crowi, app) {
 
 
     // empty page
     // empty page
     if (page.isEmpty) {
     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
     const { path } = page; // this must exist
@@ -484,8 +500,8 @@ module.exports = function(crowi, app) {
 
 
     const shareLink = await ShareLink.findOne({ _id: linkId }).populate('relatedPage');
     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');
       return res.render('layout-growi/not_found_shared_page');
     }
     }
     if (crowi.configManager.getConfig('crowi', 'security:disableLinkSharing')) {
     if (crowi.configManager.getConfig('crowi', 'security:disableLinkSharing')) {
@@ -583,7 +599,7 @@ module.exports = function(crowi, app) {
     const { redirectFrom } = req.query;
     const { redirectFrom } = req.query;
 
 
     const builder = new PageQueryBuilder(Page.find({ path }));
     const builder = new PageQueryBuilder(Page.find({ path }));
-    await Page.addConditionToFilteringByViewerForList(builder, req.user);
+    await Page.addConditionToFilteringByViewerForList(builder, req.user, true);
 
 
     const pages = await builder.query.lean().clone().exec('find');
     const pages = await builder.query.lean().clone().exec('find');
 
 
@@ -601,10 +617,6 @@ module.exports = function(crowi, app) {
     }
     }
 
 
     if (pages.length === 1) {
     if (pages.length === 1) {
-      if (pages[0].isEmpty) {
-        return _notFound(req, res);
-      }
-
       const url = new URL('https://dummy.origin');
       const url = new URL('https://dummy.origin');
       url.pathname = `/${pages[0]._id}`;
       url.pathname = `/${pages[0]._id}`;
       Object.entries(req.query).forEach(([key, value], i) => {
       Object.entries(req.query).forEach(([key, value], i) => {
@@ -613,7 +625,8 @@ module.exports = function(crowi, app) {
       return res.safeRedirect(urljoin(url.pathname, url.search));
       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) {
     if (isForbidden) {
       req.isForbidden = true;
       req.isForbidden = true;
       return _notFound(req, res);
       return _notFound(req, res);
@@ -1217,7 +1230,8 @@ module.exports = function(crowi, app) {
 
 
   validator.revertRemove = [
   validator.revertRemove = [
     body('recursively')
     body('recursively')
-      .custom(v => v === 'true' || v === true || null)
+      .optional()
+      .custom(v => v === 'true' || v === true || v == null)
       .withMessage('The body property "recursively" must be "true" or true. (Omit param for false)'),
       .withMessage('The body property "recursively" must be "true" or true. (Omit param for false)'),
   ];
   ];
 
 

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

@@ -4,7 +4,6 @@ import escapeStringRegexp from 'escape-string-regexp';
 
 
 import UserGroup from '~/server/models/user-group';
 import UserGroup from '~/server/models/user-group';
 import { PageDocument, PageModel } from '~/server/models/page';
 import { PageDocument, PageModel } from '~/server/models/page';
-import { PageQueryBuilder } from '../models/obsolete-page';
 import { isIncludesObjectId, excludeTestIdsFromTargetIds } from '~/server/util/compare-objectId';
 import { isIncludesObjectId, excludeTestIdsFromTargetIds } from '~/server/util/compare-objectId';
 
 
 const { addTrailingSlash } = pathUtils;
 const { addTrailingSlash } = pathUtils;
@@ -216,6 +215,7 @@ class PageGrantService {
    */
    */
   private async generateComparableAncestor(targetPath: string, includeNotMigratedPages: boolean): Promise<ComparableAncestor> {
   private async generateComparableAncestor(targetPath: string, includeNotMigratedPages: boolean): Promise<ComparableAncestor> {
     const Page = mongoose.model('Page') as unknown as PageModel;
     const Page = mongoose.model('Page') as unknown as PageModel;
+    const { PageQueryBuilder } = Page;
     const UserGroupRelation = mongoose.model('UserGroupRelation') as any; // TODO: Typescriptize model
     const UserGroupRelation = mongoose.model('UserGroupRelation') as any; // TODO: Typescriptize model
 
 
     let applicableUserIds: ObjectIdLike[] | undefined;
     let applicableUserIds: ObjectIdLike[] | undefined;

Some files were not shown because too many files changed in this diff