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

Merge branch 'master' into imprv/121423-show-error-message

WNomunomu 2 лет назад
Родитель
Сommit
e016fd17ae
73 измененных файлов с 801 добавлено и 651 удалено
  1. 9 0
      apps/app/.env.test
  2. 5 1
      apps/app/package.json
  3. 1 0
      apps/app/public/static/locales/en_US/commons.json
  4. 1 0
      apps/app/public/static/locales/en_US/translation.json
  5. 1 0
      apps/app/public/static/locales/ja_JP/commons.json
  6. 1 0
      apps/app/public/static/locales/ja_JP/translation.json
  7. 1 0
      apps/app/public/static/locales/zh_CN/commons.json
  8. 1 0
      apps/app/public/static/locales/zh_CN/translation.json
  9. 57 57
      apps/app/src/client/services/renderer/renderer.tsx
  10. 8 5
      apps/app/src/components/BookmarkButtons.tsx
  11. 4 4
      apps/app/src/components/Bookmarks/BookmarkFolderItem.tsx
  12. 44 51
      apps/app/src/components/Bookmarks/BookmarkFolderMenu.tsx
  13. 11 5
      apps/app/src/components/Bookmarks/BookmarkFolderTree.tsx
  14. 19 13
      apps/app/src/components/Bookmarks/BookmarkItem.tsx
  15. 4 3
      apps/app/src/components/InstallerForm.tsx
  16. 0 6
      apps/app/src/components/Layout/NoLoginLayout.module.scss
  17. 6 0
      apps/app/src/components/LoginForm.module.scss
  18. 1 1
      apps/app/src/components/LoginForm.tsx
  19. 10 4
      apps/app/src/components/PageEditor.tsx
  20. 2 2
      apps/app/src/components/PageEditor/CodeMirrorEditor.jsx
  21. 39 21
      apps/app/src/components/TemplateModal/TemplateModal.tsx
  22. 1 0
      apps/app/src/components/TemplateModal/index.tsx
  23. 101 0
      apps/app/src/components/TemplateModal/use-formatter.spec.tsx
  24. 48 0
      apps/app/src/components/TemplateModal/use-formatter.tsx
  25. 0 2
      apps/app/src/features/activate-plugin/index.ts
  26. 0 1
      apps/app/src/features/activate-plugin/utils/index.ts
  27. 0 0
      apps/app/src/features/growi-plugin/components/Admin/PluginsExtensionPageContents/PluginCard.module.scss
  28. 0 0
      apps/app/src/features/growi-plugin/components/Admin/PluginsExtensionPageContents/PluginCard.tsx
  29. 2 1
      apps/app/src/features/growi-plugin/components/Admin/PluginsExtensionPageContents/PluginInstallerForm.tsx
  30. 1 1
      apps/app/src/features/growi-plugin/components/Admin/PluginsExtensionPageContents/PluginsExtensionPageContents.tsx
  31. 1 0
      apps/app/src/features/growi-plugin/components/Admin/PluginsExtensionPageContents/index.ts
  32. 1 1
      apps/app/src/features/growi-plugin/components/GrowiPluginsActivator.client.tsx
  33. 0 0
      apps/app/src/features/growi-plugin/components/index.ts
  34. 6 6
      apps/app/src/features/growi-plugin/interfaces/growi-plugin.ts
  35. 1 0
      apps/app/src/features/growi-plugin/interfaces/index.ts
  36. 16 15
      apps/app/src/features/growi-plugin/models/growi-plugin.ts
  37. 1 0
      apps/app/src/features/growi-plugin/models/index.ts
  38. 12 33
      apps/app/src/features/growi-plugin/routes/growi-plugins.ts
  39. 22 26
      apps/app/src/features/growi-plugin/services/growi-plugin.ts
  40. 1 0
      apps/app/src/features/growi-plugin/services/index.ts
  41. 3 2
      apps/app/src/features/growi-plugin/stores/growi-plugin.tsx
  42. 0 0
      apps/app/src/features/growi-plugin/utils/growi-facade-utils.client.ts
  43. 0 0
      apps/app/src/features/mermaid/components/MermaidViewer.tsx
  44. 0 0
      apps/app/src/features/mermaid/components/index.ts
  45. 0 0
      apps/app/src/features/mermaid/index.ts
  46. 0 0
      apps/app/src/features/mermaid/services/index.ts
  47. 0 0
      apps/app/src/features/mermaid/services/mermaid.ts
  48. 3 2
      apps/app/src/interfaces/bookmark-info.ts
  49. 1 1
      apps/app/src/pages/[[...path]].page.tsx
  50. 3 3
      apps/app/src/pages/_document.page.tsx
  51. 1 1
      apps/app/src/pages/admin/plugins.page.tsx
  52. 7 23
      apps/app/src/server/crowi/index.js
  53. 1 0
      apps/app/src/server/routes/apiv3/bookmarks.js
  54. 6 8
      apps/app/src/server/routes/apiv3/customize-setting.js
  55. 1 1
      apps/app/src/server/routes/apiv3/index.js
  56. 0 221
      apps/app/src/server/service/acl.integ.test.ts
  57. 51 38
      apps/app/src/server/service/acl.integ.ts
  58. 19 27
      apps/app/src/server/service/acl.ts
  59. 2 5
      apps/app/src/server/service/customize.ts
  60. 1 0
      apps/app/src/stores/bookmark.ts
  61. 9 5
      apps/app/src/stores/modal.tsx
  62. 1 1
      apps/app/src/stores/renderer.tsx
  63. 103 23
      apps/app/src/stores/template.tsx
  64. 5 0
      apps/app/test-with-vite/.eslintrc.js
  65. 26 0
      apps/app/test-with-vite/setup/mongoms.ts
  66. 11 0
      apps/app/test-with-vite/tsconfig.json
  67. 0 19
      apps/app/test/integration/models/config.test.js
  68. 1 1
      apps/app/tsconfig.json
  69. 22 0
      apps/app/vitest.config.integ.ts
  70. 5 3
      apps/app/vitest.config.ts
  71. 2 2
      packages/core/vitest.config.ts
  72. 2 2
      packages/slack/vitest.config.ts
  73. 76 4
      yarn.lock

+ 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

+ 5 - 1
apps/app/package.json

@@ -35,9 +35,11 @@
     "prelint:swagger2openapi": "yarn openapi:v3",
     "test": "run-p test:*",
     "test:jest": "cross-env NODE_ENV=test NODE_OPTIONS=\"--max-old-space-size=4096\" jest --logHeapUsage",
-    "test:vitest": "vitest run config src --coverage",
+    "test:vitest": "run-p vitest:run vitest:run:integ",
     "jest:run": "cross-env NODE_ENV=test jest --passWithNoTests -- ",
     "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",
@@ -131,6 +133,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",
@@ -229,6 +232,7 @@
     "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",

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

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

@@ -454,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"
     },

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

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

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

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

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

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

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

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

+ 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 type { Pluggable } from 'unified';
 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';
@@ -58,11 +58,11 @@ export const generateViewOptions = (
   remarkPlugins.push(
     math,
     [plantuml.remarkPlugin, { plantumlUri: config.plantumlUri }],
-    drawioPlugin.remarkPlugin,
+    drawio.remarkPlugin,
+    mermaid.remarkPlugin,
     xsvToTable.remarkPlugin,
-    lsxGrowiPlugin.remarkPlugin,
-    refsGrowiPlugin.remarkPlugin,
-    mermaidPlugin.remarkPlugin,
+    lsxGrowiDirective.remarkPlugin,
+    refsGrowiDirective.remarkPlugin,
   );
   if (config.isEnabledLinebreaks) {
     remarkPlugins.push(breaks);
@@ -75,18 +75,18 @@ export const generateViewOptions = (
   const rehypeSanitizePlugin: Pluggable<any[]> | (() => void) = config.isEnabledXssPrevention
     ? [sanitize, deepmerge(
       commonSanitizeOption,
-      drawioPlugin.sanitizeOption,
-      lsxGrowiPlugin.sanitizeOption,
-      refsGrowiPlugin.sanitizeOption,
-      mermaidPlugin.sanitizeOption,
+      drawio.sanitizeOption,
+      mermaid.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 }],
@@ -100,15 +100,15 @@ 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.mermaid = mermaidPlugin.MermaidViewer;
+    components.mermaid = mermaid.MermaidViewer;
   }
 
   if (config.isEnabledXssPrevention) {
@@ -164,11 +164,11 @@ export const generateSimpleViewOptions = (
   remarkPlugins.push(
     math,
     [plantuml.remarkPlugin, { plantumlUri: config.plantumlUri }],
-    drawioPlugin.remarkPlugin,
+    drawio.remarkPlugin,
+    mermaid.remarkPlugin,
     xsvToTable.remarkPlugin,
-    lsxGrowiPlugin.remarkPlugin,
-    refsGrowiPlugin.remarkPlugin,
-    mermaidPlugin.remarkPlugin,
+    lsxGrowiDirective.remarkPlugin,
+    refsGrowiDirective.remarkPlugin,
   );
 
   const isEnabledLinebreaks = overrideIsEnabledLinebreaks ?? config.isEnabledLinebreaks;
@@ -185,17 +185,17 @@ export const generateSimpleViewOptions = (
   const rehypeSanitizePlugin: Pluggable<any[]> | (() => void) = config.isEnabledXssPrevention
     ? [sanitize, deepmerge(
       commonSanitizeOption,
-      drawioPlugin.sanitizeOption,
-      lsxGrowiPlugin.sanitizeOption,
-      refsGrowiPlugin.sanitizeOption,
-      mermaidPlugin.sanitizeOption,
+      drawio.sanitizeOption,
+      mermaid.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,
@@ -203,14 +203,14 @@ 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.mermaid = mermaidPlugin.MermaidViewer;
+    components.lsx = lsxGrowiDirective.LsxImmutable;
+    components.ref = refsGrowiDirective.RefImmutable;
+    components.refs = refsGrowiDirective.RefsImmutable;
+    components.refimg = refsGrowiDirective.RefImgImmutable;
+    components.refsimg = refsGrowiDirective.RefsImgImmutable;
+    components.gallery = refsGrowiDirective.GalleryImmutable;
+    components.drawio = drawio.DrawioViewer;
+    components.mermaid = mermaid.MermaidViewer;
   }
 
   if (config.isEnabledXssPrevention) {
@@ -241,11 +241,11 @@ 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,
-    refsGrowiPlugin.remarkPlugin,
-    mermaidPlugin.remarkPlugin,
+    lsxGrowiDirective.remarkPlugin,
+    refsGrowiDirective.remarkPlugin,
   );
   if (config.isEnabledLinebreaks) {
     remarkPlugins.push(breaks);
@@ -258,18 +258,18 @@ 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,
-      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,
@@ -277,14 +277,14 @@ 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.mermaid = mermaidPlugin.MermaidViewer;
+    components.lsx = lsxGrowiDirective.LsxImmutable;
+    components.ref = refsGrowiDirective.RefImmutable;
+    components.refs = refsGrowiDirective.RefsImmutable;
+    components.refimg = refsGrowiDirective.RefImgImmutable;
+    components.refsimg = refsGrowiDirective.RefsImgImmutable;
+    components.gallery = refsGrowiDirective.GalleryImmutable;
+    components.drawio = drawio.DrawioViewer;
+    components.mermaid = mermaid.MermaidViewer;
   }
 
   if (config.isEnabledXssPrevention) {

+ 8 - 5
apps/app/src/components/BookmarkButtons.tsx

@@ -45,13 +45,16 @@ export const BookmarkButtons: FC<Props> = (props: Props) => {
     return 'tooltip.bookmark';
   }, [isGuestUser]);
 
+  if (bookmarkInfo == null) {
+    return <></>;
+  }
 
   return (
     <div className={`btn-group btn-group-bookmark ${styles['btn-group-bookmark']}`} role="group" aria-label="Bookmark buttons">
-      <BookmarkFolderMenu >
+      <BookmarkFolderMenu bookmarkInfo={bookmarkInfo}>
         <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>
+          ${bookmarkInfo.isBookmarked ? 'active' : ''} ${isGuestUser ? 'disabled' : ''}`}>
+          <i className={`fa ${bookmarkInfo.isBookmarked ? 'fa-bookmark' : 'fa-bookmark-o'}`}></i>
         </DropdownToggle>
       </BookmarkFolderMenu>
 
@@ -65,9 +68,9 @@ 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 ${bookmarkInfo.isBookmarked ? 'active' : ''}`}
           >
-            {bookmarkInfo?.sumOfBookmarks ?? 0}
+            {bookmarkInfo.sumOfBookmarks ?? 0}
           </button>
           { bookmarkedUsers != null && (
             <Popover placement="bottom" isOpen={isPopoverOpen} target="po-total-bookmarks" toggle={togglePopover} trigger="legacy">

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

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

+ 44 - 51
apps/app/src/components/Bookmarks/BookmarkFolderMenu.tsx

@@ -6,14 +6,15 @@ import { DropdownItem, DropdownMenu, UncontrolledDropdown } from 'reactstrap';
 
 import { addBookmarkToFolder, toggleBookmark } from '~/client/util/bookmark-utils';
 import { toastError } from '~/client/util/toastr';
+import { IBookmarkInfo } from '~/interfaces/bookmark-info';
 import { useSWRBookmarkInfo, useSWRxUserBookmarks } from '~/stores/bookmark';
 import { useSWRxBookmarkFolderAndChild } from '~/stores/bookmark-folder';
 import { useCurrentUser } from '~/stores/context';
-import { useSWRxCurrentPage, useSWRxPageInfo } from '~/stores/page';
+import { useSWRxPageInfo } from '~/stores/page';
 
 import { BookmarkFolderMenuItem } from './BookmarkFolderMenuItem';
 
-export const BookmarkFolderMenu: React.FC<{children?: React.ReactNode}> = ({ children }): JSX.Element => {
+export const BookmarkFolderMenu: React.FC<{children?: React.ReactNode, bookmarkInfo: IBookmarkInfo }> = ({ children, bookmarkInfo }): JSX.Element => {
   const { t } = useTranslation();
 
   const [selectedItem, setSelectedItem] = useState<string | null>(null);
@@ -21,13 +22,12 @@ export const BookmarkFolderMenu: React.FC<{children?: React.ReactNode}> = ({ chi
 
   const { data: currentUser } = useCurrentUser();
   const { data: bookmarkFolders, mutate: mutateBookmarkFolders } = useSWRxBookmarkFolderAndChild(currentUser?._id);
-  const { data: currentPage } = useSWRxCurrentPage();
-  const { data: bookmarkInfo, mutate: mutateBookmarkInfo } = useSWRBookmarkInfo(currentPage?._id);
+  const { mutate: mutateBookmarkInfo } = useSWRBookmarkInfo(bookmarkInfo.pageId);
 
   const { mutate: mutateUserBookmarks } = useSWRxUserBookmarks(currentUser?._id);
-  const { mutate: mutatePageInfo } = useSWRxPageInfo(currentPage?._id);
+  const { mutate: mutatePageInfo } = useSWRxPageInfo(bookmarkInfo.pageId);
 
-  const isBookmarked = bookmarkInfo?.isBookmarked ?? false;
+  const isBookmarked = bookmarkInfo.isBookmarked ?? false;
 
   const isBookmarkFolderExists = useMemo((): boolean => {
     return bookmarkFolders != null && bookmarkFolders.length > 0;
@@ -35,14 +35,12 @@ export const BookmarkFolderMenu: React.FC<{children?: React.ReactNode}> = ({ chi
 
   const toggleBookmarkHandler = useCallback(async() => {
     try {
-      if (currentPage != null) {
-        await toggleBookmark(currentPage._id, isBookmarked);
-      }
+      await toggleBookmark(bookmarkInfo.pageId, isBookmarked);
     }
     catch (err) {
       toastError(err);
     }
-  }, [currentPage, isBookmarked]);
+  }, [bookmarkInfo.pageId, isBookmarked]);
 
   const onUnbookmarkHandler = useCallback(async() => {
     await toggleBookmarkHandler();
@@ -60,7 +58,7 @@ export const BookmarkFolderMenu: React.FC<{children?: React.ReactNode}> = ({ chi
     if (isOpen && bookmarkFolders != null) {
       bookmarkFolders.forEach((bookmarkFolder) => {
         bookmarkFolder.bookmarks.forEach((bookmark) => {
-          if (bookmark.page._id === currentPage?._id) {
+          if (bookmark.page._id === bookmarkInfo.pageId) {
             setSelectedItem(bookmarkFolder._id);
           }
         });
@@ -83,7 +81,7 @@ export const BookmarkFolderMenu: React.FC<{children?: React.ReactNode}> = ({ chi
       }
     }
   },
-  [isOpen, bookmarkFolders, selectedItem, isBookmarked, currentPage?._id, toggleBookmarkHandler, mutateUserBookmarks, mutateBookmarkInfo, mutatePageInfo]);
+  [isOpen, bookmarkFolders, selectedItem, isBookmarked, bookmarkInfo.pageId, toggleBookmarkHandler, mutateUserBookmarks, mutateBookmarkInfo, mutatePageInfo]);
 
   const onMenuItemClickHandler = useCallback(async(e, itemId: string) => {
     e.stopPropagation();
@@ -91,9 +89,7 @@ export const BookmarkFolderMenu: React.FC<{children?: React.ReactNode}> = ({ chi
     setSelectedItem(itemId);
 
     try {
-      if (currentPage != null) {
-        await addBookmarkToFolder(currentPage._id, itemId === 'root' ? null : itemId);
-      }
+      await addBookmarkToFolder(bookmarkInfo.pageId, itemId === 'root' ? null : itemId);
       mutateUserBookmarks();
       mutateBookmarkFolders();
       mutateBookmarkInfo();
@@ -101,7 +97,7 @@ export const BookmarkFolderMenu: React.FC<{children?: React.ReactNode}> = ({ chi
     catch (err) {
       toastError(err);
     }
-  }, [mutateBookmarkFolders, currentPage, mutateBookmarkInfo, mutateUserBookmarks]);
+  }, [bookmarkInfo.pageId, mutateUserBookmarks, mutateBookmarkFolders, mutateBookmarkInfo]);
 
   const renderBookmarkMenuItem = () => {
     return (
@@ -120,7 +116,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}
@@ -128,48 +124,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>
             ))}
           </>
         )}

+ 11 - 5
apps/app/src/components/Bookmarks/BookmarkFolderTree.tsx

@@ -8,7 +8,7 @@ import { IPageToDeleteWithMeta } from '~/interfaces/page';
 import { OnDeletedFunction } from '~/interfaces/ui';
 import { useSWRxUserBookmarks, useSWRBookmarkInfo } from '~/stores/bookmark';
 import { useSWRxBookmarkFolderAndChild } from '~/stores/bookmark-folder';
-import { useIsReadOnlyUser } from '~/stores/context';
+import { useIsReadOnlyUser, useCurrentUser } from '~/stores/context';
 import { usePageDeleteModal } from '~/stores/modal';
 import { useSWRxCurrentPage } from '~/stores/page';
 
@@ -35,20 +35,26 @@ export const BookmarkFolderTree: React.FC<Props> = (props: Props) => {
   // const acceptedTypes: DragItemType[] = [DRAG_ITEM_TYPE.FOLDER, DRAG_ITEM_TYPE.BOOKMARK];
   const { t } = useTranslation();
 
+  // In order to update the bookmark information in the sidebar when bookmarking or unbookmarking a page on someone else's user homepage
+  const { data: currentUser } = useCurrentUser();
+  const shouldMutateCurrentUserbookmarks = currentUser?._id !== userId;
+
   const { data: isReadOnlyUser } = useIsReadOnlyUser();
   const { data: currentPage } = useSWRxCurrentPage();
   const { mutate: mutateBookmarkInfo } = useSWRBookmarkInfo(currentPage?._id);
   const { data: bookmarkFolders, mutate: mutateBookmarkFolders } = useSWRxBookmarkFolderAndChild(userId);
   const { data: userBookmarks, mutate: mutateUserBookmarks } = useSWRxUserBookmarks(userId);
+  const { mutate: mutateCurrentUserBookmarks } = useSWRxUserBookmarks(shouldMutateCurrentUserbookmarks ? currentUser?._id : undefined);
   const { open: openDeleteModal } = usePageDeleteModal();
 
   const bookmarkFolderTreeMutation = useCallback(() => {
     mutateUserBookmarks();
+    mutateCurrentUserBookmarks();
     mutateBookmarkInfo();
     mutateBookmarkFolders();
-  }, [mutateBookmarkFolders, mutateBookmarkInfo, mutateUserBookmarks]);
+  }, [mutateBookmarkFolders, mutateBookmarkInfo, mutateCurrentUserBookmarks, mutateUserBookmarks]);
 
-  const onClickDeleteBookmarkHandler = useCallback((pageToDelete: IPageToDeleteWithMeta) => {
+  const onClickDeleteMenuItemHandler = useCallback((pageToDelete: IPageToDeleteWithMeta) => {
     const pageDeletedHandler: OnDeletedFunction = (pathOrPathsToDelete, _isRecursively, isCompletely) => {
       if (typeof pathOrPathsToDelete !== 'string') return;
 
@@ -107,7 +113,7 @@ export const BookmarkFolderTree: React.FC<Props> = (props: Props) => {
               level={0}
               root={bookmarkFolder._id}
               isUserHomePage={isUserHomePage}
-              onClickDeleteBookmarkHandler={onClickDeleteBookmarkHandler}
+              onClickDeleteMenuItemHandler={onClickDeleteMenuItemHandler}
               bookmarkFolderTreeMutation={bookmarkFolderTreeMutation}
             />
           );
@@ -122,7 +128,7 @@ export const BookmarkFolderTree: React.FC<Props> = (props: Props) => {
               level={0}
               parentFolder={null}
               canMoveToRoot={false}
-              onClickDeleteBookmarkHandler={onClickDeleteBookmarkHandler}
+              onClickDeleteMenuItemHandler={onClickDeleteMenuItemHandler}
               bookmarkFolderTreeMutation={bookmarkFolderTreeMutation}
             />
           </div>

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

@@ -6,7 +6,7 @@ import { DevidedPagePath, pathUtils } from '@growi/core';
 import { useTranslation } from 'react-i18next';
 import { 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';
@@ -28,7 +28,7 @@ type Props = {
   level: number,
   parentFolder: BookmarkFolderItems | null,
   canMoveToRoot: boolean,
-  onClickDeleteBookmarkHandler: (pageToDelete: IPageToDeleteWithMeta) => void,
+  onClickDeleteMenuItemHandler: (pageToDelete: IPageToDeleteWithMeta) => void,
   bookmarkFolderTreeMutation: () => void
 }
 
@@ -39,14 +39,13 @@ export const BookmarkItem = (props: Props): JSX.Element => {
   const { t } = useTranslation();
 
   const {
-    isReadOnlyUser, isOperable, 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}`;
@@ -65,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);
@@ -86,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) {
@@ -107,8 +113,8 @@ 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
@@ -137,12 +143,12 @@ export const BookmarkItem = (props: Props): JSX.Element => {
             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}
           >

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

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

@@ -132,6 +132,8 @@ const PageEditor = React.memo((): JSX.Element => {
   const markdownToSave = useRef<string>(initialValue);
   const [markdownToPreview, setMarkdownToPreview] = useState<string>(initialValue);
 
+  const [isPageCreatedWithAttachmentUpload, setIsPageCreatedWithAttachmentUpload] = useState(false);
+
   const { data: socket } = useGlobalSocket();
 
   const { mutate: mutateIsConflict } = useIsConflict();
@@ -322,10 +324,11 @@ 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);
+        setIsPageCreatedWithAttachmentUpload(true);
         globalEmitter.emit('resetInitializedHackMdStatus');
-        mutateGrant(res.page.grant);
         mutateIsLatestRevision(true);
         await mutateCurrentPageId(res.page._id);
         await mutateCurrentPage();
@@ -338,7 +341,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 +522,14 @@ 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
+  // Also, if an attachment is uploaded and a new page is created,
+  // "useCurrentPagePath" changes, but no page transition is made, so nothing is done.
   useEffect(() => {
-    if (currentPagePath != null) {
+    if (currentPagePath != null && !isPageCreatedWithAttachmentUpload) {
       editorRef.current?.setValue(initialValue);
     }
-  }, [currentPagePath, initialValue]);
+    setIsPageCreatedWithAttachmentUpload(false);
+  }, [currentPagePath, initialValue, isPageCreatedWithAttachmentUpload]);
 
   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() {

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

+ 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


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

@@ -4,7 +4,8 @@ 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 { useSWRxPlugins } from '../../../stores/growi-plugin';
 
 export const PluginInstallerForm = (): JSX.Element => {
   const { mutate } = useSWRxPlugins();

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

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

+ 22 - 26
apps/app/src/server/service/plugin.ts → apps/app/src/features/growi-plugin/services/growi-plugin.ts

@@ -8,13 +8,14 @@ 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';
 
 const logger = loggerFactory('growi:plugins:plugin-utils');
 
@@ -27,7 +28,7 @@ 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 +36,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 +56,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
@@ -113,7 +113,7 @@ 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> {
+  async install(origin: IGrowiPluginOrigin): Promise<string> {
     const ghUrl = new URL(origin.url);
     const ghPathname = ghUrl.pathname;
     // TODO: Branch names can be specified.
@@ -137,7 +137,7 @@ export class PluginService implements IPluginService {
     const organizationPath = path.join(pluginStoringPath, ghOrganizationName);
 
 
-    let plugins: GrowiPlugin<GrowiPluginMeta>[];
+    let plugins: IGrowiPlugin<IGrowiPluginMeta>[];
 
     try {
       // download github repository to file system's temporary path
@@ -146,7 +146,7 @@ export class PluginService implements IPluginService {
       fs.renameSync(unzippedReposPath, temporaryReposPath);
 
       // detect plugins
-      plugins = await PluginService.detectPlugins(origin, ghOrganizationName, ghReposName);
+      plugins = await GrowiPluginService.detectPlugins(origin, ghOrganizationName, ghReposName);
 
       if (!fs.existsSync(organizationPath)) fs.mkdirSync(organizationPath);
 
@@ -184,7 +184,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 +229,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 +277,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 +288,7 @@ export class PluginService implements IPluginService {
     return [plugin];
   }
 
-  async listPlugins(): Promise<GrowiPlugin[]> {
+  async listPlugins(): Promise<IGrowiPlugin[]> {
     return [];
   }
 
@@ -302,7 +300,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 +327,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 +368,6 @@ export class PluginService implements IPluginService {
 
   async retrieveAllPluginResourceEntries(): Promise<GrowiPluginResourceEntries> {
 
-    const GrowiPlugin = mongoose.model('GrowiPlugin') as GrowiPluginModel;
-
     const entries: GrowiPluginResourceEntries = [];
 
     try {
@@ -409,3 +402,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


+ 3 - 2
apps/app/src/interfaces/bookmark-info.ts

@@ -4,9 +4,10 @@ import { IPageHasId } from '~/interfaces/page';
 import { IUser } from '~/interfaces/user';
 
 export interface IBookmarkInfo {
-  sumOfBookmarks: number;
+  sumOfBookmarks: number,
   isBookmarked: boolean,
-  bookmarkedUsers: IUser[]
+  bookmarkedUsers: IUser[],
+  pageId: string,
 }
 
 export interface BookmarkedPage {

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

@@ -67,7 +67,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')

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

+ 7 - 23
apps/app/src/server/crowi/index.js

@@ -19,11 +19,10 @@ 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 as configManagerSingletonInstance } from '../service/config-manager';
@@ -32,8 +31,6 @@ 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';
@@ -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(),
@@ -308,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));
@@ -394,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);
@@ -612,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;
 };
 
 /**
@@ -719,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() {

+ 1 - 0
apps/app/src/server/routes/apiv3/bookmarks.js

@@ -128,6 +128,7 @@ module.exports = (crowi) => {
       }
       responsesParams.sumOfBookmarks = bookmarks.length;
       responsesParams.bookmarkedUsers = users;
+      responsesParams.pageId = pageId;
     }
     catch (err) {
       logger.error('get-bookmark-document-failed', err);

+ 6 - 8
apps/app/src/server/routes/apiv3/customize-setting.js

@@ -1,10 +1,14 @@
 /* eslint-disable no-unused-vars */
 
 import { ErrorV3 } from '@growi/core';
+import express from 'express';
+import { body } from 'express-validator';
 import mongoose from 'mongoose';
+import multer from 'multer';
 
+import { GrowiPluginResourceType } from '~/features/growi-plugin/interfaces';
+import { GrowiPlugin } from '~/features/growi-plugin/models';
 import { SupportedAction } from '~/interfaces/activity';
-import { GrowiPluginResourceType } from '~/interfaces/plugin';
 import { AttachmentType } from '~/server/interfaces/attachment';
 import loggerFactory from '~/utils/logger';
 
@@ -14,13 +18,8 @@ import { apiV3FormValidator } from '../../middlewares/apiv3-form-validator';
 
 const logger = loggerFactory('growi:routes:apiv3:customize-setting');
 
-const express = require('express');
-
 const router = express.Router();
 
-const { body, query } = require('express-validator');
-const multer = require('multer');
-
 
 /**
  * @swagger
@@ -276,8 +275,7 @@ module.exports = (crowi) => {
       const currentTheme = await crowi.configManager.getConfig('crowi', 'customize:theme');
 
       // retrieve plugin manifests
-      const GrowiPluginModel = mongoose.model('GrowiPlugin');
-      const themePlugins = await GrowiPluginModel.findEnabledPluginsIncludingAnyTypes([GrowiPluginResourceType.Theme]);
+      const themePlugins = await GrowiPlugin.findEnabledPluginsIncludingAnyTypes([GrowiPluginResourceType.Theme]);
 
       const pluginThemesMetadatas = themePlugins
         .map(themePlugin => themePlugin.meta.themes)

+ 1 - 1
apps/app/src/server/routes/apiv3/index.js

@@ -108,7 +108,7 @@ module.exports = (crowi, app) => {
     userActivation.validateCompleteRegistration,
     userActivation.completeRegistrationAction(crowi));
 
-  router.use('/plugins', require('./plugins')(crowi));
+  router.use('/plugins', require('~/features/growi-plugin/routes/growi-plugins')(crowi));
 
   router.use('/user-ui-settings', require('./user-ui-settings')(crowi));
 

+ 0 - 221
apps/app/src/server/service/acl.integ.test.ts

@@ -1,221 +0,0 @@
-// import {
-//   vi,
-//   beforeAll, beforeEach, afterEach,
-//   describe, test, expect,
-// } from 'vitest';
-
-// import { aclService } from './acl';
-// import { configManager } from './config-manager';
-
-
-// describe('AclService', () => {
-//   test("has consts 'isLabeledStatement'", () => {
-//     expect(aclService.labels.SECURITY_RESTRICT_GUEST_MODE_DENY).toBe('Deny');
-//     expect(aclService.labels.SECURITY_RESTRICT_GUEST_MODE_READONLY).toBe('Readonly');
-//     expect(aclService.labels.SECURITY_REGISTRATION_MODE_OPEN).toBe('Open');
-//     expect(aclService.labels.SECURITY_REGISTRATION_MODE_RESTRICTED).toBe('Restricted');
-//     expect(aclService.labels.SECURITY_REGISTRATION_MODE_CLOSED).toBe('Closed');
-//   });
-// });
-
-// describe('AclService test', () => {
-
-//   const initialEnv = process.env;
-
-//   beforeAll(async() => {
-//     await configManager.loadConfigs();
-//   });
-
-//   afterEach(() => {
-//     process.env = initialEnv;
-//   });
-
-//   describe('isAclEnabled()', () => {
-
-//     test('to be false when FORCE_WIKI_MODE is undefined', async() => {
-//       delete process.env.FORCE_WIKI_MODE;
-
-//       // reload
-//       await configManager.loadConfigs();
-
-//       const result = aclService.isAclEnabled();
-
-//       const wikiMode = configManager.getConfig('crowi', 'security:wikiMode');
-//       expect(wikiMode).toBe(undefined);
-//       expect(result).toBe(true);
-//     });
-
-//     test('to be false when FORCE_WIKI_MODE is dummy string', async() => {
-//       process.env.FORCE_WIKI_MODE = 'dummy string';
-
-//       // reload
-//       await configManager.loadConfigs();
-
-//       const result = aclService.isAclEnabled();
-
-//       const wikiMode = configManager.getConfig('crowi', 'security:wikiMode');
-//       expect(wikiMode).toBe('dummy string');
-//       expect(result).toBe(true);
-//     });
-
-//     test('to be true when FORCE_WIKI_MODE=private', async() => {
-//       process.env.FORCE_WIKI_MODE = 'private';
-
-//       // reload
-//       await configManager.loadConfigs();
-
-//       const result = aclService.isAclEnabled();
-
-//       const wikiMode = configManager.getConfig('crowi', 'security:wikiMode');
-//       expect(wikiMode).toBe('private');
-//       expect(result).toBe(true);
-//     });
-
-//     test('to be false when FORCE_WIKI_MODE=public', async() => {
-//       process.env.FORCE_WIKI_MODE = 'public';
-
-//       // reload
-//       await configManager.loadConfigs();
-
-//       const result = aclService.isAclEnabled();
-
-//       const wikiMode = configManager.getConfig('crowi', 'security:wikiMode');
-//       expect(wikiMode).toBe('public');
-//       expect(result).toBe(false);
-//     });
-
-//   });
-
-
-//   describe('isWikiModeForced()', () => {
-
-//     test('to be false when FORCE_WIKI_MODE is undefined', async() => {
-//       delete process.env.FORCE_WIKI_MODE;
-
-//       // reload
-//       await configManager.loadConfigs();
-
-//       const result = aclService.isWikiModeForced();
-
-//       const wikiMode = configManager.getConfig('crowi', 'security:wikiMode');
-//       expect(wikiMode).toBe(undefined);
-//       expect(result).toBe(false);
-//     });
-
-//     test('to be false when FORCE_WIKI_MODE is dummy string', async() => {
-//       process.env.FORCE_WIKI_MODE = 'dummy string';
-
-//       // reload
-//       await configManager.loadConfigs();
-
-//       const result = aclService.isWikiModeForced();
-
-//       const wikiMode = configManager.getConfig('crowi', 'security:wikiMode');
-//       expect(wikiMode).toBe('dummy string');
-//       expect(result).toBe(false);
-//     });
-
-//     test('to be true when FORCE_WIKI_MODE=private', async() => {
-//       process.env.FORCE_WIKI_MODE = 'private';
-
-//       // reload
-//       await configManager.loadConfigs();
-
-//       const result = aclService.isWikiModeForced();
-
-//       const wikiMode = configManager.getConfig('crowi', 'security:wikiMode');
-//       expect(wikiMode).toBe('private');
-//       expect(result).toBe(true);
-//     });
-
-//     test('to be false when FORCE_WIKI_MODE=public', async() => {
-//       process.env.FORCE_WIKI_MODE = 'public';
-
-//       // reload
-//       await configManager.loadConfigs();
-
-//       const result = aclService.isWikiModeForced();
-
-//       const wikiMode = configManager.getConfig('crowi', 'security:wikiMode');
-//       expect(wikiMode).toBe('public');
-//       expect(result).toBe(true);
-//     });
-
-//   });
-
-
-//   describe('isGuestAllowedToRead()', () => {
-//     let getConfigSpy;
-
-//     beforeEach(async() => {
-//       // prepare spy for ConfigManager.getConfig
-//       getConfigSpy = vi.spyOn(configManager, 'getConfig');
-//     });
-
-//     test('to be false when FORCE_WIKI_MODE=private', async() => {
-//       process.env.FORCE_WIKI_MODE = 'private';
-
-//       // reload
-//       await configManager.loadConfigs();
-
-//       const result = aclService.isGuestAllowedToRead();
-
-//       const wikiMode = configManager.getConfig('crowi', 'security:wikiMode');
-//       expect(wikiMode).toBe('private');
-//       expect(getConfigSpy).not.toHaveBeenCalledWith('crowi', 'security:restrictGuestMode');
-//       expect(result).toBe(false);
-//     });
-
-//     test('to be true when FORCE_WIKI_MODE=public', async() => {
-//       process.env.FORCE_WIKI_MODE = 'public';
-
-//       // reload
-//       await configManager.loadConfigs();
-
-//       const result = aclService.isGuestAllowedToRead();
-
-//       const wikiMode = configManager.getConfig('crowi', 'security:wikiMode');
-//       expect(wikiMode).toBe('public');
-//       expect(getConfigSpy).not.toHaveBeenCalledWith('crowi', 'security:restrictGuestMode');
-//       expect(result).toBe(true);
-//     });
-
-//     /* eslint-disable indent */
-//     describe.each`
-//       restrictGuestMode   | expected
-//       ${undefined}        | ${false}
-//       ${'Deny'}           | ${false}
-//       ${'Readonly'}       | ${true}
-//       ${'Open'}           | ${false}
-//       ${'Restricted'}     | ${false}
-//       ${'closed'}         | ${false}
-//     `('to be $expected', ({ restrictGuestMode, expected }) => {
-//       test(`when FORCE_WIKI_MODE is undefined and 'security:restrictGuestMode' is '${restrictGuestMode}`, async() => {
-
-//         // reload
-//         await configManager.loadConfigs();
-
-//         // setup mock implementation
-//         getConfigSpy.mockImplementation((ns, key) => {
-//           if (ns === 'crowi' && key === 'security:restrictGuestMode') {
-//             return restrictGuestMode;
-//           }
-//           if (ns === 'crowi' && key === 'security:wikiMode') {
-//             return undefined;
-//           }
-//           throw new Error('Unexpected behavior.');
-//         });
-
-//         const result = aclService.isGuestAllowedToRead();
-
-//         expect(getConfigSpy).toHaveBeenCalledTimes(2);
-//         expect(getConfigSpy).toHaveBeenCalledWith('crowi', 'security:wikiMode');
-//         expect(getConfigSpy).toHaveBeenCalledWith('crowi', 'security:restrictGuestMode');
-//         expect(result).toBe(expected);
-//       });
-//     });
-
-//   });
-
-
-// });

+ 51 - 38
apps/app/test/integration/service/acl.test.js → apps/app/src/server/service/acl.integ.ts

@@ -1,15 +1,28 @@
-const { getInstance } = require('../setup-crowi');
+import { aclService } from './acl';
+import { configManager } from './config-manager';
+
+
+describe('AclService', () => {
+  test("has consts 'isLabeledStatement'", () => {
+    expect(aclService.labels.SECURITY_RESTRICT_GUEST_MODE_DENY).toBe('Deny');
+    expect(aclService.labels.SECURITY_RESTRICT_GUEST_MODE_READONLY).toBe('Readonly');
+    expect(aclService.labels.SECURITY_REGISTRATION_MODE_OPEN).toBe('Open');
+    expect(aclService.labels.SECURITY_REGISTRATION_MODE_RESTRICTED).toBe('Restricted');
+    expect(aclService.labels.SECURITY_REGISTRATION_MODE_CLOSED).toBe('Closed');
+  });
+});
 
 describe('AclService test', () => {
-  let crowi;
 
   const initialEnv = process.env;
 
-  beforeEach(async() => {
-    crowi = await getInstance();
-    process.env = initialEnv;
+  beforeAll(async() => {
+    await configManager.loadConfigs();
   });
 
+  afterEach(() => {
+    process.env = initialEnv;
+  });
 
   describe('isAclEnabled()', () => {
 
@@ -17,11 +30,11 @@ describe('AclService test', () => {
       delete process.env.FORCE_WIKI_MODE;
 
       // reload
-      await crowi.configManager.loadConfigs();
+      await configManager.loadConfigs();
 
-      const result = crowi.aclService.isAclEnabled();
+      const result = aclService.isAclEnabled();
 
-      const wikiMode = crowi.configManager.getConfig('crowi', 'security:wikiMode');
+      const wikiMode = configManager.getConfig('crowi', 'security:wikiMode');
       expect(wikiMode).toBe(undefined);
       expect(result).toBe(true);
     });
@@ -30,11 +43,11 @@ describe('AclService test', () => {
       process.env.FORCE_WIKI_MODE = 'dummy string';
 
       // reload
-      await crowi.configManager.loadConfigs();
+      await configManager.loadConfigs();
 
-      const result = crowi.aclService.isAclEnabled();
+      const result = aclService.isAclEnabled();
 
-      const wikiMode = crowi.configManager.getConfig('crowi', 'security:wikiMode');
+      const wikiMode = configManager.getConfig('crowi', 'security:wikiMode');
       expect(wikiMode).toBe('dummy string');
       expect(result).toBe(true);
     });
@@ -43,11 +56,11 @@ describe('AclService test', () => {
       process.env.FORCE_WIKI_MODE = 'private';
 
       // reload
-      await crowi.configManager.loadConfigs();
+      await configManager.loadConfigs();
 
-      const result = crowi.aclService.isAclEnabled();
+      const result = aclService.isAclEnabled();
 
-      const wikiMode = crowi.configManager.getConfig('crowi', 'security:wikiMode');
+      const wikiMode = configManager.getConfig('crowi', 'security:wikiMode');
       expect(wikiMode).toBe('private');
       expect(result).toBe(true);
     });
@@ -56,11 +69,11 @@ describe('AclService test', () => {
       process.env.FORCE_WIKI_MODE = 'public';
 
       // reload
-      await crowi.configManager.loadConfigs();
+      await configManager.loadConfigs();
 
-      const result = crowi.aclService.isAclEnabled();
+      const result = aclService.isAclEnabled();
 
-      const wikiMode = crowi.configManager.getConfig('crowi', 'security:wikiMode');
+      const wikiMode = configManager.getConfig('crowi', 'security:wikiMode');
       expect(wikiMode).toBe('public');
       expect(result).toBe(false);
     });
@@ -74,11 +87,11 @@ describe('AclService test', () => {
       delete process.env.FORCE_WIKI_MODE;
 
       // reload
-      await crowi.configManager.loadConfigs();
+      await configManager.loadConfigs();
 
-      const result = crowi.aclService.isWikiModeForced();
+      const result = aclService.isWikiModeForced();
 
-      const wikiMode = crowi.configManager.getConfig('crowi', 'security:wikiMode');
+      const wikiMode = configManager.getConfig('crowi', 'security:wikiMode');
       expect(wikiMode).toBe(undefined);
       expect(result).toBe(false);
     });
@@ -87,11 +100,11 @@ describe('AclService test', () => {
       process.env.FORCE_WIKI_MODE = 'dummy string';
 
       // reload
-      await crowi.configManager.loadConfigs();
+      await configManager.loadConfigs();
 
-      const result = crowi.aclService.isWikiModeForced();
+      const result = aclService.isWikiModeForced();
 
-      const wikiMode = crowi.configManager.getConfig('crowi', 'security:wikiMode');
+      const wikiMode = configManager.getConfig('crowi', 'security:wikiMode');
       expect(wikiMode).toBe('dummy string');
       expect(result).toBe(false);
     });
@@ -100,11 +113,11 @@ describe('AclService test', () => {
       process.env.FORCE_WIKI_MODE = 'private';
 
       // reload
-      await crowi.configManager.loadConfigs();
+      await configManager.loadConfigs();
 
-      const result = crowi.aclService.isWikiModeForced();
+      const result = aclService.isWikiModeForced();
 
-      const wikiMode = crowi.configManager.getConfig('crowi', 'security:wikiMode');
+      const wikiMode = configManager.getConfig('crowi', 'security:wikiMode');
       expect(wikiMode).toBe('private');
       expect(result).toBe(true);
     });
@@ -113,11 +126,11 @@ describe('AclService test', () => {
       process.env.FORCE_WIKI_MODE = 'public';
 
       // reload
-      await crowi.configManager.loadConfigs();
+      await configManager.loadConfigs();
 
-      const result = crowi.aclService.isWikiModeForced();
+      const result = aclService.isWikiModeForced();
 
-      const wikiMode = crowi.configManager.getConfig('crowi', 'security:wikiMode');
+      const wikiMode = configManager.getConfig('crowi', 'security:wikiMode');
       expect(wikiMode).toBe('public');
       expect(result).toBe(true);
     });
@@ -130,18 +143,18 @@ describe('AclService test', () => {
 
     beforeEach(async() => {
       // prepare spy for ConfigManager.getConfig
-      getConfigSpy = jest.spyOn(crowi.configManager, 'getConfig');
+      getConfigSpy = vi.spyOn(configManager, 'getConfig');
     });
 
     test('to be false when FORCE_WIKI_MODE=private', async() => {
       process.env.FORCE_WIKI_MODE = 'private';
 
       // reload
-      await crowi.configManager.loadConfigs();
+      await configManager.loadConfigs();
 
-      const result = crowi.aclService.isGuestAllowedToRead();
+      const result = aclService.isGuestAllowedToRead();
 
-      const wikiMode = crowi.configManager.getConfig('crowi', 'security:wikiMode');
+      const wikiMode = configManager.getConfig('crowi', 'security:wikiMode');
       expect(wikiMode).toBe('private');
       expect(getConfigSpy).not.toHaveBeenCalledWith('crowi', 'security:restrictGuestMode');
       expect(result).toBe(false);
@@ -151,11 +164,11 @@ describe('AclService test', () => {
       process.env.FORCE_WIKI_MODE = 'public';
 
       // reload
-      await crowi.configManager.loadConfigs();
+      await configManager.loadConfigs();
 
-      const result = crowi.aclService.isGuestAllowedToRead();
+      const result = aclService.isGuestAllowedToRead();
 
-      const wikiMode = crowi.configManager.getConfig('crowi', 'security:wikiMode');
+      const wikiMode = configManager.getConfig('crowi', 'security:wikiMode');
       expect(wikiMode).toBe('public');
       expect(getConfigSpy).not.toHaveBeenCalledWith('crowi', 'security:restrictGuestMode');
       expect(result).toBe(true);
@@ -174,7 +187,7 @@ describe('AclService test', () => {
       test(`when FORCE_WIKI_MODE is undefined and 'security:restrictGuestMode' is '${restrictGuestMode}`, async() => {
 
         // reload
-        await crowi.configManager.loadConfigs();
+        await configManager.loadConfigs();
 
         // setup mock implementation
         getConfigSpy.mockImplementation((ns, key) => {
@@ -187,7 +200,7 @@ describe('AclService test', () => {
           throw new Error('Unexpected behavior.');
         });
 
-        const result = crowi.aclService.isGuestAllowedToRead();
+        const result = aclService.isGuestAllowedToRead();
 
         expect(getConfigSpy).toHaveBeenCalledTimes(2);
         expect(getConfigSpy).toHaveBeenCalledWith('crowi', 'security:wikiMode');

+ 19 - 27
apps/app/src/server/service/acl.js → apps/app/src/server/service/acl.ts

@@ -1,16 +1,25 @@
 import loggerFactory from '~/utils/logger';
 
-// eslint-disable-next-line no-unused-vars
+import { configManager } from './config-manager';
+
+// eslint-disable-next-line @typescript-eslint/no-unused-vars
 const logger = loggerFactory('growi:service:AclService');
 
+export interface AclService {
+  get labels(): { [key: string]: string },
+  isAclEnabled(): boolean,
+  isWikiModeForced(): boolean,
+  isGuestAllowedToRead(): boolean,
+  getGuestModeValue(): string,
+}
+
 /**
  * the service class of AclService
  */
-class AclService {
+class AclServiceImpl implements AclService {
 
-  constructor(configManager) {
-    this.configManager = configManager;
-    this.labels = {
+  get labels() {
+    return {
       SECURITY_RESTRICT_GUEST_MODE_DENY: 'Deny',
       SECURITY_RESTRICT_GUEST_MODE_READONLY: 'Readonly',
       SECURITY_REGISTRATION_MODE_OPEN: 'Open',
@@ -23,7 +32,7 @@ class AclService {
    * @returns Whether Access Control is enabled or not
    */
   isAclEnabled() {
-    const wikiMode = this.configManager.getConfig('crowi', 'security:wikiMode');
+    const wikiMode = configManager.getConfig('crowi', 'security:wikiMode');
     return wikiMode !== 'public';
   }
 
@@ -31,7 +40,7 @@ class AclService {
    * @returns Whether wiki mode is set
    */
   isWikiModeForced() {
-    const wikiMode = this.configManager.getConfig('crowi', 'security:wikiMode');
+    const wikiMode = configManager.getConfig('crowi', 'security:wikiMode');
     const isPrivateOrPublic = wikiMode === 'private' || wikiMode === 'public';
 
     return isPrivateOrPublic;
@@ -41,7 +50,7 @@ class AclService {
    * @returns Whether guest users are allowed to read public pages
    */
   isGuestAllowedToRead() {
-    const wikiMode = this.configManager.getConfig('crowi', 'security:wikiMode');
+    const wikiMode = configManager.getConfig('crowi', 'security:wikiMode');
 
     // return false if private wiki mode
     if (wikiMode === 'private') {
@@ -52,7 +61,7 @@ class AclService {
       return true;
     }
 
-    const guestMode = this.configManager.getConfig('crowi', 'security:restrictGuestMode');
+    const guestMode = configManager.getConfig('crowi', 'security:restrictGuestMode');
 
     // 'Readonly' => returns true (allow access to guests)
     // 'Deny', null, undefined, '', ... everything else => returns false (requires login)
@@ -65,23 +74,6 @@ class AclService {
       : this.labels.SECURITY_RESTRICT_GUEST_MODE_DENY;
   }
 
-  getRestrictGuestModeLabels() {
-    const labels = {};
-    labels[this.labels.SECURITY_RESTRICT_GUEST_MODE_DENY] = 'security_settings.guest_mode.deny';
-    labels[this.labels.SECURITY_RESTRICT_GUEST_MODE_READONLY] = 'security_settings.guest_mode.readonly';
-
-    return labels;
-  }
-
-  getRegistrationModeLabels() {
-    const labels = {};
-    labels[this.labels.SECURITY_REGISTRATION_MODE_OPEN] = 'security_settings.registration_mode.open';
-    labels[this.labels.SECURITY_REGISTRATION_MODE_RESTRICTED] = 'security_settings.registration_mode.restricted';
-    labels[this.labels.SECURITY_REGISTRATION_MODE_CLOSED] = 'security_settings.registration_mode.closed';
-
-    return labels;
-  }
-
 }
 
-module.exports = AclService;
+export const aclService = new AclServiceImpl();

+ 2 - 5
apps/app/src/server/service/customize.ts

@@ -3,12 +3,12 @@ import { ColorScheme, DevidedPagePath, getForcedColorScheme } from '@growi/core'
 import { DefaultThemeMetadata, PresetThemesMetadatas } from '@growi/preset-themes';
 import uglifycss from 'uglifycss';
 
+import { growiPluginService } from '~/features/growi-plugin/services';
 import loggerFactory from '~/utils/logger';
 
 import S2sMessage from '../models/vo/s2s-message';
 
 import type { ConfigManager } from './config-manager';
-import type { IPluginService } from './plugin';
 import type { S2sMessageHandlable } from './s2s-messaging/handlable';
 
 
@@ -28,8 +28,6 @@ class CustomizeService implements S2sMessageHandlable {
 
   xssService: any;
 
-  pluginService: IPluginService;
-
   lastLoadedAt?: Date;
 
   customCss?: string;
@@ -47,7 +45,6 @@ class CustomizeService implements S2sMessageHandlable {
     this.s2sMessagingService = crowi.s2sMessagingService;
     this.appService = crowi.appService;
     this.xssService = crowi.xssService;
-    this.pluginService = crowi.pluginService;
   }
 
   /**
@@ -155,7 +152,7 @@ class CustomizeService implements S2sMessageHandlable {
 
     this.theme = theme;
 
-    const resultForThemePlugin = await this.pluginService.findThemePlugin(theme);
+    const resultForThemePlugin = await growiPluginService.findThemePlugin(theme);
 
     if (resultForThemePlugin != null) {
       this.forcedColorScheme = getForcedColorScheme(resultForThemePlugin.themeMetadata.schemeType);

+ 1 - 0
apps/app/src/stores/bookmark.ts

@@ -14,6 +14,7 @@ export const useSWRBookmarkInfo = (pageId: string | null | undefined): SWRRespon
         sumOfBookmarks: response.data.sumOfBookmarks,
         isBookmarked: response.data.isBookmarked,
         bookmarkedUsers: response.data.bookmarkedUsers,
+        pageId: response.data.pageId,
       };
     }),
   );

+ 9 - 5
apps/app/src/stores/modal.tsx

@@ -627,13 +627,17 @@ export const useBookmarkFolderDeleteModal = (status?: DeleteBookmarkFolderModalS
 /*
  * TemplateModal
  */
-type TemplateModalStatus = {
+
+type TemplateSelectedCallback = (templateText: string) => void;
+type TemplateModalOptions = {
+  onSubmit?: TemplateSelectedCallback,
+}
+type TemplateModalStatus = TemplateModalOptions & {
   isOpened: boolean,
-  onSubmit?: (templateText: string) => void
 }
 
 type TemplateModalUtils = {
-  open(onSubmit: (templateText: string) => void): void,
+  open(opts: TemplateModalOptions): void,
   close(): void,
 }
 
@@ -643,8 +647,8 @@ export const useTemplateModal = (): SWRResponse<TemplateModalStatus, Error> & Te
   const swrResponse = useStaticSWR<TemplateModalStatus, Error>('templateModal', undefined, { fallbackData: initialStatus });
 
   return Object.assign(swrResponse, {
-    open: (onSubmit: (templateText: string) => void) => {
-      swrResponse.mutate({ isOpened: true, onSubmit });
+    open: (opts: TemplateModalOptions) => {
+      swrResponse.mutate({ isOpened: true, onSubmit: opts.onSubmit });
     },
     close: () => {
       swrResponse.mutate({ isOpened: false });

+ 1 - 1
apps/app/src/stores/renderer.tsx

@@ -3,7 +3,7 @@ import { useCallback } from 'react';
 import type { HtmlElementNode } from 'rehype-toc';
 import useSWR, { type SWRResponse } from 'swr';
 
-import { getGrowiFacade } from '~/features/activate-plugin';
+import { getGrowiFacade } from '~/features/growi-plugin/utils/growi-facade-utils.client';
 import type { RendererOptions } from '~/interfaces/renderer-options';
 
 

+ 103 - 23
apps/app/src/stores/template.tsx

@@ -1,49 +1,129 @@
-import { ITemplate } from '@growi/core';
-import useSWR, { SWRResponse } from 'swr';
+import type { ITemplate } from '@growi/core';
+import useSWR, { type SWRResponse } from 'swr';
 
-import { getGrowiFacade } from '~/features/activate-plugin';
+import { getGrowiFacade } from '~/features/growi-plugin/utils/growi-facade-utils.client';
 
 const presetTemplates: ITemplate[] = [
   // preset 1
   {
     id: '__preset1__',
-    name: '[Preset] WESEEK Inner Wiki Style',
-    markdown: `# 関連ページ
+    name: '日報',
+    markdown: `# {{yyyy}}/{{MM}}/{{dd}} 日報
+
+## 今日の目標
+- 目標1
+    - 〇〇の完了
+- 目標2
+    - 〇〇を〇件達成
+
+
+## 内容
+- 10:00 ~ 10:20 今日のタスク確認
+- 10:20 ~ 11:00 全体会議
+
 
-$lsx()
+## 進捗
+- 目標1
+    - 完了
+- 目標2
+    - 〇〇件達成
 
-# `,
+
+## メモ
+- 改善できることの振り返り
+
+
+## 翌営業日の目標
+- 目標1
+    - 〇〇の完了
+- 目標2
+    - 〇〇を〇件達成
+`,
   },
 
   // preset 2
   {
     id: '__preset2__',
-    name: '[Preset] Qiita Style',
-    markdown: `# <会議体名>
+    name: '議事録',
+    markdown: `# {{{title}}}{{^title}}<会議名>{{/title}}
+
 ## 日時
-yyyy/mm/dd hh:mm〜hh:mm
+{{yyyy}}/{{MM}}/{{dd}} {{HH}}:{{mm}}〜hh:mm
 
-## 場所
 
-## 出席
+## 参加
 -
 
 ## 議題
-1. [議題1](#link)
+1.
 2.
-3.
 
-## 議事内容
-### <a name="link"></a>議題1
 
-## 決定事項
-- 決定事項1
+## 1.
+### 内容
+
+
+### 決定事項
+
+
+### Next Action
+
+
+## 2.
+### 内容
+
+
+### 決定事項
+
+
+### Next Action
+
 
-## アクション事項
-- [ ] アクション
+## 次回会議
+- 会議内容
+- 会議時間
+    - {{yyyy}}/{{MM}}/dd
+`,
+  },
+
+  // preset 3
+  {
+    id: '__preset3__',
+    name: '企画書',
+    markdown: `# {{{title}}}{{^title}}<企画タイトル>{{/title}}
+
+## 目的
+
+
+## 現状の課題
+
+
+## 概要
+#### 企画の内容
+
+#### スケジュール
+
+
+## 効果
+#### メリット
+
+#### 数値目標
+
+
+## 参考資料
+
+`,
+  },
+
+  // preset 4
+  {
+    id: '__preset4__',
+    name: '関連ページの一覧表示',
+    markdown: `# 関連ページ
 
-## 次回
-yyyy/mm/dd (予定、時間は追って連絡)`,
+## 子ページ一覧
+$lsx(depth=1)
+`,
   },
 ];
 
@@ -52,7 +132,7 @@ export const useTemplates = (): SWRResponse<ITemplate[], Error> => {
     'templates',
     () => [
       ...presetTemplates,
-      ...Object.values(getGrowiFacade().customTemplates ?? {}),
+      ...Object.values<ITemplate>(getGrowiFacade().customTemplates ?? {}),
     ],
     {
       fallbackData: presetTemplates,

+ 5 - 0
apps/app/test-with-vite/.eslintrc.js

@@ -0,0 +1,5 @@
+module.exports = {
+  extends: [
+    'plugin:vitest/recommended',
+  ],
+};

+ 26 - 0
apps/app/test-with-vite/setup/mongoms.ts

@@ -0,0 +1,26 @@
+import { MongoMemoryServer } from 'mongodb-memory-server';
+import mongoose from 'mongoose';
+
+import { mongoOptions } from '~/server/util/mongoose-utils';
+
+
+beforeAll(async() => {
+  // set debug flag
+  process.env.MONGOMS_DEBUG = process.env.VITE_MONGOMS_DEBUG;
+
+  // set version
+  const mongoServer = await MongoMemoryServer.create({
+    instance: {
+      dbName: 'growi_test',
+    },
+    binary: {
+      version: process.env.VITE_MONGOMS_VERSION,
+      downloadDir: 'node_modules/.cache/mongodb-binaries',
+    },
+  });
+  await mongoose.connect(mongoServer.getUri(), mongoOptions);
+});
+
+afterAll(async() => {
+  await mongoose.disconnect();
+});

+ 11 - 0
apps/app/test-with-vite/tsconfig.json

@@ -0,0 +1,11 @@
+{
+  "$schema": "http://json.schemastore.org/tsconfig",
+  "extends": "../../../tsconfig.base.json",
+  "compilerOptions": {
+    "baseUrl": "../",
+    "paths": {
+      "~/*": ["./src/*"],
+      "^/*": ["./*"],
+    }
+  }
+}

+ 0 - 19
apps/app/test/integration/models/config.test.js

@@ -1,19 +0,0 @@
-const { getInstance } = require('../setup-crowi');
-
-describe('Config model test', () => {
-  // eslint-disable-next-line no-unused-vars
-  let crowi;
-
-  beforeAll(async() => {
-    crowi = await getInstance();
-  });
-
-  describe('.CONSTANTS', () => {
-    test('AclService has constants', async() => {
-      expect(crowi.aclService.labels.SECURITY_REGISTRATION_MODE_OPEN).toBe('Open');
-      expect(crowi.aclService.labels.SECURITY_REGISTRATION_MODE_RESTRICTED).toBe('Restricted');
-      expect(crowi.aclService.labels.SECURITY_REGISTRATION_MODE_CLOSED).toBe('Closed');
-    });
-  });
-
-});

+ 1 - 1
apps/app/tsconfig.json

@@ -20,7 +20,7 @@
   "include": [
     "next-env.d.ts",
     "config",
-    "src"
+    "src",
   ],
   "ts-node": {
     "transpileOnly": true,

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

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

+ 5 - 3
apps/app/vitest.config.ts

@@ -1,13 +1,15 @@
 import tsconfigPaths from 'vite-tsconfig-paths';
-import { defineProject } from 'vitest/config';
+import { defineConfig } from 'vitest/config';
 
-export default defineProject({
+export default defineConfig({
   plugins: [
     tsconfigPaths(),
   ],
   test: {
     environment: 'node',
-    exclude: ['**/test/**'],
+    exclude: [
+      '**/test/**',
+    ],
     clearMocks: true,
     globals: true,
   },

+ 2 - 2
packages/core/vitest.config.ts

@@ -1,7 +1,7 @@
 import tsconfigPaths from 'vite-tsconfig-paths';
-import { defineProject } from 'vitest/config';
+import { defineConfig } from 'vitest/config';
 
-export default defineProject({
+export default defineConfig({
   plugins: [
     tsconfigPaths(),
   ],

+ 2 - 2
packages/slack/vitest.config.ts

@@ -1,7 +1,7 @@
 import tsconfigPaths from 'vite-tsconfig-paths';
-import { defineProject } from 'vitest/config';
+import { defineConfig } from 'vitest/config';
 
-export default defineProject({
+export default defineConfig({
   plugins: [
     tsconfigPaths(),
   ],

+ 76 - 4
yarn.lock

@@ -4802,6 +4802,13 @@ async-each@^1.0.0:
   resolved "https://registry.yarnpkg.com/async-each/-/async-each-1.0.3.tgz#b727dbf87d7651602f06f4d4ac387f47d91b0cbf"
   integrity sha512-z/WhQ5FPySLdvREByI2vZiTWwCnF0moMJ1hK9YQwDTHKh6I7/uSckMetoRGb5UBZPC1z0jlw+n/XCgjeH7y1AQ==
 
+async-mutex@^0.3.2:
+  version "0.3.2"
+  resolved "https://registry.yarnpkg.com/async-mutex/-/async-mutex-0.3.2.tgz#1485eda5bda1b0ec7c8df1ac2e815757ad1831df"
+  integrity sha512-HuTK7E7MT7jZEh1P9GtRW9+aTWiDWWi9InbZ5hjxrnRa39KS4BW04+xLBhYNS2aXhHUIKZSw3gj4Pn1pj+qGAA==
+  dependencies:
+    tslib "^2.3.1"
+
 async-retry@^1.3.1, async-retry@^1.3.3:
   version "1.3.3"
   resolved "https://registry.yarnpkg.com/async-retry/-/async-retry-1.3.3.tgz#0e7f36c04d8478e7a58bdbed80cedf977785f280"
@@ -5561,6 +5568,11 @@ camelcase@^6.2.0:
   resolved "https://registry.yarnpkg.com/camelcase/-/camelcase-6.2.0.tgz#924af881c9d525ac9d87f40d964e5cea982a1809"
   integrity sha512-c7wVvbw3f37nuobQNtgsgG9POC9qMbNuMQmTCqZv23b6MIz0fcYpBiOlv9gEN/hdLdnZTDQhg6e9Dq5M1vKvfg==
 
+camelcase@^6.3.0:
+  version "6.3.0"
+  resolved "https://registry.yarnpkg.com/camelcase/-/camelcase-6.3.0.tgz#5685b95eb209ac9c0c177467778c9c84df58ba9a"
+  integrity sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==
+
 can-use-dom@^0.1.0:
   version "0.1.0"
   resolved "https://registry.yarnpkg.com/can-use-dom/-/can-use-dom-0.1.0.tgz#22cc4a34a0abc43950f42c6411024a3f6366b45a"
@@ -8463,7 +8475,7 @@ finalhandler@~1.1.2:
     statuses "~1.5.0"
     unpipe "~1.0.0"
 
-find-cache-dir@^3.3.1:
+find-cache-dir@^3.3.1, find-cache-dir@^3.3.2:
   version "3.3.2"
   resolved "https://registry.yarnpkg.com/find-cache-dir/-/find-cache-dir-3.3.2.tgz#b30c5b6eff0730731aea9bbd9dbecbd80256d64b"
   integrity sha512-wXZV5emFEjrridIgED11OoUKLxiYjAcqot/NJdAkOhlJ+vGzwhOAfcG5OX1jP+S0PcjEn8bdMJv+g2jwQ3Onig==
@@ -8846,6 +8858,11 @@ get-pkg-repo@^4.0.0:
     meow "^7.0.0"
     through2 "^2.0.0"
 
+get-port@^5.1.1:
+  version "5.1.1"
+  resolved "https://registry.yarnpkg.com/get-port/-/get-port-5.1.1.tgz#0469ed07563479de6efb986baf053dcd7d4e3193"
+  integrity sha512-g/Q1aTSDOxFpchXC4i8ZWvxA1lnPqx/JHqcpIw0/LX9T8x/GBbi6YnlN5nhaKIFkT8oFsscUKgDJYxfwfS6QsQ==
+
 get-stdin@^4.0.1:
   version "4.0.1"
   resolved "https://registry.yarnpkg.com/get-stdin/-/get-stdin-4.0.1.tgz#b968c6b0a04384324902e8bf1a5df32579a450fe"
@@ -9698,6 +9715,14 @@ https-proxy-agent@^5.0.0:
     agent-base "6"
     debug "4"
 
+https-proxy-agent@^5.0.1:
+  version "5.0.1"
+  resolved "https://registry.yarnpkg.com/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz#c59ef224a04fe8b754f3db0063a25ea30d0005d6"
+  integrity sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==
+  dependencies:
+    agent-base "6"
+    debug "4"
+
 human-signals@^1.1.1:
   version "1.1.1"
   resolved "https://registry.yarnpkg.com/human-signals/-/human-signals-1.1.1.tgz#c5b1cd14f50aeae09ab6c59fe63ba3395fe4dfa3"
@@ -11819,6 +11844,11 @@ md5-file@4.0.0:
   resolved "https://registry.yarnpkg.com/md5-file/-/md5-file-4.0.0.tgz#f3f7ba1e2dd1144d5bf1de698d0e5f44a4409584"
   integrity sha512-UC0qFwyAjn4YdPpKaDNw6gNxRf7Mcx7jC1UGCY4boCzgvU2Aoc1mOGzTtrjjLKhM5ivsnhoKpQVxKPp+1j1qwg==
 
+md5-file@^5.0.0:
+  version "5.0.0"
+  resolved "https://registry.yarnpkg.com/md5-file/-/md5-file-5.0.0.tgz#e519f631feca9c39e7f9ea1780b63c4745012e20"
+  integrity sha512-xbEFXCYVWrSx/gEKS1VPlg84h/4L20znVIulKw6kMfmBUAZNAnF00eczz9ICMl+/hjQGo5KSXRxbL/47X3rmMw==
+
 md5-hex@^3.0.1:
   version "3.0.1"
   resolved "https://registry.yarnpkg.com/md5-hex/-/md5-hex-3.0.1.tgz#be3741b510591434b2784d79e556eefc2c9a8e5c"
@@ -12688,7 +12718,35 @@ mongodb-connection-string-url@^2.5.4:
     "@types/whatwg-url" "^8.2.1"
     whatwg-url "^11.0.0"
 
-mongodb@4.16.0:
+mongodb-memory-server-core@8.12.2:
+  version "8.12.2"
+  resolved "https://registry.yarnpkg.com/mongodb-memory-server-core/-/mongodb-memory-server-core-8.12.2.tgz#730cc4c01c457657e16e4e4a8a642b9ff8382fea"
+  integrity sha512-bls+lroejnhbZTOm5KHtxtf9PK6xASEAsCvZCPoXrNk1f10p0jDw7Xb4GUqVi0ZuVmuLZBNgmzYeHmb3WUgvLg==
+  dependencies:
+    async-mutex "^0.3.2"
+    camelcase "^6.3.0"
+    debug "^4.3.4"
+    find-cache-dir "^3.3.2"
+    get-port "^5.1.1"
+    https-proxy-agent "^5.0.1"
+    md5-file "^5.0.0"
+    mongodb "^4.13.0"
+    new-find-package-json "^2.0.0"
+    semver "^7.3.8"
+    tar-stream "^2.1.4"
+    tslib "^2.5.0"
+    uuid "^9.0.0"
+    yauzl "^2.10.0"
+
+mongodb-memory-server@^8.12.2:
+  version "8.12.2"
+  resolved "https://registry.yarnpkg.com/mongodb-memory-server/-/mongodb-memory-server-8.12.2.tgz#09d7f7fb7877d309c486f3e703390633bfd137f4"
+  integrity sha512-WM3uJnKWqhJxu3LlHfvRXRrhc+kiEGdGDHMrAG0N1E2fWbRlvSnUKau7Jdcf7cIA5HlRC/K8uVe0DCym45KfAA==
+  dependencies:
+    mongodb-memory-server-core "8.12.2"
+    tslib "^2.5.0"
+
+mongodb@4.16.0, mongodb@^4.13.0:
   version "4.16.0"
   resolved "https://registry.yarnpkg.com/mongodb/-/mongodb-4.16.0.tgz#8b0043de7b577c6a7e0ce44a2ca7315b9c0a7927"
   integrity sha512-0EB113Fsucaq1wsY0dOhi1fmZOwFtLOtteQkiqOXGklvWMnSH3g2QS53f0KTP+/6qOkuoXE2JksubSZNmxeI+g==
@@ -12837,7 +12895,7 @@ multer@^1.4.2, multer@~1.4.0:
     type-is "^1.6.4"
     xtend "^4.0.0"
 
-mustache@4.2.0:
+mustache@4.2.0, mustache@^4.2.0:
   version "4.2.0"
   resolved "https://registry.yarnpkg.com/mustache/-/mustache-4.2.0.tgz#e5892324d60a12ec9c2a73359edca52972bf6f64"
   integrity sha512-71ippSywq5Yb7/tVYyGbkBggbU8H3u5Rz56fH60jGFgr8uHwxs+aSKeqmluIVzM0m0kB7xQjKS6qPfd0b2ZoqQ==
@@ -12950,6 +13008,13 @@ nested-error-stacks@^2.0.0:
   resolved "https://registry.yarnpkg.com/nested-error-stacks/-/nested-error-stacks-2.1.0.tgz#0fbdcf3e13fe4994781280524f8b96b0cdff9c61"
   integrity sha512-AO81vsIO1k1sM4Zrd6Hu7regmJN1NSiAja10gc4bX3F0wd+9rQmcuHQaHVQCYIEC8iFXnE+mavh23GOt7wBgug==
 
+new-find-package-json@^2.0.0:
+  version "2.0.0"
+  resolved "https://registry.yarnpkg.com/new-find-package-json/-/new-find-package-json-2.0.0.tgz#96553638781db35061f351e8ccb4d07126b6407d"
+  integrity sha512-lDcBsjBSMlj3LXH2v/FW3txlh2pYTjmbOXPYJD93HI5EwuLzI11tdHSIpUMmfq/IOsldj4Ps8M8flhm+pCK4Ew==
+  dependencies:
+    debug "^4.3.4"
+
 next-i18next@^13.2.1:
   version "13.2.2"
   resolved "https://registry.yarnpkg.com/next-i18next/-/next-i18next-13.2.2.tgz#9609546fab1d1d5f9b227e86c5ca23d0cbbbddb4"
@@ -15531,6 +15596,13 @@ semver@>=7.3.2, semver@^7.3.2, semver@^7.3.4, semver@^7.3.5, semver@^7.3.7, semv
   dependencies:
     lru-cache "^6.0.0"
 
+semver@^7.3.8:
+  version "7.5.1"
+  resolved "https://registry.yarnpkg.com/semver/-/semver-7.5.1.tgz#c90c4d631cf74720e46b21c1d37ea07edfab91ec"
+  integrity sha512-Wvss5ivl8TMRZXXESstBA4uR5iXgEN/VC5/sOcuXdVLzcdkz4HWetIoRfG5gb5X+ij/G9rw9YoGn3QoQ8OCSpw==
+  dependencies:
+    lru-cache "^6.0.0"
+
 send@0.17.1:
   version "0.17.1"
   resolved "https://registry.yarnpkg.com/send/-/send-0.17.1.tgz#c1d8b059f7900f7466dd4938bdc44e11ddb376c8"
@@ -16675,7 +16747,7 @@ tape@^5.0.0:
     string.prototype.trim "^1.2.5"
     through "^2.3.8"
 
-tar-stream@^2.2.0:
+tar-stream@^2.1.4, tar-stream@^2.2.0:
   version "2.2.0"
   resolved "https://registry.yarnpkg.com/tar-stream/-/tar-stream-2.2.0.tgz#acad84c284136b060dc3faa64474aa9aebd77287"
   integrity sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==