Răsfoiți Sursa

Merge pull request #99 from crowi/feature-search-subtree

Feature search under the path
Sotaro KARASAWA 9 ani în urmă
părinte
comite
fbffb4f899

+ 9 - 1
lib/routes/search.js

@@ -33,6 +33,7 @@ module.exports = function(crowi, app) {
    */
    */
   api.search = function(req, res){
   api.search = function(req, res){
     var keyword = req.query.q || null;
     var keyword = req.query.q || null;
+    var tree = req.query.tree || null;
     if (keyword === null || keyword === '') {
     if (keyword === null || keyword === '') {
       return res.json(ApiResponse.error('keyword should not empty.'));
       return res.json(ApiResponse.error('keyword should not empty.'));
     }
     }
@@ -42,8 +43,15 @@ module.exports = function(crowi, app) {
       return res.json(ApiResponse.error('Configuration of ELASTICSEARCH_URI is required.'));
       return res.json(ApiResponse.error('Configuration of ELASTICSEARCH_URI is required.'));
     }
     }
 
 
+
+    var doSearch;
+    if (tree) {
+      doSearch = search.searchKeywordUnderPath(keyword, tree, {});
+    } else {
+      doSearch = search.searchKeyword(keyword, {});
+    }
     var result = {};
     var result = {};
-    search.searchKeyword(keyword, {})
+    doSearch
       .then(function(data) {
       .then(function(data) {
         result.meta = data.meta;
         result.meta = data.meta;
 
 

+ 1 - 1
lib/views/layout/layout.html

@@ -52,7 +52,7 @@
       {% block title %}{{ config.crowi['app:title'] }}{% endblock %}
       {% block title %}{{ config.crowi['app:title'] }}{% endblock %}
     </a>
     </a>
   {% if searchConfigured() %}
   {% if searchConfigured() %}
-  <div class="navbar-form navbar-left search-top" role="search" id="search-top">
+  <div class="navbar-form navbar-left search-top visible-lg visible-md" role="search" id="search-top">
   </div>
   </div>
   {% endif %}
   {% endif %}
   </div>
   </div>

+ 23 - 9
lib/views/page_list.html

@@ -2,6 +2,11 @@
 
 
 {% block html_title %}{{ path|path2name }} · {{ path }}{% endblock %}
 {% block html_title %}{{ path|path2name }} · {{ path }}{% endblock %}
 
 
+{% block html_base_attr %}
+  data-spy="scroll"
+  data-target="#search-result-list"
+{% endblock %}
+
 {% block content_head %}
 {% block content_head %}
 
 
 {% block content_head_before %}
 {% block content_head_before %}
@@ -15,17 +20,17 @@
     {% endif %}
     {% endif %}
     <h1 class="title">
     <h1 class="title">
       <span class="" id="revision-path">{{ path|insertSpaceToEachSlashes }}</span>
       <span class="" id="revision-path">{{ path|insertSpaceToEachSlashes }}</span>
-      {#
       {% if searchConfigured() && path != '/' %}
       {% if searchConfigured() && path != '/' %}
-      <div class="form-group form-group-sm has-feedback search-input-group" data-toggle="tooltip" data-placement="bottom" title="{{ path }} 以下から検索">
-        <label class="control-label sr-only" for="inputSuccess5">Search</label>
-        <input
-        type="text" class="search-listpage-input form-control" data-path="{{ path }}"
-        >
-        <i class="form-control-feedback search-listpage-icon fa fa-search"></i>
-      </div>
+      <form class="input-group search-input-group hidden-xs hidden-sm" data-toggle="tooltip" data-placement="bottom" title="{{ path }} 以下から検索" id="search-listpage-form">
+        <input type="text" class="search-listpage-input form-control" data-path="{{ path }}" id="search-listpage-input">
+        <span class="input-group-btn search-listpage-submit-group">
+          <button type="submit" class="btn btn-default" id="search-listpage-submit">
+            <i class="fa fa-search"></i>
+          </button>
+        </span>
+        <a class="search-listpage-clear" id="search-listpage-clear"><i class="fa fa-times-circle"></i></a>
+      </form>
       {% endif %}
       {% endif %}
-      #}
     </h1>
     </h1>
   </header>
   </header>
 </div>
 </div>
@@ -37,6 +42,15 @@
 {% block content_main_before %}
 {% block content_main_before %}
 {% endblock %}
 {% endblock %}
 
 
+{# page-list-search should be fully managed by react.js,
+ # but now the header and page list content is rendered separately by the server,
+ # so now bind the values through the hidden fields.
+ #}
+{% if searchConfigured() && path != '/' %}
+<div id="page-list-search">
+</div>
+{% endif %}
+
 <div class="page-list content-main {% if req.body.pageForm %}on-edit{% endif %}"
 <div class="page-list content-main {% if req.body.pageForm %}on-edit{% endif %}"
   id="content-main"
   id="content-main"
   data-path="{{ path }}"
   data-path="{{ path }}"

+ 1 - 1
resource/css/_page.scss

@@ -69,7 +69,7 @@
 
 
         a:last-child {
         a:last-child {
           color: #D1E2E4;
           color: #D1E2E4;
-          opacity: .7;
+          opacity: .5;
 
 
           &:hover {
           &:hover {
             color: inherit;
             color: inherit;

+ 38 - 5
resource/css/_search.scss

@@ -2,24 +2,57 @@
   font-size: 16px;
   font-size: 16px;
   color: #999;
   color: #999;
 }
 }
+.search-listpage-clear {
+  z-index: 3;
+  display: none;
+  position: absolute;
+  right: 8px;
+  z-index: 10;
+  font-size: 0.6em;
+  width: 22px;
+  height: 22px;
+  color: #ccc;
+  padding: 8px;
+}
 
 
 .search-input-group {
 .search-input-group {
   display: inline-block;
   display: inline-block;
   margin-bottom: 0;
   margin-bottom: 0;
   width: 200px;
   width: 200px;
   vertical-align: bottom;
   vertical-align: bottom;
-}
 
 
-.search-listpage-input {
+  .search-listpage-submit-group {
+    position: absolute;
+    width: 30px;
+    height: 30px;
+    padding-left: 197px;
+    border-radius: 0;
+
+    > .btn {
+      padding: 6px 12px 7px;
+    }
+  }
 }
 }
 
 
+
 .search-top {
 .search-top {
   .search-top-input-group {
   .search-top-input-group {
     .search-top-input {
     .search-top-input {
-    }
-    &:focus {
       width: 400px;
       width: 400px;
-      transition: .3s ease;
+    }
+    .btn {
+      padding: 6px 12px 7px;
+      margin-left: -3px;
+    }
+
+    .search-top-clear {
+      position: absolute;
+      right: 40px;
+      z-index: 10;
+      width: 22px;
+      height: 22px;
+      color: #ccc;
+      padding: 8px;
     }
     }
   }
   }
 }
 }

+ 2 - 1
resource/js/app.js

@@ -3,11 +3,12 @@ import ReactDOM from 'react-dom';
 
 
 import HeaderSearchBox  from './components/HeaderSearchBox';
 import HeaderSearchBox  from './components/HeaderSearchBox';
 import SearchPage  from './components/SearchPage';
 import SearchPage  from './components/SearchPage';
-//import ListPageSearch  from './components/ListPageSearch';
+import PageListSearch  from './components/PageListSearch';
 
 
 const componentMappings = {
 const componentMappings = {
   'search-top': <HeaderSearchBox />,
   'search-top': <HeaderSearchBox />,
   'search-page': <SearchPage />,
   'search-page': <SearchPage />,
+  'page-list-search': <PageListSearch />,
 };
 };
 
 
 Object.keys(componentMappings).forEach((key) => {
 Object.keys(componentMappings).forEach((key) => {

+ 19 - 1
resource/js/components/HeaderSearchBox/SearchForm.js

@@ -14,6 +14,7 @@ export default class SearchForm extends React.Component {
     this.handleChange = this.handleChange.bind(this);
     this.handleChange = this.handleChange.bind(this);
     this.handleFocus = this.handleFocus.bind(this);
     this.handleFocus = this.handleFocus.bind(this);
     this.handleBlur = this.handleBlur.bind(this);
     this.handleBlur = this.handleBlur.bind(this);
+    this.clearForm = this.clearForm.bind(this);
     this.ticker = null;
     this.ticker = null;
   }
   }
 
 
@@ -32,6 +33,20 @@ export default class SearchForm extends React.Component {
     }
     }
   }
   }
 
 
+  getFormClearComponent() {
+    if (this.state.keyword !== '') {
+      return <a className="search-top-clear" onClick={this.clearForm}><i className="fa fa-times-circle" /></a>;
+
+    } else {
+      return '';
+    }
+  }
+
+  clearForm() {
+    this.setState({keyword: ''});
+    this.search();
+  }
+
   searchFieldTicker() {
   searchFieldTicker() {
     this.search();
     this.search();
   }
   }
@@ -50,6 +65,8 @@ export default class SearchForm extends React.Component {
   }
   }
 
 
   render() {
   render() {
+    const formClear = this.getFormClearComponent();
+
     return (
     return (
       <form
       <form
         action="/_search"
         action="/_search"
@@ -59,7 +76,7 @@ export default class SearchForm extends React.Component {
           autocomplete="off"
           autocomplete="off"
           type="text"
           type="text"
           className="search-top-input form-control"
           className="search-top-input form-control"
-          placeholder="Search ..."
+          placeholder="Search ... Page Title (Path) and Content"
           name="q"
           name="q"
           value={this.state.keyword}
           value={this.state.keyword}
           onFocus={this.handleFocus}
           onFocus={this.handleFocus}
@@ -71,6 +88,7 @@ export default class SearchForm extends React.Component {
             <i className="search-top-icon fa fa-search"></i>
             <i className="search-top-icon fa fa-search"></i>
           </button>
           </button>
         </span>
         </span>
+        {formClear}
       </form>
       </form>
     );
     );
   }
   }

+ 2 - 1
resource/js/components/PageList/Page.js

@@ -18,7 +18,7 @@ export default class Page extends React.Component {
         {this.props.children}
         {this.props.children}
         <UserPicture user={page.revision.author} />
         <UserPicture user={page.revision.author} />
         <a className="page-list-link" href={link}>
         <a className="page-list-link" href={link}>
-          <PagePath page={page} />
+          <PagePath page={page} excludePathString={this.props.excludePathString} />
         </a>
         </a>
         <PageListMeta page={page} />
         <PageListMeta page={page} />
       </li>
       </li>
@@ -34,5 +34,6 @@ Page.propTypes = {
 Page.defaultProps = {
 Page.defaultProps = {
   page: {},
   page: {},
   linkTo: '',
   linkTo: '',
+  excludePathString: '',
 };
 };
 
 

+ 5 - 2
resource/js/components/PageList/PagePath.js

@@ -26,8 +26,10 @@ export default class PagePath extends React.Component {
 
 
   render() {
   render() {
     const page = this.props.page;
     const page = this.props.page;
-    const shortPath = this.getShortPath(page.path);
-    const pathPrefix = page.path.replace(new RegExp(shortPath + '(/)?$'), '');
+    const pagePath = page.path.replace(this.props.excludePathString.replace(/^\//, ''), '');
+    const shortPath = this.getShortPath(pagePath);
+    const shortPathEscaped = shortPath.replace(/[-\/\\^$*+?.()|[\]{}]/g, '\\$&');
+    const pathPrefix = pagePath.replace(new RegExp(shortPathEscaped + '(/)?$'), '');
 
 
     return (
     return (
       <span className="page-path">
       <span className="page-path">
@@ -43,4 +45,5 @@ PagePath.propTypes = {
 
 
 PagePath.defaultProps = {
 PagePath.defaultProps = {
   page: {},
   page: {},
+  excludePathString: '',
 };
 };

+ 177 - 0
resource/js/components/PageListSearch.js

@@ -0,0 +1,177 @@
+// This is the root component for #page-list-search
+
+import React from 'react';
+import axios from 'axios'
+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,
+    });
+    axios.get('/_api/search', {params: {q: keyword, tree: tree}})
+    .then((res) => {
+      if (res.data.ok) {
+
+        this.setState({
+          searchedKeyword: keyword,
+          searchedPages: res.data.data,
+          searchResultMeta: res.data.meta,
+        });
+      } else {
+        this.setState({
+          searchError: res.data,
+        });
+      }
+
+
+      // TODO error
+    })
+    .catch((res) => {
+      this.setState({
+        searchError: res.data,
+      });
+      // TODO error
+    });
+  };
+
+  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}
+          />
+      </div>
+    );
+  }
+}
+
+PageListSearch.propTypes = {
+  query: React.PropTypes.object,
+};
+PageListSearch.defaultProps = {
+  //pollInterval: 1000,
+  query: PageListSearch.getQueryByLocation(location || {}),
+};
+

+ 10 - 1
resource/js/components/SearchPage.js

@@ -59,6 +59,8 @@ export default class SearchPage extends React.Component {
       this.setState({
       this.setState({
         searchingKeyword: '',
         searchingKeyword: '',
         searchedPages: [],
         searchedPages: [],
+        searchResultMeta: {},
+        searchError: null,
       });
       });
 
 
       return true;
       return true;
@@ -78,13 +80,19 @@ export default class SearchPage extends React.Component {
           searchedPages: res.data.data,
           searchedPages: res.data.data,
           searchResultMeta: res.data.meta,
           searchResultMeta: res.data.meta,
         });
         });
+      } else {
+        this.setState({
+          searchError: res.data,
+        });
       }
       }
 
 
-
       // TODO error
       // TODO error
     })
     })
     .catch((res) => {
     .catch((res) => {
       // TODO error
       // TODO error
+      this.setState({
+        searchError: res.data,
+      });
     });
     });
   };
   };
 
 
@@ -116,5 +124,6 @@ SearchPage.propTypes = {
 SearchPage.defaultProps = {
 SearchPage.defaultProps = {
   //pollInterval: 1000,
   //pollInterval: 1000,
   query: SearchPage.getQueryByLocation(location || {}),
   query: SearchPage.getQueryByLocation(location || {}),
+  searchError: null,
 };
 };
 
 

+ 0 - 6
resource/js/components/SearchPage/SearchForm.js

@@ -15,12 +15,6 @@ export default class SearchForm extends React.Component {
     this.handleChange = this.handleChange.bind(this);
     this.handleChange = this.handleChange.bind(this);
   }
   }
 
 
-  componentDidMount() {
-  }
-
-  componentWillUnmount() {
-  }
-
   search() {
   search() {
     if (this.state.searchedKeyword != this.state.keyword) {
     if (this.state.searchedKeyword != this.state.keyword) {
       this.props.onSearchFormChanged({keyword: this.state.keyword});
       this.props.onSearchFormChanged({keyword: this.state.keyword});

+ 61 - 5
resource/js/components/SearchPage/SearchResult.js

@@ -6,12 +6,59 @@ import SearchResultList from './SearchResultList';
 // Search.SearchResult
 // Search.SearchResult
 export default class SearchResult extends React.Component {
 export default class SearchResult extends React.Component {
 
 
+  isNotSearchedYet() {
+    return !this.props.searchResultMeta.took;
+  }
+
+  isNotFound() {
+    return this.props.searchingKeyword !== '' && this.props.pages.length === 0;
+  }
+
+  isError() {
+    if (this.props.searchError !== null) {
+      return true;
+    }
+    return false;
+  }
+
   render() {
   render() {
+    const excludePathString = this.props.tree;
+
+    console.log(this.props.searchError);
+    console.log(this.isError());
+    if (this.isError()) {
+      return (
+        <div className="content-main">
+          <i className="searcing fa fa-warning"></i> Error on searching.
+        </div>
+      );
+    }
+
+    if (this.isNotSearchedYet()) {
+      return <div />;
+    }
+
+    if (this.isNotFound()) {
+      let under = '';
+      if (this.props.tree !== '') {
+        under = ` under "${this.props.tree}"`;
+      }
+      return (
+        <div className="content-main">
+            <i className="fa fa-meh-o" /> No page found with "{this.props.searchingKeyword}"{under}
+        </div>
+      );
+
+    }
 
 
     const listView = this.props.pages.map((page) => {
     const listView = this.props.pages.map((page) => {
       const pageId = "#" + page._id;
       const pageId = "#" + page._id;
       return (
       return (
-        <Page page={page} linkTo={pageId} key={page._id}>
+        <Page page={page}
+          linkTo={pageId}
+          key={page._id}
+          excludePathString={excludePathString}
+          >
           <div className="page-list-option">
           <div className="page-list-option">
             <a href={page.path}><i className="fa fa-arrow-circle-right" /></a>
             <a href={page.path}><i className="fa fa-arrow-circle-right" /></a>
           </div>
           </div>
@@ -19,15 +66,20 @@ export default class SearchResult extends React.Component {
       );
       );
     });
     });
 
 
+    // TODO あとでなんとかする
+    setTimeout(() => {
+      $('#search-result-list > nav').affix({ offset: { top: 120 }});
+    }, 1200);
+
     /*
     /*
     UI あとで考える
     UI あとで考える
     <span className="search-result-meta">Found: {this.props.searchResultMeta.total} pages with "{this.props.searchingKeyword}"</span>
     <span className="search-result-meta">Found: {this.props.searchResultMeta.total} pages with "{this.props.searchingKeyword}"</span>
     */
     */
     return (
     return (
-      <div className="content-main" id="content-main">
+      <div className="content-main">
         <div className="search-result row" id="search-result">
         <div className="search-result row" id="search-result">
           <div className="col-md-4 page-list search-result-list" id="search-result-list">
           <div className="col-md-4 page-list search-result-list" id="search-result-list">
-            <nav data-spy="affix"  data-offset-top="120">
+            <nav data-spy="affix" data-offset-top="120">
               <ul className="page-list-ul nav">
               <ul className="page-list-ul nav">
                 {listView}
                 {listView}
               </ul>
               </ul>
@@ -47,12 +99,16 @@ export default class SearchResult extends React.Component {
 }
 }
 
 
 SearchResult.propTypes = {
 SearchResult.propTypes = {
-  searchedPages: React.PropTypes.array.isRequired,
+  tree: React.PropTypes.string.isRequired,
+  pages: React.PropTypes.array.isRequired,
   searchingKeyword: React.PropTypes.string.isRequired,
   searchingKeyword: React.PropTypes.string.isRequired,
+  searchResultMeta: React.PropTypes.object.isRequired,
 };
 };
 SearchResult.defaultProps = {
 SearchResult.defaultProps = {
-  searchedPages: [],
+  tree: '',
+  pages: [],
   searchingKeyword: '',
   searchingKeyword: '',
   searchResultMeta: {},
   searchResultMeta: {},
+  searchError: null,
 };
 };
 
 

+ 1 - 1
resource/js/crowi.js

@@ -471,7 +471,7 @@ $(function() {
         }
         }
       });
       });
       $('[data-affix-disable]').on('click', function(e) {
       $('[data-affix-disable]').on('click', function(e) {
-        $elm = $($(this).data('affix-disable'));
+        var $elm = $($(this).data('affix-disable'));
         $(window).off('.affix');
         $(window).off('.affix');
         $elm.removeData('affix').removeClass('affix affix-top affix-bottom');
         $elm.removeData('affix').removeClass('affix affix-top affix-bottom');
         return false;
         return false;