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

Merge branch 'master' into feat/emoji

cao пре 3 година
родитељ
комит
e8f3426466
100 измењених фајлова са 2146 додато и 974 уклоњено
  1. 7 0
      .devcontainer/Dockerfile
  2. 26 0
      .github/workflows/ci-app-prod.yml
  3. 13 2
      .github/workflows/ci-app.yml
  4. 8 0
      .github/workflows/ci-slackbot-proxy.yml
  5. 6 0
      .github/workflows/codeql-analysis.yml
  6. 2 1
      .github/workflows/draft-release.yml
  7. 3 2
      .github/workflows/pr-to-master.yml
  8. 3 10
      .github/workflows/release-rc.yml
  9. 5 12
      .github/workflows/release-slackbot-proxy.yml
  10. 4 12
      .github/workflows/release.yml
  11. 14 19
      .github/workflows/reusable-app-prod.yml
  12. 5 1
      .github/workflows/reusable-app-reg-suit.yml
  13. 124 11
      CHANGELOG.md
  14. 1 1
      lerna.json
  15. 1 1
      package.json
  16. 2 2
      packages/app/cypress.json
  17. 2 6
      packages/app/docker/Dockerfile
  18. 2 2
      packages/app/docker/README.md
  19. 8 8
      packages/app/package.json
  20. 25 5
      packages/app/resource/locales/en_US/translation.json
  21. 26 5
      packages/app/resource/locales/ja_JP/translation.json
  22. 26 5
      packages/app/resource/locales/zh_CN/translation.json
  23. 41 38
      packages/app/src/client/app.jsx
  24. 9 8
      packages/app/src/client/base.jsx
  25. 10 6
      packages/app/src/client/nologin.jsx
  26. 9 7
      packages/app/src/client/services/ContextExtractor.tsx
  27. 8 7
      packages/app/src/client/util/GrowiRenderer.js
  28. 50 0
      packages/app/src/client/util/markdown-it/link-by-relative-path.ts
  29. 1 1
      packages/app/src/components/Admin/Users/UserInviteModal.jsx
  30. 17 0
      packages/app/src/components/Common/CountBadge.tsx
  31. 35 36
      packages/app/src/components/Common/Dropdown/PageItemControl.tsx
  32. 5 2
      packages/app/src/components/CustomNavigation/CustomNav.jsx
  33. 4 1
      packages/app/src/components/CustomNavigation/CustomNavAndContents.jsx
  34. 61 0
      packages/app/src/components/EmptyTrashButton.tsx
  35. 0 71
      packages/app/src/components/EmptyTrashModal.jsx
  36. 92 0
      packages/app/src/components/EmptyTrashModal.tsx
  37. 7 7
      packages/app/src/components/Fab.jsx
  38. 14 4
      packages/app/src/components/InAppNotification/InAppNotificationElm.tsx
  39. 55 0
      packages/app/src/components/MaintenanceModeContent.tsx
  40. 7 3
      packages/app/src/components/Me/ApiSettings.jsx
  41. 5 4
      packages/app/src/components/Me/AssociateModal.jsx
  42. 10 3
      packages/app/src/components/Me/BasicInfoSettings.jsx
  43. 11 4
      packages/app/src/components/Me/EditorSettings.tsx
  44. 12 4
      packages/app/src/components/Me/ExternalAccountLinkedMe.jsx
  45. 4 2
      packages/app/src/components/Me/InAppNotificationSettings.tsx
  46. 8 3
      packages/app/src/components/Me/PasswordSettings.jsx
  47. 8 4
      packages/app/src/components/Me/PersonalSettings.jsx
  48. 7 5
      packages/app/src/components/Me/ProfileImageSettings.jsx
  49. 4 3
      packages/app/src/components/Me/UserSettings.jsx
  50. 7 8
      packages/app/src/components/Navbar/GrowiNavbar.tsx
  51. 4 3
      packages/app/src/components/Navbar/PageEditorModeManager.jsx
  52. 21 34
      packages/app/src/components/Navbar/PersonalDropdown.jsx
  53. 16 14
      packages/app/src/components/Page/DisplaySwitcher.tsx
  54. 5 3
      packages/app/src/components/PageAttachment.jsx
  55. 8 22
      packages/app/src/components/PageCreateModal.jsx
  56. 12 10
      packages/app/src/components/PageDeleteModal.tsx
  57. 5 7
      packages/app/src/components/PageDuplicateModal.tsx
  58. 10 11
      packages/app/src/components/PageList/PageListItemL.tsx
  59. 48 21
      packages/app/src/components/PageRenameModal.tsx
  60. 192 52
      packages/app/src/components/PrivateLegacyPages.tsx
  61. 3 3
      packages/app/src/components/PrivateLegacyPagesMigrationModal.tsx
  62. 8 5
      packages/app/src/components/PutbackPageModal.jsx
  63. 7 5
      packages/app/src/components/SearchPage.tsx
  64. 70 58
      packages/app/src/components/SearchPage/SearchControl.tsx
  65. 3 2
      packages/app/src/components/SearchTypeahead.tsx
  66. 0 89
      packages/app/src/components/ShareLink/ShareLinkList.jsx
  67. 113 0
      packages/app/src/components/ShareLink/ShareLinkList.tsx
  68. 73 0
      packages/app/src/components/Sidebar/InfiniteScroll.tsx
  69. 15 35
      packages/app/src/components/Sidebar/PageTree/Item.tsx
  70. 18 12
      packages/app/src/components/Sidebar/RecentChanges.tsx
  71. 1 1
      packages/app/src/components/Sidebar/SidebarNav.tsx
  72. 56 16
      packages/app/src/components/Sidebar/Tag.tsx
  73. 32 19
      packages/app/src/components/TagCloudBox.tsx
  74. 76 0
      packages/app/src/components/TagList.tsx
  75. 59 0
      packages/app/src/components/TagPage.tsx
  76. 0 126
      packages/app/src/components/TagsList.jsx
  77. 9 2
      packages/app/src/components/TrashPageList.jsx
  78. 48 0
      packages/app/src/interfaces/activity.ts
  79. 7 0
      packages/app/src/interfaces/errors/v5-conversion-error.ts
  80. 12 0
      packages/app/src/interfaces/tag.ts
  81. 6 0
      packages/app/src/interfaces/websocket.ts
  82. 25 0
      packages/app/src/migrations/20220411114257-set-sparse-option-to-slack-member-id.js
  83. 2 2
      packages/app/src/server/crowi/index.js
  84. 18 6
      packages/app/src/server/interfaces/search.ts
  85. 6 8
      packages/app/src/server/models/activity.ts
  86. 12 0
      packages/app/src/server/models/errors.ts
  87. 6 5
      packages/app/src/server/models/in-app-notification.ts
  88. 0 2
      packages/app/src/server/models/obsolete-page.js
  89. 115 19
      packages/app/src/server/models/page.ts
  90. 3 4
      packages/app/src/server/models/subscription.ts
  91. 13 4
      packages/app/src/server/models/user.js
  92. 28 0
      packages/app/src/server/models/vo/search-error.ts
  93. 28 0
      packages/app/src/server/models/vo/v5-conversion-error.ts
  94. 4 3
      packages/app/src/server/routes/apiv3/attachment.js
  95. 10 7
      packages/app/src/server/routes/apiv3/forgot-password.js
  96. 7 2
      packages/app/src/server/routes/apiv3/index.js
  97. 16 0
      packages/app/src/server/routes/apiv3/logout.js
  98. 5 1
      packages/app/src/server/routes/apiv3/page.js
  99. 85 17
      packages/app/src/server/routes/apiv3/pages.js
  100. 12 8
      packages/app/src/server/routes/apiv3/share-links.js

+ 7 - 0
.devcontainer/Dockerfile

@@ -30,8 +30,15 @@ RUN chown -R $USER_UID:$USER_GID /home/$USERNAME /workspace;
 # * See https://docs.docker.com/engine/reference/builder/#run *
 # *************************************************************
 ENV DEBIAN_FRONTEND=noninteractive
+
+# Prepare to install Chrome for VRT
+RUN sh -c 'echo "deb [arch=amd64] http://dl.google.com/linux/chrome/deb/ stable main" >> /etc/apt/sources.list.d/google.list'
+RUN wget -q -O - https://dl.google.com/linux/linux_signing_key.pub | sudo apt-key add -
+
 RUN apt-get update \
    && apt-get -y install --no-install-recommends git-lfs \
+      # Chrome
+      google-chrome-stable \
       # for Cypress
       libgtk2.0-0 libgtk-3-0 libgbm-dev libnotify-dev libgconf-2-4 libnss3 libxss1 libasound2 libxtst6 xauth xvfb fonts-noto-cjk \
 

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

@@ -4,10 +4,34 @@ on:
   push:
     branches:
       - master
+    paths:
+      - .github/workflows/ci-app-prod.yml
+      - .github/workflows/reusable-app-prod.yml
+      - .github/workflows/reusable-app-reg-suit.yml
+      - tsconfig.base.json
+      - yarn.lock
+      - packages/app/**
+      - '!packages/app/docker/**'
+      - packages/core/**
+      - packages/slack/**
+      - packages/ui/**
+      - packages/plugin-**
   pull_request:
     branches:
         - master
     types: [opened, reopened, synchronize]
+    paths:
+      - .github/workflows/ci-app-prod.yml
+      - .github/workflows/reusable-app-prod.yml
+      - .github/workflows/reusable-app-reg-suit.yml
+      - tsconfig.base.json
+      - yarn.lock
+      - packages/app/**
+      - '!packages/app/docker/**'
+      - packages/core/**
+      - packages/slack/**
+      - packages/ui/**
+      - packages/plugin-**
 
 jobs:
 
@@ -24,6 +48,7 @@ jobs:
     uses: weseek/growi/.github/workflows/reusable-app-prod.yml@master
     with:
       node-version: 16.x
+      skip-cypress: ${{ contains( github.event.pull_request.labels.*.name, 'dependencies' ) && contains( github.event.pull_request.labels.*.name, 'github_actions' ) }}
       cypress-report-artifact-name: Cypress report
     secrets:
       SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
@@ -38,6 +63,7 @@ jobs:
 
     with:
       node-version: 16.x
+      skip-reg-suit: ${{ contains( github.event.pull_request.labels.*.name, 'dependencies' ) && contains( github.event.pull_request.labels.*.name, 'github_actions' ) }}
       cypress-report-artifact-name: Cypress report
     secrets:
       REG_NOTIFY_GITHUB_PLUGIN_CLIENTID: ${{ secrets.REG_NOTIFY_GITHUB_PLUGIN_CLIENTID }}

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

@@ -7,6 +7,17 @@ on:
       - rc/**
       - chore/**
       - support/prepare-v**
+    paths:
+      - .github/workflows/ci-app.yml
+      - .eslint*
+      - tsconfig.base.json
+      - yarn.lock
+      - packages/app/**
+      - '!packages/app/docker/**'
+      - packages/core/**
+      - packages/slack/**
+      - packages/ui/**
+      - packages/plugin-*/**
 
 jobs:
   lint:
@@ -45,7 +56,7 @@ jobs:
           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
+          yarn lerna run lint --scope @growi/app --scope @growi/codemirror-textlint --scope @growi/core --scope @growi/ui
 
       - name: Slack Notification
         uses: weseek/ghaction-slack-notification@master
@@ -102,7 +113,7 @@ jobs:
           MONGO_URI: mongodb://localhost:${{ job.services.mongodb.ports['27017'] }}/growi_test
 
       - name: Upload coverage report as artifact
-        uses: actions/upload-artifact@v2
+        uses: actions/upload-artifact@v3
         with:
           name: Coverage Report
           path: packages/app/coverage

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

@@ -7,6 +7,14 @@ on:
       - rc/**
       - chore/**
       - support/prepare-v**
+    paths:
+      - .github/workflows/ci-slackbot-proxy.yml
+      - .eslint*
+      - tsconfig.base.json
+      - yarn.lock
+      - packages/slackbot-proxy/**
+      - '!packages/slackbot-proxy/docker/**'
+      - packages/slack/**
 
 jobs:
 

+ 6 - 0
.github/workflows/codeql-analysis.yml

@@ -14,9 +14,15 @@ name: "CodeQL"
 on:
   push:
     branches: [ master, dev/*, release/current ]
+    paths:
+      - .github/workflows/codeql-analysis.yml
+      - packages/**
   pull_request:
     # The branches below must be a subset of the branches above
     branches: [ master ]
+    paths:
+      - .github/workflows/codeql-analysis.yml
+      - packages/**
   schedule:
     - cron: '28 20 * * 6'
 

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

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

+ 3 - 2
.github/workflows/pr-to-master.yml

@@ -13,7 +13,8 @@ jobs:
   auto-labeling:
     runs-on: ubuntu-latest
 
-    if: ${{ !contains(github.event.pull_request.labels.*.name, 'exclude from changelog') }}
+    if: |
+      !contains(github.event.pull_request.labels.*.name, 'exclude from changelog')
 
     steps:
       - uses: release-drafter/release-drafter@v5
@@ -30,7 +31,7 @@ jobs:
         !startsWith( github.head_ref, 'dependabot/' ))
 
     steps:
-      - uses: amannn/action-semantic-pull-request@v3.4.5
+      - uses: amannn/action-semantic-pull-request@v4.2.0
         with:
           types: |
             feat

+ 3 - 10
.github/workflows/release-rc.yml

@@ -43,14 +43,6 @@ jobs:
     - name: Set up Docker Buildx
       uses: docker/setup-buildx-action@v1
 
-    - name: Cache Docker layers
-      uses: actions/cache@v3
-      with:
-        path: /tmp/.buildx-cache
-        key: ${{ runner.os }}-buildx-app-${{ github.sha }}
-        restore-keys: |
-          ${{ runner.os }}-buildx-app-
-
     - name: Build and push
       uses: docker/build-push-action@v2
       with:
@@ -58,8 +50,9 @@ jobs:
         file: ./packages/app/docker/Dockerfile
         platforms: linux/amd64
         push: true
-        cache-from: type=local,src=/tmp/.buildx-cache
-        cache-to: type=local,mode=max,dest=/tmp/.buildx-cache-new
+        builder: ${{ steps.buildx.outputs.name }}
+        cache-from: type=gha
+        cache-to: type=gha,mode=max
         tags: ${{ steps.meta.outputs.tags }}
 
     - name: Move cache

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

@@ -56,14 +56,6 @@ jobs:
     - name: Set up Docker Buildx
       uses: docker/setup-buildx-action@v1
 
-    - name: Cache Docker layers
-      uses: actions/cache@v3
-      with:
-        path: /tmp/.buildx-cache
-        key: ${{ runner.os }}-buildx-slackbot-proxy-${{ github.sha }}
-        restore-keys: |
-          ${{ runner.os }}-buildx-slackbot-proxy-
-
     - name: Build and push
       uses: docker/build-push-action@v2
       with:
@@ -71,8 +63,9 @@ jobs:
         file: ./packages/slackbot-proxy/docker/Dockerfile
         platforms: linux/amd64
         push: true
-        cache-from: type=local,src=/tmp/.buildx-cache
-        cache-to: type=local,mode=max,dest=/tmp/.buildx-cache-new
+        builder: ${{ steps.buildx.outputs.name }}
+        cache-from: type=gha
+        cache-to: type=gha,mode=max
         tags: ${{ steps.meta.outputs.tags }}
 
     - name: Move cache
@@ -81,7 +74,7 @@ jobs:
         mv /tmp/.buildx-cache-new /tmp/.buildx-cache
 
     - name: Add tag
-      uses: anothrNick/github-tag-action@1.36.0
+      uses: anothrNick/github-tag-action@1.38.0
       env:
         GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
         CUSTOM_TAG: v${{ steps.package-json.outputs.packageVersion }}
@@ -127,7 +120,7 @@ jobs:
         workingDir: packages/slackbot-proxy
 
     - name: Commit
-      uses: github-actions-x/commit@v2.8
+      uses: github-actions-x/commit@v2.9
       with:
         github-token: ${{ secrets.GITHUB_TOKEN }}
         push-branch: support/prepare-v${{ steps.package-json.outputs.packageVersion }}

+ 4 - 12
.github/workflows/release.yml

@@ -103,7 +103,7 @@ jobs:
       id: package-json
 
     - name: Commit
-      uses: github-actions-x/commit@v2.8
+      uses: github-actions-x/commit@v2.9
       with:
         github-token: ${{ secrets.GITHUB_TOKEN }}
         push-branch: support/prepare-v${{ steps.package-json.outputs.packageVersion }}
@@ -169,15 +169,6 @@ jobs:
     - name: Set up Docker Buildx
       uses: docker/setup-buildx-action@v1
 
-    - name: Cache Docker layers
-      uses: actions/cache@v3
-      with:
-        path: /tmp/.buildx-cache
-        key: ${{ runner.os }}-buildx-app-${{ matrix.flavor }}-${{ github.sha }}
-        restore-keys: |
-          ${{ runner.os }}-buildx-app-${{ matrix.flavor }}-
-          ${{ runner.os }}-buildx-app-
-
     - name: Build and push
       uses: docker/build-push-action@v2
       with:
@@ -187,8 +178,9 @@ jobs:
         push: true
         build-args: |
           flavor=${{ matrix.flavor }}
-        cache-from: type=local,src=/tmp/.buildx-cache
-        cache-to: type=local,mode=max,dest=/tmp/.buildx-cache-new
+        builder: ${{ steps.buildx.outputs.name }}
+        cache-from: type=gha
+        cache-to: type=gha,mode=max
         tags: ${{ steps.meta.outputs.tags }}
 
     - name: Move cache

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

@@ -63,13 +63,13 @@ jobs:
         echo ::set-output name=file::production.tar
 
     - name: Upload production files as artifact
-      uses: actions/upload-artifact@v2
+      uses: actions/upload-artifact@v3
       with:
         name: Production Files
         path: ${{ steps.archive-prod-files.outputs.file }}
 
     - name: Upload report as artifact
-      uses: actions/upload-artifact@v2
+      uses: actions/upload-artifact@v3
       with:
         name: Bundle Analyzing Report
         path: packages/app/report/bundle-analyzer.html
@@ -140,7 +140,7 @@ jobs:
         npx lerna bootstrap -- --production
 
     - name: Download production files artifact
-      uses: actions/download-artifact@v2
+      uses: actions/download-artifact@v3
       with:
         name: Production Files
 
@@ -175,17 +175,12 @@ jobs:
     if: ${{ !inputs.skip-cypress }}
 
     runs-on: ubuntu-latest
-    container:
-      image: cypress/base:16.13.0
-      # solve permissions issue
-      # see: https://github.com/cypress-io/github-action/issues/446#issuecomment-987015822
-      options: --user 1001
 
     strategy:
       fail-fast: false
       matrix:
         # List string expressions that is comma separated ids of tests in "test/cypress/integration"
-        spec-group: ['1', '2', '3', '4']
+        spec-group: ['1', '2', '3', '4', '6']
 
     services:
       mongodb:
@@ -202,10 +197,11 @@ jobs:
     steps:
     - uses: actions/checkout@v3
 
-    - name: Get yarn cache dir
-      id: yarn-cache-dir
-      run: |
-        echo "::set-output name=value::`yarn cache dir --silent`"
+    - uses: actions/setup-node@v3
+      with:
+        node-version: ${{ matrix.node-version }}
+        cache: 'yarn'
+        cache-dependency-path: '**/yarn.lock'
 
     - name: Cache/Restore dependencies
       uses: actions/cache@v3
@@ -213,7 +209,6 @@ jobs:
         path: |
           **/node_modules
           ~/.cache/Cypress
-          ${{ steps.yarn-cache-dir.outputs.value }}
         key: deps-for-cypress-${{ runner.OS }}-node${{ inputs.node-version }}-${{ hashFiles('**/yarn.lock') }}-${{ hashFiles('packages/app/package.json') }}
         restore-keys: |
           deps-for-cypress-${{ runner.OS }}-node${{ inputs.node-version }}-${{ hashFiles('**/yarn.lock') }}-
@@ -224,7 +219,7 @@ jobs:
         npx lerna bootstrap -- --frozen-lockfile
 
     - name: Download production files artifact
-      uses: actions/download-artifact@v2
+      uses: actions/download-artifact@v3
       with:
         name: Production Files
 
@@ -252,18 +247,18 @@ jobs:
     - name: Cypress Run
       uses: cypress-io/github-action@v3
       with:
+        browser: chrome
         working-directory: ./packages/app
-        install: false
         spec: '${{ steps.determine-spec-exp.outputs.value }}'
         start: yarn server
         wait-on: 'http://localhost:3000'
       env:
-        MONGO_URI: mongodb://mongodb:27017/growi-vrt
-        ELASTICSEARCH_URI: http://elasticsearch:9200/growi
+        MONGO_URI: mongodb://localhost:${{ job.services.mongodb.ports['27017'] }}/growi-vrt
+        ELASTICSEARCH_URI: http://localhost:${{ job.services.elasticsearch.ports['9200'] }}/growi
 
     - name: Upload results
       if: always()
-      uses: actions/upload-artifact@v2
+      uses: actions/upload-artifact@v3
       with:
         name: ${{ inputs.cypress-report-artifact-name }}
         path: |

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

@@ -9,6 +9,8 @@ on:
       checkout-ref:
         type: string
         default: ${{ github.head_ref }}
+      skip-reg-suit:
+        type: boolean
       cypress-report-artifact-name:
         required: true
         type: string
@@ -33,6 +35,8 @@ jobs:
     # https://github.com/weseek/growi/settings/environments/376165508/edit
     environment: VRT
 
+    if: ${{ !inputs.skip-reg-suit }}
+
     env:
       REG_NOTIFY_GITHUB_PLUGIN_CLIENTID: ${{ secrets.REG_NOTIFY_GITHUB_PLUGIN_CLIENTID }}
       AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
@@ -71,7 +75,7 @@ jobs:
         npx lerna bootstrap -- --frozen-lockfile
 
     - name: Download screenshots taken by cypress
-      uses: actions/download-artifact@v2
+      uses: actions/download-artifact@v3
       with:
         name: ${{ inputs.cypress-report-artifact-name }}
         path: packages/app/test/cypress

+ 124 - 11
CHANGELOG.md

@@ -1,9 +1,132 @@
 # Changelog
 
-## [Unreleased](https://github.com/weseek/growi/compare/v5.0.0...HEAD)
+## [Unreleased](https://github.com/weseek/growi/compare/v5.0.4...HEAD)
 
 *Please do not manually update this file. We've automated the process.*
 
+## [v5.0.4](https://github.com/weseek/growi/compare/v5.0.3...v5.0.4) - 2022-04-28
+
+### 💎 Features
+
+- feat: Private legacy pages convert by path (#5787) @hakumizuki
+- feat: Generate activity when page is created (#5765) @miya
+- feat: Private legacy pages convert by path API (#5760) @hakumizuki
+- feat:  Create notification when page is reverted (#5756) @miya
+- feat: Create notification when page is duplicated (#5749) @miya
+- feat: Add count badge to Page List button and Comment button (#5740) @yukendev
+- feat: Infinite scroll for Recent Changes in Sidebar (#5647) @mudana-grune
+
+### 🚀 Improvement
+
+- imprv: Change GET method to POST for logout operation (#5751) @kaoritokashiki
+- imprv: Redesign tags (#5730) @miya
+- imprv: i18n for already_exists error in PutBackPageModal (#5747) @kaoritokashiki
+
+### 🐛 Bug Fixes
+
+- fix: Default markdown linker with relative path does not respect the current page path (#5788) @yuki-takei
+- fix: Include any public pages as applicable ancestors (#5786) @hakumizuki
+- fix: Not create unnecessary empty pages when ancestors are public (#5774) @hakumizuki
+- fix: Too many footstamps icons are shown by lsx output 2 (#5763) @yuki-takei
+- fix:  footstamp-icon size (#5759) @kaoritokashiki
+
+## [v4.5.19](https://github.com/weseek/growi/compare/v4.5.18...v4.5.19) - 2022-04-28
+
+### 🐛 Bug Fixes
+
+- fix: Swiping to previous/next page for Mac users (4.5.x) (#5758) @hirokei-camel
+- fix: Get attachment list api without "page" parameter returns 500 response (#5726) @miya
+
+## [v5.0.3](https://github.com/weseek/growi/compare/v5.0.2...v5.0.3) - 2022-04-21
+
+### 💎 Features
+
+- feat: Search on private legacy pages (#5723) @hakumizuki
+
+### 🚀 Improvement
+
+- imprv: Dark theme color optimization (#5737) @shukmos
+- imprv: Change the order of menu items (#5722) @miya
+
+### 🐛 Bug Fixes
+
+- fix: Get attachment list api without "page" parameter returns 500 response (#5726) @miya
+- fix: New user notification email is also sent TO: deleted_at_<epoch_time>@deleted (#5735) @yuki-takei
+- fix: Too many footstamps icons are shown by lsx output (#5727) @yuki-takei
+
+## [v5.0.2](https://github.com/weseek/growi/compare/v5.0.1...v5.0.2) - 2022-04-15
+
+### 🐛 Bug Fixes
+
+- fix: Edit button to open built-in editor does not work when HackMD is disabled (#5719) @yuki-takei
+- fix: Share link list occures error when related page is not found (#5718) @yuki-takei
+
+## [v5.0.1](https://github.com/weseek/growi/compare/v5.0.0...v5.0.1) - 2022-04-15
+
+### 💎 Features
+
+- feat: Input Slack member ID (#5412) @mudana-grune
+- feat: Remove child group from parent group (#5600) @miya
+
+### 🚀 Improvement
+
+- imprv: Add spinner to tag sidebar (#5700) @miya
+- imprv: Adjust pagelist and comment position (#5682) @Yohei-Shiina
+- imprv: Adjust layout for PageTree Descendant Count (#5666) @miya
+- imprv: adjust spaces in page item control and subnav btn (#5655) @Yohei-Shiina
+- imprv: Clickable area of PageListItemL (#5665) @yuki-takei
+- imprv: Add an expiration date for the link in the email (#5660) @miya
+- imprv: remove min-width from search-sort-option-btn (#5656) @kaoritokashiki
+
+### 🐛 Bug Fixes
+
+- fix: Correction of expiredAt attached to email (#5715) @miya
+- fix: Normalize parent so it does not include siblings (#5678) @hakumizuki
+- fix: Prevent auto completing email with username stored by browser in /me page (#5702) @Yohei-Shiina
+- fix: Do not include granted users if change page permission restricted (#5693) @miya
+- fix: Do not include in search results if the page grant is restricted (#5691) @miya
+- fix: Password reset gives error update password failed when submitting a new password (#5685) @kaoritokashiki
+- fix: Cannot register new users (#5683) @kaoritokashiki
+- fix: Sync change of count for both like and bookmark in search page (#5667) @Yohei-Shiina
+- imprv: Adjust layout for PageTree Descendant Count (#5666) @miya
+- fix: HackMD disabled tooltip on mobile (#5658) @yuki-takei
+- fix: One Time Token is not available (#5654) @miya
+- fix: Page items disappear when dnd (#5651) @miya
+
+### 🧰 Maintenance
+
+- ci(deps): bump anothrNick/github-tag-action from 1.36.0 to 1.38.0 (#5271) @dependabot
+- ci(deps): bump amannn/action-semantic-pull-request from 3.4.5 to 4.2.0 (#5627) @dependabot
+- ci(deps): bump actions/upload-artifact from 2 to 3 (#5686) @dependabot
+- ci(deps): bump actions/download-artifact from 2 to 3 (#5687) @dependabot
+- support: Migration for setting sparce option to slack member id (#5694) @kaoritokashiki
+- support: Update eslint-config-weseek (#5673) @yuki-takei
+
+## [v4.5.18](https://github.com/weseek/growi/compare/v4.5.17...v4.5.18) - 2022-04-15
+
+### 🐛 Bug Fixes
+
+- fix: One Time Token is not available for v4.5.x (#5713) @miya
+- fix: Prevent auto completing email with username stored by browser in /me page for v4.5.x (#5703) @Yohei-Shiina
+- fix: Page view count stops at 15 (#5705) @miya
+
+## [v4.5.17](https://github.com/weseek/growi/compare/v4.5.16...v4.5.17) - 2022-04-07
+
+### 🐛 Bug Fixes
+
+- fix: Elasticsearch doesn't work properly on production (#5676) @Yohei-Shiina
+
+## [v4.5.16](https://github.com/weseek/growi/compare/v4.5.15...v4.5.16) - 2022-04-06
+
+### 💎 Features
+
+- feat: Support Elasticsearch 7 (#5613) @Yohei-Shiina
+
+### 🐛 Bug Fixes
+
+- fix: Domain whitelist is not respected (fix #5408) (#5488) @yuto-oweseek
+- fix: Add tags to pages restricted by specified groups on View mode (for v4.5.x) (#5487) @yuto-oweseek
+
 ## [v5.0.0](https://github.com/weseek/growi/compare/v4.5.15...v5.0.0) - 2022-04-01
 
 ### 💎 Features
@@ -54,16 +177,6 @@
 - support: update nanoid yarn.lock v3.1.30 to v3.2.0 (#5216) @LuqmanHakim-Grune
 - support: update validator version (#5562) @LuqmanHakim-Grune
 
-## [v4.5.16](https://github.com/weseek/growi/compare/v4.5.15...v4.5.16) - 2022-04-06
-
-### 💎 Features
-
-- feat: Support Elasticsearch 7 (#5613) @Yohei-Shiina
-
-### 🐛 Bug Fixes
-
-- fix: Domain whitelist is not respected (fix #5408) (#5488) @yuto-oweseek
-- fix: Add tags to pages restricted by specified groups on View mode (for v4.5.x) (#5487) @yuto-oweseek
 
 ## [v4.5.15](https://github.com/weseek/growi/compare/v4.5.14...v4.5.15) - 2022-02-17
 

+ 1 - 1
lerna.json

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

+ 1 - 1
package.json

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

+ 2 - 2
packages/app/cypress.json

@@ -10,8 +10,8 @@
   "pluginsFile": "test/cypress/plugins/index.ts",
   "testFiles": "**/*.spec.ts",
 
-  "viewportWidth": 1440,
-  "viewportHeight": 1200,
+  "viewportWidth": 1400,
+  "viewportHeight": 1024,
 
   "experimentalSessionSupport": true
 }

+ 2 - 6
packages/app/docker/Dockerfile

@@ -1,4 +1,4 @@
-# syntax = docker/dockerfile:1
+# syntax = docker/dockerfile:1.4
 
 ARG flavor=default
 
@@ -157,11 +157,7 @@ RUN tar -xf node_modules.tar
 RUN tar -xf packages.tar
 RUN rm node_modules.tar packages.tar
 
-USER root
-
-COPY packages/app/docker/docker-entrypoint.sh /
-RUN chmod 700 /docker-entrypoint.sh
-RUN chown node:node ${appDir}
+COPY --chown=node:node --chmod=700 packages/app/docker/docker-entrypoint.sh /
 
 WORKDIR ${appDir}/packages/app
 

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

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

+ 8 - 8
packages/app/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@growi/app",
-  "version": "5.0.1-RC.0",
+  "version": "5.0.5-RC.0",
   "license": "MIT",
   "scripts": {
     "//// for production": "",
@@ -28,7 +28,7 @@
     "dev:migrate:status": "yarn dev:migrate-mongo status",
     "dev:migrate:up": "yarn dev:migrate-mongo up",
     "dev:migrate:down": "yarn dev:migrate-mongo down",
-    "cy:run": "cypress run --headless",
+    "cy:run": "cypress run --browser chrome",
     "//// for CI": "",
     "dev:ci": "yarn dev:client:nowatch && yarn dev:server --ci",
     "predev:ci": "run-p resources:*",
@@ -62,11 +62,11 @@
     "@elastic/elasticsearch7": "npm:@elastic/elasticsearch@^7.17.0",
     "@godaddy/terminus": "^4.9.0",
     "@google-cloud/storage": "^5.8.5",
-    "@growi/codemirror-textlint": "^5.0.1-RC.0",
-    "@growi/plugin-attachment-refs": "^5.0.1-RC.0",
-    "@growi/plugin-lsx": "^5.0.1-RC.0",
-    "@growi/plugin-pukiwiki-like-linker": "^5.0.1-RC.0",
-    "@growi/slack": "^5.0.1-RC.0",
+    "@growi/codemirror-textlint": "^5.0.5-RC.0",
+    "@growi/plugin-attachment-refs": "^5.0.5-RC.0",
+    "@growi/plugin-lsx": "^5.0.5-RC.0",
+    "@growi/plugin-pukiwiki-like-linker": "^5.0.5-RC.0",
+    "@growi/slack": "^5.0.5-RC.0",
     "@promster/express": "^7.0.2",
     "@promster/server": "^7.0.4",
     "@slack/events-api": "^3.0.0",
@@ -167,7 +167,7 @@
   },
   "devDependencies": {
     "@alienfast/i18next-loader": "^1.1.4",
-    "@growi/ui": "^5.0.1-RC.0",
+    "@growi/ui": "^5.0.5-RC.0",
     "@handsontable/react": "=2.1.0",
     "@types/compression": "^1.7.0",
     "@types/express": "^4.17.11",

+ 25 - 5
packages/app/resource/locales/en_US/translation.json

@@ -100,6 +100,7 @@
   "Connected": "Connected",
   "Show": "Show",
   "Hide": "Hide",
+  "Loading": "Loading...",
   "Disclose E-mail": "Disclose E-mail",
   "page exists": "this page already exists",
   "Error occurred": "Error occurred",
@@ -148,6 +149,7 @@
   "Shareable link": "Shareable link",
   "The whitelist of registration permission E-mail address": "The whitelist of registration permission E-mail address",
   "Add tags for this page": "Add tags for this page",
+  "popular_tags": "Popular tags",
   "Check All tags": "check all tags",
   "You have no tag, You can set tags on pages": "You have no tag, You can set tags on pages",
   "Show latest": "Show latest",
@@ -171,6 +173,7 @@
   "you_can_not_create_page_with_this_name": "You can not create page with this name",
   "not_allowed_to_see_this_page": "You cannot see this page",
   "Confirm": "Confirm",
+  "Successfully requested": "Successfully requested.",
   "personal_dropdown": {
     "home": "Home",
     "settings": "Settings",
@@ -183,9 +186,7 @@
     "error_message": "Some values ​​are incorrect",
     "required": "%s is required",
     "invalid_syntax": "The syntax of %s is invalid.",
-    "title_required": "Title is required.",
-    "slashed_are_not_yet_supported": "Titles containing slashes are not yet supported"
-
+    "title_required": "Title is required."
   },
   "not_found_page": {
     "Create Page": "Create Page",
@@ -393,7 +394,7 @@
   },
   "page_api_error": {
     "notfound_or_forbidden": "Original page is not found or forbidden.",
-    "already_exists": "New page is already exists.",
+    "already_exists": "Page with the path already exists.",
     "outdated": "Page is updated someone and now outdated.",
     "user_not_admin": "Only admin user can delete"
   },
@@ -440,8 +441,11 @@
   "deleted_pages": "{{path}} has been deleted",
   "deleted_pages_completely": "{{path}} has been deleted completely",
   "renamed_pages": "{{path}} has been renamed",
+  "empty_trash": "The trash has been emptied",
   "modal_empty":{
     "empty_the_trash": "Empty The Trash",
+    "empty_the_trash_button": "Empty The Trash",
+    "not_deletable_notice": "Some pages cannot be removed due to lack of permission.",
     "notice": "The pages deleted completely are unrecoverable."
   },
   "modal_duplicate": {
@@ -639,7 +643,8 @@
   "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.",
+    "input_path_to_convert": "Input a path to convert pages",
+    "alert_title": "Old v4 compatible format private pages exist.",
     "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.",
@@ -650,6 +655,21 @@
       "convert_recursively_label": "Convert child pages recursively.",
       "convert_recursively_desc": "Convert pages under this path recursively.",
       "button_label": "Convert"
+    },
+    "toaster": {
+      "page_migration_succeeded": "Conversion of selected page to v5 has been successfully completed.",
+      "page_migration_failed_with_paths": "Conversion of {{paths}} to v5 has been failed.",
+      "page_migration_failed": "Conversion of page to v5 has been failed."
+    },
+    "by_path_modal": {
+      "title": "Convert to new v5 compatible format",
+      "description": "Enter a path and all pages under that path will be converted to v5 compatible format.",
+      "button_label": "Convert",
+      "success": "Successfully requested conversion.",
+      "error": "Failed to request conversion.",
+      "error_grant_invalid": "Page permissions are incorrect. Please correct it and try again.",
+      "error_page_not_found": "Page not found.",
+      "error_duplicate_pages_found": "Multiple pages with the same path name were found. Please rename or delete and try again."
     }
   },
   "security_setting": {

+ 26 - 5
packages/app/resource/locales/ja_JP/translation.json

@@ -100,6 +100,7 @@
   "Connected": "接続されています",
   "Show": "公開",
   "Hide": "非公開",
+  "Loading": "読み込み中...",
   "Disclose E-mail": "メールアドレスの公開",
   "page exists": "このページはすでに存在しています",
   "Error occurred": "エラーが発生しました",
@@ -147,7 +148,8 @@
   "Shareable link": "このページの共有用URL",
   "The whitelist of registration permission E-mail address": "登録許可メールアドレスの<br>ホワイトリスト",
   "Add tags for this page": "タグを付ける",
-  "Check All tags": "全てのタグをチェックする",
+  "popular_tags": "人気のタグ",
+  "Check All tags": "全てのタグを見る",
   "You have no tag, You can set tags on pages": "使用中のタグがありません",
   "Show latest": "最新のページを表示",
   "Load latest": "最新版を読み込む",
@@ -173,6 +175,7 @@
   "you_can_not_create_page_with_this_name": "この名前でページを作成することはできません",
   "not_allowed_to_see_this_page": "このページは閲覧できません",
   "Confirm": "確認",
+  "Successfully requested": "正常に処理を受け付けました",
   "personal_dropdown": {
     "home": "ホーム",
     "settings": "設定",
@@ -185,8 +188,7 @@
     "error_message": "いくつかの値が設定されていません",
     "required": "%sに値を入力してください",
     "invalid_syntax": "%sの構文が不正です",
-    "title_required": "タイトルを入力してください",
-    "slashed_are_not_yet_supported": "スラッシュを含むタイトルにはまだ対応していません"
+    "title_required": "タイトルを入力してください"
   },
   "not_found_page": {
     "Create Page": "ページを作成する",
@@ -392,7 +394,7 @@
   },
   "page_api_error": {
     "notfound_or_forbidden": "元のページが見つからないか、アクセス権がありません。",
-    "already_exists": "新しいページが既に存在しています。",
+    "already_exists": "そのパスを持つページは既に存在しています。",
     "outdated": "ページが他のユーザーによって更新されました。",
     "user_not_admin": "権限のあるユーザーのみが削除できます"
   },
@@ -439,8 +441,11 @@
   "deleted_pages": "{{path}} をゴミ箱に入れました",
   "deleted_pages_completely": "{{path}} を完全に削除しました",
   "renamed_pages": "{{path}} を移動/名前変更しました",
+  "empty_trash": "ゴミ箱を空にしました",
   "modal_empty":{
     "empty_the_trash": "ゴミ箱を空にする",
+    "empty_the_trash_button": "空にする",
+    "not_deletable_notice": "権限がないため、いくつかのページは削除できません",
     "notice": "完全削除したページは元に戻すことができません"
   },
   "modal_duplicate": {
@@ -638,7 +643,8 @@
   "private_legacy_pages": {
     "bulk_operation": "一括操作",
     "convert_all_selected_pages": "新しい v5 互換形式に一括変換",
-    "alert_title": "古い v4 互換形式のプライベートページを表示しています",
+    "input_path_to_convert": "パスを入力して変換",
+    "alert_title": "古い v4 互換形式のプライベートページが存在します",
     "alert_desc1": "このページでは、チェックボックスでページを選択し、画面上部の「一括操作」ボタンから新しい v5 互換形式に一括変換できます。",
     "nopages_title": "おめでとうございます。GROWI v5 を使う準備が完了しました!",
     "nopages_desc1": "今あなたが管理可能なページはすべて v5 互換形式になっているようです。",
@@ -649,6 +655,21 @@
       "convert_recursively_label": "再起的に変換",
       "convert_recursively_desc": "このページの配下のページを再起的に変換します",
       "button_label": "変換"
+    },
+    "toaster": {
+      "page_migration_succeeded": "選択されたページの v5 互換形式への変換が正常に終了しました。",
+      "page_migration_failed_with_paths": "{{paths}} の v5 互換形式への変換中にエラーが発生しました。",
+      "page_migration_failed": "ページの v5 互換形式への変換中にエラーが発生しました。"
+    },
+    "by_path_modal": {
+      "title": "新しい v5 互換形式への変換",
+      "description": "パスを入力することで、そのパスの配下のページを全て v5 互換形式に変換します",
+      "button_label": "変換",
+      "success": "正常に変換を開始しました",
+      "error": "変換を開始できませんでした",
+      "error_grant_invalid": "ページの権限が正しくありません。修正してから再度実行してください",
+      "error_page_not_found": "ページが見つかりませんでした",
+      "error_duplicate_pages_found": "同名のパスを持つページが複数見つかりました。リネームまたは削除してから再度実行してください"
     }
   },
   "security_setting": {

+ 26 - 5
packages/app/resource/locales/zh_CN/translation.json

@@ -107,6 +107,7 @@
 	"Connected": "Connected",
 	"Show": "显示",
 	"Hide": "隐藏",
+  "Loading": "加载...",
 	"Reset": "重置",
 	"Disclose E-mail": "显示邮箱",
 	"page exists": "页面已存在",
@@ -156,6 +157,7 @@
 	"Shareable link": "可分享链接",
 	"The whitelist of registration permission E-mail address": "注册许可电子邮件地址的白名单",
 	"Add tags for this page": "添加标签",
+  "popular_tags": "流行标签",
   "Check All tags": "检查所有标签",
 	"You have no tag, You can set tags on pages": "你没有标签,可以在页面上设置标签",
 	"Show latest": "显示最新",
@@ -179,12 +181,12 @@
   "you_can_not_create_page_with_this_name": "您无法使用此名称创建页面",
   "not_allowed_to_see_this_page": "你不能看到这个页面",
   "Confirm": "确定",
+  "Successfully requested": "进程成功接受",
 	"form_validation": {
 		"error_message": "有些值不正确",
 		"required": "%s 是必需的",
 		"invalid_syntax": "%s的语法无效。",
-    "title_required": "标题是必需的。",
-    "slashed_are_not_yet_supported": "目前还不支持包含斜线的标题"
+    "title_required": "标题是必需的。"
   },
   "not_found_page": {
     "Create Page": "创建页面",
@@ -371,7 +373,7 @@
   },
 	"page_api_error": {
 		"notfound_or_forbidden": "未找到或禁止原始页。",
-		"already_exists": "新建页面已存在",
+		"already_exists": "具有该路径的页面已存在",
 		"outdated": "页面已被某人更新,现在已过时。",
 		"user_not_admin": "仅管理员用户可以删除"
   },
@@ -418,8 +420,11 @@
   "deleted_pages": "将 {{path}} 放入垃圾箱",
   "deleted_pages_completely": "{{path}} 已被完全删除",
   "renamed_pages": "移动/重命名 {{path}}",
+  "empty_trash": "清空垃圾",
 	"modal_empty": {
-		"empty_the_trash": "Empty The Trash",
+		"empty_the_trash": "清空垃圾",
+    "empty_the_trash_button": "清空垃圾",
+    "not_deletable_notice": "由于缺乏权限,一些页面不能被删除",
 		"notice": "完全删除的页面是不可恢复的。"
 	},
 	"modal_duplicate": {
@@ -925,7 +930,8 @@
   "private_legacy_pages": {
     "bulk_operation": "批量操作",
     "convert_all_selected_pages": "全部转换为新的v5兼容格式",
-    "alert_title": "你正在查看旧的v4兼容的私人网页。",
+		"input_path_to_convert": "输入一个转换页面的路径",
+    "alert_title": "存在旧的v4兼容格式的私人网页。",
     "alert_desc1": "在这一页,你可以用复选框选择页面,并通过屏幕上方的批量操作按钮批量转换为新的v5兼容格式。",
     "nopages_title": "恭喜你。准备使用GROWI v5!",
     "nopages_desc1": "现在你能管理的所有页面似乎都是v5兼容的格式。",
@@ -936,6 +942,21 @@
       "convert_recursively_label": "递归地转换子页面。",
       "convert_recursively_desc": "递归地转换该路径下的页面。",
       "button_label": "转换"
+    },
+    "toaster": {
+      "page_migration_succeeded": "已成功将所选页面转换为 v5 兼容格式。",
+      "page_migration_failed_with_paths": "将 {{paths}} 转换为 v5 兼容格式时出错",
+      "page_migration_failed": "将页面转换为 v5 兼容格式时出错。"
+    },
+    "by_path_modal": {
+      "title": "转换为新的v5兼容格式",
+      "description": "输入一个路径,该路径下的所有页面将被转换为v5兼容格式。",
+      "button_label": "转换",
+      "success": "成功地请求转换。",
+      "error": "请求转换失败。",
+      "error_grant_invalid": "页面权限不正确。请更正并重试。",
+      "error_page_not_found": "没有找到页面。",
+      "error_duplicate_pages_found": "发现多个具有相同路径名称的页面。请重新命名或删除并重试。"
     }
   },
 	"to_cloud_settings": "進入 GROWI.cloud 的管理界面",

+ 41 - 38
packages/app/src/client/app.jsx

@@ -1,57 +1,58 @@
 import React from 'react';
-import ReactDOM from 'react-dom';
-import { Provider } from 'unstated';
-import { I18nextProvider } from 'react-i18next';
+
 import { DndProvider } from 'react-dnd';
 import { HTML5Backend } from 'react-dnd-html5-backend';
-
+import ReactDOM from 'react-dom';
+import { I18nextProvider } from 'react-i18next';
 import { SWRConfig } from 'swr';
+import { Provider } from 'unstated';
 
+import CommentContainer from '~/client/services/CommentContainer';
+import ContextExtractor from '~/client/services/ContextExtractor';
+import EditorContainer from '~/client/services/EditorContainer';
+import PageContainer from '~/client/services/PageContainer';
+import PageHistoryContainer from '~/client/services/PageHistoryContainer';
+import PersonalContainer from '~/client/services/PersonalContainer';
+import RevisionComparerContainer from '~/client/services/RevisionComparerContainer';
+import TagContainer from '~/client/services/TagContainer';
+import IdenticalPathPage from '~/components/IdenticalPathPage';
+import PrivateLegacyPages from '~/components/PrivateLegacyPages';
 import loggerFactory from '~/utils/logger';
 import { swrGlobalConfiguration } from '~/utils/swr-utils';
 
-import InAppNotificationPage from '../components/InAppNotification/InAppNotificationPage';
 import ErrorBoundary from '../components/ErrorBoudary';
-import Sidebar from '../components/Sidebar';
-import { SearchPage } from '../components/SearchPage';
-import TagsList from '../components/TagsList';
-import DisplaySwitcher from '../components/Page/DisplaySwitcher';
-import { defaultEditorOptions, defaultPreviewOptions } from '../components/PageEditor/OptionsSelector';
-import Page from '../components/Page';
-import PageContentFooter from '../components/PageContentFooter';
-import PageComment from '../components/PageComment';
-import PageTimeline from '../components/PageTimeline';
-import CommentEditorLazyRenderer from '../components/PageComment/CommentEditorLazyRenderer';
-import ShareLinkAlert from '../components/Page/ShareLinkAlert';
-import RedirectedAlert from '../components/Page/RedirectedAlert';
-import TrashPageList from '../components/TrashPageList';
-import TrashPageAlert from '../components/Page/TrashPageAlert';
-import NotFoundPage from '../components/NotFoundPage';
-import NotFoundAlert from '../components/Page/NotFoundAlert';
+import Fab from '../components/Fab';
 import ForbiddenPage from '../components/ForbiddenPage';
-import PageStatusAlert from '../components/PageStatusAlert';
-import RecentCreated from '../components/RecentCreated/RecentCreated';
 import RecentlyCreatedIcon from '../components/Icons/RecentlyCreatedIcon';
-import MyDraftList from '../components/MyDraftList/MyDraftList';
-import BookmarkList from '../components/PageList/BookmarkList';
-import Fab from '../components/Fab';
+import InAppNotificationPage from '../components/InAppNotification/InAppNotificationPage';
+import MaintenanceModeContent from '../components/MaintenanceModeContent';
 import PersonalSettings from '../components/Me/PersonalSettings';
+import MyDraftList from '../components/MyDraftList/MyDraftList';
 import GrowiContextualSubNavigation from '../components/Navbar/GrowiContextualSubNavigation';
 import GrowiSubNavigationSwitcher from '../components/Navbar/GrowiSubNavigationSwitcher';
-import IdenticalPathPage from '~/components/IdenticalPathPage';
-
-import ContextExtractor from '~/client/services/ContextExtractor';
-import PageContainer from '~/client/services/PageContainer';
-import PageHistoryContainer from '~/client/services/PageHistoryContainer';
-import RevisionComparerContainer from '~/client/services/RevisionComparerContainer';
-import CommentContainer from '~/client/services/CommentContainer';
-import EditorContainer from '~/client/services/EditorContainer';
-import TagContainer from '~/client/services/TagContainer';
-import PersonalContainer from '~/client/services/PersonalContainer';
+import NotFoundPage from '../components/NotFoundPage';
+import Page from '../components/Page';
+import DisplaySwitcher from '../components/Page/DisplaySwitcher';
+import NotFoundAlert from '../components/Page/NotFoundAlert';
+import RedirectedAlert from '../components/Page/RedirectedAlert';
+import ShareLinkAlert from '../components/Page/ShareLinkAlert';
+import TrashPageAlert from '../components/Page/TrashPageAlert';
+import PageComment from '../components/PageComment';
+import CommentEditorLazyRenderer from '../components/PageComment/CommentEditorLazyRenderer';
+import PageContentFooter from '../components/PageContentFooter';
+import { defaultEditorOptions, defaultPreviewOptions } from '../components/PageEditor/OptionsSelector';
+import BookmarkList from '../components/PageList/BookmarkList';
+import PageStatusAlert from '../components/PageStatusAlert';
+import PageTimeline from '../components/PageTimeline';
+import RecentCreated from '../components/RecentCreated/RecentCreated';
+import { SearchPage } from '../components/SearchPage';
+import Sidebar from '../components/Sidebar';
+import TagPage from '../components/TagPage';
+import TrashPageList from '../components/TrashPageList';
 
 import { appContainer, componentMappings } from './base';
 import { toastError } from './util/apiNotification';
-import { PrivateLegacyPages } from '~/components/PrivateLegacyPages';
+
 
 const logger = loggerFactory('growi:cli:app');
 
@@ -90,10 +91,12 @@ Object.assign(componentMappings, {
   'identical-path-page': <IdenticalPathPage />,
 
   // 'revision-history': <PageHistory pageId={pageId} />,
-  'tags-page': <TagsList crowi={appContainer} />,
+  'tags-page': <TagPage />,
 
   'grw-page-status-alert-container': <PageStatusAlert />,
 
+  'maintenance-mode-content': <MaintenanceModeContent />,
+
   'trash-page-alert': <TrashPageAlert />,
 
   'trash-page-list-container': <TrashPageList />,

+ 9 - 8
packages/app/src/client/base.jsx

@@ -1,22 +1,22 @@
 import React from 'react';
 
+import AppContainer from '~/client/services/AppContainer';
+import SocketIoContainer from '~/client/services/SocketIoContainer';
+import { DescendantsPageListModal } from '~/components/DescendantsPageListModal';
+import PutbackPageModal from '~/components/PutbackPageModal';
 import Xss from '~/services/xss';
 import loggerFactory from '~/utils/logger';
 
+import EmptyTrashModal from '../components/EmptyTrashModal';
+import HotkeysManager from '../components/Hotkeys/HotkeysManager';
 import GrowiNavbar from '../components/Navbar/GrowiNavbar';
 import GrowiNavbarBottom from '../components/Navbar/GrowiNavbarBottom';
-import HotkeysManager from '../components/Hotkeys/HotkeysManager';
+import PageAccessoriesModal from '../components/PageAccessoriesModal';
 import PageCreateModal from '../components/PageCreateModal';
 import PageDeleteModal from '../components/PageDeleteModal';
 import PageDuplicateModal from '../components/PageDuplicateModal';
-import PageRenameModal from '../components/PageRenameModal';
 import PagePresentationModal from '../components/PagePresentationModal';
-import PageAccessoriesModal from '../components/PageAccessoriesModal';
-import PutbackPageModal from '~/components/PutbackPageModal';
-
-import AppContainer from '~/client/services/AppContainer';
-import SocketIoContainer from '~/client/services/SocketIoContainer';
-import { DescendantsPageListModal } from '~/components/DescendantsPageListModal';
+import PageRenameModal from '../components/PageRenameModal';
 
 const logger = loggerFactory('growi:cli:app');
 
@@ -48,6 +48,7 @@ const componentMappings = {
 
   'page-create-modal': <PageCreateModal />,
   'page-delete-modal': <PageDeleteModal />,
+  'empty-trash-modal': <EmptyTrashModal />,
   'page-duplicate-modal': <PageDuplicateModal />,
   'page-rename-modal': <PageRenameModal />,
   'page-presentation-modal': <PagePresentationModal />,

+ 10 - 6
packages/app/src/client/nologin.jsx

@@ -1,17 +1,19 @@
 import React from 'react';
+
 import ReactDOM from 'react-dom';
-import { Provider } from 'unstated';
 import { I18nextProvider } from 'react-i18next';
+import { Provider } from 'unstated';
 
-import { i18nFactory } from './util/i18n';
 
 import AppContainer from '~/client/services/AppContainer';
+import CompleteUserRegistrationForm from '~/components/CompleteUserRegistrationForm';
 
 import InstallerForm from '../components/InstallerForm';
 import LoginForm from '../components/LoginForm';
-import PasswordResetRequestForm from '../components/PasswordResetRequestForm';
 import PasswordResetExecutionForm from '../components/PasswordResetExecutionForm';
-import CompleteUserRegistrationForm from '~/components/CompleteUserRegistrationForm';
+import PasswordResetRequestForm from '../components/PasswordResetRequestForm';
+
+import { i18nFactory } from './util/i18n';
 
 const i18n = i18nFactory();
 
@@ -85,10 +87,12 @@ if (loginFormElem) {
   );
 }
 
-// render PasswordResetRequestForm
-const passwordResetRequestFormElem = document.getElementById('password-reset-request-form');
 const appContainer = new AppContainer();
 appContainer.initApp();
+
+
+// render PasswordResetRequestForm
+const passwordResetRequestFormElem = document.getElementById('password-reset-request-form');
 if (passwordResetRequestFormElem) {
 
   ReactDOM.render(

+ 9 - 7
packages/app/src/client/services/ContextExtractor.tsx

@@ -1,6 +1,15 @@
 import React, { FC, useEffect, useState } from 'react';
+
 import { pagePathUtils } from '@growi/core';
 
+import { IUserUISettings } from '~/interfaces/user-ui-settings';
+import {
+  useIsDeviceSmallerThanMd, useIsDeviceSmallerThanLg,
+  usePreferDrawerModeByUser, usePreferDrawerModeOnEditByUser, useSidebarCollapsed, useCurrentSidebarContents, useCurrentProductNavWidth,
+  useSelectedGrant, useSelectedGrantGroupId, useSelectedGrantGroupName,
+} from '~/stores/ui';
+import { useSetupGlobalSocket, useSetupGlobalAdminSocket } from '~/stores/websocket';
+
 import {
   useSiteUrl,
   useCurrentCreatedAt, useDeleteUsername, useDeletedAt, useHasChildren, useHasDraftOnHackmd,
@@ -10,13 +19,6 @@ import {
   useSlackChannels, useNotFoundTargetPathOrId, useIsSearchPage, useIsForbidden, useIsIdenticalPath,
   useIsAclEnabled, useIsSearchServiceConfigured, useIsSearchServiceReachable, useIsEnabledAttachTitleHeader, useIsNotFoundPermalink,
 } from '../../stores/context';
-import {
-  useIsDeviceSmallerThanMd, useIsDeviceSmallerThanLg,
-  usePreferDrawerModeByUser, usePreferDrawerModeOnEditByUser, useSidebarCollapsed, useCurrentSidebarContents, useCurrentProductNavWidth,
-  useSelectedGrant, useSelectedGrantGroupId, useSelectedGrantGroupName,
-} from '~/stores/ui';
-import { useSetupGlobalSocket, useSetupGlobalAdminSocket } from '~/stores/websocket';
-import { IUserUISettings } from '~/interfaces/user-ui-settings';
 
 const { isTrashPage: _isTrashPage } = pagePathUtils;
 

+ 8 - 7
packages/app/src/client/util/GrowiRenderer.js

@@ -2,24 +2,24 @@ import MarkdownIt from 'markdown-it';
 
 import loggerFactory from '~/utils/logger';
 
-import Linker from './PreProcessor/Linker';
 import CsvToTable from './PreProcessor/CsvToTable';
 import EasyGrid from './PreProcessor/EasyGrid';
+import Linker from './PreProcessor/Linker';
 import XssFilter from './PreProcessor/XssFilter';
-
+import BlockdiagConfigurer from './markdown-it/blockdiag';
+import DrawioViewerConfigurer from './markdown-it/drawio-viewer';
 import EmojiConfigurer from './markdown-it/emoji';
 import FooternoteConfigurer from './markdown-it/footernote';
-import HeaderLineNumberConfigurer from './markdown-it/header-line-number';
 import HeaderConfigurer from './markdown-it/header';
+import HeaderLineNumberConfigurer from './markdown-it/header-line-number';
+import HeaderWithEditLinkConfigurer from './markdown-it/header-with-edit-link';
+import LinkerByRelativePathConfigurer from './markdown-it/link-by-relative-path';
 import MathJaxConfigurer from './markdown-it/mathjax';
 import PlantUMLConfigurer from './markdown-it/plantuml';
 import TableConfigurer from './markdown-it/table';
+import TableWithHandsontableButtonConfigurer from './markdown-it/table-with-handsontable-button';
 import TaskListsConfigurer from './markdown-it/task-lists';
 import TocAndAnchorConfigurer from './markdown-it/toc-and-anchor';
-import BlockdiagConfigurer from './markdown-it/blockdiag';
-import DrawioViewerConfigurer from './markdown-it/drawio-viewer';
-import TableWithHandsontableButtonConfigurer from './markdown-it/table-with-handsontable-button';
-import HeaderWithEditLinkConfigurer from './markdown-it/header-with-edit-link';
 
 const logger = loggerFactory('growi:util:GrowiRenderer');
 
@@ -68,6 +68,7 @@ export default class GrowiRenderer {
     this.isMarkdownItConfigured = false;
 
     this.markdownItConfigurers = [
+      new LinkerByRelativePathConfigurer(appContainer),
       new TaskListsConfigurer(appContainer),
       new HeaderConfigurer(appContainer),
       new EmojiConfigurer(appContainer),

+ 50 - 0
packages/app/src/client/util/markdown-it/link-by-relative-path.ts

@@ -0,0 +1,50 @@
+import path from 'path';
+
+// https://regex101.com/r/vV8LUe/1
+const PATTERN_RELATIVE_PATH = new RegExp(/^(\.{1,2})(\/.*)?$/);
+
+export default class LinkerByRelativePathConfigurer {
+
+  // eslint-disable-next-line @typescript-eslint/no-explicit-any
+  appContainer: any;
+
+  // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
+  constructor(appContainer) {
+    this.appContainer = appContainer;
+  }
+
+  // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
+  configure(md): void {
+    const pageContainer = this.appContainer.getContainer('PageContainer');
+
+    // Remember old renderer, if overridden, or proxy to default renderer
+    const defaultRender = md.renderer.rules.link_open || function(tokens, idx, options, env, self) {
+      return self.renderToken(tokens, idx, options);
+    };
+
+    md.renderer.rules.link_open = function(tokens, idx, options, env, self) {
+      if (tokens[idx] == null || (typeof tokens[idx].attrIndex !== 'function')) {
+        return defaultRender(tokens, idx, options, env, self);
+      }
+
+      // get href
+      const hrefIndex = tokens[idx].attrIndex('href');
+
+      if (hrefIndex != null && hrefIndex >= 0) {
+        const href: string = tokens[idx].attrs[hrefIndex][1];
+        const currentPath: string | null = pageContainer?.state.path;
+
+        // resolve relative path and replace
+        if (PATTERN_RELATIVE_PATH.test(href) && currentPath != null) {
+          const newHref = path.resolve(path.dirname(currentPath), href);
+          tokens[idx].attrs[hrefIndex][1] = newHref;
+        }
+      }
+
+      // pass token to default renderer.
+      return defaultRender(tokens, idx, options, env, self);
+    };
+
+  }
+
+}

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

@@ -171,7 +171,7 @@ class UserInviteModal extends React.Component {
     return (
       <ul>
         {userList.map((user) => {
-          const copyText = `Email:${user.email} Password:${user.password} `;
+          const copyText = `Email:${user.email} Password:${user.password}`;
           return (
             <div className="my-1" key={user.email}>
               <CopyToClipboard text={copyText} onCopy={this.showToaster}>

+ 17 - 0
packages/app/src/components/Common/CountBadge.tsx

@@ -0,0 +1,17 @@
+import React, { FC } from 'react';
+
+type CountProps = {
+  count: number
+}
+
+const CountBadge: FC<CountProps> = (props:CountProps) => {
+  return (
+    <>
+      <span className="grw-count-badge px-2 badge badge-pill badge-light">
+        {props.count}
+      </span>
+    </>
+  );
+};
+
+export default CountBadge;

+ 35 - 36
packages/app/src/components/Common/Dropdown/PageItemControl.tsx

@@ -1,24 +1,23 @@
 import React, { useState, useCallback, useEffect } from 'react';
+
+import { useTranslation } from 'react-i18next';
 import {
   Dropdown, DropdownMenu, DropdownToggle, DropdownItem,
 } from 'reactstrap';
 
-import { useTranslation } from 'react-i18next';
-
-import loggerFactory from '~/utils/logger';
-
 import {
   IPageInfoAll, isIPageInfoForOperation,
 } from '~/interfaces/page';
 import { useSWRxPageInfo } from '~/stores/page';
+import loggerFactory from '~/utils/logger';
 
 const logger = loggerFactory('growi:cli:PageItemControl');
 
 
 export const MenuItemType = {
   BOOKMARK: 'bookmark',
-  DUPLICATE: 'duplicate',
   RENAME: 'rename',
+  DUPLICATE: 'duplicate',
   DELETE: 'delete',
   REVERT: 'revert',
 } as const;
@@ -34,8 +33,8 @@ type CommonProps = {
   forceHideMenuItems?: ForceHideMenuItems,
 
   onClickBookmarkMenuItem?: (pageId: string, newValue?: boolean) => Promise<void>,
-  onClickDuplicateMenuItem?: (pageId: string) => Promise<void> | void,
   onClickRenameMenuItem?: (pageId: string, pageInfo: IPageInfoAll | undefined) => Promise<void> | void,
+  onClickDuplicateMenuItem?: (pageId: string) => Promise<void> | void,
   onClickDeleteMenuItem?: (pageId: string, pageInfo: IPageInfoAll | undefined) => Promise<void> | void,
   onClickRevertMenuItem?: (pageId: string) => Promise<void> | void,
 
@@ -55,7 +54,7 @@ const PageItemControlDropdownMenu = React.memo((props: DropdownMenuProps): JSX.E
   const {
     pageId, isLoading,
     pageInfo, isEnableActions, forceHideMenuItems,
-    onClickBookmarkMenuItem, onClickDuplicateMenuItem, onClickRenameMenuItem, onClickDeleteMenuItem, onClickRevertMenuItem,
+    onClickBookmarkMenuItem, onClickRenameMenuItem, onClickDuplicateMenuItem, onClickDeleteMenuItem, onClickRevertMenuItem,
     additionalMenuItemRenderer: AdditionalMenuItems, isInstantRename,
   } = props;
 
@@ -68,14 +67,6 @@ const PageItemControlDropdownMenu = React.memo((props: DropdownMenuProps): JSX.E
     await onClickBookmarkMenuItem(pageId, !pageInfo.isBookmarked);
   }, [onClickBookmarkMenuItem, pageId, pageInfo]);
 
-  // eslint-disable-next-line react-hooks/rules-of-hooks
-  const duplicateItemClickedHandler = useCallback(async() => {
-    if (onClickDuplicateMenuItem == null) {
-      return;
-    }
-    await onClickDuplicateMenuItem(pageId);
-  }, [onClickDuplicateMenuItem, pageId]);
-
   // eslint-disable-next-line react-hooks/rules-of-hooks
   const renameItemClickedHandler = useCallback(async() => {
     if (onClickRenameMenuItem == null) {
@@ -88,6 +79,14 @@ const PageItemControlDropdownMenu = React.memo((props: DropdownMenuProps): JSX.E
     await onClickRenameMenuItem(pageId, pageInfo);
   }, [onClickRenameMenuItem, pageId, pageInfo]);
 
+  // eslint-disable-next-line react-hooks/rules-of-hooks
+  const duplicateItemClickedHandler = useCallback(async() => {
+    if (onClickDuplicateMenuItem == null) {
+      return;
+    }
+    await onClickDuplicateMenuItem(pageId);
+  }, [onClickDuplicateMenuItem, pageId]);
+
   const revertItemClickedHandler = useCallback(async() => {
     if (onClickRevertMenuItem == null) {
       return;
@@ -143,18 +142,6 @@ const PageItemControlDropdownMenu = React.memo((props: DropdownMenuProps): JSX.E
           </DropdownItem>
         ) }
 
-        {/* Duplicate */}
-        { !forceHideMenuItems?.includes(MenuItemType.DUPLICATE) && isEnableActions && (
-          <DropdownItem
-            onClick={duplicateItemClickedHandler}
-            data-testid="open-page-duplicate-modal-btn"
-            className="grw-page-control-dropdown-item"
-          >
-            <i className="icon-fw icon-docs grw-page-control-dropdown-icon"></i>
-            {t('Duplicate')}
-          </DropdownItem>
-        ) }
-
         {/* Move/Rename */}
         { !forceHideMenuItems?.includes(MenuItemType.RENAME) && isEnableActions && pageInfo.isMovable && (
           <DropdownItem
@@ -167,6 +154,18 @@ const PageItemControlDropdownMenu = React.memo((props: DropdownMenuProps): JSX.E
           </DropdownItem>
         ) }
 
+        {/* Duplicate */}
+        { !forceHideMenuItems?.includes(MenuItemType.DUPLICATE) && isEnableActions && (
+          <DropdownItem
+            onClick={duplicateItemClickedHandler}
+            data-testid="open-page-duplicate-modal-btn"
+            className="grw-page-control-dropdown-item"
+          >
+            <i className="icon-fw icon-docs grw-page-control-dropdown-icon"></i>
+            {t('Duplicate')}
+          </DropdownItem>
+        ) }
+
         {/* Revert */}
         { !forceHideMenuItems?.includes(MenuItemType.REVERT) && isEnableActions && pageInfo.isRevertible && (
           <DropdownItem
@@ -224,7 +223,7 @@ export const PageItemControlSubstance = (props: PageItemControlSubstanceProps):
   const {
     pageId, pageInfo: presetPageInfo, fetchOnInit,
     children,
-    onClickBookmarkMenuItem, onClickDuplicateMenuItem, onClickRenameMenuItem, onClickDeleteMenuItem,
+    onClickBookmarkMenuItem, onClickRenameMenuItem, onClickDuplicateMenuItem, onClickDeleteMenuItem,
   } = props;
 
   const [isOpen, setIsOpen] = useState(false);
@@ -255,13 +254,6 @@ export const PageItemControlSubstance = (props: PageItemControlSubstanceProps):
 
   const isLoading = shouldFetch && fetchedPageInfo == null;
 
-  const duplicateMenuItemClickHandler = useCallback(async() => {
-    if (onClickDuplicateMenuItem == null) {
-      return;
-    }
-    await onClickDuplicateMenuItem(pageId);
-  }, [onClickDuplicateMenuItem, pageId]);
-
   const renameMenuItemClickHandler = useCallback(async() => {
     if (onClickRenameMenuItem == null) {
       return;
@@ -269,6 +261,13 @@ export const PageItemControlSubstance = (props: PageItemControlSubstanceProps):
     await onClickRenameMenuItem(pageId, fetchedPageInfo ?? presetPageInfo);
   }, [onClickRenameMenuItem, pageId, fetchedPageInfo, presetPageInfo]);
 
+  const duplicateMenuItemClickHandler = useCallback(async() => {
+    if (onClickDuplicateMenuItem == null) {
+      return;
+    }
+    await onClickDuplicateMenuItem(pageId);
+  }, [onClickDuplicateMenuItem, pageId]);
+
   const deleteMenuItemClickHandler = useCallback(async() => {
     if (onClickDeleteMenuItem == null) {
       return;
@@ -289,8 +288,8 @@ export const PageItemControlSubstance = (props: PageItemControlSubstanceProps):
         isLoading={isLoading}
         pageInfo={fetchedPageInfo ?? presetPageInfo}
         onClickBookmarkMenuItem={bookmarkMenuItemClickHandler}
-        onClickDuplicateMenuItem={duplicateMenuItemClickHandler}
         onClickRenameMenuItem={renameMenuItemClickHandler}
+        onClickDuplicateMenuItem={duplicateMenuItemClickHandler}
         onClickDeleteMenuItem={deleteMenuItemClickHandler}
       />
     </Dropdown>

+ 5 - 2
packages/app/src/components/CustomNavigation/CustomNav.jsx

@@ -1,6 +1,7 @@
 import React, {
   useEffect, useState, useRef, useMemo, useCallback,
 } from 'react';
+
 import PropTypes from 'prop-types';
 import {
   Nav, NavItem, NavLink,
@@ -87,7 +88,7 @@ export const CustomNavTab = (props) => {
   const [sliderMarginLeft, setSliderMarginLeft] = useState(0);
 
   const {
-    activeTab, navTabMapping, onNavSelected, hideBorderBottom, breakpointToHideInactiveTabsDown,
+    activeTab, navTabMapping, onNavSelected, hideBorderBottom, breakpointToHideInactiveTabsDown, navRightElement,
   } = props;
 
   const navTabRefs = useMemo(() => {
@@ -149,7 +150,7 @@ export const CustomNavTab = (props) => {
 
   return (
     <div className="grw-custom-nav-tab">
-      <div ref={navContainer}>
+      <div ref={navContainer} className="d-flex justify-content-between">
         <Nav className="nav-title">
           {Object.entries(navTabMapping).map(([key, value]) => {
 
@@ -169,6 +170,7 @@ export const CustomNavTab = (props) => {
             );
           })}
         </Nav>
+        {navRightElement}
       </div>
       <hr className="my-0 grw-nav-slide-hr border-none" style={{ width: `${sliderWidth}%`, marginLeft: `${sliderMarginLeft}%` }} />
       { !hideBorderBottom && <hr className="my-0 border-top-0 border-bottom" /> }
@@ -183,6 +185,7 @@ CustomNavTab.propTypes = {
   onNavSelected: PropTypes.func,
   hideBorderBottom: PropTypes.bool,
   breakpointToHideInactiveTabsDown: PropTypes.oneOf(['xs', 'sm', 'md', 'lg', 'xl']),
+  navRightElement: PropTypes.node,
 };
 
 CustomNavTab.defaultProps = {

+ 4 - 1
packages/app/src/components/CustomNavigation/CustomNavAndContents.jsx

@@ -1,4 +1,5 @@
 import React, { useState } from 'react';
+
 import PropTypes from 'prop-types';
 
 import CustomNav, { CustomNavTab, CustomNavDropdown } from './CustomNav';
@@ -7,7 +8,7 @@ import CustomTabContent from './CustomTabContent';
 
 const CustomNavAndContents = (props) => {
   const {
-    navTabMapping, defaultTabIndex, navigationMode, tabContentClasses, breakpointToHideInactiveTabsDown,
+    navTabMapping, defaultTabIndex, navigationMode, tabContentClasses, breakpointToHideInactiveTabsDown, navRightElement,
   } = props;
   const [activeTab, setActiveTab] = useState(Object.keys(props.navTabMapping)[defaultTabIndex || 0]);
 
@@ -31,6 +32,7 @@ const CustomNavAndContents = (props) => {
         navTabMapping={navTabMapping}
         onNavSelected={setActiveTab}
         breakpointToHideInactiveTabsDown={breakpointToHideInactiveTabsDown}
+        navRightElement={navRightElement}
       />
       <CustomTabContent activeTab={activeTab} navTabMapping={navTabMapping} additionalClassNames={tabContentClasses} />
     </>
@@ -43,6 +45,7 @@ CustomNavAndContents.propTypes = {
   navigationMode: PropTypes.oneOf(['both', 'tab', 'dropdown']),
   tabContentClasses: PropTypes.arrayOf(PropTypes.string),
   breakpointToHideInactiveTabsDown: PropTypes.oneOf(['xs', 'sm', 'md', 'lg', 'xl']),
+  navRightElement: PropTypes.node,
 };
 CustomNavAndContents.defaultProps = {
   navigationMode: 'tab',

+ 61 - 0
packages/app/src/components/EmptyTrashButton.tsx

@@ -0,0 +1,61 @@
+import React, { useCallback } from 'react';
+
+import { useTranslation } from 'react-i18next';
+
+import { toastSuccess } from '~/client/util/apiNotification';
+import {
+  IDataWithMeta,
+  IPageHasId,
+  IPageInfo,
+} from '~/interfaces/page';
+import { useEmptyTrashModal } from '~/stores/modal';
+import { useSWRxDescendantsPageListForCurrrentPath, useSWRxPageInfoForList } from '~/stores/page';
+
+
+const EmptyTrashButton = () => {
+  const { t } = useTranslation();
+  const { open: openEmptyTrashModal } = useEmptyTrashModal();
+  const { data: pagingResult, mutate } = useSWRxDescendantsPageListForCurrrentPath();
+
+  const pageIds = pagingResult?.items?.map(page => page._id);
+  const { injectTo } = useSWRxPageInfoForList(pageIds, true, true);
+
+  let pageWithMetas: IDataWithMeta<IPageHasId, IPageInfo>[] = [];
+
+  const convertToIDataWithMeta = (page) => {
+    return { data: page };
+  };
+
+  if (pagingResult != null) {
+    const dataWithMetas = pagingResult.items.map(page => convertToIDataWithMeta(page));
+    pageWithMetas = injectTo(dataWithMetas);
+  }
+
+  const deletablePages = pageWithMetas.filter(page => page.meta?.isAbleToDeleteCompletely);
+
+  const onEmptiedTrashHandler = useCallback(() => {
+    toastSuccess(t('empty_trash'));
+
+    mutate();
+  }, [t, mutate]);
+
+  const emptyTrashClickHandler = () => {
+    if (deletablePages.length === 0) { return }
+    openEmptyTrashModal(deletablePages, { onEmptiedTrash: onEmptiedTrashHandler, canDelepeAllPages: pagingResult?.totalCount === deletablePages.length });
+  };
+
+  return (
+    <div className="d-flex align-items-center">
+      <button
+        type="button"
+        className="btn btn-outline-secondary rounded-pill text-danger d-flex align-items-center"
+        onClick={() => emptyTrashClickHandler()}
+      >
+        <i className="icon-fw icon-trash"></i>
+        <div>{t('modal_empty.empty_the_trash')}</div>
+      </button>
+    </div>
+  );
+};
+
+export default EmptyTrashButton;

+ 0 - 71
packages/app/src/components/EmptyTrashModal.jsx

@@ -1,71 +0,0 @@
-import React, { useState } from 'react';
-import PropTypes from 'prop-types';
-
-import {
-  Modal, ModalHeader, ModalBody, ModalFooter,
-} from 'reactstrap';
-
-import { withTranslation } from 'react-i18next';
-import { withUnstatedContainers } from './UnstatedUtils';
-
-import SocketIoContainer from '~/client/services/SocketIoContainer';
-import AppContainer from '~/client/services/AppContainer';
-import ApiErrorMessageList from './PageManagement/ApiErrorMessageList';
-
-const EmptyTrashModal = (props) => {
-  const {
-    t, isOpen, onClose, appContainer, socketIoContainer,
-  } = props;
-
-  const [errs, setErrs] = useState(null);
-
-  async function emptyTrash() {
-    setErrs(null);
-
-    try {
-      await appContainer.apiv3Delete('/pages/empty-trash');
-      window.location.reload();
-    }
-    catch (err) {
-      setErrs(err);
-    }
-  }
-
-  function emptyButtonHandler() {
-    emptyTrash();
-  }
-
-  return (
-    <Modal isOpen={isOpen} toggle={onClose} className="grw-create-page">
-      <ModalHeader tag="h4" toggle={onClose} className="bg-danger text-light">
-        { t('modal_empty.empty_the_trash')}
-      </ModalHeader>
-      <ModalBody>
-        { t('modal_empty.notice')}
-      </ModalBody>
-      <ModalFooter>
-        <ApiErrorMessageList errs={errs} />
-        <button type="button" className="btn btn-danger" onClick={emptyButtonHandler}>
-          <i className="icon-trash mr-2" aria-hidden="true"></i> Empty
-        </button>
-      </ModalFooter>
-    </Modal>
-  );
-};
-
-/**
- * Wrapper component for using unstated
- */
-const EmptyTrashModalWrapper = withUnstatedContainers(EmptyTrashModal, [AppContainer, SocketIoContainer]);
-
-
-EmptyTrashModal.propTypes = {
-  t: PropTypes.func.isRequired, //  i18next
-  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
-  socketIoContainer: PropTypes.instanceOf(SocketIoContainer),
-
-  isOpen: PropTypes.bool.isRequired,
-  onClose: PropTypes.func.isRequired,
-};
-
-export default withTranslation()(EmptyTrashModalWrapper);

+ 92 - 0
packages/app/src/components/EmptyTrashModal.tsx

@@ -0,0 +1,92 @@
+import React, {
+  useState, FC,
+} from 'react';
+
+import { useTranslation } from 'react-i18next';
+import {
+  Modal, ModalHeader, ModalBody, ModalFooter,
+} from 'reactstrap';
+
+import { apiv3Delete } from '~/client/util/apiv3-client';
+import { useEmptyTrashModal } from '~/stores/modal';
+
+import ApiErrorMessageList from './PageManagement/ApiErrorMessageList';
+
+const EmptyTrashModal: FC = () => {
+  const { t } = useTranslation();
+
+  const { data: emptyTrashModalData, close: closeEmptyTrashModal } = useEmptyTrashModal();
+
+  const isOpened = emptyTrashModalData?.isOpened ?? false;
+
+  const canDeleteAllpages = emptyTrashModalData?.opts?.canDelepeAllPages ?? false;
+
+  const [errs, setErrs] = useState<Error[] | null>(null);
+
+  async function emptyTrash() {
+    if (emptyTrashModalData == null || emptyTrashModalData.pages == null) {
+      return;
+    }
+
+    try {
+      await apiv3Delete('/pages/empty-trash');
+      const onEmptiedTrash = emptyTrashModalData.opts?.onEmptiedTrash;
+      if (onEmptiedTrash != null) {
+        onEmptiedTrash();
+      }
+      closeEmptyTrashModal();
+    }
+    catch (err) {
+      setErrs([err]);
+    }
+  }
+
+  async function emptyTrashButtonHandler() {
+    await emptyTrash();
+  }
+
+  const renderPagePaths = () => {
+    const pages = emptyTrashModalData?.pages;
+
+    if (pages != null) {
+      return pages.map(page => (
+        <p key={page.data._id} className="mb-1">
+          <code>{ page.data.path }</code>
+        </p>
+      ));
+    }
+    return <></>;
+  };
+
+  return (
+    <Modal size="lg" isOpen={isOpened} toggle={closeEmptyTrashModal} data-testid="page-delete-modal" className="grw-create-page">
+      <ModalHeader tag="h4" toggle={closeEmptyTrashModal} className="bg-danger text-light">
+        <i className="icon-fw icon-fire"></i>
+        {t('modal_empty.empty_the_trash')}
+      </ModalHeader>
+      <ModalBody>
+        <div className="form-group grw-scrollable-modal-body pb-1">
+          <label>{ t('modal_delete.deleting_page') }:</label><br />
+          {/* Todo: change the way to show path on modal when too many pages are selected */}
+          {renderPagePaths()}
+        </div>
+        {!canDeleteAllpages && t('modal_empty.not_deletable_notice')}<br />
+        {t('modal_empty.notice')}
+      </ModalBody>
+      <ModalFooter>
+        <ApiErrorMessageList errs={errs} />
+        <button
+          type="button"
+          className="btn btn-danger"
+          onClick={emptyTrashButtonHandler}
+        >
+          <i className="mr-1 icon-fire" aria-hidden="true"></i>
+          {t('modal_empty.empty_the_trash_button')}
+        </button>
+      </ModalFooter>
+    </Modal>
+
+  );
+};
+
+export default EmptyTrashModal;

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

@@ -1,18 +1,18 @@
 import React, { useState, useCallback, useEffect } from 'react';
+
 import PropTypes from 'prop-types';
 import StickyEvents from 'sticky-events';
-import loggerFactory from '~/utils/logger';
 
 
 import AppContainer from '~/client/services/AppContainer';
-
-import { usePageCreateModal } from '~/stores/modal';
 import { smoothScrollIntoView } from '~/client/util/smooth-scroll';
+import { useCurrentPagePath } from '~/stores/context';
+import { usePageCreateModal } from '~/stores/modal';
+import loggerFactory from '~/utils/logger';
 
-import { withUnstatedContainers } from './UnstatedUtils';
 import CreatePageIcon from './Icons/CreatePageIcon';
 import ReturnTopIcon from './Icons/ReturnTopIcon';
-import { useCurrentPagePath } from '~/stores/context';
+import { withUnstatedContainers } from './UnstatedUtils';
 
 const logger = loggerFactory('growi:cli:Fab');
 
@@ -55,7 +55,7 @@ const Fab = (props) => {
   function renderPageCreateButton() {
     return (
       <>
-        <div data-testid="grw-fab-create-page" className={`rounded-circle position-absolute ${animateClasses}`} style={{ bottom: '2.3rem', right: '4rem' }}>
+        <div className={`rounded-circle position-absolute ${animateClasses}`} style={{ bottom: '2.3rem', right: '4rem' }}>
           <button
             type="button"
             className={`btn btn-lg btn-create-page btn-primary rounded-circle p-0 waves-effect waves-light ${buttonClasses}`}
@@ -69,7 +69,7 @@ const Fab = (props) => {
   }
 
   return (
-    <div className="grw-fab d-none d-md-block d-edit-none">
+    <div className="grw-fab d-none d-md-block d-edit-none" data-testid="grw-fab">
       {currentUser != null && renderPageCreateButton()}
       <div data-testid="grw-fab-return-to-top" className={`rounded-circle position-absolute ${animateClasses}`} style={{ bottom: 0, right: 0 }}>
         <button

+ 14 - 4
packages/app/src/components/InAppNotification/InAppNotificationElm.tsx

@@ -1,16 +1,18 @@
 import React, {
   FC, useRef,
 } from 'react';
-import { DropdownItem } from 'reactstrap';
 
 import { UserPicture } from '@growi/ui';
-import { IInAppNotification, InAppNotificationStatuses } from '~/interfaces/in-app-notification';
+import { DropdownItem } from 'reactstrap';
+
+import { IInAppNotificationOpenable } from '~/client/interfaces/in-app-notification-openable';
+import { apiv3Post } from '~/client/util/apiv3-client';
 import { HasObjectId } from '~/interfaces/has-object-id';
+import { IInAppNotification, InAppNotificationStatuses } from '~/interfaces/in-app-notification';
 
 // Change the display for each targetmodel
 import PageModelNotification from './PageNotification/PageModelNotification';
-import { IInAppNotificationOpenable } from '~/client/interfaces/in-app-notification-openable';
-import { apiv3Post } from '~/client/util/apiv3-client';
+
 
 interface Props {
   notification: IInAppNotification & HasObjectId
@@ -101,6 +103,10 @@ const InAppNotificationElm: FC<Props> = (props: Props) => {
       actionMsg = 'renamed';
       actionIcon = 'icon-action-redo';
       break;
+    case 'PAGE_DUPLICATE':
+      actionMsg = 'duplicated';
+      actionIcon = 'icon-docs';
+      break;
     case 'PAGE_DELETE':
       actionMsg = 'deleted';
       actionIcon = 'icon-trash';
@@ -109,6 +115,10 @@ const InAppNotificationElm: FC<Props> = (props: Props) => {
       actionMsg = 'completely deleted';
       actionIcon = 'icon-fire';
       break;
+    case 'PAGE_REVERT':
+      actionMsg = 'reverted';
+      actionIcon = 'icon-action-undo';
+      break;
     case 'COMMENT_CREATE':
       actionMsg = 'commented on';
       actionIcon = 'icon-bubble';

+ 55 - 0
packages/app/src/components/MaintenanceModeContent.tsx

@@ -0,0 +1,55 @@
+import React from 'react';
+
+import { useTranslation } from 'react-i18next';
+
+import { toastError } from '~/client/util/apiNotification';
+import { apiv3Post } from '~/client/util/apiv3-client';
+import { useCurrentUser } from '~/stores/context';
+
+
+const MaintenanceModeContent = () => {
+  const { t } = useTranslation();
+
+  const { data: currentUser } = useCurrentUser();
+
+  const logoutHandler = async() => {
+    try {
+      await apiv3Post('/logout');
+      window.location.reload();
+    }
+    catch (err) {
+      toastError(err);
+    }
+
+  };
+
+  return (
+    <div className="text-left">
+      {currentUser?.admin
+      && (
+        <p>
+          <i className="icon-arrow-right"></i>
+          <a className="btn btn-link" href="/admin">{ t('maintenance_mode.admin_page') }</a>
+        </p>
+      )}
+      {currentUser != null
+        ? (
+          <p>
+            <i className="icon-arrow-right"></i>
+            <a className="btn btn-link" onClick={logoutHandler} id="maintanounse-mode-logout">{ t('maintenance_mode.logout') }</a>
+          </p>
+        )
+        : (
+          <p>
+            <i className="icon-arrow-right"></i>
+            <a className="btn btn-link" href="/login">{ t('maintenance_mode.login') }</a>
+          </p>
+        )
+      }
+    </div>
+  );
+
+};
+
+
+export default MaintenanceModeContent;

+ 7 - 3
packages/app/src/components/Me/ApiSettings.jsx

@@ -1,13 +1,14 @@
 
 import React from 'react';
+
 import PropTypes from 'prop-types';
 import { withTranslation } from 'react-i18next';
 
-import { toastSuccess, toastError } from '~/client/util/apiNotification';
-import { withUnstatedContainers } from '../UnstatedUtils';
-
 import AppContainer from '~/client/services/AppContainer';
 import PersonalContainer from '~/client/services/PersonalContainer';
+import { toastSuccess, toastError } from '~/client/util/apiNotification';
+
+import { withUnstatedContainers } from '../UnstatedUtils';
 
 
 class ApiSettings extends React.Component {
@@ -46,6 +47,8 @@ class ApiSettings extends React.Component {
             {personalContainer.state.apiToken != null
               ? (
                 <input
+                  data-testid="grw-api-settings-input"
+                  data-hide-in-vrt
                   className="form-control"
                   type="text"
                   name="apiToken"
@@ -76,6 +79,7 @@ class ApiSettings extends React.Component {
         <div className="row my-3">
           <div className="offset-4 col-5">
             <button
+              data-testid="grw-api-settings-update-button"
               type="button"
               className="btn btn-primary text-nowrap"
               onClick={this.onClickSubmit}

+ 5 - 4
packages/app/src/components/Me/AssociateModal.jsx

@@ -1,21 +1,22 @@
 
 import React from 'react';
+
 import PropTypes from 'prop-types';
 import { withTranslation } from 'react-i18next';
-
 import {
   Modal,
   ModalHeader,
   ModalBody,
   ModalFooter,
 } from 'reactstrap';
-import { toastSuccess, toastError } from '~/client/util/apiNotification';
-import { withUnstatedContainers } from '../UnstatedUtils';
+
 
 import AppContainer from '~/client/services/AppContainer';
 import PersonalContainer from '~/client/services/PersonalContainer';
+import { toastSuccess, toastError } from '~/client/util/apiNotification';
 
 import LdapAuthTest from '../Admin/Security/LdapAuthTest';
+import { withUnstatedContainers } from '../UnstatedUtils';
 
 class AssociateModal extends React.Component {
 
@@ -70,7 +71,7 @@ class AssociateModal extends React.Component {
     const { t } = this.props;
 
     return (
-      <Modal isOpen={this.props.isOpen} toggle={this.props.onClose} size="lg">
+      <Modal isOpen={this.props.isOpen} toggle={this.props.onClose} size="lg" data-testid="grw-associate-modal">
         <ModalHeader className="bg-primary text-light" toggle={this.props.onClose}>
           { t('admin:user_management.create_external_account') }
         </ModalHeader>

+ 10 - 3
packages/app/src/components/Me/BasicInfoSettings.jsx

@@ -1,14 +1,15 @@
 
 import React, { Fragment } from 'react';
+
 import PropTypes from 'prop-types';
 import { withTranslation } from 'react-i18next';
 
+import PersonalContainer from '~/client/services/PersonalContainer';
+import { toastSuccess, toastError } from '~/client/util/apiNotification';
 import { localeMetadatas } from '~/client/util/i18n';
 
-import { toastSuccess, toastError } from '~/client/util/apiNotification';
 import { withUnstatedContainers } from '../UnstatedUtils';
 
-import PersonalContainer from '~/client/services/PersonalContainer';
 
 class BasicInfoSettings extends React.Component {
 
@@ -144,7 +145,13 @@ class BasicInfoSettings extends React.Component {
 
         <div className="row my-3">
           <div className="offset-4 col-5">
-            <button type="button" className="btn btn-primary" onClick={this.onClickSubmit} disabled={personalContainer.state.retrieveError != null}>
+            <button
+              data-testid="grw-besic-info-settings-update-button"
+              type="button"
+              className="btn btn-primary"
+              onClick={this.onClickSubmit}
+              disabled={personalContainer.state.retrieveError != null}
+            >
               {t('Update')}
             </button>
           </div>

+ 11 - 4
packages/app/src/components/Me/EditorSettings.tsx

@@ -2,13 +2,15 @@ import React, {
   Dispatch,
   FC, SetStateAction, useCallback, useEffect, useState,
 } from 'react';
-import { useTranslation } from 'react-i18next';
+
 import PropTypes from 'prop-types';
+import { useTranslation } from 'react-i18next';
+
 import AppContainer from '~/client/services/AppContainer';
+import { toastSuccess, toastError } from '~/client/util/apiNotification';
 
 import { withUnstatedContainers } from '../UnstatedUtils';
 
-import { toastSuccess, toastError } from '~/client/util/apiNotification';
 
 type EditorSettingsBodyProps = {
   appContainer: AppContainer
@@ -249,8 +251,12 @@ const EditorSettingsBody: FC<EditorSettingsBodyProps> = (props) => {
     }
   };
 
+  if (textlintRules == null) {
+    return <></>;
+  }
+
   return (
-    <>
+    <div data-testid="grw-editor-settings">
       <RuleListGroup
         title="editor_settings.common_settings.common_settings"
         ruleList={commonRulesMenuItems}
@@ -267,6 +273,7 @@ const EditorSettingsBody: FC<EditorSettingsBodyProps> = (props) => {
       <div className="row my-3">
         <div className="offset-4 col-5">
           <button
+            data-testid="grw-editor-settings-update-button"
             type="button"
             className="btn btn-primary"
             onClick={updateRulesHandler}
@@ -275,7 +282,7 @@ const EditorSettingsBody: FC<EditorSettingsBodyProps> = (props) => {
           </button>
         </div>
       </div>
-    </>
+    </div>
   );
 };
 

+ 12 - 4
packages/app/src/components/Me/ExternalAccountLinkedMe.jsx

@@ -1,16 +1,19 @@
 
 import React, { Fragment } from 'react';
+
 import PropTypes from 'prop-types';
 import { withTranslation } from 'react-i18next';
 
-import { withUnstatedContainers } from '../UnstatedUtils';
-import { toastError } from '~/client/util/apiNotification';
 
 import AppContainer from '~/client/services/AppContainer';
 import PersonalContainer from '~/client/services/PersonalContainer';
-import ExternalAccountRow from './ExternalAccountRow';
+import { toastError } from '~/client/util/apiNotification';
+
+import { withUnstatedContainers } from '../UnstatedUtils';
+
 import AssociateModal from './AssociateModal';
 import DisassociateModal from './DisassociateModal';
+import ExternalAccountRow from './ExternalAccountRow';
 
 class ExternalAccountLinkedMe extends React.Component {
 
@@ -68,7 +71,12 @@ class ExternalAccountLinkedMe extends React.Component {
     return (
       <Fragment>
         <h2 className="border-bottom my-4">
-          <button type="button" className="btn btn-outline-secondary btn-sm pull-right" onClick={this.openAssociateModal}>
+          <button
+            type="button"
+            data-testid="grw-external-account-add-button"
+            className="btn btn-outline-secondary btn-sm pull-right"
+            onClick={this.openAssociateModal}
+          >
             <i className="icon-plus" aria-hidden="true" />
             Add
           </button>

+ 4 - 2
packages/app/src/components/Me/InAppNotificationSettings.tsx

@@ -2,10 +2,11 @@ import React, {
   FC, useState, useEffect, useCallback,
 } from 'react';
 
-import { useTranslation } from 'react-i18next';
 import { pullAllBy } from 'lodash';
-import { apiv3Get, apiv3Put } from '~/client/util/apiv3-client';
+import { useTranslation } from 'react-i18next';
+
 import { toastSuccess, toastError } from '~/client/util/apiNotification';
+import { apiv3Get, apiv3Put } from '~/client/util/apiv3-client';
 import { subscribeRuleNames, SubscribeRuleDescriptions } from '~/interfaces/in-app-notification';
 
 type SubscribeRule = {
@@ -96,6 +97,7 @@ const InAppNotificationSettings: FC = () => {
       <div className="row my-3">
         <div className="offset-4 col-5">
           <button
+            data-testid="grw-in-app-notification-settings-update-button"
             type="button"
             className="btn btn-primary"
             onClick={updateSettingsHandler}

+ 8 - 3
packages/app/src/components/Me/PasswordSettings.jsx

@@ -1,13 +1,14 @@
 
 import React from 'react';
+
 import PropTypes from 'prop-types';
 import { withTranslation } from 'react-i18next';
 
-import { toastSuccess, toastError } from '~/client/util/apiNotification';
-import { withUnstatedContainers } from '../UnstatedUtils';
-
 import AppContainer from '~/client/services/AppContainer';
 import PersonalContainer from '~/client/services/PersonalContainer';
+import { toastSuccess, toastError } from '~/client/util/apiNotification';
+
+import { withUnstatedContainers } from '../UnstatedUtils';
 
 
 class PasswordSettings extends React.Component {
@@ -98,6 +99,9 @@ class PasswordSettings extends React.Component {
           <div className="row mb-3">
             <label htmlFor="oldPassword" className="col-md-3 text-md-right">{ t('personal_settings.current_password') }</label>
             <div className="col-md-5">
+              {/* to prevent autocomplete username into userForm[email] in BasicInfoSettings component */}
+              {/* https://developer.mozilla.org/en-US/docs/Web/Security/Securing_your_site/Turning_off_form_autocompletion */}
+              <input type="password" autoComplete="new-password" style={{ display: 'none' }} />
               <input
                 className="form-control"
                 type="password"
@@ -138,6 +142,7 @@ class PasswordSettings extends React.Component {
         <div className="row my-3">
           <div className="offset-5">
             <button
+              data-testid="grw-password-settings-update-button"
               type="button"
               className="btn btn-primary"
               onClick={this.onClickSubmit}

+ 8 - 4
packages/app/src/components/Me/PersonalSettings.jsx

@@ -1,15 +1,17 @@
 
 import React, { useMemo } from 'react';
+
 import PropTypes from 'prop-types';
 import { withTranslation } from 'react-i18next';
 
 import CustomNavAndContents from '../CustomNavigation/CustomNavAndContents';
-import UserSettings from './UserSettings';
-import PasswordSettings from './PasswordSettings';
-import ExternalAccountLinkedMe from './ExternalAccountLinkedMe';
+
 import ApiSettings from './ApiSettings';
 import { EditorSettings } from './EditorSettings';
+import ExternalAccountLinkedMe from './ExternalAccountLinkedMe';
 import InAppNotificationSettings from './InAppNotificationSettings';
+import PasswordSettings from './PasswordSettings';
+import UserSettings from './UserSettings';
 
 const PersonalSettings = (props) => {
 
@@ -58,7 +60,9 @@ const PersonalSettings = (props) => {
 
 
   return (
-    <CustomNavAndContents navTabMapping={navTabMapping} navigationMode="both" tabContentClasses={['px-0']} />
+    <div data-testid="grw-personal-settings">
+      <CustomNavAndContents navTabMapping={navTabMapping} navigationMode="both" tabContentClasses={['px-0']} />
+    </div>
   );
 
 };

+ 7 - 5
packages/app/src/components/Me/ProfileImageSettings.jsx

@@ -1,13 +1,15 @@
 import React from 'react';
+
+import md5 from 'md5';
 import PropTypes from 'prop-types';
 import { withTranslation } from 'react-i18next';
-import md5 from 'md5';
 
+import AppContainer from '~/client/services/AppContainer';
+import PersonalContainer from '~/client/services/PersonalContainer';
 import { toastSuccess, toastError } from '~/client/util/apiNotification';
+
 import { withUnstatedContainers } from '../UnstatedUtils';
 
-import AppContainer from '~/client/services/AppContainer';
-import PersonalContainer from '~/client/services/PersonalContainer';
 
 import ImageCropModal from './ImageCropModal';
 
@@ -115,14 +117,14 @@ class ProfileImageSettings extends React.Component {
                   onChange={() => { personalContainer.changeIsGravatarEnabled(true) }}
                 />
                 <label className="custom-control-label" htmlFor="radioGravatar">
-                  <img src="https://gravatar.com/avatar/00000000000000000000000000000000?s=24" /> Gravatar
+                  <img src="https://gravatar.com/avatar/00000000000000000000000000000000?s=24" data-hide-in-vrt /> Gravatar
                 </label>
                 <a href="https://gravatar.com/">
                   <small><i className="icon-arrow-right-circle" aria-hidden="true"></i></small>
                 </a>
               </div>
             </h4>
-            <img src={this.generateGravatarSrc()} width="64" />
+            <img src={this.generateGravatarSrc()} width="64" data-hide-in-vrt />
           </div>
 
           <div className="col-md-6 col-12">

+ 4 - 3
packages/app/src/components/Me/UserSettings.jsx

@@ -1,5 +1,6 @@
 
-import React, { Fragment } from 'react';
+import React from 'react';
+
 import PropTypes from 'prop-types';
 import { withTranslation } from 'react-i18next';
 
@@ -12,7 +13,7 @@ class UserSettings extends React.Component {
     const { t } = this.props;
 
     return (
-      <Fragment>
+      <div data-testid="grw-user-settings">
         <div className="mb-5">
           <h2 className="border-bottom my-4">{t('Basic Info')}</h2>
           <BasicInfoSettings />
@@ -21,7 +22,7 @@ class UserSettings extends React.Component {
           <h2 className="border-bottom my-4">{t('Set Profile Image')}</h2>
           <ProfileImageSettings />
         </div>
-      </Fragment>
+      </div>
     );
   }
 

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

@@ -1,22 +1,21 @@
 import React, { FC, memo } from 'react';
-import PropTypes from 'prop-types';
 
+import PropTypes from 'prop-types';
 import { useTranslation } from 'react-i18next';
-
 import { UncontrolledTooltip } from 'reactstrap';
 
 import AppContainer from '~/client/services/AppContainer';
 import { IUser } from '~/interfaces/user';
-import { useIsDeviceSmallerThanMd } from '~/stores/ui';
-import { usePageCreateModal } from '~/stores/modal';
 import { useIsSearchPage, useCurrentPagePath } from '~/stores/context';
+import { usePageCreateModal } from '~/stores/modal';
+import { useIsDeviceSmallerThanMd } from '~/stores/ui';
 
-import { withUnstatedContainers } from '../UnstatedUtils';
 import GrowiLogo from '../Icons/GrowiLogo';
+import InAppNotificationDropdown from '../InAppNotification/InAppNotificationDropdown';
+import { withUnstatedContainers } from '../UnstatedUtils';
 
-import PersonalDropdown from './PersonalDropdown';
 import GlobalSearch from './GlobalSearch';
-import InAppNotificationDropdown from '../InAppNotification/InAppNotificationDropdown';
+import PersonalDropdown from './PersonalDropdown';
 
 
 type NavbarRightProps = {
@@ -52,7 +51,7 @@ const NavbarRight: FC<NavbarRightProps> = memo((props: NavbarRightProps) => {
         </button>
       </li>
 
-      <li className="grw-personal-dropdown nav-item dropdown dropdown-toggle dropdown-toggle-no-caret">
+      <li className="grw-personal-dropdown nav-item dropdown dropdown-toggle dropdown-toggle-no-caret" data-testid="grw-personal-dropdown">
         <PersonalDropdown />
       </li>
     </>

+ 4 - 3
packages/app/src/components/Navbar/PageEditorModeManager.jsx

@@ -1,4 +1,5 @@
 import React, { useCallback } from 'react';
+
 import PropTypes from 'prop-types';
 import { useTranslation } from 'react-i18next';
 import { UncontrolledTooltip } from 'reactstrap';
@@ -50,13 +51,13 @@ function PageEditorModeManager(props) {
   const showHackmdBtn = isHackmdEnabled || isAdmin;
 
   const pageEditorModeButtonClickedHandler = useCallback((viewType) => {
-    if (isBtnDisabled || !isHackmdEnabled) {
+    if (isBtnDisabled) {
       return;
     }
     if (onPageEditorModeButtonClicked != null) {
       onPageEditorModeButtonClicked(viewType);
     }
-  }, [isBtnDisabled, isHackmdEnabled, onPageEditorModeButtonClicked]);
+  }, [isBtnDisabled, onPageEditorModeButtonClicked]);
 
   return (
     <>
@@ -91,7 +92,7 @@ function PageEditorModeManager(props) {
             <PageEditorModeButtonWrapper
               editorMode={editorMode}
               isBtnDisabled={isBtnDisabled || !isHackmdEnabled}
-              onClick={pageEditorModeButtonClickedHandler}
+              onClick={isHackmdEnabled ? pageEditorModeButtonClickedHandler : undefined}
               targetMode={EditorMode.HackMD}
               icon={<i className="fa fa-file-text-o" />}
               label={t('hackmd.hack_md')}

+ 21 - 34
packages/app/src/components/Navbar/PersonalDropdown.jsx

@@ -1,18 +1,12 @@
 import React, { useState, useCallback } from 'react';
-import PropTypes from 'prop-types';
-
-import { withTranslation } from 'react-i18next';
-
-import { UncontrolledTooltip } from 'reactstrap';
 
 import { UserPicture } from '@growi/ui';
+import { useTranslation } from 'react-i18next';
+import { UncontrolledTooltip } from 'reactstrap';
 
 import { useUserUISettings } from '~/client/services/user-ui-settings';
-import AppContainer from '~/client/services/AppContainer';
-
-import { withUnstatedContainers } from '../UnstatedUtils';
-import { usePreferDrawerModeByUser, usePreferDrawerModeOnEditByUser } from '~/stores/ui';
-
+import { toastError } from '~/client/util/apiNotification';
+import { apiv3Post } from '~/client/util/apiv3-client';
 import {
   isUserPreferenceExists,
   isDarkMode as isDarkModeByUtil,
@@ -21,18 +15,21 @@ import {
   updateUserPreference,
   updateUserPreferenceWithOsSettings,
 } from '~/client/util/color-scheme';
+import { useCurrentUser } from '~/stores/context';
+import { usePreferDrawerModeByUser, usePreferDrawerModeOnEditByUser } from '~/stores/ui';
 
 
-import SidebarDrawerIcon from '../Icons/SidebarDrawerIcon';
-import SidebarDockIcon from '../Icons/SidebarDockIcon';
 import MoonIcon from '../Icons/MoonIcon';
+import SidebarDockIcon from '../Icons/SidebarDockIcon';
+import SidebarDrawerIcon from '../Icons/SidebarDrawerIcon';
 import SunIcon from '../Icons/SunIcon';
 
 
-const PersonalDropdown = (props) => {
+const PersonalDropdown = () => {
+  const { t } = useTranslation();
+  const { data: currentUser } = useCurrentUser();
 
-  const { t, appContainer } = props;
-  const user = appContainer.currentUser || {};
+  const user = currentUser || {};
 
   const [useOsSettings, setOsSettings] = useState(!isUserPreferenceExists());
   const [isDarkMode, setIsDarkMode] = useState(isDarkModeByUtil());
@@ -41,13 +38,14 @@ const PersonalDropdown = (props) => {
   const { data: isPreferDrawerModeOnEdit, mutate: mutatePreferDrawerModeOnEdit } = usePreferDrawerModeOnEditByUser();
   const { scheduleToPut } = useUserUISettings();
 
-  const logoutHandler = () => {
-    const { interceptorManager } = appContainer;
-
-    const context = {};
-    interceptorManager.process('logout', context);
-
-    window.location.href = '/logout';
+  const logoutHandler = async() => {
+    try {
+      await apiv3Post('/logout');
+      window.location.reload();
+    }
+    catch (err) {
+      toastError(err);
+    }
   };
 
   const preferDrawerModeSwitchModifiedHandler = useCallback((bool) => {
@@ -232,15 +230,4 @@ const PersonalDropdown = (props) => {
 
 };
 
-/**
- * Wrapper component for using unstated
- */
-const PersonalDropdownWrapper = withUnstatedContainers(PersonalDropdown, [AppContainer]);
-
-
-PersonalDropdown.propTypes = {
-  t: PropTypes.func.isRequired, //  i18next
-  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
-};
-
-export default withTranslation()(PersonalDropdownWrapper);
+export default PersonalDropdown;

+ 16 - 14
packages/app/src/components/Page/DisplaySwitcher.tsx

@@ -1,27 +1,28 @@
 import React, { useMemo } from 'react';
+
+import { pagePathUtils } from '@growi/core';
 import { useTranslation } from 'react-i18next';
 import { TabContent, TabPane } from 'reactstrap';
 
-import { pagePathUtils } from '@growi/core';
 
-import { EditorMode, useEditorMode } from '~/stores/ui';
-import { useDescendantsPageListModal } from '~/stores/modal';
+import { smoothScrollIntoView } from '~/client/util/smooth-scroll';
 import {
   useCurrentPagePath, useIsSharedUser, useIsEditable, useCurrentPageId, useIsUserPage, usePageUser,
 } from '~/stores/context';
+import { useDescendantsPageListModal } from '~/stores/modal';
+import { useSWRxPageByPath } from '~/stores/page';
+import { EditorMode, useEditorMode } from '~/stores/ui';
 
-
-import { smoothScrollIntoView } from '~/client/util/smooth-scroll';
-
+import CountBadge from '../Common/CountBadge';
+import ContentLinkButtons from '../ContentLinkButtons';
+import HashChanged from '../EventListeneres/HashChanged';
 import PageListIcon from '../Icons/PageListIcon';
-import Editor from '../PageEditor';
 import Page from '../Page';
-import UserInfo from '../User/UserInfo';
-import TableOfContents from '../TableOfContents';
-import ContentLinkButtons from '../ContentLinkButtons';
-import PageEditorByHackmd from '../PageEditorByHackmd';
+import Editor from '../PageEditor';
 import EditorNavbarBottom from '../PageEditor/EditorNavbarBottom';
-import HashChanged from '../EventListeneres/HashChanged';
+import PageEditorByHackmd from '../PageEditorByHackmd';
+import TableOfContents from '../TableOfContents';
+import UserInfo from '../User/UserInfo';
 
 
 const WIKI_HEADER_LINK = 120;
@@ -43,6 +44,7 @@ const DisplaySwitcher = (): JSX.Element => {
   const { data: isUserPage } = useIsUserPage();
   const { data: isEditable } = useIsEditable();
   const { data: pageUser } = usePageUser();
+  const { data: currentPage } = useSWRxPageByPath(currentPath);
 
   const { data: editorMode } = useEditorMode();
 
@@ -74,7 +76,7 @@ const DisplaySwitcher = (): JSX.Element => {
                           <PageListIcon />
                         </div>
                         {t('page_list')}
-                        <span></span> {/* for a count badge */}
+                        {currentPage?.descendantCount != null && <CountBadge count={currentPage.descendantCount + 1} />}
                       </button>
                     ) }
                   </div>
@@ -89,7 +91,7 @@ const DisplaySwitcher = (): JSX.Element => {
                       >
                         <i className="icon-fw icon-bubbles grw-page-accessories-control-icon"></i>
                         <span>Comments</span>
-                        <span></span> {/* for a count badge */}
+                        {currentPage?.commentCount != null && <CountBadge count={currentPage.commentCount} />}
                       </button>
                     </div>
                   ) }

+ 5 - 3
packages/app/src/components/PageAttachment.jsx

@@ -1,14 +1,16 @@
 /* eslint-disable react/no-access-state-in-setstate */
 import React from 'react';
+
 import PropTypes from 'prop-types';
 import { withTranslation } from 'react-i18next';
 
-import PageAttachmentList from './PageAttachment/PageAttachmentList';
+import AppContainer from '~/client/services/AppContainer';
+import PageContainer from '~/client/services/PageContainer';
+
 import DeleteAttachmentModal from './PageAttachment/DeleteAttachmentModal';
+import PageAttachmentList from './PageAttachment/PageAttachmentList';
 import PaginationWrapper from './PaginationWrapper';
 import { withUnstatedContainers } from './UnstatedUtils';
-import AppContainer from '~/client/services/AppContainer';
-import PageContainer from '~/client/services/PageContainer';
 
 class PageAttachment extends React.Component {
 

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

@@ -1,24 +1,22 @@
 import React, {
   useEffect, useState, useMemo, useCallback,
 } from 'react';
-import PropTypes from 'prop-types';
 
+import { pagePathUtils, pathUtils } from '@growi/core';
+import { format } from 'date-fns';
+import PropTypes from 'prop-types';
+import { withTranslation } from 'react-i18next';
 import { Modal, ModalHeader, ModalBody } from 'reactstrap';
 import { debounce } from 'throttle-debounce';
 
-import { withTranslation } from 'react-i18next';
-import { format } from 'date-fns';
-
-import { pagePathUtils, pathUtils } from '@growi/core';
-
 
 import AppContainer from '~/client/services/AppContainer';
-import { withUnstatedContainers } from './UnstatedUtils';
 import { toastError } from '~/client/util/apiNotification';
-
 import { usePageCreateModal } from '~/stores/modal';
 
 import PagePathAutoComplete from './PagePathAutoComplete';
+import { withUnstatedContainers } from './UnstatedUtils';
+
 
 const {
   userPageRoot, isCreatablePage, generateEditorPath, isUsersHomePage,
@@ -83,14 +81,6 @@ const PageCreateModal = (props) => {
     setTodayInput2(value);
   }
 
-  /**
-   * change pageNameInput
-   * @param {string} value
-   */
-  function onChangePageNameInputHandler(value) {
-    setPageNameInput(value);
-  }
-
   /**
    * change template
    * @param {string} value
@@ -131,10 +121,6 @@ const PageCreateModal = (props) => {
     redirectToEditor(pageNameInput);
   }
 
-  function ppacInputChangeHandler(value) {
-    setPageNameInput(value);
-  }
-
   function ppacSubmitHandler(input) {
     redirectToEditor(input);
   }
@@ -212,7 +198,7 @@ const PageCreateModal = (props) => {
                     initializedPath={pageNameInput}
                     addTrailingSlash
                     onSubmit={ppacSubmitHandler}
-                    onInputChange={ppacInputChangeHandler}
+                    onInputChange={value => setPageNameInput(value)}
                     autoFocus
                   />
                 )
@@ -223,7 +209,7 @@ const PageCreateModal = (props) => {
                       value={pageNameInput}
                       className="form-control flex-fill"
                       placeholder={t('Input page name')}
-                      onChange={e => onChangePageNameInputHandler(e.target.value)}
+                      onChange={e => setPageNameInput(e.target.value)}
                       required
                     />
                   </form>

+ 12 - 10
packages/app/src/components/PageDeleteModal.tsx

@@ -1,22 +1,26 @@
-import React, { useState, FC, useMemo } from 'react';
+import React, {
+  useState, FC, useMemo,
+} from 'react';
+
+import { useTranslation } from 'react-i18next';
 import {
   Modal, ModalHeader, ModalBody, ModalFooter,
 } from 'reactstrap';
-import { useTranslation } from 'react-i18next';
 
 import { apiPost } from '~/client/util/apiv1-client';
 import { apiv3Post } from '~/client/util/apiv3-client';
-import { usePageDeleteModal } from '~/stores/modal';
-import loggerFactory from '~/utils/logger';
-
+import { HasObjectId } from '~/interfaces/has-object-id';
 import {
   IDeleteSinglePageApiv1Result, IDeleteManyPageApiv3Result, IPageToDeleteWithMeta, IDataWithMeta, isIPageInfoForEntity, IPageInfoForEntity,
 } from '~/interfaces/page';
-import { HasObjectId } from '~/interfaces/has-object-id';
+import { usePageDeleteModal } from '~/stores/modal';
+import { useSWRxPageInfoForList } from '~/stores/page';
+import loggerFactory from '~/utils/logger';
+
 
 import ApiErrorMessageList from './PageManagement/ApiErrorMessageList';
+
 import { isTrashPage } from '^/../core/src/utils/page-path-utils';
-import { useSWRxPageInfoForList } from '~/stores/page';
 
 
 const logger = loggerFactory('growi:cli:PageDeleteModal');
@@ -121,7 +125,6 @@ const PageDeleteModal: FC = () => {
         if (onDeleted != null) {
           onDeleted(data.paths, data.isRecursively, data.isCompletely);
         }
-
         closeDeleteModal();
       }
       catch (err) {
@@ -231,7 +234,6 @@ const PageDeleteModal: FC = () => {
         <div className="form-group grw-scrollable-modal-body pb-1">
           <label>{ t('modal_delete.deleting_page') }:</label><br />
           {/* Todo: change the way to show path on modal when too many pages are selected */}
-          {/* https://redmine.weseek.co.jp/issues/82787 */}
           {renderPagePathsToDelete()}
         </div>
         { isDeletable && renderDeleteRecursivelyForm()}
@@ -245,7 +247,7 @@ const PageDeleteModal: FC = () => {
           disabled={!isDeletable}
           onClick={deleteButtonHandler}
         >
-          <i className={`icon-${deleteIconAndKey[deleteMode].icon}`} aria-hidden="true"></i>
+          <i className={`mr-1 icon-${deleteIconAndKey[deleteMode].icon}`} aria-hidden="true"></i>
           { t(`modal_delete.delete_${deleteIconAndKey[deleteMode].translationKey}`) }
         </button>
       </ModalFooter>

+ 5 - 7
packages/app/src/components/PageDuplicateModal.tsx

@@ -2,22 +2,20 @@ import React, {
   useState, useEffect, useCallback, useMemo,
 } from 'react';
 
+import { useTranslation } from 'react-i18next';
 import {
   Modal, ModalHeader, ModalBody, ModalFooter,
 } from 'reactstrap';
-
-import { useTranslation } from 'react-i18next';
 import { debounce } from 'throttle-debounce';
 
-import { apiv3Get, apiv3Post } from '~/client/util/apiv3-client';
 import { toastError } from '~/client/util/apiNotification';
-
-import { usePageDuplicateModal } from '~/stores/modal';
+import { apiv3Get, apiv3Post } from '~/client/util/apiv3-client';
 import { useIsSearchServiceReachable, useSiteUrl } from '~/stores/context';
+import { usePageDuplicateModal } from '~/stores/modal';
 
-import PagePathAutoComplete from './PagePathAutoComplete';
-import ApiErrorMessageList from './PageManagement/ApiErrorMessageList';
 import DuplicatePathsTable from './DuplicatedPathsTable';
+import ApiErrorMessageList from './PageManagement/ApiErrorMessageList';
+import PagePathAutoComplete from './PagePathAutoComplete';
 
 
 const PageDuplicateModal = (): JSX.Element => {

+ 10 - 11
packages/app/src/components/PageList/PageListItemL.tsx

@@ -3,24 +3,18 @@ import React, {
   ForwardRefRenderFunction, memo, useCallback, useImperativeHandle, useRef, useEffect,
 } from 'react';
 
-import { useTranslation } from 'react-i18next';
-import { CustomInput } from 'reactstrap';
 
-import Clamp from 'react-multiline-clamp';
+import { DevidedPagePath } from '@growi/core';
+import { UserPicture, PageListMeta } from '@growi/ui';
 import { format } from 'date-fns';
+import { useTranslation } from 'react-i18next';
+import Clamp from 'react-multiline-clamp';
+import { CustomInput } from 'reactstrap';
 import urljoin from 'url-join';
 
-import { UserPicture, PageListMeta } from '@growi/ui';
-import { DevidedPagePath } from '@growi/core';
-
-import { useSWRxPageInfo } from '../../stores/page';
 
 import { ISelectable } from '~/client/interfaces/selectable-all';
 import { bookmark, unbookmark } from '~/client/services/page-operation';
-import { useIsDeviceSmallerThanLg } from '~/stores/ui';
-import {
-  usePageRenameModal, usePageDuplicateModal, usePageDeleteModal, usePutBackPageModal,
-} from '~/stores/modal';
 import {
   IPageInfoAll, IPageInfoForEntity, IPageInfoForListing, IPageWithMeta, isIPageInfoForListing, isIPageInfoForEntity,
 } from '~/interfaces/page';
@@ -29,7 +23,12 @@ import {
   OnDuplicatedFunction, OnRenamedFunction, OnDeletedFunction, OnPutBackedFunction,
 } from '~/interfaces/ui';
 import LinkedPagePath from '~/models/linked-page-path';
+import {
+  usePageRenameModal, usePageDuplicateModal, usePageDeleteModal, usePutBackPageModal,
+} from '~/stores/modal';
+import { useIsDeviceSmallerThanLg } from '~/stores/ui';
 
+import { useSWRxPageInfo } from '../../stores/page';
 import { ForceHideMenuItems, PageItemControl } from '../Common/Dropdown/PageItemControl';
 import PagePathHierarchicalLink from '../PagePathHierarchicalLink';
 

+ 48 - 21
packages/app/src/components/PageRenameModal.tsx

@@ -2,25 +2,24 @@ import React, {
   useState, useEffect, useCallback, useMemo,
 } from 'react';
 
+import { pagePathUtils } from '@growi/core';
+import { useTranslation } from 'react-i18next';
 import {
   Collapse, Modal, ModalHeader, ModalBody, ModalFooter,
 } from 'reactstrap';
-
-import { useTranslation } from 'react-i18next';
-
 import { debounce } from 'throttle-debounce';
-import { pagePathUtils } from '@growi/core';
-import { usePageRenameModal } from '~/stores/modal';
-import { toastError } from '~/client/util/apiNotification';
 
+import { toastError } from '~/client/util/apiNotification';
 import { apiv3Get, apiv3Put } from '~/client/util/apiv3-client';
-
-import ApiErrorMessageList from './PageManagement/ApiErrorMessageList';
-import DuplicatedPathsTable from './DuplicatedPathsTable';
-import { useSiteUrl } from '~/stores/context';
 import { isIPageInfoForEntity } from '~/interfaces/page';
+import { useSiteUrl, useIsSearchServiceReachable } from '~/stores/context';
+import { usePageRenameModal } from '~/stores/modal';
 import { useSWRxPageInfo } from '~/stores/page';
 
+import DuplicatedPathsTable from './DuplicatedPathsTable';
+import ApiErrorMessageList from './PageManagement/ApiErrorMessageList';
+import PagePathAutoComplete from './PagePathAutoComplete';
+
 
 const isV5Compatible = (meta: unknown): boolean => {
   return isIPageInfoForEntity(meta) ? meta.isV5Compatible : true;
@@ -33,6 +32,7 @@ const PageRenameModal = (): JSX.Element => {
   const { isUsersHomePage } = pagePathUtils;
   const { data: siteUrl } = useSiteUrl();
   const { data: renameModalData, close: closeRenameModal } = usePageRenameModal();
+  const { data: isReachable } = useIsSearchServiceReachable();
 
   const isOpened = renameModalData?.isOpened ?? false;
   const page = renameModalData?.page;
@@ -50,6 +50,7 @@ const PageRenameModal = (): JSX.Element => {
 
   const [subordinatedPages, setSubordinatedPages] = useState([]);
   const [existingPaths, setExistingPaths] = useState<string[]>([]);
+  const [canRename, setCanRename] = useState(false);
   const [isRenameRecursively, setIsRenameRecursively] = useState(true);
   const [isRenameRedirect, setIsRenameRedirect] = useState(false);
   const [isRemainMetadata, setIsRemainMetadata] = useState(false);
@@ -81,7 +82,7 @@ const PageRenameModal = (): JSX.Element => {
   }, [isOpened, page, updateSubordinatedList]);
 
   const rename = useCallback(async() => {
-    if (page == null) {
+    if (page == null || !canRename) {
       return;
     }
 
@@ -116,7 +117,7 @@ const PageRenameModal = (): JSX.Element => {
     catch (err) {
       setErrs(err);
     }
-  }, [closeRenameModal, isRemainMetadata, isRenameRecursively, isRenameRedirect, page, pageNameInput, renameModalData?.opts?.onRenamed]);
+  }, [closeRenameModal, canRename, isRemainMetadata, isRenameRecursively, isRenameRedirect, page, pageNameInput, renameModalData?.opts?.onRenamed]);
 
   const checkExistPaths = useCallback(async(fromPath, toPath) => {
     if (page == null) {
@@ -124,8 +125,11 @@ const PageRenameModal = (): JSX.Element => {
     }
 
     try {
-      const res = await apiv3Get<{ existPaths: string[] }>('/page/exist-paths', { fromPath, toPath });
+      const res = await apiv3Get<{ existPaths: string[]}>('/page/exist-paths', { fromPath, toPath });
       const { existPaths } = res.data;
+      if (existPaths.length === 0) {
+        setCanRename(true);
+      }
       setExistingPaths(existPaths);
     }
     catch (err) {
@@ -153,6 +157,15 @@ const PageRenameModal = (): JSX.Element => {
     }
   }, [pageNameInput, subordinatedPages, checkExistPathsDebounce, page, checkIsUsersHomePageDebounce]);
 
+  useEffect(() => {
+    setCanRename(false);
+  }, [pageNameInput]);
+
+
+  function ppacInputChangeHandler(value) {
+    setErrs(null);
+    setPageNameInput(value);
+  }
 
   /**
    * change pageNameInput
@@ -194,6 +207,9 @@ const PageRenameModal = (): JSX.Element => {
   if (isMatchedWithUserHomePagePath) {
     submitButtonDisabled = true;
   }
+  else if (!canRename) {
+    submitButtonDisabled = true;
+  }
   else if (isV5Compatible(page.meta)) {
     submitButtonDisabled = existingPaths.length !== 0; // v5 data
   }
@@ -219,14 +235,25 @@ const PageRenameModal = (): JSX.Element => {
               <span className="input-group-text">{siteUrl}</span>
             </div>
             <form className="flex-fill" onSubmit={(e) => { e.preventDefault(); rename() }}>
-              <input
-                type="text"
-                value={pageNameInput}
-                className="form-control"
-                onChange={e => inputChangeHandler(e.target.value)}
-                required
-                autoFocus
-              />
+              {isReachable
+                ? (
+                  <PagePathAutoComplete
+                    initializedPath={path}
+                    onSubmit={rename}
+                    onInputChange={ppacInputChangeHandler}
+                    autoFocus
+                  />
+                )
+                : (
+                  <input
+                    type="text"
+                    value={pageNameInput}
+                    className="form-control"
+                    onChange={e => inputChangeHandler(e.target.value)}
+                    required
+                    autoFocus
+                  />
+                )}
             </form>
           </div>
         </div>

+ 192 - 52
packages/app/src/components/PrivateLegacyPages.tsx

@@ -1,33 +1,41 @@
 import React, {
-  useCallback, useMemo, useRef, useState,
+  useCallback, useMemo, useRef, useState, useEffect,
 } from 'react';
-import { useTranslation } from 'react-i18next';
 
+import { useTranslation } from 'react-i18next';
 import {
-  UncontrolledButtonDropdown, DropdownToggle, DropdownMenu, DropdownItem,
+  UncontrolledButtonDropdown, DropdownToggle, DropdownMenu, DropdownItem, Modal, ModalHeader, ModalBody, ModalFooter,
 } 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 AppContainer from '~/client/services/AppContainer';
+import { toastSuccess, toastError } from '~/client/util/apiNotification';
+import { apiv3Post } from '~/client/util/apiv3-client';
+import { V5ConversionErrCode } from '~/interfaces/errors/v5-conversion-error';
+import { V5MigrationStatus } from '~/interfaces/page-listing-results';
+import { IFormattedSearchResult } from '~/interfaces/search';
+import { PageMigrationErrorData, SocketEventName } from '~/interfaces/websocket';
 import {
-  ILegacyPrivatePage, useLegacyPrivatePagesMigrationModal,
+  ILegacyPrivatePage, usePrivateLegacyPagesMigrationModal,
 } from '~/stores/modal';
+import { useSWRxV5MigrationStatus } from '~/stores/page-listing';
+import {
+  useSWRxSearch,
+} from '~/stores/search';
+import { useGlobalSocket } from '~/stores/websocket';
 
+import { MenuItemType } from './Common/Dropdown/PageItemControl';
 import PaginationWrapper from './PaginationWrapper';
+import { PrivateLegacyPagesMigrationModal } from './PrivateLegacyPagesMigrationModal';
 import { OperateAllControl } from './SearchPage/OperateAllControl';
-
+import SearchControl from './SearchPage/SearchControl';
 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;
+const INITIAL_PAGING_SIZE = 20;
+
+const initQ = '/';
 
 
 /**
@@ -39,6 +47,7 @@ type SearchResultListHeadProps = {
   offset: number,
   pagingSize: number,
   onPagingSizeChanged: (size: number) => void,
+  migrationStatus?: V5MigrationStatus,
 }
 
 const SearchResultListHead = React.memo((props: SearchResultListHeadProps): JSX.Element => {
@@ -46,14 +55,24 @@ const SearchResultListHead = React.memo((props: SearchResultListHeadProps): JSX.
 
   const {
     searchResult, offset, pagingSize,
-    onPagingSizeChanged,
+    onPagingSizeChanged, migrationStatus,
   } = props;
 
+  if (migrationStatus == null) {
+    return (
+      <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>
+    );
+  }
+
   const { took, total, hitsCount } = searchResult.meta;
   const leftNum = offset + 1;
   const rightNum = offset + hitsCount;
 
-  if (total === 0) {
+  const isSuccess = migrationStatus.migratablePagesCount === 0;
+
+  if (isSuccess) {
     return (
       <div className="card border-success mt-3">
         <div className="card-body">
@@ -108,6 +127,38 @@ const SearchResultListHead = React.memo((props: SearchResultListHeadProps): JSX.
   );
 });
 
+/*
+ * ConvertByPathModal
+ */
+type ConvertByPathModalProps = {
+  isOpen: boolean,
+  close?: () => void,
+  onSubmit?: (convertPath: string) => Promise<void> | void,
+}
+const ConvertByPathModal = React.memo((props: ConvertByPathModalProps): JSX.Element => {
+  const { t } = useTranslation();
+
+  const [currentInput, setInput] = useState<string>('');
+
+  return (
+    <Modal size="lg" isOpen={props.isOpen} toggle={props.close} className="grw-create-page">
+      <ModalHeader tag="h4" toggle={props.close} className="bg-primary text-light">
+        { t('private_legacy_pages.by_path_modal.title') }
+      </ModalHeader>
+      <ModalBody>
+        <p>{t('private_legacy_pages.by_path_modal.description')}</p>
+        <input type="text" className="form-control" placeholder="/" value={currentInput} onChange={e => setInput(e.target.value)} />
+      </ModalBody>
+      <ModalFooter>
+        <button type="button" className="btn btn-primary" onClick={() => props.onSubmit?.(currentInput)}>
+          <i className="icon-fw icon-refresh" aria-hidden="true"></i>
+          { t('private_legacy_pages.by_path_modal.button_label') }
+        </button>
+      </ModalFooter>
+    </Modal>
+  );
+});
+
 
 /**
  * LegacyPage
@@ -117,7 +168,7 @@ type Props = {
   appContainer: AppContainer,
 }
 
-export const PrivateLegacyPages = (props: Props): JSX.Element => {
+const PrivateLegacyPages = (props: Props): JSX.Element => {
   const { t } = useTranslation();
 
   const {
@@ -125,20 +176,56 @@ export const PrivateLegacyPages = (props: Props): JSX.Element => {
   } = props;
 
 
+  const [keyword, setKeyword] = useState<string>(initQ);
   const [offset, setOffset] = useState<number>(0);
-  const [limit, setLimit] = useState<number>(INITIAL_PAGIONG_SIZE);
+  const [limit, setLimit] = useState<number>(INITIAL_PAGING_SIZE);
+  const [isOpenConvertModal, setOpenConvertModal] = useState<boolean>(false);
 
   const [isControlEnabled, setControlEnabled] = useState(false);
 
   const selectAllControlRef = useRef<ISelectableAndIndeterminatable|null>(null);
   const searchPageBaseRef = useRef<ISelectableAll & IReturnSelectedPageIds|null>(null);
 
-  const { data, conditions, mutate } = useSWRxNamedQuerySearch('PrivateLegacyPages', {
+  const { data, conditions, mutate } = useSWRxSearch(keyword, 'PrivateLegacyPages', {
     offset,
     limit,
+    includeUserPages: true,
+    includeTrashPages: false,
   });
 
-  const { open: openModal, close: closeModal } = useLegacyPrivatePagesMigrationModal();
+  const { data: migrationStatus, mutate: mutateMigrationStatus } = useSWRxV5MigrationStatus();
+
+  const searchInvokedHandler = useCallback((_keyword: string) => {
+    mutateMigrationStatus();
+    setKeyword(_keyword);
+    setOffset(0);
+  }, []);
+
+  const { open: openModal, close: closeModal } = usePrivateLegacyPagesMigrationModal();
+  const { data: socket } = useGlobalSocket();
+
+  useEffect(() => {
+    socket?.on(SocketEventName.PageMigrationSuccess, () => {
+      toastSuccess(t('private_legacy_pages.toaster.page_migration_succeeded'));
+    });
+
+    socket?.on(SocketEventName.PageMigrationError, (data?: PageMigrationErrorData) => {
+      if (data == null || data.paths.length === 0) {
+        toastError(t('private_legacy_pages.toaster.page_migration_failed'));
+      }
+      else {
+        const errorPaths = data.paths.length > 3
+          ? `${data.paths.slice(0, 3).join(', ')}...`
+          : data.paths.join(', ');
+        toastError(t('private_legacy_pages.toaster.page_migration_failed_with_paths', { paths: errorPaths }));
+      }
+    });
+
+    return () => {
+      socket?.off(SocketEventName.PageMigrationSuccess);
+      socket?.off(SocketEventName.PageMigrationError);
+    };
+  }, [socket]);
 
   const selectAllCheckboxChangedHandler = useCallback((isChecked: boolean) => {
     const instance = searchPageBaseRef.current;
@@ -204,12 +291,13 @@ export const PrivateLegacyPages = (props: Props): JSX.Element => {
     openModal(
       selectedPages,
       () => {
-        toastSuccess('success');
+        toastSuccess(t('Successfully requested'));
         closeModal();
+        mutateMigrationStatus();
         mutate();
       },
     );
-  }, [data, mutate, openModal, closeModal]);
+  }, [data, mutate, openModal, closeModal, mutateMigrationStatus]);
 
   const pagingSizeChangedHandler = useCallback((pagingSize: number) => {
     setOffset(0);
@@ -224,42 +312,58 @@ export const PrivateLegacyPages = (props: Props): JSX.Element => {
 
   const hitsCount = data?.meta.hitsCount;
 
-  const searchControl = useMemo(() => {
+  const searchControlAllAction = 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 className="search-control d-flex align-items-center">
+        <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 className="d-flex pl-md-2">
+          <button type="button" className="btn btn-light" onClick={() => setOpenConvertModal(true)}>
+            {t('private_legacy_pages.input_path_to_convert')}
+          </button>
         </div>
       </div>
     );
   }, [convertMenuItemClickedHandler, deleteAllButtonClickedHandler, hitsCount, isControlEnabled, selectAllCheckboxChangedHandler, t]);
 
+  const searchControl = useMemo(() => {
+    return (
+      <SearchControl
+        isSearchServiceReachable
+        isEnableSort={false}
+        isEnableFilter={false}
+        initialSearchConditions={{ keyword: initQ, limit: INITIAL_PAGING_SIZE }}
+        onSearchInvoked={searchInvokedHandler}
+        allControl={searchControlAllAction}
+      />
+    );
+  }, [searchInvokedHandler, searchControlAllAction]);
+
   const searchResultListHead = useMemo(() => {
     if (data == null) {
       return <></>;
@@ -270,9 +374,10 @@ export const PrivateLegacyPages = (props: Props): JSX.Element => {
         offset={offset}
         pagingSize={limit}
         onPagingSizeChanged={pagingSizeChangedHandler}
+        migrationStatus={migrationStatus}
       />
     );
-  }, [data, limit, offset, pagingSizeChangedHandler]);
+  }, [data, limit, offset, pagingSizeChangedHandler, migrationStatus]);
 
   const searchPager = useMemo(() => {
     // when pager is not needed
@@ -307,7 +412,42 @@ export const PrivateLegacyPages = (props: Props): JSX.Element => {
         searchPager={searchPager}
       />
 
-      <LegacyPrivatePagesMigrationModal />
+      <PrivateLegacyPagesMigrationModal />
+      <ConvertByPathModal
+        isOpen={isOpenConvertModal}
+        close={() => setOpenConvertModal(false)}
+        onSubmit={async(convertPath: string) => {
+          try {
+            await apiv3Post<void>('/pages/legacy-pages-migration', {
+              convertPath,
+            });
+            toastSuccess(t('private_legacy_pages.by_path_modal.success'));
+            setOpenConvertModal(false);
+          }
+          catch (errs) {
+            if (errs.length === 1) {
+              switch (errs[0].code) {
+                case V5ConversionErrCode.GRANT_INVALID:
+                  toastError(t('private_legacy_pages.by_path_modal.error_grant_invalid'));
+                  break;
+                case V5ConversionErrCode.PAGE_NOT_FOUND:
+                  toastError(t('private_legacy_pages.by_path_modal.error_page_not_found'));
+                  break;
+                case V5ConversionErrCode.DUPLICATE_PAGES_FOUND:
+                  toastError(t('private_legacy_pages.by_path_modal.error_duplicate_pages_found'));
+                  break;
+                default:
+                  toastError(t('private_legacy_pages.by_path_modal.error'));
+              }
+            }
+            else {
+              toastError(t('private_legacy_pages.by_path_modal.error'));
+            }
+          }
+        }}
+      />
     </>
   );
 };
+
+export default PrivateLegacyPages;

+ 3 - 3
packages/app/src/components/LegacyPrivatePagesMigrationModal.tsx → packages/app/src/components/PrivateLegacyPagesMigrationModal.tsx

@@ -5,7 +5,7 @@ import {
 import { useTranslation } from 'react-i18next';
 
 import { apiv3Post } from '~/client/util/apiv3-client';
-import { useLegacyPrivatePagesMigrationModal } from '~/stores/modal';
+import { usePrivateLegacyPagesMigrationModal } from '~/stores/modal';
 
 import ApiErrorMessageList from './PageManagement/ApiErrorMessageList';
 
@@ -14,10 +14,10 @@ type Props = {
 
 }
 
-export const LegacyPrivatePagesMigrationModal = (props: Props): JSX.Element => {
+export const PrivateLegacyPagesMigrationModal = (props: Props): JSX.Element => {
   const { t } = useTranslation();
 
-  const { data: status, close } = useLegacyPrivatePagesMigrationModal();
+  const { data: status, close } = usePrivateLegacyPagesMigrationModal();
 
   const isOpened = status?.isOpened ?? false;
 

+ 8 - 5
packages/app/src/components/PutbackPageModal.jsx

@@ -1,13 +1,14 @@
 import React, { useState } from 'react';
 
+
+import { useTranslation } from 'react-i18next';
 import {
   Modal, ModalHeader, ModalBody, ModalFooter,
 } from 'reactstrap';
 
-import { useTranslation } from 'react-i18next';
-
-import { usePutBackPageModal } from '~/stores/modal';
 import { apiPost } from '~/client/util/apiv1-client';
+import { PathAlreadyExistsError } from '~/server/models/errors';
+import { usePutBackPageModal } from '~/stores/modal';
 
 import ApiErrorMessageList from './PageManagement/ApiErrorMessageList';
 
@@ -20,6 +21,7 @@ const PutBackPageModal = () => {
   const onPutBacked = pageDataToRevert.opts?.onPutBacked;
 
   const [errs, setErrs] = useState(null);
+  const [targetPath, setTargetPath] = useState(null);
 
   const [isPutbackRecursively, setIsPutbackRecursively] = useState(true);
 
@@ -46,7 +48,8 @@ const PutBackPageModal = () => {
       closePutBackPageModal();
     }
     catch (err) {
-      setErrs(err);
+      setTargetPath(err.data);
+      setErrs([err]);
     }
   }
 
@@ -78,7 +81,7 @@ const PutBackPageModal = () => {
         </div>
       </ModalBody>
       <ModalFooter>
-        <ApiErrorMessageList errs={errs} />
+        <ApiErrorMessageList errs={errs} targetPath={targetPath} />
         <button type="button" className="btn btn-info" onClick={putbackPageButtonHandler}>
           <i className="icon-action-undo mr-2" aria-hidden="true"></i> { t('Put Back') }
         </button>

+ 7 - 5
packages/app/src/components/SearchPage.tsx

@@ -9,7 +9,7 @@ import AppContainer from '~/client/services/AppContainer';
 import { IFormattedSearchResult } from '~/interfaces/search';
 import { ISelectableAll, ISelectableAndIndeterminatable } from '~/client/interfaces/selectable-all';
 import { useIsSearchServiceReachable } from '~/stores/context';
-import { ISearchConditions, ISearchConfigurations, useSWRxFullTextSearch } from '~/stores/search';
+import { ISearchConditions, ISearchConfigurations, useSWRxSearch } from '~/stores/search';
 
 import PaginationWrapper from './PaginationWrapper';
 import { OperateAllControl } from './SearchPage/OperateAllControl';
@@ -119,7 +119,7 @@ export const SearchPage = (props: Props): JSX.Element => {
 
   const { data: isSearchServiceReachable } = useIsSearchServiceReachable();
 
-  const { data, conditions, mutate } = useSWRxFullTextSearch(keyword, {
+  const { data, conditions, mutate } = useSWRxSearch(keyword, null, {
     ...configurationsByControl,
     offset,
     limit,
@@ -193,7 +193,7 @@ export const SearchPage = (props: Props): JSX.Element => {
   }, [keyword]);
   const hitsCount = data?.meta.hitsCount;
 
-  const deleteAllControl = useMemo(() => {
+  const allControl = useMemo(() => {
     const isDisabled = hitsCount === 0;
 
     return (
@@ -222,13 +222,15 @@ export const SearchPage = (props: Props): JSX.Element => {
     return (
       <SearchControl
         isSearchServiceReachable={isSearchServiceReachable}
+        isEnableSort
+        isEnableFilter
         initialSearchConditions={initialSearchConditions}
         onSearchInvoked={searchInvokedHandler}
-        deleteAllControl={deleteAllControl}
+        allControl={allControl}
       >
       </SearchControl>
     );
-  }, [deleteAllControl, initialSearchConditions, isSearchServiceReachable, searchInvokedHandler]);
+  }, [allControl, initialSearchConditions, isSearchServiceReachable, searchInvokedHandler]);
 
   const searchResultListHead = useMemo(() => {
     if (data == null) {

+ 70 - 58
packages/app/src/components/SearchPage/SearchControl.tsx

@@ -12,20 +12,24 @@ import SearchForm from '../SearchForm';
 
 type Props = {
   isSearchServiceReachable: boolean,
+  isEnableSort: boolean,
+  isEnableFilter: boolean,
   initialSearchConditions: Partial<ISearchConditions>,
 
   onSearchInvoked: (keyword: string, configurations: Partial<ISearchConfigurations>) => void,
 
-  deleteAllControl: React.ReactNode,
+  allControl: React.ReactNode,
 }
 
 const SearchControl: FC <Props> = React.memo((props: Props) => {
 
   const {
     isSearchServiceReachable,
+    isEnableSort,
+    isEnableFilter,
     initialSearchConditions,
     onSearchInvoked,
-    deleteAllControl,
+    allControl,
   } = props;
 
   const [keyword, setKeyword] = useState(initialSearchConditions.keyword ?? '');
@@ -73,71 +77,79 @@ const SearchControl: FC <Props> = React.memo((props: Props) => {
         </div>
 
         {/* sort option: show when screen is larger than lg */}
-        <div className="mr-4 d-lg-flex d-none">
-          <SortControl
-            sort={sort}
-            order={order}
-            onChange={changeSortHandler}
-          />
-        </div>
+        {isEnableSort && (
+          <div className="mr-4 d-lg-flex d-none">
+            <SortControl
+              sort={sort}
+              order={order}
+              onChange={changeSortHandler}
+            />
+          </div>
+        )}
       </div>
       {/* TODO: replace the following elements deleteAll button , relevance button and include specificPath button component */}
       <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">
-          {deleteAllControl}
+          {allControl}
         </div>
         {/* sort option: show when screen is smaller than lg */}
-        <div className="mr-md-4 mr-2 d-flex d-lg-none ml-auto">
-          <SortControl
-            sort={sort}
-            order={order}
-            onChange={changeSortHandler}
-          />
-        </div>
-        {/* filter option */}
-        <div className="d-lg-none">
-          <button
-            type="button"
-            className="btn"
-            onClick={() => setIsFileterOptionModalShown(true)}
-          >
-            <i className="icon-equalizer"></i>
-          </button>
-        </div>
-        <div className="d-none d-lg-flex align-items-center ml-auto search-control-include-options">
-          <div className="border rounded px-2 py-1 mr-3">
-            <div className="custom-control custom-checkbox custom-checkbox-primary">
-              <input
-                className="custom-control-input mr-2"
-                type="checkbox"
-                id="flexCheckDefault"
-                defaultChecked={includeUserPages}
-                onChange={e => setIncludeUserPages(e.target.checked)}
-              />
-              <label className="custom-control-label mb-0 d-flex align-items-center text-secondary with-no-font-weight" htmlFor="flexCheckDefault">
-                {t('Include Subordinated Target Page', { target: '/user' })}
-              </label>
-            </div>
+        {isEnableSort && (
+          <div className="mr-md-4 mr-2 d-flex d-lg-none ml-auto">
+            <SortControl
+              sort={sort}
+              order={order}
+              onChange={changeSortHandler}
+            />
           </div>
-          <div className="border rounded px-2 py-1">
-            <div className="custom-control custom-checkbox custom-checkbox-primary">
-              <input
-                className="custom-control-input mr-2"
-                type="checkbox"
-                id="flexCheckChecked"
-                checked={includeTrashPages}
-                onChange={e => setIncludeTrashPages(e.target.checked)}
-              />
-              <label
-                className="custom-control-label
-              d-flex align-items-center text-secondary with-no-font-weight"
-                htmlFor="flexCheckChecked"
+        )}
+        {/* filter option */}
+        {isEnableFilter && (
+          <>
+            <div className="d-lg-none">
+              <button
+                type="button"
+                className="btn"
+                onClick={() => setIsFileterOptionModalShown(true)}
               >
-                {t('Include Subordinated Target Page', { target: '/trash' })}
-              </label>
+                <i className="icon-equalizer"></i>
+              </button>
             </div>
-          </div>
-        </div>
+            <div className="d-none d-lg-flex align-items-center ml-auto search-control-include-options">
+              <div className="border rounded px-2 py-1 mr-3">
+                <div className="custom-control custom-checkbox custom-checkbox-primary">
+                  <input
+                    className="custom-control-input mr-2"
+                    type="checkbox"
+                    id="flexCheckDefault"
+                    defaultChecked={includeUserPages}
+                    onChange={e => setIncludeUserPages(e.target.checked)}
+                  />
+                  <label className="custom-control-label mb-0 d-flex align-items-center text-secondary with-no-font-weight" htmlFor="flexCheckDefault">
+                    {t('Include Subordinated Target Page', { target: '/user' })}
+                  </label>
+                </div>
+              </div>
+              <div className="border rounded px-2 py-1">
+                <div className="custom-control custom-checkbox custom-checkbox-primary">
+                  <input
+                    className="custom-control-input mr-2"
+                    type="checkbox"
+                    id="flexCheckChecked"
+                    checked={includeTrashPages}
+                    onChange={e => setIncludeTrashPages(e.target.checked)}
+                  />
+                  <label
+                    className="custom-control-label
+                  d-flex align-items-center text-secondary with-no-font-weight"
+                    htmlFor="flexCheckChecked"
+                  >
+                    {t('Include Subordinated Target Page', { target: '/trash' })}
+                  </label>
+                </div>
+              </div>
+            </div>
+          </>
+        )}
       </div>
 
       <SearchOptionModal

+ 3 - 2
packages/app/src/components/SearchTypeahead.tsx

@@ -11,7 +11,7 @@ import { IFocusable } from '~/client/interfaces/focusable';
 import { TypeaheadProps } from '~/client/interfaces/react-bootstrap-typeahead';
 import { IPageSearchMeta } from '~/interfaces/search';
 import { IPageWithMeta } from '~/interfaces/page';
-import { useSWRxFullTextSearch } from '~/stores/search';
+import { useSWRxSearch } from '~/stores/search';
 
 
 type ResetFormButtonProps = {
@@ -61,8 +61,9 @@ const SearchTypeahead: ForwardRefRenderFunction<IFocusable, Props> = (props: Pro
   const [searchKeyword, setSearchKeyword] = useState('');
   const [isForcused, setFocused] = useState(false);
 
-  const { data: searchResult, error: searchError } = useSWRxFullTextSearch(
+  const { data: searchResult, error: searchError } = useSWRxSearch(
     disableIncrementalSearch ? null : searchKeyword,
+    null,
     { limit: 10 },
   );
 

+ 0 - 89
packages/app/src/components/ShareLink/ShareLinkList.jsx

@@ -1,89 +0,0 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-
-
-import { withTranslation } from 'react-i18next';
-import dateFnsFormat from 'date-fns/format';
-
-import { withUnstatedContainers } from '../UnstatedUtils';
-
-import AppContainer from '~/client/services/AppContainer';
-import CopyDropdown from '../Page/CopyDropdown';
-
-const ShareLinkList = (props) => {
-
-  const { t } = props;
-  function deleteLinkHandler(shareLinkId) {
-    if (props.onClickDeleteButton == null) {
-      return;
-    }
-    props.onClickDeleteButton(shareLinkId);
-  }
-
-  function renderShareLinks() {
-    return (
-      <>
-        {props.shareLinks.map(shareLink => (
-          <tr key={shareLink._id}>
-            <td>
-              <div className="d-flex">
-                <span className="mr-auto my-auto">{shareLink._id}</span>
-                <CopyDropdown
-                  pagePath={shareLink.relatedPage.path}
-                  dropdownToggleId={`copydropdown-${shareLink._id}`}
-                  pageId={shareLink._id}
-                  isShareLinkMode
-                >
-                  Copy Link
-                </CopyDropdown>
-              </div>
-            </td>
-            {props.isAdmin && <td><a href={shareLink.relatedPage.path}>{shareLink.relatedPage.path}</a></td>}
-            <td>{shareLink.expiredAt && <span>{dateFnsFormat(new Date(shareLink.expiredAt), 'yyyy-MM-dd HH:mm')}</span>}</td>
-            <td>{shareLink.description}</td>
-            <td>
-              <button className="btn btn-outline-warning" type="button" onClick={() => deleteLinkHandler(shareLink._id)}>
-                <i className="icon-trash"></i>{t('Delete')}
-              </button>
-            </td>
-          </tr>
-        ))}
-      </>
-    );
-  }
-
-  return (
-    <div className="table-responsive">
-      <table className="table table-bordered">
-        <thead>
-          <tr>
-            <th>{t('share_links.Share Link')}</th>
-            {props.isAdmin && <th>{t('share_links.Page Path')}</th>}
-            <th>{t('share_links.expire')}</th>
-            <th>{t('share_links.description')}</th>
-            <th></th>
-          </tr>
-        </thead>
-        <tbody>
-          {renderShareLinks()}
-        </tbody>
-      </table>
-    </div>
-  );
-};
-
-/**
- * Wrapper component for using unstated
- */
-const ShareLinkListWrapper = withUnstatedContainers(ShareLinkList, [AppContainer]);
-
-ShareLinkList.propTypes = {
-  t: PropTypes.func.isRequired, //  i18next
-  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
-
-  shareLinks: PropTypes.array.isRequired,
-  onClickDeleteButton: PropTypes.func,
-  isAdmin: PropTypes.bool,
-};
-
-export default withTranslation()(ShareLinkListWrapper);

+ 113 - 0
packages/app/src/components/ShareLink/ShareLinkList.tsx

@@ -0,0 +1,113 @@
+import React from 'react';
+
+import dateFnsFormat from 'date-fns/format';
+import { useTranslation } from 'react-i18next';
+
+import CopyDropdown from '../Page/CopyDropdown';
+
+
+type ShareLinkTrProps = {
+  // eslint-disable-next-line @typescript-eslint/no-explicit-any
+  shareLink: any,
+  isAdmin?: boolean,
+  onDelete?: () => void,
+}
+
+const ShareLinkTr = (props: ShareLinkTrProps): JSX.Element => {
+  const { t } = useTranslation();
+
+  const { isAdmin, shareLink, onDelete } = props;
+
+  const { _id: shareLinkId, relatedPage } = shareLink;
+
+  const isRelatedPageExists = relatedPage != null;
+
+  return (
+    <tr key={shareLinkId}>
+      <td>
+        <div className="d-flex">
+          <span className="mr-auto my-auto">{shareLinkId}</span>
+
+          { isRelatedPageExists && (
+            <CopyDropdown
+              pagePath={relatedPage.path}
+              dropdownToggleId={`copydropdown-${shareLinkId}`}
+              pageId={shareLinkId}
+              isShareLinkMode
+            >
+              Copy Link
+            </CopyDropdown>
+          ) }
+        </div>
+      </td>
+      { isAdmin && (
+        <td>
+          { isRelatedPageExists
+            ? <a href={relatedPage.path}>{relatedPage.path}</a>
+            : '(Page is not found)'
+          }
+        </td>
+      ) }
+      <td>{shareLink.expiredAt && <span>{dateFnsFormat(new Date(shareLink.expiredAt), 'yyyy-MM-dd HH:mm')}</span>}</td>
+      <td>{shareLink.description}</td>
+      <td>
+        <button className="btn btn-outline-warning" type="button" onClick={onDelete}>
+          <i className="icon-trash"></i>{t('Delete')}
+        </button>
+      </td>
+    </tr>
+  );
+};
+
+
+type Props = {
+  // eslint-disable-next-line @typescript-eslint/no-explicit-any
+  shareLinks: any[],
+  onClickDeleteButton?: (shareLinkId: string) => void,
+  isAdmin?: boolean,
+}
+
+const ShareLinkList = (props: Props): JSX.Element => {
+
+  const { t } = useTranslation();
+
+  function renderShareLinks() {
+    return (
+      <>
+        {props.shareLinks.map(shareLink => (
+          <ShareLinkTr
+            isAdmin={props.isAdmin}
+            shareLink={shareLink}
+            onDelete={() => {
+              if (props.onClickDeleteButton == null) {
+                return;
+              }
+              props.onClickDeleteButton(shareLink._id);
+            }}
+          />
+        ))}
+      </>
+    );
+  }
+
+  return (
+    <div className="table-responsive">
+      <table className="table table-bordered">
+        <thead>
+          <tr>
+            <th>{t('share_links.Share Link')}</th>
+            {props.isAdmin && <th>{t('share_links.Page Path')}</th>}
+            <th>{t('share_links.expire')}</th>
+            <th>{t('share_links.description')}</th>
+            <th></th>
+          </tr>
+        </thead>
+        <tbody>
+          {renderShareLinks()}
+        </tbody>
+      </table>
+    </div>
+  );
+};
+
+export default ShareLinkList;

+ 73 - 0
packages/app/src/components/Sidebar/InfiniteScroll.tsx

@@ -0,0 +1,73 @@
+import React, {
+  Ref, useEffect, useState,
+} from 'react';
+import type { SWRInfiniteResponse } from 'swr/infinite';
+
+type Props<T> = {
+  swrInifiniteResponse : SWRInfiniteResponse<T>
+  children: React.ReactChild | ((item: T) => React.ReactNode),
+  loadingIndicator?: React.ReactNode
+  endingIndicator?: React.ReactNode
+  isReachingEnd?: boolean,
+  offset?: number
+}
+
+const useIntersection = <E extends HTMLElement>(): [boolean, Ref<E>] => {
+  const [intersecting, setIntersecting] = useState<boolean>(false);
+  const [element, setElement] = useState<HTMLElement>();
+  useEffect(() => {
+    if (element != null) {
+      const observer = new IntersectionObserver((entries) => {
+        setIntersecting(entries[0]?.isIntersecting);
+      });
+      observer.observe(element);
+      return () => observer.unobserve(element);
+    }
+    return;
+  }, [element]);
+  return [intersecting, el => el && setElement(el)];
+};
+
+const LoadingIndicator = (): React.ReactElement => {
+  return (
+    <div className="text-muted text-center">
+      <i className="fa fa-2x fa-spinner fa-pulse mr-1"></i>
+    </div>
+  );
+};
+
+const InfiniteScroll = <E, >(props: Props<E>): React.ReactElement<Props<E>> => {
+  const {
+    swrInifiniteResponse: {
+      setSize, data, isValidating,
+    },
+    children,
+    loadingIndicator,
+    endingIndicator,
+    isReachingEnd,
+    offset = 0,
+  } = props;
+
+  const [intersecting, ref] = useIntersection<HTMLDivElement>();
+
+  useEffect(() => {
+    if (intersecting && !isValidating && !isReachingEnd) {
+      setSize(size => size + 1);
+    }
+  }, [setSize, intersecting]);
+
+  return (
+    <>
+      {typeof children === 'function' ? data?.map(item => children(item)) : children}
+      <div style={{ position: 'relative' }}>
+        <div ref={ref} style={{ position: 'absolute', top: offset }}></div>
+        {isReachingEnd
+          ? endingIndicator
+          : loadingIndicator || <LoadingIndicator />
+        }
+      </div>
+    </>
+  );
+};
+
+export default InfiniteScroll;

+ 15 - 35
packages/app/src/components/Sidebar/PageTree/Item.tsx

@@ -1,32 +1,33 @@
 import React, {
   useCallback, useState, FC, useEffect,
 } from 'react';
-import { DropdownToggle } from 'reactstrap';
-import { useTranslation } from 'react-i18next';
-
-import { useDrag, useDrop } from 'react-dnd';
 
 import nodePath from 'path';
 
 import { pathUtils, pagePathUtils } from '@growi/core';
+import { useDrag, useDrop } from 'react-dnd';
+import { useTranslation } from 'react-i18next';
+import { DropdownToggle } from 'reactstrap';
 
-import loggerFactory from '~/utils/logger';
 
+import { bookmark, unbookmark } from '~/client/services/page-operation';
 import { toastWarning, toastError, toastSuccess } from '~/client/util/apiNotification';
-
-import { useSWRxPageChildren } from '~/stores/page-listing';
 import { apiv3Put, apiv3Post } from '~/client/util/apiv3-client';
+import TriangleIcon from '~/components/Icons/TriangleIcon';
+import {
+  IPageHasId, IPageInfoAll, IPageToDeleteWithMeta,
+} from '~/interfaces/page';
 import { IPageForPageDuplicateModal } from '~/stores/modal';
+import { useSWRxPageChildren } from '~/stores/page-listing';
+import { usePageTreeDescCountMap } from '~/stores/ui';
+import loggerFactory from '~/utils/logger';
+
 
-import TriangleIcon from '~/components/Icons/TriangleIcon';
-import { bookmark, unbookmark } from '~/client/services/page-operation';
 import ClosableTextInput, { AlertInfo, AlertType } from '../../Common/ClosableTextInput';
+import CountBadge from '../../Common/CountBadge';
 import { PageItemControl } from '../../Common/Dropdown/PageItemControl';
+
 import { ItemNode } from './ItemNode';
-import { usePageTreeDescCountMap } from '~/stores/ui';
-import {
-  IPageHasId, IPageInfoAll, IPageToDeleteWithMeta,
-} from '~/interfaces/page';
 
 
 const logger = loggerFactory('growi:cli:Item');
@@ -94,20 +95,6 @@ const isDroppable = (fromPage?: Partial<IPageHasId>, newParentPage?: Partial<IPa
 };
 
 
-type ItemCountProps = {
-  descendantCount: number
-}
-
-const ItemCount: FC<ItemCountProps> = (props:ItemCountProps) => {
-  return (
-    <>
-      <span className="grw-pagetree-count badge badge-pill badge-light">
-        {props.descendantCount}
-      </span>
-    </>
-  );
-};
-
 const Item: FC<ItemProps> = (props: ItemProps) => {
   const { t } = useTranslation();
   const {
@@ -381,13 +368,6 @@ const Item: FC<ItemProps> = (props: ItemProps) => {
       };
     }
 
-    if (title.includes('/')) {
-      return {
-        type: AlertType.WARNING,
-        message: t('form_validation.slashed_are_not_yet_supported'),
-      };
-    }
-
     return null;
   };
 
@@ -465,7 +445,7 @@ const Item: FC<ItemProps> = (props: ItemProps) => {
           )}
         {descendantCount > 0 && !isRenameInputShown && (
           <div className="grw-pagetree-count-wrapper">
-            <ItemCount descendantCount={descendantCount} />
+            <CountBadge count={descendantCount} />
           </div>
         )}
         <div className="grw-pagetree-control d-flex">

+ 18 - 12
packages/app/src/components/Sidebar/RecentChanges.tsx

@@ -10,11 +10,11 @@ import { UserPicture, FootstampIcon } from '@growi/ui';
 import { DevidedPagePath } from '@growi/core';
 
 import PagePathHierarchicalLink from '~/components/PagePathHierarchicalLink';
-import { useSWRxRecentlyUpdated } from '~/stores/page';
+import { useSWRInifinitexRecentlyUpdated } from '~/stores/page';
 import loggerFactory from '~/utils/logger';
 
 import LinkedPagePath from '~/models/linked-page-path';
-
+import InfiniteScroll from './InfiniteScroll';
 
 import FormattedDistanceDate from '../FormattedDistanceDate';
 
@@ -120,14 +120,13 @@ SmallPageItem.propTypes = {
   page: PropTypes.any,
 };
 
-
 const RecentChanges = (): JSX.Element => {
-
+  const PER_PAGE = 20;
   const { t } = useTranslation();
-  const { data: pages, mutate } = useSWRxRecentlyUpdated();
-
+  const swr = useSWRInifinitexRecentlyUpdated();
   const [isRecentChangesSidebarSmall, setIsRecentChangesSidebarSmall] = useState(false);
-
+  const isEmpty = swr.data?.[0].length === 0;
+  const isReachingEnd = isEmpty || (swr.data && swr.data[swr.data.length - 1]?.length < PER_PAGE);
   const retrieveSizePreferenceFromLocalStorage = useCallback(() => {
     if (window.localStorage.isRecentChangesSidebarSmall === 'true') {
       setIsRecentChangesSidebarSmall(true);
@@ -148,7 +147,7 @@ const RecentChanges = (): JSX.Element => {
     <>
       <div className="grw-sidebar-content-header p-3 d-flex">
         <h3 className="mb-0  text-nowrap">{t('Recent Changes')}</h3>
-        <button type="button" className="btn btn-sm ml-auto grw-btn-reload" onClick={() => mutate()}>
+        <button type="button" className="btn btn-sm ml-auto grw-btn-reload" onClick={() => swr.mutate()}>
           <i className="icon icon-reload"></i>
         </button>
         <div className="d-flex align-items-center">
@@ -167,14 +166,21 @@ const RecentChanges = (): JSX.Element => {
       </div>
       <div className="grw-recent-changes p-3">
         <ul className="list-group list-group-flush">
-          {(pages || []).map(page => (isRecentChangesSidebarSmall
-            ? <SmallPageItem key={page._id} page={page} />
-            : <LargePageItem key={page._id} page={page} />))}
+          <InfiniteScroll
+            swrInifiniteResponse={swr}
+            isReachingEnd={isReachingEnd}
+          >
+            {pages => pages.map(page => (
+              isRecentChangesSidebarSmall
+                ? <SmallPageItem key={page._id} page={page} />
+                : <LargePageItem key={page._id} page={page} />
+            ))
+            }
+          </InfiniteScroll>
         </ul>
       </div>
     </>
   );
 
 };
-
 export default RecentChanges;

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

@@ -86,7 +86,7 @@ const SidebarNav: FC<Props> = (props: Props) => {
         <PrimaryItem contents={SidebarContentsType.RECENT} label="Recent Changes" iconName="update" onItemSelected={onItemSelected} />
         {/* <PrimaryItem id="tag" label="Tags" iconName="icon-tag" /> */}
         {/* <PrimaryItem id="favorite" label="Favorite" iconName="fa fa-bookmark-o" /> */}
-        <PrimaryItem contents={SidebarContentsType.TAG} label="Tags" iconName="tag" onItemSelected={onItemSelected} />
+        <PrimaryItem contents={SidebarContentsType.TAG} label="Tags" iconName="local_offer" onItemSelected={onItemSelected} />
         {/* <PrimaryItem id="favorite" label="Favorite" iconName="icon-star" /> */}
         {/* eslint-enable max-len */}
       </div>

+ 56 - 16
packages/app/src/components/Sidebar/Tag.tsx

@@ -1,41 +1,81 @@
-import React, { FC, useState, useEffect } from 'react';
+import React, { FC, useState, useCallback } from 'react';
+
 import { useTranslation } from 'react-i18next';
-import TagsList from '../TagsList';
+
+import { ITagCountHasId } from '~/interfaces/tag';
+import { useSWRxTagsList } from '~/stores/tag';
+
+import TagCloudBox from '../TagCloudBox';
+import TagList from '../TagList';
+
+
+const PAGING_LIMIT = 10;
 
 const Tag: FC = () => {
+  const [activePage, setActivePage] = useState<number>(1);
+  const [offset, setOffset] = useState<number>(0);
+
+  const { data: tagDataList, mutate: mutateTagDataList, error } = useSWRxTagsList(PAGING_LIMIT, offset);
+  const tagData: ITagCountHasId[] = tagDataList?.data || [];
+  const totalCount: number = tagDataList?.totalCount || 0;
+  const isLoading = tagDataList === undefined && error == null;
+
   const { t } = useTranslation('');
-  const [isOnReload, setIsOnReload] = useState<boolean>(false);
 
-  useEffect(() => {
-    setIsOnReload(false);
-  }, [isOnReload]);
+  const setOffsetByPageNumber = useCallback((selectedPageNumber: number) => {
+    setActivePage(selectedPageNumber);
+    setOffset((selectedPageNumber - 1) * PAGING_LIMIT);
+  }, []);
 
+  const onReload = useCallback(() => {
+    mutateTagDataList();
+  }, [mutateTagDataList]);
+
+  // todo: adjust design by XD
   return (
-    <div data-testid="grw-sidebar-content-tags">
-      <div className="grw-sidebar-content-header p-3 d-flex">
+    <div className="grw-container-convertible px-4 mb-5 pb-5" data-testid="grw-sidebar-content-tags">
+      <div className="grw-sidebar-content-header py-3 d-flex">
         <h3 className="mb-0">{t('Tags')}</h3>
         <button
           type="button"
           className="btn btn-sm ml-auto grw-btn-reload-rc"
-          onClick={() => {
-            setIsOnReload(true);
-          }}
+          onClick={onReload}
         >
           <i className="icon icon-reload"></i>
         </button>
       </div>
-      <div className="d-flex justify-content-center">
+      <h2 className="my-3">{t('popular_tags')}</h2>
+
+      <div className="px-3 text-center">
+        <TagCloudBox tags={tagData} />
+      </div>
+
+      <div className="d-flex justify-content-center my-5">
         <button
-          className="btn btn-primary my-4"
+          className="btn btn-primary rounded px-5"
           type="button"
           onClick={() => { window.location.href = '/tags' }}
         >
           {t('Check All tags')}
         </button>
       </div>
-      <div className="grw-container-convertible mb-5 pb-5">
-        <TagsList isOnReload={isOnReload} />
-      </div>
+
+      { isLoading
+        ? (
+          <div className="text-muted text-center">
+            <i className="fa fa-2x fa-spinner fa-pulse mt-3"></i>
+          </div>
+        )
+        : (
+          <TagList
+            tagData={tagData}
+            totalTags={totalCount}
+            activePage={activePage}
+            onChangePage={setOffsetByPageNumber}
+            pagingLimit={PAGING_LIMIT}
+          />
+        )
+      }
     </div>
   );
 

+ 32 - 19
packages/app/src/components/TagCloudBox.tsx

@@ -1,38 +1,51 @@
-import React, { FC } from 'react';
-
+import React, { FC, memo } from 'react';
 import { TagCloud } from 'react-tagcloud';
-
-type Tag = {
-  _id: string,
-  name: string,
-  count: number,
-}
+import { ITagCountHasId } from '~/interfaces/tag';
 
 type Props = {
-  tags:Tag[],
+  tags:ITagCountHasId[],
   minSize?: number,
   maxSize?: number,
-}
+  maxTagTextLength?: number,
+  isDisableRandomColor?: boolean,
+};
+
+const defaultProps = {
+  isDisableRandomColor: true,
+};
+
+const MIN_FONT_SIZE = 10;
+const MAX_FONT_SIZE = 24;
+const MAX_TAG_TEXT_LENGTH = 8;
 
-const MIN_FONT_SIZE = 12;
-const MAX_FONT_SIZE = 36;
+const TagCloudBox: FC<Props> = memo((props:(Props & typeof defaultProps)) => {
+  const {
+    tags, minSize, maxSize, isDisableRandomColor,
+  } = props;
+  const maxTagTextLength: number = props.maxTagTextLength ?? MAX_TAG_TEXT_LENGTH;
 
-const TagCloudBox: FC<Props> = (props:Props) => {
   return (
     <>
       <TagCloud
-        minSize={props.minSize || MIN_FONT_SIZE}
-        maxSize={props.maxSize || MAX_FONT_SIZE}
-        tags={props.tags.map((tag) => {
-          return { value: tag.name, count: tag.count };
+        minSize={minSize ?? MIN_FONT_SIZE}
+        maxSize={maxSize ?? MAX_FONT_SIZE}
+        tags={tags.map((tag:ITagCountHasId) => {
+          return {
+            // text truncation
+            value: (tag.name).length > maxTagTextLength ? `${(tag.name).slice(0, maxTagTextLength)}...` : tag.name,
+            count: tag.count,
+          };
         })}
+        disableRandomColor={isDisableRandomColor}
         style={{ cursor: 'pointer' }}
-        className="simple-cloud"
+        className="simple-cloud text-secondary"
         onClick={(target) => { window.location.href = `/_search?q=tag:${encodeURIComponent(target.value)}` }}
       />
     </>
   );
 
-};
+});
+
+TagCloudBox.defaultProps = defaultProps;
 
 export default TagCloudBox;

+ 76 - 0
packages/app/src/components/TagList.tsx

@@ -0,0 +1,76 @@
+import React, {
+  FC, useCallback,
+} from 'react';
+
+import { useTranslation } from 'react-i18next';
+
+import { ITagCountHasId } from '~/interfaces/tag';
+
+import PaginationWrapper from './PaginationWrapper';
+
+type TagListProps = {
+  tagData: ITagCountHasId[],
+  totalTags: number,
+  activePage: number,
+  onChangePage?: (selectedPageNumber: number) => void,
+  pagingLimit: number,
+  isPaginationShown?: boolean,
+}
+
+const defaultProps = {
+  isPaginationShown: true,
+};
+
+const TagList: FC<TagListProps> = (props:(TagListProps & typeof defaultProps)) => {
+  const {
+    tagData, totalTags, activePage, onChangePage, pagingLimit, isPaginationShown,
+  } = props;
+  const isTagExist: boolean = tagData.length > 0;
+  const { t } = useTranslation('');
+
+  const generateTagList = useCallback((tagData) => {
+    return tagData.map((tag:ITagCountHasId, index:number) => {
+      const tagListClasses: string = index === 0 ? 'list-group-item d-flex' : 'list-group-item d-flex border-top-0';
+
+      return (
+        <a
+          key={tag._id}
+          href={`/_search?q=tag:${encodeURIComponent(tag.name)}`}
+          className={tagListClasses}
+        >
+          <div className="text-truncate">{tag.name}</div>
+          <div className="ml-4 my-auto py-1 px-2 list-tag-count badge badge-secondary text-white">{tag.count}</div>
+        </a>
+      );
+    });
+  }, []);
+
+  if (!isTagExist) {
+    return <h3>{ t('You have no tag, You can set tags on pages') }</h3>;
+  }
+
+  return (
+    <>
+      <ul className="list-group text-left mb-4">
+        {generateTagList(tagData)}
+      </ul>
+      {isPaginationShown
+      && (
+        <PaginationWrapper
+          activePage={activePage}
+          changePage={onChangePage}
+          totalItemsCount={totalTags}
+          pagingLimit={pagingLimit}
+          align="center"
+          size="md"
+        />
+      )
+      }
+    </>
+  );
+
+};
+
+TagList.defaultProps = defaultProps;
+
+export default TagList;

+ 59 - 0
packages/app/src/components/TagPage.tsx

@@ -0,0 +1,59 @@
+import React, { FC, useState, useCallback } from 'react';
+
+import { useTranslation } from 'react-i18next';
+
+import { ITagCountHasId } from '~/interfaces/tag';
+import { useSWRxTagsList } from '~/stores/tag';
+
+import TagCloudBox from './TagCloudBox';
+import TagList from './TagList';
+
+const PAGING_LIMIT = 10;
+
+const TagPage: FC = () => {
+  const [activePage, setActivePage] = useState<number>(1);
+  const [offset, setOffset] = useState<number>(0);
+
+  const { data: tagDataList, error } = useSWRxTagsList(PAGING_LIMIT, offset);
+  const tagData: ITagCountHasId[] = tagDataList?.data || [];
+  const totalCount: number = tagDataList?.totalCount || 0;
+  const isLoading = tagDataList === undefined && error == null;
+
+  const { t } = useTranslation('');
+
+  const setOffsetByPageNumber = useCallback((selectedPageNumber: number) => {
+    setActivePage(selectedPageNumber);
+    setOffset((selectedPageNumber - 1) * PAGING_LIMIT);
+  }, []);
+
+  // todo: adjust margin and redesign tags page
+  return (
+    <div className="grw-container-convertible mb-5 pb-5">
+      <h2 className="my-3">{`${t('Tags')}(${totalCount})`}</h2>
+      <div className="px-3 mb-5 text-center">
+        <TagCloudBox tags={tagData} minSize={20} />
+      </div>
+      { isLoading
+        ? (
+          <div className="text-muted text-center">
+            <i className="fa fa-2x fa-spinner fa-pulse mt-3"></i>
+          </div>
+        )
+        : (
+          <div data-testid="grw-tags-list">
+            <TagList
+              tagData={tagData}
+              totalTags={totalCount}
+              activePage={activePage}
+              onChangePage={setOffsetByPageNumber}
+              pagingLimit={PAGING_LIMIT}
+            />
+          </div>
+        )
+      }
+    </div>
+  );
+
+};
+
+export default TagPage;

+ 0 - 126
packages/app/src/components/TagsList.jsx

@@ -1,126 +0,0 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-
-import { withTranslation } from 'react-i18next';
-
-import PaginationWrapper from './PaginationWrapper';
-import TagCloudBox from './TagCloudBox';
-import { apiGet } from '../client/util/apiv1-client';
-import { toastError } from '../client/util/apiNotification';
-
-class TagsList extends React.Component {
-
-  constructor(props) {
-    super(props);
-
-    this.state = {
-      tagData: [],
-      activePage: 1,
-      totalTags: 0,
-      pagingLimit: 10,
-    };
-
-    this.handlePage = this.handlePage.bind(this);
-    this.getTagList = this.getTagList.bind(this);
-  }
-
-  async componentWillMount() {
-    await this.getTagList(1);
-  }
-
-  async componentDidUpdate() {
-    if (this.props.isOnReload) {
-      await this.getTagList(this.state.activePage);
-    }
-  }
-
-  async handlePage(selectedPage) {
-    await this.getTagList(selectedPage);
-  }
-
-  async getTagList(selectPageNumber) {
-    const limit = this.state.pagingLimit;
-    const offset = (selectPageNumber - 1) * limit;
-    let res;
-
-    try {
-      res = await apiGet('/tags.list', { limit, offset });
-    }
-    catch (error) {
-      toastError(error);
-    }
-
-    const totalTags = res.totalCount;
-    const tagData = res.data;
-    const activePage = selectPageNumber;
-
-    this.setState({
-      tagData,
-      activePage,
-      totalTags,
-    });
-  }
-
-  /**
-   * generate Elements of Tag
-   *
-   * @param {any} pages Array of pages Model Obj
-   *
-   */
-  generateTagList(tagData) {
-    return tagData.map((data) => {
-      return (
-        <a key={data.name} href={`/_search?q=tag:${data.name}`} className="list-group-item">
-          <i className="icon-tag mr-2"></i>{data.name}
-          <span className="ml-4 list-tag-count badge badge-secondary text-muted">{data.count}</span>
-        </a>
-      );
-    });
-  }
-
-  render() {
-    const { t } = this.props;
-    const messageForNoTag = this.state.tagData.length ? null : <h3>{ t('You have no tag, You can set tags on pages') }</h3>;
-
-    return (
-      <>
-        <header className="py-0">
-          <h1 className="title text-center mt-5 mb-3 font-weight-bold">{`${t('Tags')}(${this.state.totalTags})`}</h1>
-        </header>
-        <div className="row text-center">
-          <div className="col-12 mb-5 px-5">
-            <TagCloudBox tags={this.state.tagData} minSize={20} />
-          </div>
-          <div className="col-12 tag-list mb-4">
-            <ul className="list-group text-left">
-              {this.generateTagList(this.state.tagData)}
-            </ul>
-            {messageForNoTag}
-          </div>
-          <div className="col-12 tag-list-pagination">
-            <PaginationWrapper
-              activePage={this.state.activePage}
-              changePage={this.handlePage}
-              totalItemsCount={this.state.totalTags}
-              pagingLimit={this.state.pagingLimit}
-              align="center"
-              size="md"
-            />
-          </div>
-        </div>
-      </>
-    );
-  }
-
-}
-
-TagsList.propTypes = {
-  isOnReload: PropTypes.bool,
-  t: PropTypes.func.isRequired, // i18next
-};
-
-TagsList.defaultProps = {
-  isOnReload: false,
-};
-
-export default withTranslation()(TagsList);

+ 9 - 2
packages/app/src/components/TrashPageList.jsx

@@ -1,9 +1,12 @@
 import React, { useMemo } from 'react';
+
 import PropTypes from 'prop-types';
 import { withTranslation } from 'react-i18next';
-import PageListIcon from './Icons/PageListIcon';
+
 import CustomNavAndContents from './CustomNavigation/CustomNavAndContents';
 import { DescendantsPageListForCurrentPath } from './DescendantsPageList';
+import EmptyTrashButton from './EmptyTrashButton';
+import PageListIcon from './Icons/PageListIcon';
 
 
 const TrashPageList = (props) => {
@@ -20,9 +23,13 @@ const TrashPageList = (props) => {
     };
   }, [t]);
 
+  const emptyTrashButton = useMemo(() => {
+    return <EmptyTrashButton />;
+  }, [t]);
+
   return (
     <div data-testid="trash-page-list" className="mt-5 d-edit-none">
-      <CustomNavAndContents navTabMapping={navTabMapping} />
+      <CustomNavAndContents navTabMapping={navTabMapping} navRightElement={emptyTrashButton} />
     </div>
   );
 };

+ 48 - 0
packages/app/src/interfaces/activity.ts

@@ -0,0 +1,48 @@
+// Model
+const MODEL_PAGE = 'Page';
+const MODEL_COMMENT = 'Comment';
+
+// Action
+const ACTION_PAGE_LIKE = 'PAGE_LIKE';
+const ACTION_PAGE_BOOKMARK = 'PAGE_BOOKMARK';
+const ACTION_PAGE_CREATE = 'PAGE_CREATE';
+const ACTION_PAGE_UPDATE = 'PAGE_UPDATE';
+const ACTION_PAGE_RENAME = 'PAGE_RENAME';
+const ACTION_PAGE_DUPLICATE = 'PAGE_DUPLICATE';
+const ACTION_PAGE_DELETE = 'PAGE_DELETE';
+const ACTION_PAGE_DELETE_COMPLETELY = 'PAGE_DELETE_COMPLETELY';
+const ACTION_PAGE_REVERT = 'PAGE_REVERT';
+const ACTION_COMMENT_CREATE = 'COMMENT_CREATE';
+const ACTION_COMMENT_UPDATE = 'COMMENT_UPDATE';
+
+
+export const SUPPORTED_TARGET_MODEL_TYPE = {
+  MODEL_PAGE,
+} as const;
+
+export const SUPPORTED_EVENT_MODEL_TYPE = {
+  MODEL_COMMENT,
+} as const;
+
+export const SUPPORTED_ACTION_TYPE = {
+  ACTION_PAGE_LIKE,
+  ACTION_PAGE_BOOKMARK,
+  ACTION_PAGE_CREATE,
+  ACTION_PAGE_UPDATE,
+  ACTION_PAGE_RENAME,
+  ACTION_PAGE_DUPLICATE,
+  ACTION_PAGE_DELETE,
+  ACTION_PAGE_DELETE_COMPLETELY,
+  ACTION_PAGE_REVERT,
+  ACTION_COMMENT_CREATE,
+  ACTION_COMMENT_UPDATE,
+} as const;
+
+
+export const AllSupportedTargetModelType = Object.values(SUPPORTED_TARGET_MODEL_TYPE);
+export const AllSupportedEventModelType = Object.values(SUPPORTED_EVENT_MODEL_TYPE);
+export const AllSupportedActionType = Object.values(SUPPORTED_ACTION_TYPE);
+
+// type supportedTargetModelType = typeof SUPPORTED_TARGET_MODEL_NAMES[keyof typeof SUPPORTED_TARGET_MODEL_NAMES];
+// type supportedEventModelType = typeof SUPPORTED_EVENT_MODEL_NAMES[keyof typeof SUPPORTED_EVENT_MODEL_NAMES];
+// type supportedActionType = typeof SUPPORTED_ACTION_NAMES[keyof typeof SUPPORTED_ACTION_NAMES];

+ 7 - 0
packages/app/src/interfaces/errors/v5-conversion-error.ts

@@ -0,0 +1,7 @@
+export const V5ConversionErrCode = {
+  GRANT_INVALID: 'GrantInvalid',
+  PAGE_NOT_FOUND: 'PageNotFound',
+  DUPLICATE_PAGES_FOUND: 'DuplicatePagesFound',
+} as const;
+
+export type V5ConversionErrCode = typeof V5ConversionErrCode[keyof typeof V5ConversionErrCode];

+ 12 - 0
packages/app/src/interfaces/tag.ts

@@ -1,9 +1,21 @@
+import { HasObjectId } from './has-object-id';
+
 export type ITag = {
   name: string,
   createdAt: Date;
 }
 
+export type ITagCount = Omit<ITag, 'createdAt'> & {count: number}
+
+export type ITagCountHasId = ITagCount & HasObjectId
+
 export type ITagsSearchApiv1Result = {
   ok: boolean,
   tags: string[]
 }
+
+export type ITagsListApiv1Result = {
+  ok: boolean,
+  data: ITagCountHasId[],
+  totalCount: number,
+}

+ 6 - 0
packages/app/src/interfaces/websocket.ts

@@ -7,6 +7,10 @@ export const SocketEventName = {
   PMMigrating: 'PublicMigrationMigrating',
   PMErrorCount: 'PublicMigrationErrorCount',
   PMEnded: 'PublicMigrationEnded',
+
+  // Page migration
+  PageMigrationSuccess: 'PageMigrationSuccess',
+  PageMigrationError: 'PageMigrationError',
 } as const;
 export type SocketEventName = typeof SocketEventName[keyof typeof SocketEventName];
 
@@ -22,3 +26,5 @@ export type PMStartedData = { total: number };
 export type PMMigratingData = { count: number };
 export type PMErrorCountData = { skip: number };
 export type PMEndedData = { isSucceeded: boolean };
+
+export type PageMigrationErrorData = { paths: string[] }

+ 25 - 0
packages/app/src/migrations/20220411114257-set-sparse-option-to-slack-member-id.js

@@ -0,0 +1,25 @@
+import { getModelSafely, getMongoUri, mongoOptions } from '@growi/core';
+import mongoose from 'mongoose';
+
+import loggerFactory from '~/utils/logger';
+
+const logger = loggerFactory('growi:migrate:set-sparse-option-to-slack-member-id');
+
+/**
+ * set sparse option to slackMemberId
+ */
+module.exports = {
+  async up(db) {
+    logger.info('Apply migration');
+    mongoose.connect(getMongoUri(), mongoOptions);
+
+    const User = getModelSafely('User') || require('~/server/models/user')();
+    await User.syncIndexes();
+
+    logger.info('Migration has successfully applied');
+  },
+
+  down(db) {
+    // do not rollback
+  },
+};

+ 2 - 2
packages/app/src/server/crowi/index.js

@@ -130,7 +130,7 @@ Crowi.prototype.init = async function() {
     this.setUpAcl(),
     this.setUpCustomize(),
     this.setUpRestQiitaAPI(),
-    this.setupUserGroup(),
+    this.setupUserGroupService(),
     this.setupExport(),
     this.setupImport(),
     this.setupPageService(),
@@ -644,7 +644,7 @@ Crowi.prototype.setUpRestQiitaAPI = async function() {
   }
 };
 
-Crowi.prototype.setupUserGroup = async function() {
+Crowi.prototype.setupUserGroupService = async function() {
   const UserGroupService = require('../service/user-group');
   if (this.userGroupService == null) {
     this.userGroupService = new UserGroupService(this);

+ 18 - 6
packages/app/src/server/interfaces/search.ts

@@ -14,22 +14,34 @@ export type QueryTerms = {
   not_tag: string[],
 }
 
-export type ParsedQuery = { queryString: string, terms?: QueryTerms, delegatorName?: string }
+export type ParsedQuery = { queryString: string, terms: QueryTerms, delegatorName?: string }
 
 export interface SearchQueryParser {
-  parseSearchQuery(queryString: string): Promise<ParsedQuery>
+  parseSearchQuery(queryString: string, nqName: string | null): Promise<ParsedQuery>
 }
 
-export interface SearchResolver{
+export interface SearchResolver {
   resolve(parsedQuery: ParsedQuery): Promise<[SearchDelegator, SearchableData | null]>
 }
 
-export interface SearchDelegator<T = unknown> {
+export interface SearchDelegator<T = unknown, KEY extends AllTermsKey = AllTermsKey, QTERMS = unknown> {
   name?: SearchDelegatorName
   search(data: SearchableData | null, user, userGroups, option): Promise<ISearchResult<T>>
+  isTermsNormalized(terms: Partial<QueryTerms>): terms is QTERMS,
+  validateTerms(terms: QueryTerms): UnavailableTermsKey<KEY>[],
 }
 
-export type SearchableData = {
+export type SearchableData<T = Partial<QueryTerms>> = {
   queryString: string
-  terms: QueryTerms
+  terms: T
 }
+
+// Terms Key types
+export type AllTermsKey = keyof QueryTerms;
+export type UnavailableTermsKey<K extends AllTermsKey> = Exclude<AllTermsKey, K>;
+export type ESTermsKey = 'match' | 'not_match' | 'phrase' | 'not_phrase' | 'prefix' | 'not_prefix' | 'tag' | 'not_tag';
+export type MongoTermsKey = 'match' | 'not_match' | 'prefix' | 'not_prefix';
+
+// Query Terms types
+export type ESQueryTerms = Pick<QueryTerms, ESTermsKey>;
+export type MongoQueryTerms = Pick<QueryTerms, MongoTermsKey>;

+ 6 - 8
packages/app/src/server/models/activity.ts

@@ -1,19 +1,17 @@
+import { getOrCreateModel, getModelSafely } from '@growi/core';
 import {
   Types, Document, Model, Schema,
 } from 'mongoose';
 
-import { getOrCreateModel, getModelSafely } from '@growi/core';
-import loggerFactory from '../../utils/logger';
-
+import { AllSupportedTargetModelType, AllSupportedEventModelType, AllSupportedActionType } from '~/interfaces/activity';
 
-import ActivityDefine from '../util/activityDefine';
+import loggerFactory from '../../utils/logger';
 import activityEvent from '../events/activity';
 
 import Subscription from './subscription';
 
 const logger = loggerFactory('growi:models:activity');
 
-
 export interface ActivityDocument extends Document {
   _id: Types.ObjectId
   user: Types.ObjectId | any
@@ -40,7 +38,7 @@ const activitySchema = new Schema<ActivityDocument, ActivityModel>({
   targetModel: {
     type: String,
     require: true,
-    enum: ActivityDefine.getSupportTargetModelNames(),
+    enum: AllSupportedTargetModelType,
   },
   target: {
     type: Schema.Types.ObjectId,
@@ -50,7 +48,7 @@ const activitySchema = new Schema<ActivityDocument, ActivityModel>({
   action: {
     type: String,
     require: true,
-    enum: ActivityDefine.getSupportActionNames(),
+    enum: AllSupportedActionType,
   },
   event: {
     type: Schema.Types.ObjectId,
@@ -58,7 +56,7 @@ const activitySchema = new Schema<ActivityDocument, ActivityModel>({
   },
   eventModel: {
     type: String,
-    enum: ActivityDefine.getSupportEventModelNames(),
+    enum: AllSupportedEventModelType,
   },
 }, {
   timestamps: true,

+ 12 - 0
packages/app/src/server/models/errors.ts

@@ -0,0 +1,12 @@
+import ExtensibleCustomError from 'extensible-custom-error';
+
+export class PathAlreadyExistsError extends ExtensibleCustomError {
+
+  targetPath: string;
+
+  constructor(message: string, targetPath: string) {
+    super(message);
+    this.targetPath = targetPath;
+  }
+
+}

+ 6 - 5
packages/app/src/server/models/in-app-notification.ts

@@ -1,13 +1,14 @@
+import { getOrCreateModel } from '@growi/core';
 import {
   Types, Document, Schema, Model,
 } from 'mongoose';
 import mongoosePaginate from 'mongoose-paginate-v2';
 
-import { getOrCreateModel } from '@growi/core';
+import { AllSupportedTargetModelType, AllSupportedActionType } from '~/interfaces/activity';
+import { InAppNotificationStatuses } from '~/interfaces/in-app-notification';
+
 import { ActivityDocument } from './activity';
-import ActivityDefine from '../util/activityDefine';
 
-import { InAppNotificationStatuses } from '~/interfaces/in-app-notification';
 
 const { STATUS_UNREAD, STATUS_UNOPENED, STATUS_OPENED } = InAppNotificationStatuses;
 
@@ -45,7 +46,7 @@ const inAppNotificationSchema = new Schema<InAppNotificationDocument, InAppNotif
   targetModel: {
     type: String,
     require: true,
-    enum: ActivityDefine.getSupportTargetModelNames(),
+    enum: AllSupportedTargetModelType,
   },
   target: {
     type: Schema.Types.ObjectId,
@@ -55,7 +56,7 @@ const inAppNotificationSchema = new Schema<InAppNotificationDocument, InAppNotif
   action: {
     type: String,
     require: true,
-    enum: ActivityDefine.getSupportActionNames(),
+    enum: AllSupportedActionType,
   },
   activities: [
     {

+ 0 - 2
packages/app/src/server/models/obsolete-page.js

@@ -485,7 +485,6 @@ export const getPageSchema = (crowi) => {
     validateCrowi();
 
     const User = crowi.model('User');
-
     const opt = Object.assign({ sort: 'updatedAt', desc: -1 }, option);
     const sortOpt = {};
     sortOpt[opt.sort] = opt.desc;
@@ -505,7 +504,6 @@ export const getPageSchema = (crowi) => {
     builder.addConditionToPagenate(opt.offset, opt.limit, sortOpt);
     builder.populateDataToList(User.USER_FIELDS_EXCEPT_CONFIDENTIAL);
     const pages = await builder.query.lean().clone().exec('find');
-
     const result = {
       pages, totalCount, offset: opt.offset, limit: opt.limit,
     };

+ 115 - 19
packages/app/src/server/models/page.ts

@@ -1,27 +1,29 @@
 /* eslint-disable @typescript-eslint/no-explicit-any */
 
+import nodePath from 'path';
+
+import { getOrCreateModel, pagePathUtils, pathUtils } from '@growi/core';
+import escapeStringRegexp from 'escape-string-regexp';
 import mongoose, {
   Schema, Model, Document, AnyObject,
 } from 'mongoose';
 import mongoosePaginate from 'mongoose-paginate-v2';
 import uniqueValidator from 'mongoose-unique-validator';
-import escapeStringRegexp from 'escape-string-regexp';
-import nodePath from 'path';
-import { getOrCreateModel, pagePathUtils, pathUtils } from '@growi/core';
 
+import { IUserHasId } from '~/interfaces/user';
+import { ObjectIdLike } from '~/server/interfaces/mongoose-utils';
+
+import { IPage, IPageHasId } from '../../interfaces/page';
 import loggerFactory from '../../utils/logger';
 import Crowi from '../crowi';
-import { IPage } from '../../interfaces/page';
+
 import { getPageSchema, extractToAncestorsPaths, populateDataToShowRevision } from './obsolete-page';
-import { ObjectIdLike } from '~/server/interfaces/mongoose-utils';
 import { PageRedirectModel } from './page-redirect';
 
-const { addTrailingSlash } = pathUtils;
+const { addTrailingSlash, normalizePath } = pathUtils;
 const { isTopPage, collectAncestorPaths } = pagePathUtils;
 
 const logger = loggerFactory('growi:models:page');
-
-
 /*
  * define schema
  */
@@ -34,7 +36,7 @@ const PAGE_GRANT_ERROR = 1;
 const STATUS_PUBLISHED = 'published';
 const STATUS_DELETED = 'deleted';
 
-export interface PageDocument extends IPage, Document {}
+export interface PageDocument extends IPage, Document { }
 
 
 type TargetAndAncestorsResult = {
@@ -42,17 +44,24 @@ type TargetAndAncestorsResult = {
   rootPage: PageDocument
 }
 
+type PaginatedPages = {
+  pages: PageDocument[],
+  totalCount: number,
+  limit: number,
+  offset: number
+}
+
 export type CreateMethod = (path: string, body: string, user, options) => Promise<PageDocument & { _id: any }>
 export interface PageModel extends Model<PageDocument> {
   [x: string]: any; // for obsolete methods
   createEmptyPagesByPaths(paths: string[], user: any | null, onlyMigratedAsExistingPages?: boolean, andFilter?): Promise<void>
   getParentAndFillAncestors(path: string, user): Promise<PageDocument & { _id: any }>
   findByIdsAndViewer(pageIds: ObjectIdLike[], user, userGroups?, includeEmpty?: boolean): Promise<PageDocument[]>
-  findByPathAndViewer(path: string | null, user, userGroups?, useFindOne?: boolean, includeEmpty?: boolean): Promise<PageDocument[]>
+  findByPathAndViewer(path: string | null, user, userGroups?, useFindOne?: boolean, includeEmpty?: boolean): Promise<PageDocument | PageDocument[] | null>
   findTargetAndAncestorsByPathOrId(pathOrId: string): Promise<TargetAndAncestorsResult>
   findChildrenByParentPathOrIdAndViewer(parentPathOrId: string, user, userGroups?): Promise<PageDocument[]>
   findAncestorsChildrenByPathAndViewer(path: string, user, userGroups?): Promise<Record<string, PageDocument[]>>
-
+  findRecentUpdatedPages(path: string, user, option, includeEmpty?: boolean): Promise<PaginatedPages>
   generateGrantCondition(
     user, userGroups, showAnyoneKnowsLink?: boolean, showPagesRestrictedByOwner?: boolean, showPagesRestrictedByGroup?: boolean,
   ): { $or: any[] }
@@ -124,7 +133,7 @@ const generateChildrenRegExp = (path: string): RegExp => {
   return new RegExp(`^${path}(\\/[^/]+)\\/?$`);
 };
 
-class PageQueryBuilder {
+export class PageQueryBuilder {
 
   query: any;
 
@@ -245,7 +254,9 @@ class PageQueryBuilder {
    * *option*
    *   Left for backward compatibility
    */
-  addConditionToListByStartWith(path, option?) {
+  addConditionToListByStartWith(str: string): PageQueryBuilder {
+    const path = normalizePath(str);
+
     // No request is set for the top page
     if (isTopPage(path)) {
       return this;
@@ -259,6 +270,50 @@ class PageQueryBuilder {
     return this;
   }
 
+  addConditionToListByNotStartWith(str: string): PageQueryBuilder {
+    const path = normalizePath(str);
+
+    // No request is set for the top page
+    if (isTopPage(path)) {
+      return this;
+    }
+
+    const startsPattern = escapeStringRegexp(str);
+
+    this.query = this.query
+      .and({ path: new RegExp(`^(?!${startsPattern}).*$`) });
+
+    return this;
+  }
+
+  addConditionToListByMatch(str: string): PageQueryBuilder {
+    // No request is set for "/"
+    if (str === '/') {
+      return this;
+    }
+
+    const match = escapeStringRegexp(str);
+
+    this.query = this.query
+      .and({ path: new RegExp(`^(?=.*${match}).*$`) });
+
+    return this;
+  }
+
+  addConditionToListByNotMatch(str: string): PageQueryBuilder {
+    // No request is set for "/"
+    if (str === '/') {
+      return this;
+    }
+
+    const match = escapeStringRegexp(str);
+
+    this.query = this.query
+      .and({ path: new RegExp(`^(?!.*${match}).*$`) });
+
+    return this;
+  }
+
   async addConditionForParentNormalization(user) {
     // determine UserGroup condition
     let userGroups;
@@ -440,6 +495,7 @@ schema.statics.createEmptyPagesByPaths = async function(paths: string[], user: a
     aggregationPipeline.push({
       $match: {
         $or: [
+          { grant: GRANT_PUBLIC },
           { parent: { $ne: null } },
           { path: '/' },
         ],
@@ -603,8 +659,11 @@ schema.statics.getParentAndFillAncestors = async function(path: string, user): P
   });
   await this.bulkWrite(operations);
 
-  const createdParent = ancestorsMap.get(parentPath);
-
+  const parentId = ancestorsMap.get(parentPath)._id; // get parent page id to fetch updated parent parent
+  const createdParent = await this.findOne({ _id: parentId });
+  if (createdParent == null) {
+    throw Error('updated parent not Found');
+  }
   return createdParent;
 };
 
@@ -649,6 +708,39 @@ schema.statics.findByPathAndViewer = async function(
   return queryBuilder.query.exec();
 };
 
+schema.statics.findRecentUpdatedPages = async function(
+    path: string, user, options, includeEmpty = false,
+): Promise<PaginatedPages> {
+
+  const sortOpt = {};
+  sortOpt[options.sort] = options.desc;
+
+  const Page = this;
+  const User = mongoose.model('User') as any;
+
+  if (path == null) {
+    throw new Error('path is required.');
+  }
+
+  const baseQuery = this.find({});
+  const queryBuilder = new PageQueryBuilder(baseQuery, includeEmpty);
+  if (!options.includeTrashed) {
+    queryBuilder.addConditionToExcludeTrashed();
+  }
+
+  queryBuilder.addConditionToListWithDescendants(path, options);
+  queryBuilder.populateDataToList(User.USER_FIELDS_EXCEPT_CONFIDENTIAL);
+  await addViewerCondition(queryBuilder, user);
+  const pages = await Page.paginate(queryBuilder.query.clone(), {
+    lean: true, sort: sortOpt, offset: options.offset, limit: options.limit,
+  });
+  const results = {
+    pages: pages.docs, totalCount: pages.totalDocs, offset: options.offset, limit: options.limit,
+  };
+
+  return results;
+};
+
 
 /*
  * Find all ancestor pages by path. When duplicate pages found, it uses the oldest page as a result
@@ -729,7 +821,7 @@ schema.statics.findAncestorsChildrenByPathAndViewer = async function(path: strin
     .lean()
     .exec();
   // mark target
-  const pages = _pages.map((page: PageDocument & {isTarget?: boolean}) => {
+  const pages = _pages.map((page: PageDocument & { isTarget?: boolean }) => {
     if (page.path === path) {
       page.isTarget = true;
     }
@@ -777,7 +869,7 @@ schema.statics.incrementDescendantCountOfPageIds = async function(pageIds: Objec
 /**
  * recount descendantCount of a page with the provided id and return it
  */
-schema.statics.recountDescendantCount = async function(id: ObjectIdLike):Promise<number> {
+schema.statics.recountDescendantCount = async function(id: ObjectIdLike): Promise<number> {
   const res = await this.aggregate(
     [
       {
@@ -1086,11 +1178,15 @@ export default (crowi: Crowi): any => {
     return savedPage;
   };
 
-  const shouldUseUpdatePageV4 = (grant:number, isV5Compatible:boolean, isOnTree:boolean): boolean => {
+  const shouldUseUpdatePageV4 = (grant: number, isV5Compatible: boolean, isOnTree: boolean): boolean => {
     const isRestricted = grant === GRANT_RESTRICTED;
     return !isRestricted && (!isV5Compatible || !isOnTree);
   };
 
+  schema.statics.emitPageEventUpdate = (page: IPageHasId, user: IUserHasId): void => {
+    pageEvent.emit('update', page, user);
+  };
+
   schema.statics.updatePage = async function(pageData, body, previousBody, user, options = {}) {
     if (crowi.configManager == null || crowi.pageGrantService == null || crowi.pageService == null) {
       throw Error('Crowi is not set up');
@@ -1160,7 +1256,7 @@ export default (crowi: Crowi): any => {
       savedPage = await this.syncRevisionToHackmd(savedPage);
     }
 
-    pageEvent.emit('update', savedPage, user);
+    this.emitPageEventUpdate(savedPage, user);
 
     // Update ex children's parent
     if (!wasOnTree && shouldBeOnTree) {

+ 3 - 4
packages/app/src/server/models/subscription.ts

@@ -1,12 +1,11 @@
+import { getOrCreateModel } from '@growi/core';
 import {
   Types, Document, Model, Schema,
 } from 'mongoose';
 
-import { getOrCreateModel } from '@growi/core';
-
+import { AllSupportedTargetModelType } from '~/interfaces/activity';
 import { SubscriptionStatusType, AllSubscriptionStatusType } from '~/interfaces/subscription';
 
-import ActivityDefine from '../util/activityDefine';
 
 export interface ISubscription {
   user: Types.ObjectId
@@ -39,7 +38,7 @@ const subscriptionSchema = new Schema<SubscriptionDocument, SubscriptionModel>({
   targetModel: {
     type: String,
     require: true,
-    enum: ActivityDefine.getSupportTargetModelNames(),
+    enum: AllSupportedTargetModelType,
   },
   target: {
     type: Schema.Types.ObjectId,

+ 13 - 4
packages/app/src/server/models/user.js

@@ -1,14 +1,15 @@
 /* eslint-disable no-use-before-define */
 import loggerFactory from '~/utils/logger';
 
+const crypto = require('crypto');
+
 const debug = require('debug')('growi:models:user');
+const md5 = require('md5');
 const mongoose = require('mongoose');
 const mongoosePaginate = require('mongoose-paginate-v2');
 const uniqueValidator = require('mongoose-unique-validator');
-const md5 = require('md5');
 
 const ObjectId = mongoose.Schema.Types.ObjectId;
-const crypto = require('crypto');
 
 const { listLocaleIds, migrateDeprecatedLocaleId } = require('~/utils/locale-utils');
 
@@ -393,8 +394,16 @@ module.exports = function(crowi) {
       .sort(sort);
   };
 
-  userSchema.statics.findAdmins = async function() {
-    return this.find({ admin: true });
+  userSchema.statics.findAdmins = async function(option) {
+    const sort = option?.sort ?? { createdAt: -1 };
+
+    let status = option?.status ?? [STATUS_ACTIVE];
+    if (!Array.isArray(status)) {
+      status = [status];
+    }
+
+    return this.find({ admin: true, status: { $in: status } })
+      .sort(sort);
   };
 
   userSchema.statics.findUserByUsername = function(username) {

+ 28 - 0
packages/app/src/server/models/vo/search-error.ts

@@ -0,0 +1,28 @@
+import ExtensibleCustomError from 'extensible-custom-error';
+
+import { AllTermsKey } from '~/server/interfaces/search';
+
+export class SearchError extends ExtensibleCustomError {
+
+  readonly id = 'SearchError'
+
+  unavailableTermsKeys!: AllTermsKey[]
+
+  constructor(message = '', unavailableTermsKeys: AllTermsKey[]) {
+    super(message);
+    this.unavailableTermsKeys = unavailableTermsKeys;
+  }
+
+}
+
+export const isSearchError = (err: any): err is SearchError => {
+  if (err == null || typeof err !== 'object') {
+    return false;
+  }
+
+  if (err instanceof SearchError) {
+    return true;
+  }
+
+  return err?.id === 'SearchError';
+};

+ 28 - 0
packages/app/src/server/models/vo/v5-conversion-error.ts

@@ -0,0 +1,28 @@
+import ExtensibleCustomError from 'extensible-custom-error';
+
+import { V5ConversionErrCode } from '~/interfaces/errors/v5-conversion-error';
+
+export class V5ConversionError extends ExtensibleCustomError {
+
+  readonly id = 'V5ConversionError'
+
+  code!: V5ConversionErrCode
+
+  constructor(message: string, code: V5ConversionErrCode) {
+    super(message);
+    this.code = code;
+  }
+
+}
+
+export const isV5ConversionError = (err: any): err is V5ConversionError => {
+  if (err == null || typeof err !== 'object') {
+    return false;
+  }
+
+  if (err instanceof V5ConversionError) {
+    return true;
+  }
+
+  return err?.id === 'V5ConversionError';
+};

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

@@ -8,8 +8,8 @@ const express = require('express');
 
 const router = express.Router();
 const { query } = require('express-validator');
-const { serializeUserSecurely } = require('../../models/serializers/user-serializer');
 
+const { serializeUserSecurely } = require('../../models/serializers/user-serializer');
 const ErrorV3 = require('../../models/vo/error-apiv3');
 
 /**
@@ -28,7 +28,8 @@ module.exports = (crowi) => {
   const validator = {
     retrieveAttachments: [
       query('pageId').isMongoId().withMessage('pageId is required'),
-      query('limit').if(value => value != null).isInt({ max: 100 }).withMessage('You should set less than 100 or not to set limit.'),
+      query('page').optional().isInt().withMessage('page must be a number'),
+      query('limit').optional().isInt({ max: 100 }).withMessage('You should set less than 100 or not to set limit.'),
     ],
   };
   /**
@@ -52,7 +53,7 @@ module.exports = (crowi) => {
   router.get('/list', accessTokenParser, loginRequired, validator.retrieveAttachments, apiV3FormValidator, async(req, res) => {
 
     const limit = req.query.limit || await crowi.configManager.getConfig('crowi', 'customize:showPageLimitationS') || 10;
-    const page = req.query.page;
+    const page = req.query.page || 1;
     const offset = (page - 1) * limit;
 
     try {

+ 10 - 7
packages/app/src/server/routes/apiv3/forgot-password.js

@@ -1,19 +1,20 @@
-import { format } from 'date-fns';
+import { format, subSeconds } from 'date-fns';
 import rateLimit from 'express-rate-limit';
 
+import injectResetOrderByTokenMiddleware from '~/server/middlewares/inject-reset-order-by-token-middleware';
 import PasswordResetOrder from '~/server/models/password-reset-order';
 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';
 import { apiV3FormValidator } from '../../middlewares/apiv3-form-validator';
+import httpErrorHandler from '../../middlewares/http-error-handler';
+import { checkForgotPasswordEnabledMiddlewareFactory } from '../forgot-password';
 
 const logger = loggerFactory('growi:routes:apiv3:forgotPassword'); // eslint-disable-line no-unused-vars
 
 const express = require('express');
 const { body } = require('express-validator');
+
 const { serializeUserSecurely } = require('../../models/serializers/user-serializer');
 
 const router = express.Router();
@@ -77,8 +78,10 @@ module.exports = (crowi) => {
       const passwordResetOrderData = await PasswordResetOrder.createPasswordResetOrder(email);
       const url = new URL(`/forgot-password/${passwordResetOrderData.token}`, appUrl);
       const oneTimeUrl = url.href;
-      const expiredAt = format(passwordResetOrderData.expiredAt, 'yyyy/MM/dd HH:mm');
-      await sendPasswordResetEmail('passwordReset', i18n, email, oneTimeUrl, expiredAt);
+      const grwTzoffsetSec = crowi.appService.getTzoffset() * 60;
+      const expiredAt = subSeconds(passwordResetOrderData.expiredAt, grwTzoffsetSec);
+      const formattedExpiredAt = format(expiredAt, 'yyyy/MM/dd HH:mm');
+      await sendPasswordResetEmail('passwordReset', i18n, email, oneTimeUrl, formattedExpiredAt);
       return res.apiv3();
     }
     catch (err) {
@@ -93,7 +96,7 @@ module.exports = (crowi) => {
     const { passwordResetOrder } = req;
     const { email } = passwordResetOrder;
     const grobalLang = configManager.getConfig('crowi', 'app:globalLang');
-    const i18n = req.language || grobalLang;
+    const i18n = grobalLang || req.language;
     const { newPassword } = req.body;
 
     const user = await User.findOne({ email });

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

@@ -1,8 +1,9 @@
 import loggerFactory from '~/utils/logger';
-import * as userActivation from './user-activation';
+
 import injectUserRegistrationOrderByTokenMiddleware from '../../middlewares/inject-user-registration-order-by-token-middleware';
 
 import pageListing from './page-listing';
+import * as userActivation from './user-activation';
 
 const logger = loggerFactory('growi:routes:apiv3'); // eslint-disable-line no-unused-vars
 
@@ -10,6 +11,7 @@ const express = require('express');
 
 const router = express.Router();
 const routerForAdmin = express.Router();
+const routerForAuth = express.Router();
 
 module.exports = (crowi) => {
 
@@ -34,6 +36,9 @@ module.exports = (crowi) => {
   routerForAdmin.use('/slack-integration-settings', require('./slack-integration-settings')(crowi));
   routerForAdmin.use('/slack-integration-legacy-settings', require('./slack-integration-legacy-settings')(crowi));
 
+  // auth
+  routerForAuth.use('/logout', require('./logout')(crowi));
+
 
   router.use('/in-app-notification', require('./in-app-notification')(crowi));
 
@@ -75,5 +80,5 @@ module.exports = (crowi) => {
   router.use('/user-ui-settings', require('./user-ui-settings')(crowi));
 
 
-  return [router, routerForAdmin];
+  return [router, routerForAdmin, routerForAuth];
 };

+ 16 - 0
packages/app/src/server/routes/apiv3/logout.js

@@ -0,0 +1,16 @@
+import loggerFactory from '~/utils/logger';
+
+const logger = loggerFactory('growi:routes:apiv3:logout'); // eslint-disable-line no-unused-vars
+
+const express = require('express');
+
+const router = express.Router();
+
+module.exports = (crowi) => {
+  router.post('/', async(req, res) => {
+    req.session.destroy();
+    return res.send();
+  });
+
+  return router;
+};

+ 5 - 1
packages/app/src/server/routes/apiv3/page.js

@@ -1,8 +1,8 @@
 import { pagePathUtils } from '@growi/core';
-import loggerFactory from '~/utils/logger';
 
 import { AllSubscriptionStatusType } from '~/interfaces/subscription';
 import Subscription from '~/server/models/subscription';
+import loggerFactory from '~/utils/logger';
 
 import { apiV3FormValidator } from '../../middlewares/apiv3-form-validator';
 
@@ -474,6 +474,10 @@ module.exports = (crowi) => {
 
     try {
       const fromPage = await Page.findByPath(fromPath);
+      if (fromPage == null) {
+        return res.apiv3Err(new ErrorV3('fromPage is Null'), 400);
+      }
+
       const fromPageDescendants = await Page.findManageableListWithDescendants(fromPage, req.user);
 
       const toPathDescendantsArray = fromPageDescendants.map((subordinatedPage) => {

+ 85 - 17
packages/app/src/server/routes/apiv3/pages.js

@@ -1,16 +1,17 @@
+import { SUPPORTED_TARGET_MODEL_TYPE, SUPPORTED_ACTION_TYPE } from '~/interfaces/activity';
+import { subscribeRuleNames } from '~/interfaces/in-app-notification';
 import loggerFactory from '~/utils/logger';
 
-import { subscribeRuleNames } from '~/interfaces/in-app-notification';
 
 import { apiV3FormValidator } from '../../middlewares/apiv3-form-validator';
+import { isV5ConversionError } from '../../models/vo/v5-conversion-error';
 
 const logger = loggerFactory('growi:routes:apiv3:pages'); // eslint-disable-line no-unused-vars
-const express = require('express');
 const { pathUtils, pagePathUtils } = require('@growi/core');
-const mongoose = require('mongoose');
-
+const express = require('express');
 const { body } = require('express-validator');
 const { query } = require('express-validator');
+const mongoose = require('mongoose');
 
 const ErrorV3 = require('../../models/vo/error-apiv3');
 
@@ -196,8 +197,10 @@ module.exports = (crowi) => {
         .withMessage('The body property "isRecursively" must be "true" or true. (Omit param for false)'),
     ],
     legacyPagesMigration: [
-      body('pageIds').isArray().withMessage('pageIds is required'),
+      body('convertPath').optional().isString().withMessage('convertPath must be a string'),
+      body('pageIds').optional().isArray().withMessage('pageIds must be an array'),
       body('isRecursively')
+        .optional()
         .custom(v => v === 'true' || v === true || v == null)
         .withMessage('The body property "isRecursively" must be "true" or true. (Omit param for false)'),
     ],
@@ -341,6 +344,20 @@ module.exports = (crowi) => {
       }
     }
 
+    // create activity
+    try {
+      const parameters = {
+        user: req.user._id,
+        targetModel: SUPPORTED_TARGET_MODEL_TYPE.MODEL_PAGE,
+        target: createdPage,
+        action: SUPPORTED_ACTION_TYPE.ACTION_PAGE_CREATE,
+      };
+      await crowi.activityService.createByParameters(parameters);
+    }
+    catch (err) {
+      logger.error('Failed to create activity', err);
+    }
+
     // create subscription
     try {
       await crowi.inAppNotificationService.createSubscription(req.user.id, createdPage._id, subscribeRuleNames.PAGE_CREATE);
@@ -366,18 +383,17 @@ module.exports = (crowi) => {
   router.get('/recent', accessTokenParser, loginRequired, async(req, res) => {
     const limit = 20;
     const offset = parseInt(req.query.offset) || 0;
-
+    const skip = offset > 0 ? (offset - 1) * limit : offset;
     const queryOptions = {
-      offset,
+      offset: skip,
       limit,
       includeTrashed: false,
       isRegExpEscapedFromPath: true,
       sort: 'updatedAt',
       desc: -1,
     };
-
     try {
-      const result = await Page.findListWithDescendants('/', req.user, queryOptions);
+      const result = await Page.findRecentUpdatedPages('/', req.user, queryOptions);
       if (result.pages.length > limit) {
         result.pages.pop();
       }
@@ -546,15 +562,38 @@ module.exports = (crowi) => {
    *          200:
    *            description: Succeeded to remove all trash pages
    */
-  router.delete('/empty-trash', accessTokenParser, loginRequired, adminRequired, csrf, apiV3FormValidator, async(req, res) => {
+  router.delete('/empty-trash', accessTokenParser, loginRequired, csrf, apiV3FormValidator, async(req, res) => {
     const options = {};
 
-    try {
-      const pages = await crowi.pageService.emptyTrashPage(req.user, options);
-      return res.apiv3({ pages });
+    const pagesInTrash = await Page.findChildrenByParentPathOrIdAndViewer('/trash', req.user);
+
+    const deletablePages = crowi.pageService.filterPagesByCanDeleteCompletely(pagesInTrash, req.user, true);
+
+    if (deletablePages.length === 0) {
+      const msg = 'No pages can be deleted.';
+      return res.apiv3Err(new ErrorV3(msg), 500);
     }
-    catch (err) {
-      return res.apiv3Err(new ErrorV3('Failed to update page.', 'unknown'), 500);
+
+    // when some pages are not deletable
+    if (deletablePages.length < pagesInTrash.length) {
+      try {
+        const options = { isCompletely: true, isRecursively: true };
+        await crowi.pageService.deleteMultiplePages(deletablePages, req.user, options);
+        return res.apiv3({ deletablePages });
+      }
+      catch (err) {
+        return res.apiv3Err(new ErrorV3('Failed to update page.', 'unknown'), 500);
+      }
+    }
+    // when all pages are deletable
+    else {
+      try {
+        const pages = await crowi.pageService.emptyTrashPage(req.user, options);
+        return res.apiv3({ pages });
+      }
+      catch (err) {
+        return res.apiv3Err(new ErrorV3('Failed to update page.', 'unknown'), 500);
+      }
     }
   });
 
@@ -786,15 +825,44 @@ module.exports = (crowi) => {
 
   // eslint-disable-next-line max-len
   router.post('/legacy-pages-migration', accessTokenParser, loginRequired, csrf, validator.legacyPagesMigration, apiV3FormValidator, async(req, res) => {
-    const { pageIds: _pageIds, isRecursively } = req.body;
+    const { convertPath, pageIds: _pageIds, isRecursively } = req.body;
+
+    // Convert by path
+    if (convertPath != null) {
+      const normalizedPath = pathUtils.normalizePath(convertPath);
+      try {
+        await crowi.pageService.normalizeParentByPath(normalizedPath, req.user);
+      }
+      catch (err) {
+        logger.error(err);
+
+        if (isV5ConversionError(err)) {
+          return res.apiv3Err(new ErrorV3(err.message, err.code), 400);
+        }
+
+        return res.apiv3Err(new ErrorV3('Failed to convert pages.'), 400);
+      }
+
+      return res.apiv3({});
+    }
+
+    // Convert by pageIds
     const pageIds = _pageIds == null ? [] : _pageIds;
 
     if (pageIds.length > LIMIT_FOR_MULTIPLE_PAGE_OP) {
       return res.apiv3Err(new ErrorV3(`The maximum number of pages you can select is ${LIMIT_FOR_MULTIPLE_PAGE_OP}.`, 'exceeded_maximum_number'), 400);
     }
+    if (pageIds.length === 0) {
+      return res.apiv3Err(new ErrorV3('No page is selected.'), 400);
+    }
 
     try {
-      await crowi.pageService.normalizeParentByPageIds(pageIds, req.user, isRecursively);
+      if (isRecursively) {
+        await crowi.pageService.normalizeParentByPageIdsRecursively(pageIds, req.user);
+      }
+      else {
+        await crowi.pageService.normalizeParentByPageIds(pageIds, req.user);
+      }
     }
     catch (err) {
       return res.apiv3Err(new ErrorV3(`Failed to migrate pages: ${err.message}`), 500);

+ 12 - 8
packages/app/src/server/routes/apiv3/share-links.js

@@ -249,21 +249,25 @@ module.exports = (crowi) => {
   */
   router.delete('/:id', loginRequired, csrf, validator.deleteShareLink, apiV3FormValidator, async(req, res) => {
     const { id } = req.params;
+    const { user } = req;
 
     try {
-      const deletedShareLink = await ShareLink.findOne({ _id: id });
+      const shareLinkToDelete = await ShareLink.findOne({ _id: id });
 
       // check permission
-      const page = await Page.findByIdAndViewer(deletedShareLink.relatedPage, req.user);
-      if (page == null) {
-        const msg = 'Page is not found or forbidden';
-        logger.error('Error', msg);
-        return res.apiv3Err(new ErrorV3(msg, 'delete-shareLink-failed'));
+      if (!user.isAdmin) {
+        const page = await Page.findByIdAndViewer(shareLinkToDelete.relatedPage, user);
+        const isPageExists = (await Page.count({ _id: shareLinkToDelete.relatedPage }) > 0);
+        if (page == null && isPageExists) {
+          const msg = 'Page is not found or forbidden';
+          logger.error('Error', msg);
+          return res.apiv3Err(new ErrorV3(msg, 'delete-shareLink-failed'));
+        }
       }
 
       // remove
-      await deletedShareLink.remove();
-      return res.apiv3({ deletedShareLink });
+      await shareLinkToDelete.remove();
+      return res.apiv3({ deletedShareLink: shareLinkToDelete });
     }
     catch (err) {
       const msg = 'Error occurred in delete share link';

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