Просмотр исходного кода

Merge remote-tracking branch 'origin/feat/pt-dev-4' into feat/79811-81436-add-pagetree-item-control

Takayuki Tamura 4 лет назад
Родитель
Сommit
1ff42ca2a9
100 измененных файлов с 2344 добавлено и 1200 удалено
  1. 11 1
      CHANGELOG.md
  2. 1 1
      lerna.json
  3. 1 1
      package.json
  4. 1 0
      packages/app/config/logger/config.dev.js
  5. 2 2
      packages/app/docker/README.md
  6. 14 12
      packages/app/package.json
  7. 8 5
      packages/app/resource/locales/en_US/admin/admin.json
  8. 6 2
      packages/app/resource/locales/en_US/translation.json
  9. 8 5
      packages/app/resource/locales/ja_JP/admin/admin.json
  10. 6 2
      packages/app/resource/locales/ja_JP/translation.json
  11. 8 5
      packages/app/resource/locales/zh_CN/admin/admin.json
  12. 6 2
      packages/app/resource/locales/zh_CN/translation.json
  13. 17 0
      packages/app/resource/search/mappings.json
  14. 5 15
      packages/app/src/client/app.jsx
  15. 31 31
      packages/app/src/client/legacy/crowi.js
  16. 28 10
      packages/app/src/client/services/ContextExtractor.tsx
  17. 55 137
      packages/app/src/client/services/NavigationContainer.js
  18. 5 5
      packages/app/src/client/services/PageContainer.js
  19. 0 0
      packages/app/src/client/services/user-ui-settings.ts
  20. 1 1
      packages/app/src/client/util/apiv3-client.ts
  21. 4 2
      packages/app/src/components/Admin/ElasticsearchManagement/ElasticsearchManagement.jsx
  22. 2 1
      packages/app/src/components/Admin/SlackIntegration/CustomBotWithProxySettings.jsx
  23. 2 0
      packages/app/src/components/Admin/SlackIntegration/CustomBotWithoutProxySettings.jsx
  24. 5 4
      packages/app/src/components/Admin/SlackIntegration/CustomBotWithoutProxySettingsAccordion.jsx
  25. 238 122
      packages/app/src/components/Admin/SlackIntegration/ManageCommandsProcess.jsx
  26. 56 23
      packages/app/src/components/Admin/SlackIntegration/ManageCommandsProcessWithoutProxy.jsx
  27. 11 1
      packages/app/src/components/Admin/SlackIntegration/SlackIntegration.jsx
  28. 5 2
      packages/app/src/components/Admin/SlackIntegration/WithProxyAccordions.jsx
  29. 5 1
      packages/app/src/components/Fab.jsx
  30. 9 11
      packages/app/src/components/Hotkeys/Subscribers/CreatePage.jsx
  31. 4 2
      packages/app/src/components/Hotkeys/Subscribers/EditPage.jsx
  32. 3 3
      packages/app/src/components/Icons/GrowiLogo.jsx
  33. 0 115
      packages/app/src/components/Navbar/GrowiNavbar.jsx
  34. 128 0
      packages/app/src/components/Navbar/GrowiNavbar.tsx
  35. 8 4
      packages/app/src/components/Navbar/GrowiNavbarBottom.jsx
  36. 13 12
      packages/app/src/components/Navbar/GrowiSubNavigation.jsx
  37. 17 15
      packages/app/src/components/Navbar/PageEditorModeManager.jsx
  38. 4 2
      packages/app/src/components/Navbar/PersonalDropdown.jsx
  39. 3 3
      packages/app/src/components/Navbar/SubNavButtons.jsx
  40. 13 9
      packages/app/src/components/Page/DisplaySwitcher.jsx
  41. 13 13
      packages/app/src/components/Page/NotFoundAlert.jsx
  42. 8 7
      packages/app/src/components/PageCreateModal.jsx
  43. 15 9
      packages/app/src/components/PageEditor/EditorNavbarBottom.jsx
  44. 23 29
      packages/app/src/components/PaginationWrapper.tsx
  45. 2 1
      packages/app/src/components/SearchForm.jsx
  46. 163 25
      packages/app/src/components/SearchPage.jsx
  47. 62 0
      packages/app/src/components/SearchPage/DeleteSelectedPageGroup.tsx
  48. 42 0
      packages/app/src/components/SearchPage/IncludeSpecificPathButton.jsx
  49. 104 0
      packages/app/src/components/SearchPage/SearchControl.tsx
  50. 31 13
      packages/app/src/components/SearchPage/SearchPageForm.jsx
  51. 52 0
      packages/app/src/components/SearchPage/SearchPageLayout.tsx
  52. 0 350
      packages/app/src/components/SearchPage/SearchResult.jsx
  53. 50 0
      packages/app/src/components/SearchPage/SearchResultContent.tsx
  54. 0 64
      packages/app/src/components/SearchPage/SearchResultList.jsx
  55. 49 0
      packages/app/src/components/SearchPage/SearchResultList.tsx
  56. 140 0
      packages/app/src/components/SearchPage/SearchResultListItem.tsx
  57. 4 3
      packages/app/src/components/Sidebar.tsx
  58. 1 1
      packages/app/src/components/Sidebar/PageTree/PrivateLegacyPages.tsx
  59. 1 1
      packages/app/src/components/Sidebar/SidebarNav.tsx
  60. 6 9
      packages/app/src/components/StickyStretchableScroller.jsx
  61. 5 1
      packages/app/src/interfaces/page.ts
  62. 18 0
      packages/app/src/interfaces/search.ts
  63. 2 1
      packages/app/src/interfaces/user-ui-settings.ts
  64. 47 0
      packages/app/src/migrations/20211129125654-initialize-private-legacy-pages-named-query.js
  65. 12 0
      packages/app/src/server/crowi/express-init.js
  66. 2 1
      packages/app/src/server/crowi/index.js
  67. 17 0
      packages/app/src/server/events/comment.ts
  68. 3 1
      packages/app/src/server/events/page.js
  69. 1 1
      packages/app/src/server/interfaces/search.ts
  70. 1 0
      packages/app/src/server/interfaces/slack-integration/events.ts
  71. 32 0
      packages/app/src/server/interfaces/slack-integration/link-shared-unfurl.ts
  72. 1 1
      packages/app/src/server/middlewares/auto-reconnect-to-search.js
  73. 18 0
      packages/app/src/server/models/comment.js
  74. 2 2
      packages/app/src/server/models/editor-settings.ts
  75. 1 1
      packages/app/src/server/models/external-account.js
  76. 41 7
      packages/app/src/server/models/obsolete-page.js
  77. 2 2
      packages/app/src/server/models/page.ts
  78. 2 2
      packages/app/src/server/models/password-reset-order.ts
  79. 5 1
      packages/app/src/server/models/slack-app-integration.js
  80. 1 1
      packages/app/src/server/models/update-post.ts
  81. 6 2
      packages/app/src/server/routes/apiv3/pages.js
  82. 1 1
      packages/app/src/server/routes/apiv3/response.js
  83. 42 29
      packages/app/src/server/routes/apiv3/slack-integration-settings.js
  84. 75 3
      packages/app/src/server/routes/apiv3/slack-integration.js
  85. 0 1
      packages/app/src/server/routes/avoid-session-routes.js
  86. 8 0
      packages/app/src/server/routes/comment.js
  87. 4 0
      packages/app/src/server/routes/index.js
  88. 1 1
      packages/app/src/server/routes/page.js
  89. 7 0
      packages/app/src/server/routes/private-legacy-pages.ts
  90. 24 12
      packages/app/src/server/routes/search.js
  91. 1 1
      packages/app/src/server/service/attachment.js
  92. 7 1
      packages/app/src/server/service/config-loader.ts
  93. 72 12
      packages/app/src/server/service/page.js
  94. 3 3
      packages/app/src/server/service/passport.ts
  95. 58 6
      packages/app/src/server/service/search-delegator/elasticsearch.ts
  96. 53 0
      packages/app/src/server/service/search-delegator/private-legacy-pages.ts
  97. 51 10
      packages/app/src/server/service/search.ts
  98. 12 0
      packages/app/src/server/service/slack-event-handler/base-event-handler.ts
  99. 179 0
      packages/app/src/server/service/slack-event-handler/link-shared.ts
  100. 17 2
      packages/app/src/server/service/slack-integration.ts

+ 11 - 1
CHANGELOG.md

@@ -1,9 +1,19 @@
 # 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.*
 
+## [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
 
 ### 🐛 Bug Fixes

+ 1 - 1
lerna.json

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

+ 1 - 1
package.json

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

+ 1 - 0
packages/app/config/logger/config.dev.js

@@ -35,5 +35,6 @@ module.exports = {
   'growi:services:*': 'debug',
   // 'growi:StaffCredit': 'debug',
   // 'growi:cli:StickyStretchableScroller': 'debug',
+  'growi:searchResultList': 'debug',
 
 };

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

@@ -10,8 +10,8 @@ GROWI Official docker image
 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-nocdn`, `4.3-nocdn` (Dockerfile)](https://github.com/weseek/growi/blob/v4.3.3/docker/Dockerfile)
 

+ 14 - 12
packages/app/package.json

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

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

@@ -348,16 +348,19 @@
       "install_complete_if_checked": "Confirm that \"Install your app\" is checked.",
       "invite_bot_to_channel": "Invite GROWI bot to channel by calling @example.",
       "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",
       "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",
       "deny_all": "Deny all",
       "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_by_pressing_button": "Press the button to test the connection",
       "test_connection_only_public_channel":"Please test connection in a public channel",

+ 6 - 2
packages/app/resource/locales/en_US/translation.json

@@ -64,6 +64,7 @@
   "Include Attachment File": "Include Attachment File",
   "Include Comment": "Include Comment",
   "Include Subordinated Page": "Include Subordinated Page",
+  "Include Subordinated Target Page": "include {{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",
@@ -148,6 +149,7 @@
   "Sign out": "Logout",
   "Disassociate": "Disassociate",
   "No bookmarks yet": "No bookmarks yet",
+  "Add to bookmark": "Add to bookmark",
   "Recent Created": "Recent Created",
   "Recent Changes": "Recent Changes",
   "Page Tree": "Page Tree",
@@ -568,13 +570,15 @@
     "popover_desc": "Input channel name. You can notify multiple channels by entering a comma-separated list."
   },
   "search_result": {
-    "result_meta": "Found \"{{keyword}}\" in {{total}}.",
+    "result_meta": "Search results for:",
     "deletion_mode_btn_lavel": "Select and delete page",
     "cancel": "Cancel",
     "delete": "Delete",
     "check_all": "Check all",
     "deletion_modal_header": "Delete page",
-    "delete_completely": "Delete completely"
+    "delete_completely": "Delete completely",
+    "include_certain_path" : "Include {{pathToInclude}} path ",
+    "delete_all_selected_page" : "Delete All"
   },
   "security_setting": {
     "Guest Users Access": "Guest users access",

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

@@ -347,16 +347,19 @@
       "install_complete_if_checked": "Install your app の右側に緑色のチェックがつけばワークスペースへのインストール完了です。",
       "invite_bot_to_channel": "GROWI bot を使いたいチャンネルに @example を使用して招待します。",
       "register_secret_and_token": "Signing Secret と Bot Token を登録する",
-      "manage_commands": "使用可能なGROWIコマンドを設定する",
+      "manage_permission": "権限を設定する",
+      "growi_commands": "GROWI コマンド",
       "multiple_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": "全てのチャンネルを許可",
       "deny_all": "全てのチャンネルを拒否",
       "allow_specified": "特定のチャンネルを許可",
-      "allow_all-long": "全て許可 (このコマンドは全てのチャンネルから使用することができます)",
-      "deny_all-long": "全て拒否 (このコマンドはどのチャンネルからも使用することはできません)",
-      "allow_specified-long": "特定のチャンネルを許可 (テキストボックスに入力されたチャンネルのみ許可されます)",
+      "allow_all_long": "全て許可 (全てのチャンネルから使用することができます)",
+      "deny_all_long": "全て拒否 (どのチャンネルからも使用することはできません)",
+      "allow_specified_long": "特定のチャンネルを許可 (テキストボックスに入力されたチャンネルのみ許可されます)",
       "test_connection": "連携状況のテストをする",
       "test_connection_by_pressing_button": "以下のテストボタンを押して、Slack連携が完了しているかの確認をしましょう",
       "test_connection_only_public_channel":"連携テストは public チャンネルで確認してください",

+ 6 - 2
packages/app/resource/locales/ja_JP/translation.json

@@ -64,6 +64,7 @@
   "Include Attachment File": "添付ファイルも含める",
   "Include Comment": "コメントも含める",
   "Include Subordinated Page": "配下ページも含める",
+  "Include Subordinated Target Page": "{{target}} 下を含む",
   "All Subordinated Page": "全ての配下ページ",
   "Specify Hierarchy": "階層の深さを指定",
   "Submitted the request to create the archive": "アーカイブ作成のリクエストを正常に送信しました",
@@ -150,6 +151,7 @@
   "Sidebar mode": "サイドバーモード",
   "Sidebar mode on Editor": "サイドバーモード(編集時)",
   "No bookmarks yet": "No bookmarks yet",
+  "Add to bookmark": "ブックマークに追加",
   "Recent Created": "最新の作成",
   "Recent Changes": "最新の変更",
   "Page Tree": "ページツリー",
@@ -568,13 +570,15 @@
     "popover_desc": "チャンネル名を入れてください。カンマ区切りのリストを入力することで複数のチャンネルに通知することができます。"
   },
   "search_result": {
-    "result_meta": "{{total}}件のページが見つかりました。検索ワード: \"{{keyword}}\"",
+    "result_meta": "検索結果:",
     "deletion_mode_btn_lavel": "ページを指定して削除",
     "cancel": "キャンセル",
     "delete": "削除",
     "check_all": "すべてチェック",
     "deletion_modal_header": "以下のページを削除",
-    "delete_completely": "完全に削除する"
+    "delete_completely": "完全に削除する",
+    "include_certain_path": "{{pathToInclude}}下を含む ",
+    "delete_all_selected_page" : "一括削除"
   },
   "security_setting": {
     "Guest Users Access": "ゲストユーザーのアクセス",

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

@@ -357,16 +357,19 @@
       "install_complete_if_checked": "确认已选中 \"Install your app\"。",
       "invite_bot_to_channel": "通过调用 @example 邀请 GROWI Bot 进行频道。",
       "register_secret_and_token": "设置签名秘密和BOT令牌",
-      "manage_commands": "管理 GROWI 命令",
+      "manage_permission": "设置权限",
+      "growi_commands": "GROWI 命令",
       "multiple_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": "允许所有",
       "deny_all": "拒绝所有",
       "allow_specified": "允许指定",
-      "allow_all_long": "允许所有(允许从任何通道发出命令)",
-      "deny_all_long": "拒绝所有(该命令被拒绝于任何通道)",
-      "allow_specified_long": "允许指定(该命令只允许来自指定的通道)",
+      "allow_all_long": "允许所有(允许从任何渠道)",
+      "deny_all_long": "拒绝所有(拒绝来自任何渠道)",
+      "allow_specified_long": "允许指定(只允许来自指定的渠道)",
       "test_connection": "测试连接",
       "test_connection_by_pressing_button": "按下按钮以测试连接",
       "test_connection_only_public_channel":"请在一个公共频道中测试连接",

+ 6 - 2
packages/app/resource/locales/zh_CN/translation.json

@@ -65,6 +65,7 @@
   "Include Attachment File": "包含附件",
   "Include Comment": "包含评论",
   "Include Subordinated Page": "包括子页面",
+  "Include Subordinated Target Page": "包括 {{target}}",
   "All Subordinated Page": "所有子页面",
   "Specify Hierarchy": "指定层级",
   "Submitted the request to create the archive": "提交创建归档请求",
@@ -156,6 +157,7 @@
 	"Sign out": "退出",
   "Disassociate": "解除关联",
   "No bookmarks yet": "暂无书签",
+  "Add to bookmark": "添加到书签",
 	"Recent Created": "最新创建",
   "Recent Changes": "最新修改",
   "Page Tree": "页面树",
@@ -841,13 +843,15 @@
 		"use_os_settings": "使用操作系统设置"
 	},
 	"search_result": {
-		"result_meta": "在{{total}中找到了{{keyword}。",
+		"result_meta": "搜索结果:",
 		"deletion_mode_btn_lavel": "选择并删除页面",
 		"cancel": "取消",
 		"delete": "删除",
 		"check_all": "全部检查",
 		"deletion_modal_header": "删除页",
-		"delete_completely": "完全删除"
+		"delete_completely": "完全删除",
+    "include_certain_path": "包含 {{pathToInclude}} 路径 ",
+    "delete_all_selected_page": "删除所有"
 	},
 	"to_cloud_settings": "進入 GROWI.cloud 的管理界面",
 	"login": {

+ 17 - 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": {
           "type": "keyword"
         },
@@ -74,6 +88,9 @@
         "bookmark_count": {
           "type": "integer"
         },
+        "seenUsers_count":{
+          "type": "integer"
+        },
         "like_count": {
           "type": "integer"
         },

+ 5 - 15
packages/app/src/client/app.jsx

@@ -41,6 +41,7 @@ import PersonalSettings from '../components/Me/PersonalSettings';
 import GrowiSubNavigation from '../components/Navbar/GrowiSubNavigation';
 import GrowiSubNavigationSwitcher from '../components/Navbar/GrowiSubNavigationSwitcher';
 
+import ContextExtractor from '~/client/services/ContextExtractor';
 import NavigationContainer from '~/client/services/NavigationContainer';
 import PageContainer from '~/client/services/PageContainer';
 import PageHistoryContainer from '~/client/services/PageHistoryContainer';
@@ -50,7 +51,6 @@ import EditorContainer from '~/client/services/EditorContainer';
 import TagContainer from '~/client/services/TagContainer';
 import PersonalContainer from '~/client/services/PersonalContainer';
 import PageAccessoriesContainer from '~/client/services/PageAccessoriesContainer';
-import ContextExtractor from '~/client/services/ContextExtractor';
 
 import { appContainer, componentMappings } from './base';
 
@@ -99,7 +99,6 @@ Object.assign(componentMappings, {
 
   'not-found-page': <NotFoundPage />,
   'not-found-alert': <NotFoundAlert
-    onPageCreateClicked={navigationContainer.setEditorMode}
     isGuestUserMode={appContainer.isGuestUser}
     isHidden={pageContainer.state.isNotCreatable || pageContainer.state.isTrashPage}
   />,
@@ -131,7 +130,6 @@ if (pageContainer.state.pageId != null) {
 
     'recent-created-icon': <RecentlyCreatedIcon />,
     'user-bookmark-icon': <BookmarkIcon />,
-    'page-context': <ContextExtractor />, // use static swr
   });
 
   // show the Page accessory modal when query of "compare" is requested
@@ -176,19 +174,12 @@ const renderMainComponents = () => {
 };
 
 // extract context before rendering main components
-const elem = document.getElementById('page-context');
-
+const elem = document.getElementById('growi-context-extractor');
 if (elem != null) {
   ReactDOM.render(
-    <I18nextProvider i18n={i18n}>
-      <ErrorBoundary>
-        <SWRConfig value={swrGlobalConfiguration}>
-          <Provider inject={injectableContainers}>
-            {componentMappings['page-context']}
-          </Provider>
-        </SWRConfig>
-      </ErrorBoundary>
-    </I18nextProvider>,
+    <SWRConfig value={swrGlobalConfiguration}>
+      <ContextExtractor></ContextExtractor>
+    </SWRConfig>,
     elem,
     renderMainComponents,
   );
@@ -197,6 +188,5 @@ else {
   renderMainComponents();
 }
 
-
 // initialize scrollpos-styler
 ScrollPosStyler.init();

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

@@ -17,7 +17,7 @@ window.Crowi = Crowi;
 Crowi.setCaretLineData = function(line) {
   const { appContainer } = window;
   const navigationContainer = appContainer.getContainer('NavigationContainer');
-  navigationContainer.setEditorMode('edit');
+  // navigationContainer.setEditorMode('edit');
   const pageEditorDom = document.querySelector('#page-editor');
   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', () => {
   const crowi = window.crowi;
@@ -228,18 +228,18 @@ window.addEventListener('hashchange', (e) => {
   Crowi.unblinkSelectedSection(Crowi.findHashFromUrl(e.oldURL));
   Crowi.blinkSelectedSection(Crowi.findHashFromUrl(e.newURL));
   Crowi.modifyScrollTop();
-  const { appContainer } = window;
-  const navigationContainer = appContainer.getContainer('NavigationContainer');
+  // const { appContainer } = window;
+  // const navigationContainer = appContainer.getContainer('NavigationContainer');
 
 
   // hash on page
   if (window.location.hash) {
     if (window.location.hash === '#edit') {
-      navigationContainer.setEditorMode('edit');
+      // navigationContainer.setEditorMode('edit');
       Crowi.setCaretLineAndFocusToEditor();
     }
     else if (window.location.hash === '#hackmd') {
-      navigationContainer.setEditorMode('hackmd');
+      // navigationContainer.setEditorMode('hackmd');
     }
   }
 });

+ 28 - 10
packages/app/src/client/services/ContextExtractor.tsx

@@ -1,4 +1,4 @@
-import React, { FC } from 'react';
+import React, { FC, useEffect, useState } from 'react';
 import { pagePathUtils } from '@growi/core';
 
 import {
@@ -7,15 +7,27 @@ import {
   usePageId, usePageIdOnHackmd, usePageUser, useCurrentPagePath, useRevisionCreatedAt, useRevisionId, useRevisionIdHackmdSynced,
   useShareLinkId, useShareLinksNumber, useTemplateTagData, useUpdatedAt, useCreator, useRevisionAuthor, useCurrentUser, useTargetAndAncestors,
 } from '../../stores/context';
+
 import {
-  useEditorMode, useIsDeviceSmallerThanMd, usePreferDrawerModeByUser, usePreferDrawerModeOnEditByUser,
+  EditorMode, useEditorMode, useIsDeviceSmallerThanMd, usePreferDrawerModeByUser, usePreferDrawerModeOnEditByUser,
 } from '~/stores/ui';
 
 const { isTrashPage: _isTrashPage } = pagePathUtils;
 
 const jsonNull = 'null';
 
-const ContextExtractor: FC = () => {
+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');
 
@@ -34,7 +46,7 @@ const ContextExtractor: FC = () => {
   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') || '') != 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);
@@ -62,7 +74,7 @@ const ContextExtractor: FC = () => {
   useCurrentUser(currentUser);
 
   // Navigation
-  useEditorMode();
+  useEditorMode(getInitialEditorMode());
   usePreferDrawerModeByUser();
   usePreferDrawerModeOnEditByUser();
   useIsDeviceSmallerThanMd();
@@ -96,11 +108,17 @@ const ContextExtractor: FC = () => {
   useRevisionAuthor(revisionAuthor);
   useTargetAndAncestors(targetAndAncestors);
 
-  return (
-    <div>
-      {/* Render nothing */}
-    </div>
-  );
+  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;
 
     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,
-
-      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();
   }
 
@@ -56,26 +42,6 @@ export default class NavigationContainer extends Container {
     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() {
     window.addEventListener('scroll', () => {
       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
@@ -176,36 +111,19 @@ export default class NavigationContainer extends Container {
    *
    * 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

+ 5 - 5
packages/app/src/client/services/PageContainer.js

@@ -162,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

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


+ 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>> {
   try {
     const res = await axios[method](urljoin(apiv3Root, path), params);
-    return res.data;
+    return res;
   }
   catch (err) {
     const errors = apiv3ErrorHandler(err);

+ 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({
         isRebuildingProcessing: false,
         isRebuildingCompleted: true,
@@ -69,7 +70,8 @@ class ElasticsearchManagement extends React.Component {
     const { appContainer } = this.props;
 
     try {
-      const { info } = await appContainer.apiv3Get('/search/indices');
+      const { data } = await appContainer.apiv3Get('/search/indices');
+      const { info } = data;
 
       this.setState({
         isConnected: true,

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

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

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

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

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

@@ -21,7 +21,7 @@ export const botInstallationStep = {
 const CustomBotWithoutProxySettingsAccordion = (props) => {
   const {
     appContainer, activeStep, onTestConnectionInvoked,
-    slackSigningSecret, slackBotToken, slackSigningSecretEnv, slackBotTokenEnv, commandPermission,
+    slackSigningSecret, slackBotToken, slackSigningSecretEnv, slackBotTokenEnv, commandPermission, eventActionsPermission,
   } = props;
   const successMessage = 'Successfully sent to Slack workspace.';
 
@@ -125,10 +125,11 @@ const CustomBotWithoutProxySettingsAccordion = (props) => {
       <Accordion
         defaultIsActive={defaultOpenAccordionKeys.has(botInstallationStep.CONNECTION_TEST)}
         // 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
-          commandPermission={props.commandPermission}
+          commandPermission={commandPermission}
+          eventActionsPermission={eventActionsPermission}
           apiv3Put={props.appContainer.apiv3.put}
         />
       </Accordion>
@@ -200,7 +201,7 @@ CustomBotWithoutProxySettingsAccordion.propTypes = {
   slackBotToken: PropTypes.string,
   slackBotTokenEnv: PropTypes.string,
   commandPermission: PropTypes.object,
-
+  eventActionsPermission: PropTypes.object,
 };
 
 export default CustomBotWithoutProxySettingsAccordionWrapper;

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

@@ -1,7 +1,7 @@
 import React, { useCallback, useState } from 'react';
 import PropTypes from 'prop-types';
 import { useTranslation } from 'react-i18next';
-import { defaultSupportedCommandsNameForBroadcastUse, defaultSupportedCommandsNameForSingleUse } from '@growi/slack';
+import { defaultSupportedCommandsNameForBroadcastUse, defaultSupportedCommandsNameForSingleUse, defaultSupportedSlackEventActions } from '@growi/slack';
 import loggerFactory from '~/utils/logger';
 
 import { toastSuccess, toastError } from '../../../client/util/apiNotification';
@@ -19,6 +19,11 @@ const CommandUsageTypes = {
   SINGLE_USE: 'singleUse',
 };
 
+const EventTypes = {
+  LINK_SHARING: 'linkSharing',
+};
+
+
 // A utility function that returns the new state but identical to the previous state
 const getUpdatedChannelsList = (prevState, commandName, value) => {
   // string to array
@@ -62,9 +67,110 @@ const getPermissionTypeFromValue = (value) => {
   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
 const ManageCommandsProcess = ({
-  apiv3Put, slackAppIntegrationId, permissionsForBroadcastUseCommands, permissionsForSingleUseCommands,
+  apiv3Put, slackAppIntegrationId, permissionsForBroadcastUseCommands, permissionsForSingleUseCommands, permissionsForSlackEventActions,
 }) => {
   const { t } = useTranslation();
 
@@ -75,6 +181,9 @@ const ManageCommandsProcess = ({
     note: permissionsForSingleUseCommands.note,
     keep: permissionsForSingleUseCommands.keep,
   });
+  const [permissionsForEventsState, setPermissionsForEventsState] = useState({
+    unfurl: permissionsForSlackEventActions.unfurl,
+  });
   const [currentPermissionTypes, setCurrentPermissionTypes] = useState(() => {
     const initialState = {};
     Object.entries(permissionsForBroadcastUseCommandsState).forEach((entry) => {
@@ -85,14 +194,28 @@ const ManageCommandsProcess = ({
       const [commandName, value] = entry;
       initialState[commandName] = getPermissionTypeFromValue(value);
     });
+    Object.entries(permissionsForEventsState).forEach((entry) => {
+      const [commandName, value] = entry;
+      initialState[commandName] = getPermissionTypeFromValue(value);
+    });
     return initialState;
   });
 
-  const updatePermissionsForBroadcastUseCommandsState = useCallback((e) => {
+
+  const handleUpdateSingleUsePermissions = useCallback((e) => {
     const { target } = e;
     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));
     setCurrentPermissionTypes((prevState) => {
       const newState = { ...prevState };
@@ -101,12 +224,10 @@ const ManageCommandsProcess = ({
     });
   }, []);
 
-  const updatePermissionsForSingleUseCommandsState = useCallback((e) => {
+  const handleUpdateEventsPermissions = useCallback((e) => {
     const { target } = e;
     const { name: commandName, value } = target;
-
-    // update state
-    setPermissionsForSingleUseCommandsState(prev => getUpdatedPermissionSettings(prev, commandName, value));
+    setPermissionsForEventsState(prev => getUpdatedPermissionSettings(prev, commandName, value));
     setCurrentPermissionTypes((prevState) => {
       const newState = { ...prevState };
       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 { name: commandName, value } = target;
-    // update state
     setPermissionsForBroadcastUseCommandsState(prev => getUpdatedChannelsList(prev, commandName, value));
   }, []);
 
-  const updateChannelsListForSingleUseCommandsState = useCallback((e) => {
+  const handleUpdateEventsChannels = useCallback((e) => {
     const { target } = e;
     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 {
-      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,
         permissionsForSingleUseCommands: permissionsForSingleUseCommandsState,
+        permissionsForSlackEventActions: permissionsForEventsState,
       });
       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 (
-      <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 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="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>
       </>
     );
   };
 
-  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 (
     <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="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">
-          {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 className="row">
         <button
           type="submit"
           className="btn btn-primary mx-auto"
-          onClick={updateCommandsHandler}
+          onClick={updateSettingsHandler}
         >
           { t('Update') }
         </button>
@@ -290,6 +405,7 @@ ManageCommandsProcess.propTypes = {
   slackAppIntegrationId: PropTypes.string.isRequired,
   permissionsForBroadcastUseCommands: PropTypes.object.isRequired,
   permissionsForSingleUseCommands: PropTypes.object.isRequired,
+  permissionsForSlackEventActions: PropTypes.object.isRequired,
 };
 
 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 PropTypes from 'prop-types';
 import { useTranslation } from 'react-i18next';
-import { defaultSupportedCommandsNameForBroadcastUse, defaultSupportedCommandsNameForSingleUse } from '@growi/slack';
+import { defaultSupportedCommandsNameForBroadcastUse, defaultSupportedCommandsNameForSingleUse, defaultSupportedSlackEventActions } from '@growi/slack';
 import loggerFactory from '~/utils/logger';
 
 import { toastSuccess, toastError } from '../../../client/util/apiNotification';
@@ -49,7 +49,7 @@ const getUpdatedPermissionSettings = (commandPermissionObj, commandName, value)
 };
 
 
-const PermissionSettingForEachCommandComponent = ({
+const SinglePermissionSettingComponent = ({
   commandName, editingCommandPermission, onPermissionTypeClicked, onPermissionListChanged,
 }) => {
   const { t } = useTranslation();
@@ -144,7 +144,7 @@ const PermissionSettingForEachCommandComponent = ({
   );
 };
 
-PermissionSettingForEachCommandComponent.propTypes = {
+SinglePermissionSettingComponent.propTypes = {
   commandName: PropTypes.string,
   editingCommandPermission: PropTypes.object,
   onPermissionTypeClicked: PropTypes.func,
@@ -153,18 +153,10 @@ PermissionSettingForEachCommandComponent.propTypes = {
 
 
 // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
-const ManageCommandsProcessWithoutProxy = ({ apiv3Put, commandPermission }) => {
+const ManageCommandsProcessWithoutProxy = ({ apiv3Put, commandPermission, eventActionsPermission }) => {
   const { t } = useTranslation();
   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(() => {
     if (commandPermission == null) {
@@ -174,21 +166,43 @@ const ManageCommandsProcessWithoutProxy = ({ apiv3Put, commandPermission }) => {
     setEditingCommandPermission(updatedState);
   }, [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 { 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) => {
     try {
       await apiv3Put('/slack-integration-settings/without-proxy/update-permissions', {
         commandPermission: editingCommandPermission,
+        eventActionsPermission: editingEventActionsPermission,
       });
       toastSuccess(t('toaster.update_successed', { target: 'the permission for commands' }));
     }
@@ -200,7 +214,7 @@ const ManageCommandsProcessWithoutProxy = ({ apiv3Put, commandPermission }) => {
 
   return (
     <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="col-8">
           <div className="custom-control custom-checkbox">
@@ -208,12 +222,12 @@ const ManageCommandsProcessWithoutProxy = ({ apiv3Put, commandPermission }) => {
               { defaultCommandsName.map((commandName) => {
                 // eslint-disable-next-line max-len
                 return (
-                  <PermissionSettingForEachCommandComponent
+                  <SinglePermissionSettingComponent
                     key={`${commandName}-component`}
                     commandName={commandName}
                     editingCommandPermission={editingCommandPermission}
                     onPermissionTypeClicked={updatePermissionsCommandsState}
-                    onPermissionListChanged={updateChannelsListState}
+                    onPermissionListChanged={updateCommandsChannelsListState}
                   />
                 );
               })}
@@ -221,6 +235,24 @@ const ManageCommandsProcessWithoutProxy = ({ apiv3Put, commandPermission }) => {
           </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">
         <button
           type="submit"
@@ -237,6 +269,7 @@ const ManageCommandsProcessWithoutProxy = ({ apiv3Put, commandPermission }) => {
 ManageCommandsProcessWithoutProxy.propTypes = {
   apiv3Put: PropTypes.func,
   commandPermission: PropTypes.object,
+  eventActionsPermission: PropTypes.object,
 };
 
 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 [slackBotTokenEnv, setSlackBotTokenEnv] = useState('');
   const [commandPermission, setCommandPermission] = useState(null);
+  const [eventActionsPermission, setEventActionsPermission] = useState(null);
   const [isDeleteConfirmModalShown, setIsDeleteConfirmModalShown] = useState(false);
   const [slackAppIntegrations, setSlackAppIntegrations] = useState();
   const [proxyServerUri, setProxyServerUri] = useState();
@@ -41,7 +42,14 @@ const SlackIntegration = (props) => {
     try {
       const { data } = await appContainer.apiv3.get('/slack-integration-settings');
       const {
-        slackSigningSecret, slackBotToken, slackSigningSecretEnvVars, slackBotTokenEnvVars, slackAppIntegrations, proxyServerUri, commandPermission,
+        slackSigningSecret,
+        slackBotToken,
+        slackSigningSecretEnvVars,
+        slackBotTokenEnvVars,
+        slackAppIntegrations,
+        proxyServerUri,
+        commandPermission,
+        eventActionsPermission,
       } = data.settings;
 
       setErrorMsg(data.errorMsg);
@@ -55,6 +63,7 @@ const SlackIntegration = (props) => {
       setSlackAppIntegrations(slackAppIntegrations);
       setProxyServerUri(proxyServerUri);
       setCommandPermission(commandPermission);
+      setEventActionsPermission(eventActionsPermission);
     }
     catch (err) {
       toastError(err);
@@ -154,6 +163,7 @@ const SlackIntegration = (props) => {
           onUpdatedSecretToken={changeSecretAndToken}
           connectionStatuses={connectionStatuses}
           commandPermission={commandPermission}
+          eventActionsPermission={eventActionsPermission}
         />
       );
       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
         apiv3Put={props.appContainer.apiv3.put}
         slackAppIntegrationId={props.slackAppIntegrationId}
         permissionsForBroadcastUseCommands={props.permissionsForBroadcastUseCommands}
         permissionsForSingleUseCommands={props.permissionsForSingleUseCommands}
+        permissionsForSlackEventActions={props.permissionsForSlackEventActions}
       />,
     },
     '④': {
@@ -384,12 +385,13 @@ const WithProxyAccordions = (props) => {
       content: <RegisteringProxyUrlProcess />,
     },
     '⑤': {
-      title: 'manage_commands',
+      title: 'manage_permission',
       content: <ManageCommandsProcess
         apiv3Put={props.appContainer.apiv3.put}
         slackAppIntegrationId={props.slackAppIntegrationId}
         permissionsForBroadcastUseCommands={props.permissionsForBroadcastUseCommands}
         permissionsForSingleUseCommands={props.permissionsForSingleUseCommands}
+        permissionsForSlackEventActions={props.permissionsForSlackEventActions}
       />,
     },
     '⑥': {
@@ -443,6 +445,7 @@ WithProxyAccordions.propTypes = {
   tokenGtoP: PropTypes.string,
   permissionsForBroadcastUseCommands: PropTypes.object.isRequired,
   permissionsForSingleUseCommands: PropTypes.object.isRequired,
+  permissionsForSlackEventActions: PropTypes.object.isRequired,
 };
 
 export default WithProxyAccordionsWrapper;

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

@@ -6,6 +6,8 @@ import loggerFactory from '~/utils/logger';
 
 import AppContainer from '~/client/services/AppContainer';
 import NavigationContainer from '~/client/services/NavigationContainer';
+import { usePageCreateModalOpened } from '~/stores/ui';
+
 import { withUnstatedContainers } from './UnstatedUtils';
 import CreatePageIcon from './Icons/CreatePageIcon';
 import ReturnTopIcon from './Icons/ReturnTopIcon';
@@ -16,6 +18,8 @@ const Fab = (props) => {
   const { navigationContainer, appContainer } = props;
   const { currentUser } = appContainer;
 
+  const { mutate: mutatePageCreateModalOpened } = usePageCreateModalOpened();
+
   const [animateClasses, setAnimateClasses] = useState('invisible');
   const [buttonClasses, setButtonClasses] = useState('');
 
@@ -52,7 +56,7 @@ const Fab = (props) => {
           <button
             type="button"
             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 />
           </button>

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

@@ -1,31 +1,29 @@
 import React, { useEffect } from 'react';
 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
   useEffect(() => {
-    props.navigationContainer.openPageCreateModal();
+    mutate(true);
 
     // remove this
     props.onDeleteRender(this);
-  }, [props]);
+  }, [mutate, props]);
 
   return <></>;
-};
+});
 
 CreatePage.propTypes = {
-  navigationContainer: PropTypes.instanceOf(NavigationContainer).isRequired,
   onDeleteRender: PropTypes.func.isRequired,
 };
 
-const CreatePageWrapper = withUnstatedContainers(CreatePage, [NavigationContainer]);
-
-CreatePageWrapper.getHotkeyStrokes = () => {
+CreatePage.getHotkeyStrokes = () => {
   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 { withUnstatedContainers } from '../../UnstatedUtils';
+import { EditorMode, useEditorMode } from '~/stores/ui';
 
 const EditPage = (props) => {
+  const { mutate: mutateEditorMode } = useEditorMode();
 
   // setup effect
   useEffect(() => {
@@ -13,11 +15,11 @@ const EditPage = (props) => {
       return;
     }
 
-    props.navigationContainer.setEditorMode('edit');
+    mutateEditorMode(EditorMode.Editor);
 
     // remove this
     props.onDeleteRender(this);
-  }, [props]);
+  }, [mutateEditorMode, props]);
 
   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
     xmlns="http://www.w3.org/2000/svg"
     width="32"
@@ -29,6 +29,6 @@ const GrowiLogo = () => (
     >
     </path>
   </svg>
-);
+));
 
 export default GrowiLogo;

+ 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 NavigationContainer from '~/client/services/NavigationContainer';
-import { withUnstatedContainers } from '../UnstatedUtils';
+import { usePageCreateModalOpened, useIsDeviceSmallerThanMd, useDrawerOpened } from '~/stores/ui';
 
+import { withUnstatedContainers } from '../UnstatedUtils';
 import GlobalSearch from './GlobalSearch';
 
 const GrowiNavbarBottom = (props) => {
@@ -11,7 +12,10 @@ const GrowiNavbarBottom = (props) => {
   const {
     navigationContainer,
   } = 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'];
   if (isDrawerOpened) {
@@ -36,7 +40,7 @@ const GrowiNavbarBottom = (props) => {
             <a
               role="button"
               className="nav-link btn-lg"
-              onClick={() => navigationContainer.toggleDrawer()}
+              onClick={() => mutateDrawerOpened(true)}
             >
               <i className="icon-menu"></i>
             </a>
@@ -55,7 +59,7 @@ const GrowiNavbarBottom = (props) => {
             <a
               role="button"
               className="nav-link btn-lg"
-              onClick={() => navigationContainer.openPageCreateModal()}
+              onClick={() => mutatePageCreateModalOpened(true)}
             >
               <i className="icon-pencil"></i>
             </a>

+ 13 - 12
packages/app/src/components/Navbar/GrowiSubNavigation.jsx

@@ -1,16 +1,16 @@
 import React from 'react';
 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 AppContainer from '~/client/services/AppContainer';
-import NavigationContainer from '~/client/services/NavigationContainer';
 import PageContainer from '~/client/services/PageContainer';
+import {
+  EditorMode, useDrawerMode, useEditorMode, useIsDeviceSmallerThanMd,
+} from '~/stores/ui';
 
 import CopyDropdown from '../Page/CopyDropdown';
 import TagLabels from '../Page/TagLabels';
@@ -67,21 +67,24 @@ const PagePathNav = ({
 };
 
 const GrowiSubNavigation = (props) => {
+  const { data: isDeviceSmallerThanMd } = useIsDeviceSmallerThanMd();
+  const { data: isDrawerMode } = useDrawerMode();
+  const { data: editorMode, mutate: mutateEditorMode } = useEditorMode();
+
   const {
-    appContainer, navigationContainer, pageContainer, isCompactMode,
+    appContainer, pageContainer, isCompactMode,
   } = props;
-  const { isDrawerMode, editorMode, isDeviceSmallerThanMd } = navigationContainer.state;
   const {
     pageId, path, createdAt, creator, updatedAt, revisionAuthor, isPageExist,
   } = pageContainer.state;
 
   const { isGuestUser } = appContainer;
-  const isEditorMode = editorMode !== 'view';
+  const isEditorMode = editorMode !== EditorMode.View;
   // Tags cannot be edited while the new page and editorMode is view
-  const isTagLabelHidden = (editorMode !== 'edit' && !isPageExist);
+  const isTagLabelHidden = (editorMode !== EditorMode.Editor && !isPageExist);
 
   function onPageEditorModeButtonClicked(viewType) {
-    navigationContainer.setEditorMode(viewType);
+    mutateEditorMode(viewType);
   }
 
   return (
@@ -145,16 +148,14 @@ const GrowiSubNavigation = (props) => {
 /**
  * Wrapper component for using unstated
  */
-const GrowiSubNavigationWrapper = withUnstatedContainers(GrowiSubNavigation, [AppContainer, NavigationContainer, PageContainer]);
+const GrowiSubNavigationWrapper = withUnstatedContainers(GrowiSubNavigation, [AppContainer, PageContainer]);
 
 
 GrowiSubNavigation.propTypes = {
-  t: PropTypes.func.isRequired, //  i18next
   appContainer: PropTypes.instanceOf(AppContainer).isRequired,
-  navigationContainer: PropTypes.instanceOf(NavigationContainer).isRequired,
   pageContainer: PropTypes.instanceOf(PageContainer).isRequired,
 
   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 PropTypes from 'prop-types';
-import { withTranslation } from 'react-i18next';
+import { useTranslation } from 'react-i18next';
 import { UncontrolledTooltip } from 'reactstrap';
 
-import { withUnstatedContainers } from '../UnstatedUtils';
 import AppContainer from '~/client/services/AppContainer';
+import { EditorMode, useIsDeviceSmallerThanMd } from '~/stores/ui';
+
+import { withUnstatedContainers } from '../UnstatedUtils';
 
 /* eslint-disable react/prop-types */
 const PageEditorModeButtonWrapper = React.memo(({
@@ -36,14 +38,17 @@ const PageEditorModeButtonWrapper = React.memo(({
 
 function PageEditorModeManager(props) {
   const {
-    t, appContainer,
-    editorMode, onPageEditorModeButtonClicked, isBtnDisabled, isDeviceSmallerThanMd,
+    appContainer,
+    editorMode, onPageEditorModeButtonClicked, isBtnDisabled,
   } = props;
 
+  const { t } = useTranslation();
+  const { data: isDeviceSmallerThanMd } = useIsDeviceSmallerThanMd();
+
   const isAdmin = appContainer.isAdmin;
   const isHackmdEnabled = appContainer.config.env.HACKMD_URI != null;
   const showHackmdBtn = isHackmdEnabled || isAdmin;
-  const showHackmdDisabledTooltip = isAdmin && !isHackmdEnabled && editorMode !== 'hackmd';
+  const showHackmdDisabledTooltip = isAdmin && !isHackmdEnabled && editorMode !== EditorMode.HackMD;
 
   const pageEditorModeButtonClickedHandler = useCallback((viewType) => {
     if (isBtnDisabled) {
@@ -62,32 +67,32 @@ function PageEditorModeManager(props) {
         aria-label="page-editor-mode-manager"
         id="grw-page-editor-mode-manager"
       >
-        {(!isDeviceSmallerThanMd || editorMode !== 'view') && (
+        {(!isDeviceSmallerThanMd || editorMode !== EditorMode.View) && (
           <PageEditorModeButtonWrapper
             editorMode={editorMode}
             isBtnDisabled={isBtnDisabled}
             onClick={pageEditorModeButtonClickedHandler}
-            targetMode="view"
+            targetMode={EditorMode.View}
             icon={<i className="icon-control-play" />}
             label={t('view')}
           />
         )}
-        {(!isDeviceSmallerThanMd || editorMode === 'view') && (
+        {(!isDeviceSmallerThanMd || editorMode === EditorMode.View) && (
           <PageEditorModeButtonWrapper
             editorMode={editorMode}
             isBtnDisabled={isBtnDisabled}
             onClick={pageEditorModeButtonClickedHandler}
-            targetMode="edit"
+            targetMode={EditorMode.Editor}
             icon={<i className="icon-note" />}
             label={t('Edit')}
           />
         )}
-        {(!isDeviceSmallerThanMd || editorMode === 'view') && showHackmdBtn && (
+        {(!isDeviceSmallerThanMd || editorMode === EditorMode.View) && showHackmdBtn && (
           <PageEditorModeButtonWrapper
             editorMode={editorMode}
             isBtnDisabled={isBtnDisabled}
             onClick={pageEditorModeButtonClickedHandler}
-            targetMode="hackmd"
+            targetMode={EditorMode.HackMD}
             icon={<i className="fa fa-file-text-o" />}
             label={t('hackmd.hack_md')}
             id="grw-page-editor-mode-manager-hackmd-button"
@@ -110,18 +115,15 @@ function PageEditorModeManager(props) {
 }
 
 PageEditorModeManager.propTypes = {
-  t: PropTypes.func.isRequired, //  i18next
   appContainer: PropTypes.instanceOf(AppContainer).isRequired,
 
   onPageEditorModeButtonClicked: PropTypes.func,
   isBtnDisabled: PropTypes.bool,
   editorMode: PropTypes.string,
-  isDeviceSmallerThanMd: PropTypes.bool,
 };
 
 PageEditorModeManager.defaultProps = {
   isBtnDisabled: false,
-  isDeviceSmallerThanMd: false,
 };
 
 /**
@@ -129,4 +131,4 @@ PageEditorModeManager.defaultProps = {
  */
 const PageEditorModeManagerWrapper = withUnstatedContainers(PageEditorModeManager, [AppContainer]);
 
-export default withTranslation()(PageEditorModeManagerWrapper);
+export default PageEditorModeManagerWrapper;

+ 4 - 2
packages/app/src/components/Navbar/PersonalDropdown.jsx

@@ -6,11 +6,13 @@ import { withTranslation } from 'react-i18next';
 import { UncontrolledTooltip } from 'reactstrap';
 
 import { UserPicture } from '@growi/ui';
-import { withUnstatedContainers } from '../UnstatedUtils';
+
+import { scheduleToPutUserUISettings } from '~/client/services/user-ui-settings';
 import AppContainer from '~/client/services/AppContainer';
+
+import { withUnstatedContainers } from '../UnstatedUtils';
 import NavigationContainer from '~/client/services/NavigationContainer';
 import { usePreferDrawerModeByUser, usePreferDrawerModeOnEditByUser } from '~/stores/ui';
-import { scheduleToPutUserUISettings } from '~/services/user-ui-settings';
 
 import {
   isUserPreferenceExists,

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

@@ -3,6 +3,7 @@ import PropTypes from 'prop-types';
 import AppContainer from '~/client/services/AppContainer';
 import NavigationContainer from '~/client/services/NavigationContainer';
 import PageContainer from '~/client/services/PageContainer';
+import { EditorMode, useEditorMode } from '~/stores/ui';
 import { withUnstatedContainers } from '../UnstatedUtils';
 
 import BookmarkButton from '../BookmarkButton';
@@ -14,7 +15,7 @@ const SubnavButtons = (props) => {
     appContainer, navigationContainer, pageContainer, isCompactMode,
   } = props;
 
-  /* eslint-enable react/prop-types */
+  const { data: editorMode } = useEditorMode();
 
   /* eslint-disable react/prop-types */
   const PageReactionButtons = ({ pageContainer }) => {
@@ -34,8 +35,7 @@ const SubnavButtons = (props) => {
   };
   /* eslint-enable react/prop-types */
 
-  const { editorMode } = navigationContainer.state;
-  const isViewMode = editorMode === 'view';
+  const isViewMode = editorMode === EditorMode.View;
 
   return (
     <>

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

@@ -1,9 +1,11 @@
 import React from 'react';
 import { TabContent, TabPane } from 'reactstrap';
 import propTypes from 'prop-types';
+
 import { withUnstatedContainers } from '../UnstatedUtils';
-import NavigationContainer from '~/client/services/NavigationContainer';
 import PageContainer from '~/client/services/PageContainer';
+import { EditorMode, useEditorMode } from '~/stores/ui';
+
 import Editor from '../PageEditor';
 import Page from '../Page';
 import UserInfo from '../User/UserInfo';
@@ -16,15 +18,18 @@ import EditorNavbarBottom from '../PageEditor/EditorNavbarBottom';
 
 const DisplaySwitcher = (props) => {
   const {
-    navigationContainer, pageContainer,
+    pageContainer,
   } = props;
-  const { editorMode } = navigationContainer.state;
   const { isPageExist, pageUser } = pageContainer.state;
 
+  const { data: editorMode } = useEditorMode();
+
+  const isViewMode = editorMode === EditorMode.View;
+
   return (
     <>
       <TabContent activeTab={editorMode}>
-        <TabPane tabId="view">
+        <TabPane tabId={EditorMode.View}>
           <div className="d-flex flex-column flex-lg-row-reverse">
 
             <div className="grw-side-contents-container">
@@ -49,26 +54,25 @@ const DisplaySwitcher = (props) => {
 
           </div>
         </TabPane>
-        <TabPane tabId="edit">
+        <TabPane tabId={EditorMode.Editor}>
           <div id="page-editor">
             <Editor />
           </div>
         </TabPane>
-        <TabPane tabId="hackmd">
+        <TabPane tabId={EditorMode.HackMD}>
           <div id="page-editor-with-hackmd">
             <PageEditorByHackmd />
           </div>
         </TabPane>
       </TabContent>
-      {editorMode !== 'view' && <EditorNavbarBottom /> }
+      {!isViewMode && <EditorNavbarBottom /> }
     </>
   );
 };
 
 DisplaySwitcher.propTypes = {
-  navigationContainer: propTypes.instanceOf(NavigationContainer).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 { withTranslation } from 'react-i18next';
+import { useTranslation } from 'react-i18next';
 import { UncontrolledTooltip } from 'reactstrap';
+import { EditorMode, useEditorMode } from '~/stores/ui';
 
 
 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,
     // disabled of button cannot be used for using tooltip.
     if (isGuestUserMode) {
       return;
     }
 
-    if (props.onPageCreateClicked === null) {
-      return;
-    }
-    props.onPageCreateClicked(viewType);
-  }
+    mutateEditorMode(EditorMode.Editor);
+
+  }, [isGuestUserMode, mutateEditorMode]);
 
   if (isHidden) {
     return null;
@@ -38,7 +40,7 @@ const NotFoundAlert = (props) => {
           <button
             type="button"
             className={`pl-3 pr-3 btn bg-info text-white ${isGuestUserMode ? 'disabled' : ''}`}
-            onClick={() => { clickHandler('edit') }}
+            onClick={clickHandler}
           >
             <i className="icon-note icon-fw" />
             {t('not_found_page.Create Page')}
@@ -58,10 +60,8 @@ const NotFoundAlert = (props) => {
 
 
 NotFoundAlert.propTypes = {
-  t: PropTypes.func.isRequired, // i18next
-  onPageCreateClicked: PropTypes.func,
   isHidden: PropTypes.bool.isRequired,
   isGuestUserMode: PropTypes.bool.isRequired,
 };
 
-export default withTranslation()(NotFoundAlert);
+export default NotFoundAlert;

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

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

@@ -3,9 +3,12 @@ import PropTypes from 'prop-types';
 
 import { Collapse, Button } from 'reactstrap';
 
-import NavigationContainer from '~/client/services/NavigationContainer';
 import EditorContainer from '~/client/services/EditorContainer';
 import AppContainer from '~/client/services/AppContainer';
+import {
+  EditorMode, useDrawerOpened, useEditorMode, useIsDeviceSmallerThanMd,
+} from '~/stores/ui';
+
 import SlackNotification from '../SlackNotification';
 import SlackLogo from '../SlackLogo';
 import { withUnstatedContainers } from '../UnstatedUtils';
@@ -16,20 +19,24 @@ import OptionsSelector from './OptionsSelector';
 
 const EditorNavbarBottom = (props) => {
 
+  const { data: editorMode } = useEditorMode();
+
   const [isExpanded, setExpanded] = useState(false);
 
   const [isSlackExpanded, setSlackExpanded] = useState(false);
   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 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>
     </button>
   );
@@ -55,7 +62,7 @@ const EditorNavbarBottom = (props) => {
     </div>
   );
 
-  const isOptionsSelectorEnabled = editorMode !== 'hackmd';
+  const isOptionsSelectorEnabled = editorMode !== EditorMode.HackMD;
   const isCollapsedOptionsSelectorEnabled = isOptionsSelectorEnabled && isDeviceSmallerThanMd;
 
   return (
@@ -127,9 +134,8 @@ const EditorNavbarBottom = (props) => {
 };
 
 EditorNavbarBottom.propTypes = {
-  navigationContainer: PropTypes.instanceOf(NavigationContainer).isRequired,
   appContainer: PropTypes.instanceOf(AppContainer).isRequired,
   editorContainer: PropTypes.instanceOf(EditorContainer).isRequired,
 };
 
-export default withUnstatedContainers(EditorNavbarBottom, [NavigationContainer, EditorContainer, AppContainer]);
+export default withUnstatedContainers(EditorNavbarBottom, [EditorContainer, AppContainer]);

+ 23 - 29
packages/app/src/components/PaginationWrapper.jsx → packages/app/src/components/PaginationWrapper.tsx

@@ -1,18 +1,21 @@
-import React, { useCallback, useMemo } from 'react';
-import PropTypes from 'prop-types';
+import React, {
+  FC, memo, useCallback, useMemo,
+} from 'react';
 
 import { Pagination, PaginationItem, PaginationLink } from 'reactstrap';
 
-/**
- *
- * @author Mikitaka Itizawa <itizawa@weseek.co.jp>
- *
- * @export
- * @class PaginationWrapper
- * @extends {React.Component}
- */
 
-const PaginationWrapper = React.memo((props) => {
+type Props = {
+  activePage: number,
+  changePage?: (number) => void,
+  totalItemsCount: number,
+  pagingLimit?: number,
+  align?: string,
+  size?: string,
+};
+
+
+const PaginationWrapper: FC<Props> = memo((props: Props) => {
   const {
     activePage, changePage, totalItemsCount, pagingLimit, align,
   } = props;
@@ -59,14 +62,14 @@ const PaginationWrapper = React.memo((props) => {
    * this function set << & <
    */
   const generateFirstPrev = useCallback(() => {
-    const paginationItems = [];
+    const paginationItems: JSX.Element[] = [];
     if (activePage !== 1) {
       paginationItems.push(
         <PaginationItem key="painationItemFirst">
-          <PaginationLink first onClick={() => { return changePage(1) }} />
+          <PaginationLink first onClick={() => { return changePage != null && changePage(1) }} />
         </PaginationItem>,
         <PaginationItem key="painationItemPrevious">
-          <PaginationLink previous onClick={() => { return changePage(activePage - 1) }} />
+          <PaginationLink previous onClick={() => { return changePage != null && changePage(activePage - 1) }} />
         </PaginationItem>,
       );
     }
@@ -89,11 +92,11 @@ const PaginationWrapper = React.memo((props) => {
    * this function set  numbers
    */
   const generatePaginations = useCallback(() => {
-    const paginationItems = [];
+    const paginationItems: JSX.Element[] = [];
     for (let number = paginationStart; number <= maxViewPageNum; number++) {
       paginationItems.push(
         <PaginationItem key={`paginationItem-${number}`} active={number === activePage}>
-          <PaginationLink onClick={() => { return changePage(number) }}>
+          <PaginationLink onClick={() => { return changePage != null && changePage(number) }}>
             {number}
           </PaginationLink>
         </PaginationItem>,
@@ -108,14 +111,14 @@ const PaginationWrapper = React.memo((props) => {
    * this function set > & >>
    */
   const generateNextLast = useCallback(() => {
-    const paginationItems = [];
+    const paginationItems: JSX.Element[] = [];
     if (totalPage !== activePage) {
       paginationItems.push(
         <PaginationItem key="painationItemNext">
-          <PaginationLink next onClick={() => { return changePage(activePage + 1) }} />
+          <PaginationLink next onClick={() => { return changePage != null && changePage(activePage + 1) }} />
         </PaginationItem>,
         <PaginationItem key="painationItemLast">
-          <PaginationLink last onClick={() => { return changePage(totalPage) }} />
+          <PaginationLink last onClick={() => { return changePage != null && changePage(totalPage) }} />
         </PaginationItem>,
       );
     }
@@ -133,7 +136,7 @@ const PaginationWrapper = React.memo((props) => {
   }, [activePage, changePage, totalPage]);
 
   const getListClassName = useMemo(() => {
-    const listClassNames = [];
+    const listClassNames: string[] = [];
 
     if (align === 'center') {
       listClassNames.push('justify-content-center');
@@ -157,15 +160,6 @@ const PaginationWrapper = React.memo((props) => {
 
 });
 
-PaginationWrapper.propTypes = {
-  activePage: PropTypes.number.isRequired,
-  changePage: PropTypes.func.isRequired,
-  totalItemsCount: PropTypes.number.isRequired,
-  pagingLimit: PropTypes.number,
-  align: PropTypes.string,
-  size: PropTypes.string,
-};
-
 PaginationWrapper.defaultProps = {
   align: 'left',
   size: 'md',

+ 2 - 1
packages/app/src/components/SearchForm.jsx

@@ -1,5 +1,6 @@
 import React from 'react';
 import PropTypes from 'prop-types';
+import { withTranslation } from 'react-i18next';
 import { withUnstatedContainers } from './UnstatedUtils';
 import AppContainer from '~/client/services/AppContainer';
 
@@ -174,4 +175,4 @@ SearchForm.defaultProps = {
   onInputChange: () => {},
 };
 
-export default SearchFormWrapper;
+export default withTranslation()(SearchFormWrapper);

+ 163 - 25
packages/app/src/components/SearchPage.jsx

@@ -8,24 +8,45 @@ import { withUnstatedContainers } from './UnstatedUtils';
 import AppContainer from '~/client/services/AppContainer';
 
 import { toastError } from '~/client/util/apiNotification';
+import SearchPageLayout from './SearchPage/SearchPageLayout';
+import SearchResultContent from './SearchPage/SearchResultContent';
+import SearchResultList from './SearchPage/SearchResultList';
+import SearchControl from './SearchPage/SearchControl';
 
-import SearchPageForm from './SearchPage/SearchPageForm';
-import SearchResult from './SearchPage/SearchResult';
+export const specificPathNames = {
+  user: '/user',
+  trash: '/trash',
+};
 
 class SearchPage extends React.Component {
 
   constructor(props) {
     super(props);
-
+    // NOTE : selectedPages is deletion related state, will be used later in story 77535, 77565.
+    // deletionModal, deletion related functions are all removed, add them back when necessary.
+    // i.e ) in story 77525 or any tasks implementing deletion functionalities
     this.state = {
       searchingKeyword: decodeURI(this.props.query.q) || '',
       searchedKeyword: '',
       searchedPages: [],
       searchResultMeta: {},
+      focusedPage: {},
+      selectedPages: new Set(),
+      searchResultCount: 0,
+      activePage: 1,
+      pagingLimit: 10, // change to an appropriate limit number
+      excludeUsersHome: true,
+      excludeTrash: true,
     };
 
-    this.search = this.search.bind(this);
     this.changeURL = this.changeURL.bind(this);
+    this.search = this.search.bind(this);
+    this.searchHandler = this.searchHandler.bind(this);
+    this.selectPage = this.selectPage.bind(this);
+    this.toggleCheckBox = this.toggleCheckBox.bind(this);
+    this.onExcludeUsersHome = this.onExcludeUsersHome.bind(this);
+    this.onExcludeTrash = this.onExcludeTrash.bind(this);
+    this.onPagingNumberChanged = this.onPagingNumberChanged.bind(this);
   }
 
   componentDidMount() {
@@ -47,6 +68,14 @@ class SearchPage extends React.Component {
     return query;
   }
 
+  onExcludeUsersHome() {
+    this.setState({ excludeUsersHome: !this.state.excludeUsersHome });
+  }
+
+  onExcludeTrash() {
+    this.setState({ excludeTrash: !this.state.excludeTrash });
+  }
+
   changeURL(keyword, refreshHash) {
     let hash = window.location.hash || '';
     // TODO 整理する
@@ -58,13 +87,48 @@ class SearchPage extends React.Component {
     }
   }
 
-  search(data) {
+  createSearchQuery(keyword) {
+    let query = keyword;
+
+    // pages included in specific path are not retrived when prefix is added
+    if (this.state.excludeTrash) {
+      query = `${query} -prefix:${specificPathNames.trash}`;
+    }
+    if (this.state.excludeUsersHome) {
+      query = `${query} -prefix:${specificPathNames.user}`;
+    }
+
+    return query;
+  }
+
+  /**
+   * this method is called when user changes paging number
+   */
+  async onPagingNumberChanged(activePage) {
+    // this.setState does not change the state immediately and following calls of this.search outside of this.setState will have old activePage state.
+    // To prevent above, pass this.search as a callback function to make sure this.search will have the latest activePage state.
+    this.setState({ activePage }, () => this.search({ keyword: this.state.searchedKeyword }));
+  }
+
+  /**
+   * this method is called when user searches by pressing Enter or using searchbox
+   */
+  async searchHandler(data) {
+    // this.setState does not change the state immediately and following calls of this.search outside of this.setState will have old activePage state.
+    // To prevent above, pass this.search as a callback function to make sure this.search will have the latest activePage state.
+    this.setState({ activePage: 1 }, () => this.search(data));
+  }
+
+  async search(data) {
     const keyword = data.keyword;
     if (keyword === '') {
       this.setState({
         searchingKeyword: '',
+        searchedKeyword: '',
         searchedPages: [],
         searchResultMeta: {},
+        searchResultCount: 0,
+        activePage: 1,
       });
 
       return true;
@@ -73,37 +137,111 @@ class SearchPage extends React.Component {
     this.setState({
       searchingKeyword: keyword,
     });
-
-    this.props.appContainer.apiGet('/search', { q: keyword })
-      .then((res) => {
-        this.changeURL(keyword);
-
+    const pagingLimit = this.state.pagingLimit;
+    const offset = (this.state.activePage * pagingLimit) - pagingLimit;
+    try {
+      const res = await this.props.appContainer.apiGet('/search', {
+        q: this.createSearchQuery(keyword),
+        limit: pagingLimit,
+        offset,
+      });
+      this.changeURL(keyword);
+      if (res.data.length > 0) {
         this.setState({
           searchedKeyword: keyword,
           searchedPages: res.data,
           searchResultMeta: res.meta,
+          searchResultCount: res.meta.total,
+          focusedPage: res.data[0],
+          // reset active page if keyword changes, otherwise set the current state
+          activePage: this.state.searchedKeyword === keyword ? this.state.activePage : 1,
         });
-      })
-      .catch((err) => {
-        toastError(err);
-      });
+      }
+      else {
+        this.setState({
+          searchedKeyword: keyword,
+          searchedPages: [],
+          searchResultMeta: {},
+          searchResultCount: 0,
+          focusedPage: {},
+          activePage: 1,
+        });
+      }
+    }
+    catch (err) {
+      toastError(err);
+    }
+  }
+
+  selectPage= (pageId) => {
+    const index = this.state.searchedPages.findIndex((page) => {
+      return page._id === pageId;
+    });
+    this.setState({
+      focusedPage: this.state.searchedPages[index],
+    });
+  }
+
+  toggleCheckBox = (page) => {
+    if (this.state.selectedPages.has(page)) {
+      this.state.selectedPages.delete(page);
+    }
+    else {
+      this.state.selectedPages.add(page);
+    }
+  }
+
+  renderSearchResultContent = () => {
+    return (
+      <SearchResultContent
+        appContainer={this.props.appContainer}
+        searchingKeyword={this.state.searchingKeyword}
+        focusedPage={this.state.focusedPage}
+      >
+      </SearchResultContent>
+    );
+  }
+
+  renderSearchResultList = () => {
+    return (
+      <SearchResultList
+        pages={this.state.searchedPages || []}
+        focusedPage={this.state.focusedPage}
+        selectedPages={this.state.selectedPages || []}
+        searchResultCount={this.state.searchResultCount}
+        activePage={this.state.activePage}
+        pagingLimit={this.state.pagingLimit}
+        onClickInvoked={this.selectPage}
+        onChangedInvoked={this.toggleCheckBox}
+        onPagingNumberChanged={this.onPagingNumberChanged}
+      />
+    );
+  }
+
+  renderSearchControl = () => {
+    return (
+      <SearchControl
+        searchingKeyword={this.state.searchingKeyword}
+        appContainer={this.props.appContainer}
+        onSearchInvoked={this.searchHandler}
+        onExcludeUsersHome={this.onExcludeUsersHome}
+        onExcludeTrash={this.onExcludeTrash}
+      >
+      </SearchControl>
+    );
   }
 
   render() {
     return (
       <div>
-        <div className="search-page-input sps sps--abv">
-          <SearchPageForm
-            t={this.props.t}
-            onSearchFormChanged={this.search}
-            keyword={this.state.searchingKeyword}
-          />
-        </div>
-        <SearchResult
-          pages={this.state.searchedPages}
-          searchingKeyword={this.state.searchingKeyword}
+        <SearchPageLayout
+          SearchControl={this.renderSearchControl}
+          SearchResultList={this.renderSearchResultList}
+          SearchResultContent={this.renderSearchResultContent}
           searchResultMeta={this.state.searchResultMeta}
-        />
+          searchingKeyword={this.state.searchedKeyword}
+        >
+        </SearchPageLayout>
       </div>
     );
   }

+ 62 - 0
packages/app/src/components/SearchPage/DeleteSelectedPageGroup.tsx

@@ -0,0 +1,62 @@
+import React, { FC } from 'react';
+import { useTranslation } from 'react-i18next';
+import loggerFactory from '~/utils/logger';
+import { CheckboxType } from '../../interfaces/search';
+
+const logger = loggerFactory('growi:searchResultList');
+
+type Props = {
+  checkboxState: CheckboxType,
+  onClickInvoked?: () => void,
+  onCheckInvoked?: (string:CheckboxType) => void,
+}
+
+const DeleteSelectedPageGroup:FC<Props> = (props:Props) => {
+  const { t } = useTranslation();
+  const {
+    checkboxState, onClickInvoked, onCheckInvoked,
+  } = props;
+
+  const changeCheckboxStateHandler = () => {
+    console.log(`changeCheckboxStateHandler is called. current changebox state is ${checkboxState}`);
+    // Todo: determine next checkboxState from one of the following and tell the parent component
+    // to change the checkboxState by passing onCheckInvoked function the next checkboxState
+    // - NONE_CHECKED
+    // - INDETERMINATE
+    // - ALL_CHECKED
+    // https://estoc.weseek.co.jp/redmine/issues/77525
+    // use CheckboxType by importing from packages/app/src/interfaces/
+    if (onCheckInvoked == null) { logger.error('onCheckInvoked is null') }
+    else { onCheckInvoked(CheckboxType.ALL_CHECKED) } // change this to an appropriate value
+  };
+
+
+  return (
+    <div className="d-flex align-items-center">
+      <input
+        id="check-all-pages"
+        type="checkbox"
+        name="check-all-pages"
+        className="custom-control custom-checkbox ml-1 align-self-center"
+        onChange={changeCheckboxStateHandler}
+        checked={checkboxState === CheckboxType.INDETERMINATE || checkboxState === CheckboxType.ALL_CHECKED}
+      />
+      <button
+        type="button"
+        className="btn text-danger font-weight-light p-0 ml-2"
+        onClick={() => {
+          if (onClickInvoked == null) { logger.error('onClickInvoked is null') }
+          else { onClickInvoked() }
+        }}
+      >
+        <i className="icon-trash"></i>
+        {t('search_result.delete_all_selected_page')}
+      </button>
+    </div>
+  );
+
+};
+
+DeleteSelectedPageGroup.propTypes = {
+};
+export default DeleteSelectedPageGroup;

+ 42 - 0
packages/app/src/components/SearchPage/IncludeSpecificPathButton.jsx

@@ -0,0 +1,42 @@
+import React, { useState } from 'react';
+import PropTypes from 'prop-types';
+import { useTranslation } from 'react-i18next';
+
+const IncludeSpecificPathButton = (props) => {
+  const { pathToInclude, checked } = props;
+  const { t } = useTranslation();
+
+  // TODO : implement this function
+  // 77526 story https://estoc.weseek.co.jp/redmine/issues/77526
+  // 77535 stroy https://estoc.weseek.co.jp/redmine/issues/77535
+  function includeSpecificPathInSearchResult(pathToInclude) {
+    console.log(`now including ${pathToInclude} in search result`);
+  }
+  return (
+    <div className="border px-2 btn btn-outline-secondary">
+      <label className="mb-0">
+        <span className="font-weight-light">
+          {pathToInclude === '/user'
+            ? t('search_result.include_certain_path', { pathToInclude: '/user' }) : t('search_result.include_certain_path', { pathToInclude: '/trash' })}
+        </span>
+        <input
+          type="checkbox"
+          name="check-include-specific-path"
+          onChange={() => {
+            if (checked) {
+              includeSpecificPathInSearchResult(pathToInclude);
+            }
+          }}
+        />
+      </label>
+    </div>
+  );
+
+};
+
+IncludeSpecificPathButton.propTypes = {
+  pathToInclude: PropTypes.string.isRequired,
+  checked: PropTypes.bool.isRequired,
+};
+
+export default IncludeSpecificPathButton;

+ 104 - 0
packages/app/src/components/SearchPage/SearchControl.tsx

@@ -0,0 +1,104 @@
+import React, { FC } from 'react';
+import { useTranslation } from 'react-i18next';
+import SearchPageForm from './SearchPageForm';
+import AppContainer from '../../client/services/AppContainer';
+import DeleteSelectedPageGroup from './DeleteSelectedPageGroup';
+import { CheckboxType } from '../../interfaces/search';
+
+type Props = {
+  searchingKeyword: string,
+  appContainer: AppContainer,
+  onSearchInvoked: (data : any[]) => boolean,
+  onExcludeUsersHome?: () => void,
+  onExcludeTrash?: () => void,
+}
+
+const SearchControl: FC <Props> = (props: Props) => {
+  // Temporaly workaround for lint error
+  // later needs to be fixed: SearchControl to typescript componet
+  const SearchPageFormTypeAny : any = SearchPageForm;
+  const { t } = useTranslation('');
+
+  const onExcludeUsersHome = () => {
+    if (props.onExcludeUsersHome != null) {
+      props.onExcludeUsersHome();
+    }
+  };
+
+  const onExcludeTrash = () => {
+    if (props.onExcludeTrash != null) {
+      props.onExcludeTrash();
+    }
+  };
+
+  const onDeleteSelectedPageHandler = () => {
+    console.log('onDeleteSelectedPageHandler is called');
+    // TODO: implement this function to delete selected pages.
+    // https://estoc.weseek.co.jp/redmine/issues/77525
+  };
+
+  const onCheckAllPagesInvoked = (nextCheckboxState:CheckboxType) => {
+    console.log(`onCheckAllPagesInvoked is called with arg ${nextCheckboxState}`);
+    // Todo: set the checkboxState, isChecked, and indeterminate value of checkbox element according to the passed argument
+    // https://estoc.weseek.co.jp/redmine/issues/77525
+
+    // setting checkbox to indeterminate is required to use of useRef to access checkbox element.
+    // ref: https://getbootstrap.com/docs/4.5/components/forms/#checkboxes
+  };
+
+  return (
+    <>
+      <div className="search-page-nav d-flex py-3 align-items-center">
+        <div className="flex-grow-1 mx-4">
+          <SearchPageFormTypeAny
+            keyword={props.searchingKeyword}
+            appContainer={props.appContainer}
+            onSearchFormChanged={props.onSearchInvoked}
+          />
+        </div>
+        <div className="mr-4">
+          {/* TODO: replace the following button */}
+          <button type="button">related pages</button>
+        </div>
+      </div>
+      {/* TODO: replace the following elements deleteAll button , relevance button and include specificPath button component */}
+      <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
+            checkboxState={'' || CheckboxType.NONE_CHECKED} // Todo: change the left value to appropriate value
+            onClickInvoked={onDeleteSelectedPageHandler}
+            onCheckInvoked={onCheckAllPagesInvoked}
+          />
+        </div>
+        <div className="d-flex align-items-center mr-3">
+          <div className="border border-gray mr-3">
+            <label className="px-3 py-2 mb-0 d-flex align-items-center" htmlFor="flexCheckDefault">
+              <input
+                className="mr-2"
+                type="checkbox"
+                id="flexCheckDefault"
+                onClick={() => onExcludeUsersHome()}
+              />
+              {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" htmlFor="flexCheckChecked">
+              <input
+                className="mr-2"
+                type="checkbox"
+                id="flexCheckChecked"
+                onClick={() => onExcludeTrash()}
+              />
+              {t('Include Subordinated Target Page', { target: '/trash' })}
+            </label>
+          </div>
+        </div>
+      </div>
+    </>
+  );
+};
+
+
+export default SearchControl;

+ 31 - 13
packages/app/src/components/SearchPage/SearchPageForm.jsx

@@ -4,6 +4,9 @@ import PropTypes from 'prop-types';
 import { withUnstatedContainers } from '../UnstatedUtils';
 import AppContainer from '~/client/services/AppContainer';
 import SearchForm from '../SearchForm';
+import loggerFactory from '~/utils/logger';
+
+const logger = loggerFactory('growi:searchPageForm');
 
 // Search.SearchForm
 class SearchPageForm extends React.Component {
@@ -21,9 +24,14 @@ class SearchPageForm extends React.Component {
   }
 
   search() {
-    const keyword = this.state.keyword;
-    this.props.onSearchFormChanged({ keyword });
-    this.setState({ searchedKeyword: keyword });
+    if (this.props.onSearchFormChanged != null) {
+      const keyword = this.state.keyword;
+      this.props.onSearchFormChanged({ keyword });
+      this.setState({ searchedKeyword: keyword });
+    }
+    else {
+      throw new Error('onSearchFormChanged method is null');
+    }
   }
 
   onInputChange(input) { // for only submitting with button
@@ -32,19 +40,30 @@ class SearchPageForm extends React.Component {
 
   render() {
     return (
-      <div className="input-group mb-3 d-flex">
-        <div className="flex-fill">
+      // TODO: modify design after other component is created
+      <div className="grw-search-form-in-search-result-page d-flex align-items-center">
+        <div className="input-group flex-nowrap">
           <SearchForm
-            t={this.props.t}
             onSubmit={this.search}
             keyword={this.state.searchedKeyword}
             onInputChange={this.onInputChange}
           />
-        </div>
-        <div className="input-group-append">
-          <button className="btn btn-secondary" type="button" id="button-addon2" onClick={this.search}>
-            <i className="icon-magnifier"></i>
-          </button>
+          <div className="btn-group-submit-search">
+            <button
+              className="btn border-0 pb-1"
+              type="button"
+              onClick={() => {
+                try {
+                  this.search();
+                }
+                catch (error) {
+                  logger.error(error);
+                }
+              }}
+            >
+              <i className="pr-2 icon-magnifier"></i>
+            </button>
+          </div>
         </div>
       </div>
     );
@@ -58,11 +77,10 @@ class SearchPageForm extends React.Component {
 const SearchPageFormWrapper = withUnstatedContainers(SearchPageForm, [AppContainer]);
 
 SearchPageForm.propTypes = {
-  t: PropTypes.func.isRequired, // i18next
   appContainer: PropTypes.instanceOf(AppContainer).isRequired,
 
   keyword: PropTypes.string,
-  onSearchFormChanged: PropTypes.func.isRequired,
+  onSearchFormChanged: PropTypes.func,
 };
 SearchPageForm.defaultProps = {
 };

+ 52 - 0
packages/app/src/components/SearchPage/SearchPageLayout.tsx

@@ -0,0 +1,52 @@
+import React, { FC } from 'react';
+import { useTranslation } from 'react-i18next';
+
+type SearchResultMeta = {
+  took : number,
+  total : number,
+  results: number
+}
+
+type Props = {
+  SearchControl: React.FunctionComponent,
+  SearchResultList: React.FunctionComponent,
+  SearchResultContent: React.FunctionComponent,
+  searchResultMeta: SearchResultMeta,
+  searchingKeyword: string
+}
+
+const SearchPageLayout: FC<Props> = (props: Props) => {
+  const { t } = useTranslation('');
+  const {
+    SearchResultList, SearchControl, SearchResultContent, searchResultMeta, searchingKeyword,
+  } = props;
+
+  return (
+    <div className="content-main">
+      <div className="search-result row" id="search-result">
+        <div className="col-lg-6  page-list border boder-gray search-result-list px-0" id="search-result-list">
+
+          <nav><SearchControl></SearchControl></nav>
+          <div className="d-flex align-items-start justify-content-between mt-1">
+            <div className="search-result-meta">
+              <span className="font-weight-light">{t('search_result.result_meta')} </span>
+              <span className="h5">{`"${searchingKeyword}"`}</span>
+              {/* Todo: replace "1-10" to the appropriate value */}
+              <span className="ml-3">1-10 / {searchResultMeta.total || 0}</span>
+            </div>
+          </div>
+
+          <div className="page-list">
+            <ul className="page-list-ul page-list-ul-flat nav nav-pills"><SearchResultList></SearchResultList></ul>
+          </div>
+        </div>
+        <div className="col-lg-6 d-none d-lg-block search-result-content">
+          <SearchResultContent></SearchResultContent>
+        </div>
+      </div>
+    </div>
+  );
+};
+
+
+export default SearchPageLayout;

+ 0 - 350
packages/app/src/components/SearchPage/SearchResult.jsx

@@ -1,350 +0,0 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-import * as toastr from 'toastr';
-
-import { withTranslation } from 'react-i18next';
-
-import Page from '../PageList/Page';
-import SearchResultList from './SearchResultList';
-import DeletePageListModal from './DeletePageListModal';
-import AppContainer from '~/client/services/AppContainer';
-import { withUnstatedContainers } from '../UnstatedUtils';
-
-class SearchResult extends React.Component {
-
-  constructor(props) {
-    super(props);
-    this.state = {
-      deletionMode: false,
-      selectedPages: new Set(),
-      isDeleteCompletely: undefined,
-      isDeleteConfirmModalShown: false,
-      errorMessageForDeleting: undefined,
-    };
-    this.toggleDeleteCompletely = this.toggleDeleteCompletely.bind(this);
-    this.deleteSelectedPages = this.deleteSelectedPages.bind(this);
-    this.closeDeleteConfirmModal = this.closeDeleteConfirmModal.bind(this);
-  }
-
-  isNotSearchedYet() {
-    return !this.props.searchResultMeta.took;
-  }
-
-  isNotFound() {
-    return this.props.searchingKeyword !== '' && this.props.pages.length === 0;
-  }
-
-  isError() {
-    if (this.props.searchError !== null) {
-      return true;
-    }
-    return false;
-  }
-
-  /**
-   * move the page
-   */
-  visitPageButtonHandler(e) {
-    window.location.href = e.currentTarget.value;
-  }
-
-  /**
-   * toggle checkbox and add (or delete from) selected pages list
-   *
-   * @param {any} page
-   * @memberof SearchResult
-   */
-  toggleCheckbox(page) {
-    if (this.state.selectedPages.has(page)) {
-      this.state.selectedPages.delete(page);
-    }
-    else {
-      this.state.selectedPages.add(page);
-    }
-    this.setState({ isDeleteConfirmModalShown: false });
-    this.setState({ selectedPages: this.state.selectedPages });
-  }
-
-  /**
-   * check and return is all pages selected for delete?
-   *
-   * @returns all pages selected (or not)
-   * @memberof SearchResult
-   */
-  isAllSelected() {
-    return this.state.selectedPages.size === this.props.pages.length;
-  }
-
-  /**
-   * handle checkbox clicking that all pages select for delete
-   *
-   * @memberof SearchResult
-   */
-  handleAllSelect() {
-    if (this.isAllSelected()) {
-      this.state.selectedPages.clear();
-    }
-    else {
-      this.state.selectedPages.clear();
-      this.props.pages.map((page) => {
-        this.state.selectedPages.add(page);
-        return;
-      });
-    }
-    this.setState({ selectedPages: this.state.selectedPages });
-  }
-
-  /**
-   * change deletion mode
-   *
-   * @memberof SearchResult
-   */
-  handleDeletionModeChange() {
-    this.state.selectedPages.clear();
-    this.setState({ deletionMode: !this.state.deletionMode });
-  }
-
-  /**
-   * toggle check delete completely
-   *
-   * @memberof SearchResult
-   */
-  toggleDeleteCompletely() {
-    // request で completely が undefined でないと指定アリと見なされるため
-    this.setState({ isDeleteCompletely: this.state.isDeleteCompletely ? undefined : true });
-  }
-
-  /**
-   * delete selected pages
-   *
-   * @memberof SearchResult
-   */
-  deleteSelectedPages() {
-    const deleteCompletely = this.state.isDeleteCompletely;
-    Promise.all(Array.from(this.state.selectedPages).map((page) => {
-      return new Promise((resolve, reject) => {
-        const pageId = page._id;
-        const revisionId = page.revision._id;
-
-        this.props.appContainer.apiPost('/pages.remove', { page_id: pageId, revision_id: revisionId, completely: deleteCompletely })
-          .then((res) => {
-            if (res.ok) {
-              this.state.selectedPages.delete(page);
-              return resolve();
-            }
-
-            return reject();
-
-          })
-          .catch((err) => {
-            console.log(err.message); // eslint-disable-line no-console
-            this.setState({ errorMessageForDeleting: err.message });
-            return reject();
-          });
-      });
-    }))
-      .then(() => {
-        window.location.reload();
-      })
-      .catch((err) => {
-        toastr.error(err, 'Error occured', {
-          closeButton: true,
-          progressBar: true,
-          newestOnTop: false,
-          showDuration: '100',
-          hideDuration: '100',
-          timeOut: '3000',
-        });
-      });
-  }
-
-  /**
-   * open confirm modal for page selection delete
-   *
-   * @memberof SearchResult
-   */
-  showDeleteConfirmModal() {
-    this.setState({ isDeleteConfirmModalShown: true });
-  }
-
-  /**
-   * close confirm modal for page selection delete
-   *
-   * @memberof SearchResult
-   */
-  closeDeleteConfirmModal() {
-    this.setState({
-      isDeleteConfirmModalShown: false,
-      errorMessageForDeleting: undefined,
-    });
-  }
-
-  renderListView(pages) {
-    return pages.map((page) => {
-      // 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 = `#id_${page._id}`;
-      return (
-        <li key={page._id} className="nav-item page-list-li w-100 m-1">
-          <a className="nav-link page-list-link d-flex align-items-baseline" href={pageId}>
-            <Page page={page} noLink />
-            <div className="ml-auto d-flex">
-              { this.state.deletionMode
-                && (
-                  <div className="custom-control custom-checkbox custom-checkbox-danger">
-                    <input
-                      type="checkbox"
-                      id={`page-delete-check-${page._id}`}
-                      className="custom-control-input search-result-list-delete-checkbox"
-                      value={pageId}
-                      checked={this.state.selectedPages.has(page)}
-                      onChange={() => { return this.toggleCheckbox(page) }}
-                    />
-                    <label className="custom-control-label" htmlFor={`page-delete-check-${page._id}`}></label>
-                  </div>
-                )
-              }
-              <div className="page-list-option">
-                <button type="button" className="btn btn-link p-0" value={page.path} onClick={this.visitPageButtonHandler}><i className="icon-login" /></button>
-              </div>
-            </div>
-          </a>
-        </li>
-      );
-    });
-  }
-
-  render() {
-    const { t } = this.props;
-
-    if (this.isError()) {
-      return (
-        <div className="content-main">
-          <i className="searcing fa fa-warning"></i> Error on searching.
-        </div>
-      );
-    }
-
-    if (this.isNotSearchedYet()) {
-      return <div />;
-    }
-
-    if (this.isNotFound()) {
-      let under = '';
-      if (this.props.tree != null) {
-        under = ` under "${this.props.tree}"`;
-      }
-      return (
-        <div className="content-main">
-          <i className="icon-fw icon-info" /> No page found with &quot;{this.props.searchingKeyword}&quot;{under}
-        </div>
-      );
-
-    }
-
-    let deletionModeButtons = '';
-    let allSelectCheck = '';
-
-    if (this.state.deletionMode) {
-      deletionModeButtons = (
-        <div className="btn-group">
-          <button type="button" className="btn btn-outline-secondary btn-sm rounded-pill-weak" onClick={() => { return this.handleDeletionModeChange() }}>
-            <i className="icon-ban" /> {t('search_result.cancel')}
-          </button>
-          <button
-            type="button"
-            className="btn btn-danger btn-sm rounded-pill-weak"
-            onClick={() => { return this.showDeleteConfirmModal() }}
-            disabled={this.state.selectedPages.size === 0}
-          >
-            <i className="icon-trash" /> {t('search_result.delete')}
-          </button>
-        </div>
-      );
-      allSelectCheck = (
-        <div className="custom-control custom-checkbox custom-checkbox-danger">
-          <input
-            id="all-select-check"
-            className="custom-control-input"
-            type="checkbox"
-            onChange={() => { return this.handleAllSelect() }}
-            checked={this.isAllSelected()}
-          />
-          <label className="custom-control-label" htmlFor="all-select-check">&nbsp;{t('search_result.check_all')}</label>
-        </div>
-      );
-    }
-    else {
-      deletionModeButtons = (
-        <div className="btn-group">
-          <button type="button" className="btn btn-outline-secondary rounded-pill btn-sm" onClick={() => { return this.handleDeletionModeChange() }}>
-            <i className="ti-check-box" /> {t('search_result.deletion_mode_btn_lavel')}
-          </button>
-        </div>
-      );
-    }
-
-    const listView = this.renderListView(this.props.pages);
-
-    /*
-    UI あとで考える
-    <span className="search-result-meta">Found: {this.props.searchResultMeta.total} pages with "{this.props.searchingKeyword}"</span>
-    */
-    return (
-      <div className="content-main">
-        <div className="search-result row" id="search-result">
-          <div className="col-lg-4 d-none d-lg-block page-list search-result-list pr-0" id="search-result-list">
-            <nav>
-              <div className="d-flex align-items-start justify-content-between mt-1">
-                <div className="search-result-meta">
-                  <i className="icon-magnifier" /> Found {this.props.searchResultMeta.total} pages with &quot;{this.props.searchingKeyword}&quot;
-                </div>
-                <div className="text-nowrap">
-                  {deletionModeButtons}
-                  {allSelectCheck}
-                </div>
-              </div>
-
-              <div className="page-list">
-                <ul className="page-list-ul page-list-ul-flat nav nav-pills">{listView}</ul>
-              </div>
-            </nav>
-          </div>
-          <div className="col-lg-8 search-result-content" id="search-result-content">
-            <SearchResultList pages={this.props.pages} searchingKeyword={this.props.searchingKeyword} />
-          </div>
-        </div>
-        <DeletePageListModal
-          isShown={this.state.isDeleteConfirmModalShown}
-          pages={Array.from(this.state.selectedPages)}
-          errorMessage={this.state.errorMessageForDeleting}
-          cancel={this.closeDeleteConfirmModal}
-          confirmedToDelete={this.deleteSelectedPages}
-          isDeleteCompletely={this.state.isDeleteCompletely}
-          toggleDeleteCompletely={this.toggleDeleteCompletely}
-        />
-      </div> // content-main
-    );
-  }
-
-}
-
-/**
- * Wrapper component for using unstated
- */
-const SearchResultWrapper = withUnstatedContainers(SearchResult, [AppContainer]);
-
-SearchResult.propTypes = {
-  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
-  t: PropTypes.func.isRequired, // i18next
-
-  pages: PropTypes.array.isRequired,
-  searchingKeyword: PropTypes.string.isRequired,
-  searchResultMeta: PropTypes.object.isRequired,
-  searchError: PropTypes.object,
-  tree: PropTypes.string,
-};
-SearchResult.defaultProps = {
-  searchError: null,
-};
-
-export default withTranslation()(SearchResultWrapper);

+ 50 - 0
packages/app/src/components/SearchPage/SearchResultContent.tsx

@@ -0,0 +1,50 @@
+import React, { FC } from 'react';
+
+import RevisionLoader from '../Page/RevisionLoader';
+import AppContainer from '../../client/services/AppContainer';
+
+
+type Props ={
+  appContainer: AppContainer,
+  searchingKeyword:string,
+  focusedPage : any,
+}
+const SearchResultContent: FC<Props> = (props: Props) => {
+  // Temporaly workaround for lint error
+  // later needs to be fixed: RevisoinRender to typescriptcomponet
+  const RevisionRenderTypeAny: any = RevisionLoader;
+  const renderPage = (page) => {
+    const growiRenderer = props.appContainer.getRenderer('searchresult');
+    let showTags = false;
+    if (page.tags != null && page.tags.length > 0) { showTags = true }
+    return (
+      <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>
+        <RevisionRenderTypeAny
+          growiRenderer={growiRenderer}
+          pageId={page._id}
+          pagePath={page.path}
+          revisionId={page.revision}
+          highlightKeywords={props.searchingKeyword}
+        />
+      </div>
+    );
+  };
+  const content = renderPage(props.focusedPage);
+  return (
+
+    <div>{content}</div>
+  );
+};
+
+
+export default SearchResultContent;

+ 0 - 64
packages/app/src/components/SearchPage/SearchResultList.jsx

@@ -1,64 +0,0 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-
-import RevisionLoader from '../Page/RevisionLoader';
-import AppContainer from '~/client/services/AppContainer';
-import { withUnstatedContainers } from '../UnstatedUtils';
-
-class SearchResultList extends React.Component {
-
-  constructor(props) {
-    super(props);
-
-    this.growiRenderer = this.props.appContainer.getRenderer('searchresult');
-  }
-
-  render() {
-    const resultList = this.props.pages.map((page) => {
-      const showTags = (page.tags != null) && (page.tags.length > 0);
-
-      return (
-        // Add prefix 'id_' in id attr, because scrollspy of bootstrap doesn't work when the first letter of id of target component is numeral.
-        <div id={`id_${page._id}`} 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>
-          <RevisionLoader
-            growiRenderer={this.growiRenderer}
-            pageId={page._id}
-            pagePath={page.path}
-            revisionId={page.revision}
-            highlightKeywords={this.props.searchingKeyword}
-          />
-        </div>
-      );
-    });
-
-    return (
-      <div>
-        {resultList}
-      </div>
-    );
-  }
-
-}
-
-/**
- * Wrapper component for using unstated
- */
-const SearchResultListWrapper = withUnstatedContainers(SearchResultList, [AppContainer]);
-
-SearchResultList.propTypes = {
-  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
-
-  pages: PropTypes.array.isRequired,
-  searchingKeyword: PropTypes.string.isRequired,
-};
-
-SearchResultList.defaultProps = {
-};
-
-export default SearchResultListWrapper;

+ 49 - 0
packages/app/src/components/SearchPage/SearchResultList.tsx

@@ -0,0 +1,49 @@
+import React, { FC } from 'react';
+import SearchResultListItem from './SearchResultListItem';
+import PaginationWrapper from '../PaginationWrapper';
+import { IPageSearchResultData } from '../../interfaces/search';
+
+
+type Props = {
+  pages: IPageSearchResultData[],
+  selectedPages: IPageSearchResultData[],
+  onClickInvoked?: (pageId: string) => void,
+  searchResultCount?: number,
+  activePage?: number,
+  pagingLimit?: number,
+  onPagingNumberChanged?: (activePage: number) => void,
+  focusedPage?: IPageSearchResultData,
+}
+
+const SearchResultList: FC<Props> = (props:Props) => {
+  const { focusedPage } = props;
+  const focusedPageId = (focusedPage !== undefined && focusedPage.pageData !== undefined) ? focusedPage.pageData._id : '';
+  return (
+    <>
+      {props.pages.map((page) => {
+        return (
+          <SearchResultListItem
+            key={page.pageData._id}
+            page={page}
+            onClickInvoked={props.onClickInvoked}
+            isSelected={page.pageData._id === focusedPageId || false}
+          />
+        );
+      })}
+      {props.searchResultCount != null && props.searchResultCount > 0 && (
+        <div className="my-4 mx-auto">
+          <PaginationWrapper
+            activePage={props.activePage || 1}
+            changePage={props.onPagingNumberChanged}
+            totalItemsCount={props.searchResultCount || 0}
+            pagingLimit={props.pagingLimit}
+          />
+        </div>
+      )}
+
+    </>
+  );
+
+};
+
+export default SearchResultList;

+ 140 - 0
packages/app/src/components/SearchPage/SearchResultListItem.tsx

@@ -0,0 +1,140 @@
+import React, { FC } from 'react';
+
+import Clamp from 'react-multiline-clamp';
+
+import { useTranslation } from 'react-i18next';
+import { UserPicture, PageListMeta, PagePathLabel } from '@growi/ui';
+import { DevidedPagePath } from '@growi/core';
+import { IPageSearchResultData } from '../../interfaces/search';
+
+
+import loggerFactory from '~/utils/logger';
+import { IPageHasId } from '~/interfaces/page';
+
+const logger = loggerFactory('growi:searchResultList');
+
+type PageItemControlProps = {
+  page: IPageHasId,
+}
+
+const PageItemControl: FC<PageItemControlProps> = (props: {page: IPageHasId}) => {
+
+  const { page } = props;
+  const { t } = useTranslation('');
+
+  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="fa fa-ellipsis-v text-muted"></i>
+      </button>
+      <div className="dropdown-menu dropdown-menu-right">
+
+        {/* TODO: if there is the following button in XD add it here
+        <button
+          type="button"
+          className="btn btn-link p-0"
+          value={page.path}
+          onClick={(e) => {
+            window.location.href = e.currentTarget.value;
+          }}
+        >
+          <i className="icon-login" />
+        </button>
+        */}
+
+        {/*
+          TODO: add function to the following buttons like using modal or others
+          ref: https://estoc.weseek.co.jp/redmine/issues/79026
+        */}
+        <button className="dropdown-item text-danger" type="button" onClick={() => console.log('delete modal show')}>
+          <i className="icon-fw icon-fire"></i>{t('Delete')}
+        </button>
+        <button className="dropdown-item" type="button" onClick={() => console.log('duplicate modal show')}>
+          <i className="icon-fw icon-star"></i>{t('Add to bookmark')}
+        </button>
+        <button className="dropdown-item" type="button" onClick={() => console.log('duplicate modal show')}>
+          <i className="icon-fw icon-docs"></i>{t('Duplicate')}
+        </button>
+        <button className="dropdown-item" type="button" onClick={() => console.log('rename function will be added')}>
+          <i className="icon-fw  icon-action-redo"></i>{t('Move/Rename')}
+        </button>
+      </div>
+    </>
+  );
+
+};
+
+type Props = {
+  page: IPageSearchResultData,
+  isSelected: boolean,
+  onClickInvoked?: (pageId: string) => void,
+}
+
+const SearchResultListItem: FC<Props> = (props:Props) => {
+  const { page: { pageData, pageMeta }, isSelected } = 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.
+  const pageId = `#${pageData._id}`;
+
+  const dPagePath = new DevidedPagePath(pageData.path, false, true);
+  const pagePathElem = <PagePathLabel page={pageData} isFormerOnly />;
+
+  const onClickInvoked = (pageId) => {
+    if (props.onClickInvoked != null) {
+      props.onClickInvoked(pageId);
+    }
+  };
+
+  return (
+    <li key={pageData._id} className={`page-list-li search-page-item w-100 border-bottom px-4 list-group-item-action ${isSelected ? 'active' : ''}`}>
+      <a
+        className="d-block pt-3"
+        href={pageId}
+        onClick={() => onClickInvoked(pageData._id)}
+      >
+        <div className="d-flex">
+          {/* checkbox */}
+          <div className="form-check my-auto mr-3">
+            <input className="form-check-input my-auto" type="checkbox" value="" id="flexCheckDefault" />
+          </div>
+          <div className="w-100">
+            {/* page path */}
+            <small className="mb-1">
+              <i className="icon-fw icon-home"></i>
+              {pagePathElem}
+            </small>
+            <div className="d-flex my-1 align-items-center">
+              {/* page title */}
+              <h3 className="mb-0">
+                <UserPicture user={pageData.lastUpdateUser} />
+                <span className="mx-2">{dPagePath.latter}</span>
+              </h3>
+              {/* page meta */}
+              <div className="d-flex mx-2">
+                <PageListMeta page={pageData} bookmarkCount={pageMeta.bookmarkCount} />
+              </div>
+              {/* doropdown icon includes page control buttons */}
+              <div className="ml-auto">
+                <PageItemControl page={pageData} />
+              </div>
+            </div>
+            <div className="my-2">
+              <Clamp
+                lines={2}
+              >
+                {pageMeta.elasticSearchResult && <div className="mt-1" dangerouslySetInnerHTML={{ __html: pageMeta.elasticSearchResult.snippet }}></div>}
+              </Clamp>
+            </div>
+          </div>
+        </div>
+        {/* TODO: adjust snippet position */}
+      </a>
+    </li>
+  );
+};
+
+export default SearchResultListItem;

+ 4 - 3
packages/app/src/components/Sidebar.tsx

@@ -2,6 +2,7 @@ import React, {
   FC, useCallback, useEffect, useRef, useState,
 } from 'react';
 
+import { scheduleToPutUserUISettings } from '~/client/services/user-ui-settings';
 import {
   useDrawerMode, useDrawerOpened,
   useSidebarCollapsed,
@@ -14,8 +15,8 @@ import DrawerToggler from './Navbar/DrawerToggler';
 
 import SidebarNav from './Sidebar/SidebarNav';
 import SidebarContents from './Sidebar/SidebarContents';
-import { scheduleToPutUserUISettings } from '~/services/user-ui-settings';
 import { NavigationResizeHexagon } from './Sidebar/NavigationResizeHexagon';
+import StickyStretchableScroller from './StickyStretchableScroller';
 
 const sidebarMinWidth = 240;
 const sidebarMinimizeWidth = 20;
@@ -67,12 +68,12 @@ const SidebarContentsWrapper = () => {
 
   return (
     <>
-      {/* <StickyStretchableScroller
+      <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">

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

@@ -6,7 +6,7 @@ const PrivateLegacyPages: FC = memo(() => {
 
   return (
     <div className="grw-prvt-legacy-pages p-3">
-      <a href="#" className="h5">
+      <a href="/private-legacy-pages?q=[nq:PrivateLegacyPages]" className="h5">
         <i className="icon-drawer mr-2"></i> PrivateLegacyPages
       </a>
     </div>

+ 1 - 1
packages/app/src/components/Sidebar/SidebarNav.tsx

@@ -1,7 +1,7 @@
 import React, { FC, memo, useCallback } from 'react';
 
+import { scheduleToPutUserUISettings } from '~/client/services/user-ui-settings';
 import { SidebarContentsType } from '~/interfaces/ui';
-import { scheduleToPutUserUISettings } from '~/services/user-ui-settings';
 import { useCurrentUser, useIsSharedUser } from '~/stores/context';
 import { useCurrentSidebarContents } from '~/stores/ui';
 

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

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

+ 5 - 1
packages/app/src/interfaces/page.ts

@@ -14,7 +14,7 @@ export type IPage = {
   createdAt: Date,
   updatedAt: Date,
   seenUsers: Ref<IUser>[],
-  parent: Ref<IPage>,
+  parent: Ref<IPage> | null,
   isEmpty: boolean,
   redirectTo: string,
   grant: number,
@@ -32,3 +32,7 @@ export type IPage = {
 }
 
 export type IPageForItem = Partial<IPage & {isTarget?: boolean} & HasObjectId>;
+
+export type IPageHasId = IPage & {
+  _id: string,
+};

+ 18 - 0
packages/app/src/interfaces/search.ts

@@ -0,0 +1,18 @@
+import { IPageHasId } from './page';
+
+export enum CheckboxType {
+  NONE_CHECKED = 'noneChecked',
+  INDETERMINATE = 'indeterminate',
+  ALL_CHECKED = 'allChecked',
+}
+
+export type IPageSearchResultData = {
+  pageData: IPageHasId,
+  pageMeta: {
+    bookmarkCount: number,
+    elasticSearchResult: {
+      snippet: string,
+      matchedPath: string,
+    },
+  },
+}

+ 2 - 1
packages/app/src/interfaces/user-ui-settings.ts

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

+ 47 - 0
packages/app/src/migrations/20211129125654-initialize-private-legacy-pages-named-query.js

@@ -0,0 +1,47 @@
+import mongoose from 'mongoose';
+
+import { getModelSafely, getMongoUri, mongoOptions } from '@growi/core';
+import NamedQuery from '~/server/models/named-query';
+import { SearchDelegatorName } from '~/interfaces/named-query';
+import loggerFactory from '~/utils/logger';
+
+
+const logger = loggerFactory('growi:migrate:initialize-private-legacy-pages-named-query');
+
+module.exports = {
+  async up(db, next) {
+    mongoose.connect(getMongoUri(), mongoOptions);
+
+    try {
+      await NamedQuery.insertMany({
+        name: SearchDelegatorName.PRIVATE_LEGACY_PAGES,
+        delegatorName: SearchDelegatorName.PRIVATE_LEGACY_PAGES,
+      });
+    }
+    catch (err) {
+      logger.error('Failed to migrate named query for private legacy pages search delagator.', err);
+      throw err;
+    }
+
+    next();
+    logger.info('Successfully migrated named query for private legacy pages search delagator.');
+  },
+
+  async down(db, next) {
+    mongoose.connect(getMongoUri(), mongoOptions);
+
+    try {
+      await NamedQuery.findOneAndDelete({
+        name: SearchDelegatorName.PRIVATE_LEGACY_PAGES,
+        delegatorName: SearchDelegatorName.PRIVATE_LEGACY_PAGES,
+      });
+    }
+    catch (err) {
+      logger.error('Failed to delete named query for private legacy pages search delagator.', err);
+      throw err;
+    }
+
+    next();
+    logger.info('Successfully deleted named query for private legacy pages search delagator.');
+  },
+};

+ 12 - 0
packages/app/src/server/crowi/express-init.js

@@ -99,6 +99,18 @@ module.exports = function(crowi, app) {
   app.set('view engine', 'html');
   app.set('views', crowi.viewsDir);
   app.use(methodOverride());
+
+  // inject rawBody to req
+  app.use((req, res, next) => {
+    if (!req.is('multipart/form-data')) {
+      req.rawBody = '';
+      req.on('data', (chunk) => {
+        req.rawBody += chunk;
+      });
+    }
+
+    next();
+  });
   app.use(bodyParser.urlencoded({ extended: true, limit: '50mb' }));
   app.use(bodyParser.json({ limit: '50mb' }));
   app.use(cookieParser());

+ 2 - 1
packages/app/src/server/crowi/index.js

@@ -18,6 +18,7 @@ import { projectRoot } from '~/utils/project-dir-utils';
 import ConfigManager from '../service/config-manager';
 import AppService from '../service/app';
 import AclService from '../service/acl';
+import SearchService from '../service/search';
 import AttachmentService from '../service/attachment';
 import { SlackIntegrationService } from '../service/slack-integration';
 import { UserNotificationService } from '../service/user-notification';
@@ -81,6 +82,7 @@ function Crowi() {
     user: new (require('../events/user'))(this),
     page: new (require('../events/page'))(this),
     bookmark: new (require('../events/bookmark'))(this),
+    comment: new (require('../events/comment'))(this),
     tag: new (require('../events/tag'))(this),
     admin: new (require('../events/admin'))(this),
   };
@@ -370,7 +372,6 @@ Crowi.prototype.setupPassport = async function() {
 };
 
 Crowi.prototype.setupSearcher = async function() {
-  const SearchService = require('~/server/service/search');
   this.searchService = new SearchService(this);
 };
 

+ 17 - 0
packages/app/src/server/events/comment.ts

@@ -0,0 +1,17 @@
+
+import util from 'util';
+
+const events = require('events');
+
+function CommentEvent(crowi) {
+  this.crowi = crowi;
+
+  events.EventEmitter.call(this);
+}
+util.inherits(CommentEvent, events.EventEmitter);
+
+CommentEvent.prototype.onCreate = function(comment) {};
+CommentEvent.prototype.onUpdate = function(comment) {};
+CommentEvent.prototype.onDelete = function(comment) {};
+
+module.exports = CommentEvent;

+ 3 - 1
packages/app/src/server/events/page.js

@@ -18,5 +18,7 @@ PageEvent.prototype.onUpdate = function(page, user) {
 PageEvent.prototype.onCreateMany = function(pages, user) {
   debug('onCreateMany event fired');
 };
-
+PageEvent.prototype.onAddSeenUsers = function(pages, user) {
+  debug('onAddSeenUsers event fired');
+};
 module.exports = PageEvent;

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

@@ -33,7 +33,7 @@ export type Result<T> = {
 }
 
 export type MetaData = {
-  meta: { [key:string]: any }
+  meta?: { [key:string]: any }
 }
 
 export type SearchableData = {

+ 1 - 0
packages/app/src/server/interfaces/slack-integration/events.ts

@@ -0,0 +1 @@
+export type EventActionsPermission = Map<string, boolean | string[]>

+ 32 - 0
packages/app/src/server/interfaces/slack-integration/link-shared-unfurl.ts

@@ -0,0 +1,32 @@
+export type PrivateData = {
+  isPublic: false,
+  isPermalink: boolean,
+  id: string,
+  path: string,
+}
+
+export type PublicData = {
+  isPublic: true,
+  isPermalink: boolean,
+  id: string,
+  path: string,
+  pageBody: string,
+  updatedAt: Date,
+  commentCount: number,
+}
+
+export type DataForUnfurl = PrivateData | PublicData;
+
+export type UnfurlEventLink = {
+  url: string,
+  domain: string,
+}
+
+export type UnfurlRequestEvent = {
+  channel: string,
+
+  // eslint-disable-next-line camelcase
+  message_ts: string,
+
+  links: UnfurlEventLink[],
+}

+ 1 - 1
packages/app/src/server/middlewares/auto-reconnect-to-search.js

@@ -14,7 +14,7 @@ module.exports = (crowi) => {
       await searchService.reconnectClient();
     }
     catch (err) {
-      logger.error('Auto reconnection failed.');
+      logger.error('Auto reconnection failed.', err);
     }
 
     return searchService.isReachable;

+ 18 - 0
packages/app/src/server/models/comment.js

@@ -51,6 +51,24 @@ module.exports = function(crowi) {
     return this.find({ revision: id }).sort({ createdAt: -1 });
   };
 
+
+  /**
+   * @return {object} key: page._id, value: comments
+   */
+  commentSchema.statics.getPageIdToCommentMap = async function(pageIds) {
+    const results = await this.aggregate()
+      .match({ page: { $in: pageIds } })
+      .group({ _id: '$page', comments: { $push: '$comment' } });
+
+    // convert to map
+    const idToCommentMap = {};
+    results.forEach((result, i) => {
+      idToCommentMap[result._id] = result.comments;
+    });
+
+    return idToCommentMap;
+  };
+
   commentSchema.statics.countCommentByPageId = function(page) {
     const self = this;
 

+ 2 - 2
packages/app/src/server/models/editor-settings.ts

@@ -11,7 +11,7 @@ export interface ILintRule {
 }
 
 export interface ITextlintSettings {
-  isTexlintEnabled: string;
+  isTextlintEnabled: boolean;
   textlintRules: ILintRule[];
 }
 
@@ -33,7 +33,7 @@ const textlintSettingsSchema = new Schema<ITextlintSettings>({
 });
 
 const editorSettingsSchema = new Schema<EditorSettingsDocument, EditorSettingsModel>({
-  userId: { type: String },
+  userId: { type: Schema.Types.ObjectId },
   textlintSettings: textlintSettingsSchema,
 });
 

+ 1 - 1
packages/app/src/server/models/external-account.js

@@ -71,7 +71,7 @@ class ExternalAccount {
    * @memberof ExternalAccount
    */
   getPopulatedUser() {
-    return this.populate('user').execPopulate()
+    return this.populate('user')
       .then((account) => {
         return account.user;
       });

+ 41 - 7
packages/app/src/server/models/obsolete-page.js

@@ -219,6 +219,26 @@ export class PageQueryBuilder {
     return this;
   }
 
+  addConditionAsNonRootPage() {
+    this.query = this.query.and({ path: { $ne: '/' } });
+
+    return this;
+  }
+
+  addConditionAsNotMigrated() {
+    this.query = this.query
+      .and({ parent: null });
+
+    return this;
+  }
+
+  addConditionAsMigrated() {
+    this.query = this.query
+      .and({ parent: { $ne: null } });
+
+    return this;
+  }
+
   /*
    * Add this condition when get any ancestor pages including the target's parent
    */
@@ -245,6 +265,17 @@ export class PageQueryBuilder {
     return this;
   }
 
+  addConditionToListByPageIdsArray(pageIds) {
+    this.query = this.query
+      .and({
+        _id: {
+          $in: pageIds,
+        },
+      });
+
+    return this;
+  }
+
   populateDataToList(userPublicFields) {
     this.query = this.query
       .populate({
@@ -270,6 +301,7 @@ export const getPageSchema = (crowi) => {
     pageEvent.on('create', pageEvent.onCreate);
     pageEvent.on('update', pageEvent.onUpdate);
     pageEvent.on('createMany', pageEvent.onCreateMany);
+    pageEvent.on('addSeenUsers', pageEvent.onAddSeenUsers);
   }
 
   function validateCrowi() {
@@ -395,6 +427,7 @@ export const getPageSchema = (crowi) => {
     const saved = await this.save();
 
     debug('seenUsers updated!', added);
+    pageEvent.emit('addSeenUsers', saved);
 
     return saved;
   };
@@ -416,8 +449,7 @@ export const getPageSchema = (crowi) => {
     validateCrowi();
 
     const User = crowi.model('User');
-    return populateDataToShowRevision(this, User.USER_FIELDS_EXCEPT_CONFIDENTIAL)
-      .execPopulate();
+    return populateDataToShowRevision(this, User.USER_FIELDS_EXCEPT_CONFIDENTIAL);
   };
 
   pageSchema.methods.populateDataToMakePresentation = async function(revisionId) {
@@ -425,7 +457,7 @@ export const getPageSchema = (crowi) => {
     if (revisionId != null) {
       this.revision = revisionId;
     }
-    return this.populate('revision').execPopulate();
+    return this.populate('revision');
   };
 
   pageSchema.methods.applyScope = function(user, grant, grantUserGroupId) {
@@ -672,13 +704,15 @@ export const getPageSchema = (crowi) => {
     return await findListFromBuilderAndViewer(builder, currentUser, showAnyoneKnowsLink, opt);
   };
 
-  pageSchema.statics.findListByPageIds = async function(ids, option) {
+  pageSchema.statics.findListByPageIds = async function(ids, option, excludeRedirect = true) {
     const User = crowi.model('User');
 
     const opt = Object.assign({}, option);
     const builder = new PageQueryBuilder(this.find({ _id: { $in: ids } }));
 
-    builder.addConditionToExcludeRedirect();
+    if (excludeRedirect) {
+      builder.addConditionToExcludeRedirect();
+    }
     builder.addConditionToPagenate(opt.offset, opt.limit);
 
     // count
@@ -686,7 +720,7 @@ export const getPageSchema = (crowi) => {
 
     // find
     builder.populateDataToList(User.USER_FIELDS_EXCEPT_CONFIDENTIAL);
-    const pages = await builder.query.exec('find');
+    const pages = await builder.query.clone().exec('find');
 
     const result = {
       pages, totalCount, offset: opt.offset, limit: opt.limit,
@@ -729,7 +763,7 @@ export const getPageSchema = (crowi) => {
     // find
     builder.addConditionToPagenate(opt.offset, opt.limit, sortOpt);
     builder.populateDataToList(User.USER_FIELDS_EXCEPT_CONFIDENTIAL);
-    const pages = await builder.query.lean().exec('find');
+    const pages = await builder.query.lean().clone().exec('find');
 
     const result = {
       pages, totalCount, offset: opt.offset, limit: opt.limit,

+ 2 - 2
packages/app/src/server/models/page.ts

@@ -82,8 +82,8 @@ const schema = new Schema<PageDocument, PageModel>({
   pageIdOnHackmd: { type: String },
   revisionHackmdSynced: { type: ObjectId, ref: 'Revision' }, // the revision that is synced to HackMD
   hasDraftOnHackmd: { type: Boolean }, // set true if revision and revisionHackmdSynced are same but HackMD document has modified
-  createdAt: { type: Date, default: Date.now },
-  updatedAt: { type: Date, default: Date.now },
+  createdAt: { type: Date, default: new Date() },
+  updatedAt: { type: Date, default: new Date() },
   deleteUser: { type: ObjectId, ref: 'User' },
   deletedAt: { type: Date },
 }, {

+ 2 - 2
packages/app/src/server/models/password-reset-order.ts

@@ -33,8 +33,8 @@ const schema = new Schema<PasswordResetOrderDocument, PasswordResetOrderModel>({
   email: { type: String, required: true },
   relatedUser: { type: ObjectId, ref: 'User' },
   isRevoked: { type: Boolean, default: false, required: true },
-  createdAt: { type: Date, default: Date.now, required: true },
-  expiredAt: { type: Date, default: Date.now() + 600000, required: true },
+  createdAt: { type: Date, default: new Date(Date.now()), required: true },
+  expiredAt: { type: Date, default: new Date(Date.now() + 600000), required: true },
 });
 schema.plugin(uniqueValidator);
 

+ 5 - 1
packages/app/src/server/models/slack-app-integration.js

@@ -1,6 +1,6 @@
 const crypto = require('crypto');
 const mongoose = require('mongoose');
-const { defaultSupportedCommandsNameForBroadcastUse, defaultSupportedCommandsNameForSingleUse } = require('@growi/slack');
+const { defaultSupportedSlackEventActions } = require('@growi/slack');
 
 
 const schema = new mongoose.Schema({
@@ -9,6 +9,10 @@ const schema = new mongoose.Schema({
   isPrimary: { type: Boolean, unique: true, sparse: true },
   permissionsForBroadcastUseCommands: Map,
   permissionsForSingleUseCommands: Map,
+  permissionsForSlackEventActions: {
+    type: Map,
+    default: new Map(defaultSupportedSlackEventActions.map(action => [action, false])),
+  },
 });
 
 class SlackAppIntegration {

+ 1 - 1
packages/app/src/server/models/update-post.ts

@@ -36,7 +36,7 @@ const updatePostSchema = new Schema<UpdatePostDocument, UpdatePostModel>({
   channel: { type: String, required: true },
   provider: { type: String, required: true },
   creator: { type: Schema.Types.ObjectId, ref: 'User', index: true },
-  createdAt: { type: Date, default: Date.now },
+  createdAt: { type: Date, default: new Date(Date.now()) },
 });
 
 updatePostSchema.statics.normalizeChannelName = function(channel) {

+ 6 - 2
packages/app/src/server/routes/apiv3/pages.js

@@ -186,6 +186,7 @@ module.exports = (crowi) => {
     ],
     v5PageMigration: [
       body('action').isString().withMessage('action is required'),
+      body('pageIds').isArray().withMessage('pageIds must be an array'),
     ],
   };
 
@@ -685,18 +686,21 @@ module.exports = (crowi) => {
   });
 
   router.post('/v5-schema-migration', accessTokenParser, loginRequired, adminRequired, csrf, validator.v5PageMigration, apiV3FormValidator, async(req, res) => {
-    const { action } = req.body;
+    const { action, pageIds } = req.body;
     const isV5Compatible = crowi.configManager.getConfig('crowi', 'app:isV5Compatible');
+    const Page = crowi.model('Page');
 
     try {
       switch (action) {
         case 'initialMigration':
           if (!isV5Compatible) {
-            const Page = crowi.model('Page');
             // this method throws and emit socketIo event when error occurs
             crowi.pageService.v5InitialMigration(Page.GRANT_PUBLIC); // not await
           }
           break;
+        case 'privateLegacyPages':
+          crowi.pageService.v5MigrationByPageIds(pageIds);
+          break;
 
         default:
           logger.error(`${action} action is not supported.`);

+ 1 - 1
packages/app/src/server/routes/apiv3/response.js

@@ -10,7 +10,7 @@ const addCustomFunctionToResponse = (express, crowi) => {
       throw new Error('invalid value supplied to res.apiv3');
     }
 
-    this.status(status).json({ data: obj });
+    this.status(status).json(obj);
   };
 
   express.response.apiv3Err = function(_err, status = 400, info) { // not arrow function

+ 42 - 29
packages/app/src/server/routes/apiv3/slack-integration-settings.js

@@ -1,4 +1,4 @@
-import { SlackbotType } from '@growi/slack';
+import { SlackbotType, defaultSupportedSlackEventActions } from '@growi/slack';
 
 import loggerFactory from '~/utils/logger';
 
@@ -70,9 +70,15 @@ module.exports = (crowi) => {
     makePrimary: [
       param('id').isMongoId().withMessage('id is required'),
     ],
-    updateSupportedCommands: [
-      body('supportedCommandsForSingleUse').toArray(),
-      body('supportedCommandsForBroadcastUse').toArray(),
+    updatePermissionsWithoutProxy: [
+      body('commandPermission').exists(),
+      body('eventActionsPermission').exists(),
+      param('id').isMongoId().withMessage('id is required'),
+    ],
+    updatePermissionsWithProxy: [
+      body('permissionsForBroadcastUseCommands').exists(),
+      body('permissionsForSingleUseCommands').exists(),
+      body('permissionsForSlackEventActions').exists(),
       param('id').isMongoId().withMessage('id is required'),
     ],
     relationTest: [
@@ -106,6 +112,7 @@ module.exports = (crowi) => {
       'slackbot:withoutProxy:botToken': null,
       'slackbot:proxyUri': null,
       'slackbot:withoutProxy:commandPermission': null,
+      'slackbot:withoutProxy:eventActionsPermission': null,
     };
 
     return updateSlackBotSettings(params);
@@ -175,6 +182,7 @@ module.exports = (crowi) => {
       settings.slackSigningSecret = configManager.getConfig('crowi', 'slackbot:withoutProxy:signingSecret');
       settings.slackBotToken = configManager.getConfig('crowi', 'slackbot:withoutProxy:botToken');
       settings.commandPermission = configManager.getConfig('crowi', 'slackbot:withoutProxy:commandPermission');
+      settings.eventActionsPermission = configManager.getConfig('crowi', 'slackbot:withoutProxy:eventActionsPermission');
     }
     else {
       settings.proxyServerUri = slackIntegrationService.proxyUriForCurrentType;
@@ -251,9 +259,18 @@ module.exports = (crowi) => {
         commandPermission[commandName] = true;
       });
 
-      const requestParams = { 'slackbot:withoutProxy:commandPermission': commandPermission };
+      // default event actions permission value
+      const eventActionsPermission = {};
+      defaultSupportedSlackEventActions.forEach((action) => {
+        eventActionsPermission[action] = false;
+      });
+
+      const params = {
+        'slackbot:withoutProxy:commandPermission': commandPermission,
+        'slackbot:withoutProxy:eventActionsPermission': eventActionsPermission,
+      };
       try {
-        await updateSlackBotSettings(requestParams);
+        await updateSlackBotSettings(params);
         crowi.slackIntegrationService.publishUpdatedMessage();
       }
       catch (error) {
@@ -361,11 +378,6 @@ module.exports = (crowi) => {
     try {
       await updateSlackBotSettings(requestParams);
       crowi.slackIntegrationService.publishUpdatedMessage();
-
-      const customBotWithoutProxySettingParams = {
-        slackSigningSecret: crowi.configManager.getConfig('crowi', 'slackbot:withoutProxy:signingSecret'),
-        slackBotToken: crowi.configManager.getConfig('crowi', 'slackbot:withoutProxy:botToken'),
-      };
       return res.apiv3();
     }
     catch (error) {
@@ -389,19 +401,21 @@ module.exports = (crowi) => {
    *             description: Succeeded to put CustomBotWithoutProxy permissions.
    */
 
-  router.put('/without-proxy/update-permissions', loginRequiredStrictly, adminRequired, csrf, async(req, res) => {
+  router.put('/without-proxy/update-permissions', loginRequiredStrictly, adminRequired, csrf, validator.updatePermissionsWithoutProxy, async(req, res) => {
     const currentBotType = crowi.configManager.getConfig('crowi', 'slackbot:currentBotType');
     if (currentBotType !== SlackbotType.CUSTOM_WITHOUT_PROXY) {
       const msg = 'Not CustomBotWithoutProxy';
       return res.apiv3Err(new ErrorV3(msg, 'not-customBotWithoutProxy'), 400);
     }
 
-    const { commandPermission } = req.body;
-    const requestParams = {
+    // TODO: look here 78978
+    const { commandPermission, eventActionsPermission } = req.body;
+    const params = {
       'slackbot:withoutProxy:commandPermission': commandPermission,
+      'slackbot:withoutProxy:eventActionsPermission': eventActionsPermission,
     };
     try {
-      await updateSlackBotSettings(requestParams);
+      await updateSlackBotSettings(params);
       crowi.slackIntegrationService.publishUpdatedMessage();
       return res.apiv3();
     }
@@ -438,21 +452,16 @@ module.exports = (crowi) => {
 
     const { tokenGtoP, tokenPtoG } = await SlackAppIntegration.generateUniqueAccessTokens();
     try {
-      const initialSupportedCommandsForBroadcastUse = new Map();
-      const initialSupportedCommandsForSingleUse = new Map();
-
-      defaultSupportedCommandsNameForBroadcastUse.forEach((commandName) => {
-        initialSupportedCommandsForBroadcastUse.set(commandName, true);
-      });
-      defaultSupportedCommandsNameForSingleUse.forEach((commandName) => {
-        initialSupportedCommandsForSingleUse.set(commandName, true);
-      });
+      const initialSupportedCommandsForBroadcastUse = new Map(defaultSupportedCommandsNameForBroadcastUse.map(command => [command, true]));
+      const initialSupportedCommandsForSingleUse = new Map(defaultSupportedCommandsNameForSingleUse.map(command => [command, true]));
+      const initialPermissionsForSlackEventActions = new Map(defaultSupportedSlackEventActions.map(action => [action, true]));
 
       const slackAppTokens = await SlackAppIntegration.create({
         tokenGtoP,
         tokenPtoG,
         permissionsForBroadcastUseCommands: initialSupportedCommandsForBroadcastUse,
         permissionsForSingleUseCommands: initialSupportedCommandsForSingleUse,
+        permissionsForSlackEvents: initialPermissionsForSlackEventActions,
         isPrimary: count === 0,
       });
       return res.apiv3(slackAppTokens, 200);
@@ -595,23 +604,26 @@ module.exports = (crowi) => {
   /**
    * @swagger
    *
-   *    /slack-integration-settings/slack-app-integrations/:id/supported-commands:
+   *    /slack-integration-settings/slack-app-integrations/:id/permissions:
    *      put:
    *        tags: [SlackIntegration]
    *        operationId: putSupportedCommands
-   *        summary: /slack-integration-settings/:id/supported-commands
+   *        summary: /slack-integration-settings/:id/permissions
    *        description: update supported commands
    *        responses:
    *          200:
    *            description: Succeeded to update supported commands
    */
   // eslint-disable-next-line max-len
-  router.put('/slack-app-integrations/:id/supported-commands', loginRequiredStrictly, adminRequired, csrf, validator.updateSupportedCommands, apiV3FormValidator, async(req, res) => {
-    const { permissionsForBroadcastUseCommands, permissionsForSingleUseCommands } = req.body;
+  router.put('/slack-app-integrations/:id/permissions', loginRequiredStrictly, adminRequired, csrf, validator.updatePermissionsWithProxy, apiV3FormValidator, async(req, res) => {
+    // TODO: look here 78975
+    const { permissionsForBroadcastUseCommands, permissionsForSingleUseCommands, permissionsForSlackEventActions } = req.body;
     const { id } = req.params;
 
     const updatePermissionsForBroadcastUseCommands = new Map(Object.entries(permissionsForBroadcastUseCommands));
     const updatePermissionsForSingleUseCommands = new Map(Object.entries(permissionsForSingleUseCommands));
+    const newPermissionsForSlackEventActions = new Map(Object.entries(permissionsForSlackEventActions));
+
 
     try {
       const slackAppIntegration = await SlackAppIntegration.findByIdAndUpdate(
@@ -619,6 +631,7 @@ module.exports = (crowi) => {
         {
           permissionsForBroadcastUseCommands: updatePermissionsForBroadcastUseCommands,
           permissionsForSingleUseCommands: updatePermissionsForSingleUseCommands,
+          permissionsForSlackEventActions: newPermissionsForSlackEventActions,
         },
         { new: true },
       );
@@ -641,7 +654,7 @@ module.exports = (crowi) => {
     catch (error) {
       const msg = `Error occured in updating settings. Cause: ${error.message}`;
       logger.error('Error', error);
-      return res.apiv3Err(new ErrorV3(msg, 'update-supported-commands-failed'), 500);
+      return res.apiv3Err(new ErrorV3(msg, 'update-permissions-failed'), 500);
     }
   });
 

+ 75 - 3
packages/app/src/server/routes/apiv3/slack-integration.js

@@ -4,15 +4,17 @@ import {
 import createError from 'http-errors';
 import loggerFactory from '~/utils/logger';
 import { SlackCommandHandlerError } from '~/server/models/vo/slack-command-handler-error';
+import ErrorV3 from '../../models/vo/error-apiv3';
 
 const express = require('express');
 const mongoose = require('mongoose');
-const urljoin = require('url-join');
+const { body } = require('express-validator');
 
 const {
   verifySlackRequest, parseSlashCommand, InteractionPayloadAccessor, respond,
 } = require('@growi/slack');
 
+
 const logger = loggerFactory('growi:routes:apiv3:slack-integration');
 const router = express.Router();
 const SlackAppIntegration = mongoose.model('SlackAppIntegration');
@@ -95,7 +97,10 @@ module.exports = (crowi) => {
 
     const tokenPtoG = req.headers['x-growi-ptog-tokens'];
     const extractPermissions = await extractPermissionsCommands(tokenPtoG);
-    const fromChannel = req.body.channel_name;
+    const fromChannel = {
+      id: req.body.channel_id,
+      name: req.body.channel_name,
+    };
     const siteUrl = crowi.appService.getSiteUrl();
 
     let commandPermission;
@@ -141,7 +146,7 @@ module.exports = (crowi) => {
 
     const { actionId, callbackId } = interactionPayloadAccessor.getActionIdAndCallbackIdFromPayLoad();
     const callbacIdkOrActionId = callbackId || actionId;
-    const fromChannel = interactionPayloadAccessor.getChannelName();
+    const fromChannel = interactionPayloadAccessor.getChannel();
 
     const tokenPtoG = req.headers['x-growi-ptog-tokens'];
     const extractPermissions = await extractPermissionsCommands(tokenPtoG);
@@ -180,6 +185,18 @@ module.exports = (crowi) => {
     return next();
   };
 
+  const verifyUrlMiddleware = (req, res, next) => {
+    const { body } = req;
+
+    // eslint-disable-next-line max-len
+    // see: https://api.slack.com/apis/connections/events-api#the-events-api__subscribing-to-event-types__events-api-request-urls__request-url-configuration--verification
+    if (body.type === 'url_verification') {
+      return res.send({ challenge: body.challenge });
+    }
+
+    next();
+  };
+
   const parseSlackInteractionRequest = (req, res, next) => {
     if (req.body.payload == null) {
       return next(new Error('The payload is not in the request from slack or proxy.'));
@@ -368,6 +385,61 @@ module.exports = (crowi) => {
     return res.apiv3({ permissionsForBroadcastUseCommands, permissionsForSingleUseCommands });
   });
 
+  router.post('/events', verifyUrlMiddleware, addSigningSecretToReq, verifySlackRequest, async(req, res) => {
+    const { event } = req.body;
+
+    const growiBotEvent = {
+      eventType: event.type,
+      event,
+    };
+
+    try {
+      const client = await slackIntegrationService.generateClientForCustomBotWithoutProxy();
+      // convert permission object to map
+      const permission = new Map(Object.entries(crowi.configManager.getConfig('crowi', 'slackbot:withoutProxy:eventActionsPermission')));
+
+      await crowi.slackIntegrationService.handleEventsRequest(client, growiBotEvent, permission);
+
+      return res.apiv3({});
+    }
+    catch (err) {
+      logger.error('Error occurred while handling event request.', err);
+      return res.apiv3Err(new ErrorV3('Error occurred while handling event request.'));
+    }
+  });
+
+  const validator = {
+    validateEventRequest: [
+      body('growiBotEvent').exists(),
+      body('data').exists(),
+    ],
+  };
+
+  router.post('/proxied/events', verifyAccessTokenFromProxy, validator.validateEventRequest, async(req, res) => {
+    const { growiBotEvent, data } = req.body;
+
+    try {
+      const tokenPtoG = req.headers['x-growi-ptog-tokens'];
+      const SlackAppIntegration = mongoose.model('SlackAppIntegration');
+      const slackAppIntegration = await SlackAppIntegration.findOne({ tokenPtoG });
+
+      if (slackAppIntegration == null) {
+        throw new Error('No SlackAppIntegration exists that corresponds to the tokenPtoG specified.');
+      }
+
+      const client = await slackIntegrationService.generateClientBySlackAppIntegration(slackAppIntegration);
+      const { permissionsForSlackEventActions } = slackAppIntegration;
+
+      await crowi.slackIntegrationService.handleEventsRequest(client, growiBotEvent, permissionsForSlackEventActions, data);
+
+      return res.apiv3({});
+    }
+    catch (err) {
+      logger.error('Error occurred while handling event request.', err);
+      return res.apiv3Err(new ErrorV3('Error occurred while handling event request.'));
+    }
+  });
+
   // error handler
   router.use(async(err, req, res, next) => {
     const responseUrl = getResponseUrl(req);

+ 0 - 1
packages/app/src/server/routes/avoid-session-routes.js

@@ -1,4 +1,3 @@
 module.exports = [
-  /^\/_hackmd\//,
   /^\/api-docs\//,
 ];

+ 8 - 0
packages/app/src/server/routes/comment.js

@@ -231,6 +231,7 @@ module.exports = function(crowi, app) {
     const position = commentForm.comment_position || -1;
     const isMarkdown = commentForm.is_markdown;
     const replyTo = commentForm.replyTo;
+    const commentEvent = crowi.event('comment');
 
     // check whether accessible
     const isAccessible = await Page.isAccessiblePageByViewer(pageId, req.user);
@@ -241,6 +242,7 @@ module.exports = function(crowi, app) {
     let createdComment;
     try {
       createdComment = await Comment.create(pageId, req.user._id, revisionId, comment, position, isMarkdown, replyTo);
+      commentEvent.emit('create', createdComment);
     }
     catch (err) {
       logger.error(err);
@@ -345,6 +347,8 @@ module.exports = function(crowi, app) {
     const commentId = commentForm.comment_id;
     const revision = commentForm.revision_id;
 
+    const commentEvent = crowi.event('comment');
+
     if (commentStr === '') {
       return res.json(ApiResponse.error('Comment text is required'));
     }
@@ -375,6 +379,7 @@ module.exports = function(crowi, app) {
         { _id: commentId },
         { $set: { comment: commentStr, isMarkdown, revision } },
       );
+      commentEvent.emit('create', updatedComment);
     }
     catch (err) {
       logger.error(err);
@@ -428,6 +433,8 @@ module.exports = function(crowi, app) {
    * @apiParam {String} comment_id Comment Id.
    */
   api.remove = async function(req, res) {
+    const commentEvent = crowi.event('comment');
+
     const commentId = req.body.comment_id;
     if (!commentId) {
       return Promise.resolve(res.json(ApiResponse.error('\'comment_id\' is undefined')));
@@ -452,6 +459,7 @@ module.exports = function(crowi, app) {
 
       await comment.removeWithReplies();
       await Page.updateCommentCount(comment.page);
+      commentEvent.emit('delete', comment);
     }
     catch (err) {
       return res.json(ApiResponse.error(err));

+ 4 - 0
packages/app/src/server/routes/index.js

@@ -3,6 +3,7 @@ import express from 'express';
 import injectResetOrderByTokenMiddleware from '../middlewares/inject-reset-order-by-token-middleware';
 
 import * as forgotPassword from './forgot-password';
+import * as privateLegacyPages from './private-legacy-pages';
 
 const multer = require('multer');
 const autoReap = require('multer-autoreap');
@@ -191,6 +192,9 @@ module.exports = function(crowi, app) {
     .get('/:token', apiLimiter, injectResetOrderByTokenMiddleware, forgotPassword.resetPassword)
     .use(forgotPassword.handleHttpErrosMiddleware));
 
+  app.use('/private-legacy-pages', express.Router()
+    .get('/', privateLegacyPages.renderPrivateLegacyPages));
+
   app.get('/share/:linkId', page.showSharedPage);
 
   app.get('/:id([0-9a-z]{24})'       , loginRequired , page.showPage);

+ 1 - 1
packages/app/src/server/routes/page.js

@@ -318,7 +318,7 @@ module.exports = function(crowi, app) {
       // add scope variables by ancestor page
       const ancestor = await Page.findAncestorByPathAndViewer(path, req.user);
       if (ancestor != null) {
-        await ancestor.populate('grantedGroup').execPopulate();
+        await ancestor.populate('grantedGroup');
         addRenderVarsForScope(renderVars, ancestor);
       }
     }

+ 7 - 0
packages/app/src/server/routes/private-legacy-pages.ts

@@ -0,0 +1,7 @@
+import {
+  Request, Response,
+} from 'express';
+
+export const renderPrivateLegacyPages = (req: Request, res: Response): void => {
+  return res.render('private-legacy-pages');
+};

+ 24 - 12
packages/app/src/server/routes/search.js

@@ -139,7 +139,9 @@ module.exports = function(crowi, app) {
 
     const result = {};
     try {
-      const esResult = await searchService.searchKeyword(keyword, user, userGroups, searchOpts);
+      const esResult = searchService.formatResult(
+        await searchService.searchKeyword(keyword, user, userGroups, searchOpts),
+      );
 
       // create score map for sorting
       // key: id , value: score
@@ -151,22 +153,33 @@ module.exports = function(crowi, app) {
       const ids = esResult.data.map((page) => { return page._id });
       const findResult = await Page.findListByPageIds(ids);
 
-      // add tag data to result pages
-      findResult.pages.map((page) => {
-        const data = esResult.data.find((data) => { return page.id === data._id });
-        page._doc.tags = data._source.tag_names;
-        return page;
+      // add tags data to page
+      findResult.pages.map((pageData) => {
+        const data = esResult.data.find((data) => {
+          return pageData.id === data._id;
+        });
+        pageData._doc.tags = data._source.tag_names;
+        return pageData;
       });
 
       result.meta = esResult.meta;
       result.totalCount = findResult.totalCount;
       result.data = findResult.pages
-        .map((page) => {
-          if (page.lastUpdateUser != null && page.lastUpdateUser instanceof User) {
-            page.lastUpdateUser = serializeUserSecurely(page.lastUpdateUser);
+        .map((pageData) => {
+          if (pageData.lastUpdateUser != null && pageData.lastUpdateUser instanceof User) {
+            pageData.lastUpdateUser = serializeUserSecurely(pageData.lastUpdateUser);
           }
-          page.bookmarkCount = (page._source && page._source.bookmark_count) || 0;
-          return page;
+
+          const data = esResult.data.find((data) => {
+            return pageData.id === data._id;
+          });
+
+          const pageMeta = {
+            bookmarkCount: data._source.bookmark_count || 0,
+            elasticSearchResult: data.elasticSearchResult,
+          };
+
+          return { pageData, pageMeta };
         })
         .sort((page1, page2) => {
           // note: this do not consider NaN
@@ -176,7 +189,6 @@ module.exports = function(crowi, app) {
     catch (err) {
       return res.json(ApiResponse.error(err));
     }
-
     return res.json(ApiResponse.success(result));
   };
 

+ 1 - 1
packages/app/src/server/service/attachment.js

@@ -56,7 +56,7 @@ class AttachmentService {
     }
 
     attachments.forEach((attachment) => {
-      unorderAttachmentsBulkOp.find({ _id: attachment._id }).remove();
+      unorderAttachmentsBulkOp.find({ _id: attachment._id }).delete();
     });
     await unorderAttachmentsBulkOp.execute();
 

+ 7 - 1
packages/app/src/server/service/config-loader.ts

@@ -493,6 +493,12 @@ const ENV_VAR_NAME_TO_CONFIG_INFO = {
     type:    ValueType.STRING,
     default: null,
   },
+  SLACKBOT_WITHOUT_PROXY_EVENT_ACTIONS_PERMISSION: {
+    ns:      'crowi',
+    key:     'slackbot:withoutProxy:eventActionsPermission',
+    type:    ValueType.STRING,
+    default: null,
+  },
   SLACKBOT_WITH_PROXY_SALT_FOR_GTOP: {
     ns:      'crowi',
     key:     'slackbot:withProxy:saltForGtoP',
@@ -566,7 +572,7 @@ export default class ConfigLoader {
       if (!config[doc.ns]) {
         config[doc.ns] = {};
       }
-      config[doc.ns][doc.key] = JSON.parse(doc.value);
+      config[doc.ns][doc.key] = doc.value ? JSON.parse(doc.value) : null;
     }
 
     logger.debug('ConfigLoader#loadFromDB', config);

+ 72 - 12
packages/app/src/server/service/page.js

@@ -1,5 +1,4 @@
 import { pagePathUtils } from '@growi/core';
-import Page from '~/components/Page';
 import loggerFactory from '~/utils/logger';
 
 const mongoose = require('mongoose');
@@ -27,6 +26,7 @@ class PageService {
     this.pageEvent.on('create', this.pageEvent.onCreate);
     this.pageEvent.on('update', this.pageEvent.onUpdate);
     this.pageEvent.on('createMany', this.pageEvent.onCreateMany);
+    this.pageEvent.on('addSeenUsers', this.pageEvent.onAddSeenUsers);
   }
 
   async findPageAndMetaDataByViewer({ pageId, path, user }) {
@@ -286,7 +286,7 @@ class PageService {
     const Page = this.crowi.model('Page');
     const PageTagRelation = mongoose.model('PageTagRelation');
     // populate
-    await page.populate({ path: 'revision', model: 'Revision', select: 'body' }).execPopulate();
+    await page.populate({ path: 'revision', model: 'Revision', select: 'body' });
 
     // create option
     const options = { page };
@@ -656,7 +656,7 @@ class PageService {
       // So, it's ok to delete the page
       // However, If a page exists that is not "redirectTo", something is wrong. (Data correction is needed).
         if (pathToPageMapping[toPath].redirectTo === page.path) {
-          removePageBulkOp.find({ path: toPath }).remove();
+          removePageBulkOp.find({ path: toPath }).delete();
         }
       }
       revertPageBulkOp.find({ _id: page._id }).update({
@@ -776,6 +776,28 @@ class PageService {
     }
   }
 
+  async v5MigrationByPageIds(pageIds) {
+    const Page = mongoose.model('Page');
+
+    if (pageIds == null || pageIds.length === 0) {
+      return;
+    }
+
+    // generate regexps
+    const regexps = await this._generateRegExpsByPageIds(pageIds);
+
+    // migrate recursively
+    try {
+      await this._v5RecursiveMigration(null, regexps);
+    }
+    catch (err) {
+      logger.error('V5 initial miration failed.', err);
+      // socket.emit('v5InitialMirationFailed', { error: err.message }); TODO: use socket to tell user
+
+      throw err;
+    }
+  }
+
   async v5InitialMigration(grant) {
     const socket = this.crowi.socketIoService.getAdminSocket();
     try {
@@ -809,6 +831,27 @@ class PageService {
     await this._setIsV5CompatibleTrue();
   }
 
+  /*
+   * returns an array of js RegExp instance instead of RE2 instance for mongo filter
+   */
+  async _generateRegExpsByPageIds(pageIds) {
+    const Page = mongoose.model('Page');
+
+    let result;
+    try {
+      result = await Page.findListByPageIds(pageIds, null, false);
+    }
+    catch (err) {
+      logger.error('Failed to find pages by ids', err);
+      throw err;
+    }
+
+    const { pages } = result;
+    const regexps = pages.map(page => new RegExp(`^${page.path}`));
+
+    return regexps;
+  }
+
   async _setIsV5CompatibleTrue() {
     try {
       await this.crowi.configManager.updateConfigsInTheSameNamespace('crowi', {
@@ -823,21 +866,38 @@ class PageService {
   }
 
   // TODO: use websocket to show progress
-  async _v5RecursiveMigration(grant, rootPath) {
+  async _v5RecursiveMigration(grant, regexps) {
     const BATCH_SIZE = 100;
     const PAGES_LIMIT = 1000;
     const Page = this.crowi.model('Page');
     const { PageQueryBuilder } = Page;
 
-    const total = await Page.countDocuments({ grant, parent: null });
+    // generate filter
+    let filter = {
+      parent: null,
+      path: { $ne: '/' },
+    };
+    if (grant != null) {
+      filter = {
+        ...filter,
+        grant,
+      };
+    }
+    if (regexps != null && regexps.length !== 0) {
+      filter = {
+        ...filter,
+        path: {
+          $in: regexps,
+        },
+      };
+    }
+
+    const total = await Page.countDocuments(filter);
 
     let baseAggregation = Page
       .aggregate([
         {
-          $match: {
-            grant,
-            parent: null,
-          },
+          $match: filter,
         },
         {
           $project: { // minimize data to fetch
@@ -852,7 +912,7 @@ class PageService {
       baseAggregation = baseAggregation.limit(Math.floor(total * 0.3));
     }
 
-    const pagesStream = await baseAggregation.cursor({ batchSize: BATCH_SIZE }).exec();
+    const pagesStream = await baseAggregation.cursor({ batchSize: BATCH_SIZE });
 
     // use batch stream
     const batchStream = createBatchStream(BATCH_SIZE);
@@ -921,8 +981,8 @@ class PageService {
 
     await streamToPromise(migratePagesStream);
 
-    if (await Page.exists({ grant, parent: null, path: { $ne: '/' } })) {
-      return this._v5RecursiveMigration(grant, rootPath);
+    if (await Page.exists(filter)) {
+      return this._v5RecursiveMigration(grant, regexps);
     }
 
   }

+ 3 - 3
packages/app/src/server/service/passport.ts

@@ -8,7 +8,7 @@ import { Strategy as GoogleStrategy } from 'passport-google-oauth20';
 import { Strategy as GitHubStrategy } from 'passport-github';
 import { Strategy as TwitterStrategy } from 'passport-twitter';
 import { Strategy as OidcStrategy, Issuer as OIDCIssuer } from 'openid-client';
-import { Strategy as SamlStrategy } from 'passport-saml';
+import { Profile, Strategy as SamlStrategy, VerifiedCallback } from 'passport-saml';
 import { BasicStrategy } from 'passport-http';
 
 import { IncomingMessage } from 'http';
@@ -722,12 +722,12 @@ class PassportService implements S2sMessageHandlable {
           issuer: configManager.getConfig('crowi', 'security:passport-saml:issuer'),
           cert: configManager.getConfig('crowi', 'security:passport-saml:cert'),
         },
-        (profile, done) => {
+        (profile: Profile, done: VerifiedCallback) => {
           if (profile) {
             return done(null, profile);
           }
 
-          return done(null, false);
+          return done(null);
         },
       ),
     );

+ 58 - 6
packages/app/src/server/service/search-delegator/elasticsearch.ts

@@ -325,13 +325,16 @@ class ElasticsearchDelegator implements SearchDelegator<Data> {
     };
 
     const bookmarkCount = page.bookmarkCount || 0;
+    const seenUsersCount = page.seenUsers.length || 0;
     let document = {
       path: page.path,
       body: page.revision.body,
       // username: page.creator?.username, // available Node.js v14 and above
       username: page.creator != null ? page.creator.username : null,
+      comments: page.comments,
       comment_count: page.commentCount,
       bookmark_count: bookmarkCount,
+      seenUsers_count: seenUsersCount,
       like_count: page.liker.length || 0,
       created_at: page.createdAt,
       updated_at: page.updatedAt,
@@ -387,6 +390,7 @@ class ElasticsearchDelegator implements SearchDelegator<Data> {
     const Page = mongoose.model('Page') as PageModel;
     const { PageQueryBuilder } = Page;
     const Bookmark = mongoose.model('Bookmark') as any; // TODO: typescriptize model
+    const Comment = mongoose.model('Comment') as any; // TODO: typescriptize model
     const PageTagRelation = mongoose.model('PageTagRelation') as any; // TODO: typescriptize model
 
     const socket = this.socketIoService.getAdminSocket();
@@ -447,6 +451,28 @@ class ElasticsearchDelegator implements SearchDelegator<Data> {
       },
     });
 
+
+    const appendCommentStream = new Transform({
+      objectMode: true,
+      async transform(chunk, encoding, callback) {
+        const pageIds = chunk.map(doc => doc._id);
+
+        const idToCommentMap = await Comment.getPageIdToCommentMap(pageIds);
+        const idsHavingComment = Object.keys(idToCommentMap);
+
+        // append comments
+        chunk
+          .filter(doc => idsHavingComment.includes(doc._id.toString()))
+          .forEach((doc) => {
+            // append comments from idToCommentMap
+            doc.comments = idToCommentMap[doc._id.toString()];
+          });
+
+        this.push(chunk);
+        callback();
+      },
+    });
+
     const appendTagNamesStream = new Transform({
       objectMode: true,
       async transform(chunk, encoding, callback) {
@@ -519,6 +545,7 @@ class ElasticsearchDelegator implements SearchDelegator<Data> {
       .pipe(thinOutStream)
       .pipe(batchStream)
       .pipe(appendBookmarkCountStream)
+      .pipe(appendCommentStream)
       .pipe(appendTagNamesStream)
       .pipe(writeStream);
 
@@ -567,14 +594,19 @@ class ElasticsearchDelegator implements SearchDelegator<Data> {
         results: result.hits.hits.length,
       },
       data: result.hits.hits.map((elm) => {
-        return { _id: elm._id, _score: elm._score, _source: elm._source };
+        return {
+          _id: elm._id,
+          _score: elm._score,
+          _source: elm._source,
+          _highlight: elm.highlight,
+        };
       }),
     };
   }
 
   createSearchQuerySortedByUpdatedAt(option) {
     // getting path by default is almost for debug
-    let fields = ['path', 'bookmark_count', 'comment_count', 'updated_at', 'tag_names'];
+    let fields = ['path', 'bookmark_count', 'comment_count', 'seenUsers_count', 'updated_at', 'tag_names'];
     if (option) {
       fields = option.fields || fields;
     }
@@ -595,7 +627,7 @@ class ElasticsearchDelegator implements SearchDelegator<Data> {
   }
 
   createSearchQuerySortedByScore(option?) {
-    let fields = ['path', 'bookmark_count', 'comment_count', 'updated_at', 'tag_names'];
+    let fields = ['path', 'bookmark_count', 'comment_count', 'seenUsers_count', 'updated_at', 'tag_names', 'comments'];
     if (option) {
       fields = option.fields || fields;
     }
@@ -648,7 +680,7 @@ class ElasticsearchDelegator implements SearchDelegator<Data> {
         multi_match: {
           query: parsedKeywords.match.join(' '),
           type: 'most_fields',
-          fields: ['path.ja^2', 'path.en^2', 'body.ja', 'body.en'],
+          fields: ['path.ja^2', 'path.en^2', 'body.ja', 'body.en', 'comments.ja', 'comments.en'],
         },
       };
       query.body.query.bool.must.push(q);
@@ -658,7 +690,7 @@ class ElasticsearchDelegator implements SearchDelegator<Data> {
       const q = {
         multi_match: {
           query: parsedKeywords.not_match.join(' '),
-          fields: ['path.ja', 'path.en', 'body.ja', 'body.en'],
+          fields: ['path.ja', 'path.en', 'body.ja', 'body.en', 'comments.ja', 'comments.en'],
           operator: 'or',
         },
       };
@@ -670,12 +702,13 @@ class ElasticsearchDelegator implements SearchDelegator<Data> {
       parsedKeywords.phrase.forEach((phrase) => {
         phraseQueries.push({
           multi_match: {
-            query: phrase, // each phrase is quoteted words
+            query: phrase, // each phrase is quoteted words like "This is GROWI"
             type: 'phrase',
             fields: [
               // Not use "*.ja" fields here, because we want to analyze (parse) search words
               'path.raw^2',
               'body',
+              'comments',
             ],
           },
         });
@@ -834,6 +867,19 @@ class ElasticsearchDelegator implements SearchDelegator<Data> {
     };
   }
 
+  appendHighlight(query) {
+    query.body.highlight = {
+      fields: {
+        '*': {
+          fragment_size: 40,
+          fragmenter: 'simple',
+          pre_tags: ["<em class='highlighted-keyword'>"],
+          post_tags: ['</em>'],
+        },
+      },
+    };
+  }
+
   async search(data: SearchableData, user, userGroups, option): Promise<Result<Data> & MetaData> {
     const { queryString, terms } = data;
 
@@ -923,6 +969,12 @@ class ElasticsearchDelegator implements SearchDelegator<Data> {
     return this.updateOrInsertPageById(pageId);
   }
 
+  async syncCommentChanged(comment) {
+    logger.debug('SearchClient.syncCommentChanged', comment);
+
+    return this.updateOrInsertPageById(comment.page);
+  }
+
   async syncTagChanged(page) {
     logger.debug('SearchClient.syncTagChanged', page.path);
 

+ 53 - 0
packages/app/src/server/service/search-delegator/private-legacy-pages.ts

@@ -0,0 +1,53 @@
+import mongoose from 'mongoose';
+
+import { PageModel, PageDocument } from '~/server/models/page';
+import { SearchDelegatorName } from '~/interfaces/named-query';
+import { IPage } from '~/interfaces/page';
+import {
+  MetaData, Result, SearchableData, SearchDelegator,
+} from '../../interfaces/search';
+
+
+type Data = {
+  pages: IPage[]
+}
+
+class PrivateLegacyPagesDelegator implements SearchDelegator<Data> {
+
+  name!: SearchDelegatorName.PRIVATE_LEGACY_PAGES
+
+  async search(data: SearchableData | null, user, userGroups, option): Promise<Result<Data> & MetaData> {
+    const { offset, limit } = option;
+
+    if (offset == null || limit == null) {
+      throw Error('PrivateLegacyPagesDelegator requires pagination options (offset, limit).');
+    }
+    if (user == null && userGroups == null) {
+      throw Error('Either of user and userGroups must not be null.');
+    }
+
+    // find private legacy pages
+    const Page = mongoose.model('Page') as PageModel;
+    const { PageQueryBuilder } = Page;
+
+    const queryBuilder = new PageQueryBuilder(Page.find());
+
+    const pages: PageDocument[] = await queryBuilder
+      .addConditionAsNonRootPage()
+      .addConditionAsNotMigrated()
+      .addConditionToFilteringByViewer(user, userGroups)
+      .addConditionToPagenate(offset, limit)
+      .query
+      .lean()
+      .exec();
+
+    return {
+      data: {
+        pages,
+      },
+    };
+  }
+
+}
+
+export default PrivateLegacyPagesDelegator;

+ 51 - 10
packages/app/src/server/service/search.ts

@@ -1,17 +1,29 @@
-import mongoose from 'mongoose';
 import RE2 from 're2';
+import xss from 'xss';
 
-import { NamedQueryModel } from '../models/named-query';
 import { SearchDelegatorName } from '~/interfaces/named-query';
+
+import NamedQuery from '../models/named-query';
 import {
   SearchDelegator, SearchQueryParser, SearchResolver, ParsedQuery, Result, MetaData, SearchableData, QueryTerms,
 } from '../interfaces/search';
+import ElasticsearchDelegator from './search-delegator/elasticsearch';
+import PrivateLegacyPagesDelegator from './search-delegator/private-legacy-pages';
 
 import loggerFactory from '~/utils/logger';
 
 // eslint-disable-next-line no-unused-vars
 const logger = loggerFactory('growi:service:search');
 
+// options for filtering xss
+const filterXssOptions = {
+  whiteList: {
+    em: ['class'],
+  },
+};
+
+const filterXss = new xss.FilterXSS(filterXssOptions);
+
 const normalizeQueryString = (_queryString: string): string => {
   let queryString = _queryString.trim();
   queryString = queryString.replace(/\s+/g, ' ');
@@ -31,7 +43,7 @@ class SearchService implements SearchQueryParser, SearchResolver {
 
   fullTextSearchDelegator: any & SearchDelegator
 
-  nqDelegators: {[key in SearchDelegatorName]: SearchDelegator} // TODO: initialize
+  nqDelegators: {[key in SearchDelegatorName]: SearchDelegator}
 
   constructor(crowi) {
     this.crowi = crowi;
@@ -41,7 +53,9 @@ class SearchService implements SearchQueryParser, SearchResolver {
     this.isErrorOccuredOnSearching = null;
 
     try {
-      this.fullTextSearchDelegator = this.generateDelegator();
+      this.fullTextSearchDelegator = this.generateFullTextSearchDelegator();
+      this.nqDelegators = this.generateNQDelegators(this.fullTextSearchDelegator);
+      logger.info('Succeeded to initialize search delegators');
     }
     catch (err) {
       logger.error(err);
@@ -66,11 +80,10 @@ class SearchService implements SearchQueryParser, SearchResolver {
     return uri != null && uri.length > 0;
   }
 
-  generateDelegator() {
+  generateFullTextSearchDelegator() {
     logger.info('Initializing search delegator');
 
     if (this.isElasticsearchEnabled) {
-      const ElasticsearchDelegator = require('./search-delegator/elasticsearch');
       logger.info('Elasticsearch is enabled');
       return new ElasticsearchDelegator(this.configManager, this.crowi.socketIoService);
     }
@@ -78,6 +91,13 @@ class SearchService implements SearchQueryParser, SearchResolver {
     logger.info('No elasticsearch URI is specified so that full text search is disabled.');
   }
 
+  generateNQDelegators(defaultDelegator: SearchDelegator): {[key in SearchDelegatorName]: SearchDelegator} {
+    return {
+      [SearchDelegatorName.DEFAULT]: defaultDelegator,
+      [SearchDelegatorName.PRIVATE_LEGACY_PAGES]: new PrivateLegacyPagesDelegator(),
+    };
+  }
+
   registerUpdateEvent() {
     const pageEvent = this.crowi.event('page');
     pageEvent.on('create', this.fullTextSearchDelegator.syncPageUpdated.bind(this.fullTextSearchDelegator));
@@ -86,6 +106,7 @@ class SearchService implements SearchQueryParser, SearchResolver {
     pageEvent.on('delete', this.fullTextSearchDelegator.syncPageDeleted.bind(this.fullTextSearchDelegator));
     pageEvent.on('updateMany', this.fullTextSearchDelegator.syncPagesUpdated.bind(this.fullTextSearchDelegator));
     pageEvent.on('syncDescendants', this.fullTextSearchDelegator.syncDescendantsPagesUpdated.bind(this.fullTextSearchDelegator));
+    pageEvent.on('addSeenUsers', this.fullTextSearchDelegator.syncPageUpdated.bind(this.fullTextSearchDelegator));
 
     const bookmarkEvent = this.crowi.event('bookmark');
     bookmarkEvent.on('create', this.fullTextSearchDelegator.syncBookmarkChanged.bind(this.fullTextSearchDelegator));
@@ -93,6 +114,11 @@ class SearchService implements SearchQueryParser, SearchResolver {
 
     const tagEvent = this.crowi.event('tag');
     tagEvent.on('update', this.fullTextSearchDelegator.syncTagChanged.bind(this.fullTextSearchDelegator));
+
+    const commentEvent = this.crowi.event('comment');
+    commentEvent.on('create', this.fullTextSearchDelegator.syncCommentChanged.bind(this.fullTextSearchDelegator));
+    commentEvent.on('update', this.fullTextSearchDelegator.syncCommentChanged.bind(this.fullTextSearchDelegator));
+    commentEvent.on('delete', this.fullTextSearchDelegator.syncCommentChanged.bind(this.fullTextSearchDelegator));
   }
 
   resetErrorStatus() {
@@ -154,7 +180,6 @@ class SearchService implements SearchQueryParser, SearchResolver {
   }
 
   async parseSearchQuery(_queryString: string): Promise<ParsedQuery> {
-
     const regexp = new RE2(/^\[nq:.+\]$/g); // https://regex101.com/r/FzDUvT/1
     const replaceRegexp = new RE2(/\[nq:|\]/g);
 
@@ -166,8 +191,6 @@ class SearchService implements SearchQueryParser, SearchResolver {
     }
 
     // when Named Query
-    const NamedQuery = mongoose.model('NamedQuery') as NamedQueryModel;
-
     const name = queryString.replace(replaceRegexp, '');
     const nq = await NamedQuery.findOne({ name });
 
@@ -202,7 +225,7 @@ class SearchService implements SearchQueryParser, SearchResolver {
       queryString,
       terms: terms as QueryTerms,
     };
-    return [this.fullTextSearchDelegator, data];
+    return [this.nqDelegators[SearchDelegatorName.DEFAULT], data];
   }
 
   async searchKeyword(keyword: string, user, userGroups, searchOpts): Promise<Result<any> & MetaData> {
@@ -308,6 +331,24 @@ class SearchService implements SearchQueryParser, SearchResolver {
     return terms;
   }
 
+  /**
+   * formatting result
+   */
+  formatResult(esResult) {
+    esResult.data.forEach((data) => {
+      const highlightData = data._highlight;
+      const snippet = highlightData['body.en'] || highlightData['body.ja'] || '';
+      const pathMatch = highlightData['path.en'] || highlightData['path.ja'] || '';
+
+      data.elasticSearchResult = {
+        snippet: filterXss.process(snippet),
+        // todo: use filter xss.process() for matchedPath;
+        matchedPath: pathMatch,
+      };
+    });
+    return esResult;
+  }
+
 }
 
 export default SearchService;

+ 12 - 0
packages/app/src/server/service/slack-event-handler/base-event-handler.ts

@@ -0,0 +1,12 @@
+import { WebClient } from '@slack/web-api';
+import { GrowiBotEvent } from '@growi/slack';
+
+import { EventActionsPermission } from '../../interfaces/slack-integration/events';
+
+export interface SlackEventHandler<T> {
+
+  shouldHandle(eventType: string, permission: EventActionsPermission, channel?: string): boolean
+
+  handleEvent(client: WebClient, growiBotEvent: GrowiBotEvent<T>, data?: any): Promise<void>
+
+}

+ 179 - 0
packages/app/src/server/service/slack-event-handler/link-shared.ts

@@ -0,0 +1,179 @@
+import urljoin from 'url-join';
+import { format } from 'date-fns';
+import {
+  MessageAttachment, LinkUnfurls, WebClient,
+} from '@slack/web-api';
+import { GrowiBotEvent } from '@growi/slack';
+import { SlackEventHandler } from './base-event-handler';
+import {
+  DataForUnfurl, PublicData, UnfurlEventLink, UnfurlRequestEvent,
+} from '../../interfaces/slack-integration/link-shared-unfurl';
+import loggerFactory from '~/utils/logger';
+import { EventActionsPermission } from '~/server/interfaces/slack-integration/events';
+
+const logger = loggerFactory('growi:service:SlackEventHandler:link-shared');
+
+export class LinkSharedEventHandler implements SlackEventHandler<UnfurlRequestEvent> {
+
+  crowi!: any;
+
+  constructor(crowi) {
+    this.crowi = crowi;
+  }
+
+  shouldHandle(eventType: string, permission: EventActionsPermission, channel: string): boolean {
+    if (eventType !== 'link_shared') return false;
+
+    const unfurlPermission = permission.get('unfurl');
+
+    if (!Array.isArray(unfurlPermission)) {
+      return unfurlPermission as boolean;
+    }
+
+    return unfurlPermission.includes(channel);
+  }
+
+  async handleEvent(client: WebClient, growiBotEvent: GrowiBotEvent<UnfurlRequestEvent>, data?: {origin: string}): Promise<void> {
+    const { event } = growiBotEvent;
+    const origin = data?.origin || this.crowi.appService.getSiteUrl();
+    const { channel, message_ts: ts, links } = event;
+
+    let unfurlData: DataForUnfurl[];
+    try {
+      unfurlData = await this.generateUnfurlsObject(links);
+    }
+    catch (err) {
+      logger.error('Failed to generate unfurl data:', err);
+      throw err;
+    }
+
+    // unfurl
+    const unfurlResults = await Promise.allSettled(unfurlData.map(async(data: DataForUnfurl) => {
+      const toUrl = urljoin(origin, data.id);
+
+      let targetUrl;
+      if (data.isPermalink) {
+        targetUrl = urljoin(origin, data.id);
+      }
+      else {
+        targetUrl = urljoin(origin, data.path);
+      }
+
+      let unfurls: LinkUnfurls;
+
+      if (data.isPublic === false) {
+        unfurls = {
+          [targetUrl]: {
+            text: 'Page is not public.',
+          },
+        };
+      }
+      else {
+        unfurls = this.generateLinkUnfurls(data as PublicData, targetUrl, toUrl);
+      }
+
+      await client.chat.unfurl({
+        channel,
+        ts,
+        unfurls,
+      });
+    }));
+
+    this.logErrorRejectedResults(unfurlResults);
+  }
+
+  // builder method for unfurl parameter
+  generateLinkUnfurls(body: PublicData, growiTargetUrl: string, toUrl: string): LinkUnfurls {
+    const { pageBody: text, updatedAt, commentCount } = body;
+
+    const siteUrl = this.crowi.appService.getSiteUrl();
+
+    const updatedAtFormatted = format(updatedAt, 'yyyy-MM-dd HH:mm');
+    const footer = `URL: ${siteUrl}  Updated at: ${updatedAtFormatted}`;
+
+    const attachment: MessageAttachment = {
+      title: body.path,
+      title_link: toUrl, // permalink
+      text,
+      footer,
+    };
+
+    const unfurls: LinkUnfurls = {
+      [growiTargetUrl]: attachment,
+    };
+    return unfurls;
+  }
+
+  async generateUnfurlsObject(links: UnfurlEventLink[]): Promise<DataForUnfurl[]> {
+    // generate paths array
+    const pathOrIds: string[] = links.map((link) => {
+      const { url: growiTargetUrl } = link;
+      const urlObject = new URL(growiTargetUrl);
+
+      return decodeURI(urlObject.pathname);
+    });
+
+    const idRegExp = /^\/[0-9a-z]{24}$/;
+    const paths = pathOrIds.filter(pathOrId => !idRegExp.test(pathOrId));
+    const ids = pathOrIds.filter(pathOrId => idRegExp.test(pathOrId)).map(id => id.replace('/', '')); // remove a slash
+
+    // get pages with revision
+    const Page = this.crowi.model('Page');
+    const { PageQueryBuilder } = Page;
+
+    const pageQueryBuilderByPaths = new PageQueryBuilder(Page.find());
+    const pagesByPaths = await pageQueryBuilderByPaths
+      .addConditionToListByPathsArray(paths)
+      .query
+      .populate('revision')
+      .lean()
+      .exec();
+
+    const pageQueryBuilderByIds = new PageQueryBuilder(Page.find());
+    const pagesByIds = await pageQueryBuilderByIds
+      .addConditionToListByPageIdsArray(ids)
+      .query
+      .populate('revision')
+      .lean()
+      .exec();
+
+    const unfurlDataFromNormalLinks = this.generateDataForUnfurl(pagesByPaths, false);
+    const unfurlDataFromPermalinks = this.generateDataForUnfurl(pagesByIds, true);
+
+    return [...unfurlDataFromNormalLinks, ...unfurlDataFromPermalinks];
+  }
+
+  // eslint-disable-next-line @typescript-eslint/no-explicit-any
+  private generateDataForUnfurl(pages: any, isPermalink: boolean): DataForUnfurl[] {
+    const Page = this.crowi.model('Page');
+    const unfurlData: DataForUnfurl[] = [];
+
+    pages.forEach((page) => {
+      // not send non-public page
+      if (page.grant !== Page.GRANT_PUBLIC) {
+        return unfurlData.push({
+          isPublic: false, isPermalink, id: page._id.toString(), path: page.path,
+        });
+      }
+
+      // public page
+      const { updatedAt, commentCount } = page;
+      const { body } = page.revision;
+      unfurlData.push({
+        isPublic: true, isPermalink, id: page._id.toString(), path: page.path, pageBody: body, updatedAt, commentCount,
+      });
+    });
+
+    return unfurlData;
+  }
+
+  // Promise util method to output rejected results
+  private logErrorRejectedResults<T>(results: PromiseSettledResult<T>[]): void {
+    const rejectedResults: PromiseRejectedResult[] = results.filter((result): result is PromiseRejectedResult => result.status === 'rejected');
+
+    rejectedResults.forEach((rejected, i) => {
+      logger.error(`Error occurred (count: ${i}): `, rejected.reason.toString());
+    });
+  }
+
+}

+ 17 - 2
packages/app/src/server/service/slack-integration.ts

@@ -5,7 +5,7 @@ import { ChatPostMessageArguments, WebClient } from '@slack/web-api';
 
 import {
   generateWebClient, GrowiCommand, InteractionPayloadAccessor, markdownSectionBlock, SlackbotType,
-  RespondUtil,
+  RespondUtil, GrowiBotEvent,
 } from '@growi/slack';
 
 import loggerFactory from '~/utils/logger';
@@ -16,7 +16,8 @@ import ConfigManager from './config-manager';
 import { S2sMessagingService } from './s2s-messaging/base';
 import { S2sMessageHandlable } from './s2s-messaging/handlable';
 import { SlackCommandHandlerError } from '../models/vo/slack-command-handler-error';
-
+import { LinkSharedEventHandler } from './slack-event-handler/link-shared';
+import { EventActionsPermission } from '../interfaces/slack-integration/events';
 
 const logger = loggerFactory('growi:service:SlackBotService');
 
@@ -34,10 +35,13 @@ export class SlackIntegrationService implements S2sMessageHandlable {
 
   lastLoadedAt?: Date;
 
+  linkSharedHandler!: LinkSharedEventHandler;
+
   constructor(crowi) {
     this.crowi = crowi;
     this.configManager = crowi.configManager;
     this.s2sMessagingService = crowi.s2sMessagingService;
+    this.linkSharedHandler = new LinkSharedEventHandler(crowi);
 
     this.initialize();
   }
@@ -306,4 +310,15 @@ export class SlackIntegrationService implements S2sMessageHandlable {
     return handler.handleInteractions(client, interactionPayload, interactionPayloadAccessor, handlerMethodName, respondUtil);
   }
 
+  async handleEventsRequest(client: WebClient, growiBotEvent: GrowiBotEvent<any>, permission: EventActionsPermission, data?: any): Promise<void> {
+    const { eventType } = growiBotEvent;
+    const { channel = '' } = growiBotEvent.event; // only channelId
+
+    if (this.linkSharedHandler.shouldHandle(eventType, permission, channel)) {
+      return this.linkSharedHandler.handleEvent(client, growiBotEvent, data);
+    }
+
+    logger.error(`Any event actions are not permitted, or, a handler for '${eventType}' event is not implemented`);
+  }
+
 }

Некоторые файлы не были показаны из-за большого количества измененных файлов