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

Merge branch 'master' into fix/show-page-history-comparation-modal

Yuki Takei пре 3 година
родитељ
комит
c7ee5ebf67
100 измењених фајлова са 1346 додато и 1500 уклоњено
  1. 8 2
      .github/workflows/reusable-app-prod.yml
  2. 80 1
      CHANGELOG.md
  3. 1 12
      THIRD-PARTY-NOTICES.md
  4. 1 1
      lerna.json
  5. 1 1
      package.json
  6. 0 1
      packages/app/.eslintrc.js
  7. 1 0
      packages/app/config/ci/.env.local.for-auto-install-with-allowing-guest
  8. 4 3
      packages/app/config/webpack.common.js
  9. 4 4
      packages/app/docker/README.md
  10. 13 11
      packages/app/package.json
  11. 0 0
      packages/app/public/static/locales/en_US/admin/admin.json
  12. 0 0
      packages/app/public/static/locales/en_US/meta.json
  13. 15 2
      packages/app/public/static/locales/en_US/translation.json
  14. 0 0
      packages/app/public/static/locales/index.js
  15. 0 0
      packages/app/public/static/locales/ja_JP/admin/admin.json
  16. 0 0
      packages/app/public/static/locales/ja_JP/meta.json
  17. 15 2
      packages/app/public/static/locales/ja_JP/translation.json
  18. 0 0
      packages/app/public/static/locales/zh_CN/admin/admin.json
  19. 0 0
      packages/app/public/static/locales/zh_CN/meta.json
  20. 16 3
      packages/app/public/static/locales/zh_CN/translation.json
  21. 1 0
      packages/app/resource/Contributor.js
  22. 36 40
      packages/app/src/client/admin.jsx
  23. 0 4
      packages/app/src/client/app.jsx
  24. 6 0
      packages/app/src/client/base.jsx
  25. 60 0
      packages/app/src/client/installer.jsx
  26. 3 122
      packages/app/src/client/legacy/crowi.js
  27. 67 65
      packages/app/src/client/nologin.jsx
  28. 4 63
      packages/app/src/client/services/AppContainer.js
  29. 2 7
      packages/app/src/client/services/CommentContainer.js
  30. 11 5
      packages/app/src/client/services/ContextExtractor.tsx
  31. 0 14
      packages/app/src/client/services/EditorContainer.js
  32. 15 46
      packages/app/src/client/services/PageContainer.js
  33. 0 77
      packages/app/src/client/services/PersonalContainer.js
  34. 9 1
      packages/app/src/client/services/page-operation.ts
  35. 15 17
      packages/app/src/client/util/GrowiRenderer.js
  36. 7 0
      packages/app/src/client/util/apiv1-client.ts
  37. 12 3
      packages/app/src/client/util/apiv3-client.ts
  38. 4 4
      packages/app/src/client/util/editor.ts
  39. 3 2
      packages/app/src/client/util/i18n.js
  40. 2 8
      packages/app/src/client/util/interceptor/detach-code-blocks.js
  41. 6 10
      packages/app/src/client/util/interceptor/drawio-interceptor.js
  42. 0 4
      packages/app/src/client/util/markdown-it/footernote.js
  43. 1 2
      packages/app/src/client/util/markdown-it/header-line-number.js
  44. 1 5
      packages/app/src/client/util/markdown-it/header-with-edit-link.js
  45. 1 3
      packages/app/src/client/util/markdown-it/header.js
  46. 1 5
      packages/app/src/client/util/markdown-it/table-with-handsontable-button.js
  47. 0 4
      packages/app/src/client/util/markdown-it/table.js
  48. 6 12
      packages/app/src/client/util/markdown-it/toc-and-anchor.js
  49. 2 2
      packages/app/src/client/util/reveal/plugins/growi-renderer.js
  50. 8 8
      packages/app/src/components/Admin/App/AwsSetting.jsx
  51. 8 3
      packages/app/src/components/Admin/ExportArchiveDataPage.jsx
  52. 0 33
      packages/app/src/components/Admin/FullTextSearchManagement.jsx
  53. 22 0
      packages/app/src/components/Admin/FullTextSearchManagement.tsx
  54. 3 13
      packages/app/src/components/Admin/ImportData/GrowiArchive/UploadForm.jsx
  55. 13 7
      packages/app/src/components/Admin/ManageExternalAccount.jsx
  56. 11 5
      packages/app/src/components/Admin/UserGroupDetail/UserGroupUserFormByInput.jsx
  57. 0 79
      packages/app/src/components/Admin/Users/RemoveAdminButton.jsx
  58. 62 0
      packages/app/src/components/Admin/Users/RemoveAdminMenuItem.tsx
  59. 60 0
      packages/app/src/components/Admin/Users/StatusSuspendMenuItem.tsx
  60. 0 78
      packages/app/src/components/Admin/Users/StatusSuspendedButton.jsx
  61. 12 9
      packages/app/src/components/Admin/Users/UserMenu.jsx
  62. 9 6
      packages/app/src/components/ArchiveCreateModal.jsx
  63. 9 6
      packages/app/src/components/Common/CountBadge.tsx
  64. 43 5
      packages/app/src/components/Common/Dropdown/PageItemControl.tsx
  65. 8 10
      packages/app/src/components/CreateTemplateModal.jsx
  66. 11 3
      packages/app/src/components/DescendantsPageList.tsx
  67. 0 108
      packages/app/src/components/Drawio.jsx
  68. 94 0
      packages/app/src/components/Drawio.tsx
  69. 4 12
      packages/app/src/components/Fab.jsx
  70. 1 1
      packages/app/src/components/FormattedDistanceDate.jsx
  71. 9 6
      packages/app/src/components/InAppNotification/InAppNotificationDropdown.tsx
  72. 5 4
      packages/app/src/components/InAppNotification/PageNotification/PageModelNotification.tsx
  73. 13 5
      packages/app/src/components/InstallerForm.jsx
  74. 24 13
      packages/app/src/components/LoginForm.jsx
  75. 12 4
      packages/app/src/components/Me/ApiSettings.jsx
  76. 11 4
      packages/app/src/components/Me/AssociateModal.jsx
  77. 12 4
      packages/app/src/components/Me/BasicInfoSettings.jsx
  78. 17 7
      packages/app/src/components/Me/DisassociateModal.jsx
  79. 12 4
      packages/app/src/components/Me/ExternalAccountLinkedMe.jsx
  80. 5 7
      packages/app/src/components/Me/ExternalAccountRow.jsx
  81. 14 13
      packages/app/src/components/Me/ImageCropModal.jsx
  82. 17 11
      packages/app/src/components/Me/PasswordSettings.jsx
  83. 4 8
      packages/app/src/components/Me/PersonalSettings.jsx
  84. 0 197
      packages/app/src/components/Me/ProfileImageSettings.jsx
  85. 177 0
      packages/app/src/components/Me/ProfileImageSettings.tsx
  86. 0 36
      packages/app/src/components/Me/UserSettings.jsx
  87. 29 0
      packages/app/src/components/Me/UserSettings.tsx
  88. 15 12
      packages/app/src/components/MyDraftList/Draft.jsx
  89. 12 8
      packages/app/src/components/MyDraftList/MyDraftList.jsx
  90. 51 56
      packages/app/src/components/Navbar/GrowiContextualSubNavigation.tsx
  91. 9 10
      packages/app/src/components/Navbar/GrowiNavbar.tsx
  92. 3 7
      packages/app/src/components/Navbar/GrowiSubNavigation.tsx
  93. 8 8
      packages/app/src/components/Navbar/SubNavButtons.tsx
  94. 7 9
      packages/app/src/components/NotAvailableForGuest.jsx
  95. 37 9
      packages/app/src/components/Page.jsx
  96. 7 10
      packages/app/src/components/Page/CopyDropdown.jsx
  97. 12 12
      packages/app/src/components/Page/DisplaySwitcher.tsx
  98. 7 5
      packages/app/src/components/Page/FixPageGrantAlert.tsx
  99. 0 72
      packages/app/src/components/Page/NotFoundAlert.tsx
  100. 5 5
      packages/app/src/components/Page/RevisionBody.jsx

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

@@ -180,7 +180,7 @@ jobs:
       fail-fast: false
       fail-fast: false
       matrix:
       matrix:
         # List string expressions that is comma separated ids of tests in "test/cypress/integration"
         # List string expressions that is comma separated ids of tests in "test/cypress/integration"
-        spec-group: ['1', '2', '3', '4', '6']
+        spec-group: ['10', '20', '21', '30', '40', '60']
 
 
     services:
     services:
       mongodb:
       mongodb:
@@ -239,11 +239,17 @@ jobs:
         cat config/ci/.env.local.for-ci >> .env.production.local
         cat config/ci/.env.local.for-ci >> .env.production.local
 
 
     - name: Copy dotenv file for automatic installation
     - name: Copy dotenv file for automatic installation
-      if: ${{ matrix.spec-group != '1' }}
+      if: ${{ matrix.spec-group != '10' }}
       working-directory: ./packages/app
       working-directory: ./packages/app
       run: |
       run: |
         cat config/ci/.env.local.for-auto-install >> .env.production.local
         cat config/ci/.env.local.for-auto-install >> .env.production.local
 
 
+    - name: Copy dotenv file for automatic installation with allowing guest mode
+      if: ${{ matrix.spec-group == '21' }}
+      working-directory: ./packages/app
+      run: |
+        cat config/ci/.env.local.for-auto-install-with-allowing-guest >> .env.production.local
+
     - name: Cypress Run
     - name: Cypress Run
       uses: cypress-io/github-action@v3
       uses: cypress-io/github-action@v3
       with:
       with:

+ 80 - 1
CHANGELOG.md

@@ -1,9 +1,75 @@
 # Changelog
 # Changelog
 
 
-## [Unreleased](https://github.com/weseek/growi/compare/v5.0.6...HEAD)
+## [Unreleased](https://github.com/weseek/growi/compare/v5.0.9...HEAD)
 
 
 *Please do not manually update this file. We've automated the process.*
 *Please do not manually update this file. We've automated the process.*
 
 
+## [v5.0.9](https://github.com/weseek/growi/compare/v5.0.8...v5.0.9) - 2022-06-13
+
+### 🚀 Improvement
+
+- imprv: Render MathJax in Preview tab of comment (#6025) @yuki-takei
+- imprv: Exception handling for user authentication (#6019) @kaoritokashiki
+- imprv: Sidebar background color on light theme and add shadow on dark theme (#6012) @shukmos
+- imprv: Limit display of notification paths (#5991) @jam411
+
+### 🐛 Bug Fixes
+
+- fix: Getting page API is broken (#6023) @yuki-takei
+- fix: MathJax does not working (#6020) @yuki-takei
+
+## [v5.0.8](https://github.com/weseek/growi/compare/v5.0.7...v5.0.8) - 2022-06-07
+
+### 🚀 Improvement
+
+- imprv: Fix subnavigation spacing (#5995) @yuki-takei
+- imprv: Set Content-Length header in response of attachment (#5972) @hiroki-hgs
+- imprv: Fix sidebar tag layout (#5984) @jam411
+- imprv: PageStatusAlert labels when data is outdated (#5961) @yuki-takei
+- imprv: Delete NotFoundAlert from not found page (#5919) @Shunm634-source
+
+### 🐛 Bug Fixes
+
+- fix: Too many footstamps icons are shown by lsx output 3 (#6000) @yuki-takei
+- fix: Adjust PageItemControl alignment (#5994) @yuki-takei
+- fix: CodeMirror placeholder color (#5993) @yuki-takei
+- fix: Chinese notation is broken on create new page modal (#5973) @jam411
+- fix: Document timestamps does not updated (#5979) @yuki-takei
+- fix: Slack channels are not automatically filled after setting up user trigger notification on v5.0.x (#5911) @kaoritokashiki
+- fix: Login required when viewing sharelink page (#5959) @yuki-takei
+- fix: Editor scroll sync by Preview scrolling does not work (#5949) @yuki-takei
+
+### 🧰 Maintenance
+
+- support: Enable garbage collection at runtime with expose-gc package (#5986) @yuki-takei
+- support: Upgrade aws-sdk to v3 (#5863) @mudana-grune
+
+## [v4.5.22](https://github.com/weseek/growi/compare/v4.5.21...v4.5.22) - 2022-06-07
+
+### 🐛 Bug Fixes
+
+- fix: Fixed the bug of auto-filling unintended values into the Email field of the User settings (#5885) @Shunm634-source
+- fix: google-oauth2 Automatically bind external accounts does not work (#5891) @kaoritokashiki
+- fix: Slack channels are not automatically filled after setting up user trigger notification (#5976) @kaoritokashiki
+
+### 🧰 Maintenance
+
+- support: Enable garbage collection at runtime with expose-gc package (#5998) @kaoritokashiki
+
+## [v5.0.7](https://github.com/weseek/growi/compare/v5.0.6...v5.0.7) - 2022-05-30
+
+### 💎 Features
+
+- feat: Set the min length of passwords by environment variable (#5899) @Shunm634-source
+- feat: API to find username (#5907) @miya
+
+### 🐛 Bug Fixes
+
+- fix: Page is not rendered for guest (#5930) @yuki-takei
+- fix: Server error due to the canDeleteLogic method (#5927) @hakumizuki
+- fix: Show pagename on toastr when page deleted (#5772) @hirokei-camel
+- fix: Search result screen is broken under content 100% setting (#5917) @jam411
+
 ## [v5.0.6](https://github.com/weseek/growi/compare/v5.0.5...v5.0.6) - 2022-05-27
 ## [v5.0.6](https://github.com/weseek/growi/compare/v5.0.5...v5.0.6) - 2022-05-27
 
 
 ### 💎 Features
 ### 💎 Features
@@ -29,6 +95,13 @@
 - fix: Can not toggle textlint function on v5.0.x (#5854) @kaoritokashiki
 - fix: Can not toggle textlint function on v5.0.x (#5854) @kaoritokashiki
 - fix(google-oauth2): Automatically bind external accounts  does not work on v5.0.x (#5886) @kaoritokashiki
 - fix(google-oauth2): Automatically bind external accounts  does not work on v5.0.x (#5886) @kaoritokashiki
 
 
+## [v4.5.21](https://github.com/weseek/growi/compare/v4.5.20...v4.5.21) - 2022-05-23
+
+### 🐛 Bug Fixes
+
+- fix: Can not toggle textlint function on v4.5.x (https://github.com/weseek/growi/pull/5855) @kaoritokashiki
+- fix: Error on searching (https://github.com/weseek/growi/pull/5873) @miya
+
 ## [v5.0.5](https://github.com/weseek/growi/compare/v5.0.4...v5.0.5) - 2022-05-16
 ## [v5.0.5](https://github.com/weseek/growi/compare/v5.0.4...v5.0.5) - 2022-05-16
 
 
 ### 💎 Features
 ### 💎 Features
@@ -53,6 +126,12 @@
 
 
 - support: Typescriptize tag model (#5778) @kaoritokashiki
 - support: Typescriptize tag model (#5778) @kaoritokashiki
 
 
+## [v4.5.20](https://github.com/weseek/growi/compare/v4.5.19...v4.5.20) - 2022-05-12
+
+### 🐛 Bug Fixes
+
+- fix: Guest user cannot access share link pages (#5819) @kaoritokashiki
+
 ## [v5.0.4](https://github.com/weseek/growi/compare/v5.0.3...v5.0.4) - 2022-04-28
 ## [v5.0.4](https://github.com/weseek/growi/compare/v5.0.3...v5.0.4) - 2022-04-28
 
 
 ### 💎 Features
 ### 💎 Features

+ 1 - 12
THIRD-PARTY-NOTICES.md

@@ -16,8 +16,7 @@ https://github.com/weseek/growi.
 2. crowi/crowi (https://github.com/crowi/crowi)
 2. crowi/crowi (https://github.com/crowi/crowi)
 3. Microsoft/vscode (https://github.com/Microsoft/vscode)
 3. Microsoft/vscode (https://github.com/Microsoft/vscode)
 4. stephenhutchings/typicons.font (https://github.com/stephenhutchings/typicons.font)
 4. stephenhutchings/typicons.font (https://github.com/stephenhutchings/typicons.font)
-5. EmojiOne Version 3 (https://github.com/joypixels/emojione/tree/v3.1.1)
-6. Kuromoji.js (https://github.com/takuyaa/kuromoji.js)
+5. Kuromoji.js (https://github.com/takuyaa/kuromoji.js)
 
 
 
 
 License Notice for Apache License, Version 2.0 Derivative Works
 License Notice for Apache License, Version 2.0 Derivative Works
@@ -101,16 +100,6 @@ Copyright (c) 2018 Stephen Hutchings
 ```
 ```
 
 
 
 
-License Notice for EmojiOne
-------------------------
-
-https://creativecommons.org/licenses/by/4.0/
-
-```
-author: "EmojiOne <ryan@emojione.com> (http://emojione.com)"
-```
-
-
 License Notice for Kuromoji.js
 License Notice for Kuromoji.js
 ------------------------
 ------------------------
 
 

+ 1 - 1
lerna.json

@@ -1,7 +1,7 @@
 {
 {
   "npmClient": "yarn",
   "npmClient": "yarn",
   "useWorkspaces": true,
   "useWorkspaces": true,
-  "version": "5.0.7-RC.0",
+  "version": "5.0.10-RC.0",
   "packages": [
   "packages": [
     "packages/*"
     "packages/*"
   ]
   ]

+ 1 - 1
package.json

@@ -1,6 +1,6 @@
 {
 {
   "name": "growi",
   "name": "growi",
-  "version": "5.0.7-RC.0",
+  "version": "5.0.10-RC.0",
   "description": "Team collaboration software using markdown",
   "description": "Team collaboration software using markdown",
   "tags": [
   "tags": [
     "wiki",
     "wiki",

+ 0 - 1
packages/app/.eslintrc.js

@@ -12,7 +12,6 @@ module.exports = {
   globals: {
   globals: {
     $: true,
     $: true,
     jquery: true,
     jquery: true,
-    emojione: true,
     hljs: true,
     hljs: true,
     ScrollPosStyler: true,
     ScrollPosStyler: true,
     window: true,
     window: true,

+ 1 - 0
packages/app/config/ci/.env.local.for-auto-install-with-allowing-guest

@@ -0,0 +1 @@
+AUTO_INSTALL_ALLOW_GUEST_MODE=true

+ 4 - 3
packages/app/config/webpack.common.js

@@ -2,14 +2,15 @@
  * @author: Yuki Takei <yuki@weseek.co.jp>
  * @author: Yuki Takei <yuki@weseek.co.jp>
  */
  */
 const path = require('path');
 const path = require('path');
+
+const LodashModuleReplacementPlugin = require('lodash-webpack-plugin');
+const TsconfigPathsPlugin = require('tsconfig-paths-webpack-plugin');
 const webpack = require('webpack');
 const webpack = require('webpack');
 
 
 /*
 /*
   * Webpack Plugins
   * Webpack Plugins
   */
   */
 const WebpackAssetsManifest = require('webpack-assets-manifest');
 const WebpackAssetsManifest = require('webpack-assets-manifest');
-const LodashModuleReplacementPlugin = require('lodash-webpack-plugin');
-const TsconfigPathsPlugin = require('tsconfig-paths-webpack-plugin');
 
 
 /*
 /*
   * Webpack configuration
   * Webpack configuration
@@ -24,6 +25,7 @@ module.exports = (options) => {
       'js/app':                       './src/client/app',
       'js/app':                       './src/client/app',
       'js/admin':                     './src/client/admin',
       'js/admin':                     './src/client/admin',
       'js/nologin':                   './src/client/nologin',
       'js/nologin':                   './src/client/nologin',
+      'js/installer':                   './src/client/installer',
       'js/legacy':                    './src/client/legacy/crowi',
       'js/legacy':                    './src/client/legacy/crowi',
       'js/legacy-presentation':       './src/client/legacy/crowi-presentation',
       'js/legacy-presentation':       './src/client/legacy/crowi-presentation',
       'js/plugin':                    './src/client/plugin',
       'js/plugin':                    './src/client/plugin',
@@ -60,7 +62,6 @@ module.exports = (options) => {
       // require("jquery") is external and available
       // require("jquery") is external and available
       //  on the global var jQuery
       //  on the global var jQuery
       jquery: 'jQuery',
       jquery: 'jQuery',
-      emojione: 'emojione',
       hljs: 'hljs',
       hljs: 'hljs',
       'dtrace-provider': 'dtrace-provider',
       'dtrace-provider': 'dtrace-provider',
     },
     },

+ 4 - 4
packages/app/docker/README.md

@@ -10,10 +10,10 @@ GROWI Official docker image
 Supported tags and respective Dockerfile links
 Supported tags and respective Dockerfile links
 ------------------------------------------------
 ------------------------------------------------
 
 
-* [`5.0.6`, `5.0`, `5`, `latest` (Dockerfile)](https://github.com/weseek/growi/blob/v5.0.6/docker/Dockerfile)
-* [`5.0.6-nocdn`, `5.0-nocdn`, `5-nocdn`, `latest-nocdn` (Dockerfile)](https://github.com/weseek/growi/blob/v5.0.6/docker/Dockerfile)
-* [`4.5.15`, `4.5`, `4`, `latest` (Dockerfile)](https://github.com/weseek/growi/blob/v4.5.15/docker/Dockerfile)
-* [`4.5.15-nocdn`, `4.5-nocdn`, `4-nocdn`, `latest-nocdn` (Dockerfile)](https://github.com/weseek/growi/blob/v4.5.15/docker/Dockerfile)
+* [`5.0.9`, `5.0`, `5`, `latest` (Dockerfile)](https://github.com/weseek/growi/blob/v5.0.9/docker/Dockerfile)
+* [`5.0.9-nocdn`, `5.0-nocdn`, `5-nocdn`, `latest-nocdn` (Dockerfile)](https://github.com/weseek/growi/blob/v5.0.9/docker/Dockerfile)
+* [`4.5.22`, `4.5`, `4`, `latest` (Dockerfile)](https://github.com/weseek/growi/blob/v4.5.22/docker/Dockerfile)
+* [`4.5.22-nocdn`, `4.5-nocdn`, `4-nocdn`, `latest-nocdn` (Dockerfile)](https://github.com/weseek/growi/blob/v4.5.22/docker/Dockerfile)
 * [`4.4.13`, `4.4` (Dockerfile)](https://github.com/weseek/growi/blob/v4.4.13/docker/Dockerfile)
 * [`4.4.13`, `4.4` (Dockerfile)](https://github.com/weseek/growi/blob/v4.4.13/docker/Dockerfile)
 * [`4.4.13-nocdn`, `4.4-nocdn` (Dockerfile)](https://github.com/weseek/growi/blob/v4.4.13/docker/Dockerfile)
 * [`4.4.13-nocdn`, `4.4-nocdn` (Dockerfile)](https://github.com/weseek/growi/blob/v4.4.13/docker/Dockerfile)
 
 

+ 13 - 11
packages/app/package.json

@@ -1,6 +1,6 @@
 {
 {
   "name": "@growi/app",
   "name": "@growi/app",
-  "version": "5.0.7-RC.0",
+  "version": "5.0.10-RC.0",
   "license": "MIT",
   "license": "MIT",
   "scripts": {
   "scripts": {
     "//// for production": "",
     "//// for production": "",
@@ -11,7 +11,7 @@
     "clean": "npx -y shx rm -rf dist transpiled",
     "clean": "npx -y shx rm -rf dist transpiled",
     "prebuild": "yarn cross-env NODE_ENV=production run-p clean resources:*",
     "prebuild": "yarn cross-env NODE_ENV=production run-p clean resources:*",
     "postbuild": "npx -y shx mv transpiled/src dist && npx -y shx cp -r src/server/views dist/server/ && npx -y shx rm -rf transpiled",
     "postbuild": "npx -y shx mv transpiled/src dist && npx -y shx cp -r src/server/views dist/server/ && npx -y shx rm -rf transpiled",
-    "server": "yarn cross-env NODE_ENV=production node -r dotenv-flow/config --expose_gc dist/server/app.js",
+    "server": "yarn cross-env NODE_ENV=production node -r dotenv-flow/config dist/server/app.js",
     "server:ci": "yarn server --ci",
     "server:ci": "yarn server --ci",
     "preserver": "yarn cross-env NODE_ENV=production yarn migrate",
     "preserver": "yarn cross-env NODE_ENV=production yarn migrate",
     "migrate": "node -r dotenv-flow/config node_modules/.bin/migrate-mongo up",
     "migrate": "node -r dotenv-flow/config node_modules/.bin/migrate-mongo up",
@@ -19,7 +19,7 @@
     "dev": "run-p dev:client dev:server",
     "dev": "run-p dev:client dev:server",
     "dev:client": "yarn cross-env NODE_ENV=development webpack --config config/webpack.dev.js --progress --watch",
     "dev:client": "yarn cross-env NODE_ENV=development webpack --config config/webpack.dev.js --progress --watch",
     "dev:client:nowatch": "yarn cross-env NODE_ENV=development webpack --config config/webpack.dev.js",
     "dev:client:nowatch": "yarn cross-env NODE_ENV=development webpack --config config/webpack.dev.js",
-    "dev:server": "yarn cross-env NODE_ENV=development ts-node-dev --inspect --expose-gc -r tsconfig-paths/register -r dotenv-flow/config --transpile-only src/server/app.ts",
+    "dev:server": "yarn cross-env NODE_ENV=development ts-node-dev --inspect -r tsconfig-paths/register -r dotenv-flow/config --transpile-only src/server/app.ts",
     "predev:client": "yarn cross-env NODE_ENV=development run-p resources:*",
     "predev:client": "yarn cross-env NODE_ENV=development run-p resources:*",
     "predev:server": "yarn cross-env NODE_ENV=development yarn dev:migrate:up",
     "predev:server": "yarn cross-env NODE_ENV=development yarn dev:migrate:up",
     "dev:migrate-mongo": "yarn cross-env NODE_ENV=development yarn ts-node node_modules/.bin/migrate-mongo",
     "dev:migrate-mongo": "yarn cross-env NODE_ENV=development yarn ts-node node_modules/.bin/migrate-mongo",
@@ -57,16 +57,18 @@
     "string-width": "5.0.0 or above exports only ESM."
     "string-width": "5.0.0 or above exports only ESM."
   },
   },
   "dependencies": {
   "dependencies": {
+    "@aws-sdk/client-s3": "^3.58.0",
+    "@aws-sdk/s3-request-presigner": "^3.58.0",
     "@browser-bunyan/console-formatted-stream": "^1.6.2",
     "@browser-bunyan/console-formatted-stream": "^1.6.2",
     "@elastic/elasticsearch6": "npm:@elastic/elasticsearch@^6.8.8",
     "@elastic/elasticsearch6": "npm:@elastic/elasticsearch@^6.8.8",
     "@elastic/elasticsearch7": "npm:@elastic/elasticsearch@^7.17.0",
     "@elastic/elasticsearch7": "npm:@elastic/elasticsearch@^7.17.0",
     "@godaddy/terminus": "^4.9.0",
     "@godaddy/terminus": "^4.9.0",
     "@google-cloud/storage": "^5.8.5",
     "@google-cloud/storage": "^5.8.5",
-    "@growi/codemirror-textlint": "^5.0.7-RC.0",
-    "@growi/plugin-attachment-refs": "^5.0.7-RC.0",
-    "@growi/plugin-lsx": "^5.0.7-RC.0",
-    "@growi/plugin-pukiwiki-like-linker": "^5.0.7-RC.0",
-    "@growi/slack": "^5.0.7-RC.0",
+    "@growi/codemirror-textlint": "^5.0.10-RC.0",
+    "@growi/plugin-attachment-refs": "^5.0.10-RC.0",
+    "@growi/plugin-lsx": "^5.0.10-RC.0",
+    "@growi/plugin-pukiwiki-like-linker": "^5.0.10-RC.0",
+    "@growi/slack": "^5.0.10-RC.0",
     "@promster/express": "^7.0.2",
     "@promster/express": "^7.0.2",
     "@promster/server": "^7.0.4",
     "@promster/server": "^7.0.4",
     "@slack/events-api": "^3.0.0",
     "@slack/events-api": "^3.0.0",
@@ -97,6 +99,7 @@
     "esa-node": "^0.2.2",
     "esa-node": "^0.2.2",
     "escape-string-regexp": "=4.0.0",
     "escape-string-regexp": "=4.0.0",
     "eslint-plugin-regex": "^1.8.0",
     "eslint-plugin-regex": "^1.8.0",
+    "expose-gc": "^1.0.0",
     "express": "^4.16.1",
     "express": "^4.16.1",
     "express-bunyan-logger": "^1.3.3",
     "express-bunyan-logger": "^1.3.3",
     "express-mongo-sanitize": "^2.1.0",
     "express-mongo-sanitize": "^2.1.0",
@@ -143,7 +146,6 @@
     "react-dnd-html5-backend": "^14.1.0",
     "react-dnd-html5-backend": "^14.1.0",
     "react-image-crop": "^8.3.0",
     "react-image-crop": "^8.3.0",
     "react-multiline-clamp": "^2.0.0",
     "react-multiline-clamp": "^2.0.0",
-    "react-tagcloud": "^2.1.1",
     "reconnecting-websocket": "^4.4.0",
     "reconnecting-websocket": "^4.4.0",
     "redis": "^3.0.2",
     "redis": "^3.0.2",
     "rimraf": "^3.0.0",
     "rimraf": "^3.0.0",
@@ -167,7 +169,7 @@
   },
   },
   "devDependencies": {
   "devDependencies": {
     "@alienfast/i18next-loader": "^1.1.4",
     "@alienfast/i18next-loader": "^1.1.4",
-    "@growi/ui": "^5.0.7-RC.0",
+    "@growi/ui": "^5.0.10-RC.0",
     "@handsontable/react": "=2.1.0",
     "@handsontable/react": "=2.1.0",
     "@types/compression": "^1.7.0",
     "@types/compression": "^1.7.0",
     "@types/express": "^4.17.11",
     "@types/express": "^4.17.11",
@@ -188,7 +190,6 @@
     "diff2html": "^3.1.2",
     "diff2html": "^3.1.2",
     "eazy-logger": "^3.1.0",
     "eazy-logger": "^3.1.0",
     "emoji-mart": "npm:panta82-emoji-mart@^3.0.1",
     "emoji-mart": "npm:panta82-emoji-mart@^3.0.1",
-    "markdown-it-emoji-mart": "^0.1.1",
     "eslint-plugin-cypress": "^2.12.1",
     "eslint-plugin-cypress": "^2.12.1",
     "eslint-plugin-regex": "^1.8.0",
     "eslint-plugin-regex": "^1.8.0",
     "file-loader": "^5.0.2",
     "file-loader": "^5.0.2",
@@ -206,6 +207,7 @@
     "markdown-it-blockdiag": "^1.1.1",
     "markdown-it-blockdiag": "^1.1.1",
     "markdown-it-drawio-viewer": "^1.3.1",
     "markdown-it-drawio-viewer": "^1.3.1",
     "markdown-it-emoji": "^1.4.0",
     "markdown-it-emoji": "^1.4.0",
+    "markdown-it-emoji-mart": "^0.1.1",
     "markdown-it-footnote": "^3.0.1",
     "markdown-it-footnote": "^3.0.1",
     "markdown-it-mathjax": "^2.0.0",
     "markdown-it-mathjax": "^2.0.0",
     "markdown-it-named-headers": "^0.0.4",
     "markdown-it-named-headers": "^0.0.4",

+ 0 - 0
packages/app/resource/locales/en_US/admin/admin.json → packages/app/public/static/locales/en_US/admin/admin.json


+ 0 - 0
packages/app/resource/locales/en_US/meta.json → packages/app/public/static/locales/en_US/meta.json


+ 15 - 2
packages/app/resource/locales/en_US/translation.json → packages/app/public/static/locales/en_US/translation.json

@@ -5,6 +5,7 @@
   "Delete": "Delete",
   "Delete": "Delete",
   "delete_all": "Delete all",
   "delete_all": "Delete all",
   "Duplicate": "Duplicate",
   "Duplicate": "Duplicate",
+  "PathRecovery": "Path recovery",
   "Copy": "Copy",
   "Copy": "Copy",
   "preview":"Preview",
   "preview":"Preview",
   "desktop":"Desktop",
   "desktop":"Desktop",
@@ -147,6 +148,7 @@
   "Shareable link": "Shareable link",
   "Shareable link": "Shareable link",
   "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",
   "Add tags for this page": "Add tags for this page",
   "Add tags for this page": "Add tags for this page",
+  "tag_list": "Tag list",
   "popular_tags": "Popular tags",
   "popular_tags": "Popular tags",
   "Check All tags": "check all tags",
   "Check All tags": "check all tags",
   "You have no tag, You can set tags on pages": "You have no tag, You can set tags on pages",
   "You have no tag, You can set tags on pages": "You have no tag, You can set tags on pages",
@@ -212,7 +214,7 @@
     },
     },
     "form_help": {
     "form_help": {
       "email": "You must have email address which listed below to sign up to this wiki.",
       "email": "You must have email address which listed below to sign up to this wiki.",
-      "password": "Your password must be at least 8 characters long.",
+      "password": "Your password must be at least {{target}} characters long.",
       "user_id": "The URL of pages you create will contain your User ID. Your User ID can consist of letters, numbers, and some symbols."
       "user_id": "The URL of pages you create will contain your User ID. Your User ID can consist of letters, numbers, and some symbols."
     }
     }
   },
   },
@@ -437,6 +439,7 @@
     "recursively": "Delete pages under this path recursively.",
     "recursively": "Delete pages under this path recursively.",
     "completely": "Delete completely instead of putting it into trash."
     "completely": "Delete completely instead of putting it into trash."
   },
   },
+  "deleted_page": "Moved to the trash",
   "deleted_pages": "{{path}} has been deleted",
   "deleted_pages": "{{path}} has been deleted",
   "deleted_pages_completely": "{{path}} has been deleted completely",
   "deleted_pages_completely": "{{path}} has been deleted completely",
   "renamed_pages": "{{path}} has been renamed",
   "renamed_pages": "{{path}} has been renamed",
@@ -982,6 +985,7 @@
     "application_already_installed": "Application already installed.",
     "application_already_installed": "Application already installed.",
     "email_address_could_not_be_used": "This email address could not be used. (Make sure the allowed email address)",
     "email_address_could_not_be_used": "This email address could not be used. (Make sure the allowed email address)",
     "user_id_is_not_available":"This User ID is not available.",
     "user_id_is_not_available":"This User ID is not available.",
+    "username_should_not_be_null":"Username should not be null. Please check Authentication Mechanism Settings on admin page",
     "email_address_is_already_registered":"This email address is already registered.",
     "email_address_is_already_registered":"This email address is already registered.",
     "can_not_register_maximum_number_of_users":"Can not register more than the maximum number of users.",
     "can_not_register_maximum_number_of_users":"Can not register more than the maximum number of users.",
     "failed_to_register":"Failed to register.",
     "failed_to_register":"Failed to register.",
@@ -1108,6 +1112,15 @@
     "cancel_bookmark": "Cancel Bookmark",
     "cancel_bookmark": "Cancel Bookmark",
     "receive_notifications": "Receive Notifications",
     "receive_notifications": "Receive Notifications",
     "stop_notification": "Stop Notification",
     "stop_notification": "Stop Notification",
-    "footprints": "Footprints"
+    "footprints": "Footprints",
+    "operation": {
+      "attention": {
+        "rename": "Renaming paths of descendant pages was not successful, please open the menu from the 3-point reader and select 'Path recovery'"
+      }
+    }
+  },
+  "page_operation":{
+    "paths_recovered": "Paths recovered successfully",
+    "path_recovery_failed":"Path recovery failed"
   }
   }
 }
 }

+ 0 - 0
packages/app/resource/locales/index.js → packages/app/public/static/locales/index.js


+ 0 - 0
packages/app/resource/locales/ja_JP/admin/admin.json → packages/app/public/static/locales/ja_JP/admin/admin.json


+ 0 - 0
packages/app/resource/locales/ja_JP/meta.json → packages/app/public/static/locales/ja_JP/meta.json


+ 15 - 2
packages/app/resource/locales/ja_JP/translation.json → packages/app/public/static/locales/ja_JP/translation.json

@@ -5,6 +5,7 @@
   "Delete": "削除",
   "Delete": "削除",
   "delete_all": "全て削除",
   "delete_all": "全て削除",
   "Duplicate": "複製",
   "Duplicate": "複製",
+  "PathRecovery": "パスを修復",
   "Copy": "コピー",
   "Copy": "コピー",
   "preview":"プレビュー",
   "preview":"プレビュー",
   "desktop":"パソコン",
   "desktop":"パソコン",
@@ -146,6 +147,7 @@
   "Shareable link": "このページの共有用URL",
   "Shareable link": "このページの共有用URL",
   "The whitelist of registration permission E-mail address": "登録許可メールアドレスの<br>ホワイトリスト",
   "The whitelist of registration permission E-mail address": "登録許可メールアドレスの<br>ホワイトリスト",
   "Add tags for this page": "タグを付ける",
   "Add tags for this page": "タグを付ける",
+  "tag_list": "タグ一覧",
   "popular_tags": "人気のタグ",
   "popular_tags": "人気のタグ",
   "Check All tags": "全てのタグを見る",
   "Check All tags": "全てのタグを見る",
   "You have no tag, You can set tags on pages": "使用中のタグがありません",
   "You have no tag, You can set tags on pages": "使用中のタグがありません",
@@ -214,7 +216,7 @@
     },
     },
     "form_help": {
     "form_help": {
       "email": "この Wiki では以下のメールアドレスのみ登録可能です。",
       "email": "この Wiki では以下のメールアドレスのみ登録可能です。",
-      "password": "パスワードには、8文字以上の半角英数字または記号等を設定してください。",
+      "password": "パスワードには、{{target}}文字以上の半角英数字または記号等を設定してください。",
       "user_id": "ユーザーIDは、ユーザーページのURLなどに利用されます。半角英数字と一部の記号のみ利用できます。"
       "user_id": "ユーザーIDは、ユーザーページのURLなどに利用されます。半角英数字と一部の記号のみ利用できます。"
     }
     }
   },
   },
@@ -437,6 +439,7 @@
     "recursively": "配下のページも削除します",
     "recursively": "配下のページも削除します",
     "completely": "ゴミ箱を経由せず、完全に削除します"
     "completely": "ゴミ箱を経由せず、完全に削除します"
   },
   },
+  "deleted_page": "ゴミ箱に入れました",
   "deleted_pages": "{{path}} をゴミ箱に入れました",
   "deleted_pages": "{{path}} をゴミ箱に入れました",
   "deleted_pages_completely": "{{path}} を完全に削除しました",
   "deleted_pages_completely": "{{path}} を完全に削除しました",
   "renamed_pages": "{{path}} を移動/名前変更しました",
   "renamed_pages": "{{path}} を移動/名前変更しました",
@@ -975,6 +978,7 @@
     "application_already_installed": "アプリケーションのインストールが完了しました。",
     "application_already_installed": "アプリケーションのインストールが完了しました。",
     "email_address_could_not_be_used":"このメールアドレスは使用できません。(許可されたメールアドレスを確認してください。)",
     "email_address_could_not_be_used":"このメールアドレスは使用できません。(許可されたメールアドレスを確認してください。)",
     "user_id_is_not_available":"このユーザーIDは使用できません。",
     "user_id_is_not_available":"このユーザーIDは使用できません。",
+    "username_should_not_be_null":"Username が null になっています 管理画面の認証機構設定にて設定の確認をしてください",
     "email_address_is_already_registered":"このメールアドレスは既に登録されています。",
     "email_address_is_already_registered":"このメールアドレスは既に登録されています。",
     "can_not_register_maximum_number_of_users":"ユーザー数が上限を超えたため登録できません。",
     "can_not_register_maximum_number_of_users":"ユーザー数が上限を超えたため登録できません。",
     "failed_to_register":"登録に失敗しました。",
     "failed_to_register":"登録に失敗しました。",
@@ -1101,6 +1105,15 @@
     "cancel_bookmark": "ブックマークを取り消す",
     "cancel_bookmark": "ブックマークを取り消す",
     "receive_notifications": "通知を受け取る",
     "receive_notifications": "通知を受け取る",
     "stop_notification": "通知を止める",
     "stop_notification": "通知を止める",
-    "footprints": "足跡"
+    "footprints": "足跡",
+    "operation": {
+      "attention": {
+        "rename": "配下のページパスの更新が正常に行われませんでした。3点リーダーからメニューを開き、「パスを修復」を選択してしてください。"
+      }
+    }
+  },
+  "page_operation":{
+    "paths_recovered": "パスを修復しました",
+    "path_recovery_failed":"パスを修復できませんでした"
   }
   }
 }
 }

+ 0 - 0
packages/app/resource/locales/zh_CN/admin/admin.json → packages/app/public/static/locales/zh_CN/admin/admin.json


+ 0 - 0
packages/app/resource/locales/zh_CN/meta.json → packages/app/public/static/locales/zh_CN/meta.json


+ 16 - 3
packages/app/resource/locales/zh_CN/translation.json → packages/app/public/static/locales/zh_CN/translation.json

@@ -5,6 +5,7 @@
 	"Delete": "删除",
 	"Delete": "删除",
 	"delete_all": "删除所有",
 	"delete_all": "删除所有",
 	"Duplicate": "复制",
 	"Duplicate": "复制",
+  "PathRecovery": "路径恢复",
 	"Copy": "复制",
 	"Copy": "复制",
   "preview":"预览",
   "preview":"预览",
   "desktop":"电脑",
   "desktop":"电脑",
@@ -155,6 +156,7 @@
 	"Shareable link": "可分享链接",
 	"Shareable link": "可分享链接",
 	"The whitelist of registration permission E-mail address": "注册许可电子邮件地址的白名单",
 	"The whitelist of registration permission E-mail address": "注册许可电子邮件地址的白名单",
 	"Add tags for this page": "添加标签",
 	"Add tags for this page": "添加标签",
+  "tag_list": "标签列表",
   "popular_tags": "流行标签",
   "popular_tags": "流行标签",
   "Check All tags": "检查所有标签",
   "Check All tags": "检查所有标签",
 	"You have no tag, You can set tags on pages": "你没有标签,可以在页面上设置标签",
 	"You have no tag, You can set tags on pages": "你没有标签,可以在页面上设置标签",
@@ -416,6 +418,7 @@
 		"recursively": "Delete children of <code>%s</code> recursively.",
 		"recursively": "Delete children of <code>%s</code> recursively.",
 		"completely": "Delete completely instead of putting it into trash."
 		"completely": "Delete completely instead of putting it into trash."
   },
   },
+  "deleted_page": "移到了垃圾箱。",
   "deleted_pages": "将 {{path}} 放入垃圾箱",
   "deleted_pages": "将 {{path}} 放入垃圾箱",
   "deleted_pages_completely": "{{path}} 已被完全删除",
   "deleted_pages_completely": "{{path}} 已被完全删除",
   "renamed_pages": "移动/重命名 {{path}}",
   "renamed_pages": "移动/重命名 {{path}}",
@@ -523,7 +526,7 @@
 	"template": {
 	"template": {
 		"modal_label": {
 		"modal_label": {
 			"Create/Edit Template Page": "创建/编辑模板页",
 			"Create/Edit Template Page": "创建/编辑模板页",
-			"Create template under": "在下面创建模板页:<br/><code><small>%s</small></code>"
+			"Create template under": "在下面创建模板页"
 		},
 		},
 		"option_label": {
 		"option_label": {
 			"create/edit": "创建/编辑模板页。",
 			"create/edit": "创建/编辑模板页。",
@@ -984,7 +987,8 @@
 		"aws_sttings_required": "使用此功能所需的AWS设置。请询问管理员。",
 		"aws_sttings_required": "使用此功能所需的AWS设置。请询问管理员。",
 		"application_already_installed": "应用程序已安装。",
 		"application_already_installed": "应用程序已安装。",
 		"email_address_could_not_be_used": "无法使用此电子邮件地址。(确保允许的电子邮件地址)",
 		"email_address_could_not_be_used": "无法使用此电子邮件地址。(确保允许的电子邮件地址)",
-		"user_id_is_not_available": "此用户ID不可用。",
+    "user_id_is_not_available": "此用户ID不可用。",
+    "username_should_not_be_null":"用户名不应为空。请检查管理页面上的身份验证机制设置",
 		"email_address_is_already_registered": "此电子邮件地址已注册。",
 		"email_address_is_already_registered": "此电子邮件地址已注册。",
 		"can_not_register_maximum_number_of_users": "注册的用户数不能超过最大值。",
 		"can_not_register_maximum_number_of_users": "注册的用户数不能超过最大值。",
 		"failed_to_register": "注册失败。",
 		"failed_to_register": "注册失败。",
@@ -1111,6 +1115,15 @@
     "cancel_bookmark": "取消书签",
     "cancel_bookmark": "取消书签",
     "receive_notifications": "接收通知",
     "receive_notifications": "接收通知",
     "stop_notification": "停止通知",
     "stop_notification": "停止通知",
-    "footprints": "脚印"
+    "footprints": "脚印",
+    "operation": {
+      "attention": {
+        "rename": "重命名子孙页的路径没有成功,请从三点式阅读器上打开菜单,选择 '路径恢复'。"
+      }
+    }
+  },
+  "page_operation":{
+    "paths_recovered": "成功恢复了页面路径",
+    "path_recovery_failed":"路径恢复失败"
   }
   }
 }
 }

+ 1 - 0
packages/app/resource/Contributor.js

@@ -41,6 +41,7 @@ const contributors = [
           { name: 'N1koge' },
           { name: 'N1koge' },
           { name: 'Ertai87' },
           { name: 'Ertai87' },
           { name: 'takayuki-t' },
           { name: 'takayuki-t' },
+          { name: 'ayaka0417' },
           { name: 'zahmis' },
           { name: 'zahmis' },
           { name: 'takeru0001' },
           { name: 'takeru0001' },
           { name: 'Shu Katabe' },
           { name: 'Shu Katabe' },

+ 36 - 40
packages/app/src/client/admin.jsx

@@ -1,55 +1,52 @@
 import React from 'react';
 import React from 'react';
+
 import ReactDOM from 'react-dom';
 import ReactDOM from 'react-dom';
-import { Provider } from 'unstated';
 import { I18nextProvider } from 'react-i18next';
 import { I18nextProvider } from 'react-i18next';
-
 import { SWRConfig } from 'swr';
 import { SWRConfig } from 'swr';
+import { Provider } from 'unstated';
 
 
-import loggerFactory from '~/utils/logger';
-import { swrGlobalConfiguration } from '~/utils/swr-utils';
-
-import ErrorBoundary from '../components/ErrorBoudary';
-
-import AdminHome from '../components/Admin/AdminHome/AdminHome';
-import UserGroupDetailPage from '../components/Admin/UserGroupDetail/UserGroupDetailPage';
-import NotificationSetting from '../components/Admin/Notification/NotificationSetting';
-import LegacySlackIntegration from '../components/Admin/LegacySlackIntegration/LegacySlackIntegration';
-import SlackIntegration from '../components/Admin/SlackIntegration/SlackIntegration';
-import ManageGlobalNotification from '../components/Admin/Notification/ManageGlobalNotification';
-import MarkdownSetting from '../components/Admin/MarkdownSetting/MarkDownSetting';
-import UserManagement from '../components/Admin/UserManagement';
-import AppSettingsPage from '../components/Admin/App/AppSettingsPage';
-import SecurityManagement from '../components/Admin/Security/SecurityManagement';
-import ManageExternalAccount from '../components/Admin/ManageExternalAccount';
-import UserGroupPage from '../components/Admin/UserGroup/UserGroupPage';
-import Customize from '../components/Admin/Customize/Customize';
-import ImportDataPage from '../components/Admin/ImportDataPage';
-import ExportArchiveDataPage from '../components/Admin/ExportArchiveDataPage';
-import FullTextSearchManagement from '../components/Admin/FullTextSearchManagement';
-import AdminNavigation from '../components/Admin/Common/AdminNavigation';
-
-import AdminSocketIoContainer from '~/client/services/AdminSocketIoContainer';
-import AdminHomeContainer from '~/client/services/AdminHomeContainer';
-import AdminCustomizeContainer from '~/client/services/AdminCustomizeContainer';
-import AdminUserGroupDetailContainer from '~/client/services/AdminUserGroupDetailContainer';
-import AdminUsersContainer from '~/client/services/AdminUsersContainer';
 import AdminAppContainer from '~/client/services/AdminAppContainer';
 import AdminAppContainer from '~/client/services/AdminAppContainer';
-import AdminImportContainer from '~/client/services/AdminImportContainer';
-import AdminMarkDownContainer from '~/client/services/AdminMarkDownContainer';
+import AdminBasicSecurityContainer from '~/client/services/AdminBasicSecurityContainer';
+import AdminCustomizeContainer from '~/client/services/AdminCustomizeContainer';
 import AdminExternalAccountsContainer from '~/client/services/AdminExternalAccountsContainer';
 import AdminExternalAccountsContainer from '~/client/services/AdminExternalAccountsContainer';
 import AdminGeneralSecurityContainer from '~/client/services/AdminGeneralSecurityContainer';
 import AdminGeneralSecurityContainer from '~/client/services/AdminGeneralSecurityContainer';
+import AdminGitHubSecurityContainer from '~/client/services/AdminGitHubSecurityContainer';
+import AdminGoogleSecurityContainer from '~/client/services/AdminGoogleSecurityContainer';
+import AdminHomeContainer from '~/client/services/AdminHomeContainer';
+import AdminImportContainer from '~/client/services/AdminImportContainer';
 import AdminLdapSecurityContainer from '~/client/services/AdminLdapSecurityContainer';
 import AdminLdapSecurityContainer from '~/client/services/AdminLdapSecurityContainer';
 import AdminLocalSecurityContainer from '~/client/services/AdminLocalSecurityContainer';
 import AdminLocalSecurityContainer from '~/client/services/AdminLocalSecurityContainer';
-import AdminSamlSecurityContainer from '~/client/services/AdminSamlSecurityContainer';
-import AdminOidcSecurityContainer from '~/client/services/AdminOidcSecurityContainer';
-import AdminBasicSecurityContainer from '~/client/services/AdminBasicSecurityContainer';
-import AdminGoogleSecurityContainer from '~/client/services/AdminGoogleSecurityContainer';
-import AdminGitHubSecurityContainer from '~/client/services/AdminGitHubSecurityContainer';
-import AdminTwitterSecurityContainer from '~/client/services/AdminTwitterSecurityContainer';
+import AdminMarkDownContainer from '~/client/services/AdminMarkDownContainer';
 import AdminNotificationContainer from '~/client/services/AdminNotificationContainer';
 import AdminNotificationContainer from '~/client/services/AdminNotificationContainer';
+import AdminOidcSecurityContainer from '~/client/services/AdminOidcSecurityContainer';
+import AdminSamlSecurityContainer from '~/client/services/AdminSamlSecurityContainer';
 import AdminSlackIntegrationLegacyContainer from '~/client/services/AdminSlackIntegrationLegacyContainer';
 import AdminSlackIntegrationLegacyContainer from '~/client/services/AdminSlackIntegrationLegacyContainer';
-
+import AdminSocketIoContainer from '~/client/services/AdminSocketIoContainer';
+import AdminTwitterSecurityContainer from '~/client/services/AdminTwitterSecurityContainer';
+import AdminUserGroupDetailContainer from '~/client/services/AdminUserGroupDetailContainer';
+import AdminUsersContainer from '~/client/services/AdminUsersContainer';
 import ContextExtractor from '~/client/services/ContextExtractor';
 import ContextExtractor from '~/client/services/ContextExtractor';
+import loggerFactory from '~/utils/logger';
+import { swrGlobalConfiguration } from '~/utils/swr-utils';
+
+import AdminHome from '../components/Admin/AdminHome/AdminHome';
+import AppSettingsPage from '../components/Admin/App/AppSettingsPage';
+import AdminNavigation from '../components/Admin/Common/AdminNavigation';
+import Customize from '../components/Admin/Customize/Customize';
+import ExportArchiveDataPage from '../components/Admin/ExportArchiveDataPage';
+import FullTextSearchManagement from '../components/Admin/FullTextSearchManagement';
+import ImportDataPage from '../components/Admin/ImportDataPage';
+import LegacySlackIntegration from '../components/Admin/LegacySlackIntegration/LegacySlackIntegration';
+import ManageExternalAccount from '../components/Admin/ManageExternalAccount';
+import MarkdownSetting from '../components/Admin/MarkdownSetting/MarkDownSetting';
+import ManageGlobalNotification from '../components/Admin/Notification/ManageGlobalNotification';
+import NotificationSetting from '../components/Admin/Notification/NotificationSetting';
+import SecurityManagement from '../components/Admin/Security/SecurityManagement';
+import SlackIntegration from '../components/Admin/SlackIntegration/SlackIntegration';
+import UserGroupPage from '../components/Admin/UserGroup/UserGroupPage';
+import UserGroupDetailPage from '../components/Admin/UserGroupDetail/UserGroupDetailPage';
+import UserManagement from '../components/Admin/UserManagement';
+import ErrorBoundary from '../components/ErrorBoudary';
 
 
 import { appContainer, componentMappings } from './base';
 import { appContainer, componentMappings } from './base';
 
 
@@ -58,7 +55,6 @@ const logger = loggerFactory('growi:admin');
 appContainer.initContents();
 appContainer.initContents();
 
 
 const { i18n } = appContainer;
 const { i18n } = appContainer;
-
 // create unstated container instance
 // create unstated container instance
 const adminAppContainer = new AdminAppContainer(appContainer);
 const adminAppContainer = new AdminAppContainer(appContainer);
 const adminImportContainer = new AdminImportContainer(appContainer);
 const adminImportContainer = new AdminImportContainer(appContainer);

+ 0 - 4
packages/app/src/client/app.jsx

@@ -34,7 +34,6 @@ import NotFoundPage from '../components/NotFoundPage';
 import Page from '../components/Page';
 import Page from '../components/Page';
 import DisplaySwitcher from '../components/Page/DisplaySwitcher';
 import DisplaySwitcher from '../components/Page/DisplaySwitcher';
 import FixPageGrantAlert from '../components/Page/FixPageGrantAlert';
 import FixPageGrantAlert from '../components/Page/FixPageGrantAlert';
-import NotFoundAlert from '../components/Page/NotFoundAlert';
 import RedirectedAlert from '../components/Page/RedirectedAlert';
 import RedirectedAlert from '../components/Page/RedirectedAlert';
 import ShareLinkAlert from '../components/Page/ShareLinkAlert';
 import ShareLinkAlert from '../components/Page/ShareLinkAlert';
 import TrashPageAlert from '../components/Page/TrashPageAlert';
 import TrashPageAlert from '../components/Page/TrashPageAlert';
@@ -115,9 +114,6 @@ Object.assign(componentMappings, {
 
 
   'share-link-alert': <ShareLinkAlert />,
   'share-link-alert': <ShareLinkAlert />,
   'redirected-alert': <RedirectedAlert />,
   'redirected-alert': <RedirectedAlert />,
-  'not-found-alert': <NotFoundAlert
-    isGuestUserMode={appContainer.isGuestUser}
-  />,
 });
 });
 
 
 // additional definitions if data exists
 // additional definitions if data exists

+ 6 - 0
packages/app/src/client/base.jsx

@@ -1,9 +1,12 @@
 import React from 'react';
 import React from 'react';
 
 
+import EventEmitter from 'events';
+
 import AppContainer from '~/client/services/AppContainer';
 import AppContainer from '~/client/services/AppContainer';
 import SocketIoContainer from '~/client/services/SocketIoContainer';
 import SocketIoContainer from '~/client/services/SocketIoContainer';
 import { DescendantsPageListModal } from '~/components/DescendantsPageListModal';
 import { DescendantsPageListModal } from '~/components/DescendantsPageListModal';
 import PutbackPageModal from '~/components/PutbackPageModal';
 import PutbackPageModal from '~/components/PutbackPageModal';
+import InterceptorManager from '~/services/interceptor-manager';
 import Xss from '~/services/xss';
 import Xss from '~/services/xss';
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 
 
@@ -30,6 +33,9 @@ if (!window) {
 const xss = new Xss();
 const xss = new Xss();
 window.xss = xss;
 window.xss = xss;
 
 
+window.globalEmitter = new EventEmitter();
+window.interceptorManager = new InterceptorManager();
+
 // create unstated container instance
 // create unstated container instance
 const appContainer = new AppContainer();
 const appContainer = new AppContainer();
 // eslint-disable-next-line no-unused-vars
 // eslint-disable-next-line no-unused-vars

+ 60 - 0
packages/app/src/client/installer.jsx

@@ -0,0 +1,60 @@
+import React from 'react';
+
+import ReactDOM from 'react-dom';
+import { I18nextProvider } from 'react-i18next';
+import { SWRConfig } from 'swr';
+
+
+import { swrGlobalConfiguration } from '~/utils/swr-utils';
+
+import InstallerForm from '../components/InstallerForm';
+
+import ContextExtractor from './services/ContextExtractor';
+import { i18nFactory } from './util/i18n';
+
+const i18n = i18nFactory();
+
+const componentMappings = {};
+
+// render InstallerForm
+const installerFormContainerElem = document.getElementById('installer-form-container');
+if (installerFormContainerElem) {
+  const userName = installerFormContainerElem.dataset.userName;
+  const name = installerFormContainerElem.dataset.name;
+  const email = installerFormContainerElem.dataset.email;
+
+  Object.assign(componentMappings, {
+    'installer-form-container': <InstallerForm userName={userName} name={name} email={email} />,
+  });
+}
+
+const renderMainComponents = () => {
+  Object.keys(componentMappings).forEach((key) => {
+    const elem = document.getElementById(key);
+    if (elem) {
+      ReactDOM.render(
+        <I18nextProvider i18n={i18n}>
+          <SWRConfig value={swrGlobalConfiguration}>
+            {componentMappings[key]}
+          </SWRConfig>
+        </I18nextProvider>,
+        elem,
+      );
+    }
+  });
+};
+
+// extract context before rendering main components
+const elem = document.getElementById('growi-context-extractor');
+if (elem != null) {
+  ReactDOM.render(
+    <SWRConfig value={swrGlobalConfiguration}>
+      <ContextExtractor></ContextExtractor>
+    </SWRConfig>,
+    elem,
+    renderMainComponents,
+  );
+}
+else {
+  renderMainComponents();
+}

+ 3 - 122
packages/app/src/client/legacy/crowi.js

@@ -12,38 +12,9 @@ if (!window) {
 }
 }
 window.Crowi = Crowi;
 window.Crowi = Crowi;
 
 
-/**
- * set 'data-caret-line' attribute that will be processed when 'shown.bs.tab' event fired
- * @param {number} line
- */
-Crowi.setCaretLineData = function(line) {
-  const { appContainer } = window;
-  const pageEditorDom = document.querySelector('#page-editor');
-  pageEditorDom.setAttribute('data-caret-line', line);
-};
-
-/**
- * invoked when;
- *
- * 1. 'shown.bs.tab' event fired
- */
-Crowi.setCaretLineAndFocusToEditor = function() {
-  // get 'data-caret-line' attributes
-  const pageEditorDom = document.querySelector('#page-editor');
-
-  if (pageEditorDom == null) {
-    return;
-  }
-
-  const { appContainer } = window;
-  const editorContainer = appContainer.getContainer('EditorContainer');
-  const line = pageEditorDom.getAttribute('data-caret-line') || 0;
-  editorContainer.setCaretLine(+line);
-  // reset data-caret-line attribute
-  pageEditorDom.removeAttribute('data-caret-line');
-
-  // focus
-  editorContainer.focusToEditor();
+Crowi.setCaretLine = function(line) {
+  // eslint-disable-next-line no-undef
+  globalEmitter.emit('setCaretLine', line);
 };
 };
 
 
 // original: middleware.swigFilter
 // original: middleware.swigFilter
@@ -55,39 +26,6 @@ Crowi.userPicture = function(user) {
   return user.image || '/images/icons/user.svg';
   return user.image || '/images/icons/user.svg';
 };
 };
 
 
-Crowi.modifyScrollTop = function() {
-  const offset = 10;
-
-  const hash = window.location.hash;
-  if (hash === '') {
-    return;
-  }
-
-  const pageHeader = document.querySelector('#page-header');
-  if (!pageHeader) {
-    return;
-  }
-  const pageHeaderRect = pageHeader.getBoundingClientRect();
-
-  const sectionHeader = Crowi.findSectionHeader(hash);
-  if (sectionHeader === null) {
-    return;
-  }
-
-  let timeout = 0;
-  if (window.scrollY === 0) {
-    timeout = 200;
-  }
-  setTimeout(() => {
-    const sectionHeaderRect = sectionHeader.getBoundingClientRect();
-    if (sectionHeaderRect.top >= pageHeaderRect.bottom) {
-      return;
-    }
-
-    window.scrollTo(0, (window.scrollY - pageHeaderRect.height - offset));
-  }, timeout);
-};
-
 Crowi.initClassesByOS = function() {
 Crowi.initClassesByOS = function() {
   // add classes to cmd-key by OS
   // add classes to cmd-key by OS
   const platform = navigator.platform.toLowerCase();
   const platform = navigator.platform.toLowerCase();
@@ -112,63 +50,6 @@ Crowi.initClassesByOS = function() {
   });
   });
 };
 };
 
 
-window.addEventListener('load', () => {
-  const crowi = window.crowi;
-  if (crowi && crowi.users && crowi.users.length !== 0) {
-    const totalUsers = crowi.users.length;
-    const $listLiker = $('.page-list-liker');
-    $listLiker.each((i, liker) => {
-      const count = $(liker).data('count') || 0;
-      if (count / totalUsers > 0.05) {
-        $(liker).addClass('popular-page-high');
-        // 5%
-      }
-      else if (count / totalUsers > 0.02) {
-        $(liker).addClass('popular-page-mid');
-        // 2%
-      }
-      else if (count / totalUsers > 0.005) {
-        $(liker).addClass('popular-page-low');
-        // 0.5%
-      }
-    });
-    const $listSeer = $('.page-list-seer');
-    $listSeer.each((i, seer) => {
-      const count = $(seer).data('count') || 0;
-      if (count / totalUsers > 0.10) {
-        // 10%
-        $(seer).addClass('popular-page-high');
-      }
-      else if (count / totalUsers > 0.05) {
-        // 5%
-        $(seer).addClass('popular-page-mid');
-      }
-      else if (count / totalUsers > 0.02) {
-        // 2%
-        $(seer).addClass('popular-page-low');
-      }
-    });
-  }
-
-  blinkSectionHeaderAtBoot();
-
-  Crowi.modifyScrollTop();
-  Crowi.initClassesByOS();
-});
-
-window.addEventListener('hashchange', (e) => {
-  Crowi.modifyScrollTop();
-
-  // hash on page
-  if (window.location.hash) {
-    if (window.location.hash === '#edit') {
-      Crowi.setCaretLineAndFocusToEditor();
-    }
-    // else if (window.location.hash === '#hackmd') {
-    // }
-  }
-});
-
 // adjust min-height of page for print temporarily
 // adjust min-height of page for print temporarily
 window.onbeforeprint = function() {
 window.onbeforeprint = function() {
   $('#page-wrapper').css('min-height', '0px');
   $('#page-wrapper').css('min-height', '0px');

+ 67 - 65
packages/app/src/client/nologin.jsx

@@ -2,42 +2,32 @@ import React from 'react';
 
 
 import ReactDOM from 'react-dom';
 import ReactDOM from 'react-dom';
 import { I18nextProvider } from 'react-i18next';
 import { I18nextProvider } from 'react-i18next';
+import { SWRConfig } from 'swr';
 import { Provider } from 'unstated';
 import { Provider } from 'unstated';
 
 
 
 
 import AppContainer from '~/client/services/AppContainer';
 import AppContainer from '~/client/services/AppContainer';
 import CompleteUserRegistrationForm from '~/components/CompleteUserRegistrationForm';
 import CompleteUserRegistrationForm from '~/components/CompleteUserRegistrationForm';
+import { swrGlobalConfiguration } from '~/utils/swr-utils';
 
 
-import InstallerForm from '../components/InstallerForm';
 import LoginForm from '../components/LoginForm';
 import LoginForm from '../components/LoginForm';
 import PasswordResetExecutionForm from '../components/PasswordResetExecutionForm';
 import PasswordResetExecutionForm from '../components/PasswordResetExecutionForm';
 import PasswordResetRequestForm from '../components/PasswordResetRequestForm';
 import PasswordResetRequestForm from '../components/PasswordResetRequestForm';
 
 
+import ContextExtractor from './services/ContextExtractor';
 import { i18nFactory } from './util/i18n';
 import { i18nFactory } from './util/i18n';
 
 
 const i18n = i18nFactory();
 const i18n = i18nFactory();
 
 
-// render InstallerForm
-const installerFormContainerElem = document.getElementById('installer-form-container');
-if (installerFormContainerElem) {
-  const userName = installerFormContainerElem.dataset.userName;
-  const name = installerFormContainerElem.dataset.name;
-  const email = installerFormContainerElem.dataset.email;
-  const csrf = installerFormContainerElem.dataset.csrf;
-  ReactDOM.render(
-    <I18nextProvider i18n={i18n}>
-      <InstallerForm userName={userName} name={name} email={email} csrf={csrf} />
-    </I18nextProvider>,
-    installerFormContainerElem,
-  );
-}
+
+const componentMappings = {};
+
+const appContainer = new AppContainer();
+appContainer.initApp();
 
 
 // render loginForm
 // render loginForm
 const loginFormElem = document.getElementById('login-form');
 const loginFormElem = document.getElementById('login-form');
 if (loginFormElem) {
 if (loginFormElem) {
-  const appContainer = new AppContainer();
-  appContainer.initApp();
-
   const username = loginFormElem.dataset.username;
   const username = loginFormElem.dataset.username;
   const name = loginFormElem.dataset.name;
   const name = loginFormElem.dataset.name;
   const email = loginFormElem.dataset.email;
   const email = loginFormElem.dataset.email;
@@ -65,78 +55,90 @@ if (loginFormElem) {
     basic: loginFormElem.dataset.isBasicAuthEnabled === 'true',
     basic: loginFormElem.dataset.isBasicAuthEnabled === 'true',
   };
   };
 
 
-  ReactDOM.render(
-    <I18nextProvider i18n={i18n}>
-      <Provider inject={[appContainer]}>
-        <LoginForm
-          username={username}
-          name={name}
-          email={email}
-          isRegistrationEnabled={isRegistrationEnabled}
-          isEmailAuthenticationEnabled={isEmailAuthenticationEnabled}
-          registrationMode={registrationMode}
-          registrationWhiteList={registrationWhiteList}
-          isPasswordResetEnabled={isPasswordResetEnabled}
-          isLocalStrategySetup={isLocalStrategySetup}
-          isLdapStrategySetup={isLdapStrategySetup}
-          objOfIsExternalAuthEnableds={objOfIsExternalAuthEnableds}
-        />
-      </Provider>
-    </I18nextProvider>,
-    loginFormElem,
-  );
+  Object.assign(componentMappings, {
+    [loginFormElem.id]: (
+      <LoginForm
+        username={username}
+        name={name}
+        email={email}
+        isRegistrationEnabled={isRegistrationEnabled}
+        isEmailAuthenticationEnabled={isEmailAuthenticationEnabled}
+        registrationMode={registrationMode}
+        registrationWhiteList={registrationWhiteList}
+        isPasswordResetEnabled={isPasswordResetEnabled}
+        isLocalStrategySetup={isLocalStrategySetup}
+        isLdapStrategySetup={isLdapStrategySetup}
+        objOfIsExternalAuthEnableds={objOfIsExternalAuthEnableds}
+      />
+    ),
+  });
 }
 }
 
 
-const appContainer = new AppContainer();
-appContainer.initApp();
-
-
 // render PasswordResetRequestForm
 // render PasswordResetRequestForm
 const passwordResetRequestFormElem = document.getElementById('password-reset-request-form');
 const passwordResetRequestFormElem = document.getElementById('password-reset-request-form');
 if (passwordResetRequestFormElem) {
 if (passwordResetRequestFormElem) {
-
-  ReactDOM.render(
-    <I18nextProvider i18n={i18n}>
-      <Provider inject={[appContainer]}>
-        <PasswordResetRequestForm />
-      </Provider>
-    </I18nextProvider>,
-    passwordResetRequestFormElem,
-  );
+  Object.assign(componentMappings, {
+    [passwordResetRequestFormElem.id]: <PasswordResetRequestForm />,
+  });
 }
 }
 
 
 // render PasswordResetExecutionForm
 // render PasswordResetExecutionForm
 const passwordResetExecutionFormElem = document.getElementById('password-reset-execution-form');
 const passwordResetExecutionFormElem = document.getElementById('password-reset-execution-form');
 if (passwordResetExecutionFormElem) {
 if (passwordResetExecutionFormElem) {
-
-  ReactDOM.render(
-    <I18nextProvider i18n={i18n}>
-      <Provider inject={[appContainer]}>
-        <PasswordResetExecutionForm />
-      </Provider>
-    </I18nextProvider>,
-    passwordResetExecutionFormElem,
-  );
+  Object.assign(componentMappings, {
+    [passwordResetExecutionFormElem.id]: <PasswordResetExecutionForm />,
+  });
 }
 }
 
 
 // render UserActivationForm
 // render UserActivationForm
 const UserActivationForm = document.getElementById('user-activation-form');
 const UserActivationForm = document.getElementById('user-activation-form');
 if (UserActivationForm) {
 if (UserActivationForm) {
-
   const messageErrors = UserActivationForm.dataset.messageErrors;
   const messageErrors = UserActivationForm.dataset.messageErrors;
   const inputs = UserActivationForm.dataset.inputs;
   const inputs = UserActivationForm.dataset.inputs;
   const email = UserActivationForm.dataset.email;
   const email = UserActivationForm.dataset.email;
   const token = UserActivationForm.dataset.token;
   const token = UserActivationForm.dataset.token;
 
 
-  ReactDOM.render(
-    <I18nextProvider i18n={i18n}>
+  Object.assign(componentMappings, {
+    [UserActivationForm.id]: (
       <CompleteUserRegistrationForm
       <CompleteUserRegistrationForm
         messageErrors={messageErrors}
         messageErrors={messageErrors}
         inputs={inputs}
         inputs={inputs}
         email={email}
         email={email}
         token={token}
         token={token}
       />
       />
-    </I18nextProvider>,
-    UserActivationForm,
+    ),
+  });
+}
+
+const renderMainComponents = () => {
+  Object.keys(componentMappings).forEach((key) => {
+    const elem = document.getElementById(key);
+    if (elem) {
+      ReactDOM.render(
+        <I18nextProvider i18n={i18n}>
+          <SWRConfig value={swrGlobalConfiguration}>
+            <Provider inject={[appContainer]}>
+              {componentMappings[key]}
+            </Provider>
+          </SWRConfig>
+        </I18nextProvider>,
+        elem,
+      );
+    }
+  });
+};
+
+// extract context before rendering main components
+const elem = document.getElementById('growi-context-extractor');
+if (elem != null) {
+  ReactDOM.render(
+    <SWRConfig value={swrGlobalConfiguration}>
+      <ContextExtractor></ContextExtractor>
+    </SWRConfig>,
+    elem,
+    renderMainComponents,
   );
   );
 }
 }
+else {
+  renderMainComponents();
+}

+ 4 - 63
packages/app/src/client/services/AppContainer.js

@@ -1,6 +1,5 @@
 import { Container } from 'unstated';
 import { Container } from 'unstated';
 
 
-import InterceptorManager from '~/services/interceptor-manager';
 
 
 import GrowiRenderer from '../util/GrowiRenderer';
 import GrowiRenderer from '../util/GrowiRenderer';
 import { i18nFactory } from '../util/i18n';
 import { i18nFactory } from '../util/i18n';
@@ -14,25 +13,15 @@ export default class AppContainer extends Container {
   constructor() {
   constructor() {
     super();
     super();
 
 
-    // get csrf token from body element
-    // DO NOT REMOVE: uploading attachment data requires appContainer.csrfToken
-    const body = document.querySelector('body');
-    this.csrfToken = body.dataset.csrftoken;
-
     this.config = JSON.parse(document.getElementById('growi-context-hydrate').textContent || '{}');
     this.config = JSON.parse(document.getElementById('growi-context-hydrate').textContent || '{}');
 
 
+    // init i18n
     const currentUserElem = document.getElementById('growi-current-user');
     const currentUserElem = document.getElementById('growi-current-user');
+    let userLocaleId;
     if (currentUserElem != null) {
     if (currentUserElem != null) {
-      this.currentUser = JSON.parse(currentUserElem.textContent);
+      const currentUser = JSON.parse(currentUserElem.textContent);
+      userLocaleId = currentUser?.lang;
     }
     }
-
-    const isSharedPageElem = document.getElementById('is-shared-page');
-
-    // check what kind of user
-    this.isGuestUser = this.currentUser == null;
-    this.isSharedUser = isSharedPageElem != null && this.currentUser == null;
-
-    const userLocaleId = this.currentUser?.lang;
     this.i18n = i18nFactory(userLocaleId);
     this.i18n = i18nFactory(userLocaleId);
 
 
     this.containerInstances = {};
     this.containerInstances = {};
@@ -58,8 +47,6 @@ export default class AppContainer extends Container {
 
 
     this.originRenderer = new GrowiRenderer(this);
     this.originRenderer = new GrowiRenderer(this);
 
 
-    this.interceptorManager = new InterceptorManager();
-
     const isPluginEnabled = body.dataset.pluginEnabled === 'true';
     const isPluginEnabled = body.dataset.pluginEnabled === 'true';
     if (isPluginEnabled) {
     if (isPluginEnabled) {
       this.initPlugins();
       this.initPlugins();
@@ -85,27 +72,6 @@ export default class AppContainer extends Container {
     window.crowiPlugin = window.growiPlugin;
     window.crowiPlugin = window.growiPlugin;
   }
   }
 
 
-  get currentUserId() {
-    if (this.currentUser == null) {
-      return null;
-    }
-    return this.currentUser._id;
-  }
-
-  get currentUsername() {
-    if (this.currentUser == null) {
-      return null;
-    }
-    return this.currentUser.username;
-  }
-
-  /**
-   * @return {Object} window.Crowi (js/legacy/crowi.js)
-   */
-  getCrowiForJquery() {
-    return window.Crowi;
-  }
-
   getConfig() {
   getConfig() {
     return this.config;
     return this.config;
   }
   }
@@ -149,10 +115,6 @@ export default class AppContainer extends Container {
       throw new Error('The specified instance must not be null');
       throw new Error('The specified instance must not be null');
     }
     }
 
 
-    if (this.componentInstances[id] != null) {
-      throw new Error('The specified instance couldn\'t register because the same id has already been registered');
-    }
-
     this.componentInstances[id] = instance;
     this.componentInstances[id] = instance;
   }
   }
 
 
@@ -186,25 +148,4 @@ export default class AppContainer extends Container {
     return renderer;
     return renderer;
   }
   }
 
 
-
-  launchHandsontableModal(componentKind, beginLineNumber, endLineNumber) {
-    let targetComponent;
-    switch (componentKind) {
-      case 'page':
-        targetComponent = this.getComponentInstance('Page');
-        break;
-    }
-    targetComponent.launchHandsontableModal(beginLineNumber, endLineNumber);
-  }
-
-  launchDrawioModal(componentKind, beginLineNumber, endLineNumber) {
-    let targetComponent;
-    switch (componentKind) {
-      case 'page':
-        targetComponent = this.getComponentInstance('Page');
-        break;
-    }
-    targetComponent.launchDrawioModal(beginLineNumber, endLineNumber);
-  }
-
 }
 }

+ 2 - 7
packages/app/src/client/services/CommentContainer.js

@@ -2,7 +2,7 @@ import { Container } from 'unstated';
 
 
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 
 
-import { apiGet, apiPost } from '../util/apiv1-client';
+import { apiGet, apiPost, apiPostForm } from '../util/apiv1-client';
 import { apiv3Put } from '../util/apiv3-client';
 import { apiv3Put } from '../util/apiv3-client';
 
 
 const logger = loggerFactory('growi:services:CommentContainer');
 const logger = loggerFactory('growi:services:CommentContainer');
@@ -30,10 +30,6 @@ export default class CommentContainer extends Container {
 
 
     this.state = {
     this.state = {
       comments: [],
       comments: [],
-
-      // settings shared among all of CommentEditor
-      isSlackEnabled: false,
-      slackChannels: mainContent.getAttribute('data-slack-channels') || '',
     };
     };
 
 
     this.retrieveComments = this.retrieveComments.bind(this);
     this.retrieveComments = this.retrieveComments.bind(this);
@@ -161,12 +157,11 @@ export default class CommentContainer extends Container {
 
 
     const endpoint = '/attachments.add';
     const endpoint = '/attachments.add';
     const formData = new FormData();
     const formData = new FormData();
-    formData.append('_csrf', this.appContainer.csrfToken);
     formData.append('file', file);
     formData.append('file', file);
     formData.append('path', pagePath);
     formData.append('path', pagePath);
     formData.append('page_id', pageId);
     formData.append('page_id', pageId);
 
 
-    return apiPost(endpoint, formData);
+    return apiPostForm(endpoint, formData);
   }
   }
 
 
 }
 }

+ 11 - 5
packages/app/src/client/services/ContextExtractor.tsx

@@ -16,9 +16,9 @@ import {
   useIsDeleted, useIsNotCreatable, useIsTrashPage, useIsUserPage, useLastUpdateUsername,
   useIsDeleted, useIsNotCreatable, useIsTrashPage, useIsUserPage, useLastUpdateUsername,
   useCurrentPageId, usePageIdOnHackmd, usePageUser, useCurrentPagePath, useRevisionCreatedAt, useRevisionId, useRevisionIdHackmdSynced,
   useCurrentPageId, usePageIdOnHackmd, usePageUser, useCurrentPagePath, useRevisionCreatedAt, useRevisionId, useRevisionIdHackmdSynced,
   useShareLinkId, useShareLinksNumber, useTemplateTagData, useCurrentUpdatedAt, useCreator, useRevisionAuthor, useCurrentUser, useTargetAndAncestors,
   useShareLinkId, useShareLinksNumber, useTemplateTagData, useCurrentUpdatedAt, useCreator, useRevisionAuthor, useCurrentUser, useTargetAndAncestors,
-  useSlackChannels, useNotFoundTargetPathOrId, useIsSearchPage, useIsForbidden, useIsIdenticalPath, useHasParent,
+  useNotFoundTargetPathOrId, useIsSearchPage, useIsForbidden, useIsIdenticalPath, useHasParent,
   useIsAclEnabled, useIsSearchServiceConfigured, useIsSearchServiceReachable, useIsEnabledAttachTitleHeader, useIsNotFoundPermalink,
   useIsAclEnabled, useIsSearchServiceConfigured, useIsSearchServiceReachable, useIsEnabledAttachTitleHeader, useIsNotFoundPermalink,
-  useDefaultIndentSize, useIsIndentSizeForced,
+  useDefaultIndentSize, useIsIndentSizeForced, useCsrfToken,
 } from '../../stores/context';
 } from '../../stores/context';
 
 
 const { isTrashPage: _isTrashPage } = pagePathUtils;
 const { isTrashPage: _isTrashPage } = pagePathUtils;
@@ -32,6 +32,11 @@ const ContextExtractorOnce: FC = () => {
   const notFoundContent = document.getElementById('growi-not-found-context');
   const notFoundContent = document.getElementById('growi-not-found-context');
   const forbiddenContent = document.getElementById('forbidden-page');
   const forbiddenContent = document.getElementById('forbidden-page');
 
 
+  // get csrf token from body element
+  // DO NOT REMOVE: uploading attachment data requires appContainer.csrfToken
+  const body = document.querySelector('body');
+  const csrfToken = body?.dataset.csrftoken;
+
   /*
   /*
    * App Context from DOM
    * App Context from DOM
    */
    */
@@ -85,7 +90,6 @@ const ContextExtractorOnce: FC = () => {
   const targetAndAncestors = JSON.parse(document.getElementById('growi-pagetree-target-and-ancestors')?.textContent || jsonNull);
   const targetAndAncestors = JSON.parse(document.getElementById('growi-pagetree-target-and-ancestors')?.textContent || jsonNull);
   const notFoundTargetPathOrId = JSON.parse(notFoundContentForPt?.getAttribute('data-not-found-target-path-or-id') || jsonNull);
   const notFoundTargetPathOrId = JSON.parse(notFoundContentForPt?.getAttribute('data-not-found-target-path-or-id') || jsonNull);
   const isNotFoundPermalink = JSON.parse(notFoundContent?.getAttribute('data-is-not-found-permalink') || jsonNull);
   const isNotFoundPermalink = JSON.parse(notFoundContent?.getAttribute('data-is-not-found-permalink') || jsonNull);
-  const slackChannels = mainContent?.getAttribute('data-slack-channels') || '';
   const isSearchPage = document.getElementById('search-page') != null;
   const isSearchPage = document.getElementById('search-page') != null;
 
 
   const grant = +(mainContent?.getAttribute('data-page-grant') || 1);
   const grant = +(mainContent?.getAttribute('data-page-grant') || 1);
@@ -95,6 +99,8 @@ const ContextExtractorOnce: FC = () => {
   /*
   /*
    * use static swr
    * use static swr
    */
    */
+  useCsrfToken(csrfToken);
+
   // App
   // App
   useCurrentUser(currentUser);
   useCurrentUser(currentUser);
 
 
@@ -158,7 +164,6 @@ const ContextExtractorOnce: FC = () => {
   useIsDeviceSmallerThanMd();
   useIsDeviceSmallerThanMd();
 
 
   // Editor
   // Editor
-  useSlackChannels(slackChannels);
   useSelectedGrant(grant);
   useSelectedGrant(grant);
   useSelectedGrantGroupId(grantGroupId);
   useSelectedGrantGroupId(grantGroupId);
   useSelectedGrantGroupName(grantGroupName);
   useSelectedGrantGroupName(grantGroupName);
@@ -168,7 +173,8 @@ const ContextExtractorOnce: FC = () => {
 
 
   // Global Socket
   // Global Socket
   useSetupGlobalSocket();
   useSetupGlobalSocket();
-  useSetupGlobalAdminSocket();
+  const shouldInitAdminSock = !!currentUser?.isAdmin;
+  useSetupGlobalAdminSocket(shouldInitAdminSock);
 
 
   return null;
   return null;
 };
 };

+ 0 - 14
packages/app/src/client/services/EditorContainer.js

@@ -59,20 +59,6 @@ export default class EditorContainer extends Container {
     }
     }
   }
   }
 
 
-  setCaretLine(line) {
-    const pageEditor = this.appContainer.getComponentInstance('PageEditor');
-    if (pageEditor != null) {
-      pageEditor.setCaretLine(line);
-    }
-  }
-
-  focusToEditor() {
-    const pageEditor = this.appContainer.getComponentInstance('PageEditor');
-    if (pageEditor != null) {
-      pageEditor.focusToEditor();
-    }
-  }
-
   getCurrentOptionsToSave() {
   getCurrentOptionsToSave() {
     const opt = {
     const opt = {
       pageTags: this.state.tags,
       pageTags: this.state.tags,

+ 15 - 46
packages/app/src/client/services/PageContainer.js

@@ -52,7 +52,6 @@ export default class PageContainer extends Container {
       revisionId,
       revisionId,
       revisionCreatedAt: +mainContent.getAttribute('data-page-revision-created'),
       revisionCreatedAt: +mainContent.getAttribute('data-page-revision-created'),
       path,
       path,
-      tocHtml: '',
 
 
       createdAt: mainContent.getAttribute('data-page-created-at'),
       createdAt: mainContent.getAttribute('data-page-created-at'),
       // please use useCurrentUpdatedAt instead
       // please use useCurrentUpdatedAt instead
@@ -100,14 +99,13 @@ export default class PageContainer extends Container {
       logger.warn('The data of \'data-page-revision-author\' is invalid', e);
       logger.warn('The data of \'data-page-revision-author\' is invalid', e);
     }
     }
 
 
-    const { interceptorManager } = this.appContainer;
-    interceptorManager.addInterceptor(new DetachCodeBlockInterceptor(appContainer), 10); // process as soon as possible
-    interceptorManager.addInterceptor(new DrawioInterceptor(appContainer), 20);
-    interceptorManager.addInterceptor(new RestoreCodeBlockInterceptor(appContainer), 900); // process as late as possible
+    const { interceptorManager } = window;
+    interceptorManager.addInterceptor(new DetachCodeBlockInterceptor(), 10); // process as soon as possible
+    interceptorManager.addInterceptor(new DrawioInterceptor(), 20);
+    interceptorManager.addInterceptor(new RestoreCodeBlockInterceptor(), 900); // process as late as possible
 
 
     this.initStateMarkdown();
     this.initStateMarkdown();
 
 
-    this.setTocHtml = this.setTocHtml.bind(this);
     this.save = this.save.bind(this);
     this.save = this.save.bind(this);
 
 
     this.emitJoinPageRoomRequest = this.emitJoinPageRoomRequest.bind(this);
     this.emitJoinPageRoomRequest = this.emitJoinPageRoomRequest.bind(this);
@@ -138,29 +136,6 @@ export default class PageContainer extends Container {
     return 'PageContainer';
     return 'PageContainer';
   }
   }
 
 
-  /**
-   * whether to Empty Trash Page
-   * not displayed when guest user and not on trash page
-   */
-  get isAbleToShowEmptyTrashButton() {
-    const { currentUser } = this.appContainer;
-    const { path, hasChildren } = this.state;
-
-    return (currentUser != null && currentUser.admin && path === '/trash' && hasChildren);
-  }
-
-  /**
-   * whether to display trash management buttons
-   * ex.) undo, delete completly
-   * not displayed when guest user
-   */
-  get isAbleToShowTrashPageManagementButtons() {
-    const { currentUser } = this.appContainer;
-    const { isDeleted } = this.state;
-
-    return (isDeleted && currentUser != null);
-  }
-
   /**
   /**
    * initialize state for markdown data
    * initialize state for markdown data
    */
    */
@@ -194,12 +169,6 @@ export default class PageContainer extends Container {
     this.setState(newState);
     this.setState(newState);
   }
   }
 
 
-  async setTocHtml(tocHtml) {
-    if (this.state.tocHtml !== tocHtml) {
-      this.setState({ tocHtml });
-    }
-  }
-
   /**
   /**
    * save success handler
    * save success handler
    * @param {object} page Page instance
    * @param {object} page Page instance
@@ -225,13 +194,12 @@ export default class PageContainer extends Container {
     }
     }
     this.setState(newState);
     this.setState(newState);
 
 
-    // PageEditor component
-    const pageEditor = this.appContainer.getComponentInstance('PageEditor');
-    if (pageEditor != null) {
-      if (editorMode !== EditorMode.Editor) {
-        pageEditor.updateEditorValue(newState.markdown);
-      }
+    // Update PageEditor component
+    if (editorMode !== EditorMode.Editor) {
+      // eslint-disable-next-line no-undef
+      globalEmitter.emit('updateEditorValue', newState.markdown);
     }
     }
+
     // PageEditorByHackmd component
     // PageEditorByHackmd component
     const pageEditorByHackmd = this.appContainer.getComponentInstance('PageEditorByHackmd');
     const pageEditorByHackmd = this.appContainer.getComponentInstance('PageEditorByHackmd');
     if (pageEditorByHackmd != null) {
     if (pageEditorByHackmd != null) {
@@ -279,7 +247,7 @@ export default class PageContainer extends Container {
     let { revisionId } = this.state;
     let { revisionId } = this.state;
     const options = Object.assign({}, optionsToSave);
     const options = Object.assign({}, optionsToSave);
 
 
-    if (editorMode === 'hackmd') {
+    if (editorMode === EditorMode.HackMD) {
       // set option to sync
       // set option to sync
       options.isSyncRevisionToHackmd = true;
       options.isSyncRevisionToHackmd = true;
       revisionId = this.state.revisionIdHackmdSynced;
       revisionId = this.state.revisionIdHackmdSynced;
@@ -314,7 +282,7 @@ export default class PageContainer extends Container {
     const options = Object.assign({}, optionsToSave);
     const options = Object.assign({}, optionsToSave);
 
 
     let markdown;
     let markdown;
-    if (editorMode === 'hackmd') {
+    if (editorMode === EditorMode.HackMD) {
       const pageEditorByHackmd = this.appContainer.getComponentInstance('PageEditorByHackmd');
       const pageEditorByHackmd = this.appContainer.getComponentInstance('PageEditorByHackmd');
       markdown = await pageEditorByHackmd.getMarkdown();
       markdown = await pageEditorByHackmd.getMarkdown();
       // set option to sync
       // set option to sync
@@ -459,7 +427,6 @@ export default class PageContainer extends Container {
 
 
     const { pageId, remoteRevisionId, path } = this.state;
     const { pageId, remoteRevisionId, path } = this.state;
     const editorContainer = this.appContainer.getContainer('EditorContainer');
     const editorContainer = this.appContainer.getContainer('EditorContainer');
-    const pageEditor = this.appContainer.getComponentInstance('PageEditor');
     const options = editorContainer.getCurrentOptionsToSave();
     const options = editorContainer.getCurrentOptionsToSave();
     const optionsToSave = Object.assign({}, options);
     const optionsToSave = Object.assign({}, options);
 
 
@@ -468,8 +435,10 @@ export default class PageContainer extends Container {
     editorContainer.clearDraft(path);
     editorContainer.clearDraft(path);
     this.updateStateAfterSave(res.page, res.tags, res.revision, editorMode);
     this.updateStateAfterSave(res.page, res.tags, res.revision, editorMode);
 
 
-    if (pageEditor != null) {
-      pageEditor.updateEditorValue(markdown);
+    // Update PageEditor component
+    if (editorMode !== EditorMode.Editor) {
+      // eslint-disable-next-line no-undef
+      globalEmitter.emit('updateEditorValue', markdown);
     }
     }
 
 
     editorContainer.setState({ tags: res.tags });
     editorContainer.setState({ tags: res.tags });

+ 0 - 77
packages/app/src/client/services/PersonalContainer.js

@@ -8,8 +8,6 @@ import { apiv3Get, apiv3Put } from '../util/apiv3-client';
 // eslint-disable-next-line no-unused-vars
 // eslint-disable-next-line no-unused-vars
 const logger = loggerFactory('growi:services:PersonalContainer');
 const logger = loggerFactory('growi:services:PersonalContainer');
 
 
-const DEFAULT_IMAGE = '/images/icons/user.svg';
-
 /**
 /**
  * Service container for personal settings page (PersonalSettings.jsx)
  * Service container for personal settings page (PersonalSettings.jsx)
  * @extends {Container} unstated Container
  * @extends {Container} unstated Container
@@ -29,8 +27,6 @@ export default class PersonalContainer extends Container {
       isEmailPublished: false,
       isEmailPublished: false,
       lang: 'en_US',
       lang: 'en_US',
       isGravatarEnabled: false,
       isGravatarEnabled: false,
-      isUploadedPicture: false,
-      uploadedPictureSrc: this.getUploadedPictureSrc(this.appContainer.currentUser),
       externalAccounts: [],
       externalAccounts: [],
       apiToken: '',
       apiToken: '',
       slackMemberId: '',
       slackMemberId: '',
@@ -69,25 +65,6 @@ export default class PersonalContainer extends Container {
     }
     }
   }
   }
 
 
-  /**
-   * define a function for uploaded picture
-   */
-  getUploadedPictureSrc(user) {
-    if (user == null) {
-      return DEFAULT_IMAGE;
-    }
-    if (user.image) {
-      this.setState({ isUploadedPicture: true });
-      return user.image;
-    }
-    if (user.imageAttachment != null) {
-      this.setState({ isUploadedPicture: true });
-      return user.imageAttachment.filePathProxied;
-    }
-
-    return DEFAULT_IMAGE;
-  }
-
   /**
   /**
    * retrieve external accounts that linked me
    * retrieve external accounts that linked me
    */
    */
@@ -178,60 +155,6 @@ export default class PersonalContainer extends Container {
     }
     }
   }
   }
 
 
-  /**
-   * Update profile image
-   * @memberOf PersonalContainer
-   */
-  async updateProfileImage() {
-    try {
-      const response = await apiv3Put('/personal-setting/image-type', {
-        isGravatarEnabled: this.state.isGravatarEnabled,
-      });
-      const { userData } = response.data;
-      this.setState({
-        isGravatarEnabled: userData.isGravatarEnabled,
-      });
-    }
-    catch (err) {
-      this.setState({ retrieveError: err });
-      logger.error(err);
-      throw new Error('Failed to update profile image');
-    }
-  }
-
-  /**
-   * Upload image
-   */
-  async uploadAttachment(file) {
-    try {
-      const formData = new FormData();
-      formData.append('file', file);
-      formData.append('_csrf', this.appContainer.csrfToken);
-      const response = await apiPost('/attachments.uploadProfileImage', formData);
-      this.setState({ isUploadedPicture: true, uploadedPictureSrc: response.attachment.filePathProxied });
-    }
-    catch (err) {
-      this.setState({ retrieveError: err });
-      logger.error(err);
-      throw new Error('Failed to upload profile image');
-    }
-  }
-
-  /**
-   * Delete image
-   */
-  async deleteProfileImage() {
-    try {
-      await apiPost('/attachments.removeProfileImage', { _csrf: this.appContainer.csrfToken });
-      this.setState({ isUploadedPicture: false, uploadedPictureSrc: DEFAULT_IMAGE });
-    }
-    catch (err) {
-      this.setState({ retrieveError: err });
-      logger.error(err);
-      throw new Error('Failed to delete profile image');
-    }
-  }
-
   /**
   /**
    * Associate LDAP account
    * Associate LDAP account
    */
    */

+ 9 - 1
packages/app/src/client/services/page-operation.ts

@@ -3,7 +3,8 @@ import urljoin from 'url-join';
 import { SubscriptionStatusType } from '~/interfaces/subscription';
 import { SubscriptionStatusType } from '~/interfaces/subscription';
 
 
 import { toastError } from '../util/apiNotification';
 import { toastError } from '../util/apiNotification';
-import { apiv3Put } from '../util/apiv3-client';
+import { apiv3Post, apiv3Put } from '../util/apiv3-client';
+
 
 
 export const toggleSubscribe = async(pageId: string, currentStatus: SubscriptionStatusType | undefined): Promise<void> => {
 export const toggleSubscribe = async(pageId: string, currentStatus: SubscriptionStatusType | undefined): Promise<void> => {
   try {
   try {
@@ -60,3 +61,10 @@ export const exportAsMarkdown = (pageId: string, revisionId: string, format: str
   url.searchParams.append('revisionId', revisionId);
   url.searchParams.append('revisionId', revisionId);
   window.location.href = url.href;
   window.location.href = url.href;
 };
 };
+
+/**
+ * send request to fix broken paths caused by unexpected events such as server shutdown while renaming page paths
+ */
+export const resumeRenameOperation = async(pageId: string): Promise<void> => {
+  await apiv3Post('/pages/resume-rename', { pageId });
+};

+ 15 - 17
packages/app/src/client/util/GrowiRenderer.js

@@ -40,9 +40,9 @@ export default class GrowiRenderer {
     }
     }
     else {
     else {
       this.preProcessors = [
       this.preProcessors = [
-        new EasyGrid(appContainer),
-        new Linker(appContainer),
-        new CsvToTable(appContainer),
+        new EasyGrid(),
+        new Linker(),
+        new CsvToTable(),
         new XssFilter(appContainer),
         new XssFilter(appContainer),
       ];
       ];
       this.postProcessors = [
       this.postProcessors = [
@@ -70,10 +70,10 @@ export default class GrowiRenderer {
     this.markdownItConfigurers = [
     this.markdownItConfigurers = [
       new LinkerByRelativePathConfigurer(appContainer),
       new LinkerByRelativePathConfigurer(appContainer),
       new TaskListsConfigurer(appContainer),
       new TaskListsConfigurer(appContainer),
-      new HeaderConfigurer(appContainer),
-      new EmojiConfigurer(appContainer),
+      new HeaderConfigurer(),
+      new EmojiConfigurer(),
       new MathJaxConfigurer(appContainer),
       new MathJaxConfigurer(appContainer),
-      new DrawioViewerConfigurer(appContainer),
+      new DrawioViewerConfigurer(),
       new PlantUMLConfigurer(appContainer),
       new PlantUMLConfigurer(appContainer),
       new BlockdiagConfigurer(appContainer),
       new BlockdiagConfigurer(appContainer),
     ];
     ];
@@ -81,29 +81,27 @@ export default class GrowiRenderer {
     // add configurers according to mode
     // add configurers according to mode
     switch (mode) {
     switch (mode) {
       case 'page': {
       case 'page': {
-        const pageContainer = appContainer.getContainer('PageContainer');
-
         this.markdownItConfigurers = this.markdownItConfigurers.concat([
         this.markdownItConfigurers = this.markdownItConfigurers.concat([
-          new FooternoteConfigurer(appContainer),
-          new TocAndAnchorConfigurer(appContainer, pageContainer.setTocHtml),
-          new HeaderLineNumberConfigurer(appContainer),
-          new HeaderWithEditLinkConfigurer(appContainer),
-          new TableWithHandsontableButtonConfigurer(appContainer),
+          new FooternoteConfigurer(),
+          new TocAndAnchorConfigurer(),
+          new HeaderLineNumberConfigurer(),
+          new HeaderWithEditLinkConfigurer(),
+          new TableWithHandsontableButtonConfigurer(),
         ]);
         ]);
         break;
         break;
       }
       }
       case 'editor':
       case 'editor':
         this.markdownItConfigurers = this.markdownItConfigurers.concat([
         this.markdownItConfigurers = this.markdownItConfigurers.concat([
-          new FooternoteConfigurer(appContainer),
-          new HeaderLineNumberConfigurer(appContainer),
-          new TableConfigurer(appContainer),
+          new FooternoteConfigurer(),
+          new HeaderLineNumberConfigurer(),
+          new TableConfigurer(),
         ]);
         ]);
         break;
         break;
       // case 'comment':
       // case 'comment':
       //   break;
       //   break;
       default:
       default:
         this.markdownItConfigurers = this.markdownItConfigurers.concat([
         this.markdownItConfigurers = this.markdownItConfigurers.concat([
-          new TableConfigurer(appContainer),
+          new TableConfigurer(),
         ]);
         ]);
         break;
         break;
     }
     }

+ 7 - 0
packages/app/src/client/util/apiv1-client.ts

@@ -58,6 +58,13 @@ export async function apiPost(path: string, params: any & ParamWithCsrfKey = {})
   return apiRequest('post', path, params);
   return apiRequest('post', path, params);
 }
 }
 
 
+export async function apiPostForm(path: string, formData: FormData): Promise<unknown> {
+  if (formData.get('_csrf') == null && csrfToken != null) {
+    formData.append('_csrf', csrfToken);
+  }
+  return apiPost(path, formData);
+}
+
 // eslint-disable-next-line @typescript-eslint/no-explicit-any
 // eslint-disable-next-line @typescript-eslint/no-explicit-any
 export async function apiDelete(path: string, params: any & ParamWithCsrfKey = {}): Promise<unknown> {
 export async function apiDelete(path: string, params: any & ParamWithCsrfKey = {}): Promise<unknown> {
   if (params._csrf == null) {
   if (params._csrf == null) {

+ 12 - 3
packages/app/src/client/util/apiv3-client.ts

@@ -1,11 +1,12 @@
+// eslint-disable-next-line no-restricted-imports
+import { AxiosResponse } from 'axios';
 import * as urljoin from 'url-join';
 import * as urljoin from 'url-join';
 
 
 // eslint-disable-next-line no-restricted-imports
 // eslint-disable-next-line no-restricted-imports
-import { AxiosResponse } from 'axios';
 
 
-import loggerFactory from '~/utils/logger';
-import axios from '~/utils/axios';
 import { toArrayIfNot } from '~/utils/array-utils';
 import { toArrayIfNot } from '~/utils/array-utils';
+import axios from '~/utils/axios';
+import loggerFactory from '~/utils/logger';
 
 
 const apiv3Root = '/_api/v3';
 const apiv3Root = '/_api/v3';
 
 
@@ -57,6 +58,14 @@ export async function apiv3Post<T = any>(path: string, params: any & ParamWithCs
   return apiv3Request('post', path, params);
   return apiv3Request('post', path, params);
 }
 }
 
 
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+export async function apiv3PostForm<T = any>(path: string, formData: FormData): Promise<AxiosResponse<T>> {
+  if (formData.get('_csrf') == null && csrfToken != null) {
+    formData.append('_csrf', csrfToken);
+  }
+  return apiv3Post<T>(path, formData);
+}
+
 // eslint-disable-next-line @typescript-eslint/no-explicit-any
 // eslint-disable-next-line @typescript-eslint/no-explicit-any
 export async function apiv3Put<T = any>(path: string, params: any & ParamWithCsrfKey = {}): Promise<AxiosResponse<T>> {
 export async function apiv3Put<T = any>(path: string, params: any & ParamWithCsrfKey = {}): Promise<AxiosResponse<T>> {
   if (params._csrf == null) {
   if (params._csrf == null) {

+ 4 - 4
packages/app/src/client/util/editor.ts

@@ -5,8 +5,8 @@ type OptionsToSave = {
   slackChannels: string;
   slackChannels: string;
   grant: number;
   grant: number;
   pageTags: string[] | null;
   pageTags: string[] | null;
-  grantUserGroupId: string | null;
-  grantUserGroupName: string | null;
+  grantUserGroupId?: string | null;
+  grantUserGroupName?: string | null;
 };
 };
 
 
 // TODO: Remove editorContainer upon migration to SWR
 // TODO: Remove editorContainer upon migration to SWR
@@ -14,8 +14,8 @@ export const getOptionsToSave = (
     isSlackEnabled: boolean,
     isSlackEnabled: boolean,
     slackChannels: string,
     slackChannels: string,
     grant: number,
     grant: number,
-    grantUserGroupId: string | null,
-    grantUserGroupName: string | null,
+    grantUserGroupId: string | null | undefined,
+    grantUserGroupName: string | null | undefined,
     editorContainer: EditorContainer,
     editorContainer: EditorContainer,
 ): OptionsToSave => {
 ): OptionsToSave => {
   const optionsToSave = editorContainer.getCurrentOptionsToSave();
   const optionsToSave = editorContainer.getCurrentOptionsToSave();

+ 3 - 2
packages/app/src/client/util/i18n.js

@@ -1,7 +1,8 @@
 import i18n from 'i18next';
 import i18n from 'i18next';
 import LanguageDetector from 'i18next-browser-languagedetector';
 import LanguageDetector from 'i18next-browser-languagedetector';
 import { initReactI18next } from 'react-i18next';
 import { initReactI18next } from 'react-i18next';
-import locales from '^/resource/locales';
+
+import locales from '^/public/static/locales';
 
 
 const aliasesMapping = {};
 const aliasesMapping = {};
 Object.values(locales).forEach((locale) => {
 Object.values(locales).forEach((locale) => {
@@ -13,7 +14,7 @@ Object.values(locales).forEach((locale) => {
   });
   });
 });
 });
 
 
-// extract metadata list from 'resource/locales/${locale}/meta.json'
+// extract metadata list from 'public/static/locales/${locale}/meta.json'
 export const localeMetadatas = Object.values(locales).map(locale => locale.meta);
 export const localeMetadatas = Object.values(locales).map(locale => locale.meta);
 
 
 export const i18nFactory = (userLocaleId) => {
 export const i18nFactory = (userLocaleId) => {

+ 2 - 8
packages/app/src/client/util/interceptor/detach-code-blocks.js

@@ -15,12 +15,9 @@ class DetachCodeBlockUtil {
  */
  */
 export class DetachCodeBlockInterceptor extends BasicInterceptor {
 export class DetachCodeBlockInterceptor extends BasicInterceptor {
 
 
-  constructor(crowi) {
+  constructor() {
     super();
     super();
     this.logger = loggerFactory('growi:interceptor:DetachCodeBlockInterceptor');
     this.logger = loggerFactory('growi:interceptor:DetachCodeBlockInterceptor');
-
-    this.crowi = crowi;
-    this.crowiForJquery = crowi.getCrowiForJquery();
   }
   }
 
 
   /**
   /**
@@ -94,12 +91,9 @@ export class DetachCodeBlockInterceptor extends BasicInterceptor {
  */
  */
 export class RestoreCodeBlockInterceptor extends BasicInterceptor {
 export class RestoreCodeBlockInterceptor extends BasicInterceptor {
 
 
-  constructor(crowi) {
+  constructor() {
     super();
     super();
     this.logger = loggerFactory('growi:interceptor:DetachCodeBlockInterceptor');
     this.logger = loggerFactory('growi:interceptor:DetachCodeBlockInterceptor');
-
-    this.crowi = crowi;
-    this.crowiForJquery = crowi.getCrowiForJquery();
   }
   }
 
 
   /**
   /**

+ 6 - 10
packages/app/src/client/util/interceptor/drawio-interceptor.js

@@ -3,7 +3,6 @@ import React from 'react';
 
 
 import { BasicInterceptor } from '@growi/core';
 import { BasicInterceptor } from '@growi/core';
 import ReactDOM from 'react-dom';
 import ReactDOM from 'react-dom';
-import { Provider } from 'unstated';
 
 
 import Drawio from '~/components/Drawio';
 import Drawio from '~/components/Drawio';
 
 
@@ -14,11 +13,10 @@ import Drawio from '~/components/Drawio';
  */
  */
 export class DrawioInterceptor extends BasicInterceptor {
 export class DrawioInterceptor extends BasicInterceptor {
 
 
-  constructor(appContainer) {
+  constructor() {
     super();
     super();
 
 
     this.previousPreviewContext = null;
     this.previousPreviewContext = null;
-    this.appContainer = appContainer;
   }
   }
 
 
   /**
   /**
@@ -125,13 +123,11 @@ export class DrawioInterceptor extends BasicInterceptor {
   renderReactDOM(drawioMapEntry, elem, isPreview) {
   renderReactDOM(drawioMapEntry, elem, isPreview) {
     ReactDOM.render(
     ReactDOM.render(
       // eslint-disable-next-line react/jsx-filename-extension
       // eslint-disable-next-line react/jsx-filename-extension
-      <Provider inject={[this.appContainer]}>
-        <Drawio
-          drawioContent={drawioMapEntry.contentHtml}
-          isPreview={isPreview}
-          rangeLineNumberOfMarkdown={drawioMapEntry.rangeLineNumberOfMarkdown}
-        />
-      </Provider>,
+      <Drawio
+        drawioContent={drawioMapEntry.contentHtml}
+        isPreview={isPreview}
+        rangeLineNumberOfMarkdown={drawioMapEntry.rangeLineNumberOfMarkdown}
+      />,
       elem,
       elem,
     );
     );
   }
   }

+ 0 - 4
packages/app/src/client/util/markdown-it/footernote.js

@@ -1,9 +1,5 @@
 export default class FooternoteConfigurer {
 export default class FooternoteConfigurer {
 
 
-  constructor(crowi) {
-    this.crowi = crowi;
-  }
-
   configure(md) {
   configure(md) {
     md.use(require('markdown-it-footnote'));
     md.use(require('markdown-it-footnote'));
   }
   }

+ 1 - 2
packages/app/src/client/util/markdown-it/header-line-number.js

@@ -1,7 +1,6 @@
 export default class HeaderLineNumberConfigurer {
 export default class HeaderLineNumberConfigurer {
 
 
-  constructor(crowi) {
-    this.crowi = crowi;
+  constructor() {
     this.firstLine = 0;
     this.firstLine = 0;
   }
   }
 
 

+ 1 - 5
packages/app/src/client/util/markdown-it/header-with-edit-link.js

@@ -1,13 +1,9 @@
 export default class HeaderWithEditLinkConfigurer {
 export default class HeaderWithEditLinkConfigurer {
 
 
-  constructor(crowi) {
-    this.crowi = crowi;
-  }
-
   configure(md) {
   configure(md) {
     md.renderer.rules.heading_close = (tokens, idx) => {
     md.renderer.rules.heading_close = (tokens, idx) => {
       return `<span class="revision-head-edit-button">
       return `<span class="revision-head-edit-button">
-                <a href="#edit" onClick="Crowi.setCaretLineData(parseInt(this.parentNode.parentNode.dataset.line, 10))">
+                <a href="#edit" onClick="Crowi.setCaretLine(parseInt(this.parentNode.parentNode.dataset.line, 10))">
                   <i class="icon-note"></i>
                   <i class="icon-note"></i>
                 </a>
                 </a>
               </span></${tokens[idx].tag}>`;
               </span></${tokens[idx].tag}>`;

+ 1 - 3
packages/app/src/client/util/markdown-it/header.js

@@ -1,8 +1,6 @@
 export default class HeaderConfigurer {
 export default class HeaderConfigurer {
 
 
-  constructor(crowi) {
-    this.crowi = crowi;
-
+  constructor() {
     this.injectRevisionHeadClass = this.injectRevisionHeadClass.bind(this);
     this.injectRevisionHeadClass = this.injectRevisionHeadClass.bind(this);
   }
   }
 
 

+ 1 - 5
packages/app/src/client/util/markdown-it/table-with-handsontable-button.js

@@ -1,15 +1,11 @@
 export default class TableWithHandsontableButtonConfigurer {
 export default class TableWithHandsontableButtonConfigurer {
 
 
-  constructor(crowi) {
-    this.crowi = crowi;
-  }
-
   configure(md) {
   configure(md) {
     md.renderer.rules.table_open = (tokens, idx) => {
     md.renderer.rules.table_open = (tokens, idx) => {
       const beginLine = tokens[idx].map[0] + 1;
       const beginLine = tokens[idx].map[0] + 1;
       const endLine = tokens[idx].map[1];
       const endLine = tokens[idx].map[1];
       // eslint-disable-next-line max-len
       // eslint-disable-next-line max-len
-      return `<div class="editable-with-handsontable"><button class="handsontable-modal-trigger" onClick="crowi.launchHandsontableModal('page', ${beginLine}, ${endLine})"><i class="icon-note"></i></button><table class="table table-bordered">`;
+      return `<div class="editable-with-handsontable"><button class="handsontable-modal-trigger" onClick="globalEmitter.emit('launchHandsontableModal', ${beginLine}, ${endLine})"><i class="icon-note"></i></button><table class="table table-bordered">`;
     };
     };
 
 
     md.renderer.rules.table_close = (tokens, idx) => {
     md.renderer.rules.table_close = (tokens, idx) => {

+ 0 - 4
packages/app/src/client/util/markdown-it/table.js

@@ -1,9 +1,5 @@
 export default class TableConfigurer {
 export default class TableConfigurer {
 
 
-  constructor(crowi) {
-    this.crowi = crowi;
-  }
-
   configure(md) {
   configure(md) {
     md.renderer.rules.table_open = (tokens, idx) => {
     md.renderer.rules.table_open = (tokens, idx) => {
       return '<table class="table table-bordered">';
       return '<table class="table table-bordered">';

+ 6 - 12
packages/app/src/client/util/markdown-it/toc-and-anchor.js

@@ -5,11 +5,6 @@ import { emojiMartData } from './emoji-mart-data';
 
 
 export default class TocAndAnchorConfigurer {
 export default class TocAndAnchorConfigurer {
 
 
-  constructor(crowi, setHtml) {
-    this.crowi = crowi;
-    this.setHtml = setHtml;
-  }
-
   configure(md) {
   configure(md) {
     md.use(markdownItEmojiMart, { defs: emojiMartData })
     md.use(markdownItEmojiMart, { defs: emojiMartData })
       .use(markdownItToc, {
       .use(markdownItToc, {
@@ -21,13 +16,12 @@ export default class TocAndAnchorConfigurer {
       });
       });
 
 
     // set toc render function
     // set toc render function
-    if (this.setHtml != null) {
-      md.set({
-        tocCallback: (tocMarkdown, tocArray, tocHtml) => {
-          this.setHtml(tocHtml);
-        },
-      });
-    }
+    md.set({
+      tocCallback: (tocMarkdown, tocArray, tocHtml) => {
+        // eslint-disable-next-line no-undef
+        globalEmitter.emit('renderTocHtml', tocHtml);
+      },
+    });
   }
   }
 
 
 }
 }

+ 2 - 2
packages/app/src/client/util/reveal/plugins/growi-renderer.js

@@ -28,7 +28,7 @@
       const section = sections[i];
       const section = sections[i];
       const markdown = marked.getMarkdownFromSlide(section);
       const markdown = marked.getMarkdownFromSlide(section);
       const context = { markdown };
       const context = { markdown };
-      const interceptorManager = appContainer.interceptorManager;
+      const { interceptorManager } = window.parent;
       let dataSeparator = section.getAttribute('data-separator') || DEFAULT_SLIDE_SEPARATOR;
       let dataSeparator = section.getAttribute('data-separator') || DEFAULT_SLIDE_SEPARATOR;
       // replace string '\n' to LF code.
       // replace string '\n' to LF code.
       dataSeparator = dataSeparator.replace(/\\n/g, '\n');
       dataSeparator = dataSeparator.replace(/\\n/g, '\n');
@@ -51,7 +51,7 @@
   function convertSlides() {
   function convertSlides() {
     const sections = document.querySelectorAll('[data-markdown]');
     const sections = document.querySelectorAll('[data-markdown]');
     let markdown;
     let markdown;
-    const interceptorManager = appContainer.interceptorManager;
+    const { interceptorManager } = window.parent;
 
 
     for (let i = 0, len = sections.length; i < len; i++) {
     for (let i = 0, len = sections.length; i < len; i++) {
       const section = sections[i];
       const section = sections[i];

+ 8 - 8
packages/app/src/components/Admin/App/AwsSetting.jsx

@@ -1,14 +1,16 @@
 import React from 'react';
 import React from 'react';
+
 import PropTypes from 'prop-types';
 import PropTypes from 'prop-types';
-import { withTranslation } from 'react-i18next';
+import { useTranslation } from 'react-i18next';
+
+import AdminAppContainer from '~/client/services/AdminAppContainer';
 
 
 import { withUnstatedContainers } from '../../UnstatedUtils';
 import { withUnstatedContainers } from '../../UnstatedUtils';
 
 
-import AppContainer from '~/client/services/AppContainer';
-import AdminAppContainer from '~/client/services/AdminAppContainer';
 
 
 function AwsSetting(props) {
 function AwsSetting(props) {
-  const { t, adminAppContainer } = props;
+  const { t } = useTranslation();
+  const { adminAppContainer } = props;
   const { s3ReferenceFileWithRelayMode } = adminAppContainer.state;
   const { s3ReferenceFileWithRelayMode } = adminAppContainer.state;
 
 
   return (
   return (
@@ -150,12 +152,10 @@ function AwsSetting(props) {
 /**
 /**
  * Wrapper component for using unstated
  * Wrapper component for using unstated
  */
  */
-const AwsSettingWrapper = withUnstatedContainers(AwsSetting, [AppContainer, AdminAppContainer]);
+const AwsSettingWrapper = withUnstatedContainers(AwsSetting, [AdminAppContainer]);
 
 
 AwsSetting.propTypes = {
 AwsSetting.propTypes = {
-  t: PropTypes.func.isRequired, // i18next
-  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
   adminAppContainer: PropTypes.instanceOf(AdminAppContainer).isRequired,
   adminAppContainer: PropTypes.instanceOf(AdminAppContainer).isRequired,
 };
 };
 
 
-export default withTranslation()(AwsSettingWrapper);
+export default AwsSettingWrapper;

+ 8 - 3
packages/app/src/components/Admin/ExportArchiveDataPage.jsx

@@ -1,7 +1,7 @@
 import React, { Fragment } from 'react';
 import React, { Fragment } from 'react';
 
 
 import PropTypes from 'prop-types';
 import PropTypes from 'prop-types';
-import { withTranslation } from 'react-i18next';
+import { useTranslation } from 'react-i18next';
 import * as toastr from 'toastr';
 import * as toastr from 'toastr';
 
 
 
 
@@ -254,9 +254,14 @@ ExportArchiveDataPage.propTypes = {
   adminSocketIoContainer: PropTypes.instanceOf(AdminSocketIoContainer).isRequired,
   adminSocketIoContainer: PropTypes.instanceOf(AdminSocketIoContainer).isRequired,
 };
 };
 
 
+const ExportArchiveDataPageWrapperFC = (props) => {
+  const { t } = useTranslation();
+  return <ExportArchiveDataPage t={t} {...props} />;
+};
+
 /**
 /**
  * Wrapper component for using unstated
  * Wrapper component for using unstated
  */
  */
-const ExportArchiveDataPageWrapper = withUnstatedContainers(ExportArchiveDataPage, [AppContainer, AdminSocketIoContainer]);
+const ExportArchiveDataPageWrapper = withUnstatedContainers(ExportArchiveDataPageWrapperFC, [AppContainer, AdminSocketIoContainer]);
 
 
-export default withTranslation()(ExportArchiveDataPageWrapper);
+export default ExportArchiveDataPageWrapper;

+ 0 - 33
packages/app/src/components/Admin/FullTextSearchManagement.jsx

@@ -1,33 +0,0 @@
-import React, { Fragment } from 'react';
-import PropTypes from 'prop-types';
-import { withTranslation } from 'react-i18next';
-
-import { withUnstatedContainers } from '../UnstatedUtils';
-import AppContainer from '~/client/services/AppContainer';
-
-import ElasticsearchManagement from './ElasticsearchManagement/ElasticsearchManagement';
-
-
-class FullTextSearchManagement extends React.Component {
-
-  render() {
-    const { t } = this.props;
-
-    return (
-      <div data-testid="admin-full-text-search">
-        <h2> { t('full_text_search_management.elasticsearch_management') } </h2>
-        <ElasticsearchManagement />
-      </div>
-    );
-  }
-
-}
-
-const FullTextSearchManagementWrapper = withUnstatedContainers(FullTextSearchManagement, [AppContainer]);
-
-FullTextSearchManagement.propTypes = {
-  t: PropTypes.func.isRequired, // i18next
-  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
-};
-
-export default withTranslation()(FullTextSearchManagementWrapper);

+ 22 - 0
packages/app/src/components/Admin/FullTextSearchManagement.tsx

@@ -0,0 +1,22 @@
+import React, { FC } from 'react';
+
+import { useTranslation } from 'react-i18next';
+
+import ElasticsearchManagement from './ElasticsearchManagement/ElasticsearchManagement';
+
+type Props = {
+
+};
+
+const FullTextSearchManagement: FC<Props> = () => {
+  const { t } = useTranslation();
+
+  return (
+    <div data-testid="admin-full-text-search">
+      <h2> { t('full_text_search_management.elasticsearch_management') } </h2>
+      <ElasticsearchManagement />
+    </div>
+  );
+};
+
+export default FullTextSearchManagement;

+ 3 - 13
packages/app/src/components/Admin/ImportData/GrowiArchive/UploadForm.jsx

@@ -3,11 +3,8 @@ import React from 'react';
 import PropTypes from 'prop-types';
 import PropTypes from 'prop-types';
 import { withTranslation } from 'react-i18next';
 import { withTranslation } from 'react-i18next';
 
 
-import AppContainer from '~/client/services/AppContainer';
 import { toastError } from '~/client/util/apiNotification';
 import { toastError } from '~/client/util/apiNotification';
-import { apiv3Post } from '~/client/util/apiv3-client';
-
-import { withUnstatedContainers } from '../../../UnstatedUtils';
+import { apiv3PostForm } from '~/client/util/apiv3-client';
 
 
 class UploadForm extends React.Component {
 class UploadForm extends React.Component {
 
 
@@ -31,11 +28,10 @@ class UploadForm extends React.Component {
     e.preventDefault();
     e.preventDefault();
 
 
     const formData = new FormData();
     const formData = new FormData();
-    formData.append('_csrf', this.props.appContainer.csrfToken);
     formData.append('file', this.inputRef.current.files[0]);
     formData.append('file', this.inputRef.current.files[0]);
 
 
     try {
     try {
-      const { data } = await apiv3Post('/import/upload', formData);
+      const { data } = await apiv3PostForm('/import/upload', formData);
       // TODO: toastSuccess, toastError
       // TODO: toastSuccess, toastError
       this.props.onUpload(data);
       this.props.onUpload(data);
     }
     }
@@ -96,15 +92,9 @@ class UploadForm extends React.Component {
 
 
 UploadForm.propTypes = {
 UploadForm.propTypes = {
   t: PropTypes.func.isRequired, // i18next
   t: PropTypes.func.isRequired, // i18next
-  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
   onUpload: PropTypes.func.isRequired,
   onUpload: PropTypes.func.isRequired,
   isTheSameVersion: PropTypes.bool,
   isTheSameVersion: PropTypes.bool,
   onVersionMismatch: PropTypes.func,
   onVersionMismatch: PropTypes.func,
 };
 };
 
 
-/**
- * Wrapper component for using unstated
- */
-const UploadFormWrapper = withUnstatedContainers(UploadForm, [AppContainer]);
-
-export default withTranslation()(UploadFormWrapper);
+export default withTranslation()(UploadForm);

+ 13 - 7
packages/app/src/components/Admin/ManageExternalAccount.jsx

@@ -1,14 +1,16 @@
 import React, { Fragment } from 'react';
 import React, { Fragment } from 'react';
+
 import PropTypes from 'prop-types';
 import PropTypes from 'prop-types';
-import { withTranslation } from 'react-i18next';
+import { useTranslation } from 'react-i18next';
 
 
-import PaginationWrapper from '../PaginationWrapper';
+import AdminExternalAccountsContainer from '~/client/services/AdminExternalAccountsContainer';
+import AppContainer from '~/client/services/AppContainer';
+import { toastError } from '~/client/util/apiNotification';
 
 
+import PaginationWrapper from '../PaginationWrapper';
 import { withUnstatedContainers } from '../UnstatedUtils';
 import { withUnstatedContainers } from '../UnstatedUtils';
-import AppContainer from '~/client/services/AppContainer';
-import AdminExternalAccountsContainer from '~/client/services/AdminExternalAccountsContainer';
+
 import ExternalAccountTable from './Users/ExternalAccountTable';
 import ExternalAccountTable from './Users/ExternalAccountTable';
-import { toastError } from '~/client/util/apiNotification';
 
 
 
 
 class ManageExternalAccount extends React.Component {
 class ManageExternalAccount extends React.Component {
@@ -82,7 +84,11 @@ ManageExternalAccount.propTypes = {
   adminExternalAccountsContainer: PropTypes.instanceOf(AdminExternalAccountsContainer).isRequired,
   adminExternalAccountsContainer: PropTypes.instanceOf(AdminExternalAccountsContainer).isRequired,
 };
 };
 
 
-const ManageExternalAccountWrapper = withUnstatedContainers(ManageExternalAccount, [AppContainer, AdminExternalAccountsContainer]);
+const ManageExternalAccountWrapperFC = (props) => {
+  const { t } = useTranslation();
+  return <ManageExternalAccount t={t} {...props} />;
+};
 
 
+const ManageExternalAccountWrapper = withUnstatedContainers(ManageExternalAccountWrapperFC, [AppContainer, AdminExternalAccountsContainer]);
 
 
-export default withTranslation()(ManageExternalAccountWrapper);
+export default ManageExternalAccountWrapper;

+ 11 - 5
packages/app/src/components/Admin/UserGroupDetail/UserGroupUserFormByInput.jsx

@@ -1,15 +1,17 @@
 import React from 'react';
 import React from 'react';
-import PropTypes from 'prop-types';
-import { withTranslation } from 'react-i18next';
 
 
+import { UserPicture } from '@growi/ui';
+import PropTypes from 'prop-types';
 import { AsyncTypeahead } from 'react-bootstrap-typeahead';
 import { AsyncTypeahead } from 'react-bootstrap-typeahead';
+import { withTranslation } from 'react-i18next';
 import { debounce } from 'throttle-debounce';
 import { debounce } from 'throttle-debounce';
-import { UserPicture } from '@growi/ui';
-import { withUnstatedContainers } from '../../UnstatedUtils';
-import AppContainer from '~/client/services/AppContainer';
+
 import AdminUserGroupDetailContainer from '~/client/services/AdminUserGroupDetailContainer';
 import AdminUserGroupDetailContainer from '~/client/services/AdminUserGroupDetailContainer';
+import AppContainer from '~/client/services/AppContainer';
 import { toastSuccess, toastError } from '~/client/util/apiNotification';
 import { toastSuccess, toastError } from '~/client/util/apiNotification';
 
 
+import { withUnstatedContainers } from '../../UnstatedUtils';
+
 class UserGroupUserFormByInput extends React.Component {
 class UserGroupUserFormByInput extends React.Component {
 
 
   constructor(props) {
   constructor(props) {
@@ -44,12 +46,16 @@ class UserGroupUserFormByInput extends React.Component {
 
 
     try {
     try {
       await adminUserGroupDetailContainer.addUserByUsername(userName);
       await adminUserGroupDetailContainer.addUserByUsername(userName);
+      await adminUserGroupDetailContainer.init();
+      await adminUserGroupDetailContainer.closeUserGroupUserModal();
       toastSuccess(`Added "${this.xss.process(userName)}" to "${this.xss.process(userGroup.name)}"`);
       toastSuccess(`Added "${this.xss.process(userName)}" to "${this.xss.process(userGroup.name)}"`);
       this.setState({ inputUser: '' });
       this.setState({ inputUser: '' });
     }
     }
     catch (err) {
     catch (err) {
       toastError(new Error(`Unable to add "${this.xss.process(userName)}" to "${this.xss.process(userGroup.name)}"`));
       toastError(new Error(`Unable to add "${this.xss.process(userName)}" to "${this.xss.process(userGroup.name)}"`));
     }
     }
+
+
   }
   }
 
 
   validateForm() {
   validateForm() {

+ 0 - 79
packages/app/src/components/Admin/Users/RemoveAdminButton.jsx

@@ -1,79 +0,0 @@
-import React, { Fragment } from 'react';
-import PropTypes from 'prop-types';
-import { withTranslation } from 'react-i18next';
-
-import { withUnstatedContainers } from '../../UnstatedUtils';
-import AppContainer from '~/client/services/AppContainer';
-import { toastSuccess, toastError } from '~/client/util/apiNotification';
-import AdminUsersContainer from '~/client/services/AdminUsersContainer';
-
-class RemoveAdminButton extends React.Component {
-
-  constructor(props) {
-    super(props);
-
-    this.onClickRemoveAdminBtn = this.onClickRemoveAdminBtn.bind(this);
-  }
-
-  async onClickRemoveAdminBtn() {
-    const { t } = this.props;
-
-    try {
-      const username = await this.props.adminUsersContainer.removeUserAdmin(this.props.user._id);
-      toastSuccess(t('toaster.remove_user_admin', { username }));
-    }
-    catch (err) {
-      toastError(err);
-    }
-  }
-
-
-  renderRemoveAdminBtn() {
-    const { t } = this.props;
-
-    return (
-      <button className="dropdown-item" type="button" onClick={() => { this.onClickRemoveAdminBtn() }}>
-        <i className="icon-fw icon-user-unfollow"></i> {t('admin:user_management.user_table.remove_admin_access')}
-      </button>
-    );
-  }
-
-  renderRemoveAdminAlert() {
-    const { t } = this.props;
-
-    return (
-      <div className="px-4">
-        <i className="icon-fw icon-user-unfollow mb-2"></i>{t('admin:user_management.user_table.remove_admin_access')}
-        <p className="alert alert-danger">{t('admin:user_management.user_table.cannot_remove')}</p>
-      </div>
-    );
-  }
-
-  render() {
-    const { user } = this.props;
-    const { currentUsername } = this.props.appContainer;
-
-    return (
-      <Fragment>
-        {user.username !== currentUsername ? this.renderRemoveAdminBtn()
-          : this.renderRemoveAdminAlert()}
-      </Fragment>
-    );
-  }
-
-}
-
-/**
-* Wrapper component for using unstated
-*/
-const RemoveAdminButtonWrapper = withUnstatedContainers(RemoveAdminButton, [AppContainer, AdminUsersContainer]);
-
-RemoveAdminButton.propTypes = {
-  t: PropTypes.func.isRequired, // i18next
-  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
-  adminUsersContainer: PropTypes.instanceOf(AdminUsersContainer).isRequired,
-
-  user: PropTypes.object.isRequired,
-};
-
-export default withTranslation()(RemoveAdminButtonWrapper);

+ 62 - 0
packages/app/src/components/Admin/Users/RemoveAdminMenuItem.tsx

@@ -0,0 +1,62 @@
+import React, { useCallback, useMemo } from 'react';
+
+import { useTranslation } from 'react-i18next';
+
+import AdminUsersContainer from '~/client/services/AdminUsersContainer';
+import { toastSuccess, toastError } from '~/client/util/apiNotification';
+import { IUserHasId } from '~/interfaces/user';
+import { useCurrentUser } from '~/stores/context';
+
+import { withUnstatedContainers } from '../../UnstatedUtils';
+
+
+const RemoveAdminAlert = React.memo((): JSX.Element => {
+  const { t } = useTranslation();
+
+  return (
+    <div className="px-4">
+      <i className="icon-fw icon-user-unfollow mb-2"></i>{t('admin:user_management.user_table.remove_admin_access')}
+      <p className="alert alert-danger">{t('admin:user_management.user_table.cannot_remove')}</p>
+    </div>
+  );
+});
+
+
+type Props = {
+  adminUsersContainer: AdminUsersContainer,
+  user: IUserHasId,
+}
+
+const RemoveAdminMenuItem = (props: Props): JSX.Element => {
+  const { t } = useTranslation();
+
+  const { adminUsersContainer, user } = props;
+
+  const { data: currentUser } = useCurrentUser();
+
+  const clickRemoveAdminBtnHandler = useCallback(async() => {
+    try {
+      const username = await adminUsersContainer.removeUserAdmin(user._id);
+      toastSuccess(t('toaster.remove_user_admin', { username }));
+    }
+    catch (err) {
+      toastError(err);
+    }
+  }, [adminUsersContainer, t, user._id]);
+
+
+  return user.username !== currentUser?.username
+    ? (
+      <button className="dropdown-item" type="button" onClick={clickRemoveAdminBtnHandler}>
+        <i className="icon-fw icon-user-unfollow"></i> {t('admin:user_management.user_table.remove_admin_access')}
+      </button>
+    )
+    : <RemoveAdminAlert />;
+};
+
+/**
+* Wrapper component for using unstated
+*/
+const RemoveAdminMenuItemWrapper = withUnstatedContainers(RemoveAdminMenuItem, [AdminUsersContainer]);
+
+export default RemoveAdminMenuItemWrapper;

+ 60 - 0
packages/app/src/components/Admin/Users/StatusSuspendMenuItem.tsx

@@ -0,0 +1,60 @@
+import React, { useCallback } from 'react';
+
+import { useTranslation } from 'react-i18next';
+
+import AdminUsersContainer from '~/client/services/AdminUsersContainer';
+import { toastSuccess, toastError } from '~/client/util/apiNotification';
+import { withUnstatedContainers } from '~/components/UnstatedUtils';
+import { IUserHasId } from '~/interfaces/user';
+import { useCurrentUser } from '~/stores/context';
+
+
+const SuspendAlert = React.memo((): JSX.Element => {
+  const { t } = useTranslation();
+
+  return (
+    <div className="px-4">
+      <i className="icon-fw icon-ban mb-2"></i>{t('admin:user_management.user_table.deactivate_account')}
+      <p className="alert alert-danger">{t('admin:user_management.user_table.your_own')}</p>
+    </div>
+  );
+});
+
+
+type Props = {
+  adminUsersContainer: AdminUsersContainer,
+  user: IUserHasId,
+}
+
+const StatusSuspendMenuItem = (props: Props): JSX.Element => {
+  const { t } = useTranslation();
+
+  const { adminUsersContainer, user } = props;
+
+  const { data: currentUser } = useCurrentUser();
+
+  const clickDeactiveBtnHandler = useCallback(async() => {
+    try {
+      const username = await adminUsersContainer.deactivateUser(user._id);
+      toastSuccess(t('toaster.deactivate_user_success', { username }));
+    }
+    catch (err) {
+      toastError(err);
+    }
+  }, [adminUsersContainer, t, user._id]);
+
+  return user.username !== currentUser?.username
+    ? (
+      <button className="dropdown-item" type="button" onClick={clickDeactiveBtnHandler}>
+        <i className="icon-fw icon-ban"></i> {t('admin:user_management.user_table.deactivate_account')}
+      </button>
+    )
+    : <SuspendAlert />;
+};
+
+/**
+ * Wrapper component for using unstated
+ */
+const StatusSuspendMenuItemWrapper = withUnstatedContainers(StatusSuspendMenuItem, [AdminUsersContainer]);
+
+export default StatusSuspendMenuItemWrapper;

+ 0 - 78
packages/app/src/components/Admin/Users/StatusSuspendedButton.jsx

@@ -1,78 +0,0 @@
-import React, { Fragment } from 'react';
-import PropTypes from 'prop-types';
-import { withTranslation } from 'react-i18next';
-
-import { withUnstatedContainers } from '../../UnstatedUtils';
-import AppContainer from '~/client/services/AppContainer';
-import { toastSuccess, toastError } from '~/client/util/apiNotification';
-import AdminUsersContainer from '~/client/services/AdminUsersContainer';
-
-class StatusSuspendedButton extends React.Component {
-
-  constructor(props) {
-    super(props);
-
-    this.onClickDeactiveBtn = this.onClickDeactiveBtn.bind(this);
-  }
-
-  async onClickDeactiveBtn() {
-    const { t } = this.props;
-
-    try {
-      const username = await this.props.adminUsersContainer.deactivateUser(this.props.user._id);
-      toastSuccess(t('toaster.deactivate_user_success', { username }));
-    }
-    catch (err) {
-      toastError(err);
-    }
-  }
-
-  renderSuspendedBtn() {
-    const { t } = this.props;
-
-    return (
-      <button className="dropdown-item" type="button" onClick={() => { this.onClickDeactiveBtn() }}>
-        <i className="icon-fw icon-ban"></i> {t('admin:user_management.user_table.deactivate_account')}
-      </button>
-    );
-  }
-
-  renderSuspendedAlert() {
-    const { t } = this.props;
-
-    return (
-      <div className="px-4">
-        <i className="icon-fw icon-ban mb-2"></i>{t('admin:user_management.user_table.deactivate_account')}
-        <p className="alert alert-danger">{t('admin:user_management.user_table.your_own')}</p>
-      </div>
-    );
-  }
-
-  render() {
-    const { user } = this.props;
-    const { currentUsername } = this.props.appContainer;
-
-    return (
-      <Fragment>
-        {user.username !== currentUsername ? this.renderSuspendedBtn()
-          : this.renderSuspendedAlert()}
-      </Fragment>
-    );
-  }
-
-}
-
-/**
- * Wrapper component for using unstated
- */
-const StatusSuspendedFormWrapper = withUnstatedContainers(StatusSuspendedButton, [AppContainer, AdminUsersContainer]);
-
-StatusSuspendedButton.propTypes = {
-  t: PropTypes.func.isRequired, // i18next
-  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
-  adminUsersContainer: PropTypes.instanceOf(AdminUsersContainer).isRequired,
-
-  user: PropTypes.object.isRequired,
-};
-
-export default withTranslation()(StatusSuspendedFormWrapper);

+ 12 - 9
packages/app/src/components/Admin/Users/UserMenu.jsx

@@ -1,20 +1,23 @@
 import React, { Fragment } from 'react';
 import React, { Fragment } from 'react';
+
 import PropTypes from 'prop-types';
 import PropTypes from 'prop-types';
 import { withTranslation } from 'react-i18next';
 import { withTranslation } from 'react-i18next';
 import {
 import {
   UncontrolledDropdown, DropdownToggle, DropdownMenu,
   UncontrolledDropdown, DropdownToggle, DropdownMenu,
 } from 'reactstrap';
 } from 'reactstrap';
 
 
-import StatusActivateButton from './StatusActivateButton';
-import StatusSuspendedButton from './StatusSuspendedButton';
-import UserRemoveButton from './UserRemoveButton';
-import RemoveAdminButton from './RemoveAdminButton';
+import AdminUsersContainer from '~/client/services/AdminUsersContainer';
+import AppContainer from '~/client/services/AppContainer';
+
+import { withUnstatedContainers } from '../../UnstatedUtils';
+
 import GiveAdminButton from './GiveAdminButton';
 import GiveAdminButton from './GiveAdminButton';
+import RemoveAdminMenuItem from './RemoveAdminMenuItem';
 import SendInvitationEmailButton from './SendInvitationEmailButton';
 import SendInvitationEmailButton from './SendInvitationEmailButton';
+import StatusActivateButton from './StatusActivateButton';
+import StatusSuspendedMenuItem from './StatusSuspendMenuItem';
+import UserRemoveButton from './UserRemoveButton';
 
 
-import { withUnstatedContainers } from '../../UnstatedUtils';
-import AppContainer from '~/client/services/AppContainer';
-import AdminUsersContainer from '~/client/services/AdminUsersContainer';
 
 
 class UserMenu extends React.Component {
 class UserMenu extends React.Component {
 
 
@@ -63,7 +66,7 @@ class UserMenu extends React.Component {
         <li className="dropdown-header">{t('status')}</li>
         <li className="dropdown-header">{t('status')}</li>
         <li>
         <li>
           {(user.status === 1 || user.status === 3) && <StatusActivateButton user={user} />}
           {(user.status === 1 || user.status === 3) && <StatusActivateButton user={user} />}
-          {user.status === 2 && <StatusSuspendedButton user={user} />}
+          {user.status === 2 && <StatusSuspendedMenuItem user={user} />}
           {user.status === 5 && (
           {user.status === 5 && (
             <SendInvitationEmailButton
             <SendInvitationEmailButton
               user={user}
               user={user}
@@ -85,7 +88,7 @@ class UserMenu extends React.Component {
         <li className="dropdown-divider pl-0"></li>
         <li className="dropdown-divider pl-0"></li>
         <li className="dropdown-header">{t('admin:user_management.user_table.administrator_menu')}</li>
         <li className="dropdown-header">{t('admin:user_management.user_table.administrator_menu')}</li>
         <li>
         <li>
-          {user.admin === true && <RemoveAdminButton user={user} />}
+          {user.admin === true && <RemoveAdminMenuItem user={user} />}
           {user.admin === false && <GiveAdminButton user={user} />}
           {user.admin === false && <GiveAdminButton user={user} />}
         </li>
         </li>
       </Fragment>
       </Fragment>

+ 9 - 6
packages/app/src/components/ArchiveCreateModal.jsx

@@ -1,7 +1,7 @@
 import React, { useState, useCallback } from 'react';
 import React, { useState, useCallback } from 'react';
 
 
 import PropTypes from 'prop-types';
 import PropTypes from 'prop-types';
-import { withTranslation } from 'react-i18next';
+import { useTranslation } from 'react-i18next';
 import {
 import {
   Modal, ModalHeader, ModalBody, ModalFooter,
   Modal, ModalHeader, ModalBody, ModalFooter,
 } from 'reactstrap';
 } from 'reactstrap';
@@ -14,7 +14,8 @@ import { withUnstatedContainers } from './UnstatedUtils';
 
 
 
 
 const ArchiveCreateModal = (props) => {
 const ArchiveCreateModal = (props) => {
-  const { t, appContainer } = props;
+  const { t } = useTranslation();
+  const { appContainer } = props;
   const [isCommentDownload, setIsCommentDownload] = useState(false);
   const [isCommentDownload, setIsCommentDownload] = useState(false);
   const [isAttachmentFileDownload, setIsAttachmentFileDownload] = useState(false);
   const [isAttachmentFileDownload, setIsAttachmentFileDownload] = useState(false);
   const [isSubordinatedPageDownload, setIsSubordinatedPageDownload] = useState(false);
   const [isSubordinatedPageDownload, setIsSubordinatedPageDownload] = useState(false);
@@ -233,10 +234,7 @@ const ArchiveCreateModal = (props) => {
   );
   );
 };
 };
 
 
-const ArchiveCreateModalWrapper = withUnstatedContainers(ArchiveCreateModal, [AppContainer]);
-
 ArchiveCreateModal.propTypes = {
 ArchiveCreateModal.propTypes = {
-  t: PropTypes.func.isRequired, //  i18next
   appContainer: PropTypes.instanceOf(AppContainer).isRequired,
   appContainer: PropTypes.instanceOf(AppContainer).isRequired,
   isOpen: PropTypes.bool.isRequired,
   isOpen: PropTypes.bool.isRequired,
   onClose: PropTypes.func,
   onClose: PropTypes.func,
@@ -245,4 +243,9 @@ ArchiveCreateModal.propTypes = {
   errorMessage: PropTypes.string,
   errorMessage: PropTypes.string,
 };
 };
 
 
-export default withTranslation()(ArchiveCreateModalWrapper);
+/**
+ * Wrapper component for using unstated
+ */
+const ArchiveCreateModalWrapper = withUnstatedContainers(ArchiveCreateModal, [AppContainer]);
+
+export default ArchiveCreateModalWrapper;

+ 9 - 6
packages/app/src/components/Common/CountBadge.tsx

@@ -1,16 +1,19 @@
 import React, { FC } from 'react';
 import React, { FC } from 'react';
 
 
 type CountProps = {
 type CountProps = {
-  count: number
+  count?: number,
+  offset?: number,
 }
 }
 
 
 const CountBadge: FC<CountProps> = (props:CountProps) => {
 const CountBadge: FC<CountProps> = (props:CountProps) => {
+  const { count, offset = 0 } = props;
+
+
   return (
   return (
-    <>
-      <span className="grw-count-badge px-2 badge badge-pill badge-light">
-        {props.count}
-      </span>
-    </>
+    <span className="grw-count-badge px-2 badge badge-pill badge-light">
+      { count == null && <span className="text-muted">―</span> }
+      { count != null && count + offset }
+    </span>
   );
   );
 };
 };
 
 

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

@@ -8,6 +8,7 @@ import {
 import {
 import {
   IPageInfoAll, isIPageInfoForOperation,
   IPageInfoAll, isIPageInfoForOperation,
 } from '~/interfaces/page';
 } from '~/interfaces/page';
+import { IPageOperationProcessData } from '~/interfaces/page-operation';
 import { useSWRxPageInfo } from '~/stores/page';
 import { useSWRxPageInfo } from '~/stores/page';
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 
 
@@ -20,6 +21,7 @@ export const MenuItemType = {
   DUPLICATE: 'duplicate',
   DUPLICATE: 'duplicate',
   DELETE: 'delete',
   DELETE: 'delete',
   REVERT: 'revert',
   REVERT: 'revert',
+  PATH_RECOVERY: 'pathRecovery',
 } as const;
 } as const;
 export type MenuItemType = typeof MenuItemType[keyof typeof MenuItemType];
 export type MenuItemType = typeof MenuItemType[keyof typeof MenuItemType];
 
 
@@ -37,15 +39,18 @@ type CommonProps = {
   onClickDuplicateMenuItem?: (pageId: string) => Promise<void> | void,
   onClickDuplicateMenuItem?: (pageId: string) => Promise<void> | void,
   onClickDeleteMenuItem?: (pageId: string, pageInfo: IPageInfoAll | undefined) => Promise<void> | void,
   onClickDeleteMenuItem?: (pageId: string, pageInfo: IPageInfoAll | undefined) => Promise<void> | void,
   onClickRevertMenuItem?: (pageId: string) => Promise<void> | void,
   onClickRevertMenuItem?: (pageId: string) => Promise<void> | void,
+  onClickPathRecoveryMenuItem?: (pageId: string) => Promise<void> | void,
 
 
   additionalMenuItemRenderer?: React.FunctionComponent<AdditionalMenuItemsRendererProps>,
   additionalMenuItemRenderer?: React.FunctionComponent<AdditionalMenuItemsRendererProps>,
   isInstantRename?: boolean,
   isInstantRename?: boolean,
+  alignRight?: boolean,
 }
 }
 
 
 
 
 type DropdownMenuProps = CommonProps & {
 type DropdownMenuProps = CommonProps & {
   pageId: string,
   pageId: string,
   isLoading?: boolean,
   isLoading?: boolean,
+  operationProcessData?: IPageOperationProcessData,
 }
 }
 
 
 const PageItemControlDropdownMenu = React.memo((props: DropdownMenuProps): JSX.Element => {
 const PageItemControlDropdownMenu = React.memo((props: DropdownMenuProps): JSX.Element => {
@@ -53,9 +58,9 @@ const PageItemControlDropdownMenu = React.memo((props: DropdownMenuProps): JSX.E
 
 
   const {
   const {
     pageId, isLoading,
     pageId, isLoading,
-    pageInfo, isEnableActions, forceHideMenuItems,
-    onClickBookmarkMenuItem, onClickRenameMenuItem, onClickDuplicateMenuItem, onClickDeleteMenuItem, onClickRevertMenuItem,
-    additionalMenuItemRenderer: AdditionalMenuItems, isInstantRename,
+    pageInfo, isEnableActions, forceHideMenuItems, operationProcessData,
+    onClickBookmarkMenuItem, onClickRenameMenuItem, onClickDuplicateMenuItem, onClickDeleteMenuItem, onClickRevertMenuItem, onClickPathRecoveryMenuItem,
+    additionalMenuItemRenderer: AdditionalMenuItems, isInstantRename, alignRight,
   } = props;
   } = props;
 
 
 
 
@@ -107,6 +112,14 @@ const PageItemControlDropdownMenu = React.memo((props: DropdownMenuProps): JSX.E
     await onClickDeleteMenuItem(pageId, pageInfo);
     await onClickDeleteMenuItem(pageId, pageInfo);
   }, [onClickDeleteMenuItem, pageId, pageInfo]);
   }, [onClickDeleteMenuItem, pageId, pageInfo]);
 
 
+  // eslint-disable-next-line react-hooks/rules-of-hooks
+  const pathRecoveryItemClickedHandler = useCallback(async() => {
+    if (onClickPathRecoveryMenuItem == null) {
+      return;
+    }
+    await onClickPathRecoveryMenuItem(pageId);
+  }, [onClickPathRecoveryMenuItem, pageId]);
+
   let contents = <></>;
   let contents = <></>;
 
 
   if (isLoading) {
   if (isLoading) {
@@ -121,6 +134,10 @@ const PageItemControlDropdownMenu = React.memo((props: DropdownMenuProps): JSX.E
     const showDeviderBeforeAdditionalMenuItems = (forceHideMenuItems?.length ?? 0) < 3;
     const showDeviderBeforeAdditionalMenuItems = (forceHideMenuItems?.length ?? 0) < 3;
     const showDeviderBeforeDelete = AdditionalMenuItems != null || showDeviderBeforeAdditionalMenuItems;
     const showDeviderBeforeDelete = AdditionalMenuItems != null || showDeviderBeforeAdditionalMenuItems;
 
 
+    // PathRecovery
+    // Todo: It is wanted to find a better way to pass operationProcessData to PageItemControl
+    const shouldShowPathRecoveryButton = operationProcessData?.Rename != null ? operationProcessData?.Rename.isProcessable : false;
+
     contents = (
     contents = (
       <>
       <>
         { !isEnableActions && (
         { !isEnableActions && (
@@ -184,6 +201,17 @@ const PageItemControlDropdownMenu = React.memo((props: DropdownMenuProps): JSX.E
           </>
           </>
         ) }
         ) }
 
 
+        {/* PathRecovery */}
+        { !forceHideMenuItems?.includes(MenuItemType.PATH_RECOVERY) && isEnableActions && shouldShowPathRecoveryButton && (
+          <DropdownItem
+            onClick={pathRecoveryItemClickedHandler}
+            className="grw-page-control-dropdown-item"
+          >
+            <i className="icon-fw icon-wrench grw-page-control-dropdown-icon"></i>
+            {t('PathRecovery')}
+          </DropdownItem>
+        ) }
+
         {/* divider */}
         {/* divider */}
         {/* Delete */}
         {/* Delete */}
         { !forceHideMenuItems?.includes(MenuItemType.DELETE) && isEnableActions && pageInfo.isMovable && (
         { !forceHideMenuItems?.includes(MenuItemType.DELETE) && isEnableActions && pageInfo.isMovable && (
@@ -205,7 +233,7 @@ const PageItemControlDropdownMenu = React.memo((props: DropdownMenuProps): JSX.E
   }
   }
 
 
   return (
   return (
-    <DropdownMenu positionFixed modifiers={{ preventOverflow: { boundariesElement: undefined } }}>
+    <DropdownMenu positionFixed modifiers={{ preventOverflow: { boundariesElement: undefined } }} right={alignRight}>
       {contents}
       {contents}
     </DropdownMenu>
     </DropdownMenu>
   );
   );
@@ -216,6 +244,7 @@ type PageItemControlSubstanceProps = CommonProps & {
   pageId: string,
   pageId: string,
   fetchOnInit?: boolean,
   fetchOnInit?: boolean,
   children?: React.ReactNode,
   children?: React.ReactNode,
+  operationProcessData?: IPageOperationProcessData,
 }
 }
 
 
 export const PageItemControlSubstance = (props: PageItemControlSubstanceProps): JSX.Element => {
 export const PageItemControlSubstance = (props: PageItemControlSubstanceProps): JSX.Element => {
@@ -223,7 +252,7 @@ export const PageItemControlSubstance = (props: PageItemControlSubstanceProps):
   const {
   const {
     pageId, pageInfo: presetPageInfo, fetchOnInit,
     pageId, pageInfo: presetPageInfo, fetchOnInit,
     children,
     children,
-    onClickBookmarkMenuItem, onClickRenameMenuItem, onClickDuplicateMenuItem, onClickDeleteMenuItem,
+    onClickBookmarkMenuItem, onClickRenameMenuItem, onClickDuplicateMenuItem, onClickDeleteMenuItem, onClickPathRecoveryMenuItem,
   } = props;
   } = props;
 
 
   const [isOpen, setIsOpen] = useState(false);
   const [isOpen, setIsOpen] = useState(false);
@@ -275,6 +304,13 @@ export const PageItemControlSubstance = (props: PageItemControlSubstanceProps):
     await onClickDeleteMenuItem(pageId, fetchedPageInfo ?? presetPageInfo);
     await onClickDeleteMenuItem(pageId, fetchedPageInfo ?? presetPageInfo);
   }, [onClickDeleteMenuItem, pageId, fetchedPageInfo, presetPageInfo]);
   }, [onClickDeleteMenuItem, pageId, fetchedPageInfo, presetPageInfo]);
 
 
+  const pathRecoveryMenuItemClickHandler = useCallback(async() => {
+    if (onClickPathRecoveryMenuItem == null) {
+      return;
+    }
+    await onClickPathRecoveryMenuItem(pageId);
+  }, [onClickPathRecoveryMenuItem, pageId]);
+
   return (
   return (
     <Dropdown isOpen={isOpen} toggle={() => setIsOpen(!isOpen)} data-testid="open-page-item-control-btn">
     <Dropdown isOpen={isOpen} toggle={() => setIsOpen(!isOpen)} data-testid="open-page-item-control-btn">
       { children ?? (
       { children ?? (
@@ -291,6 +327,7 @@ export const PageItemControlSubstance = (props: PageItemControlSubstanceProps):
         onClickRenameMenuItem={renameMenuItemClickHandler}
         onClickRenameMenuItem={renameMenuItemClickHandler}
         onClickDuplicateMenuItem={duplicateMenuItemClickHandler}
         onClickDuplicateMenuItem={duplicateMenuItemClickHandler}
         onClickDeleteMenuItem={deleteMenuItemClickHandler}
         onClickDeleteMenuItem={deleteMenuItemClickHandler}
+        onClickPathRecoveryMenuItem={pathRecoveryMenuItemClickHandler}
       />
       />
     </Dropdown>
     </Dropdown>
   );
   );
@@ -301,6 +338,7 @@ export const PageItemControlSubstance = (props: PageItemControlSubstanceProps):
 type PageItemControlProps = CommonProps & {
 type PageItemControlProps = CommonProps & {
   pageId?: string,
   pageId?: string,
   children?: React.ReactNode,
   children?: React.ReactNode,
+  operationProcessData?: IPageOperationProcessData,
 }
 }
 
 
 export const PageItemControl = (props: PageItemControlProps): JSX.Element => {
 export const PageItemControl = (props: PageItemControlProps): JSX.Element => {

+ 8 - 10
packages/app/src/components/CreateTemplateModal.jsx

@@ -1,15 +1,14 @@
 import React from 'react';
 import React from 'react';
-import PropTypes from 'prop-types';
-
-import { Modal, ModalHeader, ModalBody } from 'reactstrap';
 
 
-import { withTranslation } from 'react-i18next';
 import { pathUtils } from '@growi/core';
 import { pathUtils } from '@growi/core';
+import PropTypes from 'prop-types';
+import { useTranslation } from 'react-i18next';
+import { Modal, ModalHeader, ModalBody } from 'reactstrap';
 import urljoin from 'url-join';
 import urljoin from 'url-join';
 
 
-
 const CreateTemplateModal = (props) => {
 const CreateTemplateModal = (props) => {
-  const { t, path } = props;
+  const { t } = useTranslation();
+  const { path } = props;
 
 
   const parentPath = pathUtils.addTrailingSlash(path);
   const parentPath = pathUtils.addTrailingSlash(path);
 
 
@@ -30,6 +29,7 @@ const CreateTemplateModal = (props) => {
         </div>
         </div>
         <div className="card-footer text-center">
         <div className="card-footer text-center">
           <a
           <a
+            data-testid={`template-button-${target}`}
             href={generateUrl(label)}
             href={generateUrl(label)}
             className="btn btn-sm btn-primary"
             className="btn btn-sm btn-primary"
             id={`template-button-${target}`}
             id={`template-button-${target}`}
@@ -42,7 +42,7 @@ const CreateTemplateModal = (props) => {
   }
   }
 
 
   return (
   return (
-    <Modal isOpen={props.isOpen} toggle={props.onClose} className="grw-create-page">
+    <Modal isOpen={props.isOpen} toggle={props.onClose} data-testid="page-template-modal" className="grw-create-page">
       <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>
@@ -63,12 +63,10 @@ const CreateTemplateModal = (props) => {
   );
   );
 };
 };
 
 
-
 CreateTemplateModal.propTypes = {
 CreateTemplateModal.propTypes = {
-  t: PropTypes.func.isRequired, //  i18next
   path: PropTypes.string.isRequired,
   path: PropTypes.string.isRequired,
   isOpen: PropTypes.bool.isRequired,
   isOpen: PropTypes.bool.isRequired,
   onClose: PropTypes.func.isRequired,
   onClose: PropTypes.func.isRequired,
 };
 };
 
 
-export default withTranslation()(CreateTemplateModal);
+export default CreateTemplateModal;

+ 11 - 3
packages/app/src/components/DescendantsPageList.tsx

@@ -1,5 +1,7 @@
 import React, { useCallback, useState } from 'react';
 import React, { useCallback, useState } from 'react';
+
 import { useTranslation } from 'react-i18next';
 import { useTranslation } from 'react-i18next';
+
 import { toastSuccess } from '~/client/util/apiNotification';
 import { toastSuccess } from '~/client/util/apiNotification';
 import {
 import {
   IDataWithMeta,
   IDataWithMeta,
@@ -9,13 +11,12 @@ import {
 import { IPagingResult } from '~/interfaces/paging-result';
 import { IPagingResult } from '~/interfaces/paging-result';
 import { OnDeletedFunction, OnPutBackedFunction } from '~/interfaces/ui';
 import { OnDeletedFunction, OnPutBackedFunction } from '~/interfaces/ui';
 import { useIsGuestUser, useIsSharedUser, useIsTrashPage } from '~/stores/context';
 import { useIsGuestUser, useIsSharedUser, useIsTrashPage } from '~/stores/context';
-
 import {
 import {
   useSWRxDescendantsPageListForCurrrentPath, useSWRxPageInfoForList, useSWRxPageList, useDescendantsPageListForCurrentPathTermManager,
   useSWRxDescendantsPageListForCurrrentPath, useSWRxPageInfoForList, useSWRxPageList, useDescendantsPageListForCurrentPathTermManager,
 } from '~/stores/page';
 } from '~/stores/page';
 import { usePageTreeTermManager } from '~/stores/page-listing';
 import { usePageTreeTermManager } from '~/stores/page-listing';
-import { ForceHideMenuItems, MenuItemType } from './Common/Dropdown/PageItemControl';
 
 
+import { ForceHideMenuItems, MenuItemType } from './Common/Dropdown/PageItemControl';
 import PageList from './PageList/PageList';
 import PageList from './PageList/PageList';
 import PaginationWrapper from './PaginationWrapper';
 import PaginationWrapper from './PaginationWrapper';
 
 
@@ -61,7 +62,14 @@ export const DescendantsPageListSubstance = (props: SubstanceProps): JSX.Element
   }
   }
 
 
   const pageDeletedHandler: OnDeletedFunction = useCallback((...args) => {
   const pageDeletedHandler: OnDeletedFunction = useCallback((...args) => {
-    toastSuccess(args[2] ? t('deleted_pages_completely') : t('deleted_pages'));
+    const path = args[0];
+    const isCompletely = args[2];
+    if (path == null || isCompletely == null) {
+      toastSuccess(t('deleted_page'));
+    }
+    else {
+      toastSuccess(t('deleted_pages_completely', { path }));
+    }
 
 
     advancePt();
     advancePt();
 
 

+ 0 - 108
packages/app/src/components/Drawio.jsx

@@ -1,108 +0,0 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-
-import { debounce } from 'throttle-debounce';
-
-import { withTranslation } from 'react-i18next';
-
-import AppContainer from '~/client/services/AppContainer';
-
-import { withUnstatedContainers } from './UnstatedUtils';
-
-import NotAvailableForGuest from './NotAvailableForGuest';
-
-class Drawio extends React.Component {
-
-  constructor(props) {
-    super(props);
-
-    this.drawioContainer = React.createRef();
-
-    this.style = {
-      borderRadius: 3,
-      border: '1px solid #d7d7d7',
-      margin: '20px 0',
-    };
-
-    this.isPreview = this.props.isPreview;
-    this.drawioContent = this.props.drawioContent;
-
-    this.onEdit = this.onEdit.bind(this);
-
-    // create debounced method for rendering Drawio
-    this.renderDrawioWithDebounce = debounce(200, this.renderDrawio);
-  }
-
-  onEdit() {
-    const { appContainer, rangeLineNumberOfMarkdown } = this.props;
-    const { beginLineNumber, endLineNumber } = rangeLineNumberOfMarkdown;
-    appContainer.launchDrawioModal('page', beginLineNumber, endLineNumber);
-  }
-
-  componentDidMount() {
-    const DrawioViewer = window.GraphViewer;
-    if (DrawioViewer != null) {
-      this.renderDrawio();
-    }
-    else {
-      this.renderDrawioWithDebounce();
-    }
-  }
-
-  renderDrawio() {
-    const DrawioViewer = window.GraphViewer;
-    if (DrawioViewer != null) {
-      const mxgraphs = this.drawioContainer.getElementsByClassName('mxgraph');
-      if (mxgraphs.length > 0) {
-        // GROWI では、mxgraph element は最初のものをレンダリングする前提とする
-        const div = mxgraphs[0];
-
-        if (div != null) {
-          div.innerHTML = '';
-          DrawioViewer.createViewerForElement(div);
-        }
-      }
-    }
-    else {
-      this.renderDrawioWithDebounce();
-    }
-  }
-
-  renderContents() {
-    return this.drawioContent;
-  }
-
-  render() {
-    return (
-      <div className="editable-with-drawio position-relative">
-        { !this.isPreview && (
-          <NotAvailableForGuest>
-            <button type="button" className="drawio-iframe-trigger position-absolute btn btn-outline-secondary" onClick={this.onEdit}>
-              <i className="icon-note mr-1"></i>{this.props.t('Edit')}
-            </button>
-          </NotAvailableForGuest>
-        ) }
-        <div
-          className="drawio"
-          style={this.style}
-          ref={(c) => { this.drawioContainer = c }}
-          // eslint-disable-next-line react/no-danger
-          dangerouslySetInnerHTML={{ __html: this.renderContents() }}
-        >
-        </div>
-      </div>
-    );
-  }
-
-}
-
-Drawio.propTypes = {
-  t: PropTypes.func.isRequired, // i18next
-  appContainer: PropTypes.object.isRequired,
-
-  drawioContent: PropTypes.any.isRequired,
-  isPreview: PropTypes.bool,
-  rangeLineNumberOfMarkdown: PropTypes.object.isRequired,
-};
-
-export default withTranslation()(withUnstatedContainers(Drawio, [AppContainer]));

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

@@ -0,0 +1,94 @@
+import React, {
+  useCallback, useEffect, useMemo, useRef,
+} from 'react';
+
+import EventEmitter from 'events';
+
+import { useTranslation } from 'react-i18next';
+import { debounce } from 'throttle-debounce';
+
+import NotAvailableForGuest from './NotAvailableForGuest';
+
+
+declare const globalEmitter: EventEmitter;
+declare const GraphViewer: {
+  createViewerForElement: (Element) => void,
+};
+
+type Props = {
+  drawioContent: string,
+  rangeLineNumberOfMarkdown: { beginLineNumber: number, endLineNumber: number },
+  isPreview?: boolean,
+}
+
+const Drawio = (props: Props): JSX.Element => {
+
+  const { t } = useTranslation();
+
+  const { drawioContent, rangeLineNumberOfMarkdown, isPreview } = props;
+
+  // const { open: openDrawioModal } = useDrawioModalForPage();
+
+  const drawioContainerRef = useRef<HTMLDivElement>(null);
+
+  const editButtonClickHandler = useCallback(() => {
+    const { beginLineNumber, endLineNumber } = rangeLineNumberOfMarkdown;
+    globalEmitter.emit('launchDrawioModal', beginLineNumber, endLineNumber);
+  }, [rangeLineNumberOfMarkdown]);
+
+  const renderDrawio = useCallback(() => {
+    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);
+      }
+    }
+  }, []);
+
+  const renderDrawioWithDebounce = useMemo(() => debounce(200, renderDrawio), [renderDrawio]);
+
+  useEffect(() => {
+    if (GraphViewer == null) {
+      return;
+    }
+
+    renderDrawioWithDebounce();
+  }, [renderDrawioWithDebounce]);
+
+  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;

+ 4 - 12
packages/app/src/components/Fab.jsx

@@ -1,24 +1,20 @@
 import React, { useState, useCallback, useEffect } from 'react';
 import React, { useState, useCallback, useEffect } from 'react';
 
 
-import PropTypes from 'prop-types';
 import StickyEvents from 'sticky-events';
 import StickyEvents from 'sticky-events';
 
 
 
 
-import AppContainer from '~/client/services/AppContainer';
 import { smoothScrollIntoView } from '~/client/util/smooth-scroll';
 import { smoothScrollIntoView } from '~/client/util/smooth-scroll';
-import { useCurrentPagePath } from '~/stores/context';
+import { useCurrentPagePath, useCurrentUser } from '~/stores/context';
 import { usePageCreateModal } from '~/stores/modal';
 import { usePageCreateModal } from '~/stores/modal';
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 
 
 import CreatePageIcon from './Icons/CreatePageIcon';
 import CreatePageIcon from './Icons/CreatePageIcon';
 import ReturnTopIcon from './Icons/ReturnTopIcon';
 import ReturnTopIcon from './Icons/ReturnTopIcon';
-import { withUnstatedContainers } from './UnstatedUtils';
 
 
 const logger = loggerFactory('growi:cli:Fab');
 const logger = loggerFactory('growi:cli:Fab');
 
 
-const Fab = (props) => {
-  const { appContainer } = props;
-  const { currentUser } = appContainer;
+const Fab = () => {
+  const { data: currentUser } = useCurrentUser();
 
 
   const { open: openCreateModal } = usePageCreateModal();
   const { open: openCreateModal } = usePageCreateModal();
   const { data: currentPath = '' } = useCurrentPagePath();
   const { data: currentPath = '' } = useCurrentPagePath();
@@ -85,8 +81,4 @@ const Fab = (props) => {
 
 
 };
 };
 
 
-Fab.propTypes = {
-  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
-};
-
-export default withUnstatedContainers(Fab, [AppContainer]);
+export default Fab;

+ 1 - 1
packages/app/src/components/FormattedDistanceDate.jsx

@@ -1,7 +1,7 @@
 import React from 'react';
 import React from 'react';
-import PropTypes from 'prop-types';
 
 
 import { format, formatDistanceStrict, differenceInSeconds } from 'date-fns';
 import { format, formatDistanceStrict, differenceInSeconds } from 'date-fns';
+import PropTypes from 'prop-types';
 import { UncontrolledTooltip } from 'reactstrap';
 import { UncontrolledTooltip } from 'reactstrap';
 
 
 const FormattedDistanceDate = (props) => {
 const FormattedDistanceDate = (props) => {

+ 9 - 6
packages/app/src/components/InAppNotification/InAppNotificationDropdown.tsx

@@ -1,19 +1,22 @@
 import React, {
 import React, {
   useState, useEffect, FC, useCallback,
   useState, useEffect, FC, useCallback,
 } from 'react';
 } from 'react';
+
+import { useTranslation } from 'react-i18next';
 import {
 import {
   Dropdown, DropdownToggle, DropdownMenu, DropdownItem,
   Dropdown, DropdownToggle, DropdownMenu, DropdownItem,
 } from 'reactstrap';
 } from 'reactstrap';
-import { useTranslation } from 'react-i18next';
-import loggerFactory from '~/utils/logger';
 
 
+import SocketIoContainer from '~/client/services/SocketIoContainer';
+import { toastError } from '~/client/util/apiNotification';
 import { apiv3Post } from '~/client/util/apiv3-client';
 import { apiv3Post } from '~/client/util/apiv3-client';
+import { useSWRxInAppNotifications, useSWRxInAppNotificationStatus } from '~/stores/in-app-notification';
+import loggerFactory from '~/utils/logger';
+
 import { withUnstatedContainers } from '../UnstatedUtils';
 import { withUnstatedContainers } from '../UnstatedUtils';
+
 import InAppNotificationList from './InAppNotificationList';
 import InAppNotificationList from './InAppNotificationList';
-import SocketIoContainer from '~/client/services/SocketIoContainer';
-import { useSWRxInAppNotifications, useSWRxInAppNotificationStatus } from '~/stores/in-app-notification';
 
 
-import { toastError } from '~/client/util/apiNotification';
 
 
 const logger = loggerFactory('growi:InAppNotificationDropdown');
 const logger = loggerFactory('growi:InAppNotificationDropdown');
 
 
@@ -74,7 +77,7 @@ const InAppNotificationDropdown: FC<Props> = (props: Props) => {
   }
   }
 
 
   return (
   return (
-    <Dropdown className="notification-wrapper" isOpen={isOpen} toggle={toggleDropdownHandler}>
+    <Dropdown className="notification-wrapper grw-notification-dropdown" isOpen={isOpen} toggle={toggleDropdownHandler}>
       <DropdownToggle tag="a" className="px-3 nav-link border-0 bg-transparent waves-effect waves-light">
       <DropdownToggle tag="a" className="px-3 nav-link border-0 bg-transparent waves-effect waves-light">
         <i className="icon-bell" /> {badge}
         <i className="icon-bell" /> {badge}
       </DropdownToggle>
       </DropdownToggle>

+ 5 - 4
packages/app/src/components/InAppNotification/PageNotification/PageModelNotification.tsx

@@ -1,14 +1,15 @@
 import React, {
 import React, {
   forwardRef, ForwardRefRenderFunction, useImperativeHandle,
   forwardRef, ForwardRefRenderFunction, useImperativeHandle,
 } from 'react';
 } from 'react';
+
 import { PagePathLabel } from '@growi/ui';
 import { PagePathLabel } from '@growi/ui';
 
 
 import { IInAppNotificationOpenable } from '~/client/interfaces/in-app-notification-openable';
 import { IInAppNotificationOpenable } from '~/client/interfaces/in-app-notification-openable';
-import { IInAppNotification } from '~/interfaces/in-app-notification';
 import { HasObjectId } from '~/interfaces/has-object-id';
 import { HasObjectId } from '~/interfaces/has-object-id';
+import { IInAppNotification } from '~/interfaces/in-app-notification';
 
 
-import FormattedDistanceDate from '../../FormattedDistanceDate';
 import { parseSnapshot } from '../../../models/serializers/in-app-notification-snapshot/page';
 import { parseSnapshot } from '../../../models/serializers/in-app-notification-snapshot/page';
+import FormattedDistanceDate from '../../FormattedDistanceDate';
 
 
 interface Props {
 interface Props {
   notification: IInAppNotification & HasObjectId
   notification: IInAppNotification & HasObjectId
@@ -39,8 +40,8 @@ const PageModelNotification: ForwardRefRenderFunction<IInAppNotificationOpenable
   }));
   }));
 
 
   return (
   return (
-    <div className="p-2">
-      <div>
+    <div className="p-2 overflow-hidden">
+      <div className="text-truncate">
         <b>{actionUsers}</b> {actionMsg} <PagePathLabel path={snapshot.path} />
         <b>{actionUsers}</b> {actionMsg} <PagePathLabel path={snapshot.path} />
       </div>
       </div>
       <i className={`${actionIcon} mr-2`} />
       <i className={`${actionIcon} mr-2`} />

+ 13 - 5
packages/app/src/components/InstallerForm.jsx

@@ -1,10 +1,11 @@
 import React from 'react';
 import React from 'react';
-import PropTypes from 'prop-types';
 
 
 import i18next from 'i18next';
 import i18next from 'i18next';
-import { withTranslation } from 'react-i18next';
+import PropTypes from 'prop-types';
+import { useTranslation } from 'react-i18next';
 
 
 import { localeMetadatas } from '~/client/util/i18n';
 import { localeMetadatas } from '~/client/util/i18n';
+import { useCsrfToken } from '~/stores/context';
 
 
 class InstallerForm extends React.Component {
 class InstallerForm extends React.Component {
 
 
@@ -175,7 +176,7 @@ class InstallerForm extends React.Component {
               />
               />
             </div>
             </div>
 
 
-            <input type="hidden" name="_csrf" value={this.props.csrf} />
+            <input type="hidden" name="_csrf" value={this.props.csrfToken} />
 
 
             <div className="input-group mt-4 mb-3 d-flex justify-content-center">
             <div className="input-group mt-4 mb-3 d-flex justify-content-center">
               <button
               <button
@@ -211,7 +212,14 @@ InstallerForm.propTypes = {
   userName: PropTypes.string,
   userName: PropTypes.string,
   name: PropTypes.string,
   name: PropTypes.string,
   email: PropTypes.string,
   email: PropTypes.string,
-  csrf: PropTypes.string,
+  csrfToken: PropTypes.string,
+};
+
+const InstallerFormWrapperFC = (props) => {
+  const { t } = useTranslation();
+  const { data: csrfToken } = useCsrfToken();
+
+  return <InstallerForm t={t} csrfToken={csrfToken} {...props} />;
 };
 };
 
 
-export default withTranslation()(InstallerForm);
+export default InstallerFormWrapperFC;

+ 24 - 13
packages/app/src/components/LoginForm.jsx

@@ -1,10 +1,12 @@
 import React from 'react';
 import React from 'react';
+
 import PropTypes from 'prop-types';
 import PropTypes from 'prop-types';
 import ReactCardFlip from 'react-card-flip';
 import ReactCardFlip from 'react-card-flip';
-
-import { withTranslation } from 'react-i18next';
+import { useTranslation } from 'react-i18next';
 
 
 import AppContainer from '~/client/services/AppContainer';
 import AppContainer from '~/client/services/AppContainer';
+import { useCsrfToken } from '~/stores/context';
+
 import { withUnstatedContainers } from './UnstatedUtils';
 import { withUnstatedContainers } from './UnstatedUtils';
 
 
 class LoginForm extends React.Component {
 class LoginForm extends React.Component {
@@ -35,12 +37,12 @@ class LoginForm extends React.Component {
 
 
   handleLoginWithExternalAuth(e) {
   handleLoginWithExternalAuth(e) {
     const auth = e.currentTarget.id;
     const auth = e.currentTarget.id;
-    const { csrf } = this.props.appContainer;
-    window.location.href = `/passport/${auth}?_csrf=${csrf}`;
+    const { csrfToken } = this.props;
+    window.location.href = `/passport/${auth}?_csrf=${csrfToken}`;
   }
   }
 
 
   renderLocalOrLdapLoginForm() {
   renderLocalOrLdapLoginForm() {
-    const { t, appContainer, isLdapStrategySetup } = this.props;
+    const { t, csrfToken, isLdapStrategySetup } = this.props;
 
 
     return (
     return (
       <form role="form" action="/login" method="post">
       <form role="form" action="/login" method="post">
@@ -70,7 +72,7 @@ class LoginForm extends React.Component {
         </div>
         </div>
 
 
         <div className="input-group my-4">
         <div className="input-group my-4">
-          <input type="hidden" name="_csrf" value={appContainer.csrfToken} />
+          <input type="hidden" name="_csrf" value={csrfToken} />
           <button type="submit" id="login" className="btn btn-fill rounded-0 login mx-auto" data-testid="btnSubmitForLogin">
           <button type="submit" id="login" className="btn btn-fill rounded-0 login mx-auto" data-testid="btnSubmitForLogin">
             <div className="eff"></div>
             <div className="eff"></div>
             <span className="btn-label">
             <span className="btn-label">
@@ -148,6 +150,7 @@ class LoginForm extends React.Component {
     const {
     const {
       t,
       t,
       appContainer,
       appContainer,
+      csrfToken,
       isEmailAuthenticationEnabled,
       isEmailAuthenticationEnabled,
       username,
       username,
       name,
       name,
@@ -251,7 +254,7 @@ class LoginForm extends React.Component {
           )}
           )}
 
 
           <div className="input-group justify-content-center my-4">
           <div className="input-group justify-content-center my-4">
-            <input type="hidden" name="_csrf" value={appContainer.csrfToken} />
+            <input type="hidden" name="_csrf" value={csrfToken} />
             <button type="submit" className="btn btn-fill rounded-0" id="register" disabled={(!isMailerSetup && isEmailAuthenticationEnabled)}>
             <button type="submit" className="btn btn-fill rounded-0" id="register" disabled={(!isMailerSetup && isEmailAuthenticationEnabled)}>
               <div className="eff"></div>
               <div className="eff"></div>
               <span className="btn-label">
               <span className="btn-label">
@@ -327,16 +330,12 @@ class LoginForm extends React.Component {
 
 
 }
 }
 
 
-/**
- * Wrapper component for using unstated
- */
-const LoginFormWrapper = withUnstatedContainers(LoginForm, [AppContainer]);
-
 LoginForm.propTypes = {
 LoginForm.propTypes = {
   // i18next
   // i18next
   t: PropTypes.func.isRequired,
   t: PropTypes.func.isRequired,
   appContainer: PropTypes.instanceOf(AppContainer).isRequired,
   appContainer: PropTypes.instanceOf(AppContainer).isRequired,
 
 
+  csrfToken: PropTypes.string,
   isRegistering: PropTypes.bool,
   isRegistering: PropTypes.bool,
   username: PropTypes.string,
   username: PropTypes.string,
   name: PropTypes.string,
   name: PropTypes.string,
@@ -351,4 +350,16 @@ LoginForm.propTypes = {
   objOfIsExternalAuthEnableds: PropTypes.object,
   objOfIsExternalAuthEnableds: PropTypes.object,
 };
 };
 
 
-export default withTranslation()(LoginFormWrapper);
+const LoginFormWrapperFC = (props) => {
+  const { t } = useTranslation();
+  const { data: csrfToken } = useCsrfToken();
+
+  return <LoginForm t={t} csrfToken={csrfToken} {...props} />;
+};
+
+/**
+ * Wrapper component for using unstated
+ */
+const LoginFormWrapper = withUnstatedContainers(LoginFormWrapperFC, [AppContainer]);
+
+export default LoginFormWrapper;

+ 12 - 4
packages/app/src/components/Me/ApiSettings.jsx

@@ -2,7 +2,7 @@
 import React from 'react';
 import React from 'react';
 
 
 import PropTypes from 'prop-types';
 import PropTypes from 'prop-types';
-import { withTranslation } from 'react-i18next';
+import { useTranslation } from 'react-i18next';
 
 
 import AppContainer from '~/client/services/AppContainer';
 import AppContainer from '~/client/services/AppContainer';
 import PersonalContainer from '~/client/services/PersonalContainer';
 import PersonalContainer from '~/client/services/PersonalContainer';
@@ -97,12 +97,20 @@ class ApiSettings extends React.Component {
 
 
 }
 }
 
 
-const ApiSettingsWrapper = withUnstatedContainers(ApiSettings, [AppContainer, PersonalContainer]);
-
 ApiSettings.propTypes = {
 ApiSettings.propTypes = {
   t: PropTypes.func.isRequired, // i18next
   t: PropTypes.func.isRequired, // i18next
   appContainer: PropTypes.instanceOf(AppContainer).isRequired,
   appContainer: PropTypes.instanceOf(AppContainer).isRequired,
   personalContainer: PropTypes.instanceOf(PersonalContainer).isRequired,
   personalContainer: PropTypes.instanceOf(PersonalContainer).isRequired,
 };
 };
 
 
-export default withTranslation()(ApiSettingsWrapper);
+const ApiSettingsWrapperFC = (props) => {
+  const { t } = useTranslation();
+  return <ApiSettings t={t} {...props} />;
+};
+
+/**
+ * Wrapper component for using unstated
+ */
+const ApiSettingsWrapper = withUnstatedContainers(ApiSettingsWrapperFC, [AppContainer, PersonalContainer]);
+
+export default ApiSettingsWrapper;

+ 11 - 4
packages/app/src/components/Me/AssociateModal.jsx

@@ -2,7 +2,7 @@
 import React from 'react';
 import React from 'react';
 
 
 import PropTypes from 'prop-types';
 import PropTypes from 'prop-types';
-import { withTranslation } from 'react-i18next';
+import { useTranslation } from 'react-i18next';
 import {
 import {
   Modal,
   Modal,
   ModalHeader,
   ModalHeader,
@@ -130,8 +130,6 @@ class AssociateModal extends React.Component {
 
 
 }
 }
 
 
-const AssociateModalWrapper = withUnstatedContainers(AssociateModal, [AppContainer, PersonalContainer]);
-
 AssociateModal.propTypes = {
 AssociateModal.propTypes = {
   t: PropTypes.func.isRequired, // i18next
   t: PropTypes.func.isRequired, // i18next
   appContainer: PropTypes.instanceOf(AppContainer).isRequired,
   appContainer: PropTypes.instanceOf(AppContainer).isRequired,
@@ -141,5 +139,14 @@ AssociateModal.propTypes = {
   onClose: PropTypes.func.isRequired,
   onClose: PropTypes.func.isRequired,
 };
 };
 
 
+const AssociateModalWrapperFC = (props) => {
+  const { t } = useTranslation();
+  return <AssociateModal t={t} {...props} />;
+};
+
+/**
+ * Wrapper component for using unstated
+ */
+const AssociateModalWrapper = withUnstatedContainers(AssociateModalWrapperFC, [AppContainer, PersonalContainer]);
 
 
-export default withTranslation()(AssociateModalWrapper);
+export default AssociateModalWrapper;

+ 12 - 4
packages/app/src/components/Me/BasicInfoSettings.jsx

@@ -2,7 +2,7 @@
 import React, { Fragment } from 'react';
 import React, { Fragment } from 'react';
 
 
 import PropTypes from 'prop-types';
 import PropTypes from 'prop-types';
-import { withTranslation } from 'react-i18next';
+import { useTranslation } from 'react-i18next';
 
 
 import PersonalContainer from '~/client/services/PersonalContainer';
 import PersonalContainer from '~/client/services/PersonalContainer';
 import { toastSuccess, toastError } from '~/client/util/apiNotification';
 import { toastSuccess, toastError } from '~/client/util/apiNotification';
@@ -163,11 +163,19 @@ class BasicInfoSettings extends React.Component {
 
 
 }
 }
 
 
-const BasicInfoSettingsWrapper = withUnstatedContainers(BasicInfoSettings, [PersonalContainer]);
-
 BasicInfoSettings.propTypes = {
 BasicInfoSettings.propTypes = {
   t: PropTypes.func.isRequired, // i18next
   t: PropTypes.func.isRequired, // i18next
   personalContainer: PropTypes.instanceOf(PersonalContainer).isRequired,
   personalContainer: PropTypes.instanceOf(PersonalContainer).isRequired,
 };
 };
 
 
-export default withTranslation()(BasicInfoSettingsWrapper);
+const BasicInfoSettingsWrapperFC = (props) => {
+  const { t } = useTranslation();
+  return <BasicInfoSettings t={t} {...props} />;
+};
+
+/**
+ * Wrapper component for using unstated
+ */
+const BasicInfoSettingsWrapper = withUnstatedContainers(BasicInfoSettingsWrapperFC, [PersonalContainer]);
+
+export default BasicInfoSettingsWrapper;

+ 17 - 7
packages/app/src/components/Me/DisassociateModal.jsx

@@ -1,19 +1,21 @@
 
 
 import React from 'react';
 import React from 'react';
-import PropTypes from 'prop-types';
-import { withTranslation } from 'react-i18next';
 
 
+import PropTypes from 'prop-types';
+import { useTranslation } from 'react-i18next';
 import {
 import {
   Modal,
   Modal,
   ModalHeader,
   ModalHeader,
   ModalBody,
   ModalBody,
   ModalFooter,
   ModalFooter,
 } from 'reactstrap';
 } from 'reactstrap';
-import { toastSuccess, toastError } from '~/client/util/apiNotification';
-import { withUnstatedContainers } from '../UnstatedUtils';
 
 
 import AppContainer from '~/client/services/AppContainer';
 import AppContainer from '~/client/services/AppContainer';
 import PersonalContainer from '~/client/services/PersonalContainer';
 import PersonalContainer from '~/client/services/PersonalContainer';
+import { toastSuccess, toastError } from '~/client/util/apiNotification';
+
+import { withUnstatedContainers } from '../UnstatedUtils';
+
 
 
 class DisassociateModal extends React.Component {
 class DisassociateModal extends React.Component {
 
 
@@ -71,8 +73,6 @@ class DisassociateModal extends React.Component {
 
 
 }
 }
 
 
-const DisassociateModalWrapper = withUnstatedContainers(DisassociateModal, [AppContainer, PersonalContainer]);
-
 DisassociateModal.propTypes = {
 DisassociateModal.propTypes = {
   t: PropTypes.func.isRequired, // i18next
   t: PropTypes.func.isRequired, // i18next
   appContainer: PropTypes.instanceOf(AppContainer).isRequired,
   appContainer: PropTypes.instanceOf(AppContainer).isRequired,
@@ -84,5 +84,15 @@ DisassociateModal.propTypes = {
 
 
 };
 };
 
 
+const DisassociateModalWrapperFC = (props) => {
+  const { t } = useTranslation();
+  return <DisassociateModal t={t} {...props} />;
+};
+
+/**
+ * Wrapper component for using unstated
+ */
+const DisassociateModalWrapper = withUnstatedContainers(DisassociateModalWrapperFC, [AppContainer, PersonalContainer]);
+
 
 
-export default withTranslation()(DisassociateModalWrapper);
+export default DisassociateModalWrapper;

+ 12 - 4
packages/app/src/components/Me/ExternalAccountLinkedMe.jsx

@@ -2,7 +2,7 @@
 import React, { Fragment } from 'react';
 import React, { Fragment } from 'react';
 
 
 import PropTypes from 'prop-types';
 import PropTypes from 'prop-types';
-import { withTranslation } from 'react-i18next';
+import { useTranslation } from 'react-i18next';
 
 
 
 
 import AppContainer from '~/client/services/AppContainer';
 import AppContainer from '~/client/services/AppContainer';
@@ -125,12 +125,20 @@ class ExternalAccountLinkedMe extends React.Component {
 
 
 }
 }
 
 
-const ExternalAccountLinkedMeWrapper = withUnstatedContainers(ExternalAccountLinkedMe, [AppContainer, PersonalContainer]);
-
 ExternalAccountLinkedMe.propTypes = {
 ExternalAccountLinkedMe.propTypes = {
   t: PropTypes.func.isRequired, // i18next
   t: PropTypes.func.isRequired, // i18next
   appContainer: PropTypes.instanceOf(AppContainer).isRequired,
   appContainer: PropTypes.instanceOf(AppContainer).isRequired,
   personalContainer: PropTypes.instanceOf(PersonalContainer).isRequired,
   personalContainer: PropTypes.instanceOf(PersonalContainer).isRequired,
 };
 };
 
 
-export default withTranslation()(ExternalAccountLinkedMeWrapper);
+const ExternalAccountLinkedMeWrapperFC = (props) => {
+  const { t } = useTranslation();
+  return <ExternalAccountLinkedMe t={t} {...props} />;
+};
+
+/**
+ * Wrapper component for using unstated
+ */
+const ExternalAccountLinkedMeWrapper = withUnstatedContainers(ExternalAccountLinkedMeWrapperFC, [AppContainer, PersonalContainer]);
+
+export default ExternalAccountLinkedMeWrapper;

+ 5 - 7
packages/app/src/components/Me/ExternalAccountRow.jsx

@@ -1,12 +1,13 @@
 
 
 import React from 'react';
 import React from 'react';
-import PropTypes from 'prop-types';
 
 
-import { withTranslation } from 'react-i18next';
 import dateFnsFormat from 'date-fns/format';
 import dateFnsFormat from 'date-fns/format';
+import PropTypes from 'prop-types';
+import { useTranslation } from 'react-i18next';
 
 
 const ExternalAccountRow = (props) => {
 const ExternalAccountRow = (props) => {
-  const { t, account } = props;
+  const { t } = useTranslation();
+  const { account } = props;
 
 
   return (
   return (
     <tr>
     <tr>
@@ -29,12 +30,9 @@ const ExternalAccountRow = (props) => {
   );
   );
 };
 };
 
 
-
 ExternalAccountRow.propTypes = {
 ExternalAccountRow.propTypes = {
-  t: PropTypes.func.isRequired, // i18next
-
   account: PropTypes.object.isRequired,
   account: PropTypes.object.isRequired,
   openDisassociateModal: PropTypes.func.isRequired,
   openDisassociateModal: PropTypes.func.isRequired,
 };
 };
 
 
-export default withTranslation()(ExternalAccountRow);
+export default ExternalAccountRow;

+ 14 - 13
packages/app/src/components/Me/ImageCropModal.jsx

@@ -1,20 +1,20 @@
 import React from 'react';
 import React from 'react';
-import PropTypes from 'prop-types';
-import canvasToBlob from 'async-canvas-to-blob';
 
 
+import canvasToBlob from 'async-canvas-to-blob';
+import PropTypes from 'prop-types';
+import { useTranslation } from 'react-i18next';
+import ReactCrop from 'react-image-crop';
 import {
 import {
   Modal,
   Modal,
   ModalHeader,
   ModalHeader,
   ModalBody,
   ModalBody,
   ModalFooter,
   ModalFooter,
 } from 'reactstrap';
 } from 'reactstrap';
-import { withTranslation } from 'react-i18next';
-import ReactCrop from 'react-image-crop';
-import loggerFactory from '~/utils/logger';
-import AppContainer from '~/client/services/AppContainer';
-import { withUnstatedContainers } from '../UnstatedUtils';
+
 import 'react-image-crop/dist/ReactCrop.css';
 import 'react-image-crop/dist/ReactCrop.css';
 import { toastError } from '~/client/util/apiNotification';
 import { toastError } from '~/client/util/apiNotification';
+import loggerFactory from '~/utils/logger';
+
 
 
 const logger = loggerFactory('growi:ImageCropModal');
 const logger = loggerFactory('growi:ImageCropModal');
 
 
@@ -110,15 +110,16 @@ class ImageCropModal extends React.Component {
 
 
 }
 }
 
 
-/**
- * Wrapper component for using unstated
- */
-const ProfileImageFormWrapper = withUnstatedContainers(ImageCropModal, [AppContainer]);
 ImageCropModal.propTypes = {
 ImageCropModal.propTypes = {
-  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
   show: PropTypes.bool.isRequired,
   show: PropTypes.bool.isRequired,
   src: PropTypes.string,
   src: PropTypes.string,
   onModalClose: PropTypes.func.isRequired,
   onModalClose: PropTypes.func.isRequired,
   onCropCompleted: PropTypes.func.isRequired,
   onCropCompleted: PropTypes.func.isRequired,
 };
 };
-export default withTranslation()(ProfileImageFormWrapper);
+
+const ImageCropModalWrapperFC = (props) => {
+  const { t } = useTranslation();
+  return <ImageCropModal t={t} {...props} />;
+};
+
+export default ImageCropModalWrapperFC;

+ 17 - 11
packages/app/src/components/Me/PasswordSettings.jsx

@@ -2,7 +2,7 @@
 import React from 'react';
 import React from 'react';
 
 
 import PropTypes from 'prop-types';
 import PropTypes from 'prop-types';
-import { withTranslation } from 'react-i18next';
+import { useTranslation } from 'react-i18next';
 
 
 import PersonalContainer from '~/client/services/PersonalContainer';
 import PersonalContainer from '~/client/services/PersonalContainer';
 import { toastSuccess, toastError } from '~/client/util/apiNotification';
 import { toastSuccess, toastError } from '~/client/util/apiNotification';
@@ -10,7 +10,6 @@ import { apiv3Get, apiv3Put } from '~/client/util/apiv3-client';
 
 
 import { withUnstatedContainers } from '../UnstatedUtils';
 import { withUnstatedContainers } from '../UnstatedUtils';
 
 
-
 class PasswordSettings extends React.Component {
 class PasswordSettings extends React.Component {
 
 
   constructor() {
   constructor() {
@@ -22,6 +21,7 @@ class PasswordSettings extends React.Component {
       newPassword: '',
       newPassword: '',
       newPasswordConfirm: '',
       newPasswordConfirm: '',
       isPasswordSet: false,
       isPasswordSet: false,
+      minPasswordLength: null,
     };
     };
 
 
     this.onClickSubmit = this.onClickSubmit.bind(this);
     this.onClickSubmit = this.onClickSubmit.bind(this);
@@ -32,8 +32,8 @@ class PasswordSettings extends React.Component {
   async componentDidMount() {
   async componentDidMount() {
     try {
     try {
       const res = await apiv3Get('/personal-setting/is-password-set');
       const res = await apiv3Get('/personal-setting/is-password-set');
-      const { isPasswordSet } = res.data;
-      this.setState({ isPasswordSet });
+      const { isPasswordSet, minPasswordLength } = res.data;
+      this.setState({ isPasswordSet, minPasswordLength });
     }
     }
     catch (err) {
     catch (err) {
       toastError(err);
       toastError(err);
@@ -74,9 +74,8 @@ class PasswordSettings extends React.Component {
 
 
   render() {
   render() {
     const { t } = this.props;
     const { t } = this.props;
-    const { newPassword, newPasswordConfirm } = this.state;
+    const { newPassword, newPasswordConfirm, minPasswordLength } = this.state;
     const isIncorrectConfirmPassword = (newPassword !== newPasswordConfirm);
     const isIncorrectConfirmPassword = (newPassword !== newPasswordConfirm);
-
     if (this.state.retrieveError != null) {
     if (this.state.retrieveError != null) {
       throw new Error(this.state.retrieveError.message);
       throw new Error(this.state.retrieveError.message);
     }
     }
@@ -131,7 +130,7 @@ class PasswordSettings extends React.Component {
               onChange={(e) => { this.onChangeNewPasswordConfirm(e.target.value) }}
               onChange={(e) => { this.onChangeNewPasswordConfirm(e.target.value) }}
             />
             />
 
 
-            <p className="form-text text-muted">{t('page_register.form_help.password') }</p>
+            <p className="form-text text-muted">{t('page_register.form_help.password', { target: minPasswordLength }) }</p>
           </div>
           </div>
         </div>
         </div>
 
 
@@ -154,12 +153,19 @@ class PasswordSettings extends React.Component {
 
 
 }
 }
 
 
-
-const PasswordSettingsWrapper = withUnstatedContainers(PasswordSettings, [PersonalContainer]);
-
 PasswordSettings.propTypes = {
 PasswordSettings.propTypes = {
   t: PropTypes.func.isRequired, // i18next
   t: PropTypes.func.isRequired, // i18next
   personalContainer: PropTypes.instanceOf(PersonalContainer).isRequired,
   personalContainer: PropTypes.instanceOf(PersonalContainer).isRequired,
 };
 };
 
 
-export default withTranslation()(PasswordSettingsWrapper);
+const PasswordSettingsWrapperFC = (props) => {
+  const { t } = useTranslation();
+  return <PasswordSettings t={t} {...props} />;
+};
+
+/**
+ * Wrapper component for using unstated
+ */
+const PasswordSettingsWrapper = withUnstatedContainers(PasswordSettingsWrapperFC, [PersonalContainer]);
+
+export default PasswordSettingsWrapper;

+ 4 - 8
packages/app/src/components/Me/PersonalSettings.jsx

@@ -2,7 +2,7 @@
 import React, { useMemo } from 'react';
 import React, { useMemo } from 'react';
 
 
 import PropTypes from 'prop-types';
 import PropTypes from 'prop-types';
-import { withTranslation } from 'react-i18next';
+import { useTranslation } from 'react-i18next';
 
 
 import CustomNavAndContents from '../CustomNavigation/CustomNavAndContents';
 import CustomNavAndContents from '../CustomNavigation/CustomNavAndContents';
 
 
@@ -13,9 +13,9 @@ import InAppNotificationSettings from './InAppNotificationSettings';
 import PasswordSettings from './PasswordSettings';
 import PasswordSettings from './PasswordSettings';
 import UserSettings from './UserSettings';
 import UserSettings from './UserSettings';
 
 
-const PersonalSettings = (props) => {
+const PersonalSettings = () => {
 
 
-  const { t } = props;
+  const { t } = useTranslation();
 
 
   const navTabMapping = useMemo(() => {
   const navTabMapping = useMemo(() => {
     return {
     return {
@@ -67,8 +67,4 @@ const PersonalSettings = (props) => {
 
 
 };
 };
 
 
-PersonalSettings.propTypes = {
-  t: PropTypes.func.isRequired, // i18next
-};
-
-export default withTranslation()(PersonalSettings);
+export default PersonalSettings;

+ 0 - 197
packages/app/src/components/Me/ProfileImageSettings.jsx

@@ -1,197 +0,0 @@
-import React from 'react';
-
-import md5 from 'md5';
-import PropTypes from 'prop-types';
-import { withTranslation } from 'react-i18next';
-
-import AppContainer from '~/client/services/AppContainer';
-import PersonalContainer from '~/client/services/PersonalContainer';
-import { toastSuccess, toastError } from '~/client/util/apiNotification';
-
-import { withUnstatedContainers } from '../UnstatedUtils';
-
-
-import ImageCropModal from './ImageCropModal';
-
-class ProfileImageSettings extends React.Component {
-
-  constructor(appContainer) {
-    super();
-
-    this.state = {
-      show: false,
-      src: null,
-    };
-
-    this.imageRef = null;
-    this.onSelectFile = this.onSelectFile.bind(this);
-    this.onClickDeleteBtn = this.onClickDeleteBtn.bind(this);
-    this.hideModal = this.hideModal.bind(this);
-    this.cancelModal = this.cancelModal.bind(this);
-    this.onCropCompleted = this.onCropCompleted.bind(this);
-    this.onClickSubmit = this.onClickSubmit.bind(this);
-  }
-
-  async onClickSubmit() {
-    const { t, personalContainer } = this.props;
-
-    try {
-      await personalContainer.updateProfileImage();
-      toastSuccess(t('toaster.update_successed', { target: t('Set Profile Image') }));
-    }
-    catch (err) {
-      toastError(err);
-    }
-  }
-
-  generateGravatarSrc() {
-    const email = this.props.personalContainer.state.email || '';
-    const hash = md5(email.trim().toLowerCase());
-    return `https://gravatar.com/avatar/${hash}`;
-  }
-
-  onSelectFile(e) {
-    if (e.target.files && e.target.files.length > 0) {
-      const reader = new FileReader();
-      reader.addEventListener('load', () => this.setState({ src: reader.result }));
-      reader.readAsDataURL(e.target.files[0]);
-      this.setState({ show: true });
-    }
-  }
-
-  /**
-   * @param {object} croppedImage cropped profile image for upload
-   */
-  async onCropCompleted(croppedImage) {
-    const { t, personalContainer } = this.props;
-    try {
-      await personalContainer.uploadAttachment(croppedImage);
-      toastSuccess(t('toaster.update_successed', { target: t('Current Image') }));
-    }
-    catch (err) {
-      toastError(err);
-    }
-    this.hideModal();
-  }
-
-  async onClickDeleteBtn() {
-    const { t, personalContainer } = this.props;
-    try {
-      await personalContainer.deleteProfileImage();
-      toastSuccess(t('toaster.update_successed', { target: t('Current Image') }));
-    }
-    catch (err) {
-      toastError(err);
-    }
-  }
-
-  showModal() {
-    this.setState({ show: true });
-  }
-
-  hideModal() {
-    this.setState({ show: false });
-  }
-
-  cancelModal() {
-    this.hideModal();
-  }
-
-  render() {
-    const { t, personalContainer } = this.props;
-    const { uploadedPictureSrc, isGravatarEnabled, isUploadedPicture } = personalContainer.state;
-
-    return (
-      <React.Fragment>
-        <div className="row">
-          <div className="col-md-6 col-12 mb-3 mb-md-0">
-            <h4>
-              <div className="custom-control custom-radio radio-primary">
-                <input
-                  type="radio"
-                  id="radioGravatar"
-                  className="custom-control-input"
-                  form="formImageType"
-                  name="imagetypeForm[isGravatarEnabled]"
-                  checked={isGravatarEnabled}
-                  onChange={() => { personalContainer.changeIsGravatarEnabled(true) }}
-                />
-                <label className="custom-control-label" htmlFor="radioGravatar">
-                  <img src="https://gravatar.com/avatar/00000000000000000000000000000000?s=24" data-hide-in-vrt /> Gravatar
-                </label>
-                <a href="https://gravatar.com/">
-                  <small><i className="icon-arrow-right-circle" aria-hidden="true"></i></small>
-                </a>
-              </div>
-            </h4>
-            <img src={this.generateGravatarSrc()} width="64" data-hide-in-vrt />
-          </div>
-
-          <div className="col-md-6 col-12">
-            <h4>
-              <div className="custom-control custom-radio radio-primary">
-                <input
-                  type="radio"
-                  id="radioUploadPicture"
-                  className="custom-control-input"
-                  form="formImageType"
-                  name="imagetypeForm[isGravatarEnabled]"
-                  checked={!isGravatarEnabled}
-                  onChange={() => { personalContainer.changeIsGravatarEnabled(false) }}
-                />
-                <label className="custom-control-label" htmlFor="radioUploadPicture">
-                  { t('Upload Image') }
-                </label>
-              </div>
-            </h4>
-            <div className="row mb-3">
-              <label className="col-sm-4 col-12 col-form-label text-left">
-                { t('Current Image') }
-              </label>
-              <div className="col-sm-8 col-12">
-                {uploadedPictureSrc && (<p><img src={uploadedPictureSrc} className="picture picture-lg rounded-circle" id="settingUserPicture" /></p>)}
-                {isUploadedPicture && <button type="button" className="btn btn-danger" onClick={this.onClickDeleteBtn}>{ t('Delete Image') }</button>}
-              </div>
-            </div>
-            <div className="row">
-              <label className="col-sm-4 col-12 col-form-label text-left">
-                {t('Upload new image')}
-              </label>
-              <div className="col-sm-8 col-12">
-                <input type="file" onChange={this.onSelectFile} name="profileImage" accept="image/*" />
-              </div>
-            </div>
-          </div>
-        </div>
-
-        <ImageCropModal
-          show={this.state.show}
-          src={this.state.src}
-          onModalClose={this.cancelModal}
-          onCropCompleted={this.onCropCompleted}
-        />
-
-        <div className="row my-3">
-          <div className="offset-4 col-5">
-            <button type="button" className="btn btn-primary" onClick={this.onClickSubmit} disabled={personalContainer.state.retrieveError != null}>
-              {t('Update')}
-            </button>
-          </div>
-        </div>
-
-      </React.Fragment>
-    );
-  }
-
-}
-
-
-const ProfileImageSettingsWrapper = withUnstatedContainers(ProfileImageSettings, [AppContainer, PersonalContainer]);
-
-ProfileImageSettings.propTypes = {
-  t: PropTypes.func.isRequired, // i18next
-  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
-  personalContainer: PropTypes.instanceOf(PersonalContainer).isRequired,
-};
-
-export default withTranslation()(ProfileImageSettingsWrapper);

+ 177 - 0
packages/app/src/components/Me/ProfileImageSettings.tsx

@@ -0,0 +1,177 @@
+import React, { useCallback, useState } from 'react';
+
+
+import { useTranslation } from 'react-i18next';
+
+import { toastSuccess, toastError } from '~/client/util/apiNotification';
+import { apiPost, apiPostForm } from '~/client/util/apiv1-client';
+import { apiv3Put } from '~/client/util/apiv3-client';
+import { useCurrentUser } from '~/stores/context';
+import { generateGravatarSrc, GRAVATAR_DEFAULT } from '~/utils/gravatar';
+
+import ImageCropModal from './ImageCropModal';
+
+const DEFAULT_IMAGE = '/images/icons/user.svg';
+
+
+const ProfileImageSettings = (): JSX.Element => {
+  const { t } = useTranslation();
+
+  const { data: currentUser } = useCurrentUser();
+
+  const [isGravatarEnabled, setGravatarEnabled] = useState(currentUser?.isGravatarEnabled);
+  const [uploadedPictureSrc, setUploadedPictureSrc] = useState(() => {
+    if (typeof currentUser?.imageAttachment === 'string') {
+      return currentUser?.image;
+    }
+    return currentUser?.imageAttachment?.filePathProxied ?? currentUser?.image;
+  });
+
+  const [showImageCropModal, setShowImageCropModal] = useState(false);
+  const [imageCropSrc, setImageCropSrc] = useState<string|ArrayBuffer|null>(null);
+
+  const selectFileHandler = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
+    if (e.target.files == null || e.target.files.length === 0) {
+      return;
+    }
+
+    const reader = new FileReader();
+    reader.addEventListener('load', () => setImageCropSrc(reader.result));
+    reader.readAsDataURL(e.target.files[0]);
+
+    setShowImageCropModal(true);
+  }, []);
+
+  const cropCompletedHandler = useCallback(async(croppedImage) => {
+    try {
+      const formData = new FormData();
+      formData.append('file', croppedImage);
+      const response = await apiPostForm('/attachments.uploadProfileImage', formData);
+
+      toastSuccess(t('toaster.update_successed', { target: t('Current Image') }));
+
+      // eslint-disable-next-line @typescript-eslint/no-explicit-any
+      setUploadedPictureSrc((response as any).attachment.filePathProxied);
+
+      // close modal
+      setShowImageCropModal(false);
+    }
+    catch (err) {
+      toastError(err);
+    }
+  }, [t]);
+
+  const deleteImageHandler = useCallback(async() => {
+    try {
+      await apiPost('/attachments.removeProfileImage');
+
+      setUploadedPictureSrc(undefined);
+      toastSuccess(t('toaster.update_successed', { target: t('Current Image') }));
+    }
+    catch (err) {
+      toastError(err);
+    }
+  }, [t]);
+
+  const submit = useCallback(async() => {
+    try {
+      const response = await apiv3Put('/personal-setting/image-type', { isGravatarEnabled });
+
+      const { userData } = response.data;
+      setGravatarEnabled(userData.isGravatarEnabled);
+
+      toastSuccess(t('toaster.update_successed', { target: t('Set Profile Image') }));
+    }
+    catch (err) {
+      toastError(err);
+    }
+  }, [isGravatarEnabled, t]);
+
+  if (currentUser == null) {
+    return <></>;
+  }
+
+  return (
+    <>
+      <div className="row">
+        <div className="col-md-6 col-12 mb-3 mb-md-0">
+          <h4>
+            <div className="custom-control custom-radio radio-primary">
+              <input
+                type="radio"
+                id="radioGravatar"
+                className="custom-control-input"
+                form="formImageType"
+                name="imagetypeForm[isGravatarEnabled]"
+                checked={isGravatarEnabled}
+                onChange={() => setGravatarEnabled(true)}
+              />
+              <label className="custom-control-label" htmlFor="radioGravatar">
+                <img src={GRAVATAR_DEFAULT} data-hide-in-vrt /> Gravatar
+              </label>
+              <a href="https://gravatar.com/">
+                <small><i className="icon-arrow-right-circle" aria-hidden="true"></i></small>
+              </a>
+            </div>
+          </h4>
+          <img src={generateGravatarSrc(currentUser.email)} width="64" data-hide-in-vrt />
+        </div>
+
+        <div className="col-md-6 col-12">
+          <h4>
+            <div className="custom-control custom-radio radio-primary">
+              <input
+                type="radio"
+                id="radioUploadPicture"
+                className="custom-control-input"
+                form="formImageType"
+                name="imagetypeForm[isGravatarEnabled]"
+                checked={!isGravatarEnabled}
+                onChange={() => setGravatarEnabled(false)}
+              />
+              <label className="custom-control-label" htmlFor="radioUploadPicture">
+                { t('Upload Image') }
+              </label>
+            </div>
+          </h4>
+          <div className="row mb-3">
+            <label className="col-sm-4 col-12 col-form-label text-left">
+              { t('Current Image') }
+            </label>
+            <div className="col-sm-8 col-12">
+              <p><img src={uploadedPictureSrc ?? DEFAULT_IMAGE} className="picture picture-lg rounded-circle" id="settingUserPicture" /></p>
+              {uploadedPictureSrc && <button type="button" className="btn btn-danger" onClick={deleteImageHandler}>{ t('Delete Image') }</button>}
+            </div>
+          </div>
+          <div className="row">
+            <label className="col-sm-4 col-12 col-form-label text-left">
+              {t('Upload new image')}
+            </label>
+            <div className="col-sm-8 col-12">
+              <input type="file" onChange={selectFileHandler} name="profileImage" accept="image/*" />
+            </div>
+          </div>
+        </div>
+      </div>
+
+      <ImageCropModal
+        show={showImageCropModal}
+        src={imageCropSrc}
+        onModalClose={() => setShowImageCropModal(false)}
+        onCropCompleted={cropCompletedHandler}
+      />
+
+      <div className="row my-3">
+        <div className="offset-4 col-5">
+          <button type="button" className="btn btn-primary" onClick={submit}>
+            {t('Update')}
+          </button>
+        </div>
+      </div>
+
+    </>
+  );
+
+};
+
+export default ProfileImageSettings;

+ 0 - 36
packages/app/src/components/Me/UserSettings.jsx

@@ -1,36 +0,0 @@
-
-import React from 'react';
-
-import PropTypes from 'prop-types';
-import { withTranslation } from 'react-i18next';
-
-import BasicInfoSettings from './BasicInfoSettings';
-import ProfileImageSettings from './ProfileImageSettings';
-
-class UserSettings extends React.Component {
-
-  render() {
-    const { t } = this.props;
-
-    return (
-      <div data-testid="grw-user-settings">
-        <div className="mb-5">
-          <h2 className="border-bottom my-4">{t('Basic Info')}</h2>
-          <BasicInfoSettings />
-        </div>
-        <div className="mb-5">
-          <h2 className="border-bottom my-4">{t('Set Profile Image')}</h2>
-          <ProfileImageSettings />
-        </div>
-      </div>
-    );
-  }
-
-}
-
-
-UserSettings.propTypes = {
-  t: PropTypes.func.isRequired, // i18next
-};
-
-export default withTranslation()(UserSettings);

+ 29 - 0
packages/app/src/components/Me/UserSettings.tsx

@@ -0,0 +1,29 @@
+import React, { FC } from 'react';
+
+import { useTranslation } from 'react-i18next';
+
+import BasicInfoSettings from './BasicInfoSettings';
+import ProfileImageSettings from './ProfileImageSettings';
+
+type Props = {
+
+};
+
+const UserSettings: FC<Props> = () => {
+  const { t } = useTranslation();
+
+  return (
+    <div data-testid="grw-user-settings">
+      <div className="mb-5">
+        <h2 className="border-bottom my-4">{t('Basic Info')}</h2>
+        <BasicInfoSettings />
+      </div>
+      <div className="mb-5">
+        <h2 className="border-bottom my-4">{t('Set Profile Image')}</h2>
+        <ProfileImageSettings />
+      </div>
+    </div>
+  );
+};
+
+export default UserSettings;

+ 15 - 12
packages/app/src/components/MyDraftList/Draft.jsx

@@ -1,18 +1,17 @@
 import React from 'react';
 import React from 'react';
-import PropTypes from 'prop-types';
 
 
-import { withTranslation } from 'react-i18next';
+import PropTypes from 'prop-types';
 import { CopyToClipboard } from 'react-copy-to-clipboard';
 import { CopyToClipboard } from 'react-copy-to-clipboard';
-
+import { useTranslation } from 'react-i18next';
 import {
 import {
   Collapse,
   Collapse,
   UncontrolledTooltip,
   UncontrolledTooltip,
 } from 'reactstrap';
 } from 'reactstrap';
 
 
-import { withUnstatedContainers } from '../UnstatedUtils';
 import AppContainer from '~/client/services/AppContainer';
 import AppContainer from '~/client/services/AppContainer';
 
 
 import RevisionBody from '../Page/RevisionBody';
 import RevisionBody from '../Page/RevisionBody';
+import { withUnstatedContainers } from '../UnstatedUtils';
 
 
 class Draft extends React.Component {
 class Draft extends React.Component {
 
 
@@ -60,7 +59,7 @@ class Draft extends React.Component {
     };
     };
 
 
     const growiRenderer = this.growiRenderer;
     const growiRenderer = this.growiRenderer;
-    const interceptorManager = this.props.appContainer.interceptorManager;
+    const { interceptorManager } = window;
     await interceptorManager.process('prePreProcess', context)
     await interceptorManager.process('prePreProcess', context)
       .then(() => {
       .then(() => {
         context.markdown = growiRenderer.preProcess(context.markdown, context);
         context.markdown = growiRenderer.preProcess(context.markdown, context);
@@ -192,12 +191,6 @@ class Draft extends React.Component {
 
 
 }
 }
 
 
-/**
- * Wrapper component for using unstated
- */
-const DraftWrapper = withUnstatedContainers(Draft, [AppContainer]);
-
-
 Draft.propTypes = {
 Draft.propTypes = {
   t: PropTypes.func.isRequired,
   t: PropTypes.func.isRequired,
   appContainer: PropTypes.instanceOf(AppContainer).isRequired,
   appContainer: PropTypes.instanceOf(AppContainer).isRequired,
@@ -209,4 +202,14 @@ Draft.propTypes = {
   clearDraft: PropTypes.func.isRequired,
   clearDraft: PropTypes.func.isRequired,
 };
 };
 
 
-export default withTranslation()(DraftWrapper);
+const DraftWrapperFC = (props) => {
+  const { t } = useTranslation();
+  return <Draft t={t} {...props} />;
+};
+
+/**
+ * Wrapper component for using unstated
+ */
+const DraftWrapper = withUnstatedContainers(DraftWrapperFC, [AppContainer]);
+
+export default DraftWrapper;

+ 12 - 8
packages/app/src/components/MyDraftList/MyDraftList.jsx

@@ -1,7 +1,7 @@
 import React from 'react';
 import React from 'react';
 
 
 import PropTypes from 'prop-types';
 import PropTypes from 'prop-types';
-import { withTranslation } from 'react-i18next';
+import { useTranslation } from 'react-i18next';
 
 
 import EditorContainer from '~/client/services/EditorContainer';
 import EditorContainer from '~/client/services/EditorContainer';
 import PageContainer from '~/client/services/PageContainer';
 import PageContainer from '~/client/services/PageContainer';
@@ -171,12 +171,6 @@ class MyDraftList extends React.Component {
 
 
 }
 }
 
 
-/**
- * Wrapper component for using unstated
- */
-const MyDraftListWrapper = withUnstatedContainers(MyDraftList, [PageContainer, EditorContainer]);
-
-
 MyDraftList.propTypes = {
 MyDraftList.propTypes = {
   t: PropTypes.func.isRequired, // react-i18next
   t: PropTypes.func.isRequired, // react-i18next
 
 
@@ -184,4 +178,14 @@ MyDraftList.propTypes = {
   editorContainer: PropTypes.instanceOf(EditorContainer).isRequired,
   editorContainer: PropTypes.instanceOf(EditorContainer).isRequired,
 };
 };
 
 
-export default withTranslation()(MyDraftListWrapper);
+const MyDraftListWrapperFC = (props) => {
+  const { t } = useTranslation();
+  return <MyDraftList t={t} {...props} />;
+};
+
+/**
+ * Wrapper component for using unstated
+ */
+const MyDraftListWrapper = withUnstatedContainers(MyDraftListWrapperFC, [PageContainer, EditorContainer]);
+
+export default MyDraftListWrapper;

+ 51 - 56
packages/app/src/components/Navbar/GrowiContextualSubNavigation.tsx

@@ -1,45 +1,41 @@
 import React, { useState, useCallback } from 'react';
 import React, { useState, useCallback } from 'react';
-import { useTranslation } from 'react-i18next';
-import PropTypes from 'prop-types';
-
 
 
+import PropTypes from 'prop-types';
+import { useTranslation } from 'react-i18next';
 import { DropdownItem } from 'reactstrap';
 import { DropdownItem } from 'reactstrap';
 
 
-import { OnDuplicatedFunction, OnRenamedFunction, OnDeletedFunction } from '~/interfaces/ui';
-import { IPageHasId, IPageToRenameWithMeta, IPageWithMeta } from '~/interfaces/page';
-
-import { withUnstatedContainers } from '../UnstatedUtils';
 import EditorContainer from '~/client/services/EditorContainer';
 import EditorContainer from '~/client/services/EditorContainer';
+import { exportAsMarkdown } from '~/client/services/page-operation';
+import { toastSuccess, toastError } from '~/client/util/apiNotification';
+import { apiPost } from '~/client/util/apiv1-client';
+import { IPageHasId, IPageToRenameWithMeta, IPageWithMeta } from '~/interfaces/page';
+import { OnDuplicatedFunction, OnRenamedFunction, OnDeletedFunction } from '~/interfaces/ui';
 import {
 import {
-  EditorMode, useDrawerMode, useEditorMode, useIsDeviceSmallerThanMd, useIsAbleToShowPageManagement, useIsAbleToShowTagLabel,
-  useIsAbleToShowPageEditorModeManager, useIsAbleToShowPageAuthors,
-} from '~/stores/ui';
+  useCurrentCreatedAt, useCurrentUpdatedAt, useCurrentPageId, useRevisionId, useCurrentPagePath,
+  useCreator, useRevisionAuthor, useCurrentUser, useIsGuestUser, useIsSharedUser, useShareLinkId,
+} from '~/stores/context';
 import {
 import {
   usePageAccessoriesModal, PageAccessoriesModalContents, IPageForPageDuplicateModal,
   usePageAccessoriesModal, PageAccessoriesModalContents, IPageForPageDuplicateModal,
   usePageDuplicateModal, usePageRenameModal, usePageDeleteModal, usePagePresentationModal,
   usePageDuplicateModal, usePageRenameModal, usePageDeleteModal, usePagePresentationModal,
 } from '~/stores/modal';
 } from '~/stores/modal';
-
-
-import {
-  useCurrentCreatedAt, useCurrentUpdatedAt, useCurrentPageId, useRevisionId, useCurrentPagePath,
-  useCreator, useRevisionAuthor, useCurrentUser, useIsGuestUser, useIsSharedUser, useShareLinkId,
-} from '~/stores/context';
 import { useSWRTagsInfo } from '~/stores/page';
 import { useSWRTagsInfo } from '~/stores/page';
+import {
+  EditorMode, useDrawerMode, useEditorMode, useIsDeviceSmallerThanMd, useIsAbleToShowPageManagement, useIsAbleToShowTagLabel,
+  useIsAbleToShowPageEditorModeManager, useIsAbleToShowPageAuthors,
+} from '~/stores/ui';
 
 
-
-import { toastSuccess, toastError } from '~/client/util/apiNotification';
-import { apiPost } from '~/client/util/apiv1-client';
-
-import HistoryIcon from '../Icons/HistoryIcon';
+import { AdditionalMenuItemsRendererProps } from '../Common/Dropdown/PageItemControl';
+import CreateTemplateModal from '../CreateTemplateModal';
 import AttachmentIcon from '../Icons/AttachmentIcon';
 import AttachmentIcon from '../Icons/AttachmentIcon';
+import HistoryIcon from '../Icons/HistoryIcon';
+import PresentationIcon from '../Icons/PresentationIcon';
 import ShareLinkIcon from '../Icons/ShareLinkIcon';
 import ShareLinkIcon from '../Icons/ShareLinkIcon';
-import { AdditionalMenuItemsRendererProps } from '../Common/Dropdown/PageItemControl';
-import { SubNavButtons } from './SubNavButtons';
-import PageEditorModeManager from './PageEditorModeManager';
+import { withUnstatedContainers } from '../UnstatedUtils';
+
+
 import { GrowiSubNavigation } from './GrowiSubNavigation';
 import { GrowiSubNavigation } from './GrowiSubNavigation';
-import PresentationIcon from '../Icons/PresentationIcon';
-import CreateTemplateModal from '../CreateTemplateModal';
-import { exportAsMarkdown } from '~/client/services/page-operation';
+import PageEditorModeManager from './PageEditorModeManager';
+import { SubNavButtons } from './SubNavButtons';
 
 
 
 
 type AdditionalMenuItemsProps = AdditionalMenuItemsRendererProps & {
 type AdditionalMenuItemsProps = AdditionalMenuItemsRendererProps & {
@@ -101,6 +97,7 @@ const AdditionalMenuItems = (props: AdditionalMenuItemsProps): JSX.Element => {
       <DropdownItem
       <DropdownItem
         onClick={() => openAccessoriesModal(PageAccessoriesModalContents.PageHistory)}
         onClick={() => openAccessoriesModal(PageAccessoriesModalContents.PageHistory)}
         disabled={isGuestUser || isSharedUser}
         disabled={isGuestUser || isSharedUser}
+        data-testid="open-page-accessories-modal-btn-with-history-tab"
         className="grw-page-control-dropdown-item"
         className="grw-page-control-dropdown-item"
       >
       >
         <span className="grw-page-control-dropdown-icon">
         <span className="grw-page-control-dropdown-icon">
@@ -111,6 +108,7 @@ const AdditionalMenuItems = (props: AdditionalMenuItemsProps): JSX.Element => {
 
 
       <DropdownItem
       <DropdownItem
         onClick={() => openAccessoriesModal(PageAccessoriesModalContents.Attachment)}
         onClick={() => openAccessoriesModal(PageAccessoriesModalContents.Attachment)}
+        data-testid="open-page-accessories-modal-btn-with-attachment-data-tab"
         className="grw-page-control-dropdown-item"
         className="grw-page-control-dropdown-item"
       >
       >
         <span className="grw-page-control-dropdown-icon">
         <span className="grw-page-control-dropdown-icon">
@@ -136,6 +134,7 @@ const AdditionalMenuItems = (props: AdditionalMenuItemsProps): JSX.Element => {
       <DropdownItem
       <DropdownItem
         onClick={openPageTemplateModalHandler}
         onClick={openPageTemplateModalHandler}
         className="grw-page-control-dropdown-item"
         className="grw-page-control-dropdown-item"
+        data-testid="open-page-template-modal-btn"
       >
       >
         <i className="icon-fw icon-magic-wand grw-page-control-dropdown-icon"></i>
         <i className="icon-fw icon-magic-wand grw-page-control-dropdown-icon"></i>
         { t('template.option_label.create/edit') }
         { t('template.option_label.create/edit') }
@@ -246,36 +245,34 @@ const GrowiContextualSubNavigation = (props) => {
       mutateEditorMode(viewType);
       mutateEditorMode(viewType);
     }
     }
 
 
-    const className = `d-flex flex-column align-items-end justify-content-center ${isViewMode ? ' h-50' : ''}`;
-
     return (
     return (
       <>
       <>
-        <div className={className}>
+        <div className="d-flex flex-column align-items-end justify-content-center py-md-2" style={{ gap: `${isCompactMode ? '5px' : '7px'}` }}>
           { pageId != null && isViewMode && (
           { pageId != null && isViewMode && (
-            <SubNavButtons
-              isCompactMode={isCompactMode}
-              pageId={pageId}
-              shareLinkId={shareLinkId}
-              revisionId={revisionId}
-              path={path}
-              disableSeenUserInfoPopover={isSharedUser}
-              showPageControlDropdown={isAbleToShowPageManagement}
-              additionalMenuItemRenderer={props => (
-                <AdditionalMenuItems
-                  {...props}
-                  pageId={pageId}
-                  revisionId={revisionId}
-                  isLinkSharingDisabled={isLinkSharingDisabled}
-                  onClickTemplateMenuItem={templateMenuItemClickHandler}
-                />
-              )}
-              onClickDuplicateMenuItem={duplicateItemClickedHandler}
-              onClickRenameMenuItem={renameItemClickedHandler}
-              onClickDeleteMenuItem={deleteItemClickedHandler}
-            />
+            <div className="h-50">
+              <SubNavButtons
+                isCompactMode={isCompactMode}
+                pageId={pageId}
+                shareLinkId={shareLinkId}
+                revisionId={revisionId}
+                path={path}
+                disableSeenUserInfoPopover={isSharedUser}
+                showPageControlDropdown={isAbleToShowPageManagement}
+                additionalMenuItemRenderer={props => (
+                  <AdditionalMenuItems
+                    {...props}
+                    pageId={pageId}
+                    revisionId={revisionId}
+                    isLinkSharingDisabled={isLinkSharingDisabled}
+                    onClickTemplateMenuItem={templateMenuItemClickHandler}
+                  />
+                )}
+                onClickDuplicateMenuItem={duplicateItemClickedHandler}
+                onClickRenameMenuItem={renameItemClickedHandler}
+                onClickDeleteMenuItem={deleteItemClickedHandler}
+              />
+            </div>
           ) }
           ) }
-        </div>
-        <div className={`${className} ${isCompactMode ? '' : 'mt-2'}`}>
           {isAbleToShowPageEditorModeManager && (
           {isAbleToShowPageEditorModeManager && (
             <PageEditorModeManager
             <PageEditorModeManager
               onPageEditorModeButtonClicked={onPageEditorModeButtonClicked}
               onPageEditorModeButtonClicked={onPageEditorModeButtonClicked}
@@ -285,7 +282,7 @@ const GrowiContextualSubNavigation = (props) => {
             />
             />
           )}
           )}
         </div>
         </div>
-        {currentUser != null && (
+        {path != null && currentUser != null && (
           <CreateTemplateModal
           <CreateTemplateModal
             path={path}
             path={path}
             isOpen={isPageTemplateModalShown}
             isOpen={isPageTemplateModalShown}
@@ -302,7 +299,6 @@ const GrowiContextualSubNavigation = (props) => {
     path, templateMenuItemClickHandler, isPageTemplateModalShown,
     path, templateMenuItemClickHandler, isPageTemplateModalShown,
   ]);
   ]);
 
 
-
   if (path == null) {
   if (path == null) {
     return <></>;
     return <></>;
   }
   }
@@ -317,7 +313,6 @@ const GrowiContextualSubNavigation = (props) => {
     updatedAt: updatedAt ?? undefined,
     updatedAt: updatedAt ?? undefined,
   };
   };
 
 
-
   return (
   return (
     <GrowiSubNavigation
     <GrowiSubNavigation
       page={currentPage}
       page={currentPage}

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

@@ -5,8 +5,9 @@ import { useTranslation } from 'react-i18next';
 import { UncontrolledTooltip } from 'reactstrap';
 import { UncontrolledTooltip } from 'reactstrap';
 
 
 import AppContainer from '~/client/services/AppContainer';
 import AppContainer from '~/client/services/AppContainer';
-import { IUser } from '~/interfaces/user';
-import { useIsSearchPage, useCurrentPagePath } from '~/stores/context';
+import {
+  useIsSearchPage, useCurrentPagePath, useIsGuestUser,
+} from '~/stores/context';
 import { usePageCreateModal } from '~/stores/modal';
 import { usePageCreateModal } from '~/stores/modal';
 import { useIsDeviceSmallerThanMd } from '~/stores/ui';
 import { useIsDeviceSmallerThanMd } from '~/stores/ui';
 
 
@@ -19,16 +20,15 @@ import GlobalSearch from './GlobalSearch';
 import PersonalDropdown from './PersonalDropdown';
 import PersonalDropdown from './PersonalDropdown';
 
 
 
 
-type NavbarRightProps = {
-  currentUser: IUser,
-}
-const NavbarRight: FC<NavbarRightProps> = memo((props: NavbarRightProps) => {
+const NavbarRight = memo((): JSX.Element => {
   const { t } = useTranslation();
   const { t } = useTranslation();
+
   const { data: currentPagePath } = useCurrentPagePath();
   const { data: currentPagePath } = useCurrentPagePath();
+  const { data: isGuestUser } = useIsGuestUser();
+
   const { open: openCreateModal } = usePageCreateModal();
   const { open: openCreateModal } = usePageCreateModal();
 
 
-  const { currentUser } = props;
-  const isAuthenticated = currentUser != null;
+  const isAuthenticated = isGuestUser === false;
 
 
   const authenticatedNavItem = useMemo(() => {
   const authenticatedNavItem = useMemo(() => {
     return (
     return (
@@ -110,7 +110,6 @@ const Confidential: FC<ConfidentialProps> = memo((props: ConfidentialProps) => {
 const GrowiNavbar = (props) => {
 const GrowiNavbar = (props) => {
 
 
   const { appContainer } = props;
   const { appContainer } = props;
-  const { currentUser } = appContainer;
   const { crowi, isSearchServiceConfigured } = appContainer.config;
   const { crowi, isSearchServiceConfigured } = appContainer.config;
 
 
   const { data: isDeviceSmallerThanMd } = useIsDeviceSmallerThanMd();
   const { data: isDeviceSmallerThanMd } = useIsDeviceSmallerThanMd();
@@ -132,7 +131,7 @@ const GrowiNavbar = (props) => {
 
 
       {/* Navbar Right  */}
       {/* Navbar Right  */}
       <ul className="navbar-nav ml-auto">
       <ul className="navbar-nav ml-auto">
-        <NavbarRight currentUser={currentUser}></NavbarRight>
+        <NavbarRight></NavbarRight>
         <Confidential confidential={crowi.confidential}></Confidential>
         <Confidential confidential={crowi.confidential}></Confidential>
       </ul>
       </ul>
 
 

+ 3 - 7
packages/app/src/components/Navbar/GrowiSubNavigation.tsx

@@ -1,19 +1,17 @@
 import React from 'react';
 import React from 'react';
 
 
 import { IPageHasId } from '~/interfaces/page';
 import { IPageHasId } from '~/interfaces/page';
-
+import { IUser } from '~/interfaces/user';
 import {
 import {
   EditorMode, useEditorMode,
   EditorMode, useEditorMode,
 } from '~/stores/ui';
 } from '~/stores/ui';
 
 
 import TagLabels from '../Page/TagLabels';
 import TagLabels from '../Page/TagLabels';
+import PagePathNav from '../PagePathNav';
 
 
 import AuthorInfo from './AuthorInfo';
 import AuthorInfo from './AuthorInfo';
 import DrawerToggler from './DrawerToggler';
 import DrawerToggler from './DrawerToggler';
 
 
-import PagePathNav from '../PagePathNav';
-import { IUser } from '~/interfaces/user';
-
 
 
 type Props = {
 type Props = {
   page: Partial<IPageHasId>,
   page: Partial<IPageHasId>,
@@ -85,9 +83,7 @@ export const GrowiSubNavigation = (props: Props): JSX.Element => {
       {/* Right side */}
       {/* Right side */}
       <div className="d-flex">
       <div className="d-flex">
 
 
-        <div className="d-flex flex-column py-md-2" style={{ gap: `${isCompactMode ? '5px' : '0'}` }}>
-          { Controls && <Controls></Controls> }
-        </div>
+        { Controls && <Controls></Controls> }
 
 
         {/* Page Authors */}
         {/* Page Authors */}
         { (showPageAuthors && !isCompactMode) && (
         { (showPageAuthors && !isCompactMode) && (

+ 8 - 8
packages/app/src/components/Navbar/SubNavButtons.tsx

@@ -1,23 +1,22 @@
 import React, { useCallback } from 'react';
 import React, { useCallback } from 'react';
 
 
+import { toggleBookmark, toggleLike, toggleSubscribe } from '~/client/services/page-operation';
 import {
 import {
   IPageInfoAll, IPageToDeleteWithMeta, IPageToRenameWithMeta, isIPageInfoForEntity, isIPageInfoForOperation,
   IPageInfoAll, IPageToDeleteWithMeta, IPageToRenameWithMeta, isIPageInfoForEntity, isIPageInfoForOperation,
 } from '~/interfaces/page';
 } from '~/interfaces/page';
-
-import { useSWRxPageInfo } from '../../stores/page';
-import { useSWRBookmarkInfo } from '../../stores/bookmark';
-import { useSWRxUsersList } from '../../stores/user';
 import { useIsGuestUser } from '~/stores/context';
 import { useIsGuestUser } from '~/stores/context';
 import { IPageForPageDuplicateModal } from '~/stores/modal';
 import { IPageForPageDuplicateModal } from '~/stores/modal';
 
 
-import SubscribeButton from '../SubscribeButton';
-import LikeButtons from '../LikeButtons';
+import { useSWRBookmarkInfo } from '../../stores/bookmark';
+import { useSWRxPageInfo } from '../../stores/page';
+import { useSWRxUsersList } from '../../stores/user';
 import BookmarkButtons from '../BookmarkButtons';
 import BookmarkButtons from '../BookmarkButtons';
-import SeenUserInfo from '../User/SeenUserInfo';
-import { toggleBookmark, toggleLike, toggleSubscribe } from '~/client/services/page-operation';
 import {
 import {
   AdditionalMenuItemsRendererProps, ForceHideMenuItems, MenuItemType, PageItemControl,
   AdditionalMenuItemsRendererProps, ForceHideMenuItems, MenuItemType, PageItemControl,
 } from '../Common/Dropdown/PageItemControl';
 } from '../Common/Dropdown/PageItemControl';
+import LikeButtons from '../LikeButtons';
+import SubscribeButton from '../SubscribeButton';
+import SeenUserInfo from '../User/SeenUserInfo';
 
 
 
 
 type CommonProps = {
 type CommonProps = {
@@ -184,6 +183,7 @@ const SubNavButtonsSubstance = (props: SubNavButtonsSubstanceProps): JSX.Element
       ) }
       ) }
       { showPageControlDropdown && (
       { showPageControlDropdown && (
         <PageItemControl
         <PageItemControl
+          alignRight
           pageId={pageId}
           pageId={pageId}
           pageInfo={pageInfo}
           pageInfo={pageInfo}
           isEnableActions={!isGuestUser}
           isEnableActions={!isGuestUser}

+ 7 - 9
packages/app/src/components/NotAvailableForGuest.jsx

@@ -1,17 +1,16 @@
 import React from 'react';
 import React from 'react';
-import PropTypes from 'prop-types';
 
 
+import PropTypes from 'prop-types';
 import { UncontrolledTooltip } from 'reactstrap';
 import { UncontrolledTooltip } from 'reactstrap';
 
 
-import AppContainer from '~/client/services/AppContainer';
-
-import { withUnstatedContainers } from './UnstatedUtils';
+import { useIsGuestUser } from '~/stores/context';
 
 
 const NotAvailableForGuest = (props) => {
 const NotAvailableForGuest = (props) => {
-  const { appContainer, children } = props;
-  const isLoggedin = appContainer.currentUser != null;
+  const { children } = props;
+
+  const { data: isGuestUser } = useIsGuestUser();
 
 
-  if (isLoggedin) {
+  if (!isGuestUser) {
     return props.children;
     return props.children;
   }
   }
 
 
@@ -34,8 +33,7 @@ const NotAvailableForGuest = (props) => {
 };
 };
 
 
 NotAvailableForGuest.propTypes = {
 NotAvailableForGuest.propTypes = {
-  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
   children: PropTypes.node.isRequired,
   children: PropTypes.node.isRequired,
 };
 };
 
 
-export default withUnstatedContainers(NotAvailableForGuest, [AppContainer]);
+export default NotAvailableForGuest;

+ 37 - 9
packages/app/src/components/Page.jsx

@@ -1,4 +1,4 @@
-import React from 'react';
+import React, { useEffect, useRef } from 'react';
 
 
 import PropTypes from 'prop-types';
 import PropTypes from 'prop-types';
 
 
@@ -9,9 +9,9 @@ import EditorContainer from '~/client/services/EditorContainer';
 import PageContainer from '~/client/services/PageContainer';
 import PageContainer from '~/client/services/PageContainer';
 import { getOptionsToSave } from '~/client/util/editor';
 import { getOptionsToSave } from '~/client/util/editor';
 import {
 import {
-  useCurrentPagePath, useIsGuestUser, useSlackChannels,
+  useCurrentPagePath, useIsGuestUser,
 } from '~/stores/context';
 } from '~/stores/context';
-import { useIsSlackEnabled } from '~/stores/editor';
+import { useSWRxSlackChannels, useIsSlackEnabled } from '~/stores/editor';
 import {
 import {
   useEditorMode, useIsMobile, useSelectedGrant, useSelectedGrantGroupId, useSelectedGrantGroupName,
   useEditorMode, useIsMobile, useSelectedGrant, useSelectedGrantGroupId, useSelectedGrantGroupName,
 } from '~/stores/ui';
 } from '~/stores/ui';
@@ -49,10 +49,6 @@ class Page extends React.Component {
     this.saveHandlerForDrawioModal = this.saveHandlerForDrawioModal.bind(this);
     this.saveHandlerForDrawioModal = this.saveHandlerForDrawioModal.bind(this);
   }
   }
 
 
-  componentWillMount() {
-    this.props.appContainer.registerComponentInstance('Page', this);
-  }
-
   /**
   /**
    * launch HandsontableModal with data specified by arguments
    * launch HandsontableModal with data specified by arguments
    * @param beginLineNumber
    * @param beginLineNumber
@@ -191,25 +187,57 @@ const PageWrapper = (props) => {
   const { data: editorMode } = useEditorMode();
   const { data: editorMode } = useEditorMode();
   const { data: isGuestUser } = useIsGuestUser();
   const { data: isGuestUser } = useIsGuestUser();
   const { data: isMobile } = useIsMobile();
   const { data: isMobile } = useIsMobile();
+  const { data: slackChannelsData } = useSWRxSlackChannels(currentPagePath);
   const { data: isSlackEnabled } = useIsSlackEnabled();
   const { data: isSlackEnabled } = useIsSlackEnabled();
-  const { data: slackChannels } = useSlackChannels();
   const { data: grant } = useSelectedGrant();
   const { data: grant } = useSelectedGrant();
   const { data: grantGroupId } = useSelectedGrantGroupId();
   const { data: grantGroupId } = useSelectedGrantGroupId();
   const { data: grantGroupName } = useSelectedGrantGroupName();
   const { data: grantGroupName } = useSelectedGrantGroupName();
 
 
+  const pageRef = useRef(null);
+
+  // 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);
+    };
+  }, []);
+
+  // set handler to open HandsontableModal
+  useEffect(() => {
+    const handler = (beginLineNumber, endLineNumber) => {
+      if (pageRef?.current != null) {
+        pageRef.current.launchHandsontableModal(beginLineNumber, endLineNumber);
+      }
+    };
+    window.globalEmitter.on('launchHandsontableModal', handler);
+
+    return function cleanup() {
+      window.globalEmitter.removeListener('launchHandsontableModal', handler);
+    };
+  }, []);
+
   if (currentPagePath == null || editorMode == null || isGuestUser == null) {
   if (currentPagePath == null || editorMode == null || isGuestUser == null) {
     return null;
     return null;
   }
   }
 
 
+
   return (
   return (
     <Page
     <Page
       {...props}
       {...props}
+      ref={pageRef}
       pagePath={currentPagePath}
       pagePath={currentPagePath}
       editorMode={editorMode}
       editorMode={editorMode}
       isGuestUser={isGuestUser}
       isGuestUser={isGuestUser}
       isMobile={isMobile}
       isMobile={isMobile}
       isSlackEnabled={isSlackEnabled}
       isSlackEnabled={isSlackEnabled}
-      slackChannels={slackChannels}
+      slackChannels={slackChannelsData.toString()}
       grant={grant}
       grant={grant}
       grantGroupId={grantGroupId}
       grantGroupId={grantGroupId}
       grantGroupName={grantGroupName}
       grantGroupName={grantGroupName}

+ 7 - 10
packages/app/src/components/Page/CopyDropdown.jsx

@@ -1,18 +1,16 @@
 import React, {
 import React, {
   useState, useMemo, useCallback,
   useState, useMemo, useCallback,
 } from 'react';
 } from 'react';
-import PropTypes from 'prop-types';
-
-import { withTranslation } from 'react-i18next';
 
 
+import { pagePathUtils } from '@growi/core';
+import PropTypes from 'prop-types';
+import { CopyToClipboard } from 'react-copy-to-clipboard';
+import { useTranslation } from 'react-i18next';
 import {
 import {
   Dropdown, DropdownToggle, DropdownMenu, DropdownItem,
   Dropdown, DropdownToggle, DropdownMenu, DropdownItem,
   Tooltip,
   Tooltip,
 } from 'reactstrap';
 } from 'reactstrap';
 
 
-import { CopyToClipboard } from 'react-copy-to-clipboard';
-
-import { pagePathUtils } from '@growi/core';
 
 
 const { encodeSpaces } = pagePathUtils;
 const { encodeSpaces } = pagePathUtils;
 
 
@@ -102,8 +100,9 @@ const CopyDropdown = (props) => {
   /*
   /*
    * render
    * render
    */
    */
+  const { t } = useTranslation();
   const {
   const {
-    t, dropdownToggleId, pageId, dropdownToggleClassName, children, isShareLinkMode,
+    dropdownToggleId, pageId, dropdownToggleClassName, children, isShareLinkMode,
   } = props;
   } = props;
 
 
   const customSwitchForParamsId = `customSwitchForParams_${dropdownToggleId}`;
   const customSwitchForParamsId = `customSwitchForParams_${dropdownToggleId}`;
@@ -199,8 +198,6 @@ const CopyDropdown = (props) => {
 };
 };
 
 
 CopyDropdown.propTypes = {
 CopyDropdown.propTypes = {
-  t: PropTypes.func.isRequired, // i18next
-
   children: PropTypes.node.isRequired,
   children: PropTypes.node.isRequired,
   dropdownToggleId: PropTypes.string.isRequired,
   dropdownToggleId: PropTypes.string.isRequired,
   pagePath: PropTypes.string.isRequired,
   pagePath: PropTypes.string.isRequired,
@@ -210,4 +207,4 @@ CopyDropdown.propTypes = {
   isShareLinkMode: PropTypes.bool,
   isShareLinkMode: PropTypes.bool,
 };
 };
 
 
-export default withTranslation()(CopyDropdown);
+export default CopyDropdown;

+ 12 - 12
packages/app/src/components/Page/DisplaySwitcher.tsx

@@ -7,10 +7,10 @@ import { TabContent, TabPane } from 'reactstrap';
 
 
 import { smoothScrollIntoView } from '~/client/util/smooth-scroll';
 import { smoothScrollIntoView } from '~/client/util/smooth-scroll';
 import {
 import {
-  useCurrentPagePath, useIsSharedUser, useIsEditable, useCurrentPageId, useIsUserPage, usePageUser,
+  useCurrentPagePath, useIsSharedUser, useIsEditable, useCurrentPageId, useIsUserPage, usePageUser, useShareLinkId,
 } from '~/stores/context';
 } from '~/stores/context';
 import { useDescendantsPageListModal } from '~/stores/modal';
 import { useDescendantsPageListModal } from '~/stores/modal';
-import { useSWRxPageByPath } from '~/stores/page';
+import { useSWRxCurrentPage } from '~/stores/page';
 import { EditorMode, useEditorMode } from '~/stores/ui';
 import { EditorMode, useEditorMode } from '~/stores/ui';
 
 
 import CountBadge from '../Common/CountBadge';
 import CountBadge from '../Common/CountBadge';
@@ -18,7 +18,7 @@ import ContentLinkButtons from '../ContentLinkButtons';
 import HashChanged from '../EventListeneres/HashChanged';
 import HashChanged from '../EventListeneres/HashChanged';
 import PageListIcon from '../Icons/PageListIcon';
 import PageListIcon from '../Icons/PageListIcon';
 import Page from '../Page';
 import Page from '../Page';
-import Editor from '../PageEditor';
+import PageEditor from '../PageEditor';
 import EditorNavbarBottom from '../PageEditor/EditorNavbarBottom';
 import EditorNavbarBottom from '../PageEditor/EditorNavbarBottom';
 import PageEditorByHackmd from '../PageEditorByHackmd';
 import PageEditorByHackmd from '../PageEditorByHackmd';
 import TableOfContents from '../TableOfContents';
 import TableOfContents from '../TableOfContents';
@@ -33,18 +33,18 @@ const { isTopPage } = pagePathUtils;
 const DisplaySwitcher = (): JSX.Element => {
 const DisplaySwitcher = (): JSX.Element => {
   const { t } = useTranslation();
   const { t } = useTranslation();
 
 
-
   // get element for smoothScroll
   // get element for smoothScroll
   const getCommentListDom = useMemo(() => { return document.getElementById('page-comments-list') }, []);
   const getCommentListDom = useMemo(() => { return document.getElementById('page-comments-list') }, []);
 
 
 
 
   const { data: currentPageId } = useCurrentPageId();
   const { data: currentPageId } = useCurrentPageId();
-  const { data: currentPath } = useCurrentPagePath();
+  const { data: currentPagePath } = useCurrentPagePath();
   const { data: isSharedUser } = useIsSharedUser();
   const { data: isSharedUser } = useIsSharedUser();
+  const { data: shareLinkId } = useShareLinkId();
   const { data: isUserPage } = useIsUserPage();
   const { data: isUserPage } = useIsUserPage();
   const { data: isEditable } = useIsEditable();
   const { data: isEditable } = useIsEditable();
   const { data: pageUser } = usePageUser();
   const { data: pageUser } = usePageUser();
-  const { data: currentPage } = useSWRxPageByPath(currentPath);
+  const { data: currentPage } = useSWRxCurrentPage(shareLinkId ?? undefined);
 
 
   const { data: editorMode } = useEditorMode();
   const { data: editorMode } = useEditorMode();
 
 
@@ -52,7 +52,7 @@ const DisplaySwitcher = (): JSX.Element => {
 
 
   const isPageExist = currentPageId != null;
   const isPageExist = currentPageId != null;
   const isViewMode = editorMode === EditorMode.View;
   const isViewMode = editorMode === EditorMode.View;
-  const isTopPagePath = isTopPage(currentPath ?? '');
+  const isTopPagePath = isTopPage(currentPagePath ?? '');
 
 
   return (
   return (
     <>
     <>
@@ -66,17 +66,17 @@ const DisplaySwitcher = (): JSX.Element => {
 
 
                   {/* Page list */}
                   {/* Page list */}
                   <div className="grw-page-accessories-control">
                   <div className="grw-page-accessories-control">
-                    { currentPath != null && !isSharedUser && (
+                    { currentPagePath != null && !isSharedUser && (
                       <button
                       <button
                         type="button"
                         type="button"
                         className="btn btn-block btn-outline-secondary grw-btn-page-accessories rounded-pill d-flex justify-content-between align-items-center"
                         className="btn btn-block btn-outline-secondary grw-btn-page-accessories rounded-pill d-flex justify-content-between align-items-center"
-                        onClick={() => openDescendantPageListModal(currentPath)}
+                        onClick={() => openDescendantPageListModal(currentPagePath)}
                       >
                       >
                         <div className="grw-page-accessories-control-icon">
                         <div className="grw-page-accessories-control-icon">
                           <PageListIcon />
                           <PageListIcon />
                         </div>
                         </div>
                         {t('page_list')}
                         {t('page_list')}
-                        {currentPage?.descendantCount != null && <CountBadge count={currentPage.descendantCount + 1} />}
+                        <CountBadge count={currentPage?.descendantCount} offset={1} />
                       </button>
                       </button>
                     ) }
                     ) }
                   </div>
                   </div>
@@ -91,7 +91,7 @@ const DisplaySwitcher = (): JSX.Element => {
                       >
                       >
                         <i className="icon-fw icon-bubbles grw-page-accessories-control-icon"></i>
                         <i className="icon-fw icon-bubbles grw-page-accessories-control-icon"></i>
                         <span>Comments</span>
                         <span>Comments</span>
-                        {currentPage?.commentCount != null && <CountBadge count={currentPage.commentCount} />}
+                        <CountBadge count={currentPage?.commentCount} />
                       </button>
                       </button>
                     </div>
                     </div>
                   ) }
                   ) }
@@ -117,7 +117,7 @@ const DisplaySwitcher = (): JSX.Element => {
         { isEditable && (
         { isEditable && (
           <TabPane tabId={EditorMode.Editor}>
           <TabPane tabId={EditorMode.Editor}>
             <div data-testid="page-editor" id="page-editor">
             <div data-testid="page-editor" id="page-editor">
-              <Editor />
+              <PageEditor />
             </div>
             </div>
           </TabPane>
           </TabPane>
         ) }
         ) }

+ 7 - 5
packages/app/src/components/Page/FixPageGrantAlert.tsx

@@ -9,7 +9,7 @@ import { toastError, toastSuccess } from '~/client/util/apiNotification';
 import { apiv3Put } from '~/client/util/apiv3-client';
 import { apiv3Put } from '~/client/util/apiv3-client';
 import { PageGrant, IPageGrantData } from '~/interfaces/page';
 import { PageGrant, IPageGrantData } from '~/interfaces/page';
 import { IRecordApplicableGrant, IResIsGrantNormalizedGrantData } from '~/interfaces/page-grant';
 import { IRecordApplicableGrant, IResIsGrantNormalizedGrantData } from '~/interfaces/page-grant';
-import { useCurrentPageId, useHasParent } from '~/stores/context';
+import { useCurrentPageId, useCurrentUser, useHasParent } from '~/stores/context';
 import { useSWRxApplicableGrant, useSWRxIsGrantNormalized } from '~/stores/page';
 import { useSWRxApplicableGrant, useSWRxIsGrantNormalized } from '~/stores/page';
 
 
 type ModalProps = {
 type ModalProps = {
@@ -231,12 +231,14 @@ const FixPageGrantModal = (props: ModalProps): JSX.Element => {
 const FixPageGrantAlert = (): JSX.Element => {
 const FixPageGrantAlert = (): JSX.Element => {
   const { t } = useTranslation();
   const { t } = useTranslation();
 
 
-  const [isOpen, setOpen] = useState<boolean>(false);
-
+  const { data: currentUser } = useCurrentUser();
   const { data: pageId } = useCurrentPageId();
   const { data: pageId } = useCurrentPageId();
   const { data: hasParent } = useHasParent();
   const { data: hasParent } = useHasParent();
-  const { data: dataIsGrantNormalized } = useSWRxIsGrantNormalized(pageId);
-  const { data: dataApplicableGrant } = useSWRxApplicableGrant(pageId);
+
+  const [isOpen, setOpen] = useState<boolean>(false);
+
+  const { data: dataIsGrantNormalized } = useSWRxIsGrantNormalized(currentUser != null ? pageId : null);
+  const { data: dataApplicableGrant } = useSWRxApplicableGrant(currentUser != null ? pageId : null);
 
 
   // Dependencies
   // Dependencies
   if (!hasParent) {
   if (!hasParent) {

+ 0 - 72
packages/app/src/components/Page/NotFoundAlert.tsx

@@ -1,72 +0,0 @@
-import React, { useCallback } from 'react';
-import { useTranslation } from 'react-i18next';
-import { UncontrolledTooltip } from 'reactstrap';
-import { useIsNotFoundPermalink } from '~/stores/context';
-
-import { EditorMode, useEditorMode } from '~/stores/ui';
-
-
-type Props = {
-  isGuestUserMode?: boolean,
-}
-
-const NotFoundAlert = (props: Props): JSX.Element => {
-  const { t } = useTranslation();
-  const { isGuestUserMode } = props;
-
-  const { data: editorMode, mutate: mutateEditorMode } = useEditorMode();
-  const { data: isNotFoundPermalink } = useIsNotFoundPermalink(); // TODO: Remove this when renaming on editor is implemented
-
-  const isEditorMode = editorMode !== EditorMode.View;
-
-  const clickHandler = useCallback(() => {
-    // check guest user,
-    // disabled of button cannot be used for using tooltip.
-    if (isGuestUserMode) {
-      return;
-    }
-
-    mutateEditorMode(EditorMode.Editor);
-
-  }, [isGuestUserMode, mutateEditorMode]);
-
-  if (isEditorMode) {
-    return <></>;
-  }
-
-  return (
-    <div className="border border-info p-3">
-      <div
-        className="col-md-12 p-0"
-      >
-        <h2 className="text-info lead">
-          <i className="icon-info pr-2 font-weight-bold" aria-hidden="true"></i>
-          {t('not_found_page.page_not_exist_alert')}
-        </h2>
-        {
-          !isNotFoundPermalink && (
-            <div id="create-page-btn-wrapper-for-tooltip" className="d-inline-block">
-              <button
-                type="button"
-                className={`pl-3 pr-3 btn bg-info text-white ${isGuestUserMode ? 'disabled' : ''}`}
-                onClick={clickHandler}
-              >
-                <i className="icon-note icon-fw" />
-                {t('not_found_page.Create Page')}
-              </button>
-            </div>
-          )
-        }
-
-        {!isNotFoundPermalink && isGuestUserMode && (
-          <UncontrolledTooltip placement="bottom" target="create-page-btn-wrapper-for-tooltip" fade={false}>
-            {t('Not available for guest')}
-          </UncontrolledTooltip>
-        )}
-      </div>
-    </div>
-  );
-};
-
-
-export default NotFoundAlert;

+ 5 - 5
packages/app/src/components/Page/RevisionBody.jsx

@@ -1,6 +1,6 @@
 import React from 'react';
 import React from 'react';
-import PropTypes from 'prop-types';
 
 
+import PropTypes from 'prop-types';
 import { debounce } from 'throttle-debounce';
 import { debounce } from 'throttle-debounce';
 
 
 export default class RevisionBody extends React.PureComponent {
 export default class RevisionBody extends React.PureComponent {
@@ -58,10 +58,10 @@ export default class RevisionBody extends React.PureComponent {
     const additionalClassName = this.props.additionalClassName || '';
     const additionalClassName = this.props.additionalClassName || '';
     return (
     return (
       <div
       <div
-        ref={(elm) => {
-          this.element = elm;
+        ref={(elem) => {
+          this.element = elem;
           if (this.props.inputRef != null) {
           if (this.props.inputRef != null) {
-            this.props.inputRef(elm);
+            this.props.inputRef.current = elem;
           }
           }
         }}
         }}
         id="wiki"
         id="wiki"
@@ -76,7 +76,7 @@ export default class RevisionBody extends React.PureComponent {
 
 
 RevisionBody.propTypes = {
 RevisionBody.propTypes = {
   html: PropTypes.string,
   html: PropTypes.string,
-  inputRef: PropTypes.func, // for getting div element
+  inputRef: PropTypes.shape({ current: PropTypes.instanceOf(Element) }),
   isMathJaxEnabled: PropTypes.bool,
   isMathJaxEnabled: PropTypes.bool,
   renderMathJaxOnInit: PropTypes.bool,
   renderMathJaxOnInit: PropTypes.bool,
   renderMathJaxInRealtime: PropTypes.bool,
   renderMathJaxInRealtime: PropTypes.bool,

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