Explorar o código

Merge branch 'feat/search-implement' into feat/77524-search-result-conent-page

* feat/search-implement: (32 commits)
  81922 add default value
  81405 remove unused code
  81405 remove unnecessary comments
  81405 get value from crowi configManager
  81405 read pageLimitationContainer from adminCustomizeContainer
  81405 change method name
  81405 use customizeParams for the default liimit number for pages to display
  81403 add tentative selectbox to search control
  77804 change var name
  77804 add default
  77804 import footstamp from packages/ui
  77804 change code position to prioritise styles of  _search.scss over _page_list.scss
  77804 change icon color
  77804 add @growi/app to import footstamp component
  77804 add null check
  77804 add condition to show footprint
  77804 change footstamp's color and margin
  77804 add tentative foot print
  refactor as ts
  typescriptize
  ...
Mao %!s(int64=4) %!d(string=hai) anos
pai
achega
ef93dc2f59

+ 4 - 1
packages/app/resource/locales/en_US/translation.json

@@ -576,7 +576,10 @@
     "deletion_modal_header": "Delete page",
     "delete_completely": "Delete completely",
     "include_certain_path" : "Include {{pathToInclude}} path ",
-    "delete_all_selected_page" : "Delete All"
+    "delete_all_selected_page" : "Delete All",
+    "number_of_list_to_display" : "Display",
+    "page_number_unit" : "pages"
+
   },
   "security_setting": {
     "Guest Users Access": "Guest users access",

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

@@ -576,7 +576,9 @@
     "deletion_modal_header": "以下のページを削除",
     "delete_completely": "完全に削除する",
     "include_certain_path": "{{pathToInclude}}下を含む ",
-    "delete_all_selected_page" : "一括削除"
+    "delete_all_selected_page" : "一括削除",
+    "number_of_list_to_display" : "表示件数",
+    "page_number_unit" : "件"
   },
   "security_setting": {
     "Guest Users Access": "ゲストユーザーのアクセス",

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

@@ -849,7 +849,9 @@
 		"deletion_modal_header": "删除页",
 		"delete_completely": "完全删除",
     "include_certain_path": "包含 {{pathToInclude}} 路径 ",
-    "delete_all_selected_page": "删除所有"
+    "delete_all_selected_page": "删除所有",
+    "number_of_list_to_display" : "显示器的数量",
+    "page_number_unit" : "例"
 	},
 	"to_cloud_settings": "進入 GROWI.cloud 的管理界面",
 	"login": {

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

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

+ 15 - 11
packages/app/src/components/SearchPage.jsx

@@ -6,7 +6,6 @@ import { withTranslation } from 'react-i18next';
 
 import { withUnstatedContainers } from './UnstatedUtils';
 import AppContainer from '~/client/services/AppContainer';
-
 import { toastError } from '~/client/util/apiNotification';
 import SearchPageLayout from './SearchPage/SearchPageLayout';
 import SearchResultContent from './SearchPage/SearchResultContent';
@@ -30,23 +29,24 @@ class SearchPage extends React.Component {
       searchedKeyword: '',
       searchResults: [],
       searchResultMeta: {},
-      focusedSearchResultData: {},
+      focusedSearchResultData: null,
       selectedPages: new Set(),
       searchResultCount: 0,
       activePage: 1,
-      pagingLimit: 10, // change to an appropriate limit number
+      pagingLimit: this.props.appContainer.config.pageLimitationL,
       excludeUsersHome: true,
       excludeTrash: true,
     };
 
     this.changeURL = this.changeURL.bind(this);
     this.search = this.search.bind(this);
-    this.searchHandler = this.searchHandler.bind(this);
+    this.onSearchInvoked = this.onSearchInvoked.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);
+    this.onPagingLimitChanged = this.onPagingLimitChanged.bind(this);
   }
 
   componentDidMount() {
@@ -105,20 +105,23 @@ class SearchPage extends React.Component {
    * 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.
+  async onSearchInvoked(data) {
     this.setState({ activePage: 1 }, () => this.search(data));
   }
 
+  /**
+   * change number of pages to display per page and execute search method after.
+   */
+  async onPagingLimitChanged(limit) {
+    this.setState({ pagingLimit: limit }, () => this.search({ keyword: this.state.searchedKeyword }));
+  }
+
   async search(data) {
     const keyword = data.keyword;
     if (keyword === '') {
@@ -223,7 +226,7 @@ class SearchPage extends React.Component {
       <SearchControl
         searchingKeyword={this.state.searchingKeyword}
         appContainer={this.props.appContainer}
-        onSearchInvoked={this.searchHandler}
+        onSearchInvoked={this.onSearchInvoked}
         onExcludeUsersHome={this.onExcludeUsersHome}
         onExcludeTrash={this.onExcludeTrash}
       >
@@ -240,6 +243,8 @@ class SearchPage extends React.Component {
           SearchResultContent={this.renderSearchResultContent}
           searchResultMeta={this.state.searchResultMeta}
           searchingKeyword={this.state.searchedKeyword}
+          onPagingLimitChanged={this.onPagingLimitChanged}
+          initialPagingLimit={this.props.appContainer.config.pageLimitationL || 50}
         >
         </SearchPageLayout>
       </div>
@@ -256,7 +261,6 @@ const SearchPageWrapper = withUnstatedContainers(SearchPage, [AppContainer]);
 SearchPage.propTypes = {
   t: PropTypes.func.isRequired, // i18next
   appContainer: PropTypes.instanceOf(AppContainer).isRequired,
-
   query: PropTypes.object,
 };
 SearchPage.defaultProps = {

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

@@ -12,7 +12,9 @@ type Props = {
   SearchResultList: React.FunctionComponent,
   SearchResultContent: React.FunctionComponent,
   searchResultMeta: SearchResultMeta,
-  searchingKeyword: string
+  searchingKeyword: string,
+  initialPagingLimit: number,
+  onPagingLimitChanged: (limit: number) => void
 }
 
 const SearchPageLayout: FC<Props> = (props: Props) => {
@@ -27,13 +29,23 @@ const SearchPageLayout: FC<Props> = (props: Props) => {
         <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">
+          <div className="d-flex align-items-center justify-content-between mt-1 mb-3">
+            <div className="search-result-meta text-nowrap mr-3">
               <span className="font-weight-light">{t('search_result.result_meta')} </span>
               <span className="h5">{`"${searchingKeyword}"`}</span>
               {/* Todo: replace "1-10" to the appropriate value */}
               <span className="ml-3">1-10 / {searchResultMeta.total || 0}</span>
             </div>
+            <div className="input-group search-result-select-group">
+              <div className="input-group-prepend">
+                <label className="input-group-text text-secondary" htmlFor="inputGroupSelect01">{t('search_result.number_of_list_to_display')}</label>
+              </div>
+              <select className="custom-select" id="inputGroupSelect01" onChange={(e) => { props.onPagingLimitChanged(Number(e.target.value)) }}>
+                {[20, 50, 100, 200].map((limit) => {
+                  return <option selected={limit === props.initialPagingLimit} value={limit}>{limit}{t('search_result.page_number_unit')}</option>;
+                })}
+              </select>
+            </div>
           </div>
 
           <div className="page-list">

+ 2 - 5
packages/app/src/components/SearchPage/SearchResultListItem.tsx

@@ -7,12 +7,8 @@ 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,
 }
@@ -80,8 +76,9 @@ const SearchResultListItem: FC<Props> = (props:Props) => {
   // Add prefix 'id_' in pageId, because scrollspy of bootstrap doesn't work when the first letter of id attr of target component is numeral.
   const pageId = `#${pageData._id}`;
 
+  const isPathIncludedHtml = pageMeta.elasticSearchResult.highlightedPath != null;
   const dPagePath = new DevidedPagePath(pageData.path, false, true);
-  const pagePathElem = <PagePathLabel page={pageData} isFormerOnly />;
+  const pagePathElem = <PagePathLabel path={pageMeta.elasticSearchResult.highlightedPath} isFormerOnly isPathIncludedHtml={isPathIncludedHtml} />;
 
   const onClickInvoked = (pageId) => {
     if (props.onClickInvoked != null) {

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

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

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

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

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

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

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

@@ -12,7 +12,7 @@ export type IPageSearchResultData = {
     bookmarkCount: number,
     elasticSearchResult: {
       snippet: string,
-      matchedPath: string,
+      highlightedPath: string,
     },
   },
 }

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

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

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

@@ -179,6 +179,8 @@ module.exports = function(crowi, app) {
             elasticSearchResult: data.elasticSearchResult,
           };
 
+          pageData._doc.seenUserCount = (pageData.seenUsers && pageData.seenUsers.length) || 0;
+
           return { pageData, pageMeta };
         })
         .sort((page1, page2) => {

+ 1 - 2
packages/app/src/server/service/search.js

@@ -170,8 +170,7 @@ class SearchService {
 
       data.elasticSearchResult = {
         snippet: filterXss.process(snippet),
-        // todo: use filter xss.process() for matchedPath;
-        matchedPath: pathMatch,
+        highlightedPath: filterXss.process(pathMatch),
       };
     });
     return esResult;

+ 13 - 2
packages/app/src/styles/_search.scss

@@ -195,14 +195,25 @@
             margin-right: 3px;
           }
         }
+        .page-list-meta {
+          > span {
+            margin-right: 12px;
+          }
+          .footstamp-icon {
+            margin-right: 2px;
+          }
+        }
       }
     }
 
     .search-result-meta {
-      margin-bottom: 10px;
       font-weight: bold;
     }
-
+    .search-result-select-group {
+      > select {
+        max-width: 8rem;
+      }
+    }
     .search-result-list-delete-checkbox {
       margin: 0 10px 0 0;
       vertical-align: middle;

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

@@ -18,7 +18,8 @@ $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%);
+$bgcolor-search-item-active: lighten($bordercolor-search-item-left-active, 76%) !default;
+$color-search-item-pagelist-meta: $gray-500 !default;
 
 // override bootstrap variables
 $body-bg: $bgcolor-global;
@@ -600,6 +601,12 @@ body.pathname-sidebar {
           }
         }
       }
+      .page-list-meta {
+        color: $color-search-item-pagelist-meta;
+        svg {
+          fill: $color-search-item-pagelist-meta;
+        }
+      }
     }
   }
 }

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

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

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

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

+ 12 - 4
packages/ui/src/components/PagePath/PageListMeta.jsx

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

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

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

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

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

+ 1 - 3
packages/app/src/components/FootstampIcon.jsx → packages/ui/src/components/SearchPage/FootstampIcon.jsx

@@ -1,6 +1,6 @@
 import React from 'react';
 
-const FootstampIcon = () => (
+export const FootstampIcon = () => (
   <svg
     xmlns="http://www.w3.org/2000/svg"
     width="16"
@@ -27,5 +27,3 @@ const FootstampIcon = () => (
     <path d="M13.49,7.57a.81.81,0,0,0-.8.71l-.1.71a.82.82,0,0,0,.7.91h.11a.81.81,0,0,0,.8-.71l.1-.71a.81.81,0,0,0-.7-.91Z" />
   </svg>
 );
-
-export default FootstampIcon;

+ 1 - 0
packages/ui/src/index.ts

@@ -2,3 +2,4 @@ export * from './components/Attachment/Attachment';
 export * from './components/PagePath/PageListMeta';
 export * from './components/PagePath/PagePathLabel';
 export * from './components/User/UserPicture';
+export * from './components/SearchPage/FootstampIcon';