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

Merge branch 'feat/rich-attachment' into feat/86710-121390-show-user-picture-tooltip

ryoji-s 2 лет назад
Родитель
Сommit
cd397d06ab
100 измененных файлов с 1240 добавлено и 833 удалено
  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. 1 1
      .github/workflows/release-slackbot-proxy.yml
  9. 2 2
      .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. 106 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. 26 12
      apps/app/package.json
  22. 14 10
      apps/app/public/static/locales/en_US/admin.json
  23. 1 0
      apps/app/public/static/locales/en_US/commons.json
  24. 2 7
      apps/app/public/static/locales/en_US/translation.json
  25. 13 9
      apps/app/public/static/locales/ja_JP/admin.json
  26. 1 0
      apps/app/public/static/locales/ja_JP/commons.json
  27. 2 7
      apps/app/public/static/locales/ja_JP/translation.json
  28. 13 9
      apps/app/public/static/locales/zh_CN/admin.json
  29. 1 0
      apps/app/public/static/locales/zh_CN/commons.json
  30. 2 7
      apps/app/public/static/locales/zh_CN/translation.json
  31. 6 6
      apps/app/src/client/services/AdminUsersContainer.js
  32. 57 57
      apps/app/src/client/services/renderer/renderer.tsx
  33. 4 2
      apps/app/src/client/util/bookmark-utils.ts
  34. 9 9
      apps/app/src/components/Admin/Users/GrantAdminButton.tsx
  35. 15 15
      apps/app/src/components/Admin/Users/RevokeAdminButton.tsx
  36. 13 13
      apps/app/src/components/Admin/Users/RevokeAdminMenuItem.tsx
  37. 3 3
      apps/app/src/components/Admin/Users/UserMenu.tsx
  38. 54 28
      apps/app/src/components/BookmarkButtons.tsx
  39. 44 38
      apps/app/src/components/Bookmarks/BookmarkFolderItem.tsx
  40. 73 65
      apps/app/src/components/Bookmarks/BookmarkFolderMenu.tsx
  41. 25 11
      apps/app/src/components/Bookmarks/BookmarkFolderTree.tsx
  42. 23 14
      apps/app/src/components/Bookmarks/BookmarkItem.tsx
  43. 4 3
      apps/app/src/components/InstallerForm.tsx
  44. 0 6
      apps/app/src/components/Layout/NoLoginLayout.module.scss
  45. 6 0
      apps/app/src/components/LoginForm.module.scss
  46. 1 1
      apps/app/src/components/LoginForm.tsx
  47. 3 1
      apps/app/src/components/Navbar/GrowiContextualSubNavigation.tsx
  48. 3 5
      apps/app/src/components/Navbar/SubNavButtons.tsx
  49. 4 3
      apps/app/src/components/PageAlert/TrashPageAlert.tsx
  50. 1 0
      apps/app/src/components/PageDuplicateModal.tsx
  51. 12 6
      apps/app/src/components/PageEditor.tsx
  52. 2 2
      apps/app/src/components/PageEditor/CodeMirrorEditor.jsx
  53. 1 1
      apps/app/src/components/PageList/PageList.tsx
  54. 6 6
      apps/app/src/components/PageList/PageListItemL.tsx
  55. 2 2
      apps/app/src/components/PageRenameModal.tsx
  56. 21 31
      apps/app/src/components/PageTimeline.tsx
  57. 1 1
      apps/app/src/components/SavePageControls/GrantSelector.tsx
  58. 5 2
      apps/app/src/components/Sidebar/Bookmarks/BookmarkContents.tsx
  59. 0 75
      apps/app/src/components/Sidebar/InfiniteScroll.tsx
  60. 6 5
      apps/app/src/components/Sidebar/PageTree/Item.tsx
  61. 39 21
      apps/app/src/components/TemplateModal/TemplateModal.tsx
  62. 1 0
      apps/app/src/components/TemplateModal/index.tsx
  63. 101 0
      apps/app/src/components/TemplateModal/use-formatter.spec.tsx
  64. 48 0
      apps/app/src/components/TemplateModal/use-formatter.tsx
  65. 5 1
      apps/app/src/components/UsersHomePageFooter.tsx
  66. 0 2
      apps/app/src/features/activate-plugin/index.ts
  67. 0 1
      apps/app/src/features/activate-plugin/utils/index.ts
  68. 0 0
      apps/app/src/features/growi-plugin/components/Admin/PluginsExtensionPageContents/PluginCard.module.scss
  69. 0 0
      apps/app/src/features/growi-plugin/components/Admin/PluginsExtensionPageContents/PluginCard.tsx
  70. 21 7
      apps/app/src/features/growi-plugin/components/Admin/PluginsExtensionPageContents/PluginInstallerForm.tsx
  71. 1 1
      apps/app/src/features/growi-plugin/components/Admin/PluginsExtensionPageContents/PluginsExtensionPageContents.tsx
  72. 1 0
      apps/app/src/features/growi-plugin/components/Admin/PluginsExtensionPageContents/index.ts
  73. 1 1
      apps/app/src/features/growi-plugin/components/GrowiPluginsActivator.client.tsx
  74. 0 0
      apps/app/src/features/growi-plugin/components/index.ts
  75. 6 6
      apps/app/src/features/growi-plugin/interfaces/growi-plugin.ts
  76. 1 0
      apps/app/src/features/growi-plugin/interfaces/index.ts
  77. 16 15
      apps/app/src/features/growi-plugin/models/growi-plugin.ts
  78. 1 0
      apps/app/src/features/growi-plugin/models/index.ts
  79. 68 0
      apps/app/src/features/growi-plugin/models/vo/github-url.spec.ts
  80. 51 0
      apps/app/src/features/growi-plugin/models/vo/github-url.ts
  81. 12 33
      apps/app/src/features/growi-plugin/routes/growi-plugins.ts
  82. 38 63
      apps/app/src/features/growi-plugin/services/growi-plugin.ts
  83. 1 0
      apps/app/src/features/growi-plugin/services/index.ts
  84. 3 2
      apps/app/src/features/growi-plugin/stores/growi-plugin.tsx
  85. 0 0
      apps/app/src/features/growi-plugin/utils/growi-facade-utils.client.ts
  86. 0 0
      apps/app/src/features/mermaid/components/MermaidViewer.tsx
  87. 0 0
      apps/app/src/features/mermaid/components/index.ts
  88. 0 0
      apps/app/src/features/mermaid/index.ts
  89. 0 0
      apps/app/src/features/mermaid/services/index.ts
  90. 0 0
      apps/app/src/features/mermaid/services/mermaid.ts
  91. 6 6
      apps/app/src/interfaces/activity.ts
  92. 13 15
      apps/app/src/interfaces/bookmark-info.ts
  93. 26 5
      apps/app/src/pages/[[...path]].page.tsx
  94. 3 3
      apps/app/src/pages/_document.page.tsx
  95. 1 1
      apps/app/src/pages/admin/plugins.page.tsx
  96. 20 6
      apps/app/src/pages/share/[[...path]].page.tsx
  97. 24 2
      apps/app/src/pages/utils/commons.ts
  98. 1 3
      apps/app/src/server/console.js
  99. 3 1
      apps/app/src/server/crowi/express-init.js
  100. 10 28
      apps/app/src/server/crowi/index.js

+ 0 - 1
.devcontainer/devcontainer.json

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

+ 0 - 11
.eslintrc.js

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

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

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

+ 4 - 0
.github/dependabot.yml

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

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

@@ -1,35 +1,35 @@
 categories:
   - title: 'BREAKING CHANGES'
     labels:
-      - 'breaking'
+      - 'type/reaking'
   - title: '💎 Features'
     labels:
-      - 'feature'
+      - 'type/feature'
   - title: '🚀 Improvement'
     labels:
-      - 'improvement'
+      - 'type/improvement'
   - title: '🐛 Bug Fixes'
     labels:
-      - 'bug'
+      - 'type/bug'
   - title: '🧰 Maintenance'
     labels:
-      - 'support'
-      - 'dependencies'
+      - 'type/support'
+      - 'type/dependencies'
 category-template: '### $TITLE'
 change-title-escapes: '\<*_&' # You can add # and @ to disable mentions, and add ` to disable code blocks.
 autolabeler:
-  - label: 'feature'
+  - label: 'type/feature'
     branch:
       - '/^feat\/.+/'
-  - label: 'improvement'
+  - label: 'type/improvement'
     branch:
       - '/^imprv\/.+/'
-  - label: 'bug'
+  - label: 'type/bug'
     branch:
       - '/^fix\/.+/'
     title:
       - '/^fix/i'
-  - label: 'support'
+  - label: 'type/support'
     branch:
       - '/^support\/.+/'
     title:
@@ -39,13 +39,13 @@ autolabeler:
       - '/^docs/i'
       - '/^test/i'
 include-labels:
-  - breaking
-  - feature
-  - improvement
-  - bug
-  - support
-  - dependencies
+  - type/breaking
+  - type/feature
+  - type/improvement
+  - type/bug
+  - type/support
+  - type/dependencies
 exclude-labels:
-  - 'exclude from changelog'
+  - 'flag/exclude-from-changelog'
 template: |
   $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:
   pull_request_target:
@@ -9,7 +8,9 @@ permissions:
   pull-requests: write
 
 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
     if: ${{ github.actor == 'dependabot[bot]' }}
     steps:

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

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

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

@@ -135,6 +135,6 @@ jobs:
         source_branch: support/prepare-v${{ steps.package-json.outputs.packageVersion }}
         destination_branch: master
         pr_title: Prepare v${{ steps.package-json.outputs.packageVersion }}
-        pr_label: exclude from changelog
+        pr_label: flag/exclude-from-changelog
         pr_body: "An automated PR generated by ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}"
         github_token: ${{ secrets.GITHUB_TOKEN }}

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

@@ -118,8 +118,8 @@ jobs:
         source_branch: support/prepare-v${{ steps.package-json.outputs.packageVersion }}
         destination_branch: master
         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 }}
 
 

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

@@ -217,7 +217,7 @@ jobs:
       fail-fast: false
       matrix:
         # 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:
       mongodb:
@@ -289,7 +289,7 @@ jobs:
     - name: Determine spec expression
       id: determine-spec-exp
       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
 
     - name: Copy dotenv file for ci

+ 1 - 2
.mergify.yml

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

+ 22 - 11
.vscode/launch.json

@@ -2,17 +2,7 @@
     "version": "0.2.0",
     "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",
         "name": "Debug: Current File",
         "skipFiles": [
@@ -26,6 +16,27 @@
           "${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",
         "request": "launch",

+ 106 - 48
CHANGELOG.md

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

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

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

@@ -1 +1,2 @@
 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 { initMongooseGlobalSettings, getMongoUri, mongoOptions } = isProduction
+const { getMongoUri, mongoOptions } = isProduction
   // eslint-disable-next-line import/extensions, import/no-unresolved
   ? require('../dist/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.');
 }
 
-
-initMongooseGlobalSettings();
-
 const mongoUri = getMongoUri();
 
 // 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({
   e2e: {
     baseUrl: 'http://localhost:3000',
-    specPattern: 'test/cypress/integration/',
+    specPattern: 'test/cypress/e2e/**/*.cy.{ts,tsx}',
     supportFile: 'test/cypress/support/index.ts',
     setupNodeEvents: (on) => {
       // change screen size

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

@@ -13,26 +13,6 @@ module.exports = {
   moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx'],
 
   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',
 

+ 26 - 12
apps/app/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@growi/app",
-  "version": "6.1.1-RC.0",
+  "version": "6.1.4-RC.0",
   "license": "MIT",
   "scripts": {
     "//// for production": "",
@@ -33,9 +33,13 @@
     "lint:swagger2openapi": "node node_modules/.bin/oas-validate tmp/swagger.json",
     "lint": "run-p lint:*",
     "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 -- ",
     "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": "",
     "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",
@@ -59,18 +63,19 @@
     "@elastic/elasticsearch8": "npm:@elastic/elasticsearch@^8.7.0",
     "@godaddy/terminus": "^4.9.0",
     "@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": "^6.1.4-RC.0",
+    "@growi/hackmd": "^6.1.4-RC.0",
+    "@growi/preset-themes": "^6.1.4-RC.0",
+    "@growi/remark-attachment-refs": "^6.1.4-RC.0",
+    "@growi/remark-drawio": "^6.1.4-RC.0",
+    "@growi/remark-growi-directive": "^6.1.4-RC.0",
+    "@growi/remark-lsx": "^6.1.4-RC.0",
+    "@growi/slack": "^6.1.4-RC.0",
     "@promster/express": "^7.0.6",
     "@promster/server": "^7.0.8",
     "@slack/web-api": "^6.2.4",
     "@slack/webhook": "^6.0.0",
+    "@types/jest": "^29.5.2",
     "JSONStream": "^1.3.5",
     "archiver": "^5.3.0",
     "array.prototype.flatmap": "^1.2.2",
@@ -129,6 +134,7 @@
     "mongoose-unique-validator": "^2.0.3",
     "multer": "~1.4.0",
     "multer-autoreap": "^1.0.3",
+    "mustache": "^4.2.0",
     "next": "^13.3.0",
     "next-i18next": "^13.2.1",
     "next-superjson": "^0.0.4",
@@ -202,12 +208,15 @@
     "handsontable": "v7.0.0 or above is no loger MIT lisence."
   },
   "devDependencies": {
-    "@growi/presentation": "^6.1.1-RC.0",
-    "@growi/ui": "^6.1.1-RC.0",
+    "@growi/presentation": "^6.1.4-RC.0",
+    "@growi/ui": "^6.1.4-RC.0",
     "@handsontable/react": "=2.1.0",
     "@icon/themify-icons": "1.0.1-alpha.3",
     "@next/bundle-analyzer": "^13.2.3",
+    "@swc-node/jest": "^1.6.2",
+    "@swc/jest": "^0.2.24",
     "@types/express": "^4.17.11",
+    "@types/jest": "^29.5.2",
     "@types/react-scroll": "^1.8.4",
     "autoprefixer": "^9.0.0",
     "babel-loader": "^8.2.5",
@@ -219,14 +228,19 @@
     "eazy-logger": "^3.1.0",
     "emoji-mart": "npm:panta82-emoji-mart@^3.0.1",
     "eslint-plugin-cypress": "^2.12.1",
+    "eslint-plugin-jest": "^26.5.3",
     "eslint-plugin-regex": "^1.8.0",
     "font-awesome": "^4.7.0",
     "handsontable": "=6.2.2",
     "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.cookie": "~1.4.1",
     "load-css-file": "^1.0.0",
     "material-icons": "^1.11.3",
+    "mongodb-memory-server": "^8.12.2",
     "morgan": "^1.10.0",
     "null-loader": "^4.0.1",
     "penpal": "^4.0.0",

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

@@ -290,7 +290,7 @@
     "management_wiki": "Management Wiki",
     "system_information": "System information",
     "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",
     "specified_version": "Specified version",
     "installed_version": "Installed version",
@@ -743,9 +743,9 @@
       "accept": "Accept",
       "deactivate_account": "Deactivate 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",
       "grant_read_only_access": "Grant read only access",
       "send_invitation_email": "Send invitation email",
@@ -857,8 +857,12 @@
   "plugins": {
     "plugins": "Plugins",
     "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_is_not_installed": "Plugin is not installed",
     "install": "Install",
@@ -1018,8 +1022,8 @@
     "ADMIN_USERS_PASSWORD_RESET": "Reset user password",
     "ADMIN_USERS_ACTIVATE": "Activate 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_REVOKE_READ_ONLY": "Revoke read only access",
     "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"
   },
   "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",
     "revoke_user_read_only": "Succeeded to revoke {{username}} read only",
     "activate_user_success": "Succeeded to activating {{username}}",

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

@@ -2,6 +2,7 @@
   "Show": "Show",
   "Hide": "Hide",
   "Add": "Add",
+  "Insert": "Insert",
   "Reset": "Reset",
   "Sign out": "Logout",
   "New": "New",

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

@@ -168,7 +168,7 @@
     "could_not_creata_path": "Couldn't create path."
   },
   "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": {
     "tab": "Create account",
@@ -445,12 +445,6 @@
     "file_upload_succeeded": "File upload succeeded.",
     "file_upload_failed": "File upload failed.",
     "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}}",
     "issue_share_link": "Succeeded to issue new share link",
     "remove_share_link": "Succeeded to remove {{count}} share links",
@@ -460,6 +454,7 @@
   },
   "template": {
     "modal_label": {
+      "Select template": "Select template",
       "Create/Edit Template Page": "Create/Edit template page",
       "Create template under": "Create template page under this page"
     },

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

@@ -751,9 +751,9 @@
       "accept": "承認する",
       "deactivate_account": "アカウント停止",
       "your_own": "自分自身のアカウントを停止することはできません",
-      "remove_admin_access": "管理者から外す",
-      "cannot_remove": "自分自身を管理者から外すことはできません",
-      "give_admin_access": "管理者にする",
+      "revoke_admin_access": "管理者から外す",
+      "cannot_revoke": "自分自身を管理者から外すことはできません",
+      "grant_admin_access": "管理者にする",
       "revoke_read_only_access": "閲覧のみアクセス権を外す",
       "grant_read_only_access": "閲覧のみアクセス権を付与する",
       "send_invitation_email": "招待メールの送信",
@@ -865,8 +865,12 @@
   "plugins": {
     "plugins": "プラグイン",
     "plugin_installer": "プラグインインストーラー",
-    "repository_url": "URL",
-    "description": "リポジトリのURLの入力してください。",
+    "form": {
+      "label_url": "リポジトリURL",
+      "desc_url": "リポジトリのURLの入力してください。",
+      "label_branch": "ブランチの指定",
+      "desc_branch": "インストール対象のブランチを設定できます。デフォルト: `main`"
+    },
     "plugin_card": "プラグインカード",
     "plugin_is_not_installed": "プラグインがインストールされていません",
     "install": "インストール",
@@ -1026,8 +1030,8 @@
     "ADMIN_USERS_PASSWORD_RESET": "ユーザーのパスワードをリセット",
     "ADMIN_USERS_ACTIVATE": "ユーザーを承認する",
     "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_REVOKE_READ_ONLY": "閲覧のみアクセス権を外す",
     "ADMIN_USERS_SEND_INVITATION_EMAIL": "招待メールの再送信",
@@ -1046,8 +1050,8 @@
     "error_send_growi_archive": "GROWI アーカイブファイルの送信に失敗しました"
   },
   "toaster": {
-    "give_user_admin": "{{username}}を管理者に設定しました",
-    "remove_user_admin": "{{username}}を管理者から外しました",
+    "grant_user_admin": "{{username}}を管理者に設定しました",
+    "revoke_user_admin": "{{username}}を管理者から外しました",
     "grant_user_read_only": "{{username}}に閲覧のみアクセス権を付与しました",
     "revoke_user_read_only": "{{username}}から閲覧のみアクセス権を外しました",
     "activate_user_success": "{{username}}を有効化しました",

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

@@ -2,6 +2,7 @@
   "Show": "公開",
   "Hide": "非公開",
   "Add": "追加",
+  "Insert": "挿入",
   "Reset": "リセット",
   "Sign out": "ログアウト",
   "New": "作成",

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

@@ -169,7 +169,7 @@
     "could_not_creata_path": "パスを作成できませんでした。"
   },
   "custom_navigation": {
-    "no_page_list": "このページの配下にはページが存在しません。"
+    "no_pages_under_this_page": "このページの配下にはページが存在しません。"
   },
   "installer": {
     "tab": "アカウント作成",
@@ -478,12 +478,6 @@
     "file_upload_succeeded": "ファイルをアップロードしました",
     "file_upload_failed": "ファイルのアップロードに失敗しました",
     "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}}を削除しました",
     "issue_share_link": "共有リンクを作成しました",
     "remove_share_link": "共有リンクを{{count}}件削除しました",
@@ -493,6 +487,7 @@
   },
   "template": {
     "modal_label": {
+      "Select template": "テンプレートの選択",
       "Create/Edit Template Page": "テンプレートページの作成/編集",
       "Create template under": "配下にテンプレートページを作成"
     },

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

@@ -751,9 +751,9 @@
       "accept": "接受",
       "deactivate_account": "停用帐户",
       "your_own": "您不能停用自己的帐户",
-      "remove_admin_access": "删除管理员访问权限",
-      "cannot_remove": "您不能从管理员中删除自己",
-      "give_admin_access": "授予管理员访问权限",
+      "revoke_admin_access": "删除管理员访问权限",
+      "cannot_revoke": "您不能从管理员中删除自己",
+      "grant_admin_access": "授予管理员访问权限",
       "revoke_read_only_access": "取消只读访问",
       "grant_read_only_access": "给予只读权限",
       "send_invitation_email": "发送邀请邮件",
@@ -865,8 +865,12 @@
   "plugins": {
     "plugins": "Plugins",
     "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_is_not_installed": "Plugin is not installed",
     "install": "Install",
@@ -1026,8 +1030,8 @@
     "ADMIN_USERS_PASSWORD_RESET": "重置用户密码",
     "ADMIN_USERS_ACTIVATE": "激活用户",
     "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_REVOKE_READ_ONLY": "取消只读访问",
     "ADMIN_USERS_SEND_INVITATION_EMAIL": "重发邀请函",
@@ -1046,8 +1050,8 @@
     "error_send_growi_archive": "Failed to send GROWI archive file to the destination GROWI"
   },
   "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",
     "revoke_user_read_only": "Succeeded to revoke {{username}} read only",
 		"activate_user_success": "Succeeded to activating {{username}}",

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

@@ -2,6 +2,7 @@
 	"Show": "显示",
 	"Hide": "隐藏",
   "Add": "添加",
+  "Insert": "插入",
   "Reset": "重启",
 	"Sign out": "退出",
   "New": "新建",

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

@@ -175,7 +175,7 @@
     "could_not_creata_path": "无法创建路径"
   },
   "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": {
     "tab": "创建账户",
@@ -434,12 +434,6 @@
     "file_upload_succeeded": "文件上传成功",
     "file_upload_failed": "文件上传失败",
     "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": "成功更新分享链接设置",
     "failed_to_reset_password":"Failed to reset password",
     "save_succeeded": "已成功保存",
@@ -447,6 +441,7 @@
   },
 	"template": {
 		"modal_label": {
+      "Select template": "选择模板",
 			"Create/Edit Template Page": "创建/编辑模板页",
 			"Create template under": "在下面创建模板页"
 		},

+ 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
    * @param {string} userId
    * @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;
     await this.retrieveUsersByPagingNum(this.state.activePage);
     return username;
   }
 
   /**
-   * Remove user admin
+   * Revoke user admin
    * @memberOf AdminUsersContainer
    * @param {string} userId
    * @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;
     await this.retrieveUsersByPagingNum(this.state.activePage);
     return username;

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

@@ -1,10 +1,10 @@
 import assert from 'assert';
 
 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
-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 sanitize from 'rehype-sanitize';
 import slug from 'rehype-slug';
@@ -18,7 +18,7 @@ import { Attachment } from '~/components/ReactMarkdownComponents/Attachment';
 import { DrawioViewerWithEditButton } from '~/components/ReactMarkdownComponents/DrawioViewerWithEditButton';
 import { Header } from '~/components/ReactMarkdownComponents/Header';
 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 type { RendererOptions } from '~/interfaces/renderer-options';
 import type { RendererConfig } from '~/interfaces/services/renderer';
@@ -60,12 +60,12 @@ export const generateViewOptions = (
   remarkPlugins.push(
     math,
     [plantuml.remarkPlugin, { plantumlUri: config.plantumlUri }],
-    drawioPlugin.remarkPlugin,
+    drawio.remarkPlugin,
+    mermaid.remarkPlugin,
     xsvToTable.remarkPlugin,
-    lsxGrowiPlugin.remarkPlugin,
+    lsxGrowiDirective.remarkPlugin,
+    refsGrowiDirective.remarkPlugin,
     attachmentPlugin.remarkPlugin,
-    refsGrowiPlugin.remarkPlugin,
-    mermaidPlugin.remarkPlugin,
   );
   if (config.isEnabledLinebreaks) {
     remarkPlugins.push(breaks);
@@ -78,19 +78,19 @@ export const generateViewOptions = (
   const rehypeSanitizePlugin: Pluggable<any[]> | (() => void) = config.isEnabledXssPrevention
     ? [sanitize, deepmerge(
       commonSanitizeOption,
-      drawioPlugin.sanitizeOption,
-      lsxGrowiPlugin.sanitizeOption,
+      drawio.sanitizeOption,
+      mermaid.sanitizeOption,
       attachmentPlugin.sanitizeOption,
-      refsGrowiPlugin.sanitizeOption,
-      mermaidPlugin.sanitizeOption,
+      lsxGrowiDirective.sanitizeOption,
+      refsGrowiDirective.sanitizeOption,
     )]
     : () => {};
 
   // add rehype plugins
   rehypePlugins.push(
     slug,
-    [lsxGrowiPlugin.rehypePlugin, { pagePath, isSharedPage: config.isSharedPage }],
-    [refsGrowiPlugin.rehypePlugin, { pagePath }],
+    [lsxGrowiDirective.rehypePlugin, { pagePath, isSharedPage: config.isSharedPage }],
+    [refsGrowiDirective.rehypePlugin, { pagePath }],
     rehypeSanitizePlugin,
     katex,
     [relocateToc.rehypePluginStore, { storeTocNode }],
@@ -104,16 +104,16 @@ export const generateViewOptions = (
     components.h4 = Header;
     components.h5 = 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.table = TableWithEditButton;
     components.attachment = Attachment;
-    components.mermaid = mermaidPlugin.MermaidViewer;
+    components.mermaid = mermaid.MermaidViewer;
   }
 
   if (config.isEnabledXssPrevention) {
@@ -169,12 +169,12 @@ export const generateSimpleViewOptions = (
   remarkPlugins.push(
     math,
     [plantuml.remarkPlugin, { plantumlUri: config.plantumlUri }],
-    drawioPlugin.remarkPlugin,
+    drawio.remarkPlugin,
+    mermaid.remarkPlugin,
     xsvToTable.remarkPlugin,
-    lsxGrowiPlugin.remarkPlugin,
     attachmentPlugin.remarkPlugin,
-    refsGrowiPlugin.remarkPlugin,
-    mermaidPlugin.remarkPlugin,
+    lsxGrowiDirective.remarkPlugin,
+    refsGrowiDirective.remarkPlugin,
   );
 
   const isEnabledLinebreaks = overrideIsEnabledLinebreaks ?? config.isEnabledLinebreaks;
@@ -191,18 +191,18 @@ export const generateSimpleViewOptions = (
   const rehypeSanitizePlugin: Pluggable<any[]> | (() => void) = config.isEnabledXssPrevention
     ? [sanitize, deepmerge(
       commonSanitizeOption,
-      drawioPlugin.sanitizeOption,
-      lsxGrowiPlugin.sanitizeOption,
+      drawio.sanitizeOption,
+      mermaid.sanitizeOption,
       attachmentPlugin.sanitizeOption,
-      refsGrowiPlugin.sanitizeOption,
-      mermaidPlugin.sanitizeOption,
+      lsxGrowiDirective.sanitizeOption,
+      refsGrowiDirective.sanitizeOption,
     )]
     : () => {};
 
   // add rehype plugins
   rehypePlugins.push(
-    [lsxGrowiPlugin.rehypePlugin, { pagePath, isSharedPage: config.isSharedPage }],
-    [refsGrowiPlugin.rehypePlugin, { pagePath }],
+    [lsxGrowiDirective.rehypePlugin, { pagePath, isSharedPage: config.isSharedPage }],
+    [refsGrowiDirective.rehypePlugin, { pagePath }],
     [keywordHighlighter.rehypePlugin, { keywords: highlightKeywords }],
     rehypeSanitizePlugin,
     katex,
@@ -210,15 +210,15 @@ export const generateSimpleViewOptions = (
 
   // add components
   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.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.attachment = Attachment;
-    components.mermaid = mermaidPlugin.MermaidViewer;
+    components.mermaid = mermaid.MermaidViewer;
   }
 
   if (config.isEnabledXssPrevention) {
@@ -249,12 +249,12 @@ export const generatePreviewOptions = (config: RendererConfig, pagePath: string)
   remarkPlugins.push(
     math,
     [plantuml.remarkPlugin, { plantumlUri: config.plantumlUri }],
-    drawioPlugin.remarkPlugin,
+    drawio.remarkPlugin,
+    mermaid.remarkPlugin,
     xsvToTable.remarkPlugin,
-    lsxGrowiPlugin.remarkPlugin,
+    lsxGrowiDirective.remarkPlugin,
     attachmentPlugin.remarkPlugin,
-    refsGrowiPlugin.remarkPlugin,
-    mermaidPlugin.remarkPlugin,
+    refsGrowiDirective.remarkPlugin,
   );
   if (config.isEnabledLinebreaks) {
     remarkPlugins.push(breaks);
@@ -267,19 +267,19 @@ export const generatePreviewOptions = (config: RendererConfig, pagePath: string)
   const rehypeSanitizePlugin: Pluggable<any[]> | (() => void) = config.isEnabledXssPrevention
     ? [sanitize, deepmerge(
       commonSanitizeOption,
-      lsxGrowiPlugin.sanitizeOption,
-      refsGrowiPlugin.sanitizeOption,
-      drawioPlugin.sanitizeOption,
+      drawio.sanitizeOption,
+      mermaid.sanitizeOption,
+      lsxGrowiDirective.sanitizeOption,
+      refsGrowiDirective.sanitizeOption,
       addLineNumberAttribute.sanitizeOption,
       attachmentPlugin.sanitizeOption,
-      mermaidPlugin.sanitizeOption,
     )]
     : () => {};
 
   // add rehype plugins
   rehypePlugins.push(
-    [lsxGrowiPlugin.rehypePlugin, { pagePath, isSharedPage: config.isSharedPage }],
-    [refsGrowiPlugin.rehypePlugin, { pagePath }],
+    [lsxGrowiDirective.rehypePlugin, { pagePath, isSharedPage: config.isSharedPage }],
+    [refsGrowiDirective.rehypePlugin, { pagePath }],
     addLineNumberAttribute.rehypePlugin,
     rehypeSanitizePlugin,
     katex,
@@ -287,15 +287,15 @@ export const generatePreviewOptions = (config: RendererConfig, pagePath: string)
 
   // add components
   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.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.attachment = Attachment;
-    components.mermaid = mermaidPlugin.MermaidViewer;
+    components.mermaid = mermaid.MermaidViewer;
   }
 
   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
-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,
+  });
 };

+ 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';
 
-type GiveAdminButtonProps = {
+type GrantAdminButtonProps = {
   adminUsersContainer: AdminUsersContainer,
   user: IUserHasId,
 }
 
-const GiveAdminButton = (props: GiveAdminButtonProps): JSX.Element => {
+const GrantAdminButton = (props: GrantAdminButtonProps): JSX.Element => {
 
   const { t } = useTranslation('admin');
   const { adminUsersContainer, user } = props;
 
-  const onClickGiveAdminBtnHandler = useCallback(async() => {
+  const onClickGrantAdminBtnHandler = useCallback(async() => {
     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) {
       toastError(err);
@@ -29,8 +29,8 @@ const GiveAdminButton = (props: GiveAdminButtonProps): JSX.Element => {
   }, [adminUsersContainer, t, user._id]);
 
   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>
   );
 
@@ -40,6 +40,6 @@ const GiveAdminButton = (props: GiveAdminButtonProps): JSX.Element => {
  * Wrapper component for using unstated
  */
 // 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';
 
-type RemoveAdminButtonProps = {
+type RevokeAdminButtonProps = {
   adminUsersContainer: AdminUsersContainer,
   user: IUserHasId,
 }
 
-const RemoveAdminButton = (props: RemoveAdminButtonProps): JSX.Element => {
+const RevokeAdminButton = (props: RevokeAdminButtonProps): JSX.Element => {
 
   const { t } = useTranslation('admin');
   const { data: currentUser } = useCurrentUser();
   const { adminUsersContainer, user } = props;
 
-  const onClickRemoveAdminBtnHandler = useCallback(async() => {
+  const onClickRevokeAdminBtnHandler = useCallback(async() => {
     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) {
       toastError(err);
     }
   }, [adminUsersContainer, t, user._id]);
 
-  const renderRemoveAdminBtn = () => {
+  const renderRevokeAdminBtn = () => {
     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>
     );
   };
 
-  const renderRemoveAdminAlert = () => {
+  const renderRevokeAdminAlert = () => {
     return (
       <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>
     );
   };
@@ -53,8 +53,8 @@ const RemoveAdminButton = (props: RemoveAdminButtonProps): JSX.Element => {
 
   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
 */
-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';
 
 
-const RemoveAdminAlert = React.memo((): JSX.Element => {
+const RevokeAdminAlert = React.memo((): JSX.Element => {
   const { t } = useTranslation();
 
   return (
     <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>
   );
 });
-RemoveAdminAlert.displayName = 'RemoveAdminAlert';
+RevokeAdminAlert.displayName = 'RevokeAdminAlert';
 
 
 type Props = {
@@ -28,17 +28,17 @@ type Props = {
   user: IUserHasId,
 }
 
-const RemoveAdminMenuItem = (props: Props): JSX.Element => {
+const RevokeAdminMenuItem = (props: Props): JSX.Element => {
   const { t } = useTranslation('admin');
 
   const { adminUsersContainer, user } = props;
 
   const { data: currentUser } = useCurrentUser();
 
-  const clickRemoveAdminBtnHandler = useCallback(async() => {
+  const clickRevokeAdminBtnHandler = useCallback(async() => {
     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) {
       toastError(err);
@@ -48,17 +48,17 @@ const RemoveAdminMenuItem = (props: Props): JSX.Element => {
 
   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>
     )
-    : <RemoveAdminAlert />;
+    : <RevokeAdminAlert />;
 };
 
 /**
 * Wrapper component for using unstated
 */
 // 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 GiveAdminButton from './GiveAdminButton';
+import GrantAdminButton from './GrantAdminButton';
 import GrantReadOnlyButton from './GrantReadOnlyButton';
-import RemoveAdminMenuItem from './RemoveAdminMenuItem';
+import RevokeAdminMenuItem from './RevokeAdminMenuItem';
 import RevokeReadOnlyMenuItem from './RevokeReadOnlyMenuItem';
 import SendInvitationEmailButton from './SendInvitationEmailButton';
 import StatusActivateButton from './StatusActivateButton';
@@ -83,7 +83,7 @@ const UserMenu = (props: UserMenuProps) => {
         <li className="dropdown-divider pl-0"></li>
         <li className="dropdown-header">{t('user_management.user_table.administrator_menu')}</li>
         <li>
-          {user.admin ? <RemoveAdminMenuItem user={user} /> : <GiveAdminButton user={user} />}
+          {user.admin ? <RevokeAdminMenuItem user={user} /> : <GrantAdminButton user={user} />}
         </li>
         <li>
           {user.readOnly ? <RevokeReadOnlyMenuItem user={user} /> : <GrantReadOnlyButton user={user} />}

+ 54 - 28
apps/app/src/components/BookmarkButtons.tsx

@@ -3,38 +3,49 @@ import React, {
 } from 'react';
 
 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 { IUser } from '../interfaces/user';
-
 import { BookmarkFolderMenu } from './Bookmarks/BookmarkFolderMenu';
 import UserPictureList from './User/UserPictureList';
 
 import styles from './BookmarkButtons.module.scss';
 
 interface Props {
-  bookmarkedUsers?: IUser[]
-  hideTotalNumber?: boolean
-  bookmarkInfo? : IBookmarkInfo
+  pageId: string,
+  isBookmarked?: boolean,
+  bookmarkCount: number,
+  hideTotalNumber?: boolean,
 }
 
 export const BookmarkButtons: FC<Props> = (props: Props) => {
   const { t } = useTranslation();
   const {
-    bookmarkedUsers, hideTotalNumber, bookmarkInfo,
+    pageId, isBookmarked, bookmarkCount, hideTotalNumber,
   } = props;
 
-  const [isPopoverOpen, setIsPopoverOpen] = useState(false);
+  const [isBookmarkFolderMenuOpen, setBookmarkFolderMenuOpen] = useState(false);
+  const [isBookmarkUsersPopoverOpen, setBookmarkUsersPopoverOpen] = useState(false);
 
   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(() => {
@@ -45,16 +56,23 @@ export const BookmarkButtons: FC<Props> = (props: Props) => {
     return 'tooltip.bookmark';
   }, [isGuestUser]);
 
+  if (pageId == null) {
+    return <></>;
+  }
 
   return (
     <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
-          ${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>
       </BookmarkFolderMenu>
-
       <UncontrolledTooltip placement="top" data-testid="bookmark-button-tooltip" target="bookmark-dropdown-btn" fade={false}>
         {t(getTooltipMessage())}
       </UncontrolledTooltip>
@@ -65,19 +83,27 @@ export const BookmarkButtons: FC<Props> = (props: Props) => {
             type="button"
             id="po-total-bookmarks"
             className={`shadow-none btn btn-bookmark border-0
-              total-bookmarks ${bookmarkInfo?.isBookmarked ? 'active' : ''}`}
+              total-bookmarks ${isBookmarked ? 'active' : ''}`}
           >
-            {bookmarkInfo?.sumOfBookmarks ?? 0}
+            {bookmarkCount}
           </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>

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

@@ -26,10 +26,11 @@ type BookmarkFolderItemProps = {
   isReadOnlyUser: boolean
   bookmarkFolder: BookmarkFolderItems
   isOpen?: boolean
+  isOperable: boolean,
   level: number
   root: string
   isUserHomePage?: boolean
-  onClickDeleteBookmarkHandler: (pageToDelete: IPageToDeleteWithMeta) => void
+  onClickDeleteMenuItemHandler: (pageToDelete: IPageToDeleteWithMeta) => void
   bookmarkFolderTreeMutation: () => void
 }
 
@@ -37,8 +38,8 @@ export const BookmarkFolderItem: FC<BookmarkFolderItemProps> = (props: BookmarkF
   const BASE_FOLDER_PADDING = 15;
   const acceptedTypes: DragItemType[] = [DRAG_ITEM_TYPE.FOLDER, DRAG_ITEM_TYPE.BOOKMARK];
   const {
-    isReadOnlyUser, bookmarkFolder, isOpen: _isOpen = false, level, root, isUserHomePage,
-    onClickDeleteBookmarkHandler, bookmarkFolderTreeMutation,
+    isReadOnlyUser, bookmarkFolder, isOpen: _isOpen = false, isOperable, level, root, isUserHomePage,
+    onClickDeleteMenuItemHandler, bookmarkFolderTreeMutation,
   } = props;
 
   const {
@@ -64,14 +65,15 @@ export const BookmarkFolderItem: FC<BookmarkFolderItemProps> = (props: BookmarkF
   // Rename for bookmark folder handler
   const onPressEnterHandlerForRename = useCallback(async(folderName: string) => {
     try {
-      await updateBookmarkFolder(folderId, folderName, parent);
+      // TODO: do not use any type
+      await updateBookmarkFolder(folderId, folderName, parent as any, children);
       bookmarkFolderTreeMutation();
       setIsRenameAction(false);
     }
     catch (err) {
       toastError(err);
     }
-  }, [bookmarkFolderTreeMutation, folderId, parent]);
+  }, [bookmarkFolderTreeMutation, children, folderId, parent]);
 
   // Create new folder / subfolder handler
   const onPressEnterHandlerForCreate = useCallback(async(folderName: string) => {
@@ -98,7 +100,7 @@ export const BookmarkFolderItem: FC<BookmarkFolderItemProps> = (props: BookmarkF
     if (dragItemType === DRAG_ITEM_TYPE.FOLDER) {
       try {
         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();
         }
       }
@@ -148,11 +150,12 @@ export const BookmarkFolderItem: FC<BookmarkFolderItemProps> = (props: BookmarkF
           <BookmarkFolderItem
             key={childFolder._id}
             isReadOnlyUser={isReadOnlyUser}
+            isOperable={props.isOperable}
             bookmarkFolder={childFolder}
             level={level + 1}
             root={root}
             isUserHomePage={isUserHomePage}
-            onClickDeleteBookmarkHandler={onClickDeleteBookmarkHandler}
+            onClickDeleteMenuItemHandler={onClickDeleteMenuItemHandler}
             bookmarkFolderTreeMutation={bookmarkFolderTreeMutation}
           />
         </div>
@@ -166,11 +169,12 @@ export const BookmarkFolderItem: FC<BookmarkFolderItemProps> = (props: BookmarkF
         <BookmarkItem
           key={bookmark._id}
           isReadOnlyUser={isReadOnlyUser}
+          isOperable={props.isOperable}
           bookmarkedPage={bookmark.page}
           level={level + 1}
           parentFolder={bookmarkFolder}
           canMoveToRoot={true}
-          onClickDeleteBookmarkHandler={onClickDeleteBookmarkHandler}
+          onClickDeleteMenuItemHandler={onClickDeleteMenuItemHandler}
           bookmarkFolderTreeMutation={bookmarkFolderTreeMutation}
         />
       );
@@ -197,13 +201,13 @@ export const BookmarkFolderItem: FC<BookmarkFolderItemProps> = (props: BookmarkF
 
   const onClickMoveToRootHandlerForBookmarkFolderItemControl = useCallback(async() => {
     try {
-      await updateBookmarkFolder(bookmarkFolder._id, bookmarkFolder.name, null);
+      await updateBookmarkFolder(bookmarkFolder._id, bookmarkFolder.name, null, bookmarkFolder.children);
       bookmarkFolderTreeMutation();
     }
     catch (err) {
       toastError(err);
     }
-  }, [bookmarkFolder._id, bookmarkFolder.name, bookmarkFolderTreeMutation]);
+  }, [bookmarkFolder._id, bookmarkFolder.children, bookmarkFolder.name, bookmarkFolderTreeMutation]);
 
   return (
     <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}
         type={acceptedTypes}
         item={props}
-        useDragMode={true}
-        useDropMode={true}
+        useDragMode={isOperable}
+        useDropMode={isOperable}
         onDropItem={itemDropHandler}
         isDropable={isDropable}
       >
@@ -252,33 +256,35 @@ export const BookmarkFolderItem: FC<BookmarkFolderItemProps> = (props: BookmarkF
               </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>
       </DragAndDropWrapper>
       {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 { toastError } from '~/client/util/toastr';
-import { useSWRBookmarkInfo, useSWRxCurrentUserBookmarks } from '~/stores/bookmark';
+import { useSWRMUTxCurrentUserBookmarks } from '~/stores/bookmark';
 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';
 
-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 [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 => {
     return bookmarkFolders != null && bookmarkFolders.length > 0;
@@ -32,38 +44,40 @@ export const BookmarkFolderMenu: React.FC<{children?: React.ReactNode}> = ({ chi
 
   const toggleBookmarkHandler = useCallback(async() => {
     try {
-      if (currentPage != null) {
-        await toggleBookmark(currentPage._id, isBookmarked);
-      }
+      await toggleBookmark(pageId, isBookmarked);
     }
     catch (err) {
       toastError(err);
     }
-  }, [currentPage, isBookmarked]);
+  }, [isBookmarked, pageId]);
 
   const onUnbookmarkHandler = useCallback(async() => {
+    if (onUnbookmark != null) {
+      onUnbookmark();
+    }
     await toggleBookmarkHandler();
-    setIsOpen(false);
     setSelectedItem(null);
-    mutateUserBookmarks();
-    mutateBookmarkInfo();
+    mutateCurrentUserBookmarks();
     mutateBookmarkFolders();
     mutatePageInfo();
-  }, [mutateBookmarkFolders, mutateBookmarkInfo, mutatePageInfo, mutateUserBookmarks, toggleBookmarkHandler]);
+  }, [onUnbookmark, toggleBookmarkHandler, mutateCurrentUserBookmarks, mutateBookmarkFolders, mutatePageInfo]);
 
   const toggleHandler = useCallback(async() => {
-    setIsOpen(!isOpen);
-
+    // on close
     if (isOpen && bookmarkFolders != null) {
       bookmarkFolders.forEach((bookmarkFolder) => {
         bookmarkFolder.bookmarks.forEach((bookmark) => {
-          if (bookmark.page._id === currentPage?._id) {
+          if (bookmark.page._id === pageId) {
             setSelectedItem(bookmarkFolder._id);
           }
         });
       });
     }
 
+    if (onToggle != null) {
+      onToggle();
+    }
+
     if (selectedItem == null) {
       setSelectedItem('root');
     }
@@ -71,8 +85,7 @@ export const BookmarkFolderMenu: React.FC<{children?: React.ReactNode}> = ({ chi
     if (!isOpen && !isBookmarked) {
       try {
         await toggleBookmarkHandler();
-        mutateUserBookmarks();
-        mutateBookmarkInfo();
+        mutateCurrentUserBookmarks();
         mutatePageInfo();
       }
       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) => {
     e.stopPropagation();
@@ -88,17 +101,15 @@ export const BookmarkFolderMenu: React.FC<{children?: React.ReactNode}> = ({ chi
     setSelectedItem(itemId);
 
     try {
-      if (currentPage != null) {
-        await addBookmarkToFolder(currentPage._id, itemId === 'root' ? null : itemId);
-      }
-      mutateUserBookmarks();
+      await addBookmarkToFolder(pageId, itemId === 'root' ? null : itemId);
+      mutateCurrentUserBookmarks();
       mutateBookmarkFolders();
-      mutateBookmarkInfo();
+      mutatePageInfo();
     }
     catch (err) {
       toastError(err);
     }
-  }, [mutateBookmarkFolders, currentPage, mutateBookmarkInfo, mutateUserBookmarks]);
+  }, [pageId, mutateCurrentUserBookmarks, mutateBookmarkFolders, mutatePageInfo]);
 
   const renderBookmarkMenuItem = () => {
     return (
@@ -117,7 +128,7 @@ export const BookmarkFolderMenu: React.FC<{children?: React.ReactNode}> = ({ chi
         {isBookmarkFolderExists && (
           <>
             <DropdownItem divider />
-            <div key='root'>
+            <div key="root">
               <div
                 className="dropdown-item grw-bookmark-folder-menu-item list-group-item list-group-item-action border-0 py-0"
                 tabIndex={0}
@@ -125,48 +136,45 @@ export const BookmarkFolderMenu: React.FC<{children?: React.ReactNode}> = ({ chi
                 onClick={e => onMenuItemClickHandler(e, 'root')}
               >
                 <BookmarkFolderMenuItem
-                  itemId='root'
+                  itemId="root"
                   itemName={t('bookmark_folder.root')}
                   isSelected={selectedItem === 'root'}
                 />
               </div>
             </div>
             {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>
-                <>
-                  {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>
             ))}
           </>
         )}

+ 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 { IPageToDeleteWithMeta } from '~/interfaces/page';
 import { OnDeletedFunction } from '~/interfaces/ui';
-import { useSWRxCurrentUserBookmarks, useSWRBookmarkInfo } from '~/stores/bookmark';
+import {
+  useSWRxUserBookmarks, useSWRMUTxCurrentUserBookmarks,
+} from '~/stores/bookmark';
 import { useSWRxBookmarkFolderAndChild } from '~/stores/bookmark-folder';
 import { useIsReadOnlyUser } from '~/stores/context';
 import { usePageDeleteModal } from '~/stores/modal';
-import { useSWRxCurrentPage } from '~/stores/page';
+import { useSWRMUTxPageInfo, useSWRxCurrentPage } from '~/stores/page';
 
 import { BookmarkFolderItem } from './BookmarkFolderItem';
 import { BookmarkItem } from './BookmarkItem';
@@ -23,24 +25,34 @@ import styles from './BookmarkFolderTree.module.scss';
 //   parentFolder: BookmarkFolderItems | null
 //  } & 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 { t } = useTranslation();
 
   const { data: isReadOnlyUser } = useIsReadOnlyUser();
   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 bookmarkFolderTreeMutation = useCallback(() => {
     mutateUserBookmarks();
-    mutateBookmarkInfo();
+    mutateCurrentUserBookmarks();
+    mutatePageInfo();
     mutateBookmarkFolders();
-  }, [mutateBookmarkFolders, mutateBookmarkInfo, mutateUserBookmarks]);
+  }, [mutateBookmarkFolders, mutatePageInfo, mutateCurrentUserBookmarks, mutateUserBookmarks]);
 
-  const onClickDeleteBookmarkHandler = useCallback((pageToDelete: IPageToDeleteWithMeta) => {
+  const onClickDeleteMenuItemHandler = useCallback((pageToDelete: IPageToDeleteWithMeta) => {
     const pageDeletedHandler: OnDeletedFunction = (pathOrPathsToDelete, _isRecursively, isCompletely) => {
       if (typeof pathOrPathsToDelete !== 'string') return;
 
@@ -93,12 +105,13 @@ export const BookmarkFolderTree: React.FC<{isUserHomePage?: boolean}> = ({ isUse
             <BookmarkFolderItem
               key={bookmarkFolder._id}
               isReadOnlyUser={!!isReadOnlyUser}
+              isOperable={props.isOperable}
               bookmarkFolder={bookmarkFolder}
               isOpen={false}
               level={0}
               root={bookmarkFolder._id}
               isUserHomePage={isUserHomePage}
-              onClickDeleteBookmarkHandler={onClickDeleteBookmarkHandler}
+              onClickDeleteMenuItemHandler={onClickDeleteMenuItemHandler}
               bookmarkFolderTreeMutation={bookmarkFolderTreeMutation}
             />
           );
@@ -108,11 +121,12 @@ export const BookmarkFolderTree: React.FC<{isUserHomePage?: boolean}> = ({ isUse
             <BookmarkItem
               key={userBookmark._id}
               isReadOnlyUser={!!isReadOnlyUser}
+              isOperable={props.isOperable}
               bookmarkedPage={userBookmark}
               level={0}
               parentFolder={null}
               canMoveToRoot={false}
-              onClickDeleteBookmarkHandler={onClickDeleteBookmarkHandler}
+              onClickDeleteMenuItemHandler={onClickDeleteMenuItemHandler}
               bookmarkFolderTreeMutation={bookmarkFolderTreeMutation}
             />
           </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 { 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 { ValidationTarget } from '~/client/util/input-validator';
 import { toastError } from '~/client/util/toastr';
@@ -23,11 +23,12 @@ import { DragAndDropWrapper } from './DragAndDropWrapper';
 
 type Props = {
   isReadOnlyUser: boolean
+  isOperable: boolean,
   bookmarkedPage: IPageHasId,
   level: number,
   parentFolder: BookmarkFolderItems | null,
   canMoveToRoot: boolean,
-  onClickDeleteBookmarkHandler: (pageToDelete: IPageToDeleteWithMeta) => void,
+  onClickDeleteMenuItemHandler: (pageToDelete: IPageToDeleteWithMeta) => void,
   bookmarkFolderTreeMutation: () => void
 }
 
@@ -38,14 +39,13 @@ export const BookmarkItem = (props: Props): JSX.Element => {
   const { t } = useTranslation();
 
   const {
-    isReadOnlyUser, bookmarkedPage, onClickDeleteBookmarkHandler,
+    isReadOnlyUser, isOperable, bookmarkedPage, onClickDeleteMenuItemHandler,
     parentFolder, level, canMoveToRoot, bookmarkFolderTreeMutation,
   } = props;
 
   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 { latter: pageTitle, former: formerPagePath } = dPagePath;
   const bookmarkItemId = `bookmark-item-${bookmarkedPage._id}`;
@@ -64,10 +64,16 @@ export const BookmarkItem = (props: Props): JSX.Element => {
     }
   }, [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();
-  }, [bookmarkedPage._id, bookmarkFolderTreeMutation]);
+    mutatePageInfo();
+  }, [bookmarkFolderTreeMutation, mutatePageInfo]);
 
   const renameMenuItemClickHandler = useCallback(() => {
     setRenameInputShown(true);
@@ -85,12 +91,13 @@ export const BookmarkItem = (props: Props): JSX.Element => {
       setRenameInputShown(false);
       await renamePage(bookmarkedPage._id, bookmarkedPage.revision, newPagePath);
       bookmarkFolderTreeMutation();
+      mutatePageInfo();
     }
     catch (err) {
       setRenameInputShown(true);
       toastError(err);
     }
-  }, [bookmarkedPage, bookmarkFolderTreeMutation]);
+  }, [bookmarkedPage.path, bookmarkedPage._id, bookmarkedPage.revision, bookmarkFolderTreeMutation, mutatePageInfo]);
 
   const deleteMenuItemClickHandler = useCallback(async(_pageId: string, pageInfo: IPageInfoAll | undefined): Promise<void> => {
     if (bookmarkedPage._id == null || bookmarkedPage.path == null) {
@@ -106,14 +113,14 @@ export const BookmarkItem = (props: Props): JSX.Element => {
       meta: pageInfo,
     };
 
-    onClickDeleteBookmarkHandler(pageToDelete);
-  }, [bookmarkedPage._id, bookmarkedPage.path, bookmarkedPage.revision, onClickDeleteBookmarkHandler]);
+    onClickDeleteMenuItemHandler(pageToDelete);
+  }, [bookmarkedPage._id, bookmarkedPage.path, bookmarkedPage.revision, onClickDeleteMenuItemHandler]);
 
   return (
     <DragAndDropWrapper
       item={dragItem}
       type={[DRAG_ITEM_TYPE.BOOKMARK]}
-      useDragMode={true}
+      useDragMode={isOperable}
     >
       <li
         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}
           />
         ) : <PageListItemS page={bookmarkedPage} pageTitle={pageTitle}/>}
+
         <div className='grw-foldertree-control'>
           <PageItemControl
             pageId={bookmarkedPage._id}
             isEnableActions
             isReadOnlyUser={isReadOnlyUser}
-            pageInfo={fetchedPageInfo}
+            pageInfo={pageInfo}
             forceHideMenuItems={[MenuItemType.DUPLICATE]}
             onClickBookmarkMenuItem={bookmarkMenuItemClickHandler}
             onClickRenameMenuItem={renameMenuItemClickHandler}
             onClickDeleteMenuItem={deleteMenuItemClickHandler}
-            additionalMenuItemOnTopRenderer={canMoveToRoot
+            additionalMenuItemOnTopRenderer={canMoveToRoot && isOperable
               ? () => <BookmarkMoveToRootBtn pageId={bookmarkedPage._id} onClickMoveToRootHandler={onClickMoveToRootHandler}/>
               : undefined}
           >
@@ -149,6 +157,7 @@ export const BookmarkItem = (props: Props): JSX.Element => {
             </DropdownToggle>
           </PageItemControl>
         </div>
+
         <UncontrolledTooltip
           modifiers={{ preventOverflow: { boundariesElement: 'window' } }}
           autohide={false}

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

@@ -214,7 +214,7 @@ const InstallerForm = memo((): JSX.Element => {
             />
           </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
               data-testid="btnSubmit"
               type="submit"
@@ -228,11 +228,12 @@ const InstallerForm = memo((): JSX.Element => {
             </button>
           </div>
 
-          <div className="input-group mt-4 d-flex justify-content-center">
+          <div>
             <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>
           </div>
+
         </form>
       </div>
     </div>

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

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

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

@@ -9,4 +9,10 @@
   .collapse-external-auth {
     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>
         <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>
       </div>
     </div>

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

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

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

@@ -13,7 +13,6 @@ import {
 import { useIsGuestUser, useIsReadOnlyUser } from '~/stores/context';
 import { IPageForPageDuplicateModal } from '~/stores/modal';
 
-import { useSWRBookmarkInfo } from '../../stores/bookmark';
 import { useSWRxPageInfo } from '../../stores/page';
 import { useSWRxUsersList } from '../../stores/user';
 import { BookmarkButtons } from '../BookmarkButtons';
@@ -94,8 +93,6 @@ const SubNavButtonsSubstance = (props: SubNavButtonsSubstanceProps): JSX.Element
 
   const { mutate: mutatePageInfo } = useSWRxPageInfo(pageId, shareLinkId);
 
-  const { data: bookmarkInfo } = useSWRBookmarkInfo(pageId);
-
   const likerIds = isIPageInfoForEntity(pageInfo) ? (pageInfo.likerIds ?? []).slice(0, 15) : [];
   const seenUserIds = isIPageInfoForEntity(pageInfo) ? (pageInfo.seenUserIds ?? []).slice(0, 15) : [];
 
@@ -227,9 +224,10 @@ const SubNavButtonsSubstance = (props: SubNavButtonsSubstanceProps): JSX.Element
       )}
       {revisionId != null && (
         <BookmarkButtons
+          pageId={pageId}
+          isBookmarked={pageInfo.isBookmarked}
+          bookmarkCount={pageInfo.bookmarkCount}
           hideTotalNumber={isCompactMode}
-          bookmarkedUsers={bookmarkInfo?.bookmarkedUsers}
-          bookmarkInfo={bookmarkInfo}
         />
       )}
       {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 { usePageDeleteModal, usePutBackPageModal } from '~/stores/modal';
 import {
-  useCurrentPagePath, useSWRxPageInfo, useSWRxCurrentPage, useIsTrashPage,
+  useCurrentPagePath, useSWRxPageInfo, useSWRxCurrentPage, useIsTrashPage, useSWRMUTxCurrentPage,
 } from '~/stores/page';
 import { useIsAbleToShowTrashPageManagementButtons } from '~/stores/ui';
 
@@ -33,11 +33,11 @@ export const TrashPageAlert = (): JSX.Element => {
   const pagePath = pageData?.path;
   const { data: pageInfo } = useSWRxPageInfo(pageId ?? null);
 
-
   const { open: openDeleteModal } = usePageDeleteModal();
   const { open: openPutBackPageModal } = usePutBackPageModal();
   const { data: currentPagePath } = useCurrentPagePath();
 
+  const { trigger: mutateCurrentPage } = useSWRMUTxCurrentPage();
 
   const deleteUser = pageData?.deleteUser;
   const deletedAt = pageData?.deletedAt ? format(new Date(pageData?.deletedAt), 'yyyy/MM/dd HH:mm') : '';
@@ -55,13 +55,14 @@ export const TrashPageAlert = (): JSX.Element => {
       try {
         unlink(currentPagePath);
         router.push(`/${pageId}`);
+        mutateCurrentPage();
       }
       catch (err) {
         toastError(err);
       }
     };
     openPutBackPageModal({ pageId, path: pagePath }, { onPutBacked: putBackedHandler });
-  }, [currentPagePath, openPutBackPageModal, pageId, pagePath, router]);
+  }, [currentPagePath, mutateCurrentPage, openPutBackPageModal, pageId, pagePath, router]);
 
   const openPageDeleteModalHandler = useCallback(() => {
     if (pageId === undefined || revisionId === undefined || pagePath === undefined) {

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

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

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

@@ -322,10 +322,10 @@ const PageEditor = React.memo((): JSX.Element => {
       editorRef.current.insertText(insertText);
 
       // when if created newly
+      // Not using 'mutateGrant' to inherit the grant of the parent page
       if (res.pageCreated) {
         logger.info('Page is created', res.page._id);
         globalEmitter.emit('resetInitializedHackMdStatus');
-        mutateGrant(res.page.grant);
         mutateIsLatestRevision(true);
         await mutateCurrentPageId(res.page._id);
         await mutateCurrentPage();
@@ -338,7 +338,7 @@ const PageEditor = React.memo((): JSX.Element => {
     finally {
       editorRef.current.terminateUploadingState();
     }
-  }, [currentPagePath, mutateCurrentPage, mutateCurrentPageId, mutateGrant, mutateIsLatestRevision, pageId]);
+  }, [currentPagePath, mutateCurrentPage, mutateCurrentPageId, mutateIsLatestRevision, pageId]);
 
 
   const scrollPreviewByEditorLine = useCallback((line: number) => {
@@ -519,11 +519,17 @@ const PageEditor = React.memo((): JSX.Element => {
 
   // when transitioning to a different page, if the initialValue is the same,
   // UnControlled CodeMirror value does not reset, so explicitly set the value to initialValue
+  const onRouterChangeComplete = useCallback(() => {
+    editorRef.current?.setValue(initialValue);
+    editorRef.current?.setCaretLine(0);
+  }, [initialValue]);
+
   useEffect(() => {
-    if (currentPagePath != null) {
-      editorRef.current?.setValue(initialValue);
-    }
-  }, [currentPagePath, initialValue]);
+    router.events.on('routeChangeComplete', onRouterChangeComplete);
+    return () => {
+      router.events.off('routeChangeComplete', onRouterChangeComplete);
+    };
+  }, [onRouterChangeComplete, router.events]);
 
   if (!isEditable) {
     return <></>;

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

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

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

@@ -51,7 +51,7 @@ const PageList = (props: Props<IPageInfoForEntity>): JSX.Element => {
   if (pageList.length === 0) {
     return (
       <div className="mt-2">
-        <p>{t('custom_navigation.no_page_list')}</p>
+        <p>{t('custom_navigation.no_pages_under_this_page')}</p>
       </div>
     );
   }

+ 6 - 6
apps/app/src/components/PageList/PageListItemL.tsx

@@ -24,13 +24,13 @@ import {
   OnDuplicatedFunction, OnRenamedFunction, OnDeletedFunction, OnPutBackedFunction,
 } from '~/interfaces/ui';
 import LinkedPagePath from '~/models/linked-page-path';
-import { useSWRBookmarkInfo, useSWRxCurrentUserBookmarks } from '~/stores/bookmark';
+import { useSWRMUTxCurrentUserBookmarks } from '~/stores/bookmark';
 import {
   usePageRenameModal, usePageDuplicateModal, usePageDeleteModal, usePutBackPageModal,
 } from '~/stores/modal';
 import { useIsDeviceSmallerThanLg } from '~/stores/ui';
 
-import { useSWRxPageInfo } from '../../stores/page';
+import { useSWRMUTxPageInfo, useSWRxPageInfo } from '../../stores/page';
 import { ForceHideMenuItems, PageItemControl } from '../Common/Dropdown/PageItemControl';
 import PagePathHierarchicalLink from '../PagePathHierarchicalLink';
 
@@ -90,8 +90,8 @@ const PageListItemLSubstance: ForwardRefRenderFunction<ISelectable, Props> = (pr
 
   const shouldFetch = isSelected && (pageData != null || pageMeta != null);
   const { data: pageInfo } = useSWRxPageInfo(shouldFetch ? pageData?._id : null);
-  const { mutate: mutateCurrentUserBookmark } = useSWRxCurrentUserBookmarks();
-  const { mutate: mutateBookmarkInfo } = useSWRBookmarkInfo(pageData?._id);
+  const { trigger: mutatePageInfo } = useSWRMUTxPageInfo(pageData?._id ?? null);
+  const { trigger: mutateCurrentUserBookmarks } = useSWRMUTxCurrentUserBookmarks();
   const elasticSearchResult = isIPageSearchMeta(pageMeta) ? pageMeta.elasticSearchResult : null;
   const revisionShortBody = isIPageInfoForListing(pageMeta) ? pageMeta.revisionShortBody : null;
 
@@ -128,8 +128,8 @@ const PageListItemLSubstance: ForwardRefRenderFunction<ISelectable, Props> = (pr
   const bookmarkMenuItemClickHandler = async(_pageId: string, _newValue: boolean): Promise<void> => {
     const bookmarkOperation = _newValue ? bookmark : unbookmark;
     await bookmarkOperation(_pageId);
-    mutateCurrentUserBookmark();
-    mutateBookmarkInfo();
+    mutateCurrentUserBookmarks();
+    mutatePageInfo();
   };
 
   const duplicateMenuItemClickHandler = useCallback(() => {

+ 2 - 2
apps/app/src/components/PageRenameModal.tsx

@@ -255,7 +255,7 @@ const PageRenameModal = (): JSX.Element => {
             <div className="custom-control custom-radio custom-radio-warning">
               <input
                 className="custom-control-input"
-                name="recursively"
+                name="withoutExistRecursively"
                 id="cbRenameThisPageOnly"
                 type="radio"
                 checked={!isRenameRecursively}
@@ -268,7 +268,7 @@ const PageRenameModal = (): JSX.Element => {
             <div className="custom-control custom-radio custom-radio-warning mt-1">
               <input
                 className="custom-control-input"
-                name="withoutExistRecursively"
+                name="recursively"
                 id="cbForceRenameRecursively"
                 type="radio"
                 checked={isRenameRecursively}

+ 21 - 31
apps/app/src/components/PageTimeline.tsx

@@ -1,15 +1,15 @@
-import React, { useCallback, useEffect, useState } from 'react';
+import React from 'react';
 
 import { useTranslation } from 'next-i18next';
 import Link from 'next/link';
 
-import { apiv3Get } from '~/client/util/apiv3-client';
 import { IPageHasId } from '~/interfaces/page';
 import { useCurrentPagePath } from '~/stores/page';
+import { useSWRINFxPageTimeline } from '~/stores/page-timeline';
 import { useTimelineOptions } from '~/stores/renderer';
 
+import InfiniteScroll from './InfiniteScroll';
 import { RevisionLoader } from './Page/RevisionLoader';
-import PaginationWrapper from './PaginationWrapper';
 
 import styles from './PageTimeline.module.scss';
 
@@ -42,48 +42,38 @@ const TimelineCard = ({ page }: TimelineCardProps): JSX.Element => {
   );
 };
 
-
 export const PageTimeline = (): JSX.Element => {
-  const [activePage, setActivePage] = useState(1);
-  const [totalPageItems, setTotalPageItems] = useState(0);
-  const [limit, setLimit] = useState(10);
-  const [pages, setPages] = useState<IPageHasId[] | null>(null);
 
-  const { data: currentPagePath } = useCurrentPagePath();
+  const PER_PAGE = 3;
   const { t } = useTranslation();
+  const { data: currentPagePath } = useCurrentPagePath();
 
-  const handlePage = useCallback(async(selectedPage: number) => {
-    if (currentPagePath == null) { return }
-    const res = await apiv3Get('/pages/list', { path: currentPagePath, page: selectedPage });
-    setTotalPageItems(res.data.totalCount);
-    setPages(res.data.pages);
-    setLimit(res.data.limit);
-    setActivePage(selectedPage);
-  }, [currentPagePath]);
+  const swrInfinitexPageTimeline = useSWRINFxPageTimeline(currentPagePath, PER_PAGE);
+  const { data } = swrInfinitexPageTimeline;
 
-  useEffect(() => {
-    handlePage(1);
-  }, [handlePage]);
+  const isEmpty = data?.[0]?.pages.length === 0;
+  const isReachingEnd = isEmpty || (data != null && data[data.length - 1]?.pages.length < PER_PAGE);
 
-  if (pages == null || pages.length === 0) {
+  if (data == null || isEmpty) {
     return (
       <div className="mt-2">
-        {/* eslint-disable-next-line react/no-danger */}
-        <p>{t('custom_navigation.no_page_list')}</p>
+        <p>{t('custom_navigation.no_pages_under_this_page')}</p>
       </div>
     );
   }
 
   return (
     <div>
-      { pages.map(page => <TimelineCard key={page._id} page={page} />) }
-      <PaginationWrapper
-        activePage={activePage}
-        changePage={handlePage}
-        totalItemsCount={totalPageItems}
-        pagingLimit={limit}
-        align="center"
-      />
+      <InfiniteScroll
+        swrInifiniteResponse={swrInfinitexPageTimeline}
+        isReachingEnd={isReachingEnd}
+      >
+        { data != null && data.flatMap(apiResult => apiResult.pages)
+          .map(page => (
+            <TimelineCard key={page._id} page={page} />
+          ))
+        }
+      </InfiniteScroll>
     </div>
   );
 };

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

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

+ 5 - 2
apps/app/src/components/Sidebar/Bookmarks/BookmarkContents.tsx

@@ -8,12 +8,15 @@ import { BookmarkFolderNameInput } from '~/components/Bookmarks/BookmarkFolderNa
 import { BookmarkFolderTree } from '~/components/Bookmarks/BookmarkFolderTree';
 import { FolderPlusIcon } from '~/components/Icons/FolderPlusIcon';
 import { useSWRxBookmarkFolderAndChild } from '~/stores/bookmark-folder';
+import { useCurrentUser } from '~/stores/context';
 
 export const BookmarkContents = (): JSX.Element => {
 
   const { t } = useTranslation();
   const [isCreateAction, setIsCreateAction] = useState<boolean>(false);
-  const { mutate: mutateBookmarkFolders } = useSWRxBookmarkFolderAndChild();
+
+  const { data: currentUser } = useCurrentUser();
+  const { mutate: mutateBookmarkFolders } = useSWRxBookmarkFolderAndChild(currentUser?._id);
 
   const onClickNewBookmarkFolder = useCallback(() => {
     setIsCreateAction(true);
@@ -53,7 +56,7 @@ export const BookmarkContents = (): JSX.Element => {
           />
         </div>
       )}
-      <BookmarkFolderTree />
+      <BookmarkFolderTree isOperable userId={currentUser?._id} />
     </>
   );
 };

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

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

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

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

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

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

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

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

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

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

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

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

+ 5 - 1
apps/app/src/components/UsersHomePageFooter.tsx

@@ -2,9 +2,11 @@ import React, { useState } from 'react';
 
 import { useTranslation } from 'next-i18next';
 
+
 import { RecentlyCreatedIcon } from '~/components/Icons/RecentlyCreatedIcon';
 import { RecentCreated } from '~/components/RecentCreated/RecentCreated';
 import styles from '~/components/UsersHomePageFooter.module.scss';
+import { useCurrentUser } from '~/stores/context';
 
 import { BookmarkFolderTree } from './Bookmarks/BookmarkFolderTree';
 import { CompressIcon } from './Icons/CompressIcon';
@@ -18,6 +20,8 @@ export const UsersHomePageFooter = (props: UsersHomePageFooterProps): JSX.Elemen
   const { t } = useTranslation();
   const { creatorId } = props;
   const [isExpanded, setIsExpanded] = useState<boolean>(false);
+  const { data: currentUser } = useCurrentUser();
+  const isOperable = currentUser?._id === creatorId;
 
   return (
     <div className={`container-lg user-page-footer py-5 ${styles['user-page-footer']}`}>
@@ -39,7 +43,7 @@ export const UsersHomePageFooter = (props: UsersHomePageFooterProps): JSX.Elemen
         </h2>
         {/* TODO: In bookmark folders v1, the button to create a new folder does not exist. The button should be included in the bookmark component. */}
         <div className={`${isExpanded ? `${styles['grw-bookarks-contents-expanded']}` : `${styles['grw-bookarks-contents-compressed']}`}`}>
-          <BookmarkFolderTree isUserHomePage={true} />
+          <BookmarkFolderTree isUserHomePage={true} isOperable={isOperable} userId={creatorId} />
         </div>
       </div>
       <div className="grw-user-page-list-m mt-5 d-edit-none">

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

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

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

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

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


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


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

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

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

@@ -3,7 +3,7 @@ import React from 'react';
 import { useTranslation } from 'next-i18next';
 import { Spinner } from 'reactstrap';
 
-import { useSWRxPlugins } from '~/stores/plugin';
+import { useSWRxPlugins } from '../../../stores/growi-plugin';
 
 import { PluginCard } from './PluginCard';
 import { PluginInstallerForm } from './PluginInstallerForm';

+ 1 - 0
apps/app/src/features/growi-plugin/components/Admin/PluginsExtensionPageContents/index.ts

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

+ 1 - 1
apps/app/src/features/activate-plugin/components/GrowiPluginsActivator.client.tsx → apps/app/src/features/growi-plugin/components/GrowiPluginsActivator.client.tsx

@@ -1,6 +1,6 @@
 import { useEffect } from 'react';
 
-import { initializeGrowiFacade, registerGrowiFacade } from '../utils/growi-facade-utils';
+import { initializeGrowiFacade, registerGrowiFacade } from '../utils/growi-facade-utils.client';
 
 declare global {
   // eslint-disable-next-line vars-on-top, no-var

+ 0 - 0
apps/app/src/features/activate-plugin/components/index.ts → apps/app/src/features/growi-plugin/components/index.ts


+ 6 - 6
apps/app/src/interfaces/plugin.ts → apps/app/src/features/growi-plugin/interfaces/growi-plugin.ts

@@ -8,29 +8,29 @@ export const GrowiPluginResourceType = {
 } as const;
 export type GrowiPluginResourceType = typeof GrowiPluginResourceType[keyof typeof GrowiPluginResourceType];
 
-export type GrowiPluginOrigin = {
+export type IGrowiPluginOrigin = {
   url: string,
   ghBranch?: string,
   ghTag?: string,
 }
 
-export type GrowiPlugin<M extends GrowiPluginMeta = GrowiPluginMeta> = {
+export type IGrowiPlugin<M extends IGrowiPluginMeta = IGrowiPluginMeta> = {
   isEnabled: boolean,
   installedPath: string,
   organizationName: string,
-  origin: GrowiPluginOrigin,
+  origin: IGrowiPluginOrigin,
   meta: M,
 }
 
-export type GrowiPluginMeta = {
+export type IGrowiPluginMeta = {
   name: string,
   types: GrowiPluginResourceType[],
   desc?: string,
   author?: string,
 }
 
-export type GrowiThemePluginMeta = GrowiPluginMeta & {
+export type IGrowiThemePluginMeta = IGrowiPluginMeta & {
   themes: GrowiThemeMetadata[]
 }
 
-export type GrowiPluginHasId = GrowiPlugin & HasObjectId;
+export type IGrowiPluginHasId = IGrowiPlugin & HasObjectId;

+ 1 - 0
apps/app/src/features/growi-plugin/interfaces/index.ts

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

+ 16 - 15
apps/app/src/server/models/growi-plugin.ts → apps/app/src/features/growi-plugin/models/growi-plugin.ts

@@ -1,19 +1,20 @@
 import { GrowiThemeMetadata, GrowiThemeSchemeType } from '@growi/core';
 import {
-  Schema, Model, Document, Types,
+  Schema, type Model, type Document, type Types,
 } from 'mongoose';
 
-import {
-  GrowiPlugin, GrowiPluginMeta, GrowiPluginOrigin, GrowiPluginResourceType, GrowiThemePluginMeta,
-} from '~/interfaces/plugin';
+import { getOrCreateModel } from '~/server/util/mongoose-utils';
 
-import { getOrCreateModel } from '../util/mongoose-utils';
+import { GrowiPluginResourceType } from '../interfaces';
+import type {
+  IGrowiPlugin, IGrowiPluginMeta, IGrowiPluginOrigin, IGrowiThemePluginMeta,
+} from '../interfaces';
 
-export interface GrowiPluginDocument extends GrowiPlugin, Document {
+export interface IGrowiPluginDocument extends IGrowiPlugin, Document {
 }
-export interface GrowiPluginModel extends Model<GrowiPluginDocument> {
-  findEnabledPlugins(): Promise<GrowiPlugin[]>
-  findEnabledPluginsIncludingAnyTypes(includingTypes: GrowiPluginResourceType[]): Promise<GrowiPlugin[]>
+export interface IGrowiPluginModel extends Model<IGrowiPluginDocument> {
+  findEnabledPlugins(): Promise<IGrowiPlugin[]>
+  findEnabledPluginsIncludingAnyTypes(includingTypes: GrowiPluginResourceType[]): Promise<IGrowiPlugin[]>
   activatePlugin(id: Types.ObjectId): Promise<string>
   deactivatePlugin(id: Types.ObjectId): Promise<string>
 }
@@ -32,7 +33,7 @@ const growiThemeMetadataSchema = new Schema<GrowiThemeMetadata>({
   accent: { type: String, required: true },
 });
 
-const growiPluginMetaSchema = new Schema<GrowiPluginMeta|GrowiThemePluginMeta>({
+const growiPluginMetaSchema = new Schema<IGrowiPluginMeta|IGrowiThemePluginMeta>({
   name: { type: String, required: true },
   types: {
     type: [String],
@@ -44,13 +45,13 @@ const growiPluginMetaSchema = new Schema<GrowiPluginMeta|GrowiThemePluginMeta>({
   themes: [growiThemeMetadataSchema],
 });
 
-const growiPluginOriginSchema = new Schema<GrowiPluginOrigin>({
+const growiPluginOriginSchema = new Schema<IGrowiPluginOrigin>({
   url: { type: String },
   ghBranch: { type: String },
   ghTag: { type: String },
 });
 
-const growiPluginSchema = new Schema<GrowiPluginDocument, GrowiPluginModel>({
+const growiPluginSchema = new Schema<IGrowiPluginDocument, IGrowiPluginModel>({
   isEnabled: { type: Boolean },
   installedPath: { type: String },
   organizationName: { type: String },
@@ -58,11 +59,11 @@ const growiPluginSchema = new Schema<GrowiPluginDocument, GrowiPluginModel>({
   meta: growiPluginMetaSchema,
 });
 
-growiPluginSchema.statics.findEnabledPlugins = async function(): Promise<GrowiPlugin[]> {
+growiPluginSchema.statics.findEnabledPlugins = async function(): Promise<IGrowiPlugin[]> {
   return this.find({ isEnabled: true });
 };
 
-growiPluginSchema.statics.findEnabledPluginsIncludingAnyTypes = async function(types: GrowiPluginResourceType[]): Promise<GrowiPlugin[]> {
+growiPluginSchema.statics.findEnabledPluginsIncludingAnyTypes = async function(types: GrowiPluginResourceType[]): Promise<IGrowiPlugin[]> {
   return this.find({
     isEnabled: true,
     'meta.types': { $in: types },
@@ -89,4 +90,4 @@ growiPluginSchema.statics.deactivatePlugin = async function(id: Types.ObjectId):
   return pluginName;
 };
 
-export default getOrCreateModel<GrowiPluginDocument, GrowiPluginModel>('GrowiPlugin', growiPluginSchema);
+export const GrowiPlugin = getOrCreateModel<IGrowiPluginDocument, IGrowiPluginModel>('GrowiPlugin', growiPluginSchema);

+ 1 - 0
apps/app/src/features/growi-plugin/models/index.ts

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

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

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

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

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

+ 12 - 33
apps/app/src/server/routes/apiv3/plugins.ts → apps/app/src/features/growi-plugin/routes/growi-plugins.ts

@@ -2,10 +2,12 @@ import express, { Request, Router } from 'express';
 import { body, query } from 'express-validator';
 import mongoose from 'mongoose';
 
-import Crowi from '../../crowi';
-import type { GrowiPluginModel } from '../../models/growi-plugin';
+import Crowi from '~/server/crowi';
+import { ApiV3Response } from '~/server/routes/apiv3/interfaces/apiv3-response';
+
+import { GrowiPlugin } from '../models';
+import { growiPluginService } from '../services';
 
-import { ApiV3Response } from './interfaces/apiv3-response';
 
 const ObjectID = mongoose.Types.ObjectId;
 
@@ -22,20 +24,14 @@ const validator = {
 };
 
 module.exports = (crowi: Crowi): Router => {
-  const loginRequiredStrictly = require('../../middlewares/login-required')(crowi);
-  const adminRequired = require('../../middlewares/admin-required')(crowi);
+  const loginRequiredStrictly = require('~/server/middlewares/login-required')(crowi);
+  const adminRequired = require('~/server/middlewares/admin-required')(crowi);
 
   const router = express.Router();
-  const { pluginService } = crowi;
 
   router.get('/', loginRequiredStrictly, adminRequired, async(req: Request, res: ApiV3Response) => {
-    if (pluginService == null) {
-      return res.apiv3Err('\'pluginService\' is not set up', 500);
-    }
-
     try {
-      const GrowiPluginModel = mongoose.model('GrowiPlugin') as GrowiPluginModel;
-      const data = await GrowiPluginModel.find({});
+      const data = await GrowiPlugin.find({});
       return res.apiv3({ plugins: data });
     }
     catch (err) {
@@ -44,14 +40,10 @@ module.exports = (crowi: Crowi): Router => {
   });
 
   router.post('/', loginRequiredStrictly, adminRequired, validator.pluginFormValueisRequired, async(req: Request, res: ApiV3Response) => {
-    if (pluginService == null) {
-      return res.apiv3Err('\'pluginService\' is not set up', 500);
-    }
-
     const { pluginInstallerForm: formValue } = req.body;
 
     try {
-      const pluginName = await pluginService.install(formValue);
+      const pluginName = await growiPluginService.install(formValue);
       return res.apiv3({ pluginName });
     }
     catch (err) {
@@ -60,15 +52,11 @@ module.exports = (crowi: Crowi): Router => {
   });
 
   router.put('/:id/activate', loginRequiredStrictly, adminRequired, validator.pluginIdisRequired, async(req: Request, res: ApiV3Response) => {
-    if (pluginService == null) {
-      return res.apiv3Err('\'pluginService\' is not set up', 500);
-    }
     const { id } = req.params;
     const pluginId = new ObjectID(id);
 
     try {
-      const GrowiPluginModel = mongoose.model('GrowiPlugin') as GrowiPluginModel;
-      const pluginName = await GrowiPluginModel.activatePlugin(pluginId);
+      const pluginName = await GrowiPlugin.activatePlugin(pluginId);
       return res.apiv3({ pluginName });
     }
     catch (err) {
@@ -77,16 +65,11 @@ module.exports = (crowi: Crowi): Router => {
   });
 
   router.put('/:id/deactivate', loginRequiredStrictly, adminRequired, validator.pluginIdisRequired, async(req: Request, res: ApiV3Response) => {
-    if (pluginService == null) {
-      return res.apiv3Err('\'pluginService\' is not set up', 500);
-    }
-
     const { id } = req.params;
     const pluginId = new ObjectID(id);
 
     try {
-      const GrowiPluginModel = mongoose.model('GrowiPlugin') as GrowiPluginModel;
-      const pluginName = await GrowiPluginModel.deactivatePlugin(pluginId);
+      const pluginName = await GrowiPlugin.deactivatePlugin(pluginId);
       return res.apiv3({ pluginName });
     }
     catch (err) {
@@ -95,15 +78,11 @@ module.exports = (crowi: Crowi): Router => {
   });
 
   router.delete('/:id/remove', loginRequiredStrictly, adminRequired, validator.pluginIdisRequired, async(req: Request, res: ApiV3Response) => {
-    if (pluginService == null) {
-      return res.apiv3Err('\'pluginService\' is not set up', 500);
-    }
-
     const { id } = req.params;
     const pluginId = new ObjectID(id);
 
     try {
-      const pluginName = await pluginService.deletePlugin(pluginId);
+      const pluginName = await growiPluginService.deletePlugin(pluginId);
       return res.apiv3({ pluginName });
     }
     catch (err) {

+ 38 - 63
apps/app/src/server/service/plugin.ts → apps/app/src/features/growi-plugin/services/growi-plugin.ts

@@ -8,26 +8,25 @@ import mongoose from 'mongoose';
 import streamToPromise from 'stream-to-promise';
 import unzipper from 'unzipper';
 
-import {
-  GrowiPlugin, GrowiPluginOrigin, GrowiPluginResourceType, GrowiThemePluginMeta, GrowiPluginMeta,
-} from '~/interfaces/plugin';
 import loggerFactory from '~/utils/logger';
 import { resolveFromRoot } from '~/utils/project-dir-utils';
 
-import type { GrowiPluginModel } from '../models/growi-plugin';
+import { GrowiPluginResourceType } from '../interfaces';
+import type {
+  IGrowiPlugin, IGrowiPluginOrigin, IGrowiThemePluginMeta, IGrowiPluginMeta,
+} from '../interfaces';
+import { GrowiPlugin } from '../models';
+import { GitHubUrl } from '../models/vo/github-url';
 
 const logger = loggerFactory('growi:plugins:plugin-utils');
 
 const pluginStoringPath = resolveFromRoot('tmp/plugins');
 
-// https://regex101.com/r/fK2rV3/1
-const githubReposIdPattern = new RegExp(/^\/([^/]+)\/([^/]+)$/);
-
 const PLUGINS_STATIC_DIR = '/static/plugins'; // configured by express.static
 
 export type GrowiPluginResourceEntries = [installedPath: string, href: string][];
 
-function retrievePluginManifest(growiPlugin: GrowiPlugin): ViteManifest {
+function retrievePluginManifest(growiPlugin: IGrowiPlugin): ViteManifest {
   const manifestPath = resolveFromRoot(path.join('tmp/plugins', growiPlugin.installedPath, 'dist/manifest.json'));
   const manifestStr: string = readFileSync(manifestPath, 'utf-8');
   return JSON.parse(manifestStr);
@@ -35,19 +34,19 @@ function retrievePluginManifest(growiPlugin: GrowiPlugin): ViteManifest {
 
 
 type FindThemePluginResult = {
-  growiPlugin: GrowiPlugin,
+  growiPlugin: IGrowiPlugin,
   themeMetadata: GrowiThemeMetadata,
   themeHref: string,
 }
 
-export interface IPluginService {
-  install(origin: GrowiPluginOrigin): Promise<string>
+export interface IGrowiPluginService {
+  install(origin: IGrowiPluginOrigin): Promise<string>
   findThemePlugin(theme: string): Promise<FindThemePluginResult | null>
   retrieveAllPluginResourceEntries(): Promise<GrowiPluginResourceEntries>
   downloadNotExistPluginRepositories(): Promise<void>
 }
 
-export class PluginService implements IPluginService {
+export class GrowiPluginService implements IGrowiPluginService {
 
   /*
   * Downloading a non-existent repository to the file system
@@ -55,7 +54,6 @@ export class PluginService implements IPluginService {
   async downloadNotExistPluginRepositories(): Promise<void> {
     try {
       // find all growi plugin documents
-      const GrowiPlugin = mongoose.model<GrowiPlugin>('GrowiPlugin');
       const growiPlugins = await GrowiPlugin.find({});
 
       // if not exists repository in file system, download latest plugin repository
@@ -71,26 +69,16 @@ export class PluginService implements IPluginService {
           }
 
           // TODO: imprv Document version and repository version possibly different.
-          const ghUrl = new URL(growiPlugin.origin.url);
-          const ghPathname = ghUrl.pathname;
-          // TODO: Branch names can be specified.
-          const ghBranch = 'main';
-          const match = ghPathname.match(githubReposIdPattern);
-          if (ghUrl.hostname !== 'github.com' || match == null) {
-            throw new Error('GitHub repository URL is invalid.');
-          }
-
-          const ghOrganizationName = match[1];
-          const ghReposName = match[2];
+          const ghUrl = new GitHubUrl(growiPlugin.origin.url, growiPlugin.origin.branchName);
+          const { reposName, branchName, archiveUrl } = ghUrl;
 
-          const requestUrl = `https://github.com/${ghOrganizationName}/${ghReposName}/archive/refs/heads/${ghBranch}.zip`;
-          const zipFilePath = path.join(pluginStoringPath, `${ghBranch}.zip`);
+          const zipFilePath = path.join(pluginStoringPath, `${branchName}.zip`);
           const unzippedPath = pluginStoringPath;
-          const unzippedReposPath = path.join(pluginStoringPath, `${ghReposName}-${ghBranch}`);
+          const unzippedReposPath = path.join(pluginStoringPath, `${reposName}-${branchName}`);
 
           try {
             // download github repository to local file system
-            await this.download(requestUrl, zipFilePath);
+            await this.download(archiveUrl, zipFilePath);
             await this.unzip(zipFilePath, unzippedPath);
             fs.renameSync(unzippedReposPath, pluginPath);
           }
@@ -113,40 +101,31 @@ export class PluginService implements IPluginService {
   /*
   * Install a plugin from URL and save it in the DB and file system.
   */
-  async install(origin: GrowiPluginOrigin): Promise<string> {
-    const ghUrl = new URL(origin.url);
-    const ghPathname = ghUrl.pathname;
-    // TODO: Branch names can be specified.
-    const ghBranch = 'main';
-
-    const match = ghPathname.match(githubReposIdPattern);
-    if (ghUrl.hostname !== 'github.com' || match == null) {
-      throw new Error('GitHub repository URL is invalid.');
-    }
-
-    const ghOrganizationName = match[1];
-    const ghReposName = match[2];
-    const installedPath = `${ghOrganizationName}/${ghReposName}`;
+  async install(origin: IGrowiPluginOrigin): Promise<string> {
+    const ghUrl = new GitHubUrl(origin.url, origin.ghBranch);
+    const {
+      organizationName, reposName, branchName, archiveUrl,
+    } = ghUrl;
+    const installedPath = `${organizationName}/${reposName}`;
 
-    const requestUrl = `https://github.com/${ghOrganizationName}/${ghReposName}/archive/refs/heads/${ghBranch}.zip`;
-    const zipFilePath = path.join(pluginStoringPath, `${ghBranch}.zip`);
+    const zipFilePath = path.join(pluginStoringPath, `${branchName}.zip`);
     const unzippedPath = pluginStoringPath;
-    const unzippedReposPath = path.join(pluginStoringPath, `${ghReposName}-${ghBranch}`);
-    const temporaryReposPath = path.join(pluginStoringPath, ghReposName);
+    const unzippedReposPath = path.join(pluginStoringPath, `${reposName}-${branchName}`);
+    const temporaryReposPath = path.join(pluginStoringPath, reposName);
     const reposStoringPath = path.join(pluginStoringPath, `${installedPath}`);
-    const organizationPath = path.join(pluginStoringPath, ghOrganizationName);
+    const organizationPath = path.join(pluginStoringPath, organizationName);
 
 
-    let plugins: GrowiPlugin<GrowiPluginMeta>[];
+    let plugins: IGrowiPlugin<IGrowiPluginMeta>[];
 
     try {
       // download github repository to file system's temporary path
-      await this.download(requestUrl, zipFilePath);
+      await this.download(archiveUrl, zipFilePath);
       await this.unzip(zipFilePath, unzippedPath);
       fs.renameSync(unzippedReposPath, temporaryReposPath);
 
       // detect plugins
-      plugins = await PluginService.detectPlugins(origin, ghOrganizationName, ghReposName);
+      plugins = await GrowiPluginService.detectPlugins(origin, organizationName, reposName);
 
       if (!fs.existsSync(organizationPath)) fs.mkdirSync(organizationPath);
 
@@ -184,7 +163,6 @@ export class PluginService implements IPluginService {
   }
 
   private async deleteOldPluginDocument(path: string): Promise<void> {
-    const GrowiPlugin = mongoose.model<GrowiPlugin>('GrowiPlugin');
     await GrowiPlugin.deleteMany({ installedPath: path });
   }
 
@@ -230,13 +208,12 @@ export class PluginService implements IPluginService {
     }
   }
 
-  private async savePluginMetaData(plugins: GrowiPlugin[]): Promise<void> {
-    const GrowiPlugin = mongoose.model('GrowiPlugin');
+  private async savePluginMetaData(plugins: IGrowiPlugin[]): Promise<void> {
     await GrowiPlugin.insertMany(plugins);
   }
 
   // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types, max-len
-  private static async detectPlugins(origin: GrowiPluginOrigin, ghOrganizationName: string, ghReposName: string, parentPackageJson?: any): Promise<GrowiPlugin[]> {
+  private static async detectPlugins(origin: IGrowiPluginOrigin, ghOrganizationName: string, ghReposName: string, parentPackageJson?: any): Promise<IGrowiPlugin[]> {
     const packageJsonPath = path.resolve(pluginStoringPath, ghReposName, 'package.json');
     const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf-8'));
 
@@ -279,7 +256,7 @@ export class PluginService implements IPluginService {
 
     // add theme metadata
     if (growiPlugin.types.includes(GrowiPluginResourceType.Theme)) {
-      (plugin as GrowiPlugin<GrowiThemePluginMeta>).meta = {
+      (plugin as IGrowiPlugin<IGrowiThemePluginMeta>).meta = {
         ...plugin.meta,
         themes: growiPlugin.themes,
       };
@@ -290,7 +267,7 @@ export class PluginService implements IPluginService {
     return [plugin];
   }
 
-  async listPlugins(): Promise<GrowiPlugin[]> {
+  async listPlugins(): Promise<IGrowiPlugin[]> {
     return [];
   }
 
@@ -302,7 +279,6 @@ export class PluginService implements IPluginService {
       return fs.promises.rm(path, { recursive: true });
     };
 
-    const GrowiPlugin = mongoose.model<GrowiPlugin>('GrowiPlugin');
     const growiPlugins = await GrowiPlugin.findById(pluginId);
 
     if (growiPlugins == null) {
@@ -330,14 +306,12 @@ export class PluginService implements IPluginService {
   }
 
   async findThemePlugin(theme: string): Promise<FindThemePluginResult | null> {
-    const GrowiPlugin = mongoose.model('GrowiPlugin') as GrowiPluginModel;
-
-    let matchedPlugin: GrowiPlugin | undefined;
+    let matchedPlugin: IGrowiPlugin | undefined;
     let matchedThemeMetadata: GrowiThemeMetadata | undefined;
 
     try {
       // retrieve plugin manifests
-      const growiPlugins = await GrowiPlugin.findEnabledPluginsIncludingAnyTypes([GrowiPluginResourceType.Theme]) as GrowiPlugin<GrowiThemePluginMeta>[];
+      const growiPlugins = await GrowiPlugin.findEnabledPluginsIncludingAnyTypes([GrowiPluginResourceType.Theme]) as IGrowiPlugin<IGrowiThemePluginMeta>[];
 
       growiPlugins
         .forEach(async(growiPlugin) => {
@@ -373,8 +347,6 @@ export class PluginService implements IPluginService {
 
   async retrieveAllPluginResourceEntries(): Promise<GrowiPluginResourceEntries> {
 
-    const GrowiPlugin = mongoose.model('GrowiPlugin') as GrowiPluginModel;
-
     const entries: GrowiPluginResourceEntries = [];
 
     try {
@@ -409,3 +381,6 @@ export class PluginService implements IPluginService {
   }
 
 }
+
+
+export const growiPluginService = new GrowiPluginService();

+ 1 - 0
apps/app/src/features/growi-plugin/services/index.ts

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

+ 3 - 2
apps/app/src/stores/plugin.tsx → apps/app/src/features/growi-plugin/stores/growi-plugin.tsx

@@ -1,10 +1,11 @@
 import useSWR, { SWRResponse } from 'swr';
 
 import { apiv3Get } from '~/client/util/apiv3-client';
-import { GrowiPluginHasId } from '~/interfaces/plugin';
+
+import type { IGrowiPluginHasId } from '../interfaces';
 
 type Plugins = {
-  plugins: GrowiPluginHasId[]
+  plugins: IGrowiPluginHasId[]
 }
 
 const pluginsFetcher = () => {

+ 0 - 0
apps/app/src/features/activate-plugin/utils/growi-facade-utils.ts → apps/app/src/features/growi-plugin/utils/growi-facade-utils.client.ts


+ 0 - 0
apps/app/src/features/mermaid-plugin/components/MermaidViewer.tsx → apps/app/src/features/mermaid/components/MermaidViewer.tsx


+ 0 - 0
apps/app/src/features/mermaid-plugin/components/index.ts → apps/app/src/features/mermaid/components/index.ts


+ 0 - 0
apps/app/src/features/mermaid-plugin/index.ts → apps/app/src/features/mermaid/index.ts


+ 0 - 0
apps/app/src/features/mermaid-plugin/services/index.ts → apps/app/src/features/mermaid/services/index.ts


+ 0 - 0
apps/app/src/features/mermaid-plugin/services/mermaid.ts → apps/app/src/features/mermaid/services/mermaid.ts


+ 6 - 6
apps/app/src/interfaces/activity.ts

@@ -149,8 +149,8 @@ const ACTION_ADMIN_SLACK_CONFIGURATION_SETTING_UPDATE = 'ADMIN_SLACK_CONFIGURATI
 const ACTION_ADMIN_USERS_INVITE = 'ADMIN_USERS_INVITE';
 const ACTION_ADMIN_USERS_PASSWORD_RESET = 'ADMIN_USERS_PASSWORD_RESET';
 const ACTION_ADMIN_USERS_ACTIVATE = 'ADMIN_USERS_ACTIVATE';
-const ACTION_ADMIN_USERS_GIVE_ADMIN = 'ADMIN_USERS_GIVE_ADMIN';
-const ACTION_ADMIN_USERS_REMOVE_ADMIN = 'ADMIN_USERS_REMOVE_ADMIN';
+const ACTION_ADMIN_USERS_GRANT_ADMIN = 'ADMIN_USERS_GRANT_ADMIN';
+const ACTION_ADMIN_USERS_REVOKE_ADMIN = 'ADMIN_USERS_REVOKE_ADMIN';
 const ACTION_ADMIN_USERS_GRANT_READ_ONLY = 'ADMIN_USERS_GRANT_READ_ONLY';
 const ACTION_ADMIN_USERS_REVOKE_READ_ONLY = 'ADMIN_USERS_REVOKE_READ_ONLY';
 const ACTION_ADMIN_USERS_DEACTIVATE = 'ADMIN_USERS_DEACTIVATE';
@@ -329,8 +329,8 @@ export const SupportedAction = {
   ACTION_ADMIN_USERS_PASSWORD_RESET,
   ACTION_ADMIN_USERS_ACTIVATE,
   ACTION_ADMIN_USERS_DEACTIVATE,
-  ACTION_ADMIN_USERS_GIVE_ADMIN,
-  ACTION_ADMIN_USERS_REMOVE_ADMIN,
+  ACTION_ADMIN_USERS_GRANT_ADMIN,
+  ACTION_ADMIN_USERS_REVOKE_ADMIN,
   ACTION_ADMIN_USERS_GRANT_READ_ONLY,
   ACTION_ADMIN_USERS_REVOKE_READ_ONLY,
   ACTION_ADMIN_USERS_SEND_INVITATION_EMAIL,
@@ -516,8 +516,8 @@ export const LargeActionGroup = {
   ACTION_ADMIN_USERS_PASSWORD_RESET,
   ACTION_ADMIN_USERS_ACTIVATE,
   ACTION_ADMIN_USERS_DEACTIVATE,
-  ACTION_ADMIN_USERS_GIVE_ADMIN,
-  ACTION_ADMIN_USERS_REMOVE_ADMIN,
+  ACTION_ADMIN_USERS_GRANT_ADMIN,
+  ACTION_ADMIN_USERS_REVOKE_ADMIN,
   ACTION_ADMIN_USERS_GRANT_READ_ONLY,
   ACTION_ADMIN_USERS_REVOKE_READ_ONLY,
   ACTION_ADMIN_USERS_SEND_INVITATION_EMAIL,

+ 13 - 15
apps/app/src/interfaces/bookmark-info.ts

@@ -3,13 +3,14 @@ import { Ref } from '@growi/core';
 import { IPageHasId } from '~/interfaces/page';
 import { IUser } from '~/interfaces/user';
 
-export type IBookmarkInfo = {
-  sumOfBookmarks: number;
+export interface IBookmarkInfo {
+  sumOfBookmarks: number,
   isBookmarked: boolean,
-  bookmarkedUsers: IUser[]
-};
+  bookmarkedUsers: IUser[],
+  pageId: string,
+}
 
-type BookmarkedPage = {
+export interface BookmarkedPage {
   _id: string,
   page: IPageHasId,
   user: Ref<IUser>,
@@ -24,12 +25,10 @@ export interface IBookmarkFolder {
   parent?: Ref<this>
 }
 
-export interface BookmarkFolderItems {
-  _id: string
-  name: string
-  parent: string
-  children: this[]
-  bookmarks: BookmarkedPage[]
+export interface BookmarkFolderItems extends IBookmarkFolder {
+  _id: string;
+  children: BookmarkFolderItems[];
+  bookmarks: BookmarkedPage[];
 }
 
 export const DRAG_ITEM_TYPE = {
@@ -37,15 +36,14 @@ export const DRAG_ITEM_TYPE = {
   BOOKMARK: 'BOOKMARK',
 } as const;
 
-type BookmarkDragItem = {
+interface BookmarkDragItem {
   bookmarkFolder: BookmarkFolderItems
   level: number
   root: string
 }
 
-export type DragItemDataType = BookmarkDragItem & {
+export interface DragItemDataType extends BookmarkDragItem, IPageHasId {
   parentFolder: BookmarkFolderItems | null
-} & IPageHasId
-
+}
 
 export type DragItemType = typeof DRAG_ITEM_TYPE[keyof typeof DRAG_ITEM_TYPE];

+ 26 - 5
apps/app/src/pages/[[...path]].page.tsx

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

+ 3 - 3
apps/app/src/pages/_document.page.tsx

@@ -6,8 +6,8 @@ import Document, {
   Html, Head, Main, NextScript,
 } from 'next/document';
 
+import { growiPluginService, type GrowiPluginResourceEntries } from '~/features/growi-plugin/services';
 import type { CrowiRequest } from '~/interfaces/crowi-request';
-import type { IPluginService, GrowiPluginResourceEntries } from '~/server/service/plugin';
 import loggerFactory from '~/utils/logger';
 
 const logger = loggerFactory('growi:page:_document');
@@ -49,7 +49,7 @@ class GrowiDocument extends Document<GrowiDocumentInitialProps> {
   static override async getInitialProps(ctx: DocumentContext): Promise<GrowiDocumentInitialProps> {
     const initialProps: DocumentInitialProps = await Document.getInitialProps(ctx);
     const { crowi } = ctx.req as CrowiRequest<any>;
-    const { customizeService, pluginService } = crowi;
+    const { customizeService } = crowi;
 
     const { themeHref } = customizeService;
     const customScript: string | null = customizeService.getCustomScript();
@@ -57,7 +57,7 @@ class GrowiDocument extends Document<GrowiDocumentInitialProps> {
     const customNoscript: string | null = customizeService.getCustomNoscript();
 
     // retrieve plugin manifests
-    const pluginResourceEntries = await (pluginService as IPluginService).retrieveAllPluginResourceEntries();
+    const pluginResourceEntries = await growiPluginService.retrieveAllPluginResourceEntries();
 
     return {
       ...initialProps,

+ 1 - 1
apps/app/src/pages/admin/plugins.page.tsx

@@ -18,7 +18,7 @@ import { retrieveServerSideProps } from '../../utils/admin-page-util';
 
 const AdminLayout = dynamic(() => import('~/components/Layout/AdminLayout'), { ssr: false });
 const PluginsExtensionPageContents = dynamic(
-  () => import('~/components/Admin/PluginsExtension/PluginsExtensionPageContents').then(mod => mod.PluginsExtensionPageContents),
+  () => import('~/features/growi-plugin/components/Admin/PluginsExtensionPageContents').then(mod => mod.PluginsExtensionPageContents),
   { ssr: false },
 );
 

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

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

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

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

+ 1 - 3
apps/app/src/server/console.js

@@ -4,7 +4,7 @@ const repl = require('repl');
 
 const mongoose = require('mongoose');
 
-const { initMongooseGlobalSettings, getMongoUri, mongoOptions } = require('~/server/util/mongoose-utils');
+const { getMongoUri, mongoOptions } = require('~/server/util/mongoose-utils');
 
 const models = require('./models');
 
@@ -34,8 +34,6 @@ fs.readFile(replHistoryPath, 'utf8', (err, data) => {
 replServer.context.mongoose = mongoose;
 replServer.context.models = models;
 
-initMongooseGlobalSettings();
-
 mongoose.connect(getMongoUri(), mongoOptions)
   .then(() => {
     replServer.context.db = mongoose.connection.db;

+ 3 - 1
apps/app/src/server/crowi/express-init.js

@@ -5,6 +5,8 @@ import qs from 'qs';
 import loggerFactory from '~/utils/logger';
 import { resolveFromRoot } from '~/utils/project-dir-utils';
 
+import registerSafeRedirectFactory from '../middlewares/safe-redirect';
+
 const logger = loggerFactory('growi:crowi:express-init');
 
 module.exports = function(crowi, app) {
@@ -22,7 +24,7 @@ module.exports = function(crowi, app) {
   const mongoSanitize = require('express-mongo-sanitize');
 
   const promster = require('../middlewares/promster')(crowi, app);
-  const registerSafeRedirect = require('../middlewares/safe-redirect')();
+  const registerSafeRedirect = registerSafeRedirectFactory();
   const injectCurrentuserToLocalvars = require('../middlewares/inject-currentuser-to-localvars')();
   const autoReconnectToS2sMsgServer = require('../middlewares/auto-reconnect-to-s2s-msg-server')(crowi);
 

+ 10 - 28
apps/app/src/server/crowi/index.js

@@ -19,25 +19,22 @@ import { projectRoot } from '~/utils/project-dir-utils';
 
 
 import Activity from '../models/activity';
-import GrowiPlugin from '../models/growi-plugin';
 import PageRedirect from '../models/page-redirect';
 import Tag from '../models/tag';
 import UserGroup from '../models/user-group';
-import AclService from '../service/acl';
+import { aclService as aclServiceSingletonInstance } from '../service/acl';
 import AppService from '../service/app';
 import AttachmentService from '../service/attachment';
-import ConfigManager from '../service/config-manager';
+import { configManager as configManagerSingletonInstance } from '../service/config-manager';
 import { G2GTransferPusherService, G2GTransferReceiverService } from '../service/g2g-transfer';
 import { InstallerService } from '../service/installer';
 import PageService from '../service/page';
 import PageGrantService from '../service/page-grant';
 import PageOperationService from '../service/page-operation';
-// eslint-disable-next-line import/no-cycle
-import { PluginService } from '../service/plugin';
 import SearchService from '../service/search';
 import { SlackIntegrationService } from '../service/slack-integration';
 import { UserNotificationService } from '../service/user-notification';
-import { initMongooseGlobalSettings, getMongoUri, mongoOptions } from '../util/mongoose-utils';
+import { getMongoUri, mongoOptions } from '../util/mongoose-utils';
 
 
 const logger = loggerFactory('growi:crowi');
@@ -134,7 +131,6 @@ Crowi.prototype.init = async function() {
     this.scanRuntimeVersions(),
     this.setupPassport(),
     this.setupSearcher(),
-    this.setupPluginer(),
     this.setupMailer(),
     this.setupSlackIntegrationService(),
     this.setupG2GTransferService(),
@@ -146,7 +142,7 @@ Crowi.prototype.init = async function() {
     this.setupUserGroupService(),
     this.setupExport(),
     this.setupImport(),
-    this.setupPluginService(),
+    this.setupGrowiPluginService(),
     this.setupPageService(),
     this.setupInAppNotificationService(),
     this.setupActivityService(),
@@ -224,8 +220,6 @@ Crowi.prototype.setupDatabase = function() {
   // mongoUri = mongodb://user:password@host/dbname
   const mongoUri = getMongoUri();
 
-  initMongooseGlobalSettings();
-
   return mongoose.connect(mongoUri, mongoOptions);
 };
 
@@ -276,7 +270,7 @@ Crowi.prototype.setupSessionConfig = async function() {
 };
 
 Crowi.prototype.setupConfigManager = async function() {
-  this.configManager = new ConfigManager();
+  this.configManager = configManagerSingletonInstance;
   return this.configManager.loadConfigs();
 };
 
@@ -310,7 +304,6 @@ Crowi.prototype.setupModels = async function() {
   allModels.Tag = Tag;
   allModels.UserGroup = UserGroup;
   allModels.PageRedirect = PageRedirect;
-  allModels.growiPlugin = GrowiPlugin;
 
   Object.keys(allModels).forEach((key) => {
     return this.model(key, models[key](this));
@@ -396,13 +389,6 @@ Crowi.prototype.setupSearcher = async function() {
   this.searchService = new SearchService(this);
 };
 
-/**
- * setup PluginService
- */
-Crowi.prototype.setupPluginer = async function() {
-  this.pluginService = new PluginService(this);
-};
-
 Crowi.prototype.setupMailer = async function() {
   const MailService = require('~/server/service/mail');
   this.mailService = new MailService(this);
@@ -614,9 +600,7 @@ Crowi.prototype.setUpXss = async function() {
  * setup AclService
  */
 Crowi.prototype.setUpAcl = async function() {
-  if (this.aclService == null) {
-    this.aclService = new AclService(this.configManager);
-  }
+  this.aclService = aclServiceSingletonInstance;
 };
 
 /**
@@ -721,14 +705,12 @@ Crowi.prototype.setupImport = async function() {
   }
 };
 
-Crowi.prototype.setupPluginService = async function() {
-  const { PluginService } = require('../service/plugin');
-  if (this.pluginService == null) {
-    this.pluginService = new PluginService(this);
-  }
+Crowi.prototype.setupGrowiPluginService = async function() {
+  const { growiPluginService } = require('~/features/growi-plugin/services');
+
   // download plugin repositories, if document exists but there is no repository
   // TODO: Cannot download unless connected to the Internet at setup.
-  await this.pluginService.downloadNotExistPluginRepositories();
+  await growiPluginService.downloadNotExistPluginRepositories();
 };
 
 Crowi.prototype.setupPageService = async function() {

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