Explorar o código

Merge pull request #899 from weseek/feat/show-tags-page

Feat/show tags page
Yuki Takei %!s(int64=7) %!d(string=hai) anos
pai
achega
9c4bf2b5c4

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

@@ -17,6 +17,7 @@ import GrowiRenderer from './util/GrowiRenderer';
 
 
 import HeaderSearchBox from './components/HeaderSearchBox';
 import HeaderSearchBox from './components/HeaderSearchBox';
 import SearchPage from './components/SearchPage';
 import SearchPage from './components/SearchPage';
+import TagsList from './components/TagsList';
 import PageEditor from './components/PageEditor';
 import PageEditor from './components/PageEditor';
 // eslint-disable-next-line import/no-duplicates
 // eslint-disable-next-line import/no-duplicates
 import OptionsSelector from './components/PageEditor/OptionsSelector';
 import OptionsSelector from './components/PageEditor/OptionsSelector';
@@ -297,6 +298,8 @@ const componentMappings = {
   'bookmark-button': <BookmarkButton pageId={pageId} crowi={crowi} />,
   'bookmark-button': <BookmarkButton pageId={pageId} crowi={crowi} />,
   'bookmark-button-lg': <BookmarkButton pageId={pageId} crowi={crowi} size="lg" />,
   'bookmark-button-lg': <BookmarkButton pageId={pageId} crowi={crowi} size="lg" />,
 
 
+  'tags-page': <TagsList crowi={crowi} />,
+
   'create-page-name-input': <PagePathAutoComplete crowi={crowi} initializedPath={pagePath} addTrailingSlash />,
   'create-page-name-input': <PagePathAutoComplete crowi={crowi} initializedPath={pagePath} addTrailingSlash />,
   'rename-page-name-input': <PagePathAutoComplete crowi={crowi} initializedPath={pagePath} />,
   'rename-page-name-input': <PagePathAutoComplete crowi={crowi} initializedPath={pagePath} />,
   'duplicate-page-name-input': <PagePathAutoComplete crowi={crowi} initializedPath={pagePath} />,
   'duplicate-page-name-input': <PagePathAutoComplete crowi={crowi} initializedPath={pagePath} />,

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

@@ -0,0 +1,185 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+
+import Pagination from 'react-bootstrap/lib/Pagination';
+
+export default 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">
+          <p className="float-left my-0">{data.name}</p>
+        </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 tagList = this.generateTagList(this.state.tagData);
+
+    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);
+
+    return (
+      <div>
+        <ul className="list-group mx-4">{tagList}</ul>
+        <div className="text-center">
+          <Pagination>{paginationItems}</Pagination>
+        </div>
+      </div>
+    );
+  }
+
+}
+
+TagsList.propTypes = {
+  crowi: PropTypes.object.isRequired,
+};
+
+TagsList.defaultProps = {
+};

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

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

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

@@ -573,7 +573,7 @@ module.exports = function(crowi) {
       /\s+\/\s+/, // avoid miss in renaming
       /\s+\/\s+/, // avoid miss in renaming
       /.+\/edit$/,
       /.+\/edit$/,
       /.+\.md$/,
       /.+\.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;
     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);
   schema.loadClass(Tag);
   const model = mongoose.model('Tag', schema);
   const model = mongoose.model('Tag', schema);
   return model;
   return model;

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

@@ -202,6 +202,8 @@ 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.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.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.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.get('/_api/tags.search'         , accessTokenParser, loginRequired(crowi, app, false), tag.api.search);
   app.get('/_api/comments.get'        , accessTokenParser , loginRequired(crowi, app, false) , comment.api.get);
   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.add'       , form.comment, accessTokenParser , loginRequired(crowi, app) , csrf, comment.api.add);

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

@@ -1,12 +1,16 @@
 module.exports = function(crowi, app) {
 module.exports = function(crowi, app) {
 
 
   const Tag = crowi.model('Tag');
   const Tag = crowi.model('Tag');
+  const PageTagRelation = crowi.model('PageTagRelation');
   const ApiResponse = require('../util/apiResponse');
   const ApiResponse = require('../util/apiResponse');
   const actions = {};
   const actions = {};
   const api = {};
   const api = {};
 
 
   actions.api = api;
   actions.api = api;
 
 
+  actions.showPage = function(req, res) {
+    return res.render('tags');
+  };
 
 
   /**
   /**
    * @api {get} /tags.search search tags
    * @api {get} /tags.search search tags
@@ -21,5 +25,49 @@ module.exports = function(crowi, app) {
     return res.json(ApiResponse.success({ tags }));
     return res.json(ApiResponse.success({ tags }));
   };
   };
 
 
+  /**
+   * @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 list = await PageTagRelation.createTagListWithCount(queryOptions);
+      const ids = 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 = 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 = await Tag.count();
+
+      return res.json(ApiResponse.success(result));
+    }
+    catch (err) {
+      return res.json(ApiResponse.error(err));
+    }
+  };
+
+
   return actions;
   return actions;
 };
 };

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

@@ -152,6 +152,11 @@
             <i class="ti-menu"></i>
             <i class="ti-menu"></i>
           </a>
           </a>
         </li>
         </li>
+        <li>
+          <a href="/tags">
+            <i class="fa fa-tags"></i>Tags
+          </a>
+        </li>
         <li>
         <li>
           {% if searchConfigured() %}
           {% if searchConfigured() %}
           <div class="navbar-form navbar-left search-top" role="search" id="search-top"></div>
           <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">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 #}