Taichi Masuyama 4 лет назад
Родитель
Сommit
9bfefd1786
33 измененных файлов с 919 добавлено и 536 удалено
  1. 1 0
      packages/app/config/logger/config.dev.js
  2. 1 0
      packages/app/package.json
  3. 6 2
      packages/app/resource/locales/en_US/translation.json
  4. 6 2
      packages/app/resource/locales/ja_JP/translation.json
  5. 6 2
      packages/app/resource/locales/zh_CN/translation.json
  6. 3 0
      packages/app/resource/search/mappings.json
  7. 23 29
      packages/app/src/components/PaginationWrapper.tsx
  8. 2 1
      packages/app/src/components/SearchForm.jsx
  9. 163 25
      packages/app/src/components/SearchPage.jsx
  10. 62 0
      packages/app/src/components/SearchPage/DeleteSelectedPageGroup.tsx
  11. 42 0
      packages/app/src/components/SearchPage/IncludeSpecificPathButton.jsx
  12. 104 0
      packages/app/src/components/SearchPage/SearchControl.tsx
  13. 31 13
      packages/app/src/components/SearchPage/SearchPageForm.jsx
  14. 52 0
      packages/app/src/components/SearchPage/SearchPageLayout.tsx
  15. 0 350
      packages/app/src/components/SearchPage/SearchResult.jsx
  16. 50 0
      packages/app/src/components/SearchPage/SearchResultContent.tsx
  17. 0 64
      packages/app/src/components/SearchPage/SearchResultList.jsx
  18. 49 0
      packages/app/src/components/SearchPage/SearchResultList.tsx
  19. 140 0
      packages/app/src/components/SearchPage/SearchResultListItem.tsx
  20. 12 5
      packages/app/src/interfaces/page.ts
  21. 18 0
      packages/app/src/interfaces/search.ts
  22. 3 1
      packages/app/src/server/events/page.js
  23. 2 0
      packages/app/src/server/models/page.js
  24. 24 12
      packages/app/src/server/routes/search.js
  25. 1 0
      packages/app/src/server/service/page.js
  26. 24 4
      packages/app/src/server/service/search-delegator/elasticsearch.js
  27. 29 0
      packages/app/src/server/service/search.js
  28. 1 1
      packages/app/src/server/views/search.html
  29. 35 16
      packages/app/src/styles/_search.scss
  30. 8 9
      packages/app/src/styles/theme/_apply-colors.scss
  31. 8 0
      packages/ui/src/components/PagePath/PageListMeta.jsx
  32. 8 0
      packages/ui/src/components/PagePath/PagePathLabel.jsx
  33. 5 0
      yarn.lock

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

+ 1 - 0
packages/app/package.json

@@ -133,6 +133,7 @@
     "prom-client": "^13.0.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",

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

@@ -64,6 +64,7 @@
   "Include Attachment File": "Include Attachment File",
   "Include Comment": "Include Comment",
   "Include Subordinated Page": "Include Subordinated Page",
+  "Include Subordinated Target Page": "include {{target}}",
   "All Subordinated Page": "All Subordinated Page",
   "Specify Hierarchy": "Specify Hierarchy",
   "Submitted the request to create the archive": "Submitted the request to create the archive",
@@ -147,6 +148,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",
   "original_path":"Original path",
@@ -566,13 +568,15 @@
     "popover_desc": "Input channel name. You can notify multiple channels by entering a comma-separated list."
   },
   "search_result": {
-    "result_meta": "Found \"{{keyword}}\" in {{total}}.",
+    "result_meta": "Search results for:",
     "deletion_mode_btn_lavel": "Select and delete page",
     "cancel": "Cancel",
     "delete": "Delete",
     "check_all": "Check all",
     "deletion_modal_header": "Delete page",
-    "delete_completely": "Delete completely"
+    "delete_completely": "Delete completely",
+    "include_certain_path" : "Include {{pathToInclude}} path ",
+    "delete_all_selected_page" : "Delete All"
   },
   "security_setting": {
     "Guest Users Access": "Guest users access",

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

@@ -0,0 +1,50 @@
+import React, { FC } from 'react';
+
+import RevisionLoader from '../Page/RevisionLoader';
+import AppContainer from '../../client/services/AppContainer';
+
+
+type Props ={
+  appContainer: AppContainer,
+  searchingKeyword:string,
+  focusedPage : any,
+}
+const SearchResultContent: FC<Props> = (props: Props) => {
+  // Temporaly workaround for lint error
+  // later needs to be fixed: RevisoinRender to typescriptcomponet
+  const RevisionRenderTypeAny: any = RevisionLoader;
+  const renderPage = (page) => {
+    const growiRenderer = props.appContainer.getRenderer('searchresult');
+    let showTags = false;
+    if (page.tags != null && page.tags.length > 0) { showTags = true }
+    return (
+      <div key={page._id} className="search-result-page mb-5">
+        <h2>
+          <a href={page.path} className="text-break">
+            {page.path}
+          </a>
+          {showTags && (
+            <div className="mt-1 small">
+              <i className="tag-icon icon-tag"></i> {page.tags.join(', ')}
+            </div>
+          )}
+        </h2>
+        <RevisionRenderTypeAny
+          growiRenderer={growiRenderer}
+          pageId={page._id}
+          pagePath={page.path}
+          revisionId={page.revision}
+          highlightKeywords={props.searchingKeyword}
+        />
+      </div>
+    );
+  };
+  const content = renderPage(props.focusedPage);
+  return (
+
+    <div>{content}</div>
+  );
+};
+
+
+export default SearchResultContent;

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

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

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

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

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

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

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

@@ -5,10 +5,17 @@ import { ITag } from './tag';
 export type IPage = {
   path: string,
   status: string,
-  revision: IRevision,
-  tags: ITag[],
-  creator: IUser,
+  revision: string | IRevision,
+  tags?: ITag[],
+  lastUpdateUser: any,
+  commentCount: number,
+  creator: string | IUser,
+  seenUsers: string[],
+  liker: string[],
   createdAt: Date,
   updatedAt: Date,
-  seenUsers: string[]
-}
+};
+
+export type IPageHasId = IPage & {
+  _id: string,
+};

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

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

+ 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 - 0
packages/app/src/server/models/page.js

@@ -299,6 +299,7 @@ module.exports = function(crowi) {
     pageEvent.on('create', pageEvent.onCreate);
     pageEvent.on('update', pageEvent.onUpdate);
     pageEvent.on('createMany', pageEvent.onCreateMany);
+    pageEvent.on('addSeenUsers', pageEvent.onAddSeenUsers);
   }
 
   function validateCrowi() {
@@ -424,6 +425,7 @@ module.exports = function(crowi) {
     const saved = await this.save();
 
     debug('seenUsers updated!', added);
+    pageEvent.emit('addSeenUsers', saved);
 
     return saved;
   };

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

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

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

@@ -25,6 +25,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);
   }
 
   /**

+ 24 - 4
packages/app/src/server/service/search-delegator/elasticsearch.js

@@ -309,6 +309,7 @@ class ElasticsearchDelegator {
     };
 
     const bookmarkCount = page.bookmarkCount || 0;
+    const seenUsersCount = page.seenUsers.length || 0;
     let document = {
       path: page.path,
       body: page.revision.body,
@@ -317,6 +318,7 @@ class ElasticsearchDelegator {
       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,
@@ -576,14 +578,19 @@ class ElasticsearchDelegator {
         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;
     }
@@ -604,7 +611,7 @@ class ElasticsearchDelegator {
   }
 
   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'];
     if (option) {
       fields = option.fields || fields;
     }
@@ -882,6 +889,19 @@ class ElasticsearchDelegator {
     };
   }
 
+  appendHighlight(query) {
+    query.body.highlight = {
+      fields: {
+        '*': {
+          fragment_size: 40,
+          fragmenter: 'simple',
+          pre_tags: ["<em class='highlighted-keyword'>"],
+          post_tags: ['</em>'],
+        },
+      },
+    };
+  }
+
   async searchKeyword(queryString, user, userGroups, option) {
     const from = option.offset || null;
     const size = option.limit || null;
@@ -895,7 +915,7 @@ class ElasticsearchDelegator {
     this.appendResultSize(query, from, size);
 
     this.appendFunctionScore(query, queryString);
-
+    this.appendHighlight(query);
     return this.search(query);
   }
 

+ 29 - 0
packages/app/src/server/service/search.js

@@ -2,6 +2,16 @@ import loggerFactory from '~/utils/logger';
 
 // eslint-disable-next-line no-unused-vars
 const logger = loggerFactory('growi:service:search');
+const xss = require('xss');
+
+// options for filtering xss
+const filterXssOptions = {
+  whiteList: {
+    em: ['class'],
+  },
+};
+
+const filterXss = new xss.FilterXSS(filterXssOptions);
 
 class SearchService {
 
@@ -68,6 +78,7 @@ class SearchService {
     pageEvent.on('delete', this.delegator.syncPageDeleted.bind(this.delegator));
     pageEvent.on('updateMany', this.delegator.syncPagesUpdated.bind(this.delegator));
     pageEvent.on('syncDescendants', this.delegator.syncDescendantsPagesUpdated.bind(this.delegator));
+    pageEvent.on('addSeenUsers', this.delegator.syncPageUpdated.bind(this.delegator));
 
     const bookmarkEvent = this.crowi.event('bookmark');
     bookmarkEvent.on('create', this.delegator.syncBookmarkChanged.bind(this.delegator));
@@ -153,6 +164,24 @@ class SearchService {
     }
   }
 
+  /**
+   * formatting result
+   */
+  formatResult(esResult) {
+    esResult.data.forEach((data) => {
+      const highlightData = data._highlight;
+      const snippet = highlightData['body.en'] || highlightData['body.ja'] || '';
+      const pathMatch = highlightData['path.en'] || highlightData['path.ja'] || '';
+
+      data.elasticSearchResult = {
+        snippet: filterXss.process(snippet),
+        // todo: use filter xss.process() for matchedPath;
+        matchedPath: pathMatch,
+      };
+    });
+    return esResult;
+  }
+
 }
 
 module.exports = SearchService;

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

+ 35 - 16
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,7 +189,7 @@
           }
           &.active {
             padding-right: 5px;
-            border-right: solid 3px transparent;
+            border-left: solid 3px transparent;
           }
           > * {
             margin-right: 3px;
@@ -222,6 +236,7 @@
   }
 }
 
+// 2021/9/22 TODO: Remove after moving to SearchResult
 .search-page-input {
   position: sticky;
   top: 15px;
@@ -243,6 +258,10 @@
   }
 }
 
+.search-page-item {
+  height: 130px;
+}
+
 @include media-breakpoint-down(sm) {
   .grw-search-table {
     th {

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

@@ -17,6 +17,8 @@ $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%);
 
 // override bootstrap variables
 $body-bg: $bgcolor-global;
@@ -587,17 +589,14 @@ 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;
           }
         }
       }

+ 8 - 0
packages/ui/src/components/PagePath/PageListMeta.jsx

@@ -37,6 +37,12 @@ export class PageListMeta extends React.Component {
       locked = <span><i className="icon-lock" /></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}
@@ -44,6 +50,7 @@ export class PageListMeta extends React.Component {
         {commentCount}
         {likerCount}
         {locked}
+        {bookmarkCount}
       </span>
     );
   }
@@ -52,6 +59,7 @@ export class PageListMeta extends React.Component {
 
 PageListMeta.propTypes = {
   page: PropTypes.object.isRequired,
+  bookmarkCount: PropTypes.number,
 };
 
 PageListMeta.defaultProps = {

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

@@ -14,6 +14,13 @@ export const PagePathLabel = (props) => {
     return <span className={classNames.join(' ')}>{dPagePath.latter}</span>;
   }
 
+  if (props.isFormerOnly) {
+    const textElem = dPagePath.isFormerRoot
+      ? <>/</>
+      : <>{dPagePath.former}</>;
+    return <span className={classNames.join(' ')}>{textElem}</span>;
+  }
+
   const textElem = dPagePath.isRoot
     ? <><strong>/</strong></>
     : <>{dPagePath.former}/<strong>{dPagePath.latter}</strong></>;
@@ -24,6 +31,7 @@ export const PagePathLabel = (props) => {
 PagePathLabel.propTypes = {
   page: PropTypes.object.isRequired,
   isLatterOnly: PropTypes.bool,
+  isFormerOnly: PropTypes.bool,
   additionalClassNames: PropTypes.arrayOf(PropTypes.string),
 };
 

+ 5 - 0
yarn.lock

@@ -16833,6 +16833,11 @@ react-motion@^0.5.0, react-motion@^0.5.2:
     prop-types "^15.5.8"
     raf "^3.1.0"
 
+react-multiline-clamp@^2.0.0:
+  version "2.0.0"
+  resolved "https://registry.yarnpkg.com/react-multiline-clamp/-/react-multiline-clamp-2.0.0.tgz#913a2092368ef1b52c1c79364d506ba4af27e019"
+  integrity sha512-iPm3HxFD6LO63lE5ZnThiqs+6A3c+LW3WbsEM0oa0iNTa0qN4SKx/LK/6ZToSmXundEcQXBFVNzKDvgmExawTw==
+
 react-node-resolver@^1.0.1:
   version "1.0.1"
   resolved "https://registry.yarnpkg.com/react-node-resolver/-/react-node-resolver-1.0.1.tgz#1798a729c0e218bf2f0e8ddf79c550d4af61d83a"