Przeglądaj źródła

Merge branch 'master' into imprv/86056-commonize-codemirror

yuto-o 4 lat temu
rodzic
commit
053141c072
100 zmienionych plików z 3904 dodań i 2054 usunięć
  1. 3 1
      .devcontainer/Dockerfile
  2. 9 3
      .devcontainer/docker-compose.yml
  3. 10 0
      .eslintrc.js
  4. 44 0
      .github/workflows/ci-app-prod.yml
  5. 175 0
      .github/workflows/ci-app.yml
  6. 25 15
      .github/workflows/ci-slackbot-proxy.yml
  7. 0 254
      .github/workflows/ci.yml
  8. 2 2
      .github/workflows/list-unhealthy-branches.yml
  9. 1 1
      .github/workflows/release-slackbot-proxy.yml
  10. 2 2
      .github/workflows/release.yml
  11. 275 0
      .github/workflows/reusable-app-prod.yml
  12. 91 0
      .github/workflows/reusable-app-reg-suit.yml
  13. 61 1
      CHANGELOG.md
  14. 2 2
      README.md
  15. 2 2
      README_JP.md
  16. 53 0
      bin/github-actions/generate-cypress-spec-arg.js
  17. 1 1
      lerna.json
  18. 13 5
      package.json
  19. 12 0
      packages/app/.eslintrc.js
  20. 5 0
      packages/app/.gitignore
  21. 2 2
      packages/app/bin/github-actions/update-readme.sh
  22. 7 0
      packages/app/config/ci/.env.local.for-auto-install
  23. 2 0
      packages/app/config/logger/config.dev.js
  24. 17 0
      packages/app/cypress.json
  25. 6 6
      packages/app/docker/Dockerfile
  26. 4 2
      packages/app/docker/README.md
  27. 7 12
      packages/app/jest.config.js
  28. 25 16
      packages/app/package.json
  29. 25 0
      packages/app/regconfig.json
  30. 17 1
      packages/app/resource/locales/en_US/admin/admin.json
  31. 42 4
      packages/app/resource/locales/en_US/translation.json
  32. 16 0
      packages/app/resource/locales/ja_JP/admin/admin.json
  33. 40 3
      packages/app/resource/locales/ja_JP/translation.json
  34. 16 0
      packages/app/resource/locales/zh_CN/admin/admin.json
  35. 40 3
      packages/app/resource/locales/zh_CN/translation.json
  36. 3 0
      packages/app/resource/search/mappings-es6.json
  37. 115 0
      packages/app/resource/search/mappings-es7.json
  38. 18 17
      packages/app/src/client/app.jsx
  39. 10 0
      packages/app/src/client/base.jsx
  40. 7 7
      packages/app/src/client/nologin.jsx
  41. 18 0
      packages/app/src/client/services/AdminAppContainer.js
  42. 3 2
      packages/app/src/client/services/AdminHomeContainer.js
  43. 23 9
      packages/app/src/client/services/AdminUserGroupDetailContainer.js
  44. 33 17
      packages/app/src/client/services/ContextExtractor.tsx
  45. 0 54
      packages/app/src/client/services/PageAccessoriesContainer.js
  46. 0 199
      packages/app/src/client/services/PageContainer.js
  47. 62 0
      packages/app/src/client/services/page-operation.ts
  48. 2 2
      packages/app/src/client/util/smooth-scroll.ts
  49. 81 68
      packages/app/src/components/Admin/AdminHome/AdminHome.jsx
  50. 25 2
      packages/app/src/components/Admin/App/AppSettingsPageContents.jsx
  51. 57 0
      packages/app/src/components/Admin/App/V5PageMigration.tsx
  52. 61 0
      packages/app/src/components/Admin/App/V5PageMigrationModal.tsx
  53. 6 1
      packages/app/src/components/Admin/ImportData/GrowiArchive/ImportForm.jsx
  54. 1 1
      packages/app/src/components/Admin/Notification/GlobalNotificationList.jsx
  55. 1 1
      packages/app/src/components/Admin/Notification/ManageGlobalNotification.jsx
  56. 0 118
      packages/app/src/components/Admin/UserGroup/UserGroupCreateForm.jsx
  57. 0 216
      packages/app/src/components/Admin/UserGroup/UserGroupDeleteModal.jsx
  58. 219 0
      packages/app/src/components/Admin/UserGroup/UserGroupDeleteModal.tsx
  59. 70 0
      packages/app/src/components/Admin/UserGroup/UserGroupDropdown.tsx
  60. 119 0
      packages/app/src/components/Admin/UserGroup/UserGroupForm.tsx
  61. 0 152
      packages/app/src/components/Admin/UserGroup/UserGroupPage.jsx
  62. 158 0
      packages/app/src/components/Admin/UserGroup/UserGroupPage.tsx
  63. 0 157
      packages/app/src/components/Admin/UserGroup/UserGroupTable.jsx
  64. 187 0
      packages/app/src/components/Admin/UserGroup/UserGroupTable.tsx
  65. 0 49
      packages/app/src/components/Admin/UserGroupDetail/UserGroupDetailPage.jsx
  66. 179 0
      packages/app/src/components/Admin/UserGroupDetail/UserGroupDetailPage.tsx
  67. 0 111
      packages/app/src/components/Admin/UserGroupDetail/UserGroupEditForm.jsx
  68. 2 2
      packages/app/src/components/Admin/UserGroupDetail/UserGroupPageList.jsx
  69. 32 37
      packages/app/src/components/BookmarkButtons.tsx
  70. 127 0
      packages/app/src/components/Common/ClosableTextInput.tsx
  71. 259 0
      packages/app/src/components/Common/Dropdown/PageItemControl.tsx
  72. 3 11
      packages/app/src/components/ComparePathsTable.jsx
  73. 0 96
      packages/app/src/components/ContentLinkButtons.jsx
  74. 66 0
      packages/app/src/components/ContentLinkButtons.tsx
  75. 3 13
      packages/app/src/components/CreateTemplateModal.jsx
  76. 22 4
      packages/app/src/components/CustomNavigation/CustomTabContent.tsx
  77. 106 0
      packages/app/src/components/DescendantsPageList.tsx
  78. 100 0
      packages/app/src/components/DescendantsPageListModal.tsx
  79. 3 3
      packages/app/src/components/EventListeneres/HashChanged.tsx
  80. 4 3
      packages/app/src/components/Fab.jsx
  81. 20 17
      packages/app/src/components/ForbiddenPage.tsx
  82. 4 4
      packages/app/src/components/Hotkeys/Subscribers/CreatePage.jsx
  83. 2 1
      packages/app/src/components/Icons/AttachmentIcon.jsx
  84. 0 28
      packages/app/src/components/Icons/BookmarkIcon.jsx
  85. 2 1
      packages/app/src/components/Icons/HistoryIcon.jsx
  86. 1 1
      packages/app/src/components/Icons/ShareLinkIcon.jsx
  87. 17 0
      packages/app/src/components/Icons/TriangleIcon.tsx
  88. 128 0
      packages/app/src/components/IdenticalPathPage.tsx
  89. 1 2
      packages/app/src/components/InAppNotification/PageNotification/PageModelNotification.tsx
  90. 14 4
      packages/app/src/components/InstallerForm.jsx
  91. 0 102
      packages/app/src/components/LikeButtons.jsx
  92. 81 0
      packages/app/src/components/LikeButtons.tsx
  93. 14 14
      packages/app/src/components/LoginForm.jsx
  94. 1 1
      packages/app/src/components/Navbar/AuthorInfo.jsx
  95. 9 5
      packages/app/src/components/Navbar/GlobalSearch.tsx
  96. 243 0
      packages/app/src/components/Navbar/GrowiContextualSubNavigation.tsx
  97. 6 4
      packages/app/src/components/Navbar/GrowiNavbar.tsx
  98. 24 16
      packages/app/src/components/Navbar/GrowiNavbarBottom.jsx
  99. 0 164
      packages/app/src/components/Navbar/GrowiSubNavigation.jsx
  100. 100 0
      packages/app/src/components/Navbar/GrowiSubNavigation.tsx

+ 3 - 1
.devcontainer/Dockerfile

@@ -3,7 +3,7 @@
 # Licensed under the MIT License. See https://go.microsoft.com/fwlink/?linkid=2090316 for license information.
 #-------------------------------------------------------------------------------------------------------------
 
-FROM mcr.microsoft.com/vscode/devcontainers/javascript-node:0-14
+FROM mcr.microsoft.com/vscode/devcontainers/javascript-node:0-16
 
 # The node image includes a non-root user with sudo access. Use the
 # "remoteUser" property in devcontainer.json to use it. On Linux, update
@@ -32,6 +32,8 @@ RUN chown -R $USER_UID:$USER_GID /home/$USERNAME /workspace;
 ENV DEBIAN_FRONTEND=noninteractive
 RUN apt-get update \
    && apt-get -y install --no-install-recommends git-lfs \
+      # for Cypress
+      libgtk2.0-0 libgtk-3-0 libgbm-dev libnotify-dev libgconf-2-4 libnss3 libxss1 libasound2 libxtst6 xauth xvfb fonts-noto-cjk \
 
    # Clean up
    && apt-get autoremove -y \

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

@@ -40,6 +40,7 @@ services:
     build:
       context: ../../growi-docker-compose/elasticsearch
       dockerfile: ./Dockerfile
+    container_name: elasticsearch
     restart: unless-stopped
     ports:
       - 9200:9200
@@ -54,11 +55,16 @@ services:
       - /usr/share/elasticsearch/data
       - ../../growi-docker-compose/elasticsearch/config/elasticsearch.yml:/usr/share/elasticsearch/config/elasticsearch.yml
 
-  elasticsearch-head:
-    image: tobias74/elasticsearch-head:6
+  #need to adjust kibana version based on elasticsearch version
+  kibana:
+    image: docker.elastic.co/kibana/kibana:6.8.0
     restart: unless-stopped
+    environment:
+      ELASTICSEARCH_HOSTS: 'http://elasticsearch:9200'
     ports:
-      - 9100:9100
+      - 5601:5601
+    depends_on:
+      - elasticsearch
 
   # This container requires '../../growi-docker-compose' repository
   #   cloned from https://github.com/weseek/growi-docker-compose.git

+ 10 - 0
.eslintrc.js

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

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

@@ -0,0 +1,44 @@
+name: Node CI for app production
+
+on:
+  push:
+    branches:
+      - master
+  pull_request:
+    types: [opened, reopened, synchronize]
+
+jobs:
+
+  test-prod-node14:
+    uses: weseek/growi/.github/workflows/reusable-app-prod.yml@master
+    with:
+      node-version: 14.x
+      skip-cypress: true
+    secrets:
+      SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
+
+
+  test-prod-node16:
+    uses: weseek/growi/.github/workflows/reusable-app-prod.yml@master
+    with:
+      node-version: 16.x
+      cypress-report-artifact-name: Cypress report
+    secrets:
+      SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
+
+
+  run-reg-suit-node16:
+    needs: [test-prod-node16]
+
+    uses: weseek/growi/.github/workflows/reusable-app-reg-suit.yml@master
+
+    if: always()
+
+    with:
+      node-version: 16.x
+      cypress-report-artifact-name: Cypress report
+    secrets:
+      REG_NOTIFY_GITHUB_PLUGIN_CLIENTID: ${{ secrets.REG_NOTIFY_GITHUB_PLUGIN_CLIENTID }}
+      AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
+      AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
+      SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}

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

@@ -0,0 +1,175 @@
+name: Node CI for app development
+
+on:
+  push:
+    branches-ignore:
+      - release/**
+      - rc/**
+      - chore/**
+      - support/prepare-v**
+
+jobs:
+
+  lint:
+    runs-on: ubuntu-latest
+
+    strategy:
+      matrix:
+        node-version: [16.x]
+
+    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 }}
+
+
+
+  test:
+    runs-on: ubuntu-latest
+
+    strategy:
+      matrix:
+        node-version: [16.x]
+
+    services:
+      mongodb:
+        image: mongo:4.4
+        ports:
+        - 27017/tcp
+
+    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 }}
+
+
+
+  launch-dev:
+    runs-on: ubuntu-latest
+
+    strategy:
+      matrix:
+        node-version: [16.x]
+
+    services:
+      mongodb:
+        image: mongo:4.4
+        ports:
+        - 27017/tcp
+
+    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 }}

+ 25 - 15
.github/workflows/ci-slackbot-proxy.yml

@@ -15,7 +15,7 @@ jobs:
 
     strategy:
       matrix:
-        node-version: [14.x]
+        node-version: [16.x]
 
     steps:
     - uses: actions/checkout@v2
@@ -26,14 +26,19 @@ jobs:
         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
-    - name: Print dependencies
-      run: |
-        echo -n "node " && node -v
-        echo -n "npm " && npm -v
-        yarn list --depth=0
+        npx lerna bootstrap -- --frozen-lockfile
 
     - name: yarn lint
       run: |
@@ -58,7 +63,7 @@ jobs:
 
     strategy:
       matrix:
-        node-version: [14.x]
+        node-version: [16.x]
 
     services:
       mysql:
@@ -79,14 +84,19 @@ jobs:
         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
-    - name: Print dependencies
-      run: |
-        echo -n "node " && node -v
-        echo -n "npm " && npm -v
-        yarn list --depth=0
+        npx lerna bootstrap -- --frozen-lockfile
 
     - name: yarn dev:ci
       working-directory: ./packages/slackbot-proxy
@@ -118,7 +128,7 @@ jobs:
 
     strategy:
       matrix:
-        node-version: [14.x]
+        node-version: [16.x]
 
     services:
       mysql:

+ 0 - 254
.github/workflows/ci.yml

@@ -1,254 +0,0 @@
-name: Node CI for growi
-
-on:
-  push:
-    branches-ignore:
-      - release/**
-      - rc/**
-      - chore/**
-      - support/prepare-v**
-
-jobs:
-
-  lint:
-    runs-on: ubuntu-latest
-
-    strategy:
-      matrix:
-        node-version: [14.x]
-
-    steps:
-    - uses: actions/checkout@v2
-
-    - uses: actions/setup-node@v2
-      with:
-        node-version: ${{ matrix.node-version }}
-        cache: 'yarn'
-        cache-dependency-path: '**/yarn.lock'
-
-    - name: lerna bootstrap
-      run: |
-        npx lerna bootstrap
-    - name: Print dependencies
-      run: |
-        echo -n "node " && node -v
-        echo -n "npm " && npm -v
-        yarn list --depth=0
-
-    - 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:
-    runs-on: ubuntu-latest
-
-    strategy:
-      matrix:
-        node-version: [14.x]
-
-    services:
-      mongodb:
-        image: mongo:4.4
-        ports:
-        - 27017/tcp
-      mongodb36:
-        image: mongo:3.6
-        ports:
-        - 27017/tcp
-
-    steps:
-    - uses: actions/checkout@v2
-
-    - uses: actions/setup-node@v2
-      with:
-        node-version: ${{ matrix.node-version }}
-        cache: 'yarn'
-        cache-dependency-path: '**/yarn.lock'
-
-    - name: lerna bootstrap
-      run: |
-        npx lerna bootstrap
-    - name: Print dependencies
-      run: |
-        echo -n "node " && node -v
-        echo -n "npm " && npm -v
-        yarn list --depth=0
-
-    - name: yarn test
-      working-directory: ./packages/app
-      run: |
-        yarn test
-      env:
-        MONGO_URI: mongodb://localhost:${{ job.services.mongodb.ports['27017'] }}/growi_test
-    - name: yarn test with MongoDB 3.6
-      working-directory: ./packages/app
-      run: |
-        yarn test
-      env:
-        MONGO_URI: mongodb://localhost:${{ job.services.mongodb36.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:
-    runs-on: ubuntu-latest
-
-    strategy:
-      matrix:
-        node-version: [14.x]
-
-    services:
-      mongodb:
-        image: mongo:4.4
-        ports:
-        - 27017/tcp
-
-    steps:
-    - uses: actions/checkout@v2
-
-    - uses: actions/setup-node@v2
-      with:
-        node-version: ${{ matrix.node-version }}
-        cache: 'yarn'
-        cache-dependency-path: '**/yarn.lock'
-
-    - name: lerna bootstrap
-      run: |
-        npx lerna bootstrap
-    - name: Print dependencies
-      run: |
-        echo -n "node " && node -v
-        echo -n "npm " && npm -v
-        yarn list --depth=0
-
-    - 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 - build-dev (${{ matrix.node-version }})*'
-        channel: '#ci'
-        isCompactMode: true
-        url: ${{ secrets.SLACK_WEBHOOK_URL }}
-
-
-  launch-prod:
-    runs-on: ubuntu-latest
-
-    strategy:
-      matrix:
-        node-version: [12.x, 14.x]
-
-    services:
-      mongodb:
-        image: mongo:4.4
-        ports:
-        - 27017/tcp
-      mongodb36:
-        image: mongo:3.6
-        ports:
-        - 27017/tcp
-
-    steps:
-    - uses: actions/checkout@v2
-
-    - uses: actions/setup-node@v2
-      with:
-        node-version: ${{ matrix.node-version }}
-        cache: 'yarn'
-        cache-dependency-path: '**/yarn.lock'
-
-    - name: Remove unnecessary packages
-      run: |
-        rm -rf packages/slackbot-proxy
-    - name: lerna bootstrap
-      run: |
-        npx lerna bootstrap
-    - name: Print dependencies
-      run: |
-        echo -n "node " && node -v
-        echo -n "npm " && npm -v
-        yarn list --depth=0
-    - name: Build
-      run: |
-        yarn lerna run build
-      env:
-        ANALYZE_BUNDLE_SIZE: ${{ matrix.node-version == '14.x' }}
-    - name: lerna bootstrap --production
-      run: |
-        npx lerna bootstrap -- --production
-    - name: Print dependencies
-      run: |
-        echo -n "node " && node -v
-        echo -n "npm " && npm -v
-        yarn list --production --depth=0
-    - name: Get DB name
-      id: getdbname
-      run: |
-        echo ::set-output name=suffix::$(echo '${{ matrix.node-version }}' | sed s/\\.//)
-    - name: yarn server:ci
-      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.mongodb.ports['27017'] }}/growi-${{ steps.getdbname.outputs.suffix }}
-    - 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-${{ steps.getdbname.outputs.suffix }}
-
-    - name: Upload report as artifact
-      uses: actions/upload-artifact@v2
-      with:
-        name: Bundle Analyzing Report
-        path: packages/app/report/bundle-analyzer.html
-
-    - name: Slack Notification
-      uses: weseek/ghaction-slack-notification@master
-      if: failure()
-      with:
-        type: ${{ job.status }}
-        job_name: '*Node CI for growi - build-prod (${{ matrix.node-version }})*'
-        channel: '#ci'
-        isCompactMode: true
-        url: ${{ secrets.SLACK_WEBHOOK_URL }}

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

@@ -14,9 +14,9 @@ jobs:
       with:
         fetch-depth: 0
 
-    - uses: actions/setup-node@v2-beta
+    - uses: actions/setup-node@v2
       with:
-        node-version: '14'
+        node-version: '16'
 
     - name: List branches
       id: list-branches

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

@@ -108,7 +108,7 @@ jobs:
 
     - uses: actions/setup-node@v2
       with:
-        node-version: '14'
+        node-version: '16'
         cache: 'yarn'
         cache-dependency-path: '**/yarn.lock'
 

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

@@ -24,7 +24,7 @@ jobs:
 
     - uses: actions/setup-node@v2
       with:
-        node-version: '14'
+        node-version: '16'
         cache: 'yarn'
         cache-dependency-path: '**/yarn.lock'
 
@@ -85,7 +85,7 @@ jobs:
 
     - uses: actions/setup-node@v2
       with:
-        node-version: '14'
+        node-version: '16'
         cache: 'yarn'
         cache-dependency-path: '**/yarn.lock'
 

+ 275 - 0
.github/workflows/reusable-app-prod.yml

@@ -0,0 +1,275 @@
+name: Reusable build app workflow for production
+
+on:
+  workflow_call:
+    inputs:
+      node-version:
+        required: true
+        type: string
+      skip-cypress:
+        type: boolean
+      cypress-report-artifact-name:
+        type: string
+    secrets:
+      SLACK_WEBHOOK_URL:
+        required: true
+
+jobs:
+
+  build-prod:
+    runs-on: ubuntu-latest
+
+    outputs:
+      PROD_FILES: ${{ steps.archive-prod-files.outputs.file }}
+
+    steps:
+    - uses: actions/checkout@v2
+
+    - uses: actions/setup-node@v2
+      with:
+        node-version: ${{ inputs.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${{ inputs.node-version }}-${{ hashFiles('**/yarn.lock') }}
+        restore-keys: |
+          node_modules-${{ runner.OS }}-node${{ inputs.node-version }}-
+
+    - name: lerna bootstrap
+      run: |
+        npx lerna bootstrap -- --frozen-lockfile
+
+    - name: Remove unnecessary packages
+      run: |
+        rm -rf packages/slackbot-proxy
+
+    - name: Build
+      run: |
+        yarn lerna run build
+      env:
+        ANALYZE_BUNDLE_SIZE: 1
+
+    - name: Archive production files
+      id: archive-prod-files
+      run: |
+        tar -cf production.tar packages/**/dist packages/app/public
+        echo ::set-output name=file::production.tar
+
+    - name: Upload production files as artifact
+      uses: actions/upload-artifact@v2
+      with:
+        name: Production Files
+        path: ${{ steps.archive-prod-files.outputs.file }}
+
+    - name: Upload report as artifact
+      uses: actions/upload-artifact@v2
+      with:
+        name: Bundle Analyzing Report
+        path: packages/app/report/bundle-analyzer.html
+
+    - name: Slack Notification
+      uses: weseek/ghaction-slack-notification@master
+      if: failure()
+      with:
+        type: ${{ job.status }}
+        job_name: '*Node CI for growi - build-prod (${{ inputs.node-version }})*'
+        channel: '#ci'
+        isCompactMode: true
+        url: ${{ secrets.SLACK_WEBHOOK_URL }}
+
+
+
+  launch-prod:
+    needs: [build-prod]
+    runs-on: ubuntu-latest
+
+    services:
+      mongodb:
+        image: mongo:4.4
+        ports:
+        - 27017/tcp
+      mongodb36:
+        image: mongo:3.6
+        ports:
+        - 27017/tcp
+
+    steps:
+    - uses: actions/checkout@v2
+
+    - uses: actions/setup-node@v2
+      with:
+        node-version: ${{ inputs.node-version }}
+        cache: 'yarn'
+        cache-dependency-path: '**/yarn.lock'
+
+    - name: Get Date
+      id: get-date
+      run: |
+        echo "::set-output name=dateYmdHM::$(/bin/date -u "+%Y%m%d%H%M")"
+        echo "::set-output name=dateYm::$(/bin/date -u "+%Y%m")"
+
+    - name: Cache/Restore node_modules (not reused)
+      id: cache-dependencies
+      uses: actions/cache@v2
+      with:
+        path: |
+          **/node_modules
+        key: node_modules-build-prod-${{ runner.OS }}-node${{ inputs.node-version }}-${{ steps.get-date.outputs.dateYmdHM }}
+        restore-keys: |
+          node_modules-${{ runner.OS }}-node${{ inputs.node-version }}-${{ hashFiles('**/yarn.lock') }}
+          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 }}-
+
+    - name: Remove unnecessary packages
+      run: |
+        rm -rf packages/slackbot-proxy
+
+    - name: lerna bootstrap --production
+      run: |
+        npx lerna bootstrap -- --production
+
+    - name: Download production files artifact
+      uses: actions/download-artifact@v2
+      with:
+        name: Production Files
+
+    - name: Extract procution files artifact
+      run: |
+        tar -xf ${{ needs.build-prod.outputs.PROD_FILES }}
+
+    - name: yarn server:ci
+      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.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
+
+    - name: Slack Notification
+      uses: weseek/ghaction-slack-notification@master
+      if: failure()
+      with:
+        type: ${{ job.status }}
+        job_name: '*Node CI for growi - build-prod (${{ inputs.node-version }})*'
+        channel: '#ci'
+        isCompactMode: true
+        url: ${{ secrets.SLACK_WEBHOOK_URL }}
+
+
+
+  run-cypress:
+    needs: [build-prod]
+
+    if: ${{ !inputs.skip-cypress }}
+
+    runs-on: ubuntu-latest
+    container:
+      image: cypress/base:16.13.0
+      # solve permissions issue
+      # see: https://github.com/cypress-io/github-action/issues/446#issuecomment-987015822
+      options: --user 1001
+
+    strategy:
+      fail-fast: false
+      matrix:
+        # List string expressions that is comma separated ids of tests in "test/cypress/integration"
+        spec-group: ['1', '2']
+
+    services:
+      mongodb:
+        image: mongo:4.4
+        ports:
+        - 27017/tcp
+
+    steps:
+    - uses: actions/checkout@v2
+
+    - name: Get yarn cache dir
+      id: yarn-cache-dir
+      run: |
+        echo "::set-output name=value::`yarn cache dir --silent`"
+
+    - name: Cache/Restore dependencies
+      uses: actions/cache@v2
+      with:
+        path: |
+          **/node_modules
+          ~/.cache/Cypress
+          ${{ steps.yarn-cache-dir.outputs.value }}
+        key: deps-for-cypress-${{ runner.OS }}-node${{ inputs.node-version }}-${{ hashFiles('**/yarn.lock') }}
+        restore-keys: |
+          deps-for-cypress-${{ runner.OS }}-node${{ inputs.node-version }}
+
+    - name: lerna bootstrap
+      run: |
+        npx lerna bootstrap -- --frozen-lockfile
+
+    - name: Download production files artifact
+      uses: actions/download-artifact@v2
+      with:
+        name: Production Files
+
+    - name: Extract procution files artifact
+      run: |
+        tar -xf ${{ needs.build-prod.outputs.PROD_FILES }}
+
+    - name: Determine spec expression
+      id: determine-spec-exp
+      run: |
+        SPEC=`node bin/github-actions/generate-cypress-spec-arg.js --prefix="test/cypress/integration/" --suffix="-*/**" "${{ matrix.spec-group }}"`
+        echo "::set-output name=value::$SPEC"
+
+    - name: Copy dotenv file for ci
+      working-directory: ./packages/app
+      run: |
+        cat config/ci/.env.local.for-ci >> .env.production.local
+
+    - name: Copy dotenv file for automatic installation
+      if: ${{ matrix.spec-group != '1' }}
+      working-directory: ./packages/app
+      run: |
+        cat config/ci/.env.local.for-auto-install >> .env.production.local
+
+    - name: Cypress Run
+      uses: cypress-io/github-action@v2
+      with:
+        working-directory: ./packages/app
+        install: false
+        spec: '${{ steps.determine-spec-exp.outputs.value }}'
+        start: yarn server
+        wait-on: 'http://localhost:3000'
+      env:
+        MONGO_URI: mongodb://mongodb:27017/growi-vrt
+
+    - name: Upload results
+      if: always()
+      uses: actions/upload-artifact@v2
+      with:
+        name: ${{ inputs.cypress-report-artifact-name }}
+        path: |
+          packages/app/test/cypress/screenshots
+          packages/app/test/cypress/videos
+
+    - name: Slack Notification
+      uses: weseek/ghaction-slack-notification@master
+      if: failure()
+      with:
+        type: ${{ job.status }}
+        job_name: '*Node CI for growi - run-cypress (${{ inputs.node-version }})*'
+        channel: '#ci'
+        isCompactMode: true
+        url: ${{ secrets.SLACK_WEBHOOK_URL }}

+ 91 - 0
.github/workflows/reusable-app-reg-suit.yml

@@ -0,0 +1,91 @@
+name: Reusable build app workflow for production
+
+on:
+  workflow_call:
+    inputs:
+      node-version:
+        required: true
+        type: string
+      checkout-ref:
+        type: string
+        default: ${{ github.head_ref }}
+      cypress-report-artifact-name:
+        required: true
+        type: string
+    secrets:
+      REG_NOTIFY_GITHUB_PLUGIN_CLIENTID:
+        required: true
+      AWS_ACCESS_KEY_ID:
+        required: true
+      AWS_SECRET_ACCESS_KEY:
+        required: true
+      SLACK_WEBHOOK_URL:
+        required: true
+    outputs:
+      EXPECTED_IMAGES_EXIST:
+        value: ${{ jobs.run-reg-suit.outputs.EXPECTED_IMAGES_EXIST }}
+
+
+jobs:
+
+  run-reg-suit:
+    # use secrets for "VRT" environment
+    # https://github.com/weseek/growi/settings/environments/376165508/edit
+    environment: VRT
+
+    env:
+      REG_NOTIFY_GITHUB_PLUGIN_CLIENTID: ${{ secrets.REG_NOTIFY_GITHUB_PLUGIN_CLIENTID }}
+      AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
+      AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
+      SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
+
+    runs-on: ubuntu-latest
+
+    outputs:
+      EXPECTED_IMAGES_EXIST: ${{ steps.check-expected-images.outputs.EXPECTED_IMAGES_EXIST }}
+
+    steps:
+    - uses: actions/checkout@v2
+      with:
+        ref: ${{ inputs.checkout-ref }}
+        fetch-depth: 0
+
+    - uses: actions/setup-node@v2
+      with:
+        node-version: ${{ inputs.node-version }}
+        cache: 'yarn'
+        cache-dependency-path: '**/yarn.lock'
+
+    - name: Cache/Restore node_modules
+      uses: actions/cache@v2
+      with:
+        path: |
+          **/node_modules
+        key: node_modules-${{ runner.OS }}-node${{ inputs.node-version }}-${{ hashFiles('**/yarn.lock') }}
+        restore-keys: |
+          node_modules-${{ runner.OS }}-node${{ inputs.node-version }}-
+
+    - name: lerna bootstrap
+      run: |
+        npx lerna bootstrap -- --frozen-lockfile
+
+    - name: Download screenshots taken by cypress
+      uses: actions/download-artifact@v2
+      with:
+        name: ${{ inputs.cypress-report-artifact-name }}
+        path: packages/app/test/cypress
+
+    - name: Run reg-suit
+      working-directory: ./packages/app
+      run: |
+        yarn reg:run
+
+    - name: Slack Notification
+      uses: weseek/ghaction-slack-notification@master
+      if: failure()
+      with:
+        type: ${{ job.status }}
+        job_name: '*Node CI for growi - run-reg-suit (${{ inputs.node-version }})*'
+        channel: '#ci'
+        isCompactMode: true
+        url: ${{ secrets.SLACK_WEBHOOK_URL }}

+ 61 - 1
CHANGELOG.md

@@ -1,9 +1,69 @@
 # Changelog
 
-## [Unreleased](https://github.com/weseek/growi/compare/v4.5.8...HEAD)
+## [Unreleased](https://github.com/weseek/growi/compare/v4.5.13...HEAD)
 
 *Please do not manually update this file. We've automated the process.*
 
+## [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
+
+### 🐛 Bug Fixes
+
+- fix: Internal server error occured when "Restrict complete deletion of pages" option's value is "Admin and author" (#5175 ) @yuki-takei
+
+## [v4.5.10](https://github.com/weseek/growi/compare/v4.5.9...v4.5.10) - 2022-01-26
+
+### 💎 Features
+
+- feat: Automatic installation (#5141) @yuki-takei
+
+### 🚀 Improvement
+
+- imprv: Migrate like states to swr (#5137) @miya
+
+### 🐛 Bug Fixes
+
+- fix: 86631-cannot-reset-password-in-case-that-register-limitation-is-Closed (#5155) @kaoritokashiki
+
+### 🧰 Maintenance
+
+- support: VRT with Cypress (#5030) @yuki-takei
+
+## [v4.5.9](https://github.com/weseek/growi/compare/v4.5.8...v4.5.9) - 2022-01-21
+
+### 🚀 Improvement
+
+- imprv: 79291 make password min length 8 charactors (#5116) @kaoritokashiki
+
+### 🐛 Bug Fixes
+
+- fix: OIDC reconnection bug fix (#5104) @mudana-grune
+- fix: /_api/v3/page is broken and dump 500 error "get-page-failed TypeError: user.canDeleteCompletely is not a function" (#5103) @yuki-takei
+- fix: Default completely deletion settings label mismatched against to actual (#5102) @yuki-takei
+- fix: OIDC issuer host availability check (#5099) @mudana-grune
+
+### 🧰 Maintenance
+
+- support: Improve multistage build (#5090) @yuki-takei
+- support: Omit node-re2 (#5089) @yuki-takei
+- ci(deps-dev): bump swr from 1.0.1 to 1.1.2 (#5018) @dependabot
+
 ## [v4.5.8](https://github.com/weseek/growi/compare/v4.5.7...v4.5.8) - 2022-01-12
 
 ### 💎 Features

+ 2 - 2
README.md

@@ -82,7 +82,7 @@ See [GROWI Docs: Environment Variables](https://docs.growi.org/en/admin-guide/ad
 
 ## Dependencies
 
-- Node.js v12.x or v14.x
+- Node.js v14.x or v16.x
 - npm 6.x
 - yarn
 - MongoDB 4.x
@@ -90,7 +90,7 @@ See [GROWI Docs: Environment Variables](https://docs.growi.org/en/admin-guide/ad
 ### Optional Dependencies
 
 - Redis 3.x
-- ElasticSearch 6.x (needed when using Full-text search)
+- ElasticSearch 6.x or 7.x (needed when using Full-text search)
   - **CAUTION: Following plugins are required**
     - [Japanese (kuromoji) Analysis plugin](https://www.elastic.co/guide/en/elasticsearch/plugins/current/analysis-kuromoji.html)
     - [ICU Analysis Plugin](https://www.elastic.co/guide/en/elasticsearch/plugins/current/analysis-icu.html)

+ 2 - 2
README_JP.md

@@ -81,7 +81,7 @@ Crowi からの移行は **[こちら](https://docs.growi.org/en/admin-guide/mig
 
 ## 依存関係
 
-- Node.js v12.x or v14.x
+- Node.js v14.x or v16.x
 - npm 6.x
 - yarn
 - MongoDB 4.x
@@ -89,7 +89,7 @@ Crowi からの移行は **[こちら](https://docs.growi.org/en/admin-guide/mig
 ### オプションの依存関係
 
 - Redis 3.x
-- ElasticSearch 6.x (needed when using Full-text search)
+- ElasticSearch 6.x or 7.x (needed when using Full-text search)
   - **注意: 次のプラグインが必要です**
     - [Japanese (kuromoji) Analysis plugin](https://www.elastic.co/guide/en/elasticsearch/plugins/current/analysis-kuromoji.html)
     - [ICU Analysis Plugin](https://www.elastic.co/guide/en/elasticsearch/plugins/current/analysis-icu.html)

+ 53 - 0
bin/github-actions/generate-cypress-spec-arg.js

@@ -0,0 +1,53 @@
+/* eslint-disable no-console */
+
+/*
+ * USAGE:
+ *  node generate-cypress-spec-arg --prefix=${prefix} --suffix=${suffix} ${value}
+ *
+ * OPTIONS:
+ *  --prefix : prefix string for each items
+ *  --suffix : suffix string for each items
+ *
+ * EXAMPLE:
+ *  node generate-cypress-spec-arg --prefix=${prefix}"A" --suffix="Z" "1,3,5"
+ *  => A1Z,A3Z,A5Z
+ */
+
+const yargs = require('yargs/yargs');
+const { hideBin } = require('yargs/helpers');
+
+const argv = yargs(hideBin(process.argv)).argv;
+
+
+const printExample = () => {
+  console.group('** Usage **');
+  // eslint-disable-next-line no-template-curly-in-string
+  console.log('$ node generate-cypress-spec-arg --prefix=${prefix}"A" --suffix="Z" "1,3,5"');
+  console.log('  ==> A1Z,A3Z,A5Z');
+  console.groupEnd();
+  console.log('\n');
+};
+
+
+const { prefix, suffix, _: value } = argv;
+
+if (prefix == null) {
+  printExample();
+  throw new Error('The option "prefix" must be specified');
+}
+if (suffix == null) {
+  printExample();
+  throw new Error('The option "suffix" must be specified');
+}
+if (value.length === 0) {
+  printExample();
+  throw new Error('A value string must be specified');
+}
+
+const result = value[0]
+  .toString().split(',')
+  .map(v => v.trim())
+  .map(v => `${prefix}${v}${suffix}`)
+  .join(',');
+
+console.log(result);

+ 1 - 1
lerna.json

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

+ 13 - 5
package.json

@@ -1,6 +1,6 @@
 {
   "name": "growi",
-  "version": "4.5.9-RC.0",
+  "version": "5.0.0-RC.0",
   "description": "Team collaboration software using markdown",
   "tags": [
     "wiki",
@@ -51,11 +51,13 @@
     "tslib": "^2.3.1"
   },
   "devDependencies": {
+    "@testing-library/cypress": "^8.0.2",
     "@types/jest": "^26.0.22",
     "@types/node": "^14.14.35",
     "@types/rewire": "^2.5.28",
     "@typescript-eslint/eslint-plugin": "^4.28.5",
     "@typescript-eslint/parser": "^4.28.5",
+    "cypress": "^9.2.0",
     "eslint": "^7.31.0",
     "eslint-config-weseek": "^1.1.0",
     "eslint-import-resolver-typescript": "^2.4.0",
@@ -69,6 +71,11 @@
     "lerna": "^4.0.0",
     "postcss": "^8.4.5",
     "postcss-scss": "^4.0.3",
+    "reg-keygen-git-hash-plugin": "^0.11.1",
+    "reg-notify-github-plugin": "^0.11.1",
+    "reg-notify-slack-plugin": "^0.11.0",
+    "reg-publish-s3-plugin": "^0.11.0",
+    "reg-suit": "^0.11.1",
     "rewire": "^5.0.0",
     "shipjs": "^0.24.1",
     "stylelint": "^14.2.0",
@@ -76,11 +83,12 @@
     "ts-jest": "^27.0.4",
     "ts-node": "^9.1.1",
     "tsconfig-paths": "^3.9.0",
-    "typescript": "^4.2.3"
+    "typescript": "^4.2.3",
+    "yargs": "^17.3.1"
   },
   "engines": {
-    "node": "^12 || ^14",
-    "npm": ">=6.11.3 <7",
-    "yarn": ">=1.19.1 <2"
+    "node": "^14 || ^16",
+    "npm": ">=6.14 <7 || >=8.1 < 9",
+    "yarn": ">=1.22 <2"
   }
 }

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

@@ -3,6 +3,9 @@ module.exports = {
     'weseek/react',
     'weseek/typescript',
   ],
+  plugins: [
+    'regex',
+  ],
   env: {
     jquery: true,
   },
@@ -25,6 +28,15 @@ module.exports = {
       name: 'axios',
       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',
 
     // set 'warn' temporarily -- 2021.08.02 Yuki Takei

+ 5 - 0
packages/app/.gitignore

@@ -2,6 +2,11 @@
 /.next/
 /out/
 
+# test
+test/cypress/screenshots
+test/cypress/videos
+.reg
+
 # dist
 /dist/
 /transpiled/

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

@@ -2,5 +2,5 @@
 
 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

+ 7 - 0
packages/app/config/ci/.env.local.for-auto-install

@@ -0,0 +1,7 @@
+APP_SITE_URL=http://localhost:3000
+
+AUTO_INSTALL_ADMIN_USERNAME=admin
+AUTO_INSTALL_ADMIN_NAME=Admin
+AUTO_INSTALL_ADMIN_EMAIL=admin@example.com
+AUTO_INSTALL_ADMIN_PASSWORD=adminadmin
+AUTO_INSTALL_GLOBAL_LANG=zh_CN

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

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

+ 17 - 0
packages/app/cypress.json

@@ -0,0 +1,17 @@
+{
+  "baseUrl": "http://localhost:3000",
+
+  "fileServerFolder": "test/cypress",
+  "fixturesFolder": "test/cypress/fixtures",
+  "integrationFolder": "test/cypress/integration",
+  "screenshotsFolder": "test/cypress/screenshots",
+  "videosFolder": "test/cypress/videos",
+  "supportFile": "test/cypress/support/index.ts",
+  "pluginsFile": "test/cypress/plugins/index.ts",
+  "testFiles": "**/*.spec.ts",
+
+  "viewportWidth": 1440,
+  "viewportHeight": 1200,
+
+  "experimentalSessionSupport": true
+}

+ 6 - 6
packages/app/docker/Dockerfile

@@ -6,7 +6,7 @@ ARG flavor=default
 ##
 ## packages-json-picker
 ##
-FROM node:14-slim AS packages-json-picker
+FROM node:16-slim AS packages-json-picker
 
 ENV optDir /opt
 
@@ -20,7 +20,7 @@ RUN find packages \! -name "package.json" -mindepth 2 -maxdepth 2 -print | xargs
 ##
 ## deps-resolver
 ##
-FROM node:14-slim AS deps-resolver
+FROM node:16-slim AS deps-resolver
 
 ENV optDir /opt
 
@@ -31,7 +31,7 @@ COPY --from=packages-json-picker ${optDir} .
 
 # setup
 RUN yarn config set network-timeout 300000
-RUN npx lerna bootstrap -- --frozen-lockfile
+RUN npx -y lerna bootstrap -- --frozen-lockfile
 
 # make artifacts
 RUN tar -cf node_modules.tar \
@@ -48,7 +48,7 @@ FROM deps-resolver AS deps-resolver-prod
 # remove unnecessary packages
 RUN rm -rf packages/slackbot-proxy
 
-RUN npx lerna bootstrap -- --production
+RUN npx -y lerna bootstrap -- --production
 # make artifacts
 RUN tar -cf node_modules.tar \
   node_modules \
@@ -59,7 +59,7 @@ RUN tar -cf node_modules.tar \
 ##
 ## prebuilder-default
 ##
-FROM node:14-slim AS prebuilder-default
+FROM node:16-slim AS prebuilder-default
 
 ENV optDir /opt
 
@@ -128,7 +128,7 @@ RUN tar -cf packages.tar \
 ##
 ## release
 ##
-FROM node:14-slim
+FROM node:16-slim
 LABEL maintainer Yuki Takei <yuki@weseek.co.jp>
 
 ENV NODE_ENV production

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

@@ -10,8 +10,10 @@ GROWI Official docker image
 Supported tags and respective Dockerfile links
 ------------------------------------------------
 
-* [`4.5.8`, `4.5`, `4`, `latest` (Dockerfile)](https://github.com/weseek/growi/blob/v4.5.8/docker/Dockerfile)
-* [`4.5.8-nocdn`, `4.5-nocdn`, `4-nocdn`, `latest-nocdn` (Dockerfile)](https://github.com/weseek/growi/blob/v4.5.8/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.13`, `4.5`, `4`, (Dockerfile)](https://github.com/weseek/growi/blob/v4.5.13/docker/Dockerfile)
+* [`4.5.13-nocdn`, `4.5-nocdn`, `4-nocdn`, (Dockerfile)](https://github.com/weseek/growi/blob/v4.5.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)
 

+ 7 - 12
packages/app/jest.config.js

@@ -21,8 +21,8 @@ module.exports = {
       preset: 'ts-jest/presets/js-with-ts',
 
       rootDir: '.',
-      roots: ['<rootDir>/src'],
-      testMatch: ['<rootDir>/src/test/unit/**/*.test.ts', '<rootDir>/src/test/unit/**/*.test.js'],
+      roots: ['<rootDir>'],
+      testMatch: ['<rootDir>/test/unit/**/*.test.ts', '<rootDir>/test/unit/**/*.test.js'],
 
       testEnvironment: 'node',
 
@@ -36,23 +36,18 @@ module.exports = {
       preset: 'ts-jest/presets/js-with-ts',
 
       rootDir: '.',
-      roots: ['<rootDir>/src'],
-      testMatch: ['<rootDir>/src/test/integration/**/*.test.ts', '<rootDir>/src/test/integration/**/*.test.js'],
+      roots: ['<rootDir>'],
+      testMatch: ['<rootDir>/test/integration/**/*.test.ts', '<rootDir>/test/integration/**/*.test.js'],
 
       testEnvironment: 'node',
-      globalSetup: '<rootDir>/src/test/integration/global-setup.js',
-      globalTeardown: '<rootDir>/src/test/integration/global-teardown.js',
-      setupFilesAfterEnv: ['<rootDir>/src/test/integration/setup.js'],
+      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: 'client',
-    //   rootDir: '.',
-    //   testMatch: ['<rootDir>/src/test/client/**/*.test.js'],
-    // },
   ],
 
   // Automatically clear mock calls and instances between every test

+ 25 - 16
packages/app/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@growi/app",
-  "version": "4.5.9-RC.0",
+  "version": "5.0.0-RC.0",
   "license": "MIT",
   "scripts": {
     "//// for production": "",
@@ -8,9 +8,9 @@
     "build": "run-p build:*",
     "build:client": "yarn cross-env NODE_ENV=production webpack --config config/webpack.prod.js",
     "build:server": "yarn cross-env NODE_ENV=production tsc -p tsconfig.build.server.json && tsc-alias -p tsconfig.build.server.json",
-    "clean": "npx shx rm -rf dist transpiled",
+    "clean": "npx -y shx rm -rf dist transpiled",
     "prebuild": "yarn cross-env NODE_ENV=production run-p clean resources:*",
-    "postbuild": "npx shx mv transpiled/src dist && npx shx cp -r src/server/views dist/server/ && npx shx rm -rf transpiled",
+    "postbuild": "npx -y shx mv transpiled/src dist && npx -y shx cp -r src/server/views dist/server/ && npx -y shx rm -rf transpiled",
     "server": "yarn cross-env NODE_ENV=production node -r dotenv-flow/config --expose_gc dist/server/app.js",
     "server:ci": "yarn server --ci",
     "preserver": "yarn cross-env NODE_ENV=production yarn migrate",
@@ -28,10 +28,11 @@
     "dev:migrate:status": "yarn dev:migrate-mongo status",
     "dev:migrate:up": "yarn dev:migrate-mongo up",
     "dev:migrate:down": "yarn dev:migrate-mongo down",
+    "cy:run": "cypress run --headless",
     "//// for CI": "",
     "dev:ci": "yarn dev:client:nowatch && yarn dev:server --ci",
     "predev:ci": "run-p resources:*",
-    "lint:typecheck": "npx tsc",
+    "lint:typecheck": "npx -y tsc",
     "lint:eslint": "eslint --quiet \"**/*.{js,jsx,ts,tsx}\"",
     "lint:styles": "stylelint src/**/*.scss",
     "lint:swagger2openapi": "node node_modules/.bin/oas-validate tmp/swagger.json",
@@ -39,6 +40,7 @@
     "test": "cross-env NODE_ENV=test jest --passWithNoTests -- ",
     "prelint:eslint": "yarn resources:plugin",
     "prelint:swagger2openapi": "yarn openapi:v3",
+    "reg:run": "reg-suit run",
     "//// misc": "",
     "console": "yarn cross-env NODE_ENV=development yarn ts-node --experimental-repl-await src/server/console.js",
     "swagger-jsdoc": "swagger-jsdoc -o tmp/swagger.json -d config/swagger-definition.js",
@@ -57,13 +59,13 @@
     "@browser-bunyan/console-formatted-stream": "^1.6.2",
     "@godaddy/terminus": "^4.9.0",
     "@google-cloud/storage": "^5.8.5",
-    "@growi/codemirror-textlint": "^4.5.9-RC.0",
-    "@growi/plugin-attachment-refs": "^4.5.9-RC.0",
-    "@growi/plugin-lsx": "^4.5.9-RC.0",
-    "@growi/plugin-pukiwiki-like-linker": "^4.5.9-RC.0",
-    "@growi/slack": "^4.5.9-RC.0",
-    "@promster/express": "^5.1.0",
-    "@promster/server": "^6.0.3",
+    "@growi/codemirror-textlint": "^5.0.0-RC.0",
+    "@growi/plugin-attachment-refs": "^5.0.0-RC.0",
+    "@growi/plugin-lsx": "^5.0.0-RC.0",
+    "@growi/plugin-pukiwiki-like-linker": "^5.0.0-RC.0",
+    "@growi/slack": "^5.0.0-RC.0",
+    "@promster/express": "^7.0.2",
+    "@promster/server": "^7.0.4",
     "@slack/events-api": "^3.0.0",
     "@slack/web-api": "^6.2.4",
     "@slack/webhook": "^6.0.0",
@@ -87,19 +89,21 @@
     "date-fns": "^2.23.0",
     "detect-indent": "^7.0.0",
     "diff": "^5.0.0",
+    "@elastic/elasticsearch6": "npm:@elastic/elasticsearch@^6.8.7",
+    "@elastic/elasticsearch7": "npm:@elastic/elasticsearch@^7.16.0",
     "diff_match_patch": "^0.1.1",
-    "elasticsearch": "^16.0.0",
     "entities": "^2.0.0",
-    "esa-nodejs": "^0.0.7",
+    "esa-node": "^0.2.2",
     "escape-string-regexp": "=4.0.0",
+    "eslint-plugin-regex": "^1.8.0",
     "express": "^4.16.1",
     "express-bunyan-logger": "^1.3.3",
-    "express-form": "~0.12.0",
     "express-mongo-sanitize": "^2.1.0",
     "express-rate-limit": "^5.3.0",
     "express-session": "^1.16.1",
     "express-validator": "^6.1.1",
     "express-webpack-assets": "^0.1.0",
+    "extensible-custom-error": "^0.0.7",
     "graceful-fs": "^4.1.11",
     "helmet": "^4.6.0",
     "http-errors": "~1.8.0",
@@ -134,7 +138,10 @@
     "passport-twitter": "^1.0.4",
     "prom-client": "^13.0.0",
     "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-multiline-clamp": "^2.0.0",
     "react-tagcloud": "^2.1.1",
     "reconnecting-websocket": "^4.4.0",
     "redis": "^3.0.2",
@@ -159,7 +166,7 @@
   },
   "devDependencies": {
     "@alienfast/i18next-loader": "^1.1.4",
-    "@growi/ui": "^4.5.9-RC.0",
+    "@growi/ui": "^5.0.0-RC.0",
     "@handsontable/react": "=2.1.0",
     "@types/compression": "^1.7.0",
     "@types/express": "^4.17.11",
@@ -172,13 +179,15 @@
     "bunyan-debug": "^2.0.0",
     "cli": "~1.0.1",
     "codemirror": "^5.63.0",
-    "colors": "^1.2.5",
+    "colors": "=1.4.0",
     "connect-browser-sync": "^2.1.0",
     "core-js": "=2.6.9",
     "css-loader": "^3.0.0",
     "csv-to-markdown-table": "^1.0.1",
     "diff2html": "^3.1.2",
     "eazy-logger": "^3.1.0",
+    "eslint-plugin-regex": "^1.8.0",
+    "eslint-plugin-cypress": "^2.12.1",
     "file-loader": "^5.0.2",
     "handsontable": "=6.2.2",
     "hard-source-webpack-plugin": "^0.13.1",

+ 25 - 0
packages/app/regconfig.json

@@ -0,0 +1,25 @@
+{
+  "core": {
+    "workingDir": ".reg",
+    "actualDir": "test/cypress/screenshots",
+    "thresholdRate": 0.001,
+    "addIgnore": true,
+    "ximgdiff": {
+      "invocationType": "client"
+    }
+  },
+  "plugins": {
+    "reg-keygen-git-hash-plugin": true,
+    "reg-notify-github-plugin": {
+      "prCommentBehavior": "new",
+      "setCommitStatus": false,
+      "clientId": "$REG_NOTIFY_GITHUB_PLUGIN_CLIENTID"
+    },
+    "reg-notify-slack-plugin": {
+      "webhookUrl": "$SLACK_WEBHOOK_URL"
+    },
+    "reg-publish-s3-plugin": {
+      "bucketName": "growi-vrt-snapshots"
+    }
+  }
+}

+ 17 - 1
packages/app/resource/locales/en_US/admin/admin.json

@@ -19,6 +19,17 @@
     "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>"
   },
+  "v5_page_migration": {
+    "page_tree_not_avaliable" : "Page tree feature is not available yet.",
+    "go_to_settings": "Go to settings to enable the feature",
+    "migration_desc": "Some of the public pages have the old schema. To take advantage of new features such as page trees and easy renaming, please upgrade the schema of all your pages.",
+    "migration_note": "Note: You will lose unique constraints from the page paths.",
+    "upgrade_to_v5": "Upgrade to V5",
+    "modal_migration_warning": "This process may take long. It is highly recommended that administrators tell users not to create, modify, or delete pages during migration.",
+    "start_upgrading": "Start upgrading",
+    "successfully_started": "Succeeded to start migration",
+    "already_upgraded": "You have already completed upgrading"
+  },
   "app_setting": {
     "site_name": "Site name",
     "sitename_change": "You can change site name which is used for header and HTML title.",
@@ -178,6 +189,9 @@
     "beta_warning": "This function is Beta.",
     "import_from": "Import from {{from}}",
     "import_growi_archive": "Import GROWI archive",
+    "error": {
+      "only_upsert_available": "Only 'Upsert' option is available for pages collection."
+    },
     "growi_settings": {
       "description_of_import_mode": {
         "about": "When you import data with the same name as an existing one, choose from the following three modes below.",
@@ -192,7 +206,7 @@
       "upload": "Upload",
       "discard": "Discard uploaded data",
       "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.",
         "page_and_revision": "'Pages' and 'Revisions' must be imported both.",
         "depends": "'{{target}}' must be selected when '{{condition}}' is selected."
@@ -440,6 +454,7 @@
   },
   "user_group_management": {
     "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.",
     "group_name": "Group name",
     "group_example": "e.g. : Group1",
@@ -452,6 +467,7 @@
       "backward_match": "Backward match"
     },
     "group_list": "Group list",
+    "child_group_list": "Child group list",
     "back_to_list": "Go back to group list",
     "basic_info": "Basic info",
     "user_list": "User list",

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

@@ -11,6 +11,7 @@
   "phone":"Smartphone",
   "tablet":"Tablet",
   "Click to copy": "Click to copy",
+  "Rename" : "Rename",
   "Move/Rename": "Move/Rename",
   "Moved": "Moved",
   "Redirected": "Redirected",
@@ -20,6 +21,7 @@
   "Done": "Done",
   "Cancel": "Cancel",
   "Create": "Create",
+  "Description": "Description",
   "Admin": "Admin",
   "administrator": "Admin",
   "Tag": "Tag",
@@ -39,6 +41,7 @@
   "account_id": "Account Id",
   "Update": "Update",
   "Update Page": "Update Page",
+  "Error": "Error",
   "Warning": "Warning",
   "Sign in": "Sign in",
   "Sign up is here": "Sign up",
@@ -57,6 +60,7 @@
   "Presentation Mode": "Presentation",
   "The end": "The end",
   "Not available for guest": "Not available for guest",
+  "No users have liked this yet": "No users have liked this yet",
   "No users have liked this yet.": "No users have liked this yet.",
   "No users have bookmarked yet": "No users have bookmarked yet",
   "Create Archive Page": "Create Archive Page",
@@ -65,6 +69,7 @@
   "Include Attachment File": "Include Attachment File",
   "Include Comment": "Include Comment",
   "Include Subordinated Page": "Include Subordinated Page",
+  "Include Subordinated Target Page": "include {{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",
@@ -107,6 +112,9 @@
   "Create under": "Create page under below:",
   "Wiki Management Home Page": "Wiki Management Home Page",
   "App Settings": "App Settings",
+  "V5 Page Migration": "V5 Page Migration",
+  "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",
   "Markdown Settings": "Markdown Settings",
   "Customize": "Customize",
@@ -116,6 +124,7 @@
   "Legacy_Slack_Integration": "Legacy Slack Integration",
   "User_Management": "User Management",
   "external_account_management": "External Account Management",
+  "UserGroup": "UserGroup",
   "UserGroup Management": "UserGroup Management",
   "Full Text Search Management": "Full Text Search Management",
   "Import Data": "Import Data",
@@ -149,8 +158,11 @@
   "Sign out": "Logout",
   "Disassociate": "Disassociate",
   "No bookmarks yet": "No bookmarks yet",
+  "add_bookmark": "Add to Bookmarks",
+  "remove_bookmark": "Remove from Bookmarks",
   "Recent Created": "Recent Created",
   "Recent Changes": "Recent Changes",
+  "Page Tree": "Page Tree",
   "original_path":"Original path",
   "new_path":"New path",
   "duplicated_path":"duplicated_path",
@@ -166,7 +178,10 @@
   "form_validation": {
     "error_message": "Some values ​​are incorrect",
     "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": {
     "Create Page": "Create Page",
@@ -238,7 +253,7 @@
     "expire": "Expiration",
     "Days": "Days",
     "Custom": "Custom",
-    "description": "description",
+    "description": "Description",
     "enter_desc": "Enter description",
     "Unlimited": "unlimited",
     "Issue": "Issue",
@@ -507,7 +522,10 @@
     "page_not_found_in_preview": "\"{{path}}\" is not a GROWI page."
   },
   "toaster": {
+    "create_succeeded": "Succeeded to create {{target}}",
+    "create_failed": "Failed to create {{target}}",
     "update_successed": "Succeeded to update {{target}}",
+    "update_failed": "Failed to update {{target}}",
     "initialize_successed": "Succeeded to initialize {{target}}",
     "give_user_admin": "Succeeded to give {{username}} admin",
     "remove_user_admin": "Succeeded to remove {{username}} admin",
@@ -596,13 +614,24 @@
     "popover_desc": "Input channel name. You can notify multiple channels by entering a comma-separated list."
   },
   "search_result": {
-    "result_meta": "Found \"{{keyword}}\" in {{total}}.",
+    "result_meta": "Search results for:",
     "deletion_mode_btn_lavel": "Select and delete page",
     "cancel": "Cancel",
     "delete": "Delete",
     "check_all": "Check all",
     "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"
+    }
   },
   "security_setting": {
     "Guest Users Access": "Guest users access",
@@ -944,5 +973,14 @@
     "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.",
     "password_and_confirm_password_does_not_match": "Password and confirm password does not match"
+  },
+  "pagetree": {
+    "private_legacy_pages": "Private Legacy Pages",
+    "cannot_rename_a_title_that_contains_slash": "Cannot rename a title that contains '/'"
+  },
+  "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"
   }
 }

+ 16 - 0
packages/app/resource/locales/ja_JP/admin/admin.json

@@ -19,6 +19,17 @@
     "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>"
   },
+  "v5_page_migration": {
+    "page_tree_not_avaliable" : "Page Tree 機能は現在使用できません。",
+    "go_to_settings": "設定する",
+    "migration_desc": "公開されているページに古いスキーマのものが存在します。ページツリーや簡単なリネームなどの新機能を利用するには、全てのページのスキーマをアップグレードしてください。",
+    "migration_note": "注意: ページパスからユニーク制約が失われます。",
+    "upgrade_to_v5": "V5 にアップグレード",
+    "modal_migration_warning": "管理者はユーザーに、マイグレーション中はページを作成・変更・削除しないように伝えることを強くお勧めします。",
+    "start_upgrading": "アップグレードを開始",
+    "successfully_started": "正常にマイグレーションが開始されました",
+    "already_upgraded": "アップグレードは既に完了しています"
+  },
   "app_setting": {
     "site_name": "サイト名",
     "sitename_change": "ヘッダーや HTML タイトルに使用されるサイト名を変更できます。",
@@ -196,6 +207,9 @@
     "beta_warning": "この機能はベータ版です",
     "import_from": "{{from}} からインポート",
     "import_growi_archive": "GROWI アーカイブをインポート",
+    "error": {
+      "only_upsert_available": "pages コレクションには 'Upsert' オプションのみ対応しています"
+    },
     "growi_settings": {
       "description_of_import_mode": {
         "about": "既存のデータと同名であるデータをインポートする際の挙動は以下の3つのモードから選べます。",
@@ -439,6 +453,7 @@
   },
   "user_group_management": {
     "create_group": "新規グループの作成",
+    "add_child_group": "子グループの追加",
     "deny_create_group": "新規グループの作成はできません。",
     "group_name": "グループ名",
     "group_example": "例: Group1",
@@ -451,6 +466,7 @@
       "backward_match": "後方一致"
     },
     "group_list": "グループ一覧",
+    "child_group_list": "子グループ一覧",
     "back_to_list": "グループ一覧に戻る",
     "basic_info": "基本情報",
     "user_list": "ユーザー一覧",

+ 40 - 3
packages/app/resource/locales/ja_JP/translation.json

@@ -11,6 +11,7 @@
   "phone":"スマホ",
   "tablet":"タブレット",
   "Click to copy": "クリックでコピー",
+  "Rename": "名前変更",
   "Move/Rename": "移動/名前変更",
   "Moved": "移動しました",
   "Redirected": "リダイレクトされました",
@@ -20,6 +21,7 @@
   "Done": "完了",
   "Cancel": "キャンセル",
   "Create": "作成",
+  "Description": "説明",
   "Admin": "管理",
   "administrator": "管理者",
   "Tag": "タグ",
@@ -40,6 +42,7 @@
   "Initialize": "初期化",
   "Update": "更新",
   "Update Page": "ページを更新",
+  "Error": "エラー",
   "Warning": "注意",
   "Sign in": "ログイン",
   "Sign up is here": "新規登録はこちら",
@@ -58,6 +61,7 @@
   "Presentation Mode": "プレゼンテーション",
   "The end": "おしまい",
   "Not available for guest": "ゲストユーザーは利用できません",
+  "No users have liked this yet": "いいねをしているユーザーはいません",
   "No users have bookmarked yet": "ブックマークしているユーザーはいません",
   "Create Archive Page": "アーカイブページの作成",
   "Target page": "対象ページ",
@@ -65,6 +69,7 @@
   "Include Attachment File": "添付ファイルも含める",
   "Include Comment": "コメントも含める",
   "Include Subordinated Page": "配下ページも含める",
+  "Include Subordinated Target Page": "{{target}} 下も含む",
   "All Subordinated Page": "全ての配下ページ",
   "Specify Hierarchy": "階層の深さを指定",
   "Submitted the request to create the archive": "アーカイブ作成のリクエストを正常に送信しました",
@@ -107,6 +112,9 @@
   "Create under": "ページを以下に作成",
   "Wiki Management Home Page": "Wiki管理トップ",
   "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設定",
   "Markdown Settings": "マークダウン設定",
   "Customize": "カスタマイズ",
@@ -116,6 +124,7 @@
   "Legacy_Slack_Integration": "Slack連携 (レガシー)",
   "User_Management": "ユーザー管理",
   "external_account_management": "外部アカウント管理",
+  "UserGroup": "グループ",
   "UserGroup Management": "グループ管理",
   "Full Text Search Management": "全文検索管理",
   "Import Data": "データインポート",
@@ -151,8 +160,11 @@
   "Sidebar mode": "サイドバーモード",
   "Sidebar mode on Editor": "サイドバーモード(編集時)",
   "No bookmarks yet": "No bookmarks yet",
+  "add_bookmark": "ブックマークに追加",
+  "remove_bookmark": "ブックマークから削除",
   "Recent Created": "最新の作成",
   "Recent Changes": "最新の変更",
+  "Page Tree": "ページツリー",
   "original_path":"元のパス",
   "new_path":"新しいパス",
   "duplicated_path":"重複したパス",
@@ -168,7 +180,9 @@
   "form_validation": {
     "error_message": "いくつかの値が設定されていません",
     "required": "%sに値を入力してください",
-    "invalid_syntax": "%sの構文が不正です"
+    "invalid_syntax": "%sの構文が不正です",
+    "title_required": "タイトルを入力してください",
+    "slashed_are_not_yet_supported": "スラッシュを含むタイトルにはまだ対応していません"
   },
   "not_found_page": {
     "Create Page": "ページを作成する",
@@ -507,7 +521,10 @@
     "page_not_found_in_preview": "\"{{path}}\" というページはありません。"
   },
   "toaster": {
+    "create_succeeded": "新しい{{target}}が作成されました",
+    "create_failed": "{{target}}の作成に失敗しました",
     "update_successed": "{{target}}を更新しました",
+    "update_failed": "{{target}}の更新に失敗しました",
     "initialize_successed": "{{target}}を初期化しました",
     "give_user_admin": "{{username}}を管理者に設定しました",
     "remove_user_admin": "{{username}}を管理者から外しました",
@@ -596,13 +613,24 @@
     "popover_desc": "チャンネル名を入れてください。カンマ区切りのリストを入力することで複数のチャンネルに通知することができます。"
   },
   "search_result": {
-    "result_meta": "{{total}}件のページが見つかりました。検索ワード: \"{{keyword}}\"",
+    "result_meta": "検索結果:",
     "deletion_mode_btn_lavel": "ページを指定して削除",
     "cancel": "キャンセル",
     "delete": "削除",
     "check_all": "すべてチェック",
     "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": "更新日時"
+    }
   },
   "security_setting": {
     "Guest Users Access": "ゲストユーザーのアクセス",
@@ -937,5 +965,14 @@
     "success_to_send_email": "メールを送信しました",
     "incorrect_token_or_expired_url":"トークンが正しくないか、URLの有効期限が切れています。 以下のリンクからパスワードリセットリクエストを再送信してください。",
     "password_and_confirm_password_does_not_match": "パスワードと確認パスワードが一致しません"
+  },
+  "pagetree": {
+    "private_legacy_pages": "待避所",
+    "cannot_rename_a_title_that_contains_slash": "`/` が含まれているタイトルにリネームできません"
+  },
+  "duplicated_page_alert" : {
+    "same_page_name_exists": "ページ名 「{{pageName}}」が重複しています",
+    "same_page_name_exists_at_path" : "”{{path}}” において ”{{pageName}}”というページは複数存在しています。",
+    "select_page_to_see" : "以下から遷移するページを選択してください。"
   }
 }

+ 16 - 0
packages/app/resource/locales/zh_CN/admin/admin.json

@@ -19,6 +19,17 @@
     "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>"
   },
+  "v5_page_migration": {
+    "page_tree_not_avaliable": "Page Tree 功能不可用",
+    "go_to_settings": "进入设置,启用该功能",
+    "migration_desc": "Some of the public pages have the old schema. To take advantage of new features such as page trees and easy renaming, please upgrade the schema of all your pages. ",
+    "migration_note": "Note: You will lose unique constraints from the page paths.",
+    "upgrade_to_v5": "Upgrade to V5",
+    "modal_migration_warning": "This process may take long. It is highly recommended that administrators tell users not to create, modify, or delete pages during migration.",
+    "start_upgrading": "Start upgrading",
+    "successfully_started": "Succeeded to start migration",
+    "already_upgraded": "You have already completed upgrading"
+  },
   "app_setting": {
     "site_name": "网站名称 ",
     "sitename_change": "您可以更改用于标题和HTML标题的网站名称。",
@@ -188,6 +199,9 @@
     "beta_warning": "这个函数是Beta。",
     "import_from": "Import from {{from}}",
     "import_growi_archive": "Import GROWI archive",
+    "error": {
+      "only_upsert_available": "Only 'Upsert' option is available for pages collection."
+    },
     "growi_settings": {
       "description_of_import_mode": {
         "about": "When you import data with the same name as an existing one, choose from the following three modes below.",
@@ -449,6 +463,7 @@
   },
   "user_group_management": {
     "create_group": "创建新组",
+    "add_child_group": "添加一个子组",
     "deny_create_group": "不能用当前设置创建新组。",
     "group_name": "组名",
     "group_example": "e.g.:第1组",
@@ -461,6 +476,7 @@
       "backward_match": "向后匹配"
     },
     "group_list": "组列表",
+    "child_group_list": "儿童组名单",
     "back_to_list": "返回组列表",
     "basic_info": "基本信息",
     "user_list": "用户列表",

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

@@ -12,6 +12,7 @@
   "tablet":"平板",
 	"Login": "登录",
 	"Click to copy": "点击复制",
+  "Rename": "重命名",
 	"Move/Rename": "移动/重命名",
 	"Moved": "移动",
 	"Redirected": "重定向",
@@ -21,6 +22,7 @@
   "Done": "Done",
   "Cancel": "取消",
 	"Create": "创建",
+  "Description": "描述",
 	"Admin": "管理",
 	"administrator": "管理员",
 	"Tag": "标签",
@@ -41,6 +43,7 @@
 	"Initialize": "初始化",
   "Update": "更新",
 	"Update Page": "更新本页",
+	"Error": "误差",
 	"Warning": "警告",
   "Sign in": "登录",
 	"Sign up is here": "注册",
@@ -59,6 +62,7 @@
 	"Presentation Mode": "演示文稿",
   "The end": "结束",
   "Not available for guest": "Not available for guest",
+  "No users have liked this yet": "还没有用户喜欢这个",
   "No users have bookmarked yet": "还没有用户加入书签",
   "Create Archive Page": "创建归档页",
   "File type": "文件类型",
@@ -66,6 +70,7 @@
   "Include Attachment File": "包含附件",
   "Include Comment": "包含评论",
   "Include Subordinated Page": "包括子页面",
+  "Include Subordinated Target Page": "包括 {{target}}",
   "All Subordinated Page": "所有子页面",
   "Specify Hierarchy": "指定层级",
   "Submitted the request to create the archive": "提交创建归档请求",
@@ -115,6 +120,9 @@
 	"Create under": "Create page under below:",
 	"Wiki Management Home Page": "Wiki管理首页",
 	"App Settings": "系统设置",
+  "V5 Page Migration": "V5 Page Migration",
+  "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设置",
 	"Markdown Settings": "Markdown设置",
 	"Customize": "页面定制",
@@ -124,6 +132,7 @@
   "Legacy_Slack_Integration": "旧版Slack一体化",
 	"User_Management": "用户管理",
 	"external_account_management": "外部账户管理",
+  "UserGroup": "用户组",
 	"UserGroup Management": "用户组管理",
 	"Full Text Search Management": "全文搜索管理",
 	"Import Data": "导入数据",
@@ -157,8 +166,11 @@
 	"Sign out": "退出",
   "Disassociate": "解除关联",
   "No bookmarks yet": "暂无书签",
+  "add_bookmark": "添加到书签",
+  "remove_bookmark": "从书签中删除",
 	"Recent Created": "最新创建",
   "Recent Changes": "最新修改",
+  "Page Tree": "页面树",
   "original_path":"Original path",
   "new_path":"New path",
   "duplicated_path":"duplicated_path",
@@ -166,7 +178,9 @@
 	"form_validation": {
 		"error_message": "有些值不正确",
 		"required": "%s 是必需的",
-		"invalid_syntax": "%s的语法无效。"
+		"invalid_syntax": "%s的语法无效。",
+    "title_required": "标题是必需的。",
+    "slashed_are_not_yet_supported": "スラッシュを含むタイトルにはまだ対応していません"
   },
   "not_found_page": {
     "Create Page": "创建页面",
@@ -485,7 +499,10 @@
     "page_not_found_in_preview": "\"{{path}}\" is not a GROWI page."
   },
 	"toaster": {
+    "create_succeeded": "Succeeded to create {{target}}",
+    "create_failed": "Failed to create {{target}}",
 		"update_successed": "Succeeded to update {{target}}",
+    "update_failed": "Failed to update {{target}}",
     "initialize_successed": "Succeeded to initialize {{target}}",
 		"give_user_admin": "Succeeded to give {{username}} admin",
     "remove_user_admin": "Succeeded to remove {{username}} admin ",
@@ -874,13 +891,24 @@
 		"use_os_settings": "使用操作系统设置"
 	},
 	"search_result": {
-		"result_meta": "在{{total}中找到了{{keyword}。",
+		"result_meta": "搜索结果:",
 		"deletion_mode_btn_lavel": "选择并删除页面",
 		"cancel": "取消",
 		"delete": "删除",
 		"check_all": "全部检查",
 		"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": "按更新日期排序"
+    }
 	},
 	"to_cloud_settings": "進入 GROWI.cloud 的管理界面",
 	"login": {
@@ -947,5 +975,14 @@
     "success_to_send_email": "我发了一封电子邮件",
     "incorrect_token_or_expired_url":"令牌不正确或 URL 已过期。 请通过以下链接重新发送密码重置请求",
     "password_and_confirm_password_does_not_match": "密码和确认密码不匹配"
+  },
+  "pagetree": {
+    "private_legacy_pages": "私人遗留页面",
+    "cannot_rename_a_title_that_contains_slash": "不能重命名包含 ’/' 的标题"
+  },
+  "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.json → packages/app/resource/search/mappings-es6.json

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

+ 115 - 0
packages/app/resource/search/mappings-es7.json

@@ -0,0 +1,115 @@
+{
+  "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": "kuromoji_tokenizer",
+          "char_filter" : ["icu_normalizer"]
+        },
+        "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"
+      }
+    }
+  }
+}

+ 18 - 17
packages/app/src/client/app.jsx

@@ -2,6 +2,8 @@ import React from 'react';
 import ReactDOM from 'react-dom';
 import { Provider } from 'unstated';
 import { I18nextProvider } from 'react-i18next';
+import { DndProvider } from 'react-dnd';
+import { HTML5Backend } from 'react-dnd-html5-backend';
 
 import { SWRConfig } from 'swr';
 
@@ -34,13 +36,12 @@ import PageStatusAlert from '../components/PageStatusAlert';
 import RecentCreated from '../components/RecentCreated/RecentCreated';
 import RecentlyCreatedIcon from '../components/Icons/RecentlyCreatedIcon';
 import MyDraftList from '../components/MyDraftList/MyDraftList';
-import BookmarkIcon from '../components/Icons/BookmarkIcon';
 import BookmarkList from '../components/PageList/BookmarkList';
-import LikerList from '../components/User/LikerList';
 import Fab from '../components/Fab';
 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 IdenticalPathPage from '~/components/IdenticalPathPage';
 
 import ContextExtractor from '~/client/services/ContextExtractor';
 import PageContainer from '~/client/services/PageContainer';
@@ -50,9 +51,9 @@ import CommentContainer from '~/client/services/CommentContainer';
 import EditorContainer from '~/client/services/EditorContainer';
 import TagContainer from '~/client/services/TagContainer';
 import PersonalContainer from '~/client/services/PersonalContainer';
-import PageAccessoriesContainer from '~/client/services/PageAccessoriesContainer';
 
 import { appContainer, componentMappings } from './base';
+import { toastError } from './util/apiNotification';
 
 const logger = loggerFactory('growi:cli:app');
 
@@ -69,10 +70,9 @@ const commentContainer = new CommentContainer(appContainer);
 const editorContainer = new EditorContainer(appContainer, defaultEditorOptions, defaultPreviewOptions);
 const tagContainer = new TagContainer(appContainer);
 const personalContainer = new PersonalContainer(appContainer);
-const pageAccessoriesContainer = new PageAccessoriesContainer(appContainer);
 const injectableContainers = [
   appContainer, socketIoContainer, pageContainer, pageHistoryContainer, revisionComparerContainer,
-  commentContainer, editorContainer, tagContainer, personalContainer, pageAccessoriesContainer,
+  commentContainer, editorContainer, tagContainer, personalContainer,
 ];
 
 logger.info('unstated containers have been initialized');
@@ -87,6 +87,7 @@ Object.assign(componentMappings, {
 
   'search-page': <SearchPage crowi={appContainer} />,
   'all-in-app-notifications': <InAppNotificationPage />,
+  'identical-path-page': <IdenticalPathPage />,
 
   // 'revision-history': <PageHistory pageId={pageId} />,
   'tags-page': <TagsList crowi={appContainer} />,
@@ -98,12 +99,8 @@ Object.assign(componentMappings, {
   'trash-page-list': <TrashPageList />,
 
   '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 />,
 
@@ -117,6 +114,9 @@ Object.assign(componentMappings, {
   'duplicated-alert': <DuplicatedAlert />,
   'redirected-alert': <RedirectedAlert />,
   'renamed-alert': <RenamedAlert />,
+  'not-found-alert': <NotFoundAlert
+    isGuestUserMode={appContainer.isGuestUser}
+  />,
 });
 
 // additional definitions if data exists
@@ -125,16 +125,15 @@ if (pageContainer.state.pageId != null) {
     'page-comments-list': <PageComments />,
     'page-comment-write': <CommentEditorLazyRenderer />,
     'page-management': <PageManagement />,
-    'liker-list': <LikerList />,
     'page-content-footer': <PageContentFooter />,
 
     'recent-created-icon': <RecentlyCreatedIcon />,
-    'user-bookmark-icon': <BookmarkIcon />,
   });
 
   // show the Page accessory modal when query of "compare" is requested
   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) {
@@ -147,8 +146,8 @@ if (pageContainer.state.path != null) {
   Object.assign(componentMappings, {
     // eslint-disable-next-line quote-props
     '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 />,
   });
 }
@@ -162,7 +161,9 @@ const renderMainComponents = () => {
           <ErrorBoundary>
             <SWRConfig value={swrGlobalConfiguration}>
               <Provider inject={injectableContainers}>
-                {componentMappings[key]}
+                <DndProvider backend={HTML5Backend}>
+                  {componentMappings[key]}
+                </DndProvider>
               </Provider>
             </SWRConfig>
           </ErrorBoundary>

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

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

+ 7 - 7
packages/app/src/client/nologin.jsx

@@ -16,17 +16,17 @@ import CompleteUserRegistrationForm from '~/components/CompleteUserRegistrationF
 const i18n = i18nFactory();
 
 // render InstallerForm
-const installerFormElem = document.getElementById('installer-form');
-if (installerFormElem) {
-  const userName = installerFormElem.dataset.userName;
-  const name = installerFormElem.dataset.name;
-  const email = installerFormElem.dataset.email;
-  const csrf = installerFormElem.dataset.csrf;
+const installerFormContainerElem = document.getElementById('installer-form-container');
+if (installerFormContainerElem) {
+  const userName = installerFormContainerElem.dataset.userName;
+  const name = installerFormContainerElem.dataset.name;
+  const email = installerFormContainerElem.dataset.email;
+  const csrf = installerFormContainerElem.dataset.csrf;
   ReactDOM.render(
     <I18nextProvider i18n={i18n}>
       <InstallerForm userName={userName} name={name} email={email} csrf={csrf} />
     </I18nextProvider>,
-    installerFormElem,
+    installerFormContainerElem,
   );
 }
 

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

@@ -22,6 +22,7 @@ export default class AdminAppContainer extends Container {
       isEmailPublishedForNewUser: true,
       fileUpload: '',
 
+      isV5Compatible: null,
       siteUrl: '',
       envSiteUrl: '',
       isSetSiteUrl: true,
@@ -81,6 +82,7 @@ export default class AdminAppContainer extends Container {
       globalLang: appSettingsParams.globalLang,
       isEmailPublishedForNewUser: appSettingsParams.isEmailPublishedForNewUser,
       fileUpload: appSettingsParams.fileUpload,
+      isV5Compatible: appSettingsParams.isV5Compatible,
       siteUrl: appSettingsParams.siteUrl,
       envSiteUrl: appSettingsParams.envSiteUrl,
       isSetSiteUrl: !!appSettingsParams.siteUrl,
@@ -160,6 +162,13 @@ export default class AdminAppContainer extends Container {
     this.setState({ fileUpload });
   }
 
+  /**
+   * Change site url
+   */
+  changeIsV5Compatible(isV5Compatible) {
+    this.setState({ isV5Compatible });
+  }
+
   /**
    * Change site url
    */
@@ -440,5 +449,14 @@ export default class AdminAppContainer extends Container {
     return pluginSettingParams;
   }
 
+  /**
+   * Start v5 page migration
+   * @memberOf AdminAppContainer
+   */
+  async v5PageMigrationHandler() {
+    const response = await this.appContainer.apiv3.post('/pages/v5-schema-migration');
+    const { isV5Compatible } = response.data;
+    return { isV5Compatible };
+  }
 
 }

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

@@ -25,13 +25,13 @@ export default class AdminHomeContainer extends Container {
     this.timer = null;
 
     this.state = {
-      retrieveError: null,
       growiVersion: '',
       nodeVersion: '',
       npmVersion: '',
       yarnVersion: '',
       copyState: this.copyStateValues.DEFAULT,
       installedPlugins: [],
+      isV5Compatible: null,
     };
 
   }
@@ -63,11 +63,12 @@ export default class AdminHomeContainer extends Container {
         yarnVersion: adminHomeParams.yarnVersion,
         installedPlugins: adminHomeParams.installedPlugins,
         envVars: adminHomeParams.envVars,
+        isV5Compatible: adminHomeParams.isV5Compatible,
       }));
     }
     catch (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 loggerFactory from '~/utils/logger';
 
 import { toastError } from '../util/apiNotification';
 
+import {
+  apiv3Get, apiv3Delete, apiv3Put, apiv3Post,
+} from '~/client/util/apiv3-client';
+
 // eslint-disable-next-line no-unused-vars
 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)
  * @extends {Container} unstated Container
  */
-export default class AdminAdminUserGroupDetailContainer extends Container {
+export default class AdminUserGroupDetailContainer extends Container {
 
   constructor(appContainer) {
     super();
@@ -27,8 +35,14 @@ export default class AdminAdminUserGroupDetailContainer extends Container {
     this.state = {
       // TODO: [SPA] get userGroup from props
       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,
       searchType: 'partial',
       isAlsoMailSearched: false,
@@ -61,8 +75,8 @@ export default class AdminAdminUserGroupDetailContainer extends Container {
         userGroupRelations,
         relatedPages,
       ] = 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({
@@ -105,7 +119,7 @@ export default class AdminAdminUserGroupDetailContainer extends Container {
    * @return {object} response object
    */
   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;
 
     await this.setState({ userGroup });
@@ -136,7 +150,7 @@ export default class AdminAdminUserGroupDetailContainer extends Container {
    * @param {string} username username of the user to be searched
    */
   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,
       searchType: this.state.searchType,
       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
    */
   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
     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
    */
   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) => {
       return {

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

@@ -2,14 +2,14 @@ import React, { FC, useEffect, useState } from 'react';
 import { pagePathUtils } from '@growi/core';
 
 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';
+  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,
+} from '../../stores/context';
 import {
-  useIsDeviceSmallerThanMd,
+  useIsDeviceSmallerThanMd, useIsDeviceSmallerThanLg,
   usePreferDrawerModeByUser, usePreferDrawerModeOnEditByUser, useSidebarCollapsed, useCurrentSidebarContents, useCurrentProductNavWidth,
   useSelectedGrant, useSelectedGrantGroupId, useSelectedGrantGroupName,
 } from '~/stores/ui';
@@ -22,6 +22,8 @@ const jsonNull = 'null';
 const ContextExtractorOnce: FC = () => {
 
   const mainContent = document.querySelector('#content-main');
+  const notFoundContent = document.getElementById('growi-pagetree-not-found-context');
+  const forbiddenContent = document.getElementById('forbidden-page');
 
   /*
    * App Context from DOM
@@ -49,13 +51,12 @@ const ContextExtractorOnce: FC = () => {
   const updatedAt: Date | null = (updatedAtAttribute != null) ? new Date(updatedAtAttribute) : 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 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 hasChildren = JSON.parse(mainContent?.getAttribute('data-page-has-children') || jsonNull);
   const templateTagData = mainContent?.getAttribute('data-template-tags') || null;
@@ -68,10 +69,15 @@ const ContextExtractorOnce: FC = () => {
   const hasDraftOnHackmd = !!mainContent?.getAttribute('data-page-has-draft-on-hackmd');
   const creator = JSON.parse(mainContent?.getAttribute('data-page-creator') || 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(notFoundContent?.getAttribute('data-not-found-target-path-or-id') || jsonNull);
   const slackChannels = mainContent?.getAttribute('data-slack-channels') || '';
+  const isSearchPage = document.getElementById('search-page') != null;
+
   const grant = +(mainContent?.getAttribute('data-page-grant') || 1);
   const grantGroupId = mainContent?.getAttribute('data-page-grant-group') || null;
   const grantGroupName = mainContent?.getAttribute('data-page-grant-group-name') || null;
+
   /*
    * use static swr
    */
@@ -91,15 +97,14 @@ const ContextExtractorOnce: FC = () => {
   useDeletedAt(deletedAt);
   useHasChildren(hasChildren);
   useHasDraftOnHackmd(hasDraftOnHackmd);
-  useIsAbleToDeleteCompletely(isAbleToDeleteCompletely);
-  useIsDeletable(isDeletable);
+  useIsIdenticalPath(isIdenticalPath);
   useIsDeleted(isDeleted);
   useIsNotCreatable(isNotCreatable);
-  useIsPageExist(isPageExist);
+  useIsForbidden(isForbidden);
   useIsTrashPage(isTrashPage);
   useIsUserPage(isUserPage);
   useLastUpdateUsername(lastUpdateUsername);
-  usePageId(pageId);
+  useCurrentPageId(pageId);
   usePageIdOnHackmd(pageIdOnHackmd);
   usePageUser(pageUser);
   useCurrentPagePath(path);
@@ -112,6 +117,14 @@ const ContextExtractorOnce: FC = () => {
   useCurrentUpdatedAt(updatedAt);
   useCreator(creator);
   useRevisionAuthor(revisionAuthor);
+  useTargetAndAncestors(targetAndAncestors);
+  useNotFoundTargetPathOrId(notFoundTargetPathOrId);
+  useIsSearchPage(isSearchPage);
+
+  // Navigation
+  usePreferDrawerModeByUser();
+  usePreferDrawerModeOnEditByUser();
+  useIsDeviceSmallerThanMd();
 
   // Navigation
   usePreferDrawerModeByUser();
@@ -124,6 +137,9 @@ const ContextExtractorOnce: FC = () => {
   useSelectedGrantGroupId(grantGroupId);
   useSelectedGrantGroupName(grantGroupName);
 
+  // SearchResult
+  useIsDeviceSmallerThanLg();
+
   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),
-    });
-  }
-
-}

+ 0 - 199
packages/app/src/client/services/PageContainer.js

@@ -54,15 +54,6 @@ export default class PageContainer extends Container {
       path,
       tocHtml: '',
 
-      seenUsers: [],
-      seenUserIds: [],
-      sumOfSeenUsers: [],
-
-      isLiked: false,
-      likers: [],
-      likerIds: [],
-      sumOfLikers: 0,
-
       createdAt: mainContent.getAttribute('data-page-created-at'),
       // please use useCurrentUpdatedAt instead
       updatedAt: mainContent.getAttribute('data-page-updated-at'),
@@ -71,9 +62,7 @@ export default class PageContainer extends Container {
       isUserPage: JSON.parse(mainContent.getAttribute('data-page-user')) != null,
       isTrashPage: isTrashPage(path),
       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')),
-      isAbleToDeleteCompletely: JSON.parse(mainContent.getAttribute('data-page-is-able-to-delete-completely')),
       isPageExist: mainContent.getAttribute('data-page-id') != null,
 
       pageUser: JSON.parse(mainContent.getAttribute('data-page-user')),
@@ -117,23 +106,9 @@ export default class PageContainer extends Container {
     interceptorManager.addInterceptor(new RestoreCodeBlockInterceptor(appContainer), 900); // process as late as possible
 
     this.initStateMarkdown();
-    this.checkAndUpdateImageUrlCached(this.state.likers);
-
-    const { isSharedUser } = this.appContainer;
-
-    // see https://dev.growi.org/5fabddf8bbeb1a0048bcb9e9
-    const isAbleToGetAttachedInformationAboutPages = this.state.isPageExist && !isSharedUser;
-
-    if (isAbleToGetAttachedInformationAboutPages) {
-      // We don't retrieve bookmarks in the initial page load
-      // as it is stored in a separate collection to like and seen user
-      // data so it has a separate api endpoint.
-      this.initialPageLoad();
-    }
 
     this.setTocHtml = this.setTocHtml.bind(this);
     this.save = this.save.bind(this);
-    this.checkAndUpdateImageUrlCached = this.checkAndUpdateImageUrlCached.bind(this);
 
     this.emitJoinPageRoomRequest = this.emitJoinPageRoomRequest.bind(this);
     this.emitJoinPageRoomRequest();
@@ -163,71 +138,6 @@ export default class PageContainer extends Container {
     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
    * not displayed when guest user and not on trash page
@@ -266,72 +176,6 @@ export default class PageContainer extends Container {
     this.state.markdown = markdown;
   }
 
-
-  async initialPageLoad() {
-    {
-      const {
-        data: {
-          likerIds, sumOfLikers, isLiked, seenUserIds, sumOfSeenUsers, isSeen,
-        },
-      } = await this.appContainer.apiv3Get('/page/info', { pageId: this.state.pageId });
-
-      await this.setState({
-        sumOfLikers,
-        isLiked,
-        likerIds,
-        seenUserIds,
-        sumOfSeenUsers,
-        isSeen,
-      });
-    }
-
-    await this.retrieveLikersAndSeenUsers();
-  }
-
-  async toggleLike() {
-    {
-      const toggledIsLiked = !this.state.isLiked;
-      await this.appContainer.apiv3Put('/page/likes', { pageId: this.state.pageId, bool: toggledIsLiked });
-
-      await this.setState(state => ({
-        isLiked: toggledIsLiked,
-        sumOfLikers: toggledIsLiked ? state.sumOfLikers + 1 : state.sumOfLikers - 1,
-        likerIds: toggledIsLiked
-          ? [...this.state.likerIds, this.appContainer.currentUserId]
-          : state.likerIds.filter(id => id !== this.appContainer.currentUserId),
-      }));
-    }
-
-    await this.retrieveLikersAndSeenUsers();
-  }
-
-  async retrieveLikersAndSeenUsers() {
-    const { users } = await this.appContainer.apiGet('/users.list', { user_ids: [...this.state.likerIds, ...this.state.seenUserIds].join(',') });
-
-    await this.setState({
-      likers: users.filter(({ id }) => this.state.likerIds.includes(id)).slice(0, 15),
-      seenUsers: users.filter(({ id }) => this.state.seenUserIds.includes(id)).slice(0, 15),
-    });
-
-    this.checkAndUpdateImageUrlCached(users);
-  }
-
-  async checkAndUpdateImageUrlCached(users) {
-    const noImageCacheUsers = users.filter((user) => { return user.imageUrlCached == null });
-    if (noImageCacheUsers.length === 0) {
-      return;
-    }
-
-    const noImageCacheUserIds = noImageCacheUsers.map((user) => { return user.id });
-    try {
-      await this.appContainer.apiv3Put('/users/update.imageUrlCache', { userIds: noImageCacheUserIds });
-    }
-    catch (err) {
-      // Error alert doesn't apear, because user don't need to notice this error.
-      logger.error(err);
-    }
-  }
-
   setLatestRemotePageData(s2cMessagePageUpdated) {
     const newState = {
       remoteRevisionId: s2cMessagePageUpdated.revisionId,
@@ -529,49 +373,6 @@ export default class PageContainer extends Container {
     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() {
     toastr.success(undefined, 'Saved successfully', {
       closeButton: true,

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

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

@@ -1,6 +1,6 @@
 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;
 
   // 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;
 
-  window.scrollTo({
+  scrollElement.scrollTo({
     top,
     behavior: 'smooth',
   });

+ 81 - 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 { withTranslation } from 'react-i18next';
+import { useTranslation } from 'react-i18next';
 import { CopyToClipboard } from 'react-copy-to-clipboard';
 import { Tooltip } from 'reactstrap';
 import loggerFactory from '~/utils/logger';
@@ -10,99 +10,112 @@ import { toastError } from '~/client/util/apiNotification';
 import { withUnstatedContainers } from '../../UnstatedUtils';
 import AppContainer from '~/client/services/AppContainer';
 import AdminHomeContainer from '~/client/services/AdminHomeContainer';
+import { useSWRxV5MigrationStatus } from '~/stores/page-listing';
 import SystemInfomationTable from './SystemInfomationTable';
 import InstalledPluginTable from './InstalledPluginTable';
 import EnvVarsTable from './EnvVarsTable';
 
 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 {
       await adminHomeContainer.retrieveAdminHomeData();
     }
     catch (err) {
       toastError(err);
-      adminHomeContainer.setState({ retrieveError: 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 (
+    <>
+      {
+      // 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>
+        )
+      }
+      <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 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 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 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.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-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.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>
-      </Fragment>
-    );
-  }
+      </div>
+    </>
+  );
+};
 
-}
 
 const AdminHomeWrapper = withUnstatedContainers(AdminHome, [AppContainer, AdminHomeContainer]);
 
 AdminHome.propTypes = {
-  t: PropTypes.func.isRequired, // i18next
   appContainer: PropTypes.instanceOf(AppContainer).isRequired,
   adminHomeContainer: PropTypes.instanceOf(AdminHomeContainer).isRequired,
 };
 
-export default withTranslation()(AdminHomeWrapper);
+export default AdminHomeWrapper;

+ 25 - 2
packages/app/src/components/Admin/App/AppSettingsPageContents.jsx

@@ -2,19 +2,36 @@ import React, { Fragment } from 'react';
 import { withTranslation } from 'react-i18next';
 import PropTypes from 'prop-types';
 
+import { withUnstatedContainers } from '../../UnstatedUtils';
 import AppSetting from './AppSetting';
 import SiteUrlSetting from './SiteUrlSetting';
 import MailSetting from './MailSetting';
 import PluginSetting from './PluginSetting';
 import FileUploadSetting from './FileUploadSetting';
+import V5PageMigration from './V5PageMigration';
+
+import AdminAppContainer from '~/client/services/AdminAppContainer';
 
 class AppSettingsPageContents extends React.Component {
 
   render() {
-    const { t } = this.props;
+    const { t, adminAppContainer } = this.props;
+    const { isV5Compatible } = adminAppContainer.state;
 
     return (
       <Fragment>
+        {
+          !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="col-lg-12">
             <h2 className="admin-setting-header">{t('App Settings')}</h2>
@@ -55,8 +72,14 @@ class AppSettingsPageContents extends React.Component {
 
 }
 
+/**
+ * Wrapper component for using unstated
+ */
+const AppSettingsPageContentsWrapper = withUnstatedContainers(AppSettingsPageContents, [AdminAppContainer]);
+
 AppSettingsPageContents.propTypes = {
   t: PropTypes.func.isRequired, // i18next
+  adminAppContainer: PropTypes.instanceOf(AdminAppContainer).isRequired,
 };
 
-export default withTranslation()(AppSettingsPageContents);
+export default withTranslation()(AppSettingsPageContentsWrapper);

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

@@ -0,0 +1,57 @@
+import React, { FC, useState } from 'react';
+import { useTranslation } from 'react-i18next';
+import { V5PageMigrationModal } from './V5PageMigrationModal';
+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 (
+    <>
+      <V5PageMigrationModal
+        isModalOpen={isV5PageMigrationModalShown}
+        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)}>Upgrade to v5</button>
+        </div>
+      </div>
+    </>
+  );
+};
+
+export default withUnstatedContainers(V5PageMigration, [AdminAppContainer]);

+ 61 - 0
packages/app/src/components/Admin/App/V5PageMigrationModal.tsx

@@ -0,0 +1,61 @@
+import React, { FC } from 'react';
+import {
+  Modal, ModalHeader, ModalBody, ModalFooter,
+} from 'reactstrap';
+import { useTranslation } from 'react-i18next';
+
+type V5PageMigrationModalProps = {
+  isModalOpen: boolean
+  onConfirm?: () => Promise<void>;
+  onCancel?: () => void;
+};
+
+export const V5PageMigrationModal: FC<V5PageMigrationModalProps> = (props: V5PageMigrationModalProps) => {
+  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-warning">
+        <i className="icon-fw icon-question" />
+        Warning
+      </ModalHeader>
+      <ModalBody>
+        {t('admin:v5_page_migration.modal_migration_warning')}
+        <br />
+        <br />
+        <span className="text-danger">
+          <i className="icon-exclamation icon-fw"></i>
+          {t('admin:v5_page_migration.migration_note')}
+        </span>
+      </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}
+        >
+          {t('admin:v5_page_migration.start_upgrading')}
+        </button>
+      </ModalFooter>
+    </Modal>
+  );
+};

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

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

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

@@ -116,7 +116,7 @@ class GlobalNotificationList extends React.Component {
                   )}
                   {notification.triggerEvents.includes('pageLike') && (
                     <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>
                   )}
                   {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')}
                 >
                   <span className="badge badge-pill badge-info">
-                    <i className="icon-like mr-1" />LIKE
+                    <i className="fa fa-heart-o mr-1" />LIKE
                   </span>
                 </TriggerEventCheckBox>
               </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);

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

@@ -0,0 +1,219 @@
+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 AppContainer from '~/client/services/AppContainer';
+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 = {
+  appContainer: AppContainer,
+
+  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-5">
+          {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;

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

@@ -0,0 +1,119 @@
+import React, { FC, useCallback, useState } from 'react';
+import { useTranslation } from 'react-i18next';
+import dateFnsFormat from 'date-fns/format';
+import { TFunctionResult } from 'i18next';
+
+import { withUnstatedContainers } from '../../UnstatedUtils';
+import AppContainer from '~/client/services/AppContainer';
+import { toastSuccess, toastError } from '~/client/util/apiNotification';
+import { IUserGroup, IUserGroupHasId } from '~/interfaces/user';
+import { CustomWindow } from '~/interfaces/global';
+import Xss from '~/services/xss';
+
+type Props = {
+  userGroup?: IUserGroupHasId,
+  successedMessage: TFunctionResult;
+  failedMessage: TFunctionResult;
+  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();
+
+  /*
+   * State
+   */
+  const [currentName, setName] = useState(props.userGroup != null ? props.userGroup.name : '');
+  const [currentDescription, setDescription] = useState(props.userGroup != null ? props.userGroup.description : '');
+  const [currentParent, setParent] = useState(props.userGroup != null ? props.userGroup.parent : '');
+
+  /*
+   * 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 (props.onSubmit == null) {
+      return;
+    }
+
+    try {
+      await props.onSubmit({ name: currentName, description: currentDescription, parent: currentParent });
+
+      toastSuccess(props.successedMessage);
+    }
+    catch (err) {
+      toastError(props.failedMessage);
+    }
+  }, [currentName, currentDescription, currentParent, props.onSubmit, props.successedMessage, props.failedMessage]);
+
+  return (
+    <form onSubmit={onSubmitHandler}>
+
+      <fieldset>
+        <h2 className="admin-setting-header">{t('admin:user_group_management.basic_info')}</h2>
+        {/* TODO 85062: improve style */}
+        {
+          props.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(props.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} required />
+          </div>
+        </div>
+
+        {/* TODO 85062: select parent dropdown */}
+
+        <div className="form-group row">
+          <div className="offset-md-2 col-md-10">
+            <button type="submit" className="btn btn-primary">
+              {props.submitButtonLabel}
+            </button>
+          </div>
+        </div>
+      </fieldset>
+    </form>
+  );
+};
+
+/**
+ * Wrapper component for using unstated
+ */
+const UserGroupFormWrapper = withUnstatedContainers<unknown, Props>(UserGroupForm, [AppContainer]);
+
+export default UserGroupFormWrapper;

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

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

@@ -0,0 +1,158 @@
+import React, {
+  FC, Fragment, useState, useCallback,
+} from 'react';
+import { useTranslation } from 'react-i18next';
+
+import UserGroupTable from './UserGroupTable';
+import UserGroupForm from './UserGroupForm';
+import UserGroupDeleteModal from './UserGroupDeleteModal';
+
+import { withUnstatedContainers } from '../../UnstatedUtils';
+import AppContainer from '~/client/services/AppContainer';
+import { toastSuccess, toastError } from '~/client/util/apiNotification';
+import { IUserGroup, IUserGroupHasId } from '~/interfaces/user';
+import Xss from '~/services/xss';
+import { CustomWindow } from '~/interfaces/global';
+import { apiv3Delete, apiv3Post } from '~/client/util/apiv3-client';
+import { useSWRxUserGroupList, useSWRxChildUserGroupList, useSWRxUserGroupRelationList } from '~/stores/user-group';
+
+type Props = {
+  appContainer: AppContainer,
+};
+
+const UserGroupPage: FC<Props> = (props: Props) => {
+  const xss: Xss = (window as CustomWindow).xss;
+  const { t } = useTranslation();
+  const { isAclEnabled } = props.appContainer.config;
+
+  /*
+   * Fetch
+   */
+  const { data: userGroups, mutate: mutateUserGroups } = useSWRxUserGroupList();
+  const userGroupIds = userGroups?.map(group => group._id);
+  const { data: userGroupRelations, mutate: mutateUserGroupRelations } = useSWRxUserGroupRelationList(userGroupIds);
+  const { data: childUserGroups } = useSWRxChildUserGroupList(userGroupIds);
+
+  /*
+   * State
+   */
+  const [selectedUserGroup, setSelectedUserGroup] = useState<IUserGroupHasId | undefined>(undefined); // not null but undefined (to use defaultProps in UserGroupDeleteModal)
+  const [isDeleteModalShown, setDeleteModalShown] = useState<boolean>(false);
+
+  /*
+   * Functions
+   */
+  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 addUserGroup = useCallback(async(userGroupData: IUserGroup) => {
+    try {
+      await apiv3Post('/user-groups', {
+        name: userGroupData.name,
+        description: userGroupData.description,
+        parent: userGroupData.parent,
+      });
+
+      // sync
+      await mutateUserGroups();
+    }
+    catch (err) {
+      toastError(err);
+    }
+  }, [mutateUserGroups]);
+
+  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, mutateUserGroupRelations]);
+
+  if (userGroups == null || userGroupRelations == null || childUserGroups == null) {
+    return <></>;
+  }
+
+  return (
+    <Fragment>
+      {
+        isAclEnabled ? (
+          <div className="mb-2">
+            <button type="button" className="btn btn-outline-secondary" data-toggle="collapse" data-target="#createGroupForm">
+              {t('admin:user_group_management.create_group')}
+            </button>
+            <div id="createGroupForm" className="collapse">
+              <UserGroupForm
+                successedMessage={t('toaster.create_succeeded', { target: t('UserGroup') })}
+                failedMessage={t('toaster.create_failed', { target: t('UserGroup') })}
+                submitButtonLabel={t('Create')}
+                onSubmit={addUserGroup}
+              />
+            </div>
+          </div>
+        ) : (
+          t('admin:user_group_management.deny_create_group')
+        )
+      }
+      <UserGroupTable
+        appContainer={props.appContainer}
+        userGroups={userGroups}
+        childUserGroups={childUserGroups}
+        isAclEnabled={isAclEnabled}
+        onDelete={showDeleteModal}
+        userGroupRelations={userGroupRelations}
+      />
+      <UserGroupDeleteModal
+        appContainer={props.appContainer}
+        userGroups={userGroups}
+        deleteUserGroup={selectedUserGroup}
+        onDelete={deleteUserGroupById}
+        isShow={isDeleteModalShown}
+        onShow={showDeleteModal}
+        onHide={hideDeleteModal}
+      />
+    </Fragment>
+  );
+};
+
+/**
+ * Wrapper component for using unstated
+ */
+const UserGroupPageWrapper = withUnstatedContainers(UserGroupPage, [AppContainer]);
+
+export default UserGroupPageWrapper;

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

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

@@ -0,0 +1,187 @@
+import React, {
+  FC, useState, useCallback, useEffect,
+} from 'react';
+import { useTranslation } from 'react-i18next';
+import dateFnsFormat from 'date-fns/format';
+
+import Xss from '~/services/xss';
+import AppContainer from '~/client/services/AppContainer';
+import { IUserGroupHasId, IUserGroupRelation, IUserHasId } from '~/interfaces/user';
+import { CustomWindow } from '~/interfaces/global';
+
+
+type Props = {
+  appContainer: AppContainer,
+
+  userGroups: IUserGroupHasId[],
+  userGroupRelations: IUserGroupRelation[],
+  childUserGroups: IUserGroupHasId[],
+  isAclEnabled: boolean,
+  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 onClickDelete = useCallback((e) => { // no preventDefault
+    if (props.onDelete == null) {
+      return;
+    }
+
+    const groupId = e.target.getAttribute('data-user-group-id');
+    const group = props.userGroups.find((group) => {
+      return group._id === groupId;
+    });
+
+    if (group == null) {
+      return;
+    }
+
+    props.onDelete(group);
+  }, [props.userGroups, props.onDelete]);
+
+  /*
+   * useEffect
+   */
+  useEffect(() => {
+    setGroupIdToUsersMap(generateGroupIdToUsersMap(props.userGroupRelations));
+    setGroupIdToChildGroupsMap(generateGroupIdToChildGroupsMap(props.childUserGroups));
+  }, [props.userGroupRelations, props.childUserGroups]);
+
+  return (
+    <>
+      <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('Description')}</th>
+            <th>{t('User')}</th>
+            <th>{t('Child groups')}</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}`}>
+                          <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={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);

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

@@ -0,0 +1,179 @@
+import React, {
+  FC, useState, useCallback,
+} from 'react';
+import { useTranslation } from 'react-i18next';
+
+import UserGroupForm from '../UserGroup/UserGroupForm';
+import UserGroupDropdown from '../UserGroup/UserGroupDropdown';
+import UserGroupUserTable from './UserGroupUserTable';
+import UserGroupUserModal from './UserGroupUserModal';
+import UserGroupPageList from './UserGroupPageList';
+import { withUnstatedContainers } from '../../UnstatedUtils';
+import AppContainer from '~/client/services/AppContainer';
+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, IUserGroupRelation,
+} from '~/interfaces/user';
+import { useSWRxUserGroupPages, useSWRxUserGroupRelations, useSWRxSelectableUserGroups } from '~/stores/user-group';
+
+
+const UserGroupDetailPage: FC = () => {
+  const rootElem = document.getElementById('admin-user-group-detail');
+  const { t } = useTranslation();
+
+  /*
+   * State (from AdminUserGroupDetailContainer)
+   */
+  const [userGroup, setUserGroup] = useState<IUserGroupHasId>(JSON.parse(rootElem?.getAttribute('data-user-group') || 'null'));
+
+  // TODO 85062: /_api/v3/user-groups/children?include_grand_child=boolean
+  const [childUserGroups, setChildUserGroups] = useState<IUserGroupHasId[]>([]); // TODO 85062: fetch data on init (findChildGroupsByParentIds) For child group list
+  const [grandChildUserGroups, setGrandChildUserGroups] = useState<IUserGroupHasId[]>([]); // TODO 85062: fetch data on init (findChildGroupsByParentIds) For child group list
+
+  const [childUserGroupRelations, setChildUserGroupRelations] = useState<IUserGroupRelation[]>([]); // TODO 85062: fetch data on init (findRelationsByGroupIds) For child group list
+  const [relatedPages, setRelatedPages] = useState<IPageHasId[]>([]); // For page list
+  const [isUserGroupUserModalOpen, setUserGroupUserModalOpen] = useState<boolean>(false);
+  const [searchType, setSearchType] = useState<string>('partial');
+  const [isAlsoMailSearched, setAlsoMailSearched] = useState<boolean>(false);
+  const [isAlsoNameSearched, setAlsoNameSearched] = useState<boolean>(false);
+
+  /*
+   * Fetch
+   */
+  const { data: userGroupPages } = useSWRxUserGroupPages(userGroup._id, 10, 0);
+  const { data: userGroupRelations, mutate: mutateUserGroupRelations } = useSWRxUserGroupRelations(userGroup._id);
+  const { data: selectableUserGroups, mutate: mutateSelectableUserGroups } = useSWRxSelectableUserGroups(userGroup._id);
+
+  /*
+   * 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(param: Partial<IUserGroup>) => {
+    const res = await apiv3Put<{ userGroup: IUserGroupHasId }>(`/user-groups/${userGroup._id}`, param);
+    const { userGroup: newUserGroup } = res.data;
+
+    setUserGroup(newUserGroup);
+
+    return newUserGroup;
+  }, [userGroup]);
+
+  const openUserGroupUserModal = useCallback(() => {
+    setUserGroupUserModalOpen(true);
+  }, []);
+
+  const closeUserGroupUserModal = useCallback(() => {
+    setUserGroupUserModalOpen(false);
+  }, []);
+
+  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 onClickAddChildButtonHandler = async(selectedUserGroup: IUserGroupHasId) => {
+    try {
+      await apiv3Put(`/user-groups/${selectedUserGroup._id}`, {
+        name: selectedUserGroup.name,
+        description: selectedUserGroup.description,
+        parentId: userGroup._id,
+        forceUpdateParents: false, //  TODO 87748: Make forceUpdateParents optionally selectable
+      });
+      mutateSelectableUserGroups();
+      toastSuccess(t('toaster.update_successed', { target: t('UserGroup') }));
+    }
+    catch (err) {
+      toastError(err);
+    }
+  };
+
+  // TODO 87614: UserGroup New creation form can be displayed in modal
+  const onClickCreateChildGroupButtonHandler = () => {
+    console.log('button clicked!');
+  };
+
+  /*
+   * Dependencies
+   */
+  if (userGroup == null) {
+    return <></>;
+  }
+
+  return (
+    <div>
+      <a href="/admin/user-groups" className="btn btn-outline-secondary">
+        <i className="icon-fw ti-arrow-left" aria-hidden="true"></i>
+        {t('admin:user_group_management.back_to_list')}
+      </a>
+      {/* TODO 85062: Link to the ancestors group */}
+      <div className="mt-4 form-box">
+        <UserGroupForm
+          userGroup={userGroup}
+          successedMessage={t('toaster.update_successed', { target: t('UserGroup') })}
+          failedMessage={t('toaster.update_failed', { target: t('UserGroup') })}
+          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={selectableUserGroups}
+        onClickAddExistingUserGroupButtonHandler={onClickAddChildButtonHandler}
+        onClickCreateUserGroupButtonHandler={() => onClickCreateChildGroupButtonHandler()}
+      />
+
+      <h2 className="admin-setting-header mt-4">{t('Page')}</h2>
+      <div className="page-list">
+        <UserGroupPageList />
+      </div>
+    </div>
+  );
+
+};
+
+
+/**
+ * Wrapper component for using unstated
+ */
+const UserGroupDetailPageWrapper = withUnstatedContainers(UserGroupDetailPage, [AppContainer]);
+
+export default UserGroupDetailPageWrapper;

+ 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 { withTranslation } from 'react-i18next';
 
-import Page from '../../PageList/Page';
+import PageListItemS from '../../PageList/PageListItemS';
 import PaginationWrapper from '../../PaginationWrapper';
 import { withUnstatedContainers } from '../../UnstatedUtils';
 import AppContainer from '~/client/services/AppContainer';
@@ -57,7 +57,7 @@ class UserGroupPageList extends React.Component {
     return (
       <Fragment>
         <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>
         {relatedPages.length === 0 ? <p>{t('admin:user_group_management.no_pages')}</p> : (
           <PaginationWrapper

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

@@ -1,49 +1,39 @@
 import React, { FC, useState } from 'react';
 
-import { Types } from 'mongoose';
 import { UncontrolledTooltip, Popover, PopoverBody } from 'reactstrap';
 import { useTranslation } from 'react-i18next';
 
+import { IUser } from '../interfaces/user';
+
 import UserPictureList from './User/UserPictureList';
-import { toastError } from '~/client/util/apiNotification';
 import { useIsGuestUser } from '~/stores/context';
-import { useSWRxBookmarksInfo } from '~/stores/bookmarks';
-import { apiv3Put } from '~/client/util/apiv3-client';
 
 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 { pageId } = props;
+
+  const {
+    bookmarkCount, isBookmarked, bookmarkedUsers, hideTotalNumber,
+  } = props;
 
   const [isPopoverOpen, setIsPopoverOpen] = useState(false);
 
   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 = () => {
     setIsPopoverOpen(!isPopoverOpen);
   };
 
   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
           ${isBookmarked ? 'active' : ''} ${isGuestUser ? 'disabled' : ''}`}
       >
-        <i className="icon-star"></i>
+        <i className={`fa ${isBookmarked ? 'fa-bookmark' : 'fa-bookmark-o'}`}></i>
       </button>
 
       {isGuestUser && (
@@ -65,19 +55,24 @@ const BookmarkButton: FC<Props> = (props: Props) => {
         </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>
   );
 };
 
-export default BookmarkButton;
+export default BookmarkButtons;

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

@@ -0,0 +1,127 @@
+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 = {
+  isShown: boolean
+  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 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);
+  };
+
+  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={props.isShown ? 'd-block' : 'd-none'}>
+      <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}
+      />
+      <AlertInfo />
+    </div>
+  );
+});
+
+export default ClosableTextInput;

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

@@ -0,0 +1,259 @@
+import React, { useState, useCallback } from 'react';
+import {
+  Dropdown, DropdownMenu, DropdownToggle, DropdownItem,
+} from 'reactstrap';
+
+import toastr from 'toastr';
+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 type AdditionalMenuItemsRendererProps = { pageInfo: IPageInfoAll };
+
+type CommonProps = {
+  pageInfo?: IPageInfoAll,
+  isEnableActions?: boolean,
+  showBookmarkMenuItem?: boolean,
+  onClickBookmarkMenuItem?: (pageId: string, newValue?: boolean) => Promise<void>,
+  onClickDuplicateMenuItem?: () => Promise<void> | void,
+  onClickRenameMenuItem?: (pageId: string) => Promise<void> | void,
+  onClickDeleteMenuItem?: (pageId: string) => void,
+
+  additionalMenuItemRenderer?: React.FunctionComponent<AdditionalMenuItemsRendererProps>,
+}
+
+
+type DropdownMenuProps = CommonProps & {
+  pageId: string,
+  isLoading?: boolean,
+}
+
+const PageItemControlDropdownMenu = React.memo((props: DropdownMenuProps): JSX.Element => {
+  const { t } = useTranslation('');
+
+  const {
+    pageId, isLoading,
+    pageInfo, isEnableActions, showBookmarkMenuItem,
+    onClickBookmarkMenuItem, onClickDuplicateMenuItem, onClickRenameMenuItem, onClickDeleteMenuItem,
+    additionalMenuItemRenderer: AdditionalMenuItems,
+  } = 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();
+  }, [onClickDuplicateMenuItem]);
+
+  // eslint-disable-next-line react-hooks/rules-of-hooks
+  const renameItemClickedHandler = useCallback(async() => {
+    if (onClickRenameMenuItem == null) {
+      return;
+    }
+    await onClickRenameMenuItem(pageId);
+  }, [onClickRenameMenuItem, 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);
+  }, [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) {
+    contents = (
+      <>
+        { !isEnableActions && (
+          <DropdownItem>
+            <p>
+              {t('search_result.currently_not_implemented')}
+            </p>
+          </DropdownItem>
+        ) }
+
+        {/* Bookmark */}
+        { showBookmarkMenuItem && 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 */}
+        { isEnableActions && !pageInfo.isEmpty && (
+          <DropdownItem onClick={duplicateItemClickedHandler}>
+            <i className="icon-fw icon-docs"></i>
+            {t('Duplicate')}
+          </DropdownItem>
+        ) }
+
+        {/* Move/Rename */}
+        { isEnableActions && pageInfo.isMovable && (
+          <DropdownItem onClick={renameItemClickedHandler}>
+            <i className="icon-fw  icon-action-redo"></i>
+            {t('Move/Rename')}
+          </DropdownItem>
+        ) }
+
+        { AdditionalMenuItems && <AdditionalMenuItems pageInfo={pageInfo} /> }
+
+        {/* divider */}
+        {/* Delete */}
+        { isEnableActions && pageInfo.isMovable && !pageInfo.isEmpty && (
+          <>
+            <DropdownItem divider />
+            <DropdownItem
+              className={`pt-2 ${pageInfo.isDeletable ? 'text-danger' : ''}`}
+              disabled={!pageInfo.isDeletable}
+              onClick={deleteItemClickedHandler}
+            >
+              <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,
+  } = props;
+
+  const [isOpen, setIsOpen] = useState(false);
+
+  const shouldFetch = fetchOnInit === true || (!isIPageInfoForOperation(presetPageInfo) && isOpen);
+  const shouldMutate = fetchOnInit === true || !isIPageInfoForOperation(presetPageInfo);
+
+  const { data: fetchedPageInfo, mutate: mutatePageInfo } = useSWRxPageInfo(shouldFetch ? pageId : null);
+
+  // mutate after handle event
+  const bookmarkMenuItemClickHandler = useCallback(async(_pageId: string, _newValue: boolean) => {
+    if (onClickBookmarkMenuItem != null) {
+      await onClickBookmarkMenuItem(_pageId, _newValue);
+    }
+
+    if (shouldMutate) {
+      mutatePageInfo();
+    }
+  }, [mutatePageInfo, onClickBookmarkMenuItem, shouldMutate]);
+
+  const isLoading = shouldFetch && fetchedPageInfo == null;
+
+  const duplicateMenuItemClickHandler = useCallback(async() => {
+    if (onClickDuplicateMenuItem == null) {
+      return;
+    }
+    await onClickDuplicateMenuItem();
+  }, [onClickDuplicateMenuItem]);
+
+  const renameMenuItemClickHandler = useCallback(async() => {
+    if (onClickRenameMenuItem == null) {
+      return;
+    }
+    await onClickRenameMenuItem(pageId);
+  }, [onClickRenameMenuItem, pageId]);
+
+  return (
+    <Dropdown isOpen={isOpen} toggle={() => setIsOpen(!isOpen)}>
+
+      { children ?? (
+        <DropdownToggle color="transparent" className="border-0 rounded btn-page-item-control">
+          <i className="icon-options text-muted"></i>
+        </DropdownToggle>
+      ) }
+
+      <PageItemControlDropdownMenu
+        {...props}
+        isLoading={isLoading}
+        pageInfo={fetchedPageInfo ?? presetPageInfo}
+        onClickBookmarkMenuItem={bookmarkMenuItemClickHandler}
+        onClickDuplicateMenuItem={duplicateMenuItemClickHandler}
+        onClickRenameMenuItem={renameMenuItemClickHandler}
+      />
+    </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} />;
+};

+ 3 - 11
packages/app/src/components/ComparePathsTable.jsx

@@ -3,17 +3,14 @@ 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,
+    path, subordinatedPages, newPagePath, t,
   } = props;
-  const { path } = pageContainer.state;
 
   return (
     <table className="table table-bordered grw-compare-paths-table">
@@ -45,18 +42,13 @@ function ComparePathsTable(props) {
 }
 
 
-/**
- * Wrapper component for using unstated
- */
-const PageDuplicateModallWrapper = withUnstatedContainers(ComparePathsTable, [PageContainer]);
-
 ComparePathsTable.propTypes = {
   t: PropTypes.func.isRequired, //  i18next
 
-  pageContainer: PropTypes.instanceOf(PageContainer).isRequired,
+  path: PropTypes.string.isRequired,
   subordinatedPages: PropTypes.array.isRequired,
   newPagePath: PropTypes.string.isRequired,
 };
 
 
-export default withTranslation()(PageDuplicateModallWrapper);
+export default withTranslation()(ComparePathsTable);

+ 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 { pathUtils } from '@growi/core';
 import urljoin from 'url-join';
-import { withUnstatedContainers } from './UnstatedUtils';
 
-import PageContainer from '~/client/services/PageContainer';
 
 const CreateTemplateModal = (props) => {
-  const { t, pageContainer } = props;
+  const { t, path } = props;
 
-  const { path } = pageContainer.state;
   const parentPath = pathUtils.addTrailingSlash(path);
 
   function generateUrl(label) {
@@ -67,18 +64,11 @@ const CreateTemplateModal = (props) => {
 };
 
 
-/**
- * Wrapper component for using unstated
- */
-const CreateTemplateModalWrapper = withUnstatedContainers(CreateTemplateModal, [PageContainer]);
-
-
 CreateTemplateModal.propTypes = {
   t: PropTypes.func.isRequired, //  i18next
-  pageContainer: PropTypes.instanceOf(PageContainer).isRequired,
-
+  path: PropTypes.string.isRequired,
   isOpen: PropTypes.bool.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 {
   TabContent, TabPane,
 } 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 [activatedContent, setActivatedContent] = useState<Set<string>>(new Set<string>());
+
+  // add activated content to Set
+  useEffect(() => {
+    setActivatedContent(activatedContent.add(activeTab));
+  }, [activatedContent, activeTab]);
+
   return (
-    <TabContent activeTab={activeTab} className={additionalClassNames.join(' ')}>
+    <TabContent activeTab={activeTab} className={additionalClassNames != null ? additionalClassNames.join(' ') : ''}>
       {Object.entries(navTabMapping).map(([key, value]) => {
 
+        const shouldRender = key === activeTab || activatedContent.has(key);
         const { Content } = value;
 
         return (
           <TabPane key={key} tabId={key}>
-            <Content />
+            { shouldRender && <Content /> }
           </TabPane>
         );
       })}

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

@@ -0,0 +1,106 @@
+import React, { useState } from 'react';
+import {
+  IPageHasId, IPageWithMeta,
+} from '~/interfaces/page';
+import { IPagingResult } from '~/interfaces/paging-result';
+import { useIsGuestUser, useIsSharedUser } from '~/stores/context';
+
+import { useSWRxPageInfoForList, useSWRxPageList } from '~/stores/page';
+
+import PageList from './PageList/PageList';
+import PaginationWrapper from './PaginationWrapper';
+
+type Props = {
+  path: string,
+}
+
+
+const convertToIPageWithEmptyMeta = (page: IPageHasId): IPageWithMeta => {
+  return { pageData: page };
+};
+
+const DescendantsPageList = (props: Props): JSX.Element => {
+  const { path } = props;
+
+  const [activePage, setActivePage] = useState(1);
+
+  const { data: isGuestUser } = useIsGuestUser();
+  const { data: isSharedUser } = useIsSharedUser();
+
+  const { data: pagingResult, error } = useSWRxPageList(isSharedUser ? null : path, activePage);
+
+  const pageIds = pagingResult?.items?.map(page => page._id);
+  const { data: idToPageInfo } = useSWRxPageInfoForList(pageIds);
+
+  let pagingResultWithMeta: IPagingResult<IPageWithMeta> | undefined;
+
+  // initial data
+  if (pagingResult != null) {
+    const pages = pagingResult.items;
+
+    // convert without meta at first
+    pagingResultWithMeta = {
+      ...pagingResult,
+      items: pages.map(page => convertToIPageWithEmptyMeta(page)),
+    };
+  }
+
+  // inject data for listing
+  if (pagingResult != null) {
+    const pages = pagingResult.items;
+
+    const pageWithMetas = pages.map((page) => {
+      const pageInfo = (idToPageInfo ?? {})[page._id];
+
+      return {
+        pageData: page,
+        pageMeta: pageInfo,
+      } as IPageWithMeta;
+    });
+
+    pagingResultWithMeta = {
+      ...pagingResult,
+      items: pageWithMetas,
+    };
+  }
+
+  function setPageNumber(selectedPageNumber) {
+    setActivePage(selectedPageNumber);
+  }
+
+  if (error != null) {
+    return (
+      <div className="my-5">
+        <div className="text-danger">{error.message}</div>
+      </div>
+    );
+  }
+
+  if (pagingResult == null || pagingResultWithMeta == 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>
+    );
+  }
+
+  return (
+    <>
+      <PageList pages={pagingResultWithMeta} isEnableActions={!isGuestUser} />
+
+      <div className="my-4">
+        <PaginationWrapper
+          activePage={activePage}
+          changePage={setPageNumber}
+          totalItemsCount={pagingResult.totalCount}
+          pagingLimit={pagingResult.limit}
+          align="center"
+        />
+      </div>
+    </>
+  );
+};
+
+export default DescendantsPageList;

+ 100 - 0
packages/app/src/components/DescendantsPageListModal.tsx

@@ -0,0 +1,100 @@
+
+import React, { useState, useMemo } from 'react';
+import { useTranslation } from 'react-i18next';
+
+import {
+  Modal, ModalHeader, ModalBody,
+} from 'reactstrap';
+
+import { useDescendantsPageListModal } from '~/stores/ui';
+import { useIsSharedUser } from '~/stores/context';
+
+import DescendantsPageList from './DescendantsPageList';
+import ExpandOrContractButton from './ExpandOrContractButton';
+import { CustomNavTab } from './CustomNavigation/CustomNav';
+import PageListIcon from './Icons/PageListIcon';
+import TimeLineIcon from './Icons/TimeLineIcon';
+import CustomTabContent from './CustomNavigation/CustomTabContent';
+import PageTimeline from './PageTimeline';
+
+
+type Props = {
+}
+
+export const DescendantsPageListModal = (props: Props): JSX.Element => {
+  const { t } = useTranslation();
+
+  const [activeTab, setActiveTab] = useState('pagelist');
+  const [isWindowExpanded, setIsWindowExpanded] = useState(false);
+
+  const { data: isSharedUser } = useIsSharedUser();
+
+  const { data: status, close } = useDescendantsPageListModal();
+
+  const navTabMapping = useMemo(() => {
+    return {
+      pagelist: {
+        Icon: PageListIcon,
+        Content: () => {
+          if (status == null || status.path == null || !status.isOpened) {
+            return <></>;
+          }
+          return <DescendantsPageList path={status.path} />;
+        },
+        i18n: t('page_list'),
+        index: 0,
+        isLinkEnabled: () => !isSharedUser,
+      },
+      timeline: {
+        Icon: TimeLineIcon,
+        Content: () => <PageTimeline />,
+        i18n: t('Timeline View'),
+        index: 1,
+        isLinkEnabled: () => !isSharedUser,
+      },
+    };
+  }, [isSharedUser, status, t]);
+
+  const buttons = useMemo(() => (
+    <div className="d-flex flex-nowrap">
+      <ExpandOrContractButton
+        isWindowExpanded={isWindowExpanded}
+        expandWindow={() => setIsWindowExpanded(true)}
+        contractWindow={() => setIsWindowExpanded(false)}
+      />
+      <button type="button" className="close" onClick={close} aria-label="Close">
+        <span aria-hidden="true">&times;</span>
+      </button>
+    </div>
+  ), [close, isWindowExpanded]);
+
+
+  if (status == null) {
+    return <></>;
+  }
+
+  const { isOpened } = status;
+
+  return (
+    <Modal
+      size="xl"
+      isOpen={isOpened}
+      toggle={close}
+      className={`grw-page-accessories-modal ${isWindowExpanded ? 'grw-modal-expanded' : ''} `}
+    >
+      <ModalHeader className="p-0" toggle={close} close={buttons}>
+        <CustomNavTab
+          activeTab={activeTab}
+          navTabMapping={navTabMapping}
+          breakpointToHideInactiveTabsDown="md"
+          onNavSelected={v => setActiveTab(v)}
+          hideBorderBottom
+        />
+      </ModalHeader>
+      <ModalBody>
+        <CustomTabContent activeTab={activeTab} navTabMapping={navTabMapping} />
+      </ModalBody>
+    </Modal>
+  );
+
+};

+ 3 - 3
packages/app/src/components/EventListeneres/HashChanged.tsx

@@ -1,4 +1,4 @@
-import { FC, useCallback, useEffect } from 'react';
+import React, { useCallback, useEffect } from 'react';
 
 import { useEditorMode, determineEditorModeByHash } from '~/stores/ui';
 import { useIsEditable } from '~/stores/context';
@@ -6,7 +6,7 @@ import { useIsEditable } from '~/stores/context';
 /**
  * Change editorMode by browser forward/back operation
  */
-const HashChanged: FC<void> = () => {
+const HashChanged = (): JSX.Element => {
   const { data: isEditable } = useIsEditable();
   const { data: editorMode, mutate: mutateEditorMode } = useEditorMode();
 
@@ -33,7 +33,7 @@ const HashChanged: FC<void> = () => {
 
   }, [hashchangeHandler, isEditable, mutateEditorMode]);
 
-  return null;
+  return <></>;
 };
 
 export default HashChanged;

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

@@ -5,8 +5,9 @@ import loggerFactory from '~/utils/logger';
 
 
 import AppContainer from '~/client/services/AppContainer';
+
+import { useCreateModalStatus } from '~/stores/ui';
 import { smoothScrollIntoView } from '~/client/util/smooth-scroll';
-import { usePageCreateModalOpened } from '~/stores/ui';
 
 import { withUnstatedContainers } from './UnstatedUtils';
 import CreatePageIcon from './Icons/CreatePageIcon';
@@ -18,7 +19,7 @@ const Fab = (props) => {
   const { appContainer } = props;
   const { currentUser } = appContainer;
 
-  const { mutate: mutatePageCreateModalOpened } = usePageCreateModalOpened();
+  const { open: openCreateModal } = useCreateModalStatus();
 
   const [animateClasses, setAnimateClasses] = useState('invisible');
   const [buttonClasses, setButtonClasses] = useState('');
@@ -56,7 +57,7 @@ const Fab = (props) => {
           <button
             type="button"
             className={`btn btn-lg btn-create-page btn-primary rounded-circle p-0 waves-effect waves-light ${buttonClasses}`}
-            onClick={() => mutatePageCreateModalOpened(true)}
+            onClick={() => openCreateModal()}
           >
             <CreatePageIcon />
           </button>

+ 20 - 17
packages/app/src/components/ForbiddenPage.jsx → packages/app/src/components/ForbiddenPage.tsx

@@ -1,19 +1,23 @@
 import React, { useMemo } from 'react';
-import PropTypes from 'prop-types';
-import { withTranslation } from 'react-i18next';
+import { useTranslation } from 'react-i18next';
+
 import PageListIcon from './Icons/PageListIcon';
 import CustomNavAndContents from './CustomNavigation/CustomNavAndContents';
-import PageList from './PageList';
+import DescendantsPageList from './DescendantsPageList';
+
 
+type Props = {
+  isLinkSharingDisabled?: boolean,
+}
 
-const ForbiddenPage = (props) => {
-  const { t } = props;
+const ForbiddenPage = React.memo((props: Props): JSX.Element => {
+  const { t } = useTranslation();
 
   const navTabMapping = useMemo(() => {
     return {
       pagelist: {
         Icon: PageListIcon,
-        Content: PageList,
+        Content: DescendantsPageList,
         i18n: t('page_list'),
         index: 0,
       },
@@ -31,24 +35,23 @@ const ForbiddenPage = (props) => {
         </div>
       </div>
 
-
       <div className="row row-alerts d-edit-none">
         <div className="col-sm-12">
           <p className="alert alert-primary py-3 px-4">
             <i className="icon-fw icon-lock" aria-hidden="true" />
-            {t('Browsing of this page is restricted')}
+            { props.isLinkSharingDisabled ? t('custom_navigation.link_sharing_is_disabled') : t('Browsing of this page is restricted')}
           </p>
         </div>
       </div>
-      <div className="mt-5">
-        <CustomNavAndContents navTabMapping={navTabMapping} />
-      </div>
+
+      { !props.isLinkSharingDisabled && (
+        <div className="mt-5">
+          <CustomNavAndContents navTabMapping={navTabMapping} />
+        </div>
+      ) }
+
     </>
   );
-};
-
-ForbiddenPage.propTypes = {
-  t: PropTypes.func.isRequired,
-};
+});
 
-export default withTranslation()(ForbiddenPage);
+export default ForbiddenPage;

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

@@ -1,19 +1,19 @@
 import React, { useEffect } from 'react';
 import PropTypes from 'prop-types';
 
-import { usePageCreateModalOpened } from '~/stores/ui';
+import { useCreateModalStatus } from '~/stores/ui';
 
 const CreatePage = React.memo((props) => {
 
-  const { mutate } = usePageCreateModalOpened();
+  const { open: openCreateModal } = useCreateModalStatus();
 
   // setup effect
   useEffect(() => {
-    mutate(true);
+    openCreateModal();
 
     // remove this
     props.onDeleteRender(this);
-  }, [mutate, props]);
+  }, [openCreateModal, props]);
 
   return <></>;
 });

+ 2 - 1
packages/app/src/components/Icons/AttachmentIcon.jsx

@@ -4,7 +4,8 @@ const Attachment = () => (
   <svg
     xmlns="http://www.w3.org/2000/svg"
     viewBox="0 0 14 14"
-
+    width="14px"
+    height="14px"
   >
     <rect width="14" height="14" fillOpacity="0" />
     <g className="cls-1">

+ 0 - 28
packages/app/src/components/Icons/BookmarkIcon.jsx

@@ -1,28 +0,0 @@
-import React from 'react';
-
-const BookmarkIcon = () => (
-
-  <svg
-    xmlns="http://www.w3.org/2000/svg"
-    width="20"
-    height="20"
-    viewBox="0 0 20 20"
-  >
-
-    <g transform="translate(-925.888 168.873)">
-      <rect width="20" height="20" transform="translate(925.888 -168.873)" fill="none" />
-      <path d="M936.092-168.527a1.141,1.141,0,0,1,.205.039,1.685,1.685,0,0,1,.185.068c.058.026.116.056.175.088a1.038,1.038,0,0,1,
-        .166.117,1.826,1.826,0,0,1,.146.146c.045.052.088.1.127.156a.8.8,0,0,1,.1.175l2.26,4.7,5.2.76a1.424,1.424,0,0,1,.7.311,1.413,
-        1.413,0,0,1,.449.643,1.294,1.294,0,0,1-.351,1.423l-3.8,3.8.876,5.28a1.225,1.225,0,0,1-.088.76,1.451,1.451,0,0,1-.5.6,1.456,
-        1.456,0,0,1-.838.253,1.614,1.614,0,0,1-.351-.039,1.316,1.316,0,0,1-.35-.137l-4.52-2.435-4.54,2.435a1.37,1.37,0,0,1-.682.176h-.156a.525.525,
-        0,0,1-.146-.02l-.137-.039a1.117,1.117,0,0,1-.136-.049,1.231,1.231,0,0,1-.136-.068c-.046-.026-.088-.052-.127-.077a1.462,1.462,
-        0,0,1-.5-.6,1.232,1.232,0,0,1-.087-.76l.877-5.28-3.8-3.8a1.29,1.29,0,0,1-.35-1.423,1.4,1.4,0,0,1,.448-.643,1.423,1.423,0,0,1,
-        .7-.311l5.2-.76,2.26-4.7a1.351,1.351,0,0,1,.526-.584,1.467,1.467,0,0,1,.78-.215C935.953-168.537,936.02-168.533,936.092-168.527Zm-2.49,
-        5.9-.41.84-6.1.9,4.415,4.415-.136.879-.9,5.275,5.412-2.891,5.411,2.891-.9-5.275-.137-.879,4.415-4.415-6.115-.9-2.676-5.587Z"
-      />
-    </g>
-  </svg>
-
-);
-
-export default BookmarkIcon;

+ 2 - 1
packages/app/src/components/Icons/HistoryIcon.jsx

@@ -4,7 +4,8 @@ const RecentChanges = () => (
   <svg
     xmlns="http://www.w3.org/2000/svg"
     viewBox="0 0 14 14"
-
+    width="14px"
+    height="14px"
   >
     <rect width="14" height="14" fillOpacity="0" />
     <path

+ 1 - 1
packages/app/src/components/Icons/ShareLinkIcon.jsx

@@ -1,7 +1,7 @@
 import React from 'react';
 
 const ShareLink = () => (
-  <svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 20 20">
+  <svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 20 20">
     <g transform="translate(-142 -502)">
       <rect width="20" height="20" transform="translate(142 502)" fill="none" />
       <g transform="translate(16 286.938)">

+ 17 - 0
packages/app/src/components/Icons/TriangleIcon.tsx

@@ -0,0 +1,17 @@
+import React from 'react';
+
+const TriangleIcon = (): JSX.Element => (
+  <svg
+    xmlns="http://www.w3.org/2000/svg"
+    width="12"
+    height="12"
+    viewBox="0 0 12 12"
+  >
+    <g transform="translate(18194 -6790)">
+      <rect width="12" height="12" transform="translate(-18194 6790)" fill="none" />
+      <path d="M5.2,1.067a1,1,0,0,1,1.6,0l4,5.333A1,1,0,0,1,10,8H2a1,1,0,0,1-.8-1.6Z" transform="translate(-18183 6790) rotate(90)" />
+    </g>
+  </svg>
+);
+
+export default TriangleIcon;

+ 128 - 0
packages/app/src/components/IdenticalPathPage.tsx

@@ -0,0 +1,128 @@
+import React, { FC } from 'react';
+import { useTranslation } from 'react-i18next';
+
+import { DevidedPagePath } from '@growi/core';
+
+import { IPageHasId, IPageWithMeta } from '~/interfaces/page';
+import { useCurrentPagePath, useIsSharedUser } from '~/stores/context';
+import { useDescendantsPageListModal } from '~/stores/ui';
+import { useSWRxPageInfoForList } from '~/stores/page';
+
+import PageListIcon from './Icons/PageListIcon';
+import { PageListItemL } from './PageList/PageListItemL';
+
+
+type IdenticalPathAlertProps = {
+  path? : string | null,
+}
+
+const IdenticalPathAlert : FC<IdenticalPathAlertProps> = (props: IdenticalPathAlertProps) => {
+  const { path } = props;
+  const { t } = useTranslation();
+
+  let _path = '――';
+  let _pageName = '――';
+
+  if (path != null) {
+    const devidedPath = new DevidedPagePath(path);
+    _path = devidedPath.isFormerRoot ? '/' : devidedPath.former;
+    _pageName = devidedPath.latter;
+  }
+
+
+  return (
+    <div className="alert alert-warning py-3">
+      <h5 className="font-weight-bold mt-1">{t('duplicated_page_alert.same_page_name_exists', { pageName: _pageName })}</h5>
+      <p>
+        {t('duplicated_page_alert.same_page_name_exists_at_path',
+          { path: _path, pageName: _pageName })}<br />
+        <span
+          // eslint-disable-next-line react/no-danger
+          dangerouslySetInnerHTML={{ __html: t('See_more_detail_on_new_schema', { url: t('GROWI.5.0_new_schema') }) }}
+        />
+      </p>
+      <p className="mb-1">{t('duplicated_page_alert.select_page_to_see')}</p>
+    </div>
+  );
+};
+
+
+type IdenticalPathPageProps= {
+  // add props and types here
+}
+
+
+const jsonNull = 'null';
+
+const IdenticalPathPage:FC<IdenticalPathPageProps> = (props: IdenticalPathPageProps) => {
+  const { t } = useTranslation();
+
+  const identicalPageDocument = document.getElementById('identical-path-page');
+  const pages = JSON.parse(identicalPageDocument?.getAttribute('data-identical-path-pages') || jsonNull) as IPageHasId[];
+
+  const pageIds = pages.map(page => page._id) as string[];
+
+
+  const { data: currentPath } = useCurrentPagePath();
+  const { data: isSharedUser } = useIsSharedUser();
+
+  const { data: idToPageInfoMap } = useSWRxPageInfoForList(pageIds);
+
+  const { open: openDescendantPageListModal } = useDescendantsPageListModal();
+
+  return (
+    <div className="d-flex flex-column flex-lg-row-reverse">
+
+      <div className="grw-side-contents-container">
+        <div className="grw-page-accessories-control pb-1">
+          { currentPath != null && !isSharedUser && (
+            <button
+              type="button"
+              className="btn btn-block btn-outline-secondary grw-btn-page-accessories rounded-pill d-flex justify-content-between"
+              onClick={() => openDescendantPageListModal(currentPath)}
+            >
+              <PageListIcon />
+              {t('page_list')}
+              <span></span> {/* for a count badge */}
+            </button>
+          ) }
+        </div>
+      </div>
+
+      <div className="flex-grow-1 flex-basis-0 mw-0">
+
+        <IdenticalPathAlert path={currentPath} />
+
+        <div className="page-list">
+          <ul className="page-list-ul list-group list-group-flush">
+            {pages.map((page) => {
+              const pageId = page._id;
+              const pageInfo = (idToPageInfoMap ?? {})[pageId];
+
+              const pageWithMeta: IPageWithMeta = {
+                pageData: page,
+                pageMeta: pageInfo,
+              };
+
+              return (
+                <PageListItemL
+                  key={pageId}
+                  page={pageWithMeta}
+                  isSelected={false}
+                  isChecked={false}
+                  isEnableActions
+                  showPageUpdatedTime
+                // Todo: add onClickDeleteButton when delete feature implemented
+                />
+              );
+            })}
+          </ul>
+        </div>
+
+      </div>
+
+    </div>
+  );
+};
+
+export default IdenticalPathPage;

+ 1 - 2
packages/app/src/components/InAppNotification/PageNotification/PageModelNotification.tsx

@@ -24,7 +24,6 @@ const PageModelNotification: ForwardRefRenderFunction<IInAppNotificationOpenable
   } = props;
 
   const snapshot = parseSnapshot(notification.snapshot);
-  const pagePath = { path: snapshot.path };
 
   // publish open()
   useImperativeHandle(ref, () => ({
@@ -42,7 +41,7 @@ const PageModelNotification: ForwardRefRenderFunction<IInAppNotificationOpenable
   return (
     <div className="p-2">
       <div>
-        <b>{actionUsers}</b> {actionMsg} <PagePathLabel page={pagePath} />
+        <b>{actionUsers}</b> {actionMsg} <PagePathLabel path={snapshot.path} />
       </div>
       <i className={`${actionIcon} mr-2`} />
       <FormattedDistanceDate

+ 14 - 4
packages/app/src/components/InstallerForm.jsx

@@ -55,8 +55,6 @@ class InstallerForm extends React.Component {
     setTimeout(() => {
       this.setState({ isSubmittingDisabled: false });
     }, 3000);
-
-    document['register-form'].submit();
   }
 
   render() {
@@ -66,7 +64,7 @@ class InstallerForm extends React.Component {
       : <span><i className="icon-fw icon-ban" />{ this.props.t('installer.unavaliable_user_id') }</span>;
 
     return (
-      <div className={`login-dialog p-3 mx-auto${hasErrorClass}`}>
+      <div data-testid="installerForm" className={`login-dialog p-3 mx-auto${hasErrorClass}`}>
         <div className="row">
           <div className="col-md-12">
             <p className="alert alert-success">
@@ -84,6 +82,7 @@ class InstallerForm extends React.Component {
                   type="button"
                   className="btn btn-secondary dropdown-toggle text-right w-100 border-0 shadow-none"
                   id="dropdownLanguage"
+                  data-testid="dropdownLanguage"
                   data-toggle="dropdown"
                   aria-haspopup="true"
                   aria-expanded="true"
@@ -100,7 +99,13 @@ class InstallerForm extends React.Component {
                 <div className="dropdown-menu" aria-labelledby="dropdownLanguage">
                   {
                     localeMetadatas.map(meta => (
-                      <button key={meta.id} className="dropdown-item" type="button" onClick={() => { this.changeLanguage(meta) }}>
+                      <button
+                        key={meta.id}
+                        data-testid={`dropdownLanguageMenu-${meta.id}`}
+                        className="dropdown-item"
+                        type="button"
+                        onClick={() => { this.changeLanguage(meta) }}
+                      >
                         {meta.displayName}
                       </button>
                     ))
@@ -114,6 +119,7 @@ class InstallerForm extends React.Component {
                 <span className="input-group-text"><i className="icon-user" /></span>
               </div>
               <input
+                data-testid="tiUsername"
                 type="text"
                 className="form-control"
                 placeholder={this.props.t('User ID')}
@@ -130,6 +136,7 @@ class InstallerForm extends React.Component {
                 <span className="input-group-text"><i className="icon-tag" /></span>
               </div>
               <input
+                data-testid="tiName"
                 type="text"
                 className="form-control"
                 placeholder={this.props.t('Name')}
@@ -144,6 +151,7 @@ class InstallerForm extends React.Component {
                 <span className="input-group-text"><i className="icon-envelope" /></span>
               </div>
               <input
+                data-testid="tiEmail"
                 type="email"
                 className="form-control"
                 placeholder={this.props.t('Email')}
@@ -158,6 +166,7 @@ class InstallerForm extends React.Component {
                 <span className="input-group-text"><i className="icon-lock" /></span>
               </div>
               <input
+                data-testid="tiPassword"
                 type="password"
                 className="form-control"
                 placeholder={this.props.t('Password')}
@@ -170,6 +179,7 @@ class InstallerForm extends React.Component {
 
             <div className="input-group mt-4 mb-3 d-flex justify-content-center">
               <button
+                data-testid="btnSubmit"
                 type="submit"
                 className="btn-fill btn btn-register"
                 id="register"

+ 0 - 102
packages/app/src/components/LikeButtons.jsx

@@ -1,102 +0,0 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-
-import { UncontrolledTooltip, Popover, PopoverBody } from 'reactstrap';
-import { withTranslation } from 'react-i18next';
-import UserPictureList from './User/UserPictureList';
-import { withUnstatedContainers } from './UnstatedUtils';
-
-import { toastError } from '~/client/util/apiNotification';
-import AppContainer from '~/client/services/AppContainer';
-import PageContainer from '~/client/services/PageContainer';
-
-class LikeButtons extends React.Component {
-
-  constructor(props) {
-    super(props);
-
-    this.state = {
-      isPopoverOpen: false,
-    };
-
-    this.togglePopover = this.togglePopover.bind(this);
-    this.handleClick = this.handleClick.bind(this);
-  }
-
-  togglePopover() {
-    this.setState(prevState => ({
-      ...prevState,
-      isPopoverOpen: !prevState.isPopoverOpen,
-    }));
-  }
-
-  async handleClick() {
-    const { appContainer, pageContainer } = this.props;
-    const { isGuestUser } = appContainer;
-
-    if (isGuestUser) {
-      return;
-    }
-
-    try {
-      pageContainer.toggleLike();
-    }
-    catch (err) {
-      toastError(err);
-    }
-  }
-
-  render() {
-    const { appContainer, pageContainer, t } = this.props;
-    const { isGuestUser } = appContainer;
-    const {
-      state: { likers, sumOfLikers, isLiked },
-    } = pageContainer;
-
-    return (
-      <div className="btn-group" role="group" aria-label="Like buttons">
-        <button
-          type="button"
-          id="like-button"
-          onClick={this.handleClick}
-          className={`btn btn-like border-0
-            ${isLiked ? 'active' : ''} ${isGuestUser ? 'disabled' : ''}`}
-        >
-          <i className="icon-like"></i>
-        </button>
-        {isGuestUser && (
-          <UncontrolledTooltip placement="top" target="like-button" fade={false}>
-            {t('Not available for guest')}
-          </UncontrolledTooltip>
-        )}
-
-        <button type="button" id="po-total-likes" className={`btn btn-like border-0 total-likes ${isLiked ? 'active' : ''}`}>
-          {sumOfLikers}
-        </button>
-        <Popover placement="bottom" isOpen={this.state.isPopoverOpen} target="po-total-likes" toggle={this.togglePopover} trigger="legacy">
-          <PopoverBody className="seen-user-popover">
-            <div className="px-2 text-right user-list-content text-truncate text-muted">
-              {likers.length ? <UserPictureList users={likers} /> : t('No users have liked this yet.')}
-            </div>
-          </PopoverBody>
-        </Popover>
-      </div>
-    );
-  }
-
-}
-
-/**
- * Wrapper component for using unstated
- */
-const LikeButtonsWrapper = withUnstatedContainers(LikeButtons, [AppContainer, PageContainer]);
-
-LikeButtons.propTypes = {
-  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
-  pageContainer: PropTypes.instanceOf(PageContainer).isRequired,
-
-  t: PropTypes.func.isRequired,
-  size: PropTypes.string,
-};
-
-export default withTranslation()(LikeButtonsWrapper);

+ 81 - 0
packages/app/src/components/LikeButtons.tsx

@@ -0,0 +1,81 @@
+import React, { FC, useState } from 'react';
+
+import { UncontrolledTooltip, Popover, PopoverBody } from 'reactstrap';
+import { useTranslation } from 'react-i18next';
+import UserPictureList from './User/UserPictureList';
+import { withUnstatedContainers } from './UnstatedUtils';
+
+import AppContainer from '~/client/services/AppContainer';
+import { IUser } from '../interfaces/user';
+
+type LikeButtonsProps = {
+
+  hideTotalNumber?: boolean,
+  sumOfLikers: number,
+  likers: IUser[],
+
+  isGuestUser?: boolean,
+  isLiked?: boolean,
+  onLikeClicked?: ()=>void,
+}
+
+const LikeButtons: FC<LikeButtonsProps> = (props: LikeButtonsProps) => {
+  const { t } = useTranslation();
+
+  const [isPopoverOpen, setIsPopoverOpen] = useState(false);
+
+  const togglePopover = () => {
+    setIsPopoverOpen(!isPopoverOpen);
+  };
+
+  const {
+    hideTotalNumber, isGuestUser, isLiked, sumOfLikers, onLikeClicked,
+  } = props;
+
+  return (
+    <div className="btn-group" role="group" aria-label="Like buttons">
+      <button
+        type="button"
+        id="like-button"
+        onClick={onLikeClicked}
+        className={`btn btn-like border-0
+            ${isLiked ? 'active' : ''} ${isGuestUser ? 'disabled' : ''}`}
+      >
+        <i className={`fa ${isLiked ? 'fa-heart' : 'fa-heart-o'}`}></i>
+      </button>
+      { isGuestUser && (
+        <UncontrolledTooltip placement="top" target="like-button" fade={false}>
+          {t('Not available for guest')}
+        </UncontrolledTooltip>
+      )}
+
+      { !hideTotalNumber && (
+        <>
+          <button type="button" id="po-total-likes" className={`btn btn-like border-0 total-likes ${isLiked ? 'active' : ''}`}>
+            {sumOfLikers}
+          </button>
+          <Popover placement="bottom" isOpen={isPopoverOpen} target="po-total-likes" toggle={togglePopover} trigger="legacy">
+            <PopoverBody className="user-list-popover">
+              <div className="px-2 text-right user-list-content text-truncate text-muted">
+                {props.likers?.length ? <UserPictureList users={props.likers} /> : t('No users have liked this yet.')}
+              </div>
+            </PopoverBody>
+          </Popover>
+        </>
+      ) }
+    </div>
+  );
+
+};
+
+/**
+ * Wrapper component for using unstated
+ */
+const LikeButtonsUnstatedWrapper = withUnstatedContainers(LikeButtons, [AppContainer]);
+
+// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
+const LikeButtonsWrapper = (props) => {
+  return <LikeButtonsUnstatedWrapper {...props}></LikeButtonsUnstatedWrapper>;
+};
+
+export default LikeButtonsWrapper;

+ 14 - 14
packages/app/src/components/LoginForm.jsx

@@ -50,7 +50,7 @@ class LoginForm extends React.Component {
               <i className="icon-user"></i>
             </span>
           </div>
-          <input type="text" className="form-control rounded-0" placeholder="Username or E-mail" name="loginForm[username]" />
+          <input type="text" className="form-control rounded-0" data-testid="tiUsernameForLogin" placeholder="Username or E-mail" name="loginForm[username]" />
           {isLdapStrategySetup && (
             <div className="input-group-append">
               <small className="input-group-text text-success">
@@ -66,12 +66,12 @@ class LoginForm extends React.Component {
               <i className="icon-lock"></i>
             </span>
           </div>
-          <input type="password" className="form-control rounded-0" placeholder="Password" name="loginForm[password]" />
+          <input type="password" className="form-control rounded-0" data-testid="tiPasswordForLogin" placeholder="Password" name="loginForm[password]" />
         </div>
 
         <div className="input-group my-4">
           <input type="hidden" name="_csrf" value={appContainer.csrfToken} />
-          <button type="submit" id="login" className="btn btn-fill rounded-0 login mx-auto">
+          <button type="submit" id="login" className="btn btn-fill rounded-0 login mx-auto" data-testid="btnSubmitForLogin">
             <div className="eff"></div>
             <span className="btn-label">
               <i className="icon-login"></i>
@@ -297,18 +297,18 @@ class LoginForm extends React.Component {
               <div className="front">
                 {isLocalOrLdapStrategiesEnabled && this.renderLocalOrLdapLoginForm()}
                 {isSomeExternalAuthEnabled && this.renderExternalAuthLoginForm()}
+                {isPasswordResetEnabled && (
+                  <div className="text-right mb-2">
+                    <a href="/forgot-password" className="d-block link-switch">
+                      <i className="icon-key"></i> {t('forgot_password.forgot_password')}
+                    </a>
+                  </div>
+                )}
                 {isRegistrationEnabled && (
-                  <div className="row">
-                    <div className="col-12 text-right py-2">
-                      {isPasswordResetEnabled && (
-                        <a href="/forgot-password" className="d-block link-switch mb-1">
-                          <i className="icon-key"></i> {t('forgot_password.forgot_password')}
-                        </a>
-                      )}
-                      <a href="#register" id="register" className="link-switch" onClick={this.switchForm}>
-                        <i className="ti-check-box"></i> {t('Sign up is here')}
-                      </a>
-                    </div>
+                  <div className="text-right mb-2">
+                    <a href="#register" id="register" className="link-switch" onClick={this.switchForm}>
+                      <i className="ti-check-box"></i> {t('Sign up is here')}
+                    </a>
                   </div>
                 )}
               </div>

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

@@ -34,7 +34,7 @@ const AuthorInfo = (props) => {
       if (err instanceof RangeError) {
         return <p>{nullinfoLabelForFooter} <UserPicture user={user} size="sm" /> {userLabel}</p>;
       }
-      return;
+      return <></>;
     }
   }
 

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

@@ -2,15 +2,17 @@ import React, {
   FC, useState, useCallback, useRef,
 } from 'react';
 import { useTranslation } from 'react-i18next';
+import assert from 'assert';
 
 import AppContainer from '~/client/services/AppContainer';
-import { IPage } from '~/interfaces/page';
 import { IFocusable } from '~/client/interfaces/focusable';
+import { useGlobalSearchFormRef } from '~/stores/ui';
+import { IPageSearchMeta } from '~/interfaces/search';
+import { IPageWithMeta } from '~/interfaces/page';
 
 import { withUnstatedContainers } from '../UnstatedUtils';
 
 import SearchForm from '../SearchForm';
-import { useGlobalSearchFormRef } from '~/stores/ui';
 
 
 type Props = {
@@ -31,12 +33,14 @@ const GlobalSearch: FC<Props> = (props: Props) => {
   const [isScopeChildren, setScopeChildren] = useState<boolean>(appContainer.getConfig().isSearchScopeChildrenAsDefault);
   const [isFocused, setFocused] = useState<boolean>(false);
 
-  const gotoPage = useCallback((data: unknown[]) => {
-    const page = data[0] as IPage; // should be single page selected
+  const gotoPage = useCallback((data: IPageWithMeta<IPageSearchMeta>[]) => {
+    assert(data.length > 0);
+
+    const page = data[0].pageData; // should be single page selected
 
     // navigate to page
     if (page != null) {
-      window.location.href = page.path;
+      window.location.href = page._id;
     }
   }, []);
 

+ 243 - 0
packages/app/src/components/Navbar/GrowiContextualSubNavigation.tsx

@@ -0,0 +1,243 @@
+import React, { useCallback } from 'react';
+import PropTypes from 'prop-types';
+
+import { useTranslation } from 'react-i18next';
+
+import { DropdownItem } from 'reactstrap';
+
+import { withUnstatedContainers } from '../UnstatedUtils';
+import EditorContainer from '~/client/services/EditorContainer';
+import {
+  EditorMode, useDrawerMode, useEditorMode, useIsDeviceSmallerThanMd, useIsAbleToShowPageManagement, useIsAbleToShowTagLabel,
+  useIsAbleToShowPageEditorModeManager, useIsAbleToShowPageAuthors, usePageAccessoriesModal, PageAccessoriesModalContents,
+} from '~/stores/ui';
+import {
+  useCurrentCreatedAt, useCurrentUpdatedAt, useCurrentPageId, useRevisionId, useCurrentPagePath,
+  useCreator, useRevisionAuthor, useIsGuestUser, useIsSharedUser, useShareLinkId,
+} from '~/stores/context';
+import { useSWRTagsInfo } from '~/stores/page';
+
+
+import { toastSuccess, toastError } from '~/client/util/apiNotification';
+import { apiPost } from '~/client/util/apiv1-client';
+import { IPageHasId } from '~/interfaces/page';
+
+import HistoryIcon from '../Icons/HistoryIcon';
+import AttachmentIcon from '../Icons/AttachmentIcon';
+import ShareLinkIcon from '../Icons/ShareLinkIcon';
+import { AdditionalMenuItemsRendererProps } from '../Common/Dropdown/PageItemControl';
+import { SubNavButtons } from './SubNavButtons';
+import PageEditorModeManager from './PageEditorModeManager';
+import { GrowiSubNavigation } from './GrowiSubNavigation';
+import PresentationIcon from '../Icons/PresentationIcon';
+import { exportAsMarkdown } from '~/client/services/page-operation';
+
+
+type AdditionalMenuItemsProps = AdditionalMenuItemsRendererProps & {
+  pageId: string,
+  revisionId: string,
+  isLinkSharingDisabled?: boolean,
+}
+
+const AdditionalMenuItems = (props: AdditionalMenuItemsProps): JSX.Element => {
+  const { t } = useTranslation();
+
+  const { pageId, revisionId, isLinkSharingDisabled } = props;
+
+  const { data: isGuestUser } = useIsGuestUser();
+  const { data: isSharedUser } = useIsSharedUser();
+
+  const { open } = usePageAccessoriesModal();
+
+  return (
+    <>
+      {/* Presentation */}
+      <DropdownItem onClick={() => { /* TODO: implement in https://redmine.weseek.co.jp/issues/87672 */ }}>
+        <i className="icon-fw"><PresentationIcon /></i>
+        { t('Presentation Mode') }
+      </DropdownItem>
+
+      {/* Export markdown */}
+      <DropdownItem onClick={() => exportAsMarkdown(pageId, revisionId, 'md')}>
+        <i className="icon-fw icon-cloud-download"></i>
+        {t('export_bulk.export_page_markdown')}
+      </DropdownItem>
+
+      <DropdownItem divider />
+
+      {/*
+        TODO: show Tooltip when menu is disabled
+        refs: PageAccessoriesModalControl
+      */}
+      <DropdownItem
+        onClick={() => open(PageAccessoriesModalContents.PageHistory)}
+        disabled={isGuestUser || isSharedUser}
+      >
+        <span className="mr-1"><HistoryIcon /></span>
+        {t('History')}
+      </DropdownItem>
+
+      <DropdownItem
+        onClick={() => open(PageAccessoriesModalContents.Attachment)}
+      >
+        <span className="mr-1"><AttachmentIcon /></span>
+        {t('attachment_data')}
+      </DropdownItem>
+
+      <DropdownItem
+        onClick={() => open(PageAccessoriesModalContents.ShareLink)}
+        disabled={isGuestUser || isSharedUser || isLinkSharingDisabled}
+      >
+        <span className="mr-1"><ShareLinkIcon /></span>
+        {t('share_links.share_link_management')}
+      </DropdownItem>
+
+      <DropdownItem divider />
+
+      {/* Create template */}
+      <DropdownItem onClick={() => { /* TODO: implement in https://redmine.weseek.co.jp/issues/87673 */ }}>
+        <i className="icon-fw icon-magic-wand"></i> { t('template.option_label.create/edit') }
+      </DropdownItem>
+    </>
+  );
+};
+
+
+const GrowiContextualSubNavigation = (props) => {
+  const { data: isDeviceSmallerThanMd } = useIsDeviceSmallerThanMd();
+  const { data: isDrawerMode } = useDrawerMode();
+  const { data: editorMode, mutate: mutateEditorMode } = useEditorMode();
+  const { data: createdAt } = useCurrentCreatedAt();
+  const { data: updatedAt } = useCurrentUpdatedAt();
+  const { data: pageId } = useCurrentPageId();
+  const { data: revisionId } = useRevisionId();
+  const { data: path } = useCurrentPagePath();
+  const { data: creator } = useCreator();
+  const { data: revisionAuthor } = useRevisionAuthor();
+  const { data: isGuestUser } = useIsGuestUser();
+  const { data: isSharedUser } = useIsSharedUser();
+  const { data: shareLinkId } = useShareLinkId();
+
+  const { data: isAbleToShowPageManagement } = useIsAbleToShowPageManagement();
+  const { data: isAbleToShowTagLabel } = useIsAbleToShowTagLabel();
+  const { data: isAbleToShowPageEditorModeManager } = useIsAbleToShowPageEditorModeManager();
+  const { data: isAbleToShowPageAuthors } = useIsAbleToShowPageAuthors();
+
+  const { mutate: mutateSWRTagsInfo, data: tagsInfoData } = useSWRTagsInfo(pageId);
+
+  const {
+    editorContainer, isCompactMode, isLinkSharingDisabled,
+  } = props;
+
+  const isViewMode = editorMode === EditorMode.View;
+
+  const tagsUpdatedHandler = useCallback(async(newTags: string[]) => {
+    // It will not be reflected in the DB until the page is refreshed
+    if (editorMode === EditorMode.Editor) {
+      return editorContainer.setState({ tags: newTags });
+    }
+
+    try {
+      const { tags } = await apiPost('/tags.update', { pageId, revisionId, tags: newTags }) as { tags };
+
+      // revalidate SWRTagsInfo
+      mutateSWRTagsInfo();
+      // update editorContainer.state
+      editorContainer.setState({ tags });
+
+      toastSuccess('updated tags successfully');
+    }
+    catch (err) {
+      toastError(err, 'fail to update tags');
+    }
+  // eslint-disable-next-line react-hooks/exhaustive-deps
+  }, [pageId]);
+
+  const ControlComponents = useCallback(() => {
+    function onPageEditorModeButtonClicked(viewType) {
+      mutateEditorMode(viewType);
+    }
+
+    return (
+      <>
+        <div className="h-50 d-flex flex-column align-items-end justify-content-center">
+          { pageId != null && isViewMode && (
+            <SubNavButtons
+              isCompactMode={isCompactMode}
+              pageId={pageId}
+              shareLinkId={shareLinkId}
+              revisionId={revisionId}
+              disableSeenUserInfoPopover={isSharedUser}
+              showPageControlDropdown={isAbleToShowPageManagement}
+              additionalMenuItemRenderer={props => (
+                <AdditionalMenuItems {...props} pageId={pageId} revisionId={revisionId} isLinkSharingDisabled={isLinkSharingDisabled} />
+              )}
+            />
+          ) }
+        </div>
+        <div className="h-50 d-flex flex-column align-items-end justify-content-center">
+          {isAbleToShowPageEditorModeManager && (
+            <PageEditorModeManager
+              onPageEditorModeButtonClicked={onPageEditorModeButtonClicked}
+              isBtnDisabled={isGuestUser}
+              editorMode={editorMode}
+              isDeviceSmallerThanMd={isDeviceSmallerThanMd}
+            />
+          )}
+        </div>
+      </>
+    );
+  }, [
+    pageId, revisionId, shareLinkId,
+    editorMode, mutateEditorMode,
+    isCompactMode, isLinkSharingDisabled,
+    isDeviceSmallerThanMd, isGuestUser, isSharedUser,
+    isViewMode, isAbleToShowPageEditorModeManager, isAbleToShowPageManagement,
+  ]);
+
+
+  if (path == null) {
+    return <></>;
+  }
+
+  const currentPage: Partial<IPageHasId> = {
+    _id: pageId ?? undefined,
+    path,
+    revision: revisionId ?? undefined,
+    creator: creator ?? undefined,
+    lastUpdateUser: revisionAuthor,
+    createdAt: createdAt ?? undefined,
+    updatedAt: updatedAt ?? undefined,
+  };
+
+
+  return (
+    <GrowiSubNavigation
+      page={currentPage}
+      showDrawerToggler={isDrawerMode}
+      showTagLabel={isAbleToShowTagLabel}
+      showPageAuthors={isAbleToShowPageAuthors}
+      isGuestUser={isGuestUser}
+      isDrawerMode={isDrawerMode}
+      isCompactMode={isCompactMode}
+      tags={tagsInfoData?.tags || []}
+      tagsUpdatedHandler={tagsUpdatedHandler}
+      controls={ControlComponents}
+    />
+  );
+};
+
+/**
+ * Wrapper component for using unstated
+ */
+const GrowiContextualSubNavigationWrapper = withUnstatedContainers(GrowiContextualSubNavigation, [EditorContainer]);
+
+
+GrowiContextualSubNavigation.propTypes = {
+  editorContainer: PropTypes.instanceOf(EditorContainer).isRequired,
+
+  isCompactMode: PropTypes.bool,
+  isLinkSharingDisabled: PropTypes.bool,
+};
+
+export default GrowiContextualSubNavigationWrapper;

+ 6 - 4
packages/app/src/components/Navbar/GrowiNavbar.tsx

@@ -7,7 +7,8 @@ import { UncontrolledTooltip } from 'reactstrap';
 
 import AppContainer from '~/client/services/AppContainer';
 import { IUser } from '~/interfaces/user';
-import { useIsDeviceSmallerThanMd, usePageCreateModalOpened } from '~/stores/ui';
+import { useIsDeviceSmallerThanMd, useCreateModalStatus } from '~/stores/ui';
+import { useIsSearchPage } from '~/stores/context';
 
 import { withUnstatedContainers } from '../UnstatedUtils';
 import GrowiLogo from '../Icons/GrowiLogo';
@@ -22,7 +23,7 @@ type NavbarRightProps = {
 }
 const NavbarRight: FC<NavbarRightProps> = memo((props: NavbarRightProps) => {
   const { t } = useTranslation();
-  const { mutate: mutatePageCreateModalOpened } = usePageCreateModalOpened();
+  const { open: openCreateModal } = useCreateModalStatus();
 
   const { currentUser } = props;
 
@@ -41,7 +42,7 @@ const NavbarRight: FC<NavbarRightProps> = memo((props: NavbarRightProps) => {
         <button
           className="px-md-3 nav-link btn-create-page border-0 bg-transparent"
           type="button"
-          onClick={() => mutatePageCreateModalOpened(true)}
+          onClick={() => openCreateModal()}
         >
           <i className="icon-pencil mr-2"></i>
           <span className="d-none d-lg-block">{ t('New') }</span>
@@ -90,6 +91,7 @@ const GrowiNavbar = (props) => {
   const { crowi, isSearchServiceConfigured } = appContainer.config;
 
   const { data: isDeviceSmallerThanMd } = useIsDeviceSmallerThanMd();
+  const { data: isSearchPage } = useIsSearchPage();
 
   return (
     <>
@@ -111,7 +113,7 @@ const GrowiNavbar = (props) => {
         <Confidential confidential={crowi.confidential}></Confidential>
       </ul>
 
-      { isSearchServiceConfigured && !isDeviceSmallerThanMd && (
+      { isSearchServiceConfigured && !isDeviceSmallerThanMd && !isSearchPage && (
         <div className="grw-global-search grw-global-search-top position-absolute">
           <GlobalSearch />
         </div>

+ 24 - 16
packages/app/src/components/Navbar/GrowiNavbarBottom.jsx

@@ -1,7 +1,9 @@
 import React from 'react';
 import PropTypes from 'prop-types';
 
-import { usePageCreateModalOpened, useIsDeviceSmallerThanMd, useDrawerOpened } from '~/stores/ui';
+
+import { useCreateModalStatus, useIsDeviceSmallerThanMd, useDrawerOpened } from '~/stores/ui';
+import { useCurrentPagePath, useIsSearchPage } from '~/stores/context';
 
 import GlobalSearch from './GlobalSearch';
 
@@ -9,7 +11,9 @@ const GrowiNavbarBottom = (props) => {
 
   const { data: isDrawerOpened, mutate: mutateDrawerOpened } = useDrawerOpened();
   const { data: isDeviceSmallerThanMd } = useIsDeviceSmallerThanMd();
-  const { mutate: mutatePageCreateModalOpened } = usePageCreateModalOpened();
+  const { open: openCreateModal } = useCreateModalStatus();
+  const { data: currentPagePath } = useCurrentPagePath();
+  const { data: isSearchPage } = useIsSearchPage();
 
   const additionalClasses = ['grw-navbar-bottom'];
   if (isDrawerOpened) {
@@ -19,7 +23,7 @@ const GrowiNavbarBottom = (props) => {
   return (
     <div className="d-md-none d-edit-none fixed-bottom">
 
-      { isDeviceSmallerThanMd && (
+      { isDeviceSmallerThanMd && !isSearchPage && (
         <div id="grw-global-search-collapse" className="grw-global-search collapse bg-dark">
           <div className="p-3">
             <GlobalSearch dropup />
@@ -30,7 +34,7 @@ const GrowiNavbarBottom = (props) => {
       <div className={`navbar navbar-expand navbar-dark bg-primary px-0 ${additionalClasses.join(' ')}`}>
 
         <ul className="navbar-nav w-100">
-          <li className="nav-item">
+          <li className="nav-item mr-auto">
             <a
               role="button"
               className="nav-link btn-lg"
@@ -39,21 +43,25 @@ const GrowiNavbarBottom = (props) => {
               <i className="icon-menu"></i>
             </a>
           </li>
-          <li className="nav-item mx-auto">
-            <a
-              role="button"
-              className="nav-link btn-lg"
-              data-target="#grw-global-search-collapse"
-              data-toggle="collapse"
-            >
-              <i className="icon-magnifier"></i>
-            </a>
-          </li>
-          <li className="nav-item">
+          {
+            !isSearchPage && (
+              <li className="nav-item">
+                <a
+                  role="button"
+                  className="nav-link btn-lg"
+                  data-target="#grw-global-search-collapse"
+                  data-toggle="collapse"
+                >
+                  <i className="icon-magnifier"></i>
+                </a>
+              </li>
+            )
+          }
+          <li className="nav-item ml-auto">
             <a
               role="button"
               className="nav-link btn-lg"
-              onClick={() => mutatePageCreateModalOpened(true)}
+              onClick={() => openCreateModal(currentPagePath || '')}
             >
               <i className="icon-pencil"></i>
             </a>

+ 0 - 164
packages/app/src/components/Navbar/GrowiSubNavigation.jsx

@@ -1,164 +0,0 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-
-import { DevidedPagePath } from '@growi/core';
-import PagePathHierarchicalLink from '~/components/PagePathHierarchicalLink';
-import LinkedPagePath from '~/models/linked-page-path';
-
-import { withUnstatedContainers } from '../UnstatedUtils';
-import AppContainer from '~/client/services/AppContainer';
-import PageContainer from '~/client/services/PageContainer';
-import {
-  EditorMode, useDrawerMode, useEditorMode, useIsDeviceSmallerThanMd,
-} from '~/stores/ui';
-import { useCurrentCreatedAt, useCurrentUpdatedAt } from '~/stores/context';
-
-import CopyDropdown from '../Page/CopyDropdown';
-import TagLabels from '../Page/TagLabels';
-import SubnavButtons from './SubNavButtons';
-import PageEditorModeManager from './PageEditorModeManager';
-
-import AuthorInfo from './AuthorInfo';
-import DrawerToggler from './DrawerToggler';
-
-const PagePathNav = ({
-  // eslint-disable-next-line react/prop-types
-  pageId, pagePath, isEditorMode, isCompactMode,
-}) => {
-
-  const dPagePath = new DevidedPagePath(pagePath, false, true);
-
-  let formerLink;
-  let latterLink;
-
-  // one line
-  if (dPagePath.isRoot || dPagePath.isFormerRoot || isEditorMode) {
-    const linkedPagePath = new LinkedPagePath(pagePath);
-    latterLink = <PagePathHierarchicalLink linkedPagePath={linkedPagePath} />;
-  }
-  // two line
-  else {
-    const linkedPagePathFormer = new LinkedPagePath(dPagePath.former);
-    const linkedPagePathLatter = new LinkedPagePath(dPagePath.latter);
-    formerLink = <PagePathHierarchicalLink linkedPagePath={linkedPagePathFormer} />;
-    latterLink = <PagePathHierarchicalLink linkedPagePath={linkedPagePathLatter} basePath={dPagePath.former} />;
-  }
-
-  const copyDropdownId = `copydropdown${isCompactMode ? '-subnav-compact' : ''}-${pageId}`;
-  const copyDropdownToggleClassName = 'd-block text-muted bg-transparent btn-copy border-0 py-0';
-
-  return (
-    <div className="grw-page-path-nav">
-      {formerLink}
-      <span className="d-flex align-items-center">
-        <h1 className="m-0">{latterLink}</h1>
-        <div className="mx-2">
-          <CopyDropdown
-            pageId={pageId}
-            pagePath={pagePath}
-            dropdownToggleId={copyDropdownId}
-            dropdownToggleClassName={copyDropdownToggleClassName}
-          >
-            <i className="ti-clipboard"></i>
-          </CopyDropdown>
-        </div>
-      </span>
-    </div>
-  );
-};
-
-const GrowiSubNavigation = (props) => {
-  const { data: isDeviceSmallerThanMd } = useIsDeviceSmallerThanMd();
-  const { data: isDrawerMode } = useDrawerMode();
-  const { data: editorMode, mutate: mutateEditorMode } = useEditorMode();
-  const { data: createdAt } = useCurrentCreatedAt();
-  const { data: updatedAt } = useCurrentUpdatedAt();
-
-  const {
-    appContainer, pageContainer, isCompactMode,
-  } = props;
-  const {
-    pageId, path, creator, revisionAuthor, isPageExist,
-  } = pageContainer.state;
-
-  const { isGuestUser } = appContainer;
-  const isEditorMode = editorMode !== EditorMode.View;
-  // Tags cannot be edited while the new page and editorMode is view
-  const isTagLabelHidden = (editorMode !== EditorMode.Editor && !isPageExist);
-
-  function onPageEditorModeButtonClicked(viewType) {
-    mutateEditorMode(viewType);
-  }
-
-  return (
-    <div className={`grw-subnav container-fluid d-flex align-items-center justify-content-between ${isCompactMode ? 'grw-subnav-compact d-print-none' : ''}`}>
-
-      {/* Left side */}
-      <div className="d-flex grw-subnav-left-side">
-        { isDrawerMode && (
-          <div className={`d-none d-md-flex align-items-center ${isEditorMode ? 'mr-2 pr-2' : 'border-right mr-4 pr-4'}`}>
-            <DrawerToggler />
-          </div>
-        ) }
-
-        <div className="grw-path-nav-container">
-          { pageContainer.isAbleToShowTagLabel && !isCompactMode && !isTagLabelHidden && (
-            <div className="grw-taglabels-container">
-              <TagLabels editorMode={editorMode} />
-            </div>
-          ) }
-          <PagePathNav pageId={pageId} pagePath={path} isEditorMode={isEditorMode} isCompactMode={isCompactMode} />
-        </div>
-      </div>
-
-      {/* Right side */}
-      <div className="d-flex">
-
-        <div className="d-flex flex-column align-items-end">
-          <div className="d-flex">
-            <SubnavButtons isCompactMode={isCompactMode} />
-          </div>
-          <div className="mt-2">
-            {pageContainer.isAbleToShowPageEditorModeManager && (
-              <PageEditorModeManager
-                onPageEditorModeButtonClicked={onPageEditorModeButtonClicked}
-                isBtnDisabled={isGuestUser}
-                editorMode={editorMode}
-                isDeviceSmallerThanMd={isDeviceSmallerThanMd}
-              />
-            )}
-          </div>
-        </div>
-
-        {/* Page Authors */}
-        { (pageContainer.isAbleToShowPageAuthors && !isCompactMode) && (
-          <ul className="authors text-nowrap border-left d-none d-lg-block d-edit-none py-2 pl-4 mb-0 ml-3">
-            <li className="pb-1">
-              <AuthorInfo user={creator} date={createdAt} locate="subnav" />
-            </li>
-            <li className="mt-1 pt-1 border-top">
-              <AuthorInfo user={revisionAuthor} date={updatedAt} mode="update" locate="subnav" />
-            </li>
-          </ul>
-        ) }
-      </div>
-
-    </div>
-  );
-
-};
-
-/**
- * Wrapper component for using unstated
- */
-const GrowiSubNavigationWrapper = withUnstatedContainers(GrowiSubNavigation, [AppContainer, PageContainer]);
-
-
-GrowiSubNavigation.propTypes = {
-  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
-  pageContainer: PropTypes.instanceOf(PageContainer).isRequired,
-
-  isCompactMode: PropTypes.bool,
-};
-
-export default GrowiSubNavigationWrapper;

+ 100 - 0
packages/app/src/components/Navbar/GrowiSubNavigation.tsx

@@ -0,0 +1,100 @@
+import React from 'react';
+
+import { IPageHasId } from '~/interfaces/page';
+
+import {
+  EditorMode, useEditorMode,
+} from '~/stores/ui';
+
+import TagLabels from '../Page/TagLabels';
+
+import AuthorInfo from './AuthorInfo';
+import DrawerToggler from './DrawerToggler';
+
+import PagePathNav from '../PagePathNav';
+import { IUser } from '~/interfaces/user';
+
+
+type Props = {
+  page: Partial<IPageHasId>,
+
+  showDrawerToggler?: boolean,
+  showTagLabel?: boolean,
+  showPageAuthors?: boolean,
+
+  isGuestUser?: boolean,
+  isDrawerMode?: boolean,
+  isCompactMode?: boolean,
+
+  tags?: string[],
+  tagsUpdatedHandler?: (newTags: string[]) => Promise<void>,
+
+  controls?: React.FunctionComponent,
+}
+
+export const GrowiSubNavigation = (props: Props): JSX.Element => {
+  const { data: editorMode } = useEditorMode();
+
+  const {
+    page,
+    showDrawerToggler, showTagLabel, showPageAuthors,
+    isGuestUser, isDrawerMode, isCompactMode,
+    tags, tagsUpdatedHandler,
+    controls: Controls,
+  } = props;
+
+  const {
+    _id: pageId, path, creator, lastUpdateUser,
+    createdAt, updatedAt,
+  } = page;
+
+  const isViewMode = editorMode === EditorMode.View;
+  const isEditorMode = !isViewMode;
+
+  if (path == null) {
+    return <></>;
+  }
+
+  return (
+    <div className={`grw-subnav container-fluid d-flex align-items-center justify-content-between ${isCompactMode ? 'grw-subnav-compact d-print-none' : ''}`}>
+
+      {/* Left side */}
+      <div className="d-flex grw-subnav-left-side">
+        { showDrawerToggler && isDrawerMode && (
+          <div className={`d-none d-md-flex align-items-center ${isEditorMode ? 'mr-2 pr-2' : 'border-right mr-4 pr-4'}`}>
+            <DrawerToggler />
+          </div>
+        ) }
+
+        <div className="grw-path-nav-container">
+          { showTagLabel && !isCompactMode && (
+            <div className="grw-taglabels-container">
+              <TagLabels tags={tags} isGuestUser={isGuestUser ?? false} tagsUpdateInvoked={tagsUpdatedHandler} />
+            </div>
+          ) }
+          <PagePathNav pageId={pageId} pagePath={path} isSingleLineMode={isEditorMode} isCompactMode={isCompactMode} />
+        </div>
+      </div>
+
+      {/* Right side */}
+      <div className="d-flex">
+
+        <div className="d-flex flex-column" style={{ gap: `${isCompactMode ? '5px' : '0'}` }}>
+          { Controls && <Controls></Controls> }
+        </div>
+
+        {/* Page Authors */}
+        { (showPageAuthors && !isCompactMode) && (
+          <ul className="authors text-nowrap border-left d-none d-lg-block d-edit-none py-2 pl-4 mb-0 ml-3">
+            <li className="pb-1">
+              <AuthorInfo user={creator as IUser} date={createdAt} locate="subnav" />
+            </li>
+            <li className="mt-1 pt-1 border-top">
+              <AuthorInfo user={lastUpdateUser as IUser} date={updatedAt} mode="update" locate="subnav" />
+            </li>
+          </ul>
+        ) }
+      </div>
+    </div>
+  );
+};

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