Преглед изворни кода

Merge branch 'master' into feat/not-use-rehype-sanitize-when-no-xss-prevention

Shun Miyazawa пре 3 година
родитељ
комит
700fce9507
100 измењених фајлова са 1117 додато и 824 уклоњено
  1. 2 4
      .github/workflows/ci-app-prod.yml
  2. 9 7
      .github/workflows/ci-app.yml
  3. 5 1
      .github/workflows/reusable-app-prod.yml
  4. 2 6
      .vscode/launch.json
  5. 2 1
      package.json
  6. 0 0
      packages-obsolete/plugin-attachment-refs/.eslintignore
  7. 0 0
      packages-obsolete/plugin-attachment-refs/.gitignore
  8. 0 0
      packages-obsolete/plugin-attachment-refs/README.md
  9. 0 0
      packages-obsolete/plugin-attachment-refs/package.json
  10. 0 0
      packages-obsolete/plugin-attachment-refs/src/client-entry.js
  11. 0 0
      packages-obsolete/plugin-attachment-refs/src/client/css/index.css
  12. 0 0
      packages-obsolete/plugin-attachment-refs/src/client/js/components/AttachmentList.jsx
  13. 0 0
      packages-obsolete/plugin-attachment-refs/src/client/js/components/ExtractedAttachments.jsx
  14. 0 0
      packages-obsolete/plugin-attachment-refs/src/client/js/util/GalleryContext.js
  15. 0 0
      packages-obsolete/plugin-attachment-refs/src/client/js/util/Interceptor/RefsPostRenderInterceptor.js
  16. 0 0
      packages-obsolete/plugin-attachment-refs/src/client/js/util/Interceptor/RefsPreRenderInterceptor.js
  17. 0 0
      packages-obsolete/plugin-attachment-refs/src/client/js/util/RefsContext.js
  18. 0 0
      packages-obsolete/plugin-attachment-refs/src/client/js/util/TagCacheManagerFactory.js
  19. 0 0
      packages-obsolete/plugin-attachment-refs/src/index.js
  20. 0 0
      packages-obsolete/plugin-attachment-refs/src/server-entry.js
  21. 0 0
      packages-obsolete/plugin-attachment-refs/src/server/routes/index.js
  22. 0 0
      packages-obsolete/plugin-attachment-refs/src/server/routes/refs.js
  23. 0 0
      packages-obsolete/plugin-attachment-refs/src/utils/logger/index.ts
  24. 0 0
      packages-obsolete/plugin-attachment-refs/tsconfig.base.json
  25. 0 0
      packages-obsolete/plugin-attachment-refs/tsconfig.build.cjs.json
  26. 0 0
      packages-obsolete/plugin-attachment-refs/tsconfig.build.esm.json
  27. 0 0
      packages-obsolete/plugin-attachment-refs/tsconfig.json
  28. 0 0
      packages/app/_obsolete/src/client/legacy/crowi.js
  29. 16 14
      packages/app/_obsolete/src/client/services/PageContainer.js
  30. 0 17
      packages/app/bin/templates/plugin-definitions.js.swig
  31. 29 0
      packages/app/cypress.config.ts
  32. 0 18
      packages/app/cypress.json
  33. 4 3
      packages/app/docker/Dockerfile
  34. 1 0
      packages/app/next.config.js
  35. 8 5
      packages/app/package.json
  36. 37 33
      packages/app/public/static/locales/en_US/admin.json
  37. 1 0
      packages/app/public/static/locales/en_US/commons.json
  38. 0 3
      packages/app/public/static/locales/en_US/translation.json
  39. 29 23
      packages/app/public/static/locales/ja_JP/admin.json
  40. 1 0
      packages/app/public/static/locales/ja_JP/commons.json
  41. 0 1
      packages/app/public/static/locales/ja_JP/translation.json
  42. 8 17
      packages/app/public/static/locales/zh_CN/admin.json
  43. 1 0
      packages/app/public/static/locales/zh_CN/commons.json
  44. 0 3
      packages/app/public/static/locales/zh_CN/translation.json
  45. 1 1
      packages/app/resource/locales/en_US/sandbox-diagrams.md
  46. 1 1
      packages/app/resource/locales/ja_JP/sandbox-diagrams.md
  47. 1 1
      packages/app/resource/locales/zh_CN/sandbox-diagrams.md
  48. 0 2
      packages/app/src/client/services/AdminHomeContainer.js
  49. 51 0
      packages/app/src/client/services/activate-plugin.ts
  50. 50 37
      packages/app/src/client/services/page-operation.ts
  51. 6 52
      packages/app/src/client/util/apiNotification.js
  52. 0 21
      packages/app/src/client/util/editor.ts
  53. 91 0
      packages/app/src/client/util/toastr.ts
  54. 0 8
      packages/app/src/components/Admin/AdminHome/AdminHome.jsx
  55. 0 55
      packages/app/src/components/Admin/AdminHome/InstalledPluginTable.jsx
  56. 1 1
      packages/app/src/components/Admin/App/AppSettingsPageContents.tsx
  57. 21 0
      packages/app/src/components/Admin/Common/AdminInstallButtonRow.tsx
  58. 3 0
      packages/app/src/components/Admin/Common/AdminNavigation.jsx
  59. 5 7
      packages/app/src/components/Admin/Customize/CustomizeThemeOptions.jsx
  60. 26 25
      packages/app/src/components/Admin/Customize/CustomizeThemeSetting.tsx
  61. 4 1
      packages/app/src/components/Admin/ElasticsearchManagement/ElasticsearchManagement.tsx
  62. 2 20
      packages/app/src/components/Admin/MarkdownSetting/XssForm.jsx
  63. 13 0
      packages/app/src/components/Admin/PluginsExtension/Loading.js
  64. 68 0
      packages/app/src/components/Admin/PluginsExtension/PluginCard.module.scss
  65. 85 0
      packages/app/src/components/Admin/PluginsExtension/PluginCard.tsx
  66. 88 0
      packages/app/src/components/Admin/PluginsExtension/PluginInstallerForm.tsx
  67. 58 0
      packages/app/src/components/Admin/PluginsExtension/PluginsExtensionPageContents.tsx
  68. 2 2
      packages/app/src/components/Admin/Security/LocalSecuritySettingContents.jsx
  69. 2 2
      packages/app/src/components/Admin/UserGroup/UserGroupTable.tsx
  70. 2 5
      packages/app/src/components/BookmarkButtons.tsx
  71. 1 1
      packages/app/src/components/Common/Dropdown/PageItemControl.tsx
  72. 2 2
      packages/app/src/components/CreateTemplateModal.jsx
  73. 0 117
      packages/app/src/components/Drawio.tsx
  74. 1 1
      packages/app/src/components/EmptyTrashModal.tsx
  75. 17 2
      packages/app/src/components/EventListeneres/HashChanged.tsx
  76. 2 2
      packages/app/src/components/InAppNotification/InAppNotificationList.tsx
  77. 3 6
      packages/app/src/components/InAppNotification/PageNotification/PageModelNotification.tsx
  78. 5 3
      packages/app/src/components/InstallerForm.tsx
  79. 1 1
      packages/app/src/components/Layout/AdminLayout.tsx
  80. 0 2
      packages/app/src/components/Layout/BasicLayout.tsx
  81. 0 10
      packages/app/src/components/Layout/NoLoginLayout.module.scss
  82. 6 12
      packages/app/src/components/Layout/RawLayout.tsx
  83. 2 5
      packages/app/src/components/LikeButtons.tsx
  84. 9 4
      packages/app/src/components/LoginForm.tsx
  85. 4 6
      packages/app/src/components/Navbar/AppearanceModeDropdown.tsx
  86. 7 4
      packages/app/src/components/Navbar/GrowiContextualSubNavigation.tsx
  87. 9 3
      packages/app/src/components/Navbar/GrowiNavbar.tsx
  88. 0 5
      packages/app/src/components/Navbar/PageEditorModeManager.jsx
  89. 3 3
      packages/app/src/components/Navbar/PersonalDropdown.jsx
  90. 0 39
      packages/app/src/components/NotAvailableForGuest.jsx
  91. 31 0
      packages/app/src/components/NotAvailableForGuest.tsx
  92. 6 0
      packages/app/src/components/Page.module.scss
  93. 174 158
      packages/app/src/components/Page.tsx
  94. 1 1
      packages/app/src/components/Page/CopyDropdown.jsx
  95. 58 1
      packages/app/src/components/Page/DisplaySwitcher.tsx
  96. 21 24
      packages/app/src/components/Page/RenderTagLabels.tsx
  97. 1 1
      packages/app/src/components/Page/TagLabels.tsx
  98. 1 1
      packages/app/src/components/PageAlert/FixPageGrantAlert.tsx
  99. 15 12
      packages/app/src/components/PageAlert/TrashPageAlert.tsx
  100. 1 1
      packages/app/src/components/PageComment/Comment.tsx

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

@@ -14,10 +14,9 @@ on:
       - '!packages/app/docker/**'
       - '!packages/app/docker/**'
       - packages/codemirror-textlint/**
       - packages/codemirror-textlint/**
       - packages/core/**
       - packages/core/**
-      - packages/remark-growi-plugin/**
+      - packages/remark-*/**
       - packages/slack/**
       - packages/slack/**
       - packages/ui/**
       - packages/ui/**
-      - packages/plugin-**
   pull_request:
   pull_request:
     branches:
     branches:
       - master
       - master
@@ -32,10 +31,9 @@ on:
       - '!packages/app/docker/**'
       - '!packages/app/docker/**'
       - packages/codemirror-textlint/**
       - packages/codemirror-textlint/**
       - packages/core/**
       - packages/core/**
-      - packages/remark-growi-plugin/**
+      - packages/remark-*/**
       - packages/slack/**
       - packages/slack/**
       - packages/ui/**
       - packages/ui/**
-      - packages/plugin-**
   workflow_call:
   workflow_call:
     inputs:
     inputs:
       cypress-config-video:
       cypress-config-video:

+ 9 - 7
.github/workflows/ci-app.yml

@@ -16,10 +16,9 @@ on:
       - '!packages/app/docker/**'
       - '!packages/app/docker/**'
       - packages/codemirror-textlint/**
       - packages/codemirror-textlint/**
       - packages/core/**
       - packages/core/**
-      - packages/remark-growi-plugin/**
+      - packages/remark-*/**
       - packages/slack/**
       - packages/slack/**
       - packages/ui/**
       - packages/ui/**
-      - packages/plugin-*/**
 
 
 jobs:
 jobs:
   lint:
   lint:
@@ -53,12 +52,15 @@ jobs:
         run: |
         run: |
           npx lerna bootstrap -- --frozen-lockfile
           npx lerna bootstrap -- --frozen-lockfile
 
 
-      - name: lerna run lint for plugins
+      - name: lerna run lint for dependent packages
         run: |
         run: |
-          yarn lerna run lint --scope @growi/remark-growi-plugin --scope @growi/plugin-*
+          yarn lerna run lint --scope @growi/codemirror-textlint --scope @growi/core --scope @growi/hackmd --scope @growi/preset-themes --scope @growi/remark-* --scope @growi/slack --scope @growi/ui
+      - name: build dependent packages
+        run: |
+          yarn lerna run build --scope @growi/preset-themes
       - name: lerna run lint for app
       - name: lerna run lint for app
         run: |
         run: |
-          yarn lerna run lint --scope @growi/app --scope @growi/codemirror-textlint --scope @growi/core --scope @growi/slack --scope @growi/ui
+          yarn lerna run lint --scope @growi/app
 
 
       - name: Slack Notification
       - name: Slack Notification
         uses: weseek/ghaction-slack-notification@master
         uses: weseek/ghaction-slack-notification@master
@@ -109,7 +111,7 @@ jobs:
 
 
       - name: lerna run test for plugins
       - name: lerna run test for plugins
         run: |
         run: |
-          yarn lerna run test --scope @growi/remark-growi-plugin --scope @growi/plugin-*
+          yarn lerna run test --scope @growi/remark-*
 
 
       - name: Test app
       - name: Test app
         working-directory: ./packages/app
         working-directory: ./packages/app
@@ -124,7 +126,7 @@ jobs:
           name: Coverage Report
           name: Coverage Report
           path: |
           path: |
             packages/app/coverage
             packages/app/coverage
-            packages/remark-growi-plugin/coverage
+            packages/remark-growi-directive/coverage
 
 
       - name: Slack Notification
       - name: Slack Notification
         uses: weseek/ghaction-slack-notification@master
         uses: weseek/ghaction-slack-notification@master

+ 5 - 1
.github/workflows/reusable-app-prod.yml

@@ -235,7 +235,11 @@ jobs:
 
 
     - name: lerna bootstrap
     - name: lerna bootstrap
       run: |
       run: |
-        npx lerna bootstrap -- --frozen-lockfile
+        npx lerna bootstrap -- --production
+
+    - name: lerna add packages needed for CI
+      run: |
+        npx lerna add yargs
 
 
     - name: Download production files artifact
     - name: Download production files artifact
       uses: actions/download-artifact@v3
       uses: actions/download-artifact@v3

+ 2 - 6
.vscode/launch.json

@@ -73,12 +73,8 @@
             "path": "${workspaceFolder}/packages/core"
             "path": "${workspaceFolder}/packages/core"
           },
           },
           {
           {
-            "url": "webpack://_n_e/plugin-attachment-refs",
-            "path": "${workspaceFolder}/packages/plugin-attachment-refs"
-          },
-          {
-            "url": "webpack://_n_e/plugin-lsx",
-            "path": "${workspaceFolder}/packages/plugin-lsx"
+            "url": "webpack://_n_e/remark-lsx",
+            "path": "${workspaceFolder}/packages/remark-lsx"
           },
           },
           {
           {
             "url": "webpack://_n_e/slack",
             "url": "webpack://_n_e/slack",

+ 2 - 1
package.json

@@ -61,7 +61,7 @@
     "@types/rewire": "^2.5.28",
     "@types/rewire": "^2.5.28",
     "@typescript-eslint/eslint-plugin": "^5.0.0",
     "@typescript-eslint/eslint-plugin": "^5.0.0",
     "@typescript-eslint/parser": "^5.0.0",
     "@typescript-eslint/parser": "^5.0.0",
-    "cypress": "^9.2.0",
+    "cypress": "^12.0.1",
     "eslint": "^8.18.0",
     "eslint": "^8.18.0",
     "eslint-config-next": "^12.1.6",
     "eslint-config-next": "^12.1.6",
     "eslint-config-weseek": "^2.1.0",
     "eslint-config-weseek": "^2.1.0",
@@ -89,6 +89,7 @@
     "ts-node": "^10.9.1",
     "ts-node": "^10.9.1",
     "tsconfig-paths": "^3.9.0",
     "tsconfig-paths": "^3.9.0",
     "typescript": "~4.7",
     "typescript": "~4.7",
+    "vite": "^3.2.5",
     "yargs": "^17.3.1"
     "yargs": "^17.3.1"
   },
   },
   "engines": {
   "engines": {

+ 0 - 0
packages/plugin-attachment-refs/.eslintignore → packages-obsolete/plugin-attachment-refs/.eslintignore


+ 0 - 0
packages/plugin-attachment-refs/.gitignore → packages-obsolete/plugin-attachment-refs/.gitignore


+ 0 - 0
packages/plugin-attachment-refs/README.md → packages-obsolete/plugin-attachment-refs/README.md


+ 0 - 0
packages/plugin-attachment-refs/package.json → packages-obsolete/plugin-attachment-refs/package.json


+ 0 - 0
packages/plugin-attachment-refs/src/client-entry.js → packages-obsolete/plugin-attachment-refs/src/client-entry.js


+ 0 - 0
packages/plugin-attachment-refs/src/client/css/index.css → packages-obsolete/plugin-attachment-refs/src/client/css/index.css


+ 0 - 0
packages/plugin-attachment-refs/src/client/js/components/AttachmentList.jsx → packages-obsolete/plugin-attachment-refs/src/client/js/components/AttachmentList.jsx


+ 0 - 0
packages/plugin-attachment-refs/src/client/js/components/ExtractedAttachments.jsx → packages-obsolete/plugin-attachment-refs/src/client/js/components/ExtractedAttachments.jsx


+ 0 - 0
packages/plugin-attachment-refs/src/client/js/util/GalleryContext.js → packages-obsolete/plugin-attachment-refs/src/client/js/util/GalleryContext.js


+ 0 - 0
packages/plugin-attachment-refs/src/client/js/util/Interceptor/RefsPostRenderInterceptor.js → packages-obsolete/plugin-attachment-refs/src/client/js/util/Interceptor/RefsPostRenderInterceptor.js


+ 0 - 0
packages/plugin-attachment-refs/src/client/js/util/Interceptor/RefsPreRenderInterceptor.js → packages-obsolete/plugin-attachment-refs/src/client/js/util/Interceptor/RefsPreRenderInterceptor.js


+ 0 - 0
packages/plugin-attachment-refs/src/client/js/util/RefsContext.js → packages-obsolete/plugin-attachment-refs/src/client/js/util/RefsContext.js


+ 0 - 0
packages/plugin-attachment-refs/src/client/js/util/TagCacheManagerFactory.js → packages-obsolete/plugin-attachment-refs/src/client/js/util/TagCacheManagerFactory.js


+ 0 - 0
packages/plugin-attachment-refs/src/index.js → packages-obsolete/plugin-attachment-refs/src/index.js


+ 0 - 0
packages/plugin-attachment-refs/src/server-entry.js → packages-obsolete/plugin-attachment-refs/src/server-entry.js


+ 0 - 0
packages/plugin-attachment-refs/src/server/routes/index.js → packages-obsolete/plugin-attachment-refs/src/server/routes/index.js


+ 0 - 0
packages/plugin-attachment-refs/src/server/routes/refs.js → packages-obsolete/plugin-attachment-refs/src/server/routes/refs.js


+ 0 - 0
packages/plugin-attachment-refs/src/utils/logger/index.ts → packages-obsolete/plugin-attachment-refs/src/utils/logger/index.ts


+ 0 - 0
packages/plugin-attachment-refs/tsconfig.base.json → packages-obsolete/plugin-attachment-refs/tsconfig.base.json


+ 0 - 0
packages/plugin-attachment-refs/tsconfig.build.cjs.json → packages-obsolete/plugin-attachment-refs/tsconfig.build.cjs.json


+ 0 - 0
packages/plugin-attachment-refs/tsconfig.build.esm.json → packages-obsolete/plugin-attachment-refs/tsconfig.build.esm.json


+ 0 - 0
packages/plugin-attachment-refs/tsconfig.json → packages-obsolete/plugin-attachment-refs/tsconfig.json


+ 0 - 0
packages/app/src/client/legacy/crowi.js → packages/app/_obsolete/src/client/legacy/crowi.js


+ 16 - 14
packages/app/_obsolete/src/client/services/PageContainer.js

@@ -277,12 +277,13 @@ export default class PageContainer extends Container {
     });
     });
   }
   }
 
 
-  // request to server so the client to join a room for each page
-  emitJoinPageRoomRequest() {
-    const socketIoContainer = this.appContainer.getContainer('SocketIoContainer');
-    const socket = socketIoContainer.getSocket();
-    socket.emit('join:page', { socketId: socket.id, pageId: this.state.pageId });
-  }
+  // replaced accompanied by omiting container: https://github.com/weseek/growi/pull/6968
+  // // request to server so the client to join a room for each page
+  // emitJoinPageRoomRequest() {
+  //   const socketIoContainer = this.appContainer.getContainer('SocketIoContainer');
+  //   const socket = socketIoContainer.getSocket();
+  //   socket.emit('join:page', { socketId: socket.id, pageId: this.state.pageId });
+  // }
 
 
   addWebSocketEventHandlers() {
   addWebSocketEventHandlers() {
     // eslint-disable-next-line @typescript-eslint/no-this-alias
     // eslint-disable-next-line @typescript-eslint/no-this-alias
@@ -300,15 +301,16 @@ export default class PageContainer extends Container {
       }
       }
     });
     });
 
 
-    socket.on('page:update', (data) => {
-      logger.debug({ obj: data }, `websocket on 'page:update'`); // eslint-disable-line quotes
+    // replaced accompanied by omiting container: https://github.com/weseek/growi/pull/6968
+    // socket.on('page:update', (data) => {
+    //   logger.debug({ obj: data }, `websocket on 'page:update'`); // eslint-disable-line quotes
 
 
-      // update remote page data
-      const { s2cMessagePageUpdated } = data;
-      if (s2cMessagePageUpdated.pageId === pageContainer.state.pageId) {
-        pageContainer.setLatestRemotePageData(s2cMessagePageUpdated);
-      }
-    });
+    //   // update remote page data
+    //   const { s2cMessagePageUpdated } = data;
+    //   if (s2cMessagePageUpdated.pageId === pageContainer.state.pageId) {
+    //     pageContainer.setLatestRemotePageData(s2cMessagePageUpdated);
+    //   }
+    // });
 
 
     socket.on('page:delete', (data) => {
     socket.on('page:delete', (data) => {
       logger.debug({ obj: data }, `websocket on 'page:delete'`); // eslint-disable-line quotes
       logger.debug({ obj: data }, `websocket on 'page:delete'`); // eslint-disable-line quotes

+ 0 - 17
packages/app/bin/templates/plugin-definitions.js.swig

@@ -1,17 +0,0 @@
-/*
- * !! don't commit this file !!
- * !!      just revert       !!
- */
-module.exports = [
-  {% for definition in definitions %}{
-    name: '{{ definition.name }}',
-    meta: require('{{ definition.name }}'),
-    entries: [
-      {% for entryPath in definition.entries %}
-      require('{{ entryPath }}').default,
-      {% endfor %}
-    ]
-  },
-  {% endfor %}
-
-]

+ 29 - 0
packages/app/cypress.config.ts

@@ -0,0 +1,29 @@
+import { defineConfig } from 'cypress';
+
+export default defineConfig({
+  e2e: {
+    baseUrl: 'http://localhost:3000',
+    specPattern: 'test/cypress/integration/',
+    supportFile: 'test/cypress/support/index.ts',
+    setupNodeEvents: (on) => {
+      // 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) {
+          launchOptions.args.push('--window-size=1400,1024');
+          launchOptions.args.push('--force-device-scale-factor=1');
+        }
+        return launchOptions;
+      });
+    },
+  },
+  fileServerFolder: 'test/cypress',
+  fixturesFolder: 'test/cypress/fixtures',
+  screenshotsFolder: 'test/cypress/screenshots',
+  videosFolder: 'test/cypress/videos',
+
+  viewportWidth: 1400,
+  viewportHeight: 1024,
+
+  defaultCommandTimeout: 30000,
+});

+ 0 - 18
packages/app/cypress.json

@@ -1,18 +0,0 @@
-{
-  "baseUrl": "http://localhost:3000",
-
-  "fileServerFolder": "test/cypress",
-  "fixturesFolder": "test/cypress/fixtures",
-  "integrationFolder": "test/cypress/integration",
-  "screenshotsFolder": "test/cypress/screenshots",
-  "videosFolder": "test/cypress/videos",
-  "supportFile": "test/cypress/support/index.ts",
-  "pluginsFile": "test/cypress/plugins/index.ts",
-  "testFiles": "**/*.spec.ts",
-
-  "viewportWidth": 1400,
-  "viewportHeight": 1024,
-
-  "experimentalSessionSupport": true,
-  "defaultCommandTimeout": 30000
-}

+ 4 - 3
packages/app/docker/Dockerfile

@@ -105,12 +105,13 @@ COPY ["package.json", "lerna.json", "tsconfig.base.json", "./"]
 COPY packages/app packages/app
 COPY packages/app packages/app
 COPY packages/core packages/core
 COPY packages/core packages/core
 COPY packages/codemirror-textlint packages/codemirror-textlint
 COPY packages/codemirror-textlint packages/codemirror-textlint
-COPY packages/plugin-attachment-refs packages/plugin-attachment-refs
-COPY packages/plugin-lsx packages/plugin-lsx
 COPY packages/slack packages/slack
 COPY packages/slack packages/slack
 COPY packages/ui packages/ui
 COPY packages/ui packages/ui
-COPY packages/remark-growi-plugin packages/remark-growi-plugin
+COPY packages/remark-drawio packages/remark-drawio
+COPY packages/remark-growi-directive packages/remark-growi-directive
+COPY packages/remark-lsx packages/remark-lsx
 COPY packages/hackmd packages/hackmd
 COPY packages/hackmd packages/hackmd
+COPY packages/preset-themes packages/preset-themes
 
 
 # build
 # build
 RUN yarn lerna run build
 RUN yarn lerna run build

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

@@ -25,6 +25,7 @@ const setupTranspileModules = () => {
     // listing ESM packages until experimental.esmExternals works correctly to avoid ERR_REQUIRE_ESM
     // listing ESM packages until experimental.esmExternals works correctly to avoid ERR_REQUIRE_ESM
     'react-markdown',
     'react-markdown',
     'unified',
     'unified',
+    'markdown-table',
     'character-entities-html4',
     'character-entities-html4',
     'comma-separated-tokens',
     'comma-separated-tokens',
     'decode-named-character-reference',
     'decode-named-character-reference',

+ 8 - 5
packages/app/package.json

@@ -46,8 +46,7 @@
     "openapi:v3": "yarn cross-env API_VERSION=3 yarn swagger-jsdoc -- \"src/server/routes/apiv3/**/*.js\" \"src/server/models/**/*.js\"",
     "openapi:v3": "yarn cross-env API_VERSION=3 yarn swagger-jsdoc -- \"src/server/routes/apiv3/**/*.js\" \"src/server/models/**/*.js\"",
     "openapi:v1": "yarn cross-env API_VERSION=1 yarn swagger-jsdoc -- \"src/server/*/*.js\" \"src/server/models/**/*.js\"",
     "openapi:v1": "yarn cross-env API_VERSION=1 yarn swagger-jsdoc -- \"src/server/*/*.js\" \"src/server/models/**/*.js\"",
     "resources:hackmd": "yarn lerna run build --scope=@growi/hackmd",
     "resources:hackmd": "yarn lerna run build --scope=@growi/hackmd",
-    "resources:dummy": "true",
-    "// resources:plugin": "yarn ts-node bin/generate-plugin-definitions-source.ts",
+    "resources:preset-themes": "yarn lerna run build --scope=@growi/preset-themes",
     "// resources:dl-resources": "yarn ts-node bin/download-cdn-resources.ts",
     "// resources:dl-resources": "yarn ts-node bin/download-cdn-resources.ts",
     "ts-node": "node -r ts-node/register -r tsconfig-paths/register -r dotenv-flow/config"
     "ts-node": "node -r ts-node/register -r tsconfig-paths/register -r dotenv-flow/config"
   },
   },
@@ -68,8 +67,10 @@
     "@growi/codemirror-textlint": "^6.0.0-RC.9",
     "@growi/codemirror-textlint": "^6.0.0-RC.9",
     "@growi/core": "^6.0.0-RC.9",
     "@growi/core": "^6.0.0-RC.9",
     "@growi/hackmd": "^6.0.0-RC.9",
     "@growi/hackmd": "^6.0.0-RC.9",
-    "@growi/plugin-attachment-refs": "^6.0.0-RC.9",
-    "@growi/plugin-lsx": "^6.0.0-RC.9",
+    "@growi/preset-themes": "^6.0.0-RC.9",
+    "@growi/remark-drawio": "^6.0.0-RC.9",
+    "@growi/remark-growi-directive": "^6.0.0-RC.9",
+    "@growi/remark-lsx": "^6.0.0-RC.9",
     "@growi/slack": "^6.0.0-RC.9",
     "@growi/slack": "^6.0.0-RC.9",
     "@promster/express": "^7.0.2",
     "@promster/express": "^7.0.2",
     "@promster/server": "^7.0.4",
     "@promster/server": "^7.0.4",
@@ -122,6 +123,7 @@
     "is-absolute-url": "^4.0.1",
     "is-absolute-url": "^4.0.1",
     "is-iso-date": "^0.0.1",
     "is-iso-date": "^0.0.1",
     "lucene-query-parser": "^1.2.0",
     "lucene-query-parser": "^1.2.0",
+    "markdown-table": "^1.1.1",
     "md5": "^2.2.1",
     "md5": "^2.2.1",
     "method-override": "^3.0.0",
     "method-override": "^3.0.0",
     "migrate-mongo": "^8.2.3",
     "migrate-mongo": "^8.2.3",
@@ -156,6 +158,7 @@
     "react-bootstrap-typeahead": "^5.2.2",
     "react-bootstrap-typeahead": "^5.2.2",
     "react-card-flip": "^1.0.10",
     "react-card-flip": "^1.0.10",
     "react-datepicker": "^4.7.0",
     "react-datepicker": "^4.7.0",
+    "react-disable": "^0.1.1",
     "react-dnd": "^14.0.5",
     "react-dnd": "^14.0.5",
     "react-dnd-html5-backend": "^14.1.0",
     "react-dnd-html5-backend": "^14.1.0",
     "react-dom": "^18.2.0",
     "react-dom": "^18.2.0",
@@ -164,6 +167,7 @@
     "react-multiline-clamp": "^2.0.0",
     "react-multiline-clamp": "^2.0.0",
     "react-scroll": "^1.8.7",
     "react-scroll": "^1.8.7",
     "react-syntax-highlighter": "^15.5.0",
     "react-syntax-highlighter": "^15.5.0",
+    "react-toastify": "^9.1.1",
     "react-use-ripple": "^1.5.2",
     "react-use-ripple": "^1.5.2",
     "reactstrap": "^8.9.0",
     "reactstrap": "^8.9.0",
     "reconnecting-websocket": "^4.4.0",
     "reconnecting-websocket": "^4.4.0",
@@ -236,7 +240,6 @@
     "jquery.cookie": "~1.4.1",
     "jquery.cookie": "~1.4.1",
     "jshint": "^2.13.0",
     "jshint": "^2.13.0",
     "load-css-file": "^1.0.0",
     "load-css-file": "^1.0.0",
-    "markdown-table": "^1.1.1",
     "material-icons": "^1.11.3",
     "material-icons": "^1.11.3",
     "morgan": "^1.10.0",
     "morgan": "^1.10.0",
     "next-transpile-modules": "^9.0.0",
     "next-transpile-modules": "^9.0.0",

+ 37 - 33
packages/app/public/static/locales/en_US/admin.json

@@ -2,11 +2,13 @@
   "meta": {
   "meta": {
     "display_name": "English"
     "display_name": "English"
   },
   },
-  "wiki_management_home_page": "Wiki Management Home Page",
   "last_login": "Last login",
   "last_login": "Last login",
-  "anyone_with_the_link": "anyone with the link",
-  "only_me": "only me",
-  "only_inside_the_group": "only inside the group",
+  "wiki_management_home_page": "Wiki Management Home Page",
+  "public": "Public",
+  "anyone_with_the_link": "Anyone with the link",
+  "specified_users": "Specified users",
+  "only_me": "Only me",
+  "only_inside_the_group": "Only inside the group",
   "security_settings": {
   "security_settings": {
     "security_settings": "Security Settings",
     "security_settings": "Security Settings",
     "scope_of_page_disclosure": "Scope of page disclosure",
     "scope_of_page_disclosure": "Scope of page disclosure",
@@ -16,8 +18,8 @@
     "always_displayed": "Always displayed",
     "always_displayed": "Always displayed",
     "displayed_or_hidden": "Displayed / Hidden",
     "displayed_or_hidden": "Displayed / Hidden",
     "Fixed by env var": "This is fixed by the env var <code>{{key}}={{value}}</code>.",
     "Fixed by env var": "This is fixed by the env var <code>{{key}}={{value}}</code>.",
-    "Register limitation": "Register limitation",
-    "Register limitation desc": "Restriction of new users' registration",
+    "register_limitation": "Register limitation",
+    "register_limitation_desc": "Restriction of new users' registration",
     "The whitelist of registration permission E-mail address": "The whitelist of registration permission E-mail address",
     "The whitelist of registration permission E-mail address": "The whitelist of registration permission E-mail address",
     "users_without_account": "Users without account is not accessible",
     "users_without_account": "Users without account is not accessible",
     "example": "Example",
     "example": "Example",
@@ -203,7 +205,7 @@
         "username_detail": "Specification of mappings for <code>username</code> when creating new users",
         "username_detail": "Specification of mappings for <code>username</code> when creating new users",
         "name_detail": "Specification of mappings for <code>name</code> when creating new users",
         "name_detail": "Specification of mappings for <code>name</code> when creating new users",
         "mapping_detail": "Specification of mappings for %s when creating new users",
         "mapping_detail": "Specification of mappings for %s when creating new users",
-        "register_1": "Contant to OIDC IdP Administrator",
+        "register_1": "Contact to OIDC IdP Administrator",
         "register_2": "Register your OIDC App with \"Authorization callback URL\" as <code>%s</code>",
         "register_2": "Register your OIDC App with \"Authorization callback URL\" as <code>%s</code>",
         "register_3": "Copy and paste your ClientID and Client Secret above",
         "register_3": "Copy and paste your ClientID and Client Secret above",
         "updated_oidc": "Succeeded to update OpenID Connect",
         "updated_oidc": "Succeeded to update OpenID Connect",
@@ -279,13 +281,35 @@
     "toggle_notification": "Updated setting of {{path}}",
     "toggle_notification": "Updated setting of {{path}}",
     "not_found_global_notification_triggerid": "Not found the global notification id"
     "not_found_global_notification_triggerid": "Not found the global notification id"
   },
   },
+  "full_text_search_management": {
+    "full_text_search_management": "Full Text Search Management",
+    "elasticsearch_management": "Elasticsearch management",
+    "connection_status": "Connection status",
+    "connection_status_label_unconfigured": "UNCONFIGURED",
+    "connection_status_label_connected": "CONNECTED",
+    "connection_status_label_disconnected": "DISCONNECTED",
+    "connection_status_label_erroroccured": "ERROR OCCURED ON SEARCH SERVICE",
+    "indices_status": "Indices Status",
+    "indices_status_label_normalized": "NORMALIZED",
+    "indices_status_label_unnormalized": "REBUILDING or BROKEN",
+    "indices_summary": "Indices summary",
+    "reconnect": "Reconnect",
+    "reconnect_button": "Try to reconnect",
+    "reconnect_description": "Click the button to try to reconnect to Elasticsearch.",
+    "normalize": "Normalize",
+    "normalize_button": "Normalize indices",
+    "normalize_description": "Click the button to repair broken indices.",
+    "rebuild": "Rebuild",
+    "rebuild_button": "Rebuild index",
+    "rebuild_description_1": "Click the button to rebuild index and add all page datas.",
+    "rebuild_description_2": "This may take a while."
+  },
   "mailer_setup_required":"<a href='/admin/app'>Email settings</a> are required to send.",
   "mailer_setup_required":"<a href='/admin/app'>Email settings</a> are required to send.",
   "admin_top": {
   "admin_top": {
     "management_wiki": "Management Wiki",
     "management_wiki": "Management Wiki",
     "system_information": "System information",
     "system_information": "System information",
     "wiki_administrator": "Only wiki administrator can access this page",
     "wiki_administrator": "Only wiki administrator can access this page",
     "assign_administrator": "You can assign the selected user to be a wiki administrator on the User Management page using the 'Give admin access' button",
     "assign_administrator": "You can assign the selected user to be a wiki administrator on the User Management page using the 'Give admin access' button",
-    "list_of_installed_plugins": "List of installed plugins",
     "package_name": "Package name",
     "package_name": "Package name",
     "specified_version": "Specified version",
     "specified_version": "Specified version",
     "installed_version": "Installed version",
     "installed_version": "Installed version",
@@ -498,6 +522,11 @@
   },
   },
   "importer_management": {
   "importer_management": {
     "import_data": "Import Data",
     "import_data": "Import Data",
+    "article": "Article",
+    "category": "Category",
+    "tag": "Tag",
+    "page": "Page",
+    "page_path": "Page Path",
     "beta_warning": "This function is Beta.",
     "beta_warning": "This function is Beta.",
     "import_from": "Import from {{from}}",
     "import_from": "Import from {{from}}",
     "import_growi_archive": "Import GROWI archive",
     "import_growi_archive": "Import GROWI archive",
@@ -636,7 +665,6 @@
     "integration_procedure": "Integration Procedure",
     "integration_procedure": "Integration Procedure",
     "custom_bot_without_proxy_settings": "Custom Bot without proxy Settings",
     "custom_bot_without_proxy_settings": "Custom Bot without proxy Settings",
     "integration_failed":"Integration failed",
     "integration_failed":"Integration failed",
-    "official_bot_settings": "Official bot Settings",
     "reset": "Reset",
     "reset": "Reset",
     "reset_all_settings": "Reset all settings",
     "reset_all_settings": "Reset all settings",
     "delete_slackbot_settings": "Delete Slack Bot settings",
     "delete_slackbot_settings": "Delete Slack Bot settings",
@@ -709,7 +737,6 @@
   "slack_integration_legacy": {
   "slack_integration_legacy": {
     "slack_integration_legacy": "Legacy Slack Integration",
     "slack_integration_legacy": "Legacy Slack Integration",
     "alert_disabled": "This 'Slack Legacy Intenfation' has been currently disabled since <a href='/admin/slack-integration'>New settings</a> are enabled",
     "alert_disabled": "This 'Slack Legacy Intenfation' has been currently disabled since <a href='/admin/slack-integration'>New settings</a> are enabled",
-    "alert_Pd": "This 'Legacy Slack Integration' is currently disabled because <a href='/admin/slack-integration'>the new settings</a> is enabled.",
     "alert_deplicated": "This 'Legacy Slack Integration' is outdated and will be discontinued in the future. Use <a href='/admin/slack-integration'>the new settings</a> instead. "
     "alert_deplicated": "This 'Legacy Slack Integration' is outdated and will be discontinued in the future. Use <a href='/admin/slack-integration'>the new settings</a> instead. "
   },
   },
   "user_management": {
   "user_management": {
@@ -819,29 +846,6 @@
       "force_update_parents_description": "Enable this option to force the addition of missing users to the ancestor groups if they exist after changing a parent group."
       "force_update_parents_description": "Enable this option to force the addition of missing users to the ancestor groups if they exist after changing a parent group."
     }
     }
   },
   },
-  "full_text_search_management": {
-    "full_text_search_management": "Full Text Search Management",
-    "elasticsearch_management": "Elasticsearch management",
-    "connection_status": "Connection status",
-    "connection_status_label_unconfigured": "UNCONFIGURED",
-    "connection_status_label_connected": "CONNECTED",
-    "connection_status_label_disconnected": "DISCONNECTED",
-    "connection_status_label_erroroccured": "ERROR OCCURED ON SEARCH SERVICE",
-    "indices_status": "Indices Status",
-    "indices_status_label_normalized": "NORMALIZED",
-    "indices_status_label_unnormalized": "REBUILDING or BROKEN",
-    "indices_summary": "Indices summary",
-    "reconnect": "Reconnect",
-    "reconnect_button": "Try to reconnect",
-    "reconnect_description": "Click the button to try to reconnect to Elasticsearch.",
-    "normalize": "Normalize",
-    "normalize_button": "Normalize indices",
-    "normalize_description": "Click the button to repair broken indices.",
-    "rebuild": "Rebuild",
-    "rebuild_button": "Rebuild index",
-    "rebuild_description_1": "Click the button to rebuild index and add all page datas.",
-    "rebuild_description_2": "This may take a while."
-  },
   "audit_log_management": {
   "audit_log_management": {
     "audit_log": "Audit Log",
     "audit_log": "Audit Log",
     "audit_log_settings": "Audit Log Settings",
     "audit_log_settings": "Audit Log Settings",

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

@@ -4,6 +4,7 @@
   "Add": "Add",
   "Add": "Add",
   "Reset": "Reset",
   "Reset": "Reset",
   "Sign out": "Logout",
   "Sign out": "Logout",
+  "New": "New",
 
 
   "meta": {
   "meta": {
     "display_name": "English"
     "display_name": "English"

+ 0 - 3
packages/app/public/static/locales/en_US/translation.json

@@ -28,7 +28,6 @@
   "administrator": "Admin",
   "administrator": "Admin",
   "Tag": "Tag",
   "Tag": "Tag",
   "Tags": "Tags",
   "Tags": "Tags",
-  "New": "New",
   "Close": "Close",
   "Close": "Close",
   "Shortcuts": "Shortcuts",
   "Shortcuts": "Shortcuts",
   "CustomSidebar": "Custom Sidebar",
   "CustomSidebar": "Custom Sidebar",
@@ -36,7 +35,6 @@
   "add": "Add",
   "add": "Add",
   "Undo": "Undo",
   "Undo": "Undo",
   "Article": "Article",
   "Article": "Article",
-  "Page": "Page",
   "Page Path": "Page path",
   "Page Path": "Page path",
   "Category": "Category",
   "Category": "Category",
   "User": "User",
   "User": "User",
@@ -117,7 +115,6 @@
   "UserGroup": "UserGroup",
   "UserGroup": "UserGroup",
   "Basic Settings": "Basic Settings",
   "Basic Settings": "Basic Settings",
   "Basic authentication": "Basic authentication",
   "Basic authentication": "Basic authentication",
-  "Register limitation": "Register limitation",
   "The contents entered here will be shown in the header etc": "The contents entered here will be shown in the header etc",
   "The contents entered here will be shown in the header etc": "The contents entered here will be shown in the header etc",
   "Public": "Public",
   "Public": "Public",
   "Anyone with the link": "Anyone with the link",
   "Anyone with the link": "Anyone with the link",

+ 29 - 23
packages/app/public/static/locales/ja_JP/admin.json

@@ -26,8 +26,8 @@
     "always_displayed": "表示 (固定)",
     "always_displayed": "表示 (固定)",
     "displayed_or_hidden": "表示 / 非表示",
     "displayed_or_hidden": "表示 / 非表示",
     "Fixed by env var": "環境変数 <code>{{forcewikimode}}={{wikimode}}</code> により固定されています。",
     "Fixed by env var": "環境変数 <code>{{forcewikimode}}={{wikimode}}</code> により固定されています。",
-    "Register limitation": "登録の制限",
-    "Register limitation desc": "新しいユーザーを登録する方法を制限します.",
+    "register_limitation": "登録の制限",
+    "register_limitation_desc": "新しいユーザーを登録する方法を制限します。",
     "The whitelist of registration permission E-mail address": "登録許可メールアドレスの<br>ホワイトリスト",
     "The whitelist of registration permission E-mail address": "登録許可メールアドレスの<br>ホワイトリスト",
     "users_without_account": "アカウントを持たないユーザーはアクセス不可",
     "users_without_account": "アカウントを持たないユーザーはアクセス不可",
     "example": "例",
     "example": "例",
@@ -64,6 +64,9 @@
     "xss_prevent_setting": "XSS(Cross Site Scripting)対策設定",
     "xss_prevent_setting": "XSS(Cross Site Scripting)対策設定",
     "xss_prevent_setting_link": "マークダウン設定ページに移動",
     "xss_prevent_setting_link": "マークダウン設定ページに移動",
     "callback_URL": "コールバックURL",
     "callback_URL": "コールバックURL",
+    "providerName": "プロバイダ名",
+    "issuerHost": "発行ホスト",
+    "scope": "範囲",
     "desc_of_callback_URL": "{{AuthName}} プロバイダ側の設定で利用してください。",
     "desc_of_callback_URL": "{{AuthName}} プロバイダ側の設定で利用してください。",
     "authorization_endpoint": "認可エンドポイント",
     "authorization_endpoint": "認可エンドポイント",
     "token_endpoint": "トークンエンドポイント",
     "token_endpoint": "トークンエンドポイント",
@@ -210,13 +213,17 @@
         "username_detail": "新規ユーザーのアカウント名(<code>username</code>)に関連付ける属性",
         "username_detail": "新規ユーザーのアカウント名(<code>username</code>)に関連付ける属性",
         "name_detail": "新規ユーザー名(<code>name</code>)に関連付ける属性",
         "name_detail": "新規ユーザー名(<code>name</code>)に関連付ける属性",
         "mapping_detail": "新規ユーザーの{{target}}に関連付ける属性",
         "mapping_detail": "新規ユーザーの{{target}}に関連付ける属性",
+        "register_1": "OIDC IdP Administrator へ接続します。",
+        "register_2": "OIDCアプリの認証コールバックURLを<code>%s</code>として登録します。",
+        "register_3": "上記のClientIDとClient Secretをコピー&ペーストしてください。",
         "updated_oidc": "OpenID Connect を更新しました",
         "updated_oidc": "OpenID Connect を更新しました",
         "Use discovered URL if empty": "データベース側の値が空の場合、\"Issuer Host\"から検出した値を利用します。"
         "Use discovered URL if empty": "データベース側の値が空の場合、\"Issuer Host\"から検出した値を利用します。"
       },
       },
       "how_to": {
       "how_to": {
         "google": "Google OAuth の設定方法",
         "google": "Google OAuth の設定方法",
         "github": "GitHub OAuth の設定方法",
         "github": "GitHub OAuth の設定方法",
-        "twitter": "Twitter OAuth の設定方法"
+        "twitter": "Twitter OAuth の設定方法",
+        "oidc": "OIDC の設定方法"
       }
       }
     },
     },
     "form_item_name": {
     "form_item_name": {
@@ -311,7 +318,6 @@
     "system_information": "システム情報",
     "system_information": "システム情報",
     "wiki_administrator": "この画面はWiki管理者のみがアクセスできる画面です。",
     "wiki_administrator": "この画面はWiki管理者のみがアクセスできる画面です。",
     "assign_administrator": "「ユーザー管理」から「管理者にする」ボタンを使ってユーザーをWiki管理者に任命することができます。",
     "assign_administrator": "「ユーザー管理」から「管理者にする」ボタンを使ってユーザーをWiki管理者に任命することができます。",
-    "list_of_installed_plugins": "インストールされているプラグイン一覧",
     "package_name": "パッケージ名",
     "package_name": "パッケージ名",
     "specified_version": "指定バージョン",
     "specified_version": "指定バージョン",
     "installed_version": "インストールされているバージョン",
     "installed_version": "インストールされているバージョン",
@@ -522,25 +528,6 @@
     "upload_new_logo": "新しいロゴをアップロードする",
     "upload_new_logo": "新しいロゴをアップロードする",
     "delete_logo": "ロゴを削除"
     "delete_logo": "ロゴを削除"
   },
   },
-  "export_management": {
-    "export_archive_data": "データアーカイブ",
-    "exporting_collection_list": "エクスポート中のコレクション",
-    "exported_data_list": "エクスポートされたアーカイブリスト",
-    "export_collections": "コレクションのエクスポート",
-    "check_all": "全てにチェックを付ける",
-    "uncheck_all": "全てからチェックを外す",
-    "desc_password_seed": "<p>ユーザーデータをバックアップ/リストアする場合、現在の <code>PASSWORD_SEED</code> を新しい GROWI システムにセットすることを忘れないでください。さもなくば、ユーザーがパスワードでログインできなくなります。<br><br><strong>ヒント:</strong><br>現在の <code>PASSWORD_SEED</code> は、エクスポートされる ZIP 中の <code>meta.json</code> に保存されます。</p>",
-    "create_new_archive_data": "アーカイブデータの新規作成",
-    "export": "エクスポート",
-    "cancel": "キャンセル",
-    "file": "ファイル名",
-    "growi_version": "GROWI バージョン",
-    "collections": "コレクション",
-    "exported_at": "エクスポートされた時間",
-    "export_menu": "エクスポートメニュー",
-    "download": "ダウンロード",
-    "delete": "削除"
-  },
   "importer_management": {
   "importer_management": {
     "import_data": "データインポート",
     "import_data": "データインポート",
     "article": "記事",
     "article": "記事",
@@ -618,6 +605,25 @@
     "page_skip": "既に GROWI 側に同名のページが存在する場合、そのページはスキップされます",
     "page_skip": "既に GROWI 側に同名のページが存在する場合、そのページはスキップされます",
     "Directory_hierarchy_tag": "ディレクトリ階層タグ"
     "Directory_hierarchy_tag": "ディレクトリ階層タグ"
   },
   },
+  "export_management": {
+    "export_archive_data": "データアーカイブ",
+    "exporting_collection_list": "エクスポート中のコレクション",
+    "exported_data_list": "エクスポートされたアーカイブリスト",
+    "export_collections": "コレクションのエクスポート",
+    "check_all": "全てにチェックを付ける",
+    "uncheck_all": "全てからチェックを外す",
+    "desc_password_seed": "<p>ユーザーデータをバックアップ/リストアする場合、現在の <code>PASSWORD_SEED</code> を新しい GROWI システムにセットすることを忘れないでください。さもなくば、ユーザーがパスワードでログインできなくなります。<br><br><strong>ヒント:</strong><br>現在の <code>PASSWORD_SEED</code> は、エクスポートされる ZIP 中の <code>meta.json</code> に保存されます。</p>",
+    "create_new_archive_data": "アーカイブデータの新規作成",
+    "export": "エクスポート",
+    "cancel": "キャンセル",
+    "file": "ファイル名",
+    "growi_version": "GROWI バージョン",
+    "collections": "コレクション",
+    "exported_at": "エクスポートされた時間",
+    "export_menu": "エクスポートメニュー",
+    "download": "ダウンロード",
+    "delete": "削除"
+  },
   "external_notification": {
   "external_notification": {
     "external_notification": "外部ツールへの通知",
     "external_notification": "外部ツールへの通知",
     "enabled": "有効",
     "enabled": "有効",

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

@@ -4,6 +4,7 @@
   "Add": "追加",
   "Add": "追加",
   "Reset": "リセット",
   "Reset": "リセット",
   "Sign out": "ログアウト",
   "Sign out": "ログアウト",
+  "New": "作成",
 
 
   "meta": {
   "meta": {
     "display_name": "日本語"
     "display_name": "日本語"

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

@@ -28,7 +28,6 @@
   "administrator": "管理者",
   "administrator": "管理者",
   "Tag": "タグ",
   "Tag": "タグ",
   "Tags": "タグ",
   "Tags": "タグ",
-  "New": "作成",
   "Close": "閉じる",
   "Close": "閉じる",
   "Shortcuts": "ショートカット",
   "Shortcuts": "ショートカット",
   "CustomSidebar": "カスタムサイドバー",
   "CustomSidebar": "カスタムサイドバー",

+ 8 - 17
packages/app/public/static/locales/zh_CN/admin.json

@@ -7,6 +7,7 @@
   "User": "用户",
   "User": "用户",
   "Name": "姓名",
   "Name": "姓名",
   "Created": "创建",
   "Created": "创建",
+  "Page": "页面",
   "Edit": "编辑",
   "Edit": "编辑",
   "Description": "描述",
   "Description": "描述",
   "last_login": "上次登录",
   "last_login": "上次登录",
@@ -25,8 +26,8 @@
     "displayed_or_hidden": "显示/隐藏",
     "displayed_or_hidden": "显示/隐藏",
     "Guest Users Access": "来宾用户访问",
     "Guest Users Access": "来宾用户访问",
 		"Fixed by env var": "这是由env var<code>%s=%s</code>修复的。",
 		"Fixed by env var": "这是由env var<code>%s=%s</code>修复的。",
-		"Register limitation": "注册限制",
-		"Register limitation desc": "限制新用户注册",
+		"register_limitation": "注册限制",
+		"register_limitation_desc": "限制新用户注册",
 		"The whitelist of registration permission E-mail address": "注册许可电子邮件地址的白名单",
 		"The whitelist of registration permission E-mail address": "注册许可电子邮件地址的白名单",
 		"users_without_account": "无法访问没有帐户的用户",
 		"users_without_account": "无法访问没有帐户的用户",
 		"example": "例子",
 		"example": "例子",
@@ -79,7 +80,7 @@
 		"client_secret": "客户机密",
 		"client_secret": "客户机密",
 		"updated_general_security_setting": "更新安全设置成功",
 		"updated_general_security_setting": "更新安全设置成功",
 		"setup_not_completed_yet": "安装尚未完成",
 		"setup_not_completed_yet": "安装尚未完成",
-		"guest_mode": {
+    "guest_mode": {
 			"deny": "拒绝(仅限注册用户)",
 			"deny": "拒绝(仅限注册用户)",
 			"readonly": "接受(来宾可以只读)"
 			"readonly": "接受(来宾可以只读)"
 		},
 		},
@@ -212,7 +213,7 @@
 				"username_detail": "Specification of mappings for <code>username</code> when creating new users",
 				"username_detail": "Specification of mappings for <code>username</code> when creating new users",
 				"name_detail": "Specification of mappings for <code>name</code> when creating new users",
 				"name_detail": "Specification of mappings for <code>name</code> when creating new users",
 				"mapping_detail": "Specification of mappings for %s when creating new users",
 				"mapping_detail": "Specification of mappings for %s when creating new users",
-				"register_1": "Contant to OIDC IdP Administrator",
+				"register_1": "Contact to OIDC IdP Administrator",
 				"register_2": "Register your OIDC App with \"Authorization callback URL\" as <code>%s</code>",
 				"register_2": "Register your OIDC App with \"Authorization callback URL\" as <code>%s</code>",
 				"register_3": "Copy and paste your ClientID and Client Secret above",
 				"register_3": "Copy and paste your ClientID and Client Secret above",
 				"updated_oidc": "Succeeded to update OpenID Connect",
 				"updated_oidc": "Succeeded to update OpenID Connect",
@@ -238,6 +239,7 @@
 		}
 		}
   },
   },
   "notification_settings": {
   "notification_settings": {
+    "notification_settings": "通知设置",
 		"slack_incoming_configuration": "Slack Incoming Webhooks configuration",
 		"slack_incoming_configuration": "Slack Incoming Webhooks configuration",
 		"prioritize_webhook": "Prioritize incoming webhook than Slack App",
 		"prioritize_webhook": "Prioritize incoming webhook than Slack App",
 		"prioritize_webhook_desc": "Check this option and GROWI use Incoming Webhooks even if Slack App settings are enabled.",
 		"prioritize_webhook_desc": "Check this option and GROWI use Incoming Webhooks even if Slack App settings are enabled.",
@@ -316,7 +318,6 @@
     "system_information": "系统信息",
     "system_information": "系统信息",
     "wiki_administrator": "只有wiki管理员可以访问此页",
     "wiki_administrator": "只有wiki管理员可以访问此页",
     "assign_administrator": "您可以使用“授予管理员访问权限”按钮在“用户管理”页上将所选用户指定为wiki管理员",
     "assign_administrator": "您可以使用“授予管理员访问权限”按钮在“用户管理”页上将所选用户指定为wiki管理员",
-    "list_of_installed_plugins": "已安装插件列表",
     "package_name": "包名称",
     "package_name": "包名称",
     "specified_version": "指定版本",
     "specified_version": "指定版本",
     "installed_version": "已安装版本",
     "installed_version": "已安装版本",
@@ -400,9 +401,10 @@
     "aws_label": "AWS(S3)",
     "aws_label": "AWS(S3)",
     "local_label": "Local",
     "local_label": "Local",
     "gridfs_label": "MongoDB(GridFS)",
     "gridfs_label": "MongoDB(GridFS)",
+    "fixed_by_env_var": "这是由env var 修复的 <code>{{key}}={{value}}</code>.",
+    "file_upload": "This is for uploading file settings. If you complete file upload settings, file upload function, profile picture function etc will be enabled.",
     "ses_settings": "SES设置",
     "ses_settings": "SES设置",
     "test_connection": "测试邮件服务器连接",
     "test_connection": "测试邮件服务器连接",
-    "": "如果您没有SMTP设置,电子邮件将通过SES发送。您需要从电子邮件地址和生产设置进行验证。",
     "change_setting": "注意:如果你更改此设置未完成,您将无法访问迄今为止上传的文件。",
     "change_setting": "注意:如果你更改此设置未完成,您将无法访问迄今为止上传的文件。",
     "region": "Region",
     "region": "Region",
     "bucket_name": "Bucket name",
     "bucket_name": "Bucket name",
@@ -475,17 +477,6 @@
       "expanded": "内容宽度100% "
       "expanded": "内容宽度100% "
     },
     },
     "theme": "主体",
     "theme": "主体",
-    "behavior": "行为",
-    "behavior_desc": {
-      "growi_text1": "<code>/page</code> and <code>/page/</code> 都显示同一页。",
-      "growi_text2": "<code>/nonexistent_page</code> 显示编辑表单",
-      "growi_text3": "如果使用GROWI增强布局,则所有页面都显示子页面列表",
-      "crowi_text1": "<code>/page</code> 显示页面",
-      "crowi_text2": "<code>/page/</code> 显示子页列表",
-      "crowi_text3": "如果portal应用于<code>/page/</code>,则会显示portal和子页面列表",
-      "crowi_text4": "<code>/nonexistent_page</code> 显示编辑表单<",
-      "crowi_text5": "<code>/nonexistent_page/</code> 子页列表"
-    },
     "theme_desc": {
     "theme_desc": {
       "light_and_dark": "明暗模式",
       "light_and_dark": "明暗模式",
       "unique": "只有一种模式"
       "unique": "只有一种模式"

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

@@ -4,6 +4,7 @@
   "Add": "添加",
   "Add": "添加",
   "Reset": "重启",
   "Reset": "重启",
 	"Sign out": "退出",
 	"Sign out": "退出",
+  "New": "新建",
 
 
   "meta": {
   "meta": {
     "display_name": "简体中文"
     "display_name": "简体中文"

+ 0 - 3
packages/app/public/static/locales/zh_CN/translation.json

@@ -28,7 +28,6 @@
 	"Admin": "管理",
 	"Admin": "管理",
 	"administrator": "管理员",
 	"administrator": "管理员",
 	"Tags": "Tags",
 	"Tags": "Tags",
-  "New": "新建",
   "Close": "Close",
   "Close": "Close",
 	"Shortcuts": "快捷方式",
 	"Shortcuts": "快捷方式",
   "CustomSidebar": "Custom Sidebar",
   "CustomSidebar": "Custom Sidebar",
@@ -118,13 +117,11 @@
   "GROWI.5.0_new_schema": "GROWI.5.0 new schema",
   "GROWI.5.0_new_schema": "GROWI.5.0 new schema",
   "See_more_detail_on_new_schema": "更多详情请见<a href='https://docs.growi.org/en/admin-guide/upgrading/50x.html#about-the-new-v5-compatible-format' target='_blank'> {{title}}</a> <i class='icon-share-alt'></i> ",
   "See_more_detail_on_new_schema": "更多详情请见<a href='https://docs.growi.org/en/admin-guide/upgrading/50x.html#about-the-new-v5-compatible-format' target='_blank'> {{title}}</a> <i class='icon-share-alt'></i> ",
 	"Markdown Settings": "Markdown设置",
 	"Markdown Settings": "Markdown设置",
-	"Notification Settings": "通知设置",
 	"external_account_management": "外部账户管理",
 	"external_account_management": "外部账户管理",
   "UserGroup": "用户组",
   "UserGroup": "用户组",
   "ChildUserGroup": "儿童用户组",
   "ChildUserGroup": "儿童用户组",
 	"Basic Settings": "基础设置",
 	"Basic Settings": "基础设置",
 	"Basic authentication": "基本身份验证",
 	"Basic authentication": "基本身份验证",
-	"Register limitation": "注册限制",
 	"The contents entered here will be shown in the header etc": "此处输入的内容将显示在标题等中",
 	"The contents entered here will be shown in the header etc": "此处输入的内容将显示在标题等中",
 	"Public": "公共",
 	"Public": "公共",
 	"Anyone with the link": "任何人",
 	"Anyone with the link": "任何人",

Разлика између датотеке није приказан због своје велике величине
+ 1 - 1
packages/app/resource/locales/en_US/sandbox-diagrams.md


Разлика између датотеке није приказан због своје велике величине
+ 1 - 1
packages/app/resource/locales/ja_JP/sandbox-diagrams.md


Разлика између датотеке није приказан због своје велике величине
+ 1 - 1
packages/app/resource/locales/zh_CN/sandbox-diagrams.md


+ 0 - 2
packages/app/src/client/services/AdminHomeContainer.js

@@ -3,7 +3,6 @@ import { Container } from 'unstated';
 
 
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 
 
-import { toastError } from '../util/apiNotification';
 import { apiv3Get } from '../util/apiv3-client';
 import { apiv3Get } from '../util/apiv3-client';
 
 
 // eslint-disable-next-line no-unused-vars
 // eslint-disable-next-line no-unused-vars
@@ -66,7 +65,6 @@ export default class AdminHomeContainer extends Container {
         nodeVersion: adminHomeParams.nodeVersion,
         nodeVersion: adminHomeParams.nodeVersion,
         npmVersion: adminHomeParams.npmVersion,
         npmVersion: adminHomeParams.npmVersion,
         yarnVersion: adminHomeParams.yarnVersion,
         yarnVersion: adminHomeParams.yarnVersion,
-        installedPlugins: adminHomeParams.installedPlugins,
         envVars: adminHomeParams.envVars,
         envVars: adminHomeParams.envVars,
         isV5Compatible: adminHomeParams.isV5Compatible,
         isV5Compatible: adminHomeParams.isV5Compatible,
         isMaintenanceMode: adminHomeParams.isMaintenanceMode,
         isMaintenanceMode: adminHomeParams.isMaintenanceMode,

+ 51 - 0
packages/app/src/client/services/activate-plugin.ts

@@ -0,0 +1,51 @@
+import { readFileSync } from 'fs';
+import path from 'path';
+
+import { GrowiPlugin } from '~/interfaces/plugin';
+import { initializeGrowiFacade } from '~/utils/growi-facade';
+import { resolveFromRoot } from '~/utils/project-dir-utils';
+
+
+declare global {
+  // eslint-disable-next-line vars-on-top, no-var
+  var pluginActivators: {
+    [key: string]: {
+      activate: () => void,
+      deactivate: () => void,
+    },
+  };
+}
+
+
+export type GrowiPluginManifestEntries = [growiPlugin: GrowiPlugin, manifest: any][];
+
+
+export class ActivatePluginService {
+
+  static async retrievePluginManifests(growiPlugins: GrowiPlugin[]): Promise<GrowiPluginManifestEntries> {
+    const entries: GrowiPluginManifestEntries = [];
+
+    growiPlugins.forEach(async(growiPlugin) => {
+      const manifestPath = resolveFromRoot(path.join('tmp/plugins', growiPlugin.installedPath, 'dist/manifest.json'));
+      const customManifestStr: string = await readFileSync(manifestPath, 'utf-8');
+      entries.push([growiPlugin, JSON.parse(customManifestStr)]);
+    });
+
+    return entries;
+  }
+
+  static activateAll(): void {
+    initializeGrowiFacade();
+
+    const { pluginActivators } = window;
+
+    if (pluginActivators == null) {
+      return;
+    }
+
+    Object.entries(pluginActivators).forEach(([, activator]) => {
+      activator.activate();
+    });
+  }
+
+}

+ 50 - 37
packages/app/src/client/services/page-operation.ts

@@ -1,7 +1,8 @@
 import { SubscriptionStatusType, Nullable } from '@growi/core';
 import { SubscriptionStatusType, Nullable } from '@growi/core';
 import urljoin from 'url-join';
 import urljoin from 'url-join';
 
 
-import { OptionsToSave } from '~/interfaces/editor-settings';
+import { OptionsToSave } from '~/interfaces/page-operation';
+import { useIsEnabledUnsavedWarning } from '~/stores/editor';
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 
 
 import { toastError } from '../util/apiNotification';
 import { toastError } from '../util/apiNotification';
@@ -118,43 +119,55 @@ type PageInfo= {
   revisionId: Nullable<string>,
   revisionId: Nullable<string>,
 }
 }
 
 
+type SaveOrUpdateFunction = (markdown: string, pageInfo: PageInfo, optionsToSave?: OptionsToSave) => any;
+
 // TODO: define return type
 // TODO: define return type
-export const saveOrUpdate = async(optionsToSave: OptionsToSave, pageInfo: PageInfo, markdown: string) => {
-  const { path, pageId, revisionId } = pageInfo;
-
-  const options = Object.assign({}, optionsToSave);
-
-  /*
-  * Note: variable "markdown" will be received from params
-  * please delete the following code after implemating HackMD editor function
-  */
-  // let markdown;
-  // if (editorMode === EditorMode.HackMD) {
-  // const pageEditorByHackmd = this.appContainer.getComponentInstance('PageEditorByHackmd');
-  // markdown = await pageEditorByHackmd.getMarkdown();
-  // // set option to sync
-  // options.isSyncRevisionToHackmd = true;
-  // revisionId = this.state.revisionIdHackmdSynced;
-  // }
-  // else {
-  // const pageEditor = this.appContainer.getComponentInstance('PageEditor');
-  // const pageEditor = getComponentInstance('PageEditor');
-  // markdown = pageEditor.getMarkdown();
-  // }
-
-  const isNoRevisionPage = pageId != null && revisionId == null;
-
-  let res;
-  if (pageId == null || isNoRevisionPage) {
-    res = await createPage(path, markdown, options);
-  }
-  else {
-    if (revisionId == null) {
-      const msg = '\'revisionId\' is required to update page';
-      throw new Error(msg);
+export const useSaveOrUpdate = (): SaveOrUpdateFunction => {
+  /* eslint-disable react-hooks/rules-of-hooks */
+  const { mutate: mutateIsEnabledUnsavedWarning } = useIsEnabledUnsavedWarning();
+  /* eslint-enable react-hooks/rules-of-hooks */
+
+  return async function(markdown: string, pageInfo: PageInfo, optionsToSave?: OptionsToSave) {
+    const { path, pageId, revisionId } = pageInfo;
+
+    const options: OptionsToSave = Object.assign({}, optionsToSave);
+    /*
+    * Note: variable "markdown" will be received from params
+    * please delete the following code after implemating HackMD editor function
+    */
+    // let markdown;
+    // if (editorMode === EditorMode.HackMD) {
+    // const pageEditorByHackmd = this.appContainer.getComponentInstance('PageEditorByHackmd');
+    // markdown = await pageEditorByHackmd.getMarkdown();
+    // // set option to sync
+    // options.isSyncRevisionToHackmd = true;
+    // revisionId = this.state.revisionIdHackmdSynced;
+    // }
+    // else {
+    // const pageEditor = this.appContainer.getComponentInstance('PageEditor');
+    // const pageEditor = getComponentInstance('PageEditor');
+    // markdown = pageEditor.getMarkdown();
+    // }
+
+    const isNoRevisionPage = pageId != null && revisionId == null;
+
+    let res;
+    if (pageId == null || isNoRevisionPage) {
+      res = await createPage(path, markdown, options);
+    }
+    else {
+      if (revisionId == null) {
+        const msg = '\'revisionId\' is required to update page';
+        throw new Error(msg);
+      }
+      res = await updatePage(pageId, revisionId, markdown, options);
     }
     }
-    res = await updatePage(pageId, revisionId, markdown, options);
-  }
 
 
-  return res;
+    // The updateFn should be a promise or asynchronous function to handle the remote mutation
+    // it should return updated data. see: https://swr.vercel.app/docs/mutation#optimistic-updates
+    // Moreover, `async() => false` does not work since it's too fast to be calculated.
+    await mutateIsEnabledUnsavedWarning(new Promise(r => setTimeout(() => r(false), 10)), { optimisticData: () => false });
+
+    return res;
+  };
 };
 };

+ 6 - 52
packages/app/src/client/util/apiNotification.js

@@ -1,53 +1,7 @@
-// show API error/sucess toastr
+import { legacy } from './toastr';
 
 
-import * as toastr from 'toastr';
-import { toArrayIfNot } from '~/utils/array-utils';
-
-const toastrOption = {
-  error: {
-    closeButton: true,
-    progressBar: true,
-    newestOnTop: false,
-    showDuration: '100',
-    hideDuration: '100',
-    timeOut: '0',
-  },
-  success: {
-    closeButton: true,
-    progressBar: true,
-    newestOnTop: false,
-    showDuration: '100',
-    hideDuration: '100',
-    timeOut: '3000',
-  },
-  warning: {
-    closeButton: true,
-    progressBar: true,
-    newestOnTop: false,
-    showDuration: '100',
-    hideDuration: '100',
-    timeOut: '6000',
-  },
-};
-
-// accepts both a single error and an array of errors
-export const toastError = (err, header = 'Error', option = toastrOption.error) => {
-  const errs = toArrayIfNot(err);
-
-  if (err.length === 0) {
-    toastr.error('', header);
-  }
-
-  for (const err of errs) {
-    toastr.error(err.message || err, header, option);
-  }
-};
-
-// only accepts a single item
-export const toastSuccess = (body, header = 'Success', option = toastrOption.success) => {
-  toastr.success(body, header, option);
-};
-
-export const toastWarning = (body, header = 'Warning', option = toastrOption.warning) => {
-  toastr.warning(body, header, option);
-};
+// DEPRECATED -- 2022.12.07 Yuki Takei
+// Use methods from './toastr.ts' instead
+export const toastError = legacy.toastError;
+export const toastSuccess = legacy.toastSuccess;
+export const toastWarning = legacy.toastWarning;

+ 0 - 21
packages/app/src/client/util/editor.ts

@@ -1,21 +0,0 @@
-import type { OptionsToSave } from '~/interfaces/editor-settings';
-
-export const getOptionsToSave = (
-    isSlackEnabled: boolean,
-    slackChannels: string,
-    grant: number,
-    grantUserGroupId: string | null | undefined,
-    grantUserGroupName: string | null | undefined,
-    pageTags: string[],
-    isSyncRevisionToHackmd?: boolean,
-): OptionsToSave => {
-  return {
-    pageTags,
-    isSlackEnabled,
-    slackChannels,
-    grant,
-    grantUserGroupId,
-    grantUserGroupName,
-    isSyncRevisionToHackmd,
-  };
-};

+ 91 - 0
packages/app/src/client/util/toastr.ts

@@ -0,0 +1,91 @@
+import { toast, ToastContent, ToastOptions } from 'react-toastify';
+import * as toastrLegacy from 'toastr';
+
+import { toArrayIfNot } from '~/utils/array-utils';
+
+
+export const toastErrorOption: ToastOptions = {
+  autoClose: false,
+  closeButton: true,
+};
+export const toastError = (err: string | Error | Error[], option: ToastOptions = toastErrorOption): void => {
+  const errs = toArrayIfNot(err);
+
+  if (errs.length === 0) {
+    return;
+  }
+
+  for (const err of errs) {
+    const message = (typeof err === 'string') ? err : err.message;
+    toast.error(message || err, option);
+  }
+};
+
+export const toastSuccessOption: ToastOptions = {
+  autoClose: 2000,
+  closeButton: true,
+};
+export const toastSuccess = (content: ToastContent, option: ToastOptions = toastSuccessOption): void => {
+  toast.success(content, option);
+};
+
+export const toastWarningOption: ToastOptions = {
+  autoClose: 5000,
+  closeButton: true,
+};
+export const toastWarning = (content: ToastContent, option: ToastOptions = toastWarningOption): void => {
+  toastrLegacy.warning(content, option);
+};
+
+
+const toastrLegacyOption = {
+  error: {
+    closeButton: true,
+    progressBar: true,
+    newestOnTop: false,
+    showDuration: '100',
+    hideDuration: '100',
+    timeOut: '0',
+  },
+  success: {
+    closeButton: true,
+    progressBar: true,
+    newestOnTop: false,
+    showDuration: '100',
+    hideDuration: '100',
+    timeOut: '3000',
+  },
+  warning: {
+    closeButton: true,
+    progressBar: true,
+    newestOnTop: false,
+    showDuration: '100',
+    hideDuration: '100',
+    timeOut: '6000',
+  },
+};
+
+export const legacy = {
+  // accepts both a single error and an array of errors
+  toastError: (err: string | Error | Error[], header = 'Error', option = toastrLegacyOption.error): void => {
+    const errs = toArrayIfNot(err);
+
+    if (errs.length === 0) {
+      toastrLegacy.error('', header);
+    }
+
+    for (const err of errs) {
+      const message = (typeof err === 'string') ? err : err.message;
+      toastrLegacy.error(message || err, header, option);
+    }
+  },
+
+  // only accepts a single item
+  toastSuccess: (body: string, header = 'Success', option = toastrLegacyOption.success): void => {
+    toastrLegacy.success(body, header, option);
+  },
+
+  toastWarning: (body: string, header = 'Warning', option = toastrLegacyOption.warning): void => {
+    toastrLegacy.warning(body, header, option);
+  },
+};

+ 0 - 8
packages/app/src/components/Admin/AdminHome/AdminHome.jsx

@@ -15,7 +15,6 @@ import { withUnstatedContainers } from '../../UnstatedUtils';
 
 
 
 
 import EnvVarsTable from './EnvVarsTable';
 import EnvVarsTable from './EnvVarsTable';
-import InstalledPluginTable from './InstalledPluginTable';
 import SystemInfomationTable from './SystemInfomationTable';
 import SystemInfomationTable from './SystemInfomationTable';
 
 
 const logger = loggerFactory('growi:admin');
 const logger = loggerFactory('growi:admin');
@@ -85,13 +84,6 @@ const AdminHome = (props) => {
         </div>
         </div>
       </div>
       </div>
 
 
-      <div className="row mb-5">
-        <div className="col-lg-12">
-          <h2 className="admin-setting-header">{t('admin:admin_top.list_of_installed_plugins')}</h2>
-          <InstalledPluginTable />
-        </div>
-      </div>
-
       <div className="row mb-5">
       <div className="row mb-5">
         <div className="col-md-12">
         <div className="col-md-12">
           <h2 className="admin-setting-header">{t('admin:admin_top.list_of_env_vars')}</h2>
           <h2 className="admin-setting-header">{t('admin:admin_top.list_of_env_vars')}</h2>

+ 0 - 55
packages/app/src/components/Admin/AdminHome/InstalledPluginTable.jsx

@@ -1,55 +0,0 @@
-import React from 'react';
-
-import PropTypes from 'prop-types';
-import { useTranslation } from 'next-i18next';
-
-import AdminHomeContainer from '~/client/services/AdminHomeContainer';
-
-import { withUnstatedContainers } from '../../UnstatedUtils';
-
-const InstalledPluginTable = (props) => {
-  const { t } = useTranslation();
-  const { adminHomeContainer } = props;
-
-  const { installedPlugins } = adminHomeContainer.state;
-
-  if (installedPlugins == null) {
-    return <></>;
-  }
-
-  return (
-    <table data-testid="admin-installed-plugin-table" className="table table-bordered">
-      <thead>
-        <tr>
-          <th className="text-center">{t('admin:admin_top.package_name')}</th>
-          <th className="text-center">{t('admin:admin_top.specified_version')}</th>
-          <th className="text-center">{t('admin:admin_top.installed_version')}</th>
-        </tr>
-      </thead>
-      <tbody>
-        {adminHomeContainer.state.installedPlugins.map((plugin) => {
-          return (
-            <tr key={plugin.name}>
-              <td>{plugin.name}</td>
-              <td data-hide-in-vrt className="text-center">{plugin.requiredVersion}</td>
-              <td data-hide-in-vrt className="text-center">{plugin.installedVersion}</td>
-            </tr>
-          );
-        })}
-      </tbody>
-    </table>
-  );
-
-};
-
-InstalledPluginTable.propTypes = {
-  adminHomeContainer: PropTypes.instanceOf(AdminHomeContainer).isRequired,
-};
-
-
-/**
- * Wrapper component for using unstated
- */
-const InstalledPluginTableWrapper = withUnstatedContainers(InstalledPluginTable, [AdminHomeContainer]);
-
-export default InstalledPluginTableWrapper;

+ 1 - 1
packages/app/src/components/Admin/App/AppSettingsPageContents.tsx

@@ -73,7 +73,7 @@ const AppSettingsPageContents = (props: Props) => {
           && (
           && (
             <div className="row">
             <div className="row">
               <div className="col-lg-12">
               <div className="col-lg-12">
-                <h2 className="admin-setting-header">{t('V5 Page Migration')}</h2>
+                <h2 className="admin-setting-header" data-testid="v5-page-migration">{t('V5 Page Migration')}</h2>
                 <V5PageMigration />
                 <V5PageMigration />
               </div>
               </div>
             </div>
             </div>

+ 21 - 0
packages/app/src/components/Admin/Common/AdminInstallButtonRow.tsx

@@ -0,0 +1,21 @@
+import React from 'react';
+
+import { useTranslation } from 'next-i18next';
+
+type Props = {
+  onClick: () => void,
+  disabled: boolean,
+
+}
+
+export const AdminInstallButtonRow = (props: Props): JSX.Element => {
+  // TODO: const { t } = useTranslation('admin');
+
+  return (
+    <div className="row my-3">
+      <div className="mx-auto">
+        <button type="button" className="btn btn-primary" onClick={props.onClick} disabled={props.disabled}>Install</button>
+      </div>
+    </div>
+  );
+};

+ 3 - 0
packages/app/src/components/Admin/Common/AdminNavigation.jsx

@@ -36,6 +36,7 @@ const AdminNavigation = (props) => {
       case 'user-groups':              return <><i className="mr-1 icon-fw icon-people"></i>{          t('user_group_management.user_group_management') }</>;
       case 'user-groups':              return <><i className="mr-1 icon-fw icon-people"></i>{          t('user_group_management.user_group_management') }</>;
       case 'search':                   return <><i className="mr-1 icon-fw icon-magnifier"></i>{       t('full_text_search_management.full_text_search_management') }</>;
       case 'search':                   return <><i className="mr-1 icon-fw icon-magnifier"></i>{       t('full_text_search_management.full_text_search_management') }</>;
       case 'audit-log':                return <><i className="mr-1 icon-fw icon-feed"></i>{            t('audit_log_management.audit_log')}</>;
       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>{           'Plugins Extension'}</>;
       case 'cloud':                    return <><i className="mr-1 icon-fw icon-share-alt"></i>{       t('cloud_setting_management.to_cloud_settings')} </>;
       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') }</>;
       default:                         return <><i className="mr-1 icon-fw icon-home"></i>{            t('wiki_management_home_page') }</>;
       /* eslint-enable no-multi-spaces, max-len */
       /* eslint-enable no-multi-spaces, max-len */
@@ -91,6 +92,7 @@ const AdminNavigation = (props) => {
         <MenuLink menu="users"        isListGroupItems isActive={isActiveMenu('/users')} />
         <MenuLink menu="users"        isListGroupItems isActive={isActiveMenu('/users')} />
         <MenuLink menu="user-groups"  isListGroupItems isActive={isActiveMenu('/user-groups')} />
         <MenuLink menu="user-groups"  isListGroupItems isActive={isActiveMenu('/user-groups')} />
         <MenuLink menu="audit-log"    isListGroupItems isActive={isActiveMenu('/audit-log')} />
         <MenuLink menu="audit-log"    isListGroupItems isActive={isActiveMenu('/audit-log')} />
+        <MenuLink menu="plugins"      isListGroupItems isActive={isActiveMenu('/plugins')} />
         <MenuLink menu="search"       isListGroupItems isActive={isActiveMenu('/search')} />
         <MenuLink menu="search"       isListGroupItems isActive={isActiveMenu('/search')} />
         {growiCloudUri != null && growiAppIdForGrowiCloud != null
         {growiCloudUri != null && growiAppIdForGrowiCloud != null
           && (
           && (
@@ -140,6 +142,7 @@ const AdminNavigation = (props) => {
             {isActiveMenu('/user-groups') &&       <MenuLabel menu="user-groups" />}
             {isActiveMenu('/user-groups') &&       <MenuLabel menu="user-groups" />}
             {isActiveMenu('/search') &&            <MenuLabel menu="search" />}
             {isActiveMenu('/search') &&            <MenuLabel menu="search" />}
             {isActiveMenu('/audit-log') &&         <MenuLabel menu="audit-log" />}
             {isActiveMenu('/audit-log') &&         <MenuLabel menu="audit-log" />}
+            {isActiveMenu('/plugins') &&           <MenuLabel menu="plugins" />}
             {/* eslint-enable no-multi-spaces */}
             {/* eslint-enable no-multi-spaces */}
           </span>
           </span>
         </button>
         </button>

+ 5 - 7
packages/app/src/components/Admin/Customize/CustomizeThemeOptions.jsx

@@ -49,14 +49,13 @@ const uniqueTheme = [{
 
 
 const CustomizeThemeOptions = (props) => {
 const CustomizeThemeOptions = (props) => {
 
 
-  const { adminCustomizeContainer, currentTheme } = props;
-  const { currentLayout } = adminCustomizeContainer.state;
+  const { selectedTheme } = props;
 
 
   const { t } = useTranslation('admin');
   const { t } = useTranslation('admin');
 
 
 
 
   return (
   return (
-    <div id="themeOptions" className={`${currentLayout === 'kibela' && 'disabled'}`}>
+    <div id="themeOptions">
       {/* Light and Dark Themes */}
       {/* Light and Dark Themes */}
       <div>
       <div>
         <h3>{t('customize_settings.theme_desc.light_and_dark')}</h3>
         <h3>{t('customize_settings.theme_desc.light_and_dark')}</h3>
@@ -65,7 +64,7 @@ const CustomizeThemeOptions = (props) => {
             return (
             return (
               <ThemeColorBox
               <ThemeColorBox
                 key={theme.name}
                 key={theme.name}
-                isSelected={currentTheme === theme.name}
+                isSelected={selectedTheme === theme.name}
                 onSelected={() => props.onSelected(theme.name)}
                 onSelected={() => props.onSelected(theme.name)}
                 {...theme}
                 {...theme}
               />
               />
@@ -81,7 +80,7 @@ const CustomizeThemeOptions = (props) => {
             return (
             return (
               <ThemeColorBox
               <ThemeColorBox
                 key={theme.name}
                 key={theme.name}
-                isSelected={currentTheme === theme.name}
+                isSelected={selectedTheme === theme.name}
                 onSelected={() => props.onSelected(theme.name)}
                 onSelected={() => props.onSelected(theme.name)}
                 {...theme}
                 {...theme}
               />
               />
@@ -97,9 +96,8 @@ const CustomizeThemeOptions = (props) => {
 const CustomizeThemeOptionsWrapper = withUnstatedContainers(CustomizeThemeOptions, [AdminCustomizeContainer]);
 const CustomizeThemeOptionsWrapper = withUnstatedContainers(CustomizeThemeOptions, [AdminCustomizeContainer]);
 
 
 CustomizeThemeOptions.propTypes = {
 CustomizeThemeOptions.propTypes = {
-  adminCustomizeContainer: PropTypes.instanceOf(AdminCustomizeContainer).isRequired,
   onSelected: PropTypes.func,
   onSelected: PropTypes.func,
-  currentTheme: PropTypes.string,
+  selectedTheme: PropTypes.string,
 };
 };
 
 
 export default CustomizeThemeOptionsWrapper;
 export default CustomizeThemeOptionsWrapper;

+ 26 - 25
packages/app/src/components/Admin/Customize/CustomizeThemeSetting.tsx

@@ -1,60 +1,61 @@
-import React, { useCallback } from 'react';
+import React, { useCallback, useEffect, useState } from 'react';
 
 
 import { useTranslation } from 'next-i18next';
 import { useTranslation } from 'next-i18next';
 
 
-import AdminCustomizeContainer from '~/client/services/AdminCustomizeContainer';
-import { toastSuccess, toastError } from '~/client/util/apiNotification';
 import { apiv3Put } from '~/client/util/apiv3-client';
 import { apiv3Put } from '~/client/util/apiv3-client';
-import { useGrowiTheme } from '~/stores/context';
+import { toastSuccess, toastError, toastWarning } from '~/client/util/toastr';
+import { useSWRxGrowiTheme } from '~/stores/admin/customize';
 
 
-
-import { withUnstatedContainers } from '../../UnstatedUtils';
 import AdminUpdateButtonRow from '../Common/AdminUpdateButtonRow';
 import AdminUpdateButtonRow from '../Common/AdminUpdateButtonRow';
 
 
 import CustomizeThemeOptions from './CustomizeThemeOptions';
 import CustomizeThemeOptions from './CustomizeThemeOptions';
 
 
+// eslint-disable-next-line @typescript-eslint/ban-types
 type Props = {
 type Props = {
-  adminCustomizeContainer: AdminCustomizeContainer
 }
 }
 
 
+// eslint-disable-next-line @typescript-eslint/no-unused-vars
 const CustomizeThemeSetting = (props: Props): JSX.Element => {
 const CustomizeThemeSetting = (props: Props): JSX.Element => {
-
-  const { adminCustomizeContainer } = props;
-  const { data: currentTheme } = useGrowiTheme();
   const { t } = useTranslation();
   const { t } = useTranslation();
 
 
-  const selectedHandler = useCallback((themeName) => {
-    // TODO: preview without using mutate of useGrowiTheme
-    // https://github.com/weseek/growi/pull/6860
-    // mutateGrowiTheme(themeName);
+  const { data: currentTheme, error } = useSWRxGrowiTheme();
+  const [selectedTheme, setSelectedTheme] = useState(currentTheme);
+
+  useEffect(() => {
+    setSelectedTheme(currentTheme);
+  }, [currentTheme]);
+
+  const selectedHandler = useCallback((themeName: string) => {
+    setSelectedTheme(themeName);
   }, []);
   }, []);
 
 
   const submitHandler = useCallback(async() => {
   const submitHandler = useCallback(async() => {
+    if (selectedTheme == null) {
+      toastWarning('The selected theme is undefined');
+      return;
+    }
+
     try {
     try {
-      if (currentTheme != null) {
-        await apiv3Put('/customize-setting/theme', {
-          themeType: currentTheme,
-        });
-      }
+      await apiv3Put('/customize-setting/theme', {
+        theme: selectedTheme,
+      });
 
 
       toastSuccess(t('toaster.update_successed', { target: t('admin:customize_settings.theme'), ns: 'commons' }));
       toastSuccess(t('toaster.update_successed', { target: t('admin:customize_settings.theme'), ns: 'commons' }));
     }
     }
     catch (err) {
     catch (err) {
       toastError(err);
       toastError(err);
     }
     }
-  }, [currentTheme, t]);
+  }, [selectedTheme, t]);
 
 
   return (
   return (
     <div className="row">
     <div className="row">
       <div className="col-12">
       <div className="col-12">
         <h2 className="admin-setting-header">{t('admin:customize_settings.theme')}</h2>
         <h2 className="admin-setting-header">{t('admin:customize_settings.theme')}</h2>
-        <CustomizeThemeOptions onSelected={selectedHandler} currentTheme={currentTheme} />
-        <AdminUpdateButtonRow onClick={submitHandler} disabled={adminCustomizeContainer.state.retrieveError != null} />
+        <CustomizeThemeOptions onSelected={selectedHandler} selectedTheme={selectedTheme} />
+        <AdminUpdateButtonRow onClick={submitHandler} disabled={error != null} />
       </div>
       </div>
     </div>
     </div>
   );
   );
 };
 };
 
 
-const CustomizeThemeSettingWrapper = withUnstatedContainers(CustomizeThemeSetting, [AdminCustomizeContainer]);
-
-export default CustomizeThemeSettingWrapper;
+export default CustomizeThemeSetting;

+ 4 - 1
packages/app/src/components/Admin/ElasticsearchManagement/ElasticsearchManagement.tsx

@@ -54,9 +54,12 @@ const ElasticsearchManagement = () => {
             setIsConfigured(false);
             setIsConfigured(false);
           }
           }
         }
         }
+        toastError(errors as Error[]);
+      }
+      else {
+        toastError(errors as Error);
       }
       }
 
 
-      toastError(errors);
     }
     }
     finally {
     finally {
       setIsInitialized(true);
       setIsInitialized(true);

+ 2 - 20
packages/app/src/components/Admin/MarkdownSetting/XssForm.jsx

@@ -43,26 +43,8 @@ class XssForm extends React.Component {
     return (
     return (
       <div className="form-group col-12 my-3">
       <div className="form-group col-12 my-3">
         <div className="row">
         <div className="row">
-          <div className="col-md-4 col-sm-12 align-self-start mb-4">
-            <div className="custom-control custom-radio ">
-              <input
-                type="radio"
-                className="custom-control-input"
-                id="xssOption1"
-                name="XssOption"
-                checked={xssOption === 1}
-                onChange={() => { adminMarkDownContainer.setState({ xssOption: 1 }) }}
-              />
-              <label className="custom-control-label w-100" htmlFor="xssOption1">
-                <p className="font-weight-bold">{t('markdown_settings.xss_options.remove_all_tags')}</p>
-                <div className="mt-4">
-                  {t('markdown_settings.xss_options.remove_all_tags_desc')}
-                </div>
-              </label>
-            </div>
-          </div>
 
 
-          <div className="col-md-4 col-sm-12 align-self-start mb-4">
+          <div className="col-md-6 col-sm-12 align-self-start mb-4">
             <div className="custom-control custom-radio">
             <div className="custom-control custom-radio">
               <input
               <input
                 type="radio"
                 type="radio"
@@ -104,7 +86,7 @@ class XssForm extends React.Component {
             </div>
             </div>
           </div>
           </div>
 
 
-          <div className="col-md-4 col-sm-12 align-self-start mb-4">
+          <div className="col-md-6 col-sm-12 align-self-start mb-4">
             <div className="custom-control custom-radio">
             <div className="custom-control custom-radio">
               <input
               <input
                 type="radio"
                 type="radio"

+ 13 - 0
packages/app/src/components/Admin/PluginsExtension/Loading.js

@@ -0,0 +1,13 @@
+import {
+  Spinner,
+} from 'reactstrap';
+
+const Loading = () => {
+  return (
+    <Spinner className='d-flex justify-content-center aligh-items-center'>
+      Loading...
+    </Spinner>
+  );
+};
+
+export default Loading;

+ 68 - 0
packages/app/src/components/Admin/PluginsExtension/PluginCard.module.scss

@@ -0,0 +1,68 @@
+// TODO: Rewrite according to guidelines
+.plugin_card :global {
+
+  .switch__label {
+    position: relative;
+    display: inline-block;
+    width: 50px;
+  }
+  .switch__content {
+    position: relative;
+    display: block;
+    height: 31px;
+    overflow: hidden;
+    cursor: pointer;
+    border-radius: 30px;
+  }
+  .switch__content:before {
+    position: absolute;
+    top: 0;
+    left: 0;
+    display: block;
+    width: calc(100% - 3px);
+    height: calc(100% - 3px);
+    content: '';
+    background-color: #fff;
+    border: 1.5px solid #E5E5EA;
+    border-radius: 30px;
+  }
+  .switch__content:after {
+    position: absolute;
+    top: 50%;
+    left: 50%;
+    display: block;
+    width: 0;
+    height: 0;
+    content: '';
+    background-color: transparent;
+    border-radius: 30px;
+    transition: all .5s;
+  }
+  .switch__input {
+    display: none;
+  }
+
+  .switch__circle {
+    position: absolute;
+    top: 2px;
+    left: 2px;
+    display: block;
+    width: 27px;
+    height: 27px;
+    background-color: #fff;
+    border-radius: 20px;
+    box-shadow: 0 2px 6px #999;
+    transition: all .5s;
+  }
+  .switch__input:checked ~ .switch__circle {
+    left: 21px;
+  }
+
+  .switch__input:checked ~ .switch__content:after {
+    top: 0;
+    left: 0;
+    width: 100%;
+    height: 100%;
+    background-color: #0078D7;
+  }
+}

+ 85 - 0
packages/app/src/components/Admin/PluginsExtension/PluginCard.tsx

@@ -0,0 +1,85 @@
+// import { faCircleArrowDown, faCircleCheck } from '@fortawesome/free-solid-svg-icons';
+// import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
+
+import Link from 'next/link';
+
+import styles from './PluginCard.module.scss';
+
+
+type Props = {
+  name: string,
+  url: string,
+  description: string,
+}
+
+export const PluginCard = (props: Props): JSX.Element => {
+  const {
+    name, url, description,
+  } = props;
+  // const [isEnabled, setIsEnabled] = useState(true);
+
+  // const checkboxHandler = useCallback(() => {
+  //   setIsEnabled(false);
+  // }, []);
+
+  return (
+    <div className="card shadow border-0" key={name}>
+      <div className="card-body px-5 py-4 mt-3">
+        <div className="row mb-3">
+          <div className="col-9">
+            <h2 className="card-title h3 border-bottom pb-2 mb-3">
+              <Link href={`${url}`}>{name}</Link>
+            </h2>
+            <p className="card-text text-muted">{description}</p>
+          </div>
+          <div className='col-3'>
+            <div className={`${styles.plugin_card}`}>
+              <div className="switch">
+                <label className="switch__label">
+                  <input type="checkbox" className="switch__input" checked/>
+                  <span className="switch__content"></span>
+                  <span className="switch__circle"></span>
+                </label>
+              </div>
+            </div>
+            {/* <div className="custom-control custom-switch custom-switch-lg custom-switch-slack">
+              <input
+                type="checkbox"
+                className="custom-control-input border-0"
+                checked={isEnabled}
+                onChange={checkboxHandler}
+              />
+              <label className="custom-control-label align-center"></label>
+            </div> */}
+            {/* <Image className="mx-auto" alt="GitHub avator image" src={owner.avatar_url} width={250} height={250} /> */}
+          </div>
+        </div>
+        <div className="row">
+          <div className="col-12 d-flex flex-wrap gap-2">
+            {/* {topics?.map((topic: string) => {
+              return (
+                <span key={`${name}-${topic}`} className="badge rounded-1 mp-bg-light-blue text-dark fw-normal">
+                  {topic}
+                </span>
+              );
+            })} */}
+          </div>
+        </div>
+      </div>
+      <div className="card-footer px-5 border-top-0 mp-bg-light-blue">
+        <p className="d-flex justify-content-between align-self-center mb-0">
+          <span>
+            {/* {owner.login === 'weseek' ? <FontAwesomeIcon icon={faCircleCheck} className="me-1 text-primary" /> : <></>}
+
+            <a href={owner.html_url} target="_blank" rel="noreferrer">
+              {owner.login}
+            </a> */}
+          </span>
+          {/* <span>
+            <FontAwesomeIcon icon={faCircleArrowDown} className="me-1" /> {stargazersCount}
+          </span> */}
+        </p>
+      </div>
+    </div>
+  );
+};

+ 88 - 0
packages/app/src/components/Admin/PluginsExtension/PluginInstallerForm.tsx

@@ -0,0 +1,88 @@
+import React, { useCallback } from 'react';
+
+import { apiv3Post } from '~/client/util/apiv3-client';
+import { toastError, toastSuccess } from '~/client/util/toastr';
+
+import AdminInstallButtonRow from '../Common/AdminUpdateButtonRow';
+// TODO: error notification (toast, loggerFactory)
+// TODO: i18n
+
+export const PluginInstallerForm = (): JSX.Element => {
+  // const { t } = useTranslation('admin');
+
+  const submitHandler = useCallback(async(e) => {
+    e.preventDefault();
+
+    const formData = e.target.elements;
+
+    const {
+      'pluginInstallerForm[url]': { value: url },
+      // 'pluginInstallerForm[ghBranch]': { value: ghBranch },
+      // 'pluginInstallerForm[ghTag]': { value: ghTag },
+    } = formData;
+
+    const pluginInstallerForm = {
+      url,
+      // ghBranch,
+      // ghTag,
+    };
+
+    try {
+      await apiv3Post('/plugins', { pluginInstallerForm });
+      toastSuccess('Plugin Install Successed!');
+    }
+    catch (err) {
+      toastError(err);
+    }
+  }, []);
+
+  return (
+    <form role="form" onSubmit={submitHandler}>
+      <div className='form-group row'>
+        <label className="text-left text-md-right col-md-3 col-form-label">GitHub Repository URL</label>
+        <div className="col-md-6">
+          <input
+            className="form-control"
+            type="text"
+            // defaultValue={adminAppContainer.state.title || ''}
+            name="pluginInstallerForm[url]"
+            placeholder="https://github.com/weseek/growi-plugin-lsx"
+            required
+          />
+          <p className="form-text text-muted">You can install plugins by inputting the GitHub URL.</p>
+          {/* <p className="form-text text-muted">{t('admin:app_setting.sitename_change')}</p> */}
+        </div>
+      </div>
+      {/* <div className='form-group row'>
+        <label className="text-left text-md-right col-md-3 col-form-label">branch</label>
+        <div className="col-md-6">
+          <input
+            className="form-control"
+            type="text"
+            name="pluginInstallerForm[ghBranch]"
+            placeholder="main"
+          />
+          <p className="form-text text-muted">branch name</p>
+        </div>
+      </div>
+      <div className='form-group row'>
+        <label className="text-left text-md-right col-md-3 col-form-label">tag</label>
+        <div className="col-md-6">
+          <input
+            className="form-control"
+            type="text"
+            name="pluginInstallerForm[ghTag]"
+            placeholder="tags"
+          />
+          <p className="form-text text-muted">tag name</p>
+        </div>
+      </div> */}
+
+      <div className="row my-3">
+        <div className="mx-auto">
+          <button type="submit" className="btn btn-primary">Install</button>
+        </div>
+      </div>
+    </form>
+  );
+};

+ 58 - 0
packages/app/src/components/Admin/PluginsExtension/PluginsExtensionPageContents.tsx

@@ -0,0 +1,58 @@
+import React from 'react';
+
+import type { SearchResultItem } from '~/interfaces/github-api';
+import { useInstalledPlugins } from '~/stores/useInstalledPlugins';
+
+import Loading from './Loading';
+import { PluginCard } from './PluginCard';
+import { PluginInstallerForm } from './PluginInstallerForm';
+
+
+// TODO: i18n
+
+export const PluginsExtensionPageContents = (): JSX.Element => {
+  // const { data, error } = useInstalledPlugins();
+
+  // if (data == null) {
+  //   return <Loading />;
+  // }
+
+  return (
+    <div>
+
+      <div className="row mb-5">
+        <div className="col-lg-12">
+          <h2 className="admin-setting-header">Plugin Installer</h2>
+          <PluginInstallerForm />
+        </div>
+      </div>
+
+      <div className="row mb-5">
+        <div className="col-lg-12">
+          <h2 className="admin-setting-header">Plugins</h2>
+          <div className="d-grid gap-5">
+            <PluginCard
+              name={'growi-plugin-templates-for-office'}
+              url={'https://github.com/weseek/growi-plugin-templates-for-office'}
+              description={'GROWI markdown templates for office.'}
+            />
+            {/* <PluginCard
+              name={'growi-plugin-theme-welcome-to-fumiya-room'}
+              url={'https://github.com/weseek/growi-plugin-theme-welcome-to-fumiya-room'}
+              description={'Welcome to fumiya\'s room! This is very very "latest" design...'}
+            /> */}
+            <PluginCard
+              name={'growi-plugin-copy-code-to-clipboard'}
+              url={'https://github.com/weseek/growi-plugin-copy-code-to-clipboard'}
+              description={'Add copy button on code blocks.'}
+            />
+            {/* {data?.items.map((item: SearchResultItem) => {
+              return <PluginCard key={item.name} {...item} />;
+            })} */}
+          </div>
+        </div>
+      </div>
+
+    </div>
+  );
+};

+ 2 - 2
packages/app/src/components/Admin/Security/LocalSecuritySettingContents.jsx

@@ -100,7 +100,7 @@ class LocalSecuritySettingContents extends React.Component {
 
 
             <div className="row">
             <div className="row">
               <div className="col-12 col-md-3 text-left text-md-right py-2">
               <div className="col-12 col-md-3 text-left text-md-right py-2">
-                <strong>{t('security_settings.Register limitation')}</strong>
+                <strong>{t('security_settings.register_limitation')}</strong>
               </div>
               </div>
               <div className="col-12 col-md-6">
               <div className="col-12 col-md-6">
                 <div className="dropdown">
                 <div className="dropdown">
@@ -147,7 +147,7 @@ class LocalSecuritySettingContents extends React.Component {
                   </div>
                   </div>
                 </div>
                 </div>
 
 
-                <p className="form-text text-muted small">{t('security_settings.Register limitation desc')}</p>
+                <p className="form-text text-muted small">{t('security_settings.register_limitation_desc')}</p>
               </div>
               </div>
             </div>
             </div>
             <div className="row">
             <div className="row">

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

@@ -126,7 +126,7 @@ export const UserGroupTable: FC<Props> = (props: Props) => {
   }, [props.userGroupRelations, props.childUserGroups]);
   }, [props.userGroupRelations, props.childUserGroups]);
 
 
   return (
   return (
-    <>
+    <div data-testid="grw-user-group-table">
       <h2>{props.headerLabel}</h2>
       <h2>{props.headerLabel}</h2>
 
 
       <table className="table table-bordered table-user-list">
       <table className="table table-bordered table-user-list">
@@ -216,6 +216,6 @@ export const UserGroupTable: FC<Props> = (props: Props) => {
           })}
           })}
         </tbody>
         </tbody>
       </table>
       </table>
-    </>
+    </div>
   );
   );
 };
 };

+ 2 - 5
packages/app/src/components/BookmarkButtons.tsx

@@ -41,15 +41,12 @@ const BookmarkButtons: FC<Props> = (props: Props) => {
   };
   };
 
 
   const getTooltipMessage = useCallback(() => {
   const getTooltipMessage = useCallback(() => {
-    if (isGuestUser) {
-      return 'Not available for guest';
-    }
 
 
     if (isBookmarked) {
     if (isBookmarked) {
       return 'tooltip.cancel_bookmark';
       return 'tooltip.cancel_bookmark';
     }
     }
     return 'tooltip.bookmark';
     return 'tooltip.bookmark';
-  }, [isGuestUser, isBookmarked]);
+  }, [isBookmarked]);
 
 
   return (
   return (
     <div className={`btn-group btn-group-bookmark ${styles['btn-group-bookmark']}`} role="group" aria-label="Bookmark buttons">
     <div className={`btn-group btn-group-bookmark ${styles['btn-group-bookmark']}`} role="group" aria-label="Bookmark buttons">
@@ -63,7 +60,7 @@ const BookmarkButtons: FC<Props> = (props: Props) => {
         <i className={`fa ${isBookmarked ? 'fa-bookmark' : 'fa-bookmark-o'}`}></i>
         <i className={`fa ${isBookmarked ? 'fa-bookmark' : 'fa-bookmark-o'}`}></i>
       </button>
       </button>
 
 
-      <UncontrolledTooltip placement="top" target="bookmark-button" fade={false}>
+      <UncontrolledTooltip data-testid="bookmark-button-tooltip" placement="top" target="bookmark-button" fade={false}>
         {t(getTooltipMessage())}
         {t(getTooltipMessage())}
       </UncontrolledTooltip>
       </UncontrolledTooltip>
 
 

+ 1 - 1
packages/app/src/components/Common/Dropdown/PageItemControl.tsx

@@ -248,7 +248,7 @@ const PageItemControlDropdownMenu = React.memo((props: DropdownMenuProps): JSX.E
     <DropdownMenu
     <DropdownMenu
       data-testid="page-item-control-menu"
       data-testid="page-item-control-menu"
       positionFixed
       positionFixed
-      modifiers={{ preventOverflow: { boundariesElement: undefined } }}
+      modifiers={{ preventOverflow: { boundariesElement: 'viewport' } }}
       right={alignRight}
       right={alignRight}
     >
     >
       {contents}
       {contents}

+ 2 - 2
packages/app/src/components/CreateTemplateModal.jsx

@@ -1,8 +1,8 @@
 import React from 'react';
 import React from 'react';
 
 
 import { pathUtils } from '@growi/core';
 import { pathUtils } from '@growi/core';
-import PropTypes from 'prop-types';
 import { useTranslation } from 'next-i18next';
 import { useTranslation } from 'next-i18next';
+import PropTypes from 'prop-types';
 import { Modal, ModalHeader, ModalBody } from 'reactstrap';
 import { Modal, ModalHeader, ModalBody } from 'reactstrap';
 import urljoin from 'url-join';
 import urljoin from 'url-join';
 
 
@@ -42,7 +42,7 @@ const CreateTemplateModal = (props) => {
   }
   }
 
 
   return (
   return (
-    <Modal isOpen={props.isOpen} toggle={props.onClose} data-testid="page-template-modal" className="grw-create-page">
+    <Modal isOpen={props.isOpen} toggle={props.onClose} data-testid="page-template-modal">
       <ModalHeader tag="h4" toggle={props.onClose} className="bg-primary text-light">
       <ModalHeader tag="h4" toggle={props.onClose} className="bg-primary text-light">
         {t('template.modal_label.Create/Edit Template Page')}
         {t('template.modal_label.Create/Edit Template Page')}
       </ModalHeader>
       </ModalHeader>

+ 0 - 117
packages/app/src/components/Drawio.tsx

@@ -1,117 +0,0 @@
-import React, {
-  useCallback, useEffect, useMemo, useRef, useState,
-} from 'react';
-
-import EventEmitter from 'events';
-
-import { useTranslation } from 'next-i18next';
-import { debounce } from 'throttle-debounce';
-
-import { CustomWindow } from '~/interfaces/global';
-import { IGraphViewer, isGraphViewer } from '~/interfaces/graph-viewer';
-
-import NotAvailableForGuest from './NotAvailableForGuest';
-
-type Props = {
-  GraphViewer: IGraphViewer,
-  drawioContent: string,
-  rangeLineNumberOfMarkdown: { beginLineNumber: number, endLineNumber: number },
-  isPreview?: boolean,
-}
-
-// It calls callback when GraphViewer is not null.
-// eslint-disable-next-line @typescript-eslint/ban-types
-const waitForGraphViewer = async(callback: Function) => {
-  const MAX_WAIT_COUNT = 10; // no reason for 10
-
-  for (let i = 0; i < MAX_WAIT_COUNT; i++) {
-    if (isGraphViewer((window as CustomWindow).GraphViewer)) {
-      callback((window as CustomWindow).GraphViewer);
-      break;
-    }
-    // Sleep 500 ms
-    // eslint-disable-next-line no-await-in-loop
-    await new Promise<void>(r => setTimeout(() => r(), 500));
-  }
-};
-
-const Drawio = (props: Props): JSX.Element => {
-
-  const { t } = useTranslation();
-
-  // Wrap with a function since GraphViewer is a function.
-  // This applies when call setGraphViewer as well.
-  const [GraphViewer, setGraphViewer] = useState<IGraphViewer | undefined>(() => (window as CustomWindow).GraphViewer);
-
-  const { drawioContent, rangeLineNumberOfMarkdown, isPreview } = props;
-
-  // const { open: openDrawioModal } = useDrawioModalForPage();
-
-  const drawioContainerRef = useRef<HTMLDivElement>(null);
-
-  const globalEmitter: EventEmitter = (window as CustomWindow).globalEmitter;
-
-  const editButtonClickHandler = useCallback(() => {
-    const { beginLineNumber, endLineNumber } = rangeLineNumberOfMarkdown;
-    globalEmitter.emit('launchDrawioModal', beginLineNumber, endLineNumber);
-  }, [rangeLineNumberOfMarkdown, globalEmitter]);
-
-  const renderDrawio = useCallback((GraphViewer: IGraphViewer) => {
-    if (drawioContainerRef.current == null) {
-      return;
-    }
-
-    const mxgraphs = drawioContainerRef.current.getElementsByClassName('mxgraph');
-    if (mxgraphs.length > 0) {
-      // GROWI では、mxgraph element は最初のものをレンダリングする前提とする
-      const div = mxgraphs[0];
-
-      if (div != null) {
-        div.innerHTML = '';
-        GraphViewer.createViewerForElement(div);
-      }
-    }
-  }, [drawioContainerRef]);
-
-  const renderDrawioWithDebounce = useMemo(() => debounce(200, renderDrawio), [renderDrawio]);
-
-  useEffect(() => {
-    if (GraphViewer == null) {
-      waitForGraphViewer((gv: IGraphViewer) => {
-        setGraphViewer(() => gv);
-      });
-      return;
-    }
-
-    renderDrawioWithDebounce(GraphViewer);
-  }, [renderDrawioWithDebounce, GraphViewer]);
-
-  return (
-    <div className="editable-with-drawio position-relative">
-      { !isPreview && (
-        <NotAvailableForGuest>
-          <button type="button" className="drawio-iframe-trigger position-absolute btn btn-outline-secondary" onClick={editButtonClickHandler}>
-            <i className="icon-note mr-1"></i>{t('Edit')}
-          </button>
-        </NotAvailableForGuest>
-      ) }
-      <div
-        className="drawio"
-        style={
-          {
-            borderRadius: 3,
-            border: '1px solid #d7d7d7',
-            margin: '20px 0',
-          }
-        }
-        ref={drawioContainerRef}
-        // eslint-disable-next-line react/no-danger
-        dangerouslySetInnerHTML={{ __html: drawioContent }}
-      >
-      </div>
-    </div>
-  );
-
-};
-
-export default Drawio;

+ 1 - 1
packages/app/src/components/EmptyTrashModal.tsx

@@ -59,7 +59,7 @@ const EmptyTrashModal: FC = () => {
   };
   };
 
 
   return (
   return (
-    <Modal size="lg" isOpen={isOpened} toggle={closeEmptyTrashModal} data-testid="page-delete-modal" className="grw-create-page">
+    <Modal size="lg" isOpen={isOpened} toggle={closeEmptyTrashModal} data-testid="page-delete-modal">
       <ModalHeader tag="h4" toggle={closeEmptyTrashModal} className="bg-danger text-light">
       <ModalHeader tag="h4" toggle={closeEmptyTrashModal} className="bg-danger text-light">
         <i className="icon-fw icon-fire"></i>
         <i className="icon-fw icon-fire"></i>
         {t('modal_empty.empty_the_trash')}
         {t('modal_empty.empty_the_trash')}

+ 17 - 2
packages/app/src/components/EventListeneres/HashChanged.tsx

@@ -1,12 +1,15 @@
 import React, { useCallback, useEffect } from 'react';
 import React, { useCallback, useEffect } from 'react';
 
 
-import { useEditorMode, determineEditorModeByHash } from '~/stores/ui';
+import { useRouter } from 'next/router';
+
 import { useIsEditable } from '~/stores/context';
 import { useIsEditable } from '~/stores/context';
+import { useEditorMode, determineEditorModeByHash } from '~/stores/ui';
 
 
 /**
 /**
  * Change editorMode by browser forward/back operation
  * Change editorMode by browser forward/back operation
  */
  */
 const HashChanged = (): JSX.Element => {
 const HashChanged = (): JSX.Element => {
+  const router = useRouter();
   const { data: isEditable } = useIsEditable();
   const { data: isEditable } = useIsEditable();
   const { data: editorMode, mutate: mutateEditorMode } = useEditorMode();
   const { data: editorMode, mutate: mutateEditorMode } = useEditorMode();
 
 
@@ -31,7 +34,19 @@ const HashChanged = (): JSX.Element => {
       window.removeEventListener('hashchange', hashchangeHandler);
       window.removeEventListener('hashchange', hashchangeHandler);
     };
     };
 
 
-  }, [hashchangeHandler, isEditable, mutateEditorMode]);
+  }, [hashchangeHandler, isEditable]);
+
+  /*
+  * Route changes by Next Router
+  * https://nextjs.org/docs/api-reference/next/router
+  */
+  useEffect(() => {
+    router.events.on('routeChangeComplete', hashchangeHandler);
+
+    return () => {
+      router.events.off('routeChangeComplete', hashchangeHandler);
+    };
+  }, [hashchangeHandler, router.events]);
 
 
   return <></>;
   return <></>;
 };
 };

+ 2 - 2
packages/app/src/components/InAppNotification/InAppNotificationList.tsx

@@ -2,7 +2,7 @@ import React, { FC } from 'react';
 
 
 import { HasObjectId } from '@growi/core';
 import { HasObjectId } from '@growi/core';
 
 
-import { IInAppNotification, PaginateResult } from '~/interfaces/in-app-notification';
+import type { IInAppNotification, PaginateResult } from '~/interfaces/in-app-notification';
 
 
 import InAppNotificationElm from './InAppNotificationElm';
 import InAppNotificationElm from './InAppNotificationElm';
 
 
@@ -26,7 +26,7 @@ const InAppNotificationList: FC<Props> = (props: Props) => {
     );
     );
   }
   }
 
 
-  const notifications = inAppNotificationData.docs;
+  const notifications = inAppNotificationData.docs.filter((notification) => { return notification.parsedSnapshot != null });
 
 
   return (
   return (
     <>
     <>

+ 3 - 6
packages/app/src/components/InAppNotification/PageNotification/PageModelNotification.tsx

@@ -6,10 +6,9 @@ import { HasObjectId } from '@growi/core';
 import { PagePathLabel } from '@growi/ui';
 import { PagePathLabel } from '@growi/ui';
 import { useRouter } from 'next/router';
 import { useRouter } from 'next/router';
 
 
-import { IInAppNotificationOpenable } from '~/client/interfaces/in-app-notification-openable';
-import { IInAppNotification } from '~/interfaces/in-app-notification';
+import type { IInAppNotificationOpenable } from '~/client/interfaces/in-app-notification-openable';
+import type { IInAppNotification } from '~/interfaces/in-app-notification';
 
 
-import { parseSnapshot } from '../../../models/serializers/in-app-notification-snapshot/page';
 import FormattedDistanceDate from '../../FormattedDistanceDate';
 import FormattedDistanceDate from '../../FormattedDistanceDate';
 
 
 interface Props {
 interface Props {
@@ -27,8 +26,6 @@ const PageModelNotification: ForwardRefRenderFunction<IInAppNotificationOpenable
 
 
   const router = useRouter();
   const router = useRouter();
 
 
-  const snapshot = parseSnapshot(notification.snapshot);
-
   // publish open()
   // publish open()
   useImperativeHandle(ref, () => ({
   useImperativeHandle(ref, () => ({
     open() {
     open() {
@@ -45,7 +42,7 @@ const PageModelNotification: ForwardRefRenderFunction<IInAppNotificationOpenable
   return (
   return (
     <div className="p-2 overflow-hidden">
     <div className="p-2 overflow-hidden">
       <div className="text-truncate">
       <div className="text-truncate">
-        <b>{actionUsers}</b> {actionMsg} <PagePathLabel path={snapshot.path} />
+        <b>{actionUsers}</b> {actionMsg} <PagePathLabel path={notification.parsedSnapshot?.path ?? ''} />
       </div>
       </div>
       <i className={`${actionIcon} mr-2`} />
       <i className={`${actionIcon} mr-2`} />
       <FormattedDistanceDate
       <FormattedDistanceDate

+ 5 - 3
packages/app/src/components/InstallerForm.tsx

@@ -106,11 +106,13 @@ const InstallerForm = memo((): JSX.Element => {
       <div className="row">
       <div className="row">
         <form role="form" id="register-form" className="col-md-12" onSubmit={submitHandler}>
         <form role="form" id="register-form" className="col-md-12" onSubmit={submitHandler}>
           <div className="dropdown mb-3">
           <div className="dropdown mb-3">
-            <div className="d-flex dropdown-with-icon">
-              <i className="icon-bubbles border-0 rounded-0" />
+            <div className="input-group">
+              <div className="input-group-prepend dropdown-with-icon">
+                <i className="input-group-text icon-bubbles border-0 rounded-0" />
+              </div>
               <button
               <button
                 type="button"
                 type="button"
-                className="btn btn-secondary dropdown-toggle text-right w-100 border-0 shadow-none"
+                className="btn btn-secondary dropdown-toggle form-control text-right rounded-right"
                 id="dropdownLanguage"
                 id="dropdownLanguage"
                 data-testid="dropdownLanguage"
                 data-testid="dropdownLanguage"
                 data-toggle="dropdown"
                 data-toggle="dropdown"

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

@@ -28,7 +28,7 @@ const AdminLayout = ({
   return (
   return (
     <RawLayout title={title}>
     <RawLayout title={title}>
       <div className={`admin-page ${styles['admin-page']}`}>
       <div className={`admin-page ${styles['admin-page']}`}>
-        <GrowiNavbar />
+        <GrowiNavbar isGlobalSearchHidden={true} />
 
 
         <header className="py-0 container-fluid">
         <header className="py-0 container-fluid">
           <h1 className="title px-3">{componentTitle}</h1>
           <h1 className="title px-3">{componentTitle}</h1>

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

@@ -22,7 +22,6 @@ const PageDeleteModal = dynamic(() => import('../PageDeleteModal'), { ssr: false
 const PageRenameModal = dynamic(() => import('../PageRenameModal'), { ssr: false });
 const PageRenameModal = dynamic(() => import('../PageRenameModal'), { ssr: false });
 const PagePresentationModal = dynamic(() => import('../PagePresentationModal'), { ssr: false });
 const PagePresentationModal = dynamic(() => import('../PagePresentationModal'), { ssr: false });
 const PageAccessoriesModal = dynamic(() => import('../PageAccessoriesModal'), { ssr: false });
 const PageAccessoriesModal = dynamic(() => import('../PageAccessoriesModal'), { ssr: false });
-const DrawioModal = dynamic(() => import('../PageEditor/DrawioModal').then(mod => mod.DrawioModal), { ssr: false });
 // Fab
 // Fab
 const Fab = dynamic(() => import('../Fab').then(mod => mod.Fab), { ssr: false });
 const Fab = dynamic(() => import('../Fab').then(mod => mod.Fab), { ssr: false });
 
 
@@ -64,7 +63,6 @@ export const BasicLayout = ({
         <PageDeleteModal />
         <PageDeleteModal />
         <PageRenameModal />
         <PageRenameModal />
         <PageAccessoriesModal />
         <PageAccessoriesModal />
-        <DrawioModal />
       </DndProvider>
       </DndProvider>
 
 
       <PagePresentationModal />
       <PagePresentationModal />

+ 0 - 10
packages/app/src/components/Layout/NoLoginLayout.module.scss

@@ -63,16 +63,6 @@
     }
     }
   }
   }
 
 
-  .dropdown-with-icon {
-    .dropdown-toggle {
-      @extend .form-control;
-    }
-    i {
-      @extend .input-group-text;
-      margin-right: -1px;
-    }
-  }
-
   .input-group {
   .input-group {
     margin-bottom: 10px;
     margin-bottom: 10px;
 
 

+ 6 - 12
packages/app/src/components/Layout/RawLayout.tsx

@@ -1,16 +1,13 @@
 import React, { ReactNode, useState } from 'react';
 import React, { ReactNode, useState } from 'react';
 
 
 import Head from 'next/head';
 import Head from 'next/head';
+import { ToastContainer } from 'react-toastify';
 import { useIsomorphicLayoutEffect } from 'usehooks-ts';
 import { useIsomorphicLayoutEffect } from 'usehooks-ts';
 
 
-import { useGrowiTheme } from '~/stores/context';
 import { ColorScheme, useNextThemes, NextThemesProvider } from '~/stores/use-next-themes';
 import { ColorScheme, useNextThemes, NextThemesProvider } from '~/stores/use-next-themes';
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 
 
 
 
-import { ThemeProvider as GrowiThemeProvider } from '../Theme/utils/ThemeProvider';
-
-
 const logger = loggerFactory('growi:cli:RawLayout');
 const logger = loggerFactory('growi:cli:RawLayout');
 
 
 
 
@@ -25,8 +22,6 @@ export const RawLayout = ({ children, title, className }: Props): JSX.Element =>
   if (className != null) {
   if (className != null) {
     classNames.push(className);
     classNames.push(className);
   }
   }
-  const { data: growiTheme } = useGrowiTheme();
-
   // get color scheme from next-themes
   // get color scheme from next-themes
   const { resolvedTheme, resolvedThemeByAttributes } = useNextThemes();
   const { resolvedTheme, resolvedThemeByAttributes } = useNextThemes();
 
 
@@ -35,7 +30,7 @@ export const RawLayout = ({ children, title, className }: Props): JSX.Element =>
   // set colorScheme in CSR
   // set colorScheme in CSR
   useIsomorphicLayoutEffect(() => {
   useIsomorphicLayoutEffect(() => {
     setColorScheme(resolvedTheme ?? resolvedThemeByAttributes);
     setColorScheme(resolvedTheme ?? resolvedThemeByAttributes);
-  }, [resolvedTheme]);
+  }, [resolvedTheme, resolvedThemeByAttributes]);
 
 
   return (
   return (
     <>
     <>
@@ -45,11 +40,10 @@ export const RawLayout = ({ children, title, className }: Props): JSX.Element =>
         <meta name="viewport" content="initial-scale=1.0, width=device-width" />
         <meta name="viewport" content="initial-scale=1.0, width=device-width" />
       </Head>
       </Head>
       <NextThemesProvider>
       <NextThemesProvider>
-        <GrowiThemeProvider theme={growiTheme} colorScheme={colorScheme}>
-          <div className={classNames.join(' ')} data-color-scheme={colorScheme}>
-            {children}
-          </div>
-        </GrowiThemeProvider>
+        <div className={classNames.join(' ')} data-color-scheme={colorScheme}>
+          {children}
+          <ToastContainer theme={colorScheme} />
+        </div>
       </NextThemesProvider>
       </NextThemesProvider>
     </>
     </>
   );
   );

+ 2 - 5
packages/app/src/components/LikeButtons.tsx

@@ -34,15 +34,12 @@ const LikeButtons: FC<LikeButtonsProps> = (props: LikeButtonsProps) => {
   } = props;
   } = props;
 
 
   const getTooltipMessage = useCallback(() => {
   const getTooltipMessage = useCallback(() => {
-    if (isGuestUser) {
-      return 'Not available for guest';
-    }
 
 
     if (isLiked) {
     if (isLiked) {
       return 'tooltip.cancel_like';
       return 'tooltip.cancel_like';
     }
     }
     return 'tooltip.like';
     return 'tooltip.like';
-  }, [isGuestUser, isLiked]);
+  }, [isLiked]);
 
 
   return (
   return (
     <div className={`btn-group btn-group-like ${styles['btn-group-like']}`} role="group" aria-label="Like buttons">
     <div className={`btn-group btn-group-like ${styles['btn-group-like']}`} role="group" aria-label="Like buttons">
@@ -56,7 +53,7 @@ const LikeButtons: FC<LikeButtonsProps> = (props: LikeButtonsProps) => {
         <i className={`fa ${isLiked ? 'fa-heart' : 'fa-heart-o'}`}></i>
         <i className={`fa ${isLiked ? 'fa-heart' : 'fa-heart-o'}`}></i>
       </button>
       </button>
 
 
-      <UncontrolledTooltip placement="top" target="like-button" fade={false}>
+      <UncontrolledTooltip data-testid="like-button-tooltip" placement="top" target="like-button" autohide={false} fade={false}>
         {t(getTooltipMessage())}
         {t(getTooltipMessage())}
       </UncontrolledTooltip>
       </UncontrolledTooltip>
 
 

+ 9 - 4
packages/app/src/components/LoginForm.tsx

@@ -7,8 +7,9 @@ import { useRouter } from 'next/router';
 import ReactCardFlip from 'react-card-flip';
 import ReactCardFlip from 'react-card-flip';
 
 
 import { apiv3Post } from '~/client/util/apiv3-client';
 import { apiv3Post } from '~/client/util/apiv3-client';
+import type { IExternalAccountLoginError } from '~/interfaces/errors/external-account-login-error';
 import { LoginErrorCode } from '~/interfaces/errors/login-error';
 import { LoginErrorCode } from '~/interfaces/errors/login-error';
-import { IErrorV3 } from '~/interfaces/errors/v3-error';
+import type { IErrorV3 } from '~/interfaces/errors/v3-error';
 import { RegistrationMode } from '~/interfaces/registration-mode';
 import { RegistrationMode } from '~/interfaces/registration-mode';
 import { toArrayIfNot } from '~/utils/array-utils';
 import { toArrayIfNot } from '~/utils/array-utils';
 
 
@@ -26,7 +27,8 @@ type LoginFormProps = {
   isLdapStrategySetup: boolean,
   isLdapStrategySetup: boolean,
   isLdapSetupFailed: boolean,
   isLdapSetupFailed: boolean,
   objOfIsExternalAuthEnableds?: any,
   objOfIsExternalAuthEnableds?: any,
-  isMailerSetup?: boolean
+  isMailerSetup?: boolean,
+  externalAccountLoginError?: IExternalAccountLoginError,
 }
 }
 export const LoginForm = (props: LoginFormProps): JSX.Element => {
 export const LoginForm = (props: LoginFormProps): JSX.Element => {
   const { t } = useTranslation();
   const { t } = useTranslation();
@@ -129,7 +131,7 @@ export const LoginForm = (props: LoginFormProps): JSX.Element => {
   }, [t]);
   }, [t]);
 
 
   // wrap error elements which do not use dangerouslySetInnerHtml
   // wrap error elements which do not use dangerouslySetInnerHtml
-  const generateSafelySetErrors = useCallback((errors: IErrorV3[]): JSX.Element => {
+  const generateSafelySetErrors = useCallback((errors: (IErrorV3 | IExternalAccountLoginError)[]): JSX.Element => {
     if (errors == null || errors.length === 0) return <></>;
     if (errors == null || errors.length === 0) return <></>;
     return (
     return (
       <ul className="alert alert-danger">
       <ul className="alert alert-danger">
@@ -151,7 +153,10 @@ export const LoginForm = (props: LoginFormProps): JSX.Element => {
     // Generate login error elements using dangerouslySetInnerHTML
     // Generate login error elements using dangerouslySetInnerHTML
     const loginErrorElementWithDangerouslySetInnerHTML = generateDangerouslySetErrors(loginErrorListForDangerouslySetInnerHTML);
     const loginErrorElementWithDangerouslySetInnerHTML = generateDangerouslySetErrors(loginErrorListForDangerouslySetInnerHTML);
     // Generate login error elements using <ul>, <li>
     // Generate login error elements using <ul>, <li>
-    const loginErrorElement = generateSafelySetErrors(loginErrorList);
+
+    const loginErrorElement = props.externalAccountLoginError != null
+      ? generateSafelySetErrors([...loginErrorList, props.externalAccountLoginError])
+      : generateSafelySetErrors(loginErrorList);
 
 
     return (
     return (
       <>
       <>

+ 4 - 6
packages/app/src/components/Navbar/AppearanceModeDropdown.tsx

@@ -68,6 +68,8 @@ export const AppearanceModeDropdown:FC<AppearanceModeDropdownProps> = (props: Ap
     </>
     </>
   );
   );
 
 
+  const dropdownDivider = <div className="dropdown-divider"></div>;
+
   const renderSidebarModeSwitch = useCallback((isEditMode: boolean) => {
   const renderSidebarModeSwitch = useCallback((isEditMode: boolean) => {
     return (
     return (
       <>
       <>
@@ -111,14 +113,10 @@ export const AppearanceModeDropdown:FC<AppearanceModeDropdownProps> = (props: Ap
       <div className="dropdown-menu dropdown-menu-right">
       <div className="dropdown-menu dropdown-menu-right">
 
 
         {/* sidebar mode */}
         {/* sidebar mode */}
-        {renderSidebarModeSwitch(false)}
-
-        <div className="dropdown-divider"></div>
+        {[renderSidebarModeSwitch(false), dropdownDivider]}
 
 
         {/* side bar mode on editor */}
         {/* side bar mode on editor */}
-        {isAuthenticated && renderSidebarModeSwitch(true)}
-
-        <div className="dropdown-divider"></div>
+        {isAuthenticated && [renderSidebarModeSwitch(true), dropdownDivider]}
 
 
         {/* color mode */}
         {/* color mode */}
         <h6 className="dropdown-header">{t('personal_dropdown.color_mode')}</h6>
         <h6 className="dropdown-header">{t('personal_dropdown.color_mode')}</h6>

+ 7 - 4
packages/app/src/components/Navbar/GrowiContextualSubNavigation.tsx

@@ -1,6 +1,6 @@
 import React, { useState, useEffect, useCallback } from 'react';
 import React, { useState, useEffect, useCallback } from 'react';
 
 
-import { isPopulated, IUser } from '@growi/core';
+import { isPopulated, IUser, pagePathUtils } from '@growi/core';
 import { useTranslation } from 'next-i18next';
 import { useTranslation } from 'next-i18next';
 import dynamic from 'next/dynamic';
 import dynamic from 'next/dynamic';
 import { useRouter } from 'next/router';
 import { useRouter } from 'next/router';
@@ -43,6 +43,7 @@ import type { SubNavButtonsProps } from './SubNavButtons';
 import AuthorInfoStyles from './AuthorInfo.module.scss';
 import AuthorInfoStyles from './AuthorInfo.module.scss';
 import PageEditorModeManagerStyles from './PageEditorModeManager.module.scss';
 import PageEditorModeManagerStyles from './PageEditorModeManager.module.scss';
 
 
+const { isUsersHomePage } = pagePathUtils;
 
 
 const AuthorInfoSkeleton = () => <Skeleton additionalClass={`${AuthorInfoStyles['grw-author-info-skeleton']} py-1`} />;
 const AuthorInfoSkeleton = () => <Skeleton additionalClass={`${AuthorInfoStyles['grw-author-info-skeleton']} py-1`} />;
 
 
@@ -304,11 +305,13 @@ const GrowiContextualSubNavigation = (props: GrowiContextualSubNavigationProps):
         router.push(path);
         router.push(path);
       }
       }
       else {
       else {
-        reload();
+        // Do not use "router.push(currentPathname)" to avoid `Error: Invariant: attempted to hard navigate to the same URL`
+        // See: https://github.com/weseek/growi/pull/7061
+        router.reload();
       }
       }
     };
     };
     openDeleteModal([pageWithMeta], { onDeleted: deletedHandler });
     openDeleteModal([pageWithMeta], { onDeleted: deletedHandler });
-  }, [openDeleteModal, reload, router]);
+  }, [openDeleteModal, router]);
 
 
   const switchContentWidthHandler = useCallback(async(pageId: string, value: boolean) => {
   const switchContentWidthHandler = useCallback(async(pageId: string, value: boolean) => {
     await updateContentWidth(pageId, value);
     await updateContentWidth(pageId, value);
@@ -378,7 +381,7 @@ const GrowiContextualSubNavigation = (props: GrowiContextualSubNavigationProps):
               />
               />
             )}
             )}
           </div>
           </div>
-          { (isAbleToShowPageAuthors && !isCompactMode) && (
+          { (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`}>
             <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">
               <li className="pb-1">
                 { currentPage != null
                 { currentPage != null

+ 9 - 3
packages/app/src/components/Navbar/GrowiNavbar.tsx

@@ -59,7 +59,7 @@ const NavbarRight = memo((): JSX.Element => {
             onClick={() => openCreateModal(currentPagePath || '')}
             onClick={() => openCreateModal(currentPagePath || '')}
           >
           >
             <i className="icon-pencil mr-2"></i>
             <i className="icon-pencil mr-2"></i>
-            <span className="d-none d-lg-block">{ t('New') }</span>
+            <span className="d-none d-lg-block">{ t('commons:New') }</span>
           </button>
           </button>
         </li>
         </li>
 
 
@@ -136,7 +136,13 @@ const GrowiNavbarLogo: FC<NavbarLogoProps> = memo((props: NavbarLogoProps) => {
 
 
 GrowiNavbarLogo.displayName = 'GrowiNavbarLogo';
 GrowiNavbarLogo.displayName = 'GrowiNavbarLogo';
 
 
-export const GrowiNavbar = (): JSX.Element => {
+type Props = {
+  isGlobalSearchHidden?: boolean
+}
+
+export const GrowiNavbar = (props: Props): JSX.Element => {
+
+  const { isGlobalSearchHidden } = props;
 
 
   const GlobalSearch = dynamic<GlobalSearchProps>(() => import('./GlobalSearch').then(mod => mod.GlobalSearch), { ssr: false });
   const GlobalSearch = dynamic<GlobalSearchProps>(() => import('./GlobalSearch').then(mod => mod.GlobalSearch), { ssr: false });
 
 
@@ -169,7 +175,7 @@ export const GrowiNavbar = (): JSX.Element => {
       </ul>
       </ul>
 
 
       <div className="grw-global-search-container position-absolute">
       <div className="grw-global-search-container position-absolute">
-        { isSearchServiceConfigured && !isDeviceSmallerThanMd && !isSearchPage && (
+        { !isGlobalSearchHidden && isSearchServiceConfigured && !isDeviceSmallerThanMd && !isSearchPage && (
           <GlobalSearch />
           <GlobalSearch />
         ) }
         ) }
       </div>
       </div>

+ 0 - 5
packages/app/src/components/Navbar/PageEditorModeManager.jsx

@@ -110,11 +110,6 @@ function PageEditorModeManager(props) {
           </>
           </>
         )}
         )}
       </div>
       </div>
-      {isBtnDisabled && (
-        <UncontrolledTooltip placement="top" target="grw-page-editor-mode-manager" fade={false}>
-          {t('Not available for guest')}
-        </UncontrolledTooltip>
-      )}
     </>
     </>
   );
   );
 
 

+ 3 - 3
packages/app/src/components/Navbar/PersonalDropdown.jsx

@@ -39,7 +39,7 @@ const PersonalDropdown = () => {
       </button>
       </button>
 
 
       {/* Menu */}
       {/* Menu */}
-      <div className="dropdown-menu dropdown-menu-right">
+      <div className="dropdown-menu dropdown-menu-right" data-testid="personal-dropdown-menu">
 
 
         <div className="px-4 pt-3 pb-2 text-center">
         <div className="px-4 pt-3 pb-2 text-center">
           <UserPicture user={user} size="lg" noLink noTooltip />
           <UserPicture user={user} size="lg" noLink noTooltip />
@@ -55,12 +55,12 @@ const PersonalDropdown = () => {
 
 
           <div className="btn-group btn-block mt-2" role="group">
           <div className="btn-group btn-block mt-2" role="group">
             <Link href={`/user/${user.username}`}>
             <Link href={`/user/${user.username}`}>
-              <a className="btn btn-sm btn-outline-secondary col">
+              <a className="btn btn-sm btn-outline-secondary col" data-testid="grw-personal-dropdown-menu-user-home">
                 <i className="icon-fw icon-home"></i>{t('personal_dropdown.home')}
                 <i className="icon-fw icon-home"></i>{t('personal_dropdown.home')}
               </a>
               </a>
             </Link>
             </Link>
             <Link href="/me">
             <Link href="/me">
-              <a className="btn btn-sm btn-outline-secondary col">
+              <a className="btn btn-sm btn-outline-secondary col" data-testid="grw-personal-dropdown-menu-user-settings">
                 <i className="icon-fw icon-wrench"></i>{t('personal_dropdown.settings')}
                 <i className="icon-fw icon-wrench"></i>{t('personal_dropdown.settings')}
               </a>
               </a>
             </Link>
             </Link>

+ 0 - 39
packages/app/src/components/NotAvailableForGuest.jsx

@@ -1,39 +0,0 @@
-import React from 'react';
-
-import PropTypes from 'prop-types';
-import { UncontrolledTooltip } from 'reactstrap';
-
-import { useIsGuestUser } from '~/stores/context';
-
-const NotAvailableForGuest = (props) => {
-  const { children } = props;
-
-  const { data: isGuestUser } = useIsGuestUser();
-
-  if (!isGuestUser) {
-    return props.children;
-  }
-
-  const id = children.props.id || `grw-not-available-for-guest-${Math.random().toString(32).substring(2)}`;
-
-  // clone and add className
-  const clonedChild = React.cloneElement(children, {
-    id,
-    className: `${children.props.className} grw-not-available-for-guest`,
-    onClick: () => { /* do nothing */ },
-  });
-
-  return (
-    <>
-      { clonedChild }
-      <UncontrolledTooltip placement="top" target={id}>Not available for guest</UncontrolledTooltip>
-    </>
-  );
-
-};
-
-NotAvailableForGuest.propTypes = {
-  children: PropTypes.node.isRequired,
-};
-
-export default NotAvailableForGuest;

+ 31 - 0
packages/app/src/components/NotAvailableForGuest.tsx

@@ -0,0 +1,31 @@
+import React from 'react';
+
+import { useTranslation } from 'next-i18next';
+import { Disable } from 'react-disable';
+import { UncontrolledTooltip } from 'reactstrap';
+
+import { useIsGuestUser } from '~/stores/context';
+
+type NotAvailableForGuestProps = {
+  children: JSX.Element
+}
+
+export const NotAvailableForGuest = ({ children }: NotAvailableForGuestProps): JSX.Element => {
+  const { t } = useTranslation();
+
+  const { data: isGuestUser } = useIsGuestUser();
+  const isDisabled = !!isGuestUser;
+
+  const id = `grw-not-available-for-guest-${Math.random().toString(32).substring(2)}`;
+
+  return (
+    <>
+      <div id={id}>
+        <Disable disabled={isDisabled}>
+          { children }
+        </Disable>
+      </div>
+      <UncontrolledTooltip placement="top" target={id}>{t('Not available for guest')}</UncontrolledTooltip>
+    </>
+  );
+};

+ 6 - 0
packages/app/src/components/Page.module.scss

@@ -0,0 +1,6 @@
+// mobile
+.page-mobile :global {
+  .editable-with-handsontable .handsontable-modal-trigger {
+    opacity: 0.3;
+  }
+}

+ 174 - 158
packages/app/src/components/Page.tsx

@@ -1,156 +1,56 @@
 import React, {
 import React, {
   useCallback,
   useCallback,
-  useEffect, useRef, useState,
+  useEffect, useRef,
 } from 'react';
 } from 'react';
 
 
 import EventEmitter from 'events';
 import EventEmitter from 'events';
 
 
+import { DrawioEditByViewerProps } from '@growi/remark-drawio';
+import { useTranslation } from 'next-i18next';
 import dynamic from 'next/dynamic';
 import dynamic from 'next/dynamic';
-// import { debounce } from 'throttle-debounce';
-
 import { HtmlElementNode } from 'rehype-toc';
 import { HtmlElementNode } from 'rehype-toc';
 
 
+import MarkdownTable from '~/client/models/MarkdownTable';
+import { useSaveOrUpdate } from '~/client/services/page-operation';
 import { toastSuccess, toastError } from '~/client/util/apiNotification';
 import { toastSuccess, toastError } from '~/client/util/apiNotification';
-import { getOptionsToSave } from '~/client/util/editor';
+import { OptionsToSave } from '~/interfaces/page-operation';
 import {
 import {
   useIsGuestUser, useShareLinkId,
   useIsGuestUser, useShareLinkId,
 } from '~/stores/context';
 } from '~/stores/context';
-import {
-  useSWRxSlackChannels, useIsSlackEnabled, usePageTagsForEditors, useIsEnabledUnsavedWarning,
-} from '~/stores/editor';
-import { useSWRxCurrentPage } from '~/stores/page';
+import { useEditingMarkdown } from '~/stores/editor';
+import { useDrawioModal, useHandsontableModal } from '~/stores/modal';
+import { useSWRxCurrentPage, useSWRxTagsInfo } from '~/stores/page';
 import { useViewOptions } from '~/stores/renderer';
 import { useViewOptions } from '~/stores/renderer';
 import {
 import {
   useCurrentPageTocNode,
   useCurrentPageTocNode,
-  useEditorMode, useIsMobile,
+  useIsMobile,
 } from '~/stores/ui';
 } from '~/stores/ui';
+import { registerGrowiFacade } from '~/utils/growi-facade';
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 
 
 import RevisionRenderer from './Page/RevisionRenderer';
 import RevisionRenderer from './Page/RevisionRenderer';
-import { DrawioModal } from './PageEditor/DrawioModal';
 import mdu from './PageEditor/MarkdownDrawioUtil';
 import mdu from './PageEditor/MarkdownDrawioUtil';
+import mtu from './PageEditor/MarkdownTableUtil';
+
+import styles from './Page.module.scss';
 
 
 
 
-declare const globalEmitter: EventEmitter;
+declare global {
+  // eslint-disable-next-line vars-on-top, no-var
+  var globalEmitter: EventEmitter;
+}
 
 
 // const DrawioModal = dynamic(() => import('./PageEditor/DrawioModal'), { ssr: false });
 // const DrawioModal = dynamic(() => import('./PageEditor/DrawioModal'), { ssr: false });
 const GridEditModal = dynamic(() => import('./PageEditor/GridEditModal'), { ssr: false });
 const GridEditModal = dynamic(() => import('./PageEditor/GridEditModal'), { ssr: false });
 const LinkEditModal = dynamic(() => import('./PageEditor/LinkEditModal'), { ssr: false });
 const LinkEditModal = dynamic(() => import('./PageEditor/LinkEditModal'), { ssr: false });
 
 
-const logger = loggerFactory('growi:Page');
-
-type PageSubstanceProps = {
-  rendererOptions: any,
-  page: any,
-  pageTags?: string[],
-  editorMode: string,
-  isGuestUser: boolean,
-  isMobile?: boolean,
-  isSlackEnabled: boolean,
-  slackChannels: string,
-};
-
-class PageSubstance extends React.Component<PageSubstanceProps> {
-
-  gridEditModal: any;
-
-  linkEditModal: any;
-
-  drawioModal: any;
-
-  constructor(props: PageSubstanceProps) {
-    super(props);
 
 
-    this.state = {
-      currentTargetTableArea: null,
-      currentTargetDrawioArea: null,
-    };
-
-    this.gridEditModal = React.createRef();
-    this.linkEditModal = React.createRef();
-    this.drawioModal = React.createRef();
-
-    this.saveHandlerForDrawioModal = this.saveHandlerForDrawioModal.bind(this);
-  }
-
-  /**
-   * launch DrawioModal with data specified by arguments
-   * @param beginLineNumber
-   * @param endLineNumber
-   */
-  launchDrawioModal(beginLineNumber, endLineNumber) {
-    // const markdown = this.props.pageContainer.state.markdown;
-    // const drawioMarkdownArray = markdown.split(/\r\n|\r|\n/).slice(beginLineNumber - 1, endLineNumber);
-    // const drawioData = drawioMarkdownArray.slice(1, drawioMarkdownArray.length - 1).join('\n').trim();
-    // this.setState({ currentTargetDrawioArea: { beginLineNumber, endLineNumber } });
-    // this.drawioModal.current.show(drawioData);
-  }
+const logger = loggerFactory('growi:Page');
 
 
-  async saveHandlerForDrawioModal(drawioData) {
-  //   const {
-  //     isSlackEnabled, slackChannels, pageContainer, pageTags, grant, grantGroupId, grantGroupName, mutateIsEnabledUnsavedWarning,
-  //   } = this.props;
-  //   const optionsToSave = getOptionsToSave(isSlackEnabled, slackChannels, grant, grantGroupId, grantGroupName, pageTags);
-
-    //   const newMarkdown = mdu.replaceDrawioInMarkdown(
-    //     drawioData,
-    //     this.props.pageContainer.state.markdown,
-    //     this.state.currentTargetDrawioArea.beginLineNumber,
-    //     this.state.currentTargetDrawioArea.endLineNumber,
-    //   );
-
-    //   try {
-    //     // disable unsaved warning
-    //     mutateIsEnabledUnsavedWarning(false);
-
-    //     // eslint-disable-next-line no-unused-vars
-    //     const { page, tags } = await pageContainer.save(newMarkdown, this.props.editorMode, optionsToSave);
-    //     logger.debug('success to save');
-
-    // // Todo: add translation
-    //   toastSuccess(t(''));
-    //   }
-  //   catch (error) {
-  //     logger.error('failed to save', error);
-  //     toastError(error);
-  //   }
-  //   finally {
-  //     this.setState({ currentTargetDrawioArea: null });
-  //   }
-  }
-
-  override render() {
-    const {
-      rendererOptions, page, isMobile, isGuestUser,
-    } = this.props;
-    const { path } = page;
-    const { _id: revisionId, body: markdown } = page.revision;
-
-    return (
-      <div className={`mb-5 ${isMobile ? 'page-mobile' : ''}`}>
-
-        { revisionId != null && (
-          <RevisionRenderer rendererOptions={rendererOptions} markdown={markdown} />
-        )}
-
-        { !isGuestUser && (
-          <>
-            <GridEditModal ref={this.gridEditModal} />
-            <LinkEditModal ref={this.linkEditModal} />
-            {/* TODO: use global DrawioModal https://redmine.weseek.co.jp/issues/105981 */}
-            {/* <DrawioModal
-              ref={this.drawioModal}
-              onSave={this.saveHandlerForDrawioModal}
-            /> */}
-          </>
-        )}
-      </div>
-    );
-  }
-
-}
 
 
 export const Page = (props) => {
 export const Page = (props) => {
+  const { t } = useTranslation();
+
   // Pass tocRef to generateViewOptions (=> rehypePlugin => customizeTOC) to call mutateCurrentPageTocNode when tocRef.current changes.
   // Pass tocRef to generateViewOptions (=> rehypePlugin => customizeTOC) to call mutateCurrentPageTocNode when tocRef.current changes.
   // The toc node passed by customizeTOC is assigned to tocRef.current.
   // The toc node passed by customizeTOC is assigned to tocRef.current.
   const tocRef = useRef<HtmlElementNode>();
   const tocRef = useRef<HtmlElementNode>();
@@ -160,41 +60,154 @@ export const Page = (props) => {
   }, []);
   }, []);
 
 
   const { data: shareLinkId } = useShareLinkId();
   const { data: shareLinkId } = useShareLinkId();
-  const { data: currentPage } = useSWRxCurrentPage(shareLinkId ?? undefined);
-  const { data: editorMode } = useEditorMode();
+  const { data: currentPage, mutate: mutateCurrentPage } = useSWRxCurrentPage(shareLinkId ?? undefined);
+  const { mutate: mutateEditingMarkdown } = useEditingMarkdown();
+  const { data: tagsInfo } = useSWRxTagsInfo(currentPage?._id);
   const { data: isGuestUser } = useIsGuestUser();
   const { data: isGuestUser } = useIsGuestUser();
   const { data: isMobile } = useIsMobile();
   const { data: isMobile } = useIsMobile();
-  const { data: slackChannelsData } = useSWRxSlackChannels(currentPage?.path);
-  const { data: isSlackEnabled } = useIsSlackEnabled();
-  const { data: pageTags } = usePageTagsForEditors(null); // TODO: pass pageId
-  const { data: rendererOptions } = useViewOptions(storeTocNodeHandler);
-  const { mutate: mutateIsEnabledUnsavedWarning } = useIsEnabledUnsavedWarning();
+  const { data: rendererOptions, mutate: mutateRendererOptions } = useViewOptions(storeTocNodeHandler);
   const { mutate: mutateCurrentPageTocNode } = useCurrentPageTocNode();
   const { mutate: mutateCurrentPageTocNode } = useCurrentPageTocNode();
+  const { open: openDrawioModal } = useDrawioModal();
+  const { open: openHandsontableModal } = useHandsontableModal();
+
+  const saveOrUpdate = useSaveOrUpdate();
 
 
-  const pageRef = useRef(null);
+
+  // register to facade
+  useEffect(() => {
+    registerGrowiFacade({
+      markdownRenderer: {
+        optionsMutators: {
+          viewOptionsMutator: mutateRendererOptions,
+        },
+      },
+    });
+  }, [mutateRendererOptions]);
 
 
   useEffect(() => {
   useEffect(() => {
     mutateCurrentPageTocNode(tocRef.current);
     mutateCurrentPageTocNode(tocRef.current);
   // eslint-disable-next-line react-hooks/exhaustive-deps
   // eslint-disable-next-line react-hooks/exhaustive-deps
   }, [mutateCurrentPageTocNode, tocRef.current]); // include tocRef.current to call mutateCurrentPageTocNode when tocRef.current changes
   }, [mutateCurrentPageTocNode, tocRef.current]); // include tocRef.current to call mutateCurrentPageTocNode when tocRef.current changes
 
 
-  // // set handler to open DrawioModal
-  // useEffect(() => {
-  //   const handler = (beginLineNumber, endLineNumber) => {
-  //     if (pageRef?.current != null) {
-  //       pageRef.current.launchDrawioModal(beginLineNumber, endLineNumber);
-  //     }
-  //   };
-  //   window.globalEmitter.on('launchDrawioModal', handler);
-
-  //   return function cleanup() {
-  //     window.globalEmitter.removeListener('launchDrawioModal', handler);
-  //   };
-  // }, []);
-
-  if (currentPage == null || editorMode == null || isGuestUser == null || rendererOptions == null) {
+
+  // TODO: refactor commonize saveByDrawioModal and saveByHandsontableModal
+  const saveByDrawioModal = useCallback(async(drawioMxFile: string, bol: number, eol: number) => {
+    if (currentPage == null || tagsInfo == null) {
+      return;
+    }
+
+    // disable if share link
+    if (shareLinkId != null) {
+      return;
+    }
+
+    const currentMarkdown = currentPage.revision.body;
+    const optionsToSave: OptionsToSave = {
+      isSlackEnabled: false,
+      slackChannels: '',
+      grant: currentPage.grant,
+      grantUserGroupId: currentPage.grantedGroup?._id,
+      grantUserGroupName: currentPage.grantedGroup?.name,
+      pageTags: tagsInfo.tags,
+    };
+
+    const newMarkdown = mdu.replaceDrawioInMarkdown(drawioMxFile, currentMarkdown, bol, eol);
+
+    try {
+      const currentRevisionId = currentPage.revision._id;
+      await saveOrUpdate(
+        newMarkdown,
+        { pageId: currentPage._id, path: currentPage.path, revisionId: currentRevisionId },
+        optionsToSave,
+      );
+
+      toastSuccess(t('toaster.save_succeeded'));
+
+      // rerender
+      mutateCurrentPage();
+      mutateEditingMarkdown(newMarkdown);
+    }
+    catch (error) {
+      logger.error('failed to save', error);
+      toastError(error);
+    }
+  }, [currentPage, mutateCurrentPage, mutateEditingMarkdown, saveOrUpdate, shareLinkId, t, tagsInfo]);
+
+  // set handler to open DrawioModal
+  useEffect(() => {
+    // disable if share link
+    if (shareLinkId != null) {
+      return;
+    }
+
+    const handler = (data: DrawioEditByViewerProps) => {
+      openDrawioModal(data.drawioMxFile, drawioMxFile => saveByDrawioModal(drawioMxFile, data.bol, data.eol));
+    };
+    globalEmitter.on('launchDrawioModal', handler);
+
+    return function cleanup() {
+      globalEmitter.removeListener('launchDrawioModal', handler);
+    };
+  }, [openDrawioModal, saveByDrawioModal, shareLinkId]);
+
+  const saveByHandsontableModal = useCallback(async(table: MarkdownTable, bol: number, eol: number) => {
+    if (currentPage == null || tagsInfo == null || shareLinkId != null) {
+      return;
+    }
+
+    const currentMarkdown = currentPage.revision.body;
+    const optionsToSave: OptionsToSave = {
+      isSlackEnabled: false,
+      slackChannels: '',
+      grant: currentPage.grant,
+      grantUserGroupId: currentPage.grantedGroup?._id,
+      grantUserGroupName: currentPage.grantedGroup?.name,
+      pageTags: tagsInfo.tags,
+    };
+
+    const newMarkdown = mtu.replaceMarkdownTableInMarkdown(table, currentMarkdown, bol, eol);
+
+    try {
+      const currentRevisionId = currentPage.revision._id;
+      await saveOrUpdate(
+        newMarkdown,
+        { pageId: currentPage._id, path: currentPage.path, revisionId: currentRevisionId },
+        optionsToSave,
+      );
+
+      toastSuccess(t('toaster.save_succeeded'));
+
+      // rerender
+      mutateCurrentPage();
+      mutateEditingMarkdown(newMarkdown);
+    }
+    catch (error) {
+      logger.error('failed to save', error);
+      toastError(error);
+    }
+  }, [currentPage, mutateCurrentPage, mutateEditingMarkdown, saveOrUpdate, shareLinkId, t, tagsInfo]);
+
+  // set handler to open HandsonTableModal
+  useEffect(() => {
+    if (currentPage == null || shareLinkId != null) {
+      return;
+    }
+
+    const handler = (bol: number, eol: number) => {
+      const markdown = currentPage.revision.body;
+      const currentMarkdownTable = mtu.getMarkdownTableFromLine(markdown, bol, eol);
+      openHandsontableModal(currentMarkdownTable, undefined, false, table => saveByHandsontableModal(table, bol, eol));
+    };
+    globalEmitter.on('launchHandsonTableModal', handler);
+
+    return function cleanup() {
+      globalEmitter.removeListener('launchHandsonTableModal', handler);
+    };
+  }, [currentPage, openHandsontableModal, saveByHandsontableModal, shareLinkId]);
+
+  if (currentPage == null || isGuestUser == null || rendererOptions == null) {
     const entries = Object.entries({
     const entries = Object.entries({
-      currentPage, editorMode, isGuestUser, rendererOptions,
+      currentPage, isGuestUser, rendererOptions,
     })
     })
       .map(([key, value]) => [key, value == null ? 'null' : undefined])
       .map(([key, value]) => [key, value == null ? 'null' : undefined])
       .filter(([, value]) => value != null);
       .filter(([, value]) => value != null);
@@ -203,19 +216,22 @@ export const Page = (props) => {
     return null;
     return null;
   }
   }
 
 
+  const { _id: revisionId, body: markdown } = currentPage.revision;
+
   return (
   return (
-    <PageSubstance
-      {...props}
-      ref={pageRef}
-      rendererOptions={rendererOptions}
-      page={currentPage}
-      editorMode={editorMode}
-      isGuestUser={isGuestUser}
-      isMobile={isMobile}
-      isSlackEnabled={isSlackEnabled}
-      pageTags={pageTags}
-      slackChannels={slackChannelsData?.toString()}
-      mutateIsEnabledUnsavedWarning={mutateIsEnabledUnsavedWarning}
-    />
+    <div className={`mb-5 ${isMobile ? `page-mobile ${styles['page-mobile']}` : ''}`}>
+
+      { revisionId != null && (
+        <RevisionRenderer rendererOptions={rendererOptions} markdown={markdown} />
+      )}
+
+      { !isGuestUser && (
+        <>
+          <GridEditModal />
+          <LinkEditModal />
+        </>
+      )}
+    </div>
   );
   );
+
 };
 };

+ 1 - 1
packages/app/src/components/Page/CopyDropdown.jsx

@@ -118,7 +118,7 @@ const CopyDropdown = (props) => {
           <span id={dropdownToggleId}>{children}</span>
           <span id={dropdownToggleId}>{children}</span>
         </DropdownToggle>
         </DropdownToggle>
 
 
-        <DropdownMenu positionFixed modifiers={{ preventOverflow: { boundariesElement: undefined } }}>
+        <DropdownMenu positionFixed modifiers={{ preventOverflow: { boundariesElement: 'viewport' } }}>
 
 
           <div className="d-flex align-items-center justify-content-between">
           <div className="d-flex align-items-center justify-content-between">
             <DropdownItem header className="px-3">
             <DropdownItem header className="px-3">

+ 58 - 1
packages/app/src/components/Page/DisplaySwitcher.tsx

@@ -1,4 +1,4 @@
-import React, { useMemo } from 'react';
+import React, { useCallback, useEffect, useMemo } from 'react';
 
 
 import { pagePathUtils } from '@growi/core';
 import { pagePathUtils } from '@growi/core';
 import { useTranslation } from 'next-i18next';
 import { useTranslation } from 'next-i18next';
@@ -6,12 +6,18 @@ import dynamic from 'next/dynamic';
 import { Link } from 'react-scroll';
 import { Link } from 'react-scroll';
 
 
 import { DEFAULT_AUTO_SCROLL_OPTS } from '~/client/util/smooth-scroll';
 import { DEFAULT_AUTO_SCROLL_OPTS } from '~/client/util/smooth-scroll';
+import { SocketEventName } from '~/interfaces/websocket';
 import {
 import {
   useIsSharedUser, useIsEditable, useShareLinkId, useIsNotFound,
   useIsSharedUser, useIsEditable, useShareLinkId, useIsNotFound,
 } from '~/stores/context';
 } from '~/stores/context';
+import { useIsHackmdDraftUpdatingInRealtime } from '~/stores/hackmd';
 import { useDescendantsPageListModal } from '~/stores/modal';
 import { useDescendantsPageListModal } from '~/stores/modal';
 import { useCurrentPagePath, useSWRxCurrentPage } from '~/stores/page';
 import { useCurrentPagePath, useSWRxCurrentPage } from '~/stores/page';
+import {
+  useSetRemoteLatestPageData,
+} from '~/stores/remote-latest-page';
 import { EditorMode, useEditorMode } from '~/stores/ui';
 import { EditorMode, useEditorMode } from '~/stores/ui';
+import { useGlobalSocket } from '~/stores/websocket';
 
 
 import CountBadge from '../Common/CountBadge';
 import CountBadge from '../Common/CountBadge';
 import { ContentLinkButtonsProps } from '../ContentLinkButtons';
 import { ContentLinkButtonsProps } from '../ContentLinkButtons';
@@ -44,10 +50,61 @@ const PageView = React.memo((): JSX.Element => {
   const { data: isNotFound } = useIsNotFound();
   const { data: isNotFound } = useIsNotFound();
   const { data: currentPage } = useSWRxCurrentPage(shareLinkId ?? undefined);
   const { data: currentPage } = useSWRxCurrentPage(shareLinkId ?? undefined);
   const { open: openDescendantPageListModal } = useDescendantsPageListModal();
   const { open: openDescendantPageListModal } = useDescendantsPageListModal();
+  const { setRemoteLatestPageData } = useSetRemoteLatestPageData();
+
+  const { mutate: mutateIsHackmdDraftUpdatingInRealtime } = useIsHackmdDraftUpdatingInRealtime();
 
 
   const isTopPagePath = isTopPage(currentPagePath ?? '');
   const isTopPagePath = isTopPage(currentPagePath ?? '');
   const isUsersHomePagePath = isUsersHomePage(currentPagePath ?? '');
   const isUsersHomePagePath = isUsersHomePage(currentPagePath ?? '');
 
 
+  const { data: socket } = useGlobalSocket();
+
+  const setLatestRemotePageData = useCallback((data) => {
+    const { s2cMessagePageUpdated } = data;
+
+    const remoteData = {
+      remoteRevisionId: s2cMessagePageUpdated.revisionId,
+      remoteRevisionBody: s2cMessagePageUpdated.revisionBody,
+      remoteRevisionLastUpdateUser: s2cMessagePageUpdated.remoteLastUpdateUser,
+      remoteRevisionLastUpdatedAt: s2cMessagePageUpdated.revisionUpdateAt,
+      revisionIdHackmdSynced: s2cMessagePageUpdated.revisionIdHackmdSynced,
+      hasDraftOnHackmd: s2cMessagePageUpdated.hasDraftOnHackmd,
+    };
+    setRemoteLatestPageData(remoteData);
+  }, [setRemoteLatestPageData]);
+
+  const setIsHackmdDraftUpdatingInRealtime = useCallback((data) => {
+    const { s2cMessagePageUpdated } = data;
+    if (s2cMessagePageUpdated.pageId === currentPage?._id) {
+      mutateIsHackmdDraftUpdatingInRealtime(true);
+    }
+  }, [currentPage?._id, mutateIsHackmdDraftUpdatingInRealtime]);
+
+  // listen socket for someone updating this page
+  useEffect(() => {
+
+    if (socket == null) { return }
+
+    socket.on(SocketEventName.PageUpdated, setLatestRemotePageData);
+
+    return () => {
+      socket.off(SocketEventName.PageUpdated, setLatestRemotePageData);
+    };
+
+  }, [setLatestRemotePageData, socket]);
+
+  // listen socket for hackmd saved
+  useEffect(() => {
+
+    if (socket == null) { return }
+
+    socket.on(SocketEventName.EditingWithHackmd, setIsHackmdDraftUpdatingInRealtime);
+
+    return () => {
+      socket.off(SocketEventName.EditingWithHackmd, setIsHackmdDraftUpdatingInRealtime);
+    };
+  }, [setIsHackmdDraftUpdatingInRealtime, socket]);
+
   return (
   return (
     <div className="d-flex flex-column flex-lg-row">
     <div className="d-flex flex-column flex-lg-row">
 
 

+ 21 - 24
packages/app/src/components/Page/RenderTagLabels.tsx

@@ -1,7 +1,8 @@
 import React from 'react';
 import React from 'react';
 
 
 import { useTranslation } from 'next-i18next';
 import { useTranslation } from 'next-i18next';
-import { UncontrolledTooltip } from 'reactstrap';
+
+import { NotAvailableForGuest } from '../NotAvailableForGuest';
 
 
 type RenderTagLabelsProps = {
 type RenderTagLabelsProps = {
   tags: string[],
   tags: string[],
@@ -21,33 +22,29 @@ const RenderTagLabels = React.memo((props: RenderTagLabelsProps) => {
   }
   }
 
 
   const isTagsEmpty = tags.length === 0;
   const isTagsEmpty = tags.length === 0;
-  const tagElements = tags.map((tag) => {
-    return (
-      <a key={tag} href={`/_search?q=tag:${tag}`} className="grw-tag-label badge badge-secondary mr-2">
-        {tag}
-      </a>
-    );
-  });
 
 
   return (
   return (
     <>
     <>
-      {tagElements}
-
-      <div id="edit-tags-btn-wrapper-for-tooltip">
-        <a
-          className={`btn btn-link btn-edit-tags p-0 text-muted d-flex ${isTagsEmpty ? 'no-tags' : ''} ${isGuestUser ? 'disabled' : ''}`}
-          onClick={openEditorHandler}
-        >
-          { isTagsEmpty && <>{ t('Add tags for this page') }</>}
-          <i className="ml-1 icon-plus"></i>
-        </a>
-      </div>
-      {isGuestUser && (
-        <UncontrolledTooltip placement="top" target="edit-tags-btn-wrapper-for-tooltip" fade={false}>
-          {t('Not available for guest')}
-        </UncontrolledTooltip>
-      )}
+      {tags.map((tag) => {
+        return (
+          <a key={tag} href={`/_search?q=tag:${tag}`} className="grw-tag-label badge badge-secondary mr-2">
+            {tag}
+          </a>
+        );
+      })}
+      <NotAvailableForGuest>
+        <div id="edit-tags-btn-wrapper-for-tooltip">
+          <a
+            className={`btn btn-link btn-edit-tags text-muted p-0 d-flex align-items-center ${isTagsEmpty && 'no-tags'} ${isGuestUser && 'disabled'}`}
+            onClick={openEditorHandler}
+          >
+            { isTagsEmpty && <>{ t('Add tags for this page') }</>}
+            <i className={`icon-plus ${isTagsEmpty && 'ml-1'}`}/>
+          </a>
+        </div>
+      </NotAvailableForGuest>
     </>
     </>
+
   );
   );
 
 
 });
 });

+ 1 - 1
packages/app/src/components/Page/TagLabels.tsx

@@ -37,7 +37,7 @@ export const TagLabels:FC<Props> = (props: Props) => {
   return (
   return (
     <>
     <>
       <div className={`${styles['grw-tag-labels']} grw-tag-labels d-flex align-items-center`} data-testid="grw-tag-labels">
       <div className={`${styles['grw-tag-labels']} grw-tag-labels d-flex align-items-center`} data-testid="grw-tag-labels">
-        <i className="tag-icon icon-tag mr-2"></i>
+        <i className="tag-icon icon-tag mr-2"/>
         <RenderTagLabels
         <RenderTagLabels
           tags={tags}
           tags={tags}
           openEditorModal={openEditorModal}
           openEditorModal={openEditorModal}

+ 1 - 1
packages/app/src/components/PageAlert/FixPageGrantAlert.tsx

@@ -224,7 +224,7 @@ const FixPageGrantModal = (props: ModalProps): JSX.Element => {
   };
   };
 
 
   return (
   return (
-    <Modal size="lg" isOpen={isOpen} toggle={close} className="grw-create-page">
+    <Modal size="lg" isOpen={isOpen} toggle={close}>
       <ModalHeader tag="h4" toggle={close} className="bg-primary text-light">
       <ModalHeader tag="h4" toggle={close} className="bg-primary text-light">
         { t('fix_page_grant.modal.title') }
         { t('fix_page_grant.modal.title') }
       </ModalHeader>
       </ModalHeader>

+ 15 - 12
packages/app/src/components/PageAlert/TrashPageAlert.tsx

@@ -1,4 +1,4 @@
-import React from 'react';
+import React, { useCallback } from 'react';
 
 
 import { UserPicture } from '@growi/ui';
 import { UserPicture } from '@growi/ui';
 import { format } from 'date-fns';
 import { format } from 'date-fns';
@@ -32,27 +32,25 @@ export const TrashPageAlert = (): JSX.Element => {
   const { open: openDeleteModal } = usePageDeleteModal();
   const { open: openDeleteModal } = usePageDeleteModal();
   const { open: openPutBackPageModal } = usePutBackPageModal();
   const { open: openPutBackPageModal } = usePutBackPageModal();
 
 
-  if (!isTrashPage) {
-    return <></>;
-  }
-
 
 
   const deleteUser = pageData?.deleteUser;
   const deleteUser = pageData?.deleteUser;
   const deletedAt = pageData?.deletedAt ? format(new Date(pageData?.deletedAt), 'yyyy/MM/dd HH:mm') : '';
   const deletedAt = pageData?.deletedAt ? format(new Date(pageData?.deletedAt), 'yyyy/MM/dd HH:mm') : '';
   const revisionId = pageData?.revision?._id;
   const revisionId = pageData?.revision?._id;
 
 
 
 
-  function openPutbackPageModalHandler() {
+  const openPutbackPageModalHandler = useCallback(() => {
     if (pageId === undefined || pagePath === undefined) {
     if (pageId === undefined || pagePath === undefined) {
       return;
       return;
     }
     }
     const putBackedHandler = () => {
     const putBackedHandler = () => {
-      router.push(`/${pageId}`);
+      // Do not use "router.push(`/${pageId}`)" to avoid `Error: Invariant: attempted to hard navigate to the same URL`
+      // See: https://github.com/weseek/growi/pull/7054
+      router.reload();
     };
     };
     openPutBackPageModal({ pageId, path: pagePath }, { onPutBacked: putBackedHandler });
     openPutBackPageModal({ pageId, path: pagePath }, { onPutBacked: putBackedHandler });
-  }
+  }, [openPutBackPageModal, pageId, pagePath, router]);
 
 
-  function openPageDeleteModalHandler() {
+  const openPageDeleteModalHandler = useCallback(() => {
     if (pageId === undefined || revisionId === undefined || pagePath === undefined) {
     if (pageId === undefined || revisionId === undefined || pagePath === undefined) {
       return;
       return;
     }
     }
@@ -65,9 +63,9 @@ export const TrashPageAlert = (): JSX.Element => {
       meta: pageInfo,
       meta: pageInfo,
     };
     };
     openDeleteModal([pageToDelete], { onDeleted: onDeletedHandler });
     openDeleteModal([pageToDelete], { onDeleted: onDeletedHandler });
-  }
+  }, [openDeleteModal, pageId, pageInfo, pagePath, revisionId]);
 
 
-  function renderTrashPageManagementButtons() {
+  const renderTrashPageManagementButtons = useCallback(() => {
     return (
     return (
       <>
       <>
         <button
         <button
@@ -75,6 +73,7 @@ export const TrashPageAlert = (): JSX.Element => {
           className="btn btn-info rounded-pill btn-sm ml-auto mr-2"
           className="btn btn-info rounded-pill btn-sm ml-auto mr-2"
           onClick={openPutbackPageModalHandler}
           onClick={openPutbackPageModalHandler}
           data-toggle="modal"
           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>
@@ -88,11 +87,15 @@ export const TrashPageAlert = (): JSX.Element => {
         </button>
         </button>
       </>
       </>
     );
     );
+  }, [openPageDeleteModalHandler, openPutbackPageModalHandler, pageInfo?.isAbleToDeleteCompletely, t]);
+
+  if (!isTrashPage) {
+    return <></>;
   }
   }
 
 
   return (
   return (
     <>
     <>
-      <div className="alert alert-warning py-3 pl-4 d-flex flex-column flex-lg-row">
+      <div className="alert alert-warning py-3 pl-4 d-flex flex-column flex-lg-row" data-testid="trash-page-alert">
         <div className="flex-grow-1">
         <div className="flex-grow-1">
           This page is in the trash <i className="icon-trash" aria-hidden="true"></i>.
           This page is in the trash <i className="icon-trash" aria-hidden="true"></i>.
           <br />
           <br />

+ 1 - 1
packages/app/src/components/PageComment/Comment.tsx

@@ -124,7 +124,7 @@ export const Comment = (props: CommentProps): JSX.Element => {
   }, [comment, isMarkdown, markdown, rendererOptions]);
   }, [comment, isMarkdown, markdown, rendererOptions]);
 
 
   const rootClassName = getRootClassName(comment);
   const rootClassName = getRootClassName(comment);
-  const revHref = `?revision=${comment.revision}`;
+  const revHref = `?revisionId=${comment.revision}`;
   const editedDateId = `editedDate-${comment._id}`;
   const editedDateId = `editedDate-${comment._id}`;
   const editedDateFormatted = isEdited ? format(updatedAt, 'yyyy/MM/dd HH:mm') : null;
   const editedDateFormatted = isEdited ? format(updatedAt, 'yyyy/MM/dd HH:mm') : null;
 
 

Неке датотеке нису приказане због велике количине промена