فهرست منبع

Merge pull request #820 from weseek/feat/819-search-under-current-page

Feat/819 search under current page
Yuki Takei 7 سال پیش
والد
کامیت
a24d100b4f

+ 3 - 0
CHANGES.md

@@ -4,9 +4,12 @@ CHANGES
 ## 3.3.7-RC
 ## 3.3.7-RC
 
 
 * Feature: Editor toolbar
 * Feature: Editor toolbar
+* Feature: `prefix:/path` searching syntax to filter with page path prefix
+* Feature: Add an option to filter only children to searching box of navbar
 * Improvement: Suggest page path when moving/duplicating/searching
 * Improvement: Suggest page path when moving/duplicating/searching
 * Fix: Anonymous users couldn't search
 * Fix: Anonymous users couldn't search
     * Introduced by 3.3.6
     * Introduced by 3.3.6
+* I18n: Searching help
 * Support: Prepare to suppoert Node.js v10
 * Support: Prepare to suppoert Node.js v10
 * Support: Upgrade libs
 * Support: Upgrade libs
     * node-sass
     * node-sass

+ 32 - 0
resource/locales/en-US/translation.json

@@ -163,6 +163,38 @@
 
 
   "Security settings": "Security settings",
   "Security settings": "Security settings",
 
 
+  "header_search_box": {
+    "label": {
+      "This tree": "This tree"
+    },
+    "item_label": {
+      "This tree": "Only children of this tree"
+    }
+  },
+
+  "search_help": {
+    "title": "Searching Help",
+    "and": {
+      "syntax help": "divide with space",
+      "desc": "Search pages that include both {{word1}}, {{word2}} in the title or body"
+    },
+    "exclude": {
+      "desc": "Exclude pages that include {{word}} in the title or body"
+    },
+    "phrase": {
+      "syntax help": "surround with double quotes",
+      "desc": "Search pages that include the phrase \"{{phrase}}\""
+    },
+    "prefix": {
+      "desc": "Search only the pages that the title start with {{path}}"
+    },
+    "exclude_prefix": {
+      "desc": "Exclude the pages that the title start with {{path}}"
+    }
+  },
+  "search": {
+    "search page bodies": "Hit [Enter] key to full-text search"
+  },
 
 
   "page_page": {
   "page_page": {
       "notice": {
       "notice": {

+ 33 - 0
resource/locales/ja/translation.json

@@ -181,6 +181,39 @@
   "Current API Token": "現在のAPI Token",
   "Current API Token": "現在のAPI Token",
   "Update API Token": "API Tokenを更新",
   "Update API Token": "API Tokenを更新",
 
 
+  "header_search_box": {
+    "label": {
+      "This tree": "この階層"
+    },
+    "item_label": {
+      "This tree": "この階層下の子ページのみ"
+    }
+  },
+
+  "search_help": {
+    "title": "検索のヘルプ",
+    "and": {
+      "syntax help": "スペース区切り",
+      "desc": "ページ名 or 本文に {{word1}}, {{word2}} の両方を含むページを検索"
+    },
+    "exclude": {
+      "desc": "ページ名 or 本文に {{word}} を含むページを除外"
+    },
+    "phrase": {
+      "syntax help": "ダブルクォートで囲う",
+      "desc": "{{phrase}} という文章を含むページを検索"
+    },
+    "prefix": {
+      "desc": "ページ名が {{path}} から始まるページに絞る"
+    },
+    "exclude_prefix": {
+      "desc": "ページ名が {{path}} から始まるページを除外"
+    }
+  },
+  "search": {
+    "search page bodies": "[Enter] キー押下で全文検索"
+  },
+
   "page_page": {
   "page_page": {
       "notice": {
       "notice": {
           "version": "これは現在の版ではありません。",
           "version": "これは現在の版ではありません。",

+ 2 - 4
src/client/js/app.js

@@ -20,7 +20,6 @@ import { EditorOptions, PreviewOptions } from './components/PageEditor/OptionsSe
 import SavePageControls from './components/SavePageControls';
 import SavePageControls from './components/SavePageControls';
 import PageEditorByHackmd from './components/PageEditorByHackmd';
 import PageEditorByHackmd from './components/PageEditorByHackmd';
 import Page             from './components/Page';
 import Page             from './components/Page';
-import PageListSearch   from './components/PageListSearch';
 import PageHistory      from './components/PageHistory';
 import PageHistory      from './components/PageHistory';
 import PageComments     from './components/PageComments';
 import PageComments     from './components/PageComments';
 import CommentForm from './components/PageComment/CommentForm';
 import CommentForm from './components/PageComment/CommentForm';
@@ -273,10 +272,9 @@ if (!pageRevisionId && draft != null) {
  *  value: React Element
  *  value: React Element
  */
  */
 const componentMappings = {
 const componentMappings = {
-  'search-top': <HeaderSearchBox crowi={crowi} />,
+  'search-top': <I18nextProvider i18n={i18n}><HeaderSearchBox crowi={crowi} /></I18nextProvider>,
   'search-sidebar': <HeaderSearchBox crowi={crowi} />,
   'search-sidebar': <HeaderSearchBox crowi={crowi} />,
-  'search-page': <SearchPage crowi={crowi} crowiRenderer={crowiRenderer} />,
-  'page-list-search': <PageListSearch crowi={crowi} />,
+  'search-page': <I18nextProvider i18n={i18n}><SearchPage crowi={crowi} crowiRenderer={crowiRenderer} /></I18nextProvider>,
 
 
   //'revision-history': <PageHistory pageId={pageId} />,
   //'revision-history': <PageHistory pageId={pageId} />,
   'seen-user-list': <SeenUserList pageId={pageId} crowi={crowi} />,
   'seen-user-list': <SeenUserList pageId={pageId} crowi={crowi} />,

+ 0 - 37
src/client/js/components/HeaderSearchBox.js

@@ -1,37 +0,0 @@
-// This is the root component for #search-top
-
-import React from 'react';
-
-import HeaderSearchForm from './HeaderSearchBox/HeaderSearchForm';
-// import SearchSuggest from './HeaderSearchBox/SearchSuggest'; // omit since using react-bootstrap-typeahead in SearchForm
-
-export default class SearchBox extends React.Component {
-
-  constructor(props) {
-    super(props);
-  }
-
-  render() {
-    return (
-      <div className="search-box">
-        <HeaderSearchForm />
-        {/* omit since using react-bootstrap-typeahead in SearchForm
-        <SearchSuggest
-          searchingKeyword={this.state.searchingKeyword}
-          searchedPages={this.state.searchedPages}
-          searchError={this.state.searchError}
-          searching={this.state.searching}
-          focused={this.state.focused}
-          />
-        */}
-      </div>
-    );
-  }
-}
-
-SearchBox.propTypes = {
-  //pollInterval: PropTypes.number,
-};
-SearchBox.defaultProps = {
-  //pollInterval: 1000,
-};

+ 99 - 0
src/client/js/components/HeaderSearchBox.jsx

@@ -0,0 +1,99 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { translate } from 'react-i18next';
+
+import FormGroup from 'react-bootstrap/es/FormGroup';
+import Button from 'react-bootstrap/es/Button';
+import DropdownButton from 'react-bootstrap/es/DropdownButton';
+import MenuItem from 'react-bootstrap/es/MenuItem';
+import InputGroup from 'react-bootstrap/es/InputGroup';
+
+import SearchForm from './SearchForm';
+
+
+class HeaderSearchBox extends React.Component {
+
+  constructor(props) {
+    super(props);
+
+    this.state = {
+      text: '',
+      isScopeChildren: false,
+    };
+
+    this.onInputChange = this.onInputChange.bind(this);
+    this.onClickAllPages = this.onClickAllPages.bind(this);
+    this.onClickChildren = this.onClickChildren.bind(this);
+    this.search = this.search.bind(this);
+  }
+
+  componentDidMount() {
+  }
+
+  componentWillUnmount() {
+  }
+
+  onInputChange(text) {
+    this.setState({ text });
+  }
+
+  onClickAllPages() {
+    this.setState({ isScopeChildren: false });
+  }
+
+  onClickChildren() {
+    this.setState({ isScopeChildren: true });
+  }
+
+  search() {
+    const url = new URL(location.href);
+    url.pathname = '/_search';
+
+    // construct search query
+    let q = this.state.text;
+    if (this.state.isScopeChildren) {
+      q += ` prefix:${location.pathname}`;
+    }
+    url.searchParams.append('q', q);
+
+    location.href = url.href;
+  }
+
+  render() {
+    const t = this.props.t;
+    const scopeLabel = this.state.isScopeChildren
+      ? t('header_search_box.label.This tree')
+      : 'All pages';
+
+    return (
+      <FormGroup>
+        <InputGroup>
+        <InputGroup.Button className="btn-group-dropdown-scope">
+          <DropdownButton id="dbScope" title={scopeLabel}>
+            <MenuItem onClick={this.onClickAllPages}>All pages</MenuItem>
+            <MenuItem onClick={this.onClickChildren}>{ t('header_search_box.item_label.This tree') }</MenuItem>
+          </DropdownButton>
+        </InputGroup.Button>
+          <SearchForm t={this.props.t}
+            crowi={this.props.crowi}
+            onInputChange={this.onInputChange}
+            onSubmit={this.search}
+            placeholder="Search ..."
+          />
+          <InputGroup.Button className="btn-group-submit-search">
+            <Button bsStyle="link" onClick={this.search}>
+              <i className="icon-magnifier"></i>
+            </Button >
+          </InputGroup.Button>
+        </InputGroup>
+      </FormGroup>
+    );
+  }
+}
+
+HeaderSearchBox.propTypes = {
+  t: PropTypes.func.isRequired,               // i18next
+  crowi: PropTypes.object.isRequired,
+};
+
+export default translate()(HeaderSearchBox);

+ 0 - 63
src/client/js/components/HeaderSearchBox/HeaderSearchForm.js

@@ -1,63 +0,0 @@
-import React from 'react';
-
-import FormGroup from 'react-bootstrap/es/FormGroup';
-import Button from 'react-bootstrap/es/Button';
-import InputGroup from 'react-bootstrap/es/InputGroup';
-
-import SearchForm from '../SearchForm';
-
-
-// Header.SearchForm
-export default class HeaderSearchForm extends React.Component {
-
-  constructor(props) {
-    super(props);
-
-    this.crowi = window.crowi; // FIXME
-
-    this.onSubmit = this.onSubmit.bind(this);
-  }
-
-  componentDidMount() {
-  }
-
-  componentWillUnmount() {
-  }
-
-  onSubmit() {
-    this.refs.form.submit();
-  }
-
-  render() {
-    return (
-      <form
-        ref='form'
-        action='/_search'
-        className='search-form form-group input-group search-input-group hidden-print'
-      >
-        <FormGroup>
-          <InputGroup>
-            <SearchForm
-              crowi={this.crowi}
-              onSubmit={this.onSubmit}
-              placeholder="Search ..."
-            />
-            <InputGroup.Button>
-              <Button type="submit" bsStyle="link">
-                <i className="icon-magnifier"></i>
-              </Button >
-            </InputGroup.Button>
-          </InputGroup>
-        </FormGroup>
-
-      </form>
-
-    );
-  }
-}
-
-HeaderSearchForm.propTypes = {
-};
-
-HeaderSearchForm.defaultProps = {
-};

+ 0 - 61
src/client/js/components/HeaderSearchBox/SearchSuggest.js

@@ -1,61 +0,0 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-
-import ListView from '../PageList/ListView';
-
-export default class SearchSuggest extends React.Component {
-
-  render() {
-    if (!this.props.focused) {
-      return <div></div>;
-    }
-
-    if (this.props.searching) {
-      return (
-        <div className="search-suggest" id="search-suggest">
-          <i className="searcing fa fa-circle-o-notch fa-spin fa-fw"></i> Searching ...
-        </div>
-      );
-    }
-
-    if (this.props.searchError !== null) {
-      return (
-        <div className="search-suggest" id="search-suggest">
-          <i className="searcing fa fa-warning"></i> Error on searching.
-        </div>
-      );
-    }
-
-    if (this.props.searchedPages.length < 1) {
-      if (this.props.searchingKeyword !== '') {
-        return (
-          <div className="search-suggest" id="search-suggest">
-            No results for "{this.props.searchingKeyword}".
-          </div>
-        );
-      }
-      return <div></div>;
-    }
-
-    return (
-      <div className="search-suggest" id="search-suggest">
-        <ListView pages={this.props.searchedPages} />
-      </div>
-    );
-  }
-
-}
-
-SearchSuggest.propTypes = {
-  searchedPages: PropTypes.array.isRequired,
-  searchingKeyword: PropTypes.string.isRequired,
-  searching: PropTypes.bool.isRequired,
-};
-
-SearchSuggest.defaultProps = {
-  searchedPages: [],
-  searchingKeyword: '',
-  searchError: null,
-  searching: false,
-  focused: false,
-};

+ 0 - 169
src/client/js/components/PageListSearch.js

@@ -1,169 +0,0 @@
-// This is the root component for #page-list-search
-
-import React from 'react';
-import PropTypes from 'prop-types';
-
-import SearchResult from './SearchPage/SearchResult';
-
-export default class PageListSearch extends React.Component {
-
-  constructor(props) {
-    super(props);
-
-    this.state = {
-      location: location,
-      tree: $('#search-listpage-input').data('path'),
-      searchingKeyword: this.props.query.q || '',
-      searchedKeyword: '',
-      searchedPages: [],
-      searchResultMeta: {},
-      searchError: null,
-    };
-
-    this.changeURL = this.changeURL.bind(this);
-    this.search = this.search.bind(this);
-    this.handleChange = this.handleChange.bind(this);
-  }
-
-  componentDidMount() {
-    const $pageListSearchForm = $('#search-listpage-input');
-
-    // search after page initialized
-    if (this.state.searchingKeyword !== '') {
-      const keyword = this.state.searchingKeyword;
-      $pageListSearchForm.val(keyword);
-      this.search({keyword});
-    }
-
-    // This is temporary data bind ... (out of component)
-    $('#search-listpage-form').on('submit', () => {
-      const keyword = $pageListSearchForm.val();
-      if (keyword != this.state.searchingKeyword)  {
-        this.search({keyword});
-      }
-
-      return false;
-    });
-
-    $('#search-listpage-clear').on('click', () => {
-      $pageListSearchForm.val('');
-      this.search({keyword: ''});
-
-      return false;
-    });
-  }
-
-  componentWillUnmount() {
-  }
-
-  static getQueryByLocation(location) {
-    let search = location.search || '';
-    let query = {};
-
-    search.replace(/^\?/, '').split('&').forEach(function(element) {
-      let queryParts = element.split('=');
-      query[queryParts[0]] = decodeURIComponent(queryParts[1]).replace(/\+/g, ' ');
-    });
-
-    return query;
-  }
-
-  handleChange(event) {
-    // this is not fired now because of force-data-bound by jQuery
-    const keyword = event.target.value;
-    this.setState({searchedKeyword: keyword});
-    //console.log('Changed');
-  }
-
-  stopSearching() {
-    $('#content-main').show();
-    $('#search-listpage-clear').hide();
-    $('.main-container').removeClass('aside-hidden');
-  }
-
-  startSearching() {
-    $('#content-main').hide();
-    $('#search-listpage-clear').show();
-    $('.main-container').addClass('aside-hidden');
-  }
-
-  changeURL(keyword, refreshHash) {
-    const tree = this.state.tree;
-    let hash = location.hash || '';
-    // TODO 整理する
-    if (refreshHash || this.state.searchedKeyword !== '') {
-      hash = '';
-    }
-    if (window.history && window.history.pushState) {
-      window.history.pushState('', `Search - ${keyword}`, `${tree}?q=${keyword}${hash}`);
-    }
-  }
-
-  search(data) {
-    const keyword = data.keyword || '';
-    const tree = this.state.tree;
-
-    this.changeURL(keyword);
-    if (keyword === '') {
-      this.stopSearching();
-      this.setState({
-        searchingKeyword: '',
-        searchedPages: [],
-        searchResultMeta: {},
-        searchError: null,
-      });
-
-      return true;
-    }
-
-    this.startSearching();
-    this.setState({
-      searchingKeyword: keyword,
-    });
-
-    this.props.crowi.apiGet('/search', {q: keyword, tree: tree})
-    .then((res) => {
-      this.setState({
-        searchedKeyword: keyword,
-        searchedPages: res.data,
-        searchResultMeta: res.meta,
-      });
-    }).catch(err => {
-      this.setState({
-        searchError: err,
-      });
-    });
-  }
-
-  render() {
-    return (
-      <div>
-        <input
-          type="hidden"
-          name="q"
-          value={this.state.searchingKeyword}
-          onChange={this.handleChange}
-          className="form-control"
-          />
-        <SearchResult
-          tree={this.state.tree}
-          pages={this.state.searchedPages}
-          searchingKeyword={this.state.searchingKeyword}
-          searchResultMeta={this.state.searchResultMeta}
-          searchError={this.state.searchError}
-          crowi={this.props.crowi}
-          />
-      </div>
-    );
-  }
-}
-
-PageListSearch.propTypes = {
-  query: PropTypes.object,
-  crowi: PropTypes.object.isRequired,
-};
-PageListSearch.defaultProps = {
-  //pollInterval: 1000,
-  query: PageListSearch.getQueryByLocation(location || {}),
-};
-

+ 26 - 8
src/client/js/components/SearchForm.js

@@ -38,23 +38,39 @@ export default class SearchForm extends React.Component {
   }
   }
 
 
   getHelpElement() {
   getHelpElement() {
+    const t = this.props.t;
+
     return (
     return (
       <table className="table m-1 search-help">
       <table className="table m-1 search-help">
         <caption className="text-left text-primary p-2 mb-2">
         <caption className="text-left text-primary p-2 mb-2">
-          <h5 className="m-1"><i className="icon-magnifier pr-2 mb-2"/>Search Help</h5>
+          <h5 className="m-1"><i className="icon-magnifier pr-2 mb-2"/>{ t('search_help.title') }</h5>
         </caption>
         </caption>
         <tbody>
         <tbody>
           <tr>
           <tr>
-            <td className="text-right mt-0 pr-2 p-1"><code>keyword</code></td>
-            <th className="mr-2"><h6 className="pr-2 m-0 pt-1">記事名 or 本文に<samp>"keyword"</samp>を含む</h6></th>
+            <th className="text-right pt-2">
+              <code>word1</code> <code>word2</code><br></br>
+              <small>({ t('search_help.and.syntax help') })</small>
+            </th>
+            <td><h6 className="m-0 pt-1">{ t('search_help.and.desc', { word1: 'word1', word2: 'word2' }) }</h6></td>
+          </tr>
+          <tr>
+            <th className="text-right pt-2">
+              <code>"This is GROWI"</code><br></br>
+              <small>({ t('search_help.phrase.syntax help') })</small>
+            </th>
+            <td><h6 className="m-0 pt-1">{ t('search_help.phrase.desc', { phrase: 'This is GROWI' }) }</h6></td>
+          </tr>
+          <tr>
+            <th className="text-right pt-2"><code>-keyword</code></th>
+            <td><h6 className="m-0 pt-1">{ t('search_help.exclude.desc', { word: 'keyword' }) }</h6></td>
           </tr>
           </tr>
           <tr>
           <tr>
-            <td className="text-right mt-0 pr-2 p-1"><code>a b</code></td>
-            <th><h6 className="m-0 pt-1">文字列<samp>"a"</samp>と<samp>"b"</samp>を含む (スペース区切り)</h6></th>
+            <th className="text-right pt-2"><code>prefix:/user/</code></th>
+            <td><h6 className="m-0 pt-1">{ t('search_help.prefix.desc', { path: '/user/' }) }</h6></td>
           </tr>
           </tr>
           <tr>
           <tr>
-            <td className="text-right mt-0 pr-2 p-1"><code>-keyword</code></td>
-            <th><h6 className="m-0 pt-1">文字列<samp>"keyword"</samp>を含まない</h6></th>
+            <th className="text-right pt-2"><code>-prefix:/user/</code></th>
+            <td><h6 className="m-0 pt-1">{ t('search_help.exclude_prefix.desc', { path: '/user/' }) }</h6></td>
           </tr>
           </tr>
         </tbody>
         </tbody>
       </table>
       </table>
@@ -62,9 +78,10 @@ export default class SearchForm extends React.Component {
   }
   }
 
 
   render() {
   render() {
+    const t = this.props.t;
     const emptyLabel = (this.state.searchError !== null)
     const emptyLabel = (this.state.searchError !== null)
       ? 'Error on searching.'
       ? 'Error on searching.'
-      : 'No matches found on title... Hit [Enter] key so that search on contents.';
+      : t('search.search page bodies');
 
 
     return (
     return (
       <SearchTypeahead
       <SearchTypeahead
@@ -83,6 +100,7 @@ export default class SearchForm extends React.Component {
 }
 }
 
 
 SearchForm.propTypes = {
 SearchForm.propTypes = {
+  t: PropTypes.func.isRequired,               // i18next
   crowi: PropTypes.object.isRequired,
   crowi: PropTypes.object.isRequired,
   keyword: PropTypes.string,
   keyword: PropTypes.string,
   onSubmit: PropTypes.func.isRequired,
   onSubmit: PropTypes.func.isRequired,

+ 5 - 2
src/client/js/components/SearchPage.js

@@ -2,11 +2,12 @@
 
 
 import React from 'react';
 import React from 'react';
 import PropTypes from 'prop-types';
 import PropTypes from 'prop-types';
+import { translate } from 'react-i18next';
 
 
 import SearchPageForm from './SearchPage/SearchPageForm';
 import SearchPageForm from './SearchPage/SearchPageForm';
 import SearchResult from './SearchPage/SearchResult';
 import SearchResult from './SearchPage/SearchResult';
 
 
-export default class SearchPage extends React.Component {
+class SearchPage extends React.Component {
 
 
   constructor(props) {
   constructor(props) {
     super(props);
     super(props);
@@ -92,7 +93,7 @@ export default class SearchPage extends React.Component {
     return (
     return (
       <div>
       <div>
         <div className="search-page-input">
         <div className="search-page-input">
-          <SearchPageForm
+          <SearchPageForm t={this.props.t}
             crowi={this.props.crowi}
             crowi={this.props.crowi}
             onSearchFormChanged={this.search}
             onSearchFormChanged={this.search}
             keyword={this.state.searchingKeyword}
             keyword={this.state.searchingKeyword}
@@ -110,6 +111,7 @@ export default class SearchPage extends React.Component {
 }
 }
 
 
 SearchPage.propTypes = {
 SearchPage.propTypes = {
+  t: PropTypes.func.isRequired,               // i18next
   crowi: PropTypes.object.isRequired,
   crowi: PropTypes.object.isRequired,
   crowiRenderer: PropTypes.object.isRequired,
   crowiRenderer: PropTypes.object.isRequired,
   query: PropTypes.object,
   query: PropTypes.object,
@@ -120,3 +122,4 @@ SearchPage.defaultProps = {
   searchError: null,
   searchError: null,
 };
 };
 
 
+export default translate()(SearchPage);

+ 20 - 24
src/client/js/components/SearchPage/SearchPageForm.js

@@ -1,5 +1,10 @@
 import React from 'react';
 import React from 'react';
 import PropTypes from 'prop-types';
 import PropTypes from 'prop-types';
+
+import FormGroup from 'react-bootstrap/es/FormGroup';
+import Button from 'react-bootstrap/es/Button';
+import InputGroup from 'react-bootstrap/es/InputGroup';
+
 import SearchForm from '../SearchForm';
 import SearchForm from '../SearchForm';
 
 
 // Search.SearchForm
 // Search.SearchForm
@@ -14,20 +19,13 @@ export default class SearchPageForm extends React.Component {
     };
     };
 
 
     this.search = this.search.bind(this);
     this.search = this.search.bind(this);
-    this.onSubmit = this.onSubmit.bind(this);
     this.onInputChange = this.onInputChange.bind(this);
     this.onInputChange = this.onInputChange.bind(this);
   }
   }
 
 
-  search(keyword) {
-    if (this.state.searchedKeyword != keyword) {
-      this.props.onSearchFormChanged({keyword: keyword});
-      this.setState({searchedKeyword: keyword});
-    }
-  }
-
-  onSubmit(event) { // submit with button
-    event.preventDefault(); // prevent refreshing page
-    this.search(this.state.keyword);
+  search() {
+    const keyword = this.state.keyword;
+    this.props.onSearchFormChanged({keyword: keyword});
+    this.setState({searchedKeyword: keyword});
   }
   }
 
 
   onInputChange(input) { // for only submitting with button
   onInputChange(input) { // for only submitting with button
@@ -35,28 +33,26 @@ export default class SearchPageForm extends React.Component {
   }
   }
 
 
   render() {
   render() {
-    return (
-      <form ref='form'
-        className="form form-group input-group"
-        onSubmit={this.onSubmit}
-      >
-        <SearchForm
+    return <FormGroup>
+      <InputGroup>
+        <SearchForm t={this.props.t}
           crowi={this.props.crowi}
           crowi={this.props.crowi}
           onSubmit={this.search}
           onSubmit={this.search}
           keyword={this.state.searchedKeyword}
           keyword={this.state.searchedKeyword}
           onInputChange={this.onInputChange}
           onInputChange={this.onInputChange}
         />
         />
-        <span className="input-group-btn">
-          <button type="submit" className="btn btn-default">
-            <i className="search-top-icon icon-magnifier"></i>
-          </button>
-        </span>
-      </form>
-    );
+        <InputGroup.Button className="">
+          <Button onClick={this.search}>
+            <i className="icon-magnifier"></i>
+          </Button >
+        </InputGroup.Button>
+      </InputGroup>
+    </FormGroup>;
   }
   }
 }
 }
 
 
 SearchPageForm.propTypes = {
 SearchPageForm.propTypes = {
+  t: PropTypes.func.isRequired,               // i18next
   crowi: PropTypes.object.isRequired,
   crowi: PropTypes.object.isRequired,
   keyword: PropTypes.string,
   keyword: PropTypes.string,
   onSearchFormChanged: PropTypes.func.isRequired,
   onSearchFormChanged: PropTypes.func.isRequired,

+ 1 - 1
src/client/js/components/SearchTypeahead.js

@@ -156,7 +156,7 @@ export default class SearchTypeahead extends React.Component {
         <AsyncTypeahead
         <AsyncTypeahead
           {...this.props}
           {...this.props}
           ref="typeahead"
           ref="typeahead"
-          inputProps={{name: 'q', autoComplete: 'off'}}
+          inputProps={{autoComplete: 'off'}}
           isLoading={this.state.isLoading}
           isLoading={this.state.isLoading}
           labelKey="path"
           labelKey="path"
           minLength={0}
           minLength={0}

+ 3 - 0
src/client/styles/agile-admin/inverse/colors/_apply-colors-light.scss

@@ -28,6 +28,9 @@
  * GROWI search-top
  * GROWI search-top
  */
  */
 .search-top {
 .search-top {
+  .btn-group-dropdown-scope .dropdown-toggle {
+    background-color: rgba($bodycolor, 0.8);
+  }
   .rbt-input.form-control {
   .rbt-input.form-control {
     background-color: rgba($bodycolor, 0.9);
     background-color: rgba($bodycolor, 0.9);
   }
   }

+ 24 - 27
src/client/styles/scss/_search.scss

@@ -47,6 +47,7 @@
   }
   }
 }
 }
 
 
+
 // top and sidebar input styles
 // top and sidebar input styles
 .search-top, .search-sidebar {
 .search-top, .search-sidebar {
   .search-clear {
   .search-clear {
@@ -54,29 +55,36 @@
     right: 26px;
     right: 26px;
   }
   }
 
 
-  .input-group-btn {
-    position: absolute;
-    top: 0;
-    right: 0;
-    .btn {
-      padding: 4px 10px;
-    }
+  .btn-group-dropdown-scope .dropdown-toggle {
+    min-width: 95px;
+    height: 30px;
+    border-top-left-radius: 40px;
+    border-bottom-left-radius: 40px;
+    padding-top: 4px;
+    padding-bottom: 4px;
+    padding-right: 4px;
   }
   }
-
   // using react-bootstrap-typeahead
   // using react-bootstrap-typeahead
   // see: https://github.com/ericgio/react-bootstrap-typeahead
   // see: https://github.com/ericgio/react-bootstrap-typeahead
   .rbt-input.form-control {
   .rbt-input.form-control {
     border: none;
     border: none;
-    border-radius: 40px;
     border-top-right-radius: 40px;
     border-top-right-radius: 40px;
     border-bottom-right-radius: 40px;
     border-bottom-right-radius: 40px;
-    padding-top: 6px;
     height: 30px;
     height: 30px;
 
 
     .rbt-input-wrapper {
     .rbt-input-wrapper {
       margin-left: 8px;
       margin-left: 8px;
     }
     }
   }
   }
+  .btn-group-submit-search {
+    position: absolute;
+    top: 0;
+    right: 0;
+    .btn {
+      padding: 4px 10px;
+    }
+    z-index: 3;
+  }
 }
 }
 
 
 // layout
 // layout
@@ -85,25 +93,19 @@
   margin-bottom: 10px;
   margin-bottom: 10px;
 
 
   .rbt-input.form-control {
   .rbt-input.form-control {
-    width: 180px;
+    width: 200px;
     transition: 0.3s ease-out;
     transition: 0.3s ease-out;
     // focus
     // focus
     &.focus {
     &.focus {
       width: 300px;
       width: 300px;
     }
     }
   }
   }
-
-  table.search-help {
-    th, td {
-      border: none;
-    }
-  }
 }
 }
 .search-sidebar {
 .search-sidebar {
   .search-form, .form-group, .rbt-input.form-control, .input-group {
   .search-form, .form-group, .rbt-input.form-control, .input-group {
     width: 100%;
     width: 100%;
   }
   }
-  .input-group-btn {
+  .btn-group-submit-search {
     right: 30px;
     right: 30px;
   }
   }
 }
 }
@@ -169,18 +171,13 @@
   }
   }
 }
 }
 
 
-.search-page-input{
+.search-page-input {
   padding: 10px 0;
   padding: 10px 0;
   position: sticky;
   position: sticky;
   top: 0;
   top: 0;
   z-index: 99;
   z-index: 99;
-  .form{
-    margin: 0;
-    .input-group .form-control{
-      height: 100%;
-    }
-    .input-group-btn .btn{
-      height: 34px;
-    }
+  .input-group-btn .btn{
+    padding: 0px 10px;
+    height: 34px;
   }
   }
 }
 }

+ 2 - 8
src/server/routes/search.js

@@ -32,7 +32,7 @@ module.exports = function(crowi, app) {
    */
    */
   api.search = async function(req, res) {
   api.search = async function(req, res) {
     const user = req.user;
     const user = req.user;
-    const { q: keyword = null, tree = null, type = null } = req.query;
+    const { q: keyword = null, type = null } = req.query;
     let paginateOpts;
     let paginateOpts;
 
 
     try {
     try {
@@ -61,13 +61,7 @@ module.exports = function(crowi, app) {
 
 
     const result = {};
     const result = {};
     try {
     try {
-      let esResult;
-      if (tree) {
-        esResult = await search.searchKeywordUnderPath(keyword, tree, user, userGroups, searchOpts);
-      }
-      else {
-        esResult = await search.searchKeyword(keyword, user, userGroups, searchOpts);
-      }
+      const esResult = await search.searchKeyword(keyword, user, userGroups, searchOpts);
 
 
       // create score map for sorting
       // create score map for sorting
       // key: id , value: score
       // key: id , value: score

+ 61 - 71
src/server/util/search.js

@@ -437,42 +437,31 @@ SearchClient.prototype.initializeBoolQuery = function(query) {
   return query;
   return query;
 };
 };
 
 
-SearchClient.prototype.appendCriteriaForKeywordContains = function(query, keyword) {
+SearchClient.prototype.appendCriteriaForQueryString = function(query, queryString) {
   query = this.initializeBoolQuery(query);
   query = this.initializeBoolQuery(query);
 
 
-  const appendMultiMatchQuery = function(query, type, keywords) {
-    let target;
-    let operator = 'and';
-    switch (type) {
-      case 'not_match':
-        target = query.body.query.bool.must_not;
-        operator = 'or';
-        break;
-      case 'match':
-      default:
-        target = query.body.query.bool.must;
-    }
+  // parse
+  let parsedKeywords = this.parseQueryString(queryString);
 
 
-    target.push({
+  if (parsedKeywords.match.length > 0) {
+    const q = {
       multi_match: {
       multi_match: {
-        query: keywords.join(' '),
-        // TODO: By user's i18n setting, change boost or search target fields
+        query: parsedKeywords.match.join(' '),
         fields: ['path.ja^2', 'path.en^2', 'body.ja', 'body.en'],
         fields: ['path.ja^2', 'path.en^2', 'body.ja', 'body.en'],
-        operator: operator,
       },
       },
-    });
-
-    return query;
-  };
-
-  let parsedKeywords = this.getParsedKeywords(keyword);
-
-  if (parsedKeywords.match.length > 0) {
-    query = appendMultiMatchQuery(query, 'match', parsedKeywords.match);
+    };
+    query.body.query.bool.must.push(q);
   }
   }
 
 
   if (parsedKeywords.not_match.length > 0) {
   if (parsedKeywords.not_match.length > 0) {
-    query = appendMultiMatchQuery(query, 'not_match', parsedKeywords.not_match);
+    const q = {
+      multi_match: {
+        query: parsedKeywords.not_match.join(' '),
+        fields: ['path.ja^2', 'path.en^2', 'body.ja', 'body.en'],
+        operator: 'or'
+      },
+    };
+    query.body.query.bool.must_not.push(q);
   }
   }
 
 
   if (parsedKeywords.phrase.length > 0) {
   if (parsedKeywords.phrase.length > 0) {
@@ -512,19 +501,21 @@ SearchClient.prototype.appendCriteriaForKeywordContains = function(query, keywor
 
 
     query.body.query.bool.must_not.push(notPhraseQueries);
     query.body.query.bool.must_not.push(notPhraseQueries);
   }
   }
-};
 
 
-SearchClient.prototype.appendCriteriaForPathFilter = function(query, path) {
-  query = this.initializeBoolQuery(query);
+  if (parsedKeywords.prefix.length > 0) {
+    const queries = parsedKeywords.prefix.map(path => {
+      return { prefix: { 'path.raw': path } };
+    });
+    query.body.query.bool.filter.push({ bool: { should: queries } });
+  }
 
 
-  if (path.match(/\/$/)) {
-    path = path.substr(0, path.length - 1);
+  if (parsedKeywords.not_prefix.length > 0) {
+    const queries = parsedKeywords.not_prefix.map(path => {
+      return { prefix: { 'path.raw': path } };
+    });
+    query.body.query.bool.filter.push({ bool: { must_not: queries } });
   }
   }
-  query.body.query.bool.filter.push({
-    wildcard: {
-      'path.raw': path + '/*',
-    },
-  });
+
 };
 };
 
 
 SearchClient.prototype.filterPagesByViewer = async function(query, user, userGroups) {
 SearchClient.prototype.filterPagesByViewer = async function(query, user, userGroups) {
@@ -651,12 +642,12 @@ SearchClient.prototype.appendFunctionScore = function(query) {
   };
   };
 };
 };
 
 
-SearchClient.prototype.searchKeyword = async function(keyword, user, userGroups, option) {
+SearchClient.prototype.searchKeyword = async function(queryString, user, userGroups, option) {
   const from = option.offset || null;
   const from = option.offset || null;
   const size = option.limit || null;
   const size = option.limit || null;
   const type = option.type || null;
   const type = option.type || null;
   const query = this.createSearchQuerySortedByScore();
   const query = this.createSearchQuerySortedByScore();
-  this.appendCriteriaForKeywordContains(query, keyword);
+  this.appendCriteriaForQueryString(query, queryString);
 
 
   this.filterPagesByType(query, type);
   this.filterPagesByType(query, type);
   await this.filterPagesByViewer(query, user, userGroups);
   await this.filterPagesByViewer(query, user, userGroups);
@@ -668,43 +659,23 @@ SearchClient.prototype.searchKeyword = async function(keyword, user, userGroups,
   return this.search(query);
   return this.search(query);
 };
 };
 
 
-SearchClient.prototype.searchByPath = async function(keyword, prefix) {
-  // TODO path 名だけから検索
-};
-
-SearchClient.prototype.searchKeywordUnderPath = async function(keyword, path, user, userGroups, option) {
-  const from = option.offset || null;
-  const size = option.limit || null;
-  const type = option.type || null;
-  const query = this.createSearchQuerySortedByScore();
-  this.appendCriteriaForKeywordContains(query, keyword);
-  this.appendCriteriaForPathFilter(query, path);
-
-  this.filterPagesByType(query, type);
-  await this.filterPagesByViewer(query, user, userGroups);
-
-  this.appendResultSize(query, from, size);
-
-  this.appendFunctionScore(query);
-
-  return this.search(query);
-};
-
-SearchClient.prototype.getParsedKeywords = function(keyword) {
+SearchClient.prototype.parseQueryString = function(queryString) {
   let matchWords = [];
   let matchWords = [];
   let notMatchWords = [];
   let notMatchWords = [];
   let phraseWords = [];
   let phraseWords = [];
   let notPhraseWords = [];
   let notPhraseWords = [];
+  let prefixPaths = [];
+  let notPrefixPaths = [];
 
 
-  keyword.trim();
-  keyword = keyword.replace(/\s+/g, ' ');
+  queryString.trim();
+  queryString = queryString.replace(/\s+/g, ' ');
 
 
   // First: Parse phrase keywords
   // First: Parse phrase keywords
   let phraseRegExp = new RegExp(/(-?"[^"]+")/g);
   let phraseRegExp = new RegExp(/(-?"[^"]+")/g);
-  let phrases = keyword.match(phraseRegExp);
+  let phrases = queryString.match(phraseRegExp);
 
 
   if (phrases !== null) {
   if (phrases !== null) {
-    keyword = keyword.replace(phraseRegExp, '');
+    queryString = queryString.replace(phraseRegExp, '');
 
 
     phrases.forEach(function(phrase) {
     phrases.forEach(function(phrase) {
       phrase.trim();
       phrase.trim();
@@ -718,16 +689,33 @@ SearchClient.prototype.getParsedKeywords = function(keyword) {
   }
   }
 
 
   // Second: Parse other keywords (include minus keywords)
   // Second: Parse other keywords (include minus keywords)
-  keyword.split(' ').forEach(function(word) {
+  queryString.split(' ').forEach(function(word) {
     if (word === '') {
     if (word === '') {
       return;
       return;
     }
     }
 
 
-    if (word.match(/^-(.+)$/)) {
-      notMatchWords.push(RegExp.$1);
+    // https://regex101.com/r/lN4LIV/1
+    const matchNegative = word.match(/^-(prefix:)?(.+)$/);
+    // https://regex101.com/r/gVssZe/1
+    const matchPositive = word.match(/^(prefix:)?(.+)$/);
+
+    if (matchNegative != null) {
+      const isPrefixCondition = (matchNegative[1] != null);
+      if (isPrefixCondition) {
+        notPrefixPaths.push(matchNegative[2]);
+      }
+      else {
+        notMatchWords.push(matchNegative[2]);
+      }
     }
     }
-    else {
-      matchWords.push(word);
+    else if (matchPositive != null) {
+      const isPrefixCondition = (matchPositive[1] != null);
+      if (isPrefixCondition) {
+        prefixPaths.push(matchPositive[2]);
+      }
+      else {
+        matchWords.push(matchPositive[2]);
+      }
     }
     }
   });
   });
 
 
@@ -736,6 +724,8 @@ SearchClient.prototype.getParsedKeywords = function(keyword) {
     not_match: notMatchWords,
     not_match: notMatchWords,
     phrase: phraseWords,
     phrase: phraseWords,
     not_phrase: notPhraseWords,
     not_phrase: notPhraseWords,
+    prefix: prefixPaths,
+    not_prefix: notPrefixPaths,
   };
   };
 };
 };
 
 

+ 0 - 14
src/server/views/layout-crowi/page_list.html

@@ -18,20 +18,6 @@
       <div class="title-container">
       <div class="title-container">
         <div class="d-flex">
         <div class="d-flex">
           <h1 class="title" id="revision-path"></h1>
           <h1 class="title" id="revision-path"></h1>
-          {% if false %} {# Disable temporaly -- 2018.03.08 Yuki Takei #}
-          {% if searchConfigured() && !isTopPage() && !isTrashPage() %}
-          <form id="search-listpage-form" class="m-l-10 input-group search-input-group hidden-xs hidden-sm"
-              data-toggle="tooltip" data-placement="bottom" title="{{ path }} 以下から検索" data-container="body">
-            <div class="input-group">
-              <input id="search-listpage-input" type="text" class="form-control input-sm" data-path="{{ path }}" placeholder="Search for...">
-              <span class="input-group-btn">
-                <button class="btn btn-default btn-sm"><i class="icon-magnifier"></i></button>
-              </span>
-            </div><!-- /input-group -->
-            <a class="search-listpage-clear" id="search-listpage-clear"><i class="fa fa-times-circle"></i></a>
-          </form>
-          {% endif %}
-          {% endif %}
         </div>
         </div>
         <div id="revision-url" class="url-line"></div>
         <div id="revision-url" class="url-line"></div>
       </div>
       </div>