Преглед изворни кода

Merge branch 'master' into feat/gw7598-GROWI-and-Slack-user-linking-accounts

Haku Mizuki пре 4 година
родитељ
комит
ebc9a0b8a4
100 измењених фајлова са 2305 додато и 1578 уклоњено
  1. 10 1
      .devcontainer/docker-compose.yml
  2. 126 128
      .github/workflows/ci-app.yml
  3. 19 13
      .github/workflows/reusable-app-prod.yml
  4. 3 2
      .github/workflows/reusable-app-reg-suit.yml
  5. 7 1
      CHANGELOG.md
  6. 0 2
      README.md
  7. 0 2
      README_JP.md
  8. 1 1
      lerna.json
  9. 1 1
      package.json
  10. 4 0
      packages/app/.env.development
  11. 3 1
      packages/app/config/ci/.env.local.for-auto-install
  12. 0 3
      packages/app/config/ci/.env.local.for-ci
  13. 2 2
      packages/app/docker/README.md
  14. 19 0
      packages/app/jest.config.js
  15. 9 8
      packages/app/package.json
  16. 4 4
      packages/app/resource/locales/en_US/sandbox.md
  17. 30 12
      packages/app/resource/locales/en_US/translation.json
  18. 3 3
      packages/app/resource/locales/ja_JP/sandbox.md
  19. 31 13
      packages/app/resource/locales/ja_JP/translation.json
  20. 4 4
      packages/app/resource/locales/zh_CN/sandbox.md
  21. 33 15
      packages/app/resource/locales/zh_CN/translation.json
  22. 123 0
      packages/app/resource/search/mappings-es6-for-ci.json
  23. 36 15
      packages/app/src/client/admin.jsx
  24. 6 9
      packages/app/src/client/app.jsx
  25. 4 0
      packages/app/src/client/base.jsx
  26. 13 0
      packages/app/src/client/interfaces/selectable-all.ts
  27. 5 5
      packages/app/src/client/services/AdminHomeContainer.js
  28. 22 2
      packages/app/src/client/services/ContextExtractor.tsx
  29. 5 0
      packages/app/src/client/services/PageContainer.js
  30. 2 2
      packages/app/src/components/Admin/AdminHome/AdminHome.jsx
  31. 9 3
      packages/app/src/components/Admin/AdminHome/InstalledPluginTable.jsx
  32. 13 5
      packages/app/src/components/Admin/AdminHome/SystemInfomationTable.jsx
  33. 2 2
      packages/app/src/components/Admin/App/AppSettingsPageContents.jsx
  34. 2 2
      packages/app/src/components/Admin/Customize/Customize.jsx
  35. 2 1
      packages/app/src/components/Admin/ElasticsearchManagement/StatusTable.jsx
  36. 2 2
      packages/app/src/components/Admin/ExportArchiveDataPage.jsx
  37. 2 2
      packages/app/src/components/Admin/FullTextSearchManagement.jsx
  38. 2 2
      packages/app/src/components/Admin/ImportData/ImportDataPageContents.jsx
  39. 2 2
      packages/app/src/components/Admin/LegacySlackIntegration/LegacySlackIntegration.jsx
  40. 2 2
      packages/app/src/components/Admin/MarkdownSetting/MarkDownSettingContents.jsx
  41. 3 3
      packages/app/src/components/Admin/Notification/NotificationSetting.jsx
  42. 2 2
      packages/app/src/components/Admin/Security/SecurityManagementContents.jsx
  43. 2 2
      packages/app/src/components/Admin/SlackIntegration/SlackIntegration.jsx
  44. 95 0
      packages/app/src/components/Admin/UserGroup/UserGroupCreateModal.tsx
  45. 0 3
      packages/app/src/components/Admin/UserGroup/UserGroupDeleteModal.tsx
  46. 12 27
      packages/app/src/components/Admin/UserGroup/UserGroupForm.tsx
  47. 39 46
      packages/app/src/components/Admin/UserGroup/UserGroupPage.tsx
  48. 5 7
      packages/app/src/components/Admin/UserGroup/UserGroupTable.tsx
  49. 112 46
      packages/app/src/components/Admin/UserGroupDetail/UserGroupDetailPage.tsx
  50. 2 2
      packages/app/src/components/Admin/UserManagement.jsx
  51. 1 1
      packages/app/src/components/Admin/Users/UserTable.jsx
  52. 4 2
      packages/app/src/components/Common/ClosableTextInput.tsx
  53. 61 16
      packages/app/src/components/Common/Dropdown/PageItemControl.tsx
  54. 136 58
      packages/app/src/components/DescendantsPageList.tsx
  55. 2 2
      packages/app/src/components/DescendantsPageListModal.tsx
  56. 4 4
      packages/app/src/components/Fab.jsx
  57. 2 2
      packages/app/src/components/ForbiddenPage.tsx
  58. 2 2
      packages/app/src/components/Hotkeys/Subscribers/CreatePage.jsx
  59. 7 13
      packages/app/src/components/IdenticalPathPage.tsx
  60. 99 0
      packages/app/src/components/LegacyPrivatePagesMigrationModal.tsx
  61. 1 1
      packages/app/src/components/LoginForm.jsx
  62. 1 1
      packages/app/src/components/Navbar/GlobalSearch.tsx
  63. 58 20
      packages/app/src/components/Navbar/GrowiContextualSubNavigation.tsx
  64. 7 4
      packages/app/src/components/Navbar/GrowiNavbar.tsx
  65. 3 2
      packages/app/src/components/Navbar/GrowiNavbarBottom.jsx
  66. 7 1
      packages/app/src/components/Navbar/GrowiSubNavigation.tsx
  67. 6 1
      packages/app/src/components/Navbar/GrowiSubNavigationSwitcher.jsx
  68. 37 18
      packages/app/src/components/Navbar/SubNavButtons.tsx
  69. 4 13
      packages/app/src/components/NotFoundPage.tsx
  70. 5 4
      packages/app/src/components/Page.jsx
  71. 3 2
      packages/app/src/components/Page/DisplaySwitcher.tsx
  72. 0 24
      packages/app/src/components/Page/DuplicatedAlert.jsx
  73. 17 12
      packages/app/src/components/Page/NotFoundAlert.tsx
  74. 0 264
      packages/app/src/components/Page/PageManagement.jsx
  75. 0 22
      packages/app/src/components/Page/RenamedAlert.jsx
  76. 1 0
      packages/app/src/components/Page/RevisionBody.jsx
  77. 3 1
      packages/app/src/components/Page/RevisionLoader.jsx
  78. 16 8
      packages/app/src/components/Page/RevisionRenderer.jsx
  79. 40 21
      packages/app/src/components/Page/TrashPageAlert.jsx
  80. 1 1
      packages/app/src/components/PageAccessoriesModal.tsx
  81. 18 8
      packages/app/src/components/PageCreateModal.jsx
  82. 89 46
      packages/app/src/components/PageDeleteModal.tsx
  83. 22 16
      packages/app/src/components/PageDuplicateModal.jsx
  84. 8 0
      packages/app/src/components/PageEditor/AbstractEditor.tsx
  85. 2 10
      packages/app/src/components/PageEditor/CodeMirrorEditor.jsx
  86. 3 2
      packages/app/src/components/PageEditor/LinkEditModal.jsx
  87. 18 5
      packages/app/src/components/PageList/PageList.tsx
  88. 124 39
      packages/app/src/components/PageList/PageListItemL.tsx
  89. 3 0
      packages/app/src/components/PagePathAutoComplete.jsx
  90. 17 4
      packages/app/src/components/PagePathHierarchicalLink.jsx
  91. 6 16
      packages/app/src/components/PagePresentationModal.jsx
  92. 17 15
      packages/app/src/components/PageRenameModal.jsx
  93. 1 0
      packages/app/src/components/PageTimeline.jsx
  94. 1 1
      packages/app/src/components/PaginationWrapper.tsx
  95. 313 0
      packages/app/src/components/PrivateLegacyPages.tsx
  96. 17 26
      packages/app/src/components/PutbackPageModal.jsx
  97. 3 0
      packages/app/src/components/SearchForm.tsx
  98. 0 396
      packages/app/src/components/SearchPage.jsx
  99. 280 0
      packages/app/src/components/SearchPage.tsx
  100. 0 64
      packages/app/src/components/SearchPage/DeleteSelectedPageGroup.tsx

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

@@ -34,12 +34,21 @@ services:
     volumes:
       - /data/db
 
+  ogp:
+    image: ghcr.io/weseek/growi-unique-ogp:latest
+    ports:
+      - 8088:8088
+    restart: unless-stopped
+    tty: true
+
   # This container requires '../../growi-docker-compose' repository
   #   cloned from https://github.com/weseek/growi-docker-compose.git
   elasticsearch:
     build:
       context: ../../growi-docker-compose/elasticsearch
       dockerfile: ./Dockerfile
+      args:
+        - version=6.8.22
     container_name: elasticsearch
     restart: unless-stopped
     ports:
@@ -57,7 +66,7 @@ services:
 
   #need to adjust kibana version based on elasticsearch version
   kibana:
-    image: docker.elastic.co/kibana/kibana:6.8.0
+    image: docker.elastic.co/kibana/kibana:6.8.22
     restart: unless-stopped
     environment:
       ELASTICSEARCH_HOSTS: 'http://elasticsearch:9200'

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

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

+ 19 - 13
.github/workflows/reusable-app-prod.yml

@@ -37,8 +37,9 @@ jobs:
       with:
         path: |
           **/node_modules
-        key: node_modules-${{ runner.OS }}-node${{ inputs.node-version }}-${{ hashFiles('**/yarn.lock') }}
+        key: node_modules-${{ runner.OS }}-node${{ inputs.node-version }}-${{ hashFiles('**/yarn.lock') }}-${{ hashFiles('packages/app/package.json') }}
         restore-keys: |
+          node_modules-${{ runner.OS }}-node${{ inputs.node-version }}-${{ hashFiles('**/yarn.lock') }}-
           node_modules-${{ runner.OS }}-node${{ inputs.node-version }}-
 
     - name: lerna bootstrap
@@ -94,10 +95,12 @@ jobs:
         image: mongo:4.4
         ports:
         - 27017/tcp
-      mongodb36:
-        image: mongo:3.6
+      elasticsearch:
+        image: docker.elastic.co/elasticsearch/elasticsearch:6.8.23
         ports:
-        - 27017/tcp
+        - 9200/tcp
+        env:
+          discovery.type: single-node
 
     steps:
     - uses: actions/checkout@v2
@@ -122,6 +125,7 @@ jobs:
           **/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') }}-${{ hashFiles('packages/app/package.json') }}
           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 }}
@@ -151,13 +155,7 @@ jobs:
         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
+        ELASTICSEARCH_URI: http://localhost:${{ job.services.elasticsearch.ports['9200'] }}/growi
 
     - name: Slack Notification
       uses: weseek/ghaction-slack-notification@master
@@ -187,13 +185,19 @@ jobs:
       fail-fast: false
       matrix:
         # List string expressions that is comma separated ids of tests in "test/cypress/integration"
-        spec-group: ['1', '2']
+        spec-group: ['1', '2', '3']
 
     services:
       mongodb:
         image: mongo:4.4
         ports:
         - 27017/tcp
+      elasticsearch:
+        image: docker.elastic.co/elasticsearch/elasticsearch:6.8.23
+        ports:
+        - 9200/tcp
+        env:
+          discovery.type: single-node
 
     steps:
     - uses: actions/checkout@v2
@@ -210,8 +214,9 @@ jobs:
           **/node_modules
           ~/.cache/Cypress
           ${{ steps.yarn-cache-dir.outputs.value }}
-        key: deps-for-cypress-${{ runner.OS }}-node${{ inputs.node-version }}-${{ hashFiles('**/yarn.lock') }}
+        key: deps-for-cypress-${{ runner.OS }}-node${{ inputs.node-version }}-${{ hashFiles('**/yarn.lock') }}-${{ hashFiles('packages/app/package.json') }}
         restore-keys: |
+          deps-for-cypress-${{ runner.OS }}-node${{ inputs.node-version }}-${{ hashFiles('**/yarn.lock') }}-
           deps-for-cypress-${{ runner.OS }}-node${{ inputs.node-version }}
 
     - name: lerna bootstrap
@@ -254,6 +259,7 @@ jobs:
         wait-on: 'http://localhost:3000'
       env:
         MONGO_URI: mongodb://mongodb:27017/growi-vrt
+        ELASTICSEARCH_URI: http://elasticsearch:9200/growi
 
     - name: Upload results
       if: always()

+ 3 - 2
.github/workflows/reusable-app-reg-suit.yml

@@ -61,9 +61,10 @@ jobs:
       with:
         path: |
           **/node_modules
-        key: node_modules-${{ runner.OS }}-node${{ inputs.node-version }}-${{ hashFiles('**/yarn.lock') }}
+        key: node_modules-${{ runner.OS }}-node${{ matrix.node-version }}-${{ hashFiles('**/yarn.lock') }}-${{ hashFiles('packages/app/package.json') }}
         restore-keys: |
-          node_modules-${{ runner.OS }}-node${{ inputs.node-version }}-
+          node_modules-${{ runner.OS }}-node${{ matrix.node-version }}-${{ hashFiles('**/yarn.lock') }}-
+          node_modules-${{ runner.OS }}-node${{ matrix.node-version }}-
 
     - name: lerna bootstrap
       run: |

+ 7 - 1
CHANGELOG.md

@@ -1,9 +1,15 @@
 # Changelog
 
-## [Unreleased](https://github.com/weseek/growi/compare/v4.5.13...HEAD)
+## [Unreleased](https://github.com/weseek/growi/compare/v4.5.14...HEAD)
 
 *Please do not manually update this file. We've automated the process.*
 
+## [v4.5.14](https://github.com/weseek/growi/compare/v4.5.13...v4.5.14) - 2022-02-10
+
+### 💎 Features
+
+- feat: OGP in public wiki (#5304) @yuto-oweseek
+
 ## [v4.5.13](https://github.com/weseek/growi/compare/v4.5.12...v4.5.13) - 2022-02-08
 
 ### 🐛 Bug Fixes

+ 0 - 2
README.md

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

+ 0 - 2
README_JP.md

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

+ 1 - 1
lerna.json

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

+ 1 - 1
package.json

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

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

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

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

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

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

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

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

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

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

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

+ 9 - 8
packages/app/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@growi/app",
-  "version": "5.0.0-RC.0",
+  "version": "5.0.0-RC.8",
   "license": "MIT",
   "scripts": {
     "//// for production": "",
@@ -38,6 +38,7 @@
     "lint:swagger2openapi": "node node_modules/.bin/oas-validate tmp/swagger.json",
     "lint": "run-p lint:*",
     "test": "cross-env NODE_ENV=test jest --passWithNoTests -- ",
+    "test:ci": "cross-env NODE_ENV=test jest",
     "prelint:eslint": "yarn resources:plugin",
     "prelint:swagger2openapi": "yarn openapi:v3",
     "reg:run": "reg-suit run",
@@ -59,11 +60,11 @@
     "@browser-bunyan/console-formatted-stream": "^1.6.2",
     "@godaddy/terminus": "^4.9.0",
     "@google-cloud/storage": "^5.8.5",
-    "@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",
+    "@growi/codemirror-textlint": "^5.0.0-RC.8",
+    "@growi/plugin-attachment-refs": "^5.0.0-RC.8",
+    "@growi/plugin-lsx": "^5.0.0-RC.8",
+    "@growi/plugin-pukiwiki-like-linker": "^5.0.0-RC.8",
+    "@growi/slack": "^5.0.0-RC.8",
     "@promster/express": "^7.0.2",
     "@promster/server": "^7.0.4",
     "@slack/events-api": "^3.0.0",
@@ -106,7 +107,7 @@
     "extensible-custom-error": "^0.0.7",
     "graceful-fs": "^4.1.11",
     "helmet": "^4.6.0",
-    "http-errors": "~1.8.0",
+    "http-errors": "^2.0.0",
     "i18next": "^20.3.2",
     "i18next-express-middleware": "^2.0.0",
     "i18next-node-fs-backend": "^2.1.3",
@@ -166,7 +167,7 @@
   },
   "devDependencies": {
     "@alienfast/i18next-loader": "^1.1.4",
-    "@growi/ui": "^5.0.0-RC.0",
+    "@growi/ui": "^5.0.0-RC.8",
     "@handsontable/react": "=2.1.0",
     "@types/compression": "^1.7.0",
     "@types/express": "^4.17.11",

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

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

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

@@ -13,7 +13,6 @@
   "Click to copy": "Click to copy",
   "Rename" : "Rename",
   "Move/Rename": "Move/Rename",
-  "Moved": "Moved",
   "Redirected": "Redirected",
   "Unlinked": "Unlinked",
   "Like!": "Like!",
@@ -125,6 +124,7 @@
   "User_Management": "User Management",
   "external_account_management": "External Account Management",
   "UserGroup": "UserGroup",
+  "ChildUserGroup": "ChildUserGroup",
   "UserGroup Management": "UserGroup Management",
   "Full Text Search Management": "Full Text Search Management",
   "Import Data": "Import Data",
@@ -167,6 +167,8 @@
   "new_path":"New path",
   "duplicated_path":"duplicated_path",
   "Link sharing is disabled": "Link sharing is disabled",
+  "successfully_saved_the_page": "Successfully saved the page",
+  "you_can_not_create_page_with_this_name": "You can not create page with this name",
   "personal_dropdown": {
     "home": "Home",
     "settings": "Settings",
@@ -366,12 +368,8 @@
   "page_page": {
     "notice": {
       "version": "This is not the current version.",
-      "moved": "This page was moved from",
-      "moved_period": ".",
       "redirected": "You are redirected from",
       "redirected_period": ".",
-      "duplicated": "This page was duplicated from",
-      "duplicated_period": ".",
       "unlinked": "Redirect pages to this page have been deleted.",
       "restricted": "Access to this page is restricted",
       "stale": "More than {{count}} year has passed since last update.",
@@ -380,9 +378,6 @@
       "no_deadline":"This page has no expiration date"
     }
   },
-  "page_table_of_contents": {
-    "empty": "Table of Contents is empty"
-  },
   "page_edit": {
     "Show active line": "Show active line",
     "auto_format_table": "Auto format table",
@@ -413,8 +408,8 @@
     "label": {
       "Move/Rename page": "Move/Rename page",
       "New page name": "New page name",
-      "Fail to get subordinated pages": "Fail to get subordinated pages",
-      "Fail to get exist path": "Fail to get exist path",
+      "Failed to get subordinated pages": "Failed to get subordinated pages",
+      "Failed to get exist path": "Failed to get exist path",
       "Rename without exist path": "Rename without exist path",
       "Current page name": "Current page name",
       "Recursively": "Recursively",
@@ -429,6 +424,7 @@
   },
   "Put Back": "Put back",
   "Delete Completely": "Delete completely",
+  "page_has_been_reverted": "{{path}} has been reverted",
   "modal_delete": {
     "delete_page": "Delete page",
     "deleting_page": "Deleting page",
@@ -438,6 +434,9 @@
     "recursively": "Delete pages under this path recursively.",
     "completely": "Delete completely instead of putting it into trash."
   },
+  "deleted_pages": "{{path}} has been deleted",
+  "deleted_pages_completely": "{{path}} has been deleted completely",
+  "renamed_pages": "{{path}} has been renamed",
   "modal_empty":{
     "empty_the_trash": "Empty The Trash",
     "notice": "The pages deleted completely are unrecoverable."
@@ -446,7 +445,7 @@
     "label": {
       "Duplicate page": "Duplicate page",
       "New page name": "New page name",
-      "Fail to get subordinated pages": "Fail to get subordinated pages",
+      "Failed to get subordinated pages": "Failed to get subordinated pages",
       "Current page name": "Current page name",
       "Recursively": "Recursively",
       "Duplicate without exist path": "Duplicate without exist path",
@@ -456,6 +455,7 @@
       "recursive": "Duplicate children of under this path recursively"
     }
   },
+  "duplicated_pages": "{{fromPath}} has been duplicated",
   "modal_putback": {
     "label": {
       "Put Back Page": "Put back page",
@@ -633,6 +633,22 @@
       "updatedAt": "Last update date"
     }
   },
+  "private_legacy_pages": {
+    "bulk_operation": "Bulk operation",
+    "convert_all_selected_pages": "Convert all to new v5 compatible format",
+    "alert_title": "You are viewing old v4 compatible private pages.",
+    "alert_desc1": "On this page, you can select pages with the checkbox and batch convert to the new v5 compatible format from the \"Bulk operation\" button at the top of the screen.",
+    "nopages_title": "Congratulations. Ready to use GROWI v5!",
+    "nopages_desc1": "Now all the pages you can manage seem to be in v5 compatible format.",
+    "detail_info": "See the detail information from <a href='https://docs.growi.org/en/admin-guide/upgrading/50x.html' target='_blank' class='alert-link'>Upgrading GROWI to v5.0.x <i class='icon-share-alt'></i></a>.",
+    "modal": {
+      "title": "Convert to new v5 compatible format",
+      "converting_pages": "Converting pages",
+      "convert_recursively_label": "Convert child pages recursively.",
+      "convert_recursively_desc": "Convert pages under this path recursively.",
+      "button_label": "Convert"
+    }
+  },
   "security_setting": {
     "Guest Users Access": "Guest users access",
     "Fixed by env var": "This is fixed by the env var <code>%s=%s</code>.",
@@ -976,7 +992,9 @@
   },
   "pagetree": {
     "private_legacy_pages": "Private Legacy Pages",
-    "cannot_rename_a_title_that_contains_slash": "Cannot rename a title that contains '/'"
+    "cannot_rename_a_title_that_contains_slash": "Cannot rename a title that contains '/'",
+    "you_cannot_move_this_page_now": "You cannot move this page now",
+    "something_went_wrong_with_moving_page": "Something went wrong with moving page"
   },
   "duplicated_page_alert" : {
     "same_page_name_exists": "Same page name exits as「{{pageName}}」",

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

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

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

@@ -13,7 +13,6 @@
   "Click to copy": "クリックでコピー",
   "Rename": "名前変更",
   "Move/Rename": "移動/名前変更",
-  "Moved": "移動しました",
   "Redirected": "リダイレクトされました",
   "Unlinked": "リダイレクト削除",
   "Like!": "いいね!",
@@ -125,6 +124,7 @@
   "User_Management": "ユーザー管理",
   "external_account_management": "外部アカウント管理",
   "UserGroup": "グループ",
+  "ChildUserGroup": "子グループ",
   "UserGroup Management": "グループ管理",
   "Full Text Search Management": "全文検索管理",
   "Import Data": "データインポート",
@@ -169,6 +169,8 @@
   "new_path":"新しいパス",
   "duplicated_path":"重複したパス",
   "Link sharing is disabled": "リンクのシェアは無効化されています",
+  "successfully_saved_the_page": "ページが正常に保存されました",
+  "you_can_not_create_page_with_this_name": "この名前でページを作成することはできません",
   "personal_dropdown": {
     "home": "ホーム",
     "settings": "設定",
@@ -366,12 +368,8 @@
   "page_page": {
     "notice": {
       "version": "これは現在の版ではありません。",
-      "moved": "このページは",
-      "moved_period":"から移動しました。",
       "redirected": "リダイレクト元 >>",
       "redirected_period":"",
-      "duplicated": "このページは",
-      "duplicated_period": "から複製されました。",
       "unlinked": "このページへのリダイレクトは削除されました。",
       "restricted": "このページの閲覧は制限されています",
       "stale": "このページは最終更新日から{{count}}年以上が経過しています。",
@@ -379,9 +377,6 @@
       "no_deadline": "このページに有効期限は設定されていません。"
     }
   },
-  "page_table_of_contents": {
-    "empty": "目次は空です"
-  },
   "page_edit": {
     "Show active line": "アクティブ行をハイライト",
     "auto_format_table": "表の自動整形",
@@ -412,8 +407,8 @@
     "label": {
       "Move/Rename page": "ページを移動/名前変更する",
       "New page name": "移動先のページ名",
-      "Fail to get subordinated pages": "配下ページの取得に失敗しました",
-      "Fail to get exist path": "存在するパスの取得に失敗しました",
+      "Failed to get subordinated pages": "配下ページの取得に失敗しました",
+      "Failed to get exist path": "存在するパスの取得に失敗しました",
       "Rename without exist path": "存在するパス以外を名前変更する",
       "Current page name": "現在のページ名",
       "Recursively": "再帰的に移動/名前変更",
@@ -428,6 +423,7 @@
   },
   "Put Back": "元に戻す",
   "Delete Completely": "完全削除",
+  "page_has_been_reverted": "{{path}} を元に戻しました",
   "modal_delete": {
     "delete_page": "ページを削除する",
     "deleting_page": "ページパス",
@@ -437,6 +433,9 @@
     "recursively": "配下のページも削除します",
     "completely": "ゴミ箱を経由せず、完全に削除します"
   },
+  "deleted_pages": "{{path}} をゴミ箱に入れました",
+  "deleted_pages_completely": "{{path}} を完全に削除しました",
+  "renamed_pages": "{{path}} を移動/名前変更しました",
   "modal_empty":{
     "empty_the_trash": "ゴミ箱を空にする",
     "notice": "完全削除したページは元に戻すことができません"
@@ -445,7 +444,7 @@
     "label": {
       "Duplicate page": "ページを複製する",
       "New page name": "複製後のページ名",
-      "Fail to get subordinated pages": "配下ページの取得に失敗しました",
+      "Failed to get subordinated pages": "配下ページの取得に失敗しました",
       "Current page name": "現在のページ名",
       "Recursively": "再帰的に複製",
       "Duplicate without exist path": "存在するパス以外を複製する",
@@ -455,6 +454,7 @@
       "recursive": "配下のページも複製します"
     }
   },
+  "duplicated_pages": "{{fromPath}} を複製しました",
   "modal_putback": {
     "label": {
       "Put Back Page": "ページを元に戻す",
@@ -632,6 +632,22 @@
       "updatedAt": "更新日時"
     }
   },
+  "private_legacy_pages": {
+    "bulk_operation": "一括操作",
+    "convert_all_selected_pages": "新しい v5 互換形式に一括変換",
+    "alert_title": "古い v4 互換形式のプライベートページを表示しています",
+    "alert_desc1": "このページでは、チェックボックスでページを選択し、画面上部の「一括操作」ボタンから新しい v5 互換形式に一括変換できます。",
+    "nopages_title": "おめでとうございます。GROWI v5 を使う準備が完了しました!",
+    "nopages_desc1": "今あなたが管理可能なページはすべて v5 互換形式になっているようです。",
+    "detail_info": "詳しくは <a href='https://docs.growi.org/ja/admin-guide/upgrading/50x.html' target='_blank' class='alert-link'>GROWI v5.0.x へのアップグレード  <i class='icon-share-alt'></i></a> を参照ください。",
+    "modal": {
+      "title": "新しい v5 互換形式への変換",
+      "converting_pages": "以下のページを変換します",
+      "convert_recursively_label": "再起的に変換",
+      "convert_recursively_desc": "このページの配下のページを再起的に変換します",
+      "button_label": "変換"
+    }
+  },
   "security_setting": {
     "Guest Users Access": "ゲストユーザーのアクセス",
     "Fixed by env var": "環境変数 <code>{{forcewikimode}}={{wikimode}}</code> により固定されています。",
@@ -967,8 +983,10 @@
     "password_and_confirm_password_does_not_match": "パスワードと確認パスワードが一致しません"
   },
   "pagetree": {
-    "private_legacy_pages": "待避所",
-    "cannot_rename_a_title_that_contains_slash": "`/` が含まれているタイトルにリネームできません"
+    "private_legacy_pages": "旧形式のプライベートページ",
+    "cannot_rename_a_title_that_contains_slash": "`/` が含まれているタイトルにリネームできません",
+    "you_cannot_move_this_page_now": "現在、このページを移動することはできません",
+    "something_went_wrong_with_moving_page": "ページの移動に問題が発生しました"
   },
   "duplicated_page_alert" : {
     "same_page_name_exists": "ページ名 「{{pageName}}」が重複しています",

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

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

+ 33 - 15
packages/app/resource/locales/zh_CN/translation.json

@@ -14,7 +14,6 @@
 	"Click to copy": "点击复制",
   "Rename": "重命名",
 	"Move/Rename": "移动/重命名",
-	"Moved": "移动",
 	"Redirected": "重定向",
 	"Unlinked": "Unlinked",
 	"Like!": "Like!",
@@ -133,6 +132,7 @@
 	"User_Management": "用户管理",
 	"external_account_management": "外部账户管理",
   "UserGroup": "用户组",
+  "ChildUserGroup": "儿童用户组",
 	"UserGroup Management": "用户组管理",
 	"Full Text Search Management": "全文搜索管理",
 	"Import Data": "导入数据",
@@ -175,6 +175,8 @@
   "new_path":"New path",
   "duplicated_path":"duplicated_path",
   "Link sharing is disabled": "你不允许分享该链接",
+  "successfully_saved_the_page": "成功地保存了该页面",
+  "you_can_not_create_page_with_this_name": "您无法使用此名称创建页面",
 	"form_validation": {
 		"error_message": "有些值不正确",
 		"required": "%s 是必需的",
@@ -345,12 +347,8 @@
 	"page_page": {
 		"notice": {
 			"version": "这不是当前版本。",
-			"moved": "此页已从",
-      "moved_period": "",
 			"redirected": "您将从",
       "redirected_period": "",
-			"duplicated": "此页来自",
-      "duplicated_period": "",
 			"unlinked": "将网页重定向到此网页已被删除。",
 			"restricted": "访问此页受到限制",
 			"stale": "自上次更新以来,已超过{{count}年。",
@@ -366,9 +364,6 @@
 			"conflict": "无法保存您所做的更改,因为其他人正在编辑此页。请在重新加载页面后重新编辑受影响的部分。"
 		}
   },
-  "page_table_of_contents": {
-    "empty": "目录为空"
-  },
   "page_comment": {
     "display_the_page_when_posting_this_comment": "Display the page when posting this comment"
   },
@@ -391,8 +386,8 @@
 		"label": {
 			"Move/Rename page": "页面 移动/重命名",
       "New page name": "新建页面名称",
-      "Fail to get subordinated pages": "Fail to get subordinated pages",
-      "Fail to get exist path": "Fail to get exist path",
+      "Failed to get subordinated pages": "Failed to get subordinated pages",
+      "Failed to get exist path": "Failed to get exist path",
       "Rename without exist path": "Rename without exist path",
 			"Current page name": "当前页面名称",
 			"Recursively": "递归地",
@@ -406,7 +401,8 @@
 		}
 	},
 	"Put Back": "Put back",
-	"Delete Completely": "Delete completely",
+  "Delete Completely": "Delete completely",
+  "page_has_been_reverted": "{{path}} 已还原",
 	"modal_delete": {
 		"delete_page": "Delete page",
 		"deleting_page": "Deleting page",
@@ -415,7 +411,10 @@
 		"delete_completely_restriction": "You don't have the authority to delete pages completely.",
 		"recursively": "Delete children of <code>%s</code> recursively.",
 		"completely": "Delete completely instead of putting it into trash."
-	},
+  },
+  "deleted_pages": "将 {{path}} 放入垃圾箱",
+  "deleted_pages_completely": "{{path}} 已被完全删除",
+  "renamed_pages": "移动/重命名 {{path}}",
 	"modal_empty": {
 		"empty_the_trash": "Empty The Trash",
 		"notice": "完全删除的页面是不可恢复的。"
@@ -424,7 +423,7 @@
 		"label": {
 			"Duplicate page": "Duplicate page",
       "New page name": "New page name",
-      "Fail to get subordinated pages": "Fail to get subordinated pages",
+      "Failed to get subordinated pages": "Failed to get subordinated pages",
 			"Current page name": "Current page name",
       "Recursively": "Recursively",
       "Duplicate without exist path": "Duplicate without exist path",
@@ -433,7 +432,8 @@
     "help": {
       "recursive": "Duplicate children of under this path recursively"
     }
-	},
+  },
+  "duplicated_pages": "{{fromPath}} 已重复",
 	"modal_putback": {
 		"label": {
 			"Put Back Page": "Put back page",
@@ -910,6 +910,22 @@
       "updatedAt": "按更新日期排序"
     }
 	},
+  "private_legacy_pages": {
+    "bulk_operation": "Bulk operation",
+    "convert_all_selected_pages": "Convert all to new v5 compatible format",
+    "alert_title": "You are viewing old v4 compatible private pages.",
+    "alert_desc1": "On this page, you can select pages with the checkbox and batch convert to the new v5 compatible format from the \"Bulk operation\" button at the top of the screen.",
+    "nopages_title": "Congratulations. Ready to use GROWI v5!",
+    "nopages_desc1": "Now all the pages you can manage seem to be in v5 compatible format.",
+    "detail_info": "See the detail information from <a href='https://docs.growi.org/en/admin-guide/upgrading/50x.html' target='_blank' class='alert-link'>Upgrading GROWI to v5.0.x <i class='icon-share-alt'></i></a>.",
+    "modal": {
+      "title": "Convert to new v5 compatible format",
+      "converting_pages": "Converting pages",
+      "convert_recursively_label": "Convert child pages recursively.",
+      "convert_recursively_desc": "Convert pages under this path recursively.",
+      "button_label": "Convert"
+    }
+  },
 	"to_cloud_settings": "進入 GROWI.cloud 的管理界面",
 	"login": {
 		"Sign in error": "登录错误",
@@ -978,7 +994,9 @@
   },
   "pagetree": {
     "private_legacy_pages": "私人遗留页面",
-    "cannot_rename_a_title_that_contains_slash": "不能重命名包含 ’/' 的标题"
+    "cannot_rename_a_title_that_contains_slash": "不能重命名包含 ’/' 的标题",
+    "you_cannot_move_this_page_now": "你现在不能移动这个页面",
+    "something_went_wrong_with_moving_page": "移动页面时出了问题"
   },
   "duplicated_page_alert" : {
     "same_page_name_exists": "页面名称「{{pageName}}」是重复的",

+ 123 - 0
packages/app/resource/search/mappings-es6-for-ci.json

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

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

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

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

@@ -13,7 +13,7 @@ import { swrGlobalConfiguration } from '~/utils/swr-utils';
 import InAppNotificationPage from '../components/InAppNotification/InAppNotificationPage';
 import ErrorBoundary from '../components/ErrorBoudary';
 import Sidebar from '../components/Sidebar';
-import SearchPage from '../components/SearchPage';
+import { SearchPage } from '../components/SearchPage';
 import TagsList from '../components/TagsList';
 import DisplaySwitcher from '../components/Page/DisplaySwitcher';
 import { defaultEditorOptions, defaultPreviewOptions } from '../components/PageEditor/OptionsSelector';
@@ -22,11 +22,8 @@ import PageComments from '../components/PageComments';
 import PageContentFooter from '../components/PageContentFooter';
 import PageTimeline from '../components/PageTimeline';
 import CommentEditorLazyRenderer from '../components/PageComment/CommentEditorLazyRenderer';
-import PageManagement from '../components/Page/PageManagement';
 import ShareLinkAlert from '../components/Page/ShareLinkAlert';
-import DuplicatedAlert from '../components/Page/DuplicatedAlert';
 import RedirectedAlert from '../components/Page/RedirectedAlert';
-import RenamedAlert from '../components/Page/RenamedAlert';
 import TrashPageList from '../components/TrashPageList';
 import TrashPageAlert from '../components/Page/TrashPageAlert';
 import NotFoundPage from '../components/NotFoundPage';
@@ -54,6 +51,7 @@ import PersonalContainer from '~/client/services/PersonalContainer';
 
 import { appContainer, componentMappings } from './base';
 import { toastError } from './util/apiNotification';
+import { PrivateLegacyPages } from '~/components/PrivateLegacyPages';
 
 const logger = loggerFactory('growi:cli:app');
 
@@ -85,7 +83,9 @@ logger.info('unstated containers have been initialized');
 Object.assign(componentMappings, {
   'grw-sidebar-wrapper': <Sidebar />,
 
-  'search-page': <SearchPage crowi={appContainer} />,
+  'search-page': <SearchPage appContainer={appContainer} />,
+  'private-regacy-pages': <PrivateLegacyPages appContainer={appContainer} />,
+
   'all-in-app-notifications': <InAppNotificationPage />,
   'identical-path-page': <IdenticalPathPage />,
 
@@ -96,7 +96,7 @@ Object.assign(componentMappings, {
 
   'trash-page-alert': <TrashPageAlert />,
 
-  'trash-page-list': <TrashPageList />,
+  'trash-page-list-container': <TrashPageList />,
 
   'not-found-page': <NotFoundPage />,
 
@@ -111,9 +111,7 @@ Object.assign(componentMappings, {
   'grw-fab-container': <Fab />,
 
   'share-link-alert': <ShareLinkAlert />,
-  'duplicated-alert': <DuplicatedAlert />,
   'redirected-alert': <RedirectedAlert />,
-  'renamed-alert': <RenamedAlert />,
   'not-found-alert': <NotFoundAlert
     isGuestUserMode={appContainer.isGuestUser}
   />,
@@ -124,7 +122,6 @@ if (pageContainer.state.pageId != null) {
   Object.assign(componentMappings, {
     'page-comments-list': <PageComments />,
     'page-comment-write': <CommentEditorLazyRenderer />,
-    'page-management': <PageManagement />,
     'page-content-footer': <PageContentFooter />,
 
     'recent-created-icon': <RecentlyCreatedIcon />,

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

@@ -10,7 +10,9 @@ import PageCreateModal from '../components/PageCreateModal';
 import PageDeleteModal from '../components/PageDeleteModal';
 import PageDuplicateModal from '../components/PageDuplicateModal';
 import PageRenameModal from '../components/PageRenameModal';
+import PagePresentationModal from '../components/PagePresentationModal';
 import PageAccessoriesModal from '../components/PageAccessoriesModal';
+import PutbackPageModal from '~/components/PutbackPageModal';
 
 import AppContainer from '~/client/services/AppContainer';
 import SocketIoContainer from '~/client/services/SocketIoContainer';
@@ -48,8 +50,10 @@ const componentMappings = {
   'page-delete-modal': <PageDeleteModal />,
   'page-duplicate-modal': <PageDuplicateModal />,
   'page-rename-modal': <PageRenameModal />,
+  'page-presentation-modal': <PagePresentationModal />,
   'page-accessories-modal': <PageAccessoriesModal />,
   'descendants-page-list-modal': <DescendantsPageListModal />,
+  'page-put-back-modal': <PutbackPageModal />,
 
   'grw-hotkeys-manager': <HotkeysManager />,
 

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

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

+ 5 - 5
packages/app/src/client/services/AdminHomeContainer.js

@@ -25,12 +25,12 @@ export default class AdminHomeContainer extends Container {
     this.timer = null;
 
     this.state = {
-      growiVersion: '',
-      nodeVersion: '',
-      npmVersion: '',
-      yarnVersion: '',
+      growiVersion: null,
+      nodeVersion: null,
+      npmVersion: null,
+      yarnVersion: null,
       copyState: this.copyStateValues.DEFAULT,
-      installedPlugins: [],
+      installedPlugins: null,
       isV5Compatible: null,
     };
 

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

@@ -7,12 +7,14 @@ import {
   useCurrentPageId, usePageIdOnHackmd, usePageUser, useCurrentPagePath, useRevisionCreatedAt, useRevisionId, useRevisionIdHackmdSynced,
   useShareLinkId, useShareLinksNumber, useTemplateTagData, useCurrentUpdatedAt, useCreator, useRevisionAuthor, useCurrentUser, useTargetAndAncestors,
   useSlackChannels, useNotFoundTargetPathOrId, useIsSearchPage, useIsForbidden, useIsIdenticalPath,
+  useIsAclEnabled, useIsSearchServiceConfigured, useIsSearchServiceReachable, useIsEnabledAttachTitleHeader, useIsNotFoundPermalink,
 } from '../../stores/context';
 import {
   useIsDeviceSmallerThanMd, useIsDeviceSmallerThanLg,
   usePreferDrawerModeByUser, usePreferDrawerModeOnEditByUser, useSidebarCollapsed, useCurrentSidebarContents, useCurrentProductNavWidth,
   useSelectedGrant, useSelectedGrantGroupId, useSelectedGrantGroupName,
 } from '~/stores/ui';
+import { useSetupGlobalSocket } from '~/stores/websocket';
 import { IUserUISettings } from '~/interfaces/user-ui-settings';
 
 const { isTrashPage: _isTrashPage } = pagePathUtils;
@@ -22,7 +24,8 @@ const jsonNull = 'null';
 const ContextExtractorOnce: FC = () => {
 
   const mainContent = document.querySelector('#content-main');
-  const notFoundContent = document.getElementById('growi-pagetree-not-found-context');
+  const notFoundContentForPt = document.getElementById('growi-pagetree-not-found-context');
+  const notFoundContent = document.getElementById('growi-not-found-context');
   const forbiddenContent = document.getElementById('forbidden-page');
 
   /*
@@ -30,6 +33,11 @@ const ContextExtractorOnce: FC = () => {
    */
   const currentUser = JSON.parse(document.getElementById('growi-current-user')?.textContent || jsonNull);
 
+  /*
+   * Settings from context-hydrate DOM
+   */
+  const configByContextHydrate = JSON.parse(document.getElementById('growi-context-hydrate')?.textContent || jsonNull);
+
   /*
    * UserUISettings from DOM
    */
@@ -70,7 +78,8 @@ const ContextExtractorOnce: FC = () => {
   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 notFoundTargetPathOrId = JSON.parse(notFoundContentForPt?.getAttribute('data-not-found-target-path-or-id') || jsonNull);
+  const isNotFoundPermalink = JSON.parse(notFoundContent?.getAttribute('data-is-not-found-permalink') || jsonNull);
   const slackChannels = mainContent?.getAttribute('data-slack-channels') || '';
   const isSearchPage = document.getElementById('search-page') != null;
 
@@ -91,6 +100,13 @@ const ContextExtractorOnce: FC = () => {
   useCurrentSidebarContents(userUISettings?.currentSidebarContents);
   useCurrentProductNavWidth(userUISettings?.currentProductNavWidth);
 
+  // hydrated config
+  useIsAclEnabled(configByContextHydrate.isAclEnabled);
+  useIsSearchServiceConfigured(configByContextHydrate.isSearchServiceConfigured);
+  useIsSearchServiceReachable(configByContextHydrate.isSearchServiceReachable);
+  useIsEnabledAttachTitleHeader(configByContextHydrate.isEnabledAttachTitleHeader);
+
+
   // Page
   useCurrentCreatedAt(createdAt);
   useDeleteUsername(deleteUsername);
@@ -119,6 +135,7 @@ const ContextExtractorOnce: FC = () => {
   useRevisionAuthor(revisionAuthor);
   useTargetAndAncestors(targetAndAncestors);
   useNotFoundTargetPathOrId(notFoundTargetPathOrId);
+  useIsNotFoundPermalink(isNotFoundPermalink);
   useIsSearchPage(isSearchPage);
 
   // Navigation
@@ -140,6 +157,9 @@ const ContextExtractorOnce: FC = () => {
   // SearchResult
   useIsDeviceSmallerThanLg();
 
+  // Global Socket
+  useSetupGlobalSocket();
+
   return null;
 };
 

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

@@ -459,6 +459,7 @@ export default class PageContainer extends Container {
 
     const { pageId, remoteRevisionId, path } = this.state;
     const editorContainer = this.appContainer.getContainer('EditorContainer');
+    const pageEditor = this.appContainer.getComponentInstance('PageEditor');
     const options = editorContainer.getCurrentOptionsToSave();
     const optionsToSave = Object.assign({}, options);
 
@@ -467,6 +468,10 @@ export default class PageContainer extends Container {
     editorContainer.clearDraft(path);
     this.updateStateAfterSave(res.page, res.tags, res.revision, editorMode);
 
+    if (pageEditor != null) {
+      pageEditor.updateEditorValue(markdown);
+    }
+
     editorContainer.setState({ tags: res.tags });
 
     return res;

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

@@ -37,7 +37,7 @@ const AdminHome = (props) => {
   }, [fetchAdminHomeData]);
 
   return (
-    <>
+    <div data-testid="admin-home">
       {
       // Alert message will be displayed in case that V5 migration has not been compleated
         (migrationStatus != null && !migrationStatus.isV5Compatible)
@@ -106,7 +106,7 @@ const AdminHome = (props) => {
           </div>
         </div>
       </div>
-    </>
+    </div>
   );
 };
 

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

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

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

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

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

@@ -19,7 +19,7 @@ class AppSettingsPageContents extends React.Component {
     const { isV5Compatible } = adminAppContainer.state;
 
     return (
-      <Fragment>
+      <div data-testid="admin-app-settings">
         {
           !isV5Compatible
           && (
@@ -66,7 +66,7 @@ class AppSettingsPageContents extends React.Component {
             <PluginSetting />
           </div>
         </div>
-      </Fragment>
+      </div>
     );
   }
 

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

@@ -7,7 +7,6 @@ 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';
@@ -20,8 +19,6 @@ import Xss from '~/services/xss';
  * @extends {React.Component}
  */
 type Props = {
-  appContainer: AppContainer,
-
   userGroups: IUserGroupHasId[],
   deleteUserGroup?: IUserGroupHasId,
   onDelete?: (deleteGroupId: string, actionName: string, transferToUserGroupId: string) => Promise<void> | void,

+ 12 - 27
packages/app/src/components/Admin/UserGroup/UserGroupForm.tsx

@@ -3,17 +3,12 @@ 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>
 };
@@ -23,12 +18,14 @@ const UserGroupForm: FC<Props> = (props: Props) => {
 
   const { t } = useTranslation();
 
+  const { userGroup, submitButtonLabel, onSubmit } = props;
+
   /*
    * 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 : '');
+  const [currentName, setName] = useState(userGroup != null ? userGroup.name : '');
+  const [currentDescription, setDescription] = useState(userGroup != null ? userGroup.description : '');
+  const [currentParent, setParent] = useState(userGroup != null ? userGroup.parent : '');
 
   /*
    * Function
@@ -44,19 +41,12 @@ const UserGroupForm: FC<Props> = (props: Props) => {
   const onSubmitHandler = useCallback(async(e) => {
     e.preventDefault(); // no reload
 
-    if (props.onSubmit == null) {
+    if (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]);
+    await onSubmit({ name: currentName, description: currentDescription, parent: currentParent });
+  }, [currentName, currentDescription, currentParent, onSubmit]);
 
   return (
     <form onSubmit={onSubmitHandler}>
@@ -65,10 +55,10 @@ const UserGroupForm: FC<Props> = (props: Props) => {
         <h2 className="admin-setting-header">{t('admin:user_group_management.basic_info')}</h2>
         {/* TODO 85062: improve style */}
         {
-          props.userGroup?.createdAt != null && (
+          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>
+              <p className="col-md-4 my-auto">{dateFnsFormat(new Date(userGroup.createdAt), 'yyyy-MM-dd')}</p>
             </div>
           )
         }
@@ -102,7 +92,7 @@ const UserGroupForm: FC<Props> = (props: Props) => {
         <div className="form-group row">
           <div className="offset-md-2 col-md-10">
             <button type="submit" className="btn btn-primary">
-              {props.submitButtonLabel}
+              {submitButtonLabel}
             </button>
           </div>
         </div>
@@ -111,9 +101,4 @@ const UserGroupForm: FC<Props> = (props: Props) => {
   );
 };
 
-/**
- * Wrapper component for using unstated
- */
-const UserGroupFormWrapper = withUnstatedContainers<unknown, Props>(UserGroupForm, [AppContainer]);
-
-export default UserGroupFormWrapper;
+export default UserGroupForm;

+ 39 - 46
packages/app/src/components/Admin/UserGroup/UserGroupPage.tsx

@@ -1,47 +1,55 @@
-import React, {
-  FC, Fragment, useState, useCallback,
-} from 'react';
+import React, { FC, useState, useCallback } from 'react';
 import { useTranslation } from 'react-i18next';
 
 import UserGroupTable from './UserGroupTable';
-import UserGroupForm from './UserGroupForm';
+import UserGroupCreateModal from './UserGroupCreateModal';
 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';
+import { useIsAclEnabled } from '~/stores/context';
 
-type Props = {
-  appContainer: AppContainer,
-};
-
-const UserGroupPage: FC<Props> = (props: Props) => {
+const UserGroupPage: FC = () => {
   const xss: Xss = (window as CustomWindow).xss;
   const { t } = useTranslation();
-  const { isAclEnabled } = props.appContainer.config;
+
+  const { data: isAclEnabled } = useIsAclEnabled();
 
   /*
    * 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);
+  const { data: userGroupList, mutate: mutateUserGroups } = useSWRxUserGroupList();
+  const userGroups = userGroupList != null ? userGroupList : [];
+  const userGroupIds = userGroups.map(group => group._id);
+
+  const { data: userGroupRelationList } = useSWRxUserGroupRelationList(userGroupIds);
+  const userGroupRelations = userGroupRelationList != null ? userGroupRelationList : [];
+
+  const { data: childUserGroupsList } = useSWRxChildUserGroupList(userGroupIds);
+  const childUserGroups = childUserGroupsList != null ? childUserGroupsList.childUserGroups : [];
 
   /*
    * State
    */
   const [selectedUserGroup, setSelectedUserGroup] = useState<IUserGroupHasId | undefined>(undefined); // not null but undefined (to use defaultProps in UserGroupDeleteModal)
+  const [isCreateModalShown, setCreateModalShown] = useState<boolean>(false);
   const [isDeleteModalShown, setDeleteModalShown] = useState<boolean>(false);
 
   /*
    * Functions
    */
+  const showCreateModal = useCallback(() => {
+    setCreateModalShown(true);
+  }, [setCreateModalShown]);
+
+  const hideCreateModal = useCallback(() => {
+    setCreateModalShown(false);
+  }, [setCreateModalShown]);
+
   const syncUserGroupAndRelations = useCallback(async() => {
     try {
       await mutateUserGroups();
@@ -68,21 +76,19 @@ const UserGroupPage: FC<Props> = (props: Props) => {
     setDeleteModalShown(false);
   }, []);
 
-  const addUserGroup = useCallback(async(userGroupData: IUserGroup) => {
+  const createUserGroup = useCallback(async(userGroupData: IUserGroup) => {
     try {
       await apiv3Post('/user-groups', {
         name: userGroupData.name,
         description: userGroupData.description,
-        parent: userGroupData.parent,
       });
-
-      // sync
+      toastSuccess(t('toaster.update_successed', { target: t('UserGroup') }));
       await mutateUserGroups();
     }
     catch (err) {
       toastError(err);
     }
-  }, [mutateUserGroups]);
+  }, [t, mutateUserGroups]);
 
   const deleteUserGroupById = useCallback(async(deleteGroupId: string, actionName: string, transferToUserGroupId: string) => {
     try {
@@ -102,43 +108,35 @@ const UserGroupPage: FC<Props> = (props: Props) => {
     catch (err) {
       toastError(new Error('Unable to delete the groups'));
     }
-  }, [mutateUserGroups, mutateUserGroupRelations]);
-
-  if (userGroups == null || userGroupRelations == null || childUserGroups == null) {
-    return <></>;
-  }
+  }, [mutateUserGroups]);
 
   return (
-    <Fragment>
+    <div data-testid="admin-user-groups">
       {
         isAclEnabled ? (
           <div className="mb-2">
-            <button type="button" className="btn btn-outline-secondary" data-toggle="collapse" data-target="#createGroupForm">
+            <button type="button" className="btn btn-outline-secondary" onClick={showCreateModal}>
               {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')
         )
       }
+      <UserGroupCreateModal
+        onClickCreateButton={createUserGroup}
+        isShow={isCreateModalShown}
+        onHide={hideCreateModal}
+      />
       <UserGroupTable
-        appContainer={props.appContainer}
+        headerLabel={t('admin:user_group_management.group_list')}
         userGroups={userGroups}
         childUserGroups={childUserGroups}
-        isAclEnabled={isAclEnabled}
+        isAclEnabled={isAclEnabled ?? false}
         onDelete={showDeleteModal}
         userGroupRelations={userGroupRelations}
       />
       <UserGroupDeleteModal
-        appContainer={props.appContainer}
         userGroups={userGroups}
         deleteUserGroup={selectedUserGroup}
         onDelete={deleteUserGroupById}
@@ -146,13 +144,8 @@ const UserGroupPage: FC<Props> = (props: Props) => {
         onShow={showDeleteModal}
         onHide={hideDeleteModal}
       />
-    </Fragment>
+    </div>
   );
 };
 
-/**
- * Wrapper component for using unstated
- */
-const UserGroupPageWrapper = withUnstatedContainers(UserGroupPage, [AppContainer]);
-
-export default UserGroupPageWrapper;
+export default UserGroupPage;

+ 5 - 7
packages/app/src/components/Admin/UserGroup/UserGroupTable.tsx

@@ -2,17 +2,15 @@ import React, {
   FC, useState, useCallback, useEffect,
 } from 'react';
 import { useTranslation } from 'react-i18next';
+import { TFunctionResult } from 'i18next';
 import dateFnsFormat from 'date-fns/format';
 
 import Xss from '~/services/xss';
-import AppContainer from '~/client/services/AppContainer';
 import { IUserGroupHasId, IUserGroupRelation, IUserHasId } from '~/interfaces/user';
 import { CustomWindow } from '~/interfaces/global';
 
-
 type Props = {
-  appContainer: AppContainer,
-
+  headerLabel?: TFunctionResult,
   userGroups: IUserGroupHasId[],
   userGroupRelations: IUserGroupRelation[],
   childUserGroups: IUserGroupHasId[],
@@ -82,7 +80,7 @@ const UserGroupTable: FC<Props> = (props: Props) => {
     }
 
     props.onDelete(group);
-  }, [props.userGroups, props.onDelete]);
+  }, [props]);
 
   /*
    * useEffect
@@ -94,7 +92,7 @@ const UserGroupTable: FC<Props> = (props: Props) => {
 
   return (
     <>
-      <h2>{t('admin:user_group_management.group_list')}</h2>
+      <h2>{props.headerLabel}</h2>
 
       <table className="table table-bordered table-user-list">
         <thead>
@@ -102,7 +100,7 @@ const UserGroupTable: FC<Props> = (props: Props) => {
             <th>{t('Name')}</th>
             <th>{t('Description')}</th>
             <th>{t('User')}</th>
-            <th>{t('Child groups')}</th>
+            <th>{t('ChildUserGroup')}</th>
             <th style={{ width: 100 }}>{t('Created')}</th>
             <th style={{ width: 70 }}></th>
           </tr>

+ 112 - 46
packages/app/src/components/Admin/UserGroupDetail/UserGroupDetailPage.tsx

@@ -4,50 +4,60 @@ import React, {
 import { useTranslation } from 'react-i18next';
 
 import UserGroupForm from '../UserGroup/UserGroupForm';
+import UserGroupTable from '../UserGroup/UserGroupTable';
+import UserGroupCreateModal from '../UserGroup/UserGroupCreateModal';
+import UserGroupDeleteModal from '../UserGroup/UserGroupDeleteModal';
 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,
+  IUserGroup, IUserGroupHasId,
 } from '~/interfaces/user';
-import { useSWRxUserGroupPages, useSWRxUserGroupRelations, useSWRxSelectableUserGroups } from '~/stores/user-group';
-
+import {
+  useSWRxUserGroupPages, useSWRxUserGroupRelationList, useSWRxChildUserGroupList, useSWRxSelectableUserGroups,
+} from '~/stores/user-group';
+import { useIsAclEnabled } from '~/stores/context';
 
 const UserGroupDetailPage: FC = () => {
-  const rootElem = document.getElementById('admin-user-group-detail');
   const { t } = useTranslation();
+  const adminUserGroupDetailElem = document.getElementById('admin-user-group-detail');
 
   /*
    * 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 [userGroup, setUserGroup] = useState<IUserGroupHasId>(JSON.parse(adminUserGroupDetailElem?.getAttribute('data-user-group') || 'null'));
   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);
+  const [selectedUserGroup, setSelectedUserGroup] = useState<IUserGroupHasId | undefined>(undefined); // not null but undefined (to use defaultProps in UserGroupDeleteModal)
+  const [isCreateModalShown, setCreateModalShown] = useState<boolean>(false);
+  const [isDeleteModalShown, setDeleteModalShown] = useState<boolean>(false);
 
   /*
    * Fetch
    */
   const { data: userGroupPages } = useSWRxUserGroupPages(userGroup._id, 10, 0);
-  const { data: userGroupRelations, mutate: mutateUserGroupRelations } = useSWRxUserGroupRelations(userGroup._id);
+
+  const { data: childUserGroupsList, mutate: mutateChildUserGroups } = useSWRxChildUserGroupList([userGroup._id], true);
+  const childUserGroups = childUserGroupsList != null ? childUserGroupsList.childUserGroups : [];
+  const grandChildUserGroups = childUserGroupsList != null ? childUserGroupsList.grandChildUserGroups : [];
+  const childUserGroupIds = childUserGroups.map(group => group._id);
+
+  const { data: userGroupRelationList, mutate: mutateUserGroupRelations } = useSWRxUserGroupRelationList(childUserGroupIds);
+  const childUserGroupRelations = userGroupRelationList != null ? userGroupRelationList : [];
+
   const { data: selectableUserGroups, mutate: mutateSelectableUserGroups } = useSWRxSelectableUserGroups(userGroup._id);
 
+  const { data: isAclEnabled } = useIsAclEnabled();
+
   /*
    * Function
    */
@@ -66,21 +76,16 @@ const UserGroupDetailPage: FC = () => {
   }, []);
 
   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);
-  }, []);
+    try {
+      const res = await apiv3Put<{ userGroup: IUserGroupHasId }>(`/user-groups/${userGroup._id}`, param);
+      const { userGroup: newUserGroup } = res.data;
+      setUserGroup(newUserGroup);
+      toastSuccess(t('toaster.update_successed', { target: t('UserGroup') }));
+    }
+    catch (err) {
+      toastError(err);
+    }
+  }, [t, userGroup._id, setUserGroup]);
 
   const fetchApplicableUsers = useCallback(async(searchWord) => {
     const res = await apiv3Get(`/user-groups/${userGroup._id}/unrelated-users`, {
@@ -112,9 +117,10 @@ const UserGroupDetailPage: FC = () => {
         name: selectedUserGroup.name,
         description: selectedUserGroup.description,
         parentId: userGroup._id,
-        forceUpdateParents: false, //  TODO 87748: Make forceUpdateParents optionally selectable
+        forceUpdateParents: false,
       });
       mutateSelectableUserGroups();
+      mutateChildUserGroups();
       toastSuccess(t('toaster.update_successed', { target: t('UserGroup') }));
     }
     catch (err) {
@@ -122,10 +128,58 @@ const UserGroupDetailPage: FC = () => {
     }
   };
 
-  // TODO 87614: UserGroup New creation form can be displayed in modal
-  const onClickCreateChildGroupButtonHandler = () => {
-    console.log('button clicked!');
-  };
+  const showCreateModal = useCallback(() => {
+    setCreateModalShown(true);
+  }, [setCreateModalShown]);
+
+  const hideCreateModal = useCallback(() => {
+    setCreateModalShown(false);
+  }, [setCreateModalShown]);
+
+  const createChildUserGroup = useCallback(async(userGroupData: IUserGroup) => {
+    try {
+      await apiv3Post('/user-groups', {
+        name: userGroupData.name,
+        description: userGroupData.description,
+        parentId: userGroup._id,
+      });
+      mutateChildUserGroups();
+      toastSuccess(t('toaster.update_successed', { target: t('UserGroup') }));
+    }
+    catch (err) {
+      toastError(err);
+    }
+  }, [t, userGroup, mutateChildUserGroups]);
+
+  const showDeleteModal = useCallback(async(group: IUserGroupHasId) => {
+    setSelectedUserGroup(group);
+    setDeleteModalShown(true);
+  }, [setSelectedUserGroup, setDeleteModalShown]);
+
+  const hideDeleteModal = useCallback(() => {
+    setSelectedUserGroup(undefined);
+    setDeleteModalShown(false);
+  }, [setSelectedUserGroup, setDeleteModalShown]);
+
+  const deleteChildUserGroupById = useCallback(async(deleteGroupId: string, actionName: string, transferToUserGroupId: string) => {
+    try {
+      const res = await apiv3Delete(`/user-groups/${deleteGroupId}`, {
+        actionName,
+        transferToUserGroupId,
+      });
+
+      // sync
+      await mutateChildUserGroups();
+
+      setSelectedUserGroup(undefined);
+      setDeleteModalShown(false);
+
+      toastSuccess(`Deleted ${res.data.userGroups.length} groups.`);
+    }
+    catch (err) {
+      toastError(new Error('Unable to delete the groups'));
+    }
+  }, [mutateChildUserGroups, setSelectedUserGroup, setDeleteModalShown]);
 
   /*
    * Dependencies
@@ -144,8 +198,6 @@ const UserGroupDetailPage: FC = () => {
       <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}
         />
@@ -158,7 +210,28 @@ const UserGroupDetailPage: FC = () => {
       <UserGroupDropdown
         selectableUserGroups={selectableUserGroups}
         onClickAddExistingUserGroupButtonHandler={onClickAddChildButtonHandler}
-        onClickCreateUserGroupButtonHandler={() => onClickCreateChildGroupButtonHandler()}
+        onClickCreateUserGroupButtonHandler={showCreateModal}
+      />
+      <UserGroupCreateModal
+        onClickCreateButton={createChildUserGroup}
+        isShow={isCreateModalShown}
+        onHide={hideCreateModal}
+      />
+
+      <UserGroupTable
+        userGroups={childUserGroups}
+        childUserGroups={grandChildUserGroups}
+        isAclEnabled={isAclEnabled ?? false}
+        onDelete={showDeleteModal}
+        userGroupRelations={childUserGroupRelations}
+      />
+      <UserGroupDeleteModal
+        userGroups={childUserGroups}
+        deleteUserGroup={selectedUserGroup}
+        onDelete={deleteChildUserGroupById}
+        isShow={isDeleteModalShown}
+        onShow={showDeleteModal}
+        onHide={hideDeleteModal}
       />
 
       <h2 className="admin-setting-header mt-4">{t('Page')}</h2>
@@ -167,13 +240,6 @@ const UserGroupDetailPage: FC = () => {
       </div>
     </div>
   );
-
 };
 
-
-/**
- * Wrapper component for using unstated
- */
-const UserGroupDetailPageWrapper = withUnstatedContainers(UserGroupDetailPage, [AppContainer]);
-
-export default UserGroupDetailPageWrapper;
+export default UserGroupDetailPage;

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

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

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

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

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

@@ -30,6 +30,7 @@ const ClosableTextInput: FC<ClosableTextInputProps> = memo((props: ClosableTextI
 
   const [inputText, setInputText] = useState(props.value);
   const [currentAlertInfo, setAlertInfo] = useState<AlertInfo | null>(null);
+  const [isAbleToShowAlert, setIsAbleToShowAlert] = useState<boolean>(false);
 
   const createValidation = async(inputText: string) => {
     if (props.inputValidator != null) {
@@ -42,6 +43,7 @@ const ClosableTextInput: FC<ClosableTextInputProps> = memo((props: ClosableTextI
     const inputText = e.target.value;
     createValidation(inputText);
     setInputText(inputText);
+    setIsAbleToShowAlert(true);
   };
 
   const onFocusHandler = async(e: React.ChangeEvent<HTMLInputElement>) => {
@@ -107,7 +109,7 @@ const ClosableTextInput: FC<ClosableTextInputProps> = memo((props: ClosableTextI
   return (
     <div className={props.isShown ? 'd-block' : 'd-none'}>
       <input
-        value={inputText}
+        value={inputText || ''}
         ref={inputRef}
         type="text"
         className="form-control"
@@ -119,7 +121,7 @@ const ClosableTextInput: FC<ClosableTextInputProps> = memo((props: ClosableTextI
         onBlur={onBlurHandler}
         autoFocus={false}
       />
-      <AlertInfo />
+      {isAbleToShowAlert && <AlertInfo />}
     </div>
   );
 });

+ 61 - 16
packages/app/src/components/Common/Dropdown/PageItemControl.tsx

@@ -3,7 +3,6 @@ import {
   Dropdown, DropdownMenu, DropdownToggle, DropdownItem,
 } from 'reactstrap';
 
-import toastr from 'toastr';
 import { useTranslation } from 'react-i18next';
 
 import loggerFactory from '~/utils/logger';
@@ -16,16 +15,29 @@ import { useSWRxPageInfo } from '~/stores/page';
 const logger = loggerFactory('growi:cli:PageItemControl');
 
 
+export const MenuItemType = {
+  BOOKMARK: 'bookmark',
+  DUPLICATE: 'duplicate',
+  RENAME: 'rename',
+  DELETE: 'delete',
+  REVERT: 'revert',
+} as const;
+export type MenuItemType = typeof MenuItemType[keyof typeof MenuItemType];
+
+export type ForceHideMenuItems = MenuItemType[];
+
 export type AdditionalMenuItemsRendererProps = { pageInfo: IPageInfoAll };
 
 type CommonProps = {
   pageInfo?: IPageInfoAll,
   isEnableActions?: boolean,
-  showBookmarkMenuItem?: boolean,
+  forceHideMenuItems?: ForceHideMenuItems,
+
   onClickBookmarkMenuItem?: (pageId: string, newValue?: boolean) => Promise<void>,
   onClickDuplicateMenuItem?: (pageId: string) => Promise<void> | void,
   onClickRenameMenuItem?: (pageId: string) => Promise<void> | void,
-  onClickDeleteMenuItem?: (pageId: string) => Promise<void> | void,
+  onClickDeleteMenuItem?: (pageId: string, pageInfo: IPageInfoAll | undefined) => Promise<void> | void,
+  onClickRevertMenuItem?: (pageId: string) => Promise<void> | void,
 
   additionalMenuItemRenderer?: React.FunctionComponent<AdditionalMenuItemsRendererProps>,
 }
@@ -41,8 +53,8 @@ const PageItemControlDropdownMenu = React.memo((props: DropdownMenuProps): JSX.E
 
   const {
     pageId, isLoading,
-    pageInfo, isEnableActions, showBookmarkMenuItem,
-    onClickBookmarkMenuItem, onClickDuplicateMenuItem, onClickRenameMenuItem, onClickDeleteMenuItem,
+    pageInfo, isEnableActions, forceHideMenuItems,
+    onClickBookmarkMenuItem, onClickDuplicateMenuItem, onClickRenameMenuItem, onClickDeleteMenuItem, onClickRevertMenuItem,
     additionalMenuItemRenderer: AdditionalMenuItems,
   } = props;
 
@@ -71,6 +83,14 @@ const PageItemControlDropdownMenu = React.memo((props: DropdownMenuProps): JSX.E
     await onClickRenameMenuItem(pageId);
   }, [onClickRenameMenuItem, pageId]);
 
+  const revertItemClickedHandler = useCallback(async() => {
+    if (onClickRevertMenuItem == null) {
+      return;
+    }
+    await onClickRevertMenuItem(pageId);
+  }, [onClickRevertMenuItem, pageId]);
+
+
   // eslint-disable-next-line react-hooks/rules-of-hooks
   const deleteItemClickedHandler = useCallback(async() => {
     if (pageInfo == null || onClickDeleteMenuItem == null) {
@@ -80,7 +100,7 @@ const PageItemControlDropdownMenu = React.memo((props: DropdownMenuProps): JSX.E
       logger.warn('This page could not be deleted.');
       return;
     }
-    await onClickDeleteMenuItem(pageId);
+    await onClickDeleteMenuItem(pageId, pageInfo);
   }, [onClickDeleteMenuItem, pageId, pageInfo]);
 
   let contents = <></>;
@@ -93,6 +113,10 @@ const PageItemControlDropdownMenu = React.memo((props: DropdownMenuProps): JSX.E
     );
   }
   else if (pageId != null && pageInfo != null) {
+
+    const showDeviderBeforeAdditionalMenuItems = (forceHideMenuItems?.length ?? 0) < 3;
+    const showDeviderBeforeDelete = AdditionalMenuItems != null || showDeviderBeforeAdditionalMenuItems;
+
     contents = (
       <>
         { !isEnableActions && (
@@ -104,7 +128,7 @@ const PageItemControlDropdownMenu = React.memo((props: DropdownMenuProps): JSX.E
         ) }
 
         {/* Bookmark */}
-        { showBookmarkMenuItem && isEnableActions && !pageInfo.isEmpty && isIPageInfoForOperation(pageInfo) && (
+        { !forceHideMenuItems?.includes(MenuItemType.BOOKMARK) && isEnableActions && !pageInfo.isEmpty && isIPageInfoForOperation(pageInfo) && (
           <DropdownItem onClick={bookmarkItemClickedHandler}>
             <i className="fa fa-fw fa-bookmark-o"></i>
             { pageInfo.isBookmarked ? t('remove_bookmark') : t('add_bookmark') }
@@ -112,7 +136,7 @@ const PageItemControlDropdownMenu = React.memo((props: DropdownMenuProps): JSX.E
         ) }
 
         {/* Duplicate */}
-        { isEnableActions && !pageInfo.isEmpty && (
+        { !forceHideMenuItems?.includes(MenuItemType.DUPLICATE) && isEnableActions && (
           <DropdownItem onClick={duplicateItemClickedHandler}>
             <i className="icon-fw icon-docs"></i>
             {t('Duplicate')}
@@ -120,20 +144,33 @@ const PageItemControlDropdownMenu = React.memo((props: DropdownMenuProps): JSX.E
         ) }
 
         {/* Move/Rename */}
-        { isEnableActions && pageInfo.isMovable && (
+        { !forceHideMenuItems?.includes(MenuItemType.RENAME) && isEnableActions && pageInfo.isMovable && (
           <DropdownItem onClick={renameItemClickedHandler}>
             <i className="icon-fw  icon-action-redo"></i>
             {t('Move/Rename')}
           </DropdownItem>
         ) }
 
-        { AdditionalMenuItems && <AdditionalMenuItems pageInfo={pageInfo} /> }
+        {/* Revert */}
+        { !forceHideMenuItems?.includes(MenuItemType.REVERT) && isEnableActions && pageInfo.isRevertible && (
+          <DropdownItem onClick={revertItemClickedHandler}>
+            <i className="icon-fw  icon-action-undo"></i>
+            {t('modal_putback.label.Put Back Page')}
+          </DropdownItem>
+        ) }
+
+        { AdditionalMenuItems && (
+          <>
+            { showDeviderBeforeAdditionalMenuItems && <DropdownItem divider /> }
+            <AdditionalMenuItems pageInfo={pageInfo} />
+          </>
+        ) }
 
         {/* divider */}
         {/* Delete */}
-        { isEnableActions && pageInfo.isMovable && !pageInfo.isEmpty && (
+        { !forceHideMenuItems?.includes(MenuItemType.DELETE) && isEnableActions && pageInfo.isMovable && (
           <>
-            <DropdownItem divider />
+            { showDeviderBeforeDelete && <DropdownItem divider /> }
             <DropdownItem
               className={`pt-2 ${pageInfo.isDeletable ? 'text-danger' : ''}`}
               disabled={!pageInfo.isDeletable}
@@ -167,7 +204,7 @@ export const PageItemControlSubstance = (props: PageItemControlSubstanceProps):
   const {
     pageId, pageInfo: presetPageInfo, fetchOnInit,
     children,
-    onClickBookmarkMenuItem, onClickDuplicateMenuItem, onClickRenameMenuItem,
+    onClickBookmarkMenuItem, onClickDuplicateMenuItem, onClickRenameMenuItem, onClickDeleteMenuItem,
   } = props;
 
   const [isOpen, setIsOpen] = useState(false);
@@ -175,7 +212,8 @@ export const PageItemControlSubstance = (props: PageItemControlSubstanceProps):
   const shouldFetch = fetchOnInit === true || (!isIPageInfoForOperation(presetPageInfo) && isOpen);
   const shouldMutate = fetchOnInit === true || !isIPageInfoForOperation(presetPageInfo);
 
-  const { data: fetchedPageInfo, mutate: mutatePageInfo } = useSWRxPageInfo(shouldFetch ? pageId : null);
+  const { data: fetchedPageInfo } = useSWRxPageInfo(shouldFetch ? pageId : null);
+  const { mutate: mutatePageInfo } = useSWRxPageInfo(shouldMutate ? pageId : null);
 
   // mutate after handle event
   const bookmarkMenuItemClickHandler = useCallback(async(_pageId: string, _newValue: boolean) => {
@@ -204,9 +242,15 @@ export const PageItemControlSubstance = (props: PageItemControlSubstanceProps):
     await onClickRenameMenuItem(pageId);
   }, [onClickRenameMenuItem, pageId]);
 
-  return (
-    <Dropdown isOpen={isOpen} toggle={() => setIsOpen(!isOpen)}>
+  const deleteMenuItemClickHandler = useCallback(async() => {
+    if (onClickDeleteMenuItem == null) {
+      return;
+    }
+    await onClickDeleteMenuItem(pageId, fetchedPageInfo ?? presetPageInfo);
+  }, [onClickDeleteMenuItem, pageId, fetchedPageInfo, presetPageInfo]);
 
+  return (
+    <Dropdown isOpen={isOpen} toggle={() => setIsOpen(!isOpen)} data-testid="open-page-item-control-btn">
       { children ?? (
         <DropdownToggle color="transparent" className="border-0 rounded btn-page-item-control">
           <i className="icon-options text-muted"></i>
@@ -220,6 +264,7 @@ export const PageItemControlSubstance = (props: PageItemControlSubstanceProps):
         onClickBookmarkMenuItem={bookmarkMenuItemClickHandler}
         onClickDuplicateMenuItem={duplicateMenuItemClickHandler}
         onClickRenameMenuItem={renameMenuItemClickHandler}
+        onClickDeleteMenuItem={deleteMenuItemClickHandler}
       />
     </Dropdown>
   );

+ 136 - 58
packages/app/src/components/DescendantsPageList.tsx

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

+ 2 - 2
packages/app/src/components/DescendantsPageListModal.tsx

@@ -6,10 +6,10 @@ import {
   Modal, ModalHeader, ModalBody,
 } from 'reactstrap';
 
-import { useDescendantsPageListModal } from '~/stores/ui';
+import { useDescendantsPageListModal } from '~/stores/modal';
 import { useIsSharedUser } from '~/stores/context';
 
-import DescendantsPageList from './DescendantsPageList';
+import { DescendantsPageList } from './DescendantsPageList';
 import ExpandOrContractButton from './ExpandOrContractButton';
 import { CustomNavTab } from './CustomNavigation/CustomNav';
 import PageListIcon from './Icons/PageListIcon';

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

@@ -6,7 +6,7 @@ import loggerFactory from '~/utils/logger';
 
 import AppContainer from '~/client/services/AppContainer';
 
-import { useCreateModalStatus } from '~/stores/ui';
+import { usePageCreateModal } from '~/stores/modal';
 import { smoothScrollIntoView } from '~/client/util/smooth-scroll';
 
 import { withUnstatedContainers } from './UnstatedUtils';
@@ -19,7 +19,7 @@ const Fab = (props) => {
   const { appContainer } = props;
   const { currentUser } = appContainer;
 
-  const { open: openCreateModal } = useCreateModalStatus();
+  const { open: openCreateModal } = usePageCreateModal();
 
   const [animateClasses, setAnimateClasses] = useState('invisible');
   const [buttonClasses, setButtonClasses] = useState('');
@@ -53,7 +53,7 @@ const Fab = (props) => {
   function renderPageCreateButton() {
     return (
       <>
-        <div className={`rounded-circle position-absolute ${animateClasses}`} style={{ bottom: '2.3rem', right: '4rem' }}>
+        <div data-testid="grw-fab-create-page" className={`rounded-circle position-absolute ${animateClasses}`} style={{ bottom: '2.3rem', right: '4rem' }}>
           <button
             type="button"
             className={`btn btn-lg btn-create-page btn-primary rounded-circle p-0 waves-effect waves-light ${buttonClasses}`}
@@ -69,7 +69,7 @@ const Fab = (props) => {
   return (
     <div className="grw-fab d-none d-md-block d-edit-none">
       {currentUser != null && renderPageCreateButton()}
-      <div className={`rounded-circle position-absolute ${animateClasses}`} style={{ bottom: 0, right: 0 }}>
+      <div data-testid="grw-fab-return-to-top" className={`rounded-circle position-absolute ${animateClasses}`} style={{ bottom: 0, right: 0 }}>
         <button
           type="button"
           className={`btn btn-light btn-scroll-to-top rounded-circle p-0 ${buttonClasses}`}

+ 2 - 2
packages/app/src/components/ForbiddenPage.tsx

@@ -3,7 +3,7 @@ import { useTranslation } from 'react-i18next';
 
 import PageListIcon from './Icons/PageListIcon';
 import CustomNavAndContents from './CustomNavigation/CustomNavAndContents';
-import DescendantsPageList from './DescendantsPageList';
+import { DescendantsPageListForCurrentPath } from './DescendantsPageList';
 
 
 type Props = {
@@ -17,7 +17,7 @@ const ForbiddenPage = React.memo((props: Props): JSX.Element => {
     return {
       pagelist: {
         Icon: PageListIcon,
-        Content: DescendantsPageList,
+        Content: DescendantsPageListForCurrentPath,
         i18n: t('page_list'),
         index: 0,
       },

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

@@ -1,11 +1,11 @@
 import React, { useEffect } from 'react';
 import PropTypes from 'prop-types';
 
-import { useCreateModalStatus } from '~/stores/ui';
+import { usePageCreateModal } from '~/stores/modal';
 
 const CreatePage = React.memo((props) => {
 
-  const { open: openCreateModal } = useCreateModalStatus();
+  const { open: openCreateModal } = usePageCreateModal();
 
   // setup effect
   useEffect(() => {

+ 7 - 13
packages/app/src/components/IdenticalPathPage.tsx

@@ -3,9 +3,9 @@ import { useTranslation } from 'react-i18next';
 
 import { DevidedPagePath } from '@growi/core';
 
-import { IPageHasId, IPageWithMeta } from '~/interfaces/page';
+import { IPageHasId } from '~/interfaces/page';
 import { useCurrentPagePath, useIsSharedUser } from '~/stores/context';
-import { useDescendantsPageListModal } from '~/stores/ui';
+import { useDescendantsPageListModal } from '~/stores/modal';
 import { useSWRxPageInfoForList } from '~/stores/page';
 
 import PageListIcon from './Icons/PageListIcon';
@@ -66,10 +66,12 @@ const IdenticalPathPage:FC<IdenticalPathPageProps> = (props: IdenticalPathPagePr
   const { data: currentPath } = useCurrentPagePath();
   const { data: isSharedUser } = useIsSharedUser();
 
-  const { data: idToPageInfoMap } = useSWRxPageInfoForList(pageIds);
+  const { injectTo } = useSWRxPageInfoForList(pageIds, true, true);
 
   const { open: openDescendantPageListModal } = useDescendantsPageListModal();
 
+  const injectedPages = injectTo(pages);
+
   return (
     <div className="d-flex flex-column flex-lg-row-reverse">
 
@@ -95,24 +97,16 @@ const IdenticalPathPage:FC<IdenticalPathPageProps> = (props: IdenticalPathPagePr
 
         <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,
-              };
+            {injectedPages.map((pageWithMeta) => {
+              const pageId = pageWithMeta.data._id;
 
               return (
                 <PageListItemL
                   key={pageId}
                   page={pageWithMeta}
                   isSelected={false}
-                  isChecked={false}
                   isEnableActions
                   showPageUpdatedTime
-                // Todo: add onClickDeleteButton when delete feature implemented
                 />
               );
             })}

+ 99 - 0
packages/app/src/components/LegacyPrivatePagesMigrationModal.tsx

@@ -0,0 +1,99 @@
+import React, { useState } from 'react';
+import {
+  Modal, ModalHeader, ModalBody, ModalFooter,
+} from 'reactstrap';
+import { useTranslation } from 'react-i18next';
+
+import { apiv3Post } from '~/client/util/apiv3-client';
+import { useLegacyPrivatePagesMigrationModal } from '~/stores/modal';
+
+import ApiErrorMessageList from './PageManagement/ApiErrorMessageList';
+
+
+type Props = {
+
+}
+
+export const LegacyPrivatePagesMigrationModal = (props: Props): JSX.Element => {
+  const { t } = useTranslation();
+
+  const { data: status, close } = useLegacyPrivatePagesMigrationModal();
+
+  const isOpened = status?.isOpened ?? false;
+
+  const [isRecursively, setIsRecursively] = useState(true);
+
+  // eslint-disable-next-line @typescript-eslint/no-unused-vars
+  const [errs, setErrs] = useState<Error[] | null>(null);
+
+  async function submit() {
+    if (status == null || status.pages == null || status.pages.length === 0) {
+      return;
+    }
+
+    const { pages, onSubmited } = status;
+    const pageIds = pages.map(page => page.pageId);
+    try {
+      await apiv3Post<void>('/pages/legacy-pages-migration', {
+        pageIds,
+        isRecursively: isRecursively ? true : undefined,
+      });
+
+      if (onSubmited != null) {
+        onSubmited(pages, isRecursively);
+      }
+    }
+    catch (err) {
+      setErrs([err]);
+    }
+  }
+
+  function renderForm() {
+    return (
+      <div className="custom-control custom-checkbox custom-checkbox-warning">
+        <input
+          className="custom-control-input"
+          id="convertRecursively"
+          type="checkbox"
+          onChange={e => setIsRecursively(e.target.checked)}
+        />
+        <label className="custom-control-label" htmlFor="convertRecursively">
+          { t('private_legacy_pages.modal.convert_recursively_label') }
+          <p className="form-text text-muted mt-0"> { t('private_legacy_pages.modal.convert_recursively_desc') }</p>
+        </label>
+      </div>
+    );
+  }
+
+  const renderPageIds = () => {
+    if (status != null && status.pages != null) {
+      return status.pages.map(page => <div key={page.pageId}><code>{ page.path }</code></div>);
+    }
+    return <></>;
+  };
+
+  return (
+    <Modal size="lg" isOpen={isOpened} toggle={close} className="grw-create-page">
+      <ModalHeader tag="h4" toggle={close} className="bg-primary text-light">
+        { t('private_legacy_pages.modal.title') }
+      </ModalHeader>
+      <ModalBody>
+        <div className="form-group grw-scrollable-modal-body pb-1">
+          <label>{ t('private_legacy_pages.modal.converting_pages') }:</label><br />
+          {/* Todo: change the way to show path on modal when too many pages are selected */}
+          {/* https://redmine.weseek.co.jp/issues/82787 */}
+          {renderPageIds()}
+        </div>
+        {renderForm()}
+      </ModalBody>
+      <ModalFooter>
+        <ApiErrorMessageList errs={errs} />
+        <button type="button" className="btn btn-primary" onClick={submit}>
+          <i className="icon-fw icon-refresh" aria-hidden="true"></i>
+          { t('private_legacy_pages.modal.button_label') }
+        </button>
+      </ModalFooter>
+    </Modal>
+
+  );
+};

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

@@ -297,7 +297,7 @@ class LoginForm extends React.Component {
               <div className="front">
                 {isLocalOrLdapStrategiesEnabled && this.renderLocalOrLdapLoginForm()}
                 {isSomeExternalAuthEnabled && this.renderExternalAuthLoginForm()}
-                {isPasswordResetEnabled && (
+                {isLocalOrLdapStrategiesEnabled && 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')}

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

@@ -36,7 +36,7 @@ const GlobalSearch: FC<Props> = (props: Props) => {
   const gotoPage = useCallback((data: IPageWithMeta<IPageSearchMeta>[]) => {
     assert(data.length > 0);
 
-    const page = data[0].pageData; // should be single page selected
+    const page = data[0].data; // should be single page selected
 
     // navigate to page
     if (page != null) {

+ 58 - 20
packages/app/src/components/Navbar/GrowiContextualSubNavigation.tsx

@@ -1,17 +1,25 @@
 import React, { useState, useCallback } from 'react';
+import { useTranslation } from 'react-i18next';
 import PropTypes from 'prop-types';
 
-import { useTranslation } from 'react-i18next';
 
 import { DropdownItem } from 'reactstrap';
 
+import { OnDuplicatedFunction, OnRenamedFunction, OnDeletedFunction } from '~/interfaces/ui';
+import { IPageHasId, IPageWithMeta } from '~/interfaces/page';
+
 import { withUnstatedContainers } from '../UnstatedUtils';
 import EditorContainer from '~/client/services/EditorContainer';
 import {
   EditorMode, useDrawerMode, useEditorMode, useIsDeviceSmallerThanMd, useIsAbleToShowPageManagement, useIsAbleToShowTagLabel,
-  useIsAbleToShowPageEditorModeManager, useIsAbleToShowPageAuthors, usePageAccessoriesModal, PageAccessoriesModalContents,
-  usePageDuplicateModalStatus, usePageRenameModalStatus, usePageDeleteModal,
+  useIsAbleToShowPageEditorModeManager, useIsAbleToShowPageAuthors,
 } from '~/stores/ui';
+import {
+  usePageAccessoriesModal, PageAccessoriesModalContents, IPageForPageDuplicateModal,
+  usePageDuplicateModal, usePageRenameModal, IPageForPageRenameModal, usePageDeleteModal, usePagePresentationModal,
+} from '~/stores/modal';
+
+
 import {
   useCurrentCreatedAt, useCurrentUpdatedAt, useCurrentPageId, useRevisionId, useCurrentPagePath,
   useCreator, useRevisionAuthor, useCurrentUser, useIsGuestUser, useIsSharedUser, useShareLinkId,
@@ -21,7 +29,6 @@ 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';
@@ -57,12 +64,18 @@ const AdditionalMenuItems = (props: AdditionalMenuItemsProps): JSX.Element => {
   const { data: isGuestUser } = useIsGuestUser();
   const { data: isSharedUser } = useIsSharedUser();
 
-  const { open } = usePageAccessoriesModal();
+  const { open: openPresentationModal } = usePagePresentationModal();
+  const { open: openAccessoriesModal } = usePageAccessoriesModal();
+
+  const hrefForPresentationModal = `${pageId}/?presentation=1`;
 
   return (
     <>
       {/* Presentation */}
-      <DropdownItem onClick={() => { /* TODO: implement in https://redmine.weseek.co.jp/issues/87672 */ }}>
+      <DropdownItem
+        onClick={() => openPresentationModal(hrefForPresentationModal)}
+        data-testid="open-presentation-modal-btn"
+      >
         <i className="icon-fw"><PresentationIcon /></i>
         { t('Presentation Mode') }
       </DropdownItem>
@@ -80,7 +93,7 @@ const AdditionalMenuItems = (props: AdditionalMenuItemsProps): JSX.Element => {
         refs: PageAccessoriesModalControl
       */}
       <DropdownItem
-        onClick={() => open(PageAccessoriesModalContents.PageHistory)}
+        onClick={() => openAccessoriesModal(PageAccessoriesModalContents.PageHistory)}
         disabled={isGuestUser || isSharedUser}
       >
         <span className="mr-1"><HistoryIcon /></span>
@@ -88,14 +101,14 @@ const AdditionalMenuItems = (props: AdditionalMenuItemsProps): JSX.Element => {
       </DropdownItem>
 
       <DropdownItem
-        onClick={() => open(PageAccessoriesModalContents.Attachment)}
+        onClick={() => openAccessoriesModal(PageAccessoriesModalContents.Attachment)}
       >
         <span className="mr-1"><AttachmentIcon /></span>
         {t('attachment_data')}
       </DropdownItem>
 
       <DropdownItem
-        onClick={() => open(PageAccessoriesModalContents.ShareLink)}
+        onClick={() => openAccessoriesModal(PageAccessoriesModalContents.ShareLink)}
         disabled={isGuestUser || isSharedUser || isLinkSharingDisabled}
       >
         <span className="mr-1"><ShareLinkIcon /></span>
@@ -136,8 +149,8 @@ const GrowiContextualSubNavigation = (props) => {
 
   const { mutate: mutateSWRTagsInfo, data: tagsInfoData } = useSWRTagsInfo(pageId);
 
-  const { open: openDuplicateModal } = usePageDuplicateModalStatus();
-  const { open: openRenameModal } = usePageRenameModalStatus();
+  const { open: openDuplicateModal } = usePageDuplicateModal();
+  const { open: openRenameModal } = usePageRenameModal();
   const { open: openDeleteModal } = usePageDeleteModal();
 
   const [isPageTemplateModalShown, setIsPageTempleteModalShown] = useState(false);
@@ -170,17 +183,39 @@ const GrowiContextualSubNavigation = (props) => {
   // eslint-disable-next-line react-hooks/exhaustive-deps
   }, [pageId]);
 
-  const duplicateItemClickedHandler = useCallback(async(pageId, path) => {
-    openDuplicateModal(pageId, path);
+  const duplicateItemClickedHandler = useCallback(async(page: IPageForPageDuplicateModal) => {
+    const duplicatedHandler: OnDuplicatedFunction = (fromPath, toPath) => {
+      window.location.href = toPath;
+    };
+    openDuplicateModal(page, { onDuplicated: duplicatedHandler });
   }, [openDuplicateModal]);
 
-  const renameItemClickedHandler = useCallback(async(pageId, revisionId, path) => {
-    openRenameModal(pageId, revisionId, path);
+  const renameItemClickedHandler = useCallback(async(page: IPageForPageRenameModal) => {
+    const renamedHandler: OnRenamedFunction = () => {
+      window.location.reload();
+    };
+    openRenameModal(page, { onRenamed: renamedHandler });
   }, [openRenameModal]);
 
-  const deleteItemClickedHandler = useCallback(async(pageToDelete) => {
-    openDeleteModal([pageToDelete]);
-  }, [openDeleteModal]);
+  const onDeletedHandler: OnDeletedFunction = useCallback((pathOrPathsToDelete, isRecursively, isCompletely) => {
+    if (typeof pathOrPathsToDelete !== 'string') {
+      return;
+    }
+
+    const path = pathOrPathsToDelete;
+
+    if (isCompletely) {
+      // redirect to NotFound Page
+      window.location.href = path;
+    }
+    else {
+      window.location.reload();
+    }
+  }, []);
+
+  const deleteItemClickedHandler = useCallback((pageWithMeta: IPageWithMeta) => {
+    openDeleteModal([pageWithMeta], { onDeleted: onDeletedHandler });
+  }, [onDeletedHandler, openDeleteModal]);
 
   const templateMenuItemClickHandler = useCallback(() => {
     setIsPageTempleteModalShown(true);
@@ -192,9 +227,11 @@ const GrowiContextualSubNavigation = (props) => {
       mutateEditorMode(viewType);
     }
 
+    const className = `d-flex flex-column align-items-end justify-content-center ${isViewMode ? ' h-50' : ''}`;
+
     return (
       <>
-        <div className="h-50 d-flex flex-column align-items-end justify-content-center">
+        <div className={className}>
           { pageId != null && isViewMode && (
             <SubNavButtons
               isCompactMode={isCompactMode}
@@ -219,7 +256,7 @@ const GrowiContextualSubNavigation = (props) => {
             />
           ) }
         </div>
-        <div className="h-50 d-flex flex-column align-items-end justify-content-center">
+        <div className={className}>
           {isAbleToShowPageEditorModeManager && (
             <PageEditorModeManager
               onPageEditorModeButtonClicked={onPageEditorModeButtonClicked}
@@ -274,6 +311,7 @@ const GrowiContextualSubNavigation = (props) => {
       tags={tagsInfoData?.tags || []}
       tagsUpdatedHandler={tagsUpdatedHandler}
       controls={ControlComponents}
+      additionalClasses={['container-fluid']}
     />
   );
 };

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

@@ -7,8 +7,9 @@ import { UncontrolledTooltip } from 'reactstrap';
 
 import AppContainer from '~/client/services/AppContainer';
 import { IUser } from '~/interfaces/user';
-import { useIsDeviceSmallerThanMd, useCreateModalStatus } from '~/stores/ui';
-import { useIsSearchPage } from '~/stores/context';
+import { useIsDeviceSmallerThanMd } from '~/stores/ui';
+import { usePageCreateModal } from '~/stores/modal';
+import { useIsSearchPage, useCurrentPagePath } from '~/stores/context';
 
 import { withUnstatedContainers } from '../UnstatedUtils';
 import GrowiLogo from '../Icons/GrowiLogo';
@@ -23,7 +24,8 @@ type NavbarRightProps = {
 }
 const NavbarRight: FC<NavbarRightProps> = memo((props: NavbarRightProps) => {
   const { t } = useTranslation();
-  const { open: openCreateModal } = useCreateModalStatus();
+  const { data: currentPagePath } = useCurrentPagePath();
+  const { open: openCreateModal } = usePageCreateModal();
 
   const { currentUser } = props;
 
@@ -42,7 +44,8 @@ const NavbarRight: FC<NavbarRightProps> = memo((props: NavbarRightProps) => {
         <button
           className="px-md-3 nav-link btn-create-page border-0 bg-transparent"
           type="button"
-          onClick={() => openCreateModal()}
+          data-testid="newPageBtn"
+          onClick={() => openCreateModal(currentPagePath || '')}
         >
           <i className="icon-pencil mr-2"></i>
           <span className="d-none d-lg-block">{ t('New') }</span>

+ 3 - 2
packages/app/src/components/Navbar/GrowiNavbarBottom.jsx

@@ -2,7 +2,8 @@ import React from 'react';
 import PropTypes from 'prop-types';
 
 
-import { useCreateModalStatus, useIsDeviceSmallerThanMd, useDrawerOpened } from '~/stores/ui';
+import { useIsDeviceSmallerThanMd, useDrawerOpened } from '~/stores/ui';
+import { usePageCreateModal } from '~/stores/modal';
 import { useCurrentPagePath, useIsSearchPage } from '~/stores/context';
 
 import GlobalSearch from './GlobalSearch';
@@ -11,7 +12,7 @@ const GrowiNavbarBottom = (props) => {
 
   const { data: isDrawerOpened, mutate: mutateDrawerOpened } = useDrawerOpened();
   const { data: isDeviceSmallerThanMd } = useIsDeviceSmallerThanMd();
-  const { open: openCreateModal } = useCreateModalStatus();
+  const { open: openCreateModal } = usePageCreateModal();
   const { data: currentPagePath } = useCurrentPagePath();
   const { data: isSearchPage } = useIsSearchPage();
 

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

@@ -30,6 +30,7 @@ type Props = {
   tagsUpdatedHandler?: (newTags: string[]) => Promise<void>,
 
   controls?: React.FunctionComponent,
+  additionalClasses?: string[],
 }
 
 export const GrowiSubNavigation = (props: Props): JSX.Element => {
@@ -41,6 +42,7 @@ export const GrowiSubNavigation = (props: Props): JSX.Element => {
     isGuestUser, isDrawerMode, isCompactMode,
     tags, tagsUpdatedHandler,
     controls: Controls,
+    additionalClasses = [],
   } = props;
 
   const {
@@ -56,7 +58,11 @@ export const GrowiSubNavigation = (props: Props): JSX.Element => {
   }
 
   return (
-    <div className={`grw-subnav container-fluid d-flex align-items-center justify-content-between ${isCompactMode ? 'grw-subnav-compact d-print-none' : ''}`}>
+    <div className={
+      'grw-subnav d-flex align-items-center justify-content-between'
+      + ` ${additionalClasses.join(' ')}`
+      + ` ${isCompactMode ? 'grw-subnav-compact d-print-none' : ''}`}
+    >
 
       {/* Left side */}
       <div className="d-flex grw-subnav-left-side">

+ 6 - 1
packages/app/src/components/Navbar/GrowiSubNavigationSwitcher.jsx

@@ -110,7 +110,12 @@ const GrowiSubNavigationSwitcher = (props) => {
 
   return (
     <div className={`grw-subnav-switcher ${isVisible ? '' : 'grw-subnav-switcher-hidden'}`}>
-      <div id="grw-subnav-fixed-container" className="grw-subnav-fixed-container position-fixed" ref={fixedContainerRef} style={{ width }}>
+      <div
+        id="grw-subnav-fixed-container"
+        className="grw-subnav-fixed-container position-fixed grw-subnav-append-shadow-container"
+        ref={fixedContainerRef}
+        style={{ width }}
+      >
         <GrowiContextualSubNavigation isCompactMode isLinkSharingDisabled />
       </div>
     </div>

+ 37 - 18
packages/app/src/components/Navbar/SubNavButtons.tsx

@@ -1,32 +1,37 @@
 import React, { useCallback } from 'react';
 
-import { IPageInfoAll, isIPageInfoForEntity, isIPageInfoForOperation } from '~/interfaces/page';
+import {
+  IPageInfoAll, IPageToDeleteWithMeta, isIPageInfoForEntity, isIPageInfoForOperation,
+} from '~/interfaces/page';
 
 import { useSWRxPageInfo } from '../../stores/page';
 import { useSWRBookmarkInfo } from '../../stores/bookmark';
 import { useSWRxUsersList } from '../../stores/user';
 import { useIsGuestUser } from '~/stores/context';
-import { IPageForPageDeleteModal } from '~/stores/ui';
+import { IPageForPageRenameModal, IPageForPageDuplicateModal } from '~/stores/modal';
 
 import SubscribeButton from '../SubscribeButton';
 import LikeButtons from '../LikeButtons';
 import BookmarkButtons from '../BookmarkButtons';
 import SeenUserInfo from '../User/SeenUserInfo';
 import { toggleBookmark, toggleLike, toggleSubscribe } from '~/client/services/page-operation';
-import { AdditionalMenuItemsRendererProps, PageItemControl } from '../Common/Dropdown/PageItemControl';
+import {
+  AdditionalMenuItemsRendererProps, ForceHideMenuItems, MenuItemType, PageItemControl,
+} from '../Common/Dropdown/PageItemControl';
 
 
 type CommonProps = {
   isCompactMode?: boolean,
   disableSeenUserInfoPopover?: boolean,
   showPageControlDropdown?: boolean,
+  forceHideMenuItems?: ForceHideMenuItems,
   additionalMenuItemRenderer?: React.FunctionComponent<AdditionalMenuItemsRendererProps>,
-  onClickDuplicateMenuItem?: (pageId: string, path: string) => void,
-  onClickRenameMenuItem?: (pageId: string, revisionId: string, path: string) => void,
-  onClickDeleteMenuItem?: (pageToDelete: IPageForPageDeleteModal | null) => void,
+  onClickDuplicateMenuItem?: (pageToDuplicate: IPageForPageDuplicateModal) => void,
+  onClickRenameMenuItem?: (pageToRename: IPageForPageRenameModal) => void,
+  onClickDeleteMenuItem?: (pageToDelete: IPageToDeleteWithMeta) => void,
 }
 
-type SubNavButtonsSubstanceProps= CommonProps & {
+type SubNavButtonsSubstanceProps = CommonProps & {
   pageId: string,
   shareLinkId?: string | null,
   revisionId: string,
@@ -38,7 +43,7 @@ const SubNavButtonsSubstance = (props: SubNavButtonsSubstanceProps): JSX.Element
   const {
     pageInfo,
     pageId, revisionId, path, shareLinkId,
-    isCompactMode, disableSeenUserInfoPopover, showPageControlDropdown, additionalMenuItemRenderer,
+    isCompactMode, disableSeenUserInfoPopover, showPageControlDropdown, forceHideMenuItems, additionalMenuItemRenderer,
     onClickDuplicateMenuItem, onClickRenameMenuItem, onClickDeleteMenuItem,
   } = props;
 
@@ -97,16 +102,17 @@ const SubNavButtonsSubstance = (props: SubNavButtonsSubstanceProps): JSX.Element
     if (onClickDuplicateMenuItem == null || path == null) {
       return;
     }
+    const page: IPageForPageDuplicateModal = { pageId, path };
 
-    onClickDuplicateMenuItem(pageId, path);
+    onClickDuplicateMenuItem(page);
   }, [onClickDuplicateMenuItem, pageId, path]);
 
   const renameMenuItemClickHandler = useCallback(async(_pageId: string): Promise<void> => {
     if (onClickRenameMenuItem == null || path == null) {
       return;
     }
-
-    onClickRenameMenuItem(pageId, revisionId, path);
+    const page: IPageForPageRenameModal = { pageId, revisionId, path };
+    onClickRenameMenuItem(page);
   }, [onClickRenameMenuItem, pageId, path, revisionId]);
 
   const deleteMenuItemClickHandler = useCallback(async(_pageId: string): Promise<void> => {
@@ -114,14 +120,17 @@ const SubNavButtonsSubstance = (props: SubNavButtonsSubstanceProps): JSX.Element
       return;
     }
 
-    const pageToDelete: IPageForPageDeleteModal = {
-      pageId,
-      revisionId,
-      path,
+    const pageToDelete: IPageToDeleteWithMeta = {
+      data: {
+        _id: pageId,
+        revision: revisionId,
+        path,
+      },
+      meta: pageInfo,
     };
 
     onClickDeleteMenuItem(pageToDelete);
-  }, [onClickDeleteMenuItem, pageId, path, revisionId]);
+  }, [onClickDeleteMenuItem, pageId, pageInfo, path, revisionId]);
 
   if (!isIPageInfoForOperation(pageInfo)) {
     return <></>;
@@ -129,9 +138,12 @@ const SubNavButtonsSubstance = (props: SubNavButtonsSubstanceProps): JSX.Element
 
 
   const {
-    sumOfLikers, isLiked, bookmarkCount, isBookmarked,
+    sumOfLikers, sumOfSeenUsers, isLiked, bookmarkCount, isBookmarked,
   } = pageInfo;
 
+  const forceHideMenuItemsWithBookmark = forceHideMenuItems ?? [];
+  forceHideMenuItemsWithBookmark.push(MenuItemType.BOOKMARK);
+
   return (
     <div className="d-flex" style={{ gap: '2px' }}>
       <span>
@@ -154,12 +166,19 @@ const SubNavButtonsSubstance = (props: SubNavButtonsSubstanceProps): JSX.Element
         bookmarkedUsers={bookmarkInfo?.bookmarkedUsers}
         onBookMarkClicked={bookmarkClickHandler}
       />
-      <SeenUserInfo seenUsers={seenUsers} disabled={disableSeenUserInfoPopover} />
+      { !isCompactMode && (
+        <SeenUserInfo
+          seenUsers={seenUsers}
+          sumOfSeenUsers={sumOfSeenUsers}
+          disabled={disableSeenUserInfoPopover}
+        />
+      ) }
       { showPageControlDropdown && (
         <PageItemControl
           pageId={pageId}
           pageInfo={pageInfo}
           isEnableActions={!isGuestUser}
+          forceHideMenuItems={forceHideMenuItemsWithBookmark}
           additionalMenuItemRenderer={additionalMenuItemRenderer}
           onClickRenameMenuItem={renameMenuItemClickHandler}
           onClickDuplicateMenuItem={duplicateMenuItemClickHandler}

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

@@ -1,30 +1,21 @@
-import React, { useCallback, useMemo } from 'react';
+import React, { useMemo } from 'react';
 import { useTranslation } from 'react-i18next';
 
 import PageListIcon from './Icons/PageListIcon';
 import TimeLineIcon from './Icons/TimeLineIcon';
 import CustomNavAndContents from './CustomNavigation/CustomNavAndContents';
-import DescendantsPageList from './DescendantsPageList';
+import { DescendantsPageListForCurrentPath } from './DescendantsPageList';
 import PageTimeline from './PageTimeline';
-import { useCurrentPagePath } from '~/stores/context';
 
 
 const NotFoundPage = (): JSX.Element => {
   const { t } = useTranslation();
 
-  const { data: currentPagePath } = useCurrentPagePath();
-
-  const DescendantsPageListForThisPage = useCallback((): JSX.Element => {
-    return currentPagePath != null
-      ? <DescendantsPageList path={currentPagePath} />
-      : <></>;
-  }, [currentPagePath]);
-
   const navTabMapping = useMemo(() => {
     return {
       pagelist: {
         Icon: PageListIcon,
-        Content: DescendantsPageListForThisPage,
+        Content: DescendantsPageListForCurrentPath,
         i18n: t('page_list'),
         index: 0,
       },
@@ -35,7 +26,7 @@ const NotFoundPage = (): JSX.Element => {
         index: 1,
       },
     };
-  }, [DescendantsPageListForThisPage, t]);
+  }, [t]);
 
 
   return (

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

@@ -21,7 +21,7 @@ import { getOptionsToSave } from '~/client/util/editor';
 
 // TODO: remove this when omitting unstated is completed
 import {
-  useEditorMode, useSelectedGrant, useSelectedGrantGroupId, useSelectedGrantGroupName,
+  EditorMode, useEditorMode, useSelectedGrant, useSelectedGrantGroupId, useSelectedGrantGroupName,
 } from '~/stores/ui';
 import { useIsSlackEnabled } from '~/stores/editor';
 import { useSlackChannels } from '~/stores/context';
@@ -143,14 +143,15 @@ class Page extends React.Component {
   }
 
   render() {
-    const { appContainer, pageContainer } = this.props;
+    const { appContainer, pageContainer, editorMode } = this.props;
     const { isMobile } = appContainer;
     const isLoggedIn = appContainer.currentUser != null;
-    const { markdown } = pageContainer.state;
+    const { markdown, revisionId } = pageContainer.state;
+    const isRenderable = !(editorMode === EditorMode.View && revisionId == null);
 
     return (
       <div className={`mb-5 ${isMobile ? 'page-mobile' : ''}`}>
-        <RevisionRenderer growiRenderer={this.growiRenderer} markdown={markdown} />
+        <RevisionRenderer growiRenderer={this.growiRenderer} markdown={markdown} isRenderable={isRenderable} />
 
         { isLoggedIn && (
           <>

+ 3 - 2
packages/app/src/components/Page/DisplaySwitcher.tsx

@@ -4,7 +4,8 @@ import { TabContent, TabPane } from 'reactstrap';
 
 import { pagePathUtils } from '@growi/core';
 
-import { EditorMode, useEditorMode, useDescendantsPageListModal } from '~/stores/ui';
+import { EditorMode, useEditorMode } from '~/stores/ui';
+import { useDescendantsPageListModal } from '~/stores/modal';
 import {
   useCurrentPagePath, useIsSharedUser, useIsEditable, useCurrentPageId, useIsUserPage, usePageUser,
 } from '~/stores/context';
@@ -111,7 +112,7 @@ const DisplaySwitcher = (): JSX.Element => {
         </TabPane>
         { isEditable && (
           <TabPane tabId={EditorMode.Editor}>
-            <div id="page-editor">
+            <div data-testid="page-editor" id="page-editor">
               <Editor />
             </div>
           </TabPane>

+ 0 - 24
packages/app/src/components/Page/DuplicatedAlert.jsx

@@ -1,24 +0,0 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-import { withTranslation } from 'react-i18next';
-
-
-const DuplicatedAlert = (props) => {
-  const { t } = props;
-  const urlParams = new URLSearchParams(window.location.search);
-  const fromPath = urlParams.get('duplicated');
-
-  return (
-    <div className="alert alert-success py-3 px-4">
-      <strong>
-        { t('Duplicated') }: {t('page_page.notice.duplicated')} <code>{fromPath}</code> {t('page_page.notice.duplicated_period')}
-      </strong>
-    </div>
-  );
-};
-
-DuplicatedAlert.propTypes = {
-  t: PropTypes.func.isRequired, // i18next
-};
-
-export default withTranslation()(DuplicatedAlert);

+ 17 - 12
packages/app/src/components/Page/NotFoundAlert.tsx

@@ -1,6 +1,7 @@
 import React, { useCallback } from 'react';
 import { useTranslation } from 'react-i18next';
 import { UncontrolledTooltip } from 'reactstrap';
+import { useIsNotFoundPermalink } from '~/stores/context';
 
 import { EditorMode, useEditorMode } from '~/stores/ui';
 
@@ -14,6 +15,7 @@ const NotFoundAlert = (props: Props): JSX.Element => {
   const { isGuestUserMode } = props;
 
   const { data: editorMode, mutate: mutateEditorMode } = useEditorMode();
+  const { data: isNotFoundPermalink } = useIsNotFoundPermalink(); // TODO: Remove this when renaming on editor is implemented
 
   const isEditorMode = editorMode !== EditorMode.View;
 
@@ -41,19 +43,22 @@ const NotFoundAlert = (props: Props): JSX.Element => {
           <i className="icon-info pr-2 font-weight-bold" aria-hidden="true"></i>
           {t('not_found_page.page_not_exist_alert')}
         </h2>
-        <div id="create-page-btn-wrapper-for-tooltip" className="d-inline-block">
-          <button
-            type="button"
-            className={`pl-3 pr-3 btn bg-info text-white ${isGuestUserMode ? 'disabled' : ''}`}
-            onClick={clickHandler}
-          >
-            <i className="icon-note icon-fw" />
-            {t('not_found_page.Create Page')}
-          </button>
-        </div>
+        {
+          !isNotFoundPermalink && (
+            <div id="create-page-btn-wrapper-for-tooltip" className="d-inline-block">
+              <button
+                type="button"
+                className={`pl-3 pr-3 btn bg-info text-white ${isGuestUserMode ? 'disabled' : ''}`}
+                onClick={clickHandler}
+              >
+                <i className="icon-note icon-fw" />
+                {t('not_found_page.Create Page')}
+              </button>
+            </div>
+          )
+        }
 
-
-        {isGuestUserMode && (
+        {!isNotFoundPermalink && isGuestUserMode && (
           <UncontrolledTooltip placement="bottom" target="create-page-btn-wrapper-for-tooltip" fade={false}>
             {t('Not available for guest')}
           </UncontrolledTooltip>

+ 0 - 264
packages/app/src/components/Page/PageManagement.jsx

@@ -1,264 +0,0 @@
-import React, { useState } from 'react';
-import PropTypes from 'prop-types';
-import { UncontrolledTooltip } from 'reactstrap';
-import { withTranslation } from 'react-i18next';
-import urljoin from 'url-join';
-
-import { pagePathUtils } from '@growi/core';
-import { usePageDeleteModal } from '~/stores/ui';
-
-import { withUnstatedContainers } from '../UnstatedUtils';
-import AppContainer from '~/client/services/AppContainer';
-import PageRenameModal from '../PageRenameModal';
-import PageDuplicateModal from '../PageDuplicateModal';
-import CreateTemplateModal from '../CreateTemplateModal';
-import PagePresentationModal from '../PagePresentationModal';
-import PresentationIcon from '../Icons/PresentationIcon';
-
-const { isTopPage } = pagePathUtils;
-
-
-const LegacyPageManagemenet = (props) => {
-  const {
-    t, appContainer, isCompactMode, pageId, revisionId, path, isDeletable, isAbleToDeleteCompletely,
-  } = props;
-
-  const { open: openDeleteModal } = usePageDeleteModal();
-
-  const { currentUser } = appContainer;
-  const isTopPagePath = isTopPage(path);
-  const [isPageRenameModalShown, setIsPageRenameModalShown] = useState(false);
-  const [isPageDuplicateModalShown, setIsPageDuplicateModalShown] = useState(false);
-  const [isPageTemplateModalShown, setIsPageTempleteModalShown] = useState(false);
-  const [isPagePresentationModalShown, setIsPagePresentationModalShown] = useState(false);
-  const presentationHref = urljoin(window.location.origin, path, '?presentation=1');
-
-  function openPageRenameModalHandler() {
-    setIsPageRenameModalShown(true);
-  }
-
-  function closePageRenameModalHandler() {
-    setIsPageRenameModalShown(false);
-  }
-
-  function openPageDuplicateModalHandler() {
-    setIsPageDuplicateModalShown(true);
-  }
-  function closePageDuplicateModalHandler() {
-    setIsPageDuplicateModalShown(false);
-  }
-
-  function openPageTemplateModalHandler() {
-    setIsPageTempleteModalShown(true);
-  }
-
-  function closePageTemplateModalHandler() {
-    setIsPageTempleteModalShown(false);
-  }
-
-  function openPagePresentationModalHandler() {
-    setIsPagePresentationModalShown(true);
-  }
-
-  function closePagePresentationModalHandler() {
-    setIsPagePresentationModalShown(false);
-  }
-
-
-  // TODO GW-2746 bulk export pages
-  // async function getArchivePageData() {
-  //   try {
-  //     const res = await appContainer.apiv3Get('page/count-children-pages', { pageId });
-  //     setTotalPages(res.data.dummy);
-  //   }
-  //   catch (err) {
-  //     setErrorMessage(t('export_bulk.failed_to_count_pages'));
-  //   }
-  // }
-
-  async function exportPageHandler(format) {
-    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;
-  }
-
-  // TODO GW-2746 create api to bulk export pages
-  // function openArchiveModalHandler() {
-  //   setIsArchiveCreateModalShown(true);
-  //   getArchivePageData();
-  // }
-
-  // TODO GW-2746 create api to bulk export pages
-  // function closeArchiveCreateModalHandler() {
-  //   setIsArchiveCreateModalShown(false);
-  // }
-
-  function renderDropdownItemForTopPage() {
-    return (
-      <>
-        <button className="dropdown-item" type="button" onClick={openPageDuplicateModalHandler}>
-          <i className="icon-fw icon-docs"></i> { t('Duplicate') }
-        </button>
-        {/* TODO Presentation Mode is not function. So if it is really necessary, survey this cause and implement Presentation Mode in top page */}
-        {/* <button className="dropdown-item" type="button" onClick={openPagePresentationModalHandler}>
-          <i className="icon-fw"><PresentationIcon /></i><span className="d-none d-sm-inline"> { t('Presentation Mode') }</span>
-        </button> */}
-        <button type="button" className="dropdown-item" onClick={() => { exportPageHandler('md') }}>
-          <i className="icon-fw icon-cloud-download"></i>{t('export_bulk.export_page_markdown')}
-        </button>
-        <div className="dropdown-divider"></div>
-      </>
-    );
-  }
-
-  function renderDropdownItemForNotTopPage() {
-    return (
-      <>
-        <button className="dropdown-item" type="button" onClick={openPageRenameModalHandler}>
-          <i className="icon-fw icon-action-redo"></i> { t('Move/Rename') }
-        </button>
-        <button className="dropdown-item" type="button" onClick={openPageDuplicateModalHandler}>
-          <i className="icon-fw icon-docs"></i> { t('Duplicate') }
-        </button>
-        <button className="dropdown-item" type="button" onClick={openPagePresentationModalHandler}>
-          <i className="icon-fw"><PresentationIcon /></i> { t('Presentation Mode') }
-        </button>
-        <button className="dropdown-item" type="button" onClick={() => { exportPageHandler('md') }}>
-          <i className="icon-fw icon-cloud-download"></i>{t('export_bulk.export_page_markdown')}
-        </button>
-        {/* TODO GW-2746 create api to bulk export pages */}
-        {/* <button className="dropdown-item" type="button" onClick={openArchiveModalHandler}>
-          <i className="icon-fw"></i>{t('Create Archive Page')}
-        </button> */}
-        <div className="dropdown-divider"></div>
-      </>
-    );
-  }
-
-  function generatePageObjectToDelete() {
-    return { pageId, revisionId, path };
-  }
-  const pageToDelete = generatePageObjectToDelete();
-
-  function renderDropdownItemForDeletablePage() {
-    return (
-      <>
-        <div className="dropdown-divider"></div>
-        <button className="dropdown-item text-danger" type="button" onClick={() => openDeleteModal([pageToDelete])}>
-          <i className="icon-fw icon-fire"></i> { t('Delete') }
-        </button>
-      </>
-    );
-  }
-
-
-  function renderModals() {
-    if (currentUser == null) {
-      return null;
-    }
-
-    return (
-      <>
-        <PageRenameModal
-          isOpen={isPageRenameModalShown}
-          onClose={closePageRenameModalHandler}
-          pageId={pageId}
-          revisionId={revisionId}
-          path={path}
-        />
-        <PageDuplicateModal
-          isOpen={isPageDuplicateModalShown}
-          onClose={closePageDuplicateModalHandler}
-          pageId={pageId}
-          path={path}
-        />
-        <CreateTemplateModal
-          path={path}
-          isOpen={isPageTemplateModalShown}
-          onClose={closePageTemplateModalHandler}
-        />
-        <PagePresentationModal
-          isOpen={isPagePresentationModalShown}
-          onClose={closePagePresentationModalHandler}
-          href={presentationHref}
-        />
-      </>
-    );
-  }
-
-  function renderDotsIconForCurrentUser() {
-    return (
-      <>
-        <button
-          type="button"
-          className="btn-link nav-link dropdown-toggle dropdown-toggle-no-caret border-0 rounded btn-page-item-control"
-          data-toggle="dropdown"
-        >
-          <i className="text-muted icon-options"></i>
-        </button>
-      </>
-    );
-  }
-
-  function renderDotsIconForGuestUser() {
-    return (
-      <>
-        <button
-          type="button"
-          className="btn nav-link bg-transparent dropdown-toggle dropdown-toggle-no-caret disabled"
-          id="icon-options-guest-tltips"
-        >
-          <i className="text-muted icon-options"></i>
-        </button>
-        <UncontrolledTooltip placement="top" target="icon-options-guest-tltips" fade={false}>
-          {t('Not available for guest')}
-        </UncontrolledTooltip>
-      </>
-    );
-  }
-
-
-  return (
-    <>
-      {currentUser == null ? renderDotsIconForGuestUser() : renderDotsIconForCurrentUser()}
-      <div className="dropdown-menu dropdown-menu-right">
-        {isTopPagePath ? renderDropdownItemForTopPage() : renderDropdownItemForNotTopPage()}
-        <button className="dropdown-item" type="button" onClick={openPageTemplateModalHandler}>
-          <i className="icon-fw icon-magic-wand"></i> { t('template.option_label.create/edit') }
-        </button>
-        {(!isTopPagePath && isDeletable) && renderDropdownItemForDeletablePage()}
-      </div>
-      {renderModals()}
-    </>
-  );
-};
-
-/**
- * Wrapper component for using unstated
- */
-const LegacyPageManagemenetWrapper = withUnstatedContainers(LegacyPageManagemenet, [AppContainer]);
-
-
-LegacyPageManagemenet.propTypes = {
-  t: PropTypes.func.isRequired, // i18next
-  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
-
-
-  pageId: PropTypes.string.isRequired,
-  revisionId: PropTypes.string.isRequired,
-  path: PropTypes.string.isRequired,
-  isDeletable: PropTypes.bool.isRequired,
-  isAbleToDeleteCompletely: PropTypes.bool,
-
-  isCompactMode: PropTypes.bool,
-};
-
-LegacyPageManagemenet.defaultProps = {
-  isCompactMode: false,
-};
-
-const PageManagement = (props) => {
-  return <LegacyPageManagemenetWrapper {...props}></LegacyPageManagemenetWrapper>;
-};
-export default withTranslation()(PageManagement);

+ 0 - 22
packages/app/src/components/Page/RenamedAlert.jsx

@@ -1,22 +0,0 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-import { withTranslation } from 'react-i18next';
-
-
-const RenamedAlert = (props) => {
-  const { t } = props;
-  const urlParams = new URLSearchParams(window.location.search);
-  const fromPath = urlParams.get('renamedFrom');
-
-  return (
-    <>
-      <strong>{ t('Moved') }:</strong> {t('page_page.notice.moved')} <code>{fromPath}</code> {t('page_page.notice.moved_period')}
-    </>
-  );
-};
-
-RenamedAlert.propTypes = {
-  t: PropTypes.func.isRequired, // i18next
-};
-
-export default withTranslation()(RenamedAlert);

+ 1 - 0
packages/app/src/components/Page/RevisionBody.jsx

@@ -64,6 +64,7 @@ export default class RevisionBody extends React.PureComponent {
             this.props.inputRef(elm);
           }
         }}
+        id="wiki"
         className={`wiki ${additionalClassName}`}
         // eslint-disable-next-line react/no-danger
         dangerouslySetInnerHTML={this.generateInnerHtml(this.props.html)}

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

@@ -107,6 +107,7 @@ class LegacyRevisionLoader extends React.Component {
         growiRenderer={this.props.growiRenderer}
         markdown={markdown}
         highlightKeywords={this.props.highlightKeywords}
+        isRenderable={this.props.isRenderable}
       />
     );
   }
@@ -126,7 +127,8 @@ LegacyRevisionLoader.propTypes = {
   revisionId: PropTypes.string.isRequired,
   lazy: PropTypes.bool,
   onRevisionLoaded: PropTypes.func,
-  highlightKeywords: PropTypes.string,
+  highlightKeywords: PropTypes.arrayOf(PropTypes.string),
+  isRenderable: PropTypes.bool,
 };
 
 const RevisionLoader = (props) => {

+ 16 - 8
packages/app/src/components/Page/RevisionRenderer.jsx

@@ -33,16 +33,20 @@ class LegacyRevisionRenderer extends React.PureComponent {
   }
 
   componentDidMount() {
-    this.initCurrentRenderingContext();
-    this.renderHtml();
+    const { isRenderable } = this.props;
+
+    if (isRenderable) {
+      this.initCurrentRenderingContext();
+      this.renderHtml();
+    }
   }
 
   componentDidUpdate(prevProps) {
     const { markdown: prevMarkdown, highlightKeywords: prevHighlightKeywords } = prevProps;
-    const { markdown, highlightKeywords } = this.props;
+    const { markdown, isRenderable, highlightKeywords } = this.props;
 
     // render only when props.markdown is updated
-    if (markdown !== prevMarkdown || highlightKeywords !== prevHighlightKeywords) {
+    if ((markdown !== prevMarkdown || highlightKeywords !== prevHighlightKeywords) && isRenderable) {
       this.initCurrentRenderingContext();
       this.renderHtml();
       return;
@@ -68,7 +72,8 @@ class LegacyRevisionRenderer extends React.PureComponent {
     // Separate keywords
     // - Surrounded by double quotation
     // - Split by both full-width and half-width spaces
-    [...keywords.match(/"[^"]+"|[^\u{20}\u{3000}]+/ug)].forEach((keyword, i) => {
+    // [...keywords.match(/"[^"]+"|[^\u{20}\u{3000}]+/ug)].forEach((keyword, i) => {
+    keywords.forEach((keyword, i) => {
       if (keyword === '') {
         return;
       }
@@ -138,7 +143,8 @@ class LegacyRevisionRenderer extends React.PureComponent {
     await interceptorManager.process('prePostProcess', context);
     context.parsedHTML = growiRenderer.postProcess(context.parsedHTML);
 
-    if (highlightKeywords != null) {
+    const isMarkdownEmpty = context.markdown.trim().length === 0;
+    if (highlightKeywords != null && highlightKeywords.length > 0 && !isMarkdownEmpty) {
       context.parsedHTML = this.getHighlightedBody(context.parsedHTML, highlightKeywords);
     }
     await interceptorManager.process('postPostProcess', context);
@@ -167,7 +173,8 @@ LegacyRevisionRenderer.propTypes = {
   appContainer: PropTypes.instanceOf(AppContainer).isRequired,
   growiRenderer: PropTypes.instanceOf(GrowiRenderer).isRequired,
   markdown: PropTypes.string.isRequired,
-  highlightKeywords: PropTypes.string,
+  isRenderable: PropTypes.bool,
+  highlightKeywords: PropTypes.arrayOf(PropTypes.string),
   additionalClassName: PropTypes.string,
 };
 
@@ -185,7 +192,8 @@ const RevisionRenderer = (props) => {
 RevisionRenderer.propTypes = {
   growiRenderer: PropTypes.instanceOf(GrowiRenderer).isRequired,
   markdown: PropTypes.string.isRequired,
-  highlightKeywords: PropTypes.string,
+  isRenderable: PropTypes.bool,
+  highlightKeywords: PropTypes.arrayOf(PropTypes.string),
   additionalClassName: PropTypes.string,
 };
 

+ 40 - 21
packages/app/src/components/Page/TrashPageAlert.jsx

@@ -7,22 +7,40 @@ import { UserPicture } from '@growi/ui';
 import { withUnstatedContainers } from '../UnstatedUtils';
 import AppContainer from '~/client/services/AppContainer';
 import PageContainer from '~/client/services/PageContainer';
-import PutbackPageModal from '../PutbackPageModal';
+
+import { useCurrentUpdatedAt, useShareLinkId } from '~/stores/context';
+import { usePageDeleteModal, usePutBackPageModal } from '~/stores/modal';
+import { useSWRxPageInfo } from '~/stores/page';
+
 import EmptyTrashModal from '../EmptyTrashModal';
 
-import { useCurrentUpdatedAt } from '~/stores/context';
-import { usePageDeleteModal } from '~/stores/ui';
+const onDeletedHandler = (pathOrPathsToDelete, isRecursively, isCompletely) => {
+  if (typeof pathOrPathsToDelete !== 'string') {
+    return;
+  }
+
+  window.location.href = '/';
+};
 
 const TrashPageAlert = (props) => {
   const { t, pageContainer } = props;
   const {
-    pageId, revisionId, path, isDeleted, lastUpdateUsername, deletedUserName, deletedAt, isAbleToDeleteCompletely,
+    pageId, revisionId, path, isDeleted, lastUpdateUsername, deletedUserName, deletedAt,
   } = pageContainer.state;
+  const { data: shareLinkId } = useShareLinkId();
+
+  /*
+  * TODO: Do not use useSWRxPageInfo on this component
+  * Ideal: use useSWRxPageInfo on TrashPage after applying Next.js
+  * Reference: https://github.com/weseek/growi/pull/5359#discussion_r808381329
+  */
+  const { data: pageInfo } = useSWRxPageInfo(pageId ?? null, shareLinkId);
+
   const { data: updatedAt } = useCurrentUpdatedAt();
   const [isEmptyTrashModalShown, setIsEmptyTrashModalShown] = useState(false);
-  const [isPutbackPageModalShown, setIsPutbackPageModalShown] = useState(false);
 
   const { open: openDeleteModal } = usePageDeleteModal();
+  const { open: openPutBackPageModal } = usePutBackPageModal();
 
   function openEmptyTrashModalHandler() {
     setIsEmptyTrashModalShown(true);
@@ -33,20 +51,27 @@ const TrashPageAlert = (props) => {
   }
 
   function openPutbackPageModalHandler() {
-    setIsPutbackPageModalShown(true);
-  }
-
-  function closePutbackPageModalHandler() {
-    setIsPutbackPageModalShown(false);
+    const putBackedHandler = (path) => {
+      window.location.reload();
+    };
+    openPutBackPageModal({ pageId, path }, { onPutBacked: putBackedHandler });
   }
 
   function openPageDeleteModalHandler() {
     const pageToDelete = {
-      pageId,
-      revisionId,
-      path,
+      data: {
+        _id: pageId,
+        revision: revisionId,
+        path,
+      },
     };
-    openDeleteModal([pageToDelete]);
+    openDeleteModal(
+      [pageToDelete],
+      {
+        isAbleToDeleteCompletely: pageInfo.isAbleToDeleteCompletely,
+        onDeleted: onDeletedHandler,
+      },
+    );
   }
 
   function renderEmptyButton() {
@@ -77,7 +102,7 @@ const TrashPageAlert = (props) => {
         <button
           type="button"
           className="btn btn-danger rounded-pill btn-sm"
-          disabled={!isAbleToDeleteCompletely}
+          disabled={!(pageInfo?.isAbleToDeleteCompletely ?? false)}
           onClick={openPageDeleteModalHandler}
         >
           <i className="icon-fire" aria-hidden="true"></i> { t('Delete Completely') }
@@ -93,12 +118,6 @@ const TrashPageAlert = (props) => {
           isOpen={isEmptyTrashModalShown}
           onClose={closeEmptyTrashModalHandler}
         />
-        <PutbackPageModal
-          isOpen={isPutbackPageModalShown}
-          onClose={closePutbackPageModalHandler}
-          pageId={pageId}
-          path={path}
-        />
       </>
     );
   }

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

@@ -7,7 +7,7 @@ import {
 import { useTranslation } from 'react-i18next';
 
 import { useIsGuestUser, useIsSharedUser } from '~/stores/context';
-import { usePageAccessoriesModal, PageAccessoriesModalContents } from '~/stores/ui';
+import { usePageAccessoriesModal, PageAccessoriesModalContents } from '~/stores/modal';
 import AppContainer from '~/client/services/AppContainer';
 
 import HistoryIcon from './Icons/HistoryIcon';

+ 18 - 8
packages/app/src/components/PageCreateModal.jsx

@@ -13,7 +13,8 @@ import { pagePathUtils, pathUtils } from '@growi/core';
 import AppContainer from '~/client/services/AppContainer';
 import { withUnstatedContainers } from './UnstatedUtils';
 import { toastError } from '~/client/util/apiNotification';
-import { useCreateModalStatus, useCreateModalOpened, useCreateModalPath } from '~/stores/ui';
+
+import { usePageCreateModal } from '~/stores/modal';
 
 import PagePathAutoComplete from './PagePathAutoComplete';
 
@@ -24,10 +25,8 @@ const {
 const PageCreateModal = (props) => {
   const { t, appContainer } = props;
 
-  const { close: closeCreateModal } = useCreateModalStatus();
-  const { data: isOpened } = useCreateModalOpened();
-  const { data: path } = useCreateModalPath();
-
+  const { data: pageCreateModalData, close: closeCreateModal } = usePageCreateModal();
+  const { isOpened, path } = pageCreateModalData;
 
   const config = appContainer.getConfig();
   const isReachable = config.isSearchServiceReachable;
@@ -166,7 +165,12 @@ const PageCreateModal = (props) => {
             </div>
 
             <div className="d-flex justify-content-end mt-1 mt-sm-0">
-              <button type="button" className="grw-btn-create-page btn btn-outline-primary rounded-pill text-nowrap ml-3" onClick={createTodayPage}>
+              <button
+                type="button"
+                data-testid="btn-create-memo"
+                className="grw-btn-create-page btn btn-outline-primary rounded-pill text-nowrap ml-3"
+                onClick={createTodayPage}
+              >
                 <i className="icon-fw icon-doc"></i>{t('Create')}
               </button>
             </div>
@@ -180,7 +184,7 @@ const PageCreateModal = (props) => {
 
   function renderInputPageForm() {
     return (
-      <div className="row">
+      <div className="row" data-testid="row-create-page-under-below">
         <fieldset className="col-12 mb-4">
           <h3 className="grw-modal-head pb-2">{t('Create under')}</h3>
 
@@ -212,7 +216,12 @@ const PageCreateModal = (props) => {
             </div>
 
             <div className="d-flex justify-content-end mt-1 mt-sm-0">
-              <button type="button" className="grw-btn-create-page btn btn-outline-primary rounded-pill text-nowrap ml-3" onClick={createInputPage}>
+              <button
+                type="button"
+                data-testid="btn-create-page-under-below"
+                className="grw-btn-create-page btn btn-outline-primary rounded-pill text-nowrap ml-3"
+                onClick={createInputPage}
+              >
                 <i className="icon-fw icon-doc"></i>{t('Create')}
               </button>
             </div>
@@ -276,6 +285,7 @@ const PageCreateModal = (props) => {
       size="lg"
       isOpen={isOpened}
       toggle={() => closeCreateModal()}
+      data-testid="page-create-modal"
       className="grw-create-page"
       autoFocus={false}
     >

+ 89 - 46
packages/app/src/components/PageDeleteModal.tsx

@@ -1,4 +1,4 @@
-import React, { useState, FC } from 'react';
+import React, { useState, FC, useMemo } from 'react';
 import {
   Modal, ModalHeader, ModalBody, ModalFooter,
 } from 'reactstrap';
@@ -6,11 +6,20 @@ import { useTranslation } from 'react-i18next';
 
 import { apiPost } from '~/client/util/apiv1-client';
 import { apiv3Post } from '~/client/util/apiv3-client';
-import { usePageDeleteModal, usePageDeleteModalOpened } from '~/stores/ui';
+import { usePageDeleteModal } from '~/stores/modal';
+import loggerFactory from '~/utils/logger';
 
-import { IDeleteSinglePageApiv1Result, IDeleteManyPageApiv3Result } from '~/interfaces/page';
+import {
+  IDeleteSinglePageApiv1Result, IDeleteManyPageApiv3Result, isIPageInfoForOperation, IPageToDeleteWithMeta, IDataWithMeta, IPageInfoForOperation,
+} from '~/interfaces/page';
+import { HasObjectId } from '~/interfaces/has-object-id';
 
 import ApiErrorMessageList from './PageManagement/ApiErrorMessageList';
+import { isTrashPage } from '^/../core/src/utils/page-path-utils';
+import { useSWRxPageInfoForList } from '~/stores/page';
+
+
+const logger = loggerFactory('growi:cli:PageDeleteModal');
 
 
 const deleteIconAndKey = {
@@ -26,26 +35,46 @@ const deleteIconAndKey = {
   },
 };
 
-type Props = {
-  isDeleteCompletelyModal: boolean,
-  isAbleToDeleteCompletely: boolean,
-  onClose?: () => void,
-}
+const PageDeleteModal: FC = () => {
+  const { t } = useTranslation();
+
+  const { data: deleteModalData, close: closeDeleteModal } = usePageDeleteModal();
+
+  const isOpened = deleteModalData?.isOpened ?? false;
 
-const PageDeleteModal: FC<Props> = (props: Props) => {
-  const { t } = useTranslation('');
-  const {
-    isDeleteCompletelyModal, isAbleToDeleteCompletely,
-  } = props;
+  const notOperatablePages: IPageToDeleteWithMeta[] = (deleteModalData?.pages ?? [])
+    .filter(p => !isIPageInfoForOperation(p.meta));
+  const notOperatablePageIds = notOperatablePages.map(p => p.data._id);
 
-  const { data: deleteModalStatus, close: closeDeleteModal } = usePageDeleteModal();
-  const { data: pageDeleteModalOpened } = usePageDeleteModalOpened();
+  const { injectTo } = useSWRxPageInfoForList(notOperatablePageIds);
+
+  // inject IPageInfo to operate
+  let injectedPages: IDataWithMeta<HasObjectId & { path: string }, IPageInfoForOperation>[] | null = null;
+  if (deleteModalData?.pages != null && notOperatablePageIds.length > 0) {
+    injectedPages = injectTo(deleteModalData?.pages);
+  }
+
+  // calculate conditions to delete
+  const [isDeletable, isAbleToDeleteCompletely] = useMemo(() => {
+    if (injectedPages != null && injectedPages.length > 0) {
+      const isDeletable = injectedPages.every(pageWithMeta => pageWithMeta.meta?.isDeletable);
+      const isAbleToDeleteCompletely = injectedPages.every(pageWithMeta => pageWithMeta.meta?.isAbleToDeleteCompletely);
+      return [isDeletable, isAbleToDeleteCompletely];
+    }
+    return [true, true];
+  }, [injectedPages]);
 
-  const isOpened = pageDeleteModalOpened?.isOpend != null ? pageDeleteModalOpened.isOpend : false;
+  // calculate condition to determine modal status
+  const forceDeleteCompletelyMode = useMemo(() => {
+    if (deleteModalData != null && deleteModalData.pages != null && deleteModalData.pages.length > 0) {
+      return deleteModalData.pages.every(pageWithMeta => isTrashPage(pageWithMeta.data?.path ?? ''));
+    }
+    return false;
+  }, [deleteModalData]);
 
   const [isDeleteRecursively, setIsDeleteRecursively] = useState(true);
-  const [isDeleteCompletely, setIsDeleteCompletely] = useState(isDeleteCompletelyModal && isAbleToDeleteCompletely);
-  const deleteMode = isDeleteCompletely ? 'completely' : 'temporary';
+  const [isDeleteCompletely, setIsDeleteCompletely] = useState(forceDeleteCompletelyMode);
+  const deleteMode = forceDeleteCompletelyMode || isDeleteCompletely ? 'completely' : 'temporary';
 
   // eslint-disable-next-line @typescript-eslint/no-unused-vars
   const [errs, setErrs] = useState<Error[] | null>(null);
@@ -55,27 +84,32 @@ const PageDeleteModal: FC<Props> = (props: Props) => {
   }
 
   function changeIsDeleteCompletelyHandler() {
-    if (!isAbleToDeleteCompletely) {
+    if (forceDeleteCompletelyMode) {
       return;
     }
     setIsDeleteCompletely(!isDeleteCompletely);
   }
 
   async function deletePage() {
-    if (deleteModalStatus == null || deleteModalStatus.pages == null) {
+    if (deleteModalData == null || deleteModalData.pages == null) {
+      return;
+    }
+
+    if (!isDeletable) {
+      logger.error('At least one page is not deletable.');
       return;
     }
 
     /*
      * When multiple pages
      */
-    if (deleteModalStatus.pages.length > 1) {
+    if (deleteModalData.pages.length > 1) {
       try {
         const isRecursively = isDeleteRecursively === true ? true : undefined;
         const isCompletely = isDeleteCompletely === true ? true : undefined;
 
         const pageIdToRevisionIdMap = {};
-        deleteModalStatus.pages.forEach((p) => { pageIdToRevisionIdMap[p.pageId] = p.revisionId });
+        deleteModalData.pages.forEach((p) => { pageIdToRevisionIdMap[p.data._id] = p.data.revision as string });
 
         const { data } = await apiv3Post<IDeleteManyPageApiv3Result>('/pages/delete', {
           pageIdToRevisionIdMap,
@@ -83,9 +117,12 @@ const PageDeleteModal: FC<Props> = (props: Props) => {
           isCompletely,
         });
 
-        if (pageDeleteModalOpened != null && pageDeleteModalOpened.onDeleted != null) {
-          pageDeleteModalOpened.onDeleted(data.paths, data.isRecursively, data.isCompletely);
+        const onDeleted = deleteModalData.opts?.onDeleted;
+        if (onDeleted != null) {
+          onDeleted(data.paths, data.isRecursively, data.isCompletely);
         }
+
+        closeDeleteModal();
       }
       catch (err) {
         setErrs([err]);
@@ -97,20 +134,23 @@ const PageDeleteModal: FC<Props> = (props: Props) => {
     else {
       try {
         const recursively = isDeleteRecursively === true ? true : undefined;
-        const completely = isDeleteCompletely === true ? true : undefined;
+        const completely = forceDeleteCompletelyMode || isDeleteCompletely ? true : undefined;
 
-        const page = deleteModalStatus.pages[0];
+        const page = deleteModalData.pages[0].data;
 
         const { path, isRecursively, isCompletely } = await apiPost('/pages.remove', {
-          page_id: page.pageId,
-          revision_id: page.revisionId,
+          page_id: page._id,
+          revision_id: page.revision,
           recursively,
           completely,
         }) as IDeleteSinglePageApiv1Result;
 
-        if (pageDeleteModalOpened != null && pageDeleteModalOpened.onDeleted != null) {
-          pageDeleteModalOpened.onDeleted(path, isRecursively, isCompletely);
+        const onDeleted = deleteModalData.opts?.onDeleted;
+        if (onDeleted != null) {
+          onDeleted(path, isRecursively, isCompletely);
         }
+
+        closeDeleteModal();
       }
       catch (err) {
         setErrs([err]);
@@ -119,7 +159,6 @@ const PageDeleteModal: FC<Props> = (props: Props) => {
   }
 
   async function deleteButtonHandler() {
-    await closeDeleteModal();
     await deletePage();
   }
 
@@ -136,17 +175,12 @@ const PageDeleteModal: FC<Props> = (props: Props) => {
         />
         <label className="custom-control-label" htmlFor="deleteRecursively">
           { t('modal_delete.delete_recursively') }
+          <p className="form-text text-muted mt-0"> { t('modal_delete.recursively') }</p>
         </label>
       </div>
     );
   }
 
-  // DeleteCompletely is currently disabled
-  // TODO1 : Retrive isAbleToDeleteCompleltly state everywhere in the system via swr.
-  // Story: https://redmine.weseek.co.jp/issues/82222
-
-  // TODO2 : use toaster
-  // TASK : https://redmine.weseek.co.jp/issues/82299
   function renderDeleteCompletelyForm() {
     return (
       <div className="custom-control custom-checkbox custom-checkbox-danger">
@@ -155,13 +189,10 @@ const PageDeleteModal: FC<Props> = (props: Props) => {
           name="completely"
           id="deleteCompletely"
           type="checkbox"
-          // disabled={!isAbleToDeleteCompletely}
-          // disabled // Todo: will be implemented at https://redmine.weseek.co.jp/issues/82222
+          disabled={!isAbleToDeleteCompletely}
           checked={isDeleteCompletely}
           onChange={changeIsDeleteCompletelyHandler}
         />
-        {/* ↓↓ undo this comment out at https://redmine.weseek.co.jp/issues/82222 ↓↓ */}
-        {/* <label className="custom-control-label text-danger" htmlFor="deleteCompletely"> */}
         <label className="custom-control-label" htmlFor="deleteCompletely">
           { t('modal_delete.delete_completely')}
           <p className="form-text text-muted mt-0"> { t('modal_delete.completely') }</p>
@@ -177,8 +208,15 @@ const PageDeleteModal: FC<Props> = (props: Props) => {
   }
 
   const renderPagePathsToDelete = () => {
-    if (deleteModalStatus != null && deleteModalStatus.pages != null) {
-      return deleteModalStatus.pages.map(page => <div key={page.pageId}><code>{ page.path }</code></div>);
+    const pages = injectedPages != null && injectedPages.length > 0 ? injectedPages : deleteModalData?.pages;
+
+    if (pages != null) {
+      return pages.map(page => (
+        <div key={page.data._id}>
+          <code>{ page.data.path }</code>
+          { !page.meta?.isDeletable && <span className="ml-3 text-danger"><strong>(CAN NOT TO DELETE)</strong></span> }
+        </div>
+      ));
     }
     return <></>;
   };
@@ -196,12 +234,17 @@ const PageDeleteModal: FC<Props> = (props: Props) => {
           {/* https://redmine.weseek.co.jp/issues/82787 */}
           {renderPagePathsToDelete()}
         </div>
-        {renderDeleteRecursivelyForm()}
-        {!isDeleteCompletelyModal && renderDeleteCompletelyForm()}
+        { isDeletable && renderDeleteRecursivelyForm()}
+        { isDeletable && !forceDeleteCompletelyMode && renderDeleteCompletelyForm() }
       </ModalBody>
       <ModalFooter>
         <ApiErrorMessageList errs={errs} />
-        <button type="button" className={`btn btn-${deleteIconAndKey[deleteMode].color}`} onClick={deleteButtonHandler}>
+        <button
+          type="button"
+          className={`btn btn-${deleteIconAndKey[deleteMode].color}`}
+          disabled={!isDeletable}
+          onClick={deleteButtonHandler}
+        >
           <i className={`icon-${deleteIconAndKey[deleteMode].icon}`} aria-hidden="true"></i>
           { t(`modal_delete.delete_${deleteIconAndKey[deleteMode].translationKey}`) }
         </button>

+ 22 - 16
packages/app/src/components/PageDuplicateModal.jsx

@@ -9,7 +9,7 @@ import { withTranslation } from 'react-i18next';
 import { debounce } from 'throttle-debounce';
 import { withUnstatedContainers } from './UnstatedUtils';
 import { toastError } from '~/client/util/apiNotification';
-import { usePageDuplicateModalStatus, usePageDuplicateModalOpened } from '~/stores/ui';
+import { usePageDuplicateModal } from '~/stores/modal';
 
 import AppContainer from '~/client/services/AppContainer';
 import PagePathAutoComplete from './PagePathAutoComplete';
@@ -27,10 +27,10 @@ const PageDuplicateModal = (props) => {
   const config = appContainer.getConfig();
   const isReachable = config.isSearchServiceReachable;
   const { crowi } = appContainer.config;
-  const { data: pagesDataToDuplicate, close: closeDuplicateModal } = usePageDuplicateModalStatus();
-  const { data: isOpened } = usePageDuplicateModalOpened();
+  const { data: duplicateModalData, close: closeDuplicateModal } = usePageDuplicateModal();
 
-  const { path, pageId } = pagesDataToDuplicate;
+  const { isOpened, page } = duplicateModalData;
+  const { pageId, path } = page;
 
   const [pageNameInput, setPageNameInput] = useState(path);
 
@@ -41,7 +41,7 @@ const PageDuplicateModal = (props) => {
   const [isDuplicateRecursivelyWithoutExistPath, setIsDuplicateRecursivelyWithoutExistPath] = useState(true);
   const [existingPaths, setExistingPaths] = useState([]);
 
-  const checkExistPaths = async(newParentPath) => {
+  const checkExistPaths = useCallback(async(newParentPath) => {
     try {
       const res = await appContainer.apiv3Get('/page/exist-paths', { fromPath: path, toPath: newParentPath });
       const { existPaths } = res.data;
@@ -51,15 +51,15 @@ const PageDuplicateModal = (props) => {
       setErrs(err);
       toastError(t('modal_rename.label.Fail to get exist path'));
     }
-  };
+  }, [appContainer, path, t]);
 
-  // eslint-disable-next-line react-hooks/exhaustive-deps
-  const checkExistPathsDebounce = useCallback(
-    debounce(1000, checkExistPaths), [pageId, path],
-  );
+
+  const checkExistPathsDebounce = useCallback(() => {
+    debounce(1000, checkExistPaths);
+  }, [checkExistPaths]);
 
   useEffect(() => {
-    if (pageId != null && pageNameInput !== path) {
+    if (pageId != null && path != null && pageNameInput !== path) {
       checkExistPathsDebounce(pageNameInput, subordinatedPages);
     }
   }, [pageNameInput, subordinatedPages, path, pageId, checkExistPathsDebounce]);
@@ -89,12 +89,11 @@ const PageDuplicateModal = (props) => {
   const getSubordinatedList = useCallback(async() => {
     try {
       const res = await appContainer.apiv3Get('/pages/subordinated-list', { path, limit: LIMIT_FOR_LIST });
-      const { subordinatedPaths } = res.data;
-      setSubordinatedPages(subordinatedPaths);
+      setSubordinatedPages(res.data.subordinatedPages);
     }
     catch (err) {
       setErrs(err);
-      toastError(t('modal_duplicate.label.Fail to get subordinated pages'));
+      toastError(t('modal_duplicate.label.Failed to get subordinated pages'));
     }
   }, [appContainer, path, t]);
 
@@ -113,8 +112,15 @@ const PageDuplicateModal = (props) => {
     setErrs(null);
 
     try {
-      await appContainer.apiv3Post('/pages/duplicate', { pageId, pageNameInput, isRecursively: isDuplicateRecursively });
-      window.location.href = encodeURI(`${pageNameInput}?duplicated=${path}`);
+      const { data } = await appContainer.apiv3Post('/pages/duplicate', { pageId, pageNameInput, isRecursively: isDuplicateRecursively });
+      const onDuplicated = duplicateModalData.opts?.onDuplicated;
+      const fromPath = path;
+      const toPath = data.page.path;
+
+      if (onDuplicated != null) {
+        onDuplicated(fromPath, toPath);
+      }
+      closeDuplicateModal();
     }
     catch (err) {
       setErrs(err);

+ 8 - 0
packages/app/src/components/PageEditor/AbstractEditor.tsx

@@ -12,6 +12,10 @@ export interface AbstractEditorProps extends ICodeMirror {
   onCtrlEnter?: (event: Event) => void;
 }
 
+interface defaultProps {
+  isGfmMode: true,
+}
+
 export default class AbstractEditor<T extends AbstractEditorProps> extends React.Component<T, Record<string, unknown>> {
 
   constructor(props: Readonly<T>) {
@@ -29,6 +33,10 @@ export default class AbstractEditor<T extends AbstractEditorProps> extends React
     this.dispatchSave = this.dispatchSave.bind(this);
   }
 
+  public static defaultProps: defaultProps = {
+    isGfmMode: true,
+  };
+
   forceToFocus(): void {}
 
   /**

+ 2 - 10
packages/app/src/components/PageEditor/CodeMirrorEditor.jsx

@@ -3,8 +3,6 @@ import PropTypes from 'prop-types';
 
 import urljoin from 'url-join';
 import * as codemirror from 'codemirror';
-import { UnControlled as UncontrolledCodeMirror } from 'react-codemirror2';
-
 import { Button } from 'reactstrap';
 
 import { JSHINT } from 'jshint';
@@ -13,6 +11,7 @@ import * as loadScript from 'simple-load-script';
 import * as loadCssSync from 'load-css-file';
 
 import { createValidator } from '@growi/codemirror-textlint';
+import { UncontrolledCodeMirror } from '../UncontrolledCodeMirror';
 import InterceptorManager from '~/services/interceptor-manager';
 import loggerFactory from '~/utils/logger';
 
@@ -32,7 +31,6 @@ import LinkEditModal from './LinkEditModal';
 import HandsontableModal from './HandsontableModal';
 import EditorIcon from './EditorIcon';
 import DrawioModal from './DrawioModal';
-// import { UncontrolledCodeMirror } from '../UncontrolledCodeMirror';
 
 // Textlint
 window.JSHINT = JSHINT;
@@ -110,7 +108,7 @@ export default class CodeMirrorEditor extends AbstractEditor {
 
     this.state = {
       value: this.props.value,
-      isGfmMode: this.props.isGfmMode ?? true,
+      isGfmMode: this.props.isGfmMode,
       isEnabledEmojiAutoComplete: false,
       isLoadingKeymap: false,
       isSimpleCheatsheetShown: this.props.isGfmMode && this.props.value.length === 0,
@@ -909,7 +907,6 @@ export default class CodeMirrorEditor extends AbstractEditor {
   }
 
   render() {
-    const mode = this.state.isGfmMode ? 'gfm-growi' : undefined;
     const lint = this.props.isTextlintEnabled ? this.codemirrorLintConfig : false;
     const additionalClasses = Array.from(this.state.additionalClassSet).join(' ');
     const placeholder = this.state.isGfmMode ? 'Input with Markdown..' : 'Input with Plain Text..';
@@ -936,11 +933,6 @@ export default class CodeMirrorEditor extends AbstractEditor {
           }}
           value={this.state.value}
           options={{
-            mode,
-            theme: this.props.editorOptions.theme,
-            styleActiveLine: this.props.editorOptions.styleActiveLine,
-            lineNumbers: this.props.lineNumbers,
-            tabSize: 4,
             indentUnit: this.props.indentSize,
             lineWrapping: true,
             scrollPastEnd: true,

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

@@ -202,8 +202,9 @@ class LinkEditModal extends React.PureComponent {
   }
 
   handleChangeTypeahead(selected) {
-    const page = selected[0];
-    if (page != null) {
+    const pageWithMeta = selected[0];
+    if (pageWithMeta != null) {
+      const page = pageWithMeta.pageData;
       const permalink = `${window.location.origin}/${page.id}`;
       this.setState({ linkInputValue: page.path, permalink });
     }

+ 18 - 5
packages/app/src/components/PageList/PageList.tsx

@@ -2,19 +2,25 @@ import React from 'react';
 import { useTranslation } from 'react-i18next';
 
 import { IPageWithMeta } from '~/interfaces/page';
-import { IPagingResult } from '~/interfaces/paging-result';
+import { OnDeletedFunction, OnPutBackedFunction } from '~/interfaces/ui';
+import { ForceHideMenuItems } from '../Common/Dropdown/PageItemControl';
 
 import { PageListItemL } from './PageListItemL';
 
 
 type Props = {
-  pages: IPagingResult<IPageWithMeta>,
+  pages: IPageWithMeta[],
   isEnableActions?: boolean,
+  forceHideMenuItems?: ForceHideMenuItems,
+  onPagesDeleted?: OnDeletedFunction,
+  onPagePutBacked?: OnPutBackedFunction,
 }
 
 const PageList = (props: Props): JSX.Element => {
   const { t } = useTranslation();
-  const { pages, isEnableActions } = props;
+  const {
+    pages, isEnableActions, forceHideMenuItems, onPagesDeleted, onPagePutBacked,
+  } = props;
 
   if (pages == null) {
     return (
@@ -26,8 +32,15 @@ const PageList = (props: Props): JSX.Element => {
     );
   }
 
-  const pageList = pages.items.map(page => (
-    <PageListItemL key={page.pageData._id} page={page} isEnableActions={isEnableActions} />
+  const pageList = pages.map(page => (
+    <PageListItemL
+      key={page.data._id}
+      page={page}
+      isEnableActions={isEnableActions}
+      forceHideMenuItems={forceHideMenuItems}
+      onPageDeleted={onPagesDeleted}
+      onPagePutBacked={onPagePutBacked}
+    />
   ));
 
   if (pageList.length === 0) {

+ 124 - 39
packages/app/src/components/PageList/PageListItemL.tsx

@@ -1,4 +1,9 @@
-import React, { memo, useCallback } from 'react';
+import React, {
+  forwardRef,
+  ForwardRefRenderFunction, memo, useCallback, useImperativeHandle, useRef,
+} from 'react';
+
+import { CustomInput } from 'reactstrap';
 
 import Clamp from 'react-multiline-clamp';
 import { format } from 'date-fns';
@@ -6,47 +11,83 @@ import urljoin from 'url-join';
 
 import { UserPicture, PageListMeta } from '@growi/ui';
 import { DevidedPagePath } from '@growi/core';
-import { useIsDeviceSmallerThanLg, usePageRenameModalStatus, usePageDuplicateModalStatus } from '~/stores/ui';
+
+
+import { ISelectable } from '~/client/interfaces/selectable-all';
+import { bookmark, unbookmark } from '~/client/services/page-operation';
+import { useIsDeviceSmallerThanLg } from '~/stores/ui';
 import {
-  IPageInfoAll, IPageWithMeta, isIPageInfoForEntity, isIPageInfoForListing,
+  usePageRenameModal, usePageDuplicateModal, usePageDeleteModal, usePutBackPageModal,
+} from '~/stores/modal';
+import {
+  IPageInfoAll, IPageInfoForEntity, IPageInfoForListing, IPageWithMeta, isIPageInfoForListing,
 } from '~/interfaces/page';
 import { IPageSearchMeta, isIPageSearchMeta } from '~/interfaces/search';
-
-import { PageItemControl } from '../Common/Dropdown/PageItemControl';
+import {
+  OnDuplicatedFunction, OnRenamedFunction, OnDeletedFunction, OnPutBackedFunction,
+} from '~/interfaces/ui';
 import LinkedPagePath from '~/models/linked-page-path';
+
+import { ForceHideMenuItems, PageItemControl } from '../Common/Dropdown/PageItemControl';
 import PagePathHierarchicalLink from '../PagePathHierarchicalLink';
 
 type Props = {
-  page: IPageWithMeta | IPageWithMeta<IPageInfoAll & IPageSearchMeta>,
+  page: IPageWithMeta<IPageInfoForEntity> | IPageWithMeta<IPageSearchMeta> | IPageWithMeta<IPageInfoForListing & IPageSearchMeta>,
   isSelected?: boolean, // is item selected(focused)
-  isChecked?: boolean, // is checkbox of item checked
   isEnableActions?: boolean,
+  forceHideMenuItems?: ForceHideMenuItems,
   showPageUpdatedTime?: boolean, // whether to show page's updated time at the top-right corner of item
-  onClickCheckbox?: (pageId: string) => void,
+  onCheckboxChanged?: (isChecked: boolean, pageId: string) => void,
   onClickItem?: (pageId: string) => void,
-  onClickDeleteButton?: (pageId: string) => void,
+  onPageDuplicated?: OnDuplicatedFunction,
+  onPageRenamed?: OnRenamedFunction,
+  onPageDeleted?: OnDeletedFunction,
+  onPagePutBacked?: OnPutBackedFunction,
 }
 
-export const PageListItemL = memo((props: Props): JSX.Element => {
+const PageListItemLSubstance: ForwardRefRenderFunction<ISelectable, Props> = (props: Props, ref): JSX.Element => {
   const {
     // todo: refactoring variable name to clear what changed
-    page: { pageData, pageMeta }, isSelected, onClickItem, onClickCheckbox, isChecked, isEnableActions,
+    page: { data: pageData, meta: pageMeta }, isSelected, isEnableActions,
+    forceHideMenuItems,
     showPageUpdatedTime,
+    onClickItem, onCheckboxChanged, onPageDuplicated, onPageRenamed, onPageDeleted, onPagePutBacked,
   } = props;
 
+  const inputRef = useRef<HTMLInputElement>(null);
+
+  // publish ISelectable methods
+  useImperativeHandle(ref, () => ({
+    select: () => {
+      const input = inputRef.current;
+      if (input != null) {
+        input.checked = true;
+      }
+    },
+    deselect: () => {
+      const input = inputRef.current;
+      if (input != null) {
+        input.checked = false;
+      }
+    },
+  }));
+
   const { data: isDeviceSmallerThanLg } = useIsDeviceSmallerThanLg();
-  const { open: openDuplicateModal } = usePageDuplicateModalStatus();
-  const { open: openRenameModal } = usePageRenameModalStatus();
+  const { open: openDuplicateModal } = usePageDuplicateModal();
+  const { open: openRenameModal } = usePageRenameModal();
+  const { open: openDeleteModal } = usePageDeleteModal();
+  const { open: openPutBackPageModal } = usePutBackPageModal();
 
   const elasticSearchResult = isIPageSearchMeta(pageMeta) ? pageMeta.elasticSearchResult : null;
   const revisionShortBody = isIPageInfoForListing(pageMeta) ? pageMeta.revisionShortBody : null;
 
-  const dPagePath: DevidedPagePath = new DevidedPagePath(pageData.path, true);
+  const dPagePath: DevidedPagePath = new DevidedPagePath(elasticSearchResult?.highlightedPath || pageData.path, true);
   const linkedPagePathFormer = new LinkedPagePath(dPagePath.former);
   const linkedPagePathLatter = new LinkedPagePath(dPagePath.latter);
 
   const lastUpdateDate = format(new Date(pageData.updatedAt), 'yyyy/MM/dd HH:mm:ss');
 
+
   // click event handler
   const clickHandler = useCallback(() => {
     // do nothing if mobile
@@ -59,20 +100,47 @@ export const PageListItemL = memo((props: Props): JSX.Element => {
     }
   }, [isDeviceSmallerThanLg, onClickItem, pageData._id]);
 
+  const bookmarkMenuItemClickHandler = async(_pageId: string, _newValue: boolean): Promise<void> => {
+    const bookmarkOperation = _newValue ? bookmark : unbookmark;
+    await bookmarkOperation(_pageId);
+  };
+
   const duplicateMenuItemClickHandler = useCallback(() => {
-    const { _id: pageId, path } = pageData;
-    openDuplicateModal(pageId, path);
-  }, [openDuplicateModal, pageData]);
+    const page = {
+      pageId: pageData._id,
+      path: pageData.path,
+    };
+    openDuplicateModal(page, { onDuplicated: onPageDuplicated });
+  }, [onPageDuplicated, openDuplicateModal, pageData._id, pageData.path]);
 
   const renameMenuItemClickHandler = useCallback(() => {
-    const { _id: pageId, revision: revisionId, path } = pageData;
-    openRenameModal(pageId, revisionId as string, path);
-  }, [openRenameModal, pageData]);
+    const page = {
+      pageId: pageData._id,
+      revisionId: pageData.revision as string,
+      path: pageData.path,
+    };
+    openRenameModal(page, { onRenamed: onPageRenamed });
+  }, [onPageRenamed, openRenameModal, pageData._id, pageData.path, pageData.revision]);
+
 
-  const styleListGroupItem = (!isDeviceSmallerThanLg && onClickCheckbox != null) ? 'list-group-item-action' : '';
+  const deleteMenuItemClickHandler = useCallback((_id: string, pageInfo: IPageInfoAll | undefined) => {
+    const pageToDelete = { data: pageData, meta: pageInfo };
+
+    // open modal
+    openDeleteModal([pageToDelete], { onDeleted: onPageDeleted });
+  }, [pageData, openDeleteModal, onPageDeleted]);
+
+  const revertMenuItemClickHandler = useCallback(() => {
+    const { _id: pageId, path } = pageData;
+    openPutBackPageModal({ pageId, path }, { onPutBacked: onPagePutBacked });
+  }, [onPagePutBacked, openPutBackPageModal, pageData]);
+
+  const styleListGroupItem = (!isDeviceSmallerThanLg && onClickItem != null) ? 'list-group-item-action' : '';
   // background color of list item changes when class "active" exists under 'list-group-item'
   const styleActive = !isDeviceSmallerThanLg && isSelected ? 'active' : '';
 
+  const shouldDangerouslySetInnerHTMLForPaths = elasticSearchResult != null && elasticSearchResult.highlightedPath.length > 0;
+
   return (
     <li
       key={pageData._id}
@@ -84,14 +152,14 @@ export const PageListItemL = memo((props: Props): JSX.Element => {
       >
         <div className="d-flex">
           {/* checkbox */}
-          {onClickCheckbox != null && (
-            <div className="form-check d-flex align-items-center justify-content-center px-md-2 pl-3 pr-2 search-item-checkbox">
-              <input
-                className="form-check-input position-relative m-0"
+          {onCheckboxChanged != null && (
+            <div className="d-flex align-items-center justify-content-center pl-md-2 pl-3">
+              <CustomInput
                 type="checkbox"
-                id="flexCheckDefault"
-                onChange={() => { onClickCheckbox(pageData._id) }}
-                checked={isChecked}
+                id={`cbSelect-${pageData._id}`}
+                data-testid="cb-select"
+                innerRef={inputRef}
+                onChange={(e) => { onCheckboxChanged(e.target.checked, pageData._id) }}
               />
             </div>
           )}
@@ -99,7 +167,10 @@ export const PageListItemL = memo((props: Props): JSX.Element => {
           <div className="flex-grow-1 p-md-3 pl-2 py-3 pr-3">
             <div className="d-flex justify-content-between">
               {/* page path */}
-              <PagePathHierarchicalLink linkedPagePath={linkedPagePathFormer} />
+              <PagePathHierarchicalLink
+                linkedPagePath={linkedPagePathFormer}
+                shouldDangerouslySetInnerHTML={shouldDangerouslySetInnerHTMLForPaths}
+              />
               { showPageUpdatedTime && (
                 <span className="page-list-updated-at text-muted">Last update: {lastUpdateDate}</span>
               ) }
@@ -114,27 +185,39 @@ export const PageListItemL = memo((props: Props): JSX.Element => {
                 <span className="h5 mb-0">
                   {/* Use permanent links to care for pages with the same name (Cannot use page path url) */}
                   <span className="grw-page-path-hierarchical-link text-break">
-                    <a className="page-segment" href={encodeURI(urljoin('/', pageData._id))}>{linkedPagePathLatter.pathName}</a>
+                    {shouldDangerouslySetInnerHTMLForPaths
+                      ? (
+                        <a
+                          className="page-segment"
+                          href={encodeURI(urljoin('/', pageData._id))}
+                          // eslint-disable-next-line react/no-danger
+                          dangerouslySetInnerHTML={{ __html: linkedPagePathLatter.pathName }}
+                        >
+                        </a>
+                      )
+                      : <a className="page-segment" href={encodeURI(urljoin('/', pageData._id))}>{linkedPagePathLatter.pathName}</a>
+                    }
                   </span>
                 </span>
               </Clamp>
 
               {/* page meta */}
-              { isIPageInfoForEntity(pageMeta) && (
-                <div className="d-none d-md-flex py-0 px-1">
-                  <PageListMeta page={pageData} bookmarkCount={pageMeta.bookmarkCount} shouldSpaceOutIcon />
-                </div>
-              ) }
+              <div className="d-none d-md-flex py-0 px-1">
+                <PageListMeta page={pageData} bookmarkCount={pageMeta?.bookmarkCount} shouldSpaceOutIcon />
+              </div>
 
               {/* doropdown icon includes page control buttons */}
               <div className="item-control ml-auto">
                 <PageItemControl
                   pageId={pageData._id}
-                  pageInfo={pageMeta}
-                  onClickDeleteMenuItem={props.onClickDeleteButton}
-                  onClickRenameMenuItem={renameMenuItemClickHandler}
+                  pageInfo={isIPageInfoForListing(pageMeta) ? pageMeta : undefined}
                   isEnableActions={isEnableActions}
+                  forceHideMenuItems={forceHideMenuItems}
+                  onClickBookmarkMenuItem={bookmarkMenuItemClickHandler}
+                  onClickRenameMenuItem={renameMenuItemClickHandler}
                   onClickDuplicateMenuItem={duplicateMenuItemClickHandler}
+                  onClickDeleteMenuItem={deleteMenuItemClickHandler}
+                  onClickRevertMenuItem={revertMenuItemClickHandler}
                 />
               </div>
             </div>
@@ -155,4 +238,6 @@ export const PageListItemL = memo((props: Props): JSX.Element => {
       </div>
     </li>
   );
-});
+};
+
+export const PageListItemL = memo(forwardRef(PageListItemLSubstance));

+ 3 - 0
packages/app/src/components/PagePathAutoComplete.jsx

@@ -30,6 +30,9 @@ const PagePathAutoComplete = (props) => {
   }
 
   function getKeywordOnInit(path) {
+    if (path == null) {
+      return;
+    }
     return addTrailingSlash
       ? pathUtils.addTrailingSlash(path)
       : pathUtils.removeTrailingSlash(path);

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

@@ -7,8 +7,9 @@ import LinkedPagePath from '../models/linked-page-path';
 
 
 const PagePathHierarchicalLink = (props) => {
-  const { linkedPagePath, basePath, isInTrash } = props;
-
+  const {
+    linkedPagePath, basePath, isInTrash, shouldDangerouslySetInnerHTML,
+  } = props;
   // render root element
   if (linkedPagePath.isRoot) {
     if (basePath != null) {
@@ -52,13 +53,24 @@ const PagePathHierarchicalLink = (props) => {
   return (
     <RootElm>
       { isParentExists && (
-        <PagePathHierarchicalLink linkedPagePath={linkedPagePath.parent} basePath={basePath} isInTrash={isInTrash || linkedPagePath.isInTrash} isInnerElem />
+        <PagePathHierarchicalLink
+          linkedPagePath={linkedPagePath.parent}
+          basePath={basePath}
+          isInTrash={isInTrash || linkedPagePath.isInTrash}
+          isInnerElem
+          shouldDangerouslySetInnerHTML={shouldDangerouslySetInnerHTML}
+        />
       ) }
       { isSeparatorRequired && (
         <span className="separator">/</span>
       ) }
 
-      <a className="page-segment" href={href}>{linkedPagePath.pathName}</a>
+      {
+        shouldDangerouslySetInnerHTML
+          ? <a className="page-segment" href={href} dangerouslySetInnerHTML={{ __html: linkedPagePath.pathName }}></a>
+          : <a className="page-segment" href={href}>{linkedPagePath.pathName}</a>
+      }
+
     </RootElm>
   );
 };
@@ -67,6 +79,7 @@ PagePathHierarchicalLink.propTypes = {
   linkedPagePath: PropTypes.instanceOf(LinkedPagePath).isRequired,
   basePath: PropTypes.string,
   isInTrash: PropTypes.bool,
+  shouldDangerouslySetInnerHTML: PropTypes.bool,
 
   // !!INTERNAL USE ONLY!!
   isInnerElem: PropTypes.bool,

+ 6 - 16
packages/app/src/components/PagePresentationModal.jsx

@@ -1,31 +1,21 @@
 import React from 'react';
-import PropTypes from 'prop-types';
 import {
   Modal, ModalBody,
 } from 'reactstrap';
 
-const PagePresentationModal = (props) => {
+import { usePagePresentationModal } from '~/stores/modal';
 
-  function closeModalHandler() {
-    if (props.onClose === null) {
-      return;
-    }
-    props.onClose();
-  }
+const PagePresentationModal = () => {
+
+  const { data: presentationData, close: closePresentationModal } = usePagePresentationModal();
 
   return (
-    <Modal isOpen={props.isOpen} toggle={closeModalHandler} className="grw-presentation-modal" unmountOnClose={false}>
+    <Modal isOpen={presentationData.isOpened} toggle={closePresentationModal} className="grw-presentation-modal" unmountOnClose={false}>
       <ModalBody className="modal-body">
-        <iframe src={props.href} />
+        <iframe src={presentationData.href} />
       </ModalBody>
     </Modal>
   );
 };
-PagePresentationModal.propTypes = {
-  isOpen: PropTypes.bool.isRequired,
-  onClose: PropTypes.func,
-  href: PropTypes.string.isRequired,
-};
-
 
 export default PagePresentationModal;

+ 17 - 15
packages/app/src/components/PageRenameModal.jsx

@@ -10,7 +10,7 @@ import {
 import { withTranslation } from 'react-i18next';
 
 import { debounce } from 'throttle-debounce';
-import { usePageRenameModalStatus, usePageRenameModalOpened } from '~/stores/ui';
+import { usePageRenameModal } from '~/stores/modal';
 import { withUnstatedContainers } from './UnstatedUtils';
 import { toastError } from '~/client/util/apiNotification';
 
@@ -29,10 +29,10 @@ const PageRenameModal = (props) => {
   } = props;
 
   const { crowi } = appContainer.config;
-  const { data: isOpened } = usePageRenameModalOpened();
-  const { data: pagesDataToRename, close: closeRenameModal } = usePageRenameModalStatus();
+  const { data: renameModalData, close: closeRenameModal } = usePageRenameModal();
 
-  const { path, revisionId, pageId } = pagesDataToRename;
+  const { isOpened, page } = renameModalData;
+  const { pageId, revisionId, path } = page;
 
   const [pageNameInput, setPageNameInput] = useState('');
 
@@ -65,12 +65,11 @@ const PageRenameModal = (props) => {
   const updateSubordinatedList = useCallback(async() => {
     try {
       const res = await apiv3Get('/pages/subordinated-list', { path });
-      const { subordinatedPaths } = res.data;
-      setSubordinatedPages(subordinatedPaths);
+      setSubordinatedPages(res.data.subordinatedPages);
     }
     catch (err) {
       setErrs(err);
-      toastError(t('modal_rename.label.Fail to get subordinated pages'));
+      toastError(t('modal_rename.label.Failed to get subordinated pages'));
     }
   }, [path, t]);
 
@@ -82,7 +81,7 @@ const PageRenameModal = (props) => {
   }, [isOpened, path, updateSubordinatedList]);
 
 
-  const checkExistPaths = async(newParentPath) => {
+  const checkExistPaths = useCallback(async(newParentPath) => {
     try {
       const res = await apiv3Get('/page/exist-paths', { fromPath: path, toPath: newParentPath });
       const { existPaths } = res.data;
@@ -92,15 +91,15 @@ const PageRenameModal = (props) => {
       setErrs(err);
       toastError(t('modal_rename.label.Fail to get exist path'));
     }
-  };
+  }, [path, t]);
 
   // eslint-disable-next-line react-hooks/exhaustive-deps
-  const checkExistPathsDebounce = useCallback(
-    debounce(1000, checkExistPaths), [path],
-  );
+  const checkExistPathsDebounce = useCallback(() => {
+    debounce(1000, checkExistPaths);
+  }, [checkExistPaths]);
 
   useEffect(() => {
-    if (pageId != null && pageNameInput !== path) {
+    if (pageId != null && path != null && pageNameInput !== path) {
       checkExistPathsDebounce(pageNameInput, subordinatedPages);
     }
   }, [pageNameInput, subordinatedPages, pageId, path, checkExistPathsDebounce]);
@@ -130,12 +129,15 @@ const PageRenameModal = (props) => {
 
       const { page } = response.data;
       const url = new URL(page.path, 'https://dummy');
-      url.searchParams.append('renamedFrom', path);
       if (isRenameRedirect) {
         url.searchParams.append('withRedirect', true);
       }
 
-      window.location.href = `${url.pathname}${url.search}`;
+      const onRenamed = renameModalData.opts?.onRenamed;
+      if (onRenamed != null) {
+        onRenamed(path);
+      }
+      closeRenameModal();
     }
     catch (err) {
       setErrs(err);

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

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

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

@@ -7,7 +7,7 @@ import { Pagination, PaginationItem, PaginationLink } from 'reactstrap';
 
 type Props = {
   activePage: number,
-  changePage?: (number) => void,
+  changePage?: (activePage: number) => void,
   totalItemsCount: number,
   pagingLimit?: number,
   align?: string,

+ 313 - 0
packages/app/src/components/PrivateLegacyPages.tsx

@@ -0,0 +1,313 @@
+import React, {
+  useCallback, useMemo, useRef, useState,
+} from 'react';
+import { useTranslation } from 'react-i18next';
+
+import {
+  UncontrolledButtonDropdown, DropdownToggle, DropdownMenu, DropdownItem,
+} from 'reactstrap';
+
+import { IFormattedSearchResult } from '~/interfaces/search';
+import AppContainer from '~/client/services/AppContainer';
+import { ISelectableAll, ISelectableAndIndeterminatable } from '~/client/interfaces/selectable-all';
+import { toastSuccess } from '~/client/util/apiNotification';
+import {
+  useSWRxNamedQuerySearch,
+} from '~/stores/search';
+import {
+  ILegacyPrivatePage, useLegacyPrivatePagesMigrationModal,
+} from '~/stores/modal';
+
+import PaginationWrapper from './PaginationWrapper';
+import { OperateAllControl } from './SearchPage/OperateAllControl';
+
+import { IReturnSelectedPageIds, SearchPageBase, usePageDeleteModalForBulkDeletion } from './SearchPage2/SearchPageBase';
+import { MenuItemType } from './Common/Dropdown/PageItemControl';
+import { LegacyPrivatePagesMigrationModal } from './LegacyPrivatePagesMigrationModal';
+
+
+// TODO: replace with "customize:showPageLimitationS"
+const INITIAL_PAGIONG_SIZE = 20;
+
+
+/**
+ * SearchResultListHead
+ */
+
+type SearchResultListHeadProps = {
+  searchResult: IFormattedSearchResult,
+  offset: number,
+  pagingSize: number,
+  onPagingSizeChanged: (size: number) => void,
+}
+
+const SearchResultListHead = React.memo((props: SearchResultListHeadProps): JSX.Element => {
+  const { t } = useTranslation();
+
+  const {
+    searchResult, offset, pagingSize,
+    onPagingSizeChanged,
+  } = props;
+
+  const { took, total, hitsCount } = searchResult.meta;
+  const leftNum = offset + 1;
+  const rightNum = offset + hitsCount;
+
+  if (total === 0) {
+    return (
+      <div className="card border-success mt-3">
+        <div className="card-body">
+          <h2 className="card-title text-success">{t('private_legacy_pages.nopages_title')}</h2>
+          <p className="card-text">
+            {t('private_legacy_pages.nopages_desc1')}<br />
+            {/* eslint-disable-next-line react/no-danger */}
+            <span dangerouslySetInnerHTML={{ __html: t('private_legacy_pages.detail_info') }}></span>
+          </p>
+        </div>
+      </div>
+    );
+  }
+
+  return (
+    <>
+      <div className="form-inline d-flex align-items-center justify-content-between">
+        <div className="text-nowrap">
+          {t('search_result.result_meta')}
+          <span className="ml-3">{`${leftNum}-${rightNum}`} / {total}</span>
+          { took != null && (
+            <span className="ml-3 text-muted">({took}ms)</span>
+          ) }
+        </div>
+        <div className="input-group flex-nowrap search-result-select-group ml-auto d-md-flex d-none">
+          <div className="input-group-prepend">
+            <label className="input-group-text text-muted" htmlFor="inputGroupSelect01">{t('search_result.number_of_list_to_display')}</label>
+          </div>
+          <select
+            defaultValue={pagingSize}
+            className="custom-select"
+            id="inputGroupSelect01"
+            onChange={e => onPagingSizeChanged(Number(e.target.value))}
+          >
+            {[20, 50, 100, 200].map((limit) => {
+              return <option key={limit} value={limit}>{limit} {t('search_result.page_number_unit')}</option>;
+            })}
+          </select>
+        </div>
+      </div>
+      <div className="card border-warning mt-3">
+        <div className="card-body">
+          <h2 className="card-title text-warning">{t('private_legacy_pages.alert_title')}</h2>
+          <p className="card-text">
+            {t('private_legacy_pages.alert_desc1', { delete_all_selected_page: t('search_result.delete_all_selected_page') })}<br />
+            {/* eslint-disable-next-line react/no-danger */}
+            <span dangerouslySetInnerHTML={{ __html: t('private_legacy_pages.detail_info') }}></span>
+          </p>
+        </div>
+      </div>
+    </>
+  );
+});
+
+
+/**
+ * LegacyPage
+ */
+
+type Props = {
+  appContainer: AppContainer,
+}
+
+export const PrivateLegacyPages = (props: Props): JSX.Element => {
+  const { t } = useTranslation();
+
+  const {
+    appContainer,
+  } = props;
+
+
+  const [offset, setOffset] = useState<number>(0);
+  const [limit, setLimit] = useState<number>(INITIAL_PAGIONG_SIZE);
+
+  const [isControlEnabled, setControlEnabled] = useState(false);
+
+  const selectAllControlRef = useRef<ISelectableAndIndeterminatable|null>(null);
+  const searchPageBaseRef = useRef<ISelectableAll & IReturnSelectedPageIds|null>(null);
+
+  const { data, conditions, mutate } = useSWRxNamedQuerySearch('PrivateLegacyPages', {
+    offset,
+    limit,
+  });
+
+  const { open: openModal, close: closeModal } = useLegacyPrivatePagesMigrationModal();
+
+  const selectAllCheckboxChangedHandler = useCallback((isChecked: boolean) => {
+    const instance = searchPageBaseRef.current;
+
+    if (instance == null) {
+      return;
+    }
+
+    if (isChecked) {
+      instance.selectAll();
+      setControlEnabled(true);
+    }
+    else {
+      instance.deselectAll();
+      setControlEnabled(false);
+    }
+  }, []);
+
+  const selectedPagesByCheckboxesChangedHandler = useCallback((selectedCount: number, totalCount: number) => {
+    const instance = selectAllControlRef.current;
+
+    if (instance == null) {
+      return;
+    }
+
+    if (selectedCount === 0) {
+      instance.deselect();
+      setControlEnabled(false);
+    }
+    else if (selectedCount === totalCount) {
+      instance.select();
+      setControlEnabled(true);
+    }
+    else {
+      instance.setIndeterminate();
+      setControlEnabled(true);
+    }
+  }, []);
+
+  // for bulk deletion
+  const deleteAllButtonClickedHandler = usePageDeleteModalForBulkDeletion(data, searchPageBaseRef, () => mutate());
+
+  const convertMenuItemClickedHandler = useCallback(() => {
+    if (data == null) {
+      return;
+    }
+
+    const instance = searchPageBaseRef.current;
+    if (instance == null || instance.getSelectedPageIds == null) {
+      return;
+    }
+
+    const selectedPageIds = instance.getSelectedPageIds();
+
+    if (selectedPageIds.size === 0) {
+      return;
+    }
+
+    const selectedPages = data.data
+      .filter(pageWithMeta => selectedPageIds.has(pageWithMeta.data._id))
+      .map(pageWithMeta => ({ pageId: pageWithMeta.data._id, path: pageWithMeta.data.path } as ILegacyPrivatePage));
+
+    openModal(
+      selectedPages,
+      () => {
+        toastSuccess('success');
+        closeModal();
+        mutate();
+      },
+    );
+  }, [data, mutate, openModal, closeModal]);
+
+  const pagingSizeChangedHandler = useCallback((pagingSize: number) => {
+    setOffset(0);
+    setLimit(pagingSize);
+    mutate();
+  }, [mutate]);
+
+  const pagingNumberChangedHandler = useCallback((activePage: number) => {
+    setOffset((activePage - 1) * limit);
+    mutate();
+  }, [limit, mutate]);
+
+  const hitsCount = data?.meta.hitsCount;
+
+  const searchControl = useMemo(() => {
+    const isCheckboxDisabled = hitsCount === 0;
+
+    return (
+      <div className="shadow-sm">
+        <div className="search-control d-flex align-items-center py-md-2 py-3 px-md-4 px-3 border-bottom border-gray">
+          <div className="d-flex pl-md-2">
+            <OperateAllControl
+              ref={selectAllControlRef}
+              isCheckboxDisabled={isCheckboxDisabled}
+              onCheckboxChanged={selectAllCheckboxChangedHandler}
+            >
+              <UncontrolledButtonDropdown>
+                <DropdownToggle caret color="outline-primary" disabled={!isControlEnabled}>
+                  {t('private_legacy_pages.bulk_operation')}
+                </DropdownToggle>
+                <DropdownMenu>
+                  <DropdownItem onClick={convertMenuItemClickedHandler}>
+                    <i className="icon-fw icon-refresh"></i>
+                    {t('private_legacy_pages.convert_all_selected_pages')}
+                  </DropdownItem>
+                  <DropdownItem onClick={deleteAllButtonClickedHandler}>
+                    <span className="text-danger">
+                      <i className="icon-fw icon-trash"></i>
+                      {t('search_result.delete_all_selected_page')}
+                    </span>
+                  </DropdownItem>
+                </DropdownMenu>
+              </UncontrolledButtonDropdown>
+            </OperateAllControl>
+          </div>
+        </div>
+      </div>
+    );
+  }, [convertMenuItemClickedHandler, deleteAllButtonClickedHandler, hitsCount, isControlEnabled, selectAllCheckboxChangedHandler, t]);
+
+  const searchResultListHead = useMemo(() => {
+    if (data == null) {
+      return <></>;
+    }
+    return (
+      <SearchResultListHead
+        searchResult={data}
+        offset={offset}
+        pagingSize={limit}
+        onPagingSizeChanged={pagingSizeChangedHandler}
+      />
+    );
+  }, [data, limit, offset, pagingSizeChangedHandler]);
+
+  const searchPager = useMemo(() => {
+    // when pager is not needed
+    if (data == null || data.meta.hitsCount === data.meta.total) {
+      return <></>;
+    }
+
+    const { total } = data.meta;
+    const { offset, limit } = conditions;
+
+    return (
+      <PaginationWrapper
+        activePage={Math.floor(offset / limit) + 1}
+        totalItemsCount={total}
+        pagingLimit={limit}
+        changePage={pagingNumberChangedHandler}
+      />
+    );
+  }, [conditions, data, pagingNumberChangedHandler]);
+
+  return (
+    <>
+      <SearchPageBase
+        ref={searchPageBaseRef}
+        appContainer={appContainer}
+        pages={data?.data}
+        onSelectedPagesByCheckboxesChanged={selectedPagesByCheckboxesChangedHandler}
+        forceHideMenuItems={[MenuItemType.BOOKMARK, MenuItemType.RENAME, MenuItemType.DUPLICATE, MenuItemType.REVERT]}
+        // Components
+        searchControl={searchControl}
+        searchResultListHead={searchResultListHead}
+        searchPager={searchPager}
+      />
+
+      <LegacyPrivatePagesMigrationModal />
+    </>
+  );
+};

+ 17 - 26
packages/app/src/components/PutbackPageModal.jsx

@@ -1,20 +1,23 @@
 import React, { useState } from 'react';
-import PropTypes from 'prop-types';
 
 import {
   Modal, ModalHeader, ModalBody, ModalFooter,
 } from 'reactstrap';
 
-import { withTranslation } from 'react-i18next';
+import { useTranslation } from 'react-i18next';
 
+import { usePutBackPageModal } from '~/stores/modal';
 import { apiPost } from '~/client/util/apiv1-client';
 
 import ApiErrorMessageList from './PageManagement/ApiErrorMessageList';
 
-const PutBackPageModal = (props) => {
-  const {
-    t, isOpen, onClose, pageId, path,
-  } = props;
+const PutBackPageModal = () => {
+  const { t } = useTranslation();
+
+  const { data: pageDataToRevert, close: closePutBackPageModal } = usePutBackPageModal();
+  const { isOpened, page } = pageDataToRevert;
+  const { pageId, path } = page;
+  const onPutBacked = pageDataToRevert.opts?.onPutBacked;
 
   const [errs, setErrs] = useState(null);
 
@@ -24,7 +27,7 @@ const PutBackPageModal = (props) => {
     setIsPutbackRecursively(!isPutbackRecursively);
   }
 
-  async function putbackPage() {
+  async function putbackPageButtonHandler() {
     setErrs(null);
 
     try {
@@ -37,21 +40,20 @@ const PutBackPageModal = (props) => {
         recursively,
       });
 
-      const putbackPagePath = response.page.path;
-      window.location.href = encodeURI(putbackPagePath);
+      if (onPutBacked != null) {
+        onPutBacked(response.page.path);
+      }
+      closePutBackPageModal();
     }
     catch (err) {
       setErrs(err);
     }
   }
 
-  async function putbackPageButtonHandler() {
-    putbackPage();
-  }
 
   return (
-    <Modal isOpen={isOpen} toggle={onClose} className="grw-create-page">
-      <ModalHeader tag="h4" toggle={onClose} className="bg-info text-light">
+    <Modal isOpen={isOpened} toggle={closePutBackPageModal} className="grw-create-page">
+      <ModalHeader tag="h4" toggle={closePutBackPageModal} className="bg-info text-light">
         <i className="icon-action-undo mr-2" aria-hidden="true"></i> { t('modal_putback.label.Put Back Page') }
       </ModalHeader>
       <ModalBody>
@@ -86,15 +88,4 @@ const PutBackPageModal = (props) => {
 
 };
 
-PutBackPageModal.propTypes = {
-  t: PropTypes.func.isRequired, //  i18next
-
-  isOpen: PropTypes.bool.isRequired,
-  onClose: PropTypes.func.isRequired,
-
-  pageId: PropTypes.string.isRequired,
-  path: PropTypes.string.isRequired,
-};
-
-
-export default withTranslation()(PutBackPageModal);
+export default PutBackPageModal;

+ 3 - 0
packages/app/src/components/SearchForm.tsx

@@ -85,6 +85,7 @@ type Props = {
 
   dropup?: boolean,
   keyword?: string,
+  disableIncrementalSearch?: boolean,
   onChange?: (data: IPageWithMeta<IPageSearchMeta>[]) => void,
   onBlur?: () => void,
   onFocus?: () => void,
@@ -97,6 +98,7 @@ const SearchForm: ForwardRefRenderFunction<IFocusable, Props> = (props: Props, r
   const { t } = useTranslation();
   const {
     isSearchServiceReachable, dropup,
+    disableIncrementalSearch,
     onChange, onBlur, onFocus, onSubmit, onInputChange,
   } = props;
 
@@ -129,6 +131,7 @@ const SearchForm: ForwardRefRenderFunction<IFocusable, Props> = (props: Props, r
       dropup={dropup}
       emptyLabel={emptyLabel}
       placeholder={placeholder}
+      disableIncrementalSearch={disableIncrementalSearch}
       onChange={onChange}
       onSubmit={onSubmit}
       onInputChange={onInputChange}

+ 0 - 396
packages/app/src/components/SearchPage.jsx

@@ -1,396 +0,0 @@
-// This is the root component for #search-page
-
-import React from 'react';
-import PropTypes from 'prop-types';
-import { withTranslation } from 'react-i18next';
-
-import {
-  DetachCodeBlockInterceptor,
-  RestoreCodeBlockInterceptor,
-} from '../client/util/interceptor/detach-code-blocks';
-
-import { withUnstatedContainers } from './UnstatedUtils';
-import AppContainer from '~/client/services/AppContainer';
-import { toastError } from '~/client/util/apiNotification';
-import SearchPageLayout from './SearchPage/SearchPageLayout';
-import SearchResultContent from './SearchPage/SearchResultContent';
-import SearchResultList from './SearchPage/SearchResultList';
-import SearchControl from './SearchPage/SearchControl';
-import { CheckboxType, SORT_AXIS, SORT_ORDER } from '~/interfaces/search';
-import PageDeleteModal from './PageDeleteModal';
-import { useIsGuestUser } from '~/stores/context';
-
-export const specificPathNames = {
-  user: '/user',
-  trash: '/trash',
-};
-class SearchPage extends React.Component {
-
-  constructor(props) {
-    super(props);
-    // NOTE : selectedPages is deletion related state, will be used later in story 77535, 77565.
-    // deletionModal, deletion related functions are all removed, add them back when necessary.
-    // i.e ) in story 77525 or any tasks implementing deletion functionalities
-    this.state = {
-      searchingKeyword: decodeURI(this.props.query.q) || '',
-      searchedKeyword: '',
-      searchResults: [],
-      searchResultMeta: {},
-      focusedSearchResultData: null,
-      selectedPagesIdList: new Set(),
-      searchResultCount: 0,
-      activePage: 1,
-      pagingLimit: this.props.appContainer.config.pageLimitationL || 50,
-      excludeUserPages: true,
-      excludeTrashPages: true,
-      sort: SORT_AXIS.RELATION_SCORE,
-      order: SORT_ORDER.DESC,
-      selectAllCheckboxType: CheckboxType.NONE_CHECKED,
-      isDeleteConfirmModalShown: false,
-      deleteTargetPageIds: new Set(),
-    };
-
-    // TODO: Move this code to the right place after completing the "omit unstated" initiative.
-    const { interceptorManager } = props.appContainer;
-    interceptorManager.addInterceptor(new DetachCodeBlockInterceptor(props.appContainer), 10); // process as soon as possible
-    interceptorManager.addInterceptor(new RestoreCodeBlockInterceptor(props.appContainer), 900); // process as late as possible
-
-    this.changeURL = this.changeURL.bind(this);
-    this.search = this.search.bind(this);
-    this.onSearchInvoked = this.onSearchInvoked.bind(this);
-    this.selectPage = this.selectPage.bind(this);
-    this.toggleCheckBox = this.toggleCheckBox.bind(this);
-    this.switchExcludeUserPagesHandler = this.switchExcludeUserPagesHandler.bind(this);
-    this.switchExcludeTrashPagesHandler = this.switchExcludeTrashPagesHandler.bind(this);
-    this.onChangeSortInvoked = this.onChangeSortInvoked.bind(this);
-    this.onPagingNumberChanged = this.onPagingNumberChanged.bind(this);
-    this.onPagingLimitChanged = this.onPagingLimitChanged.bind(this);
-    this.deleteSinglePageButtonHandler = this.deleteSinglePageButtonHandler.bind(this);
-    this.deleteAllPagesButtonHandler = this.deleteAllPagesButtonHandler.bind(this);
-    this.closeDeleteConfirmModalHandler = this.closeDeleteConfirmModalHandler.bind(this);
-  }
-
-  componentDidMount() {
-    const keyword = this.state.searchingKeyword;
-    if (keyword !== '') {
-      this.search({ keyword });
-    }
-  }
-
-  static getQueryByLocation(location) {
-    const search = location.search || '';
-    const query = {};
-
-    search.replace(/^\?/, '').split('&').forEach((element) => {
-      const queryParts = element.split('=');
-      query[queryParts[0]] = decodeURIComponent(queryParts[1]).replace(/\+/g, ' ');
-    });
-
-    return query;
-  }
-
-  switchExcludeUserPagesHandler() {
-    this.setState({ excludeUserPages: !this.state.excludeUserPages });
-  }
-
-  switchExcludeTrashPagesHandler() {
-    this.setState({ excludeTrashPages: !this.state.excludeTrashPages });
-  }
-
-  onChangeSortInvoked(nextSort, nextOrder) {
-    this.setState({
-      sort: nextSort,
-      order: nextOrder,
-    });
-  }
-
-  changeURL(keyword, refreshHash) {
-    let hash = window.location.hash || '';
-    // TODO 整理する
-    if (refreshHash || this.state.searchedKeyword !== '') {
-      hash = '';
-    }
-    if (window.history && window.history.pushState) {
-      window.history.pushState('', `Search - ${keyword}`, `/_search?q=${keyword}${hash}`);
-    }
-  }
-
-  createSearchQuery(keyword) {
-    let query = keyword;
-
-    // pages included in specific path are not retrived when prefix is added
-    if (this.state.excludeTrashPages) {
-      query = `${query} -prefix:${specificPathNames.trash}`;
-    }
-    if (this.state.excludeUserPages) {
-      query = `${query} -prefix:${specificPathNames.user}`;
-    }
-
-    return query;
-  }
-
-  /**
-   * this method is called when user changes paging number
-   */
-  async onPagingNumberChanged(activePage) {
-    this.setState({ activePage }, () => this.search({ keyword: this.state.searchedKeyword }));
-  }
-
-  /**
-   * this method is called when user searches by pressing Enter or using searchbox
-   */
-  async onSearchInvoked(data) {
-    this.setState({ activePage: 1 }, () => this.search(data));
-  }
-
-  /**
-   * change number of pages to display per page and execute search method after.
-   */
-  async onPagingLimitChanged(limit) {
-    this.setState({ pagingLimit: limit }, () => this.search({ keyword: this.state.searchedKeyword }));
-  }
-
-  // todo: refactoring
-  // refs: https://redmine.weseek.co.jp/issues/82139
-  async search(data) {
-    // reset following states when search runs
-    this.setState({
-      selectedPagesIdList: new Set(),
-      selectAllCheckboxType: CheckboxType.NONE_CHECKED,
-    });
-
-    const keyword = data.keyword;
-    if (keyword === '') {
-      this.setState({
-        searchingKeyword: '',
-        searchedKeyword: '',
-        searchResults: [],
-        searchResultMeta: {},
-        searchResultCount: 0,
-        activePage: 1,
-      });
-
-      return true;
-    }
-
-    this.setState({
-      searchingKeyword: keyword,
-    });
-    const pagingLimit = this.state.pagingLimit;
-    const offset = (this.state.activePage * pagingLimit) - pagingLimit;
-    const { sort, order } = this.state;
-    try {
-      const res = await this.props.appContainer.apiGet('/search', {
-        q: this.createSearchQuery(keyword),
-        limit: pagingLimit,
-        offset,
-        sort,
-        order,
-      });
-
-      this.changeURL(keyword);
-      if (res.data.length > 0) {
-        this.setState({
-          searchedKeyword: keyword,
-          searchResults: res.data,
-          searchResultMeta: res.meta,
-          searchResultCount: res.meta.total,
-          focusedSearchResultData: res.data[0],
-          // reset active page if keyword changes, otherwise set the current state
-          activePage: this.state.searchedKeyword === keyword ? this.state.activePage : 1,
-        });
-      }
-      else {
-        this.setState({
-          searchedKeyword: keyword,
-          searchResults: [],
-          searchResultMeta: {},
-          searchResultCount: 0,
-          focusedSearchResultData: {},
-          activePage: 1,
-        });
-      }
-    }
-    catch (err) {
-      toastError(err);
-    }
-  }
-
-  selectPage= (pageId) => {
-    const index = this.state.searchResults.findIndex(({ pageData }) => {
-      return pageData._id === pageId;
-    });
-    this.setState({
-      focusedSearchResultData: this.state.searchResults[index],
-    });
-  }
-
-  toggleCheckBox = (pageId) => {
-    const { selectedPagesIdList } = this.state;
-
-    if (selectedPagesIdList.has(pageId)) {
-      selectedPagesIdList.delete(pageId);
-    }
-    else {
-      selectedPagesIdList.add(pageId);
-    }
-    switch (selectedPagesIdList.size) {
-      case 0:
-        return this.setState({ selectAllCheckboxType: CheckboxType.NONE_CHECKED });
-      case this.state.searchResults.length:
-        return this.setState({ selectAllCheckboxType: CheckboxType.ALL_CHECKED });
-      default:
-        return this.setState({ selectAllCheckboxType: CheckboxType.INDETERMINATE });
-    }
-  }
-
-  toggleAllCheckBox = (nextSelectAllCheckboxType) => {
-    const { selectedPagesIdList, searchResults } = this.state;
-    if (nextSelectAllCheckboxType === CheckboxType.NONE_CHECKED) {
-      selectedPagesIdList.clear();
-    }
-    else {
-      searchResults.forEach((page) => {
-        selectedPagesIdList.add(page.pageData._id);
-      });
-    }
-    this.setState({
-      selectedPagesIdList,
-      selectAllCheckboxType: nextSelectAllCheckboxType,
-    });
-  };
-
-  getSelectedPagesToDelete() {
-    const filteredPages = this.state.searchResults.filter((page) => {
-      return Array.from(this.state.deleteTargetPageIds).find(id => id === page.pageData._id);
-    });
-    return filteredPages.map(page => ({
-      pageId: page.pageData._id,
-      revisionId: page.pageData.revision,
-      path: page.pageData.path,
-    }));
-  }
-
-  deleteSinglePageButtonHandler(pageId) {
-    this.setState({ deleteTargetPageIds: new Set([pageId]) });
-    this.setState({ isDeleteConfirmModalShown: true });
-  }
-
-  deleteAllPagesButtonHandler() {
-    if (this.state.selectedPagesIdList.size === 0) { return }
-    this.setState({ deleteTargetPageIds: this.state.selectedPagesIdList });
-    this.setState({ isDeleteConfirmModalShown: true });
-  }
-
-  closeDeleteConfirmModalHandler() {
-    this.setState({ isDeleteConfirmModalShown: false });
-  }
-
-  renderSearchResultContent = () => {
-    return (
-      <SearchResultContent
-        appContainer={this.props.appContainer}
-        searchingKeyword={this.state.searchingKeyword}
-        focusedSearchResultData={this.state.focusedSearchResultData}
-        showPageControlDropdown={!this.props.isGuestUser}
-      >
-      </SearchResultContent>
-    );
-  }
-
-  renderSearchResultList = () => {
-    return (
-      <SearchResultList
-        pages={this.state.searchResults || []}
-        isEnableActions={!this.props.isGuestUser}
-        focusedSearchResultData={this.state.focusedSearchResultData}
-        selectedPagesIdList={this.state.selectedPagesIdList || []}
-        searchResultCount={this.state.searchResultCount}
-        activePage={this.state.activePage}
-        pagingLimit={this.state.pagingLimit}
-        onClickItem={this.selectPage}
-        onClickCheckbox={this.toggleCheckBox}
-        onPagingNumberChanged={this.onPagingNumberChanged}
-        onClickDeleteButton={this.deleteSinglePageButtonHandler}
-      />
-    );
-  }
-
-  renderSearchControl = () => {
-    return (
-      <SearchControl
-        searchingKeyword={this.state.searchingKeyword}
-        sort={this.state.sort}
-        order={this.state.order}
-        searchResultCount={this.state.searchResultCount || 0}
-        appContainer={this.props.appContainer}
-        onSearchInvoked={this.onSearchInvoked}
-        onClickSelectAllCheckbox={this.toggleAllCheckBox}
-        selectAllCheckboxType={this.state.selectAllCheckboxType}
-        onClickDeleteAllButton={this.deleteAllPagesButtonHandler}
-        onExcludeUserPagesSwitched={this.switchExcludeUserPagesHandler}
-        onExcludeTrashPagesSwitched={this.switchExcludeTrashPagesHandler}
-        excludeUserPages={this.state.excludeUserPages}
-        excludeTrashPages={this.state.excludeTrashPages}
-        onChangeSortInvoked={this.onChangeSortInvoked}
-      >
-      </SearchControl>
-    );
-  }
-
-  render() {
-    return (
-      <div>
-        <SearchPageLayout
-          SearchControl={this.renderSearchControl}
-          SearchResultList={this.renderSearchResultList}
-          SearchResultContent={this.renderSearchResultContent}
-          searchResultMeta={this.state.searchResultMeta}
-          searchingKeyword={this.state.searchedKeyword}
-          onPagingLimitChanged={this.onPagingLimitChanged}
-          pagingLimit={this.state.pagingLimit}
-          activePage={this.state.activePage}
-        >
-        </SearchPageLayout>
-        {/* TODO: show PageDeleteModal with usePageDeleteModal by 87569  */}
-        <PageDeleteModal
-          isOpen={this.state.isDeleteConfirmModalShown}
-          onClose={this.closeDeleteConfirmModalHandler}
-          pages={this.getSelectedPagesToDelete()}
-        />
-      </div>
-    );
-  }
-
-}
-
-/**
- * Wrapper component for using unstated
- */
-const SearchPageHOCWrapper = withTranslation()(withUnstatedContainers(SearchPage, [AppContainer]));
-
-SearchPage.propTypes = {
-  t: PropTypes.func.isRequired, // i18next
-  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
-  query: PropTypes.object,
-  isGuestUser: PropTypes.bool.isRequired,
-};
-SearchPage.defaultProps = {
-  // pollInterval: 1000,
-  query: SearchPage.getQueryByLocation(window.location || {}),
-};
-
-const SearchPageFCWrapper = (props) => {
-  const { data: isGuestUser } = useIsGuestUser();
-
-  /*
-   * dependencies
-   */
-  if (isGuestUser == null) {
-    return null;
-  }
-
-  return <SearchPageHOCWrapper {...props} isGuestUser={isGuestUser} />;
-};
-
-export default SearchPageFCWrapper;

+ 280 - 0
packages/app/src/components/SearchPage.tsx

@@ -0,0 +1,280 @@
+import React, {
+  useCallback, useEffect, useMemo, useRef, useState,
+} from 'react';
+import { useTranslation } from 'react-i18next';
+
+import { parse as parseQuerystring } from 'querystring';
+
+import AppContainer from '~/client/services/AppContainer';
+import { IFormattedSearchResult } from '~/interfaces/search';
+import { ISelectableAll, ISelectableAndIndeterminatable } from '~/client/interfaces/selectable-all';
+import { useIsSearchServiceReachable } from '~/stores/context';
+import { ISearchConditions, ISearchConfigurations, useSWRxFullTextSearch } from '~/stores/search';
+
+import PaginationWrapper from './PaginationWrapper';
+import { OperateAllControl } from './SearchPage/OperateAllControl';
+import SearchControl from './SearchPage/SearchControl';
+
+import { IReturnSelectedPageIds, SearchPageBase, usePageDeleteModalForBulkDeletion } from './SearchPage2/SearchPageBase';
+
+
+// TODO: replace with "customize:showPageLimitationS"
+const INITIAL_PAGIONG_SIZE = 20;
+
+
+/**
+ * SearchResultListHead
+ */
+
+type SearchResultListHeadProps = {
+  searchResult: IFormattedSearchResult,
+  searchingKeyword: string,
+  offset: number,
+  pagingSize: number,
+  onPagingSizeChanged: (size: number) => void,
+}
+
+const SearchResultListHead = React.memo((props: SearchResultListHeadProps): JSX.Element => {
+  const { t } = useTranslation();
+
+  const {
+    searchResult, searchingKeyword, offset, pagingSize,
+    onPagingSizeChanged,
+  } = props;
+
+  const { took, total, hitsCount } = searchResult.meta;
+  const leftNum = offset + 1;
+  const rightNum = offset + hitsCount;
+
+  if (total === 0) {
+    return (
+      <div className="d-flex justify-content-center h2 text-muted my-5">
+        0 {t('search_result.page_number_unit')}
+      </div>
+    );
+  }
+
+  return (
+    <div className="form-inline d-flex align-items-center justify-content-between">
+      <div className="text-nowrap">
+        {t('search_result.result_meta')}
+        <span className="search-result-keyword ml-2">{`${searchingKeyword}`}</span>
+        <span className="ml-3">{`${leftNum}-${rightNum}`} / {total}</span>
+        { took != null && (
+          // blackout 70px rectangle in VRT
+          <span data-hide-in-vrt className="ml-3 text-muted d-inline-block" style={{ minWidth: '70px' }}>({took}ms)</span>
+        ) }
+      </div>
+      <div className="input-group flex-nowrap search-result-select-group ml-auto d-md-flex d-none">
+        <div className="input-group-prepend">
+          <label className="input-group-text text-muted" htmlFor="inputGroupSelect01">{t('search_result.number_of_list_to_display')}</label>
+        </div>
+        <select
+          defaultValue={pagingSize}
+          className="custom-select"
+          id="inputGroupSelect01"
+          onChange={e => onPagingSizeChanged(Number(e.target.value))}
+        >
+          {[20, 50, 100, 200].map((limit) => {
+            return <option key={limit} value={limit}>{limit} {t('search_result.page_number_unit')}</option>;
+          })}
+        </select>
+      </div>
+    </div>
+  );
+});
+
+
+/**
+ * SearchPage
+ */
+
+const getParsedUrlQuery = () => {
+  const search = window.location.search || '?';
+  return parseQuerystring(search.slice(1)); // remove heading '?' and parse
+};
+
+type Props = {
+  appContainer: AppContainer,
+}
+
+export const SearchPage = (props: Props): JSX.Element => {
+  const { t } = useTranslation();
+
+  const {
+    appContainer,
+  } = props;
+
+  // parse URL Query
+  const parsedQueries = getParsedUrlQuery().q;
+  const initQ = (Array.isArray(parsedQueries) ? parsedQueries.join(' ') : parsedQueries) ?? '';
+
+  const [keyword, setKeyword] = useState<string>(initQ);
+  const [offset, setOffset] = useState<number>(0);
+  const [limit, setLimit] = useState<number>(INITIAL_PAGIONG_SIZE);
+  const [configurationsByControl, setConfigurationsByControl] = useState<Partial<ISearchConfigurations>>({});
+
+  const selectAllControlRef = useRef<ISelectableAndIndeterminatable|null>(null);
+  const searchPageBaseRef = useRef<ISelectableAll & IReturnSelectedPageIds|null>(null);
+
+  const { data: isSearchServiceReachable } = useIsSearchServiceReachable();
+
+  const { data, conditions, mutate } = useSWRxFullTextSearch(keyword, {
+    ...configurationsByControl,
+    offset,
+    limit,
+  });
+
+  const searchInvokedHandler = useCallback((_keyword: string, newConfigurations: Partial<ISearchConfigurations>) => {
+    setKeyword(_keyword);
+    setOffset(0);
+    setConfigurationsByControl(newConfigurations);
+  }, []);
+
+  const selectAllCheckboxChangedHandler = useCallback((isChecked: boolean) => {
+    const instance = searchPageBaseRef.current;
+
+    if (instance == null) {
+      return;
+    }
+
+    if (isChecked) {
+      instance.selectAll();
+    }
+    else {
+      instance.deselectAll();
+    }
+  }, []);
+
+  const selectedPagesByCheckboxesChangedHandler = useCallback((selectedCount: number, totalCount: number) => {
+    const instance = selectAllControlRef.current;
+
+    if (instance == null) {
+      return;
+    }
+
+    if (selectedCount === 0) {
+      instance.deselect();
+    }
+    else if (selectedCount === totalCount) {
+      instance.select();
+    }
+    else {
+      instance.setIndeterminate();
+    }
+  }, []);
+
+  const pagingSizeChangedHandler = useCallback((pagingSize: number) => {
+    setOffset(0);
+    setLimit(pagingSize);
+    mutate();
+  }, [mutate]);
+
+  const pagingNumberChangedHandler = useCallback((activePage: number) => {
+    setOffset((activePage - 1) * limit);
+    mutate();
+  }, [limit, mutate]);
+
+  const initialSearchConditions: Partial<ISearchConditions> = useMemo(() => {
+    return {
+      keyword: initQ,
+      limit: INITIAL_PAGIONG_SIZE,
+    };
+  }, [initQ]);
+
+  // for bulk deletion
+  const deleteAllButtonClickedHandler = usePageDeleteModalForBulkDeletion(data, searchPageBaseRef, () => mutate());
+
+  // push state
+  useEffect(() => {
+    const newUrl = new URL('/_search', 'http://example.com');
+    newUrl.searchParams.append('q', keyword);
+    window.history.pushState('', `Search - ${keyword}`, `${newUrl.pathname}${newUrl.search}`);
+  }, [keyword]);
+  const hitsCount = data?.meta.hitsCount;
+
+  const deleteAllControl = useMemo(() => {
+    const isDisabled = hitsCount === 0;
+
+    return (
+      <OperateAllControl
+        ref={selectAllControlRef}
+        isCheckboxDisabled={isDisabled}
+        onCheckboxChanged={selectAllCheckboxChangedHandler}
+      >
+        <button
+          type="button"
+          className="btn btn-outline-danger border-0 px-2"
+          disabled={isDisabled}
+          onClick={deleteAllButtonClickedHandler}
+        >
+          <i className="icon-fw icon-trash"></i>
+          {t('search_result.delete_all_selected_page')}
+        </button>
+      </OperateAllControl>
+    );
+  }, [deleteAllButtonClickedHandler, hitsCount, selectAllCheckboxChangedHandler, t]);
+
+  const searchControl = useMemo(() => {
+    if (!isSearchServiceReachable) {
+      return <></>;
+    }
+    return (
+      <SearchControl
+        isSearchServiceReachable={isSearchServiceReachable}
+        initialSearchConditions={initialSearchConditions}
+        onSearchInvoked={searchInvokedHandler}
+        deleteAllControl={deleteAllControl}
+      >
+      </SearchControl>
+    );
+  }, [deleteAllControl, initialSearchConditions, isSearchServiceReachable, searchInvokedHandler]);
+
+  const searchResultListHead = useMemo(() => {
+    if (data == null) {
+      return <></>;
+    }
+    return (
+      <SearchResultListHead
+        searchResult={data}
+        searchingKeyword={keyword}
+        offset={offset}
+        pagingSize={limit}
+        onPagingSizeChanged={pagingSizeChangedHandler}
+      />
+    );
+  }, [data, keyword, limit, offset, pagingSizeChangedHandler]);
+
+  const searchPager = useMemo(() => {
+    // when pager is not needed
+    if (data == null || data.meta.hitsCount === data.meta.total) {
+      return <></>;
+    }
+
+    const { total } = data.meta;
+    const { offset, limit } = conditions;
+
+    return (
+      <PaginationWrapper
+        activePage={Math.floor(offset / limit) + 1}
+        totalItemsCount={total}
+        pagingLimit={limit}
+        changePage={pagingNumberChangedHandler}
+      />
+    );
+  }, [conditions, data, pagingNumberChangedHandler]);
+
+  return (
+    <SearchPageBase
+      ref={searchPageBaseRef}
+      appContainer={appContainer}
+      pages={data?.data}
+      searchingKeyword={keyword}
+      onSelectedPagesByCheckboxesChanged={selectedPagesByCheckboxesChangedHandler}
+      // Components
+      searchControl={searchControl}
+      searchResultListHead={searchResultListHead}
+      searchPager={searchPager}
+    />
+  );
+};

+ 0 - 64
packages/app/src/components/SearchPage/DeleteSelectedPageGroup.tsx

@@ -1,64 +0,0 @@
-import React, { FC, useEffect, useRef } from 'react';
-import { useTranslation } from 'react-i18next';
-import { IndeterminateInputElement } from '~/interfaces/indeterminate-input-elm';
-import { CheckboxType } from '../../interfaces/search';
-
-type Props = {
-  isSelectAllCheckboxDisabled: boolean,
-  selectAllCheckboxType: CheckboxType,
-  onClickDeleteAllButton?: () => void,
-  onClickSelectAllCheckbox?: (nextSelectAllCheckboxType: CheckboxType) => void,
-}
-
-const DeleteSelectedPageGroup:FC<Props> = (props:Props) => {
-  const { t } = useTranslation();
-  const {
-    onClickDeleteAllButton, onClickSelectAllCheckbox, selectAllCheckboxType,
-  } = props;
-
-  const onClickCheckbox = () => {
-    if (onClickSelectAllCheckbox != null) {
-      const next = selectAllCheckboxType === CheckboxType.ALL_CHECKED ? CheckboxType.NONE_CHECKED : CheckboxType.ALL_CHECKED;
-      onClickSelectAllCheckbox(next);
-    }
-  };
-
-  const onClickDeleteButton = () => {
-    if (onClickDeleteAllButton != null) { onClickDeleteAllButton() }
-  };
-
-  const selectAllCheckboxElm = useRef<IndeterminateInputElement>(null);
-  useEffect(() => {
-    if (selectAllCheckboxElm.current != null) {
-      selectAllCheckboxElm.current.indeterminate = selectAllCheckboxType === CheckboxType.INDETERMINATE;
-    }
-  }, [selectAllCheckboxType]);
-
-  return (
-
-    <div className="d-flex align-items-center">
-      <input
-        id="check-all-pages"
-        type="checkbox"
-        name="check-all-pages"
-        className="grw-indeterminate-checkbox"
-        ref={selectAllCheckboxElm}
-        disabled={props.isSelectAllCheckboxDisabled}
-        onClick={onClickCheckbox}
-        checked={selectAllCheckboxType === CheckboxType.ALL_CHECKED}
-      />
-      <button
-        type="button"
-        className="btn text-danger font-weight-light p-0 ml-2"
-        disabled={selectAllCheckboxType === CheckboxType.NONE_CHECKED}
-        onClick={onClickDeleteButton}
-      >
-        <i className="icon-trash"></i>
-        {t('search_result.delete_all_selected_page')}
-      </button>
-    </div>
-  );
-
-};
-
-export default DeleteSelectedPageGroup;

Неке датотеке нису приказане због велике количине промена