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

Merge branch 'dev/5.0.x' into imprv/fix-regexp-for-v5-migration

Taichi Masuyama 4 лет назад
Родитель
Сommit
2e8a1e68d3
100 измененных файлов с 2386 добавлено и 1516 удалено
  1. 1 0
      packages/app/config/logger/config.dev.js
  2. 3 1
      packages/app/package.json
  3. 10 2
      packages/app/resource/locales/en_US/translation.json
  4. 9 2
      packages/app/resource/locales/ja_JP/translation.json
  5. 9 2
      packages/app/resource/locales/zh_CN/translation.json
  6. 3 0
      packages/app/resource/search/mappings.json
  7. 2 7
      packages/app/src/client/app.jsx
  8. 31 31
      packages/app/src/client/legacy/crowi.js
  9. 26 9
      packages/app/src/client/services/ContextExtractor.tsx
  10. 55 137
      packages/app/src/client/services/NavigationContainer.js
  11. 5 70
      packages/app/src/client/services/PageContainer.js
  12. 0 0
      packages/app/src/client/services/user-ui-settings.ts
  13. 1 1
      packages/app/src/client/util/apiv3-client.ts
  14. 2 1
      packages/app/src/components/Admin/ElasticsearchManagement/ElasticsearchManagement.jsx
  15. 25 24
      packages/app/src/components/BookmarkButton.jsx
  16. 3 11
      packages/app/src/components/ComparePathsTable.jsx
  17. 3 13
      packages/app/src/components/CreateTemplateModal.jsx
  18. 5 1
      packages/app/src/components/Fab.jsx
  19. 9 11
      packages/app/src/components/Hotkeys/Subscribers/CreatePage.jsx
  20. 4 2
      packages/app/src/components/Hotkeys/Subscribers/EditPage.jsx
  21. 3 3
      packages/app/src/components/Icons/GrowiLogo.jsx
  22. 0 102
      packages/app/src/components/LikeButtons.jsx
  23. 81 0
      packages/app/src/components/LikeButtons.tsx
  24. 0 115
      packages/app/src/components/Navbar/GrowiNavbar.jsx
  25. 128 0
      packages/app/src/components/Navbar/GrowiNavbar.tsx
  26. 8 4
      packages/app/src/components/Navbar/GrowiNavbarBottom.jsx
  27. 68 70
      packages/app/src/components/Navbar/GrowiSubNavigation.jsx
  28. 17 15
      packages/app/src/components/Navbar/PageEditorModeManager.jsx
  29. 4 2
      packages/app/src/components/Navbar/PersonalDropdown.jsx
  30. 0 66
      packages/app/src/components/Navbar/SubNavButtons.jsx
  31. 119 0
      packages/app/src/components/Navbar/SubNavButtons.tsx
  32. 13 9
      packages/app/src/components/Page/DisplaySwitcher.jsx
  33. 13 13
      packages/app/src/components/Page/NotFoundAlert.jsx
  34. 25 11
      packages/app/src/components/Page/PageManagement.jsx
  35. 7 4
      packages/app/src/components/Page/RevisionLoader.jsx
  36. 9 48
      packages/app/src/components/Page/TagLabels.jsx
  37. 4 1
      packages/app/src/components/Page/TrashPageAlert.jsx
  38. 8 7
      packages/app/src/components/PageCreateModal.jsx
  39. 25 13
      packages/app/src/components/PageDeleteModal.jsx
  40. 8 6
      packages/app/src/components/PageDuplicateModal.jsx
  41. 15 9
      packages/app/src/components/PageEditor/EditorNavbarBottom.jsx
  42. 1 1
      packages/app/src/components/PageList/Page.jsx
  43. 56 0
      packages/app/src/components/PagePathNav.tsx
  44. 41 0
      packages/app/src/components/PageReactionButtons.tsx
  45. 26 23
      packages/app/src/components/PageRenameModal.jsx
  46. 23 29
      packages/app/src/components/PaginationWrapper.tsx
  47. 13 12
      packages/app/src/components/PutbackPageModal.jsx
  48. 2 1
      packages/app/src/components/SearchForm.jsx
  49. 176 30
      packages/app/src/components/SearchPage.jsx
  50. 62 0
      packages/app/src/components/SearchPage/DeleteSelectedPageGroup.tsx
  51. 42 0
      packages/app/src/components/SearchPage/IncludeSpecificPathButton.jsx
  52. 148 0
      packages/app/src/components/SearchPage/SearchControl.tsx
  53. 83 0
      packages/app/src/components/SearchPage/SearchOptionModal.tsx
  54. 31 13
      packages/app/src/components/SearchPage/SearchPageForm.jsx
  55. 64 0
      packages/app/src/components/SearchPage/SearchPageLayout.tsx
  56. 0 350
      packages/app/src/components/SearchPage/SearchResult.jsx
  57. 44 0
      packages/app/src/components/SearchPage/SearchResultContent.tsx
  58. 91 0
      packages/app/src/components/SearchPage/SearchResultContentSubNavigation.tsx
  59. 0 64
      packages/app/src/components/SearchPage/SearchResultList.jsx
  60. 49 0
      packages/app/src/components/SearchPage/SearchResultList.tsx
  61. 144 0
      packages/app/src/components/SearchPage/SearchResultListItem.tsx
  62. 1 1
      packages/app/src/components/SearchTypeahead.jsx
  63. 4 3
      packages/app/src/components/Sidebar.tsx
  64. 37 3
      packages/app/src/components/Sidebar/PageTree/Item.tsx
  65. 1 3
      packages/app/src/components/Sidebar/RecentChanges.tsx
  66. 1 1
      packages/app/src/components/Sidebar/SidebarNav.tsx
  67. 6 9
      packages/app/src/components/StickyStretchableScroller.jsx
  68. 1 1
      packages/app/src/components/User/SeenUserInfo.jsx
  69. 4 0
      packages/app/src/interfaces/bookmark-info.ts
  70. 8 0
      packages/app/src/interfaces/page-info.ts
  71. 2 0
      packages/app/src/interfaces/page.ts
  72. 3 0
      packages/app/src/interfaces/pageTagsInfo.ts
  73. 18 0
      packages/app/src/interfaces/search.ts
  74. 3 1
      packages/app/src/server/events/page.js
  75. 2 2
      packages/app/src/server/interfaces/search.ts
  76. 1 0
      packages/app/src/server/models/config.ts
  77. 4 2
      packages/app/src/server/models/obsolete-page.js
  78. 1 0
      packages/app/src/server/models/page.ts
  79. 1 1
      packages/app/src/server/routes/apiv3/response.js
  80. 0 1
      packages/app/src/server/routes/avoid-session-routes.js
  81. 2 54
      packages/app/src/server/routes/search.js
  82. 1 0
      packages/app/src/server/service/page.js
  83. 23 3
      packages/app/src/server/service/search-delegator/elasticsearch.ts
  84. 9 4
      packages/app/src/server/service/search-delegator/private-legacy-pages.ts
  85. 129 0
      packages/app/src/server/service/search.ts
  86. 2 0
      packages/app/src/server/views/layout-growi/base/layout.html
  87. 1 1
      packages/app/src/server/views/search.html
  88. 16 0
      packages/app/src/stores/bookmark.ts
  89. 24 0
      packages/app/src/stores/page.tsx
  90. 39 10
      packages/app/src/stores/ui.tsx
  91. 10 0
      packages/app/src/stores/user.tsx
  92. 25 4
      packages/app/src/styles/_page-tree.scss
  93. 48 18
      packages/app/src/styles/_search.scss
  94. 9 1
      packages/app/src/styles/_sidebar.scss
  95. 15 9
      packages/app/src/styles/theme/_apply-colors.scss
  96. 2 2
      packages/core/src/models/devided-page-path.js
  97. 1 1
      packages/plugin-lsx/src/client/js/components/LsxPageList/PagePathWrapper.jsx
  98. 19 3
      packages/ui/src/components/PagePath/PageListMeta.jsx
  99. 0 32
      packages/ui/src/components/PagePath/PagePathLabel.jsx
  100. 56 0
      packages/ui/src/components/PagePath/PagePathLabel.tsx

+ 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',
 
 };

+ 3 - 1
packages/app/package.json

@@ -122,7 +122,7 @@
     "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",
@@ -134,6 +134,7 @@
     "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",
@@ -161,6 +162,7 @@
     "@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",

+ 10 - 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,19 @@
     "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",
+    "search_again" : "Search again",
+    "number_of_list_to_display" : "Display",
+    "page_number_unit" : "pages"
+
   },
   "security_setting": {
     "Guest Users Access": "Guest users access",

+ 9 - 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,18 @@
     "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" : "一括削除",
+    "search_again" : "再検索",
+    "number_of_list_to_display" : "表示件数",
+    "page_number_unit" : "件"
   },
   "security_setting": {
     "Guest Users Access": "ゲストユーザーのアクセス",

+ 9 - 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,18 @@
 		"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": "删除所有",
+    "search_again" : "再次搜索",
+    "number_of_list_to_display" : "显示器的数量",
+    "page_number_unit" : "例"
 	},
 	"to_cloud_settings": "進入 GROWI.cloud 的管理界面",
 	"login": {

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

@@ -88,6 +88,9 @@
         "bookmark_count": {
           "type": "integer"
         },
+        "seenUsers_count":{
+          "type": "integer"
+        },
         "like_count": {
           "type": "integer"
         },

+ 2 - 7
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}
   />,
@@ -118,8 +117,6 @@ Object.assign(componentMappings, {
   'duplicated-alert': <DuplicatedAlert />,
   'redirected-alert': <RedirectedAlert />,
   'renamed-alert': <RenamedAlert />,
-
-  'growi-context-extractor': <ContextExtractor />, // use static swr
 });
 
 // additional definitions if data exists
@@ -133,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
@@ -182,7 +178,7 @@ const elem = document.getElementById('growi-context-extractor');
 if (elem != null) {
   ReactDOM.render(
     <SWRConfig value={swrGlobalConfiguration}>
-      {componentMappings['growi-context-extractor']}
+      <ContextExtractor></ContextExtractor>
     </SWRConfig>,
     elem,
     renderMainComponents,
@@ -192,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');
     }
   }
 });

+ 26 - 9
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 {
@@ -9,14 +9,25 @@ import {
 } 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');
 
@@ -63,7 +74,7 @@ const ContextExtractor: FC = () => {
   useCurrentUser(currentUser);
 
   // Navigation
-  useEditorMode();
+  useEditorMode(getInitialEditorMode());
   usePreferDrawerModeByUser();
   usePreferDrawerModeOnEditByUser();
   useIsDeviceSmallerThanMd();
@@ -97,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 - 70
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
@@ -293,22 +293,6 @@ export default class PageContainer extends Container {
     await this.retrieveLikersAndSeenUsers();
   }
 
-  async toggleLike() {
-    {
-      const toggledIsLiked = !this.state.isLiked;
-      await this.appContainer.apiv3Put('/page/likes', { pageId: this.state.pageId, bool: toggledIsLiked });
-
-      await this.setState(state => ({
-        isLiked: toggledIsLiked,
-        sumOfLikers: toggledIsLiked ? state.sumOfLikers + 1 : state.sumOfLikers - 1,
-        likerIds: toggledIsLiked
-          ? [...this.state.likerIds, this.appContainer.currentUserId]
-          : state.likerIds.filter(id => id !== this.appContainer.currentUserId),
-      }));
-    }
-
-    await this.retrieveLikersAndSeenUsers();
-  }
 
   async retrieveLikersAndSeenUsers() {
     const { users } = await this.appContainer.apiGet('/users.list', { user_ids: [...this.state.likerIds, ...this.state.seenUserIds].join(',') });
@@ -329,12 +313,6 @@ export default class PageContainer extends Container {
     });
   }
 
-  async toggleBookmark() {
-    const bool = !this.state.isBookmarked;
-    await this.appContainer.apiv3Put('/bookmarks', { pageId: this.state.pageId, bool });
-    return this.retrieveBookmarkInfo();
-  }
-
   async checkAndUpdateImageUrlCached(users) {
     const noImageCacheUsers = users.filter((user) => { return user.imageUrlCached == null });
     if (noImageCacheUsers.length === 0) {
@@ -530,49 +508,6 @@ export default class PageContainer extends Container {
     return res;
   }
 
-  deletePage(isRecursively, isCompletely) {
-    const socketIoContainer = this.appContainer.getContainer('SocketIoContainer');
-
-    // control flag
-    const completely = isCompletely ? true : null;
-    const recursively = isRecursively ? true : null;
-
-    return this.appContainer.apiPost('/pages.remove', {
-      recursively,
-      completely,
-      page_id: this.state.pageId,
-      revision_id: this.state.revisionId,
-    });
-
-  }
-
-  revertRemove(isRecursively) {
-    const socketIoContainer = this.appContainer.getContainer('SocketIoContainer');
-
-    // control flag
-    const recursively = isRecursively ? true : null;
-
-    return this.appContainer.apiPost('/pages.revertRemove', {
-      recursively,
-      page_id: this.state.pageId,
-    });
-  }
-
-  rename(newPagePath, isRecursively, isRenameRedirect, isRemainMetadata) {
-    const socketIoContainer = this.appContainer.getContainer('SocketIoContainer');
-    const { pageId, revisionId, path } = this.state;
-
-    return this.appContainer.apiv3Put('/pages/rename', {
-      revisionId,
-      pageId,
-      isRecursively,
-      isRenameRedirect,
-      isRemainMetadata,
-      newPagePath,
-      path,
-    });
-  }
-
   showSuccessToastr() {
     toastr.success(undefined, 'Saved successfully', {
       closeButton: true,

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

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

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

+ 25 - 24
packages/app/src/components/BookmarkButton.jsx

@@ -6,10 +6,11 @@ import { withTranslation } from 'react-i18next';
 import { withUnstatedContainers } from './UnstatedUtils';
 
 import { toastError } from '~/client/util/apiNotification';
-import PageContainer from '~/client/services/PageContainer';
+import { apiv3Put } from '~/client/util/apiv3-client';
+
 import AppContainer from '~/client/services/AppContainer';
 
-class BookmarkButton extends React.Component {
+class LegacyBookmarkButton extends React.Component {
 
   constructor(props) {
     super(props);
@@ -18,24 +19,17 @@ class BookmarkButton extends React.Component {
   }
 
   async handleClick() {
-    const { appContainer, pageContainer } = this.props;
-    const { isGuestUser } = appContainer;
 
-    if (isGuestUser) {
+    if (this.props.onBookMarkClicked == null) {
       return;
     }
-
-    try {
-      pageContainer.toggleBookmark();
-    }
-    catch (err) {
-      toastError(err);
-    }
+    this.props.onBookMarkClicked();
   }
 
-
   render() {
-    const { appContainer, pageContainer, t } = this.props;
+    const {
+      appContainer, t, isBookmarked, sumOfBookmarks,
+    } = this.props;
     const { isGuestUser } = appContainer;
 
     return (
@@ -45,12 +39,14 @@ class BookmarkButton extends React.Component {
           id="bookmark-button"
           onClick={this.handleClick}
           className={`btn btn-bookmark border-0
-          ${`btn-${this.props.size}`} ${pageContainer.state.isBookmarked ? 'active' : ''} ${isGuestUser ? 'disabled' : ''}`}
+          ${`btn-${this.props.size}`} ${isBookmarked ? 'active' : ''} ${isGuestUser ? 'disabled' : ''}`}
         >
           <i className="icon-star mr-3"></i>
-          <span className="total-bookmarks">
-            {pageContainer.state.sumOfBookmarks}
-          </span>
+          {sumOfBookmarks && (
+            <span className="total-bookmarks">
+              {sumOfBookmarks}
+            </span>
+          )}
         </button>
 
         {isGuestUser && (
@@ -67,19 +63,24 @@ class BookmarkButton extends React.Component {
 /**
  * Wrapper component for using unstated
  */
-const BookmarkButtonWrapper = withUnstatedContainers(BookmarkButton, [AppContainer, PageContainer]);
+const LegacyBookmarkButtonWrapper = withUnstatedContainers(LegacyBookmarkButton, [AppContainer]);
 
-BookmarkButton.propTypes = {
+LegacyBookmarkButton.propTypes = {
   appContainer: PropTypes.instanceOf(AppContainer).isRequired,
-  pageContainer: PropTypes.instanceOf(PageContainer).isRequired,
 
-  pageId: PropTypes.string,
+  isBookmarked: PropTypes.bool.isRequired,
+  sumOfBookmarks: PropTypes.number,
   t: PropTypes.func.isRequired,
   size: PropTypes.string,
+  onBookMarkClicked: PropTypes.func,
 };
 
-BookmarkButton.defaultProps = {
+LegacyBookmarkButton.defaultProps = {
   size: 'md',
 };
 
-export default withTranslation()(BookmarkButtonWrapper);
+const BookmarkButton = (props) => {
+  return <LegacyBookmarkButtonWrapper {...props}></LegacyBookmarkButtonWrapper>;
+};
+
+export default withTranslation()(BookmarkButton);

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

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

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

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

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

@@ -6,6 +6,8 @@ import loggerFactory from '~/utils/logger';
 
 import AppContainer from '~/client/services/AppContainer';
 import 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 - 102
packages/app/src/components/LikeButtons.jsx

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

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

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

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

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

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

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

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

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

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

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

@@ -1,9 +1,11 @@
 import React from 'react';
 import { 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;

+ 25 - 11
packages/app/src/components/Page/PageManagement.jsx

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

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

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

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

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

+ 4 - 1
packages/app/src/components/Page/TrashPageAlert.jsx

@@ -15,7 +15,7 @@ import PageDeleteModal from '../PageDeleteModal';
 const TrashPageAlert = (props) => {
   const { t, pageContainer } = props;
   const {
-    path, isDeleted, lastUpdateUsername, updatedAt, deletedUserName, deletedAt, isAbleToDeleteCompletely,
+    pageId, revisionId, path, isDeleted, lastUpdateUsername, updatedAt, deletedUserName, deletedAt, isAbleToDeleteCompletely,
   } = pageContainer.state;
   const [isEmptyTrashModalShown, setIsEmptyTrashModalShown] = useState(false);
   const [isPutbackPageModalShown, setIsPutbackPageModalShown] = useState(false);
@@ -92,11 +92,14 @@ const TrashPageAlert = (props) => {
         <PutbackPageModal
           isOpen={isPutbackPageModalShown}
           onClose={closePutbackPageModalHandler}
+          pageId={pageId}
           path={path}
         />
         <PageDeleteModal
           isOpen={isPageDeleteModalShown}
           onClose={opclosePageDeleteModalHandler}
+          pageId={pageId}
+          revisionId={revisionId}
           path={path}
           isDeleteCompletelyModal
           isAbleToDeleteCompletely={isAbleToDeleteCompletely}

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

+ 25 - 13
packages/app/src/components/PageDeleteModal.jsx

@@ -7,8 +7,7 @@ import {
 
 import { withTranslation } from 'react-i18next';
 
-import { withUnstatedContainers } from './UnstatedUtils';
-import PageContainer from '~/client/services/PageContainer';
+import { apiPost } from '~/client/util/apiv1-client';
 
 import ApiErrorMessageList from './PageManagement/ApiErrorMessageList';
 
@@ -27,7 +26,7 @@ const deleteIconAndKey = {
 
 const PageDeleteModal = (props) => {
   const {
-    t, pageContainer, isOpen, onClose, isDeleteCompletelyModal, path, isAbleToDeleteCompletely,
+    t, isOpen, onClose, isDeleteCompletelyModal, pageId, revisionId, path, isAbleToDeleteCompletely,
   } = props;
   const [isDeleteRecursively, setIsDeleteRecursively] = useState(true);
   const [isDeleteCompletely, setIsDeleteCompletely] = useState(isDeleteCompletelyModal && isAbleToDeleteCompletely);
@@ -50,7 +49,18 @@ const PageDeleteModal = (props) => {
     setErrs(null);
 
     try {
-      const response = await pageContainer.deletePage(isDeleteRecursively, isDeleteCompletely);
+      // control flag
+      // If is it not true, Request value must be `null`.
+      const recursively = isDeleteRecursively ? true : null;
+      const completely = isDeleteCompletely ? true : null;
+
+      const response = await apiPost('/pages.remove', {
+        page_id: pageId,
+        revision_id: revisionId,
+        recursively,
+        completely,
+      });
+
       const trashPagePath = response.page.path;
       window.location.href = encodeURI(trashPagePath);
     }
@@ -81,6 +91,12 @@ const PageDeleteModal = (props) => {
     );
   }
 
+  // DeleteCompletely is currently disabled
+  // TODO1 : Retrive isAbleToDeleteCompleltly state everywhere in the system via swr.
+  // Story: https://redmine.weseek.co.jp/issues/82222
+
+  // TODO2 : use toaster
+  // TASK : https://redmine.weseek.co.jp/issues/82299
   function renderDeleteCompletelyForm() {
     return (
       <div className="custom-control custom-checkbox custom-checkbox-danger">
@@ -89,12 +105,12 @@ const PageDeleteModal = (props) => {
           name="completely"
           id="deleteCompletely"
           type="checkbox"
-          disabled={!isAbleToDeleteCompletely}
+          disabled
           checked={isDeleteCompletely}
           onChange={changeIsDeleteCompletelyHandler}
         />
         <label className="custom-control-label text-danger" htmlFor="deleteCompletely">
-          { t('modal_delete.delete_completely') }
+          { t('modal_delete.delete_completely')}
           <p className="form-text text-muted mt-0"> { t('modal_delete.completely') }</p>
         </label>
         {!isAbleToDeleteCompletely
@@ -133,18 +149,14 @@ const PageDeleteModal = (props) => {
   );
 };
 
-/**
- * Wrapper component for using unstated
- */
-const PageDeleteModalWrapper = withUnstatedContainers(PageDeleteModal, [PageContainer]);
-
 PageDeleteModal.propTypes = {
   t: PropTypes.func.isRequired, //  i18next
-  pageContainer: PropTypes.instanceOf(PageContainer).isRequired,
 
   isOpen: PropTypes.bool.isRequired,
   onClose: PropTypes.func.isRequired,
 
+  pageId: PropTypes.string.isRequired,
+  revisionId: PropTypes.string.isRequired,
   path: PropTypes.string.isRequired,
   isDeleteCompletelyModal: PropTypes.bool,
   isAbleToDeleteCompletely: PropTypes.bool,
@@ -154,4 +166,4 @@ PageDeleteModal.defaultProps = {
   isDeleteCompletelyModal: false,
 };
 
-export default withTranslation()(PageDeleteModalWrapper);
+export default withTranslation()(PageDeleteModal);

+ 8 - 6
packages/app/src/components/PageDuplicateModal.jsx

@@ -11,7 +11,6 @@ import { withUnstatedContainers } from './UnstatedUtils';
 import { toastError } from '~/client/util/apiNotification';
 
 import AppContainer from '~/client/services/AppContainer';
-import PageContainer from '~/client/services/PageContainer';
 import PagePathAutoComplete from './PagePathAutoComplete';
 import ApiErrorMessageList from './PageManagement/ApiErrorMessageList';
 import ComparePathsTable from './ComparePathsTable';
@@ -20,11 +19,12 @@ import DuplicatePathsTable from './DuplicatedPathsTable';
 const LIMIT_FOR_LIST = 10;
 
 const PageDuplicateModal = (props) => {
-  const { t, appContainer, pageContainer } = props;
+  const {
+    t, appContainer, pageId, path,
+  } = props;
 
   const config = appContainer.getConfig();
   const isReachable = config.isSearchServiceReachable;
-  const { pageId, path } = pageContainer.state;
   const { crowi } = appContainer.config;
 
   const [pageNameInput, setPageNameInput] = useState(path);
@@ -188,7 +188,7 @@ const PageDuplicateModal = (props) => {
             )}
           </div>
           <div>
-            {isDuplicateRecursively && <ComparePathsTable subordinatedPages={subordinatedPages} newPagePath={pageNameInput} />}
+            {isDuplicateRecursively && <ComparePathsTable path={path} subordinatedPages={subordinatedPages} newPagePath={pageNameInput} />}
             {isDuplicateRecursively && existingPaths.length !== 0 && <DuplicatePathsTable existingPaths={existingPaths} oldPagePath={pageNameInput} />}
           </div>
         </div>
@@ -213,16 +213,18 @@ const PageDuplicateModal = (props) => {
 /**
  * Wrapper component for using unstated
  */
-const PageDuplicateModallWrapper = withUnstatedContainers(PageDuplicateModal, [AppContainer, PageContainer]);
+const PageDuplicateModallWrapper = withUnstatedContainers(PageDuplicateModal, [AppContainer]);
 
 
 PageDuplicateModal.propTypes = {
   t: PropTypes.func.isRequired, //  i18next
   appContainer: PropTypes.instanceOf(AppContainer).isRequired,
-  pageContainer: PropTypes.instanceOf(PageContainer).isRequired,
 
   isOpen: PropTypes.bool.isRequired,
   onClose: PropTypes.func.isRequired,
+
+  pageId: PropTypes.string.isRequired,
+  path: PropTypes.string.isRequired,
 };
 
 export default withTranslation()(PageDuplicateModallWrapper);

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

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

@@ -11,7 +11,7 @@ export default class Page extends React.Component {
       page, noLink,
     } = this.props;
 
-    let pagePathElem = <PagePathLabel page={page} additionalClassNames={['mx-1']} />;
+    let pagePathElem = <PagePathLabel path={page.path} additionalClassNames={['mx-1']} />;
     if (!noLink) {
       pagePathElem = <a className="text-break" href={page.path}>{pagePathElem}</a>;
     }

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

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

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

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

+ 26 - 23
packages/app/src/components/PageRenameModal.jsx

@@ -14,7 +14,9 @@ import { withUnstatedContainers } from './UnstatedUtils';
 import { toastError } from '~/client/util/apiNotification';
 
 import AppContainer from '~/client/services/AppContainer';
-import PageContainer from '~/client/services/PageContainer';
+
+import { apiv3Get, apiv3Put } from '~/client/util/apiv3-client';
+
 import ApiErrorMessageList from './PageManagement/ApiErrorMessageList';
 import ComparePathsTable from './ComparePathsTable';
 import DuplicatedPathsTable from './DuplicatedPathsTable';
@@ -22,11 +24,9 @@ import DuplicatedPathsTable from './DuplicatedPathsTable';
 
 const PageRenameModal = (props) => {
   const {
-    t, appContainer, pageContainer,
+    t, appContainer, path, pageId, revisionId,
   } = props;
 
-  const { path } = pageContainer.state;
-
   const { crowi } = appContainer.config;
 
   const [pageNameInput, setPageNameInput] = useState(path);
@@ -37,7 +37,7 @@ const PageRenameModal = (props) => {
   const [existingPaths, setExistingPaths] = useState([]);
   const [isRenameRecursively, SetIsRenameRecursively] = useState(true);
   const [isRenameRedirect, SetIsRenameRedirect] = useState(false);
-  const [isRenameMetadata, SetIsRenameMetadata] = useState(false);
+  const [isRemainMetadata, SetIsRemainMetadata] = useState(false);
   const [subordinatedError] = useState(null);
   const [isRenameRecursivelyWithoutExistPath, setIsRenameRecursivelyWithoutExistPath] = useState(true);
 
@@ -53,13 +53,13 @@ const PageRenameModal = (props) => {
     SetIsRenameRedirect(!isRenameRedirect);
   }
 
-  function changeIsRenameMetadataHandler() {
-    SetIsRenameMetadata(!isRenameMetadata);
+  function changeIsRemainMetadataHandler() {
+    SetIsRemainMetadata(!isRemainMetadata);
   }
 
   const updateSubordinatedList = useCallback(async() => {
     try {
-      const res = await appContainer.apiv3Get('/pages/subordinated-list', { path });
+      const res = await apiv3Get('/pages/subordinated-list', { path });
       const { subordinatedPaths } = res.data;
       setSubordinatedPages(subordinatedPaths);
     }
@@ -67,7 +67,7 @@ const PageRenameModal = (props) => {
       setErrs(err);
       toastError(t('modal_rename.label.Fail to get subordinated pages'));
     }
-  }, [appContainer, path, t]);
+  }, [path, t]);
 
   useEffect(() => {
     if (props.isOpen) {
@@ -78,7 +78,7 @@ const PageRenameModal = (props) => {
 
   const checkExistPaths = async(newParentPath) => {
     try {
-      const res = await appContainer.apiv3Get('/page/exist-paths', { fromPath: path, toPath: newParentPath });
+      const res = await apiv3Get('/page/exist-paths', { fromPath: path, toPath: newParentPath });
       const { existPaths } = res.data;
       setExistingPaths(existPaths);
     }
@@ -112,12 +112,15 @@ const PageRenameModal = (props) => {
     setErrs(null);
 
     try {
-      const response = await pageContainer.rename(
-        pageNameInput,
-        isRenameRecursively,
+      const response = await apiv3Put('/pages/rename', {
+        revisionId,
+        pageId,
+        isRecursively: isRenameRecursively,
         isRenameRedirect,
-        isRenameMetadata,
-      );
+        isRemainMetadata,
+        newPagePath: pageNameInput,
+        path,
+      });
 
       const { page } = response.data;
       const url = new URL(page.path, 'https://dummy');
@@ -192,7 +195,7 @@ const PageRenameModal = (props) => {
               </label>
             </div>
           )}
-          {isRenameRecursively && <ComparePathsTable subordinatedPages={subordinatedPages} newPagePath={pageNameInput} />}
+          {isRenameRecursively && <ComparePathsTable path={path} subordinatedPages={subordinatedPages} newPagePath={pageNameInput} />}
           {isRenameRecursively && existingPaths.length !== 0 && <DuplicatedPathsTable existingPaths={existingPaths} oldPagePath={pageNameInput} />}
         </div>
 
@@ -215,12 +218,12 @@ const PageRenameModal = (props) => {
           <input
             className="custom-control-input"
             name="remain_metadata"
-            id="cbRenameMetadata"
+            id="cbRemainMetadata"
             type="checkbox"
-            checked={isRenameMetadata}
-            onChange={changeIsRenameMetadataHandler}
+            checked={isRemainMetadata}
+            onChange={changeIsRemainMetadataHandler}
           />
-          <label className="custom-control-label" htmlFor="cbRenameMetadata">
+          <label className="custom-control-label" htmlFor="cbRemainMetadata">
             { t('modal_rename.label.Do not update metadata') }
             <p className="form-text text-muted mt-0">{ t('modal_rename.help.metadata') }</p>
           </label>
@@ -244,17 +247,17 @@ const PageRenameModal = (props) => {
 /**
  * Wrapper component for using unstated
  */
-const PageRenameModalWrapper = withUnstatedContainers(PageRenameModal, [AppContainer, PageContainer]);
-
+const PageRenameModalWrapper = withUnstatedContainers(PageRenameModal, [AppContainer]);
 
 PageRenameModal.propTypes = {
   t: PropTypes.func.isRequired, //  i18next
   appContainer: PropTypes.instanceOf(AppContainer).isRequired,
-  pageContainer: PropTypes.instanceOf(PageContainer).isRequired,
 
   isOpen: PropTypes.bool.isRequired,
   onClose: PropTypes.func.isRequired,
 
+  pageId: PropTypes.string.isRequired,
+  revisionId: PropTypes.string.isRequired,
   path: PropTypes.string.isRequired,
 };
 

+ 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',

+ 13 - 12
packages/app/src/components/PutbackPageModal.jsx

@@ -7,15 +7,13 @@ import {
 
 import { withTranslation } from 'react-i18next';
 
-import { withUnstatedContainers } from './UnstatedUtils';
-
-import PageContainer from '~/client/services/PageContainer';
+import { apiPost } from '~/client/util/apiv1-client';
 
 import ApiErrorMessageList from './PageManagement/ApiErrorMessageList';
 
 const PutBackPageModal = (props) => {
   const {
-    t, isOpen, onClose, pageContainer, path,
+    t, isOpen, onClose, pageId, path,
   } = props;
 
   const [errs, setErrs] = useState(null);
@@ -30,7 +28,15 @@ const PutBackPageModal = (props) => {
     setErrs(null);
 
     try {
-      const response = await pageContainer.revertRemove(isPutbackRecursively);
+      // control flag
+      // If is it not true, Request value must be `null`.
+      const recursively = isPutbackRecursively ? true : null;
+
+      const response = await apiPost('/pages.revertRemove', {
+        page_id: pageId,
+        recursively,
+      });
+
       const putbackPagePath = response.page.path;
       window.location.href = encodeURI(putbackPagePath);
     }
@@ -80,20 +86,15 @@ const PutBackPageModal = (props) => {
 
 };
 
-/**
- * Wrapper component for using unstated
- */
-const PutBackPageModalWrapper = withUnstatedContainers(PutBackPageModal, [PageContainer]);
-
 PutBackPageModal.propTypes = {
   t: PropTypes.func.isRequired, //  i18next
-  pageContainer: PropTypes.instanceOf(PageContainer).isRequired,
 
   isOpen: PropTypes.bool.isRequired,
   onClose: PropTypes.func.isRequired,
 
+  pageId: PropTypes.string.isRequired,
   path: PropTypes.string.isRequired,
 };
 
 
-export default withTranslation()(PutBackPageModalWrapper);
+export default withTranslation()(PutBackPageModal);

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

+ 176 - 30
packages/app/src/components/SearchPage.jsx

@@ -6,26 +6,47 @@ import { withTranslation } from 'react-i18next';
 
 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: [],
+      searchResults: [],
       searchResultMeta: {},
+      focusedSearchResultData: null,
+      selectedPages: new Set(),
+      searchResultCount: 0,
+      activePage: 1,
+      pagingLimit: this.props.appContainer.config.pageLimitationL,
+      excludeUserPages: true,
+      excludeTrashPages: true,
     };
 
-    this.search = this.search.bind(this);
     this.changeURL = this.changeURL.bind(this);
+    this.search = this.search.bind(this);
+    this.onSearchInvoked = this.onSearchInvoked.bind(this);
+    this.selectPage = this.selectPage.bind(this);
+    this.toggleCheckBox = this.toggleCheckBox.bind(this);
+    this.switchExcludeUserPagesHandler = this.switchExcludeUserPagesHandler.bind(this);
+    this.switchExcludeTrashPagesHandler = this.switchExcludeTrashPagesHandler.bind(this);
+    this.onPagingNumberChanged = this.onPagingNumberChanged.bind(this);
+    this.onPagingLimitChanged = this.onPagingLimitChanged.bind(this);
   }
 
   componentDidMount() {
@@ -47,6 +68,14 @@ class SearchPage extends React.Component {
     return query;
   }
 
+  switchExcludeUserPagesHandler() {
+    this.setState({ excludeUserPages: !this.state.excludeUserPages });
+  }
+
+  switchExcludeTrashPagesHandler() {
+    this.setState({ excludeTrashPages: !this.state.excludeTrashPages });
+  }
+
   changeURL(keyword, refreshHash) {
     let hash = window.location.hash || '';
     // TODO 整理する
@@ -58,13 +87,53 @@ 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.excludeTrashPages) {
+      query = `${query} -prefix:${specificPathNames.trash}`;
+    }
+    if (this.state.excludeUserPages) {
+      query = `${query} -prefix:${specificPathNames.user}`;
+    }
+
+    return query;
+  }
+
+  /**
+   * this method is called when user changes paging number
+   */
+  async onPagingNumberChanged(activePage) {
+    this.setState({ activePage }, () => this.search({ keyword: this.state.searchedKeyword }));
+  }
+
+  /**
+   * this method is called when user searches by pressing Enter or using searchbox
+   */
+  async onSearchInvoked(data) {
+    this.setState({ activePage: 1 }, () => this.search(data));
+  }
+
+  /**
+   * change number of pages to display per page and execute search method after.
+   */
+  async onPagingLimitChanged(limit) {
+    this.setState({ pagingLimit: limit }, () => this.search({ keyword: this.state.searchedKeyword }));
+  }
+
+  // todo: refactoring
+  // refs: https://redmine.weseek.co.jp/issues/82139
+  async search(data) {
     const keyword = data.keyword;
     if (keyword === '') {
       this.setState({
         searchingKeyword: '',
-        searchedPages: [],
+        searchedKeyword: '',
+        searchResults: [],
         searchResultMeta: {},
+        searchResultCount: 0,
+        activePage: 1,
       });
 
       return true;
@@ -73,37 +142,115 @@ 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,
+          searchResults: res.data,
           searchResultMeta: res.meta,
+          searchResultCount: res.meta.total,
+          focusedSearchResultData: 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,
+          searchResults: [],
+          searchResultMeta: {},
+          searchResultCount: 0,
+          focusedSearchResultData: {},
+          activePage: 1,
+        });
+      }
+    }
+    catch (err) {
+      toastError(err);
+    }
+  }
+
+  selectPage= (pageId) => {
+    const index = this.state.searchResults.findIndex(({ pageData }) => {
+      return pageData._id === pageId;
+    });
+    this.setState({
+      focusedSearchResultData: this.state.searchResults[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}
+        focusedSearchResultData={this.state.focusedSearchResultData}
+      >
+      </SearchResultContent>
+    );
+  }
+
+  renderSearchResultList = () => {
+    return (
+      <SearchResultList
+        pages={this.state.searchResults || []}
+        focusedSearchResultData={this.state.focusedSearchResultData}
+        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.onSearchInvoked}
+        onExcludeUserPagesSwitched={this.switchExcludeUserPagesHandler}
+        onExcludeTrashPagesSwitched={this.switchExcludeTrashPagesHandler}
+        excludeUserPages={this.state.excludeUserPages}
+        excludeTrashPages={this.state.excludeTrashPages}
+      >
+      </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}
+          onPagingLimitChanged={this.onPagingLimitChanged}
+          initialPagingLimit={this.props.appContainer.config.pageLimitationL || 50}
+        >
+        </SearchPageLayout>
       </div>
     );
   }
@@ -118,7 +265,6 @@ const SearchPageWrapper = withUnstatedContainers(SearchPage, [AppContainer]);
 SearchPage.propTypes = {
   t: PropTypes.func.isRequired, // i18next
   appContainer: PropTypes.instanceOf(AppContainer).isRequired,
-
   query: PropTypes.object,
 };
 SearchPage.defaultProps = {

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

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

@@ -0,0 +1,148 @@
+import React, { FC, useState } from 'react';
+import { useTranslation } from 'react-i18next';
+import SearchPageForm from './SearchPageForm';
+import AppContainer from '../../client/services/AppContainer';
+import DeleteSelectedPageGroup from './DeleteSelectedPageGroup';
+import SearchOptionModal from './SearchOptionModal';
+import { CheckboxType } from '../../interfaces/search';
+
+type Props = {
+  searchingKeyword: string,
+  appContainer: AppContainer,
+  excludeUserPages: boolean,
+  excludeTrashPages: boolean,
+  onSearchInvoked: (data: {keyword: string}) => boolean,
+  onExcludeUserPagesSwitched?: () => void,
+  onExcludeTrashPagesSwitched?: () => void,
+}
+
+const SearchControl: FC <Props> = (props: Props) => {
+
+  const [isFileterOptionModalShown, setIsFileterOptionModalShown] = useState(false);
+  // Temporaly workaround for lint error
+  // later needs to be fixed: SearchControl to typescript componet
+  const SearchPageFormTypeAny : any = SearchPageForm;
+  const { t } = useTranslation('');
+
+  const switchExcludeUserPagesHandler = () => {
+    if (props.onExcludeUserPagesSwitched != null) {
+      props.onExcludeUserPagesSwitched();
+    }
+  };
+
+  const switchExcludeTrashPagesHandler = () => {
+    if (props.onExcludeTrashPagesSwitched != null) {
+      props.onExcludeTrashPagesSwitched();
+    }
+  };
+
+  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
+  };
+
+  const openSearchOptionModalHandler = () => {
+    setIsFileterOptionModalShown(true);
+  };
+
+  const closeSearchOptionModalHandler = () => {
+    setIsFileterOptionModalShown(false);
+  };
+
+  const onRetrySearchInvoked = () => {
+    if (props.onSearchInvoked != null) {
+      props.onSearchInvoked({ keyword: props.searchingKeyword });
+    }
+  };
+
+  const rednerSearchOptionModal = () => {
+    return (
+      <SearchOptionModal
+        isOpen={isFileterOptionModalShown || false}
+        onClickFilteringSearchResult={onRetrySearchInvoked}
+        onClose={closeSearchOptionModalHandler}
+        onExcludeUserPagesSwitched={switchExcludeUserPagesHandler}
+        onExcludeTrashPagesSwitched={switchExcludeTrashPagesHandler}
+        excludeUserPages={props.excludeUserPages}
+        excludeTrashPages={props.excludeTrashPages}
+      />
+    );
+  };
+
+  return (
+    <>
+      <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>
+        {/** filter option */}
+        <div className="d-lg-none mr-4">
+          <button
+            type="button"
+            className="btn"
+            onClick={openSearchOptionModalHandler}
+          >
+            <i className="icon-equalizer"></i>
+          </button>
+        </div>
+        <div className="d-none d-lg-flex align-items-center mr-3">
+          <div className="border border-gray mr-3">
+            <label className="px-3 py-2 mb-0 d-flex align-items-center" htmlFor="flexCheckDefault">
+              <input
+                className="mr-2"
+                type="checkbox"
+                id="flexCheckDefault"
+                onClick={switchExcludeUserPagesHandler}
+              />
+              {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={switchExcludeTrashPagesHandler}
+              />
+              {t('Include Subordinated Target Page', { target: '/trash' })}
+            </label>
+          </div>
+        </div>
+      </div>
+      {rednerSearchOptionModal()}
+    </>
+  );
+};
+
+
+export default SearchControl;

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

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

+ 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 = {
 };

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

@@ -0,0 +1,64 @@
+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,
+  initialPagingLimit: number,
+  onPagingLimitChanged: (limit: number) => void
+}
+
+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-center justify-content-between mt-1 mb-3">
+            <div className="search-result-meta text-nowrap mr-3">
+              <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 className="input-group search-result-select-group">
+              <div className="input-group-prepend">
+                <label className="input-group-text text-secondary" htmlFor="inputGroupSelect01">{t('search_result.number_of_list_to_display')}</label>
+              </div>
+              <select className="custom-select" id="inputGroupSelect01" onChange={(e) => { props.onPagingLimitChanged(Number(e.target.value)) }}>
+                {[20, 50, 100, 200].map((limit) => {
+                  return <option selected={limit === props.initialPagingLimit} value={limit}>{limit}{t('search_result.page_number_unit')}</option>;
+                })}
+              </select>
+            </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 == null;
-  }
-
-  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);

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

@@ -0,0 +1,44 @@
+import React, { FC } from 'react';
+
+import { IPageSearchResultData } from '../../interfaces/search';
+
+import RevisionLoader from '../Page/RevisionLoader';
+import AppContainer from '../../client/services/AppContainer';
+import SearchResultContentSubNavigation from './SearchResultContentSubNavigation';
+
+// TODO : set focusedPage type to ?IPageSearchResultData once #80214 is merged
+// PR: https://github.com/weseek/growi/pull/4649
+
+type Props ={
+  appContainer: AppContainer,
+  searchingKeyword:string,
+  focusedSearchResultData : IPageSearchResultData,
+}
+
+
+const SearchResultContent: FC<Props> = (props: Props) => {
+  const page = props.focusedSearchResultData?.pageData;
+  // return if page is null
+  if (page == null) return <></>;
+  const growiRenderer = props.appContainer.getRenderer('searchresult');
+  return (
+    <div key={page._id} className="search-result-page mb-5">
+      <SearchResultContentSubNavigation
+        pageId={page._id}
+        revisionId={page.revision}
+        path={page.path}
+      >
+      </SearchResultContentSubNavigation>
+      <RevisionLoader
+        growiRenderer={growiRenderer}
+        pageId={page._id}
+        pagePath={page.path}
+        revisionId={page.revision}
+        highlightKeywords={props.searchingKeyword}
+      />
+    </div>
+  );
+};
+
+
+export default SearchResultContent;

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

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

+ 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,
+  focusedSearchResultData?: IPageSearchResultData,
+}
+
+const SearchResultList: FC<Props> = (props:Props) => {
+  const { focusedSearchResultData } = props;
+  const focusedPageId = (focusedSearchResultData != null && focusedSearchResultData.pageData != null) ? focusedSearchResultData.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;

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

@@ -0,0 +1,144 @@
+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 { IPageHasId } from '~/interfaces/page';
+
+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 isPathIncludedHtml = pageMeta.elasticSearchResult?.highlightedPath != null || pageData.path != null;
+  const dPagePath = new DevidedPagePath(pageData.path, false, true);
+  const pagePathElem = (
+    <PagePathLabel
+      path={pageMeta.elasticSearchResult?.highlightedPath || pageData.path}
+      isFormerOnly
+      isPathIncludedHtml={isPathIncludedHtml}
+    />
+  );
+
+  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 != null
+                && <div className="mt-1" dangerouslySetInnerHTML={{ __html: pageMeta.elasticSearchResult.snippet }}></div>}
+              </Clamp>
+            </div>
+          </div>
+        </div>
+        {/* TODO: adjust snippet position */}
+      </a>
+    </li>
+  );
+};
+
+export default SearchResultListItem;

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

@@ -180,7 +180,7 @@ class SearchTypeahead extends React.Component {
     return (
       <span>
         <UserPicture user={page.lastUpdateUser} size="sm" noLink />
-        <span className="ml-1 text-break text-wrap"><PagePathLabel page={page} /></span>
+        <span className="ml-1 text-break text-wrap"><PagePathLabel path={page.path} /></span>
         <PageListMeta page={page} />
       </span>
     );

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

+ 37 - 3
packages/app/src/components/Sidebar/PageTree/Item.tsx

@@ -25,6 +25,37 @@ const markTarget = (children: ItemNode[], targetId: string): void => {
   return;
 };
 
+const ItemContol: FC = () => {
+  return (
+    <>
+      <button
+        type="button"
+        className="btn-link nav-link dropdown-toggle dropdown-toggle-no-caret border-0 rounded grw-btn-page-management py-0"
+        data-toggle="dropdown"
+      >
+        <i className="icon-options-vertical text-muted"></i>
+      </button>
+      <button
+        type="button"
+        className="btn-link nav-link dropdown-toggle dropdown-toggle-no-caret border-0 rounded grw-btn-page-management py-0"
+        data-toggle="dropdown"
+      >
+        <i className="icon-plus text-muted"></i>
+      </button>
+    </>
+  );
+};
+
+const ItemCount: FC = () => {
+  return (
+    <>
+      <span className="grw-pagetree-count badge badge-pill badge-light">
+        10
+      </span>
+    </>
+  );
+};
+
 const Item: FC<ItemProps> = (props: ItemProps) => {
   const { itemNode, isOpen: _isOpen = false } = props;
 
@@ -86,11 +117,14 @@ const Item: FC<ItemProps> = (props: ItemProps) => {
         >
           <i className="icon-control-play"></i>
         </button>
-        <a href={page._id} className="flex-grow-1">
+        <a href={page._id} className="grw-pagetree-title-anchor flex-grow-1">
           <p className="grw-pagetree-title m-auto">{nodePath.basename(page.path as string) || '/'}</p>
         </a>
-        <div className="grw-pagetree-control">
-          Ctrl
+        <div className="grw-pagetree-count-wrapper">
+          <ItemCount />
+        </div>
+        <div className="grw-pagetree-control d-none">
+          <ItemContol />
         </div>
       </div>
       {

+ 1 - 3
packages/app/src/components/Sidebar/RecentChanges.tsx

@@ -6,7 +6,7 @@ import PropTypes from 'prop-types';
 
 import { useTranslation } from 'react-i18next';
 
-import { UserPicture } from '@growi/ui';
+import { UserPicture, FootstampIcon } from '@growi/ui';
 import { DevidedPagePath } from '@growi/core';
 
 import PagePathHierarchicalLink from '~/components/PagePathHierarchicalLink';
@@ -15,8 +15,6 @@ import loggerFactory from '~/utils/logger';
 
 import LinkedPagePath from '~/models/linked-page-path';
 
-import FootstampIcon from '../FootstampIcon';
-
 
 import FormattedDistanceDate from '../FormattedDistanceDate';
 

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

+ 1 - 1
packages/app/src/components/User/SeenUserInfo.jsx

@@ -5,13 +5,13 @@ import React, { useState } from 'react';
 import {
   Button, Popover, PopoverBody,
 } from 'reactstrap';
+import { FootstampIcon } from '@growi/ui';
 import UserPictureList from './UserPictureList';
 
 import { withUnstatedContainers } from '../UnstatedUtils';
 
 import PageContainer from '~/client/services/PageContainer';
 
-import FootstampIcon from '../FootstampIcon';
 
 /* eslint react/no-multi-comp: 0, react/prop-types: 0 */
 

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

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

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

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

+ 2 - 0
packages/app/src/interfaces/page.ts

@@ -32,3 +32,5 @@ export type IPage = {
 }
 
 export type IPageForItem = Partial<IPage & {isTarget?: boolean} & HasObjectId>;
+
+export type IPageHasId = IPage & HasObjectId;

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

@@ -0,0 +1,3 @@
+export type IPageTagsInfo = {
+  tags : 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,
+      highlightedPath: string,
+    },
+  },
+}

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

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

@@ -29,11 +29,11 @@ export interface SearchDelegator<T = unknown> {
 }
 
 export type Result<T> = {
-  data: T
+  data: T[]
 }
 
 export type MetaData = {
-  meta?: {
+  meta: {
     [key:string]: any,
     total: number,
   }

+ 1 - 0
packages/app/src/server/models/config.ts

@@ -236,6 +236,7 @@ schema.statics.getLocalconfig = function(crowi) {
     isSearchServiceReachable: crowi.searchService.isReachable,
     isMailerSetup: crowi.mailService.isMailerSetup,
     globalLang: crowi.configManager.getConfig('crowi', 'app:globalLang'),
+    pageLimitationL: crowi.configManager.getConfig('crowi', 'customize:showPageLimitationL'),
   };
 
   return localConfig;

+ 4 - 2
packages/app/src/server/models/obsolete-page.js

@@ -301,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() {
@@ -382,7 +383,7 @@ export const getPageSchema = (crowi) => {
       }
       else {
         logger.debug('liker not updated');
-        return reject(self);
+        return reject(new Error('Already liked'));
       }
     }));
   };
@@ -403,7 +404,7 @@ export const getPageSchema = (crowi) => {
       }
       else {
         logger.debug('liker not updated');
-        return reject(self);
+        return reject(new Error('Already unliked'));
       }
     }));
   };
@@ -426,6 +427,7 @@ export const getPageSchema = (crowi) => {
     const saved = await this.save();
 
     debug('seenUsers updated!', added);
+    pageEvent.emit('addSeenUsers', saved);
 
     return saved;
   };

+ 1 - 0
packages/app/src/server/models/page.ts

@@ -38,6 +38,7 @@ type TargetAndAncestorsResult = {
   rootPage: PageDocument
 }
 export interface PageModel extends Model<PageDocument> {
+  [x: string]: any; // for obsolete methods
   createEmptyPagesByPaths(paths: string[]): Promise<void>
   getParentIdAndFillAncestors(path: string): Promise<string | null>
   findByPathAndViewer(path: string | null, user, userGroups?, useFindOne?): Promise<PageDocument[]>

+ 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

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

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

+ 2 - 54
packages/app/src/server/routes/search.js

@@ -1,7 +1,4 @@
-import { SearchDelegatorName } from '~/interfaces/named-query';
-
 const { default: loggerFactory } = require('~/utils/logger');
-const { serializeUserSecurely } = require('../models/serializers/user-serializer');
 
 const logger = loggerFactory('growi:routes:search');
 
@@ -41,54 +38,6 @@ module.exports = function(crowi, app) {
   const actions = {};
   const api = {};
 
-  // TODO: optimize the way to check isReshapable e.g. check data schema of searchResult
-  // So far, it determines by delegatorName passed by searchService.searchKeyword
-  const checkIsReshapable = (searchResult, delegatorName) => {
-    return delegatorName === SearchDelegatorName.DEFAULT;
-  };
-
-  const reshapeSearchResult = async(searchResult, delegatorName) => {
-    if (!checkIsReshapable(searchResult, delegatorName)) {
-      return searchResult;
-    }
-
-    const result = {};
-
-    // create score map for sorting
-    // key: id , value: score
-    const scoreMap = {};
-    for (const esPage of searchResult.data) {
-      scoreMap[esPage._id] = esPage._score;
-    }
-
-    const ids = searchResult.data.map((page) => { return page._id });
-    const findResult = await Page.findListByPageIds(ids);
-
-    // add tag data to result pages
-    findResult.pages.map((page) => {
-      const data = searchResult.data.find((data) => { return page.id === data._id });
-      page._doc.tags = data._source.tag_names;
-      return page;
-    });
-
-    result.meta = searchResult.meta;
-    result.totalCount = findResult.totalCount;
-    result.data = findResult.pages
-      .map((page) => {
-        if (page.lastUpdateUser != null && page.lastUpdateUser instanceof User) {
-          page.lastUpdateUser = serializeUserSecurely(page.lastUpdateUser);
-        }
-        page.bookmarkCount = (page._source && page._source.bookmark_count) || 0;
-        return page;
-      })
-      .sort((page1, page2) => {
-        // note: this do not consider NaN
-        return scoreMap[page2._id] - scoreMap[page1._id];
-      });
-
-    return result;
-  };
-
   actions.searchPage = function(req, res) {
     const keyword = req.query.q || null;
 
@@ -193,7 +142,7 @@ module.exports = function(crowi, app) {
     let searchResult;
     let delegatorName;
     try {
-      [searchResult, delegatorName] = await searchService.searchKeyword(keyword, user, userGroups, searchOpts); // TODO: separate when not full-text search
+      [searchResult, delegatorName] = await searchService.searchKeyword(keyword, user, userGroups, searchOpts);
     }
     catch (err) {
       logger.error('Failed to search', err);
@@ -202,12 +151,11 @@ module.exports = function(crowi, app) {
 
     let result;
     try {
-      result = await reshapeSearchResult(searchResult, delegatorName);
+      result = await searchService.formatSearchResult(searchResult, delegatorName);
     }
     catch (err) {
       return res.json(ApiResponse.error(err));
     }
-
     return res.json(ApiResponse.success(result));
   };
 

+ 1 - 0
packages/app/src/server/service/page.js

@@ -27,6 +27,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 }) {

+ 23 - 3
packages/app/src/server/service/search-delegator/elasticsearch.ts

@@ -329,6 +329,7 @@ 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,
@@ -337,6 +338,7 @@ class ElasticsearchDelegator implements SearchDelegator<Data> {
       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,
@@ -596,14 +598,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;
     }
@@ -624,7 +631,7 @@ class ElasticsearchDelegator implements SearchDelegator<Data> {
   }
 
   createSearchQuerySortedByScore(option?) {
-    let fields = ['path', 'bookmark_count', 'comment_count', 'updated_at', 'tag_names', 'comments'];
+    let fields = ['path', 'bookmark_count', 'comment_count', 'seenUsers_count', 'updated_at', 'tag_names', 'comments'];
     if (option) {
       fields = option.fields || fields;
     }
@@ -864,6 +871,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;
 

+ 9 - 4
packages/app/src/server/service/search-delegator/private-legacy-pages.ts

@@ -6,9 +6,10 @@ import { IPage } from '~/interfaces/page';
 import {
   MetaData, Result, SearchableData, SearchDelegator,
 } from '../../interfaces/search';
+import { serializeUserSecurely } from '../../models/serializers/user-serializer';
 
 
-class PrivateLegacyPagesDelegator implements SearchDelegator<IPage[]> {
+class PrivateLegacyPagesDelegator implements SearchDelegator<IPage> {
 
   name!: SearchDelegatorName.PRIVATE_LEGACY_PAGES
 
@@ -16,7 +17,7 @@ class PrivateLegacyPagesDelegator implements SearchDelegator<IPage[]> {
     this.name = SearchDelegatorName.PRIVATE_LEGACY_PAGES;
   }
 
-  async search(data: SearchableData | null, user, userGroups, option): Promise<Result<IPage[]> & MetaData> {
+  async search(_data: SearchableData | null, user, userGroups, option): Promise<Result<IPage> & MetaData> {
     const { offset, limit } = option;
 
     if (offset == null || limit == null) {
@@ -32,16 +33,20 @@ class PrivateLegacyPagesDelegator implements SearchDelegator<IPage[]> {
 
     const queryBuilder = new PageQueryBuilder(Page.find());
 
-    const pages: PageDocument[] = await queryBuilder
+    const _pages: PageDocument[] = await queryBuilder
       .addConditionAsNonRootPage()
       .addConditionAsNotMigrated()
       .addConditionToFilteringByViewer(user, userGroups)
       .addConditionToPagenate(offset, limit)
       .query
       .populate('lastUpdateUser')
-      .lean()
       .exec();
 
+    const pages = _pages.map((page) => {
+      page.lastUpdateUser = serializeUserSecurely(page.lastUpdateUser);
+      return page;
+    });
+
     return {
       data: pages,
       meta: {

+ 129 - 0
packages/app/src/server/service/search.ts

@@ -1,4 +1,5 @@
 import RE2 from 're2';
+import xss from 'xss';
 
 import { SearchDelegatorName } from '~/interfaces/named-query';
 
@@ -10,10 +11,22 @@ import ElasticsearchDelegator from './search-delegator/elasticsearch';
 import PrivateLegacyPagesDelegator from './search-delegator/private-legacy-pages';
 
 import loggerFactory from '~/utils/logger';
+import { PageModel } from '../models/page';
+import { serializeUserSecurely } from '../models/serializers/user-serializer';
+import { IPageHasId } from '~/interfaces/page';
 
 // 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, ' ');
@@ -21,6 +34,27 @@ const normalizeQueryString = (_queryString: string): string => {
   return queryString;
 };
 
+export type FormattedSearchResult = {
+  data: {
+    pageData: IPageHasId
+    pageMeta: {
+      bookmarkCount?: number
+      elasticsearchResult?: {
+        snippet: string
+        highlightedPath: string
+      }
+    }
+  }[]
+
+  totalCount: number
+
+  meta: {
+    total: number
+    took?: number
+    count?: number
+  }
+}
+
 class SearchService implements SearchQueryParser, SearchResolver {
 
   crowi!: any
@@ -96,6 +130,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));
@@ -320,6 +355,100 @@ class SearchService implements SearchQueryParser, SearchResolver {
     return terms;
   }
 
+  // TODO: optimize the way to check isFormattable e.g. check data schema of searchResult
+  // So far, it determines by delegatorName passed by searchService.searchKeyword
+  checkIsFormattable(searchResult, delegatorName): boolean {
+    return delegatorName === SearchDelegatorName.DEFAULT;
+  }
+
+  /**
+   * formatting result
+   */
+  async formatSearchResult(searchResult: Result<any> & MetaData, delegatorName): Promise<FormattedSearchResult> {
+    if (!this.checkIsFormattable(searchResult, delegatorName)) {
+      const data = searchResult.data.map((page) => {
+        return {
+          pageData: page,
+          pageMeta: {},
+        };
+      });
+
+      return {
+        data,
+        totalCount: data.length,
+        meta: searchResult.meta,
+      };
+    }
+
+    /*
+     * Format ElasticSearch result
+     */
+
+    const Page = this.crowi.model('Page') as PageModel;
+    const User = this.crowi.model('User');
+    const result = {} as FormattedSearchResult;
+
+    // create score map for sorting
+    // key: id , value: score
+    const scoreMap = {};
+    for (const esPage of searchResult.data) {
+      scoreMap[esPage._id] = esPage._score;
+    }
+
+    const ids = searchResult.data.map((page) => { return page._id });
+    const findResult = await Page.findListByPageIds(ids);
+
+    // add tags data to page
+    findResult.pages.map((pageData) => {
+      const data = searchResult.data.find((data) => {
+        return pageData.id === data._id;
+      });
+      pageData._doc.tags = data._source.tag_names;
+      return pageData;
+    });
+
+    result.meta = searchResult.meta;
+    result.totalCount = findResult.totalCount;
+    result.data = findResult.pages
+      .map((pageData) => {
+        if (pageData.lastUpdateUser != null && pageData.lastUpdateUser instanceof User) {
+          pageData.lastUpdateUser = serializeUserSecurely(pageData.lastUpdateUser);
+        }
+
+        const data = searchResult.data.find((data) => {
+          return pageData.id === data._id;
+        });
+
+        // increment elasticSearchResult
+        let elasticSearchResult;
+        const highlightData = data._highlight;
+        if (highlightData != null) {
+          const snippet = highlightData['body.en'] || highlightData['body.ja'] || '';
+          const pathMatch = highlightData['path.en'] || highlightData['path.ja'] || '';
+
+          elasticSearchResult = {
+            snippet: filterXss.process(snippet),
+            highlightedPath: filterXss.process(pathMatch),
+          };
+        }
+
+        const pageMeta = {
+          bookmarkCount: data._source.bookmark_count || 0,
+          elasticSearchResult,
+        };
+
+        pageData._doc.seenUserCount = (pageData.seenUsers && pageData.seenUsers.length) || 0;
+
+        return { pageData, pageMeta };
+      })
+      .sort((page1, page2) => {
+        // note: this do not consider NaN
+        return scoreMap[page2.pageData._id] - scoreMap[page1.pageData._id];
+      });
+
+    return result;
+  }
+
 }
 
 export default SearchService;

+ 2 - 0
packages/app/src/server/views/layout-growi/base/layout.html

@@ -9,6 +9,8 @@
 {% block layout_main %}
 <div class="h-100 d-flex flex-column justify-content-between">
 
+  <div id="growi-context-extractor"></div>
+
   {% block content_header_wrapper %}
     <header class="py-0">
       {% block content_header %}

+ 1 - 1
packages/app/src/server/views/search.html

@@ -17,7 +17,7 @@
 <div class="container-fluid">
 
   <div class="row">
-    <div id="main" class="main col-lg-12 search-page">
+    <div id="main" class="main col-lg-12 search-page mt-0">
       <div class="" id="search-page"></div>
     </div>
   </div>

+ 16 - 0
packages/app/src/stores/bookmark.ts

@@ -0,0 +1,16 @@
+import useSWR, { SWRResponse } from 'swr';
+import { apiv3Get } from '../client/util/apiv3-client';
+import { IBookmarkInfo } from '../interfaces/bookmark-info';
+
+
+export const useSWRBookmarkInfo = (pageId: string): SWRResponse<IBookmarkInfo, Error> => {
+  return useSWR(
+    `/bookmarks/info?pageId=${pageId}`,
+    endpoint => apiv3Get(endpoint).then((response) => {
+      return {
+        sumOfBookmarks: response.data.sumOfBookmarks,
+        isBookmarked: response.data.isBookmarked,
+      };
+    }),
+  );
+};

+ 24 - 0
packages/app/src/stores/page.tsx

@@ -5,7 +5,10 @@ import { HasObjectId } from '~/interfaces/has-object-id';
 
 import { IPage } from '~/interfaces/page';
 import { IPagingResult } from '~/interfaces/paging-result';
+import { apiGet } from '../client/util/apiv1-client';
 
+import { IPageTagsInfo } from '../interfaces/pageTagsInfo';
+import { IPageInfo } from '../interfaces/page-info';
 
 export const useSWRxPageByPath = (path: string, initialData?: IPage): SWRResponse<IPage & HasObjectId, Error> => {
   return useSWR(
@@ -43,3 +46,24 @@ export const useSWRxPageList = (
     }),
   );
 };
+
+export const useSWRPageInfo = (pageId: string): SWRResponse<IPageInfo, Error> => {
+  return useSWR(`/page/info?pageId=${pageId}`, endpoint => apiv3Get(endpoint).then((response) => {
+    return {
+      sumOfLikers: response.data.sumOfLikers,
+      likerIds: response.data.likerIds,
+      seenUserIds: response.data.seenUserIds,
+      sumOfSeenUsers: response.data.sumOfSeenUsers,
+      isSeen: response.data.isSeen,
+      isLiked: response.data?.isLiked,
+    };
+  }));
+};
+
+export const useSWRTagsInfo = (pageId: string): SWRResponse<IPageTagsInfo, Error> => {
+  return useSWR(`/pages.getPageTag?pageId=${pageId}`, endpoint => apiGet(endpoint).then((response: IPageTagsInfo) => {
+    return {
+      tags: response.tags,
+    };
+  }));
+};

+ 39 - 10
packages/app/src/stores/ui.tsx

@@ -59,19 +59,43 @@ export const useIsMobile = (): SWRResponse<boolean|null, Error> => {
   return useStaticSWR(key, null, configuration);
 };
 
-// drawer mode keys
-const IS_DRAWER_MODE: Key = 'isDrawerMode';
 
-export const mutateDrawerMode: Middleware = (useSWRNext) => {
+const postChangeEditorModeMiddleware: Middleware = (useSWRNext) => {
   return (...args) => {
-    const { mutate } = useSWRConfig();
+    // -- TODO: https://redmine.weseek.co.jp/issues/81817
     const swrNext = useSWRNext(...args);
     return {
       ...swrNext,
       mutate: (data, shouldRevalidate) => {
         return swrNext.mutate(data, shouldRevalidate)
           .then((value) => {
-            mutate(IS_DRAWER_MODE); // mutate isDrawerMode
+            const newEditorMode = value as unknown as EditorMode;
+            switch (newEditorMode) {
+              case EditorMode.View:
+                $('body').removeClass('on-edit');
+                $('body').removeClass('builtin-editor');
+                $('body').removeClass('hackmd');
+                $('body').removeClass('pathname-sidebar');
+                window.history.replaceState(null, '', window.location.pathname);
+                break;
+              case EditorMode.Editor:
+                $('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';
+                break;
+              case EditorMode.HackMD:
+                $('body').addClass('on-edit');
+                $('body').addClass('hackmd');
+                $('body').removeClass('builtin-editor');
+                $('body').removeClass('pathname-sidebar');
+                window.location.hash = '#hackmd';
+                break;
+            }
             return value;
           });
       },
@@ -83,7 +107,7 @@ export const useEditorMode = (editorMode?: EditorMode): SWRResponse<EditorMode,
   const key: Key = 'editorMode';
   const initialData = EditorMode.View;
 
-  return useStaticSWR(key, editorMode || null, { fallbackData: initialData, use: [mutateDrawerMode] });
+  return useStaticSWR(key, editorMode || null, { fallbackData: initialData, use: [postChangeEditorModeMiddleware] });
 };
 
 export const useIsDeviceSmallerThanMd = (): SWRResponse<boolean|null, Error> => {
@@ -107,7 +131,7 @@ export const useIsDeviceSmallerThanMd = (): SWRResponse<boolean|null, Error> =>
     }
   }
 
-  return useStaticSWR(key, null, { use: [mutateDrawerMode] });
+  return useStaticSWR(key);
 };
 
 export const usePreferDrawerModeByUser = (isPrefered?: boolean): SWRResponse<boolean, Error> => {
@@ -115,7 +139,7 @@ export const usePreferDrawerModeByUser = (isPrefered?: boolean): SWRResponse<boo
   const key: Key = data === undefined ? null : 'preferDrawerModeByUser';
   const initialData = data?.preferDrawerModeByUser;
 
-  return useStaticSWR(key, isPrefered || null, { fallbackData: initialData, use: [mutateDrawerMode, sessionStorageMiddleware] });
+  return useStaticSWR(key, isPrefered || null, { fallbackData: initialData, use: [sessionStorageMiddleware] });
 };
 
 export const usePreferDrawerModeOnEditByUser = (isPrefered?: boolean): SWRResponse<boolean, Error> => {
@@ -123,7 +147,7 @@ export const usePreferDrawerModeOnEditByUser = (isPrefered?: boolean): SWRRespon
   const key: Key = data === undefined ? null : 'preferDrawerModeOnEditByUser';
   const initialData = data?.preferDrawerModeOnEditByUser;
 
-  return useStaticSWR(key, isPrefered || null, { fallbackData: initialData, use: [mutateDrawerMode, sessionStorageMiddleware] });
+  return useStaticSWR(key, isPrefered || null, { fallbackData: initialData, use: [sessionStorageMiddleware] });
 };
 
 export const useDrawerMode = (): SWRResponse<boolean, Error> => {
@@ -145,7 +169,7 @@ export const useDrawerMode = (): SWRResponse<boolean, Error> => {
   };
 
   return useSWR(
-    condition ? [IS_DRAWER_MODE, editorMode, preferDrawerModeByUser, preferDrawerModeOnEditByUser, isDeviceSmallerThanMd] : null,
+    condition ? [editorMode, preferDrawerModeByUser, preferDrawerModeOnEditByUser, isDeviceSmallerThanMd] : null,
     calcDrawerMode,
     {
       fallback: calcDrawerMode,
@@ -209,3 +233,8 @@ export const useSidebarResizeDisabled = (isDisabled?: boolean): SWRResponse<bool
   const initialData = false;
   return useStaticSWR('isSidebarResizeDisabled', isDisabled || null, { fallbackData: initialData });
 };
+
+export const usePageCreateModalOpened = (isOpened?: boolean): SWRResponse<boolean, Error> => {
+  const initialData = false;
+  return useStaticSWR('isPageCreateModalOpened', isOpened || null, { fallbackData: initialData });
+};

+ 10 - 0
packages/app/src/stores/user.tsx

@@ -0,0 +1,10 @@
+import useSWR, { SWRResponse } from 'swr';
+import { IUser } from '../interfaces/user';
+import { apiGet } from '../client/util/apiv1-client';
+
+export const useSWRxLikerList = (likerIds: string[] = []): SWRResponse<IUser[], Error> => {
+  const shouldFetch = likerIds.length > 0;
+  return useSWR(shouldFetch ? ['/users.list', [...likerIds].join(',')] : null, (endpoint:string, userIds:string) => {
+    return apiGet(endpoint, { user_ids: userIds }).then((response:any) => response.users);
+  });
+};

+ 25 - 4
packages/app/src/styles/_page-tree.scss

@@ -7,6 +7,14 @@
   .grw-pagetree-item {
     &:hover {
       opacity: 0.7;
+
+      .grw-pagetree-control {
+        display: flex !important;
+      }
+
+      .grw-pagetree-count {
+        display: none;
+      }
     }
 
     .grw-pagetree-button {
@@ -17,13 +25,26 @@
       }
     }
 
-    .grw-pagetree-title {
-      font-size: medium;
+    .grw-pagetree-title-anchor {
+      width: 100%;
+      overflow: hidden;
+
+      .grw-pagetree-title {
+        overflow: hidden;
+        font-size: medium;
+        text-overflow: ellipsis;
+      }
     }
 
-    .grw-pagetree-control {
+    .grw-pagetree-count-wrapper {
+      display: inline-block;
+
       &:hover {
-        display: inline-block;
+        display: none;
+      }
+
+      .grw-pagetree-count {
+        padding: 0.3rem 1rem;
       }
     }
   }

+ 48 - 18
packages/app/src/styles/_search.scss

@@ -1,6 +1,17 @@
-.search-listpage-icon {
-  font-size: 16px;
-  color: $gray-400;
+.search-page-nav {
+  background-color: #f7f7f7;
+}
+
+.search-group-submit-button {
+  position: absolute;
+  top: 0;
+  right: 0;
+  z-index: 3;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  width: 32px;
+  height: 32px;
 }
 
 .search-listpage-clear {
@@ -102,17 +113,19 @@
   }
 
   .btn-group-submit-search {
-    position: absolute;
-    top: 0;
-    right: 0;
+    @extend .search-group-submit-button;
+  }
+}
 
-    z-index: 3;
+.grw-search-form-in-search-result-page {
+  .btn-group-submit-search {
+    @extend .search-group-submit-button;
+  }
 
-    display: flex;
-    align-items: center;
-    justify-content: center;
-    width: 32px;
-    height: 32px;
+  button {
+    &:focus {
+      box-shadow: none !important;
+    }
   }
 }
 
@@ -158,14 +171,15 @@
 .search-result {
   .search-result-list {
     position: sticky;
-    top: 64px;
+    top: 0px;
     height: 100vh;
     overflow-y: scroll;
 
     .nav.nav-pills {
-      > li {
+      > .page-list-li {
         > a {
-          padding: 2px 8px;
+          height: 123px;
+          padding: 2px 4px;
           word-break: break-all;
           border-radius: 0;
 
@@ -175,20 +189,31 @@
           }
           &.active {
             padding-right: 5px;
-            border-right: solid 3px transparent;
+            border-left: solid 3px transparent;
           }
           > * {
             margin-right: 3px;
           }
         }
+        .page-list-meta {
+          > span {
+            margin-right: 12px;
+          }
+          .footstamp-icon {
+            margin-right: 2px;
+          }
+        }
       }
     }
 
     .search-result-meta {
-      margin-bottom: 10px;
       font-weight: bold;
     }
-
+    .search-result-select-group {
+      > select {
+        max-width: 8rem;
+      }
+    }
     .search-result-list-delete-checkbox {
       margin: 0 10px 0 0;
       vertical-align: middle;
@@ -222,6 +247,7 @@
   }
 }
 
+// 2021/9/22 TODO: Remove after moving to SearchResult
 .search-page-input {
   position: sticky;
   top: 15px;
@@ -243,6 +269,10 @@
   }
 }
 
+.search-page-item {
+  height: 130px;
+}
+
 @include media-breakpoint-down(sm) {
   .grw-search-table {
     th {

+ 9 - 1
packages/app/src/styles/_sidebar.scss

@@ -262,7 +262,11 @@
 @mixin drawer() {
   z-index: $zindex-fixed + 2;
 
-  // override @atlaskit/navigation-next styles
+  .data-layout-container {
+    position: fixed;
+    top: 0;
+    width: 0;
+  }
   div.navigation {
     max-width: 80vw;
 
@@ -286,6 +290,10 @@
     }
   }
 
+  .grw-navigation-resize-button {
+    display: none;
+  }
+
   .grw-drawer-toggler {
     position: fixed;
     right: -15px;

+ 15 - 9
packages/app/src/styles/theme/_apply-colors.scss

@@ -17,6 +17,9 @@ $bordercolor-nav-tabs-active: $bordercolor-nav-tabs $bordercolor-nav-tabs $bgcol
 $color-seen-user: #549c79 !default;
 $color-btn-reload-in-sidebar: $gray-500;
 $bgcolor-keyword-highlighted: $grw-marker-yellow !default;
+$bordercolor-search-item-left-active: $primary;
+$bgcolor-search-item-active: lighten($bordercolor-search-item-left-active, 76%) !default;
+$color-search-item-pagelist-meta: $gray-500 !default;
 
 // override bootstrap variables
 $body-bg: $bgcolor-global;
@@ -597,20 +600,23 @@ body.pathname-sidebar {
 .search-result {
   .search-result-list {
     .page-list {
+      .highlighted-keyword {
+        background-color: $bgcolor-keyword-highlighted;
+      }
       .page-list-ul {
-        > li.nav-item > a.nav-link {
-          color: inherit;
-        }
-        a {
-          &.hover {
-            background-color: darken($bgcolor-global, 4%);
-          }
+        .page-list-li {
           &.active {
-            background-color: darken($bgcolor-global, 8%);
-            border-color: theme-color('primary');
+            background-color: $bgcolor-search-item-active;
+            border-color: $bordercolor-search-item-left-active;
           }
         }
       }
+      .page-list-meta {
+        color: $color-search-item-pagelist-meta;
+        svg {
+          fill: $color-search-item-pagelist-meta;
+        }
+      }
     }
   }
 }

+ 2 - 2
packages/core/src/models/devided-page-path.js

@@ -2,8 +2,8 @@ import * as pathUtils from '../utils/path-utils';
 
 // https://regex101.com/r/BahpKX/2
 const PATTERN_INCLUDE_DATE = /^(.+\/[^/]+)\/(\d{4}|\d{4}\/\d{2}|\d{4}\/\d{2}\/\d{2})$/;
-// https://regex101.com/r/WVpPpY/1
-const PATTERN_DEFAULT = /^((.*)\/)?([^/]+)$/;
+// https://regex101.com/r/HJNvMW/1
+const PATTERN_DEFAULT = /^((.*)(?<!<)\/)?(.+)$/;
 
 export class DevidedPagePath {
 

+ 1 - 1
packages/plugin-lsx/src/client/js/components/LsxPageList/PagePathWrapper.jsx

@@ -13,7 +13,7 @@ export class PagePathWrapper extends React.Component {
     }
 
     return (
-      <PagePathLabel page={{ path: this.props.pagePath }} isLatterOnly additionalClassNames={classNames} />
+      <PagePathLabel path={this.props.pagePath} isLatterOnly additionalClassNames={classNames} />
     );
   }
 

+ 19 - 3
packages/ui/src/components/PagePath/PageListMeta.jsx

@@ -1,6 +1,7 @@
 import React from 'react';
 import PropTypes from 'prop-types';
 import { templateChecker, pagePathUtils } from '@growi/core';
+import { FootstampIcon } from '../SearchPage/FootstampIcon';
 
 const { isTopPage } = pagePathUtils;
 const { checkTemplatePath } = templateChecker;
@@ -37,13 +38,30 @@ export class PageListMeta extends React.Component {
       locked = <span><i className="icon-lock" /></span>;
     }
 
+    let seenUserCount;
+    if (page.seenUserCount > 0) {
+      seenUserCount = (
+        <span>
+          <i className="footstamp-icon"><FootstampIcon /></i>
+          {page.seenUsers.length}
+        </span>
+      );
+    }
+
+    let bookmarkCount;
+    if (this.props.bookmarkCount > 0) {
+      bookmarkCount = <span><i className="icon-star" />{this.props.bookmarkCount}</span>;
+    }
+
     return (
       <span className="page-list-meta">
         {topLabel}
         {templateLabel}
+        {seenUserCount}
         {commentCount}
         {likerCount}
         {locked}
+        {bookmarkCount}
       </span>
     );
   }
@@ -52,7 +70,5 @@ export class PageListMeta extends React.Component {
 
 PageListMeta.propTypes = {
   page: PropTypes.object.isRequired,
-};
-
-PageListMeta.defaultProps = {
+  bookmarkCount: PropTypes.number,
 };

+ 0 - 32
packages/ui/src/components/PagePath/PagePathLabel.jsx

@@ -1,32 +0,0 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-
-import { DevidedPagePath } from '@growi/core';
-
-export const PagePathLabel = (props) => {
-
-  const dPagePath = new DevidedPagePath(props.page.path, false, true);
-
-  let classNames = [''];
-  classNames = classNames.concat(props.additionalClassNames);
-
-  if (props.isLatterOnly) {
-    return <span className={classNames.join(' ')}>{dPagePath.latter}</span>;
-  }
-
-  const textElem = dPagePath.isRoot
-    ? <><strong>/</strong></>
-    : <>{dPagePath.former}/<strong>{dPagePath.latter}</strong></>;
-
-  return <span className={classNames.join(' ')}>{textElem}</span>;
-};
-
-PagePathLabel.propTypes = {
-  page: PropTypes.object.isRequired,
-  isLatterOnly: PropTypes.bool,
-  additionalClassNames: PropTypes.arrayOf(PropTypes.string),
-};
-
-PagePathLabel.defaultProps = {
-  additionalClassNames: [],
-};

+ 56 - 0
packages/ui/src/components/PagePath/PagePathLabel.tsx

@@ -0,0 +1,56 @@
+import React, { FC } from 'react';
+
+import { DevidedPagePath } from '@growi/core';
+
+
+type TextElemProps = {
+  children?: React.ReactNode
+  isHTML?: boolean,
+}
+
+const TextElement: FC<TextElemProps> = (props: TextElemProps) => (
+  <>
+    { props.isHTML
+      // eslint-disable-next-line react/no-danger
+      ? <span dangerouslySetInnerHTML={{ __html: props.children?.toString() || '' }}></span>
+      : <>{props.children}</>
+    }
+  </>
+);
+
+
+type Props = {
+  path: string,
+  isLatterOnly?: boolean,
+  isFormerOnly?: boolean,
+  isPathIncludedHtml?: boolean,
+  additionalClassNames?: string[],
+}
+
+export const PagePathLabel: FC<Props> = (props:Props) => {
+  const {
+    isLatterOnly, isFormerOnly, isPathIncludedHtml, additionalClassNames, path,
+  } = props;
+
+  const dPagePath = new DevidedPagePath(path, false, true);
+
+  const classNames = additionalClassNames || [];
+
+  let textElem;
+
+  if (isLatterOnly) {
+    textElem = <TextElement isHTML={isPathIncludedHtml}>{dPagePath.latter}</TextElement>;
+  }
+  else if (isFormerOnly) {
+    textElem = dPagePath.isFormerRoot
+      ? <>/</>
+      : <TextElement isHTML={isPathIncludedHtml}>{dPagePath.former}</TextElement>;
+  }
+  else {
+    textElem = dPagePath.isRoot
+      ? <strong>/</strong>
+      : <TextElement isHTML={isPathIncludedHtml}>{dPagePath.former}/<strong>{dPagePath.latter}</strong></TextElement>;
+  }
+
+  return <span className={classNames.join(' ')}>{textElem}</span>;
+};

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