Przeglądaj źródła

Merge remote-tracking branch 'origin/master' into imprv/92989-i18n-err-for-putback-modal

kaori 4 lat temu
rodzic
commit
2ce848b2e7
59 zmienionych plików z 965 dodań i 327 usunięć
  1. 24 0
      .github/workflows/ci-app-prod.yml
  2. 12 1
      .github/workflows/ci-app.yml
  3. 8 0
      .github/workflows/ci-slackbot-proxy.yml
  4. 6 0
      .github/workflows/codeql-analysis.yml
  5. 2 1
      .github/workflows/draft-release.yml
  6. 1 1
      .github/workflows/release-slackbot-proxy.yml
  7. 1 1
      .github/workflows/release.yml
  8. 32 1
      CHANGELOG.md
  9. 1 1
      lerna.json
  10. 1 1
      package.json
  11. 2 2
      packages/app/docker/README.md
  12. 7 7
      packages/app/package.json
  13. 1 1
      packages/app/resource/locales/en_US/translation.json
  14. 1 1
      packages/app/resource/locales/ja_JP/translation.json
  15. 1 1
      packages/app/resource/locales/zh_CN/translation.json
  16. 17 0
      packages/app/src/components/Common/CountBadge.tsx
  17. 16 14
      packages/app/src/components/Page/DisplaySwitcher.tsx
  18. 5 3
      packages/app/src/components/PageAttachment.jsx
  19. 76 36
      packages/app/src/components/PrivateLegacyPages.tsx
  20. 7 5
      packages/app/src/components/SearchPage.tsx
  21. 70 58
      packages/app/src/components/SearchPage/SearchControl.tsx
  22. 3 2
      packages/app/src/components/SearchTypeahead.tsx
  23. 73 0
      packages/app/src/components/Sidebar/InfiniteScroll.tsx
  24. 15 28
      packages/app/src/components/Sidebar/PageTree/Item.tsx
  25. 18 12
      packages/app/src/components/Sidebar/RecentChanges.tsx
  26. 2 2
      packages/app/src/components/TagsList.tsx
  27. 18 6
      packages/app/src/server/interfaces/search.ts
  28. 0 2
      packages/app/src/server/models/obsolete-page.js
  29. 90 4
      packages/app/src/server/models/page.ts
  30. 13 4
      packages/app/src/server/models/user.js
  31. 28 0
      packages/app/src/server/models/vo/error-search.ts
  32. 4 3
      packages/app/src/server/routes/apiv3/attachment.js
  33. 6 9
      packages/app/src/server/routes/apiv3/pages.js
  34. 13 7
      packages/app/src/server/routes/search.ts
  35. 24 4
      packages/app/src/server/service/search-delegator/elasticsearch.ts
  36. 47 4
      packages/app/src/server/service/search-delegator/private-legacy-pages.ts
  37. 52 33
      packages/app/src/server/service/search.ts
  38. 1 1
      packages/app/src/stores/page-listing.tsx
  39. 17 4
      packages/app/src/stores/page.tsx
  40. 3 15
      packages/app/src/stores/search.tsx
  41. 2 2
      packages/app/src/styles/_page-tree.scss
  42. 12 0
      packages/app/src/styles/_page_list.scss
  43. 25 8
      packages/app/src/styles/theme/_apply-colors-dark.scss
  44. 6 1
      packages/app/src/styles/theme/_apply-colors-light.scss
  45. 2 0
      packages/app/test/cypress/integration/2-basic-features/access-to-page.spec.ts
  46. 84 7
      packages/app/test/integration/models/user.test.js
  47. 39 9
      packages/app/test/integration/service/search/search-service.test.js
  48. 1 1
      packages/codemirror-textlint/package.json
  49. 1 1
      packages/core/package.json
  50. 1 1
      packages/plugin-attachment-refs/package.json
  51. 1 1
      packages/plugin-lsx/package.json
  52. 4 1
      packages/plugin-lsx/src/client/js/components/Lsx.jsx
  53. 3 2
      packages/plugin-lsx/src/client/js/components/LsxPageList/LsxPage.jsx
  54. 11 1
      packages/plugin-lsx/src/server/routes/lsx.js
  55. 1 1
      packages/plugin-pukiwiki-like-linker/package.json
  56. 1 1
      packages/slack/package.json
  57. 2 2
      packages/slackbot-proxy/package.json
  58. 1 1
      packages/ui/package.json
  59. 50 12
      packages/ui/src/components/PagePath/PageListMeta.tsx

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

@@ -4,10 +4,34 @@ on:
   push:
   push:
     branches:
     branches:
       - master
       - 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:
   pull_request:
     branches:
     branches:
         - master
         - master
     types: [opened, reopened, synchronize]
     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:
 jobs:
 
 

+ 12 - 1
.github/workflows/ci-app.yml

@@ -7,6 +7,17 @@ on:
       - rc/**
       - rc/**
       - chore/**
       - chore/**
       - support/prepare-v**
       - 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:
 jobs:
   lint:
   lint:
@@ -45,7 +56,7 @@ jobs:
           yarn lerna run lint --scope @growi/plugin-*
           yarn lerna run lint --scope @growi/plugin-*
       - name: lerna run lint for app
       - name: lerna run lint for app
         run: |
         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
       - name: Slack Notification
         uses: weseek/ghaction-slack-notification@master
         uses: weseek/ghaction-slack-notification@master

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

@@ -7,6 +7,14 @@ on:
       - rc/**
       - rc/**
       - chore/**
       - chore/**
       - support/prepare-v**
       - 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:
 jobs:
 
 

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

@@ -14,9 +14,15 @@ name: "CodeQL"
 on:
 on:
   push:
   push:
     branches: [ master, dev/*, release/current ]
     branches: [ master, dev/*, release/current ]
+    paths:
+      - .github/workflows/codeql-analysis.yml
+      - packages/**
   pull_request:
   pull_request:
     # The branches below must be a subset of the branches above
     # The branches below must be a subset of the branches above
     branches: [ master ]
     branches: [ master ]
+    paths:
+      - .github/workflows/codeql-analysis.yml
+      - packages/**
   schedule:
   schedule:
     - cron: '28 20 * * 6'
     - 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 }}`
           RELEASE_VERSION=`npx semver -i patch ${{ needs.update-release-draft.outputs.CURRENT_VERSION }}`
           echo ::set-output name=RELEASE_VERSION::$RELEASE_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
       - name: Create/Update Pull Request
-        uses: bakunyo/git-pr-release-action@master
+        uses: bakunyo/git-pr-release-action@281e1fe424fac01f3992542266805e4202a22fe0
         env:
         env:
           GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
           GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
           GIT_PR_RELEASE_BRANCH_PRODUCTION: release/current
           GIT_PR_RELEASE_BRANCH_PRODUCTION: release/current

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

@@ -127,7 +127,7 @@ jobs:
         workingDir: packages/slackbot-proxy
         workingDir: packages/slackbot-proxy
 
 
     - name: Commit
     - name: Commit
-      uses: github-actions-x/commit@v2.8
+      uses: github-actions-x/commit@v2.9
       with:
       with:
         github-token: ${{ secrets.GITHUB_TOKEN }}
         github-token: ${{ secrets.GITHUB_TOKEN }}
         push-branch: support/prepare-v${{ steps.package-json.outputs.packageVersion }}
         push-branch: support/prepare-v${{ steps.package-json.outputs.packageVersion }}

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

@@ -103,7 +103,7 @@ jobs:
       id: package-json
       id: package-json
 
 
     - name: Commit
     - name: Commit
-      uses: github-actions-x/commit@v2.8
+      uses: github-actions-x/commit@v2.9
       with:
       with:
         github-token: ${{ secrets.GITHUB_TOKEN }}
         github-token: ${{ secrets.GITHUB_TOKEN }}
         push-branch: support/prepare-v${{ steps.package-json.outputs.packageVersion }}
         push-branch: support/prepare-v${{ steps.package-json.outputs.packageVersion }}

+ 32 - 1
CHANGELOG.md

@@ -1,9 +1,26 @@
 # Changelog
 # Changelog
 
 
-## [Unreleased](https://github.com/weseek/growi/compare/v5.0.2...HEAD)
+## [Unreleased](https://github.com/weseek/growi/compare/v5.0.3...HEAD)
 
 
 *Please do not manually update this file. We've automated the process.*
 *Please do not manually update this file. We've automated the process.*
 
 
+## [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
 ## [v5.0.2](https://github.com/weseek/growi/compare/v5.0.1...v5.0.2) - 2022-04-15
 
 
 ### 🐛 Bug Fixes
 ### 🐛 Bug Fixes
@@ -102,6 +119,20 @@
 - support: update nanoid yarn.lock v3.1.30 to v3.2.0 (#5216) @LuqmanHakim-Grune
 - support: update nanoid yarn.lock v3.1.30 to v3.2.0 (#5216) @LuqmanHakim-Grune
 - support: update validator version (#5562) @LuqmanHakim-Grune
 - support: update validator version (#5562) @LuqmanHakim-Grune
 
 
+## [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
 ## [v4.5.16](https://github.com/weseek/growi/compare/v4.5.15...v4.5.16) - 2022-04-06
 
 
 ### 💎 Features
 ### 💎 Features

+ 1 - 1
lerna.json

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

+ 1 - 1
package.json

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

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

@@ -10,8 +10,8 @@ GROWI Official docker image
 Supported tags and respective Dockerfile links
 Supported tags and respective Dockerfile links
 ------------------------------------------------
 ------------------------------------------------
 
 
-* [`5.0.2`, `5.0`, `5`, `latest` (Dockerfile)](https://github.com/weseek/growi/blob/v5.0.2/docker/Dockerfile)
-* [`5.0.2-nocdn`, `5.0-nocdn`, `5-nocdn`, `latest-nocdn` (Dockerfile)](https://github.com/weseek/growi/blob/v5.0.2/docker/Dockerfile)
+* [`5.0.3`, `5.0`, `5`, `latest` (Dockerfile)](https://github.com/weseek/growi/blob/v5.0.3/docker/Dockerfile)
+* [`5.0.3-nocdn`, `5.0-nocdn`, `5-nocdn`, `latest-nocdn` (Dockerfile)](https://github.com/weseek/growi/blob/v5.0.3/docker/Dockerfile)
 * [`4.5.15`, `4.5`, `4`, `latest` (Dockerfile)](https://github.com/weseek/growi/blob/v4.5.15/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.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)
 * [`4.4.13`, `4.4` (Dockerfile)](https://github.com/weseek/growi/blob/v4.4.13/docker/Dockerfile)

+ 7 - 7
packages/app/package.json

@@ -1,6 +1,6 @@
 {
 {
   "name": "@growi/app",
   "name": "@growi/app",
-  "version": "5.0.3-RC.0",
+  "version": "5.0.4-RC.0",
   "license": "MIT",
   "license": "MIT",
   "scripts": {
   "scripts": {
     "//// for production": "",
     "//// for production": "",
@@ -62,11 +62,11 @@
     "@elastic/elasticsearch7": "npm:@elastic/elasticsearch@^7.17.0",
     "@elastic/elasticsearch7": "npm:@elastic/elasticsearch@^7.17.0",
     "@godaddy/terminus": "^4.9.0",
     "@godaddy/terminus": "^4.9.0",
     "@google-cloud/storage": "^5.8.5",
     "@google-cloud/storage": "^5.8.5",
-    "@growi/codemirror-textlint": "^5.0.3-RC.0",
-    "@growi/plugin-attachment-refs": "^5.0.3-RC.0",
-    "@growi/plugin-lsx": "^5.0.3-RC.0",
-    "@growi/plugin-pukiwiki-like-linker": "^5.0.3-RC.0",
-    "@growi/slack": "^5.0.3-RC.0",
+    "@growi/codemirror-textlint": "^5.0.4-RC.0",
+    "@growi/plugin-attachment-refs": "^5.0.4-RC.0",
+    "@growi/plugin-lsx": "^5.0.4-RC.0",
+    "@growi/plugin-pukiwiki-like-linker": "^5.0.4-RC.0",
+    "@growi/slack": "^5.0.4-RC.0",
     "@promster/express": "^7.0.2",
     "@promster/express": "^7.0.2",
     "@promster/server": "^7.0.4",
     "@promster/server": "^7.0.4",
     "@slack/events-api": "^3.0.0",
     "@slack/events-api": "^3.0.0",
@@ -167,7 +167,7 @@
   },
   },
   "devDependencies": {
   "devDependencies": {
     "@alienfast/i18next-loader": "^1.1.4",
     "@alienfast/i18next-loader": "^1.1.4",
-    "@growi/ui": "^5.0.3-RC.0",
+    "@growi/ui": "^5.0.4-RC.0",
     "@handsontable/react": "=2.1.0",
     "@handsontable/react": "=2.1.0",
     "@types/compression": "^1.7.0",
     "@types/compression": "^1.7.0",
     "@types/express": "^4.17.11",
     "@types/express": "^4.17.11",

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

@@ -640,7 +640,7 @@
   "private_legacy_pages": {
   "private_legacy_pages": {
     "bulk_operation": "Bulk operation",
     "bulk_operation": "Bulk operation",
     "convert_all_selected_pages": "Convert all to new v5 compatible format",
     "convert_all_selected_pages": "Convert all to new v5 compatible format",
-    "alert_title": "You are viewing old v4 compatible private 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.",
     "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_title": "Congratulations. Ready to use GROWI v5!",
     "nopages_desc1": "Now all the pages you can manage seem to be in v5 compatible format.",
     "nopages_desc1": "Now all the pages you can manage seem to be in v5 compatible format.",

+ 1 - 1
packages/app/resource/locales/ja_JP/translation.json

@@ -639,7 +639,7 @@
   "private_legacy_pages": {
   "private_legacy_pages": {
     "bulk_operation": "一括操作",
     "bulk_operation": "一括操作",
     "convert_all_selected_pages": "新しい v5 互換形式に一括変換",
     "convert_all_selected_pages": "新しい v5 互換形式に一括変換",
-    "alert_title": "古い v4 互換形式のプライベートページを表示しています",
+    "alert_title": "古い v4 互換形式のプライベートページが存在します",
     "alert_desc1": "このページでは、チェックボックスでページを選択し、画面上部の「一括操作」ボタンから新しい v5 互換形式に一括変換できます。",
     "alert_desc1": "このページでは、チェックボックスでページを選択し、画面上部の「一括操作」ボタンから新しい v5 互換形式に一括変換できます。",
     "nopages_title": "おめでとうございます。GROWI v5 を使う準備が完了しました!",
     "nopages_title": "おめでとうございます。GROWI v5 を使う準備が完了しました!",
     "nopages_desc1": "今あなたが管理可能なページはすべて v5 互換形式になっているようです。",
     "nopages_desc1": "今あなたが管理可能なページはすべて v5 互換形式になっているようです。",

+ 1 - 1
packages/app/resource/locales/zh_CN/translation.json

@@ -926,7 +926,7 @@
   "private_legacy_pages": {
   "private_legacy_pages": {
     "bulk_operation": "批量操作",
     "bulk_operation": "批量操作",
     "convert_all_selected_pages": "全部转换为新的v5兼容格式",
     "convert_all_selected_pages": "全部转换为新的v5兼容格式",
-    "alert_title": "你正在查看旧的v4兼容的私人网页。",
+    "alert_title": "存在旧的v4兼容格式的私人网页。",
     "alert_desc1": "在这一页,你可以用复选框选择页面,并通过屏幕上方的批量操作按钮批量转换为新的v5兼容格式。",
     "alert_desc1": "在这一页,你可以用复选框选择页面,并通过屏幕上方的批量操作按钮批量转换为新的v5兼容格式。",
     "nopages_title": "恭喜你。准备使用GROWI v5!",
     "nopages_title": "恭喜你。准备使用GROWI v5!",
     "nopages_desc1": "现在你能管理的所有页面似乎都是v5兼容的格式。",
     "nopages_desc1": "现在你能管理的所有页面似乎都是v5兼容的格式。",

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

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

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

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

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

+ 76 - 36
packages/app/src/components/PrivateLegacyPages.tsx

@@ -12,7 +12,7 @@ import AppContainer from '~/client/services/AppContainer';
 import { ISelectableAll, ISelectableAndIndeterminatable } from '~/client/interfaces/selectable-all';
 import { ISelectableAll, ISelectableAndIndeterminatable } from '~/client/interfaces/selectable-all';
 import { toastSuccess } from '~/client/util/apiNotification';
 import { toastSuccess } from '~/client/util/apiNotification';
 import {
 import {
-  useSWRxNamedQuerySearch,
+  useSWRxSearch,
 } from '~/stores/search';
 } from '~/stores/search';
 import {
 import {
   ILegacyPrivatePage, useLegacyPrivatePagesMigrationModal,
   ILegacyPrivatePage, useLegacyPrivatePagesMigrationModal,
@@ -24,10 +24,15 @@ import { OperateAllControl } from './SearchPage/OperateAllControl';
 import { IReturnSelectedPageIds, SearchPageBase, usePageDeleteModalForBulkDeletion } from './SearchPage2/SearchPageBase';
 import { IReturnSelectedPageIds, SearchPageBase, usePageDeleteModalForBulkDeletion } from './SearchPage2/SearchPageBase';
 import { MenuItemType } from './Common/Dropdown/PageItemControl';
 import { MenuItemType } from './Common/Dropdown/PageItemControl';
 import { LegacyPrivatePagesMigrationModal } from './LegacyPrivatePagesMigrationModal';
 import { LegacyPrivatePagesMigrationModal } from './LegacyPrivatePagesMigrationModal';
+import SearchControl from './SearchPage/SearchControl';
+import { useSWRxV5MigrationStatus } from '~/stores/page-listing';
+import { V5MigrationStatus } from '~/interfaces/page-listing-results';
 
 
 
 
 // TODO: replace with "customize:showPageLimitationS"
 // TODO: replace with "customize:showPageLimitationS"
-const INITIAL_PAGIONG_SIZE = 20;
+const INITIAL_PAGING_SIZE = 20;
+
+const initQ = '/';
 
 
 
 
 /**
 /**
@@ -39,6 +44,7 @@ type SearchResultListHeadProps = {
   offset: number,
   offset: number,
   pagingSize: number,
   pagingSize: number,
   onPagingSizeChanged: (size: number) => void,
   onPagingSizeChanged: (size: number) => void,
+  migrationStatus?: V5MigrationStatus,
 }
 }
 
 
 const SearchResultListHead = React.memo((props: SearchResultListHeadProps): JSX.Element => {
 const SearchResultListHead = React.memo((props: SearchResultListHeadProps): JSX.Element => {
@@ -46,14 +52,24 @@ const SearchResultListHead = React.memo((props: SearchResultListHeadProps): JSX.
 
 
   const {
   const {
     searchResult, offset, pagingSize,
     searchResult, offset, pagingSize,
-    onPagingSizeChanged,
+    onPagingSizeChanged, migrationStatus,
   } = props;
   } = 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 { took, total, hitsCount } = searchResult.meta;
   const leftNum = offset + 1;
   const leftNum = offset + 1;
   const rightNum = offset + hitsCount;
   const rightNum = offset + hitsCount;
 
 
-  if (total === 0) {
+  const isSuccess = migrationStatus.migratablePagesCount === 0;
+
+  if (isSuccess) {
     return (
     return (
       <div className="card border-success mt-3">
       <div className="card border-success mt-3">
         <div className="card-body">
         <div className="card-body">
@@ -125,19 +141,30 @@ export const PrivateLegacyPages = (props: Props): JSX.Element => {
   } = props;
   } = props;
 
 
 
 
+  const [keyword, setKeyword] = useState<string>(initQ);
   const [offset, setOffset] = useState<number>(0);
   const [offset, setOffset] = useState<number>(0);
-  const [limit, setLimit] = useState<number>(INITIAL_PAGIONG_SIZE);
+  const [limit, setLimit] = useState<number>(INITIAL_PAGING_SIZE);
 
 
   const [isControlEnabled, setControlEnabled] = useState(false);
   const [isControlEnabled, setControlEnabled] = useState(false);
 
 
   const selectAllControlRef = useRef<ISelectableAndIndeterminatable|null>(null);
   const selectAllControlRef = useRef<ISelectableAndIndeterminatable|null>(null);
   const searchPageBaseRef = useRef<ISelectableAll & IReturnSelectedPageIds|null>(null);
   const searchPageBaseRef = useRef<ISelectableAll & IReturnSelectedPageIds|null>(null);
 
 
-  const { data, conditions, mutate } = useSWRxNamedQuerySearch('PrivateLegacyPages', {
+  const { data, conditions, mutate } = useSWRxSearch(keyword, 'PrivateLegacyPages', {
     offset,
     offset,
     limit,
     limit,
+    includeUserPages: true,
+    includeTrashPages: false,
   });
   });
 
 
+  const { data: migrationStatus, mutate: mutateMigrationStatus } = useSWRxV5MigrationStatus();
+
+  const searchInvokedHandler = useCallback((_keyword: string) => {
+    mutateMigrationStatus();
+    setKeyword(_keyword);
+    setOffset(0);
+  }, []);
+
   const { open: openModal, close: closeModal } = useLegacyPrivatePagesMigrationModal();
   const { open: openModal, close: closeModal } = useLegacyPrivatePagesMigrationModal();
 
 
   const selectAllCheckboxChangedHandler = useCallback((isChecked: boolean) => {
   const selectAllCheckboxChangedHandler = useCallback((isChecked: boolean) => {
@@ -206,10 +233,11 @@ export const PrivateLegacyPages = (props: Props): JSX.Element => {
       () => {
       () => {
         toastSuccess(t('Successfully requested'));
         toastSuccess(t('Successfully requested'));
         closeModal();
         closeModal();
+        mutateMigrationStatus();
         mutate();
         mutate();
       },
       },
     );
     );
-  }, [data, mutate, openModal, closeModal]);
+  }, [data, mutate, openModal, closeModal, mutateMigrationStatus]);
 
 
   const pagingSizeChangedHandler = useCallback((pagingSize: number) => {
   const pagingSizeChangedHandler = useCallback((pagingSize: number) => {
     setOffset(0);
     setOffset(0);
@@ -224,42 +252,53 @@ export const PrivateLegacyPages = (props: Props): JSX.Element => {
 
 
   const hitsCount = data?.meta.hitsCount;
   const hitsCount = data?.meta.hitsCount;
 
 
-  const searchControl = useMemo(() => {
+  const searchControlAllAction = useMemo(() => {
     const isCheckboxDisabled = hitsCount === 0;
     const isCheckboxDisabled = hitsCount === 0;
 
 
     return (
     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>
       </div>
       </div>
     );
     );
   }, [convertMenuItemClickedHandler, deleteAllButtonClickedHandler, hitsCount, isControlEnabled, selectAllCheckboxChangedHandler, t]);
   }, [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(() => {
   const searchResultListHead = useMemo(() => {
     if (data == null) {
     if (data == null) {
       return <></>;
       return <></>;
@@ -270,9 +309,10 @@ export const PrivateLegacyPages = (props: Props): JSX.Element => {
         offset={offset}
         offset={offset}
         pagingSize={limit}
         pagingSize={limit}
         onPagingSizeChanged={pagingSizeChangedHandler}
         onPagingSizeChanged={pagingSizeChangedHandler}
+        migrationStatus={migrationStatus}
       />
       />
     );
     );
-  }, [data, limit, offset, pagingSizeChangedHandler]);
+  }, [data, limit, offset, pagingSizeChangedHandler, migrationStatus]);
 
 
   const searchPager = useMemo(() => {
   const searchPager = useMemo(() => {
     // when pager is not needed
     // when pager is not needed

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

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

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

@@ -12,20 +12,24 @@ import SearchForm from '../SearchForm';
 
 
 type Props = {
 type Props = {
   isSearchServiceReachable: boolean,
   isSearchServiceReachable: boolean,
+  isEnableSort: boolean,
+  isEnableFilter: boolean,
   initialSearchConditions: Partial<ISearchConditions>,
   initialSearchConditions: Partial<ISearchConditions>,
 
 
   onSearchInvoked: (keyword: string, configurations: Partial<ISearchConfigurations>) => void,
   onSearchInvoked: (keyword: string, configurations: Partial<ISearchConfigurations>) => void,
 
 
-  deleteAllControl: React.ReactNode,
+  allControl: React.ReactNode,
 }
 }
 
 
 const SearchControl: FC <Props> = React.memo((props: Props) => {
 const SearchControl: FC <Props> = React.memo((props: Props) => {
 
 
   const {
   const {
     isSearchServiceReachable,
     isSearchServiceReachable,
+    isEnableSort,
+    isEnableFilter,
     initialSearchConditions,
     initialSearchConditions,
     onSearchInvoked,
     onSearchInvoked,
-    deleteAllControl,
+    allControl,
   } = props;
   } = props;
 
 
   const [keyword, setKeyword] = useState(initialSearchConditions.keyword ?? '');
   const [keyword, setKeyword] = useState(initialSearchConditions.keyword ?? '');
@@ -73,71 +77,79 @@ const SearchControl: FC <Props> = React.memo((props: Props) => {
         </div>
         </div>
 
 
         {/* sort option: show when screen is larger than lg */}
         {/* 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>
       </div>
       {/* TODO: replace the following elements deleteAll button , relevance button and include specificPath button component */}
       {/* 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="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">
         <div className="d-flex">
-          {deleteAllControl}
+          {allControl}
         </div>
         </div>
         {/* sort option: show when screen is smaller than lg */}
         {/* 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>
-          <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>
+            <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>
       </div>
 
 
       <SearchOptionModal
       <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 { TypeaheadProps } from '~/client/interfaces/react-bootstrap-typeahead';
 import { IPageSearchMeta } from '~/interfaces/search';
 import { IPageSearchMeta } from '~/interfaces/search';
 import { IPageWithMeta } from '~/interfaces/page';
 import { IPageWithMeta } from '~/interfaces/page';
-import { useSWRxFullTextSearch } from '~/stores/search';
+import { useSWRxSearch } from '~/stores/search';
 
 
 
 
 type ResetFormButtonProps = {
 type ResetFormButtonProps = {
@@ -61,8 +61,9 @@ const SearchTypeahead: ForwardRefRenderFunction<IFocusable, Props> = (props: Pro
   const [searchKeyword, setSearchKeyword] = useState('');
   const [searchKeyword, setSearchKeyword] = useState('');
   const [isForcused, setFocused] = useState(false);
   const [isForcused, setFocused] = useState(false);
 
 
-  const { data: searchResult, error: searchError } = useSWRxFullTextSearch(
+  const { data: searchResult, error: searchError } = useSWRxSearch(
     disableIncrementalSearch ? null : searchKeyword,
     disableIncrementalSearch ? null : searchKeyword,
+    null,
     { limit: 10 },
     { limit: 10 },
   );
   );
 
 

+ 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 - 28
packages/app/src/components/Sidebar/PageTree/Item.tsx

@@ -1,32 +1,33 @@
 import React, {
 import React, {
   useCallback, useState, FC, useEffect,
   useCallback, useState, FC, useEffect,
 } from 'react';
 } from 'react';
-import { DropdownToggle } from 'reactstrap';
-import { useTranslation } from 'react-i18next';
-
-import { useDrag, useDrop } from 'react-dnd';
 
 
 import nodePath from 'path';
 import nodePath from 'path';
 
 
 import { pathUtils, pagePathUtils } from '@growi/core';
 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 { toastWarning, toastError, toastSuccess } from '~/client/util/apiNotification';
-
-import { useSWRxPageChildren } from '~/stores/page-listing';
 import { apiv3Put, apiv3Post } from '~/client/util/apiv3-client';
 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 { 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 ClosableTextInput, { AlertInfo, AlertType } from '../../Common/ClosableTextInput';
+import CountBadge from '../../Common/CountBadge';
 import { PageItemControl } from '../../Common/Dropdown/PageItemControl';
 import { PageItemControl } from '../../Common/Dropdown/PageItemControl';
+
 import { ItemNode } from './ItemNode';
 import { ItemNode } from './ItemNode';
-import { usePageTreeDescCountMap } from '~/stores/ui';
-import {
-  IPageHasId, IPageInfoAll, IPageToDeleteWithMeta,
-} from '~/interfaces/page';
 
 
 
 
 const logger = loggerFactory('growi:cli:Item');
 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 Item: FC<ItemProps> = (props: ItemProps) => {
   const { t } = useTranslation();
   const { t } = useTranslation();
   const {
   const {
@@ -465,7 +452,7 @@ const Item: FC<ItemProps> = (props: ItemProps) => {
           )}
           )}
         {descendantCount > 0 && !isRenameInputShown && (
         {descendantCount > 0 && !isRenameInputShown && (
           <div className="grw-pagetree-count-wrapper">
           <div className="grw-pagetree-count-wrapper">
-            <ItemCount descendantCount={descendantCount} />
+            <CountBadge count={descendantCount} />
           </div>
           </div>
         )}
         )}
         <div className="grw-pagetree-control d-flex">
         <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 { DevidedPagePath } from '@growi/core';
 
 
 import PagePathHierarchicalLink from '~/components/PagePathHierarchicalLink';
 import PagePathHierarchicalLink from '~/components/PagePathHierarchicalLink';
-import { useSWRxRecentlyUpdated } from '~/stores/page';
+import { useSWRInifinitexRecentlyUpdated } from '~/stores/page';
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 
 
 import LinkedPagePath from '~/models/linked-page-path';
 import LinkedPagePath from '~/models/linked-page-path';
-
+import InfiniteScroll from './InfiniteScroll';
 
 
 import FormattedDistanceDate from '../FormattedDistanceDate';
 import FormattedDistanceDate from '../FormattedDistanceDate';
 
 
@@ -120,14 +120,13 @@ SmallPageItem.propTypes = {
   page: PropTypes.any,
   page: PropTypes.any,
 };
 };
 
 
-
 const RecentChanges = (): JSX.Element => {
 const RecentChanges = (): JSX.Element => {
-
+  const PER_PAGE = 20;
   const { t } = useTranslation();
   const { t } = useTranslation();
-  const { data: pages, mutate } = useSWRxRecentlyUpdated();
-
+  const swr = useSWRInifinitexRecentlyUpdated();
   const [isRecentChangesSidebarSmall, setIsRecentChangesSidebarSmall] = useState(false);
   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(() => {
   const retrieveSizePreferenceFromLocalStorage = useCallback(() => {
     if (window.localStorage.isRecentChangesSidebarSmall === 'true') {
     if (window.localStorage.isRecentChangesSidebarSmall === 'true') {
       setIsRecentChangesSidebarSmall(true);
       setIsRecentChangesSidebarSmall(true);
@@ -148,7 +147,7 @@ const RecentChanges = (): JSX.Element => {
     <>
     <>
       <div className="grw-sidebar-content-header p-3 d-flex">
       <div className="grw-sidebar-content-header p-3 d-flex">
         <h3 className="mb-0  text-nowrap">{t('Recent Changes')}</h3>
         <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>
           <i className="icon icon-reload"></i>
         </button>
         </button>
         <div className="d-flex align-items-center">
         <div className="d-flex align-items-center">
@@ -167,14 +166,21 @@ const RecentChanges = (): JSX.Element => {
       </div>
       </div>
       <div className="grw-recent-changes p-3">
       <div className="grw-recent-changes p-3">
         <ul className="list-group list-group-flush">
         <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>
         </ul>
       </div>
       </div>
     </>
     </>
   );
   );
 
 
 };
 };
-
 export default RecentChanges;
 export default RecentChanges;

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

@@ -43,7 +43,7 @@ const TagsList: FC<Props> = (props: Props) => {
   }
   }
 
 
   return (
   return (
-    <>
+    <div data-testid="grw-tags-list">
       <header className="py-0">
       <header className="py-0">
         <h1 className="title text-center mt-5 mb-3 font-weight-bold">{`${t('Tags')}(${tagsList?.totalCount || 0})`}</h1>
         <h1 className="title text-center mt-5 mb-3 font-weight-bold">{`${t('Tags')}(${tagsList?.totalCount || 0})`}</h1>
       </header>
       </header>
@@ -78,7 +78,7 @@ const TagsList: FC<Props> = (props: Props) => {
           />
           />
         </div>
         </div>
       </div>
       </div>
-    </>
+    </div>
   );
   );
 };
 };
 
 

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

@@ -14,22 +14,34 @@ export type QueryTerms = {
   not_tag: string[],
   not_tag: string[],
 }
 }
 
 
-export type ParsedQuery = { queryString: string, terms?: QueryTerms, delegatorName?: string }
+export type ParsedQuery = { queryString: string, terms: QueryTerms, delegatorName?: string }
 
 
 export interface SearchQueryParser {
 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]>
   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
   name?: SearchDelegatorName
   search(data: SearchableData | null, user, userGroups, option): Promise<ISearchResult<T>>
   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
   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>;

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

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

+ 90 - 4
packages/app/src/server/models/page.ts

@@ -20,7 +20,7 @@ import Crowi from '../crowi';
 import { getPageSchema, extractToAncestorsPaths, populateDataToShowRevision } from './obsolete-page';
 import { getPageSchema, extractToAncestorsPaths, populateDataToShowRevision } from './obsolete-page';
 import { PageRedirectModel } from './page-redirect';
 import { PageRedirectModel } from './page-redirect';
 
 
-const { addTrailingSlash } = pathUtils;
+const { addTrailingSlash, normalizePath } = pathUtils;
 const { isTopPage, collectAncestorPaths } = pagePathUtils;
 const { isTopPage, collectAncestorPaths } = pagePathUtils;
 
 
 const logger = loggerFactory('growi:models:page');
 const logger = loggerFactory('growi:models:page');
@@ -44,6 +44,13 @@ type TargetAndAncestorsResult = {
   rootPage: PageDocument
   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 type CreateMethod = (path: string, body: string, user, options) => Promise<PageDocument & { _id: any }>
 export interface PageModel extends Model<PageDocument> {
 export interface PageModel extends Model<PageDocument> {
   [x: string]: any; // for obsolete methods
   [x: string]: any; // for obsolete methods
@@ -54,7 +61,7 @@ export interface PageModel extends Model<PageDocument> {
   findTargetAndAncestorsByPathOrId(pathOrId: string): Promise<TargetAndAncestorsResult>
   findTargetAndAncestorsByPathOrId(pathOrId: string): Promise<TargetAndAncestorsResult>
   findChildrenByParentPathOrIdAndViewer(parentPathOrId: string, user, userGroups?): Promise<PageDocument[]>
   findChildrenByParentPathOrIdAndViewer(parentPathOrId: string, user, userGroups?): Promise<PageDocument[]>
   findAncestorsChildrenByPathAndViewer(path: string, user, userGroups?): Promise<Record<string, PageDocument[]>>
   findAncestorsChildrenByPathAndViewer(path: string, user, userGroups?): Promise<Record<string, PageDocument[]>>
-
+  findRecentUpdatedPages(path: string, user, option, includeEmpty?: boolean): Promise<PaginatedPages>
   generateGrantCondition(
   generateGrantCondition(
     user, userGroups, showAnyoneKnowsLink?: boolean, showPagesRestrictedByOwner?: boolean, showPagesRestrictedByGroup?: boolean,
     user, userGroups, showAnyoneKnowsLink?: boolean, showPagesRestrictedByOwner?: boolean, showPagesRestrictedByGroup?: boolean,
   ): { $or: any[] }
   ): { $or: any[] }
@@ -126,7 +133,7 @@ const generateChildrenRegExp = (path: string): RegExp => {
   return new RegExp(`^${path}(\\/[^/]+)\\/?$`);
   return new RegExp(`^${path}(\\/[^/]+)\\/?$`);
 };
 };
 
 
-class PageQueryBuilder {
+export class PageQueryBuilder {
 
 
   query: any;
   query: any;
 
 
@@ -247,7 +254,9 @@ class PageQueryBuilder {
    * *option*
    * *option*
    *   Left for backward compatibility
    *   Left for backward compatibility
    */
    */
-  addConditionToListByStartWith(path, option?) {
+  addConditionToListByStartWith(str: string): PageQueryBuilder {
+    const path = normalizePath(str);
+
     // No request is set for the top page
     // No request is set for the top page
     if (isTopPage(path)) {
     if (isTopPage(path)) {
       return this;
       return this;
@@ -261,6 +270,50 @@ class PageQueryBuilder {
     return this;
     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) {
   async addConditionForParentNormalization(user) {
     // determine UserGroup condition
     // determine UserGroup condition
     let userGroups;
     let userGroups;
@@ -654,6 +707,39 @@ schema.statics.findByPathAndViewer = async function(
   return queryBuilder.query.exec();
   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
  * Find all ancestor pages by path. When duplicate pages found, it uses the oldest page as a result

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

@@ -1,14 +1,15 @@
 /* eslint-disable no-use-before-define */
 /* eslint-disable no-use-before-define */
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 
 
+const crypto = require('crypto');
+
 const debug = require('debug')('growi:models:user');
 const debug = require('debug')('growi:models:user');
+const md5 = require('md5');
 const mongoose = require('mongoose');
 const mongoose = require('mongoose');
 const mongoosePaginate = require('mongoose-paginate-v2');
 const mongoosePaginate = require('mongoose-paginate-v2');
 const uniqueValidator = require('mongoose-unique-validator');
 const uniqueValidator = require('mongoose-unique-validator');
-const md5 = require('md5');
 
 
 const ObjectId = mongoose.Schema.Types.ObjectId;
 const ObjectId = mongoose.Schema.Types.ObjectId;
-const crypto = require('crypto');
 
 
 const { listLocaleIds, migrateDeprecatedLocaleId } = require('~/utils/locale-utils');
 const { listLocaleIds, migrateDeprecatedLocaleId } = require('~/utils/locale-utils');
 
 
@@ -393,8 +394,16 @@ module.exports = function(crowi) {
       .sort(sort);
       .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) {
   userSchema.statics.findUserByUsername = function(username) {

+ 28 - 0
packages/app/src/server/models/vo/error-search.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';
+};

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

@@ -8,8 +8,8 @@ const express = require('express');
 
 
 const router = express.Router();
 const router = express.Router();
 const { query } = require('express-validator');
 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');
 const ErrorV3 = require('../../models/vo/error-apiv3');
 
 
 /**
 /**
@@ -28,7 +28,8 @@ module.exports = (crowi) => {
   const validator = {
   const validator = {
     retrieveAttachments: [
     retrieveAttachments: [
       query('pageId').isMongoId().withMessage('pageId is required'),
       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) => {
   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 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;
     const offset = (page - 1) * limit;
 
 
     try {
     try {

+ 6 - 9
packages/app/src/server/routes/apiv3/pages.js

@@ -1,16 +1,14 @@
-import loggerFactory from '~/utils/logger';
-
 import { subscribeRuleNames } from '~/interfaces/in-app-notification';
 import { subscribeRuleNames } from '~/interfaces/in-app-notification';
+import loggerFactory from '~/utils/logger';
 
 
 import { apiV3FormValidator } from '../../middlewares/apiv3-form-validator';
 import { apiV3FormValidator } from '../../middlewares/apiv3-form-validator';
 
 
 const logger = loggerFactory('growi:routes:apiv3:pages'); // eslint-disable-line no-unused-vars
 const logger = loggerFactory('growi:routes:apiv3:pages'); // eslint-disable-line no-unused-vars
-const express = require('express');
 const { pathUtils, pagePathUtils } = require('@growi/core');
 const { pathUtils, pagePathUtils } = require('@growi/core');
-const mongoose = require('mongoose');
-
+const express = require('express');
 const { body } = require('express-validator');
 const { body } = require('express-validator');
 const { query } = require('express-validator');
 const { query } = require('express-validator');
+const mongoose = require('mongoose');
 
 
 const ErrorV3 = require('../../models/vo/error-apiv3');
 const ErrorV3 = require('../../models/vo/error-apiv3');
 
 
@@ -366,18 +364,17 @@ module.exports = (crowi) => {
   router.get('/recent', accessTokenParser, loginRequired, async(req, res) => {
   router.get('/recent', accessTokenParser, loginRequired, async(req, res) => {
     const limit = 20;
     const limit = 20;
     const offset = parseInt(req.query.offset) || 0;
     const offset = parseInt(req.query.offset) || 0;
-
+    const skip = offset > 0 ? (offset - 1) * limit : offset;
     const queryOptions = {
     const queryOptions = {
-      offset,
+      offset: skip,
       limit,
       limit,
       includeTrashed: false,
       includeTrashed: false,
       isRegExpEscapedFromPath: true,
       isRegExpEscapedFromPath: true,
       sort: 'updatedAt',
       sort: 'updatedAt',
       desc: -1,
       desc: -1,
     };
     };
-
     try {
     try {
-      const result = await Page.findListWithDescendants('/', req.user, queryOptions);
+      const result = await Page.findRecentUpdatedPages('/', req.user, queryOptions);
       if (result.pages.length > limit) {
       if (result.pages.length > limit) {
         result.pages.pop();
         result.pages.pop();
       }
       }

+ 13 - 7
packages/app/src/server/routes/search.js → packages/app/src/server/routes/search.ts

@@ -1,4 +1,5 @@
-const { default: loggerFactory } = require('~/utils/logger');
+import loggerFactory from '~/utils/logger';
+import { isSearchError } from '../models/vo/error-search';
 
 
 const logger = loggerFactory('growi:routes:search');
 const logger = loggerFactory('growi:routes:search');
 
 
@@ -30,13 +31,11 @@ const logger = loggerFactory('growi:routes:search');
  */
  */
 module.exports = function(crowi, app) {
 module.exports = function(crowi, app) {
   // var debug = require('debug')('growi:routes:search')
   // var debug = require('debug')('growi:routes:search')
-  const Page = crowi.model('Page');
-  const User = crowi.model('User');
   const ApiResponse = require('../util/apiResponse');
   const ApiResponse = require('../util/apiResponse');
   const ApiPaginate = require('../util/apiPaginate');
   const ApiPaginate = require('../util/apiPaginate');
 
 
-  const actions = {};
-  const api = {};
+  const actions: any = {};
+  const api: any = {};
 
 
   actions.searchPage = function(req, res) {
   actions.searchPage = function(req, res) {
     const keyword = req.query.q || null;
     const keyword = req.query.q || null;
@@ -113,7 +112,7 @@ module.exports = function(crowi, app) {
   api.search = async function(req, res) {
   api.search = async function(req, res) {
     const user = req.user;
     const user = req.user;
     const {
     const {
-      q = null, type = null, sort = null, order = null,
+      q = null, nq = null, type = null, sort = null, order = null,
     } = req.query;
     } = req.query;
     let paginateOpts;
     let paginateOpts;
 
 
@@ -147,10 +146,17 @@ module.exports = function(crowi, app) {
     let delegatorName;
     let delegatorName;
     try {
     try {
       const keyword = decodeURIComponent(q);
       const keyword = decodeURIComponent(q);
-      [searchResult, delegatorName] = await searchService.searchKeyword(keyword, user, userGroups, searchOpts);
+      const nqName = nq ?? decodeURIComponent(nq);
+      [searchResult, delegatorName] = await searchService.searchKeyword(keyword, nqName, user, userGroups, searchOpts);
     }
     }
     catch (err) {
     catch (err) {
       logger.error('Failed to search', err);
       logger.error('Failed to search', err);
+
+      if (isSearchError(err)) {
+        const { unavailableTermsKeys } = err;
+        return res.json(ApiResponse.error(err, 400, { unavailableTermsKeys }));
+      }
+
       return res.json(ApiResponse.error(err));
       return res.json(ApiResponse.error(err));
     }
     }
 
 

+ 24 - 4
packages/app/src/server/service/search-delegator/elasticsearch.ts

@@ -13,7 +13,7 @@ import {
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 
 
 import {
 import {
-  SearchDelegator, SearchableData, QueryTerms,
+  SearchDelegator, SearchableData, QueryTerms, UnavailableTermsKey, ESQueryTerms, ESTermsKey,
 } from '../../interfaces/search';
 } from '../../interfaces/search';
 import { PageModel } from '../../models/page';
 import { PageModel } from '../../models/page';
 import { createBatchStream } from '../../util/batch-stream';
 import { createBatchStream } from '../../util/batch-stream';
@@ -40,9 +40,11 @@ const ES_SORT_ORDER = {
   [ASC]: 'asc',
   [ASC]: 'asc',
 };
 };
 
 
+const AVAILABLE_KEYS = ['match', 'not_match', 'phrase', 'not_phrase', 'prefix', 'not_prefix', 'tag', 'not_tag'];
+
 type Data = any;
 type Data = any;
 
 
-class ElasticsearchDelegator implements SearchDelegator<Data> {
+class ElasticsearchDelegator implements SearchDelegator<Data, ESTermsKey, ESQueryTerms> {
 
 
   name!: SearchDelegatorName.DEFAULT
   name!: SearchDelegatorName.DEFAULT
 
 
@@ -739,7 +741,7 @@ class ElasticsearchDelegator implements SearchDelegator<Data> {
     return query;
     return query;
   }
   }
 
 
-  appendCriteriaForQueryString(query, parsedKeywords: QueryTerms) {
+  appendCriteriaForQueryString(query, parsedKeywords: ESQueryTerms): void {
     query = this.initializeBoolQuery(query); // eslint-disable-line no-param-reassign
     query = this.initializeBoolQuery(query); // eslint-disable-line no-param-reassign
 
 
     if (parsedKeywords.match.length > 0) {
     if (parsedKeywords.match.length > 0) {
@@ -950,9 +952,13 @@ class ElasticsearchDelegator implements SearchDelegator<Data> {
     };
     };
   }
   }
 
 
-  async search(data: SearchableData, user, userGroups, option): Promise<ISearchResult<unknown>> {
+  async search(data: SearchableData<ESQueryTerms>, user, userGroups, option): Promise<ISearchResult<unknown>> {
     const { queryString, terms } = data;
     const { queryString, terms } = data;
 
 
+    if (terms == null) {
+      throw Error('Cannnot process search since terms is undefined.');
+    }
+
     const from = option.offset || null;
     const from = option.offset || null;
     const size = option.limit || null;
     const size = option.limit || null;
     const sort = option.sort || null;
     const sort = option.sort || null;
@@ -972,6 +978,20 @@ class ElasticsearchDelegator implements SearchDelegator<Data> {
     return this.searchKeyword(query);
     return this.searchKeyword(query);
   }
   }
 
 
+  isTermsNormalized(terms: Partial<QueryTerms>): terms is ESQueryTerms {
+    const entries = Object.entries(terms);
+
+    return !entries.some(([key, val]) => !AVAILABLE_KEYS.includes(key) && typeof val?.length === 'number' && val.length > 0);
+  }
+
+  validateTerms(terms: QueryTerms): UnavailableTermsKey<ESTermsKey>[] {
+    const entries = Object.entries(terms);
+
+    return entries
+      .filter(([key, val]) => !AVAILABLE_KEYS.includes(key) && val.length > 0)
+      .map(([key]) => key as UnavailableTermsKey<ESTermsKey>);
+  }
+
   async syncPageUpdated(page, user) {
   async syncPageUpdated(page, user) {
     logger.debug('SearchClient.syncPageUpdated', page.path);
     logger.debug('SearchClient.syncPageUpdated', page.path);
 
 

+ 47 - 4
packages/app/src/server/service/search-delegator/private-legacy-pages.ts

@@ -1,16 +1,18 @@
 import mongoose from 'mongoose';
 import mongoose from 'mongoose';
 
 
-import { PageModel, PageDocument } from '~/server/models/page';
+import { PageModel, PageDocument, PageQueryBuilder } from '~/server/models/page';
 import { SearchDelegatorName } from '~/interfaces/named-query';
 import { SearchDelegatorName } from '~/interfaces/named-query';
 import { IPage } from '~/interfaces/page';
 import { IPage } from '~/interfaces/page';
 import {
 import {
-  SearchableData, SearchDelegator,
+  QueryTerms, MongoTermsKey,
+  SearchableData, SearchDelegator, UnavailableTermsKey, MongoQueryTerms,
 } from '../../interfaces/search';
 } from '../../interfaces/search';
 import { serializeUserSecurely } from '../../models/serializers/user-serializer';
 import { serializeUserSecurely } from '../../models/serializers/user-serializer';
 import { ISearchResult } from '~/interfaces/search';
 import { ISearchResult } from '~/interfaces/search';
 
 
+const AVAILABLE_KEYS = ['match', 'not_match', 'prefix', 'not_prefix'];
 
 
-class PrivateLegacyPagesDelegator implements SearchDelegator<IPage> {
+class PrivateLegacyPagesDelegator implements SearchDelegator<IPage, MongoTermsKey, MongoQueryTerms> {
 
 
   name!: SearchDelegatorName.PRIVATE_LEGACY_PAGES
   name!: SearchDelegatorName.PRIVATE_LEGACY_PAGES
 
 
@@ -18,7 +20,8 @@ class PrivateLegacyPagesDelegator implements SearchDelegator<IPage> {
     this.name = SearchDelegatorName.PRIVATE_LEGACY_PAGES;
     this.name = SearchDelegatorName.PRIVATE_LEGACY_PAGES;
   }
   }
 
 
-  async search(_data: SearchableData | null, user, userGroups, option): Promise<ISearchResult<IPage>> {
+  async search(data: SearchableData<MongoQueryTerms>, user, userGroups, option): Promise<ISearchResult<IPage>> {
+    const { terms } = data;
     const { offset, limit } = option;
     const { offset, limit } = option;
 
 
     if (offset == null || limit == null) {
     if (offset == null || limit == null) {
@@ -37,15 +40,20 @@ class PrivateLegacyPagesDelegator implements SearchDelegator<IPage> {
     const findQueryBuilder = new PageQueryBuilder(Page.find());
     const findQueryBuilder = new PageQueryBuilder(Page.find());
     await findQueryBuilder.addConditionAsMigratablePages(user);
     await findQueryBuilder.addConditionAsMigratablePages(user);
 
 
+    this.addConditionByTerms(countQueryBuilder, terms);
+    this.addConditionByTerms(findQueryBuilder, terms);
+
     const total = await countQueryBuilder.query.count();
     const total = await countQueryBuilder.query.count();
 
 
     const _pages: PageDocument[] = await findQueryBuilder
     const _pages: PageDocument[] = await findQueryBuilder
       .addConditionToPagenate(offset, limit)
       .addConditionToPagenate(offset, limit)
       .query
       .query
+      .populate('creator')
       .populate('lastUpdateUser')
       .populate('lastUpdateUser')
       .exec();
       .exec();
 
 
     const pages = _pages.map((page) => {
     const pages = _pages.map((page) => {
+      page.creator = serializeUserSecurely(page.creator);
       page.lastUpdateUser = serializeUserSecurely(page.lastUpdateUser);
       page.lastUpdateUser = serializeUserSecurely(page.lastUpdateUser);
       return page;
       return page;
     });
     });
@@ -59,6 +67,41 @@ class PrivateLegacyPagesDelegator implements SearchDelegator<IPage> {
     };
     };
   }
   }
 
 
+  private addConditionByTerms(builder: PageQueryBuilder, terms: MongoQueryTerms): PageQueryBuilder {
+    const {
+      match, not_match: notMatch, prefix, not_prefix: notPrefix,
+    } = terms;
+
+    if (match.length > 0) {
+      match.forEach(m => builder.addConditionToListByMatch(m));
+    }
+    if (notMatch.length > 0) {
+      notMatch.forEach(nm => builder.addConditionToListByNotMatch(nm));
+    }
+    if (prefix.length > 0) {
+      prefix.forEach(p => builder.addConditionToListByStartWith(p));
+    }
+    if (notPrefix.length > 0) {
+      notPrefix.forEach(np => builder.addConditionToListByNotStartWith(np));
+    }
+
+    return builder;
+  }
+
+  isTermsNormalized(terms: Partial<QueryTerms>): terms is MongoQueryTerms {
+    const entries = Object.entries(terms);
+
+    return !entries.some(([key, val]) => !AVAILABLE_KEYS.includes(key) && typeof val?.length === 'number' && val.length > 0);
+  }
+
+  validateTerms(terms: QueryTerms): UnavailableTermsKey<MongoTermsKey>[] {
+    const entries = Object.entries(terms);
+
+    return entries
+      .filter(([key, val]) => !AVAILABLE_KEYS.includes(key) && val.length > 0)
+      .map(([key]) => key as UnavailableTermsKey<MongoTermsKey>); // use "as": https://github.com/microsoft/TypeScript/issues/41173
+  }
+
 }
 }
 
 
 export default PrivateLegacyPagesDelegator;
 export default PrivateLegacyPagesDelegator;

+ 52 - 33
packages/app/src/server/service/search.ts

@@ -17,6 +17,7 @@ import { PageModel } from '../models/page';
 import { serializeUserSecurely } from '../models/serializers/user-serializer';
 import { serializeUserSecurely } from '../models/serializers/user-serializer';
 
 
 import { ObjectIdLike } from '../interfaces/mongoose-utils';
 import { ObjectIdLike } from '../interfaces/mongoose-utils';
+import { SearchError } from '../models/vo/error-search';
 
 
 // eslint-disable-next-line no-unused-vars
 // eslint-disable-next-line no-unused-vars
 const logger = loggerFactory('growi:service:search');
 const logger = loggerFactory('growi:service:search');
@@ -39,6 +40,10 @@ const normalizeQueryString = (_queryString: string): string => {
   return queryString;
   return queryString;
 };
 };
 
 
+const normalizeNQName = (nqName: string): string => {
+  return nqName.trim();
+};
+
 const findPageListByIds = async(pageIds: ObjectIdLike[], crowi: any) => {
 const findPageListByIds = async(pageIds: ObjectIdLike[], crowi: any) => {
 
 
   const Page = crowi.model('Page') as unknown as PageModel;
   const Page = crowi.model('Page') as unknown as PageModel;
@@ -74,7 +79,7 @@ class SearchService implements SearchQueryParser, SearchResolver {
 
 
   isErrorOccuredOnSearching: boolean | null
   isErrorOccuredOnSearching: boolean | null
 
 
-  fullTextSearchDelegator: any & SearchDelegator
+  fullTextSearchDelegator: any & ElasticsearchDelegator
 
 
   nqDelegators: {[key in SearchDelegatorName]: SearchDelegator}
   nqDelegators: {[key in SearchDelegatorName]: SearchDelegator}
 
 
@@ -124,10 +129,10 @@ class SearchService implements SearchQueryParser, SearchResolver {
     logger.info('No elasticsearch URI is specified so that full text search is disabled.');
     logger.info('No elasticsearch URI is specified so that full text search is disabled.');
   }
   }
 
 
-  generateNQDelegators(defaultDelegator: SearchDelegator): {[key in SearchDelegatorName]: SearchDelegator} {
+  generateNQDelegators(defaultDelegator: ElasticsearchDelegator): {[key in SearchDelegatorName]: SearchDelegator} {
     return {
     return {
       [SearchDelegatorName.DEFAULT]: defaultDelegator,
       [SearchDelegatorName.DEFAULT]: defaultDelegator,
-      [SearchDelegatorName.PRIVATE_LEGACY_PAGES]: new PrivateLegacyPagesDelegator(),
+      [SearchDelegatorName.PRIVATE_LEGACY_PAGES]: new PrivateLegacyPagesDelegator() as unknown as SearchDelegator,
     };
     };
   }
   }
 
 
@@ -218,68 +223,79 @@ class SearchService implements SearchQueryParser, SearchResolver {
     return this.fullTextSearchDelegator.rebuildIndex();
     return this.fullTextSearchDelegator.rebuildIndex();
   }
   }
 
 
-  async parseSearchQuery(_queryString: string): Promise<ParsedQuery> {
-    const regexp = new RegExp(/^\[nq:.+\]$/g); // https://regex101.com/r/FzDUvT/1
-    const replaceRegexp = new RegExp(/\[nq:|\]/g);
+  async parseSearchQuery(queryString: string, nqName: string | null): Promise<ParsedQuery> {
+    // eslint-disable-next-line no-param-reassign
+    queryString = normalizeQueryString(queryString);
 
 
-    const queryString = normalizeQueryString(_queryString);
+    const terms = this.parseQueryString(queryString);
 
 
-    // when Normal Query
-    if (!regexp.test(queryString)) {
-      return { queryString, terms: this.parseQueryString(queryString) };
+    if (nqName == null) {
+      return { queryString, terms };
     }
     }
 
 
-    // when Named Query
-    const name = queryString.replace(replaceRegexp, '');
-    const nq = await NamedQuery.findOne({ name });
+    const nq = await NamedQuery.findOne({ name: normalizeNQName(nqName) });
 
 
     // will delegate to full-text search
     // will delegate to full-text search
     if (nq == null) {
     if (nq == null) {
-      return { queryString, terms: this.parseQueryString(queryString) };
+      logger.debug(`Delegated to full-text search since a named query document did not found. (nqName="${nqName}")`);
+      return { queryString, terms };
     }
     }
 
 
     const { aliasOf, delegatorName } = nq;
     const { aliasOf, delegatorName } = nq;
 
 
-    let parsedQuery;
+    let parsedQuery: ParsedQuery;
     if (aliasOf != null) {
     if (aliasOf != null) {
       parsedQuery = { queryString: normalizeQueryString(aliasOf), terms: this.parseQueryString(aliasOf) };
       parsedQuery = { queryString: normalizeQueryString(aliasOf), terms: this.parseQueryString(aliasOf) };
     }
     }
-    if (delegatorName != null) {
-      parsedQuery = { queryString, delegatorName };
+    else {
+      parsedQuery = { queryString, terms, delegatorName };
     }
     }
 
 
     return parsedQuery;
     return parsedQuery;
   }
   }
 
 
-  async resolve(parsedQuery: ParsedQuery): Promise<[SearchDelegator, SearchableData | null]> {
-    const { queryString, terms, delegatorName } = parsedQuery;
-    if (delegatorName != null) {
-      const nqDelegator = this.nqDelegators[delegatorName];
-      if (nqDelegator != null) {
-        return [nqDelegator, null];
-      }
-    }
+  async resolve(parsedQuery: ParsedQuery): Promise<[SearchDelegator, SearchableData]> {
+    const { queryString, terms, delegatorName = SearchDelegatorName.DEFAULT } = parsedQuery;
+    const nqDeledator = this.nqDelegators[delegatorName];
 
 
     const data = {
     const data = {
       queryString,
       queryString,
-      terms: terms as QueryTerms,
+      terms,
     };
     };
-    return [this.nqDelegators[SearchDelegatorName.DEFAULT], data];
+    return [nqDeledator, data];
   }
   }
 
 
-  async searchKeyword(keyword: string, user, userGroups, searchOpts): Promise<[ISearchResult<unknown>, string]> {
-    let parsedQuery;
+  /**
+   * Throws SearchError if data is corrupted.
+   * @param {SearchableData} data
+   * @param {SearchDelegator} delegator
+   * @throws {SearchError} SearchError
+   */
+  private validateSearchableData(delegator: SearchDelegator, data: SearchableData): void {
+    const { terms } = data;
+
+    if (delegator.isTermsNormalized(terms)) {
+      return;
+    }
+
+    const unavailableTermsKeys = delegator.validateTerms(terms);
+
+    throw new SearchError('The query string includes unavailable terms.', unavailableTermsKeys);
+  }
+
+  async searchKeyword(keyword: string, nqName: string | null, user, userGroups, searchOpts): Promise<[ISearchResult<unknown>, string | null]> {
+    let parsedQuery: ParsedQuery;
     // parse
     // parse
     try {
     try {
-      parsedQuery = await this.parseSearchQuery(keyword);
+      parsedQuery = await this.parseSearchQuery(keyword, nqName);
     }
     }
     catch (err) {
     catch (err) {
       logger.error('Error occurred while parseSearchQuery', err);
       logger.error('Error occurred while parseSearchQuery', err);
       throw err;
       throw err;
     }
     }
 
 
-    let delegator;
-    let data;
+    let delegator: SearchDelegator;
+    let data: SearchableData;
     // resolve
     // resolve
     try {
     try {
       [delegator, data] = await this.resolve(parsedQuery);
       [delegator, data] = await this.resolve(parsedQuery);
@@ -289,7 +305,10 @@ class SearchService implements SearchQueryParser, SearchResolver {
       throw err;
       throw err;
     }
     }
 
 
-    return [await delegator.search(data, user, userGroups, searchOpts), delegator.name];
+    // throws
+    this.validateSearchableData(delegator, data);
+
+    return [await delegator.search(data, user, userGroups, searchOpts), delegator.name ?? null];
   }
   }
 
 
   parseQueryString(queryString: string): QueryTerms {
   parseQueryString(queryString: string): QueryTerms {

+ 1 - 1
packages/app/src/stores/page-listing.tsx

@@ -57,7 +57,7 @@ export const useSWRxPageChildren = (
 
 
 export const useSWRxV5MigrationStatus = (
 export const useSWRxV5MigrationStatus = (
 ): SWRResponse<V5MigrationStatus, Error> => {
 ): SWRResponse<V5MigrationStatus, Error> => {
-  return useSWRImmutable(
+  return useSWR(
     '/pages/v5-migration-status',
     '/pages/v5-migration-status',
     endpoint => apiv3Get(endpoint).then((response) => {
     endpoint => apiv3Get(endpoint).then((response) => {
       return {
       return {

+ 17 - 4
packages/app/src/stores/page.tsx

@@ -1,13 +1,14 @@
 import useSWR, { SWRResponse } from 'swr';
 import useSWR, { SWRResponse } from 'swr';
+import useSWRInfinite, { SWRInfiniteResponse } from 'swr/infinite';
 import useSWRImmutable from 'swr/immutable';
 import useSWRImmutable from 'swr/immutable';
 
 
 import { apiv3Get } from '~/client/util/apiv3-client';
 import { apiv3Get } from '~/client/util/apiv3-client';
 import { HasObjectId } from '~/interfaces/has-object-id';
 import { HasObjectId } from '~/interfaces/has-object-id';
-
 import {
 import {
   IPageInfo, IPageHasId, IPageInfoForOperation, IPageInfoForListing, IDataWithMeta,
   IPageInfo, IPageHasId, IPageInfoForOperation, IPageInfoForListing, IDataWithMeta,
 } from '~/interfaces/page';
 } from '~/interfaces/page';
 import { IPagingResult } from '~/interfaces/paging-result';
 import { IPagingResult } from '~/interfaces/paging-result';
+
 import { apiGet } from '../client/util/apiv1-client';
 import { apiGet } from '../client/util/apiv1-client';
 import { IPageTagsInfo } from '../interfaces/pageTagsInfo';
 import { IPageTagsInfo } from '../interfaces/pageTagsInfo';
 
 
@@ -15,9 +16,9 @@ import { useCurrentPagePath } from './context';
 import { ITermNumberManagerUtil, useTermNumberManager } from './use-static-swr';
 import { ITermNumberManagerUtil, useTermNumberManager } from './use-static-swr';
 
 
 
 
-export const useSWRxPageByPath = (path: string, initialData?: IPageHasId): SWRResponse<IPageHasId, Error> => {
+export const useSWRxPageByPath = (path: string | null | undefined, initialData?: IPageHasId): SWRResponse<IPageHasId, Error> => {
   return useSWR(
   return useSWR(
-    ['/page', path],
+    path != null ? ['/page', path] : null,
     (endpoint, path) => apiv3Get(endpoint, { path }).then(result => result.data.page),
     (endpoint, path) => apiv3Get(endpoint, { path }).then(result => result.data.page),
     {
     {
       fallbackData: initialData,
       fallbackData: initialData,
@@ -33,7 +34,19 @@ export const useSWRxRecentlyUpdated = (): SWRResponse<(IPageHasId)[], Error> =>
     endpoint => apiv3Get<{ pages:(IPageHasId)[] }>(endpoint).then(response => response.data?.pages),
     endpoint => apiv3Get<{ pages:(IPageHasId)[] }>(endpoint).then(response => response.data?.pages),
   );
   );
 };
 };
-
+export const useSWRInifinitexRecentlyUpdated = () : SWRInfiniteResponse<(IPageHasId)[], Error> => {
+  const getKey = (page: number) => {
+    return `/pages/recent?offset=${page + 1}`;
+  };
+  return useSWRInfinite(
+    getKey,
+    (endpoint: string) => apiv3Get<{ pages:(IPageHasId)[] }>(endpoint).then(response => response.data?.pages),
+    {
+      revalidateFirstPage: false,
+      revalidateAll: false,
+    },
+  );
+};
 // eslint-disable-next-line @typescript-eslint/no-unused-vars
 // eslint-disable-next-line @typescript-eslint/no-unused-vars
 export const useSWRxPageList = (path: string | null, pageNumber?: number, termNumber?: number): SWRResponse<IPagingResult<IPageHasId>, Error> => {
 export const useSWRxPageList = (path: string | null, pageNumber?: number, termNumber?: number): SWRResponse<IPagingResult<IPageHasId>, Error> => {
 
 

+ 3 - 15
packages/app/src/stores/search.tsx

@@ -50,8 +50,8 @@ const createSearchQuery = (keyword: string, includeTrashPages: boolean, includeU
   return query;
   return query;
 };
 };
 
 
-export const useSWRxFullTextSearch = (
-    keyword: string | null, configurations: ISearchConfigurations, disableTermManager = false,
+export const useSWRxSearch = (
+    keyword: string | null, nqName: string | null, configurations: ISearchConfigurations, disableTermManager = false,
 ): SWRResponse<IFormattedSearchResult, Error> & { conditions: ISearchConditions } => {
 ): SWRResponse<IFormattedSearchResult, Error> & { conditions: ISearchConditions } => {
   const { data: termNumber } = useFullTextSearchTermManager(disableTermManager);
   const { data: termNumber } = useFullTextSearchTermManager(disableTermManager);
 
 
@@ -81,6 +81,7 @@ export const useSWRxFullTextSearch = (
       return apiGet(
       return apiGet(
         endpoint, {
         endpoint, {
           q: encodeURIComponent(rawQuery),
           q: encodeURIComponent(rawQuery),
+          nq: typeof nqName === 'string' ? encodeURIComponent(nqName) : null,
           limit,
           limit,
           offset,
           offset,
           sort,
           sort,
@@ -100,16 +101,3 @@ export const useSWRxFullTextSearch = (
     },
     },
   };
   };
 };
 };
-
-export const useSWRxNamedQuerySearch = (
-    namedQuery: string, configurations: ISearchConfigurations,
-): SWRResponse<IFormattedSearchResult, Error> & { conditions: ISearchConditions } => {
-
-  const keyword = `[nq:${namedQuery}]`;
-  return useSWRxFullTextSearch(keyword, {
-    ...configurations,
-    includeTrashPages: true,
-    includeUserPages: true,
-  });
-
-};

+ 2 - 2
packages/app/src/styles/_page-tree.scss

@@ -21,7 +21,7 @@ $grw-pagetree-item-padding-left: 10px;
         display: block;
         display: block;
       }
       }
 
 
-      .grw-pagetree-count {
+      .grw-count-badge {
         display: none;
         display: none;
       }
       }
     }
     }
@@ -49,7 +49,7 @@ $grw-pagetree-item-padding-left: 10px;
         display: none;
         display: none;
       }
       }
 
 
-      .grw-pagetree-count {
+      .grw-count-badge {
         min-width: 28px;
         min-width: 28px;
         padding: 0.1rem 0.5rem;
         padding: 0.1rem 0.5rem;
         font-size: 12px;
         font-size: 12px;

+ 12 - 0
packages/app/src/styles/_page_list.scss

@@ -48,6 +48,18 @@ body .page-list {
       i {
       i {
         margin-right: 2px;
         margin-right: 2px;
       }
       }
+
+      .seen-users-count {
+        &.strength-1 {
+          font-weight: bold;
+        }
+        &.strength-2 {
+          font-weight: normal;
+        }
+        &.strength-3 {
+          opacity: 0.6;
+        }
+      }
     }
     }
 
 
     // after second level indent
     // after second level indent

+ 25 - 8
packages/app/src/styles/theme/_apply-colors-dark.scss

@@ -277,19 +277,30 @@ ul.pagination {
   // Pagetree
   // Pagetree
   .grw-pagetree {
   .grw-pagetree {
     @include override-list-group-item-for-pagetree(
     @include override-list-group-item-for-pagetree(
-      $color-list,
+      $gray-200,
       $bgcolor-sidebar-list-group,
       $bgcolor-sidebar-list-group,
-      $color-list-hover,
-      $bgcolor-list-hover,
-      $color-list-active,
-      lighten($bgcolor-list-hover, 5%)
+      $gray-200,
+      lighten($bgcolor-sidebar-context, 8%),
+      $gray-200,
+      lighten($bgcolor-sidebar-context, 15%)
     );
     );
     .grw-pagetree-triangle-btn {
     .grw-pagetree-triangle-btn {
       @include button-outline-svg-icon-variant($secondary, $gray-200);
       @include button-outline-svg-icon-variant($secondary, $gray-200);
     }
     }
-    .grw-pagetree-count {
+    .grw-count-badge {
       color: $gray-400;
       color: $gray-400;
-      background: $gray-700;
+      background: lighten($bgcolor-sidebar-context, 15%);
+    }
+    .btn-page-item-control {
+      @include button-outline-variant($gray-500, $gray-500, $secondary, transparent);
+      @include hover() {
+        background-color: lighten($bgcolor-sidebar-context, 20%);
+      }
+      &:not(:disabled):not(.disabled):active,
+      &:not(:disabled):not(.disabled).active {
+        background-color: lighten($bgcolor-sidebar-context, 34%);
+      }
+      box-shadow: none !important;
     }
     }
   }
   }
   .private-legacy-pages-link {
   .private-legacy-pages-link {
@@ -302,11 +313,12 @@ ul.pagination {
 .btn.btn-page-item-control {
 .btn.btn-page-item-control {
   @include button-outline-variant($gray-500, $gray-500, $secondary, transparent);
   @include button-outline-variant($gray-500, $gray-500, $secondary, transparent);
   @include hover() {
   @include hover() {
-    background-color: $gray-600;
+    background-color: $gray-700;
   }
   }
   &:not(:disabled):not(.disabled):active,
   &:not(:disabled):not(.disabled):active,
   &:not(:disabled):not(.disabled).active {
   &:not(:disabled):not(.disabled).active {
     color: $gray-200;
     color: $gray-200;
+    background-color: $gray-600;
   }
   }
   box-shadow: none !important;
   box-shadow: none !important;
 }
 }
@@ -469,6 +481,11 @@ ul.pagination {
 * grw-side-contents
 * grw-side-contents
 */
 */
 .grw-side-contents-sticky-container {
 .grw-side-contents-sticky-container {
+  .grw-count-badge {
+    color: $gray-400;
+    background: $gray-700;
+  }
+
   .grw-border-vr {
   .grw-border-vr {
     border-color: $border-color-toc;
     border-color: $border-color-toc;
   }
   }

+ 6 - 1
packages/app/src/styles/theme/_apply-colors-light.scss

@@ -192,7 +192,7 @@ $dropdown-link-active-bg: $bgcolor-dropdown-link-active;
     .grw-pagetree-triangle-btn {
     .grw-pagetree-triangle-btn {
       @include button-outline-svg-icon-variant($gray-400, $primary);
       @include button-outline-svg-icon-variant($gray-400, $primary);
     }
     }
-    .grw-pagetree-count {
+    .grw-count-badge {
       color: $gray-500;
       color: $gray-500;
       background: $gray-200;
       background: $gray-200;
     }
     }
@@ -357,6 +357,11 @@ $dropdown-link-active-bg: $bgcolor-dropdown-link-active;
 * grw-side-contents
 * grw-side-contents
 */
 */
 .grw-side-contents-sticky-container {
 .grw-side-contents-sticky-container {
+  .grw-count-badge {
+    color: $primary;
+    background: $gray-200;
+  }
+
   .grw-border-vr {
   .grw-border-vr {
     border-color: $border-color-toc;
     border-color: $border-color-toc;
   }
   }

+ 2 - 0
packages/app/test/cypress/integration/2-basic-features/access-to-page.spec.ts

@@ -95,6 +95,8 @@ context('Access to special pages', () => {
     // select tags
     // select tags
     cy.getByTestid('grw-sidebar-nav-primary-tags').click();
     cy.getByTestid('grw-sidebar-nav-primary-tags').click();
     cy.getByTestid('grw-sidebar-content-tags').should('be.visible');
     cy.getByTestid('grw-sidebar-content-tags').should('be.visible');
+    cy.getByTestid('grw-tags-list').should('be.visible');
+    cy.getByTestid('grw-tags-list').contains('You have no tag, You can set tags on pages');
 
 
     cy.getByTestid('tags-page').should('be.visible');
     cy.getByTestid('tags-page').should('be.visible');
     cy.screenshot(`${ssPrefix}-tags`);
     cy.screenshot(`${ssPrefix}-tags`);

+ 84 - 7
packages/app/test/integration/models/user.test.js

@@ -8,17 +8,53 @@ describe('User', () => {
   let crowi;
   let crowi;
   let User;
   let User;
 
 
+  let adminusertestToBeRemovedId;
+
   beforeAll(async() => {
   beforeAll(async() => {
     crowi = await getInstance();
     crowi = await getInstance();
     User = mongoose.model('User');
     User = mongoose.model('User');
 
 
-    await User.create({
-      name: 'Example for User Test',
-      username: 'usertest',
-      email: 'usertest@example.com',
-      password: 'usertestpass',
-      lang: 'en_US',
-    });
+    await User.insertMany([
+      {
+        name: 'Example for User Test',
+        username: 'usertest',
+        email: 'usertest@example.com',
+        password: 'usertestpass',
+        lang: 'en_US',
+      },
+      {
+        name: 'Admin Example Active',
+        username: 'adminusertest1',
+        email: 'adminusertest1@example.com',
+        password: 'adminusertestpass',
+        admin: true,
+        status: User.STATUS_ACTIVE,
+        lang: 'en_US',
+      },
+      {
+        name: 'Admin Example Suspended',
+        username: 'adminusertest2',
+        email: 'adminusertes2@example.com',
+        password: 'adminusertestpass',
+        admin: true,
+        status: User.STATUS_SUSPENDED,
+        lang: 'en_US',
+      },
+      {
+        name: 'Admin Example to delete',
+        username: 'adminusertestToBeRemoved',
+        email: 'adminusertestToBeRemoved@example.com',
+        password: 'adminusertestpass',
+        admin: true,
+        status: User.STATUS_ACTIVE,
+        lang: 'en_US',
+      },
+    ]);
+
+    // delete adminusertestToBeRemoved
+    const adminusertestToBeRemoved = await User.findOne({ username: 'adminusertestToBeRemoved' });
+    adminusertestToBeRemovedId = adminusertestToBeRemoved._id;
+    await adminusertestToBeRemoved.statusDelete();
   });
   });
 
 
   describe('Create and Find.', () => {
   describe('Create and Find.', () => {
@@ -39,6 +75,47 @@ describe('User', () => {
       });
       });
 
 
     });
     });
+
+  });
+
+  describe('Delete.', () => {
+
+    describe('Deleted users', () => {
+      test('should have correct attributes', async() => {
+        const adminusertestToBeRemoved = await User.findOne({ _id: adminusertestToBeRemovedId });
+
+        expect(adminusertestToBeRemoved).toBeInstanceOf(User);
+        expect(adminusertestToBeRemoved.name).toBe('');
+        expect(adminusertestToBeRemoved.password).toBe('');
+        expect(adminusertestToBeRemoved.googleId).toBeNull();
+        expect(adminusertestToBeRemoved.isGravatarEnabled).toBeFalsy();
+        expect(adminusertestToBeRemoved.image).toBeNull();
+      });
+    });
+  });
+
+  describe('User.findAdmins', () => {
+    test('should retrieves only active users', async() => {
+      const users = await User.findAdmins();
+      const adminusertestActive = users.find(user => user.username === 'adminusertest1');
+      const adminusertestSuspended = users.find(user => user.username === 'adminusertest2');
+      const adminusertestToBeRemoved = users.find(user => user._id.toString() === adminusertestToBeRemovedId.toString());
+
+      expect(adminusertestActive).toBeInstanceOf(User);
+      expect(adminusertestSuspended).toBeUndefined();
+      expect(adminusertestToBeRemoved).toBeUndefined();
+    });
+
+    test('with \'includesInactive\' option should retrieves suspended users', async() => {
+      const users = await User.findAdmins({ status: [User.STATUS_ACTIVE, User.STATUS_SUSPENDED] });
+      const adminusertestActive = users.find(user => user.username === 'adminusertest1');
+      const adminusertestSuspended = users.find(user => user.username === 'adminusertest2');
+      const adminusertestToBeRemoved = users.find(user => user._id.toString() === adminusertestToBeRemovedId.toString());
+
+      expect(adminusertestActive).toBeInstanceOf(User);
+      expect(adminusertestSuspended).toBeInstanceOf(User);
+      expect(adminusertestToBeRemoved).toBeUndefined();
+    });
   });
   });
 
 
   describe('User Utilities', () => {
   describe('User Utilities', () => {

+ 39 - 9
packages/app/test/integration/service/search/search-service.test.js

@@ -1,3 +1,7 @@
+/*
+ * !! TODO: https://redmine.weseek.co.jp/issues/92050 Fix & adjust test !!
+ */
+
 import mongoose from 'mongoose';
 import mongoose from 'mongoose';
 
 
 import SearchService from '~/server/service/search';
 import SearchService from '~/server/service/search';
@@ -68,20 +72,32 @@ describe('SearchService test', () => {
   describe('parseSearchQuery()', () => {
   describe('parseSearchQuery()', () => {
 
 
     test('should return result with delegatorName', async() => {
     test('should return result with delegatorName', async() => {
-      const queryString = '[nq:named_query1]';
-      const parsedQuery = await searchService.parseSearchQuery(queryString);
+      const queryString = '/';
+      const nqName = 'named_query1';
+      const parsedQuery = await searchService.parseSearchQuery(queryString, nqName);
 
 
       const expected = {
       const expected = {
         queryString,
         queryString,
         delegatorName: PRIVATE_LEGACY_PAGES,
         delegatorName: PRIVATE_LEGACY_PAGES,
+        terms: {
+          match: ['/'],
+          not_match: [],
+          phrase: [],
+          not_phrase: [],
+          prefix: [],
+          not_prefix: [],
+          tag: [],
+          not_tag: [],
+        },
       };
       };
 
 
       expect(parsedQuery).toStrictEqual(expected);
       expect(parsedQuery).toStrictEqual(expected);
     });
     });
 
 
     test('should return result with expanded aliasOf value', async() => {
     test('should return result with expanded aliasOf value', async() => {
-      const queryString = '[nq:named_query2]';
-      const parsedQuery = await searchService.parseSearchQuery(queryString);
+      const queryString = '/';
+      const nqName = 'named_query2';
+      const parsedQuery = await searchService.parseSearchQuery(queryString, nqName);
       const expected = {
       const expected = {
         queryString: dummyAliasOf,
         queryString: dummyAliasOf,
         terms: {
         terms: {
@@ -125,17 +141,30 @@ describe('SearchService test', () => {
     });
     });
 
 
     test('should resolve as custom search delegator', async() => {
     test('should resolve as custom search delegator', async() => {
-      const queryString = '[nq:named_query1]';
+      const queryString = '/';
       const parsedQuery = {
       const parsedQuery = {
         queryString,
         queryString,
         delegatorName: PRIVATE_LEGACY_PAGES,
         delegatorName: PRIVATE_LEGACY_PAGES,
+        terms: {
+          match: ['/'],
+          not_match: [],
+          phrase: [],
+          not_phrase: [],
+          prefix: [],
+          not_prefix: [],
+          tag: [],
+          not_tag: [],
+        },
       };
       };
 
 
       const [delegator, data] = await searchService.resolve(parsedQuery);
       const [delegator, data] = await searchService.resolve(parsedQuery);
 
 
-      const expectedData = null;
+      const expectedData = {
+        queryString: '/',
+        terms: parsedQuery.terms,
+      };
 
 
-      expect(data).toBe(expectedData);
+      expect(data).toStrictEqual(expectedData);
       expect(typeof delegator.search).toBe('function');
       expect(typeof delegator.search).toBe('function');
     });
     });
   });
   });
@@ -186,9 +215,10 @@ describe('SearchService test', () => {
         },
         },
       ]);
       ]);
 
 
-      const queryString = '[nq:named_query1]';
+      const queryString = '/';
+      const nqName = 'named_query1';
 
 
-      const [result, delegatorName] = await searchService.searchKeyword(queryString, testUser1, null, { offset: 0, limit: 100 });
+      const [result, delegatorName] = await searchService.searchKeyword(queryString, nqName, testUser1, null, { offset: 0, limit: 100 });
 
 
       const resultPaths = result.data.map(page => page.path);
       const resultPaths = result.data.map(page => page.path);
       const flag = resultPaths.includes('/user1') && resultPaths.includes('/user1_owner') && resultPaths.includes('/user2_public');
       const flag = resultPaths.includes('/user1') && resultPaths.includes('/user1_owner') && resultPaths.includes('/user2_public');

+ 1 - 1
packages/codemirror-textlint/package.json

@@ -1,6 +1,6 @@
 {
 {
   "name": "@growi/codemirror-textlint",
   "name": "@growi/codemirror-textlint",
-  "version": "5.0.3-RC.0",
+  "version": "5.0.4-RC.0",
   "license": "MIT",
   "license": "MIT",
   "main": "dist/index.js",
   "main": "dist/index.js",
   "scripts": {
   "scripts": {

+ 1 - 1
packages/core/package.json

@@ -1,6 +1,6 @@
 {
 {
   "name": "@growi/core",
   "name": "@growi/core",
-  "version": "5.0.3-RC.0",
+  "version": "5.0.4-RC.0",
   "description": "GROWI Core Libraries",
   "description": "GROWI Core Libraries",
   "license": "MIT",
   "license": "MIT",
   "keywords": [
   "keywords": [

+ 1 - 1
packages/plugin-attachment-refs/package.json

@@ -1,6 +1,6 @@
 {
 {
   "name": "@growi/plugin-attachment-refs",
   "name": "@growi/plugin-attachment-refs",
-  "version": "5.0.3-RC.0",
+  "version": "5.0.4-RC.0",
   "description": "GROWI Plugin to add ref/refimg/refs/refsimg tags",
   "description": "GROWI Plugin to add ref/refimg/refs/refsimg tags",
   "license": "MIT",
   "license": "MIT",
   "keywords": [
   "keywords": [

+ 1 - 1
packages/plugin-lsx/package.json

@@ -1,6 +1,6 @@
 {
 {
   "name": "@growi/plugin-lsx",
   "name": "@growi/plugin-lsx",
-  "version": "5.0.3-RC.0",
+  "version": "5.0.4-RC.0",
   "description": "GROWI plugin to list pages",
   "description": "GROWI plugin to list pages",
   "license": "MIT",
   "license": "MIT",
   "keywords": [
   "keywords": [

+ 4 - 1
packages/plugin-lsx/src/client/js/components/Lsx.jsx

@@ -1,9 +1,10 @@
 
 
+import React from 'react';
+
 import * as url from 'url';
 import * as url from 'url';
 
 
 import { pathUtils } from '@growi/core';
 import { pathUtils } from '@growi/core';
 import PropTypes from 'prop-types';
 import PropTypes from 'prop-types';
-import React from 'react';
 
 
 // eslint-disable-next-line no-unused-vars
 // eslint-disable-next-line no-unused-vars
 import styles from '../../css/index.css';
 import styles from '../../css/index.css';
@@ -59,6 +60,8 @@ export class Lsx extends React.Component {
     try {
     try {
       const res = await this.props.appContainer.apiGet('/plugins/lsx', { pagePath, options: lsxContext.options });
       const res = await this.props.appContainer.apiGet('/plugins/lsx', { pagePath, options: lsxContext.options });
 
 
+      lsxContext.activeUsersCount = res.activeUsersCount;
+
       if (res.ok) {
       if (res.ok) {
         const nodeTree = this.generatePageNodeTree(pagePath, res.pages);
         const nodeTree = this.generatePageNodeTree(pagePath, res.pages);
         this.setState({ nodeTree });
         this.setState({ nodeTree });

+ 3 - 2
packages/plugin-lsx/src/client/js/components/LsxPageList/LsxPage.jsx

@@ -1,8 +1,8 @@
+import React from 'react';
 
 
 import { pathUtils } from '@growi/core';
 import { pathUtils } from '@growi/core';
 import { PageListMeta } from '@growi/ui';
 import { PageListMeta } from '@growi/ui';
 import PropTypes from 'prop-types';
 import PropTypes from 'prop-types';
-import React from 'react';
 
 
 import { LsxContext } from '../../util/LsxContext';
 import { LsxContext } from '../../util/LsxContext';
 import { PageNode } from '../PageNode';
 import { PageNode } from '../PageNode';
@@ -79,6 +79,7 @@ export class LsxPage extends React.Component {
 
 
   render() {
   render() {
     const pageNode = this.props.pageNode;
     const pageNode = this.props.pageNode;
+    const { activeUsersCount } = this.props.lsxContext;
 
 
     // create PagePath element
     // create PagePath element
     let pagePathNode = <PagePathWrapper pagePath={pageNode.pagePath} isExists={this.state.isExists} />;
     let pagePathNode = <PagePathWrapper pagePath={pageNode.pagePath} isExists={this.state.isExists} />;
@@ -87,7 +88,7 @@ export class LsxPage extends React.Component {
     }
     }
 
 
     // create PageListMeta element
     // create PageListMeta element
-    const pageListMeta = (this.state.isExists) ? <PageListMeta page={pageNode.page} /> : '';
+    const pageListMeta = (this.state.isExists) ? <PageListMeta page={pageNode.page} activeUsersCount={activeUsersCount} /> : '';
 
 
     return (
     return (
       <li className="page-list-li">
       <li className="page-list-li">

+ 11 - 1
packages/plugin-lsx/src/server/routes/lsx.js

@@ -162,6 +162,7 @@ class Lsx {
 
 
 module.exports = (crowi, app) => {
 module.exports = (crowi, app) => {
   const Page = crowi.model('Page');
   const Page = crowi.model('Page');
+  const User = crowi.model('User');
   const ApiResponse = crowi.require('../util/apiResponse');
   const ApiResponse = crowi.require('../util/apiResponse');
   const actions = {};
   const actions = {};
 
 
@@ -207,6 +208,15 @@ module.exports = (crowi, app) => {
 
 
     const builder = await generateBaseQueryBuilder(pagePath, user);
     const builder = await generateBaseQueryBuilder(pagePath, user);
 
 
+    // count active users
+    let activeUsersCount;
+    try {
+      activeUsersCount = await User.countListByStatus(User.STATUS_ACTIVE);
+    }
+    catch (error) {
+      return res.json(ApiResponse.error(error));
+    }
+
     let query = builder.query;
     let query = builder.query;
     try {
     try {
       // depth
       // depth
@@ -227,7 +237,7 @@ module.exports = (crowi, app) => {
       query = Lsx.addSortCondition(query, pagePath, options.sort, options.reverse);
       query = Lsx.addSortCondition(query, pagePath, options.sort, options.reverse);
 
 
       const pages = await query.exec();
       const pages = await query.exec();
-      res.json(ApiResponse.success({ pages }));
+      res.json(ApiResponse.success({ pages, activeUsersCount }));
     }
     }
     catch (error) {
     catch (error) {
       return res.json(ApiResponse.error(error));
       return res.json(ApiResponse.error(error));

+ 1 - 1
packages/plugin-pukiwiki-like-linker/package.json

@@ -1,6 +1,6 @@
 {
 {
   "name": "@growi/plugin-pukiwiki-like-linker",
   "name": "@growi/plugin-pukiwiki-like-linker",
-  "version": "5.0.3-RC.0",
+  "version": "5.0.4-RC.0",
   "description": "GROWI plugin to add PukiwikiLikeLinker",
   "description": "GROWI plugin to add PukiwikiLikeLinker",
   "license": "MIT",
   "license": "MIT",
   "keywords": [
   "keywords": [

+ 1 - 1
packages/slack/package.json

@@ -1,6 +1,6 @@
 {
 {
   "name": "@growi/slack",
   "name": "@growi/slack",
-  "version": "5.0.3-RC.0",
+  "version": "5.0.4-RC.0",
   "license": "MIT",
   "license": "MIT",
   "main": "dist/index.js",
   "main": "dist/index.js",
   "typings": "dist/index.d.ts",
   "typings": "dist/index.d.ts",

+ 2 - 2
packages/slackbot-proxy/package.json

@@ -1,6 +1,6 @@
 {
 {
   "name": "@growi/slackbot-proxy",
   "name": "@growi/slackbot-proxy",
-  "version": "5.0.3-slackbot-proxy.0",
+  "version": "5.0.4-slackbot-proxy.0",
   "license": "MIT",
   "license": "MIT",
   "scripts": {
   "scripts": {
     "build": "yarn tsc && tsc-alias -p tsconfig.build.json",
     "build": "yarn tsc && tsc-alias -p tsconfig.build.json",
@@ -25,7 +25,7 @@
   },
   },
   "dependencies": {
   "dependencies": {
     "@godaddy/terminus": "^4.9.0",
     "@godaddy/terminus": "^4.9.0",
-    "@growi/slack": "^5.0.3-RC.0",
+    "@growi/slack": "^5.0.4-RC.0",
     "@slack/oauth": "^2.0.1",
     "@slack/oauth": "^2.0.1",
     "@slack/web-api": "^6.2.4",
     "@slack/web-api": "^6.2.4",
     "@tsed/common": "^6.43.0",
     "@tsed/common": "^6.43.0",

+ 1 - 1
packages/ui/package.json

@@ -1,6 +1,6 @@
 {
 {
   "name": "@growi/ui",
   "name": "@growi/ui",
-  "version": "5.0.3-RC.0",
+  "version": "5.0.4-RC.0",
   "description": "GROWI UI Libraries",
   "description": "GROWI UI Libraries",
   "license": "MIT",
   "license": "MIT",
   "keywords": [
   "keywords": [

+ 50 - 12
packages/ui/src/components/PagePath/PageListMeta.tsx

@@ -1,5 +1,7 @@
 import React, { FC } from 'react';
 import React, { FC } from 'react';
 
 
+import assert from 'assert';
+
 import { IPageHasId } from '@growi/app/src/interfaces/page';
 import { IPageHasId } from '@growi/app/src/interfaces/page';
 import { templateChecker, pagePathUtils } from '@growi/core';
 import { templateChecker, pagePathUtils } from '@growi/core';
 
 
@@ -8,16 +10,62 @@ import { FootstampIcon } from '../SearchPage/FootstampIcon';
 const { isTopPage } = pagePathUtils;
 const { isTopPage } = pagePathUtils;
 const { checkTemplatePath } = templateChecker;
 const { checkTemplatePath } = templateChecker;
 
 
+
+const SEEN_USERS_HIDE_THRES__ACTIVE_USERS_COUNT = 5;
+const MIN_STRENGTH_LEVEL = -3;
+
+type SeenUsersCountProps = {
+  count: number,
+  activeUsersCount?: number,
+  shouldSpaceOutIcon?: boolean,
+}
+
+const SeenUsersCount = (props: SeenUsersCountProps): JSX.Element => {
+
+  const { count, shouldSpaceOutIcon, activeUsersCount } = props;
+
+  if (count === 0) {
+    return <></>;
+  }
+
+  if (activeUsersCount != null && activeUsersCount <= SEEN_USERS_HIDE_THRES__ACTIVE_USERS_COUNT) {
+    return <></>;
+  }
+
+  const strengthLevel = Math.log(count / (activeUsersCount ?? count)); // Max: 0
+
+  if (strengthLevel <= MIN_STRENGTH_LEVEL) {
+    return <></>;
+  }
+
+  assert(strengthLevel > MIN_STRENGTH_LEVEL); // [0, MIN_STRENGTH_LEVEL)
+
+  let strengthClass = '';
+  if (strengthLevel < 0) {
+    strengthClass = `strength-${Math.ceil(strengthLevel * -1)}`; // strength-{0, 1, 2, 3}
+  }
+
+  return (
+    <span className={`seen-users-count ${shouldSpaceOutIcon ? 'mr-3' : ''} ${strengthClass}`}>
+      <i className="footstamp-icon"><FootstampIcon /></i>
+      {count}
+    </span>
+  );
+
+};
+
+
 type PageListMetaProps = {
 type PageListMetaProps = {
   page: IPageHasId,
   page: IPageHasId,
   likerCount?: number,
   likerCount?: number,
   bookmarkCount?: number,
   bookmarkCount?: number,
   shouldSpaceOutIcon?: boolean,
   shouldSpaceOutIcon?: boolean,
+  activeUsersCount?: number,
 }
 }
 
 
 export const PageListMeta: FC<PageListMetaProps> = (props: PageListMetaProps) => {
 export const PageListMeta: FC<PageListMetaProps> = (props: PageListMetaProps) => {
 
 
-  const { page, shouldSpaceOutIcon } = props;
+  const { page, shouldSpaceOutIcon, activeUsersCount } = props;
 
 
   // top check
   // top check
   let topLabel;
   let topLabel;
@@ -46,16 +94,6 @@ export const PageListMeta: FC<PageListMetaProps> = (props: PageListMetaProps) =>
     locked = <span className={`${shouldSpaceOutIcon ? 'mr-3' : ''}`}><i className="icon-lock" /></span>;
     locked = <span className={`${shouldSpaceOutIcon ? 'mr-3' : ''}`}><i className="icon-lock" /></span>;
   }
   }
 
 
-  let seenUserCount;
-  if (page.seenUsers.length > 0) {
-    seenUserCount = (
-      <span className={`${shouldSpaceOutIcon ? 'mr-3' : ''}`}>
-        <i className="footstamp-icon"><FootstampIcon /></i>
-        {page.seenUsers.length}
-      </span>
-    );
-  }
-
   let bookmarkCount;
   let bookmarkCount;
   if (props.bookmarkCount != null && props.bookmarkCount > 0) {
   if (props.bookmarkCount != null && props.bookmarkCount > 0) {
     bookmarkCount = <span className={`${shouldSpaceOutIcon ? 'mr-3' : ''}`}><i className="fa fa-bookmark-o" />{props.bookmarkCount}</span>;
     bookmarkCount = <span className={`${shouldSpaceOutIcon ? 'mr-3' : ''}`}><i className="fa fa-bookmark-o" />{props.bookmarkCount}</span>;
@@ -65,7 +103,7 @@ export const PageListMeta: FC<PageListMetaProps> = (props: PageListMetaProps) =>
     <span className="page-list-meta">
     <span className="page-list-meta">
       {topLabel}
       {topLabel}
       {templateLabel}
       {templateLabel}
-      {seenUserCount}
+      <SeenUsersCount count={page.seenUsers.length} activeUsersCount={activeUsersCount} />
       {commentCount}
       {commentCount}
       {likerCount}
       {likerCount}
       {locked}
       {locked}