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

Merge branch 'dev/5.0.x' into feat/77525-enable-to-delete-pages

# Conflicts:
#	packages/app/resource/locales/en_US/translation.json
#	packages/app/resource/locales/ja_JP/translation.json
#	packages/app/resource/locales/zh_CN/translation.json
#	packages/app/src/components/PageDeleteModal.tsx
#	packages/app/src/components/SearchPage.jsx
#	packages/app/src/components/SearchPage/SearchControl.tsx
Yohei-Shiina пре 4 година
родитељ
комит
e3ce7494e6
100 измењених фајлова са 2826 додато и 1625 уклоњено
  1. 11 1
      CHANGELOG.md
  2. 1 1
      lerna.json
  3. 1 1
      package.json
  4. 0 2
      packages/app/config/webpack.dev.dll.js
  5. 2 2
      packages/app/docker/README.md
  6. 14 14
      packages/app/package.json
  7. 17 5
      packages/app/resource/locales/en_US/admin/admin.json
  8. 3 0
      packages/app/resource/locales/en_US/translation.json
  9. 17 5
      packages/app/resource/locales/ja_JP/admin/admin.json
  10. 4 1
      packages/app/resource/locales/ja_JP/translation.json
  11. 17 5
      packages/app/resource/locales/zh_CN/admin/admin.json
  12. 3 0
      packages/app/resource/locales/zh_CN/translation.json
  13. 14 0
      packages/app/resource/search/mappings.json
  14. 35 18
      packages/app/src/client/app.jsx
  15. 31 31
      packages/app/src/client/legacy/crowi.js
  16. 19 0
      packages/app/src/client/services/AdminAppContainer.js
  17. 2 0
      packages/app/src/client/services/AdminHomeContainer.js
  18. 124 0
      packages/app/src/client/services/ContextExtractor.tsx
  19. 55 137
      packages/app/src/client/services/NavigationContainer.js
  20. 6 21
      packages/app/src/client/services/PageContainer.js
  21. 28 0
      packages/app/src/client/services/user-ui-settings.ts
  22. 1 1
      packages/app/src/client/util/apiv3-client.ts
  23. 18 0
      packages/app/src/components/Admin/AdminHome/AdminHome.jsx
  24. 25 2
      packages/app/src/components/Admin/App/AppSettingsPageContents.jsx
  25. 57 0
      packages/app/src/components/Admin/App/V5PageMigration.tsx
  26. 61 0
      packages/app/src/components/Admin/App/V5PageMigrationModal.tsx
  27. 4 2
      packages/app/src/components/Admin/ElasticsearchManagement/ElasticsearchManagement.jsx
  28. 2 1
      packages/app/src/components/Admin/SlackIntegration/CustomBotWithProxySettings.jsx
  29. 2 0
      packages/app/src/components/Admin/SlackIntegration/CustomBotWithoutProxySettings.jsx
  30. 5 4
      packages/app/src/components/Admin/SlackIntegration/CustomBotWithoutProxySettingsAccordion.jsx
  31. 238 122
      packages/app/src/components/Admin/SlackIntegration/ManageCommandsProcess.jsx
  32. 56 23
      packages/app/src/components/Admin/SlackIntegration/ManageCommandsProcessWithoutProxy.jsx
  33. 11 1
      packages/app/src/components/Admin/SlackIntegration/SlackIntegration.jsx
  34. 5 2
      packages/app/src/components/Admin/SlackIntegration/WithProxyAccordions.jsx
  35. 12 23
      packages/app/src/components/BookmarkButton.jsx
  36. 3 11
      packages/app/src/components/ComparePathsTable.jsx
  37. 3 13
      packages/app/src/components/CreateTemplateModal.jsx
  38. 5 1
      packages/app/src/components/Fab.jsx
  39. 9 11
      packages/app/src/components/Hotkeys/Subscribers/CreatePage.jsx
  40. 4 2
      packages/app/src/components/Hotkeys/Subscribers/EditPage.jsx
  41. 3 3
      packages/app/src/components/Icons/GrowiLogo.jsx
  42. 0 102
      packages/app/src/components/LikeButtons.jsx
  43. 81 0
      packages/app/src/components/LikeButtons.tsx
  44. 0 46
      packages/app/src/components/Navbar/DrawerToggler.jsx
  45. 28 0
      packages/app/src/components/Navbar/DrawerToggler.tsx
  46. 0 115
      packages/app/src/components/Navbar/GrowiNavbar.jsx
  47. 128 0
      packages/app/src/components/Navbar/GrowiNavbar.tsx
  48. 8 4
      packages/app/src/components/Navbar/GrowiNavbarBottom.jsx
  49. 68 70
      packages/app/src/components/Navbar/GrowiSubNavigation.jsx
  50. 17 15
      packages/app/src/components/Navbar/PageEditorModeManager.jsx
  51. 21 20
      packages/app/src/components/Navbar/PersonalDropdown.jsx
  52. 0 80
      packages/app/src/components/Navbar/SubNavButtons.jsx
  53. 119 0
      packages/app/src/components/Navbar/SubNavButtons.tsx
  54. 13 9
      packages/app/src/components/Page/DisplaySwitcher.jsx
  55. 13 13
      packages/app/src/components/Page/NotFoundAlert.jsx
  56. 19 13
      packages/app/src/components/Page/PageManagement.jsx
  57. 7 4
      packages/app/src/components/Page/RevisionLoader.jsx
  58. 18 5
      packages/app/src/components/Page/RevisionRenderer.jsx
  59. 9 48
      packages/app/src/components/Page/TagLabels.jsx
  60. 1 0
      packages/app/src/components/PageContentFooter.jsx
  61. 8 7
      packages/app/src/components/PageCreateModal.jsx
  62. 7 1
      packages/app/src/components/PageDeleteModal.tsx
  63. 1 1
      packages/app/src/components/PageDuplicateModal.jsx
  64. 15 9
      packages/app/src/components/PageEditor/EditorNavbarBottom.jsx
  65. 2 1
      packages/app/src/components/PageEditor/LinkEditModal.jsx
  66. 56 0
      packages/app/src/components/PagePathNav.tsx
  67. 41 0
      packages/app/src/components/PageReactionButtons.tsx
  68. 1 1
      packages/app/src/components/PageRenameModal.jsx
  69. 17 13
      packages/app/src/components/SearchPage.jsx
  70. 62 16
      packages/app/src/components/SearchPage/SearchControl.tsx
  71. 83 0
      packages/app/src/components/SearchPage/SearchOptionModal.tsx
  72. 15 18
      packages/app/src/components/SearchPage/SearchResultContent.tsx
  73. 91 0
      packages/app/src/components/SearchPage/SearchResultContentSubNavigation.tsx
  74. 10 3
      packages/app/src/components/SearchPage/SearchResultListItem.tsx
  75. 0 242
      packages/app/src/components/Sidebar.jsx
  76. 341 0
      packages/app/src/components/Sidebar.tsx
  77. 17 39
      packages/app/src/components/Sidebar/CustomSidebar.tsx
  78. 20 0
      packages/app/src/components/Sidebar/NavigationResizeHexagon.tsx
  79. 36 0
      packages/app/src/components/Sidebar/PageTree.tsx
  80. 144 0
      packages/app/src/components/Sidebar/PageTree/Item.tsx
  81. 18 0
      packages/app/src/components/Sidebar/PageTree/ItemNode.ts
  82. 95 0
      packages/app/src/components/Sidebar/PageTree/ItemsTree.tsx
  83. 16 0
      packages/app/src/components/Sidebar/PageTree/PrivateLegacyPages.tsx
  84. 6 100
      packages/app/src/components/Sidebar/RecentChanges.tsx
  85. 0 49
      packages/app/src/components/Sidebar/SidebarContents.jsx
  86. 33 0
      packages/app/src/components/Sidebar/SidebarContents.tsx
  87. 0 94
      packages/app/src/components/Sidebar/SidebarNav.jsx
  88. 99 0
      packages/app/src/components/Sidebar/SidebarNav.tsx
  89. 6 9
      packages/app/src/components/StickyStretchableScroller.jsx
  90. 4 0
      packages/app/src/interfaces/bookmark-info.ts
  91. 7 0
      packages/app/src/interfaces/common.ts
  92. 3 0
      packages/app/src/interfaces/has-object-id.ts
  93. 13 0
      packages/app/src/interfaces/named-query.ts
  94. 8 0
      packages/app/src/interfaces/page-info.ts
  95. 23 0
      packages/app/src/interfaces/page-listing-results.ts
  96. 26 11
      packages/app/src/interfaces/page.ts
  97. 3 0
      packages/app/src/interfaces/pageTagsInfo.ts
  98. 1 1
      packages/app/src/interfaces/search.ts
  99. 7 0
      packages/app/src/interfaces/ui.ts
  100. 13 0
      packages/app/src/interfaces/user-ui-settings.ts

+ 11 - 1
CHANGELOG.md

@@ -1,9 +1,19 @@
 # Changelog
 # Changelog
 
 
-## [Unreleased](https://github.com/weseek/growi/compare/v4.4.12...HEAD)
+## [Unreleased](https://github.com/weseek/growi/compare/v4.4.13...HEAD)
 
 
 *Please do not manually update this file. We've automated the process.*
 *Please do not manually update this file. We've automated the process.*
 
 
+## [v4.4.13](https://github.com/weseek/growi/compare/v4.4.12...v4.4.13) - 2021-11-19
+
+### 💎 Features
+
+- feat: Including comments in full text search (#4703) @kaoritokashiki
+
+### 🐛 Bug Fixes
+
+- fix(slackbot): Interactions from private channels not working (#4688) @stevenfukase
+
 ## [v4.4.12](https://github.com/weseek/growi/compare/v4.4.11...v4.4.12) - 2021-11-15
 ## [v4.4.12](https://github.com/weseek/growi/compare/v4.4.11...v4.4.12) - 2021-11-15
 
 
 ### 🐛 Bug Fixes
 ### 🐛 Bug Fixes

+ 1 - 1
lerna.json

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

+ 1 - 1
package.json

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

+ 0 - 2
packages/app/config/webpack.dev.dll.js

@@ -10,8 +10,6 @@ module.exports = {
   entry: {
   entry: {
     dlls: [
     dlls: [
       // Libraries
       // Libraries
-      '@atlaskit/drawer',
-      '@atlaskit/navigation-next',
       'axios',
       'axios',
       'browser-bunyan', 'bunyan-format',
       'browser-bunyan', 'bunyan-format',
       'codemirror', 'react-codemirror2',
       'codemirror', 'react-codemirror2',

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

@@ -10,8 +10,8 @@ GROWI Official docker image
 Supported tags and respective Dockerfile links
 Supported tags and respective Dockerfile links
 ------------------------------------------------
 ------------------------------------------------
 
 
-* [`4.4.12`, `4.4`, `4`, `latest` (Dockerfile)](https://github.com/weseek/growi/blob/v4.4.12/docker/Dockerfile)
-* [`4.4.12-nocdn`, `4.4-nocdn`, `4-nocdn`, `latest-nocdn` (Dockerfile)](https://github.com/weseek/growi/blob/v4.4.12/docker/Dockerfile)
+* [`4.4.13`, `4.4`, `4`, `latest` (Dockerfile)](https://github.com/weseek/growi/blob/v4.4.13/docker/Dockerfile)
+* [`4.4.13-nocdn`, `4.4-nocdn`, `4-nocdn`, `latest-nocdn` (Dockerfile)](https://github.com/weseek/growi/blob/v4.4.13/docker/Dockerfile)
 * [`4.3.3`, `4.3` (Dockerfile)](https://github.com/weseek/growi/blob/v4.3.3/docker/Dockerfile)
 * [`4.3.3`, `4.3` (Dockerfile)](https://github.com/weseek/growi/blob/v4.3.3/docker/Dockerfile)
 * [`4.3.3-nocdn`, `4.3-nocdn` (Dockerfile)](https://github.com/weseek/growi/blob/v4.3.3/docker/Dockerfile)
 * [`4.3.3-nocdn`, `4.3-nocdn` (Dockerfile)](https://github.com/weseek/growi/blob/v4.3.3/docker/Dockerfile)
 
 

+ 14 - 14
packages/app/package.json

@@ -1,6 +1,6 @@
 {
 {
   "name": "@growi/app",
   "name": "@growi/app",
-  "version": "4.4.13-RC.0",
+  "version": "4.4.14-RC.0",
   "license": "MIT",
   "license": "MIT",
   "scripts": {
   "scripts": {
     "//// for production": "",
     "//// for production": "",
@@ -58,11 +58,11 @@
     "@browser-bunyan/console-formatted-stream": "^1.6.2",
     "@browser-bunyan/console-formatted-stream": "^1.6.2",
     "@godaddy/terminus": "^4.9.0",
     "@godaddy/terminus": "^4.9.0",
     "@google-cloud/storage": "^5.8.5",
     "@google-cloud/storage": "^5.8.5",
-    "@growi/codemirror-textlint": "^4.4.13-RC.0",
-    "@growi/plugin-attachment-refs": "^4.4.13-RC.0",
-    "@growi/plugin-lsx": "^4.4.13-RC.0",
-    "@growi/plugin-pukiwiki-like-linker": "^4.4.13-RC.0",
-    "@growi/slack": "^4.4.13-RC.0",
+    "@growi/codemirror-textlint": "^4.4.14-RC.0",
+    "@growi/plugin-attachment-refs": "^4.4.14-RC.0",
+    "@growi/plugin-lsx": "^4.4.14-RC.0",
+    "@growi/plugin-pukiwiki-like-linker": "^4.4.14-RC.0",
+    "@growi/slack": "^4.4.14-RC.0",
     "@promster/express": "^5.1.0",
     "@promster/express": "^5.1.0",
     "@promster/server": "^6.0.3",
     "@promster/server": "^6.0.3",
     "@slack/events-api": "^3.0.0",
     "@slack/events-api": "^3.0.0",
@@ -79,7 +79,7 @@
     "bunyan": "^1.8.15",
     "bunyan": "^1.8.15",
     "check-node-version": "^4.1.0",
     "check-node-version": "^4.1.0",
     "connect-flash": "~0.1.1",
     "connect-flash": "~0.1.1",
-    "connect-mongo": "^4.4.1",
+    "connect-mongo": "^4.6.0",
     "connect-redis": "^4.0.4",
     "connect-redis": "^4.0.4",
     "cookie-parser": "^1.4.5",
     "cookie-parser": "^1.4.5",
     "csrf": "^3.1.0",
     "csrf": "^3.1.0",
@@ -110,9 +110,9 @@
     "lucene-query-parser": "^1.2.0",
     "lucene-query-parser": "^1.2.0",
     "md5": "^2.2.1",
     "md5": "^2.2.1",
     "method-override": "^3.0.0",
     "method-override": "^3.0.0",
-    "migrate-mongo": "^8.2.2",
+    "migrate-mongo": "^8.2.3",
     "mkdirp": "^1.0.3",
     "mkdirp": "^1.0.3",
-    "mongoose": "=5.13.12",
+    "mongoose": "^6.0.13",
     "mongoose-gridfs": "^1.2.42",
     "mongoose-gridfs": "^1.2.42",
     "mongoose-paginate-v2": "^1.3.9",
     "mongoose-paginate-v2": "^1.3.9",
     "mongoose-unique-validator": "^2.0.3",
     "mongoose-unique-validator": "^2.0.3",
@@ -122,15 +122,16 @@
     "nodemailer": "^6.6.2",
     "nodemailer": "^6.6.2",
     "nodemailer-ses-transport": "~1.5.0",
     "nodemailer-ses-transport": "~1.5.0",
     "openid-client": "=2.5.0",
     "openid-client": "=2.5.0",
-    "passport": "^0.4.0",
+    "passport": "^0.5.0",
     "passport-github": "^1.1.0",
     "passport-github": "^1.1.0",
     "passport-google-oauth20": "^2.0.0",
     "passport-google-oauth20": "^2.0.0",
     "passport-http": "^0.3.0",
     "passport-http": "^0.3.0",
     "passport-ldapauth": "^3.0.1",
     "passport-ldapauth": "^3.0.1",
     "passport-local": "^1.0.0",
     "passport-local": "^1.0.0",
-    "passport-saml": "^2.2.0",
+    "passport-saml": "^3.2.0",
     "passport-twitter": "^1.0.4",
     "passport-twitter": "^1.0.4",
     "prom-client": "^13.0.0",
     "prom-client": "^13.0.0",
+    "re2": "^1.16.0",
     "react-card-flip": "^1.0.10",
     "react-card-flip": "^1.0.10",
     "react-image-crop": "^8.3.0",
     "react-image-crop": "^8.3.0",
     "react-multiline-clamp": "^2.0.0",
     "react-multiline-clamp": "^2.0.0",
@@ -157,12 +158,11 @@
   },
   },
   "devDependencies": {
   "devDependencies": {
     "@alienfast/i18next-loader": "^1.0.16",
     "@alienfast/i18next-loader": "^1.0.16",
-    "@atlaskit/drawer": "^5.3.7",
-    "@atlaskit/navigation-next": "^8.0.5",
-    "@growi/ui": "^4.4.13-RC.0",
+    "@growi/ui": "^4.4.14-RC.0",
     "@handsontable/react": "=2.1.0",
     "@handsontable/react": "=2.1.0",
     "@types/compression": "^1.7.0",
     "@types/compression": "^1.7.0",
     "@types/express": "^4.17.11",
     "@types/express": "^4.17.11",
+    "@types/jquery": "^3.5.8",
     "@types/multer": "^1.4.5",
     "@types/multer": "^1.4.5",
     "@types/react-dom": "^17.0.9",
     "@types/react-dom": "^17.0.9",
     "autoprefixer": "^9.0.0",
     "autoprefixer": "^9.0.0",

+ 17 - 5
packages/app/resource/locales/en_US/admin/admin.json

@@ -19,6 +19,15 @@
     "bug_report": "Submitting a bug report",
     "bug_report": "Submitting a bug report",
     "submit_bug_report": "<a href='https://github.com/weseek/growi/issues/new?assignees=&labels=bug&template=bug-report.md&title=Bug%3A' target='_blank' rel='noreferrer'>then submit your issue to GitHub.</a>"
     "submit_bug_report": "<a href='https://github.com/weseek/growi/issues/new?assignees=&labels=bug&template=bug-report.md&title=Bug%3A' target='_blank' rel='noreferrer'>then submit your issue to GitHub.</a>"
   },
   },
+  "v5_page_migration": {
+    "migration_desc": "Some of the public pages have the old schema. To take advantage of new features such as page trees and easy renaming, please upgrade the schema of all your pages.",
+    "migration_note": "Note: You will lose unique constraints from the page paths.",
+    "upgrade_to_v5": "Upgrade to V5",
+    "modal_migration_warning": "This process may take long. It is highly recommended that administrators tell users not to create, modify, or delete pages during migration.",
+    "start_upgrading": "Start upgrading",
+    "successfully_started": "Succeeded to start migration",
+    "already_upgraded": "You have already completed upgrading"
+  },
   "app_setting": {
   "app_setting": {
     "site_name": "Site name",
     "site_name": "Site name",
     "sitename_change": "You can change site name which is used for header and HTML title.",
     "sitename_change": "You can change site name which is used for header and HTML title.",
@@ -339,16 +348,19 @@
       "install_complete_if_checked": "Confirm that \"Install your app\" is checked.",
       "install_complete_if_checked": "Confirm that \"Install your app\" is checked.",
       "invite_bot_to_channel": "Invite GROWI bot to channel by calling @example.",
       "invite_bot_to_channel": "Invite GROWI bot to channel by calling @example.",
       "register_secret_and_token": "Set Signing Secret and Bot Token",
       "register_secret_and_token": "Set Signing Secret and Bot Token",
-      "manage_commands": "Manage GROWI commands",
+      "manage_permission": "Manage Permission",
+      "growi_commands": "GROWI Commands",
       "multiple_growi_command": "Commands that could be sent to multiple GROWI instances at once",
       "multiple_growi_command": "Commands that could be sent to multiple GROWI instances at once",
       "single_growi_command": "Commands that could be sent to single GROWI instance at a time",
       "single_growi_command": "Commands that could be sent to single GROWI instance at a time",
-      "allowed_channels_description": "Input allowed channels for \"{{commandName}}\" command. Separate each channel with \",\" . Users can will be able to use \"{{commandName}}\" command from channels written here.",
+      "allowed_channels_description": "Input allowed channels for \"{{keyName}}\" command. Separate each channel with \",\" . Users can will be able to use \"{{keyName}}\" command from channels written here.",
+      "unfurl_description": "Show GROWI page contents when page links have been shared on Slack",
+      "unfurl_allowed_channels_description": "Input allowed channel IDs for \"unfurl\" . Separate each channel with \",\" . GROWI public page links or permanent links sent in specified channels will show the content in the message.",
       "allow_all": "Allow all",
       "allow_all": "Allow all",
       "deny_all": "Deny all",
       "deny_all": "Deny all",
       "allow_specified": "Allow specified",
       "allow_specified": "Allow specified",
-      "allow_all_long": "Allow all (The command is allowed from any channel)",
-      "deny_all_long": "Deny all (The command is denied from any channel)",
-      "allow_specified_long": "Allow specified (The command is allowed from only specified channels)",
+      "allow_all_long": "Allow all (Allowed from any channel)",
+      "deny_all_long": "Deny all (Denied from any channel)",
+      "allow_specified_long": "Allow specified (Allowed from only specified channels)",
       "test_connection": "Test Connection",
       "test_connection": "Test Connection",
       "test_connection_by_pressing_button": "Press the button to test the connection",
       "test_connection_by_pressing_button": "Press the button to test the connection",
       "test_connection_only_public_channel":"Please test connection in a public channel",
       "test_connection_only_public_channel":"Please test connection in a public channel",

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

@@ -107,6 +107,7 @@
   "Create under": "Create page under below:",
   "Create under": "Create page under below:",
   "Wiki Management Home Page": "Wiki Management Home Page",
   "Wiki Management Home Page": "Wiki Management Home Page",
   "App Settings": "App Settings",
   "App Settings": "App Settings",
+  "V5 Page Migration": "V5 Page Migration",
   "Site URL settings": "Site URL settings",
   "Site URL settings": "Site URL settings",
   "Markdown Settings": "Markdown Settings",
   "Markdown Settings": "Markdown Settings",
   "Customize": "Customize",
   "Customize": "Customize",
@@ -151,6 +152,7 @@
   "Add to bookmark": "Add to bookmark",
   "Add to bookmark": "Add to bookmark",
   "Recent Created": "Recent Created",
   "Recent Created": "Recent Created",
   "Recent Changes": "Recent Changes",
   "Recent Changes": "Recent Changes",
+  "Page Tree": "Page Tree",
   "original_path":"Original path",
   "original_path":"Original path",
   "new_path":"New path",
   "new_path":"New path",
   "duplicated_path":"duplicated_path",
   "duplicated_path":"duplicated_path",
@@ -578,6 +580,7 @@
     "include_certain_path" : "Include {{pathToInclude}} path ",
     "include_certain_path" : "Include {{pathToInclude}} path ",
     "delete_all_selected_page" : "Delete All",
     "delete_all_selected_page" : "Delete All",
     "currently_not_implemented":"This is not currently implemented",
     "currently_not_implemented":"This is not currently implemented",
+    "search_again" : "Search again",
     "number_of_list_to_display" : "Display",
     "number_of_list_to_display" : "Display",
     "page_number_unit" : "pages"
     "page_number_unit" : "pages"
 
 

+ 17 - 5
packages/app/resource/locales/ja_JP/admin/admin.json

@@ -19,6 +19,15 @@
     "bug_report": "バグを報告する",
     "bug_report": "バグを報告する",
     "submit_bug_report": "<a href='https://github.com/weseek/growi/issues/new?assignees=&labels=bug&template=bug-report.md&title=Bug%3A' target='_blank' rel='noreferrer'>次に GitHub で Issue を投稿してください。</a>"
     "submit_bug_report": "<a href='https://github.com/weseek/growi/issues/new?assignees=&labels=bug&template=bug-report.md&title=Bug%3A' target='_blank' rel='noreferrer'>次に GitHub で Issue を投稿してください。</a>"
   },
   },
+  "v5_page_migration": {
+    "migration_desc": "公開されているページに古いスキーマのものが存在します。ページツリーや簡単なリネームなどの新機能を利用するには、全てのページのスキーマをアップグレードしてください。",
+    "migration_note": "注意: ページパスからユニーク制約が失われます。",
+    "upgrade_to_v5": "V5 にアップグレード",
+    "modal_migration_warning": "管理者はユーザーに、マイグレーション中はページを作成・変更・削除しないように伝えることを強くお勧めします。",
+    "start_upgrading": "アップグレードを開始",
+    "successfully_started": "正常にマイグレーションが開始されました",
+    "already_upgraded": "アップグレードは既に完了しています"
+  },
   "app_setting": {
   "app_setting": {
     "site_name": "サイト名",
     "site_name": "サイト名",
     "sitename_change": "ヘッダーや HTML タイトルに使用されるサイト名を変更できます。",
     "sitename_change": "ヘッダーや HTML タイトルに使用されるサイト名を変更できます。",
@@ -338,16 +347,19 @@
       "install_complete_if_checked": "Install your app の右側に緑色のチェックがつけばワークスペースへのインストール完了です。",
       "install_complete_if_checked": "Install your app の右側に緑色のチェックがつけばワークスペースへのインストール完了です。",
       "invite_bot_to_channel": "GROWI bot を使いたいチャンネルに @example を使用して招待します。",
       "invite_bot_to_channel": "GROWI bot を使いたいチャンネルに @example を使用して招待します。",
       "register_secret_and_token": "Signing Secret と Bot Token を登録する",
       "register_secret_and_token": "Signing Secret と Bot Token を登録する",
-      "manage_commands": "使用可能なGROWIコマンドを設定する",
+      "manage_permission": "権限を設定する",
+      "growi_commands": "GROWI コマンド",
       "multiple_growi_command": "複数のGROWIに対して送信できるコマンド",
       "multiple_growi_command": "複数のGROWIに対して送信できるコマンド",
       "single_growi_command": "一つのGROWIに対して送信できるコマンド",
       "single_growi_command": "一つのGROWIに対して送信できるコマンド",
-      "allowed_channels_description": "\"{{commandName}}\" コマンドの使用を許可するチャンネルを \",\" 区切りで入力してください。ユーザーはここに記入されているチャンネルから \"{{commandName}}\" コマンドを使用することができます。",
+      "allowed_channels_description": "\"{{keyName}}\" コマンドの使用を許可するチャンネルを \",\" 区切りで入力してください。ユーザーはここに記入されているチャンネルから \"{{keyName}}\" コマンドを使用することができます。",
+      "unfurl_description": "Slack で GROWI のリンクを共有したときにページの内容を表示する",
+      "unfurl_allowed_channels_description": "\"unfurl\" の使用を許可するチャンネルの ID を \",\" 区切りで入力してください。ここに記入されているチャンネルで GROWI の ページリンクを共有するとページの内容が表示されます。",
       "allow_all": "全てのチャンネルを許可",
       "allow_all": "全てのチャンネルを許可",
       "deny_all": "全てのチャンネルを拒否",
       "deny_all": "全てのチャンネルを拒否",
       "allow_specified": "特定のチャンネルを許可",
       "allow_specified": "特定のチャンネルを許可",
-      "allow_all-long": "全て許可 (このコマンドは全てのチャンネルから使用することができます)",
-      "deny_all-long": "全て拒否 (このコマンドはどのチャンネルからも使用することはできません)",
-      "allow_specified-long": "特定のチャンネルを許可 (テキストボックスに入力されたチャンネルのみ許可されます)",
+      "allow_all_long": "全て許可 (全てのチャンネルから使用することができます)",
+      "deny_all_long": "全て拒否 (どのチャンネルからも使用することはできません)",
+      "allow_specified_long": "特定のチャンネルを許可 (テキストボックスに入力されたチャンネルのみ許可されます)",
       "test_connection": "連携状況のテストをする",
       "test_connection": "連携状況のテストをする",
       "test_connection_by_pressing_button": "以下のテストボタンを押して、Slack連携が完了しているかの確認をしましょう",
       "test_connection_by_pressing_button": "以下のテストボタンを押して、Slack連携が完了しているかの確認をしましょう",
       "test_connection_only_public_channel":"連携テストは public チャンネルで確認してください",
       "test_connection_only_public_channel":"連携テストは public チャンネルで確認してください",

+ 4 - 1
packages/app/resource/locales/ja_JP/translation.json

@@ -64,7 +64,7 @@
   "Include Attachment File": "添付ファイルも含める",
   "Include Attachment File": "添付ファイルも含める",
   "Include Comment": "コメントも含める",
   "Include Comment": "コメントも含める",
   "Include Subordinated Page": "配下ページも含める",
   "Include Subordinated Page": "配下ページも含める",
-  "Include Subordinated Target Page": "{{target}} 下含む",
+  "Include Subordinated Target Page": "{{target}} 下含む",
   "All Subordinated Page": "全ての配下ページ",
   "All Subordinated Page": "全ての配下ページ",
   "Specify Hierarchy": "階層の深さを指定",
   "Specify Hierarchy": "階層の深さを指定",
   "Submitted the request to create the archive": "アーカイブ作成のリクエストを正常に送信しました",
   "Submitted the request to create the archive": "アーカイブ作成のリクエストを正常に送信しました",
@@ -107,6 +107,7 @@
   "Create under": "ページを以下に作成",
   "Create under": "ページを以下に作成",
   "Wiki Management Home Page": "Wiki管理トップ",
   "Wiki Management Home Page": "Wiki管理トップ",
   "App Settings": "アプリ設定",
   "App Settings": "アプリ設定",
+  "V5 Page Migration": "V5 ページマイグレーション",
   "Site URL settings": "サイトURL設定",
   "Site URL settings": "サイトURL設定",
   "Markdown Settings": "マークダウン設定",
   "Markdown Settings": "マークダウン設定",
   "Customize": "カスタマイズ",
   "Customize": "カスタマイズ",
@@ -153,6 +154,7 @@
   "Add to bookmark": "ブックマークに追加",
   "Add to bookmark": "ブックマークに追加",
   "Recent Created": "最新の作成",
   "Recent Created": "最新の作成",
   "Recent Changes": "最新の変更",
   "Recent Changes": "最新の変更",
+  "Page Tree": "ページツリー",
   "original_path":"元のパス",
   "original_path":"元のパス",
   "new_path":"新しいパス",
   "new_path":"新しいパス",
   "duplicated_path":"重複したパス",
   "duplicated_path":"重複したパス",
@@ -578,6 +580,7 @@
     "include_certain_path": "{{pathToInclude}}下を含む ",
     "include_certain_path": "{{pathToInclude}}下を含む ",
     "delete_all_selected_page" : "一括削除",
     "delete_all_selected_page" : "一括削除",
     "currently_not_implemented":"現在未実装の機能です",
     "currently_not_implemented":"現在未実装の機能です",
+    "search_again" : "再検索",
     "number_of_list_to_display" : "表示件数",
     "number_of_list_to_display" : "表示件数",
     "page_number_unit" : "件"
     "page_number_unit" : "件"
   },
   },

+ 17 - 5
packages/app/resource/locales/zh_CN/admin/admin.json

@@ -19,6 +19,15 @@
     "bug_report": "提交一个错误报告",
     "bug_report": "提交一个错误报告",
     "submit_bug_report": "<a href='https://github.com/weseek/growi/issues/new?assignees=&labels=bug&template=bug-report.md&title=Bug%3A' target='_blank' rel='noreferrer'>然后提交你的问题到GitHub。</a>"
     "submit_bug_report": "<a href='https://github.com/weseek/growi/issues/new?assignees=&labels=bug&template=bug-report.md&title=Bug%3A' target='_blank' rel='noreferrer'>然后提交你的问题到GitHub。</a>"
   },
   },
+  "v5_page_migration": {
+    "migration_desc": "Some of the public pages have the old schema. To take advantage of new features such as page trees and easy renaming, please upgrade the schema of all your pages. ",
+    "migration_note": "Note: You will lose unique constraints from the page paths.",
+    "upgrade_to_v5": "Upgrade to V5",
+    "modal_migration_warning": "This process may take long. It is highly recommended that administrators tell users not to create, modify, or delete pages during migration.",
+    "start_upgrading": "Start upgrading",
+    "successfully_started": "Succeeded to start migration",
+    "already_upgraded": "You have already completed upgrading"
+  },
   "app_setting": {
   "app_setting": {
     "site_name": "网站名称 ",
     "site_name": "网站名称 ",
     "sitename_change": "您可以更改用于标题和HTML标题的网站名称。",
     "sitename_change": "您可以更改用于标题和HTML标题的网站名称。",
@@ -348,16 +357,19 @@
       "install_complete_if_checked": "确认已选中 \"Install your app\"。",
       "install_complete_if_checked": "确认已选中 \"Install your app\"。",
       "invite_bot_to_channel": "通过调用 @example 邀请 GROWI Bot 进行频道。",
       "invite_bot_to_channel": "通过调用 @example 邀请 GROWI Bot 进行频道。",
       "register_secret_and_token": "设置签名秘密和BOT令牌",
       "register_secret_and_token": "设置签名秘密和BOT令牌",
-      "manage_commands": "管理 GROWI 命令",
+      "manage_permission": "设置权限",
+      "growi_commands": "GROWI 命令",
       "multiple_growi_command": "可以一次发送到多个 GROWI 实例的命令",
       "multiple_growi_command": "可以一次发送到多个 GROWI 实例的命令",
       "single_growi_command": "可以一次发送到一个 GROWI 实例的命令",
       "single_growi_command": "可以一次发送到一个 GROWI 实例的命令",
-      "allowed_channels_description": "为 \"{{commandName}}\" 命令输入允许的通道。每个通道之间用 \",\" 隔开。用户可以从这里写入的通道中使用 \"{{commandName}}\"。",
+      "allowed_channels_description": "为 \"{{keyName}}\" 命令输入允许的通道。每个通道之间用 \",\" 隔开。用户可以从这里写入的通道中使用 \"{{keyName}}\"。",
+      "unfurl_description": "在 Slack 中共享 GROWI 链接时显示页面内容",
+      "unfurl_allowed_channels_description": "为 \"unfurl\" 输入允许的通道ID。每个频道用 \",\"分开。在指定频道中发送的GROWI公共页面链接或永久链接将显示消息中的内容。",
       "allow_all": "允许所有",
       "allow_all": "允许所有",
       "deny_all": "拒绝所有",
       "deny_all": "拒绝所有",
       "allow_specified": "允许指定",
       "allow_specified": "允许指定",
-      "allow_all_long": "允许所有(允许从任何通道发出命令)",
-      "deny_all_long": "拒绝所有(该命令被拒绝于任何通道)",
-      "allow_specified_long": "允许指定(该命令只允许来自指定的通道)",
+      "allow_all_long": "允许所有(允许从任何渠道)",
+      "deny_all_long": "拒绝所有(拒绝来自任何渠道)",
+      "allow_specified_long": "允许指定(只允许来自指定的渠道)",
       "test_connection": "测试连接",
       "test_connection": "测试连接",
       "test_connection_by_pressing_button": "按下按钮以测试连接",
       "test_connection_by_pressing_button": "按下按钮以测试连接",
       "test_connection_only_public_channel":"请在一个公共频道中测试连接",
       "test_connection_only_public_channel":"请在一个公共频道中测试连接",

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

@@ -115,6 +115,7 @@
 	"Create under": "Create page under below:",
 	"Create under": "Create page under below:",
 	"Wiki Management Home Page": "Wiki管理首页",
 	"Wiki Management Home Page": "Wiki管理首页",
 	"App Settings": "系统设置",
 	"App Settings": "系统设置",
+  "V5 Page Migration": "V5 Page Migration",
 	"Site URL settings": "主页URL设置",
 	"Site URL settings": "主页URL设置",
 	"Markdown Settings": "Markdown设置",
 	"Markdown Settings": "Markdown设置",
 	"Customize": "页面定制",
 	"Customize": "页面定制",
@@ -159,6 +160,7 @@
   "Add to bookmark": "添加到书签",
   "Add to bookmark": "添加到书签",
 	"Recent Created": "最新创建",
 	"Recent Created": "最新创建",
   "Recent Changes": "最新修改",
   "Recent Changes": "最新修改",
+  "Page Tree": "页面树",
   "original_path":"Original path",
   "original_path":"Original path",
   "new_path":"New path",
   "new_path":"New path",
   "duplicated_path":"duplicated_path",
   "duplicated_path":"duplicated_path",
@@ -851,6 +853,7 @@
     "include_certain_path": "包含 {{pathToInclude}} 路径 ",
     "include_certain_path": "包含 {{pathToInclude}} 路径 ",
     "delete_all_selected_page": "删除所有",
     "delete_all_selected_page": "删除所有",
     "currently_not_implemented": "这是当前未实现的功能",
     "currently_not_implemented": "这是当前未实现的功能",
+    "search_again" : "再次搜索",
     "number_of_list_to_display" : "显示器的数量",
     "number_of_list_to_display" : "显示器的数量",
     "page_number_unit" : "例"
     "page_number_unit" : "例"
 	},
 	},

+ 14 - 0
packages/app/resource/search/mappings.json

@@ -65,6 +65,20 @@
             }
             }
           }
           }
         },
         },
+        "comments": {
+          "type": "text",
+          "fields": {
+            "ja": {
+              "type": "text",
+              "analyzer": "japanese"
+            },
+            "en": {
+              "type": "text",
+              "analyzer": "english_edge_ngram",
+              "search_analyzer": "standard"
+            }
+          }
+        },
         "username": {
         "username": {
           "type": "keyword"
           "type": "keyword"
         },
         },

+ 35 - 18
packages/app/src/client/app.jsx

@@ -41,6 +41,7 @@ import PersonalSettings from '../components/Me/PersonalSettings';
 import GrowiSubNavigation from '../components/Navbar/GrowiSubNavigation';
 import GrowiSubNavigation from '../components/Navbar/GrowiSubNavigation';
 import GrowiSubNavigationSwitcher from '../components/Navbar/GrowiSubNavigationSwitcher';
 import GrowiSubNavigationSwitcher from '../components/Navbar/GrowiSubNavigationSwitcher';
 
 
+import ContextExtractor from '~/client/services/ContextExtractor';
 import NavigationContainer from '~/client/services/NavigationContainer';
 import NavigationContainer from '~/client/services/NavigationContainer';
 import PageContainer from '~/client/services/PageContainer';
 import PageContainer from '~/client/services/PageContainer';
 import PageHistoryContainer from '~/client/services/PageHistoryContainer';
 import PageHistoryContainer from '~/client/services/PageHistoryContainer';
@@ -98,7 +99,6 @@ Object.assign(componentMappings, {
 
 
   'not-found-page': <NotFoundPage />,
   'not-found-page': <NotFoundPage />,
   'not-found-alert': <NotFoundAlert
   'not-found-alert': <NotFoundAlert
-    onPageCreateClicked={navigationContainer.setEditorMode}
     isGuestUserMode={appContainer.isGuestUser}
     isGuestUserMode={appContainer.isGuestUser}
     isHidden={pageContainer.state.isNotCreatable || pageContainer.state.isTrashPage}
     isHidden={pageContainer.state.isNotCreatable || pageContainer.state.isTrashPage}
   />,
   />,
@@ -153,23 +153,40 @@ if (pageContainer.state.path != null) {
   });
   });
 }
 }
 
 
-Object.keys(componentMappings).forEach((key) => {
-  const elem = document.getElementById(key);
-  if (elem) {
-    ReactDOM.render(
-      <I18nextProvider i18n={i18n}>
-        <ErrorBoundary>
-          <SWRConfig value={swrGlobalConfiguration}>
-            <Provider inject={injectableContainers}>
-              {componentMappings[key]}
-            </Provider>
-          </SWRConfig>
-        </ErrorBoundary>
-      </I18nextProvider>,
-      elem,
-    );
-  }
-});
+const renderMainComponents = () => {
+  Object.keys(componentMappings).forEach((key) => {
+    const elem = document.getElementById(key);
+    if (elem) {
+      ReactDOM.render(
+        <I18nextProvider i18n={i18n}>
+          <ErrorBoundary>
+            <SWRConfig value={swrGlobalConfiguration}>
+              <Provider inject={injectableContainers}>
+                {componentMappings[key]}
+              </Provider>
+            </SWRConfig>
+          </ErrorBoundary>
+        </I18nextProvider>,
+        elem,
+      );
+    }
+  });
+};
+
+// extract context before rendering main components
+const elem = document.getElementById('growi-context-extractor');
+if (elem != null) {
+  ReactDOM.render(
+    <SWRConfig value={swrGlobalConfiguration}>
+      <ContextExtractor></ContextExtractor>
+    </SWRConfig>,
+    elem,
+    renderMainComponents,
+  );
+}
+else {
+  renderMainComponents();
+}
 
 
 // initialize scrollpos-styler
 // initialize scrollpos-styler
 ScrollPosStyler.init();
 ScrollPosStyler.init();

+ 31 - 31
packages/app/src/client/legacy/crowi.js

@@ -17,7 +17,7 @@ window.Crowi = Crowi;
 Crowi.setCaretLineData = function(line) {
 Crowi.setCaretLineData = function(line) {
   const { appContainer } = window;
   const { appContainer } = window;
   const navigationContainer = appContainer.getContainer('NavigationContainer');
   const navigationContainer = appContainer.getContainer('NavigationContainer');
-  navigationContainer.setEditorMode('edit');
+  // navigationContainer.setEditorMode('edit');
   const pageEditorDom = document.querySelector('#page-editor');
   const pageEditorDom = document.querySelector('#page-editor');
   pageEditorDom.setAttribute('data-caret-line', line);
   pageEditorDom.setAttribute('data-caret-line', line);
 };
 };
@@ -154,32 +154,32 @@ Crowi.blinkSelectedSection = function(hash) {
   }
   }
 };
 };
 
 
-window.addEventListener('load', () => {
-  const { appContainer } = window;
-  const pageContainer = appContainer.getContainer('PageContainer');
-
-  // Do nothing if the page does not exist
-  // ex.) admin page,login page
-  if (pageContainer == null) {
-    return null;
-  }
-  const { isAbleToOpenPageEditor } = pageContainer;
-
-  // hash on page
-  if (window.location.hash) {
-    const navigationContainer = appContainer.getContainer('NavigationContainer');
-
-    if (window.location.hash === '#edit' && isAbleToOpenPageEditor) {
-      navigationContainer.setEditorMode('edit');
-
-      // focus
-      Crowi.setCaretLineAndFocusToEditor();
-    }
-    else if (window.location.hash === '#hackmd') {
-      navigationContainer.setEditorMode('hackmd');
-    }
-  }
-});
+// window.addEventListener('load', () => {
+//   const { appContainer } = window;
+//   const pageContainer = appContainer.getContainer('PageContainer');
+
+//   // Do nothing if the page does not exist
+//   // ex.) admin page,login page
+//   if (pageContainer == null) {
+//     return null;
+//   }
+//   const { isAbleToOpenPageEditor } = pageContainer;
+
+//   // hash on page
+//   if (window.location.hash) {
+//     const navigationContainer = appContainer.getContainer('NavigationContainer');
+
+//     if (window.location.hash === '#edit' && isAbleToOpenPageEditor) {
+//       navigationContainer.setEditorMode('edit');
+
+//       // focus
+//       Crowi.setCaretLineAndFocusToEditor();
+//     }
+//     else if (window.location.hash === '#hackmd') {
+//       navigationContainer.setEditorMode('hackmd');
+//     }
+//   }
+// });
 
 
 window.addEventListener('load', () => {
 window.addEventListener('load', () => {
   const crowi = window.crowi;
   const crowi = window.crowi;
@@ -228,18 +228,18 @@ window.addEventListener('hashchange', (e) => {
   Crowi.unblinkSelectedSection(Crowi.findHashFromUrl(e.oldURL));
   Crowi.unblinkSelectedSection(Crowi.findHashFromUrl(e.oldURL));
   Crowi.blinkSelectedSection(Crowi.findHashFromUrl(e.newURL));
   Crowi.blinkSelectedSection(Crowi.findHashFromUrl(e.newURL));
   Crowi.modifyScrollTop();
   Crowi.modifyScrollTop();
-  const { appContainer } = window;
-  const navigationContainer = appContainer.getContainer('NavigationContainer');
+  // const { appContainer } = window;
+  // const navigationContainer = appContainer.getContainer('NavigationContainer');
 
 
 
 
   // hash on page
   // hash on page
   if (window.location.hash) {
   if (window.location.hash) {
     if (window.location.hash === '#edit') {
     if (window.location.hash === '#edit') {
-      navigationContainer.setEditorMode('edit');
+      // navigationContainer.setEditorMode('edit');
       Crowi.setCaretLineAndFocusToEditor();
       Crowi.setCaretLineAndFocusToEditor();
     }
     }
     else if (window.location.hash === '#hackmd') {
     else if (window.location.hash === '#hackmd') {
-      navigationContainer.setEditorMode('hackmd');
+      // navigationContainer.setEditorMode('hackmd');
     }
     }
   }
   }
 });
 });

+ 19 - 0
packages/app/src/client/services/AdminAppContainer.js

@@ -22,6 +22,7 @@ export default class AdminAppContainer extends Container {
       isEmailPublishedForNewUser: true,
       isEmailPublishedForNewUser: true,
       fileUpload: '',
       fileUpload: '',
 
 
+      isV5Compatible: null,
       siteUrl: '',
       siteUrl: '',
       envSiteUrl: '',
       envSiteUrl: '',
       isSetSiteUrl: true,
       isSetSiteUrl: true,
@@ -81,6 +82,7 @@ export default class AdminAppContainer extends Container {
       globalLang: appSettingsParams.globalLang,
       globalLang: appSettingsParams.globalLang,
       isEmailPublishedForNewUser: appSettingsParams.isEmailPublishedForNewUser,
       isEmailPublishedForNewUser: appSettingsParams.isEmailPublishedForNewUser,
       fileUpload: appSettingsParams.fileUpload,
       fileUpload: appSettingsParams.fileUpload,
+      isV5Compatible: appSettingsParams.isV5Compatible,
       siteUrl: appSettingsParams.siteUrl,
       siteUrl: appSettingsParams.siteUrl,
       envSiteUrl: appSettingsParams.envSiteUrl,
       envSiteUrl: appSettingsParams.envSiteUrl,
       isSetSiteUrl: !!appSettingsParams.siteUrl,
       isSetSiteUrl: !!appSettingsParams.siteUrl,
@@ -160,6 +162,13 @@ export default class AdminAppContainer extends Container {
     this.setState({ fileUpload });
     this.setState({ fileUpload });
   }
   }
 
 
+  /**
+   * Change site url
+   */
+  changeIsV5Compatible(isV5Compatible) {
+    this.setState({ isV5Compatible });
+  }
+
   /**
   /**
    * Change site url
    * Change site url
    */
    */
@@ -440,5 +449,15 @@ export default class AdminAppContainer extends Container {
     return pluginSettingParams;
     return pluginSettingParams;
   }
   }
 
 
+  /**
+   * Start v5 page migration
+   * @memberOf AdminAppContainer
+   * @property action takes only 'initialMigration' for now. 'initialMigration' will start or resume migration
+   */
+  async v5PageMigrationHandler(action) {
+    const response = await this.appContainer.apiv3.post('/pages/v5-schema-migration', { action });
+    const { isV5Compatible } = response.data;
+    return { isV5Compatible };
+  }
 
 
 }
 }

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

@@ -32,6 +32,7 @@ export default class AdminHomeContainer extends Container {
       yarnVersion: '',
       yarnVersion: '',
       copyState: this.copyStateValues.DEFAULT,
       copyState: this.copyStateValues.DEFAULT,
       installedPlugins: [],
       installedPlugins: [],
+      isV5Compatible: null,
     };
     };
 
 
   }
   }
@@ -63,6 +64,7 @@ export default class AdminHomeContainer extends Container {
         yarnVersion: adminHomeParams.yarnVersion,
         yarnVersion: adminHomeParams.yarnVersion,
         installedPlugins: adminHomeParams.installedPlugins,
         installedPlugins: adminHomeParams.installedPlugins,
         envVars: adminHomeParams.envVars,
         envVars: adminHomeParams.envVars,
+        isV5Compatible: adminHomeParams.isV5Compatible,
       }));
       }));
     }
     }
     catch (err) {
     catch (err) {

+ 124 - 0
packages/app/src/client/services/ContextExtractor.tsx

@@ -0,0 +1,124 @@
+import React, { FC, useEffect, useState } from 'react';
+import { pagePathUtils } from '@growi/core';
+
+import {
+  useCreatedAt, useDeleteUsername, useDeletedAt, useHasChildren, useHasDraftOnHackmd, useIsAbleToDeleteCompletely,
+  useIsDeletable, useIsDeleted, useIsNotCreatable, useIsPageExist, useIsTrashPage, useIsUserPage, useLastUpdateUsername,
+  usePageId, usePageIdOnHackmd, usePageUser, useCurrentPagePath, useRevisionCreatedAt, useRevisionId, useRevisionIdHackmdSynced,
+  useShareLinkId, useShareLinksNumber, useTemplateTagData, useUpdatedAt, useCreator, useRevisionAuthor, useCurrentUser, useTargetAndAncestors,
+} from '../../stores/context';
+
+import {
+  EditorMode, useEditorMode, useIsDeviceSmallerThanMd, usePreferDrawerModeByUser, usePreferDrawerModeOnEditByUser,
+} from '~/stores/ui';
+
+const { isTrashPage: _isTrashPage } = pagePathUtils;
+
+const jsonNull = 'null';
+
+const getInitialEditorMode = (): EditorMode => {
+  switch (window.location.hash) {
+    case '#edit':
+      return EditorMode.Editor;
+    case '#hackmd':
+      return EditorMode.HackMD;
+    default:
+      return EditorMode.View;
+  }
+};
+
+const ContextExtractorOnce: FC = () => {
+
+  const mainContent = document.querySelector('#content-main');
+
+  /*
+   * App Context from DOM
+   */
+  const currentUser = JSON.parse(document.getElementById('growi-current-user')?.textContent || jsonNull);
+
+  /*
+   * Page Context from DOM
+   */
+  const revisionId = mainContent?.getAttribute('data-page-revision-id');
+  const path = decodeURI(mainContent?.getAttribute('data-path') || '');
+  const pageId = mainContent?.getAttribute('data-page-id') || null;
+  const revisionCreatedAt = +(mainContent?.getAttribute('data-page-revision-created') || '');
+  const createdAt = mainContent?.getAttribute('data-page-created-at');
+  const updatedAt = mainContent?.getAttribute('data-page-updated-at');
+  const deletedAt = mainContent?.getAttribute('data-page-deleted-at') || null;
+  const isUserPage = JSON.parse(mainContent?.getAttribute('data-page-user') || jsonNull);
+  const isTrashPage = _isTrashPage(path);
+  const isDeleted = JSON.parse(mainContent?.getAttribute('data-page-is-deleted') || jsonNull);
+  const isDeletable = JSON.parse(mainContent?.getAttribute('data-page-is-deletable') || jsonNull);
+  const isNotCreatable = JSON.parse(mainContent?.getAttribute('data-page-is-not-creatable') || jsonNull);
+  const isAbleToDeleteCompletely = JSON.parse(mainContent?.getAttribute('data-page-is-able-to-delete-completely') || jsonNull);
+  const isPageExist = mainContent?.getAttribute('data-page-id') != null;
+  const pageUser = JSON.parse(mainContent?.getAttribute('data-page-user') || jsonNull);
+  const hasChildren = JSON.parse(mainContent?.getAttribute('data-page-has-children') || 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);
+  const revisionIdHackmdSynced = mainContent?.getAttribute('data-page-revision-id-hackmd-synced') || null;
+  const lastUpdateUsername = mainContent?.getAttribute('data-page-last-update-username') || null;
+  const deleteUsername = mainContent?.getAttribute('data-page-delete-username') || null;
+  const pageIdOnHackmd = mainContent?.getAttribute('data-page-id-on-hackmd') || null;
+  const hasDraftOnHackmd = !!mainContent?.getAttribute('data-page-has-draft-on-hackmd');
+  const creator = JSON.parse(mainContent?.getAttribute('data-page-creator') || jsonNull);
+  const revisionAuthor = JSON.parse(mainContent?.getAttribute('data-page-revision-author') || jsonNull);
+  const targetAndAncestors = JSON.parse(mainContent?.getAttribute('data-target-and-ancestors') || jsonNull);
+
+  /*
+   * use static swr
+   */
+  // App
+  useCurrentUser(currentUser);
+
+  // Navigation
+  useEditorMode(getInitialEditorMode());
+  usePreferDrawerModeByUser();
+  usePreferDrawerModeOnEditByUser();
+  useIsDeviceSmallerThanMd();
+
+  // Page
+  useCreatedAt(createdAt);
+  useDeleteUsername(deleteUsername);
+  useDeletedAt(deletedAt);
+  useHasChildren(hasChildren);
+  useHasDraftOnHackmd(hasDraftOnHackmd);
+  useIsAbleToDeleteCompletely(isAbleToDeleteCompletely);
+  useIsDeletable(isDeletable);
+  useIsDeleted(isDeleted);
+  useIsNotCreatable(isNotCreatable);
+  useIsPageExist(isPageExist);
+  useIsTrashPage(isTrashPage);
+  useIsUserPage(isUserPage);
+  useLastUpdateUsername(lastUpdateUsername);
+  usePageId(pageId);
+  usePageIdOnHackmd(pageIdOnHackmd);
+  usePageUser(pageUser);
+  useCurrentPagePath(path);
+  useRevisionCreatedAt(revisionCreatedAt);
+  useRevisionId(revisionId);
+  useRevisionIdHackmdSynced(revisionIdHackmdSynced);
+  useShareLinkId(shareLinkId);
+  useShareLinksNumber(shareLinksNumber);
+  useTemplateTagData(templateTagData);
+  useUpdatedAt(updatedAt);
+  useCreator(creator);
+  useRevisionAuthor(revisionAuthor);
+  useTargetAndAncestors(targetAndAncestors);
+
+  return null;
+};
+
+const ContextExtractor: FC = React.memo(() => {
+  const [isRunOnce, setRunOnce] = useState(false);
+
+  useEffect(() => {
+    setRunOnce(true);
+  }, []);
+
+  return isRunOnce ? null : <ContextExtractorOnce></ContextExtractorOnce>;
+});
+
+export default ContextExtractor;

+ 55 - 137
packages/app/src/client/services/NavigationContainer.js

@@ -22,26 +22,12 @@ export default class NavigationContainer extends Container {
     const { localStorage } = window;
     const { localStorage } = window;
 
 
     this.state = {
     this.state = {
-      editorMode: 'view',
-
-      isDeviceSmallerThanMd: null,
-      preferDrawerModeByUser: localStorage.preferDrawerModeByUser === 'true',
-      preferDrawerModeOnEditByUser: // default: true
-        localStorage.preferDrawerModeOnEditByUser == null || localStorage.preferDrawerModeOnEditByUser === 'true',
-      isDrawerMode: null,
-      isDrawerOpened: false,
-
-      sidebarContentsId: localStorage.sidebarContentsId || 'recent',
+      // editorMode: 'view',
 
 
       isScrollTop: true,
       isScrollTop: true,
-
-      isPageCreateModalShown: false,
     };
     };
 
 
-    this.openPageCreateModal = this.openPageCreateModal.bind(this);
-    this.closePageCreateModal = this.closePageCreateModal.bind(this);
-    this.setEditorMode = this.setEditorMode.bind(this);
-    this.initDeviceSize();
+    // this.setEditorMode = this.setEditorMode.bind(this);
     this.initScrollEvent();
     this.initScrollEvent();
   }
   }
 
 
@@ -56,26 +42,6 @@ export default class NavigationContainer extends Container {
     return this.appContainer.getContainer('PageContainer');
     return this.appContainer.getContainer('PageContainer');
   }
   }
 
 
-  initDeviceSize() {
-    const mdOrAvobeHandler = async(mql) => {
-      let isDeviceSmallerThanMd;
-
-      // sm -> md
-      if (mql.matches) {
-        isDeviceSmallerThanMd = false;
-      }
-      // md -> sm
-      else {
-        isDeviceSmallerThanMd = true;
-      }
-
-      this.setState({ isDeviceSmallerThanMd });
-      this.updateDrawerMode({ ...this.state, isDeviceSmallerThanMd }); // generate newest state object
-    };
-
-    this.appContainer.addBreakpointListener('md', mdOrAvobeHandler, true);
-  }
-
   initScrollEvent() {
   initScrollEvent() {
     window.addEventListener('scroll', () => {
     window.addEventListener('scroll', () => {
       const currentYOffset = window.pageYOffset;
       const currentYOffset = window.pageYOffset;
@@ -91,80 +57,49 @@ export default class NavigationContainer extends Container {
     });
     });
   }
   }
 
 
-  setEditorMode(editorMode) {
-    const { isNotCreatable } = this.getPageContainer().state;
-
-    if (this.appContainer.currentUser == null) {
-      logger.warn('Please login or signup to edit the page or use hackmd.');
-      return;
-    }
-
-    if (isNotCreatable) {
-      logger.warn('This page could not edit.');
-      return;
-    }
-
-    this.setState({ editorMode });
-    if (editorMode === 'view') {
-      $('body').removeClass('on-edit');
-      $('body').removeClass('builtin-editor');
-      $('body').removeClass('hackmd');
-      $('body').removeClass('pathname-sidebar');
-      window.history.replaceState(null, '', window.location.pathname);
-    }
-
-    if (editorMode === 'edit') {
-      $('body').addClass('on-edit');
-      $('body').addClass('builtin-editor');
-      $('body').removeClass('hackmd');
-      // editing /Sidebar
-      if (window.location.pathname === '/Sidebar') {
-        $('body').addClass('pathname-sidebar');
-      }
-      window.location.hash = '#edit';
-    }
-
-    if (editorMode === 'hackmd') {
-      $('body').addClass('on-edit');
-      $('body').addClass('hackmd');
-      $('body').removeClass('builtin-editor');
-      $('body').removeClass('pathname-sidebar');
-      window.location.hash = '#hackmd';
-    }
-
-    this.updateDrawerMode({ ...this.state, editorMode }); // generate newest state object
-  }
-
-  toggleDrawer() {
-    const { isDrawerOpened } = this.state;
-    this.setState({ isDrawerOpened: !isDrawerOpened });
-  }
-
-  /**
-   * Set Sidebar mode preference by user
-   * @param {boolean} preferDockMode
-   */
-  async setDrawerModePreference(bool) {
-    this.setState({ preferDrawerModeByUser: bool });
-    this.updateDrawerMode({ ...this.state, preferDrawerModeByUser: bool }); // generate newest state object
-
-    // store settings to localStorage
-    const { localStorage } = window;
-    localStorage.preferDrawerModeByUser = bool;
-  }
-
-  /**
-   * Set Sidebar mode preference by user
-   * @param {boolean} preferDockMode
-   */
-  async setDrawerModePreferenceOnEdit(bool) {
-    this.setState({ preferDrawerModeOnEditByUser: bool });
-    this.updateDrawerMode({ ...this.state, preferDrawerModeOnEditByUser: bool }); // generate newest state object
-
-    // store settings to localStorage
-    const { localStorage } = window;
-    localStorage.preferDrawerModeOnEditByUser = bool;
-  }
+  // setEditorMode(editorMode) {
+  //   const { isNotCreatable } = this.getPageContainer().state;
+
+  //   if (this.appContainer.currentUser == null) {
+  //     logger.warn('Please login or signup to edit the page or use hackmd.');
+  //     return;
+  //   }
+
+  //   if (isNotCreatable) {
+  //     logger.warn('This page could not edit.');
+  //     return;
+  //   }
+
+  //   this.setState({ editorMode });
+  //   if (editorMode === 'view') {
+  //     $('body').removeClass('on-edit');
+  //     $('body').removeClass('builtin-editor');
+  //     $('body').removeClass('hackmd');
+  //     $('body').removeClass('pathname-sidebar');
+  //     window.history.replaceState(null, '', window.location.pathname);
+  //   }
+
+  //   if (editorMode === 'edit') {
+  //     $('body').addClass('on-edit');
+  //     $('body').addClass('builtin-editor');
+  //     $('body').removeClass('hackmd');
+  //     // editing /Sidebar
+  //     if (window.location.pathname === '/Sidebar') {
+  //       $('body').addClass('pathname-sidebar');
+  //     }
+  //     window.location.hash = '#edit';
+  //   }
+
+  //   if (editorMode === 'hackmd') {
+  //     $('body').addClass('on-edit');
+  //     $('body').addClass('hackmd');
+  //     $('body').removeClass('builtin-editor');
+  //     $('body').removeClass('pathname-sidebar');
+  //     window.location.hash = '#hackmd';
+  //   }
+
+  //   this.updateDrawerMode({ ...this.state, editorMode }); // generate newest state object
+  // }
 
 
   /**
   /**
    * Update drawer related state by specified 'newState' object
    * Update drawer related state by specified 'newState' object
@@ -176,36 +111,19 @@ export default class NavigationContainer extends Container {
    *
    *
    * because updating state of unstated container will be delayed unless you use await
    * because updating state of unstated container will be delayed unless you use await
    */
    */
-  updateDrawerMode(newState) {
-    const {
-      editorMode, isDeviceSmallerThanMd, preferDrawerModeByUser, preferDrawerModeOnEditByUser,
-    } = newState;
-
-    // get preference on view or edit
-    const preferDrawerMode = editorMode !== 'view' ? preferDrawerModeOnEditByUser : preferDrawerModeByUser;
-
-    const isDrawerMode = isDeviceSmallerThanMd || preferDrawerMode;
-    const isDrawerOpened = false; // close Drawer anyway
-
-    this.setState({ isDrawerMode, isDrawerOpened });
-  }
+  // updateDrawerMode(newState) {
+  //   const {
+  //     editorMode, isDeviceSmallerThanMd, preferDrawerModeByUser, preferDrawerModeOnEditByUser,
+  //   } = newState;
 
 
-  selectSidebarContents(contentsId) {
-    window.localStorage.setItem('sidebarContentsId', contentsId);
-    this.setState({ sidebarContentsId: contentsId });
-  }
+  //   // get preference on view or edit
+  //   const preferDrawerMode = editorMode !== 'view' ? preferDrawerModeOnEditByUser : preferDrawerModeByUser;
 
 
-  openPageCreateModal() {
-    if (this.appContainer.currentUser == null) {
-      logger.warn('Please login or signup to create a new page.');
-      return;
-    }
-    this.setState({ isPageCreateModalShown: true });
-  }
+  //   const isDrawerMode = isDeviceSmallerThanMd || preferDrawerMode;
+  //   const isDrawerOpened = false; // close Drawer anyway
 
 
-  closePageCreateModal() {
-    this.setState({ isPageCreateModalShown: false });
-  }
+  //   this.setState({ isDrawerMode, isDrawerOpened });
+  // }
 
 
   /**
   /**
    * Function that implements the click event for realizing smooth scroll
    * Function that implements the click event for realizing smooth scroll

+ 6 - 21
packages/app/src/client/services/PageContainer.js

@@ -82,6 +82,7 @@ export default class PageContainer extends Container {
       templateTagData: mainContent.getAttribute('data-template-tags') || null,
       templateTagData: mainContent.getAttribute('data-template-tags') || null,
       shareLinksNumber: mainContent.getAttribute('data-share-links-number'),
       shareLinksNumber: mainContent.getAttribute('data-share-links-number'),
       shareLinkId: JSON.parse(mainContent.getAttribute('data-share-link-id') || null),
       shareLinkId: JSON.parse(mainContent.getAttribute('data-share-link-id') || null),
+      targetAndAncestors: JSON.parse(mainContent.getAttribute('data-target-and-ancestors') || null),
 
 
       // latest(on remote) information
       // latest(on remote) information
       remoteRevisionId: revisionId,
       remoteRevisionId: revisionId,
@@ -161,12 +162,12 @@ export default class PageContainer extends Container {
   }
   }
 
 
 
 
-  get isAbleToOpenPageEditor() {
-    const { isNotCreatable, isTrashPage } = this.state;
-    const { isGuestUser } = this.appContainer;
+  // get isAbleToOpenPageEditor() {
+  //   const { isNotCreatable, isTrashPage } = this.state;
+  //   const { isGuestUser } = this.appContainer;
 
 
-    return (!isNotCreatable && !isTrashPage && !isGuestUser);
-  }
+  //   return (!isNotCreatable && !isTrashPage && !isGuestUser);
+  // }
 
 
   /**
   /**
    * whether to display reaction buttons
    * whether to display reaction buttons
@@ -292,22 +293,6 @@ export default class PageContainer extends Container {
     await this.retrieveLikersAndSeenUsers();
     await this.retrieveLikersAndSeenUsers();
   }
   }
 
 
-  async toggleLike() {
-    {
-      const toggledIsLiked = !this.state.isLiked;
-      await this.appContainer.apiv3Put('/page/likes', { pageId: this.state.pageId, bool: toggledIsLiked });
-
-      await this.setState(state => ({
-        isLiked: toggledIsLiked,
-        sumOfLikers: toggledIsLiked ? state.sumOfLikers + 1 : state.sumOfLikers - 1,
-        likerIds: toggledIsLiked
-          ? [...this.state.likerIds, this.appContainer.currentUserId]
-          : state.likerIds.filter(id => id !== this.appContainer.currentUserId),
-      }));
-    }
-
-    await this.retrieveLikersAndSeenUsers();
-  }
 
 
   async retrieveLikersAndSeenUsers() {
   async retrieveLikersAndSeenUsers() {
     const { users } = await this.appContainer.apiGet('/users.list', { user_ids: [...this.state.likerIds, ...this.state.seenUserIds].join(',') });
     const { users } = await this.appContainer.apiGet('/users.list', { user_ids: [...this.state.likerIds, ...this.state.seenUserIds].join(',') });

+ 28 - 0
packages/app/src/client/services/user-ui-settings.ts

@@ -0,0 +1,28 @@
+// eslint-disable-next-line no-restricted-imports
+import { AxiosResponse } from 'axios';
+
+import { debounce } from 'throttle-debounce';
+
+import { apiv3Put } from '~/client/util/apiv3-client';
+import { IUserUISettings } from '~/interfaces/user-ui-settings';
+
+let settingsForBulk: Partial<IUserUISettings> = {};
+const _putUserUISettingsInBulk = (): Promise<AxiosResponse<IUserUISettings>> => {
+  const result = apiv3Put<IUserUISettings>('/user-ui-settings', { settings: settingsForBulk });
+
+  // clear partial
+  settingsForBulk = {};
+
+  return result;
+};
+
+const _putUserUISettingsInBulkDebounced = debounce(1500, false, _putUserUISettingsInBulk);
+
+export const scheduleToPutUserUISettings = (settings: Partial<IUserUISettings>): Promise<AxiosResponse<IUserUISettings>> => {
+  settingsForBulk = {
+    ...settingsForBulk,
+    ...settings,
+  };
+
+  return _putUserUISettingsInBulkDebounced();
+};

+ 1 - 1
packages/app/src/client/util/apiv3-client.ts

@@ -36,7 +36,7 @@ const apiv3ErrorHandler = (_err) => {
 export async function apiv3Request<T = any>(method: string, path: string, params: unknown): Promise<AxiosResponse<T>> {
 export async function apiv3Request<T = any>(method: string, path: string, params: unknown): Promise<AxiosResponse<T>> {
   try {
   try {
     const res = await axios[method](urljoin(apiv3Root, path), params);
     const res = await axios[method](urljoin(apiv3Root, path), params);
-    return res.data;
+    return res;
   }
   }
   catch (err) {
   catch (err) {
     const errors = apiv3ErrorHandler(err);
     const errors = apiv3ErrorHandler(err);

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

@@ -10,6 +10,7 @@ import { toastError } from '~/client/util/apiNotification';
 import { withUnstatedContainers } from '../../UnstatedUtils';
 import { withUnstatedContainers } from '../../UnstatedUtils';
 import AppContainer from '~/client/services/AppContainer';
 import AppContainer from '~/client/services/AppContainer';
 import AdminHomeContainer from '~/client/services/AdminHomeContainer';
 import AdminHomeContainer from '~/client/services/AdminHomeContainer';
+import AdminAppContainer from '~/client/services/AdminAppContainer';
 import SystemInfomationTable from './SystemInfomationTable';
 import SystemInfomationTable from './SystemInfomationTable';
 import InstalledPluginTable from './InstalledPluginTable';
 import InstalledPluginTable from './InstalledPluginTable';
 import EnvVarsTable from './EnvVarsTable';
 import EnvVarsTable from './EnvVarsTable';
@@ -33,9 +34,26 @@ class AdminHome extends React.Component {
 
 
   render() {
   render() {
     const { t, adminHomeContainer } = this.props;
     const { t, adminHomeContainer } = this.props;
+    const { isV5Compatible } = adminHomeContainer.state;
+
+    let alertStyle = 'alert-info';
+    if (isV5Compatible == null) alertStyle = 'alert-warning';
 
 
     return (
     return (
       <Fragment>
       <Fragment>
+        {
+          // not show if true
+          !isV5Compatible
+          && (
+            <div className={`alert ${alertStyle}`}>
+              {t('admin:v5_page_migration.migration_desc')}
+              <a className="btn-link" href="/admin/app" rel="noopener noreferrer">
+                <i className="fa fa-link ml-1" aria-hidden="true"></i>
+                <strong>{t('admin:v5_page_migration.upgrade_to_v5')}</strong>
+              </a>
+            </div>
+          )
+        }
         <p>
         <p>
           {t('admin:admin_top.wiki_administrator')}
           {t('admin:admin_top.wiki_administrator')}
           <br></br>
           <br></br>

+ 25 - 2
packages/app/src/components/Admin/App/AppSettingsPageContents.jsx

@@ -2,19 +2,36 @@ import React, { Fragment } from 'react';
 import { withTranslation } from 'react-i18next';
 import { withTranslation } from 'react-i18next';
 import PropTypes from 'prop-types';
 import PropTypes from 'prop-types';
 
 
+import { withUnstatedContainers } from '../../UnstatedUtils';
 import AppSetting from './AppSetting';
 import AppSetting from './AppSetting';
 import SiteUrlSetting from './SiteUrlSetting';
 import SiteUrlSetting from './SiteUrlSetting';
 import MailSetting from './MailSetting';
 import MailSetting from './MailSetting';
 import PluginSetting from './PluginSetting';
 import PluginSetting from './PluginSetting';
 import FileUploadSetting from './FileUploadSetting';
 import FileUploadSetting from './FileUploadSetting';
+import V5PageMigration from './V5PageMigration';
+
+import AdminAppContainer from '~/client/services/AdminAppContainer';
 
 
 class AppSettingsPageContents extends React.Component {
 class AppSettingsPageContents extends React.Component {
 
 
   render() {
   render() {
-    const { t } = this.props;
+    const { t, adminAppContainer } = this.props;
+    const { isV5Compatible } = adminAppContainer.state;
 
 
     return (
     return (
       <Fragment>
       <Fragment>
+        {
+          !isV5Compatible
+          && (
+            <div className="row">
+              <div className="col-lg-12">
+                <h2 className="admin-setting-header">{t('V5 Page Migration')}</h2>
+                <V5PageMigration />
+              </div>
+            </div>
+          )
+        }
+
         <div className="row">
         <div className="row">
           <div className="col-lg-12">
           <div className="col-lg-12">
             <h2 className="admin-setting-header">{t('App Settings')}</h2>
             <h2 className="admin-setting-header">{t('App Settings')}</h2>
@@ -55,8 +72,14 @@ class AppSettingsPageContents extends React.Component {
 
 
 }
 }
 
 
+/**
+ * Wrapper component for using unstated
+ */
+const AppSettingsPageContentsWrapper = withUnstatedContainers(AppSettingsPageContents, [AdminAppContainer]);
+
 AppSettingsPageContents.propTypes = {
 AppSettingsPageContents.propTypes = {
   t: PropTypes.func.isRequired, // i18next
   t: PropTypes.func.isRequired, // i18next
+  adminAppContainer: PropTypes.instanceOf(AdminAppContainer).isRequired,
 };
 };
 
 
-export default withTranslation()(AppSettingsPageContents);
+export default withTranslation()(AppSettingsPageContentsWrapper);

+ 57 - 0
packages/app/src/components/Admin/App/V5PageMigration.tsx

@@ -0,0 +1,57 @@
+import React, { FC, useState } from 'react';
+import { useTranslation } from 'react-i18next';
+import { V5PageMigrationModal } from './V5PageMigrationModal';
+import AdminAppContainer from '../../../client/services/AdminAppContainer';
+import { withUnstatedContainers } from '../../UnstatedUtils';
+import { toastSuccess, toastError } from '../../../client/util/apiNotification';
+
+type Props = {
+  adminAppContainer: typeof AdminAppContainer & { v5PageMigrationHandler: (action: string) => Promise<{ isV5Compatible: boolean }> },
+}
+
+const V5PageMigration: FC<Props> = (props: Props) => {
+  const [isV5PageMigrationModalShown, setIsV5PageMigrationModalShown] = useState(false);
+  const { adminAppContainer } = props;
+  const { t } = useTranslation();
+
+  const onConfirm = async() => {
+    setIsV5PageMigrationModalShown(false);
+    try {
+      const { isV5Compatible } = await adminAppContainer.v5PageMigrationHandler('initialMigration');
+      if (isV5Compatible) {
+
+        return toastSuccess(t('admin:v5_page_migration.already_upgraded'));
+      }
+      toastSuccess(t('admin:v5_page_migration.successfully_started'));
+    }
+    catch (err) {
+      toastError(err);
+    }
+  };
+
+  return (
+    <>
+      <V5PageMigrationModal
+        isModalOpen={isV5PageMigrationModalShown}
+        onConfirm={onConfirm}
+        onCancel={() => setIsV5PageMigrationModalShown(false)}
+      />
+      <p className="card well">
+        {t('admin:v5_page_migration.migration_desc')}
+        <br />
+        <br />
+        <span className="text-danger">
+          <i className="icon-exclamation icon-fw"></i>
+          {t('admin:v5_page_migration.migration_note')}
+        </span>
+      </p>
+      <div className="row my-3">
+        <div className="mx-auto">
+          <button type="button" className="btn btn-warning" onClick={() => setIsV5PageMigrationModalShown(true)}>Upgrade to v5</button>
+        </div>
+      </div>
+    </>
+  );
+};
+
+export default withUnstatedContainers(V5PageMigration, [AdminAppContainer]);

+ 61 - 0
packages/app/src/components/Admin/App/V5PageMigrationModal.tsx

@@ -0,0 +1,61 @@
+import React, { FC } from 'react';
+import {
+  Modal, ModalHeader, ModalBody, ModalFooter,
+} from 'reactstrap';
+import { useTranslation } from 'react-i18next';
+
+type V5PageMigrationModalProps = {
+  isModalOpen: boolean
+  onConfirm?: () => Promise<void>;
+  onCancel?: () => void;
+};
+
+export const V5PageMigrationModal: FC<V5PageMigrationModalProps> = (props: V5PageMigrationModalProps) => {
+  const { t } = useTranslation();
+
+  const onCancel = () => {
+    if (props.onCancel != null) {
+      props.onCancel();
+    }
+  };
+
+  const onConfirm = () => {
+    if (props.onConfirm != null) {
+      props.onConfirm();
+    }
+  };
+
+  return (
+    <Modal isOpen={props.isModalOpen} toggle={onCancel} className="">
+      <ModalHeader tag="h4" toggle={onCancel} className="bg-warning">
+        <i className="icon-fw icon-question" />
+        Warning
+      </ModalHeader>
+      <ModalBody>
+        {t('admin:v5_page_migration.modal_migration_warning')}
+        <br />
+        <br />
+        <span className="text-danger">
+          <i className="icon-exclamation icon-fw"></i>
+          {t('admin:v5_page_migration.migration_note')}
+        </span>
+      </ModalBody>
+      <ModalFooter>
+        <button
+          type="button"
+          className="btn btn-outline-secondary"
+          onClick={onCancel}
+        >
+          {t('Cancel')}
+        </button>
+        <button
+          type="button"
+          className="btn btn-outline-primary ml-3"
+          onClick={onConfirm}
+        >
+          {t('admin:v5_page_migration.start_upgrading')}
+        </button>
+      </ModalFooter>
+    </Modal>
+  );
+};

+ 4 - 2
packages/app/src/components/Admin/ElasticsearchManagement/ElasticsearchManagement.jsx

@@ -53,7 +53,8 @@ class ElasticsearchManagement extends React.Component {
       });
       });
     });
     });
 
 
-    socket.on('finishAddPage', (data) => {
+    socket.on('finishAddPage', async(data) => {
+      await this.retrieveIndicesStatus();
       this.setState({
       this.setState({
         isRebuildingProcessing: false,
         isRebuildingProcessing: false,
         isRebuildingCompleted: true,
         isRebuildingCompleted: true,
@@ -69,7 +70,8 @@ class ElasticsearchManagement extends React.Component {
     const { appContainer } = this.props;
     const { appContainer } = this.props;
 
 
     try {
     try {
-      const { info } = await appContainer.apiv3Get('/search/indices');
+      const { data } = await appContainer.apiv3Get('/search/indices');
+      const { info } = data;
 
 
       this.setState({
       this.setState({
         isConnected: true,
         isConnected: true,

+ 2 - 1
packages/app/src/components/Admin/SlackIntegration/CustomBotWithProxySettings.jsx

@@ -127,7 +127,7 @@ const CustomBotWithProxySettings = (props) => {
       <div className="mx-3">
       <div className="mx-3">
         {slackAppIntegrations.map((slackAppIntegration, i) => {
         {slackAppIntegrations.map((slackAppIntegration, i) => {
           const {
           const {
-            tokenGtoP, tokenPtoG, _id, permissionsForBroadcastUseCommands, permissionsForSingleUseCommands,
+            tokenGtoP, tokenPtoG, _id, permissionsForBroadcastUseCommands, permissionsForSingleUseCommands, permissionsForSlackEventActions,
           } = slackAppIntegration;
           } = slackAppIntegration;
           const workspaceName = connectionStatuses[_id]?.workspaceName;
           const workspaceName = connectionStatuses[_id]?.workspaceName;
           return (
           return (
@@ -150,6 +150,7 @@ const CustomBotWithProxySettings = (props) => {
                 tokenPtoG={tokenPtoG}
                 tokenPtoG={tokenPtoG}
                 permissionsForBroadcastUseCommands={permissionsForBroadcastUseCommands}
                 permissionsForBroadcastUseCommands={permissionsForBroadcastUseCommands}
                 permissionsForSingleUseCommands={permissionsForSingleUseCommands}
                 permissionsForSingleUseCommands={permissionsForSingleUseCommands}
+                permissionsForSlackEventActions={permissionsForSlackEventActions}
                 onUpdateTokens={onUpdateTokens}
                 onUpdateTokens={onUpdateTokens}
                 onSubmitForm={onSubmitForm}
                 onSubmitForm={onSubmitForm}
               />
               />

+ 2 - 0
packages/app/src/components/Admin/SlackIntegration/CustomBotWithoutProxySettings.jsx

@@ -51,6 +51,7 @@ const CustomBotWithoutProxySettings = (props) => {
           onTestConnectionInvoked={props.onTestConnectionInvoked}
           onTestConnectionInvoked={props.onTestConnectionInvoked}
           onUpdatedSecretToken={props.onUpdatedSecretToken}
           onUpdatedSecretToken={props.onUpdatedSecretToken}
           commandPermission={props.commandPermission}
           commandPermission={props.commandPermission}
+          eventActionsPermission={props.eventActionsPermission}
         />
         />
       </div>
       </div>
     </>
     </>
@@ -71,6 +72,7 @@ CustomBotWithoutProxySettings.propTypes = {
   onTestConnectionInvoked: PropTypes.func.isRequired,
   onTestConnectionInvoked: PropTypes.func.isRequired,
   connectionStatuses: PropTypes.object.isRequired,
   connectionStatuses: PropTypes.object.isRequired,
   commandPermission: PropTypes.object,
   commandPermission: PropTypes.object,
+  eventActionsPermission: PropTypes.object,
 };
 };
 
 
 export default CustomBotWithoutProxySettingsWrapper;
 export default CustomBotWithoutProxySettingsWrapper;

+ 5 - 4
packages/app/src/components/Admin/SlackIntegration/CustomBotWithoutProxySettingsAccordion.jsx

@@ -21,7 +21,7 @@ export const botInstallationStep = {
 const CustomBotWithoutProxySettingsAccordion = (props) => {
 const CustomBotWithoutProxySettingsAccordion = (props) => {
   const {
   const {
     appContainer, activeStep, onTestConnectionInvoked,
     appContainer, activeStep, onTestConnectionInvoked,
-    slackSigningSecret, slackBotToken, slackSigningSecretEnv, slackBotTokenEnv, commandPermission,
+    slackSigningSecret, slackBotToken, slackSigningSecretEnv, slackBotTokenEnv, commandPermission, eventActionsPermission,
   } = props;
   } = props;
   const successMessage = 'Successfully sent to Slack workspace.';
   const successMessage = 'Successfully sent to Slack workspace.';
 
 
@@ -125,10 +125,11 @@ const CustomBotWithoutProxySettingsAccordion = (props) => {
       <Accordion
       <Accordion
         defaultIsActive={defaultOpenAccordionKeys.has(botInstallationStep.CONNECTION_TEST)}
         defaultIsActive={defaultOpenAccordionKeys.has(botInstallationStep.CONNECTION_TEST)}
         // eslint-disable-next-line max-len
         // eslint-disable-next-line max-len
-        title={<><span className="mr-2">④</span>{t('admin:slack_integration.accordion.manage_commands')}</>}
+        title={<><span className="mr-2">④</span>{t('admin:slack_integration.accordion.manage_permission')}</>}
       >
       >
         <ManageCommandsProcessWithoutProxy
         <ManageCommandsProcessWithoutProxy
-          commandPermission={props.commandPermission}
+          commandPermission={commandPermission}
+          eventActionsPermission={eventActionsPermission}
           apiv3Put={props.appContainer.apiv3.put}
           apiv3Put={props.appContainer.apiv3.put}
         />
         />
       </Accordion>
       </Accordion>
@@ -200,7 +201,7 @@ CustomBotWithoutProxySettingsAccordion.propTypes = {
   slackBotToken: PropTypes.string,
   slackBotToken: PropTypes.string,
   slackBotTokenEnv: PropTypes.string,
   slackBotTokenEnv: PropTypes.string,
   commandPermission: PropTypes.object,
   commandPermission: PropTypes.object,
-
+  eventActionsPermission: PropTypes.object,
 };
 };
 
 
 export default CustomBotWithoutProxySettingsAccordionWrapper;
 export default CustomBotWithoutProxySettingsAccordionWrapper;

+ 238 - 122
packages/app/src/components/Admin/SlackIntegration/ManageCommandsProcess.jsx

@@ -1,7 +1,7 @@
 import React, { useCallback, useState } from 'react';
 import React, { useCallback, useState } from 'react';
 import PropTypes from 'prop-types';
 import PropTypes from 'prop-types';
 import { useTranslation } from 'react-i18next';
 import { useTranslation } from 'react-i18next';
-import { defaultSupportedCommandsNameForBroadcastUse, defaultSupportedCommandsNameForSingleUse } from '@growi/slack';
+import { defaultSupportedCommandsNameForBroadcastUse, defaultSupportedCommandsNameForSingleUse, defaultSupportedSlackEventActions } from '@growi/slack';
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 
 
 import { toastSuccess, toastError } from '../../../client/util/apiNotification';
 import { toastSuccess, toastError } from '../../../client/util/apiNotification';
@@ -19,6 +19,11 @@ const CommandUsageTypes = {
   SINGLE_USE: 'singleUse',
   SINGLE_USE: 'singleUse',
 };
 };
 
 
+const EventTypes = {
+  LINK_SHARING: 'linkSharing',
+};
+
+
 // A utility function that returns the new state but identical to the previous state
 // A utility function that returns the new state but identical to the previous state
 const getUpdatedChannelsList = (prevState, commandName, value) => {
 const getUpdatedChannelsList = (prevState, commandName, value) => {
   // string to array
   // string to array
@@ -62,9 +67,110 @@ const getPermissionTypeFromValue = (value) => {
   logger.error('The value type must be boolean or string[]');
   logger.error('The value type must be boolean or string[]');
 };
 };
 
 
+const PermissionSettingForEachPermissionTypeComponent = ({
+  keyName, onUpdatePermissions, onUpdateChannels, singleCommandDescription, allowedChannelsDescription, currentPermissionType, permissionSettings,
+}) => {
+  const { t } = useTranslation();
+  const hiddenClass = currentPermissionType === PermissionTypes.ALLOW_SPECIFIED ? '' : 'd-none';
+
+  const permission = permissionSettings[keyName];
+  if (permission === undefined) logger.error('Must be implemented');
+  const textareaDefaultValue = Array.isArray(permission) ? permission.join(',') : '';
+
+
+  return (
+    <div className="my-1 mb-2">
+      <div className="row align-items-center mb-3">
+        <p className="col-md-5 text-md-right mb-2">
+          <strong className="text-capitalize">{keyName}</strong>
+          {singleCommandDescription && (
+            <small className="form-text text-muted small">
+              { singleCommandDescription }
+            </small>
+          )}
+        </p>
+        <div className="col dropdown">
+          <button
+            className="btn btn-outline-secondary dropdown-toggle text-right col-12 col-md-auto"
+            type="button"
+            id="dropdownMenuButton"
+            data-toggle="dropdown"
+            aria-haspopup="true"
+            aria-expanded="true"
+          >
+            <span className="float-left">
+              {currentPermissionType === PermissionTypes.ALLOW_ALL
+              && t('admin:slack_integration.accordion.allow_all')}
+              {currentPermissionType === PermissionTypes.DENY_ALL
+              && t('admin:slack_integration.accordion.deny_all')}
+              {currentPermissionType === PermissionTypes.ALLOW_SPECIFIED
+              && t('admin:slack_integration.accordion.allow_specified')}
+            </span>
+          </button>
+          <div className="dropdown-menu">
+            <button
+              className="dropdown-item"
+              type="button"
+              name={keyName}
+              value={PermissionTypes.ALLOW_ALL}
+              onClick={onUpdatePermissions}
+            >
+              {t('admin:slack_integration.accordion.allow_all_long')}
+            </button>
+            <button
+              className="dropdown-item"
+              type="button"
+              name={keyName}
+              value={PermissionTypes.DENY_ALL}
+              onClick={onUpdatePermissions}
+            >
+              {t('admin:slack_integration.accordion.deny_all_long')}
+            </button>
+            <button
+              className="dropdown-item"
+              type="button"
+              name={keyName}
+              value={PermissionTypes.ALLOW_SPECIFIED}
+              onClick={onUpdatePermissions}
+            >
+              {t('admin:slack_integration.accordion.allow_specified_long')}
+            </button>
+          </div>
+        </div>
+      </div>
+      <div className={`row ${hiddenClass}`}>
+        <div className="col-md-7 offset-md-5">
+          <textarea
+            className="form-control"
+            type="textarea"
+            name={keyName}
+            defaultValue={textareaDefaultValue}
+            onChange={onUpdateChannels}
+          />
+          <p className="form-text text-muted small">
+            {t(allowedChannelsDescription, { keyName })}
+          </p>
+        </div>
+      </div>
+    </div>
+  );
+};
+
+PermissionSettingForEachPermissionTypeComponent.propTypes = {
+  keyName: PropTypes.string,
+  usageType: PropTypes.string,
+  currentPermissionType: PropTypes.string,
+  singleCommandDescription: PropTypes.string,
+  onUpdatePermissions: PropTypes.func,
+  onUpdateChannels: PropTypes.func,
+  allowedChannelsDescription: PropTypes.string,
+  permissionSettings: PropTypes.object,
+};
+
+
 // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
 // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
 const ManageCommandsProcess = ({
 const ManageCommandsProcess = ({
-  apiv3Put, slackAppIntegrationId, permissionsForBroadcastUseCommands, permissionsForSingleUseCommands,
+  apiv3Put, slackAppIntegrationId, permissionsForBroadcastUseCommands, permissionsForSingleUseCommands, permissionsForSlackEventActions,
 }) => {
 }) => {
   const { t } = useTranslation();
   const { t } = useTranslation();
 
 
@@ -75,6 +181,9 @@ const ManageCommandsProcess = ({
     note: permissionsForSingleUseCommands.note,
     note: permissionsForSingleUseCommands.note,
     keep: permissionsForSingleUseCommands.keep,
     keep: permissionsForSingleUseCommands.keep,
   });
   });
+  const [permissionsForEventsState, setPermissionsForEventsState] = useState({
+    unfurl: permissionsForSlackEventActions.unfurl,
+  });
   const [currentPermissionTypes, setCurrentPermissionTypes] = useState(() => {
   const [currentPermissionTypes, setCurrentPermissionTypes] = useState(() => {
     const initialState = {};
     const initialState = {};
     Object.entries(permissionsForBroadcastUseCommandsState).forEach((entry) => {
     Object.entries(permissionsForBroadcastUseCommandsState).forEach((entry) => {
@@ -85,14 +194,28 @@ const ManageCommandsProcess = ({
       const [commandName, value] = entry;
       const [commandName, value] = entry;
       initialState[commandName] = getPermissionTypeFromValue(value);
       initialState[commandName] = getPermissionTypeFromValue(value);
     });
     });
+    Object.entries(permissionsForEventsState).forEach((entry) => {
+      const [commandName, value] = entry;
+      initialState[commandName] = getPermissionTypeFromValue(value);
+    });
     return initialState;
     return initialState;
   });
   });
 
 
-  const updatePermissionsForBroadcastUseCommandsState = useCallback((e) => {
+
+  const handleUpdateSingleUsePermissions = useCallback((e) => {
     const { target } = e;
     const { target } = e;
     const { name: commandName, value } = target;
     const { name: commandName, value } = target;
+    setPermissionsForSingleUseCommandsState(prev => getUpdatedPermissionSettings(prev, commandName, value));
+    setCurrentPermissionTypes((prevState) => {
+      const newState = { ...prevState };
+      newState[commandName] = value;
+      return newState;
+    });
+  }, []);
 
 
-    // update state
+  const handleUpdateBroadcastUsePermissions = useCallback((e) => {
+    const { target } = e;
+    const { name: commandName, value } = target;
     setPermissionsForBroadcastUseCommandsState(prev => getUpdatedPermissionSettings(prev, commandName, value));
     setPermissionsForBroadcastUseCommandsState(prev => getUpdatedPermissionSettings(prev, commandName, value));
     setCurrentPermissionTypes((prevState) => {
     setCurrentPermissionTypes((prevState) => {
       const newState = { ...prevState };
       const newState = { ...prevState };
@@ -101,12 +224,10 @@ const ManageCommandsProcess = ({
     });
     });
   }, []);
   }, []);
 
 
-  const updatePermissionsForSingleUseCommandsState = useCallback((e) => {
+  const handleUpdateEventsPermissions = useCallback((e) => {
     const { target } = e;
     const { target } = e;
     const { name: commandName, value } = target;
     const { name: commandName, value } = target;
-
-    // update state
-    setPermissionsForSingleUseCommandsState(prev => getUpdatedPermissionSettings(prev, commandName, value));
+    setPermissionsForEventsState(prev => getUpdatedPermissionSettings(prev, commandName, value));
     setCurrentPermissionTypes((prevState) => {
     setCurrentPermissionTypes((prevState) => {
       const newState = { ...prevState };
       const newState = { ...prevState };
       newState[commandName] = value;
       newState[commandName] = value;
@@ -114,25 +235,32 @@ const ManageCommandsProcess = ({
     });
     });
   }, []);
   }, []);
 
 
-  const updateChannelsListForBroadcastUseCommandsState = useCallback((e) => {
+  const handleUpdateSingleUseChannels = useCallback((e) => {
+    const { target } = e;
+    const { name: commandName, value } = target;
+    setPermissionsForSingleUseCommandsState(prev => getUpdatedChannelsList(prev, commandName, value));
+  }, []);
+
+  const handleUpdateBroadcastUseChannels = useCallback((e) => {
     const { target } = e;
     const { target } = e;
     const { name: commandName, value } = target;
     const { name: commandName, value } = target;
-    // update state
     setPermissionsForBroadcastUseCommandsState(prev => getUpdatedChannelsList(prev, commandName, value));
     setPermissionsForBroadcastUseCommandsState(prev => getUpdatedChannelsList(prev, commandName, value));
   }, []);
   }, []);
 
 
-  const updateChannelsListForSingleUseCommandsState = useCallback((e) => {
+  const handleUpdateEventsChannels = useCallback((e) => {
     const { target } = e;
     const { target } = e;
     const { name: commandName, value } = target;
     const { name: commandName, value } = target;
-    // update state
-    setPermissionsForSingleUseCommandsState(prev => getUpdatedChannelsList(prev, commandName, value));
+    setPermissionsForEventsState(prev => getUpdatedChannelsList(prev, commandName, value));
   }, []);
   }, []);
 
 
-  const updateCommandsHandler = async(e) => {
+
+  const updateSettingsHandler = async(e) => {
     try {
     try {
-      await apiv3Put(`/slack-integration-settings/slack-app-integrations/${slackAppIntegrationId}/supported-commands`, {
+      // TODO: add new attribute 78975
+      await apiv3Put(`/slack-integration-settings/slack-app-integrations/${slackAppIntegrationId}/permissions`, {
         permissionsForBroadcastUseCommands: permissionsForBroadcastUseCommandsState,
         permissionsForBroadcastUseCommands: permissionsForBroadcastUseCommandsState,
         permissionsForSingleUseCommands: permissionsForSingleUseCommandsState,
         permissionsForSingleUseCommands: permissionsForSingleUseCommandsState,
+        permissionsForSlackEventActions: permissionsForEventsState,
       });
       });
       toastSuccess(t('toaster.update_successed', { target: 'Token' }));
       toastSuccess(t('toaster.update_successed', { target: 'Token' }));
     }
     }
@@ -142,141 +270,128 @@ const ManageCommandsProcess = ({
     }
     }
   };
   };
 
 
-  const PermissionSettingForEachCommandComponent = ({ commandName, commandUsageType }) => {
-    const hiddenClass = currentPermissionTypes[commandName] === PermissionTypes.ALLOW_SPECIFIED ? '' : 'd-none';
-    const isCommandBroadcastUse = commandUsageType === CommandUsageTypes.BROADCAST_USE;
+  const PermissionSettingsForEachCategoryComponent = ({
+    currentPermissionTypes,
+    usageType,
+    menuItem,
+  }) => {
+    const permissionMap = {
+      broadcastUse: permissionsForBroadcastUseCommandsState,
+      singleUse: permissionsForSingleUseCommandsState,
+      linkSharing: permissionsForEventsState,
+    };
 
 
-    const permissionSettings = isCommandBroadcastUse ? permissionsForBroadcastUseCommandsState : permissionsForSingleUseCommandsState;
-    const permission = permissionSettings[commandName];
-    if (permission === undefined) logger.error('Must be implemented');
-
-    const textareaDefaultValue = Array.isArray(permission) ? permission.join(',') : '';
+    const {
+      title,
+      description,
+      defaultCommandsName,
+      singleCommandDescription,
+      updatePermissionsHandler,
+      updateChannelsHandler,
+      allowedChannelsDescription,
+    } = menuItem;
 
 
     return (
     return (
-      <div className="my-1 mb-2">
-        <div className="row align-items-center mb-3">
-          <p className="col-md-5 text-md-right text-capitalize mb-2"><strong>{commandName}</strong></p>
-          <div className="col dropdown">
-            <button
-              className="btn btn-outline-secondary dropdown-toggle text-right col-12 col-md-auto"
-              type="button"
-              id="dropdownMenuButton"
-              data-toggle="dropdown"
-              aria-haspopup="true"
-              aria-expanded="true"
-            >
-              <span className="float-left">
-                {currentPermissionTypes[commandName] === PermissionTypes.ALLOW_ALL
-                && t('admin:slack_integration.accordion.allow_all')}
-                {currentPermissionTypes[commandName] === PermissionTypes.DENY_ALL
-                && t('admin:slack_integration.accordion.deny_all')}
-                {currentPermissionTypes[commandName] === PermissionTypes.ALLOW_SPECIFIED
-                && t('admin:slack_integration.accordion.allow_specified')}
-              </span>
-            </button>
-            <div className="dropdown-menu">
-              <button
-                className="dropdown-item"
-                type="button"
-                name={commandName}
-                value={PermissionTypes.ALLOW_ALL}
-                onClick={isCommandBroadcastUse ? updatePermissionsForBroadcastUseCommandsState : updatePermissionsForSingleUseCommandsState}
-              >
-                {t('admin:slack_integration.accordion.allow_all_long')}
-              </button>
-              <button
-                className="dropdown-item"
-                type="button"
-                name={commandName}
-                value={PermissionTypes.DENY_ALL}
-                onClick={isCommandBroadcastUse ? updatePermissionsForBroadcastUseCommandsState : updatePermissionsForSingleUseCommandsState}
-              >
-                {t('admin:slack_integration.accordion.deny_all_long')}
-              </button>
-              <button
-                className="dropdown-item"
-                type="button"
-                name={commandName}
-                value={PermissionTypes.ALLOW_SPECIFIED}
-                onClick={isCommandBroadcastUse ? updatePermissionsForBroadcastUseCommandsState : updatePermissionsForSingleUseCommandsState}
-              >
-                {t('admin:slack_integration.accordion.allow_specified_long')}
-              </button>
+      <>
+        {(title || description) && (
+          <div className="row">
+            <div className="col-md-7 offset-md-2">
+              { title && <p className="font-weight-bold mb-1">{title}</p> }
+              { description && <p className="text-muted">{description}</p> }
             </div>
             </div>
           </div>
           </div>
-        </div>
-        <div className={`row ${hiddenClass}`}>
-          <div className="col-md-7 offset-md-5">
-            <textarea
-              className="form-control"
-              type="textarea"
-              name={commandName}
-              defaultValue={textareaDefaultValue}
-              onChange={isCommandBroadcastUse ? updateChannelsListForBroadcastUseCommandsState : updateChannelsListForSingleUseCommandsState}
-            />
-            <p className="form-text text-muted small">
-              {t('admin:slack_integration.accordion.allowed_channels_description', { commandName })}
-              <br />
-            </p>
-          </div>
-        </div>
-      </div>
-    );
-  };
+        )}
 
 
-  PermissionSettingForEachCommandComponent.propTypes = {
-    commandName: PropTypes.string,
-    commandUsageType: PropTypes.string,
-  };
-
-  const PermissionSettingsForEachCommandTypeComponent = ({ commandUsageType }) => {
-    const isCommandBroadcastUse = commandUsageType === CommandUsageTypes.BROADCAST_USE;
-    const defaultCommandsName = isCommandBroadcastUse ? defaultSupportedCommandsNameForBroadcastUse : defaultSupportedCommandsNameForSingleUse;
-    return (
-      <>
-        <div className="row">
-          <div className="col-md-7 offset-md-2">
-            <p className="font-weight-bold mb-1">{isCommandBroadcastUse ? 'Multiple GROWI' : 'Single GROWI'}</p>
-            <p className="text-muted">
-              {isCommandBroadcastUse
-                ? t('admin:slack_integration.accordion.multiple_growi_command')
-                : t('admin:slack_integration.accordion.single_growi_command')}
-            </p>
-          </div>
-        </div>
         <div className="custom-control custom-checkbox">
         <div className="custom-control custom-checkbox">
           <div className="row mb-5 d-block">
           <div className="row mb-5 d-block">
-            {defaultCommandsName.map((commandName) => {
-              // eslint-disable-next-line max-len
-              return <PermissionSettingForEachCommandComponent key={`${commandName}-component`} commandName={commandName} commandUsageType={commandUsageType} />;
-            })}
+            {defaultCommandsName.map(keyName => (
+              <PermissionSettingForEachPermissionTypeComponent
+                key={`${keyName}-component`}
+                keyName={keyName}
+                usageType={usageType}
+                permissionSettings={permissionMap[usageType]}
+                currentPermissionType={currentPermissionTypes[keyName]}
+                singleCommandDescription={singleCommandDescription}
+                onUpdatePermissions={updatePermissionsHandler}
+                onUpdateChannels={updateChannelsHandler}
+                allowedChannelsDescription={allowedChannelsDescription}
+              />
+            ))}
           </div>
           </div>
         </div>
         </div>
       </>
       </>
     );
     );
   };
   };
 
 
-  PermissionSettingsForEachCommandTypeComponent.propTypes = {
-    commandUsageType: PropTypes.string,
+
+  PermissionSettingsForEachCategoryComponent.propTypes = {
+    currentPermissionTypes: PropTypes.object,
+    usageType: PropTypes.string,
+    menuItem: PropTypes.object,
   };
   };
 
 
+  // Using i18n in allowedChannelsDescription will cause interpolation error
+  const menuMap = {
+    broadcastUse: {
+      title: 'Multiple GROWI',
+      description: t('admin:slack_integration.accordion.multiple_growi_command'),
+      defaultCommandsName: defaultSupportedCommandsNameForBroadcastUse,
+      updatePermissionsHandler: handleUpdateBroadcastUsePermissions,
+      updateChannelsHandler: handleUpdateBroadcastUseChannels,
+      allowedChannelsDescription: 'admin:slack_integration.accordion.allowed_channels_description',
+    },
+    singleUse: {
+      title: 'Single GROWI',
+      description: t('admin:slack_integration.accordion.single_growi_command'),
+      defaultCommandsName: defaultSupportedCommandsNameForSingleUse,
+      updatePermissionsHandler: handleUpdateSingleUsePermissions,
+      updateChannelsHandler: handleUpdateSingleUseChannels,
+      allowedChannelsDescription: 'admin:slack_integration.accordion.allowed_channels_description',
+    },
+    linkSharing: {
+      defaultCommandsName: defaultSupportedSlackEventActions,
+      updatePermissionsHandler: handleUpdateEventsPermissions,
+      updateChannelsHandler: handleUpdateEventsChannels,
+      singleCommandDescription: t('admin:slack_integration.accordion.unfurl_description'),
+      allowedChannelsDescription: 'admin:slack_integration.accordion.unfurl_allowed_channels_description',
+    },
+  };
 
 
   return (
   return (
     <div className="py-4 px-5">
     <div className="py-4 px-5">
-      <p className="mb-4 font-weight-bold">{t('admin:slack_integration.accordion.manage_commands')}</p>
+      <p className="mb-4 font-weight-bold">{t('admin:slack_integration.accordion.growi_commands')}</p>
       <div className="row d-flex flex-column align-items-center">
       <div className="row d-flex flex-column align-items-center">
+        <div className="col-8">
+          {Object.values(CommandUsageTypes).map(commandUsageType => (
+            <PermissionSettingsForEachCategoryComponent
+              key={commandUsageType}
+              currentPermissionTypes={currentPermissionTypes}
+              usageType={commandUsageType}
+              menuItem={menuMap[commandUsageType]}
+            />
+          ))}
+        </div>
+      </div>
 
 
+      <p className="mb-4 font-weight-bold">Events</p>
+      <div className="row d-flex flex-column align-items-center">
         <div className="col-8">
         <div className="col-8">
-          {Object.values(CommandUsageTypes).map((commandUsageType) => {
-            return <PermissionSettingsForEachCommandTypeComponent key={commandUsageType} commandUsageType={commandUsageType} />;
-          })}
+          {Object.values(EventTypes).map(EventType => (
+            <PermissionSettingsForEachCategoryComponent
+              key={EventType}
+              currentPermissionTypes={currentPermissionTypes}
+              usageType={EventType}
+              menuItem={menuMap[EventType]}
+            />
+          ))}
         </div>
         </div>
       </div>
       </div>
+
       <div className="row">
       <div className="row">
         <button
         <button
           type="submit"
           type="submit"
           className="btn btn-primary mx-auto"
           className="btn btn-primary mx-auto"
-          onClick={updateCommandsHandler}
+          onClick={updateSettingsHandler}
         >
         >
           { t('Update') }
           { t('Update') }
         </button>
         </button>
@@ -290,6 +405,7 @@ ManageCommandsProcess.propTypes = {
   slackAppIntegrationId: PropTypes.string.isRequired,
   slackAppIntegrationId: PropTypes.string.isRequired,
   permissionsForBroadcastUseCommands: PropTypes.object.isRequired,
   permissionsForBroadcastUseCommands: PropTypes.object.isRequired,
   permissionsForSingleUseCommands: PropTypes.object.isRequired,
   permissionsForSingleUseCommands: PropTypes.object.isRequired,
+  permissionsForSlackEventActions: PropTypes.object.isRequired,
 };
 };
 
 
 export default ManageCommandsProcess;
 export default ManageCommandsProcess;

+ 56 - 23
packages/app/src/components/Admin/SlackIntegration/ManageCommandsProcessWithoutProxy.jsx

@@ -1,7 +1,7 @@
 import React, { useCallback, useEffect, useState } from 'react';
 import React, { useCallback, useEffect, useState } from 'react';
 import PropTypes from 'prop-types';
 import PropTypes from 'prop-types';
 import { useTranslation } from 'react-i18next';
 import { useTranslation } from 'react-i18next';
-import { defaultSupportedCommandsNameForBroadcastUse, defaultSupportedCommandsNameForSingleUse } from '@growi/slack';
+import { defaultSupportedCommandsNameForBroadcastUse, defaultSupportedCommandsNameForSingleUse, defaultSupportedSlackEventActions } from '@growi/slack';
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 
 
 import { toastSuccess, toastError } from '../../../client/util/apiNotification';
 import { toastSuccess, toastError } from '../../../client/util/apiNotification';
@@ -49,7 +49,7 @@ const getUpdatedPermissionSettings = (commandPermissionObj, commandName, value)
 };
 };
 
 
 
 
-const PermissionSettingForEachCommandComponent = ({
+const SinglePermissionSettingComponent = ({
   commandName, editingCommandPermission, onPermissionTypeClicked, onPermissionListChanged,
   commandName, editingCommandPermission, onPermissionTypeClicked, onPermissionListChanged,
 }) => {
 }) => {
   const { t } = useTranslation();
   const { t } = useTranslation();
@@ -144,7 +144,7 @@ const PermissionSettingForEachCommandComponent = ({
   );
   );
 };
 };
 
 
-PermissionSettingForEachCommandComponent.propTypes = {
+SinglePermissionSettingComponent.propTypes = {
   commandName: PropTypes.string,
   commandName: PropTypes.string,
   editingCommandPermission: PropTypes.object,
   editingCommandPermission: PropTypes.object,
   onPermissionTypeClicked: PropTypes.func,
   onPermissionTypeClicked: PropTypes.func,
@@ -153,18 +153,10 @@ PermissionSettingForEachCommandComponent.propTypes = {
 
 
 
 
 // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
 // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
-const ManageCommandsProcessWithoutProxy = ({ apiv3Put, commandPermission }) => {
+const ManageCommandsProcessWithoutProxy = ({ apiv3Put, commandPermission, eventActionsPermission }) => {
   const { t } = useTranslation();
   const { t } = useTranslation();
   const [editingCommandPermission, setEditingCommandPermission] = useState({});
   const [editingCommandPermission, setEditingCommandPermission] = useState({});
-
-  const updatePermissionsCommandsState = useCallback((e) => {
-    const { target } = e;
-    const { name: commandName, value } = target;
-
-    // update state
-    setEditingCommandPermission(commandPermissionObj => getUpdatedPermissionSettings(commandPermissionObj, commandName, value));
-  }, []);
-
+  const [editingEventActionsPermission, setEditingEventActionsPermission] = useState({});
 
 
   useEffect(() => {
   useEffect(() => {
     if (commandPermission == null) {
     if (commandPermission == null) {
@@ -174,21 +166,43 @@ const ManageCommandsProcessWithoutProxy = ({ apiv3Put, commandPermission }) => {
     setEditingCommandPermission(updatedState);
     setEditingCommandPermission(updatedState);
   }, [commandPermission]);
   }, [commandPermission]);
 
 
-  const updateChannelsListState = useCallback((e) => {
+  useEffect(() => {
+    if (eventActionsPermission == null) {
+      return;
+    }
+    const updatedState = { ...eventActionsPermission };
+    setEditingEventActionsPermission(updatedState);
+  }, [eventActionsPermission]);
+
+  const updatePermissionsCommandsState = useCallback((e) => {
+    const { target } = e;
+    const { name: commandName, value } = target;
+    setEditingCommandPermission(commandPermissionObj => getUpdatedPermissionSettings(commandPermissionObj, commandName, value));
+  }, []);
+
+  const updatePermissionsEventsState = useCallback((e) => {
+    const { target } = e;
+    const { name: actionName, value } = target;
+    setEditingEventActionsPermission(eventActionPermissionObj => getUpdatedPermissionSettings(eventActionPermissionObj, actionName, value));
+  }, []);
+
+  const updateCommandsChannelsListState = useCallback((e) => {
     const { target } = e;
     const { target } = e;
     const { name: commandName, value } = target;
     const { name: commandName, value } = target;
-    // update state
-    setEditingCommandPermission((commandPermissionObj) => {
-      return {
-        ...getUpdatedChannelsList(commandPermissionObj, commandName, value),
-      };
-    });
+    setEditingCommandPermission(commandPermissionObj => ({ ...getUpdatedChannelsList(commandPermissionObj, commandName, value) }));
+  }, []);
+
+  const updateEventsChannelsListState = useCallback((e) => {
+    const { target } = e;
+    const { name: actionName, value } = target;
+    setEditingEventActionsPermission(eventActionPermissionObj => ({ ...getUpdatedChannelsList(eventActionPermissionObj, actionName, value) }));
   }, []);
   }, []);
 
 
   const updateCommandsHandler = async(e) => {
   const updateCommandsHandler = async(e) => {
     try {
     try {
       await apiv3Put('/slack-integration-settings/without-proxy/update-permissions', {
       await apiv3Put('/slack-integration-settings/without-proxy/update-permissions', {
         commandPermission: editingCommandPermission,
         commandPermission: editingCommandPermission,
+        eventActionsPermission: editingEventActionsPermission,
       });
       });
       toastSuccess(t('toaster.update_successed', { target: 'the permission for commands' }));
       toastSuccess(t('toaster.update_successed', { target: 'the permission for commands' }));
     }
     }
@@ -200,7 +214,7 @@ const ManageCommandsProcessWithoutProxy = ({ apiv3Put, commandPermission }) => {
 
 
   return (
   return (
     <div className="py-4 px-5">
     <div className="py-4 px-5">
-      <p className="mb-4 font-weight-bold">{t('admin:slack_integration.accordion.manage_commands')}</p>
+      <p className="mb-4 font-weight-bold">{t('admin:slack_integration.accordion.growi_commands')}</p>
       <div className="row d-flex flex-column align-items-center">
       <div className="row d-flex flex-column align-items-center">
         <div className="col-8">
         <div className="col-8">
           <div className="custom-control custom-checkbox">
           <div className="custom-control custom-checkbox">
@@ -208,12 +222,12 @@ const ManageCommandsProcessWithoutProxy = ({ apiv3Put, commandPermission }) => {
               { defaultCommandsName.map((commandName) => {
               { defaultCommandsName.map((commandName) => {
                 // eslint-disable-next-line max-len
                 // eslint-disable-next-line max-len
                 return (
                 return (
-                  <PermissionSettingForEachCommandComponent
+                  <SinglePermissionSettingComponent
                     key={`${commandName}-component`}
                     key={`${commandName}-component`}
                     commandName={commandName}
                     commandName={commandName}
                     editingCommandPermission={editingCommandPermission}
                     editingCommandPermission={editingCommandPermission}
                     onPermissionTypeClicked={updatePermissionsCommandsState}
                     onPermissionTypeClicked={updatePermissionsCommandsState}
-                    onPermissionListChanged={updateChannelsListState}
+                    onPermissionListChanged={updateCommandsChannelsListState}
                   />
                   />
                 );
                 );
               })}
               })}
@@ -221,6 +235,24 @@ const ManageCommandsProcessWithoutProxy = ({ apiv3Put, commandPermission }) => {
           </div>
           </div>
         </div>
         </div>
       </div>
       </div>
+      <p className="mb-4 font-weight-bold">Events</p>
+      <div className="row d-flex flex-column align-items-center">
+        <div className="col-8">
+          <div className="custom-control custom-checkbox">
+            <div className="row mb-5 d-block">
+              { defaultSupportedSlackEventActions.map(actionName => (
+                <SinglePermissionSettingComponent
+                  key={`${actionName}-component`}
+                  commandName={actionName}
+                  editingCommandPermission={editingEventActionsPermission}
+                  onPermissionTypeClicked={updatePermissionsEventsState}
+                  onPermissionListChanged={updateEventsChannelsListState}
+                />
+              ))}
+            </div>
+          </div>
+        </div>
+      </div>
       <div className="row">
       <div className="row">
         <button
         <button
           type="submit"
           type="submit"
@@ -237,6 +269,7 @@ const ManageCommandsProcessWithoutProxy = ({ apiv3Put, commandPermission }) => {
 ManageCommandsProcessWithoutProxy.propTypes = {
 ManageCommandsProcessWithoutProxy.propTypes = {
   apiv3Put: PropTypes.func,
   apiv3Put: PropTypes.func,
   commandPermission: PropTypes.object,
   commandPermission: PropTypes.object,
+  eventActionsPermission: PropTypes.object,
 };
 };
 
 
 export default ManageCommandsProcessWithoutProxy;
 export default ManageCommandsProcessWithoutProxy;

+ 11 - 1
packages/app/src/components/Admin/SlackIntegration/SlackIntegration.jsx

@@ -28,6 +28,7 @@ const SlackIntegration = (props) => {
   const [slackSigningSecretEnv, setSlackSigningSecretEnv] = useState('');
   const [slackSigningSecretEnv, setSlackSigningSecretEnv] = useState('');
   const [slackBotTokenEnv, setSlackBotTokenEnv] = useState('');
   const [slackBotTokenEnv, setSlackBotTokenEnv] = useState('');
   const [commandPermission, setCommandPermission] = useState(null);
   const [commandPermission, setCommandPermission] = useState(null);
+  const [eventActionsPermission, setEventActionsPermission] = useState(null);
   const [isDeleteConfirmModalShown, setIsDeleteConfirmModalShown] = useState(false);
   const [isDeleteConfirmModalShown, setIsDeleteConfirmModalShown] = useState(false);
   const [slackAppIntegrations, setSlackAppIntegrations] = useState();
   const [slackAppIntegrations, setSlackAppIntegrations] = useState();
   const [proxyServerUri, setProxyServerUri] = useState();
   const [proxyServerUri, setProxyServerUri] = useState();
@@ -41,7 +42,14 @@ const SlackIntegration = (props) => {
     try {
     try {
       const { data } = await appContainer.apiv3.get('/slack-integration-settings');
       const { data } = await appContainer.apiv3.get('/slack-integration-settings');
       const {
       const {
-        slackSigningSecret, slackBotToken, slackSigningSecretEnvVars, slackBotTokenEnvVars, slackAppIntegrations, proxyServerUri, commandPermission,
+        slackSigningSecret,
+        slackBotToken,
+        slackSigningSecretEnvVars,
+        slackBotTokenEnvVars,
+        slackAppIntegrations,
+        proxyServerUri,
+        commandPermission,
+        eventActionsPermission,
       } = data.settings;
       } = data.settings;
 
 
       setErrorMsg(data.errorMsg);
       setErrorMsg(data.errorMsg);
@@ -55,6 +63,7 @@ const SlackIntegration = (props) => {
       setSlackAppIntegrations(slackAppIntegrations);
       setSlackAppIntegrations(slackAppIntegrations);
       setProxyServerUri(proxyServerUri);
       setProxyServerUri(proxyServerUri);
       setCommandPermission(commandPermission);
       setCommandPermission(commandPermission);
+      setEventActionsPermission(eventActionsPermission);
     }
     }
     catch (err) {
     catch (err) {
       toastError(err);
       toastError(err);
@@ -154,6 +163,7 @@ const SlackIntegration = (props) => {
           onUpdatedSecretToken={changeSecretAndToken}
           onUpdatedSecretToken={changeSecretAndToken}
           connectionStatuses={connectionStatuses}
           connectionStatuses={connectionStatuses}
           commandPermission={commandPermission}
           commandPermission={commandPermission}
+          eventActionsPermission={eventActionsPermission}
         />
         />
       );
       );
       break;
       break;

+ 5 - 2
packages/app/src/components/Admin/SlackIntegration/WithProxyAccordions.jsx

@@ -340,12 +340,13 @@ const WithProxyAccordions = (props) => {
       />,
       />,
     },
     },
     '③': {
     '③': {
-      title: 'manage_commands',
+      title: 'manage_permission',
       content: <ManageCommandsProcess
       content: <ManageCommandsProcess
         apiv3Put={props.appContainer.apiv3.put}
         apiv3Put={props.appContainer.apiv3.put}
         slackAppIntegrationId={props.slackAppIntegrationId}
         slackAppIntegrationId={props.slackAppIntegrationId}
         permissionsForBroadcastUseCommands={props.permissionsForBroadcastUseCommands}
         permissionsForBroadcastUseCommands={props.permissionsForBroadcastUseCommands}
         permissionsForSingleUseCommands={props.permissionsForSingleUseCommands}
         permissionsForSingleUseCommands={props.permissionsForSingleUseCommands}
+        permissionsForSlackEventActions={props.permissionsForSlackEventActions}
       />,
       />,
     },
     },
     '④': {
     '④': {
@@ -384,12 +385,13 @@ const WithProxyAccordions = (props) => {
       content: <RegisteringProxyUrlProcess />,
       content: <RegisteringProxyUrlProcess />,
     },
     },
     '⑤': {
     '⑤': {
-      title: 'manage_commands',
+      title: 'manage_permission',
       content: <ManageCommandsProcess
       content: <ManageCommandsProcess
         apiv3Put={props.appContainer.apiv3.put}
         apiv3Put={props.appContainer.apiv3.put}
         slackAppIntegrationId={props.slackAppIntegrationId}
         slackAppIntegrationId={props.slackAppIntegrationId}
         permissionsForBroadcastUseCommands={props.permissionsForBroadcastUseCommands}
         permissionsForBroadcastUseCommands={props.permissionsForBroadcastUseCommands}
         permissionsForSingleUseCommands={props.permissionsForSingleUseCommands}
         permissionsForSingleUseCommands={props.permissionsForSingleUseCommands}
+        permissionsForSlackEventActions={props.permissionsForSlackEventActions}
       />,
       />,
     },
     },
     '⑥': {
     '⑥': {
@@ -443,6 +445,7 @@ WithProxyAccordions.propTypes = {
   tokenGtoP: PropTypes.string,
   tokenGtoP: PropTypes.string,
   permissionsForBroadcastUseCommands: PropTypes.object.isRequired,
   permissionsForBroadcastUseCommands: PropTypes.object.isRequired,
   permissionsForSingleUseCommands: PropTypes.object.isRequired,
   permissionsForSingleUseCommands: PropTypes.object.isRequired,
+  permissionsForSlackEventActions: PropTypes.object.isRequired,
 };
 };
 
 
 export default WithProxyAccordionsWrapper;
 export default WithProxyAccordionsWrapper;

+ 12 - 23
packages/app/src/components/BookmarkButton.jsx

@@ -10,7 +10,7 @@ import { apiv3Put } from '~/client/util/apiv3-client';
 
 
 import AppContainer from '~/client/services/AppContainer';
 import AppContainer from '~/client/services/AppContainer';
 
 
-class BookmarkButton extends React.Component {
+class LegacyBookmarkButton extends React.Component {
 
 
   constructor(props) {
   constructor(props) {
     super(props);
     super(props);
@@ -19,25 +19,11 @@ class BookmarkButton extends React.Component {
   }
   }
 
 
   async handleClick() {
   async handleClick() {
-    const {
-      appContainer, pageId, isBookmarked, onChangeInvoked,
-    } = this.props;
-    const { isGuestUser } = appContainer;
 
 
-    if (isGuestUser) {
+    if (this.props.onBookMarkClicked == null) {
       return;
       return;
     }
     }
-
-    try {
-      const bool = !isBookmarked;
-      await apiv3Put('/bookmarks', { pageId, bool });
-      if (onChangeInvoked != null) {
-        onChangeInvoked();
-      }
-    }
-    catch (err) {
-      toastError(err);
-    }
+    this.props.onBookMarkClicked();
   }
   }
 
 
   render() {
   render() {
@@ -77,21 +63,24 @@ class BookmarkButton extends React.Component {
 /**
 /**
  * Wrapper component for using unstated
  * Wrapper component for using unstated
  */
  */
-const BookmarkButtonWrapper = withUnstatedContainers(BookmarkButton, [AppContainer]);
+const LegacyBookmarkButtonWrapper = withUnstatedContainers(LegacyBookmarkButton, [AppContainer]);
 
 
-BookmarkButton.propTypes = {
+LegacyBookmarkButton.propTypes = {
   appContainer: PropTypes.instanceOf(AppContainer).isRequired,
   appContainer: PropTypes.instanceOf(AppContainer).isRequired,
 
 
-  pageId: PropTypes.string.isRequired,
   isBookmarked: PropTypes.bool.isRequired,
   isBookmarked: PropTypes.bool.isRequired,
   sumOfBookmarks: PropTypes.number,
   sumOfBookmarks: PropTypes.number,
-  onChangeInvoked: PropTypes.func,
   t: PropTypes.func.isRequired,
   t: PropTypes.func.isRequired,
   size: PropTypes.string,
   size: PropTypes.string,
+  onBookMarkClicked: PropTypes.func,
 };
 };
 
 
-BookmarkButton.defaultProps = {
+LegacyBookmarkButton.defaultProps = {
   size: 'md',
   size: 'md',
 };
 };
 
 
-export default withTranslation()(BookmarkButtonWrapper);
+const BookmarkButton = (props) => {
+  return <LegacyBookmarkButtonWrapper {...props}></LegacyBookmarkButtonWrapper>;
+};
+
+export default withTranslation()(BookmarkButton);

+ 3 - 11
packages/app/src/components/ComparePathsTable.jsx

@@ -3,17 +3,14 @@ import PropTypes from 'prop-types';
 
 
 import { withTranslation } from 'react-i18next';
 import { withTranslation } from 'react-i18next';
 import { pagePathUtils } from '@growi/core';
 import { pagePathUtils } from '@growi/core';
-import { withUnstatedContainers } from './UnstatedUtils';
 
 
-import PageContainer from '~/client/services/PageContainer';
 
 
 const { convertToNewAffiliationPath } = pagePathUtils;
 const { convertToNewAffiliationPath } = pagePathUtils;
 
 
 function ComparePathsTable(props) {
 function ComparePathsTable(props) {
   const {
   const {
-    subordinatedPages, pageContainer, newPagePath, t,
+    path, subordinatedPages, newPagePath, t,
   } = props;
   } = props;
-  const { path } = pageContainer.state;
 
 
   return (
   return (
     <table className="table table-bordered grw-compare-paths-table">
     <table className="table table-bordered grw-compare-paths-table">
@@ -45,18 +42,13 @@ function ComparePathsTable(props) {
 }
 }
 
 
 
 
-/**
- * Wrapper component for using unstated
- */
-const PageDuplicateModallWrapper = withUnstatedContainers(ComparePathsTable, [PageContainer]);
-
 ComparePathsTable.propTypes = {
 ComparePathsTable.propTypes = {
   t: PropTypes.func.isRequired, //  i18next
   t: PropTypes.func.isRequired, //  i18next
 
 
-  pageContainer: PropTypes.instanceOf(PageContainer).isRequired,
+  path: PropTypes.string.isRequired,
   subordinatedPages: PropTypes.array.isRequired,
   subordinatedPages: PropTypes.array.isRequired,
   newPagePath: PropTypes.string.isRequired,
   newPagePath: PropTypes.string.isRequired,
 };
 };
 
 
 
 
-export default withTranslation()(PageDuplicateModallWrapper);
+export default withTranslation()(ComparePathsTable);

+ 3 - 13
packages/app/src/components/CreateTemplateModal.jsx

@@ -6,14 +6,11 @@ import { Modal, ModalHeader, ModalBody } from 'reactstrap';
 import { withTranslation } from 'react-i18next';
 import { withTranslation } from 'react-i18next';
 import { pathUtils } from '@growi/core';
 import { pathUtils } from '@growi/core';
 import urljoin from 'url-join';
 import urljoin from 'url-join';
-import { withUnstatedContainers } from './UnstatedUtils';
 
 
-import PageContainer from '~/client/services/PageContainer';
 
 
 const CreateTemplateModal = (props) => {
 const CreateTemplateModal = (props) => {
-  const { t, pageContainer } = props;
+  const { t, path } = props;
 
 
-  const { path } = pageContainer.state;
   const parentPath = pathUtils.addTrailingSlash(path);
   const parentPath = pathUtils.addTrailingSlash(path);
 
 
   function generateUrl(label) {
   function generateUrl(label) {
@@ -67,18 +64,11 @@ const CreateTemplateModal = (props) => {
 };
 };
 
 
 
 
-/**
- * Wrapper component for using unstated
- */
-const CreateTemplateModalWrapper = withUnstatedContainers(CreateTemplateModal, [PageContainer]);
-
-
 CreateTemplateModal.propTypes = {
 CreateTemplateModal.propTypes = {
   t: PropTypes.func.isRequired, //  i18next
   t: PropTypes.func.isRequired, //  i18next
-  pageContainer: PropTypes.instanceOf(PageContainer).isRequired,
-
+  path: PropTypes.string.isRequired,
   isOpen: PropTypes.bool.isRequired,
   isOpen: PropTypes.bool.isRequired,
   onClose: PropTypes.func.isRequired,
   onClose: PropTypes.func.isRequired,
 };
 };
 
 
-export default withTranslation()(CreateTemplateModalWrapper);
+export default withTranslation()(CreateTemplateModal);

+ 5 - 1
packages/app/src/components/Fab.jsx

@@ -6,6 +6,8 @@ import loggerFactory from '~/utils/logger';
 
 
 import AppContainer from '~/client/services/AppContainer';
 import AppContainer from '~/client/services/AppContainer';
 import NavigationContainer from '~/client/services/NavigationContainer';
 import NavigationContainer from '~/client/services/NavigationContainer';
+import { usePageCreateModalOpened } from '~/stores/ui';
+
 import { withUnstatedContainers } from './UnstatedUtils';
 import { withUnstatedContainers } from './UnstatedUtils';
 import CreatePageIcon from './Icons/CreatePageIcon';
 import CreatePageIcon from './Icons/CreatePageIcon';
 import ReturnTopIcon from './Icons/ReturnTopIcon';
 import ReturnTopIcon from './Icons/ReturnTopIcon';
@@ -16,6 +18,8 @@ const Fab = (props) => {
   const { navigationContainer, appContainer } = props;
   const { navigationContainer, appContainer } = props;
   const { currentUser } = appContainer;
   const { currentUser } = appContainer;
 
 
+  const { mutate: mutatePageCreateModalOpened } = usePageCreateModalOpened();
+
   const [animateClasses, setAnimateClasses] = useState('invisible');
   const [animateClasses, setAnimateClasses] = useState('invisible');
   const [buttonClasses, setButtonClasses] = useState('');
   const [buttonClasses, setButtonClasses] = useState('');
 
 
@@ -52,7 +56,7 @@ const Fab = (props) => {
           <button
           <button
             type="button"
             type="button"
             className={`btn btn-lg btn-create-page btn-primary rounded-circle p-0 waves-effect waves-light ${buttonClasses}`}
             className={`btn btn-lg btn-create-page btn-primary rounded-circle p-0 waves-effect waves-light ${buttonClasses}`}
-            onClick={navigationContainer.openPageCreateModal}
+            onClick={() => mutatePageCreateModalOpened(true)}
           >
           >
             <CreatePageIcon />
             <CreatePageIcon />
           </button>
           </button>

+ 9 - 11
packages/app/src/components/Hotkeys/Subscribers/CreatePage.jsx

@@ -1,31 +1,29 @@
 import React, { useEffect } from 'react';
 import React, { useEffect } from 'react';
 import PropTypes from 'prop-types';
 import PropTypes from 'prop-types';
 
 
-import NavigationContainer from '~/client/services/NavigationContainer';
-import { withUnstatedContainers } from '../../UnstatedUtils';
+import { usePageCreateModalOpened } from '~/stores/ui';
 
 
-const CreatePage = (props) => {
+const CreatePage = React.memo((props) => {
+
+  const { mutate } = usePageCreateModalOpened();
 
 
   // setup effect
   // setup effect
   useEffect(() => {
   useEffect(() => {
-    props.navigationContainer.openPageCreateModal();
+    mutate(true);
 
 
     // remove this
     // remove this
     props.onDeleteRender(this);
     props.onDeleteRender(this);
-  }, [props]);
+  }, [mutate, props]);
 
 
   return <></>;
   return <></>;
-};
+});
 
 
 CreatePage.propTypes = {
 CreatePage.propTypes = {
-  navigationContainer: PropTypes.instanceOf(NavigationContainer).isRequired,
   onDeleteRender: PropTypes.func.isRequired,
   onDeleteRender: PropTypes.func.isRequired,
 };
 };
 
 
-const CreatePageWrapper = withUnstatedContainers(CreatePage, [NavigationContainer]);
-
-CreatePageWrapper.getHotkeyStrokes = () => {
+CreatePage.getHotkeyStrokes = () => {
   return [['c']];
   return [['c']];
 };
 };
 
 
-export default CreatePageWrapper;
+export default CreatePage;

+ 4 - 2
packages/app/src/components/Hotkeys/Subscribers/EditPage.jsx

@@ -3,8 +3,10 @@ import PropTypes from 'prop-types';
 
 
 import NavigationContainer from '~/client/services/NavigationContainer';
 import NavigationContainer from '~/client/services/NavigationContainer';
 import { withUnstatedContainers } from '../../UnstatedUtils';
 import { withUnstatedContainers } from '../../UnstatedUtils';
+import { EditorMode, useEditorMode } from '~/stores/ui';
 
 
 const EditPage = (props) => {
 const EditPage = (props) => {
+  const { mutate: mutateEditorMode } = useEditorMode();
 
 
   // setup effect
   // setup effect
   useEffect(() => {
   useEffect(() => {
@@ -13,11 +15,11 @@ const EditPage = (props) => {
       return;
       return;
     }
     }
 
 
-    props.navigationContainer.setEditorMode('edit');
+    mutateEditorMode(EditorMode.Editor);
 
 
     // remove this
     // remove this
     props.onDeleteRender(this);
     props.onDeleteRender(this);
-  }, [props]);
+  }, [mutateEditorMode, props]);
 
 
   return <></>;
   return <></>;
 };
 };

+ 3 - 3
packages/app/src/components/Icons/GrowiLogo.jsx

@@ -1,6 +1,6 @@
-import React from 'react';
+import React, { memo } from 'react';
 
 
-const GrowiLogo = () => (
+const GrowiLogo = memo(() => (
   <svg
   <svg
     xmlns="http://www.w3.org/2000/svg"
     xmlns="http://www.w3.org/2000/svg"
     width="32"
     width="32"
@@ -29,6 +29,6 @@ const GrowiLogo = () => (
     >
     >
     </path>
     </path>
   </svg>
   </svg>
-);
+));
 
 
 export default GrowiLogo;
 export default GrowiLogo;

+ 0 - 102
packages/app/src/components/LikeButtons.jsx

@@ -1,102 +0,0 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-
-import { UncontrolledTooltip, Popover, PopoverBody } from 'reactstrap';
-import { withTranslation } from 'react-i18next';
-import UserPictureList from './User/UserPictureList';
-import { withUnstatedContainers } from './UnstatedUtils';
-
-import { toastError } from '~/client/util/apiNotification';
-import AppContainer from '~/client/services/AppContainer';
-import PageContainer from '~/client/services/PageContainer';
-
-class LikeButtons extends React.Component {
-
-  constructor(props) {
-    super(props);
-
-    this.state = {
-      isPopoverOpen: false,
-    };
-
-    this.togglePopover = this.togglePopover.bind(this);
-    this.handleClick = this.handleClick.bind(this);
-  }
-
-  togglePopover() {
-    this.setState(prevState => ({
-      ...prevState,
-      isPopoverOpen: !prevState.isPopoverOpen,
-    }));
-  }
-
-  async handleClick() {
-    const { appContainer, pageContainer } = this.props;
-    const { isGuestUser } = appContainer;
-
-    if (isGuestUser) {
-      return;
-    }
-
-    try {
-      pageContainer.toggleLike();
-    }
-    catch (err) {
-      toastError(err);
-    }
-  }
-
-  render() {
-    const { appContainer, pageContainer, t } = this.props;
-    const { isGuestUser } = appContainer;
-    const {
-      state: { likers, sumOfLikers, isLiked },
-    } = pageContainer;
-
-    return (
-      <div className="btn-group" role="group" aria-label="Like buttons">
-        <button
-          type="button"
-          id="like-button"
-          onClick={this.handleClick}
-          className={`btn btn-like border-0
-            ${isLiked ? 'active' : ''} ${isGuestUser ? 'disabled' : ''}`}
-        >
-          <i className="icon-like"></i>
-        </button>
-        {isGuestUser && (
-          <UncontrolledTooltip placement="top" target="like-button" fade={false}>
-            {t('Not available for guest')}
-          </UncontrolledTooltip>
-        )}
-
-        <button type="button" id="po-total-likes" className={`btn btn-like border-0 total-likes ${isLiked ? 'active' : ''}`}>
-          {sumOfLikers}
-        </button>
-        <Popover placement="bottom" isOpen={this.state.isPopoverOpen} target="po-total-likes" toggle={this.togglePopover} trigger="legacy">
-          <PopoverBody className="seen-user-popover">
-            <div className="px-2 text-right user-list-content text-truncate text-muted">
-              {likers.length ? <UserPictureList users={likers} /> : t('No users have liked this yet.')}
-            </div>
-          </PopoverBody>
-        </Popover>
-      </div>
-    );
-  }
-
-}
-
-/**
- * Wrapper component for using unstated
- */
-const LikeButtonsWrapper = withUnstatedContainers(LikeButtons, [AppContainer, PageContainer]);
-
-LikeButtons.propTypes = {
-  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
-  pageContainer: PropTypes.instanceOf(PageContainer).isRequired,
-
-  t: PropTypes.func.isRequired,
-  size: PropTypes.string,
-};
-
-export default withTranslation()(LikeButtonsWrapper);

+ 81 - 0
packages/app/src/components/LikeButtons.tsx

@@ -0,0 +1,81 @@
+import React, { FC, useState } from 'react';
+
+import { UncontrolledTooltip, Popover, PopoverBody } from 'reactstrap';
+import { withTranslation } from 'react-i18next';
+import UserPictureList from './User/UserPictureList';
+import { withUnstatedContainers } from './UnstatedUtils';
+
+import AppContainer from '~/client/services/AppContainer';
+import { IUser } from '../interfaces/user';
+
+type LikeButtonsProps = {
+  appContainer: AppContainer,
+  sumOfLikers: number,
+  isLiked: boolean,
+  likers: IUser[],
+  onLikeClicked?: ()=>void,
+  t: (s:string)=>string,
+}
+
+const LikeButtons: FC<LikeButtonsProps> = (props: LikeButtonsProps) => {
+  const [isPopoverOpen, setIsPopoverOpen] = useState(false);
+
+  const togglePopover = () => {
+    setIsPopoverOpen(!isPopoverOpen);
+  };
+
+
+  const handleClick = () => {
+    if (props.onLikeClicked == null) {
+      return;
+    }
+    props.onLikeClicked();
+  };
+
+  const {
+    appContainer, isLiked, sumOfLikers, t,
+  } = props;
+  const { isGuestUser } = appContainer;
+
+  return (
+    <div className="btn-group" role="group" aria-label="Like buttons">
+      <button
+        type="button"
+        id="like-button"
+        onClick={handleClick}
+        className={`btn btn-like border-0
+            ${isLiked ? 'active' : ''} ${isGuestUser ? 'disabled' : ''}`}
+      >
+        <i className="icon-like"></i>
+      </button>
+      {isGuestUser && (
+        <UncontrolledTooltip placement="top" target="like-button" fade={false}>
+          {t('Not available for guest')}
+        </UncontrolledTooltip>
+      )}
+
+      <button type="button" id="po-total-likes" className={`btn btn-like border-0 total-likes ${isLiked ? 'active' : ''}`}>
+        {sumOfLikers}
+      </button>
+      <Popover placement="bottom" isOpen={isPopoverOpen} target="po-total-likes" toggle={togglePopover} trigger="legacy">
+        <PopoverBody className="seen-user-popover">
+          <div className="px-2 text-right user-list-content text-truncate text-muted">
+            {props.likers.length ? <UserPictureList users={props.likers} /> : t('No users have liked this yet.')}
+          </div>
+        </PopoverBody>
+      </Popover>
+    </div>
+  );
+
+};
+
+/**
+ * Wrapper component for using unstated
+ */
+const LikeButtonsUnstatedWrapper = withUnstatedContainers(LikeButtons, [AppContainer]);
+
+const LikeButtonsWrapper = (props) => {
+  return <LikeButtonsUnstatedWrapper {...props}></LikeButtonsUnstatedWrapper>;
+};
+
+export default withTranslation()(LikeButtonsWrapper);

+ 0 - 46
packages/app/src/components/Navbar/DrawerToggler.jsx

@@ -1,46 +0,0 @@
-import React, { useCallback } from 'react';
-import PropTypes from 'prop-types';
-
-import { withTranslation } from 'react-i18next';
-
-import { withUnstatedContainers } from '../UnstatedUtils';
-import NavigationContainer from '~/client/services/NavigationContainer';
-
-const DrawerToggler = (props) => {
-
-  const { navigationContainer } = props;
-
-  const clickHandler = useCallback(() => {
-    navigationContainer.toggleDrawer();
-  }, [navigationContainer]);
-
-  const iconClass = props.iconClass || 'icon-menu';
-
-  return (
-    <button
-      className="grw-drawer-toggler btn btn-secondary"
-      type="button"
-      aria-expanded="false"
-      aria-label="Toggle navigation"
-      onClick={clickHandler}
-    >
-      <i className={iconClass}></i>
-    </button>
-  );
-
-};
-
-/**
- * Wrapper component for using unstated
- */
-const DrawerTogglerWrapper = withUnstatedContainers(DrawerToggler, [NavigationContainer]);
-
-
-DrawerToggler.propTypes = {
-  t: PropTypes.func.isRequired, //  i18next
-  navigationContainer: PropTypes.instanceOf(NavigationContainer).isRequired,
-
-  iconClass: PropTypes.string,
-};
-
-export default withTranslation()(DrawerTogglerWrapper);

+ 28 - 0
packages/app/src/components/Navbar/DrawerToggler.tsx

@@ -0,0 +1,28 @@
+import React, { FC } from 'react';
+import { useDrawerOpened } from '~/stores/ui';
+
+type Props = {
+  iconClass?: string,
+}
+
+const DrawerToggler: FC<Props> = (props: Props) => {
+
+  const { data: isOpened, mutate } = useDrawerOpened();
+
+  const iconClass = props.iconClass || 'icon-menu';
+
+  return (
+    <button
+      className="grw-drawer-toggler btn btn-secondary"
+      type="button"
+      aria-expanded="false"
+      aria-label="Toggle navigation"
+      onClick={() => mutate(!isOpened)}
+    >
+      <i className={iconClass}></i>
+    </button>
+  );
+
+};
+
+export default DrawerToggler;

+ 0 - 115
packages/app/src/components/Navbar/GrowiNavbar.jsx

@@ -1,115 +0,0 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-
-import { withTranslation } from 'react-i18next';
-
-import { UncontrolledTooltip } from 'reactstrap';
-import { withUnstatedContainers } from '../UnstatedUtils';
-import NavigationContainer from '~/client/services/NavigationContainer';
-import AppContainer from '~/client/services/AppContainer';
-
-
-import GrowiLogo from '../Icons/GrowiLogo';
-
-import PersonalDropdown from './PersonalDropdown';
-import GlobalSearch from './GlobalSearch';
-
-class GrowiNavbar extends React.Component {
-
-  renderNavbarRight() {
-    const { t, appContainer, navigationContainer } = this.props;
-    const { currentUser } = appContainer;
-
-    // render login button
-    if (currentUser == null) {
-      return <li id="login-user" className="nav-item"><a className="nav-link" href="/login">Login</a></li>;
-    }
-
-    return (
-      <>
-        <li className="nav-item d-none d-md-block">
-          <button className="px-md-2 nav-link btn-create-page border-0 bg-transparent" type="button" onClick={navigationContainer.openPageCreateModal}>
-            <i className="icon-pencil mr-2"></i>
-            <span className="d-none d-lg-block">{ t('New') }</span>
-          </button>
-        </li>
-
-        <li className="grw-personal-dropdown nav-item dropdown dropdown-toggle dropdown-toggle-no-caret">
-          <PersonalDropdown />
-        </li>
-      </>
-    );
-  }
-
-  renderConfidential() {
-    const { appContainer } = this.props;
-    const { crowi } = appContainer.config;
-
-    return (
-      <li className="nav-item confidential text-light">
-        <i id="confidentialTooltip" className="icon-info d-md-none" />
-        <span className="d-none d-md-inline">
-          {crowi.confidential}
-        </span>
-        <UncontrolledTooltip
-          placement="bottom"
-          target="confidentialTooltip"
-          className="d-md-none"
-        >
-          {crowi.confidential}
-        </UncontrolledTooltip>
-      </li>
-    );
-  }
-
-  render() {
-    const { appContainer, navigationContainer } = this.props;
-    const { crowi, isSearchServiceConfigured } = appContainer.config;
-    const { isDeviceSmallerThanMd } = navigationContainer.state;
-
-    return (
-      <>
-
-        {/* Brand Logo  */}
-        <div className="navbar-brand mr-0">
-          <a className="grw-logo d-block" href="/">
-            <GrowiLogo />
-          </a>
-        </div>
-
-        <div className="grw-app-title d-none d-md-block">
-          {crowi.title}
-        </div>
-
-
-        {/* Navbar Right  */}
-        <ul className="navbar-nav ml-auto">
-          {this.renderNavbarRight()}
-          {crowi.confidential != null && this.renderConfidential()}
-        </ul>
-
-        { isSearchServiceConfigured && !isDeviceSmallerThanMd && (
-          <div className="grw-global-search grw-global-search-top position-absolute">
-            <GlobalSearch />
-          </div>
-        ) }
-      </>
-    );
-  }
-
-}
-
-/**
- * Wrapper component for using unstated
- */
-const GrowiNavbarWrapper = withUnstatedContainers(GrowiNavbar, [AppContainer, NavigationContainer]);
-
-
-GrowiNavbar.propTypes = {
-  t: PropTypes.func.isRequired, //  i18next
-
-  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
-  navigationContainer: PropTypes.instanceOf(NavigationContainer).isRequired,
-};
-
-export default withTranslation()(GrowiNavbarWrapper);

+ 128 - 0
packages/app/src/components/Navbar/GrowiNavbar.tsx

@@ -0,0 +1,128 @@
+import React, { FC, memo } from 'react';
+import PropTypes from 'prop-types';
+
+import { useTranslation } from 'react-i18next';
+
+import { UncontrolledTooltip } from 'reactstrap';
+
+import AppContainer from '~/client/services/AppContainer';
+import { IUser } from '~/interfaces/user';
+import { useIsDeviceSmallerThanMd, usePageCreateModalOpened } from '~/stores/ui';
+
+import { withUnstatedContainers } from '../UnstatedUtils';
+import GrowiLogo from '../Icons/GrowiLogo';
+
+import PersonalDropdown from './PersonalDropdown';
+import GlobalSearch from './GlobalSearch';
+
+type NavbarRightProps = {
+  currentUser: IUser,
+}
+const NavbarRight: FC<NavbarRightProps> = memo((props: NavbarRightProps) => {
+  const { t } = useTranslation();
+  const { mutate: mutatePageCreateModalOpened } = usePageCreateModalOpened();
+
+  const { currentUser } = props;
+
+  // render login button
+  if (currentUser == null) {
+    return <li id="login-user" className="nav-item"><a className="nav-link" href="/login">Login</a></li>;
+  }
+
+  return (
+    <>
+      <li className="nav-item d-none d-md-block">
+        <button
+          className="px-md-2 nav-link btn-create-page border-0 bg-transparent"
+          type="button"
+          onClick={() => mutatePageCreateModalOpened(true)}
+        >
+          <i className="icon-pencil mr-2"></i>
+          <span className="d-none d-lg-block">{ t('New') }</span>
+        </button>
+      </li>
+
+      <li className="grw-personal-dropdown nav-item dropdown dropdown-toggle dropdown-toggle-no-caret">
+        <PersonalDropdown />
+      </li>
+    </>
+  );
+});
+
+type ConfidentialProps = {
+  confidential?: string,
+}
+const Confidential: FC<ConfidentialProps> = memo((props: ConfidentialProps) => {
+  const { confidential } = props;
+
+  if (confidential == null) {
+    return null;
+  }
+
+  return (
+    <li className="nav-item confidential text-light">
+      <i id="confidentialTooltip" className="icon-info d-md-none" />
+      <span className="d-none d-md-inline">
+        {confidential}
+      </span>
+      <UncontrolledTooltip
+        placement="bottom"
+        target="confidentialTooltip"
+        className="d-md-none"
+      >
+        {confidential}
+      </UncontrolledTooltip>
+    </li>
+  );
+});
+
+
+const GrowiNavbar = (props) => {
+
+  const { appContainer } = props;
+  const { currentUser } = appContainer;
+  const { crowi, isSearchServiceConfigured } = appContainer.config;
+
+  const { data: isDeviceSmallerThanMd } = useIsDeviceSmallerThanMd();
+
+  return (
+    <>
+      {/* Brand Logo  */}
+      <div className="navbar-brand mr-0">
+        <a className="grw-logo d-block" href="/">
+          <GrowiLogo />
+        </a>
+      </div>
+
+      <div className="grw-app-title d-none d-md-block">
+        {crowi.title}
+      </div>
+
+
+      {/* Navbar Right  */}
+      <ul className="navbar-nav ml-auto">
+        <NavbarRight currentUser={currentUser}></NavbarRight>
+        <Confidential confidential={crowi.confidential}></Confidential>
+      </ul>
+
+      { isSearchServiceConfigured && !isDeviceSmallerThanMd && (
+        <div className="grw-global-search grw-global-search-top position-absolute">
+          <GlobalSearch />
+        </div>
+      ) }
+    </>
+  );
+
+};
+
+/**
+ * Wrapper component for using unstated
+ */
+const GrowiNavbarWrapper = withUnstatedContainers(GrowiNavbar, [AppContainer]);
+
+
+GrowiNavbar.propTypes = {
+  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
+};
+
+export default GrowiNavbarWrapper;

+ 8 - 4
packages/app/src/components/Navbar/GrowiNavbarBottom.jsx

@@ -2,8 +2,9 @@ import React from 'react';
 import PropTypes from 'prop-types';
 import PropTypes from 'prop-types';
 
 
 import NavigationContainer from '~/client/services/NavigationContainer';
 import NavigationContainer from '~/client/services/NavigationContainer';
-import { withUnstatedContainers } from '../UnstatedUtils';
+import { usePageCreateModalOpened, useIsDeviceSmallerThanMd, useDrawerOpened } from '~/stores/ui';
 
 
+import { withUnstatedContainers } from '../UnstatedUtils';
 import GlobalSearch from './GlobalSearch';
 import GlobalSearch from './GlobalSearch';
 
 
 const GrowiNavbarBottom = (props) => {
 const GrowiNavbarBottom = (props) => {
@@ -11,7 +12,10 @@ const GrowiNavbarBottom = (props) => {
   const {
   const {
     navigationContainer,
     navigationContainer,
   } = props;
   } = props;
-  const { isDrawerOpened, isDeviceSmallerThanMd } = navigationContainer.state;
+
+  const { data: isDrawerOpened, mutate: mutateDrawerOpened } = useDrawerOpened();
+  const { data: isDeviceSmallerThanMd } = useIsDeviceSmallerThanMd();
+  const { mutate: mutatePageCreateModalOpened } = usePageCreateModalOpened();
 
 
   const additionalClasses = ['grw-navbar-bottom'];
   const additionalClasses = ['grw-navbar-bottom'];
   if (isDrawerOpened) {
   if (isDrawerOpened) {
@@ -36,7 +40,7 @@ const GrowiNavbarBottom = (props) => {
             <a
             <a
               role="button"
               role="button"
               className="nav-link btn-lg"
               className="nav-link btn-lg"
-              onClick={() => navigationContainer.toggleDrawer()}
+              onClick={() => mutateDrawerOpened(true)}
             >
             >
               <i className="icon-menu"></i>
               <i className="icon-menu"></i>
             </a>
             </a>
@@ -55,7 +59,7 @@ const GrowiNavbarBottom = (props) => {
             <a
             <a
               role="button"
               role="button"
               className="nav-link btn-lg"
               className="nav-link btn-lg"
-              onClick={() => navigationContainer.openPageCreateModal()}
+              onClick={() => mutatePageCreateModalOpened(true)}
             >
             >
               <i className="icon-pencil"></i>
               <i className="icon-pencil"></i>
             </a>
             </a>

+ 68 - 70
packages/app/src/components/Navbar/GrowiSubNavigation.jsx

@@ -1,89 +1,82 @@
-import React from 'react';
+import React, { useCallback } from 'react';
 import PropTypes from 'prop-types';
 import PropTypes from 'prop-types';
 
 
-import { withTranslation } from 'react-i18next';
-
-import { DevidedPagePath } from '@growi/core';
-import PagePathHierarchicalLink from '~/components/PagePathHierarchicalLink';
-import LinkedPagePath from '~/models/linked-page-path';
-
 import { withUnstatedContainers } from '../UnstatedUtils';
 import { withUnstatedContainers } from '../UnstatedUtils';
 import AppContainer from '~/client/services/AppContainer';
 import AppContainer from '~/client/services/AppContainer';
-import NavigationContainer from '~/client/services/NavigationContainer';
 import PageContainer from '~/client/services/PageContainer';
 import PageContainer from '~/client/services/PageContainer';
+import {
+  EditorMode, useDrawerMode, useEditorMode, useIsDeviceSmallerThanMd,
+} from '~/stores/ui';
+import EditorContainer from '~/client/services/EditorContainer';
 
 
-import CopyDropdown from '../Page/CopyDropdown';
 import TagLabels from '../Page/TagLabels';
 import TagLabels from '../Page/TagLabels';
-import SubnavButtons from './SubNavButtons';
+import SubNavButtons from './SubNavButtons';
 import PageEditorModeManager from './PageEditorModeManager';
 import PageEditorModeManager from './PageEditorModeManager';
 
 
 import AuthorInfo from './AuthorInfo';
 import AuthorInfo from './AuthorInfo';
 import DrawerToggler from './DrawerToggler';
 import DrawerToggler from './DrawerToggler';
 
 
-const PagePathNav = ({
-  // eslint-disable-next-line react/prop-types
-  pageId, pagePath, isEditorMode, isCompactMode,
-}) => {
-
-  const dPagePath = new DevidedPagePath(pagePath, false, true);
+import PagePathNav from '../PagePathNav';
 
 
-  let formerLink;
-  let latterLink;
-
-  // one line
-  if (dPagePath.isRoot || dPagePath.isFormerRoot || isEditorMode) {
-    const linkedPagePath = new LinkedPagePath(pagePath);
-    latterLink = <PagePathHierarchicalLink linkedPagePath={linkedPagePath} />;
-  }
-  // two line
-  else {
-    const linkedPagePathFormer = new LinkedPagePath(dPagePath.former);
-    const linkedPagePathLatter = new LinkedPagePath(dPagePath.latter);
-    formerLink = <PagePathHierarchicalLink linkedPagePath={linkedPagePathFormer} />;
-    latterLink = <PagePathHierarchicalLink linkedPagePath={linkedPagePathLatter} basePath={dPagePath.former} />;
-  }
-
-  const copyDropdownId = `copydropdown${isCompactMode ? '-subnav-compact' : ''}-${pageId}`;
-  const copyDropdownToggleClassName = 'd-block text-muted bg-transparent btn-copy border-0 py-0';
-
-  return (
-    <div className="grw-page-path-nav">
-      {formerLink}
-      <span className="d-flex align-items-center">
-        <h1 className="m-0">{latterLink}</h1>
-        <div className="mx-2">
-          <CopyDropdown
-            pageId={pageId}
-            pagePath={pagePath}
-            dropdownToggleId={copyDropdownId}
-            dropdownToggleClassName={copyDropdownToggleClassName}
-          >
-            <i className="ti-clipboard"></i>
-          </CopyDropdown>
-        </div>
-      </span>
-    </div>
-  );
-};
+import { toastSuccess, toastError } from '~/client/util/apiNotification';
+import { apiPost } from '~/client/util/apiv1-client';
 
 
 const GrowiSubNavigation = (props) => {
 const GrowiSubNavigation = (props) => {
+  const { data: isDeviceSmallerThanMd } = useIsDeviceSmallerThanMd();
+  const { data: isDrawerMode } = useDrawerMode();
+  const { data: editorMode, mutate: mutateEditorMode } = useEditorMode();
+
   const {
   const {
-    appContainer, navigationContainer, pageContainer, isCompactMode,
+    appContainer, pageContainer, editorContainer, isCompactMode,
   } = props;
   } = props;
-  const { isDrawerMode, editorMode, isDeviceSmallerThanMd } = navigationContainer.state;
+
   const {
   const {
-    pageId, path, createdAt, creator, updatedAt, revisionAuthor, isPageExist,
+    pageId,
+    revisionId,
+    path,
+    isDeletable,
+    isAbleToDeleteCompletely,
+    createdAt,
+    creator,
+    updatedAt,
+    revisionAuthor,
+    isPageExist,
+    isTrashPage,
+    tags,
   } = pageContainer.state;
   } = pageContainer.state;
 
 
-  const { isGuestUser } = appContainer;
-  const isEditorMode = editorMode !== 'view';
+  const { isGuestUser, isSharedUser } = appContainer;
+  const isEditorMode = editorMode !== EditorMode.View;
   // Tags cannot be edited while the new page and editorMode is view
   // Tags cannot be edited while the new page and editorMode is view
-  const isTagLabelHidden = (editorMode !== 'edit' && !isPageExist);
+  const isTagLabelHidden = (editorMode !== EditorMode.Editor && !isPageExist);
 
 
+  const isAbleToShowPageManagement = isPageExist && !isTrashPage && !isSharedUser;
   function onPageEditorModeButtonClicked(viewType) {
   function onPageEditorModeButtonClicked(viewType) {
-    navigationContainer.setEditorMode(viewType);
+    mutateEditorMode(viewType);
   }
   }
 
 
+  const tagsUpdatedHandler = useCallback(async(newTags) => {
+    // It will not be reflected in the DB until the page is refreshed
+    if (editorMode === 'edit') {
+      return editorContainer.setState({ tags: newTags });
+    }
+
+    try {
+      const { tags } = await apiPost('/tags.update', { pageId, tags: newTags });
+
+      // update pageContainer.state
+      pageContainer.setState({ tags });
+      // update editorContainer.state
+      editorContainer.setState({ tags });
+
+      toastSuccess('updated tags successfully');
+    }
+    catch (err) {
+      toastError(err, 'fail to update tags');
+    }
+  // eslint-disable-next-line react-hooks/exhaustive-deps
+  }, [pageId]);
+
   return (
   return (
     <div className={`grw-subnav container-fluid d-flex align-items-center justify-content-between ${isCompactMode ? 'grw-subnav-compact d-print-none' : ''}`}>
     <div className={`grw-subnav container-fluid d-flex align-items-center justify-content-between ${isCompactMode ? 'grw-subnav-compact d-print-none' : ''}`}>
 
 
@@ -98,10 +91,10 @@ const GrowiSubNavigation = (props) => {
         <div className="grw-path-nav-container">
         <div className="grw-path-nav-container">
           { pageContainer.isAbleToShowTagLabel && !isCompactMode && !isTagLabelHidden && (
           { pageContainer.isAbleToShowTagLabel && !isCompactMode && !isTagLabelHidden && (
             <div className="grw-taglabels-container">
             <div className="grw-taglabels-container">
-              <TagLabels editorMode={editorMode} />
+              <TagLabels tags={tags} tagsUpdateInvoked={tagsUpdatedHandler} />
             </div>
             </div>
           ) }
           ) }
-          <PagePathNav pageId={pageId} pagePath={path} isEditorMode={isEditorMode} isCompactMode={isCompactMode} />
+          <PagePathNav pageId={pageId} pagePath={path} isSingleLineMode={isEditorMode} isCompactMode={isCompactMode} />
         </div>
         </div>
       </div>
       </div>
 
 
@@ -110,7 +103,15 @@ const GrowiSubNavigation = (props) => {
 
 
         <div className="d-flex flex-column align-items-end">
         <div className="d-flex flex-column align-items-end">
           <div className="d-flex">
           <div className="d-flex">
-            <SubnavButtons isCompactMode={isCompactMode} />
+            <SubNavButtons
+              isCompactMode={isCompactMode}
+              pageId={pageId}
+              revisionId={revisionId}
+              path={path}
+              isDeletable={isDeletable}
+              isAbleToDeleteCompletely={isAbleToDeleteCompletely}
+              willShowPageManagement={isAbleToShowPageManagement}
+            />
           </div>
           </div>
           <div className="mt-2">
           <div className="mt-2">
             {pageContainer.isAbleToShowPageEditorModeManager && (
             {pageContainer.isAbleToShowPageEditorModeManager && (
@@ -136,25 +137,22 @@ const GrowiSubNavigation = (props) => {
           </ul>
           </ul>
         ) }
         ) }
       </div>
       </div>
-
     </div>
     </div>
   );
   );
-
 };
 };
 
 
 /**
 /**
  * Wrapper component for using unstated
  * Wrapper component for using unstated
  */
  */
-const GrowiSubNavigationWrapper = withUnstatedContainers(GrowiSubNavigation, [AppContainer, NavigationContainer, PageContainer]);
+const GrowiSubNavigationWrapper = withUnstatedContainers(GrowiSubNavigation, [AppContainer, PageContainer, EditorContainer]);
 
 
 
 
 GrowiSubNavigation.propTypes = {
 GrowiSubNavigation.propTypes = {
-  t: PropTypes.func.isRequired, //  i18next
   appContainer: PropTypes.instanceOf(AppContainer).isRequired,
   appContainer: PropTypes.instanceOf(AppContainer).isRequired,
-  navigationContainer: PropTypes.instanceOf(NavigationContainer).isRequired,
   pageContainer: PropTypes.instanceOf(PageContainer).isRequired,
   pageContainer: PropTypes.instanceOf(PageContainer).isRequired,
+  editorContainer: PropTypes.instanceOf(EditorContainer).isRequired,
 
 
   isCompactMode: PropTypes.bool,
   isCompactMode: PropTypes.bool,
 };
 };
 
 
-export default withTranslation()(GrowiSubNavigationWrapper);
+export default GrowiSubNavigationWrapper;

+ 17 - 15
packages/app/src/components/Navbar/PageEditorModeManager.jsx

@@ -1,10 +1,12 @@
 import React, { useCallback } from 'react';
 import React, { useCallback } from 'react';
 import PropTypes from 'prop-types';
 import PropTypes from 'prop-types';
-import { withTranslation } from 'react-i18next';
+import { useTranslation } from 'react-i18next';
 import { UncontrolledTooltip } from 'reactstrap';
 import { UncontrolledTooltip } from 'reactstrap';
 
 
-import { withUnstatedContainers } from '../UnstatedUtils';
 import AppContainer from '~/client/services/AppContainer';
 import AppContainer from '~/client/services/AppContainer';
+import { EditorMode, useIsDeviceSmallerThanMd } from '~/stores/ui';
+
+import { withUnstatedContainers } from '../UnstatedUtils';
 
 
 /* eslint-disable react/prop-types */
 /* eslint-disable react/prop-types */
 const PageEditorModeButtonWrapper = React.memo(({
 const PageEditorModeButtonWrapper = React.memo(({
@@ -36,14 +38,17 @@ const PageEditorModeButtonWrapper = React.memo(({
 
 
 function PageEditorModeManager(props) {
 function PageEditorModeManager(props) {
   const {
   const {
-    t, appContainer,
-    editorMode, onPageEditorModeButtonClicked, isBtnDisabled, isDeviceSmallerThanMd,
+    appContainer,
+    editorMode, onPageEditorModeButtonClicked, isBtnDisabled,
   } = props;
   } = props;
 
 
+  const { t } = useTranslation();
+  const { data: isDeviceSmallerThanMd } = useIsDeviceSmallerThanMd();
+
   const isAdmin = appContainer.isAdmin;
   const isAdmin = appContainer.isAdmin;
   const isHackmdEnabled = appContainer.config.env.HACKMD_URI != null;
   const isHackmdEnabled = appContainer.config.env.HACKMD_URI != null;
   const showHackmdBtn = isHackmdEnabled || isAdmin;
   const showHackmdBtn = isHackmdEnabled || isAdmin;
-  const showHackmdDisabledTooltip = isAdmin && !isHackmdEnabled && editorMode !== 'hackmd';
+  const showHackmdDisabledTooltip = isAdmin && !isHackmdEnabled && editorMode !== EditorMode.HackMD;
 
 
   const pageEditorModeButtonClickedHandler = useCallback((viewType) => {
   const pageEditorModeButtonClickedHandler = useCallback((viewType) => {
     if (isBtnDisabled) {
     if (isBtnDisabled) {
@@ -62,32 +67,32 @@ function PageEditorModeManager(props) {
         aria-label="page-editor-mode-manager"
         aria-label="page-editor-mode-manager"
         id="grw-page-editor-mode-manager"
         id="grw-page-editor-mode-manager"
       >
       >
-        {(!isDeviceSmallerThanMd || editorMode !== 'view') && (
+        {(!isDeviceSmallerThanMd || editorMode !== EditorMode.View) && (
           <PageEditorModeButtonWrapper
           <PageEditorModeButtonWrapper
             editorMode={editorMode}
             editorMode={editorMode}
             isBtnDisabled={isBtnDisabled}
             isBtnDisabled={isBtnDisabled}
             onClick={pageEditorModeButtonClickedHandler}
             onClick={pageEditorModeButtonClickedHandler}
-            targetMode="view"
+            targetMode={EditorMode.View}
             icon={<i className="icon-control-play" />}
             icon={<i className="icon-control-play" />}
             label={t('view')}
             label={t('view')}
           />
           />
         )}
         )}
-        {(!isDeviceSmallerThanMd || editorMode === 'view') && (
+        {(!isDeviceSmallerThanMd || editorMode === EditorMode.View) && (
           <PageEditorModeButtonWrapper
           <PageEditorModeButtonWrapper
             editorMode={editorMode}
             editorMode={editorMode}
             isBtnDisabled={isBtnDisabled}
             isBtnDisabled={isBtnDisabled}
             onClick={pageEditorModeButtonClickedHandler}
             onClick={pageEditorModeButtonClickedHandler}
-            targetMode="edit"
+            targetMode={EditorMode.Editor}
             icon={<i className="icon-note" />}
             icon={<i className="icon-note" />}
             label={t('Edit')}
             label={t('Edit')}
           />
           />
         )}
         )}
-        {(!isDeviceSmallerThanMd || editorMode === 'view') && showHackmdBtn && (
+        {(!isDeviceSmallerThanMd || editorMode === EditorMode.View) && showHackmdBtn && (
           <PageEditorModeButtonWrapper
           <PageEditorModeButtonWrapper
             editorMode={editorMode}
             editorMode={editorMode}
             isBtnDisabled={isBtnDisabled}
             isBtnDisabled={isBtnDisabled}
             onClick={pageEditorModeButtonClickedHandler}
             onClick={pageEditorModeButtonClickedHandler}
-            targetMode="hackmd"
+            targetMode={EditorMode.HackMD}
             icon={<i className="fa fa-file-text-o" />}
             icon={<i className="fa fa-file-text-o" />}
             label={t('hackmd.hack_md')}
             label={t('hackmd.hack_md')}
             id="grw-page-editor-mode-manager-hackmd-button"
             id="grw-page-editor-mode-manager-hackmd-button"
@@ -110,18 +115,15 @@ function PageEditorModeManager(props) {
 }
 }
 
 
 PageEditorModeManager.propTypes = {
 PageEditorModeManager.propTypes = {
-  t: PropTypes.func.isRequired, //  i18next
   appContainer: PropTypes.instanceOf(AppContainer).isRequired,
   appContainer: PropTypes.instanceOf(AppContainer).isRequired,
 
 
   onPageEditorModeButtonClicked: PropTypes.func,
   onPageEditorModeButtonClicked: PropTypes.func,
   isBtnDisabled: PropTypes.bool,
   isBtnDisabled: PropTypes.bool,
   editorMode: PropTypes.string,
   editorMode: PropTypes.string,
-  isDeviceSmallerThanMd: PropTypes.bool,
 };
 };
 
 
 PageEditorModeManager.defaultProps = {
 PageEditorModeManager.defaultProps = {
   isBtnDisabled: false,
   isBtnDisabled: false,
-  isDeviceSmallerThanMd: false,
 };
 };
 
 
 /**
 /**
@@ -129,4 +131,4 @@ PageEditorModeManager.defaultProps = {
  */
  */
 const PageEditorModeManagerWrapper = withUnstatedContainers(PageEditorModeManager, [AppContainer]);
 const PageEditorModeManagerWrapper = withUnstatedContainers(PageEditorModeManager, [AppContainer]);
 
 
-export default withTranslation()(PageEditorModeManagerWrapper);
+export default PageEditorModeManagerWrapper;

+ 21 - 20
packages/app/src/components/Navbar/PersonalDropdown.jsx

@@ -1,4 +1,4 @@
-import React, { useState } from 'react';
+import React, { useState, useCallback } from 'react';
 import PropTypes from 'prop-types';
 import PropTypes from 'prop-types';
 
 
 import { withTranslation } from 'react-i18next';
 import { withTranslation } from 'react-i18next';
@@ -6,9 +6,13 @@ import { withTranslation } from 'react-i18next';
 import { UncontrolledTooltip } from 'reactstrap';
 import { UncontrolledTooltip } from 'reactstrap';
 
 
 import { UserPicture } from '@growi/ui';
 import { UserPicture } from '@growi/ui';
-import { withUnstatedContainers } from '../UnstatedUtils';
+
+import { scheduleToPutUserUISettings } from '~/client/services/user-ui-settings';
 import AppContainer from '~/client/services/AppContainer';
 import AppContainer from '~/client/services/AppContainer';
+
+import { withUnstatedContainers } from '../UnstatedUtils';
 import NavigationContainer from '~/client/services/NavigationContainer';
 import NavigationContainer from '~/client/services/NavigationContainer';
+import { usePreferDrawerModeByUser, usePreferDrawerModeOnEditByUser } from '~/stores/ui';
 
 
 import {
 import {
   isUserPreferenceExists,
   isUserPreferenceExists,
@@ -28,12 +32,15 @@ import SunIcon from '../Icons/SunIcon';
 
 
 const PersonalDropdown = (props) => {
 const PersonalDropdown = (props) => {
 
 
-  const { t, appContainer, navigationContainer } = props;
+  const { t, appContainer } = props;
   const user = appContainer.currentUser || {};
   const user = appContainer.currentUser || {};
 
 
   const [useOsSettings, setOsSettings] = useState(!isUserPreferenceExists());
   const [useOsSettings, setOsSettings] = useState(!isUserPreferenceExists());
   const [isDarkMode, setIsDarkMode] = useState(isDarkModeByUtil());
   const [isDarkMode, setIsDarkMode] = useState(isDarkModeByUtil());
 
 
+  const { data: isPreferDrawerMode, mutate: mutatePreferDrawerMode } = usePreferDrawerModeByUser();
+  const { data: isPreferDrawerModeOnEdit, mutate: mutatePreferDrawerModeOnEdit } = usePreferDrawerModeOnEditByUser();
+
   const logoutHandler = () => {
   const logoutHandler = () => {
     const { interceptorManager } = appContainer;
     const { interceptorManager } = appContainer;
 
 
@@ -46,13 +53,15 @@ const PersonalDropdown = (props) => {
     window.location.href = '/logout';
     window.location.href = '/logout';
   };
   };
 
 
-  const preferDrawerModeSwitchModifiedHandler = (bool) => {
-    navigationContainer.setDrawerModePreference(bool);
-  };
+  const preferDrawerModeSwitchModifiedHandler = useCallback((bool) => {
+    mutatePreferDrawerMode(bool);
+    scheduleToPutUserUISettings({ preferDrawerModeByUser: bool });
+  }, [mutatePreferDrawerMode]);
 
 
-  const preferDrawerModeOnEditSwitchModifiedHandler = (bool) => {
-    navigationContainer.setDrawerModePreferenceOnEdit(bool);
-  };
+  const preferDrawerModeOnEditSwitchModifiedHandler = useCallback((bool) => {
+    mutatePreferDrawerModeOnEdit(bool);
+    scheduleToPutUserUISettings({ preferDrawerModeOnEditByUser: bool });
+  }, [mutatePreferDrawerModeOnEdit]);
 
 
   const followOsCheckboxModifiedHandler = (bool) => {
   const followOsCheckboxModifiedHandler = (bool) => {
     if (bool) {
     if (bool) {
@@ -77,13 +86,6 @@ const PersonalDropdown = (props) => {
   };
   };
 
 
 
 
-  /*
-   * render
-   */
-  const {
-    preferDrawerModeByUser, preferDrawerModeOnEditByUser,
-  } = navigationContainer.state;
-
   /* eslint-disable react/prop-types */
   /* eslint-disable react/prop-types */
   const IconWithTooltip = ({
   const IconWithTooltip = ({
     id, label, children, additionalClasses,
     id, label, children, additionalClasses,
@@ -144,7 +146,7 @@ const PersonalDropdown = (props) => {
                   id="swSidebarMode"
                   id="swSidebarMode"
                   className="custom-control-input"
                   className="custom-control-input"
                   type="checkbox"
                   type="checkbox"
-                  checked={!preferDrawerModeByUser}
+                  checked={!isPreferDrawerMode}
                   onChange={e => preferDrawerModeSwitchModifiedHandler(!e.target.checked)}
                   onChange={e => preferDrawerModeSwitchModifiedHandler(!e.target.checked)}
                 />
                 />
                 <label className="custom-control-label" htmlFor="swSidebarMode"></label>
                 <label className="custom-control-label" htmlFor="swSidebarMode"></label>
@@ -169,7 +171,7 @@ const PersonalDropdown = (props) => {
                   id="swSidebarModeOnEditor"
                   id="swSidebarModeOnEditor"
                   className="custom-control-input"
                   className="custom-control-input"
                   type="checkbox"
                   type="checkbox"
-                  checked={!preferDrawerModeOnEditByUser}
+                  checked={!isPreferDrawerModeOnEdit}
                   onChange={e => preferDrawerModeOnEditSwitchModifiedHandler(!e.target.checked)}
                   onChange={e => preferDrawerModeOnEditSwitchModifiedHandler(!e.target.checked)}
                 />
                 />
                 <label className="custom-control-label" htmlFor="swSidebarModeOnEditor"></label>
                 <label className="custom-control-label" htmlFor="swSidebarModeOnEditor"></label>
@@ -236,13 +238,12 @@ const PersonalDropdown = (props) => {
 /**
 /**
  * Wrapper component for using unstated
  * Wrapper component for using unstated
  */
  */
-const PersonalDropdownWrapper = withUnstatedContainers(PersonalDropdown, [AppContainer, NavigationContainer]);
+const PersonalDropdownWrapper = withUnstatedContainers(PersonalDropdown, [AppContainer]);
 
 
 
 
 PersonalDropdown.propTypes = {
 PersonalDropdown.propTypes = {
   t: PropTypes.func.isRequired, //  i18next
   t: PropTypes.func.isRequired, //  i18next
   appContainer: PropTypes.instanceOf(AppContainer).isRequired,
   appContainer: PropTypes.instanceOf(AppContainer).isRequired,
-  navigationContainer: PropTypes.instanceOf(NavigationContainer).isRequired,
 };
 };
 
 
 export default withTranslation()(PersonalDropdownWrapper);
 export default withTranslation()(PersonalDropdownWrapper);

+ 0 - 80
packages/app/src/components/Navbar/SubNavButtons.jsx

@@ -1,80 +0,0 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-import AppContainer from '~/client/services/AppContainer';
-import NavigationContainer from '~/client/services/NavigationContainer';
-import PageContainer from '~/client/services/PageContainer';
-import { withUnstatedContainers } from '../UnstatedUtils';
-import loggerFactory from '~/utils/logger';
-
-import BookmarkButton from '../BookmarkButton';
-import LikeButtons from '../LikeButtons';
-import PageManagement from '../Page/PageManagement';
-
-const logger = loggerFactory('growi:SubnavButtons');
-
-const SubnavButtons = (props) => {
-  const {
-    appContainer, navigationContainer, pageContainer, isCompactMode,
-  } = props;
-
-  /* eslint-enable react/prop-types */
-
-  /* eslint-disable react/prop-types */
-  const PageReactionButtons = ({ pageContainer }) => {
-    const { pageId, isBookmarked, sumOfBookmarks } = pageContainer.state;
-
-    const onChangeInvoked = () => {
-      if (pageContainer.retrieveBookmarkInfo == null) { logger.error('retrieveBookmarkInfo is null') }
-      else { pageContainer.retrieveBookmarkInfo() }
-    };
-
-    return (
-      <>
-        {pageContainer.isAbleToShowLikeButtons && (
-          <span>
-            <LikeButtons />
-          </span>
-        )}
-        <span>
-          <BookmarkButton
-            pageId={pageId}
-            isBookmarked={isBookmarked}
-            sumOfBookmarks={sumOfBookmarks}
-            onChangeInvoked={onChangeInvoked}
-          />
-        </span>
-      </>
-    );
-  };
-  /* eslint-enable react/prop-types */
-
-  const { editorMode } = navigationContainer.state;
-  const isViewMode = editorMode === 'view';
-
-  return (
-    <>
-      {isViewMode && (
-        <>
-          {pageContainer.isAbleToShowPageReactionButtons && <PageReactionButtons appContainer={appContainer} pageContainer={pageContainer} />}
-          {pageContainer.isAbleToShowPageManagement && <PageManagement isCompactMode={isCompactMode} />}
-        </>
-      )}
-    </>
-  );
-};
-
-/**
- * Wrapper component for using unstated
- */
-const SubnavButtonsWrapper = withUnstatedContainers(SubnavButtons, [AppContainer, NavigationContainer, PageContainer]);
-
-
-SubnavButtons.propTypes = {
-  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
-  navigationContainer: PropTypes.instanceOf(NavigationContainer).isRequired,
-  pageContainer: PropTypes.instanceOf(PageContainer).isRequired,
-
-  isCompactMode: PropTypes.bool,
-};
-
-export default SubnavButtonsWrapper;

+ 119 - 0
packages/app/src/components/Navbar/SubNavButtons.tsx

@@ -0,0 +1,119 @@
+import React, {
+  FC, useCallback, useState, useEffect,
+} from 'react';
+import AppContainer from '../../client/services/AppContainer';
+import NavigationContainer from '../../client/services/NavigationContainer';
+import { withUnstatedContainers } from '../UnstatedUtils';
+
+import PageReactionButtons from '../PageReactionButtons';
+import PageManagement from '../Page/PageManagement';
+import { useSWRPageInfo } from '../../stores/page';
+import { useSWRBookmarkInfo } from '../../stores/bookmark';
+import { toastError } from '../../client/util/apiNotification';
+import { apiv3Put } from '../../client/util/apiv3-client';
+import { useSWRxLikerList } from '../../stores/user';
+
+type SubNavButtonsProps= {
+  appContainer: AppContainer,
+  navigationContainer: NavigationContainer,
+  isCompactMode?: boolean,
+  pageId: string,
+  revisionId: string,
+  path: string,
+  willShowPageManagement: boolean,
+  isDeletable: boolean,
+  isAbleToDeleteCompletely: boolean,
+}
+const SubNavButtons: FC<SubNavButtonsProps> = (props: SubNavButtonsProps) => {
+  const {
+    appContainer, navigationContainer, isCompactMode, pageId, revisionId, path, willShowPageManagement, isDeletable, isAbleToDeleteCompletely,
+  } = props;
+  const { editorMode } = navigationContainer.state;
+  const isViewMode = editorMode === 'view';
+  const { isGuestUser } = appContainer;
+
+  const { data: pageInfo, error: pageInfoError, mutate: mutatePageInfo } = useSWRPageInfo(pageId);
+  const { data: likers } = useSWRxLikerList(pageInfo?.likerIds);
+  const { data: bookmarkInfo, error: bookmarkInfoError, mutate: mutateBookmarkInfo } = useSWRBookmarkInfo(pageId);
+
+  const likeClickhandler = useCallback(async() => {
+    const { isGuestUser } = appContainer;
+    if (isGuestUser) {
+      return;
+    }
+
+    try {
+      // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
+      await apiv3Put('/page/likes', { pageId, bool: !pageInfo!.isLiked });
+      mutatePageInfo();
+    }
+    catch (err) {
+      toastError(err);
+    }
+  }, [pageInfo]);
+
+  const bookmarkClickHandler = useCallback(async() => {
+    if (isGuestUser) {
+      return;
+    }
+    try {
+      // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
+      await apiv3Put('/bookmarks', { pageId, bool: !bookmarkInfo!.isBookmarked });
+      mutateBookmarkInfo();
+    }
+    catch (err) {
+      toastError(err);
+    }
+  }, [bookmarkInfo]);
+
+  if (pageInfoError != null || pageInfo == null) {
+    return <></>;
+  }
+
+  if (bookmarkInfoError != null || bookmarkInfo == null) {
+    return <></>;
+  }
+
+  const { sumOfLikers, isLiked } = pageInfo;
+  const { sumOfBookmarks, isBookmarked } = bookmarkInfo;
+
+  return (
+    <>
+      {isViewMode && (
+        <PageReactionButtons
+          sumOfLikers={sumOfLikers}
+          isLiked={isLiked}
+          likers={likers || []}
+          onLikeClicked={likeClickhandler}
+          sumOfBookmarks={sumOfBookmarks}
+          isBookmarked={isBookmarked}
+          onBookMarkClicked={bookmarkClickHandler}
+        >
+        </PageReactionButtons>
+      )}
+      {willShowPageManagement && (
+        <PageManagement
+          pageId={pageId}
+          revisionId={revisionId}
+          path={path}
+          isCompactMode={isCompactMode}
+          isDeletable={isDeletable}
+          isAbleToDeleteCompletely={isAbleToDeleteCompletely}
+        >
+        </PageManagement>
+      )}
+    </>
+  );
+};
+
+/**
+ * Wrapper component for using unstated
+ */
+const SubNavButtonsUnstatedWrapper = withUnstatedContainers(SubNavButtons, [AppContainer, NavigationContainer]);
+
+// wrapping tsx component returned by withUnstatedContainers to avoid type error when this component used in other tsx components.
+const SubNavButtonsWrapper = (props) => {
+  return <SubNavButtonsUnstatedWrapper {...props}></SubNavButtonsUnstatedWrapper>;
+};
+
+export default SubNavButtonsWrapper;

+ 13 - 9
packages/app/src/components/Page/DisplaySwitcher.jsx

@@ -1,9 +1,11 @@
 import React from 'react';
 import React from 'react';
 import { TabContent, TabPane } from 'reactstrap';
 import { TabContent, TabPane } from 'reactstrap';
 import propTypes from 'prop-types';
 import propTypes from 'prop-types';
+
 import { withUnstatedContainers } from '../UnstatedUtils';
 import { withUnstatedContainers } from '../UnstatedUtils';
-import NavigationContainer from '~/client/services/NavigationContainer';
 import PageContainer from '~/client/services/PageContainer';
 import PageContainer from '~/client/services/PageContainer';
+import { EditorMode, useEditorMode } from '~/stores/ui';
+
 import Editor from '../PageEditor';
 import Editor from '../PageEditor';
 import Page from '../Page';
 import Page from '../Page';
 import UserInfo from '../User/UserInfo';
 import UserInfo from '../User/UserInfo';
@@ -16,15 +18,18 @@ import EditorNavbarBottom from '../PageEditor/EditorNavbarBottom';
 
 
 const DisplaySwitcher = (props) => {
 const DisplaySwitcher = (props) => {
   const {
   const {
-    navigationContainer, pageContainer,
+    pageContainer,
   } = props;
   } = props;
-  const { editorMode } = navigationContainer.state;
   const { isPageExist, pageUser } = pageContainer.state;
   const { isPageExist, pageUser } = pageContainer.state;
 
 
+  const { data: editorMode } = useEditorMode();
+
+  const isViewMode = editorMode === EditorMode.View;
+
   return (
   return (
     <>
     <>
       <TabContent activeTab={editorMode}>
       <TabContent activeTab={editorMode}>
-        <TabPane tabId="view">
+        <TabPane tabId={EditorMode.View}>
           <div className="d-flex flex-column flex-lg-row-reverse">
           <div className="d-flex flex-column flex-lg-row-reverse">
 
 
             <div className="grw-side-contents-container">
             <div className="grw-side-contents-container">
@@ -49,26 +54,25 @@ const DisplaySwitcher = (props) => {
 
 
           </div>
           </div>
         </TabPane>
         </TabPane>
-        <TabPane tabId="edit">
+        <TabPane tabId={EditorMode.Editor}>
           <div id="page-editor">
           <div id="page-editor">
             <Editor />
             <Editor />
           </div>
           </div>
         </TabPane>
         </TabPane>
-        <TabPane tabId="hackmd">
+        <TabPane tabId={EditorMode.HackMD}>
           <div id="page-editor-with-hackmd">
           <div id="page-editor-with-hackmd">
             <PageEditorByHackmd />
             <PageEditorByHackmd />
           </div>
           </div>
         </TabPane>
         </TabPane>
       </TabContent>
       </TabContent>
-      {editorMode !== 'view' && <EditorNavbarBottom /> }
+      {!isViewMode && <EditorNavbarBottom /> }
     </>
     </>
   );
   );
 };
 };
 
 
 DisplaySwitcher.propTypes = {
 DisplaySwitcher.propTypes = {
-  navigationContainer: propTypes.instanceOf(NavigationContainer).isRequired,
   pageContainer: propTypes.instanceOf(PageContainer).isRequired,
   pageContainer: propTypes.instanceOf(PageContainer).isRequired,
 };
 };
 
 
 
 
-export default withUnstatedContainers(DisplaySwitcher, [NavigationContainer, PageContainer]);
+export default withUnstatedContainers(DisplaySwitcher, [PageContainer]);

+ 13 - 13
packages/app/src/components/Page/NotFoundAlert.jsx

@@ -1,24 +1,26 @@
-import React from 'react';
+import React, { useCallback } from 'react';
 import PropTypes from 'prop-types';
 import PropTypes from 'prop-types';
-import { withTranslation } from 'react-i18next';
+import { useTranslation } from 'react-i18next';
 import { UncontrolledTooltip } from 'reactstrap';
 import { UncontrolledTooltip } from 'reactstrap';
+import { EditorMode, useEditorMode } from '~/stores/ui';
 
 
 
 
 const NotFoundAlert = (props) => {
 const NotFoundAlert = (props) => {
-  const { t, isHidden, isGuestUserMode } = props;
-  function clickHandler(viewType) {
+  const { t } = useTranslation();
+  const { isHidden, isGuestUserMode } = props;
 
 
+  const { mutate: mutateEditorMode } = useEditorMode();
+
+  const clickHandler = useCallback(() => {
     // check guest user,
     // check guest user,
     // disabled of button cannot be used for using tooltip.
     // disabled of button cannot be used for using tooltip.
     if (isGuestUserMode) {
     if (isGuestUserMode) {
       return;
       return;
     }
     }
 
 
-    if (props.onPageCreateClicked === null) {
-      return;
-    }
-    props.onPageCreateClicked(viewType);
-  }
+    mutateEditorMode(EditorMode.Editor);
+
+  }, [isGuestUserMode, mutateEditorMode]);
 
 
   if (isHidden) {
   if (isHidden) {
     return null;
     return null;
@@ -38,7 +40,7 @@ const NotFoundAlert = (props) => {
           <button
           <button
             type="button"
             type="button"
             className={`pl-3 pr-3 btn bg-info text-white ${isGuestUserMode ? 'disabled' : ''}`}
             className={`pl-3 pr-3 btn bg-info text-white ${isGuestUserMode ? 'disabled' : ''}`}
-            onClick={() => { clickHandler('edit') }}
+            onClick={clickHandler}
           >
           >
             <i className="icon-note icon-fw" />
             <i className="icon-note icon-fw" />
             {t('not_found_page.Create Page')}
             {t('not_found_page.Create Page')}
@@ -58,10 +60,8 @@ const NotFoundAlert = (props) => {
 
 
 
 
 NotFoundAlert.propTypes = {
 NotFoundAlert.propTypes = {
-  t: PropTypes.func.isRequired, // i18next
-  onPageCreateClicked: PropTypes.func,
   isHidden: PropTypes.bool.isRequired,
   isHidden: PropTypes.bool.isRequired,
   isGuestUserMode: PropTypes.bool.isRequired,
   isGuestUserMode: PropTypes.bool.isRequired,
 };
 };
 
 
-export default withTranslation()(NotFoundAlert);
+export default NotFoundAlert;

+ 19 - 13
packages/app/src/components/Page/PageManagement.jsx

@@ -7,7 +7,6 @@ import urljoin from 'url-join';
 import { pagePathUtils } from '@growi/core';
 import { pagePathUtils } from '@growi/core';
 import { withUnstatedContainers } from '../UnstatedUtils';
 import { withUnstatedContainers } from '../UnstatedUtils';
 import AppContainer from '~/client/services/AppContainer';
 import AppContainer from '~/client/services/AppContainer';
-import PageContainer from '~/client/services/PageContainer';
 import PageDeleteModal from '../PageDeleteModal';
 import PageDeleteModal from '../PageDeleteModal';
 import PageRenameModal from '../PageRenameModal';
 import PageRenameModal from '../PageRenameModal';
 import PageDuplicateModal from '../PageDuplicateModal';
 import PageDuplicateModal from '../PageDuplicateModal';
@@ -18,13 +17,10 @@ import PresentationIcon from '../Icons/PresentationIcon';
 const { isTopPage } = pagePathUtils;
 const { isTopPage } = pagePathUtils;
 
 
 
 
-const PageManagement = (props) => {
+const LegacyPageManagemenet = (props) => {
   const {
   const {
-    t, appContainer, pageContainer, isCompactMode,
+    t, appContainer, isCompactMode, pageId, revisionId, path, isDeletable, isAbleToDeleteCompletely,
   } = props;
   } = props;
-  const {
-    pageId, revisionId, path, isDeletable, isAbleToDeleteCompletely,
-  } = pageContainer.state;
 
 
   const { currentUser } = appContainer;
   const { currentUser } = appContainer;
   const isTopPagePath = isTopPage(path);
   const isTopPagePath = isTopPage(path);
@@ -33,6 +29,7 @@ const PageManagement = (props) => {
   const [isPageTemplateModalShown, setIsPageTempleteModalShown] = useState(false);
   const [isPageTemplateModalShown, setIsPageTempleteModalShown] = useState(false);
   const [isPageDeleteModalShown, setIsPageDeleteModalShown] = useState(false);
   const [isPageDeleteModalShown, setIsPageDeleteModalShown] = useState(false);
   const [isPagePresentationModalShown, setIsPagePresentationModalShown] = useState(false);
   const [isPagePresentationModalShown, setIsPagePresentationModalShown] = useState(false);
+  const presentationHref = urljoin(window.location.origin, path, '?presentation=1');
 
 
   function openPageRenameModalHandler() {
   function openPageRenameModalHandler() {
     setIsPageRenameModalShown(true);
     setIsPageRenameModalShown(true);
@@ -86,7 +83,6 @@ const PageManagement = (props) => {
   // }
   // }
 
 
   async function exportPageHandler(format) {
   async function exportPageHandler(format) {
-    const { pageId, revisionId } = pageContainer.state;
     const url = new URL(urljoin(window.location.origin, '_api/v3/page/export', pageId));
     const url = new URL(urljoin(window.location.origin, '_api/v3/page/export', pageId));
     url.searchParams.append('format', format);
     url.searchParams.append('format', format);
     url.searchParams.append('revisionId', revisionId);
     url.searchParams.append('revisionId', revisionId);
@@ -183,6 +179,7 @@ const PageManagement = (props) => {
           path={path}
           path={path}
         />
         />
         <CreateTemplateModal
         <CreateTemplateModal
+          path={path}
           isOpen={isPageTemplateModalShown}
           isOpen={isPageTemplateModalShown}
           onClose={closePageTemplateModalHandler}
           onClose={closePageTemplateModalHandler}
         />
         />
@@ -195,7 +192,7 @@ const PageManagement = (props) => {
         <PagePresentationModal
         <PagePresentationModal
           isOpen={isPagePresentationModalShown}
           isOpen={isPagePresentationModalShown}
           onClose={closePagePresentationModalHandler}
           onClose={closePagePresentationModalHandler}
-          href="?presentation=1"
+          href={presentationHref}
         />
         />
       </>
       </>
     );
     );
@@ -251,19 +248,28 @@ const PageManagement = (props) => {
 /**
 /**
  * Wrapper component for using unstated
  * Wrapper component for using unstated
  */
  */
-const PageManagementWrapper = withUnstatedContainers(PageManagement, [AppContainer, PageContainer]);
+const LegacyPageManagemenetWrapper = withUnstatedContainers(LegacyPageManagemenet, [AppContainer]);
 
 
 
 
-PageManagement.propTypes = {
+LegacyPageManagemenet.propTypes = {
   t: PropTypes.func.isRequired, // i18next
   t: PropTypes.func.isRequired, // i18next
   appContainer: PropTypes.instanceOf(AppContainer).isRequired,
   appContainer: PropTypes.instanceOf(AppContainer).isRequired,
-  pageContainer: PropTypes.instanceOf(PageContainer).isRequired,
+
+
+  pageId: PropTypes.string.isRequired,
+  revisionId: PropTypes.string.isRequired,
+  path: PropTypes.string.isRequired,
+  isDeletable: PropTypes.bool.isRequired,
+  isAbleToDeleteCompletely: PropTypes.bool,
 
 
   isCompactMode: PropTypes.bool,
   isCompactMode: PropTypes.bool,
 };
 };
 
 
-PageManagement.defaultProps = {
+LegacyPageManagemenet.defaultProps = {
   isCompactMode: false,
   isCompactMode: false,
 };
 };
 
 
-export default withTranslation()(PageManagementWrapper);
+const PageManagement = (props) => {
+  return <LegacyPageManagemenetWrapper {...props}></LegacyPageManagemenetWrapper>;
+};
+export default withTranslation()(PageManagement);

+ 7 - 4
packages/app/src/components/Page/RevisionLoader.jsx

@@ -13,7 +13,7 @@ import RevisionRenderer from './RevisionRenderer';
 /**
 /**
  * Load data from server and render RevisionBody component
  * Load data from server and render RevisionBody component
  */
  */
-class RevisionLoader extends React.Component {
+class LegacyRevisionLoader extends React.Component {
 
 
   constructor(props) {
   constructor(props) {
     super(props);
     super(props);
@@ -116,9 +116,9 @@ class RevisionLoader extends React.Component {
 /**
 /**
  * Wrapper component for using unstated
  * Wrapper component for using unstated
  */
  */
-const RevisionLoaderWrapper = withUnstatedContainers(RevisionLoader, [AppContainer]);
+const LegacyRevisionLoaderWrapper = withUnstatedContainers(LegacyRevisionLoader, [AppContainer]);
 
 
-RevisionLoader.propTypes = {
+LegacyRevisionLoader.propTypes = {
   appContainer: PropTypes.instanceOf(AppContainer).isRequired,
   appContainer: PropTypes.instanceOf(AppContainer).isRequired,
 
 
   growiRenderer: PropTypes.instanceOf(GrowiRenderer).isRequired,
   growiRenderer: PropTypes.instanceOf(GrowiRenderer).isRequired,
@@ -129,4 +129,7 @@ RevisionLoader.propTypes = {
   highlightKeywords: PropTypes.string,
   highlightKeywords: PropTypes.string,
 };
 };
 
 
-export default RevisionLoaderWrapper;
+const RevisionLoader = (props) => {
+  return <LegacyRevisionLoaderWrapper {...props}></LegacyRevisionLoaderWrapper>;
+};
+export default RevisionLoader;

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

@@ -8,7 +8,7 @@ import GrowiRenderer from '~/client/util/GrowiRenderer';
 
 
 import RevisionBody from './RevisionBody';
 import RevisionBody from './RevisionBody';
 
 
-class RevisionRenderer extends React.PureComponent {
+class LegacyRevisionRenderer extends React.PureComponent {
 
 
   constructor(props) {
   constructor(props) {
     super(props);
     super(props);
@@ -117,18 +117,31 @@ class RevisionRenderer extends React.PureComponent {
 
 
 }
 }
 
 
+LegacyRevisionRenderer.propTypes = {
+  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
+  navigationContainer: PropTypes.instanceOf(NavigationContainer).isRequired,
+  growiRenderer: PropTypes.instanceOf(GrowiRenderer).isRequired,
+  markdown: PropTypes.string.isRequired,
+  highlightKeywords: PropTypes.string,
+  additionalClassName: PropTypes.string,
+};
+
 /**
 /**
  * Wrapper component for using unstated
  * Wrapper component for using unstated
  */
  */
-const RevisionRendererWrapper = withUnstatedContainers(RevisionRenderer, [AppContainer, NavigationContainer]);
+const LegacyRevisionRendererWrapper = withUnstatedContainers(LegacyRevisionRenderer, [AppContainer, NavigationContainer]);
+
+
+// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
+const RevisionRenderer = (props) => {
+  return <LegacyRevisionRendererWrapper {...props} />;
+};
 
 
 RevisionRenderer.propTypes = {
 RevisionRenderer.propTypes = {
-  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
-  navigationContainer: PropTypes.instanceOf(NavigationContainer).isRequired,
   growiRenderer: PropTypes.instanceOf(GrowiRenderer).isRequired,
   growiRenderer: PropTypes.instanceOf(GrowiRenderer).isRequired,
   markdown: PropTypes.string.isRequired,
   markdown: PropTypes.string.isRequired,
   highlightKeywords: PropTypes.string,
   highlightKeywords: PropTypes.string,
   additionalClassName: PropTypes.string,
   additionalClassName: PropTypes.string,
 };
 };
 
 
-export default RevisionRendererWrapper;
+export default RevisionRenderer;

+ 9 - 48
packages/app/src/components/Page/TagLabels.jsx

@@ -2,12 +2,9 @@ import React, { Suspense } from 'react';
 import PropTypes from 'prop-types';
 import PropTypes from 'prop-types';
 import { withTranslation } from 'react-i18next';
 import { withTranslation } from 'react-i18next';
 
 
-import { toastSuccess, toastError } from '~/client/util/apiNotification';
 
 
 import { withUnstatedContainers } from '../UnstatedUtils';
 import { withUnstatedContainers } from '../UnstatedUtils';
 import AppContainer from '~/client/services/AppContainer';
 import AppContainer from '~/client/services/AppContainer';
-import PageContainer from '~/client/services/PageContainer';
-import EditorContainer from '~/client/services/EditorContainer';
 
 
 import RenderTagLabels from './RenderTagLabels';
 import RenderTagLabels from './RenderTagLabels';
 import TagEditModal from './TagEditModal';
 import TagEditModal from './TagEditModal';
@@ -23,18 +20,8 @@ class TagLabels extends React.Component {
 
 
     this.openEditorModal = this.openEditorModal.bind(this);
     this.openEditorModal = this.openEditorModal.bind(this);
     this.closeEditorModal = this.closeEditorModal.bind(this);
     this.closeEditorModal = this.closeEditorModal.bind(this);
-    this.tagsUpdatedHandler = this.tagsUpdatedHandler.bind(this);
   }
   }
 
 
-  /**
-   * @return tags data
-   *   1. pageContainer.state.tags if editorMode is view
-   *   2. editorContainer.state.tags if editorMode is edit
-   */
-  getTagData() {
-    const { editorContainer, pageContainer, editorMode } = this.props;
-    return (editorMode === 'edit') ? editorContainer.state.tags : pageContainer.state.tags;
-  }
 
 
   openEditorModal() {
   openEditorModal() {
     this.setState({ isTagEditModalShown: true });
     this.setState({ isTagEditModalShown: true });
@@ -44,37 +31,9 @@ class TagLabels extends React.Component {
     this.setState({ isTagEditModalShown: false });
     this.setState({ isTagEditModalShown: false });
   }
   }
 
 
-  async tagsUpdatedHandler(newTags) {
-    const {
-      appContainer, editorContainer, pageContainer, editorMode,
-    } = this.props;
-
-    const { pageId } = pageContainer.state;
-
-    // It will not be reflected in the DB until the page is refreshed
-    if (editorMode === 'edit') {
-      return editorContainer.setState({ tags: newTags });
-    }
-
-    try {
-      const { tags } = await appContainer.apiPost('/tags.update', { pageId, tags: newTags });
-
-      // update pageContainer.state
-      pageContainer.setState({ tags });
-      // update editorContainer.state
-      editorContainer.setState({ tags });
-
-      toastSuccess('updated tags successfully');
-    }
-    catch (err) {
-      toastError(err, 'fail to update tags');
-    }
-  }
-
 
 
   render() {
   render() {
-    const tags = this.getTagData();
-    const { appContainer } = this.props;
+    const { appContainer, tagsUpdateInvoked, tags } = this.props;
 
 
     return (
     return (
       <>
       <>
@@ -95,7 +54,7 @@ class TagLabels extends React.Component {
           isOpen={this.state.isTagEditModalShown}
           isOpen={this.state.isTagEditModalShown}
           onClose={this.closeEditorModal}
           onClose={this.closeEditorModal}
           appContainer={this.props.appContainer}
           appContainer={this.props.appContainer}
-          onTagsUpdated={this.tagsUpdatedHandler}
+          onTagsUpdated={tagsUpdateInvoked}
         />
         />
 
 
       </>
       </>
@@ -107,16 +66,18 @@ class TagLabels extends React.Component {
 /**
 /**
  * Wrapper component for using unstated
  * Wrapper component for using unstated
  */
  */
-const TagLabelsWrapper = withUnstatedContainers(TagLabels, [AppContainer, PageContainer, EditorContainer]);
+const TagLabelsUnstatedWrapper = withUnstatedContainers(TagLabels, [AppContainer]);
 
 
 TagLabels.propTypes = {
 TagLabels.propTypes = {
   t: PropTypes.func.isRequired, // i18next
   t: PropTypes.func.isRequired, // i18next
 
 
   appContainer: PropTypes.instanceOf(AppContainer).isRequired,
   appContainer: PropTypes.instanceOf(AppContainer).isRequired,
-  pageContainer: PropTypes.instanceOf(PageContainer).isRequired,
-  editorContainer: PropTypes.instanceOf(EditorContainer).isRequired,
-
-  editorMode: PropTypes.string.isRequired,
+  tags: PropTypes.arrayOf(String),
+  tagsUpdateInvoked: PropTypes.func,
 };
 };
 
 
+// wrapping tsx component returned by withUnstatedContainers to avoid type error when this component used in other tsx components.
+const TagLabelsWrapper = (props) => {
+  return <TagLabelsUnstatedWrapper {...props}></TagLabelsUnstatedWrapper>;
+};
 export default withTranslation()(TagLabelsWrapper);
 export default withTranslation()(TagLabelsWrapper);

+ 1 - 0
packages/app/src/components/PageContentFooter.jsx

@@ -6,6 +6,7 @@ import AuthorInfo from './Navbar/AuthorInfo';
 import AppContainer from '~/client/services/AppContainer';
 import AppContainer from '~/client/services/AppContainer';
 import PageContainer from '~/client/services/PageContainer';
 import PageContainer from '~/client/services/PageContainer';
 import { withUnstatedContainers } from './UnstatedUtils';
 import { withUnstatedContainers } from './UnstatedUtils';
+import { usePath } from '~/stores/context';
 
 
 const PageContentFooter = (props) => {
 const PageContentFooter = (props) => {
   const { pageContainer } = props;
   const { pageContainer } = props;

+ 8 - 7
packages/app/src/components/PageCreateModal.jsx

@@ -11,9 +11,9 @@ import { pagePathUtils, pathUtils } from '@growi/core';
 
 
 
 
 import AppContainer from '~/client/services/AppContainer';
 import AppContainer from '~/client/services/AppContainer';
-import NavigationContainer from '~/client/services/NavigationContainer';
 import { withUnstatedContainers } from './UnstatedUtils';
 import { withUnstatedContainers } from './UnstatedUtils';
 import { toastError } from '~/client/util/apiNotification';
 import { toastError } from '~/client/util/apiNotification';
+import { usePageCreateModalOpened } from '~/stores/ui';
 
 
 import PagePathAutoComplete from './PagePathAutoComplete';
 import PagePathAutoComplete from './PagePathAutoComplete';
 
 
@@ -22,7 +22,9 @@ const {
 } = pagePathUtils;
 } = pagePathUtils;
 
 
 const PageCreateModal = (props) => {
 const PageCreateModal = (props) => {
-  const { t, appContainer, navigationContainer } = props;
+  const { t, appContainer } = props;
+
+  const { data: isPageCreateModalOpened, mutate: mutatePageCreateModalOpened } = usePageCreateModalOpened();
 
 
   const config = appContainer.getConfig();
   const config = appContainer.getConfig();
   const isReachable = config.isSearchServiceReachable;
   const isReachable = config.isSearchServiceReachable;
@@ -264,12 +266,12 @@ const PageCreateModal = (props) => {
   return (
   return (
     <Modal
     <Modal
       size="lg"
       size="lg"
-      isOpen={navigationContainer.state.isPageCreateModalShown}
-      toggle={navigationContainer.closePageCreateModal}
+      isOpen={isPageCreateModalOpened}
+      toggle={() => mutatePageCreateModalOpened(false)}
       className="grw-create-page"
       className="grw-create-page"
       autoFocus={false}
       autoFocus={false}
     >
     >
-      <ModalHeader tag="h4" toggle={navigationContainer.closePageCreateModal} className="bg-primary text-light">
+      <ModalHeader tag="h4" toggle={() => mutatePageCreateModalOpened(false)} className="bg-primary text-light">
         {t('New Page')}
         {t('New Page')}
       </ModalHeader>
       </ModalHeader>
       <ModalBody>
       <ModalBody>
@@ -286,13 +288,12 @@ const PageCreateModal = (props) => {
 /**
 /**
  * Wrapper component for using unstated
  * Wrapper component for using unstated
  */
  */
-const ModalControlWrapper = withUnstatedContainers(PageCreateModal, [AppContainer, NavigationContainer]);
+const ModalControlWrapper = withUnstatedContainers(PageCreateModal, [AppContainer]);
 
 
 
 
 PageCreateModal.propTypes = {
 PageCreateModal.propTypes = {
   t: PropTypes.func.isRequired, //  i18next
   t: PropTypes.func.isRequired, //  i18next
   appContainer: PropTypes.instanceOf(AppContainer).isRequired,
   appContainer: PropTypes.instanceOf(AppContainer).isRequired,
-  navigationContainer: PropTypes.instanceOf(NavigationContainer).isRequired,
 };
 };
 
 
 export default withTranslation()(ModalControlWrapper);
 export default withTranslation()(ModalControlWrapper);

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

@@ -107,6 +107,12 @@ const PageDeleteModal: FC<Props> = (props: Props) => {
     );
     );
   }
   }
 
 
+  // DeleteCompletely is currently disabled
+  // TODO1 : Retrive isAbleToDeleteCompleltly state everywhere in the system via swr.
+  // Story: https://redmine.weseek.co.jp/issues/82222
+
+  // TODO2 : use toaster
+  // TASK : https://redmine.weseek.co.jp/issues/82299
   function renderDeleteCompletelyForm() {
   function renderDeleteCompletelyForm() {
     return (
     return (
       <div className="custom-control custom-checkbox custom-checkbox-danger">
       <div className="custom-control custom-checkbox custom-checkbox-danger">
@@ -123,7 +129,7 @@ const PageDeleteModal: FC<Props> = (props: Props) => {
         {/* ↓↓ undo this comment out at https://redmine.weseek.co.jp/issues/82222 ↓↓ */}
         {/* ↓↓ undo this comment out at https://redmine.weseek.co.jp/issues/82222 ↓↓ */}
         {/* <label className="custom-control-label text-danger" htmlFor="deleteCompletely"> */}
         {/* <label className="custom-control-label text-danger" htmlFor="deleteCompletely"> */}
         <label className="custom-control-label" htmlFor="deleteCompletely">
         <label className="custom-control-label" htmlFor="deleteCompletely">
-          { t('modal_delete.delete_completely') }
+          { t('modal_delete.delete_completely')}
           <p className="form-text text-muted mt-0"> { t('modal_delete.completely') }</p>
           <p className="form-text text-muted mt-0"> { t('modal_delete.completely') }</p>
         </label>
         </label>
         {!isAbleToDeleteCompletely
         {!isAbleToDeleteCompletely

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

@@ -188,7 +188,7 @@ const PageDuplicateModal = (props) => {
             )}
             )}
           </div>
           </div>
           <div>
           <div>
-            {isDuplicateRecursively && <ComparePathsTable subordinatedPages={subordinatedPages} newPagePath={pageNameInput} />}
+            {isDuplicateRecursively && <ComparePathsTable path={path} subordinatedPages={subordinatedPages} newPagePath={pageNameInput} />}
             {isDuplicateRecursively && existingPaths.length !== 0 && <DuplicatePathsTable existingPaths={existingPaths} oldPagePath={pageNameInput} />}
             {isDuplicateRecursively && existingPaths.length !== 0 && <DuplicatePathsTable existingPaths={existingPaths} oldPagePath={pageNameInput} />}
           </div>
           </div>
         </div>
         </div>

+ 15 - 9
packages/app/src/components/PageEditor/EditorNavbarBottom.jsx

@@ -3,9 +3,12 @@ import PropTypes from 'prop-types';
 
 
 import { Collapse, Button } from 'reactstrap';
 import { Collapse, Button } from 'reactstrap';
 
 
-import NavigationContainer from '~/client/services/NavigationContainer';
 import EditorContainer from '~/client/services/EditorContainer';
 import EditorContainer from '~/client/services/EditorContainer';
 import AppContainer from '~/client/services/AppContainer';
 import AppContainer from '~/client/services/AppContainer';
+import {
+  EditorMode, useDrawerOpened, useEditorMode, useIsDeviceSmallerThanMd,
+} from '~/stores/ui';
+
 import SlackNotification from '../SlackNotification';
 import SlackNotification from '../SlackNotification';
 import SlackLogo from '../SlackLogo';
 import SlackLogo from '../SlackLogo';
 import { withUnstatedContainers } from '../UnstatedUtils';
 import { withUnstatedContainers } from '../UnstatedUtils';
@@ -16,20 +19,24 @@ import OptionsSelector from './OptionsSelector';
 
 
 const EditorNavbarBottom = (props) => {
 const EditorNavbarBottom = (props) => {
 
 
+  const { data: editorMode } = useEditorMode();
+
   const [isExpanded, setExpanded] = useState(false);
   const [isExpanded, setExpanded] = useState(false);
 
 
   const [isSlackExpanded, setSlackExpanded] = useState(false);
   const [isSlackExpanded, setSlackExpanded] = useState(false);
   const isSlackConfigured = props.appContainer.getConfig().isSlackConfigured;
   const isSlackConfigured = props.appContainer.getConfig().isSlackConfigured;
 
 
-  const {
-    navigationContainer,
-  } = props;
-  const { editorMode, isDeviceSmallerThanMd } = navigationContainer.state;
+  const { mutate: mutateDrawerOpened } = useDrawerOpened();
+  const { data: isDeviceSmallerThanMd } = useIsDeviceSmallerThanMd();
 
 
   const additionalClasses = ['grw-editor-navbar-bottom'];
   const additionalClasses = ['grw-editor-navbar-bottom'];
 
 
   const renderDrawerButton = () => (
   const renderDrawerButton = () => (
-    <button type="button" className="btn btn-outline-secondary border-0" onClick={() => navigationContainer.toggleDrawer()}>
+    <button
+      type="button"
+      className="btn btn-outline-secondary border-0"
+      onClick={() => mutateDrawerOpened(true)}
+    >
       <i className="icon-menu"></i>
       <i className="icon-menu"></i>
     </button>
     </button>
   );
   );
@@ -55,7 +62,7 @@ const EditorNavbarBottom = (props) => {
     </div>
     </div>
   );
   );
 
 
-  const isOptionsSelectorEnabled = editorMode !== 'hackmd';
+  const isOptionsSelectorEnabled = editorMode !== EditorMode.HackMD;
   const isCollapsedOptionsSelectorEnabled = isOptionsSelectorEnabled && isDeviceSmallerThanMd;
   const isCollapsedOptionsSelectorEnabled = isOptionsSelectorEnabled && isDeviceSmallerThanMd;
 
 
   return (
   return (
@@ -127,9 +134,8 @@ const EditorNavbarBottom = (props) => {
 };
 };
 
 
 EditorNavbarBottom.propTypes = {
 EditorNavbarBottom.propTypes = {
-  navigationContainer: PropTypes.instanceOf(NavigationContainer).isRequired,
   appContainer: PropTypes.instanceOf(AppContainer).isRequired,
   appContainer: PropTypes.instanceOf(AppContainer).isRequired,
   editorContainer: PropTypes.instanceOf(EditorContainer).isRequired,
   editorContainer: PropTypes.instanceOf(EditorContainer).isRequired,
 };
 };
 
 
-export default withUnstatedContainers(EditorNavbarBottom, [NavigationContainer, EditorContainer, AppContainer]);
+export default withUnstatedContainers(EditorNavbarBottom, [EditorContainer, AppContainer]);

+ 2 - 1
packages/app/src/components/PageEditor/LinkEditModal.jsx

@@ -162,7 +162,8 @@ class LinkEditModal extends React.PureComponent {
       const pageId = isPermanentLink ? pathWithoutFragment.slice(1) : null;
       const pageId = isPermanentLink ? pathWithoutFragment.slice(1) : null;
 
 
       try {
       try {
-        const { page } = await this.props.appContainer.apiGet('/pages.get', { path: pathWithoutFragment, page_id: pageId });
+        const { data } = await this.props.appContainer.apiv3Get('/page', { path: pathWithoutFragment, page_id: pageId });
+        const { page } = data;
         markdown = page.revision.body;
         markdown = page.revision.body;
         permalink = page.id;
         permalink = page.id;
       }
       }

+ 56 - 0
packages/app/src/components/PagePathNav.tsx

@@ -0,0 +1,56 @@
+import React, { FC } from 'react';
+import { DevidedPagePath } from '@growi/core';
+import PagePathHierarchicalLink from './PagePathHierarchicalLink';
+import CopyDropdown from './Page/CopyDropdown';
+
+import LinkedPagePath from '../models/linked-page-path';
+
+
+type Props = {
+  pageId :string,
+  pagePath:string,
+  isSingleLineMode?:boolean,
+  isCompactMode?:boolean,
+}
+
+const PagePathNav: FC<Props> = (props: Props) => {
+  const {
+    pageId, pagePath, isSingleLineMode, isCompactMode,
+  } = props;
+  const dPagePath = new DevidedPagePath(pagePath, false, true);
+
+  let formerLink;
+  let latterLink;
+
+  // one line
+  if (dPagePath.isRoot || dPagePath.isFormerRoot || isSingleLineMode) {
+    const linkedPagePath = new LinkedPagePath(pagePath);
+    latterLink = <PagePathHierarchicalLink linkedPagePath={linkedPagePath} />;
+  }
+  // two line
+  else {
+    const linkedPagePathFormer = new LinkedPagePath(dPagePath.former);
+    const linkedPagePathLatter = new LinkedPagePath(dPagePath.latter);
+    formerLink = <PagePathHierarchicalLink linkedPagePath={linkedPagePathFormer} />;
+    latterLink = <PagePathHierarchicalLink linkedPagePath={linkedPagePathLatter} basePath={dPagePath.former} />;
+  }
+
+  const copyDropdownId = `copydropdown${isCompactMode ? '-subnav-compact' : ''}-${pageId}`;
+  const copyDropdownToggleClassName = 'd-block text-muted bg-transparent btn-copy border-0 py-0';
+
+  return (
+    <div className="grw-page-path-nav">
+      {formerLink}
+      <span className="d-flex align-items-center">
+        <h1 className="m-0">{latterLink}</h1>
+        <div className="mx-2">
+          <CopyDropdown pageId={pageId} pagePath={pagePath} dropdownToggleId={copyDropdownId} dropdownToggleClassName={copyDropdownToggleClassName}>
+            <i className="ti-clipboard"></i>
+          </CopyDropdown>
+        </div>
+      </span>
+    </div>
+  );
+};
+
+export default PagePathNav;

+ 41 - 0
packages/app/src/components/PageReactionButtons.tsx

@@ -0,0 +1,41 @@
+import React, { FC } from 'react';
+import LikeButtons from './LikeButtons';
+import { IUser } from '../interfaces/user';
+import BookmarkButton from './BookmarkButton';
+
+type Props = {
+  sumOfLikers: number,
+  isLiked: boolean,
+  likers: IUser[],
+  onLikeClicked?: ()=>void,
+  sumOfBookmarks: number,
+  isBookmarked: boolean,
+  onBookMarkClicked: ()=>void,
+}
+
+
+const PageReactionButtons : FC<Props> = (props: Props) => {
+  const {
+    sumOfLikers, isLiked, likers, onLikeClicked, sumOfBookmarks, isBookmarked, onBookMarkClicked,
+  } = props;
+
+
+  return (
+    <>
+      <span>
+        <LikeButtons
+          onLikeClicked={onLikeClicked}
+          sumOfLikers={sumOfLikers}
+          isLiked={isLiked}
+          likers={likers}
+        >
+        </LikeButtons>
+      </span>
+      <span>
+        <BookmarkButton sumOfBookmarks={sumOfBookmarks} isBookmarked={isBookmarked} onBookMarkClicked={onBookMarkClicked}></BookmarkButton>
+      </span>
+    </>
+  );
+};
+
+export default PageReactionButtons;

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

@@ -195,7 +195,7 @@ const PageRenameModal = (props) => {
               </label>
               </label>
             </div>
             </div>
           )}
           )}
-          {isRenameRecursively && <ComparePathsTable subordinatedPages={subordinatedPages} newPagePath={pageNameInput} />}
+          {isRenameRecursively && <ComparePathsTable path={path} subordinatedPages={subordinatedPages} newPagePath={pageNameInput} />}
           {isRenameRecursively && existingPaths.length !== 0 && <DuplicatedPathsTable existingPaths={existingPaths} oldPagePath={pageNameInput} />}
           {isRenameRecursively && existingPaths.length !== 0 && <DuplicatedPathsTable existingPaths={existingPaths} oldPagePath={pageNameInput} />}
         </div>
         </div>
 
 

+ 17 - 13
packages/app/src/components/SearchPage.jsx

@@ -36,8 +36,8 @@ class SearchPage extends React.Component {
       searchResultCount: 0,
       searchResultCount: 0,
       activePage: 1,
       activePage: 1,
       pagingLimit: this.props.appContainer.config.pageLimitationL,
       pagingLimit: this.props.appContainer.config.pageLimitationL,
-      excludeUsersHome: true,
-      excludeTrash: true,
+      excludeUserPages: true,
+      excludeTrashPages: true,
       selectAllCheckboxType: CheckboxType.NONE_CHECKED,
       selectAllCheckboxType: CheckboxType.NONE_CHECKED,
       isDeleteConfirmModalShown: false,
       isDeleteConfirmModalShown: false,
       deleteTargetPageIds: new Set(),
       deleteTargetPageIds: new Set(),
@@ -48,8 +48,8 @@ class SearchPage extends React.Component {
     this.onSearchInvoked = this.onSearchInvoked.bind(this);
     this.onSearchInvoked = this.onSearchInvoked.bind(this);
     this.selectPage = this.selectPage.bind(this);
     this.selectPage = this.selectPage.bind(this);
     this.toggleCheckBox = this.toggleCheckBox.bind(this);
     this.toggleCheckBox = this.toggleCheckBox.bind(this);
-    this.onExcludeUsersHome = this.onExcludeUsersHome.bind(this);
-    this.onExcludeTrash = this.onExcludeTrash.bind(this);
+    this.switchExcludeUserPagesHandler = this.switchExcludeUserPagesHandler.bind(this);
+    this.switchExcludeTrashPagesHandler = this.switchExcludeTrashPagesHandler.bind(this);
     this.onPagingNumberChanged = this.onPagingNumberChanged.bind(this);
     this.onPagingNumberChanged = this.onPagingNumberChanged.bind(this);
     this.onPagingLimitChanged = this.onPagingLimitChanged.bind(this);
     this.onPagingLimitChanged = this.onPagingLimitChanged.bind(this);
     this.deleteSinglePageButtonHandler = this.deleteSinglePageButtonHandler.bind(this);
     this.deleteSinglePageButtonHandler = this.deleteSinglePageButtonHandler.bind(this);
@@ -76,12 +76,12 @@ class SearchPage extends React.Component {
     return query;
     return query;
   }
   }
 
 
-  onExcludeUsersHome() {
-    this.setState({ excludeUsersHome: !this.state.excludeUsersHome });
+  switchExcludeUserPagesHandler() {
+    this.setState({ excludeUserPages: !this.state.excludeUserPages });
   }
   }
 
 
-  onExcludeTrash() {
-    this.setState({ excludeTrash: !this.state.excludeTrash });
+  switchExcludeTrashPagesHandler() {
+    this.setState({ excludeTrashPages: !this.state.excludeTrashPages });
   }
   }
 
 
   changeURL(keyword, refreshHash) {
   changeURL(keyword, refreshHash) {
@@ -99,10 +99,10 @@ class SearchPage extends React.Component {
     let query = keyword;
     let query = keyword;
 
 
     // pages included in specific path are not retrived when prefix is added
     // pages included in specific path are not retrived when prefix is added
-    if (this.state.excludeTrash) {
+    if (this.state.excludeTrashPages) {
       query = `${query} -prefix:${specificPathNames.trash}`;
       query = `${query} -prefix:${specificPathNames.trash}`;
     }
     }
-    if (this.state.excludeUsersHome) {
+    if (this.state.excludeUserPages) {
       query = `${query} -prefix:${specificPathNames.user}`;
       query = `${query} -prefix:${specificPathNames.user}`;
     }
     }
 
 
@@ -130,6 +130,8 @@ class SearchPage extends React.Component {
     this.setState({ pagingLimit: limit }, () => this.search({ keyword: this.state.searchedKeyword }));
     this.setState({ pagingLimit: limit }, () => this.search({ keyword: this.state.searchedKeyword }));
   }
   }
 
 
+  // todo: refactoring
+  // refs: https://redmine.weseek.co.jp/issues/82139
   async search(data) {
   async search(data) {
     const keyword = data.keyword;
     const keyword = data.keyword;
     if (keyword === '') {
     if (keyword === '') {
@@ -289,11 +291,13 @@ class SearchPage extends React.Component {
         searchResultCount={this.state.searchResultCount || 0}
         searchResultCount={this.state.searchResultCount || 0}
         appContainer={this.props.appContainer}
         appContainer={this.props.appContainer}
         onSearchInvoked={this.onSearchInvoked}
         onSearchInvoked={this.onSearchInvoked}
-        onExcludeUsersHome={this.onExcludeUsersHome}
-        onExcludeTrash={this.onExcludeTrash}
-        onClickSelectAllCheckbox={this.toggleAllCheckBox}
+        onClickSelectAllCheckbox={this.toggleAllCheck        onClickSelectAllCheckbox={this.toggleAllCheckBox}
         selectAllCheckboxType={this.state.selectAllCheckboxType}
         selectAllCheckboxType={this.state.selectAllCheckboxType}
         onClickDeleteAllButton={this.deleteAllPagesButtonHandler}
         onClickDeleteAllButton={this.deleteAllPagesButtonHandler}
+        onExcludeUserPagesSwitched={this.switchExcludeUserPagesHandler}
+        onExcludeTrashPagesSwitched={this.switchExcludeTrashPagesHandler}
+        excludeUserPages={this.state.excludeUserPages}
+        excludeTrashPages={this.state.excludeTrashPages}
       >
       >
       </SearchControl>
       </SearchControl>
     );
     );

+ 62 - 16
packages/app/src/components/SearchPage/SearchControl.tsx

@@ -1,8 +1,9 @@
-import React, { FC } from 'react';
+import React, { FC, useState } from 'react';
 import { useTranslation } from 'react-i18next';
 import { useTranslation } from 'react-i18next';
 import SearchPageForm from './SearchPageForm';
 import SearchPageForm from './SearchPageForm';
 import AppContainer from '../../client/services/AppContainer';
 import AppContainer from '../../client/services/AppContainer';
 import DeleteSelectedPageGroup from './DeleteSelectedPageGroup';
 import DeleteSelectedPageGroup from './DeleteSelectedPageGroup';
+import SearchOptionModal from './SearchOptionModal';
 import { CheckboxType } from '../../interfaces/search';
 import { CheckboxType } from '../../interfaces/search';
 
 
 type Props = {
 type Props = {
@@ -10,32 +11,64 @@ type Props = {
   appContainer: AppContainer,
   appContainer: AppContainer,
   searchResultCount: number,
   searchResultCount: number,
   selectAllCheckboxType: CheckboxType,
   selectAllCheckboxType: CheckboxType,
-  onSearchInvoked: (data : any[]) => boolean,
-  onExcludeUsersHome?: () => void,
-  onExcludeTrash?: () => void,
   onClickDeleteAllButton?: () => void
   onClickDeleteAllButton?: () => void
   onClickSelectAllCheckbox?: (nextSelectAllCheckboxType: CheckboxType) => void,
   onClickSelectAllCheckbox?: (nextSelectAllCheckboxType: CheckboxType) => void,
+  excludeUserPages: boolean,
+  excludeTrashPages: boolean,
+  onSearchInvoked: (data: {keyword: string}) => boolean,
+  onExcludeUserPagesSwitched?: () => void,
+  onExcludeTrashPagesSwitched?: () => void,
 }
 }
 
 
 const SearchControl: FC <Props> = (props: Props) => {
 const SearchControl: FC <Props> = (props: Props) => {
+
+  const [isFileterOptionModalShown, setIsFileterOptionModalShown] = useState(false);
   // Temporaly workaround for lint error
   // Temporaly workaround for lint error
   // later needs to be fixed: SearchControl to typescript componet
   // later needs to be fixed: SearchControl to typescript componet
   const SearchPageFormTypeAny : any = SearchPageForm;
   const SearchPageFormTypeAny : any = SearchPageForm;
   const { t } = useTranslation('');
   const { t } = useTranslation('');
   const { searchResultCount } = props;
   const { searchResultCount } = props;
 
 
-  const onExcludeUsersHome = () => {
-    if (props.onExcludeUsersHome != null) {
-      props.onExcludeUsersHome();
+  const switchExcludeUserPagesHandler = () => {
+    if (props.onExcludeUserPagesSwitched != null) {
+      props.onExcludeUserPagesSwitched();
+    }
+  };
+
+  const switchExcludeTrashPagesHandler = () => {
+    if (props.onExcludeTrashPagesSwitched != null) {
+      props.onExcludeTrashPagesSwitched();
     }
     }
   };
   };
 
 
-  const onExcludeTrash = () => {
-    if (props.onExcludeTrash != null) {
-      props.onExcludeTrash();
+  const openSearchOptionModalHandler = () => {
+    setIsFileterOptionModalShown(true);
+  };
+
+  const closeSearchOptionModalHandler = () => {
+    setIsFileterOptionModalShown(false);
+  };
+
+  const onRetrySearchInvoked = () => {
+    if (props.onSearchInvoked != null) {
+      props.onSearchInvoked({ keyword: props.searchingKeyword });
     }
     }
   };
   };
 
 
+  const rednerSearchOptionModal = () => {
+    return (
+      <SearchOptionModal
+        isOpen={isFileterOptionModalShown || false}
+        onClickFilteringSearchResult={onRetrySearchInvoked}
+        onClose={closeSearchOptionModalHandler}
+        onExcludeUserPagesSwitched={switchExcludeUserPagesHandler}
+        onExcludeTrashPagesSwitched={switchExcludeTrashPagesHandler}
+        excludeUserPages={props.excludeUserPages}
+        excludeTrashPages={props.excludeTrashPages}
+      />
+    );
+  };
+
   return (
   return (
     <>
     <>
       <div className="search-page-nav d-flex py-3 align-items-center">
       <div className="search-page-nav d-flex py-3 align-items-center">
@@ -52,22 +85,34 @@ const SearchControl: FC <Props> = (props: Props) => {
         </div>
         </div>
       </div>
       </div>
       {/* TODO: replace the following elements deleteAll button , relevance button and include specificPath button component */}
       {/* TODO: replace the following elements deleteAll button , relevance button and include specificPath button component */}
-      <div className="d-flex my-4">
-        {/* Todo: design will be fixed in #80324. Function will be implemented in #77525 */}
-        <DeleteSelectedPageGroup
+      <div className="d-flex align-items-center py-3 border-bottom border-gray">
+        <div className="d-flex mr-auto ml-3">
+          {/* Todo: design will be fixed in #80324. Function will be implemented in #77525 */}
+          <DeleteSelectedPageGroup
           isSelectAllCheckboxDisabled={searchResultCount === 0}
           isSelectAllCheckboxDisabled={searchResultCount === 0}
           selectAllCheckboxType={props.selectAllCheckboxType}
           selectAllCheckboxType={props.selectAllCheckboxType}
           onClickDeleteAllButton={props.onClickDeleteAllButton}
           onClickDeleteAllButton={props.onClickDeleteAllButton}
           onClickSelectAllCheckbox={props.onClickSelectAllCheckbox}
           onClickSelectAllCheckbox={props.onClickSelectAllCheckbox}
         />
         />
-        <div className="d-flex align-items-center rounded border-gray px-2 py-1 mr-2 ml-auto">
+        </div>
+        {/** filter option */}
+        <div className="d-lg-none mr-4">
+          <button
+            type="button"
+            className="btn"
+            onClick={openSearchOptionModalHandler}
+          >
+            <i className="icon-equalizer"></i>
+          </button>
+        </div>
+        <div className="d-none d-lg-flex align-items-center mr-3">
           <div className="border border-gray mr-3">
           <div className="border border-gray mr-3">
             <label className="px-3 py-2 mb-0 d-flex align-items-center" htmlFor="flexCheckDefault">
             <label className="px-3 py-2 mb-0 d-flex align-items-center" htmlFor="flexCheckDefault">
               <input
               <input
                 className="mr-2"
                 className="mr-2"
                 type="checkbox"
                 type="checkbox"
                 id="flexCheckDefault"
                 id="flexCheckDefault"
-                onClick={() => onExcludeUsersHome()}
+                onClick={switchExcludeUserPagesHandler}
               />
               />
               {t('Include Subordinated Target Page', { target: '/user' })}
               {t('Include Subordinated Target Page', { target: '/user' })}
             </label>
             </label>
@@ -78,13 +123,14 @@ const SearchControl: FC <Props> = (props: Props) => {
                 className="mr-2"
                 className="mr-2"
                 type="checkbox"
                 type="checkbox"
                 id="flexCheckChecked"
                 id="flexCheckChecked"
-                onClick={() => onExcludeTrash()}
+                onClick={switchExcludeTrashPagesHandler}
               />
               />
               {t('Include Subordinated Target Page', { target: '/trash' })}
               {t('Include Subordinated Target Page', { target: '/trash' })}
             </label>
             </label>
           </div>
           </div>
         </div>
         </div>
       </div>
       </div>
+      {rednerSearchOptionModal()}
     </>
     </>
   );
   );
 };
 };

+ 83 - 0
packages/app/src/components/SearchPage/SearchOptionModal.tsx

@@ -0,0 +1,83 @@
+import React, { FC } from 'react';
+import { useTranslation } from 'react-i18next';
+
+import {
+  Modal, ModalHeader, ModalBody, ModalFooter,
+} from 'reactstrap';
+
+
+type Props = {
+  isOpen: boolean,
+  excludeUserPages: boolean,
+  excludeTrashPages: boolean,
+  onClose?: () => void,
+  onExcludeUserPagesSwitched?: () => void,
+  onExcludeTrashPagesSwitched?: () => void,
+  onClickFilteringSearchResult?: () => void,
+}
+
+const SearchOptionModal: FC<Props> = (props: Props) => {
+
+  const { t } = useTranslation('');
+
+  const {
+    isOpen, onClose, excludeUserPages, excludeTrashPages,
+  } = props;
+
+  const onCloseModal = () => {
+    if (onClose != null) {
+      onClose();
+    }
+  };
+
+  const onClickFilteringSearchResult = () => {
+    if (props.onClickFilteringSearchResult != null) {
+      props.onClickFilteringSearchResult();
+      onCloseModal();
+    }
+  };
+
+  return (
+    <Modal size="lg" isOpen={isOpen} toggle={onCloseModal} autoFocus={false}>
+      <ModalHeader tag="h4" toggle={onCloseModal} className="bg-primary text-light">
+        Search Option
+      </ModalHeader>
+      <ModalBody>
+        <div className="d-flex p-3">
+          <div className="border border-gray mr-3">
+            <label className="px-3 py-2 mb-0 d-flex align-items-center">
+              <input
+                className="mr-2"
+                type="checkbox"
+                onClick={props.onExcludeUserPagesSwitched}
+                checked={!excludeUserPages}
+              />
+              {t('Include Subordinated Target Page', { target: '/user' })}
+            </label>
+          </div>
+          <div className="border border-gray">
+            <label className="px-3 py-2 mb-0 d-flex align-items-center">
+              <input
+                className="mr-2"
+                type="checkbox"
+                onClick={props.onExcludeTrashPagesSwitched}
+                checked={!excludeTrashPages}
+              />
+              {t('Include Subordinated Target Page', { target: '/trash' })}
+            </label>
+          </div>
+        </div>
+      </ModalBody>
+      <ModalFooter>
+        <button
+          type="button"
+          className="btn btn-secondary"
+          onClick={onClickFilteringSearchResult}
+        >{t('search_result.search_again')}
+        </button>
+      </ModalFooter>
+    </Modal>
+  );
+};
+
+export default SearchOptionModal;

+ 15 - 18
packages/app/src/components/SearchPage/SearchResultContent.tsx

@@ -4,35 +4,32 @@ import { IPageSearchResultData } from '../../interfaces/search';
 
 
 import RevisionLoader from '../Page/RevisionLoader';
 import RevisionLoader from '../Page/RevisionLoader';
 import AppContainer from '../../client/services/AppContainer';
 import AppContainer from '../../client/services/AppContainer';
+import SearchResultContentSubNavigation from './SearchResultContentSubNavigation';
 
 
+// TODO : set focusedPage type to ?IPageSearchResultData once #80214 is merged
+// PR: https://github.com/weseek/growi/pull/4649
 
 
 type Props ={
 type Props ={
   appContainer: AppContainer,
   appContainer: AppContainer,
   searchingKeyword:string,
   searchingKeyword:string,
   focusedSearchResultData : IPageSearchResultData,
   focusedSearchResultData : IPageSearchResultData,
 }
 }
+
+
 const SearchResultContent: FC<Props> = (props: Props) => {
 const SearchResultContent: FC<Props> = (props: Props) => {
-  const page = props.focusedSearchResultData?.pageData || {};
-  if (page == null) return null;
-  // Temporaly workaround for lint error
-  // later needs to be fixed: RevisoinRender to typescriptcomponet
-  const RevisionLoaderTypeAny: any = RevisionLoader;
+  const page = props.focusedSearchResultData?.pageData;
+  // return if page is null
+  if (page == null) return <></>;
   const growiRenderer = props.appContainer.getRenderer('searchresult');
   const growiRenderer = props.appContainer.getRenderer('searchresult');
-  let showTags = false;
-  if (page.tags != null && page.tags.length > 0) { showTags = true }
   return (
   return (
     <div key={page._id} className="search-result-page mb-5">
     <div key={page._id} className="search-result-page mb-5">
-      <h2>
-        <a href={page.path} className="text-break">
-          {page.path}
-        </a>
-        {showTags && (
-          <div className="mt-1 small">
-            <i className="tag-icon icon-tag"></i> {page.tags?.join(', ')}
-          </div>
-        )}
-      </h2>
-      <RevisionLoaderTypeAny
+      <SearchResultContentSubNavigation
+        pageId={page._id}
+        revisionId={page.revision}
+        path={page.path}
+      >
+      </SearchResultContentSubNavigation>
+      <RevisionLoader
         growiRenderer={growiRenderer}
         growiRenderer={growiRenderer}
         pageId={page._id}
         pageId={page._id}
         pagePath={page.path}
         pagePath={page.path}

+ 91 - 0
packages/app/src/components/SearchPage/SearchResultContentSubNavigation.tsx

@@ -0,0 +1,91 @@
+import React, { FC, useCallback } from 'react';
+import { pagePathUtils } from '@growi/core';
+import PagePathNav from '../PagePathNav';
+import { withUnstatedContainers } from '../UnstatedUtils';
+import AppContainer from '../../client/services/AppContainer';
+import TagLabels from '../Page/TagLabels';
+import { toastSuccess, toastError } from '../../client/util/apiNotification';
+import { apiPost } from '../../client/util/apiv1-client';
+import { useSWRTagsInfo } from '../../stores/page';
+import SubNavButtons from '../Navbar/SubNavButtons';
+
+type Props = {
+  appContainer:AppContainer
+  pageId: string,
+  revisionId: string,
+  path: string,
+  isSignleLineMode?: boolean,
+  isCompactMode?: boolean,
+}
+
+
+const SearchResultContentSubNavigation: FC<Props> = (props : Props) => {
+  const {
+    appContainer, pageId, revisionId, path, isCompactMode, isSignleLineMode,
+  } = props;
+
+  const { isTrashPage, isDeletablePage } = pagePathUtils;
+
+  const { data: tagInfoData, error: tagInfoError, mutate: mutateTagInfo } = useSWRTagsInfo(pageId);
+
+  const tagsUpdatedHandler = useCallback(async(newTags) => {
+    try {
+      await apiPost('/tags.update', { pageId, tags: newTags });
+      toastSuccess('updated tags successfully');
+      mutateTagInfo();
+    }
+    catch (err) {
+      toastError(err, 'fail to update tags');
+    }
+  }, [pageId, mutateTagInfo]);
+
+  if (tagInfoError != null || tagInfoData == null) {
+    return <></>;
+  }
+  const isPageDeletable = isDeletablePage(path);
+  const { isSharedUser } = appContainer;
+  const isAbleToShowPageManagement = !(isTrashPage(path)) && !isSharedUser;
+  return (
+    <div className={`grw-subnav container-fluid d-flex align-items-center justify-content-between ${isCompactMode ? 'grw-subnav-compact d-print-none' : ''}`}>
+      {/* Left side */}
+      <div className="grw-path-nav-container">
+        {!isSharedUser && !isCompactMode && (
+          <div className="grw-taglabels-container">
+            <TagLabels tags={tagInfoData.tags} tagsUpdateInvoked={tagsUpdatedHandler} />
+          </div>
+        )}
+        <PagePathNav pageId={pageId} pagePath={path} isCompactMode={isCompactMode} isSingleLineMode={isSignleLineMode} />
+      </div>
+      {/* Right side */}
+      {/*
+        DeleteCompletely is currently disabled
+        TODO : Retrive isAbleToDeleteCompleltly state everywhere in the system via swr.
+        story: https://redmine.weseek.co.jp/issues/82222
+      */}
+      <div className="d-flex">
+        <SubNavButtons
+          isCompactMode={isCompactMode}
+          pageId={pageId}
+          revisionId={revisionId}
+          path={path}
+          isDeletable={isPageDeletable}
+          // isAbleToDeleteCompletely={}
+          willShowPageManagement={isAbleToShowPageManagement}
+        >
+        </SubNavButtons>
+      </div>
+    </div>
+  );
+};
+
+
+/**
+ * Wrapper component for using unstated
+ */
+const SearchResultContentSubNavigationUnstatedWrapper = withUnstatedContainers(SearchResultContentSubNavigation, [AppContainer]);
+
+// wrapping tsx component returned by withUnstatedContainers to avoid type error when this component used in other tsx components.
+const SearchResultContentSubNavigationWrapper = (props) => {
+  return <SearchResultContentSubNavigationUnstatedWrapper {...props}></SearchResultContentSubNavigationUnstatedWrapper>;
+};
+export default SearchResultContentSubNavigationWrapper;

+ 10 - 3
packages/app/src/components/SearchPage/SearchResultListItem.tsx

@@ -89,9 +89,15 @@ const SearchResultListItem: FC<Props> = (props:Props) => {
   // Add prefix 'id_' in pageId, because scrollspy of bootstrap doesn't work when the first letter of id attr of target component is numeral.
   // Add prefix 'id_' in pageId, because scrollspy of bootstrap doesn't work when the first letter of id attr of target component is numeral.
   const pageId = `#${pageData._id}`;
   const pageId = `#${pageData._id}`;
 
 
-  const isPathIncludedHtml = pageMeta.elasticSearchResult.highlightedPath != null;
+  const isPathIncludedHtml = pageMeta.elasticSearchResult?.highlightedPath != null || pageData.path != null;
   const dPagePath = new DevidedPagePath(pageData.path, false, true);
   const dPagePath = new DevidedPagePath(pageData.path, false, true);
-  const pagePathElem = <PagePathLabel path={pageMeta.elasticSearchResult.highlightedPath} isFormerOnly isPathIncludedHtml={isPathIncludedHtml} />;
+  const pagePathElem = (
+    <PagePathLabel
+      path={pageMeta.elasticSearchResult?.highlightedPath || pageData.path}
+      isFormerOnly
+      isPathIncludedHtml={isPathIncludedHtml}
+    />
+  );
 
 
   return (
   return (
     <li key={pageData._id} className={`page-list-li search-page-item w-100 border-bottom px-4 list-group-item-action ${isSelected ? 'active' : ''}`}>
     <li key={pageData._id} className={`page-list-li search-page-item w-100 border-bottom px-4 list-group-item-action ${isSelected ? 'active' : ''}`}>
@@ -140,7 +146,8 @@ const SearchResultListItem: FC<Props> = (props:Props) => {
               <Clamp
               <Clamp
                 lines={2}
                 lines={2}
               >
               >
-                {pageMeta.elasticSearchResult && <div className="mt-1" dangerouslySetInnerHTML={{ __html: pageMeta.elasticSearchResult.snippet }}></div>}
+                {pageMeta.elasticSearchResult != null
+                && <div className="mt-1" dangerouslySetInnerHTML={{ __html: pageMeta.elasticSearchResult.snippet }}></div>}
               </Clamp>
               </Clamp>
             </div>
             </div>
           </div>
           </div>

+ 0 - 242
packages/app/src/components/Sidebar.jsx

@@ -1,242 +0,0 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-
-import {
-  withNavigationUIController,
-  LayoutManager,
-  NavigationProvider,
-  ThemeProvider,
-} from '@atlaskit/navigation-next';
-
-import { withUnstatedContainers } from './UnstatedUtils';
-import AppContainer from '~/client/services/AppContainer';
-import NavigationContainer from '~/client/services/NavigationContainer';
-
-import DrawerToggler from './Navbar/DrawerToggler';
-
-import SidebarNav from './Sidebar/SidebarNav';
-import SidebarContents from './Sidebar/SidebarContents';
-import StickyStretchableScroller from './StickyStretchableScroller';
-
-const sidebarDefaultWidth = 320;
-
-class Sidebar extends React.Component {
-
-  static propTypes = {
-    appContainer: PropTypes.instanceOf(AppContainer).isRequired,
-    navigationContainer: PropTypes.instanceOf(NavigationContainer).isRequired,
-    navigationUIController: PropTypes.any.isRequired,
-    isDrawerModeOnInit: PropTypes.bool,
-  };
-
-  componentWillMount() {
-    this.hackUIController();
-  }
-
-  componentDidUpdate(prevProps, prevState) {
-    this.toggleDrawerMode(this.isDrawerMode);
-  }
-
-  /**
-   * hack and override UIController.storeState
-   *
-   * Since UIController is an unstated container, setState() in storeState method should be awaited before writing to cache.
-   */
-  hackUIController() {
-    const { navigationUIController } = this.props;
-
-    // see: @atlaskit/navigation-next/dist/esm/ui-controller/UIController.js
-    const orgStoreState = navigationUIController.storeState;
-    navigationUIController.storeState = async(state) => {
-      await navigationUIController.setState(state);
-      orgStoreState(state);
-    };
-  }
-
-  /**
-   * return whether drawer mode or not
-   */
-  get isDrawerMode() {
-    let isDrawerMode = this.props.navigationContainer.state.isDrawerMode;
-    if (isDrawerMode == null) {
-      isDrawerMode = this.props.isDrawerModeOnInit;
-    }
-    return isDrawerMode;
-  }
-
-  toggleDrawerMode(bool) {
-    const { navigationUIController } = this.props;
-
-    const isStateModified = navigationUIController.state.isResizeDisabled !== bool;
-    if (!isStateModified) {
-      return;
-    }
-
-    // Drawer <-- Dock
-    if (bool) {
-      // cache state
-      this.sidebarCollapsedCached = navigationUIController.state.isCollapsed;
-      this.sidebarWidthCached = navigationUIController.state.productNavWidth;
-
-      // clear transition temporary
-      if (this.sidebarCollapsedCached) {
-        this.addCssClassTemporary('grw-sidebar-supress-transitions-to-drawer');
-      }
-
-      navigationUIController.disableResize();
-
-      // fix width
-      navigationUIController.setState({ productNavWidth: sidebarDefaultWidth });
-    }
-    // Drawer --> Dock
-    else {
-      // clear transition temporary
-      if (this.sidebarCollapsedCached) {
-        this.addCssClassTemporary('grw-sidebar-supress-transitions-to-dock');
-      }
-
-      navigationUIController.enableResize();
-
-      // restore width
-      if (this.sidebarWidthCached != null) {
-        navigationUIController.setState({ productNavWidth: this.sidebarWidthCached });
-      }
-    }
-  }
-
-  get sidebarElem() {
-    return document.querySelector('.grw-sidebar');
-  }
-
-  addCssClassTemporary(className) {
-    // clear
-    this.sidebarElem.classList.add(className);
-
-    // restore after 300ms
-    setTimeout(() => {
-      this.sidebarElem.classList.remove(className);
-    }, 300);
-  }
-
-  backdropClickedHandler = () => {
-    const { navigationContainer } = this.props;
-    navigationContainer.toggleDrawer();
-  }
-
-  itemSelectedHandler = (contentsId) => {
-    const { navigationContainer, navigationUIController } = this.props;
-    const { sidebarContentsId } = navigationContainer.state;
-
-    // already selected
-    if (sidebarContentsId === contentsId) {
-      navigationUIController.toggleCollapse();
-    }
-    // switch and expand
-    else {
-      navigationUIController.expand();
-    }
-  }
-
-  calcViewHeight() {
-    const scrollTargetElem = document.querySelector('#grw-sidebar-contents-scroll-target');
-    return window.innerHeight - scrollTargetElem.getBoundingClientRect().top;
-  }
-
-  renderGlobalNavigation = () => (
-    <SidebarNav onItemSelected={this.itemSelectedHandler} />
-  );
-
-  renderSidebarContents = () => {
-    const scrollTargetSelector = '#grw-sidebar-contents-scroll-target';
-
-    return (
-      <>
-        <StickyStretchableScroller
-          scrollTargetSelector={scrollTargetSelector}
-          contentsElemSelector="#grw-sidebar-content-container"
-          stickyElemSelector=".grw-sidebar"
-          calcViewHeightFunc={this.calcViewHeight}
-        />
-
-        <div id="grw-sidebar-contents-scroll-target">
-          <div id="grw-sidebar-content-container">
-            <SidebarContents
-              isSharedUser={this.props.appContainer.isSharedUser}
-            />
-          </div>
-        </div>
-
-        <DrawerToggler iconClass="icon-arrow-left" />
-      </>
-    );
-  };
-
-  render() {
-    const { isDrawerOpened } = this.props.navigationContainer.state;
-
-    return (
-      <>
-        <div className={`grw-sidebar d-print-none ${this.isDrawerMode ? 'grw-sidebar-drawer' : ''} ${isDrawerOpened ? 'open' : ''}`}>
-          <ThemeProvider
-            theme={theme => ({
-              ...theme,
-              context: 'product',
-            })}
-          >
-            <LayoutManager
-              globalNavigation={this.renderGlobalNavigation}
-              productNavigation={() => null}
-              containerNavigation={this.renderSidebarContents}
-              experimental_hideNavVisuallyOnCollapse
-              experimental_flyoutOnHover
-              experimental_alternateFlyoutBehaviour
-              experimental_fullWidthFlyout
-              shouldHideGlobalNavShadow
-              showContextualNavigation
-            >
-            </LayoutManager>
-          </ThemeProvider>
-        </div>
-
-        { isDrawerOpened && (
-          <div className="grw-sidebar-backdrop modal-backdrop show" onClick={this.backdropClickedHandler}></div>
-        ) }
-      </>
-    );
-  }
-
-}
-
-
-const SidebarWithNavigationUIController = withNavigationUIController(Sidebar);
-
-/**
- * Wrapper component for using unstated
- */
-
-const SidebarWithNavigation = (props) => {
-  const { preferDrawerModeByUser: isDrawerModeOnInit } = props.navigationContainer.state;
-
-  const initUICForDrawerMode = isDrawerModeOnInit
-    // generate initialUIController for Drawer mode
-    ? {
-      isCollapsed: false,
-      isResizeDisabled: true,
-      productNavWidth: sidebarDefaultWidth,
-    }
-    // set undefined (should be initialized by cache)
-    : undefined;
-
-  return (
-    <NavigationProvider initialUIController={initUICForDrawerMode}>
-      <SidebarWithNavigationUIController {...props} isDrawerModeOnInit={isDrawerModeOnInit} />
-    </NavigationProvider>
-  );
-};
-
-SidebarWithNavigation.propTypes = {
-  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
-  navigationContainer: PropTypes.instanceOf(NavigationContainer).isRequired,
-};
-
-export default withUnstatedContainers(SidebarWithNavigation, [AppContainer, NavigationContainer]);

+ 341 - 0
packages/app/src/components/Sidebar.tsx

@@ -0,0 +1,341 @@
+import React, {
+  FC, useCallback, useEffect, useRef, useState,
+} from 'react';
+
+import { scheduleToPutUserUISettings } from '~/client/services/user-ui-settings';
+import {
+  useDrawerMode, useDrawerOpened,
+  useSidebarCollapsed,
+  useCurrentSidebarContents,
+  useCurrentProductNavWidth,
+  useSidebarResizeDisabled,
+} from '~/stores/ui';
+
+import DrawerToggler from './Navbar/DrawerToggler';
+
+import SidebarNav from './Sidebar/SidebarNav';
+import SidebarContents from './Sidebar/SidebarContents';
+import { NavigationResizeHexagon } from './Sidebar/NavigationResizeHexagon';
+import StickyStretchableScroller from './StickyStretchableScroller';
+
+const sidebarMinWidth = 240;
+const sidebarMinimizeWidth = 20;
+
+const GlobalNavigation = () => {
+  const { data: currentContents } = useCurrentSidebarContents();
+  const { data: isCollapsed, mutate: mutateSidebarCollapsed } = useSidebarCollapsed();
+
+  const itemSelectedHandler = useCallback((selectedContents) => {
+
+    let newValue = false;
+
+    // already selected
+    if (currentContents === selectedContents) {
+      // toggle collapsed
+      newValue = !isCollapsed;
+    }
+
+    mutateSidebarCollapsed(newValue, false);
+    scheduleToPutUserUISettings({ isSidebarCollapsed: newValue });
+
+  }, [currentContents, isCollapsed, mutateSidebarCollapsed]);
+
+  return <SidebarNav onItemSelected={itemSelectedHandler} />;
+};
+
+// dummy skelton contents
+const GlobalNavigationSkelton = () => {
+  return (
+    <div className="grw-sidebar-nav">
+      <div className="grw-sidebar-nav-primary-container">
+      </div>
+      <div className="grw-sidebar-nav-secondary-container">
+      </div>
+    </div>
+  );
+};
+
+
+const SidebarContentsWrapper = () => {
+  const scrollTargetSelector = '#grw-sidebar-contents-scroll-target';
+
+  const calcViewHeight = useCallback(() => {
+    const scrollTargetElem = document.querySelector('#grw-sidebar-contents-scroll-target');
+    return scrollTargetElem != null
+      ? window.innerHeight - scrollTargetElem?.getBoundingClientRect().top
+      : window.innerHeight;
+  }, []);
+
+  return (
+    <>
+      <StickyStretchableScroller
+        scrollTargetSelector={scrollTargetSelector}
+        contentsElemSelector="#grw-sidebar-content-container"
+        stickyElemSelector=".grw-sidebar"
+        calcViewHeightFunc={calcViewHeight}
+      />
+
+      <div id="grw-sidebar-contents-scroll-target">
+        <div id="grw-sidebar-content-container">
+          <SidebarContents />
+        </div>
+      </div>
+
+      <DrawerToggler iconClass="icon-arrow-left" />
+    </>
+  );
+};
+
+// dummy skelton contents
+const SidebarSkeltonContents = () => {
+  return (
+    <div>Skelton Contents!!!</div>
+  );
+};
+
+
+type Props = {
+}
+
+const Sidebar: FC<Props> = (props: Props) => {
+  const { data: isDrawerMode } = useDrawerMode();
+  const { data: isDrawerOpened, mutate: mutateDrawerOpened } = useDrawerOpened();
+  const { data: currentProductNavWidth, mutate: mutateProductNavWidth } = useCurrentProductNavWidth();
+  const { data: isCollapsed, mutate: mutateSidebarCollapsed } = useSidebarCollapsed();
+  const { data: isResizeDisabled, mutate: mutateSidebarResizeDisabled } = useSidebarResizeDisabled();
+
+  const [isHover, setHover] = useState(false);
+  const [isDragging, setDrag] = useState(false);
+  const [isMounted, setMounted] = useState(false);
+
+  const isResizableByDrag = !isResizeDisabled && !isDrawerMode && (!isCollapsed || isHover);
+  /**
+   * hack and override UIController.storeState
+   *
+   * Since UIController is an unstated container, setState() in storeState method should be awaited before writing to cache.
+   */
+  // hackUIController() {
+  //   const { navigationUIController } = this.props;
+
+  //   // see: @atlaskit/navigation-next/dist/esm/ui-controller/UIController.js
+  //   const orgStoreState = navigationUIController.storeState;
+  //   navigationUIController.storeState = async(state) => {
+  //     await navigationUIController.setState(state);
+  //     orgStoreState(state);
+  //   };
+  // }
+
+  const toggleDrawerMode = useCallback((bool) => {
+    const isStateModified = isResizeDisabled !== bool;
+    if (!isStateModified) {
+      return;
+    }
+
+    // Drawer <-- Dock
+    if (bool) {
+      // // cache state
+      // this.sidebarCollapsedCached = navigationUIController.state.isCollapsed;
+      // this.sidebarWidthCached = navigationUIController.state.productNavWidth;
+
+      // // clear transition temporary
+      // if (this.sidebarCollapsedCached) {
+      //   this.addCssClassTemporary('grw-sidebar-supress-transitions-to-drawer');
+      // }
+
+      // disable resize
+      mutateSidebarResizeDisabled(true, false);
+    }
+    // Drawer --> Dock
+    else {
+      // // clear transition temporary
+      // if (this.sidebarCollapsedCached) {
+      //   this.addCssClassTemporary('grw-sidebar-supress-transitions-to-dock');
+      // }
+
+      // enable resize
+      mutateSidebarResizeDisabled(false, false);
+
+      // // restore width
+      // if (this.sidebarWidthCached != null) {
+      //   navigationUIController.setState({ productNavWidth: this.sidebarWidthCached });
+      // }
+    }
+  }, [isResizeDisabled, mutateSidebarResizeDisabled]);
+
+  // addCssClassTemporary(className) {
+  //   // clear
+  //   this.sidebarElem.classList.add(className);
+
+  //   // restore after 300ms
+  //   setTimeout(() => {
+  //     this.sidebarElem.classList.remove(className);
+  //   }, 300);
+  // }
+
+  const backdropClickedHandler = useCallback(() => {
+    mutateDrawerOpened(false, false);
+  }, [mutateDrawerOpened]);
+
+  useEffect(() => {
+    // this.hackUIController();
+    setMounted(true);
+  }, []);
+
+  useEffect(() => {
+    toggleDrawerMode(isDrawerMode);
+  }, [isDrawerMode, toggleDrawerMode]);
+
+  const resizableContainer = useRef<HTMLDivElement>(null);
+  const setContentWidth = useCallback((newWidth) => {
+    if (resizableContainer.current == null) {
+      return;
+    }
+    resizableContainer.current.style.width = `${newWidth}px`;
+  }, []);
+
+  const hoverOnResizableContainerHandler = useCallback(() => {
+    if (!isCollapsed || isDrawerMode || isDragging) {
+      return;
+    }
+
+    setHover(true);
+    setContentWidth(currentProductNavWidth);
+  }, [isCollapsed, isDrawerMode, isDragging, setContentWidth, currentProductNavWidth]);
+
+  const hoverOutHandler = useCallback(() => {
+    if (!isCollapsed || isDrawerMode || isDragging) {
+      return;
+    }
+
+    setHover(false);
+    setContentWidth(sidebarMinimizeWidth);
+  }, [isCollapsed, isDragging, isDrawerMode, setContentWidth]);
+
+  const toggleNavigationBtnClickHandler = useCallback(() => {
+    const newValue = !isCollapsed;
+    mutateSidebarCollapsed(newValue, false);
+    scheduleToPutUserUISettings({ isSidebarCollapsed: newValue });
+  }, [isCollapsed, mutateSidebarCollapsed]);
+
+  useEffect(() => {
+    if (isCollapsed) {
+      setContentWidth(sidebarMinimizeWidth);
+    }
+    else {
+      setContentWidth(currentProductNavWidth);
+    }
+  }, [currentProductNavWidth, isCollapsed, setContentWidth]);
+
+  const draggableAreaMoveHandler = useCallback((event: MouseEvent) => {
+    event.preventDefault();
+
+    const newWidth = event.pageX - 60;
+    if (resizableContainer.current != null) {
+      setContentWidth(newWidth);
+      resizableContainer.current.classList.add('dragging');
+    }
+  }, [setContentWidth]);
+
+  const dragableAreaMouseUpHandler = useCallback(() => {
+    if (resizableContainer.current == null) {
+      return;
+    }
+
+    setDrag(false);
+
+    if (resizableContainer.current.clientWidth < sidebarMinWidth) {
+      // force collapsed
+      mutateSidebarCollapsed(true);
+      mutateProductNavWidth(sidebarMinWidth, false);
+      scheduleToPutUserUISettings({ isSidebarCollapsed: true, currentProductNavWidth: sidebarMinWidth });
+    }
+    else {
+      const newWidth = resizableContainer.current.clientWidth;
+      mutateSidebarCollapsed(false);
+      mutateProductNavWidth(newWidth, false);
+      scheduleToPutUserUISettings({ isSidebarCollapsed: false, currentProductNavWidth: newWidth });
+    }
+
+    resizableContainer.current.classList.remove('dragging');
+
+  }, [mutateProductNavWidth, mutateSidebarCollapsed]);
+
+  const dragableAreaMouseDownHandler = useCallback((event: React.MouseEvent) => {
+    if (!isResizableByDrag) {
+      return;
+    }
+
+    event.preventDefault();
+
+    setDrag(true);
+
+    const removeEventListeners = () => {
+      document.removeEventListener('mousemove', draggableAreaMoveHandler);
+      document.removeEventListener('mouseup', dragableAreaMouseUpHandler);
+      document.removeEventListener('mouseup', removeEventListeners);
+    };
+
+    document.addEventListener('mousemove', draggableAreaMoveHandler);
+    document.addEventListener('mouseup', dragableAreaMouseUpHandler);
+    document.addEventListener('mouseup', removeEventListeners);
+
+  }, [dragableAreaMouseUpHandler, draggableAreaMoveHandler, isResizableByDrag]);
+
+  return (
+    <>
+      <div className={`grw-sidebar d-print-none ${isDrawerMode ? 'grw-sidebar-drawer' : ''} ${isDrawerOpened ? 'open' : ''}`}>
+        <div className="data-layout-container">
+          <div className="navigation" onMouseLeave={hoverOutHandler}>
+            <div className="grw-navigation-wrap">
+              <div className="grw-global-navigation">
+                { isMounted ? <GlobalNavigation></GlobalNavigation> : <GlobalNavigationSkelton></GlobalNavigationSkelton> }
+              </div>
+              <div
+                ref={resizableContainer}
+                className="grw-contextual-navigation"
+                onMouseEnter={hoverOnResizableContainerHandler}
+                style={{ width: isCollapsed ? sidebarMinimizeWidth : currentProductNavWidth }}
+              >
+                <div className="grw-contextual-navigation-child">
+                  <div role="group" className={`grw-contextual-navigation-sub ${!isHover && isCollapsed ? 'collapsed' : ''}`}>
+                    { isMounted ? <SidebarContentsWrapper></SidebarContentsWrapper> : <SidebarSkeltonContents></SidebarSkeltonContents> }
+                  </div>
+                </div>
+              </div>
+            </div>
+            <div className="grw-navigation-draggable">
+              { isResizableByDrag && (
+                <div
+                  className="grw-navigation-draggable-hitarea"
+                  onMouseDown={dragableAreaMouseDownHandler}
+                >
+                  <div className="grw-navigation-draggable-hitarea-child"></div>
+                </div>
+              ) }
+              <button
+                className={`grw-navigation-resize-button ${!isDrawerMode ? 'resizable' : ''} ${isCollapsed ? 'collapsed' : ''} `}
+                type="button"
+                aria-expanded="true"
+                aria-label="Toggle navigation"
+                disabled={isDrawerMode}
+                onClick={toggleNavigationBtnClickHandler}
+              >
+                <span className="hexagon-container" role="presentation">
+                  <NavigationResizeHexagon />
+                </span>
+                <span className="hitarea" role="presentation"></span>
+              </button>
+            </div>
+          </div>
+        </div>
+      </div>
+
+      { isDrawerOpened && (
+        <div className="grw-sidebar-backdrop modal-backdrop show" onClick={backdropClickedHandler}></div>
+      ) }
+    </>
+  );
+
+};
+
+export default Sidebar;

+ 17 - 39
packages/app/src/components/Sidebar/CustomSidebar.jsx → packages/app/src/components/Sidebar/CustomSidebar.tsx

@@ -1,13 +1,12 @@
-import React, {
-  useState, useCallback, useEffect,
-} from 'react';
-import PropTypes from 'prop-types';
+import React, { FC } from 'react';
 
 
+import AppContainer from '~/client/services/AppContainer';
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
+import { useSWRxPageByPath } from '~/stores/page';
 
 
 import { withUnstatedContainers } from '../UnstatedUtils';
 import { withUnstatedContainers } from '../UnstatedUtils';
-import AppContainer from '~/client/services/AppContainer';
 import RevisionRenderer from '../Page/RevisionRenderer';
 import RevisionRenderer from '../Page/RevisionRenderer';
+import { IRevision } from '~/interfaces/revision';
 
 
 const logger = loggerFactory('growi:cli:CustomSidebar');
 const logger = loggerFactory('growi:cli:CustomSidebar');
 
 
@@ -22,55 +21,38 @@ const SidebarNotFound = () => {
   );
   );
 };
 };
 
 
-const CustomSidebar = (props) => {
-
-  const { appContainer } = props;
-  const { apiGet } = appContainer;
+type Props = {
+  appContainer: AppContainer,
+};
 
 
-  const [isMounted, setMounted] = useState(false);
-  const [markdown, setMarkdown] = useState();
+const CustomSidebar: FC<Props> = (props: Props) => {
 
 
-  const growiRenderer = appContainer.getRenderer('sidebar');
+  const { appContainer } = props;
 
 
-  // TODO: refactor with SWR
-  const fetchDataAndRenderHtml = useCallback(async() => {
-    let page = null;
-    try {
-      const result = await apiGet('/pages.get', { path: '/Sidebar' });
-      page = result.page;
-    }
-    catch (e) {
-      logger.warn(e.message);
-      return;
-    }
-    finally {
-      setMounted(true);
-    }
+  const renderer = appContainer.getRenderer('sidebar');
 
 
-    setMarkdown(page.revision.body);
-  }, [apiGet]);
+  const { data: page, mutate } = useSWRxPageByPath('/Sidebar');
 
 
-  useEffect(() => {
-    fetchDataAndRenderHtml();
-  }, [fetchDataAndRenderHtml]);
+  const isLoading = page === undefined;
+  const markdown = (page?.revision as IRevision)?.body;
 
 
   return (
   return (
     <>
     <>
       <div className="grw-sidebar-content-header p-3 d-flex">
       <div className="grw-sidebar-content-header p-3 d-flex">
-        <h3 className="mb-0 text-nowrap">
+        <h3 className="mb-0">
           Custom Sidebar
           Custom Sidebar
           <a className="h6 ml-2" href="/Sidebar"><i className="icon-pencil"></i></a>
           <a className="h6 ml-2" href="/Sidebar"><i className="icon-pencil"></i></a>
         </h3>
         </h3>
-        <button type="button" className="btn btn-sm ml-auto grw-btn-reload" onClick={fetchDataAndRenderHtml}>
+        <button type="button" className="btn btn-sm btn-outline-secondary ml-auto" onClick={() => mutate()}>
           <i className="icon icon-reload"></i>
           <i className="icon icon-reload"></i>
         </button>
         </button>
       </div>
       </div>
-      { isMounted && markdown == null && <SidebarNotFound /> }
+      { !isLoading && markdown == null && <SidebarNotFound /> }
       {/* eslint-disable-next-line react/no-danger */}
       {/* eslint-disable-next-line react/no-danger */}
       { markdown != null && (
       { markdown != null && (
         <div className="p-3">
         <div className="p-3">
           <RevisionRenderer
           <RevisionRenderer
-            growiRenderer={growiRenderer}
+            growiRenderer={renderer}
             markdown={markdown}
             markdown={markdown}
             additionalClassName="grw-custom-sidebar-content"
             additionalClassName="grw-custom-sidebar-content"
           />
           />
@@ -81,10 +63,6 @@ const CustomSidebar = (props) => {
 
 
 };
 };
 
 
-CustomSidebar.propTypes = {
-  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
-};
-
 /**
 /**
  * Wrapper component for using unstated
  * Wrapper component for using unstated
  */
  */

+ 20 - 0
packages/app/src/components/Sidebar/NavigationResizeHexagon.tsx

@@ -0,0 +1,20 @@
+import React, { FC } from 'react';
+
+type Props = {
+
+};
+
+export const NavigationResizeHexagon: FC<Props> = () => (
+  <svg
+    xmlns="http://www.w3.org/2000/svg"
+    viewBox="0 0 27.691 23.999"
+  >
+    <g className="background" transform="translate(0 0)">
+      <path d="M20.768,0l6.923,12L20.768,24H6.923L0,12,6.923,0Z" transform="translate(0)"></path>
+    </g>
+    <g className="icon" transform="translate(10 6)">
+      { /* eslint-disable-next-line max-len */ }
+      <path d="M2.124,9.114l5.28,5.34a.647.647,0,0,0,.922,0l.616-.623a.665.665,0,0,0,0-.932L4.759,8.648,8.943,4.4a.665.665,0,0,0,0-.932l-.616-.623a.647.647,0,0,0-.922,0l-5.28,5.34A.665.665,0,0,0,2.124,9.114Z" transform="translate(-1.933 -2.648)"></path>
+    </g>
+  </svg>
+);

+ 36 - 0
packages/app/src/components/Sidebar/PageTree.tsx

@@ -0,0 +1,36 @@
+import React, { FC, memo } from 'react';
+import { useTranslation } from 'react-i18next';
+
+import { useSWRxV5MigrationStatus } from '~/stores/page-listing';
+
+import ItemsTree from './PageTree/ItemsTree';
+import PrivateLegacyPages from './PageTree/PrivateLegacyPages';
+
+
+const PageTree: FC = memo(() => {
+  const { t } = useTranslation();
+
+  const { data } = useSWRxV5MigrationStatus();
+
+  return (
+    <>
+      <div className="grw-sidebar-content-header p-3">
+        <h3 className="mb-0">{t('Page Tree')}</h3>
+      </div>
+
+      <div className="grw-sidebar-content-body">
+        <ItemsTree />
+      </div>
+
+      <div className="grw-sidebar-content-footer">
+        {
+          data?.migratablePagesCount != null && data.migratablePagesCount !== 0 && (
+            <PrivateLegacyPages />
+          )
+        }
+      </div>
+    </>
+  );
+});
+
+export default PageTree;

+ 144 - 0
packages/app/src/components/Sidebar/PageTree/Item.tsx

@@ -0,0 +1,144 @@
+import React, {
+  useCallback, useState, FC, useEffect,
+} from 'react';
+import nodePath from 'path';
+
+import { ItemNode } from './ItemNode';
+import { useSWRxPageChildren } from '../../../stores/page-listing';
+import { usePageId } from '../../../stores/context';
+
+
+interface ItemProps {
+  itemNode: ItemNode
+  isOpen?: boolean
+}
+
+// Utility to mark target
+const markTarget = (children: ItemNode[], targetId: string): void => {
+  children.forEach((node) => {
+    if (node.page._id === targetId) {
+      node.page.isTarget = true;
+    }
+    return node;
+  });
+
+  return;
+};
+
+const ItemContol: FC = () => {
+  return (
+    <>
+      <button
+        type="button"
+        className="btn-link nav-link dropdown-toggle dropdown-toggle-no-caret border-0 rounded grw-btn-page-management py-0"
+        data-toggle="dropdown"
+      >
+        <i className="icon-options-vertical text-muted"></i>
+      </button>
+      <button
+        type="button"
+        className="btn-link nav-link dropdown-toggle dropdown-toggle-no-caret border-0 rounded grw-btn-page-management py-0"
+        data-toggle="dropdown"
+      >
+        <i className="icon-plus text-muted"></i>
+      </button>
+    </>
+  );
+};
+
+const ItemCount: FC = () => {
+  return (
+    <>
+      <span className="grw-pagetree-count badge badge-pill badge-light">
+        10
+      </span>
+    </>
+  );
+};
+
+const Item: FC<ItemProps> = (props: ItemProps) => {
+  const { itemNode, isOpen: _isOpen = false } = props;
+
+  const { page, children } = itemNode;
+
+  const [currentChildren, setCurrentChildren] = useState(children);
+  const [isOpen, setIsOpen] = useState(_isOpen);
+
+  const { data: targetId } = usePageId();
+  const { data, error } = useSWRxPageChildren(isOpen ? page._id : null);
+
+  const hasChildren = useCallback((): boolean => {
+    return currentChildren != null && currentChildren.length > 0;
+  }, [currentChildren]);
+
+  const onClickLoadChildren = useCallback(async() => {
+    setIsOpen(!isOpen);
+  }, [isOpen]);
+
+  // didMount
+  useEffect(() => {
+    if (hasChildren()) setIsOpen(true);
+  }, []);
+
+  /*
+   * Make sure itemNode.children and currentChildren are synced
+   */
+  useEffect(() => {
+    if (children.length > currentChildren.length) {
+      markTarget(children, targetId);
+      setCurrentChildren(children);
+    }
+  }, []);
+
+  /*
+   * When swr fetch succeeded
+   */
+  useEffect(() => {
+    if (isOpen && error == null && data != null) {
+      const newChildren = ItemNode.generateNodesFromPages(data.children);
+      markTarget(newChildren, targetId);
+      setCurrentChildren(newChildren);
+    }
+  }, [data]);
+
+  // TODO: improve style
+  const opacityStyle = { opacity: 1.0 };
+  if (page.isTarget) opacityStyle.opacity = 0.7;
+
+  const buttonClass = isOpen ? 'rotate' : '';
+
+  return (
+    <div className="grw-pagetree-item-wrapper">
+      <div style={opacityStyle} className="grw-pagetree-item d-flex align-items-center">
+        <button
+          type="button"
+          className={`grw-pagetree-button btn ${buttonClass}`}
+          onClick={onClickLoadChildren}
+        >
+          <i className="icon-control-play"></i>
+        </button>
+        <a href={page._id} className="grw-pagetree-title-anchor flex-grow-1">
+          <p className="grw-pagetree-title m-auto">{nodePath.basename(page.path as string) || '/'}</p>
+        </a>
+        <div className="grw-pagetree-count-wrapper">
+          <ItemCount />
+        </div>
+        <div className="grw-pagetree-control d-none">
+          <ItemContol />
+        </div>
+      </div>
+      {
+        isOpen && hasChildren() && currentChildren.map(node => (
+          <Item
+            key={node.page._id}
+            itemNode={node}
+            isOpen={false}
+          />
+        ))
+      }
+    </div>
+  );
+
+};
+
+export default Item;

+ 18 - 0
packages/app/src/components/Sidebar/PageTree/ItemNode.ts

@@ -0,0 +1,18 @@
+import { IPageForItem } from '../../../interfaces/page';
+
+export class ItemNode {
+
+  page: IPageForItem;
+
+  children: ItemNode[];
+
+  constructor(page: IPageForItem, children: ItemNode[] = []) {
+    this.page = page;
+    this.children = children;
+  }
+
+  static generateNodesFromPages(pages: IPageForItem[]): ItemNode[] {
+    return pages.map(page => new ItemNode(page));
+  }
+
+}

+ 95 - 0
packages/app/src/components/Sidebar/PageTree/ItemsTree.tsx

@@ -0,0 +1,95 @@
+import React, { FC } from 'react';
+
+import { IPage } from '../../../interfaces/page';
+import { ItemNode } from './ItemNode';
+import Item from './Item';
+import { useSWRxPageAncestorsChildren } from '../../../stores/page-listing';
+import { useTargetAndAncestors, useCurrentPagePath } from '../../../stores/context';
+import { HasObjectId } from '../../../interfaces/has-object-id';
+
+
+/*
+ * Utility to generate initial node
+ */
+const generateInitialNodeBeforeResponse = (targetAndAncestors: Partial<IPage>[]): ItemNode => {
+  const nodes = targetAndAncestors.map((page): ItemNode => {
+    return new ItemNode(page, []);
+  });
+
+  // update children for each node
+  const rootNode = nodes.reduce((child, parent) => {
+    parent.children = [child];
+    return parent;
+  });
+
+  return rootNode;
+};
+
+const generateInitialNodeAfterResponse = (ancestorsChildren: Record<string, Partial<IPage & HasObjectId>[]>, rootNode: ItemNode): ItemNode => {
+  const paths = Object.keys(ancestorsChildren);
+
+  let currentNode = rootNode;
+  paths.reverse().forEach((path) => {
+    const childPages = ancestorsChildren[path];
+    currentNode.children = ItemNode.generateNodesFromPages(childPages);
+
+    const nextNode = currentNode.children.filter((node) => {
+      return paths.includes(node.page.path as string);
+    })[0];
+    currentNode = nextNode;
+  });
+
+  return rootNode;
+};
+
+
+/*
+ * ItemsTree
+ */
+const ItemsTree: FC = () => {
+  const { data: currentPath } = useCurrentPagePath();
+
+  const { data, error } = useTargetAndAncestors();
+
+  const { data: ancestorsChildrenData, error: error2 } = useSWRxPageAncestorsChildren(currentPath || null);
+
+  if (error != null || error2 != null) {
+    return null;
+  }
+
+  if (data == null) {
+    return null;
+  }
+
+  const { targetAndAncestors, rootPage } = data;
+
+  let initialNode: ItemNode;
+
+  /*
+   * Before swr response comes back
+   */
+  if (ancestorsChildrenData == null) {
+    initialNode = generateInitialNodeBeforeResponse(targetAndAncestors);
+  }
+
+  /*
+   * When swr request finishes
+   */
+  else {
+    const { ancestorsChildren } = ancestorsChildrenData;
+
+    const rootNode = new ItemNode(rootPage);
+
+    initialNode = generateInitialNodeAfterResponse(ancestorsChildren, rootNode);
+  }
+
+  const isOpen = true;
+  return (
+    <div className="grw-pagetree p-3">
+      <Item key={(initialNode as ItemNode).page.path} itemNode={(initialNode as ItemNode)} isOpen={isOpen} />
+    </div>
+  );
+};
+
+
+export default ItemsTree;

+ 16 - 0
packages/app/src/components/Sidebar/PageTree/PrivateLegacyPages.tsx

@@ -0,0 +1,16 @@
+import React, { FC, memo } from 'react';
+import { useTranslation } from 'react-i18next';
+
+const PrivateLegacyPages: FC = memo(() => {
+  const { t } = useTranslation();
+
+  return (
+    <div className="grw-prvt-legacy-pages p-3">
+      <a href="/private-legacy-pages?q=[nq:PrivateLegacyPages]" className="h5">
+        <i className="icon-drawer mr-2"></i> PrivateLegacyPages
+      </a>
+    </div>
+  );
+});
+
+export default PrivateLegacyPages;

+ 6 - 100
packages/app/src/components/Sidebar/RecentChanges.jsx → packages/app/src/components/Sidebar/RecentChanges.tsx

@@ -1,16 +1,15 @@
 import React, {
 import React, {
+  FC,
   useCallback, useEffect, useState,
   useCallback, useEffect, useState,
 } from 'react';
 } from 'react';
 import PropTypes from 'prop-types';
 import PropTypes from 'prop-types';
 
 
-import { useTranslation, withTranslation } from 'react-i18next';
+import { useTranslation } from 'react-i18next';
 
 
 import { UserPicture, FootstampIcon } from '@growi/ui';
 import { UserPicture, FootstampIcon } from '@growi/ui';
 import { DevidedPagePath } from '@growi/core';
 import { DevidedPagePath } from '@growi/core';
 
 
 import PagePathHierarchicalLink from '~/components/PagePathHierarchicalLink';
 import PagePathHierarchicalLink from '~/components/PagePathHierarchicalLink';
-import { apiv3Get } from '~/client/util/apiv3-client';
-import { toastError } from '~/client/util/apiNotification';
 import { useSWRxRecentlyUpdated } from '~/stores/page';
 import { useSWRxRecentlyUpdated } from '~/stores/page';
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 
 
@@ -122,14 +121,10 @@ SmallPageItem.propTypes = {
 };
 };
 
 
 
 
-const RecentChanges = () => {
+const RecentChanges: FC<void> = () => {
 
 
   const { t } = useTranslation();
   const { t } = useTranslation();
-  const { data: pages, error, mutate } = useSWRxRecentlyUpdated();
-
-  if (error != null) {
-    toastError(error, 'Error occurred in updating History');
-  }
+  const { data: pages, mutate } = useSWRxRecentlyUpdated();
 
 
   const [isRecentChangesSidebarSmall, setIsRecentChangesSidebarSmall] = useState(false);
   const [isRecentChangesSidebarSmall, setIsRecentChangesSidebarSmall] = useState(false);
 
 
@@ -137,7 +132,7 @@ const RecentChanges = () => {
     if (window.localStorage.isRecentChangesSidebarSmall === 'true') {
     if (window.localStorage.isRecentChangesSidebarSmall === 'true') {
       setIsRecentChangesSidebarSmall(true);
       setIsRecentChangesSidebarSmall(true);
     }
     }
-  });
+  }, []);
 
 
   const changeSizeHandler = useCallback((e) => {
   const changeSizeHandler = useCallback((e) => {
     setIsRecentChangesSidebarSmall(e.target.checked);
     setIsRecentChangesSidebarSmall(e.target.checked);
@@ -182,93 +177,4 @@ const RecentChanges = () => {
 
 
 };
 };
 
 
-// export default RecentChanges;
-
-
-class DeprecatedRecentChanges extends React.Component {
-
-  static propTypes = {
-    t: PropTypes.func.isRequired, // i18next
-  };
-
-  constructor(props) {
-    super(props);
-    this.state = {
-      isRecentChangesSidebarSmall: false,
-      recentlyUpdatedPages: [],
-    };
-    this.reloadData = this.reloadData.bind(this);
-  }
-
-  componentWillMount() {
-    this.retrieveSizePreferenceFromLocalStorage();
-  }
-
-  async componentDidMount() {
-    this.reloadData();
-  }
-
-  async reloadData() {
-    try {
-      const { data } = await apiv3Get('/pages/recent');
-      this.setState({ recentlyUpdatedPages: data.pages });
-    }
-    catch (error) {
-      logger.error('failed to save', error);
-      toastError(error, 'Error occurred in updating History');
-    }
-  }
-
-  retrieveSizePreferenceFromLocalStorage() {
-    if (window.localStorage.isRecentChangesSidebarSmall === 'true') {
-      this.setState({
-        isRecentChangesSidebarSmall: true,
-      });
-    }
-  }
-
-  changeSizeHandler = (e) => {
-    this.setState({
-      isRecentChangesSidebarSmall: e.target.checked,
-    });
-    window.localStorage.setItem('isRecentChangesSidebarSmall', e.target.checked);
-  }
-
-  render() {
-    const { t } = this.props;
-
-    return (
-      <>
-        <div className="grw-sidebar-content-header p-3 d-flex">
-          <h3 className="mb-0">{t('Recent Changes')}</h3>
-          {/* <h3 className="mb-0">{t('Recent Created')}</h3> */} {/* TODO: impl switching */}
-          <button type="button" className="btn btn-sm ml-auto grw-btn-reload-rc" onClick={this.reloadData}>
-            <i className="icon icon-reload"></i>
-          </button>
-          <div className="grw-recent-changes-resize-button custom-control custom-switch ml-2">
-            <input
-              id="recentChangesResize"
-              className="custom-control-input"
-              type="checkbox"
-              checked={this.state.isRecentChangesSidebarSmall}
-              onChange={this.changeSizeHandler}
-            />
-            <label className="custom-control-label" htmlFor="recentChangesResize">
-            </label>
-          </div>
-        </div>
-        <div className="grw-sidebar-content-body grw-recent-changes p-3">
-          <ul className="list-group list-group-flush">
-            {this.state.recentlyUpdatedPages.map(page => (this.state.isRecentChangesSidebarSmall
-              ? <SmallPageItem key={page._id} page={page} />
-              : <LargePageItem key={page._id} page={page} />))}
-          </ul>
-        </div>
-      </>
-    );
-  }
-
-}
-
-
-export default withTranslation()(DeprecatedRecentChanges);
+export default RecentChanges;

+ 0 - 49
packages/app/src/components/Sidebar/SidebarContents.jsx

@@ -1,49 +0,0 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-
-import { withTranslation } from 'react-i18next';
-
-import { withUnstatedContainers } from '../UnstatedUtils';
-import NavigationContainer from '~/client/services/NavigationContainer';
-
-import RecentChanges from './RecentChanges';
-import CustomSidebar from './CustomSidebar';
-
-const SidebarContents = (props) => {
-  const { navigationContainer, isSharedUser } = props;
-
-  if (isSharedUser) {
-    return null;
-  }
-
-  let Contents;
-  switch (navigationContainer.state.sidebarContentsId) {
-    case 'recent':
-      Contents = RecentChanges;
-      break;
-    default:
-      Contents = CustomSidebar;
-  }
-
-  return (
-    <Contents />
-  );
-
-};
-
-SidebarContents.propTypes = {
-  navigationContainer: PropTypes.instanceOf(NavigationContainer).isRequired,
-
-  isSharedUser: PropTypes.bool,
-};
-
-SidebarContents.defaultProps = {
-  isSharedUser: false,
-};
-
-/**
- * Wrapper component for using unstated
- */
-const SidebarContentsWrapper = withUnstatedContainers(SidebarContents, [NavigationContainer]);
-
-export default withTranslation()(SidebarContentsWrapper);

+ 33 - 0
packages/app/src/components/Sidebar/SidebarContents.tsx

@@ -0,0 +1,33 @@
+import React, { FC } from 'react';
+
+import { SidebarContentsType } from '~/interfaces/ui';
+import { useCurrentSidebarContents } from '~/stores/ui';
+import RecentChanges from './RecentChanges';
+import CustomSidebar from './CustomSidebar';
+import PageTree from './PageTree';
+
+type Props = {
+};
+
+const SidebarContents: FC<Props> = (props: Props) => {
+  const { data: currentSidebarContents } = useCurrentSidebarContents();
+
+  let Contents;
+  switch (currentSidebarContents) {
+    case SidebarContentsType.RECENT:
+      Contents = RecentChanges;
+      break;
+    case SidebarContentsType.TREE:
+      Contents = PageTree;
+      break;
+    default:
+      Contents = CustomSidebar;
+  }
+
+  return (
+    <Contents />
+  );
+
+};
+
+export default SidebarContents;

+ 0 - 94
packages/app/src/components/Sidebar/SidebarNav.jsx

@@ -1,94 +0,0 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-
-import { withTranslation } from 'react-i18next';
-
-import { withUnstatedContainers } from '../UnstatedUtils';
-import AppContainer from '~/client/services/AppContainer';
-import NavigationContainer from '~/client/services/NavigationContainer';
-
-
-class SidebarNav extends React.Component {
-
-  static propTypes = {
-    onItemSelected: PropTypes.func,
-  };
-
-  state = {
-  };
-
-  itemSelectedHandler = (contentsId) => {
-    const { navigationContainer, onItemSelected } = this.props;
-    if (onItemSelected != null) {
-      onItemSelected(contentsId);
-    }
-
-    navigationContainer.selectSidebarContents(contentsId);
-  }
-
-  PrimaryItem = ({ id, label, iconName }) => {
-    const { sidebarContentsId } = this.props.navigationContainer.state;
-    const isSelected = sidebarContentsId === id;
-
-    return (
-      <button
-        type="button"
-        className={`d-block btn btn-primary ${isSelected ? 'active' : ''}`}
-        onClick={() => this.itemSelectedHandler(id)}
-      >
-        <i className="material-icons">{iconName}</i>
-      </button>
-    );
-  }
-
-  SecondaryItem({
-    label, iconName, href, isBlank,
-  }) {
-    return (
-      <a href={href} className="d-block btn btn-primary" target={`${isBlank ? '_blank' : ''}`}>
-        <i className="material-icons">{iconName}</i>
-      </a>
-    );
-  }
-
-  generateIconFactory(classNames) {
-    return () => <i className={classNames}></i>;
-  }
-
-  render() {
-    const { isAdmin, currentUsername, isSharedUser } = this.props.appContainer;
-    const isLoggedIn = currentUsername != null;
-
-    const { PrimaryItem, SecondaryItem } = this;
-
-    return (
-      <div className="grw-sidebar-nav">
-        <div className="grw-sidebar-nav-primary-container">
-          {!isSharedUser && <PrimaryItem id="custom" label="Custom Sidebar" iconName="code" />}
-          {!isSharedUser && <PrimaryItem id="recent" label="Recent Changes" iconName="update" />}
-          {/* <PrimaryItem id="tag" label="Tags" iconName="icon-tag" /> */}
-          {/* <PrimaryItem id="favorite" label="Favorite" iconName="icon-star" /> */}
-        </div>
-        <div className="grw-sidebar-nav-secondary-container">
-          {isAdmin && <SecondaryItem label="Admin" iconName="settings" href="/admin" />}
-          {isLoggedIn && <SecondaryItem label="Draft" iconName="file_copy" href="/me/drafts" />}
-          <SecondaryItem label="Help" iconName="help" href="https://docs.growi.org" isBlank />
-          <SecondaryItem label="Trash" iconName="delete" href="/trash" />
-        </div>
-      </div>
-    );
-  }
-
-}
-
-SidebarNav.propTypes = {
-  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
-  navigationContainer: PropTypes.instanceOf(NavigationContainer).isRequired,
-};
-
-/**
- * Wrapper component for using unstated
- */
-const SidebarNavWrapper = withUnstatedContainers(SidebarNav, [AppContainer, NavigationContainer]);
-
-export default withTranslation()(SidebarNavWrapper);

+ 99 - 0
packages/app/src/components/Sidebar/SidebarNav.tsx

@@ -0,0 +1,99 @@
+import React, { FC, memo, useCallback } from 'react';
+
+import { scheduleToPutUserUISettings } from '~/client/services/user-ui-settings';
+import { SidebarContentsType } from '~/interfaces/ui';
+import { useCurrentUser, useIsSharedUser } from '~/stores/context';
+import { useCurrentSidebarContents } from '~/stores/ui';
+
+
+type PrimaryItemProps = {
+  contents: SidebarContentsType,
+  label: string,
+  iconName: string,
+  onItemSelected: (contents: SidebarContentsType) => void,
+}
+
+const PrimaryItem: FC<PrimaryItemProps> = (props: PrimaryItemProps) => {
+  const {
+    contents, iconName, onItemSelected,
+  } = props;
+
+  // TODO: migrate from NavigationContainer
+  const { data: currentContents, mutate } = useCurrentSidebarContents();
+
+  const isSelected = contents === currentContents;
+
+  const itemSelectedHandler = useCallback(() => {
+    // const { navigationContainer, onItemSelected } = this.props;
+    if (onItemSelected != null) {
+      onItemSelected(contents);
+    }
+
+    mutate(contents, false);
+    scheduleToPutUserUISettings({ currentSidebarContents: contents });
+  }, [contents, mutate, onItemSelected]);
+
+  return (
+    <button
+      type="button"
+      className={`d-block btn btn-primary ${isSelected ? 'active' : ''}`}
+      onClick={itemSelectedHandler}
+    >
+      <i className="material-icons">{iconName}</i>
+    </button>
+  );
+};
+
+type SecondaryItemProps = {
+  label: string,
+  href: string,
+  iconName: string,
+  isBlank?: boolean,
+}
+
+const SecondaryItem: FC<SecondaryItemProps> = memo((props: SecondaryItemProps) => {
+  const { iconName, href, isBlank } = props;
+
+  return (
+    <a href={href} className="d-block btn btn-primary" target={`${isBlank ? '_blank' : ''}`}>
+      <i className="material-icons">{iconName}</i>
+    </a>
+  );
+});
+
+
+type Props = {
+  onItemSelected: (contents: SidebarContentsType) => void,
+}
+
+const SidebarNav: FC<Props> = (props: Props) => {
+
+  const { data: isSharedUser } = useIsSharedUser();
+  const { data: currentUser } = useCurrentUser();
+
+  const isAdmin = currentUser?.admin;
+  const isLoggedIn = currentUser != null;
+
+  const { onItemSelected } = props;
+
+  return (
+    <div className="grw-sidebar-nav">
+      <div className="grw-sidebar-nav-primary-container">
+        {!isSharedUser && <PrimaryItem contents={SidebarContentsType.CUSTOM} label="Custom Sidebar" iconName="code" onItemSelected={onItemSelected} />}
+        {!isSharedUser && <PrimaryItem contents={SidebarContentsType.RECENT} label="Recent Changes" iconName="update" onItemSelected={onItemSelected} />}
+        {!isSharedUser && <PrimaryItem contents={SidebarContentsType.TREE} label="Page Tree" iconName="format_list_bulleted" onItemSelected={onItemSelected} />}
+        {/* <PrimaryItem id="tag" label="Tags" iconName="icon-tag" /> */}
+        {/* <PrimaryItem id="favorite" label="Favorite" iconName="icon-star" /> */}
+      </div>
+      <div className="grw-sidebar-nav-secondary-container">
+        {isAdmin && <SecondaryItem label="Admin" iconName="settings" href="/admin" />}
+        {isLoggedIn && <SecondaryItem label="Draft" iconName="file_copy" href="/me/drafts" />}
+        <SecondaryItem label="Help" iconName="help" href="https://docs.growi.org" isBlank />
+        <SecondaryItem label="Trash" iconName="delete" href="/trash" />
+      </div>
+    </div>
+  );
+
+};
+
+export default SidebarNav;

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

@@ -5,7 +5,6 @@ import { debounce } from 'throttle-debounce';
 import StickyEvents from 'sticky-events';
 import StickyEvents from 'sticky-events';
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 
 
-import NavigationContainer from '~/client/services/NavigationContainer';
 import { withUnstatedContainers } from './UnstatedUtils';
 import { withUnstatedContainers } from './UnstatedUtils';
 
 
 const logger = loggerFactory('growi:cli:StickyStretchableScroller');
 const logger = loggerFactory('growi:cli:StickyStretchableScroller');
@@ -49,7 +48,6 @@ const StickyStretchableScroller = (props) => {
 
 
   let { scrollTargetSelector } = props;
   let { scrollTargetSelector } = props;
   const {
   const {
-    navigationContainer,
     children, contentsElemSelector, stickyElemSelector,
     children, contentsElemSelector, stickyElemSelector,
     calcViewHeightFunc, calcContentsHeightFunc,
     calcViewHeightFunc, calcContentsHeightFunc,
   } = props;
   } = props;
@@ -142,11 +140,11 @@ const StickyStretchableScroller = (props) => {
   }, [resetScrollbarDebounced]);
   }, [resetScrollbarDebounced]);
 
 
   // setup effect by isScrollTop
   // setup effect by isScrollTop
-  useEffect(() => {
-    if (navigationContainer.state.isScrollTop) {
-      resetScrollbar();
-    }
-  }, [navigationContainer.state.isScrollTop, resetScrollbar]);
+  // useEffect(() => {
+  //   if (navigationContainer.state.isScrollTop) {
+  //     resetScrollbar();
+  //   }
+  // }, [navigationContainer.state.isScrollTop, resetScrollbar]);
 
 
   // setup effect by update props
   // setup effect by update props
   useEffect(() => {
   useEffect(() => {
@@ -161,7 +159,6 @@ const StickyStretchableScroller = (props) => {
 };
 };
 
 
 StickyStretchableScroller.propTypes = {
 StickyStretchableScroller.propTypes = {
-  navigationContainer: PropTypes.instanceOf(NavigationContainer).isRequired,
   contentsElemSelector: PropTypes.string.isRequired,
   contentsElemSelector: PropTypes.string.isRequired,
 
 
   children: PropTypes.node,
   children: PropTypes.node,
@@ -172,4 +169,4 @@ StickyStretchableScroller.propTypes = {
   calcContentsHeightFunc: PropTypes.func,
   calcContentsHeightFunc: PropTypes.func,
 };
 };
 
 
-export default withUnstatedContainers(StickyStretchableScroller, [NavigationContainer]);
+export default StickyStretchableScroller;

+ 4 - 0
packages/app/src/interfaces/bookmark-info.ts

@@ -0,0 +1,4 @@
+export type IBookmarkInfo = {
+  sumOfBookmarks: number;
+  isBookmarked: boolean,
+};

+ 7 - 0
packages/app/src/interfaces/common.ts

@@ -0,0 +1,7 @@
+/*
+ * Common types and interfaces
+ */
+
+
+// Foreign key field
+export type Ref<T> = string | T;

+ 3 - 0
packages/app/src/interfaces/has-object-id.ts

@@ -0,0 +1,3 @@
+export type HasObjectId = {
+  _id: string,
+};

+ 13 - 0
packages/app/src/interfaces/named-query.ts

@@ -0,0 +1,13 @@
+import { IUser } from './user';
+
+
+export enum SearchDelegatorName {
+  DEFAULT = 'FullTextSearch',
+  PRIVATE_LEGACY_PAGES = 'PrivateLegacyPages',
+}
+export interface INamedQuery {
+  name: string
+  aliasOf?: string
+  delegatorName?: SearchDelegatorName
+  creator?: IUser
+}

+ 8 - 0
packages/app/src/interfaces/page-info.ts

@@ -0,0 +1,8 @@
+export type IPageInfo = {
+  sumOfLikers: number;
+  likerIds: string[];
+  seenUserIds: string[];
+  sumOfSeenUsers: number;
+  isSeen: boolean;
+  isLiked: boolean;
+};

+ 23 - 0
packages/app/src/interfaces/page-listing-results.ts

@@ -0,0 +1,23 @@
+import { IPageForItem } from './page';
+
+
+type ParentPath = string;
+export interface AncestorsChildrenResult {
+  ancestorsChildren: Record<ParentPath, Partial<IPageForItem>[]>
+}
+
+
+export interface ChildrenResult {
+  children: Partial<IPageForItem>[]
+}
+
+
+export interface TargetAndAncestors {
+  targetAndAncestors: Partial<IPageForItem>[]
+  rootPage: Partial<IPageForItem>,
+}
+
+
+export interface V5MigrationStatus {
+  migratablePagesCount: number
+}

+ 26 - 11
packages/app/src/interfaces/page.ts

@@ -1,21 +1,36 @@
+import { Ref } from './common';
 import { IUser } from './user';
 import { IUser } from './user';
 import { IRevision } from './revision';
 import { IRevision } from './revision';
 import { ITag } from './tag';
 import { ITag } from './tag';
+import { HasObjectId } from './has-object-id';
+
 
 
 export type IPage = {
 export type IPage = {
   path: string,
   path: string,
   status: string,
   status: string,
-  revision: string | IRevision,
-  tags?: ITag[],
-  lastUpdateUser: any,
-  commentCount: number,
-  creator: string | IUser,
-  seenUsers: string[],
-  liker: string[],
+  revision: Ref<IRevision>,
+  tags: Ref<ITag>[],
+  creator: Ref<IUser>,
   createdAt: Date,
   createdAt: Date,
   updatedAt: Date,
   updatedAt: Date,
-};
+  seenUsers: Ref<IUser>[],
+  parent: Ref<IPage> | null,
+  isEmpty: boolean,
+  redirectTo: string,
+  grant: number,
+  grantedUsers: Ref<IUser>[],
+  grantedGroup: Ref<any>,
+  lastUpdateUser: Ref<IUser>,
+  liker: Ref<IUser>[],
+  commentCount: number
+  slackChannels: string,
+  pageIdOnHackmd: string,
+  revisionHackmdSynced: Ref<IRevision>,
+  hasDraftOnHackmd: boolean,
+  deleteUser: Ref<IUser>,
+  deletedAt: Date,
+}
+
+export type IPageForItem = Partial<IPage & {isTarget?: boolean} & HasObjectId>;
 
 
-export type IPageHasId = IPage & {
-  _id: string,
-};
+export type IPageHasId = IPage & HasObjectId;

+ 3 - 0
packages/app/src/interfaces/pageTagsInfo.ts

@@ -0,0 +1,3 @@
+export type IPageTagsInfo = {
+  tags : string[],
+}

+ 1 - 1
packages/app/src/interfaces/search.ts

@@ -10,7 +10,7 @@ export type IPageSearchResultData = {
   pageData: IPageHasId,
   pageData: IPageHasId,
   pageMeta: {
   pageMeta: {
     bookmarkCount: number,
     bookmarkCount: number,
-    elasticSearchResult: {
+    elasticSearchResult?: {
       snippet: string,
       snippet: string,
       highlightedPath: string,
       highlightedPath: string,
     },
     },

+ 7 - 0
packages/app/src/interfaces/ui.ts

@@ -0,0 +1,7 @@
+export const SidebarContentsType = {
+  CUSTOM: 'custom',
+  RECENT: 'recent',
+  TREE: 'tree',
+} as const;
+export const AllSidebarContentsType = Object.values(SidebarContentsType);
+export type SidebarContentsType = typeof SidebarContentsType[keyof typeof SidebarContentsType];

+ 13 - 0
packages/app/src/interfaces/user-ui-settings.ts

@@ -0,0 +1,13 @@
+import { IUser } from './user';
+
+import { SidebarContentsType } from './ui';
+import { Ref } from './common';
+
+export interface IUserUISettings {
+  user: Ref<IUser> | null;
+  isSidebarCollapsed: boolean,
+  currentSidebarContents: SidebarContentsType,
+  currentProductNavWidth: number,
+  preferDrawerModeByUser: boolean,
+  preferDrawerModeOnEditByUser: boolean,
+}

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