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

Merge pull request #919 from weseek/feat/tag-list-page-for-master-merge

Feat/tag list page for master merge
Yuki Takei 7 лет назад
Родитель
Сommit
c6e5129af6

+ 1 - 1
package.json

@@ -195,7 +195,7 @@
     "prettier-stylelint": "^0.4.2",
     "react": "^16.8.3",
     "react-bootstrap": "^0.32.1",
-    "react-bootstrap-typeahead": "^3.3.4",
+    "react-bootstrap-typeahead": "^3.4.2",
     "react-clipboard.js": "^2.0.0",
     "react-codemirror2": "^6.0.0",
     "react-dom": "^16.8.3",

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

@@ -11,6 +11,7 @@
   "Cancel": "Cancel",
   "Create": "Create",
   "Admin": "Admin",
+  "Tags": "Tags",
   "New": "New",
   "Shortcuts": "Shortcuts",
   "eg": "e.g.",

+ 9 - 1
resource/locales/ja/translation.json

@@ -11,6 +11,7 @@
   "Cancel": "キャンセル",
   "Create": "作成",
   "Admin": "管理",
+  "Tags": "タグ",
   "New": "作成",
   "Shortcuts": "ショートカット",
   "eg": "例:",
@@ -111,7 +112,8 @@
   "Shareable link": "このページの共有用URL",
   "The whitelist of registration permission E-mail address": "登録許可メールアドレスの<br>ホワイトリスト",
   "Selecting authentication mechanism": "認証機構選択",
-
+  "Add tags for this page": "タグを付ける",
+  "You have no tag, You can set tags on pages": "使用中のタグがありません",
 
 
   "Show latest": "最新のページを表示",
@@ -213,6 +215,12 @@
     },
     "exclude_prefix": {
       "desc": "ページ名が {{path}} から始まるページを除外"
+    },
+    "tag": {
+      "desc": "{{tag}} というタグを含むページを検索"
+    },
+    "exclude_tag": {
+      "desc": "{{tag}} というタグを含むページを除外"
     }
   },
   "search": {

+ 7 - 3
src/client/js/app.js

@@ -17,6 +17,7 @@ import GrowiRenderer from './util/GrowiRenderer';
 
 import HeaderSearchBox from './components/HeaderSearchBox';
 import SearchPage from './components/SearchPage';
+import TagsList from './components/TagsList';
 import PageEditor from './components/PageEditor';
 // eslint-disable-next-line import/no-duplicates
 import OptionsSelector from './components/PageEditor/OptionsSelector';
@@ -31,7 +32,7 @@ import CommentForm from './components/PageComment/CommentForm';
 import PageAttachment from './components/PageAttachment';
 import PageStatusAlert from './components/PageStatusAlert';
 import RevisionPath from './components/Page/RevisionPath';
-import TagViewer from './components/Page/TagViewer';
+import TagLabel from './components/Page/TagLabel';
 import RevisionUrl from './components/Page/RevisionUrl';
 import BookmarkButton from './components/BookmarkButton';
 import LikeButton from './components/LikeButton';
@@ -297,6 +298,8 @@ const componentMappings = {
   'bookmark-button': <BookmarkButton pageId={pageId} crowi={crowi} />,
   'bookmark-button-lg': <BookmarkButton pageId={pageId} crowi={crowi} size="lg" />,
 
+  'tags-page': <I18nextProvider i18n={i18n}><TagsList crowi={crowi} /></I18nextProvider>,
+
   'create-page-name-input': <PagePathAutoComplete crowi={crowi} initializedPath={pagePath} addTrailingSlash />,
   'rename-page-name-input': <PagePathAutoComplete crowi={crowi} initializedPath={pagePath} />,
   'duplicate-page-name-input': <PagePathAutoComplete crowi={crowi} initializedPath={pagePath} />,
@@ -310,8 +313,9 @@ if (pageId) {
 if (pagePath) {
   componentMappings.page = <Page crowi={crowi} crowiRenderer={crowiRenderer} markdown={markdown} pagePath={pagePath} onSaveWithShortcut={saveWithShortcut} />;
   componentMappings['revision-path'] = <RevisionPath pagePath={pagePath} crowi={crowi} />;
-  componentMappings['tag-viewer'] = <TagViewer crowi={crowi} pageId={pageId} sendTagData={setTagData} />;
-  componentMappings['revision-url'] = <RevisionUrl pageId={pageId} pagePath={pagePath} />;
+  // [TODO] there is a need to decide the destination of RevisionUrl
+  componentMappings['revision-url'] = <RevisionUrl crowi={crowi} pageId={pageId} pagePath={pagePath} sendTagData={setTagData} />;
+  componentMappings['tag-label'] = <I18nextProvider i18n={i18n}><TagLabel crowi={crowi} pageId={pageId} sendTagData={setTagData} /></I18nextProvider>;
 }
 
 Object.keys(componentMappings).forEach((key) => {

+ 144 - 0
src/client/js/components/Page/TagLabel.jsx

@@ -0,0 +1,144 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { withTranslation } from 'react-i18next';
+import * as toastr from 'toastr';
+import Button from 'react-bootstrap/es/Button';
+import Modal from 'react-bootstrap/es/Modal';
+import PageTagForm from '../PageTagForm';
+
+class TagLabel extends React.Component {
+
+  constructor(props) {
+    super(props);
+
+    this.state = {
+      currentPageTags: [],
+      newPageTags: [],
+      isOpenModal: false,
+      isEditorMode: null,
+    };
+
+    this.addNewTag = this.addNewTag.bind(this);
+    this.handleShowModal = this.handleShowModal.bind(this);
+    this.handleCloseModal = this.handleCloseModal.bind(this);
+    this.handleSubmit = this.handleSubmit.bind(this);
+    this.apiSuccessHandler = this.apiSuccessHandler.bind(this);
+    this.apiErrorHandler = this.apiErrorHandler.bind(this);
+  }
+
+  async componentWillMount() {
+    // set pageTag on button
+    const pageId = this.props.pageId;
+    if (pageId) {
+      const res = await this.props.crowi.apiGet('/pages.getPageTag', { pageId });
+      this.setState({ currentPageTags: res.tags, newPageTags: res.tags });
+      this.props.sendTagData(res.tags);
+    }
+  }
+
+  addNewTag(newPageTags) {
+    this.setState({ newPageTags });
+  }
+
+  handleCloseModal() {
+    // reset state newPageTags when user close modal without push Done button
+    this.setState({ isOpenModal: false, newPageTags: this.state.currentPageTags });
+  }
+
+  handleShowModal() {
+    const isEditorMode = this.props.crowi.getCrowiForJquery().getCurrentEditorMode();
+    this.setState({ isOpenModal: true, isEditorMode });
+  }
+
+  async handleSubmit() {
+
+    if (this.state.isEditorMode) { // set tag on draft on edit
+      this.props.sendTagData(this.state.newPageTags);
+    }
+    else { // update tags without saving the page on view
+      try {
+        await this.props.crowi.apiPost('/tags.update', { pageId: this.props.pageId, tags: this.state.newPageTags });
+        this.apiSuccessHandler();
+      }
+      catch (err) {
+        this.apiErrorHandler(err);
+        return;
+      }
+    }
+    this.setState({ currentPageTags: this.state.newPageTags, isOpenModal: false });
+  }
+
+  apiSuccessHandler() {
+    toastr.success(undefined, 'updated tags successfully', {
+      closeButton: true,
+      progressBar: true,
+      newestOnTop: false,
+      showDuration: '100',
+      hideDuration: '100',
+      timeOut: '1200',
+      extendedTimeOut: '150',
+    });
+  }
+
+  apiErrorHandler(err) {
+    toastr.error(err.message, 'Error occured', {
+      closeButton: true,
+      progressBar: true,
+      newestOnTop: false,
+      showDuration: '100',
+      hideDuration: '100',
+      timeOut: '3000',
+    });
+  }
+
+  render() {
+    const tags = [];
+    const { t } = this.props;
+
+    for (let i = 0; i < this.state.currentPageTags.length; i++) {
+      tags.push(
+        <i className="tag-icon icon-tag"></i>,
+        <a className="tag-name text-muted" href={`/_search?q=tag:${this.state.currentPageTags[i]}`} key={i.toString()}>{this.state.currentPageTags[i]}</a>,
+      );
+
+    }
+
+    return (
+      <div className="tag-viewer text-muted">
+        {this.state.currentPageTags.length === 0 && (
+          <a className="display-of-notag text-muted" onClick={this.handleShowModal}>{ t('Add tags for this page') }</a>
+        )}
+        {tags}
+        <i
+          className="manage-tags ml-2 icon-plus"
+          onClick={this.handleShowModal}
+
+        >
+        </i>
+        <Modal show={this.state.isOpenModal} onHide={this.handleCloseModal} id="editTagModal">
+          <Modal.Header closeButton className="bg-primary">
+            <Modal.Title className="text-white">Page Tag</Modal.Title>
+          </Modal.Header>
+          <Modal.Body>
+            <PageTagForm crowi={this.props.crowi} currentPageTags={this.state.currentPageTags} addNewTag={this.addNewTag} />
+          </Modal.Body>
+          <Modal.Footer>
+            <Button variant="primary" onClick={this.handleSubmit}>
+              Done
+            </Button>
+          </Modal.Footer>
+        </Modal>
+      </div>
+    );
+  }
+
+}
+
+TagLabel.propTypes = {
+  t: PropTypes.func.isRequired, // i18next
+  crowi: PropTypes.object.isRequired,
+  pageId: PropTypes.string,
+  sendTagData: PropTypes.func.isRequired,
+};
+
+export default withTranslation()(TagLabel);

+ 0 - 107
src/client/js/components/Page/TagViewer.jsx

@@ -1,107 +0,0 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-import Button from 'react-bootstrap/es/Button';
-import OverlayTrigger from 'react-bootstrap/es/OverlayTrigger';
-import Tooltip from 'react-bootstrap/es/Tooltip';
-import Modal from 'react-bootstrap/es/Modal';
-import PageTagForm from '../PageTagForm';
-
-/**
- * show tag labels on view and edit tag button on edit
- * tag labels on view is not implemented yet(GC-1391)
- */
-export default class TagViewer extends React.Component {
-
-  constructor(props) {
-    super(props);
-
-    this.state = {
-      currentPageTags: [],
-      newPageTags: [],
-      isOpenModal: false,
-    };
-
-    this.addNewTag = this.addNewTag.bind(this);
-    this.handleShowModal = this.handleShowModal.bind(this);
-    this.handleCloseModal = this.handleCloseModal.bind(this);
-    this.handleSubmit = this.handleSubmit.bind(this);
-  }
-
-  async componentWillMount() {
-    // set pageTag on button
-    const pageId = this.props.pageId;
-    if (pageId) {
-      const res = await this.props.crowi.apiGet('/pages.getPageTag', { pageId });
-      this.setState({ currentPageTags: res.tags });
-      this.props.sendTagData(res.tags);
-    }
-  }
-
-  // receive new tag from PageTagForm component
-  addNewTag(newPageTags) {
-    this.setState({ newPageTags });
-  }
-
-  handleCloseModal() {
-    this.setState({ isOpenModal: false });
-  }
-
-  handleShowModal() {
-    this.setState({ isOpenModal: true });
-  }
-
-  handleSubmit() {
-    this.props.sendTagData(this.state.newPageTags);
-    this.setState({ currentPageTags: this.state.newPageTags, isOpenModal: false });
-  }
-
-  render() {
-    const tagEditorButtonStyle = {
-      marginLeft: '0.2em',
-      padding: '0 2px',
-    };
-
-    return (
-      <span className="btn-tag-container">
-        <OverlayTrigger
-          key="tooltip"
-          placement="bottom"
-          overlay={(
-            <Tooltip id="tag-edit-button-tooltip" className="tag-tooltip">
-              {this.state.currentPageTags.length !== 0 ? this.state.currentPageTags.join() : 'tag is not set' }
-            </Tooltip>
-          )}
-        >
-          <Button
-            variant="primary"
-            onClick={this.handleShowModal}
-            className="btn btn-default btn-tag"
-            style={tagEditorButtonStyle}
-          >
-            <i className="fa fa-tags"></i>{this.state.currentPageTags.length}
-          </Button>
-        </OverlayTrigger>
-        <Modal show={this.state.isOpenModal} onHide={this.handleCloseModal} id="editTagModal">
-          <Modal.Header closeButton className="bg-primary">
-            <Modal.Title className="text-white">Page Tag</Modal.Title>
-          </Modal.Header>
-          <Modal.Body>
-            <PageTagForm crowi={this.props.crowi} currentPageTags={this.state.currentPageTags} addNewTag={this.addNewTag} />
-          </Modal.Body>
-          <Modal.Footer>
-            <Button variant="primary" onClick={this.handleSubmit}>
-              Done
-            </Button>
-          </Modal.Footer>
-        </Modal>
-      </span>
-    );
-  }
-
-}
-
-TagViewer.propTypes = {
-  crowi: PropTypes.object.isRequired,
-  pageId: PropTypes.string,
-  sendTagData: PropTypes.func.isRequired,
-};

+ 40 - 17
src/client/js/components/PageTagForm.jsx

@@ -20,36 +20,59 @@ export default class PageTagForm extends React.Component {
       resultTags: [],
       isLoading: false,
       selected: this.props.currentPageTags,
+      defaultPageTags: this.props.currentPageTags,
     };
     this.crowi = this.props.crowi;
+
+    this.handleChange = this.handleChange.bind(this);
+    this.handleSearch = this.handleSearch.bind(this);
+    this.handleSelect = this.handleSelect.bind(this);
+  }
+
+  handleChange(selected) {
+    // send tags to TagLabel Component when user add tag to form everytime
+    this.setState({ selected }, () => {
+      this.props.addNewTag(this.state.selected);
+    });
+  }
+
+  async handleSearch(query) {
+    this.setState({ isLoading: true });
+    const res = await this.crowi.apiGet('/tags.search', { q: query });
+    res.tags.unshift(query); // selectable new tag whose name equals query
+    this.setState({
+      resultTags: Array.from(new Set(res.tags)), // use Set for de-duplication
+      isLoading: false,
+    });
+  }
+
+  handleSelect(e) {
+    if (e.keyCode === 32) { // '32' means ASCII code of 'space'
+      e.preventDefault();
+      const instance = this.typeahead.getInstance();
+      const { initialItem } = instance.state;
+
+      if (initialItem) {
+        instance._handleMenuItemSelect(initialItem, e);
+      }
+    }
   }
 
   render() {
     return (
       <div className="tag-typeahead">
         <AsyncTypeahead
-          allowNew
+          id="async-typeahead"
+          ref={(typeahead) => { this.typeahead = typeahead }}
           caseSensitive={false}
-          defaultSelected={this.props.currentPageTags}
-          emptyLabel=""
+          defaultSelected={this.state.defaultPageTags}
           isLoading={this.state.isLoading}
           minLength={1}
           multiple
           newSelectionPrefix=""
-          onChange={(selected) => {
-            this.setState({ selected }, () => {
-              this.props.addNewTag(this.state.selected);
-            });
-          }}
-          onSearch={async(query) => {
-            this.setState({ isLoading: true });
-            const res = await this.crowi.apiGet('/tags.search', { q: query });
-            res.tags.unshift(query); // selectable new tag whose name equals query
-            this.setState({
-              resultTags: Array.from(new Set(res.tags)), // use Set for de-duplication
-              isLoading: false,
-            });
-          }}
+          onChange={this.handleChange}
+          onSearch={this.handleSearch}
+          onKeyDown={this.handleSelect}
           options={this.state.resultTags} // Search result (Some tag names)
           placeholder="tag name"
           selectHintOnEnter

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

@@ -72,6 +72,14 @@ export default class SearchForm extends React.Component {
             <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>
+            <th className="text-right pt-2"><code>tag:wiki</code></th>
+            <td><h6 className="m-0 pt-1">{ t('search_help.tag.desc', { tag: 'wiki' }) }</h6></td>
+          </tr>
+          <tr>
+            <th className="text-right pt-2"><code>-tag:wiki</code></th>
+            <td><h6 className="m-0 pt-1">{ t('search_help.exclude_tag.desc', { tag: 'wiki' }) }</h6></td>
+          </tr>
         </tbody>
       </table>
     );

+ 4 - 1
src/client/js/components/SearchPage/SearchResultList.js

@@ -17,7 +17,10 @@ export default class SearchResultList extends React.Component {
     const resultList = this.props.pages.map((page) => {
       return (
         <div id={page._id} key={page._id} className="search-result-page">
-          <h2><a href={page.path}>{page.path}</a></h2>
+          <h2 className="inline"><a href={page.path}>{page.path}</a></h2>
+          { page.tags.length > 0 && (
+            <span><i className="tag-icon icon-tag"></i> {page.tags.join(', ')}</span>
+          )}
           <RevisionLoader
             crowi={this.props.crowi}
             crowiRenderer={this.growiRenderer}

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

@@ -173,6 +173,7 @@ export default class SearchTypeahead extends React.Component {
       <div className="search-typeahead">
         <AsyncTypeahead
           {...this.props}
+          id="async-typeahead"
           ref={(c) => { this.typeahead = c }}
           inputProps={inputProps}
           isLoading={this.state.isLoading}

+ 197 - 0
src/client/js/components/TagsList.jsx

@@ -0,0 +1,197 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+
+import { withTranslation } from 'react-i18next';
+import Pagination from 'react-bootstrap/lib/Pagination';
+
+class TagsList extends React.Component {
+
+  constructor(props) {
+    super(props);
+
+    this.state = {
+      tagData: [],
+      activePage: 1,
+      paginationNumbers: {},
+    };
+
+    this.calculatePagination = this.calculatePagination.bind(this);
+  }
+
+  async componentWillMount() {
+    await this.getTagList(1);
+  }
+
+  async getTagList(selectPageNumber) {
+    const limit = 10;
+    const offset = (selectPageNumber - 1) * limit;
+    const res = await this.props.crowi.apiGet('/tags.list', { limit, offset });
+
+    const totalCount = res.totalCount;
+    const tagData = res.data;
+    const activePage = selectPageNumber;
+    const paginationNumbers = this.calculatePagination(limit, totalCount, activePage);
+
+    this.setState({
+      tagData,
+      activePage,
+      paginationNumbers,
+    });
+  }
+
+  calculatePagination(limit, totalCount, activePage) {
+    // calc totalPageNumber
+    const totalPage = Math.floor(totalCount / limit) + (totalCount % limit === 0 ? 0 : 1);
+
+    let paginationStart = activePage - 2;
+    let maxViewPageNum = activePage + 2;
+    // pagination Number area size = 5 , pageNumber calculate in here
+    // activePage Position calculate ex. 4 5 [6] 7 8 (Page8 over is Max), 3 4 5 [6] 7 (Page7 is Max)
+    if (paginationStart < 1) {
+      const diff = 1 - paginationStart;
+      paginationStart += diff;
+      maxViewPageNum = Math.min(totalPage, maxViewPageNum + diff);
+    }
+    if (maxViewPageNum > totalPage) {
+      const diff = maxViewPageNum - totalPage;
+      maxViewPageNum -= diff;
+      paginationStart = Math.max(1, paginationStart - diff);
+    }
+
+    return {
+      totalPage,
+      paginationStart,
+      maxViewPageNum,
+    };
+  }
+
+  /**
+   * generate Elements of Tag
+   *
+   * @param {any} pages Array of pages Model Obj
+   *
+   */
+  generateTagList(tagData) {
+    return tagData.map((data) => {
+      return (
+        <a key={data.name} href={`/_search?q=tag:${data.name}`} className="list-group-item">
+          <i className="icon-tag mr-2"></i>{data.name}
+          <span className="ml-4 list-tag-count label label-default text-muted">{data.count}</span>
+        </a>
+      );
+    });
+  }
+
+  /**
+   * generate Elements of Pagination First Prev
+   * ex.  <<   <   1  2  3  >  >>
+   * this function set << & <
+   */
+  generateFirstPrev(activePage) {
+    const paginationItems = [];
+    if (activePage !== 1) {
+      paginationItems.push(
+        <Pagination.First key="first" onClick={() => { return this.getTagList(1) }} />,
+      );
+      paginationItems.push(
+        <Pagination.Prev key="prev" onClick={() => { return this.getTagList(this.state.activePage - 1) }} />,
+      );
+    }
+    else {
+      paginationItems.push(
+        <Pagination.First key="first" disabled />,
+      );
+      paginationItems.push(
+        <Pagination.Prev key="prev" disabled />,
+      );
+
+    }
+    return paginationItems;
+  }
+
+  /**
+   * generate Elements of Pagination First Prev
+   *  ex. << < 4 5 6 7 8 > >>, << < 1 2 3 4 > >>
+   * this function set  numbers
+   */
+  generatePaginations(activePage, paginationStart, maxViewPageNum) {
+    const paginationItems = [];
+    for (let number = paginationStart; number <= maxViewPageNum; number++) {
+      paginationItems.push(
+        <Pagination.Item key={number} active={number === activePage} onClick={() => { return this.getTagList(number) }}>{number}</Pagination.Item>,
+      );
+    }
+    return paginationItems;
+  }
+
+  /**
+   * generate Elements of Pagination First Prev
+   * ex.  <<   <   1  2  3  >  >>
+   * this function set > & >>
+   */
+  generateNextLast(activePage, totalPage) {
+    const paginationItems = [];
+    if (totalPage !== activePage) {
+      paginationItems.push(
+        <Pagination.Next key="next" onClick={() => { return this.getTagList(this.state.activePage + 1) }} />,
+      );
+      paginationItems.push(
+        <Pagination.Last key="last" onClick={() => { return this.getTagList(totalPage) }} />,
+      );
+    }
+    else {
+      paginationItems.push(
+        <Pagination.Next key="next" disabled />,
+      );
+      paginationItems.push(
+        <Pagination.Last key="last" disabled />,
+      );
+
+    }
+    return paginationItems;
+  }
+
+  render() {
+    const { t } = this.props;
+    const messageForNoTag = this.state.tagData.length ? null : <h3>{ t('You have no tag, You can set tags on pages') }</h3>;
+
+    const paginationItems = [];
+
+    const activePage = this.state.activePage;
+    const totalPage = this.state.paginationNumbers.totalPage;
+    const paginationStart = this.state.paginationNumbers.paginationStart;
+    const maxViewPageNum = this.state.paginationNumbers.maxViewPageNum;
+    const firstPrevItems = this.generateFirstPrev(activePage);
+    paginationItems.push(firstPrevItems);
+    const paginations = this.generatePaginations(activePage, paginationStart, maxViewPageNum);
+    paginationItems.push(paginations);
+    const nextLastItems = this.generateNextLast(activePage, totalPage);
+    paginationItems.push(nextLastItems);
+    const pagination = this.state.tagData.length ? <Pagination>{paginationItems}</Pagination> : null;
+
+    return (
+      <div className="text-center">
+        <div className="tag-list">
+          <ul className="list-group text-left">
+            {this.generateTagList(this.state.tagData)}
+          </ul>
+          {messageForNoTag}
+        </div>
+        <div className="tag-list-pagination">
+          {pagination}
+        </div>
+      </div>
+    );
+  }
+
+}
+
+TagsList.propTypes = {
+  crowi: PropTypes.object.isRequired,
+  t: PropTypes.func.isRequired, // i18next
+};
+
+TagsList.defaultProps = {
+};
+
+export default withTranslation()(TagsList);

+ 2 - 0
src/client/styles/scss/_search.scss

@@ -187,6 +187,8 @@
       margin-top: -48px;
 
       > h2 {
+        display: inline;
+        margin-right: 10px;
         font-size: 20px;
         line-height: 1em;
       }

+ 32 - 0
src/client/styles/scss/_tag.scss

@@ -0,0 +1,32 @@
+.tag-viewer {
+  .manage-tags {
+    font-size: 10px;
+    cursor: pointer;
+  }
+
+  .tag-icon:not(:first-child) {
+    margin-left: 5px;
+  }
+
+  .display-of-notag,
+  .tag-icon {
+    font-size: 10px;
+  }
+
+  .tag-name {
+    margin-left: 1px;
+    font-size: 10px;
+  }
+}
+
+.tags-list {
+  .label.list-tag-count {
+    background: rgba(0, 0, 0, 0.08);
+  }
+}
+
+#editTagModal {
+  .form-control {
+    height: auto;
+  }
+}

+ 4 - 0
src/client/styles/scss/style-app.scss

@@ -39,6 +39,7 @@
 @import 'user_growi';
 @import 'handsontable';
 @import 'wiki';
+@import 'tag';
 
 /*
  * for Guest User Mode
@@ -64,14 +65,17 @@
     width: 48px;
     height: 48px;
   }
+
   &.picture-md {
     width: 24px;
     height: 24px;
   }
+
   &.picture-sm {
     width: 18px;
     height: 18px;
   }
+
   &.picture-xs {
     width: 14px;
     height: 14px;

+ 1 - 0
src/server/crowi/index.js

@@ -57,6 +57,7 @@ function Crowi(rootdir) {
     page: new (require(`${self.eventsDir}page`))(this),
     search: new (require(`${self.eventsDir}search`))(this),
     bookmark: new (require(`${self.eventsDir}bookmark`))(this),
+    tag: new (require(`${self.eventsDir}tag`))(this),
   };
 }
 

+ 13 - 0
src/server/events/tag.js

@@ -0,0 +1,13 @@
+const util = require('util');
+const events = require('events');
+
+function TagEvent(crowi) {
+  this.crowi = crowi;
+
+  events.EventEmitter.call(this);
+}
+util.inherits(TagEvent, events.EventEmitter);
+
+TagEvent.prototype.onUpdate = function(tag) { };
+
+module.exports = TagEvent;

+ 16 - 0
src/server/models/page-tag-relation.js

@@ -37,6 +37,22 @@ class PageTagRelation {
     }
   }
 
+  static async createTagListWithCount(option) {
+    const opt = option || {};
+    const sortOpt = opt.sortOpt || {};
+    const offset = opt.offset || 0;
+    const limit = opt.limit || 50;
+
+    const tags = await this.aggregate()
+      .group({ _id: '$relatedTag', count: { $sum: 1 } })
+      .sort(sortOpt);
+
+    const list = tags.slice(offset, offset + limit);
+    const totalCount = tags.length;
+
+    return { list, totalCount };
+  }
+
 }
 
 module.exports = function() {

+ 7 - 1
src/server/models/page.js

@@ -353,6 +353,12 @@ module.exports = function(crowi) {
     return (this.latestRevision == this.revision._id.toString());
   };
 
+  pageSchema.methods.findRelatedTagsById = async function() {
+    const PageTagRelation = mongoose.model('PageTagRelation');
+    const relations = await PageTagRelation.find({ relatedPage: this._id }).populate('relatedTag');
+    return relations.map((relation) => { return relation.relatedTag.name });
+  };
+
   pageSchema.methods.isUpdatable = function(previousRevision) {
     const revision = this.latestRevision || this.revision;
     // comparing ObjectId with string
@@ -573,7 +579,7 @@ module.exports = function(crowi) {
       /\s+\/\s+/, // avoid miss in renaming
       /.+\/edit$/,
       /.+\.md$/,
-      /^\/(installer|register|login|logout|admin|me|files|trash|paste|comments)(\/.*|$)/,
+      /^\/(installer|register|login|logout|admin|me|files|trash|paste|comments|tags)(\/.*|$)/,
     ];
 
     let isCreatable = true;

+ 2 - 1
src/server/models/tag.js

@@ -32,7 +32,8 @@ class Tag {
 
 }
 
-module.exports = function() {
+module.exports = function(crowi) {
+  Tag.crowi = crowi;
   schema.loadClass(Tag);
   const model = mongoose.model('Tag', schema);
   return model;

+ 3 - 0
src/server/routes/index.js

@@ -202,7 +202,10 @@ module.exports = function(crowi, app) {
   app.post('/_api/pages.revertRemove' , loginRequired(crowi, app) , csrf, page.api.revertRemove); // (Avoid from API Token)
   app.post('/_api/pages.unlink'       , loginRequired(crowi, app) , csrf, page.api.unlink); // (Avoid from API Token)
   app.post('/_api/pages.duplicate', accessTokenParser, loginRequired(crowi, app), csrf, page.api.duplicate);
+  app.get('/tags'                     , loginRequired(crowi, app, false), tag.showPage);
+  app.get('/_api/tags.list'           , accessTokenParser, loginRequired(crowi, app, false), tag.api.list);
   app.get('/_api/tags.search'         , accessTokenParser, loginRequired(crowi, app, false), tag.api.search);
+  app.post('/_api/tags.update'         , accessTokenParser, loginRequired(crowi, app, false), tag.api.update);
   app.get('/_api/comments.get'        , accessTokenParser , loginRequired(crowi, app, false) , comment.api.get);
   app.post('/_api/comments.add'       , form.comment, accessTokenParser , loginRequired(crowi, app) , csrf, comment.api.add);
   app.post('/_api/comments.remove'    , accessTokenParser , loginRequired(crowi, app) , csrf, comment.api.remove);

+ 2 - 0
src/server/routes/page.js

@@ -1074,10 +1074,12 @@ module.exports = function(crowi, app) {
     }
 
     await page.populateDataToShowRevision();
+    const originTags = await page.findRelatedTagsById();
 
     req.body.path = newPagePath;
     req.body.body = page.revision.body;
     req.body.grant = page.grant;
+    req.body.pageTags = originTags;
 
     return api.create(req, res);
   };

+ 9 - 1
src/server/routes/search.js

@@ -69,7 +69,15 @@ module.exports = function(crowi, app) {
         scoreMap[esPage._id] = esPage._score;
       }
 
-      const findResult = await Page.findListByPageIds(esResult.data);
+      const ids = esResult.data.map((page) => { return page._id });
+      const findResult = await Page.findListByPageIds(ids);
+
+      // add tag data to result pages
+      findResult.pages.map((page) => {
+        const data = esResult.data.find((data) => { return page.id === data._id });
+        page._doc.tags = data._source.tag_names;
+        return page;
+      });
 
       result.meta = esResult.meta;
       result.totalCount = findResult.totalCount;

+ 74 - 0
src/server/routes/tag.js

@@ -1,12 +1,16 @@
 module.exports = function(crowi, app) {
 
   const Tag = crowi.model('Tag');
+  const PageTagRelation = crowi.model('PageTagRelation');
   const ApiResponse = require('../util/apiResponse');
   const actions = {};
   const api = {};
 
   actions.api = api;
 
+  actions.showPage = function(req, res) {
+    return res.render('tags');
+  };
 
   /**
    * @api {get} /tags.search search tags
@@ -21,5 +25,75 @@ module.exports = function(crowi, app) {
     return res.json(ApiResponse.success({ tags }));
   };
 
+  /**
+   * @api {post} /tags.update update tags on view-mode (not edit-mode)
+   * @apiName UpdateTag
+   * @apiGroup Tag
+   *
+   * @apiParam {String} PageId
+   * @apiParam {array} tags
+   */
+  api.update = async function(req, res) {
+    const Page = crowi.model('Page');
+    const tagEvent = crowi.event('tag');
+    const pageId = req.body.pageId;
+    const tags = req.body.tags;
+
+    try {
+      const page = await Page.findById(pageId);
+      await page.updateTags(tags);
+
+      tagEvent.emit('update', page, tags);
+    }
+    catch (err) {
+      return res.json(ApiResponse.error(err));
+    }
+    return res.json(ApiResponse.success());
+  };
+
+  /**
+   * @api {get} /tags.list get tagnames and count pages relate each tag
+   * @apiName tagList
+   * @apiGroup Tag
+   *
+   * @apiParam {Number} limit
+   * @apiParam {Number} offset
+   */
+  api.list = async function(req, res) {
+    const limit = +req.query.limit || 50;
+    const offset = +req.query.offset || 0;
+    const sortOpt = { count: -1 };
+    const queryOptions = { offset, limit, sortOpt };
+    const result = {};
+
+    try {
+      // get tag list contains id and count properties
+      const listData = await PageTagRelation.createTagListWithCount(queryOptions);
+      const ids = listData.list.map((obj) => { return obj._id });
+
+      // get tag documents for add name data to the list
+      const tags = await Tag.find({ _id: { $in: ids } });
+
+      // add name property
+      result.data = listData.list.map((elm) => {
+        const data = {};
+        const tag = tags.find((tag) => { return (tag.id === elm._id.toString()) });
+
+        data._id = elm._id;
+        data.name = tag.name;
+        data.count = elm.count; // the number of related pages
+        return data;
+      });
+
+      result.totalCount = listData.totalCount;
+
+      return res.json(ApiResponse.success(result));
+    }
+    catch (err) {
+      return res.json(ApiResponse.error(err));
+    }
+  };
+
+
   return actions;
 };

+ 13 - 2
src/server/util/search.js

@@ -84,11 +84,15 @@ SearchClient.prototype.registerUpdateEvent = function() {
   const pageEvent = this.crowi.event('page');
   pageEvent.on('create', this.syncPageCreated.bind(this));
   pageEvent.on('update', this.syncPageUpdated.bind(this));
+  pageEvent.on('updateTag', this.syncPageUpdated.bind(this));
   pageEvent.on('delete', this.syncPageDeleted.bind(this));
 
   const bookmarkEvent = this.crowi.event('bookmark');
   bookmarkEvent.on('create', this.syncBookmarkChanged.bind(this));
   bookmarkEvent.on('delete', this.syncBookmarkChanged.bind(this));
+
+  const tagEvent = this.crowi.event('tag');
+  tagEvent.on('update', this.syncTagChanged.bind(this));
 };
 
 SearchClient.prototype.shouldIndexed = function(page) {
@@ -387,7 +391,7 @@ SearchClient.prototype.search = async function(query) {
 
 SearchClient.prototype.createSearchQuerySortedByUpdatedAt = function(option) {
   // getting path by default is almost for debug
-  let fields = ['path', 'bookmark_count'];
+  let fields = ['path', 'bookmark_count', 'tag_names'];
   if (option) {
     fields = option.fields || fields;
   }
@@ -408,7 +412,7 @@ SearchClient.prototype.createSearchQuerySortedByUpdatedAt = function(option) {
 };
 
 SearchClient.prototype.createSearchQuerySortedByScore = function(option) {
-  let fields = ['path', 'bookmark_count'];
+  let fields = ['path', 'bookmark_count', 'tag_names'];
   if (option) {
     fields = option.fields || fields;
   }
@@ -849,4 +853,11 @@ SearchClient.prototype.syncBookmarkChanged = async function(pageId) {
     .catch((err) => { return logger.error('ES Error', err) });
 };
 
+SearchClient.prototype.syncTagChanged = async function(page) {
+  this.updatePages([page])
+    .then((res) => { return debug('ES Response', res) })
+    .catch((err) => { return logger.error('ES Error', err) });
+};
+
+
 module.exports = SearchClient;

+ 7 - 3
src/server/views/layout-growi/widget/header.html

@@ -8,9 +8,13 @@
       </div>
       <div class="title-container">
         <h1 class="title" id="revision-path"></h1>
-        {# [TODO] GC-1391 activate #}
-        {# <h1 class="title" id="tag-viewer"></h1> #}
-        <div id="revision-url" class="url-line"></div>
+
+        <!-- [TODO] commentout Until the destination is decided -->
+        <!-- <div id="revision-url" class="url-line"></div> -->
+        {% if not forbidden %}
+          <div class="title" id="tag-label"></div>
+        {% endif %}
+
       </div>
       {% if page %}
       {% include '../../widget/header-buttons.html' %}

+ 7 - 0
src/server/views/layout/layout.html

@@ -152,6 +152,13 @@
             <i class="ti-menu"></i>
           </a>
         </li>
+        <li>
+          <li class="nav-item-admin">
+            <a href="/tags">
+              <i class="tag-icon icon-tag"></i><span>{{ t('Tags') }}</span>
+            </a>
+          </li>
+        </li>
         <li>
           {% if searchConfigured() %}
           <div class="navbar-form navbar-left search-top" role="search" id="search-top"></div>

+ 26 - 0
src/server/views/tags.html

@@ -0,0 +1,26 @@
+{% extends 'layout/layout.html' %}
+
+{% block layout_main %}
+<div class="container-fluid">
+  <div class="row bg-title hidden-print">
+    <div class="col-xs-12 header-container">
+      {% block content_header %}
+      <div class="header-wrap">
+        <header id="page-header">
+          <h1 id="admin-title" class="title">{{ t('Tags') }}</h1>
+        </header>
+      </div>
+      {% endblock %}
+    </div>
+  </div>
+  <div class="row">
+    <div id="main" class="main m-t-15 col-md-12 tags-page">
+      <div class="" id="tags-page"></div>
+    </div>
+  </div>
+</div><!-- /.container-fluid -->
+
+<footer class="footer">
+  {% include 'widget/system-version.html' %}
+</footer>
+{% endblock %} {# layout_main #}

Разница между файлами не показана из-за своего большого размера
+ 0 - 137
yarn.lock


Некоторые файлы не были показаны из-за большого количества измененных файлов