Browse Source

Merge branch 'feat/emoji' into feat/gw7732-fix-emoji-preview

kaori 4 years ago
parent
commit
61ee1e0c5a
100 changed files with 2359 additions and 1326 deletions
  1. 4 3
      .devcontainer/docker-compose.yml
  2. 2 0
      .github/workflows/ci-app-prod.yml
  3. 9 9
      .github/workflows/ci-app.yml
  4. 8 8
      .github/workflows/ci-slackbot-proxy.yml
  5. 1 1
      .github/workflows/codeql-analysis.yml
  6. 2 2
      .github/workflows/draft-release.yml
  7. 2 2
      .github/workflows/list-unhealthy-branches.yml
  8. 1 1
      .github/workflows/pr-to-master.yml
  9. 2 2
      .github/workflows/release-rc.yml
  10. 5 5
      .github/workflows/release-slackbot-proxy.yml
  11. 7 7
      .github/workflows/release.yml
  12. 12 12
      .github/workflows/reusable-app-prod.yml
  13. 3 3
      .github/workflows/reusable-app-reg-suit.yml
  14. 66 1
      CHANGELOG.md
  15. 1 1
      lerna.json
  16. 1 1
      package.json
  17. 1 2
      packages/app/.env.development
  18. 1 0
      packages/app/config/logger/config.dev.js
  19. 2 2
      packages/app/docker/README.md
  20. 18 17
      packages/app/package.json
  21. 13 13
      packages/app/resource/cdn-manifests.js
  22. 35 8
      packages/app/resource/locales/en_US/admin/admin.json
  23. 3 1
      packages/app/resource/locales/en_US/notifications/passwordReset.txt
  24. 3 1
      packages/app/resource/locales/en_US/notifications/userActivation.txt
  25. 1 1
      packages/app/resource/locales/en_US/sandbox.md
  26. 30 8
      packages/app/resource/locales/en_US/translation.json
  27. 35 8
      packages/app/resource/locales/ja_JP/admin/admin.json
  28. 3 1
      packages/app/resource/locales/ja_JP/notifications/passwordReset.txt
  29. 3 1
      packages/app/resource/locales/ja_JP/notifications/userActivation.txt
  30. 1 1
      packages/app/resource/locales/ja_JP/sandbox.md
  31. 31 9
      packages/app/resource/locales/ja_JP/translation.json
  32. 36 9
      packages/app/resource/locales/zh_CN/admin/admin.json
  33. 3 1
      packages/app/resource/locales/zh_CN/notifications/passwordReset.txt
  34. 3 1
      packages/app/resource/locales/zh_CN/notifications/userActivation.txt
  35. 1 1
      packages/app/resource/locales/zh_CN/sandbox.md
  36. 44 21
      packages/app/resource/locales/zh_CN/translation.json
  37. 0 123
      packages/app/resource/search/mappings-es6-for-ci.json
  38. 118 0
      packages/app/resource/search/mappings-es7-for-ci.json
  39. 9 4
      packages/app/src/client/app.jsx
  40. 3 1
      packages/app/src/client/interfaces/react-bootstrap-typeahead.ts
  41. 2 11
      packages/app/src/client/legacy/crowi-presentation.js
  42. 12 1
      packages/app/src/client/services/AdminAppContainer.js
  43. 77 3
      packages/app/src/client/services/AdminGeneralSecurityContainer.js
  44. 2 0
      packages/app/src/client/services/AdminHomeContainer.js
  45. 2 1
      packages/app/src/client/services/ContextExtractor.tsx
  46. 16 1
      packages/app/src/client/services/user-ui-settings.ts
  47. 6 6
      packages/app/src/client/util/GrowiRenderer.js
  48. 0 1
      packages/app/src/client/util/interceptor/detach-code-blocks.js
  49. 3 3
      packages/app/src/client/util/reveal/plugins/growi-renderer.js
  50. 18 0
      packages/app/src/components/Admin/AdminHome/AdminHome.jsx
  51. 29 1
      packages/app/src/components/Admin/App/AppSettingsPageContents.jsx
  52. 25 14
      packages/app/src/components/Admin/App/ConfirmModal.tsx
  53. 80 0
      packages/app/src/components/Admin/App/MaintenanceMode.tsx
  54. 100 5
      packages/app/src/components/Admin/App/V5PageMigration.tsx
  55. 3 0
      packages/app/src/components/Admin/CustomHeaderEditor.jsx
  56. 367 104
      packages/app/src/components/Admin/Security/SecuritySetting.jsx
  57. 0 95
      packages/app/src/components/Admin/UserGroup/UserGroupCreateModal.tsx
  58. 1 2
      packages/app/src/components/Admin/UserGroup/UserGroupDeleteModal.tsx
  59. 13 13
      packages/app/src/components/Admin/UserGroup/UserGroupDropdown.tsx
  60. 71 17
      packages/app/src/components/Admin/UserGroup/UserGroupForm.tsx
  61. 122 0
      packages/app/src/components/Admin/UserGroup/UserGroupModal.tsx
  62. 54 7
      packages/app/src/components/Admin/UserGroup/UserGroupPage.tsx
  63. 28 11
      packages/app/src/components/Admin/UserGroup/UserGroupTable.tsx
  64. 92 0
      packages/app/src/components/Admin/UserGroupDetail/UpdateParentConfirmModal.tsx
  65. 159 72
      packages/app/src/components/Admin/UserGroupDetail/UserGroupDetailPage.tsx
  66. 1 0
      packages/app/src/components/Admin/UserGroupDetail/UserGroupUserModal.jsx
  67. 1 2
      packages/app/src/components/Common/ClosableTextInput.tsx
  68. 21 12
      packages/app/src/components/Common/Dropdown/PageItemControl.tsx
  69. 3 1
      packages/app/src/components/Fab.jsx
  70. 3 1
      packages/app/src/components/Hotkeys/Subscribers/CreatePage.jsx
  71. 4 1
      packages/app/src/components/LegacyPrivatePagesMigrationModal.tsx
  72. 3 3
      packages/app/src/components/MyDraftList/Draft.jsx
  73. 1 1
      packages/app/src/components/Navbar/AuthorInfo.jsx
  74. 6 3
      packages/app/src/components/Navbar/GlobalSearch.tsx
  75. 1 1
      packages/app/src/components/Navbar/GrowiContextualSubNavigation.tsx
  76. 1 1
      packages/app/src/components/Navbar/GrowiSubNavigation.tsx
  77. 18 17
      packages/app/src/components/Navbar/PageEditorModeManager.jsx
  78. 7 9
      packages/app/src/components/Navbar/PersonalDropdown.jsx
  79. 8 5
      packages/app/src/components/Page.jsx
  80. 14 6
      packages/app/src/components/Page/RevisionLoader.jsx
  81. 7 4
      packages/app/src/components/Page/RevisionRenderer.jsx
  82. 0 110
      packages/app/src/components/Page/TagsInput.jsx
  83. 86 0
      packages/app/src/components/Page/TagsInput.tsx
  84. 2 7
      packages/app/src/components/Page/TrashPageAlert.jsx
  85. 219 0
      packages/app/src/components/PageComment.tsx
  86. 26 15
      packages/app/src/components/PageComment/Comment.jsx
  87. 4 4
      packages/app/src/components/PageComment/CommentEditor.jsx
  88. 0 31
      packages/app/src/components/PageComment/CommentEditorLazyRenderer.jsx
  89. 33 0
      packages/app/src/components/PageComment/CommentEditorLazyRenderer.tsx
  90. 2 0
      packages/app/src/components/PageComment/ReplayComments.jsx
  91. 0 241
      packages/app/src/components/PageComments.jsx
  92. 0 45
      packages/app/src/components/PageContentFooter.jsx
  93. 33 0
      packages/app/src/components/PageContentFooter.tsx
  94. 24 5
      packages/app/src/components/PageCreateModal.jsx
  95. 1 1
      packages/app/src/components/PageDeleteModal.tsx
  96. 1 1
      packages/app/src/components/PageDuplicateModal.tsx
  97. 11 0
      packages/app/src/components/PageEditor.jsx
  98. 1 11
      packages/app/src/components/PageEditor/Editor.jsx
  99. 12 5
      packages/app/src/components/PageEditor/LinkEditModal.jsx
  100. 0 123
      packages/app/src/components/PageEditor/Preview.jsx

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

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

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

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

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

+ 1 - 1
.github/workflows/pr-to-master.yml

@@ -30,7 +30,7 @@ jobs:
         !startsWith( github.head_ref, 'dependabot/' ))
 
     steps:
-      - uses: amannn/action-semantic-pull-request@v3.4.2
+      - uses: amannn/action-semantic-pull-request@v3.4.5
         with:
           types: |
             feat

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

+ 12 - 12
.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
@@ -185,7 +185,7 @@ jobs:
       fail-fast: false
       matrix:
         # List string expressions that is comma separated ids of tests in "test/cypress/integration"
-        spec-group: ['1', '2', '3', '4', '5', '6']
+        spec-group: ['1', '2', '3', '4']
 
     services:
       mongodb:
@@ -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
@@ -250,7 +250,7 @@ jobs:
         cat config/ci/.env.local.for-auto-install >> .env.production.local
 
     - name: Cypress Run
-      uses: cypress-io/github-action@v2
+      uses: cypress-io/github-action@v3
       with:
         working-directory: ./packages/app
         install: false

+ 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

+ 66 - 1
CHANGELOG.md

@@ -1,9 +1,74 @@
 # Changelog
 
-## [Unreleased](https://github.com/weseek/growi/compare/v4.5.14...HEAD)
+## [Unreleased](https://github.com/weseek/growi/compare/v5.0.0...HEAD)
 
 *Please do not manually update this file. We've automated the process.*
 
+## [v5.0.0](https://github.com/weseek/growi/compare/v4.5.15...v5.0.0) - 2022-04-01
+
+### 💎 Features
+
+- feat: Support Elasticsearch 7 (#5080) @yuki-takei
+- feat: Elasticsearch reindex on boot (#5149) @LuqmanHakim-Grune
+- feat: PageTree and re-impl SearchResult with list group (#5286) @yuki-takei
+- feat: Rename(Move) by Drag & Drop (#5292) @hakumizuki
+- feat: Maintenance mode (#5486) @hakumizuki
+- feat: Delete permission config (#5527) @hakumizuki
+
+### 🚀 Improvement
+
+- imprv: Show comments in search page result (#5645) @yuki-takei
+- imprv: Add description for user addition (#5614) @hakumizuki
+- imprv: Validate deletion settings (#5581) @hakumizuki
+
+### 🐛 Bug Fixes
+
+- fix: Swiping to previous/next page for Mac users (5.0.x) (#5491) @hakumizuki
+- fix: Guest User Access Dropdown shows wrong value (#5643) @miya
+- fix: Show full text on presentation mode (#5636) @hakumizuki
+- fix: Displaying minimum length of password (#5630) @Yohei-Shiina
+- fix: Domain whitelist is not respected (fix #5408) (#5470) @yuto-oweseek
+- fix: Add tags to pages restricted by specified groups on View mode (#5457) @yuto-oweseek
+
+### 🧰 Maintenance
+
+- ci(deps-dev): bump plantuml-encoder from 1.2.5 to 1.4.0 (#5633) @dependabot
+- ci(deps-dev): bump codemirror from 5.63.0 to 5.64.0 (#4777) @dependabot
+- ci(deps): bump nanoid from 3.1.30 to 3.2.0 (#5142) @dependabot
+- support: Upgrade openid client (#5185) @mudana-grune
+- ci(deps): bump amannn/action-semantic-pull-request from 3.4.2 to 3.4.5 (#4559) @dependabot
+- ci(deps): bump extend from 3.0.1 to 3.0.2 (#5222) @dependabot
+- ci(deps-dev): bump jquery-ui from 1.12.1 to 1.13.0 (#4548) @dependabot
+- ci(deps): bump actions/setup-node from 2 to 3 (#5437) @dependabot
+- ci(deps): bump actions/checkout from 2 to 3 (#5462) @dependabot
+- ci(deps): bump peter-evans/dockerhub-description from 2 to 3 (#5615) @dependabot
+- ci(deps): bump actions/cache from 2 to 3 (#5584) @dependabot
+- ci(deps-dev): bump reveal.js from 3.6.0 to 4.3.1 (#5603) @dependabot
+- support: Update yarn git-hosted-info v2.8.8 to v2.8.9 (#5215) @LuqmanHakim-Grune
+- support: dependabot trim-off-newlines (#5336) @mudana-grune
+- support: dependabot @npmcli/git (#5337) @mudana-grune
+- support: dependabot highlight.js (#5352) @mudana-grune
+- support: dependabot extend (#5335) @mudana-grune
+- support: dependabot ajv (#5333) @mudana-grune
+- support: dependabot dot-drop (#5204) @LuqmanHakim-Grune
+- support: update nanoid yarn.lock v3.1.30 to v3.2.0 (#5216) @LuqmanHakim-Grune
+- support: update validator version (#5562) @LuqmanHakim-Grune
+
+## [v4.5.15](https://github.com/weseek/growi/compare/v4.5.14...v4.5.15) - 2022-02-17
+
+### 🚀 Improvement
+
+- imprv: Hide forgot password when localstrategy is disabled (#5380) @yuki-takei
+
+### 🐛 Bug Fixes
+
+- fix: The condition to attempt to reconnect to Elasticsearch (#5344) @yuki-takei
+- fix: Highlight-addons and drawio-viewer for view missing (#5376) @yuki-takei
+
+### 🧰 Maintenance
+
+- support:  modify docker-compose indent (#5322) @yuto-oweseek
+
 ## [v4.5.14](https://github.com/weseek/growi/compare/v4.5.13...v4.5.14) - 2022-02-10
 
 ### 💎 Features

+ 1 - 1
lerna.json

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

+ 1 - 1
package.json

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

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

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

+ 1 - 0
packages/app/config/logger/config.dev.js

@@ -36,6 +36,7 @@ module.exports = {
   'growi:services:*': 'debug',
   // 'growi:StaffCredit': 'debug',
   // 'growi:cli:StickyStretchableScroller': 'debug',
+  // 'growi:cli:ItemsTree': 'debug',
   'growi:searchResultList': 'debug',
 
 };

+ 2 - 2
packages/app/docker/README.md

@@ -12,8 +12,8 @@ Supported tags and respective Dockerfile links
 
 * [`5.0.0`, `5.0`, `5`, `latest` (Dockerfile)](https://github.com/weseek/growi/blob/v5.0.0/docker/Dockerfile)
 * [`5.0.0-nocdn`, `5.0-nocdn`, `5-nocdn`, `latest-nocdn` (Dockerfile)](https://github.com/weseek/growi/blob/v5.0.0/docker/Dockerfile)
-* [`4.5.14`, `4.5`, `4`, `latest` (Dockerfile)](https://github.com/weseek/growi/blob/v4.5.14/docker/Dockerfile)
-* [`4.5.14-nocdn`, `4.5-nocdn`, `4-nocdn`, `latest-nocdn` (Dockerfile)](https://github.com/weseek/growi/blob/v4.5.14/docker/Dockerfile)
+* [`4.5.15`, `4.5`, `4`, `latest` (Dockerfile)](https://github.com/weseek/growi/blob/v4.5.15/docker/Dockerfile)
+* [`4.5.15-nocdn`, `4.5-nocdn`, `4-nocdn`, `latest-nocdn` (Dockerfile)](https://github.com/weseek/growi/blob/v4.5.15/docker/Dockerfile)
 * [`4.4.13`, `4.4` (Dockerfile)](https://github.com/weseek/growi/blob/v4.4.13/docker/Dockerfile)
 * [`4.4.13-nocdn`, `4.4-nocdn` (Dockerfile)](https://github.com/weseek/growi/blob/v4.4.13/docker/Dockerfile)
 

+ 18 - 17
packages/app/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@growi/app",
-  "version": "5.0.0-RC.8",
+  "version": "5.0.1-RC.0",
   "license": "MIT",
   "scripts": {
     "//// for production": "",
@@ -58,13 +58,15 @@
   },
   "dependencies": {
     "@browser-bunyan/console-formatted-stream": "^1.6.2",
+    "@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.8",
-    "@growi/plugin-attachment-refs": "^5.0.0-RC.8",
-    "@growi/plugin-lsx": "^5.0.0-RC.8",
-    "@growi/plugin-pukiwiki-like-linker": "^5.0.0-RC.8",
-    "@growi/slack": "^5.0.0-RC.8",
+    "@growi/codemirror-textlint": "^5.0.1-RC.0",
+    "@growi/plugin-attachment-refs": "^5.0.1-RC.0",
+    "@growi/plugin-lsx": "^5.0.1-RC.0",
+    "@growi/plugin-pukiwiki-like-linker": "^5.0.1-RC.0",
+    "@growi/slack": "^5.0.1-RC.0",
     "@promster/express": "^7.0.2",
     "@promster/server": "^7.0.4",
     "@slack/events-api": "^3.0.0",
@@ -90,8 +92,6 @@
     "date-fns": "^2.23.0",
     "detect-indent": "^7.0.0",
     "diff": "^5.0.0",
-    "@elastic/elasticsearch6": "npm:@elastic/elasticsearch@^6.8.7",
-    "@elastic/elasticsearch7": "npm:@elastic/elasticsearch@^7.16.0",
     "diff_match_patch": "^0.1.1",
     "entities": "^2.0.0",
     "esa-node": "^0.2.2",
@@ -102,7 +102,7 @@
     "express-mongo-sanitize": "^2.1.0",
     "express-rate-limit": "^5.3.0",
     "express-session": "^1.16.1",
-    "express-validator": "^6.1.1",
+    "express-validator": "^6.14.0",
     "express-webpack-assets": "^0.1.0",
     "extensible-custom-error": "^0.0.7",
     "graceful-fs": "^4.1.11",
@@ -127,7 +127,7 @@
     "nocache": "^3.0.1",
     "nodemailer": "^6.6.2",
     "nodemailer-ses-transport": "~1.5.0",
-    "openid-client": "=2.5.0",
+    "openid-client": "^5.1.2",
     "p-retry": "^4.0.0",
     "passport": "^0.5.0",
     "passport-github": "^1.1.0",
@@ -150,13 +150,13 @@
     "socket.io": "^4.2.0",
     "stream-to-promise": "^3.0.0",
     "string-width": "=4.2.2",
-    "swagger-jsdoc": "^3.4.0",
+    "swagger-jsdoc": "^6.1.0",
     "swig-templates": "^2.0.2",
     "uglifycss": "^0.0.29",
     "universal-bunyan": "^0.9.2",
     "unzipper": "^0.10.5",
     "url-join": "^4.0.0",
-    "validator": "^13.6.0",
+    "validator": "^13.7.0",
     "ws": "^8.3.0",
     "xss": "^1.0.6"
   },
@@ -167,7 +167,7 @@
   },
   "devDependencies": {
     "@alienfast/i18next-loader": "^1.1.4",
-    "@growi/ui": "^5.0.0-RC.8",
+    "@growi/ui": "^5.0.1-RC.0",
     "@handsontable/react": "=2.1.0",
     "@types/compression": "^1.7.0",
     "@types/express": "^4.17.11",
@@ -179,7 +179,7 @@
     "browser-sync": "^2.27.7",
     "bunyan-debug": "^2.0.0",
     "cli": "~1.0.1",
-    "codemirror": "^5.63.0",
+    "codemirror": "^5.64.0",
     "colors": "=1.4.0",
     "connect-browser-sync": "^2.1.0",
     "core-js": "=2.6.9",
@@ -189,8 +189,8 @@
     "eazy-logger": "^3.1.0",
     "emoji-mart": "^3.0.1",
     "markdown-it-emoji-mart": "^0.1.1",
-    "eslint-plugin-regex": "^1.8.0",
     "eslint-plugin-cypress": "^2.12.1",
+    "eslint-plugin-regex": "^1.8.0",
     "file-loader": "^5.0.2",
     "handsontable": "=6.2.2",
     "hard-source-webpack-plugin": "^0.13.1",
@@ -225,7 +225,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",
@@ -236,10 +236,11 @@
     "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",
+    "simplebar-react": "^2.3.6",
     "socket.io-client": "^4.2.0",
     "sticky-events": "^3.4.11",
     "style-loader": "^1.0.0",

+ 13 - 13
packages/app/resource/cdn-manifests.js

@@ -55,28 +55,28 @@ module.exports = {
     },
     {
       name: 'codemirror-dialog',
-      url: 'https://cdn.jsdelivr.net/npm/codemirror@5.63.0/addon/dialog/dialog.min.js',
+      url: 'https://cdn.jsdelivr.net/npm/codemirror@5.64.0/addon/dialog/dialog.min.js',
       args: {
         integrity: '',
       },
     },
     {
       name: 'codemirror-keymap-vim',
-      url: 'https://cdn.jsdelivr.net/npm/codemirror@5.63.0/keymap/vim.min.js',
+      url: 'https://cdn.jsdelivr.net/npm/codemirror@5.64.0/keymap/vim.min.js',
       args: {
         integrity: '',
       },
     },
     {
       name: 'codemirror-keymap-emacs',
-      url: 'https://cdn.jsdelivr.net/npm/codemirror@5.63.0/keymap/emacs.min.js',
+      url: 'https://cdn.jsdelivr.net/npm/codemirror@5.64.0/keymap/emacs.min.js',
       args: {
         integrity: '',
       },
     },
     {
       name: 'codemirror-keymap-sublime',
-      url: 'https://cdn.jsdelivr.net/npm/codemirror@5.63.0/keymap/sublime.min.js',
+      url: 'https://cdn.jsdelivr.net/npm/codemirror@5.64.0/keymap/sublime.min.js',
       args: {
         integrity: '',
       },
@@ -163,63 +163,63 @@ module.exports = {
     },
     {
       name: 'codemirror-dialog',
-      url: 'https://cdn.jsdelivr.net/npm/codemirror@5.63.0/addon/dialog/dialog.min.css',
+      url: 'https://cdn.jsdelivr.net/npm/codemirror@5.64.0/addon/dialog/dialog.min.css',
       args: {
         integrity: '',
       },
     },
     {
       name: 'codemirror-theme-eclipse',
-      url: 'https://cdn.jsdelivr.net/npm/codemirror@5.63.0/theme/eclipse.min.css',
+      url: 'https://cdn.jsdelivr.net/npm/codemirror@5.64.0/theme/eclipse.min.css',
       args: {
         integrity: '',
       },
     },
     {
       name: 'codemirror-theme-elegant',
-      url: 'https://cdn.jsdelivr.net/npm/codemirror@5.63.0/theme/elegant.min.css',
+      url: 'https://cdn.jsdelivr.net/npm/codemirror@5.64.0/theme/elegant.min.css',
       args: {
         integrity: '',
       },
     },
     {
       name: 'codemirror-theme-neo',
-      url: 'https://cdn.jsdelivr.net/npm/codemirror@5.63.0/theme/neo.min.css',
+      url: 'https://cdn.jsdelivr.net/npm/codemirror@5.64.0/theme/neo.min.css',
       args: {
         integrity: '',
       },
     },
     {
       name: 'codemirror-theme-mdn-like',
-      url: 'https://cdn.jsdelivr.net/npm/codemirror@5.63.0/theme/mdn-like.min.css',
+      url: 'https://cdn.jsdelivr.net/npm/codemirror@5.64.0/theme/mdn-like.min.css',
       args: {
         integrity: '',
       },
     },
     {
       name: 'codemirror-theme-material',
-      url: 'https://cdn.jsdelivr.net/npm/codemirror@5.63.0/theme/material.min.css',
+      url: 'https://cdn.jsdelivr.net/npm/codemirror@5.64.0/theme/material.min.css',
       args: {
         integrity: '',
       },
     },
     {
       name: 'codemirror-theme-dracula',
-      url: 'https://cdn.jsdelivr.net/npm/codemirror@5.63.0/theme/dracula.min.css',
+      url: 'https://cdn.jsdelivr.net/npm/codemirror@5.64.0/theme/dracula.min.css',
       args: {
         integrity: '',
       },
     },
     {
       name: 'codemirror-theme-monokai',
-      url: 'https://cdn.jsdelivr.net/npm/codemirror@5.63.0/theme/monokai.min.css',
+      url: 'https://cdn.jsdelivr.net/npm/codemirror@5.64.0/theme/monokai.min.css',
       args: {
         integrity: '',
       },
     },
     {
       name: 'codemirror-theme-twilight',
-      url: 'https://cdn.jsdelivr.net/npm/codemirror@5.63.0/theme/twilight.min.css',
+      url: 'https://cdn.jsdelivr.net/npm/codemirror@5.64.0/theme/twilight.min.css',
       args: {
         integrity: '',
       },

+ 35 - 8
packages/app/resource/locales/en_US/admin/admin.json

@@ -22,13 +22,30 @@
   "v5_page_migration": {
     "page_tree_not_avaliable" : "Page tree feature is not available yet.",
     "go_to_settings": "Go to settings to enable the feature",
-    "migration_desc": "Some of the public pages have the old schema. To take advantage of new features such as page trees and easy renaming, please upgrade the schema of all your pages.",
+    "migration_desc": "There are some pages with old v4 compatibility. To take advantage of new features such as page trees and easy renaming, please convert all your pages to v5 compatibility.",
     "migration_note": "Note: You will lose unique constraints from the page paths.",
-    "upgrade_to_v5": "Upgrade to V5",
-    "modal_migration_warning": "This process may take long. It is highly recommended that administrators tell users not to create, modify, or delete pages during migration.",
-    "start_upgrading": "Start upgrading",
-    "successfully_started": "Succeeded to start migration",
-    "already_upgraded": "You have already completed upgrading"
+    "upgrade_to_v5": "Convert to v5 compatibility",
+    "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",
+    "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",
+    "under_maintenance_mode": "Under Maintenance Mode",
+    "failed_to_start_maintenance_mode": "Failed to start maintenance mode",
+    "failed_to_end_maintenance_mode": "Failed to end maintenance mode",
+    "successfully_started_maintenance_mode": "Succussfully started maintenance mode",
+    "successfully_ended_maintenance_mode": "Succussfully ended maintenance mode",
+    "warning_message_to_start": "You will NOT able to access other than admin settings page. General users will NOT able to access to any contents until maintenance mode ends manually.",
+    "warning_message_to_end": "Please make sure that \"data importing\" or \"upgrading to v5\" is already done or not. If not, it is highly recommended to keep maintenance mode on.",
+    "supplymentary_message_to_start": "As for the API, only the administrator API will be functional.",
+    "start_maintenance_mode": "Start maintenance mode",
+    "end_maintenance_mode": "End maintenance mode",
+    "description": "Maintenance mode restricts all user operations. Always start the maintenance mode before \"importing data\" and \"upgrading to V5\". To exit, go to \"Security Settings\" > \"Maintenance Mode\"."
   },
   "app_setting": {
     "site_name": "Site name",
@@ -458,7 +475,11 @@
     "deny_create_group": "You can't create a new group with the current settings.",
     "group_name": "Group name",
     "group_example": "e.g. : Group1",
+    "parent_group": "Parent Group",
+    "select_parent_group": "Select Parent Group",
+    "release_parent_group": "Release parent group",
     "add_modal": {
+      "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}}",
@@ -469,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",
@@ -478,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."
     }
   }
 }

+ 3 - 1
packages/app/resource/locales/en_US/notifications/passwordReset.txt

@@ -2,9 +2,11 @@ Password Reset
 
 Hi, {{ email }}
 
-A request has been received to change the password your GROWI account {{ appTitle }}.
+A request has been received to change the password your GROWI ({{ appTitle }}) account.
 To reset your password, click on the link below.
 
 {{ url }}
 
+This link will expire in 10 minutes at  {{ expiredAt }}.
+
 If you did not request a password reset, you can safely ignore this email.

+ 3 - 1
packages/app/resource/locales/en_US/notifications/userActivation.txt

@@ -2,9 +2,11 @@ Account confirmation
 
 Hi, {{ email }}
 
-An acount has been created in GROWI {{ appTitle }}.
+An acount has been created in GROWI ({{ appTitle }}).
 To activate your account, click on the link below.
 
 {{ url }}
 
+This link will expire in 1 hour at  {{ expiredAt }}.
+
 If you did not created the account, you can safely ignore this email.

+ 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

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

@@ -111,7 +111,7 @@
   "Create under": "Create page under below:",
   "Wiki Management Home Page": "Wiki Management Home Page",
   "App Settings": "App Settings",
-  "V5 Page Migration": "V5 Page Migration",
+  "V5 Page Migration": "Convert To V5 Compatibility",
   "GROWI.5.0_new_schema": "GROWI.5.0 new schema",
   "See_more_detail_on_new_schema": "See more detail on <a href='#'>{{url}}</a> <i class='icon-share-alt'></i> ",
   "Site URL settings": "Site URL settings",
@@ -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",
@@ -393,7 +395,7 @@
     "notfound_or_forbidden": "Original page is not found or forbidden.",
     "already_exists": "New page is already exists.",
     "outdated": "Page is updated someone and now outdated.",
-    "user_not_admin": "Only admin user can delete completely"
+    "user_not_admin": "Only admin user can delete"
   },
   "page_history": {
     "revision_list": "Revision list",
@@ -419,7 +421,7 @@
     },
     "help": {
       "redirect": "Redirect to new page if someone accesses under this path",
-      "metadata": "Remains last update user and updated date as is",
+      "metadata": "Last update user and updated date will remain the same",
       "recursive": "Move/Rename children of under this path recursively"
     }
   },
@@ -652,7 +654,7 @@
   },
   "security_setting": {
     "Guest Users Access": "Guest users access",
-    "Fixed by env var": "This is fixed by the env var <code>%s=%s</code>.",
+    "Fixed by env var": "This is fixed by the env var <code>{{key}}={{value}}</code>.",
     "Register limitation": "Register limitation",
     "Register limitation desc": "Restriction of new users' registration",
     "The whitelist of registration permission E-mail address": "The whitelist of registration permission E-mail address",
@@ -667,9 +669,16 @@
     "page_listing_1_desc": "Show pages that are restricted by 'Only me' option when listing/searching",
     "page_listing_2": "Page listing/searching<br>restricted by User group",
     "page_listing_2_desc": "Show pages that are restricted by User group when listing/searching",
-    "page_access_and_delete_rights": "Page access / Delete rights",
-    "complete_deletion": "Restrict complete deletion of pages",
-    "complete_deletion_explain": "Restricts users who can completely delete pages.",
+    "page_access_rights": "Page access",
+    "page_delete_rights": "Delete rights",
+    "page_delete": "Page Delete",
+    "page_delete_completely": "Page Delete Completely",
+    "other_options": "Other options",
+    "deletion_explain": "Restricts users who can trash the selected single page.",
+    "complete_deletion_explain": "Restricts users who can completely delete  selected single page.",
+    "recursive_deletion_explain": "Restricts users who can trash pages including descendants.",
+    "recursive_complete_deletion_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",
     "admin_and_author": "Admin and author",
     "anyone": "Anyone",
@@ -677,6 +686,8 @@
     "max_age": "Max age (msec)",
     "max_age_desc": "Specifies the number (in milliseconds) to expire users session.<br>Default: 2592000000 (30days)",
     "max_age_caution": "Restarting the server is required after you modify this value.",
+    "forced_update_desc": "Settings have been forcibly changed. Previous setting: ",
+    "page_delete_rights_caution": "The \"Delete / Delete All\" permission (including descendant pages) is forced to be stronger than the \"Delete / Completely Delete\" permission. <br> <br> Anyone > Admin and autor > Admin only",
     "Authentication mechanism settings": "Authentication Mechanism Settings",
     "setup_is_not_yet_complete": "Setup is not yet complete",
     "alert_siteUrl_is_not_set": "'Site URL' is NOT set. Set it from the {{link}}",
@@ -1019,7 +1030,13 @@
       "4": "Medium Skin Tone",
       "5": "Medium-Dark Skin Tone",
       "6": "Dark Skin Tone"
-    }
+    },
+  "maintenance_mode":{
+    "maintenance_mode": "Maintenance Mode",
+    "growi_is_under_maintenance": "GROWI is under maintenance. Please wait until it ends.",
+    "admin_page": "Admin Page",
+    "login": "Login",
+    "logout": "Logout"
   },
   "pagetree": {
     "private_legacy_pages": "Private Legacy Pages",
@@ -1031,5 +1048,10 @@
     "same_page_name_exists": "Same page name exits as「{{pageName}}」",
     "same_page_name_exists_at_path" : "Same page name as {{pageName}} exists at {{path}} ",
     "select_page_to_see" : "Select a page to see"
+  },
+  "user_group": {
+    "select_group": "Select group",
+    "belonging_to_no_group": "Could not find the groups you belong to.",
+    "manage_user_groups": "Manage user groups"
   }
 }

+ 35 - 8
packages/app/resource/locales/ja_JP/admin/admin.json

@@ -22,13 +22,30 @@
   "v5_page_migration": {
     "page_tree_not_avaliable" : "Page Tree 機能は現在使用できません。",
     "go_to_settings": "設定する",
-    "migration_desc": "公開されているページに古いスキーマのものが存在します。ページツリーや簡単なリネームなどの新機能を利用するには、全てのページのスキーマをアップグレードしてください。",
+    "migration_desc": "公開されているページに 古い v4 互換形式のものが存在します。ページツリーや簡単なリネームなどの新機能を利用するには、全てのページを v5 互換形式に変換してください。",
     "migration_note": "注意: ページパスからユニーク制約が失われます。",
-    "upgrade_to_v5": "V5 にアップグレード",
-    "modal_migration_warning": "管理者はユーザーに、マイグレーション中はページを作成・変更・削除しないように伝えることを強くお勧めします。",
-    "start_upgrading": "アップグレードを開始",
-    "successfully_started": "正常にマイグレーションが開始されました",
-    "already_upgraded": "アップグレードは既に完了しています"
+    "upgrade_to_v5": "v5 互換形式 へ変換",
+    "modal_migration_warning": "管理者はユーザーに、v5 互換形式への変換中はページを作成・変更・削除しないように伝えることを強くお勧めします。",
+    "start_upgrading": "v5 互換形式への変換を開始",
+    "successfully_started": "正常に v5 互換形式への変換が開始されました",
+    "already_upgraded": "v5 互換形式への変換は既に完了しています",
+    "header_upgrading_progress": "アップグレード進行度",
+    "migration_succeeded": "アップグレードが正常に完了しました!メンテナンスモードを終了して、GROWI を使用することができます。",
+    "migration_failed": "アップグレードが失敗しました。失敗した場合の対処法は GROWI docs を参照してください。"
+  },
+  "maintenance_mode": {
+    "maintenance_mode": "メンテナンスモード",
+    "under_maintenance_mode": "メンテナンスモード中",
+    "failed_to_start_maintenance_mode": "メンテナンスモードを開始できませんでした",
+    "failed_to_end_maintenance_mode": "メンテナンスモードを終了できませんでした",
+    "successfully_started_maintenance_mode": "メンテナンスモードを開始しました",
+    "successfully_ended_maintenance_mode": "メンテナンスモードを終了しました",
+    "warning_message_to_start": "メンテナンスモード中は管理画面にしかアクセスできなくなり、一般ユーザーは全ての操作が不能になります。",
+    "warning_message_to_end": "「データのインポート」および「V5 へのアップグレード」が進行中の場合は、処理が終了するまでメンテナンスモードを終了しないようにすることを推奨します。",
+    "supplymentary_message_to_start": "API についても管理者用 API しか機能しなくなります。",
+    "start_maintenance_mode": "メンテナンスモードを開始する",
+    "end_maintenance_mode": "メンテナンスモードを終了する",
+    "description": "メンテナンスモードでは、ユーザーのあらゆる操作を制限します。「データのインポート」および「V5 へのアップグレード」の際には必ずメンテナンスモードを開始してから行ってください。終了するには「セキュリティ設定」>「メンテナンスモード」から操作してください。"
   },
   "app_setting": {
     "site_name": "サイト名",
@@ -457,7 +474,11 @@
     "deny_create_group": "新規グループの作成はできません。",
     "group_name": "グループ名",
     "group_example": "例: Group1",
+    "parent_group": "親グループ",
+    "select_parent_group": "親グループを選択",
+    "release_parent_group": "親グループの解除",
     "add_modal": {
+      "description": "追加したユーザーは、親グループにも追加されます。",
       "add_user": "グループへのユーザー追加",
       "search_option": "検索オプション",
       "enable_option": "{{option}}を有効にする",
@@ -468,7 +489,6 @@
     "group_list": "グループ一覧",
     "child_group_list": "子グループ一覧",
     "back_to_list": "グループ一覧に戻る",
-    "back_to_ancestors_group": "祖先グループに戻る",
     "basic_info": "基本情報",
     "user_list": "ユーザー一覧",
     "created_group": "グループを作成しました",
@@ -477,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": "このオプションを有効化すると、親グループ変更後に祖先グループに足りないユーザーが存在した場合にそれらのユーザーを強制的に追加することができます"
     }
   }
 }

+ 3 - 1
packages/app/resource/locales/ja_JP/notifications/passwordReset.txt

@@ -2,9 +2,11 @@
 
 こんにちは, {{ email }}
 
-あなたのGROWIアカウント {{ appTitle }} から、パスワード再設定のリクエストがありました。
+あなたのGROWI ({{ appTitle }}) アカウントから、パスワード再設定のリクエストがありました。
 パスワードをリセットするには、以下のリンクをクリックしてください。
 
 {{ url }}
 
+このリンクは10分後の {{ expiredAt }} に失効します。
+
 もしこのリクエストに心当たりがない場合は、このメールを無視してください。

+ 3 - 1
packages/app/resource/locales/ja_JP/notifications/userActivation.txt

@@ -2,10 +2,12 @@
 
 {{ email }} さん
 
-GROWI {{ appTitle }} で仮登録が完了いたしました。
+GROWI ({{ appTitle }}) で仮登録が完了いたしました。
 
 ご本人様確認のため、下記リンクをクリックし、アカウントの本登録を完了させて下さい。
 
 {{ url }}
 
+このリンクは1時間後の {{ expiredAt }} に失効します。
+
 ※当メールの内容に心当たりがない場合は、このメールを無視してください。

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

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

+ 31 - 9
packages/app/resource/locales/ja_JP/translation.json

@@ -111,7 +111,7 @@
   "Create under": "ページを以下に作成",
   "Wiki Management Home Page": "Wiki管理トップ",
   "App Settings": "アプリ設定",
-  "V5 Page Migration": "V5 ページマイグレーション",
+  "V5 Page Migration": "V5 互換形式 への変換",
   "GROWI.5.0_new_schema": "GROWI.5.0における新スキーマについて",
   "See_more_detail_on_new_schema": "詳しくは<a href='#'>{{url}}</a><i class='icon-share-alt'></i>を参照ください。",
   "Site URL settings": "サイトURL設定",
@@ -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": "設定",
@@ -212,7 +214,7 @@
     },
     "form_help": {
       "email": "この Wiki では以下のメールアドレスのみ登録可能です。",
-      "password": "パスワードには、6文字以上の半角英数字または記号等を設定してください。",
+      "password": "パスワードには、8文字以上の半角英数字または記号等を設定してください。",
       "user_id": "ユーザーIDは、ユーザーページのURLなどに利用されます。半角英数字と一部の記号のみ利用できます。"
     }
   },
@@ -392,7 +394,7 @@
     "notfound_or_forbidden": "元のページが見つからないか、アクセス権がありません。",
     "already_exists": "新しいページが既に存在しています。",
     "outdated": "ページが他のユーザーによって更新されました。",
-    "user_not_admin": "権限のあるユーザーのみが完全削除できます"
+    "user_not_admin": "権限のあるユーザーのみが削除できます"
   },
   "page_history": {
     "revision_list": "更新履歴",
@@ -413,12 +415,12 @@
       "Rename this page only": "このページのみを移動/名前変更",
       "Force rename all child pages": "全ての配下のページを移動/名前変更する",
       "Other options": "その他のオプション",
-      "Do not update metadata": "不更新元数据",
+      "Do not update metadata": "メタデータを更新しない",
       "Redirect": "リダイレクトする"
     },
     "help": {
       "redirect": "アクセスされた際に自動的に新しいページにジャンプします",
-      "metadata": "Remains last update user and updated date as is",
+      "metadata": "最終更新ユーザー、最終更新日を更新せず維持します",
       "recursive": "配下のページも移動/名前変更します"
     }
   },
@@ -666,9 +668,16 @@
     "page_listing_1_desc": "ページのリスト表示や検索結果において、'自分のみ'に閲覧制限をしているページをアクセス権のないユーザーにも表示します。",
     "page_listing_2": "ページのリスト表示と検索<br>特定グループに閲覧制限しているページ",
     "page_listing_2_desc": "ページのリスト表示や検索結果において、特定グループにのみ閲覧制限をしているページをアクセス権のないユーザーにも表示します。",
-    "page_access_and_delete_rights": "ページの閲覧・削除権限",
-    "complete_deletion": "ページの完全削除",
-    "complete_deletion_explain": "ページを完全に削除できるユーザーを制限します。",
+    "page_access_rights": "ページの閲覧権限",
+    "page_delete_rights": "ページの削除権限",
+    "page_delete": "ゴミ箱に入れる",
+    "page_delete_completely": "完全に削除する",
+    "other_options": "その他のオプション",
+    "deletion_explain": "ページをゴミ箱に入れることができるユーザーを制限します。",
+    "complete_deletion_explain": "ページを完全削除することができるユーザーを制限します。",
+    "recursive_deletion_explain": "子孫を含めたページをゴミ箱に入れることができるユーザーを制限します。",
+    "recursive_complete_deletion_explain": "子孫を含めたページを完全削除することができるユーザーを制限します。",
+    "inherit": "単体のみと同じ",
     "admin_only": "管理者のみ可能",
     "admin_and_author": "管理者とページ作者が可能",
     "anyone": "誰でも可能",
@@ -676,6 +685,8 @@
     "max_age": "有効期間 (ミリ秒)",
     "max_age_desc": "ユーザーのセッション情報の有効期間をミリ秒で指定できます。<br>デフォルト値: 2592000000 (30日間)",
     "max_age_caution": "この値を変更した後は、サーバーを再起動する必要があります。",
+    "forced_update_desc": "設定が強制変更されました。前回の設定: ",
+    "page_delete_rights_caution": "「(子孫ページを含む)ゴミ箱に入れる操作 / 完全に削除する」の権限は、「ゴミ箱に入れる操作 / 完全に削除する」よりも強い権限になるように強制されます。 <br><br> 誰でも可能 > 管理者とページ作者が可能 > 管理者のみ可能",
     "Authentication mechanism settings": "認証機構設定",
     "setup_is_not_yet_complete":"セットアップはまだ完了してません",
     "alert_siteUrl_is_not_set": "'サイトURL' が設定されていません。{{link}} から設定してください。",
@@ -1011,7 +1022,13 @@
       "4": "ミディアムスキントーン",
       "5": "ミディアムダークスキントーン",
       "6": "肌の色が濃い"
-    }
+    },
+  "maintenance_mode":{
+    "maintenance_mode": "メンテナンスモード",
+    "growi_is_under_maintenance": "GROWI はメンテナンス中です。終了するまでお待ちください",
+    "admin_page": "管理画面へ",
+    "login": "ログイン",
+    "logout": "ログアウト"
   },
   "pagetree": {
     "private_legacy_pages": "旧形式のプライベートページ",
@@ -1023,5 +1040,10 @@
     "same_page_name_exists": "ページ名 「{{pageName}}」が重複しています",
     "same_page_name_exists_at_path" : "”{{path}}” において ”{{pageName}}”というページは複数存在しています。",
     "select_page_to_see" : "以下から遷移するページを選択してください。"
+  },
+  "user_group": {
+    "select_group": "グループを選ぶ",
+    "belonging_to_no_group": "所属しているグループが見つかりませんでした。",
+    "manage_user_groups": "グループ管理"
   }
 }

+ 36 - 9
packages/app/resource/locales/zh_CN/admin/admin.json

@@ -22,13 +22,30 @@
   "v5_page_migration": {
     "page_tree_not_avaliable": "Page Tree 功能不可用",
     "go_to_settings": "进入设置,启用该功能",
-    "migration_desc": "Some of the public pages have the old schema. To take advantage of new features such as page trees and easy renaming, please upgrade the schema of all your pages. ",
-    "migration_note": "Note: You will lose unique constraints from the page paths.",
-    "upgrade_to_v5": "Upgrade to V5",
-    "modal_migration_warning": "This process may take long. It is highly recommended that administrators tell users not to create, modify, or delete pages during migration.",
-    "start_upgrading": "Start upgrading",
-    "successfully_started": "Succeeded to start migration",
-    "already_upgraded": "You have already completed upgrading"
+    "migration_desc": "有一些页面具有旧的v4兼容性。为了利用新的功能,如页面树和容易重命名,请将您的所有页面转换为v5兼容性。",
+    "migration_note": "注意:你将失去页面路径的唯一约束。",
+    "upgrade_to_v5": "转换为v5兼容性",
+    "modal_migration_warning": "这个过程可能需要很长时间。强烈建议管理员告诉用户在转换期间不要创建、修改或删除页面。",
+    "start_upgrading": "开始转换为v5兼容性",
+    "successfully_started": "成功开始转换",
+    "already_upgraded": "你已经完成了向v5兼容性的转换",
+    "header_upgrading_progress": "升级进度",
+    "migration_succeeded": "您的升级已经成功完成! 退出维护模式,可以使用GROWI。",
+    "migration_failed": "升级失败。请参考GROWI的文档,了解在失败情况下该如何处理。"
+  },
+  "maintenance_mode": {
+    "maintenance_mode": "维护模式",
+    "under_maintenance_mode": "在维护模式下",
+    "failed_to_start_maintenance_mode": "启动维护模式失败",
+    "failed_to_end_maintenance_mode": "结束维护模式失败",
+    "successfully_started_maintenance_mode": "成功地启动了维护模式",
+    "successfully_ended_maintenance_mode": "成功地结束了维护模式",
+    "warning_message_to_start": "你将无法访问管理员设置以外的页面。普通用户将无法访问任何内容,直到维护模式手动结束。",
+    "warning_message_to_end": "如果 \"数据导入 \"和 \"升级到V5 \"正在进行中,建议在该过程完成之前不要退出维护模式。",
+    "supplymentary_message_to_start": "至于API,只有管理员的API将是有效的。",
+    "start_maintenance_mode": "启动维护模式",
+    "end_maintenance_mode": "结束维护模式",
+    "description": "维护模式限制了所有的用户操作。 在执行 \"数据导入 \"和 \"升级到V5 \"之前,务必启动维护模式。 要退出,进入 \"安全设置\">\"维护模式\"。"
   },
   "app_setting": {
     "site_name": "网站名称 ",
@@ -467,7 +484,11 @@
     "deny_create_group": "不能用当前设置创建新组。",
     "group_name": "组名",
     "group_example": "e.g.:第1组",
+    "parent_group": "父母组",
+    "select_parent_group": "选择父组",
+    "release_parent_group": "Release parent group",
     "add_modal": {
+      "description": "添加的用户也将被添加到所有的父组。",
       "add_user": "将用户添加到创建的组",
       "search_option": "搜索选项",
       "enable_option": "启用{{option}",
@@ -478,7 +499,6 @@
     "group_list": "组列表",
     "child_group_list": "儿童组名单",
     "back_to_list": "返回组列表",
-    "back_to_ancestors_group": "返回到祖先组",
     "basic_info": "基本信息",
     "user_list": "用户列表",
     "created_group": "已创建组",
@@ -487,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": "激活这个选项,如果在父组改变后,在祖先组中有缺失的用户,可以强制添加这些用户"
     }
   }
 }

+ 3 - 1
packages/app/resource/locales/zh_CN/notifications/passwordReset.txt

@@ -2,9 +2,11 @@
 
 嗨,{{ email }}
 
-已收到更改您 GROWI 帐户 {{appTitle}} 密码的请求。
+已收到更改您 GROWI ({{appTitle}}) 帐户 密码的请求。
 要重置密码,请单击下面的链接。
 
 {{ url }}
 
+这个链接在10分钟后的{ expiredAt }}失效。
+
 如果您没有要求重置密码,则可以放心地忽略此电子邮件。

+ 3 - 1
packages/app/resource/locales/zh_CN/notifications/userActivation.txt

@@ -2,9 +2,11 @@
 
 致{{ email }},
 
-已使用 GROWI {{ appTitle }} 创建帐户。
+已使用 GROWI ({{ appTitle }}) 创建帐户。
 单击下面的链接以激活您的帐户。
 
 {{ url }}
 
+这个链接将在1小时后即{{ expiredAt }}失效。
+
 如果您尚未创建,请忽略此电子邮件。

+ 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

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

@@ -119,7 +119,7 @@
 	"Create under": "Create page under below:",
 	"Wiki Management Home Page": "Wiki管理首页",
 	"App Settings": "系统设置",
-  "V5 Page Migration": "V5 Page Migration",
+  "V5 Page Migration": "转换为V5的兼容性",
   "GROWI.5.0_new_schema": "GROWI.5.0 new schema",
   "See_more_detail_on_new_schema": "更多详情请见<a href='#'>{{url}}</a> <i class='icon-share-alt'></i> ",
 	"Site URL settings": "主页URL设置",
@@ -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": "创建页面",
@@ -210,7 +212,7 @@
 		},
 		"form_help": {
 			"email": "您必须有下面列出的电子邮件地址才能注册此wiki。",
-			"password": "密码长度必须至少为6个字符。",
+			"password": "密码长度必须至少为8个字符。",
 			"user_id": "您创建的网页的URL将包含您的用户ID。您的用户ID可以由字母、数字和一些符号组成。"
 		}
 	},
@@ -371,7 +373,7 @@
 		"notfound_or_forbidden": "未找到或禁止原始页。",
 		"already_exists": "新建页面已存在",
 		"outdated": "页面已被某人更新,现在已过时。",
-		"user_not_admin": "仅管理员用户可以完全删除"
+		"user_not_admin": "仅管理员用户可以删除"
   },
   "page_history": {
     "revision_list": "修订清单",
@@ -392,12 +394,12 @@
       "Rename this page only": "仅重命名此页面",
       "Force rename all child pages": "强制重命名所有子页面 ",
       "Other options": "其他选项",
-      "Update metadata": "更新元数据",
+      "Do not update metadata": "不更新元数据",
       "Redirect": "重定向"
 		},
 		"help": {
       "redirect": "Redirect to new page if someone accesses <code>%s</code>",
-      "metadata": "Update last update user and updated date",
+      "metadata": "Remains last update user and updated date as is",
       "recursive": "Move/Rename children of under <code>%s</code> recursively"
 		}
 	},
@@ -625,9 +627,16 @@
 		"page_listing_1_desc": "列出/搜索时显示受“仅限我”选项限制的页面",
 		"page_listing_2": "页面列表/搜索<br>受用户组限制",
 		"page_listing_2_desc": "显示列出/搜索时受用户组限制的页面",
-    "page_access_and_delete_rights": "页面访问/删除权限",
-		"complete_deletion": "限制完全删除页面",
-		"complete_deletion_explain": "限制可以完全删除页面的用户。",
+    "page_access_rights": "页面访问",
+    "page_delete_rights": "删除权限",
+    "page_delete": "删除",
+    "page_delete_completely": "彻底删除",
+    "other_options": "其他选项",
+    "deletion_explain": "限制用户对选定的单一页面进行垃圾处理。",
+    "complete_deletion_explain": "限制可以完全删除所选单页的用户。",
+    "recursive_deletion_explain": "限制用户可以捣毁包括子孙在内的页面。",
+    "recursive_complete_deletion_explain": "限制可以完全删除页面的用户,包括子孙。",
+    "inherit": "继承(使用与单页相同的设置)。",
 		"admin_only": "仅管理员",
 		"admin_and_author": "管理员|作者",
 		"anyone": "任何人",
@@ -635,6 +644,8 @@
     "max_age": "有效期间  (msec)",
     "max_age_desc": "指定使用户会话过期的数量(以毫秒为单位)。<br>默认值: 2592000000 (30天)",
     "max_age_caution": "修改该值后需要重启服务器。",
+    "forced_update_desc": "设置已被强行更改。以前的设置: ",
+    "page_delete_rights_caution": "\"删除/全部删除\"权限(包括后代页面)被强制强于\"删除/完全删除\"权限。 <br> <br> 任何人 > 管理员|作者 > 仅管理员",
 		"Authentication mechanism settings": "身份验证机制设置",
 		"setup_is_not_yet_complete": "安装尚未完成",
 		"alert_siteUrl_is_not_set": "主页URL未设置,通过 {{link}} 设置",
@@ -912,19 +923,19 @@
     }
 	},
   "private_legacy_pages": {
-    "bulk_operation": "Bulk operation",
-    "convert_all_selected_pages": "Convert all to new v5 compatible format",
-    "alert_title": "You are viewing old v4 compatible private pages.",
-    "alert_desc1": "On this page, you can select pages with the checkbox and batch convert to the new v5 compatible format from the \"Bulk operation\" button at the top of the screen.",
-    "nopages_title": "Congratulations. Ready to use GROWI v5!",
-    "nopages_desc1": "Now all the pages you can manage seem to be in v5 compatible format.",
-    "detail_info": "See the detail information from <a href='https://docs.growi.org/en/admin-guide/upgrading/50x.html' target='_blank' class='alert-link'>Upgrading GROWI to v5.0.x <i class='icon-share-alt'></i></a>.",
+    "bulk_operation": "批量操作",
+    "convert_all_selected_pages": "全部转换为新的v5兼容格式",
+    "alert_title": "你正在查看旧的v4兼容的私人网页。",
+    "alert_desc1": "在这一页,你可以用复选框选择页面,并通过屏幕上方的批量操作按钮批量转换为新的v5兼容格式。",
+    "nopages_title": "恭喜你。准备使用GROWI v5!",
+    "nopages_desc1": "现在你能管理的所有页面似乎都是v5兼容的格式。",
+    "detail_info": "请参见 <a href='https://docs.growi.org/en/admin-guide/upgrading/50x.html' target='_blank' class='alert-link'>升级GROWI到v5.0.x <i class='icon-share-alt'></i></a>.的详细内容。",
     "modal": {
-      "title": "Convert to new v5 compatible format",
-      "converting_pages": "Converting pages",
-      "convert_recursively_label": "Convert child pages recursively.",
-      "convert_recursively_desc": "Convert pages under this path recursively.",
-      "button_label": "Convert"
+      "title": "转换为新的v5兼容格式",
+      "converting_pages": "转换页面",
+      "convert_recursively_label": "递归地转换子页面。",
+      "convert_recursively_desc": "递归地转换该路径下的页面。",
+      "button_label": "转换"
     }
   },
 	"to_cloud_settings": "進入 GROWI.cloud 的管理界面",
@@ -1023,6 +1034,13 @@
       "6": "深色肤色"
     }
   },
+  "maintenance_mode":{
+    "maintenance_mode": "维护模式",
+    "growi_is_under_maintenance": "GROWI正在进行维护。请等待,直到它结束。",
+    "admin_page": "管理员页",
+    "login": "登录",
+    "logout": "登出"
+  },
   "pagetree": {
     "private_legacy_pages": "私人遗留页面",
     "cannot_rename_a_title_that_contains_slash": "不能重命名包含 ’/' 的标题",
@@ -1033,5 +1051,10 @@
     "same_page_name_exists": "页面名称「{{pageName}}」是重复的",
     "same_page_name_exists_at_path" : "在”{{path}}” 中,有不止一个名为”{{pageName}}”的页面",
     "select_page_to_see" : "请在下面选择你想去的页面。"
+  },
+  "user_group": {
+    "select_group": "选择组别",
+    "belonging_to_no_group": "无法找到你所属的团体。",
+    "manage_user_groups": "管理用户组"
   }
 }

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

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

@@ -18,8 +18,8 @@ import TagsList from '../components/TagsList';
 import DisplaySwitcher from '../components/Page/DisplaySwitcher';
 import { defaultEditorOptions, defaultPreviewOptions } from '../components/PageEditor/OptionsSelector';
 import Page from '../components/Page';
-import PageComments from '../components/PageComments';
 import PageContentFooter from '../components/PageContentFooter';
+import PageComment from '../components/PageComment';
 import PageTimeline from '../components/PageTimeline';
 import CommentEditorLazyRenderer from '../components/PageComment/CommentEditorLazyRenderer';
 import ShareLinkAlert from '../components/Page/ShareLinkAlert';
@@ -120,9 +120,14 @@ Object.assign(componentMappings, {
 // additional definitions if data exists
 if (pageContainer.state.pageId != null) {
   Object.assign(componentMappings, {
-    'page-comments-list': <PageComments />,
-    'page-comment-write': <CommentEditorLazyRenderer />,
-    'page-content-footer': <PageContentFooter />,
+    'page-comments-list': <PageComment appContainer={appContainer} pageId={pageContainer.state.pageId} isReadOnly={false} titleAlign="left" />,
+    'page-comment-write': <CommentEditorLazyRenderer appContainer={appContainer} pageId={pageContainer.state.pageId} />,
+    'page-content-footer': <PageContentFooter
+      createdAt={new Date(pageContainer.state.createdAt)}
+      updatedAt={new Date(pageContainer.state.updatedAt)}
+      creator={pageContainer.state.creator}
+      revisionAuthor={pageContainer.state.revisionAuthor}
+    />,
 
     'recent-created-icon': <RecentlyCreatedIcon />,
   });

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

+ 12 - 1
packages/app/src/client/services/AdminAppContainer.js

@@ -58,6 +58,8 @@ export default class AdminAppContainer extends Container {
       s3ReferenceFileWithRelayMode: false,
 
       isEnabledPlugins: true,
+
+      isMaintenanceMode: false,
     };
 
   }
@@ -116,6 +118,7 @@ export default class AdminAppContainer extends Container {
       envGcsBucket: appSettingsParams.envGcsBucket,
       envGcsUploadNamespace: appSettingsParams.envGcsUploadNamespace,
       isEnabledPlugins: appSettingsParams.isEnabledPlugins,
+      isMaintenanceMode: appSettingsParams.isMaintenanceMode,
     });
 
     // if useOnlyEnvVarForFileUploadType is true, get fileUploadType from only env var and make the forms fixed.
@@ -454,9 +457,17 @@ export default class AdminAppContainer extends Container {
    * @memberOf AdminAppContainer
    */
   async v5PageMigrationHandler() {
-    const response = await this.appContainer.apiv3.post('/pages/v5-schema-migration');
+    const response = await this.appContainer.apiv3.post('/app-settings/v5-schema-migration');
     const { isV5Compatible } = response.data;
     return { isV5Compatible };
   }
 
+  async startMaintenanceMode() {
+    await this.appContainer.apiv3.post('/app-settings/maintenance-mode', { flag: true });
+  }
+
+  async endMaintenanceMode() {
+    await this.appContainer.apiv3.post('/app-settings/maintenance-mode', { flag: false });
+  }
+
 }

+ 77 - 3
packages/app/src/client/services/AdminGeneralSecurityContainer.js

@@ -1,5 +1,9 @@
 import { Container } from 'unstated';
 
+import {
+  PageSingleDeleteConfigValue, PageSingleDeleteCompConfigValue,
+  PageRecursiveDeleteConfigValue, PageRecursiveDeleteCompConfigValue,
+} from '~/interfaces/page-delete-config';
 import { toastError } from '../util/apiNotification';
 import { removeNullPropertyFromObject } from '~/utils/object-utils';
 
@@ -22,7 +26,14 @@ export default class AdminGeneralSecurityContainer extends Container {
       wikiMode: '',
       // set dummy value tile for using suspense
       currentRestrictGuestMode: this.dummyCurrentRestrictGuestMode,
-      currentPageCompleteDeletionAuthority: 'adminOnly',
+      currentPageDeletionAuthority: PageSingleDeleteConfigValue.AdminOnly,
+      currentPageRecursiveDeletionAuthority: PageRecursiveDeleteConfigValue.Inherit,
+      currentPageCompleteDeletionAuthority: PageSingleDeleteCompConfigValue.AdminOnly,
+      currentPageRecursiveCompleteDeletionAuthority: PageRecursiveDeleteCompConfigValue.Inherit,
+      previousPageRecursiveDeletionAuthority: null,
+      previousPageRecursiveCompleteDeletionAuthority: null,
+      expandOtherOptionsForDeletion: false,
+      expandOtherOptionsForCompleteDeletion: false,
       isShowRestrictedByOwner: false,
       isShowRestrictedByGroup: false,
       appSiteUrl: appContainer.config.crowi.url || '',
@@ -42,6 +53,13 @@ export default class AdminGeneralSecurityContainer extends Container {
       shareLinksActivePage: 1,
     };
 
+    this.changePageDeletionAuthority = this.changePageDeletionAuthority.bind(this);
+    this.changePageCompleteDeletionAuthority = this.changePageCompleteDeletionAuthority.bind(this);
+    this.changePageRecursiveDeletionAuthority = this.changePageRecursiveDeletionAuthority.bind(this);
+    this.changePageRecursiveCompleteDeletionAuthority = this.changePageRecursiveCompleteDeletionAuthority.bind(this);
+    this.changePreviousPageRecursiveDeletionAuthority = this.changePreviousPageRecursiveDeletionAuthority.bind(this);
+    this.changePreviousPageRecursiveCompleteDeletionAuthority = this.changePreviousPageRecursiveCompleteDeletionAuthority.bind(this);
+
   }
 
   async retrieveSecurityData() {
@@ -50,7 +68,10 @@ export default class AdminGeneralSecurityContainer extends Container {
     const { generalSetting, shareLinkSetting, generalAuth } = response.data.securityParams;
     this.setState({
       currentRestrictGuestMode: generalSetting.restrictGuestMode,
+      currentPageDeletionAuthority: generalSetting.pageDeletionAuthority,
       currentPageCompleteDeletionAuthority: generalSetting.pageCompleteDeletionAuthority,
+      currentPageRecursiveDeletionAuthority: generalSetting.pageRecursiveDeletionAuthority,
+      currentPageRecursiveCompleteDeletionAuthority: generalSetting.pageRecursiveCompleteDeletionAuthority,
       isShowRestrictedByOwner: !generalSetting.hideRestrictedByOwner,
       isShowRestrictedByGroup: !generalSetting.hideRestrictedByGroup,
       sessionMaxAge: generalSetting.sessionMaxAge,
@@ -104,11 +125,61 @@ export default class AdminGeneralSecurityContainer extends Container {
     this.setState({ currentRestrictGuestMode: restrictGuestModeLabel });
   }
 
+  /**
+   * Change pageDeletionAuthority
+   */
+  changePageDeletionAuthority(val) {
+    this.setState({ currentPageDeletionAuthority: val });
+  }
+
   /**
    * Change pageCompleteDeletionAuthority
    */
-  changePageCompleteDeletionAuthority(pageCompleteDeletionAuthorityLabel) {
-    this.setState({ currentPageCompleteDeletionAuthority: pageCompleteDeletionAuthorityLabel });
+  changePageCompleteDeletionAuthority(val) {
+    this.setState({ currentPageCompleteDeletionAuthority: val });
+  }
+
+  /**
+   * Change pageRecursiveDeletionAuthority
+   */
+  changePageRecursiveDeletionAuthority(val) {
+    this.setState({ currentPageRecursiveDeletionAuthority: val });
+  }
+
+  /**
+   * Change pageRecursiveCompleteDeletionAuthority
+   */
+  changePageRecursiveCompleteDeletionAuthority(val) {
+    this.setState({ currentPageRecursiveCompleteDeletionAuthority: val });
+  }
+
+  /**
+   * Change previousPageRecursiveDeletionAuthority
+   */
+  changePreviousPageRecursiveDeletionAuthority(val) {
+    this.setState({ previousPageRecursiveDeletionAuthority: val });
+  }
+
+
+  /**
+   * Change previousPageRecursiveCompleteDeletionAuthority
+   */
+  changePreviousPageRecursiveCompleteDeletionAuthority(val) {
+    this.setState({ previousPageRecursiveCompleteDeletionAuthority: val });
+  }
+
+  /**
+   * Switch ExpandOtherOptionsForDeletion
+   */
+  switchExpandOtherOptionsForDeletion(bool) {
+    this.setState({ expandOtherOptionsForDeletion: bool });
+  }
+
+  /**
+   * Switch ExpandOtherOptionsForDeletion
+   */
+  switchExpandOtherOptionsForCompleteDeletion(bool) {
+    this.setState({ expandOtherOptionsForCompleteDeletion: bool });
   }
 
   /**
@@ -135,7 +206,10 @@ export default class AdminGeneralSecurityContainer extends Container {
     let requestParams = {
       sessionMaxAge: this.state.sessionMaxAge,
       restrictGuestMode: this.state.currentRestrictGuestMode,
+      pageDeletionAuthority: this.state.currentPageDeletionAuthority,
       pageCompleteDeletionAuthority: this.state.currentPageCompleteDeletionAuthority,
+      pageRecursiveDeletionAuthority: this.state.currentPageRecursiveDeletionAuthority,
+      pageRecursiveCompleteDeletionAuthority: this.state.currentPageRecursiveCompleteDeletionAuthority,
       hideRestrictedByGroup: !this.state.isShowRestrictedByGroup,
       hideRestrictedByOwner: !this.state.isShowRestrictedByOwner,
     };

+ 2 - 0
packages/app/src/client/services/AdminHomeContainer.js

@@ -32,6 +32,7 @@ export default class AdminHomeContainer extends Container {
       copyState: this.copyStateValues.DEFAULT,
       installedPlugins: null,
       isV5Compatible: null,
+      isMaintenanceMode: null,
     };
 
   }
@@ -64,6 +65,7 @@ export default class AdminHomeContainer extends Container {
         installedPlugins: adminHomeParams.installedPlugins,
         envVars: adminHomeParams.envVars,
         isV5Compatible: adminHomeParams.isV5Compatible,
+        isMaintenanceMode: adminHomeParams.isMaintenanceMode,
       }));
     }
     catch (err) {

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

+ 16 - 1
packages/app/src/client/services/user-ui-settings.ts

@@ -5,6 +5,7 @@ import { debounce } from 'throttle-debounce';
 
 import { apiv3Put } from '~/client/util/apiv3-client';
 import { IUserUISettings } from '~/interfaces/user-ui-settings';
+import { useIsGuestUser } from '~/stores/context';
 
 let settingsForBulk: Partial<IUserUISettings> = {};
 const _putUserUISettingsInBulk = (): Promise<AxiosResponse<IUserUISettings>> => {
@@ -18,7 +19,8 @@ const _putUserUISettingsInBulk = (): Promise<AxiosResponse<IUserUISettings>> =>
 
 const _putUserUISettingsInBulkDebounced = debounce(1500, false, _putUserUISettingsInBulk);
 
-export const scheduleToPutUserUISettings = (settings: Partial<IUserUISettings>): Promise<AxiosResponse<IUserUISettings>> => {
+type ScheduleToPutFunction = (settings: Partial<IUserUISettings>) => Promise<AxiosResponse<IUserUISettings>>;
+const scheduleToPut: ScheduleToPutFunction = (settings: Partial<IUserUISettings>): Promise<AxiosResponse<IUserUISettings>> => {
   settingsForBulk = {
     ...settingsForBulk,
     ...settings,
@@ -26,3 +28,16 @@ export const scheduleToPutUserUISettings = (settings: Partial<IUserUISettings>):
 
   return _putUserUISettingsInBulkDebounced();
 };
+
+type UserUISettingsUtil = {
+  scheduleToPut: ScheduleToPutFunction | (() => void),
+}
+export const useUserUISettings = (): UserUISettingsUtil => {
+  const { data: isGuestUser } = useIsGuestUser();
+
+  return {
+    scheduleToPut: isGuestUser
+      ? () => {}
+      : scheduleToPut,
+  };
+};

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

+ 18 - 0
packages/app/src/components/Admin/AdminHome/AdminHome.jsx

@@ -38,6 +38,24 @@ const AdminHome = (props) => {
 
   return (
     <div data-testid="admin-home">
+      {
+        // Alert message will be displayed in case that the GROWI is under maintenance
+        adminHomeContainer.state.isMaintenanceMode && (
+          <div className="alert alert-danger alert-link" role="alert">
+            <h3 className="alert-heading">
+              {t('admin:maintenance_mode.maintenance_mode')}
+            </h3>
+            <p>
+              {t('admin:maintenance_mode.description')}
+            </p>
+            <hr />
+            <a className="btn-link" href="/admin/app" rel="noopener noreferrer">
+              <i className="fa fa-link ml-1" aria-hidden="true"></i>
+              <strong>{t('admin:maintenance_mode.end_maintenance_mode')}</strong>
+            </a>
+          </div>
+        )
+      }
       {
       // Alert message will be displayed in case that V5 migration has not been compleated
         (migrationStatus != null && !migrationStatus.isV5Compatible)

+ 29 - 1
packages/app/src/components/Admin/App/AppSettingsPageContents.jsx

@@ -1,4 +1,4 @@
-import React, { Fragment } from 'react';
+import React from 'react';
 import { withTranslation } from 'react-i18next';
 import PropTypes from 'prop-types';
 
@@ -9,6 +9,7 @@ import MailSetting from './MailSetting';
 import PluginSetting from './PluginSetting';
 import FileUploadSetting from './FileUploadSetting';
 import V5PageMigration from './V5PageMigration';
+import MaintenanceMode from './MaintenanceMode';
 
 import AdminAppContainer from '~/client/services/AdminAppContainer';
 
@@ -20,6 +21,24 @@ class AppSettingsPageContents extends React.Component {
 
     return (
       <div data-testid="admin-app-settings">
+        {
+          // Alert message will be displayed in case that the GROWI is under maintenance
+          adminAppContainer.state.isMaintenanceMode && (
+            <div className="alert alert-danger alert-link" role="alert">
+              <h3 className="alert-heading">
+                {t('admin:maintenance_mode.maintenance_mode')}
+              </h3>
+              <p>
+                {t('admin:maintenance_mode.description')}
+              </p>
+              <hr />
+              <a className="btn-link" href="#maintenance-mode" rel="noopener noreferrer">
+                <i className="fa fa-fw fa-arrow-down ml-1" aria-hidden="true"></i>
+                <strong>{t('admin:maintenance_mode.end_maintenance_mode')}</strong>
+              </a>
+            </div>
+          )
+        }
         {
           !isV5Compatible
           && (
@@ -66,7 +85,16 @@ class AppSettingsPageContents extends React.Component {
             <PluginSetting />
           </div>
         </div>
+
+        <div className="row">
+          <div className="col-lg-12">
+            <h2 className="admin-setting-header" id="maintenance-mode">{t('admin:maintenance_mode.maintenance_mode')}</h2>
+            <MaintenanceMode />
+          </div>
+        </div>
+
       </div>
+
     );
   }
 

+ 25 - 14
packages/app/src/components/Admin/App/V5PageMigrationModal.tsx → packages/app/src/components/Admin/App/ConfirmModal.tsx

@@ -3,14 +3,18 @@ import {
   Modal, ModalHeader, ModalBody, ModalFooter,
 } from 'reactstrap';
 import { useTranslation } from 'react-i18next';
+import { TFunctionResult } from 'i18next';
 
-type V5PageMigrationModalProps = {
+type ConfirmModalProps = {
   isModalOpen: boolean
-  onConfirm?: () => Promise<void>;
-  onCancel?: () => void;
+  warningMessage: TFunctionResult
+  supplymentaryMessage: TFunctionResult | null
+  confirmButtonTitle: TFunctionResult
+  onConfirm?: () => Promise<void>
+  onCancel?: () => void
 };
 
-export const V5PageMigrationModal: FC<V5PageMigrationModalProps> = (props: V5PageMigrationModalProps) => {
+export const ConfirmModal: FC<ConfirmModalProps> = (props: ConfirmModalProps) => {
   const { t } = useTranslation();
 
   const onCancel = () => {
@@ -27,18 +31,25 @@ export const V5PageMigrationModal: FC<V5PageMigrationModalProps> = (props: V5Pag
 
   return (
     <Modal isOpen={props.isModalOpen} toggle={onCancel} className="">
-      <ModalHeader tag="h4" toggle={onCancel} className="bg-warning">
+      <ModalHeader tag="h4" toggle={onCancel} className="bg-danger">
         <i className="icon-fw icon-question" />
-        Warning
+        {t('Warning')}
       </ModalHeader>
       <ModalBody>
-        {t('admin:v5_page_migration.modal_migration_warning')}
-        <br />
-        <br />
-        <span className="text-danger">
-          <i className="icon-exclamation icon-fw"></i>
-          {t('admin:v5_page_migration.migration_note')}
-        </span>
+        {props.warningMessage}
+        {
+          props.supplymentaryMessage != null && (
+            <>
+              <br />
+              <br />
+              <span className="text-warning">
+                <i className="icon-exclamation icon-fw"></i>
+                {props.supplymentaryMessage}
+              </span>
+            </>
+          )
+        }
+
       </ModalBody>
       <ModalFooter>
         <button
@@ -53,7 +64,7 @@ export const V5PageMigrationModal: FC<V5PageMigrationModalProps> = (props: V5Pag
           className="btn btn-outline-primary ml-3"
           onClick={onConfirm}
         >
-          {t('admin:v5_page_migration.start_upgrading')}
+          {props.confirmButtonTitle ?? t('Confirm')}
         </button>
       </ModalFooter>
     </Modal>

+ 80 - 0
packages/app/src/components/Admin/App/MaintenanceMode.tsx

@@ -0,0 +1,80 @@
+import React, { FC, useState, useCallback } from 'react';
+import { useTranslation } from 'react-i18next';
+
+import loggerFactory from '~/utils/logger';
+
+import { withUnstatedContainers } from '../../UnstatedUtils';
+import { ConfirmModal } from './ConfirmModal';
+import { toastSuccess, toastError } from '~/client/util/apiNotification';
+
+import AdminAppContainer from '~/client/services/AdminAppContainer';
+
+const logger = loggerFactory('growi:maintenanceMode');
+
+type Props = {
+  adminAppContainer: AdminAppContainer,
+};
+
+const MaintenanceMode: FC<Props> = (props: Props) => {
+  const { t } = useTranslation();
+  const { adminAppContainer } = props;
+
+  const [isModalOpen, setModalOpen] = useState<boolean>(false);
+  const [isMaintenanceMode, setMaintenanceMode] = useState<boolean | undefined>(adminAppContainer.state.isMaintenanceMode);
+
+  const openModal = () => { setModalOpen(true) };
+  const closeModal = () => { setModalOpen(false) };
+
+  const onConfirmHandler = useCallback(async() => {
+    closeModal();
+
+    try {
+      if (isMaintenanceMode) {
+        await adminAppContainer.endMaintenanceMode();
+        setMaintenanceMode(false);
+      }
+      else {
+        await adminAppContainer.startMaintenanceMode();
+        setMaintenanceMode(true);
+      }
+    }
+    catch (err) {
+      toastError(isMaintenanceMode ? t('admin:maintenance_mode.failed_to_end_maintenance_mode') : t('admin:maintenance_mode.failed_to_start_maintenance_mode'));
+    }
+
+    // eslint-disable-next-line max-len
+    toastSuccess(isMaintenanceMode ? t('admin:maintenance_mode.successfully_ended_maintenance_mode') : t('admin:maintenance_mode.successfully_started_maintenance_mode'));
+  }, [isMaintenanceMode, adminAppContainer, closeModal]);
+
+  return (
+    <div className="mb-5">
+      <ConfirmModal
+        isModalOpen={isModalOpen}
+        warningMessage={isMaintenanceMode ? t('admin:maintenance_mode.warning_message_to_end') : t('admin:maintenance_mode.warning_message_to_start')}
+        // eslint-disable-next-line max-len
+        supplymentaryMessage={isMaintenanceMode ? null : t('admin:maintenance_mode.supplymentary_message_to_start')}
+        confirmButtonTitle={isMaintenanceMode ? t('admin:maintenance_mode.end_maintenance_mode') : t('admin:maintenance_mode.start_maintenance_mode')}
+        onConfirm={onConfirmHandler}
+        onCancel={() => closeModal()}
+      />
+      <p className="card well">
+        {t('admin:maintenance_mode.description')}
+        <br />
+        <br />
+        <span className="text-warning">
+          <i className="icon-exclamation icon-fw"></i>
+          {t('admin:maintenance_mode.supplymentary_message_to_start')}
+        </span>
+      </p>
+      <div className="row my-3">
+        <div className="mx-auto">
+          <button type="button" className="btn btn-success" onClick={() => openModal()}>
+            {isMaintenanceMode ? t('admin:maintenance_mode.end_maintenance_mode') : t('admin:maintenance_mode.start_maintenance_mode')}
+          </button>
+        </div>
+      </div>
+    </div>
+  );
+};
+
+export default withUnstatedContainers(MaintenanceMode, [AdminAppContainer]);

+ 100 - 5
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 { V5PageMigrationModal } from './V5PageMigrationModal';
+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,10 +85,46 @@ 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 (
     <>
-      <V5PageMigrationModal
+      <ConfirmModal
         isModalOpen={isV5PageMigrationModalShown}
+        warningMessage={t('admin:v5_page_migration.modal_migration_warning')}
+        supplymentaryMessage={t('admin:v5_page_migration.migration_note')}
+        confirmButtonTitle={t('admin:v5_page_migration.start_upgrading')}
         onConfirm={onConfirm}
         onCancel={() => setIsV5PageMigrationModalShown(false)}
       />
@@ -45,9 +137,12 @@ 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)}>Upgrade to v5</button>
+          <button type="button" className="btn btn-warning" onClick={() => setIsV5PageMigrationModalShown(true)} disabled={isInProgress != null}>
+            {t('admin:v5_page_migration.upgrade_to_v5')}
+          </button>
         </div>
       </div>
     </>

+ 3 - 0
packages/app/src/components/Admin/CustomHeaderEditor.jsx

@@ -7,6 +7,8 @@ require('codemirror/addon/hint/show-hint');
 require('codemirror/addon/edit/matchbrackets');
 require('codemirror/addon/edit/closebrackets');
 require('codemirror/mode/htmlmixed/htmlmixed');
+require('codemirror/addon/hint/html-hint');
+require('codemirror/addon/edit/closetag');
 require('~/client/util/codemirror/autorefresh.ext');
 
 require('jquery-ui/ui/widgets/resizable');
@@ -22,6 +24,7 @@ export default class CustomHeaderEditor extends React.Component {
         detach
         options={{
           mode: 'htmlmixed',
+          autoCloseTags: true,
           lineNumbers: true,
           tabSize: 2,
           indentUnit: 2,

+ 367 - 104
packages/app/src/components/Admin/Security/SecuritySetting.jsx

@@ -1,20 +1,93 @@
 /* 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, prepareDeleteConfigValuesForCalc } from '~/utils/page-delete-config';
 import { withUnstatedContainers } from '../../UnstatedUtils';
 import { toastSuccess, toastError } from '~/client/util/apiNotification';
-
+import { PageDeleteConfigValue } from '~/interfaces/page-delete-config';
 import AppContainer from '~/client/services/AppContainer';
 import AdminGeneralSecurityContainer from '~/client/services/AdminGeneralSecurityContainer';
 
+// used as the prefix of translation
+const DeletionTypeForT = Object.freeze({
+  Deletion: 'deletion',
+  CompleteDeletion: 'complete_deletion',
+  RecursiveDeletion: 'recursive_deletion',
+  RecursiveCompleteDeletion: 'recursive_complete_deletion',
+});
+
+const DeletionType = Object.freeze({
+  Deletion: 'deletion',
+  CompleteDeletion: 'completeDeletion',
+  RecursiveDeletion: 'recursiveDeletion',
+  RecursiveCompleteDeletion: 'recursiveCompleteDeletion',
+});
+
+const getDeletionTypeForT = (deletionType) => {
+  switch (deletionType) {
+    case DeletionType.Deletion:
+      return DeletionTypeForT.Deletion;
+    case DeletionType.RecursiveDeletion:
+      return DeletionTypeForT.RecursiveDeletion;
+    case DeletionType.CompleteDeletion:
+      return DeletionTypeForT.CompleteDeletion;
+    case DeletionType.RecursiveCompleteDeletion:
+      return DeletionTypeForT.RecursiveCompleteDeletion;
+  }
+};
+
+const getDeleteConfigValueForT = (DeleteConfigValue) => {
+  switch (DeleteConfigValue) {
+    case PageDeleteConfigValue.Anyone:
+    case null:
+      return 'security_setting.anyone';
+    case PageDeleteConfigValue.Inherit:
+      return 'security_setting.inherit';
+    case PageDeleteConfigValue.AdminOnly:
+      return 'security_setting.admin_only';
+    case PageDeleteConfigValue.AdminAndAuthor:
+      return 'security_setting.admin_and_author';
+  }
+};
+
+/**
+ * Return true if "deletionType" is DeletionType.RecursiveDeletion or DeletionType.RecursiveCompleteDeletion.
+ * @param deletionType Deletion type
+ * @returns boolean
+ */
+const isRecursiveDeletion = (deletionType) => {
+  return deletionType === DeletionType.RecursiveDeletion || deletionType === DeletionType.RecursiveCompleteDeletion;
+};
+
+/**
+ * Return true if "deletionType" is DeletionType.Deletion or DeletionType.RecursiveDeletion.
+ * @param deletionType Deletion type
+ * @returns boolean
+ */
+const isTypeDeletion = (deletionType) => {
+  return deletionType === DeletionType.Deletion || deletionType === DeletionType.RecursiveDeletion;
+};
+
 class SecuritySetting extends React.Component {
 
   constructor(props) {
     super(props);
 
+    // functions
     this.putSecuritySetting = this.putSecuritySetting.bind(this);
+    this.getRecursiveDeletionConfigState = this.getRecursiveDeletionConfigState.bind(this);
+    this.previousPageRecursiveAuthorityState = this.previousPageRecursiveAuthorityState.bind(this);
+    this.setPagePreviousRecursiveAuthorityState = this.setPagePreviousRecursiveAuthorityState.bind(this);
+    this.expantDeleteOptionsState = this.expantDeleteOptionsState.bind(this);
+    this.setExpantOtherDeleteOptionsState = this.setExpantOtherDeleteOptionsState.bind(this);
+    this.setDeletionConfigState = this.setDeletionConfigState.bind(this);
+
+    // render
+    this.renderPageDeletePermission = this.renderPageDeletePermission.bind(this);
+    this.renderPageDeletePermissionDropdown = this.renderPageDeletePermissionDropdown.bind(this);
   }
 
   async putSecuritySetting() {
@@ -28,9 +101,226 @@ class SecuritySetting extends React.Component {
     }
   }
 
+  getRecursiveDeletionConfigState(deletionType) {
+    const { adminGeneralSecurityContainer } = this.props;
+
+    if (isTypeDeletion(deletionType)) {
+      return [
+        adminGeneralSecurityContainer.state.currentPageRecursiveDeletionAuthority,
+        adminGeneralSecurityContainer.changePageRecursiveDeletionAuthority,
+      ];
+    }
+
+    return [
+      adminGeneralSecurityContainer.state.currentPageRecursiveCompleteDeletionAuthority,
+      adminGeneralSecurityContainer.changePageRecursiveCompleteDeletionAuthority,
+    ];
+  }
+
+  previousPageRecursiveAuthorityState(deletionType) {
+    const { adminGeneralSecurityContainer } = this.props;
+
+    return isTypeDeletion(deletionType)
+      ? adminGeneralSecurityContainer.state.previousPageRecursiveDeletionAuthority
+      : adminGeneralSecurityContainer.state.previousPageRecursiveCompleteDeletionAuthority;
+  }
+
+  setPagePreviousRecursiveAuthorityState(deletionType, previousState) {
+    const { adminGeneralSecurityContainer } = this.props;
+
+    if (isTypeDeletion(deletionType)) {
+      adminGeneralSecurityContainer.changePreviousPageRecursiveDeletionAuthority(previousState);
+      return;
+    }
+
+    adminGeneralSecurityContainer.changePreviousPageRecursiveCompleteDeletionAuthority(previousState);
+  }
+
+  expantDeleteOptionsState(deletionType) {
+    const { adminGeneralSecurityContainer } = this.props;
+
+    return isTypeDeletion(deletionType)
+      ? adminGeneralSecurityContainer.state.expandOtherOptionsForDeletion
+      : adminGeneralSecurityContainer.state.expandOtherOptionsForCompleteDeletion;
+  }
+
+  setExpantOtherDeleteOptionsState(deletionType, bool) {
+    const { adminGeneralSecurityContainer } = this.props;
+
+    if (isTypeDeletion(deletionType)) {
+      adminGeneralSecurityContainer.switchExpandOtherOptionsForDeletion(bool);
+      return;
+    }
+    adminGeneralSecurityContainer.switchExpandOtherOptionsForCompleteDeletion(bool);
+    return;
+  }
+
+  /**
+   * Force update deletion config for recursive operation when the deletion config for general operation is updated.
+   * @param deletionType Deletion type
+   */
+  setDeletionConfigState(newState, setState, deletionType) {
+    setState(newState);
+
+    if (this.previousPageRecursiveAuthorityState(deletionType) !== null) {
+      this.setPagePreviousRecursiveAuthorityState(deletionType, null);
+    }
+
+    if (isRecursiveDeletion(deletionType)) {
+      return;
+    }
+
+    const [recursiveState, setRecursiveState] = this.getRecursiveDeletionConfigState(deletionType);
+
+    const calculableValue = prepareDeleteConfigValuesForCalc(newState, recursiveState);
+    const shouldForceUpdate = !validateDeleteConfigs(calculableValue[0], calculableValue[1]);
+    if (shouldForceUpdate) {
+      setRecursiveState(newState);
+      this.setPagePreviousRecursiveAuthorityState(deletionType, recursiveState);
+      this.setExpantOtherDeleteOptionsState(deletionType, true);
+    }
+
+    return;
+  }
+
+  renderPageDeletePermissionDropdown(currentState, setState, deletionType, isButtonDisabled) {
+    const { t } = this.props;
+    return (
+      <div className="dropdown">
+        <button
+          className="btn btn-outline-secondary dropdown-toggle text-right"
+          type="button"
+          id="dropdownMenuButton"
+          data-toggle="dropdown"
+          aria-haspopup="true"
+          aria-expanded="true"
+        >
+          <span className="float-left">
+            {t(getDeleteConfigValueForT(currentState))}
+          </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 } = this.props;
+
+    const expantDeleteOptionsState = this.expantDeleteOptionsState(deletionType);
+
+    return (
+      <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">
+          {
+            !isRecursiveDeletion(deletionType)
+              ? (
+                <>{this.renderPageDeletePermissionDropdown(currentState, setState, deletionType, isButtonDisabled)}</>
+              )
+              : (
+                <>
+                  <button
+                    type="button"
+                    className="btn btn-link p-0 mb-4"
+                    aria-expanded="false"
+                    onClick={() => this.setExpantOtherDeleteOptionsState(deletionType, !expantDeleteOptionsState)}
+                  >
+                    <i className={`fa fa-fw fa-arrow-right ${expantDeleteOptionsState ? 'fa-rotate-90' : ''}`}></i>
+                    { t('security_setting.other_options') }
+                  </button>
+                  <Collapse isOpen={expantDeleteOptionsState}>
+                    <div className="pb-4">
+                      <p className="card well">
+                        <span className="text-warning">
+                          <i className="icon-info"></i>
+                          {/* eslint-disable-next-line react/no-danger */}
+                          <span dangerouslySetInnerHTML={{ __html: t('security_setting.page_delete_rights_caution') }} />
+                        </span>
+                      </p>
+                      { this.previousPageRecursiveAuthorityState(deletionType) !== null && (
+                        <div className="mb-3">
+                          <strong>
+                            {t('security_setting.forced_update_desc')}
+                          </strong>
+                          <code>
+                            {t(getDeleteConfigValueForT(this.previousPageRecursiveAuthorityState(deletionType)))}
+                          </code>
+                        </div>
+                      )}
+                      {this.renderPageDeletePermissionDropdown(currentState, setState, deletionType, isButtonDisabled)}
+                    </div>
+                  </Collapse>
+                </>
+              )
+          }
+        </div>
+      </div>
+    );
+  }
+
   render() {
     const { t, adminGeneralSecurityContainer } = this.props;
-    const { currentRestrictGuestMode, currentPageCompleteDeletionAuthority } = adminGeneralSecurityContainer.state;
+    const {
+      currentRestrictGuestMode, currentPageDeletionAuthority, currentPageCompleteDeletionAuthority,
+      currentPageRecursiveDeletionAuthority, currentPageRecursiveCompleteDeletionAuthority,
+    } = adminGeneralSecurityContainer.state;
+
+    const isButtonDisabledForDeletion = !validateDeleteConfigs(
+      adminGeneralSecurityContainer.state.currentPageDeletionAuthority, PageDeleteConfigValue.AdminAndAuthor,
+    );
+
+    const isButtonDisabledForCompleteDeletion = !validateDeleteConfigs(
+      adminGeneralSecurityContainer.state.currentPageCompleteDeletionAuthority, PageDeleteConfigValue.AdminAndAuthor,
+    );
 
     return (
       <React.Fragment>
@@ -45,60 +335,62 @@ class SecuritySetting extends React.Component {
         )}
 
         <h4 className="mt-4">{ t('security_setting.page_list_and_search_results') }</h4>
-        <table className="table table-bordered col-lg-9 mb-5">
-          <thead>
-            <tr>
-              <th scope="col">{ t('scope_of_page_disclosure') }</th>
-              <th scope="col">{ t('set_point') }</th>
-            </tr>
-          </thead>
-          <tbody>
-            <tr>
-              <th scope="row">{ t('Public') }</th>
-              <td>{ t('always_displayed') }</td>
-            </tr>
-            <tr>
-              <th scope="row">{ t('Anyone with the link') }</th>
-              <td>{ t('always_hidden') }</td>
-            </tr>
-            <tr>
-              <th scope="row">{ t('Only me') }</th>
-              <td>
-                <div className="custom-control custom-switch custom-checkbox-success">
-                  <input
-                    type="checkbox"
-                    className="custom-control-input"
-                    id="isShowRestrictedByOwner"
-                    checked={adminGeneralSecurityContainer.state.isShowRestrictedByOwner}
-                    onChange={() => { adminGeneralSecurityContainer.switchIsShowRestrictedByOwner() }}
-                  />
-                  <label className="custom-control-label" htmlFor="isShowRestrictedByOwner">
-                    {t('displayed_or_hidden')}
-                  </label>
-                </div>
-              </td>
-            </tr>
-            <tr>
-              <th scope="row">{ t('Only inside the group') }</th>
-              <td>
-                <div className="custom-control custom-switch custom-checkbox-success">
-                  <input
-                    type="checkbox"
-                    className="custom-control-input"
-                    id="isShowRestrictedByGroup"
-                    checked={adminGeneralSecurityContainer.state.isShowRestrictedByGroup}
-                    onChange={() => { adminGeneralSecurityContainer.switchIsShowRestrictedByGroup() }}
-                  />
-                  <label className="custom-control-label" htmlFor="isShowRestrictedByGroup">
-                    {t('displayed_or_hidden')}
-                  </label>
-                </div>
-              </td>
-            </tr>
-          </tbody>
-        </table>
-
-        <h4>{t('security_setting.page_access_and_delete_rights')}</h4>
+        <div className="row justify-content-md-center">
+          <table className="table table-bordered col-lg-9 mb-5">
+            <thead>
+              <tr>
+                <th scope="col">{ t('scope_of_page_disclosure') }</th>
+                <th scope="col">{ t('set_point') }</th>
+              </tr>
+            </thead>
+            <tbody>
+              <tr>
+                <th scope="row">{ t('Public') }</th>
+                <td>{ t('always_displayed') }</td>
+              </tr>
+              <tr>
+                <th scope="row">{ t('Anyone with the link') }</th>
+                <td>{ t('always_hidden') }</td>
+              </tr>
+              <tr>
+                <th scope="row">{ t('Only me') }</th>
+                <td>
+                  <div className="custom-control custom-switch custom-checkbox-success">
+                    <input
+                      type="checkbox"
+                      className="custom-control-input"
+                      id="isShowRestrictedByOwner"
+                      checked={adminGeneralSecurityContainer.state.isShowRestrictedByOwner}
+                      onChange={() => { adminGeneralSecurityContainer.switchIsShowRestrictedByOwner() }}
+                    />
+                    <label className="custom-control-label" htmlFor="isShowRestrictedByOwner">
+                      {t('displayed_or_hidden')}
+                    </label>
+                  </div>
+                </td>
+              </tr>
+              <tr>
+                <th scope="row">{ t('Only inside the group') }</th>
+                <td>
+                  <div className="custom-control custom-switch custom-checkbox-success">
+                    <input
+                      type="checkbox"
+                      className="custom-control-input"
+                      id="isShowRestrictedByGroup"
+                      checked={adminGeneralSecurityContainer.state.isShowRestrictedByGroup}
+                      onChange={() => { adminGeneralSecurityContainer.switchIsShowRestrictedByGroup() }}
+                    />
+                    <label className="custom-control-label" htmlFor="isShowRestrictedByGroup">
+                      {t('displayed_or_hidden')}
+                    </label>
+                  </div>
+                </td>
+              </tr>
+            </tbody>
+          </table>
+        </div>
+
+        <h4>{t('security_setting.page_access_rights')}</h4>
         <div className="row mb-4">
           <div className="col-md-3 text-md-right py-2">
             <strong>{t('security_setting.Guest Users Access')}</strong>
@@ -129,65 +421,36 @@ class SecuritySetting extends React.Component {
               </div>
             </div>
             {adminGeneralSecurityContainer.isWikiModeForced && (
-              <p className="alert alert-warning mt-2 text-left offset-3 col-6">
+              <p className="alert alert-warning mt-2 col-6">
                 <i className="icon-exclamation icon-fw">
                 </i><b>FIXED</b><br />
                 <b
                   dangerouslySetInnerHTML={{
                     __html: t('security_setting.Fixed by env var',
-                      { forcewikimode: 'FORCE_WIKI_MODE', wikimode: adminGeneralSecurityContainer.state.wikiMode }),
+                      { key: 'FORCE_WIKI_MODE', value: adminGeneralSecurityContainer.state.wikiMode }),
                   }}
                 />
               </p>
             )}
           </div>
         </div>
-        <div className="row mb-4">
-          <div className="col-md-3 text-md-right mb-2">
-            <strong>{t('security_setting.complete_deletion')}</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">
-                  {(currentPageCompleteDeletionAuthority === 'anyOne' || currentPageCompleteDeletionAuthority == null)
-                      && t('security_setting.anyone')}
-                  {currentPageCompleteDeletionAuthority === 'adminOnly' && t('security_setting.admin_only')}
-                  {currentPageCompleteDeletionAuthority === 'adminAndAuthor' && t('security_setting.admin_and_author')}
-                </span>
-              </button>
-              <div className="dropdown-menu" aria-labelledby="dropdownMenuButton">
-                <button className="dropdown-item" type="button" onClick={() => { adminGeneralSecurityContainer.changePageCompleteDeletionAuthority('anyOne') }}>
-                  {t('security_setting.anyone')}
-                </button>
-                <button
-                  className="dropdown-item"
-                  type="button"
-                  onClick={() => { adminGeneralSecurityContainer.changePageCompleteDeletionAuthority('adminOnly') }}
-                >
-                  {t('security_setting.admin_only')}
-                </button>
-                <button
-                  className="dropdown-item"
-                  type="button"
-                  onClick={() => { adminGeneralSecurityContainer.changePageCompleteDeletionAuthority('adminAndAuthor') }}
-                >
-                  {t('security_setting.admin_and_author')}
-                </button>
-              </div>
-              <p className="form-text text-muted small">
-                {t('security_setting.complete_deletion_explain')}
-              </p>
-            </div>
-          </div>
-        </div>
+
+        <h4>{t('security_setting.page_delete_rights')}</h4>
+        {/* Render PageDeletePermission */}
+        {
+          [
+            [currentPageDeletionAuthority, adminGeneralSecurityContainer.changePageDeletionAuthority, DeletionType.Deletion, false],
+            // eslint-disable-next-line max-len
+            [currentPageRecursiveDeletionAuthority, adminGeneralSecurityContainer.changePageRecursiveDeletionAuthority, DeletionType.RecursiveDeletion, isButtonDisabledForDeletion],
+          ].map(arr => this.renderPageDeletePermission(arr[0], arr[1], arr[2], arr[3]))
+        }
+        {
+          [
+            [currentPageCompleteDeletionAuthority, adminGeneralSecurityContainer.changePageCompleteDeletionAuthority, DeletionType.CompleteDeletion, false],
+            // eslint-disable-next-line max-len
+            [currentPageRecursiveCompleteDeletionAuthority, adminGeneralSecurityContainer.changePageRecursiveCompleteDeletionAuthority, DeletionType.RecursiveCompleteDeletion, isButtonDisabledForCompleteDeletion],
+          ].map(arr => this.renderPageDeletePermission(arr[0], arr[1], arr[2], arr[3]))
+        }
 
         <h4>{t('security_setting.session')}</h4>
         <div className="form-group row">

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

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

+ 1 - 2
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,7 +193,7 @@ 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')}
         </div>
       </ModalBody>

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

+ 71 - 17
packages/app/src/components/Admin/UserGroup/UserGroupForm.tsx

@@ -3,14 +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) => {
@@ -18,14 +19,16 @@ const UserGroupForm: FC<Props> = (props: Props) => {
 
   const { t } = useTranslation();
 
-  const { userGroup, submitButtonLabel, onSubmit } = props;
+  const {
+    userGroup, selectableParentUserGroups, submitButtonLabel, onSubmit,
+  } = props;
 
   /*
    * State
    */
   const [currentName, setName] = useState(userGroup != null ? userGroup.name : '');
   const [currentDescription, setDescription] = useState(userGroup != null ? userGroup.description : '');
-  const [currentParent, setParent] = useState(userGroup != null ? userGroup.parent : '');
+  const [selectedParent, setSelectedParent] = useState<IUserGroupHasId | undefined>(userGroup?.parent as IUserGroupHasId);
 
   /*
    * Function
@@ -38,22 +41,26 @@ const UserGroupForm: FC<Props> = (props: Props) => {
     setDescription(e.target.value);
   }, []);
 
-  const onSubmitHandler = useCallback(async(e) => {
-    e.preventDefault(); // no reload
-
-    if (onSubmit == null) {
-      return;
+  const onChangeParerentButtonHandler = useCallback((userGroup: IUserGroupHasId) => {
+    if (userGroup._id !== selectedParent?._id) {
+      setSelectedParent(userGroup);
     }
-
-    await onSubmit({ name: currentName, description: currentDescription, parent: currentParent });
-  }, [currentName, currentDescription, currentParent, onSubmit]);
+  }, [selectedParent, setSelectedParent]);
 
   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>
-        {/* TODO 85062: improve style */}
+
         {
           userGroup?.createdAt != null && (
             <div className="form-group row">
@@ -62,6 +69,7 @@ const UserGroupForm: FC<Props> = (props: Props) => {
             </div>
           )
         }
+
         <div className="form-group row">
           <label htmlFor="name" className="col-md-2 col-form-label">
             {t('admin:user_group_management.group_name')}
@@ -78,16 +86,62 @@ const UserGroupForm: FC<Props> = (props: Props) => {
             />
           </div>
         </div>
+
         <div className="form-group row">
           <label htmlFor="description" className="col-md-2 col-form-label">
             {t('Description')}
           </label>
           <div className="col-md-4">
-            <textarea className="form-control" name="description" value={currentDescription} onChange={onChangeDescriptionHandler} required />
+            <textarea className="form-control" name="description" value={currentDescription} onChange={onChangeDescriptionHandler} />
           </div>
         </div>
 
-        {/* TODO 85062: select parent dropdown */}
+        <div className="form-group row">
+          <label htmlFor="parent" className="col-md-2 col-form-label">
+            {t('admin:user_group_management.parent_group')}
+          </label>
+          <div className="dropdown col-md-4">
+            <button
+              type="button"
+              id="dropdownMenuButton"
+              data-toggle="dropdown"
+              className={`
+                btn btn-outline-secondary dropdown-toggle mb-3 ${selectableParentUserGroups != null && selectableParentUserGroups.length > 0 ? '' : 'disabled'}
+              `}
+            >
+              {selectedParent?.name ?? t('admin:user_group_management.select_parent_group')}
+            </button>
+            <div className="dropdown-menu" aria-labelledby="dropdownMenuButton">
+              {
+                (selectableParentUserGroups != null && selectableParentUserGroups.length > 0) && (
+                  <>
+                    {
+                      selectableParentUserGroups.map(userGroup => (
+                        <button
+                          key={userGroup._id}
+                          type="button"
+                          className={`dropdown-item ${selectedParent?._id === userGroup._id ? 'active' : ''}`}
+                          onClick={() => onChangeParerentButtonHandler(userGroup)}
+                        >
+                          {userGroup.name}
+                        </button>
+                      ))
+                    }
+                  </>
+                )
+              }
+
+              <div className="dropdown-divider" />
+
+              <button
+                className="dropdown-item"
+                type="button"
+                onClick={() => { setSelectedParent(undefined) }}
+              >{t('admin:user_group_management.release_parent_group')}
+              </button>
+            </div>
+          </div>
+        </div>
 
         <div className="form-group row">
           <div className="offset-md-2 col-md-10">

+ 122 - 0
packages/app/src/components/Admin/UserGroup/UserGroupModal.tsx

@@ -0,0 +1,122 @@
+import React, {
+  FC, useState, useEffect, useCallback,
+} from 'react';
+import {
+  Modal, ModalHeader, ModalBody, ModalFooter,
+} from 'reactstrap';
+import { useTranslation } from 'react-i18next';
+import { TFunctionResult } from 'i18next';
+
+import { Ref } from '~/interfaces/common';
+import { IUserGroup, IUserGroupHasId } from '~/interfaces/user';
+import { CustomWindow } from '~/interfaces/global';
+import Xss from '~/services/xss';
+
+type Props = {
+  userGroup?: IUserGroupHasId,
+  buttonLabel?: TFunctionResult,
+  onClickSubmit?: (userGroupData: Partial<IUserGroupHasId>) => Promise<IUserGroupHasId | void>
+  isShow?: boolean
+  onHide?: () => Promise<void> | void
+};
+
+const UserGroupModal: FC<Props> = (props: Props) => {
+  const xss: Xss = (window as CustomWindow).xss;
+
+  const { t } = useTranslation();
+
+  const {
+    userGroup, buttonLabel, onClickSubmit, isShow, onHide,
+  } = props;
+
+  /*
+   * State
+   */
+  const [currentName, setName] = useState('');
+  const [currentDescription, setDescription] = useState('');
+  const [currentParent, setParent] = useState<Ref<IUserGroup> | null>(null);
+
+  /*
+   * Function
+   */
+  const onChangeNameHandler = useCallback((e) => {
+    setName(e.target.value);
+  }, []);
+
+  const onChangeDescriptionHandler = useCallback((e) => {
+    setDescription(e.target.value);
+  }, []);
+
+  const onSubmitHandler = useCallback(async(e) => {
+    e.preventDefault(); // no reload
+
+    if (onClickSubmit == null) {
+      return;
+    }
+
+    await onClickSubmit({
+      _id: userGroup?._id,
+      name: currentName,
+      description: currentDescription,
+      parent: currentParent,
+    });
+  }, [userGroup, currentName, currentDescription, currentParent, onClickSubmit]);
+
+  // componentDidMount
+  useEffect(() => {
+    if (userGroup != null) {
+      setName(userGroup.name);
+      setDescription(userGroup.description);
+      setParent(userGroup.parent);
+    }
+  }, [userGroup]);
+
+  return (
+    <Modal className="modal-md" isOpen={isShow} toggle={onHide}>
+      <form onSubmit={onSubmitHandler}>
+        <ModalHeader tag="h4" toggle={onHide} className="bg-primary text-light">
+          {t('admin:user_group_management.basic_info')}
+        </ModalHeader>
+
+        <ModalBody>
+          <div className="form-group">
+            <label htmlFor="name">
+              {t('admin:user_group_management.group_name')}
+            </label>
+            <input
+              className="form-control"
+              type="text"
+              name="name"
+              placeholder={t('admin:user_group_management.group_example')}
+              value={currentName}
+              onChange={onChangeNameHandler}
+              required
+            />
+          </div>
+
+          <div className="form-group">
+            <label htmlFor="description">
+              {t('Description')}
+            </label>
+            <textarea className="form-control" name="description" value={currentDescription} onChange={onChangeDescriptionHandler} />
+          </div>
+
+          {/* TODO 90732: Add a drop-down to show selectable parents */}
+
+          {/* TODO 85462: Add a note that "if you change the parent, the offspring will also be moved together */}
+
+        </ModalBody>
+
+        <ModalFooter>
+          <div className="form-group">
+            <button type="submit" className="btn btn-primary">
+              {buttonLabel}
+            </button>
+          </div>
+        </ModalFooter>
+      </form>
+    </Modal>
+  );
+};
+
+export default UserGroupModal;

+ 54 - 7
packages/app/src/components/Admin/UserGroup/UserGroupPage.tsx

@@ -2,14 +2,14 @@ import React, { FC, useState, useCallback } from 'react';
 import { useTranslation } from 'react-i18next';
 
 import UserGroupTable from './UserGroupTable';
-import UserGroupCreateModal from './UserGroupCreateModal';
+import UserGroupModal from './UserGroupModal';
 import UserGroupDeleteModal from './UserGroupDeleteModal';
 
 import { toastSuccess, toastError } from '~/client/util/apiNotification';
 import { IUserGroup, IUserGroupHasId } from '~/interfaces/user';
 import Xss from '~/services/xss';
 import { CustomWindow } from '~/interfaces/global';
-import { apiv3Delete, apiv3Post } from '~/client/util/apiv3-client';
+import { apiv3Delete, apiv3Post, apiv3Put } from '~/client/util/apiv3-client';
 import { useSWRxUserGroupList, useSWRxChildUserGroupList, useSWRxUserGroupRelationList } from '~/stores/user-group';
 import { useIsAclEnabled } from '~/stores/context';
 
@@ -37,6 +37,7 @@ const UserGroupPage: FC = () => {
    */
   const [selectedUserGroup, setSelectedUserGroup] = useState<IUserGroupHasId | undefined>(undefined); // not null but undefined (to use defaultProps in UserGroupDeleteModal)
   const [isCreateModalShown, setCreateModalShown] = useState<boolean>(false);
+  const [isUpdateModalShown, setUpdateModalShown] = useState<boolean>(false);
   const [isDeleteModalShown, setDeleteModalShown] = useState<boolean>(false);
 
   /*
@@ -50,6 +51,16 @@ const UserGroupPage: FC = () => {
     setCreateModalShown(false);
   }, [setCreateModalShown]);
 
+  const showUpdateModal = useCallback((group: IUserGroupHasId) => {
+    setUpdateModalShown(true);
+    setSelectedUserGroup(group);
+  }, [setUpdateModalShown]);
+
+  const hideUpdateModal = useCallback(() => {
+    setUpdateModalShown(false);
+    setSelectedUserGroup(undefined);
+  }, [setUpdateModalShown]);
+
   const syncUserGroupAndRelations = useCallback(async() => {
     try {
       await mutateUserGroups();
@@ -82,13 +93,37 @@ const UserGroupPage: FC = () => {
         name: userGroupData.name,
         description: userGroupData.description,
       });
+
       toastSuccess(t('toaster.update_successed', { target: t('UserGroup') }));
+
+      // mutate
       await mutateUserGroups();
+
+      hideCreateModal();
     }
     catch (err) {
       toastError(err);
     }
-  }, [t, mutateUserGroups]);
+  }, [t, mutateUserGroups, hideCreateModal]);
+
+  const updateUserGroup = useCallback(async(userGroupData: IUserGroupHasId) => {
+    try {
+      await apiv3Put(`/user-groups/${userGroupData._id}`, {
+        name: userGroupData.name,
+        description: userGroupData.description,
+      });
+
+      toastSuccess(t('toaster.update_successed', { target: t('UserGroup') }));
+
+      // mutate
+      await mutateUserGroups();
+
+      hideUpdateModal();
+    }
+    catch (err) {
+      toastError(err);
+    }
+  }, [t, mutateUserGroups, hideUpdateModal]);
 
   const deleteUserGroupById = useCallback(async(deleteGroupId: string, actionName: string, transferToUserGroupId: string) => {
     try {
@@ -114,7 +149,7 @@ const UserGroupPage: FC = () => {
     <div data-testid="admin-user-groups">
       {
         isAclEnabled ? (
-          <div className="mb-2">
+          <div className="mb-3">
             <button type="button" className="btn btn-outline-secondary" onClick={showCreateModal}>
               {t('admin:user_group_management.create_group')}
             </button>
@@ -123,25 +158,37 @@ const UserGroupPage: FC = () => {
           t('admin:user_group_management.deny_create_group')
         )
       }
-      <UserGroupCreateModal
-        onClickCreateButton={createUserGroup}
+
+      <UserGroupModal
+        buttonLabel={t('Create')}
+        onClickSubmit={createUserGroup}
         isShow={isCreateModalShown}
         onHide={hideCreateModal}
       />
+
+      <UserGroupModal
+        userGroup={selectedUserGroup}
+        buttonLabel={t('Update')}
+        onClickSubmit={updateUserGroup}
+        isShow={isUpdateModalShown}
+        onHide={hideUpdateModal}
+      />
+
       <UserGroupTable
         headerLabel={t('admin:user_group_management.group_list')}
         userGroups={userGroups}
         childUserGroups={childUserGroups}
         isAclEnabled={isAclEnabled ?? false}
+        onEdit={showUpdateModal}
         onDelete={showDeleteModal}
         userGroupRelations={userGroupRelations}
       />
+
       <UserGroupDeleteModal
         userGroups={userGroups}
         deleteUserGroup={selectedUserGroup}
         onDelete={deleteUserGroupById}
         isShow={isDeleteModalShown}
-        onShow={showDeleteModal}
         onHide={hideDeleteModal}
       />
     </div>

+ 28 - 11
packages/app/src/components/Admin/UserGroup/UserGroupTable.tsx

@@ -15,6 +15,7 @@ type Props = {
   userGroupRelations: IUserGroupRelation[],
   childUserGroups: IUserGroupHasId[],
   isAclEnabled: boolean,
+  onEdit?: (userGroup: IUserGroupHasId) => void | Promise<void>,
   onDelete?: (userGroup: IUserGroupHasId) => void | Promise<void>,
 };
 
@@ -65,22 +66,38 @@ const UserGroupTable: FC<Props> = (props: Props) => {
   /*
    * Function
    */
-  const onClickDelete = useCallback((e) => { // no preventDefault
-    if (props.onDelete == null) {
-      return;
-    }
-
+  const findUserGroup = (e: React.ChangeEvent<HTMLInputElement>): IUserGroupHasId | undefined => {
     const groupId = e.target.getAttribute('data-user-group-id');
-    const group = props.userGroups.find((group) => {
+    return props.userGroups.find((group) => {
       return group._id === groupId;
     });
+  };
+
+  const onClickEdit = (e) => {
+    if (props.onEdit == null) {
+      return;
+    }
+
+    const userGroup = findUserGroup(e);
+    if (userGroup == null) {
+      return;
+    }
+
+    props.onEdit(userGroup);
+  };
+
+  const onClickDelete = (e) => { // no preventDefault
+    if (props.onDelete == null) {
+      return;
+    }
 
-    if (group == null) {
+    const userGroup = findUserGroup(e);
+    if (userGroup == null) {
       return;
     }
 
-    props.onDelete(group);
-  }, [props]);
+    props.onDelete(userGroup);
+  };
 
   /*
    * useEffect
@@ -159,9 +176,9 @@ const UserGroupTable: FC<Props> = (props: Props) => {
                           <i className="icon-settings"></i>
                         </button>
                         <div className="dropdown-menu" role="menu" aria-labelledby={`admin-group-menu-button-${group._id}`}>
-                          <a className="dropdown-item" href={`/admin/user-group-detail/${group._id}`}>
+                          <button className="dropdown-item" type="button" role="button" onClick={onClickEdit} data-user-group-id={group._id}>
                             <i className="icon-fw icon-note"></i> {t('Edit')}
-                          </a>
+                          </button>
                           <button className="dropdown-item" type="button" role="button" onClick={onClickDelete} data-user-group-id={group._id}>
                             <i className="icon-fw icon-fire text-danger"></i> {t('Delete')}
                           </button>

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

+ 159 - 72
packages/app/src/components/Admin/UserGroupDetail/UserGroupDetailPage.tsx

@@ -5,8 +5,9 @@ import { useTranslation } from 'react-i18next';
 
 import UserGroupForm from '../UserGroup/UserGroupForm';
 import UserGroupTable from '../UserGroup/UserGroupTable';
-import UserGroupCreateModal from '../UserGroup/UserGroupCreateModal';
+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';
@@ -21,9 +22,11 @@ import {
   IUserGroup, IUserGroupHasId,
 } from '~/interfaces/user';
 import {
-  useSWRxUserGroupPages, useSWRxUserGroupRelationList, useSWRxChildUserGroupList, useSWRxSelectableUserGroups, useSWRxAncestorUserGroups,
+  useSWRxUserGroupPages, useSWRxUserGroupRelationList, useSWRxChildUserGroupList,
+  useSWRxSelectableParentUserGroups, useSWRxSelectableChildUserGroups, useSWRxAncestorUserGroups,
 } from '~/stores/user-group';
 import { useIsAclEnabled } from '~/stores/context';
+import { useUpdateUserGroupConfirmModal } from '~/stores/modal';
 
 const UserGroupDetailPage: FC = () => {
   const { t } = useTranslation();
@@ -32,21 +35,22 @@ 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);
   const [isAlsoNameSearched, setAlsoNameSearched] = useState<boolean>(false);
   const [selectedUserGroup, setSelectedUserGroup] = useState<IUserGroupHasId | undefined>(undefined); // not null but undefined (to use defaultProps in UserGroupDeleteModal)
   const [isCreateModalShown, setCreateModalShown] = useState<boolean>(false);
+  const [isUpdateModalShown, setUpdateModalShown] = useState<boolean>(false);
   const [isDeleteModalShown, setDeleteModalShown] = useState<boolean>(false);
 
   /*
    * 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);
@@ -54,12 +58,15 @@ const UserGroupDetailPage: FC = () => {
   const { data: userGroupRelationList, mutate: mutateUserGroupRelations } = useSWRxUserGroupRelationList(childUserGroupIds);
   const childUserGroupRelations = userGroupRelationList != null ? userGroupRelationList : [];
 
-  const { data: selectableUserGroups, mutate: mutateSelectableUserGroups } = useSWRxSelectableUserGroups(userGroup._id);
+  const { data: selectableParentUserGroups, mutate: mutateSelectableParentUserGroups } = useSWRxSelectableParentUserGroups(currentUserGroup._id);
+  const { data: selectableChildUserGroups, mutate: mutateSelectableChildUserGroups } = useSWRxSelectableChildUserGroups(currentUserGroup._id);
 
-  const { data: ancestorUserGroups } = useSWRxAncestorUserGroups(userGroup._id);
+  const { data: ancestorUserGroups, mutate: mutateAncestorUserGroups } = useSWRxAncestorUserGroups(currentUserGroup._id);
 
   const { data: isAclEnabled } = useIsAclEnabled();
 
+  const { open: openUpdateParentConfirmModal } = useUpdateUserGroupConfirmModal();
+
   /*
    * Function
    */
@@ -77,20 +84,66 @@ const UserGroupDetailPage: FC = () => {
     setSearchType(searchType);
   }, []);
 
-  const updateUserGroup = useCallback(async(param: Partial<IUserGroup>) => {
-    try {
-      const res = await apiv3Put<{ userGroup: IUserGroupHasId }>(`/user-groups/${userGroup._id}`, param);
-      const { userGroup: newUserGroup } = res.data;
-      setUserGroup(newUserGroup);
-      toastSuccess(t('toaster.update_successed', { target: t('UserGroup') }));
+  const updateUserGroup = useCallback(async(userGroup: IUserGroupHasId, update: Partial<IUserGroupHasId>, forceUpdateParents: boolean) => {
+    if (update.parent == null) {
+      throw Error('"parent" attr must not be null');
     }
-    catch (err) {
-      toastError(err);
+
+    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],
+  );
+
+  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;
     }
-  }, [t, userGroup._id, setUserGroup]);
+
+    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,
+      );
+    }
+    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,
@@ -104,31 +157,55 @@ 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);
+    setSelectedUserGroup(group);
+  }, [setUpdateModalShown]);
+
+  const hideUpdateModal = useCallback(() => {
+    setUpdateModalShown(false);
+    setSelectedUserGroup(undefined);
+  }, [setUpdateModalShown]);
 
-  const onClickAddChildButtonHandler = async(selectedUserGroup: IUserGroupHasId) => {
+  const updateChildUserGroup = useCallback(async(userGroupData: IUserGroupHasId) => {
     try {
-      await apiv3Put(`/user-groups/${selectedUserGroup._id}`, {
-        name: selectedUserGroup.name,
-        description: selectedUserGroup.description,
-        parentId: userGroup._id,
-        forceUpdateParents: false,
+      await apiv3Put(`/user-groups/${userGroupData._id}`, {
+        name: userGroupData.name,
+        description: userGroupData.description,
+        parentId: userGroupData.parent,
       });
-      mutateSelectableUserGroups();
-      mutateChildUserGroups();
+
       toastSuccess(t('toaster.update_successed', { target: t('UserGroup') }));
+
+      // mutate
+      mutateChildUserGroups();
+
+      hideUpdateModal();
     }
     catch (err) {
       toastError(err);
     }
-  };
+  }, [t, mutateChildUserGroups, hideUpdateModal]);
+
+  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);
@@ -143,15 +220,22 @@ const UserGroupDetailPage: FC = () => {
       await apiv3Post('/user-groups', {
         name: userGroupData.name,
         description: userGroupData.description,
-        parentId: userGroup._id,
+        parentId: currentUserGroup._id,
       });
-      mutateChildUserGroups();
+
       toastSuccess(t('toaster.update_successed', { target: t('UserGroup') }));
+
+      // mutate
+      mutateChildUserGroups();
+      mutateSelectableChildUserGroups();
+      mutateSelectableParentUserGroups();
+
+      hideCreateModal();
     }
     catch (err) {
       toastError(err);
     }
-  }, [t, userGroup, mutateChildUserGroups]);
+  }, [t, currentUserGroup, mutateChildUserGroups, mutateSelectableChildUserGroups, mutateSelectableParentUserGroups, hideCreateModal]);
 
   const showDeleteModal = useCallback(async(group: IUserGroupHasId) => {
     setSelectedUserGroup(group);
@@ -186,48 +270,38 @@ const UserGroupDetailPage: FC = () => {
   /*
    * Dependencies
    */
-  if (userGroup == null) {
+  if (currentUserGroup == null) {
     return <></>;
   }
 
   return (
     <div>
-      <a href="/admin/user-groups" className="btn btn-outline-secondary">
-        <i className="icon-fw ti-arrow-left" aria-hidden="true"></i>
-        {t('admin:user_group_management.back_to_list')}
-      </a>
-
-      {
-        userGroup?.parent != null && ancestorUserGroups != null && ancestorUserGroups.length > 0 && (
-          <div className="btn-group ml-2">
-            <a className="btn btn-outline-secondary" href={`/admin/user-group-detail/${userGroup.parent}`}>
-              <i className="icon-fw ti-arrow-left" aria-hidden="true"></i>
-              {t('admin:user_group_management.back_to_ancestors_group')}
-            </a>
-            <button
-              type="button"
-              className="btn btn-outline-secondary dropdown-toggle dropdown-toggle-split"
-              data-toggle="dropdown"
-              aria-haspopup="true"
-              ria-expanded="false"
-            >
-            </button>
-            <div className="dropdown-menu">
-              {
-                ancestorUserGroups.map(userGroup => (
-                  <a className="dropdown-item" key={userGroup._id} href={`/admin/user-group-detail/${userGroup._id}`}>{userGroup.name}</a>
-                ))
-              }
-            </div>
-          </div>
-        )
-      }
+      <nav aria-label="breadcrumb">
+        <ol className="breadcrumb">
+          <li className="breadcrumb-item"><a href="/admin/user-groups">{t('admin:user_group_management.group_list')}</a></li>
+          {
+            ancestorUserGroups != null && ancestorUserGroups.length > 0 && (
+              ancestorUserGroups.map((ancestorUserGroup: IUserGroupHasId) => (
+                // 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>
+                  )}
+                </li>
+              ))
+            )
+          }
+        </ol>
+      </nav>
 
       <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>
@@ -236,29 +310,42 @@ const UserGroupDetailPage: FC = () => {
 
       <h2 className="admin-setting-header mt-4">{t('admin:user_group_management.child_group_list')}</h2>
       <UserGroupDropdown
-        selectableUserGroups={selectableUserGroups}
-        onClickAddExistingUserGroupButtonHandler={onClickAddChildButtonHandler}
-        onClickCreateUserGroupButtonHandler={showCreateModal}
+        selectableUserGroups={selectableChildUserGroups}
+        onClickAddExistingUserGroupButton={onClickAddExistingUserGroupButtonHandler}
+        onClickCreateUserGroupButton={showCreateModal}
       />
-      <UserGroupCreateModal
-        onClickCreateButton={createChildUserGroup}
+
+      <UserGroupModal
+        userGroup={selectedUserGroup}
+        buttonLabel={t('Update')}
+        onClickSubmit={updateChildUserGroup}
+        isShow={isUpdateModalShown}
+        onHide={hideUpdateModal}
+      />
+
+      <UserGroupModal
+        buttonLabel={t('Create')}
+        onClickSubmit={createChildUserGroup}
         isShow={isCreateModalShown}
         onHide={hideCreateModal}
       />
 
+      <UpdateParentConfirmModal />
+
       <UserGroupTable
         userGroups={childUserGroups}
         childUserGroups={grandChildUserGroups}
         isAclEnabled={isAclEnabled ?? false}
+        onEdit={showUpdateModal}
         onDelete={showDeleteModal}
         userGroupRelations={childUserGroupRelations}
       />
+
       <UserGroupDeleteModal
         userGroups={childUserGroups}
         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>

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

@@ -16,7 +16,6 @@ export type AlertInfo = {
 }
 
 type ClosableTextInputProps = {
-  isShown: boolean
   value?: string
   placeholder?: string
   inputValidator?(text: string): AlertInfo | Promise<AlertInfo> | null
@@ -107,7 +106,7 @@ const ClosableTextInput: FC<ClosableTextInputProps> = memo((props: ClosableTextI
 
 
   return (
-    <div className={props.isShown ? 'd-block' : 'd-none'}>
+    <div className="d-block flex-fill">
       <input
         value={inputText || ''}
         ref={inputRef}

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

@@ -1,4 +1,4 @@
-import React, { useState, useCallback } from 'react';
+import React, { useState, useCallback, useEffect } from 'react';
 import {
   Dropdown, DropdownMenu, DropdownToggle, DropdownItem,
 } from 'reactstrap';
@@ -40,6 +40,7 @@ type CommonProps = {
   onClickRevertMenuItem?: (pageId: string) => Promise<void> | void,
 
   additionalMenuItemRenderer?: React.FunctionComponent<AdditionalMenuItemsRendererProps>,
+  isInstantRename?: boolean,
 }
 
 
@@ -55,7 +56,7 @@ const PageItemControlDropdownMenu = React.memo((props: DropdownMenuProps): JSX.E
     pageId, isLoading,
     pageInfo, isEnableActions, forceHideMenuItems,
     onClickBookmarkMenuItem, onClickDuplicateMenuItem, onClickRenameMenuItem, onClickDeleteMenuItem, onClickRevertMenuItem,
-    additionalMenuItemRenderer: AdditionalMenuItems,
+    additionalMenuItemRenderer: AdditionalMenuItems, isInstantRename,
   } = props;
 
 
@@ -141,7 +142,7 @@ const PageItemControlDropdownMenu = React.memo((props: DropdownMenuProps): JSX.E
 
         {/* Duplicate */}
         { !forceHideMenuItems?.includes(MenuItemType.DUPLICATE) && isEnableActions && (
-          <DropdownItem onClick={duplicateItemClickedHandler}>
+          <DropdownItem onClick={duplicateItemClickedHandler} data-testid="open-page-duplicate-modal-btn">
             <i className="icon-fw icon-docs"></i>
             {t('Duplicate')}
           </DropdownItem>
@@ -149,9 +150,9 @@ const PageItemControlDropdownMenu = React.memo((props: DropdownMenuProps): JSX.E
 
         {/* Move/Rename */}
         { !forceHideMenuItems?.includes(MenuItemType.RENAME) && isEnableActions && pageInfo.isMovable && (
-          <DropdownItem onClick={renameItemClickedHandler}>
+          <DropdownItem onClick={renameItemClickedHandler} data-testid="open-page-move-rename-modal-btn">
             <i className="icon-fw  icon-action-redo"></i>
-            {t('Move/Rename')}
+            {t(isInstantRename ? 'Rename' : 'Move/Rename')}
           </DropdownItem>
         ) }
 
@@ -179,6 +180,7 @@ const PageItemControlDropdownMenu = React.memo((props: DropdownMenuProps): JSX.E
               className={`pt-2 ${pageInfo.isDeletable ? 'text-danger' : ''}`}
               disabled={!pageInfo.isDeletable}
               onClick={deleteItemClickedHandler}
+              data-testid="open-page-delete-modal-btn"
             >
               <i className="icon-fw icon-trash"></i>
               {t('Delete')}
@@ -212,12 +214,19 @@ export const PageItemControlSubstance = (props: PageItemControlSubstanceProps):
   } = props;
 
   const [isOpen, setIsOpen] = useState(false);
+  const [shouldFetch, setShouldFetch] = useState(fetchOnInit ?? false);
 
-  const shouldFetch = fetchOnInit === true || (!isIPageInfoForOperation(presetPageInfo) && isOpen);
-  const shouldMutate = fetchOnInit === true || !isIPageInfoForOperation(presetPageInfo);
+  const { data: fetchedPageInfo, mutate: mutatePageInfo } = useSWRxPageInfo(shouldFetch ? pageId : null);
 
-  const { data: fetchedPageInfo } = useSWRxPageInfo(shouldFetch ? pageId : null);
-  const { mutate: mutatePageInfo } = useSWRxPageInfo(shouldMutate ? pageId : null);
+  // update shouldFetch (and will never be false)
+  useEffect(() => {
+    if (shouldFetch) {
+      return;
+    }
+    if (!isIPageInfoForOperation(presetPageInfo) && isOpen) {
+      setShouldFetch(true);
+    }
+  }, [isOpen, presetPageInfo, shouldFetch]);
 
   // mutate after handle event
   const bookmarkMenuItemClickHandler = useCallback(async(_pageId: string, _newValue: boolean) => {
@@ -225,10 +234,10 @@ export const PageItemControlSubstance = (props: PageItemControlSubstanceProps):
       await onClickBookmarkMenuItem(_pageId, _newValue);
     }
 
-    if (shouldMutate) {
+    if (shouldFetch) {
       mutatePageInfo();
     }
-  }, [mutatePageInfo, onClickBookmarkMenuItem, shouldMutate]);
+  }, [mutatePageInfo, onClickBookmarkMenuItem, shouldFetch]);
 
   const isLoading = shouldFetch && fetchedPageInfo == null;
 
@@ -257,7 +266,7 @@ export const PageItemControlSubstance = (props: PageItemControlSubstanceProps):
     <Dropdown isOpen={isOpen} toggle={() => setIsOpen(!isOpen)} data-testid="open-page-item-control-btn">
       { children ?? (
         <DropdownToggle color="transparent" className="border-0 rounded btn-page-item-control d-flex align-items-center justify-content-center">
-          <i className="icon-options text-muted"></i>
+          <i className="icon-options"></i>
         </DropdownToggle>
       ) }
 

+ 3 - 1
packages/app/src/components/Fab.jsx

@@ -12,6 +12,7 @@ import { smoothScrollIntoView } from '~/client/util/smooth-scroll';
 import { withUnstatedContainers } from './UnstatedUtils';
 import CreatePageIcon from './Icons/CreatePageIcon';
 import ReturnTopIcon from './Icons/ReturnTopIcon';
+import { useCurrentPagePath } from '~/stores/context';
 
 const logger = loggerFactory('growi:cli:Fab');
 
@@ -20,6 +21,7 @@ const Fab = (props) => {
   const { currentUser } = appContainer;
 
   const { open: openCreateModal } = usePageCreateModal();
+  const { data: currentPath = '' } = useCurrentPagePath();
 
   const [animateClasses, setAnimateClasses] = useState('invisible');
   const [buttonClasses, setButtonClasses] = useState('');
@@ -57,7 +59,7 @@ const Fab = (props) => {
           <button
             type="button"
             className={`btn btn-lg btn-create-page btn-primary rounded-circle p-0 waves-effect waves-light ${buttonClasses}`}
-            onClick={() => openCreateModal()}
+            onClick={() => openCreateModal(currentPath)}
           >
             <CreatePageIcon />
           </button>

+ 3 - 1
packages/app/src/components/Hotkeys/Subscribers/CreatePage.jsx

@@ -2,14 +2,16 @@ import React, { useEffect } from 'react';
 import PropTypes from 'prop-types';
 
 import { usePageCreateModal } from '~/stores/modal';
+import { useCurrentPagePath } from '~/stores/context';
 
 const CreatePage = React.memo((props) => {
 
   const { open: openCreateModal } = usePageCreateModal();
+  const { data: currentPath = '' } = useCurrentPagePath();
 
   // setup effect
   useEffect(() => {
-    openCreateModal();
+    openCreateModal(currentPath);
 
     // remove this
     props.onDeleteRender(this);

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

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

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

+ 1 - 1
packages/app/src/components/Navbar/AuthorInfo.jsx

@@ -54,7 +54,7 @@ const AuthorInfo = (props) => {
       </div>
       <div>
         <div>{infoLabelForSubNav} {userLabel}</div>
-        <div className="text-muted text-date">
+        <div className="text-muted text-date" data-hide-in-vrt>
           {renderParsedDate()}
         </div>
       </div>

+ 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 - 1
packages/app/src/components/Navbar/GrowiContextualSubNavigation.tsx

@@ -256,7 +256,7 @@ const GrowiContextualSubNavigation = (props) => {
             />
           ) }
         </div>
-        <div className={className}>
+        <div className={`${className} ${isCompactMode ? '' : 'mt-2'}`}>
           {isAbleToShowPageEditorModeManager && (
             <PageEditorModeManager
               onPageEditorModeButtonClicked={onPageEditorModeButtonClicked}

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

@@ -85,7 +85,7 @@ export const GrowiSubNavigation = (props: Props): JSX.Element => {
       {/* Right side */}
       <div className="d-flex">
 
-        <div className="d-flex flex-column" style={{ gap: `${isCompactMode ? '5px' : '0'}` }}>
+        <div className="d-flex flex-column py-md-2" style={{ gap: `${isCompactMode ? '5px' : '0'}` }}>
           { Controls && <Controls></Controls> }
         </div>
 

+ 18 - 17
packages/app/src/components/Navbar/PageEditorModeManager.jsx

@@ -48,16 +48,15 @@ function PageEditorModeManager(props) {
   const isAdmin = appContainer.isAdmin;
   const isHackmdEnabled = appContainer.config.env.HACKMD_URI != null;
   const showHackmdBtn = isHackmdEnabled || isAdmin;
-  const showHackmdDisabledTooltip = isAdmin && !isHackmdEnabled && editorMode !== EditorMode.HackMD;
 
   const pageEditorModeButtonClickedHandler = useCallback((viewType) => {
-    if (isBtnDisabled) {
+    if (isBtnDisabled || !isHackmdEnabled) {
       return;
     }
     if (onPageEditorModeButtonClicked != null) {
       onPageEditorModeButtonClicked(viewType);
     }
-  }, [isBtnDisabled, onPageEditorModeButtonClicked]);
+  }, [isBtnDisabled, isHackmdEnabled, onPageEditorModeButtonClicked]);
 
   return (
     <>
@@ -88,15 +87,22 @@ function PageEditorModeManager(props) {
           />
         )}
         {(!isDeviceSmallerThanMd || editorMode === EditorMode.View) && showHackmdBtn && (
-          <PageEditorModeButtonWrapper
-            editorMode={editorMode}
-            isBtnDisabled={isBtnDisabled}
-            onClick={pageEditorModeButtonClickedHandler}
-            targetMode={EditorMode.HackMD}
-            icon={<i className="fa fa-file-text-o" />}
-            label={t('hackmd.hack_md')}
-            id="grw-page-editor-mode-manager-hackmd-button"
-          />
+          <>
+            <PageEditorModeButtonWrapper
+              editorMode={editorMode}
+              isBtnDisabled={isBtnDisabled || !isHackmdEnabled}
+              onClick={pageEditorModeButtonClickedHandler}
+              targetMode={EditorMode.HackMD}
+              icon={<i className="fa fa-file-text-o" />}
+              label={t('hackmd.hack_md')}
+              id="grw-page-editor-mode-manager-hackmd-button"
+            />
+            { !isHackmdEnabled && (
+              <UncontrolledTooltip placement="top" target="grw-page-editor-mode-manager-hackmd-button" fade={false}>
+                {t('hackmd.not_set_up')}
+              </UncontrolledTooltip>
+            )}
+          </>
         )}
       </div>
       {isBtnDisabled && (
@@ -104,11 +110,6 @@ function PageEditorModeManager(props) {
           {t('Not available for guest')}
         </UncontrolledTooltip>
       )}
-      {!isBtnDisabled && showHackmdDisabledTooltip && (
-        <UncontrolledTooltip placement="top" target="grw-page-editor-mode-manager-hackmd-button" fade={false}>
-          {t('hackmd.not_set_up')}
-        </UncontrolledTooltip>
-      )}
     </>
   );
 

+ 7 - 9
packages/app/src/components/Navbar/PersonalDropdown.jsx

@@ -7,7 +7,7 @@ import { UncontrolledTooltip } from 'reactstrap';
 
 import { UserPicture } from '@growi/ui';
 
-import { scheduleToPutUserUISettings } from '~/client/services/user-ui-settings';
+import { useUserUISettings } from '~/client/services/user-ui-settings';
 import AppContainer from '~/client/services/AppContainer';
 
 import { withUnstatedContainers } from '../UnstatedUtils';
@@ -39,14 +39,12 @@ const PersonalDropdown = (props) => {
 
   const { data: isPreferDrawerMode, mutate: mutatePreferDrawerMode } = usePreferDrawerModeByUser();
   const { data: isPreferDrawerModeOnEdit, mutate: mutatePreferDrawerModeOnEdit } = usePreferDrawerModeOnEditByUser();
+  const { scheduleToPut } = useUserUISettings();
 
   const logoutHandler = () => {
     const { interceptorManager } = appContainer;
 
-    const context = {
-      user,
-      currentPagePath: decodeURIComponent(window.location.pathname),
-    };
+    const context = {};
     interceptorManager.process('logout', context);
 
     window.location.href = '/logout';
@@ -54,13 +52,13 @@ const PersonalDropdown = (props) => {
 
   const preferDrawerModeSwitchModifiedHandler = useCallback((bool) => {
     mutatePreferDrawerMode(bool);
-    scheduleToPutUserUISettings({ preferDrawerModeByUser: bool });
-  }, [mutatePreferDrawerMode]);
+    scheduleToPut({ preferDrawerModeByUser: bool });
+  }, [mutatePreferDrawerMode, scheduleToPut]);
 
   const preferDrawerModeOnEditSwitchModifiedHandler = useCallback((bool) => {
     mutatePreferDrawerModeOnEdit(bool);
-    scheduleToPutUserUISettings({ preferDrawerModeOnEditByUser: bool });
-  }, [mutatePreferDrawerModeOnEdit]);
+    scheduleToPut({ preferDrawerModeOnEditByUser: bool });
+  }, [mutatePreferDrawerModeOnEdit, scheduleToPut]);
 
   const followOsCheckboxModifiedHandler = (bool) => {
     if (bool) {

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

+ 219 - 0
packages/app/src/components/PageComment.tsx

@@ -0,0 +1,219 @@
+import React, {
+  FC, useEffect, useState, useMemo, memo, useCallback,
+} from 'react';
+
+import { Button } from 'reactstrap';
+
+import CommentEditor from './PageComment/CommentEditor';
+import Comment from './PageComment/Comment';
+import ReplayComments from './PageComment/ReplayComments';
+import DeleteCommentModal from './PageComment/DeleteCommentModal';
+
+import AppContainer from '~/client/services/AppContainer';
+import { toastError } from '~/client/util/apiNotification';
+
+import { useSWRxPageComment } from '../stores/comment';
+
+import { ICommentHasId, ICommentHasIdList } from '../interfaces/comment';
+
+type Props = {
+  appContainer: AppContainer,
+  pageId: string,
+  isReadOnly : boolean,
+  titleAlign?: 'center' | 'left' | 'right',
+  highlightKeywords?:string[],
+  hideIfEmpty?: boolean,
+}
+
+
+const PageComment:FC<Props> = memo((props:Props):JSX.Element => {
+
+  const {
+    appContainer, pageId, highlightKeywords, isReadOnly, titleAlign, hideIfEmpty,
+  } = props;
+
+  const { data: comments, mutate } = useSWRxPageComment(pageId);
+
+  const [commentToBeDeleted, setCommentToBeDeleted] = useState<ICommentHasId | null>(null);
+  const [isDeleteConfirmModalShown, setIsDeleteConfirmModalShown] = useState<boolean>(false);
+  const [showEditorIds, setShowEditorIds] = useState<Set<string>>(new Set());
+  const [formatedComments, setFormatedComments] = useState<ICommentHasIdList | null>(null);
+  const [errorMessageOnDelete, setErrorMessageOnDelete] = useState<string>('');
+
+  const commentsFromOldest = useMemo(() => (formatedComments != null ? [...formatedComments].reverse() : null), [formatedComments]);
+  const commentsExceptReply: ICommentHasIdList | undefined = useMemo(
+    () => commentsFromOldest?.filter(comment => comment.replyTo == null), [commentsFromOldest],
+  );
+  const allReplies = {};
+
+  const highlightComment = useCallback((comment: string):string => {
+    if (highlightKeywords == null) return comment;
+
+    let highlightedComment = '';
+    highlightKeywords.forEach((highlightKeyword) => {
+      highlightedComment = comment.replaceAll(highlightKeyword, '<em class="highlighted-keyword">$&</em>');
+    });
+    return highlightedComment;
+  }, [highlightKeywords]);
+
+  useEffect(() => {
+
+    if (comments != null) {
+      const preprocessedCommentList: string[] = comments.map((comment) => {
+        const highlightedComment: string = highlightComment(comment.comment);
+        return highlightedComment;
+      });
+      const preprocessedComments: ICommentHasIdList = comments.map((comment, index) => {
+        return { ...comment, comment: preprocessedCommentList[index] };
+      });
+      setFormatedComments(preprocessedComments);
+    }
+
+  }, [comments, highlightComment]);
+
+  if (commentsFromOldest != null) {
+    commentsFromOldest.forEach((comment) => {
+      if (comment.replyTo != null) {
+        allReplies[comment.replyTo] = allReplies[comment.replyTo] == null ? [comment] : [...allReplies[comment.replyTo], comment];
+      }
+    });
+  }
+
+  const onClickDeleteButton = useCallback((comment: ICommentHasId) => {
+    setCommentToBeDeleted(comment);
+    setIsDeleteConfirmModalShown(true);
+  }, []);
+
+  const onCancelDeleteComment = useCallback(() => {
+    setCommentToBeDeleted(null);
+    setIsDeleteConfirmModalShown(false);
+  }, []);
+
+  const onDeleteCommentAfterOperation = useCallback(() => {
+    onCancelDeleteComment();
+    mutate();
+  }, [mutate, onCancelDeleteComment]);
+
+  const onDeleteComment = useCallback(async() => {
+    if (commentToBeDeleted == null) return;
+    try {
+      await appContainer.apiPost('/comments.remove', { comment_id: commentToBeDeleted._id });
+      onDeleteCommentAfterOperation();
+    }
+    catch (error:unknown) {
+      setErrorMessageOnDelete(error as string);
+      toastError(`error: ${error}`);
+    }
+  }, [appContainer, commentToBeDeleted, onDeleteCommentAfterOperation]);
+
+  const generateCommentInnerElement = (comment: ICommentHasId) => (
+    <Comment
+      growiRenderer={appContainer.getRenderer('comment')}
+      deleteBtnClicked={onClickDeleteButton}
+      comment={comment}
+      onComment={mutate}
+      isReadOnly={isReadOnly}
+    />
+  );
+
+  const generateAllRepliesElement = (replyComments: ICommentHasIdList) => (
+    <ReplayComments
+      replyList={replyComments}
+      deleteBtnClicked={onClickDeleteButton}
+      growiRenderer={appContainer.getRenderer('comment')}
+      isReadOnly={isReadOnly}
+    />
+  );
+
+  const removeShowEditorId = useCallback((commentId: string) => {
+    setShowEditorIds((previousState) => {
+      const previousShowEditorIds = new Set(...previousState);
+      previousShowEditorIds.delete(commentId);
+      return previousShowEditorIds;
+    });
+  }, []);
+
+
+  if (commentsFromOldest == null || commentsExceptReply == null) return <></>;
+
+  if (hideIfEmpty && comments?.length === 0) {
+    return <></>;
+  }
+
+  let commentTitleClasses = 'border-bottom py-3 mb-3';
+  commentTitleClasses = titleAlign != null ? `${commentTitleClasses} text-${titleAlign}` : `${commentTitleClasses} text-center`;
+
+  return (
+    <>
+      <div className="page-comments-row comment-list">
+        <div className="container-lg">
+          <div className="page-comments">
+            <h2 className={commentTitleClasses}><i className="icon-fw icon-bubbles"></i>Comments</h2>
+            <div className="page-comments-list" id="page-comments-list">
+              { commentsExceptReply.map((comment) => {
+
+                const defaultCommentThreadClasses = 'page-comment-thread pb-5';
+                const hasReply: boolean = Object.keys(allReplies).includes(comment._id);
+
+                let commentThreadClasses = '';
+                commentThreadClasses = hasReply ? `${defaultCommentThreadClasses} page-comment-thread-no-replies` : defaultCommentThreadClasses;
+
+                return (
+                  <div key={comment._id} className={commentThreadClasses}>
+                    {/* display comment */}
+                    {generateCommentInnerElement(comment)}
+                    {/* display reply comment */}
+                    {hasReply && generateAllRepliesElement(allReplies[comment._id])}
+                    {/* display reply button */}
+                    {(!isReadOnly && !showEditorIds.has(comment._id)) && (
+                      <div className="text-right">
+                        <Button
+                          outline
+                          color="secondary"
+                          size="sm"
+                          className="btn-comment-reply"
+                          onClick={() => {
+                            setShowEditorIds(previousState => new Set(previousState.add(comment._id)));
+                          }}
+                        >
+                          <i className="icon-fw icon-action-undo"></i> Reply
+                        </Button>
+                      </div>
+                    )}
+                    {/* display reply editor */}
+                    {(!isReadOnly && showEditorIds.has(comment._id)) && (
+                      <CommentEditor
+                        growiRenderer={appContainer.getRenderer('comment')}
+                        replyTo={comment._id}
+                        onCancelButtonClicked={() => {
+                          removeShowEditorId(comment._id);
+                        }}
+                        onCommentButtonClicked={() => {
+                          removeShowEditorId(comment._id);
+                          mutate();
+                        }}
+                      />
+                    )}
+                  </div>
+                );
+
+              })}
+            </div>
+          </div>
+        </div>
+      </div>
+      {(!isReadOnly && commentToBeDeleted != null) && (
+        <DeleteCommentModal
+          isShown={isDeleteConfirmModalShown}
+          comment={commentToBeDeleted}
+          errorMessage={errorMessageOnDelete}
+          cancel={onCancelDeleteComment}
+          confirmedToDelete={onDeleteComment}
+        />
+      )}
+    </>
+  );
+});
+
+
+export default PageComment;

+ 26 - 15
packages/app/src/components/PageComment/Comment.jsx

@@ -1,7 +1,7 @@
 import React from 'react';
 import PropTypes from 'prop-types';
 
-import { withTranslation } from 'react-i18next';
+import { useTranslation } from 'react-i18next';
 import { format } from 'date-fns';
 
 import { UncontrolledTooltip } from 'reactstrap';
@@ -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 });
@@ -147,8 +147,9 @@ class Comment extends React.PureComponent {
   }
 
   render() {
-    const { t } = this.props;
-    const comment = this.props.comment;
+    const {
+      t, comment, isReadOnly, onComment,
+    } = this.props;
     const commentId = comment._id;
     const creator = comment.creator;
     const isMarkdown = comment.isMarkdown;
@@ -167,7 +168,7 @@ class Comment extends React.PureComponent {
 
     return (
       <React.Fragment>
-        {this.state.isReEdit ? (
+        {(this.state.isReEdit && !isReadOnly) ? (
           <CommentEditor
             growiRenderer={this.props.growiRenderer}
             currentCommentId={commentId}
@@ -175,7 +176,10 @@ class Comment extends React.PureComponent {
             replyTo={undefined}
             commentCreator={creator?.username}
             onCancelButtonClicked={() => this.setState({ isReEdit: false })}
-            onCommentButtonClicked={() => this.setState({ isReEdit: false })}
+            onCommentButtonClicked={() => {
+              this.setState({ isReEdit: false });
+              if (onComment != null) onComment();
+            }}
           />
         ) : (
           <div id={commentId} className={rootClassName}>
@@ -206,7 +210,7 @@ class Comment extends React.PureComponent {
                   </UncontrolledTooltip>
                 </span>
               </div>
-              {this.isCurrentUserEqualsToAuthor() && (
+              {(this.isCurrentUserEqualsToAuthor() && !isReadOnly) && (
                 <CommentControl
                   onClickDeleteBtn={this.deleteBtnClickedHandler}
                   onClickEditBtn={() => this.setState({ isReEdit: true })}
@@ -222,19 +226,26 @@ class Comment extends React.PureComponent {
 
 }
 
-/**
- * Wrapper component for using unstated
- */
-const CommentWrapper = withUnstatedContainers(Comment, [AppContainer, PageContainer]);
-
 Comment.propTypes = {
   t: PropTypes.func.isRequired, // i18next
   appContainer: PropTypes.instanceOf(AppContainer).isRequired,
   pageContainer: PropTypes.instanceOf(PageContainer).isRequired,
 
   comment: PropTypes.object.isRequired,
+  isReadOnly: PropTypes.bool.isRequired,
   growiRenderer: PropTypes.object.isRequired,
   deleteBtnClicked: PropTypes.func.isRequired,
+  onComment: PropTypes.func,
+};
+
+const CommentWrapperFC = (props) => {
+  const { t } = useTranslation();
+  return <Comment t={t} {...props} />;
 };
 
-export default withTranslation()(CommentWrapper);
+/**
+ * Wrapper component for using unstated
+ */
+const CommentWrapper = withUnstatedContainers(CommentWrapperFC, [AppContainer, PageContainer]);
+
+export default CommentWrapper;

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

@@ -171,7 +171,7 @@ class CommentEditor extends React.Component {
       this.initializeEditor();
 
       if (onCommentButtonClicked != null) {
-        onCommentButtonClicked(replyTo || currentCommentId);
+        onCommentButtonClicked();
       }
     }
     catch (err) {
@@ -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) })

+ 0 - 31
packages/app/src/components/PageComment/CommentEditorLazyRenderer.jsx

@@ -1,31 +0,0 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-
-import { withUnstatedContainers } from '../UnstatedUtils';
-import AppContainer from '~/client/services/AppContainer';
-
-import CommentEditor from './CommentEditor';
-
-const CommentEditorLazyRenderer = (props) => {
-
-  const growiRenderer = props.appContainer.getRenderer('comment');
-
-  return (
-    <CommentEditor
-      growiRenderer={growiRenderer}
-      replyTo={undefined}
-      isForNewComment
-    />
-  );
-};
-
-/**
- * Wrapper component for using unstated
- */
-const CommentEditorLazyRendererWrapper = withUnstatedContainers(CommentEditorLazyRenderer, [AppContainer]);
-
-CommentEditorLazyRenderer.propTypes = {
-  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
-};
-
-export default CommentEditorLazyRendererWrapper;

+ 33 - 0
packages/app/src/components/PageComment/CommentEditorLazyRenderer.tsx

@@ -0,0 +1,33 @@
+import React, { FC } from 'react';
+
+import { useSWRxPageComment } from '../../stores/comment';
+
+import AppContainer from '~/client/services/AppContainer';
+
+import CommentEditor from './CommentEditor';
+
+type Props = {
+  appContainer: AppContainer,
+  pageId: string,
+}
+
+const CommentEditorLazyRenderer:FC<Props> = (props:Props):JSX.Element => {
+
+  const { pageId } = props;
+  const { mutate } = useSWRxPageComment(pageId);
+
+  const { appContainer } = props;
+  const growiRenderer = appContainer.getRenderer('comment');
+
+  return (
+    <CommentEditor
+      appContainer={appContainer}
+      growiRenderer={growiRenderer}
+      replyTo={undefined}
+      onCommentButtonClicked={mutate}
+      isForNewComment
+    />
+  );
+};
+
+export default CommentEditorLazyRenderer;

+ 2 - 0
packages/app/src/components/PageComment/ReplayComments.jsx

@@ -33,6 +33,7 @@ class ReplayComments extends React.PureComponent {
           comment={reply}
           deleteBtnClicked={this.props.deleteBtnClicked}
           growiRenderer={this.props.growiRenderer}
+          isReadOnly={this.props.isReadOnly}
         />
       </div>
     );
@@ -108,6 +109,7 @@ ReplayComments.propTypes = {
 
   growiRenderer: PropTypes.object.isRequired,
   deleteBtnClicked: PropTypes.func.isRequired,
+  isReadOnly: PropTypes.bool.isRequired,
   replyList: PropTypes.array,
 };
 

+ 0 - 241
packages/app/src/components/PageComments.jsx

@@ -1,241 +0,0 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-
-import {
-  Button,
-} from 'reactstrap';
-
-import { withTranslation } from 'react-i18next';
-
-import AppContainer from '~/client/services/AppContainer';
-import CommentContainer from '~/client/services/CommentContainer';
-import PageContainer from '~/client/services/PageContainer';
-
-import { withUnstatedContainers } from './UnstatedUtils';
-
-import CommentEditor from './PageComment/CommentEditor';
-import Comment from './PageComment/Comment';
-import DeleteCommentModal from './PageComment/DeleteCommentModal';
-import ReplayComments from './PageComment/ReplayComments';
-
-
-/**
- * Load data of comments and render the list of <Comment />
- *
- * @author Yuki Takei <yuki@weseek.co.jp>
- *
- * @export
- * @class PageComments
- * @extends {React.Component}
- */
-class PageComments extends React.Component {
-
-  constructor(props) {
-    super(props);
-
-    this.state = {
-      // for deleting comment
-      commentToDelete: undefined,
-      isDeleteConfirmModalShown: false,
-      errorMessageForDeleting: undefined,
-
-      showEditorIds: new Set(),
-    };
-
-    this.growiRenderer = this.props.appContainer.getRenderer('comment');
-
-    this.init = this.init.bind(this);
-    this.confirmToDeleteComment = this.confirmToDeleteComment.bind(this);
-    this.deleteComment = this.deleteComment.bind(this);
-    this.showDeleteConfirmModal = this.showDeleteConfirmModal.bind(this);
-    this.closeDeleteConfirmModal = this.closeDeleteConfirmModal.bind(this);
-    this.replyButtonClickedHandler = this.replyButtonClickedHandler.bind(this);
-    this.editorCancelHandler = this.editorCancelHandler.bind(this);
-    this.editorCommentHandler = this.editorCommentHandler.bind(this);
-    this.resetEditor = this.resetEditor.bind(this);
-  }
-
-  componentWillMount() {
-    this.init();
-  }
-
-  init() {
-    if (!this.props.pageContainer.state.pageId) {
-      return;
-    }
-
-    this.props.commentContainer.retrieveComments();
-  }
-
-  confirmToDeleteComment(comment) {
-    this.setState({ commentToDelete: comment });
-    this.showDeleteConfirmModal();
-  }
-
-  deleteComment() {
-    const comment = this.state.commentToDelete;
-
-    this.props.commentContainer.deleteComment(comment)
-      .then(() => {
-        this.closeDeleteConfirmModal();
-      })
-      .catch((err) => {
-        this.setState({ errorMessageForDeleting: err.message });
-      });
-  }
-
-  showDeleteConfirmModal() {
-    this.setState({ isDeleteConfirmModalShown: true });
-  }
-
-  closeDeleteConfirmModal() {
-    this.setState({
-      commentToDelete: undefined,
-      isDeleteConfirmModalShown: false,
-      errorMessageForDeleting: undefined,
-    });
-  }
-
-  replyButtonClickedHandler(commentId) {
-    const ids = this.state.showEditorIds.add(commentId);
-    this.setState({ showEditorIds: ids });
-  }
-
-  editorCancelHandler(commentId) {
-    this.resetEditor(commentId);
-  }
-
-  editorCommentHandler(commentId) {
-    this.resetEditor(commentId);
-  }
-
-  resetEditor(commentId) {
-    this.setState((prevState) => {
-      prevState.showEditorIds.delete(commentId);
-      return {
-        showEditorIds: prevState.showEditorIds,
-      };
-    });
-  }
-
-  // get replies to specific comment object
-  getRepliesFor(comment, allReplies) {
-    const replyList = [];
-    allReplies.forEach((reply) => {
-      if (reply.replyTo === comment._id) {
-        replyList.push(reply);
-      }
-    });
-    return replyList;
-  }
-
-  /**
-   * render Elements of Comment Thread
-   *
-   * @param {any} comment Comment Model Obj
-   * @param {any} replies List of Reply Comment Model Obj
-   *
-   * @memberOf PageComments
-   */
-  renderThread(comment, replies) {
-    const commentId = comment._id;
-    const showEditor = this.state.showEditorIds.has(commentId);
-    const isLoggedIn = this.props.appContainer.currentUser != null;
-
-    let rootClassNames = 'page-comment-thread';
-    if (replies.length === 0) {
-      rootClassNames += ' page-comment-thread-no-replies';
-    }
-
-    return (
-      <div key={commentId} className={rootClassNames}>
-        <Comment
-          comment={comment}
-          deleteBtnClicked={this.confirmToDeleteComment}
-          growiRenderer={this.growiRenderer}
-        />
-        {replies.length !== 0 && (
-          <ReplayComments
-            replyList={replies}
-            deleteBtnClicked={this.confirmToDeleteComment}
-            growiRenderer={this.growiRenderer}
-          />
-        )}
-        { !showEditor && isLoggedIn && (
-          <div className="text-right">
-            <Button
-              outline
-              color="secondary"
-              size="sm"
-              className="btn-comment-reply"
-              onClick={() => { return this.replyButtonClickedHandler(commentId) }}
-            >
-              <i className="icon-fw icon-action-undo"></i> Reply
-            </Button>
-          </div>
-        )}
-        { showEditor && (
-          <div className="page-comment-reply-form ml-4 ml-sm-5 mr-3">
-            <CommentEditor
-              growiRenderer={this.growiRenderer}
-              replyTo={commentId}
-              onCancelButtonClicked={this.editorCancelHandler}
-              onCommentButtonClicked={this.editorCommentHandler}
-            />
-          </div>
-        )}
-      </div>
-    );
-  }
-
-  render() {
-    const topLevelComments = [];
-    const allReplies = [];
-    const comments = this.props.commentContainer.state.comments
-      .slice().reverse(); // create shallow copy and reverse
-
-    comments.forEach((comment) => {
-      if (comment.replyTo === undefined) {
-        // comment is not a reply
-        topLevelComments.push(comment);
-      }
-      else {
-        // comment is a reply
-        allReplies.push(comment);
-      }
-    });
-
-    return (
-      <div>
-        { topLevelComments.map((topLevelComment) => {
-          // get related replies
-          const replies = this.getRepliesFor(topLevelComment, allReplies);
-
-          return this.renderThread(topLevelComment, replies);
-        }) }
-
-        <DeleteCommentModal
-          isShown={this.state.isDeleteConfirmModalShown}
-          comment={this.state.commentToDelete}
-          errorMessage={this.state.errorMessageForDeleting}
-          cancel={this.closeDeleteConfirmModal}
-          confirmedToDelete={this.deleteComment}
-        />
-      </div>
-    );
-  }
-
-}
-
-/**
- * Wrapper component for using unstated
- */
-const PageCommentsWrapper = withUnstatedContainers(PageComments, [AppContainer, PageContainer, CommentContainer]);
-
-PageComments.propTypes = {
-  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
-  pageContainer: PropTypes.instanceOf(PageContainer).isRequired,
-  commentContainer: PropTypes.instanceOf(CommentContainer).isRequired,
-};
-
-export default withTranslation()(PageCommentsWrapper);

+ 0 - 45
packages/app/src/components/PageContentFooter.jsx

@@ -1,45 +0,0 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-
-import AuthorInfo from './Navbar/AuthorInfo';
-
-import AppContainer from '~/client/services/AppContainer';
-import PageContainer from '~/client/services/PageContainer';
-import { withUnstatedContainers } from './UnstatedUtils';
-import { useCurrentCreatedAt, useCurrentUpdatedAt } from '~/stores/context';
-
-const PageContentFooter = (props) => {
-  const { pageContainer } = props;
-  const { data: createdAt } = useCurrentCreatedAt();
-  const { data: updatedAt } = useCurrentUpdatedAt();
-
-
-  const {
-    creator, revisionAuthor,
-  } = pageContainer.state;
-
-
-  return (
-    <div className="page-content-footer py-4 d-edit-none d-print-none">
-      <div className="grw-container-convertible">
-        <div className="page-meta">
-          <AuthorInfo user={creator} date={createdAt} mode="create" locate="footer" />
-          <AuthorInfo user={revisionAuthor} date={updatedAt} mode="update" locate="footer" />
-        </div>
-      </div>
-    </div>
-  );
-};
-
-/**
- * Wrapper component for using unstated
- */
-const PageContentFooterWrapper = withUnstatedContainers(PageContentFooter, [AppContainer, PageContainer]);
-
-
-PageContentFooter.propTypes = {
-  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
-  pageContainer: PropTypes.instanceOf(PageContainer).isRequired,
-};
-
-export default PageContentFooterWrapper;

+ 33 - 0
packages/app/src/components/PageContentFooter.tsx

@@ -0,0 +1,33 @@
+import React, { FC, memo } from 'react';
+
+import AuthorInfo from './Navbar/AuthorInfo';
+
+import { Ref } from '../interfaces/common';
+import { IUser } from '../interfaces/user';
+
+type Props = {
+  createdAt: Date,
+  updatedAt: Date,
+  creator: Ref<IUser>,
+  revisionAuthor: Ref<IUser>,
+}
+
+const PageContentFooter:FC<Props> = memo((props:Props):JSX.Element => {
+  const {
+    createdAt, updatedAt, creator, revisionAuthor,
+  } = props;
+
+  return (
+    <div className="page-content-footer py-4 d-edit-none d-print-none">
+      <div className="grw-container-convertible">
+        <div className="page-meta">
+          <AuthorInfo user={creator as IUser} date={createdAt} mode="create" locate="footer" />
+          <AuthorInfo user={revisionAuthor as IUser} date={updatedAt} mode="update" locate="footer" />
+        </div>
+      </div>
+    </div>
+  );
+});
+
+
+export default PageContentFooter;

+ 24 - 5
packages/app/src/components/PageCreateModal.jsx

@@ -1,8 +1,11 @@
 
-import React, { useEffect, useState } from 'react';
+import React, {
+  useEffect, useState, useMemo, useCallback,
+} from 'react';
 import PropTypes from 'prop-types';
 
 import { Modal, ModalHeader, ModalBody } from 'reactstrap';
+import { debounce } from 'throttle-debounce';
 
 import { withTranslation } from 'react-i18next';
 import { format } from 'date-fns';
@@ -19,7 +22,7 @@ import { usePageCreateModal } from '~/stores/modal';
 import PagePathAutoComplete from './PagePathAutoComplete';
 
 const {
-  userPageRoot, isCreatablePage, generateEditorPath,
+  userPageRoot, isCreatablePage, generateEditorPath, isUsersHomePage,
 } = pagePathUtils;
 
 const PageCreateModal = (props) => {
@@ -39,12 +42,25 @@ const PageCreateModal = (props) => {
   const [todayInput2, setTodayInput2] = useState('');
   const [pageNameInput, setPageNameInput] = useState(pageNameInputInitialValue);
   const [template, setTemplate] = useState(null);
+  const [isMatchedWithUserHomePagePath, setIsMatchedWithUserHomePagePath] = useState(false);
 
   // ensure pageNameInput is synced with selectedPagePath || currentPagePath
   useEffect(() => {
     setPageNameInput(isCreatablePage(pathname) ? pathUtils.addTrailingSlash(pathname) : '/');
   }, [pathname]);
 
+  const checkIsUsersHomePageDebounce = useMemo(() => {
+    const checkIsUsersHomePage = () => {
+      setIsMatchedWithUserHomePagePath(isUsersHomePage(pageNameInput));
+    };
+
+    return debounce(1000, checkIsUsersHomePage);
+  }, [pageNameInput]);
+
+  useEffect(() => {
+    checkIsUsersHomePageDebounce(pageNameInput);
+  }, [checkIsUsersHomePageDebounce, pageNameInput]);
+
   function transitBySubmitEvent(e, transitHandler) {
     // prevent page transition by submit
     e.preventDefault();
@@ -119,8 +135,8 @@ const PageCreateModal = (props) => {
     setPageNameInput(value);
   }
 
-  function ppacSubmitHandler() {
-    createInputPage();
+  function ppacSubmitHandler(input) {
+    redirectToEditor(input);
   }
 
   /**
@@ -189,7 +205,6 @@ const PageCreateModal = (props) => {
           <h3 className="grw-modal-head pb-2">{t('Create under')}</h3>
 
           <div className="d-sm-flex align-items-center justify-items-between">
-
             <div className="flex-fill">
               {isReachable
                 ? (
@@ -221,12 +236,16 @@ const PageCreateModal = (props) => {
                 data-testid="btn-create-page-under-below"
                 className="grw-btn-create-page btn btn-outline-primary rounded-pill text-nowrap ml-3"
                 onClick={createInputPage}
+                disabled={isMatchedWithUserHomePagePath}
               >
                 <i className="icon-fw icon-doc"></i>{t('Create')}
               </button>
             </div>
 
           </div>
+          { isMatchedWithUserHomePagePath && (
+            <p className="text-danger mt-2">Error: Cannot create page under /user page directory.</p>
+          ) }
 
         </fieldset>
       </div>

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

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

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

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

+ 11 - 0
packages/app/src/components/PageEditor.jsx

@@ -13,6 +13,8 @@ import { withUnstatedContainers } from './UnstatedUtils';
 import Editor from './PageEditor/Editor';
 import Preview from './PageEditor/Preview';
 import scrollSyncHelper from './PageEditor/ScrollSyncHelper';
+import { ConflictDiffModal } from './PageEditor/ConflictDiffModal';
+
 import EditorContainer from '~/client/services/EditorContainer';
 
 import { getOptionsToSave } from '~/client/util/editor';
@@ -327,6 +329,8 @@ class PageEditor extends React.Component {
     const config = this.props.appContainer.getConfig();
     const noCdn = envUtils.toBoolean(config.env.NO_CDN);
 
+    const { path } = this.props.pageContainer.state;
+
     return (
       <div className="d-flex flex-wrap">
         <div className="page-editor-editor-container flex-grow-1 flex-basis-0 mw-0">
@@ -354,6 +358,13 @@ class PageEditor extends React.Component {
             onScroll={this.onPreviewScroll}
           />
         </div>
+        <ConflictDiffModal
+          isOpen={this.props.pageContainer.state.isConflictDiffModalOpen}
+          onClose={() => this.props.pageContainer.setState({ isConflictDiffModalOpen: false })}
+          appContainer={this.props.appContainer}
+          pageContainer={this.props.pageContainer}
+          markdownOnEdit={this.state.markdown}
+        />
       </div>
     );
   }

+ 1 - 11
packages/app/src/components/PageEditor/Editor.jsx

@@ -10,7 +10,6 @@ import {
 import Dropzone from 'react-dropzone';
 
 import EditorContainer from '~/client/services/EditorContainer';
-import PageContainer from '~/client/services/PageContainer';
 import AppContainer from '~/client/services/AppContainer';
 import { withUnstatedContainers } from '../UnstatedUtils';
 
@@ -20,7 +19,6 @@ import CodeMirrorEditor from './CodeMirrorEditor';
 import TextAreaEditor from './TextAreaEditor';
 
 import pasteHelper from './PasteHelper';
-import { ConflictDiffModal } from './ConflictDiffModal';
 
 class Editor extends AbstractEditor {
 
@@ -373,13 +371,6 @@ class Editor extends AbstractEditor {
           { this.renderCheatsheetModal() }
 
         </div>
-        <ConflictDiffModal
-          isOpen={this.props.pageContainer.state.isConflictDiffModalOpen}
-          onClose={() => this.props.pageContainer.setState({ isConflictDiffModalOpen: false })}
-          appContainer={this.props.appContainer}
-          pageContainer={this.props.pageContainer}
-          markdownOnEdit={this.props.value}
-        />
       </>
     );
   }
@@ -396,8 +387,7 @@ Editor.propTypes = Object.assign({
   onChange: PropTypes.func,
   onUpload: PropTypes.func,
   editorContainer: PropTypes.instanceOf(EditorContainer).isRequired,
-  pageContainer: PropTypes.instanceOf(PageContainer).isRequired,
   appContainer: PropTypes.instanceOf(AppContainer).isRequired,
 }, AbstractEditor.propTypes);
 
-export default withUnstatedContainers(Editor, [EditorContainer, PageContainer, AppContainer]);
+export default withUnstatedContainers(Editor, [EditorContainer, AppContainer]);

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

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