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

Merge branch 'dev/5.0.x' into feat/user-group-v5

Taichi Masuyama 4 лет назад
Родитель
Сommit
75099ea57b
100 измененных файлов с 2866 добавлено и 1902 удалено
  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. 3 0
      packages/app/resource/locales/en_US/admin/admin.json
  24. 7 3
      packages/app/resource/locales/en_US/translation.json
  25. 3 0
      packages/app/resource/locales/ja_JP/admin/admin.json
  26. 5 2
      packages/app/resource/locales/ja_JP/translation.json
  27. 3 0
      packages/app/resource/locales/zh_CN/admin/admin.json
  28. 5 2
      packages/app/resource/locales/zh_CN/translation.json
  29. 0 0
      packages/app/resource/search/mappings-es6.json
  30. 115 0
      packages/app/resource/search/mappings-es7.json
  31. 9 12
      packages/app/src/client/app.jsx
  32. 10 0
      packages/app/src/client/base.jsx
  33. 7 7
      packages/app/src/client/nologin.jsx
  34. 2 3
      packages/app/src/client/services/AdminAppContainer.js
  35. 11 12
      packages/app/src/client/services/ContextExtractor.tsx
  36. 0 54
      packages/app/src/client/services/PageAccessoriesContainer.js
  37. 0 148
      packages/app/src/client/services/PageContainer.js
  38. 62 0
      packages/app/src/client/services/page-operation.ts
  39. 2 2
      packages/app/src/client/util/smooth-scroll.ts
  40. 2 2
      packages/app/src/components/Admin/App/V5PageMigration.tsx
  41. 6 1
      packages/app/src/components/Admin/ImportData/GrowiArchive/ImportForm.jsx
  42. 3 3
      packages/app/src/components/Admin/Security/SecuritySetting.jsx
  43. 2 2
      packages/app/src/components/Admin/UserGroupDetail/UserGroupPageList.jsx
  44. 20 14
      packages/app/src/components/BookmarkButtons.tsx
  45. 11 3
      packages/app/src/components/Common/ClosableTextInput.tsx
  46. 210 88
      packages/app/src/components/Common/Dropdown/PageItemControl.tsx
  47. 0 96
      packages/app/src/components/ContentLinkButtons.jsx
  48. 66 0
      packages/app/src/components/ContentLinkButtons.tsx
  49. 106 0
      packages/app/src/components/DescendantsPageList.tsx
  50. 100 0
      packages/app/src/components/DescendantsPageListModal.tsx
  51. 0 26
      packages/app/src/components/DuplicatePage.tsx
  52. 3 3
      packages/app/src/components/EventListeneres/HashChanged.tsx
  53. 20 17
      packages/app/src/components/ForbiddenPage.tsx
  54. 2 1
      packages/app/src/components/Icons/AttachmentIcon.jsx
  55. 2 1
      packages/app/src/components/Icons/HistoryIcon.jsx
  56. 1 1
      packages/app/src/components/Icons/ShareLinkIcon.jsx
  57. 117 5
      packages/app/src/components/IdenticalPathPage.tsx
  58. 14 4
      packages/app/src/components/InstallerForm.jsx
  59. 7 15
      packages/app/src/components/LikeButtons.tsx
  60. 14 14
      packages/app/src/components/LoginForm.jsx
  61. 1 1
      packages/app/src/components/Navbar/AuthorInfo.jsx
  62. 4 3
      packages/app/src/components/Navbar/GlobalSearch.tsx
  63. 243 0
      packages/app/src/components/Navbar/GrowiContextualSubNavigation.tsx
  64. 0 157
      packages/app/src/components/Navbar/GrowiSubNavigation.jsx
  65. 100 0
      packages/app/src/components/Navbar/GrowiSubNavigation.tsx
  66. 4 2
      packages/app/src/components/Navbar/GrowiSubNavigationSwitcher.jsx
  67. 112 74
      packages/app/src/components/Navbar/SubNavButtons.tsx
  68. 0 42
      packages/app/src/components/NotFoundPage.jsx
  69. 48 0
      packages/app/src/components/NotFoundPage.tsx
  70. 0 87
      packages/app/src/components/Page/DisplaySwitcher.jsx
  71. 134 0
      packages/app/src/components/Page/DisplaySwitcher.tsx
  72. 12 12
      packages/app/src/components/Page/NotFoundAlert.tsx
  73. 0 143
      packages/app/src/components/Page/PageListItem.tsx
  74. 11 22
      packages/app/src/components/Page/PageManagement.jsx
  75. 13 22
      packages/app/src/components/Page/RenderTagLabels.tsx
  76. 2 1
      packages/app/src/components/Page/RevisionRenderer.jsx
  77. 0 113
      packages/app/src/components/Page/TagLabels.jsx
  78. 54 0
      packages/app/src/components/Page/TagLabels.tsx
  79. 1 0
      packages/app/src/components/Page/TrashPageAlert.jsx
  80. 0 44
      packages/app/src/components/PageAccessories.jsx
  81. 0 159
      packages/app/src/components/PageAccessoriesModal.jsx
  82. 134 0
      packages/app/src/components/PageAccessoriesModal.tsx
  83. 11 15
      packages/app/src/components/PageAccessoriesModalControl.jsx
  84. 18 12
      packages/app/src/components/PageDeleteModal.tsx
  85. 15 15
      packages/app/src/components/PageDuplicateModal.jsx
  86. 1 1
      packages/app/src/components/PageHistory/RevisionDiff.jsx
  87. 0 102
      packages/app/src/components/PageList.jsx
  88. 2 2
      packages/app/src/components/PageList/BookmarkList.jsx
  89. 50 0
      packages/app/src/components/PageList/PageList.tsx
  90. 140 0
      packages/app/src/components/PageList/PageListItemL.tsx
  91. 3 3
      packages/app/src/components/PageList/PageListItemS.jsx
  92. 9 7
      packages/app/src/components/PagePathNav.tsx
  93. 0 49
      packages/app/src/components/PageReactionButtons.tsx
  94. 16 17
      packages/app/src/components/PageRenameModal.jsx
  95. 2 2
      packages/app/src/components/RecentCreated/RecentCreated.jsx
  96. 3 2
      packages/app/src/components/SearchForm.tsx
  97. 3 21
      packages/app/src/components/SearchPage.jsx
  98. 3 3
      packages/app/src/components/SearchPage/SearchPageLayout.tsx
  99. 77 15
      packages/app/src/components/SearchPage/SearchResultContent.tsx
  100. 0 74
      packages/app/src/components/SearchPage/SearchResultContentSubNavigation.tsx

+ 3 - 1
.devcontainer/Dockerfile

@@ -3,7 +3,7 @@
 # Licensed under the MIT License. See https://go.microsoft.com/fwlink/?linkid=2090316 for license information.
 # 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
 # The node image includes a non-root user with sudo access. Use the
 # "remoteUser" property in devcontainer.json to use it. On Linux, update
 # "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
 ENV DEBIAN_FRONTEND=noninteractive
 RUN apt-get update \
 RUN apt-get update \
    && apt-get -y install --no-install-recommends git-lfs \
    && 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
    # Clean up
    && apt-get autoremove -y \
    && apt-get autoremove -y \

+ 10 - 0
.eslintrc.js

@@ -12,6 +12,7 @@ module.exports = {
   },
   },
   plugins: [
   plugins: [
     'jest',
     'jest',
+    'regex',
   ],
   ],
   rules: {
   rules: {
     'import/prefer-default-export': 'off',
     'import/prefer-default-export': 'off',
@@ -30,5 +31,14 @@ module.exports = {
       'error',
       'error',
       { additionalTestBlockFunctions: ['each.test'] },
       { 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:
 on:
   push:
   push:
@@ -26,14 +26,19 @@ jobs:
         cache: 'yarn'
         cache: 'yarn'
         cache-dependency-path: '**/yarn.lock'
         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
     - name: lerna bootstrap
       run: |
       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
     - name: lerna run lint for plugins
       run: |
       run: |
@@ -53,6 +58,7 @@ jobs:
         url: ${{ secrets.SLACK_WEBHOOK_URL }}
         url: ${{ secrets.SLACK_WEBHOOK_URL }}
 
 
 
 
+
   test:
   test:
     runs-on: ubuntu-latest
     runs-on: ubuntu-latest
 
 
@@ -75,14 +81,19 @@ jobs:
         cache: 'yarn'
         cache: 'yarn'
         cache-dependency-path: '**/yarn.lock'
         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
     - name: lerna bootstrap
       run: |
       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
     - name: yarn test
       working-directory: ./packages/app
       working-directory: ./packages/app
@@ -108,6 +119,7 @@ jobs:
         url: ${{ secrets.SLACK_WEBHOOK_URL }}
         url: ${{ secrets.SLACK_WEBHOOK_URL }}
 
 
 
 
+
   launch-dev:
   launch-dev:
     runs-on: ubuntu-latest
     runs-on: ubuntu-latest
 
 
@@ -130,14 +142,19 @@ jobs:
         cache: 'yarn'
         cache: 'yarn'
         cache-dependency-path: '**/yarn.lock'
         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
     - name: lerna bootstrap
       run: |
       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
     - name: yarn dev:ci
       working-directory: ./packages/app
       working-directory: ./packages/app
@@ -152,82 +169,7 @@ jobs:
       if: failure()
       if: failure()
       with:
       with:
         type: ${{ job.status }}
         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'
         channel: '#ci'
         isCompactMode: true
         isCompactMode: true
         url: ${{ secrets.SLACK_WEBHOOK_URL }}
         url: ${{ secrets.SLACK_WEBHOOK_URL }}

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

@@ -26,14 +26,19 @@ jobs:
         cache: 'yarn'
         cache: 'yarn'
         cache-dependency-path: '**/yarn.lock'
         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
     - name: lerna bootstrap
       run: |
       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
     - name: yarn lint
       run: |
       run: |
@@ -79,14 +84,19 @@ jobs:
         cache: 'yarn'
         cache: 'yarn'
         cache-dependency-path: '**/yarn.lock'
         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
     - name: lerna bootstrap
       run: |
       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
     - name: yarn dev:ci
       working-directory: ./packages/slackbot-proxy
       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
 # 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.*
 *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
 ## [v4.5.8](https://github.com/weseek/growi/compare/v4.5.7...v4.5.8) - 2022-01-12
 
 
 ### 💎 Features
 ### 💎 Features

+ 3 - 1
README.md

@@ -83,12 +83,14 @@ See [GROWI Docs: Environment Variables](https://docs.growi.org/en/admin-guide/ad
 ## Dependencies
 ## Dependencies
 
 
 - Node.js v14.x or v16.x
 - Node.js v14.x or v16.x
+- npm 6.x
+- yarn
 - MongoDB 4.x
 - MongoDB 4.x
 
 
 ### Optional Dependencies
 ### Optional Dependencies
 
 
 - Redis 3.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)
   - **CAUTION: Following plugins are required**
   - **CAUTION: Following plugins are required**
     - [Japanese (kuromoji) Analysis plugin](https://www.elastic.co/guide/en/elasticsearch/plugins/current/analysis-kuromoji.html)
     - [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)
     - [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
 - Node.js v14.x or v16.x
+- npm 6.x
+- yarn
 - MongoDB 4.x
 - MongoDB 4.x
 
 
 ### オプションの依存関係
 ### オプションの依存関係
 
 
 - Redis 3.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)
     - [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)
     - [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"
     "tslib": "^2.3.1"
   },
   },
   "devDependencies": {
   "devDependencies": {
+    "@testing-library/cypress": "^8.0.2",
     "@types/jest": "^26.0.22",
     "@types/jest": "^26.0.22",
     "@types/node": "^14.14.35",
     "@types/node": "^14.14.35",
     "@types/rewire": "^2.5.28",
     "@types/rewire": "^2.5.28",
     "@typescript-eslint/eslint-plugin": "^4.28.5",
     "@typescript-eslint/eslint-plugin": "^4.28.5",
     "@typescript-eslint/parser": "^4.28.5",
     "@typescript-eslint/parser": "^4.28.5",
+    "cypress": "^9.2.0",
     "eslint": "^7.31.0",
     "eslint": "^7.31.0",
     "eslint-config-weseek": "^1.1.0",
     "eslint-config-weseek": "^1.1.0",
     "eslint-import-resolver-typescript": "^2.4.0",
     "eslint-import-resolver-typescript": "^2.4.0",
@@ -69,6 +71,11 @@
     "lerna": "^4.0.0",
     "lerna": "^4.0.0",
     "postcss": "^8.4.5",
     "postcss": "^8.4.5",
     "postcss-scss": "^4.0.3",
     "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",
     "rewire": "^5.0.0",
     "shipjs": "^0.24.1",
     "shipjs": "^0.24.1",
     "stylelint": "^14.2.0",
     "stylelint": "^14.2.0",
@@ -76,7 +83,8 @@
     "ts-jest": "^27.0.4",
     "ts-jest": "^27.0.4",
     "ts-node": "^9.1.1",
     "ts-node": "^9.1.1",
     "tsconfig-paths": "^3.9.0",
     "tsconfig-paths": "^3.9.0",
-    "typescript": "^4.2.3"
+    "typescript": "^4.2.3",
+    "yargs": "^17.3.1"
   },
   },
   "engines": {
   "engines": {
     "node": "^14 || ^16",
     "node": "^14 || ^16",

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

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

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

@@ -3,6 +3,9 @@ module.exports = {
     'weseek/react',
     'weseek/react',
     'weseek/typescript',
     'weseek/typescript',
   ],
   ],
+  plugins: [
+    'regex',
+  ],
   env: {
   env: {
     jquery: true,
     jquery: true,
   },
   },
@@ -25,6 +28,15 @@ module.exports = {
       name: 'axios',
       name: 'axios',
       message: 'Please use src/utils/axios instead.',
       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',
     '@typescript-eslint/no-var-requires': 'off',
 
 
     // set 'warn' temporarily -- 2021.08.02 Yuki Takei
     // set 'warn' temporarily -- 2021.08.02 Yuki Takei

+ 5 - 0
packages/app/.gitignore

@@ -2,6 +2,11 @@
 /.next/
 /.next/
 /out/
 /out/
 
 
+# test
+test/cypress/screenshots
+test/cypress/videos
+.reg
+
 # dist
 # dist
 /dist/
 /dist/
 /transpiled/
 /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
 ## packages-json-picker
 ##
 ##
-FROM node:14-slim AS packages-json-picker
+FROM node:16-slim AS packages-json-picker
 
 
 ENV optDir /opt
 ENV optDir /opt
 
 
@@ -20,7 +20,7 @@ RUN find packages \! -name "package.json" -mindepth 2 -maxdepth 2 -print | xargs
 ##
 ##
 ## deps-resolver
 ## deps-resolver
 ##
 ##
-FROM node:14-slim AS deps-resolver
+FROM node:16-slim AS deps-resolver
 
 
 ENV optDir /opt
 ENV optDir /opt
 
 
@@ -31,7 +31,7 @@ COPY --from=packages-json-picker ${optDir} .
 
 
 # setup
 # setup
 RUN yarn config set network-timeout 300000
 RUN yarn config set network-timeout 300000
-RUN npx lerna bootstrap -- --frozen-lockfile
+RUN npx -y lerna bootstrap -- --frozen-lockfile
 
 
 # make artifacts
 # make artifacts
 RUN tar -cf node_modules.tar \
 RUN tar -cf node_modules.tar \
@@ -48,7 +48,7 @@ FROM deps-resolver AS deps-resolver-prod
 # remove unnecessary packages
 # remove unnecessary packages
 RUN rm -rf packages/slackbot-proxy
 RUN rm -rf packages/slackbot-proxy
 
 
-RUN npx lerna bootstrap -- --production
+RUN npx -y lerna bootstrap -- --production
 # make artifacts
 # make artifacts
 RUN tar -cf node_modules.tar \
 RUN tar -cf node_modules.tar \
   node_modules \
   node_modules \
@@ -59,7 +59,7 @@ RUN tar -cf node_modules.tar \
 ##
 ##
 ## prebuilder-default
 ## prebuilder-default
 ##
 ##
-FROM node:14-slim AS prebuilder-default
+FROM node:16-slim AS prebuilder-default
 
 
 ENV optDir /opt
 ENV optDir /opt
 
 
@@ -128,7 +128,7 @@ RUN tar -cf packages.tar \
 ##
 ##
 ## release
 ## release
 ##
 ##
-FROM node:14-slim
+FROM node:16-slim
 LABEL maintainer Yuki Takei <yuki@weseek.co.jp>
 LABEL maintainer Yuki Takei <yuki@weseek.co.jp>
 
 
 ENV NODE_ENV production
 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`, `5.0`, `5`, `latest` (Dockerfile)](https://github.com/weseek/growi/blob/v5.0.0/docker/Dockerfile)
 * [`5.0.0-nocdn`, `5.0-nocdn`, `5-nocdn`, `latest-nocdn` (Dockerfile)](https://github.com/weseek/growi/blob/v5.0.0/docker/Dockerfile)
 * [`5.0.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`, `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)
 * [`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',
       preset: 'ts-jest/presets/js-with-ts',
 
 
       rootDir: '.',
       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',
       testEnvironment: 'node',
 
 
@@ -36,23 +36,18 @@ module.exports = {
       preset: 'ts-jest/presets/js-with-ts',
       preset: 'ts-jest/presets/js-with-ts',
 
 
       rootDir: '.',
       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',
       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
       // Automatically clear mock calls and instances between every test
       clearMocks: true,
       clearMocks: true,
       moduleNameMapper: MODULE_NAME_MAPPING,
       moduleNameMapper: MODULE_NAME_MAPPING,
     },
     },
-    // {
-    //   displayName: 'client',
-    //   rootDir: '.',
-    //   testMatch: ['<rootDir>/src/test/client/**/*.test.js'],
-    // },
   ],
   ],
 
 
   // Automatically clear mock calls and instances between every test
   // Automatically clear mock calls and instances between every test

+ 13 - 7
packages/app/package.json

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

+ 3 - 0
packages/app/resource/locales/en_US/admin/admin.json

@@ -189,6 +189,9 @@
     "beta_warning": "This function is Beta.",
     "beta_warning": "This function is Beta.",
     "import_from": "Import from {{from}}",
     "import_from": "Import from {{from}}",
     "import_growi_archive": "Import GROWI archive",
     "import_growi_archive": "Import GROWI archive",
+    "error": {
+      "only_upsert_available": "Only 'Upsert' option is available for pages collection."
+    },
     "growi_settings": {
     "growi_settings": {
       "description_of_import_mode": {
       "description_of_import_mode": {
         "about": "When you import data with the same name as an existing one, choose from the following three modes below.",
         "about": "When you import data with the same name as an existing one, choose from the following three modes below.",

+ 7 - 3
packages/app/resource/locales/en_US/translation.json

@@ -60,6 +60,7 @@
   "Presentation Mode": "Presentation",
   "Presentation Mode": "Presentation",
   "The end": "The end",
   "The end": "The end",
   "Not available for guest": "Not available for guest",
   "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 liked this yet.": "No users have liked this yet.",
   "No users have bookmarked yet": "No users have bookmarked yet",
   "No users have bookmarked yet": "No users have bookmarked yet",
   "Create Archive Page": "Create Archive Page",
   "Create Archive Page": "Create Archive Page",
@@ -178,7 +179,9 @@
     "error_message": "Some values ​​are incorrect",
     "error_message": "Some values ​​are incorrect",
     "required": "%s is required",
     "required": "%s is required",
     "invalid_syntax": "The syntax of %s is invalid.",
     "invalid_syntax": "The syntax of %s is invalid.",
-    "title_required": "Title is required."
+    "title_required": "Title is required.",
+    "slashed_are_not_yet_supported": "Titles containing slashes are not yet supported"
+
   },
   },
   "not_found_page": {
   "not_found_page": {
     "Create Page": "Create Page",
     "Create Page": "Create Page",
@@ -206,7 +209,7 @@
     },
     },
     "form_help": {
     "form_help": {
       "email": "You must have email address which listed below to sign up to this wiki.",
       "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."
       "user_id": "The URL of pages you create will contain your User ID. Your User ID can consist of letters, numbers, and some symbols."
     }
     }
   },
   },
@@ -972,7 +975,8 @@
     "password_and_confirm_password_does_not_match": "Password and confirm password does not match"
     "password_and_confirm_password_does_not_match": "Password and confirm password does not match"
   },
   },
   "pagetree": {
   "pagetree": {
-    "private_legacy_pages": "Private Legacy Pages"
+    "private_legacy_pages": "Private Legacy Pages",
+    "cannot_rename_a_title_that_contains_slash": "Cannot rename a title that contains '/'"
   },
   },
   "duplicated_page_alert" : {
   "duplicated_page_alert" : {
     "same_page_name_exists": "Same page name exits as「{{pageName}}」",
     "same_page_name_exists": "Same page name exits as「{{pageName}}」",

+ 3 - 0
packages/app/resource/locales/ja_JP/admin/admin.json

@@ -207,6 +207,9 @@
     "beta_warning": "この機能はベータ版です",
     "beta_warning": "この機能はベータ版です",
     "import_from": "{{from}} からインポート",
     "import_from": "{{from}} からインポート",
     "import_growi_archive": "GROWI アーカイブをインポート",
     "import_growi_archive": "GROWI アーカイブをインポート",
+    "error": {
+      "only_upsert_available": "pages コレクションには 'Upsert' オプションのみ対応しています"
+    },
     "growi_settings": {
     "growi_settings": {
       "description_of_import_mode": {
       "description_of_import_mode": {
         "about": "既存のデータと同名であるデータをインポートする際の挙動は以下の3つのモードから選べます。",
         "about": "既存のデータと同名であるデータをインポートする際の挙動は以下の3つのモードから選べます。",

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

@@ -61,6 +61,7 @@
   "Presentation Mode": "プレゼンテーション",
   "Presentation Mode": "プレゼンテーション",
   "The end": "おしまい",
   "The end": "おしまい",
   "Not available for guest": "ゲストユーザーは利用できません",
   "Not available for guest": "ゲストユーザーは利用できません",
+  "No users have liked this yet": "いいねをしているユーザーはいません",
   "No users have bookmarked yet": "ブックマークしているユーザーはいません",
   "No users have bookmarked yet": "ブックマークしているユーザーはいません",
   "Create Archive Page": "アーカイブページの作成",
   "Create Archive Page": "アーカイブページの作成",
   "Target page": "対象ページ",
   "Target page": "対象ページ",
@@ -180,7 +181,8 @@
     "error_message": "いくつかの値が設定されていません",
     "error_message": "いくつかの値が設定されていません",
     "required": "%sに値を入力してください",
     "required": "%sに値を入力してください",
     "invalid_syntax": "%sの構文が不正です",
     "invalid_syntax": "%sの構文が不正です",
-    "title_required": "タイトルを入力してください"
+    "title_required": "タイトルを入力してください",
+    "slashed_are_not_yet_supported": "スラッシュを含むタイトルにはまだ対応していません"
   },
   },
   "not_found_page": {
   "not_found_page": {
     "Create Page": "ページを作成する",
     "Create Page": "ページを作成する",
@@ -965,7 +967,8 @@
     "password_and_confirm_password_does_not_match": "パスワードと確認パスワードが一致しません"
     "password_and_confirm_password_does_not_match": "パスワードと確認パスワードが一致しません"
   },
   },
   "pagetree": {
   "pagetree": {
-    "private_legacy_pages": "待避所"
+    "private_legacy_pages": "待避所",
+    "cannot_rename_a_title_that_contains_slash": "`/` が含まれているタイトルにリネームできません"
   },
   },
   "duplicated_page_alert" : {
   "duplicated_page_alert" : {
     "same_page_name_exists": "ページ名 「{{pageName}}」が重複しています",
     "same_page_name_exists": "ページ名 「{{pageName}}」が重複しています",

+ 3 - 0
packages/app/resource/locales/zh_CN/admin/admin.json

@@ -199,6 +199,9 @@
     "beta_warning": "这个函数是Beta。",
     "beta_warning": "这个函数是Beta。",
     "import_from": "Import from {{from}}",
     "import_from": "Import from {{from}}",
     "import_growi_archive": "Import GROWI archive",
     "import_growi_archive": "Import GROWI archive",
+    "error": {
+      "only_upsert_available": "Only 'Upsert' option is available for pages collection."
+    },
     "growi_settings": {
     "growi_settings": {
       "description_of_import_mode": {
       "description_of_import_mode": {
         "about": "When you import data with the same name as an existing one, choose from the following three modes below.",
         "about": "When you import data with the same name as an existing one, choose from the following three modes below.",

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

@@ -62,6 +62,7 @@
 	"Presentation Mode": "演示文稿",
 	"Presentation Mode": "演示文稿",
   "The end": "结束",
   "The end": "结束",
   "Not available for guest": "Not available for guest",
   "Not available for guest": "Not available for guest",
+  "No users have liked this yet": "还没有用户喜欢这个",
   "No users have bookmarked yet": "还没有用户加入书签",
   "No users have bookmarked yet": "还没有用户加入书签",
   "Create Archive Page": "创建归档页",
   "Create Archive Page": "创建归档页",
   "File type": "文件类型",
   "File type": "文件类型",
@@ -178,7 +179,8 @@
 		"error_message": "有些值不正确",
 		"error_message": "有些值不正确",
 		"required": "%s 是必需的",
 		"required": "%s 是必需的",
 		"invalid_syntax": "%s的语法无效。",
 		"invalid_syntax": "%s的语法无效。",
-    "title_required": "标题是必需的。"
+    "title_required": "标题是必需的。",
+    "slashed_are_not_yet_supported": "スラッシュを含むタイトルにはまだ対応していません"
   },
   },
   "not_found_page": {
   "not_found_page": {
     "Create Page": "创建页面",
     "Create Page": "创建页面",
@@ -975,7 +977,8 @@
     "password_and_confirm_password_does_not_match": "密码和确认密码不匹配"
     "password_and_confirm_password_does_not_match": "密码和确认密码不匹配"
   },
   },
   "pagetree": {
   "pagetree": {
-    "private_legacy_pages": "私人遗留页面"
+    "private_legacy_pages": "私人遗留页面",
+    "cannot_rename_a_title_that_contains_slash": "不能重命名包含 ’/' 的标题"
   },
   },
   "duplicated_page_alert" : {
   "duplicated_page_alert" : {
     "same_page_name_exists": "页面名称「{{pageName}}」是重复的",
     "same_page_name_exists": "页面名称「{{pageName}}」是重复的",

+ 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"
+      }
+    }
+  }
+}

+ 9 - 12
packages/app/src/client/app.jsx

@@ -37,10 +37,9 @@ import RecentCreated from '../components/RecentCreated/RecentCreated';
 import RecentlyCreatedIcon from '../components/Icons/RecentlyCreatedIcon';
 import RecentlyCreatedIcon from '../components/Icons/RecentlyCreatedIcon';
 import MyDraftList from '../components/MyDraftList/MyDraftList';
 import MyDraftList from '../components/MyDraftList/MyDraftList';
 import BookmarkList from '../components/PageList/BookmarkList';
 import BookmarkList from '../components/PageList/BookmarkList';
-import LikerList from '../components/User/LikerList';
 import Fab from '../components/Fab';
 import Fab from '../components/Fab';
 import PersonalSettings from '../components/Me/PersonalSettings';
 import PersonalSettings from '../components/Me/PersonalSettings';
-import GrowiSubNavigation from '../components/Navbar/GrowiSubNavigation';
+import GrowiContextualSubNavigation from '../components/Navbar/GrowiContextualSubNavigation';
 import GrowiSubNavigationSwitcher from '../components/Navbar/GrowiSubNavigationSwitcher';
 import GrowiSubNavigationSwitcher from '../components/Navbar/GrowiSubNavigationSwitcher';
 import IdenticalPathPage from '~/components/IdenticalPathPage';
 import IdenticalPathPage from '~/components/IdenticalPathPage';
 
 
@@ -52,9 +51,9 @@ import CommentContainer from '~/client/services/CommentContainer';
 import EditorContainer from '~/client/services/EditorContainer';
 import EditorContainer from '~/client/services/EditorContainer';
 import TagContainer from '~/client/services/TagContainer';
 import TagContainer from '~/client/services/TagContainer';
 import PersonalContainer from '~/client/services/PersonalContainer';
 import PersonalContainer from '~/client/services/PersonalContainer';
-import PageAccessoriesContainer from '~/client/services/PageAccessoriesContainer';
 
 
 import { appContainer, componentMappings } from './base';
 import { appContainer, componentMappings } from './base';
+import { toastError } from './util/apiNotification';
 
 
 const logger = loggerFactory('growi:cli:app');
 const logger = loggerFactory('growi:cli:app');
 
 
@@ -71,10 +70,9 @@ const commentContainer = new CommentContainer(appContainer);
 const editorContainer = new EditorContainer(appContainer, defaultEditorOptions, defaultPreviewOptions);
 const editorContainer = new EditorContainer(appContainer, defaultEditorOptions, defaultPreviewOptions);
 const tagContainer = new TagContainer(appContainer);
 const tagContainer = new TagContainer(appContainer);
 const personalContainer = new PersonalContainer(appContainer);
 const personalContainer = new PersonalContainer(appContainer);
-const pageAccessoriesContainer = new PageAccessoriesContainer(appContainer);
 const injectableContainers = [
 const injectableContainers = [
   appContainer, socketIoContainer, pageContainer, pageHistoryContainer, revisionComparerContainer,
   appContainer, socketIoContainer, pageContainer, pageHistoryContainer, revisionComparerContainer,
-  commentContainer, editorContainer, tagContainer, personalContainer, pageAccessoriesContainer,
+  commentContainer, editorContainer, tagContainer, personalContainer,
 ];
 ];
 
 
 logger.info('unstated containers have been initialized');
 logger.info('unstated containers have been initialized');
@@ -89,7 +87,7 @@ Object.assign(componentMappings, {
 
 
   'search-page': <SearchPage crowi={appContainer} />,
   'search-page': <SearchPage crowi={appContainer} />,
   'all-in-app-notifications': <InAppNotificationPage />,
   'all-in-app-notifications': <InAppNotificationPage />,
-  'identical-path-page-list': <IdenticalPathPage />,
+  'identical-path-page': <IdenticalPathPage />,
 
 
   // 'revision-history': <PageHistory pageId={pageId} />,
   // 'revision-history': <PageHistory pageId={pageId} />,
   'tags-page': <TagsList crowi={appContainer} />,
   'tags-page': <TagsList crowi={appContainer} />,
@@ -102,7 +100,7 @@ Object.assign(componentMappings, {
 
 
   'not-found-page': <NotFoundPage />,
   'not-found-page': <NotFoundPage />,
 
 
-  'forbidden-page': <ForbiddenPage />,
+  'forbidden-page': <ForbiddenPage isLinkSharingDisabled={appContainer.config.disableLinkSharing} />,
 
 
   'page-timeline': <PageTimeline />,
   'page-timeline': <PageTimeline />,
 
 
@@ -118,7 +116,6 @@ Object.assign(componentMappings, {
   'renamed-alert': <RenamedAlert />,
   'renamed-alert': <RenamedAlert />,
   'not-found-alert': <NotFoundAlert
   'not-found-alert': <NotFoundAlert
     isGuestUserMode={appContainer.isGuestUser}
     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 +125,6 @@ if (pageContainer.state.pageId != null) {
     'page-comments-list': <PageComments />,
     'page-comments-list': <PageComments />,
     'page-comment-write': <CommentEditorLazyRenderer />,
     'page-comment-write': <CommentEditorLazyRenderer />,
     'page-management': <PageManagement />,
     'page-management': <PageManagement />,
-    'liker-list': <LikerList />,
     'page-content-footer': <PageContentFooter />,
     'page-content-footer': <PageContentFooter />,
 
 
     'recent-created-icon': <RecentlyCreatedIcon />,
     'recent-created-icon': <RecentlyCreatedIcon />,
@@ -136,7 +132,8 @@ if (pageContainer.state.pageId != null) {
 
 
   // show the Page accessory modal when query of "compare" is requested
   // show the Page accessory modal when query of "compare" is requested
   if (revisionComparerContainer.getRevisionIDsToCompareAsParam().length > 0) {
   if (revisionComparerContainer.getRevisionIDsToCompareAsParam().length > 0) {
-    pageAccessoriesContainer.openPageAccessoriesModal('pageHistory');
+    toastError('Sorry, opening PageAccessoriesModal is not implemented yet in v5.');
+  //   pageAccessoriesContainer.openPageAccessoriesModal('pageHistory');
   }
   }
 }
 }
 if (pageContainer.state.creator != null) {
 if (pageContainer.state.creator != null) {
@@ -149,8 +146,8 @@ if (pageContainer.state.path != null) {
   Object.assign(componentMappings, {
   Object.assign(componentMappings, {
     // eslint-disable-next-line quote-props
     // eslint-disable-next-line quote-props
     'page': <Page />,
     'page': <Page />,
-    'grw-subnav-container': <GrowiSubNavigation />,
-    'grw-subnav-switcher-container': <GrowiSubNavigationSwitcher />,
+    'grw-subnav-container': <GrowiContextualSubNavigation isLinkSharingDisabled={appContainer.config.disableLinkSharing} />,
+    'grw-subnav-switcher-container': <GrowiSubNavigationSwitcher isLinkSharingDisabled={appContainer.config.disableLinkSharing} />,
     'display-switcher': <DisplaySwitcher />,
     'display-switcher': <DisplaySwitcher />,
   });
   });
 }
 }

+ 10 - 0
packages/app/src/client/base.jsx

@@ -7,9 +7,14 @@ import GrowiNavbar from '../components/Navbar/GrowiNavbar';
 import GrowiNavbarBottom from '../components/Navbar/GrowiNavbarBottom';
 import GrowiNavbarBottom from '../components/Navbar/GrowiNavbarBottom';
 import HotkeysManager from '../components/Hotkeys/HotkeysManager';
 import HotkeysManager from '../components/Hotkeys/HotkeysManager';
 import PageCreateModal from '../components/PageCreateModal';
 import PageCreateModal from '../components/PageCreateModal';
+import PageDeleteModal from '../components/PageDeleteModal';
+import PageDuplicateModal from '../components/PageDuplicateModal';
+import PageRenameModal from '../components/PageRenameModal';
+import PageAccessoriesModal from '../components/PageAccessoriesModal';
 
 
 import AppContainer from '~/client/services/AppContainer';
 import AppContainer from '~/client/services/AppContainer';
 import SocketIoContainer from '~/client/services/SocketIoContainer';
 import SocketIoContainer from '~/client/services/SocketIoContainer';
+import { DescendantsPageListModal } from '~/components/DescendantsPageListModal';
 
 
 const logger = loggerFactory('growi:cli:app');
 const logger = loggerFactory('growi:cli:app');
 
 
@@ -40,6 +45,11 @@ const componentMappings = {
   'grw-navbar-bottom-container': <GrowiNavbarBottom />,
   'grw-navbar-bottom-container': <GrowiNavbarBottom />,
 
 
   'page-create-modal': <PageCreateModal />,
   'page-create-modal': <PageCreateModal />,
+  'page-delete-modal': <PageDeleteModal />,
+  'page-duplicate-modal': <PageDuplicateModal />,
+  'page-rename-modal': <PageRenameModal />,
+  'page-accessories-modal': <PageAccessoriesModal />,
+  'descendants-page-list-modal': <DescendantsPageListModal />,
 
 
   'grw-hotkeys-manager': <HotkeysManager />,
   'grw-hotkeys-manager': <HotkeysManager />,
 
 

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

@@ -16,17 +16,17 @@ import CompleteUserRegistrationForm from '~/components/CompleteUserRegistrationF
 const i18n = i18nFactory();
 const i18n = i18nFactory();
 
 
 // render InstallerForm
 // 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(
   ReactDOM.render(
     <I18nextProvider i18n={i18n}>
     <I18nextProvider i18n={i18n}>
       <InstallerForm userName={userName} name={name} email={email} csrf={csrf} />
       <InstallerForm userName={userName} name={name} email={email} csrf={csrf} />
     </I18nextProvider>,
     </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
    * Start v5 page migration
    * @memberOf AdminAppContainer
    * @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;
     const { isV5Compatible } = response.data;
     return { isV5Compatible };
     return { isV5Compatible };
   }
   }

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

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

+ 0 - 54
packages/app/src/client/services/PageAccessoriesContainer.js

@@ -1,54 +0,0 @@
-import { Container } from 'unstated';
-
-/**
- * Service container related to options for Application
- * @extends {Container} unstated Container
- */
-
-export default class PageAccessoriesContainer extends Container {
-
-  constructor(appContainer) {
-    super();
-
-    this.appContainer = appContainer;
-
-    this.state = {
-      isPageAccessoriesModalShown: false,
-      activeTab: '',
-      // Prevent unnecessary rendering
-      activeComponents: new Set(['']),
-    };
-    this.openPageAccessoriesModal = this.openPageAccessoriesModal.bind(this);
-    this.closePageAccessoriesModal = this.closePageAccessoriesModal.bind(this);
-    this.switchActiveTab = this.switchActiveTab.bind(this);
-  }
-
-  /**
-   * Workaround for the mangling in production build to break constructor.name
-   */
-  static getClassName() {
-    return 'PageAccessoriesContainer';
-  }
-
-
-  openPageAccessoriesModal(activeTab) {
-    this.setState({
-      isPageAccessoriesModalShown: true,
-    });
-    this.switchActiveTab(activeTab);
-  }
-
-  closePageAccessoriesModal() {
-    this.setState({
-      isPageAccessoriesModalShown: false,
-      activeTab: '',
-    });
-  }
-
-  switchActiveTab(activeTab) {
-    this.setState({
-      activeTab, activeComponents: this.state.activeComponents.add(activeTab),
-    });
-  }
-
-}

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

@@ -54,15 +54,6 @@ export default class PageContainer extends Container {
       path,
       path,
       tocHtml: '',
       tocHtml: '',
 
 
-      seenUsers: [],
-      seenUserIds: [],
-      sumOfSeenUsers: [],
-
-      isLiked: false,
-      likers: [],
-      likerIds: [],
-      sumOfLikers: 0,
-
       createdAt: mainContent.getAttribute('data-page-created-at'),
       createdAt: mainContent.getAttribute('data-page-created-at'),
       // please use useCurrentUpdatedAt instead
       // please use useCurrentUpdatedAt instead
       updatedAt: mainContent.getAttribute('data-page-updated-at'),
       updatedAt: mainContent.getAttribute('data-page-updated-at'),
@@ -71,9 +62,7 @@ export default class PageContainer extends Container {
       isUserPage: JSON.parse(mainContent.getAttribute('data-page-user')) != null,
       isUserPage: JSON.parse(mainContent.getAttribute('data-page-user')) != null,
       isTrashPage: isTrashPage(path),
       isTrashPage: isTrashPage(path),
       isDeleted: JSON.parse(mainContent.getAttribute('data-page-is-deleted')),
       isDeleted: JSON.parse(mainContent.getAttribute('data-page-is-deleted')),
-      isDeletable: JSON.parse(mainContent.getAttribute('data-page-is-deletable')),
       isNotCreatable: JSON.parse(mainContent.getAttribute('data-page-is-not-creatable')),
       isNotCreatable: JSON.parse(mainContent.getAttribute('data-page-is-not-creatable')),
-      isAbleToDeleteCompletely: JSON.parse(mainContent.getAttribute('data-page-is-able-to-delete-completely')),
       isPageExist: mainContent.getAttribute('data-page-id') != null,
       isPageExist: mainContent.getAttribute('data-page-id') != null,
 
 
       pageUser: JSON.parse(mainContent.getAttribute('data-page-user')),
       pageUser: JSON.parse(mainContent.getAttribute('data-page-user')),
@@ -117,23 +106,9 @@ export default class PageContainer extends Container {
     interceptorManager.addInterceptor(new RestoreCodeBlockInterceptor(appContainer), 900); // process as late as possible
     interceptorManager.addInterceptor(new RestoreCodeBlockInterceptor(appContainer), 900); // process as late as possible
 
 
     this.initStateMarkdown();
     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.setTocHtml = this.setTocHtml.bind(this);
     this.save = this.save.bind(this);
     this.save = this.save.bind(this);
-    this.checkAndUpdateImageUrlCached = this.checkAndUpdateImageUrlCached.bind(this);
 
 
     this.emitJoinPageRoomRequest = this.emitJoinPageRoomRequest.bind(this);
     this.emitJoinPageRoomRequest = this.emitJoinPageRoomRequest.bind(this);
     this.emitJoinPageRoomRequest();
     this.emitJoinPageRoomRequest();
@@ -163,71 +138,6 @@ export default class PageContainer extends Container {
     return 'PageContainer';
     return 'PageContainer';
   }
   }
 
 
-
-  /**
-   * whether to display reaction buttons
-   * ex.) like, bookmark
-   */
-  get isAbleToShowPageReactionButtons() {
-    const { isTrashPage, isPageExist } = this.state;
-    const { isSharedUser } = this.appContainer;
-
-    return (!isTrashPage && isPageExist && !isSharedUser);
-  }
-
-  /**
-   * whether to display tag labels
-   */
-  get isAbleToShowTagLabel() {
-    const { isUserPage } = this.state;
-    const { isSharedUser } = this.appContainer;
-
-    return (!isUserPage && !isSharedUser);
-  }
-
-  /**
-   * whether to display page management
-   * ex.) duplicate, rename
-   */
-  get isAbleToShowPageManagement() {
-    const { isPageExist, isTrashPage } = this.state;
-    const { isSharedUser } = this.appContainer;
-
-    return (isPageExist && !isTrashPage && !isSharedUser);
-  }
-
-  /**
-   * whether to display pageEditorModeManager
-   * ex.) view, edit, hackmd
-   */
-  get isAbleToShowPageEditorModeManager() {
-    const { isNotCreatable, isTrashPage } = this.state;
-    const { isSharedUser } = this.appContainer;
-
-    return (!isNotCreatable && !isTrashPage && !isSharedUser);
-  }
-
-  /**
-   * whether to display pageAuthors
-   * ex.) creator, lastUpdateUser
-   */
-  get isAbleToShowPageAuthors() {
-    const { isPageExist, isUserPage } = this.state;
-
-    return (isPageExist && !isUserPage);
-  }
-
-  /**
-   * whether to like button
-   * not displayed on user page
-   */
-  get isAbleToShowLikeButtons() {
-    const { isUserPage } = this.state;
-    const { isSharedUser } = this.appContainer;
-
-    return (!isUserPage && !isSharedUser);
-  }
-
   /**
   /**
    * whether to Empty Trash Page
    * whether to Empty Trash Page
    * not displayed when guest user and not on trash page
    * not displayed when guest user and not on trash page
@@ -266,64 +176,6 @@ export default class PageContainer extends Container {
     this.state.markdown = markdown;
     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) {
   setLatestRemotePageData(s2cMessagePageUpdated) {
     const newState = {
     const newState = {
       remoteRevisionId: s2cMessagePageUpdated.revisionId,
       remoteRevisionId: s2cMessagePageUpdated.revisionId,

+ 62 - 0
packages/app/src/client/services/page-operation.ts

@@ -0,0 +1,62 @@
+import urljoin from 'url-join';
+
+import { SubscriptionStatusType } from '~/interfaces/subscription';
+
+import { toastError } from '../util/apiNotification';
+import { apiv3Put } from '../util/apiv3-client';
+
+export const toggleSubscribe = async(pageId: string, currentStatus: SubscriptionStatusType | undefined): Promise<void> => {
+  try {
+    const newStatus = currentStatus === SubscriptionStatusType.SUBSCRIBE
+      ? SubscriptionStatusType.UNSUBSCRIBE
+      : SubscriptionStatusType.SUBSCRIBE;
+
+    await apiv3Put('/page/subscribe', { pageId, status: newStatus });
+  }
+  catch (err) {
+    toastError(err);
+  }
+};
+
+export const toggleLike = async(pageId: string, currentValue?: boolean): Promise<void> => {
+  try {
+    await apiv3Put('/page/likes', { pageId, bool: !currentValue });
+  }
+  catch (err) {
+    toastError(err);
+  }
+};
+
+export const toggleBookmark = async(pageId: string, currentValue?: boolean): Promise<void> => {
+  try {
+    await apiv3Put('/bookmarks', { pageId, bool: !currentValue });
+  }
+  catch (err) {
+    toastError(err);
+  }
+};
+
+export const bookmark = async(pageId: string): Promise<void> => {
+  try {
+    await apiv3Put('/bookmarks', { pageId, bool: true });
+  }
+  catch (err) {
+    toastError(err);
+  }
+};
+
+export const unbookmark = async(pageId: string): Promise<void> => {
+  try {
+    await apiv3Put('/bookmarks', { pageId, bool: false });
+  }
+  catch (err) {
+    toastError(err);
+  }
+};
+
+export const exportAsMarkdown = (pageId: string, revisionId: string, format: string): void => {
+  const url = new URL(urljoin(window.location.origin, '_api/v3/page/export', pageId));
+  url.searchParams.append('format', format);
+  url.searchParams.append('revisionId', revisionId);
+  window.location.href = url.href;
+};

+ 2 - 2
packages/app/src/client/util/smooth-scroll.ts

@@ -1,6 +1,6 @@
 const WIKI_HEADER_LINK = 120;
 const WIKI_HEADER_LINK = 120;
 
 
-export const smoothScrollIntoView = (element: HTMLElement, offsetTop = 0): void => {
+export const smoothScrollIntoView = (element: HTMLElement, offsetTop = 0, scrollElement: HTMLElement | Window = window): void => {
   const targetElement = element || window.document.body;
   const targetElement = element || window.document.body;
 
 
   // get the distance to the target element top
   // get the distance to the target element top
@@ -8,7 +8,7 @@ export const smoothScrollIntoView = (element: HTMLElement, offsetTop = 0): void
 
 
   const top = window.pageYOffset + rectTop - offsetTop;
   const top = window.pageYOffset + rectTop - offsetTop;
 
 
-  window.scrollTo({
+  scrollElement.scrollTo({
     top,
     top,
     behavior: 'smooth',
     behavior: 'smooth',
   });
   });

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

+ 6 - 1
packages/app/src/components/Admin/ImportData/GrowiArchive/ImportForm.jsx

@@ -287,7 +287,9 @@ class ImportForm extends React.Component {
   }
   }
 
 
   async import() {
   async import() {
-    const { appContainer, fileName, onPostImport } = this.props;
+    const {
+      appContainer, fileName, onPostImport, t,
+    } = this.props;
     const { selectedCollections, optionsMap } = this.state;
     const { selectedCollections, optionsMap } = this.state;
 
 
     // init progress data
     // init progress data
@@ -312,6 +314,9 @@ class ImportForm extends React.Component {
       toastSuccess(undefined, 'Import process has requested.');
       toastSuccess(undefined, 'Import process has requested.');
     }
     }
     catch (err) {
     catch (err) {
+      if (err.code === 'only_upsert_available') {
+        toastError(t('admin:importer_management.error.only_upsert_available'));
+      }
       toastError(err, 'Import request failed.');
       toastError(err, 'Import request failed.');
     }
     }
   }
   }

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

@@ -157,10 +157,10 @@ class SecuritySetting extends React.Component {
                 aria-expanded="true"
                 aria-expanded="true"
               >
               >
                 <span className="float-left">
                 <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 === '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>
                 </span>
               </button>
               </button>
               <div className="dropdown-menu" aria-labelledby="dropdownMenuButton">
               <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 PropTypes from 'prop-types';
 import { withTranslation } from 'react-i18next';
 import { withTranslation } from 'react-i18next';
 
 
-import Page from '../../PageList/Page';
+import PageListItemS from '../../PageList/PageListItemS';
 import PaginationWrapper from '../../PaginationWrapper';
 import PaginationWrapper from '../../PaginationWrapper';
 import { withUnstatedContainers } from '../../UnstatedUtils';
 import { withUnstatedContainers } from '../../UnstatedUtils';
 import AppContainer from '~/client/services/AppContainer';
 import AppContainer from '~/client/services/AppContainer';
@@ -57,7 +57,7 @@ class UserGroupPageList extends React.Component {
     return (
     return (
       <Fragment>
       <Fragment>
         <ul className="page-list-ul page-list-ul-flat mb-3">
         <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>
         </ul>
         {relatedPages.length === 0 ? <p>{t('admin:user_group_management.no_pages')}</p> : (
         {relatedPages.length === 0 ? <p>{t('admin:user_group_management.no_pages')}</p> : (
           <PaginationWrapper
           <PaginationWrapper

+ 20 - 14
packages/app/src/components/BookmarkButtons.tsx

@@ -9,16 +9,20 @@ import UserPictureList from './User/UserPictureList';
 import { useIsGuestUser } from '~/stores/context';
 import { useIsGuestUser } from '~/stores/context';
 
 
 interface Props {
 interface Props {
+  bookmarkCount?: number
+  isBookmarked?: boolean
+  bookmarkedUsers?: IUser[]
   hideTotalNumber?: boolean
   hideTotalNumber?: boolean
-  isBookmarked: boolean
-  sumOfBookmarks: number
-  bookmarkedUsers: IUser[]
   onBookMarkClicked: ()=>void;
   onBookMarkClicked: ()=>void;
 }
 }
 
 
 const BookmarkButtons: FC<Props> = (props: Props) => {
 const BookmarkButtons: FC<Props> = (props: Props) => {
   const { t } = useTranslation();
   const { t } = useTranslation();
 
 
+  const {
+    bookmarkCount, isBookmarked, bookmarkedUsers, hideTotalNumber,
+  } = props;
+
   const [isPopoverOpen, setIsPopoverOpen] = useState(false);
   const [isPopoverOpen, setIsPopoverOpen] = useState(false);
 
 
   const { data: isGuestUser } = useIsGuestUser();
   const { data: isGuestUser } = useIsGuestUser();
@@ -40,9 +44,9 @@ const BookmarkButtons: FC<Props> = (props: Props) => {
         id="bookmark-button"
         id="bookmark-button"
         onClick={handleClick}
         onClick={handleClick}
         className={`btn btn-bookmark border-0
         className={`btn btn-bookmark border-0
-          ${props.isBookmarked ? 'active' : ''} ${isGuestUser ? 'disabled' : ''}`}
+          ${isBookmarked ? 'active' : ''} ${isGuestUser ? 'disabled' : ''}`}
       >
       >
-        <i className={`fa ${props.isBookmarked ? 'fa-bookmark' : 'fa-bookmark-o'}`}></i>
+        <i className={`fa ${isBookmarked ? 'fa-bookmark' : 'fa-bookmark-o'}`}></i>
       </button>
       </button>
 
 
       {isGuestUser && (
       {isGuestUser && (
@@ -51,18 +55,20 @@ const BookmarkButtons: FC<Props> = (props: Props) => {
         </UncontrolledTooltip>
         </UncontrolledTooltip>
       )}
       )}
 
 
-      { !props.hideTotalNumber && (
+      { !hideTotalNumber && (
         <>
         <>
           <button type="button" id="po-total-bookmarks" className={`btn btn-bookmark border-0 total-bookmarks ${props.isBookmarked ? 'active' : ''}`}>
           <button type="button" id="po-total-bookmarks" className={`btn btn-bookmark border-0 total-bookmarks ${props.isBookmarked ? 'active' : ''}`}>
-            {props.sumOfBookmarks}
+            {bookmarkCount ?? 0}
           </button>
           </button>
-          <Popover placement="bottom" isOpen={isPopoverOpen} target="po-total-bookmarks" toggle={togglePopover} trigger="legacy">
-            <PopoverBody className="seen-user-popover">
-              <div className="px-2 text-right user-list-content text-truncate text-muted">
-                {props.bookmarkedUsers.length ? <UserPictureList users={props.bookmarkedUsers} /> : t('No users have bookmarked yet')}
-              </div>
-            </PopoverBody>
-          </Popover>
+          { bookmarkedUsers != null && (
+            <Popover placement="bottom" isOpen={isPopoverOpen} target="po-total-bookmarks" toggle={togglePopover} trigger="legacy">
+              <PopoverBody className="user-list-popover">
+                <div className="px-2 text-right user-list-content text-truncate text-muted">
+                  {bookmarkedUsers.length ? <UserPictureList users={props.bookmarkedUsers} /> : t('No users have bookmarked yet')}
+                </div>
+              </PopoverBody>
+            </Popover>
+          ) }
         </>
         </>
       ) }
       ) }
     </div>
     </div>

+ 11 - 3
packages/app/src/components/Common/ClosableTextInput.tsx

@@ -17,9 +17,10 @@ export type AlertInfo = {
 
 
 type ClosableTextInputProps = {
 type ClosableTextInputProps = {
   isShown: boolean
   isShown: boolean
+  value?: string
   placeholder?: string
   placeholder?: string
   inputValidator?(text: string): AlertInfo | Promise<AlertInfo> | null
   inputValidator?(text: string): AlertInfo | Promise<AlertInfo> | null
-  onPressEnter?(): void
+  onPressEnter?(inputText: string | null): void
   onClickOutside?(): void
   onClickOutside?(): void
 }
 }
 
 
@@ -27,14 +28,18 @@ const ClosableTextInput: FC<ClosableTextInputProps> = memo((props: ClosableTextI
   const { t } = useTranslation();
   const { t } = useTranslation();
   const inputRef = useRef<HTMLInputElement>(null);
   const inputRef = useRef<HTMLInputElement>(null);
 
 
+  const [inputText, setInputText] = useState(props.value);
   const [currentAlertInfo, setAlertInfo] = useState<AlertInfo | null>(null);
   const [currentAlertInfo, setAlertInfo] = useState<AlertInfo | null>(null);
 
 
   const onChangeHandler = async(e) => {
   const onChangeHandler = async(e) => {
     if (props.inputValidator == null) { return }
     if (props.inputValidator == null) { return }
 
 
-    const alertInfo = await props.inputValidator(e.target.value);
+    const inputText = e.target.value;
+
+    const alertInfo = await props.inputValidator(inputText);
 
 
     setAlertInfo(alertInfo);
     setAlertInfo(alertInfo);
+    setInputText(inputText);
   };
   };
 
 
   const onPressEnter = () => {
   const onPressEnter = () => {
@@ -42,7 +47,9 @@ const ClosableTextInput: FC<ClosableTextInputProps> = memo((props: ClosableTextI
       return;
       return;
     }
     }
 
 
-    props.onPressEnter();
+    const text = inputText != null ? inputText.trim() : null;
+
+    props.onPressEnter(text);
   };
   };
 
 
   const onKeyDownHandler = (e) => {
   const onKeyDownHandler = (e) => {
@@ -94,6 +101,7 @@ const ClosableTextInput: FC<ClosableTextInputProps> = memo((props: ClosableTextI
   return (
   return (
     <div className={props.isShown ? 'd-block' : 'd-none'}>
     <div className={props.isShown ? 'd-block' : 'd-none'}>
       <input
       <input
+        value={inputText}
         ref={inputRef}
         ref={inputRef}
         type="text"
         type="text"
         className="form-control"
         className="form-control"

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

@@ -1,4 +1,4 @@
-import React, { FC, useState, useCallback } from 'react';
+import React, { useState, useCallback } from 'react';
 import {
 import {
   Dropdown, DropdownMenu, DropdownToggle, DropdownItem,
   Dropdown, DropdownMenu, DropdownToggle, DropdownItem,
 } from 'reactstrap';
 } from 'reactstrap';
@@ -6,132 +6,254 @@ import {
 import toastr from 'toastr';
 import toastr from 'toastr';
 import { useTranslation } from 'react-i18next';
 import { useTranslation } from 'react-i18next';
 
 
-import { IPageHasId } from '~/interfaces/page';
-import { apiv3Put } from '~/client/util/apiv3-client';
-import { toastError } from '~/client/util/apiNotification';
-import { useSWRBookmarkInfo } from '~/stores/bookmark';
-
-type PageItemControlProps = {
-  page: Partial<IPageHasId>
-  isEnableActions: boolean
-  isDeletable: boolean
-  onClickDeleteButtonHandler?: (pageId: string) => void
-  onClickRenameButtonHandler?: (pageId: string) => void
+import loggerFactory from '~/utils/logger';
+
+import {
+  IPageInfoAll, isIPageInfoForOperation,
+} from '~/interfaces/page';
+import { useSWRxPageInfo } from '~/stores/page';
+
+const logger = loggerFactory('growi:cli:PageItemControl');
+
+
+export type AdditionalMenuItemsRendererProps = { pageInfo: IPageInfoAll };
+
+type CommonProps = {
+  pageInfo?: IPageInfoAll,
+  isEnableActions?: boolean,
+  showBookmarkMenuItem?: boolean,
+  onClickBookmarkMenuItem?: (pageId: string, newValue?: boolean) => Promise<void>,
+  onClickDuplicateMenuItem?: () => Promise<void> | void,
+  onClickRenameMenuItem?: (pageId: string) => Promise<void> | void,
+  onClickDeleteMenuItem?: (pageId: string) => void,
+
+  additionalMenuItemRenderer?: React.FunctionComponent<AdditionalMenuItemsRendererProps>,
 }
 }
 
 
-const PageItemControl: FC<PageItemControlProps> = (props: PageItemControlProps) => {
+
+type DropdownMenuProps = CommonProps & {
+  pageId: string,
+  isLoading?: boolean,
+}
+
+const PageItemControlDropdownMenu = React.memo((props: DropdownMenuProps): JSX.Element => {
+  const { t } = useTranslation('');
 
 
   const {
   const {
-    page, isEnableActions, onClickDeleteButtonHandler, isDeletable, onClickRenameButtonHandler,
+    pageId, isLoading,
+    pageInfo, isEnableActions, showBookmarkMenuItem,
+    onClickBookmarkMenuItem, onClickDuplicateMenuItem, onClickRenameMenuItem, onClickDeleteMenuItem,
+    additionalMenuItemRenderer: AdditionalMenuItems,
   } = props;
   } = props;
-  const { t } = useTranslation('');
-  const [isOpen, setIsOpen] = useState(false);
-  const { data: bookmarkInfo, error: bookmarkInfoError, mutate: mutateBookmarkInfo } = useSWRBookmarkInfo(page._id, isOpen);
 
 
-  const deleteButtonClickedHandler = useCallback(() => {
-    if (onClickDeleteButtonHandler != null && page._id != null) {
-      onClickDeleteButtonHandler(page._id);
-    }
-  }, [onClickDeleteButtonHandler, page._id]);
 
 
-  const renameButtonClickedHandler = useCallback(() => {
-    if (onClickRenameButtonHandler != null && page._id != null) {
-      onClickRenameButtonHandler(page._id);
+  // eslint-disable-next-line react-hooks/rules-of-hooks
+  const bookmarkItemClickedHandler = useCallback(async() => {
+    if (!isIPageInfoForOperation(pageInfo) || onClickBookmarkMenuItem == null) {
+      return;
     }
     }
-  }, [onClickRenameButtonHandler, page._id]);
-
+    await onClickBookmarkMenuItem(pageId, !pageInfo.isBookmarked);
+  }, [onClickBookmarkMenuItem, pageId, pageInfo]);
 
 
-  const bookmarkToggleHandler = (async() => {
-    try {
-      // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
-      await apiv3Put('/bookmarks', { pageId: page._id, bool: !bookmarkInfo!.isBookmarked });
-      mutateBookmarkInfo();
+  // eslint-disable-next-line react-hooks/rules-of-hooks
+  const duplicateItemClickedHandler = useCallback(async() => {
+    if (onClickDuplicateMenuItem == null) {
+      return;
     }
     }
-    catch (err) {
-      toastError(err);
-    }
-  });
+    await onClickDuplicateMenuItem();
+  }, [onClickDuplicateMenuItem]);
 
 
-  const renderBookmarkText = () => {
-    if (bookmarkInfoError != null || bookmarkInfo == null) {
-      return '';
+  // eslint-disable-next-line react-hooks/rules-of-hooks
+  const renameItemClickedHandler = useCallback(async() => {
+    if (onClickRenameMenuItem == null) {
+      return;
     }
     }
-    return bookmarkInfo.isBookmarked ? t('remove_bookmark') : t('add_bookmark');
-  };
-
+    await onClickRenameMenuItem(pageId);
+  }, [onClickRenameMenuItem, pageId]);
 
 
-  const dropdownToggle = () => {
-    setIsOpen(!isOpen);
-  };
+  // eslint-disable-next-line react-hooks/rules-of-hooks
+  const deleteItemClickedHandler = useCallback(async() => {
+    if (pageInfo == null || onClickDeleteMenuItem == null) {
+      return;
+    }
+    if (!pageInfo.isDeletable) {
+      logger.warn('This page could not be deleted.');
+      return;
+    }
+    await onClickDeleteMenuItem(pageId);
+  }, [onClickDeleteMenuItem, pageId, pageInfo]);
 
 
+  let contents = <></>;
 
 
-  return (
-    <Dropdown isOpen={isOpen} toggle={dropdownToggle}>
-      <DropdownToggle color="transparent" className="btn-link border-0 rounded grw-btn-page-management p-0">
-        <i className="icon-options fa fa-rotate-90 text-muted p-1"></i>
-      </DropdownToggle>
-      <DropdownMenu positionFixed modifiers={{ preventOverflow: { boundariesElement: undefined } }}>
-
-        {/* TODO: if there is the following button in XD add it here
-        <button
-          type="button"
-          className="btn btn-link p-0"
-          value={page.path}
-          onClick={(e) => {
-            window.location.href = e.currentTarget.value;
-          }}
-        >
-          <i className="icon-login" />
-        </button>
-        */}
-
-        {/*
-          TODO: add function to the following buttons like using modal or others
-          ref: https://estoc.weseek.co.jp/redmine/issues/79026
-        */}
-
-        {/* TODO: show dropdown when permalink section is implemented */}
-
-        {!isEnableActions && (
+  if (isLoading) {
+    contents = (
+      <div className="text-muted text-center my-2">
+        <i className="fa fa-spinner fa-pulse"></i>
+      </div>
+    );
+  }
+  else if (pageId != null && pageInfo != null) {
+    contents = (
+      <>
+        { !isEnableActions && (
           <DropdownItem>
           <DropdownItem>
             <p>
             <p>
               {t('search_result.currently_not_implemented')}
               {t('search_result.currently_not_implemented')}
             </p>
             </p>
           </DropdownItem>
           </DropdownItem>
-        )}
-        {isEnableActions && (
-          <DropdownItem onClick={bookmarkToggleHandler}>
+        ) }
+
+        {/* Bookmark */}
+        { showBookmarkMenuItem && isEnableActions && !pageInfo.isEmpty && isIPageInfoForOperation(pageInfo) && (
+          <DropdownItem onClick={bookmarkItemClickedHandler}>
             <i className="fa fa-fw fa-bookmark-o"></i>
             <i className="fa fa-fw fa-bookmark-o"></i>
-            {renderBookmarkText()}
+            { pageInfo.isBookmarked ? t('remove_bookmark') : t('add_bookmark') }
           </DropdownItem>
           </DropdownItem>
-        )}
-        {isEnableActions && (
-          <DropdownItem onClick={() => toastr.warning(t('search_result.currently_not_implemented'))}>
+        ) }
+
+        {/* Duplicate */}
+        { isEnableActions && !pageInfo.isEmpty && (
+          <DropdownItem onClick={duplicateItemClickedHandler}>
             <i className="icon-fw icon-docs"></i>
             <i className="icon-fw icon-docs"></i>
             {t('Duplicate')}
             {t('Duplicate')}
           </DropdownItem>
           </DropdownItem>
-        )}
-        {isEnableActions && (
-          <DropdownItem onClick={renameButtonClickedHandler}>
+        ) }
+
+        {/* Move/Rename */}
+        { isEnableActions && pageInfo.isMovable && (
+          <DropdownItem onClick={renameItemClickedHandler}>
             <i className="icon-fw  icon-action-redo"></i>
             <i className="icon-fw  icon-action-redo"></i>
             {t('Move/Rename')}
             {t('Move/Rename')}
           </DropdownItem>
           </DropdownItem>
-        )}
-        {isDeletable && isEnableActions && (
+        ) }
+
+        { AdditionalMenuItems && <AdditionalMenuItems pageInfo={pageInfo} /> }
+
+        {/* divider */}
+        {/* Delete */}
+        { isEnableActions && pageInfo.isMovable && !pageInfo.isEmpty && (
           <>
           <>
             <DropdownItem divider />
             <DropdownItem divider />
-            <DropdownItem className="text-danger pt-2" onClick={deleteButtonClickedHandler}>
+            <DropdownItem
+              className={`pt-2 ${pageInfo.isDeletable ? 'text-danger' : ''}`}
+              disabled={!pageInfo.isDeletable}
+              onClick={deleteItemClickedHandler}
+            >
               <i className="icon-fw icon-trash"></i>
               <i className="icon-fw icon-trash"></i>
               {t('Delete')}
               {t('Delete')}
             </DropdownItem>
             </DropdownItem>
           </>
           </>
         )}
         )}
-      </DropdownMenu>
+      </>
+    );
+  }
+
+  return (
+    <DropdownMenu positionFixed modifiers={{ preventOverflow: { boundariesElement: undefined } }}>
+      {contents}
+    </DropdownMenu>
+  );
+});
 
 
 
 
+type PageItemControlSubstanceProps = CommonProps & {
+  pageId: string,
+  fetchOnInit?: boolean,
+  children?: React.ReactNode,
+}
+
+export const PageItemControlSubstance = (props: PageItemControlSubstanceProps): JSX.Element => {
+
+  const {
+    pageId, pageInfo: presetPageInfo, fetchOnInit,
+    children,
+    onClickBookmarkMenuItem, onClickDuplicateMenuItem, onClickRenameMenuItem,
+  } = props;
+
+  const [isOpen, setIsOpen] = useState(false);
+
+  const shouldFetch = fetchOnInit === true || (!isIPageInfoForOperation(presetPageInfo) && isOpen);
+  const shouldMutate = fetchOnInit === true || !isIPageInfoForOperation(presetPageInfo);
+
+  const { data: fetchedPageInfo, mutate: mutatePageInfo } = useSWRxPageInfo(shouldFetch ? pageId : null);
+
+  // mutate after handle event
+  const bookmarkMenuItemClickHandler = useCallback(async(_pageId: string, _newValue: boolean) => {
+    if (onClickBookmarkMenuItem != null) {
+      await onClickBookmarkMenuItem(_pageId, _newValue);
+    }
+
+    if (shouldMutate) {
+      mutatePageInfo();
+    }
+  }, [mutatePageInfo, onClickBookmarkMenuItem, shouldMutate]);
+
+  const isLoading = shouldFetch && fetchedPageInfo == null;
+
+  const duplicateMenuItemClickHandler = useCallback(async() => {
+    if (onClickDuplicateMenuItem == null) {
+      return;
+    }
+    await onClickDuplicateMenuItem();
+  }, [onClickDuplicateMenuItem]);
+
+  const renameMenuItemClickHandler = useCallback(async() => {
+    if (onClickRenameMenuItem == null) {
+      return;
+    }
+    await onClickRenameMenuItem(pageId);
+  }, [onClickRenameMenuItem, pageId]);
+
+  return (
+    <Dropdown isOpen={isOpen} toggle={() => setIsOpen(!isOpen)}>
+
+      { children ?? (
+        <DropdownToggle color="transparent" className="border-0 rounded btn-page-item-control">
+          <i className="icon-options text-muted"></i>
+        </DropdownToggle>
+      ) }
+
+      <PageItemControlDropdownMenu
+        {...props}
+        isLoading={isLoading}
+        pageInfo={fetchedPageInfo ?? presetPageInfo}
+        onClickBookmarkMenuItem={bookmarkMenuItemClickHandler}
+        onClickDuplicateMenuItem={duplicateMenuItemClickHandler}
+        onClickRenameMenuItem={renameMenuItemClickHandler}
+      />
     </Dropdown>
     </Dropdown>
   );
   );
 
 
 };
 };
 
 
-export default PageItemControl;
+
+type PageItemControlProps = CommonProps & {
+  pageId?: string,
+  children?: React.ReactNode,
+}
+
+export const PageItemControl = (props: PageItemControlProps): JSX.Element => {
+  const { pageId } = props;
+
+  if (pageId == null) {
+    return <></>;
+  }
+
+  return <PageItemControlSubstance pageId={pageId} {...props} />;
+};
+
+
+type AsyncPageItemControlProps = Omit<CommonProps, 'pageInfo'> & {
+  pageId?: string,
+  children?: React.ReactNode,
+}
+
+export const AsyncPageItemControl = (props: AsyncPageItemControlProps): JSX.Element => {
+  const { pageId } = props;
+
+  if (pageId == null) {
+    return <></>;
+  }
+
+  return <PageItemControlSubstance pageId={pageId} fetchOnInit {...props} />;
+};

+ 0 - 96
packages/app/src/components/ContentLinkButtons.jsx

@@ -1,96 +0,0 @@
-import React, { useMemo } from 'react';
-import PropTypes from 'prop-types';
-
-import { pagePathUtils } from '@growi/core';
-import AppContainer from '~/client/services/AppContainer';
-import PageContainer from '~/client/services/PageContainer';
-
-import { withUnstatedContainers } from './UnstatedUtils';
-
-import RecentlyCreatedIcon from './Icons/RecentlyCreatedIcon';
-import { smoothScrollIntoView } from '~/client/util/smooth-scroll';
-
-const { isTopPage } = pagePathUtils;
-
-const WIKI_HEADER_LINK = 120;
-
-/**
- * @author Yuki Takei <yuki@weseek.co.jp>
- *
- */
-const ContentLinkButtons = (props) => {
-
-  const { appContainer, pageContainer } = props;
-  const { pageUser, path } = pageContainer.state;
-  const { isPageExist } = pageContainer.state;
-  const { isSharedUser } = appContainer;
-
-  const isTopPagePath = isTopPage(path);
-
-  // get element for smoothScroll
-  const getCommentListDom = useMemo(() => { return document.getElementById('page-comments-list') }, []);
-  const getBookMarkListHeaderDom = useMemo(() => { return document.getElementById('bookmarks-list') }, []);
-  const getRecentlyCreatedListHeaderDom = useMemo(() => { return document.getElementById('recently-created-list') }, []);
-
-
-  const CommentLinkButton = () => {
-    return (
-      <div className="mt-3">
-        <button
-          type="button"
-          className="btn btn-outline-secondary btn-sm btn-block"
-          onClick={() => smoothScrollIntoView(getCommentListDom, WIKI_HEADER_LINK)}
-        >
-          <i className="mr-2 icon-fw icon-bubbles"></i>
-          <span>Comments</span>
-        </button>
-      </div>
-    );
-  };
-
-  const BookMarkLinkButton = () => {
-    return (
-      <button
-        type="button"
-        className="btn btn-outline-secondary btn-sm px-2"
-        onClick={() => smoothScrollIntoView(getBookMarkListHeaderDom, WIKI_HEADER_LINK)}
-      >
-        <i className="fa fa-fw fa-bookmark-o"></i>
-        <span>Bookmarks</span>
-      </button>
-
-    );
-  };
-
-  const RecentlyCreatedLinkButton = () => {
-    return (
-      <button
-        type="button"
-        className="btn btn-outline-secondary btn-sm px-3"
-        onClick={() => smoothScrollIntoView(getRecentlyCreatedListHeaderDom, WIKI_HEADER_LINK)}
-      >
-        <i className="grw-icon-container-recently-created mr-2"><RecentlyCreatedIcon /></i>
-        <span>Recently Created</span>
-      </button>
-
-    );
-  };
-
-  return (
-    <>
-      {isPageExist && !isSharedUser && !isTopPagePath && <CommentLinkButton />}
-
-      <div className="mt-3 d-flex justify-content-between">
-        {pageUser && <><BookMarkLinkButton /><RecentlyCreatedLinkButton /></>}
-      </div>
-    </>
-  );
-
-};
-
-ContentLinkButtons.propTypes = {
-  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
-  pageContainer: PropTypes.instanceOf(PageContainer).isRequired,
-};
-
-export default withUnstatedContainers(ContentLinkButtons, [AppContainer, PageContainer]);

+ 66 - 0
packages/app/src/components/ContentLinkButtons.tsx

@@ -0,0 +1,66 @@
+import React, { useCallback, useMemo } from 'react';
+
+import RecentlyCreatedIcon from './Icons/RecentlyCreatedIcon';
+import { smoothScrollIntoView } from '~/client/util/smooth-scroll';
+import { usePageUser } from '~/stores/context';
+
+const WIKI_HEADER_LINK = 120;
+
+
+const ContentLinkButtons = (): JSX.Element => {
+
+  const { data: pageUser } = usePageUser();
+
+  // get element for smoothScroll
+  const getBookMarkListHeaderDom = useMemo(() => { return document.getElementById('bookmarks-list') }, []);
+  const getRecentlyCreatedListHeaderDom = useMemo(() => { return document.getElementById('recently-created-list') }, []);
+
+
+  const BookMarkLinkButton = useCallback((): JSX.Element => {
+    if (getBookMarkListHeaderDom == null) {
+      return <></>;
+    }
+
+    return (
+      <button
+        type="button"
+        className="btn btn-outline-secondary btn-sm px-2"
+        onClick={() => smoothScrollIntoView(getBookMarkListHeaderDom, WIKI_HEADER_LINK)}
+      >
+        <i className="fa fa-fw fa-bookmark-o"></i>
+        <span>Bookmarks</span>
+      </button>
+    );
+  }, [getBookMarkListHeaderDom]);
+
+  const RecentlyCreatedLinkButton = useCallback(() => {
+    if (getRecentlyCreatedListHeaderDom == null) {
+      return <></>;
+    }
+
+    return (
+      <button
+        type="button"
+        className="btn btn-outline-secondary btn-sm px-3"
+        onClick={() => smoothScrollIntoView(getRecentlyCreatedListHeaderDom, WIKI_HEADER_LINK)}
+      >
+        <i className="grw-icon-container-recently-created mr-2"><RecentlyCreatedIcon /></i>
+        <span>Recently Created</span>
+      </button>
+    );
+  }, [getRecentlyCreatedListHeaderDom]);
+
+  if (pageUser == null) {
+    return <></>;
+  }
+
+  return (
+    <div className="mt-3 d-flex justify-content-between">
+      <BookMarkLinkButton />
+      <RecentlyCreatedLinkButton />
+    </div>
+  );
+
+};
+
+export default ContentLinkButtons;

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

@@ -0,0 +1,106 @@
+import React, { useState } from 'react';
+import {
+  IPageHasId, IPageWithMeta,
+} from '~/interfaces/page';
+import { IPagingResult } from '~/interfaces/paging-result';
+import { useIsGuestUser, useIsSharedUser } from '~/stores/context';
+
+import { useSWRxPageInfoForList, useSWRxPageList } from '~/stores/page';
+
+import PageList from './PageList/PageList';
+import PaginationWrapper from './PaginationWrapper';
+
+type Props = {
+  path: string,
+}
+
+
+const convertToIPageWithEmptyMeta = (page: IPageHasId): IPageWithMeta => {
+  return { pageData: page };
+};
+
+const DescendantsPageList = (props: Props): JSX.Element => {
+  const { path } = props;
+
+  const [activePage, setActivePage] = useState(1);
+
+  const { data: isGuestUser } = useIsGuestUser();
+  const { data: isSharedUser } = useIsSharedUser();
+
+  const { data: pagingResult, error } = useSWRxPageList(isSharedUser ? null : path, activePage);
+
+  const pageIds = pagingResult?.items?.map(page => page._id);
+  const { data: idToPageInfo } = useSWRxPageInfoForList(pageIds);
+
+  let pagingResultWithMeta: IPagingResult<IPageWithMeta> | undefined;
+
+  // initial data
+  if (pagingResult != null) {
+    const pages = pagingResult.items;
+
+    // convert without meta at first
+    pagingResultWithMeta = {
+      ...pagingResult,
+      items: pages.map(page => convertToIPageWithEmptyMeta(page)),
+    };
+  }
+
+  // inject data for listing
+  if (pagingResult != null) {
+    const pages = pagingResult.items;
+
+    const pageWithMetas = pages.map((page) => {
+      const pageInfo = (idToPageInfo ?? {})[page._id];
+
+      return {
+        pageData: page,
+        pageMeta: pageInfo,
+      } as IPageWithMeta;
+    });
+
+    pagingResultWithMeta = {
+      ...pagingResult,
+      items: pageWithMetas,
+    };
+  }
+
+  function setPageNumber(selectedPageNumber) {
+    setActivePage(selectedPageNumber);
+  }
+
+  if (error != null) {
+    return (
+      <div className="my-5">
+        <div className="text-danger">{error.message}</div>
+      </div>
+    );
+  }
+
+  if (pagingResult == null || pagingResultWithMeta == 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>
+    );
+  }
+
+  return (
+    <>
+      <PageList pages={pagingResultWithMeta} isEnableActions={!isGuestUser} />
+
+      <div className="my-4">
+        <PaginationWrapper
+          activePage={activePage}
+          changePage={setPageNumber}
+          totalItemsCount={pagingResult.totalCount}
+          pagingLimit={pagingResult.limit}
+          align="center"
+        />
+      </div>
+    </>
+  );
+};
+
+export default DescendantsPageList;

+ 100 - 0
packages/app/src/components/DescendantsPageListModal.tsx

@@ -0,0 +1,100 @@
+
+import React, { useState, useMemo } from 'react';
+import { useTranslation } from 'react-i18next';
+
+import {
+  Modal, ModalHeader, ModalBody,
+} from 'reactstrap';
+
+import { useDescendantsPageListModal } from '~/stores/ui';
+import { useIsSharedUser } from '~/stores/context';
+
+import DescendantsPageList from './DescendantsPageList';
+import ExpandOrContractButton from './ExpandOrContractButton';
+import { CustomNavTab } from './CustomNavigation/CustomNav';
+import PageListIcon from './Icons/PageListIcon';
+import TimeLineIcon from './Icons/TimeLineIcon';
+import CustomTabContent from './CustomNavigation/CustomTabContent';
+import PageTimeline from './PageTimeline';
+
+
+type Props = {
+}
+
+export const DescendantsPageListModal = (props: Props): JSX.Element => {
+  const { t } = useTranslation();
+
+  const [activeTab, setActiveTab] = useState('pagelist');
+  const [isWindowExpanded, setIsWindowExpanded] = useState(false);
+
+  const { data: isSharedUser } = useIsSharedUser();
+
+  const { data: status, close } = useDescendantsPageListModal();
+
+  const navTabMapping = useMemo(() => {
+    return {
+      pagelist: {
+        Icon: PageListIcon,
+        Content: () => {
+          if (status == null || status.path == null || !status.isOpened) {
+            return <></>;
+          }
+          return <DescendantsPageList path={status.path} />;
+        },
+        i18n: t('page_list'),
+        index: 0,
+        isLinkEnabled: () => !isSharedUser,
+      },
+      timeline: {
+        Icon: TimeLineIcon,
+        Content: () => <PageTimeline />,
+        i18n: t('Timeline View'),
+        index: 1,
+        isLinkEnabled: () => !isSharedUser,
+      },
+    };
+  }, [isSharedUser, status, t]);
+
+  const buttons = useMemo(() => (
+    <div className="d-flex flex-nowrap">
+      <ExpandOrContractButton
+        isWindowExpanded={isWindowExpanded}
+        expandWindow={() => setIsWindowExpanded(true)}
+        contractWindow={() => setIsWindowExpanded(false)}
+      />
+      <button type="button" className="close" onClick={close} aria-label="Close">
+        <span aria-hidden="true">&times;</span>
+      </button>
+    </div>
+  ), [close, isWindowExpanded]);
+
+
+  if (status == null) {
+    return <></>;
+  }
+
+  const { isOpened } = status;
+
+  return (
+    <Modal
+      size="xl"
+      isOpen={isOpened}
+      toggle={close}
+      className={`grw-page-accessories-modal ${isWindowExpanded ? 'grw-modal-expanded' : ''} `}
+    >
+      <ModalHeader className="p-0" toggle={close} close={buttons}>
+        <CustomNavTab
+          activeTab={activeTab}
+          navTabMapping={navTabMapping}
+          breakpointToHideInactiveTabsDown="md"
+          onNavSelected={v => setActiveTab(v)}
+          hideBorderBottom
+        />
+      </ModalHeader>
+      <ModalBody>
+        <CustomTabContent activeTab={activeTab} navTabMapping={navTabMapping} />
+      </ModalBody>
+    </Modal>
+  );
+
+};

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

+ 3 - 3
packages/app/src/components/EventListeneres/HashChanged.tsx

@@ -1,4 +1,4 @@
-import { FC, useCallback, useEffect } from 'react';
+import React, { useCallback, useEffect } from 'react';
 
 
 import { useEditorMode, determineEditorModeByHash } from '~/stores/ui';
 import { useEditorMode, determineEditorModeByHash } from '~/stores/ui';
 import { useIsEditable } from '~/stores/context';
 import { useIsEditable } from '~/stores/context';
@@ -6,7 +6,7 @@ import { useIsEditable } from '~/stores/context';
 /**
 /**
  * Change editorMode by browser forward/back operation
  * Change editorMode by browser forward/back operation
  */
  */
-const HashChanged: FC<void> = () => {
+const HashChanged = (): JSX.Element => {
   const { data: isEditable } = useIsEditable();
   const { data: isEditable } = useIsEditable();
   const { data: editorMode, mutate: mutateEditorMode } = useEditorMode();
   const { data: editorMode, mutate: mutateEditorMode } = useEditorMode();
 
 
@@ -33,7 +33,7 @@ const HashChanged: FC<void> = () => {
 
 
   }, [hashchangeHandler, isEditable, mutateEditorMode]);
   }, [hashchangeHandler, isEditable, mutateEditorMode]);
 
 
-  return null;
+  return <></>;
 };
 };
 
 
 export default HashChanged;
 export default HashChanged;

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

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

+ 2 - 1
packages/app/src/components/Icons/AttachmentIcon.jsx

@@ -4,7 +4,8 @@ const Attachment = () => (
   <svg
   <svg
     xmlns="http://www.w3.org/2000/svg"
     xmlns="http://www.w3.org/2000/svg"
     viewBox="0 0 14 14"
     viewBox="0 0 14 14"
-
+    width="14px"
+    height="14px"
   >
   >
     <rect width="14" height="14" fillOpacity="0" />
     <rect width="14" height="14" fillOpacity="0" />
     <g className="cls-1">
     <g className="cls-1">

+ 2 - 1
packages/app/src/components/Icons/HistoryIcon.jsx

@@ -4,7 +4,8 @@ const RecentChanges = () => (
   <svg
   <svg
     xmlns="http://www.w3.org/2000/svg"
     xmlns="http://www.w3.org/2000/svg"
     viewBox="0 0 14 14"
     viewBox="0 0 14 14"
-
+    width="14px"
+    height="14px"
   >
   >
     <rect width="14" height="14" fillOpacity="0" />
     <rect width="14" height="14" fillOpacity="0" />
     <path
     <path

+ 1 - 1
packages/app/src/components/Icons/ShareLinkIcon.jsx

@@ -1,7 +1,7 @@
 import React from 'react';
 import React from 'react';
 
 
 const ShareLink = () => (
 const ShareLink = () => (
-  <svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 20 20">
+  <svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 20 20">
     <g transform="translate(-142 -502)">
     <g transform="translate(-142 -502)">
       <rect width="20" height="20" transform="translate(142 502)" fill="none" />
       <rect width="20" height="20" transform="translate(142 502)" fill="none" />
       <g transform="translate(16 286.938)">
       <g transform="translate(16 286.938)">

+ 117 - 5
packages/app/src/components/IdenticalPathPage.tsx

@@ -1,14 +1,126 @@
 import React, { FC } from 'react';
 import React, { FC } from 'react';
+import { useTranslation } from 'react-i18next';
+
+import { DevidedPagePath } from '@growi/core';
+
+import { IPageHasId, IPageWithMeta } from '~/interfaces/page';
+import { useCurrentPagePath, useIsSharedUser } from '~/stores/context';
+import { useDescendantsPageListModal } from '~/stores/ui';
+import { useSWRxPageInfoForList } from '~/stores/page';
+
+import PageListIcon from './Icons/PageListIcon';
+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 />
+        <span
+          // 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= {
 type IdenticalPathPageProps= {
   // add props and types here
   // add props and types here
 }
 }
-const IdenticalPathPage:FC<IdenticalPathPageProps> = (props:IdenticalPathPageProps) => {
+
+
+const jsonNull = 'null';
+
+const IdenticalPathPage:FC<IdenticalPathPageProps> = (props: IdenticalPathPageProps) => {
+  const { t } = useTranslation();
+
+  const identicalPageDocument = document.getElementById('identical-path-page');
+  const pages = JSON.parse(identicalPageDocument?.getAttribute('data-identical-path-pages') || jsonNull) as IPageHasId[];
+
+  const pageIds = pages.map(page => page._id) as string[];
+
+
+  const { data: currentPath } = useCurrentPagePath();
+  const { data: isSharedUser } = useIsSharedUser();
+
+  const { data: idToPageInfoMap } = useSWRxPageInfoForList(pageIds);
+
+  const { open: openDescendantPageListModal } = useDescendantsPageListModal();
+
   return (
   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-page-accessories-control pb-1">
+          { currentPath != null && !isSharedUser && (
+            <button
+              type="button"
+              className="btn btn-block btn-outline-secondary grw-btn-page-accessories rounded-pill d-flex justify-content-between"
+              onClick={() => openDescendantPageListModal(currentPath)}
+            >
+              <PageListIcon />
+              {t('page_list')}
+              <span></span> {/* for a count badge */}
+            </button>
+          ) }
+        </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 list-group-flush">
+            {pages.map((page) => {
+              const pageId = page._id;
+              const pageInfo = (idToPageInfoMap ?? {})[pageId];
+
+              const pageWithMeta: IPageWithMeta = {
+                pageData: page,
+                pageMeta: pageInfo,
+              };
+
+              return (
+                <PageListItemL
+                  key={pageId}
+                  page={pageWithMeta}
+                  isSelected={false}
+                  isChecked={false}
+                  isEnableActions
+                  showPageUpdatedTime
+                // Todo: add onClickDeleteButton when delete feature implemented
+                />
+              );
+            })}
+          </ul>
+        </div>
+
+      </div>
+
     </div>
     </div>
   );
   );
 };
 };

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

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

+ 7 - 15
packages/app/src/components/LikeButtons.tsx

@@ -9,12 +9,13 @@ import AppContainer from '~/client/services/AppContainer';
 import { IUser } from '../interfaces/user';
 import { IUser } from '../interfaces/user';
 
 
 type LikeButtonsProps = {
 type LikeButtonsProps = {
-  appContainer: AppContainer,
 
 
   hideTotalNumber?: boolean,
   hideTotalNumber?: boolean,
   sumOfLikers: number,
   sumOfLikers: number,
-  isLiked: boolean,
   likers: IUser[],
   likers: IUser[],
+
+  isGuestUser?: boolean,
+  isLiked?: boolean,
   onLikeClicked?: ()=>void,
   onLikeClicked?: ()=>void,
 }
 }
 
 
@@ -27,31 +28,22 @@ const LikeButtons: FC<LikeButtonsProps> = (props: LikeButtonsProps) => {
     setIsPopoverOpen(!isPopoverOpen);
     setIsPopoverOpen(!isPopoverOpen);
   };
   };
 
 
-
-  const handleClick = () => {
-    if (props.onLikeClicked == null) {
-      return;
-    }
-    props.onLikeClicked();
-  };
-
   const {
   const {
-    appContainer, hideTotalNumber, isLiked, sumOfLikers,
+    hideTotalNumber, isGuestUser, isLiked, sumOfLikers, onLikeClicked,
   } = props;
   } = props;
-  const { isGuestUser } = appContainer;
 
 
   return (
   return (
     <div className="btn-group" role="group" aria-label="Like buttons">
     <div className="btn-group" role="group" aria-label="Like buttons">
       <button
       <button
         type="button"
         type="button"
         id="like-button"
         id="like-button"
-        onClick={handleClick}
+        onClick={onLikeClicked}
         className={`btn btn-like border-0
         className={`btn btn-like border-0
             ${isLiked ? 'active' : ''} ${isGuestUser ? 'disabled' : ''}`}
             ${isLiked ? 'active' : ''} ${isGuestUser ? 'disabled' : ''}`}
       >
       >
         <i className={`fa ${isLiked ? 'fa-heart' : 'fa-heart-o'}`}></i>
         <i className={`fa ${isLiked ? 'fa-heart' : 'fa-heart-o'}`}></i>
       </button>
       </button>
-      {isGuestUser && (
+      { isGuestUser && (
         <UncontrolledTooltip placement="top" target="like-button" fade={false}>
         <UncontrolledTooltip placement="top" target="like-button" fade={false}>
           {t('Not available for guest')}
           {t('Not available for guest')}
         </UncontrolledTooltip>
         </UncontrolledTooltip>
@@ -63,7 +55,7 @@ const LikeButtons: FC<LikeButtonsProps> = (props: LikeButtonsProps) => {
             {sumOfLikers}
             {sumOfLikers}
           </button>
           </button>
           <Popover placement="bottom" isOpen={isPopoverOpen} target="po-total-likes" toggle={togglePopover} trigger="legacy">
           <Popover placement="bottom" isOpen={isPopoverOpen} target="po-total-likes" toggle={togglePopover} trigger="legacy">
-            <PopoverBody className="seen-user-popover">
+            <PopoverBody className="user-list-popover">
               <div className="px-2 text-right user-list-content text-truncate text-muted">
               <div className="px-2 text-right user-list-content text-truncate text-muted">
                 {props.likers?.length ? <UserPictureList users={props.likers} /> : t('No users have liked this yet.')}
                 {props.likers?.length ? <UserPictureList users={props.likers} /> : t('No users have liked this yet.')}
               </div>
               </div>

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

@@ -50,7 +50,7 @@ class LoginForm extends React.Component {
               <i className="icon-user"></i>
               <i className="icon-user"></i>
             </span>
             </span>
           </div>
           </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 && (
           {isLdapStrategySetup && (
             <div className="input-group-append">
             <div className="input-group-append">
               <small className="input-group-text text-success">
               <small className="input-group-text text-success">
@@ -66,12 +66,12 @@ class LoginForm extends React.Component {
               <i className="icon-lock"></i>
               <i className="icon-lock"></i>
             </span>
             </span>
           </div>
           </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>
 
 
         <div className="input-group my-4">
         <div className="input-group my-4">
           <input type="hidden" name="_csrf" value={appContainer.csrfToken} />
           <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>
             <div className="eff"></div>
             <span className="btn-label">
             <span className="btn-label">
               <i className="icon-login"></i>
               <i className="icon-login"></i>
@@ -297,18 +297,18 @@ class LoginForm extends React.Component {
               <div className="front">
               <div className="front">
                 {isLocalOrLdapStrategiesEnabled && this.renderLocalOrLdapLoginForm()}
                 {isLocalOrLdapStrategiesEnabled && this.renderLocalOrLdapLoginForm()}
                 {isSomeExternalAuthEnabled && this.renderExternalAuthLoginForm()}
                 {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 && (
                 {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>
                 )}
                 )}
               </div>
               </div>

+ 1 - 1
packages/app/src/components/Navbar/AuthorInfo.jsx

@@ -34,7 +34,7 @@ const AuthorInfo = (props) => {
       if (err instanceof RangeError) {
       if (err instanceof RangeError) {
         return <p>{nullinfoLabelForFooter} <UserPicture user={user} size="sm" /> {userLabel}</p>;
         return <p>{nullinfoLabelForFooter} <UserPicture user={user} size="sm" /> {userLabel}</p>;
       }
       }
-      return;
+      return <></>;
     }
     }
   }
   }
 
 

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

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

+ 243 - 0
packages/app/src/components/Navbar/GrowiContextualSubNavigation.tsx

@@ -0,0 +1,243 @@
+import React, { useCallback } from 'react';
+import PropTypes from 'prop-types';
+
+import { useTranslation } from 'react-i18next';
+
+import { DropdownItem } from 'reactstrap';
+
+import { withUnstatedContainers } from '../UnstatedUtils';
+import EditorContainer from '~/client/services/EditorContainer';
+import {
+  EditorMode, useDrawerMode, useEditorMode, useIsDeviceSmallerThanMd, useIsAbleToShowPageManagement, useIsAbleToShowTagLabel,
+  useIsAbleToShowPageEditorModeManager, useIsAbleToShowPageAuthors, usePageAccessoriesModal, PageAccessoriesModalContents,
+} from '~/stores/ui';
+import {
+  useCurrentCreatedAt, useCurrentUpdatedAt, useCurrentPageId, useRevisionId, useCurrentPagePath,
+  useCreator, useRevisionAuthor, useIsGuestUser, useIsSharedUser, useShareLinkId,
+} from '~/stores/context';
+import { useSWRTagsInfo } from '~/stores/page';
+
+
+import { toastSuccess, toastError } from '~/client/util/apiNotification';
+import { apiPost } from '~/client/util/apiv1-client';
+import { IPageHasId } from '~/interfaces/page';
+
+import HistoryIcon from '../Icons/HistoryIcon';
+import AttachmentIcon from '../Icons/AttachmentIcon';
+import ShareLinkIcon from '../Icons/ShareLinkIcon';
+import { AdditionalMenuItemsRendererProps } from '../Common/Dropdown/PageItemControl';
+import { SubNavButtons } from './SubNavButtons';
+import PageEditorModeManager from './PageEditorModeManager';
+import { GrowiSubNavigation } from './GrowiSubNavigation';
+import PresentationIcon from '../Icons/PresentationIcon';
+import { exportAsMarkdown } from '~/client/services/page-operation';
+
+
+type AdditionalMenuItemsProps = AdditionalMenuItemsRendererProps & {
+  pageId: string,
+  revisionId: string,
+  isLinkSharingDisabled?: boolean,
+}
+
+const AdditionalMenuItems = (props: AdditionalMenuItemsProps): JSX.Element => {
+  const { t } = useTranslation();
+
+  const { pageId, revisionId, isLinkSharingDisabled } = props;
+
+  const { data: isGuestUser } = useIsGuestUser();
+  const { data: isSharedUser } = useIsSharedUser();
+
+  const { open } = usePageAccessoriesModal();
+
+  return (
+    <>
+      {/* Presentation */}
+      <DropdownItem onClick={() => { /* TODO: implement in https://redmine.weseek.co.jp/issues/87672 */ }}>
+        <i className="icon-fw"><PresentationIcon /></i>
+        { t('Presentation Mode') }
+      </DropdownItem>
+
+      {/* Export markdown */}
+      <DropdownItem onClick={() => exportAsMarkdown(pageId, revisionId, 'md')}>
+        <i className="icon-fw icon-cloud-download"></i>
+        {t('export_bulk.export_page_markdown')}
+      </DropdownItem>
+
+      <DropdownItem divider />
+
+      {/*
+        TODO: show Tooltip when menu is disabled
+        refs: PageAccessoriesModalControl
+      */}
+      <DropdownItem
+        onClick={() => open(PageAccessoriesModalContents.PageHistory)}
+        disabled={isGuestUser || isSharedUser}
+      >
+        <span className="mr-1"><HistoryIcon /></span>
+        {t('History')}
+      </DropdownItem>
+
+      <DropdownItem
+        onClick={() => open(PageAccessoriesModalContents.Attachment)}
+      >
+        <span className="mr-1"><AttachmentIcon /></span>
+        {t('attachment_data')}
+      </DropdownItem>
+
+      <DropdownItem
+        onClick={() => open(PageAccessoriesModalContents.ShareLink)}
+        disabled={isGuestUser || isSharedUser || isLinkSharingDisabled}
+      >
+        <span className="mr-1"><ShareLinkIcon /></span>
+        {t('share_links.share_link_management')}
+      </DropdownItem>
+
+      <DropdownItem divider />
+
+      {/* Create template */}
+      <DropdownItem onClick={() => { /* TODO: implement in https://redmine.weseek.co.jp/issues/87673 */ }}>
+        <i className="icon-fw icon-magic-wand"></i> { t('template.option_label.create/edit') }
+      </DropdownItem>
+    </>
+  );
+};
+
+
+const GrowiContextualSubNavigation = (props) => {
+  const { data: isDeviceSmallerThanMd } = useIsDeviceSmallerThanMd();
+  const { data: isDrawerMode } = useDrawerMode();
+  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: creator } = useCreator();
+  const { data: revisionAuthor } = useRevisionAuthor();
+  const { data: isGuestUser } = useIsGuestUser();
+  const { data: isSharedUser } = useIsSharedUser();
+  const { data: shareLinkId } = useShareLinkId();
+
+  const { data: isAbleToShowPageManagement } = useIsAbleToShowPageManagement();
+  const { data: isAbleToShowTagLabel } = useIsAbleToShowTagLabel();
+  const { data: isAbleToShowPageEditorModeManager } = useIsAbleToShowPageEditorModeManager();
+  const { data: isAbleToShowPageAuthors } = useIsAbleToShowPageAuthors();
+
+  const { mutate: mutateSWRTagsInfo, data: tagsInfoData } = useSWRTagsInfo(pageId);
+
+  const {
+    editorContainer, isCompactMode, isLinkSharingDisabled,
+  } = props;
+
+  const isViewMode = editorMode === EditorMode.View;
+
+  const tagsUpdatedHandler = useCallback(async(newTags: string[]) => {
+    // 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 } = await apiPost('/tags.update', { pageId, revisionId, tags: newTags }) as { tags };
+
+      // revalidate SWRTagsInfo
+      mutateSWRTagsInfo();
+      // update editorContainer.state
+      editorContainer.setState({ tags });
+
+      toastSuccess('updated tags successfully');
+    }
+    catch (err) {
+      toastError(err, 'fail to update tags');
+    }
+  // eslint-disable-next-line react-hooks/exhaustive-deps
+  }, [pageId]);
+
+  const ControlComponents = useCallback(() => {
+    function onPageEditorModeButtonClicked(viewType) {
+      mutateEditorMode(viewType);
+    }
+
+    return (
+      <>
+        <div className="h-50 d-flex flex-column align-items-end justify-content-center">
+          { pageId != null && isViewMode && (
+            <SubNavButtons
+              isCompactMode={isCompactMode}
+              pageId={pageId}
+              shareLinkId={shareLinkId}
+              revisionId={revisionId}
+              disableSeenUserInfoPopover={isSharedUser}
+              showPageControlDropdown={isAbleToShowPageManagement}
+              additionalMenuItemRenderer={props => (
+                <AdditionalMenuItems {...props} pageId={pageId} revisionId={revisionId} isLinkSharingDisabled={isLinkSharingDisabled} />
+              )}
+            />
+          ) }
+        </div>
+        <div className="h-50 d-flex flex-column align-items-end justify-content-center">
+          {isAbleToShowPageEditorModeManager && (
+            <PageEditorModeManager
+              onPageEditorModeButtonClicked={onPageEditorModeButtonClicked}
+              isBtnDisabled={isGuestUser}
+              editorMode={editorMode}
+              isDeviceSmallerThanMd={isDeviceSmallerThanMd}
+            />
+          )}
+        </div>
+      </>
+    );
+  }, [
+    pageId, revisionId,
+    editorMode, mutateEditorMode,
+    isCompactMode, isLinkSharingDisabled,
+    isDeviceSmallerThanMd, isGuestUser, isSharedUser,
+    isViewMode, isAbleToShowPageEditorModeManager, isAbleToShowPageManagement,
+  ]);
+
+
+  if (path == null) {
+    return <></>;
+  }
+
+  const currentPage: Partial<IPageHasId> = {
+    _id: pageId ?? undefined,
+    path,
+    revision: revisionId ?? undefined,
+    creator: creator ?? undefined,
+    lastUpdateUser: revisionAuthor,
+    createdAt: createdAt ?? undefined,
+    updatedAt: updatedAt ?? undefined,
+  };
+
+
+  return (
+    <GrowiSubNavigation
+      page={currentPage}
+      showDrawerToggler={isDrawerMode}
+      showTagLabel={isAbleToShowTagLabel}
+      showPageAuthors={isAbleToShowPageAuthors}
+      isGuestUser={isGuestUser}
+      isDrawerMode={isDrawerMode}
+      isCompactMode={isCompactMode}
+      tags={tagsInfoData?.tags || []}
+      tagsUpdatedHandler={tagsUpdatedHandler}
+      controls={ControlComponents}
+    />
+  );
+};
+
+/**
+ * Wrapper component for using unstated
+ */
+const GrowiContextualSubNavigationWrapper = withUnstatedContainers(GrowiContextualSubNavigation, [EditorContainer]);
+
+
+GrowiContextualSubNavigation.propTypes = {
+  editorContainer: PropTypes.instanceOf(EditorContainer).isRequired,
+
+  isCompactMode: PropTypes.bool,
+  isLinkSharingDisabled: PropTypes.bool,
+};
+
+export default GrowiContextualSubNavigationWrapper;

+ 0 - 157
packages/app/src/components/Navbar/GrowiSubNavigation.jsx

@@ -1,157 +0,0 @@
-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,
-} from '~/stores/ui';
-import { useCurrentCreatedAt, useCurrentUpdatedAt } from '~/stores/context';
-
-import TagLabels from '../Page/TagLabels';
-import SubNavButtons from './SubNavButtons';
-import PageEditorModeManager from './PageEditorModeManager';
-
-import AuthorInfo from './AuthorInfo';
-import DrawerToggler from './DrawerToggler';
-
-import PagePathNav from '../PagePathNav';
-
-import { toastSuccess, toastError } from '~/client/util/apiNotification';
-import { apiPost } from '~/client/util/apiv1-client';
-
-const GrowiSubNavigation = (props) => {
-  const { data: isDeviceSmallerThanMd } = useIsDeviceSmallerThanMd();
-  const { data: isDrawerMode } = useDrawerMode();
-  const { data: editorMode, mutate: mutateEditorMode } = useEditorMode();
-  const { data: createdAt } = useCurrentCreatedAt();
-  const { data: updatedAt } = useCurrentUpdatedAt();
-
-  const {
-    appContainer, pageContainer, 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;
-  function onPageEditorModeButtonClicked(viewType) {
-    mutateEditorMode(viewType);
-  }
-
-  const tagsUpdatedHandler = useCallback(async(newTags) => {
-    // It will not be reflected in the DB until the page is refreshed
-    if (editorMode === 'edit') {
-      return editorContainer.setState({ tags: newTags });
-    }
-
-    try {
-      const { tags } = await apiPost('/tags.update', { pageId, tags: newTags });
-
-      // update pageContainer.state
-      pageContainer.setState({ tags });
-      // update editorContainer.state
-      editorContainer.setState({ tags });
-
-      toastSuccess('updated tags successfully');
-    }
-    catch (err) {
-      toastError(err, 'fail to update tags');
-    }
-  // eslint-disable-next-line react-hooks/exhaustive-deps
-  }, [pageId]);
-
-  return (
-    <div className={`grw-subnav container-fluid d-flex align-items-center justify-content-between ${isCompactMode ? 'grw-subnav-compact d-print-none' : ''}`}>
-
-      {/* Left side */}
-      <div className="d-flex grw-subnav-left-side">
-        { isDrawerMode && (
-          <div className={`d-none d-md-flex align-items-center ${isEditorMode ? 'mr-2 pr-2' : 'border-right mr-4 pr-4'}`}>
-            <DrawerToggler />
-          </div>
-        ) }
-
-        <div className="grw-path-nav-container">
-          { pageContainer.isAbleToShowTagLabel && !isCompactMode && !isTagLabelHidden && (
-            <div className="grw-taglabels-container">
-              <TagLabels tags={tags} tagsUpdateInvoked={tagsUpdatedHandler} />
-            </div>
-          ) }
-          <PagePathNav pageId={pageId} pagePath={path} isSingleLineMode={isEditorMode} isCompactMode={isCompactMode} />
-        </div>
-      </div>
-
-      {/* Right side */}
-      <div className="d-flex">
-
-        <div className="d-flex flex-column align-items-end">
-          <SubNavButtons
-            isCompactMode={isCompactMode}
-            pageId={pageId}
-            revisionId={revisionId}
-            path={path}
-            isDeletable={isDeletable}
-            isAbleToDeleteCompletely={isAbleToDeleteCompletely}
-            willShowPageManagement={isAbleToShowPageManagement}
-          />
-          <div className="mt-2">
-            {pageContainer.isAbleToShowPageEditorModeManager && (
-              <PageEditorModeManager
-                onPageEditorModeButtonClicked={onPageEditorModeButtonClicked}
-                isBtnDisabled={isGuestUser}
-                editorMode={editorMode}
-                isDeviceSmallerThanMd={isDeviceSmallerThanMd}
-              />
-            )}
-          </div>
-        </div>
-
-        {/* Page Authors */}
-        { (pageContainer.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" />
-            </li>
-            <li className="mt-1 pt-1 border-top">
-              <AuthorInfo user={revisionAuthor} date={updatedAt} mode="update" locate="subnav" />
-            </li>
-          </ul>
-        ) }
-      </div>
-    </div>
-  );
-};
-
-/**
- * Wrapper component for using unstated
- */
-const GrowiSubNavigationWrapper = withUnstatedContainers(GrowiSubNavigation, [AppContainer, PageContainer, EditorContainer]);
-
-
-GrowiSubNavigation.propTypes = {
-  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
-  pageContainer: PropTypes.instanceOf(PageContainer).isRequired,
-  editorContainer: PropTypes.instanceOf(EditorContainer).isRequired,
-
-  isCompactMode: PropTypes.bool,
-};
-
-export default GrowiSubNavigationWrapper;

+ 100 - 0
packages/app/src/components/Navbar/GrowiSubNavigation.tsx

@@ -0,0 +1,100 @@
+import React from 'react';
+
+import { IPageHasId } from '~/interfaces/page';
+
+import {
+  EditorMode, useEditorMode,
+} from '~/stores/ui';
+
+import TagLabels from '../Page/TagLabels';
+
+import AuthorInfo from './AuthorInfo';
+import DrawerToggler from './DrawerToggler';
+
+import PagePathNav from '../PagePathNav';
+import { IUser } from '~/interfaces/user';
+
+
+type Props = {
+  page: Partial<IPageHasId>,
+
+  showDrawerToggler?: boolean,
+  showTagLabel?: boolean,
+  showPageAuthors?: boolean,
+
+  isGuestUser?: boolean,
+  isDrawerMode?: boolean,
+  isCompactMode?: boolean,
+
+  tags?: string[],
+  tagsUpdatedHandler?: (newTags: string[]) => Promise<void>,
+
+  controls?: React.FunctionComponent,
+}
+
+export const GrowiSubNavigation = (props: Props): JSX.Element => {
+  const { data: editorMode } = useEditorMode();
+
+  const {
+    page,
+    showDrawerToggler, showTagLabel, showPageAuthors,
+    isGuestUser, isDrawerMode, isCompactMode,
+    tags, tagsUpdatedHandler,
+    controls: Controls,
+  } = props;
+
+  const {
+    _id: pageId, path, creator, lastUpdateUser,
+    createdAt, updatedAt,
+  } = page;
+
+  const isViewMode = editorMode === EditorMode.View;
+  const isEditorMode = !isViewMode;
+
+  if (path == null) {
+    return <></>;
+  }
+
+  return (
+    <div className={`grw-subnav container-fluid d-flex align-items-center justify-content-between ${isCompactMode ? 'grw-subnav-compact d-print-none' : ''}`}>
+
+      {/* Left side */}
+      <div className="d-flex grw-subnav-left-side">
+        { showDrawerToggler && isDrawerMode && (
+          <div className={`d-none d-md-flex align-items-center ${isEditorMode ? 'mr-2 pr-2' : 'border-right mr-4 pr-4'}`}>
+            <DrawerToggler />
+          </div>
+        ) }
+
+        <div className="grw-path-nav-container">
+          { showTagLabel && !isCompactMode && (
+            <div className="grw-taglabels-container">
+              <TagLabels tags={tags} isGuestUser={isGuestUser ?? false} tagsUpdateInvoked={tagsUpdatedHandler} />
+            </div>
+          ) }
+          <PagePathNav pageId={pageId} pagePath={path} isSingleLineMode={isEditorMode} isCompactMode={isCompactMode} />
+        </div>
+      </div>
+
+      {/* Right side */}
+      <div className="d-flex">
+
+        <div>
+          { Controls && <Controls></Controls> }
+        </div>
+
+        {/* Page Authors */}
+        { (showPageAuthors && !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 as IUser} date={createdAt} locate="subnav" />
+            </li>
+            <li className="mt-1 pt-1 border-top">
+              <AuthorInfo user={lastUpdateUser as IUser} date={updatedAt} mode="update" locate="subnav" />
+            </li>
+          </ul>
+        ) }
+      </div>
+    </div>
+  );
+};

+ 4 - 2
packages/app/src/components/Navbar/GrowiSubNavigationSwitcher.jsx

@@ -1,6 +1,7 @@
 import React, {
 import React, {
   useMemo, useState, useRef, useEffect, useCallback,
   useMemo, useState, useRef, useEffect, useCallback,
 } from 'react';
 } from 'react';
+import PropTypes from 'prop-types';
 
 
 import StickyEvents from 'sticky-events';
 import StickyEvents from 'sticky-events';
 import { debounce } from 'throttle-debounce';
 import { debounce } from 'throttle-debounce';
@@ -8,7 +9,7 @@ import { debounce } from 'throttle-debounce';
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 import { useSidebarCollapsed } from '~/stores/ui';
 import { useSidebarCollapsed } from '~/stores/ui';
 
 
-import GrowiSubNavigation from './GrowiSubNavigation';
+import GrowiContextualSubNavigation from './GrowiContextualSubNavigation';
 
 
 const logger = loggerFactory('growi:cli:GrowiSubNavigationSticky');
 const logger = loggerFactory('growi:cli:GrowiSubNavigationSticky');
 
 
@@ -110,13 +111,14 @@ const GrowiSubNavigationSwitcher = (props) => {
   return (
   return (
     <div className={`grw-subnav-switcher ${isVisible ? '' : 'grw-subnav-switcher-hidden'}`}>
     <div className={`grw-subnav-switcher ${isVisible ? '' : 'grw-subnav-switcher-hidden'}`}>
       <div id="grw-subnav-fixed-container" className="grw-subnav-fixed-container position-fixed" ref={fixedContainerRef} style={{ width }}>
       <div id="grw-subnav-fixed-container" className="grw-subnav-fixed-container position-fixed" ref={fixedContainerRef} style={{ width }}>
-        <GrowiSubNavigation isCompactMode />
+        <GrowiContextualSubNavigation isCompactMode isLinkSharingDisabled />
       </div>
       </div>
     </div>
     </div>
   );
   );
 };
 };
 
 
 GrowiSubNavigationSwitcher.propTypes = {
 GrowiSubNavigationSwitcher.propTypes = {
+  isLinkSharingDisabled: PropTypes.bool,
 };
 };
 
 
 export default GrowiSubNavigationSwitcher;
 export default GrowiSubNavigationSwitcher;

+ 112 - 74
packages/app/src/components/Navbar/SubNavButtons.tsx

@@ -1,115 +1,153 @@
-import React, {
-  FC, useCallback,
-} from 'react';
+import React, { useCallback } from 'react';
 
 
-import SubscribeButton from '../SubscribeButton';
-import PageReactionButtons from '../PageReactionButtons';
-import PageManagement from '../Page/PageManagement';
-import { useSWRPageInfo } from '../../stores/page';
+import { IPageInfoAll, isIPageInfoForEntity, isIPageInfoForOperation } from '~/interfaces/page';
+
+import { useSWRxPageInfo } from '../../stores/page';
 import { useSWRBookmarkInfo } from '../../stores/bookmark';
 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 { useSWRxUsersList } from '../../stores/user';
 import { useIsGuestUser } from '~/stores/context';
 import { useIsGuestUser } from '~/stores/context';
 
 
-type SubNavButtonsProps= {
+import SubscribeButton from '../SubscribeButton';
+import LikeButtons from '../LikeButtons';
+import BookmarkButtons from '../BookmarkButtons';
+import SeenUserInfo from '../User/SeenUserInfo';
+import { toggleBookmark, toggleLike, toggleSubscribe } from '~/client/services/page-operation';
+import { AdditionalMenuItemsRendererProps, PageItemControl } from '../Common/Dropdown/PageItemControl';
+
+
+type CommonProps = {
   isCompactMode?: boolean,
   isCompactMode?: boolean,
+  disableSeenUserInfoPopover?: boolean,
+  showPageControlDropdown?: boolean,
+  additionalMenuItemRenderer?: React.FunctionComponent<AdditionalMenuItemsRendererProps>,
+}
+
+type SubNavButtonsSubstanceProps= CommonProps & {
   pageId: string,
   pageId: string,
+  shareLinkId?: string | null,
   revisionId: string,
   revisionId: string,
-  path: string,
-  willShowPageManagement: boolean,
-  isDeletable: boolean,
-  isAbleToDeleteCompletely: boolean,
+  pageInfo: IPageInfoAll,
 }
 }
-const SubNavButtons: FC<SubNavButtonsProps> = (props: SubNavButtonsProps) => {
+
+const SubNavButtonsSubstance = (props: SubNavButtonsSubstanceProps): JSX.Element => {
   const {
   const {
-    isCompactMode, pageId, revisionId, path, willShowPageManagement, isDeletable, isAbleToDeleteCompletely,
+    pageInfo,
+    pageId, shareLinkId,
+    isCompactMode, disableSeenUserInfoPopover, showPageControlDropdown, additionalMenuItemRenderer,
   } = props;
   } = props;
 
 
-  const { data: editorMode } = useEditorMode();
-  const isViewMode = editorMode === 'view';
-
   const { data: isGuestUser } = useIsGuestUser();
   const { data: isGuestUser } = useIsGuestUser();
 
 
-  const { data: pageInfo, error: pageInfoError, mutate: mutatePageInfo } = useSWRPageInfo(pageId);
-  const { data: likers } = useSWRxLikerList(pageInfo?.likerIds);
-  const { data: bookmarkInfo, error: bookmarkInfoError, mutate: mutateBookmarkInfo } = useSWRBookmarkInfo(pageId, true);
+  const { mutate: mutatePageInfo } = useSWRxPageInfo(pageId, shareLinkId);
 
 
-  const likeClickhandler = useCallback(async() => {
+  const { data: bookmarkInfo, mutate: mutateBookmarkInfo } = useSWRBookmarkInfo(pageId);
+
+  const likerIds = isIPageInfoForEntity(pageInfo) ? (pageInfo.likerIds ?? []).slice(0, 15) : [];
+  const seenUserIds = isIPageInfoForEntity(pageInfo) ? (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 likers = usersList != null ? usersList.filter(({ _id }) => likerIds.includes(_id)).slice(0, 15) : [];
+  const seenUsers = usersList != null ? usersList.filter(({ _id }) => seenUserIds.includes(_id)).slice(0, 15) : [];
+
+  const subscribeClickhandler = useCallback(async() => {
     if (isGuestUser == null || isGuestUser) {
     if (isGuestUser == null || isGuestUser) {
       return;
       return;
     }
     }
+    if (!isIPageInfoForOperation(pageInfo)) {
+      return;
+    }
 
 
-    try {
-      // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
-      await apiv3Put('/page/likes', { pageId, bool: !pageInfo!.isLiked });
-      mutatePageInfo();
+    await toggleSubscribe(pageId, pageInfo.subscriptionStatus);
+    mutatePageInfo();
+  }, [isGuestUser, mutatePageInfo, pageId, pageInfo]);
+
+  const likeClickhandler = useCallback(async() => {
+    if (isGuestUser == null || isGuestUser) {
+      return;
     }
     }
-    catch (err) {
-      toastError(err);
+    if (!isIPageInfoForOperation(pageInfo)) {
+      return;
     }
     }
+
+    await toggleLike(pageId, pageInfo.isLiked);
+    mutatePageInfo();
   }, [isGuestUser, mutatePageInfo, pageId, pageInfo]);
   }, [isGuestUser, mutatePageInfo, pageId, pageInfo]);
 
 
   const bookmarkClickHandler = useCallback(async() => {
   const bookmarkClickHandler = useCallback(async() => {
     if (isGuestUser == null || isGuestUser) {
     if (isGuestUser == null || isGuestUser) {
       return;
       return;
     }
     }
-    try {
-      // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
-      await apiv3Put('/bookmarks', { pageId, bool: !bookmarkInfo!.isBookmarked });
-      mutateBookmarkInfo();
-    }
-    catch (err) {
-      toastError(err);
+    if (!isIPageInfoForOperation(pageInfo)) {
+      return;
     }
     }
-  }, [bookmarkInfo, isGuestUser, mutateBookmarkInfo, pageId]);
 
 
-  if (pageInfoError != null || pageInfo == null) {
-    return <></>;
-  }
+    await toggleBookmark(pageId, pageInfo.isBookmarked);
+    mutatePageInfo();
+    mutateBookmarkInfo();
+  }, [isGuestUser, mutateBookmarkInfo, mutatePageInfo, pageId, pageInfo]);
 
 
-  if (bookmarkInfoError != null || bookmarkInfo == null) {
+  if (!isIPageInfoForOperation(pageInfo)) {
     return <></>;
     return <></>;
   }
   }
 
 
-  const { sumOfLikers, isLiked } = pageInfo;
-  const { sumOfBookmarks, isBookmarked, bookmarkedUsers } = bookmarkInfo;
+  const {
+    sumOfLikers, isLiked, bookmarkCount, isBookmarked,
+  } = pageInfo;
 
 
   return (
   return (
     <div className="d-flex" style={{ gap: '2px' }}>
     <div className="d-flex" style={{ gap: '2px' }}>
-      {isViewMode && (
-        <>
-          <span>
-            <SubscribeButton pageId={props.pageId} />
-          </span>
-          <PageReactionButtons
-            isCompactMode={isCompactMode}
-            sumOfLikers={sumOfLikers}
-            isLiked={isLiked}
-            likers={likers || []}
-            onLikeClicked={likeClickhandler}
-            sumOfBookmarks={sumOfBookmarks}
-            isBookmarked={isBookmarked}
-            bookmarkedUsers={bookmarkedUsers}
-            onBookMarkClicked={bookmarkClickHandler}
-          >
-          </PageReactionButtons>
-        </>
-      )}
-      {willShowPageManagement && (
-        <PageManagement
+      <span>
+        <SubscribeButton
+          status={pageInfo.subscriptionStatus}
+          onClick={subscribeClickhandler}
+        />
+      </span>
+      <LikeButtons
+        hideTotalNumber={isCompactMode}
+        onLikeClicked={likeClickhandler}
+        sumOfLikers={sumOfLikers}
+        isLiked={isLiked}
+        likers={likers}
+      />
+      <BookmarkButtons
+        hideTotalNumber={isCompactMode}
+        bookmarkCount={bookmarkCount}
+        isBookmarked={isBookmarked}
+        bookmarkedUsers={bookmarkInfo?.bookmarkedUsers}
+        onBookMarkClicked={bookmarkClickHandler}
+      />
+      <SeenUserInfo seenUsers={seenUsers} disabled={disableSeenUserInfoPopover} />
+      { showPageControlDropdown && (
+        <PageItemControl
           pageId={pageId}
           pageId={pageId}
-          revisionId={revisionId}
-          path={path}
-          isCompactMode={isCompactMode}
-          isDeletable={isDeletable}
-          isAbleToDeleteCompletely={isAbleToDeleteCompletely}
-        >
-        </PageManagement>
+          pageInfo={pageInfo}
+          isEnableActions={!isGuestUser}
+          additionalMenuItemRenderer={additionalMenuItemRenderer}
+        />
       )}
       )}
     </div>
     </div>
   );
   );
 };
 };
 
 
-export default SubNavButtons;
+type SubNavButtonsProps= CommonProps & {
+  pageId: string,
+  shareLinkId?: string | null,
+  revisionId?: string | null,
+};
+
+export const SubNavButtons = (props: SubNavButtonsProps): JSX.Element => {
+  const { pageId, shareLinkId, revisionId } = props;
+
+  const { data: pageInfo, error } = useSWRxPageInfo(pageId ?? null, shareLinkId);
+
+  if (revisionId == null || error != null) {
+    return <></>;
+  }
+
+  if (!isIPageInfoForOperation(pageInfo)) {
+    return <></>;
+  }
+
+  return <SubNavButtonsSubstance {...props} pageInfo={pageInfo} pageId={pageId} revisionId={revisionId} />;
+};

+ 0 - 42
packages/app/src/components/NotFoundPage.jsx

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

+ 48 - 0
packages/app/src/components/NotFoundPage.tsx

@@ -0,0 +1,48 @@
+import React, { useCallback, useMemo } from 'react';
+import { useTranslation } from 'react-i18next';
+
+import PageListIcon from './Icons/PageListIcon';
+import TimeLineIcon from './Icons/TimeLineIcon';
+import CustomNavAndContents from './CustomNavigation/CustomNavAndContents';
+import DescendantsPageList from './DescendantsPageList';
+import PageTimeline from './PageTimeline';
+import { useCurrentPagePath } from '~/stores/context';
+
+
+const NotFoundPage = (): JSX.Element => {
+  const { t } = useTranslation();
+
+  const { data: currentPagePath } = useCurrentPagePath();
+
+  const DescendantsPageListForThisPage = useCallback((): JSX.Element => {
+    return currentPagePath != null
+      ? <DescendantsPageList path={currentPagePath} />
+      : <></>;
+  }, [currentPagePath]);
+
+  const navTabMapping = useMemo(() => {
+    return {
+      pagelist: {
+        Icon: PageListIcon,
+        Content: DescendantsPageListForThisPage,
+        i18n: t('page_list'),
+        index: 0,
+      },
+      timeLine: {
+        Icon: TimeLineIcon,
+        Content: PageTimeline,
+        i18n: t('Timeline View'),
+        index: 1,
+      },
+    };
+  }, [DescendantsPageListForThisPage, t]);
+
+
+  return (
+    <div className="d-edit-none">
+      <CustomNavAndContents navTabMapping={navTabMapping} tabContentClasses={['py-4']} />
+    </div>
+  );
+};
+
+export default NotFoundPage;

+ 0 - 87
packages/app/src/components/Page/DisplaySwitcher.jsx

@@ -1,87 +0,0 @@
-import React from 'react';
-import { TabContent, TabPane } from 'reactstrap';
-import propTypes from 'prop-types';
-
-import { withUnstatedContainers } from '../UnstatedUtils';
-import PageContainer from '~/client/services/PageContainer';
-import { EditorMode, useEditorMode } from '~/stores/ui';
-
-import Editor from '../PageEditor';
-import Page from '../Page';
-import UserInfo from '../User/UserInfo';
-import TableOfContents from '../TableOfContents';
-import ContentLinkButtons from '../ContentLinkButtons';
-import PageAccessories from '../PageAccessories';
-import PageEditorByHackmd from '../PageEditorByHackmd';
-import EditorNavbarBottom from '../PageEditor/EditorNavbarBottom';
-import HashChanged from '../EventListeneres/HashChanged';
-import { useIsEditable } from '~/stores/context';
-
-
-const DisplaySwitcher = (props) => {
-  const {
-    pageContainer,
-  } = props;
-  const { isPageExist, pageUser } = pageContainer.state;
-
-  const { data: isEditable } = useIsEditable();
-  const { data: editorMode } = useEditorMode();
-
-  const isViewMode = editorMode === EditorMode.View;
-
-  return (
-    <>
-      <TabContent activeTab={editorMode}>
-        <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>
-
-                <div className="d-none d-lg-block">
-                  <div id="revision-toc" className="revision-toc">
-                    <TableOfContents />
-                  </div>
-                  <ContentLinkButtons />
-                </div>
-              </div>
-            </div>
-
-            <div className="flex-grow-1 flex-basis-0 mw-0">
-              {pageUser && <UserInfo pageUser={pageUser} />}
-              <Page />
-            </div>
-
-          </div>
-        </TabPane>
-        { isEditable && (
-          <TabPane tabId={EditorMode.Editor}>
-            <div id="page-editor">
-              <Editor />
-            </div>
-          </TabPane>
-        ) }
-        { isEditable && (
-          <TabPane tabId={EditorMode.HackMD}>
-            <div id="page-editor-with-hackmd">
-              <PageEditorByHackmd />
-            </div>
-          </TabPane>
-        ) }
-      </TabContent>
-      { isEditable && !isViewMode && <EditorNavbarBottom /> }
-
-      { isEditable && <HashChanged></HashChanged> }
-    </>
-  );
-};
-
-DisplaySwitcher.propTypes = {
-  pageContainer: propTypes.instanceOf(PageContainer).isRequired,
-};
-
-
-export default withUnstatedContainers(DisplaySwitcher, [PageContainer]);

+ 134 - 0
packages/app/src/components/Page/DisplaySwitcher.tsx

@@ -0,0 +1,134 @@
+import React, { useMemo } from 'react';
+import { useTranslation } from 'react-i18next';
+import { TabContent, TabPane } from 'reactstrap';
+
+import { pagePathUtils } from '@growi/core';
+
+import { EditorMode, useEditorMode, useDescendantsPageListModal } from '~/stores/ui';
+import {
+  useCurrentPagePath, useIsSharedUser, useIsEditable, useCurrentPageId, useIsUserPage, usePageUser,
+} from '~/stores/context';
+
+
+import { smoothScrollIntoView } from '~/client/util/smooth-scroll';
+
+import PageListIcon from '../Icons/PageListIcon';
+import Editor from '../PageEditor';
+import Page from '../Page';
+import UserInfo from '../User/UserInfo';
+import TableOfContents from '../TableOfContents';
+import ContentLinkButtons from '../ContentLinkButtons';
+import PageEditorByHackmd from '../PageEditorByHackmd';
+import EditorNavbarBottom from '../PageEditor/EditorNavbarBottom';
+import HashChanged from '../EventListeneres/HashChanged';
+
+
+const WIKI_HEADER_LINK = 120;
+
+const { isTopPage } = pagePathUtils;
+
+
+const DisplaySwitcher = (): JSX.Element => {
+  const { t } = useTranslation();
+
+
+  // get element for smoothScroll
+  const getCommentListDom = useMemo(() => { return document.getElementById('page-comments-list') }, []);
+
+
+  const { data: currentPageId } = useCurrentPageId();
+  const { data: currentPath } = useCurrentPagePath();
+  const { data: isSharedUser } = useIsSharedUser();
+  const { data: isUserPage } = useIsUserPage();
+  const { data: isEditable } = useIsEditable();
+  const { data: pageUser } = usePageUser();
+
+  const { data: editorMode } = useEditorMode();
+
+  const { open: openDescendantPageListModal } = useDescendantsPageListModal();
+
+  const isPageExist = currentPageId != null;
+  const isViewMode = editorMode === EditorMode.View;
+  const isTopPagePath = isTopPage(currentPath ?? '');
+
+  return (
+    <>
+      <TabContent activeTab={editorMode}>
+        <TabPane tabId={EditorMode.View}>
+          <div className="d-flex flex-column flex-lg-row-reverse">
+
+            { isPageExist && (
+              <div className="grw-side-contents-container">
+                <div className="grw-side-contents-sticky-container">
+
+                  {/* Page list */}
+                  <div className="grw-page-accessories-control">
+                    { currentPath != null && !isSharedUser && (
+                      <button
+                        type="button"
+                        className="btn btn-block btn-outline-secondary grw-btn-page-accessories rounded-pill d-flex justify-content-between align-items-center"
+                        onClick={() => openDescendantPageListModal(currentPath)}
+                      >
+                        <PageListIcon />
+                        {t('page_list')}
+                        <span></span> {/* for a count badge */}
+                      </button>
+                    ) }
+                  </div>
+
+                  {/* Comments */}
+                  { getCommentListDom != null && !isTopPagePath && (
+                    <div className="mt-2">
+                      <button
+                        type="button"
+                        className="btn btn-block btn-outline-secondary grw-btn-page-accessories rounded-pill d-flex justify-content-between align-items-center"
+                        onClick={() => smoothScrollIntoView(getCommentListDom, WIKI_HEADER_LINK)}
+                      >
+                        <i className="mr-2 icon-fw icon-bubbles"></i>
+                        <span>Comments</span>
+                        <span></span> {/* for a count badge */}
+                      </button>
+                    </div>
+                  ) }
+
+                  <div className="d-none d-lg-block">
+                    <div id="revision-toc" className="revision-toc">
+                      <TableOfContents />
+                    </div>
+                    <ContentLinkButtons />
+                  </div>
+
+                </div>
+              </div>
+            ) }
+
+            <div className="flex-grow-1 flex-basis-0 mw-0">
+              { isUserPage && <UserInfo pageUser={pageUser} />}
+              <Page />
+            </div>
+
+          </div>
+        </TabPane>
+        { isEditable && (
+          <TabPane tabId={EditorMode.Editor}>
+            <div id="page-editor">
+              <Editor />
+            </div>
+          </TabPane>
+        ) }
+        { isEditable && (
+          <TabPane tabId={EditorMode.HackMD}>
+            <div id="page-editor-with-hackmd">
+              <PageEditorByHackmd />
+            </div>
+          </TabPane>
+        ) }
+      </TabContent>
+      { isEditable && !isViewMode && <EditorNavbarBottom /> }
+
+      { isEditable && <HashChanged></HashChanged> }
+    </>
+  );
+};
+
+export default DisplaySwitcher;

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

+ 0 - 143
packages/app/src/components/Page/PageListItem.tsx

@@ -1,143 +0,0 @@
-import React, { FC, memo, useCallback } from 'react';
-
-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 { IPageSearchResultData } from '../../interfaces/search';
-import PageItemControl from '../Common/Dropdown/PageItemControl';
-
-const { isTopPage } = pagePathUtils;
-
-type Props = {
-  page: IPageSearchResultData,
-  isSelected: boolean, // is item selected(focused)
-  isChecked: boolean, // is checkbox of item checked
-  isEnableActions: boolean,
-  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,
-  onClickDeleteButton?: (pageId: string) => void,
-}
-
-const PageListItem: FC<Props> = memo((props:Props) => {
-  const {
-    // todo: refactoring variable name to clear what changed
-    page: { pageData, pageMeta }, isSelected, onClickSearchResultItem, onClickCheckbox, isChecked, isEnableActions, shortBody,
-    showPageUpdatedTime,
-  } = props;
-
-  const { data: isDeviceSmallerThanLg } = useIsDeviceSmallerThanLg();
-
-  const pagePath: DevidedPagePath = new DevidedPagePath(pageData.path, true);
-
-  const pageTitle = (
-    <PagePathLabel
-      path={pageMeta.elasticSearchResult?.highlightedPath || pageData.path}
-      isLatterOnly
-      isPathIncludedHtml={pageMeta.elasticSearchResult?.isHtmlInPath}
-    >
-    </PagePathLabel>
-  );
-  const pagePathElem = (
-    <PagePathLabel
-      path={pageMeta.elasticSearchResult?.highlightedPath || pageData.path}
-      isFormerOnly
-      isPathIncludedHtml={pageMeta.elasticSearchResult?.isHtmlInPath}
-    />
-  );
-
-  // click event handler
-  const clickHandler = useCallback(() => {
-    // do nothing if mobile
-    if (isDeviceSmallerThanLg) {
-      return;
-    }
-
-    if (onClickSearchResultItem != null) {
-      onClickSearchResultItem(pageData._id);
-    }
-  }, [isDeviceSmallerThanLg, onClickSearchResultItem, pageData._id]);
-
-  // 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}`}
-    >
-      <div
-        className="h-100 text-break"
-        onClick={clickHandler}
-      >
-        <div className="d-flex h-100">
-          {/* 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">
-              <input
-                className="form-check-input position-relative m-0"
-                type="checkbox"
-                id="flexCheckDefault"
-                onChange={() => { onClickCheckbox(pageData._id) }}
-                checked={isChecked}
-              />
-            </div>
-          )}
-          <div className="search-item-text p-md-3 pl-2 py-3 pr-3 flex-grow-1">
-            {/* page path */}
-            <h6 className="mb-1 py-1 d-flex">
-              <a className="d-inline-block" href={pagePath.isRoot ? pagePath.latter : pagePath.former}>
-                <i className="icon-fw icon-home"></i>
-                {pagePathElem}
-              </a>
-              {showPageUpdatedTime && (<p className="ml-auto mb-0 mr-4 list-item-updated-time">Updated: 0000/00/00 00:00:00</p>)}
-            </h6>
-            <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" />
-              </span>
-              {/* page title */}
-              <Clamp lines={1}>
-                <span className="py-1 h5 mr-2 mb-0">
-                  <a href={`/${pageData._id}`}>{pageTitle}</a>
-                </span>
-              </Clamp>
-
-              {/* page meta */}
-              <div className="d-none d-md-flex item-meta py-0 px-1">
-                <PageListMeta page={pageData} bookmarkCount={pageMeta.bookmarkCount} />
-              </div>
-              {/* doropdown icon includes page control buttons */}
-              <div className="item-control ml-auto">
-                <PageItemControl
-                  page={pageData}
-                  onClickDeleteButtonHandler={props.onClickDeleteButton}
-                  isEnableActions={isEnableActions}
-                  isDeletable={!isTopPage(pageData.path)}
-                />
-              </div>
-            </div>
-            <div className="grw-search-result-list-snippet py-1">
-              <Clamp lines={2}>
-                {
-                  pageMeta.elasticSearchResult != null && pageMeta.elasticSearchResult?.snippet.length !== 0 ? (
-                    <div dangerouslySetInnerHTML={{ __html: pageMeta.elasticSearchResult.snippet }}></div>
-                  ) : (
-                    <div>{ shortBody != null ? shortBody : 'Loading ...' }</div> // TODO: improve indicator
-                  )
-                }
-              </Clamp>
-            </div>
-          </div>
-        </div>
-        {/* TODO: adjust snippet position */}
-      </div>
-    </li>
-  );
-});
-
-export default PageListItem;

+ 11 - 22
packages/app/src/components/Page/PageManagement.jsx

@@ -5,9 +5,10 @@ import { withTranslation } from 'react-i18next';
 import urljoin from 'url-join';
 import urljoin from 'url-join';
 
 
 import { pagePathUtils } from '@growi/core';
 import { pagePathUtils } from '@growi/core';
+import { usePageDeleteModalStatus } from '~/stores/ui';
+
 import { withUnstatedContainers } from '../UnstatedUtils';
 import { withUnstatedContainers } from '../UnstatedUtils';
 import AppContainer from '~/client/services/AppContainer';
 import AppContainer from '~/client/services/AppContainer';
-import PageDeleteModal from '../PageDeleteModal';
 import PageRenameModal from '../PageRenameModal';
 import PageRenameModal from '../PageRenameModal';
 import PageDuplicateModal from '../PageDuplicateModal';
 import PageDuplicateModal from '../PageDuplicateModal';
 import CreateTemplateModal from '../CreateTemplateModal';
 import CreateTemplateModal from '../CreateTemplateModal';
@@ -22,12 +23,13 @@ const LegacyPageManagemenet = (props) => {
     t, appContainer, isCompactMode, pageId, revisionId, path, isDeletable, isAbleToDeleteCompletely,
     t, appContainer, isCompactMode, pageId, revisionId, path, isDeletable, isAbleToDeleteCompletely,
   } = props;
   } = props;
 
 
+  const { open: openDeleteModal } = usePageDeleteModalStatus();
+
   const { currentUser } = appContainer;
   const { currentUser } = appContainer;
   const isTopPagePath = isTopPage(path);
   const isTopPagePath = isTopPage(path);
   const [isPageRenameModalShown, setIsPageRenameModalShown] = useState(false);
   const [isPageRenameModalShown, setIsPageRenameModalShown] = useState(false);
   const [isPageDuplicateModalShown, setIsPageDuplicateModalShown] = useState(false);
   const [isPageDuplicateModalShown, setIsPageDuplicateModalShown] = useState(false);
   const [isPageTemplateModalShown, setIsPageTempleteModalShown] = useState(false);
   const [isPageTemplateModalShown, setIsPageTempleteModalShown] = useState(false);
-  const [isPageDeleteModalShown, setIsPageDeleteModalShown] = useState(false);
   const [isPagePresentationModalShown, setIsPagePresentationModalShown] = useState(false);
   const [isPagePresentationModalShown, setIsPagePresentationModalShown] = useState(false);
   const presentationHref = urljoin(window.location.origin, path, '?presentation=1');
   const presentationHref = urljoin(window.location.origin, path, '?presentation=1');
 
 
@@ -54,14 +56,6 @@ const LegacyPageManagemenet = (props) => {
     setIsPageTempleteModalShown(false);
     setIsPageTempleteModalShown(false);
   }
   }
 
 
-  function openPageDeleteModalHandler() {
-    setIsPageDeleteModalShown(true);
-  }
-
-  function closePageDeleteModalHandler() {
-    setIsPageDeleteModalShown(false);
-  }
-
   function openPagePresentationModalHandler() {
   function openPagePresentationModalHandler() {
     setIsPagePresentationModalShown(true);
     setIsPagePresentationModalShown(true);
   }
   }
@@ -142,26 +136,27 @@ const LegacyPageManagemenet = (props) => {
     );
     );
   }
   }
 
 
+  function generatePageObjectToDelete() {
+    return { pageId, revisionId, path };
+  }
+  const pageToDelete = generatePageObjectToDelete();
+
   function renderDropdownItemForDeletablePage() {
   function renderDropdownItemForDeletablePage() {
     return (
     return (
       <>
       <>
         <div className="dropdown-divider"></div>
         <div className="dropdown-divider"></div>
-        <button className="dropdown-item text-danger" type="button" onClick={openPageDeleteModalHandler}>
+        <button className="dropdown-item text-danger" type="button" onClick={() => openDeleteModal([pageToDelete])}>
           <i className="icon-fw icon-fire"></i> { t('Delete') }
           <i className="icon-fw icon-fire"></i> { t('Delete') }
         </button>
         </button>
       </>
       </>
     );
     );
   }
   }
 
 
-  function generatePageObjectToDelete() {
-    return { pageId, revisionId, path };
-  }
 
 
   function renderModals() {
   function renderModals() {
     if (currentUser == null) {
     if (currentUser == null) {
       return null;
       return null;
     }
     }
-    const pageToDelete = generatePageObjectToDelete();
 
 
     return (
     return (
       <>
       <>
@@ -183,12 +178,6 @@ const LegacyPageManagemenet = (props) => {
           isOpen={isPageTemplateModalShown}
           isOpen={isPageTemplateModalShown}
           onClose={closePageTemplateModalHandler}
           onClose={closePageTemplateModalHandler}
         />
         />
-        <PageDeleteModal
-          isOpen={isPageDeleteModalShown}
-          onClose={closePageDeleteModalHandler}
-          pages={[pageToDelete]}
-          isAbleToDeleteCompletely={isAbleToDeleteCompletely}
-        />
         <PagePresentationModal
         <PagePresentationModal
           isOpen={isPagePresentationModalShown}
           isOpen={isPagePresentationModalShown}
           onClose={closePagePresentationModalHandler}
           onClose={closePagePresentationModalHandler}
@@ -203,7 +192,7 @@ const LegacyPageManagemenet = (props) => {
       <>
       <>
         <button
         <button
           type="button"
           type="button"
-          className="btn-link nav-link dropdown-toggle dropdown-toggle-no-caret border-0 rounded grw-btn-page-management"
+          className="btn-link nav-link dropdown-toggle dropdown-toggle-no-caret border-0 rounded btn-page-item-control"
           data-toggle="dropdown"
           data-toggle="dropdown"
         >
         >
           <i className="text-muted icon-options"></i>
           <i className="text-muted icon-options"></i>

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

@@ -1,28 +1,26 @@
 import React from 'react';
 import React from 'react';
-import PropTypes from 'prop-types';
-import { withTranslation } from 'react-i18next';
+import { useTranslation } from 'react-i18next';
 
 
 import { UncontrolledTooltip } from 'reactstrap';
 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() {
   function openEditorHandler() {
-    if (props.openEditorModal == null) {
+    if (openEditorModal == null) {
       return;
       return;
     }
     }
-    props.openEditorModal();
-  }
-
-  // activate suspense
-  if (tags == null) {
-    throw new Promise(() => {});
+    openEditorModal();
   }
   }
 
 
   const isTagsEmpty = tags.length === 0;
   const isTagsEmpty = tags.length === 0;
-
   const tagElements = tags.map((tag) => {
   const tagElements = tags.map((tag) => {
     return (
     return (
       <a key={tag} href={`/_search?q=tag:${tag}`} className="grw-tag-label badge badge-secondary mr-2">
       <a key={tag} href={`/_search?q=tag:${tag}`} className="grw-tag-label badge badge-secondary mr-2">
@@ -54,12 +52,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) {
   getHighlightedBody(body, keywords) {
     const normalizedKeywordsArray = [];
     const normalizedKeywordsArray = [];
-    // !!TODO!!: add test code
+    // !!TODO!!: add test code refs: https://redmine.weseek.co.jp/issues/86841
     // Separate keywords
     // Separate keywords
     // - Surrounded by double quotation
     // - Surrounded by double quotation
     // - Split by both full-width and half-width spaces
     // - Split by both full-width and half-width spaces
@@ -84,6 +84,7 @@ class LegacyRevisionRenderer extends React.PureComponent {
 
 
     // for non-chrome browsers compatibility
     // for non-chrome browsers compatibility
     try {
     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
       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) {
     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);

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

@@ -0,0 +1,54 @@
+import React, { FC, useState } from 'react';
+
+import RenderTagLabels from './RenderTagLabels';
+import TagEditModal from './TagEditModal';
+
+type Props = {
+  tags?: string[],
+  isGuestUser: boolean,
+  tagsUpdateInvoked?: (tags: string[]) => Promise<void>,
+}
+
+
+const TagLabels:FC<Props> = (props: Props) => {
+  const { tags, isGuestUser, 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>
+        { tags == null
+          ? (
+            <span className="grw-tag-label badge badge-secondary">―</span>
+          )
+          : (
+            <RenderTagLabels
+              tags={tags}
+              openEditorModal={openEditorModal}
+              isGuestUser={isGuestUser}
+            />
+          )
+        }
+      </form>
+
+      <TagEditModal
+        tags={tags}
+        isOpen={isTagEditModalShown}
+        onClose={closeEditorModal}
+        onTagsUpdated={tagsUpdateInvoked}
+      />
+    </>
+  );
+};
+
+export default TagLabels;

+ 1 - 0
packages/app/src/components/Page/TrashPageAlert.jsx

@@ -97,6 +97,7 @@ const TrashPageAlert = (props) => {
           pageId={pageId}
           pageId={pageId}
           path={path}
           path={path}
         />
         />
+        {/* TODO: show PageDeleteModal with usePageDeleteModalStatus by 87567  */}
         <PageDeleteModal
         <PageDeleteModal
           isOpen={isPageDeleteModalShown}
           isOpen={isPageDeleteModalShown}
           onClose={opclosePageDeleteModalHandler}
           onClose={opclosePageDeleteModalHandler}

+ 0 - 44
packages/app/src/components/PageAccessories.jsx

@@ -1,44 +0,0 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-
-import PageAccessoriesModalControl from './PageAccessoriesModalControl';
-import PageAccessoriesModal from './PageAccessoriesModal';
-
-import { withUnstatedContainers } from './UnstatedUtils';
-import AppContainer from '~/client/services/AppContainer';
-import PageAccessoriesContainer from '~/client/services/PageAccessoriesContainer';
-
-const PageAccessories = (props) => {
-  const { appContainer, pageAccessoriesContainer, isNotFoundPage } = props;
-  const { isGuestUser, isSharedUser } = appContainer;
-
-  return (
-    <>
-      <PageAccessoriesModalControl
-        isGuestUser={isGuestUser}
-        isSharedUser={isSharedUser}
-        isNotFoundPage={isNotFoundPage}
-      />
-      <PageAccessoriesModal
-        isGuestUser={isGuestUser}
-        isSharedUser={isSharedUser}
-        isNotFoundPage={isNotFoundPage}
-        isOpen={pageAccessoriesContainer.state.isPageAccessoriesModalShown}
-        onClose={pageAccessoriesContainer.closePageAccessoriesModal}
-      />
-    </>
-  );
-};
-/**
- * Wrapper component for using unstated
- */
-const PageAccessoriesWrapper = withUnstatedContainers(PageAccessories, [AppContainer, PageAccessoriesContainer]);
-
-PageAccessories.propTypes = {
-  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
-  pageAccessoriesContainer: PropTypes.instanceOf(PageAccessoriesContainer).isRequired,
-
-  isNotFoundPage: PropTypes.bool.isRequired,
-};
-
-export default PageAccessoriesWrapper;

+ 0 - 159
packages/app/src/components/PageAccessoriesModal.jsx

@@ -1,159 +0,0 @@
-import React, { useCallback, useMemo, useState } from 'react';
-import PropTypes from 'prop-types';
-
-import {
-  Modal, ModalBody, ModalHeader, TabContent, TabPane,
-} from 'reactstrap';
-
-import { withTranslation } from 'react-i18next';
-import PageListIcon from './Icons/PageListIcon';
-import TimeLineIcon from './Icons/TimeLineIcon';
-import HistoryIcon from './Icons/HistoryIcon';
-import AttachmentIcon from './Icons/AttachmentIcon';
-import ShareLinkIcon from './Icons/ShareLinkIcon';
-
-import { withUnstatedContainers } from './UnstatedUtils';
-import PageAccessoriesContainer from '~/client/services/PageAccessoriesContainer';
-import PageAttachment from './PageAttachment';
-import PageTimeline from './PageTimeline';
-import PageList from './PageList';
-import PageHistory from './PageHistory';
-import ShareLink from './ShareLink/ShareLink';
-import { CustomNavTab } from './CustomNavigation/CustomNav';
-import ExpandOrContractButton from './ExpandOrContractButton';
-
-const PageAccessoriesModal = (props) => {
-  const {
-    t, pageAccessoriesContainer, onClose, isGuestUser, isSharedUser, isNotFoundPage,
-  } = props;
-  const isLinkSharingDisabled = pageAccessoriesContainer.appContainer.config.disableLinkSharing;
-  const { switchActiveTab } = pageAccessoriesContainer;
-  const { activeTab, activeComponents } = pageAccessoriesContainer.state;
-  const [isWindowExpanded, setIsWindowExpanded] = useState(false);
-
-  const navTabMapping = useMemo(() => {
-    return {
-      pagelist: {
-        Icon: PageListIcon,
-        i18n: t('page_list'),
-        index: 0,
-        isLinkEnabled: v => !isSharedUser,
-      },
-      timeline: {
-        Icon: TimeLineIcon,
-        i18n: t('Timeline View'),
-        index: 1,
-        isLinkEnabled: v => !isSharedUser,
-      },
-      pageHistory: {
-        Icon: HistoryIcon,
-        i18n: t('History'),
-        index: 2,
-        isLinkEnabled: v => !isGuestUser && !isSharedUser && !isNotFoundPage,
-      },
-      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,
-      },
-    };
-  }, [t, isGuestUser, isSharedUser, isNotFoundPage, isLinkSharingDisabled]);
-
-  const closeModalHandler = useCallback(() => {
-    if (onClose == null) {
-      return;
-    }
-    onClose();
-  }, [onClose]);
-
-  const expandWindow = () => {
-    setIsWindowExpanded(true);
-  };
-
-  const contractWindow = () => {
-    setIsWindowExpanded(false);
-  };
-
-  const buttons = (
-    <div className="d-flex flex-nowrap">
-      <ExpandOrContractButton
-        isWindowExpanded={isWindowExpanded}
-        expandWindow={expandWindow}
-        contractWindow={contractWindow}
-      />
-      <button type="button" className="close" onClick={closeModalHandler} aria-label="Close">
-        <span aria-hidden="true">&times;</span>
-      </button>
-    </div>
-  );
-
-  return (
-    <React.Fragment>
-      <Modal
-        size="xl"
-        isOpen={props.isOpen}
-        toggle={closeModalHandler}
-        className={`grw-page-accessories-modal ${isWindowExpanded ? 'grw-modal-expanded' : ''} `}
-      >
-        <ModalHeader className="p-0" toggle={closeModalHandler} close={buttons}>
-          <CustomNavTab
-            activeTab={activeTab}
-            navTabMapping={navTabMapping}
-            onNavSelected={switchActiveTab}
-            breakpointToHideInactiveTabsDown="md"
-            hideBorderBottom
-          />
-        </ModalHeader>
-        <ModalBody className="overflow-auto grw-modal-body-style">
-          {/* Do not use CustomTabContent because of performance problem:
-              the 'navTabMapping[tabId].Content' for PageAccessoriesModal depends on activeComponents */}
-          <TabContent activeTab={activeTab}>
-            <TabPane tabId="pagelist">
-              {activeComponents.has('pagelist') && <PageList />}
-            </TabPane>
-            <TabPane tabId="timeline">
-              {activeComponents.has('timeline') && <PageTimeline /> }
-            </TabPane>
-            {!isGuestUser && (
-              <TabPane tabId="pageHistory">
-                {activeComponents.has('pageHistory') && <PageHistory /> }
-              </TabPane>
-            )}
-            <TabPane tabId="attachment">
-              {activeComponents.has('attachment') && <PageAttachment />}
-            </TabPane>
-            {!isGuestUser && (
-              <TabPane tabId="shareLink">
-                {activeComponents.has('shareLink') && <ShareLink />}
-              </TabPane>
-            )}
-          </TabContent>
-        </ModalBody>
-      </Modal>
-    </React.Fragment>
-  );
-};
-
-/**
- * Wrapper component for using unstated
- */
-const PageAccessoriesModalWrapper = withUnstatedContainers(PageAccessoriesModal, [PageAccessoriesContainer]);
-
-PageAccessoriesModal.propTypes = {
-  t: PropTypes.func.isRequired, //  i18next
-  pageAccessoriesContainer: PropTypes.instanceOf(PageAccessoriesContainer).isRequired,
-  isGuestUser: PropTypes.bool.isRequired,
-  isSharedUser: PropTypes.bool.isRequired,
-  isNotFoundPage: PropTypes.bool.isRequired,
-  isOpen: PropTypes.bool.isRequired,
-  onClose: PropTypes.func,
-};
-
-export default withTranslation()(PageAccessoriesModalWrapper);

+ 134 - 0
packages/app/src/components/PageAccessoriesModal.tsx

@@ -0,0 +1,134 @@
+import React, { useMemo, useState } from 'react';
+
+import {
+  Modal, ModalBody, ModalHeader, TabContent, TabPane,
+} from 'reactstrap';
+
+import { useTranslation } from 'react-i18next';
+
+import { useIsGuestUser, useIsSharedUser } from '~/stores/context';
+import { usePageAccessoriesModal, PageAccessoriesModalContents } from '~/stores/ui';
+import AppContainer from '~/client/services/AppContainer';
+
+import HistoryIcon from './Icons/HistoryIcon';
+import AttachmentIcon from './Icons/AttachmentIcon';
+import ShareLinkIcon from './Icons/ShareLinkIcon';
+import { withUnstatedContainers } from './UnstatedUtils';
+import PageAttachment from './PageAttachment';
+import PageHistory from './PageHistory';
+import ShareLink from './ShareLink/ShareLink';
+import { CustomNavTab } from './CustomNavigation/CustomNav';
+import ExpandOrContractButton from './ExpandOrContractButton';
+
+
+type Props = {
+  appContainer: AppContainer,
+  isLinkSharingDisabled: boolean,
+}
+
+const PageAccessoriesModal = (props: Props): JSX.Element => {
+  const {
+    appContainer,
+  } = props;
+
+  const isLinkSharingDisabled = appContainer.config.disableLinkSharing;
+
+  const { t } = useTranslation();
+
+  const [activeTab, setActiveTab] = useState(PageAccessoriesModalContents.PageHistory);
+  const [isWindowExpanded, setIsWindowExpanded] = useState(false);
+
+  const { data: isSharedUser } = useIsSharedUser();
+  const { data: isGuestUser } = useIsGuestUser();
+
+  const { data: status, open, close } = usePageAccessoriesModal();
+
+  const navTabMapping = useMemo(() => {
+    return {
+      [PageAccessoriesModalContents.PageHistory]: {
+        Icon: HistoryIcon,
+        i18n: t('History'),
+        index: 0,
+        isLinkEnabled: () => !isGuestUser && !isSharedUser,
+      },
+      [PageAccessoriesModalContents.Attachment]: {
+        Icon: AttachmentIcon,
+        i18n: t('attachment_data'),
+        index: 1,
+      },
+      [PageAccessoriesModalContents.ShareLink]: {
+        Icon: ShareLinkIcon,
+        i18n: t('share_links.share_link_management'),
+        index: 2,
+        isLinkEnabled: () => !isGuestUser && !isSharedUser && !isLinkSharingDisabled,
+      },
+    };
+  }, [t, isGuestUser, isSharedUser, isLinkSharingDisabled]);
+
+  const buttons = useMemo(() => (
+    <div className="d-flex flex-nowrap">
+      <ExpandOrContractButton
+        isWindowExpanded={isWindowExpanded}
+        expandWindow={() => setIsWindowExpanded(true)}
+        contractWindow={() => setIsWindowExpanded(false)}
+      />
+      <button type="button" className="close" onClick={close} aria-label="Close">
+        <span aria-hidden="true">&times;</span>
+      </button>
+    </div>
+  ), [close, isWindowExpanded]);
+
+  if (status == null) {
+    return <></>;
+  }
+
+  const { isOpened, activatedContents } = status;
+
+  return (
+    <Modal
+      size="xl"
+      isOpen={isOpened}
+      toggle={close}
+      className={`grw-page-accessories-modal ${isWindowExpanded ? 'grw-modal-expanded' : ''} `}
+    >
+      <ModalHeader className="p-0" toggle={close} close={buttons}>
+        <CustomNavTab
+          activeTab={activeTab}
+          navTabMapping={navTabMapping}
+          breakpointToHideInactiveTabsDown="md"
+          onNavSelected={(v) => {
+            setActiveTab(v);
+            open(v);
+          }}
+          hideBorderBottom
+        />
+      </ModalHeader>
+      <ModalBody className="overflow-auto grw-modal-body-style">
+        {/* Do not use CustomTabContent because of performance problem:
+            the 'navTabMapping[tabId].Content' for PageAccessoriesModal depends on activatedContents */}
+        <TabContent activeTab={activeTab}>
+          {!isGuestUser && (
+            <TabPane tabId={PageAccessoriesModalContents.PageHistory}>
+              {activatedContents.has(PageAccessoriesModalContents.PageHistory) && <PageHistory /> }
+            </TabPane>
+          )}
+          <TabPane tabId={PageAccessoriesModalContents.Attachment}>
+            {activatedContents.has(PageAccessoriesModalContents.Attachment) && <PageAttachment />}
+          </TabPane>
+          {!isGuestUser && (
+            <TabPane tabId={PageAccessoriesModalContents.ShareLink}>
+              {activatedContents.has(PageAccessoriesModalContents.ShareLink) && <ShareLink />}
+            </TabPane>
+          )}
+        </TabContent>
+      </ModalBody>
+    </Modal>
+  );
+};
+
+/**
+ * Wrapper component for using unstated
+ */
+const PageAccessoriesModalWrapper = withUnstatedContainers(PageAccessoriesModal, [AppContainer]);
+
+export default PageAccessoriesModalWrapper;

+ 11 - 15
packages/app/src/components/PageAccessoriesModalControl.jsx

@@ -4,23 +4,25 @@ import PropTypes from 'prop-types';
 import { withTranslation } from 'react-i18next';
 import { withTranslation } from 'react-i18next';
 
 
 import { UncontrolledTooltip } from 'reactstrap';
 import { UncontrolledTooltip } from 'reactstrap';
-import PageAccessoriesContainer from '~/client/services/PageAccessoriesContainer';
 
 
 import PageListIcon from './Icons/PageListIcon';
 import PageListIcon from './Icons/PageListIcon';
 import TimeLineIcon from './Icons/TimeLineIcon';
 import TimeLineIcon from './Icons/TimeLineIcon';
 import HistoryIcon from './Icons/HistoryIcon';
 import HistoryIcon from './Icons/HistoryIcon';
 import AttachmentIcon from './Icons/AttachmentIcon';
 import AttachmentIcon from './Icons/AttachmentIcon';
 import ShareLinkIcon from './Icons/ShareLinkIcon';
 import ShareLinkIcon from './Icons/ShareLinkIcon';
-import SeenUserInfo from './User/SeenUserInfo';
 
 
 import { withUnstatedContainers } from './UnstatedUtils';
 import { withUnstatedContainers } from './UnstatedUtils';
 
 
+import { useCurrentPageId } from '~/stores/context';
+
 const PageAccessoriesModalControl = (props) => {
 const PageAccessoriesModalControl = (props) => {
   const {
   const {
-    t, pageAccessoriesContainer, isGuestUser, isSharedUser, isNotFoundPage,
+    t, pageAccessoriesContainer, isGuestUser, isSharedUser,
   } = props;
   } = props;
   const isLinkSharingDisabled = pageAccessoriesContainer.appContainer.config.disableLinkSharing;
   const isLinkSharingDisabled = pageAccessoriesContainer.appContainer.config.disableLinkSharing;
 
 
+  const { data: pageId } = useCurrentPageId();
+
   const accessoriesBtnList = useMemo(() => {
   const accessoriesBtnList = useMemo(() => {
     return [
     return [
       {
       {
@@ -38,23 +40,22 @@ const PageAccessoriesModalControl = (props) => {
       {
       {
         name: 'pageHistory',
         name: 'pageHistory',
         Icon: <HistoryIcon />,
         Icon: <HistoryIcon />,
-        disabled: isGuestUser || isSharedUser || isNotFoundPage,
+        disabled: isGuestUser || isSharedUser,
         i18n: t('History'),
         i18n: t('History'),
       },
       },
       {
       {
         name: 'attachment',
         name: 'attachment',
         Icon: <AttachmentIcon />,
         Icon: <AttachmentIcon />,
-        disabled: isNotFoundPage,
         i18n: t('attachment_data'),
         i18n: t('attachment_data'),
       },
       },
       {
       {
         name: 'shareLink',
         name: 'shareLink',
         Icon: <ShareLinkIcon />,
         Icon: <ShareLinkIcon />,
-        disabled: isGuestUser || isSharedUser || isNotFoundPage || isLinkSharingDisabled,
+        disabled: isGuestUser || isSharedUser || isLinkSharingDisabled,
         i18n: t('share_links.share_link_management'),
         i18n: t('share_links.share_link_management'),
       },
       },
     ];
     ];
-  }, [t, isGuestUser, isSharedUser, isNotFoundPage, isLinkSharingDisabled]);
+  }, [t, isGuestUser, isSharedUser, isLinkSharingDisabled]);
 
 
   return (
   return (
     <div className="grw-page-accessories-control d-flex flex-nowrap align-items-center justify-content-end justify-content-lg-between">
     <div className="grw-page-accessories-control d-flex flex-nowrap align-items-center justify-content-end justify-content-lg-between">
@@ -62,7 +63,7 @@ const PageAccessoriesModalControl = (props) => {
 
 
         let tooltipMessage;
         let tooltipMessage;
         if (accessory.disabled) {
         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) {
           if (accessory.name === 'shareLink' && isLinkSharingDisabled) {
             tooltipMessage = t('Link sharing is disabled');
             tooltipMessage = t('Link sharing is disabled');
           }
           }
@@ -88,26 +89,21 @@ const PageAccessoriesModalControl = (props) => {
           </Fragment>
           </Fragment>
         );
         );
       })}
       })}
-      <div className="d-flex align-items-center">
-        <span className="border-left grw-border-vr">&nbsp;</span>
-        <SeenUserInfo disabled={isSharedUser} />
-      </div>
     </div>
     </div>
   );
   );
 };
 };
 /**
 /**
  * Wrapper component for using unstated
  * Wrapper component for using unstated
  */
  */
-const PageAccessoriesModalControlWrapper = withUnstatedContainers(PageAccessoriesModalControl, [PageAccessoriesContainer]);
+const PageAccessoriesModalControlWrapper = withUnstatedContainers(PageAccessoriesModalControl, []);
 
 
 PageAccessoriesModalControl.propTypes = {
 PageAccessoriesModalControl.propTypes = {
   t: PropTypes.func.isRequired, //  i18next
   t: PropTypes.func.isRequired, //  i18next
 
 
-  pageAccessoriesContainer: PropTypes.instanceOf(PageAccessoriesContainer).isRequired,
+  pageAccessoriesContainer: PropTypes.any,
 
 
   isGuestUser: PropTypes.bool.isRequired,
   isGuestUser: PropTypes.bool.isRequired,
   isSharedUser: PropTypes.bool.isRequired,
   isSharedUser: PropTypes.bool.isRequired,
-  isNotFoundPage: PropTypes.bool.isRequired,
 };
 };
 
 
 export default withTranslation()(PageAccessoriesModalControlWrapper);
 export default withTranslation()(PageAccessoriesModalControlWrapper);

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

@@ -6,14 +6,10 @@ import {
 import { useTranslation } from 'react-i18next';
 import { useTranslation } from 'react-i18next';
 
 
 // import { apiPost } from '~/client/util/apiv1-client';
 // import { apiPost } from '~/client/util/apiv1-client';
+import { usePageDeleteModalStatus, usePageDeleteModalOpened } from '~/stores/ui';
 
 
 import ApiErrorMessageList from './PageManagement/ApiErrorMessageList';
 import ApiErrorMessageList from './PageManagement/ApiErrorMessageList';
 
 
-export type IPageForPageDeleteModal = {
-  pageId: string,
-  revisionId: string,
-  path: string
-}
 
 
 const deleteIconAndKey = {
 const deleteIconAndKey = {
   completely: {
   completely: {
@@ -30,7 +26,6 @@ const deleteIconAndKey = {
 
 
 type Props = {
 type Props = {
   isOpen: boolean,
   isOpen: boolean,
-  pages: IPageForPageDeleteModal[],
   isDeleteCompletelyModal: boolean,
   isDeleteCompletelyModal: boolean,
   isAbleToDeleteCompletely: boolean,
   isAbleToDeleteCompletely: boolean,
   onClose?: () => void,
   onClose?: () => void,
@@ -39,12 +34,18 @@ type Props = {
 const PageDeleteModal: FC<Props> = (props: Props) => {
 const PageDeleteModal: FC<Props> = (props: Props) => {
   const { t } = useTranslation('');
   const { t } = useTranslation('');
   const {
   const {
-    isOpen, onClose, isDeleteCompletelyModal, pages, isAbleToDeleteCompletely,
+    isDeleteCompletelyModal, isAbleToDeleteCompletely,
   } = props;
   } = props;
+
+
+  const { data: pagesDataToDelete, close: closeDeleteModal } = usePageDeleteModalStatus();
+  const { data: isOpened } = usePageDeleteModalOpened();
+
   const [isDeleteRecursively, setIsDeleteRecursively] = useState(true);
   const [isDeleteRecursively, setIsDeleteRecursively] = useState(true);
   const [isDeleteCompletely, setIsDeleteCompletely] = useState(isDeleteCompletelyModal && isAbleToDeleteCompletely);
   const [isDeleteCompletely, setIsDeleteCompletely] = useState(isDeleteCompletelyModal && isAbleToDeleteCompletely);
   const deleteMode = isDeleteCompletely ? 'completely' : 'temporary';
   const deleteMode = isDeleteCompletely ? 'completely' : 'temporary';
 
 
+  // eslint-disable-next-line @typescript-eslint/no-unused-vars
   const [errs, setErrs] = useState(null);
   const [errs, setErrs] = useState(null);
 
 
   function changeIsDeleteRecursivelyHandler() {
   function changeIsDeleteRecursivelyHandler() {
@@ -142,9 +143,16 @@ const PageDeleteModal: FC<Props> = (props: Props) => {
     );
     );
   }
   }
 
 
+  const renderPagePathsToDelete = () => {
+    if (pagesDataToDelete != null && pagesDataToDelete.pages != null) {
+      return pagesDataToDelete.pages.map(page => <div key={page.pageId}><code>{ page.path }</code></div>);
+    }
+    return <></>;
+  };
+
   return (
   return (
-    <Modal size="lg" isOpen={isOpen} toggle={onClose} className="grw-create-page">
-      <ModalHeader tag="h4" toggle={onClose} className={`bg-${deleteIconAndKey[deleteMode].color} text-light`}>
+    <Modal size="lg" isOpen={isOpened} toggle={closeDeleteModal} className="grw-create-page">
+      <ModalHeader tag="h4" toggle={closeDeleteModal} className={`bg-${deleteIconAndKey[deleteMode].color} text-light`}>
         <i className={`icon-fw icon-${deleteIconAndKey[deleteMode].icon}`}></i>
         <i className={`icon-fw icon-${deleteIconAndKey[deleteMode].icon}`}></i>
         { t(`modal_delete.delete_${deleteIconAndKey[deleteMode].translationKey}`) }
         { t(`modal_delete.delete_${deleteIconAndKey[deleteMode].translationKey}`) }
       </ModalHeader>
       </ModalHeader>
@@ -153,9 +161,7 @@ const PageDeleteModal: FC<Props> = (props: Props) => {
           <label>{ t('modal_delete.deleting_page') }:</label><br />
           <label>{ t('modal_delete.deleting_page') }:</label><br />
           {/* Todo: change the way to show path on modal when too many pages are selected */}
           {/* Todo: change the way to show path on modal when too many pages are selected */}
           {/* https://redmine.weseek.co.jp/issues/82787 */}
           {/* https://redmine.weseek.co.jp/issues/82787 */}
-          {pages.map((page) => {
-            return <div key={page.pageId}><code>{ page.path }</code></div>;
-          })}
+          {renderPagePathsToDelete()}
         </div>
         </div>
         {renderDeleteRecursivelyForm()}
         {renderDeleteRecursivelyForm()}
         {!isDeleteCompletelyModal && renderDeleteCompletelyForm()}
         {!isDeleteCompletelyModal && renderDeleteCompletelyForm()}

+ 15 - 15
packages/app/src/components/PageDuplicateModal.jsx

@@ -9,6 +9,7 @@ import { withTranslation } from 'react-i18next';
 import { debounce } from 'throttle-debounce';
 import { debounce } from 'throttle-debounce';
 import { withUnstatedContainers } from './UnstatedUtils';
 import { withUnstatedContainers } from './UnstatedUtils';
 import { toastError } from '~/client/util/apiNotification';
 import { toastError } from '~/client/util/apiNotification';
+import { usePageDuplicateModalStatus, usePageDuplicateModalOpened } from '~/stores/ui';
 
 
 import AppContainer from '~/client/services/AppContainer';
 import AppContainer from '~/client/services/AppContainer';
 import PagePathAutoComplete from './PagePathAutoComplete';
 import PagePathAutoComplete from './PagePathAutoComplete';
@@ -20,12 +21,16 @@ const LIMIT_FOR_LIST = 10;
 
 
 const PageDuplicateModal = (props) => {
 const PageDuplicateModal = (props) => {
   const {
   const {
-    t, appContainer, pageId, path,
+    t, appContainer,
   } = props;
   } = props;
 
 
   const config = appContainer.getConfig();
   const config = appContainer.getConfig();
   const isReachable = config.isSearchServiceReachable;
   const isReachable = config.isSearchServiceReachable;
   const { crowi } = appContainer.config;
   const { crowi } = appContainer.config;
+  const { data: pagesDataToDuplicate, close: closeDuplicateModal } = usePageDuplicateModalStatus();
+  const { data: isOpened } = usePageDuplicateModalOpened();
+
+  const { path, pageId } = pagesDataToDuplicate;
 
 
   const [pageNameInput, setPageNameInput] = useState(path);
   const [pageNameInput, setPageNameInput] = useState(path);
 
 
@@ -50,14 +55,14 @@ const PageDuplicateModal = (props) => {
 
 
   // eslint-disable-next-line react-hooks/exhaustive-deps
   // eslint-disable-next-line react-hooks/exhaustive-deps
   const checkExistPathsDebounce = useCallback(
   const checkExistPathsDebounce = useCallback(
-    debounce(1000, checkExistPaths), [],
+    debounce(1000, checkExistPaths), [pageId, path],
   );
   );
 
 
   useEffect(() => {
   useEffect(() => {
-    if (pageNameInput !== path) {
+    if (pageId != null && pageNameInput !== path) {
       checkExistPathsDebounce(pageNameInput, subordinatedPages);
       checkExistPathsDebounce(pageNameInput, subordinatedPages);
     }
     }
-  }, [pageNameInput, subordinatedPages, path, checkExistPathsDebounce]);
+  }, [pageNameInput, subordinatedPages, path, pageId, checkExistPathsDebounce]);
 
 
   /**
   /**
    * change pageNameInput for PagePathAutoComplete
    * change pageNameInput for PagePathAutoComplete
@@ -94,10 +99,11 @@ const PageDuplicateModal = (props) => {
   }, [appContainer, path, t]);
   }, [appContainer, path, t]);
 
 
   useEffect(() => {
   useEffect(() => {
-    if (props.isOpen) {
+    if (isOpened) {
       getSubordinatedList();
       getSubordinatedList();
+      setPageNameInput(path);
     }
     }
-  }, [props.isOpen, getSubordinatedList]);
+  }, [isOpened, getSubordinatedList, path]);
 
 
   function changeIsDuplicateRecursivelyWithoutExistPathHandler() {
   function changeIsDuplicateRecursivelyWithoutExistPathHandler() {
     setIsDuplicateRecursivelyWithoutExistPath(!isDuplicateRecursivelyWithoutExistPath);
     setIsDuplicateRecursivelyWithoutExistPath(!isDuplicateRecursivelyWithoutExistPath);
@@ -120,8 +126,8 @@ const PageDuplicateModal = (props) => {
   }
   }
 
 
   return (
   return (
-    <Modal size="lg" isOpen={props.isOpen} toggle={props.onClose} className="grw-duplicate-page" autoFocus={false}>
-      <ModalHeader tag="h4" toggle={props.onClose} className="bg-primary text-light">
+    <Modal size="lg" isOpen={isOpened} toggle={closeDuplicateModal} className="grw-duplicate-page" autoFocus={false}>
+      <ModalHeader tag="h4" toggle={closeDuplicateModal} className="bg-primary text-light">
         { t('modal_duplicate.label.Duplicate page') }
         { t('modal_duplicate.label.Duplicate page') }
       </ModalHeader>
       </ModalHeader>
       <ModalBody>
       <ModalBody>
@@ -188,7 +194,7 @@ const PageDuplicateModal = (props) => {
             )}
             )}
           </div>
           </div>
           <div>
           <div>
-            {isDuplicateRecursively && <ComparePathsTable path={path} subordinatedPages={subordinatedPages} newPagePath={pageNameInput} />}
+            {isDuplicateRecursively && path != null && <ComparePathsTable path={path} subordinatedPages={subordinatedPages} newPagePath={pageNameInput} />}
             {isDuplicateRecursively && existingPaths.length !== 0 && <DuplicatePathsTable existingPaths={existingPaths} oldPagePath={pageNameInput} />}
             {isDuplicateRecursively && existingPaths.length !== 0 && <DuplicatePathsTable existingPaths={existingPaths} oldPagePath={pageNameInput} />}
           </div>
           </div>
         </div>
         </div>
@@ -219,12 +225,6 @@ const PageDuplicateModallWrapper = withUnstatedContainers(PageDuplicateModal, [A
 PageDuplicateModal.propTypes = {
 PageDuplicateModal.propTypes = {
   t: PropTypes.func.isRequired, //  i18next
   t: PropTypes.func.isRequired, //  i18next
   appContainer: PropTypes.instanceOf(AppContainer).isRequired,
   appContainer: PropTypes.instanceOf(AppContainer).isRequired,
-
-  isOpen: PropTypes.bool.isRequired,
-  onClose: PropTypes.func.isRequired,
-
-  pageId: PropTypes.string.isRequired,
-  path: PropTypes.string.isRequired,
 };
 };
 
 
 export default withTranslation()(PageDuplicateModallWrapper);
 export default withTranslation()(PageDuplicateModallWrapper);

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

@@ -29,7 +29,7 @@ class RevisionDiff extends React.Component {
       }
       }
 
 
       const patch = createPatch(
       const patch = createPatch(
-        currentRevision.path,
+        currentRevision.pageId, // currentRevision.path is DEPRECATED
         previousText,
         previousText,
         currentRevision.body,
         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 PaginationWrapper from '../PaginationWrapper';
 
 
-import Page from './Page';
+import PageListItemS from './PageListItemS';
 
 
 const logger = loggerFactory('growi:BookmarkList');
 const logger = loggerFactory('growi:BookmarkList');
 
 
@@ -56,7 +56,7 @@ const BookmarkList = (props) => {
    */
    */
   const generatePageList = pages.map(page => (
   const generatePageList = pages.map(page => (
     <li key={`my-bookmarks:${page._id}`} className="mt-4">
     <li key={`my-bookmarks:${page._id}`} className="mt-4">
-      <Page page={page.page} />
+      <PageListItemS page={page.page} />
     </li>
     </li>
   ));
   ));
 
 

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

@@ -0,0 +1,50 @@
+import React from 'react';
+import { useTranslation } from 'react-i18next';
+
+import { IPageWithMeta } from '~/interfaces/page';
+import { IPagingResult } from '~/interfaces/paging-result';
+
+import { PageListItemL } from './PageListItemL';
+
+
+type Props = {
+  pages: IPagingResult<IPageWithMeta>,
+  isEnableActions?: boolean,
+}
+
+const PageList = (props: Props): JSX.Element => {
+  const { t } = useTranslation();
+  const { pages, isEnableActions } = 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 key={page.pageData._id} page={page} isEnableActions={isEnableActions} />
+  ));
+
+  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 list-group-flush">
+        {pageList}
+      </ul>
+    </div>
+  );
+};
+
+export default PageList;

+ 140 - 0
packages/app/src/components/PageList/PageListItemL.tsx

@@ -0,0 +1,140 @@
+import React, { memo, useCallback } from 'react';
+
+import Clamp from 'react-multiline-clamp';
+import { format } from 'date-fns';
+
+import { UserPicture, PageListMeta } from '@growi/ui';
+import { DevidedPagePath } from '@growi/core';
+import { useIsDeviceSmallerThanLg } from '~/stores/ui';
+import {
+  IPageInfoAll, IPageWithMeta, isIPageInfoForEntity, isIPageInfoForListing,
+} from '~/interfaces/page';
+import { IPageSearchMeta, isIPageSearchMeta } from '~/interfaces/search';
+
+import { PageItemControl } from '../Common/Dropdown/PageItemControl';
+import LinkedPagePath from '~/models/linked-page-path';
+import PagePathHierarchicalLink from '../PagePathHierarchicalLink';
+
+type Props = {
+  page: IPageWithMeta | IPageWithMeta<IPageInfoAll & IPageSearchMeta>,
+  isSelected?: boolean, // is item selected(focused)
+  isChecked?: boolean, // is checkbox of item checked
+  isEnableActions?: boolean,
+  showPageUpdatedTime?: boolean, // whether to show page's updated time at the top-right corner of item
+  onClickCheckbox?: (pageId: string) => void,
+  onClickItem?: (pageId: string) => void,
+  onClickDeleteButton?: (pageId: string) => void,
+}
+
+export const PageListItemL = memo((props: Props): JSX.Element => {
+  const {
+    // todo: refactoring variable name to clear what changed
+    page: { pageData, pageMeta }, isSelected, onClickItem, onClickCheckbox, isChecked, isEnableActions,
+    showPageUpdatedTime,
+  } = props;
+
+  const { data: isDeviceSmallerThanLg } = useIsDeviceSmallerThanLg();
+
+  const elasticSearchResult = isIPageSearchMeta(pageMeta) ? pageMeta.elasticSearchResult : null;
+  const revisionShortBody = isIPageInfoForListing(pageMeta) ? pageMeta.revisionShortBody : null;
+
+  const dPagePath: DevidedPagePath = new DevidedPagePath(pageData.path, true);
+  const linkedPagePathFormer = new LinkedPagePath(dPagePath.former);
+  const linkedPagePathLatter = new LinkedPagePath(dPagePath.latter);
+
+  const lastUpdateDate = format(new Date(pageData.updatedAt), 'yyyy/MM/dd HH:mm:ss');
+
+  // click event handler
+  const clickHandler = useCallback(() => {
+    // do nothing if mobile
+    if (isDeviceSmallerThanLg) {
+      return;
+    }
+
+    if (onClickItem != null) {
+      onClickItem(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' : '';
+
+  return (
+    <li
+      key={pageData._id}
+      className={`list-group-item p-0 ${styleListGroupItem} ${styleActive}`}
+    >
+      <div
+        className="text-break"
+        onClick={clickHandler}
+      >
+        <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">
+              <input
+                className="form-check-input position-relative m-0"
+                type="checkbox"
+                id="flexCheckDefault"
+                onChange={() => { onClickCheckbox(pageData._id) }}
+                checked={isChecked}
+              />
+            </div>
+          )}
+
+          <div className="flex-grow-1 p-md-3 pl-2 py-3 pr-3">
+            <div className="d-flex justify-content-between">
+              {/* page path */}
+              <PagePathHierarchicalLink linkedPagePath={linkedPagePathFormer} />
+              { showPageUpdatedTime && (
+                <span className="page-list-updated-at text-muted">Last update: {lastUpdateDate}</span>
+              ) }
+            </div>
+            <div className="d-flex align-items-center mb-1">
+              {/* Picture */}
+              <span className="mr-2 d-none d-md-block">
+                <UserPicture user={pageData.lastUpdateUser} size="md" />
+              </span>
+              {/* page title */}
+              <Clamp lines={1}>
+                <span className="h5 mb-0">
+                  <PagePathHierarchicalLink linkedPagePath={linkedPagePathLatter} basePath={dPagePath.former} />
+                </span>
+              </Clamp>
+
+              {/* page meta */}
+              { isIPageInfoForEntity(pageMeta) && (
+                <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">
+                <PageItemControl
+                  pageId={pageData._id}
+                  pageInfo={pageMeta}
+                  onClickDeleteMenuItem={props.onClickDeleteButton}
+                  isEnableActions={isEnableActions}
+                />
+              </div>
+            </div>
+            <div className="page-list-snippet py-1">
+              <Clamp lines={2}>
+                { elasticSearchResult != null && elasticSearchResult?.snippet.length > 0 && (
+                  // eslint-disable-next-line react/no-danger
+                  <div dangerouslySetInnerHTML={{ __html: elasticSearchResult.snippet }}></div>
+                ) }
+                { revisionShortBody != null && (
+                  <div>{revisionShortBody}</div>
+                ) }
+              </Clamp>
+            </div>
+          </div>
+        </div>
+        {/* TODO: adjust snippet position */}
+      </div>
+    </li>
+  );
+});

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

+ 9 - 7
packages/app/src/components/PagePathNav.tsx

@@ -7,8 +7,8 @@ import LinkedPagePath from '../models/linked-page-path';
 
 
 
 
 type Props = {
 type Props = {
-  pageId :string,
-  pagePath:string,
+  pagePath: string,
+  pageId?: string | null,
   isSingleLineMode?:boolean,
   isSingleLineMode?:boolean,
   isCompactMode?:boolean,
   isCompactMode?:boolean,
 }
 }
@@ -43,11 +43,13 @@ const PagePathNav: FC<Props> = (props: Props) => {
       {formerLink}
       {formerLink}
       <span className="d-flex align-items-center">
       <span className="d-flex align-items-center">
         <h1 className="m-0">{latterLink}</h1>
         <h1 className="m-0">{latterLink}</h1>
-        <div className="mx-2">
-          <CopyDropdown pageId={pageId} pagePath={pagePath} dropdownToggleId={copyDropdownId} dropdownToggleClassName={copyDropdownToggleClassName}>
-            <i className="ti-clipboard"></i>
-          </CopyDropdown>
-        </div>
+        { pageId != null && (
+          <div className="mx-2">
+            <CopyDropdown pageId={pageId} pagePath={pagePath} dropdownToggleId={copyDropdownId} dropdownToggleClassName={copyDropdownToggleClassName}>
+              <i className="ti-clipboard"></i>
+            </CopyDropdown>
+          </div>
+        ) }
       </span>
       </span>
     </div>
     </div>
   );
   );

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

@@ -1,49 +0,0 @@
-import React, { FC } from 'react';
-import LikeButtons from './LikeButtons';
-import { IUser } from '../interfaces/user';
-import BookmarkButtons from './BookmarkButtons';
-
-type Props = {
-  isCompactMode?: boolean,
-
-  isLiked: boolean,
-  sumOfLikers: number,
-  likers: IUser[],
-  onLikeClicked?: ()=>void,
-
-  isBookmarked: boolean,
-  sumOfBookmarks: number,
-  bookmarkedUsers: IUser[]
-  onBookMarkClicked: ()=>void,
-}
-
-
-const PageReactionButtons : FC<Props> = (props: Props) => {
-  const {
-    isCompactMode, sumOfLikers, isLiked, likers, onLikeClicked, sumOfBookmarks, isBookmarked, bookmarkedUsers, onBookMarkClicked,
-  } = props;
-
-
-  return (
-    <>
-      <LikeButtons
-        hideTotalNumber={isCompactMode}
-        onLikeClicked={onLikeClicked}
-        sumOfLikers={sumOfLikers}
-        isLiked={isLiked}
-        likers={likers}
-      >
-      </LikeButtons>
-      <BookmarkButtons
-        hideTotalNumber={isCompactMode}
-        sumOfBookmarks={sumOfBookmarks}
-        isBookmarked={isBookmarked}
-        bookmarkedUsers={bookmarkedUsers}
-        onBookMarkClicked={onBookMarkClicked}
-      >
-      </BookmarkButtons>
-    </>
-  );
-};
-
-export default PageReactionButtons;

+ 16 - 17
packages/app/src/components/PageRenameModal.jsx

@@ -10,6 +10,7 @@ import {
 import { withTranslation } from 'react-i18next';
 import { withTranslation } from 'react-i18next';
 
 
 import { debounce } from 'throttle-debounce';
 import { debounce } from 'throttle-debounce';
+import { usePageRenameModalStatus, usePageRenameModalOpened } from '~/stores/ui';
 import { withUnstatedContainers } from './UnstatedUtils';
 import { withUnstatedContainers } from './UnstatedUtils';
 import { toastError } from '~/client/util/apiNotification';
 import { toastError } from '~/client/util/apiNotification';
 
 
@@ -24,12 +25,16 @@ import DuplicatedPathsTable from './DuplicatedPathsTable';
 
 
 const PageRenameModal = (props) => {
 const PageRenameModal = (props) => {
   const {
   const {
-    t, appContainer, path, pageId, revisionId,
+    t, appContainer,
   } = props;
   } = props;
 
 
   const { crowi } = appContainer.config;
   const { crowi } = appContainer.config;
+  const { data: isOpened } = usePageRenameModalOpened();
+  const { data: pagesDataToRename, close: closeRenameModal } = usePageRenameModalStatus();
 
 
-  const [pageNameInput, setPageNameInput] = useState(path);
+  const { path, revisionId, pageId } = pagesDataToRename;
+
+  const [pageNameInput, setPageNameInput] = useState('');
 
 
   const [errs, setErrs] = useState(null);
   const [errs, setErrs] = useState(null);
 
 
@@ -70,10 +75,11 @@ const PageRenameModal = (props) => {
   }, [path, t]);
   }, [path, t]);
 
 
   useEffect(() => {
   useEffect(() => {
-    if (props.isOpen) {
+    if (isOpened) {
       updateSubordinatedList();
       updateSubordinatedList();
+      setPageNameInput(path);
     }
     }
-  }, [props.isOpen, updateSubordinatedList]);
+  }, [isOpened, path, updateSubordinatedList]);
 
 
 
 
   const checkExistPaths = async(newParentPath) => {
   const checkExistPaths = async(newParentPath) => {
@@ -90,14 +96,14 @@ const PageRenameModal = (props) => {
 
 
   // eslint-disable-next-line react-hooks/exhaustive-deps
   // eslint-disable-next-line react-hooks/exhaustive-deps
   const checkExistPathsDebounce = useCallback(
   const checkExistPathsDebounce = useCallback(
-    debounce(1000, checkExistPaths), [],
+    debounce(1000, checkExistPaths), [path],
   );
   );
 
 
   useEffect(() => {
   useEffect(() => {
-    if (pageNameInput !== path) {
+    if (pageId != null && pageNameInput !== path) {
       checkExistPathsDebounce(pageNameInput, subordinatedPages);
       checkExistPathsDebounce(pageNameInput, subordinatedPages);
     }
     }
-  }, [pageNameInput, subordinatedPages, path, checkExistPathsDebounce]);
+  }, [pageNameInput, subordinatedPages, pageId, path, checkExistPathsDebounce]);
 
 
   /**
   /**
    * change pageNameInput
    * change pageNameInput
@@ -137,8 +143,8 @@ const PageRenameModal = (props) => {
   }
   }
 
 
   return (
   return (
-    <Modal size="lg" isOpen={props.isOpen} toggle={props.onClose} autoFocus={false}>
-      <ModalHeader tag="h4" toggle={props.onClose} className="bg-primary text-light">
+    <Modal size="lg" isOpen={isOpened} toggle={closeRenameModal} autoFocus={false}>
+      <ModalHeader tag="h4" toggle={closeRenameModal} className="bg-primary text-light">
         { t('modal_rename.label.Move/Rename page') }
         { t('modal_rename.label.Move/Rename page') }
       </ModalHeader>
       </ModalHeader>
       <ModalBody>
       <ModalBody>
@@ -195,7 +201,7 @@ const PageRenameModal = (props) => {
               </label>
               </label>
             </div>
             </div>
           )}
           )}
-          {isRenameRecursively && <ComparePathsTable path={path} subordinatedPages={subordinatedPages} newPagePath={pageNameInput} />}
+          {isRenameRecursively && path != null && <ComparePathsTable path={path} subordinatedPages={subordinatedPages} newPagePath={pageNameInput} />}
           {isRenameRecursively && existingPaths.length !== 0 && <DuplicatedPathsTable existingPaths={existingPaths} oldPagePath={pageNameInput} />}
           {isRenameRecursively && existingPaths.length !== 0 && <DuplicatedPathsTable existingPaths={existingPaths} oldPagePath={pageNameInput} />}
         </div>
         </div>
 
 
@@ -252,13 +258,6 @@ const PageRenameModalWrapper = withUnstatedContainers(PageRenameModal, [AppConta
 PageRenameModal.propTypes = {
 PageRenameModal.propTypes = {
   t: PropTypes.func.isRequired, //  i18next
   t: PropTypes.func.isRequired, //  i18next
   appContainer: PropTypes.instanceOf(AppContainer).isRequired,
   appContainer: PropTypes.instanceOf(AppContainer).isRequired,
-
-  isOpen: PropTypes.bool.isRequired,
-  onClose: PropTypes.func.isRequired,
-
-  pageId: PropTypes.string.isRequired,
-  revisionId: PropTypes.string.isRequired,
-  path: PropTypes.string.isRequired,
 };
 };
 
 
 export default withTranslation()(PageRenameModalWrapper);
 export default withTranslation()(PageRenameModalWrapper);

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

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

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

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

+ 3 - 21
packages/app/src/components/SearchPage.jsx

@@ -19,7 +19,6 @@ import SearchControl from './SearchPage/SearchControl';
 import { CheckboxType, SORT_AXIS, SORT_ORDER } from '~/interfaces/search';
 import { CheckboxType, SORT_AXIS, SORT_ORDER } from '~/interfaces/search';
 import PageDeleteModal from './PageDeleteModal';
 import PageDeleteModal from './PageDeleteModal';
 import { useIsGuestUser } from '~/stores/context';
 import { useIsGuestUser } from '~/stores/context';
-import { apiv3Get } from '~/client/util/apiv3-client';
 
 
 export const specificPathNames = {
 export const specificPathNames = {
   user: '/user',
   user: '/user',
@@ -40,7 +39,6 @@ class SearchPage extends React.Component {
       focusedSearchResultData: null,
       focusedSearchResultData: null,
       selectedPagesIdList: new Set(),
       selectedPagesIdList: new Set(),
       searchResultCount: 0,
       searchResultCount: 0,
-      shortBodiesMap: null,
       activePage: 1,
       activePage: 1,
       pagingLimit: this.props.appContainer.config.pageLimitationL || 50,
       pagingLimit: this.props.appContainer.config.pageLimitationL || 50,
       excludeUserPages: true,
       excludeUserPages: true,
@@ -152,11 +150,6 @@ class SearchPage extends React.Component {
     this.setState({ pagingLimit: limit }, () => this.search({ keyword: this.state.searchedKeyword }));
     this.setState({ pagingLimit: limit }, () => this.search({ keyword: this.state.searchedKeyword }));
   }
   }
 
 
-  async fetchShortBodiesMap(pageIds) {
-    const res = await apiv3Get('/page-listing/short-bodies', { pageIds });
-    this.setState({ shortBodiesMap: res.data.shortBodiesMap });
-  }
-
   // todo: refactoring
   // todo: refactoring
   // refs: https://redmine.weseek.co.jp/issues/82139
   // refs: https://redmine.weseek.co.jp/issues/82139
   async search(data) {
   async search(data) {
@@ -195,18 +188,6 @@ class SearchPage extends React.Component {
         order,
         order,
       });
       });
 
 
-      /*
-       * non-await asynchronous short body fetch
-       */
-      const pageIds = res.data.map((page) => {
-        if (page.pageMeta?.elasticSearchResult != null && page.pageMeta?.elasticSearchResult?.snippet.length !== 0) {
-          return null;
-        }
-
-        return page.pageData._id;
-      }).filter(id => id != null);
-      this.fetchShortBodiesMap(pageIds);
-
       this.changeURL(keyword);
       this.changeURL(keyword);
       if (res.data.length > 0) {
       if (res.data.length > 0) {
         this.setState({
         this.setState({
@@ -311,6 +292,7 @@ class SearchPage extends React.Component {
         appContainer={this.props.appContainer}
         appContainer={this.props.appContainer}
         searchingKeyword={this.state.searchingKeyword}
         searchingKeyword={this.state.searchingKeyword}
         focusedSearchResultData={this.state.focusedSearchResultData}
         focusedSearchResultData={this.state.focusedSearchResultData}
+        showPageControlDropdown={!this.props.isGuestUser}
       >
       >
       </SearchResultContent>
       </SearchResultContent>
     );
     );
@@ -324,10 +306,9 @@ class SearchPage extends React.Component {
         focusedSearchResultData={this.state.focusedSearchResultData}
         focusedSearchResultData={this.state.focusedSearchResultData}
         selectedPagesIdList={this.state.selectedPagesIdList || []}
         selectedPagesIdList={this.state.selectedPagesIdList || []}
         searchResultCount={this.state.searchResultCount}
         searchResultCount={this.state.searchResultCount}
-        shortBodiesMap={this.state.shortBodiesMap}
         activePage={this.state.activePage}
         activePage={this.state.activePage}
         pagingLimit={this.state.pagingLimit}
         pagingLimit={this.state.pagingLimit}
-        onClickSearchResultItem={this.selectPage}
+        onClickItem={this.selectPage}
         onClickCheckbox={this.toggleCheckBox}
         onClickCheckbox={this.toggleCheckBox}
         onPagingNumberChanged={this.onPagingNumberChanged}
         onPagingNumberChanged={this.onPagingNumberChanged}
         onClickDeleteButton={this.deleteSinglePageButtonHandler}
         onClickDeleteButton={this.deleteSinglePageButtonHandler}
@@ -371,6 +352,7 @@ class SearchPage extends React.Component {
           activePage={this.state.activePage}
           activePage={this.state.activePage}
         >
         >
         </SearchPageLayout>
         </SearchPageLayout>
+        {/* TODO: show PageDeleteModal with usePageDeleteModalStatus by 87569  */}
         <PageDeleteModal
         <PageDeleteModal
           isOpen={this.state.isDeleteConfirmModalShown}
           isOpen={this.state.isDeleteConfirmModalShown}
           onClose={this.closeDeleteConfirmModalHandler}
           onClose={this.closeDeleteConfirmModalHandler}

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

@@ -34,7 +34,7 @@ const SearchPageLayout: FC<Props> = (props: Props) => {
   return (
   return (
     <div className="content-main">
     <div className="content-main">
       <div className="search-result d-flex" id="search-result">
       <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>
           <SearchControl></SearchControl>
           <div className="search-result-list-scroll">
           <div className="search-result-list-scroll">
@@ -62,8 +62,8 @@ const SearchPageLayout: FC<Props> = (props: Props) => {
               </div>
               </div>
             </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>
           </div>
         </div>
         </div>

+ 77 - 15
packages/app/src/components/SearchPage/SearchResultContent.tsx

@@ -1,34 +1,96 @@
-import React, { FC } from 'react';
+import React, { FC, useCallback } from 'react';
 
 
-import { IPageSearchResultData } from '../../interfaces/search';
+import { DropdownItem } from 'reactstrap';
+import { useTranslation } from 'react-i18next';
+
+import { IPageWithMeta } from '~/interfaces/page';
+import { IPageSearchMeta } from '~/interfaces/search';
+
+import { exportAsMarkdown } from '~/client/services/page-operation';
 
 
 import RevisionLoader from '../Page/RevisionLoader';
 import RevisionLoader from '../Page/RevisionLoader';
 import AppContainer from '../../client/services/AppContainer';
 import AppContainer from '../../client/services/AppContainer';
-import SearchResultContentSubNavigation from './SearchResultContentSubNavigation';
+import { GrowiSubNavigation } from '../Navbar/GrowiSubNavigation';
+import { SubNavButtons } from '../Navbar/SubNavButtons';
+import { AdditionalMenuItemsRendererProps } from '../Common/Dropdown/PageItemControl';
+
+
+type AdditionalMenuItemsProps = AdditionalMenuItemsRendererProps & {
+  pageId: string,
+  revisionId: string,
+}
+
+const AdditionalMenuItems = (props: AdditionalMenuItemsProps): JSX.Element => {
+  const { t } = useTranslation();
+
+  const { pageId, revisionId } = props;
+
+  return (
+    <>
+      <DropdownItem divider />
+
+      {/* Export markdown */}
+      <DropdownItem onClick={() => exportAsMarkdown(pageId, revisionId, 'md')}>
+        <i className="icon-fw icon-cloud-download"></i>
+        {t('export_bulk.export_page_markdown')}
+      </DropdownItem>
+    </>
+  );
+};
 
 
-// TODO : set focusedPage type to ?IPageSearchResultData once #80214 is merged
-// PR: https://github.com/weseek/growi/pull/4649
 
 
 type Props ={
 type Props ={
   appContainer: AppContainer,
   appContainer: AppContainer,
   searchingKeyword:string,
   searchingKeyword:string,
-  focusedSearchResultData : IPageSearchResultData,
+  focusedSearchResultData : IPageWithMeta<IPageSearchMeta>,
+  showPageControlDropdown?: boolean,
 }
 }
 
 
-
 const SearchResultContent: FC<Props> = (props: Props) => {
 const SearchResultContent: FC<Props> = (props: Props) => {
-  const page = props.focusedSearchResultData?.pageData;
+  const {
+    appContainer,
+    focusedSearchResultData,
+    showPageControlDropdown,
+  } = props;
+
+  const page = focusedSearchResultData?.pageData;
+
+  const growiRenderer = appContainer.getRenderer('searchresult');
+
+  const ControlComponents = useCallback(() => {
+    if (page == null) {
+      return <></>;
+    }
+
+    const revisionId = typeof page.revision === 'string'
+      ? page.revision
+      : page.revision._id;
+
+    return (
+      <>
+        <div className="h-50 d-flex flex-column align-items-end justify-content-center">
+          <SubNavButtons
+            pageId={page._id}
+            revisionId={revisionId}
+            showPageControlDropdown={showPageControlDropdown}
+            additionalMenuItemRenderer={props => <AdditionalMenuItems {...props} pageId={page._id} revisionId={revisionId} />}
+          />
+        </div>
+        <div className="h-50 d-flex flex-column align-items-end justify-content-center">
+        </div>
+      </>
+    );
+  }, [page, showPageControlDropdown]);
+
   // return if page is null
   // return if page is null
   if (page == null) return <></>;
   if (page == null) return <></>;
-  const growiRenderer = props.appContainer.getRenderer('searchresult');
+
   return (
   return (
     <div key={page._id} className="search-result-page grw-page-path-text-muted-container d-flex flex-column">
     <div key={page._id} className="search-result-page grw-page-path-text-muted-container d-flex flex-column">
-      <SearchResultContentSubNavigation
-        pageId={page._id}
-        revisionId={page.revision}
-        path={page.path}
-      >
-      </SearchResultContentSubNavigation>
+      <GrowiSubNavigation
+        page={page}
+        controls={ControlComponents}
+      />
       <div className="search-result-page-content">
       <div className="search-result-page-content">
         <RevisionLoader
         <RevisionLoader
           growiRenderer={growiRenderer}
           growiRenderer={growiRenderer}

+ 0 - 74
packages/app/src/components/SearchPage/SearchResultContentSubNavigation.tsx

@@ -1,74 +0,0 @@
-import React, { FC } from 'react';
-import { pagePathUtils } from '@growi/core';
-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,
-  revisionId: string,
-  path: string,
-  isSignleLineMode?: boolean,
-  isCompactMode?: boolean,
-}
-
-
-const SearchResultContentSubNavigation: FC<Props> = (props : Props) => {
-  const {
-    appContainer, pageId, revisionId, path, isCompactMode, isSignleLineMode,
-  } = props;
-
-  const { isTrashPage, isDeletablePage } = pagePathUtils;
-
-  const { data: tagInfoData, error: tagInfoError } = useSWRTagsInfo(pageId);
-
-  if (tagInfoError != null || tagInfoData == null) {
-    return <></>;
-  }
-  const isPageDeletable = isDeletablePage(path);
-  const { isSharedUser } = appContainer;
-  const isAbleToShowPageManagement = !(isTrashPage(path)) && !isSharedUser;
-  return (
-    <div className="shadow-sm search-result-content-nav">
-      <div className={`grw-subnav container-fluid d-flex align-items-start justify-content-between ${isCompactMode ? 'grw-subnav-compact d-print-none' : ''}`}>
-        {/* Left side */}
-        <div className="grw-path-nav-container">
-          <PagePathNav pageId={pageId} pagePath={path} isCompactMode={isCompactMode} isSingleLineMode={isSignleLineMode} />
-        </div>
-        {/* Right side */}
-        {/*
-          DeleteCompletely is currently disabled
-          TODO : Retrive isAbleToDeleteCompleltly state everywhere in the system via swr.
-          story: https://redmine.weseek.co.jp/issues/82222
-        */}
-        <div className="d-flex">
-          <SubNavButtons
-            isCompactMode={isCompactMode}
-            pageId={pageId}
-            revisionId={revisionId}
-            path={path}
-            isDeletable={isPageDeletable}
-            isAbleToDeleteCompletely={false}
-            willShowPageManagement={isAbleToShowPageManagement}
-          >
-          </SubNavButtons>
-        </div>
-      </div>
-    </div>
-  );
-};
-
-
-/**
- * Wrapper component for using unstated
- */
-const SearchResultContentSubNavigationUnstatedWrapper = withUnstatedContainers(SearchResultContentSubNavigation, [AppContainer]);
-
-// wrapping tsx component returned by withUnstatedContainers to avoid type error when this component used in other tsx components.
-const SearchResultContentSubNavigationWrapper = (props) => {
-  return <SearchResultContentSubNavigationUnstatedWrapper {...props}></SearchResultContentSubNavigationUnstatedWrapper>;
-};
-export default SearchResultContentSubNavigationWrapper;

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