Browse Source

Merge pull request #877 from weseek/feat/tag-form-with-typeahead

Feat/tag form with typeahead
Yuki Takei 7 years ago
parent
commit
a3d1a64493

+ 10 - 16
src/client/js/app.js

@@ -31,8 +31,7 @@ import CommentForm from './components/PageComment/CommentForm';
 import PageAttachment from './components/PageAttachment';
 import PageStatusAlert from './components/PageStatusAlert';
 import RevisionPath from './components/Page/RevisionPath';
-// TODO GC-1430 activate
-// import PageTagForm from './components/PageTagForm';
+import TagViewer from './components/Page/TagViewer';
 import RevisionUrl from './components/Page/RevisionUrl';
 import BookmarkButton from './components/BookmarkButton';
 import LikeButton from './components/LikeButton';
@@ -70,9 +69,7 @@ let pagePath;
 let pageContent = '';
 let markdown = '';
 let slackChannels;
-// TODO GC-1430 activate
-// const currentPageTags = '';
-// let newPageTags = '';
+let pageTags = [];
 if (mainContent !== null) {
   pageId = mainContent.getAttribute('data-page-id') || null;
   pageRevisionId = mainContent.getAttribute('data-page-revision-id');
@@ -119,13 +116,12 @@ if (isEnabledPlugins) {
 }
 
 /**
- * get new tags from page tag form
- * @param {String} tags new tags [TODO] String -> Array
+ * receive tags from PageTagForm
+ * @param {Array} tagData new tags
  */
-// TODO GC-1430 activate
-// const getNewPageTags = function(tags) {
-//   newPageTags = tags;
-// };
+const setTagData = function(tagData) {
+  pageTags = tagData;
+};
 
 /**
  * component store
@@ -205,8 +201,7 @@ const saveWithShortcut = function(markdown) {
   // get options
   const options = componentInstances.savePageControls.getCurrentOptionsToSave();
   options.socketClientId = socketClientId;
-  // TODO GC-1430 activate
-  // options.pageTags = newPageTags;
+  options.pageTags = pageTags;
 
   if (editorMode === 'hackmd') {
     // set option to sync
@@ -244,8 +239,7 @@ const saveWithSubmitButton = function(submitOpts) {
   // get options
   const options = componentInstances.savePageControls.getCurrentOptionsToSave();
   options.socketClientId = socketClientId;
-  // TODO GC-1430 activate
-  // options.pageTags = newPageTags;
+  options.pageTags = pageTags;
 
   // set 'submitOpts.overwriteScopesOfDescendants' to options
   options.overwriteScopesOfDescendants = submitOpts ? !!submitOpts.overwriteScopesOfDescendants : false;
@@ -316,7 +310,7 @@ 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['page-tag'] = <PageTagForm pageTags={currentPageTags} submitTags={getNewPageTags} />; [pagetag]
+  componentMappings['tag-viewer'] = <TagViewer crowi={crowi} pageId={pageId} sendTagData={setTagData} />;
   componentMappings['revision-url'] = <RevisionUrl pageId={pageId} pagePath={pagePath} />;
 }
 

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

@@ -0,0 +1,106 @@
+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 });
+    }
+  }
+
+  // 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,
+};

+ 34 - 32
src/client/js/components/PageTagForm.jsx

@@ -1,5 +1,6 @@
 import React from 'react';
 import PropTypes from 'prop-types';
+import { AsyncTypeahead } from 'react-bootstrap-typeahead';
 
 /**
  *
@@ -16,42 +17,42 @@ export default class PageTagForm extends React.Component {
     super(props);
 
     this.state = {
-      pageTags: this.props.pageTags,
+      resultTags: [],
+      isLoading: false,
+      selected: this.props.currentPageTags,
     };
-
-    this.updateState = this.updateState.bind(this);
-    this.handleSubmit = this.handleSubmit.bind(this);
-  }
-
-  componentWillReceiveProps(nextProps) {
-    this.setState({
-      pageTags: nextProps.pageTags,
-    });
-  }
-
-  handleSubmit() {
-    this.props.submitTags(this.state.pageTags);
-  }
-
-  updateState(value) {
-    this.setState({ pageTags: value });
+    this.crowi = this.props.crowi;
   }
 
   render() {
     return (
-      <div className="input-group-sm mx-1">
-        <input
-          className="form-control page-tag-form"
-          type="text"
-          value={this.state.pageTags}
+      <div className="tag-typeahead">
+        <AsyncTypeahead
+          allowNew
+          caseSensitive={false}
+          defaultSelected={this.props.currentPageTags}
+          emptyLabel=""
+          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,
+            });
+          }}
+          options={this.state.resultTags} // Search result (Some tag names)
           placeholder="tag name"
-          data-toggle="popover"
-          title="タグ"
-          data-content="タグ付けによりページをカテゴライズすることができます。"
-          data-trigger="focus"
-          data-placement="right"
-          onChange={(e) => { return this.updateState(e.target.value) }}
-          onBlur={this.handleSubmit}
+          selectHintOnEnter
         />
       </div>
     );
@@ -60,8 +61,9 @@ export default class PageTagForm extends React.Component {
 }
 
 PageTagForm.propTypes = {
-  pageTags: PropTypes.string,
-  submitTags: PropTypes.func,
+  crowi: PropTypes.object.isRequired,
+  currentPageTags: PropTypes.array.isRequired,
+  addNewTag: PropTypes.func.isRequired,
 };
 
 PageTagForm.defaultProps = {

+ 21 - 13
src/client/styles/scss/_on-edit.scss

@@ -108,21 +108,17 @@ body.on-edit {
       overflow-x: hidden;
     }
 
-    div.title-container {
-      margin-right: 0px;
+    h1#revision-path {
+      @include variable-font-size(20px);
+      line-height: 1em;
 
-      h1#revision-path {
-        @include variable-font-size(20px);
-        line-height: 1em;
-
-        // nowrap even if the path is too long
-        .d-flex {
-          flex-wrap: nowrap;
-        }
+      // nowrap even if the path is too long
+      .d-flex {
+        flex-wrap: nowrap;
+      }
 
-        .path-segment {
-          white-space: nowrap;
-        }
+      .path-segment {
+        white-space: nowrap;
       }
     }
 
@@ -349,3 +345,15 @@ body.on-edit {
 .CodeMirror pre.CodeMirror-placeholder {
   color: $text-muted;
 }
+
+#tag-edit-button-tooltip {
+  .tooltip-inner {
+    background-color: #fff;
+    color: #000;
+    border: 1px solid #ccc;
+  }
+
+  .tooltip-arrow {
+    border-bottom: 5px solid #ccc;
+  }
+}

+ 7 - 4
src/client/styles/scss/_page.scss

@@ -14,13 +14,14 @@
       margin-right: auto;
     }
 
-    // hide unnecessary element
-    div#page-tag {
-      display: none;
+    .btn-tag {
+      line-height: 20px;
+      display: block;
     }
 
     .btn-copy,
     .btn-copy-link,
+    .btn-tag,
     .btn-edit {
       @extend .text-muted;
       border: none;
@@ -36,6 +37,7 @@
 
       .btn.btn-copy,
       .btn-copy-link,
+      .btn-tag,
       .btn.btn-edit {
         opacity: unset;
       }
@@ -69,9 +71,10 @@
       color: #999;
     }
 
-    h1#revision-path {
+    h1.title {
       margin-top: 0;
       margin-bottom: 0;
+      float: left;
 
       .d-flex {
         flex-wrap: wrap; // for long page path

+ 11 - 0
src/server/models/page.js

@@ -974,6 +974,7 @@ module.exports = function(crowi) {
     const redirectTo = options.redirectTo || null;
     const grantUserGroupId = options.grantUserGroupId || null;
     const socketClientId = options.socketClientId || null;
+    const pageTags = options.pageTags || null;
 
     // sanitize path
     path = crowi.xss.process(path); // eslint-disable-line no-param-reassign
@@ -1000,6 +1001,10 @@ module.exports = function(crowi) {
     await validateAppliedScope(user, grant, grantUserGroupId);
     page.applyScope(user, grant, grantUserGroupId);
 
+    if (pageTags != null) {
+      page.updateTags(pageTags);
+    }
+
     let savedPage = await page.save();
     const newRevision = Revision.prepareRevision(savedPage, body, null, user, { format });
     const revision = await pushRevision(savedPage, newRevision, user, grant, grantUserGroupId);
@@ -1021,6 +1026,7 @@ module.exports = function(crowi) {
     const grantUserGroupId = options.grantUserGroupId || null;
     const isSyncRevisionToHackmd = options.isSyncRevisionToHackmd;
     const socketClientId = options.socketClientId || null;
+    const pageTags = options.pageTags || null;
 
     await validateAppliedScope(user, grant, grantUserGroupId);
     pageData.applyScope(user, grant, grantUserGroupId);
@@ -1040,6 +1046,11 @@ module.exports = function(crowi) {
     if (socketClientId != null) {
       pageEvent.emit('update', savedPage, user, socketClientId);
     }
+
+    if (pageTags != null) {
+      savedPage.updateTags(pageTags);
+    }
+
     return savedPage;
   };
 

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

@@ -18,6 +18,7 @@ module.exports = function(crowi, app) {
   const attachment = require('./attachment')(crowi, app);
   const comment = require('./comment')(crowi, app);
   const bookmark = require('./bookmark')(crowi, app);
+  const tag = require('./tag')(crowi, app);
   const revision = require('./revision')(crowi, app);
   const search = require('./search')(crowi, app);
   const hackmd = require('./hackmd')(crowi, app);
@@ -192,14 +193,16 @@ module.exports = function(crowi, app) {
   app.post('/_api/pages.create'       , accessTokenParser , loginRequired(crowi, app) , csrf, page.api.create);
   app.post('/_api/pages.update'       , accessTokenParser , loginRequired(crowi, app) , csrf, page.api.update);
   app.get('/_api/pages.get'           , accessTokenParser , loginRequired(crowi, app, false) , page.api.get);
-  app.get('/_api/pages.updatePost'    , accessTokenParser , loginRequired(crowi, app, false) , page.api.getUpdatePost);
+  app.get('/_api/pages.updatePost', accessTokenParser, loginRequired(crowi, app, false), page.api.getUpdatePost);
+  app.get('/_api/pages.getPageTag'    , accessTokenParser , loginRequired(crowi, app, false) , page.api.getPageTag);
   // allow posting to guests because the client doesn't know whether the user logged in
   app.post('/_api/pages.seen'         , accessTokenParser , loginRequired(crowi, app, false) , page.api.seen);
   app.post('/_api/pages.rename'       , accessTokenParser , loginRequired(crowi, app) , csrf, page.api.rename);
   app.post('/_api/pages.remove'       , loginRequired(crowi, app) , csrf, page.api.remove); // (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.duplicate'    , accessTokenParser, loginRequired(crowi, app), csrf, page.api.duplicate);
+  app.post('/_api/pages.duplicate', accessTokenParser, loginRequired(crowi, app), csrf, page.api.duplicate);
+  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.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);

+ 25 - 6
src/server/routes/page.js

@@ -8,6 +8,7 @@ module.exports = function(crowi, app) {
   const Config = crowi.model('Config');
   const config = crowi.getConfig();
   const Bookmark = crowi.model('Bookmark');
+  const PageTagRelation = crowi.model('PageTagRelation');
   const UpdatePost = crowi.model('UpdatePost');
   const ApiResponse = require('../util/apiResponse');
   const interceptorManager = crowi.getInterceptorManager();
@@ -537,6 +538,7 @@ module.exports = function(crowi, app) {
    * @apiParam {String} body
    * @apiParam {String} path
    * @apiParam {String} grant
+   * @apiParam {Array} pageTags
    */
   api.create = async function(req, res) {
     const body = req.body.body || null;
@@ -547,6 +549,7 @@ module.exports = function(crowi, app) {
     const isSlackEnabled = !!req.body.isSlackEnabled; // cast to boolean
     const slackChannels = req.body.slackChannels || null;
     const socketClientId = req.body.socketClientId || undefined;
+    const pageTags = req.body.pageTags || undefined;
 
     if (body === null || pagePath === null) {
       return res.json(ApiResponse.error('Parameters body and path are required.'));
@@ -559,7 +562,7 @@ module.exports = function(crowi, app) {
     }
 
     const options = {
-      grant, grantUserGroupId, overwriteScopesOfDescendants, socketClientId,
+      grant, grantUserGroupId, overwriteScopesOfDescendants, socketClientId, pageTags,
     };
     const createdPage = await Page.create(pagePath, body, req.user, options);
 
@@ -610,9 +613,9 @@ module.exports = function(crowi, app) {
     const overwriteScopesOfDescendants = req.body.overwriteScopesOfDescendants || null;
     const isSlackEnabled = !!req.body.isSlackEnabled; // cast to boolean
     const slackChannels = req.body.slackChannels || null;
-    // const pageTags = req.body.pageTags || null;
     const isSyncRevisionToHackmd = !!req.body.isSyncRevisionToHackmd; // cast to boolean
     const socketClientId = req.body.socketClientId || undefined;
+    const pageTags = req.body.pageTags || undefined;
 
     if (pageId === null || pageBody === null) {
       return res.json(ApiResponse.error('page_id and body are required.'));
@@ -630,7 +633,7 @@ module.exports = function(crowi, app) {
       return res.json(ApiResponse.error('Posted param "revisionId" is outdated.', 'outdated'));
     }
 
-    const options = { isSyncRevisionToHackmd, socketClientId };
+    const options = { isSyncRevisionToHackmd, socketClientId, pageTags };
     if (grant != null) {
       options.grant = grant;
     }
@@ -669,9 +672,6 @@ module.exports = function(crowi, app) {
     if (isSlackEnabled && slackChannels != null) {
       await notifyToSlackByUser(page, req.user, slackChannels, 'update', previousRevision);
     }
-
-    // // update page tag
-    // await page.updateTags(pageTags); [pagetag]
   };
 
   /**
@@ -719,6 +719,25 @@ module.exports = function(crowi, app) {
     return res.json(ApiResponse.success(result));
   };
 
+  /**
+   * @api {get} /pages.getPageTag get page tags
+   * @apiName GetPageTag
+   * @apiGroup Page
+   *
+   * @apiParam {String} pageId
+   */
+  api.getPageTag = async function(req, res) {
+    const result = {};
+    try {
+      const tags = await PageTagRelation.find({ relatedPage: req.query.pageId }).populate('relatedTag').select('-_id relatedTag');
+      result.tags = tags.map((tag) => { return tag.relatedTag.name });
+    }
+    catch (err) {
+      return res.json(ApiResponse.error(err));
+    }
+    return res.json(ApiResponse.success(result));
+  };
+
   /**
    * @api {post} /pages.seen Mark as seen user
    * @apiName SeenPage

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

@@ -0,0 +1,25 @@
+module.exports = function(crowi, app) {
+
+  const Tag = crowi.model('Tag');
+  const ApiResponse = require('../util/apiResponse');
+  const actions = {};
+  const api = {};
+
+  actions.api = api;
+
+
+  /**
+   * @api {get} /tags.search search tags
+   * @apiName SearchTag
+   * @apiGroup Tag
+   *
+   * @apiParam {String} q keyword
+   */
+  api.search = async function(req, res) {
+    let tags = await Tag.find({ name: new RegExp(`^${req.query.q}`) }).select('-_id name');
+    tags = tags.map((tag) => { return tag.name });
+    return res.json(ApiResponse.success({ tags }));
+  };
+
+  return actions;
+};

+ 2 - 1
src/server/views/layout-growi/widget/header.html

@@ -8,9 +8,10 @@
       </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>
       </div>
-      <!-- <div class="tag" id="page-tag"></div> [pagetag]-->
       {% if page %}
       {% include '../../widget/header-buttons.html' %}