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

Merge pull request #4839 from weseek/feat/77544-search-sorting

Feat/77544 search sorting
Yohei Shiina 4 лет назад
Родитель
Сommit
02900a8d60

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

@@ -26,6 +26,7 @@ module.exports = {
   // 'growi:routes:page': 'debug',
   'growi-plugin:*': 'debug',
   // 'growi:InterceptorManager': 'debug',
+  'growi:service:search-delegator:elasticsearch': 'debug',
 
   /*
    * configure level for client

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

@@ -582,8 +582,12 @@
     "currently_not_implemented":"This is not currently implemented",
     "search_again" : "Search again",
     "number_of_list_to_display" : "Display",
-    "page_number_unit" : "pages"
-
+    "page_number_unit" : "pages",
+    "sort_axis": {
+      "relationScore": "Sort by relevance",
+      "createdAt": "Creation date",
+      "updatedAt": "Last update date"
+    }
   },
   "security_setting": {
     "Guest Users Access": "Guest users access",

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

@@ -582,7 +582,12 @@
     "currently_not_implemented":"現在未実装の機能です",
     "search_again" : "再検索",
     "number_of_list_to_display" : "表示件数",
-    "page_number_unit" : "件"
+    "page_number_unit" : "件",
+    "sort_axis": {
+      "relationScore": "関連度順",
+      "createdAt": "作成日時",
+      "updatedAt": "更新日時"
+    }
   },
   "security_setting": {
     "Guest Users Access": "ゲストユーザーのアクセス",

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

@@ -855,7 +855,12 @@
     "currently_not_implemented": "这是当前未实现的功能",
     "search_again" : "再次搜索",
     "number_of_list_to_display" : "显示器的数量",
-    "page_number_unit" : "例"
+    "page_number_unit" : "例",
+    "sort_axis": {
+      "relationScore": "按相关性排序",
+      "createdAt": "按创建日期排序",
+      "updatedAt": "按更新日期排序"
+    }
 	},
 	"to_cloud_settings": "進入 GROWI.cloud 的管理界面",
 	"login": {

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

@@ -11,10 +11,9 @@ import SearchPageLayout from './SearchPage/SearchPageLayout';
 import SearchResultContent from './SearchPage/SearchResultContent';
 import SearchResultList from './SearchPage/SearchResultList';
 import SearchControl from './SearchPage/SearchControl';
+import { CheckboxType, SORT_AXIS, SORT_ORDER } from '~/interfaces/search';
 import PageDeleteModal from './PageDeleteModal';
 
-import { CheckboxType } from '../interfaces/search';
-
 export const specificPathNames = {
   user: '/user',
   trash: '/trash',
@@ -38,6 +37,8 @@ class SearchPage extends React.Component {
       pagingLimit: this.props.appContainer.config.pageLimitationL,
       excludeUserPages: true,
       excludeTrashPages: true,
+      sort: SORT_AXIS.RELATION_SCORE,
+      order: SORT_ORDER.DESC,
       selectAllCheckboxType: CheckboxType.NONE_CHECKED,
       isDeleteConfirmModalShown: false,
       deleteTargetPageIds: new Set(),
@@ -50,6 +51,7 @@ class SearchPage extends React.Component {
     this.toggleCheckBox = this.toggleCheckBox.bind(this);
     this.switchExcludeUserPagesHandler = this.switchExcludeUserPagesHandler.bind(this);
     this.switchExcludeTrashPagesHandler = this.switchExcludeTrashPagesHandler.bind(this);
+    this.onChangeSortInvoked = this.onChangeSortInvoked.bind(this);
     this.onPagingNumberChanged = this.onPagingNumberChanged.bind(this);
     this.onPagingLimitChanged = this.onPagingLimitChanged.bind(this);
     this.deleteSinglePageButtonHandler = this.deleteSinglePageButtonHandler.bind(this);
@@ -84,6 +86,13 @@ class SearchPage extends React.Component {
     this.setState({ excludeTrashPages: !this.state.excludeTrashPages });
   }
 
+  onChangeSortInvoked(nextSort, nextOrder) {
+    this.setState({
+      sort: nextSort,
+      order: nextOrder,
+    });
+  }
+
   changeURL(keyword, refreshHash) {
     let hash = window.location.hash || '';
     // TODO 整理する
@@ -152,11 +161,14 @@ class SearchPage extends React.Component {
     });
     const pagingLimit = this.state.pagingLimit;
     const offset = (this.state.activePage * pagingLimit) - pagingLimit;
+    const { sort, order } = this.state;
     try {
       const res = await this.props.appContainer.apiGet('/search', {
         q: this.createSearchQuery(keyword),
         limit: pagingLimit,
         offset,
+        sort,
+        order,
       });
       this.changeURL(keyword);
       if (res.data.length > 0) {
@@ -288,6 +300,8 @@ class SearchPage extends React.Component {
     return (
       <SearchControl
         searchingKeyword={this.state.searchingKeyword}
+        sort={this.state.sort}
+        order={this.state.order}
         searchResultCount={this.state.searchResultCount || 0}
         appContainer={this.props.appContainer}
         onSearchInvoked={this.onSearchInvoked}
@@ -298,6 +312,7 @@ class SearchPage extends React.Component {
         onExcludeTrashPagesSwitched={this.switchExcludeTrashPagesHandler}
         excludeUserPages={this.state.excludeUserPages}
         excludeTrashPages={this.state.excludeTrashPages}
+        onChangeSortInvoked={this.onChangeSortInvoked}
       >
       </SearchControl>
     );

+ 17 - 4
packages/app/src/components/SearchPage/SearchControl.tsx

@@ -4,10 +4,13 @@ import SearchPageForm from './SearchPageForm';
 import AppContainer from '../../client/services/AppContainer';
 import DeleteSelectedPageGroup from './DeleteSelectedPageGroup';
 import SearchOptionModal from './SearchOptionModal';
-import { CheckboxType } from '../../interfaces/search';
+import SortControl from './SortControl';
+import { CheckboxType, SORT_AXIS, SORT_ORDER } from '../../interfaces/search';
 
 type Props = {
   searchingKeyword: string,
+  sort: SORT_AXIS,
+  order: SORT_ORDER,
   appContainer: AppContainer,
   searchResultCount: number,
   selectAllCheckboxType: CheckboxType,
@@ -18,6 +21,7 @@ type Props = {
   onSearchInvoked: (data: {keyword: string}) => boolean,
   onExcludeUserPagesSwitched?: () => void,
   onExcludeTrashPagesSwitched?: () => void,
+  onChangeSortInvoked?: (nextSort: SORT_AXIS, nextOrder: SORT_ORDER) => void,
 }
 
 const SearchControl: FC <Props> = (props: Props) => {
@@ -41,6 +45,12 @@ const SearchControl: FC <Props> = (props: Props) => {
     }
   };
 
+  const onChangeSortInvoked = (nextSort: SORT_AXIS, nextOrder:SORT_ORDER) => {
+    if (props.onChangeSortInvoked != null) {
+      props.onChangeSortInvoked(nextSort, nextOrder);
+    }
+  };
+
   const openSearchOptionModalHandler = () => {
     setIsFileterOptionModalShown(true);
   };
@@ -79,9 +89,12 @@ const SearchControl: FC <Props> = (props: Props) => {
             onSearchFormChanged={props.onSearchInvoked}
           />
         </div>
-        <div className="mr-4">
-          {/* TODO: replace the following button */}
-          <button type="button">related pages</button>
+        <div className="mr-4 d-flex">
+          <SortControl
+            sort={props.sort}
+            order={props.order}
+            onChangeSortInvoked={onChangeSortInvoked}
+          />
         </div>
       </div>
       {/* TODO: replace the following elements deleteAll button , relevance button and include specificPath button component */}

+ 69 - 0
packages/app/src/components/SearchPage/SortControl.tsx

@@ -0,0 +1,69 @@
+import React, { FC } from 'react';
+import { useTranslation } from 'react-i18next';
+import { SORT_AXIS, SORT_ORDER } from '../../interfaces/search';
+
+const { DESC, ASC } = SORT_ORDER;
+
+type Props = {
+  sort: SORT_AXIS,
+  order: SORT_ORDER,
+  onChangeSortInvoked?: (nextSort: SORT_AXIS, nextOrder: SORT_ORDER) => void,
+}
+
+const SortControl: FC <Props> = (props: Props) => {
+
+  const { t } = useTranslation('');
+
+  const onClickChangeSort = (nextSortAxis: SORT_AXIS, nextSortOrder: SORT_ORDER) => {
+    if (props.onChangeSortInvoked != null) {
+      props.onChangeSortInvoked(nextSortAxis, nextSortOrder);
+    }
+  };
+
+  const renderOrderIcon = (order: SORT_ORDER) => {
+    const iconClassName = ASC === order ? 'fa fa-sort-amount-asc' : 'fa fa-sort-amount-desc';
+    return <i className={iconClassName} aria-hidden="true" />;
+  };
+
+  const renderSortItem = (sort, order) => {
+    return <><span className="mr-3">{t(`search_result.sort_axis.${sort}`)}</span>{renderOrderIcon(order)}</>;
+  };
+
+  return (
+    <>
+      <div className="input-group">
+        <div className="input-group-prepend">
+          <div className="input-group-text border" id="btnGroupAddon">
+            {renderOrderIcon(props.order)}
+          </div>
+        </div>
+        <div className="btn-group" role="group">
+          <button
+            type="button"
+            className="btn border dropdown-toggle"
+            data-toggle="dropdown"
+          >
+            <span className="mr-4">{t(`search_result.sort_axis.${props.sort}`)}</span>
+          </button>
+          <div className="dropdown-menu dropdown-menu-right">
+            {Object.values(SORT_AXIS).map((sortAxis) => {
+              const nextOrder = (props.sort !== sortAxis || props.order === ASC) ? DESC : ASC;
+              return (
+                <button
+                  className="dropdown-item d-flex justify-content-between"
+                  type="button"
+                  onClick={() => { onClickChangeSort(sortAxis, nextOrder) }}
+                >
+                  {renderSortItem(sortAxis, nextOrder)}
+                </button>
+              );
+            })}
+          </div>
+        </div>
+      </div>
+    </>
+  );
+};
+
+
+export default SortControl;

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

@@ -16,3 +16,16 @@ export type IPageSearchResultData = {
     },
   },
 }
+
+export const SORT_AXIS = {
+  RELATION_SCORE: 'relationScore',
+  CREATED_AT: 'createdAt',
+  UPDATED_AT: 'updatedAt',
+} as const;
+export type SORT_AXIS = typeof SORT_AXIS[keyof typeof SORT_AXIS];
+
+export const SORT_ORDER = {
+  DESC: 'desc',
+  ASC: 'asc',
+} as const;
+export type SORT_ORDER = typeof SORT_ORDER[keyof typeof SORT_ORDER];

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

@@ -112,7 +112,9 @@ module.exports = function(crowi, app) {
    */
   api.search = async function(req, res) {
     const user = req.user;
-    const { q: keyword = null, type = null } = req.query;
+    const {
+      q: keyword = null, type = null, sort = null, order = null,
+    } = req.query;
     let paginateOpts;
 
     try {
@@ -137,7 +139,9 @@ module.exports = function(crowi, app) {
       userGroups = await UserGroupRelation.findAllUserGroupIdsRelatedToUser(user);
     }
 
-    const searchOpts = { ...paginateOpts, type };
+    const searchOpts = {
+      ...paginateOpts, type, sort, order,
+    };
 
     let searchResult;
     let delegatorName;

+ 41 - 27
packages/app/src/server/service/search-delegator/elasticsearch.ts

@@ -13,6 +13,7 @@ import {
   MetaData, SearchDelegator, Result, SearchableData, QueryTerms,
 } from '../../interfaces/search';
 import { SearchDelegatorName } from '~/interfaces/named-query';
+import { SORT_AXIS, SORT_ORDER } from '~/interfaces/search';
 
 const logger = loggerFactory('growi:service:search-delegator:elasticsearch');
 
@@ -20,6 +21,19 @@ const DEFAULT_OFFSET = 0;
 const DEFAULT_LIMIT = 50;
 const BULK_REINDEX_SIZE = 100;
 
+const { RELATION_SCORE, CREATED_AT, UPDATED_AT } = SORT_AXIS;
+const { DESC, ASC } = SORT_ORDER;
+
+const ES_SORT_AXIS = {
+  [RELATION_SCORE]: '_score',
+  [CREATED_AT]: 'created_at',
+  [UPDATED_AT]: 'updated_at',
+};
+const ES_SORT_ORDER = {
+  [DESC]: 'desc',
+  [ASC]: 'asc',
+};
+
 type Data = any;
 
 class ElasticsearchDelegator implements SearchDelegator<Data> {
@@ -608,29 +622,13 @@ class ElasticsearchDelegator implements SearchDelegator<Data> {
     };
   }
 
-  createSearchQuerySortedByUpdatedAt(option) {
-    // getting path by default is almost for debug
-    let fields = ['path', 'bookmark_count', 'comment_count', 'seenUsers_count', 'updated_at', 'tag_names'];
-    if (option) {
-      fields = option.fields || fields;
-    }
-
-    // default is only id field, sorted by updated_at
-    const query = {
-      index: this.aliasName,
-      type: 'pages',
-      body: {
-        sort: [{ updated_at: { order: 'desc' } }],
-        query: {}, // query
-        _source: fields,
-      },
-    };
-    this.appendResultSize(query);
-
-    return query;
-  }
-
-  createSearchQuerySortedByScore(option?) {
+  /**
+   * create search query for Elasticsearch
+   *
+   * @param {object | undefined} option optional paramas
+   * @returns {object} query object
+   */
+  createSearchQuery(option?) {
     let fields = ['path', 'bookmark_count', 'comment_count', 'seenUsers_count', 'updated_at', 'tag_names', 'comments'];
     if (option) {
       fields = option.fields || fields;
@@ -641,12 +639,10 @@ class ElasticsearchDelegator implements SearchDelegator<Data> {
       index: this.aliasName,
       type: 'pages',
       body: {
-        sort: [{ _score: { order: 'desc' } }],
         query: {}, // query
         _source: fields,
       },
     };
-    this.appendResultSize(query);
 
     return query;
   }
@@ -656,8 +652,22 @@ class ElasticsearchDelegator implements SearchDelegator<Data> {
     query.size = size || DEFAULT_LIMIT;
   }
 
+  appendSortOrder(query, sortAxis: SORT_AXIS, sortOrder: SORT_ORDER) {
+    // default sort order is score descending
+    const sort = ES_SORT_AXIS[sortAxis] || ES_SORT_AXIS[RELATION_SCORE];
+    const order = ES_SORT_ORDER[sortOrder] || ES_SORT_ORDER[DESC];
+    query.body.sort = { [sort]: { order } };
+  }
+
+  convertSortQuery(sortAxis) {
+    switch (sortAxis) {
+      case RELATION_SCORE:
+        return '_score';
+    }
+  }
+
   initializeBoolQuery(query) {
-    // query is created by createSearchQuerySortedByScore() or createSearchQuerySortedByUpdatedAt()
+    // query is created by createSearchQuery()
     if (!query.body.query.bool) {
       query.body.query.bool = {};
     }
@@ -889,13 +899,17 @@ class ElasticsearchDelegator implements SearchDelegator<Data> {
 
     const from = option.offset || null;
     const size = option.limit || null;
-    const query = this.createSearchQuerySortedByScore();
+    const sort = option.sort || null;
+    const order = option.order || null;
+    const query = this.createSearchQuery();
     this.appendCriteriaForQueryString(query, terms);
 
     await this.filterPagesByViewer(query, user, userGroups);
 
     this.appendResultSize(query, from, size);
 
+    this.appendSortOrder(query, sort, order);
+
     await this.appendFunctionScore(query, queryString);
     this.appendHighlight(query);
 

+ 23 - 50
packages/app/src/server/service/search.ts

@@ -374,68 +374,41 @@ class SearchService implements SearchQueryParser, SearchResolver {
     /*
      * 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;
-    }
+    // get page data
+    const pageIds = searchResult.data.map((page) => { return page._id });
+    const findPageResult = await Page.findListByPageIds(pageIds);
 
-    const ids = searchResult.data.map((page) => { return page._id });
-    const findResult = await Page.findListByPageIds(ids);
+    // set meta data
+    result.meta = searchResult.meta;
+    result.totalCount = findPageResult.totalCount;
 
-    // add tags data to page
-    findResult.pages.map((pageData) => {
-      const data = searchResult.data.find((data) => {
+    // set search result page data
+    result.data = searchResult.data.map((data) => {
+      const pageData = findPageResult.pages.find((pageData) => {
         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),
-          };
-        }
+      // add tags and seenUserCount to pageData
+      pageData._doc.tags = data._source.tag_names;
+      pageData._doc.seenUserCount = (pageData.seenUsers && pageData.seenUsers.length) || 0;
 
-        const pageMeta = {
-          bookmarkCount: data._source.bookmark_count || 0,
-          elasticSearchResult,
-        };
+      // serialize lastUpdateUser
+      if (pageData.lastUpdateUser != null && pageData.lastUpdateUser instanceof User) {
+        pageData.lastUpdateUser = serializeUserSecurely(pageData.lastUpdateUser);
+      }
 
-        pageData._doc.seenUserCount = (pageData.seenUsers && pageData.seenUsers.length) || 0;
+      // generate pageMeta data
+      const pageMeta = {
+        bookmarkCount: data._source.bookmark_count || 0,
+        elasticSearchResult: data.elasticSearchResult,
+      };
 
-        return { pageData, pageMeta };
-      })
-      .sort((page1, page2) => {
-        // note: this do not consider NaN
-        return scoreMap[page2.pageData._id] - scoreMap[page1.pageData._id];
-      });
+      return { pageData, pageMeta };
+    });
 
     return result;
   }