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

Merge pull request #969 from weseek/feat/thread_comments_frontend

Feat/thread comments frontend
Yuki Takei 6 лет назад
Родитель
Сommit
3ba037b1fc

+ 1 - 0
package.json

@@ -215,6 +215,7 @@
     "terser-webpack-plugin": "^1.2.2",
     "throttle-debounce": "^2.0.0",
     "toastr": "^2.1.2",
+    "unstated": "^2.1.1",
     "webpack": "^4.29.3",
     "webpack-assets-manifest": "^3.1.1",
     "webpack-bundle-analyzer": "^3.0.2",

+ 42 - 20
src/client/js/app.js

@@ -2,6 +2,7 @@
 
 import React from 'react';
 import ReactDOM from 'react-dom';
+import { Provider } from 'unstated';
 import { I18nextProvider } from 'react-i18next';
 import * as toastr from 'toastr';
 
@@ -28,7 +29,8 @@ import PageEditorByHackmd from './components/PageEditorByHackmd';
 import Page from './components/Page';
 import PageHistory from './components/PageHistory';
 import PageComments from './components/PageComments';
-import CommentForm from './components/PageComment/CommentForm';
+import CommentContainer from './components/PageComment/CommentContainer';
+import CommentEditorLazyRenderer from './components/PageComment/CommentEditorLazyRenderer';
 import PageAttachment from './components/PageAttachment';
 import PageStatusAlert from './components/PageStatusAlert';
 import RevisionPath from './components/Page/RevisionPath';
@@ -111,6 +113,9 @@ const crowiRenderer = new GrowiRenderer(crowi, null, {
 });
 window.crowiRenderer = crowiRenderer;
 
+// create unstated container instance
+const commentContainer = new CommentContainer(crowi, pageId, pageRevisionId);
+
 // FIXME
 const isEnabledPlugins = $('body').data('plugin-enabled');
 if (isEnabledPlugins) {
@@ -286,6 +291,8 @@ if (!pageRevisionId && draft != null) {
   markdown = draft;
 }
 
+const pageEditorOptions = new EditorOptions(crowi.editorOptions);
+
 /**
  * define components
  *  key: id of element
@@ -307,9 +314,31 @@ const componentMappings = {
   'duplicate-page-name-input': <PagePathAutoComplete crowi={crowi} initializedPath={pagePath} />,
 
 };
+
 // additional definitions if data exists
+let pageComments = null;
 if (pageId) {
-  componentMappings['page-comments-list'] = <PageComments pageId={pageId} revisionId={pageRevisionId} revisionCreatedAt={pageRevisionCreatedAt} crowi={crowi} crowiOriginRenderer={crowiRenderer} />;
+  componentMappings['page-comments-list'] = (
+    <I18nextProvider i18n={i18n}>
+      <Provider inject={[commentContainer]}>
+        <PageComments
+          ref={(elem) => {
+            if (pageComments == null) {
+              pageComments = elem;
+            }
+          }}
+          revisionCreatedAt={pageRevisionCreatedAt}
+          pageId={pageId}
+          pagePath={pagePath}
+          editorOptions={pageEditorOptions}
+          slackChannels={slackChannels}
+          crowi={crowi}
+          crowiOriginRenderer={crowiRenderer}
+          revisionId={pageRevisionId}
+        />
+      </Provider>
+    </I18nextProvider>
+  );
   componentMappings['page-attachment'] = <PageAttachment pageId={pageId} markdown={markdown} crowi={crowi} />;
 }
 if (pagePath) {
@@ -484,25 +513,18 @@ if (pageEditorElem) {
 // render comment form
 const writeCommentElem = document.getElementById('page-comment-write');
 if (writeCommentElem) {
-  const pageCommentsElem = componentInstances['page-comments-list'];
-  const postCompleteHandler = (comment) => {
-    if (pageCommentsElem != null) {
-      pageCommentsElem.retrieveData();
-    }
-  };
   ReactDOM.render(
-    <I18nextProvider i18n={i18n}>
-      <CommentForm
-        crowi={crowi}
-        crowiOriginRenderer={crowiRenderer}
-        pageId={pageId}
-        pagePath={pagePath}
-        revisionId={pageRevisionId}
-        onPostComplete={postCompleteHandler}
-        editorOptions={editorOptions}
-        slackChannels={slackChannels}
-      />
-    </I18nextProvider>,
+    <Provider inject={[commentContainer]}>
+      <I18nextProvider i18n={i18n}>
+        <CommentEditorLazyRenderer
+          crowi={crowi}
+          crowiOriginRenderer={crowiRenderer}
+          editorOptions={pageEditorOptions}
+          slackChannels={slackChannels}
+        >
+        </CommentEditorLazyRenderer>
+      </I18nextProvider>
+    </Provider>,
     writeCommentElem,
   );
 }

+ 25 - 5
src/client/js/components/PageComment/Comment.jsx

@@ -1,6 +1,7 @@
 import React from 'react';
 import PropTypes from 'prop-types';
 
+import Button from 'react-bootstrap/es/Button';
 import dateFnsFormat from 'date-fns/format';
 
 import RevisionBody from '../Page/RevisionBody';
@@ -32,6 +33,7 @@ export default class Comment extends React.Component {
     this.getRevisionLabelClassName = this.getRevisionLabelClassName.bind(this);
     this.deleteBtnClickedHandler = this.deleteBtnClickedHandler.bind(this);
     this.renderHtml = this.renderHtml.bind(this);
+    this.replyBtnClickedHandler = this.replyBtnClickedHandler.bind(this);
   }
 
   componentWillMount() {
@@ -48,11 +50,11 @@ export default class Comment extends React.Component {
   }
 
   isCurrentUserEqualsToAuthor() {
-    return this.props.comment.creator.username === this.props.currentUserId;
+    return this.props.comment.creator.username === this.props.crowi.me;
   }
 
   isCurrentRevision() {
-    return this.props.comment.revision === this.props.currentRevisionId;
+    return this.props.comment.revision === this.props.revisionId;
   }
 
   getRootClassName() {
@@ -69,6 +71,10 @@ export default class Comment extends React.Component {
     this.props.deleteBtnClicked(this.props.comment);
   }
 
+  replyBtnClickedHandler() {
+    this.props.onReplyButtonClicked(this.props.comment);
+  }
+
   renderRevisionBody() {
     const config = this.props.crowi.getConfig();
     const isMathJaxEnabled = !!config.env.MATHJAX;
@@ -133,6 +139,20 @@ export default class Comment extends React.Component {
             <Username user={creator} />
           </div>
           <div className="page-comment-body">{commentBody}</div>
+          <div className="page-comment-reply text-right">
+            {
+              comment.replyTo === undefined
+              && (
+                <Button
+                  type="button"
+                  className="fcbtn btn btn-primary btn-sm btn-success btn-rounded btn-1b"
+                  onClick={this.replyBtnClickedHandler}
+                >
+                  Reply
+                </Button>
+              )
+            }
+          </div>
           <div className="page-comment-meta">
             {commentDate}&nbsp;
             <a className={revisionLavelClassName} href={revHref}>{revFirst8Letters}</a>
@@ -151,9 +171,9 @@ export default class Comment extends React.Component {
 
 Comment.propTypes = {
   comment: PropTypes.object.isRequired,
-  currentRevisionId: PropTypes.string.isRequired,
-  currentUserId: PropTypes.string.isRequired,
+  crowiRenderer: PropTypes.object.isRequired,
   deleteBtnClicked: PropTypes.func.isRequired,
+  onReplyButtonClicked: PropTypes.func.isRequired,
   crowi: PropTypes.object.isRequired,
-  crowiRenderer: PropTypes.object.isRequired,
+  revisionId: PropTypes.string,
 };

+ 121 - 0
src/client/js/components/PageComment/CommentContainer.jsx

@@ -0,0 +1,121 @@
+import { Container } from 'unstated';
+
+/**
+ *
+ * @author Yuki Takei <yuki@weseek.co.jp>
+ *
+ * @extends {Container} unstated Container
+ */
+export default class CommentContainer extends Container {
+
+  constructor(crowi, pageId, revisionId) {
+    super();
+
+    this.crowi = crowi;
+    this.pageId = pageId;
+    this.revisionId = revisionId;
+
+    this.state = {
+      comments: [],
+    };
+
+    this.retrieveComments = this.retrieveComments.bind(this);
+  }
+
+  init() {
+    if (!this.props.pageId) {
+      return;
+    }
+  }
+
+  findAndSplice(comment) {
+    const comments = this.state.comments;
+
+    const index = comments.indexOf(comment);
+    if (index < 0) {
+      return;
+    }
+    comments.splice(index, 1);
+
+    this.setState({ comments });
+  }
+
+  /**
+   * Load data of comments and store them in state
+   */
+  retrieveComments() {
+    // get data (desc order array)
+    return this.crowi.apiGet('/comments.get', { page_id: this.pageId })
+      .then((res) => {
+        if (res.ok) {
+          this.setState({ comments: res.comments });
+        }
+      });
+  }
+
+  /**
+   * Load data of comments and rerender <PageComments />
+   */
+  postComment(comment, isMarkdown, replyTo, isSlackEnabled, slackChannels) {
+    return this.crowi.apiPost('/comments.add', {
+      commentForm: {
+        comment,
+        _csrf: this.crowi.csrfToken,
+        page_id: this.pageId,
+        revision_id: this.revisionId,
+        is_markdown: isMarkdown,
+        replyTo,
+      },
+      slackNotificationForm: {
+        isSlackEnabled,
+        slackChannels,
+      },
+    })
+      .then((res) => {
+        if (res.ok) {
+          return this.retrieveComments();
+        }
+      });
+  }
+
+  deleteComment(comment) {
+    return this.crowi.apiPost('/comments.remove', { comment_id: comment._id })
+      .then((res) => {
+        if (res.ok) {
+          this.findAndSplice(comment);
+        }
+      });
+  }
+
+  uploadAttachment(file) {
+    // const endpoint = '/attachments.add';
+
+    // // create a FromData instance
+    // const formData = new FormData();
+    // formData.append('_csrf', this.props.data.crowi.csrfToken);
+    // formData.append('file', file);
+    // formData.append('path', this.props.data.pagePath);
+    // formData.append('page_id', this.props.data.pageId || 0);
+
+    // // post
+    // this.props.data.crowi.apiPost(endpoint, formData)
+    //   .then((res) => {
+    //     const attachment = res.attachment;
+    //     const fileName = attachment.originalName;
+
+    //     let insertText = `[${fileName}](${attachment.filePathProxied})`;
+    //     // when image
+    //     if (attachment.fileFormat.startsWith('image/')) {
+    //       // modify to "![fileName](url)" syntax
+    //       insertText = `!${insertText}`;
+    //     }
+    //     this.editor.insertText(insertText);
+    //   })
+    //   .catch(this.apiErrorHandler)
+    //   // finally
+    //   .then(() => {
+    //     this.editor.terminateUploadingState();
+    //   });
+  }
+
+}

+ 365 - 0
src/client/js/components/PageComment/CommentEditor.jsx

@@ -0,0 +1,365 @@
+/* eslint-disable react/no-multi-comp */
+import React from 'react';
+import PropTypes from 'prop-types';
+
+import { Subscribe } from 'unstated';
+
+import Button from 'react-bootstrap/es/Button';
+import Tab from 'react-bootstrap/es/Tab';
+import Tabs from 'react-bootstrap/es/Tabs';
+import UserPicture from '../User/UserPicture';
+import ReactUtils from '../ReactUtils';
+
+import GrowiRenderer from '../../util/GrowiRenderer';
+
+import Editor from '../PageEditor/Editor';
+import CommentContainer from './CommentContainer';
+import CommentPreview from './CommentPreview';
+import SlackNotification from '../SlackNotification';
+
+/**
+ *
+ * @author Yuki Takei <yuki@weseek.co.jp>
+ *
+ * @extends {React.Component}
+ */
+
+class CommentEditor extends React.Component {
+
+  constructor(props) {
+    super(props);
+
+    const config = this.props.crowi.getConfig();
+    const isUploadable = config.upload.image || config.upload.file;
+    const isUploadableFile = config.upload.file;
+
+    this.state = {
+      isLayoutTypeGrowi: false,
+      comment: '',
+      isMarkdown: true,
+      html: '',
+      key: 1,
+      isUploadable,
+      isUploadableFile,
+      errorMessage: undefined,
+      hasSlackConfig: config.hasSlackConfig,
+      isSlackEnabled: false,
+      slackChannels: this.props.slackChannels,
+    };
+
+    this.growiRenderer = new GrowiRenderer(this.props.crowi, this.props.crowiOriginRenderer, { mode: 'comment' });
+
+    this.updateState = this.updateState.bind(this);
+    this.updateStateCheckbox = this.updateStateCheckbox.bind(this);
+
+    this.postHandler = this.postHandler.bind(this);
+    this.uploadHandler = this.uploadHandler.bind(this);
+
+    this.renderHtml = this.renderHtml.bind(this);
+    this.handleSelect = this.handleSelect.bind(this);
+    this.onSlackEnabledFlagChange = this.onSlackEnabledFlagChange.bind(this);
+    this.onSlackChannelsChange = this.onSlackChannelsChange.bind(this);
+  }
+
+  componentWillMount() {
+    this.init();
+  }
+
+  init() {
+    const layoutType = this.props.crowi.getConfig().layoutType;
+    this.setState({ isLayoutTypeGrowi: layoutType === 'crowi-plus' || layoutType === 'growi' });
+  }
+
+  updateState(value) {
+    this.setState({ comment: value });
+  }
+
+  updateStateCheckbox(event) {
+    const value = event.target.checked;
+    this.setState({ isMarkdown: value });
+    // changeMode
+    this.editor.setGfmMode(value);
+  }
+
+  handleSelect(key) {
+    this.setState({ key });
+    this.renderHtml(this.state.comment);
+  }
+
+  onSlackEnabledFlagChange(value) {
+    this.setState({ isSlackEnabled: value });
+  }
+
+  onSlackChannelsChange(value) {
+    this.setState({ slackChannels: value });
+  }
+
+  /**
+   * Post comment with CommentContainer and update state
+   */
+  postHandler(event) {
+    if (event != null) {
+      event.preventDefault();
+    }
+
+    this.props.commentContainer.postComment(
+      this.state.comment,
+      this.state.isMarkdown,
+      null, // TODO set replyTo
+      this.state.isSlackEnabled,
+      this.state.slackChannels,
+    )
+      .then((res) => {
+        this.setState({
+          comment: '',
+          isMarkdown: true,
+          html: '',
+          key: 1,
+          errorMessage: undefined,
+          isSlackEnabled: false,
+        });
+        // reset value
+        this.editor.setValue('');
+      })
+      .catch((err) => {
+        const errorMessage = err.message || 'An unknown error occured when posting comment';
+        this.setState({ errorMessage });
+      });
+  }
+
+  uploadHandler(file) {
+    // const endpoint = '/attachments.add';
+
+    /*
+    // create a FromData instance
+    const formData = new FormData();
+    formData.append('_csrf', this.props.data.crowi.csrfToken);
+    formData.append('file', file);
+    formData.append('path', this.props.data.pagePath);
+    formData.append('page_id', this.props.data.pageId || 0);
+
+    // post
+    this.props.data.crowi.apiPost(endpoint, formData)
+      .then((res) => {
+        const attachment = res.attachment;
+        const fileName = attachment.originalName;
+
+        let insertText = `[${fileName}](${attachment.filePathProxied})`;
+        // when image
+        if (attachment.fileFormat.startsWith('image/')) {
+          // modify to "![fileName](url)" syntax
+          insertText = `!${insertText}`;
+        }
+        this.editor.insertText(insertText);
+      })
+      .catch(this.apiErrorHandler)
+      // finally
+      .then(() => {
+        this.editor.terminateUploadingState();
+      });
+    */
+  }
+
+  // apiErrorHandler(error) {
+  //   toastr.error(error.message, 'Error occured', {
+  //     closeButton: true,
+  //     progressBar: true,
+  //     newestOnTop: false,
+  //     showDuration: '100',
+  //     hideDuration: '100',
+  //     timeOut: '3000',
+  //   });
+  // }
+
+  getCommentHtml() {
+    return (
+      <CommentPreview
+        inputRef={(el) => { this.previewElement = el }}
+        html={this.state.html}
+      />
+    );
+  }
+
+  renderHtml(markdown) {
+    const context = {
+      markdown,
+    };
+
+    const growiRenderer = this.growiRenderer;
+    const interceptorManager = this.props.crowi.interceptorManager;
+    interceptorManager.process('preRenderCommnetPreview', context)
+      .then(() => { return interceptorManager.process('prePreProcess', context) })
+      .then(() => {
+        context.markdown = growiRenderer.preProcess(context.markdown);
+      })
+      .then(() => { return interceptorManager.process('postPreProcess', context) })
+      .then(() => {
+        const parsedHTML = growiRenderer.process(context.markdown);
+        context.parsedHTML = parsedHTML;
+      })
+      .then(() => { return interceptorManager.process('prePostProcess', context) })
+      .then(() => {
+        context.parsedHTML = growiRenderer.postProcess(context.parsedHTML);
+      })
+      .then(() => { return interceptorManager.process('postPostProcess', context) })
+      .then(() => { return interceptorManager.process('preRenderCommentPreviewHtml', context) })
+      .then(() => {
+        this.setState({ html: context.parsedHTML });
+      })
+      // process interceptors for post rendering
+      .then(() => { return interceptorManager.process('postRenderCommentPreviewHtml', context) });
+  }
+
+  generateInnerHtml(html) {
+    return { __html: html };
+  }
+
+  render() {
+    const crowi = this.props.crowi;
+    const username = crowi.me;
+    const user = crowi.findUser(username);
+    const comment = this.state.comment;
+    const commentPreview = this.state.isMarkdown ? this.getCommentHtml() : ReactUtils.nl2br(comment);
+    const emojiStrategy = this.props.crowi.getEmojiStrategy();
+
+    const isLayoutTypeGrowi = this.state.isLayoutTypeGrowi;
+
+    const errorMessage = <span className="text-danger text-right mr-2">{this.state.errorMessage}</span>;
+    const submitButton = (
+      <Button
+        bsStyle="primary"
+        className="fcbtn btn btn-sm btn-primary btn-outline btn-rounded btn-1b"
+        onClick={this.postHandler}
+      >
+        Comment
+      </Button>
+    );
+
+    return (
+      <div>
+
+        { username
+          && (
+          <div className="comment-form">
+            { isLayoutTypeGrowi
+              && (
+              <div className="comment-form-user">
+                <UserPicture user={user} />
+              </div>
+              )
+            }
+            <div className="comment-form-main">
+              <div className="comment-write">
+                <Tabs activeKey={this.state.key} id="comment-form-tabs" onSelect={this.handleSelect} animation={false}>
+                  <Tab eventKey={1} title="Write">
+                    <Editor
+                      ref={(c) => { this.editor = c }}
+                      value={this.state.comment}
+                      isGfmMode={this.state.isMarkdown}
+                      editorOptions={this.props.editorOptions}
+                      lineNumbers={false}
+                      isMobile={this.props.crowi.isMobile}
+                      isUploadable={this.state.isUploadable && this.state.isLayoutTypeGrowi} // enable only when GROWI layout
+                      isUploadableFile={this.state.isUploadableFile}
+                      emojiStrategy={emojiStrategy}
+                      onChange={this.updateState}
+                      onUpload={this.uploadHandler}
+                      onCtrlEnter={this.postHandler}
+                    />
+                  </Tab>
+                  { this.state.isMarkdown
+                    && (
+                    <Tab eventKey={2} title="Preview">
+                      <div className="comment-form-preview">
+                        {commentPreview}
+                      </div>
+                    </Tab>
+                    )
+                  }
+                </Tabs>
+              </div>
+              <div className="comment-submit">
+                <div className="d-flex">
+                  <label style={{ flex: 1 }}>
+                    { isLayoutTypeGrowi && this.state.key === 1
+                      && (
+                      <span>
+                        <input
+                          type="checkbox"
+                          id="comment-form-is-markdown"
+                          name="isMarkdown"
+                          checked={this.state.isMarkdown}
+                          value="1"
+                          onChange={this.updateStateCheckbox}
+                        />
+                        <span className="ml-2">Markdown</span>
+                      </span>
+                      )
+                  }
+                  </label>
+                  <span className="hidden-xs">{ this.state.errorMessage && errorMessage }</span>
+                  { this.state.hasSlackConfig
+                    && (
+                    <div className="form-inline align-self-center mr-md-2">
+                      <SlackNotification
+                        isSlackEnabled={this.state.isSlackEnabled}
+                        slackChannels={this.state.slackChannels}
+                        onEnabledFlagChange={this.onSlackEnabledFlagChange}
+                        onChannelChange={this.onSlackChannelsChange}
+                      />
+                    </div>
+                    )
+                  }
+                  <div className="hidden-xs">{submitButton}</div>
+                </div>
+                <div className="visible-xs mt-2">
+                  <div className="d-flex justify-content-end">
+                    { this.state.errorMessage && errorMessage }
+                    <div>{submitButton}</div>
+                  </div>
+                </div>
+              </div>
+            </div>
+          </div>
+          )
+        }
+
+      </div>
+    );
+  }
+
+}
+
+/**
+ * Wrapper component for using unstated
+ */
+class CommentEditorWrapper extends React.Component {
+
+  render() {
+    return (
+      <Subscribe to={[CommentContainer]}>
+        { commentContainer => (
+          // eslint-disable-next-line arrow-body-style
+          <CommentEditor commentContainer={commentContainer} {...this.props} />
+        )}
+      </Subscribe>
+    );
+  }
+
+}
+
+CommentEditor.propTypes = {
+  crowi: PropTypes.object.isRequired,
+  crowiOriginRenderer: PropTypes.object.isRequired,
+  commentContainer: PropTypes.instanceOf(CommentContainer).isRequired,
+  editorOptions: PropTypes.object,
+  slackChannels: PropTypes.string,
+};
+CommentEditorWrapper.propTypes = {
+  crowi: PropTypes.object.isRequired,
+  crowiOriginRenderer: PropTypes.object.isRequired,
+  editorOptions: PropTypes.object,
+  slackChannels: PropTypes.string,
+};
+
+export default CommentEditorWrapper;

+ 59 - 0
src/client/js/components/PageComment/CommentEditorLazyRenderer.jsx

@@ -0,0 +1,59 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import CommentEditor from './CommentEditor';
+
+export default class CommentEditorLazyRenderer extends React.Component {
+
+  constructor(props) {
+    super(props);
+
+    this.state = {
+      isEditorShown: false,
+      isLayoutTypeGrowi: false,
+    };
+
+    this.showCommentFormBtnClickHandler = this.showCommentFormBtnClickHandler.bind(this);
+  }
+
+  componentWillMount() {
+    this.init();
+  }
+
+  init() {
+    const layoutType = this.props.crowi.getConfig().layoutType;
+    this.setState({ isLayoutTypeGrowi: layoutType === 'crowi-plus' || layoutType === 'growi' });
+  }
+
+  showCommentFormBtnClickHandler() {
+    this.setState({ isEditorShown: true });
+  }
+
+  render() {
+    return (
+      <React.Fragment>
+        { !this.state.isEditorShown
+          && (
+          <button
+            type="button"
+            className={`btn btn-lg ${this.state.isLayoutTypeGrowi ? 'btn-link' : 'btn-primary'} center-block`}
+            onClick={this.showCommentFormBtnClickHandler}
+          >
+            <i className="icon-bubble"></i> Add Comment
+          </button>
+          )
+        }
+        { this.state.isEditorShown
+          && <CommentEditor {...this.props}></CommentEditor>
+        }
+      </React.Fragment>
+    );
+  }
+
+}
+
+CommentEditorLazyRenderer.propTypes = {
+  crowi: PropTypes.object.isRequired,
+  crowiOriginRenderer: PropTypes.object.isRequired,
+  editorOptions: PropTypes.object,
+  slackChannels: PropTypes.string,
+};

+ 0 - 383
src/client/js/components/PageComment/CommentForm.jsx

@@ -1,383 +0,0 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-
-import Button from 'react-bootstrap/es/Button';
-import Tab from 'react-bootstrap/es/Tab';
-import Tabs from 'react-bootstrap/es/Tabs';
-import * as toastr from 'toastr';
-import UserPicture from '../User/UserPicture';
-import ReactUtils from '../ReactUtils';
-
-import GrowiRenderer from '../../util/GrowiRenderer';
-
-import Editor from '../PageEditor/Editor';
-import CommentPreview from './CommentPreview';
-import SlackNotification from '../SlackNotification';
-
-/**
- *
- * @author Yuki Takei <yuki@weseek.co.jp>
- *
- * @export
- * @class Comment
- * @extends {React.Component}
- */
-
-export default class CommentForm extends React.Component {
-
-  constructor(props) {
-    super(props);
-
-    const config = this.props.crowi.getConfig();
-    const isUploadable = config.upload.image || config.upload.file;
-    const isUploadableFile = config.upload.file;
-
-    this.state = {
-      isLayoutTypeGrowi: false,
-      isFormShown: false,
-      comment: '',
-      isMarkdown: true,
-      html: '',
-      key: 1,
-      isUploadable,
-      isUploadableFile,
-      errorMessage: undefined,
-      hasSlackConfig: config.hasSlackConfig,
-      isSlackEnabled: false,
-      slackChannels: this.props.slackChannels,
-    };
-
-    this.growiRenderer = new GrowiRenderer(this.props.crowi, this.props.crowiOriginRenderer, { mode: 'comment' });
-
-    this.updateState = this.updateState.bind(this);
-    this.updateStateCheckbox = this.updateStateCheckbox.bind(this);
-    this.postComment = this.postComment.bind(this);
-    this.renderHtml = this.renderHtml.bind(this);
-    this.handleSelect = this.handleSelect.bind(this);
-    this.apiErrorHandler = this.apiErrorHandler.bind(this);
-    this.onUpload = this.onUpload.bind(this);
-    this.onSlackEnabledFlagChange = this.onSlackEnabledFlagChange.bind(this);
-    this.onSlackChannelsChange = this.onSlackChannelsChange.bind(this);
-    this.showCommentFormBtnClickHandler = this.showCommentFormBtnClickHandler.bind(this);
-  }
-
-  componentWillMount() {
-    this.init();
-  }
-
-  init() {
-    if (!this.props.pageId) {
-      return;
-    }
-
-    const layoutType = this.props.crowi.getConfig().layoutType;
-    this.setState({ isLayoutTypeGrowi: layoutType === 'crowi-plus' || layoutType === 'growi' });
-  }
-
-  updateState(value) {
-    this.setState({ comment: value });
-  }
-
-  updateStateCheckbox(event) {
-    const value = event.target.checked;
-    this.setState({ isMarkdown: value });
-    // changeMode
-    this.editor.setGfmMode(value);
-  }
-
-  handleSelect(key) {
-    this.setState({ key });
-    this.renderHtml(this.state.comment);
-  }
-
-  onSlackEnabledFlagChange(value) {
-    this.setState({ isSlackEnabled: value });
-  }
-
-  onSlackChannelsChange(value) {
-    this.setState({ slackChannels: value });
-  }
-
-  /**
-   * Load data of comments and rerender <PageComments />
-   */
-  postComment(event) {
-    if (event != null) {
-      event.preventDefault();
-    }
-
-    this.props.crowi.apiPost('/comments.add', {
-      commentForm: {
-        comment: this.state.comment,
-        _csrf: this.props.crowi.csrfToken,
-        page_id: this.props.pageId,
-        revision_id: this.props.revisionId,
-        is_markdown: this.state.isMarkdown,
-        replyTo: this.props.replyTo,
-      },
-      slackNotificationForm: {
-        isSlackEnabled: this.state.isSlackEnabled,
-        slackChannels: this.state.slackChannels,
-      },
-    })
-      .then((res) => {
-        if (this.props.onPostComplete != null) {
-          this.props.onPostComplete(res.comment);
-        }
-        this.setState({
-          comment: '',
-          isMarkdown: true,
-          html: '',
-          key: 1,
-          errorMessage: undefined,
-          isSlackEnabled: false,
-        });
-        // reset value
-        this.editor.setValue('');
-      })
-      .catch((err) => {
-        const errorMessage = err.message || 'An unknown error occured when posting comment';
-        this.setState({ errorMessage });
-      });
-  }
-
-  getCommentHtml() {
-    return (
-      <CommentPreview
-        inputRef={(el) => { this.previewElement = el }}
-        html={this.state.html}
-      />
-    );
-  }
-
-  renderHtml(markdown) {
-    const context = {
-      markdown,
-    };
-
-    const growiRenderer = this.growiRenderer;
-    const interceptorManager = this.props.crowi.interceptorManager;
-    interceptorManager.process('preRenderCommnetPreview', context)
-      .then(() => { return interceptorManager.process('prePreProcess', context) })
-      .then(() => {
-        context.markdown = growiRenderer.preProcess(context.markdown);
-      })
-      .then(() => { return interceptorManager.process('postPreProcess', context) })
-      .then(() => {
-        const parsedHTML = growiRenderer.process(context.markdown);
-        context.parsedHTML = parsedHTML;
-      })
-      .then(() => { return interceptorManager.process('prePostProcess', context) })
-      .then(() => {
-        context.parsedHTML = growiRenderer.postProcess(context.parsedHTML);
-      })
-      .then(() => { return interceptorManager.process('postPostProcess', context) })
-      .then(() => { return interceptorManager.process('preRenderCommentPreviewHtml', context) })
-      .then(() => {
-        this.setState({ html: context.parsedHTML });
-      })
-      // process interceptors for post rendering
-      .then(() => { return interceptorManager.process('postRenderCommentPreviewHtml', context) });
-  }
-
-  generateInnerHtml(html) {
-    return { __html: html };
-  }
-
-  onUpload(file) {
-    const endpoint = '/attachments.add';
-
-    // create a FromData instance
-    const formData = new FormData();
-    formData.append('_csrf', this.props.crowi.csrfToken);
-    formData.append('file', file);
-    formData.append('path', this.props.pagePath);
-    formData.append('page_id', this.props.pageId || 0);
-
-    // post
-    this.props.crowi.apiPost(endpoint, formData)
-      .then((res) => {
-        const attachment = res.attachment;
-        const fileName = attachment.originalName;
-
-        let insertText = `[${fileName}](${attachment.filePathProxied})`;
-        // when image
-        if (attachment.fileFormat.startsWith('image/')) {
-          // modify to "![fileName](url)" syntax
-          insertText = `!${insertText}`;
-        }
-        this.editor.insertText(insertText);
-      })
-      .catch(this.apiErrorHandler)
-      // finally
-      .then(() => {
-        this.editor.terminateUploadingState();
-      });
-  }
-
-  apiErrorHandler(error) {
-    toastr.error(error.message, 'Error occured', {
-      closeButton: true,
-      progressBar: true,
-      newestOnTop: false,
-      showDuration: '100',
-      hideDuration: '100',
-      timeOut: '3000',
-    });
-  }
-
-  showCommentFormBtnClickHandler() {
-    this.setState({ isFormShown: true });
-  }
-
-  renderControls() {
-
-  }
-
-  render() {
-    const crowi = this.props.crowi;
-    const username = crowi.me;
-    const user = crowi.findUser(username);
-    const comment = this.state.comment;
-    const commentPreview = this.state.isMarkdown ? this.getCommentHtml() : ReactUtils.nl2br(comment);
-    const emojiStrategy = this.props.crowi.getEmojiStrategy();
-
-    const isLayoutTypeGrowi = this.state.isLayoutTypeGrowi;
-
-    const errorMessage = <span className="text-danger text-right mr-2">{this.state.errorMessage}</span>;
-    const submitButton = (
-      <Button type="submit" bsStyle="primary" className="fcbtn btn btn-sm btn-primary btn-outline btn-rounded btn-1b">
-        Comment
-      </Button>
-    );
-
-    return (
-      <div>
-
-        <form className="form page-comment-form" id="page-comment-form" onSubmit={this.postComment}>
-          { username
-            && (
-            <div className="comment-form">
-              { isLayoutTypeGrowi
-                && (
-                <div className="comment-form-user">
-                  <UserPicture user={user} />
-                </div>
-                )
-              }
-              <div className="comment-form-main">
-                {/* Add Comment Button */}
-                { !this.state.isFormShown
-                  && (
-                  <button
-                    type="button"
-                    className={`btn btn-lg ${isLayoutTypeGrowi ? 'btn-link' : 'btn-primary'} center-block`}
-                    onClick={this.showCommentFormBtnClickHandler}
-                  >
-                    <i className="icon-bubble"></i> Add Comment
-                  </button>
-                  )
-                }
-                {/* Editor */}
-                { this.state.isFormShown
-                  && (
-                  <React.Fragment>
-                    <div className="comment-write">
-                      <Tabs activeKey={this.state.key} id="comment-form-tabs" onSelect={this.handleSelect} animation={false}>
-                        <Tab eventKey={1} title="Write">
-                          <Editor
-                            ref={(c) => { this.editor = c }}
-                            value={this.state.comment}
-                            isGfmMode={this.state.isMarkdown}
-                            editorOptions={this.props.editorOptions}
-                            lineNumbers={false}
-                            isMobile={this.props.crowi.isMobile}
-                            isUploadable={this.state.isUploadable && this.state.isLayoutTypeGrowi} // enable only when GROWI layout
-                            isUploadableFile={this.state.isUploadableFile}
-                            emojiStrategy={emojiStrategy}
-                            onChange={this.updateState}
-                            onUpload={this.onUpload}
-                            onCtrlEnter={this.postComment}
-                          />
-                        </Tab>
-                        { this.state.isMarkdown
-                          && (
-                          <Tab eventKey={2} title="Preview">
-                            <div className="comment-form-preview">
-                              {commentPreview}
-                            </div>
-                          </Tab>
-                          )
-                        }
-                      </Tabs>
-                    </div>
-                    <div className="comment-submit">
-                      <div className="d-flex">
-                        <label style={{ flex: 1 }}>
-                          { isLayoutTypeGrowi && this.state.key === 1
-                            && (
-                            <span>
-                              <input
-                                type="checkbox"
-                                id="comment-form-is-markdown"
-                                name="isMarkdown"
-                                checked={this.state.isMarkdown}
-                                value="1"
-                                onChange={this.updateStateCheckbox}
-                              />
-                              <span className="ml-2">Markdown</span>
-                            </span>
-                            )
-                        }
-                        </label>
-                        <span className="hidden-xs">{ this.state.errorMessage && errorMessage }</span>
-                        { this.state.hasSlackConfig
-                          && (
-                          <div className="form-inline align-self-center mr-md-2">
-                            <SlackNotification
-                              isSlackEnabled={this.state.isSlackEnabled}
-                              slackChannels={this.state.slackChannels}
-                              onEnabledFlagChange={this.onSlackEnabledFlagChange}
-                              onChannelChange={this.onSlackChannelsChange}
-                            />
-                          </div>
-                          )
-                        }
-                        <div className="hidden-xs">{submitButton}</div>
-                      </div>
-                      <div className="visible-xs mt-2">
-                        <div className="d-flex justify-content-end">
-                          { this.state.errorMessage && errorMessage }
-                          <div>{submitButton}</div>
-                        </div>
-                      </div>
-                    </div>
-                  </React.Fragment>
-                  )
-                }
-              </div>
-            </div>
-            )
-          }
-        </form>
-
-      </div>
-    );
-  }
-
-}
-
-CommentForm.propTypes = {
-  crowi: PropTypes.object.isRequired,
-  crowiOriginRenderer: PropTypes.object.isRequired,
-  onPostComplete: PropTypes.func,
-  pageId: PropTypes.string,
-  revisionId: PropTypes.string,
-  pagePath: PropTypes.string,
-  editorOptions: PropTypes.object,
-  slackChannels: PropTypes.string,
-  replyTo: PropTypes.string,
-};
-CommentForm.defaultProps = {
-  editorOptions: {},
-};

+ 120 - 58
src/client/js/components/PageComments.js → src/client/js/components/PageComments.jsx

@@ -1,9 +1,16 @@
+/* eslint-disable react/no-multi-comp */
 /* eslint-disable react/no-access-state-in-setstate */
 import React from 'react';
 import PropTypes from 'prop-types';
 
+import { Subscribe } from 'unstated';
+
+import { withTranslation } from 'react-i18next';
 import GrowiRenderer from '../util/GrowiRenderer';
 
+import CommentContainer from './PageComment/CommentContainer';
+import CommentEditor from './PageComment/CommentEditor';
+
 import Comment from './PageComment/Comment';
 import DeleteCommentModal from './PageComment/DeleteCommentModal';
 
@@ -16,21 +23,20 @@ import DeleteCommentModal from './PageComment/DeleteCommentModal';
  * @class PageComments
  * @extends {React.Component}
  */
-export default class PageComments extends React.Component {
+class PageComments extends React.Component {
 
   constructor(props) {
     super(props);
 
     this.state = {
-      // desc order array
-      comments: [],
-
       isLayoutTypeGrowi: false,
 
       // for deleting comment
       commentToDelete: undefined,
       isDeleteConfirmModalShown: false,
       errorMessageForDeleting: undefined,
+
+      showEditorIds: new Set(),
     };
 
     this.growiRenderer = new GrowiRenderer(this.props.crowi, this.props.crowiOriginRenderer, { mode: 'comment' });
@@ -40,11 +46,11 @@ export default class PageComments extends React.Component {
     this.deleteComment = this.deleteComment.bind(this);
     this.showDeleteConfirmModal = this.showDeleteConfirmModal.bind(this);
     this.closeDeleteConfirmModal = this.closeDeleteConfirmModal.bind(this);
+    this.replyButtonClickedHandler = this.replyButtonClickedHandler.bind(this);
   }
 
   componentWillMount() {
     this.init();
-    this.retrieveData = this.retrieveData.bind(this);
   }
 
   init() {
@@ -55,20 +61,7 @@ export default class PageComments extends React.Component {
     const layoutType = this.props.crowi.getConfig().layoutType;
     this.setState({ isLayoutTypeGrowi: layoutType === 'crowi-plus' || layoutType === 'growi' });
 
-    this.retrieveData();
-  }
-
-  /**
-   * Load data of comments and store them in state
-   */
-  retrieveData() {
-    // get data (desc order array)
-    this.props.crowi.apiGet('/comments.get', { page_id: this.props.pageId })
-      .then((res) => {
-        if (res.ok) {
-          this.setState({ comments: res.comments });
-        }
-      });
+    this.props.commentContainer.retrieveComments();
   }
 
   confirmToDeleteComment(comment) {
@@ -79,11 +72,8 @@ export default class PageComments extends React.Component {
   deleteComment() {
     const comment = this.state.commentToDelete;
 
-    this.props.crowi.apiPost('/comments.remove', { comment_id: comment._id })
-      .then((res) => {
-        if (res.ok) {
-          this.findAndSplice(comment);
-        }
+    this.props.commentContainer.deleteComment(comment)
+      .then(() => {
         this.closeDeleteConfirmModal();
       })
       .catch((err) => {
@@ -91,18 +81,6 @@ export default class PageComments extends React.Component {
       });
   }
 
-  findAndSplice(comment) {
-    const comments = this.state.comments;
-
-    const index = comments.indexOf(comment);
-    if (index < 0) {
-      return;
-    }
-    comments.splice(index, 1);
-
-    this.setState({ comments });
-  }
-
   showDeleteConfirmModal() {
     this.setState({ isDeleteConfirmModalShown: true });
   }
@@ -115,6 +93,26 @@ export default class PageComments extends React.Component {
     });
   }
 
+  replyButtonClickedHandler(commentId) {
+    const ids = this.state.showEditorIds.add(commentId);
+    this.setState({ showEditorIds: ids });
+  }
+
+  // inserts reply after each corresponding comment
+  reorderBasedOnReplies(comments, replies) {
+    // const connections = this.findConnections(comments, replies);
+    // const replyConnections = this.findConnectionsWithinReplies(replies);
+    const repliesReversed = replies.slice().reverse();
+    for (let i = 0; i < comments.length; i++) {
+      for (let j = 0; j < repliesReversed.length; j++) {
+        if (repliesReversed[j].replyTo === comments[i]._id) {
+          comments.splice(i + 1, 0, repliesReversed[j]);
+        }
+      }
+    }
+    return comments;
+  }
+
   /**
    * generate Elements of Comment
    *
@@ -122,18 +120,30 @@ export default class PageComments extends React.Component {
    *
    * @memberOf PageComments
    */
-  generateCommentElements(comments) {
-    return comments.map((comment) => {
+  generateCommentElements(comments, replies) {
+    const commentsWithReplies = this.reorderBasedOnReplies(comments, replies);
+    return commentsWithReplies.map((comment) => {
+
+      const commentId = comment._id;
+      const showEditor = this.state.showEditorIds.has(commentId);
+
       return (
-        <Comment
-          key={comment._id}
-          comment={comment}
-          currentUserId={this.props.crowi.me}
-          currentRevisionId={this.props.revisionId}
-          deleteBtnClicked={this.confirmToDeleteComment}
-          crowi={this.props.crowi}
-          crowiRenderer={this.growiRenderer}
-        />
+        <div key={commentId}>
+          <Comment
+            comment={comment}
+            deleteBtnClicked={this.confirmToDeleteComment}
+            crowiRenderer={this.growiRenderer}
+            onReplyButtonClicked={() => { this.replyButtonClickedHandler(commentId) }}
+            crowi={this.props.crowi}
+          />
+          { showEditor && (
+            <CommentEditor
+              crowi={this.props.crowi}
+              crowiOriginRenderer={this.props.crowiOriginRenderer}
+              editorOptions={this.props.editorOptions}
+            />
+          )}
+        </div>
       );
     });
   }
@@ -142,8 +152,11 @@ export default class PageComments extends React.Component {
     const currentComments = [];
     const newerComments = [];
     const olderComments = [];
+    const currentReplies = [];
+    const newerReplies = [];
+    const olderReplies = [];
 
-    let comments = this.state.comments;
+    let comments = this.props.commentContainer.state.comments;
     if (this.state.isLayoutTypeGrowi) {
       // replace with asc order array
       comments = comments.slice().reverse(); // non-destructive reverse
@@ -155,21 +168,35 @@ export default class PageComments extends React.Component {
     comments.forEach((comment) => {
       // comparing ObjectId
       // eslint-disable-next-line eqeqeq
-      if (comment.revision == revisionId) {
-        currentComments.push(comment);
+      if (comment.replyTo === undefined) {
+        // comment is not a reply
+        if (comment.revision === revisionId) {
+          currentComments.push(comment);
+        }
+        else if (Date.parse(comment.createdAt) / 1000 > revisionCreatedAt) {
+          newerComments.push(comment);
+        }
+        else {
+          olderComments.push(comment);
+        }
+      }
+      else
+      // comment is a reply
+      if (comment.revision === revisionId) {
+        currentReplies.push(comment);
       }
       else if (Date.parse(comment.createdAt) / 1000 > revisionCreatedAt) {
-        newerComments.push(comment);
+        newerReplies.push(comment);
       }
       else {
-        olderComments.push(comment);
+        olderReplies.push(comment);
       }
     });
 
     // generate elements
-    const currentElements = this.generateCommentElements(currentComments);
-    const newerElements = this.generateCommentElements(newerComments);
-    const olderElements = this.generateCommentElements(olderComments);
+    const currentElements = this.generateCommentElements(currentComments, currentReplies);
+    const newerElements = this.generateCommentElements(newerComments, newerReplies);
+    const olderElements = this.generateCommentElements(olderComments, olderReplies);
     // generate blocks
     const currentBlock = (
       <div className="page-comments-list-current" id="page-comments-list-current">
@@ -247,10 +274,45 @@ export default class PageComments extends React.Component {
 
 }
 
-PageComments.propTypes = {
-  pageId: PropTypes.string,
-  revisionId: PropTypes.string,
+/**
+ * Wrapper component for using unstated
+ */
+class PageCommentsWrapper extends React.Component {
+
+  render() {
+    return (
+      <Subscribe to={[CommentContainer]}>
+        { commentContainer => (
+          // eslint-disable-next-line arrow-body-style
+          <PageComments commentContainer={commentContainer} {...this.props} />
+        )}
+      </Subscribe>
+    );
+  }
+
+}
+
+PageCommentsWrapper.propTypes = {
+  crowi: PropTypes.object.isRequired,
+  crowiOriginRenderer: PropTypes.object.isRequired,
+  pageId: PropTypes.string.isRequired,
+  revisionId: PropTypes.string.isRequired,
   revisionCreatedAt: PropTypes.number,
+  pagePath: PropTypes.string,
+  editorOptions: PropTypes.object,
+  slackChannels: PropTypes.string,
+};
+PageComments.propTypes = {
+  commentContainer: PropTypes.object.isRequired,
+
   crowi: PropTypes.object.isRequired,
   crowiOriginRenderer: PropTypes.object.isRequired,
+  pageId: PropTypes.string.isRequired,
+  revisionId: PropTypes.string.isRequired,
+  revisionCreatedAt: PropTypes.number,
+  pagePath: PropTypes.string,
+  editorOptions: PropTypes.object,
+  slackChannels: PropTypes.string,
 };
+
+export default withTranslation(null, { withRef: true })(PageCommentsWrapper);

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

@@ -14,7 +14,7 @@ module.exports = function(crowi) {
     commentPosition: { type: Number, default: -1 },
     createdAt: { type: Date, default: Date.now },
     isMarkdown: { type: Boolean, default: false },
-    replyTo: { type: ObjectId, default: undefined },
+    replyTo: { type: ObjectId },
   });
 
   commentSchema.statics.create = function(pageId, creatorId, revisionId, comment, position, isMarkdown, replyTo) {

+ 12 - 0
yarn.lock

@@ -2636,6 +2636,11 @@ create-react-context@<=0.2.2:
     fbjs "^0.8.0"
     gud "^1.0.0"
 
+create-react-context@^0.1.5:
+  version "0.1.6"
+  resolved "https://registry.yarnpkg.com/create-react-context/-/create-react-context-0.1.6.tgz#0f425931d907741127acc6e31acb4f9015dd9fdc"
+  integrity sha512-eCnYYEUEc5i32LHwpE/W7NlddOB9oHwsPaWtWzYtflNkkwa3IfindIcoXdVWs12zCbwaMCavKNu84EXogVIWHw==
+
 create-react-context@^0.2.3:
   version "0.2.3"
   resolved "https://registry.yarnpkg.com/create-react-context/-/create-react-context-0.2.3.tgz#9ec140a6914a22ef04b8b09b7771de89567cb6f3"
@@ -10795,6 +10800,13 @@ unset-value@^1.0.0:
     has-value "^0.3.1"
     isobject "^3.0.0"
 
+unstated@^2.1.1:
+  version "2.1.1"
+  resolved "https://registry.yarnpkg.com/unstated/-/unstated-2.1.1.tgz#36b124dfb2e7a12d39d0bb9c46dfb6e51276e3a2"
+  integrity sha512-fORlTWMZxq7NuMJDxyIrrYIZKN7wEWYQ9SiaJfIRcSpsowr6Ph/JIfK2tgtXLW614JfPG/t5q9eEIhXRCf55xg==
+  dependencies:
+    create-react-context "^0.1.5"
+
 unzip-response@^2.0.1:
   version "2.0.1"
   resolved "https://registry.yarnpkg.com/unzip-response/-/unzip-response-2.0.1.tgz#d2f0f737d16b0615e72a6935ed04214572d56f97"