Przeglądaj źródła

Merge branch 'master' into dependabot/github_actions/anothrNick/github-tag-action-1.38.0

Luqman Grune 4 lat temu
rodzic
commit
c595f0b98b
100 zmienionych plików z 3900 dodań i 1733 usunięć
  1. 11 1
      .devcontainer/docker-compose.yml
  2. 10 0
      .eslintrc.js
  3. 2 0
      .github/workflows/ci-app-prod.yml
  4. 126 128
      .github/workflows/ci-app.yml
  5. 8 8
      .github/workflows/ci-slackbot-proxy.yml
  6. 1 1
      .github/workflows/codeql-analysis.yml
  7. 2 2
      .github/workflows/draft-release.yml
  8. 2 2
      .github/workflows/list-unhealthy-branches.yml
  9. 2 2
      .github/workflows/release-rc.yml
  10. 5 5
      .github/workflows/release-slackbot-proxy.yml
  11. 7 7
      .github/workflows/release.yml
  12. 27 21
      .github/workflows/reusable-app-prod.yml
  13. 6 5
      .github/workflows/reusable-app-reg-suit.yml
  14. 24 1
      CHANGELOG.md
  15. 0 2
      README.md
  16. 0 2
      README_JP.md
  17. 1 1
      lerna.json
  18. 1 1
      package.json
  19. 3 2
      packages/app/.env.development
  20. 12 0
      packages/app/.eslintrc.js
  21. 2 2
      packages/app/bin/github-actions/update-readme.sh
  22. 3 1
      packages/app/config/ci/.env.local.for-auto-install
  23. 0 3
      packages/app/config/ci/.env.local.for-ci
  24. 3 0
      packages/app/config/logger/config.dev.js
  25. 4 2
      packages/app/docker/README.md
  26. 19 0
      packages/app/jest.config.js
  27. 25 18
      packages/app/package.json
  28. 36 2
      packages/app/resource/locales/en_US/admin/admin.json
  29. 4 4
      packages/app/resource/locales/en_US/sandbox.md
  30. 94 23
      packages/app/resource/locales/en_US/translation.json
  31. 35 1
      packages/app/resource/locales/ja_JP/admin/admin.json
  32. 3 3
      packages/app/resource/locales/ja_JP/sandbox.md
  33. 90 20
      packages/app/resource/locales/ja_JP/translation.json
  34. 35 1
      packages/app/resource/locales/zh_CN/admin/admin.json
  35. 4 4
      packages/app/resource/locales/zh_CN/sandbox.md
  36. 101 31
      packages/app/resource/locales/zh_CN/translation.json
  37. 3 0
      packages/app/resource/search/mappings-es6.json
  38. 118 0
      packages/app/resource/search/mappings-es7-for-ci.json
  39. 36 15
      packages/app/src/client/admin.jsx
  40. 24 24
      packages/app/src/client/app.jsx
  41. 14 0
      packages/app/src/client/base.jsx
  42. 3 1
      packages/app/src/client/interfaces/react-bootstrap-typeahead.ts
  43. 13 0
      packages/app/src/client/interfaces/selectable-all.ts
  44. 2 11
      packages/app/src/client/legacy/crowi-presentation.js
  45. 29 0
      packages/app/src/client/services/AdminAppContainer.js
  46. 58 3
      packages/app/src/client/services/AdminGeneralSecurityContainer.js
  47. 10 7
      packages/app/src/client/services/AdminHomeContainer.js
  48. 23 9
      packages/app/src/client/services/AdminUserGroupDetailContainer.js
  49. 55 17
      packages/app/src/client/services/ContextExtractor.tsx
  50. 0 54
      packages/app/src/client/services/PageAccessoriesContainer.js
  51. 5 110
      packages/app/src/client/services/PageContainer.js
  52. 62 0
      packages/app/src/client/services/page-operation.ts
  53. 16 1
      packages/app/src/client/services/user-ui-settings.ts
  54. 2 2
      packages/app/src/client/util/smooth-scroll.ts
  55. 99 68
      packages/app/src/components/Admin/AdminHome/AdminHome.jsx
  56. 9 3
      packages/app/src/components/Admin/AdminHome/InstalledPluginTable.jsx
  57. 13 5
      packages/app/src/components/Admin/AdminHome/SystemInfomationTable.jsx
  58. 56 5
      packages/app/src/components/Admin/App/AppSettingsPageContents.jsx
  59. 72 0
      packages/app/src/components/Admin/App/ConfirmModal.tsx
  60. 80 0
      packages/app/src/components/Admin/App/MaintenanceMode.tsx
  61. 62 0
      packages/app/src/components/Admin/App/V5PageMigration.tsx
  62. 2 2
      packages/app/src/components/Admin/Customize/Customize.jsx
  63. 2 1
      packages/app/src/components/Admin/ElasticsearchManagement/StatusTable.jsx
  64. 2 2
      packages/app/src/components/Admin/ExportArchiveDataPage.jsx
  65. 2 2
      packages/app/src/components/Admin/FullTextSearchManagement.jsx
  66. 6 1
      packages/app/src/components/Admin/ImportData/GrowiArchive/ImportForm.jsx
  67. 2 2
      packages/app/src/components/Admin/ImportData/ImportDataPageContents.jsx
  68. 2 2
      packages/app/src/components/Admin/LegacySlackIntegration/LegacySlackIntegration.jsx
  69. 2 2
      packages/app/src/components/Admin/MarkdownSetting/MarkDownSettingContents.jsx
  70. 1 1
      packages/app/src/components/Admin/Notification/GlobalNotificationList.jsx
  71. 1 1
      packages/app/src/components/Admin/Notification/ManageGlobalNotification.jsx
  72. 3 3
      packages/app/src/components/Admin/Notification/NotificationSetting.jsx
  73. 2 2
      packages/app/src/components/Admin/Security/SecurityManagementContents.jsx
  74. 245 49
      packages/app/src/components/Admin/Security/SecuritySetting.jsx
  75. 2 2
      packages/app/src/components/Admin/SlackIntegration/SlackIntegration.jsx
  76. 0 118
      packages/app/src/components/Admin/UserGroup/UserGroupCreateForm.jsx
  77. 0 216
      packages/app/src/components/Admin/UserGroup/UserGroupDeleteModal.jsx
  78. 216 0
      packages/app/src/components/Admin/UserGroup/UserGroupDeleteModal.tsx
  79. 70 0
      packages/app/src/components/Admin/UserGroup/UserGroupDropdown.tsx
  80. 160 0
      packages/app/src/components/Admin/UserGroup/UserGroupForm.tsx
  81. 122 0
      packages/app/src/components/Admin/UserGroup/UserGroupModal.tsx
  82. 0 152
      packages/app/src/components/Admin/UserGroup/UserGroupPage.jsx
  83. 199 0
      packages/app/src/components/Admin/UserGroup/UserGroupPage.tsx
  84. 0 157
      packages/app/src/components/Admin/UserGroup/UserGroupTable.jsx
  85. 202 0
      packages/app/src/components/Admin/UserGroup/UserGroupTable.tsx
  86. 0 49
      packages/app/src/components/Admin/UserGroupDetail/UserGroupDetailPage.jsx
  87. 328 0
      packages/app/src/components/Admin/UserGroupDetail/UserGroupDetailPage.tsx
  88. 0 111
      packages/app/src/components/Admin/UserGroupDetail/UserGroupEditForm.jsx
  89. 2 2
      packages/app/src/components/Admin/UserGroupDetail/UserGroupPageList.jsx
  90. 2 2
      packages/app/src/components/Admin/UserManagement.jsx
  91. 1 1
      packages/app/src/components/Admin/Users/UserTable.jsx
  92. 32 37
      packages/app/src/components/BookmarkButtons.tsx
  93. 128 0
      packages/app/src/components/Common/ClosableTextInput.tsx
  94. 317 0
      packages/app/src/components/Common/Dropdown/PageItemControl.tsx
  95. 0 62
      packages/app/src/components/ComparePathsTable.jsx
  96. 0 96
      packages/app/src/components/ContentLinkButtons.jsx
  97. 66 0
      packages/app/src/components/ContentLinkButtons.tsx
  98. 3 13
      packages/app/src/components/CreateTemplateModal.jsx
  99. 22 4
      packages/app/src/components/CustomNavigation/CustomTabContent.tsx
  100. 184 0
      packages/app/src/components/DescendantsPageList.tsx

+ 11 - 1
.devcontainer/docker-compose.yml

@@ -34,12 +34,21 @@ services:
     volumes:
     volumes:
       - /data/db
       - /data/db
 
 
+  ogp:
+    image: ghcr.io/weseek/growi-unique-ogp:latest
+    ports:
+      - 8088:8088
+    restart: unless-stopped
+    tty: true
+
   # This container requires '../../growi-docker-compose' repository
   # This container requires '../../growi-docker-compose' repository
   #   cloned from https://github.com/weseek/growi-docker-compose.git
   #   cloned from https://github.com/weseek/growi-docker-compose.git
   elasticsearch:
   elasticsearch:
     build:
     build:
       context: ../../growi-docker-compose/elasticsearch
       context: ../../growi-docker-compose/elasticsearch
       dockerfile: ./Dockerfile
       dockerfile: ./Dockerfile
+      args:
+        - version=7.16.1
     container_name: elasticsearch
     container_name: elasticsearch
     restart: unless-stopped
     restart: unless-stopped
     ports:
     ports:
@@ -47,6 +56,7 @@ services:
     environment:
     environment:
       - bootstrap.memory_lock=true
       - bootstrap.memory_lock=true
       - "ES_JAVA_OPTS=-Xms256m -Xmx256m"
       - "ES_JAVA_OPTS=-Xms256m -Xmx256m"
+      - LOG4J_FORMAT_MSG_NO_LOOKUPS=true # CVE-2021-44228 mitigation for Elasticsearch <= 6.8.20/7.16.0
     ulimits:
     ulimits:
       memlock:
       memlock:
         soft: -1
         soft: -1
@@ -57,7 +67,7 @@ services:
 
 
   #need to adjust kibana version based on elasticsearch version
   #need to adjust kibana version based on elasticsearch version
   kibana:
   kibana:
-    image: docker.elastic.co/kibana/kibana:6.8.0
+    image: docker.elastic.co/kibana/kibana:7.17.1
     restart: unless-stopped
     restart: unless-stopped
     environment:
     environment:
       ELASTICSEARCH_HOSTS: 'http://elasticsearch:9200'
       ELASTICSEARCH_HOSTS: 'http://elasticsearch:9200'

+ 10 - 0
.eslintrc.js

@@ -12,6 +12,7 @@ module.exports = {
   },
   },
   plugins: [
   plugins: [
     'jest',
     'jest',
+    'regex',
   ],
   ],
   rules: {
   rules: {
     'import/prefer-default-export': 'off',
     'import/prefer-default-export': 'off',
@@ -30,5 +31,14 @@ module.exports = {
       'error',
       'error',
       { additionalTestBlockFunctions: ['each.test'] },
       { additionalTestBlockFunctions: ['each.test'] },
     ],
     ],
+    'regex/invalid': ['error', [
+      {
+        regex: '\\?\\<\\!',
+        message: 'Do not use any negative lookbehind',
+      }, {
+        regex: '\\?\\<\\=',
+        message: 'Do not use any Positive lookbehind',
+      },
+    ]],
   },
   },
 };
 };

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

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

+ 126 - 128
.github/workflows/ci-app.yml

@@ -9,7 +9,6 @@ on:
       - support/prepare-v**
       - support/prepare-v**
 
 
 jobs:
 jobs:
-
   lint:
   lint:
     runs-on: ubuntu-latest
     runs-on: ubuntu-latest
 
 
@@ -18,46 +17,45 @@ jobs:
         node-version: [16.x]
         node-version: [16.x]
 
 
     steps:
     steps:
-    - uses: actions/checkout@v2
-
-    - uses: actions/setup-node@v2
-      with:
-        node-version: ${{ matrix.node-version }}
-        cache: 'yarn'
-        cache-dependency-path: '**/yarn.lock'
-
-    - name: Cache/Restore node_modules
-      id: cache-dependencies
-      uses: actions/cache@v2
-      with:
-        path: |
-          **/node_modules
-        key: node_modules-${{ runner.OS }}-node${{ matrix.node-version }}-${{ hashFiles('**/yarn.lock') }}
-        restore-keys: |
-          node_modules-${{ runner.OS }}-node${{ matrix.node-version }}-
-
-    - name: lerna bootstrap
-      run: |
-        npx lerna bootstrap -- --frozen-lockfile
-
-    - name: lerna run lint for plugins
-      run: |
-        yarn lerna run lint --scope @growi/plugin-*
-    - name: lerna run lint for app
-      run: |
-        yarn lerna run lint --scope @growi/app --scope @growi/core --scope @growi/ui
-
-    - name: Slack Notification
-      uses: weseek/ghaction-slack-notification@master
-      if: failure()
-      with:
-        type: ${{ job.status }}
-        job_name: '*Node CI for growi - lint (${{ matrix.node-version }})*'
-        channel: '#ci'
-        isCompactMode: true
-        url: ${{ secrets.SLACK_WEBHOOK_URL }}
-
-
+      - uses: actions/checkout@v3
+
+      - uses: actions/setup-node@v3
+        with:
+          node-version: ${{ matrix.node-version }}
+          cache: 'yarn'
+          cache-dependency-path: '**/yarn.lock'
+
+      - name: Cache/Restore node_modules
+        id: cache-dependencies
+        uses: actions/cache@v3
+        with:
+          path: |
+            **/node_modules
+          key: node_modules-${{ runner.OS }}-node${{ matrix.node-version }}-${{ hashFiles('**/yarn.lock') }}-${{ hashFiles('packages/app/package.json') }}
+          restore-keys: |
+            node_modules-${{ runner.OS }}-node${{ matrix.node-version }}-${{ hashFiles('**/yarn.lock') }}-
+            node_modules-${{ runner.OS }}-node${{ matrix.node-version }}-
+
+      - name: lerna bootstrap
+        run: |
+          npx lerna bootstrap -- --frozen-lockfile
+
+      - name: lerna run lint for plugins
+        run: |
+          yarn lerna run lint --scope @growi/plugin-*
+      - name: lerna run lint for app
+        run: |
+          yarn lerna run lint --scope @growi/app --scope @growi/core --scope @growi/ui
+
+      - name: Slack Notification
+        uses: weseek/ghaction-slack-notification@master
+        if: failure()
+        with:
+          type: ${{ job.status }}
+          job_name: '*Node CI for growi - lint (${{ matrix.node-version }})*'
+          channel: '#ci'
+          isCompactMode: true
+          url: ${{ secrets.SLACK_WEBHOOK_URL }}
 
 
   test:
   test:
     runs-on: ubuntu-latest
     runs-on: ubuntu-latest
@@ -70,55 +68,54 @@ jobs:
       mongodb:
       mongodb:
         image: mongo:4.4
         image: mongo:4.4
         ports:
         ports:
-        - 27017/tcp
+          - 27017/tcp
 
 
     steps:
     steps:
-    - uses: actions/checkout@v2
-
-    - uses: actions/setup-node@v2
-      with:
-        node-version: ${{ matrix.node-version }}
-        cache: 'yarn'
-        cache-dependency-path: '**/yarn.lock'
-
-    - name: Cache/Restore node_modules
-      id: cache-dependencies
-      uses: actions/cache@v2
-      with:
-        path: |
-          **/node_modules
-        key: node_modules-${{ runner.OS }}-node${{ matrix.node-version }}-${{ hashFiles('**/yarn.lock') }}
-        restore-keys: |
-          node_modules-${{ runner.OS }}-node${{ matrix.node-version }}-
-
-    - name: lerna bootstrap
-      run: |
-        npx lerna bootstrap -- --frozen-lockfile
-
-    - name: yarn test
-      working-directory: ./packages/app
-      run: |
-        yarn test
-      env:
-        MONGO_URI: mongodb://localhost:${{ job.services.mongodb.ports['27017'] }}/growi_test
-
-    - name: Upload coverage report as artifact
-      uses: actions/upload-artifact@v2
-      with:
-        name: Coverage Report
-        path: packages/app/coverage
-
-    - name: Slack Notification
-      uses: weseek/ghaction-slack-notification@master
-      if: failure()
-      with:
-        type: ${{ job.status }}
-        job_name: '*Node CI for growi - test (${{ matrix.node-version }})*'
-        channel: '#ci'
-        isCompactMode: true
-        url: ${{ secrets.SLACK_WEBHOOK_URL }}
-
-
+      - uses: actions/checkout@v3
+
+      - uses: actions/setup-node@v3
+        with:
+          node-version: ${{ matrix.node-version }}
+          cache: 'yarn'
+          cache-dependency-path: '**/yarn.lock'
+
+      - name: Cache/Restore node_modules
+        id: cache-dependencies
+        uses: actions/cache@v3
+        with:
+          path: |
+            **/node_modules
+          key: node_modules-${{ runner.OS }}-node${{ matrix.node-version }}-${{ hashFiles('**/yarn.lock') }}-${{ hashFiles('packages/app/package.json') }}
+          restore-keys: |
+            node_modules-${{ runner.OS }}-node${{ matrix.node-version }}-${{ hashFiles('**/yarn.lock') }}-
+            node_modules-${{ runner.OS }}-node${{ matrix.node-version }}-
+
+      - name: lerna bootstrap
+        run: |
+          npx lerna bootstrap -- --frozen-lockfile
+
+      - name: yarn test
+        working-directory: ./packages/app
+        run: |
+          yarn test:ci --selectProjects unit server ; yarn test:ci --selectProjects server-v5
+        env:
+          MONGO_URI: mongodb://localhost:${{ job.services.mongodb.ports['27017'] }}/growi_test
+
+      - name: Upload coverage report as artifact
+        uses: actions/upload-artifact@v2
+        with:
+          name: Coverage Report
+          path: packages/app/coverage
+
+      - name: Slack Notification
+        uses: weseek/ghaction-slack-notification@master
+        if: failure()
+        with:
+          type: ${{ job.status }}
+          job_name: '*Node CI for growi - test (${{ matrix.node-version }})*'
+          channel: '#ci'
+          isCompactMode: true
+          url: ${{ secrets.SLACK_WEBHOOK_URL }}
 
 
   launch-dev:
   launch-dev:
     runs-on: ubuntu-latest
     runs-on: ubuntu-latest
@@ -131,45 +128,46 @@ jobs:
       mongodb:
       mongodb:
         image: mongo:4.4
         image: mongo:4.4
         ports:
         ports:
-        - 27017/tcp
+          - 27017/tcp
 
 
     steps:
     steps:
-    - uses: actions/checkout@v2
-
-    - uses: actions/setup-node@v2
-      with:
-        node-version: ${{ matrix.node-version }}
-        cache: 'yarn'
-        cache-dependency-path: '**/yarn.lock'
-
-    - name: Cache/Restore node_modules
-      id: cache-dependencies
-      uses: actions/cache@v2
-      with:
-        path: |
-          **/node_modules
-        key: node_modules-${{ runner.OS }}-node${{ matrix.node-version }}-${{ hashFiles('**/yarn.lock') }}
-        restore-keys: |
-          node_modules-${{ runner.OS }}-node${{ matrix.node-version }}-
-
-    - name: lerna bootstrap
-      run: |
-        npx lerna bootstrap -- --frozen-lockfile
-
-    - name: yarn dev:ci
-      working-directory: ./packages/app
-      run: |
-        cp config/ci/.env.local.for-ci .env.development.local
-        yarn dev:ci
-      env:
-        MONGO_URI: mongodb://localhost:${{ job.services.mongodb.ports['27017'] }}/growi_dev
-
-    - name: Slack Notification
-      uses: weseek/ghaction-slack-notification@master
-      if: failure()
-      with:
-        type: ${{ job.status }}
-        job_name: '*Node CI for growi - launch-dev (${{ matrix.node-version }})*'
-        channel: '#ci'
-        isCompactMode: true
-        url: ${{ secrets.SLACK_WEBHOOK_URL }}
+      - uses: actions/checkout@v3
+
+      - uses: actions/setup-node@v3
+        with:
+          node-version: ${{ matrix.node-version }}
+          cache: 'yarn'
+          cache-dependency-path: '**/yarn.lock'
+
+      - name: Cache/Restore node_modules
+        id: cache-dependencies
+        uses: actions/cache@v3
+        with:
+          path: |
+            **/node_modules
+          key: node_modules-${{ runner.OS }}-node${{ matrix.node-version }}-${{ hashFiles('**/yarn.lock') }}-${{ hashFiles('packages/app/package.json') }}
+          restore-keys: |
+            node_modules-${{ runner.OS }}-node${{ matrix.node-version }}-${{ hashFiles('**/yarn.lock') }}-
+            node_modules-${{ runner.OS }}-node${{ matrix.node-version }}-
+
+      - name: lerna bootstrap
+        run: |
+          npx lerna bootstrap -- --frozen-lockfile
+
+      - name: yarn dev:ci
+        working-directory: ./packages/app
+        run: |
+          cp config/ci/.env.local.for-ci .env.development.local
+          yarn dev:ci
+        env:
+          MONGO_URI: mongodb://localhost:${{ job.services.mongodb.ports['27017'] }}/growi_dev
+
+      - name: Slack Notification
+        uses: weseek/ghaction-slack-notification@master
+        if: failure()
+        with:
+          type: ${{ job.status }}
+          job_name: '*Node CI for growi - launch-dev (${{ matrix.node-version }})*'
+          channel: '#ci'
+          isCompactMode: true
+          url: ${{ secrets.SLACK_WEBHOOK_URL }}

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

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

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

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

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

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

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

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

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

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

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

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

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

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

+ 27 - 21
.github/workflows/reusable-app-prod.yml

@@ -23,9 +23,9 @@ jobs:
       PROD_FILES: ${{ steps.archive-prod-files.outputs.file }}
       PROD_FILES: ${{ steps.archive-prod-files.outputs.file }}
 
 
     steps:
     steps:
-    - uses: actions/checkout@v2
+    - uses: actions/checkout@v3
 
 
-    - uses: actions/setup-node@v2
+    - uses: actions/setup-node@v3
       with:
       with:
         node-version: ${{ inputs.node-version }}
         node-version: ${{ inputs.node-version }}
         cache: 'yarn'
         cache: 'yarn'
@@ -33,12 +33,13 @@ jobs:
 
 
     - name: Cache/Restore node_modules
     - name: Cache/Restore node_modules
       id: cache-dependencies
       id: cache-dependencies
-      uses: actions/cache@v2
+      uses: actions/cache@v3
       with:
       with:
         path: |
         path: |
           **/node_modules
           **/node_modules
-        key: node_modules-${{ runner.OS }}-node${{ inputs.node-version }}-${{ hashFiles('**/yarn.lock') }}
+        key: node_modules-${{ runner.OS }}-node${{ inputs.node-version }}-${{ hashFiles('**/yarn.lock') }}-${{ hashFiles('packages/app/package.json') }}
         restore-keys: |
         restore-keys: |
+          node_modules-${{ runner.OS }}-node${{ inputs.node-version }}-${{ hashFiles('**/yarn.lock') }}-
           node_modules-${{ runner.OS }}-node${{ inputs.node-version }}-
           node_modules-${{ runner.OS }}-node${{ inputs.node-version }}-
 
 
     - name: lerna bootstrap
     - name: lerna bootstrap
@@ -94,15 +95,17 @@ jobs:
         image: mongo:4.4
         image: mongo:4.4
         ports:
         ports:
         - 27017/tcp
         - 27017/tcp
-      mongodb36:
-        image: mongo:3.6
+      elasticsearch:
+        image: docker.elastic.co/elasticsearch/elasticsearch:7.17.1
         ports:
         ports:
-        - 27017/tcp
+        - 9200/tcp
+        env:
+          discovery.type: single-node
 
 
     steps:
     steps:
-    - uses: actions/checkout@v2
+    - uses: actions/checkout@v3
 
 
-    - uses: actions/setup-node@v2
+    - uses: actions/setup-node@v3
       with:
       with:
         node-version: ${{ inputs.node-version }}
         node-version: ${{ inputs.node-version }}
         cache: 'yarn'
         cache: 'yarn'
@@ -116,12 +119,13 @@ jobs:
 
 
     - name: Cache/Restore node_modules (not reused)
     - name: Cache/Restore node_modules (not reused)
       id: cache-dependencies
       id: cache-dependencies
-      uses: actions/cache@v2
+      uses: actions/cache@v3
       with:
       with:
         path: |
         path: |
           **/node_modules
           **/node_modules
         key: node_modules-build-prod-${{ runner.OS }}-node${{ inputs.node-version }}-${{ steps.get-date.outputs.dateYmdHM }}
         key: node_modules-build-prod-${{ runner.OS }}-node${{ inputs.node-version }}-${{ steps.get-date.outputs.dateYmdHM }}
         restore-keys: |
         restore-keys: |
+          node_modules-${{ runner.OS }}-node${{ inputs.node-version }}-${{ hashFiles('**/yarn.lock') }}-${{ hashFiles('packages/app/package.json') }}
           node_modules-${{ runner.OS }}-node${{ inputs.node-version }}-${{ hashFiles('**/yarn.lock') }}
           node_modules-${{ runner.OS }}-node${{ inputs.node-version }}-${{ hashFiles('**/yarn.lock') }}
           node_modules-${{ runner.OS }}-node${{ inputs.node-version }}-
           node_modules-${{ runner.OS }}-node${{ inputs.node-version }}-
           node_modules-build-prod-${{ runner.OS }}-node${{ inputs.node-version }}-${{ steps.get-date.outputs.dateYm }}
           node_modules-build-prod-${{ runner.OS }}-node${{ inputs.node-version }}-${{ steps.get-date.outputs.dateYm }}
@@ -151,13 +155,7 @@ jobs:
         yarn server:ci
         yarn server:ci
       env:
       env:
         MONGO_URI: mongodb://localhost:${{ job.services.mongodb.ports['27017'] }}/growi
         MONGO_URI: mongodb://localhost:${{ job.services.mongodb.ports['27017'] }}/growi
-    - name: yarn server:ci with MongoDB 3.6
-      working-directory: ./packages/app
-      run: |
-        cp config/ci/.env.local.for-ci .env.production.local
-        yarn server:ci
-      env:
-        MONGO_URI: mongodb://localhost:${{ job.services.mongodb36.ports['27017'] }}/growi
+        ELASTICSEARCH_URI: http://localhost:${{ job.services.elasticsearch.ports['9200'] }}/growi
 
 
     - name: Slack Notification
     - name: Slack Notification
       uses: weseek/ghaction-slack-notification@master
       uses: weseek/ghaction-slack-notification@master
@@ -187,16 +185,22 @@ jobs:
       fail-fast: false
       fail-fast: false
       matrix:
       matrix:
         # List string expressions that is comma separated ids of tests in "test/cypress/integration"
         # List string expressions that is comma separated ids of tests in "test/cypress/integration"
-        spec-group: ['1', '2']
+        spec-group: ['1', '2', '3']
 
 
     services:
     services:
       mongodb:
       mongodb:
         image: mongo:4.4
         image: mongo:4.4
         ports:
         ports:
         - 27017/tcp
         - 27017/tcp
+      elasticsearch:
+        image: docker.elastic.co/elasticsearch/elasticsearch:7.17.1
+        ports:
+        - 9200/tcp
+        env:
+          discovery.type: single-node
 
 
     steps:
     steps:
-    - uses: actions/checkout@v2
+    - uses: actions/checkout@v3
 
 
     - name: Get yarn cache dir
     - name: Get yarn cache dir
       id: yarn-cache-dir
       id: yarn-cache-dir
@@ -204,14 +208,15 @@ jobs:
         echo "::set-output name=value::`yarn cache dir --silent`"
         echo "::set-output name=value::`yarn cache dir --silent`"
 
 
     - name: Cache/Restore dependencies
     - name: Cache/Restore dependencies
-      uses: actions/cache@v2
+      uses: actions/cache@v3
       with:
       with:
         path: |
         path: |
           **/node_modules
           **/node_modules
           ~/.cache/Cypress
           ~/.cache/Cypress
           ${{ steps.yarn-cache-dir.outputs.value }}
           ${{ steps.yarn-cache-dir.outputs.value }}
-        key: deps-for-cypress-${{ runner.OS }}-node${{ inputs.node-version }}-${{ hashFiles('**/yarn.lock') }}
+        key: deps-for-cypress-${{ runner.OS }}-node${{ inputs.node-version }}-${{ hashFiles('**/yarn.lock') }}-${{ hashFiles('packages/app/package.json') }}
         restore-keys: |
         restore-keys: |
+          deps-for-cypress-${{ runner.OS }}-node${{ inputs.node-version }}-${{ hashFiles('**/yarn.lock') }}-
           deps-for-cypress-${{ runner.OS }}-node${{ inputs.node-version }}
           deps-for-cypress-${{ runner.OS }}-node${{ inputs.node-version }}
 
 
     - name: lerna bootstrap
     - name: lerna bootstrap
@@ -254,6 +259,7 @@ jobs:
         wait-on: 'http://localhost:3000'
         wait-on: 'http://localhost:3000'
       env:
       env:
         MONGO_URI: mongodb://mongodb:27017/growi-vrt
         MONGO_URI: mongodb://mongodb:27017/growi-vrt
+        ELASTICSEARCH_URI: http://elasticsearch:9200/growi
 
 
     - name: Upload results
     - name: Upload results
       if: always()
       if: always()

+ 6 - 5
.github/workflows/reusable-app-reg-suit.yml

@@ -45,25 +45,26 @@ jobs:
       EXPECTED_IMAGES_EXIST: ${{ steps.check-expected-images.outputs.EXPECTED_IMAGES_EXIST }}
       EXPECTED_IMAGES_EXIST: ${{ steps.check-expected-images.outputs.EXPECTED_IMAGES_EXIST }}
 
 
     steps:
     steps:
-    - uses: actions/checkout@v2
+    - uses: actions/checkout@v3
       with:
       with:
         ref: ${{ inputs.checkout-ref }}
         ref: ${{ inputs.checkout-ref }}
         fetch-depth: 0
         fetch-depth: 0
 
 
-    - uses: actions/setup-node@v2
+    - uses: actions/setup-node@v3
       with:
       with:
         node-version: ${{ inputs.node-version }}
         node-version: ${{ inputs.node-version }}
         cache: 'yarn'
         cache: 'yarn'
         cache-dependency-path: '**/yarn.lock'
         cache-dependency-path: '**/yarn.lock'
 
 
     - name: Cache/Restore node_modules
     - name: Cache/Restore node_modules
-      uses: actions/cache@v2
+      uses: actions/cache@v3
       with:
       with:
         path: |
         path: |
           **/node_modules
           **/node_modules
-        key: node_modules-${{ runner.OS }}-node${{ inputs.node-version }}-${{ hashFiles('**/yarn.lock') }}
+        key: node_modules-${{ runner.OS }}-node${{ matrix.node-version }}-${{ hashFiles('**/yarn.lock') }}-${{ hashFiles('packages/app/package.json') }}
         restore-keys: |
         restore-keys: |
-          node_modules-${{ runner.OS }}-node${{ inputs.node-version }}-
+          node_modules-${{ runner.OS }}-node${{ matrix.node-version }}-${{ hashFiles('**/yarn.lock') }}-
+          node_modules-${{ runner.OS }}-node${{ matrix.node-version }}-
 
 
     - name: lerna bootstrap
     - name: lerna bootstrap
       run: |
       run: |

+ 24 - 1
CHANGELOG.md

@@ -1,9 +1,32 @@
 # Changelog
 # Changelog
 
 
-## [Unreleased](https://github.com/weseek/growi/compare/v4.5.11...HEAD)
+## [Unreleased](https://github.com/weseek/growi/compare/v4.5.14...HEAD)
 
 
 *Please do not manually update this file. We've automated the process.*
 *Please do not manually update this file. We've automated the process.*
 
 
+## [v4.5.14](https://github.com/weseek/growi/compare/v4.5.13...v4.5.14) - 2022-02-10
+
+### 💎 Features
+
+- feat: OGP in public wiki (#5304) @yuto-oweseek
+
+## [v4.5.13](https://github.com/weseek/growi/compare/v4.5.12...v4.5.13) - 2022-02-08
+
+### 🐛 Bug Fixes
+
+- fix: fix: Sidebar collapsing (#5283) @yuki-takei
+
+## [v4.5.12](https://github.com/weseek/growi/compare/v4.5.11...v4.5.12) - 2022-02-01
+
+### 🚀 Improvement
+
+- imprv: Sidebar opening delay (for v4.5.x) (#5218) @yuki-takei
+
+### 🐛 Bug Fixes
+
+- fix: /_api/v3/page with pageId param occurs an 500 error (#5212) @yuki-takei
+- fix: Resolving OIDC issure host (#5220) @yuki-takei
+
 ## [v4.5.11](https://github.com/weseek/growi/compare/v4.5.10...v4.5.11) - 2022-01-26
 ## [v4.5.11](https://github.com/weseek/growi/compare/v4.5.10...v4.5.11) - 2022-01-26
 
 
 ### 🐛 Bug Fixes
 ### 🐛 Bug Fixes

+ 0 - 2
README.md

@@ -17,8 +17,6 @@
 # GROWI
 # GROWI
 
 
 [![Actions Status](https://github.com/weseek/growi/workflows/Node%20CI/badge.svg)](https://github.com/weseek/growi/actions)
 [![Actions Status](https://github.com/weseek/growi/workflows/Node%20CI/badge.svg)](https://github.com/weseek/growi/actions)
-[![dependencies status](https://david-dm.org/weseek/growi.svg)](https://david-dm.org/weseek/growi)
-[![devDependencies Status](https://david-dm.org/weseek/growi/dev-status.svg)](https://david-dm.org/weseek/growi?type=dev)
 [![docker pulls](https://img.shields.io/docker/pulls/weseek/growi.svg)](https://hub.docker.com/r/weseek/growi/)
 [![docker pulls](https://img.shields.io/docker/pulls/weseek/growi.svg)](https://hub.docker.com/r/weseek/growi/)
 
 
 |                                                     demonstration                                                     |
 |                                                     demonstration                                                     |

+ 0 - 2
README_JP.md

@@ -16,8 +16,6 @@
 # GROWI
 # GROWI
 
 
 [![Actions Status](https://github.com/weseek/growi/workflows/Node%20CI/badge.svg)](https://github.com/weseek/growi/actions)
 [![Actions Status](https://github.com/weseek/growi/workflows/Node%20CI/badge.svg)](https://github.com/weseek/growi/actions)
-[![dependencies status](https://david-dm.org/weseek/growi.svg)](https://david-dm.org/weseek/growi)
-[![devDependencies Status](https://david-dm.org/weseek/growi/dev-status.svg)](https://david-dm.org/weseek/growi?type=dev)
 [![docker pulls](https://img.shields.io/docker/pulls/weseek/growi.svg)](https://hub.docker.com/r/weseek/growi/)
 [![docker pulls](https://img.shields.io/docker/pulls/weseek/growi.svg)](https://hub.docker.com/r/weseek/growi/)
 
 
 |                                                 デモンストレーション                                                 |
 |                                                 デモンストレーション                                                 |

+ 1 - 1
lerna.json

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

+ 1 - 1
package.json

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

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

@@ -4,6 +4,7 @@
 ##
 ##
 MIGRATIONS_DIR=src/migrations/
 MIGRATIONS_DIR=src/migrations/
 
 
+APP_SITE_URL=http://localhost:3000
 FILE_UPLOAD=mongodb
 FILE_UPLOAD=mongodb
 # MONGO_GRIDFS_TOTAL_LIMIT=10485760
 # MONGO_GRIDFS_TOTAL_LIMIT=10485760
 MATHJAX=1
 MATHJAX=1
@@ -13,10 +14,10 @@ MONGO_URI="mongodb://mongo:27017/growi"
 # NCHAN_URI="http://nchan"
 # NCHAN_URI="http://nchan"
 ELASTICSEARCH_URI="http://elasticsearch:9200/growi"
 ELASTICSEARCH_URI="http://elasticsearch:9200/growi"
 ELASTICSEARCH_REQUEST_TIMEOUT=15000
 ELASTICSEARCH_REQUEST_TIMEOUT=15000
-ELASTICSEARCH_REJECT_UNAUTHORIZED=false
-USE_ELASTICSEARCH_V6=false
+ELASTICSEARCH_REJECT_UNAUTHORIZED=true
 HACKMD_URI="http://localhost:3010"
 HACKMD_URI="http://localhost:3010"
 HACKMD_URI_FOR_SERVER="http://hackmd:3000"
 HACKMD_URI_FOR_SERVER="http://hackmd:3000"
+OGP_URI="http://ogp:8088"
 # DRAWIO_URI="http://localhost:8080/?offline=1&https=0"
 # DRAWIO_URI="http://localhost:8080/?offline=1&https=0"
 # S2SMSG_PUBSUB_SERVER_TYPE=nchan
 # S2SMSG_PUBSUB_SERVER_TYPE=nchan
 # PUBLISH_OPEN_API=true
 # PUBLISH_OPEN_API=true

+ 12 - 0
packages/app/.eslintrc.js

@@ -3,6 +3,9 @@ module.exports = {
     'weseek/react',
     'weseek/react',
     'weseek/typescript',
     'weseek/typescript',
   ],
   ],
+  plugins: [
+    'regex',
+  ],
   env: {
   env: {
     jquery: true,
     jquery: true,
   },
   },
@@ -25,6 +28,15 @@ module.exports = {
       name: 'axios',
       name: 'axios',
       message: 'Please use src/utils/axios instead.',
       message: 'Please use src/utils/axios instead.',
     }],
     }],
+    'regex/invalid': ['error', [
+      {
+        regex: '\\?\\<\\!',
+        message: 'Do not use any negative lookbehind',
+      }, {
+        regex: '\\?\\<\\=',
+        message: 'Do not use any Positive lookbehind',
+      },
+    ]],
     '@typescript-eslint/no-var-requires': 'off',
     '@typescript-eslint/no-var-requires': 'off',
 
 
     // set 'warn' temporarily -- 2021.08.02 Yuki Takei
     // set 'warn' temporarily -- 2021.08.02 Yuki Takei

+ 2 - 2
packages/app/bin/github-actions/update-readme.sh

@@ -2,5 +2,5 @@
 
 
 cd docker
 cd docker
 
 
-sed -i -e "s/^\([*] \[\`\)[^\`]\+\(\`, \`4\.5\`, .\+\]\)\(.\+\/blob\/v\).\+\(\/docker\/Dockerfile.\+\)$/\1${RELEASED_VERSION}\2\3${RELEASED_VERSION}\4/" README.md
-sed -i -e "s/^\([*] \[\`\)[^\`]\+\(\`, \`4\.5-nocdn\`, .\+\]\)\(.\+\/blob\/v\).\+\(\/docker\/Dockerfile.\+\)$/\1${RELEASED_VERSION}-nocdn\2\3${RELEASED_VERSION}\4/" README.md
+sed -i -e "s/^\([*] \[\`\)[^\`]\+\(\`, \`5\.0\`, .\+\]\)\(.\+\/blob\/v\).\+\(\/docker\/Dockerfile.\+\)$/\1${RELEASED_VERSION}\2\3${RELEASED_VERSION}\4/" README.md
+sed -i -e "s/^\([*] \[\`\)[^\`]\+\(\`, \`5\.0-nocdn\`, .\+\]\)\(.\+\/blob\/v\).\+\(\/docker\/Dockerfile.\+\)$/\1${RELEASED_VERSION}-nocdn\2\3${RELEASED_VERSION}\4/" README.md

+ 3 - 1
packages/app/config/ci/.env.local.for-auto-install

@@ -4,4 +4,6 @@ AUTO_INSTALL_ADMIN_USERNAME=admin
 AUTO_INSTALL_ADMIN_NAME=Admin
 AUTO_INSTALL_ADMIN_NAME=Admin
 AUTO_INSTALL_ADMIN_EMAIL=admin@example.com
 AUTO_INSTALL_ADMIN_EMAIL=admin@example.com
 AUTO_INSTALL_ADMIN_PASSWORD=adminadmin
 AUTO_INSTALL_ADMIN_PASSWORD=adminadmin
-AUTO_INSTALL_GLOBAL_LANG=zh_CN
+AUTO_INSTALL_GLOBAL_LANG=en_US
+
+AUTO_INSTALL_SERVER_DATE=2022-01-01T00:00:00.0

+ 0 - 3
packages/app/config/ci/.env.local.for-ci

@@ -1,4 +1 @@
 FORMAT_NODE_LOG=true
 FORMAT_NODE_LOG=true
-
-# disable Elasticsearch
-ELASTICSEARCH_URI=

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

@@ -26,6 +26,7 @@ module.exports = {
   // 'growi:routes:page': 'debug',
   // 'growi:routes:page': 'debug',
   'growi-plugin:*': 'debug',
   'growi-plugin:*': 'debug',
   // 'growi:InterceptorManager': 'debug',
   // 'growi:InterceptorManager': 'debug',
+  'growi:service:search-delegator:elasticsearch': 'debug',
 
 
   /*
   /*
    * configure level for client
    * configure level for client
@@ -35,5 +36,7 @@ module.exports = {
   'growi:services:*': 'debug',
   'growi:services:*': 'debug',
   // 'growi:StaffCredit': 'debug',
   // 'growi:StaffCredit': 'debug',
   // 'growi:cli:StickyStretchableScroller': 'debug',
   // 'growi:cli:StickyStretchableScroller': 'debug',
+  // 'growi:cli:ItemsTree': 'debug',
+  'growi:searchResultList': 'debug',
 
 
 };
 };

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

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

+ 19 - 0
packages/app/jest.config.js

@@ -38,6 +38,25 @@ module.exports = {
       rootDir: '.',
       rootDir: '.',
       roots: ['<rootDir>'],
       roots: ['<rootDir>'],
       testMatch: ['<rootDir>/test/integration/**/*.test.ts', '<rootDir>/test/integration/**/*.test.js'],
       testMatch: ['<rootDir>/test/integration/**/*.test.ts', '<rootDir>/test/integration/**/*.test.js'],
+      // https://regex101.com/r/jTaxYS/1
+      modulePathIgnorePatterns: ['<rootDir>/test/integration/*.*/v5(..*)*.[t|j]s'],
+      testEnvironment: 'node',
+      globalSetup: '<rootDir>/test/integration/global-setup.js',
+      globalTeardown: '<rootDir>/test/integration/global-teardown.js',
+      setupFilesAfterEnv: ['<rootDir>/test/integration/setup.js'],
+
+      // Automatically clear mock calls and instances between every test
+      clearMocks: true,
+      moduleNameMapper: MODULE_NAME_MAPPING,
+    },
+    {
+      displayName: 'server-v5',
+
+      preset: 'ts-jest/presets/js-with-ts',
+
+      rootDir: '.',
+      roots: ['<rootDir>'],
+      testMatch: ['<rootDir>/test/integration/**/v5.*.test.ts', '<rootDir>/test/integration/**/v5.*.test.js'],
 
 
       testEnvironment: 'node',
       testEnvironment: 'node',
       globalSetup: '<rootDir>/test/integration/global-setup.js',
       globalSetup: '<rootDir>/test/integration/global-setup.js',

+ 25 - 18
packages/app/package.json

@@ -1,6 +1,6 @@
 {
 {
   "name": "@growi/app",
   "name": "@growi/app",
-  "version": "4.6.0-RC.0",
+  "version": "5.0.0-RC.11",
   "license": "MIT",
   "license": "MIT",
   "scripts": {
   "scripts": {
     "//// for production": "",
     "//// for production": "",
@@ -38,6 +38,7 @@
     "lint:swagger2openapi": "node node_modules/.bin/oas-validate tmp/swagger.json",
     "lint:swagger2openapi": "node node_modules/.bin/oas-validate tmp/swagger.json",
     "lint": "run-p lint:*",
     "lint": "run-p lint:*",
     "test": "cross-env NODE_ENV=test jest --passWithNoTests -- ",
     "test": "cross-env NODE_ENV=test jest --passWithNoTests -- ",
+    "test:ci": "cross-env NODE_ENV=test jest",
     "prelint:eslint": "yarn resources:plugin",
     "prelint:eslint": "yarn resources:plugin",
     "prelint:swagger2openapi": "yarn openapi:v3",
     "prelint:swagger2openapi": "yarn openapi:v3",
     "reg:run": "reg-suit run",
     "reg:run": "reg-suit run",
@@ -57,15 +58,17 @@
   },
   },
   "dependencies": {
   "dependencies": {
     "@browser-bunyan/console-formatted-stream": "^1.6.2",
     "@browser-bunyan/console-formatted-stream": "^1.6.2",
+    "@elastic/elasticsearch6": "npm:@elastic/elasticsearch@^6.8.8",
+    "@elastic/elasticsearch7": "npm:@elastic/elasticsearch@^7.17.0",
     "@godaddy/terminus": "^4.9.0",
     "@godaddy/terminus": "^4.9.0",
     "@google-cloud/storage": "^5.8.5",
     "@google-cloud/storage": "^5.8.5",
-    "@growi/codemirror-textlint": "^4.6.0-RC.0",
-    "@growi/plugin-attachment-refs": "^4.6.0-RC.0",
-    "@growi/plugin-lsx": "^4.6.0-RC.0",
-    "@growi/plugin-pukiwiki-like-linker": "^4.6.0-RC.0",
-    "@growi/slack": "^4.6.0-RC.0",
-    "@promster/express": "^5.1.0",
-    "@promster/server": "^6.0.3",
+    "@growi/codemirror-textlint": "^5.0.0-RC.11",
+    "@growi/plugin-attachment-refs": "^5.0.0-RC.11",
+    "@growi/plugin-lsx": "^5.0.0-RC.11",
+    "@growi/plugin-pukiwiki-like-linker": "^5.0.0-RC.11",
+    "@growi/slack": "^5.0.0-RC.11",
+    "@promster/express": "^7.0.2",
+    "@promster/server": "^7.0.4",
     "@slack/events-api": "^3.0.0",
     "@slack/events-api": "^3.0.0",
     "@slack/web-api": "^6.2.4",
     "@slack/web-api": "^6.2.4",
     "@slack/webhook": "^6.0.0",
     "@slack/webhook": "^6.0.0",
@@ -89,23 +92,22 @@
     "date-fns": "^2.23.0",
     "date-fns": "^2.23.0",
     "detect-indent": "^7.0.0",
     "detect-indent": "^7.0.0",
     "diff": "^5.0.0",
     "diff": "^5.0.0",
-    "@elastic/elasticsearch6": "npm:@elastic/elasticsearch@^6.8.7",
-    "@elastic/elasticsearch7": "npm:@elastic/elasticsearch@^7.16.0",
     "diff_match_patch": "^0.1.1",
     "diff_match_patch": "^0.1.1",
     "entities": "^2.0.0",
     "entities": "^2.0.0",
     "esa-node": "^0.2.2",
     "esa-node": "^0.2.2",
     "escape-string-regexp": "=4.0.0",
     "escape-string-regexp": "=4.0.0",
+    "eslint-plugin-regex": "^1.8.0",
     "express": "^4.16.1",
     "express": "^4.16.1",
     "express-bunyan-logger": "^1.3.3",
     "express-bunyan-logger": "^1.3.3",
     "express-mongo-sanitize": "^2.1.0",
     "express-mongo-sanitize": "^2.1.0",
     "express-rate-limit": "^5.3.0",
     "express-rate-limit": "^5.3.0",
     "express-session": "^1.16.1",
     "express-session": "^1.16.1",
-    "express-validator": "^6.1.1",
+    "express-validator": "^6.14.0",
     "express-webpack-assets": "^0.1.0",
     "express-webpack-assets": "^0.1.0",
     "extensible-custom-error": "^0.0.7",
     "extensible-custom-error": "^0.0.7",
     "graceful-fs": "^4.1.11",
     "graceful-fs": "^4.1.11",
     "helmet": "^4.6.0",
     "helmet": "^4.6.0",
-    "http-errors": "~1.8.0",
+    "http-errors": "^2.0.0",
     "i18next": "^20.3.2",
     "i18next": "^20.3.2",
     "i18next-express-middleware": "^2.0.0",
     "i18next-express-middleware": "^2.0.0",
     "i18next-node-fs-backend": "^2.1.3",
     "i18next-node-fs-backend": "^2.1.3",
@@ -137,7 +139,10 @@
     "passport-twitter": "^1.0.4",
     "passport-twitter": "^1.0.4",
     "prom-client": "^13.0.0",
     "prom-client": "^13.0.0",
     "react-card-flip": "^1.0.10",
     "react-card-flip": "^1.0.10",
+    "react-dnd": "^14.0.5",
+    "react-dnd-html5-backend": "^14.1.0",
     "react-image-crop": "^8.3.0",
     "react-image-crop": "^8.3.0",
+    "react-multiline-clamp": "^2.0.0",
     "react-tagcloud": "^2.1.1",
     "react-tagcloud": "^2.1.1",
     "reconnecting-websocket": "^4.4.0",
     "reconnecting-websocket": "^4.4.0",
     "redis": "^3.0.2",
     "redis": "^3.0.2",
@@ -145,13 +150,13 @@
     "socket.io": "^4.2.0",
     "socket.io": "^4.2.0",
     "stream-to-promise": "^3.0.0",
     "stream-to-promise": "^3.0.0",
     "string-width": "=4.2.2",
     "string-width": "=4.2.2",
-    "swagger-jsdoc": "^3.4.0",
+    "swagger-jsdoc": "^6.1.0",
     "swig-templates": "^2.0.2",
     "swig-templates": "^2.0.2",
     "uglifycss": "^0.0.29",
     "uglifycss": "^0.0.29",
     "universal-bunyan": "^0.9.2",
     "universal-bunyan": "^0.9.2",
     "unzipper": "^0.10.5",
     "unzipper": "^0.10.5",
     "url-join": "^4.0.0",
     "url-join": "^4.0.0",
-    "validator": "^13.6.0",
+    "validator": "^13.7.0",
     "ws": "^8.3.0",
     "ws": "^8.3.0",
     "xss": "^1.0.6"
     "xss": "^1.0.6"
   },
   },
@@ -162,7 +167,7 @@
   },
   },
   "devDependencies": {
   "devDependencies": {
     "@alienfast/i18next-loader": "^1.1.4",
     "@alienfast/i18next-loader": "^1.1.4",
-    "@growi/ui": "^4.6.0-RC.0",
+    "@growi/ui": "^5.0.0-RC.11",
     "@handsontable/react": "=2.1.0",
     "@handsontable/react": "=2.1.0",
     "@types/compression": "^1.7.0",
     "@types/compression": "^1.7.0",
     "@types/express": "^4.17.11",
     "@types/express": "^4.17.11",
@@ -183,6 +188,7 @@
     "diff2html": "^3.1.2",
     "diff2html": "^3.1.2",
     "eazy-logger": "^3.1.0",
     "eazy-logger": "^3.1.0",
     "eslint-plugin-cypress": "^2.12.1",
     "eslint-plugin-cypress": "^2.12.1",
+    "eslint-plugin-regex": "^1.8.0",
     "file-loader": "^5.0.2",
     "file-loader": "^5.0.2",
     "handsontable": "=6.2.2",
     "handsontable": "=6.2.2",
     "hard-source-webpack-plugin": "^0.13.1",
     "hard-source-webpack-plugin": "^0.13.1",
@@ -217,7 +223,7 @@
     "postcss-loader": "^3.0.0",
     "postcss-loader": "^3.0.0",
     "prettier": "^1.19.1",
     "prettier": "^1.19.1",
     "react": "^16.8.3",
     "react": "^16.8.3",
-    "react-bootstrap-typeahead": "^3.4.7",
+    "react-bootstrap-typeahead": "^5.2.2",
     "react-codemirror2": "^6.0.0",
     "react-codemirror2": "^6.0.0",
     "react-copy-to-clipboard": "^5.0.1",
     "react-copy-to-clipboard": "^5.0.1",
     "react-dom": "^16.8.3",
     "react-dom": "^16.8.3",
@@ -228,10 +234,11 @@
     "react-waypoint": "^10.1.0",
     "react-waypoint": "^10.1.0",
     "reactstrap": "^8.9.0",
     "reactstrap": "^8.9.0",
     "replacestream": "^4.0.3",
     "replacestream": "^4.0.3",
-    "reveal.js": "^3.5.0",
+    "reveal.js": "^4.3.1",
     "sass": "^1.43.4",
     "sass": "^1.43.4",
     "sass-loader": "^10.1.1",
     "sass-loader": "^10.1.1",
     "simple-load-script": "^1.0.2",
     "simple-load-script": "^1.0.2",
+    "simplebar-react": "^2.3.6",
     "socket.io-client": "^4.2.0",
     "socket.io-client": "^4.2.0",
     "sticky-events": "^3.4.11",
     "sticky-events": "^3.4.11",
     "style-loader": "^1.0.0",
     "style-loader": "^1.0.0",
@@ -239,7 +246,7 @@
     "swagger2openapi": "^5.3.1",
     "swagger2openapi": "^5.3.1",
     "swr": "^1.1.2",
     "swr": "^1.1.2",
     "terser-webpack-plugin": "^4.1.0",
     "terser-webpack-plugin": "^4.1.0",
-    "throttle-debounce": "^2.0.0",
+    "throttle-debounce": "^3.0.1",
     "toastr": "^2.1.2",
     "toastr": "^2.1.2",
     "ts-loader": "^8.3.0",
     "ts-loader": "^8.3.0",
     "ts-node-dev": "^1.1.6",
     "ts-node-dev": "^1.1.6",

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

@@ -19,6 +19,31 @@
     "bug_report": "Submitting a bug report",
     "bug_report": "Submitting a bug report",
     "submit_bug_report": "<a href='https://github.com/weseek/growi/issues/new?assignees=&labels=bug&template=bug-report.md&title=Bug%3A' target='_blank' rel='noreferrer'>then submit your issue to GitHub.</a>"
     "submit_bug_report": "<a href='https://github.com/weseek/growi/issues/new?assignees=&labels=bug&template=bug-report.md&title=Bug%3A' target='_blank' rel='noreferrer'>then submit your issue to GitHub.</a>"
   },
   },
+  "v5_page_migration": {
+    "page_tree_not_avaliable" : "Page tree feature is not available yet.",
+    "go_to_settings": "Go to settings to enable the feature",
+    "migration_desc": "There are some pages with old v4 compatibility. To take advantage of new features such as page trees and easy renaming, please convert all your pages to v5 compatibility.",
+    "migration_note": "Note: You will lose unique constraints from the page paths.",
+    "upgrade_to_v5": "Convert to v5 compatibility",
+    "modal_migration_warning": "This process may take long. It is highly recommended that administrators tell users not to create, modify, or delete pages during the conversion.",
+    "start_upgrading": "Start converting to v5 compatibility",
+    "successfully_started": "Succeeded to start the conversion",
+    "already_upgraded": "You have already completed the conversion to v5 compatibility"
+  },
+  "maintenance_mode": {
+    "maintenance_mode": "Maintenance Mode",
+    "under_maintenance_mode": "Under Maintenance Mode",
+    "failed_to_start_maintenance_mode": "Failed to start maintenance mode",
+    "failed_to_end_maintenance_mode": "Failed to end maintenance mode",
+    "successfully_started_maintenance_mode": "Succussfully started maintenance mode",
+    "successfully_ended_maintenance_mode": "Succussfully ended maintenance mode",
+    "warning_message_to_start": "You will NOT able to access other than admin settings page. General users will NOT able to access to any contents until maintenance mode ends manually.",
+    "warning_message_to_end": "Please make sure that \"data importing\" or \"upgrading to v5\" is already done or not. If not, it is highly recommended to keep maintenance mode on.",
+    "supplymentary_message_to_start": "As for the API, only the administrator API will be functional.",
+    "start_maintenance_mode": "Start maintenance mode",
+    "end_maintenance_mode": "End maintenance mode",
+    "description": "Maintenance mode restricts all user operations. Always start the maintenance mode before \"importing data\" and \"upgrading to V5\". To exit, go to \"Security Settings\" > \"Maintenance Mode\"."
+  },
   "app_setting": {
   "app_setting": {
     "site_name": "Site name",
     "site_name": "Site name",
     "sitename_change": "You can change site name which is used for header and HTML title.",
     "sitename_change": "You can change site name which is used for header and HTML title.",
@@ -178,6 +203,9 @@
     "beta_warning": "This function is Beta.",
     "beta_warning": "This function is Beta.",
     "import_from": "Import from {{from}}",
     "import_from": "Import from {{from}}",
     "import_growi_archive": "Import GROWI archive",
     "import_growi_archive": "Import GROWI archive",
+    "error": {
+      "only_upsert_available": "Only 'Upsert' option is available for pages collection."
+    },
     "growi_settings": {
     "growi_settings": {
       "description_of_import_mode": {
       "description_of_import_mode": {
         "about": "When you import data with the same name as an existing one, choose from the following three modes below.",
         "about": "When you import data with the same name as an existing one, choose from the following three modes below.",
@@ -192,7 +220,7 @@
       "upload": "Upload",
       "upload": "Upload",
       "discard": "Discard uploaded data",
       "discard": "Discard uploaded data",
       "errors": {
       "errors": {
-        "different_versions": "this growi and the uploarded data versions are not met",
+        "different_versions": "This growi and the uploaded data versions are not met",
         "at_least_one": "Select one or more collections.",
         "at_least_one": "Select one or more collections.",
         "page_and_revision": "'Pages' and 'Revisions' must be imported both.",
         "page_and_revision": "'Pages' and 'Revisions' must be imported both.",
         "depends": "'{{target}}' must be selected when '{{condition}}' is selected."
         "depends": "'{{target}}' must be selected when '{{condition}}' is selected."
@@ -440,9 +468,13 @@
   },
   },
   "user_group_management": {
   "user_group_management": {
     "create_group": "Create new group",
     "create_group": "Create new group",
+    "add_child_group": "Add child group",
     "deny_create_group": "You can't create a new group with the current settings.",
     "deny_create_group": "You can't create a new group with the current settings.",
     "group_name": "Group name",
     "group_name": "Group name",
     "group_example": "e.g. : Group1",
     "group_example": "e.g. : Group1",
+    "parent_group": "Parent Group",
+    "select_parent_group": "Select Parent Group",
+    "release_parent_group": "Release parent group",
     "add_modal": {
     "add_modal": {
       "add_user": "Add a user to the created group",
       "add_user": "Add a user to the created group",
       "search_option": "Search option",
       "search_option": "Search option",
@@ -452,7 +484,9 @@
       "backward_match": "Backward match"
       "backward_match": "Backward match"
     },
     },
     "group_list": "Group list",
     "group_list": "Group list",
+    "child_group_list": "Child group list",
     "back_to_list": "Go back to group list",
     "back_to_list": "Go back to group list",
+    "back_to_ancestors_group": "Go back to ancestors group",
     "basic_info": "Basic info",
     "basic_info": "Basic info",
     "user_list": "User list",
     "user_list": "User list",
     "created_group": "Group was created",
     "created_group": "Group was created",
@@ -461,7 +495,7 @@
     "remove_from_group": "Remove this user",
     "remove_from_group": "Remove this user",
     "delete_modal": {
     "delete_modal": {
       "header": "Delete group",
       "header": "Delete group",
-      "desc": "Once deleted, the deleted group and its private pages cannot be retrieved.",
+      "desc": "All child groups under the group will also be deleted. Once deleted, the deleted group and its private pages cannot be retrieved.",
       "dropdown_desc": "Choose an action for private pages",
       "dropdown_desc": "Choose an action for private pages",
       "select_group": "Select a group",
       "select_group": "Select a group",
       "no_groups": "No groups to select",
       "no_groups": "No groups to select",

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

@@ -237,10 +237,10 @@ You can create links using `[Display text](URL)`.
 
 
 ```
 ```
 [/Sandbox]
 [/Sandbox]
-&lt;/user/admin1>
+</user/admin1>
 ```
 ```
 
 
-[/Sandbox]
+[/Sandbox]  
 </user/admin1>
 </user/admin1>
 
 
 ## Pukiwiki like linker
 ## Pukiwiki like linker
@@ -253,10 +253,10 @@ Both the page description and link address can be displayed on the page.
 
 
 ```
 ```
 [[./Bootstrap4]]
 [[./Bootstrap4]]
-Example of Bootstrap4 is[[here>./Bootstrap4]]
+Example of Bootstrap4 is [[here>./Bootstrap4]]
 ```
 ```
 
 
-[[../user]]
+[[../Bootstrap4]]  
 Example of Bootstrap4 is[[here>./Bootstrap4]]
 Example of Bootstrap4 is[[here>./Bootstrap4]]
 
 
 # :pencil: Lists
 # :pencil: Lists

+ 94 - 23
packages/app/resource/locales/en_US/translation.json

@@ -11,8 +11,8 @@
   "phone":"Smartphone",
   "phone":"Smartphone",
   "tablet":"Tablet",
   "tablet":"Tablet",
   "Click to copy": "Click to copy",
   "Click to copy": "Click to copy",
+  "Rename" : "Rename",
   "Move/Rename": "Move/Rename",
   "Move/Rename": "Move/Rename",
-  "Moved": "Moved",
   "Redirected": "Redirected",
   "Redirected": "Redirected",
   "Unlinked": "Unlinked",
   "Unlinked": "Unlinked",
   "Like!": "Like!",
   "Like!": "Like!",
@@ -20,6 +20,7 @@
   "Done": "Done",
   "Done": "Done",
   "Cancel": "Cancel",
   "Cancel": "Cancel",
   "Create": "Create",
   "Create": "Create",
+  "Description": "Description",
   "Admin": "Admin",
   "Admin": "Admin",
   "administrator": "Admin",
   "administrator": "Admin",
   "Tag": "Tag",
   "Tag": "Tag",
@@ -39,6 +40,7 @@
   "account_id": "Account Id",
   "account_id": "Account Id",
   "Update": "Update",
   "Update": "Update",
   "Update Page": "Update Page",
   "Update Page": "Update Page",
+  "Error": "Error",
   "Warning": "Warning",
   "Warning": "Warning",
   "Sign in": "Sign in",
   "Sign in": "Sign in",
   "Sign up is here": "Sign up",
   "Sign up is here": "Sign up",
@@ -66,6 +68,7 @@
   "Include Attachment File": "Include Attachment File",
   "Include Attachment File": "Include Attachment File",
   "Include Comment": "Include Comment",
   "Include Comment": "Include Comment",
   "Include Subordinated Page": "Include Subordinated Page",
   "Include Subordinated Page": "Include Subordinated Page",
+  "Include Subordinated Target Page": "include {{target}}",
   "All Subordinated Page": "All Subordinated Page",
   "All Subordinated Page": "All Subordinated Page",
   "Specify Hierarchy": "Specify Hierarchy",
   "Specify Hierarchy": "Specify Hierarchy",
   "Submitted the request to create the archive": "Submitted the request to create the archive",
   "Submitted the request to create the archive": "Submitted the request to create the archive",
@@ -108,6 +111,9 @@
   "Create under": "Create page under below:",
   "Create under": "Create page under below:",
   "Wiki Management Home Page": "Wiki Management Home Page",
   "Wiki Management Home Page": "Wiki Management Home Page",
   "App Settings": "App Settings",
   "App Settings": "App Settings",
+  "V5 Page Migration": "Convert To V5 Compatibility",
+  "GROWI.5.0_new_schema": "GROWI.5.0 new schema",
+  "See_more_detail_on_new_schema": "See more detail on <a href='#'>{{url}}</a> <i class='icon-share-alt'></i> ",
   "Site URL settings": "Site URL settings",
   "Site URL settings": "Site URL settings",
   "Markdown Settings": "Markdown Settings",
   "Markdown Settings": "Markdown Settings",
   "Customize": "Customize",
   "Customize": "Customize",
@@ -117,6 +123,8 @@
   "Legacy_Slack_Integration": "Legacy Slack Integration",
   "Legacy_Slack_Integration": "Legacy Slack Integration",
   "User_Management": "User Management",
   "User_Management": "User Management",
   "external_account_management": "External Account Management",
   "external_account_management": "External Account Management",
+  "UserGroup": "UserGroup",
+  "ChildUserGroup": "ChildUserGroup",
   "UserGroup Management": "UserGroup Management",
   "UserGroup Management": "UserGroup Management",
   "Full Text Search Management": "Full Text Search Management",
   "Full Text Search Management": "Full Text Search Management",
   "Import Data": "Import Data",
   "Import Data": "Import Data",
@@ -150,12 +158,17 @@
   "Sign out": "Logout",
   "Sign out": "Logout",
   "Disassociate": "Disassociate",
   "Disassociate": "Disassociate",
   "No bookmarks yet": "No bookmarks yet",
   "No bookmarks yet": "No bookmarks yet",
+  "add_bookmark": "Add to Bookmarks",
+  "remove_bookmark": "Remove from Bookmarks",
   "Recent Created": "Recent Created",
   "Recent Created": "Recent Created",
   "Recent Changes": "Recent Changes",
   "Recent Changes": "Recent Changes",
+  "Page Tree": "Page Tree",
   "original_path":"Original path",
   "original_path":"Original path",
   "new_path":"New path",
   "new_path":"New path",
-  "duplicated_path":"duplicated_path",
+  "duplicated_path":"Duplicated path",
   "Link sharing is disabled": "Link sharing is disabled",
   "Link sharing is disabled": "Link sharing is disabled",
+  "successfully_saved_the_page": "Successfully saved the page",
+  "you_can_not_create_page_with_this_name": "You can not create page with this name",
   "personal_dropdown": {
   "personal_dropdown": {
     "home": "Home",
     "home": "Home",
     "settings": "Settings",
     "settings": "Settings",
@@ -167,7 +180,10 @@
   "form_validation": {
   "form_validation": {
     "error_message": "Some values ​​are incorrect",
     "error_message": "Some values ​​are incorrect",
     "required": "%s is required",
     "required": "%s is required",
-    "invalid_syntax": "The syntax of %s is invalid."
+    "invalid_syntax": "The syntax of %s is invalid.",
+    "title_required": "Title is required.",
+    "slashed_are_not_yet_supported": "Titles containing slashes are not yet supported"
+
   },
   },
   "not_found_page": {
   "not_found_page": {
     "Create Page": "Create Page",
     "Create Page": "Create Page",
@@ -239,7 +255,7 @@
     "expire": "Expiration",
     "expire": "Expiration",
     "Days": "Days",
     "Days": "Days",
     "Custom": "Custom",
     "Custom": "Custom",
-    "description": "description",
+    "description": "Description",
     "enter_desc": "Enter description",
     "enter_desc": "Enter description",
     "Unlimited": "unlimited",
     "Unlimited": "unlimited",
     "Issue": "Issue",
     "Issue": "Issue",
@@ -352,12 +368,8 @@
   "page_page": {
   "page_page": {
     "notice": {
     "notice": {
       "version": "This is not the current version.",
       "version": "This is not the current version.",
-      "moved": "This page was moved from",
-      "moved_period": ".",
       "redirected": "You are redirected from",
       "redirected": "You are redirected from",
       "redirected_period": ".",
       "redirected_period": ".",
-      "duplicated": "This page was duplicated from",
-      "duplicated_period": ".",
       "unlinked": "Redirect pages to this page have been deleted.",
       "unlinked": "Redirect pages to this page have been deleted.",
       "restricted": "Access to this page is restricted",
       "restricted": "Access to this page is restricted",
       "stale": "More than {{count}} year has passed since last update.",
       "stale": "More than {{count}} year has passed since last update.",
@@ -366,9 +378,6 @@
       "no_deadline":"This page has no expiration date"
       "no_deadline":"This page has no expiration date"
     }
     }
   },
   },
-  "page_table_of_contents": {
-    "empty": "Table of Contents is empty"
-  },
   "page_edit": {
   "page_edit": {
     "Show active line": "Show active line",
     "Show active line": "Show active line",
     "auto_format_table": "Auto format table",
     "auto_format_table": "Auto format table",
@@ -384,7 +393,7 @@
     "notfound_or_forbidden": "Original page is not found or forbidden.",
     "notfound_or_forbidden": "Original page is not found or forbidden.",
     "already_exists": "New page is already exists.",
     "already_exists": "New page is already exists.",
     "outdated": "Page is updated someone and now outdated.",
     "outdated": "Page is updated someone and now outdated.",
-    "user_not_admin": "Only admin user can delete completely"
+    "user_not_admin": "Only admin user can delete"
   },
   },
   "page_history": {
   "page_history": {
     "revision_list": "Revision list",
     "revision_list": "Revision list",
@@ -399,22 +408,24 @@
     "label": {
     "label": {
       "Move/Rename page": "Move/Rename page",
       "Move/Rename page": "Move/Rename page",
       "New page name": "New page name",
       "New page name": "New page name",
-      "Fail to get subordinated pages": "Fail to get subordinated pages",
-      "Fail to get exist path": "Fail to get exist path",
-      "Rename without exist path": "Rename without exist path",
+      "Failed to get subordinated pages": "Failed to get subordinated pages",
+      "Failed to get exist path": "Failed to get exist path",
       "Current page name": "Current page name",
       "Current page name": "Current page name",
-      "Recursively": "Recursively",
+      "Rename this page only": "Rename this page only",
+      "Force rename all child pages": "Force rename all pages",
+      "Other options": "Other options",
       "Do not update metadata": "Do not update metadata",
       "Do not update metadata": "Do not update metadata",
       "Redirect": "Redirect"
       "Redirect": "Redirect"
     },
     },
     "help": {
     "help": {
       "redirect": "Redirect to new page if someone accesses under this path",
       "redirect": "Redirect to new page if someone accesses under this path",
-      "metadata": "Remains last update user and updated date as is",
+      "metadata": "Last update user and updated date will remain the same",
       "recursive": "Move/Rename children of under this path recursively"
       "recursive": "Move/Rename children of under this path recursively"
     }
     }
   },
   },
   "Put Back": "Put back",
   "Put Back": "Put back",
   "Delete Completely": "Delete completely",
   "Delete Completely": "Delete completely",
+  "page_has_been_reverted": "{{path}} has been reverted",
   "modal_delete": {
   "modal_delete": {
     "delete_page": "Delete page",
     "delete_page": "Delete page",
     "deleting_page": "Deleting page",
     "deleting_page": "Deleting page",
@@ -424,6 +435,9 @@
     "recursively": "Delete pages under this path recursively.",
     "recursively": "Delete pages under this path recursively.",
     "completely": "Delete completely instead of putting it into trash."
     "completely": "Delete completely instead of putting it into trash."
   },
   },
+  "deleted_pages": "{{path}} has been deleted",
+  "deleted_pages_completely": "{{path}} has been deleted completely",
+  "renamed_pages": "{{path}} has been renamed",
   "modal_empty":{
   "modal_empty":{
     "empty_the_trash": "Empty The Trash",
     "empty_the_trash": "Empty The Trash",
     "notice": "The pages deleted completely are unrecoverable."
     "notice": "The pages deleted completely are unrecoverable."
@@ -432,7 +446,7 @@
     "label": {
     "label": {
       "Duplicate page": "Duplicate page",
       "Duplicate page": "Duplicate page",
       "New page name": "New page name",
       "New page name": "New page name",
-      "Fail to get subordinated pages": "Fail to get subordinated pages",
+      "Failed to get subordinated pages": "Failed to get subordinated pages",
       "Current page name": "Current page name",
       "Current page name": "Current page name",
       "Recursively": "Recursively",
       "Recursively": "Recursively",
       "Duplicate without exist path": "Duplicate without exist path",
       "Duplicate without exist path": "Duplicate without exist path",
@@ -442,6 +456,7 @@
       "recursive": "Duplicate children of under this path recursively"
       "recursive": "Duplicate children of under this path recursively"
     }
     }
   },
   },
+  "duplicated_pages": "{{fromPath}} has been duplicated",
   "modal_putback": {
   "modal_putback": {
     "label": {
     "label": {
       "Put Back Page": "Put back page",
       "Put Back Page": "Put back page",
@@ -508,7 +523,10 @@
     "page_not_found_in_preview": "\"{{path}}\" is not a GROWI page."
     "page_not_found_in_preview": "\"{{path}}\" is not a GROWI page."
   },
   },
   "toaster": {
   "toaster": {
+    "create_succeeded": "Succeeded to create {{target}}",
+    "create_failed": "Failed to create {{target}}",
     "update_successed": "Succeeded to update {{target}}",
     "update_successed": "Succeeded to update {{target}}",
+    "update_failed": "Failed to update {{target}}",
     "initialize_successed": "Succeeded to initialize {{target}}",
     "initialize_successed": "Succeeded to initialize {{target}}",
     "give_user_admin": "Succeeded to give {{username}} admin",
     "give_user_admin": "Succeeded to give {{username}} admin",
     "remove_user_admin": "Succeeded to remove {{username}} admin",
     "remove_user_admin": "Succeeded to remove {{username}} admin",
@@ -597,13 +615,40 @@
     "popover_desc": "Input channel name. You can notify multiple channels by entering a comma-separated list."
     "popover_desc": "Input channel name. You can notify multiple channels by entering a comma-separated list."
   },
   },
   "search_result": {
   "search_result": {
-    "result_meta": "Found \"{{keyword}}\" in {{total}}.",
+    "result_meta": "Search results for:",
     "deletion_mode_btn_lavel": "Select and delete page",
     "deletion_mode_btn_lavel": "Select and delete page",
     "cancel": "Cancel",
     "cancel": "Cancel",
     "delete": "Delete",
     "delete": "Delete",
     "check_all": "Check all",
     "check_all": "Check all",
     "deletion_modal_header": "Delete page",
     "deletion_modal_header": "Delete page",
-    "delete_completely": "Delete completely"
+    "delete_completely": "Delete completely",
+    "include_certain_path" : "Include {{pathToInclude}} path ",
+    "delete_all_selected_page" : "Delete All",
+    "currently_not_implemented":"This is not currently implemented",
+    "search_again" : "Search again",
+    "number_of_list_to_display" : "Display",
+    "page_number_unit" : "pages",
+    "sort_axis": {
+      "relationScore": "Sort by relevance",
+      "createdAt": "Creation date",
+      "updatedAt": "Last update date"
+    }
+  },
+  "private_legacy_pages": {
+    "bulk_operation": "Bulk operation",
+    "convert_all_selected_pages": "Convert all to new v5 compatible format",
+    "alert_title": "You are viewing old v4 compatible private pages.",
+    "alert_desc1": "On this page, you can select pages with the checkbox and batch convert to the new v5 compatible format from the \"Bulk operation\" button at the top of the screen.",
+    "nopages_title": "Congratulations. Ready to use GROWI v5!",
+    "nopages_desc1": "Now all the pages you can manage seem to be in v5 compatible format.",
+    "detail_info": "See the detail information from <a href='https://docs.growi.org/en/admin-guide/upgrading/50x.html' target='_blank' class='alert-link'>Upgrading GROWI to v5.0.x <i class='icon-share-alt'></i></a>.",
+    "modal": {
+      "title": "Convert to new v5 compatible format",
+      "converting_pages": "Converting pages",
+      "convert_recursively_label": "Convert child pages recursively.",
+      "convert_recursively_desc": "Convert pages under this path recursively.",
+      "button_label": "Convert"
+    }
   },
   },
   "security_setting": {
   "security_setting": {
     "Guest Users Access": "Guest users access",
     "Guest Users Access": "Guest users access",
@@ -622,9 +667,16 @@
     "page_listing_1_desc": "Show pages that are restricted by 'Only me' option when listing/searching",
     "page_listing_1_desc": "Show pages that are restricted by 'Only me' option when listing/searching",
     "page_listing_2": "Page listing/searching<br>restricted by User group",
     "page_listing_2": "Page listing/searching<br>restricted by User group",
     "page_listing_2_desc": "Show pages that are restricted by User group when listing/searching",
     "page_listing_2_desc": "Show pages that are restricted by User group when listing/searching",
-    "page_access_and_delete_rights": "Page access / Delete rights",
-    "complete_deletion": "Restrict complete deletion of pages",
-    "complete_deletion_explain": "Restricts users who can completely delete pages.",
+    "page_access_rights": "Page access",
+    "page_delete_rights": "Delete rights",
+    "page_delete": "Page Delete",
+    "page_delete_completely": "Page Delete Completely",
+    "other_options": "Other options",
+    "deletion_explain": "Restricts users who can trash the selected single page.",
+    "complete_deletion_explain": "Restricts users who can completely delete  selected single page.",
+    "recursive_deletion_explain": "Restricts users who can trash pages including descendants.",
+    "recursive_complete_deletion_explain": "Restricts users who can completely delete pages including descendants.",
+    "inherit": "Inherit(Use the same setting as for a single page)",
     "admin_only": "Admin only",
     "admin_only": "Admin only",
     "admin_and_author": "Admin and author",
     "admin_and_author": "Admin and author",
     "anyone": "Anyone",
     "anyone": "Anyone",
@@ -632,6 +684,7 @@
     "max_age": "Max age (msec)",
     "max_age": "Max age (msec)",
     "max_age_desc": "Specifies the number (in milliseconds) to expire users session.<br>Default: 2592000000 (30days)",
     "max_age_desc": "Specifies the number (in milliseconds) to expire users session.<br>Default: 2592000000 (30days)",
     "max_age_caution": "Restarting the server is required after you modify this value.",
     "max_age_caution": "Restarting the server is required after you modify this value.",
+    "page_delete_rights_caution": "The \"operation including the descendants\" setting is forced to be stronger than the \"operation for only the selected page\" setting.",
     "Authentication mechanism settings": "Authentication Mechanism Settings",
     "Authentication mechanism settings": "Authentication Mechanism Settings",
     "setup_is_not_yet_complete": "Setup is not yet complete",
     "setup_is_not_yet_complete": "Setup is not yet complete",
     "alert_siteUrl_is_not_set": "'Site URL' is NOT set. Set it from the {{link}}",
     "alert_siteUrl_is_not_set": "'Site URL' is NOT set. Set it from the {{link}}",
@@ -945,5 +998,23 @@
     "success_to_send_email": "Success to send email",
     "success_to_send_email": "Success to send email",
     "incorrect_token_or_expired_url": "The token is incorrect or the URL has expired. Please resend a password reset request via the link below.",
     "incorrect_token_or_expired_url": "The token is incorrect or the URL has expired. Please resend a password reset request via the link below.",
     "password_and_confirm_password_does_not_match": "Password and confirm password does not match"
     "password_and_confirm_password_does_not_match": "Password and confirm password does not match"
+  },
+  "maintenance_mode":{
+    "maintenance_mode": "Maintenance Mode",
+    "growi_is_under_maintenance": "GROWI is under maintenance. Please wait until it ends.",
+    "admin_page": "Admin Page",
+    "login": "Login",
+    "logout": "Logout"
+  },
+  "pagetree": {
+    "private_legacy_pages": "Private Legacy Pages",
+    "cannot_rename_a_title_that_contains_slash": "Cannot rename a title that contains '/'",
+    "you_cannot_move_this_page_now": "You cannot move this page now",
+    "something_went_wrong_with_moving_page": "Something went wrong with moving page"
+  },
+  "duplicated_page_alert" : {
+    "same_page_name_exists": "Same page name exits as「{{pageName}}」",
+    "same_page_name_exists_at_path" : "Same page name as {{pageName}} exists at {{path}} ",
+    "select_page_to_see" : "Select a page to see"
   }
   }
 }
 }

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

@@ -19,6 +19,31 @@
     "bug_report": "バグを報告する",
     "bug_report": "バグを報告する",
     "submit_bug_report": "<a href='https://github.com/weseek/growi/issues/new?assignees=&labels=bug&template=bug-report.md&title=Bug%3A' target='_blank' rel='noreferrer'>次に GitHub で Issue を投稿してください。</a>"
     "submit_bug_report": "<a href='https://github.com/weseek/growi/issues/new?assignees=&labels=bug&template=bug-report.md&title=Bug%3A' target='_blank' rel='noreferrer'>次に GitHub で Issue を投稿してください。</a>"
   },
   },
+  "v5_page_migration": {
+    "page_tree_not_avaliable" : "Page Tree 機能は現在使用できません。",
+    "go_to_settings": "設定する",
+    "migration_desc": "公開されているページに 古い v4 互換形式のものが存在します。ページツリーや簡単なリネームなどの新機能を利用するには、全てのページを v5 互換形式に変換してください。",
+    "migration_note": "注意: ページパスからユニーク制約が失われます。",
+    "upgrade_to_v5": "v5 互換形式 へ変換",
+    "modal_migration_warning": "管理者はユーザーに、v5 互換形式への変換中はページを作成・変更・削除しないように伝えることを強くお勧めします。",
+    "start_upgrading": "v5 互換形式への変換を開始",
+    "successfully_started": "正常に v5 互換形式への変換が開始されました",
+    "already_upgraded": "v5 互換形式への変換は既に完了しています"
+  },
+  "maintenance_mode": {
+    "maintenance_mode": "メンテナンスモード",
+    "under_maintenance_mode": "メンテナンスモード中",
+    "failed_to_start_maintenance_mode": "メンテナンスモードを開始できませんでした",
+    "failed_to_end_maintenance_mode": "メンテナンスモードを終了できませんでした",
+    "successfully_started_maintenance_mode": "メンテナンスモードを開始しました",
+    "successfully_ended_maintenance_mode": "メンテナンスモードを終了しました",
+    "warning_message_to_start": "メンテナンスモード中は管理画面にしかアクセスできなくなり、一般ユーザーは全ての操作が不能になります。",
+    "warning_message_to_end": "「データのインポート」および「V5 へのアップグレード」が進行中の場合は、処理が終了するまでメンテナンスモードを終了しないようにすることを推奨します。",
+    "supplymentary_message_to_start": "API についても管理者用 API しか機能しなくなります。",
+    "start_maintenance_mode": "メンテナンスモードを開始する",
+    "end_maintenance_mode": "メンテナンスモードを終了する",
+    "description": "メンテナンスモードでは、ユーザーのあらゆる操作を制限します。「データのインポート」および「V5 へのアップグレード」の際には必ずメンテナンスモードを開始してから行ってください。終了するには「セキュリティ設定」>「メンテナンスモード」から操作してください。"
+  },
   "app_setting": {
   "app_setting": {
     "site_name": "サイト名",
     "site_name": "サイト名",
     "sitename_change": "ヘッダーや HTML タイトルに使用されるサイト名を変更できます。",
     "sitename_change": "ヘッダーや HTML タイトルに使用されるサイト名を変更できます。",
@@ -196,6 +221,9 @@
     "beta_warning": "この機能はベータ版です",
     "beta_warning": "この機能はベータ版です",
     "import_from": "{{from}} からインポート",
     "import_from": "{{from}} からインポート",
     "import_growi_archive": "GROWI アーカイブをインポート",
     "import_growi_archive": "GROWI アーカイブをインポート",
+    "error": {
+      "only_upsert_available": "pages コレクションには 'Upsert' オプションのみ対応しています"
+    },
     "growi_settings": {
     "growi_settings": {
       "description_of_import_mode": {
       "description_of_import_mode": {
         "about": "既存のデータと同名であるデータをインポートする際の挙動は以下の3つのモードから選べます。",
         "about": "既存のデータと同名であるデータをインポートする際の挙動は以下の3つのモードから選べます。",
@@ -439,9 +467,13 @@
   },
   },
   "user_group_management": {
   "user_group_management": {
     "create_group": "新規グループの作成",
     "create_group": "新規グループの作成",
+    "add_child_group": "子グループの追加",
     "deny_create_group": "新規グループの作成はできません。",
     "deny_create_group": "新規グループの作成はできません。",
     "group_name": "グループ名",
     "group_name": "グループ名",
     "group_example": "例: Group1",
     "group_example": "例: Group1",
+    "parent_group": "親グループ",
+    "select_parent_group": "親グループを選択",
+    "release_parent_group": "親グループの解除",
     "add_modal": {
     "add_modal": {
       "add_user": "グループへのユーザー追加",
       "add_user": "グループへのユーザー追加",
       "search_option": "検索オプション",
       "search_option": "検索オプション",
@@ -451,7 +483,9 @@
       "backward_match": "後方一致"
       "backward_match": "後方一致"
     },
     },
     "group_list": "グループ一覧",
     "group_list": "グループ一覧",
+    "child_group_list": "子グループ一覧",
     "back_to_list": "グループ一覧に戻る",
     "back_to_list": "グループ一覧に戻る",
+    "back_to_ancestors_group": "祖先グループに戻る",
     "basic_info": "基本情報",
     "basic_info": "基本情報",
     "user_list": "ユーザー一覧",
     "user_list": "ユーザー一覧",
     "created_group": "グループを作成しました",
     "created_group": "グループを作成しました",
@@ -460,7 +494,7 @@
     "remove_from_group": "グループから外す",
     "remove_from_group": "グループから外す",
     "delete_modal": {
     "delete_modal": {
       "header": "グループの削除",
       "header": "グループの削除",
-      "desc": "グループ及び限定公開のページの削除を行うと元に戻すことはできませんのでご注意ください。",
+      "desc": "当該グループ配下に存在する子グループも全て削除されます。また、グループ及び限定公開のページの削除を行うと元に戻すことはできませんのでご注意ください。",
       "dropdown_desc": "削除するグループの限定公開ページの処理を選択してください",
       "dropdown_desc": "削除するグループの限定公開ページの処理を選択してください",
       "select_group": "グループを選択してください",
       "select_group": "グループを選択してください",
       "no_groups": "グループがありません",
       "no_groups": "グループがありません",

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

@@ -236,10 +236,10 @@ ___
 
 
 ```
 ```
 [/Sandbox]
 [/Sandbox]
-&lt;/user/admin1>
+</user/admin1>
 ```
 ```
 
 
-[/Sandbox]
+[/Sandbox]  
 </user/admin1>
 </user/admin1>
 
 
 ## Pukiwiki like linker
 ## Pukiwiki like linker
@@ -255,7 +255,7 @@ ___
 Bootstrap4のExampleは[[こちら>./Bootstrap4]]
 Bootstrap4のExampleは[[こちら>./Bootstrap4]]
 ```
 ```
 
 
-[[../user]]
+[[../Bootstrap4]]  
 Bootstrap4のExampleは[[こちら>./Bootstrap4]]
 Bootstrap4のExampleは[[こちら>./Bootstrap4]]
 
 
 # :pencil: Lists
 # :pencil: Lists

+ 90 - 20
packages/app/resource/locales/ja_JP/translation.json

@@ -11,8 +11,8 @@
   "phone":"スマホ",
   "phone":"スマホ",
   "tablet":"タブレット",
   "tablet":"タブレット",
   "Click to copy": "クリックでコピー",
   "Click to copy": "クリックでコピー",
+  "Rename": "名前変更",
   "Move/Rename": "移動/名前変更",
   "Move/Rename": "移動/名前変更",
-  "Moved": "移動しました",
   "Redirected": "リダイレクトされました",
   "Redirected": "リダイレクトされました",
   "Unlinked": "リダイレクト削除",
   "Unlinked": "リダイレクト削除",
   "Like!": "いいね!",
   "Like!": "いいね!",
@@ -20,6 +20,7 @@
   "Done": "完了",
   "Done": "完了",
   "Cancel": "キャンセル",
   "Cancel": "キャンセル",
   "Create": "作成",
   "Create": "作成",
+  "Description": "説明",
   "Admin": "管理",
   "Admin": "管理",
   "administrator": "管理者",
   "administrator": "管理者",
   "Tag": "タグ",
   "Tag": "タグ",
@@ -40,6 +41,7 @@
   "Initialize": "初期化",
   "Initialize": "初期化",
   "Update": "更新",
   "Update": "更新",
   "Update Page": "ページを更新",
   "Update Page": "ページを更新",
+  "Error": "エラー",
   "Warning": "注意",
   "Warning": "注意",
   "Sign in": "ログイン",
   "Sign in": "ログイン",
   "Sign up is here": "新規登録はこちら",
   "Sign up is here": "新規登録はこちら",
@@ -66,6 +68,7 @@
   "Include Attachment File": "添付ファイルも含める",
   "Include Attachment File": "添付ファイルも含める",
   "Include Comment": "コメントも含める",
   "Include Comment": "コメントも含める",
   "Include Subordinated Page": "配下ページも含める",
   "Include Subordinated Page": "配下ページも含める",
+  "Include Subordinated Target Page": "{{target}} 下も含む",
   "All Subordinated Page": "全ての配下ページ",
   "All Subordinated Page": "全ての配下ページ",
   "Specify Hierarchy": "階層の深さを指定",
   "Specify Hierarchy": "階層の深さを指定",
   "Submitted the request to create the archive": "アーカイブ作成のリクエストを正常に送信しました",
   "Submitted the request to create the archive": "アーカイブ作成のリクエストを正常に送信しました",
@@ -108,6 +111,9 @@
   "Create under": "ページを以下に作成",
   "Create under": "ページを以下に作成",
   "Wiki Management Home Page": "Wiki管理トップ",
   "Wiki Management Home Page": "Wiki管理トップ",
   "App Settings": "アプリ設定",
   "App Settings": "アプリ設定",
+  "V5 Page Migration": "V5 互換形式 への変換",
+  "GROWI.5.0_new_schema": "GROWI.5.0における新スキーマについて",
+  "See_more_detail_on_new_schema": "詳しくは<a href='#'>{{url}}</a><i class='icon-share-alt'></i>を参照ください。",
   "Site URL settings": "サイトURL設定",
   "Site URL settings": "サイトURL設定",
   "Markdown Settings": "マークダウン設定",
   "Markdown Settings": "マークダウン設定",
   "Customize": "カスタマイズ",
   "Customize": "カスタマイズ",
@@ -117,6 +123,8 @@
   "Legacy_Slack_Integration": "Slack連携 (レガシー)",
   "Legacy_Slack_Integration": "Slack連携 (レガシー)",
   "User_Management": "ユーザー管理",
   "User_Management": "ユーザー管理",
   "external_account_management": "外部アカウント管理",
   "external_account_management": "外部アカウント管理",
+  "UserGroup": "グループ",
+  "ChildUserGroup": "子グループ",
   "UserGroup Management": "グループ管理",
   "UserGroup Management": "グループ管理",
   "Full Text Search Management": "全文検索管理",
   "Full Text Search Management": "全文検索管理",
   "Import Data": "データインポート",
   "Import Data": "データインポート",
@@ -152,12 +160,17 @@
   "Sidebar mode": "サイドバーモード",
   "Sidebar mode": "サイドバーモード",
   "Sidebar mode on Editor": "サイドバーモード(編集時)",
   "Sidebar mode on Editor": "サイドバーモード(編集時)",
   "No bookmarks yet": "No bookmarks yet",
   "No bookmarks yet": "No bookmarks yet",
+  "add_bookmark": "ブックマークに追加",
+  "remove_bookmark": "ブックマークから削除",
   "Recent Created": "最新の作成",
   "Recent Created": "最新の作成",
   "Recent Changes": "最新の変更",
   "Recent Changes": "最新の変更",
+  "Page Tree": "ページツリー",
   "original_path":"元のパス",
   "original_path":"元のパス",
   "new_path":"新しいパス",
   "new_path":"新しいパス",
   "duplicated_path":"重複したパス",
   "duplicated_path":"重複したパス",
   "Link sharing is disabled": "リンクのシェアは無効化されています",
   "Link sharing is disabled": "リンクのシェアは無効化されています",
+  "successfully_saved_the_page": "ページが正常に保存されました",
+  "you_can_not_create_page_with_this_name": "この名前でページを作成することはできません",
   "personal_dropdown": {
   "personal_dropdown": {
     "home": "ホーム",
     "home": "ホーム",
     "settings": "設定",
     "settings": "設定",
@@ -169,7 +182,9 @@
   "form_validation": {
   "form_validation": {
     "error_message": "いくつかの値が設定されていません",
     "error_message": "いくつかの値が設定されていません",
     "required": "%sに値を入力してください",
     "required": "%sに値を入力してください",
-    "invalid_syntax": "%sの構文が不正です"
+    "invalid_syntax": "%sの構文が不正です",
+    "title_required": "タイトルを入力してください",
+    "slashed_are_not_yet_supported": "スラッシュを含むタイトルにはまだ対応していません"
   },
   },
   "not_found_page": {
   "not_found_page": {
     "Create Page": "ページを作成する",
     "Create Page": "ページを作成する",
@@ -353,12 +368,8 @@
   "page_page": {
   "page_page": {
     "notice": {
     "notice": {
       "version": "これは現在の版ではありません。",
       "version": "これは現在の版ではありません。",
-      "moved": "このページは",
-      "moved_period":"から移動しました。",
       "redirected": "リダイレクト元 >>",
       "redirected": "リダイレクト元 >>",
       "redirected_period":"",
       "redirected_period":"",
-      "duplicated": "このページは",
-      "duplicated_period": "から複製されました。",
       "unlinked": "このページへのリダイレクトは削除されました。",
       "unlinked": "このページへのリダイレクトは削除されました。",
       "restricted": "このページの閲覧は制限されています",
       "restricted": "このページの閲覧は制限されています",
       "stale": "このページは最終更新日から{{count}}年以上が経過しています。",
       "stale": "このページは最終更新日から{{count}}年以上が経過しています。",
@@ -366,9 +377,6 @@
       "no_deadline": "このページに有効期限は設定されていません。"
       "no_deadline": "このページに有効期限は設定されていません。"
     }
     }
   },
   },
-  "page_table_of_contents": {
-    "empty": "目次は空です"
-  },
   "page_edit": {
   "page_edit": {
     "Show active line": "アクティブ行をハイライト",
     "Show active line": "アクティブ行をハイライト",
     "auto_format_table": "表の自動整形",
     "auto_format_table": "表の自動整形",
@@ -384,7 +392,7 @@
     "notfound_or_forbidden": "元のページが見つからないか、アクセス権がありません。",
     "notfound_or_forbidden": "元のページが見つからないか、アクセス権がありません。",
     "already_exists": "新しいページが既に存在しています。",
     "already_exists": "新しいページが既に存在しています。",
     "outdated": "ページが他のユーザーによって更新されました。",
     "outdated": "ページが他のユーザーによって更新されました。",
-    "user_not_admin": "権限のあるユーザーのみが完全削除できます"
+    "user_not_admin": "権限のあるユーザーのみが削除できます"
   },
   },
   "page_history": {
   "page_history": {
     "revision_list": "更新履歴",
     "revision_list": "更新履歴",
@@ -399,11 +407,12 @@
     "label": {
     "label": {
       "Move/Rename page": "ページを移動/名前変更する",
       "Move/Rename page": "ページを移動/名前変更する",
       "New page name": "移動先のページ名",
       "New page name": "移動先のページ名",
-      "Fail to get subordinated pages": "配下ページの取得に失敗しました",
-      "Fail to get exist path": "存在するパスの取得に失敗しました",
-      "Rename without exist path": "存在するパス以外を名前変更する",
+      "Failed to get subordinated pages": "配下ページの取得に失敗しました",
+      "Failed to get exist path": "存在するパスの取得に失敗しました",
       "Current page name": "現在のページ名",
       "Current page name": "現在のページ名",
-      "Recursively": "再帰的に移動/名前変更",
+      "Rename this page only": "このページのみを移動/名前変更",
+      "Force rename all child pages": "全ての配下のページを移動/名前変更する",
+      "Other options": "その他のオプション",
       "Do not update metadata": "メタデータを更新しない",
       "Do not update metadata": "メタデータを更新しない",
       "Redirect": "リダイレクトする"
       "Redirect": "リダイレクトする"
     },
     },
@@ -415,6 +424,7 @@
   },
   },
   "Put Back": "元に戻す",
   "Put Back": "元に戻す",
   "Delete Completely": "完全削除",
   "Delete Completely": "完全削除",
+  "page_has_been_reverted": "{{path}} を元に戻しました",
   "modal_delete": {
   "modal_delete": {
     "delete_page": "ページを削除する",
     "delete_page": "ページを削除する",
     "deleting_page": "ページパス",
     "deleting_page": "ページパス",
@@ -424,6 +434,9 @@
     "recursively": "配下のページも削除します",
     "recursively": "配下のページも削除します",
     "completely": "ゴミ箱を経由せず、完全に削除します"
     "completely": "ゴミ箱を経由せず、完全に削除します"
   },
   },
+  "deleted_pages": "{{path}} をゴミ箱に入れました",
+  "deleted_pages_completely": "{{path}} を完全に削除しました",
+  "renamed_pages": "{{path}} を移動/名前変更しました",
   "modal_empty":{
   "modal_empty":{
     "empty_the_trash": "ゴミ箱を空にする",
     "empty_the_trash": "ゴミ箱を空にする",
     "notice": "完全削除したページは元に戻すことができません"
     "notice": "完全削除したページは元に戻すことができません"
@@ -432,7 +445,7 @@
     "label": {
     "label": {
       "Duplicate page": "ページを複製する",
       "Duplicate page": "ページを複製する",
       "New page name": "複製後のページ名",
       "New page name": "複製後のページ名",
-      "Fail to get subordinated pages": "配下ページの取得に失敗しました",
+      "Failed to get subordinated pages": "配下ページの取得に失敗しました",
       "Current page name": "現在のページ名",
       "Current page name": "現在のページ名",
       "Recursively": "再帰的に複製",
       "Recursively": "再帰的に複製",
       "Duplicate without exist path": "存在するパス以外を複製する",
       "Duplicate without exist path": "存在するパス以外を複製する",
@@ -442,6 +455,7 @@
       "recursive": "配下のページも複製します"
       "recursive": "配下のページも複製します"
     }
     }
   },
   },
+  "duplicated_pages": "{{fromPath}} を複製しました",
   "modal_putback": {
   "modal_putback": {
     "label": {
     "label": {
       "Put Back Page": "ページを元に戻す",
       "Put Back Page": "ページを元に戻す",
@@ -508,7 +522,10 @@
     "page_not_found_in_preview": "\"{{path}}\" というページはありません。"
     "page_not_found_in_preview": "\"{{path}}\" というページはありません。"
   },
   },
   "toaster": {
   "toaster": {
+    "create_succeeded": "新しい{{target}}が作成されました",
+    "create_failed": "{{target}}の作成に失敗しました",
     "update_successed": "{{target}}を更新しました",
     "update_successed": "{{target}}を更新しました",
+    "update_failed": "{{target}}の更新に失敗しました",
     "initialize_successed": "{{target}}を初期化しました",
     "initialize_successed": "{{target}}を初期化しました",
     "give_user_admin": "{{username}}を管理者に設定しました",
     "give_user_admin": "{{username}}を管理者に設定しました",
     "remove_user_admin": "{{username}}を管理者から外しました",
     "remove_user_admin": "{{username}}を管理者から外しました",
@@ -597,13 +614,40 @@
     "popover_desc": "チャンネル名を入れてください。カンマ区切りのリストを入力することで複数のチャンネルに通知することができます。"
     "popover_desc": "チャンネル名を入れてください。カンマ区切りのリストを入力することで複数のチャンネルに通知することができます。"
   },
   },
   "search_result": {
   "search_result": {
-    "result_meta": "{{total}}件のページが見つかりました。検索ワード: \"{{keyword}}\"",
+    "result_meta": "検索結果:",
     "deletion_mode_btn_lavel": "ページを指定して削除",
     "deletion_mode_btn_lavel": "ページを指定して削除",
     "cancel": "キャンセル",
     "cancel": "キャンセル",
     "delete": "削除",
     "delete": "削除",
     "check_all": "すべてチェック",
     "check_all": "すべてチェック",
     "deletion_modal_header": "以下のページを削除",
     "deletion_modal_header": "以下のページを削除",
-    "delete_completely": "完全に削除する"
+    "delete_completely": "完全に削除する",
+    "include_certain_path": "{{pathToInclude}}下を含む ",
+    "delete_all_selected_page" : "一括削除",
+    "currently_not_implemented":"現在未実装の機能です",
+    "search_again" : "再検索",
+    "number_of_list_to_display" : "表示件数",
+    "page_number_unit" : "件",
+    "sort_axis": {
+      "relationScore": "関連度順",
+      "createdAt": "作成日時",
+      "updatedAt": "更新日時"
+    }
+  },
+  "private_legacy_pages": {
+    "bulk_operation": "一括操作",
+    "convert_all_selected_pages": "新しい v5 互換形式に一括変換",
+    "alert_title": "古い v4 互換形式のプライベートページを表示しています",
+    "alert_desc1": "このページでは、チェックボックスでページを選択し、画面上部の「一括操作」ボタンから新しい v5 互換形式に一括変換できます。",
+    "nopages_title": "おめでとうございます。GROWI v5 を使う準備が完了しました!",
+    "nopages_desc1": "今あなたが管理可能なページはすべて v5 互換形式になっているようです。",
+    "detail_info": "詳しくは <a href='https://docs.growi.org/ja/admin-guide/upgrading/50x.html' target='_blank' class='alert-link'>GROWI v5.0.x へのアップグレード  <i class='icon-share-alt'></i></a> を参照ください。",
+    "modal": {
+      "title": "新しい v5 互換形式への変換",
+      "converting_pages": "以下のページを変換します",
+      "convert_recursively_label": "再起的に変換",
+      "convert_recursively_desc": "このページの配下のページを再起的に変換します",
+      "button_label": "変換"
+    }
   },
   },
   "security_setting": {
   "security_setting": {
     "Guest Users Access": "ゲストユーザーのアクセス",
     "Guest Users Access": "ゲストユーザーのアクセス",
@@ -622,9 +666,16 @@
     "page_listing_1_desc": "ページのリスト表示や検索結果において、'自分のみ'に閲覧制限をしているページをアクセス権のないユーザーにも表示します。",
     "page_listing_1_desc": "ページのリスト表示や検索結果において、'自分のみ'に閲覧制限をしているページをアクセス権のないユーザーにも表示します。",
     "page_listing_2": "ページのリスト表示と検索<br>特定グループに閲覧制限しているページ",
     "page_listing_2": "ページのリスト表示と検索<br>特定グループに閲覧制限しているページ",
     "page_listing_2_desc": "ページのリスト表示や検索結果において、特定グループにのみ閲覧制限をしているページをアクセス権のないユーザーにも表示します。",
     "page_listing_2_desc": "ページのリスト表示や検索結果において、特定グループにのみ閲覧制限をしているページをアクセス権のないユーザーにも表示します。",
-    "page_access_and_delete_rights": "ページの閲覧・削除権限",
-    "complete_deletion": "ページの完全削除",
-    "complete_deletion_explain": "ページを完全に削除できるユーザーを制限します。",
+    "page_access_rights": "ページの閲覧権限",
+    "page_delete_rights": "ページの削除権限",
+    "page_delete": "ゴミ箱に入れる",
+    "page_delete_completely": "完全に削除する",
+    "other_options": "その他のオプション",
+    "deletion_explain": "ページをゴミ箱に入れることができるユーザーを制限します。",
+    "complete_deletion_explain": "ページを完全削除することができるユーザーを制限します。",
+    "recursive_deletion_explain": "子孫を含めたページをゴミ箱に入れることができるユーザーを制限します。",
+    "recursive_complete_deletion_explain": "子孫を含めたページを完全削除することができるユーザーを制限します。",
+    "inherit": "単体のみと同じ",
     "admin_only": "管理者のみ可能",
     "admin_only": "管理者のみ可能",
     "admin_and_author": "管理者とページ作者が可能",
     "admin_and_author": "管理者とページ作者が可能",
     "anyone": "誰でも可能",
     "anyone": "誰でも可能",
@@ -632,6 +683,7 @@
     "max_age": "有効期間 (ミリ秒)",
     "max_age": "有効期間 (ミリ秒)",
     "max_age_desc": "ユーザーのセッション情報の有効期間をミリ秒で指定できます。<br>デフォルト値: 2592000000 (30日間)",
     "max_age_desc": "ユーザーのセッション情報の有効期間をミリ秒で指定できます。<br>デフォルト値: 2592000000 (30日間)",
     "max_age_caution": "この値を変更した後は、サーバーを再起動する必要があります。",
     "max_age_caution": "この値を変更した後は、サーバーを再起動する必要があります。",
+    "page_delete_rights_caution": "「子孫を含む操作」の設定値は、「単体のみの操作」の設定値よりも強いものに強制されます。",
     "Authentication mechanism settings": "認証機構設定",
     "Authentication mechanism settings": "認証機構設定",
     "setup_is_not_yet_complete":"セットアップはまだ完了してません",
     "setup_is_not_yet_complete":"セットアップはまだ完了してません",
     "alert_siteUrl_is_not_set": "'サイトURL' が設定されていません。{{link}} から設定してください。",
     "alert_siteUrl_is_not_set": "'サイトURL' が設定されていません。{{link}} から設定してください。",
@@ -938,5 +990,23 @@
     "success_to_send_email": "メールを送信しました",
     "success_to_send_email": "メールを送信しました",
     "incorrect_token_or_expired_url":"トークンが正しくないか、URLの有効期限が切れています。 以下のリンクからパスワードリセットリクエストを再送信してください。",
     "incorrect_token_or_expired_url":"トークンが正しくないか、URLの有効期限が切れています。 以下のリンクからパスワードリセットリクエストを再送信してください。",
     "password_and_confirm_password_does_not_match": "パスワードと確認パスワードが一致しません"
     "password_and_confirm_password_does_not_match": "パスワードと確認パスワードが一致しません"
+  },
+  "maintenance_mode":{
+    "maintenance_mode": "メンテナンスモード",
+    "growi_is_under_maintenance": "GROWI はメンテナンス中です。終了するまでお待ちください",
+    "admin_page": "管理画面へ",
+    "login": "ログイン",
+    "logout": "ログアウト"
+  },
+  "pagetree": {
+    "private_legacy_pages": "旧形式のプライベートページ",
+    "cannot_rename_a_title_that_contains_slash": "`/` が含まれているタイトルにリネームできません",
+    "you_cannot_move_this_page_now": "現在、このページを移動することはできません",
+    "something_went_wrong_with_moving_page": "ページの移動に問題が発生しました"
+  },
+  "duplicated_page_alert" : {
+    "same_page_name_exists": "ページ名 「{{pageName}}」が重複しています",
+    "same_page_name_exists_at_path" : "”{{path}}” において ”{{pageName}}”というページは複数存在しています。",
+    "select_page_to_see" : "以下から遷移するページを選択してください。"
   }
   }
 }
 }

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

@@ -19,6 +19,31 @@
     "bug_report": "提交一个错误报告",
     "bug_report": "提交一个错误报告",
     "submit_bug_report": "<a href='https://github.com/weseek/growi/issues/new?assignees=&labels=bug&template=bug-report.md&title=Bug%3A' target='_blank' rel='noreferrer'>然后提交你的问题到GitHub。</a>"
     "submit_bug_report": "<a href='https://github.com/weseek/growi/issues/new?assignees=&labels=bug&template=bug-report.md&title=Bug%3A' target='_blank' rel='noreferrer'>然后提交你的问题到GitHub。</a>"
   },
   },
+  "v5_page_migration": {
+    "page_tree_not_avaliable": "Page Tree 功能不可用",
+    "go_to_settings": "进入设置,启用该功能",
+    "migration_desc": "有一些页面具有旧的v4兼容性。为了利用新的功能,如页面树和容易重命名,请将您的所有页面转换为v5兼容性。",
+    "migration_note": "注意:你将失去页面路径的唯一约束。",
+    "upgrade_to_v5": "转换为v5兼容性",
+    "modal_migration_warning": "这个过程可能需要很长时间。强烈建议管理员告诉用户在转换期间不要创建、修改或删除页面。",
+    "start_upgrading": "开始转换为v5兼容性",
+    "successfully_started": "成功开始转换",
+    "already_upgraded": "你已经完成了向v5兼容性的转换"
+  },
+  "maintenance_mode": {
+    "maintenance_mode": "维护模式",
+    "under_maintenance_mode": "在维护模式下",
+    "failed_to_start_maintenance_mode": "启动维护模式失败",
+    "failed_to_end_maintenance_mode": "结束维护模式失败",
+    "successfully_started_maintenance_mode": "成功地启动了维护模式",
+    "successfully_ended_maintenance_mode": "成功地结束了维护模式",
+    "warning_message_to_start": "你将无法访问管理员设置以外的页面。普通用户将无法访问任何内容,直到维护模式手动结束。",
+    "warning_message_to_end": "如果 \"数据导入 \"和 \"升级到V5 \"正在进行中,建议在该过程完成之前不要退出维护模式。",
+    "supplymentary_message_to_start": "至于API,只有管理员的API将是有效的。",
+    "start_maintenance_mode": "启动维护模式",
+    "end_maintenance_mode": "结束维护模式",
+    "description": "维护模式限制了所有的用户操作。 在执行 \"数据导入 \"和 \"升级到V5 \"之前,务必启动维护模式。 要退出,进入 \"安全设置\">\"维护模式\"。"
+  },
   "app_setting": {
   "app_setting": {
     "site_name": "网站名称 ",
     "site_name": "网站名称 ",
     "sitename_change": "您可以更改用于标题和HTML标题的网站名称。",
     "sitename_change": "您可以更改用于标题和HTML标题的网站名称。",
@@ -188,6 +213,9 @@
     "beta_warning": "这个函数是Beta。",
     "beta_warning": "这个函数是Beta。",
     "import_from": "Import from {{from}}",
     "import_from": "Import from {{from}}",
     "import_growi_archive": "Import GROWI archive",
     "import_growi_archive": "Import GROWI archive",
+    "error": {
+      "only_upsert_available": "Only 'Upsert' option is available for pages collection."
+    },
     "growi_settings": {
     "growi_settings": {
       "description_of_import_mode": {
       "description_of_import_mode": {
         "about": "When you import data with the same name as an existing one, choose from the following three modes below.",
         "about": "When you import data with the same name as an existing one, choose from the following three modes below.",
@@ -449,9 +477,13 @@
   },
   },
   "user_group_management": {
   "user_group_management": {
     "create_group": "创建新组",
     "create_group": "创建新组",
+    "add_child_group": "添加一个子组",
     "deny_create_group": "不能用当前设置创建新组。",
     "deny_create_group": "不能用当前设置创建新组。",
     "group_name": "组名",
     "group_name": "组名",
     "group_example": "e.g.:第1组",
     "group_example": "e.g.:第1组",
+    "parent_group": "父母组",
+    "select_parent_group": "选择父组",
+    "release_parent_group": "Release parent group",
     "add_modal": {
     "add_modal": {
       "add_user": "将用户添加到创建的组",
       "add_user": "将用户添加到创建的组",
       "search_option": "搜索选项",
       "search_option": "搜索选项",
@@ -461,7 +493,9 @@
       "backward_match": "向后匹配"
       "backward_match": "向后匹配"
     },
     },
     "group_list": "组列表",
     "group_list": "组列表",
+    "child_group_list": "儿童组名单",
     "back_to_list": "返回组列表",
     "back_to_list": "返回组列表",
+    "back_to_ancestors_group": "返回到祖先组",
     "basic_info": "基本信息",
     "basic_info": "基本信息",
     "user_list": "用户列表",
     "user_list": "用户列表",
     "created_group": "已创建组",
     "created_group": "已创建组",
@@ -470,7 +504,7 @@
     "remove_from_group": "删除此用户",
     "remove_from_group": "删除此用户",
     "delete_modal": {
     "delete_modal": {
       "header": "删除组",
       "header": "删除组",
-      "desc": "删除后,将无法检索已删除的组及其私人页。",
+      "desc": "该组下的所有子组也将被删除。删除后,将无法检索已删除的组及其私人页。",
       "dropdown_desc": "为私人页选择操作",
       "dropdown_desc": "为私人页选择操作",
       "select_group": "选择组",
       "select_group": "选择组",
       "no_groups": "没有可选择的组",
       "no_groups": "没有可选择的组",

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

@@ -237,10 +237,10 @@ You can create links using `[Display text](URL)`.
 
 
 ```
 ```
 [/Sandbox]
 [/Sandbox]
-&lt;/user/admin1>
+</user/admin1>
 ```
 ```
 
 
-[/Sandbox]
+[/Sandbox]  
 </user/admin1>
 </user/admin1>
 
 
 ## Pukiwiki like linker
 ## Pukiwiki like linker
@@ -256,8 +256,8 @@ Both the page description and link address can be displayed on the page.
 Example of Bootstrap4 is[[here>./Bootstrap4]]
 Example of Bootstrap4 is[[here>./Bootstrap4]]
 ```
 ```
 
 
-[[../user]]
-Example of Bootstrap4 is[[here>./Bootstrap4]]
+[[../Bootstrap4]]  
+Example of Bootstrap4 is [[here>./Bootstrap4]]
 
 
 # :pencil: Lists
 # :pencil: Lists
 
 

+ 101 - 31
packages/app/resource/locales/zh_CN/translation.json

@@ -12,8 +12,8 @@
   "tablet":"平板",
   "tablet":"平板",
 	"Login": "登录",
 	"Login": "登录",
 	"Click to copy": "点击复制",
 	"Click to copy": "点击复制",
+  "Rename": "重命名",
 	"Move/Rename": "移动/重命名",
 	"Move/Rename": "移动/重命名",
-	"Moved": "移动",
 	"Redirected": "重定向",
 	"Redirected": "重定向",
 	"Unlinked": "Unlinked",
 	"Unlinked": "Unlinked",
 	"Like!": "Like!",
 	"Like!": "Like!",
@@ -21,6 +21,7 @@
   "Done": "Done",
   "Done": "Done",
   "Cancel": "取消",
   "Cancel": "取消",
 	"Create": "创建",
 	"Create": "创建",
+  "Description": "描述",
 	"Admin": "管理",
 	"Admin": "管理",
 	"administrator": "管理员",
 	"administrator": "管理员",
 	"Tag": "标签",
 	"Tag": "标签",
@@ -41,6 +42,7 @@
 	"Initialize": "初始化",
 	"Initialize": "初始化",
   "Update": "更新",
   "Update": "更新",
 	"Update Page": "更新本页",
 	"Update Page": "更新本页",
+	"Error": "误差",
 	"Warning": "警告",
 	"Warning": "警告",
   "Sign in": "登录",
   "Sign in": "登录",
 	"Sign up is here": "注册",
 	"Sign up is here": "注册",
@@ -67,6 +69,7 @@
   "Include Attachment File": "包含附件",
   "Include Attachment File": "包含附件",
   "Include Comment": "包含评论",
   "Include Comment": "包含评论",
   "Include Subordinated Page": "包括子页面",
   "Include Subordinated Page": "包括子页面",
+  "Include Subordinated Target Page": "包括 {{target}}",
   "All Subordinated Page": "所有子页面",
   "All Subordinated Page": "所有子页面",
   "Specify Hierarchy": "指定层级",
   "Specify Hierarchy": "指定层级",
   "Submitted the request to create the archive": "提交创建归档请求",
   "Submitted the request to create the archive": "提交创建归档请求",
@@ -116,6 +119,9 @@
 	"Create under": "Create page under below:",
 	"Create under": "Create page under below:",
 	"Wiki Management Home Page": "Wiki管理首页",
 	"Wiki Management Home Page": "Wiki管理首页",
 	"App Settings": "系统设置",
 	"App Settings": "系统设置",
+  "V5 Page Migration": "转换为V5的兼容性",
+  "GROWI.5.0_new_schema": "GROWI.5.0 new schema",
+  "See_more_detail_on_new_schema": "更多详情请见<a href='#'>{{url}}</a> <i class='icon-share-alt'></i> ",
 	"Site URL settings": "主页URL设置",
 	"Site URL settings": "主页URL设置",
 	"Markdown Settings": "Markdown设置",
 	"Markdown Settings": "Markdown设置",
 	"Customize": "页面定制",
 	"Customize": "页面定制",
@@ -125,6 +131,8 @@
   "Legacy_Slack_Integration": "旧版Slack一体化",
   "Legacy_Slack_Integration": "旧版Slack一体化",
 	"User_Management": "用户管理",
 	"User_Management": "用户管理",
 	"external_account_management": "外部账户管理",
 	"external_account_management": "外部账户管理",
+  "UserGroup": "用户组",
+  "ChildUserGroup": "儿童用户组",
 	"UserGroup Management": "用户组管理",
 	"UserGroup Management": "用户组管理",
 	"Full Text Search Management": "全文搜索管理",
 	"Full Text Search Management": "全文搜索管理",
 	"Import Data": "导入数据",
 	"Import Data": "导入数据",
@@ -158,16 +166,23 @@
 	"Sign out": "退出",
 	"Sign out": "退出",
   "Disassociate": "解除关联",
   "Disassociate": "解除关联",
   "No bookmarks yet": "暂无书签",
   "No bookmarks yet": "暂无书签",
+  "add_bookmark": "添加到书签",
+  "remove_bookmark": "从书签中删除",
 	"Recent Created": "最新创建",
 	"Recent Created": "最新创建",
   "Recent Changes": "最新修改",
   "Recent Changes": "最新修改",
+  "Page Tree": "页面树",
   "original_path":"Original path",
   "original_path":"Original path",
   "new_path":"New path",
   "new_path":"New path",
-  "duplicated_path":"duplicated_path",
+  "duplicated_path":"Duplicated path",
   "Link sharing is disabled": "你不允许分享该链接",
   "Link sharing is disabled": "你不允许分享该链接",
+  "successfully_saved_the_page": "成功地保存了该页面",
+  "you_can_not_create_page_with_this_name": "您无法使用此名称创建页面",
 	"form_validation": {
 	"form_validation": {
 		"error_message": "有些值不正确",
 		"error_message": "有些值不正确",
 		"required": "%s 是必需的",
 		"required": "%s 是必需的",
-		"invalid_syntax": "%s的语法无效。"
+		"invalid_syntax": "%s的语法无效。",
+    "title_required": "标题是必需的。",
+    "slashed_are_not_yet_supported": "スラッシュを含むタイトルにはまだ対応していません"
   },
   },
   "not_found_page": {
   "not_found_page": {
     "Create Page": "创建页面",
     "Create Page": "创建页面",
@@ -332,12 +347,8 @@
 	"page_page": {
 	"page_page": {
 		"notice": {
 		"notice": {
 			"version": "这不是当前版本。",
 			"version": "这不是当前版本。",
-			"moved": "此页已从",
-      "moved_period": "",
 			"redirected": "您将从",
 			"redirected": "您将从",
       "redirected_period": "",
       "redirected_period": "",
-			"duplicated": "此页来自",
-      "duplicated_period": "",
 			"unlinked": "将网页重定向到此网页已被删除。",
 			"unlinked": "将网页重定向到此网页已被删除。",
 			"restricted": "访问此页受到限制",
 			"restricted": "访问此页受到限制",
 			"stale": "自上次更新以来,已超过{{count}年。",
 			"stale": "自上次更新以来,已超过{{count}年。",
@@ -353,9 +364,6 @@
 			"conflict": "无法保存您所做的更改,因为其他人正在编辑此页。请在重新加载页面后重新编辑受影响的部分。"
 			"conflict": "无法保存您所做的更改,因为其他人正在编辑此页。请在重新加载页面后重新编辑受影响的部分。"
 		}
 		}
   },
   },
-  "page_table_of_contents": {
-    "empty": "目录为空"
-  },
   "page_comment": {
   "page_comment": {
     "display_the_page_when_posting_this_comment": "Display the page when posting this comment"
     "display_the_page_when_posting_this_comment": "Display the page when posting this comment"
   },
   },
@@ -363,7 +371,7 @@
 		"notfound_or_forbidden": "未找到或禁止原始页。",
 		"notfound_or_forbidden": "未找到或禁止原始页。",
 		"already_exists": "新建页面已存在",
 		"already_exists": "新建页面已存在",
 		"outdated": "页面已被某人更新,现在已过时。",
 		"outdated": "页面已被某人更新,现在已过时。",
-		"user_not_admin": "仅管理员用户可以完全删除"
+		"user_not_admin": "仅管理员用户可以删除"
   },
   },
   "page_history": {
   "page_history": {
     "revision_list": "修订清单",
     "revision_list": "修订清单",
@@ -376,24 +384,26 @@
   },
   },
 	"modal_rename": {
 	"modal_rename": {
 		"label": {
 		"label": {
-			"Move/Rename page": "页面 移动/重命名",
+      "Move/Rename page": "页面 移动/重命名",
       "New page name": "新建页面名称",
       "New page name": "新建页面名称",
-      "Fail to get subordinated pages": "Fail to get subordinated pages",
-      "Fail to get exist path": "Fail to get exist path",
-      "Rename without exist path": "Rename without exist path",
-			"Current page name": "当前页面名称",
-			"Recursively": "递归地",
-			"Do not update metadata": "不更新元数据",
-			"Redirect": "重定向"
+      "Failed to get subordinated pages": "Failed to get subordinated pages",
+      "Failed to get exist path": "Failed to get exist path",
+      "Current page name": "当前页面名称",
+      "Rename this page only": "仅重命名此页面",
+      "Force rename all child pages": "强制重命名所有子页面 ",
+      "Other options": "其他选项",
+      "Do not update metadata": "不更新元数据",
+      "Redirect": "重定向"
 		},
 		},
 		"help": {
 		"help": {
-			"redirect": "Redirect to new page if someone accesses <code>%s</code>",
-			"metadata": "Remains last update user and updated date as is",
-			"recursive": "Move/Rename children of under <code>%s</code> recursively"
+      "redirect": "Redirect to new page if someone accesses <code>%s</code>",
+      "metadata": "Remains last update user and updated date as is",
+      "recursive": "Move/Rename children of under <code>%s</code> recursively"
 		}
 		}
 	},
 	},
 	"Put Back": "Put back",
 	"Put Back": "Put back",
-	"Delete Completely": "Delete completely",
+  "Delete Completely": "Delete completely",
+  "page_has_been_reverted": "{{path}} 已还原",
 	"modal_delete": {
 	"modal_delete": {
 		"delete_page": "Delete page",
 		"delete_page": "Delete page",
 		"deleting_page": "Deleting page",
 		"deleting_page": "Deleting page",
@@ -402,7 +412,10 @@
 		"delete_completely_restriction": "You don't have the authority to delete pages completely.",
 		"delete_completely_restriction": "You don't have the authority to delete pages completely.",
 		"recursively": "Delete children of <code>%s</code> recursively.",
 		"recursively": "Delete children of <code>%s</code> recursively.",
 		"completely": "Delete completely instead of putting it into trash."
 		"completely": "Delete completely instead of putting it into trash."
-	},
+  },
+  "deleted_pages": "将 {{path}} 放入垃圾箱",
+  "deleted_pages_completely": "{{path}} 已被完全删除",
+  "renamed_pages": "移动/重命名 {{path}}",
 	"modal_empty": {
 	"modal_empty": {
 		"empty_the_trash": "Empty The Trash",
 		"empty_the_trash": "Empty The Trash",
 		"notice": "完全删除的页面是不可恢复的。"
 		"notice": "完全删除的页面是不可恢复的。"
@@ -411,7 +424,7 @@
 		"label": {
 		"label": {
 			"Duplicate page": "Duplicate page",
 			"Duplicate page": "Duplicate page",
       "New page name": "New page name",
       "New page name": "New page name",
-      "Fail to get subordinated pages": "Fail to get subordinated pages",
+      "Failed to get subordinated pages": "Failed to get subordinated pages",
 			"Current page name": "Current page name",
 			"Current page name": "Current page name",
       "Recursively": "Recursively",
       "Recursively": "Recursively",
       "Duplicate without exist path": "Duplicate without exist path",
       "Duplicate without exist path": "Duplicate without exist path",
@@ -420,7 +433,8 @@
     "help": {
     "help": {
       "recursive": "Duplicate children of under this path recursively"
       "recursive": "Duplicate children of under this path recursively"
     }
     }
-	},
+  },
+  "duplicated_pages": "{{fromPath}} 已重复",
 	"modal_putback": {
 	"modal_putback": {
 		"label": {
 		"label": {
 			"Put Back Page": "Put back page",
 			"Put Back Page": "Put back page",
@@ -486,7 +500,10 @@
     "page_not_found_in_preview": "\"{{path}}\" is not a GROWI page."
     "page_not_found_in_preview": "\"{{path}}\" is not a GROWI page."
   },
   },
 	"toaster": {
 	"toaster": {
+    "create_succeeded": "Succeeded to create {{target}}",
+    "create_failed": "Failed to create {{target}}",
 		"update_successed": "Succeeded to update {{target}}",
 		"update_successed": "Succeeded to update {{target}}",
+    "update_failed": "Failed to update {{target}}",
     "initialize_successed": "Succeeded to initialize {{target}}",
     "initialize_successed": "Succeeded to initialize {{target}}",
 		"give_user_admin": "Succeeded to give {{username}} admin",
 		"give_user_admin": "Succeeded to give {{username}} admin",
     "remove_user_admin": "Succeeded to remove {{username}} admin ",
     "remove_user_admin": "Succeeded to remove {{username}} admin ",
@@ -608,9 +625,16 @@
 		"page_listing_1_desc": "列出/搜索时显示受“仅限我”选项限制的页面",
 		"page_listing_1_desc": "列出/搜索时显示受“仅限我”选项限制的页面",
 		"page_listing_2": "页面列表/搜索<br>受用户组限制",
 		"page_listing_2": "页面列表/搜索<br>受用户组限制",
 		"page_listing_2_desc": "显示列出/搜索时受用户组限制的页面",
 		"page_listing_2_desc": "显示列出/搜索时受用户组限制的页面",
-    "page_access_and_delete_rights": "页面访问/删除权限",
-		"complete_deletion": "限制完全删除页面",
-		"complete_deletion_explain": "限制可以完全删除页面的用户。",
+    "page_access_rights": "页面访问",
+    "page_delete_rights": "删除权限",
+    "page_delete": "删除",
+    "page_delete_completely": "彻底删除",
+    "other_options": "其他选项",
+    "deletion_explain": "限制用户对选定的单一页面进行垃圾处理。",
+    "complete_deletion_explain": "限制可以完全删除所选单页的用户。",
+    "recursive_deletion_explain": "限制用户可以捣毁包括子孙在内的页面。",
+    "recursive_complete_deletion_explain": "限制可以完全删除页面的用户,包括子孙。",
+    "inherit": "继承(使用与单页相同的设置)。",
 		"admin_only": "仅管理员",
 		"admin_only": "仅管理员",
 		"admin_and_author": "管理员|作者",
 		"admin_and_author": "管理员|作者",
 		"anyone": "任何人",
 		"anyone": "任何人",
@@ -618,6 +642,7 @@
     "max_age": "有效期间  (msec)",
     "max_age": "有效期间  (msec)",
     "max_age_desc": "指定使用户会话过期的数量(以毫秒为单位)。<br>默认值: 2592000000 (30天)",
     "max_age_desc": "指定使用户会话过期的数量(以毫秒为单位)。<br>默认值: 2592000000 (30天)",
     "max_age_caution": "修改该值后需要重启服务器。",
     "max_age_caution": "修改该值后需要重启服务器。",
+    "page_delete_rights_caution": "\"包括后代的操作\" 的设置被迫强于 \"只对选定的页面进行操作\" 的设置。",
 		"Authentication mechanism settings": "身份验证机制设置",
 		"Authentication mechanism settings": "身份验证机制设置",
 		"setup_is_not_yet_complete": "安装尚未完成",
 		"setup_is_not_yet_complete": "安装尚未完成",
 		"alert_siteUrl_is_not_set": "主页URL未设置,通过 {{link}} 设置",
 		"alert_siteUrl_is_not_set": "主页URL未设置,通过 {{link}} 设置",
@@ -875,14 +900,41 @@
 		"use_os_settings": "使用操作系统设置"
 		"use_os_settings": "使用操作系统设置"
 	},
 	},
 	"search_result": {
 	"search_result": {
-		"result_meta": "在{{total}中找到了{{keyword}。",
+		"result_meta": "搜索结果:",
 		"deletion_mode_btn_lavel": "选择并删除页面",
 		"deletion_mode_btn_lavel": "选择并删除页面",
 		"cancel": "取消",
 		"cancel": "取消",
 		"delete": "删除",
 		"delete": "删除",
 		"check_all": "全部检查",
 		"check_all": "全部检查",
 		"deletion_modal_header": "删除页",
 		"deletion_modal_header": "删除页",
-		"delete_completely": "完全删除"
+		"delete_completely": "完全删除",
+    "include_certain_path": "包含 {{pathToInclude}} 路径 ",
+    "delete_all_selected_page": "删除所有",
+    "currently_not_implemented": "这是当前未实现的功能",
+    "search_again" : "再次搜索",
+    "number_of_list_to_display" : "显示器的数量",
+    "page_number_unit" : "例",
+    "sort_axis": {
+      "relationScore": "按相关性排序",
+      "createdAt": "按创建日期排序",
+      "updatedAt": "按更新日期排序"
+    }
 	},
 	},
+  "private_legacy_pages": {
+    "bulk_operation": "批量操作",
+    "convert_all_selected_pages": "全部转换为新的v5兼容格式",
+    "alert_title": "你正在查看旧的v4兼容的私人网页。",
+    "alert_desc1": "在这一页,你可以用复选框选择页面,并通过屏幕上方的批量操作按钮批量转换为新的v5兼容格式。",
+    "nopages_title": "恭喜你。准备使用GROWI v5!",
+    "nopages_desc1": "现在你能管理的所有页面似乎都是v5兼容的格式。",
+    "detail_info": "请参见 <a href='https://docs.growi.org/en/admin-guide/upgrading/50x.html' target='_blank' class='alert-link'>升级GROWI到v5.0.x <i class='icon-share-alt'></i></a>.的详细内容。",
+    "modal": {
+      "title": "转换为新的v5兼容格式",
+      "converting_pages": "转换页面",
+      "convert_recursively_label": "递归地转换子页面。",
+      "convert_recursively_desc": "递归地转换该路径下的页面。",
+      "button_label": "转换"
+    }
+  },
 	"to_cloud_settings": "進入 GROWI.cloud 的管理界面",
 	"to_cloud_settings": "進入 GROWI.cloud 的管理界面",
 	"login": {
 	"login": {
 		"Sign in error": "登录错误",
 		"Sign in error": "登录错误",
@@ -948,5 +1000,23 @@
     "success_to_send_email": "我发了一封电子邮件",
     "success_to_send_email": "我发了一封电子邮件",
     "incorrect_token_or_expired_url":"令牌不正确或 URL 已过期。 请通过以下链接重新发送密码重置请求",
     "incorrect_token_or_expired_url":"令牌不正确或 URL 已过期。 请通过以下链接重新发送密码重置请求",
     "password_and_confirm_password_does_not_match": "密码和确认密码不匹配"
     "password_and_confirm_password_does_not_match": "密码和确认密码不匹配"
+  },
+  "maintenance_mode":{
+    "maintenance_mode": "维护模式",
+    "growi_is_under_maintenance": "GROWI正在进行维护。请等待,直到它结束。",
+    "admin_page": "管理员页",
+    "login": "登录",
+    "logout": "登出"
+  },
+  "pagetree": {
+    "private_legacy_pages": "私人遗留页面",
+    "cannot_rename_a_title_that_contains_slash": "不能重命名包含 ’/' 的标题",
+    "you_cannot_move_this_page_now": "你现在不能移动这个页面",
+    "something_went_wrong_with_moving_page": "移动页面时出了问题"
+  },
+  "duplicated_page_alert" : {
+    "same_page_name_exists": "页面名称「{{pageName}}」是重复的",
+    "same_page_name_exists_at_path" : "在”{{path}}” 中,有不止一个名为”{{pageName}}”的页面",
+    "select_page_to_see" : "请在下面选择你想去的页面。"
   }
   }
 }
 }

+ 3 - 0
packages/app/resource/search/mappings-es6.json

@@ -88,6 +88,9 @@
         "bookmark_count": {
         "bookmark_count": {
           "type": "integer"
           "type": "integer"
         },
         },
+        "seenUsers_count":{
+          "type": "integer"
+        },
         "like_count": {
         "like_count": {
           "type": "integer"
           "type": "integer"
         },
         },

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

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

+ 36 - 15
packages/app/src/client/admin.jsx

@@ -3,7 +3,10 @@ import ReactDOM from 'react-dom';
 import { Provider } from 'unstated';
 import { Provider } from 'unstated';
 import { I18nextProvider } from 'react-i18next';
 import { I18nextProvider } from 'react-i18next';
 
 
+import { SWRConfig } from 'swr';
+
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
+import { swrGlobalConfiguration } from '~/utils/swr-utils';
 
 
 import ErrorBoundary from '../components/ErrorBoudary';
 import ErrorBoundary from '../components/ErrorBoudary';
 
 
@@ -46,6 +49,8 @@ import AdminTwitterSecurityContainer from '~/client/services/AdminTwitterSecurit
 import AdminNotificationContainer from '~/client/services/AdminNotificationContainer';
 import AdminNotificationContainer from '~/client/services/AdminNotificationContainer';
 import AdminSlackIntegrationLegacyContainer from '~/client/services/AdminSlackIntegrationLegacyContainer';
 import AdminSlackIntegrationLegacyContainer from '~/client/services/AdminSlackIntegrationLegacyContainer';
 
 
+import ContextExtractor from '~/client/services/ContextExtractor';
+
 import { appContainer, componentMappings } from './base';
 import { appContainer, componentMappings } from './base';
 
 
 const logger = loggerFactory('growi:admin');
 const logger = loggerFactory('growi:admin');
@@ -109,22 +114,38 @@ Object.assign(componentMappings, {
   'admin-navigation': <AdminNavigation />,
   'admin-navigation': <AdminNavigation />,
 });
 });
 
 
+const renderMainComponents = () => {
+  Object.keys(componentMappings).forEach((key) => {
+    const elem = document.getElementById(key);
+    if (elem) {
+      ReactDOM.render(
+        <I18nextProvider i18n={i18n}>
+          <ErrorBoundary>
+            <Provider inject={injectableContainers}>
+              {componentMappings[key]}
+            </Provider>
+          </ErrorBoundary>
+        </I18nextProvider>,
+        elem,
+      );
+    }
+  });
+};
 
 
-Object.keys(componentMappings).forEach((key) => {
-  const elem = document.getElementById(key);
-  if (elem) {
-    ReactDOM.render(
-      <I18nextProvider i18n={i18n}>
-        <ErrorBoundary>
-          <Provider inject={injectableContainers}>
-            {componentMappings[key]}
-          </Provider>
-        </ErrorBoundary>
-      </I18nextProvider>,
-      elem,
-    );
-  }
-});
+// extract context before rendering main components
+const elem = document.getElementById('growi-context-extractor');
+if (elem != null) {
+  ReactDOM.render(
+    <SWRConfig value={swrGlobalConfiguration}>
+      <ContextExtractor></ContextExtractor>
+    </SWRConfig>,
+    elem,
+    renderMainComponents,
+  );
+}
+else {
+  renderMainComponents();
+}
 
 
 const adminSecuritySettingElem = document.getElementById('admin-security-setting');
 const adminSecuritySettingElem = document.getElementById('admin-security-setting');
 if (adminSecuritySettingElem != null) {
 if (adminSecuritySettingElem != null) {

+ 24 - 24
packages/app/src/client/app.jsx

@@ -2,6 +2,8 @@ import React from 'react';
 import ReactDOM from 'react-dom';
 import ReactDOM from 'react-dom';
 import { Provider } from 'unstated';
 import { Provider } from 'unstated';
 import { I18nextProvider } from 'react-i18next';
 import { I18nextProvider } from 'react-i18next';
+import { DndProvider } from 'react-dnd';
+import { HTML5Backend } from 'react-dnd-html5-backend';
 
 
 import { SWRConfig } from 'swr';
 import { SWRConfig } from 'swr';
 
 
@@ -11,7 +13,7 @@ import { swrGlobalConfiguration } from '~/utils/swr-utils';
 import InAppNotificationPage from '../components/InAppNotification/InAppNotificationPage';
 import InAppNotificationPage from '../components/InAppNotification/InAppNotificationPage';
 import ErrorBoundary from '../components/ErrorBoudary';
 import ErrorBoundary from '../components/ErrorBoudary';
 import Sidebar from '../components/Sidebar';
 import Sidebar from '../components/Sidebar';
-import SearchPage from '../components/SearchPage';
+import { SearchPage } from '../components/SearchPage';
 import TagsList from '../components/TagsList';
 import TagsList from '../components/TagsList';
 import DisplaySwitcher from '../components/Page/DisplaySwitcher';
 import DisplaySwitcher from '../components/Page/DisplaySwitcher';
 import { defaultEditorOptions, defaultPreviewOptions } from '../components/PageEditor/OptionsSelector';
 import { defaultEditorOptions, defaultPreviewOptions } from '../components/PageEditor/OptionsSelector';
@@ -20,11 +22,8 @@ import PageComments from '../components/PageComments';
 import PageContentFooter from '../components/PageContentFooter';
 import PageContentFooter from '../components/PageContentFooter';
 import PageTimeline from '../components/PageTimeline';
 import PageTimeline from '../components/PageTimeline';
 import CommentEditorLazyRenderer from '../components/PageComment/CommentEditorLazyRenderer';
 import CommentEditorLazyRenderer from '../components/PageComment/CommentEditorLazyRenderer';
-import PageManagement from '../components/Page/PageManagement';
 import ShareLinkAlert from '../components/Page/ShareLinkAlert';
 import ShareLinkAlert from '../components/Page/ShareLinkAlert';
-import DuplicatedAlert from '../components/Page/DuplicatedAlert';
 import RedirectedAlert from '../components/Page/RedirectedAlert';
 import RedirectedAlert from '../components/Page/RedirectedAlert';
-import RenamedAlert from '../components/Page/RenamedAlert';
 import TrashPageList from '../components/TrashPageList';
 import TrashPageList from '../components/TrashPageList';
 import TrashPageAlert from '../components/Page/TrashPageAlert';
 import TrashPageAlert from '../components/Page/TrashPageAlert';
 import NotFoundPage from '../components/NotFoundPage';
 import NotFoundPage from '../components/NotFoundPage';
@@ -34,12 +33,12 @@ import PageStatusAlert from '../components/PageStatusAlert';
 import RecentCreated from '../components/RecentCreated/RecentCreated';
 import RecentCreated from '../components/RecentCreated/RecentCreated';
 import RecentlyCreatedIcon from '../components/Icons/RecentlyCreatedIcon';
 import RecentlyCreatedIcon from '../components/Icons/RecentlyCreatedIcon';
 import MyDraftList from '../components/MyDraftList/MyDraftList';
 import MyDraftList from '../components/MyDraftList/MyDraftList';
-import BookmarkIcon from '../components/Icons/BookmarkIcon';
 import BookmarkList from '../components/PageList/BookmarkList';
 import BookmarkList from '../components/PageList/BookmarkList';
 import Fab from '../components/Fab';
 import Fab from '../components/Fab';
 import PersonalSettings from '../components/Me/PersonalSettings';
 import PersonalSettings from '../components/Me/PersonalSettings';
-import GrowiSubNavigation from '../components/Navbar/GrowiSubNavigation';
+import GrowiContextualSubNavigation from '../components/Navbar/GrowiContextualSubNavigation';
 import GrowiSubNavigationSwitcher from '../components/Navbar/GrowiSubNavigationSwitcher';
 import GrowiSubNavigationSwitcher from '../components/Navbar/GrowiSubNavigationSwitcher';
+import IdenticalPathPage from '~/components/IdenticalPathPage';
 
 
 import ContextExtractor from '~/client/services/ContextExtractor';
 import ContextExtractor from '~/client/services/ContextExtractor';
 import PageContainer from '~/client/services/PageContainer';
 import PageContainer from '~/client/services/PageContainer';
@@ -49,9 +48,10 @@ import CommentContainer from '~/client/services/CommentContainer';
 import EditorContainer from '~/client/services/EditorContainer';
 import EditorContainer from '~/client/services/EditorContainer';
 import TagContainer from '~/client/services/TagContainer';
 import TagContainer from '~/client/services/TagContainer';
 import PersonalContainer from '~/client/services/PersonalContainer';
 import PersonalContainer from '~/client/services/PersonalContainer';
-import PageAccessoriesContainer from '~/client/services/PageAccessoriesContainer';
 
 
 import { appContainer, componentMappings } from './base';
 import { appContainer, componentMappings } from './base';
+import { toastError } from './util/apiNotification';
+import { PrivateLegacyPages } from '~/components/PrivateLegacyPages';
 
 
 const logger = loggerFactory('growi:cli:app');
 const logger = loggerFactory('growi:cli:app');
 
 
@@ -68,10 +68,9 @@ const commentContainer = new CommentContainer(appContainer);
 const editorContainer = new EditorContainer(appContainer, defaultEditorOptions, defaultPreviewOptions);
 const editorContainer = new EditorContainer(appContainer, defaultEditorOptions, defaultPreviewOptions);
 const tagContainer = new TagContainer(appContainer);
 const tagContainer = new TagContainer(appContainer);
 const personalContainer = new PersonalContainer(appContainer);
 const personalContainer = new PersonalContainer(appContainer);
-const pageAccessoriesContainer = new PageAccessoriesContainer(appContainer);
 const injectableContainers = [
 const injectableContainers = [
   appContainer, socketIoContainer, pageContainer, pageHistoryContainer, revisionComparerContainer,
   appContainer, socketIoContainer, pageContainer, pageHistoryContainer, revisionComparerContainer,
-  commentContainer, editorContainer, tagContainer, personalContainer, pageAccessoriesContainer,
+  commentContainer, editorContainer, tagContainer, personalContainer,
 ];
 ];
 
 
 logger.info('unstated containers have been initialized');
 logger.info('unstated containers have been initialized');
@@ -84,8 +83,11 @@ logger.info('unstated containers have been initialized');
 Object.assign(componentMappings, {
 Object.assign(componentMappings, {
   'grw-sidebar-wrapper': <Sidebar />,
   'grw-sidebar-wrapper': <Sidebar />,
 
 
-  'search-page': <SearchPage crowi={appContainer} />,
+  'search-page': <SearchPage appContainer={appContainer} />,
+  'private-regacy-pages': <PrivateLegacyPages appContainer={appContainer} />,
+
   'all-in-app-notifications': <InAppNotificationPage />,
   'all-in-app-notifications': <InAppNotificationPage />,
+  'identical-path-page': <IdenticalPathPage />,
 
 
   // 'revision-history': <PageHistory pageId={pageId} />,
   // 'revision-history': <PageHistory pageId={pageId} />,
   'tags-page': <TagsList crowi={appContainer} />,
   'tags-page': <TagsList crowi={appContainer} />,
@@ -94,15 +96,11 @@ Object.assign(componentMappings, {
 
 
   'trash-page-alert': <TrashPageAlert />,
   'trash-page-alert': <TrashPageAlert />,
 
 
-  'trash-page-list': <TrashPageList />,
+  'trash-page-list-container': <TrashPageList />,
 
 
   'not-found-page': <NotFoundPage />,
   'not-found-page': <NotFoundPage />,
-  'not-found-alert': <NotFoundAlert
-    isGuestUserMode={appContainer.isGuestUser}
-    isHidden={pageContainer.state.pageId != null ? (pageContainer.state.isNotCreatable ?? pageContainer.state.isTrashPage) : false} // !!DO NOT MOVE THIS!! https://github.com/weseek/growi/pull/4899
-  />,
 
 
-  'forbidden-page': <ForbiddenPage />,
+  'forbidden-page': <ForbiddenPage isLinkSharingDisabled={appContainer.config.disableLinkSharing} />,
 
 
   'page-timeline': <PageTimeline />,
   'page-timeline': <PageTimeline />,
 
 
@@ -113,9 +111,10 @@ Object.assign(componentMappings, {
   'grw-fab-container': <Fab />,
   'grw-fab-container': <Fab />,
 
 
   'share-link-alert': <ShareLinkAlert />,
   'share-link-alert': <ShareLinkAlert />,
-  'duplicated-alert': <DuplicatedAlert />,
   'redirected-alert': <RedirectedAlert />,
   'redirected-alert': <RedirectedAlert />,
-  'renamed-alert': <RenamedAlert />,
+  'not-found-alert': <NotFoundAlert
+    isGuestUserMode={appContainer.isGuestUser}
+  />,
 });
 });
 
 
 // additional definitions if data exists
 // additional definitions if data exists
@@ -123,16 +122,15 @@ if (pageContainer.state.pageId != null) {
   Object.assign(componentMappings, {
   Object.assign(componentMappings, {
     'page-comments-list': <PageComments />,
     'page-comments-list': <PageComments />,
     'page-comment-write': <CommentEditorLazyRenderer />,
     'page-comment-write': <CommentEditorLazyRenderer />,
-    'page-management': <PageManagement />,
     'page-content-footer': <PageContentFooter />,
     'page-content-footer': <PageContentFooter />,
 
 
     'recent-created-icon': <RecentlyCreatedIcon />,
     'recent-created-icon': <RecentlyCreatedIcon />,
-    'user-bookmark-icon': <BookmarkIcon />,
   });
   });
 
 
   // show the Page accessory modal when query of "compare" is requested
   // show the Page accessory modal when query of "compare" is requested
   if (revisionComparerContainer.getRevisionIDsToCompareAsParam().length > 0) {
   if (revisionComparerContainer.getRevisionIDsToCompareAsParam().length > 0) {
-    pageAccessoriesContainer.openPageAccessoriesModal('pageHistory');
+    toastError('Sorry, opening PageAccessoriesModal is not implemented yet in v5.');
+  //   pageAccessoriesContainer.openPageAccessoriesModal('pageHistory');
   }
   }
 }
 }
 if (pageContainer.state.creator != null) {
 if (pageContainer.state.creator != null) {
@@ -145,8 +143,8 @@ if (pageContainer.state.path != null) {
   Object.assign(componentMappings, {
   Object.assign(componentMappings, {
     // eslint-disable-next-line quote-props
     // eslint-disable-next-line quote-props
     'page': <Page />,
     'page': <Page />,
-    'grw-subnav-container': <GrowiSubNavigation />,
-    'grw-subnav-switcher-container': <GrowiSubNavigationSwitcher />,
+    'grw-subnav-container': <GrowiContextualSubNavigation isLinkSharingDisabled={appContainer.config.disableLinkSharing} />,
+    'grw-subnav-switcher-container': <GrowiSubNavigationSwitcher isLinkSharingDisabled={appContainer.config.disableLinkSharing} />,
     'display-switcher': <DisplaySwitcher />,
     'display-switcher': <DisplaySwitcher />,
   });
   });
 }
 }
@@ -160,7 +158,9 @@ const renderMainComponents = () => {
           <ErrorBoundary>
           <ErrorBoundary>
             <SWRConfig value={swrGlobalConfiguration}>
             <SWRConfig value={swrGlobalConfiguration}>
               <Provider inject={injectableContainers}>
               <Provider inject={injectableContainers}>
-                {componentMappings[key]}
+                <DndProvider backend={HTML5Backend}>
+                  {componentMappings[key]}
+                </DndProvider>
               </Provider>
               </Provider>
             </SWRConfig>
             </SWRConfig>
           </ErrorBoundary>
           </ErrorBoundary>

+ 14 - 0
packages/app/src/client/base.jsx

@@ -7,9 +7,16 @@ import GrowiNavbar from '../components/Navbar/GrowiNavbar';
 import GrowiNavbarBottom from '../components/Navbar/GrowiNavbarBottom';
 import GrowiNavbarBottom from '../components/Navbar/GrowiNavbarBottom';
 import HotkeysManager from '../components/Hotkeys/HotkeysManager';
 import HotkeysManager from '../components/Hotkeys/HotkeysManager';
 import PageCreateModal from '../components/PageCreateModal';
 import PageCreateModal from '../components/PageCreateModal';
+import PageDeleteModal from '../components/PageDeleteModal';
+import PageDuplicateModal from '../components/PageDuplicateModal';
+import PageRenameModal from '../components/PageRenameModal';
+import PagePresentationModal from '../components/PagePresentationModal';
+import PageAccessoriesModal from '../components/PageAccessoriesModal';
+import PutbackPageModal from '~/components/PutbackPageModal';
 
 
 import AppContainer from '~/client/services/AppContainer';
 import AppContainer from '~/client/services/AppContainer';
 import SocketIoContainer from '~/client/services/SocketIoContainer';
 import SocketIoContainer from '~/client/services/SocketIoContainer';
+import { DescendantsPageListModal } from '~/components/DescendantsPageListModal';
 
 
 const logger = loggerFactory('growi:cli:app');
 const logger = loggerFactory('growi:cli:app');
 
 
@@ -40,6 +47,13 @@ const componentMappings = {
   'grw-navbar-bottom-container': <GrowiNavbarBottom />,
   'grw-navbar-bottom-container': <GrowiNavbarBottom />,
 
 
   'page-create-modal': <PageCreateModal />,
   'page-create-modal': <PageCreateModal />,
+  'page-delete-modal': <PageDeleteModal />,
+  'page-duplicate-modal': <PageDuplicateModal />,
+  'page-rename-modal': <PageRenameModal />,
+  'page-presentation-modal': <PagePresentationModal />,
+  'page-accessories-modal': <PageAccessoriesModal />,
+  'descendants-page-list-modal': <DescendantsPageListModal />,
+  'page-put-back-modal': <PutbackPageModal />,
 
 
   'grw-hotkeys-manager': <HotkeysManager />,
   'grw-hotkeys-manager': <HotkeysManager />,
 
 

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

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

+ 13 - 0
packages/app/src/client/interfaces/selectable-all.ts

@@ -0,0 +1,13 @@
+export interface ISelectable {
+  select: () => void,
+  deselect: () => void,
+}
+
+export interface ISelectableAndIndeterminatable extends ISelectable {
+  setIndeterminate: () => void,
+}
+
+export interface ISelectableAll {
+  selectAll: () => void,
+  deselectAll: () => void,
+}

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

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

+ 29 - 0
packages/app/src/client/services/AdminAppContainer.js

@@ -22,6 +22,7 @@ export default class AdminAppContainer extends Container {
       isEmailPublishedForNewUser: true,
       isEmailPublishedForNewUser: true,
       fileUpload: '',
       fileUpload: '',
 
 
+      isV5Compatible: null,
       siteUrl: '',
       siteUrl: '',
       envSiteUrl: '',
       envSiteUrl: '',
       isSetSiteUrl: true,
       isSetSiteUrl: true,
@@ -57,6 +58,8 @@ export default class AdminAppContainer extends Container {
       s3ReferenceFileWithRelayMode: false,
       s3ReferenceFileWithRelayMode: false,
 
 
       isEnabledPlugins: true,
       isEnabledPlugins: true,
+
+      isMaintenanceMode: false,
     };
     };
 
 
   }
   }
@@ -81,6 +84,7 @@ export default class AdminAppContainer extends Container {
       globalLang: appSettingsParams.globalLang,
       globalLang: appSettingsParams.globalLang,
       isEmailPublishedForNewUser: appSettingsParams.isEmailPublishedForNewUser,
       isEmailPublishedForNewUser: appSettingsParams.isEmailPublishedForNewUser,
       fileUpload: appSettingsParams.fileUpload,
       fileUpload: appSettingsParams.fileUpload,
+      isV5Compatible: appSettingsParams.isV5Compatible,
       siteUrl: appSettingsParams.siteUrl,
       siteUrl: appSettingsParams.siteUrl,
       envSiteUrl: appSettingsParams.envSiteUrl,
       envSiteUrl: appSettingsParams.envSiteUrl,
       isSetSiteUrl: !!appSettingsParams.siteUrl,
       isSetSiteUrl: !!appSettingsParams.siteUrl,
@@ -114,6 +118,7 @@ export default class AdminAppContainer extends Container {
       envGcsBucket: appSettingsParams.envGcsBucket,
       envGcsBucket: appSettingsParams.envGcsBucket,
       envGcsUploadNamespace: appSettingsParams.envGcsUploadNamespace,
       envGcsUploadNamespace: appSettingsParams.envGcsUploadNamespace,
       isEnabledPlugins: appSettingsParams.isEnabledPlugins,
       isEnabledPlugins: appSettingsParams.isEnabledPlugins,
+      isMaintenanceMode: appSettingsParams.isMaintenanceMode,
     });
     });
 
 
     // if useOnlyEnvVarForFileUploadType is true, get fileUploadType from only env var and make the forms fixed.
     // if useOnlyEnvVarForFileUploadType is true, get fileUploadType from only env var and make the forms fixed.
@@ -160,6 +165,13 @@ export default class AdminAppContainer extends Container {
     this.setState({ fileUpload });
     this.setState({ fileUpload });
   }
   }
 
 
+  /**
+   * Change site url
+   */
+  changeIsV5Compatible(isV5Compatible) {
+    this.setState({ isV5Compatible });
+  }
+
   /**
   /**
    * Change site url
    * Change site url
    */
    */
@@ -440,5 +452,22 @@ export default class AdminAppContainer extends Container {
     return pluginSettingParams;
     return pluginSettingParams;
   }
   }
 
 
+  /**
+   * Start v5 page migration
+   * @memberOf AdminAppContainer
+   */
+  async v5PageMigrationHandler() {
+    const response = await this.appContainer.apiv3.post('/app-settings/v5-schema-migration');
+    const { isV5Compatible } = response.data;
+    return { isV5Compatible };
+  }
+
+  async startMaintenanceMode() {
+    await this.appContainer.apiv3.post('/app-settings/maintenance-mode', { flag: true });
+  }
+
+  async endMaintenanceMode() {
+    await this.appContainer.apiv3.post('/app-settings/maintenance-mode', { flag: false });
+  }
 
 
 }
 }

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

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

+ 10 - 7
packages/app/src/client/services/AdminHomeContainer.js

@@ -25,13 +25,14 @@ export default class AdminHomeContainer extends Container {
     this.timer = null;
     this.timer = null;
 
 
     this.state = {
     this.state = {
-      retrieveError: null,
-      growiVersion: '',
-      nodeVersion: '',
-      npmVersion: '',
-      yarnVersion: '',
+      growiVersion: null,
+      nodeVersion: null,
+      npmVersion: null,
+      yarnVersion: null,
       copyState: this.copyStateValues.DEFAULT,
       copyState: this.copyStateValues.DEFAULT,
-      installedPlugins: [],
+      installedPlugins: null,
+      isV5Compatible: null,
+      isMaintenanceMode: null,
     };
     };
 
 
   }
   }
@@ -63,11 +64,13 @@ export default class AdminHomeContainer extends Container {
         yarnVersion: adminHomeParams.yarnVersion,
         yarnVersion: adminHomeParams.yarnVersion,
         installedPlugins: adminHomeParams.installedPlugins,
         installedPlugins: adminHomeParams.installedPlugins,
         envVars: adminHomeParams.envVars,
         envVars: adminHomeParams.envVars,
+        isV5Compatible: adminHomeParams.isV5Compatible,
+        isMaintenanceMode: adminHomeParams.isMaintenanceMode,
       }));
       }));
     }
     }
     catch (err) {
     catch (err) {
       logger.error(err);
       logger.error(err);
-      toastError(new Error('Failed to fetch data'));
+      throw new Error('Failed to retrive AdminHome data');
     }
     }
   }
   }
 
 

+ 23 - 9
packages/app/src/client/services/AdminUserGroupDetailContainer.js

@@ -1,9 +1,17 @@
+/*
+ * TODO 85062: AdminUserGroupDetailContainer is under transplantation to UserGroupDetailPage.tsx
+ */
+
 import { Container } from 'unstated';
 import { Container } from 'unstated';
 
 
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 
 
 import { toastError } from '../util/apiNotification';
 import { toastError } from '../util/apiNotification';
 
 
+import {
+  apiv3Get, apiv3Delete, apiv3Put, apiv3Post,
+} from '~/client/util/apiv3-client';
+
 // eslint-disable-next-line no-unused-vars
 // eslint-disable-next-line no-unused-vars
 const logger = loggerFactory('growi:services:AdminUserGroupDetailContainer');
 const logger = loggerFactory('growi:services:AdminUserGroupDetailContainer');
 
 
@@ -11,7 +19,7 @@ const logger = loggerFactory('growi:services:AdminUserGroupDetailContainer');
  * Service container for admin user group detail page (UserGroupDetailPage.jsx)
  * Service container for admin user group detail page (UserGroupDetailPage.jsx)
  * @extends {Container} unstated Container
  * @extends {Container} unstated Container
  */
  */
-export default class AdminAdminUserGroupDetailContainer extends Container {
+export default class AdminUserGroupDetailContainer extends Container {
 
 
   constructor(appContainer) {
   constructor(appContainer) {
     super();
     super();
@@ -27,8 +35,14 @@ export default class AdminAdminUserGroupDetailContainer extends Container {
     this.state = {
     this.state = {
       // TODO: [SPA] get userGroup from props
       // TODO: [SPA] get userGroup from props
       userGroup: JSON.parse(rootElem.getAttribute('data-user-group')),
       userGroup: JSON.parse(rootElem.getAttribute('data-user-group')),
-      userGroupRelations: [],
-      relatedPages: [],
+      userGroupRelations: [], // For user list
+
+      // TODO 85062: /_api/v3/user-groups/children?include_grand_child=boolean
+      childUserGroups: [], // TODO 85062: fetch data on init (findChildGroupsByParentIds) For child group list
+      grandChildUserGroups: [], // TODO 85062: fetch data on init (findChildGroupsByParentIds) For child group list
+
+      childUserGroupRelations: [], // TODO 85062: fetch data on init (findRelationsByGroupIds) For child group list users
+      relatedPages: [], // For page list
       isUserGroupUserModalOpen: false,
       isUserGroupUserModalOpen: false,
       searchType: 'partial',
       searchType: 'partial',
       isAlsoMailSearched: false,
       isAlsoMailSearched: false,
@@ -61,8 +75,8 @@ export default class AdminAdminUserGroupDetailContainer extends Container {
         userGroupRelations,
         userGroupRelations,
         relatedPages,
         relatedPages,
       ] = await Promise.all([
       ] = await Promise.all([
-        this.appContainer.apiv3.get(`/user-groups/${this.state.userGroup._id}/user-group-relations`).then((res) => { return res.data.userGroupRelations }),
-        this.appContainer.apiv3.get(`/user-groups/${this.state.userGroup._id}/pages`).then((res) => { return res.data.pages }),
+        apiv3Get(`/user-groups/${this.state.userGroup._id}/user-group-relations`).then((res) => { return res.data.userGroupRelations }),
+        apiv3Get(`/user-groups/${this.state.userGroup._id}/pages`).then((res) => { return res.data.pages }),
       ]);
       ]);
 
 
       await this.setState({
       await this.setState({
@@ -105,7 +119,7 @@ export default class AdminAdminUserGroupDetailContainer extends Container {
    * @return {object} response object
    * @return {object} response object
    */
    */
   async updateUserGroup(param) {
   async updateUserGroup(param) {
-    const res = await this.appContainer.apiv3.put(`/user-groups/${this.state.userGroup._id}`, param);
+    const res = await apiv3Put(`/user-groups/${this.state.userGroup._id}`, param);
     const { userGroup } = res.data;
     const { userGroup } = res.data;
 
 
     await this.setState({ userGroup });
     await this.setState({ userGroup });
@@ -136,7 +150,7 @@ export default class AdminAdminUserGroupDetailContainer extends Container {
    * @param {string} username username of the user to be searched
    * @param {string} username username of the user to be searched
    */
    */
   async fetchApplicableUsers(searchWord) {
   async fetchApplicableUsers(searchWord) {
-    const res = await this.appContainer.apiv3.get(`/user-groups/${this.state.userGroup._id}/unrelated-users`, {
+    const res = await apiv3Get(`/user-groups/${this.state.userGroup._id}/unrelated-users`, {
       searchWord,
       searchWord,
       searchType: this.state.searchType,
       searchType: this.state.searchType,
       isAlsoMailSearched: this.state.isAlsoMailSearched,
       isAlsoMailSearched: this.state.isAlsoMailSearched,
@@ -156,7 +170,7 @@ export default class AdminAdminUserGroupDetailContainer extends Container {
    * @param {string} username username of the user to be added to the group
    * @param {string} username username of the user to be added to the group
    */
    */
   async addUserByUsername(username) {
   async addUserByUsername(username) {
-    const res = await this.appContainer.apiv3.post(`/user-groups/${this.state.userGroup._id}/users/${username}`);
+    const res = await apiv3Post(`/user-groups/${this.state.userGroup._id}/users/${username}`);
 
 
     // do not add users for ducaplicate
     // do not add users for ducaplicate
     if (res.data.userGroupRelation == null) { return }
     if (res.data.userGroupRelation == null) { return }
@@ -171,7 +185,7 @@ export default class AdminAdminUserGroupDetailContainer extends Container {
    * @param {string} username username of the user to be removed from the group
    * @param {string} username username of the user to be removed from the group
    */
    */
   async removeUserByUsername(username) {
   async removeUserByUsername(username) {
-    const res = await this.appContainer.apiv3.delete(`/user-groups/${this.state.userGroup._id}/users/${username}`);
+    const res = await apiv3Delete(`/user-groups/${this.state.userGroup._id}/users/${username}`);
 
 
     this.setState((prevState) => {
     this.setState((prevState) => {
       return {
       return {

+ 55 - 17
packages/app/src/client/services/ContextExtractor.tsx

@@ -2,17 +2,20 @@ import React, { FC, useEffect, useState } from 'react';
 import { pagePathUtils } from '@growi/core';
 import { pagePathUtils } from '@growi/core';
 
 
 import {
 import {
-  useCurrentCreatedAt, useDeleteUsername, useDeletedAt, useHasChildren, useHasDraftOnHackmd, useIsAbleToDeleteCompletely,
-  useIsDeletable, useIsDeleted, useIsNotCreatable, useIsPageExist, useIsTrashPage, useIsUserPage, useLastUpdateUsername,
-  usePageId, usePageIdOnHackmd, usePageUser, useCurrentPagePath, useRevisionCreatedAt, useRevisionId, useRevisionIdHackmdSynced,
-  useShareLinkId, useShareLinksNumber, useTemplateTagData, useCurrentUpdatedAt, useCreator, useRevisionAuthor, useCurrentUser,
-  useSlackChannels,
-} from '~/stores/context';
+  useSiteUrl,
+  useCurrentCreatedAt, useDeleteUsername, useDeletedAt, useHasChildren, useHasDraftOnHackmd,
+  useIsDeleted, useIsNotCreatable, useIsTrashPage, useIsUserPage, useLastUpdateUsername,
+  useCurrentPageId, usePageIdOnHackmd, usePageUser, useCurrentPagePath, useRevisionCreatedAt, useRevisionId, useRevisionIdHackmdSynced,
+  useShareLinkId, useShareLinksNumber, useTemplateTagData, useCurrentUpdatedAt, useCreator, useRevisionAuthor, useCurrentUser, useTargetAndAncestors,
+  useSlackChannels, useNotFoundTargetPathOrId, useIsSearchPage, useIsForbidden, useIsIdenticalPath,
+  useIsAclEnabled, useIsSearchServiceConfigured, useIsSearchServiceReachable, useIsEnabledAttachTitleHeader, useIsNotFoundPermalink,
+} from '../../stores/context';
 import {
 import {
-  useIsDeviceSmallerThanMd,
+  useIsDeviceSmallerThanMd, useIsDeviceSmallerThanLg,
   usePreferDrawerModeByUser, usePreferDrawerModeOnEditByUser, useSidebarCollapsed, useCurrentSidebarContents, useCurrentProductNavWidth,
   usePreferDrawerModeByUser, usePreferDrawerModeOnEditByUser, useSidebarCollapsed, useCurrentSidebarContents, useCurrentProductNavWidth,
   useSelectedGrant, useSelectedGrantGroupId, useSelectedGrantGroupName,
   useSelectedGrant, useSelectedGrantGroupId, useSelectedGrantGroupName,
 } from '~/stores/ui';
 } from '~/stores/ui';
+import { useSetupGlobalSocket } from '~/stores/websocket';
 import { IUserUISettings } from '~/interfaces/user-ui-settings';
 import { IUserUISettings } from '~/interfaces/user-ui-settings';
 
 
 const { isTrashPage: _isTrashPage } = pagePathUtils;
 const { isTrashPage: _isTrashPage } = pagePathUtils;
@@ -22,12 +25,20 @@ const jsonNull = 'null';
 const ContextExtractorOnce: FC = () => {
 const ContextExtractorOnce: FC = () => {
 
 
   const mainContent = document.querySelector('#content-main');
   const mainContent = document.querySelector('#content-main');
+  const notFoundContentForPt = document.getElementById('growi-pagetree-not-found-context');
+  const notFoundContent = document.getElementById('growi-not-found-context');
+  const forbiddenContent = document.getElementById('forbidden-page');
 
 
   /*
   /*
    * App Context from DOM
    * App Context from DOM
    */
    */
   const currentUser = JSON.parse(document.getElementById('growi-current-user')?.textContent || jsonNull);
   const currentUser = JSON.parse(document.getElementById('growi-current-user')?.textContent || jsonNull);
 
 
+  /*
+   * Settings from context-hydrate DOM
+   */
+  const configByContextHydrate = JSON.parse(document.getElementById('growi-context-hydrate')?.textContent || jsonNull);
+
   /*
   /*
    * UserUISettings from DOM
    * UserUISettings from DOM
    */
    */
@@ -49,13 +60,12 @@ const ContextExtractorOnce: FC = () => {
   const updatedAt: Date | null = (updatedAtAttribute != null) ? new Date(updatedAtAttribute) : null;
   const updatedAt: Date | null = (updatedAtAttribute != null) ? new Date(updatedAtAttribute) : null;
 
 
   const deletedAt = mainContent?.getAttribute('data-page-deleted-at') || null;
   const deletedAt = mainContent?.getAttribute('data-page-deleted-at') || null;
-  const isUserPage = JSON.parse(mainContent?.getAttribute('data-page-user') || jsonNull);
+  const isIdenticalPath = JSON.parse(mainContent?.getAttribute('data-identical-path') || jsonNull) ?? false;
+  const isUserPage = JSON.parse(mainContent?.getAttribute('data-page-user') || jsonNull) != null;
   const isTrashPage = _isTrashPage(path);
   const isTrashPage = _isTrashPage(path);
-  const isDeleted = JSON.parse(mainContent?.getAttribute('data-page-is-deleted') || jsonNull);
-  const isDeletable = JSON.parse(mainContent?.getAttribute('data-page-is-deletable') || jsonNull);
-  const isNotCreatable = JSON.parse(mainContent?.getAttribute('data-page-is-not-creatable') || jsonNull);
-  const isAbleToDeleteCompletely = JSON.parse(mainContent?.getAttribute('data-page-is-able-to-delete-completely') || jsonNull);
-  const isPageExist = mainContent?.getAttribute('data-page-id') != null;
+  const isDeleted = JSON.parse(mainContent?.getAttribute('data-page-is-deleted') || jsonNull) ?? false;
+  const isNotCreatable = JSON.parse(mainContent?.getAttribute('data-page-is-not-creatable') || jsonNull) ?? false;
+  const isForbidden = forbiddenContent != null;
   const pageUser = JSON.parse(mainContent?.getAttribute('data-page-user') || jsonNull);
   const pageUser = JSON.parse(mainContent?.getAttribute('data-page-user') || jsonNull);
   const hasChildren = JSON.parse(mainContent?.getAttribute('data-page-has-children') || jsonNull);
   const hasChildren = JSON.parse(mainContent?.getAttribute('data-page-has-children') || jsonNull);
   const templateTagData = mainContent?.getAttribute('data-template-tags') || null;
   const templateTagData = mainContent?.getAttribute('data-template-tags') || null;
@@ -68,10 +78,16 @@ const ContextExtractorOnce: FC = () => {
   const hasDraftOnHackmd = !!mainContent?.getAttribute('data-page-has-draft-on-hackmd');
   const hasDraftOnHackmd = !!mainContent?.getAttribute('data-page-has-draft-on-hackmd');
   const creator = JSON.parse(mainContent?.getAttribute('data-page-creator') || jsonNull);
   const creator = JSON.parse(mainContent?.getAttribute('data-page-creator') || jsonNull);
   const revisionAuthor = JSON.parse(mainContent?.getAttribute('data-page-revision-author') || jsonNull);
   const revisionAuthor = JSON.parse(mainContent?.getAttribute('data-page-revision-author') || jsonNull);
+  const targetAndAncestors = JSON.parse(document.getElementById('growi-pagetree-target-and-ancestors')?.textContent || jsonNull);
+  const notFoundTargetPathOrId = JSON.parse(notFoundContentForPt?.getAttribute('data-not-found-target-path-or-id') || jsonNull);
+  const isNotFoundPermalink = JSON.parse(notFoundContent?.getAttribute('data-is-not-found-permalink') || jsonNull);
   const slackChannels = mainContent?.getAttribute('data-slack-channels') || '';
   const slackChannels = mainContent?.getAttribute('data-slack-channels') || '';
+  const isSearchPage = document.getElementById('search-page') != null;
+
   const grant = +(mainContent?.getAttribute('data-page-grant') || 1);
   const grant = +(mainContent?.getAttribute('data-page-grant') || 1);
   const grantGroupId = mainContent?.getAttribute('data-page-grant-group') || null;
   const grantGroupId = mainContent?.getAttribute('data-page-grant-group') || null;
   const grantGroupName = mainContent?.getAttribute('data-page-grant-group-name') || null;
   const grantGroupName = mainContent?.getAttribute('data-page-grant-group-name') || null;
+
   /*
   /*
    * use static swr
    * use static swr
    */
    */
@@ -85,21 +101,28 @@ const ContextExtractorOnce: FC = () => {
   useCurrentSidebarContents(userUISettings?.currentSidebarContents);
   useCurrentSidebarContents(userUISettings?.currentSidebarContents);
   useCurrentProductNavWidth(userUISettings?.currentProductNavWidth);
   useCurrentProductNavWidth(userUISettings?.currentProductNavWidth);
 
 
+  // hydrated config
+  useSiteUrl(configByContextHydrate.crowi.url);
+  useIsAclEnabled(configByContextHydrate.isAclEnabled);
+  useIsSearchServiceConfigured(configByContextHydrate.isSearchServiceConfigured);
+  useIsSearchServiceReachable(configByContextHydrate.isSearchServiceReachable);
+  useIsEnabledAttachTitleHeader(configByContextHydrate.isEnabledAttachTitleHeader);
+
+
   // Page
   // Page
   useCurrentCreatedAt(createdAt);
   useCurrentCreatedAt(createdAt);
   useDeleteUsername(deleteUsername);
   useDeleteUsername(deleteUsername);
   useDeletedAt(deletedAt);
   useDeletedAt(deletedAt);
   useHasChildren(hasChildren);
   useHasChildren(hasChildren);
   useHasDraftOnHackmd(hasDraftOnHackmd);
   useHasDraftOnHackmd(hasDraftOnHackmd);
-  useIsAbleToDeleteCompletely(isAbleToDeleteCompletely);
-  useIsDeletable(isDeletable);
+  useIsIdenticalPath(isIdenticalPath);
   useIsDeleted(isDeleted);
   useIsDeleted(isDeleted);
   useIsNotCreatable(isNotCreatable);
   useIsNotCreatable(isNotCreatable);
-  useIsPageExist(isPageExist);
+  useIsForbidden(isForbidden);
   useIsTrashPage(isTrashPage);
   useIsTrashPage(isTrashPage);
   useIsUserPage(isUserPage);
   useIsUserPage(isUserPage);
   useLastUpdateUsername(lastUpdateUsername);
   useLastUpdateUsername(lastUpdateUsername);
-  usePageId(pageId);
+  useCurrentPageId(pageId);
   usePageIdOnHackmd(pageIdOnHackmd);
   usePageIdOnHackmd(pageIdOnHackmd);
   usePageUser(pageUser);
   usePageUser(pageUser);
   useCurrentPagePath(path);
   useCurrentPagePath(path);
@@ -112,6 +135,15 @@ const ContextExtractorOnce: FC = () => {
   useCurrentUpdatedAt(updatedAt);
   useCurrentUpdatedAt(updatedAt);
   useCreator(creator);
   useCreator(creator);
   useRevisionAuthor(revisionAuthor);
   useRevisionAuthor(revisionAuthor);
+  useTargetAndAncestors(targetAndAncestors);
+  useNotFoundTargetPathOrId(notFoundTargetPathOrId);
+  useIsNotFoundPermalink(isNotFoundPermalink);
+  useIsSearchPage(isSearchPage);
+
+  // Navigation
+  usePreferDrawerModeByUser();
+  usePreferDrawerModeOnEditByUser();
+  useIsDeviceSmallerThanMd();
 
 
   // Navigation
   // Navigation
   usePreferDrawerModeByUser();
   usePreferDrawerModeByUser();
@@ -124,6 +156,12 @@ const ContextExtractorOnce: FC = () => {
   useSelectedGrantGroupId(grantGroupId);
   useSelectedGrantGroupId(grantGroupId);
   useSelectedGrantGroupName(grantGroupName);
   useSelectedGrantGroupName(grantGroupName);
 
 
+  // SearchResult
+  useIsDeviceSmallerThanLg();
+
+  // Global Socket
+  useSetupGlobalSocket();
+
   return null;
   return null;
 };
 };
 
 

+ 0 - 54
packages/app/src/client/services/PageAccessoriesContainer.js

@@ -1,54 +0,0 @@
-import { Container } from 'unstated';
-
-/**
- * Service container related to options for Application
- * @extends {Container} unstated Container
- */
-
-export default class PageAccessoriesContainer extends Container {
-
-  constructor(appContainer) {
-    super();
-
-    this.appContainer = appContainer;
-
-    this.state = {
-      isPageAccessoriesModalShown: false,
-      activeTab: '',
-      // Prevent unnecessary rendering
-      activeComponents: new Set(['']),
-    };
-    this.openPageAccessoriesModal = this.openPageAccessoriesModal.bind(this);
-    this.closePageAccessoriesModal = this.closePageAccessoriesModal.bind(this);
-    this.switchActiveTab = this.switchActiveTab.bind(this);
-  }
-
-  /**
-   * Workaround for the mangling in production build to break constructor.name
-   */
-  static getClassName() {
-    return 'PageAccessoriesContainer';
-  }
-
-
-  openPageAccessoriesModal(activeTab) {
-    this.setState({
-      isPageAccessoriesModalShown: true,
-    });
-    this.switchActiveTab(activeTab);
-  }
-
-  closePageAccessoriesModal() {
-    this.setState({
-      isPageAccessoriesModalShown: false,
-      activeTab: '',
-    });
-  }
-
-  switchActiveTab(activeTab) {
-    this.setState({
-      activeTab, activeComponents: this.state.activeComponents.add(activeTab),
-    });
-  }
-
-}

+ 5 - 110
packages/app/src/client/services/PageContainer.js

@@ -62,9 +62,7 @@ export default class PageContainer extends Container {
       isUserPage: JSON.parse(mainContent.getAttribute('data-page-user')) != null,
       isUserPage: JSON.parse(mainContent.getAttribute('data-page-user')) != null,
       isTrashPage: isTrashPage(path),
       isTrashPage: isTrashPage(path),
       isDeleted: JSON.parse(mainContent.getAttribute('data-page-is-deleted')),
       isDeleted: JSON.parse(mainContent.getAttribute('data-page-is-deleted')),
-      isDeletable: JSON.parse(mainContent.getAttribute('data-page-is-deletable')),
       isNotCreatable: JSON.parse(mainContent.getAttribute('data-page-is-not-creatable')),
       isNotCreatable: JSON.parse(mainContent.getAttribute('data-page-is-not-creatable')),
-      isAbleToDeleteCompletely: JSON.parse(mainContent.getAttribute('data-page-is-able-to-delete-completely')),
       isPageExist: mainContent.getAttribute('data-page-id') != null,
       isPageExist: mainContent.getAttribute('data-page-id') != null,
 
 
       pageUser: JSON.parse(mainContent.getAttribute('data-page-user')),
       pageUser: JSON.parse(mainContent.getAttribute('data-page-user')),
@@ -140,71 +138,6 @@ export default class PageContainer extends Container {
     return 'PageContainer';
     return 'PageContainer';
   }
   }
 
 
-
-  /**
-   * whether to display reaction buttons
-   * ex.) like, bookmark
-   */
-  get isAbleToShowPageReactionButtons() {
-    const { isTrashPage, isPageExist } = this.state;
-    const { isSharedUser } = this.appContainer;
-
-    return (!isTrashPage && isPageExist && !isSharedUser);
-  }
-
-  /**
-   * whether to display tag labels
-   */
-  get isAbleToShowTagLabel() {
-    const { isUserPage } = this.state;
-    const { isSharedUser } = this.appContainer;
-
-    return (!isUserPage && !isSharedUser);
-  }
-
-  /**
-   * whether to display page management
-   * ex.) duplicate, rename
-   */
-  get isAbleToShowPageManagement() {
-    const { isPageExist, isTrashPage } = this.state;
-    const { isSharedUser } = this.appContainer;
-
-    return (isPageExist && !isTrashPage && !isSharedUser);
-  }
-
-  /**
-   * whether to display pageEditorModeManager
-   * ex.) view, edit, hackmd
-   */
-  get isAbleToShowPageEditorModeManager() {
-    const { isNotCreatable, isTrashPage } = this.state;
-    const { isSharedUser } = this.appContainer;
-
-    return (!isNotCreatable && !isTrashPage && !isSharedUser);
-  }
-
-  /**
-   * whether to display pageAuthors
-   * ex.) creator, lastUpdateUser
-   */
-  get isAbleToShowPageAuthors() {
-    const { isPageExist, isUserPage } = this.state;
-
-    return (isPageExist && !isUserPage);
-  }
-
-  /**
-   * whether to like button
-   * not displayed on user page
-   */
-  get isAbleToShowLikeButtons() {
-    const { isUserPage } = this.state;
-    const { isSharedUser } = this.appContainer;
-
-    return (!isUserPage && !isSharedUser);
-  }
-
   /**
   /**
    * whether to Empty Trash Page
    * whether to Empty Trash Page
    * not displayed when guest user and not on trash page
    * not displayed when guest user and not on trash page
@@ -440,49 +373,6 @@ export default class PageContainer extends Container {
     return res;
     return res;
   }
   }
 
 
-  deletePage(isRecursively, isCompletely) {
-    const socketIoContainer = this.appContainer.getContainer('SocketIoContainer');
-
-    // control flag
-    const completely = isCompletely ? true : null;
-    const recursively = isRecursively ? true : null;
-
-    return this.appContainer.apiPost('/pages.remove', {
-      recursively,
-      completely,
-      page_id: this.state.pageId,
-      revision_id: this.state.revisionId,
-    });
-
-  }
-
-  revertRemove(isRecursively) {
-    const socketIoContainer = this.appContainer.getContainer('SocketIoContainer');
-
-    // control flag
-    const recursively = isRecursively ? true : null;
-
-    return this.appContainer.apiPost('/pages.revertRemove', {
-      recursively,
-      page_id: this.state.pageId,
-    });
-  }
-
-  rename(newPagePath, isRecursively, isRenameRedirect, isRemainMetadata) {
-    const socketIoContainer = this.appContainer.getContainer('SocketIoContainer');
-    const { pageId, revisionId, path } = this.state;
-
-    return this.appContainer.apiv3Put('/pages/rename', {
-      revisionId,
-      pageId,
-      isRecursively,
-      isRenameRedirect,
-      isRemainMetadata,
-      newPagePath,
-      path,
-    });
-  }
-
   showSuccessToastr() {
   showSuccessToastr() {
     toastr.success(undefined, 'Saved successfully', {
     toastr.success(undefined, 'Saved successfully', {
       closeButton: true,
       closeButton: true,
@@ -569,6 +459,7 @@ export default class PageContainer extends Container {
 
 
     const { pageId, remoteRevisionId, path } = this.state;
     const { pageId, remoteRevisionId, path } = this.state;
     const editorContainer = this.appContainer.getContainer('EditorContainer');
     const editorContainer = this.appContainer.getContainer('EditorContainer');
+    const pageEditor = this.appContainer.getComponentInstance('PageEditor');
     const options = editorContainer.getCurrentOptionsToSave();
     const options = editorContainer.getCurrentOptionsToSave();
     const optionsToSave = Object.assign({}, options);
     const optionsToSave = Object.assign({}, options);
 
 
@@ -577,6 +468,10 @@ export default class PageContainer extends Container {
     editorContainer.clearDraft(path);
     editorContainer.clearDraft(path);
     this.updateStateAfterSave(res.page, res.tags, res.revision, editorMode);
     this.updateStateAfterSave(res.page, res.tags, res.revision, editorMode);
 
 
+    if (pageEditor != null) {
+      pageEditor.updateEditorValue(markdown);
+    }
+
     editorContainer.setState({ tags: res.tags });
     editorContainer.setState({ tags: res.tags });
 
 
     return res;
     return res;

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

@@ -0,0 +1,62 @@
+import urljoin from 'url-join';
+
+import { SubscriptionStatusType } from '~/interfaces/subscription';
+
+import { toastError } from '../util/apiNotification';
+import { apiv3Put } from '../util/apiv3-client';
+
+export const toggleSubscribe = async(pageId: string, currentStatus: SubscriptionStatusType | undefined): Promise<void> => {
+  try {
+    const newStatus = currentStatus === SubscriptionStatusType.SUBSCRIBE
+      ? SubscriptionStatusType.UNSUBSCRIBE
+      : SubscriptionStatusType.SUBSCRIBE;
+
+    await apiv3Put('/page/subscribe', { pageId, status: newStatus });
+  }
+  catch (err) {
+    toastError(err);
+  }
+};
+
+export const toggleLike = async(pageId: string, currentValue?: boolean): Promise<void> => {
+  try {
+    await apiv3Put('/page/likes', { pageId, bool: !currentValue });
+  }
+  catch (err) {
+    toastError(err);
+  }
+};
+
+export const toggleBookmark = async(pageId: string, currentValue?: boolean): Promise<void> => {
+  try {
+    await apiv3Put('/bookmarks', { pageId, bool: !currentValue });
+  }
+  catch (err) {
+    toastError(err);
+  }
+};
+
+export const bookmark = async(pageId: string): Promise<void> => {
+  try {
+    await apiv3Put('/bookmarks', { pageId, bool: true });
+  }
+  catch (err) {
+    toastError(err);
+  }
+};
+
+export const unbookmark = async(pageId: string): Promise<void> => {
+  try {
+    await apiv3Put('/bookmarks', { pageId, bool: false });
+  }
+  catch (err) {
+    toastError(err);
+  }
+};
+
+export const exportAsMarkdown = (pageId: string, revisionId: string, format: string): void => {
+  const url = new URL(urljoin(window.location.origin, '_api/v3/page/export', pageId));
+  url.searchParams.append('format', format);
+  url.searchParams.append('revisionId', revisionId);
+  window.location.href = url.href;
+};

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

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

+ 2 - 2
packages/app/src/client/util/smooth-scroll.ts

@@ -1,6 +1,6 @@
 const WIKI_HEADER_LINK = 120;
 const WIKI_HEADER_LINK = 120;
 
 
-export const smoothScrollIntoView = (element: HTMLElement, offsetTop = 0): void => {
+export const smoothScrollIntoView = (element: HTMLElement, offsetTop = 0, scrollElement: HTMLElement | Window = window): void => {
   const targetElement = element || window.document.body;
   const targetElement = element || window.document.body;
 
 
   // get the distance to the target element top
   // get the distance to the target element top
@@ -8,7 +8,7 @@ export const smoothScrollIntoView = (element: HTMLElement, offsetTop = 0): void
 
 
   const top = window.pageYOffset + rectTop - offsetTop;
   const top = window.pageYOffset + rectTop - offsetTop;
 
 
-  window.scrollTo({
+  scrollElement.scrollTo({
     top,
     top,
     behavior: 'smooth',
     behavior: 'smooth',
   });
   });

+ 99 - 68
packages/app/src/components/Admin/AdminHome/AdminHome.jsx

@@ -1,6 +1,6 @@
-import React, { Fragment } from 'react';
+import React, { useEffect, useCallback } from 'react';
 import PropTypes from 'prop-types';
 import PropTypes from 'prop-types';
-import { withTranslation } from 'react-i18next';
+import { useTranslation } from 'react-i18next';
 import { CopyToClipboard } from 'react-copy-to-clipboard';
 import { CopyToClipboard } from 'react-copy-to-clipboard';
 import { Tooltip } from 'reactstrap';
 import { Tooltip } from 'reactstrap';
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
@@ -10,99 +10,130 @@ import { toastError } from '~/client/util/apiNotification';
 import { withUnstatedContainers } from '../../UnstatedUtils';
 import { withUnstatedContainers } from '../../UnstatedUtils';
 import AppContainer from '~/client/services/AppContainer';
 import AppContainer from '~/client/services/AppContainer';
 import AdminHomeContainer from '~/client/services/AdminHomeContainer';
 import AdminHomeContainer from '~/client/services/AdminHomeContainer';
+import { useSWRxV5MigrationStatus } from '~/stores/page-listing';
 import SystemInfomationTable from './SystemInfomationTable';
 import SystemInfomationTable from './SystemInfomationTable';
 import InstalledPluginTable from './InstalledPluginTable';
 import InstalledPluginTable from './InstalledPluginTable';
 import EnvVarsTable from './EnvVarsTable';
 import EnvVarsTable from './EnvVarsTable';
 
 
 const logger = loggerFactory('growi:admin');
 const logger = loggerFactory('growi:admin');
 
 
-class AdminHome extends React.Component {
-
-  async componentDidMount() {
-    const { adminHomeContainer } = this.props;
+const AdminHome = (props) => {
+  const { adminHomeContainer } = props;
+  const { t } = useTranslation();
+  const { data: migrationStatus } = useSWRxV5MigrationStatus();
 
 
+  const fetchAdminHomeData = useCallback(async() => {
     try {
     try {
       await adminHomeContainer.retrieveAdminHomeData();
       await adminHomeContainer.retrieveAdminHomeData();
     }
     }
     catch (err) {
     catch (err) {
       toastError(err);
       toastError(err);
-      adminHomeContainer.setState({ retrieveError: err });
       logger.error(err);
       logger.error(err);
     }
     }
-  }
-
-  render() {
-    const { t, adminHomeContainer } = this.props;
-
-    return (
-      <Fragment>
-        <p>
-          {t('admin:admin_top.wiki_administrator')}
-          <br></br>
-          {t('admin:admin_top.assign_administrator')}
-        </p>
-
-        <div className="row mb-5">
-          <div className="col-lg-12">
-            <h2 className="admin-setting-header">{t('admin:admin_top.system_information')}</h2>
-            <SystemInfomationTable />
+  }, [adminHomeContainer]);
+
+  useEffect(() => {
+    fetchAdminHomeData();
+  }, [fetchAdminHomeData]);
+
+  return (
+    <div data-testid="admin-home">
+      {
+        // Alert message will be displayed in case that the GROWI is under maintenance
+        adminHomeContainer.state.isMaintenanceMode && (
+          <div className="alert alert-danger alert-link" role="alert">
+            <h3 className="alert-heading">
+              {t('admin:maintenance_mode.maintenance_mode')}
+            </h3>
+            <p>
+              {t('admin:maintenance_mode.description')}
+            </p>
+            <hr />
+            <a className="btn-link" href="/admin/app" rel="noopener noreferrer">
+              <i className="fa fa-link ml-1" aria-hidden="true"></i>
+              <strong>{t('admin:maintenance_mode.end_maintenance_mode')}</strong>
+            </a>
           </div>
           </div>
-        </div>
-
-        <div className="row mb-5">
-          <div className="col-lg-12">
-            <h2 className="admin-setting-header">{t('admin:admin_top.list_of_installed_plugins')}</h2>
-            <InstalledPluginTable />
+        )
+      }
+      {
+      // Alert message will be displayed in case that V5 migration has not been compleated
+        (migrationStatus != null && !migrationStatus.isV5Compatible)
+        && (
+          <div className={`alert ${migrationStatus.isV5Compatible == null ? 'alert-warning' : 'alert-info'}`}>
+            {t('admin:v5_page_migration.migration_desc')}
+            <a className="btn-link" href="/admin/app" rel="noopener noreferrer">
+              <i className="fa fa-link ml-1" aria-hidden="true"></i>
+              <strong>{t('admin:v5_page_migration.upgrade_to_v5')}</strong>
+            </a>
           </div>
           </div>
+        )
+      }
+      <p>
+        {t('admin:admin_top.wiki_administrator')}
+        <br></br>
+        {t('admin:admin_top.assign_administrator')}
+      </p>
+
+      <div className="row mb-5">
+        <div className="col-lg-12">
+          <h2 className="admin-setting-header">{t('admin:admin_top.system_information')}</h2>
+          <SystemInfomationTable />
         </div>
         </div>
+      </div>
 
 
-        <div className="row mb-5">
-          <div className="col-md-12">
-            <h2 className="admin-setting-header">{t('admin:admin_top.list_of_env_vars')}</h2>
-            <p>{t('admin:admin_top.env_var_priority')}</p>
-            {/* eslint-disable-next-line react/no-danger */}
-            <p dangerouslySetInnerHTML={{ __html: t('admin:admin_top.about_security') }} />
-            {adminHomeContainer.state.envVars && <EnvVarsTable envVars={adminHomeContainer.state.envVars} />}
-          </div>
+      <div className="row mb-5">
+        <div className="col-lg-12">
+          <h2 className="admin-setting-header">{t('admin:admin_top.list_of_installed_plugins')}</h2>
+          <InstalledPluginTable />
         </div>
         </div>
-
-        <div className="row mb-5">
-          <div className="col-md-12">
-            <h2 className="admin-setting-header">{t('admin:admin_top.bug_report')}</h2>
-            <div className="d-flex align-items-center">
-              <CopyToClipboard
-                text={adminHomeContainer.generatePrefilledHostInformationMarkdown()}
-                onCopy={() => adminHomeContainer.onCopyPrefilledHostInformation()}
-              >
-                <button id="prefilledHostInformationButton" type="button" className="btn btn-primary">
-                  {t('admin:admin_top:copy_prefilled_host_information:default')}
-                </button>
-              </CopyToClipboard>
-              <Tooltip
-                placement="bottom"
-                isOpen={adminHomeContainer.state.copyState === adminHomeContainer.copyStateValues.DONE}
-                target="prefilledHostInformationButton"
-                fade={false}
-              >
-                {t('admin:admin_top:copy_prefilled_host_information:done')}
-              </Tooltip>
-              {/* eslint-disable-next-line react/no-danger */}
-              <span className="ml-2" dangerouslySetInnerHTML={{ __html: t('admin:admin_top:submit_bug_report') }} />
-            </div>
+      </div>
+
+      <div className="row mb-5">
+        <div className="col-md-12">
+          <h2 className="admin-setting-header">{t('admin:admin_top.list_of_env_vars')}</h2>
+          <p>{t('admin:admin_top.env_var_priority')}</p>
+          {/* eslint-disable-next-line react/no-danger */}
+          <p dangerouslySetInnerHTML={{ __html: t('admin:admin_top.about_security') }} />
+          {adminHomeContainer.state.envVars && <EnvVarsTable envVars={adminHomeContainer.state.envVars} />}
+        </div>
+      </div>
+
+      <div className="row mb-5">
+        <div className="col-md-12">
+          <h2 className="admin-setting-header">{t('admin:admin_top.bug_report')}</h2>
+          <div className="d-flex align-items-center">
+            <CopyToClipboard
+              text={adminHomeContainer.generatePrefilledHostInformationMarkdown()}
+              onCopy={() => adminHomeContainer.onCopyPrefilledHostInformation()}
+            >
+              <button id="prefilledHostInformationButton" type="button" className="btn btn-primary">
+                {t('admin:admin_top:copy_prefilled_host_information:default')}
+              </button>
+            </CopyToClipboard>
+            <Tooltip
+              placement="bottom"
+              isOpen={adminHomeContainer.state.copyState === adminHomeContainer.copyStateValues.DONE}
+              target="prefilledHostInformationButton"
+              fade={false}
+            >
+              {t('admin:admin_top:copy_prefilled_host_information:done')}
+            </Tooltip>
+            {/* eslint-disable-next-line react/no-danger */}
+            <span className="ml-2" dangerouslySetInnerHTML={{ __html: t('admin:admin_top:submit_bug_report') }} />
           </div>
           </div>
         </div>
         </div>
-      </Fragment>
-    );
-  }
+      </div>
+    </div>
+  );
+};
 
 
-}
 
 
 const AdminHomeWrapper = withUnstatedContainers(AdminHome, [AppContainer, AdminHomeContainer]);
 const AdminHomeWrapper = withUnstatedContainers(AdminHome, [AppContainer, AdminHomeContainer]);
 
 
 AdminHome.propTypes = {
 AdminHome.propTypes = {
-  t: PropTypes.func.isRequired, // i18next
   appContainer: PropTypes.instanceOf(AppContainer).isRequired,
   appContainer: PropTypes.instanceOf(AppContainer).isRequired,
   adminHomeContainer: PropTypes.instanceOf(AdminHomeContainer).isRequired,
   adminHomeContainer: PropTypes.instanceOf(AdminHomeContainer).isRequired,
 };
 };
 
 
-export default withTranslation()(AdminHomeWrapper);
+export default AdminHomeWrapper;

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

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

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

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

+ 56 - 5
packages/app/src/components/Admin/App/AppSettingsPageContents.jsx

@@ -1,20 +1,56 @@
-import React, { Fragment } from 'react';
+import React from 'react';
 import { withTranslation } from 'react-i18next';
 import { withTranslation } from 'react-i18next';
 import PropTypes from 'prop-types';
 import PropTypes from 'prop-types';
 
 
+import { withUnstatedContainers } from '../../UnstatedUtils';
 import AppSetting from './AppSetting';
 import AppSetting from './AppSetting';
 import SiteUrlSetting from './SiteUrlSetting';
 import SiteUrlSetting from './SiteUrlSetting';
 import MailSetting from './MailSetting';
 import MailSetting from './MailSetting';
 import PluginSetting from './PluginSetting';
 import PluginSetting from './PluginSetting';
 import FileUploadSetting from './FileUploadSetting';
 import FileUploadSetting from './FileUploadSetting';
+import V5PageMigration from './V5PageMigration';
+import MaintenanceMode from './MaintenanceMode';
+
+import AdminAppContainer from '~/client/services/AdminAppContainer';
 
 
 class AppSettingsPageContents extends React.Component {
 class AppSettingsPageContents extends React.Component {
 
 
   render() {
   render() {
-    const { t } = this.props;
+    const { t, adminAppContainer } = this.props;
+    const { isV5Compatible } = adminAppContainer.state;
 
 
     return (
     return (
-      <Fragment>
+      <div data-testid="admin-app-settings">
+        {
+          // Alert message will be displayed in case that the GROWI is under maintenance
+          adminAppContainer.state.isMaintenanceMode && (
+            <div className="alert alert-danger alert-link" role="alert">
+              <h3 className="alert-heading">
+                {t('admin:maintenance_mode.maintenance_mode')}
+              </h3>
+              <p>
+                {t('admin:maintenance_mode.description')}
+              </p>
+              <hr />
+              <a className="btn-link" href="#maintenance-mode" rel="noopener noreferrer">
+                <i className="fa fa-fw fa-arrow-down ml-1" aria-hidden="true"></i>
+                <strong>{t('admin:maintenance_mode.end_maintenance_mode')}</strong>
+              </a>
+            </div>
+          )
+        }
+        {
+          !isV5Compatible
+          && (
+            <div className="row">
+              <div className="col-lg-12">
+                <h2 className="admin-setting-header">{t('V5 Page Migration')}</h2>
+                <V5PageMigration />
+              </div>
+            </div>
+          )
+        }
+
         <div className="row">
         <div className="row">
           <div className="col-lg-12">
           <div className="col-lg-12">
             <h2 className="admin-setting-header">{t('App Settings')}</h2>
             <h2 className="admin-setting-header">{t('App Settings')}</h2>
@@ -49,14 +85,29 @@ class AppSettingsPageContents extends React.Component {
             <PluginSetting />
             <PluginSetting />
           </div>
           </div>
         </div>
         </div>
-      </Fragment>
+
+        <div className="row">
+          <div className="col-lg-12">
+            <h2 className="admin-setting-header" id="maintenance-mode">{t('admin:maintenance_mode.maintenance_mode')}</h2>
+            <MaintenanceMode />
+          </div>
+        </div>
+
+      </div>
+
     );
     );
   }
   }
 
 
 }
 }
 
 
+/**
+ * Wrapper component for using unstated
+ */
+const AppSettingsPageContentsWrapper = withUnstatedContainers(AppSettingsPageContents, [AdminAppContainer]);
+
 AppSettingsPageContents.propTypes = {
 AppSettingsPageContents.propTypes = {
   t: PropTypes.func.isRequired, // i18next
   t: PropTypes.func.isRequired, // i18next
+  adminAppContainer: PropTypes.instanceOf(AdminAppContainer).isRequired,
 };
 };
 
 
-export default withTranslation()(AppSettingsPageContents);
+export default withTranslation()(AppSettingsPageContentsWrapper);

+ 72 - 0
packages/app/src/components/Admin/App/ConfirmModal.tsx

@@ -0,0 +1,72 @@
+import React, { FC } from 'react';
+import {
+  Modal, ModalHeader, ModalBody, ModalFooter,
+} from 'reactstrap';
+import { useTranslation } from 'react-i18next';
+import { TFunctionResult } from 'i18next';
+
+type ConfirmModalProps = {
+  isModalOpen: boolean
+  warningMessage: TFunctionResult
+  supplymentaryMessage: TFunctionResult | null
+  confirmButtonTitle: TFunctionResult
+  onConfirm?: () => Promise<void>
+  onCancel?: () => void
+};
+
+export const ConfirmModal: FC<ConfirmModalProps> = (props: ConfirmModalProps) => {
+  const { t } = useTranslation();
+
+  const onCancel = () => {
+    if (props.onCancel != null) {
+      props.onCancel();
+    }
+  };
+
+  const onConfirm = () => {
+    if (props.onConfirm != null) {
+      props.onConfirm();
+    }
+  };
+
+  return (
+    <Modal isOpen={props.isModalOpen} toggle={onCancel} className="">
+      <ModalHeader tag="h4" toggle={onCancel} className="bg-danger">
+        <i className="icon-fw icon-question" />
+        {t('Warning')}
+      </ModalHeader>
+      <ModalBody>
+        {props.warningMessage}
+        {
+          props.supplymentaryMessage != null && (
+            <>
+              <br />
+              <br />
+              <span className="text-warning">
+                <i className="icon-exclamation icon-fw"></i>
+                {props.supplymentaryMessage}
+              </span>
+            </>
+          )
+        }
+
+      </ModalBody>
+      <ModalFooter>
+        <button
+          type="button"
+          className="btn btn-outline-secondary"
+          onClick={onCancel}
+        >
+          {t('Cancel')}
+        </button>
+        <button
+          type="button"
+          className="btn btn-outline-primary ml-3"
+          onClick={onConfirm}
+        >
+          {props.confirmButtonTitle ?? t('Confirm')}
+        </button>
+      </ModalFooter>
+    </Modal>
+  );
+};

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

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

+ 62 - 0
packages/app/src/components/Admin/App/V5PageMigration.tsx

@@ -0,0 +1,62 @@
+import React, { FC, useState } from 'react';
+import { useTranslation } from 'react-i18next';
+import { ConfirmModal } from './ConfirmModal';
+import AdminAppContainer from '../../../client/services/AdminAppContainer';
+import { withUnstatedContainers } from '../../UnstatedUtils';
+import { toastSuccess, toastError } from '../../../client/util/apiNotification';
+
+type Props = {
+  adminAppContainer: typeof AdminAppContainer & { v5PageMigrationHandler: () => Promise<{ isV5Compatible: boolean }> },
+}
+
+const V5PageMigration: FC<Props> = (props: Props) => {
+  const [isV5PageMigrationModalShown, setIsV5PageMigrationModalShown] = useState(false);
+  const { adminAppContainer } = props;
+  const { t } = useTranslation();
+
+  const onConfirm = async() => {
+    setIsV5PageMigrationModalShown(false);
+    try {
+      const { isV5Compatible } = await adminAppContainer.v5PageMigrationHandler();
+      if (isV5Compatible) {
+
+        return toastSuccess(t('admin:v5_page_migration.already_upgraded'));
+      }
+      toastSuccess(t('admin:v5_page_migration.successfully_started'));
+    }
+    catch (err) {
+      toastError(err);
+    }
+  };
+
+  return (
+    <>
+      <ConfirmModal
+        isModalOpen={isV5PageMigrationModalShown}
+        warningMessage={t('admin:v5_page_migration.modal_migration_warning')}
+        supplymentaryMessage={t('admin:v5_page_migration.migration_note')}
+        confirmButtonTitle={t('admin:v5_page_migration.start_upgrading')}
+        onConfirm={onConfirm}
+        onCancel={() => setIsV5PageMigrationModalShown(false)}
+      />
+      <p className="card well">
+        {t('admin:v5_page_migration.migration_desc')}
+        <br />
+        <br />
+        <span className="text-danger">
+          <i className="icon-exclamation icon-fw"></i>
+          {t('admin:v5_page_migration.migration_note')}
+        </span>
+      </p>
+      <div className="row my-3">
+        <div className="mx-auto">
+          <button type="button" className="btn btn-warning" onClick={() => setIsV5PageMigrationModalShown(true)}>
+            {t('admin:v5_page_migration.upgrade_to_v5')}
+          </button>
+        </div>
+      </div>
+    </>
+  );
+};
+
+export default withUnstatedContainers(V5PageMigration, [AdminAppContainer]);

+ 2 - 2
packages/app/src/components/Admin/Customize/Customize.jsx

@@ -46,7 +46,7 @@ function Customize(props) {
   }
   }
 
 
   return (
   return (
-    <Fragment>
+    <div data-testid="admin-customize">
       <div className="mb-5">
       <div className="mb-5">
         <CustomizeLayoutSetting appContainer={appContainer} />
         <CustomizeLayoutSetting appContainer={appContainer} />
       </div>
       </div>
@@ -71,7 +71,7 @@ function Customize(props) {
       <div className="mb-5">
       <div className="mb-5">
         <CustomizeScriptSetting />
         <CustomizeScriptSetting />
       </div>
       </div>
-    </Fragment>
+    </div>
   );
   );
 }
 }
 
 

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

@@ -27,7 +27,8 @@ class StatusTable extends React.PureComponent {
     }
     }
     else {
     else {
       connectionStatusLabel = isConnected
       connectionStatusLabel = isConnected
-        ? <span className="badge badge-pill badge-success">{ t('full_text_search_management.connection_status_label_connected') }</span>
+        // eslint-disable-next-line max-len
+        ? <span data-testid="connection-status-badge-connected" className="badge badge-pill badge-success">{ t('full_text_search_management.connection_status_label_connected') }</span>
         : <span className="badge badge-pill badge-danger">{ t('full_text_search_management.connection_status_label_disconnected') }</span>;
         : <span className="badge badge-pill badge-danger">{ t('full_text_search_management.connection_status_label_disconnected') }</span>;
     }
     }
 
 

+ 2 - 2
packages/app/src/components/Admin/ExportArchiveDataPage.jsx

@@ -210,7 +210,7 @@ class ExportArchiveDataPage extends React.Component {
     const showExportingData = (isExported || isExporting) && (progressList != null);
     const showExportingData = (isExported || isExporting) && (progressList != null);
 
 
     return (
     return (
-      <Fragment>
+      <div data-testid="admin-export-archive-data">
         <h2>{t('Export Archive Data')}</h2>
         <h2>{t('Export Archive Data')}</h2>
 
 
         <button type="button" className="btn btn-outline-secondary" disabled={isExporting} onClick={this.openExportModal}>
         <button type="button" className="btn btn-outline-secondary" disabled={isExporting} onClick={this.openExportModal}>
@@ -239,7 +239,7 @@ class ExportArchiveDataPage extends React.Component {
           onClose={this.closeExportModal}
           onClose={this.closeExportModal}
           collections={this.state.collections}
           collections={this.state.collections}
         />
         />
-      </Fragment>
+      </div>
     );
     );
   }
   }
 
 

+ 2 - 2
packages/app/src/components/Admin/FullTextSearchManagement.jsx

@@ -14,10 +14,10 @@ class FullTextSearchManagement extends React.Component {
     const { t } = this.props;
     const { t } = this.props;
 
 
     return (
     return (
-      <Fragment>
+      <div data-testid="admin-full-text-search">
         <h2> { t('full_text_search_management.elasticsearch_management') } </h2>
         <h2> { t('full_text_search_management.elasticsearch_management') } </h2>
         <ElasticsearchManagement />
         <ElasticsearchManagement />
-      </Fragment>
+      </div>
     );
     );
   }
   }
 
 

+ 6 - 1
packages/app/src/components/Admin/ImportData/GrowiArchive/ImportForm.jsx

@@ -287,7 +287,9 @@ class ImportForm extends React.Component {
   }
   }
 
 
   async import() {
   async import() {
-    const { appContainer, fileName, onPostImport } = this.props;
+    const {
+      appContainer, fileName, onPostImport, t,
+    } = this.props;
     const { selectedCollections, optionsMap } = this.state;
     const { selectedCollections, optionsMap } = this.state;
 
 
     // init progress data
     // init progress data
@@ -312,6 +314,9 @@ class ImportForm extends React.Component {
       toastSuccess(undefined, 'Import process has requested.');
       toastSuccess(undefined, 'Import process has requested.');
     }
     }
     catch (err) {
     catch (err) {
+      if (err.code === 'only_upsert_available') {
+        toastError(t('admin:importer_management.error.only_upsert_available'));
+      }
       toastError(err, 'Import request failed.');
       toastError(err, 'Import request failed.');
     }
     }
   }
   }

+ 2 - 2
packages/app/src/components/Admin/ImportData/ImportDataPageContents.jsx

@@ -13,7 +13,7 @@ class ImportDataPageContents extends React.Component {
     const { t, adminImportContainer } = this.props;
     const { t, adminImportContainer } = this.props;
 
 
     return (
     return (
-      <Fragment>
+      <div data-testid="admin-import-data">
         <GrowiArchiveSection />
         <GrowiArchiveSection />
 
 
         <form
         <form
@@ -226,7 +226,7 @@ class ImportDataPageContents extends React.Component {
 
 
 
 
         </form>
         </form>
-      </Fragment>
+      </div>
     );
     );
   }
   }
 
 

+ 2 - 2
packages/app/src/components/Admin/LegacySlackIntegration/LegacySlackIntegration.jsx

@@ -42,7 +42,7 @@ function LegacySlackIntegration(props) {
   const isDisabled = adminSlackIntegrationLegacyContainer.state.isSlackbotConfigured;
   const isDisabled = adminSlackIntegrationLegacyContainer.state.isSlackbotConfigured;
 
 
   return (
   return (
-    <>
+    <div data-testid="admin-slack-integration-legacy">
       { isDisabled && (
       { isDisabled && (
         <div className="alert alert-danger">
         <div className="alert alert-danger">
           <i className="icon-minus icon-fw"></i>
           <i className="icon-minus icon-fw"></i>
@@ -58,7 +58,7 @@ function LegacySlackIntegration(props) {
       </div>
       </div>
 
 
       <SlackConfiguration />
       <SlackConfiguration />
-    </>
+    </div>
   );
   );
 }
 }
 
 

+ 2 - 2
packages/app/src/components/Admin/MarkdownSetting/MarkDownSettingContents.jsx

@@ -14,7 +14,7 @@ class MarkDownSettingContents extends React.Component {
   render() {
   render() {
     const { t } = this.props;
     const { t } = this.props;
     return (
     return (
-      <React.Fragment>
+      <div data-testid="admin-markdown">
         {/* Line Break Setting */}
         {/* Line Break Setting */}
         <h2 className="admin-setting-header">{t('admin:markdown_setting.lineBreak_header')}</h2>
         <h2 className="admin-setting-header">{t('admin:markdown_setting.lineBreak_header')}</h2>
         <Card className="card well my-3">
         <Card className="card well my-3">
@@ -42,7 +42,7 @@ class MarkDownSettingContents extends React.Component {
           <CardBody className="px-0 py-2">{ t('admin:markdown_setting.xss_desc') }</CardBody>
           <CardBody className="px-0 py-2">{ t('admin:markdown_setting.xss_desc') }</CardBody>
         </Card>
         </Card>
         <XssForm />
         <XssForm />
-      </React.Fragment>
+      </div>
     );
     );
   }
   }
 
 

+ 1 - 1
packages/app/src/components/Admin/Notification/GlobalNotificationList.jsx

@@ -116,7 +116,7 @@ class GlobalNotificationList extends React.Component {
                   )}
                   )}
                   {notification.triggerEvents.includes('pageLike') && (
                   {notification.triggerEvents.includes('pageLike') && (
                     <li className="list-inline-item badge badge-pill badge-info">
                     <li className="list-inline-item badge badge-pill badge-info">
-                      <i className="icon-like"></i> LIKE
+                      <i className="fa fa-heart-o"></i> LIKE
                     </li>
                     </li>
                   )}
                   )}
                   {notification.triggerEvents.includes('comment') && (
                   {notification.triggerEvents.includes('comment') && (

+ 1 - 1
packages/app/src/components/Admin/Notification/ManageGlobalNotification.jsx

@@ -275,7 +275,7 @@ class ManageGlobalNotification extends React.Component {
                   onChange={() => this.onChangeTriggerEvents('pageLike')}
                   onChange={() => this.onChangeTriggerEvents('pageLike')}
                 >
                 >
                   <span className="badge badge-pill badge-info">
                   <span className="badge badge-pill badge-info">
-                    <i className="icon-like mr-1" />LIKE
+                    <i className="fa fa-heart-o mr-1" />LIKE
                   </span>
                   </span>
                 </TriggerEventCheckBox>
                 </TriggerEventCheckBox>
               </div>
               </div>

+ 3 - 3
packages/app/src/components/Admin/Notification/NotificationSetting.jsx

@@ -54,7 +54,7 @@ const SlackIntegrationListItem = ({ isEnabled, currentBotType }) => {
   const isCautionVisible = currentBotType === SlackbotType.OFFICIAL || currentBotType === SlackbotType.CUSTOM_WITH_PROXY;
   const isCautionVisible = currentBotType === SlackbotType.OFFICIAL || currentBotType === SlackbotType.CUSTOM_WITH_PROXY;
 
 
   return (
   return (
-    <li className="list-group-item">
+    <li data-testid="slack-integration-list-item" className="list-group-item">
       <h4>
       <h4>
         <Badge isEnabled={isEnabled} />
         <Badge isEnabled={isEnabled} />
         <a href="/admin/slack-integration" className="ml-2">{t('slack_integration')}</a>
         <a href="/admin/slack-integration" className="ml-2">{t('slack_integration')}</a>
@@ -144,7 +144,7 @@ function NotificationSetting(props) {
   const isSlackLegacyEnabled = !isSlackbotConfigured && isSlackLegacyConfigured;
   const isSlackLegacyEnabled = !isSlackbotConfigured && isSlackLegacyConfigured;
 
 
   return (
   return (
-    <>
+    <div data-testid="admin-notification">
       <h2 className="admin-setting-header">{t('admin:external_notification.header_status')}</h2>
       <h2 className="admin-setting-header">{t('admin:external_notification.header_status')}</h2>
       <ul className="list-group">
       <ul className="list-group">
         { !isMounted && <SkeltonListItem />}
         { !isMounted && <SkeltonListItem />}
@@ -170,7 +170,7 @@ function NotificationSetting(props) {
           {activeComponents.has('global_notification') && <GlobalNotification />}
           {activeComponents.has('global_notification') && <GlobalNotification />}
         </TabPane>
         </TabPane>
       </TabContent>
       </TabContent>
-    </>
+    </div>
   );
   );
 }
 }
 
 

+ 2 - 2
packages/app/src/components/Admin/Security/SecurityManagementContents.jsx

@@ -81,7 +81,7 @@ function SecurityManagementContents(props) {
 
 
 
 
   return (
   return (
-    <Fragment>
+    <div data-testid="admin-security">
       <div className="mb-5">
       <div className="mb-5">
         <SecuritySetting />
         <SecuritySetting />
       </div>
       </div>
@@ -141,7 +141,7 @@ function SecurityManagementContents(props) {
           </TabPane>
           </TabPane>
         </TabContent>
         </TabContent>
       </div>
       </div>
-    </Fragment>
+    </div>
   );
   );
 
 
 }
 }

+ 245 - 49
packages/app/src/components/Admin/Security/SecuritySetting.jsx

@@ -1,20 +1,72 @@
 /* eslint-disable react/no-danger */
 /* eslint-disable react/no-danger */
 import React from 'react';
 import React from 'react';
 import PropTypes from 'prop-types';
 import PropTypes from 'prop-types';
+import { Collapse } from 'reactstrap';
 import { withTranslation } from 'react-i18next';
 import { withTranslation } from 'react-i18next';
 
 
+import { validateDeleteConfigs } from '~/utils/page-delete-config';
 import { withUnstatedContainers } from '../../UnstatedUtils';
 import { withUnstatedContainers } from '../../UnstatedUtils';
 import { toastSuccess, toastError } from '~/client/util/apiNotification';
 import { toastSuccess, toastError } from '~/client/util/apiNotification';
-
+import { PageDeleteConfigValue } from '~/interfaces/page-delete-config';
 import AppContainer from '~/client/services/AppContainer';
 import AppContainer from '~/client/services/AppContainer';
 import AdminGeneralSecurityContainer from '~/client/services/AdminGeneralSecurityContainer';
 import AdminGeneralSecurityContainer from '~/client/services/AdminGeneralSecurityContainer';
 
 
+// used as the prefix of translation
+const DeletionTypeForT = Object.freeze({
+  Deletion: 'deletion',
+  CompleteDeletion: 'complete_deletion',
+  RecursiveDeletion: 'recursive_deletion',
+  RecursiveCompleteDeletion: 'recursive_complete_deletion',
+});
+
+const DeletionType = Object.freeze({
+  Deletion: 'deletion',
+  CompleteDeletion: 'completeDeletion',
+  RecursiveDeletion: 'recursiveDeletion',
+  RecursiveCompleteDeletion: 'recursiveCompleteDeletion',
+});
+
+const getDeletionTypeForT = (deletionType) => {
+  switch (deletionType) {
+    case DeletionType.Deletion:
+      return DeletionTypeForT.Deletion;
+    case DeletionType.RecursiveDeletion:
+      return DeletionTypeForT.RecursiveDeletion;
+    case DeletionType.CompleteDeletion:
+      return DeletionTypeForT.CompleteDeletion;
+    case DeletionType.RecursiveCompleteDeletion:
+      return DeletionTypeForT.RecursiveCompleteDeletion;
+  }
+};
+
+/**
+ * Return true if "deletionType" is DeletionType.RecursiveDeletion or DeletionType.RecursiveCompleteDeletion.
+ * @param deletionType Deletion type
+ * @returns boolean
+ */
+const isRecursiveDeletion = (deletionType) => {
+  return deletionType === DeletionType.RecursiveDeletion || deletionType === DeletionType.RecursiveCompleteDeletion;
+};
+
+/**
+ * Return true if "deletionType" is DeletionType.Deletion or DeletionType.RecursiveDeletion.
+ * @param deletionType Deletion type
+ * @returns boolean
+ */
+const isTypeDeletion = (deletionType) => {
+  return deletionType === DeletionType.Deletion || deletionType === DeletionType.RecursiveDeletion;
+};
+
 class SecuritySetting extends React.Component {
 class SecuritySetting extends React.Component {
 
 
   constructor(props) {
   constructor(props) {
     super(props);
     super(props);
 
 
     this.putSecuritySetting = this.putSecuritySetting.bind(this);
     this.putSecuritySetting = this.putSecuritySetting.bind(this);
+    this.getRecursiveDeletionConfigState = this.getRecursiveDeletionConfigState.bind(this);
+    this.setDeletionConfigState = this.setDeletionConfigState.bind(this);
+    this.renderPageDeletePermission = this.renderPageDeletePermission.bind(this);
+    this.renderPageDeletePermissionDropdown = this.renderPageDeletePermissionDropdown.bind(this);
   }
   }
 
 
   async putSecuritySetting() {
   async putSecuritySetting() {
@@ -28,9 +80,181 @@ class SecuritySetting extends React.Component {
     }
     }
   }
   }
 
 
+  getRecursiveDeletionConfigState(deletionType) {
+    const { adminGeneralSecurityContainer } = this.props;
+
+    if (isTypeDeletion(deletionType)) {
+      return [
+        adminGeneralSecurityContainer.state.currentPageRecursiveDeletionAuthority,
+        adminGeneralSecurityContainer.changePageRecursiveDeletionAuthority,
+      ];
+    }
+
+    return [
+      adminGeneralSecurityContainer.state.currentPageRecursiveCompleteDeletionAuthority,
+      adminGeneralSecurityContainer.changePageRecursiveCompleteDeletionAuthority,
+    ];
+  }
+
+  /**
+   * Force update deletion config for recursive operation when the deletion config for general operation is updated.
+   * @param deletionType Deletion type
+   */
+  setDeletionConfigState(newState, setState, deletionType) {
+    if (isRecursiveDeletion(deletionType)) {
+      setState(newState);
+
+      return;
+    }
+
+    const [recursiveState, setRecursiveState] = this.getRecursiveDeletionConfigState(deletionType);
+    const shouldForceUpdate = !validateDeleteConfigs(newState, recursiveState);
+    if (shouldForceUpdate) {
+      setState(newState);
+      setRecursiveState(newState);
+    }
+    else {
+      setState(newState);
+    }
+
+    return;
+  }
+
+  renderPageDeletePermissionDropdown(currentState, setState, deletionType, isButtonDisabled) {
+    const { t } = this.props;
+    return (
+      <div className="dropdown">
+        <button
+          className="btn btn-outline-secondary dropdown-toggle text-right"
+          type="button"
+          id="dropdownMenuButton"
+          data-toggle="dropdown"
+          aria-haspopup="true"
+          aria-expanded="true"
+        >
+          <span className="float-left">
+            {currentState === PageDeleteConfigValue.Inherit && t('security_setting.inherit')}
+            {(currentState === PageDeleteConfigValue.Anyone || currentState == null) && t('security_setting.anyone')}
+            {currentState === PageDeleteConfigValue.AdminOnly && t('security_setting.admin_only')}
+            {currentState === PageDeleteConfigValue.AdminAndAuthor && t('security_setting.admin_and_author')}
+          </span>
+        </button>
+        <div className="dropdown-menu" aria-labelledby="dropdownMenuButton">
+          {
+            isRecursiveDeletion(deletionType)
+              ? (
+                <button
+                  className="dropdown-item"
+                  type="button"
+                  onClick={() => { this.setDeletionConfigState(PageDeleteConfigValue.Inherit, setState, deletionType) }}
+                >
+                  {t('security_setting.inherit')}
+                </button>
+              )
+              : (
+                <button
+                  className="dropdown-item"
+                  type="button"
+                  onClick={() => { this.setDeletionConfigState(PageDeleteConfigValue.Anyone, setState, deletionType) }}
+                >
+                  {t('security_setting.anyone')}
+                </button>
+              )
+          }
+          <button
+            className={`dropdown-item ${isButtonDisabled ? 'disabled' : ''}`}
+            type="button"
+            onClick={() => { this.setDeletionConfigState(PageDeleteConfigValue.AdminAndAuthor, setState, deletionType) }}
+          >
+            {t('security_setting.admin_and_author')}
+          </button>
+          <button
+            className="dropdown-item"
+            type="button"
+            onClick={() => { this.setDeletionConfigState(PageDeleteConfigValue.AdminOnly, setState, deletionType) }}
+          >
+            {t('security_setting.admin_only')}
+          </button>
+        </div>
+        <p className="form-text text-muted small">
+          {t(`security_setting.${getDeletionTypeForT(deletionType)}_explain`)}
+        </p>
+      </div>
+    );
+  }
+
+  renderPageDeletePermission(currentState, setState, deletionType, isButtonDisabled) {
+    const { t, adminGeneralSecurityContainer } = this.props;
+
+    const expandOtherOptions = isTypeDeletion(deletionType)
+      ? adminGeneralSecurityContainer.state.expandOtherOptionsForDeletion
+      : adminGeneralSecurityContainer.state.expandOtherOptionsForCompleteDeletion;
+
+    const setExpantOtherOptions = () => {
+      if (isTypeDeletion(deletionType)) {
+        adminGeneralSecurityContainer.switchExpandOtherOptionsForDeletion();
+        return;
+      }
+      adminGeneralSecurityContainer.switchExpandOtherOptionsForCompleteDeletion();
+      return;
+    };
+
+    return (
+      <div key={`page-delete-permission-dropdown-${deletionType}`} className="row">
+
+        <div className="col-md-3 text-md-right">
+          {!isRecursiveDeletion(deletionType) && isTypeDeletion(deletionType) && (
+            <strong>{t('security_setting.page_delete')}</strong>
+          )}
+          {!isRecursiveDeletion(deletionType) && !isTypeDeletion(deletionType) && (
+            <strong>{t('security_setting.page_delete_completely')}</strong>
+          )}
+        </div>
+
+        <div className="col-md-6">
+          {
+            !isRecursiveDeletion(deletionType)
+              ? (
+                <>{this.renderPageDeletePermissionDropdown(currentState, setState, deletionType, isButtonDisabled)}</>
+              )
+              : (
+                <>
+                  <button
+                    type="button"
+                    className="btn btn-link p-0 mb-4"
+                    aria-expanded="false"
+                    onClick={() => setExpantOtherOptions()}
+                  >
+                    <i className={`fa fa-fw fa-arrow-right ${expandOtherOptions ? 'fa-rotate-90' : ''}`}></i>
+                    { t('security_setting.other_options') }
+                  </button>
+                  <Collapse isOpen={expandOtherOptions}>
+                    <div className="pb-4">
+                      {this.renderPageDeletePermissionDropdown(currentState, setState, deletionType, isButtonDisabled)}
+                    </div>
+                  </Collapse>
+                </>
+              )
+          }
+        </div>
+      </div>
+    );
+  }
+
   render() {
   render() {
     const { t, adminGeneralSecurityContainer } = this.props;
     const { t, adminGeneralSecurityContainer } = this.props;
-    const { currentRestrictGuestMode, currentPageCompleteDeletionAuthority } = adminGeneralSecurityContainer.state;
+    const {
+      currentRestrictGuestMode, currentPageDeletionAuthority, currentPageCompleteDeletionAuthority,
+      currentPageRecursiveDeletionAuthority, currentPageRecursiveCompleteDeletionAuthority,
+    } = adminGeneralSecurityContainer.state;
+
+    const isButtonDisabledForDeletion = !validateDeleteConfigs(
+      adminGeneralSecurityContainer.state.currentPageDeletionAuthority, PageDeleteConfigValue.AdminAndAuthor,
+    );
+
+    const isButtonDisabledForCompleteDeletion = !validateDeleteConfigs(
+      adminGeneralSecurityContainer.state.currentPageCompleteDeletionAuthority, PageDeleteConfigValue.AdminAndAuthor,
+    );
 
 
     return (
     return (
       <React.Fragment>
       <React.Fragment>
@@ -98,7 +322,7 @@ class SecuritySetting extends React.Component {
           </tbody>
           </tbody>
         </table>
         </table>
 
 
-        <h4>{t('security_setting.page_access_and_delete_rights')}</h4>
+        <h4>{t('security_setting.page_access_rights')}</h4>
         <div className="row mb-4">
         <div className="row mb-4">
           <div className="col-md-3 text-md-right py-2">
           <div className="col-md-3 text-md-right py-2">
             <strong>{t('security_setting.Guest Users Access')}</strong>
             <strong>{t('security_setting.Guest Users Access')}</strong>
@@ -142,52 +366,24 @@ class SecuritySetting extends React.Component {
             )}
             )}
           </div>
           </div>
         </div>
         </div>
-        <div className="row mb-4">
-          <div className="col-md-3 text-md-right mb-2">
-            <strong>{t('security_setting.complete_deletion')}</strong>
-          </div>
-          <div className="col-md-6">
-            <div className="dropdown">
-              <button
-                className="btn btn-outline-secondary dropdown-toggle text-right col-12 col-md-auto"
-                type="button"
-                id="dropdownMenuButton"
-                data-toggle="dropdown"
-                aria-haspopup="true"
-                aria-expanded="true"
-              >
-                <span className="float-left">
-                  {(currentPageCompleteDeletionAuthority === 'anyOne' || currentPageCompleteDeletionAuthority == null)
-                      && t('security_setting.anyone')}
-                  {currentPageCompleteDeletionAuthority === 'adminOnly' && t('security_setting.admin_only')}
-                  {currentPageCompleteDeletionAuthority === 'adminAndAuthor' && t('security_setting.admin_and_author')}
-                </span>
-              </button>
-              <div className="dropdown-menu" aria-labelledby="dropdownMenuButton">
-                <button className="dropdown-item" type="button" onClick={() => { adminGeneralSecurityContainer.changePageCompleteDeletionAuthority('anyOne') }}>
-                  {t('security_setting.anyone')}
-                </button>
-                <button
-                  className="dropdown-item"
-                  type="button"
-                  onClick={() => { adminGeneralSecurityContainer.changePageCompleteDeletionAuthority('adminOnly') }}
-                >
-                  {t('security_setting.admin_only')}
-                </button>
-                <button
-                  className="dropdown-item"
-                  type="button"
-                  onClick={() => { adminGeneralSecurityContainer.changePageCompleteDeletionAuthority('adminAndAuthor') }}
-                >
-                  {t('security_setting.admin_and_author')}
-                </button>
-              </div>
-              <p className="form-text text-muted small">
-                {t('security_setting.complete_deletion_explain')}
-              </p>
-            </div>
-          </div>
-        </div>
+
+        <h4>{t('security_setting.page_delete_rights')}</h4>
+        <div className="row mb-4"></div>
+        {/* Render PageDeletePermissionDropdown */}
+        {
+          [
+            [currentPageDeletionAuthority, adminGeneralSecurityContainer.changePageDeletionAuthority, DeletionType.Deletion, false],
+            // eslint-disable-next-line max-len
+            [currentPageRecursiveDeletionAuthority, adminGeneralSecurityContainer.changePageRecursiveDeletionAuthority, DeletionType.RecursiveDeletion, isButtonDisabledForDeletion],
+          ].map(arr => this.renderPageDeletePermission(arr[0], arr[1], arr[2], arr[3]))
+        }
+        {
+          [
+            [currentPageCompleteDeletionAuthority, adminGeneralSecurityContainer.changePageCompleteDeletionAuthority, DeletionType.CompleteDeletion, false],
+            // eslint-disable-next-line max-len
+            [currentPageRecursiveCompleteDeletionAuthority, adminGeneralSecurityContainer.changePageRecursiveCompleteDeletionAuthority, DeletionType.RecursiveCompleteDeletion, isButtonDisabledForCompleteDeletion],
+          ].map(arr => this.renderPageDeletePermission(arr[0], arr[1], arr[2], arr[3]))
+        }
 
 
         <h4>{t('security_setting.session')}</h4>
         <h4>{t('security_setting.session')}</h4>
         <div className="form-group row">
         <div className="form-group row">

+ 2 - 2
packages/app/src/components/Admin/SlackIntegration/SlackIntegration.jsx

@@ -192,7 +192,7 @@ const SlackIntegration = (props) => {
   }
   }
 
 
   return (
   return (
-    <>
+    <div data-testid="admin-slack-integration">
       <ConfirmBotChangeModal
       <ConfirmBotChangeModal
         isOpen={selectedBotType != null}
         isOpen={selectedBotType != null}
         onConfirmClick={changeCurrentBotSettingsHandler}
         onConfirmClick={changeCurrentBotSettingsHandler}
@@ -246,7 +246,7 @@ const SlackIntegration = (props) => {
       </div>
       </div>
 
 
       {settingsComponent}
       {settingsComponent}
-    </>
+    </div>
   );
   );
 };
 };
 
 

+ 0 - 118
packages/app/src/components/Admin/UserGroup/UserGroupCreateForm.jsx

@@ -1,118 +0,0 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-import { withTranslation } from 'react-i18next';
-
-import { withUnstatedContainers } from '../../UnstatedUtils';
-import AppContainer from '~/client/services/AppContainer';
-import { toastSuccess, toastError } from '~/client/util/apiNotification';
-
-class UserGroupCreateForm extends React.Component {
-
-  constructor(props) {
-    super(props);
-
-    this.state = {
-      name: '',
-    };
-
-    this.xss = window.xss;
-
-    this.handleChange = this.handleChange.bind(this);
-    this.handleSubmit = this.handleSubmit.bind(this);
-    this.validateForm = this.validateForm.bind(this);
-  }
-
-  handleChange(event) {
-    const target = event.target;
-    const value = target.type === 'checkbox' ? target.checked : target.value;
-    const name = target.name;
-
-    this.setState({
-      [name]: value,
-    });
-  }
-
-  async handleSubmit(e) {
-    e.preventDefault();
-
-    try {
-      const res = await this.props.appContainer.apiv3.post('/user-groups', {
-        name: this.state.name,
-      });
-
-      const userGroup = res.data.userGroup;
-      const userGroupId = userGroup._id;
-
-      const res2 = await this.props.appContainer.apiv3.get(`/user-groups/${userGroupId}/users`);
-
-      const { users } = res2.data;
-
-      this.props.onCreate(userGroup, users);
-
-      this.setState({ name: '' });
-
-      toastSuccess(`Created a user group "${this.xss.process(userGroup.name)}"`);
-    }
-    catch (err) {
-      toastError(err);
-    }
-  }
-
-  validateForm() {
-    return this.state.name !== '';
-  }
-
-  render() {
-    const { t } = this.props;
-
-    return (
-      <div>
-        <p>
-          {this.props.isAclEnabled
-            ? (
-              <button type="button" data-toggle="collapse" className="btn btn-outline-secondary" href="#createGroupForm">
-                {t('admin:user_group_management.create_group')}
-              </button>
-            )
-            : (
-              t('admin:user_group_management.deny_create_group')
-            )
-          }
-        </p>
-        <form onSubmit={this.handleSubmit}>
-          <div id="createGroupForm" className="collapse">
-            <div className="form-group">
-              <label htmlFor="name">{t('admin:user_group_management.group_name')}</label>
-              <textarea
-                id="name"
-                name="name"
-                className="form-control"
-                placeholder={t('admin:user_group_management.group_example')}
-                value={this.state.name}
-                onChange={this.handleChange}
-              >
-              </textarea>
-            </div>
-            <button type="submit" className="btn btn-primary" disabled={!this.validateForm()}>{t('Create')}</button>
-          </div>
-        </form>
-      </div>
-    );
-  }
-
-}
-
-/**
- * Wrapper component for using unstated
- */
-const UserGroupCreateFormWrapper = withUnstatedContainers(UserGroupCreateForm, [AppContainer]);
-
-UserGroupCreateForm.propTypes = {
-  t: PropTypes.func.isRequired, // i18next
-  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
-
-  isAclEnabled: PropTypes.bool.isRequired,
-  onCreate: PropTypes.func.isRequired,
-};
-
-export default withTranslation()(UserGroupCreateFormWrapper);

+ 0 - 216
packages/app/src/components/Admin/UserGroup/UserGroupDeleteModal.jsx

@@ -1,216 +0,0 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-import { withTranslation } from 'react-i18next';
-import {
-  Modal, ModalHeader, ModalBody, ModalFooter,
-} from 'reactstrap';
-
-import { withUnstatedContainers } from '../../UnstatedUtils';
-import AppContainer from '~/client/services/AppContainer';
-
-/**
- * Delete User Group Select component
- *
- * @export
- * @class GrantSelector
- * @extends {React.Component}
- */
-class UserGroupDeleteModal extends React.Component {
-
-  constructor(props) {
-    super(props);
-
-    const { t } = this.props;
-
-    // actionName master constants
-    this.actionForPages = {
-      public: 'public',
-      delete: 'delete',
-      transfer: 'transfer',
-    };
-
-    this.availableOptions = [
-      {
-        id: 1,
-        actionForPages: this.actionForPages.public,
-        iconClass: 'icon-people',
-        styleClass: '',
-        label: t('admin:user_group_management.delete_modal.publish_pages'),
-      },
-      {
-        id: 2,
-        actionForPages: this.actionForPages.delete,
-        iconClass: 'icon-trash',
-        styleClass: 'text-danger',
-        label: t('admin:user_group_management.delete_modal.delete_pages'),
-      },
-      {
-        id: 3,
-        actionForPages: this.actionForPages.transfer,
-        iconClass: 'icon-options',
-        styleClass: '',
-        label: t('admin:user_group_management.delete_modal.transfer_pages'),
-      },
-    ];
-
-    this.initialState = {
-      actionName: '',
-      transferToUserGroupId: '',
-    };
-
-    this.state = this.initialState;
-
-    this.xss = window.xss;
-
-    this.onHide = this.onHide.bind(this);
-    this.handleActionChange = this.handleActionChange.bind(this);
-    this.handleGroupChange = this.handleGroupChange.bind(this);
-    this.handleSubmit = this.handleSubmit.bind(this);
-    this.renderPageActionSelector = this.renderPageActionSelector.bind(this);
-    this.renderGroupSelector = this.renderGroupSelector.bind(this);
-    this.validateForm = this.validateForm.bind(this);
-  }
-
-  onHide() {
-    this.setState(this.initialState);
-    this.props.onHide();
-  }
-
-  handleActionChange(e) {
-    const actionName = e.target.value;
-    this.setState({ actionName });
-  }
-
-  handleGroupChange(e) {
-    const transferToUserGroupId = e.target.value;
-    this.setState({ transferToUserGroupId });
-  }
-
-  handleSubmit(e) {
-    e.preventDefault();
-
-    this.props.onDelete({
-      deleteGroupId: this.props.deleteUserGroup._id,
-      actionName: this.state.actionName,
-      transferToUserGroupId: this.state.transferToUserGroupId,
-    });
-  }
-
-  renderPageActionSelector() {
-    const { t } = this.props;
-
-    const optoins = this.availableOptions.map((opt) => {
-      const dataContent = `<i class="icon icon-fw ${opt.iconClass} ${opt.styleClass}"></i> <span class="action-name ${opt.styleClass}">${opt.label}</span>`;
-      return <option key={opt.id} value={opt.actionForPages} data-content={dataContent}>{opt.label}</option>;
-    });
-
-    return (
-      <select
-        name="actionName"
-        className="form-control"
-        placeholder="select"
-        value={this.state.actionName}
-        onChange={this.handleActionChange}
-      >
-        <option value="" disabled>{t('admin:user_group_management.delete_modal.dropdown_desc')}</option>
-        {optoins}
-      </select>
-    );
-  }
-
-  renderGroupSelector() {
-    const { t } = this.props;
-
-    const groups = this.props.userGroups.filter((group) => {
-      return group._id !== this.props.deleteUserGroup._id;
-    });
-
-    const options = groups.map((group) => {
-      const dataContent = `<i class="icon icon-fw icon-organization"></i> ${this.xss.process(group.name)}`;
-      return <option key={group._id} value={group._id} data-content={dataContent}>{this.xss.process(group.name)}</option>;
-    });
-
-    const defaultOptionText = groups.length === 0 ? t('admin:user_group_management.delete_modal.no_groups')
-      : t('admin:user_group_management.delete_modal.select_group');
-
-    return (
-      <select
-        name="transferToUserGroupId"
-        className={`form-control ${this.state.actionName === this.actionForPages.transfer ? '' : 'd-none'}`}
-        value={this.state.transferToUserGroupId}
-        onChange={this.handleGroupChange}
-      >
-        <option value="" disabled>{defaultOptionText}</option>
-        {options}
-      </select>
-    );
-  }
-
-  validateForm() {
-    let isValid = true;
-
-    if (this.state.actionName === '') {
-      isValid = false;
-    }
-    else if (this.state.actionName === this.actionForPages.transfer) {
-      isValid = this.state.transferToUserGroupId !== '';
-    }
-
-    return isValid;
-  }
-
-  render() {
-    const { t } = this.props;
-
-    return (
-      <Modal className="modal-md" isOpen={this.props.isShow} toggle={this.props.onHide}>
-        <ModalHeader tag="h4" toggle={this.props.onHide} className="bg-danger text-light">
-          <i className="icon icon-fire"></i> {t('admin:user_group_management.delete_modal.header')}
-        </ModalHeader>
-        <ModalBody>
-          <div>
-            <span className="font-weight-bold">{t('admin:user_group_management.group_name')}</span> : &quot;{this.props.deleteUserGroup.name}&quot;
-          </div>
-          <div className="text-danger mt-5">
-            {t('admin:user_group_management.delete_modal.desc')}
-          </div>
-        </ModalBody>
-        <ModalFooter>
-          <form className="d-flex justify-content-between w-100" onSubmit={this.handleSubmit}>
-            <div className="d-flex form-group mb-0">
-              {this.renderPageActionSelector()}
-              {this.renderGroupSelector()}
-            </div>
-            <button type="submit" value="" className="btn btn-sm btn-danger text-nowrap" disabled={!this.validateForm()}>
-              <i className="icon icon-fire"></i> {t('Delete')}
-            </button>
-          </form>
-        </ModalFooter>
-      </Modal>
-    );
-  }
-
-}
-
-/**
- * Wrapper component for using unstated
- */
-const UserGroupDeleteModalWrapper = withUnstatedContainers(UserGroupDeleteModal, [AppContainer]);
-
-UserGroupDeleteModal.propTypes = {
-  t: PropTypes.func.isRequired, // i18next
-  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
-
-  userGroups: PropTypes.arrayOf(PropTypes.object).isRequired,
-  deleteUserGroup: PropTypes.object,
-  onDelete: PropTypes.func.isRequired,
-  isShow: PropTypes.bool.isRequired,
-  onShow: PropTypes.func.isRequired,
-  onHide: PropTypes.func.isRequired,
-};
-
-UserGroupDeleteModal.defaultProps = {
-  deleteUserGroup: {},
-};
-
-export default withTranslation()(UserGroupDeleteModalWrapper);

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

@@ -0,0 +1,216 @@
+import React, {
+  FC, useCallback, useState, useMemo,
+} from 'react';
+import { TFunctionResult } from 'i18next';
+import { useTranslation } from 'react-i18next';
+import {
+  Modal, ModalHeader, ModalBody, ModalFooter,
+} from 'reactstrap';
+
+import { IUserGroupHasId } from '~/interfaces/user';
+import { CustomWindow } from '~/interfaces/global';
+import Xss from '~/services/xss';
+
+/**
+ * Delete User Group Select component
+ *
+ * @export
+ * @class GrantSelector
+ * @extends {React.Component}
+ */
+type Props = {
+  userGroups: IUserGroupHasId[],
+  deleteUserGroup?: IUserGroupHasId,
+  onDelete?: (deleteGroupId: string, actionName: string, transferToUserGroupId: string) => Promise<void> | void,
+  isShow: boolean,
+  onShow?: (group: IUserGroupHasId) => Promise<void> | void,
+  onHide?: () => Promise<void> | void,
+};
+
+type AvailableOption = {
+  id: number,
+  actionForPages: string,
+  iconClass: string,
+  styleClass: string,
+  label: TFunctionResult,
+};
+
+// actionName master constants
+const actionForPages = {
+  public: 'public',
+  delete: 'delete',
+  transfer: 'transfer',
+};
+
+const UserGroupDeleteModal: FC<Props> = (props: Props) => {
+  const xss: Xss = (window as CustomWindow).xss;
+
+  const { t } = useTranslation();
+
+  const availableOptions = useMemo<AvailableOption[]>(() => {
+    return [
+      {
+        id: 1,
+        actionForPages: actionForPages.public,
+        iconClass: 'icon-people',
+        styleClass: '',
+        label: t('admin:user_group_management.delete_modal.publish_pages'),
+      },
+      {
+        id: 2,
+        actionForPages: actionForPages.delete,
+        iconClass: 'icon-trash',
+        styleClass: 'text-danger',
+        label: t('admin:user_group_management.delete_modal.delete_pages'),
+      },
+      {
+        id: 3,
+        actionForPages: actionForPages.transfer,
+        iconClass: 'icon-options',
+        styleClass: '',
+        label: t('admin:user_group_management.delete_modal.transfer_pages'),
+      },
+    ];
+  }, []);
+
+  /*
+   * State
+   */
+  const [actionName, setActionName] = useState<string>('');
+  const [transferToUserGroupId, setTransferToUserGroupId] = useState<string>('');
+
+  /*
+   * Function
+   */
+  const resetStates = useCallback(() => {
+    setActionName('');
+    setTransferToUserGroupId('');
+  }, []);
+
+  const onHide = useCallback(() => {
+    if (props.onHide == null) {
+      return;
+    }
+
+    resetStates();
+    props.onHide();
+  }, [props.onHide]);
+
+  const handleActionChange = useCallback((e) => {
+    const actionName = e.target.value;
+    setActionName(actionName);
+  }, [setActionName]);
+
+  const handleGroupChange = useCallback((e) => {
+    const transferToUserGroupId = e.target.value;
+    setTransferToUserGroupId(transferToUserGroupId);
+  }, []);
+
+  const handleSubmit = useCallback((e) => {
+    if (props.onDelete == null || props.deleteUserGroup == null) {
+      return;
+    }
+
+    e.preventDefault();
+
+    props.onDelete(
+      props.deleteUserGroup._id,
+      actionName,
+      transferToUserGroupId,
+    );
+  }, [props.onDelete, props.deleteUserGroup, actionName, transferToUserGroupId]);
+
+  const renderPageActionSelector = useCallback(() => {
+    const options = availableOptions.map((opt) => {
+      const dataContent = `<i class="icon icon-fw ${opt.iconClass} ${opt.styleClass}"></i> <span class="action-name ${opt.styleClass}">${opt.label}</span>`;
+      return <option key={opt.id} value={opt.actionForPages} data-content={dataContent}>{opt.label}</option>;
+    });
+
+    return (
+      <select
+        name="actionName"
+        className="form-control"
+        placeholder="select"
+        value={actionName}
+        onChange={handleActionChange}
+      >
+        <option value="" disabled>{t('admin:user_group_management.delete_modal.dropdown_desc')}</option>
+        {options}
+      </select>
+    );
+  }, [handleActionChange, actionName, availableOptions]);
+
+  const renderGroupSelector = useCallback(() => {
+    const { deleteUserGroup } = props;
+
+    if (deleteUserGroup == null) {
+      return;
+    }
+
+    const groups = props.userGroups.filter((group) => {
+      return group._id !== deleteUserGroup._id;
+    });
+
+    const options = groups.map((group) => {
+      const dataContent = `<i class="icon icon-fw icon-organization"></i> ${xss.process(group.name)}`;
+      return <option key={group._id} value={group._id} data-content={dataContent}>{xss.process(group.name)}</option>;
+    });
+
+    const defaultOptionText = groups.length === 0 ? t('admin:user_group_management.delete_modal.no_groups')
+      : t('admin:user_group_management.delete_modal.select_group');
+
+    return (
+      <select
+        name="transferToUserGroupId"
+        className={`form-control ${actionName === actionForPages.transfer ? '' : 'd-none'}`}
+        value={transferToUserGroupId}
+        onChange={handleGroupChange}
+      >
+        <option value="" disabled>{defaultOptionText}</option>
+        {options}
+      </select>
+    );
+  }, [actionName, transferToUserGroupId, props.userGroups, props.deleteUserGroup]);
+
+  const validateForm = useCallback(() => {
+    let isValid = true;
+
+    if (actionName === '') {
+      isValid = false;
+    }
+    else if (actionName === actionForPages.transfer) {
+      isValid = transferToUserGroupId !== '';
+    }
+
+    return isValid;
+  }, [actionName, transferToUserGroupId]);
+
+  return (
+    <Modal className="modal-md" isOpen={props.isShow} toggle={onHide}>
+      <ModalHeader tag="h4" toggle={onHide} className="bg-danger text-light">
+        <i className="icon icon-fire"></i> {t('admin:user_group_management.delete_modal.header')}
+      </ModalHeader>
+      <ModalBody>
+        <div>
+          <span className="font-weight-bold">{t('admin:user_group_management.group_name')}</span> : &quot;{props?.deleteUserGroup?.name || ''}&quot;
+        </div>
+        <div className="text-danger mt-3">
+          {t('admin:user_group_management.delete_modal.desc')}
+        </div>
+      </ModalBody>
+      <ModalFooter>
+        <form className="d-flex justify-content-between w-100" onSubmit={handleSubmit}>
+          <div className="d-flex form-group mb-0">
+            {renderPageActionSelector()}
+            {renderGroupSelector()}
+          </div>
+          <button type="submit" value="" className="btn btn-sm btn-danger text-nowrap" disabled={!validateForm()}>
+            <i className="icon icon-fire"></i> {t('Delete')}
+          </button>
+        </form>
+      </ModalFooter>
+    </Modal>
+  );
+};
+
+export default UserGroupDeleteModal;

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

@@ -0,0 +1,70 @@
+import React, { FC, useCallback } from 'react';
+import { useTranslation } from 'react-i18next';
+
+import { IUserGroupHasId } from '~/interfaces/user';
+
+type Props = {
+  selectableUserGroups?: IUserGroupHasId[]
+  onClickAddExistingUserGroupButtonHandler?(userGroup: IUserGroupHasId | null): void
+  onClickCreateUserGroupButtonHandler?(): void
+};
+
+const UserGroupDropdown: FC<Props> = (props: Props) => {
+  const { t } = useTranslation();
+
+  const { selectableUserGroups, onClickAddExistingUserGroupButtonHandler, onClickCreateUserGroupButtonHandler } = props;
+
+  const onClickAddExistingUserGroupButton = useCallback((userGroup: IUserGroupHasId) => {
+    if (onClickAddExistingUserGroupButtonHandler != null) {
+      onClickAddExistingUserGroupButtonHandler(userGroup);
+    }
+  }, [onClickAddExistingUserGroupButtonHandler]);
+
+  const onClickCreateUserGroupButton = useCallback(() => {
+    if (onClickCreateUserGroupButtonHandler != null) {
+      onClickCreateUserGroupButtonHandler();
+    }
+  }, [onClickCreateUserGroupButtonHandler]);
+
+  return (
+    <>
+      <div className="dropdown">
+        <button className="btn btn-secondary dropdown-toggle" type="button" id="dropdownMenuButton" data-toggle="dropdown">
+          {t('admin:user_group_management.add_child_group')}
+        </button>
+
+        <div className="dropdown-menu" aria-labelledby="dropdownMenuButton">
+
+          {
+            (selectableUserGroups != null && selectableUserGroups.length > 0) && (
+              <>
+                {
+                  selectableUserGroups.map(userGroup => (
+                    <button
+                      key={userGroup._id}
+                      type="button"
+                      className="dropdown-item"
+                      onClick={() => onClickAddExistingUserGroupButton(userGroup)}
+                    >
+                      {userGroup.name}
+                    </button>
+                  ))
+                }
+                <div className="dropdown-divider"></div>
+              </>
+            )
+          }
+
+          <button
+            className="dropdown-item"
+            type="button"
+            onClick={() => onClickCreateUserGroupButton()}
+          >{t('admin:user_group_management.create_group')}
+          </button>
+        </div>
+      </div>
+    </>
+  );
+};
+
+export default UserGroupDropdown;

+ 160 - 0
packages/app/src/components/Admin/UserGroup/UserGroupForm.tsx

@@ -0,0 +1,160 @@
+import React, { FC, useCallback, useState } from 'react';
+import { useTranslation } from 'react-i18next';
+import dateFnsFormat from 'date-fns/format';
+import { TFunctionResult } from 'i18next';
+
+import { IUserGroup, IUserGroupHasId } from '~/interfaces/user';
+import { CustomWindow } from '~/interfaces/global';
+import Xss from '~/services/xss';
+
+type Props = {
+  userGroup?: IUserGroupHasId,
+  selectableParentUserGroups?: IUserGroupHasId[],
+  submitButtonLabel: TFunctionResult;
+  onSubmit?: (userGroupData: Partial<IUserGroup>) => Promise<IUserGroupHasId | void>
+};
+
+const UserGroupForm: FC<Props> = (props: Props) => {
+  const xss: Xss = (window as CustomWindow).xss;
+
+  const { t } = useTranslation();
+
+  const {
+    userGroup, selectableParentUserGroups, submitButtonLabel, onSubmit,
+  } = props;
+
+  /*
+   * State
+   */
+  const [currentName, setName] = useState(userGroup != null ? userGroup.name : '');
+  const [currentDescription, setDescription] = useState(userGroup != null ? userGroup.description : '');
+  const [selectedParent, setSelectedParent] = useState<IUserGroupHasId | undefined>(userGroup?.parent as IUserGroupHasId);
+
+  /*
+   * Function
+   */
+  const onChangeNameHandler = useCallback((e) => {
+    setName(e.target.value);
+  }, []);
+
+  const onChangeDescriptionHandler = useCallback((e) => {
+    setDescription(e.target.value);
+  }, []);
+
+  const onChangeParerentButtonHandler = useCallback((userGroup: IUserGroupHasId) => {
+    if (userGroup._id !== selectedParent?._id) {
+      setSelectedParent(userGroup);
+    }
+  }, [selectedParent, setSelectedParent]);
+
+  const onSubmitHandler = useCallback(async(e) => {
+    e.preventDefault(); // no reload
+
+    if (onSubmit == null) {
+      return;
+    }
+
+    await onSubmit({ name: currentName, description: currentDescription, parent: selectedParent?._id });
+  }, [currentName, currentDescription, selectedParent, onSubmit]);
+
+  return (
+    <form onSubmit={onSubmitHandler}>
+
+      <fieldset>
+        <h2 className="admin-setting-header">{t('admin:user_group_management.basic_info')}</h2>
+
+        {
+          userGroup?.createdAt != null && (
+            <div className="form-group row">
+              <p className="col-md-2 col-form-label">{t('Created')}</p>
+              <p className="col-md-4 my-auto">{dateFnsFormat(new Date(userGroup.createdAt), 'yyyy-MM-dd')}</p>
+            </div>
+          )
+        }
+
+        <div className="form-group row">
+          <label htmlFor="name" className="col-md-2 col-form-label">
+            {t('admin:user_group_management.group_name')}
+          </label>
+          <div className="col-md-4">
+            <input
+              className="form-control"
+              type="text"
+              name="name"
+              placeholder={t('admin:user_group_management.group_example')}
+              value={currentName}
+              onChange={onChangeNameHandler}
+              required
+            />
+          </div>
+        </div>
+
+        <div className="form-group row">
+          <label htmlFor="description" className="col-md-2 col-form-label">
+            {t('Description')}
+          </label>
+          <div className="col-md-4">
+            <textarea className="form-control" name="description" value={currentDescription} onChange={onChangeDescriptionHandler} />
+          </div>
+        </div>
+
+        <div className="form-group row">
+          <label htmlFor="parent" className="col-md-2 col-form-label">
+            {t('admin:user_group_management.parent_group')}
+          </label>
+          <div className="dropdown col-md-4">
+            <button
+              type="button"
+              id="dropdownMenuButton"
+              data-toggle="dropdown"
+              className={`
+                btn btn-outline-secondary dropdown-toggle ${selectableParentUserGroups != null && selectableParentUserGroups.length > 0 ? '' : 'disabled'}
+              `}
+            >
+              {selectedParent?.name ?? t('admin:user_group_management.select_parent_group')}
+            </button>
+            <div className="dropdown-menu" aria-labelledby="dropdownMenuButton">
+              {
+                (selectableParentUserGroups != null && selectableParentUserGroups.length > 0) && (
+                  <>
+                    {
+                      selectableParentUserGroups.map(userGroup => (
+                        <button
+                          key={userGroup._id}
+                          type="button"
+                          className={`dropdown-item ${selectedParent?._id === userGroup._id ? 'active' : ''}`}
+                          onClick={() => onChangeParerentButtonHandler(userGroup)}
+                        >
+                          {userGroup.name}
+                        </button>
+                      ))
+                    }
+                  </>
+                )
+              }
+
+              <div className="dropdown-divider" />
+
+              <button
+                className="dropdown-item"
+                type="button"
+                onClick={() => { setSelectedParent(undefined) }}
+              >{t('admin:user_group_management.release_parent_group')}
+              </button>
+            </div>
+          </div>
+        </div>
+
+        <div className="form-group row">
+          <div className="offset-md-2 col-md-10">
+            <button type="submit" className="btn btn-primary">
+              {submitButtonLabel}
+            </button>
+          </div>
+        </div>
+      </fieldset>
+    </form>
+  );
+};
+
+export default UserGroupForm;

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

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

+ 0 - 152
packages/app/src/components/Admin/UserGroup/UserGroupPage.jsx

@@ -1,152 +0,0 @@
-import React, { Fragment } from 'react';
-import PropTypes from 'prop-types';
-
-import UserGroupTable from './UserGroupTable';
-import UserGroupCreateForm from './UserGroupCreateForm';
-import UserGroupDeleteModal from './UserGroupDeleteModal';
-
-import { withUnstatedContainers } from '../../UnstatedUtils';
-import AppContainer from '~/client/services/AppContainer';
-import { toastSuccess, toastError } from '~/client/util/apiNotification';
-
-class UserGroupPage extends React.Component {
-
-  constructor(props) {
-    super(props);
-
-    this.state = {
-      userGroups: [],
-      userGroupRelations: [],
-      selectedUserGroup: undefined, // not null but undefined (to use defaultProps in UserGroupDeleteModal)
-      isDeleteModalShow: false,
-    };
-
-    this.xss = window.xss;
-
-    this.showDeleteModal = this.showDeleteModal.bind(this);
-    this.hideDeleteModal = this.hideDeleteModal.bind(this);
-    this.addUserGroup = this.addUserGroup.bind(this);
-    this.deleteUserGroupById = this.deleteUserGroupById.bind(this);
-  }
-
-  async componentDidMount() {
-    await this.syncUserGroupAndRelations();
-  }
-
-  async showDeleteModal(group) {
-    try {
-      await this.syncUserGroupAndRelations();
-
-      this.setState({
-        selectedUserGroup: group,
-        isDeleteModalShow: true,
-      });
-    }
-    catch (err) {
-      toastError(err);
-    }
-  }
-
-  hideDeleteModal() {
-    this.setState({
-      selectedUserGroup: undefined,
-      isDeleteModalShow: false,
-    });
-  }
-
-  addUserGroup(userGroup, users) {
-    this.setState((prevState) => {
-      const userGroupRelations = Object.assign(prevState.userGroupRelations, {
-        [userGroup._id]: users,
-      });
-
-      return {
-        userGroups: [...prevState.userGroups, userGroup],
-        userGroupRelations,
-      };
-    });
-  }
-
-  async deleteUserGroupById({ deleteGroupId, actionName, transferToUserGroupId }) {
-    try {
-      const res = await this.props.appContainer.apiv3.delete(`/user-groups/${deleteGroupId}`, {
-        actionName,
-        transferToUserGroupId,
-      });
-
-      this.setState((prevState) => {
-        const userGroups = prevState.userGroups.filter((userGroup) => {
-          return userGroup._id !== deleteGroupId;
-        });
-
-        delete prevState.userGroupRelations[deleteGroupId];
-
-        return {
-          userGroups,
-          userGroupRelations: prevState.userGroupRelations,
-          selectedUserGroup: undefined,
-          isDeleteModalShow: false,
-        };
-      });
-
-      toastSuccess(`Deleted a group "${this.xss.process(res.data.userGroup.name)}"`);
-    }
-    catch (err) {
-      toastError(new Error('Unable to delete the group'));
-    }
-  }
-
-  async syncUserGroupAndRelations() {
-    try {
-      const userGroupsRes = await this.props.appContainer.apiv3.get('/user-groups', { pagination: false });
-      const userGroupRelationsRes = await this.props.appContainer.apiv3.get('/user-group-relations');
-
-      this.setState({
-        userGroups: userGroupsRes.data.userGroups,
-        userGroupRelations: userGroupRelationsRes.data.userGroupRelations,
-      });
-    }
-    catch (err) {
-      toastError(err);
-    }
-  }
-
-  render() {
-    const { isAclEnabled } = this.props.appContainer.config;
-
-    return (
-      <Fragment>
-        <UserGroupCreateForm
-          isAclEnabled={isAclEnabled}
-          onCreate={this.addUserGroup}
-        />
-        <UserGroupTable
-          userGroups={this.state.userGroups}
-          isAclEnabled={isAclEnabled}
-          onDelete={this.showDeleteModal}
-          userGroupRelations={this.state.userGroupRelations}
-        />
-        <UserGroupDeleteModal
-          userGroups={this.state.userGroups}
-          deleteUserGroup={this.state.selectedUserGroup}
-          onDelete={this.deleteUserGroupById}
-          isShow={this.state.isDeleteModalShow}
-          onShow={this.showDeleteModal}
-          onHide={this.hideDeleteModal}
-        />
-      </Fragment>
-    );
-  }
-
-}
-
-/**
- * Wrapper component for using unstated
- */
-const UserGroupPageWrapper = withUnstatedContainers(UserGroupPage, [AppContainer]);
-
-UserGroupPage.propTypes = {
-  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
-};
-
-export default UserGroupPageWrapper;

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

@@ -0,0 +1,199 @@
+import React, { FC, useState, useCallback } from 'react';
+import { useTranslation } from 'react-i18next';
+
+import UserGroupTable from './UserGroupTable';
+import UserGroupModal from './UserGroupModal';
+import UserGroupDeleteModal from './UserGroupDeleteModal';
+
+import { toastSuccess, toastError } from '~/client/util/apiNotification';
+import { IUserGroup, IUserGroupHasId } from '~/interfaces/user';
+import Xss from '~/services/xss';
+import { CustomWindow } from '~/interfaces/global';
+import { apiv3Delete, apiv3Post, apiv3Put } from '~/client/util/apiv3-client';
+import { useSWRxUserGroupList, useSWRxChildUserGroupList, useSWRxUserGroupRelationList } from '~/stores/user-group';
+import { useIsAclEnabled } from '~/stores/context';
+
+const UserGroupPage: FC = () => {
+  const xss: Xss = (window as CustomWindow).xss;
+  const { t } = useTranslation();
+
+  const { data: isAclEnabled } = useIsAclEnabled();
+
+  /*
+   * Fetch
+   */
+  const { data: userGroupList, mutate: mutateUserGroups } = useSWRxUserGroupList();
+  const userGroups = userGroupList != null ? userGroupList : [];
+  const userGroupIds = userGroups.map(group => group._id);
+
+  const { data: userGroupRelationList } = useSWRxUserGroupRelationList(userGroupIds);
+  const userGroupRelations = userGroupRelationList != null ? userGroupRelationList : [];
+
+  const { data: childUserGroupsList } = useSWRxChildUserGroupList(userGroupIds);
+  const childUserGroups = childUserGroupsList != null ? childUserGroupsList.childUserGroups : [];
+
+  /*
+   * State
+   */
+  const [selectedUserGroup, setSelectedUserGroup] = useState<IUserGroupHasId | undefined>(undefined); // not null but undefined (to use defaultProps in UserGroupDeleteModal)
+  const [isCreateModalShown, setCreateModalShown] = useState<boolean>(false);
+  const [isUpdateModalShown, setUpdateModalShown] = useState<boolean>(false);
+  const [isDeleteModalShown, setDeleteModalShown] = useState<boolean>(false);
+
+  /*
+   * Functions
+   */
+  const showCreateModal = useCallback(() => {
+    setCreateModalShown(true);
+  }, [setCreateModalShown]);
+
+  const hideCreateModal = useCallback(() => {
+    setCreateModalShown(false);
+  }, [setCreateModalShown]);
+
+  const showUpdateModal = useCallback((group: IUserGroupHasId) => {
+    setUpdateModalShown(true);
+    setSelectedUserGroup(group);
+  }, [setUpdateModalShown]);
+
+  const hideUpdateModal = useCallback(() => {
+    setUpdateModalShown(false);
+    setSelectedUserGroup(undefined);
+  }, [setUpdateModalShown]);
+
+  const syncUserGroupAndRelations = useCallback(async() => {
+    try {
+      await mutateUserGroups();
+    }
+    catch (err) {
+      toastError(err);
+    }
+  }, [mutateUserGroups]);
+
+  const showDeleteModal = useCallback(async(group: IUserGroupHasId) => {
+    try {
+      await syncUserGroupAndRelations();
+
+      setSelectedUserGroup(group);
+      setDeleteModalShown(true);
+    }
+    catch (err) {
+      toastError(err);
+    }
+  }, [syncUserGroupAndRelations]);
+
+  const hideDeleteModal = useCallback(() => {
+    setSelectedUserGroup(undefined);
+    setDeleteModalShown(false);
+  }, []);
+
+  const createUserGroup = useCallback(async(userGroupData: IUserGroup) => {
+    try {
+      await apiv3Post('/user-groups', {
+        name: userGroupData.name,
+        description: userGroupData.description,
+      });
+
+      toastSuccess(t('toaster.update_successed', { target: t('UserGroup') }));
+
+      // mutate
+      await mutateUserGroups();
+
+      hideCreateModal();
+    }
+    catch (err) {
+      toastError(err);
+    }
+  }, [t, mutateUserGroups, hideCreateModal]);
+
+  const updateUserGroup = useCallback(async(userGroupData: IUserGroupHasId) => {
+    try {
+      await apiv3Put(`/user-groups/${userGroupData._id}`, {
+        name: userGroupData.name,
+        description: userGroupData.description,
+      });
+
+      toastSuccess(t('toaster.update_successed', { target: t('UserGroup') }));
+
+      // mutate
+      await mutateUserGroups();
+
+      hideUpdateModal();
+    }
+    catch (err) {
+      toastError(err);
+    }
+  }, [t, mutateUserGroups, hideUpdateModal]);
+
+  const deleteUserGroupById = useCallback(async(deleteGroupId: string, actionName: string, transferToUserGroupId: string) => {
+    try {
+      const res = await apiv3Delete(`/user-groups/${deleteGroupId}`, {
+        actionName,
+        transferToUserGroupId,
+      });
+
+      // sync
+      await mutateUserGroups();
+
+      setSelectedUserGroup(undefined);
+      setDeleteModalShown(false);
+
+      toastSuccess(`Deleted ${res.data.userGroups.length} groups.`);
+    }
+    catch (err) {
+      toastError(new Error('Unable to delete the groups'));
+    }
+  }, [mutateUserGroups]);
+
+  return (
+    <div data-testid="admin-user-groups">
+      {
+        isAclEnabled ? (
+          <div className="mb-3">
+            <button type="button" className="btn btn-outline-secondary" onClick={showCreateModal}>
+              {t('admin:user_group_management.create_group')}
+            </button>
+          </div>
+        ) : (
+          t('admin:user_group_management.deny_create_group')
+        )
+      }
+
+      <UserGroupModal
+        buttonLabel={t('Create')}
+        onClickSubmit={createUserGroup}
+        isShow={isCreateModalShown}
+        onHide={hideCreateModal}
+      />
+
+      <UserGroupModal
+        userGroup={selectedUserGroup}
+        buttonLabel={t('Update')}
+        onClickSubmit={updateUserGroup}
+        isShow={isUpdateModalShown}
+        onHide={hideUpdateModal}
+      />
+
+      <UserGroupTable
+        headerLabel={t('admin:user_group_management.group_list')}
+        userGroups={userGroups}
+        childUserGroups={childUserGroups}
+        isAclEnabled={isAclEnabled ?? false}
+        onEdit={showUpdateModal}
+        onDelete={showDeleteModal}
+        userGroupRelations={userGroupRelations}
+      />
+
+      <UserGroupDeleteModal
+        userGroups={userGroups}
+        deleteUserGroup={selectedUserGroup}
+        onDelete={deleteUserGroupById}
+        isShow={isDeleteModalShown}
+        onShow={showDeleteModal}
+        onHide={hideDeleteModal}
+      />
+    </div>
+  );
+};
+
+export default UserGroupPage;

+ 0 - 157
packages/app/src/components/Admin/UserGroup/UserGroupTable.jsx

@@ -1,157 +0,0 @@
-import React, { Fragment } from 'react';
-import PropTypes from 'prop-types';
-import { withTranslation } from 'react-i18next';
-import dateFnsFormat from 'date-fns/format';
-
-import { withUnstatedContainers } from '../../UnstatedUtils';
-import AppContainer from '~/client/services/AppContainer';
-
-class UserGroupTable extends React.Component {
-
-  constructor(props) {
-    super(props);
-
-    this.xss = window.xss;
-
-    this.state = {
-      userGroups: this.props.userGroups,
-      userGroupMap: {},
-    };
-
-    this.generateUserGroupMap = this.generateUserGroupMap.bind(this);
-    this.onDelete = this.onDelete.bind(this);
-  }
-
-  componentWillMount() {
-    const userGroupMap = this.generateUserGroupMap(this.props.userGroups, this.props.userGroupRelations);
-    this.setState({ userGroupMap });
-  }
-
-  componentWillReceiveProps(nextProps) {
-    const { userGroups, userGroupRelations } = nextProps;
-    const userGroupMap = this.generateUserGroupMap(userGroups, userGroupRelations);
-
-    this.setState({
-      userGroups,
-      userGroupMap,
-    });
-  }
-
-  generateUserGroupMap(userGroups, userGroupRelations) {
-    const userGroupMap = {};
-    userGroupRelations.forEach((relation) => {
-      const group = relation.relatedGroup;
-
-      const users = userGroupMap[group] || [];
-      users.push(relation.relatedUser);
-
-      // register
-      userGroupMap[group] = users;
-    });
-
-    return userGroupMap;
-  }
-
-  onDelete(e) {
-    const { target } = e;
-    const groupId = target.getAttribute('data-user-group-id');
-    const group = this.state.userGroups.find((group) => {
-      return group._id === groupId;
-    });
-
-    this.props.onDelete(group);
-  }
-
-  render() {
-    const { t } = this.props;
-
-    return (
-      <Fragment>
-        <h2>{t('admin:user_group_management.group_list')}</h2>
-
-        <table className="table table-bordered table-user-list">
-          <thead>
-            <tr>
-              <th>{t('Name')}</th>
-              <th>{t('User')}</th>
-              <th width="100px">{t('Created')}</th>
-              <th width="70px"></th>
-            </tr>
-          </thead>
-          <tbody>
-            {this.state.userGroups.map((group) => {
-              const users = this.state.userGroupMap[group._id];
-
-              return (
-                <tr key={group._id}>
-                  {this.props.isAclEnabled
-                    ? (
-                      <td><a href={`/admin/user-group-detail/${group._id}`}>{this.xss.process(group.name)}</a></td>
-                    )
-                    : (
-                      <td>{this.xss.process(group.name)}</td>
-                    )
-                  }
-                  <td>
-                    <ul className="list-inline">
-                      {users != null && users.map((user) => {
-                        return <li key={user._id} className="list-inline-item badge badge-pill badge-warning">{this.xss.process(user.username)}</li>;
-                      })}
-                    </ul>
-                  </td>
-                  <td>{dateFnsFormat(new Date(group.createdAt), 'yyyy-MM-dd')}</td>
-                  {this.props.isAclEnabled
-                    ? (
-                      <td>
-                        <div className="btn-group admin-group-menu">
-                          <button
-                            type="button"
-                            id={`admin-group-menu-button-${group._id}`}
-                            className="btn btn-outline-secondary btn-sm dropdown-toggle"
-                            data-toggle="dropdown"
-                          >
-                            <i className="icon-settings"></i>
-                          </button>
-                          <div className="dropdown-menu" role="menu" aria-labelledby={`admin-group-menu-button-${group._id}`}>
-                            <a className="dropdown-item" href={`/admin/user-group-detail/${group._id}`}>
-                              <i className="icon-fw icon-note"></i> {t('Edit')}
-                            </a>
-                            <button className="dropdown-item" type="button" role="button" onClick={this.onDelete} data-user-group-id={group._id}>
-                              <i className="icon-fw icon-fire text-danger"></i> {t('Delete')}
-                            </button>
-                          </div>
-                        </div>
-                      </td>
-                    )
-                    : (
-                      <td></td>
-                    )
-                  }
-                </tr>
-              );
-            })}
-          </tbody>
-        </table>
-      </Fragment>
-    );
-  }
-
-}
-
-/**
- * Wrapper component for using unstated
- */
-const UserGroupTableWrapper = withUnstatedContainers(UserGroupTable, [AppContainer]);
-
-
-UserGroupTable.propTypes = {
-  t: PropTypes.func.isRequired, // i18next
-  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
-
-  userGroups: PropTypes.arrayOf(PropTypes.object).isRequired,
-  userGroupRelations: PropTypes.arrayOf(PropTypes.object).isRequired,
-  isAclEnabled: PropTypes.bool.isRequired,
-  onDelete: PropTypes.func.isRequired,
-};
-
-export default withTranslation()(UserGroupTableWrapper);

+ 202 - 0
packages/app/src/components/Admin/UserGroup/UserGroupTable.tsx

@@ -0,0 +1,202 @@
+import React, {
+  FC, useState, useCallback, useEffect,
+} from 'react';
+import { useTranslation } from 'react-i18next';
+import { TFunctionResult } from 'i18next';
+import dateFnsFormat from 'date-fns/format';
+
+import Xss from '~/services/xss';
+import { IUserGroupHasId, IUserGroupRelation, IUserHasId } from '~/interfaces/user';
+import { CustomWindow } from '~/interfaces/global';
+
+type Props = {
+  headerLabel?: TFunctionResult,
+  userGroups: IUserGroupHasId[],
+  userGroupRelations: IUserGroupRelation[],
+  childUserGroups: IUserGroupHasId[],
+  isAclEnabled: boolean,
+  onEdit?: (userGroup: IUserGroupHasId) => void | Promise<void>,
+  onDelete?: (userGroup: IUserGroupHasId) => void | Promise<void>,
+};
+
+/*
+ * Utility
+ */
+const generateGroupIdToUsersMap = (userGroupRelations: IUserGroupRelation[]): Record<string, Partial<IUserHasId>[]> => {
+  const userGroupMap = {};
+  userGroupRelations.forEach((relation) => {
+    const group = relation.relatedGroup as string; // must be an id of related group
+
+    const users: Partial<IUserHasId>[] = userGroupMap[group] || [];
+    users.push(relation.relatedUser as IUserHasId);
+
+    // register
+    userGroupMap[group] = users;
+  });
+
+  return userGroupMap;
+};
+
+const generateGroupIdToChildGroupsMap = (childUserGroups: IUserGroupHasId[]): Record<string, IUserGroupHasId[]> => {
+  const map = {};
+  childUserGroups.forEach((group) => {
+    const parentId = group.parent as string; // must be an id
+
+    const groups: Partial<IUserGroupHasId>[] = map[parentId] || [];
+    groups.push(group);
+
+    // register
+    map[parentId] = groups;
+  });
+
+  return map;
+};
+
+
+const UserGroupTable: FC<Props> = (props: Props) => {
+  const xss: Xss = (window as CustomWindow).xss;
+  const { t } = useTranslation();
+
+  /*
+   * State
+   */
+  const [groupIdToUsersMap, setGroupIdToUsersMap] = useState(generateGroupIdToUsersMap(props.userGroupRelations));
+  const [groupIdToChildGroupsMap, setGroupIdToChildGroupsMap] = useState(generateGroupIdToChildGroupsMap(props.childUserGroups));
+
+  /*
+   * Function
+   */
+  const findUserGroup = (e: React.ChangeEvent<HTMLInputElement>): IUserGroupHasId | undefined => {
+    const groupId = e.target.getAttribute('data-user-group-id');
+    return props.userGroups.find((group) => {
+      return group._id === groupId;
+    });
+  };
+
+  const onClickEdit = (e) => {
+    if (props.onEdit == null) {
+      return;
+    }
+
+    const userGroup = findUserGroup(e);
+    if (userGroup == null) {
+      return;
+    }
+
+    props.onEdit(userGroup);
+  };
+
+  const onClickDelete = (e) => { // no preventDefault
+    if (props.onDelete == null) {
+      return;
+    }
+
+    const userGroup = findUserGroup(e);
+    if (userGroup == null) {
+      return;
+    }
+
+    props.onDelete(userGroup);
+  };
+
+  /*
+   * useEffect
+   */
+  useEffect(() => {
+    setGroupIdToUsersMap(generateGroupIdToUsersMap(props.userGroupRelations));
+    setGroupIdToChildGroupsMap(generateGroupIdToChildGroupsMap(props.childUserGroups));
+  }, [props.userGroupRelations, props.childUserGroups]);
+
+  return (
+    <>
+      <h2>{props.headerLabel}</h2>
+
+      <table className="table table-bordered table-user-list">
+        <thead>
+          <tr>
+            <th>{t('Name')}</th>
+            <th>{t('Description')}</th>
+            <th>{t('User')}</th>
+            <th>{t('ChildUserGroup')}</th>
+            <th style={{ width: 100 }}>{t('Created')}</th>
+            <th style={{ width: 70 }}></th>
+          </tr>
+        </thead>
+        <tbody>
+          {props.userGroups.map((group) => {
+            const users = groupIdToUsersMap[group._id];
+
+            return (
+              <tr key={group._id}>
+                {props.isAclEnabled
+                  ? (
+                    <td><a href={`/admin/user-group-detail/${group._id}`}>{xss.process(group.name)}</a></td>
+                  )
+                  : (
+                    <td>{xss.process(group.name)}</td>
+                  )
+                }
+                <td>{xss.process(group.description)}</td>
+                <td>
+                  <ul className="list-inline">
+                    {users != null && users.map((user) => {
+                      return <li key={user._id} className="list-inline-item badge badge-pill badge-warning">{xss.process(user.username)}</li>;
+                    })}
+                  </ul>
+                </td>
+                <td>
+                  <ul className="list-inline">
+                    {groupIdToChildGroupsMap[group._id] != null && groupIdToChildGroupsMap[group._id].map((group) => {
+                      return (
+                        <li key={group._id} className="list-inline-item badge badge-success">
+                          {props.isAclEnabled
+                            ? (
+                              <a href={`/admin/user-group-detail/${group._id}`}>{xss.process(group.name)}</a>
+                            )
+                            : (
+                              <p>{xss.process(group.name)}</p>
+                            )
+                          }
+                        </li>
+                      );
+                    })}
+                  </ul>
+                </td>
+                <td>{dateFnsFormat(new Date(group.createdAt), 'yyyy-MM-dd')}</td>
+                {props.isAclEnabled
+                  ? (
+                    <td>
+                      <div className="btn-group admin-group-menu">
+                        <button
+                          type="button"
+                          id={`admin-group-menu-button-${group._id}`}
+                          className="btn btn-outline-secondary btn-sm dropdown-toggle"
+                          data-toggle="dropdown"
+                        >
+                          <i className="icon-settings"></i>
+                        </button>
+                        <div className="dropdown-menu" role="menu" aria-labelledby={`admin-group-menu-button-${group._id}`}>
+                          <button className="dropdown-item" type="button" role="button" onClick={onClickEdit} data-user-group-id={group._id}>
+                            <i className="icon-fw icon-note"></i> {t('Edit')}
+                          </button>
+                          <button className="dropdown-item" type="button" role="button" onClick={onClickDelete} data-user-group-id={group._id}>
+                            <i className="icon-fw icon-fire text-danger"></i> {t('Delete')}
+                          </button>
+                        </div>
+                      </div>
+                    </td>
+                  )
+                  : (
+                    <td></td>
+                  )
+                }
+              </tr>
+            );
+          })}
+        </tbody>
+      </table>
+    </>
+  );
+};
+
+export default UserGroupTable;

+ 0 - 49
packages/app/src/components/Admin/UserGroupDetail/UserGroupDetailPage.jsx

@@ -1,49 +0,0 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-import { withTranslation } from 'react-i18next';
-
-import UserGroupEditForm from './UserGroupEditForm';
-import UserGroupUserTable from './UserGroupUserTable';
-import UserGroupUserModal from './UserGroupUserModal';
-import UserGroupPageList from './UserGroupPageList';
-import { withUnstatedContainers } from '../../UnstatedUtils';
-import AppContainer from '~/client/services/AppContainer';
-
-class UserGroupDetailPage extends React.Component {
-
-  render() {
-    const { t } = this.props;
-
-    return (
-      <div>
-        <a href="/admin/user-groups" className="btn btn-outline-secondary">
-          <i className="icon-fw ti-arrow-left" aria-hidden="true"></i>
-          {t('admin:user_group_management.back_to_list')}
-        </a>
-        <div className="mt-4 form-box">
-          <UserGroupEditForm />
-        </div>
-        <h2 className="admin-setting-header mt-4">{t('admin:user_group_management.user_list')}</h2>
-        <UserGroupUserTable />
-        <UserGroupUserModal />
-        <h2 className="admin-setting-header mt-4">{t('Page')}</h2>
-        <div className="page-list">
-          <UserGroupPageList />
-        </div>
-      </div>
-    );
-  }
-
-}
-
-UserGroupDetailPage.propTypes = {
-  t: PropTypes.func.isRequired, // i18next
-  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
-};
-
-/**
- * Wrapper component for using unstated
- */
-const UserGroupDetailPageWrapper = withUnstatedContainers(UserGroupDetailPage, [AppContainer]);
-
-export default withTranslation()(UserGroupDetailPageWrapper);

+ 328 - 0
packages/app/src/components/Admin/UserGroupDetail/UserGroupDetailPage.tsx

@@ -0,0 +1,328 @@
+import React, {
+  FC, useState, useCallback,
+} from 'react';
+import { useTranslation } from 'react-i18next';
+
+import UserGroupForm from '../UserGroup/UserGroupForm';
+import UserGroupTable from '../UserGroup/UserGroupTable';
+import UserGroupModal from '../UserGroup/UserGroupModal';
+import UserGroupDeleteModal from '../UserGroup/UserGroupDeleteModal';
+import UserGroupDropdown from '../UserGroup/UserGroupDropdown';
+import UserGroupUserTable from './UserGroupUserTable';
+import UserGroupUserModal from './UserGroupUserModal';
+import UserGroupPageList from './UserGroupPageList';
+
+import {
+  apiv3Get, apiv3Put, apiv3Delete, apiv3Post,
+} from '~/client/util/apiv3-client';
+import { toastSuccess, toastError } from '~/client/util/apiNotification';
+import { IPageHasId } from '~/interfaces/page';
+import {
+  IUserGroup, IUserGroupHasId,
+} from '~/interfaces/user';
+import {
+  useSWRxUserGroupPages, useSWRxUserGroupRelationList, useSWRxChildUserGroupList,
+  useSWRxSelectableParentUserGroups, useSWRxSelectableChildUserGroups, useSWRxAncestorUserGroups,
+} from '~/stores/user-group';
+import { useIsAclEnabled } from '~/stores/context';
+
+const UserGroupDetailPage: FC = () => {
+  const { t } = useTranslation();
+  const adminUserGroupDetailElem = document.getElementById('admin-user-group-detail');
+
+  /*
+   * State (from AdminUserGroupDetailContainer)
+   */
+  const [userGroup, setUserGroup] = useState<IUserGroupHasId>(JSON.parse(adminUserGroupDetailElem?.getAttribute('data-user-group') || 'null'));
+  const [relatedPages, setRelatedPages] = useState<IPageHasId[]>([]); // For page list
+  const [searchType, setSearchType] = useState<string>('partial');
+  const [isAlsoMailSearched, setAlsoMailSearched] = useState<boolean>(false);
+  const [isAlsoNameSearched, setAlsoNameSearched] = useState<boolean>(false);
+  const [selectedUserGroup, setSelectedUserGroup] = useState<IUserGroupHasId | undefined>(undefined); // not null but undefined (to use defaultProps in UserGroupDeleteModal)
+  const [isCreateModalShown, setCreateModalShown] = useState<boolean>(false);
+  const [isUpdateModalShown, setUpdateModalShown] = useState<boolean>(false);
+  const [isDeleteModalShown, setDeleteModalShown] = useState<boolean>(false);
+
+  /*
+   * Fetch
+   */
+  const { data: userGroupPages } = useSWRxUserGroupPages(userGroup._id, 10, 0);
+
+  const { data: childUserGroupsList, mutate: mutateChildUserGroups } = useSWRxChildUserGroupList([userGroup._id], true);
+  const childUserGroups = childUserGroupsList != null ? childUserGroupsList.childUserGroups : [];
+  const grandChildUserGroups = childUserGroupsList != null ? childUserGroupsList.grandChildUserGroups : [];
+  const childUserGroupIds = childUserGroups.map(group => group._id);
+
+  const { data: userGroupRelationList, mutate: mutateUserGroupRelations } = useSWRxUserGroupRelationList(childUserGroupIds);
+  const childUserGroupRelations = userGroupRelationList != null ? userGroupRelationList : [];
+
+  const { data: selectableParentUserGroups, mutate: mutateSelectableParentUserGroups } = useSWRxSelectableParentUserGroups(userGroup._id);
+  const { data: selectableChildUserGroups, mutate: mutateSelectableChildUserGroups } = useSWRxSelectableChildUserGroups(userGroup._id);
+
+  const { data: ancestorUserGroups, mutate: mutateAncestorUserGroups } = useSWRxAncestorUserGroups(userGroup._id);
+
+  const { data: isAclEnabled } = useIsAclEnabled();
+
+  /*
+   * Function
+   */
+  // TODO 85062: old name: switchIsAlsoMailSearched
+  const toggleIsAlsoMailSearched = useCallback(() => {
+    setAlsoMailSearched(prev => !prev);
+  }, []);
+
+  // TODO 85062: old name: switchIsAlsoNameSearched
+  const toggleAlsoNameSearched = useCallback(() => {
+    setAlsoNameSearched(prev => !prev);
+  }, []);
+
+  const switchSearchType = useCallback((searchType) => {
+    setSearchType(searchType);
+  }, []);
+
+  const updateUserGroup = useCallback(async(UserGroupData: Partial<IUserGroup>) => {
+    try {
+      const res = await apiv3Put<{ userGroup: IUserGroupHasId }>(`/user-groups/${userGroup._id}`, {
+        name: UserGroupData.name,
+        description: UserGroupData.description,
+        parentId: UserGroupData.parent,
+      });
+      const { userGroup: newUserGroup } = res.data;
+      setUserGroup(newUserGroup);
+
+      // mutate
+      mutateAncestorUserGroups();
+      mutateSelectableChildUserGroups();
+      mutateSelectableParentUserGroups();
+
+      toastSuccess(t('toaster.update_successed', { target: t('UserGroup') }));
+    }
+    catch (err) {
+      toastError(err);
+    }
+  }, [t, userGroup._id, setUserGroup, mutateAncestorUserGroups]);
+
+  const fetchApplicableUsers = useCallback(async(searchWord) => {
+    const res = await apiv3Get(`/user-groups/${userGroup._id}/unrelated-users`, {
+      searchWord,
+      searchType,
+      isAlsoMailSearched,
+      isAlsoNameSearched,
+    });
+
+    const { users } = res.data;
+
+    return users;
+  }, [searchType, isAlsoMailSearched, isAlsoNameSearched]);
+
+  // TODO 85062: will be used in UserGroupUserFormByInput
+  const addUserByUsername = useCallback(async(username: string) => {
+    await apiv3Post(`/user-groups/${userGroup._id}/users/${username}`);
+    mutateUserGroupRelations();
+  }, [userGroup, mutateUserGroupRelations]);
+
+  const removeUserByUsername = useCallback(async(username: string) => {
+    await apiv3Delete(`/user-groups/${userGroup._id}/users/${username}`);
+    mutateUserGroupRelations();
+  }, [userGroup, mutateUserGroupRelations]);
+
+  const showUpdateModal = useCallback((group: IUserGroupHasId) => {
+    setUpdateModalShown(true);
+    setSelectedUserGroup(group);
+  }, [setUpdateModalShown]);
+
+  const hideUpdateModal = useCallback(() => {
+    setUpdateModalShown(false);
+    setSelectedUserGroup(undefined);
+  }, [setUpdateModalShown]);
+
+  const updateChildUserGroup = useCallback(async(userGroupData: IUserGroupHasId) => {
+    try {
+      await apiv3Put(`/user-groups/${userGroupData._id}`, {
+        name: userGroupData.name,
+        description: userGroupData.description,
+        parentId: userGroupData.parent,
+      });
+
+      toastSuccess(t('toaster.update_successed', { target: t('UserGroup') }));
+
+      // mutate
+      mutateChildUserGroups();
+
+      hideUpdateModal();
+    }
+    catch (err) {
+      toastError(err);
+    }
+  }, [t, mutateChildUserGroups, hideUpdateModal]);
+
+  const onClickAddChildButtonHandler = async(selectedUserGroup: IUserGroupHasId) => {
+    try {
+      await apiv3Put(`/user-groups/${selectedUserGroup._id}`, {
+        name: selectedUserGroup.name,
+        description: selectedUserGroup.description,
+        parentId: userGroup._id,
+        forceUpdateParents: false,
+      });
+
+      // mutate
+      mutateChildUserGroups();
+      mutateSelectableChildUserGroups();
+      mutateSelectableParentUserGroups();
+
+      toastSuccess(t('toaster.update_successed', { target: t('UserGroup') }));
+    }
+    catch (err) {
+      toastError(err);
+    }
+  };
+
+  const showCreateModal = useCallback(() => {
+    setCreateModalShown(true);
+  }, [setCreateModalShown]);
+
+  const hideCreateModal = useCallback(() => {
+    setCreateModalShown(false);
+  }, [setCreateModalShown]);
+
+  const createChildUserGroup = useCallback(async(userGroupData: IUserGroup) => {
+    try {
+      await apiv3Post('/user-groups', {
+        name: userGroupData.name,
+        description: userGroupData.description,
+        parentId: userGroup._id,
+      });
+
+      toastSuccess(t('toaster.update_successed', { target: t('UserGroup') }));
+
+      // mutate
+      mutateChildUserGroups();
+      mutateSelectableChildUserGroups();
+      mutateSelectableParentUserGroups();
+
+      hideCreateModal();
+    }
+    catch (err) {
+      toastError(err);
+    }
+  }, [t, userGroup, mutateChildUserGroups, mutateSelectableChildUserGroups, mutateSelectableParentUserGroups, hideCreateModal]);
+
+  const showDeleteModal = useCallback(async(group: IUserGroupHasId) => {
+    setSelectedUserGroup(group);
+    setDeleteModalShown(true);
+  }, [setSelectedUserGroup, setDeleteModalShown]);
+
+  const hideDeleteModal = useCallback(() => {
+    setSelectedUserGroup(undefined);
+    setDeleteModalShown(false);
+  }, [setSelectedUserGroup, setDeleteModalShown]);
+
+  const deleteChildUserGroupById = useCallback(async(deleteGroupId: string, actionName: string, transferToUserGroupId: string) => {
+    try {
+      const res = await apiv3Delete(`/user-groups/${deleteGroupId}`, {
+        actionName,
+        transferToUserGroupId,
+      });
+
+      // sync
+      await mutateChildUserGroups();
+
+      setSelectedUserGroup(undefined);
+      setDeleteModalShown(false);
+
+      toastSuccess(`Deleted ${res.data.userGroups.length} groups.`);
+    }
+    catch (err) {
+      toastError(new Error('Unable to delete the groups'));
+    }
+  }, [mutateChildUserGroups, setSelectedUserGroup, setDeleteModalShown]);
+
+  /*
+   * Dependencies
+   */
+  if (userGroup == null) {
+    return <></>;
+  }
+
+  return (
+    <div>
+      <nav aria-label="breadcrumb">
+        <ol className="breadcrumb">
+          <li className="breadcrumb-item"><a href="/admin/user-groups">{t('admin:user_group_management.group_list')}</a></li>
+          {
+            ancestorUserGroups != null && ancestorUserGroups.length > 0 && (
+              ancestorUserGroups.map((ancestorUserGroup: IUserGroupHasId) => (
+                <li key={ancestorUserGroup._id} className={`breadcrumb-item ${ancestorUserGroup._id === userGroup._id ? 'active' : ''}`} aria-current="page">
+                  { ancestorUserGroup._id === userGroup._id ? (
+                    <>{ancestorUserGroup.name}</>
+                  ) : (
+                    <a href={`/admin/user-group-detail/${ancestorUserGroup._id}`}>{ancestorUserGroup.name}</a>
+                  )}
+                </li>
+              ))
+            )
+          }
+        </ol>
+      </nav>
+
+      <div className="mt-4 form-box">
+        <UserGroupForm
+          userGroup={userGroup}
+          selectableParentUserGroups={selectableParentUserGroups}
+          submitButtonLabel={t('Update')}
+          onSubmit={updateUserGroup}
+        />
+      </div>
+      <h2 className="admin-setting-header mt-4">{t('admin:user_group_management.user_list')}</h2>
+      <UserGroupUserTable />
+      <UserGroupUserModal />
+
+      <h2 className="admin-setting-header mt-4">{t('admin:user_group_management.child_group_list')}</h2>
+      <UserGroupDropdown
+        selectableUserGroups={selectableChildUserGroups}
+        onClickAddExistingUserGroupButtonHandler={onClickAddChildButtonHandler}
+        onClickCreateUserGroupButtonHandler={showCreateModal}
+      />
+
+      <UserGroupModal
+        userGroup={selectedUserGroup}
+        buttonLabel={t('Update')}
+        onClickSubmit={updateChildUserGroup}
+        isShow={isUpdateModalShown}
+        onHide={hideUpdateModal}
+      />
+
+      <UserGroupModal
+        buttonLabel={t('Create')}
+        onClickSubmit={createChildUserGroup}
+        isShow={isCreateModalShown}
+        onHide={hideCreateModal}
+      />
+
+      <UserGroupTable
+        userGroups={childUserGroups}
+        childUserGroups={grandChildUserGroups}
+        isAclEnabled={isAclEnabled ?? false}
+        onEdit={showUpdateModal}
+        onDelete={showDeleteModal}
+        userGroupRelations={childUserGroupRelations}
+      />
+
+      <UserGroupDeleteModal
+        userGroups={childUserGroups}
+        deleteUserGroup={selectedUserGroup}
+        onDelete={deleteChildUserGroupById}
+        isShow={isDeleteModalShown}
+        onShow={showDeleteModal}
+        onHide={hideDeleteModal}
+      />
+
+      <h2 className="admin-setting-header mt-4">{t('Page')}</h2>
+      <div className="page-list">
+        <UserGroupPageList />
+      </div>
+    </div>
+  );
+};
+
+export default UserGroupDetailPage;

+ 0 - 111
packages/app/src/components/Admin/UserGroupDetail/UserGroupEditForm.jsx

@@ -1,111 +0,0 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-import { withTranslation } from 'react-i18next';
-import dateFnsFormat from 'date-fns/format';
-
-import { withUnstatedContainers } from '../../UnstatedUtils';
-import AppContainer from '~/client/services/AppContainer';
-import AdminUserGroupDetailContainer from '~/client/services/AdminUserGroupDetailContainer';
-import { toastSuccess, toastError } from '~/client/util/apiNotification';
-
-class UserGroupEditForm extends React.Component {
-
-  constructor(props) {
-    super(props);
-
-    const { adminUserGroupDetailContainer } = props;
-    const { userGroup } = adminUserGroupDetailContainer.state;
-
-    this.state = {
-      name: userGroup.name,
-      nameCache: userGroup.name, // cache for name. update every submit
-    };
-
-    this.xss = window.xss;
-
-    this.changeUserGroupName = this.changeUserGroupName.bind(this);
-    this.handleSubmit = this.handleSubmit.bind(this);
-    this.validateForm = this.validateForm.bind(this);
-  }
-
-  changeUserGroupName(event) {
-    this.setState({
-      name: event.target.value,
-    });
-  }
-
-  async handleSubmit(e) {
-    e.preventDefault();
-
-    try {
-      const res = await this.props.adminUserGroupDetailContainer.updateUserGroup({
-        name: this.state.name,
-      });
-
-      toastSuccess(`Updated the group name to "${this.xss.process(res.data.userGroup.name)}"`);
-      this.setState({ nameCache: this.state.name });
-    }
-    catch (err) {
-      toastError(new Error('Unable to update the group name'));
-    }
-  }
-
-  validateForm() {
-    return (
-      this.state.name !== this.state.nameCache
-      && this.state.name !== ''
-    );
-  }
-
-  render() {
-    const { t, adminUserGroupDetailContainer } = this.props;
-
-    return (
-      <form onSubmit={this.handleSubmit}>
-        <fieldset>
-          <h2 className="admin-setting-header">{t('admin:user_group_management.basic_info')}</h2>
-          <div className="form-group row">
-            <label htmlFor="name" className="col-md-2 col-form-label">
-              {t('Name')}
-            </label>
-            <div className="col-md-4">
-              <input className="form-control" type="text" name="name" value={this.state.name} onChange={this.changeUserGroupName} />
-            </div>
-          </div>
-          <div className="form-group row">
-            <label className="col-md-2 col-form-label">{t('Created')}</label>
-            <div className="col-md-4">
-              <input
-                type="text"
-                className="form-control"
-                value={dateFnsFormat(new Date(adminUserGroupDetailContainer.state.userGroup.createdAt), 'yyyy-MM-dd')}
-                disabled
-              />
-            </div>
-          </div>
-          <div className="form-group row">
-            <div className="offset-md-2 col-md-10">
-              <button type="submit" className="btn btn-primary" disabled={!this.validateForm()}>
-                {t('Update')}
-              </button>
-            </div>
-          </div>
-        </fieldset>
-      </form>
-    );
-  }
-
-}
-
-UserGroupEditForm.propTypes = {
-  t: PropTypes.func.isRequired, // i18next
-  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
-  adminUserGroupDetailContainer: PropTypes.instanceOf(AdminUserGroupDetailContainer).isRequired,
-};
-
-/**
- * Wrapper component for using unstated
- */
-const UserGroupEditFormWrapper = withUnstatedContainers(UserGroupEditForm, [AppContainer, AdminUserGroupDetailContainer]);
-
-export default withTranslation()(UserGroupEditFormWrapper);

+ 2 - 2
packages/app/src/components/Admin/UserGroupDetail/UserGroupPageList.jsx

@@ -2,7 +2,7 @@ import React, { Fragment } from 'react';
 import PropTypes from 'prop-types';
 import PropTypes from 'prop-types';
 import { withTranslation } from 'react-i18next';
 import { withTranslation } from 'react-i18next';
 
 
-import Page from '../../PageList/Page';
+import PageListItemS from '../../PageList/PageListItemS';
 import PaginationWrapper from '../../PaginationWrapper';
 import PaginationWrapper from '../../PaginationWrapper';
 import { withUnstatedContainers } from '../../UnstatedUtils';
 import { withUnstatedContainers } from '../../UnstatedUtils';
 import AppContainer from '~/client/services/AppContainer';
 import AppContainer from '~/client/services/AppContainer';
@@ -57,7 +57,7 @@ class UserGroupPageList extends React.Component {
     return (
     return (
       <Fragment>
       <Fragment>
         <ul className="page-list-ul page-list-ul-flat mb-3">
         <ul className="page-list-ul page-list-ul-flat mb-3">
-          {this.state.currentPages.map(page => <li key={page._id}><Page page={page} /></li>)}
+          {this.state.currentPages.map(page => <li key={page._id}><PageListItemS page={page} /></li>)}
         </ul>
         </ul>
         {relatedPages.length === 0 ? <p>{t('admin:user_group_management.no_pages')}</p> : (
         {relatedPages.length === 0 ? <p>{t('admin:user_group_management.no_pages')}</p> : (
           <PaginationWrapper
           <PaginationWrapper

+ 2 - 2
packages/app/src/components/Admin/UserManagement.jsx

@@ -141,7 +141,7 @@ class UserManagement extends React.Component {
     );
     );
 
 
     return (
     return (
-      <Fragment>
+      <div data-testid="admin-users">
         {adminUsersContainer.state.userForPasswordResetModal != null
         {adminUsersContainer.state.userForPasswordResetModal != null
         && (
         && (
           <PasswordResetModal
           <PasswordResetModal
@@ -212,7 +212,7 @@ class UserManagement extends React.Component {
         <UserTable />
         <UserTable />
         {pager}
         {pager}
 
 
-      </Fragment>
+      </div>
     );
     );
   }
   }
 
 

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

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

+ 32 - 37
packages/app/src/components/BookmarkButtons.tsx

@@ -1,49 +1,39 @@
 import React, { FC, useState } from 'react';
 import React, { FC, useState } from 'react';
 
 
-import { Types } from 'mongoose';
 import { UncontrolledTooltip, Popover, PopoverBody } from 'reactstrap';
 import { UncontrolledTooltip, Popover, PopoverBody } from 'reactstrap';
 import { useTranslation } from 'react-i18next';
 import { useTranslation } from 'react-i18next';
 
 
+import { IUser } from '../interfaces/user';
+
 import UserPictureList from './User/UserPictureList';
 import UserPictureList from './User/UserPictureList';
-import { toastError } from '~/client/util/apiNotification';
 import { useIsGuestUser } from '~/stores/context';
 import { useIsGuestUser } from '~/stores/context';
-import { useSWRxBookmarksInfo } from '~/stores/bookmarks';
-import { apiv3Put } from '~/client/util/apiv3-client';
 
 
 interface Props {
 interface Props {
-  pageId: Types.ObjectId
+  bookmarkCount?: number
+  isBookmarked?: boolean
+  bookmarkedUsers?: IUser[]
+  hideTotalNumber?: boolean
+  onBookMarkClicked: ()=>void;
 }
 }
 
 
-const BookmarkButton: FC<Props> = (props: Props) => {
+const BookmarkButtons: FC<Props> = (props: Props) => {
   const { t } = useTranslation();
   const { t } = useTranslation();
-  const { pageId } = props;
+
+  const {
+    bookmarkCount, isBookmarked, bookmarkedUsers, hideTotalNumber,
+  } = props;
 
 
   const [isPopoverOpen, setIsPopoverOpen] = useState(false);
   const [isPopoverOpen, setIsPopoverOpen] = useState(false);
 
 
   const { data: isGuestUser } = useIsGuestUser();
   const { data: isGuestUser } = useIsGuestUser();
-  const { data: bookmarksInfo, mutate } = useSWRxBookmarksInfo(pageId);
-
-  const isBookmarked = bookmarksInfo?.isBookmarked != null ? bookmarksInfo.isBookmarked : false;
-  const sumOfBookmarks = bookmarksInfo?.sumOfBookmarks != null ? bookmarksInfo.sumOfBookmarks : 0;
-  const bookmarkedUsers = bookmarksInfo?.bookmarkedUsers != null ? bookmarksInfo.bookmarkedUsers : [];
 
 
   const togglePopover = () => {
   const togglePopover = () => {
     setIsPopoverOpen(!isPopoverOpen);
     setIsPopoverOpen(!isPopoverOpen);
   };
   };
 
 
   const handleClick = async() => {
   const handleClick = async() => {
-    if (isGuestUser) {
-      return;
-    }
-
-    try {
-      const res = await apiv3Put('/bookmarks', { pageId, bool: !isBookmarked });
-      if (res) {
-        mutate();
-      }
-    }
-    catch (err) {
-      toastError(err);
+    if (props.onBookMarkClicked != null) {
+      props.onBookMarkClicked();
     }
     }
   };
   };
 
 
@@ -56,7 +46,7 @@ const BookmarkButton: FC<Props> = (props: Props) => {
         className={`btn btn-bookmark border-0
         className={`btn btn-bookmark border-0
           ${isBookmarked ? 'active' : ''} ${isGuestUser ? 'disabled' : ''}`}
           ${isBookmarked ? 'active' : ''} ${isGuestUser ? 'disabled' : ''}`}
       >
       >
-        <i className="icon-star"></i>
+        <i className={`fa ${isBookmarked ? 'fa-bookmark' : 'fa-bookmark-o'}`}></i>
       </button>
       </button>
 
 
       {isGuestUser && (
       {isGuestUser && (
@@ -65,19 +55,24 @@ const BookmarkButton: FC<Props> = (props: Props) => {
         </UncontrolledTooltip>
         </UncontrolledTooltip>
       )}
       )}
 
 
-      <button type="button" id="po-total-bookmarks" className={`btn btn-bookmark border-0 total-bookmarks ${isBookmarked ? 'active' : ''}`}>
-        {sumOfBookmarks}
-      </button>
-
-      <Popover placement="bottom" isOpen={isPopoverOpen} target="po-total-bookmarks" toggle={togglePopover} trigger="legacy">
-        <PopoverBody className="seen-user-popover">
-          <div className="px-2 text-right user-list-content text-truncate text-muted">
-            {bookmarkedUsers.length ? <UserPictureList users={bookmarkedUsers} /> : t('No users have bookmarked yet')}
-          </div>
-        </PopoverBody>
-      </Popover>
+      { !hideTotalNumber && (
+        <>
+          <button type="button" id="po-total-bookmarks" className={`btn btn-bookmark border-0 total-bookmarks ${props.isBookmarked ? 'active' : ''}`}>
+            {bookmarkCount ?? 0}
+          </button>
+          { bookmarkedUsers != null && (
+            <Popover placement="bottom" isOpen={isPopoverOpen} target="po-total-bookmarks" toggle={togglePopover} trigger="legacy">
+              <PopoverBody className="user-list-popover">
+                <div className="px-2 text-right user-list-content text-truncate text-muted">
+                  {bookmarkedUsers.length ? <UserPictureList users={props.bookmarkedUsers} /> : t('No users have bookmarked yet')}
+                </div>
+              </PopoverBody>
+            </Popover>
+          ) }
+        </>
+      ) }
     </div>
     </div>
   );
   );
 };
 };
 
 
-export default BookmarkButton;
+export default BookmarkButtons;

+ 128 - 0
packages/app/src/components/Common/ClosableTextInput.tsx

@@ -0,0 +1,128 @@
+import React, {
+  FC, memo, useEffect, useRef, useState,
+} from 'react';
+import { useTranslation } from 'react-i18next';
+
+export const AlertType = {
+  WARNING: 'warning',
+  ERROR: 'error',
+} as const;
+
+export type AlertType = typeof AlertType[keyof typeof AlertType];
+
+export type AlertInfo = {
+  type?: AlertType
+  message?: string
+}
+
+type ClosableTextInputProps = {
+  value?: string
+  placeholder?: string
+  inputValidator?(text: string): AlertInfo | Promise<AlertInfo> | null
+  onPressEnter?(inputText: string | null): void
+  onClickOutside?(): void
+}
+
+const ClosableTextInput: FC<ClosableTextInputProps> = memo((props: ClosableTextInputProps) => {
+  const { t } = useTranslation();
+  const inputRef = useRef<HTMLInputElement>(null);
+
+  const [inputText, setInputText] = useState(props.value);
+  const [currentAlertInfo, setAlertInfo] = useState<AlertInfo | null>(null);
+  const [isAbleToShowAlert, setIsAbleToShowAlert] = useState<boolean>(false);
+
+  const createValidation = async(inputText: string) => {
+    if (props.inputValidator != null) {
+      const alertInfo = await props.inputValidator(inputText);
+      setAlertInfo(alertInfo);
+    }
+  };
+
+  const onChangeHandler = async(e: React.ChangeEvent<HTMLInputElement>) => {
+    const inputText = e.target.value;
+    createValidation(inputText);
+    setInputText(inputText);
+    setIsAbleToShowAlert(true);
+  };
+
+  const onFocusHandler = async(e: React.ChangeEvent<HTMLInputElement>) => {
+    const inputText = e.target.value;
+    await createValidation(inputText);
+  };
+
+  const onPressEnter = () => {
+    if (props.onPressEnter != null) {
+      const text = inputText != null ? inputText.trim() : null;
+      if (currentAlertInfo == null) {
+        props.onPressEnter(text);
+      }
+    }
+  };
+
+  const onKeyDownHandler = (e) => {
+    switch (e.key) {
+      case 'Enter':
+        onPressEnter();
+        break;
+      default:
+        break;
+    }
+  };
+
+  /*
+   * Hide when click outside the ref
+   */
+  const onBlurHandler = () => {
+    if (props.onClickOutside == null) {
+      return;
+    }
+
+    props.onClickOutside();
+  };
+
+  // didMount
+  useEffect(() => {
+    // autoFocus
+    if (inputRef?.current == null) {
+      return;
+    }
+    inputRef.current.focus();
+  });
+
+
+  const AlertInfo = () => {
+    if (currentAlertInfo == null) {
+      return <></>;
+    }
+
+    const alertType = currentAlertInfo.type != null ? currentAlertInfo.type : AlertType.ERROR;
+    const alertMessage = currentAlertInfo.message != null ? currentAlertInfo.message : 'Invalid value';
+    const alertTextStyle = alertType === AlertType.ERROR ? 'text-danger' : 'text-warning';
+    const translation = alertType === AlertType.ERROR ? 'Error' : 'Warning';
+    return (
+      <p className={`${alertTextStyle} text-center mt-1`}>{t(translation)}: {alertMessage}</p>
+    );
+  };
+
+
+  return (
+    <div className="d-block flex-fill">
+      <input
+        value={inputText || ''}
+        ref={inputRef}
+        type="text"
+        className="form-control"
+        placeholder={props.placeholder}
+        name="input"
+        onFocus={onFocusHandler}
+        onChange={onChangeHandler}
+        onKeyDown={onKeyDownHandler}
+        onBlur={onBlurHandler}
+        autoFocus={false}
+      />
+      {isAbleToShowAlert && <AlertInfo />}
+    </div>
+  );
+});
+
+export default ClosableTextInput;

+ 317 - 0
packages/app/src/components/Common/Dropdown/PageItemControl.tsx

@@ -0,0 +1,317 @@
+import React, { useState, useCallback, useEffect } from 'react';
+import {
+  Dropdown, DropdownMenu, DropdownToggle, DropdownItem,
+} from 'reactstrap';
+
+import { useTranslation } from 'react-i18next';
+
+import loggerFactory from '~/utils/logger';
+
+import {
+  IPageInfoAll, isIPageInfoForOperation,
+} from '~/interfaces/page';
+import { useSWRxPageInfo } from '~/stores/page';
+
+const logger = loggerFactory('growi:cli:PageItemControl');
+
+
+export const MenuItemType = {
+  BOOKMARK: 'bookmark',
+  DUPLICATE: 'duplicate',
+  RENAME: 'rename',
+  DELETE: 'delete',
+  REVERT: 'revert',
+} as const;
+export type MenuItemType = typeof MenuItemType[keyof typeof MenuItemType];
+
+export type ForceHideMenuItems = MenuItemType[];
+
+export type AdditionalMenuItemsRendererProps = { pageInfo: IPageInfoAll };
+
+type CommonProps = {
+  pageInfo?: IPageInfoAll,
+  isEnableActions?: boolean,
+  forceHideMenuItems?: ForceHideMenuItems,
+
+  onClickBookmarkMenuItem?: (pageId: string, newValue?: boolean) => Promise<void>,
+  onClickDuplicateMenuItem?: (pageId: string) => Promise<void> | void,
+  onClickRenameMenuItem?: (pageId: string, pageInfo: IPageInfoAll | undefined) => Promise<void> | void,
+  onClickDeleteMenuItem?: (pageId: string, pageInfo: IPageInfoAll | undefined) => Promise<void> | void,
+  onClickRevertMenuItem?: (pageId: string) => Promise<void> | void,
+
+  additionalMenuItemRenderer?: React.FunctionComponent<AdditionalMenuItemsRendererProps>,
+  isInstantRename?: boolean,
+}
+
+
+type DropdownMenuProps = CommonProps & {
+  pageId: string,
+  isLoading?: boolean,
+}
+
+const PageItemControlDropdownMenu = React.memo((props: DropdownMenuProps): JSX.Element => {
+  const { t } = useTranslation('');
+
+  const {
+    pageId, isLoading,
+    pageInfo, isEnableActions, forceHideMenuItems,
+    onClickBookmarkMenuItem, onClickDuplicateMenuItem, onClickRenameMenuItem, onClickDeleteMenuItem, onClickRevertMenuItem,
+    additionalMenuItemRenderer: AdditionalMenuItems, isInstantRename,
+  } = props;
+
+
+  // eslint-disable-next-line react-hooks/rules-of-hooks
+  const bookmarkItemClickedHandler = useCallback(async() => {
+    if (!isIPageInfoForOperation(pageInfo) || onClickBookmarkMenuItem == null) {
+      return;
+    }
+    await onClickBookmarkMenuItem(pageId, !pageInfo.isBookmarked);
+  }, [onClickBookmarkMenuItem, pageId, pageInfo]);
+
+  // eslint-disable-next-line react-hooks/rules-of-hooks
+  const duplicateItemClickedHandler = useCallback(async() => {
+    if (onClickDuplicateMenuItem == null) {
+      return;
+    }
+    await onClickDuplicateMenuItem(pageId);
+  }, [onClickDuplicateMenuItem, pageId]);
+
+  // eslint-disable-next-line react-hooks/rules-of-hooks
+  const renameItemClickedHandler = useCallback(async() => {
+    if (onClickRenameMenuItem == null) {
+      return;
+    }
+    if (!pageInfo?.isMovable) {
+      logger.warn('This page could not be renamed.');
+      return;
+    }
+    await onClickRenameMenuItem(pageId, pageInfo);
+  }, [onClickRenameMenuItem, pageId, pageInfo]);
+
+  const revertItemClickedHandler = useCallback(async() => {
+    if (onClickRevertMenuItem == null) {
+      return;
+    }
+    await onClickRevertMenuItem(pageId);
+  }, [onClickRevertMenuItem, pageId]);
+
+
+  // eslint-disable-next-line react-hooks/rules-of-hooks
+  const deleteItemClickedHandler = useCallback(async() => {
+    if (pageInfo == null || onClickDeleteMenuItem == null) {
+      return;
+    }
+    if (!pageInfo.isDeletable) {
+      logger.warn('This page could not be deleted.');
+      return;
+    }
+    await onClickDeleteMenuItem(pageId, pageInfo);
+  }, [onClickDeleteMenuItem, pageId, pageInfo]);
+
+  let contents = <></>;
+
+  if (isLoading) {
+    contents = (
+      <div className="text-muted text-center my-2">
+        <i className="fa fa-spinner fa-pulse"></i>
+      </div>
+    );
+  }
+  else if (pageId != null && pageInfo != null) {
+
+    const showDeviderBeforeAdditionalMenuItems = (forceHideMenuItems?.length ?? 0) < 3;
+    const showDeviderBeforeDelete = AdditionalMenuItems != null || showDeviderBeforeAdditionalMenuItems;
+
+    contents = (
+      <>
+        { !isEnableActions && (
+          <DropdownItem>
+            <p>
+              {t('search_result.currently_not_implemented')}
+            </p>
+          </DropdownItem>
+        ) }
+
+        {/* Bookmark */}
+        { !forceHideMenuItems?.includes(MenuItemType.BOOKMARK) && isEnableActions && !pageInfo.isEmpty && isIPageInfoForOperation(pageInfo) && (
+          <DropdownItem onClick={bookmarkItemClickedHandler}>
+            <i className="fa fa-fw fa-bookmark-o"></i>
+            { pageInfo.isBookmarked ? t('remove_bookmark') : t('add_bookmark') }
+          </DropdownItem>
+        ) }
+
+        {/* Duplicate */}
+        { !forceHideMenuItems?.includes(MenuItemType.DUPLICATE) && isEnableActions && (
+          <DropdownItem onClick={duplicateItemClickedHandler} data-testid="open-page-duplicate-modal-btn">
+            <i className="icon-fw icon-docs"></i>
+            {t('Duplicate')}
+          </DropdownItem>
+        ) }
+
+        {/* Move/Rename */}
+        { !forceHideMenuItems?.includes(MenuItemType.RENAME) && isEnableActions && pageInfo.isMovable && (
+          <DropdownItem onClick={renameItemClickedHandler}>
+            <i className="icon-fw  icon-action-redo"></i>
+            {t(isInstantRename ? 'Rename' : 'Move/Rename')}
+          </DropdownItem>
+        ) }
+
+        {/* Revert */}
+        { !forceHideMenuItems?.includes(MenuItemType.REVERT) && isEnableActions && pageInfo.isRevertible && (
+          <DropdownItem onClick={revertItemClickedHandler}>
+            <i className="icon-fw  icon-action-undo"></i>
+            {t('modal_putback.label.Put Back Page')}
+          </DropdownItem>
+        ) }
+
+        { AdditionalMenuItems && (
+          <>
+            { showDeviderBeforeAdditionalMenuItems && <DropdownItem divider /> }
+            <AdditionalMenuItems pageInfo={pageInfo} />
+          </>
+        ) }
+
+        {/* divider */}
+        {/* Delete */}
+        { !forceHideMenuItems?.includes(MenuItemType.DELETE) && isEnableActions && pageInfo.isMovable && (
+          <>
+            { showDeviderBeforeDelete && <DropdownItem divider /> }
+            <DropdownItem
+              className={`pt-2 ${pageInfo.isDeletable ? 'text-danger' : ''}`}
+              disabled={!pageInfo.isDeletable}
+              onClick={deleteItemClickedHandler}
+              data-testid="open-page-delete-modal-btn"
+            >
+              <i className="icon-fw icon-trash"></i>
+              {t('Delete')}
+            </DropdownItem>
+          </>
+        )}
+      </>
+    );
+  }
+
+  return (
+    <DropdownMenu positionFixed modifiers={{ preventOverflow: { boundariesElement: undefined } }}>
+      {contents}
+    </DropdownMenu>
+  );
+});
+
+
+type PageItemControlSubstanceProps = CommonProps & {
+  pageId: string,
+  fetchOnInit?: boolean,
+  children?: React.ReactNode,
+}
+
+export const PageItemControlSubstance = (props: PageItemControlSubstanceProps): JSX.Element => {
+
+  const {
+    pageId, pageInfo: presetPageInfo, fetchOnInit,
+    children,
+    onClickBookmarkMenuItem, onClickDuplicateMenuItem, onClickRenameMenuItem, onClickDeleteMenuItem,
+  } = props;
+
+  const [isOpen, setIsOpen] = useState(false);
+  const [shouldFetch, setShouldFetch] = useState(fetchOnInit ?? false);
+
+  const { data: fetchedPageInfo, mutate: mutatePageInfo } = useSWRxPageInfo(shouldFetch ? pageId : null);
+
+  // update shouldFetch (and will never be false)
+  useEffect(() => {
+    if (shouldFetch) {
+      return;
+    }
+    if (!isIPageInfoForOperation(presetPageInfo) && isOpen) {
+      setShouldFetch(true);
+    }
+  }, [isOpen, presetPageInfo, shouldFetch]);
+
+  // mutate after handle event
+  const bookmarkMenuItemClickHandler = useCallback(async(_pageId: string, _newValue: boolean) => {
+    if (onClickBookmarkMenuItem != null) {
+      await onClickBookmarkMenuItem(_pageId, _newValue);
+    }
+
+    if (shouldFetch) {
+      mutatePageInfo();
+    }
+  }, [mutatePageInfo, onClickBookmarkMenuItem, shouldFetch]);
+
+  const isLoading = shouldFetch && fetchedPageInfo == null;
+
+  const duplicateMenuItemClickHandler = useCallback(async() => {
+    if (onClickDuplicateMenuItem == null) {
+      return;
+    }
+    await onClickDuplicateMenuItem(pageId);
+  }, [onClickDuplicateMenuItem, pageId]);
+
+  const renameMenuItemClickHandler = useCallback(async() => {
+    if (onClickRenameMenuItem == null) {
+      return;
+    }
+    await onClickRenameMenuItem(pageId, fetchedPageInfo ?? presetPageInfo);
+  }, [onClickRenameMenuItem, pageId, fetchedPageInfo, presetPageInfo]);
+
+  const deleteMenuItemClickHandler = useCallback(async() => {
+    if (onClickDeleteMenuItem == null) {
+      return;
+    }
+    await onClickDeleteMenuItem(pageId, fetchedPageInfo ?? presetPageInfo);
+  }, [onClickDeleteMenuItem, pageId, fetchedPageInfo, presetPageInfo]);
+
+  return (
+    <Dropdown isOpen={isOpen} toggle={() => setIsOpen(!isOpen)} data-testid="open-page-item-control-btn">
+      { children ?? (
+        <DropdownToggle color="transparent" className="border-0 rounded btn-page-item-control d-flex align-items-center justify-content-center">
+          <i className="icon-options"></i>
+        </DropdownToggle>
+      ) }
+
+      <PageItemControlDropdownMenu
+        {...props}
+        isLoading={isLoading}
+        pageInfo={fetchedPageInfo ?? presetPageInfo}
+        onClickBookmarkMenuItem={bookmarkMenuItemClickHandler}
+        onClickDuplicateMenuItem={duplicateMenuItemClickHandler}
+        onClickRenameMenuItem={renameMenuItemClickHandler}
+        onClickDeleteMenuItem={deleteMenuItemClickHandler}
+      />
+    </Dropdown>
+  );
+
+};
+
+
+type PageItemControlProps = CommonProps & {
+  pageId?: string,
+  children?: React.ReactNode,
+}
+
+export const PageItemControl = (props: PageItemControlProps): JSX.Element => {
+  const { pageId } = props;
+
+  if (pageId == null) {
+    return <></>;
+  }
+
+  return <PageItemControlSubstance pageId={pageId} {...props} />;
+};
+
+
+type AsyncPageItemControlProps = Omit<CommonProps, 'pageInfo'> & {
+  pageId?: string,
+  children?: React.ReactNode,
+}
+
+export const AsyncPageItemControl = (props: AsyncPageItemControlProps): JSX.Element => {
+  const { pageId } = props;
+
+  if (pageId == null) {
+    return <></>;
+  }
+
+  return <PageItemControlSubstance pageId={pageId} fetchOnInit {...props} />;
+};

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

@@ -1,62 +0,0 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-
-import { withTranslation } from 'react-i18next';
-import { pagePathUtils } from '@growi/core';
-import { withUnstatedContainers } from './UnstatedUtils';
-
-import PageContainer from '~/client/services/PageContainer';
-
-const { convertToNewAffiliationPath } = pagePathUtils;
-
-function ComparePathsTable(props) {
-  const {
-    subordinatedPages, pageContainer, newPagePath, t,
-  } = props;
-  const { path } = pageContainer.state;
-
-  return (
-    <table className="table table-bordered grw-compare-paths-table">
-      <thead>
-        <tr className="d-flex">
-          <th className="w-50">{t('original_path')}</th>
-          <th className="w-50">{t('new_path')}</th>
-        </tr>
-      </thead>
-      <tbody className="overflow-auto d-block">
-        {subordinatedPages.map((subordinatedPage) => {
-          const convertedPath = convertToNewAffiliationPath(path, newPagePath, subordinatedPage.path);
-          return (
-            <tr key={subordinatedPage._id} className="d-flex">
-              <td className="text-break w-50">
-                <a href={subordinatedPage.path}>
-                  {subordinatedPage.path}
-                </a>
-              </td>
-              <td className="text-break w-50">
-                {convertedPath}
-              </td>
-            </tr>
-          );
-        })}
-      </tbody>
-    </table>
-  );
-}
-
-
-/**
- * Wrapper component for using unstated
- */
-const PageDuplicateModallWrapper = withUnstatedContainers(ComparePathsTable, [PageContainer]);
-
-ComparePathsTable.propTypes = {
-  t: PropTypes.func.isRequired, //  i18next
-
-  pageContainer: PropTypes.instanceOf(PageContainer).isRequired,
-  subordinatedPages: PropTypes.array.isRequired,
-  newPagePath: PropTypes.string.isRequired,
-};
-
-
-export default withTranslation()(PageDuplicateModallWrapper);

+ 0 - 96
packages/app/src/components/ContentLinkButtons.jsx

@@ -1,96 +0,0 @@
-import React, { useMemo } from 'react';
-import PropTypes from 'prop-types';
-
-import { pagePathUtils } from '@growi/core';
-import AppContainer from '~/client/services/AppContainer';
-import PageContainer from '~/client/services/PageContainer';
-
-import { withUnstatedContainers } from './UnstatedUtils';
-
-import RecentlyCreatedIcon from './Icons/RecentlyCreatedIcon';
-import { smoothScrollIntoView } from '~/client/util/smooth-scroll';
-
-const { isTopPage } = pagePathUtils;
-
-const WIKI_HEADER_LINK = 120;
-
-/**
- * @author Yuki Takei <yuki@weseek.co.jp>
- *
- */
-const ContentLinkButtons = (props) => {
-
-  const { appContainer, pageContainer } = props;
-  const { pageUser, path } = pageContainer.state;
-  const { isPageExist } = pageContainer.state;
-  const { isSharedUser } = appContainer;
-
-  const isTopPagePath = isTopPage(path);
-
-  // get element for smoothScroll
-  const getCommentListDom = useMemo(() => { return document.getElementById('page-comments-list') }, []);
-  const getBookMarkListHeaderDom = useMemo(() => { return document.getElementById('bookmarks-list') }, []);
-  const getRecentlyCreatedListHeaderDom = useMemo(() => { return document.getElementById('recently-created-list') }, []);
-
-
-  const CommentLinkButton = () => {
-    return (
-      <div className="mt-3">
-        <button
-          type="button"
-          className="btn btn-outline-secondary btn-sm btn-block"
-          onClick={() => smoothScrollIntoView(getCommentListDom, WIKI_HEADER_LINK)}
-        >
-          <i className="mr-2 icon-fw icon-bubbles"></i>
-          <span>Comments</span>
-        </button>
-      </div>
-    );
-  };
-
-  const BookMarkLinkButton = () => {
-    return (
-      <button
-        type="button"
-        className="btn btn-outline-secondary btn-sm px-2"
-        onClick={() => smoothScrollIntoView(getBookMarkListHeaderDom, WIKI_HEADER_LINK)}
-      >
-        <i className="mr-2 icon-star"></i>
-        <span>Bookmarks</span>
-      </button>
-
-    );
-  };
-
-  const RecentlyCreatedLinkButton = () => {
-    return (
-      <button
-        type="button"
-        className="btn btn-outline-secondary btn-sm px-3"
-        onClick={() => smoothScrollIntoView(getRecentlyCreatedListHeaderDom, WIKI_HEADER_LINK)}
-      >
-        <i className="grw-icon-container-recently-created mr-2"><RecentlyCreatedIcon /></i>
-        <span>Recently Created</span>
-      </button>
-
-    );
-  };
-
-  return (
-    <>
-      {isPageExist && !isSharedUser && !isTopPagePath && <CommentLinkButton />}
-
-      <div className="mt-3 d-flex justify-content-between">
-        {pageUser && <><BookMarkLinkButton /><RecentlyCreatedLinkButton /></>}
-      </div>
-    </>
-  );
-
-};
-
-ContentLinkButtons.propTypes = {
-  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
-  pageContainer: PropTypes.instanceOf(PageContainer).isRequired,
-};
-
-export default withUnstatedContainers(ContentLinkButtons, [AppContainer, PageContainer]);

+ 66 - 0
packages/app/src/components/ContentLinkButtons.tsx

@@ -0,0 +1,66 @@
+import React, { useCallback, useMemo } from 'react';
+
+import RecentlyCreatedIcon from './Icons/RecentlyCreatedIcon';
+import { smoothScrollIntoView } from '~/client/util/smooth-scroll';
+import { usePageUser } from '~/stores/context';
+
+const WIKI_HEADER_LINK = 120;
+
+
+const ContentLinkButtons = (): JSX.Element => {
+
+  const { data: pageUser } = usePageUser();
+
+  // get element for smoothScroll
+  const getBookMarkListHeaderDom = useMemo(() => { return document.getElementById('bookmarks-list') }, []);
+  const getRecentlyCreatedListHeaderDom = useMemo(() => { return document.getElementById('recently-created-list') }, []);
+
+
+  const BookMarkLinkButton = useCallback((): JSX.Element => {
+    if (getBookMarkListHeaderDom == null) {
+      return <></>;
+    }
+
+    return (
+      <button
+        type="button"
+        className="btn btn-outline-secondary btn-sm px-2"
+        onClick={() => smoothScrollIntoView(getBookMarkListHeaderDom, WIKI_HEADER_LINK)}
+      >
+        <i className="fa fa-fw fa-bookmark-o"></i>
+        <span>Bookmarks</span>
+      </button>
+    );
+  }, [getBookMarkListHeaderDom]);
+
+  const RecentlyCreatedLinkButton = useCallback(() => {
+    if (getRecentlyCreatedListHeaderDom == null) {
+      return <></>;
+    }
+
+    return (
+      <button
+        type="button"
+        className="btn btn-outline-secondary btn-sm px-3"
+        onClick={() => smoothScrollIntoView(getRecentlyCreatedListHeaderDom, WIKI_HEADER_LINK)}
+      >
+        <i className="grw-icon-container-recently-created mr-2"><RecentlyCreatedIcon /></i>
+        <span>Recently Created</span>
+      </button>
+    );
+  }, [getRecentlyCreatedListHeaderDom]);
+
+  if (pageUser == null) {
+    return <></>;
+  }
+
+  return (
+    <div className="mt-3 d-flex justify-content-between">
+      <BookMarkLinkButton />
+      <RecentlyCreatedLinkButton />
+    </div>
+  );
+
+};
+
+export default ContentLinkButtons;

+ 3 - 13
packages/app/src/components/CreateTemplateModal.jsx

@@ -6,14 +6,11 @@ import { Modal, ModalHeader, ModalBody } from 'reactstrap';
 import { withTranslation } from 'react-i18next';
 import { withTranslation } from 'react-i18next';
 import { pathUtils } from '@growi/core';
 import { pathUtils } from '@growi/core';
 import urljoin from 'url-join';
 import urljoin from 'url-join';
-import { withUnstatedContainers } from './UnstatedUtils';
 
 
-import PageContainer from '~/client/services/PageContainer';
 
 
 const CreateTemplateModal = (props) => {
 const CreateTemplateModal = (props) => {
-  const { t, pageContainer } = props;
+  const { t, path } = props;
 
 
-  const { path } = pageContainer.state;
   const parentPath = pathUtils.addTrailingSlash(path);
   const parentPath = pathUtils.addTrailingSlash(path);
 
 
   function generateUrl(label) {
   function generateUrl(label) {
@@ -67,18 +64,11 @@ const CreateTemplateModal = (props) => {
 };
 };
 
 
 
 
-/**
- * Wrapper component for using unstated
- */
-const CreateTemplateModalWrapper = withUnstatedContainers(CreateTemplateModal, [PageContainer]);
-
-
 CreateTemplateModal.propTypes = {
 CreateTemplateModal.propTypes = {
   t: PropTypes.func.isRequired, //  i18next
   t: PropTypes.func.isRequired, //  i18next
-  pageContainer: PropTypes.instanceOf(PageContainer).isRequired,
-
+  path: PropTypes.string.isRequired,
   isOpen: PropTypes.bool.isRequired,
   isOpen: PropTypes.bool.isRequired,
   onClose: PropTypes.func.isRequired,
   onClose: PropTypes.func.isRequired,
 };
 };
 
 
-export default withTranslation()(CreateTemplateModalWrapper);
+export default withTranslation()(CreateTemplateModal);

+ 22 - 4
packages/app/src/components/CustomNavigation/CustomTabContent.jsx → packages/app/src/components/CustomNavigation/CustomTabContent.tsx

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

+ 184 - 0
packages/app/src/components/DescendantsPageList.tsx

@@ -0,0 +1,184 @@
+import React, { useCallback, useState } from 'react';
+import { useTranslation } from 'react-i18next';
+import { toastSuccess } from '~/client/util/apiNotification';
+import {
+  IDataWithMeta,
+  IPageHasId,
+  IPageInfoForOperation,
+} from '~/interfaces/page';
+import { IPagingResult } from '~/interfaces/paging-result';
+import { OnDeletedFunction, OnPutBackedFunction } from '~/interfaces/ui';
+import { useIsGuestUser, useIsSharedUser, useIsTrashPage } from '~/stores/context';
+
+import {
+  useSWRxDescendantsPageListForCurrrentPath, useSWRxPageInfoForList, useSWRxPageList, useDescendantsPageListForCurrentPathTermManager,
+} from '~/stores/page';
+import { usePageTreeTermManager } from '~/stores/page-listing';
+import { ForceHideMenuItems, MenuItemType } from './Common/Dropdown/PageItemControl';
+
+import PageList from './PageList/PageList';
+import PaginationWrapper from './PaginationWrapper';
+
+
+type SubstanceProps = {
+  pagingResult: IPagingResult<IPageHasId> | undefined,
+  activePage: number,
+  setActivePage: (activePage: number) => void,
+  forceHideMenuItems?: ForceHideMenuItems,
+  onPagesDeleted?: OnDeletedFunction,
+  onPagePutBacked?: OnPutBackedFunction,
+}
+
+const convertToIDataWithMeta = (page: IPageHasId): IDataWithMeta<IPageHasId> => {
+  return { data: page };
+};
+
+export const DescendantsPageListSubstance = (props: SubstanceProps): JSX.Element => {
+
+  const { t } = useTranslation();
+
+  const {
+    pagingResult, activePage, setActivePage, forceHideMenuItems, onPagesDeleted, onPagePutBacked,
+  } = props;
+
+  const { data: isGuestUser } = useIsGuestUser();
+
+  const pageIds = pagingResult?.items?.map(page => page._id);
+  const { injectTo } = useSWRxPageInfoForList(pageIds, true, true);
+
+  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
+    const dataWithMetas = pagingResult.items.map(page => convertToIDataWithMeta(page));
+    // inject data for listing
+    pageWithMetas = injectTo(dataWithMetas);
+  }
+
+  const pageDeletedHandler: OnDeletedFunction = useCallback((...args) => {
+    toastSuccess(args[2] ? t('deleted_pages_completely') : t('deleted_pages'));
+
+    advancePt();
+
+    if (onPagesDeleted != null) {
+      onPagesDeleted(...args);
+    }
+  }, [advancePt, onPagesDeleted, t]);
+
+  const pagePutBackedHandler: OnPutBackedFunction = useCallback((path) => {
+    toastSuccess(t('page_has_been_reverted', { path }));
+
+    advancePt();
+    advanceDpl();
+
+    if (onPagePutBacked != null) {
+      onPagePutBacked(path);
+    }
+  }, [advanceDpl, advancePt, onPagePutBacked, t]);
+
+  function setPageNumber(selectedPageNumber) {
+    setActivePage(selectedPageNumber);
+  }
+
+  if (pagingResult == null) {
+    return (
+      <div className="wiki">
+        <div className="text-muted text-center">
+          <i className="fa fa-2x fa-spinner fa-pulse mr-1"></i>
+        </div>
+      </div>
+    );
+  }
+
+  const showPager = pagingResult.items.length > pagingResult.limit;
+
+  return (
+    <>
+      <PageList
+        pages={pageWithMetas}
+        isEnableActions={!isGuestUser}
+        forceHideMenuItems={forceHideMenuItems}
+        onPagesDeleted={pageDeletedHandler}
+        onPagePutBacked={pagePutBackedHandler}
+      />
+
+      { showPager && (
+        <div className="my-4">
+          <PaginationWrapper
+            activePage={activePage}
+            changePage={setPageNumber}
+            totalItemsCount={pagingResult.totalCount}
+            pagingLimit={pagingResult.limit}
+            align="center"
+          />
+        </div>
+      ) }
+    </>
+  );
+};
+
+type Props = {
+  path: string,
+}
+
+export const DescendantsPageList = (props: Props): JSX.Element => {
+  const { path } = 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: pagingResult, error, mutate } = useSWRxDescendantsPageListForCurrrentPath(activePage);
+
+  if (error != null) {
+    return (
+      <div className="my-5">
+        <div className="text-danger">{error.message}</div>
+      </div>
+    );
+  }
+
+  const forceHideMenuItems = isTrashPage ? [MenuItemType.RENAME] : undefined;
+
+  return (
+    <DescendantsPageListSubstance
+      pagingResult={pagingResult}
+      activePage={activePage}
+      setActivePage={setActivePage}
+      forceHideMenuItems={forceHideMenuItems}
+      onPagesDeleted={() => mutate()}
+    />
+  );
+
+};

Niektóre pliki nie zostały wyświetlone z powodu dużej ilości zmienionych plików