Browse Source

Merge pull request #7803 from weseek/master

Release v6.1.5
Ryoji Shimizu 2 years ago
parent
commit
cfb7c4d6d4
100 changed files with 961 additions and 377 deletions
  1. 1 1
      .github/ISSUE_TEMPLATE/bug-report.md
  2. 2 2
      .github/workflows/release-slackbot-proxy.yml
  3. 5 3
      .github/workflows/release.yml
  4. 1 0
      apps/app/next.config.js
  5. 16 12
      apps/app/package.json
  6. 11 0
      apps/app/public/images/icons/editor/attachment.svg
  7. 3 1
      apps/app/public/static/locales/en_US/commons.json
  8. 6 0
      apps/app/public/static/locales/en_US/translation.json
  9. 6 1
      apps/app/public/static/locales/ja_JP/commons.json
  10. 6 0
      apps/app/public/static/locales/ja_JP/translation.json
  11. 6 1
      apps/app/public/static/locales/zh_CN/commons.json
  12. 6 0
      apps/app/public/static/locales/zh_CN/translation.json
  13. 0 0
      apps/app/resource/locales/en_US/admin/userInvitation.ejs
  14. 0 0
      apps/app/resource/locales/en_US/admin/userResetPassword.ejs
  15. 0 0
      apps/app/resource/locales/en_US/admin/userWaitingActivation.ejs
  16. 1 1
      apps/app/resource/locales/en_US/notifications/comment.ejs
  17. 0 0
      apps/app/resource/locales/en_US/notifications/notActiveUser.ejs
  18. 1 1
      apps/app/resource/locales/en_US/notifications/pageCreate.ejs
  19. 1 1
      apps/app/resource/locales/en_US/notifications/pageDelete.ejs
  20. 1 1
      apps/app/resource/locales/en_US/notifications/pageEdit.ejs
  21. 1 1
      apps/app/resource/locales/en_US/notifications/pageLike.ejs
  22. 1 1
      apps/app/resource/locales/en_US/notifications/pageMove.ejs
  23. 0 0
      apps/app/resource/locales/en_US/notifications/passwordReset.ejs
  24. 0 0
      apps/app/resource/locales/en_US/notifications/passwordResetSuccessful.ejs
  25. 0 0
      apps/app/resource/locales/en_US/notifications/userActivation.ejs
  26. 1 1
      apps/app/resource/locales/en_US/welcome.md
  27. 0 0
      apps/app/resource/locales/ja_JP/admin/userInvitation.ejs
  28. 0 0
      apps/app/resource/locales/ja_JP/admin/userResetPassword.ejs
  29. 0 0
      apps/app/resource/locales/ja_JP/admin/userWaitingActivation.ejs
  30. 9 0
      apps/app/resource/locales/ja_JP/notifications/comment.ejs
  31. 0 0
      apps/app/resource/locales/ja_JP/notifications/notActiveUser.ejs
  32. 5 0
      apps/app/resource/locales/ja_JP/notifications/pageCreate.ejs
  33. 0 0
      apps/app/resource/locales/ja_JP/notifications/pageCreate.txt
  34. 5 0
      apps/app/resource/locales/ja_JP/notifications/pageDelete.ejs
  35. 0 0
      apps/app/resource/locales/ja_JP/notifications/pageDelete.txt
  36. 5 0
      apps/app/resource/locales/ja_JP/notifications/pageEdit.ejs
  37. 0 0
      apps/app/resource/locales/ja_JP/notifications/pageEdit.txt
  38. 5 0
      apps/app/resource/locales/ja_JP/notifications/pageLike.ejs
  39. 0 0
      apps/app/resource/locales/ja_JP/notifications/pageLike.txt
  40. 5 0
      apps/app/resource/locales/ja_JP/notifications/pageMove.ejs
  41. 0 0
      apps/app/resource/locales/ja_JP/notifications/pageMove.txt
  42. 0 0
      apps/app/resource/locales/ja_JP/notifications/passwordReset.ejs
  43. 0 0
      apps/app/resource/locales/ja_JP/notifications/passwordResetSuccessful.ejs
  44. 0 0
      apps/app/resource/locales/ja_JP/notifications/userActivation.ejs
  45. 0 0
      apps/app/resource/locales/zh_CN/admin/userInvitation.ejs
  46. 0 0
      apps/app/resource/locales/zh_CN/admin/userResetPassword.ejs
  47. 0 0
      apps/app/resource/locales/zh_CN/admin/userWaitingActivation.ejs
  48. 1 1
      apps/app/resource/locales/zh_CN/notifications/comment.ejs
  49. 0 0
      apps/app/resource/locales/zh_CN/notifications/notActiveUser.ejs
  50. 1 1
      apps/app/resource/locales/zh_CN/notifications/pageCreate.ejs
  51. 1 1
      apps/app/resource/locales/zh_CN/notifications/pageDelete.ejs
  52. 1 1
      apps/app/resource/locales/zh_CN/notifications/pageEdit.ejs
  53. 1 1
      apps/app/resource/locales/zh_CN/notifications/pageLike.ejs
  54. 1 1
      apps/app/resource/locales/zh_CN/notifications/pageMove.ejs
  55. 0 0
      apps/app/resource/locales/zh_CN/notifications/passwordReset.ejs
  56. 0 0
      apps/app/resource/locales/zh_CN/notifications/passwordResetSuccessful.ejs
  57. 0 0
      apps/app/resource/locales/zh_CN/notifications/userActivation.ejs
  58. 11 1
      apps/app/src/client/services/renderer/renderer.tsx
  59. 15 11
      apps/app/src/components/Admin/UserGroup/UserGroupForm.tsx
  60. 9 2
      apps/app/src/components/Admin/UserGroupDetail/UserGroupDetailPage.tsx
  61. 103 10
      apps/app/src/components/Admin/Users/PasswordResetModal.jsx
  62. 2 0
      apps/app/src/components/Layout/BasicLayout.tsx
  63. 11 2
      apps/app/src/components/Me/BasicInfoSettings.tsx
  64. 3 1
      apps/app/src/components/Navbar/GrowiContextualSubNavigation.tsx
  65. 10 11
      apps/app/src/components/Page/TagEditModal.tsx
  66. 4 3
      apps/app/src/components/PageAlert/TrashPageAlert.tsx
  67. 7 57
      apps/app/src/components/PageAttachment.tsx
  68. 65 48
      apps/app/src/components/PageAttachment/DeleteAttachmentModal.tsx
  69. 1 1
      apps/app/src/components/PageAttachment/PageAttachmentList.tsx
  70. 31 31
      apps/app/src/components/PageEditor/HandsontableModal.tsx
  71. 0 17
      apps/app/src/components/ReactMarkdownComponents/NextLink.tsx
  72. 7 0
      apps/app/src/components/ReactMarkdownComponents/RichAttachment.module.scss
  73. 84 0
      apps/app/src/components/ReactMarkdownComponents/RichAttachment.tsx
  74. 1 1
      apps/app/src/components/ReactMarkdownComponents/TableWithEditButton.tsx
  75. 20 4
      apps/app/src/components/SearchPage.tsx
  76. 14 5
      apps/app/src/components/SearchPage/SearchPageBase.tsx
  77. 3 2
      apps/app/src/components/Sidebar/SidebarNav.tsx
  78. 197 56
      apps/app/src/components/TemplateModal/TemplateModal.tsx
  79. 9 15
      apps/app/src/components/TemplateModal/use-formatter.spec.tsx
  80. 5 8
      apps/app/src/components/TemplateModal/use-formatter.tsx
  81. 1 6
      apps/app/src/components/User/Username.tsx
  82. 0 0
      apps/app/src/features/growi-plugin/client/components/Admin/PluginsExtensionPageContents/PluginCard.module.scss
  83. 0 0
      apps/app/src/features/growi-plugin/client/components/Admin/PluginsExtensionPageContents/PluginCard.tsx
  84. 4 4
      apps/app/src/features/growi-plugin/client/components/Admin/PluginsExtensionPageContents/PluginInstallerForm.tsx
  85. 2 2
      apps/app/src/features/growi-plugin/client/components/Admin/PluginsExtensionPageContents/PluginsExtensionPageContents.tsx
  86. 0 0
      apps/app/src/features/growi-plugin/client/components/Admin/PluginsExtensionPageContents/index.ts
  87. 1 0
      apps/app/src/features/growi-plugin/client/components/Admin/index.ts
  88. 1 1
      apps/app/src/features/growi-plugin/client/components/GrowiPluginsActivator.tsx
  89. 1 0
      apps/app/src/features/growi-plugin/client/components/index.ts
  90. 24 0
      apps/app/src/features/growi-plugin/client/stores/admin-plugins.tsx
  91. 0 0
      apps/app/src/features/growi-plugin/client/utils/growi-facade-utils.ts
  92. 0 1
      apps/app/src/features/growi-plugin/components/index.ts
  93. 14 11
      apps/app/src/features/growi-plugin/interfaces/growi-plugin.ts
  94. 5 0
      apps/app/src/features/growi-plugin/server/consts/index.ts
  95. 157 0
      apps/app/src/features/growi-plugin/server/models/growi-plugin.integ.ts
  96. 17 28
      apps/app/src/features/growi-plugin/server/models/growi-plugin.ts
  97. 0 0
      apps/app/src/features/growi-plugin/server/models/index.ts
  98. 0 0
      apps/app/src/features/growi-plugin/server/models/vo/github-url.spec.ts
  99. 4 2
      apps/app/src/features/growi-plugin/server/models/vo/github-url.ts
  100. 2 2
      apps/app/src/features/growi-plugin/server/routes/apiv3/admin/index.ts

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

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

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

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

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

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

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

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

+ 16 - 12
apps/app/package.json

@@ -1,6 +1,6 @@
 {
 {
   "name": "@growi/app",
   "name": "@growi/app",
-  "version": "6.1.4",
+  "version": "6.1.5-RC.0",
   "license": "MIT",
   "license": "MIT",
   "scripts": {
   "scripts": {
     "//// for production": "",
     "//// for production": "",
@@ -46,7 +46,8 @@
     "openapi:v3": "yarn cross-env API_VERSION=3 yarn swagger-jsdoc -- \"src/server/routes/apiv3/**/*.js\" \"src/server/models/**/*.js\"",
     "openapi:v3": "yarn cross-env API_VERSION=3 yarn swagger-jsdoc -- \"src/server/routes/apiv3/**/*.js\" \"src/server/models/**/*.js\"",
     "openapi:v1": "yarn cross-env API_VERSION=1 yarn swagger-jsdoc -- \"src/server/*/*.js\" \"src/server/models/**/*.js\"",
     "openapi:v1": "yarn cross-env API_VERSION=1 yarn swagger-jsdoc -- \"src/server/*/*.js\" \"src/server/models/**/*.js\"",
     "ts-node": "node -r ts-node/register -r tsconfig-paths/register -r dotenv-flow/config",
     "ts-node": "node -r ts-node/register -r tsconfig-paths/register -r dotenv-flow/config",
-    "ts-node-dev": "ts-node-dev -r tsconfig-paths/register -r dotenv-flow/config"
+    "ts-node-dev": "ts-node-dev -r tsconfig-paths/register -r dotenv-flow/config",
+    "version": "yarn version --no-git-tag-version --preid=RC"
   },
   },
   "// comments for dependencies": {
   "// comments for dependencies": {
     "escape-string-regexp": "5.0.0 or above exports only ESM",
     "escape-string-regexp": "5.0.0 or above exports only ESM",
@@ -63,14 +64,16 @@
     "@elastic/elasticsearch8": "npm:@elastic/elasticsearch@^8.7.0",
     "@elastic/elasticsearch8": "npm:@elastic/elasticsearch@^8.7.0",
     "@godaddy/terminus": "^4.9.0",
     "@godaddy/terminus": "^4.9.0",
     "@google-cloud/storage": "^5.8.5",
     "@google-cloud/storage": "^5.8.5",
-    "@growi/core": "^6.1.4",
-    "@growi/hackmd": "^6.1.4",
-    "@growi/preset-themes": "^6.1.4",
-    "@growi/remark-attachment-refs": "^6.1.4",
-    "@growi/remark-drawio": "^6.1.4",
-    "@growi/remark-growi-directive": "^6.1.4",
-    "@growi/remark-lsx": "^6.1.4",
-    "@growi/slack": "^6.1.4",
+    "@growi/core": "link:../../packages/core",
+    "@growi/hackmd": "link:../../packages/hackmd",
+    "@growi/pluginkit": "link:../../packages/pluginkit",
+    "@growi/preset-templates": "link:../../packages/preset-templates",
+    "@growi/preset-themes": "link:../../packages/preset-themes",
+    "@growi/remark-attachment-refs": "link:../../packages/remark-attachment-refs",
+    "@growi/remark-drawio": "link:../../packages/remark-drawio",
+    "@growi/remark-growi-directive": "link:../../packages/remark-growi-directive",
+    "@growi/remark-lsx": "link:../../packages/remark-lsx",
+    "@growi/slack": "link:../../packages/slack",
     "@promster/express": "^7.0.6",
     "@promster/express": "^7.0.6",
     "@promster/server": "^7.0.8",
     "@promster/server": "^7.0.8",
     "@slack/web-api": "^6.2.4",
     "@slack/web-api": "^6.2.4",
@@ -185,6 +188,7 @@
     "remark-math": "^5.1.1",
     "remark-math": "^5.1.1",
     "remark-toc": "^8.0.1",
     "remark-toc": "^8.0.1",
     "remark-wiki-link": "^1.0.4",
     "remark-wiki-link": "^1.0.4",
+    "sanitize-filename": "^1.6.3",
     "socket.io": "^4.2.0",
     "socket.io": "^4.2.0",
     "stream-to-promise": "^3.0.0",
     "stream-to-promise": "^3.0.0",
     "string-width": "=4.2.2",
     "string-width": "=4.2.2",
@@ -208,8 +212,8 @@
     "handsontable": "v7.0.0 or above is no loger MIT lisence."
     "handsontable": "v7.0.0 or above is no loger MIT lisence."
   },
   },
   "devDependencies": {
   "devDependencies": {
-    "@growi/presentation": "^6.1.4",
-    "@growi/ui": "^6.1.4",
+    "@growi/presentation": "link:../../packages/presentation",
+    "@growi/ui": "link:../../packages/ui",
     "@handsontable/react": "=2.1.0",
     "@handsontable/react": "=2.1.0",
     "@icon/themify-icons": "1.0.1-alpha.3",
     "@icon/themify-icons": "1.0.1-alpha.3",
     "@next/bundle-analyzer": "^13.2.3",
     "@next/bundle-analyzer": "^13.2.3",

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

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

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

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

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

@@ -820,5 +820,11 @@
     "tags_input": {
     "tags_input": {
       "tag_name": "tag name"
       "tag_name": "tag name"
     }
     }
+  },
+  "delete_attachment_modal": {
+    "confirm_delete_attachment": "Delete attachment?"
+  },
+  "rich_attachment": {
+    "attachment_not_be_found": "The attachment could not be found"
   }
   }
 }
 }

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

@@ -6,6 +6,10 @@
   "Reset": "リセット",
   "Reset": "リセット",
   "Sign out": "ログアウト",
   "Sign out": "ログアウト",
   "New": "作成",
   "New": "作成",
+  "Send": "送信",
+  "Close": "閉じる",
+  "Done": "完了",
+  "Delete": "削除",
   "meta": {
   "meta": {
     "display_name": "日本語"
     "display_name": "日本語"
   },
   },
@@ -22,7 +26,8 @@
   "alert": {
   "alert": {
     "siteUrl_is_not_set": "'サイトURL' が設定されていません。{{link}} から設定してください。",
     "siteUrl_is_not_set": "'サイトURL' が設定されていません。{{link}} から設定してください。",
     "please_enable_mailer": "メール認証を有効にするには、メール設定を完了させてください。",
     "please_enable_mailer": "メール認証を有効にするには、メール設定を完了させてください。",
-    "password_reset_please_enable_mailer": "パスワード再設定を有効にするには、メール設定を完了させてください。"
+    "password_reset_please_enable_mailer": "パスワード再設定を有効にするには、メール設定を完了させてください。",
+    "email_is_already_in_use": "そのメールアドレスは既に使用されています。"
   },
   },
   "headers": {
   "headers": {
     "app_settings": "アプリ設定"
     "app_settings": "アプリ設定"

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

@@ -853,5 +853,11 @@
     "tags_input": {
     "tags_input": {
       "tag_name": "タグ名"
       "tag_name": "タグ名"
     }
     }
+  },
+  "delete_attachment_modal": {
+    "confirm_delete_attachment": "アタッチメントを削除しますか?"
+  },
+  "rich_attachment": {
+    "attachment_not_be_found": "アタッチメントが見つかりません"
   }
   }
 }
 }

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

@@ -6,6 +6,10 @@
   "Reset": "重启",
   "Reset": "重启",
 	"Sign out": "退出",
 	"Sign out": "退出",
   "New": "新建",
   "New": "新建",
+  "Send": "发送",
+  "Close": "关闭",
+  "Done": "完成",
+  "Delete": "删除",
 
 
   "meta": {
   "meta": {
     "display_name": "简体中文"
     "display_name": "简体中文"
@@ -23,7 +27,8 @@
   "alert": {
   "alert": {
     "siteUrl_is_not_set": "主页URL未设置,通过 {{link}} 设置",
     "siteUrl_is_not_set": "主页URL未设置,通过 {{link}} 设置",
     "please_enable_mailer": "请先设置邮件程序。",
     "please_enable_mailer": "请先设置邮件程序。",
-    "password_reset_please_enable_mailer": "请先设置邮件程序。"
+    "password_reset_please_enable_mailer": "请先设置邮件程序。",
+    "email_is_already_in_use": "这个电子邮件地址已经在使用了。"
   },
   },
   "headers": {
   "headers": {
     "app_settings": "系统设置"
     "app_settings": "系统设置"

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

@@ -823,5 +823,11 @@
     "tags_input": {
     "tags_input": {
       "tag_name": "标签名称"
       "tag_name": "标签名称"
     }
     }
+  },
+  "delete_attachment_modal": {
+    "confirm_delete_attachment": "你想删除一个附件吗?"
+  },
+  "rich_attachment": {
+    "attachment_not_be_found": "没有找到附件"
   }
   }
 }
 }

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


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


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


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

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

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


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

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

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

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

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

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

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

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

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

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

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


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


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


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

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

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


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


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


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

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

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


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

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

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


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

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

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


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

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

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


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

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

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


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

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

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


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


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


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


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


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


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


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

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

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


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

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

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

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

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

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

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

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

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

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

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


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


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


+ 11 - 1
apps/app/src/client/services/renderer/renderer.tsx

@@ -14,9 +14,9 @@ import math from 'remark-math';
 import deepmerge from 'ts-deepmerge';
 import deepmerge from 'ts-deepmerge';
 import type { Pluggable } from 'unified';
 import type { Pluggable } from 'unified';
 
 
-
 import { DrawioViewerWithEditButton } from '~/components/ReactMarkdownComponents/DrawioViewerWithEditButton';
 import { DrawioViewerWithEditButton } from '~/components/ReactMarkdownComponents/DrawioViewerWithEditButton';
 import { Header } from '~/components/ReactMarkdownComponents/Header';
 import { Header } from '~/components/ReactMarkdownComponents/Header';
+import { RichAttachment } from '~/components/ReactMarkdownComponents/RichAttachment';
 import { TableWithEditButton } from '~/components/ReactMarkdownComponents/TableWithEditButton';
 import { TableWithEditButton } from '~/components/ReactMarkdownComponents/TableWithEditButton';
 import * as mermaid from '~/features/mermaid';
 import * as mermaid from '~/features/mermaid';
 import { RehypeSanitizeOption } from '~/interfaces/rehype';
 import { RehypeSanitizeOption } from '~/interfaces/rehype';
@@ -25,6 +25,7 @@ import type { RendererConfig } from '~/interfaces/services/renderer';
 import * as addLineNumberAttribute from '~/services/renderer/rehype-plugins/add-line-number-attribute';
 import * as addLineNumberAttribute from '~/services/renderer/rehype-plugins/add-line-number-attribute';
 import * as keywordHighlighter from '~/services/renderer/rehype-plugins/keyword-highlighter';
 import * as keywordHighlighter from '~/services/renderer/rehype-plugins/keyword-highlighter';
 import * as relocateToc from '~/services/renderer/rehype-plugins/relocate-toc';
 import * as relocateToc from '~/services/renderer/rehype-plugins/relocate-toc';
+import * as attachment from '~/services/renderer/remark-plugins/attachment';
 import * as plantuml from '~/services/renderer/remark-plugins/plantuml';
 import * as plantuml from '~/services/renderer/remark-plugins/plantuml';
 import * as xsvToTable from '~/services/renderer/remark-plugins/xsv-to-table';
 import * as xsvToTable from '~/services/renderer/remark-plugins/xsv-to-table';
 import {
 import {
@@ -61,6 +62,7 @@ export const generateViewOptions = (
     drawio.remarkPlugin,
     drawio.remarkPlugin,
     mermaid.remarkPlugin,
     mermaid.remarkPlugin,
     xsvToTable.remarkPlugin,
     xsvToTable.remarkPlugin,
+    attachment.remarkPlugin,
     lsxGrowiDirective.remarkPlugin,
     lsxGrowiDirective.remarkPlugin,
     refsGrowiDirective.remarkPlugin,
     refsGrowiDirective.remarkPlugin,
   );
   );
@@ -77,6 +79,7 @@ export const generateViewOptions = (
       commonSanitizeOption,
       commonSanitizeOption,
       drawio.sanitizeOption,
       drawio.sanitizeOption,
       mermaid.sanitizeOption,
       mermaid.sanitizeOption,
+      attachment.sanitizeOption,
       lsxGrowiDirective.sanitizeOption,
       lsxGrowiDirective.sanitizeOption,
       refsGrowiDirective.sanitizeOption,
       refsGrowiDirective.sanitizeOption,
     )]
     )]
@@ -109,6 +112,7 @@ export const generateViewOptions = (
     components.drawio = DrawioViewerWithEditButton;
     components.drawio = DrawioViewerWithEditButton;
     components.table = TableWithEditButton;
     components.table = TableWithEditButton;
     components.mermaid = mermaid.MermaidViewer;
     components.mermaid = mermaid.MermaidViewer;
+    components.attachment = RichAttachment;
   }
   }
 
 
   if (config.isEnabledXssPrevention) {
   if (config.isEnabledXssPrevention) {
@@ -167,6 +171,7 @@ export const generateSimpleViewOptions = (
     drawio.remarkPlugin,
     drawio.remarkPlugin,
     mermaid.remarkPlugin,
     mermaid.remarkPlugin,
     xsvToTable.remarkPlugin,
     xsvToTable.remarkPlugin,
+    attachment.remarkPlugin,
     lsxGrowiDirective.remarkPlugin,
     lsxGrowiDirective.remarkPlugin,
     refsGrowiDirective.remarkPlugin,
     refsGrowiDirective.remarkPlugin,
   );
   );
@@ -187,6 +192,7 @@ export const generateSimpleViewOptions = (
       commonSanitizeOption,
       commonSanitizeOption,
       drawio.sanitizeOption,
       drawio.sanitizeOption,
       mermaid.sanitizeOption,
       mermaid.sanitizeOption,
+      attachment.sanitizeOption,
       lsxGrowiDirective.sanitizeOption,
       lsxGrowiDirective.sanitizeOption,
       refsGrowiDirective.sanitizeOption,
       refsGrowiDirective.sanitizeOption,
     )]
     )]
@@ -211,6 +217,7 @@ export const generateSimpleViewOptions = (
     components.gallery = refsGrowiDirective.GalleryImmutable;
     components.gallery = refsGrowiDirective.GalleryImmutable;
     components.drawio = drawio.DrawioViewer;
     components.drawio = drawio.DrawioViewer;
     components.mermaid = mermaid.MermaidViewer;
     components.mermaid = mermaid.MermaidViewer;
+    components.attachment = RichAttachment;
   }
   }
 
 
   if (config.isEnabledXssPrevention) {
   if (config.isEnabledXssPrevention) {
@@ -244,6 +251,7 @@ export const generatePreviewOptions = (config: RendererConfig, pagePath: string)
     drawio.remarkPlugin,
     drawio.remarkPlugin,
     mermaid.remarkPlugin,
     mermaid.remarkPlugin,
     xsvToTable.remarkPlugin,
     xsvToTable.remarkPlugin,
+    attachment.remarkPlugin,
     lsxGrowiDirective.remarkPlugin,
     lsxGrowiDirective.remarkPlugin,
     refsGrowiDirective.remarkPlugin,
     refsGrowiDirective.remarkPlugin,
   );
   );
@@ -260,6 +268,7 @@ export const generatePreviewOptions = (config: RendererConfig, pagePath: string)
       commonSanitizeOption,
       commonSanitizeOption,
       drawio.sanitizeOption,
       drawio.sanitizeOption,
       mermaid.sanitizeOption,
       mermaid.sanitizeOption,
+      attachment.sanitizeOption,
       lsxGrowiDirective.sanitizeOption,
       lsxGrowiDirective.sanitizeOption,
       refsGrowiDirective.sanitizeOption,
       refsGrowiDirective.sanitizeOption,
       addLineNumberAttribute.sanitizeOption,
       addLineNumberAttribute.sanitizeOption,
@@ -285,6 +294,7 @@ export const generatePreviewOptions = (config: RendererConfig, pagePath: string)
     components.gallery = refsGrowiDirective.GalleryImmutable;
     components.gallery = refsGrowiDirective.GalleryImmutable;
     components.drawio = drawio.DrawioViewer;
     components.drawio = drawio.DrawioViewer;
     components.mermaid = mermaid.MermaidViewer;
     components.mermaid = mermaid.MermaidViewer;
+    components.attachment = RichAttachment;
   }
   }
 
 
   if (config.isEnabledXssPrevention) {
   if (config.isEnabledXssPrevention) {

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

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

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

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

+ 103 - 10
apps/app/src/components/Admin/Users/PasswordResetModal.jsx

@@ -2,13 +2,15 @@ import React from 'react';
 
 
 import { useTranslation } from 'next-i18next';
 import { useTranslation } from 'next-i18next';
 import PropTypes from 'prop-types';
 import PropTypes from 'prop-types';
+import { CopyToClipboard } from 'react-copy-to-clipboard';
 import {
 import {
-  Modal, ModalHeader, ModalBody, ModalFooter,
+  Modal, ModalHeader, ModalBody, ModalFooter, Tooltip,
 } from 'reactstrap';
 } from 'reactstrap';
 
 
+import AdminUsersContainer from '~/client/services/AdminUsersContainer';
 import { apiv3Put } from '~/client/util/apiv3-client';
 import { apiv3Put } from '~/client/util/apiv3-client';
 import { toastError } from '~/client/util/toastr';
 import { toastError } from '~/client/util/toastr';
-
+import { useIsMailerSetup } from '~/stores/context';
 
 
 class PasswordResetModal extends React.Component {
 class PasswordResetModal extends React.Component {
 
 
@@ -16,11 +18,15 @@ class PasswordResetModal extends React.Component {
     super(props);
     super(props);
 
 
     this.state = {
     this.state = {
-      temporaryPassword: [],
+      temporaryPassword: '',
       isPasswordResetDone: false,
       isPasswordResetDone: false,
+      isEmailSent: false,
+      isEmailSending: false,
+      showTooltip: false,
     };
     };
 
 
     this.resetPassword = this.resetPassword.bind(this);
     this.resetPassword = this.resetPassword.bind(this);
+    this.onClickSendNewPasswordButton = this.onClickSendNewPasswordButton.bind(this);
   }
   }
 
 
   async resetPassword() {
   async resetPassword() {
@@ -35,6 +41,44 @@ class PasswordResetModal extends React.Component {
     }
     }
   }
   }
 
 
+  renderButtons() {
+    const { t, isMailerSetup } = this.props;
+    const { isEmailSent, isEmailSending } = this.state;
+
+    return (
+      <>
+        <button type="submit" className={`btn ${isEmailSent ? 'btn-secondary' : 'btn-primary'}`}
+          onClick={this.onClickSendNewPasswordButton} disabled={!isMailerSetup || isEmailSending || isEmailSent}>
+          {isEmailSending && <i className='fa fa-spinner fa-pulse mx-2' />}
+          {!isEmailSending && (isEmailSent ? t('commons:Done') : t('commons:Send'))}
+        </button>
+        <button type="submit" className="btn btn-danger" onClick={this.props.onClose}>
+          {t('commons:Close')}
+        </button>
+      </>
+    );
+  }
+
+  renderAddress() {
+    const { t, isMailerSetup, userForPasswordResetModal } = this.props;
+
+    return (
+      <div className="d-flex col text-left ml-1 pl-0">
+        {!isMailerSetup ? (
+          <label className="form-text text-muted" dangerouslySetInnerHTML={{ __html: t('admin:mailer_setup_required') }} />
+        ) : (
+          <>
+            <p className="mr-2">To:</p>
+            <div>
+              <p className="mb-0">{userForPasswordResetModal.username}</p>
+              <p className="mb-0">{userForPasswordResetModal.email}</p>
+            </div>
+          </>
+        )}
+      </div>
+    );
+  }
+
   renderModalBodyBeforeReset() {
   renderModalBodyBeforeReset() {
     const { t, userForPasswordResetModal } = this.props;
     const { t, userForPasswordResetModal } = this.props;
 
 
@@ -53,6 +97,11 @@ class PasswordResetModal extends React.Component {
 
 
   returnModalBodyAfterReset() {
   returnModalBodyAfterReset() {
     const { t, userForPasswordResetModal } = this.props;
     const { t, userForPasswordResetModal } = this.props;
+    const { temporaryPassword, showPassword, showTooltip } = this.state;
+
+    const maskedPassword = showPassword
+      ? temporaryPassword
+      : '•'.repeat(temporaryPassword.length);
 
 
     return (
     return (
       <>
       <>
@@ -61,7 +110,28 @@ class PasswordResetModal extends React.Component {
           {t('user_management.reset_password_modal.target_user')}: <code>{userForPasswordResetModal.email}</code>
           {t('user_management.reset_password_modal.target_user')}: <code>{userForPasswordResetModal.email}</code>
         </p>
         </p>
         <p>
         <p>
-          {t('user_management.reset_password_modal.new_password')}: <code>{this.state.temporaryPassword}</code>
+          {t('user_management.reset_password_modal.new_password')}:{' '}
+          <code>
+            <span
+              onMouseEnter={() => this.setState({ showPassword: true })}
+              onMouseLeave={() => this.setState({ showPassword: false })}
+            >
+              {showPassword ? temporaryPassword : maskedPassword}
+            </span>
+          </code>
+          <CopyToClipboard text={ temporaryPassword } onCopy={() => this.setState({ showTooltip: true })}>
+            <button id="copy-tooltip" type="button" className="btn btn-outline-secondary border-0">
+              <i className="fa fa-clone" aria-hidden="true"></i>
+            </button>
+          </CopyToClipboard>
+          <Tooltip
+            placement="right"
+            isOpen={showTooltip}
+            target="copy-tooltip"
+            toggle={() => this.setState({ showTooltip: false })}
+          >
+            {t('Copied!')}
+          </Tooltip>
         </p>
         </p>
       </>
       </>
     );
     );
@@ -77,15 +147,35 @@ class PasswordResetModal extends React.Component {
   }
   }
 
 
   returnModalFooterAfterReset() {
   returnModalFooterAfterReset() {
-    const { t } = this.props;
-
     return (
     return (
-      <button type="submit" className="btn btn-primary" onClick={this.props.onClose}>
-        {t('Close')}
-      </button>
+      <>
+        {this.renderAddress()}
+        {this.renderButtons()}
+      </>
     );
     );
   }
   }
 
 
+  async onClickSendNewPasswordButton() {
+
+    const {
+      userForPasswordResetModal,
+    } = this.props;
+
+    this.setState({ isEmailSending: true });
+
+    try {
+      await apiv3Put('/users/reset-password-email', { id: userForPasswordResetModal._id, newPassword: this.state.temporaryPassword });
+      this.setState({ isEmailSent: true });
+    }
+    catch (err) {
+      this.setState({ isEmailSent: false });
+      toastError(err);
+    }
+    finally {
+      this.setState({ isEmailSending: false });
+    }
+  }
+
 
 
   render() {
   render() {
     const { t } = this.props;
     const { t } = this.props;
@@ -109,7 +199,8 @@ class PasswordResetModal extends React.Component {
 
 
 const PasswordResetModalWrapperFC = (props) => {
 const PasswordResetModalWrapperFC = (props) => {
   const { t } = useTranslation('admin');
   const { t } = useTranslation('admin');
-  return <PasswordResetModal t={t} {...props} />;
+  const { data: isMailerSetup } = useIsMailerSetup();
+  return <PasswordResetModal t={t} isMailerSetup={isMailerSetup ?? false} {...props} />;
 };
 };
 
 
 /**
 /**
@@ -122,6 +213,8 @@ PasswordResetModal.propTypes = {
   isOpen: PropTypes.bool.isRequired,
   isOpen: PropTypes.bool.isRequired,
   onClose: PropTypes.func.isRequired,
   onClose: PropTypes.func.isRequired,
   userForPasswordResetModal: PropTypes.object,
   userForPasswordResetModal: PropTypes.object,
+  onSuccessfullySentNewPasswordEmail: PropTypes.func.isRequired,
+  adminUsersContainer: PropTypes.instanceOf(AdminUsersContainer).isRequired,
 
 
 };
 };
 
 

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

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

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

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

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

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

+ 10 - 11
apps/app/src/components/Page/TagEditModal.jsx → apps/app/src/components/Page/TagEditModal.tsx

@@ -1,18 +1,24 @@
 import React, { useState, useEffect } from 'react';
 import React, { useState, useEffect } from 'react';
 
 
 import { useTranslation } from 'next-i18next';
 import { useTranslation } from 'next-i18next';
-import PropTypes from 'prop-types';
 import {
 import {
   Modal, ModalHeader, ModalBody, ModalFooter,
   Modal, ModalHeader, ModalBody, ModalFooter,
 } from 'reactstrap';
 } from 'reactstrap';
 
 
 import TagsInput from './TagsInput';
 import TagsInput from './TagsInput';
 
 
-function TagEditModal(props) {
-  const [tags, setTags] = useState([]);
+type Props = {
+  tags: string[],
+  isOpen: boolean,
+  onClose?: () => void,
+  onTagsUpdated?: (tags: string[]) => Promise<void> | void,
+};
+
+function TagEditModal(props: Props): JSX.Element {
+  const [tags, setTags] = useState<string[]>([]);
   const { t } = useTranslation();
   const { t } = useTranslation();
 
 
-  function onTagsUpdatedByTagsInput(tags) {
+  function onTagsUpdatedByTagsInput(tags: string[]) {
     setTags(tags);
     setTags(tags);
   }
   }
 
 
@@ -54,11 +60,4 @@ function TagEditModal(props) {
 
 
 }
 }
 
 
-TagEditModal.propTypes = {
-  tags: PropTypes.array,
-  isOpen: PropTypes.bool.isRequired,
-  onClose: PropTypes.func,
-  onTagsUpdated: PropTypes.func,
-};
-
 export default TagEditModal;
 export default TagEditModal;

+ 4 - 3
apps/app/src/components/PageAlert/TrashPageAlert.tsx

@@ -9,7 +9,7 @@ import { unlink } from '~/client/services/page-operation';
 import { toastError } from '~/client/util/toastr';
 import { toastError } from '~/client/util/toastr';
 import { usePageDeleteModal, usePutBackPageModal } from '~/stores/modal';
 import { usePageDeleteModal, usePutBackPageModal } from '~/stores/modal';
 import {
 import {
-  useCurrentPagePath, useSWRxPageInfo, useSWRxCurrentPage, useIsTrashPage,
+  useCurrentPagePath, useSWRxPageInfo, useSWRxCurrentPage, useIsTrashPage, useSWRMUTxCurrentPage,
 } from '~/stores/page';
 } from '~/stores/page';
 import { useIsAbleToShowTrashPageManagementButtons } from '~/stores/ui';
 import { useIsAbleToShowTrashPageManagementButtons } from '~/stores/ui';
 
 
@@ -33,11 +33,11 @@ export const TrashPageAlert = (): JSX.Element => {
   const pagePath = pageData?.path;
   const pagePath = pageData?.path;
   const { data: pageInfo } = useSWRxPageInfo(pageId ?? null);
   const { data: pageInfo } = useSWRxPageInfo(pageId ?? null);
 
 
-
   const { open: openDeleteModal } = usePageDeleteModal();
   const { open: openDeleteModal } = usePageDeleteModal();
   const { open: openPutBackPageModal } = usePutBackPageModal();
   const { open: openPutBackPageModal } = usePutBackPageModal();
   const { data: currentPagePath } = useCurrentPagePath();
   const { data: currentPagePath } = useCurrentPagePath();
 
 
+  const { trigger: mutateCurrentPage } = useSWRMUTxCurrentPage();
 
 
   const deleteUser = pageData?.deleteUser;
   const deleteUser = pageData?.deleteUser;
   const deletedAt = pageData?.deletedAt ? format(new Date(pageData?.deletedAt), 'yyyy/MM/dd HH:mm') : '';
   const deletedAt = pageData?.deletedAt ? format(new Date(pageData?.deletedAt), 'yyyy/MM/dd HH:mm') : '';
@@ -55,13 +55,14 @@ export const TrashPageAlert = (): JSX.Element => {
       try {
       try {
         unlink(currentPagePath);
         unlink(currentPagePath);
         router.push(`/${pageId}`);
         router.push(`/${pageId}`);
+        mutateCurrentPage();
       }
       }
       catch (err) {
       catch (err) {
         toastError(err);
         toastError(err);
       }
       }
     };
     };
     openPutBackPageModal({ pageId, path: pagePath }, { onPutBacked: putBackedHandler });
     openPutBackPageModal({ pageId, path: pagePath }, { onPutBacked: putBackedHandler });
-  }, [currentPagePath, openPutBackPageModal, pageId, pagePath, router]);
+  }, [currentPagePath, mutateCurrentPage, openPutBackPageModal, pageId, pagePath, router]);
 
 
   const openPageDeleteModalHandler = useCallback(() => {
   const openPageDeleteModalHandler = useCallback(() => {
     if (pageId === undefined || revisionId === undefined || pagePath === undefined) {
     if (pageId === undefined || revisionId === undefined || pagePath === undefined) {

+ 7 - 57
apps/app/src/components/PageAttachment.tsx

@@ -6,9 +6,9 @@ import { IAttachmentHasId } from '@growi/core';
 
 
 import { useSWRxAttachments } from '~/stores/attachment';
 import { useSWRxAttachments } from '~/stores/attachment';
 import { useIsGuestUser, useIsReadOnlyUser } from '~/stores/context';
 import { useIsGuestUser, useIsReadOnlyUser } from '~/stores/context';
+import { useDeleteAttachmentModal } from '~/stores/modal';
 import { useSWRxCurrentPage, useCurrentPageId } from '~/stores/page';
 import { useSWRxCurrentPage, useCurrentPageId } from '~/stores/page';
 
 
-import { DeleteAttachmentModal } from './PageAttachment/DeleteAttachmentModal';
 import { PageAttachmentList } from './PageAttachment/PageAttachmentList';
 import { PageAttachmentList } from './PageAttachment/PageAttachmentList';
 import PaginationWrapper from './PaginationWrapper';
 import PaginationWrapper from './PaginationWrapper';
 
 
@@ -19,9 +19,6 @@ const checkIfFileInUse = (markdown: string, attachment): boolean => {
 
 
 const PageAttachment = (): JSX.Element => {
 const PageAttachment = (): JSX.Element => {
 
 
-  const { data: currentPage } = useSWRxCurrentPage();
-  const markdown = currentPage?.revision.body;
-
   // Static SWRs
   // Static SWRs
   const { data: pageId } = useCurrentPageId();
   const { data: pageId } = useCurrentPageId();
   const { data: isGuestUser } = useIsGuestUser();
   const { data: isGuestUser } = useIsGuestUser();
@@ -31,12 +28,12 @@ const PageAttachment = (): JSX.Element => {
 
 
   // States
   // States
   const [pageNumber, setPageNumber] = useState(1);
   const [pageNumber, setPageNumber] = useState(1);
-  const [attachmentToDelete, setAttachmentToDelete] = useState<(IAttachmentHasId) | null>(null);
-  const [deleting, setDeleting] = useState(false);
-  const [deleteError, setDeleteError] = useState('');
 
 
   // SWRs
   // SWRs
   const { data: dataAttachments, remove } = useSWRxAttachments(pageId, pageNumber);
   const { data: dataAttachments, remove } = useSWRxAttachments(pageId, pageNumber);
+  const { open: openDeleteAttachmentModal } = useDeleteAttachmentModal();
+  const { data: currentPage } = useSWRxCurrentPage();
+  const markdown = currentPage?.revision.body;
 
 
   // Custom hooks
   // Custom hooks
   const inUseAttachmentsMap: { [id: string]: boolean } | undefined = useMemo(() => {
   const inUseAttachmentsMap: { [id: string]: boolean } | undefined = useMemo(() => {
@@ -57,29 +54,9 @@ const PageAttachment = (): JSX.Element => {
     setPageNumber(newPageNumber);
     setPageNumber(newPageNumber);
   }, []);
   }, []);
 
 
-  const onAttachmentDeleteClicked = useCallback((attachment) => {
-    setAttachmentToDelete(attachment);
-  }, []);
-
-  const onAttachmentDeleteClickedConfirmHandler = useCallback(async(attachment: IAttachmentHasId) => {
-    setDeleting(true);
-
-    try {
-      await remove({ attachment_id: attachment._id });
-
-      setAttachmentToDelete(null);
-      setDeleting(false);
-    }
-    catch {
-      setDeleteError('Something went wrong.');
-      setDeleting(false);
-    }
-  }, [remove]);
-
-  const onToggleHandler = useCallback(() => {
-    setAttachmentToDelete(null);
-    setDeleteError('');
-  }, []);
+  const onAttachmentDeleteClicked = useCallback((attachment: IAttachmentHasId) => {
+    openDeleteAttachmentModal(attachment, remove);
+  }, [openDeleteAttachmentModal, remove]);
 
 
   // Renderers
   // Renderers
   const renderPageAttachmentList = useCallback(() => {
   const renderPageAttachmentList = useCallback(() => {
@@ -101,30 +78,6 @@ const PageAttachment = (): JSX.Element => {
     );
     );
   }, [dataAttachments, inUseAttachmentsMap, isPageAttachmentDisabled, onAttachmentDeleteClicked]);
   }, [dataAttachments, inUseAttachmentsMap, isPageAttachmentDisabled, onAttachmentDeleteClicked]);
 
 
-  const renderDeleteAttachmentModal = useCallback(() => {
-    if (isPageAttachmentDisabled) {
-      return <></>;
-    }
-
-    if (dataAttachments == null || dataAttachments.attachments.length === 0 || attachmentToDelete == null) {
-      return <></>;
-    }
-
-    const isOpen = attachmentToDelete != null;
-
-    return (
-      <DeleteAttachmentModal
-        isOpen={isOpen}
-        toggle={onToggleHandler}
-        attachmentToDelete={attachmentToDelete}
-        deleting={deleting}
-        deleteError={deleteError}
-        onAttachmentDeleteClickedConfirm={onAttachmentDeleteClickedConfirmHandler}
-      />
-    );
-  // eslint-disable-next-line max-len
-  }, [attachmentToDelete, dataAttachments, deleteError, deleting, isPageAttachmentDisabled, onAttachmentDeleteClickedConfirmHandler, onToggleHandler]);
-
   const renderPaginationWrapper = useCallback(() => {
   const renderPaginationWrapper = useCallback(() => {
     if (dataAttachments == null || dataAttachments.attachments.length === 0) {
     if (dataAttachments == null || dataAttachments.attachments.length === 0) {
       return <></>;
       return <></>;
@@ -144,9 +97,6 @@ const PageAttachment = (): JSX.Element => {
   return (
   return (
     <div data-testid="page-attachment">
     <div data-testid="page-attachment">
       {renderPageAttachmentList()}
       {renderPageAttachmentList()}
-
-      {renderDeleteAttachmentModal()}
-
       {renderPaginationWrapper()}
       {renderPaginationWrapper()}
     </div>
     </div>
   );
   );

+ 65 - 48
apps/app/src/components/PageAttachment/DeleteAttachmentModal.tsx

@@ -1,79 +1,97 @@
-/* eslint-disable react/prop-types */
-import React, { useCallback } from 'react';
+import React, {
+  useCallback, useMemo, useState,
+} from 'react';
 
 
-import { IAttachmentHasId } from '@growi/core';
+import type { IUser } from '@growi/core';
 import { UserPicture } from '@growi/ui/dist/components/User/UserPicture';
 import { UserPicture } from '@growi/ui/dist/components/User/UserPicture';
+import { useTranslation } from 'next-i18next';
 import {
 import {
-  Button,
-  Modal, ModalHeader, ModalBody, ModalFooter,
+  Button, Modal, ModalHeader, ModalBody, ModalFooter,
 } from 'reactstrap';
 } from 'reactstrap';
 
 
+import { toastSuccess, toastError } from '~/client/util/toastr';
+import { useDeleteAttachmentModal } from '~/stores/modal';
+import loggerFactory from '~/utils/logger';
+
 import { Username } from '../User/Username';
 import { Username } from '../User/Username';
 
 
 import styles from './DeleteAttachmentModal.module.scss';
 import styles from './DeleteAttachmentModal.module.scss';
 
 
+const logger = loggerFactory('growi:attachmentDelete');
+
+const iconByFormat = (format: string): string => {
+  return format.match(/image\/.+/i) ? 'icon-picture' : 'icon-doc';
+};
 
 
-function iconNameByFormat(format: string): string {
-  if (format.match(/image\/.+/i)) {
-    return 'icon-picture';
-  }
+export const DeleteAttachmentModal: React.FC = () => {
+  const [deleting, setDeleting] = useState<boolean>(false);
+  const [deleteError, setDeleteError] = useState<string>('');
 
 
-  return 'icon-doc';
-}
+  const { t } = useTranslation();
+  const { data: deleteAttachmentModal, close: closeDeleteAttachmentModal } = useDeleteAttachmentModal();
+  const isOpen = deleteAttachmentModal?.isOpened;
+  const attachment = deleteAttachmentModal?.attachment;
+  const remove = deleteAttachmentModal?.remove;
 
 
+  const toggleHandler = useCallback(() => {
+    closeDeleteAttachmentModal();
+    setDeleting(false);
+    setDeleteError('');
+  }, [closeDeleteAttachmentModal]);
 
 
-type Props = {
-  isOpen: boolean,
-  toggle: () => void,
-  attachmentToDelete: IAttachmentHasId | null,
-  deleting: boolean,
-  deleteError: string,
-  onAttachmentDeleteClickedConfirm?: (attachment: IAttachmentHasId) => Promise<void>,
-}
+  const onClickDeleteButtonHandler = useCallback(async() => {
+    if (remove == null || attachment == null) {
+      return;
+    }
 
 
-export const DeleteAttachmentModal = (props: Props): JSX.Element => {
+    setDeleting(true);
 
 
-  const {
-    isOpen, toggle,
-    attachmentToDelete, deleting, deleteError,
-    onAttachmentDeleteClickedConfirm,
-  } = props;
+    try {
+      await remove({ attachment_id: attachment._id });
+      setDeleting(false);
+      closeDeleteAttachmentModal();
+      toastSuccess(`Delete ${attachment.originalName}`);
+    }
+    catch (err) {
+      setDeleting(false);
+      setDeleteError('Attachment could not be deleted.');
+      toastError(err);
+      logger.error(err);
+    }
+  }, [attachment, closeDeleteAttachmentModal, remove]);
 
 
-  const onDeleteConfirm = useCallback(() => {
-    if (attachmentToDelete == null || onAttachmentDeleteClickedConfirm == null) {
+  const attachmentFileFormat = useMemo(() => {
+    if (attachment == null) {
       return;
       return;
     }
     }
-    onAttachmentDeleteClickedConfirm(attachmentToDelete);
-  }, [attachmentToDelete, onAttachmentDeleteClickedConfirm]);
 
 
-  const renderByFileFormat = useCallback((attachment) => {
     const content = (attachment.fileFormat.match(/image\/.+/i))
     const content = (attachment.fileFormat.match(/image\/.+/i))
       // eslint-disable-next-line @next/next/no-img-element
       // eslint-disable-next-line @next/next/no-img-element
       ? <img src={attachment.filePathProxied} alt="deleting image" />
       ? <img src={attachment.filePathProxied} alt="deleting image" />
       : '';
       : '';
 
 
-
     return (
     return (
       <div className="attachment-delete-image">
       <div className="attachment-delete-image">
         <p>
         <p>
-          <i className={iconNameByFormat(attachment.fileFormat)}></i> {attachment.originalName}
+          <i className={iconByFormat(attachment.fileFormat)}></i> {attachment.originalName}
         </p>
         </p>
         <p>
         <p>
-          uploaded by <UserPicture user={attachment.creator} size="sm"></UserPicture> <Username user={attachment.creator}></Username>
+          uploaded by <UserPicture user={attachment.creator} size="sm"></UserPicture> <Username user={attachment.creator as IUser}></Username>
         </p>
         </p>
         {content}
         {content}
       </div>
       </div>
     );
     );
-  }, []);
-
-  let deletingIndicator = <></>;
-  if (deleting) {
-    deletingIndicator = <div className="speeding-wheel-sm"></div>;
-  }
-  if (deleteError) {
-    deletingIndicator = <span>{deleteError}</span>;
-  }
+  }, [attachment]);
 
 
+  const deletingIndicator = useMemo(() => {
+    if (deleting) {
+      return <div className="speeding-wheel-sm"></div>;
+    }
+    if (deleteError) {
+      return <span>{deleteError}</span>;
+    }
+    return <></>;
+  }, [deleting, deleteError]);
 
 
   return (
   return (
     <Modal
     <Modal
@@ -83,11 +101,11 @@ export const DeleteAttachmentModal = (props: Props): JSX.Element => {
       aria-labelledby="contained-modal-title-lg"
       aria-labelledby="contained-modal-title-lg"
       fade={false}
       fade={false}
     >
     >
-      <ModalHeader tag="h4" toggle={toggle} className="bg-danger text-light">
-        <span id="contained-modal-title-lg">Delete attachment?</span>
+      <ModalHeader tag="h4" toggle={toggleHandler} className="bg-danger text-light">
+        <span id="contained-modal-title-lg">{t('delete_attachment_modal.confirm_delete_attachment')}</span>
       </ModalHeader>
       </ModalHeader>
       <ModalBody>
       <ModalBody>
-        {renderByFileFormat(attachmentToDelete)}
+        {attachmentFileFormat}
       </ModalBody>
       </ModalBody>
       <ModalFooter>
       <ModalFooter>
         <div className="mr-3 d-inline-block">
         <div className="mr-3 d-inline-block">
@@ -95,12 +113,11 @@ export const DeleteAttachmentModal = (props: Props): JSX.Element => {
         </div>
         </div>
         <Button
         <Button
           color="danger"
           color="danger"
-          onClick={onDeleteConfirm}
+          onClick={onClickDeleteButtonHandler}
           disabled={deleting}
           disabled={deleting}
-        >Delete!
+        >{t('commons:Delete')}
         </Button>
         </Button>
       </ModalFooter>
       </ModalFooter>
     </Modal>
     </Modal>
   );
   );
-
 };
 };

+ 1 - 1
apps/app/src/components/PageAttachment/PageAttachmentList.tsx

@@ -7,7 +7,7 @@ import { useTranslation } from 'next-i18next';
 type Props = {
 type Props = {
   attachments: (IAttachmentHasId)[],
   attachments: (IAttachmentHasId)[],
   inUse: { [id: string]: boolean },
   inUse: { [id: string]: boolean },
-  onAttachmentDeleteClicked?: (attachment: IAttachmentHasId) => void,
+  onAttachmentDeleteClicked: (attachment: IAttachmentHasId) => void,
   isUserLoggedIn?: boolean,
   isUserLoggedIn?: boolean,
 }
 }
 
 

+ 31 - 31
apps/app/src/components/PageEditor/HandsontableModal.tsx

@@ -1,4 +1,4 @@
-import React, { useState, useEffect } from 'react';
+import React, { useState } from 'react';
 
 
 import { HotTable } from '@handsontable/react';
 import { HotTable } from '@handsontable/react';
 import Handsontable from 'handsontable';
 import Handsontable from 'handsontable';
@@ -7,7 +7,6 @@ import {
   Collapse,
   Collapse,
   Modal, ModalHeader, ModalBody, ModalFooter,
   Modal, ModalHeader, ModalBody, ModalFooter,
 } from 'reactstrap';
 } from 'reactstrap';
-import { debounce } from 'throttle-debounce';
 
 
 import MarkdownTable from '~/client/models/MarkdownTable';
 import MarkdownTable from '~/client/models/MarkdownTable';
 import mtu from '~/components/PageEditor/MarkdownTableUtil';
 import mtu from '~/components/PageEditor/MarkdownTableUtil';
@@ -19,6 +18,7 @@ import { MarkdownTableDataImportForm } from './MarkdownTableDataImportForm';
 
 
 import styles from './HandsontableModal.module.scss';
 import styles from './HandsontableModal.module.scss';
 import 'handsontable/dist/handsontable.full.min.css';
 import 'handsontable/dist/handsontable.full.min.css';
+import { debounce } from 'throttle-debounce';
 
 
 const DEFAULT_HOT_HEIGHT = 300;
 const DEFAULT_HOT_HEIGHT = 300;
 const MARKDOWNTABLE_TO_HANDSONTABLE_ALIGNMENT_SYMBOL_MAPPING = {
 const MARKDOWNTABLE_TO_HANDSONTABLE_ALIGNMENT_SYMBOL_MAPPING = {
@@ -88,13 +88,37 @@ export const HandsontableModal = (): JSX.Element => {
   const [markdownTable, setMarkdownTable] = useState<MarkdownTable>(defaultMarkdownTable);
   const [markdownTable, setMarkdownTable] = useState<MarkdownTable>(defaultMarkdownTable);
   const [markdownTableOnInit, setMarkdownTableOnInit] = useState<MarkdownTable>(defaultMarkdownTable);
   const [markdownTableOnInit, setMarkdownTableOnInit] = useState<MarkdownTable>(defaultMarkdownTable);
   const [handsontableHeight, setHandsontableHeight] = useState<number>(DEFAULT_HOT_HEIGHT);
   const [handsontableHeight, setHandsontableHeight] = useState<number>(DEFAULT_HOT_HEIGHT);
+  const [handsontableWidth, setHandsontableWidth] = useState<number>(0);
 
 
-  useEffect(() => {
+  const handleWindowExpandedChange = () => {
+    if (hotTableContainer != null) {
+      // Get the width and height of hotTableContainer
+      const { width, height } = hotTableContainer.getBoundingClientRect();
+      setHandsontableWidth(width);
+      setHandsontableHeight(height);
+    }
+  };
+
+  const debouncedHandleWindowExpandedChange = debounce(100, handleWindowExpandedChange);
+
+  const handleModalOpen = () => {
     const initTableInstance = table == null ? defaultMarkdownTable : table.clone();
     const initTableInstance = table == null ? defaultMarkdownTable : table.clone();
     setMarkdownTable(table ?? defaultMarkdownTable);
     setMarkdownTable(table ?? defaultMarkdownTable);
     setMarkdownTableOnInit(initTableInstance);
     setMarkdownTableOnInit(initTableInstance);
-  // eslint-disable-next-line react-hooks/exhaustive-deps
-  }, [isOpened]);
+    debouncedHandleWindowExpandedChange();
+  };
+
+  const expandWindow = () => {
+    setIsWindowExpanded(true);
+    debouncedHandleWindowExpandedChange();
+  };
+
+  const contractWindow = () => {
+    setIsWindowExpanded(false);
+    // Set the height to the default value
+    setHandsontableHeight(DEFAULT_HOT_HEIGHT);
+    debouncedHandleWindowExpandedChange();
+  };
 
 
   const markdownTableOption = {
   const markdownTableOption = {
     get latest() {
     get latest() {
@@ -363,32 +387,6 @@ export const HandsontableModal = (): JSX.Element => {
     toggleDataImportArea();
     toggleDataImportArea();
   };
   };
 
 
-  /**
-   * Expand the height of the Handsontable
-   *  by updating 'handsontableHeight' state
-   *  according to the height of this.refs.hotTableContainer
-   */
-  const expandHotTableHeight = () => {
-    if (isWindowExpanded && hotTableContainer != null) {
-      const height = hotTableContainer.getBoundingClientRect().height;
-      setHandsontableHeight(height);
-    }
-  };
-
-  const expandWindow = () => {
-    setIsWindowExpanded(true);
-
-    // create debounced method for expanding HotTable
-    // invoke updateHotTableHeight method with delay
-    // cz. Resizing this.refs.hotTableContainer is completed after a little delay after 'isWindowExpanded' set with 'true'
-    debounce(100, expandHotTableHeight);
-  };
-
-  const contractWindow = () => {
-    setIsWindowExpanded(false);
-    setHandsontableHeight(DEFAULT_HOT_HEIGHT);
-  };
-
   const createCustomizedContextMenu = () => {
   const createCustomizedContextMenu = () => {
     return {
     return {
       items: {
       items: {
@@ -453,6 +451,7 @@ export const HandsontableModal = (): JSX.Element => {
       size="lg"
       size="lg"
       wrapClassName={`${styles['grw-handsontable']}`}
       wrapClassName={`${styles['grw-handsontable']}`}
       className={`handsontable-modal ${isWindowExpanded && 'grw-modal-expanded'}`}
       className={`handsontable-modal ${isWindowExpanded && 'grw-modal-expanded'}`}
+      onOpened={handleModalOpen}
     >
     >
       <ModalHeader tag="h4" toggle={cancel} close={closeButton} className="bg-primary text-light">
       <ModalHeader tag="h4" toggle={cancel} close={closeButton} className="bg-primary text-light">
         {t('handsontable_modal.title')}
         {t('handsontable_modal.title')}
@@ -493,6 +492,7 @@ export const HandsontableModal = (): JSX.Element => {
             data={markdownTable.table}
             data={markdownTable.table}
             settings={handsontableSettings as Handsontable.DefaultSettings}
             settings={handsontableSettings as Handsontable.DefaultSettings}
             height={handsontableHeight}
             height={handsontableHeight}
+            width={handsontableWidth}
             afterLoadData={afterLoadDataHandler}
             afterLoadData={afterLoadDataHandler}
             modifyColWidth={modifyColWidthHandler}
             modifyColWidth={modifyColWidthHandler}
             beforeColumnMove={beforeColumnMoveHandler}
             beforeColumnMove={beforeColumnMoveHandler}

+ 0 - 17
apps/app/src/components/ReactMarkdownComponents/NextLink.tsx

@@ -22,10 +22,6 @@ const isExternalLink = (href: string, siteUrl: string | undefined): boolean => {
   }
   }
 };
 };
 
 
-const isAttached = (href: string): boolean => {
-  return href.startsWith('/attachment/');
-};
-
 type Props = Omit<LinkProps, 'href'> & {
 type Props = Omit<LinkProps, 'href'> & {
   children: React.ReactNode,
   children: React.ReactNode,
   id?: string,
   id?: string,
@@ -64,19 +60,6 @@ export const NextLink = (props: Props): JSX.Element => {
     );
     );
   }
   }
 
 
-  // when href is an attachment file
-  if (isAttached(href)) {
-    const dlhref = href.replace('/attachment/', '/download/');
-    return (
-      <span>
-        <a id={id} href={href} className={className} target="_blank" rel="noopener noreferrer" {...dataAttributes}>
-          {children}
-        </a>&nbsp;
-        <a href={dlhref} className="attachment-download"><i className='icon-cloud-download'></i></a>
-      </span>
-    );
-  }
-
   return (
   return (
     <Link {...rest} href={href} prefetch={false} legacyBehavior>
     <Link {...rest} href={href} prefetch={false} legacyBehavior>
       <a href={href} className={className} {...dataAttributes}>{children}</a>
       <a href={href} className={className} {...dataAttributes}>{children}</a>

+ 7 - 0
apps/app/src/components/ReactMarkdownComponents/RichAttachment.module.scss

@@ -0,0 +1,7 @@
+.attachment :global {
+  .attachment-icon {
+    flex-shrink: 0;
+    width: 35px;
+    height: 35px;
+  }
+}

+ 84 - 0
apps/app/src/components/ReactMarkdownComponents/RichAttachment.tsx

@@ -0,0 +1,84 @@
+import React, { useCallback } from 'react';
+
+import { UserPicture } from '@growi/ui/dist/components/User/UserPicture';
+import { useTranslation } from 'next-i18next';
+import prettyBytes from 'pretty-bytes';
+
+import { useSWRxAttachment } from '~/stores/attachment';
+import { useDeleteAttachmentModal } from '~/stores/modal';
+
+import styles from './RichAttachment.module.scss';
+
+export const RichAttachment: React.FC<{
+  attachmentId: string,
+  url: string,
+  attachmentName: string
+}> = React.memo(({ attachmentId, url, attachmentName }) => {
+  const { t } = useTranslation();
+  const { data: attachment, remove } = useSWRxAttachment(attachmentId);
+  const { open: openDeleteAttachmentModal } = useDeleteAttachmentModal();
+
+  const onClickTrashButtonHandler = useCallback(() => {
+    if (attachment == null) {
+      return;
+    }
+    openDeleteAttachmentModal(attachment, remove);
+  }, [attachment, openDeleteAttachmentModal, remove]);
+
+  if (attachment == null) {
+    return <span className='text-muted'>{t('rich_attachment.attachment_not_be_found')}</span>;
+  }
+
+  const {
+    filePathProxied,
+    originalName,
+    downloadPathProxied,
+    creator,
+    createdAt,
+    fileSize,
+  } = attachment;
+
+  // Guard here because attachment properties might be deleted in turn when an attachment is removed
+  if (filePathProxied == null
+    || originalName == null
+    || downloadPathProxied == null
+    || creator == null
+    || createdAt == null
+    || fileSize == null
+  ) {
+    return <span className='text-muted'>{t('rich_attachment.attachment_not_be_found')}</span>;
+  }
+
+  return (
+    <div className={`${styles.attachment} d-inline-block`}>
+      <div className="my-2 p-2 card">
+        <div className="p-1 card-body d-flex align-items-center">
+          <div className='mr-2 px-0 d-flex align-items-center justify-content-center'>
+            <img src='/images/icons/editor/attachment.svg' className="attachment-icon" alt='attachment icon'/>
+          </div>
+          <div className='pl-0'>
+            <div className='d-inline-block'>
+              <a target="_blank" rel="noopener noreferrer" href={filePathProxied}>
+                {attachmentName || originalName}
+              </a>
+              <a className="ml-2 attachment-download" href={downloadPathProxied}>
+                <i className="icon-cloud-download"/>
+              </a>
+              <a className="ml-2 text-danger attachment-delete" onClick={onClickTrashButtonHandler}>
+                <i className="icon-trash"/>
+              </a>
+            </div>
+            <div className='d-flex align-items-center'>
+              <UserPicture user={creator} size="sm"/>
+              <span className='ml-2 text-muted'>
+                {new Date(createdAt).toLocaleString('en-US')}
+              </span>
+              <span className='ml-2 pl-2 border-left text-muted'>{prettyBytes(fileSize)}</span>
+            </div>
+          </div>
+        </div>
+      </div>
+    </div>
+  );
+});
+RichAttachment.displayName = 'RichAttachment';

+ 1 - 1
apps/app/src/components/ReactMarkdownComponents/TableWithEditButton.tsx

@@ -46,7 +46,7 @@ export const TableWithEditButton = React.memo((props: TableWithEditButtonProps):
           <i className="icon-note"></i>
           <i className="icon-note"></i>
         </button>
         </button>
       )}
       )}
-      <table className={`${className}`}>
+      <table className={className}>
         {children}
         {children}
       </table>
       </table>
     </div>
     </div>

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

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

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

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

+ 3 - 2
apps/app/src/components/Sidebar/SidebarNav.tsx

@@ -6,7 +6,7 @@ import Link from 'next/link';
 
 
 import { useUserUISettings } from '~/client/services/user-ui-settings';
 import { useUserUISettings } from '~/client/services/user-ui-settings';
 import { SidebarContentsType } from '~/interfaces/ui';
 import { SidebarContentsType } from '~/interfaces/ui';
-import { useCurrentUser } from '~/stores/context';
+import { useCurrentUser, useGrowiCloudUri } from '~/stores/context';
 import { useCurrentSidebarContents } from '~/stores/ui';
 import { useCurrentSidebarContents } from '~/stores/ui';
 
 
 import styles from './SidebarNav.module.scss';
 import styles from './SidebarNav.module.scss';
@@ -84,6 +84,7 @@ type Props = {
 export const SidebarNav: FC<Props> = (props: Props) => {
 export const SidebarNav: FC<Props> = (props: Props) => {
 
 
   const { data: currentUser } = useCurrentUser();
   const { data: currentUser } = useCurrentUser();
+  const { data: growiCloudUri } = useGrowiCloudUri();
 
 
   const [isAdmin, setAdmin] = useState(false);
   const [isAdmin, setAdmin] = useState(false);
 
 
@@ -110,7 +111,7 @@ export const SidebarNav: FC<Props> = (props: Props) => {
       <div className="grw-sidebar-nav-secondary-container">
       <div className="grw-sidebar-nav-secondary-container">
         {isAdmin && <SecondaryItem label="Admin" iconName="settings" href="/admin" />}
         {isAdmin && <SecondaryItem label="Admin" iconName="settings" href="/admin" />}
         {/* <SecondaryItem label="Draft" iconName="file_copy" href="/me/drafts" /> */}
         {/* <SecondaryItem label="Draft" iconName="file_copy" href="/me/drafts" /> */}
-        <SecondaryItem label="Help" iconName="help" href="https://docs.growi.org" isBlank />
+        <SecondaryItem label="Help" iconName="help" href={ growiCloudUri != null ? 'https://growi.cloud/help/' : 'https://docs.growi.org' } isBlank />
         <SecondaryItem label="Trash" iconName="delete" href="/trash" />
         <SecondaryItem label="Trash" iconName="delete" href="/trash" />
       </div>
       </div>
     </div>
     </div>

+ 197 - 56
apps/app/src/components/TemplateModal/TemplateModal.tsx

@@ -2,18 +2,28 @@ import React, {
   useCallback, useEffect, useState,
   useCallback, useEffect, useState,
 } from 'react';
 } from 'react';
 
 
-import type { ITemplate } from '@growi/core/dist/interfaces/template';
+import assert from 'assert';
+
+import { Lang } from '@growi/core';
+import {
+  extractSupportedLocales, getLocalizedTemplate, type TemplateSummary,
+} from '@growi/pluginkit/dist/v4';
 import { useTranslation } from 'next-i18next';
 import { useTranslation } from 'next-i18next';
 import {
 import {
   Modal,
   Modal,
   ModalHeader,
   ModalHeader,
   ModalBody,
   ModalBody,
   ModalFooter,
   ModalFooter,
+  UncontrolledDropdown,
+  DropdownToggle,
+  DropdownMenu,
+  DropdownItem,
 } from 'reactstrap';
 } from 'reactstrap';
 
 
-import { useTemplateModal } from '~/stores/modal';
+import { useSWRxTemplate, useSWRxTemplates } from '~/features/templates/stores';
+import { useTemplateModal, type TemplateModalStatus } from '~/stores/modal';
+import { usePersonalSettings } from '~/stores/personal-settings';
 import { usePreviewOptions } from '~/stores/renderer';
 import { usePreviewOptions } from '~/stores/renderer';
-import { useTemplates } from '~/stores/template';
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 
 
 import Preview from '../PageEditor/Preview';
 import Preview from '../PageEditor/Preview';
@@ -23,108 +33,239 @@ import { useFormatter } from './use-formatter';
 const logger = loggerFactory('growi:components:TemplateModal');
 const logger = loggerFactory('growi:components:TemplateModal');
 
 
 
 
-type TemplateRadioButtonProps = {
-  template: ITemplate,
-  onChange: (selectedTemplate: ITemplate) => void,
+function constructTemplateId(templateSummary: TemplateSummary): string {
+  const defaultTemplate = templateSummary.default;
+
+  return `${defaultTemplate.pluginId ?? ''}_${defaultTemplate.id}`;
+}
+
+type TemplateItemProps = {
+  templateSummary: TemplateSummary,
+  selectedLocale?: string,
+  onClick?: () => void,
   isSelected?: boolean,
   isSelected?: boolean,
+  usersDefaultLang?: Lang,
 }
 }
 
 
-const TemplateRadioButton = ({ template, onChange, isSelected }: TemplateRadioButtonProps): JSX.Element => {
-  const radioButtonId = `rb-${template.id}`;
+const TemplateItem: React.FC<TemplateItemProps> = ({
+  templateSummary,
+  onClick,
+  isSelected,
+  usersDefaultLang,
+}) => {
+  const localizedTemplate = getLocalizedTemplate(templateSummary, usersDefaultLang);
+  const templateLocales = extractSupportedLocales(templateSummary);
+
+  assert(localizedTemplate?.isValid);
 
 
   return (
   return (
-    <div key={template.id} className="custom-control custom-radio mb-2">
-      <input
-        id={radioButtonId}
-        type="radio"
-        className="custom-control-input"
-        checked={isSelected}
-        onChange={() => onChange(template)}
-      />
-      <label className="custom-control-label" htmlFor={radioButtonId}>
-        {template.name}
-      </label>
-    </div>
+    <a
+      className={`list-group-item list-group-item-action ${isSelected ? 'active' : ''}`}
+      onClick={onClick}
+      aria-current="true"
+    >
+      <h4 className="mb-1">{localizedTemplate.title}</h4>
+      <p className="mb-2">{localizedTemplate.desc}</p>
+      { templateLocales != null && Array.from(templateLocales).map(locale => (
+        <span key={locale} className="badge border rounded-pill text-muted mr-1">{locale}</span>
+      ))}
+    </a>
   );
   );
 };
 };
 
 
-export const TemplateModal = (): JSX.Element => {
-  const { t } = useTranslation(['translation', 'commons']);
+type TemplateModalSubstanceProps = {
+  templateModalStatus: TemplateModalStatus,
+  close: () => void,
+}
 
 
+const TemplateModalSubstance = (props: TemplateModalSubstanceProps): JSX.Element => {
+  const { templateModalStatus, close } = props;
 
 
-  const { data: templateModalStatus, close } = useTemplateModal();
+  const { t } = useTranslation(['translation', 'commons']);
 
 
+  const { data: personalSettingsInfo } = usePersonalSettings();
   const { data: rendererOptions } = usePreviewOptions();
   const { data: rendererOptions } = usePreviewOptions();
-  const { data: templates } = useTemplates();
+  const { data: templateSummaries } = useSWRxTemplates();
+
+  const [selectedTemplateSummary, setSelectedTemplateSummary] = useState<TemplateSummary>();
+  const [selectedTemplateLocale, setSelectedTemplateLocale] = useState<string>();
 
 
-  const [selectedTemplate, setSelectedTemplate] = useState<ITemplate>();
+  const { data: selectedTemplateMarkdown } = useSWRxTemplate(selectedTemplateSummary, selectedTemplateLocale);
 
 
   const { format } = useFormatter();
   const { format } = useFormatter();
 
 
-  const submitHandler = useCallback((template?: ITemplate) => {
-    if (templateModalStatus == null || selectedTemplate == null) {
+  const usersDefaultLang = personalSettingsInfo?.lang;
+  const selectedLocalizedTemplate = getLocalizedTemplate(selectedTemplateSummary, usersDefaultLang);
+  const selectedTemplateLocales = extractSupportedLocales(selectedTemplateSummary);
+
+  const submitHandler = useCallback((markdown?: string) => {
+    if (markdown == null) {
       return;
       return;
     }
     }
 
 
-    if (templateModalStatus.onSubmit == null || template == null) {
+    if (templateModalStatus.onSubmit == null) {
       close();
       close();
       return;
       return;
     }
     }
 
 
-    templateModalStatus.onSubmit(format(selectedTemplate));
+    templateModalStatus.onSubmit(format(selectedTemplateMarkdown));
     close();
     close();
-  }, [close, format, selectedTemplate, templateModalStatus]);
+  }, [close, format, selectedTemplateMarkdown, templateModalStatus]);
+
+  const onClickHandler = useCallback((
+      templateSummary: TemplateSummary,
+  ) => {
+    let localeToSet: string | Lang | undefined;
+
+    if (selectedTemplateLocale != null && selectedTemplateLocale in templateSummary) {
+      localeToSet = selectedTemplateLocale;
+    }
+    else if (usersDefaultLang != null && usersDefaultLang in templateSummary) {
+      localeToSet = usersDefaultLang;
+    }
+    else {
+      localeToSet = undefined;
+    }
+
+    setSelectedTemplateLocale(localeToSet);
+    setSelectedTemplateSummary(templateSummary);
+  }, [selectedTemplateLocale, usersDefaultLang]);
 
 
   useEffect(() => {
   useEffect(() => {
-    if (!templateModalStatus?.isOpened) {
-      setSelectedTemplate(undefined);
+    if (!templateModalStatus.isOpened) {
+      setSelectedTemplateSummary(undefined);
+      setSelectedTemplateLocale(undefined);
     }
     }
-  }, [templateModalStatus?.isOpened]);
+  }, [templateModalStatus.isOpened]);
 
 
-  if (templates == null || templateModalStatus == null) {
+  if (templateSummaries == null) {
     return <></>;
     return <></>;
   }
   }
 
 
   return (
   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">
       <ModalHeader tag="h4" toggle={close} className="bg-primary text-light">
         {t('template.modal_label.Select template')}
         {t('template.modal_label.Select template')}
       </ModalHeader>
       </ModalHeader>
-
       <ModalBody className="container">
       <ModalBody className="container">
         <div className="row">
         <div className="row">
-          <div className="col-12">
-            { templates.map(template => (
-              <TemplateRadioButton
-                key={template.id}
-                template={template}
-                onChange={selected => setSelectedTemplate(selected)}
-                isSelected={template.id === selectedTemplate?.id}
-              />
-            )) }
+          {/* List Group */}
+          <div className="d-none d-lg-block col-lg-4">
+            <div className="list-group">
+              {templateSummaries.map((templateSummary) => {
+                const templateId = constructTemplateId(templateSummary);
+                const isSelected = selectedTemplateSummary != null && constructTemplateId(selectedTemplateSummary) === templateId;
+
+                return (
+                  <TemplateItem
+                    key={templateId}
+                    templateSummary={templateSummary}
+                    onClick={() => onClickHandler(templateSummary)}
+                    isSelected={isSelected}
+                    usersDefaultLang={usersDefaultLang}
+                  />
+                );
+              })}
+            </div>
           </div>
           </div>
-        </div>
+          {/* Dropdown */}
+          <div className='d-lg-none col mb-3'>
+            <UncontrolledDropdown>
+              <DropdownToggle caret type="button" outline className='w-100 text-right'>
+                <span className="float-left">
+                  {selectedLocalizedTemplate != null && selectedLocalizedTemplate.isValid
+                    ? selectedLocalizedTemplate.title
+                    : t('Select template')}
+                </span>
+              </DropdownToggle>
+              <DropdownMenu role="menu" className='p-0'>
+                {templateSummaries.map((templateSummary, index) => {
+                  const templateId = constructTemplateId(templateSummary);
+                  const localizedTemplate = getLocalizedTemplate(templateSummary, usersDefaultLang);
+                  const templateLocales = extractSupportedLocales(templateSummary);
 
 
-        <hr />
+                  assert(localizedTemplate?.isValid);
 
 
-        <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)}/>
-            ) }
+                  return (
+                    <DropdownItem
+                      key={templateId}
+                      onClick={() => onClickHandler(templateSummary)}
+                      className={`px-4 py-3 ${index === 0 ? '' : 'border-top'}`}
+                    >
+                      <h4 className="mb-1 text-wrap">{localizedTemplate.title}</h4>
+                      <p className="mb-1 text-wrap">{localizedTemplate.desc}</p>
+                      { templateLocales != null && Array.from(templateLocales).map(locale => (
+                        <span key={locale} className="badge border rounded-pill text-muted mr-1">{locale}</span>
+                      ))}
+                    </DropdownItem>
+                  );
+                })}
+              </DropdownMenu>
+            </UncontrolledDropdown>
+          </div>
+          <div className="col-12 col-lg-8">
+            <div className='row mb-2 mb-lg-0'>
+              <div className="col-6">
+                <h3>{t('preview')}</h3>
+              </div>
+              <div className="col-6 d-flex justify-content-end">
+                <UncontrolledDropdown>
+                  <DropdownToggle caret type="button" outline className='float-right' disabled={selectedTemplateSummary == null}>
+                    <span className="float-left">{selectedTemplateLocale != null ? selectedTemplateLocale : t('Language')}</span>
+                  </DropdownToggle>
+                  <DropdownMenu className="dropdown-menu" role="menu">
+                    { selectedTemplateLocales != null && Array.from(selectedTemplateLocales).map((locale) => {
+                      return (
+                        <DropdownItem
+                          key={locale}
+                          onClick={() => setSelectedTemplateLocale(locale)}>
+                          <span>{locale}</span>
+                        </DropdownItem>
+                      );
+                    }) }
+                  </DropdownMenu>
+                </UncontrolledDropdown>
+              </div>
+            </div>
+            <div className='card'>
+              <div className="card-body" style={{ height: '400px', overflowY: 'auto' }}>
+                { rendererOptions != null && selectedTemplateSummary != null && (
+                  <Preview rendererOptions={rendererOptions} markdown={format(selectedTemplateMarkdown)}/>
+                ) }
+              </div>
+            </div>
           </div>
           </div>
         </div>
         </div>
-
       </ModalBody>
       </ModalBody>
       <ModalFooter>
       <ModalFooter>
-        <button type="button" className="btn btn-sm btn-outline-secondary mx-1" onClick={close}>
+        <button type="button" className="btn btn-outline-secondary mx-1" onClick={close}>
           {t('Cancel')}
           {t('Cancel')}
         </button>
         </button>
-        <button type="submit" className="btn btn-sm btn-primary mx-1" onClick={() => submitHandler(selectedTemplate)} disabled={selectedTemplate == null}>
+        <button
+          type="submit"
+          className="btn btn-primary mx-1"
+          onClick={() => submitHandler(selectedTemplateMarkdown)}
+          disabled={selectedTemplateSummary == null}>
           {t('commons:Insert')}
           {t('commons:Insert')}
         </button>
         </button>
       </ModalFooter>
       </ModalFooter>
+    </>
+  );
+};
+
+
+export const TemplateModal = (): JSX.Element => {
+  const { data: templateModalStatus, close } = useTemplateModal();
+
+  if (templateModalStatus == null) {
+    return <></>;
+  }
+
+  return (
+    <Modal className="link-edit-modal" isOpen={templateModalStatus.isOpened} toggle={close} size="xl" autoFocus={false}>
+      { templateModalStatus.isOpened && (
+        <TemplateModalSubstance templateModalStatus={templateModalStatus} close={close} />
+      ) }
     </Modal>
     </Modal>
   );
   );
 };
 };

+ 9 - 15
apps/app/src/components/TemplateModal/use-formatter.spec.tsx

@@ -1,6 +1,3 @@
-import type { ITemplate } from '@growi/core/dist/interfaces/template';
-import { mock } from 'vitest-mock-extended';
-
 import { useFormatter } from './use-formatter';
 import { useFormatter } from './use-formatter';
 
 
 
 
@@ -47,26 +44,24 @@ describe('useFormatter', () => {
 
 
     // when
     // when
     const { format } = useFormatter();
     const { format } = useFormatter();
-    const template = mock<ITemplate>();
-    template.markdown = 'markdown body';
-    const markdown = format(template);
+    const markdown = 'markdown body';
+    const formatted = format(markdown);
 
 
     // then
     // then
-    expect(markdown).toBe('markdown body');
+    expect(formatted).toBe('markdown body');
   });
   });
 
 
   it('returns markdown formatted when currentPagePath is undefined', () => {
   it('returns markdown formatted when currentPagePath is undefined', () => {
     // when
     // when
     const { format } = useFormatter();
     const { format } = useFormatter();
-    const template = mock<ITemplate>();
-    template.markdown = `
+    const markdown = `
 title: {{{title}}}{{^title}}(empty){{/title}}
 title: {{{title}}}{{^title}}(empty){{/title}}
 path: {{{path}}}
 path: {{{path}}}
 `;
 `;
-    const markdown = format(template);
+    const formatted = format(markdown);
 
 
     // then
     // then
-    expect(markdown).toBe(`
+    expect(formatted).toBe(`
 title: (empty)
 title: (empty)
 path: /
 path: /
 `);
 `);
@@ -82,16 +77,15 @@ path: /
 
 
     // when
     // when
     const { format } = useFormatter();
     const { format } = useFormatter();
-    const template = mock<ITemplate>();
-    template.markdown = `
+    const markdown = `
 title: {{{title}}}
 title: {{{title}}}
 path: {{{path}}}
 path: {{{path}}}
 date: {{yyyy}}/{{MM}}/{{dd}} {{HH}}:{{mm}}
 date: {{yyyy}}/{{MM}}/{{dd}} {{HH}}:{{mm}}
 `;
 `;
-    const markdown = format(template);
+    const formatted = format(markdown);
 
 
     // then
     // then
-    expect(markdown).toBe(`
+    expect(formatted).toBe(`
 title: Sandbox
 title: Sandbox
 path: /Sandbox
 path: /Sandbox
 date: 2023/05/31 15:01
 date: 2023/05/31 15:01

+ 5 - 8
apps/app/src/components/TemplateModal/use-formatter.tsx

@@ -1,6 +1,5 @@
 import path from 'path';
 import path from 'path';
 
 
-import type { ITemplate } from '@growi/core/dist/interfaces/template';
 import dateFnsFormat from 'date-fns/format';
 import dateFnsFormat from 'date-fns/format';
 import mustache from 'mustache';
 import mustache from 'mustache';
 
 
@@ -10,7 +9,7 @@ import loggerFactory from '~/utils/logger';
 const logger = loggerFactory('growi:components:TemplateModal:use-formatter');
 const logger = loggerFactory('growi:components:TemplateModal:use-formatter');
 
 
 
 
-type FormatMethod = (selectedTemplate?: ITemplate) => string;
+type FormatMethod = (markdown?: string) => string;
 type FormatterData = {
 type FormatterData = {
   format: FormatMethod,
   format: FormatMethod,
 }
 }
@@ -18,16 +17,15 @@ type FormatterData = {
 export const useFormatter = (): FormatterData => {
 export const useFormatter = (): FormatterData => {
   const { data: currentPagePath } = useCurrentPagePath();
   const { data: currentPagePath } = useCurrentPagePath();
 
 
-  const format: FormatMethod = (selectedTemplate) => {
-    if (selectedTemplate == null) {
+  const format: FormatMethod = (markdown) => {
+    if (markdown == null) {
       return '';
       return '';
     }
     }
 
 
     // replace placeholder
     // replace placeholder
-    let markdown = selectedTemplate.markdown;
     const now = new Date();
     const now = new Date();
     try {
     try {
-      markdown = mustache.render(selectedTemplate.markdown, {
+      return mustache.render(markdown, {
         title: path.basename(currentPagePath ?? '/'),
         title: path.basename(currentPagePath ?? '/'),
         path: currentPagePath ?? '/',
         path: currentPagePath ?? '/',
         yyyy: dateFnsFormat(now, 'yyyy'),
         yyyy: dateFnsFormat(now, 'yyyy'),
@@ -39,9 +37,8 @@ export const useFormatter = (): FormatterData => {
     }
     }
     catch (err) {
     catch (err) {
       logger.warn('An error occured while ejs processing.', err);
       logger.warn('An error occured while ejs processing.', err);
+      return markdown;
     }
     }
-
-    return markdown;
   };
   };
 
 
   return { format };
   return { format };

+ 1 - 6
apps/app/src/components/User/Username.tsx

@@ -3,12 +3,7 @@ import React from 'react';
 import type { IUser } from '@growi/core';
 import type { IUser } from '@growi/core';
 import Link from 'next/link';
 import Link from 'next/link';
 
 
-type UsernameProps = {
- user?: IUser,
-}
-
-export const Username = (props: UsernameProps): JSX.Element => {
-  const { user } = props;
+export const Username: React.FC<{ user?: IUser }> = ({ user }): JSX.Element => {
 
 
   if (user == null) {
   if (user == null) {
     return <span>anyone</span>;
     return <span>anyone</span>;

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


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


+ 4 - 4
apps/app/src/features/growi-plugin/components/Admin/PluginsExtensionPageContents/PluginInstallerForm.tsx → apps/app/src/features/growi-plugin/client/components/Admin/PluginsExtensionPageContents/PluginInstallerForm.tsx

@@ -5,11 +5,11 @@ import { useTranslation } from 'next-i18next';
 import { apiv3Post } from '~/client/util/apiv3-client';
 import { apiv3Post } from '~/client/util/apiv3-client';
 import { toastSuccess, toastError } from '~/client/util/toastr';
 import { toastSuccess, toastError } from '~/client/util/toastr';
 
 
-import type { IGrowiPluginOrigin } from '../../../interfaces';
-import { useSWRxPlugins } from '../../../stores/growi-plugin';
+import type { IGrowiPluginOrigin } from '../../../../interfaces';
+import { useSWRxAdminPlugins } from '../../../stores/admin-plugins';
 
 
 export const PluginInstallerForm = (): JSX.Element => {
 export const PluginInstallerForm = (): JSX.Element => {
-  const { mutate } = useSWRxPlugins();
+  const { mutate } = useSWRxAdminPlugins();
   const { t } = useTranslation('admin');
   const { t } = useTranslation('admin');
 
 
   const submitHandler = useCallback(async(e) => {
   const submitHandler = useCallback(async(e) => {
@@ -25,7 +25,7 @@ export const PluginInstallerForm = (): JSX.Element => {
 
 
     const pluginInstallerForm: IGrowiPluginOrigin = {
     const pluginInstallerForm: IGrowiPluginOrigin = {
       url,
       url,
-      ghBranch,
+      ghBranch: ghBranch || 'main',
       // ghTag,
       // ghTag,
     };
     };
 
 

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

@@ -3,7 +3,7 @@ import React from 'react';
 import { useTranslation } from 'next-i18next';
 import { useTranslation } from 'next-i18next';
 import { Spinner } from 'reactstrap';
 import { Spinner } from 'reactstrap';
 
 
-import { useSWRxPlugins } from '../../../stores/growi-plugin';
+import { useSWRxAdminPlugins } from '../../../stores/admin-plugins';
 
 
 import { PluginCard } from './PluginCard';
 import { PluginCard } from './PluginCard';
 import { PluginInstallerForm } from './PluginInstallerForm';
 import { PluginInstallerForm } from './PluginInstallerForm';
@@ -19,7 +19,7 @@ const Loading = (): JSX.Element => {
 export const PluginsExtensionPageContents = (): JSX.Element => {
 export const PluginsExtensionPageContents = (): JSX.Element => {
   const { t } = useTranslation('admin');
   const { t } = useTranslation('admin');
 
 
-  const { data, mutate } = useSWRxPlugins();
+  const { data, mutate } = useSWRxAdminPlugins();
 
 
   return (
   return (
     <div>
     <div>

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


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

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

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

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

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

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

+ 24 - 0
apps/app/src/features/growi-plugin/client/stores/admin-plugins.tsx

@@ -0,0 +1,24 @@
+import useSWR, { SWRResponse } from 'swr';
+
+import { apiv3Get } from '~/client/util/apiv3-client';
+
+import type { IGrowiPluginHasId } from '../../interfaces';
+
+type Plugins = {
+  plugins: IGrowiPluginHasId[]
+}
+
+export const useSWRxAdminPlugins = (): SWRResponse<Plugins, Error> => {
+  return useSWR(
+    '/plugins',
+    async(endpoint) => {
+      try {
+        const res = await apiv3Get<Plugins>(endpoint);
+        return res.data;
+      }
+      catch (err) {
+        throw new Error(err);
+      }
+    },
+  );
+};

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


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

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

+ 14 - 11
apps/app/src/features/growi-plugin/interfaces/growi-plugin.ts

@@ -1,12 +1,5 @@
-import { GrowiThemeMetadata, HasObjectId } from '@growi/core';
-
-export const GrowiPluginResourceType = {
-  Template: 'template',
-  Style: 'style',
-  Theme: 'theme',
-  Script: 'script',
-} as const;
-export type GrowiPluginResourceType = typeof GrowiPluginResourceType[keyof typeof GrowiPluginResourceType];
+import type { GrowiPluginType, GrowiThemeMetadata, HasObjectId } from '@growi/core';
+import type { TemplateSummary } from '@growi/pluginkit/dist/v4';
 
 
 export type IGrowiPluginOrigin = {
 export type IGrowiPluginOrigin = {
   url: string,
   url: string,
@@ -24,13 +17,23 @@ export type IGrowiPlugin<M extends IGrowiPluginMeta = IGrowiPluginMeta> = {
 
 
 export type IGrowiPluginMeta = {
 export type IGrowiPluginMeta = {
   name: string,
   name: string,
-  types: GrowiPluginResourceType[],
+  types: GrowiPluginType[],
   desc?: string,
   desc?: string,
   author?: string,
   author?: string,
 }
 }
 
 
 export type IGrowiThemePluginMeta = IGrowiPluginMeta & {
 export type IGrowiThemePluginMeta = IGrowiPluginMeta & {
-  themes: GrowiThemeMetadata[]
+  themes: GrowiThemeMetadata[],
+}
+
+export type IGrowiTemplatePluginMeta = IGrowiPluginMeta & {
+  templateSummaries: TemplateSummary[],
 }
 }
 
 
+export type IGrowiPluginMetaByType<T extends GrowiPluginType = any> = T extends 'theme'
+  ? IGrowiThemePluginMeta
+  : T extends 'template'
+    ? IGrowiTemplatePluginMeta
+    : IGrowiPluginMeta;
+
 export type IGrowiPluginHasId = IGrowiPlugin & HasObjectId;
 export type IGrowiPluginHasId = IGrowiPlugin & HasObjectId;

+ 5 - 0
apps/app/src/features/growi-plugin/server/consts/index.ts

@@ -0,0 +1,5 @@
+import { resolveFromRoot } from '~/utils/project-dir-utils';
+
+export const PLUGIN_STORING_PATH = resolveFromRoot('tmp/plugins');
+
+export const PLUGIN_EXPRESS_STATIC_DIR = '/static/plugins';

+ 157 - 0
apps/app/src/features/growi-plugin/server/models/growi-plugin.integ.ts

@@ -0,0 +1,157 @@
+import { GrowiPluginType } from '@growi/core';
+
+import { GrowiPlugin } from './growi-plugin';
+
+describe('GrowiPlugin find methods', () => {
+
+  beforeAll(async() => {
+    await GrowiPlugin.insertMany([
+      {
+        isEnabled: false,
+        installedPath: 'weseek/growi-plugin-unenabled1',
+        organizationName: 'weseek',
+        origin: {
+          url: 'https://github.com/weseek/growi-plugin-unenabled1',
+        },
+        meta: {
+          name: '@growi/growi-plugin-unenabled1',
+          types: [GrowiPluginType.Script],
+        },
+      },
+      {
+        isEnabled: false,
+        installedPath: 'weseek/growi-plugin-unenabled2',
+        organizationName: 'weseek',
+        origin: {
+          url: 'https://github.com/weseek/growi-plugin-unenabled2',
+        },
+        meta: {
+          name: '@growi/growi-plugin-unenabled2',
+          types: [GrowiPluginType.Template],
+        },
+      },
+      {
+        isEnabled: true,
+        installedPath: 'weseek/growi-plugin-example1',
+        organizationName: 'weseek',
+        origin: {
+          url: 'https://github.com/weseek/growi-plugin-example1',
+        },
+        meta: {
+          name: '@growi/growi-plugin-example1',
+          types: [GrowiPluginType.Script],
+        },
+      },
+      {
+        isEnabled: true,
+        installedPath: 'weseek/growi-plugin-example2',
+        organizationName: 'weseek',
+        origin: {
+          url: 'https://github.com/weseek/growi-plugin-example2',
+        },
+        meta: {
+          name: '@growi/growi-plugin-example2',
+          types: [GrowiPluginType.Template],
+        },
+      },
+    ]);
+  });
+
+  afterAll(async() => {
+    await GrowiPlugin.deleteMany({});
+  });
+
+  describe.concurrent('.findEnabledPlugins', () => {
+    it('shoud returns documents which isEnabled is true', async() => {
+      // when
+      const results = await GrowiPlugin.findEnabledPlugins();
+
+      const pluginNames = results.map(p => p.meta.name);
+
+      // then
+      expect(results.length === 2).toBeTruthy();
+      expect(pluginNames.includes('@growi/growi-plugin-example1')).toBeTruthy();
+      expect(pluginNames.includes('@growi/growi-plugin-example2')).toBeTruthy();
+    });
+  });
+
+  describe.concurrent('.findEnabledPluginsByType', () => {
+    it("shoud returns documents which type is 'template'", async() => {
+      // when
+      const results = await GrowiPlugin.findEnabledPluginsByType(GrowiPluginType.Template);
+
+      const pluginNames = results.map(p => p.meta.name);
+
+      // then
+      expect(results.length === 1).toBeTruthy();
+      expect(pluginNames.includes('@growi/growi-plugin-example2')).toBeTruthy();
+    });
+  });
+
+});
+
+
+describe('GrowiPlugin activate/deactivate', () => {
+
+  beforeAll(async() => {
+    await GrowiPlugin.insertMany([
+      {
+        isEnabled: false,
+        installedPath: 'weseek/growi-plugin-example1',
+        organizationName: 'weseek',
+        origin: {
+          url: 'https://github.com/weseek/growi-plugin-example1',
+        },
+        meta: {
+          name: '@growi/growi-plugin-example1',
+          types: [GrowiPluginType.Script],
+        },
+      },
+    ]);
+  });
+
+  afterAll(async() => {
+    await GrowiPlugin.deleteMany({});
+  });
+
+  describe('.activatePlugin', () => {
+    it('shoud update the property "isEnabled" to true', async() => {
+      // setup
+      const plugin = await GrowiPlugin.findOne({});
+      assert(plugin != null);
+
+      expect(plugin.isEnabled).toBeFalsy(); // isEnabled: false
+
+      // when
+      const result = await GrowiPlugin.activatePlugin(plugin._id);
+      const pluginAfterActivated = await GrowiPlugin.findOne({ _id: plugin._id });
+
+      // then
+      expect(result).toEqual('@growi/growi-plugin-example1'); // equals to meta.name
+      expect(pluginAfterActivated).not.toBeNull();
+      assert(pluginAfterActivated != null);
+      expect(pluginAfterActivated.isEnabled).toBeTruthy(); // isEnabled: true
+    });
+  });
+
+  describe('.deactivatePlugin', () => {
+    it('shoud update the property "isEnabled" to true', async() => {
+      // setup
+      const plugin = await GrowiPlugin.findOne({});
+      assert(plugin != null);
+
+      expect(plugin.isEnabled).toBeTruthy(); // isEnabled: true
+
+      // when
+      const result = await GrowiPlugin.deactivatePlugin(plugin._id);
+      const pluginAfterActivated = await GrowiPlugin.findOne({ _id: plugin._id });
+
+      // then
+      expect(result).toEqual('@growi/growi-plugin-example1'); // equals to meta.name
+      expect(pluginAfterActivated).not.toBeNull();
+      assert(pluginAfterActivated != null);
+      expect(pluginAfterActivated.isEnabled).toBeFalsy(); // isEnabled: false
+    });
+  });
+
+});

+ 17 - 28
apps/app/src/features/growi-plugin/models/growi-plugin.ts → apps/app/src/features/growi-plugin/server/models/growi-plugin.ts

@@ -1,48 +1,35 @@
-import { GrowiThemeMetadata, GrowiThemeSchemeType } from '@growi/core';
+import { GrowiPluginType } from '@growi/core';
 import {
 import {
   Schema, type Model, type Document, type Types,
   Schema, type Model, type Document, type Types,
 } from 'mongoose';
 } from 'mongoose';
 
 
 import { getOrCreateModel } from '~/server/util/mongoose-utils';
 import { getOrCreateModel } from '~/server/util/mongoose-utils';
 
 
-import { GrowiPluginResourceType } from '../interfaces';
 import type {
 import type {
-  IGrowiPlugin, IGrowiPluginMeta, IGrowiPluginOrigin, IGrowiThemePluginMeta,
-} from '../interfaces';
+  IGrowiPlugin, IGrowiPluginMeta, IGrowiPluginMetaByType, IGrowiPluginOrigin, IGrowiTemplatePluginMeta, IGrowiThemePluginMeta,
+} from '../../interfaces';
 
 
-export interface IGrowiPluginDocument extends IGrowiPlugin, Document {
+export interface IGrowiPluginDocument<M extends IGrowiPluginMeta = IGrowiPluginMeta> extends IGrowiPlugin<M>, Document {
+  metaJson: IGrowiPluginMeta & IGrowiThemePluginMeta & IGrowiTemplatePluginMeta,
 }
 }
 export interface IGrowiPluginModel extends Model<IGrowiPluginDocument> {
 export interface IGrowiPluginModel extends Model<IGrowiPluginDocument> {
-  findEnabledPlugins(): Promise<IGrowiPlugin[]>
-  findEnabledPluginsIncludingAnyTypes(includingTypes: GrowiPluginResourceType[]): Promise<IGrowiPlugin[]>
+  findEnabledPlugins(): Promise<IGrowiPluginDocument[]>
+  findEnabledPluginsByType<T extends GrowiPluginType>(type: T): Promise<IGrowiPluginDocument<IGrowiPluginMetaByType<T>>[]>
   activatePlugin(id: Types.ObjectId): Promise<string>
   activatePlugin(id: Types.ObjectId): Promise<string>
   deactivatePlugin(id: Types.ObjectId): Promise<string>
   deactivatePlugin(id: Types.ObjectId): Promise<string>
 }
 }
 
 
-const growiThemeMetadataSchema = new Schema<GrowiThemeMetadata>({
-  name: { type: String, required: true },
-  manifestKey: { type: String, required: true },
-  schemeType: {
-    type: String,
-    enum: GrowiThemeSchemeType,
-    require: true,
-  },
-  bg: { type: String, required: true },
-  topbar: { type: String, required: true },
-  sidebar: { type: String, required: true },
-  accent: { type: String, required: true },
-});
-
-const growiPluginMetaSchema = new Schema<IGrowiPluginMeta|IGrowiThemePluginMeta>({
+const growiPluginMetaSchema = new Schema<IGrowiPluginMeta & IGrowiThemePluginMeta & IGrowiTemplatePluginMeta>({
   name: { type: String, required: true },
   name: { type: String, required: true },
   types: {
   types: {
     type: [String],
     type: [String],
-    enum: GrowiPluginResourceType,
+    enum: GrowiPluginType,
     require: true,
     require: true,
   },
   },
   desc: { type: String },
   desc: { type: String },
   author: { type: String },
   author: { type: String },
-  themes: [growiThemeMetadataSchema],
+  themes: [Map],
+  templateSummaries: [Map],
 });
 });
 
 
 const growiPluginOriginSchema = new Schema<IGrowiPluginOrigin>({
 const growiPluginOriginSchema = new Schema<IGrowiPluginOrigin>({
@@ -60,14 +47,16 @@ const growiPluginSchema = new Schema<IGrowiPluginDocument, IGrowiPluginModel>({
 });
 });
 
 
 growiPluginSchema.statics.findEnabledPlugins = async function(): Promise<IGrowiPlugin[]> {
 growiPluginSchema.statics.findEnabledPlugins = async function(): Promise<IGrowiPlugin[]> {
-  return this.find({ isEnabled: true });
+  return this.find({ isEnabled: true }).lean();
 };
 };
 
 
-growiPluginSchema.statics.findEnabledPluginsIncludingAnyTypes = async function(types: GrowiPluginResourceType[]): Promise<IGrowiPlugin[]> {
+growiPluginSchema.statics.findEnabledPluginsByType = async function<T extends GrowiPluginType>(
+    type: T,
+): Promise<IGrowiPlugin<IGrowiPluginMetaByType<T>>[]> {
   return this.find({
   return this.find({
     isEnabled: true,
     isEnabled: true,
-    'meta.types': { $in: types },
-  });
+    'meta.types': { $in: type },
+  }).lean();
 };
 };
 
 
 growiPluginSchema.statics.activatePlugin = async function(id: Types.ObjectId): Promise<string> {
 growiPluginSchema.statics.activatePlugin = async function(id: Types.ObjectId): Promise<string> {

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


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


+ 4 - 2
apps/app/src/features/growi-plugin/models/vo/github-url.ts → apps/app/src/features/growi-plugin/server/models/vo/github-url.ts

@@ -1,3 +1,5 @@
+import sanitize from 'sanitize-filename';
+
 // https://regex101.com/r/fK2rV3/1
 // https://regex101.com/r/fK2rV3/1
 const githubReposIdPattern = new RegExp(/^\/([^/]+)\/([^/]+)$/);
 const githubReposIdPattern = new RegExp(/^\/([^/]+)\/([^/]+)$/);
 
 
@@ -44,8 +46,8 @@ export class GitHubUrl {
 
 
     this._branchName = branchName;
     this._branchName = branchName;
 
 
-    this._organizationName = matched[1];
-    this._reposName = matched[2];
+    this._organizationName = sanitize(matched[1]);
+    this._reposName = sanitize(matched[2]);
   }
   }
 
 
 }
 }

+ 2 - 2
apps/app/src/features/growi-plugin/routes/growi-plugins.ts → apps/app/src/features/growi-plugin/server/routes/apiv3/admin/index.ts

@@ -5,8 +5,8 @@ import mongoose from 'mongoose';
 import Crowi from '~/server/crowi';
 import Crowi from '~/server/crowi';
 import { ApiV3Response } from '~/server/routes/apiv3/interfaces/apiv3-response';
 import { ApiV3Response } from '~/server/routes/apiv3/interfaces/apiv3-response';
 
 
-import { GrowiPlugin } from '../models';
-import { growiPluginService } from '../services';
+import { GrowiPlugin } from '../../../models';
+import { growiPluginService } from '../../../services';
 
 
 
 
 const ObjectID = mongoose.Types.ObjectId;
 const ObjectID = mongoose.Types.ObjectId;

Some files were not shown because too many files changed in this diff