Browse Source

Merge branch 'imprv/148445-upgrade-remark-growi-directive' into imprv/153035-add-github-alert-notation

reiji-h 1 year ago
parent
commit
e177f461f3
81 changed files with 915 additions and 1622 deletions
  1. 37 0
      .github/mergify.yml
  2. 3 2
      .github/workflows/auto-labeling.yml
  3. 15 22
      .github/workflows/ci-app-prod.yml
  4. 3 2
      .github/workflows/ci-app.yml
  5. 4 0
      .github/workflows/ci-slackbot-proxy.yml
  6. 1 1
      .github/workflows/release-slackbot-proxy.yml
  7. 1 146
      .github/workflows/reusable-app-prod.yml
  8. 0 24
      .mergify.yml
  9. 34 1
      CHANGELOG.md
  10. 0 2
      apps/app/.gitignore
  11. 0 30
      apps/app/cypress.config.ts
  12. 1 1
      apps/app/docker/README.md
  13. 5 8
      apps/app/package.json
  14. 1 1
      apps/app/playwright/20-basic-features/access-to-page.spec.ts
  15. 13 12
      apps/app/playwright/21-basic-features-for-guest/access-to-page.spec.ts
  16. 6 0
      apps/app/playwright/40-admin/access-to-admin-page.spec.ts
  17. 170 0
      apps/app/playwright/50-sidebar/access-to-sidebar.spec.ts
  18. 43 0
      apps/app/playwright/50-sidebar/switching-sidebar-mode.spec.ts
  19. 0 2
      apps/app/playwright/utils/CollapseSidebar.ts
  20. 1 1
      apps/app/regconfig.json
  21. 3 3
      apps/app/src/client/components/Admin/G2GDataTransferExportForm.tsx
  22. 1 1
      apps/app/src/client/components/Admin/ImportData/GrowiArchive/ImportCollectionConfigurationModal.jsx
  23. 1 1
      apps/app/src/client/components/Admin/ImportData/GrowiArchive/ImportCollectionItem.jsx
  24. 2 1
      apps/app/src/client/components/Admin/ImportData/GrowiArchive/ImportForm.jsx
  25. 28 25
      apps/app/src/client/components/Bookmarks/BookmarkFolderItemControl.tsx
  26. 12 9
      apps/app/src/client/components/Bookmarks/BookmarkFolderMenu.tsx
  27. 47 30
      apps/app/src/client/components/Common/Dropdown/PageItemControl.spec.tsx
  28. 14 12
      apps/app/src/client/components/Common/Dropdown/PageItemControl.tsx
  29. 13 10
      apps/app/src/client/components/InAppNotification/InAppNotificationDropdown.tsx
  30. 6 2
      apps/app/src/client/components/PageComment.tsx
  31. 1 1
      apps/app/src/client/components/PageComment/ReplyComments.tsx
  32. 1 1
      apps/app/src/client/components/PageEditor/PageEditor.tsx
  33. 16 11
      apps/app/src/client/components/PageSelectModal/PageSelectModal.tsx
  34. 1 1
      apps/app/src/client/components/Sidebar/Tag.tsx
  35. 3 2
      apps/app/src/components/ReactMarkdownComponents/CodeBlock.tsx
  36. 1 0
      apps/app/src/interfaces/apiv3/page.ts
  37. 0 14
      apps/app/src/models/admin/growi-archive-import-option.js
  38. 18 0
      apps/app/src/models/admin/growi-archive-import-option.ts
  39. 0 0
      apps/app/src/models/admin/import-mode.ts
  40. 5 3
      apps/app/src/models/admin/import-option-for-pages.ts
  41. 0 13
      apps/app/src/models/admin/import-option-for-revisions.js
  42. 15 0
      apps/app/src/models/admin/import-option-for-revisions.ts
  43. 8 4
      apps/app/src/pages/[[...path]].page.tsx
  44. 1 0
      apps/app/src/server/models/page.ts
  45. 2 2
      apps/app/src/server/models/user-group-relation.ts
  46. 1 1
      apps/app/src/server/routes/apiv3/import.js
  47. 15 5
      apps/app/src/server/routes/apiv3/page/index.ts
  48. 13 3
      apps/app/src/server/routes/apiv3/page/update-page.ts
  49. 4 3
      apps/app/src/server/routes/comment.js
  50. 5 4
      apps/app/src/server/service/g2g-transfer.ts
  51. 2 1
      apps/app/src/server/service/import/import-settings.ts
  52. 5 7
      apps/app/src/server/service/import/import.ts
  53. 0 2
      apps/app/src/server/service/import/index.ts
  54. 1 1
      apps/app/src/server/service/import/overwrite-params/index.ts
  55. 1 1
      apps/app/src/server/util/compare-objectId.ts
  56. 14 2
      apps/app/src/stores/page.tsx
  57. 18 4
      apps/app/src/stores/ui.tsx
  58. 0 8
      apps/app/test/cypress/.eslintrc.js
  59. 0 102
      apps/app/test/cypress/e2e/0-advanced-examples/misc.cy.ts
  60. 0 59
      apps/app/test/cypress/e2e/0-advanced-examples/viewport.cy.ts
  61. 0 18
      apps/app/test/cypress/e2e/21-basic-features-for-guest/21-basic-features-for-guest--access-to-page.cy.ts
  62. 0 303
      apps/app/test/cypress/e2e/50-sidebar/50-sidebar--access-to-side-bar.cy.ts
  63. 0 65
      apps/app/test/cypress/e2e/50-sidebar/50-sidebar--switching-sidebar-mode.cy.ts
  64. 0 6
      apps/app/test/cypress/fixtures/user-admin.json
  65. 0 21
      apps/app/test/cypress/support/assertions.ts
  66. 0 14
      apps/app/test/cypress/support/blackout.ts
  67. 0 118
      apps/app/test/cypress/support/commands.ts
  68. 0 48
      apps/app/test/cypress/support/index.ts
  69. 0 9
      apps/app/test/cypress/support/screenshot.ts
  70. 0 16
      apps/app/test/cypress/tsconfig.json
  71. 3 2
      apps/app/turbo.json
  72. 1 1
      apps/slackbot-proxy/package.json
  73. 3 1
      apps/slackbot-proxy/src/services/LinkSharedService.ts
  74. 42 0
      apps/slackbot-proxy/turbo.json
  75. 0 53
      bin/github-actions/generate-cypress-spec-arg.mjs
  76. 1 3
      package.json
  77. 1 1
      packages/remark-attachment-refs/package.json
  78. 1 1
      packages/remark-lsx/package.json
  79. 2 2
      packages/slack/src/interfaces/growi-event-processor.ts
  80. 0 21
      turbo.json
  81. 245 315
      yarn.lock

+ 37 - 0
.github/mergify.yml

@@ -0,0 +1,37 @@
+queue_rules:
+  - name: default
+    allow_inplace_checks: false
+    queue_conditions:
+      - check-success ~= ci-app-lint
+      - check-success ~= ci-app-test
+      - check-success ~= ci-app-launch-dev
+      - -check-failure ~= ci-app-
+      - -check-failure ~= ci-slackbot-
+      - -check-failure ~= test-prod-node20 /
+    merge_conditions:
+      - check-success ~= ci-app-lint
+      - check-success ~= ci-app-test
+      - check-success ~= ci-app-launch-dev
+      - check-success = test-prod-node20 / build-prod
+      - check-success = test-prod-node20 / launch-prod
+      - check-success ~= test-prod-node20 / run-playwright
+      - -check-failure ~= ci-app-
+      - -check-failure ~= ci-slackbot-
+      - -check-failure ~= test-prod-node20 /
+
+pull_request_rules:
+  - name: Automatic queue to merge
+    conditions:
+      - '#approved-reviews-by >= 1'
+      - '#review-requested = 0'
+      - check-success = check-title
+    actions:
+      queue:
+
+  - name: Automatic merge for Preparing next version
+    conditions:
+      - author = github-actions[bot]
+      - label = type/prepare-next-version
+    actions:
+      merge:
+        method: merge

+ 3 - 2
.github/workflows/auto-labeling.yml

@@ -21,7 +21,8 @@ jobs:
 
     if: |
       (!contains( github.event.pull_request.labels.*.name, 'flag/exclude-from-changelog' )
-        && !startsWith( github.head_ref, 'changeset-release/' ))
+        && !startsWith( github.head_ref, 'changeset-release/' )
+        && !startsWith( github.head_ref, 'mergify/merge-queue/' ))
 
     steps:
       - uses: release-drafter/release-drafter@v5
@@ -36,7 +37,7 @@ jobs:
     if: |
       (!contains( github.event.pull_request.labels.*.name, 'flag/exclude-from-changelog' )
         && !startsWith( github.head_ref, 'changeset-release/' )
-        && !startsWith( github.head_ref, 'dependabot/' ))
+        && !startsWith( github.head_ref, 'mergify/merge-queue/' ))
 
     steps:
       - uses: amannn/action-semantic-pull-request@v5

+ 15 - 22
.github/workflows/ci-app-prod.yml

@@ -7,6 +7,7 @@ on:
       - dev/7.*.x
       - dev/6.*.x
     paths:
+      - .github/mergify.yml
       - .github/workflows/ci-app-prod.yml
       - .github/workflows/reusable-app-prod.yml
       - .github/workflows/reusable-app-reg-suit.yml
@@ -22,8 +23,10 @@ on:
       - master
       - dev/7.*.x
       - dev/6.*.x
+      - release/*
     types: [opened, reopened, synchronize]
     paths:
+      - .github/mergify.yml
       - .github/workflows/ci-app-prod.yml
       - .github/workflows/reusable-app-prod.yml
       - .github/workflows/reusable-app-reg-suit.yml
@@ -34,12 +37,6 @@ on:
       - apps/app/**
       - '!apps/app/docker/**'
       - packages/**
-  workflow_call:
-    inputs:
-      cypress-config-video:
-        description: 'Enable video when running Cypress test'
-        type: boolean
-        default: false
 
 concurrency:
   group: ${{ github.workflow }}-${{ github.ref }}
@@ -62,25 +59,21 @@ jobs:
     with:
       node-version: 20.x
       skip-e2e-test: ${{ contains( github.event.pull_request.labels.*.name, 'dependencies' ) }}
-      cypress-report-artifact-name-prefix: cypress-report-
-      cypress-config-video: ${{ inputs.cypress-config-video || false }}
     secrets:
       SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
 
+  # run-reg-suit-node20:
+  #   needs: [test-prod-node20]
 
-  run-reg-suit-node20:
-    needs: [test-prod-node20]
+  #   uses: weseek/growi/.github/workflows/reusable-app-reg-suit.yml@master
 
-    uses: weseek/growi/.github/workflows/reusable-app-reg-suit.yml@master
+  #   if: always()
 
-    if: always()
-
-    with:
-      node-version: 20.x
-      skip-reg-suit: ${{ contains( github.event.pull_request.labels.*.name, 'dependencies' ) }}
-      cypress-report-artifact-name-pattern: 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 }}
+  #   with:
+  #     node-version: 20.x
+  #     skip-reg-suit: ${{ contains( github.event.pull_request.labels.*.name, 'dependencies' ) }}
+  #   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 }}

+ 3 - 2
.github/workflows/ci-app.yml

@@ -7,6 +7,7 @@ on:
       - rc/**
       - changeset-release/**
     paths:
+      - .github/mergify.yml
       - .github/workflows/ci-app.yml
       - .eslint*
       - tsconfig.base.json
@@ -205,11 +206,11 @@ jobs:
           yarn global add node-gyp
           yarn --frozen-lockfile
 
-      - name: turbo run dev:ci
+      - name: turbo run launch-dev:ci
         working-directory: ./apps/app
         run: |
           cp config/ci/.env.local.for-ci .env.development.local
-          turbo run dev:ci --env-mode=loose
+          turbo run launch-dev:ci --env-mode=loose
         env:
           MONGO_URI: mongodb://localhost:${{ job.services.mongodb.ports['27017'] }}/growi_dev
 

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

@@ -7,6 +7,7 @@ on:
       - rc/**
       - support/prepare-v**
     paths:
+      - .github/mergify.yml
       - .github/workflows/ci-slackbot-proxy.yml
       - .eslint*
       - tsconfig.base.json
@@ -175,6 +176,9 @@ jobs:
 
 
   ci-slackbot-proxy-launch-prod:
+
+    if: startsWith(github.head_ref, 'mergify/merge-queue/')
+
     runs-on: ubuntu-latest
 
     strategy:

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

@@ -48,7 +48,7 @@ jobs:
         gcloud auth configure-docker --quiet
 
     - name: Set up Docker Buildx
-      uses: docker/setup-buildx-action@v2
+      uses: docker/setup-buildx-action@v3
 
     - name: Build and push
       uses: docker/build-push-action@v4

+ 1 - 146
.github/workflows/reusable-app-prod.yml

@@ -8,11 +8,6 @@ on:
         type: string
       skip-e2e-test:
         type: boolean
-      cypress-report-artifact-name-prefix:
-        type: string
-      cypress-config-video:
-        type: boolean
-        default: false
     secrets:
       SLACK_WEBHOOK_URL:
         required: true
@@ -201,150 +196,10 @@ jobs:
         url: ${{ secrets.SLACK_WEBHOOK_URL }}
 
 
-
-  run-cypress:
-    needs: [build-prod]
-
-    if: ${{ !inputs.skip-e2e-test }}
-
-    runs-on: ubuntu-latest
-
-    strategy:
-      fail-fast: false
-      matrix:
-        # List string expressions that is comma separated ids of tests in "test/cypress/integration"
-        spec-group: ['21', '50']
-
-    services:
-      mongodb:
-        image: mongo:6.0
-        ports:
-        - 27017/tcp
-      elasticsearch:
-        image: docker.elastic.co/elasticsearch/elasticsearch:7.17.1
-        ports:
-        - 9200/tcp
-        env:
-          discovery.type: single-node
-
-    steps:
-    - uses: actions/checkout@v4
-
-    - name: Install fonts
-      run: sudo apt install fonts-noto
-
-    - uses: actions/setup-node@v4
-      with:
-        node-version: ${{ inputs.node-version }}
-        cache: 'yarn'
-        cache-dependency-path: '**/yarn.lock'
-
-    - name: Install turbo
-      run: |
-        yarn global add turbo
-
-    - name: Prune repositories
-      run: |
-        turbo prune @growi/app
-        rm -rf apps packages
-        mv out/* .
-
-    - name: Restore node_modules
-      uses: actions/cache/restore@v4
-      with:
-        path: |
-          **/node_modules
-        # saved key by build-prod
-        key: node_modules-app-build-prod-${{ runner.OS }}-node${{ inputs.node-version }}-${{ hashFiles('**/yarn.lock') }}
-        restore-keys: |
-          node_modules-app-build-prod-${{ runner.OS }}-node${{ inputs.node-version }}-
-
-    - name: Cache/Restore Cypress files
-      uses: actions/cache@v4
-      with:
-        path: |
-          ~/.cache/Cypress
-        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: Install dependencies
-      run: |
-        yarn global add node-gyp
-        yarn --frozen-lockfile
-        yarn cypress install
-
-    - name: Download production files artifact
-      uses: actions/download-artifact@v4
-      with:
-        name: Production Files (node${{ inputs.node-version }})
-
-    - 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.mjs --prefix="test/cypress/e2e/" --suffix="-*/*.cy.{ts,tsx}" "${{ matrix.spec-group }}"`
-        echo "value=$SPEC" >> $GITHUB_OUTPUT
-
-    - name: Copy dotenv file for ci
-      working-directory: ./apps/app
-      run: |
-        cat config/ci/.env.local.for-ci >> .env.production.local
-
-    - name: Copy dotenv file for automatic installation
-      if: ${{ matrix.spec-group != '10' }}
-      working-directory: ./apps/app
-      run: |
-        cat config/ci/.env.local.for-auto-install >> .env.production.local
-
-    - name: Copy dotenv file for automatic installation with allowing guest mode
-      if: ${{ matrix.spec-group == '21' }}
-      working-directory: ./apps/app
-      run: |
-        cat config/ci/.env.local.for-auto-install-with-allowing-guest >> .env.production.local
-
-    - name: Cypress Run
-      uses: cypress-io/github-action@v6
-      with:
-        browser: chromium
-        working-directory: ./apps/app
-        spec: '${{ steps.determine-spec-exp.outputs.value }}'
-        install: false
-        start: yarn server
-        wait-on: 'http://localhost:3000'
-        config: video=${{ inputs.cypress-config-video }}
-      env:
-        MONGO_URI: mongodb://localhost:${{ job.services.mongodb.ports['27017'] }}/growi-vrt
-        ELASTICSEARCH_URI: http://localhost:${{ job.services.elasticsearch.ports['9200'] }}/growi
-
-    - name: Upload results
-      if: always()
-      uses: actions/upload-artifact@v4
-      with:
-        name: ${{ inputs.cypress-report-artifact-name-prefix }}${{ matrix.spec-group }}
-        path: |
-          apps/app/test/cypress/screenshots
-          apps/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 }}
-
-
-
   run-playwright:
     needs: [build-prod]
 
-    if: ${{ !inputs.skip-e2e-test }}
+    if: ${{ !inputs.skip-e2e-test && startsWith(github.head_ref, 'mergify/merge-queue/') }}
 
     runs-on: ubuntu-latest
     container:

+ 0 - 24
.mergify.yml

@@ -1,24 +0,0 @@
-pull_request_rules:
-  - name: Automatic merge for Dependabot pull requests
-    conditions:
-      - author = dependabot[bot]
-      - '#approved-reviews-by >= 1'
-      - check-success = "ci-slackbot-proxy-lint (20.x)"
-      - check-success = "ci-slackbot-proxy-launch-dev (20.x)"
-      - check-success = "ci-slackbot-proxy-launch-prod (20.x)"
-      - check-success = "ci-app-lint (20.x)"
-      - check-success = "ci-app-test (20.x)"
-      - check-success = "ci-app-launch-dev (20.x)"
-      - check-success = "test-prod-node18 / launch-prod"
-      - check-success = "test-prod-node20 / launch-prod"
-    actions:
-      merge:
-        method: merge
-
-  - name: Automatic merge for Preparing next version
-    conditions:
-      - author = github-actions[bot]
-      - label = "type/prepare-next-version"
-    actions:
-      merge:
-        method: merge

+ 34 - 1
CHANGELOG.md

@@ -1,9 +1,42 @@
 # Changelog
 
-## [Unreleased](https://github.com/weseek/growi/compare/v7.0.17...HEAD)
+## [Unreleased](https://github.com/weseek/growi/compare/v7.0.19...HEAD)
 
 *Please do not manually update this file. We've automated the process.*
 
+## [v7.0.19](https://github.com/weseek/growi/compare/v7.0.18...v7.0.19) - 2024-09-12
+
+### 🐛 Bug Fixes
+
+* fix: Shared page is not displayed when skipping SSR (#9089) @miya
+* fix: The grant of pages can be changed via api even if restricted (#9087) @WNomunomu
+* fix: Updated content is not reflected on the View screen even after refreshing the page (#9086) @miya
+* fix: Removing comment doesn't work (#9083) @yuki-takei
+
+## [v7.0.18](https://github.com/weseek/growi/compare/v7.0.17...v7.0.18) - 2024-09-09
+
+### 🚀 Improvement
+
+* imprv: Prevent looping to update a hook for TrashPageAlert (#9066) @yuki-takei
+* imprv: Display page tree in page select modal with scrollbar (#9023) @kazutoweseek
+
+### 🐛 Bug Fixes
+
+* fix: issue that material symbols icons are not displayed in ReplyComments component (#9076) @WNomunomu
+* fix: Unable to navigate to the data transfer page (#9071) @miya
+* fix: Page content does not update when switching revisions (#9072) @miya
+* fix: Supress rendering too many invisible DropdownMenu components (#9073) @yuki-takei
+* fix: Return error when grant is string for PUT /_api/v3/page (#9069) @arafubeatbox
+* fix: Scrolling may not occurs when clicking on the edit button next to the header (#9043) @reiji-h
+* fix: API v3 Page update (#9053) @maeshinshin
+* fix: Input text becomes empty when opening the ReadOnlyEditor (#9059) @miya
+* fix: Show pages with grants that are set to be visible in security settings on RecentChanges and PageTree as well (#9044) @miya
+
+### 🧰 Maintenance
+
+* support: Omit Cypress (#9065) @miya
+* ci(deps): bump unzip-stream from 0.3.1 to 0.3.2 (#9049) @dependabot
+
 ## [v7.0.17](https://github.com/weseek/growi/compare/v7.0.16...v7.0.17) - 2024-08-26
 
 ### 🚀 Improvement

+ 0 - 2
apps/app/.gitignore

@@ -3,8 +3,6 @@
 /out/
 
 # test
-test/cypress/screenshots
-test/cypress/videos
 .reg
 
 # dist

+ 0 - 30
apps/app/cypress.config.ts

@@ -1,30 +0,0 @@
-import { defineConfig } from 'cypress';
-
-export default defineConfig({
-  e2e: {
-    baseUrl: 'http://localhost:3000',
-    specPattern: 'test/cypress/e2e/**/*.cy.{ts,tsx}',
-    supportFile: 'test/cypress/support/index.ts',
-    setupNodeEvents: (on) => {
-      // change screen size
-      // see: https://docs.cypress.io/api/plugins/browser-launch-api#Set-screen-size-when-running-headless
-      on('before:browser:launch', (browser, launchOptions) => {
-        if (browser.name === 'chromium' && browser.isHeadless) {
-          launchOptions.args.push('--window-size=1400,1024');
-          launchOptions.args.push('--force-device-scale-factor=1');
-        }
-        return launchOptions;
-      });
-    },
-    defaultCommandTimeout: 7000,
-  },
-  fileServerFolder: 'test/cypress',
-  fixturesFolder: 'test/cypress/fixtures',
-  screenshotsFolder: 'test/cypress/screenshots',
-  videosFolder: 'test/cypress/videos',
-  video: false,
-
-  viewportWidth: 1400,
-  viewportHeight: 1024,
-
-});

+ 1 - 1
apps/app/docker/README.md

@@ -10,7 +10,7 @@ GROWI Official docker image
 Supported tags and respective Dockerfile links
 ------------------------------------------------
 
-* [`7.0.17`, `7.0`, `7`, `latest` (Dockerfile)](https://github.com/weseek/growi/blob/v7.0.17/apps/app/docker/Dockerfile)
+* [`7.0.19`, `7.0`, `7`, `latest` (Dockerfile)](https://github.com/weseek/growi/blob/v7.0.19/apps/app/docker/Dockerfile)
 * [`6.3.2`, `6.3`, `6` (Dockerfile)](https://github.com/weseek/growi/blob/v6.3.2/apps/app/docker/Dockerfile)
 * [`6.2.4`, `6.2` (Dockerfile)](https://github.com/weseek/growi/blob/v6.2.4/apps/app/docker/Dockerfile)
 * [`6.1.15`, `6.1` (Dockerfile)](https://github.com/weseek/growi/blob/v6.1.15/apps/app/docker/Dockerfile)

+ 5 - 8
apps/app/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@growi/app",
-  "version": "7.0.18-RC.0",
+  "version": "7.0.20-RC.0",
   "license": "MIT",
   "private": "true",
   "scripts": {
@@ -25,9 +25,8 @@
     "dev:migrate:status": "yarn dev:migrate-mongo status -f config/migrate-mongo-config.js",
     "dev:migrate:up": "yarn dev:migrate-mongo up -f config/migrate-mongo-config.js",
     "dev:migrate:down": "yarn dev:migrate-mongo down -f config/migrate-mongo-config.js",
-    "cy:run": "cypress run --browser chromium",
     "//// for CI": "",
-    "dev:ci": "yarn cross-env NODE_ENV=development yarn ts-node src/server/app.ts --ci",
+    "launch-dev:ci": "yarn cross-env NODE_ENV=development yarn dev:migrate && yarn ts-node src/server/app.ts --ci",
     "lint:typecheck": "npx -y tspc",
     "lint:eslint": "yarn eslint --quiet \"**/*.{js,jsx,ts,tsx}\"",
     "lint:styles": "stylelint \"src/**/*.scss\"",
@@ -92,7 +91,7 @@
     "async-canvas-to-blob": "^1.0.3",
     "axios": "^0.24.0",
     "axios-retry": "^3.2.4",
-    "body-parser": "^1.18.2",
+    "body-parser": "^1.20.3",
     "browser-bunyan": "^1.8.0",
     "bson-objectid": "^2.0.4",
     "bunyan": "^1.8.15",
@@ -114,7 +113,7 @@
     "escape-string-regexp": "^4.0.0",
     "eslint-plugin-regex": "^1.8.0",
     "expose-gc": "^1.0.0",
-    "express": "^4.19.2",
+    "express": "^4.20.0",
     "express-bunyan-logger": "^1.3.3",
     "express-mongo-sanitize": "^2.1.0",
     "express-session": "^1.16.1",
@@ -151,7 +150,7 @@
     "next-themes": "^0.2.1",
     "nocache": "^4.0.0",
     "node-cron": "^3.0.2",
-    "nodemailer": "^6.9.14",
+    "nodemailer": "^6.9.15",
     "nodemailer-ses-transport": "~1.5.0",
     "openid-client": "^5.4.0",
     "p-retry": "^4.0.0",
@@ -245,11 +244,9 @@
     "babel-loader": "^8.2.5",
     "bootstrap": "=5.3.2",
     "connect-browser-sync": "^2.1.0",
-    "cypress-real-events": "^1.12.0",
     "diff2html": "^3.4.47",
     "downshift": "^8.2.3",
     "eazy-logger": "^3.1.0",
-    "eslint-plugin-cypress": "^2.12.1",
     "eslint-plugin-jest": "^26.5.3",
     "eslint-plugin-regex": "^1.8.0",
     "fslightbox-react": "^1.7.6",

+ 1 - 1
apps/app/playwright/20-basic-features/access-to-page.spec.ts

@@ -23,7 +23,7 @@ test('/Sandbox/Math is successfully loaded', async({ page }) => {
   await page.goto('/Sandbox/Math');
 
   // Expect the Math-specific elements to be present
-  await expect(page.locator('.math').first()).toBeVisible();
+  await expect(page.locator('.katex').first()).toBeVisible();
 });
 
 test('Sandbox with edit is successfully loaded', async({ page }) => {

+ 13 - 12
apps/app/playwright/21-basic-features-for-guest/access-to-page.spec.ts

@@ -15,7 +15,7 @@ test('/Sandbox/math is successfully loaded', async({ page }) => {
   await page.goto('/Sandbox/Math');
 
   // Check if the math elements are visible
-  await expect(page.locator('.math').first()).toBeVisible();
+  await expect(page.locator('.katex').first()).toBeVisible();
 });
 
 test('Access to /me page', async({ page }) => {
@@ -32,14 +32,15 @@ test('Access to /trash page', async({ page }) => {
   await expect(page.getByTestId('trash-page-list')).toBeVisible();
 });
 
-// TODO: Improve collapseSidebar (https://redmine.weseek.co.jp/issues/148538)
-// test('Access to /tags page', async({ page }) => {
-//   await page.goto('/tags');
-
-//   await collapseSidebar(page, false);
-//   await page.getByTestId('grw-sidebar-nav-primary-tags').click();
-//   await expect(page.getByTestId('grw-sidebar-content-tags')).toBeVisible();
-//   await expect(page.getByTestId('grw-tags-list').first()).toBeVisible();
-//   await expect(page.getByTestId('grw-tags-list').first()).toContainText('You have no tag, You can set tags on pages');
-//   await expect(page.getByTestId('tags-page')).toBeVisible();
-// });
+test('Access to /tags page', async({ page }) => {
+  await page.goto('/');
+
+  await collapseSidebar(page, false);
+  await page.getByTestId('grw-sidebar-nav-primary-tags').click();
+  await expect(page.getByTestId('grw-sidebar-content-tags')).toBeVisible();
+  await expect(page.getByTestId('grw-tags-list').first()).toBeVisible();
+  await expect(page.getByTestId('grw-tags-list').first()).toContainText('You have no tag, You can set tags on pages');
+
+  await page.getByTestId('check-all-tags-button').click();
+  await expect(page.getByTestId('tags-page')).toBeVisible();
+});

+ 6 - 0
apps/app/playwright/40-admin/access-to-admin-page.spec.ts

@@ -50,6 +50,12 @@ test('admin/export is successfully loaded', async({ page }) => {
   await expect(page.getByTestId('admin-export-archive-data')).toBeVisible();
 });
 
+test('admin/data-transfer is successfully loaded', async({ page }) => {
+  await page.goto('/admin/data-transfer');
+
+  await expect(page.getByTestId('admin-export-archive-data')).toBeVisible();
+});
+
 test('admin/notification is successfully loaded', async({ page }) => {
   await page.goto('/admin/notification');
 

+ 170 - 0
apps/app/playwright/50-sidebar/access-to-sidebar.spec.ts

@@ -0,0 +1,170 @@
+import { test, expect } from '@playwright/test';
+
+import { collapseSidebar } from '../utils';
+
+
+test.describe('Access to sidebar', () => {
+
+  test.beforeEach(async({ page }) => {
+    await page.goto('/');
+    await collapseSidebar(page, false);
+  });
+
+  test('Successfully show sidebar', async({ page }) => {
+    await expect(page.getByTestId('grw-sidebar-contents')).toBeVisible();
+  });
+
+  test('Successfully access to page tree', async({ page }) => {
+    await page.getByTestId('grw-sidebar-nav-primary-page-tree').click();
+    await expect(page.getByTestId('grw-sidebar-contents')).toBeVisible();
+    await expect(page.getByTestId('grw-pagetree-item-container').first()).toBeVisible();
+  });
+
+  test('Successfully access to recent changes', async({ page }) => {
+    await page.getByTestId('grw-sidebar-nav-primary-recent-changes').click();
+    await expect(page.getByTestId('grw-recent-changes')).toBeVisible();
+    await expect(page.locator('.list-group-item').first()).toBeVisible();
+  });
+
+  test('Successfully access to custom sidebar', async({ page }) => {
+    await page.getByTestId('grw-sidebar-nav-primary-custom-sidebar').click();
+    await expect(page.getByTestId('grw-sidebar-contents')).toBeVisible();
+    await expect(page.locator('.grw-sidebar-content-header > h3').locator('a')).toBeVisible();
+  });
+
+  test('Successfully access to GROWI Docs page', async({ page }) => {
+    const linkElement = page.locator('.grw-sidebar-nav-secondary-container a[href*="https://docs.growi.org"]');
+    const docsUrl = await linkElement.getAttribute('href');
+    if (docsUrl == null) {
+      throw new Error('url is null');
+    }
+    const response = await page.request.get(docsUrl);
+    const body = await response.text();
+    expect(body).toContain('</html>');
+  });
+
+  test('Successfully access to trash page', async({ page }) => {
+    await page.locator('.grw-sidebar-nav-secondary-container a[href*="/trash"]').click();
+    await expect(page.getByTestId('trash-page-list')).toBeVisible();
+  });
+
+
+  //
+  // Deactivate: An error occurs that cannot be reproduced in the development environment. -- Yuki Takei 2024.05.10
+  //
+
+  // it('Successfully click Add to Bookmarks button', () => {
+  //   cy.waitUntil(() => {
+  //     // do
+  //     cy.getByTestid('grw-sidebar-contents').within(() => {
+  //       cy.getByTestid('grw-pagetree-item-container').eq(1).within(() => { // against the second element
+  //         cy.get('li').realHover();
+  //         cy.getByTestid('open-page-item-control-btn').find('button').first().realClick();
+  //       });
+  //     });
+  //     // wait until
+  //     return cy.get('.dropdown-menu.show').then($elem => $elem.is(':visible'));
+  //   });
+
+  //   cy.get('.dropdown-menu.show').should('be.visible').within(() => {
+  //     // take a screenshot for dropdown menu
+  //     cy.screenshot(`${ssPrefix}page-tree-2-before-adding-bookmark`)
+  //     // click add remove bookmark btn
+  //     cy.getByTestid('add-bookmark-btn').click();
+  //   })
+
+  //   // show dropdown again
+  //   cy.waitUntil(() => {
+  //     // do
+  //     cy.getByTestid('grw-sidebar-contents').within(() => {
+  //       cy.getByTestid('grw-pagetree-item-container').eq(1).within(() => { // against the second element
+  //         cy.get('li').realHover();
+  //         cy.getByTestid('open-page-item-control-btn').find('button').first().realClick();
+  //       });
+  //     });
+  //     // wait until
+  //     return cy.get('.dropdown-menu.show').then($elem => $elem.is(':visible'));
+  //   });
+
+  //   cy.get('.dropdown-menu.show').should('be.visible').within(() => {
+  //     // expect to be visible
+  //     cy.getByTestid('remove-bookmark-btn').should('be.visible');
+  //     // take a screenshot for dropdown menu
+  //     cy.screenshot(`${ssPrefix}page-tree-2-after-adding-bookmark`);
+  //   });
+  // });
+
+  // it('Successfully show duplicate page modal', () => {
+  //   cy.waitUntil(() => {
+  //     // do
+  //     cy.getByTestid('grw-sidebar-contents').within(() => {
+  //       cy.getByTestid('grw-pagetree-item-container').eq(1).within(() => { // against the second element
+  //         cy.get('li').realHover();
+  //         cy.getByTestid('open-page-item-control-btn').find('button').first().realClick();
+  //       });
+  //     });
+  //     // wait until
+  //     return cy.get('.dropdown-menu.show').then($elem => $elem.is(':visible'));
+  //   });
+
+  //   cy.get('.dropdown-menu.show').should('be.visible').within(() => {
+  //     cy.getByTestid('open-page-duplicate-modal-btn').click();
+  //   })
+
+  //   cy.getByTestid('page-duplicate-modal').should('be.visible').within(() => {
+  //     cy.get('.form-control').type('_test');
+
+  //     cy.screenshot(`${ssPrefix}page-tree-5-duplicate-page-modal`, { blackout: blackoutOverride });
+
+  //     cy.get('.modal-header > button').click();
+  //   });
+  // });
+
+  // it('Successfully rename page', () => {
+  //   cy.waitUntil(() => {
+  //     // do
+  //     cy.getByTestid('grw-sidebar-contents').within(() => {
+  //       cy.getByTestid('grw-pagetree-item-container').eq(1).within(() => { // against the second element
+  //         cy.get('li').realHover();
+  //         cy.getByTestid('open-page-item-control-btn').find('button').first().realClick();
+  //       });
+  //     });
+  //     // wait until
+  //     return cy.get('.dropdown-menu.show').then($elem => $elem.is(':visible'));
+  //   });
+
+  //   cy.get('.dropdown-menu.show').should('be.visible').within(() => {
+  //     cy.getByTestid('rename-page-btn').click();
+  //   })
+
+  //   cy.getByTestid('grw-sidebar-contents').within(() => {
+  //     cy.getByTestid('autosize-submittable-input').type('_newname');
+  //   })
+
+  //   cy.screenshot(`${ssPrefix}page-tree-6-rename-page`, { blackout: blackoutOverride });
+  // });
+
+  // it('Successfully show delete page modal', () => {
+  //   cy.waitUntil(() => {
+  //     // do
+  //     cy.getByTestid('grw-sidebar-contents').within(() => {
+  //       cy.getByTestid('grw-pagetree-item-container').eq(1).within(() => { // against the second element
+  //         cy.get('li').realHover();
+  //         cy.getByTestid('open-page-item-control-btn').find('button').first().realClick();
+  //       });
+  //     });
+  //     // wait until
+  //     return cy.get('.dropdown-menu.show').then($elem => $elem.is(':visible'));
+  //   });
+
+  //   cy.get('.dropdown-menu.show').should('be.visible').within(() => {
+  //     cy.getByTestid('open-page-delete-modal-btn').click();
+  //   })
+
+  //   cy.getByTestid('page-delete-modal').should('be.visible').within(() => {
+  //     cy.screenshot(`${ssPrefix}page-tree-7-delete-page-modal`, { blackout: blackoutOverride });
+  //     cy.get('.modal-header > button').click();
+  //   });
+  // });
+
+});

+ 43 - 0
apps/app/playwright/50-sidebar/switching-sidebar-mode.spec.ts

@@ -0,0 +1,43 @@
+import { test } from '@playwright/test';
+
+import { collapseSidebar } from '../utils';
+
+
+test('Switch sidebar mode', async({ page }) => {
+  await page.goto('/');
+  await collapseSidebar(page, false);
+  await collapseSidebar(page, true);
+});
+
+// Write tests using VRT
+// context('Switch viewport size', () => {
+//   const ssPrefix = 'switch-viewport-size-';
+
+//   const sizes = {
+//     'xl': [1200, 1024],
+//     'lg': [992, 1024],
+//     'md': [768, 1024],
+//     'sm': [576, 1024],
+//     'xs': [575, 1024],
+//     'iphone-x': [375, 812],
+//   };
+
+//   Object.entries(sizes).forEach(([screenLabel, size]) => {
+//     it(`on ${screenLabel} screen`, () => {
+//       cy.viewport(size[0], size[1]);
+
+//       // login
+//       cy.fixture("user-admin.json").then(user => {
+//         cy.login(user.username, user.password);
+//       });
+//       cy.visit('/');
+
+//       cy.get('.layout-root').should('be.visible');
+
+//       cy.screenshot(`${ssPrefix}-${screenLabel}`, {
+//         blackout: blackoutOverride,
+//       });
+//     });
+//   });
+
+// });

+ 0 - 2
apps/app/playwright/utils/CollapseSidebar.ts

@@ -1,4 +1,3 @@
-// TODO: https://redmine.weseek.co.jp/issues/148538
 import { expect, type Page } from '@playwright/test';
 
 export const collapseSidebar = async(page: Page, isCollapsed: boolean): Promise<void> => {
@@ -9,7 +8,6 @@ export const collapseSidebar = async(page: Page, isCollapsed: boolean): Promise<
 
   const collapseSidebarToggle = page.getByTestId('btn-toggle-collapse');
   await expect(collapseSidebarToggle).toBeVisible();
-
   await collapseSidebarToggle.click();
 
   if (isCollapsed) {

+ 1 - 1
apps/app/regconfig.json

@@ -1,7 +1,7 @@
 {
   "core": {
     "workingDir": ".reg",
-    "actualDir": "test/cypress/screenshots",
+    "actualDir": "test/playwright/screenshots",
     "thresholdRate": 0.001,
     "addIgnore": true,
     "ximgdiff": {

+ 3 - 3
apps/app/src/client/components/Admin/G2GDataTransferExportForm.tsx

@@ -4,7 +4,7 @@ import React, {
 
 import { useTranslation } from 'next-i18next';
 
-import GrowiArchiveImportOption from '~/models/admin/growi-archive-import-option';
+import { GrowiArchiveImportOption } from '~/models/admin/growi-archive-import-option';
 import { ImportOptionForPages } from '~/models/admin/import-option-for-pages';
 import { ImportOptionForRevisions } from '~/models/admin/import-option-for-revisions';
 
@@ -22,7 +22,7 @@ const GROUPS_CONFIG = [
 ];
 const ALL_GROUPED_COLLECTIONS = GROUPS_PAGE.concat(GROUPS_USER).concat(GROUPS_CONFIG);
 
-const IMPORT_OPTION_CLASS_MAPPING = {
+const IMPORT_OPTION_CLASS_MAPPING: Record<string, typeof GrowiArchiveImportOption> = {
   pages: ImportOptionForPages,
   revisions: ImportOptionForRevisions,
 };
@@ -188,7 +188,7 @@ const G2GDataTransferExportForm = (props: Props): JSX.Element => {
         ? MODE_RESTRICTED_COLLECTION[collectionName][0]
         : DEFAULT_MODE;
       const ImportOption = IMPORT_OPTION_CLASS_MAPPING[collectionName] || GrowiArchiveImportOption;
-      initialOptionsMap[collectionName] = new ImportOption(initialMode);
+      initialOptionsMap[collectionName] = new ImportOption(collectionName, initialMode);
     });
     updateOptionsMap(initialOptionsMap);
   }, [allCollectionNames, updateOptionsMap]);

+ 1 - 1
apps/app/src/client/components/Admin/ImportData/GrowiArchive/ImportCollectionConfigurationModal.jsx

@@ -11,7 +11,7 @@ import {
   ModalFooter,
 } from 'reactstrap';
 
-import GrowiArchiveImportOption from '~/models/admin/growi-archive-import-option';
+import { GrowiArchiveImportOption } from '~/models/admin/growi-archive-import-option';
 
 // import { toastSuccess, toastError } from '~/client/util/toastr';
 

+ 1 - 1
apps/app/src/client/components/Admin/ImportData/GrowiArchive/ImportCollectionItem.jsx

@@ -3,7 +3,7 @@ import React from 'react';
 import PropTypes from 'prop-types';
 import { Progress } from 'reactstrap';
 
-import GrowiArchiveImportOption from '~/models/admin/growi-archive-import-option';
+import { GrowiArchiveImportOption } from '~/models/admin/growi-archive-import-option';
 
 
 const MODE_ATTR_MAP = {

+ 2 - 1
apps/app/src/client/components/Admin/ImportData/GrowiArchive/ImportForm.jsx

@@ -5,7 +5,7 @@ import PropTypes from 'prop-types';
 
 import { apiv3Post } from '~/client/util/apiv3-client';
 import { toastSuccess, toastError } from '~/client/util/toastr';
-import GrowiArchiveImportOption from '~/models/admin/growi-archive-import-option';
+import { GrowiArchiveImportOption } from '~/models/admin/growi-archive-import-option';
 import { ImportOptionForPages } from '~/models/admin/import-option-for-pages';
 import { ImportOptionForRevisions } from '~/models/admin/import-option-for-revisions';
 import { useAdminSocket } from '~/stores/socket-io';
@@ -27,6 +27,7 @@ const GROUPS_CONFIG = [
 ];
 const ALL_GROUPED_COLLECTIONS = GROUPS_PAGE.concat(GROUPS_USER).concat(GROUPS_CONFIG);
 
+/** @type Record<string, typeof GrowiArchiveImportOption> */
 const IMPORT_OPTION_CLASS_MAPPING = {
   pages: ImportOptionForPages,
   revisions: ImportOptionForRevisions,

+ 28 - 25
apps/app/src/client/components/Bookmarks/BookmarkFolderItemControl.tsx

@@ -26,37 +26,40 @@ export const BookmarkFolderItemControl: React.FC<{
           <span className="material-symbols-outlined">more_horiz</span>
         </DropdownToggle>
       ) }
-      <DropdownMenu
-        container="body"
-        style={{ zIndex: 1055 }} /* make it larger than $zindex-modal of bootstrap */
-      >
-        {onClickMoveToRoot && (
+
+      { isOpen && (
+        <DropdownMenu
+          container="body"
+          style={{ zIndex: 1055 }}
+        >
+          {onClickMoveToRoot && (
+            <DropdownItem
+              onClick={onClickMoveToRoot}
+              className="grw-page-control-dropdown-item"
+            >
+              <span className="material-symbols-outlined grw-page-control-dropdown-icon">bookmark</span>
+              {t('bookmark_folder.move_to_root')}
+            </DropdownItem>
+          )}
           <DropdownItem
-            onClick={onClickMoveToRoot}
+            onClick={onClickRename}
             className="grw-page-control-dropdown-item"
           >
-            <span className="material-symbols-outlined grw-page-control-dropdown-icon">bookmark</span>
-            {t('bookmark_folder.move_to_root')}
+            <span className="material-symbols-outlined me-1 grw-page-control-dropdown-icon">redo</span>
+            {t('Rename')}
           </DropdownItem>
-        )}
-        <DropdownItem
-          onClick={onClickRename}
-          className="grw-page-control-dropdown-item"
-        >
-          <span className="material-symbols-outlined me-1 grw-page-control-dropdown-icon">redo</span>
-          {t('Rename')}
-        </DropdownItem>
 
-        <DropdownItem divider />
+          <DropdownItem divider />
 
-        <DropdownItem
-          className="pt-2 grw-page-control-dropdown-item text-danger"
-          onClick={onClickDelete}
-        >
-          <span className="material-symbols-outlined me-1 grw-page-control-dropdown-icon">delete</span>
-          {t('Delete')}
-        </DropdownItem>
-      </DropdownMenu>
+          <DropdownItem
+            className="pt-2 grw-page-control-dropdown-item text-danger"
+            onClick={onClickDelete}
+          >
+            <span className="material-symbols-outlined me-1 grw-page-control-dropdown-icon">delete</span>
+            {t('Delete')}
+          </DropdownItem>
+        </DropdownMenu>
+      ) }
     </Dropdown>
   );
 };

+ 12 - 9
apps/app/src/client/components/Bookmarks/BookmarkFolderMenu.tsx

@@ -186,15 +186,18 @@ export const BookmarkFolderMenu = (props: BookmarkFolderMenuProps): JSX.Element
       onToggle={toggleHandler}
     >
       {children}
-      <DropdownMenu
-        end
-        persist
-        strategy="fixed"
-        container="body"
-        className={`grw-bookmark-folder-menu ${styles['grw-bookmark-folder-menu']}`}
-      >
-        { renderBookmarkMenuItem() }
-      </DropdownMenu>
+
+      { isOpen && (
+        <DropdownMenu
+          end
+          persist
+          strategy="fixed"
+          container="body"
+          className={`grw-bookmark-folder-menu ${styles['grw-bookmark-folder-menu']}`}
+        >
+          { renderBookmarkMenuItem() }
+        </DropdownMenu>
+      ) }
     </UncontrolledDropdown>
   );
 };

+ 47 - 30
apps/app/src/client/components/Common/Dropdown/PageItemControl.spec.tsx

@@ -1,37 +1,54 @@
-import { render, screen, waitFor } from '@testing-library/react';
-import userEvent, { PointerEventsCheckLevel } from '@testing-library/user-event';
+import { type IPageInfoForOperation } from '@growi/core/dist/interfaces';
+import {
+  fireEvent, render, screen, within,
+} from '@testing-library/react';
+import { mock } from 'vitest-mock-extended';
 
 import { PageItemControl } from './PageItemControl';
 
 
+// mock for isIPageInfoForOperation
+
+const mocks = vi.hoisted(() => ({
+  isIPageInfoForOperationMock: vi.fn(),
+}));
+
+vi.mock('@growi/core/dist/interfaces', () => ({
+  isIPageInfoForOperation: mocks.isIPageInfoForOperationMock,
+}));
+
+
 describe('PageItemControl.tsx', () => {
-  it('Should trigger onClickRenameMenuItem() when clicking the rename button with pageInfo.isDeletable being "false"', async() => {
-    // setup
-    const onClickRenameMenuItemMock = vi.fn();
-
-    const pageInfo = {
-      isMovable: true,
-      isV5Compatible: true,
-      isEmpty: false,
-      isDeletable: false,
-      isAbleToDeleteCompletely: true,
-      isRevertible: true,
-    };
-
-    const props = {
-      pageId: 'dummy-page-id',
-      isEnableActions: true,
-      pageInfo,
-      onClickRenameMenuItem: onClickRenameMenuItemMock,
-    };
-
-    render(<PageItemControl {...props} />);
-
-    // when
-    const openPageMoveRenameModalButton = screen.getByTestId('rename-page-btn');
-    await waitFor(() => userEvent.click(openPageMoveRenameModalButton, { pointerEventsCheck: PointerEventsCheckLevel.Never }));
-
-    // then
-    expect(onClickRenameMenuItemMock).toHaveBeenCalled();
+  describe('Should trigger onClickRenameMenuItem() when clicking the rename button', () => {
+    it('without fetching PageInfo by useSWRxPageInfo', async() => {
+      // setup
+      const pageInfo = mock<IPageInfoForOperation>();
+
+      const onClickRenameMenuItemMock = vi.fn();
+      // return true when the argument is pageInfo in order to supress fetching
+      mocks.isIPageInfoForOperationMock.mockImplementation((arg) => {
+        if (arg === pageInfo) {
+          return true;
+        }
+      });
+
+      const props = {
+        pageId: 'dummy-page-id',
+        isEnableActions: true,
+        pageInfo,
+        onClickRenameMenuItem: onClickRenameMenuItemMock,
+      };
+
+      render(<PageItemControl {...props} />);
+
+      // when
+      const button = within(screen.getByTestId('open-page-item-control-btn')).getByText(/more_vert/);
+      fireEvent.click(button);
+      const renameMenuItem = await screen.findByTestId('rename-page-btn');
+      fireEvent.click(renameMenuItem);
+
+      // then
+      expect(onClickRenameMenuItemMock).toHaveBeenCalled();
+    });
   });
 });

+ 14 - 12
apps/app/src/client/components/Common/Dropdown/PageItemControl.tsx

@@ -4,7 +4,7 @@ import React, {
 
 import {
   type IPageInfoAll, isIPageInfoForOperation,
-} from '@growi/core';
+} from '@growi/core/dist/interfaces';
 import { LoadingSpinner } from '@growi/ui/dist/components';
 import { useTranslation } from 'next-i18next';
 import {
@@ -338,21 +338,23 @@ export const PageItemControlSubstance = (props: PageItemControlSubstanceProps):
     <NotAvailableForGuest>
       <Dropdown isOpen={isOpen} toggle={() => setIsOpen(!isOpen)} className="grw-page-item-control" data-testid="open-page-item-control-btn">
         { children ?? (
-          <DropdownToggle color="transparent" className="border-0 rounded btn-page-item-control d-flex align-items-center justify-content-center">
+          <DropdownToggle role="button" color="transparent" className="border-0 rounded btn-page-item-control d-flex align-items-center justify-content-center">
             <span className="material-symbols-outlined">more_vert</span>
           </DropdownToggle>
         ) }
 
-        <PageItemControlDropdownMenu
-          {...props}
-          isLoading={isLoading}
-          pageInfo={fetchedPageInfo ?? presetPageInfo}
-          onClickBookmarkMenuItem={bookmarkMenuItemClickHandler}
-          onClickRenameMenuItem={renameMenuItemClickHandler}
-          onClickDuplicateMenuItem={duplicateMenuItemClickHandler}
-          onClickDeleteMenuItem={deleteMenuItemClickHandler}
-          onClickPathRecoveryMenuItem={pathRecoveryMenuItemClickHandler}
-        />
+        { isOpen && (
+          <PageItemControlDropdownMenu
+            {...props}
+            isLoading={isLoading}
+            pageInfo={fetchedPageInfo ?? presetPageInfo}
+            onClickBookmarkMenuItem={bookmarkMenuItemClickHandler}
+            onClickRenameMenuItem={renameMenuItemClickHandler}
+            onClickDuplicateMenuItem={duplicateMenuItemClickHandler}
+            onClickDeleteMenuItem={deleteMenuItemClickHandler}
+            onClickPathRecoveryMenuItem={pathRecoveryMenuItemClickHandler}
+          />
+        ) }
       </Dropdown>
 
     </NotAvailableForGuest>

+ 13 - 10
apps/app/src/client/components/InAppNotification/InAppNotificationDropdown.tsx

@@ -86,18 +86,21 @@ export const InAppNotificationDropdown = (): JSX.Element => {
       <DropdownToggle className="px-3" color="primary" innerRef={buttonRef}>
         <span className="material-symbols-outlined">notifications</span> {badge}
       </DropdownToggle>
-      <DropdownMenu end>
-        { inAppNotificationData != null && inAppNotificationData.docs.length === 0
+
+      { isOpen && (
+        <DropdownMenu end>
+          { inAppNotificationData != null && inAppNotificationData.docs.length === 0
           // no items
-          ? <DropdownItem disabled>{t('in_app_notification.mark_all_as_read')}</DropdownItem>
+            ? <DropdownItem disabled>{t('in_app_notification.mark_all_as_read')}</DropdownItem>
           // render DropdownItem
-          : <InAppNotificationList inAppNotificationData={inAppNotificationData} />
-        }
-        <DropdownItem divider />
-        <DropdownItem tag="a" href="/me/all-in-app-notifications">
-          { t('in_app_notification.see_all') }
-        </DropdownItem>
-      </DropdownMenu>
+            : <InAppNotificationList inAppNotificationData={inAppNotificationData} />
+          }
+          <DropdownItem divider />
+          <DropdownItem tag="a" href="/me/all-in-app-notifications">
+            { t('in_app_notification.see_all') }
+          </DropdownItem>
+        </DropdownMenu>
+      ) }
     </Dropdown>
   );
 };

+ 6 - 2
apps/app/src/client/components/PageComment.tsx

@@ -93,8 +93,12 @@ export const PageComment: FC<PageCommentProps> = memo((props: PageCommentProps):
       onDeleteCommentAfterOperation();
     }
     catch (error: unknown) {
-      setErrorMessageOnDelete(error as string);
-      toastError(`error: ${error}`);
+      const message = error instanceof Error
+        ? error.message
+        : (error as any).toString();
+
+      setErrorMessageOnDelete(message);
+      toastError(message);
     }
   }, [commentToBeDeleted, onDeleteCommentAfterOperation]);
 

+ 1 - 1
apps/app/src/client/components/PageComment/ReplyComments.tsx

@@ -69,7 +69,7 @@ export const ReplyComments = (props: ReplycommentsProps): JSX.Element => {
 
   const areThereHiddenReplies = (replyList.length > 2);
   const toggleButtonIconName = isOlderRepliesShown ? 'expand_less' : 'more_vert';
-  const toggleButtonIcon = <span className="material-icons-outlined me-1">{toggleButtonIconName}</span>;
+  const toggleButtonIcon = <span className="material-symbols-outlined me-1">{toggleButtonIconName}</span>;
   const toggleButtonLabel = isOlderRepliesShown ? '' : 'more';
   const shownReplies = replyList.slice(replyList.length - 2, replyList.length);
   const hiddenReplies = replyList.slice(0, replyList.length - 2);

+ 1 - 1
apps/app/src/client/components/PageEditor/PageEditor.tsx

@@ -211,7 +211,7 @@ export const PageEditor = React.memo((props: Props): JSX.Element => {
     finally {
       mutateWaitingSaveProcessing(false);
     }
-  }, [pageId, selectedGrant, mutateWaitingSaveProcessing, t, mutateIsGrantNormalized]);
+  }, [pageId, selectedGrant, mutateWaitingSaveProcessing, updatePage, mutateIsGrantNormalized, t]);
 
   const saveAndReturnToViewHandler = useCallback(async(opts: SaveOptions) => {
     const markdown = codeMirrorEditor?.getDoc();

+ 16 - 11
apps/app/src/client/components/PageSelectModal/PageSelectModal.tsx

@@ -10,6 +10,7 @@ import { useTranslation } from 'next-i18next';
 import {
   Modal, ModalHeader, ModalBody, ModalFooter, Button,
 } from 'reactstrap';
+import SimpleBar from 'simplebar-react';
 
 import type { IPageForItem } from '~/interfaces/page';
 import { useTargetAndAncestors, useIsGuestUser, useIsReadOnlyUser } from '~/stores-universal/context';
@@ -22,6 +23,7 @@ import { usePagePathRenameHandler } from '../PageEditor/page-path-rename-utils';
 
 import { TreeItemForModal } from './TreeItemForModal';
 
+
 export const PageSelectModal: FC = () => {
   const {
     data: PageSelectModalData,
@@ -82,20 +84,23 @@ export const PageSelectModal: FC = () => {
       isOpen={isOpened}
       toggle={closeModal}
       centered
-      size="sm"
     >
       <ModalHeader toggle={closeModal}>{t('page_select_modal.select_page_location')}</ModalHeader>
-      <ModalBody>
+      <ModalBody className="p-0">
         <Suspense fallback={<ItemsTreeContentSkeleton />}>
-          <ItemsTree
-            CustomTreeItem={TreeItemForModal}
-            isEnableActions={!isGuestUser}
-            isReadOnlyUser={!!isReadOnlyUser}
-            targetPath={targetPath}
-            targetPathOrId={targetPathOrId}
-            targetAndAncestorsData={targetAndAncestorsData}
-            onClickTreeItem={onClickTreeItem}
-          />
+          <SimpleBar style={{ maxHeight: 'calc(85vh - 133px)' }}> {/* 133px = 63px(ModalHeader) + 70px(ModalFooter) */}
+            <div className="p-3">
+              <ItemsTree
+                CustomTreeItem={TreeItemForModal}
+                isEnableActions={!isGuestUser}
+                isReadOnlyUser={!!isReadOnlyUser}
+                targetPath={targetPath}
+                targetPathOrId={targetPathOrId}
+                targetAndAncestorsData={targetAndAncestorsData}
+                onClickTreeItem={onClickTreeItem}
+              />
+            </div>
+          </SimpleBar>
         </Suspense>
       </ModalBody>
       <ModalFooter>

+ 1 - 1
apps/app/src/client/components/Sidebar/Tag.tsx

@@ -68,7 +68,7 @@ const Tag: FC = () => {
         )
       }
 
-      <div className="d-flex justify-content-center my-5">
+      <div className="d-flex justify-content-center my-5" data-testid="check-all-tags-button">
         <Link
           href="/tags"
           className="btn btn-primary rounded px-4"

+ 3 - 2
apps/app/src/components/ReactMarkdownComponents/CodeBlock.tsx

@@ -44,7 +44,8 @@ function CodeBlockSubstance({ lang, children }: { lang: string, children: ReactN
   // see: https://github.com/weseek/growi/pull/7484
   //
   // Note: You can also remove this code if the user requests to see the code highlighted in Prism as-is.
-  const isSimpleString = Array.isArray(children) && children.length === 1 && typeof children[0] === 'string';
+
+  const isSimpleString = typeof children === 'string' || (Array.isArray(children) && children.length === 1 && typeof children[0] === 'string');
   if (!isSimpleString) {
     return (
       <div style={oneDark['pre[class*="language-"]']}>
@@ -67,7 +68,7 @@ function CodeBlockSubstance({ lang, children }: { lang: string, children: ReactN
 }
 
 type CodeBlockProps = {
-  children: JSX.Element,
+  children: ReactNode,
   className?: string,
   inline?: string, // "" or undefined
 }

+ 1 - 0
apps/app/src/interfaces/apiv3/page.ts

@@ -42,4 +42,5 @@ export type IApiv3PageUpdateResponse = {
 
 export const PageUpdateErrorCode = {
   CONFLICT: 'conflict',
+  FORBIDDEN: 'forbidden',
 } as const;

+ 0 - 14
apps/app/src/models/admin/growi-archive-import-option.js

@@ -1,14 +0,0 @@
-class GrowiArchiveImportOption {
-
-  constructor(collectionName, mode, initProps = {}) {
-    this.collectionName = collectionName;
-    this.mode = mode;
-
-    Object.entries(initProps).forEach(([key, value]) => {
-      this[key] = value;
-    });
-  }
-
-}
-
-module.exports = GrowiArchiveImportOption;

+ 18 - 0
apps/app/src/models/admin/growi-archive-import-option.ts

@@ -0,0 +1,18 @@
+import { ImportMode } from './import-mode';
+
+export class GrowiArchiveImportOption {
+
+  collectionName: string;
+
+  mode: ImportMode;
+
+  constructor(collectionName: string, mode: ImportMode = ImportMode.insert, initProps = {}) {
+    this.collectionName = collectionName;
+    this.mode = mode;
+
+    Object.entries(initProps).forEach(([key, value]) => {
+      this[key] = value;
+    });
+  }
+
+}

+ 0 - 0
apps/app/src/server/service/import/import-mode.ts → apps/app/src/models/admin/import-mode.ts


+ 5 - 3
apps/app/src/models/admin/import-option-for-pages.ts

@@ -1,4 +1,6 @@
-import GrowiArchiveImportOption from './growi-archive-import-option';
+import { ImportMode } from '~/models/admin/import-mode';
+
+import { GrowiArchiveImportOption } from './growi-archive-import-option';
 
 const DEFAULT_PROPS = {
   isOverwriteAuthorWithCurrentUser: false,
@@ -20,8 +22,8 @@ export class ImportOptionForPages extends GrowiArchiveImportOption {
 
   initPageMetadatas;
 
-  constructor(collectionName: string, mode: string, initProps) {
-    super(collectionName, mode, initProps || DEFAULT_PROPS);
+  constructor(collectionName: string, mode: ImportMode = ImportMode.insert, initProps = DEFAULT_PROPS) {
+    super(collectionName, mode, initProps);
   }
 
 }

+ 0 - 13
apps/app/src/models/admin/import-option-for-revisions.js

@@ -1,13 +0,0 @@
-const GrowiArchiveImportOption = require('./growi-archive-import-option');
-
-const DEFAULT_PROPS = {
-  isOverwriteAuthorWithCurrentUser: false,
-};
-
-export class ImportOptionForRevisions extends GrowiArchiveImportOption {
-
-  constructor(collectionName, mode, initProps) {
-    super(collectionName, mode, initProps || DEFAULT_PROPS);
-  }
-
-}

+ 15 - 0
apps/app/src/models/admin/import-option-for-revisions.ts

@@ -0,0 +1,15 @@
+import { ImportMode } from '~/models/admin/import-mode';
+
+import { GrowiArchiveImportOption } from './growi-archive-import-option';
+
+const DEFAULT_PROPS = {
+  isOverwriteAuthorWithCurrentUser: false,
+};
+
+export class ImportOptionForRevisions extends GrowiArchiveImportOption {
+
+  constructor(collectionName: string, mode: ImportMode = ImportMode.insert, initProps = DEFAULT_PROPS) {
+    super(collectionName, mode, initProps);
+  }
+
+}

+ 8 - 4
apps/app/src/pages/[[...path]].page.tsx

@@ -245,6 +245,7 @@ const Page: NextPageWithLayout<Props> = (props: Props) => {
   const { pageWithMeta } = props;
 
   const pageId = pageWithMeta?.data._id;
+  const revisionId = pageWithMeta?.data.revision?._id;
   const revisionBody = pageWithMeta?.data.revision?.body;
 
   useCurrentPathname(props.currentPathname);
@@ -277,7 +278,7 @@ const Page: NextPageWithLayout<Props> = (props: Props) => {
       return;
     }
 
-    if (currentPageId != null && !props.isNotFound) {
+    if (currentPageId != null && revisionId != null && !props.isNotFound) {
       const mutatePageData = async() => {
         const pageData = await mutateCurrentPage();
         mutateEditingMarkdown(pageData?.revision?.body);
@@ -288,7 +289,10 @@ const Page: NextPageWithLayout<Props> = (props: Props) => {
       // Because pageWIthMeta does not contain revision.body
       mutatePageData();
     }
-  }, [currentPageId, mutateCurrentPage, mutateCurrentPageYjsDataFromApi, mutateEditingMarkdown, props.isNotFound, props.skipSSR]);
+  }, [
+    revisionId, currentPageId, mutateCurrentPage,
+    mutateCurrentPageYjsDataFromApi, mutateEditingMarkdown, props.isNotFound, props.skipSSR,
+  ]);
 
   // sync pathname by Shallow Routing https://nextjs.org/docs/routing/shallow-routing
   useEffect(() => {
@@ -308,8 +312,8 @@ const Page: NextPageWithLayout<Props> = (props: Props) => {
   }, [mutateEditingMarkdown, revisionBody, props.currentPathname]);
 
   useEffect(() => {
-    mutateRemoteRevisionId(pageWithMeta?.data.revision?._id);
-  }, [mutateRemoteRevisionId, pageWithMeta?.data.revision?._id]);
+    mutateRemoteRevisionId(revisionId);
+  }, [mutateRemoteRevisionId, revisionId]);
 
   useEffect(() => {
     mutateCurrentPageId(pageId ?? null);

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

@@ -50,6 +50,7 @@ export interface PageDocument extends IPage, Document<Types.ObjectId> {
   [x:string]: any // for obsolete methods
   getLatestRevisionBodyLength(): Promise<number | null | undefined>
   calculateAndUpdateLatestRevisionBodyLength(this: PageDocument): Promise<void>
+  populateDataToShowRevision(shouldExcludeBody?: boolean): Promise<PageDocument>
 }
 
 

+ 2 - 2
apps/app/src/server/models/user-group-relation.ts

@@ -128,7 +128,7 @@ schema.statics.findAllRelationForUserGroups = function(userGroups) {
  * @memberof UserGroupRelation
  */
 schema.statics.findAllGroupsForUser = async function(user): Promise<UserGroupDocument[]> {
-  const userGroupRelations = await this.find({ relatedUser: user.id }).populate('relatedGroup');
+  const userGroupRelations = await this.find({ relatedUser: user._id }).populate('relatedGroup');
   const userGroups = userGroupRelations.map((relation) => {
     return isPopulated(relation.relatedGroup) ? relation.relatedGroup as UserGroupDocument : null;
   });
@@ -161,7 +161,7 @@ schema.statics.findAllUserGroupIdsRelatedToUser = async function(user): Promise<
 schema.statics.countByGroupIdsAndUser = async function(userGroupIds: ObjectIdLike[], userData): Promise<number> {
   const query = {
     relatedGroup: { $in: userGroupIds },
-    relatedUser: userData.id,
+    relatedUser: userData._id,
   };
 
   return this.count(query);

+ 1 - 1
apps/app/src/server/routes/apiv3/import.js

@@ -268,7 +268,7 @@ export default function route(crowi) {
     const importSettingsMap = {};
     fileStatsToImport.forEach(({ fileName, collectionName }) => {
       // instanciate GrowiArchiveImportOption
-      /** @type {GrowiArchiveImportOption} */
+      /** @type {import('~/models/admin/growi-archive-import-option').GrowiArchiveImportOption} */
       const option = options.find(opt => opt.collectionName === collectionName);
 
       // generate options

+ 15 - 5
apps/app/src/server/routes/apiv3/page/index.ts

@@ -20,6 +20,7 @@ import { excludeReadOnlyUser } from '~/server/middlewares/exclude-read-only-user
 import { GlobalNotificationSettingEvent } from '~/server/models/GlobalNotificationSetting';
 import type { PageDocument, PageModel } from '~/server/models/page';
 import { Revision } from '~/server/models/revision';
+import ShareLink from '~/server/models/share-link';
 import Subscription from '~/server/models/subscription';
 import { configManager } from '~/server/service/config-manager';
 import type { IPageGrantService } from '~/server/service/page-grant';
@@ -202,6 +203,7 @@ module.exports = (crowi) => {
       query('pageId').optional().isString(),
       query('path').optional().isString(),
       query('findAll').optional().isBoolean(),
+      query('shareLinkId').optional().isMongoId(),
     ],
     likes: [
       body('pageId').isString(),
@@ -284,19 +286,27 @@ module.exports = (crowi) => {
    *                  $ref: '#/components/schemas/Page'
    */
   router.get('/', certifySharedPage, accessTokenParser, loginRequired, validator.getPage, apiV3FormValidator, async(req, res) => {
-    const { user } = req;
+    const { user, isSharedPage } = req;
     const {
-      pageId, path, findAll, revisionId,
+      pageId, path, findAll, revisionId, shareLinkId,
     } = req.query;
 
-    if (pageId == null && path == null) {
-      return res.apiv3Err(new ErrorV3('Either parameter of path or pageId is required.', 'invalid-request'));
+    const isValid = (shareLinkId != null && pageId != null && path == null) || (shareLinkId == null && (pageId != null || path != null));
+    if (!isValid) {
+      return res.apiv3Err(new Error('Either parameter of (pageId or path) or (pageId and shareLinkId) is required.'), 400);
     }
 
     let page;
     let pages;
     try {
-      if (pageId != null) { // prioritized
+      if (isSharedPage) {
+        const shareLink = await ShareLink.findOne({ _id: shareLinkId });
+        if (shareLink == null) {
+          throw new Error('ShareLink is not found');
+        }
+        page = await Page.findOne({ _id: getIdForRef(shareLink.relatedPage) });
+      }
+      else if (pageId != null) { // prioritized
         page = await Page.findByIdAndViewer(pageId, user);
       }
       else if (!findAll) {

+ 13 - 3
apps/app/src/server/routes/apiv3/page/update-page.ts

@@ -4,6 +4,7 @@ import type {
 } from '@growi/core';
 import { ErrorV3 } from '@growi/core/dist/models';
 import { serializeUserSecurely } from '@growi/core/dist/models/serializers';
+import { isTopPage, isUsersProtectedPages } from '@growi/core/dist/utils/page-path-utils';
 import type { Request, RequestHandler } from 'express';
 import type { ValidationChain } from 'express-validator';
 import { body } from 'express-validator';
@@ -27,6 +28,7 @@ import { apiV3FormValidator } from '../../../middlewares/apiv3-form-validator';
 import { excludeReadOnlyUser } from '../../../middlewares/exclude-read-only-user';
 import type { ApiV3Response } from '../interfaces/apiv3-response';
 
+
 const logger = loggerFactory('growi:routes:apiv3:page:update-page');
 
 
@@ -54,7 +56,9 @@ export const updatePageHandlersFactory: UpdatePageHandlersFactory = (crowi) => {
       .withMessage("'revisionId' must be specified"),
     body('body').exists().isString()
       .withMessage("Empty value is not allowed for 'body'"),
-    body('grant').optional().isInt({ min: 0, max: 5 }).withMessage('grant must be integer from 1 to 5'),
+    body('grant').optional().not().isString()
+      .isInt({ min: 0, max: 5 })
+      .withMessage('grant must be an integer from 1 to 5'),
     body('userRelatedGrantUserGroupIds').optional().isArray().withMessage('userRelatedGrantUserGroupIds must be an array of group id'),
     body('overwriteScopesOfDescendants').optional().isBoolean().withMessage('overwriteScopesOfDescendants must be boolean'),
     body('isSlackEnabled').optional().isBoolean().withMessage('isSlackEnabled must be boolean'),
@@ -119,7 +123,7 @@ export const updatePageHandlersFactory: UpdatePageHandlersFactory = (crowi) => {
     validator, apiV3FormValidator,
     async(req: UpdatePageRequest, res: ApiV3Response) => {
       const {
-        pageId, revisionId, body, origin,
+        pageId, revisionId, body, origin, grant,
       } = req.body;
 
       const sanitizeRevisionId = revisionId == null ? undefined : generalXssFilter.process(revisionId);
@@ -137,6 +141,12 @@ export const updatePageHandlersFactory: UpdatePageHandlersFactory = (crowi) => {
         return res.apiv3Err(new ErrorV3(`Page('${pageId}' is not found or forbidden`, 'notfound_or_forbidden'), 400);
       }
 
+      const isGrantImmutable = isTopPage(currentPage.path) || isUsersProtectedPages(currentPage.path);
+
+      if (grant != null && grant !== currentPage.grant && isGrantImmutable) {
+        return res.apiv3Err(new ErrorV3('The grant settings for the specified page cannot be modified.', PageUpdateErrorCode.FORBIDDEN), 403);
+      }
+
       if (currentPage != null) {
         // Normalize the latest revision which was borken by the migration script '20211227060705-revision-path-to-page-id-schema-migration--fixed-7549.js'
         try {
@@ -162,7 +172,7 @@ export const updatePageHandlersFactory: UpdatePageHandlersFactory = (crowi) => {
       let previousRevision: IRevisionHasId | null;
       try {
         const {
-          grant, userRelatedGrantUserGroupIds, overwriteScopesOfDescendants, wip,
+          userRelatedGrantUserGroupIds, overwriteScopesOfDescendants, wip,
         } = req.body;
         const options: IOptionsForUpdate = { overwriteScopesOfDescendants, origin, wip };
         if (grant != null) {

+ 4 - 3
apps/app/src/server/routes/comment.js

@@ -1,4 +1,5 @@
 
+import { getIdStringForRef } from '@growi/core';
 import { serializeUserSecurely } from '@growi/core/dist/models/serializers';
 
 import { Comment, CommentEvent, commentEvent } from '~/features/comment/server';
@@ -56,7 +57,6 @@ module.exports = function(crowi, app) {
   const logger = loggerFactory('growi:routes:comment');
   const User = crowi.model('User');
   const Page = crowi.model('Page');
-  const GlobalNotificationSetting = crowi.model('GlobalNotificationSetting');
   const ApiResponse = require('../util/apiResponse');
 
   const activityEvent = crowi.event('activity');
@@ -465,6 +465,7 @@ module.exports = function(crowi, app) {
     }
 
     try {
+      /** @type {import('mongoose').HydratedDocument<import('~/interfaces/comment').IComment>} */
       const comment = await Comment.findById(commentId).exec();
 
       if (comment == null) {
@@ -472,12 +473,12 @@ module.exports = function(crowi, app) {
       }
 
       // check whether accessible
-      const pageId = comment.page;
+      const pageId = getIdStringForRef(comment.page);
       const isAccessible = await Page.isAccessiblePageByViewer(pageId, req.user);
       if (!isAccessible) {
         throw new Error('Current user is not accessible to this page.');
       }
-      if (req.user._id !== comment.creator.toString()) {
+      if (getIdStringForRef(req.user) !== getIdStringForRef(comment.creator)) {
         throw new Error('Current user is not operatable to this comment.');
       }
 

+ 5 - 4
apps/app/src/server/service/g2g-transfer.ts

@@ -10,9 +10,10 @@ import FormData from 'form-data';
 import mongoose, { Types as MongooseTypes } from 'mongoose';
 
 import { G2G_PROGRESS_STATUS } from '~/interfaces/g2g-transfer';
-import GrowiArchiveImportOption from '~/models/admin/growi-archive-import-option';
+import { GrowiArchiveImportOption } from '~/models/admin/growi-archive-import-option';
+import { ImportMode } from '~/models/admin/import-mode';
 import TransferKeyModel from '~/server/models/transfer-key';
-import { getImportService, ImportMode, type ImportSettings } from '~/server/service/import';
+import { getImportService, type ImportSettings } from '~/server/service/import';
 import { createBatchStream } from '~/server/util/batch-stream';
 import axios from '~/utils/axios';
 import loggerFactory from '~/utils/logger';
@@ -609,12 +610,12 @@ export class G2GTransferReceiverService implements Receiver {
   ): { [key: string]: ImportSettings; } {
     const importSettingsMap = {};
     innerFileStats.forEach(({ fileName, collectionName }) => {
-      const options = new GrowiArchiveImportOption(null, optionsMap[collectionName]);
+      const options = new GrowiArchiveImportOption(collectionName, undefined, optionsMap[collectionName]);
 
       if (collectionName === 'configs' && options.mode !== ImportMode.flushAndInsert) {
         throw new Error('`flushAndInsert` is only available as an import setting for configs collection');
       }
-      if (collectionName === 'pages' && options.mode === 'insert') {
+      if (collectionName === 'pages' && options.mode === ImportMode.insert) {
         throw new Error('`insert` is not available as an import setting for pages collection');
       }
       if (collectionName === 'attachmentFiles.chunks') {

+ 2 - 1
apps/app/src/server/service/import/import-settings.ts

@@ -1,4 +1,5 @@
-import type { ImportMode } from './import-mode';
+import type { ImportMode } from '~/models/admin/import-mode';
+
 import type { OverwriteFunction } from './overwrite-function';
 
 export type OverwriteParams = { [propertyName: string]: OverwriteFunction | unknown }

+ 5 - 7
apps/app/src/server/service/import/import.ts

@@ -13,6 +13,7 @@ import mongoose from 'mongoose';
 import streamToPromise from 'stream-to-promise';
 import unzipStream from 'unzip-stream';
 
+import { ImportMode } from '~/models/admin/import-mode';
 import type Crowi from '~/server/crowi';
 import { setupIndependentModels } from '~/server/crowi/setup-models';
 import type CollectionProgress from '~/server/models/vo/collection-progress';
@@ -25,7 +26,6 @@ import { configManager } from '../config-manager';
 import type { ConvertMap } from './construct-convert-map';
 import { constructConvertMap } from './construct-convert-map';
 import { getModelFromCollectionName } from './get-model-from-collection-name';
-import { ImportMode } from './import-mode';
 import type { ImportSettings, OverwriteParams } from './import-settings';
 import { keepOriginal } from './overwrite-function';
 
@@ -303,14 +303,12 @@ export class ImportService {
 
   /**
    * process bulk operation
-   * @param {object} bulk MongoDB Bulk instance
-   * @param {string} collectionName collection name
-   * @param {object} document
-   * @param {ImportSettings} importSettings
+   * @param bulk MongoDB Bulk instance
+   * @param collectionName collection name
    */
-  bulkOperate(bulk, collectionName, document, importSettings) {
+  bulkOperate(bulk, collectionName: string, document, importSettings: ImportSettings) {
     // insert
-    if (importSettings.mode !== 'upsert') {
+    if (importSettings.mode !== ImportMode.upsert) {
       return bulk.insert(document);
     }
 

+ 0 - 2
apps/app/src/server/service/import/index.ts

@@ -18,6 +18,4 @@ export const getImportService = (): ImportService => {
   return instance;
 };
 
-
-export * from './import-mode';
 export * from './import-settings';

+ 1 - 1
apps/app/src/server/service/import/overwrite-params/index.ts

@@ -1,4 +1,4 @@
-import type GrowiArchiveImportOption from '~/models/admin/growi-archive-import-option';
+import type { GrowiArchiveImportOption } from '~/models/admin/growi-archive-import-option';
 import { isImportOptionForPages } from '~/models/admin/import-option-for-pages';
 
 import type { OverwriteParams } from '../import-settings';

+ 1 - 1
apps/app/src/server/util/compare-objectId.ts

@@ -1,6 +1,6 @@
 import mongoose from 'mongoose';
 
-import { ObjectIdLike } from '~/server/interfaces/mongoose-utils';
+import type { ObjectIdLike } from '~/server/interfaces/mongoose-utils';
 
 type IObjectId = mongoose.Types.ObjectId;
 const ObjectId = mongoose.Types.ObjectId;

+ 14 - 2
apps/app/src/stores/page.tsx

@@ -57,10 +57,14 @@ export const useTemplateBodyData = (initialData?: string): SWRResponse<string, E
 export const useSWRxCurrentPage = (initialData?: IPagePopulatedToShowRevision|null): SWRResponse<IPagePopulatedToShowRevision|null> => {
   const key = 'currentPage';
 
+  const { data: isLatestRevision } = useIsLatestRevision();
+
   const { cache } = useSWRConfig();
 
   // Problem 1: https://github.com/weseek/growi/pull/7772/files#diff-4c1708c4f959974166c15435c6b35950ba01bbf35e7e4b8e99efeb125a8000a7
   // Problem 2: https://redmine.weseek.co.jp/issues/141027
+  // Problem 3: https://redmine.weseek.co.jp/issues/153618
+  // Problem 4: https://redmine.weseek.co.jp/issues/153759
   const shouldMutate = (() => {
     if (initialData === undefined) {
       return false;
@@ -81,6 +85,14 @@ export const useSWRxCurrentPage = (initialData?: IPagePopulatedToShowRevision|nu
       return true;
     }
 
+    // mutate when opening a previous revision.
+    if (!isLatestRevision
+        && cachedData.revision?._id != null && initialData.revision?._id != null
+        && cachedData.revision._id !== initialData.revision._id
+    ) {
+      return true;
+    }
+
     return false;
   })();
 
@@ -280,7 +292,7 @@ export const useSWRxCurrentGrantData = (
     ? ['/page/grant-data', pageId]
     : null;
 
-  return useSWRImmutable(
+  return useSWR(
     key,
     ([endpoint, pageId]) => apiv3Get(endpoint, { pageId }).then(response => response.data),
   );
@@ -290,7 +302,7 @@ export const useSWRxApplicableGrant = (
     pageId: string | null | undefined,
 ): SWRResponse<IRecordApplicableGrant, Error> => {
 
-  return useSWRImmutable(
+  return useSWR(
     pageId != null ? ['/page/applicable-grant', pageId] : null,
     ([endpoint, pageId]) => apiv3Get(endpoint, { pageId }).then(response => response.data),
   );

+ 18 - 4
apps/app/src/stores/ui.tsx

@@ -337,11 +337,25 @@ export const useCommentEditorDirtyMap = (): SWRResponse<Map<string, boolean>, Er
  *********************************************************** */
 
 export const useIsAbleToShowTrashPageManagementButtons = (): SWRResponse<boolean, Error> => {
-  const { data: currentUser } = useCurrentUser();
-  const { data: isReadOnlyUser } = useIsReadOnlyUser();
-  const { data: isTrashPage } = useIsTrashPage();
+  const key = 'isAbleToShowTrashPageManagementButtons';
 
-  return useStaticSWR('isAbleToShowTrashPageManagementButtons', isTrashPage && currentUser != null && !isReadOnlyUser);
+  const { data: _currentUser } = useCurrentUser();
+  const isCurrentUserExist = _currentUser != null;
+
+  const { data: _currentPageId } = useCurrentPageId();
+  const { data: _isNotFound } = useIsNotFound();
+  const { data: _isTrashPage } = useIsTrashPage();
+  const { data: _isReadOnlyUser } = useIsReadOnlyUser();
+  const isPageExist = _currentPageId != null && _isNotFound === false;
+  const isTrashPage = isPageExist && _isTrashPage === true;
+  const isReadOnlyUser = isPageExist && _isReadOnlyUser === true;
+
+  const includesUndefined = [_currentUser, _currentPageId, _isNotFound, _isReadOnlyUser, _isTrashPage].some(v => v === undefined);
+
+  return useSWRImmutable(
+    includesUndefined ? null : [key, isTrashPage, isCurrentUserExist, isReadOnlyUser],
+    ([, isTrashPage, isCurrentUserExist, isReadOnlyUser]) => isTrashPage && isCurrentUserExist && !isReadOnlyUser,
+  );
 };
 
 export const useIsAbleToShowPageManagement = (): SWRResponse<boolean, Error> => {

+ 0 - 8
apps/app/test/cypress/.eslintrc.js

@@ -1,8 +0,0 @@
-module.exports = {
-  root: true,
-  extends: [
-    'weseek/typescript',
-    'plugin:cypress/recommended',
-  ],
-  plugins: ['@typescript-eslint', 'cypress'],
-};

+ 0 - 102
apps/app/test/cypress/e2e/0-advanced-examples/misc.cy.ts

@@ -1,102 +0,0 @@
-context('Misc', () => {
-  beforeEach(() => {
-    cy.visit('https://example.cypress.io/commands/misc')
-  })
-
-  it('.end() - end the command chain', () => {
-    // https://on.cypress.io/end
-
-    // cy.end is useful when you want to end a chain of commands
-    // and force Cypress to re-query from the root element
-    cy.get('.misc-table').within(() => {
-      // ends the current chain and yields null
-      cy.contains('Cheryl').click().end()
-
-      // queries the entire table again
-      cy.contains('Charles').click()
-    })
-  })
-
-  it('cy.exec() - execute a system command', () => {
-    // execute a system command.
-    // so you can take actions necessary for
-    // your test outside the scope of Cypress.
-    // https://on.cypress.io/exec
-
-    // we can use Cypress.platform string to
-    // select appropriate command
-    // https://on.cypress/io/platform
-    cy.log(`Platform ${Cypress.platform} architecture ${Cypress.arch}`)
-
-    // on CircleCI Windows build machines we have a failure to run bash shell
-    // https://github.com/cypress-io/cypress/issues/5169
-    // so skip some of the tests by passing flag "--env circle=true"
-    const isCircleOnWindows = Cypress.platform === 'win32' && Cypress.env('circle')
-
-    if (isCircleOnWindows) {
-      cy.log('Skipping test on CircleCI')
-
-      return
-    }
-
-    // cy.exec problem on Shippable CI
-    // https://github.com/cypress-io/cypress/issues/6718
-    const isShippable = Cypress.platform === 'linux' && Cypress.env('shippable')
-
-    if (isShippable) {
-      cy.log('Skipping test on ShippableCI')
-
-      return
-    }
-
-    cy.exec('echo Jane Lane')
-      .its('stdout').should('contain', 'Jane Lane')
-
-    if (Cypress.platform === 'win32') {
-      cy.exec('print cypress.json')
-        .its('stderr').should('be.empty')
-    } else {
-      cy.exec('cat cypress.json')
-        .its('stderr').should('be.empty')
-
-      cy.exec('pwd')
-        .its('code').should('eq', 0)
-    }
-  })
-
-  it('cy.focused() - get the DOM element that has focus', () => {
-    // https://on.cypress.io/focused
-    cy.get('.misc-form').find('#name').click()
-    cy.focused().should('have.id', 'name')
-
-    cy.get('.misc-form').find('#description').click()
-    cy.focused().should('have.id', 'description')
-  })
-
-  context('Cypress.Screenshot', function () {
-    it('cy.screenshot() - take a screenshot', () => {
-      // https://on.cypress.io/screenshot
-      cy.screenshot('my-image')
-    })
-
-    it('Cypress.Screenshot.defaults() - change default config of screenshots', function () {
-      Cypress.Screenshot.defaults({
-        blackout: ['.foo'],
-        capture: 'viewport',
-        clip: { x: 0, y: 0, width: 200, height: 200 },
-        scale: false,
-        disableTimersAndAnimations: true,
-        screenshotOnRunFailure: true,
-        onBeforeScreenshot () { },
-        onAfterScreenshot () { },
-      })
-    })
-  })
-
-  it('cy.wrap() - wrap an object', () => {
-    // https://on.cypress.io/wrap
-    cy.wrap({ foo: 'bar' })
-      .should('have.property', 'foo')
-      .and('include', 'bar')
-  })
-})

+ 0 - 59
apps/app/test/cypress/e2e/0-advanced-examples/viewport.cy.ts

@@ -1,59 +0,0 @@
-context('Viewport', () => {
-  beforeEach(() => {
-    cy.visit('https://example.cypress.io/commands/viewport')
-  })
-
-  it('cy.viewport() - set the viewport size and dimension', () => {
-    // https://on.cypress.io/viewport
-
-    cy.get('#navbar').should('be.visible')
-    cy.viewport(320, 480)
-
-    // the navbar should have collapse since our screen is smaller
-    cy.get('#navbar').should('not.be.visible')
-    cy.get('.navbar-toggle').should('be.visible').click()
-    cy.get('.nav').find('a').should('be.visible')
-
-    // lets see what our app looks like on a super large screen
-    cy.viewport(2999, 2999)
-
-    // cy.viewport() accepts a set of preset sizes
-    // to easily set the screen to a device's width and height
-
-    // We added a cy.wait() between each viewport change so you can see
-    // the change otherwise it is a little too fast to see :)
-
-    /* eslint-disable cypress/no-unnecessary-waiting */
-    cy.viewport('macbook-15')
-    cy.wait(200)
-    cy.viewport('macbook-13')
-    cy.wait(200)
-    cy.viewport('macbook-11')
-    cy.wait(200)
-    cy.viewport('ipad-2')
-    cy.wait(200)
-    cy.viewport('ipad-mini')
-    cy.wait(200)
-    cy.viewport('iphone-6+')
-    cy.wait(200)
-    cy.viewport('iphone-6')
-    cy.wait(200)
-    cy.viewport('iphone-5')
-    cy.wait(200)
-    cy.viewport('iphone-4')
-    cy.wait(200)
-    cy.viewport('iphone-3')
-    cy.wait(200)
-
-    // cy.viewport() accepts an orientation for all presets
-    // the default orientation is 'portrait'
-    cy.viewport('ipad-2', 'portrait')
-    cy.wait(200)
-    cy.viewport('iphone-4', 'landscape')
-    cy.wait(200)
-    /* eslint-enable cypress/no-unnecessary-waiting */
-
-    // The viewport will be reset back to the default dimensions
-    // in between tests (the  default can be set in cypress.json)
-  })
-})

+ 0 - 18
apps/app/test/cypress/e2e/21-basic-features-for-guest/21-basic-features-for-guest--access-to-page.cy.ts

@@ -1,18 +0,0 @@
-context('Access to special pages by guest', () => {
-  const ssPrefix = 'access-to-special-pages-by-guest-';
-  it('/tags is successfully loaded', () => {
-    cy.visit('/tags');
-
-    // open sidebar
-    cy.collapseSidebar(false);
-    // select tags
-    cy.getByTestid('grw-sidebar-nav-primary-tags').click();
-    cy.getByTestid('grw-sidebar-content-tags').should('be.visible');
-    cy.getByTestid('grw-tags-list').should('be.visible');
-    cy.getByTestid('grw-tags-list').contains('You have no tag, You can set tags on pages');
-
-    cy.getByTestid('tags-page').should('be.visible');
-    cy.screenshot(`${ssPrefix}-tags`);
-  });
-
-});

+ 0 - 303
apps/app/test/cypress/e2e/50-sidebar/50-sidebar--access-to-side-bar.cy.ts

@@ -1,303 +0,0 @@
-import { BlackoutGroup } from "../../support/blackout";
-
-// Blackout for recalculation of toc content hight
-const blackoutOverride = [
-  ...BlackoutGroup.BASIS,
-  ...BlackoutGroup.SIDE_CONTENTS,
-];
-
-describe('Access to sidebar', () => {
-  const ssPrefix = 'access-to-sidebar-';
-
-  context('when logged in', () => {
-    beforeEach(() => {
-      // login
-      cy.fixture("user-admin.json").then(user => {
-        cy.login(user.username, user.password);
-      });
-    });
-
-    context('when access to root page', { scrollBehavior: false }, () => {
-      beforeEach(() => {
-        cy.visit('/');
-
-        // Since this is a sidebar test, call collapseSidebar in beforeEach.
-        cy.collapseSidebar(false, true);
-      });
-
-      describe('Test show/collapse button', () => {
-        it('Successfully show sidebar', () => {
-          cy.getByTestid('grw-sidebar-contents').should('be.visible');
-
-          cy.waitUntilSkeletonDisappear();
-          cy.screenshot(`${ssPrefix}1-sidebar-shown`, {
-            capture: 'viewport',
-            blackout: blackoutOverride,
-          });
-        });
-
-        it('Successfully collapse sidebar', () => {
-          cy.getByTestid('btn-toggle-collapse').click({force: true});
-
-          cy.getByTestid('grw-sidebar-contents').should('not.be.visible');
-
-          cy.waitUntilSkeletonDisappear();
-          cy.screenshot(`${ssPrefix}2-sidebar-collapsed`, {
-            capture: 'viewport',
-            blackout: blackoutOverride,
-          });
-        });
-      });
-
-      describe('Test page tree tab', () => {
-        beforeEach(() => {
-          cy.getByTestid('grw-sidebar-nav-primary-page-tree').click();
-        });
-
-        it('Successfully access to page tree', () => {
-          cy.getByTestid('grw-sidebar-contents').within(() => {
-            cy.getByTestid('grw-pagetree-item-container').should('be.visible');
-
-            cy.waitUntilSkeletonDisappear();
-            cy.screenshot(`${ssPrefix}page-tree-1-access-to-page-tree`, { blackout: blackoutOverride });
-          });
-        });
-
-
-        //
-        // Deactivate: An error occurs that cannot be reproduced in the development environment. -- Yuki Takei 2024.05.10
-        //
-
-        // it('Successfully click Add to Bookmarks button', () => {
-        //   cy.waitUntil(() => {
-        //     // do
-        //     cy.getByTestid('grw-sidebar-contents').within(() => {
-        //       cy.getByTestid('grw-pagetree-item-container').eq(1).within(() => { // against the second element
-        //         cy.get('li').realHover();
-        //         cy.getByTestid('open-page-item-control-btn').find('button').first().realClick();
-        //       });
-        //     });
-        //     // wait until
-        //     return cy.get('.dropdown-menu.show').then($elem => $elem.is(':visible'));
-        //   });
-
-        //   cy.get('.dropdown-menu.show').should('be.visible').within(() => {
-        //     // take a screenshot for dropdown menu
-        //     cy.screenshot(`${ssPrefix}page-tree-2-before-adding-bookmark`)
-        //     // click add remove bookmark btn
-        //     cy.getByTestid('add-bookmark-btn').click();
-        //   })
-
-        //   // show dropdown again
-        //   cy.waitUntil(() => {
-        //     // do
-        //     cy.getByTestid('grw-sidebar-contents').within(() => {
-        //       cy.getByTestid('grw-pagetree-item-container').eq(1).within(() => { // against the second element
-        //         cy.get('li').realHover();
-        //         cy.getByTestid('open-page-item-control-btn').find('button').first().realClick();
-        //       });
-        //     });
-        //     // wait until
-        //     return cy.get('.dropdown-menu.show').then($elem => $elem.is(':visible'));
-        //   });
-
-        //   cy.get('.dropdown-menu.show').should('be.visible').within(() => {
-        //     // expect to be visible
-        //     cy.getByTestid('remove-bookmark-btn').should('be.visible');
-        //     // take a screenshot for dropdown menu
-        //     cy.screenshot(`${ssPrefix}page-tree-2-after-adding-bookmark`);
-        //   });
-        // });
-
-        // it('Successfully show duplicate page modal', () => {
-        //   cy.waitUntil(() => {
-        //     // do
-        //     cy.getByTestid('grw-sidebar-contents').within(() => {
-        //       cy.getByTestid('grw-pagetree-item-container').eq(1).within(() => { // against the second element
-        //         cy.get('li').realHover();
-        //         cy.getByTestid('open-page-item-control-btn').find('button').first().realClick();
-        //       });
-        //     });
-        //     // wait until
-        //     return cy.get('.dropdown-menu.show').then($elem => $elem.is(':visible'));
-        //   });
-
-        //   cy.get('.dropdown-menu.show').should('be.visible').within(() => {
-        //     cy.getByTestid('open-page-duplicate-modal-btn').click();
-        //   })
-
-        //   cy.getByTestid('page-duplicate-modal').should('be.visible').within(() => {
-        //     cy.get('.form-control').type('_test');
-
-        //     cy.screenshot(`${ssPrefix}page-tree-5-duplicate-page-modal`, { blackout: blackoutOverride });
-
-        //     cy.get('.modal-header > button').click();
-        //   });
-        // });
-
-        // it('Successfully rename page', () => {
-        //   cy.waitUntil(() => {
-        //     // do
-        //     cy.getByTestid('grw-sidebar-contents').within(() => {
-        //       cy.getByTestid('grw-pagetree-item-container').eq(1).within(() => { // against the second element
-        //         cy.get('li').realHover();
-        //         cy.getByTestid('open-page-item-control-btn').find('button').first().realClick();
-        //       });
-        //     });
-        //     // wait until
-        //     return cy.get('.dropdown-menu.show').then($elem => $elem.is(':visible'));
-        //   });
-
-        //   cy.get('.dropdown-menu.show').should('be.visible').within(() => {
-        //     cy.getByTestid('rename-page-btn').click();
-        //   })
-
-        //   cy.getByTestid('grw-sidebar-contents').within(() => {
-        //     cy.getByTestid('autosize-submittable-input').type('_newname');
-        //   })
-
-        //   cy.screenshot(`${ssPrefix}page-tree-6-rename-page`, { blackout: blackoutOverride });
-        // });
-
-        // it('Successfully show delete page modal', () => {
-        //   cy.waitUntil(() => {
-        //     // do
-        //     cy.getByTestid('grw-sidebar-contents').within(() => {
-        //       cy.getByTestid('grw-pagetree-item-container').eq(1).within(() => { // against the second element
-        //         cy.get('li').realHover();
-        //         cy.getByTestid('open-page-item-control-btn').find('button').first().realClick();
-        //       });
-        //     });
-        //     // wait until
-        //     return cy.get('.dropdown-menu.show').then($elem => $elem.is(':visible'));
-        //   });
-
-        //   cy.get('.dropdown-menu.show').should('be.visible').within(() => {
-        //     cy.getByTestid('open-page-delete-modal-btn').click();
-        //   })
-
-        //   cy.getByTestid('page-delete-modal').should('be.visible').within(() => {
-        //     cy.screenshot(`${ssPrefix}page-tree-7-delete-page-modal`, { blackout: blackoutOverride });
-        //     cy.get('.modal-header > button').click();
-        //   });
-        // });
-      });
-
-      describe('Test custom sidebar tab', () => {
-        beforeEach(() => {
-          cy.getByTestid('grw-sidebar-nav-primary-custom-sidebar').click();
-        });
-
-        it('Successfully access to custom sidebar', () => {
-          cy.getByTestid('grw-sidebar-contents').within(() => {
-            cy.get('.grw-sidebar-content-header > h4').find('a');
-
-            cy.waitUntilSkeletonDisappear();
-            cy.screenshot(`${ssPrefix}custom-sidebar-1-access-to-custom-sidebar`, { blackout: blackoutOverride });
-          });
-        });
-
-        // TODO: fix by https://redmine.weseek.co.jp/issues/138562
-        // it('Successfully redirect to editor', () => {
-        //   const content = '# HELLO \n ## Hello\n ### Hello';
-
-        //   cy.get('.grw-sidebar-content-header > h3 > a').should('be.visible').click();
-
-        //   cy.get('.layout-root').should('have.class', 'editing');
-        //   cy.get('.CodeMirror textarea').type(content, {force: true});
-
-        //   cy.screenshot(`${ssPrefix}custom-sidebar-2-redirect-to-editor`, { blackout: blackoutOverride });
-
-        //   cy.getByTestid('save-page-btn').click();
-        // });
-
-        // it('Successfully create custom sidebar content', () => {
-        //   cy.getByTestid('grw-sidebar-nav-primary-custom-sidebar')
-        //     .should('be.visible')
-        //     .should('have.class', 'active');
-
-        //   cy.waitUntilSkeletonDisappear();
-        //   cy.screenshot(`${ssPrefix}custom-sidebar-3-content-created`, { blackout: blackoutOverride });
-        // });
-      });
-
-      describe('Test recent changes tab', () => {
-        beforeEach(() => {
-          cy.getByTestid('grw-sidebar-nav-primary-recent-changes').click();
-        });
-
-        it('Successfully access to recent changes', () => {
-          cy.getByTestid('grw-recent-changes').should('be.visible');
-          cy.get('.list-group-item').should('be.visible');
-
-          // The scope of the screenshot is not narrowed because the blackout is shifted
-          cy.screenshot(`${ssPrefix}recent-changes-access-to-recent-changes`, { blackout: blackoutOverride });
-        });
-
-      });
-
-      //
-      // Deactivate: An error occurs that cannot be reproduced in the development environment. -- Yuki Takei 2024.05.10
-      //
-      // describe('Test tags tab', () => {
-      //   beforeEach(() => {
-      //     cy.getByTestid('grw-sidebar-nav-primary-tags').click();
-      //   });
-
-      //   it('Successfully access to tags', () => {
-      //     cy.getByTestid('grw-sidebar-contents').within(() => {
-      //       cy.getByTestid('grw-tags-list').should('be.visible');
-
-      //       cy.screenshot(`${ssPrefix}tags-1-access-to-tags`, { blackout: blackoutOverride });
-      //     });
-      //   });
-
-      //   it('Succesfully click all tags button', () => {
-      //     cy.getByTestid('grw-sidebar-content-tags').within(() => {
-      //       cy.get('.btn-primary').as('check-all-tags-button');
-      //       cy.get('@check-all-tags-button').should('be.visible');
-      //       cy.get('@check-all-tags-button').click({force: true});
-      //     });
-      //     cy.collapseSidebar(true);
-      //     cy.getByTestid('grw-tags-list').should('be.visible');
-
-      //     cy.screenshot(`${ssPrefix}tags-2-click-all-tags-button`, { blackout: blackoutOverride });
-      //   });
-      // });
-
-      // // TODO: No Drafts pages on GROWI version 6
-      // it('Successfully access to My Drafts page', () => {
-      //   cy.visit('/');
-      //   cy.collapseSidebar(true);
-      //   cy.get('.grw-sidebar-nav-secondary-container').within(() => {
-      //     cy.get('a[href*="/me/drafts"]').click();
-      //   });
-      //   cy.screenshot(`${ssPrefix}access-to-drafts-page`, { blackout: blackoutOverride });
-      // });
-
-      describe('Test access to GROWI Docs page', () => {
-        it('Successfully access to GROWI Docs page', () => {
-          cy.get('.grw-sidebar-nav-secondary-container').within(() => {
-            cy.get('a[href*="https://docs.growi.org"]').then(($a) => {
-              const url = $a.prop('href')
-              cy.request(url).its('body').should('include', '</html>');
-            });
-          });
-        });
-      });
-
-      describe('Test access to trash page', () => {
-        it('Successfully access to trash page', () => {
-          cy.collapseSidebar(true);
-          cy.get('.grw-sidebar-nav-secondary-container').within(() => {
-            cy.get('a[href*="/trash"]').click();
-          });
-
-          cy.getByTestid('trash-page-list').should('be.visible');
-
-          cy.screenshot(`${ssPrefix}access-to-trash-page`, { blackout: blackoutOverride });
-        });
-      });
-    });
-  });
-});

+ 0 - 65
apps/app/test/cypress/e2e/50-sidebar/50-sidebar--switching-sidebar-mode.cy.ts

@@ -1,65 +0,0 @@
-import { BlackoutGroup } from "../../support/blackout";
-
-// Blackout for recalculation of toc content hight
-const blackoutOverride = [
-  ...BlackoutGroup.BASIS,
-  ...BlackoutGroup.SIDE_CONTENTS,
-];
-
-context('Switch sidebar mode', () => {
-  const ssPrefix = 'switch-sidebar-mode-';
-
-  beforeEach(() => {
-    // login
-    cy.fixture("user-admin.json").then(user => {
-      cy.login(user.username, user.password);
-    });
-    cy.visit('/');
-  });
-
-  it('Switching sidebar mode', () => {
-    cy.collapseSidebar(false);
-    cy.screenshot(`${ssPrefix}-doc-mode-opened`, {
-      blackout: blackoutOverride,
-    });
-
-    cy.collapseSidebar(true);
-    cy.screenshot(`${ssPrefix}-doc-mode-closed`, {
-      blackout: blackoutOverride,
-    });
-  });
-
-});
-
-context('Switch viewport size', () => {
-  const ssPrefix = 'switch-viewport-size-';
-
-  const sizes = {
-    'xl': [1200, 1024],
-    'lg': [992, 1024],
-    'md': [768, 1024],
-    'sm': [576, 1024],
-    'xs': [575, 1024],
-    'iphone-x': [375, 812],
-  };
-
-  Object.entries(sizes).forEach(([screenLabel, size]) => {
-    it(`on ${screenLabel} screen`, () => {
-      cy.viewport(size[0], size[1]);
-
-      // login
-      cy.fixture("user-admin.json").then(user => {
-        cy.login(user.username, user.password);
-      });
-      cy.visit('/');
-
-      cy.get('.layout-root').should('be.visible');
-
-      cy.screenshot(`${ssPrefix}-${screenLabel}`, {
-        blackout: blackoutOverride,
-      });
-    });
-  });
-
-});
-

+ 0 - 6
apps/app/test/cypress/fixtures/user-admin.json

@@ -1,6 +0,0 @@
-{
-  "username": "admin",
-  "name": "Admin",
-  "email": "admin@example.com",
-  "password": "adminadmin"
-}

+ 0 - 21
apps/app/test/cypress/support/assertions.ts

@@ -1,21 +0,0 @@
-// from https://github.com/cypress-io/cypress/issues/877#issuecomment-538708750
-const isInViewport = (_chai) => {
-  function assertIsInViewport() {
-
-    const subject = this._obj;
-
-    const bottom = Cypress.config("viewportWidth");
-    const rect = subject[0].getBoundingClientRect();
-
-    this.assert(
-      rect.top < bottom && rect.bottom < bottom,
-      "expected #{this} to be in viewport",
-      "expected #{this} to not be in viewport",
-      this._obj
-    )
-  }
-
-  _chai.Assertion.addMethod('inViewport', assertIsInViewport)
-};
-
-chai.use(isInViewport);

+ 0 - 14
apps/app/test/cypress/support/blackout.ts

@@ -1,14 +0,0 @@
-export const BlackoutGroup = {
-  BASIS: [
-    '[data-vrt-blackout=true]',
-    '[data-vrt-blackout-hash=true]',
-    '[data-vrt-blackout-profile=true]',
-    '[data-vrt-blackout-datetime=true]',
-  ],
-  SIDEBAR_NAV: [
-    '[data-vrt-blackout-sidebar-nav=true]',
-  ],
-  SIDE_CONTENTS: [
-    '[data-vrt-blackout-side-contents=true]',
-  ],
-} as const;

+ 0 - 118
apps/app/test/cypress/support/commands.ts

@@ -1,118 +0,0 @@
-// ***********************************************
-// This example commands.js shows you how to
-// create various custom commands and overwrite
-// existing commands.
-//
-// For more comprehensive examples of custom
-// commands please read more here:
-// https://on.cypress.io/custom-commands
-// ***********************************************
-//
-//
-// -- This is a parent command --
-// Cypress.Commands.add('login', (email, password) => { ... })
-//
-//
-// -- This is a child command --
-// Cypress.Commands.add('drag', { prevSubject: 'element'}, (subject, options) => { ... })
-//
-//
-// -- This is a dual command --
-// Cypress.Commands.add('dismiss', { prevSubject: 'optional'}, (subject, options) => { ... })
-//
-//
-// -- This will overwrite an existing command --
-// Cypress.Commands.overwrite('visit', (originalFn, url, options) => { ... })
-
-import 'cypress-wait-until';
-
-function isVisible($elem: JQuery<Element>) {
-  return $elem.is(':visible');
-}
-function isHidden($elem: JQuery<Element>) {
-  return !isVisible($elem);
-}
-function isVisibleByTestId(testId: string) {
-  return isVisible(Cypress.$(`[data-testid=${testId}]`));
-}
-function isHiddenByTestId(testId: string) {
-  return !isVisibleByTestId(testId);
-}
-
-Cypress.Commands.add('getByTestid', (selector, options?) => {
-  return cy.get(`[data-testid=${selector}]`, options);
-});
-
-Cypress.Commands.add('login', (username, password) => {
-  cy.session(username, () => {
-    cy.visit('/page-to-return-after-login');
-    cy.getByTestid('tiUsernameForLogin').type(username);
-    cy.getByTestid('tiPasswordForLogin').type(password);
-
-    cy.intercept('POST', '/_api/v3/login').as('login');
-    cy.getByTestid('btnSubmitForLogin').click();
-    cy.wait('@login')
-  });
-});
-
-Cypress.Commands.add('waitUntilSkeletonDisappear', () => {
-  if (isHidden(Cypress.$('.grw-skeleton'))) {
-    return;
-  }
-  cy.get('.grw-skeleton').should('not.exist');
-});
-
-Cypress.Commands.add('waitUntilSpinnerDisappear', () => {
-  if (isHidden(Cypress.$('.fa-spinner'))) {
-    return;
-  }
-  cy.get('.fa-spinner').should('not.exist');
-});
-
-Cypress.Commands.add('collapseSidebar', (isCollapsed: boolean, waitUntilSaving = false) => {
-  cy.getByTestid('grw-sidebar').within(($sidebar) => {
-
-    // skip if .grw-sidebar-dock does not exist
-    if (!$sidebar.hasClass('grw-sidebar-dock')) {
-      return;
-    }
-
-  });
-
-  cy.getByTestid('grw-sidebar').should('be.visible').within(() => {
-
-    const isSidebarContentsHidden = isHiddenByTestId('grw-sidebar-contents');
-    if (isSidebarContentsHidden === isCollapsed) {
-      return;
-    }
-
-    cy.waitUntil(() => {
-      // do
-      cy.getByTestid("btn-toggle-collapse").click({force: true});
-      // wait until saving UserUISettings
-      if (waitUntilSaving) {
-        // eslint-disable-next-line cypress/no-unnecessary-waiting
-        cy.wait(1500);
-      }
-
-      // wait until
-      return cy.getByTestid('grw-sidebar-contents').then($contents => isHidden($contents) === isCollapsed);
-    });
-  });
-
-});
-
-Cypress.Commands.add('appendTextToEditorUntilContains', (inputText: string) => {
-  const lines: string[] = [];
-  cy.waitUntil(() => {
-    // do
-    cy.get('.CodeMirror textarea').type(inputText, { force: true });
-    // until
-    return cy.get('.CodeMirror-line')
-      .each(($item) => {
-        lines.push($item.text());
-      }).then(() => {
-        return lines.join('\n').endsWith(inputText);
-      });
-  });
-});

+ 0 - 48
apps/app/test/cypress/support/index.ts

@@ -1,48 +0,0 @@
-// ***********************************************************
-// This example support/index.js is processed and
-// loaded automatically before your test files.
-//
-// This is a great place to put global configuration and
-// behavior that modifies Cypress.
-//
-// You can change the location of this file or turn off
-// automatically serving support files with the
-// 'supportFile' configuration option.
-//
-// You can read more here:
-// https://on.cypress.io/configuration
-// ***********************************************************
-
-// Import commands.js using ES2015 syntax:
-import 'cypress-real-events';
-
-import './assertions'
-import './commands'
-import './screenshot'
-
-// Alternatively you can use CommonJS syntax:
-// require('./commands')
-
-// Ignore 'ResizeObserver loop limit exceeded' exception
-// https://github.com/cypress-io/cypress/issues/8418
-const resizeObserverLoopErrRe = /^[^(ResizeObserver loop limit exceeded)]/
-Cypress.on('uncaught:exception', (err) => {
-    /* returning false here prevents Cypress from failing the test */
-    if (resizeObserverLoopErrRe.test(err.message)) {
-        return false
-    }
-})
-
-declare global {
-  // eslint-disable-next-line @typescript-eslint/no-namespace
-  namespace Cypress {
-    interface Chainable {
-       getByTestid(selector: string, options?: Partial<Loggable & Timeoutable & Withinable & Shadow>): Chainable<JQuery<Element>>,
-       login(username: string, password: string): Chainable<void>,
-       collapseSidebar(isCollapsed: boolean, waitUntilSaving?: boolean): Chainable<void>,
-       waitUntilSkeletonDisappear(): Chainable<void>,
-       waitUntilSpinnerDisappear(): Chainable<void>,
-       appendTextToEditorUntilContains(inputText: string): Chainable<void>
-    }
-  }
-}

+ 0 - 9
apps/app/test/cypress/support/screenshot.ts

@@ -1,9 +0,0 @@
-import { BlackoutGroup } from "./blackout";
-
-Cypress.Screenshot.defaults({
-  blackout: [
-    ...BlackoutGroup.BASIS,
-    ...BlackoutGroup.SIDEBAR_NAV,
-  ],
-  capture: 'viewport',
-})

+ 0 - 16
apps/app/test/cypress/tsconfig.json

@@ -1,16 +0,0 @@
-{
-  "extends": "../../tsconfig.json",
-  "compilerOptions": {
-    "noEmit": true,
-    // be explicit about types included
-    // to avoid clashing with Jest types
-    "types": ["cypress", "cypress-real-events"],
-    // turn off sourceMap
-    // see: https://github.com/cypress-io/cypress/issues/26203
-    "sourceMap": false
-  },
-  "include": [
-    "../../node_modules/cypress",
-    "./**/*.ts"
-  ]
-}

+ 3 - 2
apps/app/turbo.json

@@ -49,8 +49,9 @@
       "cache": false,
       "persistent": true
     },
-    "dev:ci": {
-      "dependsOn": ["^dev", "dev:migrate", "dev:styles-prebuilt"],
+
+    "launch-dev:ci": {
+      "dependsOn": ["^dev", "dev:styles-prebuilt"],
       "cache": false
     },
 

+ 1 - 1
apps/slackbot-proxy/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@growi/slackbot-proxy",
-  "version": "7.0.18-slackbot-proxy.0",
+  "version": "7.0.20-slackbot-proxy.0",
   "license": "MIT",
   "private": "true",
   "scripts": {

+ 3 - 1
apps/slackbot-proxy/src/services/LinkSharedService.ts

@@ -3,6 +3,8 @@ import type { WebClient } from '@slack/web-api';
 import { Inject, Service } from '@tsed/di';
 import axios from 'axios';
 
+// needed to import class (not type) for injection
+// eslint-disable-next-line @typescript-eslint/consistent-type-imports
 import { RelationRepository } from '~/repositories/relation';
 import loggerFactory from '~/utils/logger';
 
@@ -42,7 +44,7 @@ type PublicData = {
 export type DataForLinkShared = PrivateData | PublicData;
 
 @Service()
-export class LinkSharedService implements GrowiEventProcessor {
+export class LinkSharedService implements GrowiEventProcessor<LinkSharedRequestEvent> {
 
   @Inject()
   relationRepository: RelationRepository;

+ 42 - 0
apps/slackbot-proxy/turbo.json

@@ -0,0 +1,42 @@
+{
+  "$schema": "https://turbo.build/schema.json",
+  "extends": ["//"],
+  "tasks": {
+
+    "clean": {
+      "dependsOn": ["@growi/slack#clean"],
+      "cache": false
+    },
+
+    "build": {
+      "dependsOn": ["@growi/slack#build"],
+      "outputs": ["dist/**"],
+      "outputLogs": "new-only"
+    },
+
+    "dev": {
+      "dependsOn": ["@growi/slack#dev"],
+      "cache": false,
+      "persistent": true
+    },
+    "dev:ci": {
+      "dependsOn": ["@growi/slack#dev"],
+      "cache": false
+    },
+
+    "lint": {
+      "dependsOn": ["@growi/slack#dev"]
+    },
+
+    "test": {
+      "dependsOn": ["@growi/slack#dev"],
+      "outputLogs": "new-only"
+    },
+
+    "version": {
+      "cache": false,
+      "dependsOn": ["^version", "//#version"]
+    }
+
+  }
+}

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

@@ -1,53 +0,0 @@
-/* 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
- */
-
-import yargs from 'yargs';
-import { hideBin } from '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);

+ 1 - 3
package.json

@@ -1,6 +1,6 @@
 {
   "name": "growi",
-  "version": "7.0.18-RC.0",
+  "version": "7.0.20-RC.0",
   "description": "Team collaboration software using markdown",
   "license": "MIT",
   "private": "true",
@@ -71,8 +71,6 @@
     "@vitejs/plugin-react": "^4.3.1",
     "@vitest/coverage-v8": "^1.6.0",
     "@vitest/ui": "^1.6.0",
-    "cypress": "^13.3.0",
-    "cypress-wait-until": "^2.0.1",
     "eslint": "^8.41.0",
     "eslint-config-next": "^12.1.6",
     "eslint-config-weseek": "^2.1.1",

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

@@ -50,7 +50,7 @@
     "axios": "^0.24.0",
     "bunyan": "^1.8.15",
     "hast-util-select": "^6.0.2",
-    "express": "^4.19.2",
+    "express": "^4.20.0",
     "mongoose": "^6.11.3",
     "swr": "^2.0.3",
     "universal-bunyan": "^0.9.2",

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

@@ -37,7 +37,7 @@
     "@growi/remark-growi-directive": "link:../remark-growi-directive",
     "@growi/ui": "link:../ui",
     "escape-string-regexp": "^4.0.0",
-    "express": "^4.19.2",
+    "express": "^4.20.0",
     "http-errors": "^2.0.0",
     "mongoose": "^6.11.3",
     "swr": "^2.2.2"

+ 2 - 2
packages/slack/src/interfaces/growi-event-processor.ts

@@ -1,7 +1,7 @@
 import type { WebClient } from '@slack/web-api';
 
-export interface GrowiEventProcessor {
+export interface GrowiEventProcessor<EVENT> {
   shouldHandleEvent(eventType: string): boolean;
 
-  processEvent(client: WebClient, event: any): Promise<void>;
+  processEvent(client: WebClient, event: EVENT): Promise<void>;
 }

+ 0 - 21
turbo.json

@@ -34,11 +34,6 @@
       "outputs": ["dist/**"],
       "outputLogs": "new-only"
     },
-    "@growi/slackbot-proxy#build": {
-      "dependsOn": ["@growi/slack#build"],
-      "outputs": ["dist/**"],
-      "outputLogs": "new-only"
-    },
     "build": {
       "outputs": ["dist/**"],
       "inputs": [
@@ -66,15 +61,6 @@
       "outputs": ["dist/**"],
       "outputLogs": "new-only"
     },
-    "@growi/slackbot-proxy#dev": {
-      "dependsOn": ["@growi/slack#dev"],
-      "cache": false,
-      "persistent": true
-    },
-    "@growi/slackbot-proxy#dev:ci": {
-      "dependsOn": ["@growi/slack#dev"],
-      "cache": false
-    },
     "dev": {
       "outputs": ["dist/**"],
       "inputs": [
@@ -108,16 +94,9 @@
     "@growi/ui#lint": {
       "dependsOn": ["@growi/core#dev"]
     },
-    "@growi/slackbot-proxy#lint": {
-      "dependsOn": ["@growi/slack#dev"]
-    },
     "lint": {
     },
 
-    "@growi/slackbot-proxy#test": {
-      "dependsOn": ["@growi/slack#dev"],
-      "outputLogs": "new-only"
-    },
     "@growi/preset-templates#test": {
       "dependsOn": ["@growi/pluginkit#dev"],
       "outputLogs": "new-only"

File diff suppressed because it is too large
+ 245 - 315
yarn.lock


Some files were not shown because too many files changed in this diff