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

Merge branch 'master' into feat/gw7660-upgrade-openid-client

Luqman Grune 4 лет назад
Родитель
Сommit
1d4b8f7a70
100 измененных файлов с 1405 добавлено и 1008 удалено
  1. 9 9
      .github/workflows/ci-app.yml
  2. 8 8
      .github/workflows/ci-slackbot-proxy.yml
  3. 1 1
      .github/workflows/codeql-analysis.yml
  4. 2 2
      .github/workflows/draft-release.yml
  5. 2 2
      .github/workflows/list-unhealthy-branches.yml
  6. 2 2
      .github/workflows/release-rc.yml
  7. 5 5
      .github/workflows/release-slackbot-proxy.yml
  8. 7 7
      .github/workflows/release.yml
  9. 10 10
      .github/workflows/reusable-app-prod.yml
  10. 3 3
      .github/workflows/reusable-app-reg-suit.yml
  11. 1 1
      lerna.json
  12. 1 1
      package.json
  13. 0 1
      packages/app/.env.development
  14. 11 11
      packages/app/package.json
  15. 13 3
      packages/app/resource/locales/en_US/admin/admin.json
  16. 1 1
      packages/app/resource/locales/en_US/sandbox.md
  17. 5 4
      packages/app/resource/locales/en_US/translation.json
  18. 13 3
      packages/app/resource/locales/ja_JP/admin/admin.json
  19. 1 1
      packages/app/resource/locales/ja_JP/sandbox.md
  20. 5 4
      packages/app/resource/locales/ja_JP/translation.json
  21. 13 3
      packages/app/resource/locales/zh_CN/admin/admin.json
  22. 1 1
      packages/app/resource/locales/zh_CN/sandbox.md
  23. 6 5
      packages/app/resource/locales/zh_CN/translation.json
  24. 0 123
      packages/app/resource/search/mappings-es6-for-ci.json
  25. 118 0
      packages/app/resource/search/mappings-es7-for-ci.json
  26. 3 1
      packages/app/src/client/interfaces/react-bootstrap-typeahead.ts
  27. 2 11
      packages/app/src/client/legacy/crowi-presentation.js
  28. 16 0
      packages/app/src/client/services/AdminGeneralSecurityContainer.js
  29. 2 1
      packages/app/src/client/services/ContextExtractor.tsx
  30. 6 6
      packages/app/src/client/util/GrowiRenderer.js
  31. 0 1
      packages/app/src/client/util/interceptor/detach-code-blocks.js
  32. 3 3
      packages/app/src/client/util/reveal/plugins/growi-renderer.js
  33. 4 4
      packages/app/src/components/Admin/AdminHome/AdminHome.jsx
  34. 93 3
      packages/app/src/components/Admin/App/V5PageMigration.tsx
  35. 114 69
      packages/app/src/components/Admin/Security/SecuritySetting.jsx
  36. 1 5
      packages/app/src/components/Admin/UserGroup/UserGroupDeleteModal.tsx
  37. 13 13
      packages/app/src/components/Admin/UserGroup/UserGroupDropdown.tsx
  38. 13 15
      packages/app/src/components/Admin/UserGroup/UserGroupForm.tsx
  39. 0 1
      packages/app/src/components/Admin/UserGroup/UserGroupPage.tsx
  40. 92 0
      packages/app/src/components/Admin/UserGroupDetail/UpdateParentConfirmModal.tsx
  41. 90 58
      packages/app/src/components/Admin/UserGroupDetail/UserGroupDetailPage.tsx
  42. 1 0
      packages/app/src/components/Admin/UserGroupDetail/UserGroupUserModal.jsx
  43. 3 3
      packages/app/src/components/MyDraftList/Draft.jsx
  44. 6 3
      packages/app/src/components/Navbar/GlobalSearch.tsx
  45. 1 4
      packages/app/src/components/Navbar/PersonalDropdown.jsx
  46. 8 5
      packages/app/src/components/Page.jsx
  47. 14 6
      packages/app/src/components/Page/RevisionLoader.jsx
  48. 7 4
      packages/app/src/components/Page/RevisionRenderer.jsx
  49. 0 110
      packages/app/src/components/Page/TagsInput.jsx
  50. 86 0
      packages/app/src/components/Page/TagsInput.tsx
  51. 2 7
      packages/app/src/components/Page/TrashPageAlert.jsx
  52. 3 3
      packages/app/src/components/PageComment/Comment.jsx
  53. 3 3
      packages/app/src/components/PageComment/CommentEditor.jsx
  54. 3 3
      packages/app/src/components/PageCreateModal.jsx
  55. 12 5
      packages/app/src/components/PageEditor/LinkEditModal.jsx
  56. 0 123
      packages/app/src/components/PageEditor/Preview.jsx
  57. 112 0
      packages/app/src/components/PageEditor/Preview.tsx
  58. 3 2
      packages/app/src/components/PageEditor/PreviewWithSuspense.jsx
  59. 38 4
      packages/app/src/components/PageList/PageListItemL.tsx
  60. 4 23
      packages/app/src/components/PagePathAutoComplete.jsx
  61. 1 0
      packages/app/src/components/PageTimeline.jsx
  62. 14 32
      packages/app/src/components/SearchForm.tsx
  63. 1 1
      packages/app/src/components/SearchPage/SearchControl.tsx
  64. 1 1
      packages/app/src/components/SearchPage/SearchResultList.tsx
  65. 1 2
      packages/app/src/components/SearchPage2/SearchPageBase.tsx
  66. 142 127
      packages/app/src/components/SearchTypeahead.tsx
  67. 1 0
      packages/app/src/components/Sidebar/CustomSidebar.tsx
  68. 1 1
      packages/app/src/components/Sidebar/PageTree/Item.tsx
  69. 2 2
      packages/app/src/interfaces/search.ts
  70. 5 0
      packages/app/src/interfaces/tag.ts
  71. 1 1
      packages/app/src/interfaces/user.ts
  72. 13 1
      packages/app/src/interfaces/websocket.ts
  73. 6 6
      packages/app/src/server/routes/apiv3/index.js
  74. 1 1
      packages/app/src/server/routes/apiv3/user-group.js
  75. 1 1
      packages/app/src/server/routes/search.js
  76. 6 12
      packages/app/src/server/service/config-loader.ts
  77. 19 6
      packages/app/src/server/service/page.ts
  78. 8 2
      packages/app/src/server/service/search-delegator/elasticsearch.ts
  79. 28 9
      packages/app/src/server/service/search.ts
  80. 16 3
      packages/app/src/server/service/user-group.ts
  81. 34 0
      packages/app/src/stores/modal.tsx
  82. 4 4
      packages/app/src/stores/search.tsx
  83. 10 7
      packages/app/src/stores/ui.tsx
  84. 24 0
      packages/app/src/stores/websocket.tsx
  85. 1 1
      packages/app/src/styles/_on-edit.scss
  86. 1 1
      packages/app/src/styles/_page-tree.scss
  87. 2 2
      packages/app/src/styles/_vendor-presentation.scss
  88. 15 14
      packages/app/src/styles/theme/_apply-colors-dark.scss
  89. 11 0
      packages/app/src/styles/theme/_apply-colors-light.scss
  90. 1 28
      packages/app/src/styles/theme/_apply-colors.scss
  91. 35 0
      packages/app/src/styles/theme/_reboot-bootstrap-dropdown.scss
  92. 0 4
      packages/app/src/styles/theme/antarctic.scss
  93. 0 1
      packages/app/src/styles/theme/blackboard.scss
  94. 1 3
      packages/app/src/styles/theme/christmas.scss
  95. 0 7
      packages/app/src/styles/theme/default.scss
  96. 0 5
      packages/app/src/styles/theme/fire-red.scss
  97. 0 5
      packages/app/src/styles/theme/future.scss
  98. 0 1
      packages/app/src/styles/theme/halloween.scss
  99. 2 5
      packages/app/src/styles/theme/hufflepuff.scss
  100. 0 2
      packages/app/src/styles/theme/island.scss

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

@@ -17,9 +17,9 @@ jobs:
         node-version: [16.x]
 
     steps:
-      - uses: actions/checkout@v2
+      - uses: actions/checkout@v3
 
-      - uses: actions/setup-node@v2
+      - uses: actions/setup-node@v3
         with:
           node-version: ${{ matrix.node-version }}
           cache: 'yarn'
@@ -27,7 +27,7 @@ jobs:
 
       - name: Cache/Restore node_modules
         id: cache-dependencies
-        uses: actions/cache@v2
+        uses: actions/cache@v3
         with:
           path: |
             **/node_modules
@@ -71,9 +71,9 @@ jobs:
           - 27017/tcp
 
     steps:
-      - uses: actions/checkout@v2
+      - uses: actions/checkout@v3
 
-      - uses: actions/setup-node@v2
+      - uses: actions/setup-node@v3
         with:
           node-version: ${{ matrix.node-version }}
           cache: 'yarn'
@@ -81,7 +81,7 @@ jobs:
 
       - name: Cache/Restore node_modules
         id: cache-dependencies
-        uses: actions/cache@v2
+        uses: actions/cache@v3
         with:
           path: |
             **/node_modules
@@ -131,9 +131,9 @@ jobs:
           - 27017/tcp
 
     steps:
-      - uses: actions/checkout@v2
+      - uses: actions/checkout@v3
 
-      - uses: actions/setup-node@v2
+      - uses: actions/setup-node@v3
         with:
           node-version: ${{ matrix.node-version }}
           cache: 'yarn'
@@ -141,7 +141,7 @@ jobs:
 
       - name: Cache/Restore node_modules
         id: cache-dependencies
-        uses: actions/cache@v2
+        uses: actions/cache@v3
         with:
           path: |
             **/node_modules

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

@@ -18,9 +18,9 @@ jobs:
         node-version: [16.x]
 
     steps:
-    - uses: actions/checkout@v2
+    - uses: actions/checkout@v3
 
-    - uses: actions/setup-node@v2
+    - uses: actions/setup-node@v3
       with:
         node-version: ${{ matrix.node-version }}
         cache: 'yarn'
@@ -28,7 +28,7 @@ jobs:
 
     - name: Cache/Restore node_modules
       id: cache-dependencies
-      uses: actions/cache@v2
+      uses: actions/cache@v3
       with:
         path: |
           **/node_modules
@@ -76,9 +76,9 @@ jobs:
           MYSQL_DATABASE: growi-slackbot-proxy
 
     steps:
-    - uses: actions/checkout@v2
+    - uses: actions/checkout@v3
 
-    - uses: actions/setup-node@v2
+    - uses: actions/setup-node@v3
       with:
         node-version: ${{ matrix.node-version }}
         cache: 'yarn'
@@ -86,7 +86,7 @@ jobs:
 
     - name: Cache/Restore node_modules
       id: cache-dependencies
-      uses: actions/cache@v2
+      uses: actions/cache@v3
       with:
         path: |
           **/node_modules
@@ -141,9 +141,9 @@ jobs:
           MYSQL_DATABASE: growi-slackbot-proxy
 
     steps:
-    - uses: actions/checkout@v2
+    - uses: actions/checkout@v3
 
-    - uses: actions/setup-node@v2
+    - uses: actions/setup-node@v3
       with:
         node-version: ${{ matrix.node-version }}
         cache: 'yarn'

+ 1 - 1
.github/workflows/codeql-analysis.yml

@@ -35,7 +35,7 @@ jobs:
 
     steps:
     - name: Checkout repository
-      uses: actions/checkout@v2
+      uses: actions/checkout@v3
 
     # Initializes the CodeQL tools for scanning.
     - name: Initialize CodeQL

+ 2 - 2
.github/workflows/draft-release.yml

@@ -16,7 +16,7 @@ jobs:
       RELEASE_DRAFT_BODY: ${{ steps.release-drafter.outputs.body }}
 
     steps:
-      - uses: actions/checkout@v2
+      - uses: actions/checkout@v3
 
       - name: Retrieve information from package.json
         uses: myrotvorets/info-from-package-json-action@1.1.0
@@ -40,7 +40,7 @@ jobs:
     runs-on: ubuntu-latest
 
     steps:
-      - uses: actions/checkout@v2
+      - uses: actions/checkout@v3
         with:
           fetch-depth: 0
 

+ 2 - 2
.github/workflows/list-unhealthy-branches.yml

@@ -10,11 +10,11 @@ jobs:
     runs-on: ubuntu-latest
 
     steps:
-    - uses: actions/checkout@v2
+    - uses: actions/checkout@v3
       with:
         fetch-depth: 0
 
-    - uses: actions/setup-node@v2
+    - uses: actions/setup-node@v3
       with:
         node-version: '16'
 

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

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

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

@@ -12,7 +12,7 @@ jobs:
     runs-on: ubuntu-latest
 
     steps:
-    - uses: actions/checkout@v2
+    - uses: actions/checkout@v3
       with:
         ref: ${{ github.event.pull_request.base.ref }}
 
@@ -57,7 +57,7 @@ jobs:
       uses: docker/setup-buildx-action@v1
 
     - name: Cache Docker layers
-      uses: actions/cache@v2
+      uses: actions/cache@v3
       with:
         path: /tmp/.buildx-cache
         key: ${{ runner.os }}-buildx-slackbot-proxy-${{ github.sha }}
@@ -88,7 +88,7 @@ jobs:
         VERBOSE : true
 
     - name: Update Docker Hub Description
-      uses: peter-evans/dockerhub-description@v2
+      uses: peter-evans/dockerhub-description@v3
       with:
         username: wsmoogle
         password: ${{ secrets.DOCKER_REGISTRY_PASSWORD }}
@@ -102,11 +102,11 @@ jobs:
     runs-on: ubuntu-latest
 
     steps:
-    - uses: actions/checkout@v2
+    - uses: actions/checkout@v3
       with:
         ref: ${{ github.event.pull_request.base.ref }}
 
-    - uses: actions/setup-node@v2
+    - uses: actions/setup-node@v3
       with:
         node-version: '16'
         cache: 'yarn'

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

@@ -18,11 +18,11 @@ jobs:
       RELEASED_VERSION: ${{ steps.package-json.outputs.packageVersion }}
 
     steps:
-    - uses: actions/checkout@v2
+    - uses: actions/checkout@v3
       with:
         ref: ${{ github.event.pull_request.base.ref }}
 
-    - uses: actions/setup-node@v2
+    - uses: actions/setup-node@v3
       with:
         node-version: '16'
         cache: 'yarn'
@@ -79,11 +79,11 @@ jobs:
     runs-on: ubuntu-latest
 
     steps:
-    - uses: actions/checkout@v2
+    - uses: actions/checkout@v3
       with:
         ref: v${{ needs.create-github-release.outputs.RELEASED_VERSION }}
 
-    - uses: actions/setup-node@v2
+    - uses: actions/setup-node@v3
       with:
         node-version: '16'
         cache: 'yarn'
@@ -131,7 +131,7 @@ jobs:
         flavor: [default, nocdn]
 
     steps:
-    - uses: actions/checkout@v2
+    - uses: actions/checkout@v3
       with:
         ref: v${{ needs.create-github-release.outputs.RELEASED_VERSION }}
         lfs: true
@@ -170,7 +170,7 @@ jobs:
       uses: docker/setup-buildx-action@v1
 
     - name: Cache Docker layers
-      uses: actions/cache@v2
+      uses: actions/cache@v3
       with:
         path: /tmp/.buildx-cache
         key: ${{ runner.os }}-buildx-app-${{ matrix.flavor }}-${{ github.sha }}
@@ -197,7 +197,7 @@ jobs:
         mv /tmp/.buildx-cache-new /tmp/.buildx-cache
 
     - name: Update Docker Hub Description
-      uses: peter-evans/dockerhub-description@v2
+      uses: peter-evans/dockerhub-description@v3
       with:
         username: wsmoogle
         password: ${{ secrets.DOCKER_REGISTRY_PASSWORD }}

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

@@ -23,9 +23,9 @@ jobs:
       PROD_FILES: ${{ steps.archive-prod-files.outputs.file }}
 
     steps:
-    - uses: actions/checkout@v2
+    - uses: actions/checkout@v3
 
-    - uses: actions/setup-node@v2
+    - uses: actions/setup-node@v3
       with:
         node-version: ${{ inputs.node-version }}
         cache: 'yarn'
@@ -33,7 +33,7 @@ jobs:
 
     - name: Cache/Restore node_modules
       id: cache-dependencies
-      uses: actions/cache@v2
+      uses: actions/cache@v3
       with:
         path: |
           **/node_modules
@@ -96,16 +96,16 @@ jobs:
         ports:
         - 27017/tcp
       elasticsearch:
-        image: docker.elastic.co/elasticsearch/elasticsearch:6.8.23
+        image: docker.elastic.co/elasticsearch/elasticsearch:7.17.1
         ports:
         - 9200/tcp
         env:
           discovery.type: single-node
 
     steps:
-    - uses: actions/checkout@v2
+    - uses: actions/checkout@v3
 
-    - uses: actions/setup-node@v2
+    - uses: actions/setup-node@v3
       with:
         node-version: ${{ inputs.node-version }}
         cache: 'yarn'
@@ -119,7 +119,7 @@ jobs:
 
     - name: Cache/Restore node_modules (not reused)
       id: cache-dependencies
-      uses: actions/cache@v2
+      uses: actions/cache@v3
       with:
         path: |
           **/node_modules
@@ -193,14 +193,14 @@ jobs:
         ports:
         - 27017/tcp
       elasticsearch:
-        image: docker.elastic.co/elasticsearch/elasticsearch:6.8.23
+        image: docker.elastic.co/elasticsearch/elasticsearch:7.17.1
         ports:
         - 9200/tcp
         env:
           discovery.type: single-node
 
     steps:
-    - uses: actions/checkout@v2
+    - uses: actions/checkout@v3
 
     - name: Get yarn cache dir
       id: yarn-cache-dir
@@ -208,7 +208,7 @@ jobs:
         echo "::set-output name=value::`yarn cache dir --silent`"
 
     - name: Cache/Restore dependencies
-      uses: actions/cache@v2
+      uses: actions/cache@v3
       with:
         path: |
           **/node_modules

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

@@ -45,19 +45,19 @@ jobs:
       EXPECTED_IMAGES_EXIST: ${{ steps.check-expected-images.outputs.EXPECTED_IMAGES_EXIST }}
 
     steps:
-    - uses: actions/checkout@v2
+    - uses: actions/checkout@v3
       with:
         ref: ${{ inputs.checkout-ref }}
         fetch-depth: 0
 
-    - uses: actions/setup-node@v2
+    - uses: actions/setup-node@v3
       with:
         node-version: ${{ inputs.node-version }}
         cache: 'yarn'
         cache-dependency-path: '**/yarn.lock'
 
     - name: Cache/Restore node_modules
-      uses: actions/cache@v2
+      uses: actions/cache@v3
       with:
         path: |
           **/node_modules

+ 1 - 1
lerna.json

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

+ 1 - 1
package.json

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

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

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

+ 11 - 11
packages/app/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@growi/app",
-  "version": "5.0.0-RC.9",
+  "version": "5.0.0-RC.12",
   "license": "MIT",
   "scripts": {
     "//// for production": "",
@@ -58,15 +58,15 @@
   },
   "dependencies": {
     "@browser-bunyan/console-formatted-stream": "^1.6.2",
-    "@elastic/elasticsearch6": "npm:@elastic/elasticsearch@^6.8.7",
-    "@elastic/elasticsearch7": "npm:@elastic/elasticsearch@^7.16.0",
+    "@elastic/elasticsearch6": "npm:@elastic/elasticsearch@^6.8.8",
+    "@elastic/elasticsearch7": "npm:@elastic/elasticsearch@^7.17.0",
     "@godaddy/terminus": "^4.9.0",
     "@google-cloud/storage": "^5.8.5",
-    "@growi/codemirror-textlint": "^5.0.0-RC.9",
-    "@growi/plugin-attachment-refs": "^5.0.0-RC.9",
-    "@growi/plugin-lsx": "^5.0.0-RC.9",
-    "@growi/plugin-pukiwiki-like-linker": "^5.0.0-RC.9",
-    "@growi/slack": "^5.0.0-RC.9",
+    "@growi/codemirror-textlint": "^5.0.0-RC.12",
+    "@growi/plugin-attachment-refs": "^5.0.0-RC.12",
+    "@growi/plugin-lsx": "^5.0.0-RC.12",
+    "@growi/plugin-pukiwiki-like-linker": "^5.0.0-RC.12",
+    "@growi/slack": "^5.0.0-RC.12",
     "@promster/express": "^7.0.2",
     "@promster/server": "^7.0.4",
     "@slack/events-api": "^3.0.0",
@@ -167,7 +167,7 @@
   },
   "devDependencies": {
     "@alienfast/i18next-loader": "^1.1.4",
-    "@growi/ui": "^5.0.0-RC.9",
+    "@growi/ui": "^5.0.0-RC.12",
     "@handsontable/react": "=2.1.0",
     "@types/compression": "^1.7.0",
     "@types/express": "^4.17.11",
@@ -223,7 +223,7 @@
     "postcss-loader": "^3.0.0",
     "prettier": "^1.19.1",
     "react": "^16.8.3",
-    "react-bootstrap-typeahead": "^3.4.7",
+    "react-bootstrap-typeahead": "^5.2.2",
     "react-codemirror2": "^6.0.0",
     "react-copy-to-clipboard": "^5.0.1",
     "react-dom": "^16.8.3",
@@ -234,7 +234,7 @@
     "react-waypoint": "^10.1.0",
     "reactstrap": "^8.9.0",
     "replacestream": "^4.0.3",
-    "reveal.js": "^3.5.0",
+    "reveal.js": "^4.3.1",
     "sass": "^1.43.4",
     "sass-loader": "^10.1.1",
     "simple-load-script": "^1.0.2",

+ 13 - 3
packages/app/resource/locales/en_US/admin/admin.json

@@ -28,7 +28,10 @@
     "modal_migration_warning": "This process may take long. It is highly recommended that administrators tell users not to create, modify, or delete pages during the conversion.",
     "start_upgrading": "Start converting to v5 compatibility",
     "successfully_started": "Succeeded to start the conversion",
-    "already_upgraded": "You have already completed the conversion to v5 compatibility"
+    "already_upgraded": "You have already completed the conversion to v5 compatibility",
+    "header_upgrading_progress": "Upgrade Progress",
+    "migration_succeeded": "Your upgrade has been successfully completed! Exit maintenance mode and GROWI can be used.",
+    "migration_failed": "Upgrade failed. Please refer to the GROWI docs for information on what to do in the event of failure."
   },
   "maintenance_mode": {
     "maintenance_mode": "Maintenance Mode",
@@ -476,6 +479,7 @@
     "select_parent_group": "Select Parent Group",
     "release_parent_group": "Release parent group",
     "add_modal": {
+      "description": "The added user will also be added to all parent groups.",
       "add_user": "Add a user to the created group",
       "search_option": "Search option",
       "enable_option": "Enable {{option}}",
@@ -486,7 +490,6 @@
     "group_list": "Group list",
     "child_group_list": "Child group list",
     "back_to_list": "Go back to group list",
-    "back_to_ancestors_group": "Go back to ancestors group",
     "basic_info": "Basic info",
     "user_list": "User list",
     "created_group": "Group was created",
@@ -495,13 +498,20 @@
     "remove_from_group": "Remove this user",
     "delete_modal": {
       "header": "Delete group",
-      "desc": "Once deleted, the deleted group and its private pages cannot be retrieved.",
+      "desc": "All child groups under the group will also be deleted. Once deleted, the deleted group and its private pages cannot be retrieved.",
       "dropdown_desc": "Choose an action for private pages",
       "select_group": "Select a group",
       "no_groups": "No groups to select",
       "publish_pages": "Publish all",
       "delete_pages": "Delete all",
       "transfer_pages": "Transfer to another group"
+    },
+    "update_parent_confirm_modal": {
+      "header": "The parent of the group will be changed",
+      "caution_change_parent": "This operation will change the parent of the group \"{{groupName}}\".",
+      "danger_message": "Note that this affects the permissions to view all pages associated with this group.",
+      "force_update_parents_label": "Forcibly add missing users",
+      "force_update_parents_description": "Enable this option to force the addition of missing users to the ancestor groups if they exist after changing a parent group."
     }
   }
 }

+ 1 - 1
packages/app/resource/locales/en_US/sandbox.md

@@ -256,7 +256,7 @@ Both the page description and link address can be displayed on the page.
 Example of Bootstrap4 is [[here>./Bootstrap4]]
 ```
 
-[[../Bootstrap4]]  
+[[./Bootstrap4]]  
 Example of Bootstrap4 is[[here>./Bootstrap4]]
 
 # :pencil: Lists

+ 5 - 4
packages/app/resource/locales/en_US/translation.json

@@ -169,6 +169,8 @@
   "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",
+  "not_allowed_to_see_this_page": "You cannot see this page",
+  "Confirm": "Confirm",
   "personal_dropdown": {
     "home": "Home",
     "settings": "Settings",
@@ -669,13 +671,12 @@
     "page_listing_2_desc": "Show pages that are restricted by User group when listing/searching",
     "page_access_rights": "Page access",
     "page_delete_rights": "Delete rights",
-    "deletion": "Restrict trashing of the selected single page",
+    "page_delete": "Page Delete",
+    "page_delete_completely": "Page Delete Completely",
+    "other_options": "Other options",
     "deletion_explain": "Restricts users who can trash the selected single page.",
-    "complete_deletion": "Restrict complete deletion of the selected single page",
     "complete_deletion_explain": "Restricts users who can completely delete  selected single page.",
-    "recursive_deletion": "Restrict trashing of pages including descendants",
     "recursive_deletion_explain": "Restricts users who can trash pages including descendants.",
-    "recursive_complete_deletion": "Restrict complete deletion of pages including descendants",
     "recursive_complete_deletion_explain": "Restricts users who can completely delete pages including descendants.",
     "inherit": "Inherit(Use the same setting as for a single page)",
     "admin_only": "Admin only",

+ 13 - 3
packages/app/resource/locales/ja_JP/admin/admin.json

@@ -28,7 +28,10 @@
     "modal_migration_warning": "管理者はユーザーに、v5 互換形式への変換中はページを作成・変更・削除しないように伝えることを強くお勧めします。",
     "start_upgrading": "v5 互換形式への変換を開始",
     "successfully_started": "正常に v5 互換形式への変換が開始されました",
-    "already_upgraded": "v5 互換形式への変換は既に完了しています"
+    "already_upgraded": "v5 互換形式への変換は既に完了しています",
+    "header_upgrading_progress": "アップグレード進行度",
+    "migration_succeeded": "アップグレードが正常に完了しました!メンテナンスモードを終了して、GROWI を使用することができます。",
+    "migration_failed": "アップグレードが失敗しました。失敗した場合の対処法は GROWI docs を参照してください。"
   },
   "maintenance_mode": {
     "maintenance_mode": "メンテナンスモード",
@@ -475,6 +478,7 @@
     "select_parent_group": "親グループを選択",
     "release_parent_group": "親グループの解除",
     "add_modal": {
+      "description": "追加したユーザーは、親グループにも追加されます。",
       "add_user": "グループへのユーザー追加",
       "search_option": "検索オプション",
       "enable_option": "{{option}}を有効にする",
@@ -485,7 +489,6 @@
     "group_list": "グループ一覧",
     "child_group_list": "子グループ一覧",
     "back_to_list": "グループ一覧に戻る",
-    "back_to_ancestors_group": "祖先グループに戻る",
     "basic_info": "基本情報",
     "user_list": "ユーザー一覧",
     "created_group": "グループを作成しました",
@@ -494,13 +497,20 @@
     "remove_from_group": "グループから外す",
     "delete_modal": {
       "header": "グループの削除",
-      "desc": "グループ及び限定公開のページの削除を行うと元に戻すことはできませんのでご注意ください。",
+      "desc": "当該グループ配下に存在する子グループも全て削除されます。また、グループ及び限定公開のページの削除を行うと元に戻すことはできませんのでご注意ください。",
       "dropdown_desc": "削除するグループの限定公開ページの処理を選択してください",
       "select_group": "グループを選択してください",
       "no_groups": "グループがありません",
       "publish_pages": "全て公開する",
       "delete_pages": "全て削除する",
       "transfer_pages": "全て他のグループに移譲する"
+    },
+    "update_parent_confirm_modal": {
+      "header": "グループの親が変更されます",
+      "caution_change_parent": "この操作はグループ \"{{groupName}}\" の親を変更します。",
+      "danger_message": "このグループに関連する全てのページの閲覧権限に影響があることに注意してください。",
+      "force_update_parents_label": "強制的に足りないユーザーを追加する",
+      "force_update_parents_description": "このオプションを有効化すると、親グループ変更後に祖先グループに足りないユーザーが存在した場合にそれらのユーザーを強制的に追加することができます"
     }
   }
 }

+ 1 - 1
packages/app/resource/locales/ja_JP/sandbox.md

@@ -255,7 +255,7 @@ ___
 Bootstrap4のExampleは[[こちら>./Bootstrap4]]
 ```
 
-[[../Bootstrap4]]  
+[[./Bootstrap4]]  
 Bootstrap4のExampleは[[こちら>./Bootstrap4]]
 
 # :pencil: Lists

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

@@ -171,6 +171,8 @@
   "Link sharing is disabled": "リンクのシェアは無効化されています",
   "successfully_saved_the_page": "ページが正常に保存されました",
   "you_can_not_create_page_with_this_name": "この名前でページを作成することはできません",
+  "not_allowed_to_see_this_page": "このページは閲覧できません",
+  "Confirm": "確認",
   "personal_dropdown": {
     "home": "ホーム",
     "settings": "設定",
@@ -668,13 +670,12 @@
     "page_listing_2_desc": "ページのリスト表示や検索結果において、特定グループにのみ閲覧制限をしているページをアクセス権のないユーザーにも表示します。",
     "page_access_rights": "ページの閲覧権限",
     "page_delete_rights": "ページの削除権限",
-    "deletion": "ページをゴミ箱に入れる(単体のみの操作)",
+    "page_delete": "ゴミ箱に入れる",
+    "page_delete_completely": "完全に削除する",
+    "other_options": "その他のオプション",
     "deletion_explain": "ページをゴミ箱に入れることができるユーザーを制限します。",
-    "complete_deletion": "ページを完全削除する(単体のみの操作)",
     "complete_deletion_explain": "ページを完全削除することができるユーザーを制限します。",
-    "recursive_deletion": "ページをゴミ箱に入れる(子孫を含む操作)",
     "recursive_deletion_explain": "子孫を含めたページをゴミ箱に入れることができるユーザーを制限します。",
-    "recursive_complete_deletion": "ページを完全削除する(子孫を含む操作)",
     "recursive_complete_deletion_explain": "子孫を含めたページを完全削除することができるユーザーを制限します。",
     "inherit": "単体のみと同じ",
     "admin_only": "管理者のみ可能",

+ 13 - 3
packages/app/resource/locales/zh_CN/admin/admin.json

@@ -28,7 +28,10 @@
     "modal_migration_warning": "这个过程可能需要很长时间。强烈建议管理员告诉用户在转换期间不要创建、修改或删除页面。",
     "start_upgrading": "开始转换为v5兼容性",
     "successfully_started": "成功开始转换",
-    "already_upgraded": "你已经完成了向v5兼容性的转换"
+    "already_upgraded": "你已经完成了向v5兼容性的转换",
+    "header_upgrading_progress": "升级进度",
+    "migration_succeeded": "您的升级已经成功完成! 退出维护模式,可以使用GROWI。",
+    "migration_failed": "升级失败。请参考GROWI的文档,了解在失败情况下该如何处理。"
   },
   "maintenance_mode": {
     "maintenance_mode": "维护模式",
@@ -485,6 +488,7 @@
     "select_parent_group": "选择父组",
     "release_parent_group": "Release parent group",
     "add_modal": {
+      "description": "添加的用户也将被添加到所有的父组。",
       "add_user": "将用户添加到创建的组",
       "search_option": "搜索选项",
       "enable_option": "启用{{option}",
@@ -495,7 +499,6 @@
     "group_list": "组列表",
     "child_group_list": "儿童组名单",
     "back_to_list": "返回组列表",
-    "back_to_ancestors_group": "返回到祖先组",
     "basic_info": "基本信息",
     "user_list": "用户列表",
     "created_group": "已创建组",
@@ -504,13 +507,20 @@
     "remove_from_group": "删除此用户",
     "delete_modal": {
       "header": "删除组",
-      "desc": "删除后,将无法检索已删除的组及其私人页。",
+      "desc": "该组下的所有子组也将被删除。删除后,将无法检索已删除的组及其私人页。",
       "dropdown_desc": "为私人页选择操作",
       "select_group": "选择组",
       "no_groups": "没有可选择的组",
       "publish_pages": "全部发布",
       "delete_pages": "全部删除",
       "transfer_pages": "转移到另一组"
+    },
+    "update_parent_confirm_modal": {
+      "header": "该组的父组被改变",
+      "caution_change_parent": "该操作改变了组的父级,即 \"{{groupName}}\" 。",
+      "danger_message": "注意,查看与该组相关的所有页面的权限会受到影响。",
+      "force_update_parents_label": "强行添加失踪的用户",
+      "force_update_parents_description": "激活这个选项,如果在父组改变后,在祖先组中有缺失的用户,可以强制添加这些用户"
     }
   }
 }

+ 1 - 1
packages/app/resource/locales/zh_CN/sandbox.md

@@ -256,7 +256,7 @@ Both the page description and link address can be displayed on the page.
 Example of Bootstrap4 is[[here>./Bootstrap4]]
 ```
 
-[[../Bootstrap4]]  
+[[./Bootstrap4]]  
 Example of Bootstrap4 is [[here>./Bootstrap4]]
 
 # :pencil: Lists

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

@@ -177,12 +177,14 @@
   "Link sharing is disabled": "你不允许分享该链接",
   "successfully_saved_the_page": "成功地保存了该页面",
   "you_can_not_create_page_with_this_name": "您无法使用此名称创建页面",
+  "not_allowed_to_see_this_page": "你不能看到这个页面",
+  "Confirm": "确定",
 	"form_validation": {
 		"error_message": "有些值不正确",
 		"required": "%s 是必需的",
 		"invalid_syntax": "%s的语法无效。",
     "title_required": "标题是必需的。",
-    "slashed_are_not_yet_supported": "スラッシュを含むタイトルにはまだ対応していません"
+    "slashed_are_not_yet_supported": "目前还不支持包含斜线的标题"
   },
   "not_found_page": {
     "Create Page": "创建页面",
@@ -627,13 +629,12 @@
 		"page_listing_2_desc": "显示列出/搜索时受用户组限制的页面",
     "page_access_rights": "页面访问",
     "page_delete_rights": "删除权限",
-    "deletion": "限制捣毁一个选定的单一页面",
+    "page_delete": "删除",
+    "page_delete_completely": "彻底删除",
+    "other_options": "其他选项",
     "deletion_explain": "限制用户对选定的单一页面进行垃圾处理。",
-    "complete_deletion": "限制完全删除一个选定的单页",
     "complete_deletion_explain": "限制可以完全删除所选单页的用户。",
-    "recursive_deletion": "限制捣毁包括子孙在内的网页",
     "recursive_deletion_explain": "限制用户可以捣毁包括子孙在内的页面。",
-    "recursive_complete_deletion": "限制完全删除包括子孙在内的页面",
     "recursive_complete_deletion_explain": "限制可以完全删除页面的用户,包括子孙。",
     "inherit": "继承(使用与单页相同的设置)。",
 		"admin_only": "仅管理员",

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

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

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

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

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

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

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

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

+ 16 - 0
packages/app/src/client/services/AdminGeneralSecurityContainer.js

@@ -30,6 +30,8 @@ export default class AdminGeneralSecurityContainer extends Container {
       currentPageRecursiveDeletionAuthority: PageRecursiveDeleteConfigValue.Inherit,
       currentPageCompleteDeletionAuthority: PageSingleDeleteCompConfigValue.AdminOnly,
       currentPageRecursiveCompleteDeletionAuthority: PageRecursiveDeleteCompConfigValue.Inherit,
+      expandOtherOptionsForDeletion: false,
+      expandOtherOptionsForCompleteDeletion: false,
       isShowRestrictedByOwner: false,
       isShowRestrictedByGroup: false,
       appSiteUrl: appContainer.config.crowi.url || '',
@@ -147,6 +149,20 @@ export default class AdminGeneralSecurityContainer extends Container {
     this.setState({ currentPageRecursiveCompleteDeletionAuthority: val });
   }
 
+  /**
+   * Switch ExpandOtherOptionsForDeletion
+   */
+  switchExpandOtherOptionsForDeletion() {
+    this.setState({ expandOtherOptionsForDeletion:  !this.state.expandOtherOptionsForDeletion });
+  }
+
+  /**
+   * Switch ExpandOtherOptionsForDeletion
+   */
+  switchExpandOtherOptionsForCompleteDeletion() {
+    this.setState({ expandOtherOptionsForCompleteDeletion:  !this.state.expandOtherOptionsForCompleteDeletion });
+  }
+
   /**
    * Switch showRestrictedByOwner
    */

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

@@ -15,7 +15,7 @@ import {
   usePreferDrawerModeByUser, usePreferDrawerModeOnEditByUser, useSidebarCollapsed, useCurrentSidebarContents, useCurrentProductNavWidth,
   useSelectedGrant, useSelectedGrantGroupId, useSelectedGrantGroupName,
 } from '~/stores/ui';
-import { useSetupGlobalSocket } from '~/stores/websocket';
+import { useSetupGlobalSocket, useSetupGlobalAdminSocket } from '~/stores/websocket';
 import { IUserUISettings } from '~/interfaces/user-ui-settings';
 
 const { isTrashPage: _isTrashPage } = pagePathUtils;
@@ -161,6 +161,7 @@ const ContextExtractorOnce: FC = () => {
 
   // Global Socket
   useSetupGlobalSocket();
+  useSetupGlobalAdminSocket();
 
   return null;
 };

+ 6 - 6
packages/app/src/client/util/GrowiRenderer.js

@@ -135,29 +135,29 @@ export default class GrowiRenderer {
     }
   }
 
-  preProcess(markdown) {
+  preProcess(markdown, context) {
     let processed = markdown;
     for (let i = 0; i < this.preProcessors.length; i++) {
       if (!this.preProcessors[i].process) {
         continue;
       }
-      processed = this.preProcessors[i].process(processed);
+      processed = this.preProcessors[i].process(processed, context);
     }
 
     return processed;
   }
 
-  process(markdown) {
-    return this.md.render(markdown);
+  process(markdown, context) {
+    return this.md.render(markdown, context);
   }
 
-  postProcess(html) {
+  postProcess(html, context) {
     let processed = html;
     for (let i = 0; i < this.postProcessors.length; i++) {
       if (!this.postProcessors[i].process) {
         continue;
       }
-      processed = this.postProcessors[i].process(processed);
+      processed = this.postProcessors[i].process(processed, context);
     }
 
     return processed;

+ 0 - 1
packages/app/src/client/util/interceptor/detach-code-blocks.js

@@ -47,7 +47,6 @@ export class DetachCodeBlockInterceptor extends BasicInterceptor {
 
     const context = Object.assign(args[0]); // clone
     const targetKey = this.getTargetKey(contextName);
-    const currentPagePath = context.currentPagePath; // eslint-disable-line no-unused-vars
 
     context.dcbContextMap = {};
 

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

@@ -66,15 +66,15 @@
         interceptorManager.process('preRender', context)
           .then(() => { return interceptorManager.process('prePreProcess', context) })
           .then(() => {
-            context.markdown = growiRenderer.preProcess(context.markdown);
+            context.markdown = growiRenderer.preProcess(context.markdown, context);
           })
           .then(() => { return interceptorManager.process('postPreProcess', context) })
           .then(() => {
-            context.parsedHTML = growiRenderer.process(context.markdown);
+            context.parsedHTML = growiRenderer.process(context.markdown, context);
           })
           .then(() => { return interceptorManager.process('prePostProcess', context) })
           .then(() => {
-            context.parsedHTML = growiRenderer.postProcess(context.parsedHTML);
+            context.parsedHTML = growiRenderer.postProcess(context.parsedHTML, context);
           })
           .then(() => { return interceptorManager.process('postPostProcess', context) })
           .then(() => { return interceptorManager.process('preRenderHtml', context) })

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

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

+ 93 - 3
packages/app/src/components/Admin/App/V5PageMigration.tsx

@@ -1,19 +1,75 @@
-import React, { FC, useState } from 'react';
+import React, {
+  FC, useCallback, useEffect, useState,
+} from 'react';
 import { useTranslation } from 'react-i18next';
 import { ConfirmModal } from './ConfirmModal';
 import AdminAppContainer from '../../../client/services/AdminAppContainer';
 import { withUnstatedContainers } from '../../UnstatedUtils';
 import { toastSuccess, toastError } from '../../../client/util/apiNotification';
+import { useGlobalAdminSocket } from '~/stores/websocket';
+import LabeledProgressBar from '../Common/LabeledProgressBar';
+import {
+  SocketEventName, PMStartedData, PMMigratingData, PMErrorCountData, PMEndedData,
+} from '~/interfaces/websocket';
 
 type Props = {
   adminAppContainer: typeof AdminAppContainer & { v5PageMigrationHandler: () => Promise<{ isV5Compatible: boolean }> },
 }
 
 const V5PageMigration: FC<Props> = (props: Props) => {
+  // Modal
   const [isV5PageMigrationModalShown, setIsV5PageMigrationModalShown] = useState(false);
-  const { adminAppContainer } = props;
+  // Progress bar
+  const [isInProgress, setProgressing] = useState<boolean | undefined>(undefined); // use false as ended
+  const [total, setTotal] = useState<number>(0);
+  const [skip, setSkip] = useState<number>(0);
+  const [current, setCurrent] = useState<number>(0);
+  const [isSucceeded, setSucceeded] = useState<boolean | undefined>(undefined);
+
+  const { data: adminSocket } = useGlobalAdminSocket();
   const { t } = useTranslation();
 
+  const { adminAppContainer } = props;
+
+  /*
+   * Local components
+   */
+  const renderResultMessage = useCallback((isSucceeded: boolean) => {
+    return (
+      <>
+        {
+          isSucceeded
+            ? <p className="text-success p-1">{t('admin:v5_page_migration.migration_succeeded')}</p>
+            : <p className="text-danger p-1">{t('admin:v5_page_migration.migration_failed')}</p>
+        }
+      </>
+    );
+  }, [t]);
+
+  const renderProgressBar = () => {
+    if (isInProgress == null) {
+      return <></>;
+    }
+
+    return (
+      <>
+        {
+          isSucceeded != null && renderResultMessage(isSucceeded)
+        }
+        <LabeledProgressBar
+          header={t('admin:v5_page_migration.header_upgrading_progress')}
+          currentCount={current}
+          errorsCount={skip}
+          totalCount={total}
+          isInProgress={isInProgress}
+        />
+      </>
+    );
+  };
+
+  /*
+   * Functions
+   */
   const onConfirm = async() => {
     setIsV5PageMigrationModalShown(false);
     try {
@@ -29,6 +85,39 @@ const V5PageMigration: FC<Props> = (props: Props) => {
     }
   };
 
+  /*
+   * Use Effect
+   */
+  // Setup Admin Socket
+  useEffect(() => {
+    adminSocket?.once(SocketEventName.PMStarted, (data: PMStartedData) => {
+      setProgressing(true);
+      setTotal(data.total);
+    });
+
+    adminSocket?.on(SocketEventName.PMMigrating, (data: PMMigratingData) => {
+      setProgressing(true);
+      setCurrent(data.count);
+    });
+
+    adminSocket?.on(SocketEventName.PMErrorCount, (data: PMErrorCountData) => {
+      setProgressing(true);
+      setSkip(data.skip);
+    });
+
+    adminSocket?.once(SocketEventName.PMEnded, (data: PMEndedData) => {
+      setProgressing(false);
+      setSucceeded(data.isSucceeded);
+    });
+
+    return () => {
+      adminSocket?.off(SocketEventName.PMStarted);
+      adminSocket?.off(SocketEventName.PMMigrating);
+      adminSocket?.off(SocketEventName.PMErrorCount);
+      adminSocket?.off(SocketEventName.PMEnded);
+    };
+  }, [adminSocket]);
+
   return (
     <>
       <ConfirmModal
@@ -48,9 +137,10 @@ const V5PageMigration: FC<Props> = (props: Props) => {
           {t('admin:v5_page_migration.migration_note')}
         </span>
       </p>
+      {renderProgressBar()}
       <div className="row my-3">
         <div className="mx-auto">
-          <button type="button" className="btn btn-warning" onClick={() => setIsV5PageMigrationModalShown(true)}>
+          <button type="button" className="btn btn-warning" onClick={() => setIsV5PageMigrationModalShown(true)} disabled={isInProgress != null}>
             {t('admin:v5_page_migration.upgrade_to_v5')}
           </button>
         </div>

+ 114 - 69
packages/app/src/components/Admin/Security/SecuritySetting.jsx

@@ -1,6 +1,7 @@
 /* eslint-disable react/no-danger */
 import React from 'react';
 import PropTypes from 'prop-types';
+import { Collapse } from 'reactstrap';
 import { withTranslation } from 'react-i18next';
 
 import { validateDeleteConfigs } from '~/utils/page-delete-config';
@@ -64,6 +65,7 @@ class SecuritySetting extends React.Component {
     this.putSecuritySetting = this.putSecuritySetting.bind(this);
     this.getRecursiveDeletionConfigState = this.getRecursiveDeletionConfigState.bind(this);
     this.setDeletionConfigState = this.setDeletionConfigState.bind(this);
+    this.renderPageDeletePermission = this.renderPageDeletePermission.bind(this);
     this.renderPageDeletePermissionDropdown = this.renderPageDeletePermissionDropdown.bind(this);
   }
 
@@ -120,70 +122,120 @@ class SecuritySetting extends React.Component {
 
   renderPageDeletePermissionDropdown(currentState, setState, deletionType, isButtonDisabled) {
     const { t } = this.props;
+    return (
+      <div className="dropdown">
+        <button
+          className="btn btn-outline-secondary dropdown-toggle text-right"
+          type="button"
+          id="dropdownMenuButton"
+          data-toggle="dropdown"
+          aria-haspopup="true"
+          aria-expanded="true"
+        >
+          <span className="float-left">
+            {currentState === PageDeleteConfigValue.Inherit && t('security_setting.inherit')}
+            {(currentState === PageDeleteConfigValue.Anyone || currentState == null) && t('security_setting.anyone')}
+            {currentState === PageDeleteConfigValue.AdminOnly && t('security_setting.admin_only')}
+            {currentState === PageDeleteConfigValue.AdminAndAuthor && t('security_setting.admin_and_author')}
+          </span>
+        </button>
+        <div className="dropdown-menu" aria-labelledby="dropdownMenuButton">
+          {
+            isRecursiveDeletion(deletionType)
+              ? (
+                <button
+                  className="dropdown-item"
+                  type="button"
+                  onClick={() => { this.setDeletionConfigState(PageDeleteConfigValue.Inherit, setState, deletionType) }}
+                >
+                  {t('security_setting.inherit')}
+                </button>
+              )
+              : (
+                <button
+                  className="dropdown-item"
+                  type="button"
+                  onClick={() => { this.setDeletionConfigState(PageDeleteConfigValue.Anyone, setState, deletionType) }}
+                >
+                  {t('security_setting.anyone')}
+                </button>
+              )
+          }
+          <button
+            className={`dropdown-item ${isButtonDisabled ? 'disabled' : ''}`}
+            type="button"
+            onClick={() => { this.setDeletionConfigState(PageDeleteConfigValue.AdminAndAuthor, setState, deletionType) }}
+          >
+            {t('security_setting.admin_and_author')}
+          </button>
+          <button
+            className="dropdown-item"
+            type="button"
+            onClick={() => { this.setDeletionConfigState(PageDeleteConfigValue.AdminOnly, setState, deletionType) }}
+          >
+            {t('security_setting.admin_only')}
+          </button>
+        </div>
+        <p className="form-text text-muted small">
+          {t(`security_setting.${getDeletionTypeForT(deletionType)}_explain`)}
+        </p>
+      </div>
+    );
+  }
+
+  renderPageDeletePermission(currentState, setState, deletionType, isButtonDisabled) {
+    const { t, adminGeneralSecurityContainer } = this.props;
+
+    const expandOtherOptions = isTypeDeletion(deletionType)
+      ? adminGeneralSecurityContainer.state.expandOtherOptionsForDeletion
+      : adminGeneralSecurityContainer.state.expandOtherOptionsForCompleteDeletion;
+
+    const setExpantOtherOptions = () => {
+      if (isTypeDeletion(deletionType)) {
+        adminGeneralSecurityContainer.switchExpandOtherOptionsForDeletion();
+        return;
+      }
+      adminGeneralSecurityContainer.switchExpandOtherOptionsForCompleteDeletion();
+      return;
+    };
 
     return (
-      <div key={`page-delete-permission-dropdown-${deletionType}`} className="row mb-4">
-        <div className="col-md-3 text-md-right mb-2">
-          <strong>{t(`security_setting.${getDeletionTypeForT(deletionType)}`)}</strong>
+      <div key={`page-delete-permission-dropdown-${deletionType}`} className="row">
+
+        <div className="col-md-3 text-md-right">
+          {!isRecursiveDeletion(deletionType) && isTypeDeletion(deletionType) && (
+            <strong>{t('security_setting.page_delete')}</strong>
+          )}
+          {!isRecursiveDeletion(deletionType) && !isTypeDeletion(deletionType) && (
+            <strong>{t('security_setting.page_delete_completely')}</strong>
+          )}
         </div>
+
         <div className="col-md-6">
-          <div className="dropdown">
-            <button
-              className="btn btn-outline-secondary dropdown-toggle text-right col-12 col-md-auto"
-              type="button"
-              id="dropdownMenuButton"
-              data-toggle="dropdown"
-              aria-haspopup="true"
-              aria-expanded="true"
-            >
-              <span className="float-left">
-                {currentState === PageDeleteConfigValue.Inherit && t('security_setting.inherit')}
-                {(currentState === PageDeleteConfigValue.Anyone || currentState == null) && t('security_setting.anyone')}
-                {currentState === PageDeleteConfigValue.AdminOnly && t('security_setting.admin_only')}
-                {currentState === PageDeleteConfigValue.AdminAndAuthor && t('security_setting.admin_and_author')}
-              </span>
-            </button>
-            <div className="dropdown-menu" aria-labelledby="dropdownMenuButton">
-              {
-                isRecursiveDeletion(deletionType)
-                  ? (
-                    <button
-                      className="dropdown-item"
-                      type="button"
-                      onClick={() => { this.setDeletionConfigState(PageDeleteConfigValue.Inherit, setState, deletionType) }}
-                    >
-                      {t('security_setting.inherit')}
-                    </button>
-                  )
-                  : (
-                    <button
-                      className="dropdown-item"
-                      type="button"
-                      onClick={() => { this.setDeletionConfigState(PageDeleteConfigValue.Anyone, setState, deletionType) }}
-                    >
-                      {t('security_setting.anyone')}
-                    </button>
-                  )
-              }
-              <button
-                className={`dropdown-item ${isButtonDisabled ? 'disabled' : ''}`}
-                type="button"
-                onClick={() => { this.setDeletionConfigState(PageDeleteConfigValue.AdminAndAuthor, setState, deletionType) }}
-              >
-                {t('security_setting.admin_and_author')}
-              </button>
-              <button
-                className="dropdown-item"
-                type="button"
-                onClick={() => { this.setDeletionConfigState(PageDeleteConfigValue.AdminOnly, setState, deletionType) }}
-              >
-                {t('security_setting.admin_only')}
-              </button>
-            </div>
-            <p className="form-text text-muted small">
-              {t(`security_setting.${getDeletionTypeForT(deletionType)}_explain`)}
-            </p>
-          </div>
+          {
+            !isRecursiveDeletion(deletionType)
+              ? (
+                <>{this.renderPageDeletePermissionDropdown(currentState, setState, deletionType, isButtonDisabled)}</>
+              )
+              : (
+                <>
+                  <button
+                    type="button"
+                    className="btn btn-link p-0 mb-4"
+                    aria-expanded="false"
+                    onClick={() => setExpantOtherOptions()}
+                  >
+                    <i className={`fa fa-fw fa-arrow-right ${expandOtherOptions ? 'fa-rotate-90' : ''}`}></i>
+                    { t('security_setting.other_options') }
+                  </button>
+                  <Collapse isOpen={expandOtherOptions}>
+                    <div className="pb-4">
+                      {this.renderPageDeletePermissionDropdown(currentState, setState, deletionType, isButtonDisabled)}
+                    </div>
+                  </Collapse>
+                </>
+              )
+          }
         </div>
       </div>
     );
@@ -316,13 +368,6 @@ class SecuritySetting extends React.Component {
         </div>
 
         <h4>{t('security_setting.page_delete_rights')}</h4>
-        <div className="row">
-          <p className="card well col-9">
-            <span className="text-warning">
-              <i className="icon-info"></i> {t('security_setting.page_delete_rights_caution')}
-            </span>
-          </p>
-        </div>
         <div className="row mb-4"></div>
         {/* Render PageDeletePermissionDropdown */}
         {
@@ -330,14 +375,14 @@ class SecuritySetting extends React.Component {
             [currentPageDeletionAuthority, adminGeneralSecurityContainer.changePageDeletionAuthority, DeletionType.Deletion, false],
             // eslint-disable-next-line max-len
             [currentPageRecursiveDeletionAuthority, adminGeneralSecurityContainer.changePageRecursiveDeletionAuthority, DeletionType.RecursiveDeletion, isButtonDisabledForDeletion],
-          ].map(arr => this.renderPageDeletePermissionDropdown(arr[0], arr[1], arr[2], arr[3]))
+          ].map(arr => this.renderPageDeletePermission(arr[0], arr[1], arr[2], arr[3]))
         }
         {
           [
             [currentPageCompleteDeletionAuthority, adminGeneralSecurityContainer.changePageCompleteDeletionAuthority, DeletionType.CompleteDeletion, false],
             // eslint-disable-next-line max-len
             [currentPageRecursiveCompleteDeletionAuthority, adminGeneralSecurityContainer.changePageRecursiveCompleteDeletionAuthority, DeletionType.RecursiveCompleteDeletion, isButtonDisabledForCompleteDeletion],
-          ].map(arr => this.renderPageDeletePermissionDropdown(arr[0], arr[1], arr[2], arr[3]))
+          ].map(arr => this.renderPageDeletePermission(arr[0], arr[1], arr[2], arr[3]))
         }
 
         <h4>{t('security_setting.session')}</h4>

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

@@ -23,7 +23,6 @@ type Props = {
   deleteUserGroup?: IUserGroupHasId,
   onDelete?: (deleteGroupId: string, actionName: string, transferToUserGroupId: string) => Promise<void> | void,
   isShow: boolean,
-  onShow?: (group: IUserGroupHasId) => Promise<void> | void,
   onHide?: () => Promise<void> | void,
 };
 
@@ -194,11 +193,8 @@ const UserGroupDeleteModal: FC<Props> = (props: Props) => {
         <div>
           <span className="font-weight-bold">{t('admin:user_group_management.group_name')}</span> : &quot;{props?.deleteUserGroup?.name || ''}&quot;
         </div>
-        <div className="text-danger mt-5">
+        <div className="text-danger mt-3">
           {t('admin:user_group_management.delete_modal.desc')}
-
-          {/* TODO 85462: Add a note: "All child groups will disappear */}
-
         </div>
       </ModalBody>
       <ModalFooter>

+ 13 - 13
packages/app/src/components/Admin/UserGroup/UserGroupDropdown.tsx

@@ -5,26 +5,26 @@ import { IUserGroupHasId } from '~/interfaces/user';
 
 type Props = {
   selectableUserGroups?: IUserGroupHasId[]
-  onClickAddExistingUserGroupButtonHandler?(userGroup: IUserGroupHasId | null): void
-  onClickCreateUserGroupButtonHandler?(): void
+  onClickAddExistingUserGroupButton?(userGroup: IUserGroupHasId | null): void
+  onClickCreateUserGroupButton?(): void
 };
 
 const UserGroupDropdown: FC<Props> = (props: Props) => {
   const { t } = useTranslation();
 
-  const { selectableUserGroups, onClickAddExistingUserGroupButtonHandler, onClickCreateUserGroupButtonHandler } = props;
+  const { selectableUserGroups, onClickAddExistingUserGroupButton, onClickCreateUserGroupButton } = props;
 
-  const onClickAddExistingUserGroupButton = useCallback((userGroup: IUserGroupHasId) => {
-    if (onClickAddExistingUserGroupButtonHandler != null) {
-      onClickAddExistingUserGroupButtonHandler(userGroup);
+  const onClickAddExistingUserGroupButtonHandler = useCallback((userGroup: IUserGroupHasId) => {
+    if (onClickAddExistingUserGroupButton != null) {
+      onClickAddExistingUserGroupButton(userGroup);
     }
-  }, [onClickAddExistingUserGroupButtonHandler]);
+  }, [onClickAddExistingUserGroupButton]);
 
-  const onClickCreateUserGroupButton = useCallback(() => {
-    if (onClickCreateUserGroupButtonHandler != null) {
-      onClickCreateUserGroupButtonHandler();
+  const onClickCreateUserGroupButtonHandler = useCallback(() => {
+    if (onClickCreateUserGroupButton != null) {
+      onClickCreateUserGroupButton();
     }
-  }, [onClickCreateUserGroupButtonHandler]);
+  }, [onClickCreateUserGroupButton]);
 
   return (
     <>
@@ -44,7 +44,7 @@ const UserGroupDropdown: FC<Props> = (props: Props) => {
                       key={userGroup._id}
                       type="button"
                       className="dropdown-item"
-                      onClick={() => onClickAddExistingUserGroupButton(userGroup)}
+                      onClick={() => onClickAddExistingUserGroupButtonHandler(userGroup)}
                     >
                       {userGroup.name}
                     </button>
@@ -58,7 +58,7 @@ const UserGroupDropdown: FC<Props> = (props: Props) => {
           <button
             className="dropdown-item"
             type="button"
-            onClick={() => onClickCreateUserGroupButton()}
+            onClick={() => onClickCreateUserGroupButtonHandler()}
           >{t('admin:user_group_management.create_group')}
           </button>
         </div>

+ 13 - 15
packages/app/src/components/Admin/UserGroup/UserGroupForm.tsx

@@ -3,15 +3,15 @@ import { useTranslation } from 'react-i18next';
 import dateFnsFormat from 'date-fns/format';
 import { TFunctionResult } from 'i18next';
 
-import { IUserGroup, IUserGroupHasId } from '~/interfaces/user';
+import { IUserGroupHasId } from '~/interfaces/user';
 import { CustomWindow } from '~/interfaces/global';
 import Xss from '~/services/xss';
 
 type Props = {
-  userGroup?: IUserGroupHasId,
+  userGroup: IUserGroupHasId,
   selectableParentUserGroups?: IUserGroupHasId[],
   submitButtonLabel: TFunctionResult;
-  onSubmit?: (userGroupData: Partial<IUserGroup>) => Promise<IUserGroupHasId | void>
+  onSubmit?: (targetGroup: IUserGroupHasId, userGroupData: Partial<IUserGroupHasId>) => Promise<void> | void
 };
 
 const UserGroupForm: FC<Props> = (props: Props) => {
@@ -47,18 +47,16 @@ const UserGroupForm: FC<Props> = (props: Props) => {
     }
   }, [selectedParent, setSelectedParent]);
 
-  const onSubmitHandler = useCallback(async(e) => {
-    e.preventDefault(); // no reload
-
-    if (onSubmit == null) {
-      return;
-    }
-
-    await onSubmit({ name: currentName, description: currentDescription, parent: selectedParent?._id });
-  }, [currentName, currentDescription, selectedParent, onSubmit]);
-
   return (
-    <form onSubmit={onSubmitHandler}>
+    <form onSubmit={(e) => {
+      e.preventDefault();
+      onSubmit?.(props.userGroup, {
+        name: currentName,
+        description: currentDescription,
+        parent: selectedParent,
+      });
+    }}
+    >
 
       <fieldset>
         <h2 className="admin-setting-header">{t('admin:user_group_management.basic_info')}</h2>
@@ -108,7 +106,7 @@ const UserGroupForm: FC<Props> = (props: Props) => {
               id="dropdownMenuButton"
               data-toggle="dropdown"
               className={`
-                btn btn-outline-secondary dropdown-toggle ${selectableParentUserGroups != null && selectableParentUserGroups.length > 0 ? '' : 'disabled'}
+                btn btn-outline-secondary dropdown-toggle mb-3 ${selectableParentUserGroups != null && selectableParentUserGroups.length > 0 ? '' : 'disabled'}
               `}
             >
               {selectedParent?.name ?? t('admin:user_group_management.select_parent_group')}

+ 0 - 1
packages/app/src/components/Admin/UserGroup/UserGroupPage.tsx

@@ -189,7 +189,6 @@ const UserGroupPage: FC = () => {
         deleteUserGroup={selectedUserGroup}
         onDelete={deleteUserGroupById}
         isShow={isDeleteModalShown}
-        onShow={showDeleteModal}
         onHide={hideDeleteModal}
       />
     </div>

+ 92 - 0
packages/app/src/components/Admin/UserGroupDetail/UpdateParentConfirmModal.tsx

@@ -0,0 +1,92 @@
+import React, { FC, useState } from 'react';
+import { useTranslation } from 'react-i18next';
+import {
+  Modal, ModalHeader, ModalBody, ModalFooter,
+} from 'reactstrap';
+
+import { useUpdateUserGroupConfirmModal } from '~/stores/modal';
+
+
+const UpdateParentConfirmModal: FC = () => {
+  const { t } = useTranslation();
+
+  const [isForceUpdate, setForceUpdate] = useState(false);
+
+  const { data: modalStatus, close: closeModal } = useUpdateUserGroupConfirmModal();
+
+  if (modalStatus == null) {
+    closeModal();
+    return <></>;
+  }
+
+  const {
+    isOpened, targetGroup, updateData, onConfirm,
+  } = modalStatus;
+
+  return (
+    <Modal className="modal-md" isOpen={isOpened} toggle={closeModal}>
+      <ModalHeader tag="h4" toggle={closeModal} className="bg-warning text-light">
+        <i className="icon icon-warning"></i> {t('admin:user_group_management.update_parent_confirm_modal.header')}
+      </ModalHeader>
+      {
+        targetGroup != null && updateData != null && updateData?.parent !== undefined ? (
+          <>
+            <ModalBody>
+              <div className="mb-2">
+                <span className="font-weight-bold">{t('admin:user_group_management.group_name')}</span> : &quot;{targetGroup.name}&quot;
+                <hr />
+                {t('admin:user_group_management.update_parent_confirm_modal.caution_change_parent', { groupName: targetGroup.name })}
+              </div>
+              <div className="text-danger mb-3">
+                <i className="icon-exclamation"></i>
+                {t('admin:user_group_management.update_parent_confirm_modal.danger_message')}
+              </div>
+
+              <div className="custom-control custom-checkbox custom-checkbox-primary pl-5">
+                <input
+                  className="custom-control-input"
+                  name="forceUpdateParents"
+                  id="forceUpdateParents"
+                  type="checkbox"
+                  checked={isForceUpdate}
+                  onChange={() => setForceUpdate(!isForceUpdate)}
+                />
+                <label className="custom-control-label" htmlFor="forceUpdateParents">
+                  {t('admin:user_group_management.update_parent_confirm_modal.force_update_parents_label')}
+                  <p className="form-text text-muted mt-0">{t('admin:user_group_management.update_parent_confirm_modal.force_update_parents_description')}</p>
+                </label>
+              </div>
+            </ModalBody>
+            <ModalFooter>
+              <button
+                type="button"
+                className="btn btn-warning"
+                onClick={() => {
+                  onConfirm?.(targetGroup, updateData, isForceUpdate);
+                  closeModal();
+                }}
+              >
+                {t('Confirm')}
+              </button>
+            </ModalFooter>
+          </>
+        ) : (
+          <>
+            <ModalBody>
+              <div>
+                <span className="text-error">Something went wrong. Please try again.</span>
+              </div>
+            </ModalBody>
+            <ModalFooter>
+              <button type="button" onClick={() => closeModal()} className="btn btn-sm btn-secondary">
+                {t('Cancel')}
+              </button>
+            </ModalFooter>
+          </>
+        )
+      }
+    </Modal>
+  );
+};
+
+export default UpdateParentConfirmModal;

+ 90 - 58
packages/app/src/components/Admin/UserGroupDetail/UserGroupDetailPage.tsx

@@ -7,6 +7,7 @@ import UserGroupForm from '../UserGroup/UserGroupForm';
 import UserGroupTable from '../UserGroup/UserGroupTable';
 import UserGroupModal from '../UserGroup/UserGroupModal';
 import UserGroupDeleteModal from '../UserGroup/UserGroupDeleteModal';
+import UpdateParentConfirmModal from './UpdateParentConfirmModal';
 import UserGroupDropdown from '../UserGroup/UserGroupDropdown';
 import UserGroupUserTable from './UserGroupUserTable';
 import UserGroupUserModal from './UserGroupUserModal';
@@ -25,6 +26,7 @@ import {
   useSWRxSelectableParentUserGroups, useSWRxSelectableChildUserGroups, useSWRxAncestorUserGroups,
 } from '~/stores/user-group';
 import { useIsAclEnabled } from '~/stores/context';
+import { useUpdateUserGroupConfirmModal } from '~/stores/modal';
 
 const UserGroupDetailPage: FC = () => {
   const { t } = useTranslation();
@@ -33,7 +35,7 @@ const UserGroupDetailPage: FC = () => {
   /*
    * State (from AdminUserGroupDetailContainer)
    */
-  const [userGroup, setUserGroup] = useState<IUserGroupHasId>(JSON.parse(adminUserGroupDetailElem?.getAttribute('data-user-group') || 'null'));
+  const [currentUserGroup, setUserGroup] = useState<IUserGroupHasId>(JSON.parse(adminUserGroupDetailElem?.getAttribute('data-user-group') || 'null'));
   const [relatedPages, setRelatedPages] = useState<IPageHasId[]>([]); // For page list
   const [searchType, setSearchType] = useState<string>('partial');
   const [isAlsoMailSearched, setAlsoMailSearched] = useState<boolean>(false);
@@ -46,9 +48,9 @@ const UserGroupDetailPage: FC = () => {
   /*
    * Fetch
    */
-  const { data: userGroupPages } = useSWRxUserGroupPages(userGroup._id, 10, 0);
+  const { data: userGroupPages } = useSWRxUserGroupPages(currentUserGroup._id, 10, 0);
 
-  const { data: childUserGroupsList, mutate: mutateChildUserGroups } = useSWRxChildUserGroupList([userGroup._id], true);
+  const { data: childUserGroupsList, mutate: mutateChildUserGroups } = useSWRxChildUserGroupList([currentUserGroup._id], true);
   const childUserGroups = childUserGroupsList != null ? childUserGroupsList.childUserGroups : [];
   const grandChildUserGroups = childUserGroupsList != null ? childUserGroupsList.grandChildUserGroups : [];
   const childUserGroupIds = childUserGroups.map(group => group._id);
@@ -56,13 +58,15 @@ const UserGroupDetailPage: FC = () => {
   const { data: userGroupRelationList, mutate: mutateUserGroupRelations } = useSWRxUserGroupRelationList(childUserGroupIds);
   const childUserGroupRelations = userGroupRelationList != null ? userGroupRelationList : [];
 
-  const { data: selectableParentUserGroups, mutate: mutateSelectableParentUserGroups } = useSWRxSelectableParentUserGroups(userGroup._id);
-  const { data: selectableChildUserGroups, mutate: mutateSelectableChildUserGroups } = useSWRxSelectableChildUserGroups(userGroup._id);
+  const { data: selectableParentUserGroups, mutate: mutateSelectableParentUserGroups } = useSWRxSelectableParentUserGroups(currentUserGroup._id);
+  const { data: selectableChildUserGroups, mutate: mutateSelectableChildUserGroups } = useSWRxSelectableChildUserGroups(currentUserGroup._id);
 
-  const { data: ancestorUserGroups, mutate: mutateAncestorUserGroups } = useSWRxAncestorUserGroups(userGroup._id);
+  const { data: ancestorUserGroups, mutate: mutateAncestorUserGroups } = useSWRxAncestorUserGroups(currentUserGroup._id);
 
   const { data: isAclEnabled } = useIsAclEnabled();
 
+  const { open: openUpdateParentConfirmModal } = useUpdateUserGroupConfirmModal();
+
   /*
    * Function
    */
@@ -80,30 +84,66 @@ const UserGroupDetailPage: FC = () => {
     setSearchType(searchType);
   }, []);
 
-  const updateUserGroup = useCallback(async(UserGroupData: Partial<IUserGroup>) => {
-    try {
-      const res = await apiv3Put<{ userGroup: IUserGroupHasId }>(`/user-groups/${userGroup._id}`, {
-        name: UserGroupData.name,
-        description: UserGroupData.description,
-        parentId: UserGroupData.parent,
-      });
-      const { userGroup: newUserGroup } = res.data;
-      setUserGroup(newUserGroup);
+  const updateUserGroup = useCallback(async(userGroup: IUserGroupHasId, update: Partial<IUserGroupHasId>, forceUpdateParents: boolean) => {
+    if (update.parent == null) {
+      throw Error('"parent" attr must not be null');
+    }
 
-      // mutate
-      mutateAncestorUserGroups();
-      mutateSelectableChildUserGroups();
-      mutateSelectableParentUserGroups();
+    const parentId = typeof update.parent === 'string' ? update.parent : update.parent?._id;
+    const res = await apiv3Put<{ userGroup: IUserGroupHasId }>(`/user-groups/${userGroup._id}`, {
+      name: update.name,
+      description: update.description,
+      parentId,
+      forceUpdateParents,
+    });
+    const { userGroup: updatedUserGroup } = res.data;
+
+    setUserGroup(updatedUserGroup);
+
+    // mutate
+    mutateAncestorUserGroups();
+    mutateSelectableChildUserGroups();
+    mutateSelectableParentUserGroups();
+  }, [setUserGroup, mutateAncestorUserGroups, mutateSelectableChildUserGroups, mutateSelectableParentUserGroups]);
+
+  const onSubmitUpdateGroup = useCallback(
+    async(targetGroup: IUserGroupHasId, userGroupData: Partial<IUserGroupHasId>, forceUpdateParents: boolean): Promise<void> => {
+      try {
+        await updateUserGroup(targetGroup, userGroupData, forceUpdateParents);
+        toastSuccess(t('toaster.update_successed', { target: t('UserGroup') }));
+      }
+      catch {
+        toastError(t('toaster.update_failed', { target: t('UserGroup') }));
+      }
+    },
+    [t, updateUserGroup],
+  );
 
-      toastSuccess(t('toaster.update_successed', { target: t('UserGroup') }));
+  const onClickSubmitForm = useCallback(async(targetGroup: IUserGroupHasId, userGroupData: Partial<IUserGroupHasId>): Promise<void> => {
+    if (userGroupData?.parent === undefined || typeof userGroupData?.parent === 'string') {
+      toastError(t('Something went wrong. Please try again.'));
+      return;
     }
-    catch (err) {
-      toastError(err);
+
+    const prevParentId = typeof targetGroup.parent === 'string' ? targetGroup.parent : (targetGroup.parent?._id || null);
+    const newParentId = typeof userGroupData.parent?._id === 'string' ? userGroupData.parent?._id : null;
+
+    const shouldShowConfirmModal = prevParentId !== newParentId;
+
+    if (shouldShowConfirmModal) { // show confirm modal before submiting
+      await openUpdateParentConfirmModal(
+        targetGroup,
+        userGroupData,
+        onSubmitUpdateGroup,
+      );
     }
-  }, [t, userGroup._id, setUserGroup, mutateAncestorUserGroups]);
+    else { // directly submit
+      await onSubmitUpdateGroup(targetGroup, userGroupData, false);
+    }
+  }, [t, openUpdateParentConfirmModal, onSubmitUpdateGroup]);
 
   const fetchApplicableUsers = useCallback(async(searchWord) => {
-    const res = await apiv3Get(`/user-groups/${userGroup._id}/unrelated-users`, {
+    const res = await apiv3Get(`/user-groups/${currentUserGroup._id}/unrelated-users`, {
       searchWord,
       searchType,
       isAlsoMailSearched,
@@ -117,14 +157,14 @@ const UserGroupDetailPage: FC = () => {
 
   // TODO 85062: will be used in UserGroupUserFormByInput
   const addUserByUsername = useCallback(async(username: string) => {
-    await apiv3Post(`/user-groups/${userGroup._id}/users/${username}`);
+    await apiv3Post(`/user-groups/${currentUserGroup._id}/users/${username}`);
     mutateUserGroupRelations();
-  }, [userGroup, mutateUserGroupRelations]);
+  }, [currentUserGroup, mutateUserGroupRelations]);
 
   const removeUserByUsername = useCallback(async(username: string) => {
-    await apiv3Delete(`/user-groups/${userGroup._id}/users/${username}`);
+    await apiv3Delete(`/user-groups/${currentUserGroup._id}/users/${username}`);
     mutateUserGroupRelations();
-  }, [userGroup, mutateUserGroupRelations]);
+  }, [currentUserGroup, mutateUserGroupRelations]);
 
   const showUpdateModal = useCallback((group: IUserGroupHasId) => {
     setUpdateModalShown(true);
@@ -156,26 +196,16 @@ const UserGroupDetailPage: FC = () => {
     }
   }, [t, mutateChildUserGroups, hideUpdateModal]);
 
-  const onClickAddChildButtonHandler = async(selectedUserGroup: IUserGroupHasId) => {
-    try {
-      await apiv3Put(`/user-groups/${selectedUserGroup._id}`, {
-        name: selectedUserGroup.name,
-        description: selectedUserGroup.description,
-        parentId: userGroup._id,
-        forceUpdateParents: false,
-      });
-
-      // mutate
-      mutateChildUserGroups();
-      mutateSelectableChildUserGroups();
-      mutateSelectableParentUserGroups();
-
-      toastSuccess(t('toaster.update_successed', { target: t('UserGroup') }));
-    }
-    catch (err) {
-      toastError(err);
-    }
-  };
+  const onClickAddExistingUserGroupButtonHandler = useCallback(async(selectedChild: IUserGroupHasId): Promise<void> => {
+    // show confirm modal before submiting
+    await openUpdateParentConfirmModal(
+      selectedChild,
+      {
+        parent: currentUserGroup._id,
+      },
+      onSubmitUpdateGroup,
+    );
+  }, [openUpdateParentConfirmModal, onSubmitUpdateGroup, currentUserGroup]);
 
   const showCreateModal = useCallback(() => {
     setCreateModalShown(true);
@@ -190,7 +220,7 @@ const UserGroupDetailPage: FC = () => {
       await apiv3Post('/user-groups', {
         name: userGroupData.name,
         description: userGroupData.description,
-        parentId: userGroup._id,
+        parentId: currentUserGroup._id,
       });
 
       toastSuccess(t('toaster.update_successed', { target: t('UserGroup') }));
@@ -205,7 +235,7 @@ const UserGroupDetailPage: FC = () => {
     catch (err) {
       toastError(err);
     }
-  }, [t, userGroup, mutateChildUserGroups, mutateSelectableChildUserGroups, mutateSelectableParentUserGroups, hideCreateModal]);
+  }, [t, currentUserGroup, mutateChildUserGroups, mutateSelectableChildUserGroups, mutateSelectableParentUserGroups, hideCreateModal]);
 
   const showDeleteModal = useCallback(async(group: IUserGroupHasId) => {
     setSelectedUserGroup(group);
@@ -240,7 +270,7 @@ const UserGroupDetailPage: FC = () => {
   /*
    * Dependencies
    */
-  if (userGroup == null) {
+  if (currentUserGroup == null) {
     return <></>;
   }
 
@@ -252,8 +282,9 @@ const UserGroupDetailPage: FC = () => {
           {
             ancestorUserGroups != null && ancestorUserGroups.length > 0 && (
               ancestorUserGroups.map((ancestorUserGroup: IUserGroupHasId) => (
-                <li key={ancestorUserGroup._id} className={`breadcrumb-item ${ancestorUserGroup._id === userGroup._id ? 'active' : ''}`} aria-current="page">
-                  { ancestorUserGroup._id === userGroup._id ? (
+                // eslint-disable-next-line max-len
+                <li key={ancestorUserGroup._id} className={`breadcrumb-item ${ancestorUserGroup._id === currentUserGroup._id ? 'active' : ''}`} aria-current="page">
+                  { ancestorUserGroup._id === currentUserGroup._id ? (
                     <>{ancestorUserGroup.name}</>
                   ) : (
                     <a href={`/admin/user-group-detail/${ancestorUserGroup._id}`}>{ancestorUserGroup.name}</a>
@@ -267,10 +298,10 @@ const UserGroupDetailPage: FC = () => {
 
       <div className="mt-4 form-box">
         <UserGroupForm
-          userGroup={userGroup}
+          userGroup={currentUserGroup}
           selectableParentUserGroups={selectableParentUserGroups}
           submitButtonLabel={t('Update')}
-          onSubmit={updateUserGroup}
+          onSubmit={onClickSubmitForm}
         />
       </div>
       <h2 className="admin-setting-header mt-4">{t('admin:user_group_management.user_list')}</h2>
@@ -280,8 +311,8 @@ const UserGroupDetailPage: FC = () => {
       <h2 className="admin-setting-header mt-4">{t('admin:user_group_management.child_group_list')}</h2>
       <UserGroupDropdown
         selectableUserGroups={selectableChildUserGroups}
-        onClickAddExistingUserGroupButtonHandler={onClickAddChildButtonHandler}
-        onClickCreateUserGroupButtonHandler={showCreateModal}
+        onClickAddExistingUserGroupButton={onClickAddExistingUserGroupButtonHandler}
+        onClickCreateUserGroupButton={showCreateModal}
       />
 
       <UserGroupModal
@@ -299,6 +330,8 @@ const UserGroupDetailPage: FC = () => {
         onHide={hideCreateModal}
       />
 
+      <UpdateParentConfirmModal />
+
       <UserGroupTable
         userGroups={childUserGroups}
         childUserGroups={grandChildUserGroups}
@@ -313,7 +346,6 @@ const UserGroupDetailPage: FC = () => {
         deleteUserGroup={selectedUserGroup}
         onDelete={deleteChildUserGroupById}
         isShow={isDeleteModalShown}
-        onShow={showDeleteModal}
         onHide={hideDeleteModal}
       />
 

+ 1 - 0
packages/app/src/components/Admin/UserGroupDetail/UserGroupUserModal.jsx

@@ -23,6 +23,7 @@ class UserGroupUserModal extends React.Component {
           {t('admin:user_group_management.add_modal.add_user') }
         </ModalHeader>
         <ModalBody>
+          <p className="card well">{t('admin:user_group_management.add_modal.description')}</p>
           <div className="p-3">
             <UserGroupUserFormByInput />
           </div>

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

@@ -63,16 +63,16 @@ class Draft extends React.Component {
     const interceptorManager = this.props.appContainer.interceptorManager;
     await interceptorManager.process('prePreProcess', context)
       .then(() => {
-        context.markdown = growiRenderer.preProcess(context.markdown);
+        context.markdown = growiRenderer.preProcess(context.markdown, context);
       })
       .then(() => { return interceptorManager.process('postPreProcess', context) })
       .then(() => {
-        const parsedHTML = growiRenderer.process(context.markdown);
+        const parsedHTML = growiRenderer.process(context.markdown, context);
         context.parsedHTML = parsedHTML;
       })
       .then(() => { return interceptorManager.process('prePostProcess', context) })
       .then(() => {
-        context.parsedHTML = growiRenderer.postProcess(context.parsedHTML);
+        context.parsedHTML = growiRenderer.postProcess(context.parsedHTML, context);
       })
       .then(() => { return interceptorManager.process('postPostProcess', context) })
       .then(() => {

+ 6 - 3
packages/app/src/components/Navbar/GlobalSearch.tsx

@@ -13,6 +13,7 @@ import { IPageWithMeta } from '~/interfaces/page';
 import { withUnstatedContainers } from '../UnstatedUtils';
 
 import SearchForm from '../SearchForm';
+import { useCurrentPagePath } from '~/stores/context';
 
 
 type Props = {
@@ -33,6 +34,8 @@ const GlobalSearch: FC<Props> = (props: Props) => {
   const [isScopeChildren, setScopeChildren] = useState<boolean>(appContainer.getConfig().isSearchScopeChildrenAsDefault);
   const [isFocused, setFocused] = useState<boolean>(false);
 
+  const { data: currentPagePath } = useCurrentPagePath();
+
   const gotoPage = useCallback((data: IPageWithMeta<IPageSearchMeta>[]) => {
     assert(data.length > 0);
 
@@ -40,7 +43,7 @@ const GlobalSearch: FC<Props> = (props: Props) => {
 
     // navigate to page
     if (page != null) {
-      window.location.href = page._id;
+      window.location.href = `/${page._id}`;
     }
   }, []);
 
@@ -51,12 +54,12 @@ const GlobalSearch: FC<Props> = (props: Props) => {
     // construct search query
     let q = text;
     if (isScopeChildren) {
-      q += ` prefix:${window.location.pathname}`;
+      q += ` prefix:${currentPagePath ?? window.location.pathname}`;
     }
     url.searchParams.append('q', q);
 
     window.location.href = url.href;
-  }, [isScopeChildren, text]);
+  }, [currentPagePath, isScopeChildren, text]);
 
   const scopeLabel = isScopeChildren
     ? t('header_search_box.label.This tree')

+ 1 - 4
packages/app/src/components/Navbar/PersonalDropdown.jsx

@@ -44,10 +44,7 @@ const PersonalDropdown = (props) => {
   const logoutHandler = () => {
     const { interceptorManager } = appContainer;
 
-    const context = {
-      user,
-      currentPagePath: decodeURIComponent(window.location.pathname),
-    };
+    const context = {};
     interceptorManager.process('logout', context);
 
     window.location.href = '/logout';

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

@@ -24,7 +24,7 @@ import {
   useEditorMode, useSelectedGrant, useSelectedGrantGroupId, useSelectedGrantGroupName,
 } from '~/stores/ui';
 import { useIsSlackEnabled } from '~/stores/editor';
-import { useSlackChannels } from '~/stores/context';
+import { useCurrentPagePath, useSlackChannels } from '~/stores/context';
 
 const logger = loggerFactory('growi:Page');
 
@@ -143,7 +143,7 @@ class Page extends React.Component {
   }
 
   render() {
-    const { appContainer, pageContainer } = this.props;
+    const { appContainer, pageContainer, pagePath } = this.props;
     const { isMobile } = appContainer;
     const isLoggedIn = appContainer.currentUser != null;
     const { markdown, revisionId } = pageContainer.state;
@@ -152,7 +152,7 @@ class Page extends React.Component {
       <div className={`mb-5 ${isMobile ? 'page-mobile' : ''}`}>
 
         { revisionId != null && (
-          <RevisionRenderer growiRenderer={this.growiRenderer} markdown={markdown} />
+          <RevisionRenderer growiRenderer={this.growiRenderer} markdown={markdown} pagePath={pagePath} />
         )}
 
         { isLoggedIn && (
@@ -170,11 +170,12 @@ class Page extends React.Component {
 }
 
 Page.propTypes = {
+  // TODO: remove this when omitting unstated is completed
   appContainer: PropTypes.instanceOf(AppContainer).isRequired,
   pageContainer: PropTypes.instanceOf(PageContainer).isRequired,
   editorContainer: PropTypes.instanceOf(EditorContainer).isRequired,
 
-  // TODO: remove this when omitting unstated is completed
+  pagePath: PropTypes.string.isRequired,
   editorMode: PropTypes.string.isRequired,
   isSlackEnabled: PropTypes.bool.isRequired,
   slackChannels: PropTypes.string.isRequired,
@@ -184,6 +185,7 @@ Page.propTypes = {
 };
 
 const PageWrapper = (props) => {
+  const { data: currentPagePath } = useCurrentPagePath();
   const { data: editorMode } = useEditorMode();
   const { data: isSlackEnabled } = useIsSlackEnabled();
   const { data: slackChannels } = useSlackChannels();
@@ -191,13 +193,14 @@ const PageWrapper = (props) => {
   const { data: grantGroupId } = useSelectedGrantGroupId();
   const { data: grantGroupName } = useSelectedGrantGroupName();
 
-  if (editorMode == null) {
+  if (currentPagePath == null || editorMode == null) {
     return null;
   }
 
   return (
     <Page
       {...props}
+      pagePath={currentPagePath}
       editorMode={editorMode}
       isSlackEnabled={isSlackEnabled}
       slackChannels={slackChannels}

+ 14 - 6
packages/app/src/components/Page/RevisionLoader.jsx

@@ -1,6 +1,7 @@
 import React from 'react';
 import PropTypes from 'prop-types';
 
+import { withTranslation } from 'react-i18next';
 import { Waypoint } from 'react-waypoint';
 
 import { withUnstatedContainers } from '../UnstatedUtils';
@@ -20,7 +21,7 @@ class LegacyRevisionLoader extends React.Component {
     this.logger = loggerFactory('growi:Page:RevisionLoader');
 
     this.state = {
-      markdown: '',
+      markdown: null,
       isLoading: false,
       isLoaded: false,
       errors: null,
@@ -49,7 +50,7 @@ class LegacyRevisionLoader extends React.Component {
       const res = await this.props.appContainer.apiv3Get(`/revisions/${revisionId}`, { pageId });
 
       this.setState({
-        markdown: res.data.revision.body,
+        markdown: res.data?.revision?.body,
         errors: null,
       });
 
@@ -94,18 +95,23 @@ class LegacyRevisionLoader extends React.Component {
     }
 
     // ----- after load -----
+    const isForbidden = this.state.errors != null && this.state.errors[0].code === 'forbidden-page';
     let markdown = this.state.markdown;
-    if (this.state.errors != null) {
+    if (isForbidden) {
+      markdown = `<i class="icon-exclamation p-1"></i>${this.props.t('not_allowed_to_see_this_page')}`;
+    }
+    else if (this.state.errors != null) {
       const errorMessages = this.state.errors.map((error) => {
-        return `<span class="text-muted"><em>${error.message}</em></span>`;
+        return `<i class="icon-exclamation p-1"></i><span class="text-muted"><em>${error.message}</em></span>`;
       });
-      markdown = errorMessages.join('');
+      markdown = errorMessages.join('\n');
     }
 
     return (
       <RevisionRenderer
         growiRenderer={this.props.growiRenderer}
         markdown={markdown}
+        pagePath={this.props.pagePath}
         highlightKeywords={this.props.highlightKeywords}
       />
     );
@@ -116,13 +122,15 @@ class LegacyRevisionLoader extends React.Component {
 /**
  * Wrapper component for using unstated
  */
-const LegacyRevisionLoaderWrapper = withUnstatedContainers(LegacyRevisionLoader, [AppContainer]);
+const LegacyRevisionLoaderWrapper = withTranslation()(withUnstatedContainers(LegacyRevisionLoader, [AppContainer]));
 
 LegacyRevisionLoader.propTypes = {
   appContainer: PropTypes.instanceOf(AppContainer).isRequired,
+  t: PropTypes.func.isRequired,
 
   growiRenderer: PropTypes.instanceOf(GrowiRenderer).isRequired,
   pageId: PropTypes.string.isRequired,
+  pagePath: PropTypes.string.isRequired,
   revisionId: PropTypes.string.isRequired,
   lazy: PropTypes.bool,
   onRevisionLoaded: PropTypes.func,

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

@@ -28,7 +28,8 @@ class LegacyRevisionRenderer extends React.PureComponent {
   initCurrentRenderingContext() {
     this.currentRenderingContext = {
       markdown: this.props.markdown,
-      currentPagePath: decodeURIComponent(window.location.pathname),
+      pagePath: this.props.pagePath,
+      currentPathname: decodeURIComponent(window.location.pathname),
     };
   }
 
@@ -133,11 +134,11 @@ class LegacyRevisionRenderer extends React.PureComponent {
 
     await interceptorManager.process('preRender', context);
     await interceptorManager.process('prePreProcess', context);
-    context.markdown = growiRenderer.preProcess(context.markdown);
+    context.markdown = growiRenderer.preProcess(context.markdown, context);
     await interceptorManager.process('postPreProcess', context);
-    context.parsedHTML = growiRenderer.process(context.markdown);
+    context.parsedHTML = growiRenderer.process(context.markdown, context);
     await interceptorManager.process('prePostProcess', context);
-    context.parsedHTML = growiRenderer.postProcess(context.parsedHTML);
+    context.parsedHTML = growiRenderer.postProcess(context.parsedHTML, context);
 
     const isMarkdownEmpty = context.markdown.trim().length === 0;
     if (highlightKeywords != null && highlightKeywords.length > 0 && !isMarkdownEmpty) {
@@ -169,6 +170,7 @@ LegacyRevisionRenderer.propTypes = {
   appContainer: PropTypes.instanceOf(AppContainer).isRequired,
   growiRenderer: PropTypes.instanceOf(GrowiRenderer).isRequired,
   markdown: PropTypes.string.isRequired,
+  pagePath: PropTypes.string.isRequired,
   highlightKeywords: PropTypes.oneOfType([PropTypes.string, PropTypes.arrayOf(PropTypes.string)]),
   additionalClassName: PropTypes.string,
 };
@@ -187,6 +189,7 @@ const RevisionRenderer = (props) => {
 RevisionRenderer.propTypes = {
   growiRenderer: PropTypes.instanceOf(GrowiRenderer).isRequired,
   markdown: PropTypes.string.isRequired,
+  pagePath: PropTypes.string.isRequired,
   highlightKeywords: PropTypes.arrayOf(PropTypes.string),
   additionalClassName: PropTypes.string,
 };

+ 0 - 110
packages/app/src/components/Page/TagsInput.jsx

@@ -1,110 +0,0 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-import { AsyncTypeahead } from 'react-bootstrap-typeahead';
-
-import { withUnstatedContainers } from '../UnstatedUtils';
-import AppContainer from '~/client/services/AppContainer';
-
-/**
- *
- * @author Yuki Takei <yuki@weseek.co.jp>
- *
- * @export
- * @class TagsInput
- * @extends {React.Component}
- */
-
-class TagsInput extends React.Component {
-
-  constructor(props) {
-    super(props);
-
-    this.state = {
-      resultTags: [],
-      isLoading: false,
-      selected: this.props.tags,
-      defaultPageTags: this.props.tags,
-    };
-
-    this.handleChange = this.handleChange.bind(this);
-    this.handleSearch = this.handleSearch.bind(this);
-    this.handleSelect = this.handleSelect.bind(this);
-  }
-
-  componentDidMount() {
-    this.typeahead.getInstance().focus();
-  }
-
-  handleChange(selected) {
-    // send tags to TagLabel Component when user add tag to form everytime
-    this.setState({ selected }, () => {
-      this.props.onTagsUpdated(this.state.selected);
-    });
-  }
-
-  async handleSearch(query) {
-    this.setState({ isLoading: true });
-    const res = await this.props.appContainer.apiGet('/tags.search', { q: query });
-    res.tags.unshift(query); // selectable new tag whose name equals query
-    this.setState({
-      resultTags: Array.from(new Set(res.tags)), // use Set for de-duplication
-      isLoading: false,
-    });
-  }
-
-  handleSelect(e) {
-    if (e.keyCode === 32) { // '32' means ASCII code of 'space'
-      e.preventDefault();
-      const instance = this.typeahead.getInstance();
-      const { initialItem } = instance.state;
-
-      if (initialItem) {
-        instance._handleMenuItemSelect(initialItem, e);
-      }
-    }
-  }
-
-  render() {
-    return (
-      <div className="tag-typeahead">
-        <AsyncTypeahead
-          id="tag-typeahead-asynctypeahead"
-          ref={(typeahead) => { this.typeahead = typeahead }}
-          caseSensitive={false}
-          defaultSelected={this.state.defaultPageTags}
-          isLoading={this.state.isLoading}
-          minLength={1}
-          multiple
-          newSelectionPrefix=""
-          onChange={this.handleChange}
-          onSearch={this.handleSearch}
-          onKeyDown={this.handleSelect}
-          options={this.state.resultTags} // Search result (Some tag names)
-          placeholder="tag name"
-          selectHintOnEnter
-          autoFocus={this.props.autoFocus}
-        />
-      </div>
-    );
-  }
-
-}
-
-/**
- * Wrapper component for using unstated
- */
-const TagsInputWrapper = withUnstatedContainers(TagsInput, [AppContainer]);
-
-TagsInput.propTypes = {
-  appContainer:  PropTypes.instanceOf(AppContainer).isRequired,
-
-  tags:          PropTypes.array.isRequired,
-  onTagsUpdated: PropTypes.func.isRequired,
-  autoFocus:     PropTypes.bool,
-};
-
-TagsInput.defaultProps = {
-  autoFocus:     false,
-};
-
-export default TagsInputWrapper;

+ 86 - 0
packages/app/src/components/Page/TagsInput.tsx

@@ -0,0 +1,86 @@
+import React, {
+  FC, useRef, useState, useCallback,
+} from 'react';
+import { AsyncTypeahead } from 'react-bootstrap-typeahead';
+
+import { apiGet } from '~/client/util/apiv1-client';
+import { toastError } from '~/client/util/apiNotification';
+import { ITagsSearchApiv1Result } from '~/interfaces/tag';
+
+type TypeaheadInstance = {
+  _handleMenuItemSelect: (activeItem: string, event: React.KeyboardEvent) => void,
+  state: {
+    initialItem: string,
+  },
+}
+
+type Props = {
+  tags: string[],
+  autoFocus: boolean,
+  onTagsUpdated: (tags: string[]) => void,
+}
+
+const TagsInput: FC<Props> = (props: Props) => {
+  const tagsInputRef = useRef<TypeaheadInstance>(null);
+
+  const [resultTags, setResultTags] = useState<string[]>([]);
+  const [isLoading, setLoading] = useState(false);
+
+  const changeHandler = useCallback((selected: string[]) => {
+    if (props.onTagsUpdated != null) {
+      props.onTagsUpdated(selected);
+    }
+  }, [props]);
+
+  const searchHandler = useCallback(async(query: string) => {
+    setLoading(true);
+    try {
+      // TODO: 91698 SWRize
+      const res = await apiGet('/tags.search', { q: query }) as ITagsSearchApiv1Result;
+      res.tags.unshift(query);
+      setResultTags(Array.from(new Set(res.tags)));
+    }
+    catch (err) {
+      toastError(err);
+    }
+    finally {
+      setLoading(false);
+    }
+  }, []);
+
+  const keyDownHandler = useCallback((event: React.KeyboardEvent) => {
+    if (event.key === ' ') {
+      event.preventDefault();
+
+      const initialItem = tagsInputRef?.current?.state?.initialItem;
+      const handleMenuItemSelect = tagsInputRef?.current?._handleMenuItemSelect;
+
+      if (initialItem != null && handleMenuItemSelect != null) {
+        handleMenuItemSelect(initialItem, event);
+      }
+    }
+  }, []);
+
+  return (
+    <div className="tag-typeahead">
+      <AsyncTypeahead
+        id="tag-typeahead-asynctypeahead"
+        ref={tagsInputRef}
+        caseSensitive={false}
+        defaultSelected={props.tags ?? []}
+        isLoading={isLoading}
+        minLength={1}
+        multiple
+        newSelectionPrefix=""
+        onChange={changeHandler}
+        onSearch={searchHandler}
+        onKeyDown={keyDownHandler}
+        options={resultTags} // Search result (Some tag names)
+        placeholder="tag name"
+        autoFocus={props.autoFocus}
+      />
+    </div>
+  );
+};
+
+export default TagsInput;

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

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

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

@@ -135,11 +135,11 @@ class Comment extends React.PureComponent {
 
     await interceptorManager.process('preRenderComment', context);
     await interceptorManager.process('prePreProcess', context);
-    context.markdown = await growiRenderer.preProcess(context.markdown);
+    context.markdown = await growiRenderer.preProcess(context.markdown, context);
     await interceptorManager.process('postPreProcess', context);
-    context.parsedHTML = await growiRenderer.process(context.markdown);
+    context.parsedHTML = await growiRenderer.process(context.markdown, context);
     await interceptorManager.process('prePostProcess', context);
-    context.parsedHTML = await growiRenderer.postProcess(context.parsedHTML);
+    context.parsedHTML = await growiRenderer.postProcess(context.parsedHTML, context);
     await interceptorManager.process('postPostProcess', context);
     await interceptorManager.process('preRenderCommentHtml', context);
     this.setState({ html: context.parsedHTML });

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

@@ -231,16 +231,16 @@ class CommentEditor extends React.Component {
     interceptorManager.process('preRenderCommnetPreview', context)
       .then(() => { return interceptorManager.process('prePreProcess', context) })
       .then(() => {
-        context.markdown = growiRenderer.preProcess(context.markdown);
+        context.markdown = growiRenderer.preProcess(context.markdown, context);
       })
       .then(() => { return interceptorManager.process('postPreProcess', context) })
       .then(() => {
-        const parsedHTML = growiRenderer.process(context.markdown);
+        const parsedHTML = growiRenderer.process(context.markdown, context);
         context.parsedHTML = parsedHTML;
       })
       .then(() => { return interceptorManager.process('prePostProcess', context) })
       .then(() => {
-        context.parsedHTML = growiRenderer.postProcess(context.parsedHTML);
+        context.parsedHTML = growiRenderer.postProcess(context.parsedHTML, context);
       })
       .then(() => { return interceptorManager.process('postPostProcess', context) })
       .then(() => { return interceptorManager.process('preRenderCommentPreviewHtml', context) })

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

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

+ 12 - 5
packages/app/src/components/PageEditor/LinkEditModal.jsx

@@ -37,7 +37,8 @@ class LinkEditModal extends React.PureComponent {
       linkInputValue: '',
       labelInputValue: '',
       linkerType: Linker.types.markdownLink,
-      markdown: '',
+      markdown: null,
+      pagePath: null,
       previewError: '',
       permalink: '',
       isPreviewOpen: false,
@@ -152,7 +153,8 @@ class LinkEditModal extends React.PureComponent {
   async setMarkdown() {
     const { t } = this.props;
     const path = this.state.linkInputValue;
-    let markdown = '';
+    let markdown = null;
+    let pagePath = null;
     let permalink = '';
     let previewError = '';
 
@@ -165,6 +167,7 @@ class LinkEditModal extends React.PureComponent {
         const { data } = await this.props.appContainer.apiv3Get('/page', { path: pathWithoutFragment, page_id: pageId });
         const { page } = data;
         markdown = page.revision.body;
+        pagePath = page.path;
         permalink = page.id;
       }
       catch (err) {
@@ -174,7 +177,9 @@ class LinkEditModal extends React.PureComponent {
     else {
       previewError = t('link_edit.page_not_found_in_preview', { path });
     }
-    this.setState({ markdown, previewError, permalink });
+    this.setState({
+      markdown, pagePath, previewError, permalink,
+    });
   }
 
   renderLinkPreview() {
@@ -204,7 +209,7 @@ class LinkEditModal extends React.PureComponent {
   handleChangeTypeahead(selected) {
     const pageWithMeta = selected[0];
     if (pageWithMeta != null) {
-      const page = pageWithMeta.pageData;
+      const page = pageWithMeta.data;
       const permalink = `${window.location.origin}/${page.id}`;
       this.setState({ linkInputValue: page.path, permalink });
     }
@@ -278,6 +283,8 @@ class LinkEditModal extends React.PureComponent {
 
   renderLinkAndLabelForm() {
     const { t } = this.props;
+    const { pagePath } = this.state;
+
     return (
       <>
         <h3 className="grw-modal-head">{t('link_edit.set_link_and_label')}</h3>
@@ -301,7 +308,7 @@ class LinkEditModal extends React.PureComponent {
                 </button>
                 <Popover trigger="focus" placement="right" isOpen={this.state.isPreviewOpen} target="preview-btn" toggle={this.toggleIsPreviewOpen}>
                   <PopoverBody>
-                    <PreviewWithSuspense setMarkdown={this.setMarkdown} markdown={this.state.markdown} error={this.state.previewError} />
+                    <PreviewWithSuspense setMarkdown={this.setMarkdown} markdown={this.state.markdown} pagePath={pagePath} error={this.state.previewError} />
                   </PopoverBody>
                 </Popover>
               </div>

+ 0 - 123
packages/app/src/components/PageEditor/Preview.jsx

@@ -1,123 +0,0 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-
-import { Subscribe } from 'unstated';
-import { withUnstatedContainers } from '../UnstatedUtils';
-
-import RevisionBody from '../Page/RevisionBody';
-
-import AppContainer from '~/client/services/AppContainer';
-import EditorContainer from '~/client/services/EditorContainer';
-
-/**
- * Wrapper component for Page/RevisionBody
- */
-class Preview extends React.PureComponent {
-
-  constructor(props) {
-    super(props);
-
-    this.state = {
-      html: '',
-    };
-
-    // get renderer
-    this.growiRenderer = props.appContainer.getRenderer('editor');
-  }
-
-  componentDidMount() {
-    this.initCurrentRenderingContext();
-    this.renderPreview();
-  }
-
-  componentDidUpdate(prevProps) {
-    const { markdown: prevMarkdown } = prevProps;
-    const { markdown } = this.props;
-
-    // render only when props.markdown is updated
-    if (markdown !== prevMarkdown) {
-      this.initCurrentRenderingContext();
-      this.renderPreview();
-      return;
-    }
-
-    const { interceptorManager } = this.props.appContainer;
-
-    interceptorManager.process('postRenderPreviewHtml', this.currentRenderingContext);
-  }
-
-  initCurrentRenderingContext() {
-    this.currentRenderingContext = {
-      markdown: this.props.markdown,
-      currentPagePath: decodeURIComponent(window.location.pathname),
-    };
-  }
-
-  async renderPreview() {
-    const { appContainer } = this.props;
-    const { growiRenderer } = this;
-
-    const { interceptorManager } = appContainer;
-    const context = this.currentRenderingContext;
-
-    await interceptorManager.process('preRenderPreview', context);
-    await interceptorManager.process('prePreProcess', context);
-    context.markdown = growiRenderer.preProcess(context.markdown);
-    await interceptorManager.process('postPreProcess', context);
-    context.parsedHTML = growiRenderer.process(context.markdown);
-    await interceptorManager.process('prePostProcess', context);
-    context.parsedHTML = growiRenderer.postProcess(context.parsedHTML);
-    await interceptorManager.process('postPostProcess', context);
-    await interceptorManager.process('preRenderPreviewHtml', context);
-
-    this.setState({ html: context.parsedHTML });
-  }
-
-  render() {
-    return (
-      <Subscribe to={[EditorContainer]}>
-        { editorContainer => (
-          // eslint-disable-next-line arrow-body-style
-          <div
-            className="page-editor-preview-body"
-            ref={(elm) => {
-              this.previewElement = elm;
-              if (this.props.inputRef != null) {
-                this.props.inputRef(elm);
-              }
-            }}
-            onScroll={(event) => {
-              if (this.props.onScroll != null) {
-                this.props.onScroll(event.target.scrollTop);
-              }
-            }}
-          >
-            <RevisionBody
-              {...this.props}
-              html={this.state.html}
-              renderMathJaxInRealtime={editorContainer.state.previewOptions.renderMathJaxInRealtime}
-            />
-          </div>
-        )}
-      </Subscribe>
-    );
-  }
-
-}
-
-/**
- * Wrapper component for using unstated
- */
-const PreviewWrapper = withUnstatedContainers(Preview, [AppContainer]);
-
-Preview.propTypes = {
-  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
-
-  markdown: PropTypes.string,
-  inputRef: PropTypes.func,
-  isMathJaxEnabled: PropTypes.bool,
-  renderMathJaxOnInit: PropTypes.bool,
-  onScroll: PropTypes.func,
-};
-
-export default PreviewWrapper;

+ 112 - 0
packages/app/src/components/PageEditor/Preview.tsx

@@ -0,0 +1,112 @@
+import React, {
+  UIEventHandler, useCallback, useEffect, useMemo, useState,
+} from 'react';
+
+import { Subscribe } from 'unstated';
+import { withUnstatedContainers } from '../UnstatedUtils';
+
+import RevisionBody from '../Page/RevisionBody';
+
+import AppContainer from '~/client/services/AppContainer';
+import EditorContainer from '~/client/services/EditorContainer';
+
+
+type Props = {
+  appContainer: AppContainer,
+  editorContainer: EditorContainer,
+
+  markdown?: string,
+  pagePath?: string,
+  inputRef?: React.RefObject<HTMLDivElement>,
+  isMathJaxEnabled?: boolean,
+  renderMathJaxOnInit?: boolean,
+  onScroll?: UIEventHandler<HTMLDivElement>,
+}
+
+
+const Preview = (props: Props): JSX.Element => {
+
+  const {
+    appContainer,
+    markdown, pagePath,
+    inputRef,
+    onScroll,
+  } = props;
+
+  const [html, setHtml] = useState('');
+
+  const { interceptorManager } = appContainer;
+  const growiRenderer = props.appContainer.getRenderer('editor');
+
+  const context = useMemo(() => {
+    return {
+      markdown,
+      pagePath,
+      currentPathname: decodeURIComponent(window.location.pathname),
+      parsedHTML: null,
+    };
+  }, [markdown, pagePath]);
+
+  const renderPreview = useCallback(async() => {
+    if (interceptorManager != null) {
+      await interceptorManager.process('preRenderPreview', context);
+      await interceptorManager.process('prePreProcess', context);
+      context.markdown = growiRenderer.preProcess(context.markdown, context);
+      await interceptorManager.process('postPreProcess', context);
+      context.parsedHTML = growiRenderer.process(context.markdown, context);
+      await interceptorManager.process('prePostProcess', context);
+      context.parsedHTML = growiRenderer.postProcess(context.parsedHTML, context);
+      await interceptorManager.process('postPostProcess', context);
+      await interceptorManager.process('preRenderPreviewHtml', context);
+    }
+
+    setHtml(context.parsedHTML ?? '');
+  }, [interceptorManager, context, growiRenderer]);
+
+  useEffect(() => {
+    if (markdown == null) {
+      setHtml('');
+    }
+
+    renderPreview();
+  }, [markdown, renderPreview]);
+
+  useEffect(() => {
+    if (html == null) {
+      return;
+    }
+
+    if (interceptorManager != null) {
+      interceptorManager.process('postRenderPreviewHtml', {
+        ...context,
+        parsedHTML: html,
+      });
+    }
+  }, [context, html, interceptorManager]);
+
+  return (
+    <Subscribe to={[EditorContainer]}>
+      { editorContainer => (
+        <div
+          className="page-editor-preview-body"
+          ref={inputRef}
+          onScroll={onScroll}
+        >
+          <RevisionBody
+            {...props}
+            html={html}
+            renderMathJaxInRealtime={editorContainer.state.previewOptions.renderMathJaxInRealtime}
+          />
+        </div>
+      ) }
+    </Subscribe>
+  );
+
+};
+
+/**
+ * Wrapper component for using unstated
+ */
+const PreviewWrapper = withUnstatedContainers(Preview, [AppContainer]);
+
+export default PreviewWrapper;

+ 3 - 2
packages/app/src/components/PageEditor/PreviewWithSuspense.jsx

@@ -5,7 +5,7 @@ import Preview from './Preview';
 import { withLoadingSppiner } from '../SuspenseUtils';
 
 function PagePreview(props) {
-  if (props.markdown === '') {
+  if (props.markdown == null || props.pagePath == null) {
     if (props.error !== '') {
       return props.error;
     }
@@ -16,7 +16,7 @@ function PagePreview(props) {
 
   return (
     <div className="linkedit-preview">
-      <Preview markdown={props.markdown} />
+      <Preview markdown={props.markdown} pagePath={props.pagePath} />
     </div>
   );
 }
@@ -24,6 +24,7 @@ function PagePreview(props) {
 PagePreview.propTypes = {
   setMarkdown: PropTypes.func,
   markdown: PropTypes.string,
+  pagePath: PropTypes.string,
   error: PropTypes.string,
 };
 

+ 38 - 4
packages/app/src/components/PageList/PageListItemL.tsx

@@ -3,6 +3,7 @@ import React, {
   ForwardRefRenderFunction, memo, useCallback, useImperativeHandle, useRef,
 } from 'react';
 
+import { useTranslation } from 'react-i18next';
 import { CustomInput } from 'reactstrap';
 
 import Clamp from 'react-multiline-clamp';
@@ -12,6 +13,7 @@ import urljoin from 'url-join';
 import { UserPicture, PageListMeta } from '@growi/ui';
 import { DevidedPagePath } from '@growi/core';
 
+import { useSWRxPageInfo } from '../../stores/page';
 
 import { ISelectable } from '~/client/interfaces/selectable-all';
 import { bookmark, unbookmark } from '~/client/services/page-operation';
@@ -20,7 +22,7 @@ import {
   usePageRenameModal, usePageDuplicateModal, usePageDeleteModal, usePutBackPageModal,
 } from '~/stores/modal';
 import {
-  IPageInfoAll, IPageInfoForEntity, IPageInfoForListing, IPageWithMeta, isIPageInfoForListing,
+  IPageInfoAll, IPageInfoForEntity, IPageInfoForListing, IPageWithMeta, isIPageInfoForListing, isIPageInfoForEntity,
 } from '~/interfaces/page';
 import { IPageSearchMeta, isIPageSearchMeta } from '~/interfaces/search';
 import {
@@ -54,6 +56,8 @@ const PageListItemLSubstance: ForwardRefRenderFunction<ISelectable, Props> = (pr
     onClickItem, onCheckboxChanged, onPageDuplicated, onPageRenamed, onPageDeleted, onPagePutBacked,
   } = props;
 
+  const { t } = useTranslation();
+
   const inputRef = useRef<HTMLInputElement>(null);
 
   // publish ISelectable methods
@@ -78,6 +82,9 @@ const PageListItemLSubstance: ForwardRefRenderFunction<ISelectable, Props> = (pr
   const { open: openDeleteModal } = usePageDeleteModal();
   const { open: openPutBackPageModal } = usePutBackPageModal();
 
+  const shouldFetch = isSelected && (pageData != null || pageMeta != null);
+  const { data: pageInfo } = useSWRxPageInfo(shouldFetch ? pageData?._id : null);
+
   const elasticSearchResult = isIPageSearchMeta(pageMeta) ? pageMeta.elasticSearchResult : null;
   const revisionShortBody = isIPageInfoForListing(pageMeta) ? pageMeta.revisionShortBody : null;
 
@@ -135,7 +142,26 @@ const PageListItemLSubstance: ForwardRefRenderFunction<ISelectable, Props> = (pr
   // background color of list item changes when class "active" exists under 'list-group-item'
   const styleActive = !isDeviceSmallerThanLg && isSelected ? 'active' : '';
 
-  const shouldDangerouslySetInnerHTMLForPaths = elasticSearchResult != null && elasticSearchResult.highlightedPath.length > 0;
+  const shouldDangerouslySetInnerHTMLForPaths = elasticSearchResult != null && elasticSearchResult.highlightedPath != null;
+
+  let likerCount;
+  if (isSelected && isIPageInfoForEntity(pageInfo)) {
+    likerCount = pageInfo.likerIds?.length;
+  }
+  else {
+    likerCount = pageData.liker.length;
+  }
+
+  let bookmarkCount;
+  if (isSelected && isIPageInfoForEntity(pageInfo)) {
+    bookmarkCount = pageInfo.bookmarkCount;
+  }
+  else {
+    bookmarkCount = pageMeta?.bookmarkCount;
+  }
+
+  const canRenderESSnippet = elasticSearchResult != null && elasticSearchResult.snippet != null;
+  const canRenderRevisionSnippet = revisionShortBody != null;
 
   return (
     <li
@@ -199,7 +225,7 @@ const PageListItemLSubstance: ForwardRefRenderFunction<ISelectable, Props> = (pr
 
               {/* page meta */}
               <div className="d-none d-md-flex py-0 px-1 ml-2 text-nowrap">
-                <PageListMeta page={pageData} bookmarkCount={pageMeta?.bookmarkCount} shouldSpaceOutIcon />
+                <PageListMeta page={pageData} likerCount={likerCount} bookmarkCount={bookmarkCount} shouldSpaceOutIcon />
               </div>
 
               {/* doropdown icon includes page control buttons */}
@@ -219,13 +245,21 @@ const PageListItemLSubstance: ForwardRefRenderFunction<ISelectable, Props> = (pr
             </div>
             <div className="page-list-snippet py-1">
               <Clamp lines={2}>
-                { elasticSearchResult != null && elasticSearchResult?.snippet.length > 0 && (
+                { elasticSearchResult != null && elasticSearchResult.snippet != null && (
                   // eslint-disable-next-line react/no-danger
                   <div dangerouslySetInnerHTML={{ __html: elasticSearchResult.snippet }}></div>
                 ) }
                 { revisionShortBody != null && (
                   <div>{revisionShortBody}</div>
                 ) }
+                {
+                  !canRenderESSnippet && !canRenderRevisionSnippet && (
+                    <>
+                      <i className="icon-exclamation p-1"></i>
+                      {t('not_allowed_to_see_this_page')}
+                    </>
+                  )
+                }
               </Clamp>
             </div>
           </div>

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

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

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

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

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

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

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

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

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

@@ -37,7 +37,7 @@ const SearchResultListSubstance: ForwardRefRenderFunction<ISelectableAll, Props>
   const { t } = useTranslation();
 
   const pageIdsWithNoSnippet = pages
-    .filter(page => (page.meta?.elasticSearchResult?.snippet.length ?? 0) === 0)
+    .filter(page => (page.meta?.elasticSearchResult?.snippet?.length ?? 0) === 0)
     .map(page => page.data._id);
 
   const { data: isGuestUser } = useIsGuestUser();

+ 1 - 2
packages/app/src/components/SearchPage2/SearchPageBase.tsx

@@ -181,8 +181,7 @@ const SearchPageBaseSubstance: ForwardRefRenderFunction<ISelectableAll & IReturn
                   <div className="page-list px-md-4">
                     <SearchResultList
                       ref={searchResultListRef}
-                      // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
-                      pages={pages!}
+                      pages={pages}
                       selectedPageId={selectedPageWithMeta?.data._id}
                       forceHideMenuItems={forceHideMenuItems}
                       onPageSelected={page => setSelectedPageWithMeta(page)}

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

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

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

@@ -62,6 +62,7 @@ const CustomSidebar: FC<Props> = (props: Props) => {
             <RevisionRenderer
               growiRenderer={renderer}
               markdown={markdown}
+              pagePath="/Sidebar"
               additionalClassName="grw-custom-sidebar-content"
             />
           </div>

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

@@ -101,7 +101,7 @@ type ItemCountProps = {
 const ItemCount: FC<ItemCountProps> = (props:ItemCountProps) => {
   return (
     <>
-      <span className="grw-pagetree-count px-0 badge badge-pill badge-light">
+      <span className="grw-pagetree-count px-2 badge badge-pill badge-light">
         {props.descendantCount}
       </span>
     </>

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

@@ -3,8 +3,8 @@ import { IPageWithMeta } from './page';
 export type IPageSearchMeta = {
   bookmarkCount?: number,
   elasticSearchResult?: {
-    snippet: string;
-    highlightedPath: string;
+    snippet?: string | null;
+    highlightedPath?: string | null;
     isHtmlInPath: boolean;
   };
 }

+ 5 - 0
packages/app/src/interfaces/tag.ts

@@ -2,3 +2,8 @@ export type ITag = {
   name: string,
   createdAt: Date;
 }
+
+export type ITagsSearchApiv1Result = {
+  ok: boolean,
+  tags: string[]
+}

+ 1 - 1
packages/app/src/interfaces/user.ts

@@ -20,7 +20,7 @@ export type IUserGroup = {
   name: string;
   createdAt: Date;
   description: string;
-  parent: Ref<IUserGroup> | null;
+  parent: Ref<IUserGroupHasId> | null;
 }
 
 export type IUserHasId = IUser & HasObjectId;

+ 13 - 1
packages/app/src/interfaces/websocket.ts

@@ -1,5 +1,12 @@
 export const SocketEventName = {
-  UpdateDescCount: 'UpdateDsecCount',
+  // Update descendantCount
+  UpdateDescCount: 'UpdateDescCount',
+
+  // Public migration
+  PMStarted: 'PublicMigrationStarted',
+  PMMigrating: 'PublicMigrationMigrating',
+  PMErrorCount: 'PublicMigrationErrorCount',
+  PMEnded: 'PublicMigrationEnded',
 } as const;
 export type SocketEventName = typeof SocketEventName[keyof typeof SocketEventName];
 
@@ -10,3 +17,8 @@ type DescendantCount = number;
  */
 export type UpdateDescCountRawData = Record<PageId, DescendantCount>;
 export type UpdateDescCountData = Map<PageId, DescendantCount>;
+
+export type PMStartedData = { total: number };
+export type PMMigratingData = { count: number };
+export type PMErrorCountData = { skip: number };
+export type PMEndedData = { isSucceeded: boolean };

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

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

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

@@ -47,7 +47,7 @@ module.exports = (crowi) => {
       body('parentId', 'ParentId must be a string').optional().isString(),
     ],
     update: [
-      body('name', 'Group name is required').trim().exists({ checkFalsy: true }),
+      body('name', 'Group name must be a string').optional().trim().isString(),
       body('description', 'Group description must be a string').optional().isString(),
       body('parentId', 'parentId must be a string').optional().isString(),
       body('forceUpdateParents', 'forceUpdateParents must be a boolean').optional().isBoolean(),

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

@@ -156,7 +156,7 @@ module.exports = function(crowi, app) {
 
     let result;
     try {
-      result = await searchService.formatSearchResult(searchResult, delegatorName);
+      result = await searchService.formatSearchResult(searchResult, delegatorName, user, userGroups);
     }
     catch (err) {
       return res.json(ApiResponse.error(err));

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

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

+ 19 - 6
packages/app/src/server/service/page.ts

@@ -2557,13 +2557,14 @@ class PageService {
     return this._normalizeParentRecursively(pathAndRegExpsToNormalize, ancestorPaths, grantFiltersByUser, user);
   }
 
-  // TODO: use websocket to show progress
   private async _normalizeParentRecursively(
-      pathOrRegExps: (RegExp | string)[], publicPathsToNormalize: string[], grantFiltersByUser: { $or: any[] }, user,
+      pathOrRegExps: (RegExp | string)[], publicPathsToNormalize: string[], grantFiltersByUser: { $or: any[] }, user, count = 0, skiped = 0, isFirst = true,
   ): Promise<void> {
     const BATCH_SIZE = 100;
     const PAGES_LIMIT = 1000;
 
+    const socket = this.crowi.socketIoService.getAdminSocket();
+
     const Page = mongoose.model('Page') as unknown as PageModel;
     const { PageQueryBuilder } = Page;
 
@@ -2617,6 +2618,9 @@ class PageService {
 
     // Limit pages to get
     const total = await Page.countDocuments(mergedFilter);
+    if (isFirst) {
+      socket.emit(SocketEventName.PMStarted, { total });
+    }
     if (total > PAGES_LIMIT) {
       baseAggregation = baseAggregation.limit(Math.floor(total * 0.3));
     }
@@ -2624,8 +2628,9 @@ class PageService {
     const pagesStream = await baseAggregation.cursor({ batchSize: BATCH_SIZE });
     const batchStream = createBatchStream(BATCH_SIZE);
 
-    let countPages = 0;
     let shouldContinue = true;
+    let nextCount = count;
+    let nextSkiped = skiped;
 
     const migratePagesStream = new Writable({
       objectMode: true,
@@ -2710,12 +2715,17 @@ class PageService {
         try {
           const res = await Page.bulkWrite(updateManyOperations);
 
-          countPages += res.result.nModified;
-          logger.info(`Page migration processing: (count=${countPages})`);
+          nextCount += res.result.nModified;
+          nextSkiped += res.result.writeErrors.length;
+          logger.info(`Page migration processing: (migratedPages=${res.result.nModified})`);
+
+          socket.emit(SocketEventName.PMMigrating, { count: nextCount });
+          socket.emit(SocketEventName.PMErrorCount, { skip: nextSkiped });
 
           // Throw if any error is found
           if (res.result.writeErrors.length > 0) {
             logger.error('Failed to migrate some pages', res.result.writeErrors);
+            socket.emit(SocketEventName.PMEnded, { isSucceeded: false });
             throw Error('Failed to migrate some pages');
           }
 
@@ -2723,6 +2733,7 @@ class PageService {
           if (res.result.nModified === 0 && res.result.nMatched === 0) {
             shouldContinue = false;
             logger.error('Migration is unable to continue', 'parentPaths:', parentPaths, 'bulkWriteResult:', res);
+            socket.emit(SocketEventName.PMEnded, { isSucceeded: false });
           }
         }
         catch (err) {
@@ -2744,9 +2755,11 @@ class PageService {
     await streamToPromise(migratePagesStream);
 
     if (await Page.exists(mergedFilter) && shouldContinue) {
-      return this._normalizeParentRecursively(pathOrRegExps, publicPathsToNormalize, grantFiltersByUser, user);
+      return this._normalizeParentRecursively(pathOrRegExps, publicPathsToNormalize, grantFiltersByUser, user, nextCount, nextSkiped, false);
     }
 
+    // End
+    socket.emit(SocketEventName.PMEnded, { isSucceeded: true });
   }
 
   private async _v5NormalizeIndex() {

+ 8 - 2
packages/app/src/server/service/search-delegator/elasticsearch.ts

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

+ 28 - 9
packages/app/src/server/service/search.ts

@@ -1,4 +1,5 @@
 import xss from 'xss';
+import mongoose from 'mongoose';
 
 import { SearchDelegatorName } from '~/interfaces/named-query';
 import { IPageWithMeta } from '~/interfaces/page';
@@ -351,7 +352,7 @@ class SearchService implements SearchQueryParser, SearchResolver {
   /**
    * formatting result
    */
-  async formatSearchResult(searchResult: ISearchResult<any>, delegatorName): Promise<IFormattedSearchResult> {
+  async formatSearchResult(searchResult: ISearchResult<any>, delegatorName: SearchDelegatorName, user, userGroups): Promise<IFormattedSearchResult> {
     if (!this.checkIsFormattable(searchResult, delegatorName)) {
       const data: IPageWithMeta<IPageSearchMeta>[] = searchResult.data.map((page) => {
         return {
@@ -398,21 +399,17 @@ class SearchService implements SearchQueryParser, SearchResolver {
         pageData.lastUpdateUser = serializeUserSecurely(pageData.lastUpdateUser);
       }
 
-      // const data = searchResult.data.find((data) => {
-      //   return pageData.id === data._id;
-      // });
-
       // increment elasticSearchResult
       let elasticSearchResult;
       const highlightData = data._highlight;
       if (highlightData != null) {
-        const snippet = highlightData['body.en'] || highlightData['body.ja'] || '';
-        const pathMatch = highlightData['path.en'] || highlightData['path.ja'] || '';
+        const snippet = this.canShowSnippet(pageData, user, userGroups) ? highlightData['body.en'] || highlightData['body.ja'] : null;
+        const pathMatch = highlightData['path.en'] || highlightData['path.ja'];
         const isHtmlInPath = highlightData['path.en'] != null || highlightData['path.ja'] != null;
 
         elasticSearchResult = {
-          snippet: filterXss.process(snippet),
-          highlightedPath: filterXss.process(pathMatch),
+          snippet: typeof snippet === 'string' ? filterXss.process(snippet) : null,
+          highlightedPath: typeof pathMatch === 'string' ? filterXss.process(pathMatch) : null,
           isHtmlInPath,
         };
       }
@@ -430,6 +427,28 @@ class SearchService implements SearchQueryParser, SearchResolver {
     return result;
   }
 
+  canShowSnippet(pageData, user, userGroups): boolean {
+    const Page = mongoose.model('Page') as unknown as PageModel;
+
+    const testGrant = pageData.grant;
+    const testGrantedUser = pageData.grantedUsers?.[0];
+    const testGrantedGroup = pageData.grantedGroup;
+
+    if (testGrant === Page.GRANT_RESTRICTED) {
+      return false;
+    }
+
+    if (testGrant === Page.GRANT_OWNER) {
+      return user._id.toString() === testGrantedUser.toString();
+    }
+
+    if (testGrant === Page.GRANT_USER_GROUP) {
+      return userGroups.map(d => d.toString()).include(testGrantedGroup.toString());
+    }
+
+    return true;
+  }
+
 }
 
 export default SearchService;

+ 16 - 3
packages/app/src/server/service/user-group.ts

@@ -27,7 +27,7 @@ class UserGroupService {
 
   // TODO 85062: write test code
   // ref: https://dev.growi.org/61b2cdabaa330ce7d8152844
-  async updateGroup(id, name: string, description: string, parentId?: string, forceUpdateParents = false) {
+  async updateGroup(id, name?: string, description?: string, parentId?: string | null, forceUpdateParents = false) {
     const userGroup = await UserGroup.findById(id);
     if (userGroup == null) {
       throw new Error('The group does not exist');
@@ -39,19 +39,32 @@ class UserGroupService {
       throw new Error('The group name is already taken');
     }
 
-    userGroup.name = name;
-    userGroup.description = description;
+    if (name != null) {
+      userGroup.name = name;
+    }
+    if (description != null) {
+      userGroup.description = description;
+    }
 
     // return when not update parent
     if (userGroup.parent === parentId) {
       return userGroup.save();
     }
+
+    /*
+     * Update parent
+     */
+    if (parentId === undefined) { // undefined will be ignored
+      return userGroup.save();
+    }
+
     // set parent to null and return when parentId is null
     if (parentId == null) {
       userGroup.parent = null;
       return userGroup.save();
     }
 
+
     const parent = await UserGroup.findById(parentId);
 
     if (parent == null) { // it should not be null

+ 34 - 0
packages/app/src/stores/modal.tsx

@@ -4,6 +4,7 @@ import {
   OnDuplicatedFunction, OnRenamedFunction, OnDeletedFunction, OnPutBackedFunction,
 } from '~/interfaces/ui';
 import { IPageToDeleteWithMeta, IPageToRenameWithMeta } from '~/interfaces/page';
+import { IUserGroupHasId } from '~/interfaces/user';
 
 
 /*
@@ -330,3 +331,36 @@ export const usePageAccessoriesModal = (): SWRResponse<PageAccessoriesModalStatu
     },
   };
 };
+
+/*
+ * UpdateUserGroupConfirmModal
+ */
+type UpdateUserGroupConfirmModalStatus = {
+  isOpened: boolean,
+  targetGroup?: IUserGroupHasId,
+  updateData?: Partial<IUserGroupHasId>,
+  onConfirm?: (targetGroup: IUserGroupHasId, updateData: Partial<IUserGroupHasId>, forceUpdateParents: boolean) => any,
+}
+
+type UpdateUserGroupConfirmModalUtils = {
+  open(targetGroup: IUserGroupHasId, updateData: Partial<IUserGroupHasId>, onConfirm?: (...args: any[]) => any): Promise<void>,
+  close(): Promise<void>,
+}
+
+export const useUpdateUserGroupConfirmModal = (): SWRResponse<UpdateUserGroupConfirmModalStatus, Error> & UpdateUserGroupConfirmModalUtils => {
+
+  const initialStatus: UpdateUserGroupConfirmModalStatus = { isOpened: false };
+  const swrResponse = useStaticSWR<UpdateUserGroupConfirmModalStatus, Error>('updateParentConfirmModal', undefined, { fallbackData: initialStatus });
+
+  return {
+    ...swrResponse,
+    async open(targetGroup: IUserGroupHasId, updateData: Partial<IUserGroupHasId>, onConfirm?: (...args: any[]) => any) {
+      await swrResponse.mutate({
+        isOpened: true, targetGroup, updateData, onConfirm,
+      });
+    },
+    async close() {
+      await swrResponse.mutate({ isOpened: false });
+    },
+  };
+};

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

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

+ 10 - 7
packages/app/src/stores/ui.tsx

@@ -68,28 +68,28 @@ export const useIsMobile = (): SWRResponse<boolean, Error> => {
   return useStaticSWR<boolean, Error>(key, undefined, configuration);
 };
 
-const updateBodyClassesByEditorMode = (newEditorMode: EditorMode) => {
+const updateBodyClassesByEditorMode = (newEditorMode: EditorMode, isSidebar = false) => {
   switch (newEditorMode) {
     case EditorMode.View:
       $('body').removeClass('on-edit');
       $('body').removeClass('builtin-editor');
       $('body').removeClass('hackmd');
-      $('body').removeClass('pathname-sidebar');
+      $('body').removeClass('editing-sidebar');
       break;
     case EditorMode.Editor:
       $('body').addClass('on-edit');
       $('body').addClass('builtin-editor');
       $('body').removeClass('hackmd');
       // editing /Sidebar
-      if (window.location.pathname === '/Sidebar') {
-        $('body').addClass('pathname-sidebar');
+      if (isSidebar) {
+        $('body').addClass('editing-sidebar');
       }
       break;
     case EditorMode.HackMD:
       $('body').addClass('on-edit');
       $('body').addClass('hackmd');
       $('body').removeClass('builtin-editor');
-      $('body').removeClass('pathname-sidebar');
+      $('body').removeClass('editing-sidebar');
       break;
   }
 };
@@ -133,6 +133,9 @@ export const useEditorMode = (): SWRResponse<EditorMode, Error> => {
   const isEditable = !isLoading && _isEditable;
   const initialData = isEditable ? editorModeByHash : EditorMode.View;
 
+  const { data: currentPagePath } = useCurrentPagePath();
+  const isSidebar = currentPagePath === '/Sidebar';
+
   const swrResponse = useSWRImmutable(
     isLoading ? null : ['editorMode', isEditable],
     null,
@@ -142,7 +145,7 @@ export const useEditorMode = (): SWRResponse<EditorMode, Error> => {
   // initial updating
   if (!isEditorModeLoaded && !isLoading && swrResponse.data != null) {
     if (isEditable) {
-      updateBodyClassesByEditorMode(swrResponse.data);
+      updateBodyClassesByEditorMode(swrResponse.data, isSidebar);
     }
     isEditorModeLoaded = true;
   }
@@ -155,7 +158,7 @@ export const useEditorMode = (): SWRResponse<EditorMode, Error> => {
       if (!isEditable) {
         return Promise.resolve(EditorMode.View); // fixed if not editable
       }
-      updateBodyClassesByEditorMode(editorMode);
+      updateBodyClassesByEditorMode(editorMode, isSidebar);
       updateHashByEditorMode(editorMode);
       return swrResponse.mutate(editorMode, shouldRevalidate);
     },

+ 24 - 0
packages/app/src/stores/websocket.tsx

@@ -9,6 +9,12 @@ const logger = loggerFactory('growi:stores:ui');
 export const GLOBAL_SOCKET_NS = '/';
 export const GLOBAL_SOCKET_KEY = 'globalSocket';
 
+export const GLOBAL_ADMIN_SOCKET_NS = '/admin';
+export const GLOBAL_ADMIN_SOCKET_KEY = 'globalAdminSocket';
+
+/*
+ * Global Socket
+ */
 export const useSetupGlobalSocket = (): SWRResponse<Socket, Error> => {
   const socket = io(GLOBAL_SOCKET_NS, {
     transports: ['websocket'],
@@ -23,3 +29,21 @@ export const useSetupGlobalSocket = (): SWRResponse<Socket, Error> => {
 export const useGlobalSocket = (): SWRResponse<Socket, Error> => {
   return useStaticSWR(GLOBAL_SOCKET_KEY);
 };
+
+/*
+ * Global Admin Socket
+ */
+export const useSetupGlobalAdminSocket = (): SWRResponse<Socket, Error> => {
+  const socket = io(GLOBAL_ADMIN_SOCKET_NS, {
+    transports: ['websocket'],
+  });
+
+  socket.on('error', (err) => { logger.error(err) });
+  socket.on('connect_error', (err) => { logger.error('Failed to connect with websocket.', err) });
+
+  return useStaticSWR(GLOBAL_ADMIN_SOCKET_KEY, socket);
+};
+
+export const useGlobalAdminSocket = (): SWRResponse<Socket, Error> => {
+  return useStaticSWR(GLOBAL_ADMIN_SOCKET_KEY);
+};

+ 1 - 1
packages/app/src/styles/_on-edit.scss

@@ -246,7 +246,7 @@ body.on-edit {
   // .builtin-editor .tab-pane#edit
 
   // editing /Sidebar
-  &.pathname-sidebar {
+  &.editing-sidebar {
     .page-editor-preview-body {
       width: 320px;
       padding-top: 0;

+ 1 - 1
packages/app/src/styles/_page-tree.scss

@@ -50,7 +50,7 @@ $grw-pagetree-item-padding-left: 10px;
       }
 
       .grw-pagetree-count {
-        width: 26px;
+        width: auto;
         padding: 0.1rem 0;
         font-size: 12px;
       }

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

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

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

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

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

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

+ 1 - 28
packages/app/src/styles/theme/_apply-colors.scss

@@ -77,10 +77,6 @@ pre:not(.hljs):not(.CodeMirror-line) {
 }
 
 // Dropdown
-.dropdown-menu {
-  color: $color-global;
-}
-
 .grw-personal-dropdown {
   .grw-icon-container svg {
     fill: $color-global;
@@ -90,29 +86,6 @@ pre:not(.hljs):not(.CodeMirror-line) {
   }
 }
 
-.dropdown-item {
-  color: $color-global;
-
-  svg {
-    fill: $color-global;
-  }
-
-  &:active,
-  &.active,
-  &:active:hover,
-  &.active:hover {
-    color: $color-dropdown-link-active;
-    background-color: $bgcolor-dropdown-link-active;
-
-    svg {
-      fill: $color-dropdown-link-active;
-    }
-  }
-  &:hover {
-    background-color: $light;
-  }
-}
-
 // Form
 .form-control {
   @include form-control-focus();
@@ -565,7 +538,7 @@ body.on-edit {
 /*
  * Preview for editing /Sidebar
  */
-body.pathname-sidebar {
+body.editing-sidebar {
   .page-editor-preview-body {
     color: $color-sidebar-context;
     background-color: $bgcolor-sidebar-context;

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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