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

Merge branch 'feat/page-rename-v5' into feat/migrate-redirect-to-to-page-redirect

Taichi Masuyama 4 лет назад
Родитель
Сommit
a4554e746c
100 измененных файлов с 1810 добавлено и 1123 удалено
  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. 2 3
      packages/app/src/client/services/AdminAppContainer.js
  31. 11 6
      packages/app/src/client/services/ContextExtractor.tsx
  32. 0 81
      packages/app/src/client/services/PageContainer.js
  33. 2 2
      packages/app/src/components/Admin/App/V5PageMigration.tsx
  34. 3 3
      packages/app/src/components/Admin/Security/SecuritySetting.jsx
  35. 2 2
      packages/app/src/components/Admin/UserGroupDetail/UserGroupPageList.jsx
  36. 1 1
      packages/app/src/components/Common/Dropdown/PageItemControl.tsx
  37. 56 0
      packages/app/src/components/DescendantsPageList.tsx
  38. 0 26
      packages/app/src/components/DuplicatePage.tsx
  39. 20 17
      packages/app/src/components/ForbiddenPage.tsx
  40. 92 6
      packages/app/src/components/IdenticalPathPage.tsx
  41. 14 4
      packages/app/src/components/InstallerForm.jsx
  42. 0 1
      packages/app/src/components/LikeButtons.tsx
  43. 14 14
      packages/app/src/components/LoginForm.jsx
  44. 4 3
      packages/app/src/components/Navbar/GlobalSearch.tsx
  45. 36 35
      packages/app/src/components/Navbar/GrowiSubNavigation.jsx
  46. 14 17
      packages/app/src/components/Navbar/SubNavButtons.tsx
  47. 8 12
      packages/app/src/components/NotFoundPage.tsx
  48. 12 10
      packages/app/src/components/Page/DisplaySwitcher.jsx
  49. 12 12
      packages/app/src/components/Page/NotFoundAlert.tsx
  50. 13 17
      packages/app/src/components/Page/RenderTagLabels.tsx
  51. 2 1
      packages/app/src/components/Page/RevisionRenderer.jsx
  52. 0 113
      packages/app/src/components/Page/TagLabels.jsx
  53. 58 0
      packages/app/src/components/Page/TagLabels.tsx
  54. 1 5
      packages/app/src/components/PageAccessories.jsx
  55. 10 9
      packages/app/src/components/PageAccessoriesModal.jsx
  56. 10 8
      packages/app/src/components/PageAccessoriesModalControl.jsx
  57. 1 1
      packages/app/src/components/PageHistory/RevisionDiff.jsx
  58. 0 102
      packages/app/src/components/PageList.jsx
  59. 2 2
      packages/app/src/components/PageList/BookmarkList.jsx
  60. 49 0
      packages/app/src/components/PageList/PageList.tsx
  61. 36 31
      packages/app/src/components/PageList/PageListItemL.tsx
  62. 3 3
      packages/app/src/components/PageList/PageListItemS.jsx
  63. 2 2
      packages/app/src/components/RecentCreated/RecentCreated.jsx
  64. 3 2
      packages/app/src/components/SearchForm.tsx
  65. 1 1
      packages/app/src/components/SearchPage.jsx
  66. 3 3
      packages/app/src/components/SearchPage/SearchPageLayout.tsx
  67. 3 5
      packages/app/src/components/SearchPage/SearchResultContent.tsx
  68. 12 1
      packages/app/src/components/SearchPage/SearchResultContentSubNavigation.tsx
  69. 11 9
      packages/app/src/components/SearchPage/SearchResultList.tsx
  70. 5 4
      packages/app/src/components/SearchTypeahead.tsx
  71. 33 18
      packages/app/src/components/Sidebar/PageTree/Item.tsx
  72. 7 10
      packages/app/src/components/Sidebar/RecentChanges.tsx
  73. 2 2
      packages/app/src/components/TrashPageList.jsx
  74. 0 51
      packages/app/src/components/User/SeenUserInfo.jsx
  75. 49 0
      packages/app/src/components/User/SeenUserInfo.tsx
  76. 7 0
      packages/app/src/interfaces/lang.ts
  77. 15 0
      packages/app/src/interfaces/page.ts
  78. 11 11
      packages/app/src/interfaces/search.ts
  79. 2 0
      packages/app/src/interfaces/user.ts
  80. 31 41
      packages/app/src/server/crowi/index.js
  81. 0 7
      packages/app/src/server/form/admin/userGroupCreate.js
  82. 0 8
      packages/app/src/server/form/index.js
  83. 0 9
      packages/app/src/server/form/invited.js
  84. 0 8
      packages/app/src/server/form/login.js
  85. 0 11
      packages/app/src/server/form/register.js
  86. 85 0
      packages/app/src/server/middlewares/login-form-validator.ts
  87. 51 0
      packages/app/src/server/middlewares/register-form-validator.ts
  88. 2 4
      packages/app/src/server/models/obsolete-page.js
  89. 5 6
      packages/app/src/server/models/page.ts
  90. 0 7
      packages/app/src/server/models/revision.js
  91. 0 15
      packages/app/src/server/models/user.js
  92. 4 4
      packages/app/src/server/routes/apiv3/forgot-password.js
  93. 30 20
      packages/app/src/server/routes/apiv3/pages.js
  94. 2 2
      packages/app/src/server/routes/apiv3/personal-setting.js
  95. 1 1
      packages/app/src/server/routes/apiv3/revisions.js
  96. 64 1
      packages/app/src/server/routes/apiv3/users.js
  97. 9 8
      packages/app/src/server/routes/index.js
  98. 14 66
      packages/app/src/server/routes/installer.js
  99. 40 16
      packages/app/src/server/routes/page.js
  100. 0 71
      packages/app/src/server/routes/user.js

+ 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",
@@ -208,7 +209,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,
   );
 }
 

+ 2 - 3
packages/app/src/client/services/AdminAppContainer.js

@@ -452,10 +452,9 @@ export default class AdminAppContainer extends Container {
   /**
    * Start v5 page migration
    * @memberOf AdminAppContainer
-   * @property action takes only 'initialMigration' for now. 'initialMigration' will start or resume migration
    */
-  async v5PageMigrationHandler(action) {
-    const response = await this.appContainer.apiv3.post('/pages/v5-schema-migration', { action });
+  async v5PageMigrationHandler() {
+    const response = await this.appContainer.apiv3.post('/pages/v5-schema-migration');
     const { isV5Compatible } = response.data;
     return { isV5Compatible };
   }

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

+ 2 - 2
packages/app/src/components/Admin/App/V5PageMigration.tsx

@@ -6,7 +6,7 @@ import { withUnstatedContainers } from '../../UnstatedUtils';
 import { toastSuccess, toastError } from '../../../client/util/apiNotification';
 
 type Props = {
-  adminAppContainer: typeof AdminAppContainer & { v5PageMigrationHandler: (action: string) => Promise<{ isV5Compatible: boolean }> },
+  adminAppContainer: typeof AdminAppContainer & { v5PageMigrationHandler: () => Promise<{ isV5Compatible: boolean }> },
 }
 
 const V5PageMigration: FC<Props> = (props: Props) => {
@@ -17,7 +17,7 @@ const V5PageMigration: FC<Props> = (props: Props) => {
   const onConfirm = async() => {
     setIsV5PageMigrationModalShown(false);
     try {
-      const { isV5Compatible } = await adminAppContainer.v5PageMigrationHandler('initialMigration');
+      const { isV5Compatible } = await adminAppContainer.v5PageMigrationHandler();
       if (isV5Compatible) {
 
         return toastSuccess(t('admin:v5_page_migration.already_upgraded'));

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

+ 1 - 1
packages/app/src/components/PageHistory/RevisionDiff.jsx

@@ -29,7 +29,7 @@ class RevisionDiff extends React.Component {
       }
 
       const patch = createPatch(
-        currentRevision.path,
+        currentRevision.pageId, // currentRevision.path is DEPRECATED
         previousText,
         currentRevision.body,
       );

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

+ 36 - 31
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;
+const { isTopPage, isUserNamePage } = 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">
@@ -117,15 +124,15 @@ const PageListItem: FC<Props> = memo((props:Props) => {
                   page={pageData}
                   onClickDeleteButtonHandler={props.onClickDeleteButton}
                   isEnableActions={isEnableActions}
-                  isDeletable={!isTopPage(pageData.path)}
+                  isDeletable={!isTopPage(pageData.path) && !isUserNamePage(pageData.path)}
                 />
               </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 +146,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>

+ 33 - 18
packages/app/src/components/Sidebar/PageTree/Item.tsx

@@ -17,7 +17,7 @@ import { apiv3Put } from '~/client/util/apiv3-client';
 
 import TriangleIcon from '~/components/Icons/TriangleIcon';
 
-const { isTopPage } = pagePathUtils;
+const { isTopPage, isUserNamePage } = pagePathUtils;
 
 
 interface ItemProps {
@@ -51,6 +51,7 @@ type ItemControlProps = {
   onClickRenameButton?(): void
 }
 
+
 const ItemControl: FC<ItemControlProps> = memo((props: ItemControlProps) => {
   const onClickPlusButton = () => {
     if (props.onClickPlusButton == null) {
@@ -100,12 +101,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>
     </>
   );
@@ -127,6 +132,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) && !isUserNamePage(page.path as string);
+
   const [{ isDragging }, drag] = useDrag(() => ({
     type: 'PAGE_TREE',
     item: { page },
@@ -272,17 +281,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
@@ -298,9 +311,11 @@ const Item: FC<ItemProps> = (props: ItemProps) => {
             <p className={`text-truncate m-auto ${page.isEmpty && 'text-muted'}`}>{nodePath.basename(pageTitle 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}
@@ -308,7 +323,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 - 0
packages/app/src/interfaces/page.ts

@@ -34,3 +34,18 @@ export interface 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

@@ -24,7 +24,7 @@ import PageService from '../service/page';
 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';
 import PageRedirect from '../models/page-redirect';
@@ -144,47 +144,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) {
@@ -419,6 +380,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();
+};

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

@@ -1020,8 +1020,7 @@ export const getPageSchema = (crowi) => {
 
     let savedPage = await page.save();
     const newRevision = Revision.prepareRevision(savedPage, body, null, user, { format });
-    const revision = await pushRevision(savedPage, newRevision, user);
-    savedPage = await this.findByPath(revision.path);
+    savedPage = await pushRevision(savedPage, newRevision, user);
     await savedPage.populateDataToShowRevision();
 
     pageEvent.emit('create', savedPage, user);
@@ -1043,8 +1042,7 @@ export const getPageSchema = (crowi) => {
     // update existing page
     let savedPage = await pageData.save();
     const newRevision = await Revision.prepareRevision(pageData, body, previousBody, user);
-    const revision = await pushRevision(savedPage, newRevision, user);
-    savedPage = await this.findByPath(revision.path);
+    savedPage = await pushRevision(savedPage, newRevision, user);
     await savedPage.populateDataToShowRevision();
 
     if (isSyncRevisionToHackmd) {

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

@@ -165,7 +165,7 @@ schema.statics.createEmptyPage = async function(
  * @param exPage a page document to be replaced
  * @returns Promise<void>
  */
-schema.statics.replaceTargetWithEmptyPage = async function(exPage): Promise<void> {
+schema.statics.replaceTargetWithPage = async function(exPage, pageToReplaceWith?): Promise<void> {
   // find parent
   const parent = await this.findOne({ _id: exPage.parent });
   if (parent == null) {
@@ -173,7 +173,7 @@ schema.statics.replaceTargetWithEmptyPage = async function(exPage): Promise<void
   }
 
   // create empty page at path
-  const newTarget = await this.createEmptyPage(exPage.path, parent);
+  const newTarget = pageToReplaceWith == null ? await this.createEmptyPage(exPage.path, parent) : pageToReplaceWith;
 
   // find children by ex-page _id
   const children = await this.find({ parent: exPage._id });
@@ -279,6 +279,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();
@@ -612,8 +613,7 @@ export default (crowi: Crowi): any => {
     }
 
     const newRevision = Revision.prepareRevision(savedPage, body, null, user, { format });
-    const revision = await pushRevision(savedPage, newRevision, user);
-    savedPage = await this.findByPath(revision.path);
+    savedPage = await pushRevision(savedPage, newRevision, user);
     await savedPage.populateDataToShowRevision();
 
     pageEvent.emit('create', savedPage, user);
@@ -668,8 +668,7 @@ export default (crowi: Crowi): any => {
     // update existing page
     let savedPage = await newPageData.save();
     const newRevision = await Revision.prepareRevision(newPageData, body, previousBody, user);
-    const revision = await pushRevision(savedPage, newRevision, user);
-    savedPage = await this.findByPath(revision.path);
+    savedPage = await pushRevision(savedPage, newRevision, user);
     await savedPage.populateDataToShowRevision();
 
     if (isSyncRevisionToHackmd) {

+ 0 - 7
packages/app/src/server/models/revision.js

@@ -30,13 +30,6 @@ module.exports = function(crowi) {
   });
   revisionSchema.plugin(mongoosePaginate);
 
-  revisionSchema.statics.findRevisionIdList = function(path) {
-    return this.find({ path })
-      .select('_id author createdAt hasDiffToPrev')
-      .sort({ createdAt: -1 })
-      .exec();
-  };
-
   revisionSchema.statics.updateRevisionListByPageId = async function(pageId, updateData) {
     return this.updateMany({ pageId }, { $set: updateData });
   };

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

+ 30 - 20
packages/app/src/server/routes/apiv3/pages.js

@@ -175,14 +175,14 @@ module.exports = (crowi) => {
       body('isRenameRedirect').if(value => value != null).isBoolean().withMessage('isRenameRedirect must be boolean'),
       body('isRemainMetadata').if(value => value != null).isBoolean().withMessage('isRemainMetadata must be boolean'),
     ],
-
     duplicatePage: [
       body('pageId').isMongoId().withMessage('pageId is required'),
       body('pageNameInput').trim().isLength({ min: 1 }).withMessage('pageNameInput is required'),
       body('isRecursively').if(value => value != null).isBoolean().withMessage('isRecursively must be boolean'),
     ],
-    v5PageMigration: [
-      body('action').isString().withMessage('action is required'),
+    legacyPagesMigration: [
+      body('pageIds').isArray().withMessage('pageIds is required'),
+      body('isRecursively').isBoolean().withMessage('isRecursively is required'),
     ],
   };
 
@@ -380,7 +380,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) => {
@@ -704,26 +706,14 @@ module.exports = (crowi) => {
 
   });
 
-  router.post('/v5-schema-migration', accessTokenParser, loginRequired, adminRequired, csrf, validator.v5PageMigration, apiV3FormValidator, async(req, res) => {
-    const { action, pageIds } = req.body;
+  router.post('/v5-schema-migration', accessTokenParser, loginRequired, adminRequired, csrf, async(req, res) => {
     const isV5Compatible = crowi.configManager.getConfig('crowi', 'app:isV5Compatible');
     const Page = crowi.model('Page');
 
     try {
-      switch (action) {
-        case 'initialMigration':
-          if (!isV5Compatible) {
-            // this method throws and emit socketIo event when error occurs
-            crowi.pageService.v5InitialMigration(Page.GRANT_PUBLIC); // not await
-          }
-          break;
-        case 'privateLegacyPages':
-          crowi.pageService.v5MigrationByPageIds(pageIds);
-          break;
-
-        default:
-          logger.error(`${action} action is not supported.`);
-          return res.apiv3Err(new ErrorV3('This action is not supported.', 'not_supported'), 400);
+      if (!isV5Compatible) {
+        // this method throws and emit socketIo event when error occurs
+        crowi.pageService.v5InitialMigration(Page.GRANT_PUBLIC); // not await
       }
     }
     catch (err) {
@@ -733,6 +723,26 @@ module.exports = (crowi) => {
     return res.apiv3({ isV5Compatible });
   });
 
+  // eslint-disable-next-line max-len
+  router.post('/legacy-pages-migration', accessTokenParser, loginRequired, adminRequired, csrf, validator.legacyPagesMigration, apiV3FormValidator, async(req, res) => {
+    const { pageIds, isRecursively } = req.body;
+
+    if (isRecursively) {
+      // this method innerly uses socket to send message
+      crowi.pageService.normalizeParentRecursivelyByPageIds(pageIds);
+    }
+    else {
+      try {
+        await crowi.pageService.normalizeParentByPageIds(pageIds);
+      }
+      catch (err) {
+        return res.apiv3Err(new ErrorV3(`Failed to migrate pages: ${err.message}`), 500);
+      }
+    }
+
+    return res.apiv3({});
+  });
+
   router.get('/v5-migration-status', accessTokenParser, loginRequired, async(req, res) => {
     try {
       const isV5Compatible = crowi.configManager.getConfig('crowi', 'app:isV5Compatible');

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

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

@@ -124,7 +124,7 @@ module.exports = (crowi) => {
       const page = await Page.findOne({ _id: pageId });
 
       const paginateResult = await Revision.paginate(
-        { path: page.path },
+        { pageId: page._id },
         {
           page: selectedPage,
           limit,

+ 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

@@ -4,6 +4,9 @@ import injectResetOrderByTokenMiddleware from '../middlewares/inject-reset-order
 import injectUserRegistrationOrderByTokenMiddleware from '../middlewares/inject-user-registration-order-by-token-middleware';
 import apiV1FormValidator from '../middlewares/apiv1-form-validator';
 
+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';
@@ -15,7 +18,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',
 });
@@ -35,7 +38,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);
@@ -62,10 +64,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);
 
@@ -76,7 +78,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;
   }
 
@@ -93,7 +95,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);
@@ -159,7 +161,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) => {

+ 40 - 16
packages/app/src/server/routes/page.js

@@ -141,6 +141,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 {
@@ -502,7 +516,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');
     }
 
@@ -606,18 +619,18 @@ module.exports = function(crowi, app) {
   async function redirector(req, res, next, path) {
     const pages = await Page.findByPathAndViewer(path, req.user, null, false, true);
 
-    if (pages.length === 0) {
-      const pageRedirect = await PageRedirect.findOne({ fromPath: path });
-      if (pageRedirect != null) {
-        return res.redirect(`${encodeURI(pageRedirect.toPath)}?redirectFrom=${encodeURIComponent(path)}`);
-      }
-    }
-
     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,
       });
     }
 
@@ -634,7 +647,18 @@ module.exports = function(crowi, app) {
       return res.safeRedirect(urljoin(url.pathname, url.search));
     }
 
-    req.isForbidden = await Page.count({ path }) > 0;
+    const isForbidden = await Page.exists({ path });
+    if (isForbidden) {
+      req.isForbidden = true;
+      return _notFound(req, res);
+    }
+
+    // redirect by PageRedirect
+    const pageRedirect = await PageRedirect.findOne({ fromPath: path });
+    if (pageRedirect != null) {
+      return res.safeRedirect(`${encodeURI(pageRedirect.toPath)}?redirectFrom=${encodeURIComponent(path)}`);
+    }
+
     return _notFound(req, res);
   }
 
@@ -1183,7 +1207,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;
 };

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