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

Merge branch 'dev/5.0.x' into feat/82892-fix-warnings-in-search-page

* dev/5.0.x: (110 commits)
  change transition time 0.2x
  change to ease-out
  remove unnecessary class
  change margin top number
  apply margin to Item component not using scss
  apply gray-color when a triangle btn is hovered
  remove box-shadow
  adjust design
  diff
  82891 fb
  fb
  82891 fb
  apply animation to pagetree triangle button
  rotate triangle icon
  fix page tree path color
  Renamed props
  change muted-color for dark theme
  apply secondary color to isEmptyPage
  fix sass style
  remove duplicate code
  ...
+++
added key in sort to fix "key required" warning
Mao 4 лет назад
Родитель
Сommit
304b67d238
33 измененных файлов с 550 добавлено и 230 удалено
  1. 1 0
      packages/app/config/logger/config.dev.js
  2. 6 2
      packages/app/resource/locales/en_US/translation.json
  3. 6 1
      packages/app/resource/locales/ja_JP/translation.json
  4. 6 1
      packages/app/resource/locales/zh_CN/translation.json
  5. 38 15
      packages/app/src/components/Common/Dropdown/PageItemControl.tsx
  6. 17 0
      packages/app/src/components/Icons/TriangleIcon.tsx
  7. 38 6
      packages/app/src/components/SearchPage.jsx
  8. 24 11
      packages/app/src/components/SearchPage/SearchControl.tsx
  9. 25 15
      packages/app/src/components/SearchPage/SearchPageLayout.tsx
  10. 3 1
      packages/app/src/components/SearchPage/SearchResultList.tsx
  11. 7 6
      packages/app/src/components/SearchPage/SearchResultListItem.tsx
  12. 69 0
      packages/app/src/components/SearchPage/SortControl.tsx
  13. 14 3
      packages/app/src/components/Sidebar/PageTree.tsx
  14. 32 20
      packages/app/src/components/Sidebar/PageTree/Item.tsx
  15. 14 6
      packages/app/src/components/Sidebar/PageTree/ItemsTree.tsx
  16. 13 0
      packages/app/src/interfaces/search.ts
  17. 12 4
      packages/app/src/server/models/page.ts
  18. 4 5
      packages/app/src/server/routes/apiv3/page-listing.ts
  19. 6 2
      packages/app/src/server/routes/search.js
  20. 41 27
      packages/app/src/server/service/search-delegator/elasticsearch.ts
  21. 33 47
      packages/app/src/server/service/search.ts
  22. 5 7
      packages/app/src/server/views/search.html
  23. 4 2
      packages/app/src/stores/page-listing.tsx
  24. 32 20
      packages/app/src/styles/_override-bootstrap-variables.scss
  25. 4 1
      packages/app/src/styles/_override-bootstrap.scss
  26. 9 6
      packages/app/src/styles/_page-tree.scss
  27. 26 17
      packages/app/src/styles/_search.scss
  28. 0 2
      packages/app/src/styles/_subnav.scss
  29. 1 1
      packages/app/src/styles/_tag.scss
  30. 21 0
      packages/app/src/styles/theme/_apply-colors-dark.scss
  31. 23 2
      packages/app/src/styles/theme/_apply-colors-light.scss
  32. 13 0
      packages/app/src/styles/theme/_apply-colors.scss
  33. 3 0
      packages/app/src/styles/theme/_reboot-bootstrap-text.scss

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

+ 38 - 15
packages/app/src/components/Common/Dropdown/PageItemControl.tsx

@@ -6,13 +6,14 @@ import { useTranslation } from 'react-i18next';
 import { IPageHasId } from '~/interfaces/page';
 
 type PageItemControlProps = {
-  page: Partial<IPageHasId>,
-  onClickDeleteButton?: (pageId: string) => void,
+  page: Partial<IPageHasId>
+  isEnableActions: boolean
+  onClickDeleteButton?: (pageId: string) => void
 }
 
 const PageItemControl: FC<PageItemControlProps> = (props: PageItemControlProps) => {
 
-  const { page, onClickDeleteButton } = props;
+  const { page, isEnableActions, onClickDeleteButton } = props;
   const { t } = useTranslation('');
 
   const deleteButtonHandler = () => {
@@ -48,18 +49,40 @@ const PageItemControl: FC<PageItemControlProps> = (props: PageItemControlProps)
           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={deleteButtonHandler}>
-          <i className="icon-fw icon-fire"></i>{t('Delete')}
-        </button>
-        <button className="dropdown-item" type="button" onClick={() => toastr.warning(t('search_result.currently_not_implemented'))}>
-          <i className="icon-fw icon-star"></i>{t('Add to bookmark')}
-        </button>
-        <button className="dropdown-item" type="button" onClick={() => toastr.warning(t('search_result.currently_not_implemented'))}>
-          <i className="icon-fw icon-docs"></i>{t('Duplicate')}
-        </button>
-        <button className="dropdown-item" type="button" onClick={() => toastr.warning(t('search_result.currently_not_implemented'))}>
-          <i className="icon-fw  icon-action-redo"></i>{t('Move/Rename')}
-        </button>
+
+        {/* TODO: show dropdown when permalink section is implemented */}
+        {!isEnableActions && (
+          <p className="dropdown-item">
+            {t('search_result.currently_not_implemented')}
+          </p>
+        )}
+        {isEnableActions && (
+          <button className="dropdown-item" type="button" onClick={() => toastr.warning(t('search_result.currently_not_implemented'))}>
+            <i className="icon-fw icon-star"></i>
+            {t('Add to bookmark')}
+          </button>
+        )}
+        {isEnableActions && (
+          <button className="dropdown-item" type="button" onClick={() => toastr.warning(t('search_result.currently_not_implemented'))}>
+            <i className="icon-fw icon-docs"></i>
+            {t('Duplicate')}
+          </button>
+        )}
+        {isEnableActions && (
+          <button className="dropdown-item" type="button" onClick={() => toastr.warning(t('search_result.currently_not_implemented'))}>
+            <i className="icon-fw  icon-action-redo"></i>
+            {t('Move/Rename')}
+          </button>
+        )}
+        {isEnableActions && (
+          <>
+            <div className="dropdown-divider"></div>
+            <button className="dropdown-item text-danger pt-2" type="button" onClick={deleteButtonHandler}>
+              <i className="icon-fw icon-trash"></i>
+              {t('Delete')}
+            </button>
+          </>
+        )}
       </div>
     </>
   );

+ 17 - 0
packages/app/src/components/Icons/TriangleIcon.tsx

@@ -0,0 +1,17 @@
+import React from 'react';
+
+const TriangleIcon = (): JSX.Element => (
+  <svg
+    xmlns="http://www.w3.org/2000/svg"
+    width="12"
+    height="12"
+    viewBox="0 0 12 12"
+  >
+    <g transform="translate(18194 -6790)">
+      <rect width="12" height="12" transform="translate(-18194 6790)" fill="none" />
+      <path d="M5.2,1.067a1,1,0,0,1,1.6,0l4,5.333A1,1,0,0,1,10,8H2a1,1,0,0,1-.8-1.6Z" transform="translate(-18183 6790) rotate(90)" />
+    </g>
+  </svg>
+);
+
+export default TriangleIcon;

+ 38 - 6
packages/app/src/components/SearchPage.jsx

@@ -11,9 +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';
+import { useIsGuestUser } from '~/stores/context';
 
 export const specificPathNames = {
   user: '/user',
@@ -35,9 +35,11 @@ class SearchPage extends React.Component {
       selectedPagesIdList: new Set(),
       searchResultCount: 0,
       activePage: 1,
-      pagingLimit: this.props.appContainer.config.pageLimitationL,
+      pagingLimit: this.props.appContainer.config.pageLimitationL || 50,
       excludeUserPages: true,
       excludeTrashPages: true,
+      sort: SORT_AXIS.RELATION_SCORE,
+      order: SORT_ORDER.DESC,
       selectAllCheckboxType: CheckboxType.NONE_CHECKED,
       isDeleteConfirmModalShown: false,
       deleteTargetPageIds: new Set(),
@@ -50,6 +52,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 +87,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 +162,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) {
@@ -271,6 +284,7 @@ class SearchPage extends React.Component {
     return (
       <SearchResultList
         pages={this.state.searchResults || []}
+        isEnableActions={!this.props.isGuestUser}
         focusedSearchResultData={this.state.focusedSearchResultData}
         selectedPagesIdList={this.state.selectedPagesIdList || []}
         searchResultCount={this.state.searchResultCount}
@@ -288,6 +302,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 +314,7 @@ class SearchPage extends React.Component {
         onExcludeTrashPagesSwitched={this.switchExcludeTrashPagesHandler}
         excludeUserPages={this.state.excludeUserPages}
         excludeTrashPages={this.state.excludeTrashPages}
+        onChangeSortInvoked={this.onChangeSortInvoked}
       >
       </SearchControl>
     );
@@ -313,7 +330,8 @@ class SearchPage extends React.Component {
           searchResultMeta={this.state.searchResultMeta}
           searchingKeyword={this.state.searchedKeyword}
           onPagingLimitChanged={this.onPagingLimitChanged}
-          initialPagingLimit={this.props.appContainer.config.pageLimitationL || 50}
+          pagingLimit={this.state.pagingLimit}
+          activePage={this.state.activePage}
         >
         </SearchPageLayout>
         <PageDeleteModal
@@ -330,16 +348,30 @@ class SearchPage extends React.Component {
 /**
  * Wrapper component for using unstated
  */
-const SearchPageWrapper = withUnstatedContainers(SearchPage, [AppContainer]);
+const SearchPageHOCWrapper = withTranslation()(withUnstatedContainers(SearchPage, [AppContainer]));
 
 SearchPage.propTypes = {
   t: PropTypes.func.isRequired, // i18next
   appContainer: PropTypes.instanceOf(AppContainer).isRequired,
   query: PropTypes.object,
+  isGuestUser: PropTypes.bool.isRequired,
 };
 SearchPage.defaultProps = {
   // pollInterval: 1000,
   query: SearchPage.getQueryByLocation(window.location || {}),
 };
 
-export default withTranslation()(SearchPageWrapper);
+const SearchPageFCWrapper = (props) => {
+  const { data: isGuestUser } = useIsGuestUser();
+
+  /*
+   * dependencies
+   */
+  if (isGuestUser == null) {
+    return null;
+  }
+
+  return <SearchPageHOCWrapper {...props} isGuestUser={isGuestUser} />;
+};
+
+export default SearchPageFCWrapper;

+ 24 - 11
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);
   };
@@ -70,7 +80,7 @@ const SearchControl: FC <Props> = (props: Props) => {
   };
 
   return (
-    <>
+    <div className="position-sticky fixed-top">
       <div className="search-page-nav d-flex py-3 align-items-center">
         <div className="flex-grow-1 mx-4">
           <SearchPageFormTypeAny
@@ -79,14 +89,17 @@ 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 */}
-      <div className="d-flex align-items-center py-3 border-bottom border-gray">
-        <div className="d-flex mr-auto ml-3">
+      <div className="search-control d-flex align-items-center py-2 border-bottom border-gray">
+        <div className="d-flex mr-auto ml-4">
           {/* Todo: design will be fixed in #80324. Function will be implemented in #77525 */}
           <DeleteSelectedPageGroup
             isSelectAllCheckboxDisabled={searchResultCount === 0}
@@ -105,9 +118,9 @@ const SearchControl: FC <Props> = (props: Props) => {
             <i className="icon-equalizer"></i>
           </button>
         </div>
-        <div className="d-none d-lg-flex align-items-center mr-3">
+        <div className="d-none d-lg-flex align-items-center mr-4">
           <div className="border border-gray mr-3">
-            <label className="px-3 py-2 mb-0 d-flex align-items-center" htmlFor="flexCheckDefault">
+            <label className="px-3 py-2 mb-0 d-flex align-items-center text-secondary with-no-font-weight" htmlFor="flexCheckDefault">
               <input
                 className="mr-2"
                 type="checkbox"
@@ -118,7 +131,7 @@ const SearchControl: FC <Props> = (props: Props) => {
             </label>
           </div>
           <div className="border border-gray">
-            <label className="px-3 py-2 mb-0 d-flex align-items-center" htmlFor="flexCheckChecked">
+            <label className="px-3 py-2 mb-0 d-flex align-items-center text-secondary with-no-font-weight" htmlFor="flexCheckChecked">
               <input
                 className="mr-2"
                 type="checkbox"
@@ -131,7 +144,7 @@ const SearchControl: FC <Props> = (props: Props) => {
         </div>
       </div>
       {rednerSearchOptionModal()}
-    </>
+    </div>
   );
 };
 

+ 25 - 15
packages/app/src/components/SearchPage/SearchPageLayout.tsx

@@ -2,9 +2,9 @@ import React, { FC } from 'react';
 import { useTranslation } from 'react-i18next';
 
 type SearchResultMeta = {
-  took : number,
-  total : number,
-  results: number
+  took?: number,
+  total?: number,
+  results?: number
 }
 
 type Props = {
@@ -13,35 +13,44 @@ type Props = {
   SearchResultContent: React.FunctionComponent,
   searchResultMeta: SearchResultMeta,
   searchingKeyword: string,
-  initialPagingLimit: number,
+  pagingLimit: number,
+  activePage: number,
   onPagingLimitChanged: (limit: number) => void
 }
 
 const SearchPageLayout: FC<Props> = (props: Props) => {
   const { t } = useTranslation('');
   const {
-    SearchResultList, SearchControl, SearchResultContent, searchResultMeta, searchingKeyword,
+    SearchResultList, SearchControl, SearchResultContent, searchResultMeta, searchingKeyword, pagingLimit, activePage,
   } = props;
 
+  const renderShowingPageCountInfo = () => {
+    if (searchResultMeta.total == null || searchResultMeta.total === 0) return;
+    const leftNum = pagingLimit * (activePage - 1) + 1;
+    const rightNum = (leftNum - 1) + (searchResultMeta.results || 0);
+    return <span className="ml-3">{`${leftNum}-${rightNum}`} / {searchResultMeta.total || 0}</span>;
+  };
+
   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="search-result d-flex" id="search-result">
+        <div className="flex-grow-1 flex-basis-0 page-list border boder-gray search-result-list" id="search-result-list">
+
+          <SearchControl></SearchControl>
           <div className="search-result-list-scroll">
-            <div className="d-flex align-items-center justify-content-between mt-1 mb-3">
-              <div className="search-result-meta text-nowrap mr-3">
+            <div className="d-flex align-items-center justify-content-between my-3 ml-4">
+              <div className="search-result-meta text-nowrap">
                 <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>
+                {renderShowingPageCountInfo()}
               </div>
-              <div className="input-group search-result-select-group">
+              <div className="input-group search-result-select-group ml-4">
                 <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
-                  defaultValue={props.initialPagingLimit}
+                  defaultValue={props.pagingLimit}
                   className="custom-select"
                   id="inputGroupSelect01"
                   onChange={(e) => { props.onPagingLimitChanged(Number(e.target.value)) }}
@@ -52,12 +61,13 @@ const SearchPageLayout: FC<Props> = (props: Props) => {
                 </select>
               </div>
             </div>
+
             <div className="page-list">
-              <ul className="page-list-ul page-list-ul-flat nav nav-pills"><SearchResultList></SearchResultList></ul>
+              <ul className="page-list-ul page-list-ul-flat pl-4 nav nav-pills"><SearchResultList></SearchResultList></ul>
             </div>
           </div>
         </div>
-        <div className="col-lg-6 d-none d-lg-block search-result-content">
+        <div className="flex-grow-1 flex-basis-0 d-none d-lg-block search-result-content">
           <SearchResultContent></SearchResultContent>
         </div>
       </div>

+ 3 - 1
packages/app/src/components/SearchPage/SearchResultList.tsx

@@ -7,6 +7,7 @@ import { IPageSearchResultData } from '../../interfaces/search';
 type Props = {
   pages: IPageSearchResultData[],
   selectedPagesIdList: Set<string>
+  isEnableActions: boolean,
   searchResultCount?: number,
   activePage?: number,
   pagingLimit?: number,
@@ -19,7 +20,7 @@ type Props = {
 }
 
 const SearchResultList: FC<Props> = (props:Props) => {
-  const { focusedSearchResultData, selectedPagesIdList } = props;
+  const { focusedSearchResultData, selectedPagesIdList, isEnableActions } = props;
 
   const focusedPageId = (focusedSearchResultData != null && focusedSearchResultData.pageData != null) ? focusedSearchResultData.pageData._id : '';
   return (
@@ -31,6 +32,7 @@ const SearchResultList: FC<Props> = (props:Props) => {
           <SearchResultListItem
             key={page.pageData._id}
             page={page}
+            isEnableActions={isEnableActions}
             onClickSearchResultItem={props.onClickSearchResultItem}
             onClickCheckbox={props.onClickCheckbox}
             isChecked={isChecked}

+ 7 - 6
packages/app/src/components/SearchPage/SearchResultListItem.tsx

@@ -13,6 +13,7 @@ type Props = {
   page: IPageSearchResultData,
   isSelected: boolean,
   isChecked: boolean,
+  isEnableActions: boolean,
   onClickCheckbox?: (pageId: string) => void,
   onClickSearchResultItem?: (pageId: string) => void,
   onClickDeleteButton?: (pageId: string) => void,
@@ -21,7 +22,7 @@ type Props = {
 const SearchResultListItem: FC<Props> = (props:Props) => {
   const {
     // todo: refactoring variable name to clear what changed
-    page: { pageData, pageMeta }, isSelected, onClickSearchResultItem, onClickCheckbox, isChecked,
+    page: { pageData, pageMeta }, isSelected, onClickSearchResultItem, onClickCheckbox, isChecked, isEnableActions,
   } = 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.
@@ -38,9 +39,9 @@ const SearchResultListItem: FC<Props> = (props:Props) => {
   );
 
   return (
-    <li key={pageData._id} className={`page-list-li search-page-item w-100 border-bottom px-4 list-group-item-action ${isSelected ? 'active' : ''}`}>
+    <li key={pageData._id} className={`page-list-li search-page-item w-100 list-group-item-action pl-2 ${isSelected ? 'active' : ''}`}>
       <a
-        className="d-block pt-3"
+        className="d-block py-4 h-100"
         href={pageId}
         onClick={() => onClickSearchResultItem != null && onClickSearchResultItem(pageData._id)}
       >
@@ -69,7 +70,7 @@ const SearchResultListItem: FC<Props> = (props:Props) => {
               {/* page title */}
               <h3 className="mb-0">
                 <UserPicture user={pageData.lastUpdateUser} />
-                <span className="mx-2">{dPagePath.latter}</span>
+                <span className="mx-2 search-result-page-title">{dPagePath.latter}</span>
               </h3>
               {/* page meta */}
               <div className="d-flex mx-2">
@@ -77,10 +78,10 @@ const SearchResultListItem: FC<Props> = (props:Props) => {
               </div>
               {/* doropdown icon includes page control buttons */}
               <div className="ml-auto">
-                <PageItemControl page={pageData} onClickDeleteButton={props.onClickDeleteButton} />
+                <PageItemControl page={pageData} onClickDeleteButton={props.onClickDeleteButton} isEnableActions={isEnableActions} />
               </div>
             </div>
-            <div className="my-2">
+            <div className="my-2 search-result-list-snippet">
               {
                 pageMeta.elasticSearchResult != null && (
                   <Clamp lines={2}>

+ 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 <div className="d-flex align-items-center"><span className="mr-3">{t(`search_result.sort_axis.${sort}`)}</span>{renderOrderIcon(order)}</div>;
+  };
+
+  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 text-secondary">{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;

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

@@ -2,7 +2,9 @@ import React, { FC, memo, useState } from 'react';
 import { useTranslation } from 'react-i18next';
 
 import { useSWRxV5MigrationStatus } from '~/stores/page-listing';
-import { useCurrentPagePath, useCurrentPageId, useTargetAndAncestors } from '~/stores/context';
+import {
+  useCurrentPagePath, useCurrentPageId, useTargetAndAncestors, useIsGuestUser,
+} from '~/stores/context';
 
 import ItemsTree from './PageTree/ItemsTree';
 import PrivateLegacyPages from './PageTree/PrivateLegacyPages';
@@ -12,16 +14,24 @@ import { IPageForPageDeleteModal } from '../PageDeleteModal';
 const PageTree: FC = memo(() => {
   const { t } = useTranslation();
 
+  const { data: isGuestUser } = useIsGuestUser();
   const { data: currentPath } = useCurrentPagePath();
   const { data: targetId } = useCurrentPageId();
   const { data: targetAndAncestorsData } = useTargetAndAncestors();
 
-  const { data: migrationStatus } = useSWRxV5MigrationStatus();
+  const { data: migrationStatus } = useSWRxV5MigrationStatus(!isGuestUser);
 
   // for delete modal
   const [isDeleteModalOpen, setDeleteModalOpen] = useState(false);
   const [pagesToDelete, setPagesToDelete] = useState<IPageForPageDeleteModal[]>([]);
 
+  /*
+   * dependencies
+   */
+  if (isGuestUser == null) {
+    return null;
+  }
+
   const onClickDeleteByPage = (page: IPageForPageDeleteModal) => {
     setDeleteModalOpen(true);
     setPagesToDelete([page]);
@@ -41,6 +51,7 @@ const PageTree: FC = memo(() => {
 
       <div className="grw-sidebar-content-body">
         <ItemsTree
+          isEnableActions={!isGuestUser}
           targetPath={path}
           targetId={targetId}
           targetAndAncestorsData={targetAndAncestorsData}
@@ -55,7 +66,7 @@ const PageTree: FC = memo(() => {
 
       <div className="grw-sidebar-content-footer">
         {
-          migrationStatus?.migratablePagesCount != null && migrationStatus.migratablePagesCount !== 0 && (
+          !isGuestUser && migrationStatus?.migratablePagesCount != null && migrationStatus.migratablePagesCount !== 0 && (
             <PrivateLegacyPages />
           )
         }

+ 32 - 20
packages/app/src/components/Sidebar/PageTree/Item.tsx

@@ -11,8 +11,11 @@ import ClosableTextInput, { AlertInfo, AlertType } from '../../Common/ClosableTe
 import PageItemControl from '../../Common/Dropdown/PageItemControl';
 import { IPageForPageDeleteModal } from '~/components/PageDeleteModal';
 
+import TriangleIcon from '~/components/Icons/TriangleIcon';
+
 
 interface ItemProps {
+  isEnableActions: boolean
   itemNode: ItemNode
   targetId?: string
   isOpen?: boolean
@@ -35,6 +38,7 @@ const markTarget = (children: ItemNode[], targetId?: string): void => {
 
 type ItemControlProps = {
   page: Partial<IPageHasId>
+  isEnableActions: boolean
   onClickDeleteButtonHandler?(): void
   onClickPlusButtonHandler?(): void
 }
@@ -62,7 +66,7 @@ const ItemControl: FC<ItemControlProps> = memo((props: ItemControlProps) => {
 
   return (
     <>
-      <PageItemControl page={props.page} onClickDeleteButton={onClickDeleteButton} />
+      <PageItemControl page={props.page} onClickDeleteButton={onClickDeleteButton} isEnableActions={props.isEnableActions} />
       <button
         type="button"
         className="btn-link nav-link border-0 rounded grw-btn-page-management py-0"
@@ -87,7 +91,7 @@ const ItemCount: FC = () => {
 const Item: FC<ItemProps> = (props: ItemProps) => {
   const { t } = useTranslation();
   const {
-    itemNode, targetId, isOpen: _isOpen = false, onClickDeleteByPage,
+    itemNode, targetId, isOpen: _isOpen = false, onClickDeleteByPage, isEnableActions,
   } = props;
 
   const { page, children } = itemNode;
@@ -173,20 +177,22 @@ const Item: FC<ItemProps> = (props: ItemProps) => {
   const opacityStyle = { opacity: 1.0 };
   if (page.isTarget) opacityStyle.opacity = 0.7;
 
-  const buttonClass = isOpen ? 'rotate' : '';
+  const buttonClass = isOpen ? 'grw-pagetree-open' : '';
 
   return (
-    <div className="grw-pagetree-item-wrapper">
+    <>
       <div style={opacityStyle} className="grw-pagetree-item d-flex align-items-center">
         <button
           type="button"
           className={`grw-pagetree-button btn ${buttonClass}`}
           onClick={onClickLoadChildren}
         >
-          <i className="icon-control-play"></i>
+          <div className="grw-triangle-icon">
+            <TriangleIcon />
+          </div>
         </button>
         <a href={page._id} className="grw-pagetree-title-anchor flex-grow-1">
-          <p className="grw-pagetree-title m-auto">{nodePath.basename(page.path as string) || '/'}</p>
+          <p className={`grw-pagetree-title m-auto ${page.isEmpty && 'text-muted'}`}>{nodePath.basename(page.path as string) || '/'}</p>
         </a>
         <div className="grw-pagetree-count-wrapper">
           <ItemCount />
@@ -196,28 +202,34 @@ const Item: FC<ItemProps> = (props: ItemProps) => {
             page={page}
             onClickDeleteButtonHandler={onClickDeleteButtonHandler}
             onClickPlusButtonHandler={() => { setNewPageInputShown(true) }}
+            isEnableActions={isEnableActions}
           />
         </div>
       </div>
 
-      <ClosableTextInput
-        isShown={isNewPageInputShown}
-        placeholder={t('Input title')}
-        onClickOutside={() => { setNewPageInputShown(false) }}
-        onPressEnter={onPressEnterHandler}
-        inputValidator={inputValidator}
-      />
+      {!isEnableActions && (
+        <ClosableTextInput
+          isShown={isNewPageInputShown}
+          placeholder={t('Input title')}
+          onClickOutside={() => { setNewPageInputShown(false) }}
+          onPressEnter={onPressEnterHandler}
+          inputValidator={inputValidator}
+        />
+      )}
       {
         isOpen && hasChildren() && currentChildren.map(node => (
-          <Item
-            key={node.page._id}
-            itemNode={node}
-            isOpen={false}
-            onClickDeleteByPage={onClickDeleteByPage}
-          />
+          <div className="ml-3 mt-2">
+            <Item
+              key={node.page._id}
+              isEnableActions={isEnableActions}
+              itemNode={node}
+              isOpen={false}
+              onClickDeleteByPage={onClickDeleteByPage}
+            />
+          </div>
         ))
       }
-    </div>
+    </>
   );
 
 };

+ 14 - 6
packages/app/src/components/Sidebar/PageTree/ItemsTree.tsx

@@ -29,7 +29,7 @@ const generateInitialNodeAfterResponse = (ancestorsChildren: Record<string, Part
   const paths = Object.keys(ancestorsChildren);
 
   let currentNode = rootNode;
-  paths.reverse().forEach((path) => {
+  paths.forEach((path) => {
     const childPages = ancestorsChildren[path];
     currentNode.children = ItemNode.generateNodesFromPages(childPages);
 
@@ -43,6 +43,7 @@ const generateInitialNodeAfterResponse = (ancestorsChildren: Record<string, Part
 };
 
 type ItemsTreeProps = {
+  isEnableActions: boolean
   targetPath: string
   targetId?: string
   targetAndAncestorsData?: TargetAndAncestors
@@ -57,11 +58,18 @@ type ItemsTreeProps = {
 }
 
 const renderByInitialNode = (
-    initialNode: ItemNode, DeleteModal: JSX.Element, targetId?: string, onClickDeleteByPage?: (page: IPageForPageDeleteModal) => void,
+    initialNode: ItemNode, DeleteModal: JSX.Element, isEnableActions: boolean, targetId?: string, onClickDeleteByPage?: (page: IPageForPageDeleteModal) => void,
 ): JSX.Element => {
   return (
     <div className="grw-pagetree p-3">
-      <Item key={initialNode.page.path} targetId={targetId} itemNode={initialNode} isOpen onClickDeleteByPage={onClickDeleteByPage} />
+      <Item
+        key={initialNode.page.path}
+        targetId={targetId}
+        itemNode={initialNode}
+        isOpen
+        isEnableActions={isEnableActions}
+        onClickDeleteByPage={onClickDeleteByPage}
+      />
       {DeleteModal}
     </div>
   );
@@ -74,7 +82,7 @@ const renderByInitialNode = (
 const ItemsTree: FC<ItemsTreeProps> = (props: ItemsTreeProps) => {
   const {
     targetPath, targetId, targetAndAncestorsData, isDeleteModalOpen, pagesToDelete, isAbleToDeleteCompletely, isDeleteCompletelyModal, onCloseDelete,
-    onClickDeleteByPage,
+    onClickDeleteByPage, isEnableActions,
   } = props;
 
   const { data: ancestorsChildrenData, error: error1 } = useSWRxPageAncestorsChildren(targetPath);
@@ -101,7 +109,7 @@ const ItemsTree: FC<ItemsTreeProps> = (props: ItemsTreeProps) => {
    */
   if (ancestorsChildrenData != null && rootPageData != null) {
     const initialNode = generateInitialNodeAfterResponse(ancestorsChildrenData.ancestorsChildren, new ItemNode(rootPageData.rootPage));
-    return renderByInitialNode(initialNode, DeleteModal, targetId, onClickDeleteByPage);
+    return renderByInitialNode(initialNode, DeleteModal, isEnableActions, targetId, onClickDeleteByPage);
   }
 
   /*
@@ -109,7 +117,7 @@ const ItemsTree: FC<ItemsTreeProps> = (props: ItemsTreeProps) => {
    */
   if (targetAndAncestorsData != null) {
     const initialNode = generateInitialNodeBeforeResponse(targetAndAncestorsData.targetAndAncestors);
-    return renderByInitialNode(initialNode, DeleteModal, targetId, onClickDeleteByPage);
+    return renderByInitialNode(initialNode, DeleteModal, isEnableActions, targetId, onClickDeleteByPage);
   }
 
   return null;

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

+ 12 - 4
packages/app/src/server/models/page.ts

@@ -310,7 +310,7 @@ schema.statics.findChildrenByParentPathOrIdAndViewer = async function(parentPath
 };
 
 schema.statics.findAncestorsChildrenByPathAndViewer = async function(path: string, user, userGroups = null): Promise<Record<string, PageDocument[]>> {
-  const ancestorPaths = isTopPage(path) ? ['/'] : collectAncestorPaths(path);
+  const ancestorPaths = isTopPage(path) ? ['/'] : collectAncestorPaths(path); // root path is necessary for rendering
   const regexps = ancestorPaths.map(path => new RegExp(generateChildrenRegExp(path))); // cannot use re2
 
   // get pages at once
@@ -330,10 +330,18 @@ schema.statics.findAncestorsChildrenByPathAndViewer = async function(path: strin
     return page;
   });
 
-  // make map
+  /*
+   * If any non-migrated page is found during creating the pathToChildren map, it will stop incrementing at that moment
+   */
   const pathToChildren: Record<string, PageDocument[]> = {};
-  ancestorPaths.forEach((path) => {
-    pathToChildren[path] = pages.filter(page => nodePath.dirname(page.path) === path);
+  const sortedPaths = ancestorPaths.sort((a, b) => a.length - b.length); // sort paths by path.length
+  sortedPaths.every((path) => {
+    const children = pages.filter(page => nodePath.dirname(page.path) === path);
+    if (children.length === 0) {
+      return false; // break when children do not exist
+    }
+    pathToChildren[path] = children;
+    return true;
   });
 
   return pathToChildren;

+ 4 - 5
packages/app/src/server/routes/apiv3/page-listing.ts

@@ -34,14 +34,13 @@ const validator = {
  */
 export default (crowi: Crowi): Router => {
   const accessTokenParser = require('../../middlewares/access-token-parser')(crowi);
-  // Do not use loginRequired with isGuestAllowed true since page tree may show private page titles
-  const loginRequiredStrictly = require('../../middlewares/login-required')(crowi);
+  const loginRequired = require('../../middlewares/login-required')(crowi, true);
   const apiV3FormValidator = require('../../middlewares/apiv3-form-validator')(crowi);
 
   const router = express.Router();
 
 
-  router.get('/root', accessTokenParser, loginRequiredStrictly, async(req: AuthorizedRequest, res: ApiV3Response) => {
+  router.get('/root', accessTokenParser, loginRequired, async(req: AuthorizedRequest, res: ApiV3Response) => {
     const Page: PageModel = crowi.model('Page');
 
     let rootPage;
@@ -56,7 +55,7 @@ export default (crowi: Crowi): Router => {
   });
 
   // eslint-disable-next-line max-len
-  router.get('/ancestors-children', accessTokenParser, loginRequiredStrictly, ...validator.pagePathRequired, apiV3FormValidator, async(req: AuthorizedRequest, res: ApiV3Response): Promise<any> => {
+  router.get('/ancestors-children', accessTokenParser, loginRequired, ...validator.pagePathRequired, apiV3FormValidator, async(req: AuthorizedRequest, res: ApiV3Response): Promise<any> => {
     const { path } = req.query;
 
     const Page: PageModel = crowi.model('Page');
@@ -76,7 +75,7 @@ export default (crowi: Crowi): Router => {
    * In most cases, using id should be prioritized
    */
   // eslint-disable-next-line max-len
-  router.get('/children', accessTokenParser, loginRequiredStrictly, validator.pageIdOrPathRequired, async(req: AuthorizedRequest, res: ApiV3Response) => {
+  router.get('/children', accessTokenParser, loginRequired, validator.pageIdOrPathRequired, async(req: AuthorizedRequest, res: ApiV3Response) => {
     const { id, path } = req.query;
 
     const Page: PageModel = crowi.model('Page');

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

+ 33 - 47
packages/app/src/server/service/search.ts

@@ -374,68 +374,54 @@ 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;
       });
+
+      // add tags and seenUserCount to pageData
       pageData._doc.tags = data._source.tag_names;
-      return pageData;
-    });
+      pageData._doc.seenUserCount = (pageData.seenUsers && pageData.seenUsers.length) || 0;
 
-    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);
-        }
+      // serialize lastUpdateUser
+      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),
-          };
-        }
+      // 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'] || '';
 
-        const pageMeta = {
-          bookmarkCount: data._source.bookmark_count || 0,
-          elasticSearchResult,
+        elasticSearchResult = {
+          snippet: filterXss.process(snippet),
+          highlightedPath: filterXss.process(pathMatch),
         };
+      }
 
-        pageData._doc.seenUserCount = (pageData.seenUsers && pageData.seenUsers.length) || 0;
+      // generate pageMeta data
+      const pageMeta = {
+        bookmarkCount: data._source.bookmark_count || 0,
+        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;
   }

+ 5 - 7
packages/app/src/server/views/search.html

@@ -11,16 +11,14 @@
   data-target="#search-result-list"
 {% endblock %}
 
+<!-- add .on-search to body tag class in layout -->
+{% set additionalBodyClass = 'on-search' %}
+
 {% block layout_main %}
 <div id="grw-fav-sticky-trigger" class="sticky-top"></div>
 
-<div class="container-fluid">
-
-  <div class="row">
-    <div id="main" class="main col-lg-12 search-page mt-0">
-      <div class="" id="search-page"></div>
-    </div>
+  <div id="main" class="main search-page mt-0">
+    <div id="search-page"></div>
   </div>
 
-</div><!-- /.container-fluid -->
 {% endblock %} {# layout_main #}

+ 4 - 2
packages/app/src/stores/page-listing.tsx

@@ -45,9 +45,11 @@ export const useSWRxPageChildren = (
   );
 };
 
-export const useSWRxV5MigrationStatus = (): SWRResponse<V5MigrationStatus, Error> => {
+export const useSWRxV5MigrationStatus = (
+    shouldFetch = true,
+): SWRResponse<V5MigrationStatus, Error> => {
   return useSWR(
-    '/pages/v5-migration-status',
+    shouldFetch ? '/pages/v5-migration-status' : null,
     endpoint => apiv3Get(endpoint).then((response) => {
       return {
         migratablePagesCount: response.data.migratablePagesCount,

+ 32 - 20
packages/app/src/styles/_override-bootstrap-variables.scss

@@ -18,13 +18,21 @@ $gray-200: $light !default;
 $gray-300: darken($light, 5%) !default;
 $gray-400: darken($light, 20%) !default;
 $gray-500: darken($light, 30%) !default;
+$gray-550: lighten($dark, 15%) !default;
 $gray-600: lighten($dark, 10%) !default;
 $gray-700: lighten($dark, 5%) !default;
 $gray-800: $dark !default;
 $gray-900: darken($dark, 5%) !default;
-$grays: ("50": $gray-50) !default;
+$grays: (
+  '50': $gray-50,
+) !default;
 $red: #ff0a54 !default;
 
+// Options
+//
+// Quickly modify global styling by enabling or disabling optional features.
+
+$enable-shadows: true;
 
 // Grid breakpoints
 //
@@ -37,7 +45,7 @@ $grid-breakpoints: (
   md: 768px,
   lg: 992px,
   xl: 1200px,
-  2xl: 1480px
+  2xl: 1480px,
 );
 
 // Grid containers
@@ -49,45 +57,45 @@ $container-max-widths: (
   md: 720px,
   lg: 960px,
   xl: 1140px,
-  2xl: 1320px
+  2xl: 1320px,
 );
 
-
 //== Typography
 //
 //## Font, line-height, and color for body text, headings, and more.
-$font-family-sans-serif:  Lato, -apple-system, BlinkMacSystemFont, 'Hiragino Kaku Gothic ProN', Meiryo, sans-serif;
-$font-family-serif:       Georgia, "Times New Roman", Times, serif;
+$font-family-sans-serif: Lato, -apple-system, BlinkMacSystemFont, 'Hiragino Kaku Gothic ProN', Meiryo, sans-serif;
+$font-family-serif: Georgia, 'Times New Roman', Times, serif;
 $font-family-monospace: SFMono-Regular, Consolas, Liberation Mono, Menlo, monospace;
-$font-family-base:        $font-family-sans-serif;
+$font-family-base: $font-family-sans-serif;
 
 $font-size-root: 14px;
 $line-height-base: 1.42857;
 
-$text-muted: $gray-500;
 $blockquote-small-color: $gray-500;
 
-
 //== Components
 //
-$border-radius:               .15rem;
-$border-radius-sm:            .1rem;
-$border-radius-lg:            .25rem;
-$border-radius-xl:            .35rem;
+$border-radius: 4px;
+$border-radius-sm: 0;
+$border-radius-lg: 8px;
+
+// Buttons + Forms
+//
+// Shared variables that are reassigned to `$input-` and `$btn-` specific variables.
+
+$input-btn-focus-box-shadow: none;
 
 // Buttons
 //
 // For each of Bootstrap's buttons, define text, background, and border color.
 $btn-link-disabled-color: $gray-500;
+$btn-focus-box-shadow: none;
+$btn-active-box-shadow: none;
 
 //== Forms
 //
 $input-border-color: $gray-300;
 
-$input-border-radius: $border-radius-sm;
-$input-border-radius-sm: $border-radius-sm;
-$input-border-radius-lg: $border-radius;
-
 $input-placeholder-color: $gray-500;
 
 $custom-control-indicator-border-color: $gray-400;
@@ -106,9 +114,14 @@ $navbar-brand-padding-y: 0;
 $navbar-nav-link-padding-x: 1rem;
 
 //== Dropdowns
-$dropdown-border-radius: $border-radius-sm;
+$dropdown-border-radius: $border-radius-lg;
 $dropdown-link-disabled-color: $gray-500;
 $dropdown-header-color: $gray-500;
+$dropdown-box-shadow: 0 0.5rem 0.7rem rgba(black, 0.1);
+
+//== Popovers
+$popover-border-radius: $border-radius;
+$popover-box-shadow: 0 0.5rem 0.7rem rgba(black, 0.1);
 
 //== Pagination
 $pagination-disabled-color: $gray-500;
@@ -122,6 +135,7 @@ $toast-header-color: $gray-500;
 
 //== Modals
 $modal-content-border-width: 0;
+$modal-content-border-radius: $border-radius-lg;
 $modal-header-padding-y: 0.75rem;
 $modal-header-padding-x: 1rem;
 
@@ -132,7 +146,6 @@ $alert-color-level: -10;
 
 //== Progress bar
 $progress-height: 4px;
-$progress-border-radius: $border-radius-sm;
 $progress-bg: $gray-100;
 $progress-box-shadow: none;
 
@@ -153,4 +166,3 @@ $pre-color: dummyinvalildcolor; // disable pre color specification with invalid
 $custom-checkbox-indicator-border-radius: 0px;
 $custom-control-indicator-focus-box-shadow: none;
 $custom-control-indicator-size: 1.2rem;
-

+ 4 - 1
packages/app/src/styles/_override-bootstrap.scss

@@ -153,7 +153,10 @@
 
   // label
   label {
-    font-weight: 700;
+    // add with-no-font-weight class in case you do not want to apply font-weight 700 to label
+    :not(.with-no-font-weight) {
+      font-weight: 700;
+    }
   }
 
   // disabled button (reproduction from bootstrap3.)

+ 9 - 6
packages/app/src/styles/_page-tree.scss

@@ -1,9 +1,4 @@
 .grw-pagetree {
-  .grw-pagetree-item-wrapper {
-    margin-top: 10px;
-    margin-left: 10px;
-  }
-
   .grw-pagetree-item {
     &:hover {
       opacity: 0.7;
@@ -19,8 +14,14 @@
 
     .grw-pagetree-button {
       background-color: transparent;
+      transition: all 0.2s ease-out;
+      transform: rotate(0deg);
+
+      &:focus {
+        box-shadow: none;
+      }
 
-      &.rotate {
+      &.grw-pagetree-open {
         transform: rotate(90deg);
       }
     }
@@ -28,6 +29,8 @@
     .grw-pagetree-title-anchor {
       width: 100%;
       overflow: hidden;
+      color: inherit;
+      text-decoration: none;
 
       .grw-pagetree-title {
         overflow: hidden;

+ 26 - 17
packages/app/src/styles/_search.scss

@@ -73,19 +73,17 @@
   .dropdown-toggle {
     min-width: 95px;
     padding-left: 1.5rem;
-    border-top-left-radius: 40px;
-    border-bottom-left-radius: 40px;
   }
 
   .search-typeahead {
     // corner radius
-    border-top-right-radius: 40px;
-    border-bottom-right-radius: 40px;
+    border-top-right-radius: $border-radius;
+    border-bottom-right-radius: $border-radius;
     .rbt-input-main {
       padding-right: 58px;
       // corner radius
-      border-top-right-radius: 40px;
-      border-bottom-right-radius: 40px;
+      border-top-right-radius: $border-radius;
+      border-bottom-right-radius: $border-radius;
     }
     .rbt-menu {
       @extend .dropdown-menu-right;
@@ -163,36 +161,35 @@
       }
     }
   }
-  .search-typeahead {
-    border-radius: 0 25px 25px 0;
-  }
 }
 
+// TODO : keep the selected list in the same positino as other lists
+// TASK : https://redmine.weseek.co.jp/issues/82470
 .search-result {
   .search-result-list {
     position: sticky;
     top: 0px;
 
     .search-result-list-scroll {
-      height: calc(100vh - 125px); // subtract the height of SearchControl component
+      // subtract the height of SearchControl component + a gap made above #page-wrapper and below #main
+      height: calc(100vh - 117px);
       overflow-y: scroll;
     }
     .nav.nav-pills {
       > .page-list-li {
+        &.active {
+          // add this negative margin to avoid inner elements of .page-list-li.active
+          // moving to right side by border-left's px size.
+          margin-left: -3px;
+          border-left: solid 3px transparent;
+        }
         > a {
-          height: 123px;
-          padding: 2px 4px;
           word-break: break-all;
-          border-radius: 0;
 
           &:hover {
             color: inherit;
             text-decoration: none;
           }
-          &.active {
-            padding-right: 5px;
-            border-left: solid 3px transparent;
-          }
           > * {
             margin-right: 3px;
           }
@@ -249,6 +246,18 @@
   }
 }
 
+// class to add to .grw-navbar to hide its navbar above the displaying page
+body.on-search {
+  .grw-navbar {
+    position: fixed !important;
+    width: 100vw;
+  }
+  .page-wrapper {
+    position: relative;
+    top: $grw-navbar-border-width;
+  }
+}
+
 // 2021/9/22 TODO: Remove after moving to SearchResult
 .search-page-input {
   position: sticky;

+ 0 - 2
packages/app/src/styles/_subnav.scss

@@ -42,7 +42,6 @@
   .btn-bookmark {
     height: 40px;
     font-size: 20px;
-    border-radius: $border-radius-xl;
   }
 
   .btn-bookmark {
@@ -94,7 +93,6 @@
 
       height: 30px;
       font-size: 15px !important;
-      border-radius: $border-radius-xl;
     }
 
     .total-likes,

+ 1 - 1
packages/app/src/styles/_tag.scss

@@ -8,7 +8,7 @@
   .grw-tag-label {
     font-size: 12px;
     font-weight: normal;
-    border-radius: $border-radius-sm;
+    border-radius: $border-radius;
   }
 }
 

+ 21 - 0
packages/app/src/styles/theme/_apply-colors-dark.scss

@@ -18,6 +18,7 @@ $border-color-global: $gray-500 !default;
 $border-color-toc: $border-color-global !default;
 
 // override bootstrap variables
+$text-muted: $gray-550;
 $table-dark-color: $color-table;
 $table-dark-bg: $bgcolor-table;
 $table-dark-border-color: $border-color-table;
@@ -25,6 +26,7 @@ $table-dark-hover-color: $color-table-hover;
 $table-dark-hover-bg: $bgcolor-table-hover;
 $border-color: $border-color-global;
 
+@import 'reboot-bootstrap-text';
 @import 'reboot-bootstrap-border-colors';
 @import 'reboot-bootstrap-tables';
 
@@ -249,6 +251,25 @@ ul.pagination {
 .grw-sidebar {
   // List
   @include override-list-group-item($color-list, $bgcolor-sidebar-list-group, $color-list-hover, $bgcolor-list-hover, $color-list-active, $bgcolor-list-active);
+
+  // Pagetree
+  .grw-pagetree {
+    .grw-pagetree-item {
+      .grw-triangle-icon {
+        &:not(:hover) {
+          svg {
+            fill: $gray-500;
+          }
+        }
+      }
+      &:hover {
+        background: $bgcolor-list-hover;
+      }
+      &:active {
+        background: lighten($bgcolor-list-hover, 5%);
+      }
+    }
+  }
 }
 
 /*

+ 23 - 2
packages/app/src/styles/theme/_apply-colors-light.scss

@@ -2,8 +2,8 @@
 $color-list: $color-global !default;
 $bgcolor-list: $bgcolor-global !default;
 $color-list-hover: $color-global !default;
-$bgcolor-list-hover: darken($bgcolor-global, 3%) !default;
-$bgcolor-list-active: $primary !default;
+$bgcolor-list-hover: lighten($primary, 72%) !default;
+$bgcolor-list-active: lighten($primary, 65%) !default;
 $color-list-active: color-yiq($bgcolor-list-active) !default;
 $bgcolor-subnav: darken($bgcolor-global, 3%) !default;
 $color-table: $color-global !default;
@@ -18,6 +18,7 @@ $border-color-global: $gray-300 !default;
 $border-color-toc: $border-color-global !default;
 
 // override bootstrap variables
+$text-muted: $gray-500;
 $table-color: $color-table;
 $table-bg: $bgcolor-table;
 $table-border-color: $border-color-table;
@@ -25,6 +26,7 @@ $table-hover-color: $color-table-hover;
 $table-hover-bg: $bgcolor-table-hover;
 $border-color: $border-color-global;
 
+@import 'reboot-bootstrap-text';
 @import 'reboot-bootstrap-border-colors';
 @import 'reboot-bootstrap-tables';
 
@@ -166,6 +168,25 @@ $border-color: $border-color-global;
 .grw-sidebar {
   // List
   @include override-list-group-item($color-list, $bgcolor-sidebar-list-group, $color-list-hover, $bgcolor-list-hover, $color-list-active, $bgcolor-list-active);
+
+  // Pagetree
+  .grw-pagetree {
+    .grw-pagetree-item {
+      .grw-triangle-icon {
+        &:not(:hover) {
+          svg {
+            fill: $gray-400;
+          }
+        }
+      }
+      &:hover {
+        background: $bgcolor-list-hover;
+      }
+      &:active {
+        background: $bgcolor-list-active;
+      }
+    }
+  }
 }
 
 /*

+ 13 - 0
packages/app/src/styles/theme/_apply-colors.scss

@@ -20,6 +20,8 @@ $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;
+$color-search-page-list-title: $color-global !default;
+$color-search-page-list-snippet: $gray-600 !default;
 
 // override bootstrap variables
 $body-bg: $bgcolor-global;
@@ -599,6 +601,9 @@ body.pathname-sidebar {
  */
 .search-result {
   .search-result-list {
+    .search-control {
+      background-color: $bgcolor-global;
+    }
     .page-list {
       .highlighted-keyword {
         background-color: $bgcolor-keyword-highlighted;
@@ -619,6 +624,14 @@ body.pathname-sidebar {
       }
     }
   }
+
+  .search-result-page-title {
+    color: $color-search-page-list-title;
+  }
+
+  .search-result-list-snippet {
+    color: $color-search-page-list-snippet;
+  }
 }
 
 /*

+ 3 - 0
packages/app/src/styles/theme/_reboot-bootstrap-text.scss

@@ -0,0 +1,3 @@
+.text-muted {
+  color: $text-muted !important;
+}