Răsfoiți Sursa

122259 fix conflict

soumaeda 2 ani în urmă
părinte
comite
79cf9bf226
100 a modificat fișierele cu 815 adăugiri și 515 ștergeri
  1. 0 1
      .devcontainer/devcontainer.json
  2. 0 11
      .eslintrc.js
  3. 1 2
      .github/ISSUE_TEMPLATE/bug-report.md
  4. 4 0
      .github/dependabot.yml
  5. 17 17
      .github/release-drafter.yml
  6. 4 3
      .github/workflows/auto-approve.yml
  7. 2 2
      .github/workflows/pr-to-master.yml
  8. 2 2
      .github/workflows/release-slackbot-proxy.yml
  9. 7 5
      .github/workflows/release.yml
  10. 2 2
      .github/workflows/reusable-app-prod.yml
  11. 1 2
      .mergify.yml
  12. 22 11
      .vscode/launch.json
  13. 122 48
      CHANGELOG.md
  14. 9 0
      apps/app/.env.test
  15. 0 11
      apps/app/.eslintrc.js
  16. 1 0
      apps/app/config/ci/.env.local.for-ci
  17. 1 4
      apps/app/config/migrate-mongo-config.js
  18. 65 0
      apps/app/config/migrate-mongo-config.spec.ts
  19. 1 1
      apps/app/cypress.config.ts
  20. 0 20
      apps/app/jest.config.js
  21. 1 0
      apps/app/next.config.js
  22. 28 13
      apps/app/package.json
  23. 11 0
      apps/app/public/images/icons/editor/attachment.svg
  24. 14 10
      apps/app/public/static/locales/en_US/admin.json
  25. 4 1
      apps/app/public/static/locales/en_US/commons.json
  26. 8 7
      apps/app/public/static/locales/en_US/translation.json
  27. 13 9
      apps/app/public/static/locales/ja_JP/admin.json
  28. 4 1
      apps/app/public/static/locales/ja_JP/commons.json
  29. 8 7
      apps/app/public/static/locales/ja_JP/translation.json
  30. 13 9
      apps/app/public/static/locales/zh_CN/admin.json
  31. 4 1
      apps/app/public/static/locales/zh_CN/commons.json
  32. 8 7
      apps/app/public/static/locales/zh_CN/translation.json
  33. 0 0
      apps/app/resource/locales/en_US/admin/userInvitation.ejs
  34. 0 0
      apps/app/resource/locales/en_US/admin/userResetPassword.ejs
  35. 0 0
      apps/app/resource/locales/en_US/admin/userWaitingActivation.ejs
  36. 1 1
      apps/app/resource/locales/en_US/notifications/comment.ejs
  37. 0 0
      apps/app/resource/locales/en_US/notifications/notActiveUser.ejs
  38. 1 1
      apps/app/resource/locales/en_US/notifications/pageCreate.ejs
  39. 1 1
      apps/app/resource/locales/en_US/notifications/pageDelete.ejs
  40. 1 1
      apps/app/resource/locales/en_US/notifications/pageEdit.ejs
  41. 1 1
      apps/app/resource/locales/en_US/notifications/pageLike.ejs
  42. 1 1
      apps/app/resource/locales/en_US/notifications/pageMove.ejs
  43. 0 0
      apps/app/resource/locales/en_US/notifications/passwordReset.ejs
  44. 0 0
      apps/app/resource/locales/en_US/notifications/passwordResetSuccessful.ejs
  45. 0 0
      apps/app/resource/locales/en_US/notifications/userActivation.ejs
  46. 1 1
      apps/app/resource/locales/en_US/welcome.md
  47. 0 0
      apps/app/resource/locales/ja_JP/admin/userInvitation.ejs
  48. 0 0
      apps/app/resource/locales/ja_JP/admin/userResetPassword.ejs
  49. 0 0
      apps/app/resource/locales/ja_JP/admin/userWaitingActivation.ejs
  50. 9 0
      apps/app/resource/locales/ja_JP/notifications/comment.ejs
  51. 0 0
      apps/app/resource/locales/ja_JP/notifications/notActiveUser.ejs
  52. 5 0
      apps/app/resource/locales/ja_JP/notifications/pageCreate.ejs
  53. 0 0
      apps/app/resource/locales/ja_JP/notifications/pageCreate.txt
  54. 5 0
      apps/app/resource/locales/ja_JP/notifications/pageDelete.ejs
  55. 0 0
      apps/app/resource/locales/ja_JP/notifications/pageDelete.txt
  56. 5 0
      apps/app/resource/locales/ja_JP/notifications/pageEdit.ejs
  57. 0 0
      apps/app/resource/locales/ja_JP/notifications/pageEdit.txt
  58. 5 0
      apps/app/resource/locales/ja_JP/notifications/pageLike.ejs
  59. 0 0
      apps/app/resource/locales/ja_JP/notifications/pageLike.txt
  60. 5 0
      apps/app/resource/locales/ja_JP/notifications/pageMove.ejs
  61. 0 0
      apps/app/resource/locales/ja_JP/notifications/pageMove.txt
  62. 0 0
      apps/app/resource/locales/ja_JP/notifications/passwordReset.ejs
  63. 0 0
      apps/app/resource/locales/ja_JP/notifications/passwordResetSuccessful.ejs
  64. 0 0
      apps/app/resource/locales/ja_JP/notifications/userActivation.ejs
  65. 0 0
      apps/app/resource/locales/zh_CN/admin/userInvitation.ejs
  66. 0 0
      apps/app/resource/locales/zh_CN/admin/userResetPassword.ejs
  67. 0 0
      apps/app/resource/locales/zh_CN/admin/userWaitingActivation.ejs
  68. 1 1
      apps/app/resource/locales/zh_CN/notifications/comment.ejs
  69. 0 0
      apps/app/resource/locales/zh_CN/notifications/notActiveUser.ejs
  70. 1 1
      apps/app/resource/locales/zh_CN/notifications/pageCreate.ejs
  71. 1 1
      apps/app/resource/locales/zh_CN/notifications/pageDelete.ejs
  72. 1 1
      apps/app/resource/locales/zh_CN/notifications/pageEdit.ejs
  73. 1 1
      apps/app/resource/locales/zh_CN/notifications/pageLike.ejs
  74. 1 1
      apps/app/resource/locales/zh_CN/notifications/pageMove.ejs
  75. 0 0
      apps/app/resource/locales/zh_CN/notifications/passwordReset.ejs
  76. 0 0
      apps/app/resource/locales/zh_CN/notifications/passwordResetSuccessful.ejs
  77. 0 0
      apps/app/resource/locales/zh_CN/notifications/userActivation.ejs
  78. 6 6
      apps/app/src/client/services/AdminUsersContainer.js
  79. 68 58
      apps/app/src/client/services/renderer/renderer.tsx
  80. 4 2
      apps/app/src/client/util/bookmark-utils.ts
  81. 11 10
      apps/app/src/components/Admin/UserGroup/UserGroupForm.tsx
  82. 9 2
      apps/app/src/components/Admin/UserGroupDetail/UserGroupDetailPage.tsx
  83. 9 9
      apps/app/src/components/Admin/Users/GrantAdminButton.tsx
  84. 15 15
      apps/app/src/components/Admin/Users/RevokeAdminButton.tsx
  85. 13 13
      apps/app/src/components/Admin/Users/RevokeAdminMenuItem.tsx
  86. 3 3
      apps/app/src/components/Admin/Users/UserMenu.tsx
  87. 54 28
      apps/app/src/components/BookmarkButtons.tsx
  88. 44 38
      apps/app/src/components/Bookmarks/BookmarkFolderItem.tsx
  89. 73 65
      apps/app/src/components/Bookmarks/BookmarkFolderMenu.tsx
  90. 25 11
      apps/app/src/components/Bookmarks/BookmarkFolderTree.tsx
  91. 23 14
      apps/app/src/components/Bookmarks/BookmarkItem.tsx
  92. 4 3
      apps/app/src/components/InstallerForm.tsx
  93. 2 0
      apps/app/src/components/Layout/BasicLayout.tsx
  94. 0 6
      apps/app/src/components/Layout/NoLoginLayout.module.scss
  95. 6 0
      apps/app/src/components/LoginForm.module.scss
  96. 1 1
      apps/app/src/components/LoginForm.tsx
  97. 11 2
      apps/app/src/components/Me/BasicInfoSettings.tsx
  98. 3 1
      apps/app/src/components/Navbar/GrowiContextualSubNavigation.tsx
  99. 3 5
      apps/app/src/components/Navbar/SubNavButtons.tsx
  100. 4 3
      apps/app/src/components/PageAlert/TrashPageAlert.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",

+ 0 - 11
.eslintrc.js

@@ -3,15 +3,8 @@ module.exports = {
   extends: [
   extends: [
     'weseek',
     'weseek',
     'weseek/typescript',
     'weseek/typescript',
-    'plugin:jest/recommended',
   ],
   ],
-  env: {
-    'jest/globals': true,
-  },
-  globals: {
-  },
   plugins: [
   plugins: [
-    'jest',
     'regex',
     'regex',
   ],
   ],
   rules: {
   rules: {
@@ -67,10 +60,6 @@ module.exports = {
         FunctionExpression: { body: 1, parameters: 2 },
         FunctionExpression: { body: 1, parameters: 2 },
       },
       },
     ],
     ],
-    'jest/no-standalone-expect': [
-      'error',
-      { additionalTestBlockFunctions: ['each.test'] },
-    ],
     'regex/invalid': ['error', [
     'regex/invalid': ['error', [
       {
       {
         regex: '\\?\\<\\!',
         regex: '\\?\\<\\!',

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

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

+ 4 - 0
.github/dependabot.yml

@@ -5,6 +5,8 @@ updates:
     open-pull-requests-limit: 3
     open-pull-requests-limit: 3
     schedule:
     schedule:
       interval: monthly
       interval: monthly
+    labels:
+      - "type/dependencies"
     commit-message:
     commit-message:
       prefix: ci
       prefix: ci
       include: scope
       include: scope
@@ -14,6 +16,8 @@ updates:
     open-pull-requests-limit: 3
     open-pull-requests-limit: 3
     schedule:
     schedule:
       interval: weekly
       interval: weekly
+    labels:
+      - "type/dependencies"
     commit-message:
     commit-message:
       prefix: ci
       prefix: ci
       include: scope
       include: scope

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

@@ -1,35 +1,35 @@
 categories:
 categories:
   - title: 'BREAKING CHANGES'
   - title: 'BREAKING CHANGES'
     labels:
     labels:
-      - 'breaking'
+      - 'type/reaking'
   - title: '💎 Features'
   - title: '💎 Features'
     labels:
     labels:
-      - 'feature'
+      - 'type/feature'
   - title: '🚀 Improvement'
   - title: '🚀 Improvement'
     labels:
     labels:
-      - 'improvement'
+      - 'type/improvement'
   - title: '🐛 Bug Fixes'
   - title: '🐛 Bug Fixes'
     labels:
     labels:
-      - 'bug'
+      - 'type/bug'
   - title: '🧰 Maintenance'
   - title: '🧰 Maintenance'
     labels:
     labels:
-      - 'support'
-      - 'dependencies'
+      - 'type/support'
+      - 'type/dependencies'
 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: 'improvement'
+  - label: 'type/improvement'
     branch:
     branch:
       - '/^imprv\/.+/'
       - '/^imprv\/.+/'
-  - label: 'bug'
+  - label: 'type/bug'
     branch:
     branch:
       - '/^fix\/.+/'
       - '/^fix\/.+/'
     title:
     title:
       - '/^fix/i'
       - '/^fix/i'
-  - label: 'support'
+  - label: 'type/support'
     branch:
     branch:
       - '/^support\/.+/'
       - '/^support\/.+/'
     title:
     title:
@@ -39,13 +39,13 @@ autolabeler:
       - '/^docs/i'
       - '/^docs/i'
       - '/^test/i'
       - '/^test/i'
 include-labels:
 include-labels:
-  - breaking
-  - feature
-  - improvement
-  - bug
-  - support
-  - dependencies
+  - type/breaking
+  - type/feature
+  - type/improvement
+  - type/bug
+  - type/support
+  - type/dependencies
 exclude-labels:
 exclude-labels:
-  - 'exclude from changelog'
+  - 'flag/exclude-from-changelog'
 template: |
 template: |
   $CHANGES
   $CHANGES

+ 4 - 3
.github/workflows/dependabot-auto-approve.yml → .github/workflows/auto-approve.yml

@@ -1,5 +1,4 @@
-# by https://zenn.dev/nemuki/articles/dependabot-auto-merge
-name: Auto approve on dependabot PR at patch update
+name: Auto approve PR
 
 
 on:
 on:
   pull_request_target:
   pull_request_target:
@@ -9,7 +8,9 @@ permissions:
   pull-requests: write
   pull-requests: write
 
 
 jobs:
 jobs:
-  dependabot-auto-approve:
+  # Auto approve on dependabot PR at patch update
+  #   by https://zenn.dev/nemuki/articles/dependabot-auto-merge
+  approve-updating-patch-version:
     runs-on: ubuntu-latest
     runs-on: ubuntu-latest
     if: ${{ github.actor == 'dependabot[bot]' }}
     if: ${{ github.actor == 'dependabot[bot]' }}
     steps:
     steps:

+ 2 - 2
.github/workflows/pr-to-master.yml

@@ -19,7 +19,7 @@ jobs:
     runs-on: ubuntu-latest
     runs-on: ubuntu-latest
 
 
     if: |
     if: |
-      !contains(github.event.pull_request.labels.*.name, 'exclude from changelog')
+      !contains(github.event.pull_request.labels.*.name, 'flag/exclude-from-changelog')
 
 
     steps:
     steps:
       - uses: release-drafter/release-drafter@v5
       - uses: release-drafter/release-drafter@v5
@@ -32,7 +32,7 @@ jobs:
     runs-on: ubuntu-latest
     runs-on: ubuntu-latest
 
 
     if: |
     if: |
-      (!contains( github.event.pull_request.labels.*.name, 'exclude from changelog' ) &&
+      (!contains( github.event.pull_request.labels.*.name, 'flag/exclude-from-changelog' ) &&
         !startsWith( github.head_ref, 'dependabot/' ))
         !startsWith( github.head_ref, 'dependabot/' ))
 
 
     steps:
     steps:

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

@@ -113,7 +113,7 @@ jobs:
 
 
     - name: Bump versions for next RC
     - name: Bump versions for next RC
       run: |
       run: |
-        yarn bump-versions:slackbot-proxy
+        turbo run version --filter=@growi/slackbot-proxy -- --prerelease
 
 
     - 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
@@ -135,6 +135,6 @@ jobs:
         source_branch: support/prepare-v${{ steps.package-json.outputs.packageVersion }}
         source_branch: support/prepare-v${{ steps.package-json.outputs.packageVersion }}
         destination_branch: master
         destination_branch: master
         pr_title: Prepare v${{ steps.package-json.outputs.packageVersion }}
         pr_title: Prepare v${{ steps.package-json.outputs.packageVersion }}
-        pr_label: exclude from changelog
+        pr_label: flag/exclude-from-changelog,type/prepare-next-version
         pr_body: "An automated PR generated by ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}"
         pr_body: "An automated PR generated by ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}"
         github_token: ${{ secrets.GITHUB_TOKEN }}
         github_token: ${{ secrets.GITHUB_TOKEN }}

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

@@ -35,7 +35,8 @@ jobs:
 
 
     - name: Bump versions
     - name: Bump versions
       run: |
       run: |
-        yarn bump-versions:patch
+        turbo run version --filter=@growi/app -- --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,9 @@ 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 version --filter=@growi/app -- --prepatch
+        turbo run version --filter=@growi/slackbot-proxy -- --prepatch
+        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
@@ -118,8 +120,8 @@ jobs:
         source_branch: support/prepare-v${{ steps.package-json.outputs.packageVersion }}
         source_branch: support/prepare-v${{ steps.package-json.outputs.packageVersion }}
         destination_branch: master
         destination_branch: master
         pr_title: Prepare v${{ steps.package-json.outputs.packageVersion }}
         pr_title: Prepare v${{ steps.package-json.outputs.packageVersion }}
-        pr_label: exclude from changelog,prepare next version
-        pr_body: "An automated PR generated by create-pr-for-next-rc"
+        pr_label: flag/exclude-from-changelog,type/prepare-next-version
+        pr_body: "[skip ci] An automated PR generated by create-pr-for-next-rc"
         github_token: ${{ secrets.GITHUB_TOKEN }}
         github_token: ${{ secrets.GITHUB_TOKEN }}
 
 
 
 

+ 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

+ 1 - 2
.mergify.yml

@@ -15,8 +15,7 @@ pull_request_rules:
   - name: Automatic merge for Preparing next version
   - name: Automatic merge for Preparing next version
     conditions:
     conditions:
       - author = github-actions[bot]
       - author = github-actions[bot]
-      - '#approved-reviews-by >= 1'
-      - label = "prepare next version"
+      - label = "type/prepare-next-version"
     actions:
     actions:
       merge:
       merge:
         method: merge
         method: merge

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

+ 122 - 48
CHANGELOG.md

@@ -1,71 +1,145 @@
 # Changelog
 # Changelog
 
 
-## [Unreleased](https://github.com/weseek/growi/compare/v6.1.0...HEAD)
+## [Unreleased](https://github.com/weseek/growi/compare/v6.1.4...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.4](https://github.com/weseek/growi/compare/v6.1.3...v6.1.4) - 2023-06-12
+
+### 💎 Features
+
+- feat(plugin): Specify repository branch name (#7783) @yuki-takei
+
+### 🚀 Improvement
+
+- imprv: Suppress unnecessary bookmark API requests (#7798) @yuki-takei
+
+### 🐛 Bug Fixes
+
+- fix: Bookmarks mutation for the current user (#7797) @yuki-takei
+- fix: Slack channels data for User Triggered Notification is not loaded (#7794) @yuki-takei
+- fix: The input of the editor is cleared when an attachment is added when a new page editing (#7788) @miya
+
+## [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
+
+### 🚀 Improvement
+
+- imprv: Unify whitelist description (#7638) @soumaeda
+- imprv: Refactoring migration script (#7694) @miya
+- imprv: Implement infinite scroll into PageTimeline (#7679) @reiji-h
+
+### 🐛 Bug Fixes
+
+- fix: Hash and search query in the relative link are omitted wrongly (#7697) @yuki-takei
+
+### 🧰 Maintenance
+
+- support: Restrict the 'populate' method in model modules (#7689) @yuki-takei
+- support: Refactor LinkEditModal (#7654) @yukendev
+- ci(deps): bump aws-actions/configure-aws-credentials from 1 to 2 (#7620) @dependabot
+- ci(deps): bump hugo19941994/delete-draft-releases from 1.0.0 to 1.0.1 (#7448) @dependabot
+
+## [v6.1.1](https://github.com/weseek/growi/compare/v6.1.0...v6.1.1) - 2023-05-24
+
+### 🐛 Bug Fixes
+
+- fix: Bookmark folders owned by others are accessible for manipulation (#7688) @miya
+- fix: remark-attachment-refs does not work in production (#7681) @yuki-takei
+- fix: User picture of bookmark not showing inside bookmark folder (#7678) @mudana-grune
+- fix: Update name attribute of PageRenameModal.tsx (#7677) @jam411
+- fix: The user's bookmarks are displayed on unrelated user's home (#7668) @miya
+- fix: The user's bookmarks are updated by unrelated user's operation (#7670) @jam411
+
 ## [v6.1.0](https://github.com/weseek/growi/compare/v6.0.15...v6.1.0) - 2023-05-17
 ## [v6.1.0](https://github.com/weseek/growi/compare/v6.0.15...v6.1.0) - 2023-05-17
 
 
 ### BREAKING CHANGES
 ### BREAKING CHANGES
 
 
-* Node.js v14 is no longer supported.
-* Elasticsearch v6 is no longer supported.
-* imprv: Omit clobber prefix (#7627) @yuki-takei
-* support: Omit textlint (#7578) @yuki-takei
-* support: Remove Blockdiag codes (#7576) @yuki-takei
+- Node.js v14 is no longer supported.
+- Elasticsearch v6 is no longer supported.
+- imprv: Omit clobber prefix (#7627) @yuki-takei
+- support: Omit textlint (#7578) @yuki-takei
+- support: Remove Blockdiag codes (#7576) @yuki-takei
 
 
 See the upgrading guide for v6.1.x. => [English](https://docs.growi.org/en/admin-guide/upgrading/61x.html) / [Japanese](https://docs.growi.org/ja/admin-guide/upgrading/61x.html)
 See the upgrading guide for v6.1.x. => [English](https://docs.growi.org/en/admin-guide/upgrading/61x.html) / [Japanese](https://docs.growi.org/ja/admin-guide/upgrading/61x.html)
 
 
 ### 💎 Features
 ### 💎 Features
 
 
-* feat: Add read-only user feature (#7648) @jam411
-* feat: Support Mermaid (move into the feature dierctory) (#7647) @miya
-* feat: Fix APP\_SITE\_URL with an environment variable (#7646) @yuki-takei
-* feat: Support Mermaid (#7645) @miya
-* feat: Support Elasticsearch v8 (#7623) @miya
-* feat: Elasticsearchv8 module (#7623) @miya
-* feat: Bookmarks folder and sidebar menu (#7450) @mudana-grune
-* feat: GROWI Questionnaire (#7316) @hakumizuki
-* feat: Revive attachment-refs with remark (#7597) @arafubeatbox
+- feat: Add read-only user feature (#7648) @jam411
+- feat: Support Mermaid (move into the feature dierctory) (#7647) @miya
+- feat: Fix APP_SITE_URL with an environment variable (#7646) @yuki-takei
+- feat: Support Mermaid (#7645) @miya
+- feat: Support Elasticsearch v8 (#7623) @miya
+- feat: Elasticsearchv8 module (#7623) @miya
+- feat: Bookmarks folder and sidebar menu (#7450) @mudana-grune
+- feat: GROWI Questionnaire (#7316) @hakumizuki
+- feat: Revive attachment-refs with remark (#7597) @arafubeatbox
 
 
 ### 🚀 Improvement
 ### 🚀 Improvement
 
 
-* imprv: Font size (#7663) @yuki-takei
-* imprv: Admin user can use `reset-password` without email settings (#7650) @jam411
-* imprv: Optimize fonts with next/font (#7633) @yuki-takei
-* imprv: GFM table performance 2 (#7640) @yuki-takei
-* imprv: GFM footnote styles (#7628) @yuki-takei
-* imprv: GFM table performance (#7619) @yuki-takei
-* imprv: Show unsaved warning when comment not posted (#7603) @arafubeatbox
-* imprv: Suppress UI Flickering for dropdowns (#7608) @jam411
-* imprv: Allow registering without GROWI email settings for ID/Password authentication's restricted registration (#7591) @jam411
-* imprv: Enable browsing video (for v6.1.0) (#7589) @yuki-takei
-* imprv: Show a spinner into the save button while the saving process (#7579) @yuki-takei
-* imprv: Inject PlantUML URI with config-loader (#7577) @yuki-takei
-* imprv: Loading draw.io (diagrams.net) resources (#7575) @yuki-takei
-
-### 🐛 Bug Fixes
-
-* fix: The environment variable for disabling link sharing (#7652) @yuki-takei
-* fix: Cursor resetting occurs after updating with the built-in editor (#7644) @yuki-takei
-* fix: Revision schema migration for v5 to v6 (#7637) @yuki-takei
-* fix: Editor not resetting when the same markdown (#7625) @arafubeatbox
-* fix: AlignRight DropdownMenu flickering (#7606) @mudana-grune
-* fix: Not display page list count badge in trash page (#7600) @yukendev
-* fix: Reverted descendant pages do not appear in search results (#7587) @miya
-* fix: Deleted descendant pages do not appear in search results (#7583) @miya
-* fix: Show lsx page list in trash page correctly (#7582) @yukendev
-* fix: Uncaught type error by `sticky-event` (#7566) @mudana-grune
+- imprv: Font size (#7663) @yuki-takei
+- imprv: Admin user can use `reset-password` without email settings (#7650) @jam411
+- imprv: Optimize fonts with next/font (#7633) @yuki-takei
+- imprv: GFM table performance 2 (#7640) @yuki-takei
+- imprv: GFM footnote styles (#7628) @yuki-takei
+- imprv: GFM table performance (#7619) @yuki-takei
+- imprv: Show unsaved warning when comment not posted (#7603) @arafubeatbox
+- imprv: Suppress UI Flickering for dropdowns (#7608) @jam411
+- imprv: Allow registering without GROWI email settings for ID/Password authentication's restricted registration (#7591) @jam411
+- imprv: Enable browsing video (for v6.1.0) (#7589) @yuki-takei
+- imprv: Show a spinner into the save button while the saving process (#7579) @yuki-takei
+- imprv: Inject PlantUML URI with config-loader (#7577) @yuki-takei
+- imprv: Loading draw.io (diagrams.net) resources (#7575) @yuki-takei
+
+### 🐛 Bug Fixes
+
+- fix: The environment variable for disabling link sharing (#7652) @yuki-takei
+- fix: Cursor resetting occurs after updating with the built-in editor (#7644) @yuki-takei
+- fix: Revision schema migration for v5 to v6 (#7637) @yuki-takei
+- fix: Editor not resetting when the same markdown (#7625) @arafubeatbox
+- fix: AlignRight DropdownMenu flickering (#7606) @mudana-grune
+- fix: Not display page list count badge in trash page (#7600) @yukendev
+- fix: Reverted descendant pages do not appear in search results (#7587) @miya
+- fix: Deleted descendant pages do not appear in search results (#7583) @miya
+- fix: Show lsx page list in trash page correctly (#7582) @yukendev
+- fix: Uncaught type error by `sticky-event` (#7566) @mudana-grune
 
 
 ### 🧰 Maintenance
 ### 🧰 Maintenance
 
 
-* support: mongoose update (#7659) @jam411
-* support: Elasticsearch8 (#7592) @miya
-* support: Replaced by IAttachmentHasId (#7629) @reiji-h
-* support: Dedupe packages (#7590) @yuki-takei
-* support: Typescriptize CustomNav (#7584) @yuki-takei
-* support: Replaced by IAttachmentHasId (#7629) @reiji-h
-* support: Migrate to Turborepo (#7417) @yuki-takei
+- support: mongoose update (#7659) @jam411
+- support: Elasticsearch8 (#7592) @miya
+- support: Replaced by IAttachmentHasId (#7629) @reiji-h
+- support: Dedupe packages (#7590) @yuki-takei
+- support: Typescriptize CustomNav (#7584) @yuki-takei
+- support: Replaced by IAttachmentHasId (#7629) @reiji-h
+- support: Migrate to Turborepo (#7417) @yuki-takei
 
 
 ## [v6.0.15](https://github.com/weseek/growi/compare/v6.0.14...v6.0.15) - 2023-04-10
 ## [v6.0.15](https://github.com/weseek/growi/compare/v6.0.14...v6.0.15) - 2023-04-10
 
 

+ 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

+ 0 - 11
apps/app/.eslintrc.js

@@ -5,16 +5,6 @@ module.exports = {
   plugins: [
   plugins: [
     'regex',
     'regex',
   ],
   ],
-  env: {
-    jquery: true,
-  },
-  globals: {
-    $: true,
-    jquery: true,
-    hljs: true,
-    ScrollPosStyler: true,
-    window: true,
-  },
   settings: {
   settings: {
     // resolve path aliases by eslint-import-resolver-typescript
     // resolve path aliases by eslint-import-resolver-typescript
     'import/resolver': {
     'import/resolver': {
@@ -40,7 +30,6 @@ module.exports = {
     // set 'warn' temporarily -- 2021.08.02 Yuki Takei
     // set 'warn' temporarily -- 2021.08.02 Yuki Takei
     '@typescript-eslint/no-use-before-define': ['warn'],
     '@typescript-eslint/no-use-before-define': ['warn'],
     '@typescript-eslint/no-this-alias': ['warn'],
     '@typescript-eslint/no-this-alias': ['warn'],
-    'jest/no-done-callback': ['warn'],
   },
   },
   overrides: [
   overrides: [
     {
     {

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

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

+ 1 - 4
apps/app/config/migrate-mongo-config.js

@@ -8,7 +8,7 @@ const isProduction = process.env.NODE_ENV === 'production';
 
 
 const { URL } = require('url');
 const { URL } = require('url');
 
 
-const { initMongooseGlobalSettings, getMongoUri, mongoOptions } = isProduction
+const { getMongoUri, mongoOptions } = isProduction
   // eslint-disable-next-line import/extensions, import/no-unresolved
   // eslint-disable-next-line import/extensions, import/no-unresolved
   ? require('../dist/server/util/mongoose-utils')
   ? require('../dist/server/util/mongoose-utils')
   : require('../src/server/util/mongoose-utils');
   : require('../src/server/util/mongoose-utils');
@@ -19,9 +19,6 @@ if (migrationsDir == null) {
   throw new Error('An env var MIGRATIONS_DIR must be set.');
   throw new Error('An env var MIGRATIONS_DIR must be set.');
 }
 }
 
 
-
-initMongooseGlobalSettings();
-
 const mongoUri = getMongoUri();
 const mongoUri = getMongoUri();
 
 
 // parse url
 // parse url

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

@@ -0,0 +1,65 @@
+import mockRequire from 'mock-require';
+
+const { reRequire } = mockRequire;
+
+
+describe('config/migrate-mongo-config.js', () => {
+
+  test.concurrent('throws an error when MIGRATIONS_DIR is not set', () => {
+
+    const getMongoUriMock = vi.fn();
+    const mongoOptionsMock = vi.fn();
+
+    // mock for mongoose-utils
+    mockRequire('../src/server/util/mongoose-utils', {
+      getMongoUri: getMongoUriMock,
+      mongoOptions: mongoOptionsMock,
+    });
+
+    // use reRequire to avoid using module cache
+    const caller = () => reRequire('./migrate-mongo-config');
+
+    expect(caller).toThrow('An env var MIGRATIONS_DIR must be set.');
+
+    mockRequire.stop('../src/server/util/mongoose-utils');
+
+    expect(getMongoUriMock).not.toHaveBeenCalled();
+  });
+
+  describe.concurrent.each`
+    MONGO_URI                                         | expectedDbName
+    ${'mongodb://example.com/growi'}                  | ${'growi'}
+    ${'mongodb://user:pass@example.com/growi'}        | ${'growi'}
+    ${'mongodb://example.com/growi?replicaSet=mySet'} | ${'growi'}
+  `('returns', ({ MONGO_URI, expectedDbName }) => {
+
+    beforeEach(async() => {
+      process.env.MIGRATIONS_DIR = 'testdir/migrations';
+    });
+
+    test(`when 'MONGO_URI' is '${MONGO_URI}`, () => {
+
+      const getMongoUriMock = vi.fn(() => MONGO_URI);
+      const mongoOptionsMock = vi.fn();
+
+      // mock for mongoose-utils
+      mockRequire('../src/server/util/mongoose-utils', {
+        getMongoUri: getMongoUriMock,
+        mongoOptions: mongoOptionsMock,
+      });
+
+      // use reRequire to avoid using module cache
+      const { mongodb, migrationsDir, changelogCollectionName } = reRequire('./migrate-mongo-config');
+
+      mockRequire.stop('../src/server/util/mongoose-utils');
+
+      // expect(getMongoUriMock).toHaveBeenCalledOnce();
+      expect(mongodb.url).toBe(MONGO_URI);
+      expect(mongodb.databaseName).toBe(expectedDbName);
+      expect(mongodb.options).toBe(mongoOptionsMock);
+      expect(migrationsDir).toBe('testdir/migrations');
+      expect(changelogCollectionName).toBe('migrations');
+    });
+  });
+
+});

+ 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

+ 0 - 20
apps/app/jest.config.js

@@ -13,26 +13,6 @@ module.exports = {
   moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx'],
   moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx'],
 
 
   projects: [
   projects: [
-    {
-      displayName: 'unit',
-
-      transform: {
-        '^.+\\.(t|j)sx?$': '@swc/jest',
-      },
-      // transform ESM to CJS (includes all packages in node_modules)
-      transformIgnorePatterns: [],
-
-      rootDir: '.',
-      roots: ['<rootDir>'],
-      testMatch: ['<rootDir>/test/unit/**/*.test.ts', '<rootDir>/test/unit/**/*.test.js'],
-
-      testEnvironment: 'node',
-
-      // Automatically clear mock calls and instances between every test
-      clearMocks: true,
-      moduleNameMapper: MODULE_NAME_MAPPING,
-
-    },
     {
     {
       displayName: 'server',
       displayName: 'server',
 
 

+ 1 - 0
apps/app/next.config.js

@@ -30,6 +30,7 @@ const getTranspilePackages = () => {
     'hastscript',
     'hastscript',
     'html-void-elements',
     'html-void-elements',
     'is-absolute-url',
     'is-absolute-url',
+    'is-plain-obj',
     'longest-streak',
     'longest-streak',
     'micromark',
     'micromark',
     'property-information',
     'property-information',

+ 28 - 13
apps/app/package.json

@@ -1,6 +1,6 @@
 {
 {
   "name": "@growi/app",
   "name": "@growi/app",
-  "version": "6.1.1-RC.0",
+  "version": "6.1.5-RC.0",
   "license": "MIT",
   "license": "MIT",
   "scripts": {
   "scripts": {
     "//// for production": "",
     "//// for production": "",
@@ -33,16 +33,21 @@
     "lint:swagger2openapi": "node node_modules/.bin/oas-validate tmp/swagger.json",
     "lint:swagger2openapi": "node node_modules/.bin/oas-validate tmp/swagger.json",
     "lint": "run-p lint:*",
     "lint": "run-p lint:*",
     "prelint:swagger2openapi": "yarn openapi:v3",
     "prelint:swagger2openapi": "yarn openapi:v3",
-    "test": "cross-env NODE_ENV=test NODE_OPTIONS=\"--max-old-space-size=4096\" jest --logHeapUsage",
+    "test": "run-p test:*",
+    "test:jest": "cross-env NODE_ENV=test NODE_OPTIONS=\"--max-old-space-size=4096\" jest --logHeapUsage",
+    "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 --preid=RC"
   },
   },
   "// 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",
@@ -59,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.1-RC.0",
-    "@growi/hackmd": "^6.1.1-RC.0",
-    "@growi/preset-themes": "^6.1.1-RC.0",
-    "@growi/remark-attachment-refs": "^6.1.1-RC.0",
-    "@growi/remark-drawio": "^6.1.1-RC.0",
-    "@growi/remark-growi-directive": "^6.1.1-RC.0",
-    "@growi/remark-lsx": "^6.1.1-RC.0",
-    "@growi/slack": "^6.1.1-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",
@@ -129,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",
@@ -202,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.1-RC.0",
-    "@growi/ui": "^6.1.1-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",
@@ -219,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",

+ 11 - 0
apps/app/public/images/icons/editor/attachment.svg

@@ -0,0 +1,11 @@
+<svg id="group_5327" data-name="group 5327" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="24" height="28.093" viewBox="0 0 24 28.093">
+  <defs>
+    <clipPath id="clip-path">
+      <rect id="rectangle_1922" data-name="rectangle 1922" width="24" height="28.093" fill="#6c757d" stroke="rgba(0,0,0,0)" stroke-width="1"/>
+    </clipPath>
+  </defs>
+  <g id="group_5319" data-name="group 5319" clip-path="url(#clip-path)">
+    <path id="pass_4850" data-name="pass 4850" d="M20.6,16.976l-.651,1.17a4.292,4.292,0,0,1-.828,1.031V21H13.7v5.619H1.479V1.479H19.123v2a1.932,1.932,0,0,1,.2.094l1.282.714V0H0V28.093H15.18v0h0L20.6,22.48l-.006-.006H20.6ZM15.18,25.957V22.474h3.369Z" fill="#6c757d" stroke="rgba(0,0,0,0)" stroke-width="1"/>
+    <path id="pass_4851" data-name="pass 4851" d="M203.477,65.236a.648.648,0,0,1,.509.96l-5.117,9.2a3.483,3.483,0,0,0,1.537,4.427,3.8,3.8,0,0,0,3.11.3,3.293,3.293,0,0,0,1.744-1.212l4.784-8.6-3.846-2.14L201.727,76.2c0,.007-.36.684.2,1a.825.825,0,0,0,.689.1.9.9,0,0,0,.461-.417l3.591-6.454,1.131.629-3.592,6.454a2.176,2.176,0,0,1-1.158,1.008,2.074,2.074,0,0,1-1.752-.19,1.832,1.832,0,0,1-.973-1.509,2.366,2.366,0,0,1,.271-1.248l4.786-8.6a.647.647,0,0,1,.88-.251l4.978,2.77a.647.647,0,0,1,.251.88l-5.1,9.163a4.531,4.531,0,0,1-2.469,1.811,5.062,5.062,0,0,1-4.146-.4,4.767,4.767,0,0,1-2.039-6.188l5.117-9.2a.648.648,0,0,1,.622-.33" transform="translate(-187.572 -62.019)" fill="#6c757d" stroke="rgba(0,0,0,0)" stroke-width="1"/>
+  </g>
+</svg>

+ 14 - 10
apps/app/public/static/locales/en_US/admin.json

@@ -290,7 +290,7 @@
     "management_wiki": "Management Wiki",
     "management_wiki": "Management Wiki",
     "system_information": "System information",
     "system_information": "System information",
     "wiki_administrator": "Only wiki administrator can access this page",
     "wiki_administrator": "Only wiki administrator can access this page",
-    "assign_administrator": "You can assign the selected user to be a wiki administrator on the User Management page using the 'Give admin access' button",
+    "assign_administrator": "You can assign the selected user to be a wiki administrator on the User Management page using the 'Grant admin access' button",
     "package_name": "Package name",
     "package_name": "Package name",
     "specified_version": "Specified version",
     "specified_version": "Specified version",
     "installed_version": "Installed version",
     "installed_version": "Installed version",
@@ -743,9 +743,9 @@
       "accept": "Accept",
       "accept": "Accept",
       "deactivate_account": "Deactivate account",
       "deactivate_account": "Deactivate account",
       "your_own": "You cannot deactivate your own account",
       "your_own": "You cannot deactivate your own account",
-      "remove_admin_access": "Remove admin access",
-      "cannot_remove": "You cannot remove yourself from administrator",
-      "give_admin_access": "Give admin access",
+      "revoke_admin_access": "Revoke admin access",
+      "cannot_revoke": "You cannot revoke yourself from administrator",
+      "grant_admin_access": "Grant admin access",
       "revoke_read_only_access": "Revoke read only access",
       "revoke_read_only_access": "Revoke read only access",
       "grant_read_only_access": "Grant read only access",
       "grant_read_only_access": "Grant read only access",
       "send_invitation_email": "Send invitation email",
       "send_invitation_email": "Send invitation email",
@@ -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",
@@ -1018,8 +1022,8 @@
     "ADMIN_USERS_PASSWORD_RESET": "Reset user password",
     "ADMIN_USERS_PASSWORD_RESET": "Reset user password",
     "ADMIN_USERS_ACTIVATE": "Activate user",
     "ADMIN_USERS_ACTIVATE": "Activate user",
     "ADMIN_USERS_DEACTIVATE": "Deactivate user",
     "ADMIN_USERS_DEACTIVATE": "Deactivate user",
-    "ADMIN_USERS_GIVE_ADMIN": "Give admin access",
-    "ADMIN_USERS_REMOVE_ADMIN": "Remove admin access",
+    "ADMIN_USERS_GRANT_ADMIN": "Grant admin access",
+    "ADMIN_USERS_REVOKE_ADMIN": "Revoke admin access",
     "ADMIN_USERS_GRANT_READ_ONLY": "Grant read only access",
     "ADMIN_USERS_GRANT_READ_ONLY": "Grant read only access",
     "ADMIN_USERS_REVOKE_READ_ONLY": "Revoke read only access",
     "ADMIN_USERS_REVOKE_READ_ONLY": "Revoke read only access",
     "ADMIN_USERS_SEND_INVITATION_EMAIL": "Resend invitation email",
     "ADMIN_USERS_SEND_INVITATION_EMAIL": "Resend invitation email",
@@ -1038,8 +1042,8 @@
     "error_send_growi_archive": "Failed to send GROWI archive file to the destination GROWI"
     "error_send_growi_archive": "Failed to send GROWI archive file to the destination GROWI"
   },
   },
   "toaster": {
   "toaster": {
-    "give_user_admin": "Succeeded to give {{username}} admin",
-    "remove_user_admin": "Succeeded to remove {{username}} admin",
+    "grant_user_admin": "Succeeded to grant {{username}} admin",
+    "revoke_user_admin": "Succeeded to revoke {{username}} admin",
     "grant_user_read_only": "Succeeded to grant {{username}} read only",
     "grant_user_read_only": "Succeeded to grant {{username}} read only",
     "revoke_user_read_only": "Succeeded to revoke {{username}} read only",
     "revoke_user_read_only": "Succeeded to revoke {{username}} read only",
     "activate_user_success": "Succeeded to activating {{username}}",
     "activate_user_success": "Succeeded to activating {{username}}",

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

@@ -2,9 +2,11 @@
   "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",
+  "Delete": "Delete",
 
 
   "meta": {
   "meta": {
     "display_name": "English"
     "display_name": "English"
@@ -22,7 +24,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"

+ 8 - 7
apps/app/public/static/locales/en_US/translation.json

@@ -168,7 +168,7 @@
     "could_not_creata_path": "Couldn't create path."
     "could_not_creata_path": "Couldn't create path."
   },
   },
   "custom_navigation": {
   "custom_navigation": {
-    "no_page_list": "There are no pages under this page."
+    "no_pages_under_this_page": "There are no pages under this page."
   },
   },
   "installer": {
   "installer": {
     "tab": "Create account",
     "tab": "Create account",
@@ -445,12 +445,6 @@
     "file_upload_succeeded": "File upload succeeded.",
     "file_upload_succeeded": "File upload succeeded.",
     "file_upload_failed": "File upload failed.",
     "file_upload_failed": "File upload failed.",
     "initialize_successed": "Succeeded to initialize {{target}}",
     "initialize_successed": "Succeeded to initialize {{target}}",
-    "give_user_admin": "Succeeded to give {{username}} admin",
-    "remove_user_admin": "Succeeded to remove {{username}} admin",
-    "activate_user_success": "Succeeded to activating {{username}}",
-    "deactivate_user_success": "Succeeded to deactivate {{username}}",
-    "remove_user_success": "Succeeded to removing {{username}}",
-    "remove_external_user_success": "Succeeded to remove {{accountId}}",
     "remove_share_link_success": "Succeeded to remove {{shareLinkId}}",
     "remove_share_link_success": "Succeeded to remove {{shareLinkId}}",
     "issue_share_link": "Succeeded to issue new share link",
     "issue_share_link": "Succeeded to issue new share link",
     "remove_share_link": "Succeeded to remove {{count}} share links",
     "remove_share_link": "Succeeded to remove {{count}} share links",
@@ -460,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"
     },
     },
@@ -825,5 +820,11 @@
     "tags_input": {
     "tags_input": {
       "tag_name": "tag name"
       "tag_name": "tag name"
     }
     }
+  },
+  "delete_attachment_modal": {
+    "confirm_delete_attachment": "Delete attachment?"
+  },
+  "rich_attachment": {
+    "attachment_not_be_found": "The attachment could not be found"
   }
   }
 }
 }

+ 13 - 9
apps/app/public/static/locales/ja_JP/admin.json

@@ -751,9 +751,9 @@
       "accept": "承認する",
       "accept": "承認する",
       "deactivate_account": "アカウント停止",
       "deactivate_account": "アカウント停止",
       "your_own": "自分自身のアカウントを停止することはできません",
       "your_own": "自分自身のアカウントを停止することはできません",
-      "remove_admin_access": "管理者から外す",
-      "cannot_remove": "自分自身を管理者から外すことはできません",
-      "give_admin_access": "管理者にする",
+      "revoke_admin_access": "管理者から外す",
+      "cannot_revoke": "自分自身を管理者から外すことはできません",
+      "grant_admin_access": "管理者にする",
       "revoke_read_only_access": "閲覧のみアクセス権を外す",
       "revoke_read_only_access": "閲覧のみアクセス権を外す",
       "grant_read_only_access": "閲覧のみアクセス権を付与する",
       "grant_read_only_access": "閲覧のみアクセス権を付与する",
       "send_invitation_email": "招待メールの送信",
       "send_invitation_email": "招待メールの送信",
@@ -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": "インストール",
@@ -1026,8 +1030,8 @@
     "ADMIN_USERS_PASSWORD_RESET": "ユーザーのパスワードをリセット",
     "ADMIN_USERS_PASSWORD_RESET": "ユーザーのパスワードをリセット",
     "ADMIN_USERS_ACTIVATE": "ユーザーを承認する",
     "ADMIN_USERS_ACTIVATE": "ユーザーを承認する",
     "ADMIN_USERS_DEACTIVATE": "ユーザーを停止する",
     "ADMIN_USERS_DEACTIVATE": "ユーザーを停止する",
-    "ADMIN_USERS_GIVE_ADMIN": "管理者にする",
-    "ADMIN_USERS_REMOVE_ADMIN": "管理者から外す",
+    "ADMIN_USERS_GRANT_ADMIN": "管理者にする",
+    "ADMIN_USERS_REVOKE_ADMIN": "管理者から外す",
     "ADMIN_USERS_GRANT_READ_ONLY": "閲覧のみアクセス権を付与する",
     "ADMIN_USERS_GRANT_READ_ONLY": "閲覧のみアクセス権を付与する",
     "ADMIN_USERS_REVOKE_READ_ONLY": "閲覧のみアクセス権を外す",
     "ADMIN_USERS_REVOKE_READ_ONLY": "閲覧のみアクセス権を外す",
     "ADMIN_USERS_SEND_INVITATION_EMAIL": "招待メールの再送信",
     "ADMIN_USERS_SEND_INVITATION_EMAIL": "招待メールの再送信",
@@ -1046,8 +1050,8 @@
     "error_send_growi_archive": "GROWI アーカイブファイルの送信に失敗しました"
     "error_send_growi_archive": "GROWI アーカイブファイルの送信に失敗しました"
   },
   },
   "toaster": {
   "toaster": {
-    "give_user_admin": "{{username}}を管理者に設定しました",
-    "remove_user_admin": "{{username}}を管理者から外しました",
+    "grant_user_admin": "{{username}}を管理者に設定しました",
+    "revoke_user_admin": "{{username}}を管理者から外しました",
     "grant_user_read_only": "{{username}}に閲覧のみアクセス権を付与しました",
     "grant_user_read_only": "{{username}}に閲覧のみアクセス権を付与しました",
     "revoke_user_read_only": "{{username}}から閲覧のみアクセス権を外しました",
     "revoke_user_read_only": "{{username}}から閲覧のみアクセス権を外しました",
     "activate_user_success": "{{username}}を有効化しました",
     "activate_user_success": "{{username}}を有効化しました",

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

@@ -2,12 +2,14 @@
   "Show": "公開",
   "Show": "公開",
   "Hide": "非公開",
   "Hide": "非公開",
   "Add": "追加",
   "Add": "追加",
+  "Insert": "挿入",
   "Reset": "リセット",
   "Reset": "リセット",
   "Sign out": "ログアウト",
   "Sign out": "ログアウト",
   "New": "作成",
   "New": "作成",
   "Send": "送信",
   "Send": "送信",
   "Close": "閉じる",
   "Close": "閉じる",
   "Done": "完了",
   "Done": "完了",
+  "Delete": "削除",
   "meta": {
   "meta": {
     "display_name": "日本語"
     "display_name": "日本語"
   },
   },
@@ -24,7 +26,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": "アプリ設定"

+ 8 - 7
apps/app/public/static/locales/ja_JP/translation.json

@@ -169,7 +169,7 @@
     "could_not_creata_path": "パスを作成できませんでした。"
     "could_not_creata_path": "パスを作成できませんでした。"
   },
   },
   "custom_navigation": {
   "custom_navigation": {
-    "no_page_list": "このページの配下にはページが存在しません。"
+    "no_pages_under_this_page": "このページの配下にはページが存在しません。"
   },
   },
   "installer": {
   "installer": {
     "tab": "アカウント作成",
     "tab": "アカウント作成",
@@ -478,12 +478,6 @@
     "file_upload_succeeded": "ファイルをアップロードしました",
     "file_upload_succeeded": "ファイルをアップロードしました",
     "file_upload_failed": "ファイルのアップロードに失敗しました",
     "file_upload_failed": "ファイルのアップロードに失敗しました",
     "initialize_successed": "{{target}}を初期化しました",
     "initialize_successed": "{{target}}を初期化しました",
-    "give_user_admin": "{{username}}を管理者に設定しました",
-    "remove_user_admin": "{{username}}を管理者から外しました",
-    "activate_user_success": "{{username}}を有効化しました",
-    "deactivate_user_success": "{{username}}を無効化しました",
-    "remove_user_success": "{{username}}を削除しました",
-    "remove_external_user_success": "{{accountId}}を削除しました",
     "remove_share_link_success": "{{shareLinkId}}を削除しました",
     "remove_share_link_success": "{{shareLinkId}}を削除しました",
     "issue_share_link": "共有リンクを作成しました",
     "issue_share_link": "共有リンクを作成しました",
     "remove_share_link": "共有リンクを{{count}}件削除しました",
     "remove_share_link": "共有リンクを{{count}}件削除しました",
@@ -493,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": "配下にテンプレートページを作成"
     },
     },
@@ -858,5 +853,11 @@
     "tags_input": {
     "tags_input": {
       "tag_name": "タグ名"
       "tag_name": "タグ名"
     }
     }
+  },
+  "delete_attachment_modal": {
+    "confirm_delete_attachment": "アタッチメントを削除しますか?"
+  },
+  "rich_attachment": {
+    "attachment_not_be_found": "アタッチメントが見つかりません"
   }
   }
 }
 }

+ 13 - 9
apps/app/public/static/locales/zh_CN/admin.json

@@ -751,9 +751,9 @@
       "accept": "接受",
       "accept": "接受",
       "deactivate_account": "停用帐户",
       "deactivate_account": "停用帐户",
       "your_own": "您不能停用自己的帐户",
       "your_own": "您不能停用自己的帐户",
-      "remove_admin_access": "删除管理员访问权限",
-      "cannot_remove": "您不能从管理员中删除自己",
-      "give_admin_access": "授予管理员访问权限",
+      "revoke_admin_access": "删除管理员访问权限",
+      "cannot_revoke": "您不能从管理员中删除自己",
+      "grant_admin_access": "授予管理员访问权限",
       "revoke_read_only_access": "取消只读访问",
       "revoke_read_only_access": "取消只读访问",
       "grant_read_only_access": "给予只读权限",
       "grant_read_only_access": "给予只读权限",
       "send_invitation_email": "发送邀请邮件",
       "send_invitation_email": "发送邀请邮件",
@@ -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",
@@ -1026,8 +1030,8 @@
     "ADMIN_USERS_PASSWORD_RESET": "重置用户密码",
     "ADMIN_USERS_PASSWORD_RESET": "重置用户密码",
     "ADMIN_USERS_ACTIVATE": "激活用户",
     "ADMIN_USERS_ACTIVATE": "激活用户",
     "ADMIN_USERS_DEACTIVATE": "停用用户",
     "ADMIN_USERS_DEACTIVATE": "停用用户",
-    "ADMIN_USERS_GIVE_ADMIN": "授予管理员访问权限",
-    "ADMIN_USERS_REMOVE_ADMIN": "删除管理员访问权限",
+    "ADMIN_USERS_GRANT_ADMIN": "授予管理员访问权限",
+    "ADMIN_USERS_REVOKE_ADMIN": "删除管理员访问权限",
     "ADMIN_USERS_GRANT_READ_ONLY": "给予只读权限",
     "ADMIN_USERS_GRANT_READ_ONLY": "给予只读权限",
     "ADMIN_USERS_REVOKE_READ_ONLY": "取消只读访问",
     "ADMIN_USERS_REVOKE_READ_ONLY": "取消只读访问",
     "ADMIN_USERS_SEND_INVITATION_EMAIL": "重发邀请函",
     "ADMIN_USERS_SEND_INVITATION_EMAIL": "重发邀请函",
@@ -1046,8 +1050,8 @@
     "error_send_growi_archive": "Failed to send GROWI archive file to the destination GROWI"
     "error_send_growi_archive": "Failed to send GROWI archive file to the destination GROWI"
   },
   },
   "toaster": {
   "toaster": {
-    "give_user_admin": "Succeeded to give {{username}} admin",
-    "remove_user_admin": "Succeeded to remove {{username}} admin",
+    "grant_user_admin": "Succeeded to grant {{username}} admin",
+    "revoke_user_admin": "Succeeded to revoke {{username}} admin",
     "grant_user_read_only": "Succeeded to grant {{username}} read only",
     "grant_user_read_only": "Succeeded to grant {{username}} read only",
     "revoke_user_read_only": "Succeeded to revoke {{username}} read only",
     "revoke_user_read_only": "Succeeded to revoke {{username}} read only",
 		"activate_user_success": "Succeeded to activating {{username}}",
 		"activate_user_success": "Succeeded to activating {{username}}",

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

@@ -2,12 +2,14 @@
 	"Show": "显示",
 	"Show": "显示",
 	"Hide": "隐藏",
 	"Hide": "隐藏",
   "Add": "添加",
   "Add": "添加",
+  "Insert": "插入",
   "Reset": "重启",
   "Reset": "重启",
 	"Sign out": "退出",
 	"Sign out": "退出",
   "New": "新建",
   "New": "新建",
   "Send": "发送",
   "Send": "发送",
   "Close": "关闭",
   "Close": "关闭",
   "Done": "完成",
   "Done": "完成",
+  "Delete": "删除",
 
 
   "meta": {
   "meta": {
     "display_name": "简体中文"
     "display_name": "简体中文"
@@ -25,7 +27,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": "系统设置"

+ 8 - 7
apps/app/public/static/locales/zh_CN/translation.json

@@ -175,7 +175,7 @@
     "could_not_creata_path": "无法创建路径"
     "could_not_creata_path": "无法创建路径"
   },
   },
   "custom_navigation": {
   "custom_navigation": {
-    "no_page_list": "There are no pages under this page."
+    "no_pages_under_this_page": "There are no pages under this page."
   },
   },
 	"installer": {
 	"installer": {
     "tab": "创建账户",
     "tab": "创建账户",
@@ -434,12 +434,6 @@
     "file_upload_succeeded": "文件上传成功",
     "file_upload_succeeded": "文件上传成功",
     "file_upload_failed": "文件上传失败",
     "file_upload_failed": "文件上传失败",
     "initialize_successed": "Succeeded to initialize {{target}}",
     "initialize_successed": "Succeeded to initialize {{target}}",
-		"give_user_admin": "Succeeded to give {{username}} admin",
-    "remove_user_admin": "Succeeded to remove {{username}} admin ",
-		"activate_user_success": "Succeeded to activating {{username}}",
-		"deactivate_user_success": "Succeeded to deactivate {{username}}",
-		"remove_user_success": "Succeeded to removing {{username}} ",
-    "remove_external_user_success": "Succeeded to remove {{accountId}} ",
     "switch_disable_link_sharing_success": "成功更新分享链接设置",
     "switch_disable_link_sharing_success": "成功更新分享链接设置",
     "failed_to_reset_password":"Failed to reset password",
     "failed_to_reset_password":"Failed to reset password",
     "save_succeeded": "已成功保存",
     "save_succeeded": "已成功保存",
@@ -447,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": "在下面创建模板页"
 		},
 		},
@@ -828,5 +823,11 @@
     "tags_input": {
     "tags_input": {
       "tag_name": "标签名称"
       "tag_name": "标签名称"
     }
     }
+  },
+  "delete_attachment_modal": {
+    "confirm_delete_attachment": "你想删除一个附件吗?"
+  },
+  "rich_attachment": {
+    "attachment_not_be_found": "没有找到附件"
   }
   }
 }
 }

+ 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/zh_CN/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/en_US/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/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/en_US/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/zh_CN/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


+ 6 - 6
apps/app/src/client/services/AdminUsersContainer.js

@@ -205,26 +205,26 @@ export default class AdminUsersContainer extends Container {
   }
   }
 
 
   /**
   /**
-   * Give user admin
+   * Grant user admin
    * @memberOf AdminUsersContainer
    * @memberOf AdminUsersContainer
    * @param {string} userId
    * @param {string} userId
    * @return {string} username
    * @return {string} username
    */
    */
-  async giveUserAdmin(userId) {
-    const response = await apiv3Put(`/users/${userId}/giveAdmin`);
+  async grantUserAdmin(userId) {
+    const response = await apiv3Put(`/users/${userId}/grant-admin`);
     const { username } = response.data.userData;
     const { username } = response.data.userData;
     await this.retrieveUsersByPagingNum(this.state.activePage);
     await this.retrieveUsersByPagingNum(this.state.activePage);
     return username;
     return username;
   }
   }
 
 
   /**
   /**
-   * Remove user admin
+   * Revoke user admin
    * @memberOf AdminUsersContainer
    * @memberOf AdminUsersContainer
    * @param {string} userId
    * @param {string} userId
    * @return {string} username
    * @return {string} username
    */
    */
-  async removeUserAdmin(userId) {
-    const response = await apiv3Put(`/users/${userId}/removeAdmin`);
+  async revokeUserAdmin(userId) {
+    const response = await apiv3Put(`/users/${userId}/revoke-admin`);
     const { username } = response.data.userData;
     const { username } = response.data.userData;
     await this.retrieveUsersByPagingNum(this.state.activePage);
     await this.retrieveUsersByPagingNum(this.state.activePage);
     return username;
     return username;

+ 68 - 58
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';
@@ -14,17 +14,18 @@ import math from 'remark-math';
 import deepmerge from 'ts-deepmerge';
 import deepmerge from 'ts-deepmerge';
 import type { Pluggable } from 'unified';
 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 { RichAttachment } from '~/components/ReactMarkdownComponents/RichAttachment';
 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';
 import * as addLineNumberAttribute from '~/services/renderer/rehype-plugins/add-line-number-attribute';
 import * as addLineNumberAttribute from '~/services/renderer/rehype-plugins/add-line-number-attribute';
 import * as keywordHighlighter from '~/services/renderer/rehype-plugins/keyword-highlighter';
 import * as keywordHighlighter from '~/services/renderer/rehype-plugins/keyword-highlighter';
 import * as relocateToc from '~/services/renderer/rehype-plugins/relocate-toc';
 import * as relocateToc from '~/services/renderer/rehype-plugins/relocate-toc';
+import * as attachment from '~/services/renderer/remark-plugins/attachment';
 import * as plantuml from '~/services/renderer/remark-plugins/plantuml';
 import * as plantuml from '~/services/renderer/remark-plugins/plantuml';
 import * as xsvToTable from '~/services/renderer/remark-plugins/xsv-to-table';
 import * as xsvToTable from '~/services/renderer/remark-plugins/xsv-to-table';
 import {
 import {
@@ -58,11 +59,12 @@ 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,
+    attachment.remarkPlugin,
+    lsxGrowiDirective.remarkPlugin,
+    refsGrowiDirective.remarkPlugin,
   );
   );
   if (config.isEnabledLinebreaks) {
   if (config.isEnabledLinebreaks) {
     remarkPlugins.push(breaks);
     remarkPlugins.push(breaks);
@@ -75,18 +77,19 @@ 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,
+      attachment.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 +103,16 @@ 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;
+    components.attachment = RichAttachment;
   }
   }
 
 
   if (config.isEnabledXssPrevention) {
   if (config.isEnabledXssPrevention) {
@@ -164,11 +168,12 @@ 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,
+    attachment.remarkPlugin,
+    lsxGrowiDirective.remarkPlugin,
+    refsGrowiDirective.remarkPlugin,
   );
   );
 
 
   const isEnabledLinebreaks = overrideIsEnabledLinebreaks ?? config.isEnabledLinebreaks;
   const isEnabledLinebreaks = overrideIsEnabledLinebreaks ?? config.isEnabledLinebreaks;
@@ -185,17 +190,18 @@ 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,
+      attachment.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 +209,15 @@ 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;
+    components.attachment = RichAttachment;
   }
   }
 
 
   if (config.isEnabledXssPrevention) {
   if (config.isEnabledXssPrevention) {
@@ -241,11 +248,12 @@ 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,
+    attachment.remarkPlugin,
+    lsxGrowiDirective.remarkPlugin,
+    refsGrowiDirective.remarkPlugin,
   );
   );
   if (config.isEnabledLinebreaks) {
   if (config.isEnabledLinebreaks) {
     remarkPlugins.push(breaks);
     remarkPlugins.push(breaks);
@@ -258,18 +266,19 @@ 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,
+      attachment.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 +286,15 @@ 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;
+    components.attachment = RichAttachment;
   }
   }
 
 
   if (config.isEnabledXssPrevention) {
   if (config.isEnabledXssPrevention) {

+ 4 - 2
apps/app/src/client/util/bookmark-utils.ts

@@ -41,6 +41,8 @@ export const toggleBookmark = async(pageId: string, status: boolean): Promise<vo
 };
 };
 
 
 // Update Bookmark folder
 // Update Bookmark folder
-export const updateBookmarkFolder = async(bookmarkFolderId: string, name: string, parent: string | null): Promise<void> => {
-  await apiv3Put('/bookmark-folder', { bookmarkFolderId, name, parent });
+export const updateBookmarkFolder = async(bookmarkFolderId: string, name: string, parent: string | null, children: BookmarkFolderItems[]): Promise<void> => {
+  await apiv3Put('/bookmark-folder', {
+    bookmarkFolderId, name, parent, children,
+  });
 };
 };

+ 11 - 10
apps/app/src/components/Admin/UserGroup/UserGroupForm.tsx

@@ -7,9 +7,10 @@ import { IUserGroupHasId } from '~/interfaces/user';
 
 
 type Props = {
 type Props = {
   userGroup: IUserGroupHasId,
   userGroup: IUserGroupHasId,
+  parentUserGroup?: IUserGroupHasId,
   selectableParentUserGroups?: IUserGroupHasId[],
   selectableParentUserGroups?: IUserGroupHasId[],
   submitButtonLabel: string;
   submitButtonLabel: string;
-  onSubmit?: (targetGroup: IUserGroupHasId, userGroupData: Partial<IUserGroupHasId>) => Promise<void> | void
+  onSubmit: (targetGroup: IUserGroupHasId, userGroupData: Partial<IUserGroupHasId>) => Promise<void>
 };
 };
 
 
 export const UserGroupForm: FC<Props> = (props: Props) => {
 export const UserGroupForm: FC<Props> = (props: Props) => {
@@ -17,16 +18,14 @@ export const UserGroupForm: FC<Props> = (props: Props) => {
   const { t } = useTranslation('admin');
   const { t } = useTranslation('admin');
 
 
   const {
   const {
-    userGroup, selectableParentUserGroups, submitButtonLabel, onSubmit,
+    userGroup, parentUserGroup, selectableParentUserGroups, submitButtonLabel, onSubmit,
   } = props;
   } = props;
-
   /*
   /*
    * State
    * State
    */
    */
-  const [currentName, setName] = useState(userGroup != null ? userGroup.name : '');
-  const [currentDescription, setDescription] = useState(userGroup != null ? userGroup.description : '');
-  const [selectedParent, setSelectedParent] = useState<IUserGroupHasId | undefined>(userGroup?.parent as IUserGroupHasId);
-
+  const [currentName, setName] = useState<string>(userGroup.name);
+  const [currentDescription, setDescription] = useState<string>(userGroup.description);
+  const [selectedParent, setSelectedParent] = useState<IUserGroupHasId | undefined>(parentUserGroup);
   /*
   /*
    * Function
    * Function
    */
    */
@@ -44,10 +43,12 @@ export const UserGroupForm: FC<Props> = (props: Props) => {
     }
     }
   }, [selectedParent, setSelectedParent]);
   }, [selectedParent, setSelectedParent]);
 
 
+  const isSelectableParentUserGroups = selectableParentUserGroups != null && selectableParentUserGroups.length > 0;
+
   return (
   return (
     <form onSubmit={(e) => {
     <form onSubmit={(e) => {
       e.preventDefault();
       e.preventDefault();
-      onSubmit?.(props.userGroup, {
+      onSubmit(props.userGroup, {
         name: currentName,
         name: currentName,
         description: currentDescription,
         description: currentDescription,
         parent: selectedParent,
         parent: selectedParent,
@@ -103,14 +104,14 @@ export const UserGroupForm: FC<Props> = (props: Props) => {
               id="dropdownMenuButton"
               id="dropdownMenuButton"
               data-toggle="dropdown"
               data-toggle="dropdown"
               className={`
               className={`
-                btn btn-outline-secondary dropdown-toggle mb-3 ${selectableParentUserGroups != null && selectableParentUserGroups.length > 0 ? '' : 'disabled'}
+                btn btn-outline-secondary dropdown-toggle mb-3 ${isSelectableParentUserGroups ? '' : 'disabled'}
               `}
               `}
             >
             >
               {selectedParent?.name ?? t('user_group_management.select_parent_group')}
               {selectedParent?.name ?? t('user_group_management.select_parent_group')}
             </button>
             </button>
             <div className="dropdown-menu" aria-labelledby="dropdownMenuButton">
             <div className="dropdown-menu" aria-labelledby="dropdownMenuButton">
               {
               {
-                (selectableParentUserGroups != null && selectableParentUserGroups.length > 0) && (
+                isSelectableParentUserGroups && (
                   <>
                   <>
                     {
                     {
                       selectableParentUserGroups.map(userGroup => (
                       selectableParentUserGroups.map(userGroup => (

+ 9 - 2
apps/app/src/components/Admin/UserGroupDetail/UserGroupDetailPage.tsx

@@ -21,9 +21,12 @@ import {
   useSWRxUserGroupPages, useSWRxUserGroupRelationList, useSWRxChildUserGroupList, useSWRxUserGroup,
   useSWRxUserGroupPages, useSWRxUserGroupRelationList, useSWRxChildUserGroupList, useSWRxUserGroup,
   useSWRxSelectableParentUserGroups, useSWRxSelectableChildUserGroups, useSWRxAncestorUserGroups, useSWRxUserGroupRelations,
   useSWRxSelectableParentUserGroups, useSWRxSelectableChildUserGroups, useSWRxAncestorUserGroups, useSWRxUserGroupRelations,
 } from '~/stores/user-group';
 } from '~/stores/user-group';
+import loggerFactory from '~/utils/logger';
 
 
 import styles from './UserGroupDetailPage.module.scss';
 import styles from './UserGroupDetailPage.module.scss';
 
 
+const logger = loggerFactory('growi:services:AdminCustomizeContainer');
+
 const UserGroupPageList = dynamic(() => import('./UserGroupPageList'), { ssr: false });
 const UserGroupPageList = dynamic(() => import('./UserGroupPageList'), { ssr: false });
 const UserGroupUserTable = dynamic(() => import('./UserGroupUserTable'), { ssr: false });
 const UserGroupUserTable = dynamic(() => import('./UserGroupUserTable'), { ssr: false });
 
 
@@ -48,6 +51,7 @@ const UserGroupDetailPage = (props: Props): JSX.Element => {
   const { userGroupId: currentUserGroupId } = props;
   const { userGroupId: currentUserGroupId } = props;
 
 
   const { data: currentUserGroup } = useSWRxUserGroup(currentUserGroupId);
   const { data: currentUserGroup } = useSWRxUserGroup(currentUserGroupId);
+
   const [searchType, setSearchType] = useState<SearchType>(SearchTypes.PARTIAL);
   const [searchType, setSearchType] = useState<SearchType>(SearchTypes.PARTIAL);
   const [isAlsoMailSearched, setAlsoMailSearched] = useState<boolean>(false);
   const [isAlsoMailSearched, setAlsoMailSearched] = useState<boolean>(false);
   const [isAlsoNameSearched, setAlsoNameSearched] = useState<boolean>(false);
   const [isAlsoNameSearched, setAlsoNameSearched] = useState<boolean>(false);
@@ -91,6 +95,7 @@ const UserGroupDetailPage = (props: Props): JSX.Element => {
 
 
   const { open: openUpdateParentConfirmModal } = useUpdateUserGroupConfirmModal();
   const { open: openUpdateParentConfirmModal } = useUpdateUserGroupConfirmModal();
 
 
+  const parentUserGroup = selectableParentUserGroups?.find(selectableParentUserGroup => selectableParentUserGroup._id === currentUserGroup?.parent);
   /*
   /*
    * Function
    * Function
    */
    */
@@ -135,9 +140,10 @@ const UserGroupDetailPage = (props: Props): JSX.Element => {
     [t, updateUserGroup],
     [t, updateUserGroup],
   );
   );
 
 
-  const onClickSubmitForm = useCallback(async(targetGroup: IUserGroupHasId, userGroupData: Partial<IUserGroupHasId>): Promise<void> => {
-    if (typeof userGroupData?.parent === 'string') {
+  const onClickSubmitForm = useCallback(async(targetGroup: IUserGroupHasId, userGroupData: IUserGroupHasId) => {
+    if (typeof userGroupData.parent === 'string') {
       toastError(t('Something went wrong. Please try again.'));
       toastError(t('Something went wrong. Please try again.'));
+      logger.error('Something went wrong.');
       return;
       return;
     }
     }
 
 
@@ -356,6 +362,7 @@ const UserGroupDetailPage = (props: Props): JSX.Element => {
       <div className="mt-4 form-box">
       <div className="mt-4 form-box">
         <UserGroupForm
         <UserGroupForm
           userGroup={currentUserGroup}
           userGroup={currentUserGroup}
+          parentUserGroup={parentUserGroup}
           selectableParentUserGroups={selectableParentUserGroups}
           selectableParentUserGroups={selectableParentUserGroups}
           submitButtonLabel={t('Update')}
           submitButtonLabel={t('Update')}
           onSubmit={onClickSubmitForm}
           onSubmit={onClickSubmitForm}

+ 9 - 9
apps/app/src/components/Admin/Users/GiveAdminButton.tsx → apps/app/src/components/Admin/Users/GrantAdminButton.tsx

@@ -8,20 +8,20 @@ import { toastSuccess, toastError } from '~/client/util/toastr';
 
 
 import { withUnstatedContainers } from '../../UnstatedUtils';
 import { withUnstatedContainers } from '../../UnstatedUtils';
 
 
-type GiveAdminButtonProps = {
+type GrantAdminButtonProps = {
   adminUsersContainer: AdminUsersContainer,
   adminUsersContainer: AdminUsersContainer,
   user: IUserHasId,
   user: IUserHasId,
 }
 }
 
 
-const GiveAdminButton = (props: GiveAdminButtonProps): JSX.Element => {
+const GrantAdminButton = (props: GrantAdminButtonProps): JSX.Element => {
 
 
   const { t } = useTranslation('admin');
   const { t } = useTranslation('admin');
   const { adminUsersContainer, user } = props;
   const { adminUsersContainer, user } = props;
 
 
-  const onClickGiveAdminBtnHandler = useCallback(async() => {
+  const onClickGrantAdminBtnHandler = useCallback(async() => {
     try {
     try {
-      const username = await adminUsersContainer.giveUserAdmin(user._id);
-      toastSuccess(t('toaster.give_user_admin', { username }));
+      const username = await adminUsersContainer.grantUserAdmin(user._id);
+      toastSuccess(t('toaster.grant_user_admin', { username }));
     }
     }
     catch (err) {
     catch (err) {
       toastError(err);
       toastError(err);
@@ -29,8 +29,8 @@ const GiveAdminButton = (props: GiveAdminButtonProps): JSX.Element => {
   }, [adminUsersContainer, t, user._id]);
   }, [adminUsersContainer, t, user._id]);
 
 
   return (
   return (
-    <button className="dropdown-item" type="button" onClick={() => onClickGiveAdminBtnHandler()}>
-      <i className="icon-fw icon-user-following"></i> {t('user_management.user_table.give_admin_access')}
+    <button className="dropdown-item" type="button" onClick={() => onClickGrantAdminBtnHandler()}>
+      <i className="icon-fw icon-user-following"></i> {t('user_management.user_table.grant_admin_access')}
     </button>
     </button>
   );
   );
 
 
@@ -40,6 +40,6 @@ const GiveAdminButton = (props: GiveAdminButtonProps): JSX.Element => {
  * Wrapper component for using unstated
  * Wrapper component for using unstated
  */
  */
 // eslint-disable-next-line max-len
 // eslint-disable-next-line max-len
-const GiveAdminButtonWrapper: React.ForwardRefExoticComponent<Pick<any, string | number | symbol> & React.RefAttributes<any>> = withUnstatedContainers(GiveAdminButton, [AdminUsersContainer]);
+const GrantAdminButtonWrapper: React.ForwardRefExoticComponent<Pick<any, string | number | symbol> & React.RefAttributes<any>> = withUnstatedContainers(GrantAdminButton, [AdminUsersContainer]);
 
 
-export default GiveAdminButtonWrapper;
+export default GrantAdminButtonWrapper;

+ 15 - 15
apps/app/src/components/Admin/Users/RemoveAdminButton.tsx → apps/app/src/components/Admin/Users/RevokeAdminButton.tsx

@@ -9,40 +9,40 @@ import { useCurrentUser } from '~/stores/context';
 
 
 import { withUnstatedContainers } from '../../UnstatedUtils';
 import { withUnstatedContainers } from '../../UnstatedUtils';
 
 
-type RemoveAdminButtonProps = {
+type RevokeAdminButtonProps = {
   adminUsersContainer: AdminUsersContainer,
   adminUsersContainer: AdminUsersContainer,
   user: IUserHasId,
   user: IUserHasId,
 }
 }
 
 
-const RemoveAdminButton = (props: RemoveAdminButtonProps): JSX.Element => {
+const RevokeAdminButton = (props: RevokeAdminButtonProps): JSX.Element => {
 
 
   const { t } = useTranslation('admin');
   const { t } = useTranslation('admin');
   const { data: currentUser } = useCurrentUser();
   const { data: currentUser } = useCurrentUser();
   const { adminUsersContainer, user } = props;
   const { adminUsersContainer, user } = props;
 
 
-  const onClickRemoveAdminBtnHandler = useCallback(async() => {
+  const onClickRevokeAdminBtnHandler = useCallback(async() => {
     try {
     try {
-      const username = await adminUsersContainer.removeUserAdmin(user._id);
-      toastSuccess(t('toaster.remove_user_admin', { username }));
+      const username = await adminUsersContainer.revokeUserAdmin(user._id);
+      toastSuccess(t('toaster.revoke_user_admin', { username }));
     }
     }
     catch (err) {
     catch (err) {
       toastError(err);
       toastError(err);
     }
     }
   }, [adminUsersContainer, t, user._id]);
   }, [adminUsersContainer, t, user._id]);
 
 
-  const renderRemoveAdminBtn = () => {
+  const renderRevokeAdminBtn = () => {
     return (
     return (
-      <button className="dropdown-item" type="button" onClick={() => onClickRemoveAdminBtnHandler()}>
-        <i className="icon-fw icon-user-unfollow"></i>{t('user_management.user_table.remove_admin_access')}
+      <button className="dropdown-item" type="button" onClick={() => onClickRevokeAdminBtnHandler()}>
+        <i className="icon-fw icon-user-unfollow"></i>{t('user_management.user_table.revoke_admin_access')}
       </button>
       </button>
     );
     );
   };
   };
 
 
-  const renderRemoveAdminAlert = () => {
+  const renderRevokeAdminAlert = () => {
     return (
     return (
       <div className="px-4">
       <div className="px-4">
-        <i className="icon-fw icon-user-unfollow mb-2"></i>{t('user_management.user_table.remove_admin_access')}
-        <p className="alert alert-danger">{t('user_management.user_table.cannot_remove')}</p>
+        <i className="icon-fw icon-user-unfollow mb-2"></i>{t('user_management.user_table.revoke_admin_access')}
+        <p className="alert alert-danger">{t('user_management.user_table.cannot_revoke')}</p>
       </div>
       </div>
     );
     );
   };
   };
@@ -53,8 +53,8 @@ const RemoveAdminButton = (props: RemoveAdminButtonProps): JSX.Element => {
 
 
   return (
   return (
     <>
     <>
-      {user.username !== currentUser.username ? renderRemoveAdminBtn()
-        : renderRemoveAdminAlert()}
+      {user.username !== currentUser.username ? renderRevokeAdminBtn()
+        : renderRevokeAdminAlert()}
     </>
     </>
   );
   );
 };
 };
@@ -62,6 +62,6 @@ const RemoveAdminButton = (props: RemoveAdminButtonProps): JSX.Element => {
 /**
 /**
 * Wrapper component for using unstated
 * Wrapper component for using unstated
 */
 */
-const RemoveAdminButtonWrapper = withUnstatedContainers(RemoveAdminButton, [AdminUsersContainer]);
+const RevokeAdminButtonWrapper = withUnstatedContainers(RevokeAdminButton, [AdminUsersContainer]);
 
 
-export default RemoveAdminButtonWrapper;
+export default RevokeAdminButtonWrapper;

+ 13 - 13
apps/app/src/components/Admin/Users/RemoveAdminMenuItem.tsx → apps/app/src/components/Admin/Users/RevokeAdminMenuItem.tsx

@@ -10,17 +10,17 @@ import { useCurrentUser } from '~/stores/context';
 import { withUnstatedContainers } from '../../UnstatedUtils';
 import { withUnstatedContainers } from '../../UnstatedUtils';
 
 
 
 
-const RemoveAdminAlert = React.memo((): JSX.Element => {
+const RevokeAdminAlert = React.memo((): JSX.Element => {
   const { t } = useTranslation();
   const { t } = useTranslation();
 
 
   return (
   return (
     <div className="px-4">
     <div className="px-4">
-      <i className="icon-fw icon-user-unfollow mb-2"></i>{t('admin:user_management.user_table.remove_admin_access')}
-      <p className="alert alert-danger">{t('admin:user_management.user_table.cannot_remove')}</p>
+      <i className="icon-fw icon-user-unfollow mb-2"></i>{t('admin:user_management.user_table.revoke_admin_access')}
+      <p className="alert alert-danger">{t('admin:user_management.user_table.cannot_revoke')}</p>
     </div>
     </div>
   );
   );
 });
 });
-RemoveAdminAlert.displayName = 'RemoveAdminAlert';
+RevokeAdminAlert.displayName = 'RevokeAdminAlert';
 
 
 
 
 type Props = {
 type Props = {
@@ -28,17 +28,17 @@ type Props = {
   user: IUserHasId,
   user: IUserHasId,
 }
 }
 
 
-const RemoveAdminMenuItem = (props: Props): JSX.Element => {
+const RevokeAdminMenuItem = (props: Props): JSX.Element => {
   const { t } = useTranslation('admin');
   const { t } = useTranslation('admin');
 
 
   const { adminUsersContainer, user } = props;
   const { adminUsersContainer, user } = props;
 
 
   const { data: currentUser } = useCurrentUser();
   const { data: currentUser } = useCurrentUser();
 
 
-  const clickRemoveAdminBtnHandler = useCallback(async() => {
+  const clickRevokeAdminBtnHandler = useCallback(async() => {
     try {
     try {
-      const username = await adminUsersContainer.removeUserAdmin(user._id);
-      toastSuccess(t('toaster.remove_user_admin', { username }));
+      const username = await adminUsersContainer.revokeUserAdmin(user._id);
+      toastSuccess(t('toaster.revoke_user_admin', { username }));
     }
     }
     catch (err) {
     catch (err) {
       toastError(err);
       toastError(err);
@@ -48,17 +48,17 @@ const RemoveAdminMenuItem = (props: Props): JSX.Element => {
 
 
   return user.username !== currentUser?.username
   return user.username !== currentUser?.username
     ? (
     ? (
-      <button className="dropdown-item" type="button" onClick={clickRemoveAdminBtnHandler}>
-        <i className="icon-fw icon-user-unfollow"></i> {t('user_management.user_table.remove_admin_access')}
+      <button className="dropdown-item" type="button" onClick={clickRevokeAdminBtnHandler}>
+        <i className="icon-fw icon-user-unfollow"></i> {t('user_management.user_table.revoke_admin_access')}
       </button>
       </button>
     )
     )
-    : <RemoveAdminAlert />;
+    : <RevokeAdminAlert />;
 };
 };
 
 
 /**
 /**
 * Wrapper component for using unstated
 * Wrapper component for using unstated
 */
 */
 // eslint-disable-next-line max-len
 // eslint-disable-next-line max-len
-const RemoveAdminMenuItemWrapper: React.ForwardRefExoticComponent<Pick<any, string | number | symbol> & React.RefAttributes<any>> = withUnstatedContainers(RemoveAdminMenuItem, [AdminUsersContainer]);
+const RevokeAdminMenuItemWrapper: React.ForwardRefExoticComponent<Pick<any, string | number | symbol> & React.RefAttributes<any>> = withUnstatedContainers(RevokeAdminMenuItem, [AdminUsersContainer]);
 
 
-export default RemoveAdminMenuItemWrapper;
+export default RevokeAdminMenuItemWrapper;

+ 3 - 3
apps/app/src/components/Admin/Users/UserMenu.tsx

@@ -10,9 +10,9 @@ import AdminUsersContainer from '~/client/services/AdminUsersContainer';
 
 
 import { withUnstatedContainers } from '../../UnstatedUtils';
 import { withUnstatedContainers } from '../../UnstatedUtils';
 
 
-import GiveAdminButton from './GiveAdminButton';
+import GrantAdminButton from './GrantAdminButton';
 import GrantReadOnlyButton from './GrantReadOnlyButton';
 import GrantReadOnlyButton from './GrantReadOnlyButton';
-import RemoveAdminMenuItem from './RemoveAdminMenuItem';
+import RevokeAdminMenuItem from './RevokeAdminMenuItem';
 import RevokeReadOnlyMenuItem from './RevokeReadOnlyMenuItem';
 import RevokeReadOnlyMenuItem from './RevokeReadOnlyMenuItem';
 import SendInvitationEmailButton from './SendInvitationEmailButton';
 import SendInvitationEmailButton from './SendInvitationEmailButton';
 import StatusActivateButton from './StatusActivateButton';
 import StatusActivateButton from './StatusActivateButton';
@@ -83,7 +83,7 @@ const UserMenu = (props: UserMenuProps) => {
         <li className="dropdown-divider pl-0"></li>
         <li className="dropdown-divider pl-0"></li>
         <li className="dropdown-header">{t('user_management.user_table.administrator_menu')}</li>
         <li className="dropdown-header">{t('user_management.user_table.administrator_menu')}</li>
         <li>
         <li>
-          {user.admin ? <RemoveAdminMenuItem user={user} /> : <GiveAdminButton user={user} />}
+          {user.admin ? <RevokeAdminMenuItem user={user} /> : <GrantAdminButton user={user} />}
         </li>
         </li>
         <li>
         <li>
           {user.readOnly ? <RevokeReadOnlyMenuItem user={user} /> : <GrantReadOnlyButton user={user} />}
           {user.readOnly ? <RevokeReadOnlyMenuItem user={user} /> : <GrantReadOnlyButton user={user} />}

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

+ 44 - 38
apps/app/src/components/Bookmarks/BookmarkFolderItem.tsx

@@ -26,10 +26,11 @@ type BookmarkFolderItemProps = {
   isReadOnlyUser: boolean
   isReadOnlyUser: boolean
   bookmarkFolder: BookmarkFolderItems
   bookmarkFolder: BookmarkFolderItems
   isOpen?: boolean
   isOpen?: boolean
+  isOperable: boolean,
   level: number
   level: number
   root: string
   root: string
   isUserHomePage?: boolean
   isUserHomePage?: boolean
-  onClickDeleteBookmarkHandler: (pageToDelete: IPageToDeleteWithMeta) => void
+  onClickDeleteMenuItemHandler: (pageToDelete: IPageToDeleteWithMeta) => void
   bookmarkFolderTreeMutation: () => void
   bookmarkFolderTreeMutation: () => void
 }
 }
 
 
@@ -37,8 +38,8 @@ export const BookmarkFolderItem: FC<BookmarkFolderItemProps> = (props: BookmarkF
   const BASE_FOLDER_PADDING = 15;
   const BASE_FOLDER_PADDING = 15;
   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, level, root, isUserHomePage,
-    onClickDeleteBookmarkHandler, bookmarkFolderTreeMutation,
+    isReadOnlyUser, bookmarkFolder, isOpen: _isOpen = false, isOperable, level, root, isUserHomePage,
+    onClickDeleteMenuItemHandler, bookmarkFolderTreeMutation,
   } = props;
   } = props;
 
 
   const {
   const {
@@ -64,14 +65,15 @@ export const BookmarkFolderItem: FC<BookmarkFolderItemProps> = (props: BookmarkF
   // Rename for bookmark folder handler
   // Rename for bookmark folder handler
   const onPressEnterHandlerForRename = useCallback(async(folderName: string) => {
   const onPressEnterHandlerForRename = useCallback(async(folderName: string) => {
     try {
     try {
-      await updateBookmarkFolder(folderId, folderName, parent);
+      // TODO: do not use any type
+      await updateBookmarkFolder(folderId, folderName, parent as any, children);
       bookmarkFolderTreeMutation();
       bookmarkFolderTreeMutation();
       setIsRenameAction(false);
       setIsRenameAction(false);
     }
     }
     catch (err) {
     catch (err) {
       toastError(err);
       toastError(err);
     }
     }
-  }, [bookmarkFolderTreeMutation, folderId, parent]);
+  }, [bookmarkFolderTreeMutation, children, folderId, parent]);
 
 
   // Create new folder / subfolder handler
   // Create new folder / subfolder handler
   const onPressEnterHandlerForCreate = useCallback(async(folderName: string) => {
   const onPressEnterHandlerForCreate = useCallback(async(folderName: string) => {
@@ -98,7 +100,7 @@ export const BookmarkFolderItem: FC<BookmarkFolderItemProps> = (props: BookmarkF
     if (dragItemType === DRAG_ITEM_TYPE.FOLDER) {
     if (dragItemType === DRAG_ITEM_TYPE.FOLDER) {
       try {
       try {
         if (item.bookmarkFolder != null) {
         if (item.bookmarkFolder != null) {
-          await updateBookmarkFolder(item.bookmarkFolder._id, item.bookmarkFolder.name, bookmarkFolder._id);
+          await updateBookmarkFolder(item.bookmarkFolder._id, item.bookmarkFolder.name, bookmarkFolder._id, item.bookmarkFolder.children);
           bookmarkFolderTreeMutation();
           bookmarkFolderTreeMutation();
         }
         }
       }
       }
@@ -148,11 +150,12 @@ export const BookmarkFolderItem: FC<BookmarkFolderItemProps> = (props: BookmarkF
           <BookmarkFolderItem
           <BookmarkFolderItem
             key={childFolder._id}
             key={childFolder._id}
             isReadOnlyUser={isReadOnlyUser}
             isReadOnlyUser={isReadOnlyUser}
+            isOperable={props.isOperable}
             bookmarkFolder={childFolder}
             bookmarkFolder={childFolder}
             level={level + 1}
             level={level + 1}
             root={root}
             root={root}
             isUserHomePage={isUserHomePage}
             isUserHomePage={isUserHomePage}
-            onClickDeleteBookmarkHandler={onClickDeleteBookmarkHandler}
+            onClickDeleteMenuItemHandler={onClickDeleteMenuItemHandler}
             bookmarkFolderTreeMutation={bookmarkFolderTreeMutation}
             bookmarkFolderTreeMutation={bookmarkFolderTreeMutation}
           />
           />
         </div>
         </div>
@@ -166,11 +169,12 @@ export const BookmarkFolderItem: FC<BookmarkFolderItemProps> = (props: BookmarkF
         <BookmarkItem
         <BookmarkItem
           key={bookmark._id}
           key={bookmark._id}
           isReadOnlyUser={isReadOnlyUser}
           isReadOnlyUser={isReadOnlyUser}
+          isOperable={props.isOperable}
           bookmarkedPage={bookmark.page}
           bookmarkedPage={bookmark.page}
           level={level + 1}
           level={level + 1}
           parentFolder={bookmarkFolder}
           parentFolder={bookmarkFolder}
           canMoveToRoot={true}
           canMoveToRoot={true}
-          onClickDeleteBookmarkHandler={onClickDeleteBookmarkHandler}
+          onClickDeleteMenuItemHandler={onClickDeleteMenuItemHandler}
           bookmarkFolderTreeMutation={bookmarkFolderTreeMutation}
           bookmarkFolderTreeMutation={bookmarkFolderTreeMutation}
         />
         />
       );
       );
@@ -197,13 +201,13 @@ export const BookmarkFolderItem: FC<BookmarkFolderItemProps> = (props: BookmarkF
 
 
   const onClickMoveToRootHandlerForBookmarkFolderItemControl = useCallback(async() => {
   const onClickMoveToRootHandlerForBookmarkFolderItemControl = useCallback(async() => {
     try {
     try {
-      await updateBookmarkFolder(bookmarkFolder._id, bookmarkFolder.name, null);
+      await updateBookmarkFolder(bookmarkFolder._id, bookmarkFolder.name, null, bookmarkFolder.children);
       bookmarkFolderTreeMutation();
       bookmarkFolderTreeMutation();
     }
     }
     catch (err) {
     catch (err) {
       toastError(err);
       toastError(err);
     }
     }
-  }, [bookmarkFolder._id, bookmarkFolder.name, bookmarkFolderTreeMutation]);
+  }, [bookmarkFolder._id, bookmarkFolder.children, bookmarkFolder.name, bookmarkFolderTreeMutation]);
 
 
   return (
   return (
     <div id={`grw-bookmark-folder-item-${folderId}`} className="grw-foldertree-item-container">
     <div id={`grw-bookmark-folder-item-${folderId}`} className="grw-foldertree-item-container">
@@ -211,8 +215,8 @@ export const BookmarkFolderItem: FC<BookmarkFolderItemProps> = (props: BookmarkF
         key={folderId}
         key={folderId}
         type={acceptedTypes}
         type={acceptedTypes}
         item={props}
         item={props}
-        useDragMode={true}
-        useDropMode={true}
+        useDragMode={isOperable}
+        useDropMode={isOperable}
         onDropItem={itemDropHandler}
         onDropItem={itemDropHandler}
         isDropable={isDropable}
         isDropable={isDropable}
       >
       >
@@ -252,33 +256,35 @@ export const BookmarkFolderItem: FC<BookmarkFolderItemProps> = (props: BookmarkF
               </div>
               </div>
             </>
             </>
           )}
           )}
-          <div className="grw-foldertree-control d-flex">
-            <BookmarkFolderItemControl
-              onClickRename={onClickRenameHandler}
-              onClickDelete={onClickDeleteHandler}
-              onClickMoveToRoot={bookmarkFolder.parent != null
-                ? onClickMoveToRootHandlerForBookmarkFolderItemControl
-                : undefined
-              }
-            >
-              <div onClick={e => e.stopPropagation()}>
-                <DropdownToggle color="transparent" className="border-0 rounded btn-page-item-control p-0 grw-visible-on-hover mr-1">
-                  <i className="icon-options fa fa-rotate-90 p-1"></i>
-                </DropdownToggle>
-              </div>
-            </BookmarkFolderItemControl>
-            {/* Maximum folder hierarchy of 2 levels */}
-            {!(bookmarkFolder.parent != null) && (
-              <button
-                id='create-bookmark-folder-button'
-                type="button"
-                className="border-0 rounded btn btn-page-item-control p-0 grw-visible-on-hover"
-                onClick={onClickPlusButton}
+          { isOperable && (
+            <div className="grw-foldertree-control d-flex">
+              <BookmarkFolderItemControl
+                onClickRename={onClickRenameHandler}
+                onClickDelete={onClickDeleteHandler}
+                onClickMoveToRoot={bookmarkFolder.parent != null
+                  ? onClickMoveToRootHandlerForBookmarkFolderItemControl
+                  : undefined
+                }
               >
               >
-                <i className="icon-plus d-block p-0" />
-              </button>
-            )}
-          </div>
+                <div onClick={e => e.stopPropagation()}>
+                  <DropdownToggle color="transparent" className="border-0 rounded btn-page-item-control p-0 grw-visible-on-hover mr-1">
+                    <i className="icon-options fa fa-rotate-90 p-1"></i>
+                  </DropdownToggle>
+                </div>
+              </BookmarkFolderItemControl>
+              {/* Maximum folder hierarchy of 2 levels */}
+              {!(bookmarkFolder.parent != null) && (
+                <button
+                  id='create-bookmark-folder-button'
+                  type="button"
+                  className="border-0 rounded btn btn-page-item-control p-0 grw-visible-on-hover"
+                  onClick={onClickPlusButton}
+                >
+                  <i className="icon-plus d-block p-0" />
+                </button>
+              )}
+            </div>
+          )}
         </li>
         </li>
       </DragAndDropWrapper>
       </DragAndDropWrapper>
       {isCreateAction && (
       {isCreateAction && (

+ 73 - 65
apps/app/src/components/Bookmarks/BookmarkFolderMenu.tsx

@@ -6,25 +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, useSWRxCurrentUserBookmarks } from '~/stores/bookmark';
+import { useSWRMUTxCurrentUserBookmarks } from '~/stores/bookmark';
 import { useSWRxBookmarkFolderAndChild } from '~/stores/bookmark-folder';
 import { useSWRxBookmarkFolderAndChild } from '~/stores/bookmark-folder';
-import { useSWRxCurrentPage, useSWRxPageInfo } from '~/stores/page';
+import { useCurrentUser } from '~/stores/context';
+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: bookmarkFolders, mutate: mutateBookmarkFolders } = useSWRxBookmarkFolderAndChild();
-  const { data: currentPage } = useSWRxCurrentPage();
-  const { data: bookmarkInfo, mutate: mutateBookmarkInfo } = useSWRBookmarkInfo(currentPage?._id);
-  const { mutate: mutateUserBookmarks } = useSWRxCurrentUserBookmarks();
-  const { mutate: mutatePageInfo } = useSWRxPageInfo(currentPage?._id);
+  const { data: currentUser } = useCurrentUser();
+  const { data: bookmarkFolders, mutate: mutateBookmarkFolders } = useSWRxBookmarkFolderAndChild(currentUser?._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;
@@ -32,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');
     }
     }
@@ -71,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) {
@@ -80,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();
@@ -88,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 (
@@ -117,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}
@@ -125,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>
             ))}
             ))}
           </>
           </>
         )}
         )}

+ 25 - 11
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 { useSWRxCurrentUserBookmarks, 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';
@@ -23,24 +25,34 @@ import styles from './BookmarkFolderTree.module.scss';
 //   parentFolder: BookmarkFolderItems | null
 //   parentFolder: BookmarkFolderItems | null
 //  } & IPageHasId
 //  } & IPageHasId
 
 
-export const BookmarkFolderTree: React.FC<{isUserHomePage?: boolean}> = ({ isUserHomePage }) => {
+type Props = {
+  isUserHomePage?: boolean,
+  userId?: string,
+  isOperable: boolean,
+}
+
+export const BookmarkFolderTree: React.FC<Props> = (props: Props) => {
+  const { isUserHomePage, userId } = props;
+
   // const acceptedTypes: DragItemType[] = [DRAG_ITEM_TYPE.FOLDER, DRAG_ITEM_TYPE.BOOKMARK];
   // const acceptedTypes: DragItemType[] = [DRAG_ITEM_TYPE.FOLDER, DRAG_ITEM_TYPE.BOOKMARK];
   const { t } = useTranslation();
   const { t } = useTranslation();
 
 
   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();
-  const { data: userBookmarks, mutate: mutateUserBookmarks } = useSWRxCurrentUserBookmarks();
+  const { data: bookmarkFolders, mutate: mutateBookmarkFolders } = useSWRxBookmarkFolderAndChild(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;
 
 
@@ -93,12 +105,13 @@ export const BookmarkFolderTree: React.FC<{isUserHomePage?: boolean}> = ({ isUse
             <BookmarkFolderItem
             <BookmarkFolderItem
               key={bookmarkFolder._id}
               key={bookmarkFolder._id}
               isReadOnlyUser={!!isReadOnlyUser}
               isReadOnlyUser={!!isReadOnlyUser}
+              isOperable={props.isOperable}
               bookmarkFolder={bookmarkFolder}
               bookmarkFolder={bookmarkFolder}
               isOpen={false}
               isOpen={false}
               level={0}
               level={0}
               root={bookmarkFolder._id}
               root={bookmarkFolder._id}
               isUserHomePage={isUserHomePage}
               isUserHomePage={isUserHomePage}
-              onClickDeleteBookmarkHandler={onClickDeleteBookmarkHandler}
+              onClickDeleteMenuItemHandler={onClickDeleteMenuItemHandler}
               bookmarkFolderTreeMutation={bookmarkFolderTreeMutation}
               bookmarkFolderTreeMutation={bookmarkFolderTreeMutation}
             />
             />
           );
           );
@@ -108,11 +121,12 @@ export const BookmarkFolderTree: React.FC<{isUserHomePage?: boolean}> = ({ isUse
             <BookmarkItem
             <BookmarkItem
               key={userBookmark._id}
               key={userBookmark._id}
               isReadOnlyUser={!!isReadOnlyUser}
               isReadOnlyUser={!!isReadOnlyUser}
+              isOperable={props.isOperable}
               bookmarkedPage={userBookmark}
               bookmarkedPage={userBookmark}
               level={0}
               level={0}
               parentFolder={null}
               parentFolder={null}
               canMoveToRoot={false}
               canMoveToRoot={false}
-              onClickDeleteBookmarkHandler={onClickDeleteBookmarkHandler}
+              onClickDeleteMenuItemHandler={onClickDeleteMenuItemHandler}
               bookmarkFolderTreeMutation={bookmarkFolderTreeMutation}
               bookmarkFolderTreeMutation={bookmarkFolderTreeMutation}
             />
             />
           </div>
           </div>

+ 23 - 14
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';
@@ -23,11 +23,12 @@ import { DragAndDropWrapper } from './DragAndDropWrapper';
 
 
 type Props = {
 type Props = {
   isReadOnlyUser: boolean
   isReadOnlyUser: boolean
+  isOperable: boolean,
   bookmarkedPage: IPageHasId,
   bookmarkedPage: IPageHasId,
   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
 }
 }
 
 
@@ -38,14 +39,13 @@ export const BookmarkItem = (props: Props): JSX.Element => {
   const { t } = useTranslation();
   const { t } = useTranslation();
 
 
   const {
   const {
-    isReadOnlyUser, 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}`;
@@ -64,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);
@@ -85,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) {
@@ -106,14 +113,14 @@ 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
       item={dragItem}
       item={dragItem}
       type={[DRAG_ITEM_TYPE.BOOKMARK]}
       type={[DRAG_ITEM_TYPE.BOOKMARK]}
-      useDragMode={true}
+      useDragMode={isOperable}
     >
     >
       <li
       <li
         className="grw-bookmark-item-list list-group-item list-group-item-action border-0 py-0 mr-auto d-flex align-items-center"
         className="grw-bookmark-item-list list-group-item list-group-item-action border-0 py-0 mr-auto d-flex align-items-center"
@@ -130,17 +137,18 @@ export const BookmarkItem = (props: Props): JSX.Element => {
             validationTarget={ValidationTarget.PAGE}
             validationTarget={ValidationTarget.PAGE}
           />
           />
         ) : <PageListItemS page={bookmarkedPage} pageTitle={pageTitle}/>}
         ) : <PageListItemS page={bookmarkedPage} pageTitle={pageTitle}/>}
+
         <div className='grw-foldertree-control'>
         <div className='grw-foldertree-control'>
           <PageItemControl
           <PageItemControl
             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}
           >
           >
@@ -149,6 +157,7 @@ export const BookmarkItem = (props: Props): JSX.Element => {
             </DropdownToggle>
             </DropdownToggle>
           </PageItemControl>
           </PageItemControl>
         </div>
         </div>
+
         <UncontrolledTooltip
         <UncontrolledTooltip
           modifiers={{ preventOverflow: { boundariesElement: 'window' } }}
           modifiers={{ preventOverflow: { boundariesElement: 'window' } }}
           autohide={false}
           autohide={false}

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

+ 2 - 0
apps/app/src/components/Layout/BasicLayout.tsx

@@ -10,6 +10,7 @@ import Sidebar from '../Sidebar';
 import { RawLayout } from './RawLayout';
 import { RawLayout } from './RawLayout';
 
 
 const AlertSiteUrlUndefined = dynamic(() => import('../AlertSiteUrlUndefined').then(mod => mod.AlertSiteUrlUndefined), { ssr: false });
 const AlertSiteUrlUndefined = dynamic(() => import('../AlertSiteUrlUndefined').then(mod => mod.AlertSiteUrlUndefined), { ssr: false });
+const DeleteAttachmentModal = dynamic(() => import('../PageAttachment/DeleteAttachmentModal').then(mod => mod.DeleteAttachmentModal), { ssr: false });
 const HotkeysManager = dynamic(() => import('../Hotkeys/HotkeysManager'), { ssr: false });
 const HotkeysManager = dynamic(() => import('../Hotkeys/HotkeysManager'), { ssr: false });
 // const PageCreateModal = dynamic(() => import('../client/js/components/PageCreateModal'), { ssr: false });
 // const PageCreateModal = dynamic(() => import('../client/js/components/PageCreateModal'), { ssr: false });
 const GrowiNavbarBottom = dynamic(() => import('../Navbar/GrowiNavbarBottom').then(mod => mod.GrowiNavbarBottom), { ssr: false });
 const GrowiNavbarBottom = dynamic(() => import('../Navbar/GrowiNavbarBottom').then(mod => mod.GrowiNavbarBottom), { ssr: false });
@@ -56,6 +57,7 @@ export const BasicLayout = ({ children, className }: Props): JSX.Element => {
         <PageDeleteModal />
         <PageDeleteModal />
         <PageRenameModal />
         <PageRenameModal />
         <PageAccessoriesModal />
         <PageAccessoriesModal />
+        <DeleteAttachmentModal />
         <DeleteBookmarkFolderModal />
         <DeleteBookmarkFolderModal />
       </DndProvider>
       </DndProvider>
 
 

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

Unele fișiere nu au fost afișate deoarece prea multe fișiere au fost modificate în acest diff