Răsfoiți Sursa

Merge branch 'master' into feat/gw7759-change-logo-on-top-navigation-bar

Mudana-Grune 4 ani în urmă
părinte
comite
78e986d78d
100 a modificat fișierele cu 1744 adăugiri și 673 ștergeri
  1. 7 0
      .devcontainer/Dockerfile
  2. 27 0
      .eslintrc.js
  3. 2 0
      .github/workflows/ci-app-prod.yml
  4. 1 1
      .github/workflows/ci-app.yml
  5. 2 1
      .github/workflows/draft-release.yml
  6. 3 2
      .github/workflows/pr-to-master.yml
  7. 1 1
      .github/workflows/release-slackbot-proxy.yml
  8. 14 19
      .github/workflows/reusable-app-prod.yml
  9. 5 1
      .github/workflows/reusable-app-reg-suit.yml
  10. 63 1
      CHANGELOG.md
  11. 1 1
      lerna.json
  12. 2 2
      package.json
  13. 2 2
      packages/app/cypress.json
  14. 2 2
      packages/app/docker/README.md
  15. 8 8
      packages/app/package.json
  16. 0 0
      packages/app/resource/locales/en_US/notifications/passwordResetSuccessful.txt
  17. 1 0
      packages/app/resource/locales/en_US/translation.json
  18. 1 0
      packages/app/resource/locales/ja_JP/translation.json
  19. 0 0
      packages/app/resource/locales/zh_CN/notifications/passwordResetSuccessful.txt
  20. 1 0
      packages/app/resource/locales/zh_CN/translation.json
  21. 35 36
      packages/app/src/components/Common/Dropdown/PageItemControl.tsx
  22. 7 7
      packages/app/src/components/Fab.jsx
  23. 7 3
      packages/app/src/components/Me/ApiSettings.jsx
  24. 5 4
      packages/app/src/components/Me/AssociateModal.jsx
  25. 10 3
      packages/app/src/components/Me/BasicInfoSettings.jsx
  26. 11 4
      packages/app/src/components/Me/EditorSettings.tsx
  27. 12 4
      packages/app/src/components/Me/ExternalAccountLinkedMe.jsx
  28. 4 2
      packages/app/src/components/Me/InAppNotificationSettings.tsx
  29. 8 3
      packages/app/src/components/Me/PasswordSettings.jsx
  30. 8 4
      packages/app/src/components/Me/PersonalSettings.jsx
  31. 4 3
      packages/app/src/components/Me/UserSettings.jsx
  32. 7 9
      packages/app/src/components/Navbar/GrowiNavbar.tsx
  33. 4 3
      packages/app/src/components/Navbar/PageEditorModeManager.jsx
  34. 7 10
      packages/app/src/components/Navbar/PersonalDropdown.jsx
  35. 5 3
      packages/app/src/components/Page/DisplaySwitcher.tsx
  36. 5 3
      packages/app/src/components/PageAttachment.jsx
  37. 23 30
      packages/app/src/components/PageList/PageListItemL.tsx
  38. 1 1
      packages/app/src/components/PrivateLegacyPages.tsx
  39. 0 89
      packages/app/src/components/ShareLink/ShareLinkList.jsx
  40. 113 0
      packages/app/src/components/ShareLink/ShareLinkList.tsx
  41. 2 0
      packages/app/src/components/Sidebar/Tag.tsx
  42. 2 6
      packages/app/src/components/TagCloudBox.tsx
  43. 0 126
      packages/app/src/components/TagsList.jsx
  44. 85 0
      packages/app/src/components/TagsList.tsx
  45. 8 0
      packages/app/src/interfaces/tag.ts
  46. 25 0
      packages/app/src/migrations/20220411114257-set-sparse-option-to-slack-member-id.js
  47. 2 2
      packages/app/src/server/crowi/index.js
  48. 5 4
      packages/app/src/server/models/obsolete-page.js
  49. 23 14
      packages/app/src/server/models/page.ts
  50. 14 5
      packages/app/src/server/models/user.js
  51. 4 3
      packages/app/src/server/routes/apiv3/attachment.js
  52. 10 7
      packages/app/src/server/routes/apiv3/forgot-password.js
  53. 12 8
      packages/app/src/server/routes/apiv3/share-links.js
  54. 6 3
      packages/app/src/server/routes/user-activation.ts
  55. 1 1
      packages/app/src/server/service/page-grant.ts
  56. 52 20
      packages/app/src/server/service/page.ts
  57. 13 25
      packages/app/src/server/service/search-delegator/elasticsearch.ts
  58. 0 1
      packages/app/src/server/service/user-group.ts
  59. 1 1
      packages/app/src/server/views/layout-growi/user_page.html
  60. 1 1
      packages/app/src/server/views/layout/layout.html
  61. 14 0
      packages/app/src/stores/tag.tsx
  62. 14 16
      packages/app/src/stores/ui.tsx
  63. 5 0
      packages/app/src/styles/_page-accessories-control.scss
  64. 12 0
      packages/app/src/styles/_page_list.scss
  65. 3 2
      packages/app/src/utils/project-dir-utils.ts
  66. 4 2
      packages/app/test/cypress/integration/2-basic-features/access-to-page.spec.ts
  67. 2 0
      packages/app/test/cypress/integration/3-search/search.spec.ts
  68. 0 32
      packages/app/test/cypress/integration/6-home-settings/access-home.spec.ts
  69. 0 85
      packages/app/test/cypress/integration/6-home-settings/access-user-settings.spec.ts
  70. 114 0
      packages/app/test/cypress/integration/6-home/home.spec.ts
  71. 11 0
      packages/app/test/cypress/plugins/index.ts
  72. 84 7
      packages/app/test/integration/models/user.test.js
  73. 376 0
      packages/app/test/integration/models/v5.page.test.js
  74. 77 0
      packages/app/test/integration/service/user-groups.test.ts
  75. 283 3
      packages/app/test/integration/service/v5.migration.test.js
  76. 1 0
      packages/app/test/integration/setup-crowi.js
  77. 1 1
      packages/codemirror-textlint/package.json
  78. 2 2
      packages/codemirror-textlint/src/index.ts
  79. 1 1
      packages/core/package.json
  80. 3 2
      packages/core/src/index.js
  81. 1 1
      packages/core/src/test/plugin/service/tag-cache-manager.test.js
  82. 7 0
      packages/core/src/utils/browser-utils.ts
  83. 1 0
      packages/core/src/utils/page-path-utils.ts
  84. 1 1
      packages/plugin-attachment-refs/package.json
  85. 1 1
      packages/plugin-attachment-refs/src/client-entry.js
  86. 4 5
      packages/plugin-attachment-refs/src/client/js/components/AttachmentList.jsx
  87. 1 2
      packages/plugin-attachment-refs/src/client/js/components/ExtractedAttachments.jsx
  88. 3 3
      packages/plugin-attachment-refs/src/client/js/util/Interceptor/RefsPostRenderInterceptor.js
  89. 1 1
      packages/plugin-lsx/package.json
  90. 1 1
      packages/plugin-lsx/src/client-entry.js
  91. 6 3
      packages/plugin-lsx/src/client/js/components/Lsx.jsx
  92. 3 2
      packages/plugin-lsx/src/client/js/components/LsxPageList/LsxListView.jsx
  93. 3 3
      packages/plugin-lsx/src/client/js/components/LsxPageList/LsxPage.jsx
  94. 2 2
      packages/plugin-lsx/src/client/js/components/LsxPageList/PagePathWrapper.jsx
  95. 2 2
      packages/plugin-lsx/src/client/js/util/Interceptor/LsxPostRenderInterceptor.js
  96. 1 1
      packages/plugin-lsx/src/client/js/util/Interceptor/LsxPreRenderInterceptor.js
  97. 11 1
      packages/plugin-lsx/src/server/routes/lsx.js
  98. 1 1
      packages/plugin-pukiwiki-like-linker/package.json
  99. 1 1
      packages/slack/package.json
  100. 1 0
      packages/slack/src/interfaces/growi-interaction-processor.ts

+ 7 - 0
.devcontainer/Dockerfile

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

+ 27 - 0
.eslintrc.js

@@ -16,6 +16,33 @@ module.exports = {
   ],
   rules: {
     'import/prefer-default-export': 'off',
+    'import/order': [
+      'warn',
+      {
+        pathGroups: [
+          {
+            pattern: 'react',
+            group: 'builtin',
+            position: 'before',
+          },
+          {
+            pattern: '^/**',
+            group: 'parent',
+            position: 'before',
+          },
+          {
+            pattern: '~/**',
+            group: 'parent',
+            position: 'before',
+          },
+        ],
+        alphabetize: {
+          order: 'asc',
+        },
+        pathGroupsExcludedImportTypes: ['react'],
+        'newlines-between': 'always',
+      },
+    ],
     '@typescript-eslint/no-explicit-any': 'off',
     indent: [
       'error',

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

@@ -24,6 +24,7 @@ jobs:
     uses: weseek/growi/.github/workflows/reusable-app-prod.yml@master
     with:
       node-version: 16.x
+      skip-cypress: ${{ contains( github.event.pull_request.labels.*.name, 'dependencies' ) && contains( github.event.pull_request.labels.*.name, 'github_actions' ) }}
       cypress-report-artifact-name: Cypress report
     secrets:
       SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
@@ -38,6 +39,7 @@ jobs:
 
     with:
       node-version: 16.x
+      skip-reg-suit: ${{ contains( github.event.pull_request.labels.*.name, 'dependencies' ) && contains( github.event.pull_request.labels.*.name, 'github_actions' ) }}
       cypress-report-artifact-name: Cypress report
     secrets:
       REG_NOTIFY_GITHUB_PLUGIN_CLIENTID: ${{ secrets.REG_NOTIFY_GITHUB_PLUGIN_CLIENTID }}

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

@@ -102,7 +102,7 @@ jobs:
           MONGO_URI: mongodb://localhost:${{ job.services.mongodb.ports['27017'] }}/growi_test
 
       - name: Upload coverage report as artifact
-        uses: actions/upload-artifact@v2
+        uses: actions/upload-artifact@v3
         with:
           name: Coverage Report
           path: packages/app/coverage

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

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

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

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

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

@@ -81,7 +81,7 @@ jobs:
         mv /tmp/.buildx-cache-new /tmp/.buildx-cache
 
     - name: Add tag
-      uses: anothrNick/github-tag-action@1.36.0
+      uses: anothrNick/github-tag-action@1.38.0
       env:
         GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
         CUSTOM_TAG: v${{ steps.package-json.outputs.packageVersion }}

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

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

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

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

+ 63 - 1
CHANGELOG.md

@@ -1,9 +1,57 @@
 # Changelog
 
-## [Unreleased](https://github.com/weseek/growi/compare/v5.0.0...HEAD)
+## [Unreleased](https://github.com/weseek/growi/compare/v5.0.2...HEAD)
 
 *Please do not manually update this file. We've automated the process.*
 
+## [v5.0.2](https://github.com/weseek/growi/compare/v5.0.1...v5.0.2) - 2022-04-15
+
+### 🐛 Bug Fixes
+
+- fix: Edit button to open built-in editor does not work when HackMD is disabled (#5719) @yuki-takei
+- fix: Share link list occures error when related page is not found (#5718) @yuki-takei
+
+## [v5.0.1](https://github.com/weseek/growi/compare/v5.0.0...v5.0.1) - 2022-04-15
+
+### 💎 Features
+
+- feat: Input Slack member ID (#5412) @mudana-grune
+- feat: Remove child group from parent group (#5600) @miya
+
+### 🚀 Improvement
+
+- imprv: Add spinner to tag sidebar (#5700) @miya
+- imprv: Adjust pagelist and comment position (#5682) @Yohei-Shiina
+- imprv: Adjust layout for PageTree Descendant Count (#5666) @miya
+- imprv: adjust spaces in page item control and subnav btn (#5655) @Yohei-Shiina
+- imprv: Clickable area of PageListItemL (#5665) @yuki-takei
+- imprv: Add an expiration date for the link in the email (#5660) @miya
+- imprv: remove min-width from search-sort-option-btn (#5656) @kaoritokashiki
+
+### 🐛 Bug Fixes
+
+- fix: Correction of expiredAt attached to email (#5715) @miya
+- fix: Normalize parent so it does not include siblings (#5678) @hakumizuki
+- fix: Prevent auto completing email with username stored by browser in /me page (#5702) @Yohei-Shiina
+- fix: Do not include granted users if change page permission restricted (#5693) @miya
+- fix: Do not include in search results if the page grant is restricted (#5691) @miya
+- fix: Password reset gives error update password failed when submitting a new password (#5685) @kaoritokashiki
+- fix: Cannot register new users (#5683) @kaoritokashiki
+- fix: Sync change of count for both like and bookmark in search page (#5667) @Yohei-Shiina
+- imprv: Adjust layout for PageTree Descendant Count (#5666) @miya
+- fix: HackMD disabled tooltip on mobile (#5658) @yuki-takei
+- fix: One Time Token is not available (#5654) @miya
+- fix: Page items disappear when dnd (#5651) @miya
+
+### 🧰 Maintenance
+
+- ci(deps): bump anothrNick/github-tag-action from 1.36.0 to 1.38.0 (#5271) @dependabot
+- ci(deps): bump amannn/action-semantic-pull-request from 3.4.5 to 4.2.0 (#5627) @dependabot
+- ci(deps): bump actions/upload-artifact from 2 to 3 (#5686) @dependabot
+- ci(deps): bump actions/download-artifact from 2 to 3 (#5687) @dependabot
+- support: Migration for setting sparce option to slack member id (#5694) @kaoritokashiki
+- support: Update eslint-config-weseek (#5673) @yuki-takei
+
 ## [v5.0.0](https://github.com/weseek/growi/compare/v4.5.15...v5.0.0) - 2022-04-01
 
 ### 💎 Features
@@ -54,6 +102,20 @@
 - support: update nanoid yarn.lock v3.1.30 to v3.2.0 (#5216) @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
 
 ### 💎 Features

+ 1 - 1
lerna.json

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

+ 2 - 2
package.json

@@ -1,6 +1,6 @@
 {
   "name": "growi",
-  "version": "5.0.1-RC.0",
+  "version": "5.0.3-RC.0",
   "description": "Team collaboration software using markdown",
   "tags": [
     "wiki",
@@ -59,7 +59,7 @@
     "@typescript-eslint/parser": "^4.28.5",
     "cypress": "^9.2.0",
     "eslint": "^7.31.0",
-    "eslint-config-weseek": "^2.0.0",
+    "eslint-config-weseek": "^2.1.0",
     "eslint-import-resolver-typescript": "^2.4.0",
     "eslint-plugin-import": "^2.23.4",
     "eslint-plugin-jest": "^24.3.2",

+ 2 - 2
packages/app/cypress.json

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

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

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

+ 8 - 8
packages/app/package.json

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

+ 0 - 0
packages/app/resource/locales/en_US/notifications/PasswordResetSuccessful.txt → packages/app/resource/locales/en_US/notifications/passwordResetSuccessful.txt


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

@@ -171,6 +171,7 @@
   "you_can_not_create_page_with_this_name": "You can not create page with this name",
   "not_allowed_to_see_this_page": "You cannot see this page",
   "Confirm": "Confirm",
+  "Successfully requested": "Successfully requested.",
   "personal_dropdown": {
     "home": "Home",
     "settings": "Settings",

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

@@ -173,6 +173,7 @@
   "you_can_not_create_page_with_this_name": "この名前でページを作成することはできません",
   "not_allowed_to_see_this_page": "このページは閲覧できません",
   "Confirm": "確認",
+  "Successfully requested": "正常に処理を受け付けました",
   "personal_dropdown": {
     "home": "ホーム",
     "settings": "設定",

+ 0 - 0
packages/app/resource/locales/zh_CN/notifications/PasswordResetSuccessful.txt → packages/app/resource/locales/zh_CN/notifications/passwordResetSuccessful.txt


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

@@ -179,6 +179,7 @@
   "you_can_not_create_page_with_this_name": "您无法使用此名称创建页面",
   "not_allowed_to_see_this_page": "你不能看到这个页面",
   "Confirm": "确定",
+  "Successfully requested": "进程成功接受",
 	"form_validation": {
 		"error_message": "有些值不正确",
 		"required": "%s 是必需的",

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

+ 7 - 10
packages/app/src/components/Navbar/PersonalDropdown.jsx

@@ -1,18 +1,13 @@
 import React, { useState, useCallback } from 'react';
-import PropTypes from 'prop-types';
 
+import { UserPicture } from '@growi/ui';
+import PropTypes from 'prop-types';
 import { withTranslation } from 'react-i18next';
-
 import { UncontrolledTooltip } from 'reactstrap';
 
-import { UserPicture } from '@growi/ui';
 
-import { useUserUISettings } from '~/client/services/user-ui-settings';
 import AppContainer from '~/client/services/AppContainer';
-
-import { withUnstatedContainers } from '../UnstatedUtils';
-import { usePreferDrawerModeByUser, usePreferDrawerModeOnEditByUser } from '~/stores/ui';
-
+import { useUserUISettings } from '~/client/services/user-ui-settings';
 import {
   isUserPreferenceExists,
   isDarkMode as isDarkModeByUtil,
@@ -21,12 +16,14 @@ import {
   updateUserPreference,
   updateUserPreferenceWithOsSettings,
 } from '~/client/util/color-scheme';
+import { usePreferDrawerModeByUser, usePreferDrawerModeOnEditByUser } from '~/stores/ui';
 
 
-import SidebarDrawerIcon from '../Icons/SidebarDrawerIcon';
-import SidebarDockIcon from '../Icons/SidebarDockIcon';
 import MoonIcon from '../Icons/MoonIcon';
+import SidebarDockIcon from '../Icons/SidebarDockIcon';
+import SidebarDrawerIcon from '../Icons/SidebarDrawerIcon';
 import SunIcon from '../Icons/SunIcon';
+import { withUnstatedContainers } from '../UnstatedUtils';
 
 
 const PersonalDropdown = (props) => {

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

@@ -70,7 +70,9 @@ const DisplaySwitcher = (): JSX.Element => {
                         className="btn btn-block btn-outline-secondary grw-btn-page-accessories rounded-pill d-flex justify-content-between align-items-center"
                         onClick={() => openDescendantPageListModal(currentPath)}
                       >
-                        <PageListIcon />
+                        <div className="grw-page-accessories-control-icon">
+                          <PageListIcon />
+                        </div>
                         {t('page_list')}
                         <span></span> {/* for a count badge */}
                       </button>
@@ -79,13 +81,13 @@ const DisplaySwitcher = (): JSX.Element => {
 
                   {/* Comments */}
                   { getCommentListDom != null && !isTopPagePath && (
-                    <div className="mt-2">
+                    <div className="grw-page-accessories-control mt-2">
                       <button
                         type="button"
                         className="btn btn-block btn-outline-secondary grw-btn-page-accessories rounded-pill d-flex justify-content-between align-items-center"
                         onClick={() => smoothScrollIntoView(getCommentListDom, WIKI_HEADER_LINK)}
                       >
-                        <i className="mr-2 icon-fw icon-bubbles"></i>
+                        <i className="icon-fw icon-bubbles grw-page-accessories-control-icon"></i>
                         <span>Comments</span>
                         <span></span> {/* for a count badge */}
                       </button>

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

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

+ 23 - 30
packages/app/src/components/PageList/PageListItemL.tsx

@@ -1,26 +1,20 @@
 import React, {
-  forwardRef,
-  ForwardRefRenderFunction, memo, useCallback, useImperativeHandle, useRef,
+  forwardRef, useState,
+  ForwardRefRenderFunction, memo, useCallback, useImperativeHandle, useRef, useEffect,
 } from 'react';
 
-import { useTranslation } from 'react-i18next';
-import { CustomInput } from 'reactstrap';
 
-import Clamp from 'react-multiline-clamp';
+import { DevidedPagePath } from '@growi/core';
+import { UserPicture, PageListMeta } from '@growi/ui';
 import { format } from 'date-fns';
+import { useTranslation } from 'react-i18next';
+import Clamp from 'react-multiline-clamp';
+import { CustomInput } from 'reactstrap';
 import urljoin from 'url-join';
 
-import { UserPicture, PageListMeta } from '@growi/ui';
-import { DevidedPagePath } from '@growi/core';
-
-import { useSWRxPageInfo } from '../../stores/page';
 
 import { ISelectable } from '~/client/interfaces/selectable-all';
 import { bookmark, unbookmark } from '~/client/services/page-operation';
-import { useIsDeviceSmallerThanLg } from '~/stores/ui';
-import {
-  usePageRenameModal, usePageDuplicateModal, usePageDeleteModal, usePutBackPageModal,
-} from '~/stores/modal';
 import {
   IPageInfoAll, IPageInfoForEntity, IPageInfoForListing, IPageWithMeta, isIPageInfoForListing, isIPageInfoForEntity,
 } from '~/interfaces/page';
@@ -29,7 +23,12 @@ import {
   OnDuplicatedFunction, OnRenamedFunction, OnDeletedFunction, OnPutBackedFunction,
 } from '~/interfaces/ui';
 import LinkedPagePath from '~/models/linked-page-path';
+import {
+  usePageRenameModal, usePageDuplicateModal, usePageDeleteModal, usePutBackPageModal,
+} from '~/stores/modal';
+import { useIsDeviceSmallerThanLg } from '~/stores/ui';
 
+import { useSWRxPageInfo } from '../../stores/page';
 import { ForceHideMenuItems, PageItemControl } from '../Common/Dropdown/PageItemControl';
 import PagePathHierarchicalLink from '../PagePathHierarchicalLink';
 
@@ -49,13 +48,15 @@ type Props = {
 
 const PageListItemLSubstance: ForwardRefRenderFunction<ISelectable, Props> = (props: Props, ref): JSX.Element => {
   const {
-    // todo: refactoring variable name to clear what changed
     page: { data: pageData, meta: pageMeta }, isSelected, isEnableActions,
     forceHideMenuItems,
     showPageUpdatedTime,
     onClickItem, onCheckboxChanged, onPageDuplicated, onPageRenamed, onPageDeleted, onPagePutBacked,
   } = props;
 
+  const [likerCount, setLikerCount] = useState(pageData.liker.length);
+  const [bookmarkCount, setBookmarkCount] = useState(pageMeta && pageMeta.bookmarkCount ? pageMeta.bookmarkCount : 0);
+
   const { t } = useTranslation();
 
   const inputRef = useRef<HTMLInputElement>(null);
@@ -97,6 +98,14 @@ const PageListItemLSubstance: ForwardRefRenderFunction<ISelectable, Props> = (pr
 
   const lastUpdateDate = format(new Date(pageData.updatedAt), 'yyyy/MM/dd HH:mm:ss');
 
+  useEffect(() => {
+    if (isIPageInfoForEntity(pageInfo) && pageInfo != null) {
+      // likerCount
+      setLikerCount(pageInfo.likerIds?.length ?? 0);
+      // bookmarkCount
+      setBookmarkCount(pageInfo.bookmarkCount ?? 0);
+    }
+  }, [pageInfo]);
 
   // click event handler
   const clickHandler = useCallback(() => {
@@ -147,22 +156,6 @@ const PageListItemLSubstance: ForwardRefRenderFunction<ISelectable, Props> = (pr
 
   const shouldDangerouslySetInnerHTMLForPaths = elasticSearchResult != null && elasticSearchResult.highlightedPath != null;
 
-  let likerCount;
-  if (isSelected && isIPageInfoForEntity(pageInfo)) {
-    likerCount = pageInfo.likerIds?.length;
-  }
-  else {
-    likerCount = pageData.liker.length;
-  }
-
-  let bookmarkCount;
-  if (isSelected && isIPageInfoForEntity(pageInfo)) {
-    bookmarkCount = pageInfo.bookmarkCount;
-  }
-  else {
-    bookmarkCount = pageMeta?.bookmarkCount;
-  }
-
   const canRenderESSnippet = elasticSearchResult != null && elasticSearchResult.snippet != null;
   const canRenderRevisionSnippet = revisionShortBody != null;
 

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

@@ -204,7 +204,7 @@ export const PrivateLegacyPages = (props: Props): JSX.Element => {
     openModal(
       selectedPages,
       () => {
-        toastSuccess('success');
+        toastSuccess(t('Successfully requested'));
         closeModal();
         mutate();
       },

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

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

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

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

+ 2 - 0
packages/app/src/components/Sidebar/Tag.tsx

@@ -1,5 +1,7 @@
 import React, { FC, useState, useEffect } from 'react';
+
 import { useTranslation } from 'react-i18next';
+
 import TagsList from '../TagsList';
 
 const Tag: FC = () => {

+ 2 - 6
packages/app/src/components/TagCloudBox.tsx

@@ -2,14 +2,10 @@ import React, { FC } from 'react';
 
 import { TagCloud } from 'react-tagcloud';
 
-type Tag = {
-  _id: string,
-  name: string,
-  count: number,
-}
+import { ITagHasCount } from '~/interfaces/tag';
 
 type Props = {
-  tags:Tag[],
+  tags: ITagHasCount[],
   minSize?: number,
   maxSize?: number,
 }

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

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

+ 85 - 0
packages/app/src/components/TagsList.tsx

@@ -0,0 +1,85 @@
+import React, { FC, useEffect, useState } from 'react';
+
+import { useTranslation } from 'react-i18next';
+
+import { useSWRxTagsList } from '~/stores/tag';
+
+import PaginationWrapper from './PaginationWrapper';
+import TagCloudBox from './TagCloudBox';
+
+
+const PAGING_LIMIT = 10;
+
+type Props = {
+  isOnReload: boolean
+}
+
+const TagsList: FC<Props> = (props: Props) => {
+  const { t } = useTranslation();
+
+  const [activePage, setActivePage] = useState<number>(1);
+  const [pagingOffset, setPagingOffset] = useState<number>(0);
+
+  const { data: tagsList, error, mutate } = useSWRxTagsList(PAGING_LIMIT, pagingOffset);
+
+  const handlePage = (selectedPageNumber: number) => {
+    setActivePage(selectedPageNumber);
+    setPagingOffset((selectedPageNumber - 1) * PAGING_LIMIT);
+  };
+
+  useEffect(() => {
+    if (props.isOnReload) {
+      mutate();
+    }
+  }, [mutate, props.isOnReload]);
+
+  const isLoading = tagsList === undefined && error == null;
+  if (isLoading) {
+    return (
+      <div className="text-muted text-center">
+        <i className="fa fa-2x fa-spinner fa-pulse mt-3"></i>
+      </div>
+    );
+  }
+
+  return (
+    <div data-testid="grw-tags-list">
+      <header className="py-0">
+        <h1 className="title text-center mt-5 mb-3 font-weight-bold">{`${t('Tags')}(${tagsList?.totalCount || 0})`}</h1>
+      </header>
+      <div className="row text-center">
+        <div className="col-12 mb-5 px-5">
+          <TagCloudBox tags={tagsList?.data || []} minSize={20} />
+        </div>
+        <div className="col-12 tag-list mb-4">
+          <ul className="list-group text-left">
+            {
+              tagsList?.data != null && tagsList.data.length > 0
+                ? tagsList.data.map((tag) => {
+                  return (
+                    <a key={tag.name} href={`/_search?q=tag:${tag.name}`} className="list-group-item">
+                      <i className="icon-tag mr-2"></i>{tag.name}
+                      <span className="ml-4 list-tag-count badge badge-secondary text-muted">{tag.count}</span>
+                    </a>
+                  );
+                })
+                : <h3>{ t('You have no tag, You can set tags on pages') }</h3>
+            }
+          </ul>
+        </div>
+        <div className="col-12 tag-list-pagination">
+          <PaginationWrapper
+            activePage={activePage}
+            changePage={handlePage}
+            totalItemsCount={tagsList?.totalCount || 0}
+            pagingLimit={PAGING_LIMIT}
+            align="center"
+            size="md"
+          />
+        </div>
+      </div>
+    </div>
+  );
+};
+
+export default TagsList;

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

@@ -3,7 +3,15 @@ export type ITag = {
   createdAt: Date;
 }
 
+export type ITagHasCount = ITag & { count: number }
+
 export type ITagsSearchApiv1Result = {
   ok: boolean,
   tags: string[]
 }
+
+export type ITagsListApiv1Result = {
+  ok: boolean,
+  data: ITagHasCount[],
+  totalCount: number,
+}

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

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

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

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

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

@@ -9,12 +9,13 @@ import loggerFactory from '~/utils/logger';
 /* eslint-disable no-use-before-define */
 
 const debug = require('debug')('growi:models:page');
+
 const nodePath = require('path');
-const urljoin = require('url-join');
-const mongoose = require('mongoose');
-const differenceInYears = require('date-fns/differenceInYears');
 
+const differenceInYears = require('date-fns/differenceInYears');
 const escapeStringRegexp = require('escape-string-regexp');
+const mongoose = require('mongoose');
+const urljoin = require('url-join');
 
 const { isTopPage, isTrashPage } = pagePathUtils;
 const { checkTemplatePath } = templateChecker;
@@ -253,7 +254,7 @@ export const getPageSchema = (crowi) => {
 
     this.grant = grant || GRANT_PUBLIC;
 
-    if (grant !== GRANT_PUBLIC && grant !== GRANT_USER_GROUP) {
+    if (grant !== GRANT_PUBLIC && grant !== GRANT_USER_GROUP && grant !== GRANT_RESTRICTED) {
       this.grantedUsers.push(user._id);
     }
 

+ 23 - 14
packages/app/src/server/models/page.ts

@@ -1,27 +1,29 @@
 /* eslint-disable @typescript-eslint/no-explicit-any */
 
+import nodePath from 'path';
+
+import { getOrCreateModel, pagePathUtils, pathUtils } from '@growi/core';
+import escapeStringRegexp from 'escape-string-regexp';
 import mongoose, {
   Schema, Model, Document, AnyObject,
 } from 'mongoose';
 import mongoosePaginate from 'mongoose-paginate-v2';
 import uniqueValidator from 'mongoose-unique-validator';
-import escapeStringRegexp from 'escape-string-regexp';
-import nodePath from 'path';
-import { getOrCreateModel, pagePathUtils, pathUtils } from '@growi/core';
 
+import { IUserHasId } from '~/interfaces/user';
+import { ObjectIdLike } from '~/server/interfaces/mongoose-utils';
+
+import { IPage, IPageHasId } from '../../interfaces/page';
 import loggerFactory from '../../utils/logger';
 import Crowi from '../crowi';
-import { IPage } from '../../interfaces/page';
+
 import { getPageSchema, extractToAncestorsPaths, populateDataToShowRevision } from './obsolete-page';
-import { ObjectIdLike } from '~/server/interfaces/mongoose-utils';
 import { PageRedirectModel } from './page-redirect';
 
 const { addTrailingSlash } = pathUtils;
 const { isTopPage, collectAncestorPaths } = pagePathUtils;
 
 const logger = loggerFactory('growi:models:page');
-
-
 /*
  * define schema
  */
@@ -34,7 +36,7 @@ const PAGE_GRANT_ERROR = 1;
 const STATUS_PUBLISHED = 'published';
 const STATUS_DELETED = 'deleted';
 
-export interface PageDocument extends IPage, Document {}
+export interface PageDocument extends IPage, Document { }
 
 
 type TargetAndAncestorsResult = {
@@ -603,8 +605,11 @@ schema.statics.getParentAndFillAncestors = async function(path: string, user): P
   });
   await this.bulkWrite(operations);
 
-  const createdParent = ancestorsMap.get(parentPath);
-
+  const parentId = ancestorsMap.get(parentPath)._id; // get parent page id to fetch updated parent parent
+  const createdParent = await this.findOne({ _id: parentId });
+  if (createdParent == null) {
+    throw Error('updated parent not Found');
+  }
   return createdParent;
 };
 
@@ -729,7 +734,7 @@ schema.statics.findAncestorsChildrenByPathAndViewer = async function(path: strin
     .lean()
     .exec();
   // mark target
-  const pages = _pages.map((page: PageDocument & {isTarget?: boolean}) => {
+  const pages = _pages.map((page: PageDocument & { isTarget?: boolean }) => {
     if (page.path === path) {
       page.isTarget = true;
     }
@@ -777,7 +782,7 @@ schema.statics.incrementDescendantCountOfPageIds = async function(pageIds: Objec
 /**
  * recount descendantCount of a page with the provided id and return it
  */
-schema.statics.recountDescendantCount = async function(id: ObjectIdLike):Promise<number> {
+schema.statics.recountDescendantCount = async function(id: ObjectIdLike): Promise<number> {
   const res = await this.aggregate(
     [
       {
@@ -1086,11 +1091,15 @@ export default (crowi: Crowi): any => {
     return savedPage;
   };
 
-  const shouldUseUpdatePageV4 = (grant:number, isV5Compatible:boolean, isOnTree:boolean): boolean => {
+  const shouldUseUpdatePageV4 = (grant: number, isV5Compatible: boolean, isOnTree: boolean): boolean => {
     const isRestricted = grant === GRANT_RESTRICTED;
     return !isRestricted && (!isV5Compatible || !isOnTree);
   };
 
+  schema.statics.emitPageEventUpdate = (page: IPageHasId, user: IUserHasId): void => {
+    pageEvent.emit('update', page, user);
+  };
+
   schema.statics.updatePage = async function(pageData, body, previousBody, user, options = {}) {
     if (crowi.configManager == null || crowi.pageGrantService == null || crowi.pageService == null) {
       throw Error('Crowi is not set up');
@@ -1160,7 +1169,7 @@ export default (crowi: Crowi): any => {
       savedPage = await this.syncRevisionToHackmd(savedPage);
     }
 
-    pageEvent.emit('update', savedPage, user);
+    this.emitPageEventUpdate(savedPage, user);
 
     // Update ex children's parent
     if (!wasOnTree && shouldBeOnTree) {

+ 14 - 5
packages/app/src/server/models/user.js

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

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

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

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

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

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

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

+ 6 - 3
packages/app/src/server/routes/user-activation.ts

@@ -1,5 +1,6 @@
 import path from 'path';
-import { format } from 'date-fns';
+
+import { format, subSeconds } from 'date-fns';
 import { body, validationResult } from 'express-validator';
 
 import UserRegistrationOrder from '../models/user-registration-order';
@@ -22,7 +23,9 @@ async function makeRegistrationEmailToken(email, crowi) {
   const appUrl = appService.getSiteUrl();
 
   const userRegistrationOrder = await UserRegistrationOrder.createUserRegistrationOrder(email);
-  const expiredAt = format(userRegistrationOrder.expiredAt, 'yyyy/MM/dd HH:mm');
+  const grwTzoffsetSec = crowi.appService.getTzoffset() * 60;
+  const expiredAt = subSeconds(userRegistrationOrder.expiredAt, grwTzoffsetSec);
+  const formattedExpiredAt = format(expiredAt, 'yyyy/MM/dd HH:mm');
   const url = new URL(`/user-activation/${userRegistrationOrder.token}`, appUrl);
   const oneTimeUrl = url.href;
   const txtFileName = 'userActivation';
@@ -34,7 +37,7 @@ async function makeRegistrationEmailToken(email, crowi) {
     vars: {
       appTitle: appService.getAppTitle(),
       email,
-      expiredAt,
+      expiredAt: formattedExpiredAt,
       url: oneTimeUrl,
     },
   });

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

@@ -74,7 +74,7 @@ class PageGrantService {
     // GRANT_OWNER
     else if (ancestor.grant === Page.GRANT_OWNER) {
       if (target.grantedUserIds?.length !== 1) {
-        throw Error('grantedUserIds must have one user');
+        return false;
       }
 
       if (target.grant !== Page.GRANT_OWNER) { // only GRANT_OWNER page can exist under GRANT_OWNER page

+ 52 - 20
packages/app/src/server/service/page.ts

@@ -2294,23 +2294,25 @@ class PageService {
       throw Error('Restricted pages can not be migrated');
     }
 
-    let updatedPage;
+    let normalizedPage;
 
     // replace if empty page exists
     if (existingPage != null && existingPage.isEmpty) {
-      await Page.replaceTargetWithPage(existingPage, page, true);
-      updatedPage = await Page.findById(page._id);
+      // Inherit descendantCount from the empty page
+      const updatedPage = await Page.findOneAndUpdate({ _id: page._id }, { descendantCount: existingPage.descendantCount }, { new: true });
+      await Page.replaceTargetWithPage(existingPage, updatedPage, true);
+      normalizedPage = await Page.findById(page._id);
     }
     else {
       const parent = await Page.getParentAndFillAncestors(page.path, user);
-      updatedPage = await Page.findOneAndUpdate({ _id: page._id }, { parent: parent._id }, { new: true });
+      normalizedPage = await Page.findOneAndUpdate({ _id: page._id }, { parent: parent._id }, { new: true });
     }
 
     // Update descendantCount
     const inc = 1;
-    await this.updateDescendantCountOfAncestors(updatedPage.parent, inc, true);
+    await this.updateDescendantCountOfAncestors(normalizedPage.parent, inc, true);
 
-    return updatedPage;
+    return normalizedPage;
   }
 
   async normalizeParentRecursivelyByPages(pages, user): Promise<void> {
@@ -2356,6 +2358,17 @@ class PageService {
         throw Error(`Cannot operate normalizeParentRecursiively to path "${page.path}" right now.`);
       }
 
+      const Page = mongoose.model('Page') as unknown as PageModel;
+      const { PageQueryBuilder } = Page;
+      const builder = new PageQueryBuilder(Page.findOne());
+      builder.addConditionAsMigrated();
+      builder.addConditionToListByPathsArray([page.path]);
+      const existingPage = await builder.query.exec();
+
+      if (existingPage?.parent != null) {
+        throw Error('This page has already converted.');
+      }
+
       let pageOp;
       try {
         pageOp = await PageOperation.create({
@@ -2371,7 +2384,14 @@ class PageService {
         logger.error('Failed to create PageOperation document.', err);
         throw err;
       }
-      await this.normalizeParentRecursivelyMainOperation(page, user, pageOp._id);
+
+      try {
+        await this.normalizeParentRecursivelyMainOperation(page, user, pageOp._id);
+      }
+      catch (err) {
+        logger.err('Failed to run normalizeParentRecursivelyMainOperation.', err);
+        throw err;
+      }
     }
   }
 
@@ -2381,6 +2401,7 @@ class PageService {
     const { PageQueryBuilder } = Page;
     const builder = new PageQueryBuilder(Page.findOne(), true);
     builder.addConditionAsMigrated();
+    builder.addConditionToListByPathsArray([page.path]);
     const exPage = await builder.query.exec();
     const options = { prevDescendantCount: exPage?.descendantCount ?? 0 };
 
@@ -2557,18 +2578,9 @@ class PageService {
     return this._normalizeParentRecursively(pathAndRegExpsToNormalize, ancestorPaths, grantFiltersByUser, user);
   }
 
-  private async _normalizeParentRecursively(
-      pathOrRegExps: (RegExp | string)[], publicPathsToNormalize: string[], grantFiltersByUser: { $or: any[] }, user, count = 0, skiped = 0, isFirst = true,
-  ): Promise<void> {
-    const BATCH_SIZE = 100;
-    const PAGES_LIMIT = 1000;
-
-    const socket = this.crowi.socketIoService.getAdminSocket();
-
+  private buildFilterForNormalizeParentRecursively(pathOrRegExps: (RegExp | string)[], publicPathsToNormalize: string[], grantFiltersByUser: { $or: any[] }) {
     const Page = mongoose.model('Page') as unknown as PageModel;
-    const { PageQueryBuilder } = Page;
 
-    // Build filter
     const andFilter: any = {
       $and: [
         {
@@ -2605,9 +2617,26 @@ class PageService {
       ],
     };
 
+    return mergedFilter;
+  }
+
+  private async _normalizeParentRecursively(
+      pathOrRegExps: (RegExp | string)[], publicPathsToNormalize: string[], grantFiltersByUser: { $or: any[] }, user, count = 0, skiped = 0, isFirst = true,
+  ): Promise<void> {
+    const BATCH_SIZE = 100;
+    const PAGES_LIMIT = 1000;
+
+    const socket = this.crowi.socketIoService.getAdminSocket();
+
+    const Page = mongoose.model('Page') as unknown as PageModel;
+    const { PageQueryBuilder } = Page;
+
+    // Build filter
+    const matchFilter = this.buildFilterForNormalizeParentRecursively(pathOrRegExps, publicPathsToNormalize, grantFiltersByUser);
+
     let baseAggregation = Page
       .aggregate([
-        { $match: mergedFilter },
+        { $match: matchFilter },
         {
           $project: { // minimize data to fetch
             _id: 1,
@@ -2617,7 +2646,7 @@ class PageService {
       ]);
 
     // Limit pages to get
-    const total = await Page.countDocuments(mergedFilter);
+    const total = await Page.countDocuments(matchFilter);
     if (isFirst) {
       socket.emit(SocketEventName.PMStarted, { total });
     }
@@ -2698,6 +2727,9 @@ class PageService {
               {
                 path: { $regex: new RegExp(`^${parentPathEscaped}(\\/[^/]+)\\/?$`, 'i') }, // see: regexr.com/6889f (e.g. /parent/any_child or /any_level1)
               },
+              {
+                path: { $in: pathOrRegExps.concat(publicPathsToNormalize) },
+              },
               filterForApplicableAncestors,
               grantFiltersByUser,
             ],
@@ -2754,7 +2786,7 @@ class PageService {
 
     await streamToPromise(migratePagesStream);
 
-    if (await Page.exists(mergedFilter) && shouldContinue) {
+    if (await Page.exists(matchFilter) && shouldContinue) {
       return this._normalizeParentRecursively(pathOrRegExps, publicPathsToNormalize, grantFiltersByUser, user, nextCount, nextSkiped, false);
     }
 

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

@@ -1,22 +1,24 @@
+import { Writable, Transform } from 'stream';
+import { URL } from 'url';
+
 import elasticsearch6 from '@elastic/elasticsearch6';
 import elasticsearch7 from '@elastic/elasticsearch7';
 import mongoose from 'mongoose';
-
-import { URL } from 'url';
-
-import { Writable, Transform } from 'stream';
 import streamToPromise from 'stream-to-promise';
 
-import { createBatchStream } from '../../util/batch-stream';
-import loggerFactory from '~/utils/logger';
-import { PageModel } from '../../models/page';
-import {
-  SearchDelegator, SearchableData, QueryTerms,
-} from '../../interfaces/search';
 import { SearchDelegatorName } from '~/interfaces/named-query';
 import {
   IFormattedSearchResult, ISearchResult, SORT_AXIS, SORT_ORDER,
 } from '~/interfaces/search';
+import loggerFactory from '~/utils/logger';
+
+import {
+  SearchDelegator, SearchableData, QueryTerms,
+} from '../../interfaces/search';
+import { PageModel } from '../../models/page';
+import { createBatchStream } from '../../util/batch-stream';
+
+
 import ElasticsearchClient from './elasticsearch-client';
 
 const logger = loggerFactory('growi:service:search-delegator:elasticsearch');
@@ -855,27 +857,13 @@ class ElasticsearchDelegator implements SearchDelegator<Data> {
 
     const Page = mongoose.model('Page') as unknown as PageModel;
     const {
-      GRANT_PUBLIC, GRANT_RESTRICTED, GRANT_SPECIFIED, GRANT_OWNER, GRANT_USER_GROUP,
+      GRANT_PUBLIC, GRANT_SPECIFIED, GRANT_OWNER, GRANT_USER_GROUP,
     } = Page;
 
     const grantConditions: any[] = [
       { term: { grant: GRANT_PUBLIC } },
     ];
 
-    // ensure to hit to GRANT_RESTRICTED pages that the user specified at own
-    if (user != null) {
-      grantConditions.push(
-        {
-          bool: {
-            must: [
-              { term: { grant: GRANT_RESTRICTED } },
-              { term: { granted_users: user._id.toString() } },
-            ],
-          },
-        },
-      );
-    }
-
     if (showPagesRestrictedByOwner) {
       grantConditions.push(
         { term: { grant: GRANT_SPECIFIED } },

+ 0 - 1
packages/app/src/server/service/user-group.ts

@@ -25,7 +25,6 @@ class UserGroupService {
     return UserGroupRelation.removeAllInvalidRelations();
   }
 
-  // TODO 85062: write test code
   // ref: https://dev.growi.org/61b2cdabaa330ce7d8152844
   async updateGroup(id, name?: string, description?: string, parentId?: string | null, forceUpdateParents = false) {
     const userGroup = await UserGroup.findById(id);

+ 1 - 1
packages/app/src/server/views/layout-growi/user_page.html

@@ -1,7 +1,7 @@
 {% extends 'page.html' %}
 
 {% block content_main %}
-  <div class="grw-container-convertible user-page">
+  <div class="grw-container-convertible user-page" data-testid="grw-user-page">
 
     {% include '../widget/page_content.html' %}
 

+ 1 - 1
packages/app/src/server/views/layout/layout.html

@@ -96,7 +96,7 @@
 </div><!-- /#wrapper -->
 
 {% block fixed-controls %}
-<div id="grw-fab-container"></div>
+<div id="grw-fab-container" data-testid="grw-fab-container"></div>
 {% endblock %}
 
 <div id="grw-hotkeys-manager"></div>

+ 14 - 0
packages/app/src/stores/tag.tsx

@@ -0,0 +1,14 @@
+import { SWRResponse } from 'swr';
+import useSWRImmutable from 'swr/immutable';
+
+import { ITagsListApiv1Result } from '~/interfaces/tag';
+
+import { apiGet } from '../client/util/apiv1-client';
+
+
+export const useSWRxTagsList = (limit?: number, offset?: number): SWRResponse<ITagsListApiv1Result, Error> => {
+  return useSWRImmutable(
+    ['/tags.list', limit, offset],
+    (endpoint, limit, offset) => apiGet(endpoint, { limit, offset }).then((result: ITagsListApiv1Result) => result),
+  );
+};

+ 14 - 16
packages/app/src/stores/ui.tsx

@@ -1,32 +1,30 @@
 import { RefObject } from 'react';
+
+import { isClient, pagePathUtils } from '@growi/core';
+import { Breakpoint, addBreakpointListener } from '@growi/ui';
+import SimpleBar from 'simplebar-react';
 import {
   useSWRConfig, SWRResponse, Key, Fetcher,
 } from 'swr';
 import useSWRImmutable from 'swr/immutable';
 
-import SimpleBar from 'simplebar-react';
-
-import { Breakpoint, addBreakpointListener } from '@growi/ui';
-import { pagePathUtils } from '@growi/core';
 
+import { IFocusable } from '~/client/interfaces/focusable';
+import { Nullable } from '~/interfaces/common';
 import { SidebarContentsType } from '~/interfaces/ui';
+import { UpdateDescCountData } from '~/interfaces/websocket';
 import loggerFactory from '~/utils/logger';
 
-import { useStaticSWR } from './use-static-swr';
 import {
   useCurrentPageId, useCurrentPagePath, useIsEditable, useIsTrashPage, useIsUserPage,
   useIsNotCreatable, useIsSharedUser, useNotFoundTargetPathOrId, useIsForbidden, useIsIdenticalPath, useIsNotFoundPermalink,
 } from './context';
-import { IFocusable } from '~/client/interfaces/focusable';
-import { Nullable } from '~/interfaces/common';
-import { UpdateDescCountData } from '~/interfaces/websocket';
+import { useStaticSWR } from './use-static-swr';
 
 const { isSharedPage } = pagePathUtils;
 
 const logger = loggerFactory('growi:stores:ui');
 
-const isServer = typeof window === 'undefined';
-
 
 /** **********************************************************
  *                          Unions
@@ -55,10 +53,10 @@ export const useSidebarScrollerRef = (initialData?: RefObject<SimpleBar>): SWRRe
  *********************************************************** */
 
 export const useIsMobile = (): SWRResponse<boolean, Error> => {
-  const key = isServer ? null : 'isMobile';
+  const key = isClient() ? 'isMobile' : null;
 
   let configuration;
-  if (!isServer) {
+  if (isClient()) {
     const userAgent = window.navigator.userAgent.toLowerCase();
     configuration = {
       fallbackData: /iphone|ipad|android/.test(userAgent),
@@ -166,11 +164,11 @@ export const useEditorMode = (): SWRResponse<EditorMode, Error> => {
 };
 
 export const useIsDeviceSmallerThanMd = (): SWRResponse<boolean, Error> => {
-  const key: Key = isServer ? null : 'isDeviceSmallerThanMd';
+  const key: Key = isClient() ? 'isDeviceSmallerThanMd' : null;
 
   const { cache, mutate } = useSWRConfig();
 
-  if (!isServer) {
+  if (isClient()) {
     const mdOrAvobeHandler = function(this: MediaQueryList): void {
       // sm -> md: matches will be true
       // md -> sm: matches will be false
@@ -190,11 +188,11 @@ export const useIsDeviceSmallerThanMd = (): SWRResponse<boolean, Error> => {
 };
 
 export const useIsDeviceSmallerThanLg = (): SWRResponse<boolean, Error> => {
-  const key: Key = isServer ? null : 'isDeviceSmallerThanLg';
+  const key: Key = isClient() ? 'isDeviceSmallerThanLg' : null;
 
   const { cache, mutate } = useSWRConfig();
 
-  if (!isServer) {
+  if (isClient()) {
     const lgOrAvobeHandler = function(this: MediaQueryList): void {
       // md -> lg: matches will be true
       // lg -> md: matches will be false

+ 5 - 0
packages/app/src/styles/_page-accessories-control.scss

@@ -8,4 +8,9 @@
       height: 16px;
     }
   }
+  .grw-page-accessories-control-icon {
+    display: flex;
+    justify-content: center;
+    width: 20px;
+  }
 }

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

@@ -48,6 +48,18 @@ body .page-list {
       i {
         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

+ 3 - 2
packages/app/src/utils/project-dir-utils.ts

@@ -4,8 +4,9 @@ import fs from 'fs';
 import path from 'path';
 import process from 'process';
 
-const isServer = typeof window === 'undefined';
-const isCurrentDirRoot = isServer && fs.existsSync('./next.config.js');
+import { isServer } from '@growi/core';
+
+const isCurrentDirRoot = isServer() && fs.existsSync('./next.config.js');
 
 export const projectRoot = isCurrentDirRoot
   ? process.cwd()

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

@@ -19,8 +19,8 @@ context('Access to page', () => {
   it('/Sandbox with anchor hash is successfully loaded', () => {
     cy.visit('/Sandbox#Headers');
 
-    // wait until opacity is 1.
-    cy.getByTestid('grw-fab-create-page').should('have.css', 'opacity', '1')
+    // hide fab
+    cy.getByTestid('grw-fab-container').invoke('attr', 'style', 'display: none');
 
     cy.screenshot(`${ssPrefix}-sandbox-headers`);
   });
@@ -95,6 +95,8 @@ context('Access to special pages', () => {
     // select tags
     cy.getByTestid('grw-sidebar-nav-primary-tags').click();
     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.screenshot(`${ssPrefix}-tags`);

+ 2 - 0
packages/app/test/cypress/integration/3-search/search.spec.ts

@@ -13,6 +13,7 @@ context('Access to search result page', () => {
   it('/_search with "q" param is successfully loaded', () => {
     cy.visit('/_search', { qs: { q: 'labels alerts cards blocks' } });
 
+    cy.getByTestid('search-result-base').should('be.visible');
     cy.getByTestid('search-result-list').should('be.visible');
     cy.getByTestid('search-result-content').should('be.visible');
 
@@ -24,6 +25,7 @@ context('Access to search result page', () => {
 
     cy.getByTestid('search-result-base').should('be.visible');
     cy.getByTestid('search-result-list').should('be.visible');
+    cy.getByTestid('search-result-content').should('be.visible');
 
     cy.getByTestid('cb-select').first().click({force: true});
     cy.screenshot(`${ssPrefix}-the-first-checkbox-on`);

+ 0 - 32
packages/app/test/cypress/integration/6-home-settings/access-home.spec.ts

@@ -1,32 +0,0 @@
-/* eslint-disable cypress/no-unnecessary-waiting */
-context('Access Home', () => {
-  const ssPrefix = 'access-home-';
-
-  let connectSid: string | undefined;
-
-  before(() => {
-    // login
-    cy.fixture("user-admin.json").then(user => {
-      cy.login(user.username, user.password);
-    });
-    cy.getCookie('connect.sid').then(cookie => {
-      connectSid = cookie?.value;
-    });
-  });
-
-  beforeEach(() => {
-    if (connectSid != null) {
-      cy.setCookie('connect.sid', connectSid);
-    }
-  });
-
-  it('Visit home', () => {
-    cy.visit('/');
-    cy.get('.grw-personal-dropdown').click();
-    cy.get('.grw-personal-dropdown .dropdown-menu .btn-group > .btn-outline-secondary:eq(0)').click();
-
-    cy.wait(1500);
-    cy.screenshot(`${ssPrefix}-visit-home`, { capture: 'viewport' });
-  });
-
-});

+ 0 - 85
packages/app/test/cypress/integration/6-home-settings/access-user-settings.spec.ts

@@ -1,85 +0,0 @@
-/* eslint-disable cypress/no-unnecessary-waiting */
-context('Access User settings', () => {
-  const ssPrefix = 'access-user-settings-';
-
-  let connectSid: string | undefined;
-
-  before(() => {
-    // login
-    cy.fixture("user-admin.json").then(user => {
-      cy.login(user.username, user.password);
-    });
-    cy.getCookie('connect.sid').then(cookie => {
-      connectSid = cookie?.value;
-    });
-
-    cy.visit('/');
-    cy.get('.grw-personal-dropdown').click();
-    cy.get('[href="/me"]').click();
-
-    cy.wait(1500);
-  });
-
-  beforeEach(() => {
-    if (connectSid != null) {
-      cy.setCookie('connect.sid', connectSid);
-    }
-  });
-
-  it('Update settings', () => {
-    // Access User information
-    cy.get('#personal-setting .tab-pane.active > div:first button').click(); // Click basic info update button
-
-    cy.wait(500);
-
-    cy.screenshot(`${ssPrefix}-user-information`, { capture: 'viewport' });
-    cy.get('.toast-close-button').click({ multiple: true }); // close toast alert
-
-    // Access External account
-    cy.get('#personal-setting .nav-title.nav li:eq(1) a').click(); // click
-    cy.get('#personal-setting .tab-pane.active h2 button').click(); // click add button
-    cy.get('.modal-footer button').click(); // click add button in modal form
-    cy.get('.close[aria-label="Close"]').click(); // close modal form
-
-    cy.wait(500);
-
-    cy.screenshot(`${ssPrefix}-external-account`, { capture: 'viewport' });
-    cy.get('.toast-close-button').click({ multiple: true }); // close toast alert
-
-    // Access Password setting
-    cy.get('#personal-setting .nav-title.nav li:eq(2) a').click();
-    cy.get('#personal-setting .tab-pane.active button').click(); // click update button
-
-    cy.wait(500);
-
-    cy.screenshot(`${ssPrefix}-password-setting`, { capture: 'viewport' });
-    cy.get('.toast-close-button').click({ multiple: true }); // close toast alert
-
-    // Access API setting
-    cy.get('#personal-setting .nav-title.nav li:eq(2) a').click();
-    cy.get('#personal-setting .tab-pane.active button').click(); // click update API token button
-
-    cy.wait(500);
-
-    cy.screenshot(`${ssPrefix}-api-setting`, { capture: 'viewport' });
-    cy.get('.toast-close-button').click({ multiple: true }); // close toast alert
-
-    // Access Editor setting
-    cy.get('#personal-setting .nav-title.nav li:eq(3) a').click();
-    cy.get('#personal-setting .tab-pane.active button').click(); // click update button
-
-    cy.wait(500);
-
-    cy.screenshot(`${ssPrefix}-editor-setting`, { capture: 'viewport' });
-    cy.get('.toast-close-button').click({ multiple: true }); // close toast alert
-
-    // Access In-app notification setting
-    cy.get('#personal-setting .nav-title.nav li:eq(4) a').click();
-    cy.get('#personal-setting .tab-pane.active button').click(); // click update button
-
-    cy.wait(500);
-
-    cy.screenshot(`${ssPrefix}-in-app-notification-setting`, { capture: 'viewport' });
-  });
-
-});

+ 114 - 0
packages/app/test/cypress/integration/6-home/home.spec.ts

@@ -0,0 +1,114 @@
+context('Access Home', () => {
+  const ssPrefix = 'home-';
+
+  beforeEach(() => {
+    // login
+    cy.fixture("user-admin.json").then(user => {
+      cy.login(user.username, user.password);
+    });
+    // collapse sidebar
+    cy.collapseSidebar(true);
+  });
+
+  it('Visit home', () => {
+    cy.visit('/dummy');
+    cy.getByTestid('grw-personal-dropdown').click();
+    cy.getByTestid('grw-personal-dropdown').find('.dropdown-menu .btn-group > .btn-outline-secondary:eq(0)').click();
+
+    cy.getByTestid('grw-user-page').should('be.visible');
+
+    cy.screenshot(`${ssPrefix}-visit-home`);
+  });
+
+});
+
+
+context('Access User settings', () => {
+  const ssPrefix = 'access-user-settings-';
+
+  beforeEach(() => {
+    // login
+    cy.fixture("user-admin.json").then(user => {
+      cy.login(user.username, user.password);
+    });
+    // collapse sidebar
+    cy.collapseSidebar(true);
+  });
+
+  it('Update settings', () => {
+    cy.visit('/me');
+
+    // hide fab
+    cy.getByTestid('grw-fab-container').invoke('attr', 'style', 'display: none');
+
+    // User information
+    cy.getByTestid('grw-user-settings').should('be.visible');
+    cy.screenshot(`${ssPrefix}-user-information-1`);
+    cy.getByTestid('grw-besic-info-settings-update-button').click();
+    cy.get('.toast').should('be.visible').invoke('attr', 'style', 'opacity: 1');
+    cy.screenshot(`${ssPrefix}-user-information-2`);
+
+    cy.get('.toast-close-button').click({ multiple: true }); // close toast alert
+    cy.get('.toast').should('not.exist');
+
+    // Access External account
+    cy.getByTestid('grw-personal-settings').find('.nav-title.nav li:eq(1) a').click();
+    cy.scrollTo('top');
+    cy.screenshot(`${ssPrefix}-external-account-1`);
+    cy.getByTestid('grw-external-account-add-button').click();
+    cy.getByTestid('grw-associate-modal').should('be.visible');
+    cy.screenshot(`${ssPrefix}-external-account-2`);
+    cy.getByTestid('grw-associate-modal').find('.modal-footer button').click(); // click add button in modal form
+    cy.screenshot(`${ssPrefix}-external-account-3`);
+    cy.getByTestid('grw-associate-modal').find('.close').click();
+    cy.get('.toast').should('be.visible').invoke('attr', 'style', 'opacity: 1');
+    cy.screenshot(`${ssPrefix}-external-account-4`);
+
+    cy.get('.toast-close-button').click({ multiple: true }); // close toast alert
+    cy.get('.toast').should('not.exist');
+
+    // Access Password setting
+    cy.getByTestid('grw-personal-settings').find('.nav-title.nav li:eq(2) a').click();
+    cy.scrollTo('top');
+    cy.screenshot(`${ssPrefix}-password-settings-1`);
+    cy.getByTestid('grw-password-settings-update-button').click();
+    cy.get('.toast').should('be.visible').invoke('attr', 'style', 'opacity: 1');
+    cy.screenshot(`${ssPrefix}-password-settings-2`);
+
+    cy.get('.toast-close-button').click({ multiple: true }); // close toast alert
+    cy.get('.toast').should('not.exist');
+
+    // Access API setting
+    cy.getByTestid('grw-personal-settings').find('.nav-title.nav li:eq(3) a').click();
+    cy.scrollTo('top');
+    cy.screenshot(`${ssPrefix}-api-setting-1`);
+    cy.getByTestid('grw-api-settings-update-button').click();
+    cy.getByTestid('grw-api-settings-input').should('be.visible');
+    cy.get('.toast').should('be.visible').invoke('attr', 'style', 'opacity: 1');
+    cy.screenshot(`${ssPrefix}-api-setting-2`);
+
+    cy.get('.toast-close-button').click({ multiple: true }); // close toast alert
+    cy.get('.toast').should('not.exist');
+
+    // Access Editor setting
+    cy.getByTestid('grw-personal-settings').find('.nav-title.nav li:eq(4) a').click();
+    cy.scrollTo('top');
+    cy.getByTestid('grw-editor-settings').should('be.visible');
+    cy.screenshot(`${ssPrefix}-editor-setting-1`);
+    cy.getByTestid('grw-editor-settings-update-button').click();
+    cy.get('.toast').should('be.visible').invoke('attr', 'style', 'opacity: 1');
+    cy.screenshot(`${ssPrefix}-editor-setting-2`);
+
+    cy.get('.toast-close-button').click({ multiple: true }); // close toast alert
+    cy.get('.toast').should('not.exist');
+
+    // Access In-app notification setting
+    cy.getByTestid('grw-personal-settings').find('.nav-title.nav li:eq(5) a').click();
+    cy.scrollTo('top');
+    cy.screenshot(`${ssPrefix}-in-app-notification-setting-1`);
+    cy.getByTestid('grw-in-app-notification-settings-update-button').click();
+    cy.get('.toast').should('be.visible').invoke('attr', 'style', 'opacity: 1');
+    cy.screenshot(`${ssPrefix}-in-app-notification-setting-2`);
+  });
+
+});

+ 11 - 0
packages/app/test/cypress/plugins/index.ts

@@ -19,4 +19,15 @@
 module.exports = (on, config) => {
   // `on` is used to hook into various events Cypress emits
   // `config` is the resolved Cypress config
+
+  // change screen size
+  // see: https://docs.cypress.io/api/plugins/browser-launch-api#Set-screen-size-when-running-headless
+  on('before:browser:launch', (browser, launchOptions) => {
+    if (browser.name === 'chrome' && browser.isHeadless) {
+      launchOptions.args.push('--window-size=1400,1024')
+      launchOptions.args.push('--force-device-scale-factor=1')
+    }
+
+    return launchOptions
+  })
 }

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

@@ -8,17 +8,53 @@ describe('User', () => {
   let crowi;
   let User;
 
+  let adminusertestToBeRemovedId;
+
   beforeAll(async() => {
     crowi = await getInstance();
     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.', () => {
@@ -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', () => {

+ 376 - 0
packages/app/test/integration/models/v5.page.test.js

@@ -13,10 +13,19 @@ describe('Page', () => {
   let Comment;
   let ShareLink;
   let PageRedirect;
+  let UserGroup;
+  let UserGroupRelation;
   let xssSpy;
 
   let rootPage;
   let dummyUser1;
+  let pModelUser1;
+  let pModelUser2;
+  let pModelUser3;
+  let groupIdIsolate;
+  let groupIdA;
+  let groupIdB;
+  let groupIdC;
 
   beforeAll(async() => {
     crowi = await getInstance();
@@ -32,11 +41,100 @@ describe('Page', () => {
     Comment = mongoose.model('Comment');
     ShareLink = mongoose.model('ShareLink');
     PageRedirect = mongoose.model('PageRedirect');
+    UserGroup = mongoose.model('UserGroup');
+    UserGroupRelation = mongoose.model('UserGroupRelation');
 
     dummyUser1 = await User.findOne({ username: 'v5DummyUser1' });
 
     rootPage = await Page.findOne({ path: '/' });
 
+    const pModelUserId1 = new mongoose.Types.ObjectId();
+    const pModelUserId2 = new mongoose.Types.ObjectId();
+    const pModelUserId3 = new mongoose.Types.ObjectId();
+    await User.insertMany([
+      {
+        _id: pModelUserId1, name: 'pmodelUser1', username: 'pmodelUser1', email: 'pmodelUser1@example.com',
+      },
+      {
+        _id: pModelUserId2, name: 'pmodelUser2', username: 'pmodelUser2', email: 'pmodelUser2@example.com',
+      },
+      {
+        _id: pModelUserId3, name: 'pModelUser3', username: 'pModelUser3', email: 'pModelUser3@example.com',
+      },
+    ]);
+    pModelUser1 = await User.findOne({ _id: pModelUserId1 });
+    pModelUser2 = await User.findOne({ _id: pModelUserId2 });
+    pModelUser3 = await User.findOne({ _id: pModelUserId3 });
+
+
+    groupIdIsolate = new mongoose.Types.ObjectId();
+    groupIdA = new mongoose.Types.ObjectId();
+    groupIdB = new mongoose.Types.ObjectId();
+    groupIdC = new mongoose.Types.ObjectId();
+    await UserGroup.insertMany([
+      {
+        _id: groupIdIsolate,
+        name: 'pModel_groupIsolate',
+      },
+      {
+        _id: groupIdA,
+        name: 'pModel_groupA',
+      },
+      {
+        _id: groupIdB,
+        name: 'pModel_groupB',
+        parent: groupIdA,
+      },
+      {
+        _id: groupIdC,
+        name: 'pModel_groupC',
+        parent: groupIdB,
+      },
+    ]);
+
+    await UserGroupRelation.insertMany([
+      {
+        relatedGroup: groupIdIsolate,
+        relatedUser: pModelUserId1,
+        createdAt: new Date(),
+      },
+      {
+        relatedGroup: groupIdIsolate,
+        relatedUser: pModelUserId2,
+        createdAt: new Date(),
+      },
+      {
+        relatedGroup: groupIdA,
+        relatedUser: pModelUserId1,
+        createdAt: new Date(),
+      },
+      {
+        relatedGroup: groupIdA,
+        relatedUser: pModelUserId2,
+        createdAt: new Date(),
+      },
+      {
+        relatedGroup: groupIdA,
+        relatedUser: pModelUserId3,
+        createdAt: new Date(),
+      },
+      {
+        relatedGroup: groupIdB,
+        relatedUser: pModelUserId2,
+        createdAt: new Date(),
+      },
+      {
+        relatedGroup: groupIdB,
+        relatedUser: pModelUserId3,
+        createdAt: new Date(),
+      },
+      {
+        relatedGroup: groupIdC,
+        relatedUser: pModelUserId3,
+        createdAt: new Date(),
+      },
+    ]);
+
     const pageIdCreate1 = new mongoose.Types.ObjectId();
     const pageIdCreate2 = new mongoose.Types.ObjectId();
     const pageIdCreate3 = new mongoose.Types.ObjectId();
@@ -129,6 +227,7 @@ describe('Page', () => {
     const pageIdUpd10 = new mongoose.Types.ObjectId();
     const pageIdUpd11 = new mongoose.Types.ObjectId();
     const pageIdUpd12 = new mongoose.Types.ObjectId();
+    const pageIdUpd13 = new mongoose.Types.ObjectId();
 
     await Page.insertMany([
       {
@@ -255,6 +354,125 @@ describe('Page', () => {
         parent: rootPage._id,
         descendantCount: 1,
       },
+      {
+        path: '/mup19',
+        grant: Page.GRANT_PUBLIC,
+        creator: dummyUser1,
+        lastUpdateUser: dummyUser1._id,
+        isEmpty: false,
+        parent: rootPage._id,
+        descendantCount: 0,
+      },
+      {
+        path: '/mup20',
+        grant: Page.GRANT_USER_GROUP,
+        grantedGroup: groupIdA,
+        creator: pModelUserId1,
+        lastUpdateUser: pModelUserId1,
+        isEmpty: false,
+        parent: rootPage._id,
+        descendantCount: 0,
+      },
+      {
+        path: '/mup21',
+        grant: Page.GRANT_RESTRICTED,
+        creator: dummyUser1,
+        lastUpdateUser: dummyUser1._id,
+        isEmpty: false,
+        descendantCount: 0,
+      },
+      {
+        _id: pageIdUpd13,
+        path: '/mup22',
+        grant: Page.GRANT_PUBLIC,
+        creator: pModelUser1,
+        lastUpdateUser: pModelUser1._id,
+        isEmpty: false,
+        parent: rootPage._id,
+        descendantCount: 1,
+      },
+      {
+        path: '/mup22/mup23',
+        grant: Page.GRANT_USER_GROUP,
+        grantedGroup: groupIdA,
+        creator: pModelUserId1,
+        lastUpdateUser: pModelUserId1,
+        isEmpty: false,
+        parent: pageIdUpd13,
+        descendantCount: 0,
+      },
+      {
+        path: '/mup24',
+        grant: Page.GRANT_OWNER,
+        grantedUsers: [dummyUser1._id],
+        creator: dummyUser1,
+        lastUpdateUser: dummyUser1._id,
+        isEmpty: false,
+        parent: rootPage._id,
+        descendantCount: 0,
+      },
+    ]);
+
+    /**
+     * getParentAndFillAncestors
+     */
+    const pageIdPAF1 = new mongoose.Types.ObjectId();
+    const pageIdPAF2 = new mongoose.Types.ObjectId();
+    const pageIdPAF3 = new mongoose.Types.ObjectId();
+
+    await Page.insertMany([
+      {
+        _id: pageIdPAF1,
+        path: '/PAF1',
+        grant: Page.GRANT_PUBLIC,
+        creator: dummyUser1,
+        lastUpdateUser: dummyUser1._id,
+        isEmpty: false,
+        parent: rootPage._id,
+        descendantCount: 0,
+      },
+      {
+        _id: pageIdPAF2,
+        path: '/emp_anc3',
+        grant: Page.GRANT_PUBLIC,
+        isEmpty: true,
+        descendantCount: 1,
+        parent: rootPage._id,
+      },
+      {
+        path: '/emp_anc3/PAF3',
+        grant: Page.GRANT_PUBLIC,
+        creator: dummyUser1,
+        lastUpdateUser: dummyUser1._id,
+        isEmpty: false,
+        descendantCount: 0,
+        parent: pageIdPAF2,
+      },
+      {
+        _id: pageIdPAF3,
+        path: '/emp_anc4',
+        grant: Page.GRANT_PUBLIC,
+        isEmpty: true,
+        descendantCount: 1,
+        parent: rootPage._id,
+      },
+      {
+        path: '/emp_anc4/PAF4',
+        grant: Page.GRANT_PUBLIC,
+        creator: dummyUser1,
+        lastUpdateUser: dummyUser1._id,
+        isEmpty: false,
+        descendantCount: 0,
+        parent: pageIdPAF3,
+      },
+      {
+        path: '/emp_anc4',
+        grant: Page.GRANT_OWNER,
+        grantedUsers: [dummyUser1._id],
+        creator: dummyUser1,
+        lastUpdateUser: dummyUser1._id,
+        isEmpty: false,
+      },
     ]);
 
   });
@@ -351,6 +569,13 @@ describe('Page', () => {
 
   describe('update', () => {
 
+    const updatePage = async(page, newRevisionBody, oldRevisionBody, user, options = {}) => {
+      const mockedRenameSubOperation = jest.spyOn(Page, 'emitPageEventUpdate').mockReturnValue(null);
+      const savedPage = await Page.updatePage(page, newRevisionBody, oldRevisionBody, user, options);
+      mockedRenameSubOperation.mockRestore();
+      return savedPage;
+    };
+
     describe('Changing grant from PUBLIC to RESTRICTED of', () => {
       test('an only-child page will delete its empty parent page', async() => {
         const pathT = '/mup13_top';
@@ -423,6 +648,22 @@ describe('Page', () => {
         expect(_pageT.descendantCount).toBe(0);
       });
     });
+
+    describe('Changing grant to GRANT_RESTRICTED', () => {
+      test('successfully change to GRANT_RESTRICTED from GRANT_OWNER', async() => {
+        const path = '/mup24';
+        const _page = await Page.findOne({ path, grant: Page.GRANT_OWNER, grantedUsers: [dummyUser1._id] });
+        expect(_page).toBeTruthy();
+
+        await updatePage(_page, 'newRevisionBody', 'oldRevisionBody', dummyUser1, { grant: Page.GRANT_RESTRICTED });
+
+        const page = await Page.findOne({ path });
+        expect(page).toBeTruthy();
+        expect(page.grant).toBe(Page.GRANT_RESTRICTED);
+        expect(page.grantedUsers).toStrictEqual([]);
+      });
+    });
+
     describe('Changing grant from RESTRICTED to PUBLIC of', () => {
       test('a page will create ancestors if they do not exist', async() => {
         const pathT = '/mup16_top';
@@ -482,5 +723,140 @@ describe('Page', () => {
       });
     });
 
+    describe('Changing grant to GRANT_OWNER(onlyme)', () => {
+      test('successfully change to GRANT_OWNER from GRANT_PUBLIC', async() => {
+        const path = '/mup19';
+        const _page = await Page.findOne({ path, grant: Page.GRANT_PUBLIC });
+        expect(_page).toBeTruthy();
+
+        await updatePage(_page, 'newRevisionBody', 'oldRevisionBody', dummyUser1, { grant: Page.GRANT_OWNER });
+
+        const page = await Page.findOne({ path });
+        expect(page.grant).toBe(Page.GRANT_OWNER);
+        expect(page.grantedUsers).toStrictEqual([dummyUser1._id]);
+
+      });
+      test('successfully change to GRANT_OWNER from GRANT_USER_GROUP', async() => {
+        const path = '/mup20';
+        const _page = await Page.findOne({ path, grant: Page.GRANT_USER_GROUP, grantedGroup: groupIdA });
+        expect(_page).toBeTruthy();
+
+        await updatePage(_page, 'newRevisionBody', 'oldRevisionBody', pModelUser1, { grant: Page.GRANT_OWNER });
+
+        const page = await Page.findOne({ path });
+        expect(page.grant).toBe(Page.GRANT_OWNER);
+        expect(page.grantedUsers).toStrictEqual([pModelUser1._id]);
+        expect(page.grantedGroup).toBeNull();
+      });
+      test('successfully change to GRANT_OWNER from GRANT_RESTRICTED', async() => {
+        const path = '/mup21';
+        const _page = await Page.findOne({ path, grant: Page.GRANT_RESTRICTED });
+        expect(_page).toBeTruthy();
+
+        await updatePage(_page, 'newRevisionBody', 'oldRevisionBody', dummyUser1, { grant: Page.GRANT_OWNER });
+
+        const page = await Page.findOne({ path });
+        expect(page.grant).toBe(Page.GRANT_OWNER);
+        expect(page.grantedUsers).toStrictEqual([dummyUser1._id]);
+      });
+      test('Failed to change to GRANT_OWNER if one of the ancestors is GRANT_USER_GROUP page', async() => {
+        const path1 = '/mup22';
+        const path2 = '/mup22/mup23';
+        const _page1 = await Page.findOne({ path: path1, grant: Page.GRANT_PUBLIC });
+        const _page2 = await Page.findOne({ path: path2, grant: Page.GRANT_USER_GROUP, grantedGroup: groupIdA });
+        expect(_page1).toBeTruthy();
+        expect(_page2).toBeTruthy();
+
+        await expect(updatePage(_page1, 'newRevisionBody', 'oldRevisionBody', dummyUser1, { grant: Page.GRANT_OWNER }))
+          .rejects.toThrow(new Error('The selected grant or grantedGroup is not assignable to this page.'));
+
+        const page1 = await Page.findOne({ path1 });
+        expect(page1).toBeTruthy();
+        expect(page1.grant).toBe(Page.GRANT_PUBLIC);
+        expect(page1.grantedUsers).not.toStrictEqual([dummyUser1._id]);
+      });
+    });
+
+  });
+
+  describe('getParentAndFillAncestors', () => {
+    test('return parent if exist', async() => {
+      const page1 = await Page.findOne({ path: '/PAF1' });
+      const parent = await Page.getParentAndFillAncestors(page1.path, dummyUser1);
+      expect(parent).toBeTruthy();
+      expect(page1.parent).toStrictEqual(parent._id);
+    });
+    test('create parent and ancestors when they do not exist, and return the new parent', async() => {
+      const path1 = '/emp_anc1';
+      const path2 = '/emp_anc1/emp_anc2';
+      const path3 = '/emp_anc1/emp_anc2/PAF2';
+      const _page1 = await Page.findOne({ path: path1 }); // not exist
+      const _page2 = await Page.findOne({ path: path2 }); // not exist
+      const _page3 = await Page.findOne({ path: path3 }); // not exist
+      expect(_page1).toBeNull();
+      expect(_page2).toBeNull();
+      expect(_page3).toBeNull();
+
+      const parent = await Page.getParentAndFillAncestors(path3, dummyUser1);
+      const page1 = await Page.findOne({ path: path1 });
+      const page2 = await Page.findOne({ path: path2 });
+      const page3 = await Page.findOne({ path: path3 });
+
+      expect(parent._id).toStrictEqual(page2._id);
+      expect(parent.path).toStrictEqual(page2.path);
+      expect(parent.parent).toStrictEqual(page2.parent);
+
+      expect(parent).toBeTruthy();
+      expect(page1).toBeTruthy();
+      expect(page2).toBeTruthy();
+      expect(page3).toBeNull();
+
+      expect(page1.parent).toStrictEqual(rootPage._id);
+      expect(page2.parent).toStrictEqual(page1._id);
+    });
+    test('return parent even if the parent page is empty', async() => {
+      const path1 = '/emp_anc3';
+      const path2 = '/emp_anc3/PAF3';
+      const _page1 = await Page.findOne({ path: path1, isEmpty: true });
+      const _page2 = await Page.findOne({ path: path2, isEmpty: false });
+      expect(_page1).toBeTruthy();
+      expect(_page2).toBeTruthy();
+
+      const parent = await Page.getParentAndFillAncestors(_page2.path, dummyUser1);
+      const page1 = await Page.findOne({ path: path1, isEmpty: true }); // parent
+      const page2 = await Page.findOne({ path: path2, isEmpty: false });
+
+      // check for the parent (should be the same as page1)
+      expect(parent._id).toStrictEqual(page1._id);
+      expect(parent.path).toStrictEqual(page1.path);
+      expect(parent.parent).toStrictEqual(page1.parent);
+
+      expect(page1.parent).toStrictEqual(rootPage._id);
+      expect(page2.parent).toStrictEqual(page1._id);
+    });
+    test("should find parent while NOT updating private legacy page's parent", async() => {
+      const path1 = '/emp_anc4';
+      const path2 = '/emp_anc4/PAF4';
+      const _page1 = await Page.findOne({ path: path1, isEmpty: true, grant: Page.GRANT_PUBLIC });
+      const _page2 = await Page.findOne({ path: path2, isEmpty: false, grant: Page.GRANT_PUBLIC });
+      const _page3 = await Page.findOne({ path: path1, isEmpty: false, grant: Page.GRANT_OWNER });
+      expect(_page1).toBeTruthy();
+      expect(_page2).toBeTruthy();
+      expect(_page3).toBeTruthy();
+      expect(_page3.parent).toBeNull();
+
+      const parent = await Page.getParentAndFillAncestors(_page2.path, dummyUser1);
+      const page1 = await Page.findOne({ path: path1, isEmpty: true, grant: Page.GRANT_PUBLIC });
+      const page2 = await Page.findOne({ path: path2, isEmpty: false, grant: Page.GRANT_PUBLIC });
+      const page3 = await Page.findOne({ path: path1, isEmpty: false, grant: Page.GRANT_OWNER });
+      expect(page1).toBeTruthy();
+      expect(page2).toBeTruthy();
+      expect(page3).toBeTruthy();
+      expect(page3.parent).toBeNull(); // parent property of page in private legacy pages should be null
+
+      expect(page1._id).toStrictEqual(parent._id);
+      expect(page2.parent).toStrictEqual(parent._id);
+
+    });
   });
 });

+ 77 - 0
packages/app/test/integration/service/user-groups.test.ts

@@ -0,0 +1,77 @@
+
+import mongoose from 'mongoose';
+
+import { getInstance } from '../setup-crowi';
+
+describe('UserGroupService', () => {
+  let crowi;
+  let UserGroup;
+
+  const groupId1 = new mongoose.Types.ObjectId();
+  const groupId2 = new mongoose.Types.ObjectId();
+  const groupId3 = new mongoose.Types.ObjectId();
+
+
+  beforeAll(async() => {
+    crowi = await getInstance();
+
+    UserGroup = mongoose.model('UserGroup');
+
+
+    // Create Groups
+    await UserGroup.insertMany([
+      // No parent
+      {
+        _id: groupId1,
+        name: 'v5_group1',
+        description: 'description1',
+      },
+      // No parent
+      {
+        _id: groupId2,
+        name: 'v5_group2',
+        description: 'description2',
+      },
+      {
+        _id: groupId3,
+        name: 'v5_group3',
+        parent: groupId1,
+        description: 'description3',
+      },
+    ]);
+  });
+
+  /*
+    * Update UserGroup
+    */
+  test('Updated values should be reflected. (name, description, parent)', async() => {
+    const userGroup = await UserGroup.findOne({ _id: groupId1 });
+
+    const newGroupName = 'v5_group1_new';
+    const newGroupDescription = 'description1_new';
+    const newParentId = groupId2;
+
+    const updatedUserGroup = await crowi.userGroupService.updateGroup(userGroup._id, newGroupName, newGroupDescription, newParentId);
+
+    expect(updatedUserGroup.name).toBe(newGroupName);
+    expect(updatedUserGroup.description).toBe(newGroupDescription);
+    expect(updatedUserGroup.parent).toStrictEqual(newParentId);
+  });
+
+  test('Should throw an error when trying to set existing group name', async() => {
+    const userGroup1 = await UserGroup.findOne({ _id: groupId1 });
+    const userGroup2 = await UserGroup.findOne({ _id: groupId2 });
+
+    const result = crowi.userGroupService.updateGroup(userGroup1._id, userGroup2.name);
+
+    await expect(result).rejects.toThrow('The group name is already taken');
+  });
+
+  test('Parent should be null when parent group is released', async() => {
+    const userGroup = await UserGroup.findOne({ _id: groupId3 });
+    const updatedUserGroup = await crowi.userGroupService.updateGroup(userGroup._id, userGroup.name, userGroup.description, null);
+
+    expect(updatedUserGroup.parent).toBeNull();
+  });
+
+});

+ 283 - 3
packages/app/test/integration/service/v5.migration.test.js

@@ -222,14 +222,12 @@ describe('V5 page migration', () => {
         path: '/normalize_10/normalize_11_gA',
         grant: Page.GRANT_USER_GROUP,
         grantedGroup: groupIdA,
-        grantedUsers: [testUser1._id],
       },
       {
         _id: pageId10,
         path: '/normalize_10/normalize_11_gA/normalize_11_gB',
         grant: Page.GRANT_USER_GROUP,
         grantedGroup: groupIdB,
-        grantedUsers: [testUser1._id],
         parent: pageId8,
         descendantCount: 0,
       },
@@ -342,6 +340,288 @@ describe('V5 page migration', () => {
 
   });
 
+  describe('should normalize only selected pages recursively (while observing the page permission rule)', () => {
+    /*
+     * # Test flow 1
+     * - Existing pages
+     *   - v5 compatible pages
+     *     - /normalize_a (empty)
+     *     - /normalize_a/normalize_b (public)
+     *   - v4 pages
+     *     - /normalize_a (user group)
+     *     - /normalize_c (user group)
+     *
+     * - Normalize /normalize_a (user group)
+     *   - Expect
+     *     - Error should be thrown
+     *
+     *
+     * # Test flow 2
+     * - Existing pages
+     *   - v5 compatible pages
+     *     - /normalize_d (empty)
+     *     - /normalize_d/normalize_e (user group)
+     *   - v4 pages
+     *     - /normalize_d (user group)
+     *     - /normalize_f (user group)
+     *
+     * - Normalize /normalize_d (user group)
+     *   - Expect
+     *     - Normalization succeeds
+     *     - /normalize_f (user group) remains in v4 schema
+     *
+     *
+     * # Test flow 3 (should replace all unnecessary empty pages)
+     * - Existing pages
+     *   - v5 compatible pages
+     *     - / (root)
+     *     - /normalize_g (public)
+     *   - v4 pages
+     *     - /normalize_g/normalize_h (only me)
+     *     - /normalize_g/normalize_i (only me)
+     *     - /normalize_g/normalize_h/normalize_j (only me)
+     *     - /normalize_g/normalize_i/normalize_k (only me)
+     *
+     * - Normalize /normalize_g/normalize_h/normalize_j (only me) & /normalize_g/normalize_i/normalize_k (only me)
+     *   - Expect
+     *     - /normalize_g/normalize_h (empty)
+     *       - parent is /normalize_g (public)
+     *     - /normalize_g/normalize_i (empty)
+     *       - parent is /normalize_g (public)
+     *     - /normalize_g/normalize_h/normalize_j (only me) is normalized
+     *     - /normalize_g/normalize_i/normalize_k (only me) is normalized
+     */
+
+    const public = filter => ({ grant: Page.GRANT_PUBLIC, ...filter });
+    const owned = filter => ({ grant: Page.GRANT_OWNER, grantedUsers: [testUser1._id], ...filter });
+    const testUser1Group = filter => ({ grantedGroup: testUser1GroupId, ...filter });
+
+    const normalized = { parent: { $ne: null } };
+    const notNormalized = { parent: null };
+    const empty = { isEmpty: true };
+
+    beforeAll(async() => {
+      // Prepare data
+      const id1 = new mongoose.Types.ObjectId();
+      const id2 = new mongoose.Types.ObjectId();
+      const id3 = new mongoose.Types.ObjectId();
+      const id4 = new mongoose.Types.ObjectId();
+
+      await Page.insertMany([
+        // 1
+        {
+          _id: id3,
+          path: '/deep_path',
+          grant: Page.GRANT_PUBLIC,
+          parent: rootPage._id,
+        },
+        {
+          _id: id1,
+          path: '/deep_path/normalize_a',
+          isEmpty: true,
+          parent: id3,
+        },
+        {
+          path: '/deep_path/normalize_a/normalize_b',
+          grant: Page.GRANT_PUBLIC,
+          parent: id1,
+        },
+        {
+          path: '/deep_path/normalize_a',
+          grant: Page.GRANT_USER_GROUP,
+          grantedGroup: testUser1GroupId,
+          parent: null,
+        },
+        {
+          path: '/deep_path/normalize_c',
+          grant: Page.GRANT_USER_GROUP,
+          grantedGroup: testUser1GroupId,
+          parent: null,
+        },
+
+        // 2
+        {
+          _id: id2,
+          path: '/normalize_d',
+          isEmpty: true,
+          parent: rootPage._id,
+        },
+        {
+          path: '/normalize_d',
+          grant: Page.GRANT_USER_GROUP,
+          grantedGroup: testUser1GroupId,
+          parent: null,
+        },
+        {
+          path: '/normalize_d/normalize_e',
+          grant: Page.GRANT_USER_GROUP,
+          grantedGroup: testUser1GroupId,
+          parent: id2,
+        },
+        {
+          path: '/normalize_f',
+          grant: Page.GRANT_USER_GROUP,
+          grantedGroup: testUser1GroupId,
+          parent: null,
+        },
+
+        // 3
+        {
+          _id: id4,
+          path: '/normalize_g',
+          parent: rootPage._id,
+        },
+        {
+          path: '/normalize_g/normalize_h',
+          grant: Page.GRANT_OWNER,
+          grantedUsers: [testUser1._id],
+          parent: null,
+        },
+        {
+          path: '/normalize_g/normalize_i',
+          grant: Page.GRANT_OWNER,
+          grantedUsers: [testUser1._id],
+          parent: null,
+        },
+        {
+          path: '/normalize_g/normalize_h/normalize_j',
+          grant: Page.GRANT_OWNER,
+          grantedUsers: [testUser1._id],
+          parent: null,
+        },
+        {
+          path: '/normalize_g/normalize_i/normalize_k',
+          grant: Page.GRANT_OWNER,
+          grantedUsers: [testUser1._id],
+          parent: null,
+        },
+      ]);
+    });
+
+    test('should not run normalization when the target page is GRANT_USER_GROUP surrounded by public pages', async() => {
+      const mockMainOperation = jest.spyOn(crowi.pageService, 'normalizeParentRecursivelyMainOperation').mockImplementation(v => v);
+      const _page1 = await Page.findOne(public({ path: '/deep_path/normalize_a', ...empty }));
+      const _page2 = await Page.findOne(public({ path: '/deep_path/normalize_a/normalize_b', ...normalized }));
+      const _page3 = await Page.findOne(testUser1Group({ path: '/deep_path/normalize_a', ...notNormalized }));
+      const _page4 = await Page.findOne(testUser1Group({ path: '/deep_path/normalize_c', ...notNormalized }));
+
+      expect(_page1).not.toBeNull();
+      expect(_page2).not.toBeNull();
+      expect(_page3).not.toBeNull();
+      expect(_page4).not.toBeNull();
+
+      // Normalize
+      await normalizeParentRecursivelyByPages([_page3], testUser1);
+
+      expect(mockMainOperation).not.toHaveBeenCalled();
+
+      mockMainOperation.mockRestore();
+    });
+
+    test('should not include siblings', async() => {
+      const _page1 = await Page.findOne(public({ path: '/normalize_d', ...empty }));
+      const _page2 = await Page.findOne(testUser1Group({ path: '/normalize_d/normalize_e', ...normalized }));
+      const _page3 = await Page.findOne(testUser1Group({ path: '/normalize_d', ...notNormalized }));
+      const _page4 = await Page.findOne(testUser1Group({ path: '/normalize_f', ...notNormalized }));
+
+      expect(_page1).not.toBeNull();
+      expect(_page2).not.toBeNull();
+      expect(_page3).not.toBeNull();
+      expect(_page4).not.toBeNull();
+
+      // Normalize
+      await normalizeParentRecursivelyByPages([_page3], testUser1);
+
+      const page1 = await Page.findOne(testUser1Group({ path: '/normalize_d/normalize_e' }));
+      const page2 = await Page.findOne(testUser1Group({ path: '/normalize_d' }));
+      const page3 = await Page.findOne(testUser1Group({ path: '/normalize_f' }));
+      const empty4 = await Page.findOne(public({ path: '/normalize_d', ...empty }));
+
+      expect(page1).not.toBeNull();
+      expect(page2).not.toBeNull();
+      expect(page3).not.toBeNull();
+      expect(empty4).toBeNull(); // empty page should be removed
+
+      // Check parent
+      expect(page1.parent).toStrictEqual(page2._id);
+      expect(page2.parent).toStrictEqual(rootPage._id);
+      expect(page3.parent).toBeNull(); // should not be normalized
+
+      // Check descendantCount
+      expect(page1.descendantCount).toBe(0);
+      expect(page2.descendantCount).toBe(1);
+      expect(page3.descendantCount).toBe(0); // should not be normalized
+    });
+
+    test('should replace all unnecessary empty pages and normalization succeeds', async() => {
+      const _pageG = await Page.findOne(public({ path: '/normalize_g', ...normalized }));
+      const _pageGH = await Page.findOne(owned({ path: '/normalize_g/normalize_h', ...notNormalized }));
+      const _pageGI = await Page.findOne(owned({ path: '/normalize_g/normalize_i', ...notNormalized }));
+      const _pageGHJ = await Page.findOne(owned({ path: '/normalize_g/normalize_h/normalize_j', ...notNormalized }));
+      const _pageGIK = await Page.findOne(owned({ path: '/normalize_g/normalize_i/normalize_k', ...notNormalized }));
+
+      expect(_pageG).not.toBeNull();
+      expect(_pageGH).not.toBeNull();
+      expect(_pageGI).not.toBeNull();
+      expect(_pageGHJ).not.toBeNull();
+      expect(_pageGIK).not.toBeNull();
+
+      // Normalize
+      await normalizeParentRecursivelyByPages([_pageGHJ, _pageGIK], testUser1);
+
+      const countG = await Page.count({ path: '/normalize_g' });
+      const countGH = await Page.count({ path: '/normalize_g/normalize_h' });
+      const countGI = await Page.count({ path: '/normalize_g/normalize_i' });
+      const countGHJ = await Page.count({ path: '/normalize_g/normalize_h/normalize_j' });
+      const countGIK = await Page.count({ path: '/normalize_g/normalize_i/normalize_k' });
+
+      expect(countG).toBe(1);
+      expect(countGH).toBe(2);
+      expect(countGI).toBe(2);
+      expect(countGHJ).toBe(1);
+      expect(countGIK).toBe(1);
+
+      // -- normalized pages
+      const pageG = await Page.findOne(public({ path: '/normalize_g' }));
+      const emptyGH = await Page.findOne({ path: '/normalize_g/normalize_h', ...empty });
+      const emptyGI = await Page.findOne({ path: '/normalize_g/normalize_i', ...empty });
+      const pageGHJ = await Page.findOne({ path: '/normalize_g/normalize_h/normalize_j' });
+      const pageGIK = await Page.findOne({ path: '/normalize_g/normalize_i/normalize_k' });
+
+      // Check existence
+      expect(pageG).not.toBeNull();
+      expect(pageGHJ).not.toBeNull();
+      expect(pageGIK).not.toBeNull();
+      expect(emptyGH).not.toBeNull();
+      expect(emptyGI).not.toBeNull();
+      // Check parent
+      expect(pageG.parent).toStrictEqual(rootPage._id);
+      expect(emptyGH.parent).toStrictEqual(pageG._id);
+      expect(emptyGI.parent).toStrictEqual(pageG._id);
+      expect(pageGHJ.parent).toStrictEqual(emptyGH._id);
+      expect(pageGIK.parent).toStrictEqual(emptyGI._id);
+      // Check descendantCount
+      expect(pageG.descendantCount).toStrictEqual(2);
+      expect(emptyGH.descendantCount).toStrictEqual(1);
+      expect(emptyGI.descendantCount).toStrictEqual(1);
+      expect(pageGHJ.descendantCount).toStrictEqual(0);
+      expect(pageGIK.descendantCount).toStrictEqual(0);
+
+      // -- not normalized pages
+      const pageGH = await Page.findOne(owned({ path: '/normalize_g/normalize_h' }));
+      const pageGI = await Page.findOne(owned({ path: '/normalize_g/normalize_i' }));
+      // Check existence
+      expect(pageGH).not.toBeNull();
+      expect(pageGI).not.toBeNull();
+      // Check parent
+      expect(pageGH.parent).toBeNull(); // should not be normalized
+      expect(pageGI.parent).toBeNull(); // should not be normalized
+      // Check descendantCount
+      expect(pageGH.descendantCount).toStrictEqual(0); // should not be normalized
+      expect(pageGI.descendantCount).toStrictEqual(0); // should not be normalized
+    });
+  });
+
   describe('should normalize only selected pages recursively (especially should NOT normalize non-selected ancestors)', () => {
     /*
      * # Test flow
@@ -506,7 +786,7 @@ describe('V5 page migration', () => {
     });
 
 
-    test('Should normalize pages one by one without including other pages', async() => {
+    test('Should normalize a single page without including other pages', async() => {
       const _owned13 = await Page.findOne(owned({ path: '/normalize_13_owned', ...notNormalized }));
       const _owned14 = await Page.findOne(owned({ path: '/normalize_13_owned/normalize_14_owned', ...notNormalized }));
       const _owned15 = await Page.findOne(owned({ path: '/normalize_13_owned/normalize_14_owned/normalize_15_owned', ...notNormalized }));

+ 1 - 0
packages/app/test/integration/setup-crowi.js

@@ -23,6 +23,7 @@ const initCrowi = async(crowi) => {
     crowi.setupPageService(),
     crowi.setupInAppNotificationService(),
     crowi.setupActivityService(),
+    crowi.setupUserGroupService(),
   ]);
 };
 

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

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

+ 2 - 2
packages/codemirror-textlint/src/index.ts

@@ -1,6 +1,7 @@
+import textlintRuleNoUnmatchedPair from '@textlint-rule/textlint-rule-no-unmatched-pair';
 import { TextlintKernel, TextlintKernelRule, TextlintRuleOptions } from '@textlint/kernel';
+import { AsyncLinter, Annotation } from 'codemirror/addon/lint/lint';
 import textlintToCodeMirror from 'textlint-message-to-codemirror';
-import textlintRuleNoUnmatchedPair from '@textlint-rule/textlint-rule-no-unmatched-pair';
 import textlintRuleCommonMisspellings from 'textlint-rule-common-misspellings';
 import textlintRuleDateWeekdayMismatch from 'textlint-rule-date-weekday-mismatch';
 // import textlintRuleEnCapitalization from 'textlint-rule-en-capitalization';  // omit because en-pos package is too big
@@ -28,7 +29,6 @@ import textlintRulePreferTariTari from 'textlint-rule-prefer-tari-tari';
 import textlintRuleSentenceLength from 'textlint-rule-sentence-length';
 import textlintRuleUseSiUnits from 'textlint-rule-use-si-units';
 
-import { AsyncLinter, Annotation } from 'codemirror/addon/lint/lint';
 import { loggerFactory } from './utils/logger';
 
 type RulesConfigObj = {

+ 1 - 1
packages/core/package.json

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

+ 3 - 2
packages/core/src/index.js

@@ -1,9 +1,9 @@
-import * as _pathUtils from './utils/path-utils';
+import * as _customTagUtils from './plugin/util/custom-tag-utils';
 import * as _envUtils from './utils/env-utils';
 import * as _pagePathUtils from './utils/page-path-utils';
 import * as _pageUtils from './utils/page-utils';
+import * as _pathUtils from './utils/path-utils';
 import * as _templateChecker from './utils/template-checker';
-import * as _customTagUtils from './plugin/util/custom-tag-utils';
 
 // export utils
 export const pathUtils = _pathUtils;
@@ -18,4 +18,5 @@ export * from './plugin/service/tag-cache-manager';
 export * from './models/devided-page-path';
 export * from './service/localstorage-manager';
 export * from './utils/basic-interceptor';
+export * from './utils/browser-utils';
 export * from './utils/mongoose-utils';

+ 1 - 1
packages/core/src/test/plugin/service/tag-cache-manager.test.js

@@ -3,8 +3,8 @@
 // import each from 'jest-each';
 jest.mock('~/service/localstorage-manager');
 
-import LocalStorageManager from '~/service/localstorage-manager';
 import TagCacheManager from '~/plugin/service/tag-cache-manager';
+import LocalStorageManager from '~/service/localstorage-manager';
 /* eslint-enable import/first */
 
 describe('TagCacheManager.constructor', () => {

+ 7 - 0
packages/core/src/utils/browser-utils.ts

@@ -0,0 +1,7 @@
+export const isClient = (): boolean => {
+  return (typeof window !== 'undefined') || (typeof navigator !== 'undefined' && navigator.webdriver);
+};
+
+export const isServer = (): boolean => {
+  return !isClient();
+};

+ 1 - 0
packages/core/src/utils/page-path-utils.ts

@@ -1,6 +1,7 @@
 import nodePath from 'path';
 
 import escapeStringRegexp from 'escape-string-regexp';
+
 import { addTrailingSlash } from './path-utils';
 
 /**

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

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

+ 1 - 1
packages/plugin-attachment-refs/src/client-entry.js

@@ -1,5 +1,5 @@
-import RefsPreRenderInterceptor from './client/js/util/Interceptor/RefsPreRenderInterceptor';
 import RefsPostRenderInterceptor from './client/js/util/Interceptor/RefsPostRenderInterceptor';
+import RefsPreRenderInterceptor from './client/js/util/Interceptor/RefsPreRenderInterceptor';
 
 export default (appContainer) => {
   // add interceptors

+ 4 - 5
packages/plugin-attachment-refs/src/client/js/components/AttachmentList.jsx

@@ -1,16 +1,15 @@
-import React from 'react';
+import { Attachment } from '@growi/ui';
+import axios from 'axios'; // import axios from growi dependencies
 import PropTypes from 'prop-types';
+import React from 'react';
 
 // eslint-disable-next-line import/no-unresolved
-import axios from 'axios'; // import axios from growi dependencies
-
-import { Attachment } from '@growi/ui';
 
+import styles from '../../css/index.css';
 import RefsContext from '../util/RefsContext';
 import TagCacheManagerFactory from '../util/TagCacheManagerFactory';
 
 // eslint-disable-next-line no-unused-vars
-import styles from '../../css/index.css';
 
 import ExtractedAttachments from './ExtractedAttachments';
 

+ 1 - 2
packages/plugin-attachment-refs/src/client/js/components/ExtractedAttachments.jsx

@@ -1,6 +1,5 @@
-import React from 'react';
 import PropTypes from 'prop-types';
-
+import React from 'react';
 import Carousel, { Modal, ModalGateway } from 'react-images';
 
 import RefsContext from '../util/RefsContext';

+ 3 - 3
packages/plugin-attachment-refs/src/client/js/util/Interceptor/RefsPostRenderInterceptor.js

@@ -1,12 +1,12 @@
+import { BasicInterceptor } from '@growi/core';
 import React from 'react';
 import ReactDOM from 'react-dom';
 
-import { BasicInterceptor } from '@growi/core';
 
-import RefsContext from '../RefsContext';
+import AttachmentList from '../../components/AttachmentList';
 import GalleryContext from '../GalleryContext';
+import RefsContext from '../RefsContext';
 
-import AttachmentList from '../../components/AttachmentList';
 
 /**
  * The interceptor for refs

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

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

+ 1 - 1
packages/plugin-lsx/src/client-entry.js

@@ -1,6 +1,6 @@
 import { LsxLogoutInterceptor } from './client/js/util/Interceptor/LsxLogoutInterceptor';
-import { LsxPreRenderInterceptor } from './client/js/util/Interceptor/LsxPreRenderInterceptor';
 import { LsxPostRenderInterceptor } from './client/js/util/Interceptor/LsxPostRenderInterceptor';
+import { LsxPreRenderInterceptor } from './client/js/util/Interceptor/LsxPreRenderInterceptor';
 
 export default (appContainer) => {
   // add interceptors

+ 6 - 3
packages/plugin-lsx/src/client/js/components/Lsx.jsx

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

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

@@ -1,8 +1,9 @@
-import React from 'react';
 import PropTypes from 'prop-types';
+import React from 'react';
 
-import { PageNode } from '../PageNode';
 import { LsxContext } from '../../util/LsxContext';
+import { PageNode } from '../PageNode';
+
 import { LsxPage } from './LsxPage';
 
 export class LsxListView extends React.Component {

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

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

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

@@ -1,7 +1,7 @@
-import React from 'react';
+import { PagePathLabel } from '@growi/ui';
 import PropTypes from 'prop-types';
+import React from 'react';
 
-import { PagePathLabel } from '@growi/ui';
 
 export class PagePathWrapper extends React.Component {
 

+ 2 - 2
packages/plugin-lsx/src/client/js/util/Interceptor/LsxPostRenderInterceptor.js

@@ -1,10 +1,10 @@
+import { BasicInterceptor } from '@growi/core';
 import React from 'react';
 import ReactDOM from 'react-dom';
 
-import { BasicInterceptor } from '@growi/core';
 
-import { LsxContext } from '../LsxContext';
 import { Lsx } from '../../components/Lsx';
+import { LsxContext } from '../LsxContext';
 
 /**
  * The interceptor for lsx

+ 1 - 1
packages/plugin-lsx/src/client/js/util/Interceptor/LsxPreRenderInterceptor.js

@@ -1,5 +1,5 @@
-import ReactDOM from 'react-dom';
 import { customTagUtils, BasicInterceptor } from '@growi/core';
+import ReactDOM from 'react-dom';
 
 /**
  * The interceptor for lsx

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

@@ -162,6 +162,7 @@ class Lsx {
 
 module.exports = (crowi, app) => {
   const Page = crowi.model('Page');
+  const User = crowi.model('User');
   const ApiResponse = crowi.require('../util/apiResponse');
   const actions = {};
 
@@ -207,6 +208,15 @@ module.exports = (crowi, app) => {
 
     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;
     try {
       // depth
@@ -227,7 +237,7 @@ module.exports = (crowi, app) => {
       query = Lsx.addSortCondition(query, pagePath, options.sort, options.reverse);
 
       const pages = await query.exec();
-      res.json(ApiResponse.success({ pages }));
+      res.json(ApiResponse.success({ pages, activeUsersCount }));
     }
     catch (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",
-  "version": "5.0.1-RC.0",
+  "version": "5.0.3-RC.0",
   "description": "GROWI plugin to add PukiwikiLikeLinker",
   "license": "MIT",
   "keywords": [

+ 1 - 1
packages/slack/package.json

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

+ 1 - 0
packages/slack/src/interfaces/growi-interaction-processor.ts

@@ -1,4 +1,5 @@
 import { AuthorizeResult } from '@slack/oauth';
+
 import { InteractionPayloadAccessor } from '../utils/interaction-payload-accessor';
 
 

Unele fișiere nu au fost afișate deoarece prea multe fișiere au fost modificate în acest diff