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

Merge branch 'master' into feat/page-bulk-export

Futa Arai 1 год назад
Родитель
Сommit
db8b6207eb
92 измененных файлов с 1452 добавлено и 745 удалено
  1. 38 0
      .github/mergify.yml
  2. 3 2
      .github/workflows/auto-labeling.yml
  3. 3 0
      .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/draft-release.yml
  7. 1 1
      .github/workflows/release-rc-scheduled.yml
  8. 1 1
      .github/workflows/release-rc.yml
  9. 4 4
      .github/workflows/release-slackbot-proxy.yml
  10. 5 5
      .github/workflows/release.yml
  11. 1 1
      .github/workflows/reusable-app-prod.yml
  12. 0 24
      .mergify.yml
  13. 68 1
      CHANGELOG.md
  14. 2 2
      apps/app/docker/README.md
  15. 1 1
      apps/app/next-env.d.ts
  16. 13 13
      apps/app/package.json
  17. 6 0
      apps/app/playwright/40-admin/access-to-admin-page.spec.ts
  18. 1 1
      apps/app/public/static/locales/en_US/commons.json
  19. 1 1
      apps/app/public/static/locales/fr_FR/commons.json
  20. 1 1
      apps/app/public/static/locales/ja_JP/commons.json
  21. 1 1
      apps/app/public/static/locales/zh_CN/commons.json
  22. 10 1
      apps/app/src/client/components/Admin/G2GDataTransfer.tsx
  23. 3 3
      apps/app/src/client/components/Admin/G2GDataTransferExportForm.tsx
  24. 1 1
      apps/app/src/client/components/Admin/ImportData/GrowiArchive/ImportCollectionConfigurationModal.jsx
  25. 1 1
      apps/app/src/client/components/Admin/ImportData/GrowiArchive/ImportCollectionItem.jsx
  26. 2 1
      apps/app/src/client/components/Admin/ImportData/GrowiArchive/ImportForm.jsx
  27. 8 2
      apps/app/src/client/components/Admin/Notification/NotificationSetting.jsx
  28. 28 25
      apps/app/src/client/components/Bookmarks/BookmarkFolderItemControl.tsx
  29. 12 9
      apps/app/src/client/components/Bookmarks/BookmarkFolderMenu.tsx
  30. 48 30
      apps/app/src/client/components/Common/Dropdown/PageItemControl.spec.tsx
  31. 14 12
      apps/app/src/client/components/Common/Dropdown/PageItemControl.tsx
  32. 0 1
      apps/app/src/client/components/CustomNavigation/CustomNav.module.scss
  33. 19 3
      apps/app/src/client/components/CustomNavigation/CustomNav.tsx
  34. 9 1
      apps/app/src/client/components/DataTransferForm.tsx
  35. 4 0
      apps/app/src/client/components/DescendantsPageListModal.module.scss
  36. 70 0
      apps/app/src/client/components/DescendantsPageListModal.spec.tsx
  37. 25 9
      apps/app/src/client/components/DescendantsPageListModal.tsx
  38. 13 10
      apps/app/src/client/components/InAppNotification/InAppNotificationDropdown.tsx
  39. 5 1
      apps/app/src/client/components/Navbar/GrowiContextualSubNavigation.tsx
  40. 4 0
      apps/app/src/client/components/PageAccessoriesModal/PageAccessoriesModal.module.scss
  41. 24 9
      apps/app/src/client/components/PageAccessoriesModal/PageAccessoriesModal.tsx
  42. 6 2
      apps/app/src/client/components/PageComment.tsx
  43. 1 1
      apps/app/src/client/components/PageComment/ReplyComments.tsx
  44. 115 0
      apps/app/src/client/components/PageHeader/PageTitleHeader.spec.tsx
  45. 1 0
      apps/app/src/client/components/PageHeader/PageTitleHeader.tsx
  46. 20 0
      apps/app/src/components/Common/PagePathNav/PagePathNav.module.scss
  47. 10 4
      apps/app/src/components/Common/PagePathNav/PagePathNavLayout.tsx
  48. 1 0
      apps/app/src/interfaces/apiv3/page.ts
  49. 0 14
      apps/app/src/models/admin/growi-archive-import-option.js
  50. 18 0
      apps/app/src/models/admin/growi-archive-import-option.ts
  51. 0 0
      apps/app/src/models/admin/import-mode.ts
  52. 5 3
      apps/app/src/models/admin/import-option-for-pages.ts
  53. 0 13
      apps/app/src/models/admin/import-option-for-revisions.js
  54. 15 0
      apps/app/src/models/admin/import-option-for-revisions.ts
  55. 8 4
      apps/app/src/pages/[[...path]].page.tsx
  56. 10 2
      apps/app/src/pages/admin/data-transfer.page.tsx
  57. 2 1
      apps/app/src/pages/installer.page.tsx
  58. 1 0
      apps/app/src/server/models/page.ts
  59. 1 1
      apps/app/src/server/routes/apiv3/import.js
  60. 15 5
      apps/app/src/server/routes/apiv3/page/index.ts
  61. 13 3
      apps/app/src/server/routes/apiv3/page/update-page.ts
  62. 4 3
      apps/app/src/server/routes/comment.js
  63. 5 4
      apps/app/src/server/service/g2g-transfer.ts
  64. 2 1
      apps/app/src/server/service/import/import-settings.ts
  65. 5 7
      apps/app/src/server/service/import/import.ts
  66. 0 2
      apps/app/src/server/service/import/index.ts
  67. 1 1
      apps/app/src/server/service/import/overwrite-params/index.ts
  68. 20 2
      apps/app/src/stores-universal/context.tsx
  69. 14 2
      apps/app/src/stores/page.tsx
  70. 15 14
      apps/app/test-with-vite/download-mongo-binary/vitest.config.ts
  71. 3 2
      apps/app/turbo.json
  72. 0 19
      apps/app/vitest.config.components.ts
  73. 0 23
      apps/app/vitest.config.integ.ts
  74. 0 19
      apps/app/vitest.config.ts
  75. 65 0
      apps/app/vitest.workspace.mts
  76. 2 2
      apps/slackbot-proxy/package.json
  77. 3 1
      apps/slackbot-proxy/src/services/LinkSharedService.ts
  78. 37 0
      apps/slackbot-proxy/turbo.json
  79. 10 9
      package.json
  80. 5 0
      packages/core-styles/scss/bootstrap/theming/_dropdown-dark.scss
  81. 6 0
      packages/core-styles/scss/bootstrap/theming/_dropdown-light.scss
  82. 1 0
      packages/core-styles/scss/bootstrap/theming/apply-dark.scss
  83. 1 0
      packages/core-styles/scss/bootstrap/theming/apply-light.scss
  84. 81 0
      packages/pluginkit/src/v4/utils/template.spec.ts
  85. 10 5
      packages/pluginkit/vitest.config.ts
  86. 1 1
      packages/remark-attachment-refs/package.json
  87. 1 1
      packages/remark-growi-directive/src/mdast-util-growi-directive/complex-types.d.ts
  88. 1 1
      packages/remark-lsx/package.json
  89. 2 2
      packages/slack/src/interfaces/growi-event-processor.ts
  90. 0 21
      turbo.json
  91. 2 0
      vitest.workspace.mts
  92. 455 369
      yarn.lock

+ 38 - 0
.github/mergify.yml

@@ -0,0 +1,38 @@
+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'
+      - '#changes-requested-reviews-by = 0'
+      - '#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

+ 3 - 0
.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

+ 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/draft-release.yml

@@ -26,7 +26,7 @@ jobs:
       - uses: actions/checkout@v4
 
       - name: Retrieve information from package.json
-        uses: myrotvorets/info-from-package-json-action@1.2.0
+        uses: myrotvorets/info-from-package-json-action@2.0.1
         id: package-json
 
       - uses: release-drafter/release-drafter@v5

+ 1 - 1
.github/workflows/release-rc-scheduled.yml

@@ -23,7 +23,7 @@ jobs:
     - uses: actions/checkout@v4
 
     - name: Retrieve information from package.json
-      uses: myrotvorets/info-from-package-json-action@1.2.0
+      uses: myrotvorets/info-from-package-json-action@2.0.1
       id: package-json
 
     - name: Docker meta for docker.io

+ 1 - 1
.github/workflows/release-rc.yml

@@ -23,7 +23,7 @@ jobs:
     - uses: actions/checkout@v4
 
     - name: Retrieve information from package.json
-      uses: myrotvorets/info-from-package-json-action@1.2.0
+      uses: myrotvorets/info-from-package-json-action@2.0.1
       id: package-json
 
     - name: Docker meta for docker.io

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

@@ -17,7 +17,7 @@ jobs:
         ref: ${{ github.event.pull_request.base.ref }}
 
     - name: Retrieve information from package.json
-      uses: myrotvorets/info-from-package-json-action@1.2.0
+      uses: myrotvorets/info-from-package-json-action@2.0.1
       id: package-json
       with:
         workingDir: apps/slackbot-proxy
@@ -41,14 +41,14 @@ jobs:
         credentials_json: '${{ secrets.GCP_SA_KEY_SLACKBOT_PROXY }}'
 
     - name: Setup gcloud
-      uses: google-github-actions/setup-gcloud@v1
+      uses: google-github-actions/setup-gcloud@v2
 
     - name: Configure docker for gcloud
       run: |
         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
@@ -110,7 +110,7 @@ jobs:
         turbo run version --filter=@growi/slackbot-proxy -- --prerelease
 
     - name: Retrieve information from package.json
-      uses: myrotvorets/info-from-package-json-action@1.2.0
+      uses: myrotvorets/info-from-package-json-action@2.0.1
       id: package-json
       with:
         workingDir: apps/slackbot-proxy

+ 5 - 5
.github/workflows/release.yml

@@ -41,7 +41,7 @@ jobs:
         sh ./apps/app/bin/github-actions/update-readme.sh
 
     - name: Retrieve information from package.json
-      uses: myrotvorets/info-from-package-json-action@1.2.0
+      uses: myrotvorets/info-from-package-json-action@2.0.1
       id: package-json
 
     - name: Update Changelog
@@ -58,13 +58,13 @@ jobs:
         RELEASED_VERSION: ${{ steps.package-json.outputs.packageVersion }}
 
     - name: Commit, Tag and Push
-      uses: stefanzweifel/git-auto-commit-action@v4
+      uses: stefanzweifel/git-auto-commit-action@v5
       with:
         branch: ${{ github.event.pull_request.base.ref }}
         commit_message: Release v${{ steps.package-json.outputs.packageVersion }}
         tagging_message: v${{ steps.package-json.outputs.packageVersion }}
 
-    - uses: softprops/action-gh-release@v1
+    - uses: softprops/action-gh-release@v2
       with:
         body: ${{ github.event.pull_request.body }}
         tag_name: v${{ steps.package-json.outputs.packageVersion }}
@@ -87,7 +87,7 @@ jobs:
     - uses: actions/checkout@v4
 
     - name: Retrieve information from package.json
-      uses: myrotvorets/info-from-package-json-action@1.2.0
+      uses: myrotvorets/info-from-package-json-action@2.0.1
       id: package-json
 
     - name: Docker meta for docker.io
@@ -181,7 +181,7 @@ jobs:
         yarn upgrade --scope=@growi
 
     - name: Retrieve information from package.json
-      uses: myrotvorets/info-from-package-json-action@1.2.0
+      uses: myrotvorets/info-from-package-json-action@2.0.1
       id: package-json
 
     - name: Commit

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

@@ -199,7 +199,7 @@ jobs:
   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

+ 68 - 1
CHANGELOG.md

@@ -1,9 +1,76 @@
 # Changelog
 
-## [Unreleased](https://github.com/weseek/growi/compare/v7.0.17...HEAD)
+## [Unreleased](https://github.com/weseek/growi/compare/v7.0.20...HEAD)
 
 *Please do not manually update this file. We've automated the process.*
 
+## [v7.0.20](https://github.com/weseek/growi/compare/v7.0.19...v7.0.20) - 2024-09-25
+
+### 🚀 Improvement
+
+* imprv: The color of the dropdown list when it is activated (#9102) @WNomunomu
+* imprv: PageTitleHeader max-width (#9166) @yuki-takei
+* imprv: Documentation URL for g2gtransfer (#9157) @yuki-takei
+* imprv: Corrected wording on admin page (/admin/data-transfer) (#9106) @miya
+* imprv: Add hover-activated clipboard copy button with icon (#9095) @reiji-h
+
+### 🐛 Bug Fixes
+
+* fix: Make PageAccessoriesModal responsive (#9171) @moekumasaka
+* fix: PageControls unexpectedly move in response to opening and closing the sidebar (#9094) @WNomunomu
+* fix: Make CustomNavTab responsive (#9123) @moekumasaka
+
+### 🧰 Maintenance
+
+* ci(deps): bump rollup from 4.22.0 to 4.22.4 (#9160) @dependabot
+* ci(deps): bump google-github-actions/setup-gcloud from 1 to 2 (#9153) @dependabot
+* ci(deps): bump softprops/action-gh-release from 1 to 2 (#9152) @dependabot
+* support: Improve vitest environment (#9144) @yuki-takei
+* ci(deps): bump next from 14.1.3 to 14.2.13 (#9154) @dependabot
+* support: Upgrade @testing-library/react (#9141) @yuki-takei
+* support: Update logo image in README.md for the official docker image (#9139) @satof3
+* ci(deps-dev): bump vite from 5.2.9 to 5.2.14 (#9134) @dependabot
+* ci(deps): bump myrotvorets/info-from-package-json-action from 1.2.0 to 2.0.1 (#9129) @dependabot
+* ci(deps): bump stefanzweifel/git-auto-commit-action from 4 to 5 (#9128) @dependabot
+* ci(deps): bump nodemailer from 6.9.14 to 6.9.15 (#9075) @dependabot
+* ci(deps): bump docker/setup-buildx-action from 2 to 3 (#8207) @dependabot
+* ci(deps): bump jose from 4.11.4 to 4.15.9 (#9114) @dependabot
+* ci(deps): bump express from 4.19.2 to 4.20.0 (#9110) @dependabot
+* ci(deps): bump body-parser from 1.20.2 to 1.20.3 (#9109) @dependabot
+
+## [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

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

@@ -4,13 +4,13 @@ GROWI Official docker image
 
 [![Actions Status](https://github.com/weseek/growi/workflows/Release/badge.svg)](https://github.com/weseek/growi/actions) [![docker-pulls](https://img.shields.io/docker/pulls/weseek/growi.svg)](https://hub.docker.com/r/weseek/growi/) [![](https://images.microbadger.com/badges/image/weseek/growi.svg)](https://microbadger.com/images/weseek/growi)
 
-![GROWI-x-docker](https://user-images.githubusercontent.com/1638767/38307565-105956e2-384f-11e8-8534-b1128522d68d.png)
+![GROWI-x-docker](https://github.com/user-attachments/assets/1a82236d-5a85-4a2e-842a-971b4c1625e6)
 
 
 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.20`, `7.0`, `7`, `latest` (Dockerfile)](https://github.com/weseek/growi/blob/v7.0.20/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)

+ 1 - 1
apps/app/next-env.d.ts

@@ -2,4 +2,4 @@
 /// <reference types="next/image-types/global" />
 
 // NOTE: This file should not be edited
-// see https://nextjs.org/docs/basic-features/typescript for more information.
+// see https://nextjs.org/docs/pages/building-your-application/configuring/typescript for more information.

+ 13 - 13
apps/app/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@growi/app",
-  "version": "7.0.18-RC.0",
+  "version": "7.0.21-RC.0",
   "license": "MIT",
   "private": "true",
   "scripts": {
@@ -26,7 +26,7 @@
     "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",
     "//// 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\"",
@@ -35,12 +35,9 @@
     "prelint:swagger2openapi": "yarn openapi:v3",
     "test": "run-p test:*",
     "test:jest": "cross-env NODE_ENV=test TS_NODE_PROJECT=test/integration/tsconfig.json jest",
-    "test:vitest": "run-p vitest:run vitest:run:integ vitest:run:components",
+    "test:vitest": "vitest run --coverage",
     "jest:run": "cross-env NODE_ENV=test TS_NODE_PROJECT=test/integration/tsconfig.json jest --passWithNoTests -- ",
     "reg:run": "reg-suit run",
-    "vitest:run": "vitest run config src --coverage",
-    "vitest:run:integ": "vitest run -c vitest.config.integ.ts src --coverage",
-    "vitest:run:components": "vitest run -c vitest.config.components.ts src --coverage",
     "previtest:run:integ": "vitest run -c test-with-vite/download-mongo-binary/vitest.config.ts test-with-vite/download-mongo-binary",
     "//// misc": "",
     "console": "yarn repl",
@@ -49,7 +46,7 @@
     "openapi:v3": "yarn cross-env API_VERSION=3 yarn swagger-jsdoc -- \"src/server/routes/apiv3/**/*.js\" \"src/server/models/**/*.js\"",
     "openapi:v1": "yarn cross-env API_VERSION=1 yarn swagger-jsdoc -- \"src/server/*/*.js\" \"src/server/models/**/*.js\"",
     "ts-node": "node -r ts-node/register/transpile-only -r tsconfig-paths/register -r dotenv-flow/config",
-    "version": "yarn version --no-git-tag-version --preid=RC"
+    "version": "yarn version --no-git-tag-version --non-interactive --preid=RC"
   },
   "// comments for dependencies": {
     "@aws-skd/*": "fix version above 3.186.0 that is required by mongodb@4.16.0",
@@ -92,7 +89,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 +111,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",
@@ -144,14 +141,14 @@
     "multer": "~1.4.0",
     "multer-autoreap": "^1.0.3",
     "mustache": "^4.2.0",
-    "next": "^14.1.3",
+    "next": "^14.2.13",
     "next-dynamic-loading-props": "^0.1.1",
     "next-i18next": "^15.2.0",
     "next-superjson": "^0.0.4",
     "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",
@@ -230,7 +227,9 @@
     "@popperjs/core": "^2.11.8",
     "@swc-node/jest": "^1.8.1",
     "@swc/jest": "^0.2.36",
-    "@testing-library/react": "^14.1.2",
+    "@testing-library/dom": "^10.4.0",
+    "@testing-library/jest-dom": "^6.5.0",
+    "@testing-library/react": "^16.0.1",
     "@testing-library/user-event": "^14.5.2",
     "@types/archiver": "^6.0.2",
     "@types/express": "^4.17.21",
@@ -239,6 +238,7 @@
     "@types/react-input-autosize": "^2.2.4",
     "@types/react-scroll": "^1.8.4",
     "@types/react-stickynode": "^4.0.3",
+    "@types/testing-library__dom": "^7.5.0",
     "@types/throttle-debounce": "^5.0.1",
     "@types/unzip-stream": "^0.3.4",
     "@types/url-join": "^4.0.2",
@@ -252,7 +252,7 @@
     "eslint-plugin-regex": "^1.8.0",
     "fslightbox-react": "^1.7.6",
     "handsontable": "=6.2.2",
-    "happy-dom": "^13.2.0",
+    "happy-dom": "^15.7.4",
     "i18next-chained-backend": "^4.6.2",
     "i18next-hmr": "^3.0.4",
     "i18next-http-backend": "^2.5.0",

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

+ 1 - 1
apps/app/public/static/locales/en_US/commons.json

@@ -157,6 +157,6 @@
     "publish_transfer_key": "Publish transfer key",
     "transfer_key_limit": "Transfer keys are valid for 1 hour after issuance.",
     "once_transfer_key_used": "Once the transfer key is used for transfer, it cannot be used for any other transfer.",
-    "transfer_to_growi_cloud": "If you wish to transfer to GROWI.cloud, please click here."
+    "transfer_to_growi_cloud": "For more details, please click <a href='{{documentationUrl}}/ja/admin-guide/management-cookbook/g2g-transfer.html'>here.</a>"
   }
 }

+ 1 - 1
apps/app/public/static/locales/fr_FR/commons.json

@@ -157,6 +157,6 @@
     "publish_transfer_key": "Publier la clé de transfert",
     "transfer_key_limit": "Les clés de transfert sont valides durant une heure.",
     "once_transfer_key_used": "Les clés de transfert sont à usage unique.",
-    "transfer_to_growi_cloud": "Si vous souhaitez transférer depuis GROWI.cloud, cliquez ici."
+    "transfer_to_growi_cloud": "Pour plus de détails, veuillez cliquer <a href='{{documentationUrl}}/ja/admin-guide/management-cookbook/g2g-transfer.html'>ici.</a>"
   }
 }

+ 1 - 1
apps/app/public/static/locales/ja_JP/commons.json

@@ -159,6 +159,6 @@
     "publish_transfer_key": "移行キーを発行する",
     "transfer_key_limit": "※ 移行キーの有効期限は発行から1時間となります。",
     "once_transfer_key_used": "※ 移行キーは一度移行に利用するとそれ以降はご利用いただけなくなります。",
-    "transfer_to_growi_cloud": "※ GROWI.cloud への移行を実施する場合はこちらをご確認ください。"
+    "transfer_to_growi_cloud": "※ 詳しくは <a href='{{documentationUrl}}/ja/admin-guide/management-cookbook/g2g-transfer.html'> GROWI お引越し機能</a>をご確認ください。"
   }
 }

+ 1 - 1
apps/app/public/static/locales/zh_CN/commons.json

@@ -160,6 +160,6 @@
     "publish_transfer_key": "发布迁移密钥",
     "transfer_key_limit": "迁移密钥在签发后一小时内有效。",
     "once_transfer_key_used": "一旦迁移密钥被用于迁移,它将不再可用于进一步的迁移。",
-    "transfer_to_growi_cloud": "如果您希望迁移到GROWI.cloud,请点击这里。"
+    "transfer_to_growi_cloud": "有关更多详情,请点击<a href='https://{{documentationUrl}}/ja/admin-guide/management-cookbook/g2g-transfer.html'>此处</a>。"
   }
 }

+ 10 - 1
apps/app/src/client/components/Admin/G2GDataTransfer.tsx

@@ -8,6 +8,7 @@ import { useGenerateTransferKey } from '~/client/services/g2g-transfer';
 import { apiv3Get, apiv3Post } from '~/client/util/apiv3-client';
 import { toastError, toastSuccess } from '~/client/util/toastr';
 import { G2G_PROGRESS_STATUS, type G2GProgress } from '~/interfaces/g2g-transfer';
+import { useGrowiDocumentationUrl } from '~/stores-universal/context';
 import { useAdminSocket } from '~/stores/socket-io';
 
 import CustomCopyToClipBoard from '../Common/CustomCopyToClipBoard';
@@ -123,6 +124,8 @@ const G2GDataTransfer = (): JSX.Element => {
     }
   }, [setTransferring, startTransferKey, selectedCollections, optionsMap]);
 
+  const { data: documentationUrl } = useGrowiDocumentationUrl();
+
   // File upload
   // const onChangeFileUploadTypeHandler = useCallback((e: ChangeEvent, type: string) => {
   //   setFileUploadType(type);
@@ -275,7 +278,13 @@ const G2GDataTransfer = (): JSX.Element => {
       <div className="alert alert-warning mt-4">
         <p className="mb-1">{t('commons:g2g_data_transfer.transfer_key_limit')}</p>
         <p className="mb-1">{t('commons:g2g_data_transfer.once_transfer_key_used')}</p>
-        <p className="mb-0">{t('commons:g2g_data_transfer.transfer_to_growi_cloud')}</p>
+        <p
+          className="mb-0"
+          // eslint-disable-next-line react/no-danger
+          dangerouslySetInnerHTML={{
+            __html: t('commons:g2g_data_transfer.transfer_to_growi_cloud', { documentationUrl }),
+          }}
+        />
       </div>
     </div>
   );

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

+ 8 - 2
apps/app/src/client/components/Admin/Notification/NotificationSetting.jsx

@@ -14,7 +14,7 @@ import { toastError } from '~/client/util/toastr';
 import { toArrayIfNot } from '~/utils/array-utils';
 import loggerFactory from '~/utils/logger';
 
-import { CustomNavTab } from '../../CustomNavigation/CustomNav';
+import CustomNav from '../../CustomNavigation/CustomNav';
 import { withUnstatedContainers } from '../../UnstatedUtils';
 
 
@@ -155,7 +155,13 @@ function NotificationSetting(props) {
 
       <h2 className="admin-setting-header mt-5">{t('notification_settings.notification_settings')}</h2>
 
-      <CustomNavTab activeTab={activeTab} navTabMapping={navTabMapping} onNavSelected={switchActiveTab} hideBorderBottom />
+      <CustomNav
+        activeTab={activeTab}
+        navTabMapping={navTabMapping}
+        onNavSelected={switchActiveTab}
+        hideBorderBottom
+        breakpointToSwitchDropdownDown="md"
+      />
 
       <TabContent activeTab={activeTab} className="p-5">
         <TabPane tabId="user_trigger_notification">

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

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

@@ -1,37 +1,55 @@
-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, screen, within,
+} from '@testing-library/dom';
+import { render } 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>

+ 0 - 1
apps/app/src/client/components/CustomNavigation/CustomNav.module.scss

@@ -14,5 +14,4 @@
     border-bottom: 3px solid;
     transition: 0.3s ease-in-out;
   }
-
 }

+ 19 - 3
apps/app/src/client/components/CustomNavigation/CustomNav.tsx

@@ -42,26 +42,42 @@ export const CustomNavDropdown = (props: CustomNavDropdownProps): JSX.Element =>
 
   const { Icon, i18n } = navTabMapping[activeTab];
 
+  const [isDropdownOpen, setIsDropdownOpen] = useState(false);
+
+  const dropdownButtonRef = useRef<HTMLButtonElement>(null);
+
+  const toggleDropdown = () => {
+    setIsDropdownOpen(prev => !prev);
+  };
+
   const menuItemClickHandler = useCallback((key) => {
     if (onNavSelected != null) {
       onNavSelected(key);
     }
+    // Manually close the dropdown
+    setIsDropdownOpen(false);
+    if (dropdownButtonRef.current) {
+      dropdownButtonRef.current.classList.remove('show');
+    }
   }, [onNavSelected]);
 
   return (
     <div className="btn-group">
       <button
+        ref={dropdownButtonRef}
         className="btn btn-outline-primary btn-lg dropdown-toggle text-end"
         type="button"
         data-bs-toggle="dropdown"
         aria-haspopup="true"
-        aria-expanded="false"
+        aria-expanded={isDropdownOpen}
+        onClick={toggleDropdown}
+        data-testid="custom-nav-dropdown"
       >
         <span className="float-start">
           { Icon != null && <Icon /> } {i18n}
         </span>
       </button>
-      <div className="dropdown-menu dropdown-menu-right">
+      <div className={`dropdown-menu dropdown-menu-right w-100 ${isDropdownOpen ? 'show' : ''} ${styles['dropdown-menu']}`}>
         {Object.entries(navTabMapping).map(([key, value]) => {
 
           const isActive = activeTab === key;
@@ -167,7 +183,7 @@ export const CustomNavTab = (props: CustomNavTabProps): JSX.Element => {
   }
 
   return (
-    <div className={`grw-custom-nav-tab ${styles['grw-custom-nav-tab']}`}>
+    <div data-testid="custom-nav-tab" className={`grw-custom-nav-tab ${styles['grw-custom-nav-tab']}`}>
       <div ref={navContainerRef} className="d-flex justify-content-between">
         <Nav className="nav-title">
           {Object.entries(navTabMapping).map(([key, value]) => {

+ 9 - 1
apps/app/src/client/components/DataTransferForm.tsx

@@ -3,12 +3,14 @@ import React from 'react';
 import { useTranslation } from 'next-i18next';
 
 import { useGenerateTransferKey } from '~/client/services/g2g-transfer';
+import { useGrowiDocumentationUrl } from '~/stores-universal/context';
 
 import CustomCopyToClipBoard from './Common/CustomCopyToClipBoard';
 
 const DataTransferForm = (): JSX.Element => {
   const { t } = useTranslation('commons');
   const { transferKey, generateTransferKey } = useGenerateTransferKey();
+  const { data: documentationUrl } = useGrowiDocumentationUrl();
 
   return (
     <div data-testid="installerForm" className="py-3 px-4">
@@ -33,7 +35,13 @@ const DataTransferForm = (): JSX.Element => {
       <div className="alert alert-warning mt-4">
         <p className="mb-1">{t('g2g_data_transfer.transfer_key_limit')}</p>
         <p className="mb-1">{t('g2g_data_transfer.once_transfer_key_used')}</p>
-        <p className="mb-0">{t('g2g_data_transfer.transfer_to_growi_cloud')}</p>
+        <p
+          className="mb-0"
+          // eslint-disable-next-line react/no-danger
+          dangerouslySetInnerHTML={{
+            __html: t('g2g_data_transfer.transfer_to_growi_cloud', { documentationUrl }),
+          }}
+        />
       </div>
     </div>
   );

+ 4 - 0
apps/app/src/client/components/DescendantsPageListModal.module.scss

@@ -9,6 +9,10 @@
     padding: 25px 30px;
   }
 
+  .grw-tab-content-style-md-down {
+    padding-top: 25px;
+  }
+
   .grw-modal-body-style {
     max-height: calc(100vh - 100px);
   }

+ 70 - 0
apps/app/src/client/components/DescendantsPageListModal.spec.tsx

@@ -0,0 +1,70 @@
+import { render, screen, fireEvent } from '@testing-library/react';
+
+import { DescendantsPageListModal } from './DescendantsPageListModal';
+
+const mockClose = vi.hoisted(() => vi.fn());
+const useIsDeviceLargerThanLg = vi.hoisted(() => vi.fn().mockReturnValue({ data: true }));
+
+vi.mock('next/router', () => ({
+  useRouter: () => ({
+    events: {
+      on: vi.fn(),
+      off: vi.fn(),
+    },
+  }),
+}));
+
+vi.mock('~/stores/modal', () => ({
+  useDescendantsPageListModal: vi.fn().mockReturnValue({
+    data: { isOpened: true },
+    close: mockClose,
+  }),
+}));
+
+vi.mock('~/stores/ui', () => ({
+  useIsDeviceLargerThanLg,
+}));
+
+describe('DescendantsPageListModal.tsx', () => {
+
+  it('should render the modal when isOpened is true', () => {
+    render(<DescendantsPageListModal />);
+    expect(screen.getByTestId('descendants-page-list-modal')).not.toBeNull();
+  });
+
+  it('should call close function when close button is clicked', () => {
+    render(<DescendantsPageListModal />);
+    const closeButton = screen.getByLabelText('Close');
+    fireEvent.click(closeButton);
+    expect(mockClose).toHaveBeenCalled();
+  });
+
+  describe('when device is larger than lg', () => {
+
+    it('should render CustomNavTab', () => {
+      render(<DescendantsPageListModal />);
+      expect(screen.getByTestId('custom-nav-tab')).not.toBeNull();
+    });
+
+    it('should not render CustomNavDropdown', () => {
+      render(<DescendantsPageListModal />);
+      expect(screen.queryByTestId('custom-nav-dropdown')).toBeNull();
+    });
+  });
+
+  describe('when device is smaller than lg', () => {
+    beforeEach(() => {
+      useIsDeviceLargerThanLg.mockReturnValue({ data: false });
+    });
+
+    it('should render CustomNavDropdown on devices smaller than lg', () => {
+      render(<DescendantsPageListModal />);
+      expect(screen.getByTestId('custom-nav-dropdown')).not.toBeNull();
+    });
+
+    it('should not render CustomNavTab', () => {
+      render(<DescendantsPageListModal />);
+      expect(screen.queryByTestId('custom-nav-tab')).toBeNull();
+    });
+  });
+});

+ 25 - 9
apps/app/src/client/components/DescendantsPageListModal.tsx

@@ -10,8 +10,9 @@ import {
 
 import { useIsSharedUser } from '~/stores-universal/context';
 import { useDescendantsPageListModal } from '~/stores/modal';
+import { useIsDeviceLargerThanLg } from '~/stores/ui';
 
-import { CustomNavTab } from './CustomNavigation/CustomNav';
+import { CustomNavDropdown, CustomNavTab } from './CustomNavigation/CustomNav';
 import CustomTabContent from './CustomNavigation/CustomTabContent';
 import type { DescendantsPageListProps } from './DescendantsPageList';
 import ExpandOrContractButton from './ExpandOrContractButton';
@@ -34,6 +35,8 @@ export const DescendantsPageListModal = (): JSX.Element => {
 
   const { events } = useRouter();
 
+  const { data: isDeviceLargerThanLg } = useIsDeviceLargerThanLg();
+
   useEffect(() => {
     events.on('routeChangeStart', close);
     return () => {
@@ -93,17 +96,30 @@ export const DescendantsPageListModal = (): JSX.Element => {
       data-testid="descendants-page-list-modal"
       className={`grw-descendants-page-list-modal ${styles['grw-descendants-page-list-modal']} ${isWindowExpanded ? 'grw-modal-expanded' : ''} `}
     >
-      <ModalHeader className="p-0" toggle={close} close={buttons}>
-        <CustomNavTab
+      <ModalHeader className={isDeviceLargerThanLg ? 'p-0' : ''} toggle={close} close={buttons}>
+        {isDeviceLargerThanLg && (
+          <CustomNavTab
+            activeTab={activeTab}
+            navTabMapping={navTabMapping}
+            breakpointToHideInactiveTabsDown="md"
+            onNavSelected={v => setActiveTab(v)}
+            hideBorderBottom
+          />
+        )}
+      </ModalHeader>
+      <ModalBody>
+        {!isDeviceLargerThanLg && (
+          <CustomNavDropdown
+            activeTab={activeTab}
+            navTabMapping={navTabMapping}
+            onNavSelected={v => setActiveTab(v)}
+          />
+        )}
+        <CustomTabContent
           activeTab={activeTab}
           navTabMapping={navTabMapping}
-          breakpointToHideInactiveTabsDown="md"
-          onNavSelected={v => setActiveTab(v)}
-          hideBorderBottom
+          additionalClassNames={!isDeviceLargerThanLg ? ['grw-tab-content-style-md-down'] : undefined}
         />
-      </ModalHeader>
-      <ModalBody>
-        <CustomTabContent activeTab={activeTab} navTabMapping={navTabMapping} />
       </ModalBody>
     </Modal>
   );

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

+ 5 - 1
apps/app/src/client/components/Navbar/GrowiContextualSubNavigation.tsx

@@ -374,7 +374,11 @@ const GrowiContextualSubNavigation = (props: GrowiContextualSubNavigationProps):
     <>
       <GroundGlassBar className="py-4 d-block d-md-none d-print-none border-bottom" />
 
-      <Sticky className="z-1" onStateChange={status => setStickyActive(status.status === Sticky.STATUS_FIXED)}>
+      <Sticky
+        className="z-1"
+        onStateChange={status => setStickyActive(status.status === Sticky.STATUS_FIXED)}
+        innerActiveClass="w-100 end-0"
+      >
         <GroundGlassBar>
 
           <nav

+ 4 - 0
apps/app/src/client/components/PageAccessoriesModal/PageAccessoriesModal.module.scss

@@ -9,6 +9,10 @@
     padding: 25px 30px;
   }
 
+  .grw-tab-content-style-md-down {
+    padding-top: 25px;
+  }
+
   .grw-modal-body-style {
     max-height: calc(100vh - 100px);
   }

+ 24 - 9
apps/app/src/client/components/PageAccessoriesModal/PageAccessoriesModal.tsx

@@ -10,8 +10,9 @@ import {
   useDisableLinkSharing, useIsGuestUser, useIsReadOnlyUser, useIsSharedUser,
 } from '~/stores-universal/context';
 import { usePageAccessoriesModal, PageAccessoriesModalContents } from '~/stores/modal';
+import { useIsDeviceLargerThanLg } from '~/stores/ui';
 
-import { CustomNavTab } from '../CustomNavigation/CustomNav';
+import { CustomNavDropdown, CustomNavTab } from '../CustomNavigation/CustomNav';
 import CustomTabContent from '../CustomNavigation/CustomTabContent';
 import ExpandOrContractButton from '../ExpandOrContractButton';
 
@@ -35,6 +36,7 @@ export const PageAccessoriesModal = (): JSX.Element => {
   const { data: isGuestUser } = useIsGuestUser();
   const { data: isReadOnlyUser } = useIsReadOnlyUser();
   const { data: isLinkSharingDisabled } = useDisableLinkSharing();
+  const { data: isDeviceLargerThanLg } = useIsDeviceLargerThanLg();
 
   const { data: status, close, selectContents } = usePageAccessoriesModal();
 
@@ -93,17 +95,30 @@ export const PageAccessoriesModal = (): JSX.Element => {
       data-testid="page-accessories-modal"
       className={`grw-page-accessories-modal ${styles['grw-page-accessories-modal']} ${isWindowExpanded ? 'grw-modal-expanded' : ''} `}
     >
-      <ModalHeader className="p-0" toggle={close} close={buttons}>
-        <CustomNavTab
+      <ModalHeader className={isDeviceLargerThanLg ? 'p-0' : ''} toggle={close} close={buttons}>
+        {isDeviceLargerThanLg && (
+          <CustomNavTab
+            activeTab={status.activatedContents}
+            navTabMapping={navTabMapping}
+            breakpointToHideInactiveTabsDown="md"
+            onNavSelected={selectContents}
+            hideBorderBottom
+          />
+        )}
+      </ModalHeader>
+      <ModalBody className="overflow-auto grw-modal-body-style">
+        {!isDeviceLargerThanLg && (
+          <CustomNavDropdown
+            activeTab={status.activatedContents}
+            navTabMapping={navTabMapping}
+            onNavSelected={selectContents}
+          />
+        )}
+        <CustomTabContent
           activeTab={status.activatedContents}
           navTabMapping={navTabMapping}
-          breakpointToHideInactiveTabsDown="md"
-          onNavSelected={selectContents}
-          hideBorderBottom
+          additionalClassNames={!isDeviceLargerThanLg ? ['grw-tab-content-style-md-down'] : undefined}
         />
-      </ModalHeader>
-      <ModalBody className="overflow-auto grw-modal-body-style">
-        <CustomTabContent activeTab={status.activatedContents} navTabMapping={navTabMapping} />
       </ModalBody>
     </Modal>
   );

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

+ 115 - 0
apps/app/src/client/components/PageHeader/PageTitleHeader.spec.tsx

@@ -0,0 +1,115 @@
+import '@testing-library/jest-dom/vitest';
+
+import { faker } from '@faker-js/faker';
+import type { IPagePopulatedToShowRevision } from '@growi/core';
+import {
+  fireEvent, render, screen, waitFor,
+} from '@testing-library/react';
+import { mock } from 'vitest-mock-extended';
+
+
+import { EditorMode } from '~/stores-universal/ui';
+
+import { PageTitleHeader } from './PageTitleHeader';
+
+const mocks = vi.hoisted(() => ({
+  useIsUntitledPageMock: vi.fn(),
+  useEditorModeMock: vi.fn(() => ({ data: EditorMode.Editor })),
+}));
+
+vi.mock('~/stores/ui', () => ({
+  useIsUntitledPage: mocks.useIsUntitledPageMock,
+}));
+vi.mock('~/stores-universal/ui', async importOriginal => ({
+  ...await importOriginal(),
+  useEditorMode: mocks.useEditorModeMock,
+}));
+
+describe('PageTitleHeader Component with untitled page', () => {
+
+  beforeAll(() => {
+    mocks.useIsUntitledPageMock.mockImplementation(() => ({ data: true }));
+  });
+
+  it('should render the textbox correctly', async() => {
+    // arrange
+    const currentPage = mock<IPagePopulatedToShowRevision>({
+      _id: faker.database.mongodbObjectId(),
+      path: '/path/to/page/Untitled-1',
+    });
+
+    // act
+    render(<PageTitleHeader currentPage={currentPage} />);
+
+    // assert
+    // header should be rendered
+    const headerElement = screen.getByText('Untitled-1');
+    const inputElement = screen.getByRole('textbox');
+    const inputElementByPlaceholder = screen.getByPlaceholderText('Input page name');
+    await waitFor(() => {
+      expect(inputElement).toBeInTheDocument();
+      expect(inputElement).toStrictEqual(inputElementByPlaceholder);
+      expect(inputElement).toHaveValue(''); // empty
+      expect(headerElement).toHaveClass('invisible');
+    });
+  });
+
+});
+
+
+describe('PageTitleHeader Component', () => {
+
+  beforeAll(() => {
+    mocks.useIsUntitledPageMock.mockImplementation(() => ({ data: false }));
+  });
+
+  it('should render the title correctly', async() => {
+    // arrange
+    const pageTitle = faker.lorem.slug();
+    const currentPage = mock<IPagePopulatedToShowRevision>({
+      _id: faker.database.mongodbObjectId(),
+      path: `/path/to/page/${pageTitle}`,
+    });
+
+    // act
+    render(<PageTitleHeader currentPage={currentPage} />);
+
+    // assert
+    // header should be rendered
+    const headerElement = screen.getByText(pageTitle);
+    await waitFor(() => {
+      expect(headerElement).toBeInTheDocument();
+      expect(headerElement).not.toHaveClass('invisible');
+    });
+    // textbox should not be rendered
+    const inputElement = screen.queryByRole('textbox');
+    expect(inputElement).not.toBeInTheDocument();
+  });
+
+  it('should render text input after clicking', async() => {
+    // arrange
+    const pageTitle = faker.lorem.slug();
+    const currentPage = mock<IPagePopulatedToShowRevision>({
+      _id: faker.database.mongodbObjectId(),
+      path: `/path/to/page/${pageTitle}`,
+    });
+
+    // act
+    render(<PageTitleHeader currentPage={currentPage} />);
+
+    const headerElement = screen.getByText(pageTitle);
+    await waitFor(() => expect(headerElement).toBeInTheDocument());
+
+    // click
+    fireEvent.click(headerElement);
+
+    // assert
+    const inputElement = screen.getByRole('textbox');
+    await waitFor(() => {
+      expect(inputElement).toBeInTheDocument();
+      expect(inputElement).toHaveValue(pageTitle);
+      expect(headerElement).toHaveClass('invisible');
+    });
+  });
+
+});

+ 1 - 0
apps/app/src/client/components/PageHeader/PageTitleHeader.tsx

@@ -129,6 +129,7 @@ export const PageTitleHeader = (props: Props): JSX.Element => {
             ${isRenameInputShown ? 'invisible' : ''} text-truncate
             ${isMovable ? 'border border-2 rounded-2' : ''} ${borderColorClass}
           `}
+          style={{ maxWidth: inputMaxWidth }}
           onClick={onClickPageTitle}
         >
           {pageTitle}

+ 20 - 0
apps/app/src/components/Common/PagePathNav/PagePathNav.module.scss

@@ -1,6 +1,26 @@
 @use '@growi/core-styles/scss/bootstrap/init' as bs;
 @use '@growi/ui/scss/atoms/btn-muted';
 
+
+.grw-page-path-nav-layout :global {
+  .grw-page-path-nav-copydropdown {
+    display: none;
+    @include bs.media-breakpoint-down(md) {
+      display: block;
+    }
+  }
+}
+
+.grw-page-path-nav-layout {
+  &:global {
+    &:hover {
+      .grw-page-path-nav-copydropdown {
+        display: block;
+      }
+    }
+  }
+}
+
 .grw-page-path-nav :global {
   .btn-copy {
     @include btn-muted.colorize(bs.$orange);

+ 10 - 4
apps/app/src/components/Common/PagePathNav/PagePathNavLayout.tsx

@@ -6,6 +6,7 @@ import { useIsNotFound } from '~/stores/page';
 
 import styles from './PagePathNav.module.scss';
 
+const moduleClass = styles['grw-page-path-nav-layout'] ?? '';
 
 export type PagePathNavLayoutProps = {
   className?: string,
@@ -40,7 +41,10 @@ export const PagePathNavLayout = (props: Props): JSX.Element => {
   const copyDropdownId = `copydropdown-${pageId}`;
 
   return (
-    <div className={className} style={{ maxWidth }}>
+    <div
+      className={`${className} ${moduleClass}`}
+      style={{ maxWidth }}
+    >
       <span className={`${formerLinkClassName ?? ''} ${styles['grw-former-link']}`}>{formerLink}</span>
       <div className="d-flex align-items-center">
         <h1 className={`m-0 ${latterLinkClassName}`}>
@@ -51,9 +55,11 @@ export const PagePathNavLayout = (props: Props): JSX.Element => {
             { isWipPage && (
               <span className="badge text-bg-secondary ms-1 me-1">WIP</span>
             )}
-            <CopyDropdown pageId={pageId} pagePath={pagePath} dropdownToggleId={copyDropdownId} dropdownToggleClassName="p-2">
-              <span className="material-symbols-outlined">content_paste</span>
-            </CopyDropdown>
+            <span className=" grw-page-path-nav-copydropdown">
+              <CopyDropdown pageId={pageId} pagePath={pagePath} dropdownToggleId={copyDropdownId} dropdownToggleClassName="p-2">
+                <span className="material-symbols-outlined">content_paste</span>
+              </CopyDropdown>
+            </span>
           </div>
         ) }
       </div>

+ 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

@@ -248,6 +248,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);
@@ -280,7 +281,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);
@@ -291,7 +292,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(() => {
@@ -311,8 +315,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);

+ 10 - 2
apps/app/src/pages/admin/data-transfer.page.tsx

@@ -9,8 +9,9 @@ import Head from 'next/head';
 import type { Container } from 'unstated';
 import { Provider } from 'unstated';
 
+import type { CrowiRequest } from '~/interfaces/crowi-request';
 import type { CommonProps } from '~/pages/utils/commons';
-import { useCurrentUser } from '~/stores-universal/context';
+import { useCurrentUser, useGrowiCloudUri } from '~/stores-universal/context';
 
 import { retrieveServerSideProps } from '../../utils/admin-page-util';
 
@@ -25,6 +26,7 @@ type Props = CommonProps;
 const DataTransferPage: NextPage<Props> = (props) => {
   const { t } = useTranslation('commons');
   useCurrentUser(props.currentUser ?? null);
+  useGrowiCloudUri(props.growiCloudUri);
 
   const title = t('g2g_data_transfer.data_transfer');
 
@@ -54,9 +56,15 @@ const DataTransferPage: NextPage<Props> = (props) => {
   );
 };
 
+const injectServerConfigurations = async(context: GetServerSidePropsContext, props: Props): Promise<void> => {
+  const req: CrowiRequest = context.req as CrowiRequest;
+  const { crowi } = req;
+
+  props.growiCloudUri = await crowi.configManager.getConfig('crowi', 'app:growiCloudUri');
+};
 
 export const getServerSideProps: GetServerSideProps = async(context: GetServerSidePropsContext) => {
-  const props = await retrieveServerSideProps(context);
+  const props = await retrieveServerSideProps(context, injectServerConfigurations);
   return props;
 };
 

+ 2 - 1
apps/app/src/pages/installer.page.tsx

@@ -11,7 +11,7 @@ import Head from 'next/head';
 import { NoLoginLayout } from '~/components/Layout/NoLoginLayout';
 import type { CrowiRequest } from '~/interfaces/crowi-request';
 import {
-  useCsrfToken, useAppTitle, useSiteUrl, useConfidential,
+  useCsrfToken, useAppTitle, useSiteUrl, useConfidential, useGrowiCloudUri,
 } from '~/stores-universal/context';
 
 import type { CommonProps } from './utils/commons';
@@ -57,6 +57,7 @@ const InstallerPage: NextPage<Props> = (props: Props) => {
   useSiteUrl(props.siteUrl);
   useConfidential(props.confidential);
   useCsrfToken(props.csrfToken);
+  useGrowiCloudUri(props.growiCloudUri);
 
   const title = generateCustomTitle(props, t('installer.title'));
   const classNames: string[] = [];

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

+ 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 { exportService } from '~/server/service/export';
@@ -203,6 +204,7 @@ module.exports = (crowi) => {
       query('pageId').optional().isString(),
       query('path').optional().isString(),
       query('findAll').optional().isBoolean(),
+      query('shareLinkId').optional().isMongoId(),
     ],
     likes: [
       body('pageId').isString(),
@@ -285,19 +287,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';
@@ -612,12 +613,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';

+ 20 - 2
apps/app/src/stores-universal/context.tsx

@@ -1,5 +1,3 @@
-import { useCallback, useEffect } from 'react';
-
 import type EventEmitter from 'events';
 
 import { AcceptedUploadFileType } from '@growi/core';
@@ -289,3 +287,23 @@ export const useAcceptedUploadFileType = (): SWRResponse<AcceptedUploadFileType,
     },
   );
 };
+
+// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
+export const useGrowiDocumentationUrl = () => {
+  const { data: growiCloudUri } = useGrowiCloudUri();
+
+  return useSWR(
+    ['documentationUrl', growiCloudUri],
+    ([, growiCloudUri]) => {
+      const url = growiCloudUri != null
+        ? new URL('/help', growiCloudUri)
+        : new URL('https://docs.growi.org');
+      return url.toString();
+    },
+    {
+      fallbackData: 'https://docs.growi.org',
+      revalidateOnFocus: false,
+      revalidateOnReconnect: false,
+    },
+  );
+};

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

+ 15 - 14
apps/app/test-with-vite/download-mongo-binary/vitest.config.ts

@@ -1,15 +1,16 @@
-import { defineConfig, mergeConfig } from 'vitest/config';
+import tsconfigPaths from 'vite-tsconfig-paths';
+import { defineConfig } from 'vitest/config';
 
-import configShared from '../../vitest.config';
-
-export default mergeConfig(
-  configShared,
-  defineConfig({
-    test: {
-      hookTimeout: 60000, // increased for downloading MongoDB binary file
-      setupFiles: [
-        './test-with-vite/setup/mongoms.ts',
-      ],
-    },
-  }),
-);
+export default defineConfig({
+  plugins: [
+    tsconfigPaths(),
+  ],
+  test: {
+    clearMocks: true,
+    globals: true,
+    hookTimeout: 60000, // increased for downloading MongoDB binary file
+    setupFiles: [
+      './test-with-vite/setup/mongoms.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
     },
 

+ 0 - 19
apps/app/vitest.config.components.ts

@@ -1,19 +0,0 @@
-import react from '@vitejs/plugin-react';
-import tsconfigPaths from 'vite-tsconfig-paths';
-import { defineConfig } from 'vitest/config';
-
-export default defineConfig({
-  plugins: [
-    react(), tsconfigPaths(),
-  ],
-  test: {
-    globals: true,
-    environment: 'happy-dom',
-    include: [
-      '**/*.spec.{tsx,jsx}',
-    ],
-    coverage: {
-      reportsDirectory: './coverage/components',
-    },
-  },
-});

+ 0 - 23
apps/app/vitest.config.integ.ts

@@ -1,23 +0,0 @@
-import { defineConfig, mergeConfig } from 'vitest/config';
-
-import configShared from './vitest.config';
-
-export default mergeConfig(
-  configShared,
-  defineConfig({
-    test: {
-      include: [
-        '**/*.integ.ts',
-      ],
-      setupFiles: [
-        './test-with-vite/setup/mongoms.ts',
-      ],
-      coverage: {
-        reportsDirectory: './coverage/integ',
-        exclude: [
-          '**/*{.,-}integ.ts',
-        ],
-      },
-    },
-  }),
-);

+ 0 - 19
apps/app/vitest.config.ts

@@ -1,19 +0,0 @@
-import tsconfigPaths from 'vite-tsconfig-paths';
-import { defineConfig } from 'vitest/config';
-
-export default defineConfig({
-  plugins: [
-    tsconfigPaths(),
-  ],
-  test: {
-    environment: 'node',
-    exclude: [
-      '**/test/**', '**/*.spec.{tsx,jsx}',
-    ],
-    clearMocks: true,
-    globals: true,
-    coverage: {
-      reportsDirectory: './coverage/unit',
-    },
-  },
-});

+ 65 - 0
apps/app/vitest.workspace.mts

@@ -0,0 +1,65 @@
+import react from '@vitejs/plugin-react';
+import tsconfigPaths from 'vite-tsconfig-paths';
+import {
+  defineConfig, defineWorkspace, mergeConfig,
+} from 'vitest/config';
+
+const configShared = defineConfig({
+  plugins: [
+    tsconfigPaths(),
+  ],
+  test: {
+    clearMocks: true,
+    globals: true,
+    exclude: [
+      'test/**',
+      'test-with-vite/**',
+      'playwright/**',
+    ]
+  },
+});
+
+export default defineWorkspace([
+
+  // unit test
+  mergeConfig(
+    configShared,
+    {
+      test: {
+        name: 'app-unit',
+        environment: 'node',
+        include: ['**/*.spec.{ts,js}'],
+      },
+    },
+  ),
+
+  // integration test
+  mergeConfig(
+    configShared,
+    {
+      test: {
+        name: 'app-integration',
+        environment: 'node',
+        include: ['**/*.integ.ts'],
+        setupFiles: [
+          './test-with-vite/setup/mongoms.ts',
+        ],
+      },
+    },
+  ),
+
+  // component test
+  mergeConfig(
+    configShared,
+    {
+      plugins: [react()],
+      test: {
+        name: 'app-components',
+        environment: 'happy-dom',
+        include: [
+          '**/*.spec.{tsx,jsx}',
+        ],
+      },
+    },
+  ),
+]);

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

@@ -1,6 +1,6 @@
 {
   "name": "@growi/slackbot-proxy",
-  "version": "7.0.18-slackbot-proxy.0",
+  "version": "7.0.21-slackbot-proxy.0",
   "license": "MIT",
   "private": "true",
   "scripts": {
@@ -20,7 +20,7 @@
     "lint:styles": "stylelint --allow-empty-input \"src/**/*.scss\" \"src/**/*.css\"",
     "lint:typecheck": "tspc",
     "lint": "run-p lint:*",
-    "version": "yarn version --no-git-tag-version --preid=slackbot-proxy",
+    "version": "yarn version --no-git-tag-version --non-interactive --preid=slackbot-proxy",
     "ts-node": "node -r ts-node/register/transpile-only -r tsconfig-paths/register -r dotenv-flow/config"
   },
   "// comments for dependencies": {

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

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

@@ -0,0 +1,37 @@
+{
+  "$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"
+    }
+
+  }
+}

+ 10 - 9
package.json

@@ -1,6 +1,6 @@
 {
   "name": "growi",
-  "version": "7.0.18-RC.0",
+  "version": "7.0.21-RC.0",
   "description": "Team collaboration software using markdown",
   "license": "MIT",
   "private": "true",
@@ -41,7 +41,7 @@
     "version-subpackages": "changeset version && yarn upgrade --scope=@growi",
     "release-subpackages": "turbo run build --filter @growi/core --filter @growi/pluginkit && changeset publish",
     "release-subpackages:snapshot": "turbo run build --filter @growi/core --filter @growi/pluginkit && changeset version --snapshot next && changeset publish --no-git-tag --snapshot --tag next",
-    "version": "yarn version --no-git-tag-version --preid=RC"
+    "version": "yarn version --no-git-tag-version --non-interactive --preid=RC"
   },
   "dependencies": {
     "cross-env": "^7.0.0",
@@ -52,11 +52,12 @@
     "yargs": "^17.7.1"
   },
   "// comments for defDependencies": {
-    "vitest": "v1.6.0 occures an error on ci-app-test: \"ENOENT: no such file or directory, lstat '/home/runner/work/growi/growi/apps/app/coverage/.tmp'\""
+    "vite-plugin-dts": "v4.2.1 causes the unexpected error 'Cannot find package 'vue-tsc''"
   },
   "devDependencies": {
     "@changesets/changelog-github": "^0.5.0",
     "@changesets/cli": "^2.27.3",
+    "@faker-js/faker": "^9.0.1",
     "@playwright/test": "^1.46.0",
     "@swc-node/register": "^1.9.1",
     "@swc/core": "^1.5.25",
@@ -69,8 +70,8 @@
     "@typescript-eslint/eslint-plugin": "^5.59.7",
     "@typescript-eslint/parser": "^5.59.7",
     "@vitejs/plugin-react": "^4.3.1",
-    "@vitest/coverage-v8": "^1.6.0",
-    "@vitest/ui": "^1.6.0",
+    "@vitest/coverage-v8": "^2.1.1",
+    "@vitest/ui": "^2.1.1",
     "eslint": "^8.41.0",
     "eslint-config-next": "^12.1.6",
     "eslint-config-weseek": "^2.1.1",
@@ -100,10 +101,10 @@
     "tsconfig-paths": "^4.2.0",
     "typescript": "~5.0.0",
     "typescript-transform-paths": "^3.4.7",
-    "vite": "^5.2.9",
-    "vite-plugin-dts": "^3.8.3",
-    "vite-tsconfig-paths": "^4.3.2",
-    "vitest": "~1.6.0",
+    "vite": "^5.4.6",
+    "vite-plugin-dts": "^3.9.1",
+    "vite-tsconfig-paths": "^5.0.1",
+    "vitest": "^2.1.1",
     "vitest-mock-extended": "^1.3.1"
   },
   "engines": {

+ 5 - 0
packages/core-styles/scss/bootstrap/theming/_dropdown-dark.scss

@@ -0,0 +1,5 @@
+@use '@growi/core-styles/scss/bootstrap/init' as bs;
+
+.dropdown-item:active{
+  --#{$prefix}dropdown-link-active-bg: var(--#{$prefix}gray-700);
+}

+ 6 - 0
packages/core-styles/scss/bootstrap/theming/_dropdown-light.scss

@@ -0,0 +1,6 @@
+@use '@growi/core-styles/scss/bootstrap/init' as bs;
+
+.dropdown-item:active{
+  --#{$prefix}dropdown-link-active-color: var(----#{$prefix}gray-600);
+  --#{$prefix}dropdown-link-active-bg: var(----#{$prefix}gray-200);
+}

+ 1 - 0
packages/core-styles/scss/bootstrap/theming/apply-dark.scss

@@ -5,3 +5,4 @@
 @import './pagination';
 @import './progress';
 @import './list-group';
+@import './dropdown-dark';

+ 1 - 0
packages/core-styles/scss/bootstrap/theming/apply-light.scss

@@ -5,3 +5,4 @@
 @import './pagination';
 @import './progress';
 @import './list-group';
+@import './dropdown-light';

+ 81 - 0
packages/pluginkit/src/v4/utils/template.spec.ts

@@ -0,0 +1,81 @@
+import type { TemplateSummary } from '../interfaces';
+
+import { getLocalizedTemplate, extractSupportedLocales } from './template';
+
+describe('getLocalizedTemplate', () => {
+  it('should return undefined if templateSummary is undefined', () => {
+    expect(getLocalizedTemplate(undefined)).toBeUndefined();
+  });
+
+  it('should return the default template if locale is not provided', () => {
+    const templateSummary: TemplateSummary = {
+      default: {
+        id: 'templateId',
+        locale: 'en_US',
+        isValid: true,
+        isDefault: true,
+        title: 'Default Title',
+      },
+    };
+    expect(getLocalizedTemplate(templateSummary)).toEqual(templateSummary.default);
+  });
+
+  it('should return the localized template if locale is provided and exists in templateSummary', () => {
+    const templateSummary: TemplateSummary = {
+      default: {
+        id: 'templateId',
+        locale: 'en_US',
+        isValid: true,
+        isDefault: true,
+        title: 'Default Title',
+      },
+      ja_JP: {
+        id: 'templateId',
+        locale: 'ja_JP',
+        isValid: true,
+        isDefault: false,
+        title: 'Japanese Title',
+      },
+    };
+    expect(getLocalizedTemplate(templateSummary, 'ja_JP')).toEqual(templateSummary.ja_JP);
+  });
+
+  it('should return the default template if locale is provided but does not exist in templateSummary', () => {
+    const templateSummary: TemplateSummary = {
+      default: {
+        id: 'templateId',
+        locale: 'en_US',
+        isValid: true,
+        isDefault: true,
+        title: 'Default Title',
+      },
+    };
+    expect(getLocalizedTemplate(templateSummary, 'fr')).toEqual(templateSummary.default);
+  });
+});
+
+describe('extractSupportedLocales', () => {
+  it('should return undefined if templateSummary is undefined', () => {
+    expect(extractSupportedLocales(undefined)).toBeUndefined();
+  });
+
+  it('should return a set of locales from the templateSummary', () => {
+    const templateSummary: TemplateSummary = {
+      default: {
+        id: 'templateId',
+        locale: 'en_US',
+        isValid: true,
+        isDefault: true,
+        title: 'Default Title',
+      },
+      ja_JP: {
+        id: 'templateId',
+        locale: 'ja_JP',
+        isValid: true,
+        isDefault: false,
+        title: 'Japanese Title',
+      },
+    };
+    expect(extractSupportedLocales(templateSummary)).toEqual(new Set(['en_US', 'ja_JP']));
+  });
+});

+ 10 - 5
packages/pluginkit/vitest.config.ts

@@ -1,5 +1,5 @@
 import tsconfigPaths from 'vite-tsconfig-paths';
-import { defineConfig } from 'vitest/config';
+import { defineConfig, coverageConfigDefaults } from 'vitest/config';
 
 export default defineConfig({
   plugins: [
@@ -10,11 +10,16 @@ export default defineConfig({
     clearMocks: true,
     globals: true,
     coverage: {
+      exclude: [
+        ...coverageConfigDefaults.exclude,
+        'src/v4/interfaces/**',
+        'src/**/index.ts',
+      ],
       thresholds: {
-        statements: 42.78,
-        branches: 63.15,
-        lines: 42.78,
-        functions: 26.31,
+        statements: 47.59,
+        branches: 89.47,
+        lines: 47.59,
+        functions: 66.66,
       },
     },
   },

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

@@ -49,7 +49,7 @@
     "@growi/ui": "link:../ui",
     "axios": "^0.24.0",
     "bunyan": "^1.8.15",
-    "express": "^4.19.2",
+    "express": "^4.20.0",
     "hast-util-select": "^5.0.5",
     "mongoose": "^6.11.3",
     "swr": "^2.0.3",

+ 1 - 1
packages/remark-growi-directive/src/mdast-util-growi-directive/complex-types.d.ts

@@ -1,7 +1,7 @@
 import type { PhrasingContent } from 'mdast';
 import type { Parent } from 'unist';
 
-import { DirectiveType } from './consts.js';
+import type { DirectiveType } from './consts.js';
 
 
 type DirectiveAttributes = Record<string, string>

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

+ 2 - 0
vitest.workspace.ts → vitest.workspace.mts

@@ -1,4 +1,6 @@
 export default [
   'apps/*/vitest.config.ts',
+  'apps/*/vitest.workspace.ts',
   'packages/*/vitest.config.ts',
+  'packages/*/vitest.workspace.ts',
 ];

Разница между файлами не показана из-за своего большого размера
+ 455 - 369
yarn.lock


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