Răsfoiți Sursa

Merge branch 'master' into imprv/122419-bookmark-sidebar-design

ryoji-s 2 ani în urmă
părinte
comite
40d964335e
100 a modificat fișierele cu 1564 adăugiri și 749 ștergeri
  1. 2 2
      .devcontainer/Dockerfile
  2. 2 0
      .github/release-drafter.yml
  3. 2 0
      .github/workflows/ci-app-prod.yml
  4. 3 2
      .github/workflows/draft-release.yml
  5. 2 3
      .github/workflows/release-slackbot-proxy.yml
  6. 3 2
      .github/workflows/release.yml
  7. 6 13
      .github/workflows/reusable-app-prod.yml
  8. 4 4
      .mergify.yml
  9. 105 1
      CHANGELOG.md
  10. 2 1
      apps/app/cypress.config.ts
  11. 1 0
      apps/app/docker/Dockerfile
  12. 0 1
      apps/app/docker/Dockerfile.dockerignore
  13. 9 6
      apps/app/package.json
  14. 1 1
      apps/app/public/static/locales/en_US/translation.json
  15. 3 0
      apps/app/public/static/locales/ja_JP/commons.json
  16. 1 1
      apps/app/public/static/locales/ja_JP/translation.json
  17. 3 0
      apps/app/public/static/locales/zh_CN/commons.json
  18. 1 1
      apps/app/public/static/locales/zh_CN/translation.json
  19. 0 34
      apps/app/src/client/services/ShowPageAccessoriesModal.tsx
  20. 0 166
      apps/app/src/components/Admin/Common/AdminNavigation.jsx
  21. 167 0
      apps/app/src/components/Admin/Common/AdminNavigation.tsx
  22. 3 2
      apps/app/src/components/Admin/Notification/ManageGlobalNotification.tsx
  23. 1 1
      apps/app/src/components/Admin/Security/ShareLinkSetting.tsx
  24. 4 1
      apps/app/src/components/Admin/UserGroup/UserGroupForm.tsx
  25. 3 2
      apps/app/src/components/Admin/UserGroup/UserGroupTable.tsx
  26. 2 2
      apps/app/src/components/Admin/UserGroupDetail/UserGroupDetailPage.tsx
  27. 6 2
      apps/app/src/components/Admin/UserGroupDetail/UserGroupPageList.tsx
  28. 103 10
      apps/app/src/components/Admin/Users/PasswordResetModal.jsx
  29. 3 2
      apps/app/src/components/Bookmarks/BookmarkFolderItem.tsx
  30. 3 3
      apps/app/src/components/Bookmarks/BookmarkFolderMenu.tsx
  31. 10 6
      apps/app/src/components/Bookmarks/BookmarkFolderTree.tsx
  32. 42 16
      apps/app/src/components/Bookmarks/BookmarkItem.tsx
  33. 11 5
      apps/app/src/components/Comments.tsx
  34. 1 6
      apps/app/src/components/DescendantsPageList.tsx
  35. 6 16
      apps/app/src/components/InstallerForm.tsx
  36. 6 13
      apps/app/src/components/InvitedForm.tsx
  37. 1 1
      apps/app/src/components/Layout/AdminLayout.tsx
  38. 4 2
      apps/app/src/components/Layout/BasicLayout.tsx
  39. 46 9
      apps/app/src/components/LoginForm.tsx
  40. 2 2
      apps/app/src/components/Me/DisassociateModal.tsx
  41. 19 17
      apps/app/src/components/Navbar/GrowiContextualSubNavigation.tsx
  42. 2 3
      apps/app/src/components/Navbar/PageEditorModeManager.jsx
  43. 6 3
      apps/app/src/components/Navbar/SubNavButtons.tsx
  44. 1 1
      apps/app/src/components/NotCreatablePage.tsx
  45. 14 9
      apps/app/src/components/Page/PageView.tsx
  46. 10 11
      apps/app/src/components/Page/TagEditModal.tsx
  47. 0 0
      apps/app/src/components/PageAccessoriesModal/PageAccessoriesModal.module.scss
  48. 25 57
      apps/app/src/components/PageAccessoriesModal/PageAccessoriesModal.tsx
  49. 2 2
      apps/app/src/components/PageAccessoriesModal/PageAttachment.tsx
  50. 8 12
      apps/app/src/components/PageAccessoriesModal/PageHistory.tsx
  51. 1 3
      apps/app/src/components/PageAccessoriesModal/ShareLink/ShareLink.tsx
  52. 0 0
      apps/app/src/components/PageAccessoriesModal/ShareLink/ShareLinkForm.tsx
  53. 1 1
      apps/app/src/components/PageAccessoriesModal/ShareLink/ShareLinkList.tsx
  54. 1 0
      apps/app/src/components/PageAccessoriesModal/ShareLink/index.ts
  55. 79 0
      apps/app/src/components/PageAccessoriesModal/hooks.tsx
  56. 1 0
      apps/app/src/components/PageAccessoriesModal/index.ts
  57. 13 10
      apps/app/src/components/PageAlert/TrashPageAlert.tsx
  58. 20 14
      apps/app/src/components/PageComment.tsx
  59. 5 3
      apps/app/src/components/PageComment/CommentEditor.tsx
  60. 15 2
      apps/app/src/components/PageEditor.tsx
  61. 21 21
      apps/app/src/components/PageList/PageListItemL.tsx
  62. 2 1
      apps/app/src/components/PageList/PageListItemS.tsx
  63. 10 2
      apps/app/src/components/PageSideContents.tsx
  64. 2 3
      apps/app/src/components/PrivateLegacyPages.tsx
  65. 1 1
      apps/app/src/components/ReactMarkdownComponents/Header.tsx
  66. 20 7
      apps/app/src/components/ReactMarkdownComponents/NextLink.tsx
  67. 1 2
      apps/app/src/components/SearchPage/SearchPageBase.tsx
  68. 0 1
      apps/app/src/components/SearchPage/SearchResultList.tsx
  69. 7 7
      apps/app/src/components/ShareLinkPageView.tsx
  70. 1 1
      apps/app/src/components/Sidebar.tsx
  71. 5 11
      apps/app/src/components/Sidebar/SidebarNav.tsx
  72. 7 0
      apps/app/src/components/TemplateModal/TemplateModal.module.scss
  73. 236 59
      apps/app/src/components/TemplateModal/TemplateModal.tsx
  74. 9 15
      apps/app/src/components/TemplateModal/use-formatter.spec.tsx
  75. 5 8
      apps/app/src/components/TemplateModal/use-formatter.tsx
  76. 0 0
      apps/app/src/features/growi-plugin/client/components/Admin/PluginsExtensionPageContents/PluginCard.module.scss
  77. 0 0
      apps/app/src/features/growi-plugin/client/components/Admin/PluginsExtensionPageContents/PluginCard.tsx
  78. 4 4
      apps/app/src/features/growi-plugin/client/components/Admin/PluginsExtensionPageContents/PluginInstallerForm.tsx
  79. 2 2
      apps/app/src/features/growi-plugin/client/components/Admin/PluginsExtensionPageContents/PluginsExtensionPageContents.tsx
  80. 0 0
      apps/app/src/features/growi-plugin/client/components/Admin/PluginsExtensionPageContents/index.ts
  81. 1 0
      apps/app/src/features/growi-plugin/client/components/Admin/index.ts
  82. 1 1
      apps/app/src/features/growi-plugin/client/components/GrowiPluginsActivator.tsx
  83. 1 0
      apps/app/src/features/growi-plugin/client/components/index.ts
  84. 24 0
      apps/app/src/features/growi-plugin/client/stores/admin-plugins.tsx
  85. 0 0
      apps/app/src/features/growi-plugin/client/utils/growi-facade-utils.ts
  86. 0 1
      apps/app/src/features/growi-plugin/components/index.ts
  87. 14 11
      apps/app/src/features/growi-plugin/interfaces/growi-plugin.ts
  88. 5 0
      apps/app/src/features/growi-plugin/server/consts/index.ts
  89. 157 0
      apps/app/src/features/growi-plugin/server/models/growi-plugin.integ.ts
  90. 17 28
      apps/app/src/features/growi-plugin/server/models/growi-plugin.ts
  91. 0 0
      apps/app/src/features/growi-plugin/server/models/index.ts
  92. 0 0
      apps/app/src/features/growi-plugin/server/models/vo/github-url.spec.ts
  93. 4 2
      apps/app/src/features/growi-plugin/server/models/vo/github-url.ts
  94. 2 2
      apps/app/src/features/growi-plugin/server/routes/apiv3/admin/index.ts
  95. 11 0
      apps/app/src/features/growi-plugin/server/services/growi-plugin/generate-template-plugin-meta.ts
  96. 12 0
      apps/app/src/features/growi-plugin/server/services/growi-plugin/generate-theme-plugin-meta.ts
  97. 94 0
      apps/app/src/features/growi-plugin/server/services/growi-plugin/growi-plugin.integ.ts
  98. 90 72
      apps/app/src/features/growi-plugin/server/services/growi-plugin/growi-plugin.ts
  99. 0 0
      apps/app/src/features/growi-plugin/server/services/growi-plugin/index.ts
  100. 1 0
      apps/app/src/features/growi-plugin/server/services/index.ts

+ 2 - 2
.devcontainer/Dockerfile

@@ -39,9 +39,9 @@ RUN wget -q -O - https://dl.google.com/linux/linux_signing_key.pub | sudo apt-ke
 RUN apt-get update \
     && apt-get -y install --no-install-recommends git-lfs \
 
-    # Uncomment below lines to install Chrome and libs for Cypress
+    # Uncomment below lines to install Chromium
     # --- works only on AMD64 ---
-    # && apt-get -y install --no-install-recommends google-chrome-stable \
+    # && apt-get -y install --no-install-recommends chromium \
     #    libgtk2.0-0 libgtk-3-0 libgbm-dev libnotify-dev libgconf-2-4 libnss3 libxss1 libasound2 libxtst6 xauth xvfb fonts-noto-cjk \
 
     # Clean up

+ 2 - 0
.github/release-drafter.yml

@@ -1,3 +1,5 @@
+# Filter previous releases to consider only those with the target matching commitish.
+filter-by-commitish: true
 categories:
   - title: 'BREAKING CHANGES'
     labels:

+ 2 - 0
.github/workflows/ci-app-prod.yml

@@ -4,6 +4,7 @@ on:
   push:
     branches:
       - master
+      - dev/6.*.x
     paths:
       - .github/workflows/ci-app-prod.yml
       - .github/workflows/reusable-app-prod.yml
@@ -18,6 +19,7 @@ on:
   pull_request:
     branches:
       - master
+      - dev/6.*.x
     types: [opened, reopened, synchronize]
     paths:
       - .github/workflows/ci-app-prod.yml

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

@@ -4,6 +4,8 @@ on:
   push:
     branches:
       - master
+      - dev/*.*.*
+
 
 concurrency:
   group: ${{ github.workflow }}-${{ github.ref }}
@@ -27,7 +29,6 @@ jobs:
         uses: myrotvorets/info-from-package-json-action@1.2.0
         id: package-json
 
-      # Drafts your next Release notes as Pull Requests are merged into "master"
       - uses: release-drafter/release-drafter@v5
         id: release-drafter
         with:
@@ -60,7 +61,7 @@ jobs:
         env:
           GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
           GIT_PR_RELEASE_BRANCH_PRODUCTION: release/current
-          GIT_PR_RELEASE_BRANCH_STAGING: master
+          GIT_PR_RELEASE_BRANCH_STAGING: ${{ github.ref_name }}
           GIT_PR_RELEASE_TEMPLATE: .github/git-pr-release-template.erb
           GIT_PR_RELEASE_TITLE: Release v${{ steps.release-version.outputs.RELEASE_VERSION }}
           GIT_PR_RELEASE_BODY: ${{ needs.update-release-draft.outputs.RELEASE_DRAFT_BODY }}

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

@@ -112,9 +112,8 @@ jobs:
         yarn --frozen-lockfile
 
     - name: Bump versions for next RC
-      working-directory: ./apps/slackbot-proxy
       run: |
-        yarn version --no-git-tag-version --prepatch --preid=slackbot-proxy
+        turbo run version --filter=@growi/slackbot-proxy -- --prerelease
 
     - name: Retrieve information from package.json
       uses: myrotvorets/info-from-package-json-action@1.2.0
@@ -136,6 +135,6 @@ jobs:
         source_branch: support/prepare-v${{ steps.package-json.outputs.packageVersion }}
         destination_branch: master
         pr_title: Prepare v${{ steps.package-json.outputs.packageVersion }}
-        pr_label: 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}"
         github_token: ${{ secrets.GITHUB_TOKEN }}

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

@@ -35,7 +35,7 @@ jobs:
 
     - name: Bump versions
       run: |
-        turbo run bump-versions:patch
+        turbo run version --filter=@growi/app -- --patch
         yarn upgrade --scope=@growi
         sh ./apps/app/bin/github-actions/update-readme.sh
 
@@ -98,7 +98,8 @@ jobs:
 
     - name: Bump versions for next RC
       run: |
-        yarn bump-versions:rc
+        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

+ 6 - 13
.github/workflows/reusable-app-prod.yml

@@ -59,15 +59,17 @@ jobs:
         yarn --frozen-lockfile
 
     - name: Restore dist
-      uses: actions/cache/restore@v3
+      uses: actions/cache@v3
       with:
         path: |
+          node_modules/.cache/turbo
           **/.turbo
           **/dist
           ${{ github.workspace }}/apps/app/.next
-        key: dist-app-prod-${{ runner.OS }}-node${{ matrix.node-version }}-${{ hashFiles('node_modules/.cache/turbo/*-meta.json') }}
+        key: dist-app-prod-${{ runner.OS }}-node${{ inputs.node-version }}-${{ github.ref_name }}-${{ github.sha }}
         restore-keys: |
-          dist-app-prod-${{ runner.OS }}-node${{ matrix.node-version }}-
+          dist-app-prod-${{ runner.OS }}-node${{ inputs.node-version }}-${{ github.ref_name }}-
+          dist-app-prod-${{ runner.OS }}-node${{ inputs.node-version }}-
 
     - name: Build
       working-directory: ./apps/app
@@ -117,15 +119,6 @@ jobs:
         isCompactMode: true
         url: ${{ secrets.SLACK_WEBHOOK_URL }}
 
-    - name: Cache dist
-      uses: actions/cache/save@v3
-      with:
-        path: |
-          **/.turbo
-          **/dist
-          ${{ github.workspace }}/apps/app/.next
-        key: dist-app-prod-${{ runner.OS }}-node${{ matrix.node-version }}-${{ hashFiles('node_modules/.cache/turbo/*-meta.json') }}
-
 
   launch-prod:
     needs: [build-prod]
@@ -312,7 +305,7 @@ jobs:
     - name: Cypress Run
       uses: cypress-io/github-action@v5
       with:
-        browser: chrome
+        browser: chromium
         working-directory: ./apps/app
         spec: '${{ steps.determine-spec-exp.outputs.value }}'
         install: false

+ 4 - 4
.mergify.yml

@@ -3,11 +3,11 @@ pull_request_rules:
     conditions:
       - author = dependabot[bot]
       - '#approved-reviews-by >= 1'
-      - check-success = "lint (16.x)"
-      - check-success = "test (16.x)"
-      - check-success = "launch-dev (16.x)"
-      - check-success = "test-prod-node14 / launch-prod"
+      - check-success = "lint (18.x)"
+      - check-success = "test (18.x)"
+      - check-success = "launch-dev (18.x)"
       - check-success = "test-prod-node16 / launch-prod"
+      - check-success = "test-prod-node18 / launch-prod"
     actions:
       merge:
         method: merge

+ 105 - 1
CHANGELOG.md

@@ -1,9 +1,113 @@
 # Changelog
 
-## [Unreleased](https://github.com/weseek/growi/compare/v6.1.3...HEAD)
+## [Unreleased](https://github.com/weseek/growi/compare/v6.1.8...HEAD)
 
 *Please do not manually update this file. We've automated the process.*
 
+## [v6.1.8](https://github.com/weseek/growi/compare/v6.1.7...v6.1.8) - 2023-07-24
+
+### 💎 Features
+
+- feat: Add plugin badge to TemplateModal's list of templates (#7897) @TatsuyaIse
+
+### 🚀 Improvement
+
+- imprv: Replace isAdmin with usersAdminHooks (#7840) @WNomunomu
+- imprv: Show alert for trashed pages only when the page is not empty (#7903) @TatsuyaIse
+- imprv: Template name truncation (#7898) @TatsuyaIse
+
+### 🐛 Bug Fixes
+
+- fix: Cancel a comment will cancel all comments (#7804) @mudana-grune
+
+### 🧰 Maintenance
+
+- ci(deps-dev): bump vite from 4.3.8 to 4.4.5 (#7901) @dependabot
+- ci(deps): bump semver from 5.7.1 to 5.7.2 (#7867) @dependabot
+- ci(deps): bump mongoose from 6.11.1 to 6.11.3 (#7891) @dependabot
+- ci(deps): bump word-wrap from 1.2.3 to 1.2.4 (#7892) @dependabot
+
+## [v6.1.7](https://github.com/weseek/growi/compare/v6.1.6...v6.1.7) - 2023-07-19
+
+### 💎 Features
+
+- feat: Authentication settings cannot be disabled if there will be no administrator user available to log in (#7761) @mudana-grune
+
+### 🚀 Improvement
+
+- imprv: Show spinner while installing and logging-in (#7823) @soumaeda
+- imprv: Routing with next link (#7880) @yuki-takei
+
+### 🐛 Bug Fixes
+
+- fix: Auto popup PageAccessoriesModal and show page history (#7888) @yuki-takei
+- fix: Auto-scroll does not work when accessing the page when the header string is CJK (#7882) @yuki-takei
+- fix: Avoid unnecessary next routing (#7863) @miya
+- fix: Work put back page on bookmark sidebar (#7698) @mudana-grune
+
+### 🧰 Maintenance
+
+- support: Render SearchPageBase in CSR (#7889) @yuki-takei
+
+## [v6.1.6](https://github.com/weseek/growi/compare/v6.1.5...v6.1.6) - 2023-07-12
+
+### 🐛 Bug Fixes
+
+- fix: Revert current page mutation and add workaround for saving page (#7877) @yuki-takei
+- fix: The official docker image missed preset-templates (#7865) @yuki-takei
+- fix: SSL connection error to Elasticsearch8 using self certificate (#7818) @miya
+
+## [v6.1.5](https://github.com/weseek/growi/compare/v6.1.4...v6.1.5) - 2023-07-10
+
+### 💎 Features
+
+- feat: Rich Attachment (#7534) @jam411
+- feat: Plugin kit (#7830) @yuki-takei
+- feat: Deciding whether to use SSR based on the volume of latestRevisionBodyLength (#7772) @miya
+
+### 🚀 Improvement
+
+- imprv: Load templates from the server 2 (#7850) @yuki-takei
+- imprv: Improve release parent group button (#7838) @WNomunomu
+- imprv: Load templates from the server (#7842) @yuki-takei
+- imprv: Able to send new passsword by email (#7758) @soumaeda
+- imprv: Convert jsx into tsx (#7832) @WNomunomu
+- imprv: After reset password footer modal design (#7790) @soumaeda
+- imprv: Update email alert (#7771) @WNomunomu
+- imprv: Can use normal browser transition in searching page (#7826) @yuki-takei
+- imprv: Show tooltip when copying password (#7800) @soumaeda
+
+### 🐛 Bug Fixes
+
+- fix(lsx): Except option (#7855) @yuki-takei
+- fix: Page body is not displayed when skipSSR (#7849) @miya
+- fix: When uploading an attachment file to a new page and pressing the update button, an error occurs (#7844) @miya
+- fix: Editing user group settings (#7827) @WNomunomu
+- fix: Handsontable not display full screen (#7784) @mudana-grune
+- fix: Brand logo fill color transition (#7828) @yuki-takei
+- fix: Email body of global notification is not displayed (#7824) @jam411
+- fix(lsx): Prefix is not uniquely determined by usage (#7815) @yuki-takei
+
+### 🧰 Maintenance
+
+- support: Dependencies specification for local packages (#7809) @yuki-takei
+
+## [v6.1.4](https://github.com/weseek/growi/compare/v6.1.3...v6.1.4) - 2023-06-12
+
+### 💎 Features
+
+- feat(plugin): Specify repository branch name (#7783) @yuki-takei
+
+### 🚀 Improvement
+
+- imprv: Suppress unnecessary bookmark API requests (#7798) @yuki-takei
+
+### 🐛 Bug Fixes
+
+- fix: Bookmarks mutation for the current user (#7797) @yuki-takei
+- fix: Slack channels data for User Triggered Notification is not loaded (#7794) @yuki-takei
+- fix: The input of the editor is cleared when an attachment is added when a new page editing (#7788) @miya
+
 ## [v6.1.3](https://github.com/weseek/growi/compare/v6.1.2...v6.1.3) - 2023-06-07
 
 ### 💎 Features

+ 2 - 1
apps/app/cypress.config.ts

@@ -9,7 +9,7 @@ export default defineConfig({
       // change screen size
       // see: https://docs.cypress.io/api/plugins/browser-launch-api#Set-screen-size-when-running-headless
       on('before:browser:launch', (browser, launchOptions) => {
-        if (browser.name === 'chrome' && browser.isHeadless) {
+        if (browser.name === 'chromium' && browser.isHeadless) {
           launchOptions.args.push('--window-size=1400,1024');
           launchOptions.args.push('--force-device-scale-factor=1');
         }
@@ -22,6 +22,7 @@ export default defineConfig({
   fixturesFolder: 'test/cypress/fixtures',
   screenshotsFolder: 'test/cypress/screenshots',
   videosFolder: 'test/cypress/videos',
+  video: false,
 
   viewportWidth: 1400,
   viewportHeight: 1024,

+ 1 - 0
apps/app/docker/Dockerfile

@@ -83,6 +83,7 @@ RUN tar -xf node_modules.tar
 RUN rm node_modules.tar
 
 # build
+RUN turbo run clean
 RUN turbo run build
 
 # make artifacts

+ 0 - 1
apps/app/docker/Dockerfile.dockerignore

@@ -1,5 +1,4 @@
 **/node_modules
-**/dist
 **/coverage
 **/Dockerfile
 **/*.dockerignore

+ 9 - 6
apps/app/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@growi/app",
-  "version": "6.1.5-RC.0",
+  "version": "6.1.9-RC.0",
   "license": "MIT",
   "scripts": {
     "//// for production": "",
@@ -8,8 +8,8 @@
     "start": "yarn next start",
     "build:client": "yarn next build",
     "build:server": "yarn cross-env NODE_ENV=production tsc -p tsconfig.build.server.json && tsc-alias -p tsconfig.build.server-tsc-alias.json",
-    "postbuild:server": "npx -y shx echo \"Listing files under transpiled\" && npx -y shx ls transpiled && npx -y shx rm -rf dist && npx -y shx mv transpiled/src dist && npx -y shx rm -rf transpiled",
-    "clean": "npx -y shx rm -rf dist transpiled",
+    "postbuild:server": "shx echo \"Listing files under transpiled\" && shx ls transpiled && shx rm -rf dist && shx mv transpiled/src dist && shx rm -rf transpiled",
+    "clean": "shx rm -rf dist transpiled",
     "server": "yarn cross-env NODE_ENV=production node -r dotenv-flow/config dist/server/app.js",
     "server:ci": "yarn server --ci",
     "preserver": "yarn cross-env NODE_ENV=production yarn migrate",
@@ -24,7 +24,7 @@
     "dev:migrate:status": "yarn dev:migrate-mongo status -f config/migrate-mongo-config.js",
     "dev:migrate:up": "yarn dev:migrate-mongo up -f config/migrate-mongo-config.js",
     "dev:migrate:down": "yarn dev:migrate-mongo down -f config/migrate-mongo-config.js",
-    "cy:run": "cypress run --browser chrome",
+    "cy:run": "cypress run --browser chromium",
     "//// for CI": "",
     "dev:ci": "yarn cross-env NODE_ENV=development yarn ts-node src/server/app.ts --ci",
     "lint:typecheck": "npx -y tsc",
@@ -47,7 +47,7 @@
     "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-dev": "ts-node-dev -r tsconfig-paths/register -r dotenv-flow/config",
-    "version": "yarn version --no-git-tag-version"
+    "version": "yarn version --no-git-tag-version --preid=RC"
   },
   "// comments for dependencies": {
     "escape-string-regexp": "5.0.0 or above exports only ESM",
@@ -66,6 +66,8 @@
     "@google-cloud/storage": "^5.8.5",
     "@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",
@@ -129,7 +131,7 @@
     "method-override": "^3.0.0",
     "migrate-mongo": "^8.2.3",
     "mkdirp": "^1.0.3",
-    "mongoose": "^6.5.0",
+    "mongoose": "^6.11.3",
     "mongoose-gridfs": "^1.2.42",
     "mongoose-paginate-v2": "^1.3.9",
     "mongoose-unique-validator": "^2.0.3",
@@ -186,6 +188,7 @@
     "remark-math": "^5.1.1",
     "remark-toc": "^8.0.1",
     "remark-wiki-link": "^1.0.4",
+    "sanitize-filename": "^1.6.3",
     "socket.io": "^4.2.0",
     "stream-to-promise": "^3.0.0",
     "string-width": "=4.2.2",

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

@@ -165,7 +165,7 @@
   "folder_name": "Folder name",
   "field": "field",
   "not_creatable_page": {
-    "could_not_creata_path": "Couldn't create path."
+    "message": "Page contents cannot be created in this path."
   },
   "custom_navigation": {
     "no_pages_under_this_page": "There are no pages under this page."

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

@@ -6,6 +6,9 @@
   "Reset": "リセット",
   "Sign out": "ログアウト",
   "New": "作成",
+  "Send": "送信",
+  "Close": "閉じる",
+  "Done": "完了",
   "Delete": "削除",
   "meta": {
     "display_name": "日本語"

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

@@ -166,7 +166,7 @@
   "folder_name": "フォルダ名",
   "field": "フィールド",
   "not_creatable_page": {
-    "could_not_creata_path": "パスを作成できませんでした。"
+    "message": "このパスではページ コンテンツを作成できません。"
   },
   "custom_navigation": {
     "no_pages_under_this_page": "このページの配下にはページが存在しません。"

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

@@ -6,6 +6,9 @@
   "Reset": "重启",
 	"Sign out": "退出",
   "New": "新建",
+  "Send": "发送",
+  "Close": "关闭",
+  "Done": "完成",
   "Delete": "删除",
 
   "meta": {

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

@@ -172,7 +172,7 @@
   "folder_name": "文件夹名称",
   "field": "字段",
   "not_creatable_page": {
-    "could_not_creata_path": "无法创建路径"
+    "message": "无法在此路径中创建页面内容。"
   },
   "custom_navigation": {
     "no_pages_under_this_page": "There are no pages under this page."

+ 0 - 34
apps/app/src/client/services/ShowPageAccessoriesModal.tsx

@@ -1,34 +0,0 @@
-import React, { useEffect, useState } from 'react';
-
-import { usePageAccessoriesModal, PageAccessoriesModalContents } from '~/stores/modal';
-
-function getURLQueryParamValue(key: string) {
-// window.location.href is page URL;
-  const queryStr: URLSearchParams = new URL(window.location.href).searchParams;
-  return queryStr.get(key);
-}
-
-const queryCompareFormat = new RegExp(/([a-z0-9]){24}...([a-z0-9]){24}/);
-
-const ShowPageAccessoriesModal = (): JSX.Element => {
-  const { data: status, open: openPageAccessories } = usePageAccessoriesModal();
-  const [isArleadyMounted, setIsArleadyMounted] = useState(false);
-  useEffect(() => {
-    const pageIdParams = getURLQueryParamValue('compare');
-    if (status == null || status.isOpened === true) {
-      return;
-    }
-    if (isArleadyMounted === true) {
-      return;
-    }
-    if (pageIdParams != null) {
-      if (queryCompareFormat.test(pageIdParams)) {
-        openPageAccessories(PageAccessoriesModalContents.PageHistory);
-      }
-    }
-    setIsArleadyMounted(true);
-  }, [openPageAccessories, status, isArleadyMounted]);
-  return <></>;
-};
-
-export default ShowPageAccessoriesModal;

+ 0 - 166
apps/app/src/components/Admin/Common/AdminNavigation.jsx

@@ -1,166 +0,0 @@
-import React from 'react';
-
-import { pathUtils } from '@growi/core';
-import { useTranslation } from 'next-i18next';
-import Link from 'next/link';
-import PropTypes from 'prop-types';
-import urljoin from 'url-join';
-
-import { useGrowiCloudUri, useGrowiAppIdForGrowiCloud } from '../../../stores/context';
-// import AppContainer from '~/client/services/AppContainer';
-
-// import { withUnstatedContainers } from '../../UnstatedUtils';
-
-const AdminNavigation = (props) => {
-  const { t } = useTranslation(['admin', 'commons']);
-  // const { appContainer } = props;
-  const pathname = window.location.pathname;
-
-  const { data: growiCloudUri } = useGrowiCloudUri();
-  const { data: growiAppIdForGrowiCloud } = useGrowiAppIdForGrowiCloud();
-
-  // eslint-disable-next-line react/prop-types
-  const MenuLabel = ({ menu }) => {
-    switch (menu) {
-      /* eslint-disable no-multi-spaces, max-len */
-      case 'app':                      return <><i className="mr-1 icon-fw icon-settings"></i>{        t('headers.app_settings', { ns: 'commons' }) }</>;
-      case 'security':                 return <><i className="mr-1 icon-fw icon-shield"></i>{          t('security_settings.security_settings') }</>;
-      case 'markdown':                 return <><i className="mr-1 icon-fw icon-note"></i>{            t('markdown_settings.markdown_settings') }</>;
-      case 'customize':                return <><i className="mr-1 icon-fw icon-wrench"></i>{          t('customize_settings.customize_settings') }</>;
-      case 'importer':                 return <><i className="mr-1 icon-fw icon-cloud-upload"></i>{    t('importer_management.import_data') }</>;
-      case 'export':                   return <><i className="mr-1 icon-fw icon-cloud-download"></i>{  t('export_management.export_archive_data') }</>;
-      case 'data-transfer':            return <><i className="mr-1 icon-fw icon-plane"></i>{           t('g2g_data_transfer.data_transfer', { ns: 'commons' })}</>;
-      case 'notification':             return <><i className="mr-1 icon-fw icon-bell"></i>{            t('external_notification.external_notification')}</>;
-      case 'slack-integration':        return <><i className="mr-1 icon-fw icon-shuffle"></i>{         t('slack_integration.slack_integration') }</>;
-      case 'slack-integration-legacy': return <><i className="mr-1 icon-fw icon-shuffle"></i>{         t('slack_integration_legacy.slack_integration_legacy')}</>;
-      case 'users':                    return <><i className="mr-1 icon-fw icon-user"></i>{            t('user_management.user_management') }</>;
-      case 'user-groups':              return <><i className="mr-1 icon-fw icon-people"></i>{          t('user_group_management.user_group_management') }</>;
-      case 'audit-log':                return <><i className="mr-1 icon-fw icon-feed"></i>{            t('audit_log_management.audit_log')}</>;
-      case 'plugins':                  return <><i className="mr-1 icon-fw icon-puzzle"></i>{          t('plugins.plugins')}</>;
-      case 'search':                   return <><i className="mr-1 icon-fw icon-magnifier"></i>{       t('full_text_search_management.full_text_search_management') }</>;
-      case 'cloud':                    return <><i className="mr-1 icon-fw icon-share-alt"></i>{       t('cloud_setting_management.to_cloud_settings')} </>;
-      default:                         return <><i className="mr-1 icon-fw icon-home"></i>{            t('wiki_management_home_page') }</>;
-      /* eslint-enable no-multi-spaces, max-len */
-    }
-  };
-
-  const MenuLink = ({
-    // eslint-disable-next-line react/prop-types
-    menu, isRoot, isListGroupItems, isActive,
-  }) => {
-    const pageTransitionClassName = isListGroupItems
-      ? 'list-group-item list-group-item-action border-0 round-corner'
-      : 'dropdown-item px-3 py-2';
-
-    const href = isRoot ? '/admin' : urljoin('/admin', menu);
-
-    return (
-      <Link
-        href={href}
-        className={`${pageTransitionClassName} ${isActive ? 'active' : ''}`}
-      >
-        <MenuLabel menu={menu} />
-      </Link>
-    );
-  };
-
-  const isActiveMenu = (path) => {
-    const basisPath = pathUtils.normalizePath(urljoin('/admin', path));
-    const basisParentPath = pathUtils.addTrailingSlash(basisPath);
-
-    return (
-      pathname === basisPath
-      || pathname.startsWith(basisParentPath)
-    );
-  };
-
-  const getListGroupItemOrDropdownItemList = (isListGroupItems) => {
-    return (
-      <>
-        {/* eslint-disable no-multi-spaces */}
-        <MenuLink menu="home"         isListGroupItems isActive={pathname === '/admin'} isRoot />
-        <MenuLink menu="app"          isListGroupItems isActive={isActiveMenu('/app')} />
-        <MenuLink menu="security"     isListGroupItems isActive={isActiveMenu('/security')} />
-        <MenuLink menu="markdown"     isListGroupItems isActive={isActiveMenu('/markdown')} />
-        <MenuLink menu="customize"    isListGroupItems isActive={isActiveMenu('/customize')} />
-        <MenuLink menu="importer"     isListGroupItems isActive={isActiveMenu('/importer')} />
-        <MenuLink menu="export"       isListGroupItems isActive={isActiveMenu('/export')} />
-        <MenuLink menu="data-transfer" isListGroupItems isActive={isActiveMenu('/data-transfer')} />
-        <MenuLink menu="notification" isListGroupItems isActive={isActiveMenu('/notification') || isActiveMenu('/global-notification')} />
-        <MenuLink menu="slack-integration" isListGroupItems isActive={isActiveMenu('/slack-integration')} />
-        <MenuLink menu="slack-integration-legacy" isListGroupItems isActive={isActiveMenu('/slack-integration-legacy')} />
-        <MenuLink menu="users"        isListGroupItems isActive={isActiveMenu('/users')} />
-        <MenuLink menu="user-groups"  isListGroupItems isActive={isActiveMenu('/user-groups')} />
-        <MenuLink menu="audit-log"    isListGroupItems isActive={isActiveMenu('/audit-log')} />
-        <MenuLink menu="plugins"      isListGroupItems isActive={isActiveMenu('/plugins')} />
-        <MenuLink menu="search"       isListGroupItems isActive={isActiveMenu('/search')} />
-        {growiCloudUri != null && growiAppIdForGrowiCloud != null
-          && (
-            <a
-              href={`${growiCloudUri}/my/apps/${growiAppIdForGrowiCloud}`}
-              className="list-group-item list-group-item-action border-0 round-corner"
-            >
-              <MenuLabel menu="cloud" />
-            </a>
-          )
-        }
-        {/* eslint-enable no-multi-spaces */}
-      </>
-    );
-  };
-
-  return (
-    <React.Fragment>
-      {/* List group */}
-      <div className="list-group admin-navigation sticky-top d-none d-lg-block">
-        {getListGroupItemOrDropdownItemList(true)}
-      </div>
-
-      {/* Dropdown */}
-      <div className="dropdown d-block d-lg-none mb-5">
-        <button
-          className="btn btn-outline-primary btn-lg dropdown-toggle col-12 text-right"
-          type="button"
-          id="dropdown-admin-navigation"
-          data-display="static"
-          data-toggle="dropdown"
-          aria-haspopup="true"
-          aria-expanded="false"
-        >
-          <span className="float-left">
-            {/* eslint-disable no-multi-spaces */}
-            {pathname === '/admin' &&              <MenuLabel menu="home" />}
-            {isActiveMenu('/app') &&               <MenuLabel menu="app" />}
-            {isActiveMenu('/security') &&          <MenuLabel menu="security" />}
-            {isActiveMenu('/markdown') &&          <MenuLabel menu="markdown" />}
-            {isActiveMenu('/customize') &&         <MenuLabel menu="customize" />}
-            {isActiveMenu('/importer') &&          <MenuLabel menu="importer" />}
-            {isActiveMenu('/export') &&            <MenuLabel menu="export" />}
-            {(isActiveMenu('/notification') || isActiveMenu('/global-notification')) && <MenuLabel menu="notification" />}
-            {isActiveMenu('/slack-integration') && <MenuLabel menu="slack-integration" />}
-            {isActiveMenu('/users') &&             <MenuLabel menu="users" />}
-            {isActiveMenu('/user-groups') &&       <MenuLabel menu="user-groups" />}
-            {isActiveMenu('/search') &&            <MenuLabel menu="search" />}
-            {isActiveMenu('/audit-log') &&         <MenuLabel menu="audit-log" />}
-            {isActiveMenu('/plugins') &&           <MenuLabel menu="plugins" />}
-            {isActiveMenu('/data-transfer') &&     <MenuLabel menu="data-transfer" />}
-            {/* eslint-enable no-multi-spaces */}
-          </span>
-        </button>
-        <div className="dropdown-menu" aria-labelledby="dropdown-admin-navigation">
-          {getListGroupItemOrDropdownItemList(false)}
-        </div>
-      </div>
-
-    </React.Fragment>
-  );
-};
-
-// const AdminNavigationWrapper = withUnstatedContainers(AdminNavigation, [AppContainer]);
-
-AdminNavigation.propTypes = {
-  // appContainer: PropTypes.instanceOf(AppContainer).isRequired,
-};
-
-// export default AdminNavigationWrapper;
-export default AdminNavigation;

+ 167 - 0
apps/app/src/components/Admin/Common/AdminNavigation.tsx

@@ -0,0 +1,167 @@
+import React, { useCallback } from 'react';
+
+import { pathUtils } from '@growi/core';
+import { useTranslation } from 'next-i18next';
+import Link from 'next/link';
+import urljoin from 'url-join';
+
+import { useGrowiCloudUri, useGrowiAppIdForGrowiCloud } from '../../../stores/context';
+
+// eslint-disable-next-line react/prop-types
+const MenuLabel = ({ menu }: { menu: string }) => {
+  const { t } = useTranslation(['admin', 'commons']);
+
+  switch (menu) {
+    /* eslint-disable no-multi-spaces, max-len */
+    case 'app':                      return <><i className="mr-1 icon-fw icon-settings"></i>{        t('headers.app_settings', { ns: 'commons' }) }</>;
+    case 'security':                 return <><i className="mr-1 icon-fw icon-shield"></i>{          t('security_settings.security_settings') }</>;
+    case 'markdown':                 return <><i className="mr-1 icon-fw icon-note"></i>{            t('markdown_settings.markdown_settings') }</>;
+    case 'customize':                return <><i className="mr-1 icon-fw icon-wrench"></i>{          t('customize_settings.customize_settings') }</>;
+    case 'importer':                 return <><i className="mr-1 icon-fw icon-cloud-upload"></i>{    t('importer_management.import_data') }</>;
+    case 'export':                   return <><i className="mr-1 icon-fw icon-cloud-download"></i>{  t('export_management.export_archive_data') }</>;
+    case 'data-transfer':            return <><i className="mr-1 icon-fw icon-plane"></i>{           t('g2g_data_transfer.data_transfer', { ns: 'commons' })}</>;
+    case 'notification':             return <><i className="mr-1 icon-fw icon-bell"></i>{            t('external_notification.external_notification')}</>;
+    case 'slack-integration':        return <><i className="mr-1 icon-fw icon-shuffle"></i>{         t('slack_integration.slack_integration') }</>;
+    case 'slack-integration-legacy': return <><i className="mr-1 icon-fw icon-shuffle"></i>{         t('slack_integration_legacy.slack_integration_legacy')}</>;
+    case 'users':                    return <><i className="mr-1 icon-fw icon-user"></i>{            t('user_management.user_management') }</>;
+    case 'user-groups':              return <><i className="mr-1 icon-fw icon-people"></i>{          t('user_group_management.user_group_management') }</>;
+    case 'audit-log':                return <><i className="mr-1 icon-fw icon-feed"></i>{            t('audit_log_management.audit_log')}</>;
+    case 'plugins':                  return <><i className="mr-1 icon-fw icon-puzzle"></i>{          t('plugins.plugins')}</>;
+    case 'search':                   return <><i className="mr-1 icon-fw icon-magnifier"></i>{       t('full_text_search_management.full_text_search_management') }</>;
+    case 'cloud':                    return <><i className="mr-1 icon-fw icon-share-alt"></i>{       t('cloud_setting_management.to_cloud_settings')} </>;
+    default:                         return <><i className="mr-1 icon-fw icon-home"></i>{            t('wiki_management_home_page') }</>;
+      /* eslint-enable no-multi-spaces, max-len */
+  }
+};
+
+type MenuLinkProps = {
+  menu: string,
+  isListGroupItems: boolean,
+  isRoot?: boolean,
+  isActive?: boolean,
+}
+
+const MenuLink = ({
+  menu, isRoot, isListGroupItems, isActive,
+}: MenuLinkProps) => {
+
+  const pageTransitionClassName = isListGroupItems
+    ? 'list-group-item list-group-item-action border-0 round-corner'
+    : 'dropdown-item px-3 py-2';
+
+  const href = isRoot ? '/admin' : urljoin('/admin', menu);
+
+  return (
+    <Link
+      href={href}
+      className={`${pageTransitionClassName} ${isActive ? 'active' : ''}`}
+    >
+      <MenuLabel menu={menu} />
+    </Link>
+  );
+};
+
+export const AdminNavigation = (): JSX.Element => {
+  const pathname = window.location.pathname;
+
+  const { data: growiCloudUri } = useGrowiCloudUri();
+  const { data: growiAppIdForGrowiCloud } = useGrowiAppIdForGrowiCloud();
+
+  const isActiveMenu = useCallback((path: string | string[]) => {
+    const paths = Array.isArray(path) ? path : [path];
+
+    return paths.some((path) => {
+      const basisPath = pathUtils.normalizePath(urljoin('/admin', path));
+      const basisParentPath = pathUtils.addTrailingSlash(basisPath);
+
+      return (
+        pathname === basisPath
+        || pathname.startsWith(basisParentPath)
+      );
+    });
+
+  }, [pathname]);
+
+  const getListGroupItemOrDropdownItemList = (isListGroupItems: boolean) => {
+    return (
+      <>
+        {/* eslint-disable no-multi-spaces */}
+        <MenuLink menu="home"                       isListGroupItems={isListGroupItems} isActive={pathname === '/admin'} isRoot />
+        <MenuLink menu="app"                        isListGroupItems={isListGroupItems} isActive={isActiveMenu('/app')} />
+        <MenuLink menu="security"                   isListGroupItems={isListGroupItems} isActive={isActiveMenu('/security')} />
+        <MenuLink menu="markdown"                   isListGroupItems={isListGroupItems} isActive={isActiveMenu('/markdown')} />
+        <MenuLink menu="customize"                  isListGroupItems={isListGroupItems} isActive={isActiveMenu('/customize')} />
+        <MenuLink menu="importer"                   isListGroupItems={isListGroupItems} isActive={isActiveMenu('/importer')} />
+        <MenuLink menu="export"                     isListGroupItems={isListGroupItems} isActive={isActiveMenu('/export')} />
+        <MenuLink menu="data-transfer"              isListGroupItems={isListGroupItems} isActive={isActiveMenu('/data-transfer')} />
+        <MenuLink menu="notification"               isListGroupItems={isListGroupItems} isActive={isActiveMenu(['/notification', '/global-notification'])} />
+        <MenuLink menu="slack-integration"          isListGroupItems={isListGroupItems} isActive={isActiveMenu('/slack-integration')} />
+        <MenuLink menu="slack-integration-legacy"   isListGroupItems={isListGroupItems} isActive={isActiveMenu('/slack-integration-legacy')} />
+        <MenuLink menu="users"                      isListGroupItems={isListGroupItems} isActive={isActiveMenu('/users')} />
+        <MenuLink menu="user-groups"                isListGroupItems={isListGroupItems} isActive={isActiveMenu(['/user-groups', 'user-group-detail'])} />
+        <MenuLink menu="audit-log"                  isListGroupItems={isListGroupItems} isActive={isActiveMenu('/audit-log')} />
+        <MenuLink menu="plugins"                    isListGroupItems={isListGroupItems} isActive={isActiveMenu('/plugins')} />
+        <MenuLink menu="search"                     isListGroupItems={isListGroupItems} isActive={isActiveMenu('/search')} />
+        {growiCloudUri != null && growiAppIdForGrowiCloud != null
+          && (
+            <a
+              href={`${growiCloudUri}/my/apps/${growiAppIdForGrowiCloud}`}
+              className="list-group-item list-group-item-action border-0 round-corner"
+            >
+              <MenuLabel menu="cloud" />
+            </a>
+          )
+        }
+        {/* eslint-enable no-multi-spaces */}
+      </>
+    );
+  };
+
+  return (
+    <React.Fragment>
+      {/* List group */}
+      <div className="list-group admin-navigation sticky-top d-none d-lg-block">
+        {getListGroupItemOrDropdownItemList(true)}
+      </div>
+
+      {/* Dropdown */}
+      <div className="dropdown d-block d-lg-none mb-5">
+        <button
+          className="btn btn-outline-primary btn-lg dropdown-toggle col-12 text-right"
+          type="button"
+          id="dropdown-admin-navigation"
+          data-display="static"
+          data-toggle="dropdown"
+          aria-haspopup="true"
+          aria-expanded="false"
+        >
+          <span className="float-left">
+            {/* eslint-disable no-multi-spaces */}
+            {pathname === '/admin'                  && <MenuLabel menu="home" />}
+            {isActiveMenu('/app')                   && <MenuLabel menu="app" />}
+            {isActiveMenu('/security')              && <MenuLabel menu="security" />}
+            {isActiveMenu('/markdown')              && <MenuLabel menu="markdown" />}
+            {isActiveMenu('/customize')             && <MenuLabel menu="customize" />}
+            {isActiveMenu('/importer')              && <MenuLabel menu="importer" />}
+            {isActiveMenu('/export')                && <MenuLabel menu="export" />}
+            {(isActiveMenu(['/notification', '/global-notification']))
+                                                    && <MenuLabel menu="notification" />}
+            {isActiveMenu('/slack-integration')     && <MenuLabel menu="slack-integration" />}
+            {isActiveMenu('/users')                 && <MenuLabel menu="users" />}
+            {isActiveMenu(['/user-groups', 'user-group-detail'])
+                                                    && <MenuLabel menu="user-groups" />}
+            {isActiveMenu('/search')                && <MenuLabel menu="search" />}
+            {isActiveMenu('/audit-log')             && <MenuLabel menu="audit-log" />}
+            {isActiveMenu('/plugins')               && <MenuLabel menu="plugins" />}
+            {isActiveMenu('/data-transfer')         && <MenuLabel menu="data-transfer" />}
+            {/* eslint-enable no-multi-spaces */}
+          </span>
+        </button>
+        <div className="dropdown-menu" aria-labelledby="dropdown-admin-navigation">
+          {getListGroupItemOrDropdownItemList(false)}
+        </div>
+      </div>
+
+    </React.Fragment>
+  );
+};

+ 3 - 2
apps/app/src/components/Admin/Notification/ManageGlobalNotification.tsx

@@ -3,6 +3,7 @@ import React, {
 } from 'react';
 
 import { useTranslation } from 'next-i18next';
+import Link from 'next/link';
 import { useRouter } from 'next/router';
 
 import { NotifyType, TriggerEventType } from '~/client/interfaces/global-notification';
@@ -111,10 +112,10 @@ const ManageGlobalNotification = (props: Props): JSX.Element => {
   return (
     <>
       <div className="my-3">
-        <a href="/admin/notification#global-notification" className="btn btn-outline-secondary">
+        <Link href="/admin/notification" className="btn btn-outline-secondary">
           <i className="icon-fw ti ti-arrow-left" aria-hidden="true"></i>
           {t('notification_settings.back_to_list')}
-        </a>
+        </Link>
       </div>
 
 

+ 1 - 1
apps/app/src/components/Admin/Security/ShareLinkSetting.tsx

@@ -8,8 +8,8 @@ import AdminGeneralSecurityContainer from '~/client/services/AdminGeneralSecurit
 import { apiv3Delete } from '~/client/util/apiv3-client';
 import { toastSuccess, toastError } from '~/client/util/toastr';
 
+import ShareLinkList from '../../PageAccessoriesModal/ShareLink/ShareLinkList';
 import PaginationWrapper from '../../PaginationWrapper';
-import ShareLinkList from '../../ShareLink/ShareLinkList';
 import { withUnstatedContainers } from '../../UnstatedUtils';
 
 import DeleteAllShareLinksModal from './DeleteAllShareLinksModal';

+ 4 - 1
apps/app/src/components/Admin/UserGroup/UserGroupForm.tsx

@@ -45,6 +45,9 @@ export const UserGroupForm: FC<Props> = (props: Props) => {
 
   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 (
     <form onSubmit={(e) => {
       e.preventDefault();
@@ -107,7 +110,7 @@ export const UserGroupForm: FC<Props> = (props: Props) => {
                 btn btn-outline-secondary dropdown-toggle mb-3 ${isSelectableParentUserGroups ? '' : 'disabled'}
               `}
             >
-              {selectedParent?.name ?? t('user_group_management.select_parent_group')}
+              {selectedParent?.name ?? messageAtReleaseParentGroup}
             </button>
             <div className="dropdown-menu" aria-labelledby="dropdownMenuButton">
               {

+ 3 - 2
apps/app/src/components/Admin/UserGroup/UserGroupTable.tsx

@@ -5,6 +5,7 @@ import React, {
 import type { IUserGroupHasId, IUserGroupRelation, IUserHasId } from '@growi/core';
 import dateFnsFormat from 'date-fns/format';
 import { useTranslation } from 'next-i18next';
+import Link from 'next/link';
 
 
 type Props = {
@@ -147,7 +148,7 @@ export const UserGroupTable: FC<Props> = (props: Props) => {
               <tr key={group._id}>
                 {props.isAclEnabled
                   ? (
-                    <td><a href={`/admin/user-group-detail/${group._id}`}>{group.name}</a></td>
+                    <td><Link href={`/admin/user-group-detail/${group._id}`}>{group.name}</Link></td>
                   )
                   : (
                     <td>{group.name}</td>
@@ -168,7 +169,7 @@ export const UserGroupTable: FC<Props> = (props: Props) => {
                         <li key={group._id} className="list-inline-item badge badge-success">
                           {props.isAclEnabled
                             ? (
-                              <a href={`/admin/user-group-detail/${group._id}`}>{group.name}</a>
+                              <Link href={`/admin/user-group-detail/${group._id}`}>{group.name}</Link>
                             )
                             : (
                               <p>{group.name}</p>

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

@@ -334,7 +334,7 @@ const UserGroupDetailPage = (props: Props): JSX.Element => {
       <nav aria-label="breadcrumb">
         <ol className="breadcrumb">
           <li className="breadcrumb-item">
-            <Link href="/admin/user-groups" prefetch={false}>
+            <Link href="/admin/user-groups">
               {t('user_group_management.group_list')}
             </Link>
           </li>
@@ -348,7 +348,7 @@ const UserGroupDetailPage = (props: Props): JSX.Element => {
                 { ancestorUserGroup._id === currentUserGroupId ? (
                   <span>{ancestorUserGroup.name}</span>
                 ) : (
-                  <Link href={`/admin/user-group-detail/${ancestorUserGroup._id}`} prefetch={false}>
+                  <Link href={`/admin/user-group-detail/${ancestorUserGroup._id}`}>
                     {ancestorUserGroup.name}
                   </Link>
                 ) }

+ 6 - 2
apps/app/src/components/Admin/UserGroupDetail/UserGroupPageList.tsx

@@ -49,8 +49,12 @@ const UserGroupPageList = (props: Props): JSX.Element => {
 
   return (
     <>
-      <ul className="page-list-ul page-list-ul-flat mb-3">
-        {currentPages.map(page => <li key={page._id}><PageListItemS page={page} /></li>)}
+      <ul className="page-list-ul page-list-ul-flat mt-3 mb-3">
+        { currentPages.map(page => (
+          <li key={page._id} className="mt-2">
+            <PageListItemS page={page} />
+          </li>
+        )) }
       </ul>
       {relatedPages != null && relatedPages.length === 0 ? <p>{t('user_group_management.no_pages')}</p> : (
         <PaginationWrapper

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

@@ -2,13 +2,15 @@ import React from 'react';
 
 import { useTranslation } from 'next-i18next';
 import PropTypes from 'prop-types';
+import { CopyToClipboard } from 'react-copy-to-clipboard';
 import {
-  Modal, ModalHeader, ModalBody, ModalFooter,
+  Modal, ModalHeader, ModalBody, ModalFooter, Tooltip,
 } from 'reactstrap';
 
+import AdminUsersContainer from '~/client/services/AdminUsersContainer';
 import { apiv3Put } from '~/client/util/apiv3-client';
 import { toastError } from '~/client/util/toastr';
-
+import { useIsMailerSetup } from '~/stores/context';
 
 class PasswordResetModal extends React.Component {
 
@@ -16,11 +18,15 @@ class PasswordResetModal extends React.Component {
     super(props);
 
     this.state = {
-      temporaryPassword: [],
+      temporaryPassword: '',
       isPasswordResetDone: false,
+      isEmailSent: false,
+      isEmailSending: false,
+      showTooltip: false,
     };
 
     this.resetPassword = this.resetPassword.bind(this);
+    this.onClickSendNewPasswordButton = this.onClickSendNewPasswordButton.bind(this);
   }
 
   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() {
     const { t, userForPasswordResetModal } = this.props;
 
@@ -53,6 +97,11 @@ class PasswordResetModal extends React.Component {
 
   returnModalBodyAfterReset() {
     const { t, userForPasswordResetModal } = this.props;
+    const { temporaryPassword, showPassword, showTooltip } = this.state;
+
+    const maskedPassword = showPassword
+      ? temporaryPassword
+      : '•'.repeat(temporaryPassword.length);
 
     return (
       <>
@@ -61,7 +110,28 @@ class PasswordResetModal extends React.Component {
           {t('user_management.reset_password_modal.target_user')}: <code>{userForPasswordResetModal.email}</code>
         </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>
       </>
     );
@@ -77,15 +147,35 @@ class PasswordResetModal extends React.Component {
   }
 
   returnModalFooterAfterReset() {
-    const { t } = this.props;
-
     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() {
     const { t } = this.props;
@@ -109,7 +199,8 @@ class PasswordResetModal extends React.Component {
 
 const PasswordResetModalWrapperFC = (props) => {
   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,
   onClose: PropTypes.func.isRequired,
   userForPasswordResetModal: PropTypes.object,
+  onSuccessfullySentNewPasswordEmail: PropTypes.func.isRequired,
+  adminUsersContainer: PropTypes.instanceOf(AdminUsersContainer).isRequired,
 
 };
 

+ 3 - 2
apps/app/src/components/Bookmarks/BookmarkFolderItem.tsx

@@ -121,7 +121,7 @@ export const BookmarkFolderItem: FC<BookmarkFolderItemProps> = (props: BookmarkF
     }
   };
 
-  const isDropable = (item: DragItemDataType, type: string | null| symbol): boolean => {
+  const isDropable = (item: DragItemDataType, type: string | null | symbol): boolean => {
     if (type === DRAG_ITEM_TYPE.FOLDER) {
       if (item.bookmarkFolder.parent === bookmarkFolder._id || item.bookmarkFolder._id === bookmarkFolder._id) {
         return false;
@@ -143,6 +143,7 @@ export const BookmarkFolderItem: FC<BookmarkFolderItemProps> = (props: BookmarkF
     return true;
   };
 
+
   const renderChildFolder = () => {
     return isOpen && children?.map((childFolder) => {
       return (
@@ -256,7 +257,7 @@ export const BookmarkFolderItem: FC<BookmarkFolderItemProps> = (props: BookmarkF
               </div>
             </>
           )}
-          { isOperable && (
+          {isOperable && (
             <div className="grw-foldertree-control d-flex">
               <BookmarkFolderItemControl
                 onClickRename={onClickRenameHandler}

+ 3 - 3
apps/app/src/components/Bookmarks/BookmarkFolderMenu.tsx

@@ -120,7 +120,7 @@ export const BookmarkFolderMenu = (props: BookmarkFolderMenuProps): JSX.Element
           className={'grw-bookmark-folder-menu-item text-danger'}
         >
           <i className="fa fa-bookmark"></i>{' '}
-          <span className="mx-2 ">
+          <span className="mx-2">
             {t('bookmark_folder.cancel_bookmark')}
           </span>
         </DropdownItem>
@@ -143,7 +143,7 @@ export const BookmarkFolderMenu = (props: BookmarkFolderMenuProps): JSX.Element
               </div>
             </div>
             {bookmarkFolders?.map(folder => (
-              <div key={folder._id}>
+              <React.Fragment key={`bookmark-folders-${folder._id}`}>
                 <div
                   className="dropdown-item grw-bookmark-folder-menu-item list-group-item list-group-item-action border-0 py-0"
                   style={{ paddingLeft: '40px' }}
@@ -174,7 +174,7 @@ export const BookmarkFolderMenu = (props: BookmarkFolderMenuProps): JSX.Element
                     </div>
                   </div>
                 ))}
-              </div>
+              </React.Fragment>
             ))}
           </>
         )}

+ 10 - 6
apps/app/src/components/Bookmarks/BookmarkFolderTree.tsx

@@ -2,6 +2,7 @@
 import React, { useCallback } from 'react';
 
 import { useTranslation } from 'next-i18next';
+import { useRouter } from 'next/router';
 
 import { toastSuccess } from '~/client/util/toastr';
 import { IPageToDeleteWithMeta } from '~/interfaces/page';
@@ -12,7 +13,7 @@ import {
 import { useSWRxBookmarkFolderAndChild } from '~/stores/bookmark-folder';
 import { useIsReadOnlyUser } from '~/stores/context';
 import { usePageDeleteModal } from '~/stores/modal';
-import { useSWRMUTxPageInfo, useSWRxCurrentPage } from '~/stores/page';
+import { mutateAllPageInfo, useSWRMUTxPageInfo, useSWRxCurrentPage } from '~/stores/page';
 
 import { BookmarkFolderItem } from './BookmarkFolderItem';
 import { BookmarkItem } from './BookmarkItem';
@@ -36,6 +37,7 @@ export const BookmarkFolderTree: React.FC<Props> = (props: Props) => {
 
   // const acceptedTypes: DragItemType[] = [DRAG_ITEM_TYPE.FOLDER, DRAG_ITEM_TYPE.BOOKMARK];
   const { t } = useTranslation();
+  const router = useRouter();
 
   const { data: isReadOnlyUser } = useIsReadOnlyUser();
   const { data: currentPage } = useSWRxCurrentPage();
@@ -55,13 +57,15 @@ export const BookmarkFolderTree: React.FC<Props> = (props: Props) => {
   const onClickDeleteMenuItemHandler = useCallback((pageToDelete: IPageToDeleteWithMeta) => {
     const pageDeletedHandler: OnDeletedFunction = (pathOrPathsToDelete, _isRecursively, isCompletely) => {
       if (typeof pathOrPathsToDelete !== 'string') return;
-
-      toastSuccess(isCompletely ? t('deleted_pages_completely', { pathOrPathsToDelete }) : t('deleted_pages', { pathOrPathsToDelete }));
-
+      toastSuccess(isCompletely ? t('deleted_pages_completely', { path: pathOrPathsToDelete }) : t('deleted_pages', { path: pathOrPathsToDelete }));
       bookmarkFolderTreeMutation();
+      mutateAllPageInfo();
+      if (pageToDelete.data._id === currentPage?._id && _isRecursively) {
+        router.push(`/trash${currentPage.path}`);
+      }
     };
     openDeleteModal([pageToDelete], { onDeleted: pageDeletedHandler });
-  }, [openDeleteModal, t, bookmarkFolderTreeMutation]);
+  }, [openDeleteModal, t, bookmarkFolderTreeMutation, currentPage?._id, currentPage?.path, router]);
 
   /* TODO: update in bookmarks folder v2. */
   // const itemDropHandler = async(item: DragItemDataType, dragType: string | null | symbol) => {
@@ -98,7 +102,7 @@ export const BookmarkFolderTree: React.FC<Props> = (props: Props) => {
   // };
 
   return (
-    <div className={`grw-folder-tree-container ${styles['grw-folder-tree-container']}` } >
+    <div className={`grw-folder-tree-container ${styles['grw-folder-tree-container']}`} >
       <ul className={`grw-foldertree ${styles['grw-foldertree']} list-group px-2 py-2`}>
         {bookmarkFolders?.map((bookmarkFolder) => {
           return (

+ 42 - 16
apps/app/src/components/Bookmarks/BookmarkItem.tsx

@@ -3,16 +3,19 @@ import React, { useCallback, useState } from 'react';
 import nodePath from 'path';
 
 import { DevidedPagePath, pathUtils } from '@growi/core';
+import { useRouter } from 'next/router';
 import { useTranslation } from 'react-i18next';
 import { UncontrolledTooltip, DropdownToggle } from 'reactstrap';
 
-import { bookmark, unbookmark } from '~/client/services/page-operation';
+
+import { bookmark, unbookmark, unlink } from '~/client/services/page-operation';
 import { addBookmarkToFolder, renamePage } from '~/client/util/bookmark-utils';
 import { ValidationTarget } from '~/client/util/input-validator';
-import { toastError } from '~/client/util/toastr';
+import { toastError, toastSuccess } from '~/client/util/toastr';
 import { BookmarkFolderItems, DragItemDataType, DRAG_ITEM_TYPE } from '~/interfaces/bookmark-info';
 import { IPageHasId, IPageInfoAll, IPageToDeleteWithMeta } from '~/interfaces/page';
-import { useSWRxPageInfo } from '~/stores/page';
+import { usePutBackPageModal } from '~/stores/modal';
+import { mutateAllPageInfo, useSWRMUTxCurrentPage, useSWRxPageInfo } from '~/stores/page';
 
 import ClosableTextInput from '../Common/ClosableTextInput';
 import { MenuItemType, PageItemControl } from '../Common/Dropdown/PageItemControl';
@@ -29,7 +32,7 @@ type Props = {
   parentFolder: BookmarkFolderItems | null,
   canMoveToRoot: boolean,
   onClickDeleteMenuItemHandler: (pageToDelete: IPageToDeleteWithMeta) => void,
-  bookmarkFolderTreeMutation: () => void
+  bookmarkFolderTreeMutation: () => void,
 }
 
 export const BookmarkItem = (props: Props): JSX.Element => {
@@ -37,15 +40,17 @@ export const BookmarkItem = (props: Props): JSX.Element => {
   const BASE_BOOKMARK_PADDING = 20;
 
   const { t } = useTranslation();
+  const router = useRouter();
 
   const {
     isReadOnlyUser, isOperable, bookmarkedPage, onClickDeleteMenuItemHandler,
     parentFolder, level, canMoveToRoot, bookmarkFolderTreeMutation,
   } = props;
-
+  const { open: openPutBackPageModal } = usePutBackPageModal();
   const [isRenameInputShown, setRenameInputShown] = useState(false);
 
   const { data: pageInfo, mutate: mutatePageInfo } = useSWRxPageInfo(bookmarkedPage._id);
+  const { trigger: mutateCurrentPage } = useSWRMUTxCurrentPage();
   const dPagePath = new DevidedPagePath(bookmarkedPage.path, false, true);
   const { latter: pageTitle, former: formerPagePath } = dPagePath;
   const bookmarkItemId = `bookmark-item-${bookmarkedPage._id}`;
@@ -116,6 +121,24 @@ export const BookmarkItem = (props: Props): JSX.Element => {
     onClickDeleteMenuItemHandler(pageToDelete);
   }, [bookmarkedPage._id, bookmarkedPage.path, bookmarkedPage.revision, onClickDeleteMenuItemHandler]);
 
+  const putBackClickHandler = useCallback(() => {
+    const { _id: pageId, path } = bookmarkedPage;
+    const putBackedHandler = async() => {
+      try {
+        await unlink(path);
+        mutateAllPageInfo();
+        bookmarkFolderTreeMutation();
+        router.push(`/${pageId}`);
+        mutateCurrentPage();
+        toastSuccess(t('page_has_been_reverted', { path }));
+      }
+      catch (err) {
+        toastError(err);
+      }
+    };
+    openPutBackPageModal({ pageId, path }, { onPutBacked: putBackedHandler });
+  }, [bookmarkedPage, openPutBackPageModal, bookmarkFolderTreeMutation, router, mutateCurrentPage, t]);
+
   return (
     <DragAndDropWrapper
       item={dragItem}
@@ -128,15 +151,17 @@ export const BookmarkItem = (props: Props): JSX.Element => {
         id={bookmarkItemId}
         style={{ paddingLeft }}
       >
-        { isRenameInputShown ? (
-          <ClosableTextInput
-            value={nodePath.basename(bookmarkedPage.path ?? '')}
-            placeholder={t('Input page name')}
-            onClickOutside={() => { setRenameInputShown(false) }}
-            onPressEnter={pressEnterForRenameHandler}
-            validationTarget={ValidationTarget.PAGE}
-          />
-        ) : <PageListItemS page={bookmarkedPage} pageTitle={pageTitle} isNarrowView/>}
+        { isRenameInputShown
+          ? (
+            <ClosableTextInput
+              value={nodePath.basename(bookmarkedPage.path ?? '')}
+              placeholder={t('Input page name')}
+              onClickOutside={() => { setRenameInputShown(false) }}
+              onPressEnter={pressEnterForRenameHandler}
+              validationTarget={ValidationTarget.PAGE}
+            />
+          )
+          : <PageListItemS page={bookmarkedPage} pageTitle={pageTitle} isNarrowView />}
 
         <div className='grw-foldertree-control'>
           <PageItemControl
@@ -148,8 +173,9 @@ export const BookmarkItem = (props: Props): JSX.Element => {
             onClickBookmarkMenuItem={bookmarkMenuItemClickHandler}
             onClickRenameMenuItem={renameMenuItemClickHandler}
             onClickDeleteMenuItem={deleteMenuItemClickHandler}
-            additionalMenuItemOnTopRenderer={canMoveToRoot && isOperable
-              ? () => <BookmarkMoveToRootBtn pageId={bookmarkedPage._id} onClickMoveToRootHandler={onClickMoveToRootHandler}/>
+            onClickRevertMenuItem={putBackClickHandler}
+            additionalMenuItemOnTopRenderer={canMoveToRoot
+              ? () => <BookmarkMoveToRootBtn pageId={bookmarkedPage._id} onClickMoveToRootHandler={onClickMoveToRootHandler} />
               : undefined}
           >
             <DropdownToggle color="transparent" className="border-0 rounded btn-page-item-control p-0 grw-visible-on-hover mr-1">

+ 11 - 5
apps/app/src/components/Comments.tsx

@@ -5,7 +5,7 @@ import dynamic from 'next/dynamic';
 
 import { ROOT_ELEM_ID as PageCommentRootElemId, type PageCommentProps } from '~/components/PageComment';
 import { useSWRxPageComment } from '~/stores/comment';
-import { useIsTrashPage } from '~/stores/page';
+import { useIsTrashPage, useSWRMUTxPageInfo } from '~/stores/page';
 
 import { useCurrentUser } from '../stores/context';
 
@@ -32,6 +32,7 @@ export const Comments = (props: CommentsProps): JSX.Element => {
   } = props;
 
   const { mutate } = useSWRxPageComment(pageId);
+  const { trigger: mutatePageInfo } = useSWRMUTxPageInfo(pageId);
   const { data: isDeleted } = useIsTrashPage();
   const { data: currentUser } = useCurrentUser();
 
@@ -41,8 +42,8 @@ export const Comments = (props: CommentsProps): JSX.Element => {
     const parent = pageCommentParentRef.current;
     if (parent == null) return;
 
-    const observerCallback = (mutationRecords:MutationRecord[]) => {
-      mutationRecords.forEach((record:MutationRecord) => {
+    const observerCallback = (mutationRecords: MutationRecord[]) => {
+      mutationRecords.forEach((record: MutationRecord) => {
         const target = record.target as HTMLElement;
 
         for (const child of Array.from(target.children)) {
@@ -69,6 +70,11 @@ export const Comments = (props: CommentsProps): JSX.Element => {
     return <></>;
   }
 
+  const onCommentButtonClickHandler = () => {
+    mutate();
+    mutatePageInfo();
+  };
+
   return (
     <div className="page-comments-row mt-5 py-4 d-edit-none d-print-none">
       <div className="container-lg">
@@ -83,12 +89,12 @@ export const Comments = (props: CommentsProps): JSX.Element => {
             hideIfEmpty={false}
           />
         </div>
-        { !isDeleted && (
+        {!isDeleted && (
           <div id="page-comment-write">
             <CommentEditor
               pageId={pageId}
               isForNewComment
-              onCommentButtonClicked={mutate}
+              onCommentButtonClicked={onCommentButtonClickHandler}
               revisionId={revision._id}
             />
           </div>

+ 1 - 6
apps/app/src/components/DescendantsPageList.tsx

@@ -10,9 +10,7 @@ import {
 } from '~/interfaces/page';
 import { IPagingResult } from '~/interfaces/paging-result';
 import { OnDeletedFunction, OnPutBackedFunction } from '~/interfaces/ui';
-import {
-  useIsGuestUser, useIsReadOnlyUser, useIsSharedUser,
-} from '~/stores/context';
+import { useIsGuestUser, useIsReadOnlyUser, useIsSharedUser } from '~/stores/context';
 import {
   mutatePageTree,
   useSWRxPageInfoForList, useSWRxPageList,
@@ -22,7 +20,6 @@ import { ForceHideMenuItems } from './Common/Dropdown/PageItemControl';
 import PageList from './PageList/PageList';
 import PaginationWrapper from './PaginationWrapper';
 
-
 type SubstanceProps = {
   pagingResult: IPagingResult<IPageHasId> | undefined,
   activePage: number,
@@ -71,7 +68,6 @@ const DescendantsPageListSubstance = (props: SubstanceProps): JSX.Element => {
     }
 
     mutatePageTree();
-
     if (onPagesDeleted != null) {
       onPagesDeleted(...args);
     }
@@ -81,7 +77,6 @@ const DescendantsPageListSubstance = (props: SubstanceProps): JSX.Element => {
     toastSuccess(t('page_has_been_reverted', { path }));
 
     mutatePageTree();
-
     if (onPagePutBacked != null) {
       onPagePutBacked(path);
     }

+ 6 - 16
apps/app/src/components/InstallerForm.tsx

@@ -19,7 +19,7 @@ const InstallerForm = memo((): JSX.Element => {
   const isSupportedLang = AllLang.includes(i18n.language as Lang);
 
   const [isValidUserName, setValidUserName] = useState(true);
-  const [isSubmittingDisabled, setSubmittingDisabled] = useState(false);
+  const [isLoading, setIsLoading] = useState(false);
   const [currentLocale, setCurrentLocale] = useState(isSupportedLang ? i18n.language : Lang.en_US);
 
   const checkUserName = useCallback(async(event) => {
@@ -42,18 +42,7 @@ const InstallerForm = memo((): JSX.Element => {
   const submitHandler: FormEventHandler = useCallback(async(e: any) => {
     e.preventDefault();
 
-    if (isSubmittingDisabled) {
-      return;
-    }
-
-    setSubmittingDisabled(true);
-    setTimeout(() => {
-      setSubmittingDisabled(false);
-    }, 3000);
-
-    if (e.target.elements == null) {
-      return;
-    }
+    setIsLoading(true);
 
     const formData = e.target.elements;
 
@@ -81,6 +70,7 @@ const InstallerForm = memo((): JSX.Element => {
     catch (errs) {
       const err = errs[0];
       const code = err.code;
+      setIsLoading(false);
 
       if (code === 'failed_to_login_after_install') {
         toastError(t('installer.failed_to_login_after_install'));
@@ -89,7 +79,7 @@ const InstallerForm = memo((): JSX.Element => {
 
       toastError(t('installer.failed_to_install'));
     }
-  }, [isSubmittingDisabled, currentLocale, router, t]);
+  }, [currentLocale, router, t]);
 
   const hasErrorClass = isValidUserName ? '' : ' has-error';
   const unavailableUserId = isValidUserName
@@ -220,10 +210,10 @@ const InstallerForm = memo((): JSX.Element => {
               type="submit"
               className="btn-fill btn btn-register"
               id="register"
-              disabled={isSubmittingDisabled}
+              disabled={isLoading}
             >
               <div className="eff"></div>
-              <span className="btn-label"><i className="icon-user-follow" /></span>
+              <span className="btn-label"><i className={isLoading ? 'fa fa-spinner fa-pulse mr-1' : 'icon-user-follow'} /></span>
               <span className="btn-label-text">{ t('Create') }</span>
             </button>
           </div>

+ 6 - 13
apps/app/src/components/InvitedForm.tsx

@@ -18,13 +18,14 @@ export const InvitedForm = (props: InvitedFormProps): JSX.Element => {
   const { t } = useTranslation();
   const router = useRouter();
   const { data: user } = useCurrentUser();
-  const [isConnectSuccess, setIsConnectSuccess] = useState<boolean>(false);
   const [loginErrors, setLoginErrors] = useState<Error[]>([]);
+  const [isLoading, setIsLoading] = useState(false);
 
   const { invitedFormUsername, invitedFormName } = props;
 
   const submitHandler = useCallback(async(e) => {
     e.preventDefault();
+    setIsLoading(true);
 
     const formData = e.target.elements;
 
@@ -42,25 +43,17 @@ export const InvitedForm = (props: InvitedFormProps): JSX.Element => {
 
     try {
       const res = await apiv3Post('/invited', { invitedForm });
-      setIsConnectSuccess(true);
       const { redirectTo } = res.data;
       router.push(redirectTo ?? '/');
     }
     catch (err) {
       setLoginErrors(err);
+      setIsLoading(false);
     }
   }, [router]);
 
   const formNotification = useCallback(() => {
 
-    if (isConnectSuccess) {
-      return (
-        <p className="alert alert-success">
-          <strong>{ t('message.successfully_connected') }</strong><br></br>
-        </p>
-      );
-    }
-
     return (
       <>
         { loginErrors != null && loginErrors.length > 0 ? (
@@ -77,7 +70,7 @@ export const InvitedForm = (props: InvitedFormProps): JSX.Element => {
         ) }
       </>
     );
-  }, [isConnectSuccess, loginErrors, t]);
+  }, [loginErrors, t]);
 
   if (user == null) {
     return <></>;
@@ -154,9 +147,9 @@ export const InvitedForm = (props: InvitedFormProps): JSX.Element => {
         </div>
         {/* Create Button */}
         <div className="input-group justify-content-center d-flex mt-4">
-          <button type="submit" className="btn btn-fill" id="register">
+          <button type="submit" className="btn btn-fill" id="register" disabled={isLoading}>
             <div className="eff"></div>
-            <span className="btn-label"><i className="icon-user-follow"></i></span>
+            <span className="btn-label"><i className={isLoading ? 'fa fa-spinner fa-pulse mr-1' : 'icon-user-follow'} /></span>
             <span className="btn-label-text">{t('Create')}</span>
           </button>
         </div>

+ 1 - 1
apps/app/src/components/Layout/AdminLayout.tsx

@@ -2,6 +2,7 @@ import React, { ReactNode } from 'react';
 
 import dynamic from 'next/dynamic';
 
+import { AdminNavigation } from '../Admin/Common/AdminNavigation';
 import { GrowiNavbar } from '../Navbar/GrowiNavbar';
 
 import { RawLayout } from './RawLayout';
@@ -9,7 +10,6 @@ import { RawLayout } from './RawLayout';
 import styles from './Admin.module.scss';
 
 
-const AdminNavigation = dynamic(() => import('~/components/Admin/Common/AdminNavigation'), { ssr: false });
 const PageCreateModal = dynamic(() => import('../PageCreateModal'), { ssr: false });
 const SystemVersion = dynamic(() => import('../SystemVersion'), { ssr: false });
 const HotkeysManager = dynamic(() => import('../Hotkeys/HotkeysManager'), { ssr: false });

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

@@ -16,13 +16,14 @@ const HotkeysManager = dynamic(() => import('../Hotkeys/HotkeysManager'), { ssr:
 const GrowiNavbarBottom = dynamic(() => import('../Navbar/GrowiNavbarBottom').then(mod => mod.GrowiNavbarBottom), { ssr: false });
 const ShortcutsModal = dynamic(() => import('../ShortcutsModal'), { ssr: false });
 const SystemVersion = dynamic(() => import('../SystemVersion'), { ssr: false });
+const PutbackPageModal = dynamic(() => import('../PutbackPageModal'), { ssr: false });
 // Page modals
 const PageCreateModal = dynamic(() => import('../PageCreateModal'), { ssr: false });
 const PageDuplicateModal = dynamic(() => import('../PageDuplicateModal'), { ssr: false });
 const PageDeleteModal = dynamic(() => import('../PageDeleteModal'), { ssr: false });
 const PageRenameModal = dynamic(() => import('../PageRenameModal'), { ssr: false });
 const PagePresentationModal = dynamic(() => import('../PagePresentationModal'), { ssr: false });
-const PageAccessoriesModal = dynamic(() => import('../PageAccessoriesModal'), { ssr: false });
+const PageAccessoriesModal = dynamic(() => import('../PageAccessoriesModal').then(mod => mod.PageAccessoriesModal), { ssr: false });
 const DeleteBookmarkFolderModal = dynamic(() => import('../DeleteBookmarkFolderModal').then(mod => mod.DeleteBookmarkFolderModal), { ssr: false });
 // Fab
 const Fab = dynamic(() => import('../Fab').then(mod => mod.Fab), { ssr: false });
@@ -40,7 +41,7 @@ export const BasicLayout = ({ children, className }: Props): JSX.Element => {
         <GrowiNavbar />
 
         <div className="page-wrapper d-flex d-print-block">
-          <div className="grw-sidebar-wrapper" data-testid="grw-sidebar-wrapper">
+          <div className="grw-sidebar-wrapper">
             <Sidebar />
           </div>
 
@@ -59,6 +60,7 @@ export const BasicLayout = ({ children, className }: Props): JSX.Element => {
         <PageAccessoriesModal />
         <DeleteAttachmentModal />
         <DeleteBookmarkFolderModal />
+        <PutbackPageModal />
       </DndProvider>
 
       <PagePresentationModal />

+ 46 - 9
apps/app/src/components/LoginForm.tsx

@@ -48,6 +48,7 @@ export const LoginForm = (props: LoginFormProps): JSX.Element => {
 
   // states
   const [isRegistering, setIsRegistering] = useState(false);
+  const [isLoading, setIsLoading] = useState(false);
   // For Login
   const [usernameForLogin, setUsernameForLogin] = useState('');
   const [passwordForLogin, setPasswordForLogin] = useState('');
@@ -93,6 +94,7 @@ export const LoginForm = (props: LoginFormProps): JSX.Element => {
   const handleLoginWithLocalSubmit = useCallback(async(e) => {
     e.preventDefault();
     resetLoginErrors();
+    setIsLoading(true);
 
     const loginForm = {
       username: usernameForLogin,
@@ -112,6 +114,7 @@ export const LoginForm = (props: LoginFormProps): JSX.Element => {
     catch (err) {
       const errs = toArrayIfNot(err);
       setLoginErrors(errs);
+      setIsLoading(false);
     }
     return;
 
@@ -176,6 +179,12 @@ export const LoginForm = (props: LoginFormProps): JSX.Element => {
 
     return (
       <>
+        {/* !! - DO NOT DELETE HIDDEN ELEMENT - !! -- 7.12 ryoji-s */}
+        {/* Import font-awesome to prevent MongoStore.js "Unable to find the session to touch" error */}
+        <div className='sr-only'>
+          <i className="fa fa-spinner fa-pulse" />
+        </div>
+        {/* !! - END OF HIDDEN ELEMENT - !! */}
         {isLdapSetupFailed && (
           <div className="alert alert-warning small">
             <strong><i className="icon-fw icon-info"></i>{t('login.enabled_ldap_has_configuration_problem')}</strong><br/>
@@ -214,10 +223,16 @@ export const LoginForm = (props: LoginFormProps): JSX.Element => {
           </div>
 
           <div className="input-group my-4">
-            <button type="submit" id="login" className="btn btn-fill rounded-0 login mx-auto" data-testid="btnSubmitForLogin">
+            <button
+              type="submit"
+              id="login"
+              className="btn btn-fill rounded-0 login mx-auto"
+              data-testid="btnSubmitForLogin"
+              disabled={isLoading}
+            >
               <div className="eff"></div>
               <span className="btn-label">
-                <i className="icon-login"></i>
+                <i className={isLoading ? 'fa fa-spinner fa-pulse mr-1' : 'icon-login'} />
               </span>
               <span className="btn-label-text">{t('Sign in')}</span>
             </button>
@@ -225,8 +240,18 @@ export const LoginForm = (props: LoginFormProps): JSX.Element => {
         </form>
       </>
     );
-  }, [generateDangerouslySetErrors, generateSafelySetErrors, handleLoginWithLocalSubmit,
-      isLdapSetupFailed, loginErrors, props, separateErrorsBasedOnErrorCode, t]);
+  }, [
+    props,
+    separateErrorsBasedOnErrorCode,
+    loginErrors,
+    generateDangerouslySetErrors,
+    generateSafelySetErrors,
+    isLdapSetupFailed,
+    t,
+    handleLoginWithLocalSubmit,
+    isLoading,
+  ]);
+
 
   const renderExternalAuthInput = useCallback((auth) => {
     const authIconNames = {
@@ -295,6 +320,7 @@ export const LoginForm = (props: LoginFormProps): JSX.Element => {
     e.preventDefault();
     setEmailForRegistrationOrder('');
     setIsSuccessToRagistration(false);
+    setIsLoading(true);
 
     const registerForm = {
       username: usernameForRegister,
@@ -323,6 +349,7 @@ export const LoginForm = (props: LoginFormProps): JSX.Element => {
       if (err != null || err.length > 0) {
         setRegisterErrors(err);
       }
+      setIsLoading(false);
     }
     return;
   }, [usernameForRegister, nameForRegister, emailForRegister, passwordForRegister, resetRegisterErrors, router, isEmailAuthenticationEnabled]);
@@ -478,11 +505,11 @@ export const LoginForm = (props: LoginFormProps): JSX.Element => {
             <button
               className="btn btn-fill rounded-0"
               id="register"
-              disabled={(!isMailerSetup && isEmailAuthenticationEnabled)}
+              disabled={(!isMailerSetup && isEmailAuthenticationEnabled) || isLoading}
             >
               <div className="eff"></div>
               <span className="btn-label">
-                <i className="icon-user-follow"></i>
+                <i className={isLoading ? 'fa fa-spinner fa-pulse mr-1' : 'icon-user-follow'} />
               </span>
               <span className="btn-label-text">{submitText}</span>
             </button>
@@ -493,7 +520,12 @@ export const LoginForm = (props: LoginFormProps): JSX.Element => {
 
         <div className="row">
           <div className="text-right col-12 mt-2 py-2">
-            <a href="#login" id="login" className="link-switch" onClick={switchForm}>
+            <a
+              href="#login"
+              id="login"
+              className="link-switch"
+              style={{ pointerEvents: isLoading ? 'none' : 'auto' }}
+              onClick={switchForm}>
               <i className="icon-fw icon-login"></i>
               {t('Sign in is here')}
             </a>
@@ -503,7 +535,7 @@ export const LoginForm = (props: LoginFormProps): JSX.Element => {
     );
   }, [
     t, isEmailAuthenticationEnabled, registrationMode, isMailerSetup, registerErrors, isSuccessToRagistration,
-    emailForRegistrationOrder, props.username, props.name, props.email, registrationWhitelist, switchForm, handleRegisterFormSubmit,
+    emailForRegistrationOrder, props.username, props.name, props.email, registrationWhitelist, switchForm, handleRegisterFormSubmit, isLoading,
   ]);
 
   if (registrationMode === RegistrationMode.RESTRICTED && isSuccessToRagistration && !isEmailAuthenticationEnabled) {
@@ -529,7 +561,12 @@ export const LoginForm = (props: LoginFormProps): JSX.Element => {
                 {/* Sign up link */}
                 {isRegistrationEnabled && (
                   <div className="text-right mb-2">
-                    <a href="#register" id="register" className="link-switch" onClick={switchForm}>
+                    <a
+                      href="#register"
+                      id="register"
+                      className="link-switch"
+                      style={{ pointerEvents: isLoading ? 'none' : 'auto' }}
+                      onClick={switchForm}>
                       <i className="ti ti-check-box"></i> {t('Sign up is here')}
                     </a>
                   </div>

+ 2 - 2
apps/app/src/components/Me/DisassociateModal.tsx

@@ -1,5 +1,6 @@
 import React, { useCallback } from 'react';
 
+import type { IExternalAccountHasId } from '@growi/core';
 import { useTranslation } from 'next-i18next';
 import {
   Modal,
@@ -9,13 +10,12 @@ import {
 } from 'reactstrap';
 
 import { toastSuccess, toastError } from '~/client/util/toastr';
-import { IExternalAccount } from '~/interfaces/external-account';
 import { usePersonalSettings, useSWRxPersonalExternalAccounts } from '~/stores/personal-settings';
 
 type Props = {
   isOpen: boolean,
   onClose: () => void,
-  accountForDisassociate: IExternalAccount,
+  accountForDisassociate: IExternalAccountHasId,
 }
 
 

+ 19 - 17
apps/app/src/components/Navbar/GrowiContextualSubNavigation.tsx

@@ -25,7 +25,7 @@ import {
   usePageDuplicateModal, usePageRenameModal, usePageDeleteModal, usePagePresentationModal,
 } from '~/stores/modal';
 import {
-  useSWRMUTxCurrentPage, useSWRxTagsInfo, useCurrentPageId, useIsNotFound, useTemplateTagData,
+  useSWRMUTxCurrentPage, useSWRxTagsInfo, useCurrentPageId, useIsNotFound, useTemplateTagData, useSWRxPageInfo,
 } from '~/stores/page';
 import {
   EditorMode, useDrawerMode, useEditorMode, useIsAbleToShowPageManagement, useIsAbleToShowTagLabel,
@@ -98,7 +98,7 @@ const PageOperationMenuItems = (props: PageOperationMenuItemsProps): JSX.Element
         <i className="icon-fw grw-page-control-dropdown-icon">
           <PresentationIcon />
         </i>
-        { t('Presentation Mode') }
+        {t('Presentation Mode')}
       </DropdownItem>
 
       {/* Export markdown */}
@@ -139,7 +139,7 @@ const PageOperationMenuItems = (props: PageOperationMenuItemsProps): JSX.Element
         {t('attachment_data')}
       </DropdownItem>
 
-      { !isGuestUser && !isReadOnlyUser && !isSharedUser && (
+      {!isGuestUser && !isReadOnlyUser && !isSharedUser && (
         <NotAvailable isDisabled={isLinkSharingDisabled ?? false} title="Disabled by admin">
           <DropdownItem
             onClick={() => openAccessoriesModal(PageAccessoriesModalContents.ShareLink)}
@@ -152,7 +152,7 @@ const PageOperationMenuItems = (props: PageOperationMenuItemsProps): JSX.Element
             {t('share_links.share_link_management')}
           </DropdownItem>
         </NotAvailable>
-      ) }
+      )}
     </>
   );
 };
@@ -179,7 +179,7 @@ const CreateTemplateMenuItems = (props: CreateTemplateMenuItemsProps): JSX.Eleme
         data-testid="open-page-template-modal-btn"
       >
         <i className="icon-fw icon-magic-wand grw-page-control-dropdown-icon"></i>
-        { t('template.option_label.create/edit') }
+        {t('template.option_label.create/edit')}
       </DropdownItem>
     </>
   );
@@ -231,6 +231,7 @@ const GrowiContextualSubNavigation = (props: GrowiContextualSubNavigationProps):
   const { open: openRenameModal } = usePageRenameModal();
   const { open: openDeleteModal } = usePageDeleteModal();
   const { data: templateTagData } = useTemplateTagData();
+  const { mutate: mutatePageInfo } = useSWRxPageInfo(pageId);
 
   const updateStateAfterSave = useUpdateStateAfterSave(pageId);
 
@@ -319,9 +320,10 @@ const GrowiContextualSubNavigation = (props: GrowiContextualSubNavigationProps):
       }
 
       mutateCurrentPage();
+      mutatePageInfo();
     };
     openDeleteModal([pageWithMeta], { onDeleted: deletedHandler });
-  }, [currentPathname, mutateCurrentPage, openDeleteModal, router]);
+  }, [currentPathname, mutateCurrentPage, openDeleteModal, router, mutatePageInfo]);
 
   const switchContentWidthHandler = useCallback(async(pageId: string, value: boolean) => {
     if (!isSharedPage) {
@@ -341,9 +343,9 @@ const GrowiContextualSubNavigation = (props: GrowiContextualSubNavigationProps):
         return (
           <>
             {!isReadOnlyUser
-            && <CreateTemplateMenuItems
-              onClickTemplateMenuItem={templateMenuItemClickHandler}
-            />
+              && <CreateTemplateMenuItems
+                onClickTemplateMenuItem={templateMenuItemClickHandler}
+              />
             }
           </>);
       }
@@ -368,9 +370,9 @@ const GrowiContextualSubNavigation = (props: GrowiContextualSubNavigationProps):
       <>
         <div className="d-flex">
           <div className="d-flex flex-column align-items-end justify-content-center py-md-2" style={{ gap: `${isCompactMode ? '5px' : '7px'}` }}>
-            { isViewMode && (
+            {isViewMode && (
               <div className="h-50">
-                { pageId != null && (
+                {pageId != null && (
                   <SubNavButtons
                     isCompactMode={isCompactMode}
                     pageId={pageId}
@@ -386,9 +388,9 @@ const GrowiContextualSubNavigation = (props: GrowiContextualSubNavigationProps):
                     onClickDeleteMenuItem={deleteItemClickedHandler}
                     onClickSwitchContentWidth={switchContentWidthHandler}
                   />
-                ) }
+                )}
               </div>
-            ) }
+            )}
             {isAbleToChangeEditorMode && (
               <PageEditorModeManager
                 onPageEditorModeButtonClicked={viewType => mutateEditorMode(viewType)}
@@ -397,22 +399,22 @@ const GrowiContextualSubNavigation = (props: GrowiContextualSubNavigationProps):
               />
             )}
           </div>
-          { (isAbleToShowPageAuthors && !isCompactMode && !isUsersHomePage(path ?? '')) && (
+          {(isAbleToShowPageAuthors && !isCompactMode && !isUsersHomePage(path ?? '')) && (
             <ul className={`${AuthorInfoStyles['grw-author-info']} text-nowrap border-left d-none d-lg-block d-edit-none py-2 pl-4 mb-0 ml-3`}>
               <li className="pb-1">
-                { currentPage != null
+                {currentPage != null
                   ? <AuthorInfo user={currentPage.creator as IUser} date={currentPage.createdAt} mode="create" locate="subnav" />
                   : <AuthorInfoSkeleton />
                 }
               </li>
               <li className="mt-1 pt-1 border-top">
-                { currentPage != null
+                {currentPage != null
                   ? <AuthorInfo user={currentPage.lastUpdateUser as IUser} date={currentPage.updatedAt} mode="update" locate="subnav" />
                   : <AuthorInfoSkeleton />
                 }
               </li>
             </ul>
-          ) }
+          )}
         </div>
 
         {path != null && currentUser != null && !isReadOnlyUser && (

+ 2 - 3
apps/app/src/components/Navbar/PageEditorModeManager.jsx

@@ -4,7 +4,7 @@ import { useTranslation } from 'next-i18next';
 import PropTypes from 'prop-types';
 import { UncontrolledTooltip } from 'reactstrap';
 
-import { useCurrentUser, useHackmdUri } from '~/stores/context';
+import { useIsAdmin, useHackmdUri } from '~/stores/context';
 import { EditorMode, useIsDeviceSmallerThanMd } from '~/stores/ui';
 
 import styles from './PageEditorModeManager.module.scss';
@@ -47,10 +47,9 @@ function PageEditorModeManager(props) {
 
   const { t } = useTranslation();
   const { data: isDeviceSmallerThanMd } = useIsDeviceSmallerThanMd();
-  const { data: currentUser } = useCurrentUser();
   const { data: hackmdUri } = useHackmdUri();
 
-  const isAdmin = currentUser?.admin;
+  const { data: isAdmin } = useIsAdmin();
   const isHackmdEnabled = hackmdUri != null;
   const showHackmdBtn = isHackmdEnabled || isAdmin;
 

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

@@ -202,8 +202,11 @@ const SubNavButtonsSubstance = (props: SubNavButtonsSubstanceProps): JSX.Element
     sumOfLikers, sumOfSeenUsers, isLiked,
   } = pageInfo;
 
-  const forceHideMenuItemsWithBookmark = forceHideMenuItems ?? [];
-  forceHideMenuItemsWithBookmark.push(MenuItemType.BOOKMARK);
+  const forceHideMenuItemsWithAdditions = [
+    ...(forceHideMenuItems ?? []),
+    MenuItemType.BOOKMARK,
+    MenuItemType.REVERT,
+  ];
 
   return (
     <div className="d-flex" style={{ gap: '2px' }}>
@@ -244,7 +247,7 @@ const SubNavButtonsSubstance = (props: SubNavButtonsSubstanceProps): JSX.Element
           pageInfo={pageInfo}
           isEnableActions={!isGuestUser}
           isReadOnlyUser={!!isReadOnlyUser}
-          forceHideMenuItems={forceHideMenuItemsWithBookmark}
+          forceHideMenuItems={forceHideMenuItemsWithAdditions}
           additionalMenuItemOnTopRenderer={!isReadOnlyUser ? additionalMenuItemOnTopRenderer : undefined}
           additionalMenuItemRenderer={additionalMenuItemRenderer}
           onClickRenameMenuItem={renameMenuItemClickHandler}

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

@@ -10,7 +10,7 @@ export const NotCreatablePage: FC = () => {
       <div className="col-md-12">
         <h2 className="text-muted">
           <i className="icon-ban mr-1" aria-hidden="true"></i>
-          { t('not_creatable_page.could_not_creata_path') }
+          { t('not_creatable_page.message') }
         </h2>
       </div>
     </div>

+ 14 - 9
apps/app/src/components/Page/PageView.tsx

@@ -83,7 +83,7 @@ export const PageView = (props: Props): JSX.Element => {
 
     const targetId = hash.slice(1);
 
-    const target = document.getElementById(targetId);
+    const target = document.getElementById(decodeURIComponent(targetId));
     target?.scrollIntoView();
 
   }, [isCommentsLoaded]);
@@ -111,11 +111,16 @@ export const PageView = (props: Props): JSX.Element => {
     ? (
       <>
         <div id="comments-container" ref={commentsContainerRef}>
-          <Comments pageId={page._id} pagePath={pagePath} revision={page.revision} onLoaded={() => setCommentsLoaded(true)} />
+          <Comments
+            pageId={page._id}
+            pagePath={pagePath}
+            revision={page.revision}
+            onLoaded={() => setCommentsLoaded(true)}
+          />
         </div>
-        { (isUsersHomePagePath && page.creator != null) && (
-          <UsersHomePageFooter creatorId={page.creator._id}/>
-        ) }
+        {(isUsersHomePagePath && page.creator != null) && (
+          <UsersHomePageFooter creatorId={page.creator._id} />
+        )}
         <PageContentFooter page={page} />
       </>
     )
@@ -144,15 +149,15 @@ export const PageView = (props: Props): JSX.Element => {
     >
       <PageAlerts />
 
-      { specialContents }
-      { specialContents == null && (
+      {specialContents}
+      {specialContents == null && (
         <>
-          { (isUsersHomePagePath && page?.creator != null) && <UserInfo author={page.creator} /> }
+          {(isUsersHomePagePath && page?.creator != null) && <UserInfo author={page.creator} />}
           <div className={`mb-5 ${isMobile ? `page-mobile ${styles['page-mobile']}` : ''}`}>
             <Contents />
           </div>
         </>
-      ) }
+      )}
 
     </MainPane>
   );

+ 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 { useTranslation } from 'next-i18next';
-import PropTypes from 'prop-types';
 import {
   Modal, ModalHeader, ModalBody, ModalFooter,
 } from 'reactstrap';
 
 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();
 
-  function onTagsUpdatedByTagsInput(tags) {
+  function onTagsUpdatedByTagsInput(tags: string[]) {
     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;

+ 0 - 0
apps/app/src/components/PageAccessoriesModal.module.scss → apps/app/src/components/PageAccessoriesModal/PageAccessoriesModal.module.scss


+ 25 - 57
apps/app/src/components/PageAccessoriesModal.tsx → apps/app/src/components/PageAccessoriesModal/PageAccessoriesModal.tsx

@@ -1,6 +1,7 @@
-import React, { useEffect, useMemo, useState } from 'react';
+import React, { useMemo, useState } from 'react';
 
 import { useTranslation } from 'next-i18next';
+import dynamic from 'next/dynamic';
 import {
   Modal, ModalBody, ModalHeader,
 } from 'reactstrap';
@@ -10,25 +11,26 @@ import {
 } from '~/stores/context';
 import { usePageAccessoriesModal, PageAccessoriesModalContents } from '~/stores/modal';
 
-import { CustomNavTab } from './CustomNavigation/CustomNav';
-import CustomTabContent from './CustomNavigation/CustomTabContent';
-import ExpandOrContractButton from './ExpandOrContractButton';
-import AttachmentIcon from './Icons/AttachmentIcon';
-import HistoryIcon from './Icons/HistoryIcon';
-import ShareLinkIcon from './Icons/ShareLinkIcon';
-import PageAttachment from './PageAttachment';
-import { PageHistory, getQueryParam } from './PageHistory';
-import ShareLink from './ShareLink/ShareLink';
+import { CustomNavTab } from '../CustomNavigation/CustomNav';
+import CustomTabContent from '../CustomNavigation/CustomTabContent';
+import ExpandOrContractButton from '../ExpandOrContractButton';
+import AttachmentIcon from '../Icons/AttachmentIcon';
+import HistoryIcon from '../Icons/HistoryIcon';
+import ShareLinkIcon from '../Icons/ShareLinkIcon';
+
+import { useAutoOpenModalByQueryParam } from './hooks';
 
 import styles from './PageAccessoriesModal.module.scss';
 
-const PageAccessoriesModal = (): JSX.Element => {
 
-  const { t } = useTranslation();
+const PageAttachment = dynamic(() => import('./PageAttachment'), { ssr: false });
+const PageHistory = dynamic(() => import('./PageHistory').then(mod => mod.PageHistory), { ssr: false });
+const ShareLink = dynamic(() => import('./ShareLink').then(mod => mod.ShareLink), { ssr: false });
+
 
-  const [activeTab, setActiveTab] = useState<PageAccessoriesModalContents>();
-  const [sourceRevisionId, setSourceRevisionId] = useState<string>();
-  const [targetRevisionId, setTargetRevisionId] = useState<string>();
+export const PageAccessoriesModal = (): JSX.Element => {
+
+  const { t } = useTranslation();
 
   const [isWindowExpanded, setIsWindowExpanded] = useState(false);
 
@@ -37,46 +39,16 @@ const PageAccessoriesModal = (): JSX.Element => {
   const { data: isReadOnlyUser } = useIsReadOnlyUser();
   const { data: isLinkSharingDisabled } = useDisableLinkSharing();
 
-  const { data: status, mutate, close } = usePageAccessoriesModal();
-
-  // activate tab when open
-  useEffect(() => {
-    if (status == null) return;
+  const { data: status, close, selectContents } = usePageAccessoriesModal();
 
-    const { isOpened, activatedContents } = status;
-    if (isOpened && activatedContents != null) {
-      setActiveTab(activatedContents);
-    }
-  }, [status]);
-
-  // Set sourceRevisionId and targetRevisionId as state with valid object id string
-  useEffect(() => {
-    const queryParams = getQueryParam();
-    // https://regex101.com/r/YHTDsr/1
-    const regex = /^([0-9a-f]{24})...([0-9a-f]{24})$/i;
-
-    if (queryParams == null || !regex.test(queryParams)) {
-      return;
-    }
-
-    const matches = queryParams.match(regex);
-
-    if (matches == null) {
-      return;
-    }
-
-    const [, sourceRevisionId, targetRevisionId] = matches;
-    setSourceRevisionId(sourceRevisionId);
-    setTargetRevisionId(targetRevisionId);
-    mutate({ isOpened: true });
-  }, [mutate]);
+  useAutoOpenModalByQueryParam();
 
   const navTabMapping = useMemo(() => {
     return {
       [PageAccessoriesModalContents.PageHistory]: {
         Icon: HistoryIcon,
         Content: () => {
-          return <PageHistory onClose={close} sourceRevisionId={sourceRevisionId} targetRevisionId={targetRevisionId}/>;
+          return <PageHistory onClose={close} />;
         },
         i18n: t('History'),
         isLinkEnabled: () => !isGuestUser && !isSharedUser,
@@ -97,7 +69,7 @@ const PageAccessoriesModal = (): JSX.Element => {
         isLinkEnabled: () => !isGuestUser && !isReadOnlyUser && !isSharedUser && !isLinkSharingDisabled,
       },
     };
-  }, [t, close, sourceRevisionId, targetRevisionId, isGuestUser, isReadOnlyUser, isSharedUser, isLinkSharingDisabled]);
+  }, [t, close, isGuestUser, isReadOnlyUser, isSharedUser, isLinkSharingDisabled]);
 
   const buttons = useMemo(() => (
     <div className="d-flex flex-nowrap">
@@ -112,7 +84,7 @@ const PageAccessoriesModal = (): JSX.Element => {
     </div>
   ), [close, isWindowExpanded]);
 
-  if (status == null || activeTab == null) {
+  if (status == null || status.activatedContents == null) {
     return <></>;
   }
 
@@ -128,20 +100,16 @@ const PageAccessoriesModal = (): JSX.Element => {
     >
       <ModalHeader className="p-0" toggle={close} close={buttons}>
         <CustomNavTab
-          activeTab={activeTab}
+          activeTab={status.activatedContents}
           navTabMapping={navTabMapping}
           breakpointToHideInactiveTabsDown="md"
-          onNavSelected={(v: PageAccessoriesModalContents) => {
-            setActiveTab(v);
-          }}
+          onNavSelected={selectContents}
           hideBorderBottom
         />
       </ModalHeader>
       <ModalBody className="overflow-auto grw-modal-body-style">
-        <CustomTabContent activeTab={activeTab} navTabMapping={navTabMapping} />
+        <CustomTabContent activeTab={status.activatedContents} navTabMapping={navTabMapping} />
       </ModalBody>
     </Modal>
   );
 };
-
-export default PageAccessoriesModal;

+ 2 - 2
apps/app/src/components/PageAttachment.tsx → apps/app/src/components/PageAccessoriesModal/PageAttachment.tsx

@@ -9,8 +9,8 @@ import { useIsGuestUser, useIsReadOnlyUser } from '~/stores/context';
 import { useDeleteAttachmentModal } from '~/stores/modal';
 import { useSWRxCurrentPage, useCurrentPageId } from '~/stores/page';
 
-import { PageAttachmentList } from './PageAttachment/PageAttachmentList';
-import PaginationWrapper from './PaginationWrapper';
+import { PageAttachmentList } from '../PageAttachment/PageAttachmentList';
+import PaginationWrapper from '../PaginationWrapper';
 
 // Utility
 const checkIfFileInUse = (markdown: string, attachment): boolean => {

+ 8 - 12
apps/app/src/components/PageHistory.tsx → apps/app/src/components/PageAccessoriesModal/PageHistory.tsx

@@ -3,34 +3,30 @@ import React from 'react';
 import { useCurrentPagePath, useCurrentPageId } from '~/stores/page';
 import loggerFactory from '~/utils/logger';
 
-import { PageRevisionTable } from './PageHistory/PageRevisionTable';
+import { PageRevisionTable } from '../PageHistory/PageRevisionTable';
+
+import { useAutoComparingRevisionsByQueryParam } from './hooks';
 
 const logger = loggerFactory('growi:PageHistory');
 
 type PageHistoryProps = {
-  sourceRevisionId?: string,
-  targetRevisionId?: string
   onClose: () => void
 }
 
-// Get string from 'compare' query params
-export const getQueryParam = (): string | null => {
-  const query: URLSearchParams = new URL(window.location.href).searchParams;
-  return query.get('compare');
-};
-
 export const PageHistory: React.FC<PageHistoryProps> = (props: PageHistoryProps) => {
-  const { sourceRevisionId, targetRevisionId, onClose } = props;
+  const { onClose } = props;
 
   const { data: currentPageId } = useCurrentPageId();
   const { data: currentPagePath } = useCurrentPagePath();
 
+  const comparingRevisions = useAutoComparingRevisionsByQueryParam();
+
   return (
     <div className="revision-history" data-testid="page-history">
       {currentPageId != null && currentPagePath != null && (
         <PageRevisionTable
-          sourceRevisionId={sourceRevisionId}
-          targetRevisionId={targetRevisionId}
+          sourceRevisionId={comparingRevisions?.sourceRevisionId}
+          targetRevisionId={comparingRevisions?.targetRevisionId}
           currentPageId={currentPageId}
           currentPagePath={currentPagePath}
           onClose={onClose}

+ 1 - 3
apps/app/src/components/ShareLink/ShareLink.tsx → apps/app/src/components/PageAccessoriesModal/ShareLink/ShareLink.tsx

@@ -12,7 +12,7 @@ import { useSWRxSharelink } from '~/stores/share-link';
 import { ShareLinkForm } from './ShareLinkForm';
 import ShareLinkList from './ShareLinkList';
 
-const ShareLink = (): JSX.Element => {
+export const ShareLink = (): JSX.Element => {
   const { t } = useTranslation();
   const [isOpenShareLinkForm, setIsOpenShareLinkForm] = useState<boolean>(false);
 
@@ -73,5 +73,3 @@ const ShareLink = (): JSX.Element => {
     </div>
   );
 };
-
-export default ShareLink;

+ 0 - 0
apps/app/src/components/ShareLink/ShareLinkForm.tsx → apps/app/src/components/PageAccessoriesModal/ShareLink/ShareLinkForm.tsx


+ 1 - 1
apps/app/src/components/ShareLink/ShareLinkList.tsx → apps/app/src/components/PageAccessoriesModal/ShareLink/ShareLinkList.tsx

@@ -3,7 +3,7 @@ import React from 'react';
 import dateFnsFormat from 'date-fns/format';
 import { useTranslation } from 'next-i18next';
 
-import CopyDropdown from '../Page/CopyDropdown';
+import CopyDropdown from '../../Page/CopyDropdown';
 
 
 type ShareLinkTrProps = {

+ 1 - 0
apps/app/src/components/PageAccessoriesModal/ShareLink/index.ts

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

+ 79 - 0
apps/app/src/components/PageAccessoriesModal/hooks.tsx

@@ -0,0 +1,79 @@
+import { useEffect, useState } from 'react';
+
+import { usePageAccessoriesModal, PageAccessoriesModalContents } from '~/stores/modal';
+
+function getURLQueryParamValue(key: string) {
+// window.location.href is page URL;
+  const queryStr: URLSearchParams = new URL(window.location.href).searchParams;
+  return queryStr.get(key);
+}
+
+// https://regex101.com/r/YHTDsr/1
+const queryCompareFormat = /^([0-9a-f]{24})...([0-9a-f]{24})$/i;
+
+
+export const useAutoOpenModalByQueryParam = (): void => {
+  const [isArleadyMounted, setIsArleadyMounted] = useState(false);
+
+  const { data: status, open: openPageAccessories } = usePageAccessoriesModal();
+
+  useEffect(() => {
+    if (isArleadyMounted) {
+      return;
+    }
+
+    if (status == null || status.isOpened === true) {
+      return;
+    }
+
+    const pageIdParams = getURLQueryParamValue('compare');
+    if (pageIdParams != null) {
+      const matches = pageIdParams.match(queryCompareFormat);
+
+      if (matches == null) {
+        return;
+      }
+
+      // open History
+      openPageAccessories(PageAccessoriesModalContents.PageHistory);
+    }
+
+    setIsArleadyMounted(true);
+  }, [openPageAccessories, status, isArleadyMounted]);
+
+};
+
+type ComparingRevisionIds = {
+  sourceRevisionId: string,
+  targetRevisionId: string,
+}
+
+export const useAutoComparingRevisionsByQueryParam = (): ComparingRevisionIds | null => {
+  const [isArleadyMounted, setIsArleadyMounted] = useState(false);
+
+  const [sourceRevisionId, setSourceRevisionId] = useState<string>();
+  const [targetRevisionId, setTargetRevisionId] = useState<string>();
+
+  useEffect(() => {
+    if (isArleadyMounted) {
+      return;
+    }
+
+    const pageIdParams = getURLQueryParamValue('compare');
+    if (pageIdParams != null) {
+      const matches = pageIdParams.match(queryCompareFormat);
+
+      if (matches != null) {
+        const [, source, target] = matches;
+        setSourceRevisionId(source);
+        setTargetRevisionId(target);
+      }
+    }
+
+    setIsArleadyMounted(true);
+  }, [isArleadyMounted]);
+
+  return sourceRevisionId != null && targetRevisionId != null
+    ? { sourceRevisionId, targetRevisionId }
+    : null;
+};

+ 1 - 0
apps/app/src/components/PageAccessoriesModal/index.ts

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

+ 13 - 10
apps/app/src/components/PageAlert/TrashPageAlert.tsx

@@ -42,10 +42,11 @@ export const TrashPageAlert = (): JSX.Element => {
   const deleteUser = pageData?.deleteUser;
   const deletedAt = pageData?.deletedAt ? format(new Date(pageData?.deletedAt), 'yyyy/MM/dd HH:mm') : '';
   const revisionId = pageData?.revision?._id;
-
+  const isEmptyPage = pageId == null || revisionId == null || pagePath == null;
 
   const openPutbackPageModalHandler = useCallback(() => {
-    if (pageId == null || pagePath == null) {
+    // User cannot operate empty page.
+    if (isEmptyPage) {
       return;
     }
     const putBackedHandler = () => {
@@ -62,10 +63,11 @@ export const TrashPageAlert = (): JSX.Element => {
       }
     };
     openPutBackPageModal({ pageId, path: pagePath }, { onPutBacked: putBackedHandler });
-  }, [currentPagePath, mutateCurrentPage, openPutBackPageModal, pageId, pagePath, router]);
+  }, [currentPagePath, mutateCurrentPage, openPutBackPageModal, pageId, pagePath, router, isEmptyPage]);
 
   const openPageDeleteModalHandler = useCallback(() => {
-    if (pageId === undefined || revisionId === undefined || pagePath === undefined) {
+    // User cannot operate empty page.
+    if (isEmptyPage) {
       return;
     }
     const pageToDelete = {
@@ -77,7 +79,7 @@ export const TrashPageAlert = (): JSX.Element => {
       meta: pageInfo,
     };
     openDeleteModal([pageToDelete], { onDeleted: onDeletedHandler });
-  }, [openDeleteModal, pageId, pageInfo, pagePath, revisionId]);
+  }, [openDeleteModal, pageId, pageInfo, pagePath, revisionId, isEmptyPage]);
 
   const renderTrashPageManagementButtons = useCallback(() => {
     return (
@@ -89,7 +91,7 @@ export const TrashPageAlert = (): JSX.Element => {
           data-toggle="modal"
           data-testid="put-back-button"
         >
-          <i className="icon-action-undo" aria-hidden="true"></i> { t('Put Back') }
+          <i className="icon-action-undo" aria-hidden="true"></i> {t('Put Back')}
         </button>
         <button
           type="button"
@@ -97,13 +99,14 @@ export const TrashPageAlert = (): JSX.Element => {
           disabled={!(pageInfo?.isAbleToDeleteCompletely ?? false)}
           onClick={openPageDeleteModalHandler}
         >
-          <i className="icon-fire" aria-hidden="true"></i> { t('Delete Completely') }
+          <i className="icon-fire" aria-hidden="true"></i> {t('Delete Completely')}
         </button>
       </>
     );
   }, [openPageDeleteModalHandler, openPutbackPageModalHandler, pageInfo?.isAbleToDeleteCompletely, t]);
 
-  if (!isTrashPage) {
+  // Show this alert only for non-empty pages in trash.
+  if (!isTrashPage || isEmptyPage) {
     return <></>;
   }
 
@@ -115,11 +118,11 @@ export const TrashPageAlert = (): JSX.Element => {
           <br />
           <UserPicture user={deleteUser} />
           <span className="ml-2">
-            Deleted by { deleteUser?.name } at <span data-vrt-blackout-datetime>{deletedAt ?? pageData?.updatedAt}</span>
+            Deleted by {deleteUser?.name} at <span data-vrt-blackout-datetime>{deletedAt ?? pageData?.updatedAt}</span>
           </span>
         </div>
         <div className="pt-1 d-flex align-items-end align-items-lg-center">
-          { isAbleToShowTrashPageManagementButtons && renderTrashPageManagementButtons()}
+          {isAbleToShowTrashPageManagementButtons && renderTrashPageManagementButtons()}
         </div>
       </div>
     </>

+ 20 - 14
apps/app/src/components/PageComment.tsx

@@ -9,6 +9,7 @@ import { Button } from 'reactstrap';
 import { apiPost } from '~/client/util/apiv1-client';
 import { toastError } from '~/client/util/toastr';
 import { RendererOptions } from '~/interfaces/renderer-options';
+import { useSWRMUTxPageInfo } from '~/stores/page';
 import { useCommentForCurrentPageOptions } from '~/stores/renderer';
 
 import { ICommentHasId, ICommentHasIdList } from '../interfaces/comment';
@@ -42,7 +43,7 @@ export type PageCommentProps = {
   hideIfEmpty?: boolean,
 }
 
-export const PageComment: FC<PageCommentProps> = memo((props:PageCommentProps): JSX.Element => {
+export const PageComment: FC<PageCommentProps> = memo((props: PageCommentProps): JSX.Element => {
 
   const {
     rendererOptions: rendererOptionsByProps,
@@ -56,6 +57,7 @@ export const PageComment: FC<PageCommentProps> = memo((props:PageCommentProps):
   const [isDeleteConfirmModalShown, setIsDeleteConfirmModalShown] = useState<boolean>(false);
   const [showEditorIds, setShowEditorIds] = useState<Set<string>>(new Set());
   const [errorMessageOnDelete, setErrorMessageOnDelete] = useState<string>('');
+  const { trigger: mutatePageInfo } = useSWRMUTxPageInfo(pageId);
 
   const commentsFromOldest = useMemo(() => (comments != null ? [...comments].reverse() : null), [comments]);
   const commentsExceptReply: ICommentHasIdList | undefined = useMemo(
@@ -84,7 +86,8 @@ export const PageComment: FC<PageCommentProps> = memo((props:PageCommentProps):
   const onDeleteCommentAfterOperation = useCallback(() => {
     onCancelDeleteComment();
     mutate();
-  }, [mutate, onCancelDeleteComment]);
+    mutatePageInfo();
+  }, [mutate, onCancelDeleteComment, mutatePageInfo]);
 
   const onDeleteComment = useCallback(async() => {
     if (commentToBeDeleted == null) return;
@@ -92,7 +95,7 @@ export const PageComment: FC<PageCommentProps> = memo((props:PageCommentProps):
       await apiPost('/comments.remove', { comment_id: commentToBeDeleted._id });
       onDeleteCommentAfterOperation();
     }
-    catch (error:unknown) {
+    catch (error: unknown) {
       setErrorMessageOnDelete(error as string);
       toastError(`error: ${error}`);
     }
@@ -100,12 +103,20 @@ export const PageComment: FC<PageCommentProps> = memo((props:PageCommentProps):
 
   const removeShowEditorId = useCallback((commentId: string) => {
     setShowEditorIds((previousState) => {
-      const previousShowEditorIds = new Set(...previousState);
-      previousShowEditorIds.delete(commentId);
-      return previousShowEditorIds;
+      return new Set([...previousState].filter(id => id !== commentId));
     });
   }, []);
 
+  const onReplyButtonClickHandler = useCallback((commentId: string) => {
+    setShowEditorIds(previousState => new Set([...previousState, commentId]));
+  }, []);
+
+  const onCommentButtonClickHandler = useCallback((commentId: string) => {
+    removeShowEditorId(commentId);
+    mutate();
+    mutatePageInfo();
+  }, [removeShowEditorId, mutate, mutatePageInfo]);
+
   if (hideIfEmpty && comments?.length === 0) {
     return <PageCommentRoot />;
   }
@@ -163,7 +174,7 @@ export const PageComment: FC<PageCommentProps> = memo((props:PageCommentProps):
         <div className="page-comments">
           <h2 className={commentTitleClasses}><i className="icon-fw icon-bubbles"></i>Comments</h2>
           <div className="page-comments-list" id="page-comments-list">
-            { commentsExceptReply.map((comment) => {
+            {commentsExceptReply.map((comment) => {
 
               const defaultCommentThreadClasses = 'page-comment-thread pb-5';
               const hasReply: boolean = Object.keys(allReplies).includes(comment._id);
@@ -184,9 +195,7 @@ export const PageComment: FC<PageCommentProps> = memo((props:PageCommentProps):
                             color="secondary"
                             size="sm"
                             className="btn-comment-reply"
-                            onClick={() => {
-                              setShowEditorIds(previousState => new Set(previousState.add(comment._id)));
-                            }}
+                            onClick={() => onReplyButtonClickHandler(comment._id)}
                           >
                             <i className="icon-fw icon-action-undo"></i> Reply
                           </Button>
@@ -201,10 +210,7 @@ export const PageComment: FC<PageCommentProps> = memo((props:PageCommentProps):
                       onCancelButtonClicked={() => {
                         removeShowEditorId(comment._id);
                       }}
-                      onCommentButtonClicked={() => {
-                        removeShowEditorId(comment._id);
-                        mutate();
-                      }}
+                      onCommentButtonClicked={() => onCommentButtonClickHandler(comment._id)}
                       revisionId={revisionId}
                     />
                   )}

+ 5 - 3
apps/app/src/components/PageComment/CommentEditor.tsx

@@ -109,6 +109,8 @@ export const CommentEditor = (props: CommentEditorProps): JSX.Element => {
   }, []);
 
   const initializeEditor = useCallback(async() => {
+    const editingCommentsNum = comment !== '' ? await decrementEditingCommentsNum() : undefined;
+
     setComment('');
     setActiveTab('comment_editor');
     setError(undefined);
@@ -116,11 +118,11 @@ export const CommentEditor = (props: CommentEditorProps): JSX.Element => {
     // reset value
     if (editorRef.current == null) { return }
     editorRef.current.setValue('');
-    const editingCommentsNum = await decrementEditingCommentsNum();
-    if (editingCommentsNum === 0) {
+
+    if (editingCommentsNum != null && editingCommentsNum === 0) {
       mutateIsEnabledUnsavedWarning(false); // must be after clearing comment or else onChange will override bool
     }
-  }, [initializeSlackEnabled, mutateIsEnabledUnsavedWarning, decrementEditingCommentsNum]);
+  }, [initializeSlackEnabled, comment, decrementEditingCommentsNum, mutateIsEnabledUnsavedWarning]);
 
   const cancelButtonClickedHandler = useCallback(() => {
     // change state to not ready

+ 15 - 2
apps/app/src/components/PageEditor.tsx

@@ -83,7 +83,7 @@ const PageEditor = React.memo((): JSX.Element => {
   const { data: currentPathname } = useCurrentPathname();
   const { data: currentPage } = useSWRxCurrentPage();
   const { trigger: mutateCurrentPage } = useSWRMUTxCurrentPage();
-  const { data: grantData, mutate: mutateGrant } = useSelectedGrant();
+  const { data: grantData } = useSelectedGrant();
   const { data: pageTags, sync: syncTagsInfoForEditor } = usePageTagsForEditors(pageId);
   const { mutate: mutateTagsInfo } = useSWRxTagsInfo(pageId);
   const { data: editingMarkdown, mutate: mutateEditingMarkdown } = useEditingMarkdown();
@@ -110,7 +110,13 @@ const PageEditor = React.memo((): JSX.Element => {
 
   const updateStateAfterSave = useUpdateStateAfterSave(pageId, { supressEditingMarkdownMutation: true });
 
-  const currentRevisionId = currentPage?.revision?._id;
+  // TODO: remove workaround
+  // for https://redmine.weseek.co.jp/issues/125923
+  const [createdPageRevisionIdWithAttachment, setCreatedPageRevisionIdWithAttachment] = useState('');
+
+  // TODO: remove workaround
+  // for https://redmine.weseek.co.jp/issues/125923
+  const currentRevisionId = currentPage?.revision?._id ?? createdPageRevisionIdWithAttachment;
 
   const initialValue = useMemo(() => {
     if (!isNotFound) {
@@ -149,6 +155,12 @@ const PageEditor = React.memo((): JSX.Element => {
 
   }, [markdownToPreview, mutateIsConflict]);
 
+  // TODO: remove workaround
+  // for https://redmine.weseek.co.jp/issues/125923
+  useEffect(() => {
+    setCreatedPageRevisionIdWithAttachment('');
+  }, [router]);
+
   useEffect(() => {
     markdownToSave.current = initialValue;
     setMarkdownToPreview(initialValue);
@@ -327,6 +339,7 @@ const PageEditor = React.memo((): JSX.Element => {
         logger.info('Page is created', res.page._id);
         globalEmitter.emit('resetInitializedHackMdStatus');
         mutateIsLatestRevision(true);
+        setCreatedPageRevisionIdWithAttachment(res.page.revision);
         await mutateCurrentPageId(res.page._id);
         await mutateCurrentPage();
       }

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

@@ -213,9 +213,9 @@ const PageListItemLSubstance: ForwardRefRenderFunction<ISelectable, Props> = (pr
                 linkedPagePath={linkedPagePathFormer}
                 linkedPagePathByHtml={linkedPagePathHighlightedFormer}
               />
-              { showPageUpdatedTime && (
+              {showPageUpdatedTime && (
                 <span className="page-list-updated-at text-muted">Last update: {lastUpdateDate}</span>
-              ) }
+              )}
             </div>
             <div className="d-flex align-items-center mb-1">
               {/* Picture */}
@@ -254,32 +254,32 @@ const PageListItemLSubstance: ForwardRefRenderFunction<ISelectable, Props> = (pr
 
               {/* doropdown icon includes page control buttons */}
               {hasBrowsingRights
-              && <div className="ml-auto">
-                <PageItemControl
-                  alignRight
-                  pageId={pageData._id}
-                  pageInfo={isIPageInfoForListing(pageMeta) ? pageMeta : undefined}
-                  isEnableActions={isEnableActions}
-                  isReadOnlyUser={isReadOnlyUser}
-                  forceHideMenuItems={forceHideMenuItems}
-                  onClickBookmarkMenuItem={bookmarkMenuItemClickHandler}
-                  onClickRenameMenuItem={renameMenuItemClickHandler}
-                  onClickDuplicateMenuItem={duplicateMenuItemClickHandler}
-                  onClickDeleteMenuItem={deleteMenuItemClickHandler}
-                  onClickRevertMenuItem={revertMenuItemClickHandler}
-                />
-              </div>
+                && <div className="ml-auto">
+                  <PageItemControl
+                    alignRight
+                    pageId={pageData._id}
+                    pageInfo={isIPageInfoForListing(pageMeta) ? pageMeta : undefined}
+                    isEnableActions={isEnableActions}
+                    isReadOnlyUser={isReadOnlyUser}
+                    forceHideMenuItems={forceHideMenuItems}
+                    onClickBookmarkMenuItem={bookmarkMenuItemClickHandler}
+                    onClickRenameMenuItem={renameMenuItemClickHandler}
+                    onClickDuplicateMenuItem={duplicateMenuItemClickHandler}
+                    onClickDeleteMenuItem={deleteMenuItemClickHandler}
+                    onClickRevertMenuItem={revertMenuItemClickHandler}
+                  />
+                </div>
               }
             </div>
             <div className="page-list-snippet py-1">
               <Clamp lines={2}>
-                { elasticSearchResult != null && elasticSearchResult.snippet != null && (
+                {elasticSearchResult != null && elasticSearchResult.snippet != null && (
                   // eslint-disable-next-line react/no-danger
                   <div dangerouslySetInnerHTML={{ __html: elasticSearchResult.snippet }}></div>
-                ) }
-                { revisionShortBody != null && (
+                )}
+                {revisionShortBody != null && (
                   <div data-testid="revision-short-body-in-page-list-item-L">{revisionShortBody}</div>
-                ) }
+                )}
                 {
                   !hasBrowsingRights && (
                     <>

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

@@ -3,6 +3,7 @@ import React from 'react';
 import { PageListMeta } from '@growi/ui/dist/components/PagePath/PageListMeta';
 import { PagePathLabel } from '@growi/ui/dist/components/PagePath/PagePathLabel';
 import { UserPicture } from '@growi/ui/dist/components/User/UserPicture';
+import Link from 'next/link';
 import Clamp from 'react-multiline-clamp';
 
 import { IPageHasId } from '~/interfaces/page';
@@ -29,7 +30,7 @@ export const PageListItemS = (props: PageListItemSProps): JSX.Element => {
 
   let pagePathElement = <PagePathLabel path={path} additionalClassNames={['mx-1']} />;
   if (!noLink) {
-    pagePathElement = <a className="text-break" href={page.path}>{pagePathElement}</a>;
+    pagePathElement = <Link href={`/${page._id}`} className="text-break" prefetch={false}>{pagePathElement}</Link>;
   }
 
   return (

+ 10 - 2
apps/app/src/components/PageSideContents.tsx

@@ -4,7 +4,9 @@ import { IPageHasId, pagePathUtils } from '@growi/core';
 import { useTranslation } from 'next-i18next';
 import { Link } from 'react-scroll';
 
+import { IPageInfoForOperation } from '~/interfaces/page';
 import { useDescendantsPageListModal } from '~/stores/modal';
+import { useSWRxPageInfo } from '~/stores/page';
 
 import CountBadge from './Common/CountBadge';
 import { ContentLinkButtons } from './ContentLinkButtons';
@@ -29,6 +31,8 @@ export const PageSideContents = (props: PageSideContentsProps): JSX.Element => {
 
   const { page, isSharedUser } = props;
 
+  const { data: pageInfo } = useSWRxPageInfo(page._id);
+
   const pagePath = page.path;
   const isTopPagePath = isTopPage(pagePath);
   const isUsersHomePagePath = isUsersHomePage(pagePath);
@@ -51,7 +55,9 @@ export const PageSideContents = (props: PageSideContentsProps): JSX.Element => {
             {t('page_list')}
 
             {/* Do not display CountBadge if '/trash/*': https://github.com/weseek/growi/pull/7600 */}
-            { !isTrash ? <CountBadge count={page?.descendantCount} offset={1} /> : <div className='px-2'></div>}
+            { !isTrash && pageInfo != null
+              ? <CountBadge count={(pageInfo as IPageInfoForOperation).descendantCount} offset={1} />
+              : <div className='px-2'></div>}
           </button>
         )}
       </div>
@@ -67,7 +73,9 @@ export const PageSideContents = (props: PageSideContentsProps): JSX.Element => {
             >
               <i className="icon-fw icon-bubbles grw-page-accessories-control-icon"></i>
               <span>Comments</span>
-              <CountBadge count={page.commentCount} />
+              { pageInfo != null
+                ? <CountBadge count={(pageInfo as IPageInfoForOperation).commentCount} />
+                : <div className='px-2'></div>}
             </button>
           </Link>
         </div>

+ 2 - 3
apps/app/src/components/PrivateLegacyPages.tsx

@@ -14,7 +14,7 @@ import { V5ConversionErrCode } from '~/interfaces/errors/v5-conversion-error';
 import { V5MigrationStatus } from '~/interfaces/page-listing-results';
 import { IFormattedSearchResult } from '~/interfaces/search';
 import { PageMigrationErrorData, SocketEventName } from '~/interfaces/websocket';
-import { useCurrentUser } from '~/stores/context';
+import { useIsAdmin } from '~/stores/context';
 import {
   ILegacyPrivatePage, usePrivateLegacyPagesMigrationModal,
 } from '~/stores/modal';
@@ -191,9 +191,8 @@ ConvertByPathModal.displayName = 'ConvertByPathModal';
 
 const PrivateLegacyPages = (): JSX.Element => {
   const { t } = useTranslation();
-  const { data: currentUser } = useCurrentUser();
 
-  const isAdmin = currentUser?.admin;
+  const { data: isAdmin } = useIsAdmin();
 
   const [keyword, setKeyword] = useState<string>(initQ);
   const [offset, setOffset] = useState<number>(0);

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

@@ -75,7 +75,7 @@ export const Header = (props: HeaderProps): JSX.Element => {
   const activateByHash = useCallback((url: string) => {
     try {
       const hash = (new URL(url, 'https://example.com')).hash.slice(1);
-      setActive(hash === id);
+      setActive(decodeURIComponent(hash) === id);
     }
     catch (err) {
       logger.debug(err);

+ 20 - 7
apps/app/src/components/ReactMarkdownComponents/NextLink.tsx

@@ -1,3 +1,4 @@
+import { pagePathUtils } from '@growi/core';
 import Link, { LinkProps } from 'next/link';
 
 import { useSiteUrl } from '~/stores/context';
@@ -22,6 +23,18 @@ const isExternalLink = (href: string, siteUrl: string | undefined): boolean => {
   }
 };
 
+const isCreatablePage = (href: string) => {
+  try {
+    const url = new URL(href);
+    const pathName = url.pathname;
+    return pagePathUtils.isCreatablePage(pathName);
+  }
+  catch (err) {
+    logger.debug(err);
+    return false;
+  }
+};
+
 type Props = Omit<LinkProps, 'href'> & {
   children: React.ReactNode,
   id?: string,
@@ -45,13 +58,6 @@ export const NextLink = (props: Props): JSX.Element => {
     Object.entries(rest).filter(([key]) => key.startsWith('data-')),
   );
 
-  // when href is an anchor link
-  if (isAnchorLink(href)) {
-    return (
-      <a id={id} href={href} className={className} {...dataAttributes}>{children}</a>
-    );
-  }
-
   if (isExternalLink(href, siteUrl)) {
     return (
       <a id={id} href={href} className={className} target="_blank" rel="noopener noreferrer" {...dataAttributes}>
@@ -60,6 +66,13 @@ export const NextLink = (props: Props): JSX.Element => {
     );
   }
 
+  // when href is an anchor link or not-creatable path
+  if (isAnchorLink(href) || !isCreatablePage(href)) {
+    return (
+      <a id={id} href={href} className={className} {...dataAttributes}>{children}</a>
+    );
+  }
+
   return (
     <Link {...rest} href={href} prefetch={false} legacyBehavior>
       <a href={href} className={className} {...dataAttributes}>{children}</a>

+ 1 - 2
apps/app/src/components/SearchPage/SearchPageBase.tsx

@@ -17,8 +17,6 @@ import { mutatePageTree } from '~/stores/page-listing';
 
 import { ForceHideMenuItems } from '../Common/Dropdown/PageItemControl';
 
-import { SearchResultList } from './SearchResultList';
-
 import styles from './SearchPageBase.module.scss';
 
 // https://regex101.com/r/brrkBu/1
@@ -44,6 +42,7 @@ type Props = {
 }
 
 
+const SearchResultList = dynamic(() => import('./SearchResultList').then(mod => mod.SearchResultList), { ssr: false });
 const SearchResultContent = dynamic(() => import('./SearchResultContent').then(mod => mod.SearchResultContent), {
   ssr: false,
   loading: () => <></>,

+ 0 - 1
apps/app/src/components/SearchPage/SearchResultList.tsx

@@ -18,7 +18,6 @@ import { mutateSearching } from '~/stores/search';
 import { ForceHideMenuItems } from '../Common/Dropdown/PageItemControl';
 import { PageListItemL } from '../PageList/PageListItemL';
 
-
 type Props = {
   pages: IPageWithSearchMeta[],
   selectedPageId?: string,

+ 7 - 7
apps/app/src/components/ShareLink/ShareLinkPageView.tsx → apps/app/src/components/ShareLinkPageView.tsx

@@ -1,4 +1,4 @@
-import React, { useEffect, useMemo } from 'react';
+import React, { useMemo } from 'react';
 
 import type { IPagePopulatedToShowRevision } from '@growi/core';
 import dynamic from 'next/dynamic';
@@ -10,17 +10,17 @@ import { useIsNotFound } from '~/stores/page';
 import { useViewOptions } from '~/stores/renderer';
 import loggerFactory from '~/utils/logger';
 
-import { MainPane } from '../Layout/MainPane';
-import RevisionRenderer from '../Page/RevisionRenderer';
-import ShareLinkAlert from '../Page/ShareLinkAlert';
-import type { PageSideContentsProps } from '../PageSideContents';
+import { MainPane } from './Layout/MainPane';
+import RevisionRenderer from './Page/RevisionRenderer';
+import ShareLinkAlert from './Page/ShareLinkAlert';
+import type { PageSideContentsProps } from './PageSideContents';
 
 
 const logger = loggerFactory('growi:Page');
 
 
-const PageSideContents = dynamic<PageSideContentsProps>(() => import('../PageSideContents').then(mod => mod.PageSideContents), { ssr: false });
-const ForbiddenPage = dynamic(() => import('../ForbiddenPage'), { ssr: false });
+const PageSideContents = dynamic<PageSideContentsProps>(() => import('./PageSideContents').then(mod => mod.PageSideContents), { ssr: false });
+const ForbiddenPage = dynamic(() => import('./ForbiddenPage'), { ssr: false });
 
 
 type Props = {

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

@@ -297,7 +297,7 @@ const Sidebar = memo((): JSX.Element => {
   const isOpenClass = `${isDrawerOpened ? 'open' : ''}`;
   return (
     <>
-      <div className={`${grwSidebarClass} ${sidebarModeClass} ${isOpenClass} d-print-none`}>
+      <div className={`${grwSidebarClass} ${sidebarModeClass} ${isOpenClass} d-print-none`} data-testid="grw-sidebar">
         <div className="data-layout-container">
           <div
             className='navigation transition-enabled'

+ 5 - 11
apps/app/src/components/Sidebar/SidebarNav.tsx

@@ -1,12 +1,12 @@
 import React, {
-  FC, memo, useCallback, useEffect, useState,
+  FC, memo, useCallback,
 } from 'react';
 
 import Link from 'next/link';
 
 import { useUserUISettings } from '~/client/services/user-ui-settings';
 import { SidebarContentsType } from '~/interfaces/ui';
-import { useCurrentUser } from '~/stores/context';
+import { useIsAdmin, useGrowiCloudUri } from '~/stores/context';
 import { useCurrentSidebarContents } from '~/stores/ui';
 
 import styles from './SidebarNav.module.scss';
@@ -82,17 +82,11 @@ type Props = {
 }
 
 export const SidebarNav: FC<Props> = (props: Props) => {
-
-  const { data: currentUser } = useCurrentUser();
-
-  const [isAdmin, setAdmin] = useState(false);
+  const { data: isAdmin } = useIsAdmin();
+  const { data: growiCloudUri } = useGrowiCloudUri();
 
   const { onItemSelected } = props;
 
-  useEffect(() => {
-    setAdmin(currentUser?.admin === true);
-  }, [currentUser?.admin]);
-
   return (
     <div className={`grw-sidebar-nav ${styles['grw-sidebar-nav']}`} data-vrt-blackout-sidebar-nav>
       <div className="grw-sidebar-nav-primary-container">
@@ -110,7 +104,7 @@ export const SidebarNav: FC<Props> = (props: Props) => {
       <div className="grw-sidebar-nav-secondary-container">
         {isAdmin && <SecondaryItem label="Admin" iconName="settings" href="/admin" />}
         {/* <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" />
       </div>
     </div>

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

@@ -0,0 +1,7 @@
+@use '~/styles/bootstrap/init' as bs;
+
+.dm-templates :global {
+  .dropdown-item:not(:first-child) {
+    border-top: 1px solid bs.$border-color;
+  }
+}

+ 236 - 59
apps/app/src/components/TemplateModal/TemplateModal.tsx

@@ -2,129 +2,306 @@ import React, {
   useCallback, useEffect, useState,
 } 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 {
   Modal,
   ModalHeader,
   ModalBody,
   ModalFooter,
+  UncontrolledDropdown,
+  DropdownToggle,
+  DropdownMenu,
+  DropdownItem,
 } 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 { useTemplates } from '~/stores/template';
 import loggerFactory from '~/utils/logger';
 
 import Preview from '../PageEditor/Preview';
 
 import { useFormatter } from './use-formatter';
 
+
+import styles from './TemplateModal.module.scss';
+
+
 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 TemplateSummaryItemProps = {
+  templateSummary: TemplateSummary,
+  selectedLocale?: string,
+  onClick?: () => void,
   isSelected?: boolean,
+  usersDefaultLang?: Lang,
 }
 
-const TemplateRadioButton = ({ template, onChange, isSelected }: TemplateRadioButtonProps): JSX.Element => {
-  const radioButtonId = `rb-${template.id}`;
+const TemplateListGroupItem: React.FC<TemplateSummaryItemProps> = ({
+  templateSummary,
+  onClick,
+  isSelected,
+  usersDefaultLang,
+}) => {
+  const localizedTemplate = getLocalizedTemplate(templateSummary, usersDefaultLang);
+  const templateLocales = extractSupportedLocales(templateSummary);
+
+  assert(localizedTemplate?.isValid);
 
   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 d-flex">
+        <span className="d-inline-block text-truncate">{localizedTemplate.title}</span>
+        {localizedTemplate.pluginId != null ? <i className="icon-fw icon-puzzle ml-2 text-muted small"></i> : ''}
+      </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']);
 
+const TemplateDropdownItem: React.FC<TemplateSummaryItemProps> = ({
+  templateSummary,
+  onClick,
+  usersDefaultLang,
+}) => {
 
-  const { data: templateModalStatus, close } = useTemplateModal();
+  const localizedTemplate = getLocalizedTemplate(templateSummary, usersDefaultLang);
+  const templateLocales = extractSupportedLocales(templateSummary);
+
+  assert(localizedTemplate?.isValid);
 
+  return (
+    <DropdownItem
+      onClick={onClick}
+      className="px-4 py-3"
+    >
+      <h4 className="mb-1 d-flex">
+        <span className="d-inline-block text-truncate">{localizedTemplate.title}</span>
+        {localizedTemplate.pluginId != null ? <i className="icon-fw icon-puzzle ml-2 text-muted small"></i> : ''}
+      </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>
+  );
+};
+
+type TemplateModalSubstanceProps = {
+  templateModalStatus: TemplateModalStatus,
+  close: () => void,
+}
+
+const TemplateModalSubstance = (props: TemplateModalSubstanceProps): JSX.Element => {
+  const { templateModalStatus, close } = props;
+
+  const { t } = useTranslation(['translation', 'commons']);
+
+  const { data: personalSettingsInfo } = usePersonalSettings();
   const { data: rendererOptions } = usePreviewOptions();
-  const { data: templates } = useTemplates();
+  const { data: templateSummaries, isLoading } = useSWRxTemplates();
 
-  const [selectedTemplate, setSelectedTemplate] = useState<ITemplate>();
+  const [selectedTemplateSummary, setSelectedTemplateSummary] = useState<TemplateSummary>();
+  const [selectedTemplateLocale, setSelectedTemplateLocale] = useState<string>();
+
+  const { data: selectedTemplateMarkdown } = useSWRxTemplate(selectedTemplateSummary, selectedTemplateLocale);
 
   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;
     }
 
-    if (templateModalStatus.onSubmit == null || template == null) {
+    if (templateModalStatus.onSubmit == null) {
       close();
       return;
     }
 
-    templateModalStatus.onSubmit(format(selectedTemplate));
+    templateModalStatus.onSubmit(format(selectedTemplateMarkdown));
     close();
-  }, [close, format, selectedTemplate, templateModalStatus]);
+  }, [close, format, selectedTemplateMarkdown, templateModalStatus]);
 
-  useEffect(() => {
-    if (!templateModalStatus?.isOpened) {
-      setSelectedTemplate(undefined);
+  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;
     }
-  }, [templateModalStatus?.isOpened]);
 
-  if (templates == null || templateModalStatus == null) {
-    return <></>;
-  }
+    setSelectedTemplateLocale(localeToSet);
+    setSelectedTemplateSummary(templateSummary);
+  }, [selectedTemplateLocale, usersDefaultLang]);
+
+  useEffect(() => {
+    if (!templateModalStatus.isOpened) {
+      setSelectedTemplateSummary(undefined);
+      setSelectedTemplateLocale(undefined);
+    }
+  }, [templateModalStatus.isOpened]);
 
   return (
-    <Modal className="link-edit-modal" isOpen={templateModalStatus.isOpened} toggle={close} size="lg" autoFocus={false}>
+    <div data-testid='template-modal'>
       <ModalHeader tag="h4" toggle={close} className="bg-primary text-light">
         {t('template.modal_label.Select template')}
       </ModalHeader>
-
       <ModalBody className="container">
         <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">
+
+            { isLoading && (
+              <div className='h-100 d-flex justify-content-center align-items-center'>
+                <i className="fa fa-2x fa-spinner fa-pulse text-muted mx-auto"></i>
+              </div>
+            ) }
+
+            <div className="list-group">
+              { templateSummaries != null && templateSummaries.map((templateSummary) => {
+                const templateId = constructTemplateId(templateSummary);
+                const isSelected = selectedTemplateSummary != null && constructTemplateId(selectedTemplateSummary) === templateId;
+
+                return (
+                  <TemplateListGroupItem
+                    key={templateId}
+                    templateSummary={templateSummary}
+                    onClick={() => onClickHandler(templateSummary)}
+                    isSelected={isSelected}
+                    usersDefaultLang={usersDefaultLang}
+                  />
+                );
+              }) }
+            </div>
           </div>
-        </div>
+          {/* Dropdown */}
+          <div className='d-lg-none col mb-3'>
+            <UncontrolledDropdown>
+              <DropdownToggle caret type="button" outline className='w-100 text-right' disabled={isLoading}>
+                <span className="float-left">
+                  { (() => {
+                    if (isLoading) {
+                      return 'Loading..';
+                    }
 
-        <hr />
+                    return selectedLocalizedTemplate != null && selectedLocalizedTemplate.isValid
+                      ? selectedLocalizedTemplate.title
+                      : t('Select template');
+                  })() }
+                </span>
+              </DropdownToggle>
+              <DropdownMenu role="menu" className={`p-0 mw-100 ${styles['dm-templates']}`}>
+                { templateSummaries != null && templateSummaries.map((templateSummary) => {
+                  const templateId = constructTemplateId(templateSummary);
 
-        <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 (
+                    <TemplateDropdownItem
+                      key={templateId}
+                      templateSummary={templateSummary}
+                      onClick={() => onClickHandler(templateSummary)}
+                      usersDefaultLang={usersDefaultLang}
+                    />
+                  );
+                }) }
+              </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>
-
       </ModalBody>
       <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')}
         </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')}
         </button>
       </ModalFooter>
+    </div>
+  );
+};
+
+
+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>
   );
 };

+ 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';
 
 
@@ -47,26 +44,24 @@ describe('useFormatter', () => {
 
     // when
     const { format } = useFormatter();
-    const template = mock<ITemplate>();
-    template.markdown = 'markdown body';
-    const markdown = format(template);
+    const markdown = 'markdown body';
+    const formatted = format(markdown);
 
     // then
-    expect(markdown).toBe('markdown body');
+    expect(formatted).toBe('markdown body');
   });
 
   it('returns markdown formatted when currentPagePath is undefined', () => {
     // when
     const { format } = useFormatter();
-    const template = mock<ITemplate>();
-    template.markdown = `
+    const markdown = `
 title: {{{title}}}{{^title}}(empty){{/title}}
 path: {{{path}}}
 `;
-    const markdown = format(template);
+    const formatted = format(markdown);
 
     // then
-    expect(markdown).toBe(`
+    expect(formatted).toBe(`
 title: (empty)
 path: /
 `);
@@ -82,16 +77,15 @@ path: /
 
     // when
     const { format } = useFormatter();
-    const template = mock<ITemplate>();
-    template.markdown = `
+    const markdown = `
 title: {{{title}}}
 path: {{{path}}}
 date: {{yyyy}}/{{MM}}/{{dd}} {{HH}}:{{mm}}
 `;
-    const markdown = format(template);
+    const formatted = format(markdown);
 
     // then
-    expect(markdown).toBe(`
+    expect(formatted).toBe(`
 title: Sandbox
 path: /Sandbox
 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 type { ITemplate } from '@growi/core/dist/interfaces/template';
 import dateFnsFormat from 'date-fns/format';
 import mustache from 'mustache';
 
@@ -10,7 +9,7 @@ import loggerFactory from '~/utils/logger';
 const logger = loggerFactory('growi:components:TemplateModal:use-formatter');
 
 
-type FormatMethod = (selectedTemplate?: ITemplate) => string;
+type FormatMethod = (markdown?: string) => string;
 type FormatterData = {
   format: FormatMethod,
 }
@@ -18,16 +17,15 @@ type FormatterData = {
 export const useFormatter = (): FormatterData => {
   const { data: currentPagePath } = useCurrentPagePath();
 
-  const format: FormatMethod = (selectedTemplate) => {
-    if (selectedTemplate == null) {
+  const format: FormatMethod = (markdown) => {
+    if (markdown == null) {
       return '';
     }
 
     // replace placeholder
-    let markdown = selectedTemplate.markdown;
     const now = new Date();
     try {
-      markdown = mustache.render(selectedTemplate.markdown, {
+      return mustache.render(markdown, {
         title: path.basename(currentPagePath ?? '/'),
         path: currentPagePath ?? '/',
         yyyy: dateFnsFormat(now, 'yyyy'),
@@ -39,9 +37,8 @@ export const useFormatter = (): FormatterData => {
     }
     catch (err) {
       logger.warn('An error occured while ejs processing.', err);
+      return markdown;
     }
-
-    return markdown;
   };
 
   return { format };

+ 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 { 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 => {
-  const { mutate } = useSWRxPlugins();
+  const { mutate } = useSWRxAdminPlugins();
   const { t } = useTranslation('admin');
 
   const submitHandler = useCallback(async(e) => {
@@ -25,7 +25,7 @@ export const PluginInstallerForm = (): JSX.Element => {
 
     const pluginInstallerForm: IGrowiPluginOrigin = {
       url,
-      ghBranch,
+      ghBranch: ghBranch || 'main',
       // 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 { Spinner } from 'reactstrap';
 
-import { useSWRxPlugins } from '../../../stores/growi-plugin';
+import { useSWRxAdminPlugins } from '../../../stores/admin-plugins';
 
 import { PluginCard } from './PluginCard';
 import { PluginInstallerForm } from './PluginInstallerForm';
@@ -19,7 +19,7 @@ const Loading = (): JSX.Element => {
 export const PluginsExtensionPageContents = (): JSX.Element => {
   const { t } = useTranslation('admin');
 
-  const { data, mutate } = useSWRxPlugins();
+  const { data, mutate } = useSWRxAdminPlugins();
 
   return (
     <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 { initializeGrowiFacade, registerGrowiFacade } from '../utils/growi-facade-utils.client';
+import { initializeGrowiFacade, registerGrowiFacade } from '../utils/growi-facade-utils';
 
 declare global {
   // 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 = {
   url: string,
@@ -24,13 +17,23 @@ export type IGrowiPlugin<M extends IGrowiPluginMeta = IGrowiPluginMeta> = {
 
 export type IGrowiPluginMeta = {
   name: string,
-  types: GrowiPluginResourceType[],
+  types: GrowiPluginType[],
   desc?: string,
   author?: string,
 }
 
 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;

+ 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 {
   Schema, type Model, type Document, type Types,
 } from 'mongoose';
 
 import { getOrCreateModel } from '~/server/util/mongoose-utils';
 
-import { GrowiPluginResourceType } from '../interfaces';
 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> {
-  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>
   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 },
   types: {
     type: [String],
-    enum: GrowiPluginResourceType,
+    enum: GrowiPluginType,
     require: true,
   },
   desc: { type: String },
   author: { type: String },
-  themes: [growiThemeMetadataSchema],
+  themes: [Map],
+  templateSummaries: [Map],
 });
 
 const growiPluginOriginSchema = new Schema<IGrowiPluginOrigin>({
@@ -60,14 +47,16 @@ const growiPluginSchema = new Schema<IGrowiPluginDocument, IGrowiPluginModel>({
 });
 
 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({
     isEnabled: true,
-    'meta.types': { $in: types },
-  });
+    'meta.types': { $in: type },
+  }).lean();
 };
 
 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
 const githubReposIdPattern = new RegExp(/^\/([^/]+)\/([^/]+)$/);
 
@@ -44,8 +46,8 @@ export class GitHubUrl {
 
     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 { 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;

+ 11 - 0
apps/app/src/features/growi-plugin/server/services/growi-plugin/generate-template-plugin-meta.ts

@@ -0,0 +1,11 @@
+import type { GrowiPluginValidationData } from '@growi/pluginkit';
+import { scanAllTemplates } from '@growi/pluginkit/dist/v4/server';
+
+import type { IGrowiPlugin, IGrowiTemplatePluginMeta } from '../../../interfaces';
+
+export const generateTemplatePluginMeta = async(plugin: IGrowiPlugin, validationData: GrowiPluginValidationData): Promise<IGrowiTemplatePluginMeta> => {
+  return {
+    ...plugin.meta,
+    templateSummaries: await scanAllTemplates(validationData.projectDirRoot, { pluginId: plugin.installedPath }),
+  };
+};

+ 12 - 0
apps/app/src/features/growi-plugin/server/services/growi-plugin/generate-theme-plugin-meta.ts

@@ -0,0 +1,12 @@
+import type { GrowiPluginValidationData } from '@growi/pluginkit';
+
+import type { IGrowiPlugin, IGrowiThemePluginMeta } from '../../../interfaces';
+
+export const generateThemePluginMeta = async(plugin: IGrowiPlugin, validationData: GrowiPluginValidationData): Promise<IGrowiThemePluginMeta> => {
+  // TODO: validate as a theme plugin
+
+  return {
+    ...plugin.meta,
+    themes: validationData.growiPlugin.themes,
+  };
+};

+ 94 - 0
apps/app/src/features/growi-plugin/server/services/growi-plugin/growi-plugin.integ.ts

@@ -0,0 +1,94 @@
+import fs from 'fs';
+import path from 'path';
+
+import { PLUGIN_STORING_PATH } from '../../consts';
+import { GrowiPlugin } from '../../models';
+
+import { growiPluginService } from './growi-plugin';
+
+describe('Installing a GROWI template plugin', () => {
+
+  it('install() should success', async() => {
+    // when
+    const result = await growiPluginService.install({
+      url: 'https://github.com/weseek/growi-plugin-templates-for-office',
+    });
+    const count = await GrowiPlugin.count({ 'meta.name': 'growi-plugin-templates-for-office' });
+
+    // expect
+    expect(result).toEqual('growi-plugin-templates-for-office');
+    expect(count).toBe(1);
+    expect(fs.existsSync(path.join(
+      PLUGIN_STORING_PATH,
+      'weseek',
+      'growi-plugin-templates-for-office',
+    ))).toBeTruthy();
+  });
+
+  it('install() should success (re-install)', async() => {
+    // confirm
+    const count1 = await GrowiPlugin.count({ 'meta.name': 'growi-plugin-templates-for-office' });
+    expect(count1).toBe(1);
+
+    // setup
+    const dummyFilePath = path.join(
+      PLUGIN_STORING_PATH,
+      'weseek',
+      'growi-plugin-templates-for-office',
+      'dummy.txt',
+    );
+    fs.appendFileSync(dummyFilePath, '');
+    expect(fs.existsSync(dummyFilePath)).toBeTruthy();
+
+    // when
+    const result = await growiPluginService.install({
+      url: 'https://github.com/weseek/growi-plugin-templates-for-office',
+    });
+    const count2 = await GrowiPlugin.count({ 'meta.name': 'growi-plugin-templates-for-office' });
+
+    // expect
+    expect(result).toEqual('growi-plugin-templates-for-office');
+    expect(count2).toBe(1);
+    expect(fs.existsSync(dummyFilePath)).toBeFalsy(); // the dummy file should be removed
+  });
+
+});
+
+describe('Installing a GROWI theme plugin', () => {
+
+  it('install() should success', async() => {
+    // when
+    const result = await growiPluginService.install({
+      url: 'https://github.com/weseek/growi-plugin-theme-welcome-to-fumiya-room',
+    });
+    const count = await GrowiPlugin.count({ 'meta.name': 'growi-plugin-theme-welcome-to-fumiya-room' });
+
+    // expect
+    expect(result).toEqual('growi-plugin-theme-welcome-to-fumiya-room');
+    expect(count).toBe(1);
+    expect(fs.existsSync(path.join(
+      PLUGIN_STORING_PATH,
+      'weseek',
+      'growi-plugin-theme-welcome-to-fumiya-room',
+    ))).toBeTruthy();
+  });
+
+  it('findThemePlugin() should return data with metadata and manifest', async() => {
+    // confirm
+    const count = await GrowiPlugin.count({ 'meta.name': 'growi-plugin-theme-welcome-to-fumiya-room' });
+    expect(count).toBe(1);
+
+    // when
+    const results = await growiPluginService.findThemePlugin('welcome-to-fumiya-room');
+
+    // expect
+    expect(results).not.toBeNull();
+    assert(results != null);
+    expect(results.growiPlugin).not.toBeNull();
+    expect(results.themeMetadata).not.toBeNull();
+    expect(results.themeHref).not.toBeNull();
+    expect(results.themeHref
+      .startsWith('/static/plugins/weseek/growi-plugin-theme-welcome-to-fumiya-room/dist/assets/style.')).toBeTruthy();
+  });
+
+});

+ 90 - 72
apps/app/src/features/growi-plugin/services/growi-plugin.ts → apps/app/src/features/growi-plugin/server/services/growi-plugin/growi-plugin.ts

@@ -1,33 +1,39 @@
 import fs, { readFileSync } from 'fs';
 import path from 'path';
 
-import { GrowiThemeMetadata, ViteManifest } from '@growi/core';
+import { GrowiPluginType, type GrowiThemeMetadata, type ViteManifest } from '@growi/core';
+import type { GrowiPluginPackageData } from '@growi/pluginkit';
+import { importPackageJson, validateGrowiDirective } from '@growi/pluginkit/dist/v4/server';
 // eslint-disable-next-line no-restricted-imports
 import axios from 'axios';
 import mongoose from 'mongoose';
+import sanitize from 'sanitize-filename';
 import streamToPromise from 'stream-to-promise';
 import unzipper from 'unzipper';
 
 import loggerFactory from '~/utils/logger';
-import { resolveFromRoot } from '~/utils/project-dir-utils';
 
-import { GrowiPluginResourceType } from '../interfaces';
 import type {
-  IGrowiPlugin, IGrowiPluginOrigin, IGrowiThemePluginMeta, IGrowiPluginMeta,
-} from '../interfaces';
-import { GrowiPlugin } from '../models';
-import { GitHubUrl } from '../models/vo/github-url';
+  IGrowiPlugin, IGrowiPluginOrigin, IGrowiPluginMeta,
+} from '../../../interfaces';
+import { PLUGIN_EXPRESS_STATIC_DIR, PLUGIN_STORING_PATH } from '../../consts';
+import { GrowiPlugin } from '../../models';
+import { GitHubUrl } from '../../models/vo/github-url';
+
+import { generateTemplatePluginMeta } from './generate-template-plugin-meta';
+import { generateThemePluginMeta } from './generate-theme-plugin-meta';
 
 const logger = loggerFactory('growi:plugins:plugin-utils');
 
-const pluginStoringPath = resolveFromRoot('tmp/plugins');
+export type GrowiPluginResourceEntries = [installedPath: string, href: string][];
 
-const PLUGINS_STATIC_DIR = '/static/plugins'; // configured by express.static
+function retrievePluginManifest(growiPlugin: IGrowiPlugin): ViteManifest | undefined {
+  const manifestPath = path.join(PLUGIN_STORING_PATH, growiPlugin.installedPath, 'dist/manifest.json');
 
-export type GrowiPluginResourceEntries = [installedPath: string, href: string][];
+  if (!fs.existsSync(manifestPath)) {
+    return;
+  }
 
-function retrievePluginManifest(growiPlugin: IGrowiPlugin): ViteManifest {
-  const manifestPath = resolveFromRoot(path.join('tmp/plugins', growiPlugin.installedPath, 'dist/manifest.json'));
   const manifestStr: string = readFileSync(manifestPath, 'utf-8');
   return JSON.parse(manifestStr);
 }
@@ -58,8 +64,8 @@ export class GrowiPluginService implements IGrowiPluginService {
 
       // if not exists repository in file system, download latest plugin repository
       for await (const growiPlugin of growiPlugins) {
-        const pluginPath = path.join(pluginStoringPath, growiPlugin.installedPath);
-        const organizationName = path.join(pluginStoringPath, growiPlugin.organizationName);
+        const pluginPath = path.join(PLUGIN_STORING_PATH, growiPlugin.installedPath);
+        const organizationName = path.join(PLUGIN_STORING_PATH, growiPlugin.organizationName);
         if (fs.existsSync(pluginPath)) {
           continue;
         }
@@ -69,12 +75,12 @@ export class GrowiPluginService implements IGrowiPluginService {
           }
 
           // TODO: imprv Document version and repository version possibly different.
-          const ghUrl = new GitHubUrl(growiPlugin.origin.url, growiPlugin.origin.branchName);
+          const ghUrl = new GitHubUrl(growiPlugin.origin.url, growiPlugin.origin.ghBranch);
           const { reposName, branchName, archiveUrl } = ghUrl;
 
-          const zipFilePath = path.join(pluginStoringPath, `${branchName}.zip`);
-          const unzippedPath = pluginStoringPath;
-          const unzippedReposPath = path.join(pluginStoringPath, `${reposName}-${branchName}`);
+          const zipFilePath = path.join(PLUGIN_STORING_PATH, `${branchName}.zip`);
+          const unzippedPath = PLUGIN_STORING_PATH;
+          const unzippedReposPath = path.join(PLUGIN_STORING_PATH, `${reposName}-${branchName}`);
 
           try {
             // download github repository to local file system
@@ -106,42 +112,42 @@ export class GrowiPluginService implements IGrowiPluginService {
     const {
       organizationName, reposName, branchName, archiveUrl,
     } = ghUrl;
+
+    const sanitizedBranchName = sanitize(branchName);
+
     const installedPath = `${organizationName}/${reposName}`;
 
-    const zipFilePath = path.join(pluginStoringPath, `${branchName}.zip`);
-    const unzippedPath = pluginStoringPath;
-    const unzippedReposPath = path.join(pluginStoringPath, `${reposName}-${branchName}`);
-    const temporaryReposPath = path.join(pluginStoringPath, reposName);
-    const reposStoringPath = path.join(pluginStoringPath, `${installedPath}`);
-    const organizationPath = path.join(pluginStoringPath, organizationName);
+    const organizationPath = path.join(PLUGIN_STORING_PATH, organizationName);
+    const zipFilePath = path.join(organizationPath, `${reposName}-${sanitizedBranchName}.zip`);
+    const temporaryReposPath = path.join(organizationPath, `${reposName}-${sanitizedBranchName}`);
+    const reposPath = path.join(organizationPath, reposName);
 
+    if (!fs.existsSync(organizationPath)) fs.mkdirSync(organizationPath);
 
     let plugins: IGrowiPlugin<IGrowiPluginMeta>[];
 
     try {
       // download github repository to file system's temporary path
       await this.download(archiveUrl, zipFilePath);
-      await this.unzip(zipFilePath, unzippedPath);
-      fs.renameSync(unzippedReposPath, temporaryReposPath);
+      await this.unzip(zipFilePath, organizationPath);
 
       // detect plugins
-      plugins = await GrowiPluginService.detectPlugins(origin, organizationName, reposName);
-
-      if (!fs.existsSync(organizationPath)) fs.mkdirSync(organizationPath);
+      plugins = await GrowiPluginService.detectPlugins(origin, organizationName, reposName, { packageRootPath: temporaryReposPath });
 
       // remove the old repository from the storing path
-      if (fs.existsSync(reposStoringPath)) await fs.promises.rm(reposStoringPath, { recursive: true });
+      if (fs.existsSync(reposPath)) await fs.promises.rm(reposPath, { recursive: true });
 
       // move new repository from temporary path to storing path.
-      fs.renameSync(temporaryReposPath, reposStoringPath);
+      fs.renameSync(temporaryReposPath, reposPath);
     }
     catch (err) {
+      logger.error(err);
+      throw err;
+    }
+    finally {
       // clean up
       if (fs.existsSync(zipFilePath)) await fs.promises.rm(zipFilePath);
-      if (fs.existsSync(unzippedReposPath)) await fs.promises.rm(unzippedReposPath, { recursive: true });
       if (fs.existsSync(temporaryReposPath)) await fs.promises.rm(temporaryReposPath, { recursive: true });
-      logger.error(err);
-      throw err;
     }
 
     try {
@@ -154,9 +160,10 @@ export class GrowiPluginService implements IGrowiPluginService {
       return plugins[0].meta.name;
     }
     catch (err) {
-      // clean up
-      if (fs.existsSync(reposStoringPath)) await fs.promises.rm(reposStoringPath, { recursive: true });
+      // uninstall
+      if (fs.existsSync(reposPath)) await fs.promises.rm(reposPath, { recursive: true });
       await this.deleteOldPluginDocument(installedPath);
+
       logger.error(err);
       throw err;
     }
@@ -189,22 +196,21 @@ export class GrowiPluginService implements IGrowiPluginService {
         }).catch((err) => {
           logger.error(err);
           // eslint-disable-next-line prefer-promise-reject-errors
-          rejects('Filed to download file.');
+          rejects('Failed to download file.');
         });
     });
   }
 
-  private async unzip(zipFilePath: fs.PathLike, unzippedPath: fs.PathLike): Promise<void> {
+  private async unzip(zipFilePath: fs.PathLike, destPath: fs.PathLike): Promise<void> {
     try {
       const stream = fs.createReadStream(zipFilePath);
-      const unzipStream = stream.pipe(unzipper.Extract({ path: unzippedPath }));
+      const unzipStream = stream.pipe(unzipper.Extract({ path: destPath }));
 
       await streamToPromise(unzipStream);
-      await fs.promises.rm(zipFilePath);
     }
     catch (err) {
       logger.error(err);
-      throw new Error('Filed to unzip.');
+      throw new Error('Failed to unzip.');
     }
   }
 
@@ -213,35 +219,39 @@ export class GrowiPluginService implements IGrowiPluginService {
   }
 
   // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types, max-len
-  private static async detectPlugins(origin: IGrowiPluginOrigin, ghOrganizationName: string, ghReposName: string, parentPackageJson?: any): Promise<IGrowiPlugin[]> {
-    const packageJsonPath = path.resolve(pluginStoringPath, ghReposName, 'package.json');
-    const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf-8'));
+  private static async detectPlugins(
+      origin: IGrowiPluginOrigin, ghOrganizationName: string, ghReposName: string,
+      opts?: {
+        packageRootPath?: string,
+        parentPackageData?: GrowiPluginPackageData,
+      },
+  ): Promise<IGrowiPlugin[]> {
+    const packageRootPath = opts?.packageRootPath ?? path.resolve(PLUGIN_STORING_PATH, ghOrganizationName, ghReposName);
 
-    const { growiPlugin } = packageJson;
-    const {
-      name: packageName, description: packageDesc, author: packageAuthor,
-    } = parentPackageJson ?? packageJson;
+    // validate
+    const validationData = await validateGrowiDirective(packageRootPath);
 
+    const packageData = opts?.parentPackageData ?? importPackageJson(packageRootPath);
 
-    if (growiPlugin == null) {
-      throw new Error('This package does not include \'growiPlugin\' section.');
-    }
+    const { growiPlugin } = validationData;
+    const {
+      name: packageName, description: packageDesc, author: packageAuthor,
+    } = packageData;
 
     // detect sub plugins for monorepo
     if (growiPlugin.isMonorepo && growiPlugin.packages != null) {
       const plugins = await Promise.all(
         growiPlugin.packages.map(async(subPackagePath) => {
-          const subPackageInstalledPath = path.join(ghReposName, subPackagePath);
-          return this.detectPlugins(origin, subPackageInstalledPath, packageJson);
+          return this.detectPlugins(origin, ghOrganizationName, ghReposName, {
+            packageRootPath: path.join(packageRootPath, subPackagePath),
+            parentPackageData: packageData,
+          });
         }),
       );
       return plugins.flat();
     }
 
-    if (growiPlugin.types == null) {
-      throw new Error('\'growiPlugin\' section must have a \'types\' property.');
-    }
-    const plugin = {
+    const plugin: IGrowiPlugin = {
       isEnabled: true,
       installedPath: `${ghOrganizationName}/${ghReposName}`,
       organizationName: ghOrganizationName,
@@ -255,11 +265,12 @@ export class GrowiPluginService implements IGrowiPluginService {
     };
 
     // add theme metadata
-    if (growiPlugin.types.includes(GrowiPluginResourceType.Theme)) {
-      (plugin as IGrowiPlugin<IGrowiThemePluginMeta>).meta = {
-        ...plugin.meta,
-        themes: growiPlugin.themes,
-      };
+    if (growiPlugin.types.includes(GrowiPluginType.Theme)) {
+      plugin.meta = await generateThemePluginMeta(plugin, validationData);
+    }
+    // add template metadata
+    if (growiPlugin.types.includes(GrowiPluginType.Template)) {
+      plugin.meta = await generateTemplatePluginMeta(plugin, validationData);
     }
 
     logger.info('Plugin detected => ', plugin);
@@ -286,12 +297,12 @@ export class GrowiPluginService implements IGrowiPluginService {
     }
 
     try {
-      const growiPluginsPath = path.join(pluginStoringPath, growiPlugins.installedPath);
+      const growiPluginsPath = path.join(PLUGIN_STORING_PATH, growiPlugins.installedPath);
       await deleteFolder(growiPluginsPath);
     }
     catch (err) {
       logger.error(err);
-      throw new Error('Filed to delete plugin repository.');
+      throw new Error('Failed to delete plugin repository.');
     }
 
     try {
@@ -299,7 +310,7 @@ export class GrowiPluginService implements IGrowiPluginService {
     }
     catch (err) {
       logger.error(err);
-      throw new Error('Filed to delete plugin from GrowiPlugin documents.');
+      throw new Error('Failed to delete plugin from GrowiPlugin documents.');
     }
 
     return growiPlugins.meta.name;
@@ -311,10 +322,10 @@ export class GrowiPluginService implements IGrowiPluginService {
 
     try {
       // retrieve plugin manifests
-      const growiPlugins = await GrowiPlugin.findEnabledPluginsIncludingAnyTypes([GrowiPluginResourceType.Theme]) as IGrowiPlugin<IGrowiThemePluginMeta>[];
+      const growiPlugins = await GrowiPlugin.findEnabledPluginsByType(GrowiPluginType.Theme);
 
       growiPlugins
-        .forEach(async(growiPlugin) => {
+        .forEach((growiPlugin) => {
           const themeMetadatas = growiPlugin.meta.themes;
           const themeMetadata = themeMetadatas.find(t => t.name === theme);
 
@@ -336,7 +347,10 @@ export class GrowiPluginService implements IGrowiPluginService {
     let themeHref;
     try {
       const manifest = retrievePluginManifest(matchedPlugin);
-      themeHref = `${PLUGINS_STATIC_DIR}/${matchedPlugin.installedPath}/dist/${manifest[matchedThemeMetadata.manifestKey].file}`;
+      if (manifest == null) {
+        throw new Error('The manifest file does not exists');
+      }
+      themeHref = `${PLUGIN_EXPRESS_STATIC_DIR}/${matchedPlugin.installedPath}/dist/${manifest[matchedThemeMetadata.manifestKey].file}`;
     }
     catch (e) {
       logger.error(`Could not read manifest file for the theme '${theme}'`, e);
@@ -357,14 +371,18 @@ export class GrowiPluginService implements IGrowiPluginService {
           const { types } = growiPlugin.meta;
           const manifest = await retrievePluginManifest(growiPlugin);
 
+          if (manifest == null) {
+            return;
+          }
+
           // add script
-          if (types.includes(GrowiPluginResourceType.Script) || types.includes(GrowiPluginResourceType.Template)) {
-            const href = `${PLUGINS_STATIC_DIR}/${growiPlugin.installedPath}/dist/${manifest['client-entry.tsx'].file}`;
+          if (types.includes(GrowiPluginType.Script)) {
+            const href = `${PLUGIN_EXPRESS_STATIC_DIR}/${growiPlugin.installedPath}/dist/${manifest['client-entry.tsx'].file}`;
             entries.push([growiPlugin.installedPath, href]);
           }
           // add link
-          if (types.includes(GrowiPluginResourceType.Script) || types.includes(GrowiPluginResourceType.Style)) {
-            const href = `${PLUGINS_STATIC_DIR}/${growiPlugin.installedPath}/dist/${manifest['client-entry.tsx'].css}`;
+          if (types.includes(GrowiPluginType.Script) || types.includes(GrowiPluginType.Style)) {
+            const href = `${PLUGIN_EXPRESS_STATIC_DIR}/${growiPlugin.installedPath}/dist/${manifest['client-entry.tsx'].css}`;
             entries.push([growiPlugin.installedPath, href]);
           }
         }

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


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

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

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