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

Merge branch 'master' into feat/gw7756-infinite-scroll-recent-changes-sidebar

Yuki Takei 4 лет назад
Родитель
Сommit
0c1c62c975
100 измененных файлов с 1649 добавлено и 1019 удалено
  1. 2 2
      .devcontainer/docker-compose.yml
  2. 9 9
      .github/workflows/ci-app.yml
  3. 8 8
      .github/workflows/ci-slackbot-proxy.yml
  4. 1 1
      .github/workflows/codeql-analysis.yml
  5. 2 2
      .github/workflows/draft-release.yml
  6. 2 2
      .github/workflows/list-unhealthy-branches.yml
  7. 1 1
      .github/workflows/pr-to-master.yml
  8. 2 2
      .github/workflows/release-rc.yml
  9. 5 5
      .github/workflows/release-slackbot-proxy.yml
  10. 7 7
      .github/workflows/release.yml
  11. 10 10
      .github/workflows/reusable-app-prod.yml
  12. 3 3
      .github/workflows/reusable-app-reg-suit.yml
  13. 77 1
      CHANGELOG.md
  14. 1 1
      lerna.json
  15. 2 2
      package.json
  16. 2 2
      packages/app/docker/README.md
  17. 10 10
      packages/app/package.json
  18. 13 13
      packages/app/resource/cdn-manifests.js
  19. 13 2
      packages/app/resource/locales/en_US/admin/admin.json
  20. 3 1
      packages/app/resource/locales/en_US/notifications/passwordReset.txt
  21. 3 1
      packages/app/resource/locales/en_US/notifications/userActivation.txt
  22. 1 1
      packages/app/resource/locales/en_US/sandbox.md
  23. 10 2
      packages/app/resource/locales/en_US/translation.json
  24. 13 2
      packages/app/resource/locales/ja_JP/admin/admin.json
  25. 3 1
      packages/app/resource/locales/ja_JP/notifications/passwordReset.txt
  26. 3 1
      packages/app/resource/locales/ja_JP/notifications/userActivation.txt
  27. 1 1
      packages/app/resource/locales/ja_JP/sandbox.md
  28. 10 2
      packages/app/resource/locales/ja_JP/translation.json
  29. 13 2
      packages/app/resource/locales/zh_CN/admin/admin.json
  30. 3 1
      packages/app/resource/locales/zh_CN/notifications/passwordReset.txt
  31. 3 1
      packages/app/resource/locales/zh_CN/notifications/userActivation.txt
  32. 1 1
      packages/app/resource/locales/zh_CN/sandbox.md
  33. 11 3
      packages/app/resource/locales/zh_CN/translation.json
  34. 9 4
      packages/app/src/client/app.jsx
  35. 2 11
      packages/app/src/client/legacy/crowi-presentation.js
  36. 23 4
      packages/app/src/client/services/AdminGeneralSecurityContainer.js
  37. 2 1
      packages/app/src/client/services/ContextExtractor.tsx
  38. 11 0
      packages/app/src/client/services/PersonalContainer.js
  39. 6 6
      packages/app/src/client/util/GrowiRenderer.js
  40. 0 1
      packages/app/src/client/util/interceptor/detach-code-blocks.js
  41. 3 3
      packages/app/src/client/util/reveal/plugins/growi-renderer.js
  42. 93 3
      packages/app/src/components/Admin/App/V5PageMigration.tsx
  43. 3 0
      packages/app/src/components/Admin/CustomHeaderEditor.jsx
  44. 151 84
      packages/app/src/components/Admin/Security/SecuritySetting.jsx
  45. 4 1
      packages/app/src/components/Admin/SlackIntegration/BotTypeCard.jsx
  46. 0 1
      packages/app/src/components/Admin/UserGroup/UserGroupDeleteModal.tsx
  47. 13 13
      packages/app/src/components/Admin/UserGroup/UserGroupDropdown.tsx
  48. 13 15
      packages/app/src/components/Admin/UserGroup/UserGroupForm.tsx
  49. 0 1
      packages/app/src/components/Admin/UserGroup/UserGroupPage.tsx
  50. 25 1
      packages/app/src/components/Admin/UserGroup/UserGroupTable.tsx
  51. 92 0
      packages/app/src/components/Admin/UserGroupDetail/UpdateParentConfirmModal.tsx
  52. 111 58
      packages/app/src/components/Admin/UserGroupDetail/UserGroupDetailPage.tsx
  53. 1 0
      packages/app/src/components/Admin/UserGroupDetail/UserGroupUserModal.jsx
  54. 24 10
      packages/app/src/components/Common/Dropdown/PageItemControl.tsx
  55. 3 1
      packages/app/src/components/Fab.jsx
  56. 3 1
      packages/app/src/components/Hotkeys/Subscribers/CreatePage.jsx
  57. 13 0
      packages/app/src/components/Me/BasicInfoSettings.jsx
  58. 3 3
      packages/app/src/components/MyDraftList/Draft.jsx
  59. 5 2
      packages/app/src/components/Navbar/GlobalSearch.tsx
  60. 27 8
      packages/app/src/components/Navbar/GrowiContextualSubNavigation.tsx
  61. 18 17
      packages/app/src/components/Navbar/PageEditorModeManager.jsx
  62. 1 4
      packages/app/src/components/Navbar/PersonalDropdown.jsx
  63. 8 5
      packages/app/src/components/Page.jsx
  64. 14 6
      packages/app/src/components/Page/RevisionLoader.jsx
  65. 7 4
      packages/app/src/components/Page/RevisionRenderer.jsx
  66. 0 110
      packages/app/src/components/Page/TagsInput.jsx
  67. 86 0
      packages/app/src/components/Page/TagsInput.tsx
  68. 219 0
      packages/app/src/components/PageComment.tsx
  69. 26 15
      packages/app/src/components/PageComment/Comment.jsx
  70. 4 4
      packages/app/src/components/PageComment/CommentEditor.jsx
  71. 0 31
      packages/app/src/components/PageComment/CommentEditorLazyRenderer.jsx
  72. 33 0
      packages/app/src/components/PageComment/CommentEditorLazyRenderer.tsx
  73. 2 0
      packages/app/src/components/PageComment/ReplayComments.jsx
  74. 0 241
      packages/app/src/components/PageComments.jsx
  75. 0 45
      packages/app/src/components/PageContentFooter.jsx
  76. 33 0
      packages/app/src/components/PageContentFooter.tsx
  77. 4 4
      packages/app/src/components/PageCreateModal.jsx
  78. 41 27
      packages/app/src/components/PageEditor.jsx
  79. 1 11
      packages/app/src/components/PageEditor/Editor.jsx
  80. 9 2
      packages/app/src/components/PageEditor/LinkEditModal.jsx
  81. 0 123
      packages/app/src/components/PageEditor/Preview.jsx
  82. 112 0
      packages/app/src/components/PageEditor/Preview.tsx
  83. 3 2
      packages/app/src/components/PageEditor/PreviewWithSuspense.jsx
  84. 48 13
      packages/app/src/components/PageList/PageListItemL.tsx
  85. 8 4
      packages/app/src/components/PagePathHierarchicalLink.jsx
  86. 1 1
      packages/app/src/components/PageRenameModal.tsx
  87. 1 0
      packages/app/src/components/PageTimeline.jsx
  88. 6 4
      packages/app/src/components/SavePageControls/GrantSelector.jsx
  89. 15 2
      packages/app/src/components/SearchPage/SearchResultContent.tsx
  90. 1 1
      packages/app/src/components/SearchPage/SearchResultList.tsx
  91. 1 1
      packages/app/src/components/SearchPage/SortControl.tsx
  92. 1 2
      packages/app/src/components/SearchPage2/SearchPageBase.tsx
  93. 1 0
      packages/app/src/components/Sidebar/CustomSidebar.tsx
  94. 5 1
      packages/app/src/components/Sidebar/PageTree/Item.tsx
  95. 20 0
      packages/app/src/interfaces/comment.ts
  96. 2 2
      packages/app/src/interfaces/search.ts
  97. 5 0
      packages/app/src/interfaces/tag.ts
  98. 1 1
      packages/app/src/interfaces/user.ts
  99. 13 1
      packages/app/src/interfaces/websocket.ts
  100. 11 0
      packages/app/src/migrations/20220311011114-convert-page-delete-config.js

+ 2 - 2
.devcontainer/docker-compose.yml

@@ -65,9 +65,9 @@ services:
       - /usr/share/elasticsearch/data
       - /usr/share/elasticsearch/data
       - ../../growi-docker-compose/elasticsearch/config/elasticsearch.yml:/usr/share/elasticsearch/config/elasticsearch.yml
       - ../../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:
   kibana:
-    image: docker.elastic.co/kibana/kibana:7.17.1
+    image: docker.elastic.co/kibana/kibana:7.16.1
     restart: unless-stopped
     restart: unless-stopped
     environment:
     environment:
       ELASTICSEARCH_HOSTS: 'http://elasticsearch:9200'
       ELASTICSEARCH_HOSTS: 'http://elasticsearch:9200'

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

@@ -23,9 +23,9 @@ jobs:
       PROD_FILES: ${{ steps.archive-prod-files.outputs.file }}
       PROD_FILES: ${{ steps.archive-prod-files.outputs.file }}
 
 
     steps:
     steps:
-    - uses: actions/checkout@v2
+    - uses: actions/checkout@v3
 
 
-    - uses: actions/setup-node@v2
+    - uses: actions/setup-node@v3
       with:
       with:
         node-version: ${{ inputs.node-version }}
         node-version: ${{ inputs.node-version }}
         cache: 'yarn'
         cache: 'yarn'
@@ -33,7 +33,7 @@ jobs:
 
 
     - name: Cache/Restore node_modules
     - name: Cache/Restore node_modules
       id: cache-dependencies
       id: cache-dependencies
-      uses: actions/cache@v2
+      uses: actions/cache@v3
       with:
       with:
         path: |
         path: |
           **/node_modules
           **/node_modules
@@ -103,9 +103,9 @@ jobs:
           discovery.type: single-node
           discovery.type: single-node
 
 
     steps:
     steps:
-    - uses: actions/checkout@v2
+    - uses: actions/checkout@v3
 
 
-    - uses: actions/setup-node@v2
+    - uses: actions/setup-node@v3
       with:
       with:
         node-version: ${{ inputs.node-version }}
         node-version: ${{ inputs.node-version }}
         cache: 'yarn'
         cache: 'yarn'
@@ -119,7 +119,7 @@ jobs:
 
 
     - name: Cache/Restore node_modules (not reused)
     - name: Cache/Restore node_modules (not reused)
       id: cache-dependencies
       id: cache-dependencies
-      uses: actions/cache@v2
+      uses: actions/cache@v3
       with:
       with:
         path: |
         path: |
           **/node_modules
           **/node_modules
@@ -185,7 +185,7 @@ jobs:
       fail-fast: false
       fail-fast: false
       matrix:
       matrix:
         # List string expressions that is comma separated ids of tests in "test/cypress/integration"
         # List string expressions that is comma separated ids of tests in "test/cypress/integration"
-        spec-group: ['1', '2', '3']
+        spec-group: ['1', '2', '3', '4']
 
 
     services:
     services:
       mongodb:
       mongodb:
@@ -200,7 +200,7 @@ jobs:
           discovery.type: single-node
           discovery.type: single-node
 
 
     steps:
     steps:
-    - uses: actions/checkout@v2
+    - uses: actions/checkout@v3
 
 
     - name: Get yarn cache dir
     - name: Get yarn cache dir
       id: yarn-cache-dir
       id: yarn-cache-dir
@@ -208,7 +208,7 @@ jobs:
         echo "::set-output name=value::`yarn cache dir --silent`"
         echo "::set-output name=value::`yarn cache dir --silent`"
 
 
     - name: Cache/Restore dependencies
     - name: Cache/Restore dependencies
-      uses: actions/cache@v2
+      uses: actions/cache@v3
       with:
       with:
         path: |
         path: |
           **/node_modules
           **/node_modules
@@ -250,7 +250,7 @@ jobs:
         cat config/ci/.env.local.for-auto-install >> .env.production.local
         cat config/ci/.env.local.for-auto-install >> .env.production.local
 
 
     - name: Cypress Run
     - name: Cypress Run
-      uses: cypress-io/github-action@v2
+      uses: cypress-io/github-action@v3
       with:
       with:
         working-directory: ./packages/app
         working-directory: ./packages/app
         install: false
         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 }}
       EXPECTED_IMAGES_EXIST: ${{ steps.check-expected-images.outputs.EXPECTED_IMAGES_EXIST }}
 
 
     steps:
     steps:
-    - uses: actions/checkout@v2
+    - uses: actions/checkout@v3
       with:
       with:
         ref: ${{ inputs.checkout-ref }}
         ref: ${{ inputs.checkout-ref }}
         fetch-depth: 0
         fetch-depth: 0
 
 
-    - uses: actions/setup-node@v2
+    - uses: actions/setup-node@v3
       with:
       with:
         node-version: ${{ inputs.node-version }}
         node-version: ${{ inputs.node-version }}
         cache: 'yarn'
         cache: 'yarn'
         cache-dependency-path: '**/yarn.lock'
         cache-dependency-path: '**/yarn.lock'
 
 
     - name: Cache/Restore node_modules
     - name: Cache/Restore node_modules
-      uses: actions/cache@v2
+      uses: actions/cache@v3
       with:
       with:
         path: |
         path: |
           **/node_modules
           **/node_modules

+ 77 - 1
CHANGELOG.md

@@ -1,9 +1,85 @@
 # Changelog
 # 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.*
 *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.16](https://github.com/weseek/growi/compare/v4.5.15...v4.5.16) - 2022-04-06
+
+### 💎 Features
+
+- feat: Support Elasticsearch 7 (#5613) @Yohei-Shiina
+
+### 🐛 Bug Fixes
+
+- fix: Domain whitelist is not respected (fix #5408) (#5488) @yuto-oweseek
+- fix: Add tags to pages restricted by specified groups on View mode (for v4.5.x) (#5487) @yuto-oweseek
+
+## [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
 ## [v4.5.14](https://github.com/weseek/growi/compare/v4.5.13...v4.5.14) - 2022-02-10
 
 
 ### 💎 Features
 ### 💎 Features

+ 1 - 1
lerna.json

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

+ 2 - 2
package.json

@@ -1,6 +1,6 @@
 {
 {
   "name": "growi",
   "name": "growi",
-  "version": "5.0.0-RC.10",
+  "version": "5.0.1-RC.0",
   "description": "Team collaboration software using markdown",
   "description": "Team collaboration software using markdown",
   "tags": [
   "tags": [
     "wiki",
     "wiki",
@@ -59,7 +59,7 @@
     "@typescript-eslint/parser": "^4.28.5",
     "@typescript-eslint/parser": "^4.28.5",
     "cypress": "^9.2.0",
     "cypress": "^9.2.0",
     "eslint": "^7.31.0",
     "eslint": "^7.31.0",
-    "eslint-config-weseek": "^1.1.0",
+    "eslint-config-weseek": "^2.0.0",
     "eslint-import-resolver-typescript": "^2.4.0",
     "eslint-import-resolver-typescript": "^2.4.0",
     "eslint-plugin-import": "^2.23.4",
     "eslint-plugin-import": "^2.23.4",
     "eslint-plugin-jest": "^24.3.2",
     "eslint-plugin-jest": "^24.3.2",

+ 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`, `5.0`, `5`, `latest` (Dockerfile)](https://github.com/weseek/growi/blob/v5.0.0/docker/Dockerfile)
 * [`5.0.0-nocdn`, `5.0-nocdn`, `5-nocdn`, `latest-nocdn` (Dockerfile)](https://github.com/weseek/growi/blob/v5.0.0/docker/Dockerfile)
 * [`5.0.0-nocdn`, `5.0-nocdn`, `5-nocdn`, `latest-nocdn` (Dockerfile)](https://github.com/weseek/growi/blob/v5.0.0/docker/Dockerfile)
-* [`4.5.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`, `4.4` (Dockerfile)](https://github.com/weseek/growi/blob/v4.4.13/docker/Dockerfile)
 * [`4.4.13-nocdn`, `4.4-nocdn` (Dockerfile)](https://github.com/weseek/growi/blob/v4.4.13/docker/Dockerfile)
 * [`4.4.13-nocdn`, `4.4-nocdn` (Dockerfile)](https://github.com/weseek/growi/blob/v4.4.13/docker/Dockerfile)
 
 

+ 10 - 10
packages/app/package.json

@@ -1,6 +1,6 @@
 {
 {
   "name": "@growi/app",
   "name": "@growi/app",
-  "version": "5.0.0-RC.10",
+  "version": "5.0.1-RC.0",
   "license": "MIT",
   "license": "MIT",
   "scripts": {
   "scripts": {
     "//// for production": "",
     "//// for production": "",
@@ -62,11 +62,11 @@
     "@elastic/elasticsearch7": "npm:@elastic/elasticsearch@^7.17.0",
     "@elastic/elasticsearch7": "npm:@elastic/elasticsearch@^7.17.0",
     "@godaddy/terminus": "^4.9.0",
     "@godaddy/terminus": "^4.9.0",
     "@google-cloud/storage": "^5.8.5",
     "@google-cloud/storage": "^5.8.5",
-    "@growi/codemirror-textlint": "^5.0.0-RC.10",
-    "@growi/plugin-attachment-refs": "^5.0.0-RC.10",
-    "@growi/plugin-lsx": "^5.0.0-RC.10",
-    "@growi/plugin-pukiwiki-like-linker": "^5.0.0-RC.10",
-    "@growi/slack": "^5.0.0-RC.10",
+    "@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/express": "^7.0.2",
     "@promster/server": "^7.0.4",
     "@promster/server": "^7.0.4",
     "@slack/events-api": "^3.0.0",
     "@slack/events-api": "^3.0.0",
@@ -127,7 +127,7 @@
     "nocache": "^3.0.1",
     "nocache": "^3.0.1",
     "nodemailer": "^6.6.2",
     "nodemailer": "^6.6.2",
     "nodemailer-ses-transport": "~1.5.0",
     "nodemailer-ses-transport": "~1.5.0",
-    "openid-client": "=2.5.0",
+    "openid-client": "^5.1.2",
     "p-retry": "^4.0.0",
     "p-retry": "^4.0.0",
     "passport": "^0.5.0",
     "passport": "^0.5.0",
     "passport-github": "^1.1.0",
     "passport-github": "^1.1.0",
@@ -167,7 +167,7 @@
   },
   },
   "devDependencies": {
   "devDependencies": {
     "@alienfast/i18next-loader": "^1.1.4",
     "@alienfast/i18next-loader": "^1.1.4",
-    "@growi/ui": "^5.0.0-RC.10",
+    "@growi/ui": "^5.0.1-RC.0",
     "@handsontable/react": "=2.1.0",
     "@handsontable/react": "=2.1.0",
     "@types/compression": "^1.7.0",
     "@types/compression": "^1.7.0",
     "@types/express": "^4.17.11",
     "@types/express": "^4.17.11",
@@ -179,7 +179,7 @@
     "browser-sync": "^2.27.7",
     "browser-sync": "^2.27.7",
     "bunyan-debug": "^2.0.0",
     "bunyan-debug": "^2.0.0",
     "cli": "~1.0.1",
     "cli": "~1.0.1",
-    "codemirror": "^5.63.0",
+    "codemirror": "^5.64.0",
     "colors": "=1.4.0",
     "colors": "=1.4.0",
     "connect-browser-sync": "^2.1.0",
     "connect-browser-sync": "^2.1.0",
     "core-js": "=2.6.9",
     "core-js": "=2.6.9",
@@ -234,7 +234,7 @@
     "react-waypoint": "^10.1.0",
     "react-waypoint": "^10.1.0",
     "reactstrap": "^8.9.0",
     "reactstrap": "^8.9.0",
     "replacestream": "^4.0.3",
     "replacestream": "^4.0.3",
-    "reveal.js": "^3.5.0",
+    "reveal.js": "^4.3.1",
     "sass": "^1.43.4",
     "sass": "^1.43.4",
     "sass-loader": "^10.1.1",
     "sass-loader": "^10.1.1",
     "simple-load-script": "^1.0.2",
     "simple-load-script": "^1.0.2",

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

@@ -55,28 +55,28 @@ module.exports = {
     },
     },
     {
     {
       name: 'codemirror-dialog',
       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: {
       args: {
         integrity: '',
         integrity: '',
       },
       },
     },
     },
     {
     {
       name: 'codemirror-keymap-vim',
       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: {
       args: {
         integrity: '',
         integrity: '',
       },
       },
     },
     },
     {
     {
       name: 'codemirror-keymap-emacs',
       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: {
       args: {
         integrity: '',
         integrity: '',
       },
       },
     },
     },
     {
     {
       name: 'codemirror-keymap-sublime',
       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: {
       args: {
         integrity: '',
         integrity: '',
       },
       },
@@ -170,63 +170,63 @@ module.exports = {
     },
     },
     {
     {
       name: 'codemirror-dialog',
       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: {
       args: {
         integrity: '',
         integrity: '',
       },
       },
     },
     },
     {
     {
       name: 'codemirror-theme-eclipse',
       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: {
       args: {
         integrity: '',
         integrity: '',
       },
       },
     },
     },
     {
     {
       name: 'codemirror-theme-elegant',
       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: {
       args: {
         integrity: '',
         integrity: '',
       },
       },
     },
     },
     {
     {
       name: 'codemirror-theme-neo',
       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: {
       args: {
         integrity: '',
         integrity: '',
       },
       },
     },
     },
     {
     {
       name: 'codemirror-theme-mdn-like',
       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: {
       args: {
         integrity: '',
         integrity: '',
       },
       },
     },
     },
     {
     {
       name: 'codemirror-theme-material',
       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: {
       args: {
         integrity: '',
         integrity: '',
       },
       },
     },
     },
     {
     {
       name: 'codemirror-theme-dracula',
       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: {
       args: {
         integrity: '',
         integrity: '',
       },
       },
     },
     },
     {
     {
       name: 'codemirror-theme-monokai',
       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: {
       args: {
         integrity: '',
         integrity: '',
       },
       },
     },
     },
     {
     {
       name: 'codemirror-theme-twilight',
       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: {
       args: {
         integrity: '',
         integrity: '',
       },
       },

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

@@ -28,7 +28,10 @@
     "modal_migration_warning": "This process may take long. It is highly recommended that administrators tell users not to create, modify, or delete pages during the conversion.",
     "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",
     "start_upgrading": "Start converting to v5 compatibility",
     "successfully_started": "Succeeded to start the conversion",
     "successfully_started": "Succeeded to start the conversion",
-    "already_upgraded": "You have already completed the conversion to v5 compatibility"
+    "already_upgraded": "You have already completed the conversion to v5 compatibility",
+    "header_upgrading_progress": "Upgrade Progress",
+    "migration_succeeded": "Your upgrade has been successfully completed! Exit maintenance mode and GROWI can be used.",
+    "migration_failed": "Upgrade failed. Please refer to the GROWI docs for information on what to do in the event of failure."
   },
   },
   "maintenance_mode": {
   "maintenance_mode": {
     "maintenance_mode": "Maintenance Mode",
     "maintenance_mode": "Maintenance Mode",
@@ -469,6 +472,7 @@
   "user_group_management": {
   "user_group_management": {
     "create_group": "Create new group",
     "create_group": "Create new group",
     "add_child_group": "Add child group",
     "add_child_group": "Add child group",
+    "remove_child_group": "Remove",
     "deny_create_group": "You can't create a new group with the current settings.",
     "deny_create_group": "You can't create a new group with the current settings.",
     "group_name": "Group name",
     "group_name": "Group name",
     "group_example": "e.g. : Group1",
     "group_example": "e.g. : Group1",
@@ -476,6 +480,7 @@
     "select_parent_group": "Select Parent Group",
     "select_parent_group": "Select Parent Group",
     "release_parent_group": "Release parent group",
     "release_parent_group": "Release parent group",
     "add_modal": {
     "add_modal": {
+      "description": "The added user will also be added to all parent groups.",
       "add_user": "Add a user to the created group",
       "add_user": "Add a user to the created group",
       "search_option": "Search option",
       "search_option": "Search option",
       "enable_option": "Enable {{option}}",
       "enable_option": "Enable {{option}}",
@@ -486,7 +491,6 @@
     "group_list": "Group list",
     "group_list": "Group list",
     "child_group_list": "Child group list",
     "child_group_list": "Child group list",
     "back_to_list": "Go back to group list",
     "back_to_list": "Go back to group list",
-    "back_to_ancestors_group": "Go back to ancestors group",
     "basic_info": "Basic info",
     "basic_info": "Basic info",
     "user_list": "User list",
     "user_list": "User list",
     "created_group": "Group was created",
     "created_group": "Group was created",
@@ -502,6 +506,13 @@
       "publish_pages": "Publish all",
       "publish_pages": "Publish all",
       "delete_pages": "Delete all",
       "delete_pages": "Delete all",
       "transfer_pages": "Transfer to another group"
       "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 }}
 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.
 To reset your password, click on the link below.
 
 
 {{ url }}
 {{ url }}
 
 
+This link will expire in 10 minutes at  {{ expiredAt }}.
+
 If you did not request a password reset, you can safely ignore this email.
 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 }}
 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.
 To activate your account, click on the link below.
 
 
 {{ url }}
 {{ url }}
 
 
+This link will expire in 1 hour at  {{ expiredAt }}.
+
 If you did not created the account, you can safely ignore this email.
 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]]
 Example of Bootstrap4 is [[here>./Bootstrap4]]
 ```
 ```
 
 
-[[../Bootstrap4]]  
+[[./Bootstrap4]]  
 Example of Bootstrap4 is[[here>./Bootstrap4]]
 Example of Bootstrap4 is[[here>./Bootstrap4]]
 
 
 # :pencil: Lists
 # :pencil: Lists

+ 10 - 2
packages/app/resource/locales/en_US/translation.json

@@ -169,6 +169,8 @@
   "Link sharing is disabled": "Link sharing is disabled",
   "Link sharing is disabled": "Link sharing is disabled",
   "successfully_saved_the_page": "Successfully saved the page",
   "successfully_saved_the_page": "Successfully saved the page",
   "you_can_not_create_page_with_this_name": "You can not create page with this name",
   "you_can_not_create_page_with_this_name": "You can not create page with this name",
+  "not_allowed_to_see_this_page": "You cannot see this page",
+  "Confirm": "Confirm",
   "personal_dropdown": {
   "personal_dropdown": {
     "home": "Home",
     "home": "Home",
     "settings": "Settings",
     "settings": "Settings",
@@ -652,7 +654,7 @@
   },
   },
   "security_setting": {
   "security_setting": {
     "Guest Users Access": "Guest users access",
     "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": "Register limitation",
     "Register limitation desc": "Restriction of new users' registration",
     "Register limitation desc": "Restriction of new users' registration",
     "The whitelist of registration permission E-mail address": "The whitelist of registration permission E-mail address",
     "The whitelist of registration permission E-mail address": "The whitelist of registration permission E-mail address",
@@ -684,7 +686,8 @@
     "max_age": "Max age (msec)",
     "max_age": "Max age (msec)",
     "max_age_desc": "Specifies the number (in milliseconds) to expire users session.<br>Default: 2592000000 (30days)",
     "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.",
     "max_age_caution": "Restarting the server is required after you modify this value.",
-    "page_delete_rights_caution": "The \"operation including the descendants\" setting is forced to be stronger than the \"operation for only the selected page\" setting.",
+    "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> Admin only > Admin and autor > Anyone",
     "Authentication mechanism settings": "Authentication Mechanism Settings",
     "Authentication mechanism settings": "Authentication Mechanism Settings",
     "setup_is_not_yet_complete": "Setup is not yet complete",
     "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}}",
     "alert_siteUrl_is_not_set": "'Site URL' is NOT set. Set it from the {{link}}",
@@ -1016,5 +1019,10 @@
     "same_page_name_exists": "Same page name exits as「{{pageName}}」",
     "same_page_name_exists": "Same page name exits as「{{pageName}}」",
     "same_page_name_exists_at_path" : "Same page name as {{pageName}} exists at {{path}} ",
     "same_page_name_exists_at_path" : "Same page name as {{pageName}} exists at {{path}} ",
     "select_page_to_see" : "Select a page to see"
     "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"
   }
   }
 }
 }

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

@@ -28,7 +28,10 @@
     "modal_migration_warning": "管理者はユーザーに、v5 互換形式への変換中はページを作成・変更・削除しないように伝えることを強くお勧めします。",
     "modal_migration_warning": "管理者はユーザーに、v5 互換形式への変換中はページを作成・変更・削除しないように伝えることを強くお勧めします。",
     "start_upgrading": "v5 互換形式への変換を開始",
     "start_upgrading": "v5 互換形式への変換を開始",
     "successfully_started": "正常に v5 互換形式への変換が開始されました",
     "successfully_started": "正常に v5 互換形式への変換が開始されました",
-    "already_upgraded": "v5 互換形式への変換は既に完了しています"
+    "already_upgraded": "v5 互換形式への変換は既に完了しています",
+    "header_upgrading_progress": "アップグレード進行度",
+    "migration_succeeded": "アップグレードが正常に完了しました!メンテナンスモードを終了して、GROWI を使用することができます。",
+    "migration_failed": "アップグレードが失敗しました。失敗した場合の対処法は GROWI docs を参照してください。"
   },
   },
   "maintenance_mode": {
   "maintenance_mode": {
     "maintenance_mode": "メンテナンスモード",
     "maintenance_mode": "メンテナンスモード",
@@ -468,6 +471,7 @@
   "user_group_management": {
   "user_group_management": {
     "create_group": "新規グループの作成",
     "create_group": "新規グループの作成",
     "add_child_group": "子グループの追加",
     "add_child_group": "子グループの追加",
+    "remove_child_group": "解除",
     "deny_create_group": "新規グループの作成はできません。",
     "deny_create_group": "新規グループの作成はできません。",
     "group_name": "グループ名",
     "group_name": "グループ名",
     "group_example": "例: Group1",
     "group_example": "例: Group1",
@@ -475,6 +479,7 @@
     "select_parent_group": "親グループを選択",
     "select_parent_group": "親グループを選択",
     "release_parent_group": "親グループの解除",
     "release_parent_group": "親グループの解除",
     "add_modal": {
     "add_modal": {
+      "description": "追加したユーザーは、親グループにも追加されます。",
       "add_user": "グループへのユーザー追加",
       "add_user": "グループへのユーザー追加",
       "search_option": "検索オプション",
       "search_option": "検索オプション",
       "enable_option": "{{option}}を有効にする",
       "enable_option": "{{option}}を有効にする",
@@ -485,7 +490,6 @@
     "group_list": "グループ一覧",
     "group_list": "グループ一覧",
     "child_group_list": "子グループ一覧",
     "child_group_list": "子グループ一覧",
     "back_to_list": "グループ一覧に戻る",
     "back_to_list": "グループ一覧に戻る",
-    "back_to_ancestors_group": "祖先グループに戻る",
     "basic_info": "基本情報",
     "basic_info": "基本情報",
     "user_list": "ユーザー一覧",
     "user_list": "ユーザー一覧",
     "created_group": "グループを作成しました",
     "created_group": "グループを作成しました",
@@ -501,6 +505,13 @@
       "publish_pages": "全て公開する",
       "publish_pages": "全て公開する",
       "delete_pages": "全て削除する",
       "delete_pages": "全て削除する",
       "transfer_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 }}
 こんにちは, {{ email }}
 
 
-あなたのGROWIアカウント {{ appTitle }} から、パスワード再設定のリクエストがありました。
+あなたのGROWI ({{ appTitle }}) アカウントから、パスワード再設定のリクエストがありました。
 パスワードをリセットするには、以下のリンクをクリックしてください。
 パスワードをリセットするには、以下のリンクをクリックしてください。
 
 
 {{ url }}
 {{ url }}
 
 
+このリンクは10分後の {{ expiredAt }} に失効します。
+
 もしこのリクエストに心当たりがない場合は、このメールを無視してください。
 もしこのリクエストに心当たりがない場合は、このメールを無視してください。

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

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

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

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

+ 10 - 2
packages/app/resource/locales/ja_JP/translation.json

@@ -171,6 +171,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": "このページは閲覧できません",
+  "Confirm": "確認",
   "personal_dropdown": {
   "personal_dropdown": {
     "home": "ホーム",
     "home": "ホーム",
     "settings": "設定",
     "settings": "設定",
@@ -212,7 +214,7 @@
     },
     },
     "form_help": {
     "form_help": {
       "email": "この Wiki では以下のメールアドレスのみ登録可能です。",
       "email": "この Wiki では以下のメールアドレスのみ登録可能です。",
-      "password": "パスワードには、6文字以上の半角英数字または記号等を設定してください。",
+      "password": "パスワードには、8文字以上の半角英数字または記号等を設定してください。",
       "user_id": "ユーザーIDは、ユーザーページのURLなどに利用されます。半角英数字と一部の記号のみ利用できます。"
       "user_id": "ユーザーIDは、ユーザーページのURLなどに利用されます。半角英数字と一部の記号のみ利用できます。"
     }
     }
   },
   },
@@ -683,7 +685,8 @@
     "max_age": "有効期間 (ミリ秒)",
     "max_age": "有効期間 (ミリ秒)",
     "max_age_desc": "ユーザーのセッション情報の有効期間をミリ秒で指定できます。<br>デフォルト値: 2592000000 (30日間)",
     "max_age_desc": "ユーザーのセッション情報の有効期間をミリ秒で指定できます。<br>デフォルト値: 2592000000 (30日間)",
     "max_age_caution": "この値を変更した後は、サーバーを再起動する必要があります。",
     "max_age_caution": "この値を変更した後は、サーバーを再起動する必要があります。",
-    "page_delete_rights_caution": "「子孫を含む操作」の設定値は、「単体のみの操作」の設定値よりも強いものに強制されます。",
+    "forced_update_desc": "設定が強制変更されました。前回の設定: ",
+    "page_delete_rights_caution": "「(子孫ページを含む)ゴミ箱に入れる操作 / 完全に削除する」の権限は、「ゴミ箱に入れる操作 / 完全に削除する」よりも強い権限になるように強制されます。 <br><br> 管理者のみ可能 > 管理者とページ作者が可能 > 誰でも可能",
     "Authentication mechanism settings": "認証機構設定",
     "Authentication mechanism settings": "認証機構設定",
     "setup_is_not_yet_complete":"セットアップはまだ完了してません",
     "setup_is_not_yet_complete":"セットアップはまだ完了してません",
     "alert_siteUrl_is_not_set": "'サイトURL' が設定されていません。{{link}} から設定してください。",
     "alert_siteUrl_is_not_set": "'サイトURL' が設定されていません。{{link}} から設定してください。",
@@ -1008,5 +1011,10 @@
     "same_page_name_exists": "ページ名 「{{pageName}}」が重複しています",
     "same_page_name_exists": "ページ名 「{{pageName}}」が重複しています",
     "same_page_name_exists_at_path" : "”{{path}}” において ”{{pageName}}”というページは複数存在しています。",
     "same_page_name_exists_at_path" : "”{{path}}” において ”{{pageName}}”というページは複数存在しています。",
     "select_page_to_see" : "以下から遷移するページを選択してください。"
     "select_page_to_see" : "以下から遷移するページを選択してください。"
+  },
+  "user_group": {
+    "select_group": "グループを選ぶ",
+    "belonging_to_no_group": "所属しているグループが見つかりませんでした。",
+    "manage_user_groups": "グループ管理"
   }
   }
 }
 }

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

@@ -28,7 +28,10 @@
     "modal_migration_warning": "这个过程可能需要很长时间。强烈建议管理员告诉用户在转换期间不要创建、修改或删除页面。",
     "modal_migration_warning": "这个过程可能需要很长时间。强烈建议管理员告诉用户在转换期间不要创建、修改或删除页面。",
     "start_upgrading": "开始转换为v5兼容性",
     "start_upgrading": "开始转换为v5兼容性",
     "successfully_started": "成功开始转换",
     "successfully_started": "成功开始转换",
-    "already_upgraded": "你已经完成了向v5兼容性的转换"
+    "already_upgraded": "你已经完成了向v5兼容性的转换",
+    "header_upgrading_progress": "升级进度",
+    "migration_succeeded": "您的升级已经成功完成! 退出维护模式,可以使用GROWI。",
+    "migration_failed": "升级失败。请参考GROWI的文档,了解在失败情况下该如何处理。"
   },
   },
   "maintenance_mode": {
   "maintenance_mode": {
     "maintenance_mode": "维护模式",
     "maintenance_mode": "维护模式",
@@ -478,6 +481,7 @@
   "user_group_management": {
   "user_group_management": {
     "create_group": "创建新组",
     "create_group": "创建新组",
     "add_child_group": "添加一个子组",
     "add_child_group": "添加一个子组",
+    "remove_child_group": "移除",
     "deny_create_group": "不能用当前设置创建新组。",
     "deny_create_group": "不能用当前设置创建新组。",
     "group_name": "组名",
     "group_name": "组名",
     "group_example": "e.g.:第1组",
     "group_example": "e.g.:第1组",
@@ -485,6 +489,7 @@
     "select_parent_group": "选择父组",
     "select_parent_group": "选择父组",
     "release_parent_group": "Release parent group",
     "release_parent_group": "Release parent group",
     "add_modal": {
     "add_modal": {
+      "description": "添加的用户也将被添加到所有的父组。",
       "add_user": "将用户添加到创建的组",
       "add_user": "将用户添加到创建的组",
       "search_option": "搜索选项",
       "search_option": "搜索选项",
       "enable_option": "启用{{option}",
       "enable_option": "启用{{option}",
@@ -495,7 +500,6 @@
     "group_list": "组列表",
     "group_list": "组列表",
     "child_group_list": "儿童组名单",
     "child_group_list": "儿童组名单",
     "back_to_list": "返回组列表",
     "back_to_list": "返回组列表",
-    "back_to_ancestors_group": "返回到祖先组",
     "basic_info": "基本信息",
     "basic_info": "基本信息",
     "user_list": "用户列表",
     "user_list": "用户列表",
     "created_group": "已创建组",
     "created_group": "已创建组",
@@ -511,6 +515,13 @@
       "publish_pages": "全部发布",
       "publish_pages": "全部发布",
       "delete_pages": "全部删除",
       "delete_pages": "全部删除",
       "transfer_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 }}
 嗨,{{ email }}
 
 
-已收到更改您 GROWI 帐户 {{appTitle}} 密码的请求。
+已收到更改您 GROWI ({{appTitle}}) 帐户 密码的请求。
 要重置密码,请单击下面的链接。
 要重置密码,请单击下面的链接。
 
 
 {{ url }}
 {{ url }}
 
 
+这个链接在10分钟后的{ expiredAt }}失效。
+
 如果您没有要求重置密码,则可以放心地忽略此电子邮件。
 如果您没有要求重置密码,则可以放心地忽略此电子邮件。

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

@@ -2,9 +2,11 @@
 
 
 致{{ email }},
 致{{ email }},
 
 
-已使用 GROWI {{ appTitle }} 创建帐户。
+已使用 GROWI ({{ appTitle }}) 创建帐户。
 单击下面的链接以激活您的帐户。
 单击下面的链接以激活您的帐户。
 
 
 {{ url }}
 {{ 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]]
 Example of Bootstrap4 is[[here>./Bootstrap4]]
 ```
 ```
 
 
-[[../Bootstrap4]]  
+[[./Bootstrap4]]  
 Example of Bootstrap4 is [[here>./Bootstrap4]]
 Example of Bootstrap4 is [[here>./Bootstrap4]]
 
 
 # :pencil: Lists
 # :pencil: Lists

+ 11 - 3
packages/app/resource/locales/zh_CN/translation.json

@@ -177,12 +177,14 @@
   "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": "你不能看到这个页面",
+  "Confirm": "确定",
 	"form_validation": {
 	"form_validation": {
 		"error_message": "有些值不正确",
 		"error_message": "有些值不正确",
 		"required": "%s 是必需的",
 		"required": "%s 是必需的",
 		"invalid_syntax": "%s的语法无效。",
 		"invalid_syntax": "%s的语法无效。",
     "title_required": "标题是必需的。",
     "title_required": "标题是必需的。",
-    "slashed_are_not_yet_supported": "スラッシュを含むタイトルにはまだ対応していません"
+    "slashed_are_not_yet_supported": "目前还不支持包含斜线的标题"
   },
   },
   "not_found_page": {
   "not_found_page": {
     "Create Page": "创建页面",
     "Create Page": "创建页面",
@@ -210,7 +212,7 @@
 		},
 		},
 		"form_help": {
 		"form_help": {
 			"email": "您必须有下面列出的电子邮件地址才能注册此wiki。",
 			"email": "您必须有下面列出的电子邮件地址才能注册此wiki。",
-			"password": "密码长度必须至少为6个字符。",
+			"password": "密码长度必须至少为8个字符。",
 			"user_id": "您创建的网页的URL将包含您的用户ID。您的用户ID可以由字母、数字和一些符号组成。"
 			"user_id": "您创建的网页的URL将包含您的用户ID。您的用户ID可以由字母、数字和一些符号组成。"
 		}
 		}
 	},
 	},
@@ -642,7 +644,8 @@
     "max_age": "有效期间  (msec)",
     "max_age": "有效期间  (msec)",
     "max_age_desc": "指定使用户会话过期的数量(以毫秒为单位)。<br>默认值: 2592000000 (30天)",
     "max_age_desc": "指定使用户会话过期的数量(以毫秒为单位)。<br>默认值: 2592000000 (30天)",
     "max_age_caution": "修改该值后需要重启服务器。",
     "max_age_caution": "修改该值后需要重启服务器。",
-    "page_delete_rights_caution": "\"包括后代的操作\" 的设置被迫强于 \"只对选定的页面进行操作\" 的设置。",
+    "forced_update_desc": "设置已被强行更改。以前的设置: ",
+    "page_delete_rights_caution": "\"删除/全部删除\"权限(包括后代页面)被强制强于\"删除/完全删除\"权限。 <br> <br> 仅管理员 > 管理员|作者 > 何人",
 		"Authentication mechanism settings": "身份验证机制设置",
 		"Authentication mechanism settings": "身份验证机制设置",
 		"setup_is_not_yet_complete": "安装尚未完成",
 		"setup_is_not_yet_complete": "安装尚未完成",
 		"alert_siteUrl_is_not_set": "主页URL未设置,通过 {{link}} 设置",
 		"alert_siteUrl_is_not_set": "主页URL未设置,通过 {{link}} 设置",
@@ -1018,5 +1021,10 @@
     "same_page_name_exists": "页面名称「{{pageName}}」是重复的",
     "same_page_name_exists": "页面名称「{{pageName}}」是重复的",
     "same_page_name_exists_at_path" : "在”{{path}}” 中,有不止一个名为”{{pageName}}”的页面",
     "same_page_name_exists_at_path" : "在”{{path}}” 中,有不止一个名为”{{pageName}}”的页面",
     "select_page_to_see" : "请在下面选择你想去的页面。"
     "select_page_to_see" : "请在下面选择你想去的页面。"
+  },
+  "user_group": {
+    "select_group": "选择组别",
+    "belonging_to_no_group": "无法找到你所属的团体。",
+    "manage_user_groups": "管理用户组"
   }
   }
 }
 }

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

@@ -18,8 +18,8 @@ import TagsList from '../components/TagsList';
 import DisplaySwitcher from '../components/Page/DisplaySwitcher';
 import DisplaySwitcher from '../components/Page/DisplaySwitcher';
 import { defaultEditorOptions, defaultPreviewOptions } from '../components/PageEditor/OptionsSelector';
 import { defaultEditorOptions, defaultPreviewOptions } from '../components/PageEditor/OptionsSelector';
 import Page from '../components/Page';
 import Page from '../components/Page';
-import PageComments from '../components/PageComments';
 import PageContentFooter from '../components/PageContentFooter';
 import PageContentFooter from '../components/PageContentFooter';
+import PageComment from '../components/PageComment';
 import PageTimeline from '../components/PageTimeline';
 import PageTimeline from '../components/PageTimeline';
 import CommentEditorLazyRenderer from '../components/PageComment/CommentEditorLazyRenderer';
 import CommentEditorLazyRenderer from '../components/PageComment/CommentEditorLazyRenderer';
 import ShareLinkAlert from '../components/Page/ShareLinkAlert';
 import ShareLinkAlert from '../components/Page/ShareLinkAlert';
@@ -120,9 +120,14 @@ Object.assign(componentMappings, {
 // additional definitions if data exists
 // additional definitions if data exists
 if (pageContainer.state.pageId != null) {
 if (pageContainer.state.pageId != null) {
   Object.assign(componentMappings, {
   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 />,
     'recent-created-icon': <RecentlyCreatedIcon />,
   });
   });

+ 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({
 Reveal.initialize({
   controls: true,
   controls: true,
@@ -30,8 +22,7 @@ Reveal.initialize({
 });
 });
 
 
 require.ensure([], () => {
 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('reveal.js/plugin/notes/notes');
   require('../util/reveal/plugins/growi-renderer');
   require('../util/reveal/plugins/growi-renderer');
 
 

+ 23 - 4
packages/app/src/client/services/AdminGeneralSecurityContainer.js

@@ -30,6 +30,8 @@ export default class AdminGeneralSecurityContainer extends Container {
       currentPageRecursiveDeletionAuthority: PageRecursiveDeleteConfigValue.Inherit,
       currentPageRecursiveDeletionAuthority: PageRecursiveDeleteConfigValue.Inherit,
       currentPageCompleteDeletionAuthority: PageSingleDeleteCompConfigValue.AdminOnly,
       currentPageCompleteDeletionAuthority: PageSingleDeleteCompConfigValue.AdminOnly,
       currentPageRecursiveCompleteDeletionAuthority: PageRecursiveDeleteCompConfigValue.Inherit,
       currentPageRecursiveCompleteDeletionAuthority: PageRecursiveDeleteCompConfigValue.Inherit,
+      previousPageRecursiveDeletionAuthority: null,
+      previousPageRecursiveCompleteDeletionAuthority: null,
       expandOtherOptionsForDeletion: false,
       expandOtherOptionsForDeletion: false,
       expandOtherOptionsForCompleteDeletion: false,
       expandOtherOptionsForCompleteDeletion: false,
       isShowRestrictedByOwner: false,
       isShowRestrictedByOwner: false,
@@ -55,6 +57,8 @@ export default class AdminGeneralSecurityContainer extends Container {
     this.changePageCompleteDeletionAuthority = this.changePageCompleteDeletionAuthority.bind(this);
     this.changePageCompleteDeletionAuthority = this.changePageCompleteDeletionAuthority.bind(this);
     this.changePageRecursiveDeletionAuthority = this.changePageRecursiveDeletionAuthority.bind(this);
     this.changePageRecursiveDeletionAuthority = this.changePageRecursiveDeletionAuthority.bind(this);
     this.changePageRecursiveCompleteDeletionAuthority = this.changePageRecursiveCompleteDeletionAuthority.bind(this);
     this.changePageRecursiveCompleteDeletionAuthority = this.changePageRecursiveCompleteDeletionAuthority.bind(this);
+    this.changePreviousPageRecursiveDeletionAuthority = this.changePreviousPageRecursiveDeletionAuthority.bind(this);
+    this.changePreviousPageRecursiveCompleteDeletionAuthority = this.changePreviousPageRecursiveCompleteDeletionAuthority.bind(this);
 
 
   }
   }
 
 
@@ -149,18 +153,33 @@ export default class AdminGeneralSecurityContainer extends Container {
     this.setState({ currentPageRecursiveCompleteDeletionAuthority: val });
     this.setState({ currentPageRecursiveCompleteDeletionAuthority: val });
   }
   }
 
 
+  /**
+   * Change previousPageRecursiveDeletionAuthority
+   */
+  changePreviousPageRecursiveDeletionAuthority(val) {
+    this.setState({ previousPageRecursiveDeletionAuthority: val });
+  }
+
+
+  /**
+   * Change previousPageRecursiveCompleteDeletionAuthority
+   */
+  changePreviousPageRecursiveCompleteDeletionAuthority(val) {
+    this.setState({ previousPageRecursiveCompleteDeletionAuthority: val });
+  }
+
   /**
   /**
    * Switch ExpandOtherOptionsForDeletion
    * Switch ExpandOtherOptionsForDeletion
    */
    */
-  switchExpandOtherOptionsForDeletion() {
-    this.setState({ expandOtherOptionsForDeletion:  !this.state.expandOtherOptionsForDeletion });
+  switchExpandOtherOptionsForDeletion(bool) {
+    this.setState({ expandOtherOptionsForDeletion: bool });
   }
   }
 
 
   /**
   /**
    * Switch ExpandOtherOptionsForDeletion
    * Switch ExpandOtherOptionsForDeletion
    */
    */
-  switchExpandOtherOptionsForCompleteDeletion() {
-    this.setState({ expandOtherOptionsForCompleteDeletion:  !this.state.expandOtherOptionsForCompleteDeletion });
+  switchExpandOtherOptionsForCompleteDeletion(bool) {
+    this.setState({ expandOtherOptionsForCompleteDeletion: bool });
   }
   }
 
 
   /**
   /**

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

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

+ 11 - 0
packages/app/src/client/services/PersonalContainer.js

@@ -30,6 +30,7 @@ export default class PersonalContainer extends Container {
       uploadedPictureSrc: this.getUploadedPictureSrc(this.appContainer.currentUser),
       uploadedPictureSrc: this.getUploadedPictureSrc(this.appContainer.currentUser),
       externalAccounts: [],
       externalAccounts: [],
       apiToken: '',
       apiToken: '',
+      slackMemberId: '',
     };
     };
 
 
   }
   }
@@ -55,6 +56,7 @@ export default class PersonalContainer extends Container {
         lang: currentUser.lang,
         lang: currentUser.lang,
         isGravatarEnabled: currentUser.isGravatarEnabled,
         isGravatarEnabled: currentUser.isGravatarEnabled,
         apiToken: currentUser.apiToken,
         apiToken: currentUser.apiToken,
+        slackMemberId: currentUser.slackMemberId,
       });
       });
     }
     }
     catch (err) {
     catch (err) {
@@ -114,6 +116,13 @@ export default class PersonalContainer extends Container {
     this.setState({ email: inputValue });
     this.setState({ email: inputValue });
   }
   }
 
 
+  /**
+   * Change Slack Member ID
+   */
+  changeSlackMemberId(inputValue) {
+    this.setState({ slackMemberId: inputValue });
+  }
+
   /**
   /**
    * Change isEmailPublished
    * Change isEmailPublished
    */
    */
@@ -147,6 +156,7 @@ export default class PersonalContainer extends Container {
         email: this.state.email,
         email: this.state.email,
         isEmailPublished: this.state.isEmailPublished,
         isEmailPublished: this.state.isEmailPublished,
         lang: this.state.lang,
         lang: this.state.lang,
+        slackMemberId: this.state.slackMemberId,
       });
       });
       const { updatedUser } = response.data;
       const { updatedUser } = response.data;
 
 
@@ -155,6 +165,7 @@ export default class PersonalContainer extends Container {
         email: updatedUser.email,
         email: updatedUser.email,
         isEmailPublished: updatedUser.isEmailPublished,
         isEmailPublished: updatedUser.isEmailPublished,
         lang: updatedUser.lang,
         lang: updatedUser.lang,
+        slackMemberId: updatedUser.slackMemberId,
       });
       });
     }
     }
     catch (err) {
     catch (err) {

+ 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;
     let processed = markdown;
     for (let i = 0; i < this.preProcessors.length; i++) {
     for (let i = 0; i < this.preProcessors.length; i++) {
       if (!this.preProcessors[i].process) {
       if (!this.preProcessors[i].process) {
         continue;
         continue;
       }
       }
-      processed = this.preProcessors[i].process(processed);
+      processed = this.preProcessors[i].process(processed, context);
     }
     }
 
 
     return processed;
     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;
     let processed = html;
     for (let i = 0; i < this.postProcessors.length; i++) {
     for (let i = 0; i < this.postProcessors.length; i++) {
       if (!this.postProcessors[i].process) {
       if (!this.postProcessors[i].process) {
         continue;
         continue;
       }
       }
-      processed = this.postProcessors[i].process(processed);
+      processed = this.postProcessors[i].process(processed, context);
     }
     }
 
 
     return processed;
     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 context = Object.assign(args[0]); // clone
     const targetKey = this.getTargetKey(contextName);
     const targetKey = this.getTargetKey(contextName);
-    const currentPagePath = context.currentPagePath; // eslint-disable-line no-unused-vars
 
 
     context.dcbContextMap = {};
     context.dcbContextMap = {};
 
 

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

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

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

@@ -1,19 +1,75 @@
-import React, { FC, useState } from 'react';
+import React, {
+  FC, useCallback, useEffect, useState,
+} from 'react';
 import { useTranslation } from 'react-i18next';
 import { useTranslation } from 'react-i18next';
 import { ConfirmModal } from './ConfirmModal';
 import { ConfirmModal } from './ConfirmModal';
 import AdminAppContainer from '../../../client/services/AdminAppContainer';
 import AdminAppContainer from '../../../client/services/AdminAppContainer';
 import { withUnstatedContainers } from '../../UnstatedUtils';
 import { withUnstatedContainers } from '../../UnstatedUtils';
 import { toastSuccess, toastError } from '../../../client/util/apiNotification';
 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 = {
 type Props = {
   adminAppContainer: typeof AdminAppContainer & { v5PageMigrationHandler: () => Promise<{ isV5Compatible: boolean }> },
   adminAppContainer: typeof AdminAppContainer & { v5PageMigrationHandler: () => Promise<{ isV5Compatible: boolean }> },
 }
 }
 
 
 const V5PageMigration: FC<Props> = (props: Props) => {
 const V5PageMigration: FC<Props> = (props: Props) => {
+  // Modal
   const [isV5PageMigrationModalShown, setIsV5PageMigrationModalShown] = useState(false);
   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 { 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() => {
   const onConfirm = async() => {
     setIsV5PageMigrationModalShown(false);
     setIsV5PageMigrationModalShown(false);
     try {
     try {
@@ -29,6 +85,39 @@ const V5PageMigration: FC<Props> = (props: Props) => {
     }
     }
   };
   };
 
 
+  /*
+   * Use Effect
+   */
+  // Setup Admin Socket
+  useEffect(() => {
+    adminSocket?.once(SocketEventName.PMStarted, (data: PMStartedData) => {
+      setProgressing(true);
+      setTotal(data.total);
+    });
+
+    adminSocket?.on(SocketEventName.PMMigrating, (data: PMMigratingData) => {
+      setProgressing(true);
+      setCurrent(data.count);
+    });
+
+    adminSocket?.on(SocketEventName.PMErrorCount, (data: PMErrorCountData) => {
+      setProgressing(true);
+      setSkip(data.skip);
+    });
+
+    adminSocket?.once(SocketEventName.PMEnded, (data: PMEndedData) => {
+      setProgressing(false);
+      setSucceeded(data.isSucceeded);
+    });
+
+    return () => {
+      adminSocket?.off(SocketEventName.PMStarted);
+      adminSocket?.off(SocketEventName.PMMigrating);
+      adminSocket?.off(SocketEventName.PMErrorCount);
+      adminSocket?.off(SocketEventName.PMEnded);
+    };
+  }, [adminSocket]);
+
   return (
   return (
     <>
     <>
       <ConfirmModal
       <ConfirmModal
@@ -48,9 +137,10 @@ const V5PageMigration: FC<Props> = (props: Props) => {
           {t('admin:v5_page_migration.migration_note')}
           {t('admin:v5_page_migration.migration_note')}
         </span>
         </span>
       </p>
       </p>
+      {renderProgressBar()}
       <div className="row my-3">
       <div className="row my-3">
         <div className="mx-auto">
         <div className="mx-auto">
-          <button type="button" className="btn btn-warning" onClick={() => setIsV5PageMigrationModalShown(true)}>
+          <button type="button" className="btn btn-warning" onClick={() => setIsV5PageMigrationModalShown(true)} disabled={isInProgress != null}>
             {t('admin:v5_page_migration.upgrade_to_v5')}
             {t('admin:v5_page_migration.upgrade_to_v5')}
           </button>
           </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/matchbrackets');
 require('codemirror/addon/edit/closebrackets');
 require('codemirror/addon/edit/closebrackets');
 require('codemirror/mode/htmlmixed/htmlmixed');
 require('codemirror/mode/htmlmixed/htmlmixed');
+require('codemirror/addon/hint/html-hint');
+require('codemirror/addon/edit/closetag');
 require('~/client/util/codemirror/autorefresh.ext');
 require('~/client/util/codemirror/autorefresh.ext');
 
 
 require('jquery-ui/ui/widgets/resizable');
 require('jquery-ui/ui/widgets/resizable');
@@ -22,6 +24,7 @@ export default class CustomHeaderEditor extends React.Component {
         detach
         detach
         options={{
         options={{
           mode: 'htmlmixed',
           mode: 'htmlmixed',
+          autoCloseTags: true,
           lineNumbers: true,
           lineNumbers: true,
           tabSize: 2,
           tabSize: 2,
           indentUnit: 2,
           indentUnit: 2,

+ 151 - 84
packages/app/src/components/Admin/Security/SecuritySetting.jsx

@@ -4,7 +4,7 @@ import PropTypes from 'prop-types';
 import { Collapse } from 'reactstrap';
 import { Collapse } from 'reactstrap';
 import { withTranslation } from 'react-i18next';
 import { withTranslation } from 'react-i18next';
 
 
-import { validateDeleteConfigs } from '~/utils/page-delete-config';
+import { validateDeleteConfigs, prepareDeleteConfigValuesForCalc } from '~/utils/page-delete-config';
 import { withUnstatedContainers } from '../../UnstatedUtils';
 import { withUnstatedContainers } from '../../UnstatedUtils';
 import { toastSuccess, toastError } from '~/client/util/apiNotification';
 import { toastSuccess, toastError } from '~/client/util/apiNotification';
 import { PageDeleteConfigValue } from '~/interfaces/page-delete-config';
 import { PageDeleteConfigValue } from '~/interfaces/page-delete-config';
@@ -39,6 +39,20 @@ const getDeletionTypeForT = (deletionType) => {
   }
   }
 };
 };
 
 
+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.
  * Return true if "deletionType" is DeletionType.RecursiveDeletion or DeletionType.RecursiveCompleteDeletion.
  * @param deletionType Deletion type
  * @param deletionType Deletion type
@@ -62,9 +76,16 @@ class SecuritySetting extends React.Component {
   constructor(props) {
   constructor(props) {
     super(props);
     super(props);
 
 
+    // functions
     this.putSecuritySetting = this.putSecuritySetting.bind(this);
     this.putSecuritySetting = this.putSecuritySetting.bind(this);
     this.getRecursiveDeletionConfigState = this.getRecursiveDeletionConfigState.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);
     this.setDeletionConfigState = this.setDeletionConfigState.bind(this);
+
+    // render
     this.renderPageDeletePermission = this.renderPageDeletePermission.bind(this);
     this.renderPageDeletePermission = this.renderPageDeletePermission.bind(this);
     this.renderPageDeletePermissionDropdown = this.renderPageDeletePermissionDropdown.bind(this);
     this.renderPageDeletePermissionDropdown = this.renderPageDeletePermissionDropdown.bind(this);
   }
   }
@@ -96,25 +117,67 @@ class SecuritySetting extends React.Component {
     ];
     ];
   }
   }
 
 
+  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.
    * Force update deletion config for recursive operation when the deletion config for general operation is updated.
    * @param deletionType Deletion type
    * @param deletionType Deletion type
    */
    */
   setDeletionConfigState(newState, setState, deletionType) {
   setDeletionConfigState(newState, setState, deletionType) {
-    if (isRecursiveDeletion(deletionType)) {
-      setState(newState);
+    setState(newState);
+
+    if (this.previousPageRecursiveAuthorityState(deletionType) !== null) {
+      this.setPagePreviousRecursiveAuthorityState(deletionType, null);
+    }
 
 
+    if (isRecursiveDeletion(deletionType)) {
       return;
       return;
     }
     }
 
 
     const [recursiveState, setRecursiveState] = this.getRecursiveDeletionConfigState(deletionType);
     const [recursiveState, setRecursiveState] = this.getRecursiveDeletionConfigState(deletionType);
-    const shouldForceUpdate = !validateDeleteConfigs(newState, recursiveState);
+
+    const calculableValue = prepareDeleteConfigValuesForCalc(newState, recursiveState);
+    const shouldForceUpdate = !validateDeleteConfigs(calculableValue[0], calculableValue[1]);
     if (shouldForceUpdate) {
     if (shouldForceUpdate) {
-      setState(newState);
       setRecursiveState(newState);
       setRecursiveState(newState);
-    }
-    else {
-      setState(newState);
+      this.setPagePreviousRecursiveAuthorityState(deletionType, recursiveState);
+      this.setExpantOtherDeleteOptionsState(deletionType, true);
     }
     }
 
 
     return;
     return;
@@ -133,10 +196,7 @@ class SecuritySetting extends React.Component {
           aria-expanded="true"
           aria-expanded="true"
         >
         >
           <span className="float-left">
           <span className="float-left">
-            {currentState === PageDeleteConfigValue.Inherit && t('security_setting.inherit')}
-            {(currentState === PageDeleteConfigValue.Anyone || currentState == null) && t('security_setting.anyone')}
-            {currentState === PageDeleteConfigValue.AdminOnly && t('security_setting.admin_only')}
-            {currentState === PageDeleteConfigValue.AdminAndAuthor && t('security_setting.admin_and_author')}
+            {t(getDeleteConfigValueForT(currentState))}
           </span>
           </span>
         </button>
         </button>
         <div className="dropdown-menu" aria-labelledby="dropdownMenuButton">
         <div className="dropdown-menu" aria-labelledby="dropdownMenuButton">
@@ -184,20 +244,9 @@ class SecuritySetting extends React.Component {
   }
   }
 
 
   renderPageDeletePermission(currentState, setState, deletionType, isButtonDisabled) {
   renderPageDeletePermission(currentState, setState, deletionType, isButtonDisabled) {
-    const { t, adminGeneralSecurityContainer } = this.props;
-
-    const expandOtherOptions = isTypeDeletion(deletionType)
-      ? adminGeneralSecurityContainer.state.expandOtherOptionsForDeletion
-      : adminGeneralSecurityContainer.state.expandOtherOptionsForCompleteDeletion;
+    const { t } = this.props;
 
 
-    const setExpantOtherOptions = () => {
-      if (isTypeDeletion(deletionType)) {
-        adminGeneralSecurityContainer.switchExpandOtherOptionsForDeletion();
-        return;
-      }
-      adminGeneralSecurityContainer.switchExpandOtherOptionsForCompleteDeletion();
-      return;
-    };
+    const expantDeleteOptionsState = this.expantDeleteOptionsState(deletionType);
 
 
     return (
     return (
       <div key={`page-delete-permission-dropdown-${deletionType}`} className="row">
       <div key={`page-delete-permission-dropdown-${deletionType}`} className="row">
@@ -223,13 +272,30 @@ class SecuritySetting extends React.Component {
                     type="button"
                     type="button"
                     className="btn btn-link p-0 mb-4"
                     className="btn btn-link p-0 mb-4"
                     aria-expanded="false"
                     aria-expanded="false"
-                    onClick={() => setExpantOtherOptions()}
+                    onClick={() => this.setExpantOtherDeleteOptionsState(deletionType, !expantDeleteOptionsState)}
                   >
                   >
-                    <i className={`fa fa-fw fa-arrow-right ${expandOtherOptions ? 'fa-rotate-90' : ''}`}></i>
+                    <i className={`fa fa-fw fa-arrow-right ${expantDeleteOptionsState ? 'fa-rotate-90' : ''}`}></i>
                     { t('security_setting.other_options') }
                     { t('security_setting.other_options') }
                   </button>
                   </button>
-                  <Collapse isOpen={expandOtherOptions}>
+                  <Collapse isOpen={expantDeleteOptionsState}>
                     <div className="pb-4">
                     <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)}
                       {this.renderPageDeletePermissionDropdown(currentState, setState, deletionType, isButtonDisabled)}
                     </div>
                     </div>
                   </Collapse>
                   </Collapse>
@@ -269,58 +335,60 @@ class SecuritySetting extends React.Component {
         )}
         )}
 
 
         <h4 className="mt-4">{ t('security_setting.page_list_and_search_results') }</h4>
         <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>
+        <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>
         <h4>{t('security_setting.page_access_rights')}</h4>
         <div className="row mb-4">
         <div className="row mb-4">
@@ -353,13 +421,13 @@ class SecuritySetting extends React.Component {
               </div>
               </div>
             </div>
             </div>
             {adminGeneralSecurityContainer.isWikiModeForced && (
             {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 className="icon-exclamation icon-fw">
                 </i><b>FIXED</b><br />
                 </i><b>FIXED</b><br />
                 <b
                 <b
                   dangerouslySetInnerHTML={{
                   dangerouslySetInnerHTML={{
                     __html: t('security_setting.Fixed by env var',
                     __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>
               </p>
@@ -368,8 +436,7 @@ class SecuritySetting extends React.Component {
         </div>
         </div>
 
 
         <h4>{t('security_setting.page_delete_rights')}</h4>
         <h4>{t('security_setting.page_delete_rights')}</h4>
-        <div className="row mb-4"></div>
-        {/* Render PageDeletePermissionDropdown */}
+        {/* Render PageDeletePermission */}
         {
         {
           [
           [
             [currentPageDeletionAuthority, adminGeneralSecurityContainer.changePageDeletionAuthority, DeletionType.Deletion, false],
             [currentPageDeletionAuthority, adminGeneralSecurityContainer.changePageDeletionAuthority, DeletionType.Deletion, false],

+ 4 - 1
packages/app/src/components/Admin/SlackIntegration/BotTypeCard.jsx

@@ -69,7 +69,10 @@ const BotTypeCard = (props) => {
       <div className="card-body p-4">
       <div className="card-body p-4">
         <div className="card-text">
         <div className="card-text">
           <div className="my-2">
           <div className="my-2">
-            <img className="d-block mx-auto mb-4" src={`/images/slack-integration/slackbot-difficulty-level-${botDetails[props.botType].setUp}.svg`}></img>
+            <img
+              className="bot-difficulty-icon d-block mx-auto mb-4"
+              src={`/images/slack-integration/slackbot-difficulty-level-${botDetails[props.botType].setUp}.svg`}
+            />
             <div className="d-flex justify-content-between mb-3">
             <div className="d-flex justify-content-between mb-3">
               <span>{t('admin:slack_integration.selecting_bot_types.multiple_workspaces_integration')}</span>
               <span>{t('admin:slack_integration.selecting_bot_types.multiple_workspaces_integration')}</span>
               <img className="bot-type-disc" src={`/images/slack-integration/${botDetails[props.botType].multiWSIntegration}.png`} alt="" />
               <img className="bot-type-disc" src={`/images/slack-integration/${botDetails[props.botType].multiWSIntegration}.png`} alt="" />

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

@@ -23,7 +23,6 @@ type Props = {
   deleteUserGroup?: IUserGroupHasId,
   deleteUserGroup?: IUserGroupHasId,
   onDelete?: (deleteGroupId: string, actionName: string, transferToUserGroupId: string) => Promise<void> | void,
   onDelete?: (deleteGroupId: string, actionName: string, transferToUserGroupId: string) => Promise<void> | void,
   isShow: boolean,
   isShow: boolean,
-  onShow?: (group: IUserGroupHasId) => Promise<void> | void,
   onHide?: () => Promise<void> | void,
   onHide?: () => Promise<void> | void,
 };
 };
 
 

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

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

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

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

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

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

+ 25 - 1
packages/app/src/components/Admin/UserGroup/UserGroupTable.tsx

@@ -1,6 +1,7 @@
 import React, {
 import React, {
   FC, useState, useCallback, useEffect,
   FC, useState, useCallback, useEffect,
 } from 'react';
 } from 'react';
+
 import { useTranslation } from 'react-i18next';
 import { useTranslation } from 'react-i18next';
 import { TFunctionResult } from 'i18next';
 import { TFunctionResult } from 'i18next';
 import dateFnsFormat from 'date-fns/format';
 import dateFnsFormat from 'date-fns/format';
@@ -16,6 +17,7 @@ type Props = {
   childUserGroups: IUserGroupHasId[],
   childUserGroups: IUserGroupHasId[],
   isAclEnabled: boolean,
   isAclEnabled: boolean,
   onEdit?: (userGroup: IUserGroupHasId) => void | Promise<void>,
   onEdit?: (userGroup: IUserGroupHasId) => void | Promise<void>,
+  onRemove?: (userGroup: IUserGroupHasId) => void | Promise<void>,
   onDelete?: (userGroup: IUserGroupHasId) => void | Promise<void>,
   onDelete?: (userGroup: IUserGroupHasId) => void | Promise<void>,
 };
 };
 
 
@@ -73,7 +75,7 @@ const UserGroupTable: FC<Props> = (props: Props) => {
     });
     });
   };
   };
 
 
-  const onClickEdit = (e) => {
+  const onClickEdit = async(e) => {
     if (props.onEdit == null) {
     if (props.onEdit == null) {
       return;
       return;
     }
     }
@@ -86,6 +88,25 @@ const UserGroupTable: FC<Props> = (props: Props) => {
     props.onEdit(userGroup);
     props.onEdit(userGroup);
   };
   };
 
 
+  const onClickRemove = async(e) => {
+    if (props.onRemove == null) {
+      return;
+    }
+
+    const userGroup = findUserGroup(e);
+    if (userGroup == null) {
+      return;
+    }
+
+    try {
+      await props.onRemove(userGroup);
+      userGroup.parent = null;
+    }
+    catch {
+      //
+    }
+  };
+
   const onClickDelete = (e) => { // no preventDefault
   const onClickDelete = (e) => { // no preventDefault
     if (props.onDelete == null) {
     if (props.onDelete == null) {
       return;
       return;
@@ -179,6 +200,9 @@ const UserGroupTable: FC<Props> = (props: Props) => {
                           <button className="dropdown-item" type="button" role="button" onClick={onClickEdit} data-user-group-id={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')}
                             <i className="icon-fw icon-note"></i> {t('Edit')}
                           </button>
                           </button>
+                          <button className="dropdown-item" type="button" role="button" onClick={onClickRemove} data-user-group-id={group._id}>
+                            <i className="icon-fw fa fa-chain-broken"></i> {t('admin:user_group_management.remove_child_group')}
+                          </button>
                           <button className="dropdown-item" type="button" role="button" onClick={onClickDelete} data-user-group-id={group._id}>
                           <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')}
                             <i className="icon-fw icon-fire text-danger"></i> {t('Delete')}
                           </button>
                           </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;

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

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

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

@@ -134,32 +134,46 @@ const PageItemControlDropdownMenu = React.memo((props: DropdownMenuProps): JSX.E
 
 
         {/* Bookmark */}
         {/* Bookmark */}
         { !forceHideMenuItems?.includes(MenuItemType.BOOKMARK) && isEnableActions && !pageInfo.isEmpty && isIPageInfoForOperation(pageInfo) && (
         { !forceHideMenuItems?.includes(MenuItemType.BOOKMARK) && isEnableActions && !pageInfo.isEmpty && isIPageInfoForOperation(pageInfo) && (
-          <DropdownItem onClick={bookmarkItemClickedHandler}>
-            <i className="fa fa-fw fa-bookmark-o"></i>
+          <DropdownItem
+            onClick={bookmarkItemClickedHandler}
+            className="grw-page-control-dropdown-item"
+          >
+            <i className="fa fa-fw fa-bookmark-o grw-page-control-dropdown-icon"></i>
             { pageInfo.isBookmarked ? t('remove_bookmark') : t('add_bookmark') }
             { pageInfo.isBookmarked ? t('remove_bookmark') : t('add_bookmark') }
           </DropdownItem>
           </DropdownItem>
         ) }
         ) }
 
 
         {/* Duplicate */}
         {/* Duplicate */}
         { !forceHideMenuItems?.includes(MenuItemType.DUPLICATE) && isEnableActions && (
         { !forceHideMenuItems?.includes(MenuItemType.DUPLICATE) && isEnableActions && (
-          <DropdownItem onClick={duplicateItemClickedHandler} data-testid="open-page-duplicate-modal-btn">
-            <i className="icon-fw icon-docs"></i>
+          <DropdownItem
+            onClick={duplicateItemClickedHandler}
+            data-testid="open-page-duplicate-modal-btn"
+            className="grw-page-control-dropdown-item"
+          >
+            <i className="icon-fw icon-docs grw-page-control-dropdown-icon"></i>
             {t('Duplicate')}
             {t('Duplicate')}
           </DropdownItem>
           </DropdownItem>
         ) }
         ) }
 
 
         {/* Move/Rename */}
         {/* Move/Rename */}
         { !forceHideMenuItems?.includes(MenuItemType.RENAME) && isEnableActions && pageInfo.isMovable && (
         { !forceHideMenuItems?.includes(MenuItemType.RENAME) && isEnableActions && pageInfo.isMovable && (
-          <DropdownItem onClick={renameItemClickedHandler}>
-            <i className="icon-fw  icon-action-redo"></i>
+          <DropdownItem
+            onClick={renameItemClickedHandler}
+            data-testid="open-page-move-rename-modal-btn"
+            className="grw-page-control-dropdown-item"
+          >
+            <i className="icon-fw icon-action-redo grw-page-control-dropdown-icon"></i>
             {t(isInstantRename ? 'Rename' : 'Move/Rename')}
             {t(isInstantRename ? 'Rename' : 'Move/Rename')}
           </DropdownItem>
           </DropdownItem>
         ) }
         ) }
 
 
         {/* Revert */}
         {/* Revert */}
         { !forceHideMenuItems?.includes(MenuItemType.REVERT) && isEnableActions && pageInfo.isRevertible && (
         { !forceHideMenuItems?.includes(MenuItemType.REVERT) && isEnableActions && pageInfo.isRevertible && (
-          <DropdownItem onClick={revertItemClickedHandler}>
-            <i className="icon-fw  icon-action-undo"></i>
+          <DropdownItem
+            onClick={revertItemClickedHandler}
+            className="grw-page-control-dropdown-item"
+          >
+            <i className="icon-fw icon-action-undo grw-page-control-dropdown-icon"></i>
             {t('modal_putback.label.Put Back Page')}
             {t('modal_putback.label.Put Back Page')}
           </DropdownItem>
           </DropdownItem>
         ) }
         ) }
@@ -177,12 +191,12 @@ const PageItemControlDropdownMenu = React.memo((props: DropdownMenuProps): JSX.E
           <>
           <>
             { showDeviderBeforeDelete && <DropdownItem divider /> }
             { showDeviderBeforeDelete && <DropdownItem divider /> }
             <DropdownItem
             <DropdownItem
-              className={`pt-2 ${pageInfo.isDeletable ? 'text-danger' : ''}`}
+              className={`pt-2 grw-page-control-dropdown-item ${pageInfo.isDeletable ? 'text-danger' : ''}`}
               disabled={!pageInfo.isDeletable}
               disabled={!pageInfo.isDeletable}
               onClick={deleteItemClickedHandler}
               onClick={deleteItemClickedHandler}
               data-testid="open-page-delete-modal-btn"
               data-testid="open-page-delete-modal-btn"
             >
             >
-              <i className="icon-fw icon-trash"></i>
+              <i className="icon-fw icon-trash grw-page-control-dropdown-icon"></i>
               {t('Delete')}
               {t('Delete')}
             </DropdownItem>
             </DropdownItem>
           </>
           </>

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

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

+ 13 - 0
packages/app/src/components/Me/BasicInfoSettings.jsx

@@ -128,6 +128,19 @@ class BasicInfoSettings extends React.Component {
             }
             }
           </div>
           </div>
         </div>
         </div>
+        <div className="form-group row">
+          <label htmlFor="userForm[slackMemberId]" className="text-left text-md-right col-md-3 col-form-label">{t('Slack Member ID')}</label>
+          <div className="col-md-6">
+            <input
+              className="form-control"
+              type="text"
+              key={personalContainer.state.slackMemberId}
+              name="userForm[slackMemberId]"
+              defaultValue={personalContainer.state.slackMemberId}
+              onChange={(e) => { personalContainer.changeSlackMemberId(e.target.value) }}
+            />
+          </div>
+        </div>
 
 
         <div className="row my-3">
         <div className="row my-3">
           <div className="offset-4 col-5">
           <div className="offset-4 col-5">

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

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

+ 5 - 2
packages/app/src/components/Navbar/GlobalSearch.tsx

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

+ 27 - 8
packages/app/src/components/Navbar/GrowiContextualSubNavigation.tsx

@@ -75,14 +75,20 @@ const AdditionalMenuItems = (props: AdditionalMenuItemsProps): JSX.Element => {
       <DropdownItem
       <DropdownItem
         onClick={() => openPresentationModal(hrefForPresentationModal)}
         onClick={() => openPresentationModal(hrefForPresentationModal)}
         data-testid="open-presentation-modal-btn"
         data-testid="open-presentation-modal-btn"
+        className="grw-page-control-dropdown-item"
       >
       >
-        <i className="icon-fw"><PresentationIcon /></i>
+        <i className="icon-fw grw-page-control-dropdown-icon">
+          <PresentationIcon />
+        </i>
         { t('Presentation Mode') }
         { t('Presentation Mode') }
       </DropdownItem>
       </DropdownItem>
 
 
       {/* Export markdown */}
       {/* Export markdown */}
-      <DropdownItem onClick={() => exportAsMarkdown(pageId, revisionId, 'md')}>
-        <i className="icon-fw icon-cloud-download"></i>
+      <DropdownItem
+        onClick={() => exportAsMarkdown(pageId, revisionId, 'md')}
+        className="grw-page-control-dropdown-item"
+      >
+        <i className="icon-fw icon-cloud-download grw-page-control-dropdown-icon"></i>
         {t('export_bulk.export_page_markdown')}
         {t('export_bulk.export_page_markdown')}
       </DropdownItem>
       </DropdownItem>
 
 
@@ -95,31 +101,44 @@ const AdditionalMenuItems = (props: AdditionalMenuItemsProps): JSX.Element => {
       <DropdownItem
       <DropdownItem
         onClick={() => openAccessoriesModal(PageAccessoriesModalContents.PageHistory)}
         onClick={() => openAccessoriesModal(PageAccessoriesModalContents.PageHistory)}
         disabled={isGuestUser || isSharedUser}
         disabled={isGuestUser || isSharedUser}
+        className="grw-page-control-dropdown-item"
       >
       >
-        <span className="mr-1"><HistoryIcon /></span>
+        <span className="grw-page-control-dropdown-icon">
+          <HistoryIcon />
+        </span>
         {t('History')}
         {t('History')}
       </DropdownItem>
       </DropdownItem>
 
 
       <DropdownItem
       <DropdownItem
         onClick={() => openAccessoriesModal(PageAccessoriesModalContents.Attachment)}
         onClick={() => openAccessoriesModal(PageAccessoriesModalContents.Attachment)}
+        className="grw-page-control-dropdown-item"
       >
       >
-        <span className="mr-1"><AttachmentIcon /></span>
+        <span className="grw-page-control-dropdown-icon">
+          <AttachmentIcon />
+        </span>
         {t('attachment_data')}
         {t('attachment_data')}
       </DropdownItem>
       </DropdownItem>
 
 
       <DropdownItem
       <DropdownItem
         onClick={() => openAccessoriesModal(PageAccessoriesModalContents.ShareLink)}
         onClick={() => openAccessoriesModal(PageAccessoriesModalContents.ShareLink)}
         disabled={isGuestUser || isSharedUser || isLinkSharingDisabled}
         disabled={isGuestUser || isSharedUser || isLinkSharingDisabled}
+        className="grw-page-control-dropdown-item"
       >
       >
-        <span className="mr-1"><ShareLinkIcon /></span>
+        <span className="grw-page-control-dropdown-icon">
+          <ShareLinkIcon />
+        </span>
         {t('share_links.share_link_management')}
         {t('share_links.share_link_management')}
       </DropdownItem>
       </DropdownItem>
 
 
       <DropdownItem divider />
       <DropdownItem divider />
 
 
       {/* Create template */}
       {/* Create template */}
-      <DropdownItem onClick={openPageTemplateModalHandler}>
-        <i className="icon-fw icon-magic-wand"></i> { t('template.option_label.create/edit') }
+      <DropdownItem
+        onClick={openPageTemplateModalHandler}
+        className="grw-page-control-dropdown-item"
+      >
+        <i className="icon-fw icon-magic-wand grw-page-control-dropdown-icon"></i>
+        { t('template.option_label.create/edit') }
       </DropdownItem>
       </DropdownItem>
     </>
     </>
   );
   );

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

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

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

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

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

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

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

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

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

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

+ 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 React from 'react';
 import PropTypes from 'prop-types';
 import PropTypes from 'prop-types';
 
 
-import { withTranslation } from 'react-i18next';
+import { useTranslation } from 'react-i18next';
 import { format } from 'date-fns';
 import { format } from 'date-fns';
 
 
 import { UncontrolledTooltip } from 'reactstrap';
 import { UncontrolledTooltip } from 'reactstrap';
@@ -135,11 +135,11 @@ class Comment extends React.PureComponent {
 
 
     await interceptorManager.process('preRenderComment', context);
     await interceptorManager.process('preRenderComment', context);
     await interceptorManager.process('prePreProcess', 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);
     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);
     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('postPostProcess', context);
     await interceptorManager.process('preRenderCommentHtml', context);
     await interceptorManager.process('preRenderCommentHtml', context);
     this.setState({ html: context.parsedHTML });
     this.setState({ html: context.parsedHTML });
@@ -147,8 +147,9 @@ class Comment extends React.PureComponent {
   }
   }
 
 
   render() {
   render() {
-    const { t } = this.props;
-    const comment = this.props.comment;
+    const {
+      t, comment, isReadOnly, onComment,
+    } = this.props;
     const commentId = comment._id;
     const commentId = comment._id;
     const creator = comment.creator;
     const creator = comment.creator;
     const isMarkdown = comment.isMarkdown;
     const isMarkdown = comment.isMarkdown;
@@ -167,7 +168,7 @@ class Comment extends React.PureComponent {
 
 
     return (
     return (
       <React.Fragment>
       <React.Fragment>
-        {this.state.isReEdit ? (
+        {(this.state.isReEdit && !isReadOnly) ? (
           <CommentEditor
           <CommentEditor
             growiRenderer={this.props.growiRenderer}
             growiRenderer={this.props.growiRenderer}
             currentCommentId={commentId}
             currentCommentId={commentId}
@@ -175,7 +176,10 @@ class Comment extends React.PureComponent {
             replyTo={undefined}
             replyTo={undefined}
             commentCreator={creator?.username}
             commentCreator={creator?.username}
             onCancelButtonClicked={() => this.setState({ isReEdit: false })}
             onCancelButtonClicked={() => this.setState({ isReEdit: false })}
-            onCommentButtonClicked={() => this.setState({ isReEdit: false })}
+            onCommentButtonClicked={() => {
+              this.setState({ isReEdit: false });
+              if (onComment != null) onComment();
+            }}
           />
           />
         ) : (
         ) : (
           <div id={commentId} className={rootClassName}>
           <div id={commentId} className={rootClassName}>
@@ -206,7 +210,7 @@ class Comment extends React.PureComponent {
                   </UncontrolledTooltip>
                   </UncontrolledTooltip>
                 </span>
                 </span>
               </div>
               </div>
-              {this.isCurrentUserEqualsToAuthor() && (
+              {(this.isCurrentUserEqualsToAuthor() && !isReadOnly) && (
                 <CommentControl
                 <CommentControl
                   onClickDeleteBtn={this.deleteBtnClickedHandler}
                   onClickDeleteBtn={this.deleteBtnClickedHandler}
                   onClickEditBtn={() => this.setState({ isReEdit: true })}
                   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 = {
 Comment.propTypes = {
   t: PropTypes.func.isRequired, // i18next
   t: PropTypes.func.isRequired, // i18next
   appContainer: PropTypes.instanceOf(AppContainer).isRequired,
   appContainer: PropTypes.instanceOf(AppContainer).isRequired,
   pageContainer: PropTypes.instanceOf(PageContainer).isRequired,
   pageContainer: PropTypes.instanceOf(PageContainer).isRequired,
 
 
   comment: PropTypes.object.isRequired,
   comment: PropTypes.object.isRequired,
+  isReadOnly: PropTypes.bool.isRequired,
   growiRenderer: PropTypes.object.isRequired,
   growiRenderer: PropTypes.object.isRequired,
   deleteBtnClicked: PropTypes.func.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();
       this.initializeEditor();
 
 
       if (onCommentButtonClicked != null) {
       if (onCommentButtonClicked != null) {
-        onCommentButtonClicked(replyTo || currentCommentId);
+        onCommentButtonClicked();
       }
       }
     }
     }
     catch (err) {
     catch (err) {
@@ -231,16 +231,16 @@ class CommentEditor extends React.Component {
     interceptorManager.process('preRenderCommnetPreview', context)
     interceptorManager.process('preRenderCommnetPreview', context)
       .then(() => { return interceptorManager.process('prePreProcess', context) })
       .then(() => { return interceptorManager.process('prePreProcess', context) })
       .then(() => {
       .then(() => {
-        context.markdown = growiRenderer.preProcess(context.markdown);
+        context.markdown = growiRenderer.preProcess(context.markdown, context);
       })
       })
       .then(() => { return interceptorManager.process('postPreProcess', context) })
       .then(() => { return interceptorManager.process('postPreProcess', context) })
       .then(() => {
       .then(() => {
-        const parsedHTML = growiRenderer.process(context.markdown);
+        const parsedHTML = growiRenderer.process(context.markdown, context);
         context.parsedHTML = parsedHTML;
         context.parsedHTML = parsedHTML;
       })
       })
       .then(() => { return interceptorManager.process('prePostProcess', context) })
       .then(() => { return interceptorManager.process('prePostProcess', context) })
       .then(() => {
       .then(() => {
-        context.parsedHTML = growiRenderer.postProcess(context.parsedHTML);
+        context.parsedHTML = growiRenderer.postProcess(context.parsedHTML, context);
       })
       })
       .then(() => { return interceptorManager.process('postPostProcess', context) })
       .then(() => { return interceptorManager.process('postPostProcess', context) })
       .then(() => { return interceptorManager.process('preRenderCommentPreviewHtml', 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}
           comment={reply}
           deleteBtnClicked={this.props.deleteBtnClicked}
           deleteBtnClicked={this.props.deleteBtnClicked}
           growiRenderer={this.props.growiRenderer}
           growiRenderer={this.props.growiRenderer}
+          isReadOnly={this.props.isReadOnly}
         />
         />
       </div>
       </div>
     );
     );
@@ -108,6 +109,7 @@ ReplayComments.propTypes = {
 
 
   growiRenderer: PropTypes.object.isRequired,
   growiRenderer: PropTypes.object.isRequired,
   deleteBtnClicked: PropTypes.func.isRequired,
   deleteBtnClicked: PropTypes.func.isRequired,
+  isReadOnly: PropTypes.bool.isRequired,
   replyList: PropTypes.array,
   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;

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

@@ -1,4 +1,3 @@
-
 import React, {
 import React, {
   useEffect, useState, useMemo, useCallback,
   useEffect, useState, useMemo, useCallback,
 } from 'react';
 } from 'react';
@@ -35,7 +34,8 @@ const PageCreateModal = (props) => {
   const isReachable = config.isSearchServiceReachable;
   const isReachable = config.isSearchServiceReachable;
   const pathname = path || '';
   const pathname = path || '';
   const userPageRootPath = userPageRoot(appContainer.currentUser);
   const userPageRootPath = userPageRoot(appContainer.currentUser);
-  const pageNameInputInitialValue = isCreatablePage(pathname) ? pathUtils.addTrailingSlash(pathname) : '/';
+  const isCreatable = isCreatablePage(pathname) || isUsersHomePage(pathname);
+  const pageNameInputInitialValue = isCreatable ? pathUtils.addTrailingSlash(pathname) : '/';
   const now = format(new Date(), 'yyyy/MM/dd');
   const now = format(new Date(), 'yyyy/MM/dd');
 
 
   const [todayInput1, setTodayInput1] = useState(t('Memo'));
   const [todayInput1, setTodayInput1] = useState(t('Memo'));
@@ -46,8 +46,8 @@ const PageCreateModal = (props) => {
 
 
   // ensure pageNameInput is synced with selectedPagePath || currentPagePath
   // ensure pageNameInput is synced with selectedPagePath || currentPagePath
   useEffect(() => {
   useEffect(() => {
-    setPageNameInput(isCreatablePage(pathname) ? pathUtils.addTrailingSlash(pathname) : '/');
-  }, [pathname]);
+    setPageNameInput(isCreatable ? pathUtils.addTrailingSlash(pathname) : '/');
+  }, [pathname, isCreatable]);
 
 
   const checkIsUsersHomePageDebounce = useMemo(() => {
   const checkIsUsersHomePageDebounce = useMemo(() => {
     const checkIsUsersHomePage = () => {
     const checkIsUsersHomePage = () => {

+ 41 - 27
packages/app/src/components/PageEditor.jsx

@@ -13,6 +13,8 @@ import { withUnstatedContainers } from './UnstatedUtils';
 import Editor from './PageEditor/Editor';
 import Editor from './PageEditor/Editor';
 import Preview from './PageEditor/Preview';
 import Preview from './PageEditor/Preview';
 import scrollSyncHelper from './PageEditor/ScrollSyncHelper';
 import scrollSyncHelper from './PageEditor/ScrollSyncHelper';
+import { ConflictDiffModal } from './PageEditor/ConflictDiffModal';
+
 import EditorContainer from '~/client/services/EditorContainer';
 import EditorContainer from '~/client/services/EditorContainer';
 
 
 import { getOptionsToSave } from '~/client/util/editor';
 import { getOptionsToSave } from '~/client/util/editor';
@@ -328,35 +330,47 @@ class PageEditor extends React.Component {
     const noCdn = envUtils.toBoolean(config.env.NO_CDN);
     const noCdn = envUtils.toBoolean(config.env.NO_CDN);
     const emojiStrategy = this.props.appContainer.getEmojiStrategy();
     const emojiStrategy = this.props.appContainer.getEmojiStrategy();
 
 
+    const { path } = this.props.pageContainer.state;
+
     return (
     return (
-      <div className="d-flex flex-wrap">
-        <div className="page-editor-editor-container flex-grow-1 flex-basis-0 mw-0">
-          <Editor
-            ref={(c) => { this.editor = c }}
-            value={this.state.markdown}
-            noCdn={noCdn}
-            isMobile={this.props.appContainer.isMobile}
-            isUploadable={this.state.isUploadable}
-            isUploadableFile={this.state.isUploadableFile}
-            emojiStrategy={emojiStrategy}
-            onScroll={this.onEditorScroll}
-            onScrollCursorIntoView={this.onEditorScrollCursorIntoView}
-            onChange={this.onMarkdownChanged}
-            onUpload={this.onUpload}
-            onSave={this.onSaveWithShortcut}
-          />
-        </div>
-        <div className="d-none d-lg-block page-editor-preview-container flex-grow-1 flex-basis-0 mw-0">
-          <Preview
-            markdown={this.state.markdown}
-            // eslint-disable-next-line no-return-assign
-            inputRef={(el) => { return this.previewElement = el }}
-            isMathJaxEnabled={this.state.isMathJaxEnabled}
-            renderMathJaxOnInit={false}
-            onScroll={this.onPreviewScroll}
-          />
+      <>
+        <div className="d-flex flex-wrap">
+          <div className="page-editor-editor-container flex-grow-1 flex-basis-0 mw-0">
+            <Editor
+              ref={(c) => { this.editor = c }}
+              value={this.state.markdown}
+              noCdn={noCdn}
+              isMobile={this.props.appContainer.isMobile}
+              isUploadable={this.state.isUploadable}
+              isUploadableFile={this.state.isUploadableFile}
+              emojiStrategy={emojiStrategy}
+              onScroll={this.onEditorScroll}
+              onScrollCursorIntoView={this.onEditorScrollCursorIntoView}
+              onChange={this.onMarkdownChanged}
+              onUpload={this.onUpload}
+              onSave={this.onSaveWithShortcut}
+            />
+          </div>
+          <div className="d-none d-lg-block page-editor-preview-container flex-grow-1 flex-basis-0 mw-0">
+            <Preview
+              markdown={this.state.markdown}
+              pagePath={path}
+              // eslint-disable-next-line no-return-assign
+              inputRef={(el) => { return this.previewElement = el }}
+              isMathJaxEnabled={this.state.isMathJaxEnabled}
+              renderMathJaxOnInit={false}
+              onScroll={this.onPreviewScroll}
+            />
+          </div>
         </div>
         </div>
-      </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}
+        />
+      </>
     );
     );
   }
   }
 
 

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

@@ -10,7 +10,6 @@ import {
 import Dropzone from 'react-dropzone';
 import Dropzone from 'react-dropzone';
 
 
 import EditorContainer from '~/client/services/EditorContainer';
 import EditorContainer from '~/client/services/EditorContainer';
-import PageContainer from '~/client/services/PageContainer';
 import AppContainer from '~/client/services/AppContainer';
 import AppContainer from '~/client/services/AppContainer';
 import { withUnstatedContainers } from '../UnstatedUtils';
 import { withUnstatedContainers } from '../UnstatedUtils';
 
 
@@ -20,7 +19,6 @@ import CodeMirrorEditor from './CodeMirrorEditor';
 import TextAreaEditor from './TextAreaEditor';
 import TextAreaEditor from './TextAreaEditor';
 
 
 import pasteHelper from './PasteHelper';
 import pasteHelper from './PasteHelper';
-import { ConflictDiffModal } from './ConflictDiffModal';
 
 
 class Editor extends AbstractEditor {
 class Editor extends AbstractEditor {
 
 
@@ -373,13 +371,6 @@ class Editor extends AbstractEditor {
           { this.renderCheatsheetModal() }
           { this.renderCheatsheetModal() }
 
 
         </div>
         </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}
-        />
       </>
       </>
     );
     );
   }
   }
@@ -397,8 +388,7 @@ Editor.propTypes = Object.assign({
   onChange: PropTypes.func,
   onChange: PropTypes.func,
   onUpload: PropTypes.func,
   onUpload: PropTypes.func,
   editorContainer: PropTypes.instanceOf(EditorContainer).isRequired,
   editorContainer: PropTypes.instanceOf(EditorContainer).isRequired,
-  pageContainer: PropTypes.instanceOf(PageContainer).isRequired,
   appContainer: PropTypes.instanceOf(AppContainer).isRequired,
   appContainer: PropTypes.instanceOf(AppContainer).isRequired,
 }, AbstractEditor.propTypes);
 }, AbstractEditor.propTypes);
 
 
-export default withUnstatedContainers(Editor, [EditorContainer, PageContainer, AppContainer]);
+export default withUnstatedContainers(Editor, [EditorContainer, AppContainer]);

+ 9 - 2
packages/app/src/components/PageEditor/LinkEditModal.jsx

@@ -38,6 +38,7 @@ class LinkEditModal extends React.PureComponent {
       labelInputValue: '',
       labelInputValue: '',
       linkerType: Linker.types.markdownLink,
       linkerType: Linker.types.markdownLink,
       markdown: null,
       markdown: null,
+      pagePath: null,
       previewError: '',
       previewError: '',
       permalink: '',
       permalink: '',
       isPreviewOpen: false,
       isPreviewOpen: false,
@@ -153,6 +154,7 @@ class LinkEditModal extends React.PureComponent {
     const { t } = this.props;
     const { t } = this.props;
     const path = this.state.linkInputValue;
     const path = this.state.linkInputValue;
     let markdown = null;
     let markdown = null;
+    let pagePath = null;
     let permalink = '';
     let permalink = '';
     let previewError = '';
     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 { data } = await this.props.appContainer.apiv3Get('/page', { path: pathWithoutFragment, page_id: pageId });
         const { page } = data;
         const { page } = data;
         markdown = page.revision.body;
         markdown = page.revision.body;
+        pagePath = page.path;
         permalink = page.id;
         permalink = page.id;
       }
       }
       catch (err) {
       catch (err) {
@@ -174,7 +177,9 @@ class LinkEditModal extends React.PureComponent {
     else {
     else {
       previewError = t('link_edit.page_not_found_in_preview', { path });
       previewError = t('link_edit.page_not_found_in_preview', { path });
     }
     }
-    this.setState({ markdown, previewError, permalink });
+    this.setState({
+      markdown, pagePath, previewError, permalink,
+    });
   }
   }
 
 
   renderLinkPreview() {
   renderLinkPreview() {
@@ -278,6 +283,8 @@ class LinkEditModal extends React.PureComponent {
 
 
   renderLinkAndLabelForm() {
   renderLinkAndLabelForm() {
     const { t } = this.props;
     const { t } = this.props;
+    const { pagePath } = this.state;
+
     return (
     return (
       <>
       <>
         <h3 className="grw-modal-head">{t('link_edit.set_link_and_label')}</h3>
         <h3 className="grw-modal-head">{t('link_edit.set_link_and_label')}</h3>
@@ -301,7 +308,7 @@ class LinkEditModal extends React.PureComponent {
                 </button>
                 </button>
                 <Popover trigger="focus" placement="right" isOpen={this.state.isPreviewOpen} target="preview-btn" toggle={this.toggleIsPreviewOpen}>
                 <Popover trigger="focus" placement="right" isOpen={this.state.isPreviewOpen} target="preview-btn" toggle={this.toggleIsPreviewOpen}>
                   <PopoverBody>
                   <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>
                   </PopoverBody>
                 </Popover>
                 </Popover>
               </div>
               </div>

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

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

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

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

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

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

+ 48 - 13
packages/app/src/components/PageList/PageListItemL.tsx

@@ -3,6 +3,7 @@ import React, {
   ForwardRefRenderFunction, memo, useCallback, useImperativeHandle, useRef,
   ForwardRefRenderFunction, memo, useCallback, useImperativeHandle, useRef,
 } from 'react';
 } from 'react';
 
 
+import { useTranslation } from 'react-i18next';
 import { CustomInput } from 'reactstrap';
 import { CustomInput } from 'reactstrap';
 
 
 import Clamp from 'react-multiline-clamp';
 import Clamp from 'react-multiline-clamp';
@@ -12,6 +13,7 @@ import urljoin from 'url-join';
 import { UserPicture, PageListMeta } from '@growi/ui';
 import { UserPicture, PageListMeta } from '@growi/ui';
 import { DevidedPagePath } from '@growi/core';
 import { DevidedPagePath } from '@growi/core';
 
 
+import { useSWRxPageInfo } from '../../stores/page';
 
 
 import { ISelectable } from '~/client/interfaces/selectable-all';
 import { ISelectable } from '~/client/interfaces/selectable-all';
 import { bookmark, unbookmark } from '~/client/services/page-operation';
 import { bookmark, unbookmark } from '~/client/services/page-operation';
@@ -20,7 +22,7 @@ import {
   usePageRenameModal, usePageDuplicateModal, usePageDeleteModal, usePutBackPageModal,
   usePageRenameModal, usePageDuplicateModal, usePageDeleteModal, usePutBackPageModal,
 } from '~/stores/modal';
 } from '~/stores/modal';
 import {
 import {
-  IPageInfoAll, IPageInfoForEntity, IPageInfoForListing, IPageWithMeta, isIPageInfoForListing,
+  IPageInfoAll, IPageInfoForEntity, IPageInfoForListing, IPageWithMeta, isIPageInfoForListing, isIPageInfoForEntity,
 } from '~/interfaces/page';
 } from '~/interfaces/page';
 import { IPageSearchMeta, isIPageSearchMeta } from '~/interfaces/search';
 import { IPageSearchMeta, isIPageSearchMeta } from '~/interfaces/search';
 import {
 import {
@@ -54,6 +56,8 @@ const PageListItemLSubstance: ForwardRefRenderFunction<ISelectable, Props> = (pr
     onClickItem, onCheckboxChanged, onPageDuplicated, onPageRenamed, onPageDeleted, onPagePutBacked,
     onClickItem, onCheckboxChanged, onPageDuplicated, onPageRenamed, onPageDeleted, onPagePutBacked,
   } = props;
   } = props;
 
 
+  const { t } = useTranslation();
+
   const inputRef = useRef<HTMLInputElement>(null);
   const inputRef = useRef<HTMLInputElement>(null);
 
 
   // publish ISelectable methods
   // publish ISelectable methods
@@ -78,12 +82,18 @@ const PageListItemLSubstance: ForwardRefRenderFunction<ISelectable, Props> = (pr
   const { open: openDeleteModal } = usePageDeleteModal();
   const { open: openDeleteModal } = usePageDeleteModal();
   const { open: openPutBackPageModal } = usePutBackPageModal();
   const { open: openPutBackPageModal } = usePutBackPageModal();
 
 
+  const shouldFetch = isSelected && (pageData != null || pageMeta != null);
+  const { data: pageInfo } = useSWRxPageInfo(shouldFetch ? pageData?._id : null);
+
   const elasticSearchResult = isIPageSearchMeta(pageMeta) ? pageMeta.elasticSearchResult : null;
   const elasticSearchResult = isIPageSearchMeta(pageMeta) ? pageMeta.elasticSearchResult : null;
   const revisionShortBody = isIPageInfoForListing(pageMeta) ? pageMeta.revisionShortBody : null;
   const revisionShortBody = isIPageInfoForListing(pageMeta) ? pageMeta.revisionShortBody : null;
 
 
-  const dPagePath: DevidedPagePath = new DevidedPagePath(elasticSearchResult?.highlightedPath || pageData.path, true);
+  const dPagePath: DevidedPagePath = new DevidedPagePath(pageData.path, false);
   const linkedPagePathFormer = new LinkedPagePath(dPagePath.former);
   const linkedPagePathFormer = new LinkedPagePath(dPagePath.former);
-  const linkedPagePathLatter = new LinkedPagePath(dPagePath.latter);
+
+  const dPagePathHighlighted: DevidedPagePath = new DevidedPagePath(elasticSearchResult?.highlightedPath || pageData.path, true);
+  const linkedPagePathHighlightedFormer = new LinkedPagePath(dPagePathHighlighted.former);
+  const linkedPagePathHighlightedLatter = new LinkedPagePath(dPagePathHighlighted.latter);
 
 
   const lastUpdateDate = format(new Date(pageData.updatedAt), 'yyyy/MM/dd HH:mm:ss');
   const lastUpdateDate = format(new Date(pageData.updatedAt), 'yyyy/MM/dd HH:mm:ss');
 
 
@@ -135,17 +145,34 @@ const PageListItemLSubstance: ForwardRefRenderFunction<ISelectable, Props> = (pr
   // background color of list item changes when class "active" exists under 'list-group-item'
   // background color of list item changes when class "active" exists under 'list-group-item'
   const styleActive = !isDeviceSmallerThanLg && isSelected ? 'active' : '';
   const styleActive = !isDeviceSmallerThanLg && isSelected ? 'active' : '';
 
 
-  const shouldDangerouslySetInnerHTMLForPaths = elasticSearchResult != null && elasticSearchResult.highlightedPath.length > 0;
+  const shouldDangerouslySetInnerHTMLForPaths = elasticSearchResult != null && elasticSearchResult.highlightedPath != null;
+
+  let likerCount;
+  if (isSelected && isIPageInfoForEntity(pageInfo)) {
+    likerCount = pageInfo.likerIds?.length;
+  }
+  else {
+    likerCount = pageData.liker.length;
+  }
+
+  let bookmarkCount;
+  if (isSelected && isIPageInfoForEntity(pageInfo)) {
+    bookmarkCount = pageInfo.bookmarkCount;
+  }
+  else {
+    bookmarkCount = pageMeta?.bookmarkCount;
+  }
+
+  const canRenderESSnippet = elasticSearchResult != null && elasticSearchResult.snippet != null;
+  const canRenderRevisionSnippet = revisionShortBody != null;
 
 
   return (
   return (
     <li
     <li
       key={pageData._id}
       key={pageData._id}
       className={`list-group-item d-flex align-items-center px-3 px-md-1 ${styleListGroupItem} ${styleActive}`}
       className={`list-group-item d-flex align-items-center px-3 px-md-1 ${styleListGroupItem} ${styleActive}`}
+      onClick={clickHandler}
     >
     >
-      <div
-        className="text-break w-100"
-        onClick={clickHandler}
-      >
+      <div className="text-break w-100">
         <div className="d-flex">
         <div className="d-flex">
           {/* checkbox */}
           {/* checkbox */}
           {onCheckboxChanged != null && (
           {onCheckboxChanged != null && (
@@ -165,7 +192,7 @@ const PageListItemLSubstance: ForwardRefRenderFunction<ISelectable, Props> = (pr
               {/* page path */}
               {/* page path */}
               <PagePathHierarchicalLink
               <PagePathHierarchicalLink
                 linkedPagePath={linkedPagePathFormer}
                 linkedPagePath={linkedPagePathFormer}
-                shouldDangerouslySetInnerHTML={shouldDangerouslySetInnerHTMLForPaths}
+                linkedPagePathByHtml={linkedPagePathHighlightedFormer}
               />
               />
               { showPageUpdatedTime && (
               { showPageUpdatedTime && (
                 <span className="page-list-updated-at text-muted">Last update: {lastUpdateDate}</span>
                 <span className="page-list-updated-at text-muted">Last update: {lastUpdateDate}</span>
@@ -187,11 +214,11 @@ const PageListItemLSubstance: ForwardRefRenderFunction<ISelectable, Props> = (pr
                           className="page-segment"
                           className="page-segment"
                           href={encodeURI(urljoin('/', pageData._id))}
                           href={encodeURI(urljoin('/', pageData._id))}
                           // eslint-disable-next-line react/no-danger
                           // eslint-disable-next-line react/no-danger
-                          dangerouslySetInnerHTML={{ __html: linkedPagePathLatter.pathName }}
+                          dangerouslySetInnerHTML={{ __html: linkedPagePathHighlightedLatter.pathName }}
                         >
                         >
                         </a>
                         </a>
                       )
                       )
-                      : <a className="page-segment" href={encodeURI(urljoin('/', pageData._id))}>{linkedPagePathLatter.pathName}</a>
+                      : <a className="page-segment" href={encodeURI(urljoin('/', pageData._id))}>{linkedPagePathHighlightedLatter.pathName}</a>
                     }
                     }
                   </span>
                   </span>
                 </span>
                 </span>
@@ -199,7 +226,7 @@ const PageListItemLSubstance: ForwardRefRenderFunction<ISelectable, Props> = (pr
 
 
               {/* page meta */}
               {/* page meta */}
               <div className="d-none d-md-flex py-0 px-1 ml-2 text-nowrap">
               <div className="d-none d-md-flex py-0 px-1 ml-2 text-nowrap">
-                <PageListMeta page={pageData} bookmarkCount={pageMeta?.bookmarkCount} shouldSpaceOutIcon />
+                <PageListMeta page={pageData} likerCount={likerCount} bookmarkCount={bookmarkCount} shouldSpaceOutIcon />
               </div>
               </div>
 
 
               {/* doropdown icon includes page control buttons */}
               {/* doropdown icon includes page control buttons */}
@@ -219,13 +246,21 @@ const PageListItemLSubstance: ForwardRefRenderFunction<ISelectable, Props> = (pr
             </div>
             </div>
             <div className="page-list-snippet py-1">
             <div className="page-list-snippet py-1">
               <Clamp lines={2}>
               <Clamp lines={2}>
-                { elasticSearchResult != null && elasticSearchResult?.snippet.length > 0 && (
+                { elasticSearchResult != null && elasticSearchResult.snippet != null && (
                   // eslint-disable-next-line react/no-danger
                   // eslint-disable-next-line react/no-danger
                   <div dangerouslySetInnerHTML={{ __html: elasticSearchResult.snippet }}></div>
                   <div dangerouslySetInnerHTML={{ __html: elasticSearchResult.snippet }}></div>
                 ) }
                 ) }
                 { revisionShortBody != null && (
                 { revisionShortBody != null && (
                   <div>{revisionShortBody}</div>
                   <div>{revisionShortBody}</div>
                 ) }
                 ) }
+                {
+                  !canRenderESSnippet && !canRenderRevisionSnippet && (
+                    <>
+                      <i className="icon-exclamation p-1"></i>
+                      {t('not_allowed_to_see_this_page')}
+                    </>
+                  )
+                }
               </Clamp>
               </Clamp>
             </div>
             </div>
           </div>
           </div>

+ 8 - 4
packages/app/src/components/PagePathHierarchicalLink.jsx

@@ -6,9 +6,10 @@ import urljoin from 'url-join';
 import LinkedPagePath from '../models/linked-page-path';
 import LinkedPagePath from '../models/linked-page-path';
 
 
 
 
+// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
 const PagePathHierarchicalLink = (props) => {
 const PagePathHierarchicalLink = (props) => {
   const {
   const {
-    linkedPagePath, basePath, isInTrash, shouldDangerouslySetInnerHTML,
+    linkedPagePath, linkedPagePathByHtml, basePath, isInTrash,
   } = props;
   } = props;
   // render root element
   // render root element
   if (linkedPagePath.isRoot) {
   if (linkedPagePath.isRoot) {
@@ -41,6 +42,8 @@ const PagePathHierarchicalLink = (props) => {
   const isParentRoot = linkedPagePath.parent?.isRoot;
   const isParentRoot = linkedPagePath.parent?.isRoot;
   const isSeparatorRequired = isParentExists && !isParentRoot;
   const isSeparatorRequired = isParentExists && !isParentRoot;
 
 
+  const shouldDangerouslySetInnerHTML = linkedPagePathByHtml != null;
+
   const href = encodeURI(urljoin(basePath || '/', linkedPagePath.href));
   const href = encodeURI(urljoin(basePath || '/', linkedPagePath.href));
 
 
   // eslint-disable-next-line react/prop-types
   // eslint-disable-next-line react/prop-types
@@ -55,10 +58,10 @@ const PagePathHierarchicalLink = (props) => {
       { isParentExists && (
       { isParentExists && (
         <PagePathHierarchicalLink
         <PagePathHierarchicalLink
           linkedPagePath={linkedPagePath.parent}
           linkedPagePath={linkedPagePath.parent}
+          linkedPagePathByHtml={linkedPagePathByHtml?.parent}
           basePath={basePath}
           basePath={basePath}
           isInTrash={isInTrash || linkedPagePath.isInTrash}
           isInTrash={isInTrash || linkedPagePath.isInTrash}
           isInnerElem
           isInnerElem
-          shouldDangerouslySetInnerHTML={shouldDangerouslySetInnerHTML}
         />
         />
       ) }
       ) }
       { isSeparatorRequired && (
       { isSeparatorRequired && (
@@ -67,7 +70,8 @@ const PagePathHierarchicalLink = (props) => {
 
 
       {
       {
         shouldDangerouslySetInnerHTML
         shouldDangerouslySetInnerHTML
-          ? <a className="page-segment" href={href} dangerouslySetInnerHTML={{ __html: linkedPagePath.pathName }}></a>
+          // eslint-disable-next-line react/no-danger
+          ? <a className="page-segment" href={href} dangerouslySetInnerHTML={{ __html: linkedPagePathByHtml.pathName }}></a>
           : <a className="page-segment" href={href}>{linkedPagePath.pathName}</a>
           : <a className="page-segment" href={href}>{linkedPagePath.pathName}</a>
       }
       }
 
 
@@ -77,9 +81,9 @@ const PagePathHierarchicalLink = (props) => {
 
 
 PagePathHierarchicalLink.propTypes = {
 PagePathHierarchicalLink.propTypes = {
   linkedPagePath: PropTypes.instanceOf(LinkedPagePath).isRequired,
   linkedPagePath: PropTypes.instanceOf(LinkedPagePath).isRequired,
+  linkedPagePathByHtml: PropTypes.instanceOf(LinkedPagePath), // Not required
   basePath: PropTypes.string,
   basePath: PropTypes.string,
   isInTrash: PropTypes.bool,
   isInTrash: PropTypes.bool,
-  shouldDangerouslySetInnerHTML: PropTypes.bool,
 
 
   // !!INTERNAL USE ONLY!!
   // !!INTERNAL USE ONLY!!
   isInnerElem: PropTypes.bool,
   isInnerElem: PropTypes.bool,

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

@@ -203,7 +203,7 @@ const PageRenameModal = (): JSX.Element => {
 
 
 
 
   return (
   return (
-    <Modal size="lg" isOpen={isOpened} toggle={closeRenameModal} autoFocus={false}>
+    <Modal size="lg" isOpen={isOpened} toggle={closeRenameModal} data-testid="page-rename-modal" autoFocus={false}>
       <ModalHeader tag="h4" toggle={closeRenameModal} className="bg-primary text-light">
       <ModalHeader tag="h4" toggle={closeRenameModal} className="bg-primary text-light">
         { t('modal_rename.label.Move/Rename page') }
         { t('modal_rename.label.Move/Rename page') }
       </ModalHeader>
       </ModalHeader>

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

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

+ 6 - 4
packages/app/src/components/SavePageControls/GrantSelector.jsx

@@ -174,12 +174,14 @@ class GrantSelector extends React.Component {
    * @memberof GrantSelector
    * @memberof GrantSelector
    */
    */
   renderSelectGroupModal() {
   renderSelectGroupModal() {
+    const { t } = this.props;
+
     const generateGroupListItems = () => {
     const generateGroupListItems = () => {
       return this.state.userRelatedGroups.map((group) => {
       return this.state.userRelatedGroups.map((group) => {
         return (
         return (
           <button key={group._id} type="button" className="list-group-item list-group-item-action" onClick={() => { this.groupListItemClickHandler(group) }}>
           <button key={group._id} type="button" className="list-group-item list-group-item-action" onClick={() => { this.groupListItemClickHandler(group) }}>
             <h5>{group.name}</h5>
             <h5>{group.name}</h5>
-            <div className="small">(TBD) List group members</div>
+            {/* TODO: Replace <div className="small">(TBD) List group members</div> */}
           </button>
           </button>
         );
         );
       });
       });
@@ -188,9 +190,9 @@ class GrantSelector extends React.Component {
     const content = this.state.userRelatedGroups.length === 0
     const content = this.state.userRelatedGroups.length === 0
       ? (
       ? (
         <div>
         <div>
-          <h4>There is no group to which you belong.</h4>
+          <h4>{t('user_group.belonging_to_no_group')}</h4>
           { this.props.appContainer.isAdmin
           { this.props.appContainer.isAdmin
-            && <p><a href="/admin/user-groups"><i className="icon icon-fw icon-login"></i> Manage Groups</a></p>
+            && <p><a href="/admin/user-groups"><i className="icon icon-fw icon-login"></i>{t('user_group.manage_user_groups')}</a></p>
           }
           }
         </div>
         </div>
       )
       )
@@ -207,7 +209,7 @@ class GrantSelector extends React.Component {
         toggle={this.hideSelectGroupModal}
         toggle={this.hideSelectGroupModal}
       >
       >
         <ModalHeader tag="h4" toggle={this.hideSelectGroupModal} className="bg-purple text-light">
         <ModalHeader tag="h4" toggle={this.hideSelectGroupModal} className="bg-purple text-light">
-          Select a Group
+          {t('user_group.select_group')}
         </ModalHeader>
         </ModalHeader>
         <ModalBody>
         <ModalBody>
           {content}
           {content}

+ 15 - 2
packages/app/src/components/SearchPage/SearchResultContent.tsx

@@ -15,6 +15,9 @@ import { useDescendantsPageListForCurrentPathTermManager } from '~/stores/page';
 import { exportAsMarkdown } from '~/client/services/page-operation';
 import { exportAsMarkdown } from '~/client/services/page-operation';
 import { toastSuccess } from '~/client/util/apiNotification';
 import { toastSuccess } from '~/client/util/apiNotification';
 
 
+import PageContentFooter from '../PageContentFooter';
+import PageComment from '../PageComment';
+
 import RevisionLoader from '../Page/RevisionLoader';
 import RevisionLoader from '../Page/RevisionLoader';
 import AppContainer from '../../client/services/AppContainer';
 import AppContainer from '../../client/services/AppContainer';
 import { smoothScrollIntoView } from '~/client/util/smooth-scroll';
 import { smoothScrollIntoView } from '~/client/util/smooth-scroll';
@@ -39,8 +42,11 @@ const AdditionalMenuItems = (props: AdditionalMenuItemsProps): JSX.Element => {
 
 
   return (
   return (
     // Export markdown
     // Export markdown
-    <DropdownItem onClick={() => exportAsMarkdown(pageId, revisionId, 'md')}>
-      <i className="icon-fw icon-cloud-download"></i>
+    <DropdownItem
+      onClick={() => exportAsMarkdown(pageId, revisionId, 'md')}
+      className="grw-page-control-dropdown-item"
+    >
+      <i className="icon-fw icon-cloud-download grw-page-control-dropdown-icon"></i>
       {t('export_bulk.export_page_markdown')}
       {t('export_bulk.export_page_markdown')}
     </DropdownItem>
     </DropdownItem>
   );
   );
@@ -215,6 +221,13 @@ export const SearchResultContent: FC<Props> = (props: Props) => {
           revisionId={page.revision}
           revisionId={page.revision}
           highlightKeywords={highlightKeywords}
           highlightKeywords={highlightKeywords}
         />
         />
+        <PageComment appContainer={appContainer} pageId={page._id} highlightKeywords={highlightKeywords} isReadOnly hideIfEmpty />
+        <PageContentFooter
+          createdAt={new Date(pageWithMeta.data.createdAt)}
+          updatedAt={new Date(pageWithMeta.data.updatedAt)}
+          creator={pageWithMeta.data.creator}
+          revisionAuthor={pageWithMeta.data.lastUpdateUser}
+        />
       </div>
       </div>
     </div>
     </div>
   );
   );

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

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

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

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

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

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

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

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

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

@@ -101,7 +101,7 @@ type ItemCountProps = {
 const ItemCount: FC<ItemCountProps> = (props:ItemCountProps) => {
 const ItemCount: FC<ItemCountProps> = (props:ItemCountProps) => {
   return (
   return (
     <>
     <>
-      <span className="grw-pagetree-count px-0 badge badge-pill badge-light">
+      <span className="grw-pagetree-count badge badge-pill badge-light">
         {props.descendantCount}
         {props.descendantCount}
       </span>
       </span>
     </>
     </>
@@ -195,6 +195,10 @@ const Item: FC<ItemProps> = (props: ItemProps) => {
 
 
       await mutateChildren();
       await mutateChildren();
 
 
+      if (onRenamed != null) {
+        onRenamed();
+      }
+
       // force open
       // force open
       setIsOpen(true);
       setIsOpen(true);
     }
     }

+ 20 - 0
packages/app/src/interfaces/comment.ts

@@ -0,0 +1,20 @@
+import { Nullable, Ref } from './common';
+import { IPage } from './page';
+import { IUser } from './user';
+import { IRevision } from './revision';
+import { HasObjectId } from './has-object-id';
+
+export type IComment = {
+  comment: string;
+  commentPosition: number,
+  isMarkdown: boolean,
+  replyTo: Nullable<string>,
+  createdAt: Date,
+  updatedAt: Date,
+  page: Ref<IPage>,
+  revision: Ref<IRevision>,
+  creator: IUser,
+};
+
+export type ICommentHasId = IComment & HasObjectId;
+export type ICommentHasIdList = ICommentHasId[];

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

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

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

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

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

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

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

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

+ 11 - 0
packages/app/src/migrations/20220311011114-convert-page-delete-config.js

@@ -16,6 +16,17 @@ module.exports = {
     mongoose.connect(getMongoUri(), mongoOptions);
     mongoose.connect(getMongoUri(), mongoOptions);
     const Config = getModelSafely('Config') || ConfigModel;
     const Config = getModelSafely('Config') || ConfigModel;
 
 
+    const isNewConfigExists = await Config.count({
+      ns: 'crowi',
+      key: 'security:pageDeletionAuthority',
+    }) > 0;
+
+    if (isNewConfigExists) {
+      logger.info('This migration is skipped because new configs are existed.');
+      logger.info('Migration has successfully applied');
+      return;
+    }
+
     const oldConfig = await Config.findOne({
     const oldConfig = await Config.findOne({
       ns: 'crowi',
       ns: 'crowi',
       key: 'security:pageCompleteDeletionAuthority',
       key: 'security:pageCompleteDeletionAuthority',

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