Jelajahi Sumber

Merge branch 'master' into feat/gw-7958-update-authentication-settings

Mudana-Grune 2 tahun lalu
induk
melakukan
42eff53f47
100 mengubah file dengan 697 tambahan dan 396 penghapusan
  1. 0 1
      .devcontainer/devcontainer.json
  2. 1 1
      .github/ISSUE_TEMPLATE/bug-report.md
  3. 1 1
      .github/release-drafter.yml
  4. 2 1
      .github/workflows/release-slackbot-proxy.yml
  5. 4 3
      .github/workflows/release.yml
  6. 2 2
      .github/workflows/reusable-app-prod.yml
  7. 22 11
      .vscode/launch.json
  8. 29 1
      CHANGELOG.md
  9. 9 0
      apps/app/.env.test
  10. 1 0
      apps/app/config/ci/.env.local.for-ci
  11. 0 6
      apps/app/config/migrate-mongo-config.spec.ts
  12. 1 1
      apps/app/cypress.config.ts
  13. 26 13
      apps/app/package.json
  14. 6 2
      apps/app/public/static/locales/en_US/admin.json
  15. 3 1
      apps/app/public/static/locales/en_US/commons.json
  16. 1 0
      apps/app/public/static/locales/en_US/translation.json
  17. 6 2
      apps/app/public/static/locales/ja_JP/admin.json
  18. 3 1
      apps/app/public/static/locales/ja_JP/commons.json
  19. 1 0
      apps/app/public/static/locales/ja_JP/translation.json
  20. 6 2
      apps/app/public/static/locales/zh_CN/admin.json
  21. 3 1
      apps/app/public/static/locales/zh_CN/commons.json
  22. 1 0
      apps/app/public/static/locales/zh_CN/translation.json
  23. 0 0
      apps/app/resource/locales/en_US/admin/userInvitation.ejs
  24. 0 0
      apps/app/resource/locales/en_US/admin/userResetPassword.ejs
  25. 0 0
      apps/app/resource/locales/en_US/admin/userWaitingActivation.ejs
  26. 1 1
      apps/app/resource/locales/en_US/notifications/comment.ejs
  27. 0 0
      apps/app/resource/locales/en_US/notifications/notActiveUser.ejs
  28. 1 1
      apps/app/resource/locales/en_US/notifications/pageCreate.ejs
  29. 1 1
      apps/app/resource/locales/en_US/notifications/pageDelete.ejs
  30. 1 1
      apps/app/resource/locales/en_US/notifications/pageEdit.ejs
  31. 1 1
      apps/app/resource/locales/en_US/notifications/pageLike.ejs
  32. 1 1
      apps/app/resource/locales/en_US/notifications/pageMove.ejs
  33. 0 0
      apps/app/resource/locales/en_US/notifications/passwordReset.ejs
  34. 0 0
      apps/app/resource/locales/en_US/notifications/passwordResetSuccessful.ejs
  35. 0 0
      apps/app/resource/locales/en_US/notifications/userActivation.ejs
  36. 1 1
      apps/app/resource/locales/en_US/welcome.md
  37. 0 0
      apps/app/resource/locales/ja_JP/admin/userInvitation.ejs
  38. 0 0
      apps/app/resource/locales/ja_JP/admin/userResetPassword.ejs
  39. 0 0
      apps/app/resource/locales/ja_JP/admin/userWaitingActivation.ejs
  40. 9 0
      apps/app/resource/locales/ja_JP/notifications/comment.ejs
  41. 0 0
      apps/app/resource/locales/ja_JP/notifications/comment.txt
  42. 0 0
      apps/app/resource/locales/ja_JP/notifications/notActiveUser.ejs
  43. 5 0
      apps/app/resource/locales/ja_JP/notifications/pageCreate.ejs
  44. 0 0
      apps/app/resource/locales/ja_JP/notifications/pageCreate.txt
  45. 5 0
      apps/app/resource/locales/ja_JP/notifications/pageDelete.ejs
  46. 0 0
      apps/app/resource/locales/ja_JP/notifications/pageDelete.txt
  47. 5 0
      apps/app/resource/locales/ja_JP/notifications/pageEdit.ejs
  48. 0 0
      apps/app/resource/locales/ja_JP/notifications/pageEdit.txt
  49. 5 0
      apps/app/resource/locales/ja_JP/notifications/pageLike.ejs
  50. 0 0
      apps/app/resource/locales/ja_JP/notifications/pageLike.txt
  51. 5 0
      apps/app/resource/locales/ja_JP/notifications/pageMove.ejs
  52. 0 0
      apps/app/resource/locales/ja_JP/notifications/pageMove.txt
  53. 0 0
      apps/app/resource/locales/ja_JP/notifications/passwordReset.ejs
  54. 0 0
      apps/app/resource/locales/ja_JP/notifications/passwordResetSuccessful.ejs
  55. 0 0
      apps/app/resource/locales/ja_JP/notifications/userActivation.ejs
  56. 0 0
      apps/app/resource/locales/zh_CN/admin/userInvitation.ejs
  57. 0 0
      apps/app/resource/locales/zh_CN/admin/userResetPassword.ejs
  58. 0 0
      apps/app/resource/locales/zh_CN/admin/userWaitingActivation.ejs
  59. 1 1
      apps/app/resource/locales/zh_CN/notifications/comment.ejs
  60. 0 0
      apps/app/resource/locales/zh_CN/notifications/notActiveUser.ejs
  61. 1 1
      apps/app/resource/locales/zh_CN/notifications/pageCreate.ejs
  62. 1 1
      apps/app/resource/locales/zh_CN/notifications/pageDelete.ejs
  63. 1 1
      apps/app/resource/locales/zh_CN/notifications/pageEdit.ejs
  64. 1 1
      apps/app/resource/locales/zh_CN/notifications/pageLike.ejs
  65. 1 1
      apps/app/resource/locales/zh_CN/notifications/pageMove.ejs
  66. 0 0
      apps/app/resource/locales/zh_CN/notifications/passwordReset.ejs
  67. 0 0
      apps/app/resource/locales/zh_CN/notifications/passwordResetSuccessful.ejs
  68. 0 0
      apps/app/resource/locales/zh_CN/notifications/userActivation.ejs
  69. 57 57
      apps/app/src/client/services/renderer/renderer.tsx
  70. 54 28
      apps/app/src/components/BookmarkButtons.tsx
  71. 4 4
      apps/app/src/components/Bookmarks/BookmarkFolderItem.tsx
  72. 70 65
      apps/app/src/components/Bookmarks/BookmarkFolderMenu.tsx
  73. 13 9
      apps/app/src/components/Bookmarks/BookmarkFolderTree.tsx
  74. 19 13
      apps/app/src/components/Bookmarks/BookmarkItem.tsx
  75. 4 3
      apps/app/src/components/InstallerForm.tsx
  76. 0 6
      apps/app/src/components/Layout/NoLoginLayout.module.scss
  77. 6 0
      apps/app/src/components/LoginForm.module.scss
  78. 1 1
      apps/app/src/components/LoginForm.tsx
  79. 11 2
      apps/app/src/components/Me/BasicInfoSettings.tsx
  80. 3 1
      apps/app/src/components/Navbar/GrowiContextualSubNavigation.tsx
  81. 3 5
      apps/app/src/components/Navbar/SubNavButtons.tsx
  82. 4 3
      apps/app/src/components/PageAlert/TrashPageAlert.tsx
  83. 1 0
      apps/app/src/components/PageDuplicateModal.tsx
  84. 12 6
      apps/app/src/components/PageEditor.tsx
  85. 2 2
      apps/app/src/components/PageEditor/CodeMirrorEditor.jsx
  86. 6 6
      apps/app/src/components/PageList/PageListItemL.tsx
  87. 1 1
      apps/app/src/components/SavePageControls/GrantSelector.tsx
  88. 20 4
      apps/app/src/components/SearchPage.tsx
  89. 14 5
      apps/app/src/components/SearchPage/SearchPageBase.tsx
  90. 0 75
      apps/app/src/components/Sidebar/InfiniteScroll.tsx
  91. 7 6
      apps/app/src/components/Sidebar/PageTree/Item.tsx
  92. 39 21
      apps/app/src/components/TemplateModal/TemplateModal.tsx
  93. 1 0
      apps/app/src/components/TemplateModal/index.tsx
  94. 101 0
      apps/app/src/components/TemplateModal/use-formatter.spec.tsx
  95. 48 0
      apps/app/src/components/TemplateModal/use-formatter.tsx
  96. 0 2
      apps/app/src/features/activate-plugin/index.ts
  97. 0 1
      apps/app/src/features/activate-plugin/utils/index.ts
  98. 0 0
      apps/app/src/features/growi-plugin/components/Admin/PluginsExtensionPageContents/PluginCard.module.scss
  99. 0 0
      apps/app/src/features/growi-plugin/components/Admin/PluginsExtensionPageContents/PluginCard.tsx
  100. 21 7
      apps/app/src/features/growi-plugin/components/Admin/PluginsExtensionPageContents/PluginInstallerForm.tsx

+ 0 - 1
.devcontainer/devcontainer.json

@@ -19,7 +19,6 @@
     "eamodio.gitlens",
     "eamodio.gitlens",
     "github.vscode-pull-request-github",
     "github.vscode-pull-request-github",
     "cschleiden.vscode-github-actions",
     "cschleiden.vscode-github-actions",
-    "firsttris.vscode-jest-runner",
     "msjsdiag.debugger-for-chrome",
     "msjsdiag.debugger-for-chrome",
     "firefox-devtools.vscode-firefox-debug",
     "firefox-devtools.vscode-firefox-debug",
     "editorconfig.editorconfig",
     "editorconfig.editorconfig",

+ 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

+ 1 - 1
.github/release-drafter.yml

@@ -18,7 +18,7 @@ categories:
 category-template: '### $TITLE'
 category-template: '### $TITLE'
 change-title-escapes: '\<*_&' # You can add # and @ to disable mentions, and add ` to disable code blocks.
 change-title-escapes: '\<*_&' # You can add # and @ to disable mentions, and add ` to disable code blocks.
 autolabeler:
 autolabeler:
-  - label: 'feature'
+  - label: 'type/feature'
     branch:
     branch:
       - '/^feat\/.+/'
       - '/^feat\/.+/'
   - label: 'type/improvement'
   - label: 'type/improvement'

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

@@ -112,8 +112,9 @@ jobs:
         yarn --frozen-lockfile
         yarn --frozen-lockfile
 
 
     - name: Bump versions for next RC
     - name: Bump versions for next RC
+      working-directory: ./apps/slackbot-proxy
       run: |
       run: |
-        yarn bump-versions:slackbot-proxy
+        yarn version --no-git-tag-version --prepatch --preid=slackbot-proxy
 
 
     - name: Retrieve information from package.json
     - name: Retrieve information from package.json
       uses: myrotvorets/info-from-package-json-action@1.2.0
       uses: myrotvorets/info-from-package-json-action@1.2.0

+ 4 - 3
.github/workflows/release.yml

@@ -35,7 +35,8 @@ jobs:
 
 
     - name: Bump versions
     - name: Bump versions
       run: |
       run: |
-        yarn bump-versions:patch
+        turbo run bump-versions:patch
+        yarn upgrade --scope=@growi
         sh ./apps/app/bin/github-actions/update-readme.sh
         sh ./apps/app/bin/github-actions/update-readme.sh
 
 
     - name: Retrieve information from package.json
     - name: Retrieve information from package.json
@@ -97,8 +98,8 @@ jobs:
 
 
     - name: Bump versions for next RC
     - name: Bump versions for next RC
       run: |
       run: |
-        yarn bump-versions:rc
-        yarn bump-versions:slackbot-proxy
+        turbo run bump-versions:rc
+        yarn upgrade --scope=@growi
 
 
     - name: Retrieve information from package.json
     - name: Retrieve information from package.json
       uses: myrotvorets/info-from-package-json-action@1.2.0
       uses: myrotvorets/info-from-package-json-action@1.2.0

+ 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

+ 22 - 11
.vscode/launch.json

@@ -2,17 +2,7 @@
     "version": "0.2.0",
     "version": "0.2.0",
     "configurations": [
     "configurations": [
       {
       {
-        "type": "pwa-node",
-        "request": "attach",
-        "name": "Debug: Attach Debugger to Server",
-        "port": 9229,
-        "cwd": "${workspaceFolder}/apps/app",
-        "sourceMapPathOverrides": {
-          "webpack://@growi/app/*": "${workspaceFolder}/apps/app/*"
-        }
-      },
-      {
-        "type": "pwa-node",
+        "type": "node",
         "request": "launch",
         "request": "launch",
         "name": "Debug: Current File",
         "name": "Debug: Current File",
         "skipFiles": [
         "skipFiles": [
@@ -26,6 +16,27 @@
           "${file}"
           "${file}"
         ]
         ]
       },
       },
+      {
+        "type": "node",
+        "request": "launch",
+        "name": "Debug: Current File with Vitest",
+        "autoAttachChildProcesses": true,
+        "skipFiles": ["<node_internals>/**", "**/node_modules/**"],
+        "program": "${workspaceRoot}/node_modules/vitest/vitest.mjs",
+        "args": ["run", "${relativeFile}"],
+        "smartStep": true,
+        "console": "integratedTerminal"
+      },
+      {
+        "type": "pwa-node",
+        "request": "attach",
+        "name": "Debug: Attach Debugger to Server",
+        "port": 9229,
+        "cwd": "${workspaceFolder}/apps/app",
+        "sourceMapPathOverrides": {
+          "webpack://@growi/app/*": "${workspaceFolder}/apps/app/*"
+        }
+      },
       {
       {
         "type": "pwa-node",
         "type": "pwa-node",
         "request": "launch",
         "request": "launch",

+ 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

+ 9 - 0
apps/app/.env.test

@@ -0,0 +1,9 @@
+##
+## Handled by vite
+## https://vitejs.dev/guide/env-and-mode.html
+##
+## > To prevent accidentally leaking env variables to the client, only variables prefixed with
+## > VITE_ are exposed to your Vite-processed code. e.g. for the following env variables:
+##
+VITE_MONGOMS_VERSION="6.0.6"
+# VITE_MONGOMS_DEBUG=1

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

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

+ 0 - 6
apps/app/config/migrate-mongo-config.spec.ts

@@ -1,9 +1,3 @@
-import {
-  vi,
-  beforeEach,
-  describe, test, expect,
-} from 'vitest';
-
 import mockRequire from 'mock-require';
 import mockRequire from 'mock-require';
 
 
 const { reRequire } = mockRequire;
 const { reRequire } = mockRequire;

+ 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

+ 26 - 13
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": "",
@@ -35,16 +35,19 @@
     "prelint:swagger2openapi": "yarn openapi:v3",
     "prelint:swagger2openapi": "yarn openapi:v3",
     "test": "run-p test:*",
     "test": "run-p test:*",
     "test:jest": "cross-env NODE_ENV=test NODE_OPTIONS=\"--max-old-space-size=4096\" jest --logHeapUsage",
     "test:jest": "cross-env NODE_ENV=test NODE_OPTIONS=\"--max-old-space-size=4096\" jest --logHeapUsage",
-    "test:vitest": "vitest run config src --coverage",
+    "test:vitest": "run-p vitest:run vitest:run:integ",
     "jest:run": "cross-env NODE_ENV=test jest --passWithNoTests -- ",
     "jest:run": "cross-env NODE_ENV=test jest --passWithNoTests -- ",
     "reg:run": "reg-suit run",
     "reg:run": "reg-suit run",
+    "vitest:run": "vitest run config src --coverage",
+    "vitest:run:integ": "vitest run -c vitest.config.integ.ts src --coverage",
     "//// misc": "",
     "//// misc": "",
     "console": "yarn cross-env NODE_ENV=development yarn ts-node --experimental-repl-await src/server/console.js",
     "console": "yarn cross-env NODE_ENV=development yarn ts-node --experimental-repl-await src/server/console.js",
     "swagger-jsdoc": "swagger-jsdoc -o tmp/swagger.json -d config/swagger-definition.js",
     "swagger-jsdoc": "swagger-jsdoc -o tmp/swagger.json -d config/swagger-definition.js",
     "openapi:v3": "yarn cross-env API_VERSION=3 yarn swagger-jsdoc -- \"src/server/routes/apiv3/**/*.js\" \"src/server/models/**/*.js\"",
     "openapi:v3": "yarn cross-env API_VERSION=3 yarn swagger-jsdoc -- \"src/server/routes/apiv3/**/*.js\" \"src/server/models/**/*.js\"",
     "openapi:v1": "yarn cross-env API_VERSION=1 yarn swagger-jsdoc -- \"src/server/*/*.js\" \"src/server/models/**/*.js\"",
     "openapi:v1": "yarn cross-env API_VERSION=1 yarn swagger-jsdoc -- \"src/server/*/*.js\" \"src/server/models/**/*.js\"",
     "ts-node": "node -r ts-node/register -r tsconfig-paths/register -r dotenv-flow/config",
     "ts-node": "node -r ts-node/register -r tsconfig-paths/register -r dotenv-flow/config",
-    "ts-node-dev": "ts-node-dev -r tsconfig-paths/register -r dotenv-flow/config"
+    "ts-node-dev": "ts-node-dev -r tsconfig-paths/register -r dotenv-flow/config",
+    "version": "yarn version --no-git-tag-version"
   },
   },
   "// comments for dependencies": {
   "// comments for dependencies": {
     "escape-string-regexp": "5.0.0 or above exports only ESM",
     "escape-string-regexp": "5.0.0 or above exports only ESM",
@@ -61,18 +64,19 @@
     "@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": "link:../../packages/core",
+    "@growi/hackmd": "link:../../packages/hackmd",
+    "@growi/preset-themes": "link:../../packages/preset-themes",
+    "@growi/remark-attachment-refs": "link:../../packages/remark-attachment-refs",
+    "@growi/remark-drawio": "link:../../packages/remark-drawio",
+    "@growi/remark-growi-directive": "link:../../packages/remark-growi-directive",
+    "@growi/remark-lsx": "link:../../packages/remark-lsx",
+    "@growi/slack": "link:../../packages/slack",
     "@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",
     "@slack/webhook": "^6.0.0",
     "@slack/webhook": "^6.0.0",
+    "@types/jest": "^29.5.2",
     "JSONStream": "^1.3.5",
     "JSONStream": "^1.3.5",
     "archiver": "^5.3.0",
     "archiver": "^5.3.0",
     "array.prototype.flatmap": "^1.2.2",
     "array.prototype.flatmap": "^1.2.2",
@@ -131,6 +135,7 @@
     "mongoose-unique-validator": "^2.0.3",
     "mongoose-unique-validator": "^2.0.3",
     "multer": "~1.4.0",
     "multer": "~1.4.0",
     "multer-autoreap": "^1.0.3",
     "multer-autoreap": "^1.0.3",
+    "mustache": "^4.2.0",
     "next": "^13.3.0",
     "next": "^13.3.0",
     "next-i18next": "^13.2.1",
     "next-i18next": "^13.2.1",
     "next-superjson": "^0.0.4",
     "next-superjson": "^0.0.4",
@@ -204,12 +209,15 @@
     "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": "link:../../packages/presentation",
+    "@growi/ui": "link:../../packages/ui",
     "@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",
+    "@swc-node/jest": "^1.6.2",
+    "@swc/jest": "^0.2.24",
     "@types/express": "^4.17.11",
     "@types/express": "^4.17.11",
+    "@types/jest": "^29.5.2",
     "@types/react-scroll": "^1.8.4",
     "@types/react-scroll": "^1.8.4",
     "autoprefixer": "^9.0.0",
     "autoprefixer": "^9.0.0",
     "babel-loader": "^8.2.5",
     "babel-loader": "^8.2.5",
@@ -221,14 +229,19 @@
     "eazy-logger": "^3.1.0",
     "eazy-logger": "^3.1.0",
     "emoji-mart": "npm:panta82-emoji-mart@^3.0.1",
     "emoji-mart": "npm:panta82-emoji-mart@^3.0.1",
     "eslint-plugin-cypress": "^2.12.1",
     "eslint-plugin-cypress": "^2.12.1",
+    "eslint-plugin-jest": "^26.5.3",
     "eslint-plugin-regex": "^1.8.0",
     "eslint-plugin-regex": "^1.8.0",
     "font-awesome": "^4.7.0",
     "font-awesome": "^4.7.0",
     "handsontable": "=6.2.2",
     "handsontable": "=6.2.2",
     "i18next-hmr": "^1.11.0",
     "i18next-hmr": "^1.11.0",
+    "jest": "^29.5.0",
+    "jest-date-mock": "^1.0.8",
+    "jest-localstorage-mock": "^2.4.14",
     "jquery-slimscroll": "^1.3.8",
     "jquery-slimscroll": "^1.3.8",
     "jquery.cookie": "~1.4.1",
     "jquery.cookie": "~1.4.1",
     "load-css-file": "^1.0.0",
     "load-css-file": "^1.0.0",
     "material-icons": "^1.11.3",
     "material-icons": "^1.11.3",
+    "mongodb-memory-server": "^8.12.2",
     "morgan": "^1.10.0",
     "morgan": "^1.10.0",
     "null-loader": "^4.0.1",
     "null-loader": "^4.0.1",
     "penpal": "^4.0.0",
     "penpal": "^4.0.0",

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

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

@@ -2,6 +2,7 @@
   "Show": "Show",
   "Show": "Show",
   "Hide": "Hide",
   "Hide": "Hide",
   "Add": "Add",
   "Add": "Add",
+  "Insert": "Insert",
   "Reset": "Reset",
   "Reset": "Reset",
   "Sign out": "Logout",
   "Sign out": "Logout",
   "New": "New",
   "New": "New",
@@ -22,7 +23,8 @@
   "alert": {
   "alert": {
     "siteUrl_is_not_set": "'Site URL' is NOT set. Set it from the {{link}}",
     "siteUrl_is_not_set": "'Site URL' is NOT set. Set it from the {{link}}",
     "please_enable_mailer": "Please setup mailer first.",
     "please_enable_mailer": "Please setup mailer first.",
-    "password_reset_please_enable_mailer": "Please setup mailer first."
+    "password_reset_please_enable_mailer": "Please setup mailer first.",
+    "email_is_already_in_use": "The email address is already in use."
   },
   },
   "headers": {
   "headers": {
     "app_settings": "App Settings"
     "app_settings": "App Settings"

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

@@ -454,6 +454,7 @@
   },
   },
   "template": {
   "template": {
     "modal_label": {
     "modal_label": {
+      "Select template": "Select template",
       "Create/Edit Template Page": "Create/Edit template page",
       "Create/Edit Template Page": "Create/Edit template page",
       "Create template under": "Create template page under this page"
       "Create template under": "Create template page under this page"
     },
     },

+ 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": "インストール",

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

@@ -2,6 +2,7 @@
   "Show": "公開",
   "Show": "公開",
   "Hide": "非公開",
   "Hide": "非公開",
   "Add": "追加",
   "Add": "追加",
+  "Insert": "挿入",
   "Reset": "リセット",
   "Reset": "リセット",
   "Sign out": "ログアウト",
   "Sign out": "ログアウト",
   "New": "作成",
   "New": "作成",
@@ -21,7 +22,8 @@
   "alert": {
   "alert": {
     "siteUrl_is_not_set": "'サイトURL' が設定されていません。{{link}} から設定してください。",
     "siteUrl_is_not_set": "'サイトURL' が設定されていません。{{link}} から設定してください。",
     "please_enable_mailer": "メール認証を有効にするには、メール設定を完了させてください。",
     "please_enable_mailer": "メール認証を有効にするには、メール設定を完了させてください。",
-    "password_reset_please_enable_mailer": "パスワード再設定を有効にするには、メール設定を完了させてください。"
+    "password_reset_please_enable_mailer": "パスワード再設定を有効にするには、メール設定を完了させてください。",
+    "email_is_already_in_use": "そのメールアドレスは既に使用されています。"
   },
   },
   "headers": {
   "headers": {
     "app_settings": "アプリ設定"
     "app_settings": "アプリ設定"

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

@@ -487,6 +487,7 @@
   },
   },
   "template": {
   "template": {
     "modal_label": {
     "modal_label": {
+      "Select template": "テンプレートの選択",
       "Create/Edit Template Page": "テンプレートページの作成/編集",
       "Create/Edit Template Page": "テンプレートページの作成/編集",
       "Create template under": "配下にテンプレートページを作成"
       "Create template under": "配下にテンプレートページを作成"
     },
     },

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

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

@@ -2,6 +2,7 @@
 	"Show": "显示",
 	"Show": "显示",
 	"Hide": "隐藏",
 	"Hide": "隐藏",
   "Add": "添加",
   "Add": "添加",
+  "Insert": "插入",
   "Reset": "重启",
   "Reset": "重启",
 	"Sign out": "退出",
 	"Sign out": "退出",
   "New": "新建",
   "New": "新建",
@@ -22,7 +23,8 @@
   "alert": {
   "alert": {
     "siteUrl_is_not_set": "主页URL未设置,通过 {{link}} 设置",
     "siteUrl_is_not_set": "主页URL未设置,通过 {{link}} 设置",
     "please_enable_mailer": "请先设置邮件程序。",
     "please_enable_mailer": "请先设置邮件程序。",
-    "password_reset_please_enable_mailer": "请先设置邮件程序。"
+    "password_reset_please_enable_mailer": "请先设置邮件程序。",
+    "email_is_already_in_use": "这个电子邮件地址已经在使用了。"
   },
   },
   "headers": {
   "headers": {
     "app_settings": "系统设置"
     "app_settings": "系统设置"

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

@@ -441,6 +441,7 @@
   },
   },
 	"template": {
 	"template": {
 		"modal_label": {
 		"modal_label": {
+      "Select template": "选择模板",
 			"Create/Edit Template Page": "创建/编辑模板页",
 			"Create/Edit Template Page": "创建/编辑模板页",
 			"Create template under": "在下面创建模板页"
 			"Create template under": "在下面创建模板页"
 		},
 		},

+ 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


+ 57 - 57
apps/app/src/client/services/renderer/renderer.tsx

@@ -1,10 +1,10 @@
 import assert from 'assert';
 import assert from 'assert';
 
 
 import { isClient } from '@growi/core/dist/utils/browser-utils';
 import { isClient } from '@growi/core/dist/utils/browser-utils';
-import * as refsGrowiPlugin from '@growi/remark-attachment-refs/dist/client/index.mjs';
-import * as drawioPlugin from '@growi/remark-drawio';
+import * as refsGrowiDirective from '@growi/remark-attachment-refs/dist/client/index.mjs';
+import * as drawio from '@growi/remark-drawio';
 // eslint-disable-next-line import/extensions
 // eslint-disable-next-line import/extensions
-import * as lsxGrowiPlugin from '@growi/remark-lsx/dist/client/index.mjs';
+import * as lsxGrowiDirective from '@growi/remark-lsx/dist/client/index.mjs';
 import katex from 'rehype-katex';
 import katex from 'rehype-katex';
 import sanitize from 'rehype-sanitize';
 import sanitize from 'rehype-sanitize';
 import slug from 'rehype-slug';
 import slug from 'rehype-slug';
@@ -18,7 +18,7 @@ import type { Pluggable } from 'unified';
 import { DrawioViewerWithEditButton } from '~/components/ReactMarkdownComponents/DrawioViewerWithEditButton';
 import { DrawioViewerWithEditButton } from '~/components/ReactMarkdownComponents/DrawioViewerWithEditButton';
 import { Header } from '~/components/ReactMarkdownComponents/Header';
 import { Header } from '~/components/ReactMarkdownComponents/Header';
 import { TableWithEditButton } from '~/components/ReactMarkdownComponents/TableWithEditButton';
 import { TableWithEditButton } from '~/components/ReactMarkdownComponents/TableWithEditButton';
-import * as mermaidPlugin from '~/features/mermaid-plugin';
+import * as mermaid from '~/features/mermaid';
 import { RehypeSanitizeOption } from '~/interfaces/rehype';
 import { RehypeSanitizeOption } from '~/interfaces/rehype';
 import type { RendererOptions } from '~/interfaces/renderer-options';
 import type { RendererOptions } from '~/interfaces/renderer-options';
 import type { RendererConfig } from '~/interfaces/services/renderer';
 import type { RendererConfig } from '~/interfaces/services/renderer';
@@ -58,11 +58,11 @@ export const generateViewOptions = (
   remarkPlugins.push(
   remarkPlugins.push(
     math,
     math,
     [plantuml.remarkPlugin, { plantumlUri: config.plantumlUri }],
     [plantuml.remarkPlugin, { plantumlUri: config.plantumlUri }],
-    drawioPlugin.remarkPlugin,
+    drawio.remarkPlugin,
+    mermaid.remarkPlugin,
     xsvToTable.remarkPlugin,
     xsvToTable.remarkPlugin,
-    lsxGrowiPlugin.remarkPlugin,
-    refsGrowiPlugin.remarkPlugin,
-    mermaidPlugin.remarkPlugin,
+    lsxGrowiDirective.remarkPlugin,
+    refsGrowiDirective.remarkPlugin,
   );
   );
   if (config.isEnabledLinebreaks) {
   if (config.isEnabledLinebreaks) {
     remarkPlugins.push(breaks);
     remarkPlugins.push(breaks);
@@ -75,18 +75,18 @@ export const generateViewOptions = (
   const rehypeSanitizePlugin: Pluggable<any[]> | (() => void) = config.isEnabledXssPrevention
   const rehypeSanitizePlugin: Pluggable<any[]> | (() => void) = config.isEnabledXssPrevention
     ? [sanitize, deepmerge(
     ? [sanitize, deepmerge(
       commonSanitizeOption,
       commonSanitizeOption,
-      drawioPlugin.sanitizeOption,
-      lsxGrowiPlugin.sanitizeOption,
-      refsGrowiPlugin.sanitizeOption,
-      mermaidPlugin.sanitizeOption,
+      drawio.sanitizeOption,
+      mermaid.sanitizeOption,
+      lsxGrowiDirective.sanitizeOption,
+      refsGrowiDirective.sanitizeOption,
     )]
     )]
     : () => {};
     : () => {};
 
 
   // add rehype plugins
   // add rehype plugins
   rehypePlugins.push(
   rehypePlugins.push(
     slug,
     slug,
-    [lsxGrowiPlugin.rehypePlugin, { pagePath, isSharedPage: config.isSharedPage }],
-    [refsGrowiPlugin.rehypePlugin, { pagePath }],
+    [lsxGrowiDirective.rehypePlugin, { pagePath, isSharedPage: config.isSharedPage }],
+    [refsGrowiDirective.rehypePlugin, { pagePath }],
     rehypeSanitizePlugin,
     rehypeSanitizePlugin,
     katex,
     katex,
     [relocateToc.rehypePluginStore, { storeTocNode }],
     [relocateToc.rehypePluginStore, { storeTocNode }],
@@ -100,15 +100,15 @@ export const generateViewOptions = (
     components.h4 = Header;
     components.h4 = Header;
     components.h5 = Header;
     components.h5 = Header;
     components.h6 = Header;
     components.h6 = Header;
-    components.lsx = lsxGrowiPlugin.Lsx;
-    components.ref = refsGrowiPlugin.Ref;
-    components.refs = refsGrowiPlugin.Refs;
-    components.refimg = refsGrowiPlugin.RefImg;
-    components.refsimg = refsGrowiPlugin.RefsImg;
-    components.gallery = refsGrowiPlugin.Gallery;
+    components.lsx = lsxGrowiDirective.Lsx;
+    components.ref = refsGrowiDirective.Ref;
+    components.refs = refsGrowiDirective.Refs;
+    components.refimg = refsGrowiDirective.RefImg;
+    components.refsimg = refsGrowiDirective.RefsImg;
+    components.gallery = refsGrowiDirective.Gallery;
     components.drawio = DrawioViewerWithEditButton;
     components.drawio = DrawioViewerWithEditButton;
     components.table = TableWithEditButton;
     components.table = TableWithEditButton;
-    components.mermaid = mermaidPlugin.MermaidViewer;
+    components.mermaid = mermaid.MermaidViewer;
   }
   }
 
 
   if (config.isEnabledXssPrevention) {
   if (config.isEnabledXssPrevention) {
@@ -164,11 +164,11 @@ export const generateSimpleViewOptions = (
   remarkPlugins.push(
   remarkPlugins.push(
     math,
     math,
     [plantuml.remarkPlugin, { plantumlUri: config.plantumlUri }],
     [plantuml.remarkPlugin, { plantumlUri: config.plantumlUri }],
-    drawioPlugin.remarkPlugin,
+    drawio.remarkPlugin,
+    mermaid.remarkPlugin,
     xsvToTable.remarkPlugin,
     xsvToTable.remarkPlugin,
-    lsxGrowiPlugin.remarkPlugin,
-    refsGrowiPlugin.remarkPlugin,
-    mermaidPlugin.remarkPlugin,
+    lsxGrowiDirective.remarkPlugin,
+    refsGrowiDirective.remarkPlugin,
   );
   );
 
 
   const isEnabledLinebreaks = overrideIsEnabledLinebreaks ?? config.isEnabledLinebreaks;
   const isEnabledLinebreaks = overrideIsEnabledLinebreaks ?? config.isEnabledLinebreaks;
@@ -185,17 +185,17 @@ export const generateSimpleViewOptions = (
   const rehypeSanitizePlugin: Pluggable<any[]> | (() => void) = config.isEnabledXssPrevention
   const rehypeSanitizePlugin: Pluggable<any[]> | (() => void) = config.isEnabledXssPrevention
     ? [sanitize, deepmerge(
     ? [sanitize, deepmerge(
       commonSanitizeOption,
       commonSanitizeOption,
-      drawioPlugin.sanitizeOption,
-      lsxGrowiPlugin.sanitizeOption,
-      refsGrowiPlugin.sanitizeOption,
-      mermaidPlugin.sanitizeOption,
+      drawio.sanitizeOption,
+      mermaid.sanitizeOption,
+      lsxGrowiDirective.sanitizeOption,
+      refsGrowiDirective.sanitizeOption,
     )]
     )]
     : () => {};
     : () => {};
 
 
   // add rehype plugins
   // add rehype plugins
   rehypePlugins.push(
   rehypePlugins.push(
-    [lsxGrowiPlugin.rehypePlugin, { pagePath, isSharedPage: config.isSharedPage }],
-    [refsGrowiPlugin.rehypePlugin, { pagePath }],
+    [lsxGrowiDirective.rehypePlugin, { pagePath, isSharedPage: config.isSharedPage }],
+    [refsGrowiDirective.rehypePlugin, { pagePath }],
     [keywordHighlighter.rehypePlugin, { keywords: highlightKeywords }],
     [keywordHighlighter.rehypePlugin, { keywords: highlightKeywords }],
     rehypeSanitizePlugin,
     rehypeSanitizePlugin,
     katex,
     katex,
@@ -203,14 +203,14 @@ export const generateSimpleViewOptions = (
 
 
   // add components
   // add components
   if (components != null) {
   if (components != null) {
-    components.lsx = lsxGrowiPlugin.LsxImmutable;
-    components.ref = refsGrowiPlugin.RefImmutable;
-    components.refs = refsGrowiPlugin.RefsImmutable;
-    components.refimg = refsGrowiPlugin.RefImgImmutable;
-    components.refsimg = refsGrowiPlugin.RefsImgImmutable;
-    components.gallery = refsGrowiPlugin.GalleryImmutable;
-    components.drawio = drawioPlugin.DrawioViewer;
-    components.mermaid = mermaidPlugin.MermaidViewer;
+    components.lsx = lsxGrowiDirective.LsxImmutable;
+    components.ref = refsGrowiDirective.RefImmutable;
+    components.refs = refsGrowiDirective.RefsImmutable;
+    components.refimg = refsGrowiDirective.RefImgImmutable;
+    components.refsimg = refsGrowiDirective.RefsImgImmutable;
+    components.gallery = refsGrowiDirective.GalleryImmutable;
+    components.drawio = drawio.DrawioViewer;
+    components.mermaid = mermaid.MermaidViewer;
   }
   }
 
 
   if (config.isEnabledXssPrevention) {
   if (config.isEnabledXssPrevention) {
@@ -241,11 +241,11 @@ export const generatePreviewOptions = (config: RendererConfig, pagePath: string)
   remarkPlugins.push(
   remarkPlugins.push(
     math,
     math,
     [plantuml.remarkPlugin, { plantumlUri: config.plantumlUri }],
     [plantuml.remarkPlugin, { plantumlUri: config.plantumlUri }],
-    drawioPlugin.remarkPlugin,
+    drawio.remarkPlugin,
+    mermaid.remarkPlugin,
     xsvToTable.remarkPlugin,
     xsvToTable.remarkPlugin,
-    lsxGrowiPlugin.remarkPlugin,
-    refsGrowiPlugin.remarkPlugin,
-    mermaidPlugin.remarkPlugin,
+    lsxGrowiDirective.remarkPlugin,
+    refsGrowiDirective.remarkPlugin,
   );
   );
   if (config.isEnabledLinebreaks) {
   if (config.isEnabledLinebreaks) {
     remarkPlugins.push(breaks);
     remarkPlugins.push(breaks);
@@ -258,18 +258,18 @@ export const generatePreviewOptions = (config: RendererConfig, pagePath: string)
   const rehypeSanitizePlugin: Pluggable<any[]> | (() => void) = config.isEnabledXssPrevention
   const rehypeSanitizePlugin: Pluggable<any[]> | (() => void) = config.isEnabledXssPrevention
     ? [sanitize, deepmerge(
     ? [sanitize, deepmerge(
       commonSanitizeOption,
       commonSanitizeOption,
-      lsxGrowiPlugin.sanitizeOption,
-      refsGrowiPlugin.sanitizeOption,
-      drawioPlugin.sanitizeOption,
+      drawio.sanitizeOption,
+      mermaid.sanitizeOption,
+      lsxGrowiDirective.sanitizeOption,
+      refsGrowiDirective.sanitizeOption,
       addLineNumberAttribute.sanitizeOption,
       addLineNumberAttribute.sanitizeOption,
-      mermaidPlugin.sanitizeOption,
     )]
     )]
     : () => {};
     : () => {};
 
 
   // add rehype plugins
   // add rehype plugins
   rehypePlugins.push(
   rehypePlugins.push(
-    [lsxGrowiPlugin.rehypePlugin, { pagePath, isSharedPage: config.isSharedPage }],
-    [refsGrowiPlugin.rehypePlugin, { pagePath }],
+    [lsxGrowiDirective.rehypePlugin, { pagePath, isSharedPage: config.isSharedPage }],
+    [refsGrowiDirective.rehypePlugin, { pagePath }],
     addLineNumberAttribute.rehypePlugin,
     addLineNumberAttribute.rehypePlugin,
     rehypeSanitizePlugin,
     rehypeSanitizePlugin,
     katex,
     katex,
@@ -277,14 +277,14 @@ export const generatePreviewOptions = (config: RendererConfig, pagePath: string)
 
 
   // add components
   // add components
   if (components != null) {
   if (components != null) {
-    components.lsx = lsxGrowiPlugin.LsxImmutable;
-    components.ref = refsGrowiPlugin.RefImmutable;
-    components.refs = refsGrowiPlugin.RefsImmutable;
-    components.refimg = refsGrowiPlugin.RefImgImmutable;
-    components.refsimg = refsGrowiPlugin.RefsImgImmutable;
-    components.gallery = refsGrowiPlugin.GalleryImmutable;
-    components.drawio = drawioPlugin.DrawioViewer;
-    components.mermaid = mermaidPlugin.MermaidViewer;
+    components.lsx = lsxGrowiDirective.LsxImmutable;
+    components.ref = refsGrowiDirective.RefImmutable;
+    components.refs = refsGrowiDirective.RefsImmutable;
+    components.refimg = refsGrowiDirective.RefImgImmutable;
+    components.refsimg = refsGrowiDirective.RefsImgImmutable;
+    components.gallery = refsGrowiDirective.GalleryImmutable;
+    components.drawio = drawio.DrawioViewer;
+    components.mermaid = mermaid.MermaidViewer;
   }
   }
 
 
   if (config.isEnabledXssPrevention) {
   if (config.isEnabledXssPrevention) {

+ 54 - 28
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,16 +56,23 @@ export const BookmarkButtons: FC<Props> = (props: Props) => {
     return 'tooltip.bookmark';
     return 'tooltip.bookmark';
   }, [isGuestUser]);
   }, [isGuestUser]);
 
 
+  if (pageId == null) {
+    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 >
+
+      <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>
@@ -65,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>

+ 4 - 4
apps/app/src/components/Bookmarks/BookmarkFolderItem.tsx

@@ -30,7 +30,7 @@ type BookmarkFolderItemProps = {
   level: number
   level: number
   root: string
   root: string
   isUserHomePage?: boolean
   isUserHomePage?: boolean
-  onClickDeleteBookmarkHandler: (pageToDelete: IPageToDeleteWithMeta) => void
+  onClickDeleteMenuItemHandler: (pageToDelete: IPageToDeleteWithMeta) => void
   bookmarkFolderTreeMutation: () => void
   bookmarkFolderTreeMutation: () => void
 }
 }
 
 
@@ -39,7 +39,7 @@ export const BookmarkFolderItem: FC<BookmarkFolderItemProps> = (props: BookmarkF
   const acceptedTypes: DragItemType[] = [DRAG_ITEM_TYPE.FOLDER, DRAG_ITEM_TYPE.BOOKMARK];
   const acceptedTypes: DragItemType[] = [DRAG_ITEM_TYPE.FOLDER, DRAG_ITEM_TYPE.BOOKMARK];
   const {
   const {
     isReadOnlyUser, bookmarkFolder, isOpen: _isOpen = false, isOperable, level, root, isUserHomePage,
     isReadOnlyUser, bookmarkFolder, isOpen: _isOpen = false, isOperable, level, root, isUserHomePage,
-    onClickDeleteBookmarkHandler, bookmarkFolderTreeMutation,
+    onClickDeleteMenuItemHandler, bookmarkFolderTreeMutation,
   } = props;
   } = props;
 
 
   const {
   const {
@@ -155,7 +155,7 @@ export const BookmarkFolderItem: FC<BookmarkFolderItemProps> = (props: BookmarkF
             level={level + 1}
             level={level + 1}
             root={root}
             root={root}
             isUserHomePage={isUserHomePage}
             isUserHomePage={isUserHomePage}
-            onClickDeleteBookmarkHandler={onClickDeleteBookmarkHandler}
+            onClickDeleteMenuItemHandler={onClickDeleteMenuItemHandler}
             bookmarkFolderTreeMutation={bookmarkFolderTreeMutation}
             bookmarkFolderTreeMutation={bookmarkFolderTreeMutation}
           />
           />
         </div>
         </div>
@@ -174,7 +174,7 @@ export const BookmarkFolderItem: FC<BookmarkFolderItemProps> = (props: BookmarkF
           level={level + 1}
           level={level + 1}
           parentFolder={bookmarkFolder}
           parentFolder={bookmarkFolder}
           canMoveToRoot={true}
           canMoveToRoot={true}
-          onClickDeleteBookmarkHandler={onClickDeleteBookmarkHandler}
+          onClickDeleteMenuItemHandler={onClickDeleteMenuItemHandler}
           bookmarkFolderTreeMutation={bookmarkFolderTreeMutation}
           bookmarkFolderTreeMutation={bookmarkFolderTreeMutation}
         />
         />
       );
       );

+ 70 - 65
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 { 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 { useSWRxCurrentPage, 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}> = ({ children }): 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 { data: currentPage } = useSWRxCurrentPage();
-  const { data: bookmarkInfo, mutate: mutateBookmarkInfo } = useSWRBookmarkInfo(currentPage?._id);
-
-  const { mutate: mutateUserBookmarks } = useSWRxUserBookmarks(currentUser?._id);
-  const { mutate: mutatePageInfo } = useSWRxPageInfo(currentPage?._id);
 
 
-  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,38 +44,40 @@ export const BookmarkFolderMenu: React.FC<{children?: React.ReactNode}> = ({ chi
 
 
   const toggleBookmarkHandler = useCallback(async() => {
   const toggleBookmarkHandler = useCallback(async() => {
     try {
     try {
-      if (currentPage != null) {
-        await toggleBookmark(currentPage._id, isBookmarked);
-      }
+      await toggleBookmark(pageId, isBookmarked);
     }
     }
     catch (err) {
     catch (err) {
       toastError(err);
       toastError(err);
     }
     }
-  }, [currentPage, 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 === currentPage?._id) {
+          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');
     }
     }
@@ -74,8 +85,7 @@ export const BookmarkFolderMenu: React.FC<{children?: React.ReactNode}> = ({ chi
     if (!isOpen && !isBookmarked) {
     if (!isOpen && !isBookmarked) {
       try {
       try {
         await toggleBookmarkHandler();
         await toggleBookmarkHandler();
-        mutateUserBookmarks();
-        mutateBookmarkInfo();
+        mutateCurrentUserBookmarks();
         mutatePageInfo();
         mutatePageInfo();
       }
       }
       catch (err) {
       catch (err) {
@@ -83,7 +93,7 @@ export const BookmarkFolderMenu: React.FC<{children?: React.ReactNode}> = ({ chi
       }
       }
     }
     }
   },
   },
-  [isOpen, bookmarkFolders, selectedItem, isBookmarked, currentPage?._id, 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();
@@ -91,17 +101,15 @@ export const BookmarkFolderMenu: React.FC<{children?: React.ReactNode}> = ({ chi
     setSelectedItem(itemId);
     setSelectedItem(itemId);
 
 
     try {
     try {
-      if (currentPage != null) {
-        await addBookmarkToFolder(currentPage._id, 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);
     }
     }
-  }, [mutateBookmarkFolders, currentPage, mutateBookmarkInfo, mutateUserBookmarks]);
+  }, [pageId, mutateCurrentUserBookmarks, mutateBookmarkFolders, mutatePageInfo]);
 
 
   const renderBookmarkMenuItem = () => {
   const renderBookmarkMenuItem = () => {
     return (
     return (
@@ -120,7 +128,7 @@ export const BookmarkFolderMenu: React.FC<{children?: React.ReactNode}> = ({ chi
         {isBookmarkFolderExists && (
         {isBookmarkFolderExists && (
           <>
           <>
             <DropdownItem divider />
             <DropdownItem divider />
-            <div key='root'>
+            <div key="root">
               <div
               <div
                 className="dropdown-item grw-bookmark-folder-menu-item list-group-item list-group-item-action border-0 py-0"
                 className="dropdown-item grw-bookmark-folder-menu-item list-group-item list-group-item-action border-0 py-0"
                 tabIndex={0}
                 tabIndex={0}
@@ -128,48 +136,45 @@ export const BookmarkFolderMenu: React.FC<{children?: React.ReactNode}> = ({ chi
                 onClick={e => onMenuItemClickHandler(e, 'root')}
                 onClick={e => onMenuItemClickHandler(e, 'root')}
               >
               >
                 <BookmarkFolderMenuItem
                 <BookmarkFolderMenuItem
-                  itemId='root'
+                  itemId="root"
                   itemName={t('bookmark_folder.root')}
                   itemName={t('bookmark_folder.root')}
                   isSelected={selectedItem === 'root'}
                   isSelected={selectedItem === 'root'}
                 />
                 />
               </div>
               </div>
             </div>
             </div>
             {bookmarkFolders?.map(folder => (
             {bookmarkFolders?.map(folder => (
-              <>
-                <div key={folder._id}>
-                  <div
-                    className="dropdown-item grw-bookmark-folder-menu-item list-group-item list-group-item-action border-0 py-0"
-                    style={{ paddingLeft: '40px' }}
-                    tabIndex={0}
-                    role="menuitem"
-                    onClick={e => onMenuItemClickHandler(e, folder._id)}
-                  >
-                    <BookmarkFolderMenuItem
-                      itemId={folder._id}
-                      itemName={folder.name}
-                      isSelected={selectedItem === folder._id}
-                    />
-                  </div>
+              <div key={folder._id}>
+                <div
+                  className="dropdown-item grw-bookmark-folder-menu-item list-group-item list-group-item-action border-0 py-0"
+                  style={{ paddingLeft: '40px' }}
+                  tabIndex={0}
+                  role="menuitem"
+                  onClick={e => onMenuItemClickHandler(e, folder._id)}
+                >
+                  <BookmarkFolderMenuItem
+                    itemId={folder._id}
+                    itemName={folder.name}
+                    isSelected={selectedItem === folder._id}
+                  />
                 </div>
                 </div>
-                <>
-                  {folder.children?.map(child => (
-                    <div key={child._id}>
-                      <div
-                        className='dropdown-item grw-bookmark-folder-menu-item list-group-item list-group-item-action border-0 py-0'
-                        style={{ paddingLeft: '60px' }}
-                        tabIndex={0}
-                        role="menuitem"
-                        onClick={e => onMenuItemClickHandler(e, child._id)}>
-                        <BookmarkFolderMenuItem
-                          itemId={child._id}
-                          itemName={child.name}
-                          isSelected={selectedItem === child._id}
-                        />
-                      </div>
+                {folder.children?.map(child => (
+                  <div key={child._id}>
+                    <div
+                      className='dropdown-item grw-bookmark-folder-menu-item list-group-item list-group-item-action border-0 py-0'
+                      style={{ paddingLeft: '60px' }}
+                      tabIndex={0}
+                      role="menuitem"
+                      onClick={e => onMenuItemClickHandler(e, child._id)}
+                    >
+                      <BookmarkFolderMenuItem
+                        itemId={child._id}
+                        itemName={child.name}
+                        isSelected={selectedItem === child._id}
+                      />
                     </div>
                     </div>
-                  ))}
-                </>
-              </>
+                  </div>
+                ))}
+              </div>
             ))}
             ))}
           </>
           </>
         )}
         )}

+ 13 - 9
apps/app/src/components/Bookmarks/BookmarkFolderTree.tsx

@@ -6,11 +6,13 @@ import { useTranslation } from 'next-i18next';
 import { toastSuccess } from '~/client/util/toastr';
 import { 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 } from '~/stores/context';
 import { useIsReadOnlyUser } from '~/stores/context';
 import { usePageDeleteModal } from '~/stores/modal';
 import { usePageDeleteModal } from '~/stores/modal';
-import { useSWRxCurrentPage } from '~/stores/page';
+import { useSWRMUTxPageInfo, useSWRxCurrentPage } from '~/stores/page';
 
 
 import { BookmarkFolderItem } from './BookmarkFolderItem';
 import { BookmarkFolderItem } from './BookmarkFolderItem';
 import { BookmarkItem } from './BookmarkItem';
 import { BookmarkItem } from './BookmarkItem';
@@ -37,18 +39,20 @@ export const BookmarkFolderTree: React.FC<Props> = (props: Props) => {
 
 
   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 { 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 bookmarkFolderTreeMutation = useCallback(() => {
   const bookmarkFolderTreeMutation = useCallback(() => {
     mutateUserBookmarks();
     mutateUserBookmarks();
-    mutateBookmarkInfo();
+    mutateCurrentUserBookmarks();
+    mutatePageInfo();
     mutateBookmarkFolders();
     mutateBookmarkFolders();
-  }, [mutateBookmarkFolders, mutateBookmarkInfo, mutateUserBookmarks]);
+  }, [mutateBookmarkFolders, mutatePageInfo, mutateCurrentUserBookmarks, mutateUserBookmarks]);
 
 
-  const onClickDeleteBookmarkHandler = useCallback((pageToDelete: IPageToDeleteWithMeta) => {
+  const onClickDeleteMenuItemHandler = useCallback((pageToDelete: IPageToDeleteWithMeta) => {
     const pageDeletedHandler: OnDeletedFunction = (pathOrPathsToDelete, _isRecursively, isCompletely) => {
     const pageDeletedHandler: OnDeletedFunction = (pathOrPathsToDelete, _isRecursively, isCompletely) => {
       if (typeof pathOrPathsToDelete !== 'string') return;
       if (typeof pathOrPathsToDelete !== 'string') return;
 
 
@@ -107,7 +111,7 @@ export const BookmarkFolderTree: React.FC<Props> = (props: Props) => {
               level={0}
               level={0}
               root={bookmarkFolder._id}
               root={bookmarkFolder._id}
               isUserHomePage={isUserHomePage}
               isUserHomePage={isUserHomePage}
-              onClickDeleteBookmarkHandler={onClickDeleteBookmarkHandler}
+              onClickDeleteMenuItemHandler={onClickDeleteMenuItemHandler}
               bookmarkFolderTreeMutation={bookmarkFolderTreeMutation}
               bookmarkFolderTreeMutation={bookmarkFolderTreeMutation}
             />
             />
           );
           );
@@ -122,7 +126,7 @@ export const BookmarkFolderTree: React.FC<Props> = (props: Props) => {
               level={0}
               level={0}
               parentFolder={null}
               parentFolder={null}
               canMoveToRoot={false}
               canMoveToRoot={false}
-              onClickDeleteBookmarkHandler={onClickDeleteBookmarkHandler}
+              onClickDeleteMenuItemHandler={onClickDeleteMenuItemHandler}
               bookmarkFolderTreeMutation={bookmarkFolderTreeMutation}
               bookmarkFolderTreeMutation={bookmarkFolderTreeMutation}
             />
             />
           </div>
           </div>

+ 19 - 13
apps/app/src/components/Bookmarks/BookmarkItem.tsx

@@ -6,7 +6,7 @@ import { DevidedPagePath, pathUtils } from '@growi/core';
 import { useTranslation } from 'react-i18next';
 import { useTranslation } from 'react-i18next';
 import { UncontrolledTooltip, DropdownToggle } from 'reactstrap';
 import { UncontrolledTooltip, DropdownToggle } from 'reactstrap';
 
 
-import { unbookmark } from '~/client/services/page-operation';
+import { bookmark, unbookmark } from '~/client/services/page-operation';
 import { addBookmarkToFolder, renamePage } from '~/client/util/bookmark-utils';
 import { addBookmarkToFolder, renamePage } from '~/client/util/bookmark-utils';
 import { ValidationTarget } from '~/client/util/input-validator';
 import { ValidationTarget } from '~/client/util/input-validator';
 import { toastError } from '~/client/util/toastr';
 import { toastError } from '~/client/util/toastr';
@@ -28,7 +28,7 @@ type Props = {
   level: number,
   level: number,
   parentFolder: BookmarkFolderItems | null,
   parentFolder: BookmarkFolderItems | null,
   canMoveToRoot: boolean,
   canMoveToRoot: boolean,
-  onClickDeleteBookmarkHandler: (pageToDelete: IPageToDeleteWithMeta) => void,
+  onClickDeleteMenuItemHandler: (pageToDelete: IPageToDeleteWithMeta) => void,
   bookmarkFolderTreeMutation: () => void
   bookmarkFolderTreeMutation: () => void
 }
 }
 
 
@@ -39,14 +39,13 @@ export const BookmarkItem = (props: Props): JSX.Element => {
   const { t } = useTranslation();
   const { t } = useTranslation();
 
 
   const {
   const {
-    isReadOnlyUser, isOperable, bookmarkedPage, onClickDeleteBookmarkHandler,
+    isReadOnlyUser, isOperable, bookmarkedPage, onClickDeleteMenuItemHandler,
     parentFolder, level, canMoveToRoot, bookmarkFolderTreeMutation,
     parentFolder, level, canMoveToRoot, bookmarkFolderTreeMutation,
   } = props;
   } = props;
 
 
   const [isRenameInputShown, setRenameInputShown] = useState(false);
   const [isRenameInputShown, setRenameInputShown] = useState(false);
 
 
-  const { data: fetchedPageInfo } = useSWRxPageInfo(bookmarkedPage._id);
-
+  const { data: pageInfo, mutate: mutatePageInfo } = useSWRxPageInfo(bookmarkedPage._id);
   const dPagePath = new DevidedPagePath(bookmarkedPage.path, false, true);
   const dPagePath = new DevidedPagePath(bookmarkedPage.path, false, true);
   const { latter: pageTitle, former: formerPagePath } = dPagePath;
   const { latter: pageTitle, former: formerPagePath } = dPagePath;
   const bookmarkItemId = `bookmark-item-${bookmarkedPage._id}`;
   const bookmarkItemId = `bookmark-item-${bookmarkedPage._id}`;
@@ -65,10 +64,16 @@ export const BookmarkItem = (props: Props): JSX.Element => {
     }
     }
   }, [bookmarkFolderTreeMutation, bookmarkedPage._id]);
   }, [bookmarkFolderTreeMutation, bookmarkedPage._id]);
 
 
-  const bookmarkMenuItemClickHandler = useCallback(async() => {
-    await unbookmark(bookmarkedPage._id);
+  const bookmarkMenuItemClickHandler = useCallback(async(pageId: string, shouldBookmark: boolean) => {
+    if (shouldBookmark) {
+      await bookmark(pageId);
+    }
+    else {
+      await unbookmark(pageId);
+    }
     bookmarkFolderTreeMutation();
     bookmarkFolderTreeMutation();
-  }, [bookmarkedPage._id, bookmarkFolderTreeMutation]);
+    mutatePageInfo();
+  }, [bookmarkFolderTreeMutation, mutatePageInfo]);
 
 
   const renameMenuItemClickHandler = useCallback(() => {
   const renameMenuItemClickHandler = useCallback(() => {
     setRenameInputShown(true);
     setRenameInputShown(true);
@@ -86,12 +91,13 @@ export const BookmarkItem = (props: Props): JSX.Element => {
       setRenameInputShown(false);
       setRenameInputShown(false);
       await renamePage(bookmarkedPage._id, bookmarkedPage.revision, newPagePath);
       await renamePage(bookmarkedPage._id, bookmarkedPage.revision, newPagePath);
       bookmarkFolderTreeMutation();
       bookmarkFolderTreeMutation();
+      mutatePageInfo();
     }
     }
     catch (err) {
     catch (err) {
       setRenameInputShown(true);
       setRenameInputShown(true);
       toastError(err);
       toastError(err);
     }
     }
-  }, [bookmarkedPage, bookmarkFolderTreeMutation]);
+  }, [bookmarkedPage.path, bookmarkedPage._id, bookmarkedPage.revision, bookmarkFolderTreeMutation, mutatePageInfo]);
 
 
   const deleteMenuItemClickHandler = useCallback(async(_pageId: string, pageInfo: IPageInfoAll | undefined): Promise<void> => {
   const deleteMenuItemClickHandler = useCallback(async(_pageId: string, pageInfo: IPageInfoAll | undefined): Promise<void> => {
     if (bookmarkedPage._id == null || bookmarkedPage.path == null) {
     if (bookmarkedPage._id == null || bookmarkedPage.path == null) {
@@ -107,8 +113,8 @@ export const BookmarkItem = (props: Props): JSX.Element => {
       meta: pageInfo,
       meta: pageInfo,
     };
     };
 
 
-    onClickDeleteBookmarkHandler(pageToDelete);
-  }, [bookmarkedPage._id, bookmarkedPage.path, bookmarkedPage.revision, onClickDeleteBookmarkHandler]);
+    onClickDeleteMenuItemHandler(pageToDelete);
+  }, [bookmarkedPage._id, bookmarkedPage.path, bookmarkedPage.revision, onClickDeleteMenuItemHandler]);
 
 
   return (
   return (
     <DragAndDropWrapper
     <DragAndDropWrapper
@@ -137,12 +143,12 @@ export const BookmarkItem = (props: Props): JSX.Element => {
             pageId={bookmarkedPage._id}
             pageId={bookmarkedPage._id}
             isEnableActions
             isEnableActions
             isReadOnlyUser={isReadOnlyUser}
             isReadOnlyUser={isReadOnlyUser}
-            pageInfo={fetchedPageInfo}
+            pageInfo={pageInfo}
             forceHideMenuItems={[MenuItemType.DUPLICATE]}
             forceHideMenuItems={[MenuItemType.DUPLICATE]}
             onClickBookmarkMenuItem={bookmarkMenuItemClickHandler}
             onClickBookmarkMenuItem={bookmarkMenuItemClickHandler}
             onClickRenameMenuItem={renameMenuItemClickHandler}
             onClickRenameMenuItem={renameMenuItemClickHandler}
             onClickDeleteMenuItem={deleteMenuItemClickHandler}
             onClickDeleteMenuItem={deleteMenuItemClickHandler}
-            additionalMenuItemOnTopRenderer={canMoveToRoot
+            additionalMenuItemOnTopRenderer={canMoveToRoot && isOperable
               ? () => <BookmarkMoveToRootBtn pageId={bookmarkedPage._id} onClickMoveToRootHandler={onClickMoveToRootHandler}/>
               ? () => <BookmarkMoveToRootBtn pageId={bookmarkedPage._id} onClickMoveToRootHandler={onClickMoveToRootHandler}/>
               : undefined}
               : undefined}
           >
           >

+ 4 - 3
apps/app/src/components/InstallerForm.tsx

@@ -214,7 +214,7 @@ const InstallerForm = memo((): JSX.Element => {
             />
             />
           </div>
           </div>
 
 
-          <div className="input-group mt-4 mb-3 d-flex justify-content-center">
+          <div className="input-group mt-4 d-flex justify-content-center">
             <button
             <button
               data-testid="btnSubmit"
               data-testid="btnSubmit"
               type="submit"
               type="submit"
@@ -228,11 +228,12 @@ const InstallerForm = memo((): JSX.Element => {
             </button>
             </button>
           </div>
           </div>
 
 
-          <div className="input-group mt-4 d-flex justify-content-center">
+          <div>
             <a href="https://growi.org" className="link-growi-org">
             <a href="https://growi.org" className="link-growi-org">
-              <span className="growi">GROWI</span>.<span className="org">ORG</span>
+              <span className="growi">GROWI</span>.<span className="org">org</span>
             </a>
             </a>
           </div>
           </div>
+
         </form>
         </form>
       </div>
       </div>
     </div>
     </div>

+ 0 - 6
apps/app/src/components/Layout/NoLoginLayout.module.scss

@@ -23,12 +23,6 @@
       }
       }
     }
     }
 
 
-    .link-growi-org {
-      position: absolute;
-      bottom: 9px;
-      z-index: 3;
-    }
-
   }
   }
 
 
   // styles
   // styles

+ 6 - 0
apps/app/src/components/LoginForm.module.scss

@@ -9,4 +9,10 @@
   .collapse-external-auth {
   .collapse-external-auth {
     overflow: hidden;
     overflow: hidden;
   }
   }
+
+  .link-growi-org {
+    position: absolute;
+    bottom: 9px;
+    z-index: 3;
+  }
 }
 }

+ 1 - 1
apps/app/src/components/LoginForm.tsx

@@ -543,7 +543,7 @@ export const LoginForm = (props: LoginFormProps): JSX.Element => {
           </div>
           </div>
         </div>
         </div>
         <a href="https://growi.org" className="link-growi-org pl-3">
         <a href="https://growi.org" className="link-growi-org pl-3">
-          <span className="growi">GROWI</span>.<span className="org">ORG</span>
+          <span className="growi">GROWI</span>.<span className="org">org</span>
         </a>
         </a>
       </div>
       </div>
     </div>
     </div>

+ 11 - 2
apps/app/src/components/Me/BasicInfoSettings.tsx

@@ -24,8 +24,17 @@ export const BasicInfoSettings = (): JSX.Element => {
       sync();
       sync();
       toastSuccess(t('toaster.update_successed', { target: t('Basic Info'), ns: 'commons' }));
       toastSuccess(t('toaster.update_successed', { target: t('Basic Info'), ns: 'commons' }));
     }
     }
-    catch (err) {
-      toastError(err);
+    catch (errs) {
+      const err = errs[0];
+      const message = err.message;
+      const code = err.code;
+
+      if (code === 'email-is-already-in-use') {
+        toastError(t('alert.email_is_already_in_use', { ns: 'commons' }));
+      }
+      else {
+        toastError(message);
+      }
     }
     }
   };
   };
 
 

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

@@ -317,9 +317,11 @@ const GrowiContextualSubNavigation = (props: GrowiContextualSubNavigationProps):
       else if (currentPathname != null) {
       else if (currentPathname != null) {
         router.push(currentPathname);
         router.push(currentPathname);
       }
       }
+
+      mutateCurrentPage();
     };
     };
     openDeleteModal([pageWithMeta], { onDeleted: deletedHandler });
     openDeleteModal([pageWithMeta], { onDeleted: deletedHandler });
-  }, [currentPathname, openDeleteModal, router]);
+  }, [currentPathname, mutateCurrentPage, openDeleteModal, router]);
 
 
   const switchContentWidthHandler = useCallback(async(pageId: string, value: boolean) => {
   const switchContentWidthHandler = useCallback(async(pageId: string, value: boolean) => {
     if (!isSharedPage) {
     if (!isSharedPage) {

+ 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 && (

+ 4 - 3
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';
 
 
@@ -33,11 +33,11 @@ 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 { 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') : '';
@@ -55,13 +55,14 @@ export const TrashPageAlert = (): JSX.Element => {
       try {
       try {
         unlink(currentPagePath);
         unlink(currentPagePath);
         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]);
+  }, [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) {

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

+ 12 - 6
apps/app/src/components/PageEditor.tsx

@@ -322,10 +322,10 @@ const PageEditor = React.memo((): JSX.Element => {
       editorRef.current.insertText(insertText);
       editorRef.current.insertText(insertText);
 
 
       // when if created newly
       // when if created newly
+      // 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);
         globalEmitter.emit('resetInitializedHackMdStatus');
         globalEmitter.emit('resetInitializedHackMdStatus');
-        mutateGrant(res.page.grant);
         mutateIsLatestRevision(true);
         mutateIsLatestRevision(true);
         await mutateCurrentPageId(res.page._id);
         await mutateCurrentPageId(res.page._id);
         await mutateCurrentPage();
         await mutateCurrentPage();
@@ -338,7 +338,7 @@ const PageEditor = React.memo((): JSX.Element => {
     finally {
     finally {
       editorRef.current.terminateUploadingState();
       editorRef.current.terminateUploadingState();
     }
     }
-  }, [currentPagePath, mutateCurrentPage, mutateCurrentPageId, mutateGrant, mutateIsLatestRevision, pageId]);
+  }, [currentPagePath, mutateCurrentPage, mutateCurrentPageId, mutateIsLatestRevision, pageId]);
 
 
 
 
   const scrollPreviewByEditorLine = useCallback((line: number) => {
   const scrollPreviewByEditorLine = useCallback((line: number) => {
@@ -519,11 +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
+  const onRouterChangeComplete = useCallback(() => {
+    editorRef.current?.setValue(initialValue);
+    editorRef.current?.setCaretLine(0);
+  }, [initialValue]);
+
   useEffect(() => {
   useEffect(() => {
-    if (currentPagePath != null) {
-      editorRef.current?.setValue(initialValue);
-    }
-  }, [currentPagePath, initialValue]);
+    router.events.on('routeChangeComplete', onRouterChangeComplete);
+    return () => {
+      router.events.off('routeChangeComplete', onRouterChangeComplete);
+    };
+  }, [onRouterChangeComplete, router.events]);
 
 
   if (!isEditable) {
   if (!isEditable) {
     return <></>;
     return <></>;

+ 2 - 2
apps/app/src/components/PageEditor/CodeMirrorEditor.jsx

@@ -848,8 +848,8 @@ class CodeMirrorEditor extends AbstractEditor {
   // }
   // }
 
 
   showTemplateModal() {
   showTemplateModal() {
-    const onSubmit = templateText => this.setValue(templateText);
-    this.props.onClickTemplateBtn(onSubmit);
+    const onSubmit = templateText => this.insertText(templateText);
+    this.props.onClickTemplateBtn({ onSubmit });
   }
   }
 
 
   showLinkEditModal() {
   showLinkEditModal() {

+ 6 - 6
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';
 
 
@@ -90,8 +90,8 @@ const PageListItemLSubstance: ForwardRefRenderFunction<ISelectable, Props> = (pr
 
 
   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();
-  const { mutate: mutateBookmarkInfo } = useSWRBookmarkInfo(pageData?._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;
 
 
@@ -128,8 +128,8 @@ const PageListItemLSubstance: ForwardRefRenderFunction<ISelectable, Props> = (pr
   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(() => {

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

+ 20 - 4
apps/app/src/components/SearchPage.tsx

@@ -92,7 +92,10 @@ SearchResultListHead.displayName = 'SearchResultListHead';
 export const SearchPage = (): JSX.Element => {
 export const SearchPage = (): JSX.Element => {
   const { t } = useTranslation();
   const { t } = useTranslation();
   const { data: showPageLimitationL } = useShowPageLimitationL();
   const { data: showPageLimitationL } = useShowPageLimitationL();
+
+  // routerRef solve the problem of infinite redrawing that occurs with routers
   const router = useRouter();
   const router = useRouter();
+  const routerRef = useRef(router);
 
 
   // parse URL Query
   // parse URL Query
   const queries = router.query.q;
   const queries = router.query.q;
@@ -165,10 +168,10 @@ export const SearchPage = (): JSX.Element => {
 
 
   const initialSearchConditions: Partial<ISearchConditions> = useMemo(() => {
   const initialSearchConditions: Partial<ISearchConditions> = useMemo(() => {
     return {
     return {
-      keyword: initQ,
+      keyword,
       limit: INITIAL_PAGIONG_SIZE,
       limit: INITIAL_PAGIONG_SIZE,
     };
     };
-  }, [initQ]);
+  }, [keyword]);
 
 
   // for bulk deletion
   // for bulk deletion
   const deleteAllButtonClickedHandler = usePageDeleteModalForBulkDeletion(data, searchPageBaseRef, () => mutate());
   const deleteAllButtonClickedHandler = usePageDeleteModalForBulkDeletion(data, searchPageBaseRef, () => mutate());
@@ -177,8 +180,21 @@ export const SearchPage = (): JSX.Element => {
   useEffect(() => {
   useEffect(() => {
     const newUrl = new URL('/_search', 'http://example.com');
     const newUrl = new URL('/_search', 'http://example.com');
     newUrl.searchParams.append('q', keyword);
     newUrl.searchParams.append('q', keyword);
-    window.history.pushState('', `Search - ${keyword}`, `${newUrl.pathname}${newUrl.search}`);
-  }, [keyword]);
+    routerRef.current.push(`${newUrl.pathname}${newUrl.search}`, '', { shallow: true });
+  }, [keyword, routerRef]);
+
+  // browser back and forward
+  useEffect(() => {
+    routerRef.current.beforePopState(({ url }) => {
+      const newUrl = new URL(url, 'https://exmple.com');
+      const newKeyword = newUrl.searchParams.get('q');
+      if (newKeyword != null) {
+        setKeyword(newKeyword);
+      }
+      return true;
+    });
+  }, [setKeyword, routerRef]);
+
   const hitsCount = data?.meta.hitsCount;
   const hitsCount = data?.meta.hitsCount;
 
 
   const allControl = useMemo(() => {
   const allControl = useMemo(() => {

+ 14 - 5
apps/app/src/components/SearchPage/SearchPageBase.tsx

@@ -43,8 +43,13 @@ type Props = {
   searchPager: React.ReactNode,
   searchPager: React.ReactNode,
 }
 }
 
 
+
+const SearchResultContent = dynamic(() => import('./SearchResultContent').then(mod => mod.SearchResultContent), {
+  ssr: false,
+  loading: () => <></>,
+});
 const SearchPageBaseSubstance: ForwardRefRenderFunction<ISelectableAll & IReturnSelectedPageIds, Props> = (props:Props, ref) => {
 const SearchPageBaseSubstance: ForwardRefRenderFunction<ISelectableAll & IReturnSelectedPageIds, Props> = (props:Props, ref) => {
-  const SearchResultContent = dynamic(import('./SearchResultContent').then(mod => mod.SearchResultContent), { ssr: false });
+
   const {
   const {
     pages,
     pages,
     searchingKeyword,
     searchingKeyword,
@@ -62,6 +67,7 @@ const SearchPageBaseSubstance: ForwardRefRenderFunction<ISelectableAll & IReturn
 
 
   const [selectedPageIdsByCheckboxes] = useState<Set<string>>(new Set());
   const [selectedPageIdsByCheckboxes] = useState<Set<string>>(new Set());
   // const [allPageIds] = useState<Set<string>>(new Set());
   // const [allPageIds] = useState<Set<string>>(new Set());
+
   const [selectedPageWithMeta, setSelectedPageWithMeta] = useState<IPageWithSearchMeta | undefined>();
   const [selectedPageWithMeta, setSelectedPageWithMeta] = useState<IPageWithSearchMeta | undefined>();
 
 
   // publish selectAll()
   // publish selectAll()
@@ -108,10 +114,13 @@ const SearchPageBaseSubstance: ForwardRefRenderFunction<ISelectableAll & IReturn
 
 
   // select first item on load
   // select first item on load
   useEffect(() => {
   useEffect(() => {
-    if (selectedPageWithMeta == null && pages != null && pages.length > 0) {
+    if ((pages == null || pages.length === 0)) {
+      setSelectedPageWithMeta(undefined);
+    }
+    else if ((pages != null && pages.length > 0)) {
       setSelectedPageWithMeta(pages[0]);
       setSelectedPageWithMeta(pages[0]);
     }
     }
-  }, [pages, selectedPageWithMeta]);
+  }, [pages, setSelectedPageWithMeta]);
 
 
   // reset selectedPageIdsByCheckboxes
   // reset selectedPageIdsByCheckboxes
   useEffect(() => {
   useEffect(() => {
@@ -189,7 +198,7 @@ const SearchPageBaseSubstance: ForwardRefRenderFunction<ISelectableAll & IReturn
                       pages={pages}
                       pages={pages}
                       selectedPageId={selectedPageWithMeta?.data._id}
                       selectedPageId={selectedPageWithMeta?.data._id}
                       forceHideMenuItems={forceHideMenuItems}
                       forceHideMenuItems={forceHideMenuItems}
-                      onPageSelected={page => setSelectedPageWithMeta(page)}
+                      onPageSelected={page => (setSelectedPageWithMeta(page)) }
                       onCheckboxChanged={checkboxChangedHandler}
                       onCheckboxChanged={checkboxChangedHandler}
                     />
                     />
                   </div>
                   </div>
@@ -205,7 +214,7 @@ const SearchPageBaseSubstance: ForwardRefRenderFunction<ISelectableAll & IReturn
         </div>
         </div>
 
 
         <div className="mw-0 flex-grow-1 flex-basis-0 d-none d-lg-block search-result-content">
         <div className="mw-0 flex-grow-1 flex-basis-0 d-none d-lg-block search-result-content">
-          { selectedPageWithMeta != null && (
+          {pages != null && pages.length !== 0 && selectedPageWithMeta != null && (
             <SearchResultContent
             <SearchResultContent
               pageWithMeta={selectedPageWithMeta}
               pageWithMeta={selectedPageWithMeta}
               highlightKeywords={highlightKeywords}
               highlightKeywords={highlightKeywords}

+ 0 - 75
apps/app/src/components/Sidebar/InfiniteScroll.tsx

@@ -1,75 +0,0 @@
-import React, {
-  Ref, useEffect, useState,
-} from 'react';
-
-import type { SWRInfiniteResponse } from 'swr/infinite';
-
-type Props<T> = {
-  swrInifiniteResponse : SWRInfiniteResponse<T>
-  children: React.ReactNode,
-  loadingIndicator?: React.ReactNode
-  endingIndicator?: React.ReactNode
-  isReachingEnd?: boolean,
-  offset?: number
-}
-
-const useIntersection = <E extends HTMLElement>(): [boolean, Ref<E>] => {
-  const [intersecting, setIntersecting] = useState<boolean>(false);
-  const [element, setElement] = useState<HTMLElement>();
-  useEffect(() => {
-    if (element != null) {
-      const observer = new IntersectionObserver((entries) => {
-        setIntersecting(entries[0]?.isIntersecting);
-      });
-      observer.observe(element);
-      return () => observer.unobserve(element);
-    }
-    return;
-  }, [element]);
-  return [intersecting, el => el && setElement(el)];
-};
-
-const LoadingIndicator = (): React.ReactElement => {
-  return (
-    <div className="text-muted text-center">
-      <i className="fa fa-2x fa-spinner fa-pulse mr-1"></i>
-    </div>
-  );
-};
-
-const InfiniteScroll = <E, >(props: Props<E>): React.ReactElement<Props<E>> => {
-  const {
-    swrInifiniteResponse: {
-      setSize, isValidating,
-    },
-    children,
-    loadingIndicator,
-    endingIndicator,
-    isReachingEnd,
-    offset = 0,
-  } = props;
-
-  const [intersecting, ref] = useIntersection<HTMLDivElement>();
-
-  useEffect(() => {
-    if (intersecting && !isValidating && !isReachingEnd) {
-      setSize(size => size + 1);
-    }
-  }, [setSize, intersecting, isValidating, isReachingEnd]);
-
-  return (
-    <>
-      { children }
-
-      <div style={{ position: 'relative' }}>
-        <div ref={ref} style={{ position: 'absolute', top: offset }}></div>
-        {isReachingEnd
-          ? endingIndicator
-          : loadingIndicator || <LoadingIndicator />
-        }
-      </div>
-    </>
-  );
-};
-
-export default InfiniteScroll;

+ 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 => {

+ 39 - 21
apps/app/src/components/TemplateModal.tsx → apps/app/src/components/TemplateModal/TemplateModal.tsx

@@ -1,6 +1,8 @@
-import React, { useCallback, useState } from 'react';
+import React, {
+  useCallback, useEffect, useState,
+} from 'react';
 
 
-import { ITemplate } from '@growi/core';
+import type { ITemplate } from '@growi/core/dist/interfaces/template';
 import { useTranslation } from 'next-i18next';
 import { useTranslation } from 'next-i18next';
 import {
 import {
   Modal,
   Modal,
@@ -12,8 +14,13 @@ import {
 import { useTemplateModal } from '~/stores/modal';
 import { useTemplateModal } from '~/stores/modal';
 import { usePreviewOptions } from '~/stores/renderer';
 import { usePreviewOptions } from '~/stores/renderer';
 import { useTemplates } from '~/stores/template';
 import { useTemplates } from '~/stores/template';
+import loggerFactory from '~/utils/logger';
 
 
-import Preview from './PageEditor/Preview';
+import Preview from '../PageEditor/Preview';
+
+import { useFormatter } from './use-formatter';
+
+const logger = loggerFactory('growi:components:TemplateModal');
 
 
 
 
 type TemplateRadioButtonProps = {
 type TemplateRadioButtonProps = {
@@ -42,7 +49,8 @@ const TemplateRadioButton = ({ template, onChange, isSelected }: TemplateRadioBu
 };
 };
 
 
 export const TemplateModal = (): JSX.Element => {
 export const TemplateModal = (): JSX.Element => {
-  const { t } = useTranslation();
+  const { t } = useTranslation(['translation', 'commons']);
+
 
 
   const { data: templateModalStatus, close } = useTemplateModal();
   const { data: templateModalStatus, close } = useTemplateModal();
 
 
@@ -51,16 +59,27 @@ export const TemplateModal = (): JSX.Element => {
 
 
   const [selectedTemplate, setSelectedTemplate] = useState<ITemplate>();
   const [selectedTemplate, setSelectedTemplate] = useState<ITemplate>();
 
 
+  const { format } = useFormatter();
+
   const submitHandler = useCallback((template?: ITemplate) => {
   const submitHandler = useCallback((template?: ITemplate) => {
-    if (templateModalStatus == null) { return }
+    if (templateModalStatus == null || selectedTemplate == null) {
+      return;
+    }
+
     if (templateModalStatus.onSubmit == null || template == null) {
     if (templateModalStatus.onSubmit == null || template == null) {
       close();
       close();
       return;
       return;
     }
     }
 
 
-    templateModalStatus.onSubmit(template.markdown);
+    templateModalStatus.onSubmit(format(selectedTemplate));
     close();
     close();
-  }, [close, templateModalStatus]);
+  }, [close, format, selectedTemplate, templateModalStatus]);
+
+  useEffect(() => {
+    if (!templateModalStatus?.isOpened) {
+      setSelectedTemplate(undefined);
+    }
+  }, [templateModalStatus?.isOpened]);
 
 
   if (templates == null || templateModalStatus == null) {
   if (templates == null || templateModalStatus == null) {
     return <></>;
     return <></>;
@@ -69,7 +88,7 @@ export const TemplateModal = (): JSX.Element => {
   return (
   return (
     <Modal className="link-edit-modal" isOpen={templateModalStatus.isOpened} toggle={close} size="lg" autoFocus={false}>
     <Modal className="link-edit-modal" isOpen={templateModalStatus.isOpened} toggle={close} size="lg" autoFocus={false}>
       <ModalHeader tag="h4" toggle={close} className="bg-primary text-light">
       <ModalHeader tag="h4" toggle={close} className="bg-primary text-light">
-        Template
+        {t('template.modal_label.Select template')}
       </ModalHeader>
       </ModalHeader>
 
 
       <ModalBody className="container">
       <ModalBody className="container">
@@ -79,24 +98,23 @@ export const TemplateModal = (): JSX.Element => {
               <TemplateRadioButton
               <TemplateRadioButton
                 key={template.id}
                 key={template.id}
                 template={template}
                 template={template}
-                onChange={t => setSelectedTemplate(t)}
+                onChange={selected => setSelectedTemplate(selected)}
                 isSelected={template.id === selectedTemplate?.id}
                 isSelected={template.id === selectedTemplate?.id}
               />
               />
             )) }
             )) }
           </div>
           </div>
         </div>
         </div>
 
 
-        { rendererOptions != null && (
-          <>
-            <hr />
-            <h3>Preview</h3>
-            <div className='card'>
-              <div className="card-body" style={{ maxHeight: '60vh', overflowY: 'auto' }}>
-                <Preview rendererOptions={rendererOptions} markdown={selectedTemplate?.markdown}/>
-              </div>
-            </div>
-          </>
-        ) }
+        <hr />
+
+        <h3>{t('Preview')}</h3>
+        <div className='card'>
+          <div className="card-body" style={{ height: '400px', overflowY: 'auto' }}>
+            { rendererOptions != null && selectedTemplate != null && (
+              <Preview rendererOptions={rendererOptions} markdown={format(selectedTemplate)}/>
+            ) }
+          </div>
+        </div>
 
 
       </ModalBody>
       </ModalBody>
       <ModalFooter>
       <ModalFooter>
@@ -104,7 +122,7 @@ export const TemplateModal = (): JSX.Element => {
           {t('Cancel')}
           {t('Cancel')}
         </button>
         </button>
         <button type="submit" className="btn btn-sm btn-primary mx-1" onClick={() => submitHandler(selectedTemplate)} disabled={selectedTemplate == null}>
         <button type="submit" className="btn btn-sm btn-primary mx-1" onClick={() => submitHandler(selectedTemplate)} disabled={selectedTemplate == null}>
-          {t('Update')}
+          {t('commons:Insert')}
         </button>
         </button>
       </ModalFooter>
       </ModalFooter>
     </Modal>
     </Modal>

+ 1 - 0
apps/app/src/components/TemplateModal/index.tsx

@@ -0,0 +1 @@
+export * from './TemplateModal';

+ 101 - 0
apps/app/src/components/TemplateModal/use-formatter.spec.tsx

@@ -0,0 +1,101 @@
+import type { ITemplate } from '@growi/core/dist/interfaces/template';
+import { mock } from 'vitest-mock-extended';
+
+import { useFormatter } from './use-formatter';
+
+
+const mocks = vi.hoisted(() => {
+  return {
+    useCurrentPagePathMock: vi.fn(() => { return {} }),
+  };
+});
+
+vi.mock('~/stores/page', () => {
+  return { useCurrentPagePath: mocks.useCurrentPagePathMock };
+});
+
+
+describe('useFormatter', () => {
+
+  describe('format()', () => {
+
+    it('returns an empty string when the argument is undefined', () => {
+      // setup
+      const mastacheMock = {
+        render: vi.fn(),
+      };
+      vi.doMock('mustache', () => mastacheMock);
+
+      // when
+      const { format } = useFormatter();
+      // call with undefined
+      const markdown = format(undefined);
+
+      // then
+      expect(markdown).toBe('');
+      expect(mastacheMock.render).not.toHaveBeenCalled();
+    });
+
+  });
+
+  it('returns markdown as-is when mustache.render throws an error', () => {
+    // setup
+    const mastacheMock = {
+      render: vi.fn(() => { throw new Error() }),
+    };
+    vi.doMock('mustache', () => mastacheMock);
+
+    // when
+    const { format } = useFormatter();
+    const template = mock<ITemplate>();
+    template.markdown = 'markdown body';
+    const markdown = format(template);
+
+    // then
+    expect(markdown).toBe('markdown body');
+  });
+
+  it('returns markdown formatted when currentPagePath is undefined', () => {
+    // when
+    const { format } = useFormatter();
+    const template = mock<ITemplate>();
+    template.markdown = `
+title: {{{title}}}{{^title}}(empty){{/title}}
+path: {{{path}}}
+`;
+    const markdown = format(template);
+
+    // then
+    expect(markdown).toBe(`
+title: (empty)
+path: /
+`);
+  });
+
+  it('returns markdown formatted', () => {
+    // setup
+    mocks.useCurrentPagePathMock.mockImplementation(() => {
+      return { data: '/Sandbox' };
+    });
+    // 2023/5/31 15:01:xx
+    vi.setSystemTime(new Date(2023, 4, 31, 15, 1));
+
+    // when
+    const { format } = useFormatter();
+    const template = mock<ITemplate>();
+    template.markdown = `
+title: {{{title}}}
+path: {{{path}}}
+date: {{yyyy}}/{{MM}}/{{dd}} {{HH}}:{{mm}}
+`;
+    const markdown = format(template);
+
+    // then
+    expect(markdown).toBe(`
+title: Sandbox
+path: /Sandbox
+date: 2023/05/31 15:01
+`);
+  });
+
+});

+ 48 - 0
apps/app/src/components/TemplateModal/use-formatter.tsx

@@ -0,0 +1,48 @@
+import path from 'path';
+
+import type { ITemplate } from '@growi/core/dist/interfaces/template';
+import dateFnsFormat from 'date-fns/format';
+import mustache from 'mustache';
+
+import { useCurrentPagePath } from '~/stores/page';
+import loggerFactory from '~/utils/logger';
+
+const logger = loggerFactory('growi:components:TemplateModal:use-formatter');
+
+
+type FormatMethod = (selectedTemplate?: ITemplate) => string;
+type FormatterData = {
+  format: FormatMethod,
+}
+
+export const useFormatter = (): FormatterData => {
+  const { data: currentPagePath } = useCurrentPagePath();
+
+  const format: FormatMethod = (selectedTemplate) => {
+    if (selectedTemplate == null) {
+      return '';
+    }
+
+    // replace placeholder
+    let markdown = selectedTemplate.markdown;
+    const now = new Date();
+    try {
+      markdown = mustache.render(selectedTemplate.markdown, {
+        title: path.basename(currentPagePath ?? '/'),
+        path: currentPagePath ?? '/',
+        yyyy: dateFnsFormat(now, 'yyyy'),
+        MM: dateFnsFormat(now, 'MM'),
+        dd: dateFnsFormat(now, 'dd'),
+        HH: dateFnsFormat(now, 'HH'),
+        mm: dateFnsFormat(now, 'mm'),
+      });
+    }
+    catch (err) {
+      logger.warn('An error occured while ejs processing.', err);
+    }
+
+    return markdown;
+  };
+
+  return { format };
+};

+ 0 - 2
apps/app/src/features/activate-plugin/index.ts

@@ -1,2 +0,0 @@
-export * from './components';
-export * from './utils';

+ 0 - 1
apps/app/src/features/activate-plugin/utils/index.ts

@@ -1 +0,0 @@
-export { getGrowiFacade } from './growi-facade-utils';

+ 0 - 0
apps/app/src/components/Admin/PluginsExtension/PluginCard.module.scss → apps/app/src/features/growi-plugin/components/Admin/PluginsExtensionPageContents/PluginCard.module.scss


+ 0 - 0
apps/app/src/components/Admin/PluginsExtension/PluginCard.tsx → apps/app/src/features/growi-plugin/components/Admin/PluginsExtensionPageContents/PluginCard.tsx


+ 21 - 7
apps/app/src/components/Admin/PluginsExtension/PluginInstallerForm.tsx → apps/app/src/features/growi-plugin/components/Admin/PluginsExtensionPageContents/PluginInstallerForm.tsx

@@ -4,7 +4,9 @@ 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 { useSWRxPlugins } from '~/stores/plugin';
+
+import type { IGrowiPluginOrigin } from '../../../interfaces';
+import { useSWRxPlugins } from '../../../stores/growi-plugin';
 
 
 export const PluginInstallerForm = (): JSX.Element => {
 export const PluginInstallerForm = (): JSX.Element => {
   const { mutate } = useSWRxPlugins();
   const { mutate } = useSWRxPlugins();
@@ -17,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,
     };
     };
 
 
@@ -43,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>
 
 

Beberapa file tidak ditampilkan karena terlalu banyak file yang berubah dalam diff ini