2
0
yuken 3 жил өмнө
parent
commit
a587529e22
100 өөрчлөгдсөн 2022 нэмэгдсэн , 1822 устгасан
  1. 8 2
      .github/workflows/reusable-app-prod.yml
  2. 92 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. 3 3
      packages/app/config/webpack.common.js
  9. 4 4
      packages/app/docker/README.md
  10. 12 10
      packages/app/package.json
  11. 1 4
      packages/app/resource/Contributor.js
  12. 40 3
      packages/app/resource/locales/en_US/translation.json
  13. 40 3
      packages/app/resource/locales/ja_JP/translation.json
  14. 41 4
      packages/app/resource/locales/zh_CN/translation.json
  15. 3 4
      packages/app/src/client/app.jsx
  16. 4 0
      packages/app/src/client/base.jsx
  17. 2 122
      packages/app/src/client/legacy/crowi.js
  18. 0 31
      packages/app/src/client/services/AppContainer.js
  19. 0 4
      packages/app/src/client/services/CommentContainer.js
  20. 3 3
      packages/app/src/client/services/ContextExtractor.tsx
  21. 0 14
      packages/app/src/client/services/EditorContainer.js
  22. 12 43
      packages/app/src/client/services/PageContainer.js
  23. 0 77
      packages/app/src/client/services/PersonalContainer.js
  24. 15 17
      packages/app/src/client/util/GrowiRenderer.js
  25. 4 4
      packages/app/src/client/util/editor.ts
  26. 2 8
      packages/app/src/client/util/interceptor/detach-code-blocks.js
  27. 7 11
      packages/app/src/client/util/interceptor/drawio-interceptor.js
  28. 16 16
      packages/app/src/client/util/markdown-it/emoji-mart-data.ts
  29. 4 7
      packages/app/src/client/util/markdown-it/emoji.js
  30. 0 4
      packages/app/src/client/util/markdown-it/footernote.js
  31. 1 2
      packages/app/src/client/util/markdown-it/header-line-number.js
  32. 1 5
      packages/app/src/client/util/markdown-it/header-with-edit-link.js
  33. 1 3
      packages/app/src/client/util/markdown-it/header.js
  34. 1 5
      packages/app/src/client/util/markdown-it/table-with-handsontable-button.js
  35. 0 4
      packages/app/src/client/util/markdown-it/table.js
  36. 18 19
      packages/app/src/client/util/markdown-it/toc-and-anchor.js
  37. 8 5
      packages/app/src/components/Admin/App/AwsSetting.jsx
  38. 8 3
      packages/app/src/components/Admin/ExportArchiveDataPage.jsx
  39. 0 33
      packages/app/src/components/Admin/FullTextSearchManagement.jsx
  40. 22 0
      packages/app/src/components/Admin/FullTextSearchManagement.tsx
  41. 13 7
      packages/app/src/components/Admin/ManageExternalAccount.jsx
  42. 11 5
      packages/app/src/components/Admin/UserGroupDetail/UserGroupUserFormByInput.jsx
  43. 9 6
      packages/app/src/components/ArchiveCreateModal.jsx
  44. 18 8
      packages/app/src/components/BookmarkButtons.tsx
  45. 9 6
      packages/app/src/components/Common/CountBadge.tsx
  46. 3 2
      packages/app/src/components/Common/Dropdown/PageItemControl.tsx
  47. 8 10
      packages/app/src/components/CreateTemplateModal.jsx
  48. 11 3
      packages/app/src/components/DescendantsPageList.tsx
  49. 0 108
      packages/app/src/components/Drawio.jsx
  50. 97 0
      packages/app/src/components/Drawio.tsx
  51. 4 12
      packages/app/src/components/Fab.jsx
  52. 1 1
      packages/app/src/components/FormattedDistanceDate.jsx
  53. 9 6
      packages/app/src/components/InAppNotification/InAppNotificationDropdown.tsx
  54. 5 4
      packages/app/src/components/InAppNotification/PageNotification/PageModelNotification.tsx
  55. 8 3
      packages/app/src/components/InstallerForm.jsx
  56. 21 9
      packages/app/src/components/LikeButtons.tsx
  57. 14 8
      packages/app/src/components/LoginForm.jsx
  58. 12 4
      packages/app/src/components/Me/ApiSettings.jsx
  59. 11 4
      packages/app/src/components/Me/AssociateModal.jsx
  60. 12 4
      packages/app/src/components/Me/BasicInfoSettings.jsx
  61. 17 7
      packages/app/src/components/Me/DisassociateModal.jsx
  62. 12 4
      packages/app/src/components/Me/ExternalAccountLinkedMe.jsx
  63. 5 7
      packages/app/src/components/Me/ExternalAccountRow.jsx
  64. 14 13
      packages/app/src/components/Me/ImageCropModal.jsx
  65. 17 11
      packages/app/src/components/Me/PasswordSettings.jsx
  66. 4 8
      packages/app/src/components/Me/PersonalSettings.jsx
  67. 0 197
      packages/app/src/components/Me/ProfileImageSettings.jsx
  68. 188 0
      packages/app/src/components/Me/ProfileImageSettings.tsx
  69. 0 36
      packages/app/src/components/Me/UserSettings.jsx
  70. 29 0
      packages/app/src/components/Me/UserSettings.tsx
  71. 14 11
      packages/app/src/components/MyDraftList/Draft.jsx
  72. 12 8
      packages/app/src/components/MyDraftList/MyDraftList.jsx
  73. 51 56
      packages/app/src/components/Navbar/GrowiContextualSubNavigation.tsx
  74. 9 10
      packages/app/src/components/Navbar/GrowiNavbar.tsx
  75. 3 7
      packages/app/src/components/Navbar/GrowiSubNavigation.tsx
  76. 8 8
      packages/app/src/components/Navbar/SubNavButtons.tsx
  77. 7 9
      packages/app/src/components/NotAvailableForGuest.jsx
  78. 37 9
      packages/app/src/components/Page.jsx
  79. 7 10
      packages/app/src/components/Page/CopyDropdown.jsx
  80. 12 12
      packages/app/src/components/Page/DisplaySwitcher.tsx
  81. 280 0
      packages/app/src/components/Page/FixPageGrantAlert.tsx
  82. 0 72
      packages/app/src/components/Page/NotFoundAlert.tsx
  83. 5 5
      packages/app/src/components/Page/RevisionBody.jsx
  84. 1 5
      packages/app/src/components/Page/RevisionRenderer.jsx
  85. 4 11
      packages/app/src/components/Page/ShareLinkAlert.jsx
  86. 12 15
      packages/app/src/components/Page/TrashPageAlert.jsx
  87. 1 0
      packages/app/src/components/PageAccessoriesModal.tsx
  88. 15 16
      packages/app/src/components/PageAccessoriesModalControl.jsx
  89. 30 18
      packages/app/src/components/PageAttachment.jsx
  90. 19 12
      packages/app/src/components/PageComment/Comment.jsx
  91. 33 13
      packages/app/src/components/PageComment/CommentEditor.jsx
  92. 15 20
      packages/app/src/components/PageComment/CommentPreview.jsx
  93. 12 11
      packages/app/src/components/PageCreateModal.jsx
  94. 5 1
      packages/app/src/components/PageDeleteModal.tsx
  95. 0 440
      packages/app/src/components/PageEditor.jsx
  96. 435 0
      packages/app/src/components/PageEditor.tsx
  97. 8 2
      packages/app/src/components/PageEditor/Cheatsheet.jsx
  98. 1 1
      packages/app/src/components/PageEditor/CodeMirrorEditor.jsx
  99. 5 5
      packages/app/src/components/PageEditor/CommentMentionHelper.ts
  100. 42 49
      packages/app/src/components/PageEditor/ConflictDiffModal.tsx

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

@@ -180,7 +180,7 @@ jobs:
       fail-fast: false
       matrix:
         # 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:
       mongodb:
@@ -239,11 +239,17 @@ jobs:
         cat config/ci/.env.local.for-ci >> .env.production.local
 
     - name: Copy dotenv file for automatic installation
-      if: ${{ matrix.spec-group != '1' }}
+      if: ${{ matrix.spec-group != '10' }}
       working-directory: ./packages/app
       run: |
         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
       uses: cypress-io/github-action@v3
       with:

+ 92 - 1
CHANGELOG.md

@@ -1,9 +1,93 @@
 # Changelog
 
-## [Unreleased](https://github.com/weseek/growi/compare/v5.0.5...HEAD)
+## [Unreleased](https://github.com/weseek/growi/compare/v5.0.8...HEAD)
 
 *Please do not manually update this file. We've automated the process.*
 
+## [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
+
+### 💎 Features
+
+- feat: Emoji - replace emojione to emojimart (#5668) @kaoritokashiki
+- feat: Show username suggestion for mention in comment (#5856) @mudana-grune
+- feat: Send in-app notification when containing username mention in comment  (#5906) @mudana-grune
+- feat: Customize menu in navbar for guest user (#5858) @yukendev
+- feat: Admin only page convert by path (#5902) @hakumizuki
+- feat: Fix grant alert (#5903) @hakumizuki
+
+### 🚀 Improvement
+
+- imprv: Automatic login after registration (#5860) @hiroki-hgs
+- imprv: Add tooltip to SubNavButtons (#5887) @miya
+- imprv: Mixin of argument-of-override-list-group-item-for-pagetree for dark theme (#5904) @shukmos
+- imprv: Move code to the appropriate place for fix browser auto-complete email wiith username (#5892) @Yohei-Shiina
+- imprv: Initial rendering when opening Custom Sidebar (#5880) @Kami-jo
+- imprv: Add contributors to staff credit (#5841) @hiroki-hgs
+
+### 🐛 Bug Fixes
+
+- 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
+
+## [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
 
 ### 💎 Features
@@ -28,6 +112,13 @@
 
 - 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
 
 ### 💎 Features

+ 1 - 12
THIRD-PARTY-NOTICES.md

@@ -16,8 +16,7 @@ https://github.com/weseek/growi.
 2. crowi/crowi (https://github.com/crowi/crowi)
 3. Microsoft/vscode (https://github.com/Microsoft/vscode)
 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
@@ -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
 ------------------------
 

+ 1 - 1
lerna.json

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

+ 1 - 1
package.json

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

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

@@ -12,7 +12,6 @@ module.exports = {
   globals: {
     $: true,
     jquery: true,
-    emojione: true,
     hljs: true,
     ScrollPosStyler: 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

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

@@ -2,14 +2,15 @@
  * @author: Yuki Takei <yuki@weseek.co.jp>
  */
 const path = require('path');
+
+const LodashModuleReplacementPlugin = require('lodash-webpack-plugin');
+const TsconfigPathsPlugin = require('tsconfig-paths-webpack-plugin');
 const webpack = require('webpack');
 
 /*
   * Webpack Plugins
   */
 const WebpackAssetsManifest = require('webpack-assets-manifest');
-const LodashModuleReplacementPlugin = require('lodash-webpack-plugin');
-const TsconfigPathsPlugin = require('tsconfig-paths-webpack-plugin');
 
 /*
   * Webpack configuration
@@ -60,7 +61,6 @@ module.exports = (options) => {
       // require("jquery") is external and available
       //  on the global var jQuery
       jquery: 'jQuery',
-      emojione: 'emojione',
       hljs: 'hljs',
       '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
 ------------------------------------------------
 
-* [`5.0.5`, `5.0`, `5`, `latest` (Dockerfile)](https://github.com/weseek/growi/blob/v5.0.5/docker/Dockerfile)
-* [`5.0.5-nocdn`, `5.0-nocdn`, `5-nocdn`, `latest-nocdn` (Dockerfile)](https://github.com/weseek/growi/blob/v5.0.5/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.8`, `5.0`, `5`, `latest` (Dockerfile)](https://github.com/weseek/growi/blob/v5.0.8/docker/Dockerfile)
+* [`5.0.8-nocdn`, `5.0-nocdn`, `5-nocdn`, `latest-nocdn` (Dockerfile)](https://github.com/weseek/growi/blob/v5.0.8/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-nocdn`, `4.4-nocdn` (Dockerfile)](https://github.com/weseek/growi/blob/v4.4.13/docker/Dockerfile)
 

+ 12 - 10
packages/app/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@growi/app",
-  "version": "5.0.6-RC.0",
+  "version": "5.0.9-RC.0",
   "license": "MIT",
   "scripts": {
     "//// for production": "",
@@ -11,7 +11,7 @@
     "clean": "npx -y shx rm -rf dist transpiled",
     "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",
-    "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",
     "preserver": "yarn cross-env NODE_ENV=production yarn migrate",
     "migrate": "node -r dotenv-flow/config node_modules/.bin/migrate-mongo up",
@@ -19,7 +19,7 @@
     "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: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: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",
@@ -57,16 +57,18 @@
     "string-width": "5.0.0 or above exports only ESM."
   },
   "dependencies": {
+    "@aws-sdk/client-s3": "^3.58.0",
+    "@aws-sdk/s3-request-presigner": "^3.58.0",
     "@browser-bunyan/console-formatted-stream": "^1.6.2",
     "@elastic/elasticsearch6": "npm:@elastic/elasticsearch@^6.8.8",
     "@elastic/elasticsearch7": "npm:@elastic/elasticsearch@^7.17.0",
     "@godaddy/terminus": "^4.9.0",
     "@google-cloud/storage": "^5.8.5",
-    "@growi/codemirror-textlint": "^5.0.6-RC.0",
-    "@growi/plugin-attachment-refs": "^5.0.6-RC.0",
-    "@growi/plugin-lsx": "^5.0.6-RC.0",
-    "@growi/plugin-pukiwiki-like-linker": "^5.0.6-RC.0",
-    "@growi/slack": "^5.0.6-RC.0",
+    "@growi/codemirror-textlint": "^5.0.9-RC.0",
+    "@growi/plugin-attachment-refs": "^5.0.9-RC.0",
+    "@growi/plugin-lsx": "^5.0.9-RC.0",
+    "@growi/plugin-pukiwiki-like-linker": "^5.0.9-RC.0",
+    "@growi/slack": "^5.0.9-RC.0",
     "@promster/express": "^7.0.2",
     "@promster/server": "^7.0.4",
     "@slack/events-api": "^3.0.0",
@@ -97,6 +99,7 @@
     "esa-node": "^0.2.2",
     "escape-string-regexp": "=4.0.0",
     "eslint-plugin-regex": "^1.8.0",
+    "expose-gc": "^1.0.0",
     "express": "^4.16.1",
     "express-bunyan-logger": "^1.3.3",
     "express-mongo-sanitize": "^2.1.0",
@@ -143,7 +146,6 @@
     "react-dnd-html5-backend": "^14.1.0",
     "react-image-crop": "^8.3.0",
     "react-multiline-clamp": "^2.0.0",
-    "react-tagcloud": "^2.1.1",
     "reconnecting-websocket": "^4.4.0",
     "redis": "^3.0.2",
     "rimraf": "^3.0.0",
@@ -167,7 +169,7 @@
   },
   "devDependencies": {
     "@alienfast/i18next-loader": "^1.1.4",
-    "@growi/ui": "^5.0.6-RC.0",
+    "@growi/ui": "^5.0.9-RC.0",
     "@handsontable/react": "=2.1.0",
     "@types/compression": "^1.7.0",
     "@types/express": "^4.17.11",

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

@@ -24,7 +24,6 @@ const contributors = [
         members: [
           { name: 'utsushiiro' },
           { name: 'mayumorita' },
-          { name: 'TatsuyaIse' },
           { name: 'shinoka7' },
           { name: 'SeiyaTashiro' },
           { name: 'TsuyoshiSuzukief' },
@@ -35,7 +34,6 @@ const contributors = [
           { name: 'kaishuu0123' },
           { name: 'kouki-o' },
           { name: 'Angola' },
-          { name: 'Yohei-Shiina' },
           { name: 'shukmos' },
           { name: 'sooouh' },
           { name: 'ryouhek' },
@@ -43,6 +41,7 @@ const contributors = [
           { name: 'N1koge' },
           { name: 'Ertai87' },
           { name: 'takayuki-t' },
+          { name: 'ayaka0417' },
           { name: 'zahmis' },
           { name: 'takeru0001' },
           { name: 'Shu Katabe' },
@@ -50,14 +49,12 @@ const contributors = [
           { name: 'makotoshiraishi' },
           { name: 'yamagai' },
           { name: 'stevenfukase' },
-          { name: 'miya' },
           { name: 'kaho819' },
           { name: 'yuto-oweseek' },
           { name: 'maow89126' },
           { name: 'kntowd' },
           { name: 'yukendev' },
           { name: 'asami-n' },
-          { name: 'ryohi15' },
           { name: 'yoshiro-s' },
           { name: 'kuimac' },
           { name: 'akira-sugiyama' },

+ 40 - 3
packages/app/resource/locales/en_US/translation.json

@@ -15,8 +15,6 @@
   "Move/Rename": "Move/Rename",
   "Redirected": "Redirected",
   "Unlinked": "Unlinked",
-  "Like!": "Like!",
-  "Seen by": "Seen by",
   "Done": "Done",
   "Cancel": "Cancel",
   "Create": "Create",
@@ -149,6 +147,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": "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 +213,7 @@
     },
     "form_help": {
       "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."
     }
   },
@@ -439,6 +438,7 @@
     "recursively": "Delete pages under this path recursively.",
     "completely": "Delete completely instead of putting it into trash."
   },
+  "deleted_page": "Moved to the trash",
   "deleted_pages": "{{path}} has been deleted",
   "deleted_pages_completely": "{{path}} has been deleted completely",
   "renamed_pages": "{{path}} has been renamed",
@@ -664,6 +664,8 @@
     },
     "by_path_modal": {
       "title": "Convert to new v5 compatible format",
+      "alert": "This operation cannot be undone, and pages that the user cannot view are also subject to processing.",
+      "checkbox_label": "Understood",
       "description": "Enter a path and all pages under that path will be converted to v5 compatible format.",
       "button_label": "Convert",
       "success": "Successfully requested conversion.",
@@ -982,6 +984,7 @@
     "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)",
     "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.",
     "can_not_register_maximum_number_of_users":"Can not register more than the maximum number of users.",
     "failed_to_register":"Failed to register.",
@@ -1075,5 +1078,39 @@
     "select_group": "Select group",
     "belonging_to_no_group": "Could not find the groups you belong to.",
     "manage_user_groups": "Manage user groups"
+  },
+  "fix_page_grant": {
+    "modal": {
+      "no_grant_available": "The list of selectable permissions could not be found. Please modify the permissions on the parent page first and try again.",
+      "need_to_fix_grant": "The permissions associated with this page must be modified in order to use the functionality correctly. <br> Please select from the options below to make the change.",
+      "grant_label": {
+        "isForbidden": "Authority not allowed to view",
+        "currentPageGrantLabel": "Authorization for this page: ",
+        "parentPageGrantLabel": "Authority of parent page: ",
+        "docLink": "For more information on modifying permissions, please refer to <a href='https://docs.growi.org/ja/admin-guide/admin-cookbook/integrate-with-hackmd.html'>こちらのリンク</a>"
+      },
+      "radio_btn": {
+        "restrected": "Only those who know the link",
+        "only_me": "only to oneself",
+        "grant_group": "Only specific groups"
+      },
+      "select_group_default_text": "Select Group",
+      "alert_message_select_group": "No group selected",
+      "btn_label": "Conversion",
+      "title": "Modify authority"
+    },
+    "alert": {
+      "description": "You need to modify the permission settings for this page.",
+      "btn_label": "Revision"
+    }
+  },
+  "tooltip": {
+    "like": "Like!",
+    "cancel_like": "Cancel Like",
+    "bookmark": "Bookmark",
+    "cancel_bookmark": "Cancel Bookmark",
+    "receive_notifications": "Receive Notifications",
+    "stop_notification": "Stop Notification",
+    "footprints": "Footprints"
   }
 }

+ 40 - 3
packages/app/resource/locales/ja_JP/translation.json

@@ -15,8 +15,6 @@
   "Move/Rename": "移動/名前変更",
   "Redirected": "リダイレクトされました",
   "Unlinked": "リダイレクト削除",
-  "Like!": "いいね!",
-  "Seen by": "見た人",
   "Done": "完了",
   "Cancel": "キャンセル",
   "Create": "作成",
@@ -148,6 +146,7 @@
   "Shareable link": "このページの共有用URL",
   "The whitelist of registration permission E-mail address": "登録許可メールアドレスの<br>ホワイトリスト",
   "Add tags for this page": "タグを付ける",
+  "tag_list": "タグ一覧",
   "popular_tags": "人気のタグ",
   "Check All tags": "全てのタグを見る",
   "You have no tag, You can set tags on pages": "使用中のタグがありません",
@@ -216,7 +215,7 @@
     },
     "form_help": {
       "email": "この Wiki では以下のメールアドレスのみ登録可能です。",
-      "password": "パスワードには、8文字以上の半角英数字または記号等を設定してください。",
+      "password": "パスワードには、{{target}}文字以上の半角英数字または記号等を設定してください。",
       "user_id": "ユーザーIDは、ユーザーページのURLなどに利用されます。半角英数字と一部の記号のみ利用できます。"
     }
   },
@@ -439,6 +438,7 @@
     "recursively": "配下のページも削除します",
     "completely": "ゴミ箱を経由せず、完全に削除します"
   },
+  "deleted_page": "ゴミ箱に入れました",
   "deleted_pages": "{{path}} をゴミ箱に入れました",
   "deleted_pages_completely": "{{path}} を完全に削除しました",
   "renamed_pages": "{{path}} を移動/名前変更しました",
@@ -664,6 +664,8 @@
     },
     "by_path_modal": {
       "title": "新しい v5 互換形式への変換",
+      "alert": "この操作は取り消すことができず、ユーザーが閲覧できないページも処理の対象になります。",
+      "checkbox_label": "理解しました",
       "description": "パスを入力することで、そのパスの配下のページを全て v5 互換形式に変換します",
       "button_label": "変換",
       "success": "正常に変換を開始しました",
@@ -975,6 +977,7 @@
     "application_already_installed": "アプリケーションのインストールが完了しました。",
     "email_address_could_not_be_used":"このメールアドレスは使用できません。(許可されたメールアドレスを確認してください。)",
     "user_id_is_not_available":"このユーザーIDは使用できません。",
+    "username_should_not_be_null":"Username が null になっています 管理画面の認証機構設定にて設定の確認をしてください",
     "email_address_is_already_registered":"このメールアドレスは既に登録されています。",
     "can_not_register_maximum_number_of_users":"ユーザー数が上限を超えたため登録できません。",
     "failed_to_register":"登録に失敗しました。",
@@ -1068,5 +1071,39 @@
     "select_group": "グループを選ぶ",
     "belonging_to_no_group": "所属しているグループが見つかりませんでした。",
     "manage_user_groups": "グループ管理"
+  },
+  "fix_page_grant": {
+    "modal": {
+      "no_grant_available": "選択可能な権限のリストが見つかりませんでした。まず親ページの権限を修正したのちに再試行してください。",
+      "need_to_fix_grant": "正しく機能を使用するためにはこのページに紐づく権限を修正する必要があります。 <br> 下記の選択肢から選んで変更してください。",
+      "grant_label": {
+        "isForbidden": "権限の閲覧が許可されていません",
+        "currentPageGrantLabel": "このページの権限: ",
+        "parentPageGrantLabel": "親のページの権限: ",
+        "docLink": "権限の修正についての詳細は<a href='https://docs.growi.org/ja/admin-guide/admin-cookbook/integrate-with-hackmd.html'>こちらのリンク</a>を参照してください"
+      },
+      "radio_btn": {
+        "restrected": "リンクを知っている人のみ",
+        "only_me": "自分のみ",
+        "grant_group": "特定グループのみ"
+      },
+      "select_group_default_text": "グループを選択",
+      "alert_message_select_group": "グループが選択されていません",
+      "btn_label": "変換",
+      "title": "権限を修正"
+    },
+    "alert": {
+      "description": "このページの権限設定を修正する必要があります。",
+      "btn_label": "修正"
+    }
+  },
+  "tooltip": {
+    "like": "いいね!",
+    "cancel_like": "いいねを取り消す",
+    "bookmark": "ブックマーク",
+    "cancel_bookmark": "ブックマークを取り消す",
+    "receive_notifications": "通知を受け取る",
+    "stop_notification": "通知を止める",
+    "footprints": "足跡"
   }
 }

+ 41 - 4
packages/app/resource/locales/zh_CN/translation.json

@@ -16,8 +16,6 @@
 	"Move/Rename": "移动/重命名",
 	"Redirected": "重定向",
 	"Unlinked": "Unlinked",
-	"Like!": "Like!",
-	"Seen by": "Seen by",
   "Done": "Done",
   "Cancel": "取消",
 	"Create": "创建",
@@ -157,6 +155,7 @@
 	"Shareable link": "可分享链接",
 	"The whitelist of registration permission E-mail address": "注册许可电子邮件地址的白名单",
 	"Add tags for this page": "添加标签",
+  "tag_list": "标签列表",
   "popular_tags": "流行标签",
   "Check All tags": "检查所有标签",
 	"You have no tag, You can set tags on pages": "你没有标签,可以在页面上设置标签",
@@ -418,6 +417,7 @@
 		"recursively": "Delete children of <code>%s</code> recursively.",
 		"completely": "Delete completely instead of putting it into trash."
   },
+  "deleted_page": "移到了垃圾箱。",
   "deleted_pages": "将 {{path}} 放入垃圾箱",
   "deleted_pages_completely": "{{path}} 已被完全删除",
   "renamed_pages": "移动/重命名 {{path}}",
@@ -525,7 +525,7 @@
 	"template": {
 		"modal_label": {
 			"Create/Edit Template Page": "创建/编辑模板页",
-			"Create template under": "在下面创建模板页:<br/><code><small>%s</small></code>"
+			"Create template under": "在下面创建模板页"
 		},
 		"option_label": {
 			"create/edit": "创建/编辑模板页。",
@@ -951,6 +951,8 @@
     },
     "by_path_modal": {
       "title": "转换为新的v5兼容格式",
+      "alert": "这一操作不能被撤销,用户不能查看的页面也要进行处理。",
+      "checkbox_label": "明白了",
       "description": "输入一个路径,该路径下的所有页面将被转换为v5兼容格式。",
       "button_label": "转换",
       "success": "成功地请求转换。",
@@ -984,7 +986,8 @@
 		"aws_sttings_required": "使用此功能所需的AWS设置。请询问管理员。",
 		"application_already_installed": "应用程序已安装。",
 		"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": "此电子邮件地址已注册。",
 		"can_not_register_maximum_number_of_users": "注册的用户数不能超过最大值。",
 		"failed_to_register": "注册失败。",
@@ -1078,5 +1081,39 @@
     "select_group": "选择组别",
     "belonging_to_no_group": "无法找到你所属的团体。",
     "manage_user_groups": "管理用户组"
+  },
+  "fix_page_grant": {
+    "modal": {
+      "no_grant_available": "无法找到可选择的权限列表。 请先修改父页的权限,然后再试一次。",
+      "need_to_fix_grant": "为了正确使用该功能,需要修改与该页面相关的权限。 <br> 请从以下选项中选择进行更改。",
+      "grant_label": {
+        "isForbidden": "无权查看的机构",
+        "currentPageGrantLabel": "本页的权限: ",
+        "parentPageGrantLabel": "父页的权限: ",
+        "docLink": "关于修改授权的更多信息,请参见此<a href='https://docs.growi.org/ja/admin-guide/admin-cookbook/integrate-with-hackmd.html'>此链接</a>"
+      },
+      "radio_btn": {
+        "restrected": "只有那些知道链接的人",
+        "only_me": "只对自己说",
+        "grant_group": "仅限特定群体"
+      },
+      "select_group_default_text": "选择组别",
+      "alert_message_select_group": "未选择组别",
+      "btn_label": "蜕变",
+      "title": "修改后的授权书"
+    },
+    "alert": {
+      "description": "本页的授权设置需要修改。",
+      "btn_label": "修改"
+    }
+  },
+  "tooltip": {
+    "like": "很好!",
+    "cancel_like": "取消喜欢",
+    "bookmark": "书签",
+    "cancel_bookmark": "取消书签",
+    "receive_notifications": "接收通知",
+    "stop_notification": "停止通知",
+    "footprints": "脚印"
   }
 }

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

@@ -33,7 +33,7 @@ import GrowiSubNavigationSwitcher from '../components/Navbar/GrowiSubNavigationS
 import NotFoundPage from '../components/NotFoundPage';
 import Page from '../components/Page';
 import DisplaySwitcher from '../components/Page/DisplaySwitcher';
-import NotFoundAlert from '../components/Page/NotFoundAlert';
+import FixPageGrantAlert from '../components/Page/FixPageGrantAlert';
 import RedirectedAlert from '../components/Page/RedirectedAlert';
 import ShareLinkAlert from '../components/Page/ShareLinkAlert';
 import TrashPageAlert from '../components/Page/TrashPageAlert';
@@ -98,6 +98,8 @@ Object.assign(componentMappings, {
 
   'trash-page-alert': <TrashPageAlert />,
 
+  'fix-page-grant-alert': <FixPageGrantAlert />,
+
   'trash-page-list-container': <TrashPageList />,
 
   'not-found-page': <NotFoundPage />,
@@ -114,9 +116,6 @@ Object.assign(componentMappings, {
 
   'share-link-alert': <ShareLinkAlert />,
   'redirected-alert': <RedirectedAlert />,
-  'not-found-alert': <NotFoundAlert
-    isGuestUserMode={appContainer.isGuestUser}
-  />,
 });
 
 // additional definitions if data exists

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

@@ -1,5 +1,7 @@
 import React from 'react';
 
+import EventEmitter from 'events';
+
 import AppContainer from '~/client/services/AppContainer';
 import SocketIoContainer from '~/client/services/SocketIoContainer';
 import { DescendantsPageListModal } from '~/components/DescendantsPageListModal';
@@ -28,6 +30,8 @@ if (!window) {
 const xss = new Xss();
 window.xss = xss;
 
+window.globalEmitter = new EventEmitter();
+
 // create unstated container instance
 const appContainer = new AppContainer();
 // eslint-disable-next-line no-unused-vars

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

@@ -12,38 +12,8 @@ if (!window) {
 }
 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) {
+  window.globalEmitter.emit('setCaretLine', line);
 };
 
 // original: middleware.swigFilter
@@ -55,39 +25,6 @@ Crowi.userPicture = function(user) {
   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() {
   // add classes to cmd-key by OS
   const platform = navigator.platform.toLowerCase();
@@ -112,63 +49,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
 window.onbeforeprint = function() {
   $('#page-wrapper').css('min-height', '0px');

+ 0 - 31
packages/app/src/client/services/AppContainer.js

@@ -26,12 +26,6 @@ export default class AppContainer extends Container {
       this.currentUser = JSON.parse(currentUserElem.textContent);
     }
 
-    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);
 
@@ -149,10 +143,6 @@ export default class AppContainer extends Container {
       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;
   }
 
@@ -186,25 +176,4 @@ export default class AppContainer extends Container {
     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);
-  }
-
 }

+ 0 - 4
packages/app/src/client/services/CommentContainer.js

@@ -30,10 +30,6 @@ export default class CommentContainer extends Container {
 
     this.state = {
       comments: [],
-
-      // settings shared among all of CommentEditor
-      isSlackEnabled: false,
-      slackChannels: mainContent.getAttribute('data-slack-channels') || '',
     };
 
     this.retrieveComments = this.retrieveComments.bind(this);

+ 3 - 3
packages/app/src/client/services/ContextExtractor.tsx

@@ -16,7 +16,7 @@ import {
   useIsDeleted, useIsNotCreatable, useIsTrashPage, useIsUserPage, useLastUpdateUsername,
   useCurrentPageId, usePageIdOnHackmd, usePageUser, useCurrentPagePath, useRevisionCreatedAt, useRevisionId, useRevisionIdHackmdSynced,
   useShareLinkId, useShareLinksNumber, useTemplateTagData, useCurrentUpdatedAt, useCreator, useRevisionAuthor, useCurrentUser, useTargetAndAncestors,
-  useSlackChannels, useNotFoundTargetPathOrId, useIsSearchPage, useIsForbidden, useIsIdenticalPath,
+  useNotFoundTargetPathOrId, useIsSearchPage, useIsForbidden, useIsIdenticalPath, useHasParent,
   useIsAclEnabled, useIsSearchServiceConfigured, useIsSearchServiceReachable, useIsEnabledAttachTitleHeader, useIsNotFoundPermalink,
   useDefaultIndentSize, useIsIndentSizeForced,
 } from '../../stores/context';
@@ -71,6 +71,7 @@ const ContextExtractorOnce: FC = () => {
   const isForbidden = forbiddenContent != null;
   const pageUser = JSON.parse(mainContent?.getAttribute('data-page-user') || jsonNull);
   const hasChildren = JSON.parse(mainContent?.getAttribute('data-page-has-children') || jsonNull);
+  const hasParent = JSON.parse(mainContent?.getAttribute('data-has-parent') || jsonNull);
   const templateTagData = mainContent?.getAttribute('data-template-tags') || null;
   const shareLinksNumber = mainContent?.getAttribute('data-share-links-number');
   const shareLinkId = JSON.parse(mainContent?.getAttribute('data-share-link-id') || jsonNull);
@@ -84,7 +85,6 @@ const ContextExtractorOnce: FC = () => {
   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 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 grant = +(mainContent?.getAttribute('data-page-grant') || 1);
@@ -144,6 +144,7 @@ const ContextExtractorOnce: FC = () => {
   useNotFoundTargetPathOrId(notFoundTargetPathOrId);
   useIsNotFoundPermalink(isNotFoundPermalink);
   useIsSearchPage(isSearchPage);
+  useHasParent(hasParent);
 
   // Navigation
   usePreferDrawerModeByUser();
@@ -156,7 +157,6 @@ const ContextExtractorOnce: FC = () => {
   useIsDeviceSmallerThanMd();
 
   // Editor
-  useSlackChannels(slackChannels);
   useSelectedGrant(grant);
   useSelectedGrantGroupId(grantGroupId);
   useSelectedGrantGroupName(grantGroupName);

+ 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() {
     const opt = {
       pageTags: this.state.tags,

+ 12 - 43
packages/app/src/client/services/PageContainer.js

@@ -17,7 +17,6 @@ import {
 import {
   DrawioInterceptor,
 } from '../util/interceptor/drawio-interceptor';
-import { emojiMartData } from '../util/markdown-it/emoji-mart-data';
 
 const { isTrashPage } = pagePathUtils;
 
@@ -53,7 +52,6 @@ export default class PageContainer extends Container {
       revisionId,
       revisionCreatedAt: +mainContent.getAttribute('data-page-revision-created'),
       path,
-      tocHtml: '',
 
       createdAt: mainContent.getAttribute('data-page-created-at'),
       // please use useCurrentUpdatedAt instead
@@ -102,13 +100,12 @@ export default class PageContainer extends Container {
     }
 
     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
+    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.setTocHtml = this.setTocHtml.bind(this);
     this.save = this.save.bind(this);
 
     this.emitJoinPageRoomRequest = this.emitJoinPageRoomRequest.bind(this);
@@ -195,32 +192,6 @@ export default class PageContainer extends Container {
     this.setState(newState);
   }
 
-  async setTocHtml(tocHtml) {
-    if (this.state.tocHtml !== tocHtml) {
-      const tocHtmlWithEmoji = await this.colonsToEmoji(tocHtml);
-      this.setState({ tocHtml: tocHtmlWithEmoji });
-    }
-  }
-
-  /**
-   *
-   * @param {*} html TOC html string
-   * @returns TOC html with emoji (emoji-mart) in URL
-   */
-  async colonsToEmoji(html) {
-    // Emoji colons matching
-    const colons = ':[a-zA-Z0-9-_+]+:';
-    // Emoji with skin tone matching
-    const skin = ':skin-tone-[2-6]:';
-    const colonsRegex = new RegExp(`(${colons}${skin}|${colons})`, 'g');
-    const emojiData = await emojiMartData();
-    return html.replace(colonsRegex, (index, match) => {
-      const emojiName = match.slice(1, -1);
-      return emojiData[emojiName];
-    });
-
-  }
-
   /**
    * save success handler
    * @param {object} page Page instance
@@ -246,13 +217,11 @@ export default class PageContainer extends Container {
     }
     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) {
+      window.globalEmitter.emit('updateEditorValue', newState.markdown);
     }
+
     // PageEditorByHackmd component
     const pageEditorByHackmd = this.appContainer.getComponentInstance('PageEditorByHackmd');
     if (pageEditorByHackmd != null) {
@@ -300,7 +269,7 @@ export default class PageContainer extends Container {
     let { revisionId } = this.state;
     const options = Object.assign({}, optionsToSave);
 
-    if (editorMode === 'hackmd') {
+    if (editorMode === EditorMode.HackMD) {
       // set option to sync
       options.isSyncRevisionToHackmd = true;
       revisionId = this.state.revisionIdHackmdSynced;
@@ -335,7 +304,7 @@ export default class PageContainer extends Container {
     const options = Object.assign({}, optionsToSave);
 
     let markdown;
-    if (editorMode === 'hackmd') {
+    if (editorMode === EditorMode.HackMD) {
       const pageEditorByHackmd = this.appContainer.getComponentInstance('PageEditorByHackmd');
       markdown = await pageEditorByHackmd.getMarkdown();
       // set option to sync
@@ -480,7 +449,6 @@ export default class PageContainer extends Container {
 
     const { pageId, remoteRevisionId, path } = this.state;
     const editorContainer = this.appContainer.getContainer('EditorContainer');
-    const pageEditor = this.appContainer.getComponentInstance('PageEditor');
     const options = editorContainer.getCurrentOptionsToSave();
     const optionsToSave = Object.assign({}, options);
 
@@ -489,8 +457,9 @@ export default class PageContainer extends Container {
     editorContainer.clearDraft(path);
     this.updateStateAfterSave(res.page, res.tags, res.revision, editorMode);
 
-    if (pageEditor != null) {
-      pageEditor.updateEditorValue(markdown);
+    // Update PageEditor component
+    if (editorMode !== EditorMode.Editor) {
+      window.globalEmitter.emit('updateEditorValue', markdown);
     }
 
     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
 const logger = loggerFactory('growi:services:PersonalContainer');
 
-const DEFAULT_IMAGE = '/images/icons/user.svg';
-
 /**
  * Service container for personal settings page (PersonalSettings.jsx)
  * @extends {Container} unstated Container
@@ -29,8 +27,6 @@ export default class PersonalContainer extends Container {
       isEmailPublished: false,
       lang: 'en_US',
       isGravatarEnabled: false,
-      isUploadedPicture: false,
-      uploadedPictureSrc: this.getUploadedPictureSrc(this.appContainer.currentUser),
       externalAccounts: [],
       apiToken: '',
       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
    */
@@ -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
    */

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

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

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

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

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

@@ -15,12 +15,9 @@ class DetachCodeBlockUtil {
  */
 export class DetachCodeBlockInterceptor extends BasicInterceptor {
 
-  constructor(crowi) {
+  constructor() {
     super();
     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 {
 
-  constructor(crowi) {
+  constructor() {
     super();
     this.logger = loggerFactory('growi:interceptor:DetachCodeBlockInterceptor');
-
-    this.crowi = crowi;
-    this.crowiForJquery = crowi.getCrowiForJquery();
   }
 
   /**

+ 7 - 11
packages/app/src/client/util/interceptor/drawio-interceptor.js

@@ -3,7 +3,6 @@ import React from 'react';
 
 import { BasicInterceptor } from '@growi/core';
 import ReactDOM from 'react-dom';
-import { Provider } from 'unstated';
 
 import Drawio from '~/components/Drawio';
 
@@ -14,11 +13,10 @@ import Drawio from '~/components/Drawio';
  */
 export class DrawioInterceptor extends BasicInterceptor {
 
-  constructor(appContainer) {
+  constructor() {
     super();
 
     this.previousPreviewContext = null;
-    this.appContainer = appContainer;
   }
 
   /**
@@ -104,7 +102,7 @@ export class DrawioInterceptor extends BasicInterceptor {
    */
   drawioPostRender(contextName, context) {
     const isPreview = (contextName === 'postRenderPreviewHtml');
-    const renderDrawioInRealtime = context.editorSettings?.renderDrawioInRealtime;
+    const renderDrawioInRealtime = context.renderDrawioInRealtime;
 
     Object.keys(context.DrawioMap).forEach((domId) => {
       const elem = document.getElementById(domId);
@@ -125,13 +123,11 @@ export class DrawioInterceptor extends BasicInterceptor {
   renderReactDOM(drawioMapEntry, elem, isPreview) {
     ReactDOM.render(
       // 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,
     );
   }

+ 16 - 16
packages/app/src/client/util/markdown-it/emoji-mart-data.ts

@@ -3,24 +3,28 @@ import data from 'emoji-mart/data/apple.json';
 
 const DEFAULT_EMOJI_SIZE = 24;
 
+
+type EmojiMap = {
+  [key: string]: string,
+};
+
 /**
  *
  * Get native emoji with skin tone
- * @param emoji Emoji object
  * @param skin number
  * @returns emoji data with skin tone
  */
-const getEmojiSkinTone = async(emoji) => {
+const getEmojiSkinTone = (emojiName: string): EmojiMap => {
   const emojiData = {};
   [...Array(6).keys()].forEach((index) => {
     if (index > 0) {
       const elem = Emoji({
-        emoji,
+        emoji: emojiName,
         skin: index + 1,
         size: DEFAULT_EMOJI_SIZE,
       });
       if (elem) {
-        emojiData[`${emoji}::skin-tone-${index + 1}`] = elem.props['aria-label'].split(',')[0];
+        emojiData[`${emojiName}::skin-tone-${index + 1}`] = elem.props['aria-label'].split(',')[0];
       }
     }
   });
@@ -29,27 +33,29 @@ const getEmojiSkinTone = async(emoji) => {
 
 /**
  * Get native emoji from emoji array
- * @param emojis array of emoji
  * @returns emoji data
  */
 
-const getNativeEmoji = async(emojis) => {
+const getNativeEmoji = (): EmojiMap => {
   const emojiData = {};
-  emojis.forEach(async(emoji) => {
+  Object.entries(data.emojis).forEach((emoji) => {
     const emojiName = emoji[0];
-    const hasSkinVariation = emoji[1].skin_variations;
+    const hasSkinVariation = 'skin_variations' in emoji[1];
+
     const elem = Emoji({
       emoji: emojiName,
       size: DEFAULT_EMOJI_SIZE,
     });
+
     if (elem != null) {
       emojiData[emojiName] = elem.props['aria-label'].split(',')[0];
       if (hasSkinVariation) {
-        const emojiWithSkinTone = await getEmojiSkinTone(emojiName);
+        const emojiWithSkinTone = getEmojiSkinTone(emojiName);
         Object.assign(emojiData, emojiWithSkinTone);
       }
     }
   });
+
   return emojiData;
 };
 
@@ -57,10 +63,4 @@ const getNativeEmoji = async(emojis) => {
  * Get native emoji mart data
  * @returns native emoji mart data
  */
-
-export const emojiMartData = () => {
-  const emojis = Object.entries(data.emojis).map((emoji) => {
-    return emoji;
-  });
-  return getNativeEmoji(emojis);
-};
+export const emojiMartData = getNativeEmoji();

+ 4 - 7
packages/app/src/client/util/markdown-it/emoji.js

@@ -1,15 +1,12 @@
+import markdownItEmojiMart from 'markdown-it-emoji-mart';
+
 import { emojiMartData } from './emoji-mart-data';
 
-export default class EmojiConfigurer {
 
-  constructor(crowi) {
-    this.crowi = crowi;
-  }
+export default class EmojiConfigurer {
 
   configure(md) {
-    emojiMartData().then((data) => {
-      md.use(require('markdown-it-emoji-mart'), { defs: data });
-    });
+    md.use(markdownItEmojiMart, { defs: emojiMartData });
   }
 
 }

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

@@ -1,9 +1,5 @@
 export default class FooternoteConfigurer {
 
-  constructor(crowi) {
-    this.crowi = crowi;
-  }
-
   configure(md) {
     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 {
 
-  constructor(crowi) {
-    this.crowi = crowi;
+  constructor() {
     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 {
 
-  constructor(crowi) {
-    this.crowi = crowi;
-  }
-
   configure(md) {
     md.renderer.rules.heading_close = (tokens, idx) => {
       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>
                 </a>
               </span></${tokens[idx].tag}>`;

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

@@ -1,8 +1,6 @@
 export default class HeaderConfigurer {
 
-  constructor(crowi) {
-    this.crowi = crowi;
-
+  constructor() {
     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 {
 
-  constructor(crowi) {
-    this.crowi = crowi;
-  }
-
   configure(md) {
     md.renderer.rules.table_open = (tokens, idx) => {
       const beginLine = tokens[idx].map[0] + 1;
       const endLine = tokens[idx].map[1];
       // 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) => {

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

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

+ 18 - 19
packages/app/src/client/util/markdown-it/toc-and-anchor.js

@@ -1,27 +1,26 @@
-export default class TocAndAnchorConfigurer {
+import markdownItEmojiMart from 'markdown-it-emoji-mart';
+import markdownItToc from 'markdown-it-toc-and-anchor-with-slugid';
 
-  constructor(crowi, setHtml) {
-    this.crowi = crowi;
-    this.setHtml = setHtml;
-  }
+import { emojiMartData } from './emoji-mart-data';
+
+export default class TocAndAnchorConfigurer {
 
   configure(md) {
-    md.use(require('markdown-it-toc-and-anchor-with-slugid').default, {
-      tocLastLevel: 3,
-      anchorLinkBefore: false,
-      anchorLinkSymbol: '',
-      anchorLinkSymbolClassName: 'icon-link',
-      anchorClassName: 'revision-head-link',
-    });
+    md.use(markdownItEmojiMart, { defs: emojiMartData })
+      .use(markdownItToc, {
+        tocLastLevel: 3,
+        anchorLinkBefore: false,
+        anchorLinkSymbol: '',
+        anchorLinkSymbolClassName: 'icon-link',
+        anchorClassName: 'revision-head-link',
+      });
 
     // set toc render function
-    if (this.setHtml != null) {
-      md.set({
-        tocCallback: (tocMarkdown, tocArray, tocHtml) => {
-          this.setHtml(tocHtml);
-        },
-      });
-    }
+    md.set({
+      tocCallback: (tocMarkdown, tocArray, tocHtml) => {
+        window.globalEmitter.emit('renderTocHtml', tocHtml);
+      },
+    });
   }
 
 }

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

@@ -1,14 +1,17 @@
 import React from 'react';
+
 import PropTypes from 'prop-types';
-import { withTranslation } from 'react-i18next';
+import { useTranslation } from 'react-i18next';
+
+import AdminAppContainer from '~/client/services/AdminAppContainer';
+import AppContainer from '~/client/services/AppContainer';
 
 import { withUnstatedContainers } from '../../UnstatedUtils';
 
-import AppContainer from '~/client/services/AppContainer';
-import AdminAppContainer from '~/client/services/AdminAppContainer';
 
 function AwsSetting(props) {
-  const { t, adminAppContainer } = props;
+  const { t } = useTranslation();
+  const { adminAppContainer } = props;
   const { s3ReferenceFileWithRelayMode } = adminAppContainer.state;
 
   return (
@@ -158,4 +161,4 @@ AwsSetting.propTypes = {
   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 PropTypes from 'prop-types';
-import { withTranslation } from 'react-i18next';
+import { useTranslation } from 'react-i18next';
 import * as toastr from 'toastr';
 
 
@@ -254,9 +254,14 @@ ExportArchiveDataPage.propTypes = {
   adminSocketIoContainer: PropTypes.instanceOf(AdminSocketIoContainer).isRequired,
 };
 
+const ExportArchiveDataPageWrapperFC = (props) => {
+  const { t } = useTranslation();
+  return <ExportArchiveDataPage t={t} {...props} />;
+};
+
 /**
  * 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;

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

@@ -1,14 +1,16 @@
 import React, { Fragment } from 'react';
+
 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 AppContainer from '~/client/services/AppContainer';
-import AdminExternalAccountsContainer from '~/client/services/AdminExternalAccountsContainer';
+
 import ExternalAccountTable from './Users/ExternalAccountTable';
-import { toastError } from '~/client/util/apiNotification';
 
 
 class ManageExternalAccount extends React.Component {
@@ -82,7 +84,11 @@ ManageExternalAccount.propTypes = {
   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 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 { withTranslation } from 'react-i18next';
 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 AppContainer from '~/client/services/AppContainer';
 import { toastSuccess, toastError } from '~/client/util/apiNotification';
 
+import { withUnstatedContainers } from '../../UnstatedUtils';
+
 class UserGroupUserFormByInput extends React.Component {
 
   constructor(props) {
@@ -44,12 +46,16 @@ class UserGroupUserFormByInput extends React.Component {
 
     try {
       await adminUserGroupDetailContainer.addUserByUsername(userName);
+      await adminUserGroupDetailContainer.init();
+      await adminUserGroupDetailContainer.closeUserGroupUserModal();
       toastSuccess(`Added "${this.xss.process(userName)}" to "${this.xss.process(userGroup.name)}"`);
       this.setState({ inputUser: '' });
     }
     catch (err) {
       toastError(new Error(`Unable to add "${this.xss.process(userName)}" to "${this.xss.process(userGroup.name)}"`));
     }
+
+
   }
 
   validateForm() {

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

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

+ 18 - 8
packages/app/src/components/BookmarkButtons.tsx

@@ -1,12 +1,13 @@
-import React, { FC, useState } from 'react';
+import React, { FC, useState, useCallback } from 'react';
 
-import { UncontrolledTooltip, Popover, PopoverBody } from 'reactstrap';
 import { useTranslation } from 'react-i18next';
+import { UncontrolledTooltip, Popover, PopoverBody } from 'reactstrap';
+
+import { useIsGuestUser } from '~/stores/context';
 
 import { IUser } from '../interfaces/user';
 
 import UserPictureList from './User/UserPictureList';
-import { useIsGuestUser } from '~/stores/context';
 
 interface Props {
   bookmarkCount?: number
@@ -37,6 +38,17 @@ const BookmarkButtons: FC<Props> = (props: Props) => {
     }
   };
 
+  const getTooltipMessage = useCallback(() => {
+    if (isGuestUser) {
+      return 'Not available for guest';
+    }
+
+    if (isBookmarked) {
+      return 'tooltip.cancel_bookmark';
+    }
+    return 'tooltip.bookmark';
+  }, [isGuestUser, isBookmarked]);
+
   return (
     <div className="btn-group" role="group" aria-label="Bookmark buttons">
       <button
@@ -49,11 +61,9 @@ const BookmarkButtons: FC<Props> = (props: Props) => {
         <i className={`fa ${isBookmarked ? 'fa-bookmark' : 'fa-bookmark-o'}`}></i>
       </button>
 
-      {isGuestUser && (
-        <UncontrolledTooltip placement="top" target="bookmark-button" fade={false}>
-          {t('Not available for guest')}
-        </UncontrolledTooltip>
-      )}
+      <UncontrolledTooltip placement="top" target="bookmark-button" fade={false}>
+        {t(getTooltipMessage())}
+      </UncontrolledTooltip>
 
       { !hideTotalNumber && (
         <>

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

@@ -1,16 +1,19 @@
 import React, { FC } from 'react';
 
 type CountProps = {
-  count: number
+  count?: number,
+  offset?: number,
 }
 
 const CountBadge: FC<CountProps> = (props:CountProps) => {
+  const { count, offset = 0 } = props;
+
+
   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>
   );
 };
 

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

@@ -40,6 +40,7 @@ type CommonProps = {
 
   additionalMenuItemRenderer?: React.FunctionComponent<AdditionalMenuItemsRendererProps>,
   isInstantRename?: boolean,
+  alignRight?: boolean,
 }
 
 
@@ -55,7 +56,7 @@ const PageItemControlDropdownMenu = React.memo((props: DropdownMenuProps): JSX.E
     pageId, isLoading,
     pageInfo, isEnableActions, forceHideMenuItems,
     onClickBookmarkMenuItem, onClickRenameMenuItem, onClickDuplicateMenuItem, onClickDeleteMenuItem, onClickRevertMenuItem,
-    additionalMenuItemRenderer: AdditionalMenuItems, isInstantRename,
+    additionalMenuItemRenderer: AdditionalMenuItems, isInstantRename, alignRight,
   } = props;
 
 
@@ -205,7 +206,7 @@ const PageItemControlDropdownMenu = React.memo((props: DropdownMenuProps): JSX.E
   }
 
   return (
-    <DropdownMenu positionFixed modifiers={{ preventOverflow: { boundariesElement: undefined } }}>
+    <DropdownMenu positionFixed modifiers={{ preventOverflow: { boundariesElement: undefined } }} right={alignRight}>
       {contents}
     </DropdownMenu>
   );

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

@@ -1,15 +1,14 @@
 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 PropTypes from 'prop-types';
+import { useTranslation } from 'react-i18next';
+import { Modal, ModalHeader, ModalBody } from 'reactstrap';
 import urljoin from 'url-join';
 
-
 const CreateTemplateModal = (props) => {
-  const { t, path } = props;
+  const { t } = useTranslation();
+  const { path } = props;
 
   const parentPath = pathUtils.addTrailingSlash(path);
 
@@ -30,6 +29,7 @@ const CreateTemplateModal = (props) => {
         </div>
         <div className="card-footer text-center">
           <a
+            data-testid={`template-button-${target}`}
             href={generateUrl(label)}
             className="btn btn-sm btn-primary"
             id={`template-button-${target}`}
@@ -42,7 +42,7 @@ const CreateTemplateModal = (props) => {
   }
 
   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">
         {t('template.modal_label.Create/Edit Template Page')}
       </ModalHeader>
@@ -63,12 +63,10 @@ const CreateTemplateModal = (props) => {
   );
 };
 
-
 CreateTemplateModal.propTypes = {
-  t: PropTypes.func.isRequired, //  i18next
   path: PropTypes.string.isRequired,
   isOpen: PropTypes.bool.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 { useTranslation } from 'react-i18next';
+
 import { toastSuccess } from '~/client/util/apiNotification';
 import {
   IDataWithMeta,
@@ -9,13 +11,12 @@ import {
 import { IPagingResult } from '~/interfaces/paging-result';
 import { OnDeletedFunction, OnPutBackedFunction } from '~/interfaces/ui';
 import { useIsGuestUser, useIsSharedUser, useIsTrashPage } from '~/stores/context';
-
 import {
   useSWRxDescendantsPageListForCurrrentPath, useSWRxPageInfoForList, useSWRxPageList, useDescendantsPageListForCurrentPathTermManager,
 } from '~/stores/page';
 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 PaginationWrapper from './PaginationWrapper';
 
@@ -61,7 +62,14 @@ export const DescendantsPageListSubstance = (props: SubstanceProps): JSX.Element
   }
 
   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();
 

+ 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]));

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

@@ -0,0 +1,97 @@
+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 let window: {
+  globalEmitter: EventEmitter,
+  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;
+    window.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 = '';
+        window.GraphViewer.createViewerForElement(div);
+      }
+    }
+  }, []);
+
+  const renderDrawioWithDebounce = useMemo(() => debounce(200, renderDrawio), [renderDrawio]);
+
+  const { GraphViewer } = window;
+  useEffect(() => {
+    if (GraphViewer == null) {
+      return;
+    }
+
+    renderDrawioWithDebounce();
+  }, [GraphViewer, 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 PropTypes from 'prop-types';
 import StickyEvents from 'sticky-events';
 
 
-import AppContainer from '~/client/services/AppContainer';
 import { smoothScrollIntoView } from '~/client/util/smooth-scroll';
-import { useCurrentPagePath } from '~/stores/context';
+import { useCurrentPagePath, useCurrentUser } from '~/stores/context';
 import { usePageCreateModal } from '~/stores/modal';
 import loggerFactory from '~/utils/logger';
 
 import CreatePageIcon from './Icons/CreatePageIcon';
 import ReturnTopIcon from './Icons/ReturnTopIcon';
-import { withUnstatedContainers } from './UnstatedUtils';
 
 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 { 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 PropTypes from 'prop-types';
 
 import { format, formatDistanceStrict, differenceInSeconds } from 'date-fns';
+import PropTypes from 'prop-types';
 import { UncontrolledTooltip } from 'reactstrap';
 
 const FormattedDistanceDate = (props) => {

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

@@ -1,19 +1,22 @@
 import React, {
   useState, useEffect, FC, useCallback,
 } from 'react';
+
+import { useTranslation } from 'react-i18next';
 import {
   Dropdown, DropdownToggle, DropdownMenu, DropdownItem,
 } 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 { useSWRxInAppNotifications, useSWRxInAppNotificationStatus } from '~/stores/in-app-notification';
+import loggerFactory from '~/utils/logger';
+
 import { withUnstatedContainers } from '../UnstatedUtils';
+
 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');
 
@@ -74,7 +77,7 @@ const InAppNotificationDropdown: FC<Props> = (props: Props) => {
   }
 
   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">
         <i className="icon-bell" /> {badge}
       </DropdownToggle>

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

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

+ 8 - 3
packages/app/src/components/InstallerForm.jsx

@@ -1,8 +1,8 @@
 import React from 'react';
-import PropTypes from 'prop-types';
 
 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';
 
@@ -214,4 +214,9 @@ InstallerForm.propTypes = {
   csrf: PropTypes.string,
 };
 
-export default withTranslation()(InstallerForm);
+const InstallerFormWrapperFC = (props) => {
+  const { t } = useTranslation();
+  return <InstallerForm t={t} {...props} />;
+};
+
+export default InstallerFormWrapperFC;

+ 21 - 9
packages/app/src/components/LikeButtons.tsx

@@ -1,13 +1,15 @@
-import React, { FC, useState } from 'react';
+import React, { FC, useState, useCallback } from 'react';
 
-import { UncontrolledTooltip, Popover, PopoverBody } from 'reactstrap';
 import { useTranslation } from 'react-i18next';
-import UserPictureList from './User/UserPictureList';
-import { withUnstatedContainers } from './UnstatedUtils';
+import { UncontrolledTooltip, Popover, PopoverBody } from 'reactstrap';
 
 import AppContainer from '~/client/services/AppContainer';
+
 import { IUser } from '../interfaces/user';
 
+import { withUnstatedContainers } from './UnstatedUtils';
+import UserPictureList from './User/UserPictureList';
+
 type LikeButtonsProps = {
 
   hideTotalNumber?: boolean,
@@ -32,6 +34,17 @@ const LikeButtons: FC<LikeButtonsProps> = (props: LikeButtonsProps) => {
     hideTotalNumber, isGuestUser, isLiked, sumOfLikers, onLikeClicked,
   } = props;
 
+  const getTooltipMessage = useCallback(() => {
+    if (isGuestUser) {
+      return 'Not available for guest';
+    }
+
+    if (isLiked) {
+      return 'tooltip.cancel_like';
+    }
+    return 'tooltip.like';
+  }, [isGuestUser, isLiked]);
+
   return (
     <div className="btn-group" role="group" aria-label="Like buttons">
       <button
@@ -43,11 +56,10 @@ const LikeButtons: FC<LikeButtonsProps> = (props: LikeButtonsProps) => {
       >
         <i className={`fa ${isLiked ? 'fa-heart' : 'fa-heart-o'}`}></i>
       </button>
-      { isGuestUser && (
-        <UncontrolledTooltip placement="top" target="like-button" fade={false}>
-          {t('Not available for guest')}
-        </UncontrolledTooltip>
-      )}
+
+      <UncontrolledTooltip placement="top" target="like-button" fade={false}>
+        {t(getTooltipMessage())}
+      </UncontrolledTooltip>
 
       { !hideTotalNumber && (
         <>

+ 14 - 8
packages/app/src/components/LoginForm.jsx

@@ -1,10 +1,11 @@
 import React from 'react';
+
 import PropTypes from 'prop-types';
 import ReactCardFlip from 'react-card-flip';
-
-import { withTranslation } from 'react-i18next';
+import { useTranslation } from 'react-i18next';
 
 import AppContainer from '~/client/services/AppContainer';
+
 import { withUnstatedContainers } from './UnstatedUtils';
 
 class LoginForm extends React.Component {
@@ -327,11 +328,6 @@ class LoginForm extends React.Component {
 
 }
 
-/**
- * Wrapper component for using unstated
- */
-const LoginFormWrapper = withUnstatedContainers(LoginForm, [AppContainer]);
-
 LoginForm.propTypes = {
   // i18next
   t: PropTypes.func.isRequired,
@@ -351,4 +347,14 @@ LoginForm.propTypes = {
   objOfIsExternalAuthEnableds: PropTypes.object,
 };
 
-export default withTranslation()(LoginFormWrapper);
+const LoginFormWrapperFC = (props) => {
+  const { t } = useTranslation();
+  return <LoginForm t={t} {...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 PropTypes from 'prop-types';
-import { withTranslation } from 'react-i18next';
+import { useTranslation } from 'react-i18next';
 
 import AppContainer from '~/client/services/AppContainer';
 import PersonalContainer from '~/client/services/PersonalContainer';
@@ -97,12 +97,20 @@ class ApiSettings extends React.Component {
 
 }
 
-const ApiSettingsWrapper = withUnstatedContainers(ApiSettings, [AppContainer, PersonalContainer]);
-
 ApiSettings.propTypes = {
   t: PropTypes.func.isRequired, // i18next
   appContainer: PropTypes.instanceOf(AppContainer).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 PropTypes from 'prop-types';
-import { withTranslation } from 'react-i18next';
+import { useTranslation } from 'react-i18next';
 import {
   Modal,
   ModalHeader,
@@ -130,8 +130,6 @@ class AssociateModal extends React.Component {
 
 }
 
-const AssociateModalWrapper = withUnstatedContainers(AssociateModal, [AppContainer, PersonalContainer]);
-
 AssociateModal.propTypes = {
   t: PropTypes.func.isRequired, // i18next
   appContainer: PropTypes.instanceOf(AppContainer).isRequired,
@@ -141,5 +139,14 @@ AssociateModal.propTypes = {
   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 PropTypes from 'prop-types';
-import { withTranslation } from 'react-i18next';
+import { useTranslation } from 'react-i18next';
 
 import PersonalContainer from '~/client/services/PersonalContainer';
 import { toastSuccess, toastError } from '~/client/util/apiNotification';
@@ -163,11 +163,19 @@ class BasicInfoSettings extends React.Component {
 
 }
 
-const BasicInfoSettingsWrapper = withUnstatedContainers(BasicInfoSettings, [PersonalContainer]);
-
 BasicInfoSettings.propTypes = {
   t: PropTypes.func.isRequired, // i18next
   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 PropTypes from 'prop-types';
-import { withTranslation } from 'react-i18next';
 
+import PropTypes from 'prop-types';
+import { useTranslation } from 'react-i18next';
 import {
   Modal,
   ModalHeader,
   ModalBody,
   ModalFooter,
 } from 'reactstrap';
-import { toastSuccess, toastError } from '~/client/util/apiNotification';
-import { withUnstatedContainers } from '../UnstatedUtils';
 
 import AppContainer from '~/client/services/AppContainer';
 import PersonalContainer from '~/client/services/PersonalContainer';
+import { toastSuccess, toastError } from '~/client/util/apiNotification';
+
+import { withUnstatedContainers } from '../UnstatedUtils';
+
 
 class DisassociateModal extends React.Component {
 
@@ -71,8 +73,6 @@ class DisassociateModal extends React.Component {
 
 }
 
-const DisassociateModalWrapper = withUnstatedContainers(DisassociateModal, [AppContainer, PersonalContainer]);
-
 DisassociateModal.propTypes = {
   t: PropTypes.func.isRequired, // i18next
   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 PropTypes from 'prop-types';
-import { withTranslation } from 'react-i18next';
+import { useTranslation } from 'react-i18next';
 
 
 import AppContainer from '~/client/services/AppContainer';
@@ -125,12 +125,20 @@ class ExternalAccountLinkedMe extends React.Component {
 
 }
 
-const ExternalAccountLinkedMeWrapper = withUnstatedContainers(ExternalAccountLinkedMe, [AppContainer, PersonalContainer]);
-
 ExternalAccountLinkedMe.propTypes = {
   t: PropTypes.func.isRequired, // i18next
   appContainer: PropTypes.instanceOf(AppContainer).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 PropTypes from 'prop-types';
 
-import { withTranslation } from 'react-i18next';
 import dateFnsFormat from 'date-fns/format';
+import PropTypes from 'prop-types';
+import { useTranslation } from 'react-i18next';
 
 const ExternalAccountRow = (props) => {
-  const { t, account } = props;
+  const { t } = useTranslation();
+  const { account } = props;
 
   return (
     <tr>
@@ -29,12 +30,9 @@ const ExternalAccountRow = (props) => {
   );
 };
 
-
 ExternalAccountRow.propTypes = {
-  t: PropTypes.func.isRequired, // i18next
-
   account: PropTypes.object.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 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 {
   Modal,
   ModalHeader,
   ModalBody,
   ModalFooter,
 } 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 { toastError } from '~/client/util/apiNotification';
+import loggerFactory from '~/utils/logger';
+
 
 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 = {
-  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
   show: PropTypes.bool.isRequired,
   src: PropTypes.string,
   onModalClose: 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 PropTypes from 'prop-types';
-import { withTranslation } from 'react-i18next';
+import { useTranslation } from 'react-i18next';
 
 import PersonalContainer from '~/client/services/PersonalContainer';
 import { toastSuccess, toastError } from '~/client/util/apiNotification';
@@ -10,7 +10,6 @@ import { apiv3Get, apiv3Put } from '~/client/util/apiv3-client';
 
 import { withUnstatedContainers } from '../UnstatedUtils';
 
-
 class PasswordSettings extends React.Component {
 
   constructor() {
@@ -22,6 +21,7 @@ class PasswordSettings extends React.Component {
       newPassword: '',
       newPasswordConfirm: '',
       isPasswordSet: false,
+      minPasswordLength: null,
     };
 
     this.onClickSubmit = this.onClickSubmit.bind(this);
@@ -32,8 +32,8 @@ class PasswordSettings extends React.Component {
   async componentDidMount() {
     try {
       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) {
       toastError(err);
@@ -74,9 +74,8 @@ class PasswordSettings extends React.Component {
 
   render() {
     const { t } = this.props;
-    const { newPassword, newPasswordConfirm } = this.state;
+    const { newPassword, newPasswordConfirm, minPasswordLength } = this.state;
     const isIncorrectConfirmPassword = (newPassword !== newPasswordConfirm);
-
     if (this.state.retrieveError != null) {
       throw new Error(this.state.retrieveError.message);
     }
@@ -131,7 +130,7 @@ class PasswordSettings extends React.Component {
               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>
 
@@ -154,12 +153,19 @@ class PasswordSettings extends React.Component {
 
 }
 
-
-const PasswordSettingsWrapper = withUnstatedContainers(PasswordSettings, [PersonalContainer]);
-
 PasswordSettings.propTypes = {
   t: PropTypes.func.isRequired, // i18next
   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 PropTypes from 'prop-types';
-import { withTranslation } from 'react-i18next';
+import { useTranslation } from 'react-i18next';
 
 import CustomNavAndContents from '../CustomNavigation/CustomNavAndContents';
 
@@ -13,9 +13,9 @@ import InAppNotificationSettings from './InAppNotificationSettings';
 import PasswordSettings from './PasswordSettings';
 import UserSettings from './UserSettings';
 
-const PersonalSettings = (props) => {
+const PersonalSettings = () => {
 
-  const { t } = props;
+  const { t } = useTranslation();
 
   const navTabMapping = useMemo(() => {
     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);

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

@@ -0,0 +1,188 @@
+import React, { useCallback, useState } from 'react';
+
+
+import { useTranslation } from 'react-i18next';
+
+import AppContainer from '~/client/services/AppContainer';
+import { toastSuccess, toastError } from '~/client/util/apiNotification';
+import { apiPost } 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 { withUnstatedContainers } from '../UnstatedUtils';
+
+import ImageCropModal from './ImageCropModal';
+
+const DEFAULT_IMAGE = '/images/icons/user.svg';
+
+
+type Props = {
+  appContainer: AppContainer,
+}
+
+const ProfileImageSettings = (props: Props): JSX.Element => {
+  const { t } = useTranslation();
+
+  const { appContainer } = props;
+
+  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);
+      // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
+      formData.append('_csrf', appContainer.csrfToken!);
+      const response = await apiPost('/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);
+    }
+  }, [appContainer.csrfToken, 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 withUnstatedContainers(ProfileImageSettings, [AppContainer]);

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

+ 14 - 11
packages/app/src/components/MyDraftList/Draft.jsx

@@ -1,18 +1,17 @@
 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 { useTranslation } from 'react-i18next';
 import {
   Collapse,
   UncontrolledTooltip,
 } from 'reactstrap';
 
-import { withUnstatedContainers } from '../UnstatedUtils';
 import AppContainer from '~/client/services/AppContainer';
 
 import RevisionBody from '../Page/RevisionBody';
+import { withUnstatedContainers } from '../UnstatedUtils';
 
 class Draft extends React.Component {
 
@@ -192,12 +191,6 @@ class Draft extends React.Component {
 
 }
 
-/**
- * Wrapper component for using unstated
- */
-const DraftWrapper = withUnstatedContainers(Draft, [AppContainer]);
-
-
 Draft.propTypes = {
   t: PropTypes.func.isRequired,
   appContainer: PropTypes.instanceOf(AppContainer).isRequired,
@@ -209,4 +202,14 @@ Draft.propTypes = {
   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 PropTypes from 'prop-types';
-import { withTranslation } from 'react-i18next';
+import { useTranslation } from 'react-i18next';
 
 import EditorContainer from '~/client/services/EditorContainer';
 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 = {
   t: PropTypes.func.isRequired, // react-i18next
 
@@ -184,4 +178,14 @@ MyDraftList.propTypes = {
   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 { useTranslation } from 'react-i18next';
-import PropTypes from 'prop-types';
-
 
+import PropTypes from 'prop-types';
+import { useTranslation } from 'react-i18next';
 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 { 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 {
-  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 {
   usePageAccessoriesModal, PageAccessoriesModalContents, IPageForPageDuplicateModal,
   usePageDuplicateModal, usePageRenameModal, usePageDeleteModal, usePagePresentationModal,
 } from '~/stores/modal';
-
-
-import {
-  useCurrentCreatedAt, useCurrentUpdatedAt, useCurrentPageId, useRevisionId, useCurrentPagePath,
-  useCreator, useRevisionAuthor, useCurrentUser, useIsGuestUser, useIsSharedUser, useShareLinkId,
-} from '~/stores/context';
 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 HistoryIcon from '../Icons/HistoryIcon';
+import PresentationIcon from '../Icons/PresentationIcon';
 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 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 & {
@@ -101,6 +97,7 @@ const AdditionalMenuItems = (props: AdditionalMenuItemsProps): JSX.Element => {
       <DropdownItem
         onClick={() => openAccessoriesModal(PageAccessoriesModalContents.PageHistory)}
         disabled={isGuestUser || isSharedUser}
+        data-testid="open-page-accessories-modal-btn-with-history-tab"
         className="grw-page-control-dropdown-item"
       >
         <span className="grw-page-control-dropdown-icon">
@@ -111,6 +108,7 @@ const AdditionalMenuItems = (props: AdditionalMenuItemsProps): JSX.Element => {
 
       <DropdownItem
         onClick={() => openAccessoriesModal(PageAccessoriesModalContents.Attachment)}
+        data-testid="open-page-accessories-modal-btn-with-attachment-data-tab"
         className="grw-page-control-dropdown-item"
       >
         <span className="grw-page-control-dropdown-icon">
@@ -136,6 +134,7 @@ const AdditionalMenuItems = (props: AdditionalMenuItemsProps): JSX.Element => {
       <DropdownItem
         onClick={openPageTemplateModalHandler}
         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>
         { t('template.option_label.create/edit') }
@@ -246,36 +245,34 @@ const GrowiContextualSubNavigation = (props) => {
       mutateEditorMode(viewType);
     }
 
-    const className = `d-flex flex-column align-items-end justify-content-center ${isViewMode ? ' h-50' : ''}`;
-
     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 && (
-            <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 && (
             <PageEditorModeManager
               onPageEditorModeButtonClicked={onPageEditorModeButtonClicked}
@@ -285,7 +282,7 @@ const GrowiContextualSubNavigation = (props) => {
             />
           )}
         </div>
-        {currentUser != null && (
+        {path != null && currentUser != null && (
           <CreateTemplateModal
             path={path}
             isOpen={isPageTemplateModalShown}
@@ -302,7 +299,6 @@ const GrowiContextualSubNavigation = (props) => {
     path, templateMenuItemClickHandler, isPageTemplateModalShown,
   ]);
 
-
   if (path == null) {
     return <></>;
   }
@@ -317,7 +313,6 @@ const GrowiContextualSubNavigation = (props) => {
     updatedAt: updatedAt ?? undefined,
   };
 
-
   return (
     <GrowiSubNavigation
       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 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 { useIsDeviceSmallerThanMd } from '~/stores/ui';
 
@@ -19,16 +20,15 @@ import GlobalSearch from './GlobalSearch';
 import PersonalDropdown from './PersonalDropdown';
 
 
-type NavbarRightProps = {
-  currentUser: IUser,
-}
-const NavbarRight: FC<NavbarRightProps> = memo((props: NavbarRightProps) => {
+const NavbarRight = memo((): JSX.Element => {
   const { t } = useTranslation();
+
   const { data: currentPagePath } = useCurrentPagePath();
+  const { data: isGuestUser } = useIsGuestUser();
+
   const { open: openCreateModal } = usePageCreateModal();
 
-  const { currentUser } = props;
-  const isAuthenticated = currentUser != null;
+  const isAuthenticated = isGuestUser === false;
 
   const authenticatedNavItem = useMemo(() => {
     return (
@@ -110,7 +110,6 @@ const Confidential: FC<ConfidentialProps> = memo((props: ConfidentialProps) => {
 const GrowiNavbar = (props) => {
 
   const { appContainer } = props;
-  const { currentUser } = appContainer;
   const { crowi, isSearchServiceConfigured } = appContainer.config;
 
   const { data: isDeviceSmallerThanMd } = useIsDeviceSmallerThanMd();
@@ -132,7 +131,7 @@ const GrowiNavbar = (props) => {
 
       {/* Navbar Right  */}
       <ul className="navbar-nav ml-auto">
-        <NavbarRight currentUser={currentUser}></NavbarRight>
+        <NavbarRight></NavbarRight>
         <Confidential confidential={crowi.confidential}></Confidential>
       </ul>
 

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

@@ -1,19 +1,17 @@
 import React from 'react';
 
 import { IPageHasId } from '~/interfaces/page';
-
+import { IUser } from '~/interfaces/user';
 import {
   EditorMode, useEditorMode,
 } from '~/stores/ui';
 
 import TagLabels from '../Page/TagLabels';
+import PagePathNav from '../PagePathNav';
 
 import AuthorInfo from './AuthorInfo';
 import DrawerToggler from './DrawerToggler';
 
-import PagePathNav from '../PagePathNav';
-import { IUser } from '~/interfaces/user';
-
 
 type Props = {
   page: Partial<IPageHasId>,
@@ -85,9 +83,7 @@ export const GrowiSubNavigation = (props: Props): JSX.Element => {
       {/* Right side */}
       <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 */}
         { (showPageAuthors && !isCompactMode) && (

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

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

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

@@ -1,17 +1,16 @@
 import React from 'react';
-import PropTypes from 'prop-types';
 
+import PropTypes from 'prop-types';
 import { UncontrolledTooltip } from 'reactstrap';
 
-import AppContainer from '~/client/services/AppContainer';
-
-import { withUnstatedContainers } from './UnstatedUtils';
+import { useIsGuestUser } from '~/stores/context';
 
 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;
   }
 
@@ -34,8 +33,7 @@ const NotAvailableForGuest = (props) => {
 };
 
 NotAvailableForGuest.propTypes = {
-  appContainer: PropTypes.instanceOf(AppContainer).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';
 
@@ -9,9 +9,9 @@ import EditorContainer from '~/client/services/EditorContainer';
 import PageContainer from '~/client/services/PageContainer';
 import { getOptionsToSave } from '~/client/util/editor';
 import {
-  useCurrentPagePath, useIsGuestUser, useSlackChannels,
+  useCurrentPagePath, useIsGuestUser,
 } from '~/stores/context';
-import { useIsSlackEnabled } from '~/stores/editor';
+import { useSWRxSlackChannels, useIsSlackEnabled } from '~/stores/editor';
 import {
   useEditorMode, useIsMobile, useSelectedGrant, useSelectedGrantGroupId, useSelectedGrantGroupName,
 } from '~/stores/ui';
@@ -49,10 +49,6 @@ class Page extends React.Component {
     this.saveHandlerForDrawioModal = this.saveHandlerForDrawioModal.bind(this);
   }
 
-  componentWillMount() {
-    this.props.appContainer.registerComponentInstance('Page', this);
-  }
-
   /**
    * launch HandsontableModal with data specified by arguments
    * @param beginLineNumber
@@ -191,25 +187,57 @@ const PageWrapper = (props) => {
   const { data: editorMode } = useEditorMode();
   const { data: isGuestUser } = useIsGuestUser();
   const { data: isMobile } = useIsMobile();
+  const { data: slackChannelsData } = useSWRxSlackChannels(currentPagePath);
   const { data: isSlackEnabled } = useIsSlackEnabled();
-  const { data: slackChannels } = useSlackChannels();
   const { data: grant } = useSelectedGrant();
   const { data: grantGroupId } = useSelectedGrantGroupId();
   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) {
     return null;
   }
 
+
   return (
     <Page
       {...props}
+      ref={pageRef}
       pagePath={currentPagePath}
       editorMode={editorMode}
       isGuestUser={isGuestUser}
       isMobile={isMobile}
       isSlackEnabled={isSlackEnabled}
-      slackChannels={slackChannels}
+      slackChannels={slackChannelsData.toString()}
       grant={grant}
       grantGroupId={grantGroupId}
       grantGroupName={grantGroupName}

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

@@ -1,18 +1,16 @@
 import React, {
   useState, useMemo, useCallback,
 } 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 {
   Dropdown, DropdownToggle, DropdownMenu, DropdownItem,
   Tooltip,
 } from 'reactstrap';
 
-import { CopyToClipboard } from 'react-copy-to-clipboard';
-
-import { pagePathUtils } from '@growi/core';
 
 const { encodeSpaces } = pagePathUtils;
 
@@ -102,8 +100,9 @@ const CopyDropdown = (props) => {
   /*
    * render
    */
+  const { t } = useTranslation();
   const {
-    t, dropdownToggleId, pageId, dropdownToggleClassName, children, isShareLinkMode,
+    dropdownToggleId, pageId, dropdownToggleClassName, children, isShareLinkMode,
   } = props;
 
   const customSwitchForParamsId = `customSwitchForParams_${dropdownToggleId}`;
@@ -199,8 +198,6 @@ const CopyDropdown = (props) => {
 };
 
 CopyDropdown.propTypes = {
-  t: PropTypes.func.isRequired, // i18next
-
   children: PropTypes.node.isRequired,
   dropdownToggleId: PropTypes.string.isRequired,
   pagePath: PropTypes.string.isRequired,
@@ -210,4 +207,4 @@ CopyDropdown.propTypes = {
   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 {
-  useCurrentPagePath, useIsSharedUser, useIsEditable, useCurrentPageId, useIsUserPage, usePageUser,
+  useCurrentPagePath, useIsSharedUser, useIsEditable, useCurrentPageId, useIsUserPage, usePageUser, useShareLinkId,
 } from '~/stores/context';
 import { useDescendantsPageListModal } from '~/stores/modal';
-import { useSWRxPageByPath } from '~/stores/page';
+import { useSWRxCurrentPage } from '~/stores/page';
 import { EditorMode, useEditorMode } from '~/stores/ui';
 
 import CountBadge from '../Common/CountBadge';
@@ -18,7 +18,7 @@ import ContentLinkButtons from '../ContentLinkButtons';
 import HashChanged from '../EventListeneres/HashChanged';
 import PageListIcon from '../Icons/PageListIcon';
 import Page from '../Page';
-import Editor from '../PageEditor';
+import PageEditor from '../PageEditor';
 import EditorNavbarBottom from '../PageEditor/EditorNavbarBottom';
 import PageEditorByHackmd from '../PageEditorByHackmd';
 import TableOfContents from '../TableOfContents';
@@ -33,18 +33,18 @@ const { isTopPage } = pagePathUtils;
 const DisplaySwitcher = (): JSX.Element => {
   const { t } = useTranslation();
 
-
   // get element for smoothScroll
   const getCommentListDom = useMemo(() => { return document.getElementById('page-comments-list') }, []);
 
 
   const { data: currentPageId } = useCurrentPageId();
-  const { data: currentPath } = useCurrentPagePath();
+  const { data: currentPagePath } = useCurrentPagePath();
   const { data: isSharedUser } = useIsSharedUser();
+  const { data: shareLinkId } = useShareLinkId();
   const { data: isUserPage } = useIsUserPage();
   const { data: isEditable } = useIsEditable();
   const { data: pageUser } = usePageUser();
-  const { data: currentPage } = useSWRxPageByPath(currentPath);
+  const { data: currentPage } = useSWRxCurrentPage(shareLinkId ?? undefined);
 
   const { data: editorMode } = useEditorMode();
 
@@ -52,7 +52,7 @@ const DisplaySwitcher = (): JSX.Element => {
 
   const isPageExist = currentPageId != null;
   const isViewMode = editorMode === EditorMode.View;
-  const isTopPagePath = isTopPage(currentPath ?? '');
+  const isTopPagePath = isTopPage(currentPagePath ?? '');
 
   return (
     <>
@@ -66,17 +66,17 @@ const DisplaySwitcher = (): JSX.Element => {
 
                   {/* Page list */}
                   <div className="grw-page-accessories-control">
-                    { currentPath != null && !isSharedUser && (
+                    { currentPagePath != null && !isSharedUser && (
                       <button
                         type="button"
                         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">
                           <PageListIcon />
                         </div>
                         {t('page_list')}
-                        {currentPage?.descendantCount != null && <CountBadge count={currentPage.descendantCount + 1} />}
+                        <CountBadge count={currentPage?.descendantCount} offset={1} />
                       </button>
                     ) }
                   </div>
@@ -91,7 +91,7 @@ const DisplaySwitcher = (): JSX.Element => {
                       >
                         <i className="icon-fw icon-bubbles grw-page-accessories-control-icon"></i>
                         <span>Comments</span>
-                        {currentPage?.commentCount != null && <CountBadge count={currentPage.commentCount} />}
+                        <CountBadge count={currentPage?.commentCount} />
                       </button>
                     </div>
                   ) }
@@ -117,7 +117,7 @@ const DisplaySwitcher = (): JSX.Element => {
         { isEditable && (
           <TabPane tabId={EditorMode.Editor}>
             <div data-testid="page-editor" id="page-editor">
-              <Editor />
+              <PageEditor />
             </div>
           </TabPane>
         ) }

+ 280 - 0
packages/app/src/components/Page/FixPageGrantAlert.tsx

@@ -0,0 +1,280 @@
+import React, { useEffect, useState, useCallback } from 'react';
+
+import { useTranslation } from 'react-i18next';
+import {
+  Modal, ModalHeader, ModalBody, ModalFooter,
+} from 'reactstrap';
+
+import { toastError, toastSuccess } from '~/client/util/apiNotification';
+import { apiv3Put } from '~/client/util/apiv3-client';
+import { PageGrant, IPageGrantData } from '~/interfaces/page';
+import { IRecordApplicableGrant, IResIsGrantNormalizedGrantData } from '~/interfaces/page-grant';
+import { useCurrentPageId, useCurrentUser, useHasParent } from '~/stores/context';
+import { useSWRxApplicableGrant, useSWRxIsGrantNormalized } from '~/stores/page';
+
+type ModalProps = {
+  isOpen: boolean
+  pageId: string
+  dataApplicableGrant: IRecordApplicableGrant
+  currentAndParentPageGrantData: IResIsGrantNormalizedGrantData
+  close(): void
+}
+
+const FixPageGrantModal = (props: ModalProps): JSX.Element => {
+  const { t } = useTranslation();
+
+  const {
+    isOpen, pageId, dataApplicableGrant, currentAndParentPageGrantData, close,
+  } = props;
+
+  const [selectedGrant, setSelectedGrant] = useState<PageGrant>(PageGrant.GRANT_RESTRICTED);
+  const [selectedGroup, setSelectedGroup] = useState<{_id: string, name: string} | undefined>(undefined); // TODO: Typescriptize model
+
+  // Alert message state
+  const [shouldShowModalAlert, setShowModalAlert] = useState<boolean>(false);
+
+  const applicableGroups = dataApplicableGrant[PageGrant.GRANT_USER_GROUP]?.applicableGroups;
+
+  // Reset state when opened
+  useEffect(() => {
+    if (isOpen) {
+      setSelectedGrant(PageGrant.GRANT_RESTRICTED);
+      setSelectedGroup(undefined);
+      setShowModalAlert(false);
+    }
+  }, [isOpen]);
+
+  const submit = async() => {
+    // Validate input values
+    if (selectedGrant === PageGrant.GRANT_USER_GROUP && selectedGroup == null) {
+      setShowModalAlert(true);
+      return;
+    }
+
+    close();
+
+    try {
+      await apiv3Put(`/page/${pageId}/grant`, {
+        grant: selectedGrant,
+        grantedGroup: selectedGroup?._id,
+      });
+
+      toastSuccess(t('Successfully updated'));
+    }
+    catch (err) {
+      toastError(t('Failed to update'));
+    }
+  };
+
+  const getGrantLabel = useCallback((isForbidden: boolean, grantData?: IPageGrantData): string => {
+
+    if (isForbidden) {
+      return t('fix_page_grant.modal.grant_label.isForbidden');
+    }
+
+    if (grantData == null) {
+      return t('fix_page_grant.modal.grant_label.isForbidden');
+    }
+
+    if (grantData.grant === 4) {
+      return t('fix_page_grant.modal.radio_btn.only_me');
+    }
+
+    if (grantData.grant === 5) {
+      if (grantData.grantedGroup == null) {
+        return t('fix_page_grant.modal.grant_label.isForbidden');
+      }
+      return `${t('fix_page_grant.modal.radio_btn.grant_group')}: (${grantData.grantedGroup.name})`;
+    }
+
+    throw Error('cannnot get grant label'); // this error can't be throwed
+  }, [t]);
+
+  const renderGrantDataLabel = useCallback(() => {
+    const { isForbidden, currentPageGrant, parentPageGrant } = currentAndParentPageGrantData;
+
+    const currentGrantLabel = getGrantLabel(false, currentPageGrant);
+    const parentGrantLabel = getGrantLabel(isForbidden, parentPageGrant);
+
+    return (
+      <>
+        <p className="mt-3">{ t('fix_page_grant.modal.grant_label.parentPageGrantLabel') + parentGrantLabel }</p>
+        <p>{ t('fix_page_grant.modal.grant_label.currentPageGrantLabel') + currentGrantLabel }</p>
+        {/* eslint-disable-next-line react/no-danger */}
+        <p dangerouslySetInnerHTML={{ __html: t('fix_page_grant.modal.grant_label.docLink') }} />
+      </>
+    );
+  }, [t, currentAndParentPageGrantData, getGrantLabel]);
+
+  const renderModalBodyAndFooter = () => {
+    const isGrantAvailable = Object.keys(dataApplicableGrant || {}).length > 0;
+
+    if (!isGrantAvailable) {
+      return (
+        <p className="m-5">
+          { t('fix_page_grant.modal.no_grant_available') }
+        </p>
+      );
+    }
+
+    return (
+      <>
+        <ModalBody>
+          <div className="form-group">
+            {/* eslint-disable-next-line react/no-danger */}
+            <p className="mb-2" dangerouslySetInnerHTML={{ __html: t('fix_page_grant.modal.need_to_fix_grant') }} />
+
+            {/* grant data label */}
+            {renderGrantDataLabel()}
+
+            <div className="ml-2">
+              <div className="custom-control custom-radio mb-3">
+                <input
+                  className="custom-control-input"
+                  name="grantRestricted"
+                  id="grantRestricted"
+                  type="radio"
+                  disabled={!(PageGrant.GRANT_RESTRICTED in dataApplicableGrant)}
+                  checked={selectedGrant === PageGrant.GRANT_RESTRICTED}
+                  onChange={() => setSelectedGrant(PageGrant.GRANT_RESTRICTED)}
+                />
+                <label className="custom-control-label" htmlFor="grantRestricted">
+                  { t('fix_page_grant.modal.radio_btn.restrected') }
+                </label>
+              </div>
+              <div className="custom-control custom-radio mb-3">
+                <input
+                  className="custom-control-input"
+                  name="grantUser"
+                  id="grantUser"
+                  type="radio"
+                  disabled={!(PageGrant.GRANT_OWNER in dataApplicableGrant)}
+                  checked={selectedGrant === PageGrant.GRANT_OWNER}
+                  onChange={() => setSelectedGrant(PageGrant.GRANT_OWNER)}
+                />
+                <label className="custom-control-label" htmlFor="grantUser">
+                  { t('fix_page_grant.modal.radio_btn.only_me') }
+                </label>
+              </div>
+              <div className="custom-control custom-radio d-flex mb-3">
+                <input
+                  className="custom-control-input"
+                  name="grantUserGroup"
+                  id="grantUserGroup"
+                  type="radio"
+                  disabled={!(PageGrant.GRANT_USER_GROUP in dataApplicableGrant)}
+                  checked={selectedGrant === PageGrant.GRANT_USER_GROUP}
+                  onChange={() => setSelectedGrant(PageGrant.GRANT_USER_GROUP)}
+                />
+                <label className="custom-control-label" htmlFor="grantUserGroup">
+                  { t('fix_page_grant.modal.radio_btn.grant_group') }
+                </label>
+                <div className="dropdown ml-2">
+                  <button
+                    type="button"
+                    className="btn btn-secondary dropdown-toggle text-right w-100 border-0 shadow-none"
+                    data-toggle="dropdown"
+                    disabled={selectedGrant !== PageGrant.GRANT_USER_GROUP} // disable when its radio input is not selected
+                  >
+                    <span className="float-left ml-2">
+                      {
+                        selectedGroup == null
+                          ? t('fix_page_grant.modal.select_group_default_text')
+                          : selectedGroup.name
+                      }
+                    </span>
+                  </button>
+                  <div className="dropdown-menu">
+                    {
+                      applicableGroups != null && applicableGroups.map(g => (
+                        <button
+                          className="dropdown-item"
+                          type="button"
+                          onClick={() => setSelectedGroup(g)}
+                        >
+                          {g.name}
+                        </button>
+                      ))
+                    }
+                  </div>
+                </div>
+              </div>
+              {
+                shouldShowModalAlert && (
+                  <p className="alert alert-warning">
+                    {t('fix_page_grant.modal.alert_message')}
+                  </p>
+                )
+              }
+            </div>
+          </div>
+        </ModalBody>
+        <ModalFooter>
+          <button type="button" className="btn btn-primary" onClick={submit}>
+            { t('fix_page_grant.modal.btn_label') }
+          </button>
+        </ModalFooter>
+      </>
+    );
+  };
+
+  return (
+    <Modal size="lg" isOpen={isOpen} toggle={close} className="grw-create-page">
+      <ModalHeader tag="h4" toggle={close} className="bg-primary text-light">
+        { t('fix_page_grant.modal.title') }
+      </ModalHeader>
+      {renderModalBodyAndFooter()}
+    </Modal>
+  );
+};
+
+const FixPageGrantAlert = (): JSX.Element => {
+  const { t } = useTranslation();
+
+  const { data: currentUser } = useCurrentUser();
+  const { data: pageId } = useCurrentPageId();
+  const { data: hasParent } = useHasParent();
+
+  const [isOpen, setOpen] = useState<boolean>(false);
+
+  const { data: dataIsGrantNormalized } = useSWRxIsGrantNormalized(currentUser != null ? pageId : null);
+  const { data: dataApplicableGrant } = useSWRxApplicableGrant(currentUser != null ? pageId : null);
+
+  // Dependencies
+  if (!hasParent) {
+    return <></>;
+  }
+  if (dataIsGrantNormalized?.isGrantNormalized == null || dataIsGrantNormalized.isGrantNormalized) {
+    return <></>;
+  }
+
+  return (
+    <>
+      <div className="alert alert-warning py-3 pl-4 d-flex flex-column flex-lg-row">
+        <div className="flex-grow-1 d-flex align-items-center">
+          <i className="icon-fw icon-exclamation ml-1" aria-hidden="true" />
+          {t('fix_page_grant.alert.description')}
+        </div>
+        <div className="d-flex align-items-end align-items-lg-center">
+          <button type="button" className="btn btn-info btn-sm rounded-pill px-3" onClick={() => setOpen(true)}>
+            {t('fix_page_grant.alert.btn_label')}
+          </button>
+        </div>
+      </div>
+
+      {
+        pageId != null && dataApplicableGrant != null && (
+          <FixPageGrantModal
+            isOpen={isOpen}
+            pageId={pageId}
+            dataApplicableGrant={dataApplicableGrant}
+            currentAndParentPageGrantData={dataIsGrantNormalized.grantData}
+            close={() => setOpen(false)}
+          />
+        )
+      }
+    </>
+  );
+};
+
+export default FixPageGrantAlert;

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

+ 1 - 5
packages/app/src/components/Page/RevisionRenderer.jsx

@@ -33,7 +33,7 @@ class LegacyRevisionRenderer extends React.PureComponent {
     this.currentRenderingContext = {
       markdown: this.props.markdown,
       pagePath: this.props.pagePath,
-      editorSettings: this.editorSettings,
+      renderDrawioInRealtime: this.props.editorSettings?.renderDrawioInRealtime,
       currentPathname: decodeURIComponent(window.location.pathname),
     };
   }
@@ -191,10 +191,6 @@ const LegacyRevisionRendererWrapper = withUnstatedContainers(LegacyRevisionRende
 const RevisionRenderer = (props) => {
   const { data: editorSettings } = useEditorSettings();
 
-  if (editorSettings == null) {
-    return <></>;
-  }
-
   return <LegacyRevisionRendererWrapper {...props} editorSettings={editorSettings} />;
 };
 

+ 4 - 11
packages/app/src/components/Page/ShareLinkAlert.jsx

@@ -1,11 +1,9 @@
 import React from 'react';
-import PropTypes from 'prop-types';
 
-import { withTranslation } from 'react-i18next';
-
-const ShareLinkAlert = (props) => {
-  const { t } = props;
+import { useTranslation } from 'react-i18next';
 
+const ShareLinkAlert = () => {
+  const { t } = useTranslation();
 
   const shareContent = document.getElementById('is-shared-page');
   const expiredAt = shareContent.getAttribute('data-share-link-expired-at');
@@ -51,9 +49,4 @@ const ShareLinkAlert = (props) => {
   );
 };
 
-
-ShareLinkAlert.propTypes = {
-  t: PropTypes.func.isRequired, // i18next
-};
-
-export default withTranslation()(ShareLinkAlert);
+export default ShareLinkAlert;

+ 12 - 15
packages/app/src/components/Page/TrashPageAlert.jsx

@@ -1,18 +1,17 @@
 import React, { useState } from 'react';
-import PropTypes from 'prop-types';
 
-import { withTranslation } from 'react-i18next';
 
 import { UserPicture } from '@growi/ui';
-import { withUnstatedContainers } from '../UnstatedUtils';
-import AppContainer from '~/client/services/AppContainer';
-import PageContainer from '~/client/services/PageContainer';
+import PropTypes from 'prop-types';
+import { useTranslation } from 'react-i18next';
 
+import PageContainer from '~/client/services/PageContainer';
 import { useCurrentUpdatedAt, useShareLinkId } from '~/stores/context';
 import { usePageDeleteModal, usePutBackPageModal } from '~/stores/modal';
 import { useSWRxPageInfo } from '~/stores/page';
 
 import EmptyTrashModal from '../EmptyTrashModal';
+import { withUnstatedContainers } from '../UnstatedUtils';
 
 const onDeletedHandler = (pathOrPathsToDelete, isRecursively, isCompletely) => {
   if (typeof pathOrPathsToDelete !== 'string') {
@@ -23,7 +22,8 @@ const onDeletedHandler = (pathOrPathsToDelete, isRecursively, isCompletely) => {
 };
 
 const TrashPageAlert = (props) => {
-  const { t, pageContainer } = props;
+  const { t } = useTranslation();
+  const { pageContainer } = props;
   const {
     pageId, revisionId, path, isDeleted, lastUpdateUsername, deletedUserName, deletedAt,
   } = pageContainer.state;
@@ -142,16 +142,13 @@ const TrashPageAlert = (props) => {
   );
 };
 
-/**
- * Wrapper component for using unstated
- */
-const TrashPageAlertWrapper = withUnstatedContainers(TrashPageAlert, [AppContainer, PageContainer]);
-
-
 TrashPageAlert.propTypes = {
-  t: PropTypes.func.isRequired, // i18next
-  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
   pageContainer: PropTypes.instanceOf(PageContainer).isRequired,
 };
 
-export default withTranslation()(TrashPageAlertWrapper);
+/**
+ * Wrapper component for using unstated
+ */
+const TrashPageAlertWrapper = withUnstatedContainers(TrashPageAlert, [PageContainer]);
+
+export default TrashPageAlertWrapper;

+ 1 - 0
packages/app/src/components/PageAccessoriesModal.tsx

@@ -106,6 +106,7 @@ const PageAccessoriesModal = (props: Props): JSX.Element => {
       size="xl"
       isOpen={isOpened}
       toggle={close}
+      data-testid="page-accessories-modal"
       className={`grw-page-accessories-modal ${isWindowExpanded ? 'grw-modal-expanded' : ''} `}
     >
       <ModalHeader className="p-0" toggle={close} close={buttons}>

+ 15 - 16
packages/app/src/components/PageAccessoriesModalControl.jsx

@@ -1,23 +1,23 @@
 import React, { Fragment, useMemo } from 'react';
-import PropTypes from 'prop-types';
-
-import { withTranslation } from 'react-i18next';
 
+import PropTypes from 'prop-types';
+import { useTranslation } from 'react-i18next';
 import { UncontrolledTooltip } from 'reactstrap';
 
-import PageListIcon from './Icons/PageListIcon';
-import TimeLineIcon from './Icons/TimeLineIcon';
-import HistoryIcon from './Icons/HistoryIcon';
+import { useCurrentPageId } from '~/stores/context';
+
 import AttachmentIcon from './Icons/AttachmentIcon';
+import HistoryIcon from './Icons/HistoryIcon';
+import PageListIcon from './Icons/PageListIcon';
 import ShareLinkIcon from './Icons/ShareLinkIcon';
-
+import TimeLineIcon from './Icons/TimeLineIcon';
 import { withUnstatedContainers } from './UnstatedUtils';
 
-import { useCurrentPageId } from '~/stores/context';
 
 const PageAccessoriesModalControl = (props) => {
+  const { t } = useTranslation();
   const {
-    t, pageAccessoriesContainer, isGuestUser, isSharedUser,
+    pageAccessoriesContainer, isGuestUser, isSharedUser,
   } = props;
   const isLinkSharingDisabled = pageAccessoriesContainer.appContainer.config.disableLinkSharing;
 
@@ -92,18 +92,17 @@ const PageAccessoriesModalControl = (props) => {
     </div>
   );
 };
-/**
- * Wrapper component for using unstated
- */
-const PageAccessoriesModalControlWrapper = withUnstatedContainers(PageAccessoriesModalControl, []);
 
 PageAccessoriesModalControl.propTypes = {
-  t: PropTypes.func.isRequired, //  i18next
-
   pageAccessoriesContainer: PropTypes.any,
 
   isGuestUser: PropTypes.bool.isRequired,
   isSharedUser: PropTypes.bool.isRequired,
 };
 
-export default withTranslation()(PageAccessoriesModalControlWrapper);
+/**
+ * Wrapper component for using unstated
+ */
+const PageAccessoriesModalControlWrapper = withUnstatedContainers(PageAccessoriesModalControl, []);
+
+export default PageAccessoriesModalControlWrapper;

+ 30 - 18
packages/app/src/components/PageAttachment.jsx

@@ -2,12 +2,12 @@
 import React from 'react';
 
 import PropTypes from 'prop-types';
-import { withTranslation } from 'react-i18next';
+import { useTranslation } from 'react-i18next';
 
-import AppContainer from '~/client/services/AppContainer';
 import PageContainer from '~/client/services/PageContainer';
 import { apiPost } from '~/client/util/apiv1-client';
 import { apiv3Get } from '~/client/util/apiv3-client';
+import { useIsGuestUser } from '~/stores/context';
 
 import DeleteAttachmentModal from './PageAttachment/DeleteAttachmentModal';
 import PageAttachmentList from './PageAttachment/PageAttachmentList';
@@ -110,19 +110,20 @@ class PageAttachment extends React.Component {
       });
   }
 
-  isUserLoggedIn() {
-    return this.props.appContainer.currentUser != null;
-  }
-
 
   render() {
-    const { t } = this.props;
+    const { t, isGuestUser } = this.props;
+
     if (this.state.attachments.length === 0) {
-      return t('No_attachments_yet');
+      return (
+        <div data-testid="page-attachment">
+          {t('No_attachments_yet')}
+        </div>
+      );
     }
 
     let deleteAttachmentModal = '';
-    if (this.isUserLoggedIn()) {
+    if (!isGuestUser) {
       const attachmentToDelete = this.state.attachmentToDelete;
       const deleteModalClose = () => {
         this.setState({ attachmentToDelete: null, deleteError: '' });
@@ -149,12 +150,12 @@ class PageAttachment extends React.Component {
     }
 
     return (
-      <>
+      <div data-testid="page-attachment">
         <PageAttachmentList
           attachments={this.state.attachments}
           inUse={this.state.inUse}
           onAttachmentDeleteClicked={this.onAttachmentDeleteClicked}
-          isUserLoggedIn={this.isUserLoggedIn()}
+          isUserLoggedIn={!isGuestUser}
         />
 
         {deleteAttachmentModal}
@@ -166,22 +167,33 @@ class PageAttachment extends React.Component {
           pagingLimit={this.state.limit}
           align="center"
         />
-      </>
+      </div>
     );
   }
 
 }
 
+PageAttachment.propTypes = {
+  t: PropTypes.func.isRequired,
+  pageContainer: PropTypes.instanceOf(PageContainer).isRequired,
+
+  isGuestUser: PropTypes.bool.isRequired,
+};
+
 /**
  * Wrapper component for using unstated
  */
-const PageAttachmentWrapper = withUnstatedContainers(PageAttachment, [AppContainer, PageContainer]);
+const PageAttachmentUnstatedWrapper = withUnstatedContainers(PageAttachment, [PageContainer]);
 
+const PageAttachmentWrapper = (props) => {
+  const { t } = useTranslation();
+  const { data: isGuestUser } = useIsGuestUser();
 
-PageAttachment.propTypes = {
-  t: PropTypes.func.isRequired,
-  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
-  pageContainer: PropTypes.instanceOf(PageContainer).isRequired,
+  if (isGuestUser == null) {
+    return <></>;
+  }
+
+  return <PageAttachmentUnstatedWrapper {...props} t={t} isGuestUser={isGuestUser} />;
 };
 
-export default withTranslation()(PageAttachmentWrapper);
+export default PageAttachmentWrapper;

+ 19 - 12
packages/app/src/components/PageComment/Comment.jsx

@@ -1,23 +1,24 @@
 import React from 'react';
-import PropTypes from 'prop-types';
 
-import { useTranslation } from 'react-i18next';
+import { UserPicture } from '@growi/ui';
 import { format } from 'date-fns';
-
+import PropTypes from 'prop-types';
+import { useTranslation } from 'react-i18next';
 import { UncontrolledTooltip } from 'reactstrap';
 
-import { UserPicture } from '@growi/ui';
 import AppContainer from '~/client/services/AppContainer';
 import PageContainer from '~/client/services/PageContainer';
-
-import { withUnstatedContainers } from '../UnstatedUtils';
+import { useCurrentUser } from '~/stores/context';
 
 import FormattedDistanceDate from '../FormattedDistanceDate';
+import HistoryIcon from '../Icons/HistoryIcon';
 import RevisionBody from '../Page/RevisionBody';
+import { withUnstatedContainers } from '../UnstatedUtils';
 import Username from '../User/Username';
-import CommentEditor from './CommentEditor';
+
 import CommentControl from './CommentControl';
-import HistoryIcon from '../Icons/HistoryIcon';
+import CommentEditor from './CommentEditor';
+
 
 /**
  *
@@ -74,11 +75,13 @@ class Comment extends React.PureComponent {
   }
 
   isCurrentUserEqualsToAuthor() {
-    const { creator } = this.props.comment;
-    if (creator == null) {
+    const { comment, currentUser } = this.props;
+    const { creator } = comment;
+
+    if (creator == null || currentUser == null) {
       return false;
     }
-    return creator.username === this.props.appContainer.currentUsername;
+    return creator.username === currentUser.username;
   }
 
   isCurrentRevision() {
@@ -235,12 +238,16 @@ Comment.propTypes = {
   isReadOnly: PropTypes.bool.isRequired,
   growiRenderer: PropTypes.object.isRequired,
   deleteBtnClicked: PropTypes.func.isRequired,
+  currentUser: PropTypes.object,
   onComment: PropTypes.func,
 };
 
 const CommentWrapperFC = (props) => {
   const { t } = useTranslation();
-  return <Comment t={t} {...props} />;
+
+  const { data: currentUser } = useCurrentUser();
+
+  return <Comment t={t} currentUser={currentUser} {...props} />;
 };
 
 /**

+ 33 - 13
packages/app/src/components/PageComment/CommentEditor.jsx

@@ -1,4 +1,4 @@
-import React from 'react';
+import React, { useCallback } from 'react';
 
 import { UserPicture } from '@growi/ui';
 import PropTypes from 'prop-types';
@@ -13,7 +13,8 @@ import CommentContainer from '~/client/services/CommentContainer';
 import EditorContainer from '~/client/services/EditorContainer';
 import PageContainer from '~/client/services/PageContainer';
 import GrowiRenderer from '~/client/util/GrowiRenderer';
-import { useCurrentUser } from '~/stores/context';
+import { useCurrentPagePath, useCurrentUser } from '~/stores/context';
+import { useSWRxSlackChannels, useIsSlackEnabled } from '~/stores/editor';
 import { useIsMobile } from '~/stores/ui';
 
 import { CustomNavTab } from '../CustomNavigation/CustomNav';
@@ -64,6 +65,7 @@ class CommentEditor extends React.Component {
       isUploadableFile,
       errorMessage: undefined,
       isSlackConfigured: config.isSlackConfigured,
+      slackChannels: this.props.slackChannels,
     };
 
     this.updateState = this.updateState.bind(this);
@@ -77,8 +79,8 @@ class CommentEditor extends React.Component {
 
     this.renderHtml = this.renderHtml.bind(this);
     this.handleSelect = this.handleSelect.bind(this);
-    this.onSlackEnabledFlagChange = this.onSlackEnabledFlagChange.bind(this);
     this.onSlackChannelsChange = this.onSlackChannelsChange.bind(this);
+    this.fetchSlackChannels = this.fetchSlackChannels.bind(this);
   }
 
   updateState(value) {
@@ -97,12 +99,18 @@ class CommentEditor extends React.Component {
     this.renderHtml(this.state.comment);
   }
 
-  onSlackEnabledFlagChange(isSlackEnabled) {
-    this.props.commentContainer.setState({ isSlackEnabled });
+  fetchSlackChannels(slackChannels) {
+    this.setState({ slackChannels });
+  }
+
+  componentDidUpdate(prevProps) {
+    if (this.props.slackChannels !== prevProps.slackChannels) {
+      this.fetchSlackChannels(this.props.slackChannels);
+    }
   }
 
   onSlackChannelsChange(slackChannels) {
-    this.props.commentContainer.setState({ slackChannels });
+    this.setState({ slackChannels });
   }
 
   initializeEditor() {
@@ -165,8 +173,8 @@ class CommentEditor extends React.Component {
           this.state.comment,
           this.state.isMarkdown,
           replyTo,
-          commentContainer.state.isSlackEnabled,
-          commentContainer.state.slackChannels,
+          this.props.isSlackEnabled,
+          this.state.slackChannels,
         );
       }
       this.initializeEditor();
@@ -216,7 +224,6 @@ class CommentEditor extends React.Component {
   getCommentHtml() {
     return (
       <CommentPreview
-        inputRef={(el) => { this.previewElement = el }}
         html={this.state.html}
       />
     );
@@ -273,7 +280,7 @@ class CommentEditor extends React.Component {
   }
 
   renderReady() {
-    const { appContainer, commentContainer, isMobile } = this.props;
+    const { isMobile } = this.props;
     const { activeTab } = this.state;
 
     const commentPreview = this.state.isMarkdown ? this.getCommentHtml() : null;
@@ -358,9 +365,9 @@ class CommentEditor extends React.Component {
               && (
                 <div className="form-inline align-self-center mr-md-2">
                   <SlackNotification
-                    isSlackEnabled={commentContainer.state.isSlackEnabled}
-                    slackChannels={commentContainer.state.slackChannels}
-                    onEnabledFlagChange={this.onSlackEnabledFlagChange}
+                    isSlackEnabled={this.props.isSlackEnabled}
+                    slackChannels={this.state.slackChannels}
+                    onEnabledFlagChange={this.props.onSlackEnabledFlagChange}
                     onChannelChange={this.onSlackChannelsChange}
                     id="idForComment"
                   />
@@ -416,6 +423,8 @@ CommentEditor.propTypes = {
   editorContainer: PropTypes.instanceOf(EditorContainer).isRequired,
   commentContainer: PropTypes.instanceOf(CommentContainer).isRequired,
 
+  slackChannels: PropTypes.string.isRequired,
+  isSlackEnabled: PropTypes.bool.isRequired,
   growiRenderer: PropTypes.instanceOf(GrowiRenderer).isRequired,
   currentUser: PropTypes.instanceOf(Object),
   isMobile: PropTypes.bool,
@@ -426,15 +435,26 @@ CommentEditor.propTypes = {
   commentCreator: PropTypes.string,
   onCancelButtonClicked: PropTypes.func,
   onCommentButtonClicked: PropTypes.func,
+  onSlackEnabledFlagChange: PropTypes.func,
 };
 
 const CommentEditorWrapper = (props) => {
   const { data: isMobile } = useIsMobile();
   const { data: currentUser } = useCurrentUser();
+  const { data: isSlackEnabled, mutate: mutateIsSlackEnabled } = useIsSlackEnabled();
+  const { data: currentPagePath } = useCurrentPagePath();
+  const { data: slackChannelsData } = useSWRxSlackChannels(currentPagePath);
+
+  const onSlackEnabledFlagChange = useCallback((isSlackEnabled) => {
+    mutateIsSlackEnabled(isSlackEnabled, false);
+  }, [mutateIsSlackEnabled]);
 
   return (
     <CommentEditorHOCWrapper
       {...props}
+      onSlackEnabledFlagChange={onSlackEnabledFlagChange}
+      slackChannels={slackChannelsData.toString()}
+      isSlackEnabled={isSlackEnabled}
       currentUser={currentUser}
       isMobile={isMobile}
     />

+ 15 - 20
packages/app/src/components/PageComment/CommentPreview.jsx

@@ -1,4 +1,5 @@
 import React from 'react';
+
 import PropTypes from 'prop-types';
 
 import RevisionBody from '../Page/RevisionBody';
@@ -6,29 +7,23 @@ import RevisionBody from '../Page/RevisionBody';
 /**
  * Wrapper component for Page/RevisionBody
  */
-export default class CommentPreview extends React.Component {
-
-  render() {
-    return (
-      <div
-        className="page-comment-preview-body"
-        ref={(elm) => {
-          this.previewElement = elm;
-          this.props.inputRef(elm);
-        }}
-      >
+const CommentPreview = (props) => {
 
-        <RevisionBody
-          {...this.props}
-          additionalClassName="comment"
-        />
-      </div>
-    );
-  }
+  return (
+    <div className="page-comment-preview-body">
+      <RevisionBody
+        html={props.html}
+        additionalClassName="comment"
+        isMathJaxEnabled
+        renderMathJaxInRealtime
+      />
+    </div>
+  );
 
-}
+};
 
 CommentPreview.propTypes = {
   html: PropTypes.string,
-  inputRef: PropTypes.func.isRequired, // for getting div element
 };
+
+export default CommentPreview;

+ 12 - 11
packages/app/src/components/PageCreateModal.jsx

@@ -5,13 +5,14 @@ import React, {
 import { pagePathUtils, pathUtils } from '@growi/core';
 import { format } from 'date-fns';
 import PropTypes from 'prop-types';
-import { withTranslation } from 'react-i18next';
+import { useTranslation } from 'react-i18next';
 import { Modal, ModalHeader, ModalBody } from 'reactstrap';
 import { debounce } from 'throttle-debounce';
 
 
 import AppContainer from '~/client/services/AppContainer';
 import { toastError } from '~/client/util/apiNotification';
+import { useCurrentUser } from '~/stores/context';
 import { usePageCreateModal } from '~/stores/modal';
 
 import PagePathAutoComplete from './PagePathAutoComplete';
@@ -23,7 +24,10 @@ const {
 } = pagePathUtils;
 
 const PageCreateModal = (props) => {
-  const { t, appContainer } = props;
+  const { t } = useTranslation();
+  const { appContainer } = props;
+
+  const { data: currentUser } = useCurrentUser();
 
   const { data: pageCreateModalData, close: closeCreateModal } = usePageCreateModal();
   const { isOpened, path } = pageCreateModalData;
@@ -31,7 +35,7 @@ const PageCreateModal = (props) => {
   const config = appContainer.getConfig();
   const isReachable = config.isSearchServiceReachable;
   const pathname = path || '';
-  const userPageRootPath = userPageRoot(appContainer.currentUser);
+  const userPageRootPath = userPageRoot(currentUser);
   const isCreatable = isCreatablePage(pathname) || isUsersHomePage(pathname);
   const pageNameInputInitialValue = isCreatable ? pathUtils.addTrailingSlash(pathname) : '/';
   const now = format(new Date(), 'yyyy/MM/dd');
@@ -307,16 +311,13 @@ const PageCreateModal = (props) => {
   );
 };
 
+PageCreateModal.propTypes = {
+  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
+};
 
 /**
  * Wrapper component for using unstated
  */
-const ModalControlWrapper = withUnstatedContainers(PageCreateModal, [AppContainer]);
-
-
-PageCreateModal.propTypes = {
-  t: PropTypes.func.isRequired, //  i18next
-  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
-};
+const PageCreateModalWrapper = withUnstatedContainers(PageCreateModal, [AppContainer]);
 
-export default withTranslation()(ModalControlWrapper);
+export default PageCreateModalWrapper;

+ 5 - 1
packages/app/src/components/PageDeleteModal.tsx

@@ -1,5 +1,5 @@
 import React, {
-  useState, FC, useMemo,
+  useState, FC, useMemo, useEffect,
 } from 'react';
 
 import { useTranslation } from 'react-i18next';
@@ -83,6 +83,10 @@ const PageDeleteModal: FC = () => {
   // eslint-disable-next-line @typescript-eslint/no-unused-vars
   const [errs, setErrs] = useState<Error[] | null>(null);
 
+  useEffect(() => {
+    setIsDeleteCompletely(forceDeleteCompletelyMode);
+  }, [forceDeleteCompletelyMode]);
+
   function changeIsDeleteRecursivelyHandler() {
     setIsDeleteRecursively(!isDeleteRecursively);
   }

+ 0 - 440
packages/app/src/components/PageEditor.jsx

@@ -1,440 +0,0 @@
-import React from 'react';
-
-import { envUtils } from '@growi/core';
-import detectIndent from 'detect-indent';
-import PropTypes from 'prop-types';
-import { throttle, debounce } from 'throttle-debounce';
-
-import AppContainer from '~/client/services/AppContainer';
-import EditorContainer from '~/client/services/EditorContainer';
-import PageContainer from '~/client/services/PageContainer';
-import { apiGet, apiPost } from '~/client/util/apiv1-client';
-import { getOptionsToSave } from '~/client/util/editor';
-import { useIsEditable, useIsIndentSizeForced, useSlackChannels } from '~/stores/context';
-import { useCurrentIndentSize, useIsSlackEnabled, useIsTextlintEnabled } from '~/stores/editor';
-import {
-  useEditorMode, useIsMobile, useSelectedGrant, useSelectedGrantGroupId, useSelectedGrantGroupName,
-} from '~/stores/ui';
-import loggerFactory from '~/utils/logger';
-
-
-import { ConflictDiffModal } from './PageEditor/ConflictDiffModal';
-import Editor from './PageEditor/Editor';
-import Preview from './PageEditor/Preview';
-import scrollSyncHelper from './PageEditor/ScrollSyncHelper';
-import { withUnstatedContainers } from './UnstatedUtils';
-
-
-// TODO: remove this when omitting unstated is completed
-
-const logger = loggerFactory('growi:PageEditor');
-
-class PageEditor extends React.Component {
-
-  constructor(props) {
-    super(props);
-
-    this.previewElement = React.createRef();
-
-    const config = this.props.appContainer.getConfig();
-    const isUploadable = config.upload.image || config.upload.file;
-    const isUploadableFile = config.upload.file;
-    const isMathJaxEnabled = !!config.env.MATHJAX;
-
-    this.state = {
-      markdown: this.props.pageContainer.state.markdown,
-      isUploadable,
-      isUploadableFile,
-      isMathJaxEnabled,
-    };
-
-    this.setCaretLine = this.setCaretLine.bind(this);
-    this.focusToEditor = this.focusToEditor.bind(this);
-    this.onMarkdownChanged = this.onMarkdownChanged.bind(this);
-    this.onSaveWithShortcut = this.onSaveWithShortcut.bind(this);
-    this.onUpload = this.onUpload.bind(this);
-    this.onEditorScroll = this.onEditorScroll.bind(this);
-    this.onEditorScrollCursorIntoView = this.onEditorScrollCursorIntoView.bind(this);
-    this.onPreviewScroll = this.onPreviewScroll.bind(this);
-    this.saveDraft = this.saveDraft.bind(this);
-    this.clearDraft = this.clearDraft.bind(this);
-
-    // for scrolling
-    this.lastScrolledDateWithCursor = null;
-    this.isOriginOfScrollSyncEditor = false;
-    this.isOriginOfScrollSyncEditor = false;
-
-    // create throttled function
-    this.scrollPreviewByEditorLineWithThrottle = throttle(20, this.scrollPreviewByEditorLine);
-    this.scrollPreviewByCursorMovingWithThrottle = throttle(20, this.scrollPreviewByCursorMoving);
-    this.scrollEditorByPreviewScrollWithThrottle = throttle(20, this.scrollEditorByPreviewScroll);
-    this.setMarkdownStateWithDebounce = debounce(50, throttle(100, (value) => {
-      this.setState({ markdown: value });
-    }));
-    this.saveDraftWithDebounce = debounce(800, this.saveDraft);
-
-    // Detect indent size from contents (only when users are allowed to change it)
-    // TODO: https://youtrack.weseek.co.jp/issue/GW-5368
-    if (!props.isIndentSizeForced && this.state.markdown) {
-      const detectedIndent = detectIndent(this.state.markdown);
-      if (detectedIndent.type === 'space' && new Set([2, 4]).has(detectedIndent.amount)) {
-        props.mutateCurrentIndentSize(detectedIndent.amount);
-      }
-    }
-  }
-
-  componentWillMount() {
-    this.props.appContainer.registerComponentInstance('PageEditor', this);
-  }
-
-  getMarkdown() {
-    return this.state.markdown;
-  }
-
-  updateEditorValue(markdown) {
-    this.editor.setValue(markdown);
-  }
-
-  focusToEditor() {
-    this.editor.forceToFocus();
-  }
-
-  /**
-   * set caret position of editor
-   * @param {number} line
-   */
-  setCaretLine(line) {
-    this.editor.setCaretLine(line);
-    scrollSyncHelper.scrollPreview(this.previewElement, line);
-  }
-
-  /**
-   * the change event handler for `markdown` state
-   * @param {string} value
-   */
-  onMarkdownChanged(value) {
-    const { pageContainer } = this.props;
-    this.setMarkdownStateWithDebounce(value);
-    // only when the first time to edit
-    if (!pageContainer.state.revisionId) {
-      this.saveDraftWithDebounce();
-    }
-  }
-
-  // Displays an alert if there is a difference with pageContainer's markdown
-  componentDidUpdate(prevProps, prevState) {
-    const { pageContainer, editorContainer } = this.props;
-
-    if (this.state.markdown !== prevState.markdown) {
-      if (pageContainer.state.markdown !== this.state.markdown) {
-        editorContainer.enableUnsavedWarning();
-      }
-    }
-  }
-
-  /**
-   * save and update state of containers
-   */
-  async onSaveWithShortcut() {
-    const {
-      isSlackEnabled, slackChannels, grant, grantGroupId, grantGroupName, editorContainer, pageContainer,
-    } = this.props;
-
-    const optionsToSave = getOptionsToSave(isSlackEnabled, slackChannels, grant, grantGroupId, grantGroupName, editorContainer);
-
-    try {
-      // disable unsaved warning
-      editorContainer.disableUnsavedWarning();
-
-      // eslint-disable-next-line no-unused-vars
-      const { page, tags } = await pageContainer.save(this.state.markdown, this.props.editorMode, optionsToSave);
-      logger.debug('success to save');
-
-      pageContainer.showSuccessToastr();
-
-      // update state of EditorContainer
-      editorContainer.setState({ tags });
-    }
-    catch (error) {
-      logger.error('failed to save', error);
-      pageContainer.showErrorToastr(error);
-    }
-  }
-
-  /**
-   * the upload event handler
-   * @param {any} file
-   */
-  async onUpload(file) {
-    const {
-      appContainer, pageContainer, mutateGrant,
-    } = this.props;
-
-    try {
-      let res = await apiGet('/attachments.limit', {
-        fileSize: file.size,
-      });
-
-      if (!res.isUploadable) {
-        throw new Error(res.errorMessage);
-      }
-
-      const formData = new FormData();
-      const { pageId, path } = pageContainer.state;
-      formData.append('_csrf', appContainer.csrfToken);
-      formData.append('file', file);
-      formData.append('path', path);
-      if (pageId != null) {
-        formData.append('page_id', pageContainer.state.pageId);
-      }
-
-      res = await apiPost('/attachments.add', formData);
-      const attachment = res.attachment;
-      const fileName = attachment.originalName;
-
-      let insertText = `[${fileName}](${attachment.filePathProxied})`;
-      // when image
-      if (attachment.fileFormat.startsWith('image/')) {
-        // modify to "![fileName](url)" syntax
-        insertText = `!${insertText}`;
-      }
-      this.editor.insertText(insertText);
-
-      // when if created newly
-      if (res.pageCreated) {
-        logger.info('Page is created', res.page._id);
-        pageContainer.updateStateAfterSave(res.page, res.tags, res.revision, this.props.editorMode);
-        mutateGrant(res.page.grant);
-      }
-    }
-    catch (e) {
-      logger.error('failed to upload', e);
-      pageContainer.showErrorToastr(e);
-    }
-    finally {
-      this.editor.terminateUploadingState();
-    }
-  }
-
-  /**
-   * the scroll event handler from codemirror
-   * @param {any} data {left, top, width, height, clientWidth, clientHeight} object that represents the current scroll position,
-   *                    the size of the scrollable area, and the size of the visible area (minus scrollbars).
-   *                    And data.line is also available that is added by Editor component
-   * @see https://codemirror.net/doc/manual.html#events
-   */
-  onEditorScroll(data) {
-    // prevent scrolling
-    //   if the elapsed time from last scroll with cursor is shorter than 40ms
-    const now = new Date();
-    if (now - this.lastScrolledDateWithCursor < 40) {
-      return;
-    }
-
-    this.scrollPreviewByEditorLineWithThrottle(data.line);
-  }
-
-  /**
-   * the scroll event handler from codemirror
-   * @param {number} line
-   * @see https://codemirror.net/doc/manual.html#events
-   */
-  onEditorScrollCursorIntoView(line) {
-    // record date
-    this.lastScrolledDateWithCursor = new Date();
-    this.scrollPreviewByCursorMovingWithThrottle(line);
-  }
-
-  /**
-   * scroll Preview element by scroll event
-   * @param {number} line
-   */
-  scrollPreviewByEditorLine(line) {
-    if (this.previewElement == null) {
-      return;
-    }
-
-    // prevent circular invocation
-    if (this.isOriginOfScrollSyncPreview) {
-      this.isOriginOfScrollSyncPreview = false; // turn off the flag
-      return;
-    }
-
-    // turn on the flag
-    this.isOriginOfScrollSyncEditor = true;
-    scrollSyncHelper.scrollPreview(this.previewElement, line);
-  }
-
-  /**
-   * scroll Preview element by cursor moving
-   * @param {number} line
-   */
-  scrollPreviewByCursorMoving(line) {
-    if (this.previewElement == null) {
-      return;
-    }
-
-    // prevent circular invocation
-    if (this.isOriginOfScrollSyncPreview) {
-      this.isOriginOfScrollSyncPreview = false; // turn off the flag
-      return;
-    }
-
-    // turn on the flag
-    this.isOriginOfScrollSyncEditor = true;
-    scrollSyncHelper.scrollPreviewToRevealOverflowing(this.previewElement, line);
-  }
-
-  /**
-   * the scroll event handler from Preview component
-   * @param {number} offset
-   */
-  onPreviewScroll(offset) {
-    this.scrollEditorByPreviewScrollWithThrottle(offset);
-  }
-
-  /**
-   * scroll Editor component by scroll event of Preview component
-   * @param {number} offset
-   */
-  scrollEditorByPreviewScroll(offset) {
-    if (this.previewElement == null) {
-      return;
-    }
-
-    // prevent circular invocation
-    if (this.isOriginOfScrollSyncEditor) {
-      this.isOriginOfScrollSyncEditor = false; // turn off the flag
-      return;
-    }
-
-    // turn on the flag
-    this.isOriginOfScrollSyncPreview = true;
-    scrollSyncHelper.scrollEditor(this.editor, this.previewElement, offset);
-  }
-
-  saveDraft() {
-    const { pageContainer, editorContainer } = this.props;
-    editorContainer.saveDraft(pageContainer.state.path, this.state.markdown);
-  }
-
-  clearDraft() {
-    this.props.editorContainer.clearDraft(this.props.pageContainer.state.path);
-  }
-
-  render() {
-    if (!this.props.isEditable) {
-      return null;
-    }
-
-    const config = this.props.appContainer.getConfig();
-    const noCdn = envUtils.toBoolean(config.env.NO_CDN);
-
-    return (
-      <div className="d-flex flex-wrap">
-        <div className="page-editor-editor-container flex-grow-1 flex-basis-0 mw-0">
-          <Editor
-            ref={(c) => { this.editor = c }}
-            value={this.state.markdown}
-            noCdn={noCdn}
-            isMobile={this.props.isMobile}
-            isUploadable={this.state.isUploadable}
-            isUploadableFile={this.state.isUploadableFile}
-            isTextlintEnabled={this.props.isTextlintEnabled}
-            indentSize={this.props.indentSize}
-            onScroll={this.onEditorScroll}
-            onScrollCursorIntoView={this.onEditorScrollCursorIntoView}
-            onChange={this.onMarkdownChanged}
-            onUpload={this.onUpload}
-            onSave={this.onSaveWithShortcut}
-          />
-        </div>
-        <div className="d-none d-lg-block page-editor-preview-container flex-grow-1 flex-basis-0 mw-0">
-          <Preview
-            markdown={this.state.markdown}
-            // eslint-disable-next-line no-return-assign
-            inputRef={(el) => { return this.previewElement = el }}
-            isMathJaxEnabled={this.state.isMathJaxEnabled}
-            renderMathJaxOnInit={false}
-            onScroll={this.onPreviewScroll}
-          />
-        </div>
-        <ConflictDiffModal
-          isOpen={this.props.pageContainer.state.isConflictDiffModalOpen}
-          onClose={() => this.props.pageContainer.setState({ isConflictDiffModalOpen: false })}
-          appContainer={this.props.appContainer}
-          pageContainer={this.props.pageContainer}
-          markdownOnEdit={this.state.markdown}
-        />
-      </div>
-    );
-  }
-
-}
-
-PageEditor.propTypes = {
-  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
-  pageContainer: PropTypes.instanceOf(PageContainer).isRequired,
-  editorContainer: PropTypes.instanceOf(EditorContainer).isRequired,
-
-  isEditable: PropTypes.bool.isRequired,
-
-  // TODO: remove this when omitting unstated is completed
-  editorMode: PropTypes.string.isRequired,
-  isMobile: PropTypes.bool,
-  isSlackEnabled: PropTypes.bool.isRequired,
-  slackChannels: PropTypes.string.isRequired,
-  grant: PropTypes.number.isRequired,
-  grantGroupId: PropTypes.string,
-  grantGroupName: PropTypes.string,
-  mutateGrant: PropTypes.func,
-  isTextlintEnabled: PropTypes.bool,
-  isIndentSizeForced: PropTypes.bool,
-  indentSize: PropTypes.number,
-  mutateCurrentIndentSize: PropTypes.func,
-};
-
-/**
- * Wrapper component for using unstated
- */
-const PageEditorHOCWrapper = withUnstatedContainers(PageEditor, [AppContainer, PageContainer, EditorContainer]);
-
-const PageEditorWrapper = (props) => {
-  const { data: isEditable } = useIsEditable();
-  const { data: editorMode } = useEditorMode();
-  const { data: isMobile } = useIsMobile();
-  const { data: isSlackEnabled } = useIsSlackEnabled();
-  const { data: slackChannels } = useSlackChannels();
-  const { data: grant, mutate: mutateGrant } = useSelectedGrant();
-  const { data: grantGroupId } = useSelectedGrantGroupId();
-  const { data: grantGroupName } = useSelectedGrantGroupName();
-  const { data: isTextlintEnabled } = useIsTextlintEnabled();
-  const { data: isIndentSizeForced } = useIsIndentSizeForced();
-  const { data: indentSize, mutate: mutateCurrentIndentSize } = useCurrentIndentSize();
-
-  if (isEditable == null || editorMode == null) {
-    return null;
-  }
-
-  return (
-    <PageEditorHOCWrapper
-      {...props}
-      isEditable={isEditable}
-      editorMode={editorMode}
-      isMobile={isMobile}
-      isSlackEnabled={isSlackEnabled}
-      slackChannels={slackChannels}
-      grant={grant}
-      grantGroupId={grantGroupId}
-      grantGroupName={grantGroupName}
-      mutateGrant={mutateGrant}
-      isTextlintEnabled={isTextlintEnabled}
-      isIndentSizeForced={isIndentSizeForced}
-      indentSize={indentSize}
-      mutateCurrentIndentSize={mutateCurrentIndentSize}
-
-    />
-  );
-};
-
-export default PageEditorWrapper;

+ 435 - 0
packages/app/src/components/PageEditor.tsx

@@ -0,0 +1,435 @@
+import React, {
+  useCallback, useEffect, useMemo, useRef, useState,
+} from 'react';
+
+import EventEmitter from 'events';
+
+import { envUtils } from '@growi/core';
+import detectIndent from 'detect-indent';
+import { throttle, debounce } from 'throttle-debounce';
+
+import AppContainer from '~/client/services/AppContainer';
+import EditorContainer from '~/client/services/EditorContainer';
+import PageContainer from '~/client/services/PageContainer';
+import { apiGet, apiPost } from '~/client/util/apiv1-client';
+import { getOptionsToSave } from '~/client/util/editor';
+import { useIsEditable, useIsIndentSizeForced, useCurrentPagePath } from '~/stores/context';
+import {
+  useCurrentIndentSize, useSWRxSlackChannels, useIsSlackEnabled, useIsTextlintEnabled,
+} from '~/stores/editor';
+import {
+  EditorMode,
+  useEditorMode, useIsMobile, useSelectedGrant, useSelectedGrantGroupId, useSelectedGrantGroupName,
+} from '~/stores/ui';
+import loggerFactory from '~/utils/logger';
+
+
+import { ConflictDiffModal } from './PageEditor/ConflictDiffModal';
+import Editor from './PageEditor/Editor';
+import Preview from './PageEditor/Preview';
+import scrollSyncHelper from './PageEditor/ScrollSyncHelper';
+import { withUnstatedContainers } from './UnstatedUtils';
+
+
+// TODO: remove this when omitting unstated is completed
+
+const logger = loggerFactory('growi:PageEditor');
+
+
+declare let window: {
+  globalEmitter: EventEmitter,
+};
+
+type EditorRef = {
+  setValue: (markdown: string) => void,
+  setCaretLine: (line: number) => void,
+  insertText: (text: string) => void,
+  forceToFocus: () => void,
+  terminateUploadingState: () => void,
+}
+
+type Props = {
+  appContainer: AppContainer,
+  pageContainer: PageContainer,
+  editorContainer: EditorContainer,
+
+  isEditable: boolean,
+
+  editorMode: string,
+  isSlackEnabled: boolean,
+  slackChannels: string,
+  isMobile?: boolean,
+
+  grant: number,
+  grantGroupId?: string,
+  grantGroupName?: string,
+  mutateGrant: (grant: number) => void,
+
+  isTextlintEnabled?: boolean,
+  isIndentSizeForced?: boolean,
+  indentSize?: number,
+  mutateCurrentIndentSize: (indent: number) => void,
+};
+
+// for scrolling
+let lastScrolledDateWithCursor: Date | null = null;
+let isOriginOfScrollSyncEditor = false;
+let isOriginOfScrollSyncPreview = false;
+
+const PageEditor = (props: Props): JSX.Element => {
+  const {
+    appContainer, pageContainer, editorContainer,
+  } = props;
+
+  const { data: isEditable } = useIsEditable();
+  const { data: editorMode } = useEditorMode();
+  const { data: isMobile } = useIsMobile();
+  const { data: isSlackEnabled } = useIsSlackEnabled();
+  const { data: currentPagePath } = useCurrentPagePath();
+  const { data: slackChannelsData } = useSWRxSlackChannels(currentPagePath);
+  const { data: grant, mutate: mutateGrant } = useSelectedGrant();
+  const { data: grantGroupId } = useSelectedGrantGroupId();
+  const { data: grantGroupName } = useSelectedGrantGroupName();
+  const { data: isTextlintEnabled } = useIsTextlintEnabled();
+  const { data: isIndentSizeForced } = useIsIndentSizeForced();
+  const { data: indentSize, mutate: mutateCurrentIndentSize } = useCurrentIndentSize();
+
+  // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
+  const [markdown, setMarkdown] = useState<string>(pageContainer.state.markdown!);
+
+
+  const editorRef = useRef<EditorRef>(null);
+  const previewRef = useRef<HTMLDivElement>(null);
+
+  const setMarkdownWithDebounce = useMemo(() => debounce(50, throttle(100, value => setMarkdown(value))), []);
+  const saveDraftWithDebounce = useMemo(() => debounce(800, () => {
+    editorContainer.saveDraft(pageContainer.state.path, markdown);
+  }), [editorContainer, markdown, pageContainer.state.path]);
+
+  const markdownChangedHandler = useCallback((value: string): void => {
+    setMarkdownWithDebounce(value);
+    // only when the first time to edit
+    if (!pageContainer.state.revisionId) {
+      saveDraftWithDebounce();
+    }
+  }, [pageContainer.state.revisionId, saveDraftWithDebounce, setMarkdownWithDebounce]);
+
+
+  const saveWithShortcut = useCallback(async() => {
+    if (grant == null) {
+      return;
+    }
+
+    const slackChannels = slackChannelsData ? slackChannelsData.toString() : '';
+
+    const optionsToSave = getOptionsToSave(isSlackEnabled ?? false, slackChannels, grant, grantGroupId, grantGroupName, editorContainer);
+
+    try {
+      // disable unsaved warning
+      editorContainer.disableUnsavedWarning();
+
+      // eslint-disable-next-line no-unused-vars
+      const { tags } = await pageContainer.save(markdown, editorMode, optionsToSave);
+      logger.debug('success to save');
+
+      pageContainer.showSuccessToastr();
+
+      // update state of EditorContainer
+      editorContainer.setState({ tags });
+    }
+    catch (error) {
+      logger.error('failed to save', error);
+      pageContainer.showErrorToastr(error);
+    }
+  }, [editorContainer, editorMode, grant, grantGroupId, grantGroupName, isSlackEnabled, slackChannelsData, markdown, pageContainer]);
+
+
+  /**
+   * the upload event handler
+   * @param {any} file
+   */
+  const uploadHandler = useCallback(async(file) => {
+    if (editorRef.current == null) {
+      return;
+    }
+
+    try {
+      // eslint-disable-next-line @typescript-eslint/no-explicit-any
+      let res: any = await apiGet('/attachments.limit', {
+        fileSize: file.size,
+      });
+
+      if (!res.isUploadable) {
+        throw new Error(res.errorMessage);
+      }
+
+      const formData = new FormData();
+      const { pageId, path } = pageContainer.state;
+      // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
+      formData.append('_csrf', appContainer.csrfToken!);
+      formData.append('file', file);
+      if (path != null) {
+        formData.append('path', path);
+      }
+      if (pageId != null) {
+        formData.append('page_id', pageId);
+      }
+
+      res = await apiPost('/attachments.add', formData);
+      const attachment = res.attachment;
+      const fileName = attachment.originalName;
+
+      let insertText = `[${fileName}](${attachment.filePathProxied})`;
+      // when image
+      if (attachment.fileFormat.startsWith('image/')) {
+        // modify to "![fileName](url)" syntax
+        insertText = `!${insertText}`;
+      }
+      editorRef.current.insertText(insertText);
+
+      // when if created newly
+      if (res.pageCreated) {
+        logger.info('Page is created', res.page._id);
+        pageContainer.updateStateAfterSave(res.page, res.tags, res.revision, editorMode);
+        mutateGrant(res.page.grant);
+      }
+    }
+    catch (e) {
+      logger.error('failed to upload', e);
+      pageContainer.showErrorToastr(e);
+    }
+    finally {
+      editorRef.current.terminateUploadingState();
+    }
+  }, [appContainer.csrfToken, editorMode, mutateGrant, pageContainer]);
+
+
+  const scrollPreviewByEditorLine = useCallback((line: number) => {
+    if (previewRef.current == null) {
+      return;
+    }
+
+    // prevent circular invocation
+    if (isOriginOfScrollSyncPreview) {
+      isOriginOfScrollSyncPreview = false; // turn off the flag
+      return;
+    }
+
+    // turn on the flag
+    isOriginOfScrollSyncEditor = true;
+    scrollSyncHelper.scrollPreview(previewRef.current, line);
+  }, []);
+  const scrollPreviewByEditorLineWithThrottle = useMemo(() => throttle(20, scrollPreviewByEditorLine), [scrollPreviewByEditorLine]);
+
+  /**
+   * the scroll event handler from codemirror
+   * @param {any} data {left, top, width, height, clientWidth, clientHeight} object that represents the current scroll position,
+   *                    the size of the scrollable area, and the size of the visible area (minus scrollbars).
+   *                    And data.line is also available that is added by Editor component
+   * @see https://codemirror.net/doc/manual.html#events
+   */
+  const editorScrolledHandler = useCallback(({ line }: { line: number }) => {
+    // prevent scrolling
+    //   if the elapsed time from last scroll with cursor is shorter than 40ms
+    const now = new Date();
+    if (lastScrolledDateWithCursor != null && now.getTime() - lastScrolledDateWithCursor.getTime() < 40) {
+      return;
+    }
+
+    scrollPreviewByEditorLineWithThrottle(line);
+  }, [scrollPreviewByEditorLineWithThrottle]);
+
+  /**
+   * scroll Preview element by cursor moving
+   * @param {number} line
+   */
+  const scrollPreviewByCursorMoving = useCallback((line: number) => {
+    if (previewRef.current == null) {
+      return;
+    }
+
+    // prevent circular invocation
+    if (isOriginOfScrollSyncPreview) {
+      isOriginOfScrollSyncPreview = false; // turn off the flag
+      return;
+    }
+
+    // turn on the flag
+    isOriginOfScrollSyncEditor = true;
+    scrollSyncHelper.scrollPreviewToRevealOverflowing(previewRef.current, line);
+  }, []);
+  const scrollPreviewByCursorMovingWithThrottle = useMemo(() => throttle(20, scrollPreviewByCursorMoving), [scrollPreviewByCursorMoving]);
+
+  /**
+   * the scroll event handler from codemirror
+   * @param {number} line
+   * @see https://codemirror.net/doc/manual.html#events
+   */
+  const editorScrollCursorIntoViewHandler = useCallback((line: number) => {
+    // record date
+    lastScrolledDateWithCursor = new Date();
+    scrollPreviewByCursorMovingWithThrottle(line);
+  }, [scrollPreviewByCursorMovingWithThrottle]);
+
+  /**
+   * scroll Editor component by scroll event of Preview component
+   * @param {number} offset
+   */
+  const scrollEditorByPreviewScroll = useCallback((offset: number) => {
+    if (editorRef.current == null || previewRef.current == null) {
+      return;
+    }
+
+    // prevent circular invocation
+    if (isOriginOfScrollSyncEditor) {
+      isOriginOfScrollSyncEditor = false; // turn off the flag
+      return;
+    }
+
+    // turn on the flag
+    // eslint-disable-next-line @typescript-eslint/no-unused-vars
+    isOriginOfScrollSyncPreview = true;
+
+    scrollSyncHelper.scrollEditor(editorRef.current, previewRef.current, offset);
+  }, []);
+  const scrollEditorByPreviewScrollWithThrottle = useMemo(() => throttle(20, scrollEditorByPreviewScroll), [scrollEditorByPreviewScroll]);
+
+
+  // register dummy instance to get markdown
+  useEffect(() => {
+    const pageEditorInstance = {
+      getMarkdown: () => {
+        return markdown;
+      },
+    };
+    appContainer.registerComponentInstance('PageEditor', pageEditorInstance);
+  }, [appContainer, markdown]);
+
+  // initial caret line
+  useEffect(() => {
+    if (editorRef.current != null) {
+      editorRef.current.setCaretLine(0);
+    }
+  }, []);
+
+  // set handler to set caret line
+  useEffect(() => {
+    const handler = (line) => {
+      if (editorRef.current != null) {
+        editorRef.current.setCaretLine(line);
+      }
+      if (previewRef.current != null) {
+        scrollSyncHelper.scrollPreview(previewRef.current, line);
+      }
+    };
+    window.globalEmitter.on('setCaretLine', handler);
+
+    return function cleanup() {
+      window.globalEmitter.removeListener('setCaretLine', handler);
+    };
+  }, []);
+
+  // set handler to focus
+  useEffect(() => {
+    if (editorRef.current != null && editorMode === EditorMode.Editor) {
+      editorRef.current.forceToFocus();
+    }
+  }, [editorMode]);
+
+  // set handler to update editor value
+  useEffect(() => {
+    const handler = (markdown) => {
+      if (editorRef.current != null) {
+        editorRef.current.setValue(markdown);
+      }
+    };
+    window.globalEmitter.on('updateEditorValue', handler);
+
+    return function cleanup() {
+      window.globalEmitter.removeListener('updateEditorValue', handler);
+    };
+  }, []);
+
+  // Displays an alert if there is a difference with pageContainer's markdown
+  useEffect(() => {
+    // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
+    if (pageContainer.state.markdown! !== markdown) {
+      editorContainer.enableUnsavedWarning();
+    }
+  }, [editorContainer, markdown, pageContainer.state.markdown]);
+
+  // Detect indent size from contents (only when users are allowed to change it)
+  useEffect(() => {
+    const currentPageMarkdown = pageContainer.state.markdown;
+    if (!isIndentSizeForced && currentPageMarkdown != null) {
+      const detectedIndent = detectIndent(currentPageMarkdown);
+      if (detectedIndent.type === 'space' && new Set([2, 4]).has(detectedIndent.amount)) {
+        mutateCurrentIndentSize(detectedIndent.amount);
+      }
+    }
+  }, [isIndentSizeForced, mutateCurrentIndentSize, pageContainer.state.markdown]);
+
+
+  if (!isEditable) {
+    return <></>;
+  }
+
+  const config = props.appContainer.getConfig();
+  const isUploadable = config.upload.image || config.upload.file;
+  const isUploadableFile = config.upload.file;
+  const isMathJaxEnabled = !!config.env.MATHJAX;
+
+  const noCdn = envUtils.toBoolean(config.env.NO_CDN);
+
+  // TODO: omit no-explicit-any -- 2022.06.02 Yuki Takei
+  // It is impossible to avoid the error
+  //  "Property '...' does not exist on type 'IntrinsicAttributes & RefAttributes<any>'"
+  //  because Editor is a class component and must be wrapped with React.forwardRef
+  // eslint-disable-next-line @typescript-eslint/no-explicit-any
+  const EditorAny = Editor as any;
+
+  return (
+    <div className="d-flex flex-wrap">
+      <div className="page-editor-editor-container flex-grow-1 flex-basis-0 mw-0">
+        <EditorAny
+          ref={editorRef}
+          value={markdown}
+          noCdn={noCdn}
+          isMobile={isMobile}
+          isUploadable={isUploadable}
+          isUploadableFile={isUploadableFile}
+          isTextlintEnabled={isTextlintEnabled}
+          indentSize={indentSize}
+          onScroll={editorScrolledHandler}
+          onScrollCursorIntoView={editorScrollCursorIntoViewHandler}
+          onChange={markdownChangedHandler}
+          onUpload={uploadHandler}
+          onSave={() => saveWithShortcut()}
+        />
+      </div>
+      <div className="d-none d-lg-block page-editor-preview-container flex-grow-1 flex-basis-0 mw-0">
+        <Preview
+          markdown={markdown}
+          // eslint-disable-next-line no-return-assign
+          inputRef={previewRef}
+          isMathJaxEnabled={isMathJaxEnabled}
+          renderMathJaxOnInit={false}
+          onScroll={offset => scrollEditorByPreviewScrollWithThrottle(offset)}
+        />
+      </div>
+      <ConflictDiffModal
+        isOpen={pageContainer.state.isConflictDiffModalOpen}
+        onClose={() => pageContainer.setState({ isConflictDiffModalOpen: false })}
+        pageContainer={pageContainer}
+        markdownOnEdit={markdown}
+      />
+    </div>
+  );
+};
+
+/**
+   * Wrapper component for using unstated
+   */
+const PageEditorWrapper = withUnstatedContainers(PageEditor, [AppContainer, PageContainer, EditorContainer]);
+
+export default PageEditorWrapper;

+ 8 - 2
packages/app/src/components/PageEditor/Cheatsheet.jsx

@@ -1,8 +1,9 @@
 /* eslint-disable max-len */
 
 import React from 'react';
+
 import PropTypes from 'prop-types';
-import { withTranslation } from 'react-i18next';
+import { useTranslation } from 'react-i18next';
 
 class Cheatsheet extends React.Component {
 
@@ -103,4 +104,9 @@ Cheatsheet.propTypes = {
   t: PropTypes.func.isRequired, // i18next
 };
 
-export default withTranslation()(Cheatsheet);
+const CheatsheetWrapperFC = (props) => {
+  const { t } = useTranslation();
+  return <Cheatsheet t={t} {...props} />;
+};
+
+export default CheatsheetWrapperFC;

+ 1 - 1
packages/app/src/components/PageEditor/CodeMirrorEditor.jsx

@@ -514,7 +514,7 @@ class CodeMirrorEditor extends AbstractEditor {
     const context = {
       handlers: [], // list of handlers which process enter key
       editor: this,
-      editorSettings: this.props.editorSettings,
+      autoFormatMarkdownTable: this.props.editorSettings.autoFormatMarkdownTable,
     };
 
     const interceptorManager = this.interceptorManager;

+ 5 - 5
packages/app/src/components/PageEditor/CommentMentionHelper.ts

@@ -48,12 +48,12 @@ export default class CommentMentionHelper {
     });
   }
 
-  getUsersList = async(username) => {
+  getUsersList = async(q: string) => {
     const limit = 20;
-    const { data } = await apiv3Get('/users/list', { username, limit });
-    return data.users.map(user => ({
-      text: `@${user.username} `,
-      displayText: user.username,
+    const { data } = await apiv3Get('/users/usernames', { q, limit });
+    return data.activeUser.usernames.map(username => ({
+      text: `@${username} `,
+      displayText: username,
     }));
   }
 

+ 42 - 49
packages/app/src/components/PageEditor/ConflictDiffModal.tsx

@@ -1,22 +1,22 @@
 import React, {
-  useState, useEffect, FC, useRef,
+  useState, useEffect, useRef, useMemo, useCallback,
 } from 'react';
-import PropTypes from 'prop-types';
+
 import { UserPicture } from '@growi/ui';
+import CodeMirror from 'codemirror/lib/codemirror';
+import { format } from 'date-fns';
+import { useTranslation } from 'react-i18next';
 import {
   Modal, ModalHeader, ModalBody, ModalFooter,
 } from 'reactstrap';
-import { useTranslation } from 'react-i18next';
-import { format } from 'date-fns';
-import CodeMirror from 'codemirror/lib/codemirror';
-
-import PageContainer from '../../client/services/PageContainer';
-import AppContainer from '../../client/services/AppContainer';
-import ExpandOrContractButton from '../ExpandOrContractButton';
 
+import { IUser } from '~/interfaces/user';
+import { useCurrentUser } from '~/stores/context';
 import { useEditorMode } from '~/stores/ui';
 
+import PageContainer from '../../client/services/PageContainer';
 import { IRevisionOnConflict } from '../../interfaces/revision';
+import ExpandOrContractButton from '../ExpandOrContractButton';
 import { UncontrolledCodeMirror } from '../UncontrolledCodeMirror';
 
 require('codemirror/lib/codemirror.css');
@@ -27,10 +27,9 @@ const DMP = require('diff_match_patch');
 Object.keys(DMP).forEach((key) => { window[key] = DMP[key] });
 
 type ConflictDiffModalProps = {
-  isOpen: boolean | null;
+  isOpen?: boolean;
   onClose?: (() => void);
   pageContainer: PageContainer;
-  appContainer: AppContainer;
   markdownOnEdit: string;
 };
 
@@ -38,26 +37,26 @@ type IRevisionOnConflictWithStringDate = Omit<IRevisionOnConflict, 'createdAt'>
   createdAt: string
 }
 
-export const ConflictDiffModal: FC<ConflictDiffModalProps> = (props) => {
+const ConflictDiffModalCore = (props: ConflictDiffModalProps & { currentUser: IUser }): JSX.Element => {
+  const { currentUser, pageContainer, onClose } = props;
+
+  const { data: editorMode } = useEditorMode();
+
   const { t } = useTranslation('');
   const [resolvedRevision, setResolvedRevision] = useState<string>('');
   const [isRevisionselected, setIsRevisionSelected] = useState<boolean>(false);
   const [isModalExpanded, setIsModalExpanded] = useState<boolean>(false);
   const [codeMirrorRef, setCodeMirrorRef] = useState<HTMLDivElement | null>(null);
 
-  const { data: editorMode } = useEditorMode();
-
   const uncontrolledRef = useRef<CodeMirror>(null);
 
-  const { pageContainer, appContainer } = props;
-
   const currentTime: Date = new Date();
 
   const request: IRevisionOnConflictWithStringDate = {
     revisionId: '',
     revisionBody: props.markdownOnEdit,
     createdAt: format(currentTime, 'yyyy/MM/dd HH:mm:ss'),
-    user: appContainer.currentUser,
+    user: currentUser,
   };
   const origin: IRevisionOnConflictWithStringDate = {
     revisionId: pageContainer.state.revisionId || '',
@@ -89,13 +88,13 @@ export const ConflictDiffModal: FC<ConflictDiffModalProps> = (props) => {
     }
   }, [codeMirrorRef, origin.revisionBody, request.revisionBody, latest.revisionBody]);
 
-  const onClose = () => {
-    if (props.onClose != null) {
-      props.onClose();
+  const close = useCallback(() => {
+    if (onClose != null) {
+      onClose();
     }
-  };
+  }, [onClose]);
 
-  const onResolveConflict = async() : Promise<void> => {
+  const onResolveConflict = useCallback(async() => {
     // disable button after clicked
     setIsRevisionSelected(false);
 
@@ -103,40 +102,34 @@ export const ConflictDiffModal: FC<ConflictDiffModalProps> = (props) => {
 
     try {
       await pageContainer.resolveConflict(codeMirrorVal, editorMode);
-      onClose();
+      close();
       pageContainer.showSuccessToastr();
     }
     catch (error) {
       pageContainer.showErrorToastr(error);
     }
 
-  };
-
-  const onExpandModal = () => {
-    setIsModalExpanded(true);
-  };
+  }, [editorMode, close, pageContainer]);
 
-  const onContractModal = () => {
-    setIsModalExpanded(false);
-  };
-
-  const resizeAndCloseButtons = (
+  const resizeAndCloseButtons = useMemo(() => (
     <div className="d-flex flex-nowrap">
       <ExpandOrContractButton
         isWindowExpanded={isModalExpanded}
-        expandWindow={onExpandModal}
-        contractWindow={onContractModal}
+        expandWindow={() => setIsModalExpanded(true)}
+        contractWindow={() => setIsModalExpanded(false)}
       />
-      <button type="button" className="close text-white" onClick={onClose} aria-label="Close">
+      <button type="button" className="close text-white" onClick={close} aria-label="Close">
         <span aria-hidden="true">&times;</span>
       </button>
     </div>
-  );
+  ), [isModalExpanded, close]);
+
+  const isOpen = props.isOpen ?? false;
 
   return (
     <Modal
-      isOpen={props.isOpen || false}
-      toggle={onClose}
+      isOpen={isOpen}
+      toggle={close}
       backdrop="static"
       className={`${isModalExpanded ? ' grw-modal-expanded' : ''}`}
       size="xl"
@@ -145,7 +138,7 @@ export const ConflictDiffModal: FC<ConflictDiffModalProps> = (props) => {
         <i className="icon-fw icon-exclamation" />{t('modal_resolve_conflict.resolve_conflict')}
       </ModalHeader>
       <ModalBody className="mx-4 my-1">
-        { props.isOpen
+        { isOpen
         && (
           <div className="row">
             <div className="col-12 text-center mt-2 mb-4">
@@ -269,14 +262,14 @@ export const ConflictDiffModal: FC<ConflictDiffModalProps> = (props) => {
   );
 };
 
-ConflictDiffModal.propTypes = {
-  isOpen: PropTypes.bool,
-  onClose: PropTypes.func,
-  pageContainer: PropTypes.instanceOf(PageContainer).isRequired,
-  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
-  markdownOnEdit: PropTypes.string.isRequired,
-};
 
-ConflictDiffModal.defaultProps = {
-  isOpen: false,
+export const ConflictDiffModal = (props: ConflictDiffModalProps): JSX.Element => {
+  const { isOpen } = props;
+  const { data: currentUser } = useCurrentUser();
+
+  if (!isOpen || currentUser == null) {
+    return <></>;
+  }
+
+  return <ConflictDiffModalCore {...props} currentUser={currentUser} />;
 };

Энэ ялгаанд хэт олон файл өөрчлөгдсөн тул зарим файлыг харуулаагүй болно