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

Merge branch 'dev/5.0.x' into imprv/81351-81439-pt-smooth-scroll

* dev/5.0.x: (294 commits)
  fix the hidden condition of NotFoundAlert
  remove unnecessary workflow
  fix lint errors
  fix lint errors
  use PageListItemL
  impl type guard for IPageSearchMeta
  refactor DescendantsPageList
  rename PageListItem related components
  clean code
  devide PageList to DescendantsPageList
  inject path to PageList with props
  move alert for not found page
  move the alert for link sharing disabled to ForbiddenPage
  typescriptize ForbiddenPage
  clean bs4 classes and scss
  reorganize types for PageListItem
  move PageListItem
  typescriptize NotFoundPage
  adjust layout
  hide PageAccessories
  ...
Mao 4 лет назад
Родитель
Сommit
a9f8842847
100 измененных файлов с 2117 добавлено и 1097 удалено
  1. 3 1
      .devcontainer/Dockerfile
  2. 10 0
      .eslintrc.js
  3. 44 0
      .github/workflows/ci-app-prod.yml
  4. 37 95
      .github/workflows/ci-app.yml
  5. 22 12
      .github/workflows/ci-slackbot-proxy.yml
  6. 275 0
      .github/workflows/reusable-app-prod.yml
  7. 91 0
      .github/workflows/reusable-app-reg-suit.yml
  8. 44 1
      CHANGELOG.md
  9. 3 1
      README.md
  10. 3 1
      README_JP.md
  11. 53 0
      bin/github-actions/generate-cypress-spec-arg.js
  12. 9 1
      package.json
  13. 2 0
      packages/app/.env.development
  14. 12 0
      packages/app/.eslintrc.js
  15. 5 0
      packages/app/.gitignore
  16. 7 0
      packages/app/config/ci/.env.local.for-auto-install
  17. 17 0
      packages/app/cypress.json
  18. 6 6
      packages/app/docker/Dockerfile
  19. 2 2
      packages/app/docker/README.md
  20. 7 12
      packages/app/jest.config.js
  21. 13 7
      packages/app/package.json
  22. 25 0
      packages/app/regconfig.json
  23. 2 1
      packages/app/resource/locales/en_US/translation.json
  24. 1 0
      packages/app/resource/locales/ja_JP/translation.json
  25. 1 0
      packages/app/resource/locales/zh_CN/translation.json
  26. 0 0
      packages/app/resource/search/mappings-es6.json
  27. 115 0
      packages/app/resource/search/mappings-es7.json
  28. 2 5
      packages/app/src/client/app.jsx
  29. 7 7
      packages/app/src/client/nologin.jsx
  30. 11 6
      packages/app/src/client/services/ContextExtractor.tsx
  31. 0 81
      packages/app/src/client/services/PageContainer.js
  32. 3 3
      packages/app/src/components/Admin/Security/SecuritySetting.jsx
  33. 2 2
      packages/app/src/components/Admin/UserGroupDetail/UserGroupPageList.jsx
  34. 1 1
      packages/app/src/components/Common/Dropdown/PageItemControl.tsx
  35. 56 0
      packages/app/src/components/DescendantsPageList.tsx
  36. 0 26
      packages/app/src/components/DuplicatePage.tsx
  37. 20 17
      packages/app/src/components/ForbiddenPage.tsx
  38. 92 6
      packages/app/src/components/IdenticalPathPage.tsx
  39. 14 4
      packages/app/src/components/InstallerForm.jsx
  40. 0 1
      packages/app/src/components/LikeButtons.tsx
  41. 14 14
      packages/app/src/components/LoginForm.jsx
  42. 4 3
      packages/app/src/components/Navbar/GlobalSearch.tsx
  43. 36 35
      packages/app/src/components/Navbar/GrowiSubNavigation.jsx
  44. 14 17
      packages/app/src/components/Navbar/SubNavButtons.tsx
  45. 8 12
      packages/app/src/components/NotFoundPage.tsx
  46. 12 10
      packages/app/src/components/Page/DisplaySwitcher.jsx
  47. 12 12
      packages/app/src/components/Page/NotFoundAlert.tsx
  48. 13 17
      packages/app/src/components/Page/RenderTagLabels.tsx
  49. 2 1
      packages/app/src/components/Page/RevisionRenderer.jsx
  50. 0 113
      packages/app/src/components/Page/TagLabels.jsx
  51. 58 0
      packages/app/src/components/Page/TagLabels.tsx
  52. 1 5
      packages/app/src/components/PageAccessories.jsx
  53. 10 9
      packages/app/src/components/PageAccessoriesModal.jsx
  54. 10 8
      packages/app/src/components/PageAccessoriesModalControl.jsx
  55. 0 102
      packages/app/src/components/PageList.jsx
  56. 2 2
      packages/app/src/components/PageList/BookmarkList.jsx
  57. 49 0
      packages/app/src/components/PageList/PageList.tsx
  58. 35 29
      packages/app/src/components/PageList/PageListItemL.tsx
  59. 3 3
      packages/app/src/components/PageList/PageListItemS.jsx
  60. 2 2
      packages/app/src/components/RecentCreated/RecentCreated.jsx
  61. 3 2
      packages/app/src/components/SearchForm.tsx
  62. 1 1
      packages/app/src/components/SearchPage.jsx
  63. 3 3
      packages/app/src/components/SearchPage/SearchPageLayout.tsx
  64. 3 5
      packages/app/src/components/SearchPage/SearchResultContent.tsx
  65. 12 1
      packages/app/src/components/SearchPage/SearchResultContentSubNavigation.tsx
  66. 11 9
      packages/app/src/components/SearchPage/SearchResultList.tsx
  67. 5 4
      packages/app/src/components/SearchTypeahead.tsx
  68. 37 19
      packages/app/src/components/Sidebar/PageTree/Item.tsx
  69. 7 10
      packages/app/src/components/Sidebar/RecentChanges.tsx
  70. 2 2
      packages/app/src/components/TrashPageList.jsx
  71. 0 51
      packages/app/src/components/User/SeenUserInfo.jsx
  72. 49 0
      packages/app/src/components/User/SeenUserInfo.tsx
  73. 7 0
      packages/app/src/interfaces/lang.ts
  74. 15 1
      packages/app/src/interfaces/page.ts
  75. 11 11
      packages/app/src/interfaces/search.ts
  76. 2 0
      packages/app/src/interfaces/user.ts
  77. 31 41
      packages/app/src/server/crowi/index.js
  78. 0 7
      packages/app/src/server/form/admin/userGroupCreate.js
  79. 0 8
      packages/app/src/server/form/index.js
  80. 0 9
      packages/app/src/server/form/invited.js
  81. 0 8
      packages/app/src/server/form/login.js
  82. 0 11
      packages/app/src/server/form/register.js
  83. 85 0
      packages/app/src/server/middlewares/login-form-validator.ts
  84. 51 0
      packages/app/src/server/middlewares/register-form-validator.ts
  85. 1 0
      packages/app/src/server/models/page.ts
  86. 0 15
      packages/app/src/server/models/user.js
  87. 4 4
      packages/app/src/server/routes/apiv3/forgot-password.js
  88. 3 1
      packages/app/src/server/routes/apiv3/pages.js
  89. 2 2
      packages/app/src/server/routes/apiv3/personal-setting.js
  90. 64 1
      packages/app/src/server/routes/apiv3/users.js
  91. 9 8
      packages/app/src/server/routes/index.js
  92. 14 66
      packages/app/src/server/routes/installer.js
  93. 28 8
      packages/app/src/server/routes/page.js
  94. 0 71
      packages/app/src/server/routes/user.js
  95. 7 18
      packages/app/src/server/service/app.ts
  96. 61 1
      packages/app/src/server/service/config-loader.ts
  97. 127 0
      packages/app/src/server/service/installer.ts
  98. 17 1
      packages/app/src/server/service/page.js
  99. 30 5
      packages/app/src/server/service/passport.ts
  100. 113 0
      packages/app/src/server/service/search-delegator/elasticsearch-client-types.ts

+ 3 - 1
.devcontainer/Dockerfile

@@ -3,7 +3,7 @@
 # Licensed under the MIT License. See https://go.microsoft.com/fwlink/?linkid=2090316 for license information.
 #-------------------------------------------------------------------------------------------------------------
 
-FROM mcr.microsoft.com/vscode/devcontainers/javascript-node:0-14
+FROM mcr.microsoft.com/vscode/devcontainers/javascript-node:0-16
 
 # The node image includes a non-root user with sudo access. Use the
 # "remoteUser" property in devcontainer.json to use it. On Linux, update
@@ -32,6 +32,8 @@ RUN chown -R $USER_UID:$USER_GID /home/$USERNAME /workspace;
 ENV DEBIAN_FRONTEND=noninteractive
 RUN apt-get update \
    && apt-get -y install --no-install-recommends git-lfs \
+      # for Cypress
+      libgtk2.0-0 libgtk-3-0 libgbm-dev libnotify-dev libgconf-2-4 libnss3 libxss1 libasound2 libxtst6 xauth xvfb fonts-noto-cjk \
 
    # Clean up
    && apt-get autoremove -y \

+ 10 - 0
.eslintrc.js

@@ -12,6 +12,7 @@ module.exports = {
   },
   plugins: [
     'jest',
+    'regex',
   ],
   rules: {
     'import/prefer-default-export': 'off',
@@ -30,5 +31,14 @@ module.exports = {
       'error',
       { additionalTestBlockFunctions: ['each.test'] },
     ],
+    'regex/invalid': ['error', [
+      {
+        regex: '\\?\\<\\!',
+        message: 'Do not use any negative lookbehind',
+      }, {
+        regex: '\\?\\<\\=',
+        message: 'Do not use any Positive lookbehind',
+      },
+    ]],
   },
 };

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

@@ -0,0 +1,44 @@
+name: Node CI for app production
+
+on:
+  push:
+    branches:
+      - master
+  pull_request:
+    types: [opened, reopened, synchronize]
+
+jobs:
+
+  test-prod-node14:
+    uses: weseek/growi/.github/workflows/reusable-app-prod.yml@master
+    with:
+      node-version: 14.x
+      skip-cypress: true
+    secrets:
+      SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
+
+
+  test-prod-node16:
+    uses: weseek/growi/.github/workflows/reusable-app-prod.yml@master
+    with:
+      node-version: 16.x
+      cypress-report-artifact-name: Cypress report
+    secrets:
+      SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
+
+
+  run-reg-suit-node16:
+    needs: [test-prod-node16]
+
+    uses: weseek/growi/.github/workflows/reusable-app-reg-suit.yml@master
+
+    if: always()
+
+    with:
+      node-version: 16.x
+      cypress-report-artifact-name: Cypress report
+    secrets:
+      REG_NOTIFY_GITHUB_PLUGIN_CLIENTID: ${{ secrets.REG_NOTIFY_GITHUB_PLUGIN_CLIENTID }}
+      AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
+      AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
+      SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}

+ 37 - 95
.github/workflows/ci.yml → .github/workflows/ci-app.yml

@@ -1,4 +1,4 @@
-name: Node CI for growi
+name: Node CI for app development
 
 on:
   push:
@@ -26,14 +26,19 @@ jobs:
         cache: 'yarn'
         cache-dependency-path: '**/yarn.lock'
 
+    - name: Cache/Restore node_modules
+      id: cache-dependencies
+      uses: actions/cache@v2
+      with:
+        path: |
+          **/node_modules
+        key: node_modules-${{ runner.OS }}-node${{ matrix.node-version }}-${{ hashFiles('**/yarn.lock') }}
+        restore-keys: |
+          node_modules-${{ runner.OS }}-node${{ matrix.node-version }}-
+
     - name: lerna bootstrap
       run: |
-        npx lerna bootstrap
-    - name: Print dependencies
-      run: |
-        echo -n "node " && node -v
-        echo -n "npm " && npm -v
-        yarn list --depth=0
+        npx lerna bootstrap -- --frozen-lockfile
 
     - name: lerna run lint for plugins
       run: |
@@ -53,6 +58,7 @@ jobs:
         url: ${{ secrets.SLACK_WEBHOOK_URL }}
 
 
+
   test:
     runs-on: ubuntu-latest
 
@@ -75,14 +81,19 @@ jobs:
         cache: 'yarn'
         cache-dependency-path: '**/yarn.lock'
 
+    - name: Cache/Restore node_modules
+      id: cache-dependencies
+      uses: actions/cache@v2
+      with:
+        path: |
+          **/node_modules
+        key: node_modules-${{ runner.OS }}-node${{ matrix.node-version }}-${{ hashFiles('**/yarn.lock') }}
+        restore-keys: |
+          node_modules-${{ runner.OS }}-node${{ matrix.node-version }}-
+
     - name: lerna bootstrap
       run: |
-        npx lerna bootstrap
-    - name: Print dependencies
-      run: |
-        echo -n "node " && node -v
-        echo -n "npm " && npm -v
-        yarn list --depth=0
+        npx lerna bootstrap -- --frozen-lockfile
 
     - name: yarn test
       working-directory: ./packages/app
@@ -108,6 +119,7 @@ jobs:
         url: ${{ secrets.SLACK_WEBHOOK_URL }}
 
 
+
   launch-dev:
     runs-on: ubuntu-latest
 
@@ -130,14 +142,19 @@ jobs:
         cache: 'yarn'
         cache-dependency-path: '**/yarn.lock'
 
+    - name: Cache/Restore node_modules
+      id: cache-dependencies
+      uses: actions/cache@v2
+      with:
+        path: |
+          **/node_modules
+        key: node_modules-${{ runner.OS }}-node${{ matrix.node-version }}-${{ hashFiles('**/yarn.lock') }}
+        restore-keys: |
+          node_modules-${{ runner.OS }}-node${{ matrix.node-version }}-
+
     - name: lerna bootstrap
       run: |
-        npx lerna bootstrap
-    - name: Print dependencies
-      run: |
-        echo -n "node " && node -v
-        echo -n "npm " && npm -v
-        yarn list --depth=0
+        npx lerna bootstrap -- --frozen-lockfile
 
     - name: yarn dev:ci
       working-directory: ./packages/app
@@ -152,82 +169,7 @@ jobs:
       if: failure()
       with:
         type: ${{ job.status }}
-        job_name: '*Node CI for growi - build-dev (${{ matrix.node-version }})*'
-        channel: '#ci'
-        isCompactMode: true
-        url: ${{ secrets.SLACK_WEBHOOK_URL }}
-
-
-  launch-prod:
-    runs-on: ubuntu-latest
-
-    strategy:
-      matrix:
-        node-version: [14.x, 16.x]
-
-    services:
-      mongodb:
-        image: mongo:4.4
-        ports:
-        - 27017/tcp
-
-    steps:
-    - uses: actions/checkout@v2
-
-    - uses: actions/setup-node@v2
-      with:
-        node-version: ${{ matrix.node-version }}
-        cache: 'yarn'
-        cache-dependency-path: '**/yarn.lock'
-
-    - name: Remove unnecessary packages
-      run: |
-        rm -rf packages/slackbot-proxy
-    - name: lerna bootstrap
-      run: |
-        npx lerna bootstrap
-    - name: Print dependencies
-      run: |
-        echo -n "node " && node -v
-        echo -n "npm " && npm -v
-        yarn list --depth=0
-    - name: Build
-      run: |
-        yarn lerna run build
-      env:
-        ANALYZE_BUNDLE_SIZE: ${{ matrix.node-version == '16.x' }}
-    - name: lerna bootstrap --production
-      run: |
-        npx lerna bootstrap -- --production
-    - name: Print dependencies
-      run: |
-        echo -n "node " && node -v
-        echo -n "npm " && npm -v
-        yarn list --production --depth=0
-    - name: Get DB name
-      id: getdbname
-      run: |
-        echo ::set-output name=suffix::$(echo '${{ matrix.node-version }}' | sed s/\\.//)
-    - name: yarn server:ci
-      working-directory: ./packages/app
-      run: |
-        cp config/ci/.env.local.for-ci .env.production.local
-        yarn server:ci
-      env:
-        MONGO_URI: mongodb://localhost:${{ job.services.mongodb.ports['27017'] }}/growi-${{ steps.getdbname.outputs.suffix }}
-
-    - name: Upload report as artifact
-      uses: actions/upload-artifact@v2
-      with:
-        name: Bundle Analyzing Report
-        path: packages/app/report/bundle-analyzer.html
-
-    - name: Slack Notification
-      uses: weseek/ghaction-slack-notification@master
-      if: failure()
-      with:
-        type: ${{ job.status }}
-        job_name: '*Node CI for growi - build-prod (${{ matrix.node-version }})*'
+        job_name: '*Node CI for growi - launch-dev (${{ matrix.node-version }})*'
         channel: '#ci'
         isCompactMode: true
         url: ${{ secrets.SLACK_WEBHOOK_URL }}

+ 22 - 12
.github/workflows/ci-slackbot-proxy.yml

@@ -26,14 +26,19 @@ jobs:
         cache: 'yarn'
         cache-dependency-path: '**/yarn.lock'
 
+    - name: Cache/Restore node_modules
+      id: cache-dependencies
+      uses: actions/cache@v2
+      with:
+        path: |
+          **/node_modules
+        key: node_modules-${{ runner.OS }}-node${{ matrix.node-version }}-${{ hashFiles('**/yarn.lock') }}
+        restore-keys: |
+          node_modules-${{ runner.OS }}-node${{ matrix.node-version }}-
+
     - name: lerna bootstrap
       run: |
-        npx lerna bootstrap
-    - name: Print dependencies
-      run: |
-        echo -n "node " && node -v
-        echo -n "npm " && npm -v
-        yarn list --depth=0
+        npx lerna bootstrap -- --frozen-lockfile
 
     - name: yarn lint
       run: |
@@ -79,14 +84,19 @@ jobs:
         cache: 'yarn'
         cache-dependency-path: '**/yarn.lock'
 
+    - name: Cache/Restore node_modules
+      id: cache-dependencies
+      uses: actions/cache@v2
+      with:
+        path: |
+          **/node_modules
+        key: node_modules-${{ runner.OS }}-node${{ matrix.node-version }}-${{ hashFiles('**/yarn.lock') }}
+        restore-keys: |
+          node_modules-${{ runner.OS }}-node${{ matrix.node-version }}-
+
     - name: lerna bootstrap
       run: |
-        npx lerna bootstrap
-    - name: Print dependencies
-      run: |
-        echo -n "node " && node -v
-        echo -n "npm " && npm -v
-        yarn list --depth=0
+        npx lerna bootstrap -- --frozen-lockfile
 
     - name: yarn dev:ci
       working-directory: ./packages/slackbot-proxy

+ 275 - 0
.github/workflows/reusable-app-prod.yml

@@ -0,0 +1,275 @@
+name: Reusable build app workflow for production
+
+on:
+  workflow_call:
+    inputs:
+      node-version:
+        required: true
+        type: string
+      skip-cypress:
+        type: boolean
+      cypress-report-artifact-name:
+        type: string
+    secrets:
+      SLACK_WEBHOOK_URL:
+        required: true
+
+jobs:
+
+  build-prod:
+    runs-on: ubuntu-latest
+
+    outputs:
+      PROD_FILES: ${{ steps.archive-prod-files.outputs.file }}
+
+    steps:
+    - uses: actions/checkout@v2
+
+    - uses: actions/setup-node@v2
+      with:
+        node-version: ${{ inputs.node-version }}
+        cache: 'yarn'
+        cache-dependency-path: '**/yarn.lock'
+
+    - name: Cache/Restore node_modules
+      id: cache-dependencies
+      uses: actions/cache@v2
+      with:
+        path: |
+          **/node_modules
+        key: node_modules-${{ runner.OS }}-node${{ inputs.node-version }}-${{ hashFiles('**/yarn.lock') }}
+        restore-keys: |
+          node_modules-${{ runner.OS }}-node${{ inputs.node-version }}-
+
+    - name: lerna bootstrap
+      run: |
+        npx lerna bootstrap -- --frozen-lockfile
+
+    - name: Remove unnecessary packages
+      run: |
+        rm -rf packages/slackbot-proxy
+
+    - name: Build
+      run: |
+        yarn lerna run build
+      env:
+        ANALYZE_BUNDLE_SIZE: 1
+
+    - name: Archive production files
+      id: archive-prod-files
+      run: |
+        tar -cf production.tar packages/**/dist packages/app/public
+        echo ::set-output name=file::production.tar
+
+    - name: Upload production files as artifact
+      uses: actions/upload-artifact@v2
+      with:
+        name: Production Files
+        path: ${{ steps.archive-prod-files.outputs.file }}
+
+    - name: Upload report as artifact
+      uses: actions/upload-artifact@v2
+      with:
+        name: Bundle Analyzing Report
+        path: packages/app/report/bundle-analyzer.html
+
+    - name: Slack Notification
+      uses: weseek/ghaction-slack-notification@master
+      if: failure()
+      with:
+        type: ${{ job.status }}
+        job_name: '*Node CI for growi - build-prod (${{ inputs.node-version }})*'
+        channel: '#ci'
+        isCompactMode: true
+        url: ${{ secrets.SLACK_WEBHOOK_URL }}
+
+
+
+  launch-prod:
+    needs: [build-prod]
+    runs-on: ubuntu-latest
+
+    services:
+      mongodb:
+        image: mongo:4.4
+        ports:
+        - 27017/tcp
+      mongodb36:
+        image: mongo:3.6
+        ports:
+        - 27017/tcp
+
+    steps:
+    - uses: actions/checkout@v2
+
+    - uses: actions/setup-node@v2
+      with:
+        node-version: ${{ inputs.node-version }}
+        cache: 'yarn'
+        cache-dependency-path: '**/yarn.lock'
+
+    - name: Get Date
+      id: get-date
+      run: |
+        echo "::set-output name=dateYmdHM::$(/bin/date -u "+%Y%m%d%H%M")"
+        echo "::set-output name=dateYm::$(/bin/date -u "+%Y%m")"
+
+    - name: Cache/Restore node_modules (not reused)
+      id: cache-dependencies
+      uses: actions/cache@v2
+      with:
+        path: |
+          **/node_modules
+        key: node_modules-build-prod-${{ runner.OS }}-node${{ inputs.node-version }}-${{ steps.get-date.outputs.dateYmdHM }}
+        restore-keys: |
+          node_modules-${{ runner.OS }}-node${{ inputs.node-version }}-${{ hashFiles('**/yarn.lock') }}
+          node_modules-${{ runner.OS }}-node${{ inputs.node-version }}-
+          node_modules-build-prod-${{ runner.OS }}-node${{ inputs.node-version }}-${{ steps.get-date.outputs.dateYm }}
+          node_modules-build-prod-${{ runner.OS }}-node${{ inputs.node-version }}-
+
+    - name: Remove unnecessary packages
+      run: |
+        rm -rf packages/slackbot-proxy
+
+    - name: lerna bootstrap --production
+      run: |
+        npx lerna bootstrap -- --production
+
+    - name: Download production files artifact
+      uses: actions/download-artifact@v2
+      with:
+        name: Production Files
+
+    - name: Extract procution files artifact
+      run: |
+        tar -xf ${{ needs.build-prod.outputs.PROD_FILES }}
+
+    - name: yarn server:ci
+      working-directory: ./packages/app
+      run: |
+        cp config/ci/.env.local.for-ci .env.production.local
+        yarn server:ci
+      env:
+        MONGO_URI: mongodb://localhost:${{ job.services.mongodb.ports['27017'] }}/growi
+    - name: yarn server:ci with MongoDB 3.6
+      working-directory: ./packages/app
+      run: |
+        cp config/ci/.env.local.for-ci .env.production.local
+        yarn server:ci
+      env:
+        MONGO_URI: mongodb://localhost:${{ job.services.mongodb36.ports['27017'] }}/growi
+
+    - name: Slack Notification
+      uses: weseek/ghaction-slack-notification@master
+      if: failure()
+      with:
+        type: ${{ job.status }}
+        job_name: '*Node CI for growi - build-prod (${{ inputs.node-version }})*'
+        channel: '#ci'
+        isCompactMode: true
+        url: ${{ secrets.SLACK_WEBHOOK_URL }}
+
+
+
+  run-cypress:
+    needs: [build-prod]
+
+    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']
+
+    services:
+      mongodb:
+        image: mongo:4.4
+        ports:
+        - 27017/tcp
+
+    steps:
+    - uses: actions/checkout@v2
+
+    - name: Get yarn cache dir
+      id: yarn-cache-dir
+      run: |
+        echo "::set-output name=value::`yarn cache dir --silent`"
+
+    - name: Cache/Restore dependencies
+      uses: actions/cache@v2
+      with:
+        path: |
+          **/node_modules
+          ~/.cache/Cypress
+          ${{ steps.yarn-cache-dir.outputs.value }}
+        key: deps-for-cypress-${{ runner.OS }}-node${{ inputs.node-version }}-${{ hashFiles('**/yarn.lock') }}
+        restore-keys: |
+          deps-for-cypress-${{ runner.OS }}-node${{ inputs.node-version }}
+
+    - name: lerna bootstrap
+      run: |
+        npx lerna bootstrap -- --frozen-lockfile
+
+    - name: Download production files artifact
+      uses: actions/download-artifact@v2
+      with:
+        name: Production Files
+
+    - name: Extract procution files artifact
+      run: |
+        tar -xf ${{ needs.build-prod.outputs.PROD_FILES }}
+
+    - name: Determine spec expression
+      id: determine-spec-exp
+      run: |
+        SPEC=`node bin/github-actions/generate-cypress-spec-arg.js --prefix="test/cypress/integration/" --suffix="-*/**" "${{ matrix.spec-group }}"`
+        echo "::set-output name=value::$SPEC"
+
+    - name: Copy dotenv file for ci
+      working-directory: ./packages/app
+      run: |
+        cat config/ci/.env.local.for-ci >> .env.production.local
+
+    - name: Copy dotenv file for automatic installation
+      if: ${{ matrix.spec-group != '1' }}
+      working-directory: ./packages/app
+      run: |
+        cat config/ci/.env.local.for-auto-install >> .env.production.local
+
+    - name: Cypress Run
+      uses: cypress-io/github-action@v2
+      with:
+        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
+
+    - name: Upload results
+      if: always()
+      uses: actions/upload-artifact@v2
+      with:
+        name: ${{ inputs.cypress-report-artifact-name }}
+        path: |
+          packages/app/test/cypress/screenshots
+          packages/app/test/cypress/videos
+
+    - name: Slack Notification
+      uses: weseek/ghaction-slack-notification@master
+      if: failure()
+      with:
+        type: ${{ job.status }}
+        job_name: '*Node CI for growi - run-cypress (${{ inputs.node-version }})*'
+        channel: '#ci'
+        isCompactMode: true
+        url: ${{ secrets.SLACK_WEBHOOK_URL }}

+ 91 - 0
.github/workflows/reusable-app-reg-suit.yml

@@ -0,0 +1,91 @@
+name: Reusable build app workflow for production
+
+on:
+  workflow_call:
+    inputs:
+      node-version:
+        required: true
+        type: string
+      checkout-ref:
+        type: string
+        default: ${{ github.head_ref }}
+      cypress-report-artifact-name:
+        required: true
+        type: string
+    secrets:
+      REG_NOTIFY_GITHUB_PLUGIN_CLIENTID:
+        required: true
+      AWS_ACCESS_KEY_ID:
+        required: true
+      AWS_SECRET_ACCESS_KEY:
+        required: true
+      SLACK_WEBHOOK_URL:
+        required: true
+    outputs:
+      EXPECTED_IMAGES_EXIST:
+        value: ${{ jobs.run-reg-suit.outputs.EXPECTED_IMAGES_EXIST }}
+
+
+jobs:
+
+  run-reg-suit:
+    # use secrets for "VRT" environment
+    # https://github.com/weseek/growi/settings/environments/376165508/edit
+    environment: VRT
+
+    env:
+      REG_NOTIFY_GITHUB_PLUGIN_CLIENTID: ${{ secrets.REG_NOTIFY_GITHUB_PLUGIN_CLIENTID }}
+      AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
+      AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
+      SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
+
+    runs-on: ubuntu-latest
+
+    outputs:
+      EXPECTED_IMAGES_EXIST: ${{ steps.check-expected-images.outputs.EXPECTED_IMAGES_EXIST }}
+
+    steps:
+    - uses: actions/checkout@v2
+      with:
+        ref: ${{ inputs.checkout-ref }}
+        fetch-depth: 0
+
+    - uses: actions/setup-node@v2
+      with:
+        node-version: ${{ inputs.node-version }}
+        cache: 'yarn'
+        cache-dependency-path: '**/yarn.lock'
+
+    - name: Cache/Restore node_modules
+      uses: actions/cache@v2
+      with:
+        path: |
+          **/node_modules
+        key: node_modules-${{ runner.OS }}-node${{ inputs.node-version }}-${{ hashFiles('**/yarn.lock') }}
+        restore-keys: |
+          node_modules-${{ runner.OS }}-node${{ inputs.node-version }}-
+
+    - name: lerna bootstrap
+      run: |
+        npx lerna bootstrap -- --frozen-lockfile
+
+    - name: Download screenshots taken by cypress
+      uses: actions/download-artifact@v2
+      with:
+        name: ${{ inputs.cypress-report-artifact-name }}
+        path: packages/app/test/cypress
+
+    - name: Run reg-suit
+      working-directory: ./packages/app
+      run: |
+        yarn reg:run
+
+    - name: Slack Notification
+      uses: weseek/ghaction-slack-notification@master
+      if: failure()
+      with:
+        type: ${{ job.status }}
+        job_name: '*Node CI for growi - run-reg-suit (${{ inputs.node-version }})*'
+        channel: '#ci'
+        isCompactMode: true
+        url: ${{ secrets.SLACK_WEBHOOK_URL }}

+ 44 - 1
CHANGELOG.md

@@ -1,9 +1,52 @@
 # Changelog
 
-## [Unreleased](https://github.com/weseek/growi/compare/v4.5.8...HEAD)
+## [Unreleased](https://github.com/weseek/growi/compare/v4.5.11...HEAD)
 
 *Please do not manually update this file. We've automated the process.*
 
+## [v4.5.11](https://github.com/weseek/growi/compare/v4.5.10...v4.5.11) - 2022-01-26
+
+### 🐛 Bug Fixes
+
+- fix: Internal server error occured when "Restrict complete deletion of pages" option's value is "Admin and author" (#5175 ) @yuki-takei
+
+## [v4.5.10](https://github.com/weseek/growi/compare/v4.5.9...v4.5.10) - 2022-01-26
+
+### 💎 Features
+
+- feat: Automatic installation (#5141) @yuki-takei
+
+### 🚀 Improvement
+
+- imprv: Migrate like states to swr (#5137) @miya
+
+### 🐛 Bug Fixes
+
+- fix: 86631-cannot-reset-password-in-case-that-register-limitation-is-Closed (#5155) @kaoritokashiki
+
+### 🧰 Maintenance
+
+- support: VRT with Cypress (#5030) @yuki-takei
+
+## [v4.5.9](https://github.com/weseek/growi/compare/v4.5.8...v4.5.9) - 2022-01-21
+
+### 🚀 Improvement
+
+- imprv: 79291 make password min length 8 charactors (#5116) @kaoritokashiki
+
+### 🐛 Bug Fixes
+
+- fix: OIDC reconnection bug fix (#5104) @mudana-grune
+- fix: /_api/v3/page is broken and dump 500 error "get-page-failed TypeError: user.canDeleteCompletely is not a function" (#5103) @yuki-takei
+- fix: Default completely deletion settings label mismatched against to actual (#5102) @yuki-takei
+- fix: OIDC issuer host availability check (#5099) @mudana-grune
+
+### 🧰 Maintenance
+
+- support: Improve multistage build (#5090) @yuki-takei
+- support: Omit node-re2 (#5089) @yuki-takei
+- ci(deps-dev): bump swr from 1.0.1 to 1.1.2 (#5018) @dependabot
+
 ## [v4.5.8](https://github.com/weseek/growi/compare/v4.5.7...v4.5.8) - 2022-01-12
 
 ### 💎 Features

+ 3 - 1
README.md

@@ -83,12 +83,14 @@ See [GROWI Docs: Environment Variables](https://docs.growi.org/en/admin-guide/ad
 ## Dependencies
 
 - Node.js v14.x or v16.x
+- npm 6.x
+- yarn
 - MongoDB 4.x
 
 ### Optional Dependencies
 
 - Redis 3.x
-- ElasticSearch 6.x (needed when using Full-text search)
+- ElasticSearch 6.x or 7.x (needed when using Full-text search)
   - **CAUTION: Following plugins are required**
     - [Japanese (kuromoji) Analysis plugin](https://www.elastic.co/guide/en/elasticsearch/plugins/current/analysis-kuromoji.html)
     - [ICU Analysis Plugin](https://www.elastic.co/guide/en/elasticsearch/plugins/current/analysis-icu.html)

+ 3 - 1
README_JP.md

@@ -82,12 +82,14 @@ Crowi からの移行は **[こちら](https://docs.growi.org/en/admin-guide/mig
 ## 依存関係
 
 - Node.js v14.x or v16.x
+- npm 6.x
+- yarn
 - MongoDB 4.x
 
 ### オプションの依存関係
 
 - Redis 3.x
-- ElasticSearch 6.x (needed when using Full-text search)
+- ElasticSearch 6.x or 7.x (needed when using Full-text search)
   - **注意: 次のプラグインが必要です**
     - [Japanese (kuromoji) Analysis plugin](https://www.elastic.co/guide/en/elasticsearch/plugins/current/analysis-kuromoji.html)
     - [ICU Analysis Plugin](https://www.elastic.co/guide/en/elasticsearch/plugins/current/analysis-icu.html)

+ 53 - 0
bin/github-actions/generate-cypress-spec-arg.js

@@ -0,0 +1,53 @@
+/* eslint-disable no-console */
+
+/*
+ * USAGE:
+ *  node generate-cypress-spec-arg --prefix=${prefix} --suffix=${suffix} ${value}
+ *
+ * OPTIONS:
+ *  --prefix : prefix string for each items
+ *  --suffix : suffix string for each items
+ *
+ * EXAMPLE:
+ *  node generate-cypress-spec-arg --prefix=${prefix}"A" --suffix="Z" "1,3,5"
+ *  => A1Z,A3Z,A5Z
+ */
+
+const yargs = require('yargs/yargs');
+const { hideBin } = require('yargs/helpers');
+
+const argv = yargs(hideBin(process.argv)).argv;
+
+
+const printExample = () => {
+  console.group('** Usage **');
+  // eslint-disable-next-line no-template-curly-in-string
+  console.log('$ node generate-cypress-spec-arg --prefix=${prefix}"A" --suffix="Z" "1,3,5"');
+  console.log('  ==> A1Z,A3Z,A5Z');
+  console.groupEnd();
+  console.log('\n');
+};
+
+
+const { prefix, suffix, _: value } = argv;
+
+if (prefix == null) {
+  printExample();
+  throw new Error('The option "prefix" must be specified');
+}
+if (suffix == null) {
+  printExample();
+  throw new Error('The option "suffix" must be specified');
+}
+if (value.length === 0) {
+  printExample();
+  throw new Error('A value string must be specified');
+}
+
+const result = value[0]
+  .toString().split(',')
+  .map(v => v.trim())
+  .map(v => `${prefix}${v}${suffix}`)
+  .join(',');
+
+console.log(result);

+ 9 - 1
package.json

@@ -51,11 +51,13 @@
     "tslib": "^2.3.1"
   },
   "devDependencies": {
+    "@testing-library/cypress": "^8.0.2",
     "@types/jest": "^26.0.22",
     "@types/node": "^14.14.35",
     "@types/rewire": "^2.5.28",
     "@typescript-eslint/eslint-plugin": "^4.28.5",
     "@typescript-eslint/parser": "^4.28.5",
+    "cypress": "^9.2.0",
     "eslint": "^7.31.0",
     "eslint-config-weseek": "^1.1.0",
     "eslint-import-resolver-typescript": "^2.4.0",
@@ -69,6 +71,11 @@
     "lerna": "^4.0.0",
     "postcss": "^8.4.5",
     "postcss-scss": "^4.0.3",
+    "reg-keygen-git-hash-plugin": "^0.11.1",
+    "reg-notify-github-plugin": "^0.11.1",
+    "reg-notify-slack-plugin": "^0.11.0",
+    "reg-publish-s3-plugin": "^0.11.0",
+    "reg-suit": "^0.11.1",
     "rewire": "^5.0.0",
     "shipjs": "^0.24.1",
     "stylelint": "^14.2.0",
@@ -76,7 +83,8 @@
     "ts-jest": "^27.0.4",
     "ts-node": "^9.1.1",
     "tsconfig-paths": "^3.9.0",
-    "typescript": "^4.2.3"
+    "typescript": "^4.2.3",
+    "yargs": "^17.3.1"
   },
   "engines": {
     "node": "^14 || ^16",

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

@@ -13,6 +13,8 @@ MONGO_URI="mongodb://mongo:27017/growi"
 # NCHAN_URI="http://nchan"
 ELASTICSEARCH_URI="http://elasticsearch:9200/growi"
 ELASTICSEARCH_REQUEST_TIMEOUT=15000
+ELASTICSEARCH_REJECT_UNAUTHORIZED=false
+USE_ELASTICSEARCH_V6=false
 HACKMD_URI="http://localhost:3010"
 HACKMD_URI_FOR_SERVER="http://hackmd:3000"
 # DRAWIO_URI="http://localhost:8080/?offline=1&https=0"

+ 12 - 0
packages/app/.eslintrc.js

@@ -3,6 +3,9 @@ module.exports = {
     'weseek/react',
     'weseek/typescript',
   ],
+  plugins: [
+    'regex',
+  ],
   env: {
     jquery: true,
   },
@@ -25,6 +28,15 @@ module.exports = {
       name: 'axios',
       message: 'Please use src/utils/axios instead.',
     }],
+    'regex/invalid': ['error', [
+      {
+        regex: '\\?\\<\\!',
+        message: 'Do not use any negative lookbehind',
+      }, {
+        regex: '\\?\\<\\=',
+        message: 'Do not use any Positive lookbehind',
+      },
+    ]],
     '@typescript-eslint/no-var-requires': 'off',
 
     // set 'warn' temporarily -- 2021.08.02 Yuki Takei

+ 5 - 0
packages/app/.gitignore

@@ -2,6 +2,11 @@
 /.next/
 /out/
 
+# test
+test/cypress/screenshots
+test/cypress/videos
+.reg
+
 # dist
 /dist/
 /transpiled/

+ 7 - 0
packages/app/config/ci/.env.local.for-auto-install

@@ -0,0 +1,7 @@
+APP_SITE_URL=http://localhost:3000
+
+AUTO_INSTALL_ADMIN_USERNAME=admin
+AUTO_INSTALL_ADMIN_NAME=Admin
+AUTO_INSTALL_ADMIN_EMAIL=admin@example.com
+AUTO_INSTALL_ADMIN_PASSWORD=adminadmin
+AUTO_INSTALL_GLOBAL_LANG=zh_CN

+ 17 - 0
packages/app/cypress.json

@@ -0,0 +1,17 @@
+{
+  "baseUrl": "http://localhost:3000",
+
+  "fileServerFolder": "test/cypress",
+  "fixturesFolder": "test/cypress/fixtures",
+  "integrationFolder": "test/cypress/integration",
+  "screenshotsFolder": "test/cypress/screenshots",
+  "videosFolder": "test/cypress/videos",
+  "supportFile": "test/cypress/support/index.ts",
+  "pluginsFile": "test/cypress/plugins/index.ts",
+  "testFiles": "**/*.spec.ts",
+
+  "viewportWidth": 1440,
+  "viewportHeight": 1200,
+
+  "experimentalSessionSupport": true
+}

+ 6 - 6
packages/app/docker/Dockerfile

@@ -6,7 +6,7 @@ ARG flavor=default
 ##
 ## packages-json-picker
 ##
-FROM node:14-slim AS packages-json-picker
+FROM node:16-slim AS packages-json-picker
 
 ENV optDir /opt
 
@@ -20,7 +20,7 @@ RUN find packages \! -name "package.json" -mindepth 2 -maxdepth 2 -print | xargs
 ##
 ## deps-resolver
 ##
-FROM node:14-slim AS deps-resolver
+FROM node:16-slim AS deps-resolver
 
 ENV optDir /opt
 
@@ -31,7 +31,7 @@ COPY --from=packages-json-picker ${optDir} .
 
 # setup
 RUN yarn config set network-timeout 300000
-RUN npx lerna bootstrap -- --frozen-lockfile
+RUN npx -y lerna bootstrap -- --frozen-lockfile
 
 # make artifacts
 RUN tar -cf node_modules.tar \
@@ -48,7 +48,7 @@ FROM deps-resolver AS deps-resolver-prod
 # remove unnecessary packages
 RUN rm -rf packages/slackbot-proxy
 
-RUN npx lerna bootstrap -- --production
+RUN npx -y lerna bootstrap -- --production
 # make artifacts
 RUN tar -cf node_modules.tar \
   node_modules \
@@ -59,7 +59,7 @@ RUN tar -cf node_modules.tar \
 ##
 ## prebuilder-default
 ##
-FROM node:14-slim AS prebuilder-default
+FROM node:16-slim AS prebuilder-default
 
 ENV optDir /opt
 
@@ -128,7 +128,7 @@ RUN tar -cf packages.tar \
 ##
 ## release
 ##
-FROM node:14-slim
+FROM node:16-slim
 LABEL maintainer Yuki Takei <yuki@weseek.co.jp>
 
 ENV NODE_ENV production

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

@@ -12,8 +12,8 @@ Supported tags and respective Dockerfile links
 
 * [`5.0.0`, `5.0`, `5`, `latest` (Dockerfile)](https://github.com/weseek/growi/blob/v5.0.0/docker/Dockerfile)
 * [`5.0.0-nocdn`, `5.0-nocdn`, `5-nocdn`, `latest-nocdn` (Dockerfile)](https://github.com/weseek/growi/blob/v5.0.0/docker/Dockerfile)
-* [`4.5.8`, `4.5`, `4`, `latest` (Dockerfile)](https://github.com/weseek/growi/blob/v4.5.8/docker/Dockerfile)
-* [`4.5.8-nocdn`, `4.5-nocdn`, `4-nocdn`, `latest-nocdn` (Dockerfile)](https://github.com/weseek/growi/blob/v4.5.8/docker/Dockerfile)
+* [`4.5.11`, `4.5`, `4`, (Dockerfile)](https://github.com/weseek/growi/blob/v4.5.11/docker/Dockerfile)
+* [`4.5.11-nocdn`, `4.5-nocdn`, `4-nocdn`, (Dockerfile)](https://github.com/weseek/growi/blob/v4.5.11/docker/Dockerfile)
 * [`4.4.13`, `4.4` (Dockerfile)](https://github.com/weseek/growi/blob/v4.4.13/docker/Dockerfile)
 * [`4.4.13-nocdn`, `4.4-nocdn` (Dockerfile)](https://github.com/weseek/growi/blob/v4.4.13/docker/Dockerfile)
 

+ 7 - 12
packages/app/jest.config.js

@@ -21,8 +21,8 @@ module.exports = {
       preset: 'ts-jest/presets/js-with-ts',
 
       rootDir: '.',
-      roots: ['<rootDir>/src'],
-      testMatch: ['<rootDir>/src/test/unit/**/*.test.ts', '<rootDir>/src/test/unit/**/*.test.js'],
+      roots: ['<rootDir>'],
+      testMatch: ['<rootDir>/test/unit/**/*.test.ts', '<rootDir>/test/unit/**/*.test.js'],
 
       testEnvironment: 'node',
 
@@ -36,23 +36,18 @@ module.exports = {
       preset: 'ts-jest/presets/js-with-ts',
 
       rootDir: '.',
-      roots: ['<rootDir>/src'],
-      testMatch: ['<rootDir>/src/test/integration/**/*.test.ts', '<rootDir>/src/test/integration/**/*.test.js'],
+      roots: ['<rootDir>'],
+      testMatch: ['<rootDir>/test/integration/**/*.test.ts', '<rootDir>/test/integration/**/*.test.js'],
 
       testEnvironment: 'node',
-      globalSetup: '<rootDir>/src/test/integration/global-setup.js',
-      globalTeardown: '<rootDir>/src/test/integration/global-teardown.js',
-      setupFilesAfterEnv: ['<rootDir>/src/test/integration/setup.js'],
+      globalSetup: '<rootDir>/test/integration/global-setup.js',
+      globalTeardown: '<rootDir>/test/integration/global-teardown.js',
+      setupFilesAfterEnv: ['<rootDir>/test/integration/setup.js'],
 
       // Automatically clear mock calls and instances between every test
       clearMocks: true,
       moduleNameMapper: MODULE_NAME_MAPPING,
     },
-    // {
-    //   displayName: 'client',
-    //   rootDir: '.',
-    //   testMatch: ['<rootDir>/src/test/client/**/*.test.js'],
-    // },
   ],
 
   // Automatically clear mock calls and instances between every test

+ 13 - 7
packages/app/package.json

@@ -8,9 +8,9 @@
     "build": "run-p build:*",
     "build:client": "yarn cross-env NODE_ENV=production webpack --config config/webpack.prod.js",
     "build:server": "yarn cross-env NODE_ENV=production tsc -p tsconfig.build.server.json && tsc-alias -p tsconfig.build.server.json",
-    "clean": "npx shx rm -rf dist transpiled",
+    "clean": "npx -y shx rm -rf dist transpiled",
     "prebuild": "yarn cross-env NODE_ENV=production run-p clean resources:*",
-    "postbuild": "npx shx mv transpiled/src dist && npx shx cp -r src/server/views dist/server/ && npx shx rm -rf transpiled",
+    "postbuild": "npx -y shx mv transpiled/src dist && npx -y shx cp -r src/server/views dist/server/ && npx -y shx rm -rf transpiled",
     "server": "yarn cross-env NODE_ENV=production node -r dotenv-flow/config --expose_gc dist/server/app.js",
     "server:ci": "yarn server --ci",
     "preserver": "yarn cross-env NODE_ENV=production yarn migrate",
@@ -28,10 +28,11 @@
     "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",
     "//// for CI": "",
     "dev:ci": "yarn dev:client:nowatch && yarn dev:server --ci",
     "predev:ci": "run-p resources:*",
-    "lint:typecheck": "npx tsc",
+    "lint:typecheck": "npx -y tsc",
     "lint:eslint": "eslint --quiet \"**/*.{js,jsx,ts,tsx}\"",
     "lint:styles": "stylelint src/**/*.scss",
     "lint:swagger2openapi": "node node_modules/.bin/oas-validate tmp/swagger.json",
@@ -39,6 +40,7 @@
     "test": "cross-env NODE_ENV=test jest --passWithNoTests -- ",
     "prelint:eslint": "yarn resources:plugin",
     "prelint:swagger2openapi": "yarn openapi:v3",
+    "reg:run": "reg-suit run",
     "//// misc": "",
     "console": "yarn cross-env NODE_ENV=development yarn ts-node --experimental-repl-await src/server/console.js",
     "swagger-jsdoc": "swagger-jsdoc -o tmp/swagger.json -d config/swagger-definition.js",
@@ -73,6 +75,7 @@
     "async-canvas-to-blob": "^1.0.3",
     "aws-sdk": "^2.1044.0",
     "axios": "^0.24.0",
+    "axios-retry": "^3.2.4",
     "body-parser": "^1.18.2",
     "browser-bunyan": "^1.6.3",
     "bunyan": "^1.8.15",
@@ -86,20 +89,21 @@
     "date-fns": "^2.23.0",
     "detect-indent": "^7.0.0",
     "diff": "^5.0.0",
+    "@elastic/elasticsearch6": "npm:@elastic/elasticsearch@^6.8.7",
+    "@elastic/elasticsearch7": "npm:@elastic/elasticsearch@^7.16.0",
     "diff_match_patch": "^0.1.1",
-    "elasticsearch": "^16.0.0",
     "entities": "^2.0.0",
     "esa-nodejs": "^0.0.7",
     "escape-string-regexp": "=4.0.0",
+    "eslint-plugin-regex": "^1.8.0",
     "express": "^4.16.1",
     "express-bunyan-logger": "^1.3.3",
-    "express-form": "~0.12.0",
     "express-mongo-sanitize": "^2.1.0",
     "express-rate-limit": "^5.3.0",
     "express-session": "^1.16.1",
     "express-validator": "^6.1.1",
     "express-webpack-assets": "^0.1.0",
-    "got": "^8.3.2",
+    "extensible-custom-error": "^0.0.7",
     "graceful-fs": "^4.1.11",
     "helmet": "^4.6.0",
     "http-errors": "~1.8.0",
@@ -175,13 +179,15 @@
     "bunyan-debug": "^2.0.0",
     "cli": "~1.0.1",
     "codemirror": "^5.63.0",
-    "colors": "^1.2.5",
+    "colors": "=1.4.0",
     "connect-browser-sync": "^2.1.0",
     "core-js": "=2.6.9",
     "css-loader": "^3.0.0",
     "csv-to-markdown-table": "^1.0.1",
     "diff2html": "^3.1.2",
     "eazy-logger": "^3.1.0",
+    "eslint-plugin-regex": "^1.8.0",
+    "eslint-plugin-cypress": "^2.12.1",
     "file-loader": "^5.0.2",
     "handsontable": "=6.2.2",
     "hard-source-webpack-plugin": "^0.13.1",

+ 25 - 0
packages/app/regconfig.json

@@ -0,0 +1,25 @@
+{
+  "core": {
+    "workingDir": ".reg",
+    "actualDir": "test/cypress/screenshots",
+    "thresholdRate": 0.001,
+    "addIgnore": true,
+    "ximgdiff": {
+      "invocationType": "client"
+    }
+  },
+  "plugins": {
+    "reg-keygen-git-hash-plugin": true,
+    "reg-notify-github-plugin": {
+      "prCommentBehavior": "new",
+      "setCommitStatus": false,
+      "clientId": "$REG_NOTIFY_GITHUB_PLUGIN_CLIENTID"
+    },
+    "reg-notify-slack-plugin": {
+      "webhookUrl": "$SLACK_WEBHOOK_URL"
+    },
+    "reg-publish-s3-plugin": {
+      "bucketName": "growi-vrt-snapshots"
+    }
+  }
+}

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

@@ -60,6 +60,7 @@
   "Presentation Mode": "Presentation",
   "The end": "The end",
   "Not available for guest": "Not available for guest",
+  "No users have liked this yet": "No users have liked this yet",
   "No users have liked this yet.": "No users have liked this yet.",
   "No users have bookmarked yet": "No users have bookmarked yet",
   "Create Archive Page": "Create Archive Page",
@@ -206,7 +207,7 @@
     },
     "form_help": {
       "email": "You must have email address which listed below to sign up to this wiki.",
-      "password": "Your password must be at least 6 characters long.",
+      "password": "Your password must be at least 8 characters long.",
       "user_id": "The URL of pages you create will contain your User ID. Your User ID can consist of letters, numbers, and some symbols."
     }
   },

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

@@ -61,6 +61,7 @@
   "Presentation Mode": "プレゼンテーション",
   "The end": "おしまい",
   "Not available for guest": "ゲストユーザーは利用できません",
+  "No users have liked this yet": "いいねをしているユーザーはいません",
   "No users have bookmarked yet": "ブックマークしているユーザーはいません",
   "Create Archive Page": "アーカイブページの作成",
   "Target page": "対象ページ",

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

@@ -62,6 +62,7 @@
 	"Presentation Mode": "演示文稿",
   "The end": "结束",
   "Not available for guest": "Not available for guest",
+  "No users have liked this yet": "还没有用户喜欢这个",
   "No users have bookmarked yet": "还没有用户加入书签",
   "Create Archive Page": "创建归档页",
   "File type": "文件类型",

+ 0 - 0
packages/app/resource/search/mappings.json → packages/app/resource/search/mappings-es6.json


+ 115 - 0
packages/app/resource/search/mappings-es7.json

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

+ 2 - 5
packages/app/src/client/app.jsx

@@ -37,7 +37,6 @@ import RecentCreated from '../components/RecentCreated/RecentCreated';
 import RecentlyCreatedIcon from '../components/Icons/RecentlyCreatedIcon';
 import MyDraftList from '../components/MyDraftList/MyDraftList';
 import BookmarkList from '../components/PageList/BookmarkList';
-import LikerList from '../components/User/LikerList';
 import Fab from '../components/Fab';
 import PersonalSettings from '../components/Me/PersonalSettings';
 import GrowiSubNavigation from '../components/Navbar/GrowiSubNavigation';
@@ -89,7 +88,7 @@ Object.assign(componentMappings, {
 
   'search-page': <SearchPage crowi={appContainer} />,
   'all-in-app-notifications': <InAppNotificationPage />,
-  'identical-path-page-list': <IdenticalPathPage />,
+  'identical-path-page': <IdenticalPathPage />,
 
   // 'revision-history': <PageHistory pageId={pageId} />,
   'tags-page': <TagsList crowi={appContainer} />,
@@ -102,7 +101,7 @@ Object.assign(componentMappings, {
 
   'not-found-page': <NotFoundPage />,
 
-  'forbidden-page': <ForbiddenPage />,
+  'forbidden-page': <ForbiddenPage isSharePage={appContainer.config.disableLinkSharing} />,
 
   'page-timeline': <PageTimeline />,
 
@@ -118,7 +117,6 @@ Object.assign(componentMappings, {
   'renamed-alert': <RenamedAlert />,
   'not-found-alert': <NotFoundAlert
     isGuestUserMode={appContainer.isGuestUser}
-    isHidden={pageContainer.state.pageId != null ? (pageContainer.state.isNotCreatable || pageContainer.state.isTrashPage) : false} // !!DO NOT MOVE THIS!! https://github.com/weseek/growi/pull/4899
   />,
 });
 
@@ -128,7 +126,6 @@ if (pageContainer.state.pageId != null) {
     'page-comments-list': <PageComments />,
     'page-comment-write': <CommentEditorLazyRenderer />,
     'page-management': <PageManagement />,
-    'liker-list': <LikerList />,
     'page-content-footer': <PageContentFooter />,
 
     'recent-created-icon': <RecentlyCreatedIcon />,

+ 7 - 7
packages/app/src/client/nologin.jsx

@@ -16,17 +16,17 @@ import CompleteUserRegistrationForm from '~/components/CompleteUserRegistrationF
 const i18n = i18nFactory();
 
 // render InstallerForm
-const installerFormElem = document.getElementById('installer-form');
-if (installerFormElem) {
-  const userName = installerFormElem.dataset.userName;
-  const name = installerFormElem.dataset.name;
-  const email = installerFormElem.dataset.email;
-  const csrf = installerFormElem.dataset.csrf;
+const installerFormContainerElem = document.getElementById('installer-form-container');
+if (installerFormContainerElem) {
+  const userName = installerFormContainerElem.dataset.userName;
+  const name = installerFormContainerElem.dataset.name;
+  const email = installerFormContainerElem.dataset.email;
+  const csrf = installerFormContainerElem.dataset.csrf;
   ReactDOM.render(
     <I18nextProvider i18n={i18n}>
       <InstallerForm userName={userName} name={name} email={email} csrf={csrf} />
     </I18nextProvider>,
-    installerFormElem,
+    installerFormContainerElem,
   );
 }
 

+ 11 - 6
packages/app/src/client/services/ContextExtractor.tsx

@@ -6,7 +6,7 @@ import {
   useIsDeletable, useIsDeleted, useIsNotCreatable, useIsPageExist, useIsTrashPage, useIsUserPage, useLastUpdateUsername,
   useCurrentPageId, usePageIdOnHackmd, usePageUser, useCurrentPagePath, useRevisionCreatedAt, useRevisionId, useRevisionIdHackmdSynced,
   useShareLinkId, useShareLinksNumber, useTemplateTagData, useCurrentUpdatedAt, useCreator, useRevisionAuthor, useCurrentUser, useTargetAndAncestors,
-  useSlackChannels, useNotFoundTargetPathOrId, useIsSearchPage,
+  useSlackChannels, useNotFoundTargetPathOrId, useIsSearchPage, useIsForbidden, useIsIdenticalPath,
 } from '../../stores/context';
 import {
   useIsDeviceSmallerThanMd, useIsDeviceSmallerThanLg,
@@ -23,6 +23,7 @@ const ContextExtractorOnce: FC = () => {
 
   const mainContent = document.querySelector('#content-main');
   const notFoundContent = document.getElementById('growi-pagetree-not-found-context');
+  const forbiddenContent = document.getElementById('forbidden-page');
 
   /*
    * App Context from DOM
@@ -50,13 +51,15 @@ const ContextExtractorOnce: FC = () => {
   const updatedAt: Date | null = (updatedAtAttribute != null) ? new Date(updatedAtAttribute) : null;
 
   const deletedAt = mainContent?.getAttribute('data-page-deleted-at') || null;
-  const isUserPage = JSON.parse(mainContent?.getAttribute('data-page-user') || jsonNull);
+  const isIdenticalPath = JSON.parse(mainContent?.getAttribute('data-identical-path') || jsonNull) ?? false;
+  const isUserPage = JSON.parse(mainContent?.getAttribute('data-page-user') || jsonNull) != null;
   const isTrashPage = _isTrashPage(path);
-  const isDeleted = JSON.parse(mainContent?.getAttribute('data-page-is-deleted') || jsonNull);
-  const isDeletable = JSON.parse(mainContent?.getAttribute('data-page-is-deletable') || jsonNull);
-  const isNotCreatable = JSON.parse(mainContent?.getAttribute('data-page-is-not-creatable') || jsonNull);
-  const isAbleToDeleteCompletely = JSON.parse(mainContent?.getAttribute('data-page-is-able-to-delete-completely') || jsonNull);
+  const isDeleted = JSON.parse(mainContent?.getAttribute('data-page-is-deleted') || jsonNull) ?? false;
+  const isDeletable = JSON.parse(mainContent?.getAttribute('data-page-is-deletable') || jsonNull) ?? false;
+  const isNotCreatable = JSON.parse(mainContent?.getAttribute('data-page-is-not-creatable') || jsonNull) ?? false;
+  const isAbleToDeleteCompletely = JSON.parse(mainContent?.getAttribute('data-page-is-able-to-delete-completely') || jsonNull) ?? false;
   const isPageExist = mainContent?.getAttribute('data-page-id') != null;
+  const isForbidden = forbiddenContent != null;
   const pageUser = JSON.parse(mainContent?.getAttribute('data-page-user') || jsonNull);
   const hasChildren = JSON.parse(mainContent?.getAttribute('data-page-has-children') || jsonNull);
   const templateTagData = mainContent?.getAttribute('data-template-tags') || null;
@@ -97,11 +100,13 @@ const ContextExtractorOnce: FC = () => {
   useDeletedAt(deletedAt);
   useHasChildren(hasChildren);
   useHasDraftOnHackmd(hasDraftOnHackmd);
+  useIsIdenticalPath(isIdenticalPath);
   useIsAbleToDeleteCompletely(isAbleToDeleteCompletely);
   useIsDeletable(isDeletable);
   useIsDeleted(isDeleted);
   useIsNotCreatable(isNotCreatable);
   useIsPageExist(isPageExist);
+  useIsForbidden(isForbidden);
   useIsTrashPage(isTrashPage);
   useIsUserPage(isUserPage);
   useLastUpdateUsername(lastUpdateUsername);

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

@@ -54,15 +54,6 @@ export default class PageContainer extends Container {
       path,
       tocHtml: '',
 
-      seenUsers: [],
-      seenUserIds: [],
-      sumOfSeenUsers: [],
-
-      isLiked: false,
-      likers: [],
-      likerIds: [],
-      sumOfLikers: 0,
-
       createdAt: mainContent.getAttribute('data-page-created-at'),
       // please use useCurrentUpdatedAt instead
       updatedAt: mainContent.getAttribute('data-page-updated-at'),
@@ -117,23 +108,9 @@ export default class PageContainer extends Container {
     interceptorManager.addInterceptor(new RestoreCodeBlockInterceptor(appContainer), 900); // process as late as possible
 
     this.initStateMarkdown();
-    this.checkAndUpdateImageUrlCached(this.state.likers);
-
-    const { isSharedUser } = this.appContainer;
-
-    // see https://dev.growi.org/5fabddf8bbeb1a0048bcb9e9
-    const isAbleToGetAttachedInformationAboutPages = this.state.isPageExist && !isSharedUser;
-
-    if (isAbleToGetAttachedInformationAboutPages) {
-      // We don't retrieve bookmarks in the initial page load
-      // as it is stored in a separate collection to like and seen user
-      // data so it has a separate api endpoint.
-      this.initialPageLoad();
-    }
 
     this.setTocHtml = this.setTocHtml.bind(this);
     this.save = this.save.bind(this);
-    this.checkAndUpdateImageUrlCached = this.checkAndUpdateImageUrlCached.bind(this);
 
     this.emitJoinPageRoomRequest = this.emitJoinPageRoomRequest.bind(this);
     this.emitJoinPageRoomRequest();
@@ -266,64 +243,6 @@ export default class PageContainer extends Container {
     this.state.markdown = markdown;
   }
 
-
-  async initialPageLoad() {
-    {
-      const {
-        data: {
-          likerIds, sumOfLikers, isLiked, seenUserIds, sumOfSeenUsers, isSeen,
-        },
-      } = await this.appContainer.apiv3Get('/page/info', { pageId: this.state.pageId });
-
-      await this.setState({
-        sumOfLikers,
-        isLiked,
-        likerIds,
-        seenUserIds,
-        sumOfSeenUsers,
-        isSeen,
-      });
-    }
-
-    await this.retrieveLikersAndSeenUsers();
-  }
-
-
-  async retrieveLikersAndSeenUsers() {
-    const { users } = await this.appContainer.apiGet('/users.list', { user_ids: [...this.state.likerIds, ...this.state.seenUserIds].join(',') });
-
-    await this.setState({
-      likers: users.filter(({ id }) => this.state.likerIds.includes(id)).slice(0, 15),
-      seenUsers: users.filter(({ id }) => this.state.seenUserIds.includes(id)).slice(0, 15),
-    });
-
-    this.checkAndUpdateImageUrlCached(users);
-  }
-
-  async retrieveBookmarkInfo() {
-    const response = await this.appContainer.apiv3Get('/bookmarks/info', { pageId: this.state.pageId });
-    this.setState({
-      sumOfBookmarks: response.data.sumOfBookmarks,
-      isBookmarked: response.data.isBookmarked,
-    });
-  }
-
-  async checkAndUpdateImageUrlCached(users) {
-    const noImageCacheUsers = users.filter((user) => { return user.imageUrlCached == null });
-    if (noImageCacheUsers.length === 0) {
-      return;
-    }
-
-    const noImageCacheUserIds = noImageCacheUsers.map((user) => { return user.id });
-    try {
-      await this.appContainer.apiv3Put('/users/update.imageUrlCache', { userIds: noImageCacheUserIds });
-    }
-    catch (err) {
-      // Error alert doesn't apear, because user don't need to notice this error.
-      logger.error(err);
-    }
-  }
-
   setLatestRemotePageData(s2cMessagePageUpdated) {
     const newState = {
       remoteRevisionId: s2cMessagePageUpdated.revisionId,

+ 3 - 3
packages/app/src/components/Admin/Security/SecuritySetting.jsx

@@ -157,10 +157,10 @@ class SecuritySetting extends React.Component {
                 aria-expanded="true"
               >
                 <span className="float-left">
-                  {currentPageCompleteDeletionAuthority === 'anyOne' && t('security_setting.anyone')}
+                  {(currentPageCompleteDeletionAuthority === 'anyOne' || currentPageCompleteDeletionAuthority == null)
+                      && t('security_setting.anyone')}
                   {currentPageCompleteDeletionAuthority === 'adminOnly' && t('security_setting.admin_only')}
-                  {(currentPageCompleteDeletionAuthority === 'adminAndAuthor' || currentPageCompleteDeletionAuthority == null)
-                      && t('security_setting.admin_and_author')}
+                  {currentPageCompleteDeletionAuthority === 'adminAndAuthor' && t('security_setting.admin_and_author')}
                 </span>
               </button>
               <div className="dropdown-menu" aria-labelledby="dropdownMenuButton">

+ 2 - 2
packages/app/src/components/Admin/UserGroupDetail/UserGroupPageList.jsx

@@ -2,7 +2,7 @@ import React, { Fragment } from 'react';
 import PropTypes from 'prop-types';
 import { withTranslation } from 'react-i18next';
 
-import Page from '../../PageList/Page';
+import PageListItemS from '../../PageList/PageListItemS';
 import PaginationWrapper from '../../PaginationWrapper';
 import { withUnstatedContainers } from '../../UnstatedUtils';
 import AppContainer from '~/client/services/AppContainer';
@@ -57,7 +57,7 @@ class UserGroupPageList extends React.Component {
     return (
       <Fragment>
         <ul className="page-list-ul page-list-ul-flat mb-3">
-          {this.state.currentPages.map(page => <li key={page._id}><Page page={page} /></li>)}
+          {this.state.currentPages.map(page => <li key={page._id}><PageListItemS page={page} /></li>)}
         </ul>
         {relatedPages.length === 0 ? <p>{t('admin:user_group_management.no_pages')}</p> : (
           <PaginationWrapper

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

@@ -13,7 +13,7 @@ import { useSWRBookmarkInfo } from '~/stores/bookmark';
 
 type PageItemControlProps = {
   page: Partial<IPageHasId>
-  isEnableActions: boolean
+  isEnableActions?: boolean
   isDeletable: boolean
   onClickDeleteButtonHandler?: (pageId: string) => void
   onClickRenameButtonHandler?: (pageId: string) => void

+ 56 - 0
packages/app/src/components/DescendantsPageList.tsx

@@ -0,0 +1,56 @@
+import React, { useState } from 'react';
+
+import { useSWRxPageList } from '~/stores/page';
+
+import PageList from './PageList/PageList';
+import PaginationWrapper from './PaginationWrapper';
+
+type Props = {
+  path: string,
+}
+
+const DescendantsPageList = (props: Props): JSX.Element => {
+  const { path } = props;
+
+  const [activePage, setActivePage] = useState(1);
+
+  const { data, error } = useSWRxPageList(path, activePage);
+
+  function setPageNumber(selectedPageNumber) {
+    setActivePage(selectedPageNumber);
+  }
+
+  if (error != null) {
+    return (
+      <div className="my-5">
+        <div className="text-danger">{error.message}</div>
+      </div>
+    );
+  }
+
+  if (data === undefined) {
+    return (
+      <div className="wiki">
+        <div className="text-muted text-center">
+          <i className="fa fa-2x fa-spinner fa-pulse mr-1"></i>
+        </div>
+      </div>
+    );
+  }
+
+  return (
+    <>
+      <PageList pages={data} />
+
+      <PaginationWrapper
+        activePage={activePage}
+        changePage={setPageNumber}
+        totalItemsCount={data.totalCount}
+        pagingLimit={data.limit}
+        align="center"
+      />
+    </>
+  );
+};
+
+export default DescendantsPageList;

+ 0 - 26
packages/app/src/components/DuplicatePage.tsx

@@ -1,26 +0,0 @@
-import React, { FC } from 'react';
-import { DevidedPagePath } from '@growi/core';
-import { useTranslation } from 'react-i18next';
-
-
-type DuplicatePageAlertProps = {
-  path : string,
-}
-
-const DuplicatePageAlert : FC<DuplicatePageAlertProps> = (props: DuplicatePageAlertProps) => {
-  const { path } = props;
-  const { t } = useTranslation();
-  const devidedPath = new DevidedPagePath(path);
-
-  return (
-    <div className="alert alert-warning py-3">
-      <h5 className="font-weight-bold mt-1">{t('duplicated_page_alert.same_page_name_exists', { pageName: devidedPath.latter })}</h5>
-      <p>
-        {t('duplicated_page_alert.same_page_name_exists_at_path',
-          { path: devidedPath.isFormerRoot ? '/' : devidedPath.former, pageName: devidedPath.latter })}<br />
-        <p dangerouslySetInnerHTML={{ __html: t('See_more_detail_on_new_schema', { url: t('GROWI.5.0_new_schema') }) }} />
-      </p>
-      <p className="mb-1">{t('duplicated_page_alert.select_page_to_see')}</p>
-    </div>
-  );
-};

+ 20 - 17
packages/app/src/components/ForbiddenPage.jsx → packages/app/src/components/ForbiddenPage.tsx

@@ -1,19 +1,23 @@
 import React, { useMemo } from 'react';
-import PropTypes from 'prop-types';
-import { withTranslation } from 'react-i18next';
+import { useTranslation } from 'react-i18next';
+
 import PageListIcon from './Icons/PageListIcon';
 import CustomNavAndContents from './CustomNavigation/CustomNavAndContents';
-import PageList from './PageList';
+import DescendantsPageList from './DescendantsPageList';
+
 
+type Props = {
+  isSharePage?: boolean,
+}
 
-const ForbiddenPage = (props) => {
-  const { t } = props;
+const ForbiddenPage = React.memo((props: Props): JSX.Element => {
+  const { t } = useTranslation();
 
   const navTabMapping = useMemo(() => {
     return {
       pagelist: {
         Icon: PageListIcon,
-        Content: PageList,
+        Content: DescendantsPageList,
         i18n: t('page_list'),
         index: 0,
       },
@@ -31,24 +35,23 @@ const ForbiddenPage = (props) => {
         </div>
       </div>
 
-
       <div className="row row-alerts d-edit-none">
         <div className="col-sm-12">
           <p className="alert alert-primary py-3 px-4">
             <i className="icon-fw icon-lock" aria-hidden="true" />
-            {t('Browsing of this page is restricted')}
+            { props.isSharePage ? t('custom_navigation.link_sharing_is_disabled') : t('Browsing of this page is restricted')}
           </p>
         </div>
       </div>
-      <div className="mt-5">
-        <CustomNavAndContents navTabMapping={navTabMapping} />
-      </div>
+
+      { !props.isSharePage && (
+        <div className="mt-5">
+          <CustomNavAndContents navTabMapping={navTabMapping} />
+        </div>
+      ) }
+
     </>
   );
-};
-
-ForbiddenPage.propTypes = {
-  t: PropTypes.func.isRequired,
-};
+});
 
-export default withTranslation()(ForbiddenPage);
+export default ForbiddenPage;

+ 92 - 6
packages/app/src/components/IdenticalPathPage.tsx

@@ -1,14 +1,100 @@
-import React, { FC } from 'react';
+import React, {
+  FC,
+} from 'react';
+import { useTranslation } from 'react-i18next';
+
+import { DevidedPagePath } from '@growi/core';
+
+import { useCurrentPagePath } from '~/stores/context';
+
+import { PageListItemL } from './PageList/PageListItemL';
+
+
+type IdenticalPathAlertProps = {
+  path? : string | null,
+}
+
+const IdenticalPathAlert : FC<IdenticalPathAlertProps> = (props: IdenticalPathAlertProps) => {
+  const { path } = props;
+  const { t } = useTranslation();
+
+  let _path = '――';
+  let _pageName = '――';
+
+  if (path != null) {
+    const devidedPath = new DevidedPagePath(path);
+    _path = devidedPath.isFormerRoot ? '/' : devidedPath.former;
+    _pageName = devidedPath.latter;
+  }
+
+
+  return (
+    <div className="alert alert-warning py-3">
+      <h5 className="font-weight-bold mt-1">{t('duplicated_page_alert.same_page_name_exists', { pageName: _pageName })}</h5>
+      <p>
+        {t('duplicated_page_alert.same_page_name_exists_at_path',
+          { path: _path, pageName: _pageName })}<br />
+        <p
+          // eslint-disable-next-line react/no-danger
+          dangerouslySetInnerHTML={{ __html: t('See_more_detail_on_new_schema', { url: t('GROWI.5.0_new_schema') }) }}
+        />
+      </p>
+      <p className="mb-1">{t('duplicated_page_alert.select_page_to_see')}</p>
+    </div>
+  );
+};
+
 
 type IdenticalPathPageProps= {
   // add props and types here
 }
-const IdenticalPathPage:FC<IdenticalPathPageProps> = (props:IdenticalPathPageProps) => {
+
+
+const jsonNull = 'null';
+
+const IdenticalPathPage:FC<IdenticalPathPageProps> = (props: IdenticalPathPageProps) => {
+
+  const identicalPageDocument = document.getElementById('identical-path-page');
+  const pageDataList = JSON.parse(identicalPageDocument?.getAttribute('data-identical-page-data-list') || jsonNull);
+  const shortbodyMap = JSON.parse(identicalPageDocument?.getAttribute('data-shortody-map') || jsonNull);
+
+  const { data: currentPath } = useCurrentPagePath();
+
   return (
-    <div>
-      {/* Todo: show alert */}
-      {/* Todo: show identical path page list */}
-      IdenticalPathPageList
+    <div className="d-flex flex-column flex-lg-row-reverse">
+
+      <div className="grw-side-contents-container">
+        <div className="grw-side-contents-sticky-container">
+          <div className="border-bottom pb-1">
+            {/* <PageAccessories isNotFoundPage={!isPageExist} /> */}
+          </div>
+        </div>
+      </div>
+
+      <div className="flex-grow-1 flex-basis-0 mw-0">
+
+        <IdenticalPathAlert path={currentPath} />
+
+        <div className="page-list">
+          <ul className="page-list-ul list-group-flush border px-3">
+            {pageDataList.map((data) => {
+              return (
+                <PageListItemL
+                  key={data.pageData._id}
+                  page={data}
+                  isSelected={false}
+                  isChecked={false}
+                  isEnableActions
+                  shortBody={shortbodyMap[data.pageData._id]}
+                // Todo: add onClickDeleteButton when delete feature implemented
+                />
+              );
+            })}
+          </ul>
+        </div>
+
+      </div>
+
     </div>
   );
 };

+ 14 - 4
packages/app/src/components/InstallerForm.jsx

@@ -55,8 +55,6 @@ class InstallerForm extends React.Component {
     setTimeout(() => {
       this.setState({ isSubmittingDisabled: false });
     }, 3000);
-
-    document['register-form'].submit();
   }
 
   render() {
@@ -66,7 +64,7 @@ class InstallerForm extends React.Component {
       : <span><i className="icon-fw icon-ban" />{ this.props.t('installer.unavaliable_user_id') }</span>;
 
     return (
-      <div className={`login-dialog p-3 mx-auto${hasErrorClass}`}>
+      <div data-testid="installerForm" className={`login-dialog p-3 mx-auto${hasErrorClass}`}>
         <div className="row">
           <div className="col-md-12">
             <p className="alert alert-success">
@@ -84,6 +82,7 @@ class InstallerForm extends React.Component {
                   type="button"
                   className="btn btn-secondary dropdown-toggle text-right w-100 border-0 shadow-none"
                   id="dropdownLanguage"
+                  data-testid="dropdownLanguage"
                   data-toggle="dropdown"
                   aria-haspopup="true"
                   aria-expanded="true"
@@ -100,7 +99,13 @@ class InstallerForm extends React.Component {
                 <div className="dropdown-menu" aria-labelledby="dropdownLanguage">
                   {
                     localeMetadatas.map(meta => (
-                      <button key={meta.id} className="dropdown-item" type="button" onClick={() => { this.changeLanguage(meta) }}>
+                      <button
+                        key={meta.id}
+                        data-testid={`dropdownLanguageMenu-${meta.id}`}
+                        className="dropdown-item"
+                        type="button"
+                        onClick={() => { this.changeLanguage(meta) }}
+                      >
                         {meta.displayName}
                       </button>
                     ))
@@ -114,6 +119,7 @@ class InstallerForm extends React.Component {
                 <span className="input-group-text"><i className="icon-user" /></span>
               </div>
               <input
+                data-testid="tiUsername"
                 type="text"
                 className="form-control"
                 placeholder={this.props.t('User ID')}
@@ -130,6 +136,7 @@ class InstallerForm extends React.Component {
                 <span className="input-group-text"><i className="icon-tag" /></span>
               </div>
               <input
+                data-testid="tiName"
                 type="text"
                 className="form-control"
                 placeholder={this.props.t('Name')}
@@ -144,6 +151,7 @@ class InstallerForm extends React.Component {
                 <span className="input-group-text"><i className="icon-envelope" /></span>
               </div>
               <input
+                data-testid="tiEmail"
                 type="email"
                 className="form-control"
                 placeholder={this.props.t('Email')}
@@ -158,6 +166,7 @@ class InstallerForm extends React.Component {
                 <span className="input-group-text"><i className="icon-lock" /></span>
               </div>
               <input
+                data-testid="tiPassword"
                 type="password"
                 className="form-control"
                 placeholder={this.props.t('Password')}
@@ -170,6 +179,7 @@ class InstallerForm extends React.Component {
 
             <div className="input-group mt-4 mb-3 d-flex justify-content-center">
               <button
+                data-testid="btnSubmit"
                 type="submit"
                 className="btn-fill btn btn-register"
                 id="register"

+ 0 - 1
packages/app/src/components/LikeButtons.tsx

@@ -27,7 +27,6 @@ const LikeButtons: FC<LikeButtonsProps> = (props: LikeButtonsProps) => {
     setIsPopoverOpen(!isPopoverOpen);
   };
 
-
   const handleClick = () => {
     if (props.onLikeClicked == null) {
       return;

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

@@ -50,7 +50,7 @@ class LoginForm extends React.Component {
               <i className="icon-user"></i>
             </span>
           </div>
-          <input type="text" className="form-control rounded-0" placeholder="Username or E-mail" name="loginForm[username]" />
+          <input type="text" className="form-control rounded-0" data-testid="tiUsernameForLogin" placeholder="Username or E-mail" name="loginForm[username]" />
           {isLdapStrategySetup && (
             <div className="input-group-append">
               <small className="input-group-text text-success">
@@ -66,12 +66,12 @@ class LoginForm extends React.Component {
               <i className="icon-lock"></i>
             </span>
           </div>
-          <input type="password" className="form-control rounded-0" placeholder="Password" name="loginForm[password]" />
+          <input type="password" className="form-control rounded-0" data-testid="tiPasswordForLogin" placeholder="Password" name="loginForm[password]" />
         </div>
 
         <div className="input-group my-4">
           <input type="hidden" name="_csrf" value={appContainer.csrfToken} />
-          <button type="submit" id="login" className="btn btn-fill rounded-0 login mx-auto">
+          <button type="submit" id="login" className="btn btn-fill rounded-0 login mx-auto" data-testid="btnSubmitForLogin">
             <div className="eff"></div>
             <span className="btn-label">
               <i className="icon-login"></i>
@@ -297,18 +297,18 @@ class LoginForm extends React.Component {
               <div className="front">
                 {isLocalOrLdapStrategiesEnabled && this.renderLocalOrLdapLoginForm()}
                 {isSomeExternalAuthEnabled && this.renderExternalAuthLoginForm()}
+                {isPasswordResetEnabled && (
+                  <div className="text-right mb-2">
+                    <a href="/forgot-password" className="d-block link-switch">
+                      <i className="icon-key"></i> {t('forgot_password.forgot_password')}
+                    </a>
+                  </div>
+                )}
                 {isRegistrationEnabled && (
-                  <div className="row">
-                    <div className="col-12 text-right py-2">
-                      {isPasswordResetEnabled && (
-                        <a href="/forgot-password" className="d-block link-switch mb-1">
-                          <i className="icon-key"></i> {t('forgot_password.forgot_password')}
-                        </a>
-                      )}
-                      <a href="#register" id="register" className="link-switch" onClick={this.switchForm}>
-                        <i className="ti-check-box"></i> {t('Sign up is here')}
-                      </a>
-                    </div>
+                  <div className="text-right mb-2">
+                    <a href="#register" id="register" className="link-switch" onClick={this.switchForm}>
+                      <i className="ti-check-box"></i> {t('Sign up is here')}
+                    </a>
                   </div>
                 )}
               </div>

+ 4 - 3
packages/app/src/components/Navbar/GlobalSearch.tsx

@@ -5,13 +5,14 @@ import { useTranslation } from 'react-i18next';
 import assert from 'assert';
 
 import AppContainer from '~/client/services/AppContainer';
-import { IPageSearchResultData } from '~/interfaces/search';
 import { IFocusable } from '~/client/interfaces/focusable';
+import { useGlobalSearchFormRef } from '~/stores/ui';
+import { IPageSearchMeta } from '~/interfaces/search';
+import { IPageWithMeta } from '~/interfaces/page';
 
 import { withUnstatedContainers } from '../UnstatedUtils';
 
 import SearchForm from '../SearchForm';
-import { useGlobalSearchFormRef } from '~/stores/ui';
 
 
 type Props = {
@@ -32,7 +33,7 @@ const GlobalSearch: FC<Props> = (props: Props) => {
   const [isScopeChildren, setScopeChildren] = useState<boolean>(appContainer.getConfig().isSearchScopeChildrenAsDefault);
   const [isFocused, setFocused] = useState<boolean>(false);
 
-  const gotoPage = useCallback((data: IPageSearchResultData[]) => {
+  const gotoPage = useCallback((data: IPageWithMeta<IPageSearchMeta>[]) => {
     assert(data.length > 0);
 
     const page = data[0].pageData; // should be single page selected

+ 36 - 35
packages/app/src/components/Navbar/GrowiSubNavigation.jsx

@@ -2,13 +2,16 @@ import React, { useCallback } from 'react';
 import PropTypes from 'prop-types';
 
 import { withUnstatedContainers } from '../UnstatedUtils';
-import AppContainer from '~/client/services/AppContainer';
-import PageContainer from '~/client/services/PageContainer';
 import EditorContainer from '~/client/services/EditorContainer';
 import {
-  EditorMode, useDrawerMode, useEditorMode, useIsDeviceSmallerThanMd,
+  EditorMode, useDrawerMode, useEditorMode, useIsDeviceSmallerThanMd, useIsAbleToShowPageManagement, useIsAbleToShowTagLabel,
+  useIsAbleToShowPageEditorModeManager, useIsAbleToShowPageAuthors,
 } from '~/stores/ui';
-import { useCurrentCreatedAt, useCurrentUpdatedAt } from '~/stores/context';
+import {
+  useCurrentCreatedAt, useCurrentUpdatedAt, useCurrentPageId, useRevisionId, useCurrentPagePath, useIsDeletable,
+  useIsAbleToDeleteCompletely, useCreator, useRevisionAuthor, useIsPageExist, useIsGuestUser,
+} from '~/stores/context';
+import { useSWRTagsInfo } from '~/stores/page';
 
 import TagLabels from '../Page/TagLabels';
 import SubNavButtons from './SubNavButtons';
@@ -28,30 +31,29 @@ const GrowiSubNavigation = (props) => {
   const { data: editorMode, mutate: mutateEditorMode } = useEditorMode();
   const { data: createdAt } = useCurrentCreatedAt();
   const { data: updatedAt } = useCurrentUpdatedAt();
+  const { data: pageId } = useCurrentPageId();
+  const { data: revisionId } = useRevisionId();
+  const { data: path } = useCurrentPagePath();
+  const { data: isDeletable } = useIsDeletable();
+  const { data: isAbleToDeleteCompletely } = useIsAbleToDeleteCompletely();
+  const { data: creator } = useCreator();
+  const { data: revisionAuthor } = useRevisionAuthor();
+  const { data: isGuestUser } = useIsGuestUser();
+
+  const { data: isAbleToShowPageManagement } = useIsAbleToShowPageManagement();
+  const { data: isAbleToShowTagLabel } = useIsAbleToShowTagLabel();
+  const { data: isAbleToShowPageEditorModeManager } = useIsAbleToShowPageEditorModeManager();
+  const { data: isAbleToShowPageAuthors } = useIsAbleToShowPageAuthors();
+
+  const { mutate: mutateSWRTagsInfo, data: TagsInfoData } = useSWRTagsInfo(pageId);
 
   const {
-    appContainer, pageContainer, editorContainer, isCompactMode,
+    editorContainer, isCompactMode,
   } = props;
 
-  const {
-    pageId,
-    revisionId,
-    path,
-    isDeletable,
-    isAbleToDeleteCompletely,
-    creator,
-    revisionAuthor,
-    isPageExist,
-    isTrashPage,
-    tags,
-  } = pageContainer.state;
-
-  const { isGuestUser, isSharedUser } = appContainer;
-  const isEditorMode = editorMode !== EditorMode.View;
-  // Tags cannot be edited while the new page and editorMode is view
-  const isTagLabelHidden = (editorMode !== EditorMode.Editor && !isPageExist);
-
-  const isAbleToShowPageManagement = isPageExist && !isTrashPage && !isSharedUser && !isEditorMode;
+  const isViewMode = editorMode === EditorMode.View;
+  const isEditorMode = !isViewMode;
+
   function onPageEditorModeButtonClicked(viewType) {
     mutateEditorMode(viewType);
   }
@@ -63,10 +65,10 @@ const GrowiSubNavigation = (props) => {
     }
 
     try {
-      const { tags } = await apiPost('/tags.update', { pageId, tags: newTags });
+      const { tags } = await apiPost('/tags.update', { pageId, revisionId, tags: newTags });
 
-      // update pageContainer.state
-      pageContainer.setState({ tags });
+      // revalidate SWRTagsInfo
+      mutateSWRTagsInfo();
       // update editorContainer.state
       editorContainer.setState({ tags });
 
@@ -90,9 +92,9 @@ const GrowiSubNavigation = (props) => {
         ) }
 
         <div className="grw-path-nav-container">
-          { pageContainer.isAbleToShowTagLabel && !isCompactMode && !isTagLabelHidden && (
+          { isAbleToShowTagLabel && !isCompactMode && (
             <div className="grw-taglabels-container">
-              <TagLabels tags={tags} tagsUpdateInvoked={tagsUpdatedHandler} />
+              <TagLabels tags={TagsInfoData?.tags || []} tagsUpdateInvoked={tagsUpdatedHandler} />
             </div>
           ) }
           <PagePathNav pageId={pageId} pagePath={path} isSingleLineMode={isEditorMode} isCompactMode={isCompactMode} />
@@ -110,10 +112,11 @@ const GrowiSubNavigation = (props) => {
             path={path}
             isDeletable={isDeletable}
             isAbleToDeleteCompletely={isAbleToDeleteCompletely}
-            willShowPageManagement={isAbleToShowPageManagement}
+            isViewMode={isViewMode}
+            isAbleToShowPageManagement={isAbleToShowPageManagement}
           />
           <div className="mt-2">
-            {pageContainer.isAbleToShowPageEditorModeManager && (
+            {isAbleToShowPageEditorModeManager && (
               <PageEditorModeManager
                 onPageEditorModeButtonClicked={onPageEditorModeButtonClicked}
                 isBtnDisabled={isGuestUser}
@@ -125,7 +128,7 @@ const GrowiSubNavigation = (props) => {
         </div>
 
         {/* Page Authors */}
-        { (pageContainer.isAbleToShowPageAuthors && !isCompactMode) && (
+        { (isAbleToShowPageAuthors && !isCompactMode) && (
           <ul className="authors text-nowrap border-left d-none d-lg-block d-edit-none py-2 pl-4 mb-0 ml-3">
             <li className="pb-1">
               <AuthorInfo user={creator} date={createdAt} locate="subnav" />
@@ -143,12 +146,10 @@ const GrowiSubNavigation = (props) => {
 /**
  * Wrapper component for using unstated
  */
-const GrowiSubNavigationWrapper = withUnstatedContainers(GrowiSubNavigation, [AppContainer, PageContainer, EditorContainer]);
+const GrowiSubNavigationWrapper = withUnstatedContainers(GrowiSubNavigation, [EditorContainer]);
 
 
 GrowiSubNavigation.propTypes = {
-  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
-  pageContainer: PropTypes.instanceOf(PageContainer).isRequired,
   editorContainer: PropTypes.instanceOf(EditorContainer).isRequired,
 
   isCompactMode: PropTypes.bool,

+ 14 - 17
packages/app/src/components/Navbar/SubNavButtons.tsx

@@ -10,7 +10,6 @@ import { useSWRBookmarkInfo } from '../../stores/bookmark';
 import { toastError } from '../../client/util/apiNotification';
 import { apiv3Put } from '../../client/util/apiv3-client';
 import { useSWRxLikerList } from '../../stores/user';
-import { useEditorMode } from '~/stores/ui';
 import { useIsGuestUser } from '~/stores/context';
 
 type SubNavButtonsProps= {
@@ -18,18 +17,16 @@ type SubNavButtonsProps= {
   pageId: string,
   revisionId: string,
   path: string,
-  willShowPageManagement: boolean,
+  isViewMode: boolean
+  isAbleToShowPageManagement: boolean,
   isDeletable: boolean,
   isAbleToDeleteCompletely: boolean,
 }
 const SubNavButtons: FC<SubNavButtonsProps> = (props: SubNavButtonsProps) => {
   const {
-    isCompactMode, pageId, revisionId, path, willShowPageManagement, isDeletable, isAbleToDeleteCompletely,
+    isCompactMode, pageId, revisionId, path, isViewMode, isAbleToShowPageManagement, isDeletable, isAbleToDeleteCompletely,
   } = props;
 
-  const { data: editorMode } = useEditorMode();
-  const isViewMode = editorMode === 'view';
-
   const { data: isGuestUser } = useIsGuestUser();
 
   const { data: pageInfo, error: pageInfoError, mutate: mutatePageInfo } = useSWRPageInfo(pageId);
@@ -95,19 +92,19 @@ const SubNavButtons: FC<SubNavButtonsProps> = (props: SubNavButtonsProps) => {
             onBookMarkClicked={bookmarkClickHandler}
           >
           </PageReactionButtons>
+          { isAbleToShowPageManagement && (
+            <PageManagement
+              pageId={pageId}
+              revisionId={revisionId}
+              path={path}
+              isCompactMode={isCompactMode}
+              isDeletable={isDeletable}
+              isAbleToDeleteCompletely={isAbleToDeleteCompletely}
+            >
+            </PageManagement>
+          )}
         </>
       )}
-      {willShowPageManagement && (
-        <PageManagement
-          pageId={pageId}
-          revisionId={revisionId}
-          path={path}
-          isCompactMode={isCompactMode}
-          isDeletable={isDeletable}
-          isAbleToDeleteCompletely={isAbleToDeleteCompletely}
-        >
-        </PageManagement>
-      )}
     </div>
   );
 };

+ 8 - 12
packages/app/src/components/NotFoundPage.jsx → packages/app/src/components/NotFoundPage.tsx

@@ -1,20 +1,20 @@
 import React, { useMemo } from 'react';
-import PropTypes from 'prop-types';
-import { withTranslation } from 'react-i18next';
+import { useTranslation } from 'react-i18next';
+
 import PageListIcon from './Icons/PageListIcon';
 import TimeLineIcon from './Icons/TimeLineIcon';
 import CustomNavAndContents from './CustomNavigation/CustomNavAndContents';
-import PageList from './PageList';
+import DescendantsPageList from './DescendantsPageList';
 import PageTimeline from './PageTimeline';
 
-const NotFoundPage = (props) => {
-  const { t } = props;
+const NotFoundPage = (): JSX.Element => {
+  const { t } = useTranslation();
 
   const navTabMapping = useMemo(() => {
     return {
       pagelist: {
         Icon: PageListIcon,
-        Content: PageList,
+        Content: DescendantsPageList,
         i18n: t('page_list'),
         index: 0,
       },
@@ -29,14 +29,10 @@ const NotFoundPage = (props) => {
 
 
   return (
-    <div className="mt-5 d-edit-none">
+    <div className="d-edit-none">
       <CustomNavAndContents navTabMapping={navTabMapping} />
     </div>
   );
 };
 
-NotFoundPage.propTypes = {
-  t: PropTypes.func.isRequired, //  i18next
-};
-
-export default withTranslation()(NotFoundPage);
+export default NotFoundPage;

+ 12 - 10
packages/app/src/components/Page/DisplaySwitcher.jsx

@@ -35,20 +35,22 @@ const DisplaySwitcher = (props) => {
         <TabPane tabId={EditorMode.View}>
           <div className="d-flex flex-column flex-lg-row-reverse">
 
-            <div className="grw-side-contents-container">
-              <div className="grw-side-contents-sticky-container">
-                <div className="border-bottom pb-1">
-                  <PageAccessories isNotFoundPage={!isPageExist} />
-                </div>
+            { isPageExist && (
+              <div className="grw-side-contents-container">
+                <div className="grw-side-contents-sticky-container">
+                  <div className="border-bottom pb-1">
+                    <PageAccessories />
+                  </div>
 
-                <div className="d-none d-lg-block">
-                  <div id="revision-toc" className="revision-toc">
-                    <TableOfContents />
+                  <div className="d-none d-lg-block">
+                    <div id="revision-toc" className="revision-toc">
+                      <TableOfContents />
+                    </div>
+                    <ContentLinkButtons />
                   </div>
-                  <ContentLinkButtons />
                 </div>
               </div>
-            </div>
+            ) }
 
             <div className="flex-grow-1 flex-basis-0 mw-0">
               {pageUser && <UserInfo pageUser={pageUser} />}

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

@@ -1,15 +1,21 @@
 import React, { useCallback } from 'react';
-import PropTypes from 'prop-types';
 import { useTranslation } from 'react-i18next';
 import { UncontrolledTooltip } from 'reactstrap';
+
 import { EditorMode, useEditorMode } from '~/stores/ui';
 
 
-const NotFoundAlert = (props) => {
+type Props = {
+  isGuestUserMode?: boolean,
+}
+
+const NotFoundAlert = (props: Props): JSX.Element => {
   const { t } = useTranslation();
-  const { isHidden, isGuestUserMode } = props;
+  const { isGuestUserMode } = props;
+
+  const { data: editorMode, mutate: mutateEditorMode } = useEditorMode();
 
-  const { mutate: mutateEditorMode } = useEditorMode();
+  const isEditorMode = editorMode !== EditorMode.View;
 
   const clickHandler = useCallback(() => {
     // check guest user,
@@ -22,11 +28,10 @@ const NotFoundAlert = (props) => {
 
   }, [isGuestUserMode, mutateEditorMode]);
 
-  if (isHidden) {
-    return null;
+  if (isEditorMode) {
+    return <></>;
   }
 
-
   return (
     <div className="border border-info p-3">
       <div
@@ -59,9 +64,4 @@ const NotFoundAlert = (props) => {
 };
 
 
-NotFoundAlert.propTypes = {
-  isHidden: PropTypes.bool.isRequired,
-  isGuestUserMode: PropTypes.bool.isRequired,
-};
-
 export default NotFoundAlert;

+ 13 - 17
packages/app/src/components/Page/RenderTagLabels.jsx → packages/app/src/components/Page/RenderTagLabels.tsx

@@ -1,19 +1,23 @@
 import React from 'react';
-import PropTypes from 'prop-types';
-import { withTranslation } from 'react-i18next';
+import { useTranslation } from 'react-i18next';
 
 import { UncontrolledTooltip } from 'reactstrap';
 
-const RenderTagLabels = React.memo((props) => {
-  const {
-    t, tags, isGuestUser,
-  } = props;
+type RenderTagLabelsProps = {
+  tags: string[],
+  isGuestUser: boolean,
+  openEditorModal?: () => void,
+}
+
+const RenderTagLabels = React.memo((props: RenderTagLabelsProps) => {
+  const { tags, isGuestUser, openEditorModal } = props;
+  const { t } = useTranslation();
 
   function openEditorHandler() {
-    if (props.openEditorModal == null) {
+    if (openEditorModal == null) {
       return;
     }
-    props.openEditorModal();
+    openEditorModal();
   }
 
   // activate suspense
@@ -22,7 +26,6 @@ const RenderTagLabels = React.memo((props) => {
   }
 
   const isTagsEmpty = tags.length === 0;
-
   const tagElements = tags.map((tag) => {
     return (
       <a key={tag} href={`/_search?q=tag:${tag}`} className="grw-tag-label badge badge-secondary mr-2">
@@ -54,12 +57,5 @@ const RenderTagLabels = React.memo((props) => {
 
 });
 
-RenderTagLabels.propTypes = {
-  t: PropTypes.func.isRequired, // i18next
-
-  tags: PropTypes.array,
-  openEditorModal: PropTypes.func,
-  isGuestUser: PropTypes.bool.isRequired,
-};
 
-export default withTranslation()(RenderTagLabels);
+export default RenderTagLabels;

+ 2 - 1
packages/app/src/components/Page/RevisionRenderer.jsx

@@ -64,7 +64,7 @@ class LegacyRevisionRenderer extends React.PureComponent {
    */
   getHighlightedBody(body, keywords) {
     const normalizedKeywordsArray = [];
-    // !!TODO!!: add test code
+    // !!TODO!!: add test code refs: https://redmine.weseek.co.jp/issues/86841
     // Separate keywords
     // - Surrounded by double quotation
     // - Split by both full-width and half-width spaces
@@ -84,6 +84,7 @@ class LegacyRevisionRenderer extends React.PureComponent {
 
     // for non-chrome browsers compatibility
     try {
+      // eslint-disable-next-line regex/invalid
       keywordRegexp2 = new RegExp(`(?<!<)${normalizedKeywords}(?!(.*?("|>)))`, 'ig'); // inferior (this doesn't work well when html tags exist a lot) https://regex101.com/r/Dfi61F/1
     }
     catch (err) {

+ 0 - 113
packages/app/src/components/Page/TagLabels.jsx

@@ -1,113 +0,0 @@
-import React, { Suspense } from 'react';
-import PropTypes from 'prop-types';
-import { withTranslation } from 'react-i18next';
-
-
-import { withUnstatedContainers } from '../UnstatedUtils';
-import AppContainer from '~/client/services/AppContainer';
-import EditorContainer from '~/client/services/EditorContainer';
-import PageContainer from '~/client/services/PageContainer';
-import { EditorMode } from '~/stores/ui';
-import { toastError, toastSuccess } from '~/client/util/apiNotification';
-
-import RenderTagLabels from './RenderTagLabels';
-import TagEditModal from './TagEditModal';
-
-class TagLabels extends React.Component {
-
-  constructor(props) {
-    super(props);
-
-    this.state = {
-      isTagEditModalShown: false,
-    };
-
-    this.openEditorModal = this.openEditorModal.bind(this);
-    this.closeEditorModal = this.closeEditorModal.bind(this);
-  }
-
-
-  openEditorModal() {
-    this.setState({ isTagEditModalShown: true });
-  }
-
-  closeEditorModal() {
-    this.setState({ isTagEditModalShown: false });
-  }
-
-  async tagsUpdatedHandler(newTags) {
-    const {
-      appContainer, editorContainer, pageContainer, editorMode,
-    } = this.props;
-
-    const { pageId, revisionId } = pageContainer.state;
-    // It will not be reflected in the DB until the page is refreshed
-    if (editorMode === EditorMode.Editor) {
-      return editorContainer.setState({ tags: newTags });
-    }
-    try {
-      const { tags, savedPage } = await appContainer.apiPost('/tags.update', {
-        pageId, tags: newTags, revisionId,
-      });
-      editorContainer.setState({ tags });
-      pageContainer.updatePageMetaData(savedPage, savedPage.revision, tags);
-      toastSuccess('updated tags successfully');
-    }
-    catch (err) {
-      toastError(err, 'fail to update tags');
-    }
-  }
-
-
-  render() {
-    const { appContainer, tagsUpdateInvoked, tags } = this.props;
-
-    return (
-      <>
-
-        <form className="grw-tag-labels form-inline">
-          <i className="tag-icon icon-tag mr-2"></i>
-          <Suspense fallback={<span className="grw-tag-label badge badge-secondary">―</span>}>
-            <RenderTagLabels
-              tags={tags}
-              openEditorModal={this.openEditorModal}
-              isGuestUser={appContainer.isGuestUser}
-            />
-          </Suspense>
-        </form>
-
-        <TagEditModal
-          tags={tags}
-          isOpen={this.state.isTagEditModalShown}
-          onClose={this.closeEditorModal}
-          appContainer={this.props.appContainer}
-          onTagsUpdated={tagsUpdateInvoked}
-        />
-
-      </>
-    );
-  }
-
-}
-
-/**
- * Wrapper component for using unstated
- */
-const TagLabelsUnstatedWrapper = withUnstatedContainers(TagLabels, [AppContainer, EditorContainer, PageContainer]);
-
-TagLabels.propTypes = {
-  t: PropTypes.func.isRequired, // i18next
-
-  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
-  editorContainer: PropTypes.instanceOf(EditorContainer).isRequired,
-  pageContainer: PropTypes.instanceOf(PageContainer).isRequired,
-  editorMode: PropTypes.string.isRequired,
-  tags: PropTypes.arrayOf(String),
-  tagsUpdateInvoked: PropTypes.func,
-};
-
-// wrapping tsx component returned by withUnstatedContainers to avoid type error when this component used in other tsx components.
-const TagLabelsWrapper = (props) => {
-  return <TagLabelsUnstatedWrapper {...props}></TagLabelsUnstatedWrapper>;
-};
-export default withTranslation()(TagLabelsWrapper);

+ 58 - 0
packages/app/src/components/Page/TagLabels.tsx

@@ -0,0 +1,58 @@
+import React, { FC, Suspense, useState } from 'react';
+
+import { withUnstatedContainers } from '../UnstatedUtils';
+import AppContainer from '~/client/services/AppContainer';
+
+import RenderTagLabels from './RenderTagLabels';
+import TagEditModal from './TagEditModal';
+
+type TagLabels = {
+  tags: string[],
+  appContainer: AppContainer,
+  tagsUpdateInvoked?: () => Promise<void>,
+}
+
+
+const TagLabels:FC<TagLabels> = (props:TagLabels) => {
+  const { tags, appContainer, tagsUpdateInvoked } = props;
+
+  const [isTagEditModalShown, setIsTagEditModalShown] = useState(false);
+
+  const openEditorModal = () => {
+    setIsTagEditModalShown(true);
+  };
+
+  const closeEditorModal = () => {
+    setIsTagEditModalShown(false);
+  };
+
+  return (
+    <>
+      <form className="grw-tag-labels form-inline">
+        <i className="tag-icon icon-tag mr-2"></i>
+        <Suspense fallback={<span className="grw-tag-label badge badge-secondary">―</span>}>
+          <RenderTagLabels
+            tags={tags}
+            openEditorModal={openEditorModal}
+            isGuestUser={appContainer.isGuestUser}
+          />
+        </Suspense>
+      </form>
+
+      <TagEditModal
+        tags={tags}
+        isOpen={isTagEditModalShown}
+        onClose={closeEditorModal}
+        onTagsUpdated={tagsUpdateInvoked}
+      />
+
+    </>
+  );
+};
+
+/**
+ * Wrapper component for using unstated
+ */
+const TagLabelsUnstatedWrapper = withUnstatedContainers(TagLabels, [AppContainer]);
+
+export default TagLabelsUnstatedWrapper;

+ 1 - 5
packages/app/src/components/PageAccessories.jsx

@@ -9,7 +9,7 @@ import AppContainer from '~/client/services/AppContainer';
 import PageAccessoriesContainer from '~/client/services/PageAccessoriesContainer';
 
 const PageAccessories = (props) => {
-  const { appContainer, pageAccessoriesContainer, isNotFoundPage } = props;
+  const { appContainer, pageAccessoriesContainer } = props;
   const { isGuestUser, isSharedUser } = appContainer;
 
   return (
@@ -17,12 +17,10 @@ const PageAccessories = (props) => {
       <PageAccessoriesModalControl
         isGuestUser={isGuestUser}
         isSharedUser={isSharedUser}
-        isNotFoundPage={isNotFoundPage}
       />
       <PageAccessoriesModal
         isGuestUser={isGuestUser}
         isSharedUser={isSharedUser}
-        isNotFoundPage={isNotFoundPage}
         isOpen={pageAccessoriesContainer.state.isPageAccessoriesModalShown}
         onClose={pageAccessoriesContainer.closePageAccessoriesModal}
       />
@@ -37,8 +35,6 @@ const PageAccessoriesWrapper = withUnstatedContainers(PageAccessories, [AppConta
 PageAccessories.propTypes = {
   appContainer: PropTypes.instanceOf(AppContainer).isRequired,
   pageAccessoriesContainer: PropTypes.instanceOf(PageAccessoriesContainer).isRequired,
-
-  isNotFoundPage: PropTypes.bool.isRequired,
 };
 
 export default PageAccessoriesWrapper;

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

@@ -13,10 +13,11 @@ import AttachmentIcon from './Icons/AttachmentIcon';
 import ShareLinkIcon from './Icons/ShareLinkIcon';
 
 import { withUnstatedContainers } from './UnstatedUtils';
+import PageContainer from '~/client/services/PageContainer';
 import PageAccessoriesContainer from '~/client/services/PageAccessoriesContainer';
 import PageAttachment from './PageAttachment';
 import PageTimeline from './PageTimeline';
-import PageList from './PageList';
+import DescendantsPageList from './DescendantsPageList';
 import PageHistory from './PageHistory';
 import ShareLink from './ShareLink/ShareLink';
 import { CustomNavTab } from './CustomNavigation/CustomNav';
@@ -24,7 +25,7 @@ import ExpandOrContractButton from './ExpandOrContractButton';
 
 const PageAccessoriesModal = (props) => {
   const {
-    t, pageAccessoriesContainer, onClose, isGuestUser, isSharedUser, isNotFoundPage,
+    t, pageContainer, pageAccessoriesContainer, onClose, isGuestUser, isSharedUser,
   } = props;
   const isLinkSharingDisabled = pageAccessoriesContainer.appContainer.config.disableLinkSharing;
   const { switchActiveTab } = pageAccessoriesContainer;
@@ -49,22 +50,21 @@ const PageAccessoriesModal = (props) => {
         Icon: HistoryIcon,
         i18n: t('History'),
         index: 2,
-        isLinkEnabled: v => !isGuestUser && !isSharedUser && !isNotFoundPage,
+        isLinkEnabled: v => !isGuestUser && !isSharedUser,
       },
       attachment: {
         Icon: AttachmentIcon,
         i18n: t('attachment_data'),
         index: 3,
-        isLinkEnabled: v => !isNotFoundPage,
       },
       shareLink: {
         Icon: ShareLinkIcon,
         i18n: t('share_links.share_link_management'),
         index: 4,
-        isLinkEnabled: v => !isGuestUser && !isSharedUser && !isNotFoundPage && !isLinkSharingDisabled,
+        isLinkEnabled: v => !isGuestUser && !isSharedUser && !isLinkSharingDisabled,
       },
     };
-  }, [t, isGuestUser, isSharedUser, isNotFoundPage, isLinkSharingDisabled]);
+  }, [t, isGuestUser, isSharedUser, isLinkSharingDisabled]);
 
   const closeModalHandler = useCallback(() => {
     if (onClose == null) {
@@ -116,7 +116,7 @@ const PageAccessoriesModal = (props) => {
               the 'navTabMapping[tabId].Content' for PageAccessoriesModal depends on activeComponents */}
           <TabContent activeTab={activeTab}>
             <TabPane tabId="pagelist">
-              {activeComponents.has('pagelist') && <PageList />}
+              {activeComponents.has('pagelist') && <DescendantsPageList path={pageContainer.state.path} />}
             </TabPane>
             <TabPane tabId="timeline">
               {activeComponents.has('timeline') && <PageTimeline /> }
@@ -144,14 +144,15 @@ const PageAccessoriesModal = (props) => {
 /**
  * Wrapper component for using unstated
  */
-const PageAccessoriesModalWrapper = withUnstatedContainers(PageAccessoriesModal, [PageAccessoriesContainer]);
+const PageAccessoriesModalWrapper = withUnstatedContainers(PageAccessoriesModal, [PageContainer, PageAccessoriesContainer]);
 
 PageAccessoriesModal.propTypes = {
   t: PropTypes.func.isRequired, //  i18next
+  pageContainer: PropTypes.instanceOf(PageContainer).isRequired,
   pageAccessoriesContainer: PropTypes.instanceOf(PageAccessoriesContainer).isRequired,
+
   isGuestUser: PropTypes.bool.isRequired,
   isSharedUser: PropTypes.bool.isRequired,
-  isNotFoundPage: PropTypes.bool.isRequired,
   isOpen: PropTypes.bool.isRequired,
   onClose: PropTypes.func,
 };

+ 10 - 8
packages/app/src/components/PageAccessoriesModalControl.jsx

@@ -15,12 +15,16 @@ import SeenUserInfo from './User/SeenUserInfo';
 
 import { withUnstatedContainers } from './UnstatedUtils';
 
+import { useCurrentPageId } from '~/stores/context';
+
 const PageAccessoriesModalControl = (props) => {
   const {
-    t, pageAccessoriesContainer, isGuestUser, isSharedUser, isNotFoundPage,
+    t, pageAccessoriesContainer, isGuestUser, isSharedUser,
   } = props;
   const isLinkSharingDisabled = pageAccessoriesContainer.appContainer.config.disableLinkSharing;
 
+  const { data: pageId } = useCurrentPageId();
+
   const accessoriesBtnList = useMemo(() => {
     return [
       {
@@ -38,23 +42,22 @@ const PageAccessoriesModalControl = (props) => {
       {
         name: 'pageHistory',
         Icon: <HistoryIcon />,
-        disabled: isGuestUser || isSharedUser || isNotFoundPage,
+        disabled: isGuestUser || isSharedUser,
         i18n: t('History'),
       },
       {
         name: 'attachment',
         Icon: <AttachmentIcon />,
-        disabled: isNotFoundPage,
         i18n: t('attachment_data'),
       },
       {
         name: 'shareLink',
         Icon: <ShareLinkIcon />,
-        disabled: isGuestUser || isSharedUser || isNotFoundPage || isLinkSharingDisabled,
+        disabled: isGuestUser || isSharedUser || isLinkSharingDisabled,
         i18n: t('share_links.share_link_management'),
       },
     ];
-  }, [t, isGuestUser, isSharedUser, isNotFoundPage, isLinkSharingDisabled]);
+  }, [t, isGuestUser, isSharedUser, isLinkSharingDisabled]);
 
   return (
     <div className="grw-page-accessories-control d-flex flex-nowrap align-items-center justify-content-end justify-content-lg-between">
@@ -62,7 +65,7 @@ const PageAccessoriesModalControl = (props) => {
 
         let tooltipMessage;
         if (accessory.disabled) {
-          tooltipMessage = isNotFoundPage ? t('not_found_page.page_not_exist') : t('Not available for guest');
+          tooltipMessage = t('Not available for guest');
           if (accessory.name === 'shareLink' && isLinkSharingDisabled) {
             tooltipMessage = t('Link sharing is disabled');
           }
@@ -90,7 +93,7 @@ const PageAccessoriesModalControl = (props) => {
       })}
       <div className="d-flex align-items-center">
         <span className="border-left grw-border-vr">&nbsp;</span>
-        <SeenUserInfo disabled={isSharedUser} />
+        <SeenUserInfo disabled={isSharedUser} pageId={pageId} />
       </div>
     </div>
   );
@@ -107,7 +110,6 @@ PageAccessoriesModalControl.propTypes = {
 
   isGuestUser: PropTypes.bool.isRequired,
   isSharedUser: PropTypes.bool.isRequired,
-  isNotFoundPage: PropTypes.bool.isRequired,
 };
 
 export default withTranslation()(PageAccessoriesModalControlWrapper);

+ 0 - 102
packages/app/src/components/PageList.jsx

@@ -1,102 +0,0 @@
-import React, { useState } from 'react';
-import PropTypes from 'prop-types';
-import { withTranslation } from 'react-i18next';
-
-import Page from './PageList/Page';
-import { withUnstatedContainers } from './UnstatedUtils';
-
-import AppContainer from '~/client/services/AppContainer';
-import PageContainer from '~/client/services/PageContainer';
-
-import { useSWRxPageList } from '~/stores/page';
-
-import PaginationWrapper from './PaginationWrapper';
-
-
-const PageList = (props) => {
-  const { appContainer, pageContainer, t } = props;
-  const { path } = pageContainer.state;
-
-  const [activePage, setActivePage] = useState(1);
-
-  const { data: pagesListData, error: errors } = useSWRxPageList(path, activePage);
-
-  function setPageNumber(selectedPageNumber) {
-    setActivePage(selectedPageNumber);
-  }
-
-  if (errors != null) {
-    return (
-      <div className="my-5">
-        {/* eslint-disable-next-line react/no-array-index-key */}
-        {errors.map((error, index) => <div key={index} className="text-danger">{error.message}</div>)}
-      </div>
-    );
-  }
-
-  if (pagesListData == null) {
-    return (
-      <div className="wiki">
-        <div className="text-muted text-center">
-          <i className="fa fa-2x fa-spinner fa-pulse mr-1"></i>
-        </div>
-      </div>
-    );
-  }
-
-  const liClasses = props.liClasses.join(' ');
-  const pageList = pagesListData.items.map(page => (
-    <li key={page._id} className={liClasses}>
-      <Page page={page} />
-    </li>
-  ));
-  if (pageList.length === 0) {
-    return (
-      <div className="mt-2">
-        {/* eslint-disable-next-line react/no-danger */}
-        <p>{t('custom_navigation.no_page_list')}</p>
-      </div>
-    );
-  }
-  if (appContainer.config.disableLinkSharing) {
-    return (
-      <div className="mt-2">
-        {/* eslint-disable-next-line react/no-danger */}
-        <p>{t('custom_navigation.link_sharing_is_disabled')}</p>
-      </div>
-    );
-  }
-
-  return (
-    <div className="page-list">
-      <ul className="page-list-ul page-list-ul-flat">
-        {pageList}
-      </ul>
-      <PaginationWrapper
-        activePage={activePage}
-        changePage={setPageNumber}
-        totalItemsCount={pagesListData.totalCount}
-        pagingLimit={pagesListData.limit}
-        align="center"
-      />
-    </div>
-  );
-};
-
-const PageListWrapper = withUnstatedContainers(PageList, [AppContainer, PageContainer]);
-
-const PageListTranslation = withTranslation()(PageListWrapper);
-
-
-PageList.propTypes = {
-  t: PropTypes.func.isRequired, // i18next
-  appContainer: PropTypes.instanceOf(AppContainer),
-  pageContainer: PropTypes.instanceOf(PageContainer),
-
-  liClasses: PropTypes.arrayOf(PropTypes.string),
-};
-PageList.defaultProps = {
-  liClasses: ['mb-3'],
-};
-
-export default PageListTranslation;

+ 2 - 2
packages/app/src/components/PageList/BookmarkList.jsx

@@ -10,7 +10,7 @@ import { toastError } from '~/client/util/apiNotification';
 
 import PaginationWrapper from '../PaginationWrapper';
 
-import Page from './Page';
+import PageListItemS from './PageListItemS';
 
 const logger = loggerFactory('growi:BookmarkList');
 
@@ -56,7 +56,7 @@ const BookmarkList = (props) => {
    */
   const generatePageList = pages.map(page => (
     <li key={`my-bookmarks:${page._id}`} className="mt-4">
-      <Page page={page.page} />
+      <PageListItemS page={page.page} />
     </li>
   ));
 

+ 49 - 0
packages/app/src/components/PageList/PageList.tsx

@@ -0,0 +1,49 @@
+import React from 'react';
+import { useTranslation } from 'react-i18next';
+
+import { IPageHasId } from '~/interfaces/page';
+import { IPagingResult } from '~/interfaces/paging-result';
+
+import { PageListItemL } from './PageListItemL';
+
+
+type Props = {
+  pages: IPagingResult<IPageHasId>,
+}
+
+const PageList = (props: Props): JSX.Element => {
+  const { t } = useTranslation();
+  const { pages } = props;
+
+  if (pages == null) {
+    return (
+      <div className="wiki">
+        <div className="text-muted text-center">
+          <i className="fa fa-2x fa-spinner fa-pulse mr-1"></i>
+        </div>
+      </div>
+    );
+  }
+
+  const pageList = pages.items.map(page => (
+    <PageListItemL page={{ pageData: page }} />
+  ));
+
+  if (pageList.length === 0) {
+    return (
+      <div className="mt-2">
+        <p>{t('custom_navigation.no_page_list')}</p>
+      </div>
+    );
+  }
+
+  return (
+    <div className="page-list">
+      <ul className="page-list-ul page-list-ul-flat">
+        {pageList}
+      </ul>
+    </div>
+  );
+};
+
+export default PageList;

+ 35 - 29
packages/app/src/components/Page/PageListItem.tsx → packages/app/src/components/PageList/PageListItemL.tsx

@@ -5,28 +5,29 @@ import Clamp from 'react-multiline-clamp';
 import { UserPicture, PageListMeta, PagePathLabel } from '@growi/ui';
 import { pagePathUtils, DevidedPagePath } from '@growi/core';
 import { useIsDeviceSmallerThanLg } from '~/stores/ui';
+import { IPageWithMeta } from '~/interfaces/page';
+import { IPageSearchMeta, isIPageSearchMeta } from '~/interfaces/search';
 
-import { IPageSearchResultData } from '../../interfaces/search';
 import PageItemControl from '../Common/Dropdown/PageItemControl';
 
 const { isTopPage } = pagePathUtils;
 
 type Props = {
-  page: IPageSearchResultData,
-  isSelected: boolean, // is item selected(focused)
-  isChecked: boolean, // is checkbox of item checked
-  isEnableActions: boolean,
+  page: IPageWithMeta | IPageWithMeta<IPageSearchMeta>,
+  isSelected?: boolean, // is item selected(focused)
+  isChecked?: boolean, // is checkbox of item checked
+  isEnableActions?: boolean,
   shortBody?: string
   showPageUpdatedTime?: boolean, // whether to show page's updated time at the top-right corner of item
   onClickCheckbox?: (pageId: string) => void,
-  onClickSearchResultItem?: (pageId: string) => void,
+  onClickItem?: (pageId: string) => void,
   onClickDeleteButton?: (pageId: string) => void,
 }
 
-const PageListItem: FC<Props> = memo((props:Props) => {
+export const PageListItemL: FC<Props> = memo((props:Props) => {
   const {
     // todo: refactoring variable name to clear what changed
-    page: { pageData, pageMeta }, isSelected, onClickSearchResultItem, onClickCheckbox, isChecked, isEnableActions, shortBody,
+    page: { pageData, pageMeta }, isSelected, onClickItem, onClickCheckbox, isChecked, isEnableActions, shortBody,
     showPageUpdatedTime,
   } = props;
 
@@ -34,19 +35,21 @@ const PageListItem: FC<Props> = memo((props:Props) => {
 
   const pagePath: DevidedPagePath = new DevidedPagePath(pageData.path, true);
 
+  const elasticSearchResult = isIPageSearchMeta(pageMeta) ? pageMeta.elasticSearchResult : null;
+
   const pageTitle = (
     <PagePathLabel
-      path={pageMeta.elasticSearchResult?.highlightedPath || pageData.path}
+      path={elasticSearchResult?.highlightedPath || pageData.path}
       isLatterOnly
-      isPathIncludedHtml={pageMeta.elasticSearchResult?.isHtmlInPath}
+      isPathIncludedHtml={elasticSearchResult?.isHtmlInPath}
     >
     </PagePathLabel>
   );
   const pagePathElem = (
     <PagePathLabel
-      path={pageMeta.elasticSearchResult?.highlightedPath || pageData.path}
+      path={elasticSearchResult?.highlightedPath || pageData.path}
       isFormerOnly
-      isPathIncludedHtml={pageMeta.elasticSearchResult?.isHtmlInPath}
+      isPathIncludedHtml={elasticSearchResult?.isHtmlInPath}
     />
   );
 
@@ -57,23 +60,27 @@ const PageListItem: FC<Props> = memo((props:Props) => {
       return;
     }
 
-    if (onClickSearchResultItem != null) {
-      onClickSearchResultItem(pageData._id);
+    if (onClickItem != null) {
+      onClickItem(pageData._id);
     }
-  }, [isDeviceSmallerThanLg, onClickSearchResultItem, pageData._id]);
+  }, [isDeviceSmallerThanLg, onClickItem, pageData._id]);
+
+  const styleListGroupItem = (!isDeviceSmallerThanLg && onClickCheckbox != null) ? 'list-group-item-action' : '';
+  // background color of list item changes when class "active" exists under 'list-group-item'
+  const styleActive = !isDeviceSmallerThanLg && isSelected ? 'active' : '';
+  const styleBorder = onClickCheckbox != null ? 'border-bottom' : 'list-group-item p-0';
 
-  // background color of list item changes when class "active" exists under 'grw-search-result-item'
-  const responsiveListStyleClass = `${isDeviceSmallerThanLg ? '' : `list-group-item-action ${isSelected ? 'active' : ''}`}`;
   return (
     <li
       key={pageData._id}
-      className={`w-100 grw-search-result-item border-bottom ${responsiveListStyleClass}`}
+      className={`list-group-item p-0 ${styleListGroupItem} ${styleActive} ${styleBorder}}`
+      }
     >
       <div
-        className="h-100 text-break"
+        className="text-break"
         onClick={clickHandler}
       >
-        <div className="d-flex h-100">
+        <div className="d-flex">
           {/* checkbox */}
           {onClickCheckbox != null && (
             <div className="form-check d-flex align-items-center justify-content-center px-md-2 pl-3 pr-2 search-item-checkbox">
@@ -86,7 +93,7 @@ const PageListItem: FC<Props> = memo((props:Props) => {
               />
             </div>
           )}
-          <div className="search-item-text p-md-3 pl-2 py-3 pr-3 flex-grow-1">
+          <div className="flex-grow-1 p-md-3 pl-2 py-3 pr-3">
             {/* page path */}
             <h6 className="mb-1 py-1 d-flex">
               <a className="d-inline-block" href={pagePath.isRoot ? pagePath.latter : pagePath.former}>
@@ -98,7 +105,7 @@ const PageListItem: FC<Props> = memo((props:Props) => {
             <div className="d-flex align-items-center mb-2">
               {/* Picture */}
               <span className="mr-2 d-none d-md-block">
-                <UserPicture user={pageData.lastUpdateUser} size="sm" />
+                <UserPicture user={pageData.lastUpdateUser} size="md" />
               </span>
               {/* page title */}
               <Clamp lines={1}>
@@ -108,8 +115,8 @@ const PageListItem: FC<Props> = memo((props:Props) => {
               </Clamp>
 
               {/* page meta */}
-              <div className="d-none d-md-flex item-meta py-0 px-1">
-                <PageListMeta page={pageData} bookmarkCount={pageMeta.bookmarkCount} />
+              <div className="d-none d-md-flex py-0 px-1">
+                <PageListMeta page={pageData} bookmarkCount={pageMeta?.bookmarkCount} shouldSpaceOutIcon />
               </div>
               {/* doropdown icon includes page control buttons */}
               <div className="item-control ml-auto">
@@ -118,14 +125,15 @@ const PageListItem: FC<Props> = memo((props:Props) => {
                   onClickDeleteButtonHandler={props.onClickDeleteButton}
                   isEnableActions={isEnableActions}
                   isDeletable={!isTopPage(pageData.path)}
+                  // Todo: add onClickRenameButtonHandler
                 />
               </div>
             </div>
-            <div className="grw-search-result-list-snippet py-1">
+            <div className="page-list-snippet py-1">
               <Clamp lines={2}>
                 {
-                  pageMeta.elasticSearchResult != null && pageMeta.elasticSearchResult?.snippet.length !== 0 ? (
-                    <div dangerouslySetInnerHTML={{ __html: pageMeta.elasticSearchResult.snippet }}></div>
+                  elasticSearchResult != null && elasticSearchResult?.snippet.length !== 0 ? (
+                    <div dangerouslySetInnerHTML={{ __html: elasticSearchResult.snippet }}></div>
                   ) : (
                     <div>{ shortBody != null ? shortBody : 'Loading ...' }</div> // TODO: improve indicator
                   )
@@ -139,5 +147,3 @@ const PageListItem: FC<Props> = memo((props:Props) => {
     </li>
   );
 });
-
-export default PageListItem;

+ 3 - 3
packages/app/src/components/PageList/Page.jsx → packages/app/src/components/PageList/PageListItemS.jsx

@@ -4,7 +4,7 @@ import PropTypes from 'prop-types';
 import { UserPicture, PageListMeta, PagePathLabel } from '@growi/ui';
 
 
-export default class Page extends React.Component {
+export default class PageListItemS extends React.Component {
 
   render() {
     const {
@@ -27,11 +27,11 @@ export default class Page extends React.Component {
 
 }
 
-Page.propTypes = {
+PageListItemS.propTypes = {
   page: PropTypes.object.isRequired,
   noLink: PropTypes.bool,
 };
 
-Page.defaultProps = {
+PageListItemS.defaultProps = {
   noLink: false,
 };

+ 2 - 2
packages/app/src/components/RecentCreated/RecentCreated.jsx

@@ -4,7 +4,7 @@ import PropTypes from 'prop-types';
 import { withUnstatedContainers } from '../UnstatedUtils';
 import AppContainer from '~/client/services/AppContainer';
 
-import Page from '../PageList/Page';
+import PageListItemS from '../PageList/PageListItemS';
 import PaginationWrapper from '../PaginationWrapper';
 
 class RecentCreated extends React.Component {
@@ -57,7 +57,7 @@ class RecentCreated extends React.Component {
   generatePageList(pages) {
     return pages.map(page => (
       <li key={`recent-created:list-view:${page._id}`} className="mt-4">
-        <Page page={page} />
+        <PageListItemS page={page} />
       </li>
     ));
   }

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

@@ -4,8 +4,9 @@ import React, {
 } from 'react';
 import { useTranslation } from 'react-i18next';
 
-import { IPageSearchResultData } from '~/interfaces/search';
 import { IFocusable } from '~/client/interfaces/focusable';
+import { IPageWithMeta } from '~/interfaces/page';
+import { IPageSearchMeta } from '~/interfaces/search';
 
 import SearchTypeahead from './SearchTypeahead';
 
@@ -84,7 +85,7 @@ type Props = {
 
   dropup?: boolean,
   keyword?: string,
-  onChange?: (data: IPageSearchResultData[]) => void,
+  onChange?: (data: IPageWithMeta<IPageSearchMeta>[]) => void,
   onBlur?: () => void,
   onFocus?: () => void,
   onSubmit?: (input: string) => void,

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

@@ -327,7 +327,7 @@ class SearchPage extends React.Component {
         shortBodiesMap={this.state.shortBodiesMap}
         activePage={this.state.activePage}
         pagingLimit={this.state.pagingLimit}
-        onClickSearchResultItem={this.selectPage}
+        onClickItem={this.selectPage}
         onClickCheckbox={this.toggleCheckBox}
         onPagingNumberChanged={this.onPagingNumberChanged}
         onClickDeleteButton={this.deleteSinglePageButtonHandler}

+ 3 - 3
packages/app/src/components/SearchPage/SearchPageLayout.tsx

@@ -34,7 +34,7 @@ const SearchPageLayout: FC<Props> = (props: Props) => {
   return (
     <div className="content-main">
       <div className="search-result d-flex" id="search-result">
-        <div className="mw-0 flex-grow-1 flex-basis-0 page-list border boder-gray search-result-list" id="search-result-list">
+        <div className="mw-0 flex-grow-1 flex-basis-0 border boder-gray search-result-list" id="search-result-list">
 
           <SearchControl></SearchControl>
           <div className="search-result-list-scroll">
@@ -62,8 +62,8 @@ const SearchPageLayout: FC<Props> = (props: Props) => {
               </div>
             </div>
 
-            <div className="page-list">
-              <ul className="page-list-ul page-list-ul-flat px-md-4 nav nav-pills"><SearchResultList></SearchResultList></ul>
+            <div className="page-list px-md-4">
+              <SearchResultList></SearchResultList>
             </div>
           </div>
         </div>

+ 3 - 5
packages/app/src/components/SearchPage/SearchResultContent.tsx

@@ -1,18 +1,16 @@
 import React, { FC } from 'react';
 
-import { IPageSearchResultData } from '../../interfaces/search';
+import { IPageWithMeta } from '~/interfaces/page';
+import { IPageSearchMeta } from '~/interfaces/search';
 
 import RevisionLoader from '../Page/RevisionLoader';
 import AppContainer from '../../client/services/AppContainer';
 import SearchResultContentSubNavigation from './SearchResultContentSubNavigation';
 
-// TODO : set focusedPage type to ?IPageSearchResultData once #80214 is merged
-// PR: https://github.com/weseek/growi/pull/4649
-
 type Props ={
   appContainer: AppContainer,
   searchingKeyword:string,
-  focusedSearchResultData : IPageSearchResultData,
+  focusedSearchResultData : IPageWithMeta<IPageSearchMeta>,
 }
 
 

+ 12 - 1
packages/app/src/components/SearchPage/SearchResultContentSubNavigation.tsx

@@ -1,11 +1,16 @@
 import React, { FC } from 'react';
+
 import { pagePathUtils } from '@growi/core';
+
+import { EditorMode, useEditorMode } from '~/stores/ui';
+
 import PagePathNav from '../PagePathNav';
 import { withUnstatedContainers } from '../UnstatedUtils';
 import AppContainer from '../../client/services/AppContainer';
 import { useSWRTagsInfo } from '../../stores/page';
 import SubNavButtons from '../Navbar/SubNavButtons';
 
+
 type Props = {
   appContainer:AppContainer
   pageId: string,
@@ -23,11 +28,16 @@ const SearchResultContentSubNavigation: FC<Props> = (props : Props) => {
 
   const { isTrashPage, isDeletablePage } = pagePathUtils;
 
+  const { data: editorMode } = useEditorMode();
+
   const { data: tagInfoData, error: tagInfoError } = useSWRTagsInfo(pageId);
 
   if (tagInfoError != null || tagInfoData == null) {
     return <></>;
   }
+
+  const isViewMode = editorMode === EditorMode.View;
+
   const isPageDeletable = isDeletablePage(path);
   const { isSharedUser } = appContainer;
   const isAbleToShowPageManagement = !(isTrashPage(path)) && !isSharedUser;
@@ -50,9 +60,10 @@ const SearchResultContentSubNavigation: FC<Props> = (props : Props) => {
             pageId={pageId}
             revisionId={revisionId}
             path={path}
+            isViewMode={isViewMode}
             isDeletable={isPageDeletable}
             isAbleToDeleteCompletely={false}
-            willShowPageManagement={isAbleToShowPageManagement}
+            isAbleToShowPageManagement={isAbleToShowPageManagement}
           >
           </SubNavButtons>
         </div>

+ 11 - 9
packages/app/src/components/SearchPage/SearchResultList.tsx

@@ -1,20 +1,22 @@
 import React, { FC } from 'react';
-import PageListItem from '../Page/PageListItem';
+import { IPageWithMeta } from '~/interfaces/page';
+import { IPageSearchMeta } from '~/interfaces/search';
+
+import { PageListItemL } from '../PageList/PageListItemL';
 import PaginationWrapper from '../PaginationWrapper';
-import { IPageSearchResultData } from '../../interfaces/search';
 
 
 type Props = {
-  pages: IPageSearchResultData[],
+  pages: IPageWithMeta<IPageSearchMeta>[],
   selectedPagesIdList: Set<string>
   isEnableActions: boolean,
   searchResultCount?: number,
   activePage?: number,
   pagingLimit?: number,
   shortBodiesMap?: Record<string, string>
-  focusedSearchResultData?: IPageSearchResultData,
+  focusedSearchResultData?: IPageWithMeta<IPageSearchMeta>,
   onPagingNumberChanged?: (activePage: number) => void,
-  onClickSearchResultItem?: (pageId: string) => void,
+  onClickItem?: (pageId: string) => void,
   onClickCheckbox?: (pageId: string) => void,
   onClickInvoked?: (pageId: string) => void,
   onClickDeleteButton?: (pageId: string) => void,
@@ -27,17 +29,17 @@ const SearchResultList: FC<Props> = (props:Props) => {
 
   const focusedPageId = (focusedSearchResultData != null && focusedSearchResultData.pageData != null) ? focusedSearchResultData.pageData._id : '';
   return (
-    <>
+    <ul className="page-list-ul list-group list-group-flush">
       {Array.isArray(props.pages) && props.pages.map((page) => {
         const isChecked = selectedPagesIdList.has(page.pageData._id);
 
         return (
-          <PageListItem
+          <PageListItemL
             key={page.pageData._id}
             page={page}
             isEnableActions={isEnableActions}
             shortBody={shortBodiesMap?.[page.pageData._id]}
-            onClickSearchResultItem={props.onClickSearchResultItem}
+            onClickItem={props.onClickItem}
             onClickCheckbox={props.onClickCheckbox}
             isChecked={isChecked}
             isSelected={page.pageData._id === focusedPageId || false}
@@ -56,7 +58,7 @@ const SearchResultList: FC<Props> = (props:Props) => {
         </div>
       )}
 
-    </>
+    </ul>
   );
 
 };

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

@@ -10,7 +10,8 @@ import { UserPicture, PageListMeta, PagePathLabel } from '@growi/ui';
 import { IFocusable } from '~/client/interfaces/focusable';
 import { TypeaheadProps } from '~/client/interfaces/react-bootstrap-typeahead';
 import { apiGet } from '~/client/util/apiv1-client';
-import { IPageSearchResultData, IFormattedSearchResult } from '~/interfaces/search';
+import { IFormattedSearchResult, IPageSearchMeta } from '~/interfaces/search';
+import { IPageWithMeta } from '~/interfaces/page';
 
 
 type ResetFormButtonProps = {
@@ -33,7 +34,7 @@ const ResetFormButton: FC<ResetFormButtonProps> = (props: ResetFormButtonProps)
 
 
 type Props = TypeaheadProps & {
-  onSearchSuccess?: (res: IPageSearchResultData[]) => void,
+  onSearchSuccess?: (res: IPageWithMeta<IPageSearchMeta>[]) => void,
   onSearchError?: (err: Error) => void,
   onSubmit?: (input: string) => void,
   inputName?: string,
@@ -60,7 +61,7 @@ const SearchTypeahead: ForwardRefRenderFunction<IFocusable, Props> = (props: Pro
 
   // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
   const [input, setInput] = useState(props.keywordOnInit!);
-  const [pages, setPages] = useState<IPageSearchResultData[]>();
+  const [pages, setPages] = useState<IPageWithMeta<IPageSearchMeta>[]>();
   // eslint-disable-next-line @typescript-eslint/no-unused-vars
   const [searchError, setSearchError] = useState<Error | null>(null);
   const [isLoading, setLoading] = useState(false);
@@ -187,7 +188,7 @@ const SearchTypeahead: ForwardRefRenderFunction<IFocusable, Props> = (props: Pro
     inputProps.name = props.inputName;
   }
 
-  const renderMenuItemChildren = (option: IPageSearchResultData) => {
+  const renderMenuItemChildren = (option: IPageWithMeta<IPageSearchMeta>) => {
     const { pageData } = option;
     return (
       <span>

+ 37 - 19
packages/app/src/components/Sidebar/PageTree/Item.tsx

@@ -16,7 +16,7 @@ import { IPageForPageDeleteModal } from '~/components/PageDeleteModal';
 
 import TriangleIcon from '~/components/Icons/TriangleIcon';
 
-const { isTopPage } = pagePathUtils;
+const { isTopPage, isUserPage } = pagePathUtils;
 
 
 interface ItemProps {
@@ -50,6 +50,7 @@ type ItemControlProps = {
   onClickRenameButton?(): void
 }
 
+
 const ItemControl: FC<ItemControlProps> = memo((props: ItemControlProps) => {
   const onClickPlusButton = () => {
     if (props.onClickPlusButton == null) {
@@ -99,12 +100,16 @@ const ItemControl: FC<ItemControlProps> = memo((props: ItemControlProps) => {
   );
 });
 
-const ItemCount: FC = () => {
+
+type ItemCountProps = {
+  descendantCount: number
+}
+
+const ItemCount: FC<ItemCountProps> = (props:ItemCountProps) => {
   return (
     <>
       <span className="grw-pagetree-count badge badge-pill badge-light text-muted">
-        {/* TODO: consider to show the number of children pages */}
-        00
+        {props.descendantCount}
       </span>
     </>
   );
@@ -125,6 +130,10 @@ const Item: FC<ItemProps> = (props: ItemProps) => {
 
   const { data, error } = useSWRxPageChildren(isOpen ? page._id : null);
 
+  const hasDescendants = (page.descendantCount != null && page?.descendantCount > 0);
+
+  const isDeletable = !page.isEmpty && !isTopPage(page.path as string) && !isUserPage(page.path as string);
+
   const [{ isDragging }, drag] = useDrag(() => ({
     type: 'PAGE_TREE',
     item: { page },
@@ -247,17 +256,21 @@ const Item: FC<ItemProps> = (props: ItemProps) => {
     <div className={`grw-pagetree-item-container ${isOver ? 'grw-pagetree-is-over' : ''}`}>
       <li
         ref={(c) => { drag(c); drop(c) }}
-        className={`list-group-item list-group-item-action border-0 py-1 d-flex align-items-center  ${page.isTarget ? 'grw-pagetree-is-target' : ''}`}
+        className={`list-group-item list-group-item-action border-0 py-1 d-flex align-items-center ${page.isTarget ? 'grw-pagetree-is-target' : ''}`}
       >
-        <button
-          type="button"
-          className={`grw-pagetree-button btn ${isOpen ? 'grw-pagetree-open' : ''}`}
-          onClick={onClickLoadChildren}
-        >
-          <div className="grw-triangle-icon">
-            <TriangleIcon />
-          </div>
-        </button>
+        <div className="grw-triangle-container d-flex justify-content-center">
+          {hasDescendants && (
+            <button
+              type="button"
+              className={`grw-pagetree-button btn ${isOpen ? 'grw-pagetree-open' : ''}`}
+              onClick={onClickLoadChildren}
+            >
+              <div className="grw-triangle-icon d-flex justify-content-center">
+                <TriangleIcon />
+              </div>
+            </button>
+          )}
+        </div>
         { isRenameInputShown && (
           <ClosableTextInput
             isShown
@@ -268,13 +281,18 @@ const Item: FC<ItemProps> = (props: ItemProps) => {
           />
         )}
         { !isRenameInputShown && (
-          <a href={page._id} className="grw-pagetree-title-anchor flex-grow-1">
+          <a
+            href={page._id}
+            className="grw-pagetree-title-anchor flex-grow-1"
+          >
             <p className={`text-truncate m-auto ${page.isEmpty && 'text-muted'}`}>{nodePath.basename(page.path as string) || '/'}</p>
           </a>
         )}
-        <div className="grw-pagetree-count-wrapper">
-          <ItemCount />
-        </div>
+        {(page.descendantCount != null && page.descendantCount > 0) && (
+          <div className="grw-pagetree-count-wrapper">
+            <ItemCount descendantCount={page.descendantCount} />
+          </div>
+        )}
         <div className="grw-pagetree-control d-none">
           <ItemControl
             page={page}
@@ -282,7 +300,7 @@ const Item: FC<ItemProps> = (props: ItemProps) => {
             onClickDeleteButton={onClickDeleteButton}
             onClickRenameButton={onClickRenameButton}
             isEnableActions={isEnableActions}
-            isDeletable={!page.isEmpty && !isTopPage(page.path as string)}
+            isDeletable={isDeletable}
           />
         </div>
       </li>

+ 7 - 10
packages/app/src/components/Sidebar/RecentChanges.tsx

@@ -54,16 +54,13 @@ function LargePageItem({ page }) {
   }
 
   const tags = page.tags;
-  // when tag document is deleted from database directly tags includes null
-  const tagElements = tags.includes(null)
-    ? <></>
-    : tags.map((tag) => {
-      return (
-        <a key={tag.name} href={`/_search?q=tag:${tag.name}`} className="grw-tag-label badge badge-secondary mr-2 small">
-          {tag.name}
-        </a>
-      );
-    });
+  const tagElements = tags.map((tag) => {
+    return (
+      <a key={tag.name} href={`/_search?q=tag:${tag.name}`} className="grw-tag-label badge badge-secondary mr-2 small">
+        {tag.name}
+      </a>
+    );
+  });
 
   return (
     <li className="list-group-item py-3 px-0">

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

@@ -3,7 +3,7 @@ import PropTypes from 'prop-types';
 import { withTranslation } from 'react-i18next';
 import PageListIcon from './Icons/PageListIcon';
 import CustomNavAndContents from './CustomNavigation/CustomNavAndContents';
-import PageList from './PageList';
+import DescendantsPageList from './DescendantsPageList';
 
 
 const TrashPageList = (props) => {
@@ -13,7 +13,7 @@ const TrashPageList = (props) => {
     return {
       pagelist: {
         Icon: PageListIcon,
-        Content: PageList,
+        Content: DescendantsPageList,
         i18n: t('page_list'),
         index: 0,
       },

+ 0 - 51
packages/app/src/components/User/SeenUserInfo.jsx

@@ -1,51 +0,0 @@
-// import React from 'react';
-import PropTypes from 'prop-types';
-
-import React, { useState } from 'react';
-import {
-  Button, Popover, PopoverBody,
-} from 'reactstrap';
-import { FootstampIcon } from '@growi/ui';
-import UserPictureList from './UserPictureList';
-
-import { withUnstatedContainers } from '../UnstatedUtils';
-
-import PageContainer from '~/client/services/PageContainer';
-
-
-/* eslint react/no-multi-comp: 0, react/prop-types: 0 */
-
-const SeenUserInfo = (props) => {
-  const [popoverOpen, setPopoverOpen] = useState(false);
-  const toggle = () => setPopoverOpen(!popoverOpen);
-  const { pageContainer, disabled } = props;
-  return (
-    <div className="grw-seen-user-info">
-      <Button id="po-seen-user" color="link" className="px-2">
-        <span className="mr-1 footstamp-icon">
-          <FootstampIcon />
-        </span>
-        <span className="seen-user-count">{pageContainer.state.sumOfSeenUsers}</span>
-      </Button>
-      <Popover placement="bottom" isOpen={popoverOpen} target="po-seen-user" toggle={toggle} trigger="legacy" disabled={disabled}>
-        <PopoverBody className="seen-user-popover">
-          <div className="px-2 text-right user-list-content text-truncate text-muted">
-            <UserPictureList users={pageContainer.state.seenUsers} />
-          </div>
-        </PopoverBody>
-      </Popover>
-    </div>
-  );
-};
-
-SeenUserInfo.propTypes = {
-  pageContainer: PropTypes.instanceOf(PageContainer).isRequired,
-  disabled: PropTypes.bool,
-};
-
-/**
- * Wrapper component for using unstated
- */
-const SeenUserInfoWrapper = withUnstatedContainers(SeenUserInfo, [PageContainer]);
-
-export default (SeenUserInfoWrapper);

+ 49 - 0
packages/app/src/components/User/SeenUserInfo.tsx

@@ -0,0 +1,49 @@
+import React, { FC, useState } from 'react';
+
+import { Button, Popover, PopoverBody } from 'reactstrap';
+import { FootstampIcon } from '@growi/ui';
+
+import UserPictureList from './UserPictureList';
+import { useSWRxPageInfo } from '~/stores/page';
+import { useSWRxUsersList } from '~/stores/user';
+
+interface Props {
+  pageId: string,
+  disabled: boolean
+}
+
+const SeenUserInfo: FC<Props> = (props: Props) => {
+  const { pageId, disabled } = props;
+
+  const [isPopoverOpen, setIsPopoverOpen] = useState(false);
+
+  const { data: pageInfo } = useSWRxPageInfo(pageId);
+  const likerIds = pageInfo?.likerIds != null ? pageInfo.likerIds.slice(0, 15) : [];
+  const seenUserIds = pageInfo?.seenUserIds != null ? pageInfo.seenUserIds.slice(0, 15) : [];
+
+  // Put in a mixture of seenUserIds and likerIds data to make the cache work
+  const { data: usersList } = useSWRxUsersList([...likerIds, ...seenUserIds]);
+  const seenUsers = usersList != null ? usersList.filter(({ _id }) => seenUserIds.includes(_id)).slice(0, 15) : [];
+
+  const togglePopover = () => setIsPopoverOpen(!isPopoverOpen);
+
+  return (
+    <div className="grw-seen-user-info">
+      <Button id="po-seen-user" color="link" className="px-2">
+        <span className="mr-1 footstamp-icon">
+          <FootstampIcon />
+        </span>
+        <span className="seen-user-count">{seenUsers.length}</span>
+      </Button>
+      <Popover placement="bottom" isOpen={isPopoverOpen} target="po-seen-user" toggle={togglePopover} trigger="legacy" disabled={disabled}>
+        <PopoverBody className="seen-user-popover">
+          <div className="px-2 text-right user-list-content text-truncate text-muted">
+            <UserPictureList users={seenUsers} />
+          </div>
+        </PopoverBody>
+      </Popover>
+    </div>
+  );
+};
+
+export default SeenUserInfo;

+ 7 - 0
packages/app/src/interfaces/lang.ts

@@ -0,0 +1,7 @@
+export const Lang = {
+  en_US: 'en_US',
+  ja_JP: 'ja_JP',
+  zh_CN: 'zh_CN',
+} as const;
+export const AllLang = Object.values(Lang);
+export type Lang = typeof Lang[keyof typeof Lang];

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

@@ -4,7 +4,6 @@ import { IRevision } from './revision';
 import { ITag } from './tag';
 import { HasObjectId } from './has-object-id';
 
-
 export type IPage = {
   path: string,
   status: string,
@@ -35,3 +34,18 @@ export type IPage = {
 export type IPageHasId = IPage & HasObjectId;
 
 export type IPageForItem = Partial<IPageHasId & {isTarget?: boolean}>;
+
+export type IPageInfo = {
+  bookmarkCount: number,
+  sumOfLikers: number,
+  likerIds: string[],
+  sumOfSeenUsers: number,
+  seenUserIds: string[],
+  isSeen?: boolean,
+  isLiked?: boolean,
+}
+
+export type IPageWithMeta<M = Record<string, unknown>> = {
+  pageData: IPageHasId,
+  pageMeta?: Partial<IPageInfo> & M,
+};

+ 11 - 11
packages/app/src/interfaces/search.ts

@@ -1,4 +1,4 @@
-import { IPageHasId } from './page';
+import { IPageWithMeta } from './page';
 
 export enum CheckboxType {
   NONE_CHECKED = 'noneChecked',
@@ -6,20 +6,20 @@ export enum CheckboxType {
   ALL_CHECKED = 'allChecked',
 }
 
-export type IPageSearchResultData = {
-  pageData: IPageHasId;
-  pageMeta: {
-    bookmarkCount?: number;
-    elasticSearchResult?: {
-      snippet: string;
-      highlightedPath: string;
-      isHtmlInPath: boolean;
-    };
+export type IPageSearchMeta = {
+  elasticSearchResult?: {
+    snippet: string;
+    highlightedPath: string;
+    isHtmlInPath: boolean;
   };
+}
+
+export const isIPageSearchMeta = (meta: any): meta is IPageSearchMeta => {
+  return !!(meta as IPageSearchMeta)?.elasticSearchResult;
 };
 
 export type IFormattedSearchResult = {
-  data: IPageSearchResultData[]
+  data: IPageWithMeta<IPageSearchMeta>[]
 
   totalCount: number
 

+ 2 - 0
packages/app/src/interfaces/user.ts

@@ -4,6 +4,8 @@ import { HasObjectId } from './has-object-id';
 export type IUser = {
   name: string;
   username: string;
+  email: string;
+  password: string;
   imageUrlCached: string;
   admin: boolean;
 }

+ 31 - 41
packages/app/src/server/crowi/index.js

@@ -23,7 +23,7 @@ import AttachmentService from '../service/attachment';
 import PageGrantService from '../service/page-grant';
 import { SlackIntegrationService } from '../service/slack-integration';
 import { UserNotificationService } from '../service/user-notification';
-
+import { InstallerService } from '../service/installer';
 import Activity from '../models/activity';
 import UserGroup from '../models/user-group';
 
@@ -142,47 +142,8 @@ Crowi.prototype.init = async function() {
     this.setUpGlobalNotification(),
     this.setUpUserNotification(),
   ]);
-};
-
-Crowi.prototype.initForTest = async function() {
-  await this.setupModels();
-  await this.setupConfigManager();
-
-  // setup messaging services
-  await this.setupSocketIoService();
-
-  // // customizeService depends on AppService and XssService
-  // // passportService depends on appService
-  await Promise.all([
-    this.setUpApp(),
-    this.setUpXss(),
-    // this.setUpGrowiBridge(),
-  ]);
-
-  await Promise.all([
-    // this.scanRuntimeVersions(),
-    this.setupPassport(),
-    // this.setupSearcher(),
-    // this.setupMailer(),
-    // this.setupSlackIntegrationService(),
-    // this.setupCsrf(),
-    // this.setUpFileUpload(),
-    this.setupAttachmentService(),
-    this.setUpAcl(),
-    // this.setUpCustomize(),
-    // this.setUpRestQiitaAPI(),
-    // this.setupUserGroup(),
-    // this.setupExport(),
-    // this.setupImport(),
-    this.setupPageService(),
-    this.setupInAppNotificationService(),
-    this.setupActivityService(),
-  ]);
 
-  // globalNotification depends on slack and mailer
-  // await Promise.all([
-  //   this.setUpGlobalNotification(),
-  // ]);
+  await this.autoInstall();
 };
 
 Crowi.prototype.isPageId = function(pageId) {
@@ -416,6 +377,35 @@ Crowi.prototype.setupCsrf = async function() {
   return Promise.resolve();
 };
 
+Crowi.prototype.autoInstall = function() {
+  const isInstalled = this.configManager.getConfig('crowi', 'app:installed');
+  const username = this.configManager.getConfig('crowi', 'autoInstall:adminUsername');
+
+  if (isInstalled || username == null) {
+    return;
+  }
+
+  logger.info('Start automatic installation');
+
+  const firstAdminUserToSave = {
+    username,
+    name: this.configManager.getConfig('crowi', 'autoInstall:adminName'),
+    email: this.configManager.getConfig('crowi', 'autoInstall:adminEmail'),
+    password: this.configManager.getConfig('crowi', 'autoInstall:adminPassword'),
+    admin: true,
+  };
+  const globalLang = this.configManager.getConfig('crowi', 'autoInstall:globalLang');
+
+  const installerService = new InstallerService(this);
+
+  try {
+    installerService.install(firstAdminUserToSave, globalLang ?? 'en_US');
+  }
+  catch (err) {
+    logger.warn('Automatic installation failed.', err);
+  }
+};
+
 Crowi.prototype.getTokens = function() {
   return this.tokens;
 };

+ 0 - 7
packages/app/src/server/form/admin/userGroupCreate.js

@@ -1,7 +0,0 @@
-const form = require('express-form');
-
-const field = form.field;
-
-module.exports = form(
-  field('createGroupForm[userGroupName]', 'Group name').trim().required(),
-);

+ 0 - 8
packages/app/src/server/form/index.js

@@ -1,8 +0,0 @@
-module.exports = {
-  login: require('./login'),
-  register: require('./register'),
-  invited: require('./invited'),
-  admin: {
-    userGroupCreate: require('./admin/userGroupCreate'),
-  },
-};

+ 0 - 9
packages/app/src/server/form/invited.js

@@ -1,9 +0,0 @@
-const form = require('express-form');
-
-const field = form.field;
-
-module.exports = form(
-  field('invitedForm.username').required().is(/^[\da-zA-Z\-_.]+$/),
-  field('invitedForm.name').required(),
-  field('invitedForm.password').required().is(/^[\x20-\x7F]*$/).minLength(6),
-);

+ 0 - 8
packages/app/src/server/form/login.js

@@ -1,8 +0,0 @@
-const form = require('express-form');
-
-const field = form.field;
-
-module.exports = form(
-  field('loginForm.username').required(),
-  field('loginForm.password').required(),
-);

+ 0 - 11
packages/app/src/server/form/register.js

@@ -1,11 +0,0 @@
-const form = require('express-form');
-
-const field = form.field;
-
-module.exports = form(
-  field('registerForm.username').required().is(/^[\da-zA-Z\-_.]+$/),
-  field('registerForm.name').required(),
-  field('registerForm.email').required(),
-  field('registerForm.password').required().is(/^[\x20-\x7F]*$/).minLength(6),
-  field('registerForm[app:globalLang]'),
-);

+ 85 - 0
packages/app/src/server/middlewares/login-form-validator.ts

@@ -0,0 +1,85 @@
+import { body, validationResult } from 'express-validator';
+
+// form rules
+export const inviteRules = () => {
+  return [
+    body('invitedForm.username')
+      .matches(/^[\da-zA-Z\-_.]+$/)
+      .withMessage('Username has invalid characters')
+      .not()
+      .isEmpty()
+      .withMessage('Username field is required'),
+    body('invitedForm.name').not().isEmpty().withMessage('Name field is required'),
+    body('invitedForm.password')
+      .matches(/^[\x20-\x7F]*$/)
+      .withMessage('Password has invalid character')
+      .isLength({ min: 6 })
+      .withMessage('Password minimum character should be more than 6 characters')
+      .not()
+      .isEmpty()
+      .withMessage('Password field is required'),
+  ];
+};
+
+// validation action
+export const inviteValidation = (req, res, next) => {
+  const form = req.body;
+
+  const errors = validationResult(req);
+  if (errors.isEmpty()) {
+    Object.assign(form, { isValid: true });
+    req.form = form;
+    return next();
+  }
+
+  const extractedErrors: string[] = [];
+  errors.array().map(err => extractedErrors.push(err.msg));
+
+  req.flash('errorMessages', extractedErrors);
+
+  Object.assign(form, { isValid: false });
+  req.form = form;
+
+  return next();
+};
+
+// form rules
+export const loginRules = () => {
+  return [
+    body('loginForm.username')
+      .matches(/^[\da-zA-Z\-_.@]+$/)
+      .withMessage('Username has invalid characters')
+      .not()
+      .isEmpty()
+      .withMessage('Username field is required'),
+    body('loginForm.password')
+      .matches(/^[\x20-\x7F]*$/)
+      .withMessage('Password has invalid character')
+      .isLength({ min: 6 })
+      .withMessage('Password minimum character should be more than 6 characters')
+      .not()
+      .isEmpty()
+      .withMessage('Password field is required'),
+  ];
+};
+
+// validation action
+export const loginValidation = (req, res, next) => {
+  const form = req.body;
+
+  const errors = validationResult(req);
+  if (errors.isEmpty()) {
+    Object.assign(form, { isValid: true });
+    req.form = form;
+    return next();
+  }
+
+  const extractedErrors: string[] = [];
+  errors.array().map(err => extractedErrors.push(err.msg));
+
+  req.flash('errorMessages', extractedErrors);
+  Object.assign(form, { isValid: false });
+  req.form = form;
+
+  return next();
+};

+ 51 - 0
packages/app/src/server/middlewares/register-form-validator.ts

@@ -0,0 +1,51 @@
+import { body, validationResult } from 'express-validator';
+
+// form rules
+export const registerRules = () => {
+  return [
+    body('registerForm.username')
+      .matches(/^[\da-zA-Z\-_.]+$/)
+      .withMessage('Username has invalid characters')
+      .not()
+      .isEmpty()
+      .withMessage('Username field is required'),
+    body('registerForm.name').not().isEmpty().withMessage('Name field is required'),
+    body('registerForm.email')
+      .isEmail()
+      .withMessage('Email format is invalid.')
+      .exists()
+      .withMessage('Email field is required.'),
+    body('registerForm.password')
+      .matches(/^[\x20-\x7F]*$/)
+      .withMessage('Password has invalid character')
+      .isLength({ min: 6 })
+      .withMessage('Password minimum character should be more than 6 characters')
+      .not()
+      .isEmpty()
+      .withMessage('Password field is required'),
+    body('registerForm[app:globalLang]'),
+  ];
+};
+
+// validation action
+export const registerValidation = (req, res, next) => {
+  const form = req.body;
+
+  const errors = validationResult(req);
+  if (errors.isEmpty()) {
+    Object.assign(form, { isValid: true });
+    req.form = form;
+    return next();
+  }
+
+  const extractedErrors: string[] = [];
+  errors.array().map(err => extractedErrors.push(err.msg));
+
+  Object.assign(form, {
+    isValid: false,
+    errors: extractedErrors,
+  });
+  req.form = form;
+
+  return next();
+};

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

@@ -218,6 +218,7 @@ schema.statics.findByPathAndViewer = async function(
 
   const baseQuery = useFindOne ? this.findOne({ path }) : this.find({ path });
   const queryBuilder = new PageQueryBuilder(baseQuery, includeEmpty);
+
   await addViewerCondition(queryBuilder, user, userGroups);
 
   return queryBuilder.query.exec();

+ 0 - 15
packages/app/src/server/models/user.js

@@ -187,21 +187,6 @@ module.exports = function(crowi) {
     return userData;
   };
 
-  userSchema.methods.canDeleteCompletely = function(creatorId) {
-    const pageCompleteDeletionAuthority = crowi.configManager.getConfig('crowi', 'security:pageCompleteDeletionAuthority');
-    if (this.admin) {
-      return true;
-    }
-    if (pageCompleteDeletionAuthority === 'anyOne' || pageCompleteDeletionAuthority == null) {
-      return true;
-    }
-    if (pageCompleteDeletionAuthority === 'adminAndAuthor') {
-      return (this._id.equals(creatorId));
-    }
-
-    return false;
-  };
-
   userSchema.methods.updateApiToken = async function() {
     const self = this;
 

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

@@ -23,8 +23,8 @@ module.exports = (crowi) => {
   const validator = {
     password: [
       body('newPassword').isString().not().isEmpty()
-        .isLength({ min: 6 })
-        .withMessage('password must be at least 6 characters long'),
+        .isLength({ min: 8 })
+        .withMessage('password must be at least 8 characters long'),
       // checking if password confirmation matches password
       body('newPasswordConfirm').isString().not().isEmpty()
         .custom((value, { req }) => {
@@ -35,7 +35,7 @@ module.exports = (crowi) => {
 
   const apiLimiter = rateLimit({
     windowMs: 15 * 60 * 1000, // 15 minutes
-    max: 5, // limit each IP to 5 requests per windowMs
+    max: 10, // limit each IP to 10 requests per windowMs
     message:
       'Too many requests were sent from this IP. Please try a password reset request again on the password reset request form',
   });
@@ -81,7 +81,7 @@ module.exports = (crowi) => {
     }
   });
 
-  router.put('/', injectResetOrderByTokenMiddleware, async(req, res) => {
+  router.put('/', apiLimiter, injectResetOrderByTokenMiddleware, csrf, validator.password, apiV3FormValidator, async(req, res) => {
     const { passwordResetOrder } = req;
     const { email } = passwordResetOrder;
     const grobalLang = configManager.getConfig('crowi', 'app:globalLang');

+ 3 - 1
packages/app/src/server/routes/apiv3/pages.js

@@ -385,7 +385,9 @@ module.exports = (crowi) => {
         if (!relationsMap.has(pageId)) {
           relationsMap.set(pageId, []);
         }
-        relationsMap.get(pageId).push(relation.relatedTag);
+        if (relation.relatedTag != null) {
+          relationsMap.get(pageId).push(relation.relatedTag);
+        }
       });
       // add tags to each page
       result.pages.forEach((page) => {

+ 2 - 2
packages/app/src/server/routes/apiv3/personal-setting.js

@@ -86,8 +86,8 @@ module.exports = (crowi) => {
     password: [
       body('oldPassword').isString(),
       body('newPassword').isString().not().isEmpty()
-        .isLength({ min: 6 })
-        .withMessage('password must be at least 6 characters long'),
+        .isLength({ min: 8 })
+        .withMessage('password must be at least 8 characters long'),
       body('newPasswordConfirm').isString().not().isEmpty()
         .custom((value, { req }) => {
           return (value === req.body.newPassword);

+ 64 - 1
packages/app/src/server/routes/apiv3/users.js

@@ -743,7 +743,7 @@ module.exports = (crowi) => {
   router.put('/update.imageUrlCache', loginRequiredStrictly, adminRequired, csrf, async(req, res) => {
     try {
       const userIds = req.body.userIds;
-      const users = await User.find({ _id: { $in: userIds } });
+      const users = await User.find({ _id: { $in: userIds }, imageUrlCached: null });
       const requests = await Promise.all(users.map(async(user) => {
         return {
           updateOne: {
@@ -862,5 +862,68 @@ module.exports = (crowi) => {
     }
   });
 
+  /**
+   * @swagger
+   *
+   *    paths:
+   *      /users/list:
+   *        get:
+   *          tags: [Users]
+   *          summary: /users/list
+   *          operationId: getUsersList
+   *          description: Get list of users
+   *          parameters:
+   *            - in: query
+   *              name: userIds
+   *              schema:
+   *                type: string
+   *                description: user IDs
+   *                example: 5e06fcc7516d64004dbf4da6,5e098d53baa2ac004e7d24ad
+   *          responses:
+   *            200:
+   *              description: Succeeded to get list of users.
+   *              content:
+   *                application/json:
+   *                  schema:
+   *                    properties:
+   *                      users:
+   *                        type: array
+   *                        items:
+   *                          $ref: '#/components/schemas/User'
+   *                        description: user list
+   *            403:
+   *              $ref: '#/components/responses/403'
+   *            500:
+   *              $ref: '#/components/responses/500'
+   */
+  router.get('/list', accessTokenParser, loginRequired, async(req, res) => {
+    const userIds = req.query.userIds || null;
+
+    let userFetcher;
+    if (!userIds || userIds.split(',').length <= 0) {
+      userFetcher = User.findAllUsers();
+    }
+    else {
+      userFetcher = User.findUsersByIds(userIds.split(','));
+    }
+
+    const data = {};
+    try {
+      const users = await userFetcher;
+      data.users = users.map((user) => {
+        // omit email
+        if (user.isEmailPublished !== true) { // compare to 'true' because Crowi original data doesn't have 'isEmailPublished'
+          user.email = undefined;
+        }
+        return user.toObject({ virtuals: true });
+      });
+    }
+    catch (err) {
+      return res.apiv3Err(new ErrorV3(err));
+    }
+
+    return res.apiv3(data);
+  });
+
   return router;
 };

+ 9 - 8
packages/app/src/server/routes/index.js

@@ -3,6 +3,9 @@ import express from 'express';
 import injectResetOrderByTokenMiddleware from '../middlewares/inject-reset-order-by-token-middleware';
 import injectUserRegistrationOrderByTokenMiddleware from '../middlewares/inject-user-registration-order-by-token-middleware';
 
+import * as loginFormValidator from '../middlewares/login-form-validator';
+import * as registerFormValidator from '../middlewares/register-form-validator';
+
 import * as forgotPassword from './forgot-password';
 import * as privateLegacyPages from './private-legacy-pages';
 import * as allInAppNotifications from './all-in-app-notifications';
@@ -14,7 +17,7 @@ const rateLimit = require('express-rate-limit');
 
 const apiLimiter = rateLimit({
   windowMs: 15 * 60 * 1000, // 15 minutes
-  max: 5, // limit each IP to 5 requests per windowMs
+  max: 10, // limit each IP to 10 requests per windowMs
   message:
     'Too many requests sent from this IP, please try again after 15 minutes',
 });
@@ -34,7 +37,6 @@ module.exports = function(crowi, app) {
   const injectUserUISettings = require('../middlewares/inject-user-ui-settings-to-localvars')();
 
   const uploads = multer({ dest: `${crowi.tmpDir}uploads` });
-  const form = require('../form');
   const page = require('./page')(crowi, app);
   const login = require('./login')(crowi, app);
   const loginPassport = require('./login-passport')(crowi, app);
@@ -61,10 +63,10 @@ module.exports = function(crowi, app) {
   app.get('/login/error/:reason'      , applicationInstalled, login.error);
   app.get('/login'                    , applicationInstalled, login.preLogin, login.login);
   app.get('/login/invited'            , applicationInstalled, login.invited);
-  app.post('/login/activateInvited'   , applicationInstalled, form.invited                         , csrf, login.invited);
-  app.post('/login'                   , applicationInstalled, form.login                           , csrf, loginPassport.loginWithLocal, loginPassport.loginWithLdap, loginPassport.loginFailure);
+  app.post('/login/activateInvited'   , apiLimiter , applicationInstalled, loginFormValidator.inviteRules(), loginFormValidator.inviteValidation, csrf, login.invited);
+  app.post('/login'                   , apiLimiter , applicationInstalled, loginFormValidator.loginRules(), loginFormValidator.loginValidation, csrf, loginPassport.loginWithLocal, loginPassport.loginWithLdap, loginPassport.loginFailure);
 
-  app.post('/register'                , applicationInstalled, form.register                        , csrf, login.register);
+  app.post('/register'                , apiLimiter , applicationInstalled, registerFormValidator.registerRules(), registerFormValidator.registerValidation, csrf, login.register);
   app.get('/register'                 , applicationInstalled, login.preLogin, login.register);
   app.get('/logout'                   , applicationInstalled, logout.logout);
 
@@ -75,7 +77,7 @@ module.exports = function(crowi, app) {
   if (!isInstalled) {
     const installer = require('./installer')(crowi);
     app.get('/installer'              , applicationNotInstalled , installer.index);
-    app.post('/installer'             , applicationNotInstalled , form.register , csrf, installer.install);
+    app.post('/installer'             , apiLimiter , applicationNotInstalled , registerFormValidator.registerRules(), registerFormValidator.registerValidation, csrf, installer.install);
     return;
   }
 
@@ -92,7 +94,7 @@ module.exports = function(crowi, app) {
   app.get('/passport/oidc/callback'               , loginPassport.loginPassportOidcCallback     , loginPassport.loginFailure);
   app.post('/passport/saml/callback'              , loginPassport.loginPassportSamlCallback     , loginPassport.loginFailure);
 
-  app.post('/_api/login/testLdap'    , loginRequiredStrictly , form.login , loginPassport.testLdapCredentials);
+  app.post('/_api/login/testLdap'    , apiLimiter , loginRequiredStrictly , loginFormValidator.loginRules() , loginFormValidator.loginValidation , loginPassport.testLdapCredentials);
 
   // security admin
   app.get('/admin/security'          , loginRequiredStrictly , adminRequired , admin.security.index);
@@ -158,7 +160,6 @@ module.exports = function(crowi, app) {
   app.get('/_api/me/user-group-relations'  , accessTokenParser , loginRequiredStrictly , me.api.userGroupRelations);
 
   // HTTP RPC Styled API (に徐々に移行していいこうと思う)
-  app.get('/_api/users.list'          , accessTokenParser , loginRequired , user.api.list);
   app.get('/_api/pages.list'          , accessTokenParser , loginRequired , page.api.list);
   app.post('/_api/pages.update'       , accessTokenParser , loginRequiredStrictly , csrf, page.api.update);
   app.get('/_api/pages.exist'         , accessTokenParser , loginRequired , page.api.exist);

+ 14 - 66
packages/app/src/server/routes/installer.js

@@ -1,56 +1,13 @@
 import loggerFactory from '~/utils/logger';
 
-module.exports = function(crowi) {
-  const logger = loggerFactory('growi:routes:installer');
-  const path = require('path');
-  const fs = require('graceful-fs');
+import { InstallerService, FailedToCreateAdminUserError } from '../service/installer';
 
-  const models = crowi.models;
-  const { appService } = crowi;
+const logger = loggerFactory('growi:routes:installer');
 
-  const User = models.User;
-  const Page = models.Page;
+module.exports = function(crowi) {
 
   const actions = {};
 
-  async function initSearchIndex() {
-    const { searchService } = crowi;
-    if (!searchService.isReachable) {
-      return;
-    }
-
-    await searchService.rebuildIndex();
-  }
-
-  async function createPage(filePath, pagePath, owner, lang) {
-    try {
-      const markdown = fs.readFileSync(filePath);
-      return Page.create(pagePath, markdown, owner, {});
-    }
-    catch (err) {
-      logger.error(`Failed to create ${pagePath}`, err);
-    }
-  }
-
-  async function createInitialPages(owner, lang) {
-    /*
-     * Keep in this order to avoid creating the same pages
-     */
-    await createPage(path.join(crowi.localeDir, lang, 'sandbox.md'), '/Sandbox', owner, lang);
-    await Promise.all([
-      createPage(path.join(crowi.localeDir, lang, 'sandbox-diagrams.md'), '/Sandbox/Diagrams', owner, lang),
-      createPage(path.join(crowi.localeDir, lang, 'sandbox-bootstrap4.md'), '/Sandbox/Bootstrap4', owner, lang),
-      createPage(path.join(crowi.localeDir, lang, 'sandbox-math.md'), '/Sandbox/Math', owner, lang),
-    ]);
-
-    try {
-      await initSearchIndex();
-    }
-    catch (err) {
-      logger.error('Failed to build Elasticsearch Indices', err);
-    }
-  }
-
   actions.index = function(req, res) {
     return res.render('installer');
   };
@@ -68,35 +25,26 @@ module.exports = function(crowi) {
     const password = registerForm.password;
     const language = registerForm['app:globalLang'] || 'en_US';
 
-    await appService.initDB(language);
-
-    // create the root page before creating admin user
-    await createPage(path.join(crowi.localeDir, language, 'welcome.md'), '/', { _id: '000000000000000000000000' }, language); // use 0 as a mock user id
+    const installerService = new InstallerService(crowi);
 
-    // create first admin user
-    // TODO: with transaction
     let adminUser;
     try {
-      adminUser = await User.createUser(name, username, email, password, language);
-      await adminUser.asyncMakeAdmin();
+      adminUser = await installerService.install({
+        name,
+        username,
+        email,
+        password,
+      }, language);
     }
     catch (err) {
-      req.form.errors.push(req.t('message.failed_to_create_admin_user', { errMessage: err.message }));
+      if (err instanceof FailedToCreateAdminUserError) {
+        req.form.errors.push(req.t('message.failed_to_create_admin_user', { errMessage: err.message }));
+      }
       return res.render('installer');
     }
-    // add owner after creating admin user
-    const Revision = crowi.model('Revision');
-    const rootPage = await Page.findOne({ path: '/' });
-    const rootRevision = await Revision.findOne({ path: '/' });
-    rootPage.creator = adminUser;
-    rootRevision.creator = adminUser;
-    await Promise.all([rootPage.save(), rootRevision.save()]);
-
-    // create initial pages
-    await createInitialPages(adminUser, language);
 
+    const appService = crowi.appService;
     appService.setupAfterInstall();
-    appService.publishPostInstallationMessage();
 
     // login with passport
     req.logIn(adminUser, (err) => {

+ 28 - 8
packages/app/src/server/routes/page.js

@@ -142,6 +142,7 @@ module.exports = function(crowi, app) {
 
   const Page = crowi.model('Page');
   const User = crowi.model('User');
+  const Bookmark = crowi.model('Bookmark');
   const PageTagRelation = crowi.model('PageTagRelation');
   const GlobalNotificationSetting = crowi.model('GlobalNotificationSetting');
   const ShareLink = crowi.model('ShareLink');
@@ -282,8 +283,23 @@ module.exports = function(crowi, app) {
     renderVars.notFoundTargetPathOrId = pathOrId;
   }
 
-  function addRenderVarsWhenNotCreatableOrForbidden(renderVars) {
-    renderVars.isAlertHidden = true;
+  async function addRenderVarsForIdenticalPage(renderVars, pages) {
+    const pageIds = pages.map(p => p._id);
+    const shortBodyMap = await crowi.pageService.shortBodiesMapByPageIds(pageIds);
+
+    const identicalPageDataList = await Promise.all(pages.map(async(page) => {
+      const bookmarkCount = await Bookmark.countByPageId(page._id);
+      page._doc.seenUserCount = (page.seenUsers && page.seenUsers.length) || 0;
+      return {
+        pageData: page,
+        pageMeta: {
+          bookmarkCount,
+        },
+      };
+    }));
+
+    renderVars.identicalPageDataList = identicalPageDataList;
+    renderVars.shortBodyMap = shortBodyMap;
   }
 
   function replacePlaceholdersOfTemplate(template, req) {
@@ -309,11 +325,9 @@ module.exports = function(crowi, app) {
     const renderVars = { path };
 
     if (!isCreatablePage(path)) {
-      addRenderVarsWhenNotCreatableOrForbidden(renderVars);
       view = 'layout-growi/not_creatable';
     }
     else if (req.isForbidden) {
-      addRenderVarsWhenNotCreatableOrForbidden(renderVars);
       view = 'layout-growi/forbidden';
     }
     else {
@@ -507,7 +521,6 @@ module.exports = function(crowi, app) {
       return res.render('layout-growi/not_found_shared_page');
     }
     if (crowi.configManager.getConfig('crowi', 'security:disableLinkSharing')) {
-      addRenderVarsWhenNotCreatableOrForbidden(renderVars);
       return res.render('layout-growi/forbidden');
     }
 
@@ -613,8 +626,15 @@ module.exports = function(crowi, app) {
     const { redirectFrom } = req.query;
 
     if (pages.length >= 2) {
-      return res.render('layout-growi/identical-path-page-list', {
-        pages, redirectFrom,
+
+      const renderVars = {};
+
+      await addRenderVarsForIdenticalPage(renderVars, pages);
+
+      return res.render('layout-growi/identical-path-page', {
+        ...renderVars,
+        redirectFrom,
+        path,
       });
     }
 
@@ -1172,7 +1192,7 @@ module.exports = function(crowi, app) {
 
     try {
       if (isCompletely) {
-        if (!req.user.canDeleteCompletely(page.creator)) {
+        if (!crowi.pageService.canDeleteCompletely(page.creator, req.user)) {
           return res.json(ApiResponse.error('You can not delete completely', 'user_not_admin'));
         }
         await crowi.pageService.deleteCompletely(page, req.user, options, isRecursively);

+ 0 - 71
packages/app/src/server/routes/user.js

@@ -75,76 +75,5 @@ module.exports = function(crowi, app) {
     return res.json(ApiResponse.success({ valid }));
   };
 
-  /**
-   * @swagger
-   *
-   *    /users.list:
-   *      get:
-   *        tags: [Users, CrowiCompatibles]
-   *        operationId: listUsersV1
-   *        summary: /users.list
-   *        description: Get list of users
-   *        parameters:
-   *          - in: query
-   *            name: user_ids
-   *            schema:
-   *              type: string
-   *              description: user IDs
-   *              example: 5e06fcc7516d64004dbf4da6,5e098d53baa2ac004e7d24ad
-   *        responses:
-   *          200:
-   *            description: Succeeded to get list of users.
-   *            content:
-   *              application/json:
-   *                schema:
-   *                  properties:
-   *                    ok:
-   *                      $ref: '#/components/schemas/V1Response/properties/ok'
-   *                    users:
-   *                      type: array
-   *                      items:
-   *                        $ref: '#/components/schemas/User'
-   *                      description: user list
-   *          403:
-   *            $ref: '#/components/responses/403'
-   *          500:
-   *            $ref: '#/components/responses/500'
-   */
-  /**
-   * @api {get} /users.list Get user list
-   * @apiName GetUserList
-   * @apiGroup User
-   *
-   * @apiParam {String} user_ids
-   */
-  api.list = async function(req, res) {
-    const userIds = req.query.user_ids || null; // TODO: handling
-
-    let userFetcher;
-    if (!userIds || userIds.split(',').length <= 0) {
-      userFetcher = User.findAllUsers();
-    }
-    else {
-      userFetcher = User.findUsersByIds(userIds.split(','));
-    }
-
-    const data = {};
-    try {
-      const users = await userFetcher;
-      data.users = users.map((user) => {
-        // omit email
-        if (user.isEmailPublished !== true) { // compare to 'true' because Crowi original data doesn't have 'isEmailPublished'
-          user.email = undefined;
-        }
-        return user.toObject({ virtuals: true });
-      });
-    }
-    catch (err) {
-      return res.json(ApiResponse.error(err));
-    }
-
-    return res.json(ApiResponse.success(data));
-  };
-
   return actions;
 };

+ 7 - 18
packages/app/src/server/service/app.ts

@@ -2,8 +2,6 @@ import { pathUtils } from '@growi/core';
 
 import loggerFactory from '~/utils/logger';
 
-import { generateConfigsForInstalling } from '../models/config';
-
 import S2sMessage from '../models/vo/s2s-message';
 import { S2sMessageHandlable } from './s2s-messaging/handlable';
 import { S2sMessagingService } from './s2s-messaging/base';
@@ -49,6 +47,12 @@ export default class AppService implements S2sMessageHandlable {
     const isDBInitialized = await this.isDBInitialized(true);
     if (isDBInitialized) {
       this.setupAfterInstall();
+
+      // remove message handler
+      const { s2sMessagingService } = this;
+      if (s2sMessagingService != null) {
+        this.s2sMessagingService.removeMessageHandler(this);
+      }
     }
   }
 
@@ -101,15 +105,6 @@ export default class AppService implements S2sMessageHandlable {
     return this.configManager.getConfig('crowi', 'app:confidential');
   }
 
-  /**
-   * Execute only once for installing application
-   */
-  async initDB(globalLang) {
-    const initialConfig = generateConfigsForInstalling();
-    initialConfig['app:globalLang'] = globalLang;
-    await this.configManager.updateConfigsInTheSameNamespace('crowi', initialConfig, true);
-  }
-
   async isDBInitialized(forceReload) {
     if (forceReload) {
       // load configs
@@ -118,16 +113,10 @@ export default class AppService implements S2sMessageHandlable {
     return this.configManager.getConfigFromDB('crowi', 'app:installed');
   }
 
-  async setupAfterInstall() {
+  async setupAfterInstall(): Promise<void> {
     await this.crowi.pluginService.autoDetectAndLoadPlugins();
     this.crowi.setupRoutesAtLast();
     this.crowi.setupGlobalErrorHandlers();
-
-    // remove message handler
-    const { s2sMessagingService } = this;
-    if (s2sMessagingService != null) {
-      this.s2sMessagingService.removeMessageHandler(this);
-    }
   }
 
 }

+ 61 - 1
packages/app/src/server/service/config-loader.ts

@@ -178,6 +178,36 @@ const ENV_VAR_NAME_TO_CONFIG_INFO = {
     type:    ValueType.BOOLEAN,
     default: undefined,
   },
+  AUTO_INSTALL_ADMIN_USERNAME: {
+    ns:      'crowi',
+    key:     'autoInstall:adminUsername',
+    type:    ValueType.STRING,
+    default: null,
+  },
+  AUTO_INSTALL_ADMIN_NAME: {
+    ns:      'crowi',
+    key:     'autoInstall:adminName',
+    type:    ValueType.STRING,
+    default: null,
+  },
+  AUTO_INSTALL_ADMIN_EMAIL: {
+    ns:      'crowi',
+    key:     'autoInstall:adminEmail',
+    type:    ValueType.STRING,
+    default: null,
+  },
+  AUTO_INSTALL_ADMIN_PASSWORD: {
+    ns:      'crowi',
+    key:     'autoInstall:adminPassword',
+    type:    ValueType.STRING,
+    default: null,
+  },
+  AUTO_INSTALL_GLOBAL_LANG: {
+    ns:      'crowi',
+    key:     'autoInstall:globalLang',
+    type:    ValueType.STRING,
+    default: null,
+  },
   S2SMSG_PUBSUB_SERVER_TYPE: {
     ns:      'crowi',
     key:     's2sMessagingPubsub:serverType',
@@ -262,6 +292,30 @@ const ENV_VAR_NAME_TO_CONFIG_INFO = {
     type:    ValueType.NUMBER,
     default: 8000, // msec
   },
+  SEARCHBOX_SSL_URL: {
+    ns:      'crowi',
+    key:     'app:searchboxSslUrl',
+    type:    ValueType.STRING,
+    default: null,
+  },
+  ELASTICSEARCH_REJECT_UNAUTHORIZED: {
+    ns:      'crowi',
+    key:     'app:elasticsearchRejectUnauthorized',
+    type:    ValueType.BOOLEAN,
+    default: false,
+  },
+  ELASTICSEARCH_REINDEX_ON_BOOT: {
+    ns:      'crowi',
+    key:     'app:elasticsearchReindexOnBoot',
+    type:    ValueType.BOOLEAN,
+    default: false,
+  },
+  USE_ELASTICSEARCH_V6: {
+    ns:      'crowi',
+    key:     'app:useElasticsearchV6',
+    type:    ValueType.BOOLEAN,
+    default: false,
+  },
   MONGO_GRIDFS_TOTAL_LIMIT: {
     ns:      'crowi',
     key:     'gridfs:totalLimit',
@@ -407,7 +461,13 @@ const ENV_VAR_NAME_TO_CONFIG_INFO = {
     ns: 'crowi',
     key: 'security:passport-oidc:oidcClientClockTolerance',
     type: ValueType.NUMBER,
-    default: 10,
+    default: 60,
+  },
+  OIDC_ISSUER_TIMEOUT_OPTION: {
+    ns: 'crowi',
+    key: 'security:passport-oidc:oidcIssuerTimeoutOption',
+    type: ValueType.NUMBER,
+    default: 5000,
   },
   S3_REFERENCE_FILE_WITH_RELAY_MODE: {
     ns:      'crowi',

+ 127 - 0
packages/app/src/server/service/installer.ts

@@ -0,0 +1,127 @@
+import mongoose from 'mongoose';
+import fs from 'graceful-fs';
+import path from 'path';
+import ExtensibleCustomError from 'extensible-custom-error';
+
+import { IPage } from '~/interfaces/page';
+import { IUser } from '~/interfaces/user';
+import { Lang } from '~/interfaces/lang';
+import loggerFactory from '~/utils/logger';
+
+import { generateConfigsForInstalling } from '../models/config';
+
+import SearchService from './search';
+import ConfigManager from './config-manager';
+
+const logger = loggerFactory('growi:service:installer');
+
+export class FailedToCreateAdminUserError extends ExtensibleCustomError {
+}
+
+export class InstallerService {
+
+  // eslint-disable-next-line @typescript-eslint/no-explicit-any
+  crowi: any;
+
+  // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/explicit-module-boundary-types
+  constructor(crowi: any) {
+    this.crowi = crowi;
+  }
+
+  private async initSearchIndex() {
+    const searchService: SearchService = this.crowi.searchService;
+    if (!searchService.isReachable) {
+      return;
+    }
+
+    await searchService.rebuildIndex();
+  }
+
+  private async createPage(filePath, pagePath, owner): Promise<IPage|undefined> {
+
+    // TODO typescriptize models/user.js and remove eslint-disable-next-line
+    // eslint-disable-next-line @typescript-eslint/no-explicit-any
+    const Page = mongoose.model('Page') as any;
+
+    try {
+      const markdown = fs.readFileSync(filePath);
+      return Page.create(pagePath, markdown, owner, {}) as IPage;
+    }
+    catch (err) {
+      logger.error(`Failed to create ${pagePath}`, err);
+    }
+  }
+
+  // eslint-disable-next-line @typescript-eslint/no-explicit-any
+  private async createInitialPages(owner, lang: Lang): Promise<any> {
+    const { localeDir } = this.crowi;
+    // create /Sandbox/*
+    /*
+     * Keep in this order to avoid creating the same pages
+     */
+    await this.createPage(path.join(localeDir, lang, 'sandbox.md'), '/Sandbox', owner);
+    await Promise.all([
+      this.createPage(path.join(localeDir, lang, 'sandbox-diagrams.md'), '/Sandbox/Diagrams', owner),
+      this.createPage(path.join(localeDir, lang, 'sandbox-bootstrap4.md'), '/Sandbox/Bootstrap4', owner),
+      this.createPage(path.join(localeDir, lang, 'sandbox-math.md'), '/Sandbox/Math', owner),
+    ]);
+
+    try {
+      await this.initSearchIndex();
+    }
+    catch (err) {
+      logger.error('Failed to build Elasticsearch Indices', err);
+    }
+  }
+
+  /**
+   * Execute only once for installing application
+   */
+  private async initDB(globalLang: Lang): Promise<void> {
+    const configManager: ConfigManager = this.crowi.configManager;
+
+    const initialConfig = generateConfigsForInstalling();
+    initialConfig['app:globalLang'] = globalLang;
+    return configManager.updateConfigsInTheSameNamespace('crowi', initialConfig, true);
+  }
+
+  async install(firstAdminUserToSave: IUser, globalLang: Lang): Promise<IUser> {
+    await this.initDB(globalLang);
+
+    // TODO typescriptize models/user.js and remove eslint-disable-next-line
+    // eslint-disable-next-line @typescript-eslint/no-explicit-any
+    const User = mongoose.model('User') as any;
+    const Page = mongoose.model('Page') as any;
+
+    // create portal page for '/' before creating admin user
+    await this.createPage(path.join(this.crowi.localeDir, globalLang, 'welcome.md'), '/', { _id: '000000000000000000000000' }); // use 0 as a mock user id
+
+    // create first admin user
+    // TODO: with transaction
+    let adminUser;
+    try {
+      const {
+        name, username, email, password,
+      } = firstAdminUserToSave;
+      adminUser = await User.createUser(name, username, email, password, globalLang);
+      await adminUser.asyncMakeAdmin();
+    }
+    catch (err) {
+      throw new FailedToCreateAdminUserError(err);
+    }
+
+    // add owner after creating admin user
+    const Revision = this.crowi.model('Revision');
+    const rootPage = await Page.findOne({ path: '/' });
+    const rootRevision = await Revision.findOne({ path: '/' });
+    rootPage.creator = adminUser._id;
+    rootRevision.creator = adminUser._id;
+    await Promise.all([rootPage.save(), rootRevision.save()]);
+
+    // create initial pages
+    await this.createInitialPages(adminUser, globalLang);
+
+    return adminUser;
+  }
+
+}

+ 17 - 1
packages/app/src/server/service/page.js

@@ -107,6 +107,22 @@ class PageService {
     });
   }
 
+  canDeleteCompletely(creatorId, operator) {
+    const pageCompleteDeletionAuthority = this.crowi.configManager.getConfig('crowi', 'security:pageCompleteDeletionAuthority');
+    if (operator.admin) {
+      return true;
+    }
+    if (pageCompleteDeletionAuthority === 'anyOne' || pageCompleteDeletionAuthority == null) {
+      return true;
+    }
+    if (pageCompleteDeletionAuthority === 'adminAndAuthor') {
+      const operatorId = operator?._id;
+      return (operatorId != null && operatorId.equals(creatorId));
+    }
+
+    return false;
+  }
+
   async findPageAndMetaDataByViewer({ pageId, path, user }) {
 
     const Page = this.crowi.model('Page');
@@ -139,7 +155,7 @@ class PageService {
     result.isCreatable = false;
     result.isDeletable = isDeletablePage(path);
     result.isDeleted = page.isDeleted();
-    result.canDeleteCompletely = user != null && user.canDeleteCompletely(page.creator);
+    result.canDeleteCompletely = user != null && this.canDeleteCompletely(page.creator, user);
 
     return result;
   }

+ 30 - 5
packages/app/src/server/service/passport.ts

@@ -12,7 +12,7 @@ import { Profile, Strategy as SamlStrategy, VerifiedCallback } from 'passport-sa
 import { BasicStrategy } from 'passport-http';
 
 import { IncomingMessage } from 'http';
-import got from 'got';
+import axiosRetry from 'axios-retry';
 import pRetry from 'p-retry';
 import loggerFactory from '~/utils/logger';
 
@@ -622,7 +622,8 @@ class PassportService implements S2sMessageHandlable {
 
     // setup client
     // extend oidc request timeouts
-    OIDCIssuer.defaultHttpOptions = { timeout: 5000 };
+    const OIDC_ISSUER_TIMEOUT_OPTION = await this.crowi.configManager.getConfig('crowi', 'security:passport-oidc:oidcIssuerTimeoutOption');
+    OIDCIssuer.defaultHttpOptions = { timeout: OIDC_ISSUER_TIMEOUT_OPTION };
     const issuerHost = configManager.getConfig('crowi', 'security:passport-oidc:issuerHost');
     const clientId = configManager.getConfig('crowi', 'security:passport-oidc:clientId');
     const clientSecret = configManager.getConfig('crowi', 'security:passport-oidc:clientSecret');
@@ -710,6 +711,20 @@ class PassportService implements S2sMessageHandlable {
     this.isOidcStrategySetup = false;
   }
 
+  /**
+   * Sanitize issuer Host / URL to match specified format
+   * Acceptable format : eg. https://hostname.com
+   * @param issuerHost string
+   * @returns string URL.origin
+   */
+  getOIDCIssuerHostName(issuerHost) {
+    const protocol = 'https://';
+    const pattern = /^https?:\/\//i;
+    // Set protocol if not available on url
+    const absUrl = !pattern.test(issuerHost) ? `${protocol}${issuerHost}` : issuerHost;
+    return new URL(absUrl).origin;
+  }
+
   /**
  *
  * Check and initialize connection to OIDC issuer host
@@ -720,8 +735,18 @@ class PassportService implements S2sMessageHandlable {
  */
   async isOidcHostReachable(issuerHost) {
     try {
-      const response = await got(issuerHost, { retry: { limit: 3 } });
-      return response.statusCode === 200;
+      const hostname = this.getOIDCIssuerHostName(issuerHost);
+      const client = require('axios').default;
+      axiosRetry(client, {
+        retries: 3,
+      });
+      const response = await client.get(`${hostname}/.well-known/openid-configuration`);
+      // Check for valid OIDC Issuer configuration
+      if (!response.data.issuer) {
+        logger.debug('OidcStrategy: Invalid OIDC Issuer configurations');
+        return false;
+      }
+      return true;
     }
     catch (err) {
       logger.error('OidcStrategy: issuer host unreachable:', err.code);
@@ -740,7 +765,7 @@ class PassportService implements S2sMessageHandlable {
     const OIDC_DISCOVERY_RETRIES = await this.crowi.configManager.getConfig('crowi', 'security:passport-oidc:discoveryRetries');
     const oidcIssuerHostReady = await this.isOidcHostReachable(issuerHost);
     if (!oidcIssuerHostReady) {
-      logger.error('OidcStrategy: setup failed: OIDC Issur host unreachable');
+      logger.error('OidcStrategy: setup failed');
       return;
     }
     const oidcIssuer = await pRetry(async() => {

+ 113 - 0
packages/app/src/server/service/search-delegator/elasticsearch-client-types.ts

@@ -0,0 +1,113 @@
+/* eslint-disable camelcase */
+export type NodesInfoResponse = {
+  nodes: Record<
+    string,
+    {
+      version: string
+      plugins: Plugin[]
+    }
+  >
+}
+
+export type CatIndicesResponse = {
+  index: string
+}[]
+
+export type IndicesExistsResponse = boolean
+
+export type IndicesExistsAliasResponse = boolean
+
+export type CatAliasesResponse = {
+  alias: string
+  index: string
+  filter: string
+}[]
+
+export type BulkResponse = {
+  took: number
+  errors: boolean
+  items: Record<string, any>[]
+}
+
+export type SearchResponse = {
+  took: number
+  timed_out: boolean
+  _shards: {
+    total: number
+    successful: number
+    skipped: number
+    failed: number
+  }
+  hits: {
+    total: number | {
+      value: number
+      relation: string
+    } // 6.x.x | 7.x.x
+    max_score: number | null
+    hits: Record<string, {
+      _index: string
+      _type: string
+      _id: string
+      _score: number
+      _source: any
+    }>[]
+  }
+}
+
+export type ValidateQueryResponse = {
+  valid: boolean,
+  _shards: {
+    total: number,
+    successful: number,
+    failed: number
+  },
+  explanations: Record<string, any>[]
+}
+
+export type ClusterHealthResponse = {
+  cluster_name: string,
+  status: string,
+  timed_out: boolean,
+  number_of_nodes: number,
+  number_of_data_nodes: number,
+  active_primary_shards: number,
+  active_shards: number,
+  relocating_shards: number,
+  initializing_shards: number,
+  unassigned_shards: number,
+  delayed_unassigned_shards: number,
+  number_of_pending_tasks: number,
+  number_of_in_flight_fetch: number,
+  task_max_waiting_in_queue_millis: number,
+  active_shards_percent_as_number: number
+}
+
+export type IndicesStatsResponse = {
+  _shards: {
+    total: number,
+    successful: number,
+    failed: number
+  },
+  _all: {
+    primaries: any,
+    total: any
+  },
+  indices: any
+}
+
+export type ReindexResponse = {
+  took: number,
+  timed_out: boolean,
+  total: number,
+  updated: number,
+  created: number,
+  deleted: number,
+  batches: number,
+  noops: number,
+  version_conflicts: number,
+  retries: number,
+  throttled_millis: number,
+  requests_per_second: number,
+  throttled_until_millis: number,
+  failures: any | null
+}

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