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

Merge branch 'master' into fix/87983-88132-parent-empty-page-link

NEEDLEMAN3\tatsu 4 лет назад
Родитель
Сommit
b0b95cfafd
100 измененных файлов с 2856 добавлено и 1346 удалено
  1. 10 1
      .devcontainer/docker-compose.yml
  2. 123 128
      .github/workflows/ci-app.yml
  3. 13 10
      .github/workflows/reusable-app-prod.yml
  4. 7 1
      CHANGELOG.md
  5. 1 1
      lerna.json
  6. 1 1
      package.json
  7. 4 0
      packages/app/.env.development
  8. 3 1
      packages/app/config/ci/.env.local.for-auto-install
  9. 0 3
      packages/app/config/ci/.env.local.for-ci
  10. 5 0
      packages/app/docker/README.md
  11. 19 0
      packages/app/jest.config.js
  12. 9 8
      packages/app/package.json
  13. 4 4
      packages/app/resource/locales/en_US/sandbox.md
  14. 21 1
      packages/app/resource/locales/en_US/translation.json
  15. 3 3
      packages/app/resource/locales/ja_JP/sandbox.md
  16. 22 2
      packages/app/resource/locales/ja_JP/translation.json
  17. 4 4
      packages/app/resource/locales/zh_CN/sandbox.md
  18. 20 2
      packages/app/resource/locales/zh_CN/translation.json
  19. 123 0
      packages/app/resource/search/mappings-es6-for-ci.json
  20. 4 3
      packages/app/src/client/app.jsx
  21. 2 0
      packages/app/src/client/base.jsx
  22. 2 1
      packages/app/src/client/services/ContextExtractor.tsx
  23. 2 2
      packages/app/src/components/Admin/AdminHome/AdminHome.jsx
  24. 2 2
      packages/app/src/components/Admin/App/AppSettingsPageContents.jsx
  25. 2 2
      packages/app/src/components/Admin/Customize/Customize.jsx
  26. 2 2
      packages/app/src/components/Admin/ExportArchiveDataPage.jsx
  27. 2 2
      packages/app/src/components/Admin/FullTextSearchManagement.jsx
  28. 2 2
      packages/app/src/components/Admin/ImportData/ImportDataPageContents.jsx
  29. 2 2
      packages/app/src/components/Admin/LegacySlackIntegration/LegacySlackIntegration.jsx
  30. 2 2
      packages/app/src/components/Admin/MarkdownSetting/MarkDownSettingContents.jsx
  31. 2 2
      packages/app/src/components/Admin/Notification/NotificationSetting.jsx
  32. 2 2
      packages/app/src/components/Admin/Security/SecurityManagementContents.jsx
  33. 2 2
      packages/app/src/components/Admin/SlackIntegration/SlackIntegration.jsx
  34. 95 0
      packages/app/src/components/Admin/UserGroup/UserGroupCreateModal.tsx
  35. 37 44
      packages/app/src/components/Admin/UserGroup/UserGroupPage.tsx
  36. 2 2
      packages/app/src/components/Admin/UserManagement.jsx
  37. 58 13
      packages/app/src/components/Common/Dropdown/PageItemControl.tsx
  38. 86 25
      packages/app/src/components/DescendantsPageList.tsx
  39. 99 0
      packages/app/src/components/LegacyPrivatePagesMigrationModal.tsx
  40. 1 1
      packages/app/src/components/LoginForm.jsx
  41. 34 13
      packages/app/src/components/Navbar/GrowiContextualSubNavigation.tsx
  42. 1 0
      packages/app/src/components/Navbar/GrowiNavbar.tsx
  43. 7 1
      packages/app/src/components/Navbar/GrowiSubNavigation.tsx
  44. 6 1
      packages/app/src/components/Navbar/GrowiSubNavigationSwitcher.jsx
  45. 28 13
      packages/app/src/components/Navbar/SubNavButtons.tsx
  46. 1 1
      packages/app/src/components/Page/DisplaySwitcher.tsx
  47. 0 264
      packages/app/src/components/Page/PageManagement.jsx
  48. 33 19
      packages/app/src/components/Page/TrashPageAlert.jsx
  49. 14 3
      packages/app/src/components/PageCreateModal.jsx
  50. 28 17
      packages/app/src/components/PageDeleteModal.tsx
  51. 10 9
      packages/app/src/components/PageDuplicateModal.jsx
  52. 3 2
      packages/app/src/components/PageEditor/LinkEditModal.jsx
  53. 9 2
      packages/app/src/components/PageList/PageList.tsx
  54. 60 15
      packages/app/src/components/PageList/PageListItemL.tsx
  55. 3 0
      packages/app/src/components/PagePathAutoComplete.jsx
  56. 17 4
      packages/app/src/components/PagePathHierarchicalLink.jsx
  57. 9 10
      packages/app/src/components/PageRenameModal.jsx
  58. 313 0
      packages/app/src/components/PrivateLegacyPages.tsx
  59. 7 9
      packages/app/src/components/PutbackPageModal.jsx
  60. 3 0
      packages/app/src/components/SearchForm.tsx
  61. 44 54
      packages/app/src/components/SearchPage.tsx
  62. 9 5
      packages/app/src/components/SearchPage/SearchControl.tsx
  63. 0 71
      packages/app/src/components/SearchPage/SearchPageForm.jsx
  64. 31 26
      packages/app/src/components/SearchPage/SearchResultContent.tsx
  65. 12 2
      packages/app/src/components/SearchPage/SearchResultList.tsx
  66. 1 1
      packages/app/src/components/SearchPage/SortControl.tsx
  67. 128 20
      packages/app/src/components/SearchPage2/SearchPageBase.tsx
  68. 6 5
      packages/app/src/components/SearchTypeahead.tsx
  69. 2 1
      packages/app/src/components/Sidebar.tsx
  70. 2 2
      packages/app/src/components/Sidebar/PageTree.tsx
  71. 46 36
      packages/app/src/components/Sidebar/PageTree/Item.tsx
  72. 72 43
      packages/app/src/components/Sidebar/PageTree/ItemsTree.tsx
  73. 2 4
      packages/app/src/components/Sidebar/PageTree/PrivateLegacyPagesLink.tsx
  74. 1 1
      packages/app/src/components/TrashPageList.jsx
  75. 3 2
      packages/app/src/components/User/SeenUserInfo.tsx
  76. 1 0
      packages/app/src/interfaces/page.ts
  77. 5 0
      packages/app/src/interfaces/ui.ts
  78. 5 1
      packages/app/src/migrations/20210913153942-migrate-slack-app-integration-schema.js
  79. 6 1
      packages/app/src/server/crowi/index.js
  80. 25 0
      packages/app/src/server/interfaces/page-operation.ts
  81. 1 16
      packages/app/src/server/middlewares/http-error-handler.js
  82. 17 5
      packages/app/src/server/models/obsolete-page.js
  83. 118 0
      packages/app/src/server/models/page-operation.ts
  84. 0 3
      packages/app/src/server/models/page-redirect.ts
  85. 30 57
      packages/app/src/server/models/page.ts
  86. 1 1
      packages/app/src/server/models/subscription.ts
  87. 9 2
      packages/app/src/server/routes/apiv3/forgot-password.js
  88. 14 50
      packages/app/src/server/routes/apiv3/page.js
  89. 15 16
      packages/app/src/server/routes/apiv3/pages.js
  90. 32 2
      packages/app/src/server/routes/forgot-password.ts
  91. 6 2
      packages/app/src/server/routes/index.js
  92. 148 0
      packages/app/src/server/routes/ogp.ts
  93. 3 13
      packages/app/src/server/routes/page.js
  94. 4 3
      packages/app/src/server/routes/search.js
  95. 17 2
      packages/app/src/server/service/config-loader.ts
  96. 33 11
      packages/app/src/server/service/installer.ts
  97. 2 9
      packages/app/src/server/service/page-grant.ts
  98. 101 0
      packages/app/src/server/service/page-operation.ts
  99. 484 202
      packages/app/src/server/service/page.ts
  100. 34 13
      packages/app/src/server/service/search-delegator/elasticsearch.ts

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

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

@@ -9,7 +9,6 @@ on:
       - support/prepare-v**
 
 jobs:
-
   lint:
     runs-on: ubuntu-latest
 
@@ -18,46 +17,44 @@ 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') }}
+          restore-keys: |
+            node_modules-${{ runner.OS }}-node${{ matrix.node-version }}-
+
+      - name: lerna bootstrap
+        run: |
+          npx lerna bootstrap -- --frozen-lockfile
+
+      - name: lerna run lint for plugins
+        run: |
+          yarn lerna run lint --scope @growi/plugin-*
+      - name: lerna run lint for app
+        run: |
+          yarn lerna run lint --scope @growi/app --scope @growi/core --scope @growi/ui
+
+      - name: Slack Notification
+        uses: weseek/ghaction-slack-notification@master
+        if: failure()
+        with:
+          type: ${{ job.status }}
+          job_name: '*Node CI for growi - lint (${{ matrix.node-version }})*'
+          channel: '#ci'
+          isCompactMode: true
+          url: ${{ secrets.SLACK_WEBHOOK_URL }}
 
   test:
     runs-on: ubuntu-latest
@@ -70,55 +67,53 @@ 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') }}
+          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: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 +126,45 @@ 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') }}
+          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 }}

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

@@ -94,10 +94,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
@@ -151,13 +153,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
@@ -194,6 +190,12 @@ jobs:
         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
@@ -254,6 +256,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()

+ 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

+ 1 - 1
lerna.json

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

+ 1 - 1
package.json

@@ -1,6 +1,6 @@
 {
   "name": "growi",
-  "version": "5.0.0-RC.0",
+  "version": "5.0.0-RC.7",
   "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=

+ 5 - 0
packages/app/docker/README.md

@@ -10,10 +10,15 @@ GROWI Official docker image
 Supported tags and respective Dockerfile links
 ------------------------------------------------
 
+<<<<<<< HEAD
 * [`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)
+>>>>>>> origin/release/current
 * [`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.7",
   "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.7",
+    "@growi/plugin-attachment-refs": "^5.0.0-RC.7",
+    "@growi/plugin-lsx": "^5.0.0-RC.7",
+    "@growi/plugin-pukiwiki-like-linker": "^5.0.0-RC.7",
+    "@growi/slack": "^5.0.0-RC.7",
     "@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.7",
     "@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

+ 21 - 1
packages/app/resource/locales/en_US/translation.json

@@ -441,6 +441,8 @@
     "recursively": "Delete pages under this path recursively.",
     "completely": "Delete completely instead of putting it into trash."
   },
+  "deleted_pages": "Page(s) has been deleted",
+  "deleted_pages_completely": "Page(s) has been deleted completely",
   "modal_empty":{
     "empty_the_trash": "Empty The Trash",
     "notice": "The pages deleted completely are unrecoverable."
@@ -636,6 +638,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>.",
@@ -979,7 +997,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

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

@@ -440,6 +440,8 @@
     "recursively": "配下のページも削除します",
     "completely": "ゴミ箱を経由せず、完全に削除します"
   },
+  "deleted_pages": "ページをゴミ箱に入れました",
+  "deleted_pages_completely": "ページを完全に削除しました",
   "modal_empty":{
     "empty_the_trash": "ゴミ箱を空にする",
     "notice": "完全削除したページは元に戻すことができません"
@@ -635,6 +637,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> により固定されています。",
@@ -970,8 +988,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
 

+ 20 - 2
packages/app/resource/locales/zh_CN/translation.json

@@ -418,7 +418,7 @@
 		"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."
-	},
+  },
 	"modal_empty": {
 		"empty_the_trash": "Empty The Trash",
 		"notice": "完全删除的页面是不可恢复的。"
@@ -913,6 +913,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": "登录错误",
@@ -981,7 +997,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"
+        }
+      }
+    }
+  }
+}

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

@@ -22,7 +22,6 @@ 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';
@@ -54,6 +53,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');
 
@@ -86,6 +86,8 @@ Object.assign(componentMappings, {
   'grw-sidebar-wrapper': <Sidebar />,
 
   'search-page': <SearchPage appContainer={appContainer} />,
+  'private-regacy-pages': <PrivateLegacyPages appContainer={appContainer} />,
+
   'all-in-app-notifications': <InAppNotificationPage />,
   'identical-path-page': <IdenticalPathPage />,
 
@@ -96,7 +98,7 @@ Object.assign(componentMappings, {
 
   'trash-page-alert': <TrashPageAlert />,
 
-  'trash-page-list': <TrashPageList />,
+  'trash-page-list-container': <TrashPageList />,
 
   'not-found-page': <NotFoundPage />,
 
@@ -124,7 +126,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 />,

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

@@ -12,6 +12,7 @@ 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';
@@ -52,6 +53,7 @@ const componentMappings = {
   'page-presentation-modal': <PagePresentationModal />,
   'page-accessories-modal': <PageAccessoriesModal />,
   'descendants-page-list-modal': <DescendantsPageListModal />,
+  'page-put-back-modal': <PutbackPageModal />,
 
   'grw-hotkeys-manager': <HotkeysManager />,
 

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

@@ -7,7 +7,7 @@ import {
   useCurrentPageId, usePageIdOnHackmd, usePageUser, useCurrentPagePath, useRevisionCreatedAt, useRevisionId, useRevisionIdHackmdSynced,
   useShareLinkId, useShareLinksNumber, useTemplateTagData, useCurrentUpdatedAt, useCreator, useRevisionAuthor, useCurrentUser, useTargetAndAncestors,
   useSlackChannels, useNotFoundTargetPathOrId, useIsSearchPage, useIsForbidden, useIsIdenticalPath,
-  useIsAclEnabled, useIsSearchServiceConfigured, useIsSearchServiceReachable,
+  useIsAclEnabled, useIsSearchServiceConfigured, useIsSearchServiceReachable, useIsEnabledAttachTitleHeader,
 } from '../../stores/context';
 import {
   useIsDeviceSmallerThanMd, useIsDeviceSmallerThanLg,
@@ -101,6 +101,7 @@ const ContextExtractorOnce: FC = () => {
   useIsAclEnabled(configByContextHydrate.isAclEnabled);
   useIsSearchServiceConfigured(configByContextHydrate.isSearchServiceConfigured);
   useIsSearchServiceReachable(configByContextHydrate.isSearchServiceReachable);
+  useIsEnabledAttachTitleHeader(configByContextHydrate.isEnabledAttachTitleHeader);
 
 
   // Page

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

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

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

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

+ 37 - 44
packages/app/src/components/Admin/UserGroup/UserGroupPage.tsx

@@ -1,14 +1,10 @@
-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';
@@ -17,11 +13,7 @@ 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();
 
@@ -44,11 +36,20 @@ const UserGroupPage: FC<Props> = (props: Props) => {
    * 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();
@@ -111,49 +112,41 @@ const UserGroupPage: FC<Props> = (props: Props) => {
   }, [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
-                submitButtonLabel={t('Create')}
-                onSubmit={createUserGroup}
-              />
-            </div>
           </div>
         ) : (
           t('admin:user_group_management.deny_create_group')
         )
       }
-      <>
-        <UserGroupTable
-          headerLabel={t('admin:user_group_management.group_list')}
-          userGroups={userGroups}
-          childUserGroups={childUserGroups}
-          isAclEnabled={isAclEnabled ?? false}
-          onDelete={showDeleteModal}
-          userGroupRelations={userGroupRelations}
-        />
-        <UserGroupDeleteModal
-          userGroups={userGroups}
-          deleteUserGroup={selectedUserGroup}
-          onDelete={deleteUserGroupById}
-          isShow={isDeleteModalShown}
-          onShow={showDeleteModal}
-          onHide={hideDeleteModal}
-        />
-      </>
-    </Fragment>
+      <UserGroupCreateModal
+        onClickCreateButton={createUserGroup}
+        isShow={isCreateModalShown}
+        onHide={hideCreateModal}
+      />
+      <UserGroupTable
+        headerLabel={t('admin:user_group_management.group_list')}
+        userGroups={userGroups}
+        childUserGroups={childUserGroups}
+        isAclEnabled={isAclEnabled ?? false}
+        onDelete={showDeleteModal}
+        userGroupRelations={userGroupRelations}
+      />
+      <UserGroupDeleteModal
+        userGroups={userGroups}
+        deleteUserGroup={selectedUserGroup}
+        onDelete={deleteUserGroupById}
+        isShow={isDeleteModalShown}
+        onShow={showDeleteModal}
+        onHide={hideDeleteModal}
+      />
+    </div>
   );
 };
 
-/**
- * Wrapper component for using unstated
- */
-const UserGroupPageWrapper = withUnstatedContainers(UserGroupPage, [AppContainer]);
-
-export default UserGroupPageWrapper;
+export default UserGroupPage;

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

+ 58 - 13
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]);
+
+
   // 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 && (
+        { !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 && (
+        { !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);
@@ -204,6 +241,13 @@ export const PageItemControlSubstance = (props: PageItemControlSubstanceProps):
     await onClickRenameMenuItem(pageId);
   }, [onClickRenameMenuItem, pageId]);
 
+  const deleteMenuItemClickHandler = useCallback(async() => {
+    if (onClickDeleteMenuItem == null) {
+      return;
+    }
+    await onClickDeleteMenuItem(pageId, fetchedPageInfo ?? presetPageInfo);
+  }, [onClickDeleteMenuItem, pageId, fetchedPageInfo, presetPageInfo]);
+
   return (
     <Dropdown isOpen={isOpen} toggle={() => setIsOpen(!isOpen)}>
 
@@ -220,6 +264,7 @@ export const PageItemControlSubstance = (props: PageItemControlSubstanceProps):
         onClickBookmarkMenuItem={bookmarkMenuItemClickHandler}
         onClickDuplicateMenuItem={duplicateMenuItemClickHandler}
         onClickRenameMenuItem={renameMenuItemClickHandler}
+        onClickDeleteMenuItem={deleteMenuItemClickHandler}
       />
     </Dropdown>
   );

+ 86 - 25
packages/app/src/components/DescendantsPageList.tsx

@@ -1,39 +1,49 @@
-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,
 } from '~/interfaces/page';
 import { IPagingResult } from '~/interfaces/paging-result';
-import { useCurrentPagePath, useIsGuestUser, useIsSharedUser } from '~/stores/context';
+import { OnDeletedFunction } from '~/interfaces/ui';
+import { useIsGuestUser, useIsSharedUser } from '~/stores/context';
 
-import { useSWRxPageInfoForList, useSWRxPageList } from '~/stores/page';
+import { useSWRxDescendantsPageListForCurrrentPath, useSWRxPageInfoForList, useSWRxPageList } from '~/stores/page';
+import { usePageTreeTermManager } from '~/stores/page-listing';
 
 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,
+  onPagesDeleted?: OnDeletedFunction,
+}
 
 const convertToIPageWithEmptyMeta = (page: IPageHasId): IPageWithMeta => {
   return { pageData: page };
 };
 
-export 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, onPagesDeleted,
+  } = 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);
 
   let pagingResultWithMeta: IPagingResult<IPageWithMeta> | undefined;
 
+  // for mutation
+  const { advance: advancePt } = usePageTreeTermManager();
+
   // initial data
   if (pagingResult != null) {
     const pages = pagingResult.items;
@@ -64,18 +74,20 @@ export const DescendantsPageList = (props: Props): JSX.Element => {
     };
   }
 
+  const pageDeletedHandler: OnDeletedFunction = useCallback((...args) => {
+    toastSuccess(args[2] ? t('deleted_pages_completely') : t('deleted_pages'));
+
+    advancePt();
+
+    if (onPagesDeleted != null) {
+      onPagesDeleted(...args);
+    }
+  }, [advancePt, onPagesDeleted, t]);
+
   function setPageNumber(selectedPageNumber) {
     setActivePage(selectedPageNumber);
   }
 
-  if (error != null) {
-    return (
-      <div className="my-5">
-        <div className="text-danger">{error.message}</div>
-      </div>
-    );
-  }
-
   if (pagingResult == null || pagingResultWithMeta == null) {
     return (
       <div className="wiki">
@@ -90,7 +102,11 @@ export const DescendantsPageList = (props: Props): JSX.Element => {
 
   return (
     <>
-      <PageList pages={pagingResultWithMeta} isEnableActions={!isGuestUser} />
+      <PageList
+        pages={pagingResultWithMeta}
+        isEnableActions={!isGuestUser}
+        onPagesDeleted={pageDeletedHandler}
+      />
 
       { showPager && (
         <div className="my-4">
@@ -107,12 +123,57 @@ export const DescendantsPageList = (props: Props): JSX.Element => {
   );
 };
 
+type Props = {
+  path: string,
+}
+
+export const DescendantsPageList = (props: Props): JSX.Element => {
+  const { path } = props;
+
+  const [activePage, setActivePage] = useState(1);
+
+  const { data: isSharedUser } = useIsSharedUser();
+
+  const { data: pagingResult, error, mutate } = useSWRxPageList(isSharedUser ? null : path, activePage);
+
+  if (error != null) {
+    return (
+      <div className="my-5">
+        <div className="text-danger">{error.message}</div>
+      </div>
+    );
+  }
+
+  return (
+    <DescendantsPageListSubstance
+      pagingResult={pagingResult}
+      activePage={activePage}
+      setActivePage={setActivePage}
+      onPagesDeleted={() => mutate()}
+    />
+  );
+};
+
 export const DescendantsPageListForCurrentPath = (): JSX.Element => {
 
-  const { data: path } = useCurrentPagePath();
+  const [activePage, setActivePage] = useState(1);
+  const { data: pagingResult, error, mutate } = useSWRxDescendantsPageListForCurrrentPath(activePage);
+
+  if (error != null) {
+    return (
+      <div className="my-5">
+        <div className="text-danger">{error.message}</div>
+      </div>
+    );
+  }
 
-  return path != null
-    ? <DescendantsPageList path={path} />
-    : <></>;
+  return (
+    <DescendantsPageListSubstance
+      pagingResult={pagingResult}
+      activePage={activePage}
+      setActivePage={setActivePage}
+      onPagesDeleted={() => mutate()}
+    />
+  );
 
 };

+ 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')}

+ 34 - 13
packages/app/src/components/Navbar/GrowiContextualSubNavigation.tsx

@@ -1,10 +1,13 @@
 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 { OnDeletedFunction } from '~/interfaces/ui';
+import { IPageHasId } from '~/interfaces/page';
+
 import { withUnstatedContainers } from '../UnstatedUtils';
 import EditorContainer from '~/client/services/EditorContainer';
 import {
@@ -12,8 +15,8 @@ import {
   useIsAbleToShowPageEditorModeManager, useIsAbleToShowPageAuthors,
 } from '~/stores/ui';
 import {
-  usePageAccessoriesModal, PageAccessoriesModalContents,
-  usePageDuplicateModal, usePageRenameModal, usePageDeleteModal, usePagePresentationModal,
+  usePageAccessoriesModal, PageAccessoriesModalContents, IPageForPageDuplicateModal,
+  usePageDuplicateModal, usePageRenameModal, IPageForPageRenameModal, usePageDeleteModal, usePagePresentationModal, IPageForPageDeleteModal,
 } from '~/stores/modal';
 
 
@@ -26,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';
@@ -178,17 +180,33 @@ 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) => {
+    openDuplicateModal(page);
   }, [openDuplicateModal]);
 
-  const renameItemClickedHandler = useCallback(async(pageId, revisionId, path) => {
-    openRenameModal(pageId, revisionId, path);
+  const renameItemClickedHandler = useCallback(async(page: IPageForPageRenameModal) => {
+    openRenameModal(page);
   }, [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((pageToDelete: IPageForPageDeleteModal) => {
+    openDeleteModal([pageToDelete], { onDeleted: onDeletedHandler });
+  }, [onDeletedHandler, openDeleteModal]);
 
   const templateMenuItemClickHandler = useCallback(() => {
     setIsPageTempleteModalShown(true);
@@ -200,9 +218,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}
@@ -227,7 +247,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}
@@ -282,6 +302,7 @@ const GrowiContextualSubNavigation = (props) => {
       tags={tagsInfoData?.tags || []}
       tagsUpdatedHandler={tagsUpdatedHandler}
       controls={ControlComponents}
+      additionalClasses={['container-fluid']}
     />
   );
 };

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

@@ -44,6 +44,7 @@ const NavbarRight: FC<NavbarRightProps> = memo((props: NavbarRightProps) => {
         <button
           className="px-md-3 nav-link btn-create-page border-0 bg-transparent"
           type="button"
+          data-testid="newPageBtn"
           onClick={() => openCreateModal(currentPagePath || '')}
         >
           <i className="icon-pencil mr-2"></i>

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

+ 28 - 13
packages/app/src/components/Navbar/SubNavButtons.tsx

@@ -6,27 +6,30 @@ import { useSWRxPageInfo } from '../../stores/page';
 import { useSWRBookmarkInfo } from '../../stores/bookmark';
 import { useSWRxUsersList } from '../../stores/user';
 import { useIsGuestUser } from '~/stores/context';
-import { IPageForPageDeleteModal } from '~/stores/modal';
+import { IPageForPageDeleteModal, 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: IPageForPageDeleteModal) => void,
 }
 
-type SubNavButtonsSubstanceProps= CommonProps & {
+type SubNavButtonsSubstanceProps = CommonProps & {
   pageId: string,
   shareLinkId?: string | null,
   revisionId: string,
@@ -38,7 +41,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 +100,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> => {
@@ -118,10 +122,11 @@ const SubNavButtonsSubstance = (props: SubNavButtonsSubstanceProps): JSX.Element
       pageId,
       revisionId,
       path,
+      isAbleToDeleteCompletely: pageInfo.isAbleToDeleteCompletely,
     };
 
     onClickDeleteMenuItem(pageToDelete);
-  }, [onClickDeleteMenuItem, pageId, path, revisionId]);
+  }, [onClickDeleteMenuItem, pageId, pageInfo.isAbleToDeleteCompletely, path, revisionId]);
 
   if (!isIPageInfoForOperation(pageInfo)) {
     return <></>;
@@ -129,9 +134,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 +162,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}

+ 1 - 1
packages/app/src/components/Page/DisplaySwitcher.tsx

@@ -112,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 - 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/modal';
-
-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);

+ 33 - 19
packages/app/src/components/Page/TrashPageAlert.jsx

@@ -1,4 +1,4 @@
-import React, { useState } from 'react';
+import React, { useState, useCallback } from 'react';
 import PropTypes from 'prop-types';
 
 import { withTranslation } from 'react-i18next';
@@ -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 EmptyTrashModal from '../EmptyTrashModal';
 
-import { useCurrentUpdatedAt } from '~/stores/context';
-import { usePageDeleteModal } from '~/stores/modal';
+import { useCurrentUpdatedAt, useShareLinkId } from '~/stores/context';
+import { usePageDeleteModal, usePutBackPageModal } from '~/stores/modal';
+import { useSWRxPageInfo } from '~/stores/page';
+
+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,11 +51,7 @@ const TrashPageAlert = (props) => {
   }
 
   function openPutbackPageModalHandler() {
-    setIsPutbackPageModalShown(true);
-  }
-
-  function closePutbackPageModalHandler() {
-    setIsPutbackPageModalShown(false);
+    openPutBackPageModal(pageId, path);
   }
 
   function openPageDeleteModalHandler() {
@@ -46,7 +60,13 @@ const TrashPageAlert = (props) => {
       revisionId,
       path,
     };
-    openDeleteModal([pageToDelete]);
+    openDeleteModal(
+      [pageToDelete],
+      {
+        isAbleToDeleteCompletely: pageInfo.isAbleToDeleteCompletely,
+        onDeleted: onDeletedHandler,
+      },
+    );
   }
 
   function renderEmptyButton() {
@@ -77,7 +97,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 +113,6 @@ const TrashPageAlert = (props) => {
           isOpen={isEmptyTrashModalShown}
           onClose={closeEmptyTrashModalHandler}
         />
-        <PutbackPageModal
-          isOpen={isPutbackPageModalShown}
-          onClose={closePutbackPageModalHandler}
-          pageId={pageId}
-          path={path}
-        />
       </>
     );
   }

+ 14 - 3
packages/app/src/components/PageCreateModal.jsx

@@ -165,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>
@@ -179,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>
 
@@ -211,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>
@@ -275,6 +285,7 @@ const PageCreateModal = (props) => {
       size="lg"
       isOpen={isOpened}
       toggle={() => closeCreateModal()}
+      data-testid="page-create-modal"
       className="grw-create-page"
       autoFocus={false}
     >

+ 28 - 17
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';
@@ -11,6 +11,7 @@ import { usePageDeleteModal } from '~/stores/modal';
 import { IDeleteSinglePageApiv1Result, IDeleteManyPageApiv3Result } from '~/interfaces/page';
 
 import ApiErrorMessageList from './PageManagement/ApiErrorMessageList';
+import { isTrashPage } from '^/../core/src/utils/page-path-utils';
 
 
 const deleteIconAndKey = {
@@ -32,12 +33,24 @@ const PageDeleteModal: FC = () => {
   const { data: deleteModalData, close: closeDeleteModal } = usePageDeleteModal();
 
   const isOpened = deleteModalData?.isOpened ?? false;
-  const isAbleToDeleteCompletely = deleteModalData?.isAbleToDeleteCompletely ?? false;
-  const isDeleteCompletelyModal = deleteModalData?.isDeleteCompletelyModal ?? false;
+
+  const isAbleToDeleteCompletely = useMemo(() => {
+    if (deleteModalData != null && deleteModalData.pages != null && deleteModalData.pages.length > 0) {
+      return deleteModalData.pages.every(page => page.isAbleToDeleteCompletely);
+    }
+    return true;
+  }, [deleteModalData]);
+
+  const forceDeleteCompletelyMode = useMemo(() => {
+    if (deleteModalData != null && deleteModalData.pages != null && deleteModalData.pages.length > 0) {
+      return deleteModalData.pages.every(page => isTrashPage(page.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);
@@ -47,7 +60,7 @@ const PageDeleteModal: FC = () => {
   }
 
   function changeIsDeleteCompletelyHandler() {
-    if (!isAbleToDeleteCompletely) {
+    if (forceDeleteCompletelyMode) {
       return;
     }
     setIsDeleteCompletely(!isDeleteCompletely);
@@ -75,8 +88,9 @@ const PageDeleteModal: FC = () => {
           isCompletely,
         });
 
-        if (deleteModalData.onDeleted != null) {
-          deleteModalData.onDeleted(data.paths, data.isRecursively, data.isCompletely);
+        const onDeleted = deleteModalData.opts?.onDeleted;
+        if (onDeleted != null) {
+          onDeleted(data.paths, data.isRecursively, data.isCompletely);
         }
       }
       catch (err) {
@@ -89,7 +103,7 @@ const PageDeleteModal: FC = () => {
     else {
       try {
         const recursively = isDeleteRecursively === true ? true : undefined;
-        const completely = isDeleteCompletely === true ? true : undefined;
+        const completely = forceDeleteCompletelyMode || isDeleteCompletely ? true : undefined;
 
         const page = deleteModalData.pages[0];
 
@@ -100,8 +114,9 @@ const PageDeleteModal: FC = () => {
           completely,
         }) as IDeleteSinglePageApiv1Result;
 
-        if (deleteModalData.onDeleted != null) {
-          deleteModalData.onDeleted(path, isRecursively, isCompletely);
+        const onDeleted = deleteModalData.opts?.onDeleted;
+        if (onDeleted != null) {
+          onDeleted(path, isRecursively, isCompletely);
         }
       }
       catch (err) {
@@ -128,16 +143,12 @@ const PageDeleteModal: FC = () => {
         />
         <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">
@@ -185,7 +196,7 @@ const PageDeleteModal: FC = () => {
           {renderPagePathsToDelete()}
         </div>
         {renderDeleteRecursivelyForm()}
-        {!isDeleteCompletelyModal && renderDeleteCompletelyForm()}
+        { !forceDeleteCompletelyMode && renderDeleteCompletelyForm() }
       </ModalBody>
       <ModalFooter>
         <ApiErrorMessageList errs={errs} />

+ 10 - 9
packages/app/src/components/PageDuplicateModal.jsx

@@ -27,9 +27,10 @@ const PageDuplicateModal = (props) => {
   const config = appContainer.getConfig();
   const isReachable = config.isSearchServiceReachable;
   const { crowi } = appContainer.config;
-  const { data: pagesDataToDuplicate, close: closeDuplicateModal } = usePageDuplicateModal();
+  const { data: duplicateModalData, close: closeDuplicateModal } = usePageDuplicateModal();
 
-  const { isOpened, path, pageId } = pagesDataToDuplicate;
+  const { isOpened, page } = duplicateModalData;
+  const { pageId, path } = page;
 
   const [pageNameInput, setPageNameInput] = useState(path);
 
@@ -40,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;
@@ -50,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]);

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

+ 9 - 2
packages/app/src/components/PageList/PageList.tsx

@@ -3,6 +3,7 @@ import { useTranslation } from 'react-i18next';
 
 import { IPageWithMeta } from '~/interfaces/page';
 import { IPagingResult } from '~/interfaces/paging-result';
+import { OnDeletedFunction } from '~/interfaces/ui';
 
 import { PageListItemL } from './PageListItemL';
 
@@ -10,11 +11,12 @@ import { PageListItemL } from './PageListItemL';
 type Props = {
   pages: IPagingResult<IPageWithMeta>,
   isEnableActions?: boolean,
+  onPagesDeleted?: OnDeletedFunction,
 }
 
 const PageList = (props: Props): JSX.Element => {
   const { t } = useTranslation();
-  const { pages, isEnableActions } = props;
+  const { pages, isEnableActions, onPagesDeleted } = props;
 
   if (pages == null) {
     return (
@@ -27,7 +29,12 @@ const PageList = (props: Props): JSX.Element => {
   }
 
   const pageList = pages.items.map(page => (
-    <PageListItemL key={page.pageData._id} page={page} isEnableActions={isEnableActions} />
+    <PageListItemL
+      key={page.pageData._id}
+      page={page}
+      isEnableActions={isEnableActions}
+      onPageDeleted={onPagesDeleted}
+    />
   ));
 
   if (pageList.length === 0) {

+ 60 - 15
packages/app/src/components/PageList/PageListItemL.tsx

@@ -12,13 +12,16 @@ import urljoin from 'url-join';
 import { UserPicture, PageListMeta } from '@growi/ui';
 import { DevidedPagePath } from '@growi/core';
 import { useIsDeviceSmallerThanLg } from '~/stores/ui';
-import { usePageRenameModal, usePageDuplicateModal, usePageDeleteModal } from '~/stores/modal';
+import {
+  usePageRenameModal, usePageDuplicateModal, usePageDeleteModal, usePutBackPageModal,
+} from '~/stores/modal';
 import {
   IPageInfoAll, IPageWithMeta, isIPageInfoForEntity, isIPageInfoForListing,
 } from '~/interfaces/page';
 import { IPageSearchMeta, isIPageSearchMeta } from '~/interfaces/search';
+import { OnDeletedFunction } from '~/interfaces/ui';
 
-import { PageItemControl } from '../Common/Dropdown/PageItemControl';
+import { ForceHideMenuItems, PageItemControl } from '../Common/Dropdown/PageItemControl';
 import LinkedPagePath from '~/models/linked-page-path';
 import PagePathHierarchicalLink from '../PagePathHierarchicalLink';
 import { ISelectable } from '~/client/interfaces/selectable-all';
@@ -27,17 +30,20 @@ type Props = {
   page: IPageWithMeta | IPageWithMeta<IPageInfoAll & IPageSearchMeta>,
   isSelected?: boolean, // is item selected(focused)
   isEnableActions?: boolean,
+  forceHideMenuItems?: ForceHideMenuItems,
   showPageUpdatedTime?: boolean, // whether to show page's updated time at the top-right corner of item
   onCheckboxChanged?: (isChecked: boolean, pageId: string) => void,
   onClickItem?: (pageId: string) => void,
+  onPageDeleted?: OnDeletedFunction,
 }
 
 const PageListItemLSubstance: ForwardRefRenderFunction<ISelectable, Props> = (props: Props, ref): JSX.Element => {
   const {
     // todo: refactoring variable name to clear what changed
     page: { pageData, pageMeta }, isSelected, isEnableActions,
+    forceHideMenuItems,
     showPageUpdatedTime,
-    onClickItem, onCheckboxChanged,
+    onClickItem, onCheckboxChanged, onPageDeleted,
   } = props;
 
   const inputRef = useRef<HTMLInputElement>(null);
@@ -62,16 +68,18 @@ const PageListItemLSubstance: ForwardRefRenderFunction<ISelectable, Props> = (pr
   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
@@ -85,24 +93,45 @@ const PageListItemLSubstance: ForwardRefRenderFunction<ISelectable, Props> = (pr
   }, [isDeviceSmallerThanLg, onClickItem, pageData._id]);
 
   const duplicateMenuItemClickHandler = useCallback(() => {
-    const { _id: pageId, path } = pageData;
-    openDuplicateModal(pageId, path);
+    const page = {
+      pageId: pageData._id,
+      path: pageData.path,
+    };
+    openDuplicateModal(page);
   }, [openDuplicateModal, pageData]);
 
   const renameMenuItemClickHandler = useCallback(() => {
-    const { _id: pageId, revision: revisionId, path } = pageData;
-    openRenameModal(pageId, revisionId as string, path);
+    const page = {
+      pageId: pageData._id,
+      revisionId: pageData.revision as string,
+      path: pageData.path,
+    };
+    openRenameModal(page);
   }, [openRenameModal, pageData]);
 
-  const deleteMenuItemClickHandler = useCallback(() => {
+
+  const deleteMenuItemClickHandler = useCallback((_id, pageInfo) => {
     const { _id: pageId, revision: revisionId, path } = pageData;
-    openDeleteModal([{ pageId, revisionId: revisionId as string, path }]);
-  }, [openDeleteModal, pageData]);
+    const isAbleToDeleteCompletely = pageInfo.isAbleToDeleteCompletely;
+    const pageToDelete = {
+      pageId, revisionId: revisionId as string, path, isAbleToDeleteCompletely,
+    };
+
+    // open modal
+    openDeleteModal([pageToDelete], { onDeleted: onPageDeleted });
+  }, [pageData, openDeleteModal, onPageDeleted]);
+
+  const revertMenuItemClickHandler = useCallback(() => {
+    const { _id: pageId, path } = pageData;
+    openPutBackPageModal(pageId, path);
+  }, [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}
@@ -129,7 +158,10 @@ const PageListItemLSubstance: ForwardRefRenderFunction<ISelectable, Props> = (pr
           <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>
               ) }
@@ -144,7 +176,18 @@ const PageListItemLSubstance: ForwardRefRenderFunction<ISelectable, Props> = (pr
                 <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>
@@ -161,10 +204,12 @@ const PageListItemLSubstance: ForwardRefRenderFunction<ISelectable, Props> = (pr
                 <PageItemControl
                   pageId={pageData._id}
                   pageInfo={pageMeta}
-                  onClickDeleteMenuItem={deleteMenuItemClickHandler}
-                  onClickRenameMenuItem={renameMenuItemClickHandler}
                   isEnableActions={isEnableActions}
+                  forceHideMenuItems={forceHideMenuItems}
+                  onClickRenameMenuItem={renameMenuItemClickHandler}
                   onClickDuplicateMenuItem={duplicateMenuItemClickHandler}
+                  onClickDeleteMenuItem={deleteMenuItemClickHandler}
+                  onClickRevertMenuItem={revertMenuItemClickHandler}
                 />
               </div>
             </div>

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

+ 9 - 10
packages/app/src/components/PageRenameModal.jsx

@@ -29,11 +29,10 @@ const PageRenameModal = (props) => {
   } = props;
 
   const { crowi } = appContainer.config;
-  const { data: pagesDataToRename, close: closeRenameModal } = usePageRenameModal();
+  const { data: renameModalData, close: closeRenameModal } = usePageRenameModal();
 
-  const {
-    isOpened, path, revisionId, pageId,
-  } = pagesDataToRename;
+  const { isOpened, page } = renameModalData;
+  const { pageId, revisionId, path } = page;
 
   const [pageNameInput, setPageNameInput] = useState('');
 
@@ -83,7 +82,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;
@@ -93,15 +92,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]);

+ 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.pageData._id))
+      .map(pageWithMeta => ({ pageId: pageWithMeta.pageData._id, path: pageWithMeta.pageData.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 />
+    </>
+  );
+};

+ 7 - 9
packages/app/src/components/PutbackPageModal.jsx

@@ -7,15 +7,19 @@ import {
 
 import { withTranslation } 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,
+    t,
   } = props;
 
+  const { data: pageDataToRevert, close: closePutBackPageModal } = usePutBackPageModal();
+  const { isOpened, pageId, path } = pageDataToRevert;
+
   const [errs, setErrs] = useState(null);
 
   const [isPutbackRecursively, setIsPutbackRecursively] = useState(true);
@@ -50,8 +54,8 @@ const PutBackPageModal = (props) => {
   }
 
   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>
@@ -88,12 +92,6 @@ 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,
 };
 
 

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

+ 44 - 54
packages/app/src/components/SearchPage.tsx

@@ -8,14 +8,14 @@ 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 { useIsSearchServiceConfigured, useIsSearchServiceReachable } from '~/stores/context';
+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 { SearchPageBase } from './SearchPage2/SearchPageBase';
+import { IReturnSelectedPageIds, SearchPageBase, usePageDeleteModalForBulkDeletion } from './SearchPage2/SearchPageBase';
 
 
 // TODO: replace with "customize:showPageLimitationS"
@@ -46,17 +46,25 @@ const SearchResultListHead = React.memo((props: SearchResultListHeadProps): JSX.
   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="d-flex align-items-center justify-content-between">
+    <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">{`"${searchingKeyword}"`}</span>
+        <span className="search-result-keyword">{`${searchingKeyword}`}</span>
         <span className="ml-3">{`${leftNum}-${rightNum}`} / {total}</span>
         { took != null && (
           <span className="ml-3 text-muted">({took}ms)</span>
         ) }
       </div>
-      <div className="input-group search-result-select-group ml-4 d-lg-flex d-none">
+      <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>
@@ -101,26 +109,24 @@ export const SearchPage = (props: Props): JSX.Element => {
   const initQ = (Array.isArray(parsedQueries) ? parsedQueries.join(' ') : parsedQueries) ?? '';
 
   const [keyword, setKeyword] = useState<string>(initQ);
-  const [configurationsByControl, setConfigurationsByControl] = useState<Partial<ISearchConfigurations>>({
-  });
-  const [configurationsByPagination, setConfigurationsByPagination] = useState<Partial<ISearchConfigurations>>({
-    limit: INITIAL_PAGIONG_SIZE,
-  });
+  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|null>(null);
+  const searchPageBaseRef = useRef<ISelectableAll & IReturnSelectedPageIds|null>(null);
 
-  const { data: isSearchServiceConfigured } = useIsSearchServiceConfigured();
   const { data: isSearchServiceReachable } = useIsSearchServiceReachable();
 
-  const { data, conditions } = useSWRxFullTextSearch(keyword, {
-    limit: INITIAL_PAGIONG_SIZE,
+  const { data, conditions, mutate } = useSWRxFullTextSearch(keyword, {
     ...configurationsByControl,
-    ...configurationsByPagination,
+    offset,
+    limit,
   });
 
   const searchInvokedHandler = useCallback((_keyword: string, newConfigurations: Partial<ISearchConfigurations>) => {
     setKeyword(_keyword);
+    setOffset(0);
     setConfigurationsByControl(newConfigurations);
   }, []);
 
@@ -157,13 +163,16 @@ export const SearchPage = (props: Props): JSX.Element => {
     }
   }, []);
 
+  const pagingSizeChangedHandler = useCallback((pagingSize: number) => {
+    setOffset(0);
+    setLimit(pagingSize);
+    mutate();
+  }, [mutate]);
+
   const pagingNumberChangedHandler = useCallback((activePage: number) => {
-    const currentLimit = configurationsByPagination.limit ?? INITIAL_PAGIONG_SIZE;
-    setConfigurationsByPagination({
-      ...configurationsByPagination,
-      offset: (activePage - 1) * currentLimit,
-    });
-  }, [configurationsByPagination]);
+    setOffset((activePage - 1) * limit);
+    mutate();
+  }, [limit, mutate]);
 
   const initialSearchConditions: Partial<ISearchConditions> = useMemo(() => {
     return {
@@ -172,6 +181,9 @@ export const SearchPage = (props: Props): JSX.Element => {
     };
   }, [initQ]);
 
+  // for bulk deletion
+  const deleteAllButtonClickedHandler = usePageDeleteModalForBulkDeletion(data, searchPageBaseRef, () => mutate());
+
   // push state
   useEffect(() => {
     const newUrl = new URL('/_search', 'http://example.com');
@@ -180,8 +192,6 @@ export const SearchPage = (props: Props): JSX.Element => {
   }, [keyword]);
   const hitsCount = data?.meta.hitsCount;
 
-  const { offset, limit } = conditions;
-
   const deleteAllControl = useMemo(() => {
     const isDisabled = hitsCount === 0;
 
@@ -195,25 +205,29 @@ export const SearchPage = (props: Props): JSX.Element => {
           type="button"
           className="btn btn-outline-danger border-0 px-2"
           disabled={isDisabled}
-          onClick={() => null /* TODO implement */}
+          onClick={deleteAllButtonClickedHandler}
         >
           <i className="icon-fw icon-trash"></i>
           {t('search_result.delete_all_selected_page')}
         </button>
       </OperateAllControl>
     );
-  }, [hitsCount, selectAllCheckboxChangedHandler, t]);
+  }, [deleteAllButtonClickedHandler, hitsCount, selectAllCheckboxChangedHandler, t]);
 
   const searchControl = useMemo(() => {
+    if (!isSearchServiceReachable) {
+      return <></>;
+    }
     return (
       <SearchControl
+        isSearchServiceReachable={isSearchServiceReachable}
         initialSearchConditions={initialSearchConditions}
         onSearchInvoked={searchInvokedHandler}
         deleteAllControl={deleteAllControl}
       >
       </SearchControl>
     );
-  }, [deleteAllControl, initialSearchConditions, searchInvokedHandler]);
+  }, [deleteAllControl, initialSearchConditions, isSearchServiceReachable, searchInvokedHandler]);
 
   const searchResultListHead = useMemo(() => {
     if (data == null) {
@@ -225,10 +239,10 @@ export const SearchPage = (props: Props): JSX.Element => {
         searchingKeyword={keyword}
         offset={offset}
         pagingSize={limit}
-        onPagingSizeChanged={() => {}}
+        onPagingSizeChanged={pagingSizeChangedHandler}
       />
     );
-  }, [data, keyword, limit, offset]);
+  }, [data, keyword, limit, offset, pagingSizeChangedHandler]);
 
   const searchPager = useMemo(() => {
     // when pager is not needed
@@ -243,35 +257,11 @@ export const SearchPage = (props: Props): JSX.Element => {
       <PaginationWrapper
         activePage={Math.floor(offset / limit) + 1}
         totalItemsCount={total}
-        pagingLimit={configurationsByPagination?.limit}
+        pagingLimit={limit}
         changePage={pagingNumberChangedHandler}
       />
     );
-  }, [conditions, configurationsByPagination?.limit, data, pagingNumberChangedHandler]);
-
-  if (!isSearchServiceConfigured) {
-    return (
-      <div className="grw-container-convertible">
-        <div className="row mt-5">
-          <div className="col text-muted">
-            <h1>Search service is not configured in this system.</h1>
-          </div>
-        </div>
-      </div>
-    );
-  }
-
-  if (!isSearchServiceReachable) {
-    return (
-      <div className="grw-container-convertible">
-        <div className="row mt-5">
-          <div className="col text-muted">
-            <h1>Search service occures errors. Please contact to administrators of this system.</h1>
-          </div>
-        </div>
-      </div>
-    );
-  }
+  }, [conditions, data, pagingNumberChangedHandler]);
 
   return (
     <SearchPageBase

+ 9 - 5
packages/app/src/components/SearchPage/SearchControl.tsx

@@ -6,11 +6,12 @@ import { useTranslation } from 'react-i18next';
 import { SORT_AXIS, SORT_ORDER } from '~/interfaces/search';
 import { ISearchConditions, ISearchConfigurations } from '~/stores/search';
 
-import SearchPageForm from './SearchPageForm';
 import SearchOptionModal from './SearchOptionModal';
 import SortControl from './SortControl';
+import SearchForm from '../SearchForm';
 
 type Props = {
+  isSearchServiceReachable: boolean,
   initialSearchConditions: Partial<ISearchConditions>,
 
   onSearchInvoked: (keyword: string, configurations: Partial<ISearchConfigurations>) => void,
@@ -21,6 +22,7 @@ type Props = {
 const SearchControl: FC <Props> = React.memo((props: Props) => {
 
   const {
+    isSearchServiceReachable,
     initialSearchConditions,
     onSearchInvoked,
     deleteAllControl,
@@ -45,8 +47,8 @@ const SearchControl: FC <Props> = React.memo((props: Props) => {
     });
   }, [keyword, sort, order, includeTrashPages, includeUserPages, onSearchInvoked]);
 
-  const searchFormChangedHandler = useCallback(({ keyword }) => {
-    setKeyword(keyword as string);
+  const searchFormSubmittedHandler = useCallback((input: string) => {
+    setKeyword(input);
   }, []);
 
   const changeSortHandler = useCallback((nextSort: SORT_AXIS, nextOrder: SORT_ORDER) => {
@@ -62,9 +64,11 @@ const SearchControl: FC <Props> = React.memo((props: Props) => {
     <div className="position-sticky fixed-top shadow-sm">
       <div className="grw-search-page-nav d-flex py-3 align-items-center">
         <div className="flex-grow-1 mx-4">
-          <SearchPageForm
+          <SearchForm
+            isSearchServiceReachable={isSearchServiceReachable}
             keyword={keyword}
-            onSearchFormChanged={searchFormChangedHandler}
+            disableIncrementalSearch
+            onSubmit={searchFormSubmittedHandler}
           />
         </div>
 

+ 0 - 71
packages/app/src/components/SearchPage/SearchPageForm.jsx

@@ -1,71 +0,0 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-
-import { withUnstatedContainers } from '../UnstatedUtils';
-import AppContainer from '~/client/services/AppContainer';
-import SearchForm from '../SearchForm';
-import loggerFactory from '~/utils/logger';
-
-const logger = loggerFactory('growi:searchPageForm');
-
-// Search.SearchForm
-class SearchPageForm extends React.Component {
-
-  constructor(props) {
-    super(props);
-
-    this.state = {
-      keyword: this.props.keyword,
-      searchedKeyword: this.props.keyword,
-    };
-
-    this.search = this.search.bind(this);
-    this.onInputChange = this.onInputChange.bind(this);
-  }
-
-  search() {
-    if (this.props.onSearchFormChanged != null) {
-      const keyword = this.state.keyword;
-      this.props.onSearchFormChanged({ keyword });
-      this.setState({ searchedKeyword: keyword });
-    }
-    else {
-      throw new Error('onSearchFormChanged method is null');
-    }
-  }
-
-  onInputChange(input) { // for only submitting with button
-    this.setState({ keyword: input });
-  }
-
-  render() {
-    const { appContainer } = this.props;
-    const isSearchServiceReachable = appContainer.getConfig().isSearchServiceReachable;
-
-    return (
-      <SearchForm
-        isSearchServiceReachable={isSearchServiceReachable}
-        onSubmit={this.search}
-        keyword={this.state.searchedKeyword}
-        onInputChange={this.onInputChange}
-      />
-    );
-  }
-
-}
-
-/**
- * Wrapper component for using unstated
- */
-const SearchPageFormWrapper = withUnstatedContainers(SearchPageForm, [AppContainer]);
-
-SearchPageForm.propTypes = {
-  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
-
-  keyword: PropTypes.string,
-  onSearchFormChanged: PropTypes.func,
-};
-SearchPageForm.defaultProps = {
-};
-
-export default SearchPageFormWrapper;

+ 31 - 26
packages/app/src/components/SearchPage/SearchResultContent.tsx

@@ -1,9 +1,9 @@
 import React, {
   FC, useCallback, useEffect, useRef,
 } from 'react';
+import { useTranslation } from 'react-i18next';
 
 import { DropdownItem } from 'reactstrap';
-import { useTranslation } from 'react-i18next';
 
 import { IPageWithMeta } from '~/interfaces/page';
 import { IPageSearchMeta } from '~/interfaces/search';
@@ -15,9 +15,11 @@ import AppContainer from '../../client/services/AppContainer';
 import { smoothScrollIntoView } from '~/client/util/smooth-scroll';
 import { GrowiSubNavigation } from '../Navbar/GrowiSubNavigation';
 import { SubNavButtons } from '../Navbar/SubNavButtons';
-import { AdditionalMenuItemsRendererProps } from '../Common/Dropdown/PageItemControl';
+import { AdditionalMenuItemsRendererProps, ForceHideMenuItems } from '../Common/Dropdown/PageItemControl';
 
-import { usePageDuplicateModal, usePageRenameModal, usePageDeleteModal } from '~/stores/modal';
+import {
+  usePageDuplicateModal, usePageRenameModal, usePageDeleteModal,
+} from '~/stores/modal';
 
 
 type AdditionalMenuItemsProps = AdditionalMenuItemsRendererProps & {
@@ -31,15 +33,11 @@ const AdditionalMenuItems = (props: AdditionalMenuItemsProps): JSX.Element => {
   const { pageId, revisionId } = props;
 
   return (
-    <>
-      <DropdownItem divider />
-
-      {/* Export markdown */}
-      <DropdownItem onClick={() => exportAsMarkdown(pageId, revisionId, 'md')}>
-        <i className="icon-fw icon-cloud-download"></i>
-        {t('export_bulk.export_page_markdown')}
-      </DropdownItem>
-    </>
+    // Export markdown
+    <DropdownItem onClick={() => exportAsMarkdown(pageId, revisionId, 'md')}>
+      <i className="icon-fw icon-cloud-download"></i>
+      {t('export_bulk.export_page_markdown')}
+    </DropdownItem>
   );
 };
 
@@ -51,6 +49,7 @@ type Props ={
   pageWithMeta : IPageWithMeta<IPageSearchMeta>,
   highlightKeywords?: string[],
   showPageControlDropdown?: boolean,
+  forceHideMenuItems?: ForceHideMenuItems,
 }
 
 const scrollTo = (scrollElement:HTMLElement) => {
@@ -97,26 +96,26 @@ export const SearchResultContent: FC<Props> = (props: Props) => {
     pageWithMeta,
     highlightKeywords,
     showPageControlDropdown,
+    forceHideMenuItems,
   } = props;
 
+  const page = pageWithMeta?.pageData;
   const { open: openDuplicateModal } = usePageDuplicateModal();
   const { open: openRenameModal } = usePageRenameModal();
   const { open: openDeleteModal } = usePageDeleteModal();
 
-  const page = pageWithMeta?.pageData;
-
   const growiRenderer = appContainer.getRenderer('searchresult');
 
 
-  const duplicateItemClickedHandler = useCallback(async(pageId, path) => {
-    openDuplicateModal(pageId, path);
+  const duplicateItemClickedHandler = useCallback(async(pageToDuplicate) => {
+    openDuplicateModal(pageToDuplicate);
   }, [openDuplicateModal]);
 
-  const renameItemClickedHandler = useCallback(async(pageId, revisionId, path) => {
-    openRenameModal(pageId, revisionId, path);
+  const renameItemClickedHandler = useCallback(async(pageToRename) => {
+    openRenameModal(pageToRename);
   }, [openRenameModal]);
 
-  const deleteItemClickedHandler = useCallback(async(pageToDelete) => {
+  const deleteItemClickedHandler = useCallback((pageToDelete) => {
     openDeleteModal([pageToDelete]);
   }, [openDeleteModal]);
 
@@ -137,7 +136,9 @@ export const SearchResultContent: FC<Props> = (props: Props) => {
             revisionId={revisionId}
             path={page.path}
             showPageControlDropdown={showPageControlDropdown}
+            forceHideMenuItems={forceHideMenuItems}
             additionalMenuItemRenderer={props => <AdditionalMenuItems {...props} pageId={page._id} revisionId={revisionId} />}
+            isCompactMode
             onClickDuplicateMenuItem={duplicateItemClickedHandler}
             onClickRenameMenuItem={renameItemClickedHandler}
             onClickDeleteMenuItem={deleteItemClickedHandler}
@@ -147,18 +148,22 @@ export const SearchResultContent: FC<Props> = (props: Props) => {
         </div>
       </>
     );
-  }, [page, showPageControlDropdown, duplicateItemClickedHandler, renameItemClickedHandler, deleteItemClickedHandler]);
+  }, [page, showPageControlDropdown, forceHideMenuItems, duplicateItemClickedHandler, renameItemClickedHandler, deleteItemClickedHandler]);
 
   // return if page is null
   if (page == null) return <></>;
 
   return (
-    <div key={page._id} className="search-result-page grw-page-path-text-muted-container d-flex flex-column">
-      <GrowiSubNavigation
-        page={page}
-        controls={ControlComponents}
-      />
-      <div className="search-result-page-content" ref={scrollElementRef}>
+    <div key={page._id} data-testid="search-result-content" className="search-result-content grw-page-path-text-muted-container d-flex flex-column">
+      <div className="grw-subnav-append-shadow-container">
+        <GrowiSubNavigation
+          page={page}
+          controls={ControlComponents}
+          isCompactMode
+          additionalClasses={['px-4']}
+        />
+      </div>
+      <div className="search-result-content-body-container" ref={scrollElementRef}>
         <RevisionLoader
           growiRenderer={growiRenderer}
           pageId={page._id}

+ 12 - 2
packages/app/src/components/SearchPage/SearchResultList.tsx

@@ -7,6 +7,9 @@ import { IPageWithMeta, isIPageInfoForListing } from '~/interfaces/page';
 import { IPageSearchMeta } from '~/interfaces/search';
 import { useIsGuestUser } from '~/stores/context';
 import { useSWRxPageInfoForList } from '~/stores/page';
+import { usePageTreeTermManager } from '~/stores/page-listing';
+import { useFullTextSearchTermManager } from '~/stores/search';
+import { ForceHideMenuItems } from '../Common/Dropdown/PageItemControl';
 
 import { PageListItemL } from '../PageList/PageListItemL';
 
@@ -14,6 +17,7 @@ import { PageListItemL } from '../PageList/PageListItemL';
 type Props = {
   pages: IPageWithMeta<IPageSearchMeta>[],
   selectedPageId?: string,
+  forceHideMenuItems?: ForceHideMenuItems,
   onPageSelected?: (page?: IPageWithMeta<IPageSearchMeta>) => void,
   onCheckboxChanged?: (isChecked: boolean, pageId: string) => void,
 }
@@ -21,6 +25,7 @@ type Props = {
 const SearchResultListSubstance: ForwardRefRenderFunction<ISelectableAll, Props> = (props:Props, ref) => {
   const {
     pages, selectedPageId,
+    forceHideMenuItems,
     onPageSelected,
   } = props;
 
@@ -31,6 +36,10 @@ const SearchResultListSubstance: ForwardRefRenderFunction<ISelectableAll, Props>
   const { data: isGuestUser } = useIsGuestUser();
   const { data: idToPageInfo } = useSWRxPageInfoForList(pageIdsWithNoSnippet);
 
+  // for mutation
+  const { advance: advancePt } = usePageTreeTermManager();
+  const { advance: advanceFts } = useFullTextSearchTermManager();
+
   const itemsRef = useRef<(ISelectable|null)[]>([]);
 
   // publish selectAll()
@@ -56,7 +65,6 @@ const SearchResultListSubstance: ForwardRefRenderFunction<ISelectableAll, Props>
     }
   }, [onPageSelected, pages]);
 
-
   let injectedPage;
   // inject data to list
   if (idToPageInfo != null) {
@@ -79,7 +87,7 @@ const SearchResultListSubstance: ForwardRefRenderFunction<ISelectableAll, Props>
   }
 
   return (
-    <ul className="page-list-ul list-group list-group-flush">
+    <ul data-testid="search-result-list" className="page-list-ul list-group list-group-flush">
       { (injectedPage ?? pages).map((page, i) => {
         return (
           <PageListItemL
@@ -89,8 +97,10 @@ const SearchResultListSubstance: ForwardRefRenderFunction<ISelectableAll, Props>
             page={page}
             isEnableActions={!isGuestUser}
             isSelected={page.pageData._id === selectedPageId}
+            forceHideMenuItems={forceHideMenuItems}
             onClickItem={clickItemHandler}
             onCheckboxChanged={props.onCheckboxChanged}
+            onPageDeleted={() => { advancePt(); advanceFts() }}
           />
         );
       })}

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

@@ -29,7 +29,7 @@ const SortControl: FC <Props> = (props: Props) => {
 
   return (
     <>
-      <div className="input-group">
+      <div className="input-group flex-nowrap">
         <div className="input-group-prepend">
           <div className="input-group-text border text-muted" id="btnGroupAddon">
             {renderOrderIcon()}

+ 128 - 20
packages/app/src/components/SearchPage2/SearchPageBase.tsx

@@ -1,31 +1,46 @@
 import React, {
-  forwardRef, ForwardRefRenderFunction, useEffect, useImperativeHandle, useRef, useState,
+  forwardRef, ForwardRefRenderFunction, useCallback, useEffect, useImperativeHandle, useRef, useState,
 } from 'react';
+import { useTranslation } from 'react-i18next';
 import { ISelectableAll } from '~/client/interfaces/selectable-all';
 import AppContainer from '~/client/services/AppContainer';
+import { toastSuccess } from '~/client/util/apiNotification';
 import { IPageWithMeta } from '~/interfaces/page';
-import { IPageSearchMeta } from '~/interfaces/search';
-import { useIsGuestUser } from '~/stores/context';
+import { IFormattedSearchResult, IPageSearchMeta } from '~/interfaces/search';
+import { OnDeletedFunction } from '~/interfaces/ui';
+import { useIsGuestUser, useIsSearchServiceConfigured, useIsSearchServiceReachable } from '~/stores/context';
+import { IPageForPageDeleteModal, usePageDeleteModal } from '~/stores/modal';
+import { usePageTreeTermManager } from '~/stores/page-listing';
+import { ForceHideMenuItems } from '../Common/Dropdown/PageItemControl';
 
 import { SearchResultContent } from '../SearchPage/SearchResultContent';
 import { SearchResultList } from '../SearchPage/SearchResultList';
 
+
+export interface IReturnSelectedPageIds {
+  getSelectedPageIds?: () => Set<string>,
+}
+
+
 type Props = {
   appContainer: AppContainer,
 
   pages?: IPageWithMeta<IPageSearchMeta>[],
 
+  forceHideMenuItems?: ForceHideMenuItems,
+
   onSelectedPagesByCheckboxesChanged?: (selectedCount: number, totalCount: number) => void,
 
   searchControl: React.ReactNode,
-  searchResultListHead: React.ReactNode,
+  searchResultListHead: React.ReactElement,
   searchPager: React.ReactNode,
 }
 
-const SearchPageBaseSubstance: ForwardRefRenderFunction<ISelectableAll, Props> = (props:Props, ref) => {
+const SearchPageBaseSubstance: ForwardRefRenderFunction<ISelectableAll & IReturnSelectedPageIds, Props> = (props:Props, ref) => {
   const {
     appContainer,
     pages,
+    forceHideMenuItems,
     onSelectedPagesByCheckboxesChanged,
     searchControl, searchResultListHead, searchPager,
   } = props;
@@ -33,6 +48,8 @@ const SearchPageBaseSubstance: ForwardRefRenderFunction<ISelectableAll, Props> =
   const searchResultListRef = useRef<ISelectableAll|null>(null);
 
   const { data: isGuestUser } = useIsGuestUser();
+  const { data: isSearchServiceConfigured } = useIsSearchServiceConfigured();
+  const { data: isSearchServiceReachable } = useIsSearchServiceReachable();
 
   // TODO get search keywords and split
   // ref: RevisionRenderer
@@ -62,6 +79,9 @@ const SearchPageBaseSubstance: ForwardRefRenderFunction<ISelectableAll, Props> =
 
       selectedPageIdsByCheckboxes.clear();
     },
+    getSelectedPageIds: () => {
+      return selectedPageIdsByCheckboxes;
+    },
   }));
 
   const checkboxChangedHandler = (isChecked: boolean, pageId: string) => {
@@ -103,11 +123,38 @@ const SearchPageBaseSubstance: ForwardRefRenderFunction<ISelectableAll, Props> =
     }
   }, [onSelectedPagesByCheckboxesChanged, pages, selectedPageIdsByCheckboxes]);
 
-  const isLoading = pages == null;
+  useEffect(() => {
+    if (searchResultListHead != null && searchResultListHead.props != null) {
+      setHightlightKeywords(searchResultListHead.props.searchingKeyword);
+    }
+  }, [searchResultListHead]);
+  if (!isSearchServiceConfigured) {
+    return (
+      <div className="grw-container-convertible">
+        <div className="row mt-5">
+          <div className="col text-muted">
+            <h1>Search service is not configured in this system.</h1>
+          </div>
+        </div>
+      </div>
+    );
+  }
+
+  if (!isSearchServiceReachable) {
+    return (
+      <div className="grw-container-convertible">
+        <div className="row mt-5">
+          <div className="col text-muted">
+            <h1>Search service occures errors. Please contact to administrators of this system.</h1>
+          </div>
+        </div>
+      </div>
+    );
+  }
 
   return (
     <div className="content-main">
-      <div className="search-result d-flex" id="search-result">
+      <div className="search-result-base d-flex" data-testid="search-result-base">
 
         <div className="mw-0 flex-grow-1 flex-basis-0 border boder-gray search-result-list" id="search-result-list">
 
@@ -115,27 +162,33 @@ const SearchPageBaseSubstance: ForwardRefRenderFunction<ISelectableAll, Props> =
 
           <div className="search-result-list-scroll">
 
-            { isLoading && (
+            {/* Loading */}
+            { pages == null && (
               <div className="mw-0 flex-grow-1 flex-basis-0 m-5 text-muted text-center">
                 <i className="fa fa-2x fa-spinner fa-pulse mr-1"></i>
               </div>
             ) }
 
-            { !isLoading && (
+            {/* Loaded */}
+            { pages != null && (
               <>
-                <div className="my-3 px-md-4">
+                <div className="my-3 px-md-4 px-3">
                   {searchResultListHead}
                 </div>
-                <div className="page-list px-md-4">
-                  <SearchResultList
-                    ref={searchResultListRef}
-                    // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
-                    pages={pages!}
-                    selectedPageId={selectedPageWithMeta?.pageData._id}
-                    onPageSelected={page => setSelectedPageWithMeta(page)}
-                    onCheckboxChanged={checkboxChangedHandler}
-                  />
-                </div>
+
+                { pages.length > 0 && (
+                  <div className="page-list px-md-4">
+                    <SearchResultList
+                      ref={searchResultListRef}
+                      // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
+                      pages={pages!}
+                      selectedPageId={selectedPageWithMeta?.pageData._id}
+                      forceHideMenuItems={forceHideMenuItems}
+                      onPageSelected={page => setSelectedPageWithMeta(page)}
+                      onCheckboxChanged={checkboxChangedHandler}
+                    />
+                  </div>
+                ) }
                 <div className="my-4 d-flex justify-content-center">
                   {searchPager}
                 </div>
@@ -153,6 +206,7 @@ const SearchPageBaseSubstance: ForwardRefRenderFunction<ISelectableAll, Props> =
               pageWithMeta={selectedPageWithMeta}
               highlightKeywords={highlightKeywords}
               showPageControlDropdown={!isGuestUser}
+              forceHideMenuItems={forceHideMenuItems}
             />
           )}
         </div>
@@ -163,4 +217,58 @@ const SearchPageBaseSubstance: ForwardRefRenderFunction<ISelectableAll, Props> =
 };
 
 
+type VoidFunction = () => void;
+
+export const usePageDeleteModalForBulkDeletion = (
+    data: IFormattedSearchResult | undefined,
+    ref: React.MutableRefObject<(ISelectableAll & IReturnSelectedPageIds) | null>,
+    onDeleted?: OnDeletedFunction,
+): VoidFunction => {
+
+  const { t } = useTranslation();
+
+  const { open: openDeleteModal } = usePageDeleteModal();
+
+  // for PageTree mutation
+  const { advance: advancePt } = usePageTreeTermManager();
+
+  return () => {
+    if (data == null) {
+      return;
+    }
+
+    const instance = ref.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.pageData._id))
+      .map(pageWithMeta => ({
+        pageId: pageWithMeta.pageData._id,
+        path: pageWithMeta.pageData.path,
+        revisionId: pageWithMeta.pageData.revision as string,
+      } as IPageForPageDeleteModal));
+
+    openDeleteModal(selectedPages, {
+      onDeleted: (...args) => {
+        toastSuccess(args[2] ? t('deleted_pages_completely') : t('deleted_pages'));
+        advancePt();
+
+        if (onDeleted != null) {
+          onDeleted(...args);
+        }
+      },
+    });
+  };
+
+};
+
+
 export const SearchPageBase = forwardRef(SearchPageBaseSubstance);

+ 6 - 5
packages/app/src/components/SearchTypeahead.tsx

@@ -39,6 +39,7 @@ type Props = TypeaheadProps & {
   onSubmit?: (input: string) => void,
   inputName?: string,
   keywordOnInit?: string,
+  disableIncrementalSearch?: boolean,
   // eslint-disable-next-line @typescript-eslint/no-explicit-any
   helpElement?: any,
 };
@@ -56,7 +57,7 @@ type TypeaheadInstanceFactory = {
 const SearchTypeahead: ForwardRefRenderFunction<IFocusable, Props> = (props: Props, ref) => {
   const {
     onSearchSuccess, onSearchError, onInputChange, onSubmit,
-    emptyLabel, helpElement, keywordOnInit,
+    emptyLabel, helpElement, keywordOnInit, disableIncrementalSearch,
   } = props;
 
   // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
@@ -126,7 +127,7 @@ const SearchTypeahead: ForwardRefRenderFunction<IFocusable, Props> = (props: Pro
   }, [onSearchError]);
 
   const search = useCallback(async(keyword: string) => {
-    if (keyword === '') {
+    if (disableIncrementalSearch || keyword === '') {
       return;
     }
 
@@ -143,7 +144,7 @@ const SearchTypeahead: ForwardRefRenderFunction<IFocusable, Props> = (props: Pro
       setLoading(false);
     }
 
-  }, [searchErrorHandler, searchSuccessHandler]);
+  }, [disableIncrementalSearch, searchErrorHandler, searchSuccessHandler]);
 
   const inputChangeHandler = useCallback((text: string) => {
     setInput(text);
@@ -176,7 +177,7 @@ const SearchTypeahead: ForwardRefRenderFunction<IFocusable, Props> = (props: Pro
       return emptyLabel;
     }
 
-    return false;
+    return <></>;
   };
 
   const defaultSelected = (keywordOnInit !== '')
@@ -211,7 +212,7 @@ const SearchTypeahead: ForwardRefRenderFunction<IFocusable, Props> = (props: Pro
         minLength={0}
         options={pages} // Search result (Some page names)
         promptText={props.helpElement}
-        emptyLabel={getEmptyLabel()}
+        emptyLabel={disableIncrementalSearch ? null : getEmptyLabel()}
         align="left"
         onSearch={search}
         onInputChange={inputChangeHandler}

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

@@ -312,7 +312,7 @@ const Sidebar: FC<Props> = (props: Props) => {
                 style={{ width: isCollapsed ? sidebarMinimizeWidth : currentProductNavWidth }}
               >
                 <div className="grw-contextual-navigation-child">
-                  <div role="group" className={`grw-contextual-navigation-sub ${showContents ? '' : 'd-none'}`}>
+                  <div role="group" data-testid="grw-contextual-navigation-sub" className={`grw-contextual-navigation-sub ${showContents ? '' : 'd-none'}`}>
                     <SidebarContentsWrapper></SidebarContentsWrapper>
                   </div>
                 </div>
@@ -328,6 +328,7 @@ const Sidebar: FC<Props> = (props: Props) => {
                 </div>
               ) }
               <button
+                data-testid="grw-navigation-resize-button"
                 className={`grw-navigation-resize-button ${!isDrawerMode ? 'resizable' : ''} ${isCollapsed ? 'collapsed' : ''} `}
                 type="button"
                 aria-expanded="true"

+ 2 - 2
packages/app/src/components/Sidebar/PageTree.tsx

@@ -7,7 +7,7 @@ import {
 } from '~/stores/context';
 
 import ItemsTree from './PageTree/ItemsTree';
-import PrivateLegacyPages from './PageTree/PrivateLegacyPages';
+import { PrivateLegacyPagesLink } from './PageTree/PrivateLegacyPagesLink';
 
 const PageTree: FC = memo(() => {
   const { t } = useTranslation();
@@ -76,7 +76,7 @@ const PageTree: FC = memo(() => {
 
       {!isGuestUser && migrationStatus?.migratablePagesCount != null && migrationStatus.migratablePagesCount !== 0 && (
         <div className="grw-pagetree-footer border-top p-3 w-100">
-          <PrivateLegacyPages />
+          <PrivateLegacyPagesLink />
         </div>
       )}
     </>

+ 46 - 36
packages/app/src/components/Sidebar/PageTree/Item.tsx

@@ -13,10 +13,8 @@ import { pathUtils, pagePathUtils } from '@growi/core';
 import { toastWarning, toastError, toastSuccess } from '~/client/util/apiNotification';
 
 import { useSWRxPageChildren } from '~/stores/page-listing';
-import { useSWRxPageInfo } from '~/stores/page';
 import { apiv3Put, apiv3Post } from '~/client/util/apiv3-client';
-import { useShareLinkId } from '~/stores/context';
-import { IPageForPageDeleteModal } from '~/stores/modal';
+import { IPageForPageRenameModal, IPageForPageDuplicateModal, IPageForPageDeleteModal } from '~/stores/modal';
 
 import TriangleIcon from '~/components/Icons/TriangleIcon';
 import { bookmark, unbookmark } from '~/client/services/page-operation';
@@ -28,10 +26,12 @@ interface ItemProps {
   isEnableActions: boolean
   itemNode: ItemNode
   targetPathOrId?: string
+  isScrolled: boolean,
   isOpen?: boolean
-  onClickDuplicateMenuItem?(pageId: string, path: string): void
-  onClickRenameMenuItem?(pageId: string, revisionId: string, path: string): void
-  onClickDeleteMenuItem?(pageToDelete: IPageForPageDeleteModal | null, isAbleToDeleteCompletely: boolean): void
+  isEnabledAttachTitleHeader?: boolean
+  onClickDuplicateMenuItem?(pageToDuplicate: IPageForPageDuplicateModal): void
+  onClickRenameMenuItem?(pageToRename: IPageForPageRenameModal): void
+  onClickDeleteMenuItem?(pageToDelete: IPageForPageDeleteModal): void
 }
 
 // Utility to mark target
@@ -72,14 +72,13 @@ const ItemCount: FC<ItemCountProps> = (props:ItemCountProps) => {
 const Item: FC<ItemProps> = (props: ItemProps) => {
   const { t } = useTranslation();
   const {
-    itemNode, targetPathOrId, isOpen: _isOpen = false, onClickDuplicateMenuItem, onClickRenameMenuItem, onClickDeleteMenuItem, isEnableActions,
+    itemNode, targetPathOrId, isOpen: _isOpen = false, isEnabledAttachTitleHeader,
+    onClickDuplicateMenuItem, onClickRenameMenuItem, onClickDeleteMenuItem, isEnableActions,
   } = props;
 
   const { page, children } = itemNode;
 
   const [pageTitle, setPageTitle] = useState(page.path);
-  const { data: shareLinkId } = useShareLinkId();
-  const { data: pageInfo } = useSWRxPageInfo(page._id ?? null, shareLinkId);
   const [currentChildren, setCurrentChildren] = useState(children);
   const [isOpen, setIsOpen] = useState(_isOpen);
   const [isNewPageInputShown, setNewPageInputShown] = useState(false);
@@ -144,18 +143,16 @@ const Item: FC<ItemProps> = (props: ItemProps) => {
 
       // force open
       setIsOpen(true);
-
-      toastSuccess('TODO: i18n Successfully moved pages.');
     }
     catch (err) {
       // display the dropped item
       displayDroppedItemByPageId(droppedPage._id);
 
       if (err.code === 'operation__blocked') {
-        toastWarning('TODO: i18n You cannot move this page now.');
+        toastWarning(t('pagetree.you_cannot_move_this_page_now'));
       }
       else {
-        toastError('TODO: i18n Something went wrong with moving page.');
+        toastError(t('pagetree.something_went_wrong_with_moving_page'));
       }
     }
   };
@@ -201,7 +198,9 @@ const Item: FC<ItemProps> = (props: ItemProps) => {
       throw Error('Any of _id and path must not be null.');
     }
 
-    onClickDuplicateMenuItem(pageId, path);
+    const pageToDuplicate = { pageId, path };
+
+    onClickDuplicateMenuItem(pageToDuplicate);
   }, [onClickDuplicateMenuItem, page]);
 
 
@@ -241,14 +240,16 @@ const Item: FC<ItemProps> = (props: ItemProps) => {
       throw Error('Any of _id and revisionId and path must not be null.');
     }
 
-    onClickRenameMenuItem(pageId, revisionId as string, path);
-  }, [onClickRenameMenuItem, page]);
+    const pageToRename: IPageForPageRenameModal = {
+      pageId,
+      revisionId: revisionId as string,
+      path,
+    };
 
-  const deleteMenuItemClickHandler = useCallback(async(_pageId: string): Promise<void> => {
-    if (onClickDeleteMenuItem == null) {
-      return;
-    }
+    onClickRenameMenuItem(pageToRename);
+  }, [onClickRenameMenuItem, page]);
 
+  const deleteMenuItemClickHandler = useCallback(async(_pageId: string, pageInfo): Promise<void> => {
     const { _id: pageId, revision: revisionId, path } = page;
 
     if (pageId == null || revisionId == null || path == null) {
@@ -259,11 +260,13 @@ const Item: FC<ItemProps> = (props: ItemProps) => {
       pageId,
       revisionId: revisionId as string,
       path,
+      isAbleToDeleteCompletely: pageInfo?.isAbleToDeleteCompletely,
     };
-    const isAbleToDeleteCompletely = pageInfo?.isAbleToDeleteCompletely ?? false;
 
-    onClickDeleteMenuItem(pageToDelete, isAbleToDeleteCompletely);
-  }, [onClickDeleteMenuItem, page, pageInfo?.isAbleToDeleteCompletely]);
+    if (onClickDeleteMenuItem != null) {
+      onClickDeleteMenuItem(pageToDelete);
+    }
+  }, [onClickDeleteMenuItem, page]);
 
   const onPressEnterForCreateHandler = async(inputText: string) => {
     setNewPageInputShown(false);
@@ -276,17 +279,16 @@ const Item: FC<ItemProps> = (props: ItemProps) => {
       return;
     }
 
-    // TODO 88261: Get the isEnabledAttachTitleHeader by SWR
-    // const initBody = '';
-    // const { isEnabledAttachTitleHeader } = props.appContainer.getConfig();
-    // if (isEnabledAttachTitleHeader) {
-    //   initBody = pathUtils.attachTitleHeader(newPagePath);
-    // }
+    let initBody = '';
+    if (isEnabledAttachTitleHeader) {
+      const pageTitle = pathUtils.addHeadingSlash(nodePath.basename(newPagePath));
+      initBody = pathUtils.attachTitleHeader(pageTitle);
+    }
 
     try {
       await apiv3Post('/pages/', {
         path: newPagePath,
-        body: '',
+        body: initBody,
         grant: page.grant,
         grantUserGroupId: page.grantedGroup,
         createFromPageTree: true,
@@ -317,6 +319,12 @@ const Item: FC<ItemProps> = (props: ItemProps) => {
     return null;
   };
 
+  useEffect(() => {
+    if (!props.isScrolled && page.isTarget) {
+      document.dispatchEvent(new CustomEvent('targetItemRendered'));
+    }
+  }, [props.isScrolled, page.isTarget]);
+
   // didMount
   useEffect(() => {
     if (hasChildren()) setIsOpen(true);
@@ -348,6 +356,7 @@ const Item: FC<ItemProps> = (props: ItemProps) => {
       <li
         ref={(c) => { drag(c); drop(c) }}
         className={`list-group-item list-group-item-action border-0 py-1 d-flex align-items-center ${page.isTarget ? 'grw-pagetree-is-target' : ''}`}
+        id={page.isTarget ? 'grw-pagetree-is-target' : `grw-pagetree-list-${page._id}`}
       >
         <div className="grw-triangle-container d-flex justify-content-center">
           {hasDescendants && (
@@ -374,7 +383,7 @@ const Item: FC<ItemProps> = (props: ItemProps) => {
           />
         )}
         { !isRenameInputShown && ( */}
-        <a href={page._id} className="grw-pagetree-title-anchor flex-grow-1">
+        <a href={`/${page._id}`} className="grw-pagetree-title-anchor flex-grow-1">
           <p className={`text-truncate m-auto ${page.isEmpty && 'text-muted'}`}>{nodePath.basename(pageTitle as string) || '/'}</p>
         </a>
         {/* )} */}
@@ -383,23 +392,22 @@ const Item: FC<ItemProps> = (props: ItemProps) => {
             <ItemCount descendantCount={page.descendantCount} />
           </div>
         )}
-        <div className="grw-pagetree-control d-none">
+        <div className="grw-pagetree-control d-flex">
           <PageItemControl
             pageId={page._id}
             isEnableActions={isEnableActions}
-            showBookmarkMenuItem
             onClickBookmarkMenuItem={bookmarkMenuItemClickHandler}
             onClickDuplicateMenuItem={duplicateMenuItemClickHandler}
-            onClickDeleteMenuItem={deleteMenuItemClickHandler}
             onClickRenameMenuItem={renameMenuItemClickHandler}
+            onClickDeleteMenuItem={deleteMenuItemClickHandler}
           >
-            <DropdownToggle color="transparent" className="border-0 rounded btn-page-item-control p-0">
+            <DropdownToggle color="transparent" className="border-0 rounded btn-page-item-control p-0 grw-visible-on-hover">
               <i className="icon-options fa fa-rotate-90 text-muted p-1"></i>
             </DropdownToggle>
           </PageItemControl>
           <button
             type="button"
-            className="border-0 rounded btn-page-item-control p-0"
+            className="border-0 rounded btn-page-item-control p-0 grw-visible-on-hover"
             onClick={onClickPlusButton}
           >
             <i className="icon-plus text-muted d-block p-1" />
@@ -423,7 +431,9 @@ const Item: FC<ItemProps> = (props: ItemProps) => {
               isEnableActions={isEnableActions}
               itemNode={node}
               isOpen={false}
+              isScrolled={props.isScrolled}
               targetPathOrId={targetPathOrId}
+              isEnabledAttachTitleHeader={isEnabledAttachTitleHeader}
               onClickDuplicateMenuItem={onClickDuplicateMenuItem}
               onClickRenameMenuItem={onClickRenameMenuItem}
               onClickDeleteMenuItem={onClickDeleteMenuItem}

+ 72 - 43
packages/app/src/components/Sidebar/PageTree/ItemsTree.tsx

@@ -1,17 +1,22 @@
-import React, { FC, useEffect } from 'react';
+import React, { FC, useEffect, useState } from 'react';
 import { useTranslation } from 'react-i18next';
 
 import { IPageHasId } from '../../../interfaces/page';
 import { ItemNode } from './ItemNode';
 import Item from './Item';
-import { useSWRxPageAncestorsChildren, useSWRxPageChildren, useSWRxRootPage } from '../../../stores/page-listing';
+import { usePageTreeTermManager, useSWRxPageAncestorsChildren, useSWRxRootPage } from '~/stores/page-listing';
 import { TargetAndAncestors } from '~/interfaces/page-listing-results';
+import { OnDeletedFunction } from '~/interfaces/ui';
 import { toastError, toastSuccess } from '~/client/util/apiNotification';
 import {
-  OnDeletedFunction, IPageForPageDeleteModal, usePageDuplicateModal, usePageRenameModal, usePageDeleteModal,
+  IPageForPageDeleteModal, IPageForPageDuplicateModal, usePageDuplicateModal, IPageForPageRenameModal, usePageRenameModal, usePageDeleteModal,
 } from '~/stores/modal';
 import { smoothScrollIntoView } from '~/client/util/smooth-scroll';
 
+import { useIsEnabledAttachTitleHeader } from '~/stores/context';
+import { useFullTextSearchTermManager } from '~/stores/search';
+import { useDescendantsPageListForCurrentPathTermManager } from '~/stores/page';
+
 /*
  * Utility to generate initial node
  */
@@ -61,10 +66,12 @@ type ItemsTreeProps = {
 const renderByInitialNode = (
     initialNode: ItemNode,
     isEnableActions: boolean,
+    isScrolled: boolean,
     targetPathOrId?: string,
-    onClickDuplicateMenuItem?: (pageId: string, path: string) => void,
-    onClickRenameMenuItem?: (pageId: string, revisionId: string, path: string) => void,
-    onClickDeleteMenuItem?: (pageToDelete: IPageForPageDeleteModal | null, isAbleToDeleteCompletely: boolean) => void,
+    isEnabledAttachTitleHeader?: boolean,
+    onClickDuplicateMenuItem?: (pageToDuplicate: IPageForPageDuplicateModal) => void,
+    onClickRenameMenuItem?: (pageToRename: IPageForPageRenameModal) => void,
+    onClickDeleteMenuItem?: (pageToDelete: IPageForPageDeleteModal) => void,
 ): JSX.Element => {
 
   return (
@@ -74,15 +81,30 @@ const renderByInitialNode = (
         targetPathOrId={targetPathOrId}
         itemNode={initialNode}
         isOpen
+        isEnabledAttachTitleHeader={isEnabledAttachTitleHeader}
         isEnableActions={isEnableActions}
         onClickDuplicateMenuItem={onClickDuplicateMenuItem}
         onClickRenameMenuItem={onClickRenameMenuItem}
         onClickDeleteMenuItem={onClickDeleteMenuItem}
+        isScrolled={isScrolled}
       />
     </ul>
   );
 };
 
+// --- Auto scroll related vars and util ---
+
+const SCROLL_OFFSET_TOP = window.innerHeight / 2;
+
+const scrollTargetItem = () => {
+  const scrollElement = document.getElementById('grw-sidebar-contents-scroll-target');
+  const target = document.getElementById('grw-pagetree-is-target');
+  if (scrollElement != null && target != null) {
+    smoothScrollIntoView(target, SCROLL_OFFSET_TOP, scrollElement);
+  }
+};
+// --- end ---
+
 
 /*
  * ItemsTree
@@ -95,59 +117,60 @@ const ItemsTree: FC<ItemsTreeProps> = (props: ItemsTreeProps) => {
   const { t } = useTranslation();
 
   const { data: ancestorsChildrenData, error: error1 } = useSWRxPageAncestorsChildren(targetPath);
-  const { mutate: mutateChildren } = useSWRxPageChildren(targetPathOrId);
   const { data: rootPageData, error: error2 } = useSWRxRootPage();
+  const { data: isEnabledAttachTitleHeader } = useIsEnabledAttachTitleHeader();
   const { open: openDuplicateModal } = usePageDuplicateModal();
   const { open: openRenameModal } = usePageRenameModal();
   const { open: openDeleteModal } = usePageDeleteModal();
+  const [isScrolled, setIsScrolled] = useState(false);
 
-  useEffect(() => {
-    const startFrom = document.getElementById('grw-sidebar-contents-scroll-target');
-    const targetElem = document.getElementsByClassName('grw-pagetree-is-target');
-    //  targetElem is HTML collection but only one HTML element in it all the time
-    if (targetElem[0] != null && startFrom != null) {
-      smoothScrollIntoView(targetElem[0] as HTMLElement, 0, startFrom);
-    }
-  }, [ancestorsChildrenData]);
 
-  const onClickDuplicateMenuItem = (pageId: string, path: string) => {
-    openDuplicateModal(pageId, path);
+  // for mutation
+  const { advance: advancePt } = usePageTreeTermManager();
+  const { advance: advanceFts } = useFullTextSearchTermManager();
+  const { advance: advanceDpl } = useDescendantsPageListForCurrentPathTermManager();
+
+  const scrollItem = () => {
+    scrollTargetItem();
+    setIsScrolled(true);
   };
 
-  const onClickRenameMenuItem = (pageId: string, revisionId: string, path: string) => {
-    openRenameModal(pageId, revisionId, path);
+  useEffect(() => {
+    document.addEventListener('targetItemRendered', scrollItem);
+    return () => {
+      document.removeEventListener('targetItemRendered', scrollItem);
+    };
+  }, []);
+
+  const onClickDuplicateMenuItem = (pageToDuplicate: IPageForPageDuplicateModal) => {
+    openDuplicateModal(pageToDuplicate);
   };
 
-  const onDeletedHandler: OnDeletedFunction = (pathOrPathsToDelete, isRecursively, isCompletely) => {
-    if (typeof pathOrPathsToDelete !== 'string') {
-      return;
-    }
+  const onClickRenameMenuItem = (pageToRename: IPageForPageRenameModal) => {
+    openRenameModal(pageToRename);
+  };
 
-    mutateChildren();
+  const onClickDeleteMenuItem = (pageToDelete: IPageForPageDeleteModal) => {
+    const onDeletedHandler: OnDeletedFunction = (pathOrPathsToDelete, isRecursively, isCompletely) => {
+      if (typeof pathOrPathsToDelete !== 'string') {
+        return;
+      }
 
-    const path = pathOrPathsToDelete;
+      const path = pathOrPathsToDelete;
 
-    if (isRecursively) {
-      if (isCompletely) {
-        toastSuccess(t('deleted_single_page_recursively_completely', { path }));
-      }
-      else {
-        toastSuccess(t('deleted_single_page_recursively', { path }));
-      }
-    }
-    else {
-      // eslint-disable-next-line no-lonely-if
       if (isCompletely) {
-        toastSuccess(t('deleted_single_page_completely', { path }));
+        toastSuccess(t('deleted_pages_completely', { path }));
       }
       else {
-        toastSuccess(t('deleted_single_page', { path }));
+        toastSuccess(t('deleted_pages', { path }));
       }
-    }
-  };
 
-  const onClickDeleteMenuItem = (pageToDelete: IPageForPageDeleteModal, isAbleToDeleteCompletely) => {
-    openDeleteModal([pageToDelete], onDeletedHandler, isAbleToDeleteCompletely);
+      advancePt();
+      advanceFts();
+      advanceDpl();
+    };
+
+    openDeleteModal([pageToDelete], { onDeleted: onDeletedHandler });
   };
 
   if (error1 != null || error2 != null) {
@@ -161,7 +184,10 @@ const ItemsTree: FC<ItemsTreeProps> = (props: ItemsTreeProps) => {
    */
   if (ancestorsChildrenData != null && rootPageData != null) {
     const initialNode = generateInitialNodeAfterResponse(ancestorsChildrenData.ancestorsChildren, new ItemNode(rootPageData.rootPage));
-    return renderByInitialNode(initialNode, isEnableActions, targetPathOrId, onClickDuplicateMenuItem, onClickRenameMenuItem, onClickDeleteMenuItem);
+    return renderByInitialNode(
+      // eslint-disable-next-line max-len
+      initialNode, isEnableActions, isScrolled, targetPathOrId, isEnabledAttachTitleHeader, onClickDuplicateMenuItem, onClickRenameMenuItem, onClickDeleteMenuItem,
+    );
   }
 
   /*
@@ -169,7 +195,10 @@ const ItemsTree: FC<ItemsTreeProps> = (props: ItemsTreeProps) => {
    */
   if (targetAndAncestorsData != null) {
     const initialNode = generateInitialNodeBeforeResponse(targetAndAncestorsData.targetAndAncestors);
-    return renderByInitialNode(initialNode, isEnableActions, targetPathOrId, onClickDuplicateMenuItem, onClickRenameMenuItem, onClickDeleteMenuItem);
+    return renderByInitialNode(
+      // eslint-disable-next-line max-len
+      initialNode, isEnableActions, isScrolled, targetPathOrId, isEnabledAttachTitleHeader, onClickDuplicateMenuItem, onClickRenameMenuItem, onClickDeleteMenuItem,
+    );
   }
 
   return null;

+ 2 - 4
packages/app/src/components/Sidebar/PageTree/PrivateLegacyPages.tsx → packages/app/src/components/Sidebar/PageTree/PrivateLegacyPagesLink.tsx

@@ -1,14 +1,12 @@
 import React, { FC, memo } from 'react';
 import { useTranslation } from 'react-i18next';
 
-const PrivateLegacyPages: FC = memo(() => {
+export const PrivateLegacyPagesLink: FC = memo(() => {
   const { t } = useTranslation();
 
   return (
-    <a href="/private-legacy-pages?q=[nq:PrivateLegacyPages]" className="h5 grw-private-legacy-pages-anchor text-decoration-none">
+    <a href="/_private-legacy-pages" className="h5 grw-private-legacy-pages-anchor text-decoration-none">
       <i className="icon-drawer mr-2"></i> {t('pagetree.private_legacy_pages')}
     </a>
   );
 });
-
-export default PrivateLegacyPages;

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

@@ -21,7 +21,7 @@ const TrashPageList = (props) => {
   }, [t]);
 
   return (
-    <div className="mt-5 d-edit-none">
+    <div data-testid="trash-page-list" className="mt-5 d-edit-none">
       <CustomNavAndContents navTabMapping={navTabMapping} />
     </div>
   );

+ 3 - 2
packages/app/src/components/User/SeenUserInfo.tsx

@@ -9,13 +9,14 @@ import UserPictureList from './UserPictureList';
 
 interface Props {
   seenUsers: IUser[],
+  sumOfSeenUsers?: number,
   disabled?: boolean,
 }
 
 const SeenUserInfo: FC<Props> = (props: Props) => {
   const [isPopoverOpen, setIsPopoverOpen] = useState(false);
 
-  const { seenUsers, disabled } = props;
+  const { seenUsers, sumOfSeenUsers, disabled } = props;
 
   const togglePopover = () => setIsPopoverOpen(!isPopoverOpen);
 
@@ -25,7 +26,7 @@ const SeenUserInfo: FC<Props> = (props: Props) => {
         <span className="mr-1 footstamp-icon">
           <FootstampIcon />
         </span>
-        <span className="seen-user-count">{seenUsers.length}</span>
+        <span className="seen-user-count">{sumOfSeenUsers || seenUsers.length}</span>
       </button>
       <Popover placement="bottom" isOpen={isPopoverOpen} target="btn-seen-user" toggle={togglePopover} trigger="legacy" disabled={disabled}>
         <PopoverBody className="user-list-popover">

+ 1 - 0
packages/app/src/interfaces/page.ts

@@ -41,6 +41,7 @@ export type IPageInfo = {
   isMovable: boolean,
   isDeletable: boolean,
   isAbleToDeleteCompletely: boolean,
+  isRevertible: boolean,
 }
 
 export type IPageInfoForEntity = IPageInfo & {

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

@@ -1,3 +1,5 @@
+import { Nullable } from './common';
+
 export const SidebarContentsType = {
   CUSTOM: 'custom',
   RECENT: 'recent',
@@ -17,3 +19,6 @@ export type ICustomTabContent = {
 };
 
 export type ICustomNavTabMappings = { [key: string]: ICustomTabContent };
+
+
+export type OnDeletedFunction = (idOrPaths: string | string[], isRecursively: Nullable<true>, isCompletely: Nullable<true>) => void;

+ 5 - 1
packages/app/src/migrations/20210913153942-migrate-slack-app-integration-schema.js

@@ -20,7 +20,11 @@ defaultSupportedCommandsNameForSingleUse.forEach((commandName) => {
 module.exports = {
   async up(db) {
     logger.info('Apply migration');
-    await mongoose.connect(getMongoUri(), mongoOptions);
+    // connect only if disconnected
+    // see: https://mongoosejs.com/docs/api/connection.html#connection_Connection-readyState
+    if (mongoose.connection.readyState === 0) {
+      await mongoose.connect(getMongoUri(), mongoOptions);
+    }
 
     const SlackAppIntegration = getModelSafely('SlackAppIntegration') || require('~/server/models/slack-app-integration')();
 

+ 6 - 1
packages/app/src/server/crowi/index.js

@@ -22,6 +22,7 @@ import SearchService from '../service/search';
 import AttachmentService from '../service/attachment';
 import PageService from '../service/page';
 import PageGrantService from '../service/page-grant';
+import PageOperationService from '../service/page-operation';
 import { SlackIntegrationService } from '../service/slack-integration';
 import { UserNotificationService } from '../service/user-notification';
 import { InstallerService } from '../service/installer';
@@ -398,11 +399,12 @@ Crowi.prototype.autoInstall = function() {
     admin: true,
   };
   const globalLang = this.configManager.getConfig('crowi', 'autoInstall:globalLang');
+  const serverDate = this.configManager.getConfig('crowi', 'autoInstall:serverDate');
 
   const installerService = new InstallerService(this);
 
   try {
-    installerService.install(firstAdminUserToSave, globalLang ?? 'en_US');
+    installerService.install(firstAdminUserToSave, globalLang ?? 'en_US', serverDate);
   }
   catch (err) {
     logger.warn('Automatic installation failed.', err);
@@ -678,6 +680,9 @@ Crowi.prototype.setupPageService = async function() {
   if (this.pageGrantService == null) {
     this.pageGrantService = new PageGrantService(this);
   }
+  if (this.pageOperationService == null) {
+    this.pageOperationService = new PageOperationService(this);
+  }
 };
 
 Crowi.prototype.setupInAppNotificationService = async function() {

+ 25 - 0
packages/app/src/server/interfaces/page-operation.ts

@@ -0,0 +1,25 @@
+import { ObjectIdLike } from './mongoose-utils';
+
+export type IPageForResuming = {
+  _id: ObjectIdLike,
+  path: string,
+  isEmpty: boolean,
+  parent?: ObjectIdLike,
+  grant?: number,
+  grantedUsers?: ObjectIdLike[],
+  grantedGroup?: ObjectIdLike,
+  descendantCount: number,
+  status?: number,
+  revision?: ObjectIdLike,
+  lastUpdateUser?: ObjectIdLike,
+  creator?: ObjectIdLike,
+};
+
+export type IUserForResuming = {
+  _id: ObjectIdLike,
+};
+
+export type IOptionsForResuming = {
+  updateMetadata?: boolean,
+  createRedirectPage?: boolean,
+};

+ 1 - 16
packages/app/src/server/middlewares/http-error-handler.js

@@ -1,23 +1,8 @@
-import { HttpError } from 'http-errors';
+import { isHttpError } from 'http-errors';
 import loggerFactory from '~/utils/logger';
 
 const logger = loggerFactory('growi:middleware:htto-error-handler');
 
-const isHttpError = (val) => {
-  if (!val || typeof val !== 'object') {
-    return false;
-  }
-
-  if (val instanceof HttpError) {
-    return true;
-  }
-
-  return val instanceof Error
-    && typeof val.expose === 'boolean'
-    && typeof val.statusCode === 'number'
-    && val.status === val.statusCode;
-};
-
 module.exports = async(err, req, res, next) => {
   // handle if the err is a HttpError instance
   if (isHttpError(err)) {

+ 17 - 5
packages/app/src/server/models/obsolete-page.js

@@ -15,7 +15,7 @@ const differenceInYears = require('date-fns/differenceInYears');
 const { pathUtils } = require('@growi/core');
 const escapeStringRegexp = require('escape-string-regexp');
 
-const { isTopPage, isTrashPage, isUserNamePage } = pagePathUtils;
+const { isTopPage, isTrashPage } = pagePathUtils;
 const { checkTemplatePath } = templateChecker;
 
 const logger = loggerFactory('growi:models:page');
@@ -222,6 +222,22 @@ export class PageQueryBuilder {
     return this;
   }
 
+  async addConditionAsMigratablePages(user) {
+    this.query = this.query
+      .and({
+        $or: [
+          { grant: { $ne: GRANT_RESTRICTED } },
+          { grant: { $ne: GRANT_SPECIFIED } },
+        ],
+      });
+    this.addConditionAsNotMigrated();
+    this.addConditionAsNonRootPage();
+    this.addConditionToExcludeTrashed();
+    await this.addConditionForParentNormalization(user);
+
+    return this;
+  }
+
   addConditionToFilteringByViewer(user, userGroups, showAnyoneKnowsLink = false, showPagesRestrictedByOwner = false, showPagesRestrictedByGroup = false) {
     const grantConditions = [
       { grant: null },
@@ -596,10 +612,6 @@ export const getPageSchema = (crowi) => {
     return path.replace('/trash', '');
   };
 
-  pageSchema.statics.isDeletableName = function(path) {
-    return !isTopPage(path) && !isUserNamePage(path);
-  };
-
   pageSchema.statics.fixToCreatableName = function(path) {
     return path
       .replace(/\/\//g, '/');

+ 118 - 0
packages/app/src/server/models/page-operation.ts

@@ -0,0 +1,118 @@
+import mongoose, {
+  Schema, Model, Document, QueryOptions, FilterQuery,
+} from 'mongoose';
+import { getOrCreateModel } from '@growi/core';
+
+import {
+  IPageForResuming, IUserForResuming, IOptionsForResuming,
+} from '~/server/interfaces/page-operation';
+import { ObjectIdLike } from '../interfaces/mongoose-utils';
+
+type IObjectId = mongoose.Types.ObjectId;
+const ObjectId = mongoose.Schema.Types.ObjectId;
+
+export const PageActionType = {
+  Rename: 'Rename',
+  Duplicate: 'Duplicate',
+  Delete: 'Delete',
+  DeleteCompletely: 'DeleteCompletely',
+  Revert: 'Revert',
+  NormalizeParent: 'NormalizeParent',
+} as const;
+export type PageActionType = typeof PageActionType[keyof typeof PageActionType];
+
+export const PageActionStage = {
+  Main: 'Main',
+  Sub: 'Sub',
+} as const;
+export type PageActionStage = typeof PageActionStage[keyof typeof PageActionStage];
+
+/*
+ * Main Schema
+ */
+export interface IPageOperation {
+  actionType: PageActionType,
+  actionStage: PageActionStage,
+  fromPath: string,
+  toPath?: string,
+  page: IPageForResuming,
+  user: IUserForResuming,
+  options?: IOptionsForResuming,
+  incForUpdatingDescendantCount?: number,
+}
+
+export interface PageOperationDocument extends IPageOperation, Document {}
+
+export type PageOperationDocumentHasId = PageOperationDocument & { _id: ObjectIdLike };
+
+export interface PageOperationModel extends Model<PageOperationDocument> {
+  findByIdAndUpdatePageActionStage(pageOpId: ObjectIdLike, stage: PageActionStage): Promise<PageOperationDocumentHasId | null>
+  findMainOps(filter?: FilterQuery<PageOperationDocument>, projection?: any, options?: QueryOptions): Promise<PageOperationDocumentHasId[]>
+}
+
+const pageSchemaForResuming = new Schema<IPageForResuming>({
+  _id: { type: ObjectId, ref: 'Page', index: true },
+  parent: { type: ObjectId, ref: 'Page' },
+  descendantCount: { type: Number },
+  isEmpty: { type: Boolean },
+  path: { type: String, required: true, index: true },
+  revision: { type: ObjectId, ref: 'Revision' },
+  status: { type: String },
+  grant: { type: Number },
+  grantedUsers: [{ type: ObjectId, ref: 'User' }],
+  grantedGroup: { type: ObjectId, ref: 'UserGroup' },
+  creator: { type: ObjectId, ref: 'User' },
+  lastUpdateUser: { type: ObjectId, ref: 'User' },
+});
+
+const userSchemaForResuming = new Schema<IUserForResuming>({
+  _id: { type: ObjectId, ref: 'User', required: true },
+});
+
+const optionsSchemaForResuming = new Schema<IOptionsForResuming>({
+  createRedirectPage: { type: Boolean },
+  updateMetadata: { type: Boolean },
+}, { _id: false });
+
+const schema = new Schema<PageOperationDocument, PageOperationModel>({
+  actionType: {
+    type: String,
+    enum: PageActionType,
+    required: true,
+    index: true,
+  },
+  actionStage: {
+    type: String,
+    enum: PageActionStage,
+    required: true,
+    index: true,
+  },
+  fromPath: { type: String, required: true, index: true },
+  toPath: { type: String, index: true },
+  page: { type: pageSchemaForResuming, required: true },
+  user: { type: userSchemaForResuming, required: true },
+  options: { type: optionsSchemaForResuming },
+  incForUpdatingDescendantCount: { type: Number },
+});
+
+schema.statics.findByIdAndUpdatePageActionStage = async function(
+    pageOpId: ObjectIdLike, stage: PageActionStage,
+): Promise<PageOperationDocumentHasId | null> {
+
+  return this.findByIdAndUpdate(pageOpId, {
+    $set: { actionStage: stage },
+  }, { new: true });
+};
+
+schema.statics.findMainOps = async function(
+    filter?: FilterQuery<PageOperationDocument>, projection?: any, options?: QueryOptions,
+): Promise<PageOperationDocumentHasId[]> {
+
+  return this.find(
+    { ...filter, actionStage: PageActionStage.Main },
+    projection,
+    options,
+  );
+};
+
+export default getOrCreateModel<PageOperationDocument, PageOperationModel>('PageOperation', schema);

+ 0 - 3
packages/app/src/server/models/page-redirect.ts

@@ -16,9 +16,6 @@ export interface PageRedirectModel extends Model<PageRedirectDocument> {
   [x:string]: any // TODO: improve type
 }
 
-/**
- * This is the setting for notify to 3rd party tool (like Slack).
- */
 const schema = new Schema<PageRedirectDocument, PageRedirectModel>({
   fromPath: {
     type: String, required: true, unique: true, index: true,

+ 30 - 57
packages/app/src/server/models/page.ts

@@ -167,7 +167,7 @@ schema.statics.createEmptyPage = async function(
  * @param exPage a page document to be replaced
  * @returns Promise<void>
  */
-schema.statics.replaceTargetWithPage = async function(exPage, pageToReplaceWith?, deleteExPageIfEmpty = false): Promise<void> {
+schema.statics.replaceTargetWithPage = async function(exPage, pageToReplaceWith?, deleteExPageIfEmpty = false) {
   // find parent
   const parent = await this.findOne({ _id: exPage.parent });
   if (parent == null) {
@@ -207,6 +207,8 @@ schema.statics.replaceTargetWithPage = async function(exPage, pageToReplaceWith?
     await this.deleteOne({ _id: exPage._id });
     logger.warn('Deleted empty page since it was replaced with another page.');
   }
+
+  return this.findById(newTarget._id);
 };
 
 /**
@@ -225,7 +227,7 @@ schema.statics.getParentAndFillAncestors = async function(path: string): Promise
   /*
    * Fill parents if parent is null
    */
-  const ancestorPaths = collectAncestorPaths(path, [path]); // paths of parents need to be created
+  const ancestorPaths = collectAncestorPaths(path); // paths of parents need to be created
 
   // just create ancestors with empty pages
   await this.createEmptyPagesByPaths(ancestorPaths);
@@ -423,40 +425,6 @@ async function pushRevision(pageData, newRevision, user) {
   return pageData.save();
 }
 
-/**
- * return aggregate condition to get following pages
- * - page that has the same path as the provided path
- * - pages that are descendants of the above page
- * pages without parent will be ignored
- */
-schema.statics.getAggrConditionForPageWithProvidedPathAndDescendants = function(path:string) {
-  let match;
-  if (isTopPage(path)) {
-    match = {
-      // https://regex101.com/r/Kip2rV/1
-      $match: { $or: [{ path: { $regex: '^/.*' }, parent: { $ne: null } }, { path: '/' }] },
-    };
-  }
-  else {
-    match = {
-      // https://regex101.com/r/mJvGrG/1
-      $match: { path: { $regex: `^${path}(/.*|$)` }, parent: { $ne: null } },
-    };
-  }
-  return [
-    match,
-    {
-      $project: {
-        path: 1,
-        parent: 1,
-        field_length: { $strLenCP: '$path' },
-      },
-    },
-    { $sort: { field_length: -1 } },
-    { $project: { field_length: 0 } },
-  ];
-};
-
 /**
  * add/subtract descendantCount of pages with provided paths by increment.
  * increment can be negative number
@@ -512,6 +480,9 @@ schema.statics.recountDescendantCount = async function(id: ObjectIdLike):Promise
 schema.statics.findAncestorsUsingParentRecursively = async function(pageId: ObjectIdLike, shouldIncludeTarget: boolean) {
   const self = this;
   const target = await this.findById(pageId);
+  if (target == null) {
+    throw Error('Target not found');
+  }
 
   async function findAncestorsRecursively(target, ancestors = shouldIncludeTarget ? [target] : []) {
     const parent = await self.findOne({ _id: target.parent });
@@ -531,44 +502,41 @@ schema.statics.findAncestorsUsingParentRecursively = async function(pageId: Obje
  * @param pageId ObjectIdLike
  * @returns Promise<void>
  */
-schema.statics.removeLeafEmptyPagesById = async function(pageId: ObjectIdLike): Promise<void> {
+schema.statics.removeLeafEmptyPagesRecursively = async function(pageId: ObjectIdLike): Promise<void> {
   const self = this;
 
-  const initialLeafPage = await this.findById(pageId);
+  const initialPage = await this.findById(pageId);
 
-  if (initialLeafPage == null) {
+  if (initialPage == null) {
     return;
   }
 
-  if (!initialLeafPage.isEmpty) {
+  if (!initialPage.isEmpty) {
     return;
   }
 
-  async function generatePageIdsToRemove(page, pageIds: ObjectIdLike[]): Promise<ObjectIdLike[]> {
-    const nextPage = await self.findById(page.parent);
-
-    if (nextPage == null) {
+  async function generatePageIdsToRemove(childPage, page, pageIds: ObjectIdLike[] = []): Promise<ObjectIdLike[]> {
+    if (!page.isEmpty) {
       return pageIds;
     }
 
-    // delete leaf empty pages
-    const isNextPageEmpty = nextPage.isEmpty;
-
-    if (!isNextPageEmpty) {
+    const isChildrenOtherThanTargetExist = await self.exists({ _id: { $ne: childPage?._id }, parent: page._id });
+    if (isChildrenOtherThanTargetExist) {
       return pageIds;
     }
 
-    const isSiblingsExist = await self.exists({ parent: nextPage.parent, _id: { $ne: nextPage._id } });
-    if (isSiblingsExist) {
+    pageIds.push(page._id);
+
+    const nextPage = await self.findById(page.parent);
+
+    if (nextPage == null) {
       return pageIds;
     }
 
-    return generatePageIdsToRemove(nextPage, [...pageIds, nextPage._id]);
+    return generatePageIdsToRemove(page, nextPage, pageIds);
   }
 
-  const initialPageIdsToRemove = [initialLeafPage._id];
-
-  const pageIdsToRemove = await generatePageIdsToRemove(initialLeafPage, initialPageIdsToRemove);
+  const pageIdsToRemove = await generatePageIdsToRemove(null, initialPage);
 
   await this.deleteMany({ _id: { $in: pageIdsToRemove } });
 };
@@ -578,7 +546,7 @@ schema.statics.findByPageIdsToEdit = async function(ids, user, shouldIncludeEmpt
 
   await this.addConditionToFilteringByViewerToEdit(builder, user);
 
-  const pages = await builder.query.lean().exec();
+  const pages = await builder.query.exec();
 
   return pages;
 };
@@ -608,7 +576,7 @@ export default (crowi: Crowi): any => {
   }
 
   schema.statics.create = async function(path: string, body: string, user, options: PageCreateOptions = {}) {
-    if (crowi.pageGrantService == null || crowi.configManager == null || crowi.pageService == null) {
+    if (crowi.pageGrantService == null || crowi.configManager == null || crowi.pageService == null || crowi.pageOperationService == null) {
       throw Error('Crowi is not setup');
     }
 
@@ -618,6 +586,11 @@ export default (crowi: Crowi): any => {
       return this.createV4(path, body, user, options);
     }
 
+    const canOperate = await crowi.pageOperationService.canOperate(false, null, path);
+    if (!canOperate) {
+      throw Error(`Cannot operate create to path "${path}" right now.`);
+    }
+
     const Page = this;
     const Revision = crowi.model('Revision');
     const {
@@ -707,7 +680,7 @@ export default (crowi: Crowi): any => {
     // Delete PageRedirect if exists
     const PageRedirect = mongoose.model('PageRedirect') as unknown as PageRedirectModel;
     try {
-      await PageRedirect.deleteOne({ from: path });
+      await PageRedirect.deleteOne({ fromPath: path });
       logger.warn(`Deleted page redirect after creating a new page at path "${path}".`);
     }
     catch (err) {

+ 1 - 1
packages/app/src/server/models/subscription.ts

@@ -22,7 +22,7 @@ export interface ISubscription {
 export interface SubscriptionDocument extends ISubscription, Document {}
 
 export interface SubscriptionModel extends Model<SubscriptionDocument> {
-  findByUserIdAndTargetId(userId: Types.ObjectId, targetId: Types.ObjectId): any
+  findByUserIdAndTargetId(userId: Types.ObjectId | string, targetId: Types.ObjectId | string): any
   upsertSubscription(user: Types.ObjectId, targetModel: string, target: Types.ObjectId, status: string): any
   subscribeByPageId(user: Types.ObjectId, pageId: Types.ObjectId, status: string): any
   getSubscription(target: Types.ObjectId): Promise<Types.ObjectId[]>

+ 9 - 2
packages/app/src/server/routes/apiv3/forgot-password.js

@@ -5,6 +5,9 @@ import ErrorV3 from '~/server/models/vo/error-apiv3';
 import injectResetOrderByTokenMiddleware from '~/server/middlewares/inject-reset-order-by-token-middleware';
 import loggerFactory from '~/utils/logger';
 
+import { checkForgotPasswordEnabledMiddlewareFactory } from '../forgot-password';
+import httpErrorHandler from '../../middlewares/http-error-handler';
+
 const logger = loggerFactory('growi:routes:apiv3:forgotPassword'); // eslint-disable-line no-unused-vars
 
 const express = require('express');
@@ -40,6 +43,8 @@ module.exports = (crowi) => {
       'Too many requests were sent from this IP. Please try a password reset request again on the password reset request form',
   });
 
+  const checkPassportStrategyMiddleware = checkForgotPasswordEnabledMiddlewareFactory(crowi, true);
+
   async function sendPasswordResetEmail(txtFileName, i18n, email, url) {
     return mailService.send({
       to: email,
@@ -53,7 +58,7 @@ module.exports = (crowi) => {
     });
   }
 
-  router.post('/', async(req, res) => {
+  router.post('/', checkPassportStrategyMiddleware, async(req, res) => {
     const { email } = req.body;
     const grobalLang = configManager.getConfig('crowi', 'app:globalLang');
     const i18n = req.language || grobalLang;
@@ -81,7 +86,8 @@ module.exports = (crowi) => {
     }
   });
 
-  router.put('/', apiLimiter, injectResetOrderByTokenMiddleware, csrf, validator.password, apiV3FormValidator, async(req, res) => {
+  // eslint-disable-next-line max-len
+  router.put('/', apiLimiter, checkPassportStrategyMiddleware, injectResetOrderByTokenMiddleware, csrf, validator.password, apiV3FormValidator, async(req, res) => {
     const { passwordResetOrder } = req;
     const { email } = passwordResetOrder;
     const grobalLang = configManager.getConfig('crowi', 'app:globalLang');
@@ -109,6 +115,7 @@ module.exports = (crowi) => {
   });
 
   // middleware to handle error
+  router.use(httpErrorHandler);
   router.use((error, req, res, next) => {
     if (error != null) {
       return res.apiv3Err(new ErrorV3(error.message, error.code));

+ 14 - 50
packages/app/src/server/routes/apiv3/page.js

@@ -10,7 +10,7 @@ const express = require('express');
 const { body, query } = require('express-validator');
 
 const router = express.Router();
-const { convertToNewAffiliationPath, isTopPage } = pagePathUtils;
+const { convertToNewAffiliationPath } = pagePathUtils;
 const ErrorV3 = require('../../models/vo/error-apiv3');
 
 
@@ -239,33 +239,36 @@ module.exports = (crowi) => {
       return res.apiv3Err(new ErrorV3('Parameter path or pageId is required.', 'invalid-request'));
     }
 
-    let result = {};
+    let page;
     try {
-      result = await pageService.findPageAndMetaDataByViewer({ pageId, path, user: req.user });
+      if (pageId != null) { // prioritized
+        page = await Page.findByIdAndViewer(pageId, req.user);
+      }
+      else {
+        page = await Page.findByPathAndViewer(path, req.user);
+      }
     }
     catch (err) {
       logger.error('get-page-failed', err);
       return res.apiv3Err(err, 500);
     }
 
-    const page = result.page;
-
     if (page == null) {
-      return res.apiv3(result);
+      return res.apiv3Err('Page is not found', 404);
     }
 
     try {
       page.initLatestRevisionField();
 
       // populate
-      result.page = await page.populateDataToShowRevision();
+      page = await page.populateDataToShowRevision();
     }
     catch (err) {
       logger.error('populate-page-failed', err);
       return res.apiv3Err(err, 500);
     }
 
-    return res.apiv3(result);
+    return res.apiv3({ page });
   });
 
   /**
@@ -360,52 +363,13 @@ module.exports = (crowi) => {
     const { pageId } = req.query;
 
     try {
-      const page = await Page.findByIdAndViewer(pageId, user, null, true);
+      const pageWithMeta = await pageService.findPageAndMetaDataByViewer(pageId, null, user, true, isSharedPage);
 
-      if (page == null) {
+      if (pageWithMeta == null) {
         return res.apiv3Err(`Page '${pageId}' is not found or forbidden`);
       }
 
-      if (isSharedPage) {
-        return {
-          isEmpty: page.isEmpty,
-          isMovable: false,
-          isDeletable: false,
-          isAbleToDeleteCompletely: false,
-        };
-      }
-
-      const isGuestUser = !req.user;
-      const pageInfo = pageService.constructBasicPageInfo(page, isGuestUser);
-
-      const bookmarkCount = await Bookmark.countByPageId(pageId);
-
-      const responseBodyForGuest = {
-        ...pageInfo,
-        bookmarkCount,
-      };
-
-      if (isGuestUser) {
-        return res.apiv3(responseBodyForGuest);
-      }
-
-      const isBookmarked = await Bookmark.findByPageIdAndUserId(pageId, user._id);
-      const isLiked = page.isLiked(user);
-      const isMovable = !isTopPage(page.path);
-      const isAbleToDeleteCompletely = pageService.canDeleteCompletely(page.creator?._id, user);
-
-      const subscription = await Subscription.findByUserIdAndTargetId(user._id, pageId);
-
-      const responseBody = {
-        ...responseBodyForGuest,
-        isMovable,
-        isAbleToDeleteCompletely,
-        isBookmarked,
-        isLiked,
-        subscriptionStatus: subscription?.status,
-      };
-
-      return res.apiv3(responseBody);
+      return res.apiv3(pageWithMeta.pageMeta);
     }
     catch (err) {
       logger.error('get-page-info', err);

+ 15 - 16
packages/app/src/server/routes/apiv3/pages.js

@@ -1,11 +1,10 @@
-import { pagePathUtils } from '@growi/core';
 import loggerFactory from '~/utils/logger';
 
 import { subscribeRuleNames } from '~/interfaces/in-app-notification';
 
 const logger = loggerFactory('growi:routes:apiv3:pages'); // eslint-disable-line no-unused-vars
 const express = require('express');
-const { pathUtils } = require('@growi/core');
+const { pathUtils, pagePathUtils } = require('@growi/core');
 const mongoose = require('mongoose');
 
 const { body } = require('express-validator');
@@ -485,7 +484,7 @@ module.exports = (crowi) => {
 
     const isExist = await Page.count({ path: newPagePath }) > 0;
     if (isExist) {
-      // if page found, cannot cannot rename to that path
+      // if page found, cannot rename to that path
       return res.apiv3Err(new ErrorV3(`${newPagePath} already exists`, 'already_exists'), 409);
     }
 
@@ -637,6 +636,11 @@ module.exports = (crowi) => {
 
     const newPagePath = pathUtils.normalizePath(req.body.pageNameInput);
 
+    const isCreatable = isCreatablePage(newPagePath);
+    if (!isCreatable) {
+      return res.apiv3Err(new ErrorV3('This page path is invalid', 'invalid_path'), 400);
+    }
+
     // check page existence
     const isExist = (await Page.count({ path: newPagePath })) > 0;
     if (isExist) {
@@ -645,7 +649,7 @@ module.exports = (crowi) => {
 
     const page = await Page.findByIdAndViewerToEdit(pageId, req.user, true);
 
-    const isEmptyAndNotRecursively = page?.isEmpty && isRecursively;
+    const isEmptyAndNotRecursively = page?.isEmpty && !isRecursively;
     if (page == null || isEmptyAndNotRecursively) {
       res.code = 'Page is not found';
       logger.error('Failed to find the pages');
@@ -765,7 +769,8 @@ module.exports = (crowi) => {
     }
 
     // run delete
-    crowi.pageService.deleteMultiplePages(pagesCanBeDeleted, req.user, isCompletely, isRecursively);
+    const options = { isCompletely, isRecursively };
+    crowi.pageService.deleteMultiplePages(pagesCanBeDeleted, req.user, options);
 
     return res.apiv3({ paths: pagesCanBeDeleted.map(p => p.path), isRecursively, isCompletely });
   });
@@ -787,7 +792,7 @@ module.exports = (crowi) => {
   });
 
   // eslint-disable-next-line max-len
-  router.post('/legacy-pages-migration', accessTokenParser, loginRequired, adminRequired, csrf, validator.legacyPagesMigration, apiV3FormValidator, async(req, res) => {
+  router.post('/legacy-pages-migration', accessTokenParser, loginRequired, csrf, validator.legacyPagesMigration, apiV3FormValidator, async(req, res) => {
     const { pageIds: _pageIds, isRecursively } = req.body;
     const pageIds = _pageIds == null ? [] : _pageIds;
 
@@ -795,17 +800,11 @@ module.exports = (crowi) => {
       return res.apiv3Err(new ErrorV3(`The maximum number of pages you can select is ${LIMIT_FOR_MULTIPLE_PAGE_OP}.`, 'exceeded_maximum_number'), 400);
     }
 
-    if (isRecursively) {
-      // this method innerly uses socket to send message
-      crowi.pageService.normalizeParentRecursivelyByPageIds(pageIds, req.user);
+    try {
+      await crowi.pageService.normalizeParentByPageIds(pageIds, req.user, isRecursively);
     }
-    else {
-      try {
-        await crowi.pageService.normalizeParentByPageIds(pageIds, req.user);
-      }
-      catch (err) {
-        return res.apiv3Err(new ErrorV3(`Failed to migrate pages: ${err.message}`), 500);
-      }
+    catch (err) {
+      return res.apiv3Err(new ErrorV3(`Failed to migrate pages: ${err.message}`), 500);
     }
 
     return res.apiv3({});

+ 32 - 2
packages/app/src/server/routes/forgot-password.ts

@@ -1,8 +1,38 @@
 import {
   NextFunction, Request, RequestHandler, Response,
 } from 'express';
+
+import createError from 'http-errors';
+
+import loggerFactory from '~/utils/logger';
+
 import { ReqWithPasswordResetOrder } from '../middlewares/inject-reset-order-by-token-middleware';
 
+const logger = loggerFactory('growi:routes:forgot-password');
+
+
+// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types, @typescript-eslint/no-explicit-any
+export const checkForgotPasswordEnabledMiddlewareFactory = (crowi: any, forApi = false) => {
+
+  return (req: Request, res: Response, next: NextFunction): void => {
+    const isPasswordResetEnabled = crowi.configManager.getConfig('crowi', 'security:passport-local:isPasswordResetEnabled') as boolean | null;
+    const isLocalStrategySetup = crowi.passportService.isLocalStrategySetup as boolean ?? false;
+
+    const isEnabled = isLocalStrategySetup && isPasswordResetEnabled;
+
+    if (!isEnabled) {
+      const message = 'Forgot-password function is unavailable because neither LocalStrategy and LdapStrategy is not setup.';
+      logger.error(message);
+
+      const statusCode = forApi ? 405 : 404;
+      return next(createError(statusCode, message, { code: 'password-reset-is-unavailable' }));
+    }
+
+    next();
+  };
+
+};
+
 export const forgotPassword = (req: Request, res: Response): void => {
   return res.render('forgot-password');
 };
@@ -13,9 +43,9 @@ export const resetPassword = (req: ReqWithPasswordResetOrder, res: Response): vo
 };
 
 // middleware to handle error
-export const handleHttpErrosMiddleware = (error: Error & { code: string }, req: Request, res: Response, next: NextFunction): Promise<RequestHandler> | void => {
+export const handleErrosMiddleware = (error: Error & { code: string }, req: Request, res: Response, next: NextFunction): Promise<RequestHandler> | void => {
   if (error != null) {
     return res.render('forgot-password/error', { key: error.code });
   }
-  next();
+  next(error);
 };

+ 6 - 2
packages/app/src/server/routes/index.js

@@ -50,6 +50,7 @@ module.exports = function(crowi, app) {
   const tag = require('./tag')(crowi, app);
   const search = require('./search')(crowi, app);
   const hackmd = require('./hackmd')(crowi, app);
+  const ogp = require('./ogp')(crowi);
 
   const isInstalled = crowi.configManager.getConfig('crowi', 'app:installed');
 
@@ -196,11 +197,12 @@ module.exports = function(crowi, app) {
   app.post('/_api/hackmd.saveOnHackmd'   , accessTokenParser , loginRequiredStrictly , csrf, hackmd.validateForApi, hackmd.saveOnHackmd);
 
   app.use('/forgot-password', express.Router()
+    .use(forgotPassword.checkForgotPasswordEnabledMiddlewareFactory(crowi))
     .get('/', forgotPassword.forgotPassword)
     .get('/:token', apiLimiter, injectResetOrderByTokenMiddleware, forgotPassword.resetPassword)
-    .use(forgotPassword.handleHttpErrosMiddleware));
+    .use(forgotPassword.handleErrosMiddleware));
 
-  app.use('/private-legacy-pages', express.Router()
+  app.use('/_private-legacy-pages', express.Router()
     .get('/', privateLegacyPages.renderPrivateLegacyPages));
   app.use('/user-activation', express.Router()
     .get('/:token', apiLimiter, applicationInstalled, injectUserRegistrationOrderByTokenMiddleware, userActivation.form)
@@ -209,6 +211,8 @@ module.exports = function(crowi, app) {
 
   app.get('/share/:linkId', page.showSharedPage);
 
+  app.use('/ogp', express.Router().get('/:pageId([0-9a-z]{0,})', loginRequired, ogp.pageIdRequired, ogp.ogpValidator, ogp.renderOgp));
+
   app.get('/:id([0-9a-z]{24})'       , loginRequired , injectUserUISettings, page.showPage);
 
   app.get('/*/$'                   , loginRequired , injectUserUISettings, page.redirectorWithEndOfSlash);

+ 148 - 0
packages/app/src/server/routes/ogp.ts

@@ -0,0 +1,148 @@
+import {
+  Request, Response, NextFunction,
+} from 'express';
+import { param, validationResult, ValidationError } from 'express-validator';
+
+import path from 'path';
+import * as fs from 'fs';
+
+import { DevidedPagePath } from '@growi/core';
+import axios from '~/utils/axios';
+import loggerFactory from '~/utils/logger';
+import { projectRoot } from '~/utils/project-dir-utils';
+import { convertStreamToBuffer } from '../util/stream';
+
+const logger = loggerFactory('growi:routes:ogp');
+
+const DEFAULT_USER_IMAGE_URL = '/images/icons/user.svg';
+const DEFAULT_USER_IMAGE_PATH = `public${DEFAULT_USER_IMAGE_URL}`;
+
+let bufferedDefaultUserImageCache: Buffer = Buffer.from('');
+fs.readFile(path.join(projectRoot, DEFAULT_USER_IMAGE_PATH), (err, buffer) => {
+  if (err) throw err;
+  bufferedDefaultUserImageCache = buffer;
+});
+
+
+module.exports = function(crowi) {
+
+  const isUserImageAttachment = (userImageUrlCached: string): boolean => {
+    return /^\/attachment\/.+/.test(userImageUrlCached);
+  };
+
+  const getBufferedUserImage = async(userImageUrlCached: string): Promise<Buffer> => {
+
+    let bufferedUserImage: Buffer;
+
+    if (isUserImageAttachment(userImageUrlCached)) {
+      const { fileUploadService } = crowi;
+      const Attachment = crowi.model('Attachment');
+      const attachment = await Attachment.findById(userImageUrlCached);
+      const fileStream = await fileUploadService.findDeliveryFile(attachment);
+      bufferedUserImage = await convertStreamToBuffer(fileStream);
+      return bufferedUserImage;
+    }
+
+    return (await axios.get(
+      userImageUrlCached, {
+        responseType: 'arraybuffer',
+      },
+    )).data;
+
+  };
+
+  const renderOgp = async(req: Request, res: Response) => {
+
+    const { configManager } = crowi;
+    const ogpUri = configManager.getConfig('crowi', 'app:ogpUri');
+    const page = req.body.page;
+
+    let user;
+    let pageTitle: string;
+    let bufferedUserImage: Buffer;
+
+    try {
+      const User = crowi.model('User');
+      user = await User.findById(page.creator._id.toString());
+
+      bufferedUserImage = user.imageUrlCached === DEFAULT_USER_IMAGE_URL ? bufferedDefaultUserImageCache : (await getBufferedUserImage(user.imageUrlCached));
+      // todo: consider page title
+      pageTitle = (new DevidedPagePath(page.path)).latter;
+    }
+    catch (err) {
+      logger.error(err);
+      return res.status(500).send(`error: ${err}`);
+    }
+
+    let result;
+    try {
+      result = await axios.post(
+        ogpUri, {
+          data: {
+            title: pageTitle,
+            userName: user.username,
+            userImage: bufferedUserImage,
+          },
+        }, {
+          responseType: 'stream',
+        },
+      );
+    }
+    catch (err) {
+      logger.error(err);
+      return res.status(500).send(`error: ${err}`);
+    }
+
+    res.writeHead(200, {
+      'Content-Type': 'image/jpeg',
+    });
+    result.data.pipe(res);
+
+  };
+
+  const pageIdRequired = param('pageId').not().isEmpty().withMessage('page id is not included in the parameter');
+
+  const ogpValidator = async(req:Request, res:Response, next:NextFunction) => {
+    const { aclService, fileUploadService, configManager } = crowi;
+
+    const ogpUri = configManager.getConfig('crowi', 'app:ogpUri');
+
+    if (ogpUri == null) return res.status(400).send('OGP URI for GROWI has not been setup');
+    if (!fileUploadService.getIsUploadable()) return res.status(501).send('This GROWI can not upload file');
+    if (!aclService.isGuestAllowedToRead()) return res.status(501).send('This GROWI is not public');
+
+    const errors = validationResult(req);
+
+    if (errors.isEmpty()) {
+
+      try {
+        const Page = crowi.model('Page');
+        const page = await Page.findByIdAndViewer(req.params.pageId);
+
+        if (page == null || page.status !== Page.STATUS_PUBLISHED || (page.grant !== Page.GRANT_PUBLIC && page.grant !== Page.GRANT_RESTRICTED)) {
+          return res.status(400).send('the page does not exist');
+        }
+
+        req.body.page = page;
+      }
+      catch (error) {
+        logger.error(error);
+        return res.status(500).send(`error: ${error}`);
+      }
+
+      return next();
+    }
+
+    // errors.array length is one bacause pageIdRequired is used
+    const pageIdRequiredError: ValidationError = errors.array()[0];
+
+    return res.status(400).send(pageIdRequiredError.msg);
+  };
+
+  return {
+    renderOgp,
+    pageIdRequired,
+    ogpValidator,
+  };
+
+};

+ 3 - 13
packages/app/src/server/routes/page.js

@@ -6,9 +6,8 @@ import mongoose from 'mongoose';
 import loggerFactory from '~/utils/logger';
 import { PageQueryBuilder } from '../models/obsolete-page';
 import UpdatePost from '../models/update-post';
-import { PageRedirectModel } from '../models/page-redirect';
 
-const { isCreatablePage, isTopPage } = pagePathUtils;
+const { isCreatablePage, isTopPage, isUsersHomePage } = pagePathUtils;
 const { serializePageSecurely } = require('../models/serializers/page-serializer');
 const { serializeRevisionSecurely } = require('../models/serializers/revision-serializer');
 const { serializeUserSecurely } = require('../models/serializers/user-serializer');
@@ -142,7 +141,6 @@ module.exports = function(crowi, app) {
 
   const Page = crowi.model('Page');
   const User = crowi.model('User');
-  const Bookmark = crowi.model('Bookmark');
   const PageTagRelation = crowi.model('PageTagRelation');
   const GlobalNotificationSetting = crowi.model('GlobalNotificationSetting');
   const ShareLink = crowi.model('ShareLink');
@@ -173,14 +171,6 @@ module.exports = function(crowi, app) {
     return pathUtils.normalizePath(req.pagePath || req.params[0] || '');
   }
 
-  function isUserPage(path) {
-    if (path.match(/^\/user\/[^/]+\/?$/)) {
-      return true;
-    }
-
-    return false;
-  }
-
   function generatePager(offset, limit, totalCount) {
     let prev = null;
 
@@ -462,7 +452,7 @@ module.exports = function(crowi, app) {
     const sharelinksNumber = await ShareLink.countDocuments({ relatedPage: page._id });
     renderVars.sharelinksNumber = sharelinksNumber;
 
-    if (isUserPage(path)) {
+    if (isUsersHomePage(path)) {
       // change template
       view = 'layout-growi/user_page';
       await addRenderVarsForUserPage(renderVars, page);
@@ -900,7 +890,7 @@ module.exports = function(crowi, app) {
    * - If revision_id is not specified => force update by the new contents.
    */
   api.update = async function(req, res) {
-    const pageBody = body ?? null;
+    const pageBody = req.body.body ?? null;
     const pageId = req.body.page_id || null;
     const revisionId = req.body.revision_id || null;
     const grant = req.body.grant || null;

+ 4 - 3
packages/app/src/server/routes/search.js

@@ -113,7 +113,7 @@ module.exports = function(crowi, app) {
   api.search = async function(req, res) {
     const user = req.user;
     const {
-      q: keyword = null, type = null, sort = null, order = null,
+      q = null, type = null, sort = null, order = null,
     } = req.query;
     let paginateOpts;
 
@@ -124,8 +124,8 @@ module.exports = function(crowi, app) {
       res.json(ApiResponse.error(e));
     }
 
-    if (keyword === null || keyword === '') {
-      return res.json(ApiResponse.error('keyword should not empty.'));
+    if (q === null || q === '') {
+      return res.json(ApiResponse.error('The param "q" should not empty.'));
     }
 
     const { searchService } = crowi;
@@ -146,6 +146,7 @@ module.exports = function(crowi, app) {
     let searchResult;
     let delegatorName;
     try {
+      const keyword = decodeURIComponent(q);
       [searchResult, delegatorName] = await searchService.searchKeyword(keyword, user, userGroups, searchOpts);
     }
     catch (err) {

+ 17 - 2
packages/app/src/server/service/config-loader.ts

@@ -1,3 +1,5 @@
+import { parseISO } from 'date-fns';
+
 import { envUtils } from '@growi/core';
 
 import loggerFactory from '~/utils/logger';
@@ -9,7 +11,7 @@ import ConfigModel, {
 
 const logger = loggerFactory('growi:service:ConfigLoader');
 
-enum ValueType { NUMBER, STRING, BOOLEAN }
+enum ValueType { NUMBER, STRING, BOOLEAN, DATE }
 
 interface ValueParser<T> {
   parse(value: string): T;
@@ -26,10 +28,11 @@ type EnumDictionary<T extends string | symbol | number, U> = {
   [K in T]: U;
 };
 
-const parserDictionary: EnumDictionary<ValueType, ValueParser<number | string | boolean>> = {
+const parserDictionary: EnumDictionary<ValueType, ValueParser<number | string | boolean | Date>> = {
   [ValueType.NUMBER]:  { parse: (v: string) => { return parseInt(v, 10) } },
   [ValueType.STRING]:  { parse: (v: string) => { return v } },
   [ValueType.BOOLEAN]: { parse: (v: string) => { return envUtils.toBoolean(v) } },
+  [ValueType.DATE]:    { parse: (v: string) => { return parseISO(v) } },
 };
 
 /**
@@ -208,6 +211,12 @@ const ENV_VAR_NAME_TO_CONFIG_INFO = {
     type:    ValueType.STRING,
     default: null,
   },
+  AUTO_INSTALL_SERVER_DATE: {
+    ns:      'crowi',
+    key:     'autoInstall:serverDate',
+    type:    ValueType.DATE,
+    default: null,
+  },
   S2SMSG_PUBSUB_SERVER_TYPE: {
     ns:      'crowi',
     key:     's2sMessagingPubsub:serverType',
@@ -595,6 +604,12 @@ const ENV_VAR_NAME_TO_CONFIG_INFO = {
     type:    ValueType.STRING,
     default: 'ptog',
   },
+  OGP_URI: {
+    ns:      'crowi',
+    key:     'app:ogpUri',
+    type:    ValueType.STRING,
+    default: null,
+  },
 };
 
 

+ 33 - 11
packages/app/src/server/service/installer.ts

@@ -34,7 +34,12 @@ export class InstallerService {
       return;
     }
 
-    await searchService.rebuildIndex();
+    try {
+      await searchService.rebuildIndex();
+    }
+    catch (err) {
+      logger.error('Rebuild index failed', err);
+    }
   }
 
   private async createPage(filePath, pagePath, owner): Promise<IPage|undefined> {
@@ -53,18 +58,31 @@ export class InstallerService {
   }
 
   // eslint-disable-next-line @typescript-eslint/no-explicit-any
-  private async createInitialPages(owner, lang: Lang): Promise<any> {
+  private async createInitialPages(owner, lang: Lang, initialPagesCreatedAt?: Date): Promise<any> {
     const { localeDir } = this.crowi;
     // create /Sandbox/*
     /*
-     * Keep in this order to avoid creating the same pages
+     * Keep in this order to
+     *   1. avoid creating the same pages
+     *   2. avoid difference for order in VRT
      */
     await this.createPage(path.join(localeDir, lang, 'sandbox.md'), '/Sandbox', owner);
-    await Promise.all([
-      this.createPage(path.join(localeDir, lang, 'sandbox-diagrams.md'), '/Sandbox/Diagrams', owner),
-      this.createPage(path.join(localeDir, lang, 'sandbox-bootstrap4.md'), '/Sandbox/Bootstrap4', owner),
-      this.createPage(path.join(localeDir, lang, 'sandbox-math.md'), '/Sandbox/Math', owner),
-    ]);
+    await this.createPage(path.join(localeDir, lang, 'sandbox-bootstrap4.md'), '/Sandbox/Bootstrap4', owner);
+    await this.createPage(path.join(localeDir, lang, 'sandbox-diagrams.md'), '/Sandbox/Diagrams', owner);
+    await this.createPage(path.join(localeDir, lang, 'sandbox-math.md'), '/Sandbox/Math', owner);
+
+    // update createdAt and updatedAt fields of all pages
+    if (initialPagesCreatedAt != null) {
+      try {
+        // TODO typescriptize models/user.js and remove eslint-disable-next-line
+        // eslint-disable-next-line @typescript-eslint/no-explicit-any
+        const Page = mongoose.model('Page') as any;
+        await Page.updateMany({}, { createdAt: initialPagesCreatedAt, updatedAt: initialPagesCreatedAt });
+      }
+      catch (err) {
+        logger.error('Failed to update createdAt', err);
+      }
+    }
 
     try {
       await this.initSearchIndex();
@@ -85,7 +103,7 @@ export class InstallerService {
     return configManager.updateConfigsInTheSameNamespace('crowi', initialConfig, true);
   }
 
-  async install(firstAdminUserToSave: IUser, globalLang: Lang): Promise<IUser> {
+  async install(firstAdminUserToSave: IUser, globalLang: Lang, initialPagesCreatedAt?: Date): Promise<IUser> {
     await this.initDB(globalLang);
 
     // TODO typescriptize models/user.js and remove eslint-disable-next-line
@@ -94,7 +112,11 @@ export class InstallerService {
     const Page = mongoose.model('Page') as any;
 
     // create portal page for '/' before creating admin user
-    await this.createPage(path.join(this.crowi.localeDir, globalLang, 'welcome.md'), '/', { _id: '000000000000000000000000' }); // use 0 as a mock user id
+    await this.createPage(
+      path.join(this.crowi.localeDir, globalLang, 'welcome.md'),
+      '/',
+      { _id: '000000000000000000000000' }, // use 0 as a mock user id
+    );
 
     // create first admin user
     // TODO: with transaction
@@ -120,7 +142,7 @@ export class InstallerService {
     await Promise.all([rootPage.save(), rootRevision.save()]);
 
     // create initial pages
-    await this.createInitialPages(adminUser, globalLang);
+    await this.createInitialPages(adminUser, globalLang, initialPagesCreatedAt);
 
     return adminUser;
   }

+ 2 - 9
packages/app/src/server/service/page-grant.ts

@@ -352,24 +352,17 @@ class PageGrantService {
    * @param pageIds pageIds to be tested
    * @returns a tuple with the first element of normalizable pages and the second element of NOT normalizable pages
    */
-  async separateNormalizableAndNotNormalizablePages(pageIds: ObjectIdLike[]): Promise<[(PageDocument & { _id: any })[], (PageDocument & { _id: any })[]]> {
-    if (pageIds.length > LIMIT_FOR_MULTIPLE_PAGE_OP) {
+  async separateNormalizableAndNotNormalizablePages(pages): Promise<[(PageDocument & { _id: any })[], (PageDocument & { _id: any })[]]> {
+    if (pages.length > LIMIT_FOR_MULTIPLE_PAGE_OP) {
       throw Error(`The maximum number of pageIds allowed is ${LIMIT_FOR_MULTIPLE_PAGE_OP}.`);
     }
 
-    const Page = mongoose.model('Page') as unknown as PageModel;
-    const { PageQueryBuilder } = Page;
     const shouldCheckDescendants = true;
     const shouldIncludeNotMigratedPages = true;
 
     const normalizable: (PageDocument & { _id: any })[] = [];
     const nonNormalizable: (PageDocument & { _id: any })[] = []; // can be used to tell user which page failed to migrate
 
-    const builder = new PageQueryBuilder(Page.find());
-    builder.addConditionToListByPageIdsArray(pageIds);
-
-    const pages = await builder.query.exec();
-
     for await (const page of pages) {
       const {
         path, grant, grantedUsers: grantedUserIds, grantedGroup: grantedGroupId,

+ 101 - 0
packages/app/src/server/service/page-operation.ts

@@ -0,0 +1,101 @@
+import { pagePathUtils, pathUtils } from '@growi/core';
+import escapeStringRegexp from 'escape-string-regexp';
+
+import PageOperation from '~/server/models/page-operation';
+
+const { addTrailingSlash } = pathUtils;
+const { isTrashPage } = pagePathUtils;
+
+class PageOperationService {
+
+  crowi: any;
+
+  constructor(crowi) {
+    this.crowi = crowi;
+
+    // TODO: Remove this code when resuming feature is implemented
+    PageOperation.deleteMany();
+  }
+
+  /**
+   * Check if the operation is operatable by comparing paths with all Main PageOperation documents
+   * @param fromPath The path to operate from
+   * @param toPath The path to operate to
+   * @param actionType The action type of the operation
+   * @returns Promise<boolean>
+   */
+  async canOperate(isRecursively: boolean, fromPathToOp: string | null, toPathToOp: string | null): Promise<boolean> {
+    const mainOps = await PageOperation.findMainOps();
+
+    if (mainOps.length === 0) {
+      return true;
+    }
+
+    const toPaths = mainOps.map(op => op.toPath).filter((p): p is string => p != null);
+
+    if (isRecursively) {
+
+      if (fromPathToOp != null && !isTrashPage(fromPathToOp)) {
+        const flag = toPaths.some(p => this.isEitherOfPathAreaOverlap(p, fromPathToOp));
+        if (flag) return false;
+      }
+
+      if (toPathToOp != null && !isTrashPage(toPathToOp)) {
+        const flag = toPaths.some(p => this.isPathAreaOverlap(p, toPathToOp));
+        if (flag) return false;
+      }
+
+    }
+    else {
+
+      if (fromPathToOp != null && !isTrashPage(fromPathToOp)) {
+        const flag = toPaths.some(p => this.isPathAreaOverlap(p, fromPathToOp));
+        if (flag) return false;
+      }
+
+      if (toPathToOp != null && !isTrashPage(toPathToOp)) {
+        const flag = toPaths.some(p => this.isPathAreaOverlap(p, toPathToOp));
+        if (flag) return false;
+      }
+
+    }
+
+    return true;
+  }
+
+  private isEitherOfPathAreaOverlap(path1: string, path2: string): boolean {
+    if (path1 === path2) {
+      return true;
+    }
+
+    const path1WithSlash = addTrailingSlash(path1);
+    const path2WithSlash = addTrailingSlash(path2);
+
+    const path1Area = new RegExp(`^${escapeStringRegexp(path1WithSlash)}`);
+    const path2Area = new RegExp(`^${escapeStringRegexp(path2WithSlash)}`);
+
+    if (path1Area.test(path2) || path2Area.test(path1)) {
+      return true;
+    }
+
+    return false;
+  }
+
+  private isPathAreaOverlap(pathToTest: string, pathToBeTested: string): boolean {
+    if (pathToTest === pathToBeTested) {
+      return true;
+    }
+
+    const pathWithSlash = addTrailingSlash(pathToTest);
+
+    const pathAreaToTest = new RegExp(`^${escapeStringRegexp(pathWithSlash)}`);
+    if (pathAreaToTest.test(pathToBeTested)) {
+      return true;
+    }
+
+    return false;
+  }
+
+}
+
+export default PageOperationService;

Разница между файлами не показана из-за своего большого размера
+ 484 - 202
packages/app/src/server/service/page.ts


+ 34 - 13
packages/app/src/server/service/search-delegator/elasticsearch.ts

@@ -149,10 +149,16 @@ class ElasticsearchDelegator implements SearchDelegator<Data> {
     };
   }
 
-  async init() {
+  async init(): Promise<void> {
     const normalizeIndices = await this.normalizeIndices();
     if (this.isElasticsearchReindexOnBoot) {
-      return this.rebuildIndex();
+      try {
+        await this.rebuildIndex();
+      }
+      catch (err) {
+        logger.error('Rebuild index on boot failed', err);
+      }
+      return;
     }
     return normalizeIndices;
   }
@@ -284,7 +290,8 @@ class ElasticsearchDelegator implements SearchDelegator<Data> {
       await this.addAllPages();
     }
     catch (error) {
-      logger.warn('An error occured while \'rebuildIndex\', normalize indices anyway.');
+      logger.error('An error occured while \'rebuildIndex\'.', error);
+      logger.error('error.meta.body', error?.meta?.body);
 
       const socket = this.socketIoService.getAdminSocket();
       socket.emit('rebuildingFailed', { error: error.message });
@@ -292,6 +299,7 @@ class ElasticsearchDelegator implements SearchDelegator<Data> {
       throw error;
     }
     finally {
+      logger.warn('Normalize indices anyway.');
       await this.normalizeIndices();
     }
 
@@ -325,8 +333,18 @@ class ElasticsearchDelegator implements SearchDelegator<Data> {
   }
 
   async createIndex(index) {
-    const body = this.isElasticsearchV6 ? require('^/resource/search/mappings-es6.json') : require('^/resource/search/mappings-es7.json');
-    return this.client.indices.create({ index, body });
+    let mappings = this.isElasticsearchV6
+      ? require('^/resource/search/mappings-es6.json')
+      : require('^/resource/search/mappings-es7.json');
+
+    if (process.env.CI) {
+      mappings = require('^/resource/search/mappings-es6-for-ci.json');
+    }
+
+    return this.client.indices.create({
+      index,
+      body: mappings,
+    });
   }
 
   /**
@@ -368,7 +386,7 @@ class ElasticsearchDelegator implements SearchDelegator<Data> {
     };
 
     const bookmarkCount = page.bookmarkCount || 0;
-    const seenUsersCount = page.seenUsers.length || 0;
+    const seenUsersCount = page.seenUsers?.length || 0;
     let document = {
       path: page.path,
       body: page.revision.body,
@@ -378,7 +396,7 @@ class ElasticsearchDelegator implements SearchDelegator<Data> {
       comment_count: page.commentCount,
       bookmark_count: bookmarkCount,
       seenUsers_count: seenUsersCount,
-      like_count: page.liker.length || 0,
+      like_count: page.liker?.length || 0,
       created_at: page.createdAt,
       updated_at: page.updatedAt,
       tag_names: page.tagNames,
@@ -614,22 +632,25 @@ class ElasticsearchDelegator implements SearchDelegator<Data> {
    * }
    */
   async searchKeyword(query): Promise<IFormattedSearchResult> {
+
     // for debug
     if (process.env.NODE_ENV === 'development') {
+      logger.debug('query: ', { query });
+
       const { body: result } = await this.client.indices.validateQuery({
+        index: query.index,
+        type: query.type,
         explain: true,
         body: {
           query: query.body.query,
         },
       });
-      logger.debug('ES returns explanations: ', result.explanations);
+      // for debug
+      logger.debug('ES result: ', result);
     }
 
     const { body: result } = await this.client.search(query);
 
-    // for debug
-    logger.debug('ES result: ', result);
-
     const totalValue = this.isElasticsearchV6 ? result.hits.total : result.hits.total.value;
 
     return {
@@ -665,9 +686,9 @@ class ElasticsearchDelegator implements SearchDelegator<Data> {
     // eslint-disable-next-line prefer-const
     let query = {
       index: this.aliasName,
+      _source: fields,
       body: {
         query: {}, // query
-        _source: fields,
       },
     };
 
@@ -687,7 +708,7 @@ class ElasticsearchDelegator implements SearchDelegator<Data> {
     // default sort order is score descending
     const sort = ES_SORT_AXIS[sortAxis] || ES_SORT_AXIS[RELATION_SCORE];
     const order = ES_SORT_ORDER[sortOrder] || ES_SORT_ORDER[DESC];
-    query.body.sort = { [sort]: { order } };
+    query.sort = { [sort]: { order } };
   }
 
   convertSortQuery(sortAxis) {

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