Explorar o código

Merge branch 'master' into fix-gw7957-fix-put-back-page-on-bookmark-sidebar

Mudana-Grune %!s(int64=2) %!d(string=hai) anos
pai
achega
c22840b086
Modificáronse 100 ficheiros con 997 adicións e 507 borrados
  1. 1 1
      .github/ISSUE_TEMPLATE/bug-report.md
  2. 2 2
      .github/workflows/reusable-app-prod.yml
  3. 29 1
      CHANGELOG.md
  4. 1 0
      apps/app/config/ci/.env.local.for-ci
  5. 1 1
      apps/app/cypress.config.ts
  6. 11 11
      apps/app/package.json
  7. 6 2
      apps/app/public/static/locales/en_US/admin.json
  8. 6 2
      apps/app/public/static/locales/ja_JP/admin.json
  9. 6 2
      apps/app/public/static/locales/zh_CN/admin.json
  10. 0 0
      apps/app/resource/locales/en_US/admin/userInvitation.ejs
  11. 0 0
      apps/app/resource/locales/en_US/admin/userResetPassword.ejs
  12. 0 0
      apps/app/resource/locales/en_US/admin/userWaitingActivation.ejs
  13. 1 1
      apps/app/resource/locales/en_US/notifications/comment.ejs
  14. 0 0
      apps/app/resource/locales/en_US/notifications/notActiveUser.ejs
  15. 1 1
      apps/app/resource/locales/en_US/notifications/pageCreate.ejs
  16. 1 1
      apps/app/resource/locales/en_US/notifications/pageDelete.ejs
  17. 1 1
      apps/app/resource/locales/en_US/notifications/pageEdit.ejs
  18. 1 1
      apps/app/resource/locales/en_US/notifications/pageLike.ejs
  19. 1 1
      apps/app/resource/locales/en_US/notifications/pageMove.ejs
  20. 0 0
      apps/app/resource/locales/en_US/notifications/passwordReset.ejs
  21. 0 0
      apps/app/resource/locales/en_US/notifications/passwordResetSuccessful.ejs
  22. 0 0
      apps/app/resource/locales/en_US/notifications/userActivation.ejs
  23. 1 1
      apps/app/resource/locales/en_US/welcome.md
  24. 0 0
      apps/app/resource/locales/ja_JP/admin/userInvitation.ejs
  25. 0 0
      apps/app/resource/locales/ja_JP/admin/userResetPassword.ejs
  26. 0 0
      apps/app/resource/locales/ja_JP/admin/userWaitingActivation.ejs
  27. 9 0
      apps/app/resource/locales/ja_JP/notifications/comment.ejs
  28. 0 0
      apps/app/resource/locales/ja_JP/notifications/comment.txt
  29. 0 0
      apps/app/resource/locales/ja_JP/notifications/notActiveUser.ejs
  30. 5 0
      apps/app/resource/locales/ja_JP/notifications/pageCreate.ejs
  31. 0 0
      apps/app/resource/locales/ja_JP/notifications/pageCreate.txt
  32. 5 0
      apps/app/resource/locales/ja_JP/notifications/pageDelete.ejs
  33. 0 0
      apps/app/resource/locales/ja_JP/notifications/pageDelete.txt
  34. 5 0
      apps/app/resource/locales/ja_JP/notifications/pageEdit.ejs
  35. 0 0
      apps/app/resource/locales/ja_JP/notifications/pageEdit.txt
  36. 5 0
      apps/app/resource/locales/ja_JP/notifications/pageLike.ejs
  37. 0 0
      apps/app/resource/locales/ja_JP/notifications/pageLike.txt
  38. 5 0
      apps/app/resource/locales/ja_JP/notifications/pageMove.ejs
  39. 0 0
      apps/app/resource/locales/ja_JP/notifications/pageMove.txt
  40. 0 0
      apps/app/resource/locales/ja_JP/notifications/passwordReset.ejs
  41. 0 0
      apps/app/resource/locales/ja_JP/notifications/passwordResetSuccessful.ejs
  42. 0 0
      apps/app/resource/locales/ja_JP/notifications/userActivation.ejs
  43. 0 0
      apps/app/resource/locales/zh_CN/admin/userInvitation.ejs
  44. 0 0
      apps/app/resource/locales/zh_CN/admin/userResetPassword.ejs
  45. 0 0
      apps/app/resource/locales/zh_CN/admin/userWaitingActivation.ejs
  46. 1 1
      apps/app/resource/locales/zh_CN/notifications/comment.ejs
  47. 0 0
      apps/app/resource/locales/zh_CN/notifications/notActiveUser.ejs
  48. 1 1
      apps/app/resource/locales/zh_CN/notifications/pageCreate.ejs
  49. 1 1
      apps/app/resource/locales/zh_CN/notifications/pageDelete.ejs
  50. 1 1
      apps/app/resource/locales/zh_CN/notifications/pageEdit.ejs
  51. 1 1
      apps/app/resource/locales/zh_CN/notifications/pageLike.ejs
  52. 1 1
      apps/app/resource/locales/zh_CN/notifications/pageMove.ejs
  53. 0 0
      apps/app/resource/locales/zh_CN/notifications/passwordReset.ejs
  54. 0 0
      apps/app/resource/locales/zh_CN/notifications/passwordResetSuccessful.ejs
  55. 0 0
      apps/app/resource/locales/zh_CN/notifications/userActivation.ejs
  56. 52 29
      apps/app/src/components/BookmarkButtons.tsx
  57. 38 26
      apps/app/src/components/Bookmarks/BookmarkFolderMenu.tsx
  58. 14 16
      apps/app/src/components/Bookmarks/BookmarkFolderTree.tsx
  59. 22 23
      apps/app/src/components/Navbar/GrowiContextualSubNavigation.tsx
  60. 3 5
      apps/app/src/components/Navbar/SubNavButtons.tsx
  61. 10 10
      apps/app/src/components/PageAlert/TrashPageAlert.tsx
  62. 1 0
      apps/app/src/components/PageDuplicateModal.tsx
  63. 10 10
      apps/app/src/components/PageEditor.tsx
  64. 30 32
      apps/app/src/components/PageList/PageListItemL.tsx
  65. 1 1
      apps/app/src/components/SavePageControls/GrantSelector.tsx
  66. 7 6
      apps/app/src/components/Sidebar/PageTree/Item.tsx
  67. 19 6
      apps/app/src/features/growi-plugin/components/Admin/PluginsExtensionPageContents/PluginInstallerForm.tsx
  68. 68 0
      apps/app/src/features/growi-plugin/models/vo/github-url.spec.ts
  69. 51 0
      apps/app/src/features/growi-plugin/models/vo/github-url.ts
  70. 17 38
      apps/app/src/features/growi-plugin/services/growi-plugin.ts
  71. 25 4
      apps/app/src/pages/[[...path]].page.tsx
  72. 20 6
      apps/app/src/pages/share/[[...path]].page.tsx
  73. 24 2
      apps/app/src/pages/utils/commons.ts
  74. 5 4
      apps/app/src/server/models/obsolete-page.js
  75. 2 0
      apps/app/src/server/models/page.ts
  76. 8 8
      apps/app/src/server/routes/apiv3/forgot-password.js
  77. 6 9
      apps/app/src/server/routes/apiv3/user-activation.ts
  78. 3 2
      apps/app/src/server/routes/apiv3/users.js
  79. 4 3
      apps/app/src/server/routes/login.js
  80. 6 0
      apps/app/src/server/service/config-loader.ts
  81. 3 2
      apps/app/src/server/service/global-notification/global-notification-mail.js
  82. 26 12
      apps/app/src/stores/bookmark.ts
  83. 11 6
      apps/app/src/stores/editor.tsx
  84. 31 9
      apps/app/src/stores/page.tsx
  85. 0 0
      apps/app/test/cypress/e2e/0-advanced-examples/misc.cy.ts
  86. 0 0
      apps/app/test/cypress/e2e/0-advanced-examples/viewport.cy.ts
  87. 0 0
      apps/app/test/cypress/e2e/10-install/10-install--install.cy.ts
  88. 15 22
      apps/app/test/cypress/e2e/20-basic-features/20-basic-features--access-to-page.cy.ts
  89. 0 0
      apps/app/test/cypress/e2e/20-basic-features/20-basic-features--access-to-pagelist.cy.ts
  90. 0 0
      apps/app/test/cypress/e2e/20-basic-features/20-basic-features--click-page-icons.cy.ts
  91. 0 0
      apps/app/test/cypress/e2e/20-basic-features/20-basic-features--sticky-features.cy.ts
  92. 0 181
      apps/app/test/cypress/e2e/20-basic-features/20-basic-features--use-tools.cy.ts
  93. 0 0
      apps/app/test/cypress/e2e/20-basic-features/20-basic-features--username-mention.cy.ts
  94. 0 0
      apps/app/test/cypress/e2e/21-basic-features-for-guest/21-basic-features-for-guest--access-to-page.cy.ts
  95. 0 0
      apps/app/test/cypress/e2e/21-basic-features-for-guest/21-basic-features-for-guest--sticky-for-guest.cy.ts
  96. 0 0
      apps/app/test/cypress/e2e/22-sharelink/22-sharelink--access-to-sharelink.cy.ts
  97. 194 0
      apps/app/test/cypress/e2e/23-editor/23-editor--saving.cy.ts
  98. 160 0
      apps/app/test/cypress/e2e/23-editor/23-editor--with-navigation.cy.ts
  99. 1 0
      apps/app/test/cypress/e2e/23-editor/assets/example.txt
  100. 0 0
      apps/app/test/cypress/e2e/30-search/30-search--search.cy.ts

+ 1 - 1
.github/ISSUE_TEMPLATE/bug-report.md

@@ -1,7 +1,7 @@
 ---
 ---
 name: Bug report
 name: Bug report
 about: Create a report to help us improve
 about: Create a report to help us improve
-labels: ['phase/new']
+labels: ['0️⃣ phase/new']
 ---
 ---
 
 
 Environment
 Environment

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

@@ -217,7 +217,7 @@ jobs:
       fail-fast: false
       fail-fast: false
       matrix:
       matrix:
         # List string expressions that is comma separated ids of tests in "test/cypress/integration"
         # List string expressions that is comma separated ids of tests in "test/cypress/integration"
-        spec-group: ['10', '20', '21', '22', '30', '40', '50', '60']
+        spec-group: ['10', '20', '21', '22', '23', '30', '40', '50', '60']
 
 
     services:
     services:
       mongodb:
       mongodb:
@@ -289,7 +289,7 @@ jobs:
     - name: Determine spec expression
     - name: Determine spec expression
       id: determine-spec-exp
       id: determine-spec-exp
       run: |
       run: |
-        SPEC=`node bin/github-actions/generate-cypress-spec-arg.mjs --prefix="test/cypress/integration/" --suffix="-*/**" "${{ matrix.spec-group }}"`
+        SPEC=`node bin/github-actions/generate-cypress-spec-arg.mjs --prefix="test/cypress/e2e/" --suffix="-*/*.cy.{ts,tsx}" "${{ matrix.spec-group }}"`
         echo "value=$SPEC" >> $GITHUB_OUTPUT
         echo "value=$SPEC" >> $GITHUB_OUTPUT
 
 
     - name: Copy dotenv file for ci
     - name: Copy dotenv file for ci

+ 29 - 1
CHANGELOG.md

@@ -1,9 +1,37 @@
 # Changelog
 # Changelog
 
 
-## [Unreleased](https://github.com/weseek/growi/compare/v6.1.2...HEAD)
+## [Unreleased](https://github.com/weseek/growi/compare/v6.1.3...HEAD)
 
 
 *Please do not manually update this file. We've automated the process.*
 *Please do not manually update this file. We've automated the process.*
 
 
+## [v6.1.3](https://github.com/weseek/growi/compare/v6.1.2...v6.1.3) - 2023-06-07
+
+### 💎 Features
+
+- feat(lsx):  Load more (#7774) @yuki-takei
+
+### 🚀 Improvement
+
+- imprv: Insert template (#7764) @yuki-takei
+- imprv: Update preset templates (#7762) @yuki-takei
+- imprv: Make migration script type safe (#7702) @miya
+- imprv: Update migration script docs (#7699) @miya
+
+### 🐛 Bug Fixes
+
+- fix(lsx): Parsing num/depth options (#7769) @yuki-takei
+- fix: When uploading an attachment and creating a new page, it does not inherit the grant of the parent page (#7768) @miya
+- fix: Unable to perform bookmark operations from bookmark item control (#7750) @miya
+- fix: Bookmarks status not updated on search result (#7667) @mudana-grune
+
+### 🧰 Maintenance
+
+- support: Refactor plugin related modules (#7765) @yuki-takei
+- support: Refactor AclService (#7754) @yuki-takei
+- support: typescriptize SlackLegacyUtil (#7751) @yuki-takei
+- support: Refactor ConfigManager (#7752) @yuki-takei
+- support: Convert unit tests by Jest to Vitest (#7749) @yuki-takei
+
 ## [v6.1.2](https://github.com/weseek/growi/compare/v6.1.1...v6.1.2) - 2023-05-25
 ## [v6.1.2](https://github.com/weseek/growi/compare/v6.1.1...v6.1.2) - 2023-05-25
 
 
 ### 🚀 Improvement
 ### 🚀 Improvement

+ 1 - 0
apps/app/config/ci/.env.local.for-ci

@@ -1 +1,2 @@
 FORMAT_NODE_LOG=true
 FORMAT_NODE_LOG=true
+FILE_UPLOAD=mongodb

+ 1 - 1
apps/app/cypress.config.ts

@@ -3,7 +3,7 @@ import { defineConfig } from 'cypress';
 export default defineConfig({
 export default defineConfig({
   e2e: {
   e2e: {
     baseUrl: 'http://localhost:3000',
     baseUrl: 'http://localhost:3000',
-    specPattern: 'test/cypress/integration/',
+    specPattern: 'test/cypress/e2e/**/*.cy.{ts,tsx}',
     supportFile: 'test/cypress/support/index.ts',
     supportFile: 'test/cypress/support/index.ts',
     setupNodeEvents: (on) => {
     setupNodeEvents: (on) => {
       // change screen size
       // change screen size

+ 11 - 11
apps/app/package.json

@@ -1,6 +1,6 @@
 {
 {
   "name": "@growi/app",
   "name": "@growi/app",
-  "version": "6.1.3-RC.0",
+  "version": "6.1.4-RC.0",
   "license": "MIT",
   "license": "MIT",
   "scripts": {
   "scripts": {
     "//// for production": "",
     "//// for production": "",
@@ -63,14 +63,14 @@
     "@elastic/elasticsearch8": "npm:@elastic/elasticsearch@^8.7.0",
     "@elastic/elasticsearch8": "npm:@elastic/elasticsearch@^8.7.0",
     "@godaddy/terminus": "^4.9.0",
     "@godaddy/terminus": "^4.9.0",
     "@google-cloud/storage": "^5.8.5",
     "@google-cloud/storage": "^5.8.5",
-    "@growi/core": "^6.1.3-RC.0",
-    "@growi/hackmd": "^6.1.3-RC.0",
-    "@growi/preset-themes": "^6.1.3-RC.0",
-    "@growi/remark-attachment-refs": "^6.1.3-RC.0",
-    "@growi/remark-drawio": "^6.1.3-RC.0",
-    "@growi/remark-growi-directive": "^6.1.3-RC.0",
-    "@growi/remark-lsx": "^6.1.3-RC.0",
-    "@growi/slack": "^6.1.3-RC.0",
+    "@growi/core": "^6.1.4-RC.0",
+    "@growi/hackmd": "^6.1.4-RC.0",
+    "@growi/preset-themes": "^6.1.4-RC.0",
+    "@growi/remark-attachment-refs": "^6.1.4-RC.0",
+    "@growi/remark-drawio": "^6.1.4-RC.0",
+    "@growi/remark-growi-directive": "^6.1.4-RC.0",
+    "@growi/remark-lsx": "^6.1.4-RC.0",
+    "@growi/slack": "^6.1.4-RC.0",
     "@promster/express": "^7.0.6",
     "@promster/express": "^7.0.6",
     "@promster/server": "^7.0.8",
     "@promster/server": "^7.0.8",
     "@slack/web-api": "^6.2.4",
     "@slack/web-api": "^6.2.4",
@@ -208,8 +208,8 @@
     "handsontable": "v7.0.0 or above is no loger MIT lisence."
     "handsontable": "v7.0.0 or above is no loger MIT lisence."
   },
   },
   "devDependencies": {
   "devDependencies": {
-    "@growi/presentation": "^6.1.3-RC.0",
-    "@growi/ui": "^6.1.3-RC.0",
+    "@growi/presentation": "^6.1.4-RC.0",
+    "@growi/ui": "^6.1.4-RC.0",
     "@handsontable/react": "=2.1.0",
     "@handsontable/react": "=2.1.0",
     "@icon/themify-icons": "1.0.1-alpha.3",
     "@icon/themify-icons": "1.0.1-alpha.3",
     "@next/bundle-analyzer": "^13.2.3",
     "@next/bundle-analyzer": "^13.2.3",

+ 6 - 2
apps/app/public/static/locales/en_US/admin.json

@@ -857,8 +857,12 @@
   "plugins": {
   "plugins": {
     "plugins": "Plugins",
     "plugins": "Plugins",
     "plugin_installer": "Plugin Installer",
     "plugin_installer": "Plugin Installer",
-    "repository_url": "Repository URL",
-    "description": "You can install plugins by inputting the URL",
+    "form": {
+      "label_url": "Repository URL",
+      "desc_url": "You can install plugins by inputting the URL",
+      "label_branch": "Repository Branch Name",
+      "desc_branch": "You can specify the branch name to install. Default: `main`"
+    },
     "plugin_card": "Plugin Card",
     "plugin_card": "Plugin Card",
     "plugin_is_not_installed": "Plugin is not installed",
     "plugin_is_not_installed": "Plugin is not installed",
     "install": "Install",
     "install": "Install",

+ 6 - 2
apps/app/public/static/locales/ja_JP/admin.json

@@ -865,8 +865,12 @@
   "plugins": {
   "plugins": {
     "plugins": "プラグイン",
     "plugins": "プラグイン",
     "plugin_installer": "プラグインインストーラー",
     "plugin_installer": "プラグインインストーラー",
-    "repository_url": "URL",
-    "description": "リポジトリのURLの入力してください。",
+    "form": {
+      "label_url": "リポジトリURL",
+      "desc_url": "リポジトリのURLの入力してください。",
+      "label_branch": "ブランチの指定",
+      "desc_branch": "インストール対象のブランチを設定できます。デフォルト: `main`"
+    },
     "plugin_card": "プラグインカード",
     "plugin_card": "プラグインカード",
     "plugin_is_not_installed": "プラグインがインストールされていません",
     "plugin_is_not_installed": "プラグインがインストールされていません",
     "install": "インストール",
     "install": "インストール",

+ 6 - 2
apps/app/public/static/locales/zh_CN/admin.json

@@ -865,8 +865,12 @@
   "plugins": {
   "plugins": {
     "plugins": "Plugins",
     "plugins": "Plugins",
     "plugin_installer": "Plugin Installer",
     "plugin_installer": "Plugin Installer",
-    "repository_url": "Repository URL",
-    "description": "You can install plugins by inputting the URL",
+    "form": {
+      "label_url": "Repository URL",
+      "desc_url": "You can install plugins by inputting the URL",
+      "label_branch": "Repository Branch Name",
+      "desc_branch": "You can specify the branch name to install. Default: `main`"
+    },
     "plugin_card": "Plugin Card",
     "plugin_card": "Plugin Card",
     "plugin_is_not_installed": "Plugin is not installed",
     "plugin_is_not_installed": "Plugin is not installed",
     "install": "Install",
     "install": "Install",

+ 0 - 0
apps/app/resource/locales/en_US/admin/userInvitation.txt → apps/app/resource/locales/en_US/admin/userInvitation.ejs


+ 0 - 0
apps/app/resource/locales/en_US/admin/userResetPassword.txt → apps/app/resource/locales/en_US/admin/userResetPassword.ejs


+ 0 - 0
apps/app/resource/locales/en_US/admin/userWaitingActivation.txt → apps/app/resource/locales/en_US/admin/userWaitingActivation.ejs


+ 1 - 1
apps/app/resource/locales/en_US/notifications/comment.txt → apps/app/resource/locales/en_US/notifications/comment.ejs

@@ -6,4 +6,4 @@
 
 
 ----------------------
 ----------------------
 
 
-Growi: <%- appTitle %>
+GROWI: <%- appTitle %>

+ 0 - 0
apps/app/resource/locales/en_US/notifications/notActiveUser.txt → apps/app/resource/locales/en_US/notifications/notActiveUser.ejs


+ 1 - 1
apps/app/resource/locales/zh_CN/notifications/pageCreate.txt → apps/app/resource/locales/en_US/notifications/pageCreate.ejs

@@ -2,4 +2,4 @@
 
 
 ----------------------
 ----------------------
 
 
-Growi: <%- appTitle %>
+GROWI: <%- appTitle %>

+ 1 - 1
apps/app/resource/locales/zh_CN/notifications/pageDelete.txt → apps/app/resource/locales/en_US/notifications/pageDelete.ejs

@@ -2,4 +2,4 @@
 
 
 ----------------------
 ----------------------
 
 
-Growi: <%- appTitle %>
+GROWI: <%- appTitle %>

+ 1 - 1
apps/app/resource/locales/zh_CN/notifications/pageEdit.txt → apps/app/resource/locales/en_US/notifications/pageEdit.ejs

@@ -2,4 +2,4 @@
 
 
 ----------------------
 ----------------------
 
 
-Growi: <%- appTitle %>
+GROWI: <%- appTitle %>

+ 1 - 1
apps/app/resource/locales/en_US/notifications/pageLike.txt → apps/app/resource/locales/en_US/notifications/pageLike.ejs

@@ -2,4 +2,4 @@
 
 
 ----------------------
 ----------------------
 
 
-Growi: <%- appTitle %>
+GROWI: <%- appTitle %>

+ 1 - 1
apps/app/resource/locales/en_US/notifications/pageMove.txt → apps/app/resource/locales/en_US/notifications/pageMove.ejs

@@ -2,4 +2,4 @@
 
 
 ----------------------
 ----------------------
 
 
-Growi: <%- appTitle %>
+GROWI: <%- appTitle %>

+ 0 - 0
apps/app/resource/locales/en_US/notifications/passwordReset.txt → apps/app/resource/locales/en_US/notifications/passwordReset.ejs


+ 0 - 0
apps/app/resource/locales/en_US/notifications/passwordResetSuccessful.txt → apps/app/resource/locales/en_US/notifications/passwordResetSuccessful.ejs


+ 0 - 0
apps/app/resource/locales/en_US/notifications/userActivation.txt → apps/app/resource/locales/en_US/notifications/userActivation.ejs


+ 1 - 1
apps/app/resource/locales/en_US/welcome.md

@@ -60,5 +60,5 @@ We can display the content list using a table and `$lsx`.
 
 
 <a href="https://growi-slackin.weseek.co.jp/"><img src="https://growi-slackin.weseek.co.jp/badge.svg"></a>
 <a href="https://growi-slackin.weseek.co.jp/"><img src="https://growi-slackin.weseek.co.jp/badge.svg"></a>
 
 
-We welcome newcomers joining our slack channel to help improve Growi.
+We welcome newcomers joining our slack channel to help improve GROWI.
 In addition to discussing development, we are also happy to answer your questions when you join.
 In addition to discussing development, we are also happy to answer your questions when you join.

+ 0 - 0
apps/app/resource/locales/ja_JP/admin/userInvitation.txt → apps/app/resource/locales/ja_JP/admin/userInvitation.ejs


+ 0 - 0
apps/app/resource/locales/ja_JP/admin/userResetPassword.txt → apps/app/resource/locales/ja_JP/admin/userResetPassword.ejs


+ 0 - 0
apps/app/resource/locales/ja_JP/admin/userWaitingActivation.txt → apps/app/resource/locales/ja_JP/admin/userWaitingActivation.ejs


+ 9 - 0
apps/app/resource/locales/ja_JP/notifications/comment.ejs

@@ -0,0 +1,9 @@
+<%- username %> が <%- path %> にコメントしました。
+
+----------------------
+
+<%- comment %>
+
+----------------------
+
+GROWI: <%- appTitle %>

+ 0 - 0
apps/app/resource/locales/ja_JP/notifications/comment.txt


+ 0 - 0
apps/app/resource/locales/ja_JP/notifications/notActiveUser.txt → apps/app/resource/locales/ja_JP/notifications/notActiveUser.ejs


+ 5 - 0
apps/app/resource/locales/ja_JP/notifications/pageCreate.ejs

@@ -0,0 +1,5 @@
+<%- username %> が <%- path %> を作成しました。
+
+----------------------
+
+GROWI: <%- appTitle %>

+ 0 - 0
apps/app/resource/locales/ja_JP/notifications/pageCreate.txt


+ 5 - 0
apps/app/resource/locales/ja_JP/notifications/pageDelete.ejs

@@ -0,0 +1,5 @@
+<%- username %> が <%- path %> を削除しました。
+
+----------------------
+
+GROWI: <%- appTitle %>

+ 0 - 0
apps/app/resource/locales/ja_JP/notifications/pageDelete.txt


+ 5 - 0
apps/app/resource/locales/ja_JP/notifications/pageEdit.ejs

@@ -0,0 +1,5 @@
+<%- username %> が <%- path %> を編集しました。
+
+----------------------
+
+GROWI: <%- appTitle %>

+ 0 - 0
apps/app/resource/locales/ja_JP/notifications/pageEdit.txt


+ 5 - 0
apps/app/resource/locales/ja_JP/notifications/pageLike.ejs

@@ -0,0 +1,5 @@
+<%- username %> が <%- path %> を「いいね」しました。
+
+----------------------
+
+GROWI: <%- appTitle %>

+ 0 - 0
apps/app/resource/locales/ja_JP/notifications/pageLike.txt


+ 5 - 0
apps/app/resource/locales/ja_JP/notifications/pageMove.ejs

@@ -0,0 +1,5 @@
+<%- username %> が <%- oldPath %> を <%- newPath %> に移動(名前を変更)しました。
+
+----------------------
+
+GROWI: <%- appTitle %>

+ 0 - 0
apps/app/resource/locales/ja_JP/notifications/pageMove.txt


+ 0 - 0
apps/app/resource/locales/ja_JP/notifications/passwordReset.txt → apps/app/resource/locales/ja_JP/notifications/passwordReset.ejs


+ 0 - 0
apps/app/resource/locales/ja_JP/notifications/passwordResetSuccessful.txt → apps/app/resource/locales/ja_JP/notifications/passwordResetSuccessful.ejs


+ 0 - 0
apps/app/resource/locales/ja_JP/notifications/userActivation.txt → apps/app/resource/locales/ja_JP/notifications/userActivation.ejs


+ 0 - 0
apps/app/resource/locales/zh_CN/admin/userInvitation.txt → apps/app/resource/locales/zh_CN/admin/userInvitation.ejs


+ 0 - 0
apps/app/resource/locales/zh_CN/admin/userResetPassword.txt → apps/app/resource/locales/zh_CN/admin/userResetPassword.ejs


+ 0 - 0
apps/app/resource/locales/zh_CN/admin/userWaitingActivation.txt → apps/app/resource/locales/zh_CN/admin/userWaitingActivation.ejs


+ 1 - 1
apps/app/resource/locales/zh_CN/notifications/comment.txt → apps/app/resource/locales/zh_CN/notifications/comment.ejs

@@ -6,4 +6,4 @@
 
 
 ----------------------
 ----------------------
 
 
-Growi: <%- appTitle %>
+GROWI: <%- appTitle %>

+ 0 - 0
apps/app/resource/locales/zh_CN/notifications/notActiveUser.txt → apps/app/resource/locales/zh_CN/notifications/notActiveUser.ejs


+ 1 - 1
apps/app/resource/locales/en_US/notifications/pageCreate.txt → apps/app/resource/locales/zh_CN/notifications/pageCreate.ejs

@@ -2,4 +2,4 @@
 
 
 ----------------------
 ----------------------
 
 
-Growi: <%- appTitle %>
+GROWI: <%- appTitle %>

+ 1 - 1
apps/app/resource/locales/en_US/notifications/pageDelete.txt → apps/app/resource/locales/zh_CN/notifications/pageDelete.ejs

@@ -2,4 +2,4 @@
 
 
 ----------------------
 ----------------------
 
 
-Growi: <%- appTitle %>
+GROWI: <%- appTitle %>

+ 1 - 1
apps/app/resource/locales/en_US/notifications/pageEdit.txt → apps/app/resource/locales/zh_CN/notifications/pageEdit.ejs

@@ -2,4 +2,4 @@
 
 
 ----------------------
 ----------------------
 
 
-Growi: <%- appTitle %>
+GROWI: <%- appTitle %>

+ 1 - 1
apps/app/resource/locales/zh_CN/notifications/pageLike.txt → apps/app/resource/locales/zh_CN/notifications/pageLike.ejs

@@ -2,4 +2,4 @@
 
 
 ----------------------
 ----------------------
 
 
-Growi: <%- appTitle %>
+GROWI: <%- appTitle %>

+ 1 - 1
apps/app/resource/locales/zh_CN/notifications/pageMove.txt → apps/app/resource/locales/zh_CN/notifications/pageMove.ejs

@@ -2,4 +2,4 @@
 
 
 ----------------------
 ----------------------
 
 
-Growi: <%- appTitle %>
+GROWI: <%- appTitle %>

+ 0 - 0
apps/app/resource/locales/zh_CN/notifications/passwordReset.txt → apps/app/resource/locales/zh_CN/notifications/passwordReset.ejs


+ 0 - 0
apps/app/resource/locales/zh_CN/notifications/passwordResetSuccessful.txt → apps/app/resource/locales/zh_CN/notifications/passwordResetSuccessful.ejs


+ 0 - 0
apps/app/resource/locales/zh_CN/notifications/userActivation.txt → apps/app/resource/locales/zh_CN/notifications/userActivation.ejs


+ 52 - 29
apps/app/src/components/BookmarkButtons.tsx

@@ -3,38 +3,49 @@ import React, {
 } from 'react';
 } from 'react';
 
 
 import { useTranslation } from 'next-i18next';
 import { useTranslation } from 'next-i18next';
-import {
-  UncontrolledTooltip, Popover, PopoverBody, DropdownToggle,
-} from 'reactstrap';
+import DropdownToggle from 'reactstrap/es/DropdownToggle';
+import Popover from 'reactstrap/es/Popover';
+import PopoverBody from 'reactstrap/es/PopoverBody';
+import UncontrolledTooltip from 'reactstrap/es/UncontrolledTooltip';
 
 
-import { IBookmarkInfo } from '~/interfaces/bookmark-info';
+import { useSWRxBookmarkedUsers } from '~/stores/bookmark';
 import { useIsGuestUser } from '~/stores/context';
 import { useIsGuestUser } from '~/stores/context';
 
 
-import { IUser } from '../interfaces/user';
-
 import { BookmarkFolderMenu } from './Bookmarks/BookmarkFolderMenu';
 import { BookmarkFolderMenu } from './Bookmarks/BookmarkFolderMenu';
 import UserPictureList from './User/UserPictureList';
 import UserPictureList from './User/UserPictureList';
 
 
 import styles from './BookmarkButtons.module.scss';
 import styles from './BookmarkButtons.module.scss';
 
 
 interface Props {
 interface Props {
-  bookmarkedUsers?: IUser[]
-  hideTotalNumber?: boolean
-  bookmarkInfo? : IBookmarkInfo
+  pageId: string,
+  isBookmarked?: boolean,
+  bookmarkCount: number,
+  hideTotalNumber?: boolean,
 }
 }
 
 
 export const BookmarkButtons: FC<Props> = (props: Props) => {
 export const BookmarkButtons: FC<Props> = (props: Props) => {
   const { t } = useTranslation();
   const { t } = useTranslation();
   const {
   const {
-    bookmarkedUsers, hideTotalNumber, bookmarkInfo,
+    pageId, isBookmarked, bookmarkCount, hideTotalNumber,
   } = props;
   } = props;
 
 
-  const [isPopoverOpen, setIsPopoverOpen] = useState(false);
+  const [isBookmarkFolderMenuOpen, setBookmarkFolderMenuOpen] = useState(false);
+  const [isBookmarkUsersPopoverOpen, setBookmarkUsersPopoverOpen] = useState(false);
 
 
   const { data: isGuestUser } = useIsGuestUser();
   const { data: isGuestUser } = useIsGuestUser();
 
 
-  const togglePopover = () => {
-    setIsPopoverOpen(!isPopoverOpen);
+  const { data: bookmarkedUsers, isLoading: isLoadingBookmarkedUsers } = useSWRxBookmarkedUsers(isBookmarkUsersPopoverOpen ? pageId : null);
+
+  const unbookmarkHandler = () => {
+    setBookmarkFolderMenuOpen(false);
+  };
+
+  const toggleBookmarkFolderMenuHandler = () => {
+    setBookmarkFolderMenuOpen(v => !v);
+  };
+
+  const toggleBookmarkUsersPopover = () => {
+    setBookmarkUsersPopoverOpen(v => !v);
   };
   };
 
 
   const getTooltipMessage = useCallback(() => {
   const getTooltipMessage = useCallback(() => {
@@ -45,19 +56,23 @@ export const BookmarkButtons: FC<Props> = (props: Props) => {
     return 'tooltip.bookmark';
     return 'tooltip.bookmark';
   }, [isGuestUser]);
   }, [isGuestUser]);
 
 
-  if (bookmarkInfo == null) {
+  if (pageId == null) {
     return <></>;
     return <></>;
   }
   }
 
 
   return (
   return (
     <div className={`btn-group btn-group-bookmark ${styles['btn-group-bookmark']}`} role="group" aria-label="Bookmark buttons">
     <div className={`btn-group btn-group-bookmark ${styles['btn-group-bookmark']}`} role="group" aria-label="Bookmark buttons">
-      <BookmarkFolderMenu bookmarkInfo={bookmarkInfo}>
+
+      <BookmarkFolderMenu
+        isOpen={isBookmarkFolderMenuOpen} pageId={pageId} isBookmarked={isBookmarked ?? false}
+        onToggle={toggleBookmarkFolderMenuHandler}
+        onUnbookmark={unbookmarkHandler}
+      >
         <DropdownToggle id='bookmark-dropdown-btn' color="transparent" className={`shadow-none btn btn-bookmark border-0
         <DropdownToggle id='bookmark-dropdown-btn' color="transparent" className={`shadow-none btn btn-bookmark border-0
-          ${bookmarkInfo.isBookmarked ? 'active' : ''} ${isGuestUser ? 'disabled' : ''}`}>
-          <i className={`fa ${bookmarkInfo.isBookmarked ? 'fa-bookmark' : 'fa-bookmark-o'}`}></i>
+          ${isBookmarked ? 'active' : ''} ${isGuestUser ? 'disabled' : ''}`}>
+          <i className={`fa ${isBookmarked ? 'fa-bookmark' : 'fa-bookmark-o'}`}></i>
         </DropdownToggle>
         </DropdownToggle>
       </BookmarkFolderMenu>
       </BookmarkFolderMenu>
-
       <UncontrolledTooltip placement="top" data-testid="bookmark-button-tooltip" target="bookmark-dropdown-btn" fade={false}>
       <UncontrolledTooltip placement="top" data-testid="bookmark-button-tooltip" target="bookmark-dropdown-btn" fade={false}>
         {t(getTooltipMessage())}
         {t(getTooltipMessage())}
       </UncontrolledTooltip>
       </UncontrolledTooltip>
@@ -68,19 +83,27 @@ export const BookmarkButtons: FC<Props> = (props: Props) => {
             type="button"
             type="button"
             id="po-total-bookmarks"
             id="po-total-bookmarks"
             className={`shadow-none btn btn-bookmark border-0
             className={`shadow-none btn btn-bookmark border-0
-              total-bookmarks ${bookmarkInfo.isBookmarked ? 'active' : ''}`}
+              total-bookmarks ${isBookmarked ? 'active' : ''}`}
           >
           >
-            {bookmarkInfo.sumOfBookmarks ?? 0}
+            {bookmarkCount}
           </button>
           </button>
-          { bookmarkedUsers != null && (
-            <Popover placement="bottom" isOpen={isPopoverOpen} target="po-total-bookmarks" toggle={togglePopover} trigger="legacy">
-              <PopoverBody className="user-list-popover">
-                <div className="px-2 text-right user-list-content text-truncate text-muted">
-                  {bookmarkedUsers.length ? <UserPictureList users={props.bookmarkedUsers} /> : t('No users have bookmarked yet')}
-                </div>
-              </PopoverBody>
-            </Popover>
-          ) }
+          <Popover placement="bottom" isOpen={isBookmarkUsersPopoverOpen} target="po-total-bookmarks" toggle={toggleBookmarkUsersPopover} trigger="legacy">
+            <PopoverBody className="user-list-popover">
+              { isLoadingBookmarkedUsers && <i className="fa fa-spinner fa-pulse"></i> }
+              { !isLoadingBookmarkedUsers && bookmarkedUsers != null && (
+                <>
+                  { bookmarkedUsers.length > 0
+                    ? (
+                      <div className="px-2 text-right user-list-content text-truncate text-muted">
+                        <UserPictureList users={bookmarkedUsers} />
+                      </div>
+                    )
+                    : t('No users have bookmarked yet')
+                  }
+                </>
+              ) }
+            </PopoverBody>
+          </Popover>
         </>
         </>
       ) }
       ) }
     </div>
     </div>

+ 38 - 26
apps/app/src/components/Bookmarks/BookmarkFolderMenu.tsx

@@ -6,28 +6,37 @@ import { DropdownItem, DropdownMenu, UncontrolledDropdown } from 'reactstrap';
 
 
 import { addBookmarkToFolder, toggleBookmark } from '~/client/util/bookmark-utils';
 import { addBookmarkToFolder, toggleBookmark } from '~/client/util/bookmark-utils';
 import { toastError } from '~/client/util/toastr';
 import { toastError } from '~/client/util/toastr';
-import { IBookmarkInfo } from '~/interfaces/bookmark-info';
-import { useSWRBookmarkInfo, useSWRxUserBookmarks } from '~/stores/bookmark';
+import { useSWRMUTxCurrentUserBookmarks } from '~/stores/bookmark';
 import { useSWRxBookmarkFolderAndChild } from '~/stores/bookmark-folder';
 import { useSWRxBookmarkFolderAndChild } from '~/stores/bookmark-folder';
 import { useCurrentUser } from '~/stores/context';
 import { useCurrentUser } from '~/stores/context';
-import { useSWRxPageInfo } from '~/stores/page';
+import { useSWRMUTxPageInfo } from '~/stores/page';
 
 
 import { BookmarkFolderMenuItem } from './BookmarkFolderMenuItem';
 import { BookmarkFolderMenuItem } from './BookmarkFolderMenuItem';
 
 
-export const BookmarkFolderMenu: React.FC<{children?: React.ReactNode, bookmarkInfo: IBookmarkInfo }> = ({ children, bookmarkInfo }): JSX.Element => {
+
+type BookmarkFolderMenuProps = {
+  isOpen: boolean,
+  pageId: string,
+  isBookmarked: boolean,
+  onToggle?: () => void,
+  onUnbookmark?: () => void,
+  children?: React.ReactNode,
+}
+
+export const BookmarkFolderMenu = (props: BookmarkFolderMenuProps): JSX.Element => {
+  const {
+    isOpen, pageId, isBookmarked, onToggle, onUnbookmark, children,
+  } = props;
+
   const { t } = useTranslation();
   const { t } = useTranslation();
 
 
   const [selectedItem, setSelectedItem] = useState<string | null>(null);
   const [selectedItem, setSelectedItem] = useState<string | null>(null);
-  const [isOpen, setIsOpen] = useState(false);
 
 
   const { data: currentUser } = useCurrentUser();
   const { data: currentUser } = useCurrentUser();
   const { data: bookmarkFolders, mutate: mutateBookmarkFolders } = useSWRxBookmarkFolderAndChild(currentUser?._id);
   const { data: bookmarkFolders, mutate: mutateBookmarkFolders } = useSWRxBookmarkFolderAndChild(currentUser?._id);
-  const { mutate: mutateBookmarkInfo } = useSWRBookmarkInfo(bookmarkInfo.pageId);
-
-  const { mutate: mutateUserBookmarks } = useSWRxUserBookmarks(currentUser?._id);
-  const { mutate: mutatePageInfo } = useSWRxPageInfo(bookmarkInfo.pageId);
 
 
-  const isBookmarked = bookmarkInfo.isBookmarked ?? false;
+  const { trigger: mutateCurrentUserBookmarks } = useSWRMUTxCurrentUserBookmarks();
+  const { trigger: mutatePageInfo } = useSWRMUTxPageInfo(pageId);
 
 
   const isBookmarkFolderExists = useMemo((): boolean => {
   const isBookmarkFolderExists = useMemo((): boolean => {
     return bookmarkFolders != null && bookmarkFolders.length > 0;
     return bookmarkFolders != null && bookmarkFolders.length > 0;
@@ -35,36 +44,40 @@ export const BookmarkFolderMenu: React.FC<{children?: React.ReactNode, bookmarkI
 
 
   const toggleBookmarkHandler = useCallback(async() => {
   const toggleBookmarkHandler = useCallback(async() => {
     try {
     try {
-      await toggleBookmark(bookmarkInfo.pageId, isBookmarked);
+      await toggleBookmark(pageId, isBookmarked);
     }
     }
     catch (err) {
     catch (err) {
       toastError(err);
       toastError(err);
     }
     }
-  }, [bookmarkInfo.pageId, isBookmarked]);
+  }, [isBookmarked, pageId]);
 
 
   const onUnbookmarkHandler = useCallback(async() => {
   const onUnbookmarkHandler = useCallback(async() => {
+    if (onUnbookmark != null) {
+      onUnbookmark();
+    }
     await toggleBookmarkHandler();
     await toggleBookmarkHandler();
-    setIsOpen(false);
     setSelectedItem(null);
     setSelectedItem(null);
-    mutateUserBookmarks();
-    mutateBookmarkInfo();
+    mutateCurrentUserBookmarks();
     mutateBookmarkFolders();
     mutateBookmarkFolders();
     mutatePageInfo();
     mutatePageInfo();
-  }, [mutateBookmarkFolders, mutateBookmarkInfo, mutatePageInfo, mutateUserBookmarks, toggleBookmarkHandler]);
+  }, [onUnbookmark, toggleBookmarkHandler, mutateCurrentUserBookmarks, mutateBookmarkFolders, mutatePageInfo]);
 
 
   const toggleHandler = useCallback(async() => {
   const toggleHandler = useCallback(async() => {
-    setIsOpen(!isOpen);
-
+    // on close
     if (isOpen && bookmarkFolders != null) {
     if (isOpen && bookmarkFolders != null) {
       bookmarkFolders.forEach((bookmarkFolder) => {
       bookmarkFolders.forEach((bookmarkFolder) => {
         bookmarkFolder.bookmarks.forEach((bookmark) => {
         bookmarkFolder.bookmarks.forEach((bookmark) => {
-          if (bookmark.page._id === bookmarkInfo.pageId) {
+          if (bookmark.page._id === pageId) {
             setSelectedItem(bookmarkFolder._id);
             setSelectedItem(bookmarkFolder._id);
           }
           }
         });
         });
       });
       });
     }
     }
 
 
+    if (onToggle != null) {
+      onToggle();
+    }
+
     if (selectedItem == null) {
     if (selectedItem == null) {
       setSelectedItem('root');
       setSelectedItem('root');
     }
     }
@@ -72,8 +85,7 @@ export const BookmarkFolderMenu: React.FC<{children?: React.ReactNode, bookmarkI
     if (!isOpen && !isBookmarked) {
     if (!isOpen && !isBookmarked) {
       try {
       try {
         await toggleBookmarkHandler();
         await toggleBookmarkHandler();
-        mutateUserBookmarks();
-        mutateBookmarkInfo();
+        mutateCurrentUserBookmarks();
         mutatePageInfo();
         mutatePageInfo();
       }
       }
       catch (err) {
       catch (err) {
@@ -81,7 +93,7 @@ export const BookmarkFolderMenu: React.FC<{children?: React.ReactNode, bookmarkI
       }
       }
     }
     }
   },
   },
-  [isOpen, bookmarkFolders, selectedItem, isBookmarked, bookmarkInfo.pageId, toggleBookmarkHandler, mutateUserBookmarks, mutateBookmarkInfo, mutatePageInfo]);
+  [isOpen, bookmarkFolders, onToggle, selectedItem, isBookmarked, pageId, toggleBookmarkHandler, mutateCurrentUserBookmarks, mutatePageInfo]);
 
 
   const onMenuItemClickHandler = useCallback(async(e, itemId: string) => {
   const onMenuItemClickHandler = useCallback(async(e, itemId: string) => {
     e.stopPropagation();
     e.stopPropagation();
@@ -89,15 +101,15 @@ export const BookmarkFolderMenu: React.FC<{children?: React.ReactNode, bookmarkI
     setSelectedItem(itemId);
     setSelectedItem(itemId);
 
 
     try {
     try {
-      await addBookmarkToFolder(bookmarkInfo.pageId, itemId === 'root' ? null : itemId);
-      mutateUserBookmarks();
+      await addBookmarkToFolder(pageId, itemId === 'root' ? null : itemId);
+      mutateCurrentUserBookmarks();
       mutateBookmarkFolders();
       mutateBookmarkFolders();
-      mutateBookmarkInfo();
+      mutatePageInfo();
     }
     }
     catch (err) {
     catch (err) {
       toastError(err);
       toastError(err);
     }
     }
-  }, [bookmarkInfo.pageId, mutateUserBookmarks, mutateBookmarkFolders, mutateBookmarkInfo]);
+  }, [pageId, mutateCurrentUserBookmarks, mutateBookmarkFolders, mutatePageInfo]);
 
 
   const renderBookmarkMenuItem = () => {
   const renderBookmarkMenuItem = () => {
     return (
     return (

+ 14 - 16
apps/app/src/components/Bookmarks/BookmarkFolderTree.tsx

@@ -6,11 +6,13 @@ import { useTranslation } from 'next-i18next';
 import { toastError, toastSuccess } from '~/client/util/toastr';
 import { toastError, toastSuccess } from '~/client/util/toastr';
 import { IPageToDeleteWithMeta } from '~/interfaces/page';
 import { IPageToDeleteWithMeta } from '~/interfaces/page';
 import { OnDeletedFunction } from '~/interfaces/ui';
 import { OnDeletedFunction } from '~/interfaces/ui';
-import { useSWRxUserBookmarks, useSWRBookmarkInfo } from '~/stores/bookmark';
+import {
+  useSWRxUserBookmarks, useSWRMUTxCurrentUserBookmarks,
+} from '~/stores/bookmark';
 import { useSWRxBookmarkFolderAndChild } from '~/stores/bookmark-folder';
 import { useSWRxBookmarkFolderAndChild } from '~/stores/bookmark-folder';
-import { useIsReadOnlyUser, useCurrentUser } from '~/stores/context';
-import { usePageDeleteModal, usePutBackPageModal } from '~/stores/modal';
-import { useSWRxCurrentPage, mutateAllPageInfo } from '~/stores/page';
+import { useIsReadOnlyUser } from '~/stores/context';
+import { usePageDeleteModal } from '~/stores/modal';
+import { useSWRMUTxPageInfo, useSWRxCurrentPage } from '~/stores/page';
 
 
 import { BookmarkFolderItem } from './BookmarkFolderItem';
 import { BookmarkFolderItem } from './BookmarkFolderItem';
 import { BookmarkItem } from './BookmarkItem';
 import { BookmarkItem } from './BookmarkItem';
@@ -38,25 +40,21 @@ export const BookmarkFolderTree: React.FC<Props> = (props: Props) => {
   const { t } = useTranslation();
   const { t } = useTranslation();
   const router = useRouter();
   const router = useRouter();
 
 
-  // In order to update the bookmark information in the sidebar when bookmarking or unbookmarking a page on someone else's user homepage
-  const { data: currentUser } = useCurrentUser();
-  const shouldMutateCurrentUserbookmarks = currentUser?._id !== userId;
-
   const { data: isReadOnlyUser } = useIsReadOnlyUser();
   const { data: isReadOnlyUser } = useIsReadOnlyUser();
   const { data: currentPage } = useSWRxCurrentPage();
   const { data: currentPage } = useSWRxCurrentPage();
-  const { mutate: mutateBookmarkInfo } = useSWRBookmarkInfo(currentPage?._id);
   const { data: bookmarkFolders, mutate: mutateBookmarkFolders } = useSWRxBookmarkFolderAndChild(userId);
   const { data: bookmarkFolders, mutate: mutateBookmarkFolders } = useSWRxBookmarkFolderAndChild(userId);
-  const { data: userBookmarks, mutate: mutateUserBookmarks } = useSWRxUserBookmarks(userId);
-  const { mutate: mutateCurrentUserBookmarks } = useSWRxUserBookmarks(shouldMutateCurrentUserbookmarks ? currentUser?._id : undefined);
+  const { data: userBookmarks, mutate: mutateUserBookmarks } = useSWRxUserBookmarks(userId ?? null);
+  const { trigger: mutatePageInfo } = useSWRMUTxPageInfo(currentPage?._id ?? null);
+  const { trigger: mutateCurrentUserBookmarks } = useSWRMUTxCurrentUserBookmarks();
   const { open: openDeleteModal } = usePageDeleteModal();
   const { open: openDeleteModal } = usePageDeleteModal();
   const { open: openPutBackPageModal } = usePutBackPageModal();
   const { open: openPutBackPageModal } = usePutBackPageModal();
 
 
   const bookmarkFolderTreeMutation = useCallback(() => {
   const bookmarkFolderTreeMutation = useCallback(() => {
     mutateUserBookmarks();
     mutateUserBookmarks();
     mutateCurrentUserBookmarks();
     mutateCurrentUserBookmarks();
-    mutateBookmarkInfo();
+    mutatePageInfo();
     mutateBookmarkFolders();
     mutateBookmarkFolders();
-  }, [mutateBookmarkFolders, mutateBookmarkInfo, mutateCurrentUserBookmarks, mutateUserBookmarks]);
+  }, [mutateBookmarkFolders, mutatePageInfo, mutateCurrentUserBookmarks, mutateUserBookmarks]);
 
 
   const onClickDeleteMenuItemHandler = useCallback((pageToDelete: IPageToDeleteWithMeta) => {
   const onClickDeleteMenuItemHandler = useCallback((pageToDelete: IPageToDeleteWithMeta) => {
     const pageDeletedHandler: OnDeletedFunction = (pathOrPathsToDelete, _isRecursively, isCompletely) => {
     const pageDeletedHandler: OnDeletedFunction = (pathOrPathsToDelete, _isRecursively, isCompletely) => {
@@ -104,11 +102,11 @@ export const BookmarkFolderTree: React.FC<Props> = (props: Props) => {
   //   return !isRootBookmark;
   //   return !isRootBookmark;
 
 
   // };
   // };
-  const putBackClickHandler = useCallback(async(pagePath: string) => {
+  const putBackClickHandler = useCallback(async (pagePath: string) => {
     const bookmarkedPage = userBookmarks?.filter(userBookmark => userBookmark._id === pagePath)[0];
     const bookmarkedPage = userBookmarks?.filter(userBookmark => userBookmark._id === pagePath)[0];
     if (bookmarkedPage != null) {
     if (bookmarkedPage != null) {
       const { _id: pageId, path } = bookmarkedPage;
       const { _id: pageId, path } = bookmarkedPage;
-      const putBackedHandler = async() => {
+      const putBackedHandler = async () => {
         try {
         try {
           await unlink(path);
           await unlink(path);
           mutateAllPageInfo();
           mutateAllPageInfo();
@@ -130,7 +128,7 @@ export const BookmarkFolderTree: React.FC<Props> = (props: Props) => {
 
 
 
 
   return (
   return (
-    <div className={`grw-folder-tree-container ${styles['grw-folder-tree-container']}` } >
+    <div className={`grw-folder-tree-container ${styles['grw-folder-tree-container']}`} >
       <ul className={`grw-foldertree ${styles['grw-foldertree']} list-group px-2 py-2`}>
       <ul className={`grw-foldertree ${styles['grw-foldertree']} list-group px-2 py-2`}>
         {bookmarkFolders?.map((bookmarkFolder) => {
         {bookmarkFolders?.map((bookmarkFolder) => {
           return (
           return (

+ 22 - 23
apps/app/src/components/Navbar/GrowiContextualSubNavigation.tsx

@@ -100,7 +100,7 @@ const PageOperationMenuItems = (props: PageOperationMenuItemsProps): JSX.Element
         <i className="icon-fw grw-page-control-dropdown-icon">
         <i className="icon-fw grw-page-control-dropdown-icon">
           <PresentationIcon />
           <PresentationIcon />
         </i>
         </i>
-        { t('Presentation Mode') }
+        {t('Presentation Mode')}
       </DropdownItem>
       </DropdownItem>
 
 
       {/* Export markdown */}
       {/* Export markdown */}
@@ -141,7 +141,7 @@ const PageOperationMenuItems = (props: PageOperationMenuItemsProps): JSX.Element
         {t('attachment_data')}
         {t('attachment_data')}
       </DropdownItem>
       </DropdownItem>
 
 
-      { !isGuestUser && !isReadOnlyUser && !isSharedUser && (
+      {!isGuestUser && !isReadOnlyUser && !isSharedUser && (
         <NotAvailable isDisabled={isLinkSharingDisabled ?? false} title="Disabled by admin">
         <NotAvailable isDisabled={isLinkSharingDisabled ?? false} title="Disabled by admin">
           <DropdownItem
           <DropdownItem
             onClick={() => openAccessoriesModal(PageAccessoriesModalContents.ShareLink)}
             onClick={() => openAccessoriesModal(PageAccessoriesModalContents.ShareLink)}
@@ -154,7 +154,7 @@ const PageOperationMenuItems = (props: PageOperationMenuItemsProps): JSX.Element
             {t('share_links.share_link_management')}
             {t('share_links.share_link_management')}
           </DropdownItem>
           </DropdownItem>
         </NotAvailable>
         </NotAvailable>
-      ) }
+      )}
     </>
     </>
   );
   );
 };
 };
@@ -181,7 +181,7 @@ const CreateTemplateMenuItems = (props: CreateTemplateMenuItemsProps): JSX.Eleme
         data-testid="open-page-template-modal-btn"
         data-testid="open-page-template-modal-btn"
       >
       >
         <i className="icon-fw icon-magic-wand grw-page-control-dropdown-icon"></i>
         <i className="icon-fw icon-magic-wand grw-page-control-dropdown-icon"></i>
-        { t('template.option_label.create/edit') }
+        {t('template.option_label.create/edit')}
       </DropdownItem>
       </DropdownItem>
     </>
     </>
   );
   );
@@ -262,7 +262,7 @@ const GrowiContextualSubNavigation = (props: GrowiContextualSubNavigationProps):
   const isViewMode = editorMode === EditorMode.View;
   const isViewMode = editorMode === EditorMode.View;
 
 
 
 
-  const tagsUpdatedHandlerForViewMode = useCallback(async(newTags: string[]) => {
+  const tagsUpdatedHandlerForViewMode = useCallback(async (newTags: string[]) => {
     if (currentPage == null) {
     if (currentPage == null) {
       return;
       return;
     }
     }
@@ -293,14 +293,14 @@ const GrowiContextualSubNavigation = (props: GrowiContextualSubNavigationProps):
     }
     }
   }, [currentPathname, router]);
   }, [currentPathname, router]);
 
 
-  const duplicateItemClickedHandler = useCallback(async(page: IPageForPageDuplicateModal) => {
+  const duplicateItemClickedHandler = useCallback(async (page: IPageForPageDuplicateModal) => {
     const duplicatedHandler: OnDuplicatedFunction = (fromPath, toPath) => {
     const duplicatedHandler: OnDuplicatedFunction = (fromPath, toPath) => {
       router.push(toPath);
       router.push(toPath);
     };
     };
     openDuplicateModal(page, { onDuplicated: duplicatedHandler });
     openDuplicateModal(page, { onDuplicated: duplicatedHandler });
   }, [openDuplicateModal, router]);
   }, [openDuplicateModal, router]);
 
 
-  const renameItemClickedHandler = useCallback(async(page: IPageToRenameWithMeta<IPageInfoForEntity>) => {
+  const renameItemClickedHandler = useCallback(async (page: IPageToRenameWithMeta<IPageInfoForEntity>) => {
     const renamedHandler: OnRenamedFunction = () => {
     const renamedHandler: OnRenamedFunction = () => {
       reload();
       reload();
     };
     };
@@ -322,14 +322,13 @@ const GrowiContextualSubNavigation = (props: GrowiContextualSubNavigationProps):
       else if (currentPathname != null) {
       else if (currentPathname != null) {
         router.push(currentPathname);
         router.push(currentPathname);
       }
       }
-      mutatePageInfo();
-      mutateBookmarkFolders();
-      mutateUserBookmarks();
+
+      mutateCurrentPage();
     };
     };
     openDeleteModal([pageWithMeta], { onDeleted: deletedHandler });
     openDeleteModal([pageWithMeta], { onDeleted: deletedHandler });
-  }, [currentPathname, openDeleteModal, router, mutatePageInfo, mutateBookmarkFolders, mutateUserBookmarks]);
+  }, [currentPathname, mutateCurrentPage, openDeleteModal, router]);
 
 
-  const switchContentWidthHandler = useCallback(async(pageId: string, value: boolean) => {
+  const switchContentWidthHandler = useCallback(async (pageId: string, value: boolean) => {
     if (!isSharedPage) {
     if (!isSharedPage) {
       await updateContentWidth(pageId, value);
       await updateContentWidth(pageId, value);
       mutateCurrentPage();
       mutateCurrentPage();
@@ -347,9 +346,9 @@ const GrowiContextualSubNavigation = (props: GrowiContextualSubNavigationProps):
         return (
         return (
           <>
           <>
             {!isReadOnlyUser
             {!isReadOnlyUser
-            && <CreateTemplateMenuItems
-              onClickTemplateMenuItem={templateMenuItemClickHandler}
-            />
+              && <CreateTemplateMenuItems
+                onClickTemplateMenuItem={templateMenuItemClickHandler}
+              />
             }
             }
           </>);
           </>);
       }
       }
@@ -374,9 +373,9 @@ const GrowiContextualSubNavigation = (props: GrowiContextualSubNavigationProps):
       <>
       <>
         <div className="d-flex">
         <div className="d-flex">
           <div className="d-flex flex-column align-items-end justify-content-center py-md-2" style={{ gap: `${isCompactMode ? '5px' : '7px'}` }}>
           <div className="d-flex flex-column align-items-end justify-content-center py-md-2" style={{ gap: `${isCompactMode ? '5px' : '7px'}` }}>
-            { isViewMode && (
+            {isViewMode && (
               <div className="h-50">
               <div className="h-50">
-                { pageId != null && (
+                {pageId != null && (
                   <SubNavButtons
                   <SubNavButtons
                     isCompactMode={isCompactMode}
                     isCompactMode={isCompactMode}
                     pageId={pageId}
                     pageId={pageId}
@@ -392,9 +391,9 @@ const GrowiContextualSubNavigation = (props: GrowiContextualSubNavigationProps):
                     onClickDeleteMenuItem={deleteItemClickedHandler}
                     onClickDeleteMenuItem={deleteItemClickedHandler}
                     onClickSwitchContentWidth={switchContentWidthHandler}
                     onClickSwitchContentWidth={switchContentWidthHandler}
                   />
                   />
-                ) }
+                )}
               </div>
               </div>
-            ) }
+            )}
             {isAbleToChangeEditorMode && (
             {isAbleToChangeEditorMode && (
               <PageEditorModeManager
               <PageEditorModeManager
                 onPageEditorModeButtonClicked={viewType => mutateEditorMode(viewType)}
                 onPageEditorModeButtonClicked={viewType => mutateEditorMode(viewType)}
@@ -403,22 +402,22 @@ const GrowiContextualSubNavigation = (props: GrowiContextualSubNavigationProps):
               />
               />
             )}
             )}
           </div>
           </div>
-          { (isAbleToShowPageAuthors && !isCompactMode && !isUsersHomePage(path ?? '')) && (
+          {(isAbleToShowPageAuthors && !isCompactMode && !isUsersHomePage(path ?? '')) && (
             <ul className={`${AuthorInfoStyles['grw-author-info']} text-nowrap border-left d-none d-lg-block d-edit-none py-2 pl-4 mb-0 ml-3`}>
             <ul className={`${AuthorInfoStyles['grw-author-info']} text-nowrap border-left d-none d-lg-block d-edit-none py-2 pl-4 mb-0 ml-3`}>
               <li className="pb-1">
               <li className="pb-1">
-                { currentPage != null
+                {currentPage != null
                   ? <AuthorInfo user={currentPage.creator as IUser} date={currentPage.createdAt} mode="create" locate="subnav" />
                   ? <AuthorInfo user={currentPage.creator as IUser} date={currentPage.createdAt} mode="create" locate="subnav" />
                   : <AuthorInfoSkeleton />
                   : <AuthorInfoSkeleton />
                 }
                 }
               </li>
               </li>
               <li className="mt-1 pt-1 border-top">
               <li className="mt-1 pt-1 border-top">
-                { currentPage != null
+                {currentPage != null
                   ? <AuthorInfo user={currentPage.lastUpdateUser as IUser} date={currentPage.updatedAt} mode="update" locate="subnav" />
                   ? <AuthorInfo user={currentPage.lastUpdateUser as IUser} date={currentPage.updatedAt} mode="update" locate="subnav" />
                   : <AuthorInfoSkeleton />
                   : <AuthorInfoSkeleton />
                 }
                 }
               </li>
               </li>
             </ul>
             </ul>
-          ) }
+          )}
         </div>
         </div>
 
 
         {path != null && currentUser != null && !isReadOnlyUser && (
         {path != null && currentUser != null && !isReadOnlyUser && (

+ 3 - 5
apps/app/src/components/Navbar/SubNavButtons.tsx

@@ -13,7 +13,6 @@ import {
 import { useIsGuestUser, useIsReadOnlyUser } from '~/stores/context';
 import { useIsGuestUser, useIsReadOnlyUser } from '~/stores/context';
 import { IPageForPageDuplicateModal } from '~/stores/modal';
 import { IPageForPageDuplicateModal } from '~/stores/modal';
 
 
-import { useSWRBookmarkInfo } from '../../stores/bookmark';
 import { useSWRxPageInfo } from '../../stores/page';
 import { useSWRxPageInfo } from '../../stores/page';
 import { useSWRxUsersList } from '../../stores/user';
 import { useSWRxUsersList } from '../../stores/user';
 import { BookmarkButtons } from '../BookmarkButtons';
 import { BookmarkButtons } from '../BookmarkButtons';
@@ -94,8 +93,6 @@ const SubNavButtonsSubstance = (props: SubNavButtonsSubstanceProps): JSX.Element
 
 
   const { mutate: mutatePageInfo } = useSWRxPageInfo(pageId, shareLinkId);
   const { mutate: mutatePageInfo } = useSWRxPageInfo(pageId, shareLinkId);
 
 
-  const { data: bookmarkInfo } = useSWRBookmarkInfo(pageId);
-
   const likerIds = isIPageInfoForEntity(pageInfo) ? (pageInfo.likerIds ?? []).slice(0, 15) : [];
   const likerIds = isIPageInfoForEntity(pageInfo) ? (pageInfo.likerIds ?? []).slice(0, 15) : [];
   const seenUserIds = isIPageInfoForEntity(pageInfo) ? (pageInfo.seenUserIds ?? []).slice(0, 15) : [];
   const seenUserIds = isIPageInfoForEntity(pageInfo) ? (pageInfo.seenUserIds ?? []).slice(0, 15) : [];
 
 
@@ -227,9 +224,10 @@ const SubNavButtonsSubstance = (props: SubNavButtonsSubstanceProps): JSX.Element
       )}
       )}
       {revisionId != null && (
       {revisionId != null && (
         <BookmarkButtons
         <BookmarkButtons
+          pageId={pageId}
+          isBookmarked={pageInfo.isBookmarked}
+          bookmarkCount={pageInfo.bookmarkCount}
           hideTotalNumber={isCompactMode}
           hideTotalNumber={isCompactMode}
-          bookmarkedUsers={bookmarkInfo?.bookmarkedUsers}
-          bookmarkInfo={bookmarkInfo}
         />
         />
       )}
       )}
       {revisionId != null && !isCompactMode && (
       {revisionId != null && !isCompactMode && (

+ 10 - 10
apps/app/src/components/PageAlert/TrashPageAlert.tsx

@@ -9,7 +9,7 @@ import { unlink } from '~/client/services/page-operation';
 import { toastError } from '~/client/util/toastr';
 import { toastError } from '~/client/util/toastr';
 import { usePageDeleteModal, usePutBackPageModal } from '~/stores/modal';
 import { usePageDeleteModal, usePutBackPageModal } from '~/stores/modal';
 import {
 import {
-  useCurrentPagePath, useSWRxPageInfo, useSWRxCurrentPage, useIsTrashPage,
+  useCurrentPagePath, useSWRxPageInfo, useSWRxCurrentPage, useIsTrashPage, useSWRMUTxCurrentPage,
 } from '~/stores/page';
 } from '~/stores/page';
 import { useIsAbleToShowTrashPageManagementButtons } from '~/stores/ui';
 import { useIsAbleToShowTrashPageManagementButtons } from '~/stores/ui';
 import { useCurrentUser } from '~/stores/context';
 import { useCurrentUser } from '~/stores/context';
@@ -36,13 +36,12 @@ export const TrashPageAlert = (): JSX.Element => {
   const pagePath = pageData?.path;
   const pagePath = pageData?.path;
   const { data: pageInfo } = useSWRxPageInfo(pageId ?? null);
   const { data: pageInfo } = useSWRxPageInfo(pageId ?? null);
 
 
-
   const { open: openDeleteModal } = usePageDeleteModal();
   const { open: openDeleteModal } = usePageDeleteModal();
   const { open: openPutBackPageModal } = usePutBackPageModal();
   const { open: openPutBackPageModal } = usePutBackPageModal();
   const { data: currentPagePath } = useCurrentPagePath();
   const { data: currentPagePath } = useCurrentPagePath();
-  const { data: currentUser } = useCurrentUser();
-  const { mutate: mutateBookmarkFolders } = useSWRxBookmarkFolderAndChild(currentUser?._id);
-  const { mutate: mutateUserBookmarks } = useSWRxUserBookmarks(currentUser?._id);
+
+  const { trigger: mutateCurrentPage } = useSWRMUTxCurrentPage();
+
   const deleteUser = pageData?.deleteUser;
   const deleteUser = pageData?.deleteUser;
   const deletedAt = pageData?.deletedAt ? format(new Date(pageData?.deletedAt), 'yyyy/MM/dd HH:mm') : '';
   const deletedAt = pageData?.deletedAt ? format(new Date(pageData?.deletedAt), 'yyyy/MM/dd HH:mm') : '';
   const revisionId = pageData?.revision?._id;
   const revisionId = pageData?.revision?._id;
@@ -61,13 +60,14 @@ export const TrashPageAlert = (): JSX.Element => {
         mutateBookmarkFolders();
         mutateBookmarkFolders();
         mutateUserBookmarks();
         mutateUserBookmarks();
         router.push(`/${pageId}`);
         router.push(`/${pageId}`);
+        mutateCurrentPage();
       }
       }
       catch (err) {
       catch (err) {
         toastError(err);
         toastError(err);
       }
       }
     };
     };
     openPutBackPageModal({ pageId, path: pagePath }, { onPutBacked: putBackedHandler });
     openPutBackPageModal({ pageId, path: pagePath }, { onPutBacked: putBackedHandler });
-  }, [currentPagePath, openPutBackPageModal, pageId, pagePath, router, mutateBookmarkFolders, mutateUserBookmarks]);
+  }, [currentPagePath, mutateCurrentPage, openPutBackPageModal, pageId, pagePath, router]);
 
 
   const openPageDeleteModalHandler = useCallback(() => {
   const openPageDeleteModalHandler = useCallback(() => {
     if (pageId === undefined || revisionId === undefined || pagePath === undefined) {
     if (pageId === undefined || revisionId === undefined || pagePath === undefined) {
@@ -94,7 +94,7 @@ export const TrashPageAlert = (): JSX.Element => {
           data-toggle="modal"
           data-toggle="modal"
           data-testid="put-back-button"
           data-testid="put-back-button"
         >
         >
-          <i className="icon-action-undo" aria-hidden="true"></i> { t('Put Back') }
+          <i className="icon-action-undo" aria-hidden="true"></i> {t('Put Back')}
         </button>
         </button>
         <button
         <button
           type="button"
           type="button"
@@ -102,7 +102,7 @@ export const TrashPageAlert = (): JSX.Element => {
           disabled={!(pageInfo?.isAbleToDeleteCompletely ?? false)}
           disabled={!(pageInfo?.isAbleToDeleteCompletely ?? false)}
           onClick={openPageDeleteModalHandler}
           onClick={openPageDeleteModalHandler}
         >
         >
-          <i className="icon-fire" aria-hidden="true"></i> { t('Delete Completely') }
+          <i className="icon-fire" aria-hidden="true"></i> {t('Delete Completely')}
         </button>
         </button>
       </>
       </>
     );
     );
@@ -120,11 +120,11 @@ export const TrashPageAlert = (): JSX.Element => {
           <br />
           <br />
           <UserPicture user={deleteUser} />
           <UserPicture user={deleteUser} />
           <span className="ml-2">
           <span className="ml-2">
-            Deleted by { deleteUser?.name } at <span data-vrt-blackout-datetime>{deletedAt ?? pageData?.updatedAt}</span>
+            Deleted by {deleteUser?.name} at <span data-vrt-blackout-datetime>{deletedAt ?? pageData?.updatedAt}</span>
           </span>
           </span>
         </div>
         </div>
         <div className="pt-1 d-flex align-items-end align-items-lg-center">
         <div className="pt-1 d-flex align-items-end align-items-lg-center">
-          { isAbleToShowTrashPageManagementButtons && renderTrashPageManagementButtons()}
+          {isAbleToShowTrashPageManagementButtons && renderTrashPageManagementButtons()}
         </div>
         </div>
       </div>
       </div>
     </>
     </>

+ 1 - 0
apps/app/src/components/PageDuplicateModal.tsx

@@ -252,6 +252,7 @@ const PageDuplicateModal = (): JSX.Element => {
         <button
         <button
           type="button"
           type="button"
           className="btn btn-primary"
           className="btn btn-primary"
+          data-testid="btn-duplicate"
           onClick={duplicate}
           onClick={duplicate}
           disabled={!submitButtonEnabled}
           disabled={!submitButtonEnabled}
         >
         >

+ 10 - 10
apps/app/src/components/PageEditor.tsx

@@ -132,8 +132,6 @@ const PageEditor = React.memo((): JSX.Element => {
   const markdownToSave = useRef<string>(initialValue);
   const markdownToSave = useRef<string>(initialValue);
   const [markdownToPreview, setMarkdownToPreview] = useState<string>(initialValue);
   const [markdownToPreview, setMarkdownToPreview] = useState<string>(initialValue);
 
 
-  const [isPageCreatedWithAttachmentUpload, setIsPageCreatedWithAttachmentUpload] = useState(false);
-
   const { data: socket } = useGlobalSocket();
   const { data: socket } = useGlobalSocket();
 
 
   const { mutate: mutateIsConflict } = useIsConflict();
   const { mutate: mutateIsConflict } = useIsConflict();
@@ -327,7 +325,6 @@ const PageEditor = React.memo((): JSX.Element => {
       // Not using 'mutateGrant' to inherit the grant of the parent page
       // Not using 'mutateGrant' to inherit the grant of the parent page
       if (res.pageCreated) {
       if (res.pageCreated) {
         logger.info('Page is created', res.page._id);
         logger.info('Page is created', res.page._id);
-        setIsPageCreatedWithAttachmentUpload(true);
         globalEmitter.emit('resetInitializedHackMdStatus');
         globalEmitter.emit('resetInitializedHackMdStatus');
         mutateIsLatestRevision(true);
         mutateIsLatestRevision(true);
         await mutateCurrentPageId(res.page._id);
         await mutateCurrentPageId(res.page._id);
@@ -522,14 +519,17 @@ const PageEditor = React.memo((): JSX.Element => {
 
 
   // when transitioning to a different page, if the initialValue is the same,
   // when transitioning to a different page, if the initialValue is the same,
   // UnControlled CodeMirror value does not reset, so explicitly set the value to initialValue
   // UnControlled CodeMirror value does not reset, so explicitly set the value to initialValue
-  // Also, if an attachment is uploaded and a new page is created,
-  // "useCurrentPagePath" changes, but no page transition is made, so nothing is done.
+  const onRouterChangeComplete = useCallback(() => {
+    editorRef.current?.setValue(initialValue);
+    editorRef.current?.setCaretLine(0);
+  }, [initialValue]);
+
   useEffect(() => {
   useEffect(() => {
-    if (currentPagePath != null && !isPageCreatedWithAttachmentUpload) {
-      editorRef.current?.setValue(initialValue);
-    }
-    setIsPageCreatedWithAttachmentUpload(false);
-  }, [currentPagePath, initialValue, isPageCreatedWithAttachmentUpload]);
+    router.events.on('routeChangeComplete', onRouterChangeComplete);
+    return () => {
+      router.events.off('routeChangeComplete', onRouterChangeComplete);
+    };
+  }, [onRouterChangeComplete, router.events]);
 
 
   if (!isEditable) {
   if (!isEditable) {
     return <></>;
     return <></>;

+ 30 - 32
apps/app/src/components/PageList/PageListItemL.tsx

@@ -24,13 +24,13 @@ import {
   OnDuplicatedFunction, OnRenamedFunction, OnDeletedFunction, OnPutBackedFunction,
   OnDuplicatedFunction, OnRenamedFunction, OnDeletedFunction, OnPutBackedFunction,
 } from '~/interfaces/ui';
 } from '~/interfaces/ui';
 import LinkedPagePath from '~/models/linked-page-path';
 import LinkedPagePath from '~/models/linked-page-path';
-import { useSWRBookmarkInfo, useSWRxUserBookmarks } from '~/stores/bookmark';
+import { useSWRMUTxCurrentUserBookmarks } from '~/stores/bookmark';
 import {
 import {
   usePageRenameModal, usePageDuplicateModal, usePageDeleteModal, usePutBackPageModal,
   usePageRenameModal, usePageDuplicateModal, usePageDeleteModal, usePutBackPageModal,
 } from '~/stores/modal';
 } from '~/stores/modal';
 import { useIsDeviceSmallerThanLg } from '~/stores/ui';
 import { useIsDeviceSmallerThanLg } from '~/stores/ui';
 
 
-import { useSWRxPageInfo } from '../../stores/page';
+import { useSWRMUTxPageInfo, useSWRxPageInfo } from '../../stores/page';
 import { ForceHideMenuItems, PageItemControl } from '../Common/Dropdown/PageItemControl';
 import { ForceHideMenuItems, PageItemControl } from '../Common/Dropdown/PageItemControl';
 import PagePathHierarchicalLink from '../PagePathHierarchicalLink';
 import PagePathHierarchicalLink from '../PagePathHierarchicalLink';
 import { useCurrentUser } from '~/stores/context';
 import { useCurrentUser } from '~/stores/context';
@@ -94,10 +94,8 @@ const PageListItemLSubstance: ForwardRefRenderFunction<ISelectable, Props> = (pr
   const { data: currentUser } = useCurrentUser();
   const { data: currentUser } = useCurrentUser();
   const shouldFetch = isSelected && (pageData != null || pageMeta != null);
   const shouldFetch = isSelected && (pageData != null || pageMeta != null);
   const { data: pageInfo } = useSWRxPageInfo(shouldFetch ? pageData?._id : null);
   const { data: pageInfo } = useSWRxPageInfo(shouldFetch ? pageData?._id : null);
-  const { mutate: mutateUserBookmark } = useSWRxUserBookmarks(currentUser?._id);
-  const { mutate: mutateBookmarkInfo } = useSWRBookmarkInfo(pageData?._id);
-  const { mutate: mutatePageInfo } = useSWRxPageInfo(pageData._id);
-  const { mutate: mutateBookmarkFolders } = useSWRxBookmarkFolderAndChild(currentUser?._id);
+  const { trigger: mutatePageInfo } = useSWRMUTxPageInfo(pageData?._id ?? null);
+  const { trigger: mutateCurrentUserBookmarks } = useSWRMUTxCurrentUserBookmarks();
   const elasticSearchResult = isIPageSearchMeta(pageMeta) ? pageMeta.elasticSearchResult : null;
   const elasticSearchResult = isIPageSearchMeta(pageMeta) ? pageMeta.elasticSearchResult : null;
   const revisionShortBody = isIPageInfoForListing(pageMeta) ? pageMeta.revisionShortBody : null;
   const revisionShortBody = isIPageInfoForListing(pageMeta) ? pageMeta.revisionShortBody : null;
 
 
@@ -131,11 +129,11 @@ const PageListItemLSubstance: ForwardRefRenderFunction<ISelectable, Props> = (pr
     }
     }
   }, [isDeviceSmallerThanLg, onClickItem, pageData._id]);
   }, [isDeviceSmallerThanLg, onClickItem, pageData._id]);
 
 
-  const bookmarkMenuItemClickHandler = async(_pageId: string, _newValue: boolean): Promise<void> => {
+  const bookmarkMenuItemClickHandler = async (_pageId: string, _newValue: boolean): Promise<void> => {
     const bookmarkOperation = _newValue ? bookmark : unbookmark;
     const bookmarkOperation = _newValue ? bookmark : unbookmark;
     await bookmarkOperation(_pageId);
     await bookmarkOperation(_pageId);
-    mutateUserBookmark();
-    mutateBookmarkInfo();
+    mutateCurrentUserBookmarks();
+    mutatePageInfo();
   };
   };
 
 
   const duplicateMenuItemClickHandler = useCallback(() => {
   const duplicateMenuItemClickHandler = useCallback(() => {
@@ -159,10 +157,10 @@ const PageListItemLSubstance: ForwardRefRenderFunction<ISelectable, Props> = (pr
     openDeleteModal([pageToDelete], { onDeleted: onPageDeleted });
     openDeleteModal([pageToDelete], { onDeleted: onPageDeleted });
   }, [pageData, openDeleteModal, onPageDeleted]);
   }, [pageData, openDeleteModal, onPageDeleted]);
 
 
-  const revertMenuItemClickHandler = useCallback(async() => {
+  const revertMenuItemClickHandler = useCallback(async () => {
     const { _id: pageId, path } = pageData;
     const { _id: pageId, path } = pageData;
 
 
-    const putBackedHandler = async(path) => {
+    const putBackedHandler = async (path) => {
       try {
       try {
         // pageData path should be `/trash/fuga` (`/trash` should be included to the prefix)
         // pageData path should be `/trash/fuga` (`/trash` should be included to the prefix)
         await unlink(pageData.path);
         await unlink(pageData.path);
@@ -224,9 +222,9 @@ const PageListItemLSubstance: ForwardRefRenderFunction<ISelectable, Props> = (pr
                 linkedPagePath={linkedPagePathFormer}
                 linkedPagePath={linkedPagePathFormer}
                 linkedPagePathByHtml={linkedPagePathHighlightedFormer}
                 linkedPagePathByHtml={linkedPagePathHighlightedFormer}
               />
               />
-              { showPageUpdatedTime && (
+              {showPageUpdatedTime && (
                 <span className="page-list-updated-at text-muted">Last update: {lastUpdateDate}</span>
                 <span className="page-list-updated-at text-muted">Last update: {lastUpdateDate}</span>
-              ) }
+              )}
             </div>
             </div>
             <div className="d-flex align-items-center mb-1">
             <div className="d-flex align-items-center mb-1">
               {/* Picture */}
               {/* Picture */}
@@ -265,32 +263,32 @@ const PageListItemLSubstance: ForwardRefRenderFunction<ISelectable, Props> = (pr
 
 
               {/* doropdown icon includes page control buttons */}
               {/* doropdown icon includes page control buttons */}
               {hasBrowsingRights
               {hasBrowsingRights
-              && <div className="ml-auto">
-                <PageItemControl
-                  alignRight
-                  pageId={pageData._id}
-                  pageInfo={isIPageInfoForListing(pageMeta) ? pageMeta : undefined}
-                  isEnableActions={isEnableActions}
-                  isReadOnlyUser={isReadOnlyUser}
-                  forceHideMenuItems={forceHideMenuItems}
-                  onClickBookmarkMenuItem={bookmarkMenuItemClickHandler}
-                  onClickRenameMenuItem={renameMenuItemClickHandler}
-                  onClickDuplicateMenuItem={duplicateMenuItemClickHandler}
-                  onClickDeleteMenuItem={deleteMenuItemClickHandler}
-                  onClickRevertMenuItem={revertMenuItemClickHandler}
-                />
-              </div>
+                && <div className="ml-auto">
+                  <PageItemControl
+                    alignRight
+                    pageId={pageData._id}
+                    pageInfo={isIPageInfoForListing(pageMeta) ? pageMeta : undefined}
+                    isEnableActions={isEnableActions}
+                    isReadOnlyUser={isReadOnlyUser}
+                    forceHideMenuItems={forceHideMenuItems}
+                    onClickBookmarkMenuItem={bookmarkMenuItemClickHandler}
+                    onClickRenameMenuItem={renameMenuItemClickHandler}
+                    onClickDuplicateMenuItem={duplicateMenuItemClickHandler}
+                    onClickDeleteMenuItem={deleteMenuItemClickHandler}
+                    onClickRevertMenuItem={revertMenuItemClickHandler}
+                  />
+                </div>
               }
               }
             </div>
             </div>
             <div className="page-list-snippet py-1">
             <div className="page-list-snippet py-1">
               <Clamp lines={2}>
               <Clamp lines={2}>
-                { elasticSearchResult != null && elasticSearchResult.snippet != null && (
+                {elasticSearchResult != null && elasticSearchResult.snippet != null && (
                   // eslint-disable-next-line react/no-danger
                   // eslint-disable-next-line react/no-danger
                   <div dangerouslySetInnerHTML={{ __html: elasticSearchResult.snippet }}></div>
                   <div dangerouslySetInnerHTML={{ __html: elasticSearchResult.snippet }}></div>
-                ) }
-                { revisionShortBody != null && (
+                )}
+                {revisionShortBody != null && (
                   <div data-testid="revision-short-body-in-page-list-item-L">{revisionShortBody}</div>
                   <div data-testid="revision-short-body-in-page-list-item-L">{revisionShortBody}</div>
-                ) }
+                )}
                 {
                 {
                   !hasBrowsingRights && (
                   !hasBrowsingRights && (
                     <>
                     <>

+ 1 - 1
apps/app/src/components/SavePageControls/GrantSelector.tsx

@@ -137,7 +137,7 @@ const GrantSelector = (props: Props): JSX.Element => {
     }
     }
 
 
     return (
     return (
-      <div className="form-group grw-grant-selector mb-0">
+      <div className="form-group grw-grant-selector mb-0" data-testid="grw-grant-selector">
         <UncontrolledDropdown direction="up">
         <UncontrolledDropdown direction="up">
           <DropdownToggle color={dropdownToggleBtnColor} caret className="d-flex justify-content-between align-items-center" disabled={disabled}>
           <DropdownToggle color={dropdownToggleBtnColor} caret className="d-flex justify-content-between align-items-center" disabled={disabled}>
             {dropdownToggleLabelElm}
             {dropdownToggleLabelElm}

+ 7 - 6
apps/app/src/components/Sidebar/PageTree/Item.tsx

@@ -5,7 +5,7 @@ import React, {
 import nodePath from 'path';
 import nodePath from 'path';
 
 
 import {
 import {
-  pathUtils, pagePathUtils, Nullable, DevidedPagePath,
+  pathUtils, pagePathUtils, Nullable,
 } from '@growi/core';
 } from '@growi/core';
 import { useTranslation } from 'next-i18next';
 import { useTranslation } from 'next-i18next';
 import Link from 'next/link';
 import Link from 'next/link';
@@ -22,8 +22,9 @@ import { NotAvailableForReadOnlyUser } from '~/components/NotAvailableForReadOnl
 import {
 import {
   IPageHasId, IPageInfoAll, IPageToDeleteWithMeta,
   IPageHasId, IPageInfoAll, IPageToDeleteWithMeta,
 } from '~/interfaces/page';
 } from '~/interfaces/page';
-import { useSWRBookmarkInfo, useSWRxUserBookmarks } from '~/stores/bookmark';
+import { useSWRMUTxCurrentUserBookmarks } from '~/stores/bookmark';
 import { IPageForPageDuplicateModal } from '~/stores/modal';
 import { IPageForPageDuplicateModal } from '~/stores/modal';
+import { useSWRMUTxPageInfo } from '~/stores/page';
 import { mutatePageTree, useSWRxPageChildren } from '~/stores/page-listing';
 import { mutatePageTree, useSWRxPageChildren } from '~/stores/page-listing';
 import { usePageTreeDescCountMap } from '~/stores/ui';
 import { usePageTreeDescCountMap } from '~/stores/ui';
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
@@ -124,8 +125,8 @@ const Item: FC<ItemProps> = (props: ItemProps) => {
   const [isCreating, setCreating] = useState(false);
   const [isCreating, setCreating] = useState(false);
 
 
   const { data, mutate: mutateChildren } = useSWRxPageChildren(isOpen ? page._id : null);
   const { data, mutate: mutateChildren } = useSWRxPageChildren(isOpen ? page._id : null);
-  const { mutate: mutateUserBookmarks } = useSWRxUserBookmarks();
-  const { mutate: mutateBookmarkInfo } = useSWRBookmarkInfo(page._id);
+  const { trigger: mutateCurrentUserBookmarks } = useSWRMUTxCurrentUserBookmarks();
+  const { trigger: mutatePageInfo } = useSWRMUTxPageInfo(page._id ?? null);
 
 
   // descendantCount
   // descendantCount
   const { getDescCount } = usePageTreeDescCountMap();
   const { getDescCount } = usePageTreeDescCountMap();
@@ -261,8 +262,8 @@ const Item: FC<ItemProps> = (props: ItemProps) => {
   const bookmarkMenuItemClickHandler = async(_pageId: string, _newValue: boolean): Promise<void> => {
   const bookmarkMenuItemClickHandler = async(_pageId: string, _newValue: boolean): Promise<void> => {
     const bookmarkOperation = _newValue ? bookmark : unbookmark;
     const bookmarkOperation = _newValue ? bookmark : unbookmark;
     await bookmarkOperation(_pageId);
     await bookmarkOperation(_pageId);
-    mutateUserBookmarks();
-    mutateBookmarkInfo();
+    mutateCurrentUserBookmarks();
+    mutatePageInfo();
   };
   };
 
 
   const duplicateMenuItemClickHandler = useCallback((): void => {
   const duplicateMenuItemClickHandler = useCallback((): void => {

+ 19 - 6
apps/app/src/features/growi-plugin/components/Admin/PluginsExtensionPageContents/PluginInstallerForm.tsx

@@ -5,6 +5,7 @@ import { useTranslation } from 'next-i18next';
 import { apiv3Post } from '~/client/util/apiv3-client';
 import { apiv3Post } from '~/client/util/apiv3-client';
 import { toastSuccess, toastError } from '~/client/util/toastr';
 import { toastSuccess, toastError } from '~/client/util/toastr';
 
 
+import type { IGrowiPluginOrigin } from '../../../interfaces';
 import { useSWRxPlugins } from '../../../stores/growi-plugin';
 import { useSWRxPlugins } from '../../../stores/growi-plugin';
 
 
 export const PluginInstallerForm = (): JSX.Element => {
 export const PluginInstallerForm = (): JSX.Element => {
@@ -18,13 +19,13 @@ export const PluginInstallerForm = (): JSX.Element => {
 
 
     const {
     const {
       'pluginInstallerForm[url]': { value: url },
       'pluginInstallerForm[url]': { value: url },
-      // 'pluginInstallerForm[ghBranch]': { value: ghBranch },
+      'pluginInstallerForm[ghBranch]': { value: ghBranch },
       // 'pluginInstallerForm[ghTag]': { value: ghTag },
       // 'pluginInstallerForm[ghTag]': { value: ghTag },
     } = formData;
     } = formData;
 
 
-    const pluginInstallerForm = {
+    const pluginInstallerForm: IGrowiPluginOrigin = {
       url,
       url,
-      // ghBranch,
+      ghBranch,
       // ghTag,
       // ghTag,
     };
     };
 
 
@@ -44,16 +45,28 @@ export const PluginInstallerForm = (): JSX.Element => {
   return (
   return (
     <form role="form" onSubmit={submitHandler}>
     <form role="form" onSubmit={submitHandler}>
       <div className='form-group row'>
       <div className='form-group row'>
-        <label className="text-left text-md-right col-md-3 col-form-label">{t('plugins.repository_url')}</label>
+        <label className="text-left text-md-right col-md-3 col-form-label">{t('plugins.form.label_url')}</label>
         <div className="col-md-6">
         <div className="col-md-6">
           <input
           <input
             className="form-control"
             className="form-control"
             type="text"
             type="text"
             name="pluginInstallerForm[url]"
             name="pluginInstallerForm[url]"
-            placeholder="https://github.com/growi/plugins"
+            placeholder="https://github.com/weseek/growi-plugins-example"
             required
             required
           />
           />
-          <p className="form-text text-muted">{t('plugins.description')}</p>
+          <p className="form-text text-muted">{t('plugins.form.desc_url')}</p>
+        </div>
+      </div>
+      <div className='form-group row'>
+        <label className="text-left text-md-right col-md-3 col-form-label">{t('plugins.form.label_branch')}</label>
+        <div className="col-md-6">
+          <input
+            className="form-control col-md-3"
+            type="text"
+            name="pluginInstallerForm[ghBranch]"
+            placeholder="main"
+          />
+          <p className="form-text text-muted">{t('plugins.form.desc_branch')}</p>
         </div>
         </div>
       </div>
       </div>
 
 

+ 68 - 0
apps/app/src/features/growi-plugin/models/vo/github-url.spec.ts

@@ -0,0 +1,68 @@
+import { GitHubUrl } from './github-url';
+
+describe('GitHubUrl Constructor throws an error when the url string is', () => {
+
+  it.concurrent.each`
+    url
+    ${'//example.com/org/repos'}
+    ${'https://example.com'}
+    ${'https://github.com/org/repos/foo'}
+  `("'$url'", ({ url }) => {
+    // when
+    const caller = () => new GitHubUrl(url);
+
+    // then
+    expect(caller).toThrowError(`The specified URL is invalid. : url='${url}'`);
+  });
+
+});
+
+describe('The constructor is successfully processed', () => {
+
+  it('with http schemed url', () => {
+    // when
+    const githubUrl = new GitHubUrl('http://github.com/org/repos');
+
+    // then
+    expect(githubUrl).not.toBeNull();
+    expect(githubUrl.organizationName).toEqual('org');
+    expect(githubUrl.reposName).toEqual('repos');
+    expect(githubUrl.branchName).toEqual('main');
+  });
+
+  it('with https schemed url', () => {
+    // when
+    const githubUrl = new GitHubUrl('https://github.com/org/repos');
+
+    // then
+    expect(githubUrl).not.toBeNull();
+    expect(githubUrl.organizationName).toEqual('org');
+    expect(githubUrl.reposName).toEqual('repos');
+    expect(githubUrl.branchName).toEqual('main');
+  });
+
+  it('with branchName', () => {
+    // when
+    const githubUrl = new GitHubUrl('https://github.com/org/repos', 'fix/bug');
+
+    // then
+    expect(githubUrl).not.toBeNull();
+    expect(githubUrl.organizationName).toEqual('org');
+    expect(githubUrl.reposName).toEqual('repos');
+    expect(githubUrl.branchName).toEqual('fix/bug');
+  });
+
+});
+
+describe('archiveUrl()', () => {
+  it('returns zip url', () => {
+    // setup
+    const githubUrl = new GitHubUrl('https://github.com/org/repos', 'fix/bug');
+
+    // when
+    const { archiveUrl } = githubUrl;
+
+    // then
+    expect(archiveUrl).toEqual('https://github.com/org/repos/archive/refs/heads/fix/bug.zip');
+  });
+});

+ 51 - 0
apps/app/src/features/growi-plugin/models/vo/github-url.ts

@@ -0,0 +1,51 @@
+// https://regex101.com/r/fK2rV3/1
+const githubReposIdPattern = new RegExp(/^\/([^/]+)\/([^/]+)$/);
+
+export class GitHubUrl {
+
+  private _organizationName: string;
+
+  private _reposName: string;
+
+  private _branchName: string;
+
+  get organizationName(): string {
+    return this._organizationName;
+  }
+
+  get reposName(): string {
+    return this._reposName;
+  }
+
+  get branchName(): string {
+    return this._branchName;
+  }
+
+  get archiveUrl(): string {
+    const ghUrl = new URL(`/${this.organizationName}/${this.reposName}/archive/refs/heads/${this.branchName}.zip`, 'https://github.com');
+    return ghUrl.toString();
+  }
+
+  constructor(url: string, branchName = 'main') {
+
+    let matched;
+    try {
+      const ghUrl = new URL(url);
+
+      matched = ghUrl.pathname.match(githubReposIdPattern);
+
+      if (ghUrl.hostname !== 'github.com' || matched == null) {
+        throw new Error();
+      }
+    }
+    catch (err) {
+      throw new Error(`The specified URL is invalid. : url='${url}'`);
+    }
+
+    this._branchName = branchName;
+
+    this._organizationName = matched[1];
+    this._reposName = matched[2];
+  }
+
+}

+ 17 - 38
apps/app/src/features/growi-plugin/services/growi-plugin.ts

@@ -16,14 +16,12 @@ import type {
   IGrowiPlugin, IGrowiPluginOrigin, IGrowiThemePluginMeta, IGrowiPluginMeta,
   IGrowiPlugin, IGrowiPluginOrigin, IGrowiThemePluginMeta, IGrowiPluginMeta,
 } from '../interfaces';
 } from '../interfaces';
 import { GrowiPlugin } from '../models';
 import { GrowiPlugin } from '../models';
+import { GitHubUrl } from '../models/vo/github-url';
 
 
 const logger = loggerFactory('growi:plugins:plugin-utils');
 const logger = loggerFactory('growi:plugins:plugin-utils');
 
 
 const pluginStoringPath = resolveFromRoot('tmp/plugins');
 const pluginStoringPath = resolveFromRoot('tmp/plugins');
 
 
-// https://regex101.com/r/fK2rV3/1
-const githubReposIdPattern = new RegExp(/^\/([^/]+)\/([^/]+)$/);
-
 const PLUGINS_STATIC_DIR = '/static/plugins'; // configured by express.static
 const PLUGINS_STATIC_DIR = '/static/plugins'; // configured by express.static
 
 
 export type GrowiPluginResourceEntries = [installedPath: string, href: string][];
 export type GrowiPluginResourceEntries = [installedPath: string, href: string][];
@@ -71,26 +69,16 @@ export class GrowiPluginService implements IGrowiPluginService {
           }
           }
 
 
           // TODO: imprv Document version and repository version possibly different.
           // TODO: imprv Document version and repository version possibly different.
-          const ghUrl = new URL(growiPlugin.origin.url);
-          const ghPathname = ghUrl.pathname;
-          // TODO: Branch names can be specified.
-          const ghBranch = 'main';
-          const match = ghPathname.match(githubReposIdPattern);
-          if (ghUrl.hostname !== 'github.com' || match == null) {
-            throw new Error('GitHub repository URL is invalid.');
-          }
-
-          const ghOrganizationName = match[1];
-          const ghReposName = match[2];
+          const ghUrl = new GitHubUrl(growiPlugin.origin.url, growiPlugin.origin.branchName);
+          const { reposName, branchName, archiveUrl } = ghUrl;
 
 
-          const requestUrl = `https://github.com/${ghOrganizationName}/${ghReposName}/archive/refs/heads/${ghBranch}.zip`;
-          const zipFilePath = path.join(pluginStoringPath, `${ghBranch}.zip`);
+          const zipFilePath = path.join(pluginStoringPath, `${branchName}.zip`);
           const unzippedPath = pluginStoringPath;
           const unzippedPath = pluginStoringPath;
-          const unzippedReposPath = path.join(pluginStoringPath, `${ghReposName}-${ghBranch}`);
+          const unzippedReposPath = path.join(pluginStoringPath, `${reposName}-${branchName}`);
 
 
           try {
           try {
             // download github repository to local file system
             // download github repository to local file system
-            await this.download(requestUrl, zipFilePath);
+            await this.download(archiveUrl, zipFilePath);
             await this.unzip(zipFilePath, unzippedPath);
             await this.unzip(zipFilePath, unzippedPath);
             fs.renameSync(unzippedReposPath, pluginPath);
             fs.renameSync(unzippedReposPath, pluginPath);
           }
           }
@@ -114,39 +102,30 @@ export class GrowiPluginService implements IGrowiPluginService {
   * Install a plugin from URL and save it in the DB and file system.
   * Install a plugin from URL and save it in the DB and file system.
   */
   */
   async install(origin: IGrowiPluginOrigin): Promise<string> {
   async install(origin: IGrowiPluginOrigin): Promise<string> {
-    const ghUrl = new URL(origin.url);
-    const ghPathname = ghUrl.pathname;
-    // TODO: Branch names can be specified.
-    const ghBranch = 'main';
-
-    const match = ghPathname.match(githubReposIdPattern);
-    if (ghUrl.hostname !== 'github.com' || match == null) {
-      throw new Error('GitHub repository URL is invalid.');
-    }
-
-    const ghOrganizationName = match[1];
-    const ghReposName = match[2];
-    const installedPath = `${ghOrganizationName}/${ghReposName}`;
+    const ghUrl = new GitHubUrl(origin.url, origin.ghBranch);
+    const {
+      organizationName, reposName, branchName, archiveUrl,
+    } = ghUrl;
+    const installedPath = `${organizationName}/${reposName}`;
 
 
-    const requestUrl = `https://github.com/${ghOrganizationName}/${ghReposName}/archive/refs/heads/${ghBranch}.zip`;
-    const zipFilePath = path.join(pluginStoringPath, `${ghBranch}.zip`);
+    const zipFilePath = path.join(pluginStoringPath, `${branchName}.zip`);
     const unzippedPath = pluginStoringPath;
     const unzippedPath = pluginStoringPath;
-    const unzippedReposPath = path.join(pluginStoringPath, `${ghReposName}-${ghBranch}`);
-    const temporaryReposPath = path.join(pluginStoringPath, ghReposName);
+    const unzippedReposPath = path.join(pluginStoringPath, `${reposName}-${branchName}`);
+    const temporaryReposPath = path.join(pluginStoringPath, reposName);
     const reposStoringPath = path.join(pluginStoringPath, `${installedPath}`);
     const reposStoringPath = path.join(pluginStoringPath, `${installedPath}`);
-    const organizationPath = path.join(pluginStoringPath, ghOrganizationName);
+    const organizationPath = path.join(pluginStoringPath, organizationName);
 
 
 
 
     let plugins: IGrowiPlugin<IGrowiPluginMeta>[];
     let plugins: IGrowiPlugin<IGrowiPluginMeta>[];
 
 
     try {
     try {
       // download github repository to file system's temporary path
       // download github repository to file system's temporary path
-      await this.download(requestUrl, zipFilePath);
+      await this.download(archiveUrl, zipFilePath);
       await this.unzip(zipFilePath, unzippedPath);
       await this.unzip(zipFilePath, unzippedPath);
       fs.renameSync(unzippedReposPath, temporaryReposPath);
       fs.renameSync(unzippedReposPath, temporaryReposPath);
 
 
       // detect plugins
       // detect plugins
-      plugins = await GrowiPluginService.detectPlugins(origin, ghOrganizationName, ghReposName);
+      plugins = await GrowiPluginService.detectPlugins(origin, organizationName, reposName);
 
 
       if (!fs.existsSync(organizationPath)) fs.mkdirSync(organizationPath);
       if (!fs.existsSync(organizationPath)) fs.mkdirSync(organizationPath);
 
 

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

@@ -41,7 +41,8 @@ import {
 import { useEditingMarkdown } from '~/stores/editor';
 import { useEditingMarkdown } from '~/stores/editor';
 import { useHasDraftOnHackmd, usePageIdOnHackmd, useRevisionIdHackmdSynced } from '~/stores/hackmd';
 import { useHasDraftOnHackmd, usePageIdOnHackmd, useRevisionIdHackmdSynced } from '~/stores/hackmd';
 import {
 import {
-  useSWRxCurrentPage, useSWRxIsGrantNormalized, useCurrentPageId, useIsNotFound, useIsLatestRevision, useTemplateTagData, useTemplateBodyData,
+  useSWRxCurrentPage, useSWRMUTxCurrentPage, useSWRxIsGrantNormalized, useCurrentPageId,
+  useIsNotFound, useIsLatestRevision, useTemplateTagData, useTemplateBodyData,
 } from '~/stores/page';
 } from '~/stores/page';
 import { useRedirectFrom } from '~/stores/page-redirect';
 import { useRedirectFrom } from '~/stores/page-redirect';
 import { useRemoteRevisionId } from '~/stores/remote-latest-page';
 import { useRemoteRevisionId } from '~/stores/remote-latest-page';
@@ -57,7 +58,7 @@ import { DisplaySwitcher } from '../components/Page/DisplaySwitcher';
 import type { NextPageWithLayout } from './_app.page';
 import type { NextPageWithLayout } from './_app.page';
 import type { CommonProps } from './utils/commons';
 import type { CommonProps } from './utils/commons';
 import {
 import {
-  getNextI18NextConfig, getServerSideCommonProps, generateCustomTitleForPage, useInitSidebarConfig,
+  getNextI18NextConfig, getServerSideCommonProps, generateCustomTitleForPage, useInitSidebarConfig, skipSSR,
 } from './utils/commons';
 } from './utils/commons';
 
 
 
 
@@ -172,6 +173,7 @@ type Props = CommonProps & {
   adminPreferredIndentSize: number,
   adminPreferredIndentSize: number,
   isIndentSizeForced: boolean,
   isIndentSizeForced: boolean,
   disableLinkSharing: boolean,
   disableLinkSharing: boolean,
+  skipSSR: boolean,
 
 
   grantData?: IPageGrantData,
   grantData?: IPageGrantData,
 
 
@@ -237,9 +239,11 @@ const Page: NextPageWithLayout<Props> = (props: Props) => {
 
 
   useSWRxCurrentPage(pageWithMeta?.data ?? null); // store initial data
   useSWRxCurrentPage(pageWithMeta?.data ?? null); // store initial data
 
 
+  const { trigger: mutateCurrentPage } = useSWRMUTxCurrentPage();
+
   const { mutate: mutateIsNotFound } = useIsNotFound();
   const { mutate: mutateIsNotFound } = useIsNotFound();
 
 
-  const { mutate: mutateCurrentPageId } = useCurrentPageId();
+  const { data: currentPageId, mutate: mutateCurrentPageId } = useCurrentPageId();
 
 
   const { mutate: mutateEditingMarkdown } = useEditingMarkdown();
   const { mutate: mutateEditingMarkdown } = useEditingMarkdown();
   const { mutate: mutateIsLatestRevision } = useIsLatestRevision();
   const { mutate: mutateIsLatestRevision } = useIsLatestRevision();
@@ -262,6 +266,22 @@ const Page: NextPageWithLayout<Props> = (props: Props) => {
     ? _isTrashPage(pageWithMeta.data.path)
     ? _isTrashPage(pageWithMeta.data.path)
     : false;
     : false;
 
 
+
+  useEffect(() => {
+    if (!props.skipSSR) {
+      return;
+    }
+
+    if (currentPageId != null && !props.isNotFound) {
+      const mutatePageData = async() => {
+        const pageData = await mutateCurrentPage();
+        mutateEditingMarkdown(pageData?.revision.body);
+      };
+
+      mutatePageData();
+    }
+  }, [currentPageId, mutateCurrentPage, mutateEditingMarkdown, props.isNotFound, props.skipSSR]);
+
   // sync grant data
   // sync grant data
   useEffect(() => {
   useEffect(() => {
     const grantDataToApply = props.grantData ? props.grantData : grantData?.grantData.currentPageGrant;
     const grantDataToApply = props.grantData ? props.grantData : grantData?.grantData.currentPageGrant;
@@ -464,8 +484,9 @@ async function injectPageData(context: GetServerSidePropsContext, props: Props):
   // populate & check if the revision is latest
   // populate & check if the revision is latest
   if (page != null) {
   if (page != null) {
     page.initLatestRevisionField(revisionId);
     page.initLatestRevisionField(revisionId);
-    await page.populateDataToShowRevision();
     props.isLatestRevision = page.isLatestRevision();
     props.isLatestRevision = page.isLatestRevision();
+    props.skipSSR = await skipSSR(page);
+    await page.populateDataToShowRevision(props.skipSSR); // shouldExcludeBody = skipSSR
   }
   }
 
 
   if (page == null && user != null) {
   if (page == null && user != null) {

+ 20 - 6
apps/app/src/pages/share/[[...path]].page.tsx

@@ -1,4 +1,4 @@
-import React from 'react';
+import React, { useEffect } from 'react';
 
 
 import type { IUserHasId, IPagePopulatedToShowRevision } from '@growi/core';
 import type { IUserHasId, IPagePopulatedToShowRevision } from '@growi/core';
 import type {
 import type {
@@ -22,12 +22,12 @@ import {
   useCurrentUser, useRendererConfig, useIsSearchPage, useCurrentPathname,
   useCurrentUser, useRendererConfig, useIsSearchPage, useCurrentPathname,
   useShareLinkId, useIsSearchServiceConfigured, useIsSearchServiceReachable, useIsSearchScopeChildrenAsDefault, useIsContainerFluid,
   useShareLinkId, useIsSearchServiceConfigured, useIsSearchServiceReachable, useIsSearchScopeChildrenAsDefault, useIsContainerFluid,
 } from '~/stores/context';
 } from '~/stores/context';
-import { useCurrentPageId, useIsNotFound } from '~/stores/page';
+import { useCurrentPageId, useIsNotFound, useSWRMUTxCurrentPage } from '~/stores/page';
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 
 
 import type { NextPageWithLayout } from '../_app.page';
 import type { NextPageWithLayout } from '../_app.page';
 import {
 import {
-  getServerSideCommonProps, generateCustomTitleForPage, getNextI18NextConfig, CommonProps,
+  getServerSideCommonProps, generateCustomTitleForPage, getNextI18NextConfig, CommonProps, skipSSR,
 } from '../utils/commons';
 } from '../utils/commons';
 
 
 const logger = loggerFactory('growi:next-page:share');
 const logger = loggerFactory('growi:next-page:share');
@@ -43,6 +43,7 @@ type Props = CommonProps & {
   isSearchScopeChildrenAsDefault: boolean,
   isSearchScopeChildrenAsDefault: boolean,
   drawioUri: string | null,
   drawioUri: string | null,
   rendererConfig: RendererConfig,
   rendererConfig: RendererConfig,
+  skipSSR: boolean,
 };
 };
 
 
 type IShareLinkRelatedPage = IPagePopulatedToShowRevision & PageDocument;
 type IShareLinkRelatedPage = IPagePopulatedToShowRevision & PageDocument;
@@ -92,6 +93,18 @@ const SharedPage: NextPageWithLayout<Props> = (props: Props) => {
   useIsSearchScopeChildrenAsDefault(props.isSearchScopeChildrenAsDefault);
   useIsSearchScopeChildrenAsDefault(props.isSearchScopeChildrenAsDefault);
   useIsContainerFluid(props.isContainerFluid);
   useIsContainerFluid(props.isContainerFluid);
 
 
+  const { trigger: mutateCurrentPage, data: currentPage } = useSWRMUTxCurrentPage();
+
+  useEffect(() => {
+    if (!props.skipSSR) {
+      return;
+    }
+
+    if (props.shareLink?.relatedPage._id != null && !props.isNotFound) {
+      mutateCurrentPage();
+    }
+  }, [mutateCurrentPage, props.isNotFound, props.shareLink?.relatedPage._id, props.skipSSR]);
+
 
 
   const growiLayoutFluidClass = useCurrentGrowiLayoutFluidClassName(props.shareLinkRelatedPage);
   const growiLayoutFluidClass = useCurrentGrowiLayoutFluidClassName(props.shareLinkRelatedPage);
 
 
@@ -107,7 +120,7 @@ const SharedPage: NextPageWithLayout<Props> = (props: Props) => {
 
 
       <div className={`dynamic-layout-root ${growiLayoutFluidClass} h-100 d-flex flex-column justify-content-between`}>
       <div className={`dynamic-layout-root ${growiLayoutFluidClass} h-100 d-flex flex-column justify-content-between`}>
         <header className="py-0 position-relative">
         <header className="py-0 position-relative">
-          <GrowiContextualSubNavigationForSharedPage page={props.shareLinkRelatedPage} isLinkSharingDisabled={props.disableLinkSharing} />
+          <GrowiContextualSubNavigationForSharedPage page={currentPage ?? props.shareLinkRelatedPage} isLinkSharingDisabled={props.disableLinkSharing} />
         </header>
         </header>
 
 
         <div id="grw-fav-sticky-trigger" className="sticky-top"></div>
         <div id="grw-fav-sticky-trigger" className="sticky-top"></div>
@@ -115,7 +128,7 @@ const SharedPage: NextPageWithLayout<Props> = (props: Props) => {
         <ShareLinkPageView
         <ShareLinkPageView
           pagePath={pagePath}
           pagePath={pagePath}
           rendererConfig={props.rendererConfig}
           rendererConfig={props.rendererConfig}
-          page={props.shareLinkRelatedPage}
+          page={currentPage ?? props.shareLinkRelatedPage}
           shareLink={props.shareLink}
           shareLink={props.shareLink}
           isExpired={props.isExpired}
           isExpired={props.isExpired}
           disableLinkSharing={props.disableLinkSharing}
           disableLinkSharing={props.disableLinkSharing}
@@ -221,7 +234,8 @@ export const getServerSideProps: GetServerSideProps = async(context: GetServerSi
     }
     }
     else {
     else {
       props.isNotFound = false;
       props.isNotFound = false;
-      props.shareLinkRelatedPage = await shareLink.relatedPage.populateDataToShowRevision();
+      props.skipSSR = await skipSSR(shareLink.relatedPage);
+      props.shareLinkRelatedPage = await shareLink.relatedPage.populateDataToShowRevision(props.skipSSR); // shouldExcludeBody = skipSSR
       props.isExpired = shareLink.isExpired();
       props.isExpired = shareLink.isExpired();
       props.shareLink = shareLink.toObject();
       props.shareLink = shareLink.toObject();
     }
     }

+ 24 - 2
apps/app/src/pages/utils/commons.ts

@@ -1,6 +1,6 @@
 import type { ColorScheme, IUserHasId } from '@growi/core';
 import type { ColorScheme, IUserHasId } from '@growi/core';
 import {
 import {
-  DevidedPagePath, Lang, AllLang,
+  DevidedPagePath, Lang, AllLang, isServer,
 } from '@growi/core';
 } from '@growi/core';
 import type { GetServerSideProps, GetServerSidePropsContext } from 'next';
 import type { GetServerSideProps, GetServerSidePropsContext } from 'next';
 import type { SSRConfig, UserConfig } from 'next-i18next';
 import type { SSRConfig, UserConfig } from 'next-i18next';
@@ -11,6 +11,7 @@ import { detectLocaleFromBrowserAcceptLanguage } from '~/client/util/locale-util
 import type { CrowiRequest } from '~/interfaces/crowi-request';
 import type { CrowiRequest } from '~/interfaces/crowi-request';
 import type { ISidebarConfig } from '~/interfaces/sidebar-config';
 import type { ISidebarConfig } from '~/interfaces/sidebar-config';
 import type { IUserUISettings } from '~/interfaces/user-ui-settings';
 import type { IUserUISettings } from '~/interfaces/user-ui-settings';
+import type { PageDocument } from '~/server/models/page';
 import type { UserUISettingsDocument } from '~/server/models/user-ui-settings';
 import type { UserUISettingsDocument } from '~/server/models/user-ui-settings';
 import {
 import {
   useCurrentProductNavWidth, useCurrentSidebarContents, usePreferDrawerModeByUser, usePreferDrawerModeOnEditByUser, useSidebarCollapsed,
   useCurrentProductNavWidth, useCurrentSidebarContents, usePreferDrawerModeByUser, usePreferDrawerModeOnEditByUser, useSidebarCollapsed,
@@ -74,7 +75,7 @@ export const getServerSideCommonProps: GetServerSideProps<CommonProps> = async(c
   const isDefaultLogo = crowi.configManager.getConfig('crowi', 'customize:isDefaultLogo') || !isCustomizedLogoUploaded;
   const isDefaultLogo = crowi.configManager.getConfig('crowi', 'customize:isDefaultLogo') || !isCustomizedLogoUploaded;
   const forcedColorScheme = crowi.customizeService.forcedColorScheme;
   const forcedColorScheme = crowi.customizeService.forcedColorScheme;
 
 
-  // retrieve UserUISettings
+  // retrieve UserUISett ings
   const UserUISettings = getModelSafely<UserUISettingsDocument>('UserUISettings');
   const UserUISettings = getModelSafely<UserUISettingsDocument>('UserUISettings');
   const userUISettings = user != null && UserUISettings != null
   const userUISettings = user != null && UserUISettings != null
     ? await UserUISettings.findOne({ user: user._id }).exec()
     ? await UserUISettings.findOne({ user: user._id }).exec()
@@ -168,3 +169,24 @@ export const useInitSidebarConfig = (sidebarConfig: ISidebarConfig, userUISettin
   useCurrentSidebarContents(userUISettings?.currentSidebarContents);
   useCurrentSidebarContents(userUISettings?.currentSidebarContents);
   useCurrentProductNavWidth(userUISettings?.currentProductNavWidth);
   useCurrentProductNavWidth(userUISettings?.currentProductNavWidth);
 };
 };
+
+
+export const skipSSR = async(page: PageDocument): Promise<boolean> => {
+  if (!isServer()) {
+    throw new Error('This method is not available on the client-side');
+  }
+
+  // page document only stores the bodyLength of the latest revision
+  if (!page.isLatestRevision() || page.latestRevisionBodyLength == null) {
+    return true;
+  }
+
+  const { configManager } = await import('~/server/service/config-manager');
+  await configManager.loadConfigs();
+  const ssrMaxRevisionBodyLength = configManager.getConfig('crowi', 'app:ssrMaxRevisionBodyLength');
+  if (ssrMaxRevisionBodyLength < page.latestRevisionBodyLength) {
+    return true;
+  }
+
+  return false;
+};

+ 5 - 4
apps/app/src/server/models/obsolete-page.js

@@ -63,16 +63,17 @@ export const extractToAncestorsPaths = (pagePath) => {
  * populate page (Query or Document) to show revision
  * populate page (Query or Document) to show revision
  * @param {any} page Query or Document
  * @param {any} page Query or Document
  * @param {string} userPublicFields string to set to select
  * @param {string} userPublicFields string to set to select
+ * @param {boolean} shouldExcludeBody boolean indicating whether to include 'revision.body' or not
  */
  */
 /* eslint-disable object-curly-newline, object-property-newline */
 /* eslint-disable object-curly-newline, object-property-newline */
-export const populateDataToShowRevision = (page, userPublicFields) => {
+export const populateDataToShowRevision = (page, userPublicFields, shouldExcludeBody = false) => {
   return page
   return page
     .populate([
     .populate([
       { path: 'lastUpdateUser', model: 'User', select: userPublicFields },
       { path: 'lastUpdateUser', model: 'User', select: userPublicFields },
       { path: 'creator', model: 'User', select: userPublicFields },
       { path: 'creator', model: 'User', select: userPublicFields },
       { path: 'deleteUser', model: 'User', select: userPublicFields },
       { path: 'deleteUser', model: 'User', select: userPublicFields },
       { path: 'grantedGroup', model: 'UserGroup' },
       { path: 'grantedGroup', model: 'UserGroup' },
-      { path: 'revision', model: 'Revision', populate: {
+      { path: 'revision', model: 'Revision', select: shouldExcludeBody ? '-body' : undefined, populate: {
         path: 'author', model: 'User', select: userPublicFields,
         path: 'author', model: 'User', select: userPublicFields,
       } },
       } },
     ]);
     ]);
@@ -233,11 +234,11 @@ export const getPageSchema = (crowi) => {
     }
     }
   };
   };
 
 
-  pageSchema.methods.populateDataToShowRevision = async function() {
+  pageSchema.methods.populateDataToShowRevision = async function(shouldExcludeBody) {
     validateCrowi();
     validateCrowi();
 
 
     const User = crowi.model('User');
     const User = crowi.model('User');
-    return populateDataToShowRevision(this, User.USER_FIELDS_EXCEPT_CONFIDENTIAL);
+    return populateDataToShowRevision(this, User.USER_FIELDS_EXCEPT_CONFIDENTIAL, shouldExcludeBody);
   };
   };
 
 
   pageSchema.methods.populateDataToMakePresentation = async function(revisionId) {
   pageSchema.methods.populateDataToMakePresentation = async function(revisionId) {

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

@@ -93,6 +93,7 @@ const schema = new Schema<PageDocument, PageModel>({
     type: String, required: true, index: true,
     type: String, required: true, index: true,
   },
   },
   revision: { type: ObjectId, ref: 'Revision' },
   revision: { type: ObjectId, ref: 'Revision' },
+  latestRevisionBodyLength: { type: Number },
   status: { type: String, default: STATUS_PUBLISHED, index: true },
   status: { type: String, default: STATUS_PUBLISHED, index: true },
   grant: { type: Number, default: GRANT_PUBLIC, index: true },
   grant: { type: Number, default: GRANT_PUBLIC, index: true },
   grantedUsers: [{ type: ObjectId, ref: 'User' }],
   grantedUsers: [{ type: ObjectId, ref: 'User' }],
@@ -714,6 +715,7 @@ export async function pushRevision(pageData, newRevision, user) {
   await newRevision.save();
   await newRevision.save();
 
 
   pageData.revision = newRevision;
   pageData.revision = newRevision;
+  pageData.latestRevisionBodyLength = newRevision.body.length;
   pageData.lastUpdateUser = user?._id ?? user;
   pageData.lastUpdateUser = user?._id ?? user;
   pageData.updatedAt = Date.now();
   pageData.updatedAt = Date.now();
 
 

+ 8 - 8
apps/app/src/server/routes/apiv3/forgot-password.js

@@ -5,13 +5,13 @@ import { SupportedAction } from '~/interfaces/activity';
 import { generateAddActivityMiddleware } from '~/server/middlewares/add-activity';
 import { generateAddActivityMiddleware } from '~/server/middlewares/add-activity';
 import injectResetOrderByTokenMiddleware from '~/server/middlewares/inject-reset-order-by-token-middleware';
 import injectResetOrderByTokenMiddleware from '~/server/middlewares/inject-reset-order-by-token-middleware';
 import PasswordResetOrder from '~/server/models/password-reset-order';
 import PasswordResetOrder from '~/server/models/password-reset-order';
+import { configManager } from '~/server/service/config-manager';
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 
 
 import { apiV3FormValidator } from '../../middlewares/apiv3-form-validator';
 import { apiV3FormValidator } from '../../middlewares/apiv3-form-validator';
 import httpErrorHandler from '../../middlewares/http-error-handler';
 import httpErrorHandler from '../../middlewares/http-error-handler';
 import { checkForgotPasswordEnabledMiddlewareFactory } from '../forgot-password';
 import { checkForgotPasswordEnabledMiddlewareFactory } from '../forgot-password';
 
 
-
 const logger = loggerFactory('growi:routes:apiv3:forgotPassword'); // eslint-disable-line no-unused-vars
 const logger = loggerFactory('growi:routes:apiv3:forgotPassword'); // eslint-disable-line no-unused-vars
 
 
 const express = require('express');
 const express = require('express');
@@ -22,7 +22,7 @@ const { serializeUserSecurely } = require('../../models/serializers/user-seriali
 const router = express.Router();
 const router = express.Router();
 
 
 module.exports = (crowi) => {
 module.exports = (crowi) => {
-  const { appService, mailService, configManager } = crowi;
+  const { appService, mailService } = crowi;
   const User = crowi.model('User');
   const User = crowi.model('User');
   const path = require('path');
   const path = require('path');
 
 
@@ -30,7 +30,7 @@ module.exports = (crowi) => {
 
 
   const activityEvent = crowi.event('activity');
   const activityEvent = crowi.event('activity');
 
 
-  const minPasswordLength = crowi.configManager.getConfig('crowi', 'app:minPasswordLength');
+  const minPasswordLength = configManager.getConfig('crowi', 'app:minPasswordLength');
 
 
   const validator = {
   const validator = {
     password: [
     password: [
@@ -47,11 +47,11 @@ module.exports = (crowi) => {
 
 
   const checkPassportStrategyMiddleware = checkForgotPasswordEnabledMiddlewareFactory(crowi, true);
   const checkPassportStrategyMiddleware = checkForgotPasswordEnabledMiddlewareFactory(crowi, true);
 
 
-  async function sendPasswordResetEmail(txtFileName, i18n, email, url, expiredAt) {
+  async function sendPasswordResetEmail(templateFileName, locale, email, url, expiredAt) {
     return mailService.send({
     return mailService.send({
       to: email,
       to: email,
       subject: '[GROWI] Password Reset',
       subject: '[GROWI] Password Reset',
-      template: path.join(crowi.localeDir, `${i18n}/notifications/${txtFileName}.txt`),
+      template: path.join(crowi.localeDir, `${locale}/notifications/${templateFileName}.ejs`),
       vars: {
       vars: {
         appTitle: appService.getAppTitle(),
         appTitle: appService.getAppTitle(),
         email,
         email,
@@ -63,7 +63,7 @@ module.exports = (crowi) => {
 
 
   router.post('/', checkPassportStrategyMiddleware, addActivity, async(req, res) => {
   router.post('/', checkPassportStrategyMiddleware, addActivity, async(req, res) => {
     const { email } = req.body;
     const { email } = req.body;
-    const i18n = configManager.getConfig('crowi', 'app:globalLang');
+    const locale = configManager.getConfig('crowi', 'app:globalLang');
     const appUrl = appService.getSiteUrl();
     const appUrl = appService.getSiteUrl();
 
 
     try {
     try {
@@ -71,7 +71,7 @@ module.exports = (crowi) => {
 
 
       // when the user is not found or active
       // when the user is not found or active
       if (user == null || user.status !== 2) {
       if (user == null || user.status !== 2) {
-        await sendPasswordResetEmail('notActiveUser', i18n, email, appUrl);
+        await sendPasswordResetEmail('notActiveUser', locale, email, appUrl);
         return res.apiv3();
         return res.apiv3();
       }
       }
 
 
@@ -81,7 +81,7 @@ module.exports = (crowi) => {
       const grwTzoffsetSec = crowi.appService.getTzoffset() * 60;
       const grwTzoffsetSec = crowi.appService.getTzoffset() * 60;
       const expiredAt = subSeconds(passwordResetOrderData.expiredAt, grwTzoffsetSec);
       const expiredAt = subSeconds(passwordResetOrderData.expiredAt, grwTzoffsetSec);
       const formattedExpiredAt = format(expiredAt, 'yyyy/MM/dd HH:mm');
       const formattedExpiredAt = format(expiredAt, 'yyyy/MM/dd HH:mm');
-      await sendPasswordResetEmail('passwordReset', i18n, email, oneTimeUrl, formattedExpiredAt);
+      await sendPasswordResetEmail('passwordReset', locale, email, oneTimeUrl, formattedExpiredAt);
 
 
       activityEvent.emit('update', res.locals.activity._id, { action: SupportedAction.ACTION_USER_FOGOT_PASSWORD });
       activityEvent.emit('update', res.locals.activity._id, { action: SupportedAction.ACTION_USER_FOGOT_PASSWORD });
 
 

+ 6 - 9
apps/app/src/server/routes/apiv3/user-activation.ts

@@ -7,9 +7,9 @@ import { body, validationResult } from 'express-validator';
 import { SupportedAction } from '~/interfaces/activity';
 import { SupportedAction } from '~/interfaces/activity';
 import { RegistrationMode } from '~/interfaces/registration-mode';
 import { RegistrationMode } from '~/interfaces/registration-mode';
 import UserRegistrationOrder from '~/server/models/user-registration-order';
 import UserRegistrationOrder from '~/server/models/user-registration-order';
+import { configManager } from '~/server/service/config-manager';
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 
 
-
 const logger = loggerFactory('growi:routes:apiv3:user-activation');
 const logger = loggerFactory('growi:routes:apiv3:user-activation');
 
 
 const PASSOWRD_MINIMUM_NUMBER = 8;
 const PASSOWRD_MINIMUM_NUMBER = 8;
@@ -69,7 +69,6 @@ export const completeRegistrationAction = (crowi) => {
   const User = crowi.model('User');
   const User = crowi.model('User');
   const activityEvent = crowi.event('activity');
   const activityEvent = crowi.event('activity');
   const {
   const {
-    configManager,
     aclService,
     aclService,
     appService,
     appService,
     mailService,
     mailService,
@@ -142,7 +141,8 @@ export const completeRegistrationAction = (crowi) => {
           if (isMailerSetup) {
           if (isMailerSetup) {
             const admins = await User.findAdmins();
             const admins = await User.findAdmins();
             const appTitle = appService.getAppTitle();
             const appTitle = appService.getAppTitle();
-            const template = path.join(crowi.localeDir, 'en_US/admin/userWaitingActivation.txt');
+            const locale = configManager.getConfig('crowi', 'app:globalLang');
+            const template = path.join(crowi.localeDir, `${locale}/admin/userWaitingActivation.ejs`);
             const url = appService.getSiteUrl();
             const url = appService.getSiteUrl();
 
 
             sendEmailToAllAdmins(userData, admins, appTitle, mailService, template, url);
             sendEmailToAllAdmins(userData, admins, appTitle, mailService, template, url);
@@ -205,7 +205,6 @@ export const validateRegisterForm = (req, res, next) => {
 
 
 async function makeRegistrationEmailToken(email, crowi) {
 async function makeRegistrationEmailToken(email, crowi) {
   const {
   const {
-    configManager,
     mailService,
     mailService,
     localeDir,
     localeDir,
     appService,
     appService,
@@ -216,8 +215,7 @@ async function makeRegistrationEmailToken(email, crowi) {
     throw Error('mailService is not setup');
     throw Error('mailService is not setup');
   }
   }
 
 
-  const grobalLang = configManager.getConfig('crowi', 'app:globalLang');
-  const i18n = grobalLang;
+  const locale = configManager.getConfig('crowi', 'app:globalLang');
   const appUrl = appService.getSiteUrl();
   const appUrl = appService.getSiteUrl();
 
 
   const userRegistrationOrder = await UserRegistrationOrder.createUserRegistrationOrder(email);
   const userRegistrationOrder = await UserRegistrationOrder.createUserRegistrationOrder(email);
@@ -226,12 +224,11 @@ async function makeRegistrationEmailToken(email, crowi) {
   const formattedExpiredAt = format(expiredAt, 'yyyy/MM/dd HH:mm');
   const formattedExpiredAt = format(expiredAt, 'yyyy/MM/dd HH:mm');
   const url = new URL(`/user-activation/${userRegistrationOrder.token}`, appUrl);
   const url = new URL(`/user-activation/${userRegistrationOrder.token}`, appUrl);
   const oneTimeUrl = url.href;
   const oneTimeUrl = url.href;
-  const txtFileName = 'userActivation';
 
 
   return mailService.send({
   return mailService.send({
     to: email,
     to: email,
     subject: '[GROWI] User Activation',
     subject: '[GROWI] User Activation',
-    template: path.join(localeDir, `${i18n}/notifications/${txtFileName}.txt`),
+    template: path.join(localeDir, `${locale}/notifications/userActivation.ejs`),
     vars: {
     vars: {
       appTitle: appService.getAppTitle(),
       appTitle: appService.getAppTitle(),
       email,
       email,
@@ -248,7 +245,7 @@ export const registerAction = (crowi) => {
     const registerForm = req.body.registerForm || {};
     const registerForm = req.body.registerForm || {};
     const email = registerForm.email;
     const email = registerForm.email;
     const isRegisterableEmail = await User.isRegisterableEmail(email);
     const isRegisterableEmail = await User.isRegisterableEmail(email);
-    const registrationMode = crowi.configManager.getConfig('crowi', 'security:registrationMode') as RegistrationMode;
+    const registrationMode = configManager.getConfig('crowi', 'security:registrationMode') as RegistrationMode;
     const isEmailValid = await User.isEmailValid(email);
     const isEmailValid = await User.isEmailValid(email);
 
 
     if (registrationMode === RegistrationMode.CLOSED) {
     if (registrationMode === RegistrationMode.CLOSED) {

+ 3 - 2
apps/app/src/server/routes/apiv3/users.js

@@ -2,12 +2,12 @@ import { ErrorV3 } from '@growi/core';
 
 
 import { SupportedAction } from '~/interfaces/activity';
 import { SupportedAction } from '~/interfaces/activity';
 import Activity from '~/server/models/activity';
 import Activity from '~/server/models/activity';
+import { configManager } from '~/server/service/config-manager';
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 
 
 import { generateAddActivityMiddleware } from '../../middlewares/add-activity';
 import { generateAddActivityMiddleware } from '../../middlewares/add-activity';
 import { apiV3FormValidator } from '../../middlewares/apiv3-form-validator';
 import { apiV3FormValidator } from '../../middlewares/apiv3-form-validator';
 
 
-
 const logger = loggerFactory('growi:routes:apiv3:users');
 const logger = loggerFactory('growi:routes:apiv3:users');
 
 
 const path = require('path');
 const path = require('path');
@@ -150,6 +150,7 @@ module.exports = (crowi) => {
   const sendEmailByUserList = async(userList) => {
   const sendEmailByUserList = async(userList) => {
     const { appService, mailService } = crowi;
     const { appService, mailService } = crowi;
     const appTitle = appService.getAppTitle();
     const appTitle = appService.getAppTitle();
+    const locale = configManager.getConfig('crowi', 'app:globalLang');
     const failedToSendEmailList = [];
     const failedToSendEmailList = [];
 
 
     for (const user of userList) {
     for (const user of userList) {
@@ -158,7 +159,7 @@ module.exports = (crowi) => {
         await mailService.send({
         await mailService.send({
           to: user.email,
           to: user.email,
           subject: `Invitation to ${appTitle}`,
           subject: `Invitation to ${appTitle}`,
-          template: path.join(crowi.localeDir, 'en_US/admin/userInvitation.txt'),
+          template: path.join(crowi.localeDir, `${locale}/admin/userInvitation.ejs`),
           vars: {
           vars: {
             email: user.email,
             email: user.email,
             password: user.password,
             password: user.password,

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

@@ -1,4 +1,5 @@
 import { SupportedAction, SupportedTargetModel } from '~/interfaces/activity';
 import { SupportedAction, SupportedTargetModel } from '~/interfaces/activity';
+import { configManager } from '~/server/service/config-manager';
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 
 
 // disable all of linting
 // disable all of linting
@@ -10,7 +11,7 @@ module.exports = function(crowi, app) {
   const path = require('path');
   const path = require('path');
   const User = crowi.model('User');
   const User = crowi.model('User');
   const {
   const {
-    configManager, appService, aclService, mailService, activityService,
+    appService, aclService, mailService, activityService,
   } = crowi;
   } = crowi;
   const activityEvent = crowi.event('activity');
   const activityEvent = crowi.event('activity');
 
 
@@ -19,14 +20,14 @@ module.exports = function(crowi, app) {
   async function sendEmailToAllAdmins(userData) {
   async function sendEmailToAllAdmins(userData) {
     // send mails to all admin users (derived from crowi) -- 2020.06.18 Yuki Takei
     // send mails to all admin users (derived from crowi) -- 2020.06.18 Yuki Takei
     const admins = await User.findAdmins();
     const admins = await User.findAdmins();
-
     const appTitle = appService.getAppTitle();
     const appTitle = appService.getAppTitle();
+    const locale = configManager.getConfig('crowi', 'app:globalLang');
 
 
     const promises = admins.map((admin) => {
     const promises = admins.map((admin) => {
       return mailService.send({
       return mailService.send({
         to: admin.email,
         to: admin.email,
         subject: `[${appTitle}:admin] A New User Created and Waiting for Activation`,
         subject: `[${appTitle}:admin] A New User Created and Waiting for Activation`,
-        template: path.join(crowi.localeDir, 'en_US/admin/userWaitingActivation.txt'),
+        template: path.join(crowi.localeDir, `${locale}/admin/userWaitingActivation.ejs`),
         vars: {
         vars: {
           adminUser: admin,
           adminUser: admin,
           createdUser: userData,
           createdUser: userData,

+ 6 - 0
apps/app/src/server/service/config-loader.ts

@@ -682,6 +682,12 @@ const ENV_VAR_NAME_TO_CONFIG_INFO = {
     type: ValueType.STRING,
     type: ValueType.STRING,
     default: null,
     default: null,
   },
   },
+  SSR_MAX_REVISION_BODY_LENGTH: {
+    ns: 'crowi',
+    key: 'app:ssrMaxRevisionBodyLength',
+    type: ValueType.NUMBER,
+    default: 30000,
+  },
 };
 };
 
 
 
 

+ 3 - 2
apps/app/src/server/service/global-notification/global-notification-mail.js

@@ -1,3 +1,4 @@
+import { configManager } from '~/server/service/config-manager';
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 
 
 const logger = loggerFactory('growi:service:GlobalNotificationMailService'); // eslint-disable-line no-unused-vars
 const logger = loggerFactory('growi:service:GlobalNotificationMailService'); // eslint-disable-line no-unused-vars
@@ -50,13 +51,13 @@ class GlobalNotificationMailService {
    * @return  {{ subject: string, template: string, vars: object }}
    * @return  {{ subject: string, template: string, vars: object }}
    */
    */
   generateOption(event, page, triggeredBy, { comment, oldPath }) {
   generateOption(event, page, triggeredBy, { comment, oldPath }) {
-    const defaultLang = this.crowi.configManager.getConfig('crowi', 'app:globalLang');
+    const locale = configManager.getConfig('crowi', 'app:globalLang');
     // validate for all events
     // validate for all events
     if (event == null || page == null || triggeredBy == null) {
     if (event == null || page == null || triggeredBy == null) {
       throw new Error(`invalid vars supplied to GlobalNotificationMailService.generateOption for event ${event}`);
       throw new Error(`invalid vars supplied to GlobalNotificationMailService.generateOption for event ${event}`);
     }
     }
 
 
-    const template = nodePath.join(this.crowi.localeDir, `${defaultLang}/notifications/${event}.txt`);
+    const template = nodePath.join(this.crowi.localeDir, `${locale}/notifications/${event}.ejs`);
 
 
     const path = page.path;
     const path = page.path;
     const appTitle = this.crowi.appService.getAppTitle();
     const appTitle = this.crowi.appService.getAppTitle();

+ 26 - 12
apps/app/src/stores/bookmark.ts

@@ -1,26 +1,24 @@
-import { SWRResponse } from 'swr';
+import { IUser } from '@growi/core';
+import useSWR, { SWRResponse } from 'swr';
 import useSWRImmutable from 'swr/immutable';
 import useSWRImmutable from 'swr/immutable';
+import useSWRMutation, { type SWRMutationResponse } from 'swr/mutation';
+
 
 
 import { IPageHasId } from '~/interfaces/page';
 import { IPageHasId } from '~/interfaces/page';
 
 
 import { apiv3Get } from '../client/util/apiv3-client';
 import { apiv3Get } from '../client/util/apiv3-client';
 import { IBookmarkInfo } from '../interfaces/bookmark-info';
 import { IBookmarkInfo } from '../interfaces/bookmark-info';
 
 
-export const useSWRBookmarkInfo = (pageId: string | null | undefined): SWRResponse<IBookmarkInfo, Error> => {
-  return useSWRImmutable(
+import { useCurrentUser } from './context';
+
+export const useSWRxBookmarkedUsers = (pageId: string | null): SWRResponse<IUser[], Error> => {
+  return useSWR(
     pageId != null ? `/bookmarks/info?pageId=${pageId}` : null,
     pageId != null ? `/bookmarks/info?pageId=${pageId}` : null,
-    endpoint => apiv3Get(endpoint).then((response) => {
-      return {
-        sumOfBookmarks: response.data.sumOfBookmarks,
-        isBookmarked: response.data.isBookmarked,
-        bookmarkedUsers: response.data.bookmarkedUsers,
-        pageId: response.data.pageId,
-      };
-    }),
+    endpoint => apiv3Get<IBookmarkInfo>(endpoint).then(response => response.data.bookmarkedUsers),
   );
   );
 };
 };
 
 
-export const useSWRxUserBookmarks = (userId?: string): SWRResponse<IPageHasId[], Error> => {
+export const useSWRxUserBookmarks = (userId: string | null): SWRResponse<IPageHasId[], Error> => {
   return useSWRImmutable(
   return useSWRImmutable(
     userId != null ? `/bookmarks/${userId}` : null,
     userId != null ? `/bookmarks/${userId}` : null,
     endpoint => apiv3Get(endpoint).then((response) => {
     endpoint => apiv3Get(endpoint).then((response) => {
@@ -33,3 +31,19 @@ export const useSWRxUserBookmarks = (userId?: string): SWRResponse<IPageHasId[],
     }),
     }),
   );
   );
 };
 };
+
+export const useSWRMUTxCurrentUserBookmarks = (): SWRMutationResponse<IPageHasId[], Error> => {
+  const { data: currentUser } = useCurrentUser();
+
+  return useSWRMutation(
+    currentUser != null ? `/bookmarks/${currentUser?._id}` : null,
+    endpoint => apiv3Get(endpoint).then((response) => {
+      const { userRootBookmarks } = response.data;
+      return userRootBookmarks.map((item) => {
+        return {
+          ...item.page,
+        };
+      });
+    }),
+  );
+};

+ 11 - 6
apps/app/src/stores/editor.tsx

@@ -1,13 +1,13 @@
 import { useCallback } from 'react';
 import { useCallback } from 'react';
 
 
 import { Nullable, withUtils, type SWRResponseWithUtils } from '@growi/core';
 import { Nullable, withUtils, type SWRResponseWithUtils } from '@growi/core';
-import { SWRResponse } from 'swr';
+import useSWR, { type SWRResponse } from 'swr';
 import useSWRImmutable from 'swr/immutable';
 import useSWRImmutable from 'swr/immutable';
 
 
 import { apiGet } from '~/client/util/apiv1-client';
 import { apiGet } from '~/client/util/apiv1-client';
 import { apiv3Get, apiv3Put } from '~/client/util/apiv3-client';
 import { apiv3Get, apiv3Put } from '~/client/util/apiv3-client';
-import { IEditorSettings } from '~/interfaces/editor-settings';
-import { SlackChannels } from '~/interfaces/user-trigger-notification';
+import type { IEditorSettings } from '~/interfaces/editor-settings';
+import type { SlackChannels } from '~/interfaces/user-trigger-notification';
 
 
 import {
 import {
   useCurrentUser, useDefaultIndentSize, useIsGuestUser, useIsReadOnlyUser,
   useCurrentUser, useDefaultIndentSize, useIsGuestUser, useIsReadOnlyUser,
@@ -41,7 +41,9 @@ export const useEditorSettings = (): SWRResponseWithUtils<EditorSettingsOperatio
 
 
   const swrResult = useSWRImmutable(
   const swrResult = useSWRImmutable(
     (isGuestUser || isReadOnlyUser) ? null : ['/personal-setting/editor-settings', currentUser?.username],
     (isGuestUser || isReadOnlyUser) ? null : ['/personal-setting/editor-settings', currentUser?.username],
-    ([endpoint]) => apiv3Get(endpoint).then(result => result.data),
+    ([endpoint]) => {
+      return apiv3Get(endpoint).then(result => result.data);
+    },
     {
     {
       // use: [localStorageMiddleware], // store to localStorage for initialization fastly
       // use: [localStorageMiddleware], // store to localStorage for initialization fastly
       // fallbackData: undefined,
       // fallbackData: undefined,
@@ -78,10 +80,13 @@ export const useCurrentIndentSize = (): SWRResponse<number, Error> => {
 */
 */
 export const useSWRxSlackChannels = (currentPagePath: Nullable<string>): SWRResponse<string[], Error> => {
 export const useSWRxSlackChannels = (currentPagePath: Nullable<string>): SWRResponse<string[], Error> => {
   const shouldFetch: boolean = currentPagePath != null;
   const shouldFetch: boolean = currentPagePath != null;
-  return useSWRImmutable(
+  return useSWR(
     shouldFetch ? ['/pages.updatePost', currentPagePath] : null,
     shouldFetch ? ['/pages.updatePost', currentPagePath] : null,
     ([endpoint, path]) => apiGet(endpoint, { path }).then((response: SlackChannels) => response.updatePost),
     ([endpoint, path]) => apiGet(endpoint, { path }).then((response: SlackChannels) => response.updatePost),
-    { fallbackData: [''] },
+    {
+      revalidateOnFocus: false,
+      fallbackData: [''],
+    },
   );
   );
 };
 };
 
 

+ 31 - 9
apps/app/src/stores/page.tsx

@@ -4,20 +4,20 @@ import type {
   IPageInfoForEntity, IPagePopulatedToShowRevision, Nullable, SWRInfinitePageRevisionsResponse,
   IPageInfoForEntity, IPagePopulatedToShowRevision, Nullable, SWRInfinitePageRevisionsResponse,
 } from '@growi/core';
 } from '@growi/core';
 import { Ref, isClient, pagePathUtils } from '@growi/core';
 import { Ref, isClient, pagePathUtils } from '@growi/core';
-import useSWR, { mutate, SWRResponse } from 'swr';
+import useSWR, { mutate, useSWRConfig, type SWRResponse } from 'swr';
 import useSWRImmutable from 'swr/immutable';
 import useSWRImmutable from 'swr/immutable';
-import useSWRInfinite, { SWRInfiniteResponse } from 'swr/infinite';
-import useSWRMutation, { SWRMutationResponse } from 'swr/mutation';
+import useSWRInfinite, { type SWRInfiniteResponse } from 'swr/infinite';
+import useSWRMutation, { type SWRMutationResponse } from 'swr/mutation';
 
 
 import { apiGet } from '~/client/util/apiv1-client';
 import { apiGet } from '~/client/util/apiv1-client';
 import { apiv3Get } from '~/client/util/apiv3-client';
 import { apiv3Get } from '~/client/util/apiv3-client';
-import {
+import type {
   IPageInfo, IPageInfoForOperation,
   IPageInfo, IPageInfoForOperation,
 } from '~/interfaces/page';
 } from '~/interfaces/page';
-import { IRecordApplicableGrant, IResIsGrantNormalized } from '~/interfaces/page-grant';
-import { IRevision, IRevisionHasId } from '~/interfaces/revision';
+import type { IRecordApplicableGrant, IResIsGrantNormalized } from '~/interfaces/page-grant';
+import type { IRevision, IRevisionHasId } from '~/interfaces/revision';
 
 
-import { IPageTagsInfo } from '../interfaces/tag';
+import type { IPageTagsInfo } from '../interfaces/tag';
 
 
 import {
 import {
   useCurrentPathname, useShareLinkId, useIsGuestUser, useIsReadOnlyUser,
   useCurrentPathname, useShareLinkId, useIsGuestUser, useIsReadOnlyUser,
@@ -47,18 +47,22 @@ export const useTemplateBodyData = (initialData?: string): SWRResponse<string, E
   return useStaticSWR<string, Error>('templateBodyData', initialData);
   return useStaticSWR<string, Error>('templateBodyData', initialData);
 };
 };
 
 
+/** "useSWRxCurrentPage" is intended for initial data retrieval only. Use "useSWRMUTxCurrentPage" for revalidation */
 export const useSWRxCurrentPage = (initialData?: IPagePopulatedToShowRevision|null): SWRResponse<IPagePopulatedToShowRevision|null> => {
 export const useSWRxCurrentPage = (initialData?: IPagePopulatedToShowRevision|null): SWRResponse<IPagePopulatedToShowRevision|null> => {
   const key = 'currentPage';
   const key = 'currentPage';
 
 
+  const { cache } = useSWRConfig();
+  const shouldMutate = initialData?._id !== cache.get(key)?.data?._id && initialData !== undefined;
+
   useEffect(() => {
   useEffect(() => {
-    if (initialData !== undefined) {
+    if (shouldMutate) {
       mutate(key, initialData, {
       mutate(key, initialData, {
         optimisticData: initialData,
         optimisticData: initialData,
         populateCache: true,
         populateCache: true,
         revalidate: false,
         revalidate: false,
       });
       });
     }
     }
-  }, [initialData, key]);
+  }, [initialData, key, shouldMutate]);
 
 
   return useSWR(key, null, {
   return useSWR(key, null, {
     keepPreviousData: true,
     keepPreviousData: true,
@@ -157,6 +161,24 @@ export const useSWRxPageInfo = (
   return swrResult;
   return swrResult;
 };
 };
 
 
+export const useSWRMUTxPageInfo = (
+    pageId: string | null | undefined,
+    shareLinkId?: string | null,
+): SWRMutationResponse<IPageInfo | IPageInfoForOperation> => {
+
+  // assign null if shareLinkId is undefined in order to identify SWR key only by pageId
+  const fixedShareLinkId = shareLinkId ?? null;
+
+  const key = useMemo(() => {
+    return pageId != null ? ['/page/info', pageId, fixedShareLinkId] : null;
+  }, [fixedShareLinkId, pageId]);
+
+  return useSWRMutation(
+    key,
+    ([endpoint, pageId, shareLinkId]: [string, string, string|null]) => apiv3Get(endpoint, { pageId, shareLinkId }).then(response => response.data),
+  );
+};
+
 export const useSWRxPageRevision = (pageId: string, revisionId: Ref<IRevision>): SWRResponse<IRevisionHasId> => {
 export const useSWRxPageRevision = (pageId: string, revisionId: Ref<IRevision>): SWRResponse<IRevisionHasId> => {
   const key = [`/revisions/${revisionId}`, pageId, revisionId];
   const key = [`/revisions/${revisionId}`, pageId, revisionId];
   return useSWRImmutable(
   return useSWRImmutable(

+ 0 - 0
apps/app/test/cypress/integration/0-advanced-examples/misc.spec.ts → apps/app/test/cypress/e2e/0-advanced-examples/misc.cy.ts


+ 0 - 0
apps/app/test/cypress/integration/0-advanced-examples/viewport.spec.ts → apps/app/test/cypress/e2e/0-advanced-examples/viewport.cy.ts


+ 0 - 0
apps/app/test/cypress/integration/10-install/10-install--install.spec.ts → apps/app/test/cypress/e2e/10-install/10-install--install.cy.ts


+ 15 - 22
apps/app/test/cypress/integration/20-basic-features/20-basic-features--access-to-page.spec.ts → apps/app/test/cypress/e2e/20-basic-features/20-basic-features--access-to-page.cy.ts

@@ -1,3 +1,16 @@
+function openEditor() {
+  cy.get('#grw-page-editor-mode-manager').as('pageEditorModeManager').should('be.visible');
+  cy.waitUntil(() => {
+    // do
+    cy.get('@pageEditorModeManager').within(() => {
+      cy.get('button:nth-child(2)').click();
+    });
+    // until
+    return cy.get('.layout-root').then($elem => $elem.hasClass('editing'));
+  })
+  cy.get('.CodeMirror').should('be.visible');
+}
+
 context('Access to page', () => {
 context('Access to page', () => {
   const ssPrefix = 'access-to-page-';
   const ssPrefix = 'access-to-page-';
 
 
@@ -72,17 +85,7 @@ context('Access to page', () => {
     const body1 = 'hello';
     const body1 = 'hello';
     cy.visit('/Sandbox/testForUseEditingMarkdown');
     cy.visit('/Sandbox/testForUseEditingMarkdown');
 
 
-    cy.get('#grw-page-editor-mode-manager').as('pageEditorModeManager').should('be.visible');
-    cy.waitUntil(() => {
-      // do
-      cy.get('@pageEditorModeManager').within(() => {
-        cy.get('button:nth-child(2)').click();
-      });
-      // until
-      return cy.get('.layout-root').then($elem => $elem.hasClass('editing'));
-    })
-
-    cy.get('.grw-editor-navbar-bottom').should('be.visible');
+    openEditor();
 
 
     // check edited contents after save
     // check edited contents after save
     cy.get('.CodeMirror').type(body1);
     cy.get('.CodeMirror').type(body1);
@@ -100,17 +103,7 @@ context('Access to page', () => {
 
 
     cy.visit('/Sandbox/testForUseEditingMarkdown');
     cy.visit('/Sandbox/testForUseEditingMarkdown');
 
 
-    cy.get('#grw-page-editor-mode-manager').as('pageEditorModeManager').should('be.visible');
-    cy.waitUntil(() => {
-      // do
-      cy.get('@pageEditorModeManager').within(() => {
-        cy.get('button:nth-child(2)').click();
-      });
-      // until
-      return cy.get('.layout-root').then($elem => $elem.hasClass('editing'));
-    })
-
-    cy.get('.grw-editor-navbar-bottom').should('be.visible');
+    openEditor();
 
 
     // check editing contents with shortcut key
     // check editing contents with shortcut key
     cy.get('.CodeMirror-line').children().first().invoke('text').then((text) => {
     cy.get('.CodeMirror-line').children().first().invoke('text').then((text) => {

+ 0 - 0
apps/app/test/cypress/integration/20-basic-features/20-basic-features--access-to-pagelist.spec.ts → apps/app/test/cypress/e2e/20-basic-features/20-basic-features--access-to-pagelist.cy.ts


+ 0 - 0
apps/app/test/cypress/integration/20-basic-features/20-basic-features--click-page-icons.spec.ts → apps/app/test/cypress/e2e/20-basic-features/20-basic-features--click-page-icons.cy.ts


+ 0 - 0
apps/app/test/cypress/integration/20-basic-features/20-basic-features--sticky-features.spec.ts → apps/app/test/cypress/e2e/20-basic-features/20-basic-features--sticky-features.cy.ts


+ 0 - 181
apps/app/test/cypress/integration/20-basic-features/20-basic-features--use-tools.spec.ts → apps/app/test/cypress/e2e/20-basic-features/20-basic-features--use-tools.cy.ts

@@ -9,130 +9,6 @@ context('Modal for page operation', () => {
     });
     });
   });
   });
 
 
-  it("PageCreateModal is shown and closed successfully", () => {
-    cy.visit('/');
-
-    cy.waitUntil(() => {
-      // do
-      cy.getByTestid('newPageBtn').click({force: true});
-      // wait until
-      return cy.getByTestid('page-create-modal').then($elem => $elem.is(':visible'));
-    });
-
-    cy.getByTestid('page-create-modal').should('be.visible').within(() => {
-      cy.screenshot(`${ssPrefix}new-page-modal-opened`);
-      cy.get('button.close').click();
-    });
-
-    cy.collapseSidebar(true, true);
-    cy.screenshot(`${ssPrefix}page-create-modal-closed`);
-  });
-
-  it("Successfully Create Today's page", () => {
-    const pageName = "Today's page";
-    cy.visit('/');
-
-    cy.waitUntil(() => {
-      // do
-      cy.getByTestid('newPageBtn').click({force: true});
-      // wait until
-      return cy.getByTestid('page-create-modal').then($elem => $elem.is(':visible'));
-    });
-
-    cy.getByTestid('page-create-modal').should('be.visible').within(() => {
-      cy.get('.page-today-input2').type(pageName);
-      cy.screenshot(`${ssPrefix}today-add-page-name`);
-      cy.getByTestid('btn-create-memo').click();
-    });
-
-    cy.getByTestid('page-editor').should('be.visible');
-    cy.getByTestid('save-page-btn').as('save-page-btn').should('be.visible');
-    cy.waitUntil(() => {
-      // do
-      cy.get('@save-page-btn').click();
-      // wait until
-      return cy.get('@save-page-btn').then($elem => $elem.is(':disabled'));
-    });
-    cy.get('.layout-root').should('not.have.class', 'editing');
-
-    cy.collapseSidebar(true);
-    cy.waitUntilSkeletonDisappear();
-    cy.screenshot(`${ssPrefix}create-today-page`);
-  });
-
-  it('Successfully create page under specific path', () => {
-    const pageName = 'child';
-
-    cy.visit('/foo/bar');
-
-    cy.waitUntil(() => {
-      // do
-      cy.getByTestid('newPageBtn').click({force: true});
-      // wait until
-      return cy.get('body').within(() => {
-        return Cypress.$('[data-testid=page-create-modal]').is(':visible');
-      });
-    });
-
-    cy.getByTestid('page-create-modal').should('be.visible').within(() => {
-      cy.get('.rbt-input-main').should('have.value', '/foo/bar/');
-      cy.get('.rbt-input-main').type(pageName);
-      cy.screenshot(`${ssPrefix}under-path-add-page-name`);
-      cy.getByTestid('btn-create-page-under-below').click();
-    });
-
-    cy.getByTestid('page-editor').should('be.visible');
-    cy.getByTestid('save-page-btn').as('save-page-btn').should('be.visible');
-    cy.waitUntil(() => {
-      // do
-      cy.get('@save-page-btn').click();
-      // wait until
-      return cy.get('@save-page-btn').then($elem => $elem.is(':disabled'));
-    });
-    cy.get('.layout-root').should('not.have.class', 'editing');
-
-    cy.getByTestid('grw-contextual-sub-nav').should('be.visible');
-
-    cy.waitUntilSkeletonDisappear();
-    cy.collapseSidebar(true);
-    cy.screenshot(`${ssPrefix}create-page-under-specific-page`);
-  });
-
-  it('Trying to create template page under the root page fail', () => {
-    cy.visit('/');
-
-    cy.waitUntil(() => {
-      // do
-      cy.getByTestid('newPageBtn').click({force: true});
-      // wait until
-      return cy.getByTestid('page-create-modal').then($elem => $elem.is(':visible'));
-    });
-
-    cy.getByTestid('page-create-modal').should('be.visible').within(() => {
-      cy.getByTestid('grw-page-create-modal-path-name').should('have.text', '/');
-
-      cy.get('#template-type').click();
-      cy.get('#template-type').next().find('button:eq(0)').click({force: true});
-      cy.getByTestid('grw-btn-edit-page').should('be.visible').click();
-    });
-    cy.get('.Toastify__toast').should('be.visible');
-    cy.collapseSidebar(true);
-    cy.screenshot(`${ssPrefix}create-template-for-children-error`);
-    cy.get('.Toastify__toast').should('be.visible').within(() => {
-      cy.get('.Toastify__close-button').should('be.visible').click();
-      cy.get('.Toastify__progress-bar').invoke('attr', 'style', 'display: none')
-    });
-
-    cy.getByTestid('page-create-modal').should('be.visible').within(() => {
-      cy.get('#template-type').click();
-      cy.get('#template-type').next().find('button:eq(1)').click({force: true});
-      cy.getByTestid('grw-btn-edit-page').should('be.visible').click();
-    });
-    cy.get('.Toastify__toast').should('be.visible');
-    cy.collapseSidebar(true);
-    cy.screenshot(`${ssPrefix}create-template-for-descendants-error`);
-  });
-
   it('Page Deletion and PutBack is executed successfully', { scrollBehavior: false }, () => {
   it('Page Deletion and PutBack is executed successfully', { scrollBehavior: false }, () => {
     cy.visit('/Sandbox/Bootstrap4');
     cy.visit('/Sandbox/Bootstrap4');
 
 
@@ -342,60 +218,3 @@ context('Tag Oprations', { scrollBehavior: false }, () =>{
   });
   });
 
 
 });
 });
-
-context('Shortcuts', () => {
-  const ssPrefix = 'shortcuts';
-
-  beforeEach(() => {
-    // login
-    cy.fixture("user-admin.json").then(user => {
-      cy.login(user.username, user.password);
-    });
-  });
-
-  it('Successfully updating a page using a shortcut on a previously created page', { scrollBehavior: false }, () => {
-    const body1 = 'hello';
-    const body2 = ' world!';
-    const savePageShortcutKey = '{ctrl+s}';
-
-    cy.visit('/Sandbox/child');
-
-    cy.get('#grw-page-editor-mode-manager').as('pageEditorModeManager').should('be.visible');
-    cy.waitUntil(() => {
-      // do
-      cy.get('@pageEditorModeManager').within(() => {
-        cy.get('button:nth-child(2)').click();
-      });
-      // until
-      return cy.get('.layout-root').then($elem => $elem.hasClass('editing'));
-    })
-
-    cy.get('.grw-editor-navbar-bottom').should('be.visible');
-
-    // 1st
-    cy.get('.CodeMirror').type(body1);
-    cy.get('.CodeMirror').contains(body1);
-    cy.get('.page-editor-preview-body').contains(body1);
-    cy.get('.CodeMirror').type(savePageShortcutKey);
-
-    cy.get('.Toastify__toast').should('be.visible').within(() => {
-      cy.get('.Toastify__close-button').should('be.visible').click();
-      cy.get('.Toastify__progress-bar').invoke('attr', 'style', 'display: none')
-    });
-    cy.screenshot(`${ssPrefix}-update-page-1`);
-
-    cy.get('.Toastify').should('not.be.visible');
-
-    // 2nd
-    cy.get('.CodeMirror').type(body2);
-    cy.get('.CodeMirror').contains(body2);
-    cy.get('.page-editor-preview-body').contains(body2);
-    cy.get('.CodeMirror').type(savePageShortcutKey);
-
-    cy.get('.Toastify__toast').should('be.visible').within(() => {
-      cy.get('.Toastify__close-button').should('be.visible').click();
-      cy.get('.Toastify__progress-bar').invoke('attr', 'style', 'display: none')
-    });
-    cy.screenshot(`${ssPrefix}-update-page-2`);
-  });
-});

+ 0 - 0
apps/app/test/cypress/integration/20-basic-features/20-basic-features--username-mention.spec.ts → apps/app/test/cypress/e2e/20-basic-features/20-basic-features--username-mention.cy.ts


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


+ 0 - 0
apps/app/test/cypress/integration/21-basic-features-for-guest/21-basic-features-for-guest--sticky-for-guest.spec.ts → apps/app/test/cypress/e2e/21-basic-features-for-guest/21-basic-features-for-guest--sticky-for-guest.cy.ts


+ 0 - 0
apps/app/test/cypress/integration/22-sharelink/22-sharelink--access-to-sharelink.spec.ts → apps/app/test/cypress/e2e/22-sharelink/22-sharelink--access-to-sharelink.cy.ts


+ 194 - 0
apps/app/test/cypress/e2e/23-editor/23-editor--saving.cy.ts

@@ -0,0 +1,194 @@
+context('PageCreateModal', () => {
+
+  const ssPrefix = 'page-create-modal-';
+
+  beforeEach(() => {
+    // login
+    cy.fixture("user-admin.json").then(user => {
+      cy.login(user.username, user.password);
+    });
+  });
+
+  it("PageCreateModal is shown and closed successfully", () => {
+    cy.visit('/');
+
+    cy.waitUntil(() => {
+      // do
+      cy.getByTestid('newPageBtn').click({force: true});
+      // wait until
+      return cy.getByTestid('page-create-modal').then($elem => $elem.is(':visible'));
+    });
+
+    cy.getByTestid('page-create-modal').should('be.visible').within(() => {
+      cy.screenshot(`${ssPrefix}new-page-modal-opened`);
+      cy.get('button.close').click();
+    });
+
+    cy.collapseSidebar(true, true);
+    cy.screenshot(`${ssPrefix}page-create-modal-closed`);
+  });
+
+  it("Successfully Create Today's page", () => {
+    const pageName = "Today's page";
+    cy.visit('/');
+
+    cy.waitUntil(() => {
+      // do
+      cy.getByTestid('newPageBtn').click({force: true});
+      // wait until
+      return cy.getByTestid('page-create-modal').then($elem => $elem.is(':visible'));
+    });
+
+    cy.getByTestid('page-create-modal').should('be.visible').within(() => {
+      cy.get('.page-today-input2').type(pageName);
+      cy.screenshot(`${ssPrefix}today-add-page-name`);
+      cy.getByTestid('btn-create-memo').click();
+    });
+
+    cy.getByTestid('page-editor').should('be.visible');
+    cy.getByTestid('save-page-btn').as('save-page-btn').should('be.visible');
+    cy.waitUntil(() => {
+      // do
+      cy.get('@save-page-btn').click();
+      // wait until
+      return cy.get('@save-page-btn').then($elem => $elem.is(':disabled'));
+    });
+    cy.get('.layout-root').should('not.have.class', 'editing');
+
+    cy.collapseSidebar(true);
+    cy.waitUntilSkeletonDisappear();
+    cy.screenshot(`${ssPrefix}create-today-page`);
+  });
+
+  it('Successfully create page under specific path', () => {
+    const pageName = 'child';
+
+    cy.visit('/foo/bar');
+
+    cy.waitUntil(() => {
+      // do
+      cy.getByTestid('newPageBtn').click({force: true});
+      // wait until
+      return cy.get('body').within(() => {
+        return Cypress.$('[data-testid=page-create-modal]').is(':visible');
+      });
+    });
+
+    cy.getByTestid('page-create-modal').should('be.visible').within(() => {
+      cy.get('.rbt-input-main').should('have.value', '/foo/bar/');
+      cy.get('.rbt-input-main').type(pageName);
+      cy.screenshot(`${ssPrefix}under-path-add-page-name`);
+      cy.getByTestid('btn-create-page-under-below').click();
+    });
+
+    cy.getByTestid('page-editor').should('be.visible');
+    cy.getByTestid('save-page-btn').as('save-page-btn').should('be.visible');
+    cy.waitUntil(() => {
+      // do
+      cy.get('@save-page-btn').click();
+      // wait until
+      return cy.get('@save-page-btn').then($elem => $elem.is(':disabled'));
+    });
+    cy.get('.layout-root').should('not.have.class', 'editing');
+
+    cy.getByTestid('grw-contextual-sub-nav').should('be.visible');
+
+    cy.waitUntilSkeletonDisappear();
+    cy.collapseSidebar(true);
+    cy.screenshot(`${ssPrefix}create-page-under-specific-page`);
+  });
+
+  it('Trying to create template page under the root page fail', () => {
+    cy.visit('/');
+
+    cy.waitUntil(() => {
+      // do
+      cy.getByTestid('newPageBtn').click({force: true});
+      // wait until
+      return cy.getByTestid('page-create-modal').then($elem => $elem.is(':visible'));
+    });
+
+    cy.getByTestid('page-create-modal').should('be.visible').within(() => {
+      cy.getByTestid('grw-page-create-modal-path-name').should('have.text', '/');
+
+      cy.get('#template-type').click();
+      cy.get('#template-type').next().find('button:eq(0)').click({force: true});
+      cy.getByTestid('grw-btn-edit-page').should('be.visible').click();
+    });
+    cy.get('.Toastify__toast').should('be.visible');
+    cy.collapseSidebar(true);
+    cy.screenshot(`${ssPrefix}create-template-for-children-error`);
+    cy.get('.Toastify__toast').should('be.visible').within(() => {
+      cy.get('.Toastify__close-button').should('be.visible').click();
+      cy.get('.Toastify__progress-bar').invoke('attr', 'style', 'display: none')
+    });
+
+    cy.getByTestid('page-create-modal').should('be.visible').within(() => {
+      cy.get('#template-type').click();
+      cy.get('#template-type').next().find('button:eq(1)').click({force: true});
+      cy.getByTestid('grw-btn-edit-page').should('be.visible').click();
+    });
+    cy.get('.Toastify__toast').should('be.visible');
+    cy.collapseSidebar(true);
+    cy.screenshot(`${ssPrefix}create-template-for-descendants-error`);
+  });
+
+});
+
+
+context('Shortcuts', () => {
+  const ssPrefix = 'shortcuts';
+
+  beforeEach(() => {
+    // login
+    cy.fixture("user-admin.json").then(user => {
+      cy.login(user.username, user.password);
+    });
+  });
+
+  it('Successfully updating a page using a shortcut on a previously created page', { scrollBehavior: false }, () => {
+    const body1 = 'hello';
+    const body2 = ' world!';
+    const savePageShortcutKey = '{ctrl+s}';
+
+    cy.visit('/Sandbox/child');
+
+    cy.get('#grw-page-editor-mode-manager').as('pageEditorModeManager').should('be.visible');
+    cy.waitUntil(() => {
+      // do
+      cy.get('@pageEditorModeManager').within(() => {
+        cy.get('button:nth-child(2)').click();
+      });
+      // until
+      return cy.get('.layout-root').then($elem => $elem.hasClass('editing'));
+    })
+
+    cy.get('.grw-editor-navbar-bottom').should('be.visible');
+
+    // 1st
+    cy.get('.CodeMirror').type(body1);
+    cy.get('.CodeMirror').contains(body1);
+    cy.get('.page-editor-preview-body').contains(body1);
+    cy.get('.CodeMirror').type(savePageShortcutKey);
+
+    cy.get('.Toastify__toast').should('be.visible').within(() => {
+      cy.get('.Toastify__close-button').should('be.visible').click();
+      cy.get('.Toastify__progress-bar').invoke('attr', 'style', 'display: none')
+    });
+    cy.screenshot(`${ssPrefix}-update-page-1`);
+
+    cy.get('.Toastify').should('not.be.visible');
+
+    // 2nd
+    cy.get('.CodeMirror').type(body2);
+    cy.get('.CodeMirror').contains(body2);
+    cy.get('.page-editor-preview-body').contains(body2);
+    cy.get('.CodeMirror').type(savePageShortcutKey);
+
+    cy.get('.Toastify__toast').should('be.visible').within(() => {
+      cy.get('.Toastify__close-button').should('be.visible').click();
+      cy.get('.Toastify__progress-bar').invoke('attr', 'style', 'display: none')
+    });
+    cy.screenshot(`${ssPrefix}-update-page-2`);
+  });
+});

+ 160 - 0
apps/app/test/cypress/e2e/23-editor/23-editor--with-navigation.cy.ts

@@ -0,0 +1,160 @@
+import path from 'path-browserify';
+
+function openEditor() {
+  cy.get('#grw-page-editor-mode-manager').as('pageEditorModeManager').should('be.visible');
+  cy.waitUntil(() => {
+    // do
+    cy.get('@pageEditorModeManager').within(() => {
+      cy.get('button:nth-child(2)').click();
+    });
+    // until
+    return cy.get('.layout-root').then($elem => $elem.hasClass('editing'));
+  })
+  cy.get('.CodeMirror').should('be.visible');
+}
+
+context('Editor while uploading to a new page', () => {
+
+  const ssPrefix = 'editor-while-uploading-';
+
+  beforeEach(() => {
+    // login
+    cy.fixture("user-admin.json").then(user => {
+      cy.login(user.username, user.password);
+    });
+  });
+
+  /**
+   * for the issues:
+   * @see https://redmine.weseek.co.jp/issues/122040
+   * @see https://redmine.weseek.co.jp/issues/124281
+   */
+  it('should not be cleared and should prevent GrantSelector from modified', { scrollBehavior: false }, () => {
+    cy.visit('/Sandbox/for-122040');
+
+    openEditor();
+
+    cy.screenshot(`${ssPrefix}-prevent-grantselector-modified-1`);
+
+    // input the body
+    const body = 'Hello World!';
+    cy.get('.CodeMirror').type(body + '\n\n');
+    cy.get('.CodeMirror').should('contain.text', body);
+
+    // open GrantSelector
+    cy.waitUntil(() => {
+      // do
+      cy.getByTestid('grw-grant-selector').within(() => {
+        cy.get('button.dropdown-toggle').click({force: true});
+      });
+      // wait until
+      return cy.getByTestid('grw-grant-selector').within(() => {
+        return Cypress.$('.dropdown-menu.show').is(':visible');
+      });
+    });
+
+    // Select "Only me"
+    cy.getByTestid('grw-grant-selector').within(() => {
+      // click "Only me"
+      cy.get('.dropdown-menu.show').find('.dropdown-item').should('have.length', 4).then((menuItems) => {
+        menuItems[2].click();
+      });
+    });
+
+    cy.getByTestid('grw-grant-selector').find('.dropdown-toggle').should('contain.text', 'Only me');
+    cy.screenshot(`${ssPrefix}-prevent-grantselector-modified-2`);
+
+    // drag-drop a file
+    const filePath = path.relative('/', path.resolve(Cypress.spec.relative, '../assets/example.txt'));
+    cy.get('.dropzone').selectFile(filePath, { action: 'drag-drop' });
+
+    // expect
+    cy.get('.CodeMirror').should('contain.text', body);
+    cy.get('.CodeMirror').should('contain.text', '[example.txt](/attachment/');
+    cy.getByTestid('grw-grant-selector').find('.dropdown-toggle').should('contain.text', 'Only me');
+    cy.screenshot(`${ssPrefix}-prevent-grantselector-modified-3`);
+  });
+
+});
+
+context.skip('Editor while navigation', () => {
+
+  const ssPrefix = 'editor-while-navigation-';
+
+  beforeEach(() => {
+    // login
+    cy.fixture("user-admin.json").then(user => {
+      cy.login(user.username, user.password);
+    });
+  });
+
+  /**
+   * for the issue:
+   * @see https://redmine.weseek.co.jp/issues/115285
+   */
+  it('Successfully updating the page body', { scrollBehavior: false }, () => {
+    const page1Path = '/Sandbox/for-115285/page1';
+    const page2Path = '/Sandbox/for-115285/page2';
+
+    cy.visit(page1Path);
+
+    openEditor();
+
+    // page1
+    const bodyHello = 'hello';
+    cy.get('.CodeMirror').type(bodyHello);
+    cy.get('.CodeMirror').should('contain.text', bodyHello);
+    cy.get('.page-editor-preview-body').should('contain.text', bodyHello);
+    cy.getByTestid('page-editor').should('be.visible');
+    cy.get('.CodeMirror').screenshot(`${ssPrefix}-editor-for-page1`);
+
+    // save page1
+    cy.getByTestid('save-page-btn').click();
+
+    // open duplicate modal
+    cy.waitUntil(() => {
+      // do
+      cy.get('#grw-subnav-container').within(() => {
+        cy.getByTestid('open-page-item-control-btn').find('button').click({force: true});
+      });
+      // wait until
+      return cy.getByTestid('page-item-control-menu').then($elem => $elem.is(':visible'))
+    });
+    cy.getByTestid('open-page-duplicate-modal-btn').filter(':visible').click({force: true});
+
+    // duplicate and navigate to page1
+    cy.getByTestid('page-duplicate-modal').should('be.visible').within(() => {
+      cy.get('input.form-control').clear();
+      cy.get('input.form-control').type(page2Path);
+      cy.getByTestid('btn-duplicate').click();
+    })
+
+    openEditor();
+
+    cy.get('.CodeMirror').screenshot(`${ssPrefix}-editor-for-page2`);
+
+    // type (without save)
+    const bodyWorld = ' world!!'
+    cy.get('.CodeMirror').type(`${bodyWorld}`);
+    cy.get('.CodeMirror').should('contain.text', `${bodyHello}${bodyWorld}`);
+    cy.get('.page-editor-preview-body').should('contain.text', `${bodyHello}${bodyWorld}`);
+    cy.get('.CodeMirror').screenshot(`${ssPrefix}-editor-for-page2-modified`);
+
+    // create a link to page1
+    cy.get('.CodeMirror').type('\n\n[page1](./page1)');
+
+    // go to page1
+    cy.get('.page-editor-preview-body').within(() => {
+      cy.get("a:contains('page1')").click();
+    });
+
+    openEditor();
+
+    cy.get('.CodeMirror').screenshot(`${ssPrefix}-editor-for-page1-returned`);
+
+    // expect
+    cy.get('.CodeMirror').should('contain.text', bodyHello);
+    cy.get('.CodeMirror').should('not.contain.text', bodyWorld); // text that added to page2
+    cy.get('.CodeMirror').should('not.contain.text', 'page1'); // text that added to page2
+  });
+});

+ 1 - 0
apps/app/test/cypress/e2e/23-editor/assets/example.txt

@@ -0,0 +1 @@
+example.txt

+ 0 - 0
apps/app/test/cypress/integration/30-search/30-search--search.spec.ts → apps/app/test/cypress/e2e/30-search/30-search--search.cy.ts


Algúns arquivos non se mostraron porque demasiados arquivos cambiaron neste cambio