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

Merge branch 'imprv/gw7848-imprv-bookmarks-content-on-user-page' of https://github.com/weseek/growi into imprv/gw7848-imprv-bookmarks-content-on-user-page

jam411 3 лет назад
Родитель
Сommit
add9774f23
100 измененных файлов с 2911 добавлено и 1509 удалено
  1. 0 2
      .github/workflows/ci-app.yml
  2. 0 1
      .github/workflows/ci-slackbot-proxy.yml
  3. 1 2
      .github/workflows/draft-release.yml
  4. 1 1
      .github/workflows/pr-to-master.yml
  5. 53 34
      .github/workflows/release-rc.yml
  6. 1 1
      .github/workflows/release-slackbot-proxy.yml
  7. 64 37
      .github/workflows/release.yml
  8. 56 0
      .github/workflows/reusable-app-build-image.yml
  9. 52 0
      .github/workflows/reusable-app-create-manifests.yml
  10. 1 1
      .github/workflows/reusable-app-prod.yml
  11. 5 0
      .gitignore
  12. 1 3
      .mergify.yml
  13. 78 1
      CHANGELOG.md
  14. 1 0
      bin/data-migrations/v6/README.md
  15. 57 0
      bin/data-migrations/v6/src/migration.js
  16. 65 0
      bin/data-migrations/v6/src/processor.js
  17. 1 1
      lerna.json
  18. 1 1
      package.json
  19. 0 1
      packages/app/.env.development
  20. 1 0
      packages/app/config/logger/config.dev.js
  21. 5 5
      packages/app/docker/Dockerfile
  22. 1 1
      packages/app/docker/README.md
  23. 65 0
      packages/app/docker/codebuild/.terraform.lock.hcl
  24. 32 0
      packages/app/docker/codebuild/buildspec.yml
  25. 25 0
      packages/app/docker/codebuild/codebuild.tf
  26. 23 0
      packages/app/docker/codebuild/main.tf
  27. 26 0
      packages/app/docker/codebuild/oidc.tf
  28. 15 0
      packages/app/docker/codebuild/secretsmanager.tf
  29. 0 6
      packages/app/docker/nocdn/.env.production.local
  30. 16 13
      packages/app/package.json
  31. 12 1
      packages/app/public/static/locales/en_US/admin.json
  32. 10 0
      packages/app/public/static/locales/en_US/commons.json
  33. 1 0
      packages/app/public/static/locales/en_US/translation.json
  34. 11 0
      packages/app/public/static/locales/ja_JP/admin.json
  35. 10 0
      packages/app/public/static/locales/ja_JP/commons.json
  36. 1 0
      packages/app/public/static/locales/ja_JP/translation.json
  37. 12 1
      packages/app/public/static/locales/zh_CN/admin.json
  38. 10 0
      packages/app/public/static/locales/zh_CN/commons.json
  39. 2 0
      packages/app/public/static/locales/zh_CN/translation.json
  40. 15 0
      packages/app/src/client/services/g2g-transfer.ts
  41. 2 2
      packages/app/src/client/services/page-operation.ts
  42. 89 0
      packages/app/src/client/services/side-effects/drawio-modal-launcher-for-view.ts
  43. 33 0
      packages/app/src/client/services/side-effects/hackmd-draft-updated.ts
  44. 89 0
      packages/app/src/client/services/side-effects/handsontable-modal-launcher-for-view.ts
  45. 3 6
      packages/app/src/client/services/side-effects/hash-changed.ts
  46. 39 0
      packages/app/src/client/services/side-effects/page-updated.ts
  47. 1 1
      packages/app/src/client/util/toastr.ts
  48. 31 38
      packages/app/src/components/Admin/App/AwsSetting.tsx
  49. 9 8
      packages/app/src/components/Admin/App/ConfirmModal.tsx
  50. 158 29
      packages/app/src/components/Admin/App/FileUploadSetting.tsx
  51. 37 34
      packages/app/src/components/Admin/App/GcsSetting.tsx
  52. 5 2
      packages/app/src/components/Admin/Common/AdminNavigation.jsx
  53. 3 4
      packages/app/src/components/Admin/Customize/CustomizeThemeSetting.tsx
  54. 0 250
      packages/app/src/components/Admin/ExportArchiveData/SelectCollectionsModal.jsx
  55. 232 0
      packages/app/src/components/Admin/ExportArchiveData/SelectCollectionsModal.tsx
  56. 0 261
      packages/app/src/components/Admin/ExportArchiveDataPage.jsx
  57. 198 0
      packages/app/src/components/Admin/ExportArchiveDataPage.tsx
  58. 284 0
      packages/app/src/components/Admin/G2GDataTransfer.tsx
  59. 237 0
      packages/app/src/components/Admin/G2GDataTransferExportForm.tsx
  60. 43 0
      packages/app/src/components/Admin/G2GDataTransferStatusIcon.tsx
  61. 4 2
      packages/app/src/components/Admin/ImportData/GrowiArchive/ImportCollectionItem.jsx
  62. 6 8
      packages/app/src/components/Admin/MarkdownSetting/WhiteListInput.jsx
  63. 1 4
      packages/app/src/components/Admin/MarkdownSetting/XssForm.jsx
  64. 1 25
      packages/app/src/components/Admin/SlackIntegration/WithProxyAccordions.jsx
  65. 1 2
      packages/app/src/components/Admin/UserGroup/UserGroupDeleteModal.tsx
  66. 1 2
      packages/app/src/components/Admin/UserGroup/UserGroupForm.tsx
  67. 1 2
      packages/app/src/components/Admin/UserGroup/UserGroupModal.tsx
  68. 1 2
      packages/app/src/components/Admin/UserGroup/UserGroupTable.tsx
  69. 1 1
      packages/app/src/components/Admin/UserGroupDetail/UpdateParentConfirmModal.tsx
  70. 1 1
      packages/app/src/components/Admin/UserGroupDetail/UserGroupUserTable.tsx
  71. 11 5
      packages/app/src/components/Comments.tsx
  72. 38 0
      packages/app/src/components/Common/CustomCopyToClipBoard.tsx
  73. 37 0
      packages/app/src/components/Common/LazyRenderer.tsx
  74. 7 22
      packages/app/src/components/CustomNavigation/CustomTabContent.tsx
  75. 42 0
      packages/app/src/components/DataTransferForm.tsx
  76. 13 46
      packages/app/src/components/DescendantsPageList.tsx
  77. 13 9
      packages/app/src/components/DescendantsPageListModal.tsx
  78. 33 1
      packages/app/src/components/Fab.module.scss
  79. 50 34
      packages/app/src/components/Fab.tsx
  80. 2 0
      packages/app/src/components/Layout/AdminLayout.tsx
  81. 1 12
      packages/app/src/components/Layout/BasicLayout.tsx
  82. 1 1
      packages/app/src/components/Layout/MainPane.tsx
  83. 2 1
      packages/app/src/components/Layout/RawLayout.tsx
  84. 1 1
      packages/app/src/components/Layout/ShareLinkLayout.tsx
  85. 1 1
      packages/app/src/components/Me/BasicInfoSettings.tsx
  86. 3 3
      packages/app/src/components/Navbar/AppearanceModeDropdown.tsx
  87. 35 33
      packages/app/src/components/Navbar/GrowiContextualSubNavigation.tsx
  88. 0 3
      packages/app/src/components/Navbar/GrowiNavbar.tsx
  89. 0 9
      packages/app/src/components/Navbar/GrowiSubNavigation.module.scss
  90. 0 141
      packages/app/src/components/Navbar/GrowiSubNavigationSwitcher.jsx
  91. 9 0
      packages/app/src/components/Navbar/GrowiSubNavigationSwitcher.module.scss
  92. 114 0
      packages/app/src/components/Navbar/GrowiSubNavigationSwitcher.tsx
  93. 1 2
      packages/app/src/components/NotAvailable.tsx
  94. 11 4
      packages/app/src/components/NotFoundPage.tsx
  95. 0 249
      packages/app/src/components/Page.tsx
  96. 30 132
      packages/app/src/components/Page/DisplaySwitcher.tsx
  97. 84 0
      packages/app/src/components/Page/PageContents.tsx
  98. 0 0
      packages/app/src/components/Page/PageView.module.scss
  99. 129 0
      packages/app/src/components/Page/PageView.tsx
  100. 1 3
      packages/app/src/components/Page/RevisionLoader.tsx

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

@@ -5,8 +5,6 @@ on:
     branches-ignore:
       - release/**
       - rc/**
-      - chore/**
-      - support/prepare-v**
     paths:
       - .github/workflows/ci-app.yml
       - .eslint*

+ 0 - 1
.github/workflows/ci-slackbot-proxy.yml

@@ -5,7 +5,6 @@ on:
     branches-ignore:
       - release/**
       - rc/**
-      - chore/**
       - support/prepare-v**
     paths:
       - .github/workflows/ci-slackbot-proxy.yml

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

@@ -55,9 +55,8 @@ jobs:
           RELEASE_VERSION=`npx semver -i patch ${{ needs.update-release-draft.outputs.CURRENT_VERSION }}`
           echo "RELEASE_VERSION=$RELEASE_VERSION" >> $GITHUB_OUTPUT
 
-      # See: https://github.com/bakunyo/git-pr-release-action/issues/15, https://github.com/samunohito/SimpleVolumeMixer/commit/2059044c71236509466cf9b1bb2d56d515274938
       - name: Create/Update Pull Request
-        uses: bakunyo/git-pr-release-action@281e1fe424fac01f3992542266805e4202a22fe0
+        uses: bakunyo/git-pr-release-action@master
         env:
           GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
           GIT_PR_RELEASE_BRANCH_PRODUCTION: release/current

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

@@ -36,7 +36,7 @@ jobs:
         !startsWith( github.head_ref, 'dependabot/' ))
 
     steps:
-      - uses: amannn/action-semantic-pull-request@v4.2.0
+      - uses: amannn/action-semantic-pull-request@v5.0.2
         with:
           types: |
             feat

+ 53 - 34
.github/workflows/release-rc.yml

@@ -5,60 +5,79 @@ on:
     branches:
       - rc/**
 
-jobs:
 
-  build-rc:
+concurrency:
+  group: ${{ github.workflow }}-${{ github.ref }}
+  cancel-in-progress: true
+
+
+jobs:
 
+  determine-tags:
     runs-on: ubuntu-latest
 
-    strategy:
-      matrix:
-        platform: [linux/amd64, linux/arm64]
+    outputs:
+      TAGS: ${{ steps.meta.outputs.tags }}
+      TAGS_GHCR: ${{ steps.meta-ghcr.outputs.tags }}
 
     steps:
     - uses: actions/checkout@v3
-      with:
-        lfs: true
 
     - name: Retrieve information from package.json
       uses: myrotvorets/info-from-package-json-action@1.2.0
       id: package-json
 
-    - name: Docker meta
+    - name: Docker meta for docker.io
+      uses: docker/metadata-action@v4
       id: meta
+      with:
+        images: docker.io/weseek/growi
+        sep-tags: ','
+        tags: |
+          type=raw,value=${{ steps.package-json.outputs.packageVersion }}
+          type=raw,value=${{ steps.package-json.outputs.packageVersion }}.{{sha}}
+
+    - name: Docker meta for ghcr.io
       uses: docker/metadata-action@v4
+      id: meta-ghcr
       with:
-        images: weseek/growi,ghcr.io/weseek/growi
+        images: ghcr.io/weseek/growi
+        sep-tags: ','
         tags: |
           type=raw,value=${{ steps.package-json.outputs.packageVersion }}
           type=raw,value=${{ steps.package-json.outputs.packageVersion }}.{{sha}}
 
-    - name: Login to docker.io registry
-      run: |
-        echo ${{ secrets. DOCKER_REGISTRY_PASSWORD }} | docker login --username wsmoogle --password-stdin
 
-    - name: Login to GitHub Container Registry
-      uses: docker/login-action@v2
-      with:
-        registry: ghcr.io
-        username: wsmoogle
-        password: ${{ secrets.DOCKER_REGISTRY_ON_GITHUB_PASSWORD }}
+  build-image-rc:
+    uses: weseek/growi/.github/workflows/reusable-app-build-image.yml@master
+    with:
+      image-name: weseek/growi
+      tag-temporary: latest-rc
+    secrets:
+      AWS_ROLE_TO_ASSUME_FOR_OIDC: ${{ secrets.AWS_ROLE_TO_ASSUME_FOR_OIDC }}
 
-    - name: Set up QEMU
-      if: ${{ matrix.platform == 'linux/arm64' }}
-      uses: docker/setup-qemu-action@v1
 
-    - name: Set up Docker Buildx
-      uses: docker/setup-buildx-action@v2
+  publish-image-rc:
+    needs: [determine-tags, build-image-rc]
+
+    uses: weseek/growi/.github/workflows/reusable-app-create-manifests.yml@master
+    with:
+      tags: ${{ needs.determine-tags.outputs.TAGS }}
+      registry: docker.io
+      image-name: weseek/growi
+      tag-temporary: latest-rc
+    secrets:
+      DOCKER_REGISTRY_PASSWORD: ${{ secrets.DOCKER_REGISTRY_PASSWORD }}
+
+  publish-image-rc-ghcr:
+    needs: [determine-tags, build-image-rc]
+
+    uses: weseek/growi/.github/workflows/reusable-app-create-manifests.yml@master
+    with:
+      tags: ${{ needs.determine-tags.outputs.TAGS_GHCR }}
+      registry: ghcr.io
+      image-name: weseek/growi
+      tag-temporary: latest-rc
+    secrets:
+      DOCKER_REGISTRY_PASSWORD: ${{ secrets.DOCKER_REGISTRY_ON_GITHUB_PASSWORD }}
 
-    - name: Build and push
-      uses: docker/build-push-action@v2
-      with:
-        context: .
-        file: ./packages/app/docker/Dockerfile
-        platforms: ${{ matrix.platform }}
-        push: true
-        builder: ${{ steps.buildx.outputs.name }}
-        cache-from: type=gha
-        cache-to: type=gha,mode=max
-        tags: ${{ steps.meta.outputs.tags }}

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

@@ -58,7 +58,7 @@ jobs:
       uses: docker/setup-buildx-action@v2
 
     - name: Build and push
-      uses: docker/build-push-action@v2
+      uses: docker/build-push-action@v4
       with:
         context: .
         file: ./packages/slackbot-proxy/docker/Dockerfile

+ 64 - 37
.github/workflows/release.yml

@@ -121,63 +121,91 @@ jobs:
         github_token: ${{ secrets.GITHUB_TOKEN }}
 
 
-  build-image:
+  determine-tags:
     needs: create-github-release
-
     runs-on: ubuntu-latest
 
-    strategy:
-      matrix:
-        platform: [linux/amd64, linux/arm64]
+    outputs:
+      TAGS: ${{ steps.meta.outputs.tags }}
+      TAGS_GHCR: ${{ steps.meta-ghcr.outputs.tags }}
 
     steps:
     - uses: actions/checkout@v3
-      with:
-        ref: v${{ needs.create-github-release.outputs.RELEASED_VERSION }}
-        lfs: true
 
-    - name: Docker meta
-      id: meta
+    - name: Retrieve information from package.json
+      uses: myrotvorets/info-from-package-json-action@1.2.0
+      id: package-json
+
+    - name: Docker meta for docker.io
       uses: docker/metadata-action@v4
+      id: meta
       with:
-        images: weseek/growi,ghcr.io/weseek/growi
+        images: docker.io/weseek/growi
+        sep-tags: ','
         tags: |
           type=raw,value=latest
           type=semver,value=${{ needs.create-github-release.outputs.RELEASED_VERSION }},pattern={{major}}
           type=semver,value=${{ needs.create-github-release.outputs.RELEASED_VERSION }},pattern={{major}}.{{minor}}
           type=semver,value=${{ needs.create-github-release.outputs.RELEASED_VERSION }},pattern={{major}}.{{minor}}.{{patch}}
 
-    - name: Login to Docker Hub
-      uses: docker/login-action@v2
+    - name: Docker meta for ghcr.io
+      uses: docker/metadata-action@v4
+      id: meta-ghcr
       with:
-        username: wsmoogle
-        password: ${{ secrets.DOCKER_REGISTRY_PASSWORD }}
+        images: ghcr.io/weseek/growi
+        sep-tags: ','
+        tags: |
+          type=raw,value=latest
+          type=semver,value=${{ needs.create-github-release.outputs.RELEASED_VERSION }},pattern={{major}}
+          type=semver,value=${{ needs.create-github-release.outputs.RELEASED_VERSION }},pattern={{major}}.{{minor}}
+          type=semver,value=${{ needs.create-github-release.outputs.RELEASED_VERSION }},pattern={{major}}.{{minor}}.{{patch}}
 
-    - name: Login to GitHub Container Registry
-      uses: docker/login-action@v2
-      with:
-        registry: ghcr.io
-        username: wsmoogle
-        password: ${{ secrets.DOCKER_REGISTRY_ON_GITHUB_PASSWORD }}
 
-    - name: Set up QEMU
-      if: ${{ matrix.platform == 'linux/arm64' }}
-      uses: docker/setup-qemu-action@v1
+  build-image:
+    needs: create-github-release
 
-    - name: Set up Docker Buildx
-      uses: docker/setup-buildx-action@v2
+    uses: weseek/growi/.github/workflows/reusable-app-build-image.yml@master
+    with:
+      source-version: refs/tags/v${{ needs.create-github-release.outputs.RELEASED_VERSION }}
+      image-name: weseek/growi
+      tag-temporary: latest
+    secrets:
+      AWS_ROLE_TO_ASSUME_FOR_OIDC: ${{ secrets.AWS_ROLE_TO_ASSUME_FOR_OIDC }}
+
+
+  publish-image:
+    needs: [determine-tags, build-image]
+
+    uses: weseek/growi/.github/workflows/reusable-app-create-manifests.yml@master
+    with:
+      tags: ${{ needs.determine-tags.outputs.TAGS }}
+      registry: docker.io
+      image-name: weseek/growi
+      tag-temporary: latest
+    secrets:
+      DOCKER_REGISTRY_PASSWORD: ${{ secrets.DOCKER_REGISTRY_PASSWORD }}
+
+  publish-image-ghcr:
+    needs: [determine-tags, build-image]
+
+    uses: weseek/growi/.github/workflows/reusable-app-create-manifests.yml@master
+    with:
+      tags: ${{ needs.determine-tags.outputs.TAGS_GHCR }}
+      registry: ghcr.io
+      image-name: weseek/growi
+      tag-temporary: latest
+    secrets:
+      DOCKER_REGISTRY_PASSWORD: ${{ secrets.DOCKER_REGISTRY_ON_GITHUB_PASSWORD }}
+
+
+  post-publish:
+    needs: [create-github-release, publish-image, publish-image-ghcr]
+    runs-on: ubuntu-latest
 
-    - name: Build and push
-      uses: docker/build-push-action@v3
+    steps:
+    - uses: actions/checkout@v3
       with:
-        context: .
-        file: ./packages/app/docker/Dockerfile
-        platforms: ${{ matrix.platform }}
-        push: true
-        builder: ${{ steps.buildx.outputs.name }}
-        cache-from: type=gha
-        cache-to: type=gha,mode=max
-        tags: ${{ steps.meta.outputs.tags }}
+        ref: v${{ needs.create-github-release.outputs.RELEASED_VERSION }}
 
     - name: Update Docker Hub Description
       uses: peter-evans/dockerhub-description@v3
@@ -193,7 +221,6 @@ jobs:
         channel: '#release'
         url: ${{ secrets.SLACK_WEBHOOK_URL }}
         created_tag: 'v${{ needs.create-github-release.outputs.RELEASED_VERSION }}'
-        message: '*Release v${{ needs.create-github-release.outputs.RELEASED_VERSION }} (${{ matrix.platform }})* Succeeded'
 
     - name: Check whether workspace is clean
       run: |

+ 56 - 0
.github/workflows/reusable-app-build-image.yml

@@ -0,0 +1,56 @@
+name: Reusable build app container image workflow
+
+on:
+  workflow_call:
+    inputs:
+      source-version:
+        type: string
+        default: ${{ github.sha }}
+      image-name:
+        type: string
+        default: weseek/growi
+      tag-temporary:
+        type: string
+        default: latest
+    secrets:
+      AWS_ROLE_TO_ASSUME_FOR_OIDC:
+        required: true
+
+
+
+jobs:
+
+  build-image:
+    runs-on: ubuntu-latest
+
+    # These permissions are needed to interact with GitHub's OIDC Token endpoint.
+    permissions:
+      id-token: write
+      contents: write
+
+    strategy:
+      matrix:
+        platform: [amd64, arm64]
+
+    steps:
+    - uses: actions/checkout@v3
+
+    - name: Configure AWS Credentials
+      uses: aws-actions/configure-aws-credentials@v1
+      with:
+        aws-region: ap-northeast-1
+        role-to-assume: ${{ secrets.AWS_ROLE_TO_ASSUME_FOR_OIDC }}
+        role-session-name: SessionForReleaseGROWI-RC
+
+    - name: Run CodeBuild
+      uses: dark-mechanicum/aws-codebuild@v1
+      with:
+        projectName: growi-official-image-builder
+      env:
+        CODEBUILD__sourceVersion: ${{ inputs.source-version }}
+        CODEBUILD__imageOverride: ${{ (matrix.platform == 'amd64' && 'aws/codebuild/standard:6.0') || 'aws/codebuild/amazonlinux2-aarch64-standard:2.0' }}
+        CODEBUILD__environmentTypeOverride: ${{ (matrix.platform == 'amd64' && 'LINUX_CONTAINER') || 'ARM_CONTAINER' }}
+        CODEBUILD__environmentVariablesOverride: '[
+          { "name": "IMAGE_TAG", "type": "PLAINTEXT", "value": "docker.io/${{ inputs.image-name }}:${{ inputs.tag-temporary }}-${{ matrix.platform }}" },
+          { "name": "IMAGE_TAG_GHCR", "type": "PLAINTEXT", "value": "ghcr.io/${{ inputs.image-name }}:${{ inputs.tag-temporary }}-${{ matrix.platform }}" }
+        ]'

+ 52 - 0
.github/workflows/reusable-app-create-manifests.yml

@@ -0,0 +1,52 @@
+name: Reusable create app container image manifests workflow
+
+on:
+  workflow_call:
+    inputs:
+      tags:
+        type: string
+        required: true
+      registry:
+        type: string
+        default: 'docker.io'
+      image-name:
+        type: string
+        default: weseek/growi
+      tag-temporary:
+        type: string
+        default: latest
+    secrets:
+      DOCKER_REGISTRY_PASSWORD:
+        required: true
+
+
+
+jobs:
+
+  create-manifest:
+    runs-on: ubuntu-latest
+
+    steps:
+    - name: Docker meta for extra-images
+      id: meta-extra-images
+      uses: docker/metadata-action@v4
+      with:
+        images: ${{ inputs.registry }}/${{ inputs.image-name }}
+        sep-tags: ','
+        tags: |
+          type=raw,value=${{ inputs.tag-temporary }}-amd64
+          type=raw,value=${{ inputs.tag-temporary }}-arm64
+
+    - name: Login to Container Registry
+      uses: docker/login-action@v2
+      with:
+        registry: ${{ inputs.registry }}
+        username: wsmoogle
+        password: ${{ secrets.DOCKER_REGISTRY_PASSWORD }}
+
+    - name: Create and push manifest images
+      uses: Noelware/docker-manifest-action@master
+      with:
+        base-image: ${{ inputs.tags }}
+        extra-images: ${{ steps.meta-extra-images.outputs.tags }}
+        push: true

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

@@ -196,7 +196,7 @@ jobs:
       fail-fast: false
       matrix:
         # List string expressions that is comma separated ids of tests in "test/cypress/integration"
-        spec-group: ['10', '20', '21', '30', '40', '50', '60']
+        spec-group: ['10', '20', '21', '22', '30', '40', '50', '60']
 
     services:
       mongodb:

+ 5 - 0
.gitignore

@@ -29,6 +29,11 @@ yarn-error.log*
 # typescript
 *.tsbuildinfo
 
+# Terraform
+**/.terraform/*
+# *.tfstate
+*.tfstate.*
+
 # IDE, dev #
 .idea
 *.orig

+ 1 - 3
.mergify.yml

@@ -16,9 +16,7 @@ pull_request_rules:
     conditions:
       - author = github-actions[bot]
       - '#approved-reviews-by >= 1'
-      - check-skipped = "test (16.x)"
-      - check-skipped = "test-prod-node14 / launch-prod"
-      - check-skipped = "test-prod-node16 / launch-prod"
+      - label = "prepare next version"
     actions:
       merge:
         method: merge

+ 78 - 1
CHANGELOG.md

@@ -1,9 +1,86 @@
 # Changelog
 
-## [Unreleased](https://github.com/weseek/growi/compare/v6.0.1...HEAD)
+## [Unreleased](https://github.com/weseek/growi/compare/v6.0.5...HEAD)
 
 *Please do not manually update this file. We've automated the process.*
 
+## [v6.0.5](https://github.com/weseek/growi/compare/v6.0.4...v6.0.5) - 2023-01-30
+
+### 🚀 Improvement
+
+- imprv: Override process for CommonSanitizeOptions (#7305) @miya
+
+### 🐛 Bug Fixes
+
+- fix:  Request to "/_api/v3/personal-settings"  occurs when in guest mode (#7307) @miya
+- fix: Undeleteable trash pages when clicked empty trash button bug (#7250) @jam411
+- fix: Guest users are able to move to pages that require authentication (#7300) @miya
+- fix: Modal does not close after clicking on path in DescendantsPageListModal (#7291) @miya
+- fix: GrowiContextualSubNavigation style is broken (#7304) @jam411
+- fix: Markdown in the editor reverted when save with shortcut (#7301) @yukendev
+
+## [v6.0.4](https://github.com/weseek/growi/compare/v6.0.3...v6.0.4) - 2023-01-25
+
+### 🐛 Bug Fixes
+
+- fix: Invalid URL in markdown breaks browser (#7292) @yuki-takei
+- fix: Previous editing markdown remains after changing page (#7285) @yukendev
+
+### 🧰 Maintenance
+
+- ci(deps): bump ua-parser-js from 0.7.31 to 0.7.33 (#7293) @dependabot
+
+## [v6.0.3](https://github.com/weseek/growi/compare/v6.0.2...v6.0.3) - 2023-01-24
+
+### 💎 Features
+
+- feat: GROWI to GROWI transfer (#6727) @hakumizuki
+- feat: Use configured xss custom whitelist (#7252) @miya
+- imprv: UI admin g2g transfer advanced options (#7261) @hakumizuki
+
+### 🚀 Improvement
+
+- imprv: Do not retrieve page data using API in shared page (#7240) @miya
+- imprv: Use CSS variables (#7093) @yuki-takei
+- imprv: Do not request /pages.getPageTag when on a shared page (#7214) @miya
+
+### 🐛 Bug Fixes
+
+- fix: Share link management button is not available (#7286) @yuki-takei
+- fix: Color for blinked section (#7287) @ayaka0417
+- fix: Error toaster appears after renaming (#7276) @miya
+- fix: Search Tag From Tag Sidebar Correctly (#7282) @yukendev
+- fix: Ignore backslash in page path (#7284) @yuki-takei
+- fix: Bug in Page Tree Selected Item Background Color (#7272) @yukendev
+- fix: Type guard comment.createdAt (#7281) @hakumizuki
+- fix: Color of login form (#7275) @ayaka0417
+- fix: Body of shared page is not displayed (#7270) @miya
+- fix: Refactor uri decoding in getServerSideProps (#7268) @yukendev
+- fix: Cannot login with LDAP unless local strategy is enabled (#7259) @miya
+- fix: Skeleton color (#7264) @ayaka0417
+- fix: Refactor axios date serializer config (#7249) @yukendev
+- fix: DeletePageModal shows an incorrect label when open (#7224) @yukendev
+- fix: Page path is not displayed in browser tab on shared page (#7243) @miya
+- fix: Lsx encode prefix twice (#7239) @yuki-takei
+- fix: Initial value of the page grant respects the parent page's one (#7232) @yukendev
+
+### 🧰 Maintenance
+
+- support: Build container images with AWS CodeBuild (#7258) @yuki-takei
+
+## [v6.0.2](https://github.com/weseek/growi/compare/v6.0.1...v6.0.2) - 2023-01-10
+
+### 🐛 Bug Fixes
+
+- fix: Attaching page title as initial header section (#7228) @yukendev
+- fix: Update PageTree data after saving page (#7227) @yukendev
+- fix: Lsx "filter" and "except" options does not work (#7226) @yuki-takei
+- fix: Omit remark-growi-directive shortcuts (#7225) @yuki-takei
+
+### 🧰 Maintenance
+
+- ci(deps-dev): bump textlint-rule-no-doubled-joshi from 4.0.0 to 4.0.1 (#7222) @dependabot
+
 ## [v6.0.1](https://github.com/weseek/growi/compare/v6.0.0...v6.0.1) - 2023-01-07
 
 ### 🚀 Improvement

+ 1 - 0
bin/data-migrations/v6/README.md

@@ -0,0 +1 @@
+WIP

+ 57 - 0
bin/data-migrations/v6/src/migration.js

@@ -0,0 +1,57 @@
+
+/* eslint-disable no-undef, no-var, vars-on-top, no-restricted-globals, regex/invalid, import/extensions */
+// ignore lint error because this file is js as mongoshell
+
+var pagesCollection = db.getCollection('pages');
+var revisionsCollection = db.getCollection('revisions');
+
+var getProcessorArray = require('./processor.js');
+
+var migrationType = process.env.MIGRATION_TYPE;
+var processors = getProcessorArray(migrationType);
+
+var operations = [];
+
+var batchSize = process.env.BATCH_SIZE ?? 100; // default 100 revisions in 1 bulkwrite
+var batchSizeInterval = process.env.BATCH_INTERVAL ?? 3000; // default 3 sec
+
+// ===========================================
+// replace method with processors
+// ===========================================
+function replaceLatestRevisions(body, processors) {
+  var replacedBody = body;
+  processors.forEach((processor) => {
+    replacedBody = processor(replacedBody);
+  });
+  return replacedBody;
+}
+
+if (processors.length === 0) {
+  throw Error('No valid processors found. Please enter a valid environment variable');
+}
+
+pagesCollection.find({}).forEach((doc) => {
+  if (doc.revision) {
+    var revision = revisionsCollection.findOne({ _id: doc.revision });
+    var replacedBody = replaceLatestRevisions(revision.body, [...processors]);
+    var operation = {
+      updateOne: {
+        filter: { _id: revision._id },
+        update: {
+          $set: { body: replacedBody },
+        },
+      },
+    };
+    operations.push(operation);
+
+    // bulkWrite per 100 revisions
+    if (operations.length > (batchSize - 1)) {
+      revisionsCollection.bulkWrite(operations);
+      // sleep time can be set from env var
+      sleep(batchSizeInterval);
+      operations = [];
+    }
+  }
+});
+revisionsCollection.bulkWrite(operations);
+print('migration complete!');

+ 65 - 0
bin/data-migrations/v6/src/processor.js

@@ -0,0 +1,65 @@
+
+/* eslint-disable no-undef, no-var, vars-on-top, no-restricted-globals, regex/invalid */
+// ignore lint error because this file is js as mongoshell
+
+// ===========================================
+// processors for old format
+// ===========================================
+function drawioProcessor(body) {
+  var oldDrawioRegExp = /:::\s?drawio\n(.+?)\n:::/g; // drawio old format
+  return body.replace(oldDrawioRegExp, '``` drawio\n$1\n```');
+}
+
+function plantumlProcessor(body) {
+  var oldPlantUmlRegExp = /@startuml\n([\s\S]*?)\n@enduml/g; // plantUML old format
+  return body.replace(oldPlantUmlRegExp, '``` plantuml\n$1\n```');
+}
+
+function tsvProcessor(body) {
+  var oldTsvTableRegExp = /::: tsv(-h)?\n([\s\S]*?)\n:::/g; // TSV old format
+  return body.replace(oldTsvTableRegExp, '``` tsv$1\n$2\n```');
+}
+
+function csvProcessor(body) {
+  var oldCsvTableRegExp = /::: csv(-h)?\n([\s\S]*?)\n:::/g; // CSV old format
+  return body.replace(oldCsvTableRegExp, '``` csv$1\n$2\n```');
+}
+
+function bracketlinkProcessor(body) {
+  // https://regex101.com/r/btZ4hc/1
+  var oldBracketLinkRegExp = /(?<!\[)\[{1}(\/.*?)\]{1}(?!\])/g; // Page Link old format
+  return body.replace(oldBracketLinkRegExp, '[[$1]]');
+}
+
+// ===========================================
+// define processors
+// ===========================================
+
+function getProcessorArray(migrationType) {
+  var oldFormatProcessors;
+  switch (migrationType) {
+    case 'v6-drawio':
+      oldFormatProcessors = [drawioProcessor];
+      break;
+    case 'v6-plantuml':
+      oldFormatProcessors = [plantumlProcessor];
+      break;
+    case 'v6-tsv':
+      oldFormatProcessors = [tsvProcessor];
+      break;
+    case 'v6-csv':
+      oldFormatProcessors = [csvProcessor];
+      break;
+    case 'v6-bracketlink':
+      oldFormatProcessors = [bracketlinkProcessor];
+      break;
+    case 'v6':
+      oldFormatProcessors = [drawioProcessor, plantumlProcessor, tsvProcessor, csvProcessor, bracketlinkProcessor];
+      break;
+    default:
+      oldFormatProcessors = [];
+  }
+  return oldFormatProcessors;
+}
+
+module.exports = getProcessorArray;

+ 1 - 1
lerna.json

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

+ 1 - 1
package.json

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

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

@@ -7,7 +7,6 @@ MIGRATIONS_DIR=src/migrations/
 APP_SITE_URL=http://localhost:3000
 FILE_UPLOAD=mongodb
 # MONGO_GRIDFS_TOTAL_LIMIT=10485760
-# NO_CDN=true
 MONGO_URI="mongodb://mongo:27017/growi"
 # REDIS_URI="http://redis:6379"
 # NCHAN_URI="http://nchan"

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

@@ -27,6 +27,7 @@ module.exports = {
   'growi-plugin:*': 'debug',
   // 'growi:InterceptorManager': 'debug',
   'growi:service:search-delegator:elasticsearch': 'debug',
+  'growi:service:g2g-transfer': 'debug',
 
   /*
    * configure level for client

+ 5 - 5
packages/app/docker/Dockerfile

@@ -95,16 +95,16 @@ WORKDIR ${optDir}
 # ignore eslint and stylelint
 COPY ["package.json", "lerna.json", "tsconfig.base.json", "./"]
 # copy all related packages
-COPY packages/app packages/app
-COPY packages/core packages/core
 COPY packages/codemirror-textlint packages/codemirror-textlint
+COPY packages/preset-themes packages/preset-themes
 COPY packages/slack packages/slack
-COPY packages/ui packages/ui
+COPY packages/hackmd packages/hackmd
 COPY packages/remark-drawio packages/remark-drawio
 COPY packages/remark-growi-directive packages/remark-growi-directive
 COPY packages/remark-lsx packages/remark-lsx
-COPY packages/hackmd packages/hackmd
-COPY packages/preset-themes packages/preset-themes
+COPY packages/ui packages/ui
+COPY packages/core packages/core
+COPY packages/app packages/app
 
 # build
 RUN yarn lerna run build

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

@@ -10,7 +10,7 @@ GROWI Official docker image
 Supported tags and respective Dockerfile links
 ------------------------------------------------
 
-* [`6.0.1`, `6.0`, `6`, `latest` (Dockerfile)](https://github.com/weseek/growi/blob/v6.0.1/packages/app/docker/Dockerfile)
+* [`6.0.5`, `6.0`, `6`, `latest` (Dockerfile)](https://github.com/weseek/growi/blob/v6.0.5/packages/app/docker/Dockerfile)
 * [`5.1.7`, `5.1`, `5`](https://github.com/weseek/growi/blob/v5.1.7/packages/app/docker/Dockerfile)
 * [`5.1.7-nocdn`, `5.1-nocdn`, `5-nocdn`](https://github.com/weseek/growi/blob/v5.1.7/packages/app/docker/Dockerfile)
 * [`4.5.23`, `4.5`, `4`, `latest` (Dockerfile)](https://github.com/weseek/growi/blob/v4.5.23/packages/app/docker/Dockerfile)

+ 65 - 0
packages/app/docker/codebuild/.terraform.lock.hcl

@@ -0,0 +1,65 @@
+# This file is maintained automatically by "terraform init".
+# Manual edits may be lost in future updates.
+
+provider "registry.terraform.io/hashicorp/aws" {
+  version     = "4.49.0"
+  constraints = "~> 4.16"
+  hashes = [
+    "h1:oOwWQpvQWd1uVP1axBz/TO6xzzLWoL982AY/MQfeF7I=",
+    "zh:09803937f00fdf2873eccf685eec7854408925cbf183c9b683df7c5825be463f",
+    "zh:2af1575e538fb0b669266f8d1385b17bfdaf17c521b6b6329baa1f2971fc4a4d",
+    "zh:3f71882b438cde3108fe68cfe2637839d3eed08157a9721bd97babf3912247a8",
+    "zh:577af1b38f5da8a9f29eebe5eebec9279d26e757cd03b0c8c59311f9ce8a859b",
+    "zh:60160d39094973beefb9b10cfd6aaa5b63a2e68c32445ecffcd1b101356e6f9b",
+    "zh:762656454722548baeccf35cbaa23b887976337e1ed321682df7390419fdf22d",
+    "zh:7f6d7887821659bf3bef815949077dc91ffcdb0d911644a887b6683b264a5ca6",
+    "zh:8f16a352cc903f8951fa4619c36233b3e66e27d724817b131f2035dd8896f524",
+    "zh:8f768f65e370366c8b91c00d01c9a6264fe26ea9ae1819f14bdcd12c066272bc",
+    "zh:95ad78c689a83c08ef7c3e544c3c9aca93ed528054aa77cc968ddd9efa3a1023",
+    "zh:9b12af85486a96aedd8d7984b0ff811a4b42e3d88dad1a3fb4c0b580d04fa425",
+    "zh:a47097ab6a4ca8302da82964303ffdd2310ed65e8f8524bfe4058816cf1addb7",
+    "zh:b66d820c70cd5fd628ffe882d2b97e76b969dca4e6827ac2ba0f8d3bc5d6e9c6",
+    "zh:b80f713a4f3e1355c3dd1600e9d08b9f15ed2370054ec792ad2c01f2541abe02",
+    "zh:ce065bc3962cb71fa7652562226b9d486f3d7fcb88285c1020ebe2f550e28641",
+  ]
+}
+
+provider "registry.terraform.io/hashicorp/random" {
+  version     = "3.4.3"
+  constraints = ">= 2.1.0"
+  hashes = [
+    "h1:xZGZf18JjMS06pFa4NErzANI98qi59SEcBsOcS2P2yQ=",
+    "zh:41c53ba47085d8261590990f8633c8906696fa0a3c4b384ff6a7ecbf84339752",
+    "zh:59d98081c4475f2ad77d881c4412c5129c56214892f490adf11c7e7a5a47de9b",
+    "zh:686ad1ee40b812b9e016317e7f34c0d63ef837e084dea4a1f578f64a6314ad53",
+    "zh:78d5eefdd9e494defcb3c68d282b8f96630502cac21d1ea161f53cfe9bb483b3",
+    "zh:84103eae7251384c0d995f5a257c72b0096605048f757b749b7b62107a5dccb3",
+    "zh:8ee974b110adb78c7cd18aae82b2729e5124d8f115d484215fd5199451053de5",
+    "zh:9dd4561e3c847e45de603f17fa0c01ae14cae8c4b7b4e6423c9ef3904b308dda",
+    "zh:bb07bb3c2c0296beba0beec629ebc6474c70732387477a65966483b5efabdbc6",
+    "zh:e891339e96c9e5a888727b45b2e1bb3fcbdfe0fd7c5b4396e4695459b38c8cb1",
+    "zh:ea4739860c24dfeaac6c100b2a2e357106a89d18751f7693f3c31ecf6a996f8d",
+    "zh:f0c76ac303fd0ab59146c39bc121c5d7d86f878e9a69294e29444d4c653786f8",
+    "zh:f143a9a5af42b38fed328a161279906759ff39ac428ebcfe55606e05e1518b93",
+  ]
+}
+
+provider "registry.terraform.io/hashicorp/tls" {
+  version     = "4.0.4"
+  constraints = ">= 3.0.0"
+  hashes = [
+    "h1:pe9vq86dZZKCm+8k1RhzARwENslF3SXb9ErHbQfgjXU=",
+    "zh:23671ed83e1fcf79745534841e10291bbf34046b27d6e68a5d0aab77206f4a55",
+    "zh:45292421211ffd9e8e3eb3655677700e3c5047f71d8f7650d2ce30242335f848",
+    "zh:59fedb519f4433c0fdb1d58b27c210b27415fddd0cd73c5312530b4309c088be",
+    "zh:5a8eec2409a9ff7cd0758a9d818c74bcba92a240e6c5e54b99df68fff312bbd5",
+    "zh:5e6a4b39f3171f53292ab88058a59e64825f2b842760a4869e64dc1dc093d1fe",
+    "zh:810547d0bf9311d21c81cc306126d3547e7bd3f194fc295836acf164b9f8424e",
+    "zh:824a5f3617624243bed0259d7dd37d76017097dc3193dac669be342b90b2ab48",
+    "zh:9361ccc7048be5dcbc2fafe2d8216939765b3160bd52734f7a9fd917a39ecbd8",
+    "zh:aa02ea625aaf672e649296bce7580f62d724268189fe9ad7c1b36bb0fa12fa60",
+    "zh:c71b4cd40d6ec7815dfeefd57d88bc592c0c42f5e5858dcc88245d371b4b8b1e",
+    "zh:dabcd52f36b43d250a3d71ad7abfa07b5622c69068d989e60b79b2bb4f220316",
+    "zh:f569b65999264a9416862bca5cd2a6177d94ccb0424f3a4ef424428912b9cb3c",
+  ]
+}

+ 32 - 0
packages/app/docker/codebuild/buildspec.yml

@@ -0,0 +1,32 @@
+version: 0.2
+
+env:
+  variables:
+    DOCKER_BUILDKIT: 1
+    IMAGE_TAG: ''
+    IMAGE_TAG_GHCR: ''
+  secrets-manager:
+    DOCKER_REGISTRY_PASSWORD: growi/official-image-builder:DOCKER_REGISTRY_PASSWORD
+    DOCKER_REGISTRY_ON_GITHUB_PAT: growi/official-image-builder:DOCKER_REGISTRY_ON_GITHUB_PAT
+
+phases:
+  pre_build:
+    commands:
+      # login to docker.io
+      - echo ${DOCKER_REGISTRY_PASSWORD} | docker login --username wsmoogle --password-stdin
+      # login to ghcr.io
+      - echo ${DOCKER_REGISTRY_ON_GITHUB_PAT} | docker login ghcr.io --username wsmoogle --password-stdin
+  build:
+    commands:
+      - docker build -t ${IMAGE_TAG} -f ./packages/app/docker/Dockerfile .
+      - docker tag ${IMAGE_TAG} ${IMAGE_TAG_GHCR}
+
+  post_build:
+    commands:
+      - docker push $IMAGE_TAG
+      - docker push $IMAGE_TAG_GHCR
+
+cache:
+  paths:
+    - node_modules/**/*
+    - packages/*/node_modules/**/*

+ 25 - 0
packages/app/docker/codebuild/codebuild.tf

@@ -0,0 +1,25 @@
+module "codebuild" {
+  source = "cloudposse/codebuild/aws"
+
+  name                = "growi-official-image-builder"
+  description         = "The CodeBuild Project for GROWI official docker image"
+
+  artifact_type       = "NO_ARTIFACTS"
+
+  source_type         = "GITHUB"
+  source_location     = "https://github.com/weseek/growi.git"
+  source_version      = "refs/heads/master"
+  git_clone_depth     = 1
+
+  buildspec           = "packages/app/docker/codebuild/buildspec.yml"
+
+  # https://docs.aws.amazon.com/codebuild/latest/userguide/build-env-ref-available.html
+  build_image         = "aws/codebuild/amazonlinux2-x86_64-standard:3.0"
+  build_compute_type  = "BUILD_GENERAL1_LARGE"
+
+  privileged_mode     = true
+
+  cache_type          = "LOCAL"
+  local_cache_modes   = ["LOCAL_DOCKER_LAYER_CACHE", "LOCAL_CUSTOM_CACHE"]
+
+}

+ 23 - 0
packages/app/docker/codebuild/main.tf

@@ -0,0 +1,23 @@
+terraform {
+  backend "remote" {
+    organization = "weseek"
+
+    workspaces {
+      name = "growi-official-image-builder"
+    }
+  }
+
+  required_providers {
+    aws = {
+      source  = "hashicorp/aws"
+      version = "~> 4.16"
+    }
+  }
+
+  required_version = ">= 1.2.0"
+}
+
+provider "aws" {
+  profile = "weseek"
+  region  = "ap-northeast-1"
+}

+ 26 - 0
packages/app/docker/codebuild/oidc.tf

@@ -0,0 +1,26 @@
+module "oidc_github" {
+  source  = "unfunco/oidc-github/aws"
+
+  iam_role_name = "GitHubOIDC-for-growi"
+  iam_role_inline_policies = {
+    "inline_policy" : data.aws_iam_policy_document.policy_document.json
+  }
+
+  github_repositories = [
+    "weseek/growi",
+  ]
+}
+
+data "aws_iam_policy_document" "policy_document" {
+  statement {
+    actions   = [
+      "codebuild:StartBuild",
+      "codebuild:StopBuild",
+      "codebuild:RetryBuild",
+      "codebuild:BatchGetBuilds"
+    ]
+    resources = [
+      module.codebuild.project_arn
+    ]
+  }
+}

+ 15 - 0
packages/app/docker/codebuild/secretsmanager.tf

@@ -0,0 +1,15 @@
+resource "aws_secretsmanager_secret" "secret" {
+  name = "growi/official-image-builder"
+}
+
+resource "aws_secretsmanager_secret_version" "main" {
+  secret_id     = aws_secretsmanager_secret.secret.id
+  secret_string = jsonencode({
+    KEY1 = "CHANGE THIS"
+    KEY2 = "CHANGE THIS"
+  })
+
+  lifecycle {
+    ignore_changes = [secret_string, version_stages]
+  }
+}

+ 0 - 6
packages/app/docker/nocdn/.env.production.local

@@ -1,6 +0,0 @@
-
-##
-## Handled by Next.js with dotenv or dotenv-flow
-## https://nextjs.org/docs/basic-features/environment-variables
-##
-NO_CDN=true

+ 16 - 13
packages/app/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@growi/app",
-  "version": "6.0.2-RC.0",
+  "version": "6.0.6-RC.0",
   "license": "MIT",
   "scripts": {
     "//// for production": "",
@@ -46,7 +46,7 @@
     "openapi:v3": "yarn cross-env API_VERSION=3 yarn swagger-jsdoc -- \"src/server/routes/apiv3/**/*.js\" \"src/server/models/**/*.js\"",
     "openapi:v1": "yarn cross-env API_VERSION=1 yarn swagger-jsdoc -- \"src/server/*/*.js\" \"src/server/models/**/*.js\"",
     "resources:hackmd": "yarn lerna run build --scope=@growi/hackmd",
-    "resources:preset-themes": "yarn lerna run build --scope=@growi/preset-themes",
+    "resources:preset-themes": "yarn lerna run dev:nowatch --scope=@growi/preset-themes",
     "// resources:dl-resources": "yarn ts-node bin/download-cdn-resources.ts",
     "ts-node": "node -r ts-node/register -r tsconfig-paths/register -r dotenv-flow/config"
   },
@@ -55,7 +55,8 @@
     "escape-string-regexp": "5.0.0 or above exports only ESM",
     "next": "/Sandbox rendering is crashed with v12.3 or above ",
     "string-width": "5.0.0 or above exports only ESM.",
-    "prom-client": "!!DO NOT REMOVE!! A peer dependency of @promster."
+    "prom-client": "!!DO NOT REMOVE!! A peer dependency of @promster.",
+    "remark-wiki-link": "!!DO NOT REMOVE!! including 'mdast-util-wiki-link' and 'micromark-extension-wiki-link' required by pukiwiki-like-linker"
   },
   "dependencies": {
     "@akebifiky/remark-simple-plantuml": "^1.0.2",
@@ -66,14 +67,14 @@
     "@elastic/elasticsearch7": "npm:@elastic/elasticsearch@^7.17.0",
     "@godaddy/terminus": "^4.9.0",
     "@google-cloud/storage": "^5.8.5",
-    "@growi/codemirror-textlint": "^6.0.2-RC.0",
-    "@growi/core": "^6.0.2-RC.0",
-    "@growi/hackmd": "^6.0.2-RC.0",
-    "@growi/preset-themes": "^6.0.2-RC.0",
-    "@growi/remark-drawio": "^6.0.2-RC.0",
-    "@growi/remark-growi-directive": "^6.0.2-RC.0",
-    "@growi/remark-lsx": "^6.0.2-RC.0",
-    "@growi/slack": "^6.0.2-RC.0",
+    "@growi/codemirror-textlint": "^6.0.6-RC.0",
+    "@growi/core": "^6.0.6-RC.0",
+    "@growi/hackmd": "^6.0.6-RC.0",
+    "@growi/preset-themes": "^6.0.6-RC.0",
+    "@growi/remark-drawio": "^6.0.6-RC.0",
+    "@growi/remark-growi-directive": "^6.0.6-RC.0",
+    "@growi/remark-lsx": "^6.0.6-RC.0",
+    "@growi/slack": "^6.0.6-RC.0",
     "@promster/express": "^7.0.6",
     "@promster/server": "^7.0.8",
     "@slack/web-api": "^6.2.4",
@@ -96,6 +97,7 @@
     "cookie-parser": "^1.4.5",
     "csurf": "^1.11.0",
     "csv-to-markdown-table": "^1.1.0",
+    "dayjs": "^1.11.7",
     "date-fns": "^2.23.0",
     "detect-indent": "^7.0.0",
     "diff": "^5.0.0",
@@ -111,6 +113,7 @@
     "express-session": "^1.16.1",
     "express-validator": "^6.14.0",
     "extensible-custom-error": "^0.0.7",
+    "form-data": "^4.0.0",
     "graceful-fs": "^4.1.11",
     "hast-util-select": "^5.0.2",
     "helmet": "^4.6.0",
@@ -182,7 +185,7 @@
     "string-width": "=4.2.2",
     "superjson": "^1.9.1",
     "swagger-jsdoc": "^6.1.0",
-    "swr": "^1.3.0",
+    "swr": "^2.0.2",
     "throttle-debounce": "^3.0.1",
     "toastr": "^2.1.2",
     "uglifycss": "^0.0.29",
@@ -200,7 +203,7 @@
     "handsontable": "v7.0.0 or above is no loger MIT lisence."
   },
   "devDependencies": {
-    "@growi/ui": "^6.0.2-RC.0",
+    "@growi/ui": "^6.0.6-RC.0",
     "@handsontable/react": "=2.1.0",
     "@icon/themify-icons": "1.0.1-alpha.3",
     "@next/bundle-analyzer": "^12.2.3",

+ 12 - 1
packages/app/public/static/locales/en_US/admin.json

@@ -526,7 +526,7 @@
       "upload": "Upload",
       "discard": "Discard uploaded data",
       "errors": {
-        "different_versions": "This growi and the uploaded data versions are not met",
+        "different_versions": "The version of this GROWI and the uploaded data are not the same",
         "at_least_one": "Select one or more collections.",
         "page_and_revision": "'Pages' and 'Revisions' must be imported both.",
         "depends": "'{{target}}' must be selected when '{{condition}}' is selected."
@@ -848,6 +848,12 @@
       "log_type": "https://docs.growi.org/en/admin-guide/admin-cookbook/audit-log-setup.html#log-types"
     }
   },
+  "g2g_data_transfer": {
+    "transfer_data_to_another_growi": "Transfer data from this GROWI to another GROWI",
+    "advanced_options": "Advanced options",
+    "start_transfer": "Start transfer",
+    "paste_transfer_key": "Paste transter key here"
+  },
   "plugins": {
     "plugins": "Plugins",
     "plugin_installer": "Plugin Installer",
@@ -1024,6 +1030,11 @@
     "ADMIN_SEARCH_INDICES_NORMALIZE": "Normalize of Elasticsearch indexes",
     "ADMIN_SEARCH_INDICES_REBUILD": "Rebuild Elasticsearch indexes"
   },
+  "g2g": {
+    "transfer_success": "Completed GROWI to GROWI transfer successfully",
+    "error_generate_growi_archive": "Failed to generate GROWI archive file",
+    "error_send_growi_archive": "Failed to send GROWI archive file to the destination GROWI"
+  },
   "toaster": {
     "give_user_admin": "Succeeded to give {{username}} admin",
     "remove_user_admin": "Succeeded to remove {{username}} admin",

+ 10 - 0
packages/app/public/static/locales/en_US/commons.json

@@ -96,5 +96,15 @@
 
   "not_found_page": {
     "page_not_exist": "This page does not exist."
+  },
+
+  "g2g_data_transfer": {
+    "tab": "Data transfer",
+    "data_transfer": "Data Transfer",
+    "transfer_data_to_this_growi": "Transfer data from another GROWI to this GROWI",
+    "publish_transfer_key": "Publish transfer key",
+    "transfer_key_limit": "Transfer keys are valid for 1 hour after issuance.",
+    "once_transfer_key_used": "Once the transfer key is used for transfer, it cannot be used for any other transfer.",
+    "transfer_to_growi_cloud": "If you wish to transfer to GROWI.cloud, please click here."
   }
 }

+ 1 - 0
packages/app/public/static/locales/en_US/translation.json

@@ -165,6 +165,7 @@
     "no_page_list": "There are no pages under this page."
   },
   "installer": {
+    "tab": "Create account",
     "title": "Installer",
     "setup": "Setup",
     "create_initial_account": "Create an initial account",

+ 11 - 0
packages/app/public/static/locales/ja_JP/admin.json

@@ -856,6 +856,12 @@
       "log_type": "https://docs.growi.org/ja/admin-guide/admin-cookbook/audit-log-setup.html#log-types"
     }
   },
+  "g2g_data_transfer": {
+    "transfer_data_to_another_growi": "このGROWIのデータを別GROWIへ移行する",
+    "advanced_options": "詳細オプション",
+    "start_transfer": "移行を開始する",
+    "paste_transfer_key": "移行キーをここにペースト"
+  },
   "plugins": {
     "plugins": "プラグイン",
     "plugin_installer": "プラグインインストーラー",
@@ -1032,6 +1038,11 @@
     "ADMIN_SEARCH_INDICES_NORMALIZE": "Elasticsearch のインデックスの正規化",
     "ADMIN_SEARCH_INDICES_REBUILD": "Elasticsearch のインデックスのリビルド"
   },
+  "g2g": {
+    "transfer_success": "G2G移行が完了しました",
+    "error_generate_growi_archive": "GROWI アーカイブファイルの作成に失敗しました",
+    "error_send_growi_archive": "GROWI アーカイブファイルの送信に失敗しました"
+  },
   "toaster": {
     "give_user_admin": "{{username}}を管理者に設定しました",
     "remove_user_admin": "{{username}}を管理者から外しました",

+ 10 - 0
packages/app/public/static/locales/ja_JP/commons.json

@@ -96,5 +96,15 @@
 
   "not_found_page": {
     "page_not_exist": "このページは存在しません。"
+  },
+
+  "g2g_data_transfer": {
+    "tab": "データ移行",
+    "data_transfer": "データ移行",
+    "transfer_data_to_this_growi": "別GROWIのデータをこのGROWIへ移行する",
+    "publish_transfer_key": "移行キーを発行する",
+    "transfer_key_limit": "※ 移行キーの有効期限は発行から1時間となります。",
+    "once_transfer_key_used": "※ 移行キーは一度移行に利用するとそれ以降はご利用いただけなくなります。",
+    "transfer_to_growi_cloud": "※ GROWI.cloud への移行を実施する場合はこちらをご確認ください。"
   }
 }

+ 1 - 0
packages/app/public/static/locales/ja_JP/translation.json

@@ -167,6 +167,7 @@
     "no_page_list": "このページの配下にはページが存在しません。"
   },
   "installer": {
+    "tab": "アカウント作成",
     "title": "インストーラー",
     "setup": "セットアップ",
     "create_initial_account": "最初のアカウントの作成",

+ 12 - 1
packages/app/public/static/locales/zh_CN/admin.json

@@ -534,7 +534,7 @@
       "upload": "Upload",
       "discard": "Discard uploaded data",
       "errors": {
-        "versions_not_met": "this growi and the uploaded data versions are not met",
+        "different_versions": "The version of this GROWI and the uploaded data are not the same",
         "at_least_one": "Select one or more collections.",
         "page_and_revision": "'Pages' and 'Revisions' must be imported both.",
         "depends": "'{{target}}' must be selected when '{{condition}}' is selected."
@@ -856,6 +856,12 @@
       "log_type": "https://docs.growi.org/en/admin-guide/admin-cookbook/audit-log-setup.html#log-types"
     }
   },
+  "g2g_data_transfer": {
+    "transfer_data_to_another_growi": "将数据从这个GROWI迁移到另一个GROWI上",
+    "advanced_options": "高级选项",
+    "start_transfer": "开始迁移",
+    "paste_transfer_key": "在这里粘贴过渡键"
+  },
   "plugins": {
     "plugins": "Plugins",
     "plugin_installer": "Plugin Installer",
@@ -1032,6 +1038,11 @@
     "ADMIN_SEARCH_INDICES_NORMALIZE": "试图重新连接Elasticsearch",
     "ADMIN_SEARCH_INDICES_REBUILD": "重建 Elasticsearch 索引"
   },
+  "g2g": {
+    "transfer_success": "Completed GROWI to GROWI transfer successfully",
+    "error_generate_growi_archive": "Failed to generate GROWI archive file",
+    "error_send_growi_archive": "Failed to send GROWI archive file to the destination GROWI"
+  },
   "toaster": {
     "give_user_admin": "Succeeded to give {{username}} admin",
     "remove_user_admin": "Succeeded to remove {{username}} admin ",

+ 10 - 0
packages/app/public/static/locales/zh_CN/commons.json

@@ -96,5 +96,15 @@
 
   "not_found_page": {
     "page_not_exist": "该页面不存在"
+  },
+
+  "g2g_data_transfer": {
+    "tab": "数据迁移",
+    "data_transfer": "数据迁移",
+    "transfer_data_to_this_growi": "将数据从另一个GROWI迁移到这个GROWI上",
+    "publish_transfer_key": "发布迁移密钥",
+    "transfer_key_limit": "迁移密钥在签发后一小时内有效。",
+    "once_transfer_key_used": "一旦迁移密钥被用于迁移,它将不再可用于进一步的迁移。",
+    "transfer_to_growi_cloud": "如果您希望迁移到GROWI.cloud,请点击这里。"
   }
 }

+ 2 - 0
packages/app/public/static/locales/zh_CN/translation.json

@@ -159,6 +159,7 @@
   "not_allowed_to_see_this_page": "你不能看到这个页面",
   "Confirm": "确定",
   "Successfully requested": "进程成功接受",
+  "copied_to_clipboard": "它已复制到剪贴板。",
 	"form_validation": {
 		"error_message": "有些值不正确",
 		"required": "%s 是必需的",
@@ -172,6 +173,7 @@
     "no_page_list": "There are no pages under this page."
   },
 	"installer": {
+    "tab": "创建账户",
     "title": "安装",
 		"setup": "安装",
 		"create_initial_account": "创建初始用户",

+ 15 - 0
packages/app/src/client/services/g2g-transfer.ts

@@ -0,0 +1,15 @@
+import { useCallback, useState } from 'react';
+
+import { apiv3Post } from '~/client/util/apiv3-client';
+
+export const useGenerateTransferKey = (): {transferKey: string, generateTransferKey: () => Promise<void>} => {
+  const [transferKey, setTransferKey] = useState('');
+
+  const generateTransferKey = useCallback(async() => {
+    const response = await apiv3Post('/g2g-transfer/generate-key', { appSiteUrl: window.location.origin });
+    const { transferKey } = response.data;
+    setTransferKey(transferKey);
+  }, []);
+
+  return { transferKey, generateTransferKey };
+};

+ 2 - 2
packages/app/src/client/services/page-operation.ts

@@ -6,7 +6,7 @@ import urljoin from 'url-join';
 import { OptionsToSave } from '~/interfaces/page-operation';
 import { useCurrentPageId } from '~/stores/context';
 import { useEditingMarkdown, useIsEnabledUnsavedWarning, usePageTagsForEditors } from '~/stores/editor';
-import { useSWRxCurrentPage, useSWRxTagsInfo } from '~/stores/page';
+import { useSWRMUTxCurrentPage, useSWRxTagsInfo } from '~/stores/page';
 import { useSetRemoteLatestPageData } from '~/stores/remote-latest-page';
 import loggerFactory from '~/utils/logger';
 
@@ -179,7 +179,7 @@ export const useSaveOrUpdate = (): SaveOrUpdateFunction => {
 
 export const useUpdateStateAfterSave = (pageId: string|undefined|null): (() => Promise<void>) | undefined => {
   const { mutate: mutateCurrentPageId } = useCurrentPageId();
-  const { mutate: mutateCurrentPage } = useSWRxCurrentPage();
+  const { trigger: mutateCurrentPage } = useSWRMUTxCurrentPage();
   const { setRemoteLatestPageData } = useSetRemoteLatestPageData();
   const { mutate: mutateTagsInfo } = useSWRxTagsInfo(pageId);
   const { sync: syncTagsInfoForEditor } = usePageTagsForEditors(pageId);

+ 89 - 0
packages/app/src/client/services/side-effects/drawio-modal-launcher-for-view.ts

@@ -0,0 +1,89 @@
+import { useCallback, useEffect } from 'react';
+
+import EventEmitter from 'events';
+
+import { DrawioEditByViewerProps } from '@growi/remark-drawio';
+
+import { useSaveOrUpdate } from '~/client/services/page-operation';
+import mdu from '~/components/PageEditor/MarkdownDrawioUtil';
+import type { OptionsToSave } from '~/interfaces/page-operation';
+import { useShareLinkId } from '~/stores/context';
+import { useDrawioModal } from '~/stores/modal';
+import { useSWRxCurrentPage, useSWRxTagsInfo } from '~/stores/page';
+import loggerFactory from '~/utils/logger';
+
+
+const logger = loggerFactory('growi:cli:side-effects:useDrawioModalLauncherForView');
+
+
+declare global {
+  // eslint-disable-next-line vars-on-top, no-var
+  var globalEmitter: EventEmitter;
+}
+
+
+export const useDrawioModalLauncherForView = (opts?: {
+  onSaveSuccess?: () => void,
+  onSaveError?: (error: any) => void,
+}): void => {
+
+  const { data: shareLinkId } = useShareLinkId();
+
+  const { data: currentPage } = useSWRxCurrentPage();
+  const { data: tagsInfo } = useSWRxTagsInfo(currentPage?._id);
+
+  const { open: openDrawioModal } = useDrawioModal();
+
+  const saveOrUpdate = useSaveOrUpdate();
+
+  const saveByDrawioModal = useCallback(async(drawioMxFile: string, bol: number, eol: number) => {
+    if (currentPage == null || tagsInfo == null || shareLinkId != null) {
+      return;
+    }
+
+    const currentMarkdown = currentPage.revision.body;
+    const newMarkdown = mdu.replaceDrawioInMarkdown(drawioMxFile, currentMarkdown, bol, eol);
+
+    const optionsToSave: OptionsToSave = {
+      isSlackEnabled: false,
+      slackChannels: '',
+      grant: currentPage.grant,
+      grantUserGroupId: currentPage.grantedGroup?._id,
+      grantUserGroupName: currentPage.grantedGroup?.name,
+      pageTags: tagsInfo.tags,
+    };
+
+    try {
+      const currentRevisionId = currentPage.revision._id;
+      await saveOrUpdate(
+        newMarkdown,
+        { pageId: currentPage._id, path: currentPage.path, revisionId: currentRevisionId },
+        optionsToSave,
+      );
+
+      opts?.onSaveSuccess?.();
+    }
+    catch (error) {
+      logger.error('failed to save', error);
+      opts?.onSaveError?.(error);
+    }
+  }, [currentPage, opts, saveOrUpdate, shareLinkId, tagsInfo]);
+
+
+  // set handler to open DrawioModal
+  useEffect(() => {
+    // disable if share link
+    if (shareLinkId != null) {
+      return;
+    }
+
+    const handler = (data: DrawioEditByViewerProps) => {
+      openDrawioModal(data.drawioMxFile, drawioMxFile => saveByDrawioModal(drawioMxFile, data.bol, data.eol));
+    };
+    globalEmitter.on('launchDrawioModal', handler);
+
+    return function cleanup() {
+      globalEmitter.removeListener('launchDrawioModal', handler);
+    };
+  }, [openDrawioModal, saveByDrawioModal, shareLinkId]);
+};

+ 33 - 0
packages/app/src/client/services/side-effects/hackmd-draft-updated.ts

@@ -0,0 +1,33 @@
+import { useCallback, useEffect } from 'react';
+
+import { SocketEventName } from '~/interfaces/websocket';
+import { useCurrentPageId } from '~/stores/context';
+import { useIsHackmdDraftUpdatingInRealtime } from '~/stores/hackmd';
+import { useGlobalSocket } from '~/stores/websocket';
+
+export const useHackmdDraftUpdatedEffect = (): void => {
+
+  const { data: currentPageId } = useCurrentPageId();
+  const { mutate: mutateIsHackmdDraftUpdatingInRealtime } = useIsHackmdDraftUpdatingInRealtime();
+
+  const { data: socket } = useGlobalSocket();
+
+  const setIsHackmdDraftUpdatingInRealtime = useCallback((data) => {
+    const { s2cMessagePageUpdated } = data;
+    if (s2cMessagePageUpdated.pageId === currentPageId) {
+      mutateIsHackmdDraftUpdatingInRealtime(true);
+    }
+  }, [currentPageId, mutateIsHackmdDraftUpdatingInRealtime]);
+
+  // listen socket for hackmd saved
+  useEffect(() => {
+
+    if (socket == null) { return }
+
+    socket.on(SocketEventName.EditingWithHackmd, setIsHackmdDraftUpdatingInRealtime);
+
+    return () => {
+      socket.off(SocketEventName.EditingWithHackmd, setIsHackmdDraftUpdatingInRealtime);
+    };
+  }, [setIsHackmdDraftUpdatingInRealtime, socket]);
+};

+ 89 - 0
packages/app/src/client/services/side-effects/handsontable-modal-launcher-for-view.ts

@@ -0,0 +1,89 @@
+import { useCallback, useEffect } from 'react';
+
+import EventEmitter from 'events';
+
+import MarkdownTable from '~/client/models/MarkdownTable';
+import { useSaveOrUpdate } from '~/client/services/page-operation';
+import mtu from '~/components/PageEditor/MarkdownTableUtil';
+import type { OptionsToSave } from '~/interfaces/page-operation';
+import { useShareLinkId } from '~/stores/context';
+import { useHandsontableModal } from '~/stores/modal';
+import { useSWRxCurrentPage, useSWRxTagsInfo } from '~/stores/page';
+import loggerFactory from '~/utils/logger';
+
+
+const logger = loggerFactory('growi:cli:side-effects:useHandsontableModalLauncherForView');
+
+
+declare global {
+  // eslint-disable-next-line vars-on-top, no-var
+  var globalEmitter: EventEmitter;
+}
+
+
+export const useHandsontableModalLauncherForView = (opts?: {
+  onSaveSuccess?: () => void,
+  onSaveError?: (error: any) => void,
+}): void => {
+
+  const { data: shareLinkId } = useShareLinkId();
+
+  const { data: currentPage } = useSWRxCurrentPage();
+  const { data: tagsInfo } = useSWRxTagsInfo(currentPage?._id);
+
+  const { open: openHandsontableModal } = useHandsontableModal();
+
+  const saveOrUpdate = useSaveOrUpdate();
+
+  const saveByHandsontableModal = useCallback(async(table: MarkdownTable, bol: number, eol: number) => {
+    if (currentPage == null || tagsInfo == null || shareLinkId != null) {
+      return;
+    }
+
+    const currentMarkdown = currentPage.revision.body;
+    const newMarkdown = mtu.replaceMarkdownTableInMarkdown(table, currentMarkdown, bol, eol);
+
+    const optionsToSave: OptionsToSave = {
+      isSlackEnabled: false,
+      slackChannels: '',
+      grant: currentPage.grant,
+      grantUserGroupId: currentPage.grantedGroup?._id,
+      grantUserGroupName: currentPage.grantedGroup?.name,
+      pageTags: tagsInfo.tags,
+    };
+
+    try {
+      const currentRevisionId = currentPage.revision._id;
+      await saveOrUpdate(
+        newMarkdown,
+        { pageId: currentPage._id, path: currentPage.path, revisionId: currentRevisionId },
+        optionsToSave,
+      );
+
+      opts?.onSaveSuccess?.();
+    }
+    catch (error) {
+      logger.error('failed to save', error);
+      opts?.onSaveError?.(error);
+    }
+  }, [currentPage, opts, saveOrUpdate, shareLinkId, tagsInfo]);
+
+
+  // set handler to open HandsonTableModal
+  useEffect(() => {
+    if (currentPage == null || shareLinkId != null) {
+      return;
+    }
+
+    const handler = (bol: number, eol: number) => {
+      const markdown = currentPage.revision.body;
+      const currentMarkdownTable = mtu.getMarkdownTableFromLine(markdown, bol, eol);
+      openHandsontableModal(currentMarkdownTable, undefined, false, table => saveByHandsontableModal(table, bol, eol));
+    };
+    globalEmitter.on('launchHandsonTableModal', handler);
+
+    return function cleanup() {
+      globalEmitter.removeListener('launchHandsonTableModal', handler);
+    };
+  }, [currentPage, openHandsontableModal, saveByHandsontableModal, shareLinkId]);
+};

+ 3 - 6
packages/app/src/components/EventListeneres/HashChanged.tsx → packages/app/src/client/services/side-effects/hash-changed.ts

@@ -1,4 +1,4 @@
-import React, { useCallback, useEffect } from 'react';
+import { useCallback, useEffect } from 'react';
 
 import { useRouter } from 'next/router';
 
@@ -8,8 +8,9 @@ import { useEditorMode, determineEditorModeByHash } from '~/stores/ui';
 /**
  * Change editorMode by browser forward/back operation
  */
-const HashChanged = (): JSX.Element => {
+export const useHashChangedEffect = (): void => {
   const router = useRouter();
+
   const { data: isEditable } = useIsEditable();
   const { data: editorMode, mutate: mutateEditorMode } = useEditorMode();
 
@@ -47,8 +48,4 @@ const HashChanged = (): JSX.Element => {
       router.events.off('routeChangeComplete', hashchangeHandler);
     };
   }, [hashchangeHandler, router.events]);
-
-  return <></>;
 };
-
-export default HashChanged;

+ 39 - 0
packages/app/src/client/services/side-effects/page-updated.ts

@@ -0,0 +1,39 @@
+import { useCallback, useEffect } from 'react';
+
+import { SocketEventName } from '~/interfaces/websocket';
+import { useSetRemoteLatestPageData } from '~/stores/remote-latest-page';
+import { useGlobalSocket } from '~/stores/websocket';
+
+export const usePageUpdatedEffect = (): void => {
+
+  const { setRemoteLatestPageData } = useSetRemoteLatestPageData();
+
+  const { data: socket } = useGlobalSocket();
+
+  const setLatestRemotePageData = useCallback((data) => {
+    const { s2cMessagePageUpdated } = data;
+
+    const remoteData = {
+      remoteRevisionId: s2cMessagePageUpdated.revisionId,
+      remoteRevisionBody: s2cMessagePageUpdated.revisionBody,
+      remoteRevisionLastUpdateUser: s2cMessagePageUpdated.remoteLastUpdateUser,
+      remoteRevisionLastUpdatedAt: s2cMessagePageUpdated.revisionUpdateAt,
+      revisionIdHackmdSynced: s2cMessagePageUpdated.revisionIdHackmdSynced,
+      hasDraftOnHackmd: s2cMessagePageUpdated.hasDraftOnHackmd,
+    };
+    setRemoteLatestPageData(remoteData);
+  }, [setRemoteLatestPageData]);
+
+  // listen socket for someone updating this page
+  useEffect(() => {
+
+    if (socket == null) { return }
+
+    socket.on(SocketEventName.PageUpdated, setLatestRemotePageData);
+
+    return () => {
+      socket.off(SocketEventName.PageUpdated, setLatestRemotePageData);
+    };
+
+  }, [setLatestRemotePageData, socket]);
+};

+ 1 - 1
packages/app/src/client/util/toastr.ts

@@ -17,7 +17,7 @@ export const toastError = (err: string | Error | Error[], option: ToastOptions =
 
   for (const err of errs) {
     const message = (typeof err === 'string') ? err : err.message;
-    toast.error(message || err, option);
+    toast.error(message, option);
   }
 };
 

+ 31 - 38
packages/app/src/components/Admin/App/AwsSetting.jsx → packages/app/src/components/Admin/App/AwsSetting.tsx

@@ -1,20 +1,25 @@
-import React from 'react';
-
-import PropTypes from 'prop-types';
 import { useTranslation } from 'next-i18next';
 
-import AdminAppContainer from '~/client/services/AdminAppContainer';
-
-import { withUnstatedContainers } from '../../UnstatedUtils';
-
+export type AwsSettingMoleculeProps = {
+  s3ReferenceFileWithRelayMode
+  s3Region
+  s3CustomEndpoint
+  s3Bucket
+  s3AccessKeyId
+  s3SecretAccessKey
+  onChangeS3ReferenceFileWithRelayMode: (val: boolean) => void
+  onChangeS3Region: (val: string) => void
+  onChangeS3CustomEndpoint: (val: string) => void
+  onChangeS3Bucket: (val: string) => void
+  onChangeS3AccessKeyId: (val: string) => void
+  onChangeS3SecretAccessKey: (val: string) => void
+};
 
-function AwsSetting(props) {
+export const AwsSettingMolecule = (props: AwsSettingMoleculeProps): JSX.Element => {
   const { t } = useTranslation();
-  const { adminAppContainer } = props;
-  const { s3ReferenceFileWithRelayMode } = adminAppContainer.state;
 
   return (
-    <React.Fragment>
+    <>
 
       <div className="row form-group my-3">
         <label className="text-left text-md-right col-md-3 col-form-label">
@@ -31,21 +36,21 @@ function AwsSetting(props) {
               aria-haspopup="true"
               aria-expanded="true"
             >
-              {s3ReferenceFileWithRelayMode && t('admin:app_setting.file_delivery_method_relay')}
-              {!s3ReferenceFileWithRelayMode && t('admin:app_setting.file_delivery_method_redirect')}
+              {props.s3ReferenceFileWithRelayMode && t('admin:app_setting.file_delivery_method_relay')}
+              {!props.s3ReferenceFileWithRelayMode && t('admin:app_setting.file_delivery_method_redirect')}
             </button>
             <div className="dropdown-menu" aria-labelledby="ddS3ReferenceFileWithRelayMode">
               <button
                 className="dropdown-item"
                 type="button"
-                onClick={() => { adminAppContainer.changeS3ReferenceFileWithRelayMode(true) }}
+                onClick={() => { props?.onChangeS3ReferenceFileWithRelayMode(true) }}
               >
                 {t('admin:app_setting.file_delivery_method_relay')}
               </button>
               <button
                 className="dropdown-item"
                 type="button"
-                onClick={() => { adminAppContainer.changeS3ReferenceFileWithRelayMode(false) }}
+                onClick={() => { props?.onChangeS3ReferenceFileWithRelayMode(false) }}
               >
                 { t('admin:app_setting.file_delivery_method_redirect')}
               </button>
@@ -68,9 +73,9 @@ function AwsSetting(props) {
           <input
             className="form-control"
             placeholder={`${t('eg')} ap-northeast-1`}
-            defaultValue={adminAppContainer.state.s3Region || ''}
+            defaultValue={props.s3Region || ''}
             onChange={(e) => {
-              adminAppContainer.changeS3Region(e.target.value);
+              props?.onChangeS3Region(e.target.value);
             }}
           />
         </div>
@@ -85,9 +90,9 @@ function AwsSetting(props) {
             className="form-control"
             type="text"
             placeholder={`${t('eg')} http://localhost:9000`}
-            defaultValue={adminAppContainer.state.s3CustomEndpoint || ''}
+            defaultValue={props.s3CustomEndpoint || ''}
             onChange={(e) => {
-              adminAppContainer.changeS3CustomEndpoint(e.target.value);
+              props?.onChangeS3CustomEndpoint(e.target.value);
             }}
           />
           <p className="form-text text-muted">{t('admin:app_setting.custom_endpoint_change')}</p>
@@ -103,9 +108,9 @@ function AwsSetting(props) {
             className="form-control"
             type="text"
             placeholder={`${t('eg')} crowi`}
-            defaultValue={adminAppContainer.state.s3Bucket || ''}
+            defaultValue={props.s3Bucket || ''}
             onChange={(e) => {
-              adminAppContainer.changeS3Bucket(e.target.value);
+              props.onChangeS3Bucket(e.target.value);
             }}
           />
         </div>
@@ -119,9 +124,9 @@ function AwsSetting(props) {
           <input
             className="form-control"
             type="text"
-            defaultValue={adminAppContainer.state.s3AccessKeyId || ''}
+            defaultValue={props.s3AccessKeyId || ''}
             onChange={(e) => {
-              adminAppContainer.changeS3AccessKeyId(e.target.value);
+              props?.onChangeS3AccessKeyId(e.target.value);
             }}
           />
         </div>
@@ -135,27 +140,15 @@ function AwsSetting(props) {
           <input
             className="form-control"
             type="text"
-            defaultValue={adminAppContainer.state.s3SecretAccessKey || ''}
+            defaultValue={props.s3SecretAccessKey || ''}
             onChange={(e) => {
-              adminAppContainer.changeS3SecretAccessKey(e.target.value);
+              props?.onChangeS3SecretAccessKey(e.target.value);
             }}
           />
         </div>
       </div>
 
 
-    </React.Fragment>
+    </>
   );
-}
-
-
-/**
- * Wrapper component for using unstated
- */
-const AwsSettingWrapper = withUnstatedContainers(AwsSetting, [AdminAppContainer]);
-
-AwsSetting.propTypes = {
-  adminAppContainer: PropTypes.instanceOf(AdminAppContainer).isRequired,
 };
-
-export default AwsSettingWrapper;

+ 9 - 8
packages/app/src/components/Admin/App/ConfirmModal.tsx

@@ -1,15 +1,15 @@
 import React, { FC } from 'react';
+
+import { useTranslation } from 'next-i18next';
 import {
   Modal, ModalHeader, ModalBody, ModalFooter,
 } from 'reactstrap';
-import { useTranslation } from 'next-i18next';
-import { TFunctionResult } from 'i18next';
 
 type ConfirmModalProps = {
   isModalOpen: boolean
-  warningMessage: TFunctionResult
-  supplymentaryMessage: TFunctionResult | null
-  confirmButtonTitle: TFunctionResult
+  warningMessage: string
+  supplymentaryMessage: string | null
+  confirmButtonTitle: string
   onConfirm?: () => Promise<void>
   onCancel?: () => void
 };
@@ -43,13 +43,14 @@ export const ConfirmModal: FC<ConfirmModalProps> = (props: ConfirmModalProps) =>
               <br />
               <br />
               <span className="text-warning">
-                <i className="icon-exclamation icon-fw"></i>
-                {props.supplymentaryMessage}
+                <>
+                  <i className="icon-exclamation icon-fw"></i>
+                  {props.supplymentaryMessage}
+                </>
               </span>
             </>
           )
         }
-
       </ModalBody>
       <ModalFooter>
         <button

+ 158 - 29
packages/app/src/components/Admin/App/FileUploadSetting.tsx

@@ -1,4 +1,4 @@
-import React, { useCallback } from 'react';
+import React, { ChangeEvent, useCallback } from 'react';
 
 import { useTranslation } from 'next-i18next';
 
@@ -8,30 +8,22 @@ import { toastSuccess, toastError } from '~/client/util/apiNotification';
 import { withUnstatedContainers } from '../../UnstatedUtils';
 import AdminUpdateButtonRow from '../Common/AdminUpdateButtonRow';
 
-import AwsSetting from './AwsSetting';
-import GcsSettings from './GcsSettings';
+import { AwsSettingMolecule } from './AwsSetting';
+import type { AwsSettingMoleculeProps } from './AwsSetting';
+import { GcsSettingMolecule } from './GcsSetting';
+import type { GcsSettingMoleculeProps } from './GcsSetting';
 
+const fileUploadTypes = ['aws', 'gcs', 'gridfs', 'local'] as const;
 
-type Props = {
-  adminAppContainer: AdminAppContainer,
-}
-
+type FileUploadSettingMoleculeProps = {
+  fileUploadType: string
+  isFixedFileUploadByEnvVar: boolean
+  envFileUploadType?: string
+  onChangeFileUploadType: (e: ChangeEvent, type: string) => void
+} & AwsSettingMoleculeProps & GcsSettingMoleculeProps;
 
-const FileUploadSetting = (props: Props) => {
+export const FileUploadSettingMolecule = React.memo((props: FileUploadSettingMoleculeProps): JSX.Element => {
   const { t } = useTranslation(['admin', 'commons']);
-  const { adminAppContainer } = props;
-  const { fileUploadType } = adminAppContainer.state;
-  const fileUploadTypes = ['aws', 'gcs', 'gridfs', 'local'];
-
-  const submitHandler = useCallback(async() => {
-    try {
-      await adminAppContainer.updateFileUploadSettingHandler();
-      toastSuccess(t('toaster.update_successed', { target: t('admin:app_setting.file_upload_settings'), ns: 'commons' }));
-    }
-    catch (err) {
-      toastError(err);
-    }
-  }, [adminAppContainer, t]);
 
   return (
     <>
@@ -59,29 +51,166 @@ const FileUploadSetting = (props: Props) => {
                   className="custom-control-input"
                   name="file-upload-type"
                   id={`file-upload-type-radio-${type}`}
-                  checked={adminAppContainer.state.fileUploadType === type}
-                  disabled={adminAppContainer.state.isFixedFileUploadByEnvVar}
-                  onChange={() => { adminAppContainer.changeFileUploadType(type) }}
+                  checked={props.fileUploadType === type}
+                  disabled={props.isFixedFileUploadByEnvVar}
+                  onChange={(e) => { props?.onChangeFileUploadType(e, type) }}
                 />
                 <label className="custom-control-label" htmlFor={`file-upload-type-radio-${type}`}>{t(`admin:app_setting.${type}_label`)}</label>
               </div>
             );
           })}
         </div>
-        {adminAppContainer.state.isFixedFileUploadByEnvVar && (
+        {props.isFixedFileUploadByEnvVar && (
           <p className="alert alert-warning mt-2 text-left offset-3 col-6">
             <i className="icon-exclamation icon-fw">
             </i><b>FIXED</b><br />
             {/* eslint-disable-next-line react/no-danger */}
-            <b dangerouslySetInnerHTML={{ __html: t('admin:app_setting.fixed_by_env_var', { fileUploadType: adminAppContainer.state.envFileUploadType }) }} />
+            <b dangerouslySetInnerHTML={{ __html: t('admin:app_setting.fixed_by_env_var', { fileUploadType: props.envFileUploadType }) }} />
           </p>
         )}
       </div>
 
-      {fileUploadType === 'aws' && <AwsSetting />}
-      {fileUploadType === 'gcs' && <GcsSettings />}
+      {props.fileUploadType === 'aws' && <AwsSettingMolecule
+        s3ReferenceFileWithRelayMode={props.s3ReferenceFileWithRelayMode}
+        s3Region={props.s3Region}
+        s3CustomEndpoint={props.s3CustomEndpoint}
+        s3Bucket={props.s3Bucket}
+        s3AccessKeyId={props.s3AccessKeyId}
+        s3SecretAccessKey={props.s3SecretAccessKey}
+        onChangeS3ReferenceFileWithRelayMode={props.onChangeS3ReferenceFileWithRelayMode}
+        onChangeS3Region={props.onChangeS3Region}
+        onChangeS3CustomEndpoint={props.onChangeS3CustomEndpoint}
+        onChangeS3Bucket={props.onChangeS3Bucket}
+        onChangeS3AccessKeyId={props.onChangeS3AccessKeyId}
+        onChangeS3SecretAccessKey={props.onChangeS3SecretAccessKey}
+      />}
+      {props.fileUploadType === 'gcs' && <GcsSettingMolecule
+        gcsReferenceFileWithRelayMode={props.gcsReferenceFileWithRelayMode}
+        gcsUseOnlyEnvVars={props.gcsUseOnlyEnvVars}
+        gcsApiKeyJsonPath={props.gcsApiKeyJsonPath}
+        gcsBucket={props.gcsBucket}
+        gcsUploadNamespace={props.gcsUploadNamespace}
+        envGcsApiKeyJsonPath={props.envGcsApiKeyJsonPath}
+        envGcsBucket={props.envGcsBucket}
+        envGcsUploadNamespace={props.envGcsUploadNamespace}
+        onChangeGcsReferenceFileWithRelayMode={props.onChangeGcsReferenceFileWithRelayMode}
+        onChangeGcsApiKeyJsonPath={props.onChangeGcsApiKeyJsonPath}
+        onChangeGcsBucket={props.onChangeGcsBucket}
+        onChangeGcsUploadNamespace={props.onChangeGcsUploadNamespace}
+      />}
+    </>
+  );
+});
+FileUploadSettingMolecule.displayName = 'FileUploadSettingMolecule';
+
+
+type FileUploadSettingProps = {
+  adminAppContainer: AdminAppContainer
+}
 
-      <AdminUpdateButtonRow onClick={submitHandler} disabled={adminAppContainer.state.retrieveError != null} />
+const FileUploadSetting = (props: FileUploadSettingProps): JSX.Element => {
+  const { t } = useTranslation(['admin', 'commons']);
+  const { adminAppContainer } = props;
+
+  const {
+    fileUploadType, isFixedFileUploadByEnvVar, envFileUploadType, retrieveError,
+    s3ReferenceFileWithRelayMode,
+    s3Region, s3CustomEndpoint, s3Bucket,
+    s3AccessKeyId, s3SecretAccessKey,
+    gcsReferenceFileWithRelayMode, gcsUseOnlyEnvVars,
+    gcsApiKeyJsonPath, envGcsApiKeyJsonPath, gcsBucket,
+    envGcsBucket, gcsUploadNamespace, envGcsUploadNamespace,
+  } = adminAppContainer.state;
+
+  const submitHandler = useCallback(async() => {
+    try {
+      await adminAppContainer.updateFileUploadSettingHandler();
+      toastSuccess(t('toaster.update_successed', { target: t('admin:app_setting.file_upload_settings'), ns: 'commons' }));
+    }
+    catch (err) {
+      toastError(err);
+    }
+  }, [adminAppContainer, t]);
+
+  const onChangeFileUploadTypeHandler = useCallback((e: ChangeEvent, type: string) => {
+    adminAppContainer.changeFileUploadType(type);
+  }, [adminAppContainer]);
+
+  // S3
+  const onChangeS3ReferenceFileWithRelayModeHandler = useCallback((val: boolean) => {
+    adminAppContainer.changeS3ReferenceFileWithRelayMode(val);
+  }, [adminAppContainer]);
+
+  const onChangeS3RegionHandler = useCallback((val: string) => {
+    adminAppContainer.changeS3Region(val);
+  }, [adminAppContainer]);
+
+  const onChangeS3CustomEndpointHandler = useCallback((val: string) => {
+    adminAppContainer.changeS3CustomEndpoint(val);
+  }, [adminAppContainer]);
+
+  const onChangeS3BucketHandler = useCallback((val: string) => {
+    adminAppContainer.changeS3Bucket(val);
+  }, [adminAppContainer]);
+
+  const onChangeS3AccessKeyIdHandler = useCallback((val: string) => {
+    adminAppContainer.changeS3AccessKeyId(val);
+  }, [adminAppContainer]);
+
+  const onChangeS3SecretAccessKeyHandler = useCallback((val: string) => {
+    adminAppContainer.changeS3SecretAccessKey(val);
+  }, [adminAppContainer]);
+
+  // GCS
+  const onChangeGcsReferenceFileWithRelayModeHandler = useCallback((val: boolean) => {
+    adminAppContainer.changeGcsReferenceFileWithRelayMode(val);
+  }, [adminAppContainer]);
+
+  const onChangeGcsApiKeyJsonPathHandler = useCallback((val: string) => {
+    adminAppContainer.changeGcsApiKeyJsonPath(val);
+  }, [adminAppContainer]);
+
+  const onChangeGcsBucketHandler = useCallback((val: string) => {
+    adminAppContainer.changeGcsBucket(val);
+  }, [adminAppContainer]);
+
+  const onChangeGcsUploadNamespaceHandler = useCallback((val: string) => {
+    adminAppContainer.changeGcsUploadNamespace(val);
+  }, [adminAppContainer]);
+
+  return (
+    <>
+      <FileUploadSettingMolecule
+        fileUploadType={fileUploadType}
+        isFixedFileUploadByEnvVar={isFixedFileUploadByEnvVar}
+        envFileUploadType={envFileUploadType}
+        onChangeFileUploadType={onChangeFileUploadTypeHandler}
+        s3ReferenceFileWithRelayMode={s3ReferenceFileWithRelayMode}
+        s3Region={s3Region}
+        s3CustomEndpoint={s3CustomEndpoint}
+        s3Bucket={s3Bucket}
+        s3AccessKeyId={s3AccessKeyId}
+        s3SecretAccessKey={s3SecretAccessKey}
+        onChangeS3ReferenceFileWithRelayMode={onChangeS3ReferenceFileWithRelayModeHandler}
+        onChangeS3Region={onChangeS3RegionHandler}
+        onChangeS3CustomEndpoint={onChangeS3CustomEndpointHandler}
+        onChangeS3Bucket={onChangeS3BucketHandler}
+        onChangeS3AccessKeyId={onChangeS3AccessKeyIdHandler}
+        onChangeS3SecretAccessKey={onChangeS3SecretAccessKeyHandler}
+        gcsReferenceFileWithRelayMode={gcsReferenceFileWithRelayMode}
+        gcsUseOnlyEnvVars={gcsUseOnlyEnvVars}
+        gcsApiKeyJsonPath={gcsApiKeyJsonPath}
+        gcsBucket={gcsBucket}
+        gcsUploadNamespace={gcsUploadNamespace}
+        envGcsApiKeyJsonPath={envGcsApiKeyJsonPath}
+        envGcsBucket={envGcsBucket}
+        envGcsUploadNamespace={envGcsUploadNamespace}
+        onChangeGcsReferenceFileWithRelayMode={onChangeGcsReferenceFileWithRelayModeHandler}
+        onChangeGcsApiKeyJsonPath={onChangeGcsApiKeyJsonPathHandler}
+        onChangeGcsBucket={onChangeGcsBucketHandler}
+        onChangeGcsUploadNamespace={onChangeGcsUploadNamespaceHandler}
+      />
+      <AdminUpdateButtonRow onClick={submitHandler} disabled={retrieveError != null} />
     </>
   );
 };

+ 37 - 34
packages/app/src/components/Admin/App/GcsSettings.jsx → packages/app/src/components/Admin/App/GcsSetting.tsx

@@ -1,18 +1,33 @@
-
-import React from 'react';
-
-import PropTypes from 'prop-types';
 import { useTranslation } from 'next-i18next';
 
-import AdminAppContainer from '~/client/services/AdminAppContainer';
-
-import { withUnstatedContainers } from '../../UnstatedUtils';
-
+export type GcsSettingMoleculeProps = {
+  gcsReferenceFileWithRelayMode
+  gcsUseOnlyEnvVars
+  gcsApiKeyJsonPath
+  gcsBucket
+  gcsUploadNamespace
+  envGcsApiKeyJsonPath?
+  envGcsBucket?
+  envGcsUploadNamespace?
+  onChangeGcsReferenceFileWithRelayMode: (val: boolean) => void
+  onChangeGcsApiKeyJsonPath: (val: string) => void
+  onChangeGcsBucket: (val: string) => void
+  onChangeGcsUploadNamespace: (val: string) => void
+};
 
-const GcsSetting = (props) => {
+export const GcsSettingMolecule = (props: GcsSettingMoleculeProps): JSX.Element => {
   const { t } = useTranslation();
-  const { adminAppContainer } = props;
-  const { gcsReferenceFileWithRelayMode, gcsUseOnlyEnvVars } = adminAppContainer.state;
+
+  const {
+    gcsReferenceFileWithRelayMode,
+    gcsUseOnlyEnvVars,
+    gcsApiKeyJsonPath,
+    envGcsApiKeyJsonPath,
+    gcsBucket,
+    envGcsBucket,
+    gcsUploadNamespace,
+    envGcsUploadNamespace,
+  } = props;
 
   return (
     <>
@@ -39,14 +54,14 @@ const GcsSetting = (props) => {
               <button
                 className="dropdown-item"
                 type="button"
-                onClick={() => { adminAppContainer.changeGcsReferenceFileWithRelayMode(true) }}
+                onClick={() => { props?.onChangeGcsReferenceFileWithRelayMode(true) }}
               >
                 {t('admin:app_setting.file_delivery_method_relay')}
               </button>
               <button
                 className="dropdown-item"
                 type="button"
-                onClick={() => { adminAppContainer.changeGcsReferenceFileWithRelayMode(false) }}
+                onClick={() => { props?.onChangeGcsReferenceFileWithRelayMode(false) }}
               >
                 { t('admin:app_setting.file_delivery_method_redirect')}
               </button>
@@ -90,12 +105,12 @@ const GcsSetting = (props) => {
                 type="text"
                 name="gcsApiKeyJsonPath"
                 readOnly={gcsUseOnlyEnvVars}
-                defaultValue={adminAppContainer.state.gcsApiKeyJsonPath}
-                onChange={e => adminAppContainer.changeGcsApiKeyJsonPath(e.target.value)}
+                defaultValue={gcsApiKeyJsonPath}
+                onChange={e => props?.onChangeGcsApiKeyJsonPath(e.target.value)}
               />
             </td>
             <td>
-              <input className="form-control" type="text" value={adminAppContainer.state.envGcsApiKeyJsonPath || ''} readOnly tabIndex="-1" />
+              <input className="form-control" type="text" value={envGcsApiKeyJsonPath || ''} readOnly tabIndex={-1} />
               <p className="form-text text-muted">
                 {/* eslint-disable-next-line react/no-danger */}
                 <small dangerouslySetInnerHTML={{ __html: t('admin:app_setting.use_env_var_if_empty', { variable: 'GCS_API_KEY_JSON_PATH' }) }} />
@@ -110,12 +125,12 @@ const GcsSetting = (props) => {
                 type="text"
                 name="gcsBucket"
                 readOnly={gcsUseOnlyEnvVars}
-                defaultValue={adminAppContainer.state.gcsBucket}
-                onChange={e => adminAppContainer.changeGcsBucket(e.target.value)}
+                defaultValue={gcsBucket}
+                onChange={e => props?.onChangeGcsBucket(e.target.value)}
               />
             </td>
             <td>
-              <input className="form-control" type="text" value={adminAppContainer.state.envGcsBucket || ''} readOnly tabIndex="-1" />
+              <input className="form-control" type="text" value={envGcsBucket || ''} readOnly tabIndex={-1} />
               <p className="form-text text-muted">
                 {/* eslint-disable-next-line react/no-danger */}
                 <small dangerouslySetInnerHTML={{ __html: t('admin:app_setting.use_env_var_if_empty', { variable: 'GCS_BUCKET' }) }} />
@@ -130,12 +145,12 @@ const GcsSetting = (props) => {
                 type="text"
                 name="gcsUploadNamespace"
                 readOnly={gcsUseOnlyEnvVars}
-                defaultValue={adminAppContainer.state.gcsUploadNamespace}
-                onChange={e => adminAppContainer.changeGcsUploadNamespace(e.target.value)}
+                defaultValue={gcsUploadNamespace}
+                onChange={e => props?.onChangeGcsUploadNamespace(e.target.value)}
               />
             </td>
             <td>
-              <input className="form-control" type="text" value={adminAppContainer.state.envGcsUploadNamespace || ''} readOnly tabIndex="-1" />
+              <input className="form-control" type="text" value={envGcsUploadNamespace || ''} readOnly tabIndex={-1} />
               <p className="form-text text-muted">
                 {/* eslint-disable-next-line react/no-danger */}
                 <small dangerouslySetInnerHTML={{ __html: t('admin:app_setting.use_env_var_if_empty', { variable: 'GCS_UPLOAD_NAMESPACE' }) }} />
@@ -147,16 +162,4 @@ const GcsSetting = (props) => {
 
     </>
   );
-
 };
-
-/**
- * Wrapper component for using unstated
- */
-const GcsSettingWrapper = withUnstatedContainers(GcsSetting, [AdminAppContainer]);
-
-GcsSetting.propTypes = {
-  adminAppContainer: PropTypes.instanceOf(AdminAppContainer).isRequired,
-};
-
-export default GcsSettingWrapper;

+ 5 - 2
packages/app/src/components/Admin/Common/AdminNavigation.jsx

@@ -12,7 +12,7 @@ import { useGrowiCloudUri, useGrowiAppIdForGrowiCloud } from '../../../stores/co
 // import { withUnstatedContainers } from '../../UnstatedUtils';
 
 const AdminNavigation = (props) => {
-  const { t } = useTranslation('admin');
+  const { t } = useTranslation(['admin', 'commons']);
   // const { appContainer } = props;
   const pathname = window.location.pathname;
 
@@ -29,14 +29,15 @@ const AdminNavigation = (props) => {
       case 'customize':                return <><i className="mr-1 icon-fw icon-wrench"></i>{          t('customize_settings.customize_settings') }</>;
       case 'importer':                 return <><i className="mr-1 icon-fw icon-cloud-upload"></i>{    t('importer_management.import_data') }</>;
       case 'export':                   return <><i className="mr-1 icon-fw icon-cloud-download"></i>{  t('export_management.export_archive_data') }</>;
+      case 'data-transfer':            return <><i className="mr-1 icon-fw icon-plane"></i>{           t('g2g_data_transfer.data_transfer', { ns: 'commons' })}</>;
       case 'notification':             return <><i className="mr-1 icon-fw icon-bell"></i>{            t('external_notification.external_notification')}</>;
       case 'slack-integration':        return <><i className="mr-1 icon-fw icon-shuffle"></i>{         t('slack_integration.slack_integration') }</>;
       case 'slack-integration-legacy': return <><i className="mr-1 icon-fw icon-shuffle"></i>{         t('slack_integration_legacy.slack_integration_legacy')}</>;
       case 'users':                    return <><i className="mr-1 icon-fw icon-user"></i>{            t('user_management.user_management') }</>;
       case 'user-groups':              return <><i className="mr-1 icon-fw icon-people"></i>{          t('user_group_management.user_group_management') }</>;
-      case 'search':                   return <><i className="mr-1 icon-fw icon-magnifier"></i>{       t('full_text_search_management.full_text_search_management') }</>;
       case 'audit-log':                return <><i className="mr-1 icon-fw icon-feed"></i>{            t('audit_log_management.audit_log')}</>;
       case 'plugins':                  return <><i className="mr-1 icon-fw icon-puzzle"></i>{          t('plugins.plugins')}</>;
+      case 'search':                   return <><i className="mr-1 icon-fw icon-magnifier"></i>{       t('full_text_search_management.full_text_search_management') }</>;
       case 'cloud':                    return <><i className="mr-1 icon-fw icon-share-alt"></i>{       t('cloud_setting_management.to_cloud_settings')} </>;
       default:                         return <><i className="mr-1 icon-fw icon-home"></i>{            t('wiki_management_home_page') }</>;
       /* eslint-enable no-multi-spaces, max-len */
@@ -86,6 +87,7 @@ const AdminNavigation = (props) => {
         <MenuLink menu="customize"    isListGroupItems isActive={isActiveMenu('/customize')} />
         <MenuLink menu="importer"     isListGroupItems isActive={isActiveMenu('/importer')} />
         <MenuLink menu="export"       isListGroupItems isActive={isActiveMenu('/export')} />
+        <MenuLink menu="data-transfer" isListGroupItems isActive={isActiveMenu('/data-transfer')} />
         <MenuLink menu="notification" isListGroupItems isActive={isActiveMenu('/notification') || isActiveMenu('/global-notification')} />
         <MenuLink menu="slack-integration" isListGroupItems isActive={isActiveMenu('/slack-integration')} />
         <MenuLink menu="slack-integration-legacy" isListGroupItems isActive={isActiveMenu('/slack-integration-legacy')} />
@@ -143,6 +145,7 @@ const AdminNavigation = (props) => {
             {isActiveMenu('/search') &&            <MenuLabel menu="search" />}
             {isActiveMenu('/audit-log') &&         <MenuLabel menu="audit-log" />}
             {isActiveMenu('/plugins') &&           <MenuLabel menu="plugins" />}
+            {isActiveMenu('/data-transfer') &&     <MenuLabel menu="data-transfer" />}
             {/* eslint-enable no-multi-spaces */}
           </span>
         </button>

+ 3 - 4
packages/app/src/components/Admin/Customize/CustomizeThemeSetting.tsx

@@ -3,7 +3,6 @@ import React, { useCallback, useEffect, useState } from 'react';
 import { PresetThemes, PresetThemesMetadatas } from '@growi/preset-themes';
 import { useTranslation } from 'next-i18next';
 
-import { apiv3Put } from '~/client/util/apiv3-client';
 import { toastSuccess, toastError, toastWarning } from '~/client/util/toastr';
 import { useSWRxGrowiThemeSetting } from '~/stores/admin/customize';
 
@@ -20,7 +19,7 @@ type Props = {
 const CustomizeThemeSetting = (props: Props): JSX.Element => {
   const { t } = useTranslation();
 
-  const { data, error } = useSWRxGrowiThemeSetting();
+  const { data, error, update } = useSWRxGrowiThemeSetting();
   const [currentTheme, setCurrentTheme] = useState(data?.currentTheme);
 
   useEffect(() => {
@@ -38,7 +37,7 @@ const CustomizeThemeSetting = (props: Props): JSX.Element => {
     }
 
     try {
-      await apiv3Put('/customize-setting/theme', {
+      await update({
         theme: currentTheme,
       });
 
@@ -47,7 +46,7 @@ const CustomizeThemeSetting = (props: Props): JSX.Element => {
     catch (err) {
       toastError(err);
     }
-  }, [currentTheme, t]);
+  }, [currentTheme, t, update]);
 
   const availableThemes = data?.pluginThemesMetadatas == null
     ? PresetThemesMetadatas

+ 0 - 250
packages/app/src/components/Admin/ExportArchiveData/SelectCollectionsModal.jsx

@@ -1,250 +0,0 @@
-import React from 'react';
-
-import PropTypes from 'prop-types';
-import { useTranslation } from 'next-i18next';
-import {
-  Modal, ModalHeader, ModalBody, ModalFooter,
-} from 'reactstrap';
-import * as toastr from 'toastr';
-
-import { apiPost } from '~/client/util/apiv1-client';
-
-// import { toastSuccess, toastError } from '~/client/util/apiNotification';
-
-
-const GROUPS_PAGE = [
-  'pages', 'revisions', 'tags', 'pagetagrelations', 'pageredirects', 'comments', 'sharelinks',
-];
-const GROUPS_USER = [
-  'users', 'externalaccounts', 'usergroups', 'usergrouprelations',
-  'useruisettings', 'editorsettings', 'bookmarks', 'subscriptions',
-  'inappnotificationsettings',
-];
-const GROUPS_CONFIG = [
-  'configs', 'updateposts', 'globalnotificationsettings', 'slackappintegrations',
-];
-const ALL_GROUPED_COLLECTIONS = GROUPS_PAGE.concat(GROUPS_USER).concat(GROUPS_CONFIG);
-
-class SelectCollectionsModal extends React.Component {
-
-  constructor(props) {
-    super(props);
-
-    this.state = {
-      selectedCollections: new Set(),
-    };
-
-    this.toggleCheckbox = this.toggleCheckbox.bind(this);
-    this.checkAll = this.checkAll.bind(this);
-    this.uncheckAll = this.uncheckAll.bind(this);
-    this.export = this.export.bind(this);
-    this.validateForm = this.validateForm.bind(this);
-  }
-
-  toggleCheckbox(e) {
-    const { target } = e;
-    const { name, checked } = target;
-
-    this.setState((prevState) => {
-      const selectedCollections = new Set(prevState.selectedCollections);
-      if (checked) {
-        selectedCollections.add(name);
-      }
-      else {
-        selectedCollections.delete(name);
-      }
-
-      return { selectedCollections };
-    });
-  }
-
-  checkAll() {
-    this.setState({ selectedCollections: new Set(this.props.collections) });
-  }
-
-  uncheckAll() {
-    this.setState({ selectedCollections: new Set() });
-  }
-
-  async export(e) {
-    e.preventDefault();
-
-    try {
-      // TODO: use apiv3Post
-      const result = await apiPost('/v3/export', { collections: Array.from(this.state.selectedCollections) });
-      // TODO: toastSuccess, toastError
-
-      if (!result.ok) {
-        throw new Error('Error occured.');
-      }
-
-      // TODO: toastSuccess, toastError
-      toastr.success(undefined, 'Export process has requested.', {
-        closeButton: true,
-        progressBar: true,
-        newestOnTop: false,
-        showDuration: '100',
-        hideDuration: '100',
-        timeOut: '1200',
-        extendedTimeOut: '150',
-      });
-
-      this.props.onExportingRequested();
-      this.props.onClose();
-
-      this.setState({ selectedCollections: new Set() });
-
-    }
-    catch (err) {
-      // TODO: toastSuccess, toastError
-      toastr.error(err, 'Error', {
-        closeButton: true,
-        progressBar: true,
-        newestOnTop: false,
-        showDuration: '100',
-        hideDuration: '100',
-        timeOut: '3000',
-      });
-    }
-  }
-
-  validateForm() {
-    return this.state.selectedCollections.size > 0;
-  }
-
-  renderWarnForUser() {
-    // whether this.state.selectedCollections includes one of GROUPS_USER
-    const isUserRelatedDataSelected = GROUPS_USER.some((collectionName) => {
-      return this.state.selectedCollections.has(collectionName);
-    });
-
-    if (!isUserRelatedDataSelected) {
-      return <></>;
-    }
-
-    const html = this.props.t('admin:export_management.desc_password_seed');
-
-    // eslint-disable-next-line react/no-danger
-    return <div className="card well" dangerouslySetInnerHTML={{ __html: html }}></div>;
-  }
-
-  renderGroups(groupList, color) {
-    const collectionNames = groupList.filter((collectionName) => {
-      return this.props.collections.includes(collectionName);
-    });
-
-    return this.renderCheckboxes(collectionNames, color);
-  }
-
-  renderOthers() {
-    const collectionNames = this.props.collections.filter((collectionName) => {
-      return !ALL_GROUPED_COLLECTIONS.includes(collectionName);
-    });
-
-    return this.renderCheckboxes(collectionNames);
-  }
-
-  renderCheckboxes(collectionNames, color) {
-    const checkboxColor = color ? `custom-checkbox-${color}` : 'custom-checkbox-info';
-
-    return (
-      <div className={`custom-control custom-checkbox ${checkboxColor}`}>
-        <div className="row">
-          {collectionNames.map((collectionName) => {
-            return (
-              <div className="col-sm-6 my-1" key={collectionName}>
-                <input
-                  type="checkbox"
-                  className="custom-control-input"
-                  id={collectionName}
-                  name={collectionName}
-                  value={collectionName}
-                  checked={this.state.selectedCollections.has(collectionName)}
-                  onChange={this.toggleCheckbox}
-                />
-                <label className="text-capitalize custom-control-label ml-3" htmlFor={collectionName}>
-                  {collectionName}
-                </label>
-              </div>
-            );
-          })}
-        </div>
-      </div>
-    );
-  }
-
-  render() {
-    const { t } = this.props;
-
-    return (
-      <Modal isOpen={this.props.isOpen} toggle={this.props.onClose}>
-        <ModalHeader tag="h4" toggle={this.props.onClose} className="bg-info text-light">
-          {t('admin:export_management.export_collections')}
-        </ModalHeader>
-
-        <form onSubmit={this.export}>
-          <ModalBody>
-            <div className="row">
-              <div className="col-sm-12">
-                <button type="button" className="btn btn-sm btn-outline-secondary mr-2" onClick={this.checkAll}>
-                  <i className="fa fa-check-square-o"></i> {t('admin:export_management.check_all')}
-                </button>
-                <button type="button" className="btn btn-sm btn-outline-secondary mr-2" onClick={this.uncheckAll}>
-                  <i className="fa fa-square-o"></i> {t('admin:export_management.uncheck_all')}
-                </button>
-              </div>
-            </div>
-            <div className="row mt-4">
-              <div className="col-sm-12">
-                <h3 className="admin-setting-header">MongoDB Page Collections</h3>
-                {this.renderGroups(GROUPS_PAGE)}
-              </div>
-            </div>
-            <div className="row mt-4">
-              <div className="col-sm-12">
-                <h3 className="admin-setting-header">MongoDB User Collections</h3>
-                {this.renderGroups(GROUPS_USER, 'danger')}
-                {this.renderWarnForUser()}
-              </div>
-            </div>
-            <div className="row mt-4">
-              <div className="col-sm-12">
-                <h3 className="admin-setting-header">MongoDB Config Collections</h3>
-                {this.renderGroups(GROUPS_CONFIG)}
-              </div>
-            </div>
-            <div className="row mt-4">
-              <div className="col-sm-12">
-                <h3 className="admin-setting-header">MongoDB Other Collections</h3>
-                {this.renderOthers()}
-              </div>
-            </div>
-          </ModalBody>
-
-          <ModalFooter>
-            <button type="button" className="btn btn-sm btn-outline-secondary" onClick={this.props.onClose}>{t('admin:export_management.cancel')}</button>
-            <button type="submit" className="btn btn-sm btn-primary" disabled={!this.validateForm()}>{t('admin:export_management.export')}</button>
-          </ModalFooter>
-        </form>
-      </Modal>
-    );
-  }
-
-}
-
-SelectCollectionsModal.propTypes = {
-  t: PropTypes.func.isRequired, // i18next
-
-  isOpen: PropTypes.bool.isRequired,
-  onExportingRequested: PropTypes.func.isRequired,
-  onClose: PropTypes.func.isRequired,
-  collections: PropTypes.arrayOf(PropTypes.string).isRequired,
-};
-
-const SelectCollectionsModalWrapperFc = (props) => {
-  const { t } = useTranslation();
-
-  return <SelectCollectionsModal t={t} {...props} />;
-};
-
-export default SelectCollectionsModalWrapperFc;

+ 232 - 0
packages/app/src/components/Admin/ExportArchiveData/SelectCollectionsModal.tsx

@@ -0,0 +1,232 @@
+import React, { useCallback, useState, useEffect } from 'react';
+
+import { useTranslation } from 'next-i18next';
+import {
+  Modal, ModalHeader, ModalBody, ModalFooter,
+} from 'reactstrap';
+import * as toastr from 'toastr';
+
+import { apiPost } from '~/client/util/apiv1-client';
+
+// import { toastSuccess, toastError } from '~/client/util/apiNotification';
+
+
+const GROUPS_PAGE = [
+  'pages', 'revisions', 'tags', 'pagetagrelations', 'pageredirects', 'comments', 'sharelinks',
+];
+const GROUPS_USER = [
+  'users', 'externalaccounts', 'usergroups', 'usergrouprelations',
+  'useruisettings', 'editorsettings', 'bookmarks', 'subscriptions',
+  'inappnotificationsettings',
+];
+const GROUPS_CONFIG = [
+  'configs', 'updateposts', 'globalnotificationsettings', 'slackappintegrations',
+];
+const ALL_GROUPED_COLLECTIONS = GROUPS_PAGE.concat(GROUPS_USER).concat(GROUPS_CONFIG);
+
+type Props = {
+  isOpen: boolean,
+  onExportingRequested: () => void,
+  onClose: () => void,
+  collections: string[],
+  isAllChecked?: boolean,
+};
+
+const SelectCollectionsModal = (props: Props): JSX.Element => {
+  const { t } = useTranslation();
+
+  const {
+    isOpen, onExportingRequested, onClose, collections, isAllChecked,
+  } = props;
+
+  const [selectedCollections, setSelectedCollections] = useState<Set<string>>(new Set());
+
+  const toggleCheckbox = useCallback((e) => {
+    const { target } = e;
+    const { name, checked } = target;
+
+    setSelectedCollections((prevState) => {
+      const selectedCollections = new Set(prevState);
+      if (checked) {
+        selectedCollections.add(name);
+      }
+      else {
+        selectedCollections.delete(name);
+      }
+
+      return selectedCollections;
+    });
+  }, []);
+
+  const checkAll = useCallback(() => {
+    setSelectedCollections(new Set(collections));
+  }, [collections]);
+
+  const uncheckAll = useCallback(() => {
+    setSelectedCollections(new Set());
+  }, []);
+
+  const doExport = useCallback(async(e) => {
+    e.preventDefault();
+
+    try {
+      // TODO: use apiv3Post
+      const result = await apiPost<any>('/v3/export', { collections: Array.from(selectedCollections) });
+      // TODO: toastSuccess, toastError
+
+      if (!result.ok) {
+        throw new Error('Error occured.');
+      }
+
+      // TODO: toastSuccess, toastError
+      toastr.success(undefined, 'Export process has requested.', {
+        closeButton: true,
+        progressBar: true,
+        newestOnTop: false,
+        showDuration: '100',
+        hideDuration: '100',
+        timeOut: '1200',
+        extendedTimeOut: '150',
+      });
+
+      onExportingRequested();
+      onClose();
+      uncheckAll();
+    }
+    catch (err) {
+      // TODO: toastSuccess, toastError
+      toastr.error(err, 'Error', {
+        closeButton: true,
+        progressBar: true,
+        newestOnTop: false,
+        showDuration: '100',
+        hideDuration: '100',
+        timeOut: '3000',
+      });
+    }
+  }, [onClose, onExportingRequested, selectedCollections, uncheckAll]);
+
+  const validateForm = useCallback(() => {
+    return selectedCollections.size > 0;
+  }, [selectedCollections.size]);
+
+  const renderWarnForUser = useCallback(() => {
+    // whether selectedCollections includes one of GROUPS_USER
+    const isUserRelatedDataSelected = GROUPS_USER.some((collectionName) => {
+      return selectedCollections.has(collectionName);
+    });
+
+    if (!isUserRelatedDataSelected) {
+      return <></>;
+    }
+
+    const html = t('admin:export_management.desc_password_seed');
+
+    // eslint-disable-next-line react/no-danger
+    return <div className="card well" dangerouslySetInnerHTML={{ __html: html }}></div>;
+  }, [selectedCollections, t]);
+
+  const renderCheckboxes = useCallback((collectionNames, color?) => {
+    const checkboxColor = color ? `custom-checkbox-${color}` : 'custom-checkbox-info';
+
+    return (
+      <div className={`custom-control custom-checkbox ${checkboxColor}`}>
+        <div className="row">
+          {collectionNames.map((collectionName) => {
+            return (
+              <div className="col-sm-6 my-1" key={collectionName}>
+                <input
+                  type="checkbox"
+                  className="custom-control-input"
+                  id={collectionName}
+                  name={collectionName}
+                  value={collectionName}
+                  checked={selectedCollections.has(collectionName)}
+                  onChange={toggleCheckbox}
+                />
+                <label className="text-capitalize custom-control-label ml-3" htmlFor={collectionName}>
+                  {collectionName}
+                </label>
+              </div>
+            );
+          })}
+        </div>
+      </div>
+    );
+  }, [selectedCollections, toggleCheckbox]);
+
+  const renderGroups = useCallback((groupList, color?) => {
+    const collectionNames = groupList.filter((collectionName) => {
+      return collections.includes(collectionName);
+    });
+
+    return renderCheckboxes(collectionNames, color);
+  }, [collections, renderCheckboxes]);
+
+  const renderOthers = useCallback(() => {
+    const collectionNames = collections.filter((collectionName) => {
+      return !ALL_GROUPED_COLLECTIONS.includes(collectionName);
+    });
+
+    return renderCheckboxes(collectionNames);
+  }, [collections, renderCheckboxes]);
+
+  useEffect(() => {
+    if (isAllChecked) checkAll();
+  }, [isAllChecked, checkAll]);
+
+  return (
+    <Modal isOpen={isOpen} toggle={onClose}>
+      <ModalHeader tag="h4" toggle={onClose} className="bg-info text-light">
+        {t('admin:export_management.export_collections')}
+      </ModalHeader>
+
+      <form onSubmit={doExport}>
+        <ModalBody>
+          <div className="row">
+            <div className="col-sm-12">
+              <button type="button" className="btn btn-sm btn-outline-secondary mr-2" onClick={checkAll}>
+                <i className="fa fa-check-square-o"></i> {t('admin:export_management.check_all')}
+              </button>
+              <button type="button" className="btn btn-sm btn-outline-secondary mr-2" onClick={uncheckAll}>
+                <i className="fa fa-square-o"></i> {t('admin:export_management.uncheck_all')}
+              </button>
+            </div>
+          </div>
+          <div className="row mt-4">
+            <div className="col-sm-12">
+              <h3 className="admin-setting-header">MongoDB Page Collections</h3>
+              {renderGroups(GROUPS_PAGE)}
+            </div>
+          </div>
+          <div className="row mt-4">
+            <div className="col-sm-12">
+              <h3 className="admin-setting-header">MongoDB User Collections</h3>
+              {renderGroups(GROUPS_USER, 'danger')}
+              {renderWarnForUser()}
+            </div>
+          </div>
+          <div className="row mt-4">
+            <div className="col-sm-12">
+              <h3 className="admin-setting-header">MongoDB Config Collections</h3>
+              {renderGroups(GROUPS_CONFIG)}
+            </div>
+          </div>
+          <div className="row mt-4">
+            <div className="col-sm-12">
+              <h3 className="admin-setting-header">MongoDB Other Collections</h3>
+              {renderOthers()}
+            </div>
+          </div>
+        </ModalBody>
+
+        <ModalFooter>
+          <button type="button" className="btn btn-sm btn-outline-secondary" onClick={onClose}>{t('admin:export_management.cancel')}</button>
+          <button type="submit" className="btn btn-sm btn-primary" disabled={!validateForm()}>{t('admin:export_management.export')}</button>
+        </ModalFooter>
+      </form>
+    </Modal>
+  );
+};
+
+export default SelectCollectionsModal;

+ 0 - 261
packages/app/src/components/Admin/ExportArchiveDataPage.jsx

@@ -1,261 +0,0 @@
-import React from 'react';
-
-import { useTranslation } from 'next-i18next';
-import PropTypes from 'prop-types';
-import * as toastr from 'toastr';
-
-
-// import { toastSuccess, toastError } from '~/client/util/apiNotification';
-import { apiDelete, apiGet } from '~/client/util/apiv1-client';
-import { useAdminSocket } from '~/stores/socket-io';
-
-import LabeledProgressBar from './Common/LabeledProgressBar';
-import ArchiveFilesTable from './ExportArchiveData/ArchiveFilesTable';
-import SelectCollectionsModal from './ExportArchiveData/SelectCollectionsModal';
-
-
-const IGNORED_COLLECTION_NAMES = [
-  'sessions', 'rlflx', 'activities',
-];
-
-class ExportArchiveDataPage extends React.Component {
-
-  constructor(props) {
-    super(props);
-
-    this.state = {
-      collections: [],
-      zipFileStats: [],
-      progressList: [],
-      isExportModalOpen: false,
-      isExporting: false,
-      isZipping: false,
-      isExported: false,
-    };
-
-    this.onZipFileStatAdd = this.onZipFileStatAdd.bind(this);
-    this.onZipFileStatRemove = this.onZipFileStatRemove.bind(this);
-    this.openExportModal = this.openExportModal.bind(this);
-    this.closeExportModal = this.closeExportModal.bind(this);
-    this.exportingRequestedHandler = this.exportingRequestedHandler.bind(this);
-  }
-
-  async UNSAFE_componentWillMount() {
-    // TODO:: use apiv3.get
-    // eslint-disable-next-line no-unused-vars
-    const [{ collections }, { status }] = await Promise.all([
-      apiGet('/v3/mongo/collections', {}),
-      apiGet('/v3/export/status', {}),
-    ]);
-    // TODO: toastSuccess, toastError
-
-    // filter only not ignored collection names
-    const filteredCollections = collections.filter((collectionName) => {
-      return !IGNORED_COLLECTION_NAMES.includes(collectionName);
-    });
-
-    const { zipFileStats, isExporting, progressList } = status;
-    this.setState({
-      collections: filteredCollections,
-      zipFileStats,
-      isExporting,
-      progressList,
-    });
-
-    this.setupWebsocketEventHandler();
-  }
-
-  setupWebsocketEventHandler() {
-    const { socket } = this.props;
-
-    if (socket != null) {
-      // websocket event
-      socket.on('admin:onProgressForExport', ({ currentCount, totalCount, progressList }) => {
-        this.setState({
-          isExporting: true,
-          progressList,
-        });
-      });
-
-      // websocket event
-      socket.on('admin:onStartZippingForExport', () => {
-        this.setState({
-          isZipping: true,
-        });
-      });
-
-      // websocket event
-      socket.on('admin:onTerminateForExport', ({ addedZipFileStat }) => {
-        const zipFileStats = this.state.zipFileStats.concat([addedZipFileStat]);
-
-        this.setState({
-          isExporting: false,
-          isZipping: false,
-          isExported: true,
-          zipFileStats,
-        });
-
-        // TODO: toastSuccess, toastError
-        toastr.success(undefined, `New Archive Data '${addedZipFileStat.fileName}' is added`, {
-          closeButton: true,
-          progressBar: true,
-          newestOnTop: false,
-          showDuration: '100',
-          hideDuration: '100',
-          timeOut: '1200',
-          extendedTimeOut: '150',
-        });
-      });
-    }
-  }
-
-  onZipFileStatAdd(newStat) {
-    this.setState((prevState) => {
-      return {
-        zipFileStats: [...prevState.zipFileStats, newStat],
-      };
-    });
-  }
-
-  async onZipFileStatRemove(fileName) {
-    try {
-      await apiDelete(`/v3/export/${fileName}`, {});
-
-      this.setState((prevState) => {
-        return {
-          zipFileStats: prevState.zipFileStats.filter(stat => stat.fileName !== fileName),
-        };
-      });
-
-      // TODO: toastSuccess, toastError
-      toastr.success(undefined, `Deleted ${fileName}`, {
-        closeButton: true,
-        progressBar: true,
-        newestOnTop: false,
-        showDuration: '100',
-        hideDuration: '100',
-        timeOut: '1200',
-        extendedTimeOut: '150',
-      });
-    }
-    catch (err) {
-      // TODO: toastSuccess, toastError
-      toastr.error(err, 'Error', {
-        closeButton: true,
-        progressBar: true,
-        newestOnTop: false,
-        showDuration: '100',
-        hideDuration: '100',
-        timeOut: '3000',
-      });
-    }
-  }
-
-  openExportModal() {
-    this.setState({ isExportModalOpen: true });
-  }
-
-  closeExportModal() {
-    this.setState({ isExportModalOpen: false });
-  }
-
-  /**
-   * event handler invoked when export process was requested successfully
-   */
-  exportingRequestedHandler() {
-  }
-
-  renderProgressBarsForCollections() {
-    const cols = this.state.progressList.map((progressData) => {
-      const { collectionName, currentCount, totalCount } = progressData;
-      return (
-        <div className="col-md-6" key={collectionName}>
-          <LabeledProgressBar
-            header={collectionName}
-            currentCount={currentCount}
-            totalCount={totalCount}
-          />
-        </div>
-      );
-    });
-
-    return <div className="row px-3">{cols}</div>;
-  }
-
-  renderProgressBarForZipping() {
-    const { isZipping, isExported } = this.state;
-    const showZippingBar = isZipping || isExported;
-
-    if (!showZippingBar) {
-      return <></>;
-    }
-
-    return (
-      <div className="row px-3">
-        <div className="col-md-12" key="progressBarForZipping">
-          <LabeledProgressBar
-            header="Zip Files"
-            currentCount={1}
-            totalCount={1}
-            isInProgress={isZipping}
-          />
-        </div>
-      </div>
-    );
-  }
-
-  render() {
-    const { t } = this.props;
-    const { isExporting, isExported, progressList } = this.state;
-
-    const showExportingData = (isExported || isExporting) && (progressList != null);
-
-    return (
-      <div data-testid="admin-export-archive-data">
-        <h2>{t('export_management.export_archive_data')}</h2>
-
-        <button type="button" className="btn btn-outline-secondary" disabled={isExporting} onClick={this.openExportModal}>
-          {t('export_management.create_new_archive_data')}
-        </button>
-
-        { showExportingData && (
-          <div className="mt-5">
-            <h3>{t('export_management.exporting_collection_list')}</h3>
-            { this.renderProgressBarsForCollections() }
-            { this.renderProgressBarForZipping() }
-          </div>
-        ) }
-
-        <div className="mt-5">
-          <h3>{t('export_management.exported_data_list')}</h3>
-          <ArchiveFilesTable
-            zipFileStats={this.state.zipFileStats}
-            onZipFileStatRemove={this.onZipFileStatRemove}
-          />
-        </div>
-
-        <SelectCollectionsModal
-          isOpen={this.state.isExportModalOpen}
-          onExportingRequested={this.exportingRequestedHandler}
-          onClose={this.closeExportModal}
-          collections={this.state.collections}
-        />
-      </div>
-    );
-  }
-
-}
-
-ExportArchiveDataPage.propTypes = {
-  t: PropTypes.func.isRequired, // i18next
-  socket: PropTypes.object,
-};
-
-const ExportArchiveDataPageWrapperFC = (props) => {
-  const { t } = useTranslation('admin');
-  const { data: socket } = useAdminSocket();
-
-  return <ExportArchiveDataPage t={t} socket={socket} {...props} />;
-};
-
-export default ExportArchiveDataPageWrapperFC;

+ 198 - 0
packages/app/src/components/Admin/ExportArchiveDataPage.tsx

@@ -0,0 +1,198 @@
+import React, { useCallback, useEffect, useState } from 'react';
+
+import { useTranslation } from 'react-i18next';
+import * as toastr from 'toastr';
+
+
+import { apiDelete } from '~/client/util/apiv1-client';
+import { apiv3Get } from '~/client/util/apiv3-client';
+import { useAdminSocket } from '~/stores/socket-io';
+
+import LabeledProgressBar from './Common/LabeledProgressBar';
+import ArchiveFilesTable from './ExportArchiveData/ArchiveFilesTable';
+import SelectCollectionsModal from './ExportArchiveData/SelectCollectionsModal';
+
+
+const IGNORED_COLLECTION_NAMES = [
+  'sessions', 'rlflx', 'activities',
+];
+
+const ExportArchiveDataPage = (): JSX.Element => {
+  const { data: socket } = useAdminSocket();
+  const { t } = useTranslation();
+
+  const [collections, setCollections] = useState<any[]>([]);
+  const [zipFileStats, setZipFileStats] = useState<any[]>([]);
+  const [progressList, setProgressList] = useState<any[]>([]);
+  const [isExportModalOpen, setExportModalOpen] = useState(false);
+  const [isExporting, setExporting] = useState(false);
+  const [isZipping, setZipping] = useState(false);
+  const [isExported, setExported] = useState(false);
+
+  const fetchData = useCallback(async() => {
+    const [{ data: collectionsData }, { data: statusData }] = await Promise.all([
+      apiv3Get<{collections: any[]}>('/mongo/collections', {}),
+      apiv3Get<{status: { zipFileStats: any[], isExporting: boolean, progressList: any[] }}>('/export/status', {}),
+    ]);
+    // TODO: toastSuccess, toastError
+
+    // filter only not ignored collection names
+    const filteredCollections = collectionsData.collections.filter((collectionName) => {
+      return !IGNORED_COLLECTION_NAMES.includes(collectionName);
+    });
+
+    const { zipFileStats, isExporting, progressList } = statusData.status;
+    setCollections(filteredCollections);
+    setZipFileStats(zipFileStats);
+    setExporting(isExporting);
+    setProgressList(progressList);
+  }, []);
+
+  const setupWebsocketEventHandler = useCallback(() => {
+    if (socket != null) {
+      // websocket event
+      socket.on('admin:onProgressForExport', ({ currentCount, totalCount, progressList }) => {
+        setExporting(true);
+        setProgressList(progressList);
+      });
+
+      // websocket event
+      socket.on('admin:onStartZippingForExport', () => {
+        setZipping(true);
+      });
+
+      // websocket event
+      socket.on('admin:onTerminateForExport', ({ addedZipFileStat }) => {
+
+        setExporting(false);
+        setZipping(false);
+        setExported(true);
+        setZipFileStats(prev => prev.concat([addedZipFileStat]));
+
+        // TODO: toastSuccess, toastError
+        toastr.success(undefined, `New Archive Data '${addedZipFileStat.fileName}' is added`, {
+          closeButton: true,
+          progressBar: true,
+          newestOnTop: false,
+          showDuration: '100',
+          hideDuration: '100',
+          timeOut: '1200',
+          extendedTimeOut: '150',
+        });
+      });
+    }
+  }, [socket]);
+
+  const onZipFileStatRemove = useCallback(async(fileName) => {
+    try {
+      await apiDelete(`/v3/export/${fileName}`, {});
+
+      setZipFileStats(prev => prev.filter(stat => stat.fileName !== fileName));
+
+      // TODO: toastSuccess, toastError
+      toastr.success(undefined, `Deleted ${fileName}`, {
+        closeButton: true,
+        progressBar: true,
+        newestOnTop: false,
+        showDuration: '100',
+        hideDuration: '100',
+        timeOut: '1200',
+        extendedTimeOut: '150',
+      });
+    }
+    catch (err) {
+      // TODO: toastSuccess, toastError
+      toastr.error(err, 'Error', {
+        closeButton: true,
+        progressBar: true,
+        newestOnTop: false,
+        showDuration: '100',
+        hideDuration: '100',
+        timeOut: '3000',
+      });
+    }
+  }, []);
+
+  const exportingRequestedHandler = useCallback(() => {}, []);
+
+  const renderProgressBarsForCollections = useCallback(() => {
+    const cols = progressList.map((progressData) => {
+      const { collectionName, currentCount, totalCount } = progressData;
+      return (
+        <div className="col-md-6" key={collectionName}>
+          <LabeledProgressBar
+            header={collectionName}
+            currentCount={currentCount}
+            totalCount={totalCount}
+          />
+        </div>
+      );
+    });
+
+    return <div className="row px-3">{cols}</div>;
+  }, [progressList]);
+
+  const renderProgressBarForZipping = useCallback(() => {
+    const showZippingBar = isZipping || isExported;
+
+    if (!showZippingBar) {
+      return <></>;
+    }
+
+    return (
+      <div className="row px-3">
+        <div className="col-md-12" key="progressBarForZipping">
+          <LabeledProgressBar
+            header="Zip Files"
+            currentCount={1}
+            totalCount={1}
+            isInProgress={isZipping}
+          />
+        </div>
+      </div>
+    );
+  }, [isExported, isZipping]);
+
+  useEffect(() => {
+    fetchData();
+
+    setupWebsocketEventHandler();
+  }, [fetchData, setupWebsocketEventHandler]);
+
+  const showExportingData = (isExported || isExporting) && (progressList != null);
+
+  return (
+    <div data-testid="admin-export-archive-data">
+      <h2>{t('export_management.export_archive_data')}</h2>
+
+      <button type="button" className="btn btn-outline-secondary" disabled={isExporting} onClick={() => setExportModalOpen(true)}>
+        {t('export_management.create_new_archive_data')}
+      </button>
+
+      { showExportingData && (
+        <div className="mt-5">
+          <h3>{t('export_management.exporting_collection_list')}</h3>
+          { renderProgressBarsForCollections() }
+          { renderProgressBarForZipping() }
+        </div>
+      ) }
+
+      <div className="mt-5">
+        <h3>{t('export_management.exported_data_list')}</h3>
+        <ArchiveFilesTable
+          zipFileStats={zipFileStats}
+          onZipFileStatRemove={onZipFileStatRemove}
+        />
+      </div>
+
+      <SelectCollectionsModal
+        isOpen={isExportModalOpen}
+        onExportingRequested={exportingRequestedHandler}
+        onClose={() => setExportModalOpen(false)}
+        collections={collections}
+      />
+    </div>
+  );
+};
+
+export default ExportArchiveDataPage;

+ 284 - 0
packages/app/src/components/Admin/G2GDataTransfer.tsx

@@ -0,0 +1,284 @@
+import React, {
+  ChangeEvent, useCallback, useEffect, useState,
+} from 'react';
+
+import { useTranslation } from 'next-i18next';
+
+import { useGenerateTransferKey } from '~/client/services/g2g-transfer';
+import { toastError, toastSuccess } from '~/client/util/apiNotification';
+import { apiv3Get, apiv3Post } from '~/client/util/apiv3-client';
+import { G2G_PROGRESS_STATUS, type G2GProgress } from '~/interfaces/g2g-transfer';
+import { useAdminSocket } from '~/stores/socket-io';
+
+import CustomCopyToClipBoard from '../Common/CustomCopyToClipBoard';
+
+// import { FileUploadSettingMolecule } from './App/FileUploadSetting';
+import G2GDataTransferExportForm from './G2GDataTransferExportForm';
+import G2GDataTransferStatusIcon from './G2GDataTransferStatusIcon';
+
+const IGNORED_COLLECTION_NAMES = [
+  'sessions', 'rlflx', 'activities', 'attachmentFiles.files', 'attachmentFiles.chunks',
+];
+
+const G2GDataTransfer = (): JSX.Element => {
+  const { data: socket } = useAdminSocket();
+  const { t } = useTranslation(['admin', 'commons']);
+
+  const [startTransferKey, setStartTransferKey] = useState('');
+  const [collections, setCollections] = useState<string[]>([]);
+  const [selectedCollections, setSelectedCollections] = useState<Set<string>>(new Set());
+  const [optionsMap, setOptionsMap] = useState<any>({});
+  const [isShowExportForm, setShowExportForm] = useState(false);
+  const [isTransferring, setTransferring] = useState(false);
+  const [g2gProgress, setG2GProgress] = useState<G2GProgress>({
+    mongo: G2G_PROGRESS_STATUS.PENDING,
+    attachments: G2G_PROGRESS_STATUS.PENDING,
+  });
+
+  // File upload settings
+  // const [fileUploadType, setFileUploadType] = useState('aws');
+  // const [s3ReferenceFileWithRelayMode, setS3ReferenceFileWithRelayMode] = useState(false);
+  // const [s3Region, setS3Region] = useState('');
+  // const [s3CustomEndpoint, setS3CustomEndpoint] = useState('');
+  // const [s3Bucket, setS3Bucket] = useState('');
+  // const [s3AccessKeyId, setS3AccessKeyId] = useState('');
+  // const [s3SecretAccessKey, setS3SecretAccessKey] = useState('');
+  // const [gcsReferenceFileWithRelayMode, setGcsReferenceFileWithRelayMode] = useState(false);
+  // const [gcsApiKeyJsonPath, setGcsApiKeyJsonPath] = useState('');
+  // const [gcsBucket, setGcsBucket] = useState('');
+  // const [gcsUploadNamespace, setGcsUploadNamespace] = useState('');
+
+  const updateSelectedCollections = (newSelectedCollections: Set<string>) => {
+    setSelectedCollections(newSelectedCollections);
+  };
+
+  const updateOptionsMap = (newOptionsMap: any) => {
+    setOptionsMap(newOptionsMap);
+  };
+
+  const onChangeTransferKeyHandler = useCallback((e) => {
+    setStartTransferKey(e.target.value);
+  }, []);
+
+  const setCollectionsAndSelectedCollections = useCallback(async() => {
+    const { data: collectionsData } = await apiv3Get<{collections: any[]}>('/mongo/collections', {});
+
+    // filter only not ignored collection names
+    const filteredCollections = collectionsData.collections.filter((collectionName) => {
+      return !IGNORED_COLLECTION_NAMES.includes(collectionName);
+    });
+
+    setCollections(filteredCollections);
+    setSelectedCollections(new Set(filteredCollections));
+  }, []);
+
+  const setupWebsocketEventHandler = useCallback(() => {
+    if (socket != null) {
+      socket.on('admin:g2gProgress', (g2gProgress: G2GProgress) => {
+        setG2GProgress(g2gProgress);
+
+        if (g2gProgress.mongo === G2G_PROGRESS_STATUS.COMPLETED && g2gProgress.attachments === G2G_PROGRESS_STATUS.COMPLETED) {
+          toastSuccess(t('admin:g2g:transfer_success'));
+        }
+      });
+
+      socket.on('admin:g2gError', ({ key }) => {
+        setTransferring(false);
+        toastError(t(key));
+      });
+    }
+  }, [socket, t, setTransferring, setG2GProgress]);
+
+  const cleanUpWebsocketEventHandler = useCallback(() => {
+    if (socket != null) {
+      socket.off('admin:g2gProgress');
+      socket.off('admin:g2gError');
+    }
+  }, [socket]);
+
+  const { transferKey, generateTransferKey } = useGenerateTransferKey();
+
+  const onClickHandler = useCallback(async() => {
+    try {
+      await generateTransferKey();
+    }
+    catch (errs) {
+      toastError(errs);
+    }
+  }, [generateTransferKey]);
+
+  const startTransfer = useCallback(async(e) => {
+    e.preventDefault();
+    setTransferring(true);
+
+    try {
+      await apiv3Post('/g2g-transfer/transfer', {
+        transferKey: startTransferKey,
+        collections: Array.from(selectedCollections),
+        optionsMap,
+      });
+    }
+    catch (errs) {
+      toastError(errs);
+    }
+  }, [setTransferring, startTransferKey, selectedCollections, optionsMap]);
+
+  // File upload
+  // const onChangeFileUploadTypeHandler = useCallback((e: ChangeEvent, type: string) => {
+  //   setFileUploadType(type);
+  // }, []);
+
+  // S3
+  // const onChangeS3ReferenceFileWithRelayModeHandler = useCallback((val: boolean) => {
+  //   setS3ReferenceFileWithRelayMode(val);
+  // }, []);
+
+  // const onChangeS3RegionHandler = useCallback((val: string) => {
+  //   setS3Region(val);
+  // }, []);
+
+  // const onChangeS3CustomEndpointHandler = useCallback((val: string) => {
+  //   setS3CustomEndpoint(val);
+  // }, []);
+
+  // const onChangeS3BucketHandler = useCallback((val: string) => {
+  //   setS3Bucket(val);
+  // }, []);
+
+  // const onChangeS3AccessKeyIdHandler = useCallback((val: string) => {
+  //   setS3AccessKeyId(val);
+  // }, []);
+
+  // const onChangeS3SecretAccessKeyHandler = useCallback((val: string) => {
+  //   setS3SecretAccessKey(val);
+  // }, []);
+
+  // // GCS
+  // const onChangeGcsReferenceFileWithRelayModeHandler = useCallback((val: boolean) => {
+  //   setGcsReferenceFileWithRelayMode(val);
+  // }, []);
+
+  // const onChangeGcsApiKeyJsonPathHandler = useCallback((val: string) => {
+  //   setGcsApiKeyJsonPath(val);
+  // }, []);
+
+  // const onChangeGcsBucketHandler = useCallback((val: string) => {
+  //   setGcsBucket(val);
+  // }, []);
+
+  // const onChangeGcsUploadNamespaceHandler = useCallback((val: string) => {
+  //   setGcsUploadNamespace(val);
+  // }, []);
+
+
+  useEffect(() => {
+    setCollectionsAndSelectedCollections();
+    setupWebsocketEventHandler();
+
+    return () => {
+      cleanUpWebsocketEventHandler();
+    };
+  }, [setCollectionsAndSelectedCollections, setupWebsocketEventHandler, cleanUpWebsocketEventHandler]);
+
+  return (
+    <div data-testid="admin-export-archive-data">
+      <h2 className="border-bottom">{t('admin:g2g_data_transfer.transfer_data_to_another_growi')}</h2>
+
+      <button type="button" className="btn btn-outline-secondary mt-4" disabled={isTransferring} onClick={() => setShowExportForm(!isShowExportForm)}>
+        {t('admin:g2g_data_transfer.advanced_options')}
+      </button>
+
+      {collections.length !== 0 && (
+        <div className={`${isShowExportForm ? '' : 'd-none'} px-3 pt-3`}>
+          {/* <h3 className='mb-1'>{t('admin:app_setting.file_upload')}</h3>
+          <FileUploadSettingMolecule
+            fileUploadType={fileUploadType}
+            isFixedFileUploadByEnvVar={false}
+            onChangeFileUploadType={onChangeFileUploadTypeHandler}
+            s3ReferenceFileWithRelayMode={s3ReferenceFileWithRelayMode}
+            s3Region={s3Region}
+            s3CustomEndpoint={s3CustomEndpoint}
+            s3Bucket={s3Bucket}
+            s3AccessKeyId={s3AccessKeyId}
+            s3SecretAccessKey={s3SecretAccessKey}
+            onChangeS3ReferenceFileWithRelayMode={onChangeS3ReferenceFileWithRelayModeHandler}
+            onChangeS3Region={onChangeS3RegionHandler}
+            onChangeS3CustomEndpoint={onChangeS3CustomEndpointHandler}
+            onChangeS3Bucket={onChangeS3BucketHandler}
+            onChangeS3AccessKeyId={onChangeS3AccessKeyIdHandler}
+            onChangeS3SecretAccessKey={onChangeS3SecretAccessKeyHandler}
+            gcsReferenceFileWithRelayMode={gcsReferenceFileWithRelayMode}
+            gcsUseOnlyEnvVars={false}
+            gcsApiKeyJsonPath={gcsApiKeyJsonPath}
+            gcsBucket={gcsBucket}
+            gcsUploadNamespace={gcsUploadNamespace}
+            onChangeGcsReferenceFileWithRelayMode={onChangeGcsReferenceFileWithRelayModeHandler}
+            onChangeGcsApiKeyJsonPath={onChangeGcsApiKeyJsonPathHandler}
+            onChangeGcsBucket={onChangeGcsBucketHandler}
+            onChangeGcsUploadNamespace={onChangeGcsUploadNamespaceHandler}
+          /> */}
+          <h3 className='mb-1'>{t('export_management.export_archive_data')}</h3>
+          <G2GDataTransferExportForm
+            allCollectionNames={collections}
+            selectedCollections={selectedCollections}
+            updateSelectedCollections={updateSelectedCollections}
+            optionsMap={optionsMap}
+            updateOptionsMap={updateOptionsMap}
+          />
+        </div>
+      )}
+
+      <form onSubmit={startTransfer}>
+        <div className="form-group row mt-3">
+          <div className="col-9">
+            <input
+              className="form-control"
+              type="text"
+              placeholder={t('admin:g2g_data_transfer.paste_transfer_key')}
+              onChange={onChangeTransferKeyHandler}
+              required
+            />
+          </div>
+          <div className="col-3">
+            <button type="submit" className="btn btn-primary w-100">{t('admin:g2g_data_transfer.start_transfer')}</button>
+          </div>
+        </div>
+      </form>
+
+      {isTransferring && (
+        <div className='border rounded p-4'>
+          <div>
+            <G2GDataTransferStatusIcon className='mr-2 mb-2' status={g2gProgress.mongo} /> MongoDB
+          </div>
+          <div>
+            <G2GDataTransferStatusIcon className='mr-2' status={g2gProgress.attachments} /> Attachments
+          </div>
+        </div>
+      )}
+
+      <h2 className="border-bottom mt-5">{t('commons:g2g_data_transfer.transfer_data_to_this_growi')}</h2>
+
+      <div className="form-group row mt-4">
+        <div className="col-md-3">
+          <button type="button" className="btn btn-primary w-100" onClick={onClickHandler}>
+            {t('commons:g2g_data_transfer.publish_transfer_key')}
+          </button>
+        </div>
+        <div className="col-md-9">
+          <div className="input-group-prepend mx-1">
+            <input className="form-control" type="text" value={transferKey} readOnly />
+            <CustomCopyToClipBoard textToBeCopied={transferKey} message="admin:slack_integration.copied_to_clipboard" />
+          </div>
+        </div>
+      </div>
+
+      <div className="alert alert-warning mt-4">
+        <p className="mb-1">{t('commons:g2g_data_transfer.transfer_key_limit')}</p>
+        <p className="mb-1">{t('commons:g2g_data_transfer.once_transfer_key_used')}</p>
+        <p className="mb-0">{t('commons:g2g_data_transfer.transfer_to_growi_cloud')}</p>
+      </div>
+    </div>
+  );
+};
+
+export default G2GDataTransfer;

+ 237 - 0
packages/app/src/components/Admin/G2GDataTransferExportForm.tsx

@@ -0,0 +1,237 @@
+import React, {
+  useState, useEffect, useCallback, useMemo,
+} from 'react';
+
+import { useTranslation } from 'next-i18next';
+
+import GrowiArchiveImportOption from '~/models/admin/growi-archive-import-option';
+import ImportOptionForPages from '~/models/admin/import-option-for-pages';
+import ImportOptionForRevisions from '~/models/admin/import-option-for-revisions';
+
+import ImportCollectionConfigurationModal from './ImportData/GrowiArchive/ImportCollectionConfigurationModal';
+import ImportCollectionItem, { DEFAULT_MODE, MODE_RESTRICTED_COLLECTION } from './ImportData/GrowiArchive/ImportCollectionItem';
+
+const GROUPS_PAGE = [
+  'pages', 'revisions', 'tags', 'pagetagrelations',
+];
+const GROUPS_USER = [
+  'users', 'externalaccounts', 'usergroups', 'usergrouprelations',
+];
+const GROUPS_CONFIG = [
+  'configs', 'updateposts', 'globalnotificationsettings',
+];
+const ALL_GROUPED_COLLECTIONS = GROUPS_PAGE.concat(GROUPS_USER).concat(GROUPS_CONFIG);
+
+const IMPORT_OPTION_CLASS_MAPPING = {
+  pages: ImportOptionForPages,
+  revisions: ImportOptionForRevisions,
+};
+
+type Props = {
+  allCollectionNames: string[],
+  selectedCollections: Set<string>,
+  updateSelectedCollections: (newSelectedCollections: Set<string>) => void,
+  optionsMap: any,
+  updateOptionsMap: (newOptionsMap: any) => void,
+};
+
+const G2GDataTransferExportForm = (props: Props): JSX.Element => {
+  const { t } = useTranslation('admin');
+
+  const {
+    allCollectionNames, selectedCollections, updateSelectedCollections, optionsMap, updateOptionsMap,
+  } = props;
+
+  const [isConfigurationModalOpen, setConfigurationModalOpen] = useState(false);
+  const [collectionNameForConfiguration, setCollectionNameForConfiguration] = useState<any>();
+
+  const checkAll = useCallback(() => {
+    updateSelectedCollections(new Set(allCollectionNames));
+  }, [allCollectionNames, updateSelectedCollections]);
+
+  const uncheckAll = useCallback(() => {
+    updateSelectedCollections(new Set());
+  }, [updateSelectedCollections]);
+
+  const updateOption = useCallback((collectionName, data) => {
+    const options = optionsMap[collectionName];
+
+    // merge
+    Object.assign(options, data);
+
+    const updatedOptionsMap = {};
+    updatedOptionsMap[collectionName] = options;
+    updateOptionsMap((prev) => {
+      return { ...prev, updatedOptionsMap };
+    });
+  }, [optionsMap, updateOptionsMap]);
+
+  const ImportItems = ({ collectionNames }): JSX.Element => {
+    const toggleCheckbox = (collectionName, bool) => {
+      const collections = new Set(selectedCollections);
+      if (bool) {
+        collections.add(collectionName);
+      }
+      else {
+        collections.delete(collectionName);
+      }
+
+      updateSelectedCollections(collections);
+
+      // TODO: validation
+      // this.validate();
+    };
+
+    const openConfigurationModal = (collectionName) => {
+      setConfigurationModalOpen(true);
+      setCollectionNameForConfiguration(collectionName);
+    };
+
+    return (
+      <div className="row">
+        {collectionNames.map((collectionName) => {
+          const isConfigButtonAvailable = Object.keys(IMPORT_OPTION_CLASS_MAPPING).includes(collectionName);
+
+          if (optionsMap[collectionName] == null) {
+            return null;
+          }
+
+          return (
+            <div className="col-md-6 my-1" key={collectionName}>
+              <ImportCollectionItem
+                isImporting={false}
+                isImported={false}
+                insertedCount={0}
+                modifiedCount={0}
+                errorsCount={0}
+                collectionName={collectionName}
+                isSelected={selectedCollections.has(collectionName)}
+                option={optionsMap[collectionName]}
+                isConfigButtonAvailable={isConfigButtonAvailable}
+                // events
+                onChange={toggleCheckbox}
+                onOptionChange={updateOption}
+                onConfigButtonClicked={openConfigurationModal}
+                // TODO: show progress
+                isHideProgress
+              />
+            </div>
+          );
+        })}
+      </div>
+    );
+  };
+
+  const WarnForGroups = ({ errors }): JSX.Element => {
+    if (errors.length === 0) {
+      return <></>;
+    }
+
+    return (
+      <div className="alert alert-warning">
+        <ul>
+          {errors.map((error, i) => {
+            return <li key={i}>{error}</li>;
+          })}
+        </ul>
+      </div>
+    );
+  };
+
+  const GroupImportItems = ({ groupList, groupName, errors }): JSX.Element => {
+    const collectionNames = groupList.filter((groupCollectionName) => {
+      return allCollectionNames.includes(groupCollectionName);
+    });
+
+    if (collectionNames.length === 0) {
+      return <></>;
+    }
+
+    return (
+      <div className="mt-4">
+        <legend>{groupName} Collections</legend>
+        <ImportItems collectionNames={collectionNames} />
+        <WarnForGroups errors={errors} />
+      </div>
+    );
+  };
+
+  const OtherImportItems = (): JSX.Element => {
+    const collectionNames = allCollectionNames.filter((collectionName) => {
+      return !ALL_GROUPED_COLLECTIONS.includes(collectionName);
+    });
+
+    // TODO: エラー対応
+    return <GroupImportItems groupList={collectionNames} groupName='Other' errors={[]} />;
+  };
+
+  const configurationModal = useMemo(() => {
+    if (collectionNameForConfiguration == null) {
+      return <></>;
+    }
+
+    return (
+      <ImportCollectionConfigurationModal
+        isOpen={isConfigurationModalOpen}
+        onClose={() => setConfigurationModalOpen(false)}
+        onOptionChange={updateOption}
+        collectionName={collectionNameForConfiguration}
+        option={optionsMap[collectionNameForConfiguration]}
+      />
+    );
+  }, [collectionNameForConfiguration, isConfigurationModalOpen, optionsMap, updateOption]);
+
+  const setInitialOptionsMap = useCallback(() => {
+    const initialOptionsMap = {};
+    allCollectionNames.forEach((collectionName) => {
+      const initialMode = (MODE_RESTRICTED_COLLECTION[collectionName] != null)
+        ? MODE_RESTRICTED_COLLECTION[collectionName][0]
+        : DEFAULT_MODE;
+      const ImportOption = IMPORT_OPTION_CLASS_MAPPING[collectionName] || GrowiArchiveImportOption;
+      initialOptionsMap[collectionName] = new ImportOption(initialMode);
+    });
+    updateOptionsMap(initialOptionsMap);
+  }, [allCollectionNames, updateOptionsMap]);
+
+  useEffect(() => {
+    setInitialOptionsMap();
+  }, []);
+
+  return (
+    <>
+      <form className="form-inline mt-3">
+        <div className="form-group">
+          <button type="button" className="btn btn-sm btn-outline-secondary mr-2" onClick={checkAll}>
+            <i className="fa fa-check-square-o"></i> {t('admin:export_management.check_all')}
+          </button>
+        </div>
+        <div className="form-group">
+          <button type="button" className="btn btn-sm btn-outline-secondary mr-2" onClick={uncheckAll}>
+            <i className="fa fa-square-o"></i> {t('admin:export_management.uncheck_all')}
+          </button>
+        </div>
+      </form>
+
+      <div className="card well small my-4">
+        <ul>
+          <li>{t('admin:importer_management.growi_settings.description_of_import_mode.about')}</li>
+          <ul>
+            <li>{t('admin:importer_management.growi_settings.description_of_import_mode.insert')}</li>
+            <li>{t('admin:importer_management.growi_settings.description_of_import_mode.upsert')}</li>
+            <li>{t('admin:importer_management.growi_settings.description_of_import_mode.flash_and_insert')}</li>
+          </ul>
+        </ul>
+      </div>
+
+      {/* TODO: エラー追加 */}
+      <GroupImportItems groupList={GROUPS_PAGE} groupName='Page' errors={[]} />
+      <GroupImportItems groupList={GROUPS_USER} groupName='User' errors={[]} />
+      <GroupImportItems groupList={GROUPS_CONFIG} groupName='Config' errors={[]} />
+      <OtherImportItems />
+
+      {configurationModal}
+    </>
+  );
+};
+
+export default G2GDataTransferExportForm;

+ 43 - 0
packages/app/src/components/Admin/G2GDataTransferStatusIcon.tsx

@@ -0,0 +1,43 @@
+import React, { type ComponentPropsWithoutRef } from 'react';
+
+import { G2G_PROGRESS_STATUS, type G2GProgressStatus } from '~/interfaces/g2g-transfer';
+
+/**
+ * Props for {@link G2GDataTransferStatusIcon}
+ */
+interface Props extends ComponentPropsWithoutRef<'i'>{
+  status: G2GProgressStatus;
+}
+
+/**
+ * Icon for G2G transfer status
+ */
+const G2GDataTransferStatusIcon = ({ status, className, ...props }: Props): JSX.Element => {
+  if (status === G2G_PROGRESS_STATUS.IN_PROGRESS) {
+    return (
+      <i className={`fa fa-spinner fa-pulse fa-fw ${className}`} aria-label="in progress" {...props} />
+    );
+  }
+
+  if (status === G2G_PROGRESS_STATUS.COMPLETED) {
+    return (
+      <i className={`fa fa-check-circle-o fa-fw text-info ${className}`} aria-label="completed" {...props} />
+    );
+  }
+
+  if (status === G2G_PROGRESS_STATUS.ERROR) {
+    return (
+      <i className={`fa fa-exclamation-circle fa-fw text-danger ${className}`} aria-label="error" {...props} />
+    );
+  }
+
+  if (status === G2G_PROGRESS_STATUS.SKIPPED) {
+    return (
+      <i className={`fa fa-ban fa-fw ${className}`} aria-label="skipped" {...props} />
+    );
+  }
+
+  return <i className={`fa fa-circle-o fa-fw ${className}`} aria-label="pending" {...props} />;
+};
+
+export default G2GDataTransferStatusIcon;

+ 4 - 2
packages/app/src/components/Admin/ImportData/GrowiArchive/ImportCollectionItem.jsx

@@ -17,6 +17,7 @@ export const DEFAULT_MODE = 'insert';
 export const MODE_RESTRICTED_COLLECTION = {
   configs: ['flushAndInsert'],
   users: ['insert', 'upsert'],
+  pages: ['upsert', 'flushAndInsert'],
 };
 
 export default class ImportCollectionItem extends React.Component {
@@ -194,7 +195,7 @@ export default class ImportCollectionItem extends React.Component {
 
   render() {
     const {
-      isSelected,
+      isSelected, isHideProgress,
     } = this.props;
 
     return (
@@ -210,7 +211,7 @@ export default class ImportCollectionItem extends React.Component {
             </span>
           </div>
         </div>
-        {isSelected && (
+        {isSelected && !isHideProgress && (
           <>
             {this.renderProgressBar()}
             <div className="card-body">{this.renderBody()}</div>
@@ -225,6 +226,7 @@ export default class ImportCollectionItem extends React.Component {
 ImportCollectionItem.propTypes = {
   collectionName: PropTypes.string.isRequired,
   isSelected: PropTypes.bool.isRequired,
+  isHideProgress: PropTypes.bool,
   option: PropTypes.instanceOf(GrowiArchiveImportOption).isRequired,
 
   isImporting: PropTypes.bool.isRequired,

+ 6 - 8
packages/app/src/components/Admin/MarkdownSetting/WhiteListInput.jsx

@@ -24,13 +24,13 @@ class WhiteListInput extends React.Component {
   }
 
   onClickRecommendTagButton() {
-    // this.tagWhiteList.current.value = this.tags;
-    // this.props.adminMarkDownContainer.setState({ tagWhiteList: this.tags });
+    this.tagWhiteList.current.value = this.tags;
+    this.props.adminMarkDownContainer.setState({ tagWhiteList: this.tags });
   }
 
   onClickRecommendAttrButton() {
-    // this.attrWhiteList.current.value = this.attrs;
-    // this.props.adminMarkDownContainer.setState({ attrWhiteList: this.attrs });
+    this.attrWhiteList.current.value = this.attrs;
+    this.props.adminMarkDownContainer.setState({ attrWhiteList: this.attrs });
   }
 
   render() {
@@ -41,12 +41,11 @@ class WhiteListInput extends React.Component {
         <div className="mt-4">
           <div className="d-flex justify-content-between">
             {t('markdown_settings.xss_options.tag_names')}
-            <p id="btn-import-tags" className="btn btn-sm btn-primary mb-0 disabled" onClick={this.onClickRecommendTagButton}>
+            <p id="btn-import-tags" className="btn btn-sm btn-primary mb-0" onClick={this.onClickRecommendTagButton}>
               {t('markdown_settings.xss_options.import_recommended', { target: 'Tags' })}
             </p>
           </div>
           <textarea
-            disabled
             className="form-control xss-list"
             name="recommendedTags"
             rows="6"
@@ -59,12 +58,11 @@ class WhiteListInput extends React.Component {
         <div className="mt-4">
           <div className="d-flex justify-content-between">
             {t('markdown_settings.xss_options.tag_attributes')}
-            <p id="btn-import-tags" className="btn btn-sm btn-primary mb-0 disabled" onClick={this.onClickRecommendAttrButton}>
+            <p id="btn-import-tags" className="btn btn-sm btn-primary mb-0" onClick={this.onClickRecommendAttrButton}>
               {t('markdown_settings.xss_options.import_recommended', { target: 'Attrs' })}
             </p>
           </div>
           <textarea
-            disabled
             className="form-control xss-list"
             name="recommendedAttrs"
             rows="6"

+ 1 - 4
packages/app/src/components/Admin/MarkdownSetting/XssForm.jsx

@@ -93,7 +93,6 @@ class XssForm extends React.Component {
           <div className="col-md-6 col-sm-12 align-self-start mb-4">
             <div className="custom-control custom-radio">
               <input
-                disabled
                 type="radio"
                 className="custom-control-input"
                 id="xssOption2"
@@ -102,9 +101,7 @@ class XssForm extends React.Component {
                 onChange={() => { adminMarkDownContainer.setState({ xssOption: RehypeSanitizeOption.CUSTOM }) }}
               />
               <label className="custom-control-label w-100" htmlFor="xssOption2">
-                <p className="font-weight-bold">{t('markdown_settings.xss_options.custom_whitelist')}
-                  <span className='text-warning'> (TBD: Currently unavailable)</span>
-                </p>
+                <p className="font-weight-bold">{t('markdown_settings.xss_options.custom_whitelist')}</p>
                 <WhiteListInput customizable />
               </label>
             </div>

+ 1 - 25
packages/app/src/components/Admin/SlackIntegration/WithProxyAccordions.jsx

@@ -12,6 +12,7 @@ import { apiv3Put, apiv3Post } from '~/client/util/apiv3-client';
 import { useSiteUrl } from '~/stores/context';
 import loggerFactory from '~/utils/logger';
 
+import CustomCopyToClipBoard from '../../Common/CustomCopyToClipBoard';
 import Accordion from '../Common/Accordion';
 
 import ManageCommandsProcess from './ManageCommandsProcess';
@@ -117,31 +118,6 @@ const RegisteringProxyUrlProcess = () => {
   );
 };
 
-// To get different messages for each copy happend, wrapping CopyToClipBoard and Tooltip together
-const CustomCopyToClipBoard = (props) => {
-  const { t } = useTranslation();
-  const [tooltipOpen, setTooltipOpen] = useState(false);
-
-  const showToolTip = useCallback(() => {
-    setTooltipOpen(true);
-    setTimeout(() => {
-      setTooltipOpen(false);
-    }, 1000);
-  }, []);
-  return (
-    <>
-      <CopyToClipboard text={props.textToBeCopied || ''} onCopy={showToolTip}>
-        <div className="btn input-group-text" id="tooltipTarget">
-          <i className="fa fa-clipboard mx-1" aria-hidden="true"></i>
-        </div>
-      </CopyToClipboard>
-      <Tooltip target="tooltipTarget" fade={false} isOpen={tooltipOpen}>
-        {t(props.message)}
-      </Tooltip>
-    </>
-  );
-};
-
 const GeneratingTokensAndRegisteringProxyServiceProcess = (props) => {
   const { t } = useTranslation();
   const { slackAppIntegrationId } = props;

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

@@ -2,7 +2,6 @@ import React, {
   FC, useCallback, useState, useMemo,
 } from 'react';
 
-import { TFunctionResult } from 'i18next';
 import { useTranslation } from 'next-i18next';
 import {
   Modal, ModalHeader, ModalBody, ModalFooter,
@@ -30,7 +29,7 @@ type AvailableOption = {
   actionForPages: string,
   iconClass: string,
   styleClass: string,
-  label: TFunctionResult,
+  label: string,
 };
 
 // actionName master constants

+ 1 - 2
packages/app/src/components/Admin/UserGroup/UserGroupForm.tsx

@@ -1,7 +1,6 @@
 import React, { FC, useCallback, useState } from 'react';
 
 import dateFnsFormat from 'date-fns/format';
-import { TFunctionResult } from 'i18next';
 import { useTranslation } from 'next-i18next';
 
 import { IUserGroupHasId } from '~/interfaces/user';
@@ -9,7 +8,7 @@ import { IUserGroupHasId } from '~/interfaces/user';
 type Props = {
   userGroup: IUserGroupHasId,
   selectableParentUserGroups?: IUserGroupHasId[],
-  submitButtonLabel: TFunctionResult;
+  submitButtonLabel: string;
   onSubmit?: (targetGroup: IUserGroupHasId, userGroupData: Partial<IUserGroupHasId>) => Promise<void> | void
 };
 

+ 1 - 2
packages/app/src/components/Admin/UserGroup/UserGroupModal.tsx

@@ -3,7 +3,6 @@ import React, {
 } from 'react';
 
 import { Ref } from '@growi/core';
-import { TFunctionResult } from 'i18next';
 import { useTranslation } from 'next-i18next';
 import {
   Modal, ModalHeader, ModalBody, ModalFooter,
@@ -13,7 +12,7 @@ import { IUserGroup, IUserGroupHasId } from '~/interfaces/user';
 
 type Props = {
   userGroup?: IUserGroupHasId,
-  buttonLabel?: TFunctionResult,
+  buttonLabel?: string,
   onClickSubmit?: (userGroupData: Partial<IUserGroupHasId>) => Promise<IUserGroupHasId | void>
   isShow?: boolean
   onHide?: () => Promise<void> | void

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

@@ -4,12 +4,11 @@ import React, {
 
 import type { IUserGroupHasId, IUserGroupRelation, IUserHasId } from '@growi/core';
 import dateFnsFormat from 'date-fns/format';
-import { TFunctionResult } from 'i18next';
 import { useTranslation } from 'next-i18next';
 
 
 type Props = {
-  headerLabel?: TFunctionResult,
+  headerLabel?: string,
   userGroups: IUserGroupHasId[],
   userGroupRelations: IUserGroupRelation[],
   childUserGroups: IUserGroupHasId[],

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

@@ -43,7 +43,7 @@ export const UpdateParentConfirmModal: FC = () => {
                 {t('admin:user_group_management.update_parent_confirm_modal.danger_message')}
               </div>
 
-              <div className="custom-control custom-checkbox custom-checkbox-primary pl-5">
+              <div className="custom-control custom-checkbox custom-checkbox-succsess pl-5">
                 <input
                   className="custom-control-input"
                   name="forceUpdateParents"

+ 1 - 1
packages/app/src/components/Admin/UserGroupDetail/UserGroupUserTable.tsx

@@ -13,7 +13,7 @@ type Props = {
 }
 
 export const UserGroupUserTable = (props: Props): JSX.Element => {
-  const { t } = useTranslation();
+  const { t } = useTranslation('admin');
 
   const {
     userGroupRelations, onClickRemoveUserBtn, onClickPlusBtn,

+ 11 - 5
packages/app/src/components/Comments.tsx

@@ -1,20 +1,24 @@
 import React from 'react';
 
-import { IRevisionHasId } from '@growi/core';
+import { type IRevisionHasId, pagePathUtils } from '@growi/core';
 import dynamic from 'next/dynamic';
 
-import { PageCommentProps } from '~/components/PageComment';
+import type { PageCommentProps } from '~/components/PageComment';
 import { useSWRxPageComment } from '~/stores/comment';
 import { useIsTrashPage } from '~/stores/page';
 
 import { useCurrentUser } from '../stores/context';
 
-import { CommentEditorProps } from './PageComment/CommentEditor';
+import type { CommentEditorProps } from './PageComment/CommentEditor';
+
+
+const { isTopPage } = pagePathUtils;
+
 
 const PageComment = dynamic<PageCommentProps>(() => import('~/components/PageComment').then(mod => mod.PageComment), { ssr: false });
 const CommentEditor = dynamic<CommentEditorProps>(() => import('./PageComment/CommentEditor').then(mod => mod.CommentEditor), { ssr: false });
 
-type CommentsProps = {
+export type CommentsProps = {
   pageId: string,
   pagePath: string,
   revision: IRevisionHasId,
@@ -28,7 +32,9 @@ export const Comments = (props: CommentsProps): JSX.Element => {
   const { data: isDeleted } = useIsTrashPage();
   const { data: currentUser } = useCurrentUser();
 
-  if (pageId == null) {
+  const isTopPagePath = isTopPage(pagePath);
+
+  if (pageId == null || isTopPagePath) {
     return <></>;
   }
 

+ 38 - 0
packages/app/src/components/Common/CustomCopyToClipBoard.tsx

@@ -0,0 +1,38 @@
+import React, { FC, useState, useCallback } from 'react';
+
+import { CopyToClipboard } from 'react-copy-to-clipboard';
+import { useTranslation } from 'react-i18next';
+import { Tooltip } from 'reactstrap';
+
+type Props = {
+  message: string
+  textToBeCopied?: string
+}
+
+// To get different messages for each copy happend, wrapping CopyToClipBoard and Tooltip together
+const CustomCopyToClipBoard: FC<Props> = (props: Props) => {
+  const { t } = useTranslation();
+  const [tooltipOpen, setTooltipOpen] = useState(false);
+
+  const showToolTip = useCallback(() => {
+    setTooltipOpen(true);
+    setTimeout(() => {
+      setTooltipOpen(false);
+    }, 1000);
+  }, []);
+
+  return (
+    <>
+      <CopyToClipboard text={props.textToBeCopied || ''} onCopy={showToolTip}>
+        <div className="btn input-group-text" id="tooltipTarget">
+          <i className="fa fa-clipboard mx-1" aria-hidden="true"></i>
+        </div>
+      </CopyToClipboard>
+      <Tooltip target="tooltipTarget" fade={false} isOpen={tooltipOpen}>
+        {t(props.message)}
+      </Tooltip>
+    </>
+  );
+};
+
+export default CustomCopyToClipBoard;

+ 37 - 0
packages/app/src/components/Common/LazyRenderer.tsx

@@ -0,0 +1,37 @@
+import React, { useEffect, useState } from 'react';
+
+type Props = {
+  shouldRender: boolean | (() => boolean),
+  children: JSX.Element,
+}
+
+export const LazyRenderer = (props: Props): JSX.Element => {
+  const { shouldRender: _shouldRender, children } = props;
+
+  const [isActivated, setActivated] = useState(false);
+
+  const shouldRender = typeof _shouldRender === 'function'
+    ? _shouldRender()
+    : _shouldRender;
+
+  useEffect(() => {
+    if (isActivated) {
+      return;
+    }
+    setActivated(shouldRender);
+  }, [isActivated, shouldRender]);
+
+  const additionalClassName = shouldRender ? '' : 'd-none';
+
+  if (!isActivated) {
+    return <></>;
+  }
+
+  return (
+    <>
+      { React.cloneElement(children, {
+        className: `${children.props.className ?? ''} ${additionalClassName}`,
+      }) }
+    </>
+  );
+};

+ 7 - 22
packages/app/src/components/CustomNavigation/CustomTabContent.tsx

@@ -1,41 +1,35 @@
-import React, { useEffect, useState } from 'react';
+import React from 'react';
 
-import PropTypes from 'prop-types';
 import {
   TabContent, TabPane,
 } from 'reactstrap';
 
-import { ICustomNavTabMappings } from '~/interfaces/ui';
+import type { ICustomNavTabMappings } from '~/interfaces/ui';
+
+import { LazyRenderer } from '../Common/LazyRenderer';
 
 
 type Props = {
   activeTab: string,
   navTabMapping: ICustomNavTabMappings,
   additionalClassNames?: string[],
-
 }
 
 const CustomTabContent = (props: Props): JSX.Element => {
 
   const { activeTab, navTabMapping, additionalClassNames } = props;
 
-  const [activatedContent, setActivatedContent] = useState(new Set([activeTab]));
-
-  // add activated content to Set
-  useEffect(() => {
-    setActivatedContent(activatedContent.add(activeTab));
-  }, [activatedContent, activeTab]);
-
   return (
     <TabContent activeTab={activeTab} className={additionalClassNames != null ? additionalClassNames.join(' ') : ''}>
       {Object.entries(navTabMapping).map(([key, value]) => {
 
-        const shouldRender = key === activeTab || activatedContent.has(key);
         const { Content } = value;
 
         return (
           <TabPane key={key} tabId={key}>
-            { shouldRender && <Content /> }
+            <LazyRenderer shouldRender={key === activeTab}>
+              <Content />
+            </LazyRenderer>
           </TabPane>
         );
       })}
@@ -44,13 +38,4 @@ const CustomTabContent = (props: Props): JSX.Element => {
 
 };
 
-CustomTabContent.propTypes = {
-  activeTab: PropTypes.string.isRequired,
-  navTabMapping: PropTypes.object.isRequired,
-  additionalClassNames: PropTypes.arrayOf(PropTypes.string),
-};
-CustomTabContent.defaultProps = {
-  additionalClassNames: [],
-};
-
 export default CustomTabContent;

+ 42 - 0
packages/app/src/components/DataTransferForm.tsx

@@ -0,0 +1,42 @@
+import React from 'react';
+
+import { useTranslation } from 'next-i18next';
+
+import { useGenerateTransferKey } from '~/client/services/g2g-transfer';
+
+import CustomCopyToClipBoard from './Common/CustomCopyToClipBoard';
+
+const DataTransferForm = (): JSX.Element => {
+  const { t } = useTranslation('commons');
+  const { transferKey, generateTransferKey } = useGenerateTransferKey();
+
+  return (
+    <div data-testid="installerForm" className="p-3">
+      <p className="alert alert-success">
+        <strong>{ t('g2g_data_transfer.transfer_data_to_this_growi')}</strong>
+      </p>
+
+      <div className="form-group row mt-3">
+        <div className="col-md-12">
+          <button type="button" className="btn btn-primary w-100" onClick={generateTransferKey}>
+            {t('g2g_data_transfer.publish_transfer_key')}
+          </button>
+        </div>
+        <div className="col-md-12 mt-1">
+          <div className="input-group-prepend">
+            <input className="form-control" type="text" value={transferKey} readOnly />
+            <CustomCopyToClipBoard textToBeCopied={transferKey} message="copied_to_clipboard" />
+          </div>
+        </div>
+      </div>
+
+      <div className="alert alert-warning mt-4">
+        <p className="mb-1">{t('g2g_data_transfer.transfer_key_limit')}</p>
+        <p className="mb-1">{t('g2g_data_transfer.once_transfer_key_used')}</p>
+        <p className="mb-0">{t('g2g_data_transfer.transfer_to_growi_cloud')}</p>
+      </div>
+    </div>
+  );
+};
+
+export default DataTransferForm;

+ 13 - 46
packages/app/src/components/DescendantsPageList.tsx

@@ -11,15 +11,14 @@ import {
 import { IPagingResult } from '~/interfaces/paging-result';
 import { OnDeletedFunction, OnPutBackedFunction } from '~/interfaces/ui';
 import {
-  useIsGuestUser, useIsSharedUser, useShowPageLimitationXL,
+  useIsGuestUser, useIsSharedUser,
 } from '~/stores/context';
-import { useIsTrashPage } from '~/stores/page';
 import {
-  usePageTreeTermManager, useDescendantsPageListForCurrentPathTermManager, useSWRxDescendantsPageListForCurrrentPath,
+  mutatePageTree,
   useSWRxPageInfoForList, useSWRxPageList,
 } from '~/stores/page-listing';
 
-import { ForceHideMenuItems, MenuItemType } from './Common/Dropdown/PageItemControl';
+import { ForceHideMenuItems } from './Common/Dropdown/PageItemControl';
 import PageList from './PageList/PageList';
 import PaginationWrapper from './PaginationWrapper';
 
@@ -37,7 +36,7 @@ const convertToIDataWithMeta = (page: IPageHasId): IDataWithMeta<IPageHasId> =>
   return { data: page };
 };
 
-export const DescendantsPageListSubstance = (props: SubstanceProps): JSX.Element => {
+const DescendantsPageListSubstance = (props: SubstanceProps): JSX.Element => {
 
   const { t } = useTranslation();
 
@@ -52,10 +51,6 @@ export const DescendantsPageListSubstance = (props: SubstanceProps): JSX.Element
 
   let pageWithMetas: IDataWithMeta<IPageHasId, IPageInfoForOperation>[] = [];
 
-  // for mutation
-  const { advance: advancePt } = usePageTreeTermManager();
-  const { advance: advanceDpl } = useDescendantsPageListForCurrentPathTermManager();
-
   // initial data
   if (pagingResult != null) {
     // convert without meta at first
@@ -74,23 +69,22 @@ export const DescendantsPageListSubstance = (props: SubstanceProps): JSX.Element
       toastSuccess(t('deleted_pages_completely', { path }));
     }
 
-    advancePt();
+    mutatePageTree();
 
     if (onPagesDeleted != null) {
       onPagesDeleted(...args);
     }
-  }, [advancePt, onPagesDeleted, t]);
+  }, [onPagesDeleted, t]);
 
   const pagePutBackedHandler: OnPutBackedFunction = useCallback((path) => {
     toastSuccess(t('page_has_been_reverted', { path }));
 
-    advancePt();
-    advanceDpl();
+    mutatePageTree();
 
     if (onPagePutBacked != null) {
       onPagePutBacked(path);
     }
-  }, [advanceDpl, advancePt, onPagePutBacked, t]);
+  }, [onPagePutBacked, t]);
 
   function setPageNumber(selectedPageNumber) {
     setActivePage(selectedPageNumber);
@@ -135,43 +129,18 @@ export const DescendantsPageListSubstance = (props: SubstanceProps): JSX.Element
 
 export type DescendantsPageListProps = {
   path: string,
+  limit?: number,
+  forceHideMenuItems?: ForceHideMenuItems,
 }
 
 export const DescendantsPageList = (props: DescendantsPageListProps): JSX.Element => {
-  const { path } = props;
+  const { path, limit, forceHideMenuItems } = props;
 
   const [activePage, setActivePage] = useState(1);
 
   const { data: isSharedUser } = useIsSharedUser();
 
-  const { data: pagingResult, error, mutate } = useSWRxPageList(isSharedUser ? null : path, activePage);
-
-  if (error != null) {
-    return (
-      <div className="my-5">
-        <div className="text-danger">{error.message}</div>
-      </div>
-    );
-  }
-
-  return (
-    <DescendantsPageListSubstance
-      pagingResult={pagingResult}
-      activePage={activePage}
-      setActivePage={setActivePage}
-      onPagesDeleted={() => mutate()}
-      onPagePutBacked={() => mutate()}
-    />
-  );
-};
-
-export const DescendantsPageListForCurrentPath = (): JSX.Element => {
-
-  const [activePage, setActivePage] = useState(1);
-
-  const { data: isTrashPage } = useIsTrashPage();
-  const { data: limit } = useShowPageLimitationXL();
-  const { data: pagingResult, error, mutate } = useSWRxDescendantsPageListForCurrrentPath(activePage, limit);
+  const { data: pagingResult, error, mutate } = useSWRxPageList(isSharedUser ? null : path, activePage, limit);
 
   if (error != null) {
     return (
@@ -181,8 +150,6 @@ export const DescendantsPageListForCurrentPath = (): JSX.Element => {
     );
   }
 
-  const forceHideMenuItems = isTrashPage ? [MenuItemType.RENAME] : undefined;
-
   return (
     <DescendantsPageListSubstance
       pagingResult={pagingResult}
@@ -190,7 +157,7 @@ export const DescendantsPageListForCurrentPath = (): JSX.Element => {
       setActivePage={setActivePage}
       forceHideMenuItems={forceHideMenuItems}
       onPagesDeleted={() => mutate()}
+      onPagePutBacked={() => mutate()}
     />
   );
-
 };

+ 13 - 9
packages/app/src/components/DescendantsPageListModal.tsx

@@ -1,8 +1,9 @@
 
-import React, { useState, useMemo } from 'react';
+import React, { useState, useMemo, useEffect } from 'react';
 
 import { useTranslation } from 'next-i18next';
 import dynamic from 'next/dynamic';
+import { useRouter } from 'next/router';
 import {
   Modal, ModalHeader, ModalBody,
 } from 'reactstrap';
@@ -19,15 +20,9 @@ import TimeLineIcon from './Icons/TimeLineIcon';
 
 import styles from './DescendantsPageListModal.module.scss';
 
-const DescendantsPageList = (props: DescendantsPageListProps): JSX.Element => {
-  const DescendantsPageList = dynamic<DescendantsPageListProps>(() => import('./DescendantsPageList').then(mod => mod.DescendantsPageList), { ssr: false });
-  return <DescendantsPageList {...props}/>;
-};
+const DescendantsPageList = dynamic<DescendantsPageListProps>(() => import('./DescendantsPageList').then(mod => mod.DescendantsPageList), { ssr: false });
 
-const PageTimeline = (): JSX.Element => {
-  const PageTimeline = dynamic(() => import('./PageTimeline').then(mod => mod.PageTimeline), { ssr: false });
-  return <PageTimeline />;
-};
+const PageTimeline = dynamic(() => import('./PageTimeline').then(mod => mod.PageTimeline), { ssr: false });
 
 export const DescendantsPageListModal = (): JSX.Element => {
   const { t } = useTranslation();
@@ -39,6 +34,15 @@ export const DescendantsPageListModal = (): JSX.Element => {
 
   const { data: status, close } = useDescendantsPageListModal();
 
+  const { events } = useRouter();
+
+  useEffect(() => {
+    events.on('routeChangeStart', close);
+    return () => {
+      events.off('routeChangeStart', close);
+    };
+  }, [close, events]);
+
   const navTabMapping = useMemo(() => {
     return {
       pagelist: {

+ 33 - 1
packages/app/src/components/Fab.module.scss

@@ -1,9 +1,41 @@
+@use '~/styles/bootstrap/init' as bs;
+
 .grw-fab :global {
+  position: fixed;
+  right: 1.5rem;
+  bottom: 3rem;
+  z-index: bs.$zindex-fixed;
+
+  transition: all 200ms linear;
+
+  .btn-create-page {
+    width: 60px;
+    height: 60px;
+    font-size: 24px;
+
+    box-shadow: 2px 3px 6px #0000005d;
+    svg {
+      width: 28px;
+      height: 28px;
+    }
+  }
+
+  .btn-scroll-to-top {
+    width: 40px;
+    height: 40px;
+
+    opacity: 0.4;
+    svg {
+      width: 18px;
+      height: 18px;
+    }
+  }
+
   // workaround
   // https://stackoverflow.com/a/57667536
   .fadeInUp {
     & :local {
-      animation: fab-fadeinup 1s ease 0s;
+      animation: fab-fadeinup 0.5s ease 0s;
     }
   }
   .fadeOut {

+ 50 - 34
packages/app/src/components/Fab.tsx

@@ -1,5 +1,5 @@
 import React, {
-  useState, useCallback, useRef,
+  useState, useCallback, useRef, useEffect,
 } from 'react';
 
 import { animateScroll } from 'react-scroll';
@@ -7,9 +7,9 @@ import { useRipple } from 'react-use-ripple';
 import StickyEvents from 'sticky-events';
 
 import { DEFAULT_AUTO_SCROLL_OPTS } from '~/client/util/smooth-scroll';
-import { useCurrentUser } from '~/stores/context';
 import { usePageCreateModal } from '~/stores/modal';
 import { useCurrentPagePath } from '~/stores/page';
+import { useIsAbleToChangeEditorMode } from '~/stores/ui';
 import loggerFactory from '~/utils/logger';
 
 import { CreatePageIcon } from './Icons/CreatePageIcon';
@@ -21,45 +21,61 @@ const logger = loggerFactory('growi:cli:Fab');
 
 export const Fab = (): JSX.Element => {
 
-  const { data: currentUser } = useCurrentUser();
+  const { data: isAbleToChangeEditorMode } = useIsAbleToChangeEditorMode();
   const { data: currentPath = '' } = useCurrentPagePath();
   const { open: openCreateModal } = usePageCreateModal();
 
-  const [animateClasses, setAnimateClasses] = useState('invisible');
-  const [buttonClasses, setButtonClasses] = useState('');
+  const [animateClasses, setAnimateClasses] = useState<string>('invisible');
+  const [buttonClasses, setButtonClasses] = useState<string>('');
+  const [isSticky, setIsSticky] = useState<boolean>(false);
 
   // ripple
   const createBtnRef = useRef(null);
   useRipple(createBtnRef, { rippleColor: 'rgba(255, 255, 255, 0.3)' });
 
-  /*
-  * TODO: Comment out to prevent err >>> TypeError: Cannot read properties of null (reading 'bottom')
-  *       We need add style={{ position: 'relative }} to child elements if disable StickyEvents. see: use grep = "<Fab".
-  */
-  // const stickyChangeHandler = useCallback((event) => {
-  //   logger.debug('StickyEvents.CHANGE detected');
-
-  //   const newAnimateClasses = event.detail.isSticky ? 'animated fadeInUp faster' : 'animated fadeOut faster';
-  //   const newButtonClasses = event.detail.isSticky ? '' : 'disabled grw-pointer-events-none';
-
-  //   setAnimateClasses(newAnimateClasses);
-  //   setButtonClasses(newButtonClasses);
-  // }, []);
-
-  // // setup effect by sticky event
-  // useEffect(() => {
-  //   // sticky
-  //   // See: https://github.com/ryanwalters/sticky-events
-  //   const stickyEvents = new StickyEvents({ stickySelector: '#grw-fav-sticky-trigger' });
-  //   const { stickySelector } = stickyEvents;
-  //   const elem = document.querySelector(stickySelector);
-  //   elem.addEventListener(StickyEvents.CHANGE, stickyChangeHandler);
-
-  //   // return clean up handler
-  //   return () => {
-  //     elem.removeEventListener(StickyEvents.CHANGE, stickyChangeHandler);
-  //   };
-  // }, [stickyChangeHandler]);
+  /**
+   * After the fade animation is finished, fix the button display status.
+   * Prevents the fade animation occurred each time by button components rendered.
+   * Check Fab.module.scss for fade animation time.
+   */
+  useEffect(() => {
+    const timer = setTimeout(() => {
+      if (isSticky) {
+        setAnimateClasses('visible');
+        setButtonClasses('');
+      }
+      else {
+        setAnimateClasses('invisible');
+      }
+    }, 500);
+    return () => clearTimeout(timer);
+  }, [isSticky]);
+
+  const stickyChangeHandler = useCallback((event) => {
+    logger.debug('StickyEvents.CHANGE detected');
+
+    const newAnimateClasses = event.detail.isSticky ? 'animated fadeInUp faster' : 'animated fadeOut faster';
+    const newButtonClasses = event.detail.isSticky ? '' : 'disabled grw-pointer-events-none';
+
+    setAnimateClasses(newAnimateClasses);
+    setButtonClasses(newButtonClasses);
+    setIsSticky(event.detail.isSticky);
+  }, []);
+
+  // setup effect by sticky event
+  useEffect(() => {
+    // sticky
+    // See: https://github.com/ryanwalters/sticky-events
+    const stickyEvents = new StickyEvents({ stickySelector: '#grw-fav-sticky-trigger' });
+    const { stickySelector } = stickyEvents;
+    const elem = document.querySelector(stickySelector);
+    elem.addEventListener(StickyEvents.CHANGE, stickyChangeHandler);
+
+    // return clean up handler
+    return () => {
+      elem.removeEventListener(StickyEvents.CHANGE, stickyChangeHandler);
+    };
+  }, [stickyChangeHandler]);
 
   const PageCreateButton = useCallback(() => {
     return (
@@ -102,7 +118,7 @@ export const Fab = (): JSX.Element => {
 
   return (
     <div className={`${styles['grw-fab']} grw-fab d-none d-md-block d-edit-none`} data-testid="grw-fab-container">
-      {currentUser != null && <PageCreateButton />}
+      {isAbleToChangeEditorMode && <PageCreateButton />}
       <ScrollToTopButton />
     </div>
   );

+ 2 - 0
packages/app/src/components/Layout/AdminLayout.tsx

@@ -10,6 +10,7 @@ import styles from './Admin.module.scss';
 
 
 const AdminNavigation = dynamic(() => import('~/components/Admin/Common/AdminNavigation'), { ssr: false });
+const PageCreateModal = dynamic(() => import('../PageCreateModal'), { ssr: false });
 const SystemVersion = dynamic(() => import('../SystemVersion'), { ssr: false });
 const HotkeysManager = dynamic(() => import('../Hotkeys/HotkeysManager'), { ssr: false });
 
@@ -45,6 +46,7 @@ const AdminLayout = ({
           </div>
         </div>
 
+        <PageCreateModal />
         <SystemVersion />
       </div>
 

+ 1 - 12
packages/app/src/components/Layout/BasicLayout.tsx

@@ -4,7 +4,6 @@ import dynamic from 'next/dynamic';
 import { DndProvider } from 'react-dnd';
 import { HTML5Backend } from 'react-dnd-html5-backend';
 
-import { useEditorModeClassName } from '../../client/services/layout';
 import { GrowiNavbar } from '../Navbar/GrowiNavbar';
 import Sidebar from '../Sidebar';
 
@@ -43,7 +42,7 @@ export const BasicLayout = ({ children, className }: Props): JSX.Element => {
             <Sidebar />
           </div>
 
-          <div className="flex-fill mw-0" style={{ position: 'relative' }}>
+          <div className="flex-fill mw-0">
             <AlertSiteUrlUndefined />
             {children}
           </div>
@@ -68,13 +67,3 @@ export const BasicLayout = ({ children, className }: Props): JSX.Element => {
     </RawLayout>
   );
 };
-
-export const BasicLayoutWithEditorMode = ({ children }: Props): JSX.Element => {
-  const className = useEditorModeClassName();
-
-  return (
-    <BasicLayout className={className}>
-      {children}
-    </BasicLayout>
-  );
-};

+ 1 - 1
packages/app/src/components/Layout/MainPane.tsx

@@ -20,7 +20,7 @@ export const MainPane = (props: Props): JSX.Element => {
           <div id="content-main" className="content-main grw-container-convertible">
             { sideContents != null
               ? (
-                <div className="d-flex flex-column flex-lg-row">
+                <div className="d-flex flex-column flex-column-reverse flex-lg-row">
                   <div className="flex-grow-1 flex-basis-0 mw-0">
                     {children}
                   </div>

+ 2 - 1
packages/app/src/components/Layout/RawLayout.tsx

@@ -1,10 +1,11 @@
 import React, { ReactNode, useState } from 'react';
 
+import { ColorScheme } from '@growi/core';
 import Head from 'next/head';
 import { ToastContainer } from 'react-toastify';
 import { useIsomorphicLayoutEffect } from 'usehooks-ts';
 
-import { ColorScheme, useNextThemes, NextThemesProvider } from '~/stores/use-next-themes';
+import { useNextThemes, NextThemesProvider } from '~/stores/use-next-themes';
 import loggerFactory from '~/utils/logger';
 
 

+ 1 - 1
packages/app/src/components/Layout/ShareLinkLayout.tsx

@@ -28,7 +28,7 @@ export const ShareLinkLayout = ({ children }: Props): JSX.Element => {
       <GrowiNavbar isGlobalSearchHidden={true} />
 
       <div className="page-wrapper d-flex d-print-block">
-        <div className="flex-fill mw-0" style={{ position: 'relative' }}>
+        <div className="flex-fill mw-0">
           {children}
         </div>
       </div>

+ 1 - 1
packages/app/src/components/Me/BasicInfoSettings.tsx

@@ -120,7 +120,7 @@ export const BasicInfoSettings = (): JSX.Element => {
                     checked={personalSettingsInfo?.lang === locale}
                     onChange={() => changePersonalSettingsHandler({ lang: locale })}
                   />
-                  <label className="custom-control-label" htmlFor={`radioLang${locale}`}>{fixedT('meta.display_name')}</label>
+                  <label className="custom-control-label" htmlFor={`radioLang${locale}`}>{fixedT('meta.display_name') as string}</label>
                 </div>
               );
             })

+ 3 - 3
packages/app/src/components/Navbar/AppearanceModeDropdown.tsx

@@ -47,15 +47,15 @@ export const AppearanceModeDropdown:FC<AppearanceModeDropdownProps> = (props: Ap
 
   const followOsCheckboxModifiedHandler = useCallback((isChecked: boolean) => {
     if (isChecked) {
-      setTheme(Themes.system);
+      setTheme(Themes.SYSTEM);
     }
     else {
-      setTheme(resolvedTheme ?? Themes.light);
+      setTheme(resolvedTheme ?? Themes.LIGHT);
     }
   }, [resolvedTheme, setTheme]);
 
   const userPreferenceSwitchModifiedHandler = useCallback((isDarkMode: boolean) => {
-    setTheme(isDarkMode ? Themes.dark : Themes.light);
+    setTheme(isDarkMode ? Themes.DARK : Themes.LIGHT);
   }, [setTheme]);
 
   /* eslint-disable react/prop-types */

+ 35 - 33
packages/app/src/components/Navbar/GrowiContextualSubNavigation.tsx

@@ -24,10 +24,10 @@ import {
   usePageAccessoriesModal, PageAccessoriesModalContents, IPageForPageDuplicateModal,
   usePageDuplicateModal, usePageRenameModal, usePageDeleteModal, usePagePresentationModal,
 } from '~/stores/modal';
-import { useSWRxCurrentPage, useSWRxTagsInfo } from '~/stores/page';
+import { useSWRMUTxCurrentPage, useSWRxTagsInfo } from '~/stores/page';
 import {
   EditorMode, useDrawerMode, useEditorMode, useIsAbleToShowPageManagement, useIsAbleToShowTagLabel,
-  useIsAbleToShowPageEditorModeManager, useIsAbleToShowPageAuthors,
+  useIsAbleToChangeEditorMode, useIsAbleToShowPageAuthors,
 } from '~/stores/ui';
 
 import CreateTemplateModal from '../CreateTemplateModal';
@@ -35,6 +35,7 @@ import AttachmentIcon from '../Icons/AttachmentIcon';
 import HistoryIcon from '../Icons/HistoryIcon';
 import PresentationIcon from '../Icons/PresentationIcon';
 import ShareLinkIcon from '../Icons/ShareLinkIcon';
+import { NotAvailable } from '../NotAvailable';
 import { NotAvailableForNow } from '../NotAvailableForNow';
 import { Skeleton } from '../Skeleton';
 
@@ -140,17 +141,20 @@ const PageOperationMenuItems = (props: PageOperationMenuItemsProps): JSX.Element
         {t('attachment_data')}
       </DropdownItem>
 
-      <DropdownItem
-        onClick={() => openAccessoriesModal(PageAccessoriesModalContents.ShareLink)}
-        disabled={isGuestUser || isSharedUser || isLinkSharingDisabled}
-        data-testid="open-page-accessories-modal-btn-with-share-link-management-data-tab"
-        className="grw-page-control-dropdown-item"
-      >
-        <span className="grw-page-control-dropdown-icon">
-          <ShareLinkIcon />
-        </span>
-        {t('share_links.share_link_management')}
-      </DropdownItem>
+      { !isGuestUser && !isSharedUser && (
+        <NotAvailable isDisabled={isLinkSharingDisabled ?? false} title="Disabled by admin">
+          <DropdownItem
+            onClick={() => openAccessoriesModal(PageAccessoriesModalContents.ShareLink)}
+            data-testid="open-page-accessories-modal-btn-with-share-link-management-data-tab"
+            className="grw-page-control-dropdown-item"
+          >
+            <span className="grw-page-control-dropdown-icon">
+              <ShareLinkIcon />
+            </span>
+            {t('share_links.share_link_management')}
+          </DropdownItem>
+        </NotAvailable>
+      ) }
     </>
   );
 };
@@ -184,7 +188,7 @@ const CreateTemplateMenuItems = (props: CreateTemplateMenuItemsProps): JSX.Eleme
 };
 
 type GrowiContextualSubNavigationProps = {
-  currentPage?: IPagePopulatedToShowRevision,
+  currentPage?: IPagePopulatedToShowRevision | null,
   isCompactMode?: boolean,
   isLinkSharingDisabled: boolean,
 };
@@ -196,7 +200,7 @@ const GrowiContextualSubNavigation = (props: GrowiContextualSubNavigationProps):
   const router = useRouter();
 
   const { data: shareLinkId } = useShareLinkId();
-  const { mutate: mutateCurrentPage } = useSWRxCurrentPage();
+  const { trigger: mutateCurrentPage } = useSWRMUTxCurrentPage();
 
   const { data: currentPathname } = useCurrentPathname();
   const isSharedPage = pagePathUtils.isSharedPage(currentPathname ?? '');
@@ -216,10 +220,10 @@ const GrowiContextualSubNavigation = (props: GrowiContextualSubNavigationProps):
 
   const { data: isAbleToShowPageManagement } = useIsAbleToShowPageManagement();
   const { data: isAbleToShowTagLabel } = useIsAbleToShowTagLabel();
-  const { data: isAbleToShowPageEditorModeManager } = useIsAbleToShowPageEditorModeManager();
+  const { data: isAbleToChangeEditorMode } = useIsAbleToChangeEditorMode();
   const { data: isAbleToShowPageAuthors } = useIsAbleToShowPageAuthors();
 
-  const { mutate: mutateSWRTagsInfo, data: tagsInfoData } = useSWRxTagsInfo(!isSharedPage ? currentPage?._id : undefined);
+  const { mutate: mutateSWRTagsInfo, data: tagsInfoData } = useSWRxTagsInfo(currentPage?._id);
 
   // eslint-disable-next-line max-len
   const { data: tagsForEditors, mutate: mutatePageTagsForEditors, sync: syncPageTagsForEditors } = usePageTagsForEditors(!isSharedPage ? currentPage?._id : undefined);
@@ -380,7 +384,7 @@ const GrowiContextualSubNavigation = (props: GrowiContextualSubNavigationProps):
                 ) }
               </div>
             ) }
-            {isAbleToShowPageEditorModeManager && (
+            {isAbleToChangeEditorMode && (
               <PageEditorModeManager
                 onPageEditorModeButtonClicked={viewType => mutateEditorMode(viewType)}
                 isBtnDisabled={isGuestUser}
@@ -423,21 +427,19 @@ const GrowiContextualSubNavigation = (props: GrowiContextualSubNavigationProps):
     : currentPage?.path;
 
   return (
-    <div data-testid="grw-contextual-sub-nav">
-      <GrowiSubNavigation
-        pagePath={pagePath}
-        pageId={currentPage?._id}
-        showDrawerToggler={isDrawerMode}
-        showTagLabel={isAbleToShowTagLabel}
-        isGuestUser={isGuestUser}
-        isDrawerMode={isDrawerMode}
-        isCompactMode={isCompactMode}
-        tags={isViewMode ? tagsInfoData?.tags : tagsForEditors}
-        tagsUpdatedHandler={isViewMode ? tagsUpdatedHandlerForViewMode : tagsUpdatedHandlerForEditMode}
-        rightComponent={RightComponent}
-        additionalClasses={['container-fluid']}
-      />
-    </div>
+    <GrowiSubNavigation
+      pagePath={pagePath}
+      pageId={currentPage?._id}
+      showDrawerToggler={isDrawerMode}
+      showTagLabel={isAbleToShowTagLabel}
+      isGuestUser={isGuestUser}
+      isDrawerMode={isDrawerMode}
+      isCompactMode={isCompactMode}
+      tags={isViewMode ? tagsInfoData?.tags : tagsForEditors}
+      tagsUpdatedHandler={isViewMode ? tagsUpdatedHandlerForViewMode : tagsUpdatedHandlerForEditMode}
+      rightComponent={RightComponent}
+      additionalClasses={['container-fluid']}
+    />
   );
 };
 

+ 0 - 3
packages/app/src/components/Navbar/GrowiNavbar.tsx

@@ -2,10 +2,8 @@ import React, {
   FC, memo, useMemo, useRef,
 } from 'react';
 
-import { isServer } from '@growi/core';
 import { useTranslation } from 'next-i18next';
 import dynamic from 'next/dynamic';
-import Image from 'next/image';
 import Link from 'next/link';
 import { useRipple } from 'react-use-ripple';
 import { UncontrolledTooltip } from 'reactstrap';
@@ -17,7 +15,6 @@ import { usePageCreateModal } from '~/stores/modal';
 import { useCurrentPagePath } from '~/stores/page';
 import { useIsDeviceSmallerThanMd } from '~/stores/ui';
 
-import { HasChildren } from '../../interfaces/common';
 import GrowiLogo from '../Icons/GrowiLogo';
 
 import { GlobalSearchProps } from './GlobalSearch';

+ 0 - 9
packages/app/src/components/Navbar/GrowiSubNavigation.module.scss

@@ -172,12 +172,3 @@
     }
   }
 }
-
-/*
- * shadow
- */
-.grw-subnav-append-shadow-container {
-  .grw-subnav {
-    box-shadow: 0px 0px 6px 3px rgba(black, 0.15);
-  }
-}

+ 0 - 141
packages/app/src/components/Navbar/GrowiSubNavigationSwitcher.jsx

@@ -1,141 +0,0 @@
-import React, {
-  useMemo, useState, useRef, useEffect, useCallback,
-} from 'react';
-
-import PropTypes from 'prop-types';
-import StickyEvents from 'sticky-events';
-import { debounce } from 'throttle-debounce';
-
-import { useSidebarCollapsed } from '~/stores/ui';
-import loggerFactory from '~/utils/logger';
-
-import { useSWRxCurrentPage } from '~/stores/page';
-
-import GrowiContextualSubNavigation from './GrowiContextualSubNavigation';
-
-import styles from './GrowiSubNavigationSwitcher.module.scss';
-
-const logger = loggerFactory('growi:cli:GrowiSubNavigationSticky');
-
-
-/**
- * Subnavigation
- *
- * needs:
- *   #grw-subnav-fixed-container element
- *   #grw-subnav-sticky-trigger element
- *
- * @param {object} props
- */
-const GrowiSubNavigationSwitcher = (props) => {
-
-  const { data: currentPage } = useSWRxCurrentPage();
-  const { data: isSidebarCollapsed } = useSidebarCollapsed();
-
-  const [isVisible, setVisible] = useState(false);
-  const [width, setWidth] = useState(null);
-
-  const fixedContainerRef = useRef();
-  /*
-  * Comment out to prevent err >>> TypeError: Cannot read properties of null (reading 'bottom')
-  * The above err occurs when moving to admin page after rendering normal pages.
-  * This is because id "grw-subnav-sticky-trigger" does not exist on admin pages.
-  */
-  // const stickyEvents = useMemo(() => new StickyEvents({ stickySelector: '#grw-subnav-sticky-trigger' }), []);
-
-  const initWidth = useCallback(() => {
-    const instance = fixedContainerRef.current;
-
-    if (instance == null || instance.parentNode == null) {
-      return;
-    }
-
-    // get parent width
-    const { clientWidth } = instance.parentNode;
-    // update style
-    setWidth(clientWidth);
-  }, []);
-
-  // const initVisible = useCallback(() => {
-  //   const elements = stickyEvents.stickyElements;
-
-  //   for (const elem of elements) {
-  //     const bool = stickyEvents.isSticking(elem);
-  //     if (bool) {
-  //       setVisible(bool);
-  //       break;
-  //     }
-  //   }
-
-  // }, [stickyEvents]);
-
-  // setup effect by resizing event
-  useEffect(() => {
-    const resizeHandler = debounce(100, initWidth);
-
-    window.addEventListener('resize', resizeHandler);
-
-    // return clean up handler
-    return () => {
-      window.removeEventListener('resize', resizeHandler);
-    };
-  }, [initWidth]);
-
-  const stickyChangeHandler = useCallback((event) => {
-    logger.debug('StickyEvents.CHANGE detected');
-    setVisible(event.detail.isSticky);
-  }, []);
-
-  // // setup effect by sticky event
-  // useEffect(() => {
-  //   // sticky
-  //   // See: https://github.com/ryanwalters/sticky-events
-  //   const { stickySelector } = stickyEvents;
-  //   const elem = document.querySelector(stickySelector);
-  //   elem.addEventListener(StickyEvents.CHANGE, stickyChangeHandler);
-
-  //   // return clean up handler
-  //   return () => {
-  //     elem.removeEventListener(StickyEvents.CHANGE, stickyChangeHandler);
-  //   };
-  // }, [stickyChangeHandler, stickyEvents]);
-
-  // update width when sidebar collapsing changed
-  useEffect(() => {
-    if (isSidebarCollapsed != null) {
-      setTimeout(initWidth, 300);
-    }
-  }, [isSidebarCollapsed, initWidth]);
-
-  // // initialize
-  // useEffect(() => {
-  //   initWidth();
-
-  //   // check sticky state several times
-  //   setTimeout(initVisible, 100);
-  //   setTimeout(initVisible, 300);
-  //   setTimeout(initVisible, 2000);
-
-  // }, [initWidth, initVisible]);
-
-  // ${styles['grw-subnav-switcher']}
-
-  return (
-    <div className={`${styles['grw-subnav-switcher']} ${isVisible ? '' : 'grw-subnav-switcher-hidden'}`}>
-      <div
-        id="grw-subnav-fixed-container"
-        className={`grw-subnav-fixed-container ${styles['grw-subnav-fixed-container']} position-fixed grw-subnav-append-shadow-container`}
-        ref={fixedContainerRef}
-        style={{ width }}
-      >
-        <GrowiContextualSubNavigation currentPage isCompactMode isLinkSharingDisabled />
-      </div>
-    </div>
-  );
-};
-
-GrowiSubNavigationSwitcher.propTypes = {
-  isLinkSharingDisabled: PropTypes.bool,
-};
-
-export default GrowiSubNavigationSwitcher;

+ 9 - 0
packages/app/src/components/Navbar/GrowiSubNavigationSwitcher.module.scss

@@ -19,6 +19,15 @@ $easeInOutCubic: cubic-bezier(0.65, 0, 0.35, 1);
     .grw-subnav-fixed-container {
       transition: transform 150ms $easeInOutCubic;
     }
+
+    /*
+    * shadow
+    */
+    .grw-subnav-append-shadow-container {
+      .grw-subnav {
+        box-shadow: 0px 0px 6px 3px rgba(black, 0.15);
+      }
+    }
   }
 
   &:global {

+ 114 - 0
packages/app/src/components/Navbar/GrowiSubNavigationSwitcher.tsx

@@ -0,0 +1,114 @@
+import React, {
+  useState, useRef, useEffect, useCallback,
+} from 'react';
+
+import StickyEvents from 'sticky-events';
+import { debounce } from 'throttle-debounce';
+
+import { useSWRxCurrentPage } from '~/stores/page';
+import { useSidebarCollapsed } from '~/stores/ui';
+import loggerFactory from '~/utils/logger';
+
+import GrowiContextualSubNavigation from './GrowiContextualSubNavigation';
+
+import styles from './GrowiSubNavigationSwitcher.module.scss';
+
+const logger = loggerFactory('growi:cli:GrowiSubNavigationSticky');
+
+export type GrowiSubNavigationSwitcherProps = {
+  isLinkSharingDisabled: boolean,
+}
+
+/**
+ * GrowiSubNavigation
+ *
+ * needs:
+ *   #grw-subnav-fixed-container element
+ *   #grw-subnav-sticky-trigger element
+ */
+export const GrowiSubNavigationSwitcher = (props: GrowiSubNavigationSwitcherProps): JSX.Element => {
+  const { isLinkSharingDisabled } = props;
+
+  const { data: currentPage } = useSWRxCurrentPage();
+  const { data: isSidebarCollapsed } = useSidebarCollapsed();
+
+  const [isVisible, setIsVisible] = useState<boolean>(false);
+  const [width, setWidth] = useState<number>(0);
+
+  // use more specific type HTMLDivElement for avoid assertion error.
+  // see: https://developer.mozilla.org/en-US/docs/Web/API/HTMLDivElement
+  const fixedContainerRef = useRef<HTMLDivElement>(null);
+  const clientWidth = fixedContainerRef.current?.parentElement?.clientWidth;
+
+  // Do not use clientWidth as useCallback deps, resizing events will not work in production builds.
+  const initWidth = useCallback(() => {
+    if (fixedContainerRef.current != null && fixedContainerRef.current.parentElement != null) {
+      // get parent elements width
+      const { clientWidth } = fixedContainerRef.current.parentElement;
+      setWidth(clientWidth);
+    }
+  }, []);
+
+  const stickyChangeHandler = useCallback((event) => {
+    logger.debug('StickyEvents.CHANGE detected');
+    setIsVisible(event.detail.isSticky);
+  }, []);
+
+  // setup effect by sticky-events
+  useEffect(() => {
+    // sticky-events
+    // See: https://github.com/ryanwalters/sticky-events
+    const { stickySelector } = new StickyEvents({ stickySelector: '#grw-subnav-sticky-trigger' });
+    const elem = document.querySelector(stickySelector);
+    elem.addEventListener(StickyEvents.CHANGE, stickyChangeHandler);
+
+    // return clean up handler
+    return () => {
+      elem.removeEventListener(StickyEvents.CHANGE, stickyChangeHandler);
+    };
+  }, [stickyChangeHandler]);
+
+  // setup effect by resizing event
+  useEffect(() => {
+    const resizeHandler = debounce(100, initWidth);
+    window.addEventListener('resize', resizeHandler);
+
+    // return clean up handler
+    return () => {
+      window.removeEventListener('resize', resizeHandler);
+    };
+  }, [initWidth]);
+
+  // update width when sidebar collapsing changed
+  useEffect(() => {
+    if (isSidebarCollapsed != null) {
+      setTimeout(initWidth, 300);
+    }
+  }, [isSidebarCollapsed, initWidth]);
+
+  /*
+   * initialize width.
+   * Since width is not recalculated at production build first rendering,
+   * make initWidth execution dependent on clientWidth.
+   */
+  useEffect(() => {
+    if (clientWidth != null) initWidth();
+  }, [initWidth, clientWidth]);
+
+  if (currentPage == null) {
+    return <></>;
+  }
+
+  return (
+    <div className={`${styles['grw-subnav-switcher']} ${isVisible ? '' : 'grw-subnav-switcher-hidden'}`}>
+      <div
+        id="grw-subnav-fixed-container"
+        className={`grw-subnav-fixed-container ${styles['grw-subnav-fixed-container']} position-fixed grw-subnav-append-shadow-container`}
+        ref={fixedContainerRef}
+        style={{ width }}
+      >
+        <GrowiContextualSubNavigation currentPage={currentPage} isCompactMode isLinkSharingDisabled={isLinkSharingDisabled} />
+      </div>
+    </div>
+  );
+};

+ 1 - 2
packages/app/src/components/NotAvailable.tsx

@@ -1,13 +1,12 @@
 import React from 'react';
 
-import { TFunction } from 'next-i18next';
 import { Disable } from 'react-disable';
 import { UncontrolledTooltip, UncontrolledTooltipProps } from 'reactstrap';
 
 type NotAvailableProps = {
   children: JSX.Element
   isDisabled: boolean
-  title: ReturnType<TFunction>
+  title: string
   classNamePrefix?: string
   placement?: UncontrolledTooltipProps['placement']
 }

+ 11 - 4
packages/app/src/components/NotFoundPage.tsx

@@ -3,19 +3,26 @@ import React, { useMemo } from 'react';
 import { useTranslation } from 'next-i18next';
 
 import CustomNavAndContents from './CustomNavigation/CustomNavAndContents';
-import { DescendantsPageListForCurrentPath } from './DescendantsPageList';
+import { DescendantsPageList } from './DescendantsPageList';
 import PageListIcon from './Icons/PageListIcon';
 import TimeLineIcon from './Icons/TimeLineIcon';
 import { PageTimeline } from './PageTimeline';
 
-const NotFoundPage = (): JSX.Element => {
+
+type NotFoundPageProps = {
+  path: string,
+}
+
+const NotFoundPage = (props: NotFoundPageProps): JSX.Element => {
   const { t } = useTranslation();
 
+  const { path } = props;
+
   const navTabMapping = useMemo(() => {
     return {
       pagelist: {
         Icon: PageListIcon,
-        Content: DescendantsPageListForCurrentPath,
+        Content: () => <DescendantsPageList path={path} />,
         i18n: t('page_list'),
         index: 0,
       },
@@ -26,7 +33,7 @@ const NotFoundPage = (): JSX.Element => {
         index: 1,
       },
     };
-  }, [t]);
+  }, [path, t]);
 
   return (
     <div className="d-edit-none">

+ 0 - 249
packages/app/src/components/Page.tsx

@@ -1,249 +0,0 @@
-import React, {
-  FC, useCallback,
-  useEffect, useRef,
-} from 'react';
-
-import EventEmitter from 'events';
-
-import { pagePathUtils, IPagePopulatedToShowRevision } from '@growi/core';
-import { DrawioEditByViewerProps } from '@growi/remark-drawio';
-import { useTranslation } from 'next-i18next';
-import dynamic from 'next/dynamic';
-import { HtmlElementNode } from 'rehype-toc';
-
-import MarkdownTable from '~/client/models/MarkdownTable';
-import { useSaveOrUpdate } from '~/client/services/page-operation';
-import { toastSuccess, toastError } from '~/client/util/apiNotification';
-import { OptionsToSave } from '~/interfaces/page-operation';
-import {
-  useIsGuestUser, useShareLinkId, useCurrentPathname,
-} from '~/stores/context';
-import { useEditingMarkdown } from '~/stores/editor';
-import { useDrawioModal, useHandsontableModal } from '~/stores/modal';
-import { useSWRxCurrentPage, useSWRxTagsInfo } from '~/stores/page';
-import { useViewOptions } from '~/stores/renderer';
-import {
-  useCurrentPageTocNode,
-  useIsMobile,
-} from '~/stores/ui';
-import { registerGrowiFacade } from '~/utils/growi-facade';
-import loggerFactory from '~/utils/logger';
-
-import RevisionRenderer from './Page/RevisionRenderer';
-import mdu from './PageEditor/MarkdownDrawioUtil';
-import mtu from './PageEditor/MarkdownTableUtil';
-
-import styles from './Page.module.scss';
-
-
-declare global {
-  // eslint-disable-next-line vars-on-top, no-var
-  var globalEmitter: EventEmitter;
-}
-
-// const DrawioModal = dynamic(() => import('./PageEditor/DrawioModal'), { ssr: false });
-const GridEditModal = dynamic(() => import('./PageEditor/GridEditModal'), { ssr: false });
-const LinkEditModal = dynamic(() => import('./PageEditor/LinkEditModal'), { ssr: false });
-
-
-const logger = loggerFactory('growi:Page');
-
-type Props = {
-  currentPage?: IPagePopulatedToShowRevision,
-}
-
-export const Page: FC<Props> = (props: Props) => {
-  const { t } = useTranslation();
-  const { currentPage } = props;
-
-  // Pass tocRef to generateViewOptions (=> rehypePlugin => customizeTOC) to call mutateCurrentPageTocNode when tocRef.current changes.
-  // The toc node passed by customizeTOC is assigned to tocRef.current.
-  const tocRef = useRef<HtmlElementNode>();
-
-  const storeTocNodeHandler = useCallback((toc: HtmlElementNode) => {
-    tocRef.current = toc;
-  }, []);
-
-  const { data: currentPathname } = useCurrentPathname();
-  const isSharedPage = pagePathUtils.isSharedPage(currentPathname ?? '');
-
-  const { data: shareLinkId } = useShareLinkId();
-  const { mutate: mutateCurrentPage } = useSWRxCurrentPage();
-  const { mutate: mutateEditingMarkdown } = useEditingMarkdown();
-  const { data: tagsInfo } = useSWRxTagsInfo(!isSharedPage ? currentPage?._id : undefined);
-  const { data: isGuestUser } = useIsGuestUser();
-  const { data: isMobile } = useIsMobile();
-  const { data: rendererOptions, mutate: mutateRendererOptions } = useViewOptions(storeTocNodeHandler);
-  const { mutate: mutateCurrentPageTocNode } = useCurrentPageTocNode();
-  const { open: openDrawioModal } = useDrawioModal();
-  const { open: openHandsontableModal } = useHandsontableModal();
-
-  const saveOrUpdate = useSaveOrUpdate();
-
-
-  // register to facade
-  useEffect(() => {
-    registerGrowiFacade({
-      markdownRenderer: {
-        optionsMutators: {
-          viewOptionsMutator: mutateRendererOptions,
-        },
-      },
-    });
-  }, [mutateRendererOptions]);
-
-  useEffect(() => {
-    mutateCurrentPageTocNode(tocRef.current);
-  // eslint-disable-next-line react-hooks/exhaustive-deps
-  }, [mutateCurrentPageTocNode, tocRef.current]); // include tocRef.current to call mutateCurrentPageTocNode when tocRef.current changes
-
-
-  // TODO: refactor commonize saveByDrawioModal and saveByHandsontableModal
-  const saveByDrawioModal = useCallback(async(drawioMxFile: string, bol: number, eol: number) => {
-    if (currentPage == null || tagsInfo == null) {
-      return;
-    }
-
-    // disable if share link
-    if (shareLinkId != null) {
-      return;
-    }
-
-    const currentMarkdown = currentPage.revision.body;
-    const optionsToSave: OptionsToSave = {
-      isSlackEnabled: false,
-      slackChannels: '',
-      grant: currentPage.grant,
-      grantUserGroupId: currentPage.grantedGroup?._id,
-      grantUserGroupName: currentPage.grantedGroup?.name,
-      pageTags: tagsInfo.tags,
-    };
-
-    const newMarkdown = mdu.replaceDrawioInMarkdown(drawioMxFile, currentMarkdown, bol, eol);
-
-    try {
-      const currentRevisionId = currentPage.revision._id;
-      await saveOrUpdate(
-        newMarkdown,
-        { pageId: currentPage._id, path: currentPage.path, revisionId: currentRevisionId },
-        optionsToSave,
-      );
-
-      toastSuccess(t('toaster.save_succeeded'));
-
-      // rerender
-      if (!isSharedPage) {
-        mutateCurrentPage();
-      }
-      mutateEditingMarkdown(newMarkdown);
-    }
-    catch (error) {
-      logger.error('failed to save', error);
-      toastError(error);
-    }
-  }, [currentPage, isSharedPage, mutateCurrentPage, mutateEditingMarkdown, saveOrUpdate, shareLinkId, t, tagsInfo]);
-
-  // set handler to open DrawioModal
-  useEffect(() => {
-    // disable if share link
-    if (shareLinkId != null) {
-      return;
-    }
-
-    const handler = (data: DrawioEditByViewerProps) => {
-      openDrawioModal(data.drawioMxFile, drawioMxFile => saveByDrawioModal(drawioMxFile, data.bol, data.eol));
-    };
-    globalEmitter.on('launchDrawioModal', handler);
-
-    return function cleanup() {
-      globalEmitter.removeListener('launchDrawioModal', handler);
-    };
-  }, [openDrawioModal, saveByDrawioModal, shareLinkId]);
-
-  const saveByHandsontableModal = useCallback(async(table: MarkdownTable, bol: number, eol: number) => {
-    if (currentPage == null || tagsInfo == null || shareLinkId != null) {
-      return;
-    }
-
-    const currentMarkdown = currentPage.revision.body;
-    const optionsToSave: OptionsToSave = {
-      isSlackEnabled: false,
-      slackChannels: '',
-      grant: currentPage.grant,
-      grantUserGroupId: currentPage.grantedGroup?._id,
-      grantUserGroupName: currentPage.grantedGroup?.name,
-      pageTags: tagsInfo.tags,
-    };
-
-    const newMarkdown = mtu.replaceMarkdownTableInMarkdown(table, currentMarkdown, bol, eol);
-
-    try {
-      const currentRevisionId = currentPage.revision._id;
-      await saveOrUpdate(
-        newMarkdown,
-        { pageId: currentPage._id, path: currentPage.path, revisionId: currentRevisionId },
-        optionsToSave,
-      );
-
-      toastSuccess(t('toaster.save_succeeded'));
-
-      // rerender
-      if (!isSharedPage) {
-        mutateCurrentPage();
-      }
-      mutateEditingMarkdown(newMarkdown);
-    }
-    catch (error) {
-      logger.error('failed to save', error);
-      toastError(error);
-    }
-  }, [currentPage, isSharedPage, mutateCurrentPage, mutateEditingMarkdown, saveOrUpdate, shareLinkId, t, tagsInfo]);
-
-  // set handler to open HandsonTableModal
-  useEffect(() => {
-    if (currentPage == null || shareLinkId != null) {
-      return;
-    }
-
-    const handler = (bol: number, eol: number) => {
-      const markdown = currentPage.revision.body;
-      const currentMarkdownTable = mtu.getMarkdownTableFromLine(markdown, bol, eol);
-      openHandsontableModal(currentMarkdownTable, undefined, false, table => saveByHandsontableModal(table, bol, eol));
-    };
-    globalEmitter.on('launchHandsonTableModal', handler);
-
-    return function cleanup() {
-      globalEmitter.removeListener('launchHandsonTableModal', handler);
-    };
-  }, [currentPage, openHandsontableModal, saveByHandsontableModal, shareLinkId]);
-
-  if (currentPage == null || isGuestUser == null || rendererOptions == null) {
-    const entries = Object.entries({
-      currentPage, isGuestUser, rendererOptions,
-    })
-      .map(([key, value]) => [key, value == null ? 'null' : undefined])
-      .filter(([, value]) => value != null);
-
-    logger.warn('Some of materials are missing.', Object.fromEntries(entries));
-    return null;
-  }
-
-  const { _id: revisionId, body: markdown } = currentPage.revision;
-
-  return (
-    <div className={`mb-5 ${isMobile ? `page-mobile ${styles['page-mobile']}` : ''}`}>
-
-      { revisionId != null && (
-        <RevisionRenderer rendererOptions={rendererOptions} markdown={markdown} />
-      )}
-
-      { !isGuestUser && (
-        <>
-          <GridEditModal />
-          <LinkEditModal />
-        </>
-      )}
-    </div>
-  );
-
-};

+ 30 - 132
packages/app/src/components/Page/DisplaySwitcher.tsx

@@ -1,157 +1,55 @@
-import React, { useCallback, useEffect, useMemo } from 'react';
+import React from 'react';
 
-import { pagePathUtils } from '@growi/core';
 import dynamic from 'next/dynamic';
 
-import { SocketEventName } from '~/interfaces/websocket';
-import {
-  useIsEditable, useShareLinkId, useIsNotFound,
-} from '~/stores/context';
-import { useIsHackmdDraftUpdatingInRealtime } from '~/stores/hackmd';
-import { useCurrentPagePath, useSWRxCurrentPage } from '~/stores/page';
-import {
-  useSetRemoteLatestPageData,
-} from '~/stores/remote-latest-page';
-import { EditorMode, useEditorMode } from '~/stores/ui';
-import { useGlobalSocket } from '~/stores/websocket';
 
-import CustomTabContent from '../CustomNavigation/CustomTabContent';
-import { Page } from '../Page';
-import { UserInfoProps } from '../User/UserInfo';
+import { useHackmdDraftUpdatedEffect } from '~/client/services/side-effects/hackmd-draft-updated';
+import { useHashChangedEffect } from '~/client/services/side-effects/hash-changed';
+import { usePageUpdatedEffect } from '~/client/services/side-effects/page-updated';
+import { useIsEditable } from '~/stores/context';
+import { EditorMode, useEditorMode } from '~/stores/ui';
 
-const { isUsersHomePage } = pagePathUtils;
+import { LazyRenderer } from '../Common/LazyRenderer';
 
 
 const PageEditor = dynamic(() => import('../PageEditor'), { ssr: false });
 const PageEditorByHackmd = dynamic(() => import('../PageEditorByHackmd').then(mod => mod.PageEditorByHackmd), { ssr: false });
 const EditorNavbarBottom = dynamic(() => import('../PageEditor/EditorNavbarBottom'), { ssr: false });
-const HashChanged = dynamic(() => import('../EventListeneres/HashChanged'), { ssr: false });
-const NotFoundPage = dynamic(() => import('../NotFoundPage'), { ssr: false });
-const UserInfo = dynamic<UserInfoProps>(() => import('../User/UserInfo').then(mod => mod.UserInfo), { ssr: false });
-
-
-const PageView = React.memo((): JSX.Element => {
-  const { data: currentPagePath } = useCurrentPagePath();
-  const { data: shareLinkId } = useShareLinkId();
-  const { data: isNotFound } = useIsNotFound();
-  const { data: currentPage } = useSWRxCurrentPage();
-  const { setRemoteLatestPageData } = useSetRemoteLatestPageData();
-
-  const { mutate: mutateIsHackmdDraftUpdatingInRealtime } = useIsHackmdDraftUpdatingInRealtime();
-
-  const isUsersHomePagePath = isUsersHomePage(currentPagePath ?? '');
-
-  const { data: socket } = useGlobalSocket();
-
-  const setLatestRemotePageData = useCallback((data) => {
-    const { s2cMessagePageUpdated } = data;
-
-    const remoteData = {
-      remoteRevisionId: s2cMessagePageUpdated.revisionId,
-      remoteRevisionBody: s2cMessagePageUpdated.revisionBody,
-      remoteRevisionLastUpdateUser: s2cMessagePageUpdated.remoteLastUpdateUser,
-      remoteRevisionLastUpdatedAt: s2cMessagePageUpdated.revisionUpdateAt,
-      revisionIdHackmdSynced: s2cMessagePageUpdated.revisionIdHackmdSynced,
-      hasDraftOnHackmd: s2cMessagePageUpdated.hasDraftOnHackmd,
-    };
-    setRemoteLatestPageData(remoteData);
-  }, [setRemoteLatestPageData]);
-
-  const setIsHackmdDraftUpdatingInRealtime = useCallback((data) => {
-    const { s2cMessagePageUpdated } = data;
-    if (s2cMessagePageUpdated.pageId === currentPage?._id) {
-      mutateIsHackmdDraftUpdatingInRealtime(true);
-    }
-  }, [currentPage?._id, mutateIsHackmdDraftUpdatingInRealtime]);
-
-  // listen socket for someone updating this page
-  useEffect(() => {
-
-    if (socket == null) { return }
-
-    socket.on(SocketEventName.PageUpdated, setLatestRemotePageData);
-
-    return () => {
-      socket.off(SocketEventName.PageUpdated, setLatestRemotePageData);
-    };
-
-  }, [setLatestRemotePageData, socket]);
 
-  // listen socket for hackmd saved
-  useEffect(() => {
 
-    if (socket == null) { return }
+type Props = {
+  pageView: JSX.Element,
+}
 
-    socket.on(SocketEventName.EditingWithHackmd, setIsHackmdDraftUpdatingInRealtime);
-
-    return () => {
-      socket.off(SocketEventName.EditingWithHackmd, setIsHackmdDraftUpdatingInRealtime);
-    };
-  }, [setIsHackmdDraftUpdatingInRealtime, socket]);
-
-  return (
-    <>
-      { isUsersHomePagePath && <UserInfo author={currentPage?.creator} /> }
-      { !isNotFound && <Page currentPage={currentPage ?? undefined} /> }
-      { isNotFound && <NotFoundPage /> }
-    </>
-  );
-});
-PageView.displayName = 'PageView';
-
-
-const DisplaySwitcher = React.memo((): JSX.Element => {
+export const DisplaySwitcher = (props: Props): JSX.Element => {
+  const { pageView } = props;
 
+  const { data: editorMode = EditorMode.View } = useEditorMode();
   const { data: isEditable } = useIsEditable();
 
-  const { data: editorMode = EditorMode.View } = useEditorMode();
+  usePageUpdatedEffect();
+  useHashChangedEffect();
+  useHackmdDraftUpdatedEffect();
 
   const isViewMode = editorMode === EditorMode.View;
 
-  const navTabMapping = useMemo(() => {
-    return {
-      [EditorMode.View]: {
-        Content: () => (
-          <div data-testid="page-view" id="page-view">
-            <PageView />
-          </div>
-        ),
-      },
-      [EditorMode.Editor]: {
-        Content: () => (
-          isEditable
-            ? (
-              <div data-testid="page-editor" id="page-editor">
-                <PageEditor />
-              </div>
-            )
-            : <></>
-        ),
-      },
-      [EditorMode.HackMD]: {
-        Content: () => (
-          isEditable
-            ? (
-              <div id="page-editor-with-hackmd">
-                <PageEditorByHackmd />
-              </div>
-            )
-            : <></>
-        ),
-      },
-    };
-  }, [isEditable]);
-
-
   return (
     <>
-      <CustomTabContent activeTab={editorMode} navTabMapping={navTabMapping} />
+      { isViewMode && pageView }
+
+      <LazyRenderer shouldRender={isEditable === true && editorMode === EditorMode.Editor}>
+        <div data-testid="page-editor" id="page-editor" className="editor-root">
+          <PageEditor />
+        </div>
+      </LazyRenderer>
+
+      <LazyRenderer shouldRender={isEditable === true && editorMode === EditorMode.HackMD}>
+        <div id="page-editor-with-hackmd" className="editor-root">
+          <PageEditorByHackmd />
+        </div>
+      </LazyRenderer>
 
       { isEditable && !isViewMode && <EditorNavbarBottom /> }
-      { isEditable && <HashChanged></HashChanged> }
     </>
   );
-});
-DisplaySwitcher.displayName = 'DisplaySwitcher';
-
-export default DisplaySwitcher;
+};

+ 84 - 0
packages/app/src/components/Page/PageContents.tsx

@@ -0,0 +1,84 @@
+import React, { useEffect } from 'react';
+
+import { useTranslation } from 'next-i18next';
+
+import { useUpdateStateAfterSave } from '~/client/services/page-operation';
+import { useDrawioModalLauncherForView } from '~/client/services/side-effects/drawio-modal-launcher-for-view';
+import { useHandsontableModalLauncherForView } from '~/client/services/side-effects/handsontable-modal-launcher-for-view';
+import { toastSuccess, toastError } from '~/client/util/toastr';
+import { useSWRxCurrentPage } from '~/stores/page';
+import { useViewOptions } from '~/stores/renderer';
+import { registerGrowiFacade } from '~/utils/growi-facade';
+import loggerFactory from '~/utils/logger';
+
+import RevisionRenderer from './RevisionRenderer';
+
+
+const logger = loggerFactory('growi:Page');
+
+
+export const PageContents = (): JSX.Element => {
+  const { t } = useTranslation();
+
+  const { data: currentPage } = useSWRxCurrentPage();
+  const updateStateAfterSave = useUpdateStateAfterSave(currentPage?._id);
+
+  const { data: rendererOptions, mutate: mutateRendererOptions } = useViewOptions();
+
+  // register to facade
+  useEffect(() => {
+    registerGrowiFacade({
+      markdownRenderer: {
+        optionsMutators: {
+          viewOptionsMutator: mutateRendererOptions,
+        },
+      },
+    });
+  }, [mutateRendererOptions]);
+
+  useHandsontableModalLauncherForView({
+    onSaveSuccess: () => {
+      toastSuccess(t('toaster.save_succeeded'));
+
+      updateStateAfterSave?.();
+    },
+    onSaveError: (error) => {
+      toastError(error);
+    },
+  });
+
+  useDrawioModalLauncherForView({
+    onSaveSuccess: () => {
+      toastSuccess(t('toaster.save_succeeded'));
+
+      updateStateAfterSave?.();
+    },
+    onSaveError: (error) => {
+      toastError(error);
+    },
+  });
+
+
+  if (currentPage == null || rendererOptions == null) {
+    const entries = Object.entries({
+      currentPage, rendererOptions,
+    })
+      .map(([key, value]) => [key, value == null ? 'null' : undefined])
+      .filter(([, value]) => value != null);
+
+    logger.warn('Some of materials are missing.', Object.fromEntries(entries));
+
+    return <></>;
+  }
+
+  const { _id: revisionId, body: markdown } = currentPage.revision;
+
+  return (
+    <>
+      { revisionId != null && (
+        <RevisionRenderer rendererOptions={rendererOptions} markdown={markdown} />
+      )}
+    </>
+  );
+
+};

+ 0 - 0
packages/app/src/components/Page.module.scss → packages/app/src/components/Page/PageView.module.scss


+ 129 - 0
packages/app/src/components/Page/PageView.tsx

@@ -0,0 +1,129 @@
+import React, { useMemo } from 'react';
+
+import { type IPagePopulatedToShowRevision, pagePathUtils } from '@growi/core';
+import dynamic from 'next/dynamic';
+
+
+import {
+  useIsForbidden, useIsIdenticalPath, useIsNotCreatable, useIsNotFound,
+} from '~/stores/context';
+import { useIsMobile } from '~/stores/ui';
+
+import type { CommentsProps } from '../Comments';
+import { MainPane } from '../Layout/MainPane';
+import { PageAlerts } from '../PageAlert/PageAlerts';
+import { PageContentFooter } from '../PageContentFooter';
+import type { PageSideContentsProps } from '../PageSideContents';
+import { UserInfo } from '../User/UserInfo';
+import type { UsersHomePageFooterProps } from '../UsersHomePageFooter';
+
+import { PageContents } from './PageContents';
+
+import styles from './PageView.module.scss';
+
+
+const { isUsersHomePage } = pagePathUtils;
+
+
+const NotCreatablePage = dynamic(() => import('../NotCreatablePage').then(mod => mod.NotCreatablePage), { ssr: false });
+const ForbiddenPage = dynamic(() => import('../ForbiddenPage'), { ssr: false });
+const NotFoundPage = dynamic(() => import('../NotFoundPage'), { ssr: false });
+const PageSideContents = dynamic<PageSideContentsProps>(() => import('../PageSideContents').then(mod => mod.PageSideContents), { ssr: false });
+const Comments = dynamic<CommentsProps>(() => import('../Comments').then(mod => mod.Comments), { ssr: false });
+const UsersHomePageFooter = dynamic<UsersHomePageFooterProps>(() => import('../UsersHomePageFooter')
+  .then(mod => mod.UsersHomePageFooter), { ssr: false });
+
+const IdenticalPathPage = (): JSX.Element => {
+  const IdenticalPathPage = dynamic(() => import('../IdenticalPathPage').then(mod => mod.IdenticalPathPage), { ssr: false });
+  return <IdenticalPathPage />;
+};
+
+
+type Props = {
+  pagePath: string,
+  page?: IPagePopulatedToShowRevision,
+  ssrBody?: JSX.Element,
+}
+
+export const PageView = (props: Props): JSX.Element => {
+  const {
+    pagePath, page, ssrBody,
+  } = props;
+
+  const pageId = page?._id;
+
+  const { data: isIdenticalPathPage } = useIsIdenticalPath();
+  const { data: isForbidden } = useIsForbidden();
+  const { data: isNotCreatable } = useIsNotCreatable();
+  const { data: isNotFound } = useIsNotFound();
+  const { data: isMobile } = useIsMobile();
+
+  const specialContents = useMemo(() => {
+    if (isIdenticalPathPage) {
+      return <IdenticalPathPage />;
+    }
+    if (isForbidden) {
+      return <ForbiddenPage />;
+    }
+    if (isNotCreatable) {
+      return <NotCreatablePage />;
+    }
+    if (isNotFound) {
+      return <NotFoundPage path={pagePath} />;
+    }
+  }, [isForbidden, isIdenticalPathPage, isNotCreatable, isNotFound, pagePath]);
+
+  const sideContents = !isNotFound && !isNotCreatable
+    ? (
+      <PageSideContents page={page} />
+    )
+    : null;
+
+  const footerContents = !isIdenticalPathPage && !isNotFound && page != null
+    ? (
+      <>
+        { pageId != null && pagePath != null && (
+          <Comments pageId={pageId} pagePath={pagePath} revision={page.revision} />
+        ) }
+        { pagePath != null && isUsersHomePage(pagePath) && (
+          <UsersHomePageFooter creatorId={page.creator._id}/>
+        ) }
+        <PageContentFooter page={page} />
+      </>
+    )
+    : null;
+
+  const isUsersHomePagePath = isUsersHomePage(pagePath);
+
+  const contents = specialContents != null
+    ? <></>
+    // TODO: show SSR body
+    // : (() => {
+    //   const PageContents = dynamic(() => import('./PageContents').then(mod => mod.PageContents), {
+    //     ssr: false,
+    //     // loading: () => ssrBody ?? <></>,
+    //   });
+    //   return <PageContents />;
+    // })();
+    : <PageContents />;
+
+  return (
+    <MainPane
+      sideContents={sideContents}
+      footerContents={footerContents}
+    >
+      <PageAlerts />
+
+      { specialContents }
+      { specialContents == null && (
+        <>
+          { isUsersHomePagePath && <UserInfo author={page?.creator} /> }
+          <div className={`mb-5 ${isMobile ? `page-mobile ${styles['page-mobile']}` : ''}`}>
+            { contents }
+          </div>
+        </>
+      ) }
+
+    </MainPane>
+  );
+};

+ 1 - 3
packages/app/src/components/Page/RevisionLoader.tsx

@@ -84,9 +84,7 @@ export const RevisionLoader = (props: RevisionLoaderProps): JSX.Element => {
   /* ----- before load ----- */
   if (lazy && !isLoaded) {
     return (
-      <Waypoint onPositionChange={onWaypointChange} bottomOffset="-100px">
-        <div></div>
-      </Waypoint>
+      <Waypoint onPositionChange={onWaypointChange} bottomOffset="-100px" />
     );
   }
 

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