Переглянути джерело

Merge pull request #1195 from weseek/master

release v3.5.13
Yuki Takei 6 роки тому
батько
коміт
86304a72d8

+ 8 - 1
CHANGES.md

@@ -1,6 +1,13 @@
 # CHANGES
 # CHANGES
 
 
-## 3.5.12-RC
+## 3.5.13-RC
+
+* Feature: Re-edit comments
+* Support: Update libs
+    * entities
+    * markdown-it
+
+## 3.5.12
 
 
 * Improvement: Use Elasticsearch Alias
 * Improvement: Use Elasticsearch Alias
 * Improvement: Connect to HTTPS PlantUML URL in default
 * Improvement: Connect to HTTPS PlantUML URL in default

+ 1 - 0
config/env.dev.js

@@ -9,6 +9,7 @@ module.exports = {
   PLUGIN_NAMES_TOBE_LOADED: [
   PLUGIN_NAMES_TOBE_LOADED: [
     // 'growi-plugin-lsx',
     // 'growi-plugin-lsx',
     // 'growi-plugin-pukiwiki-like-linker',
     // 'growi-plugin-pukiwiki-like-linker',
+    // 'growi-plugin-attachment-refs',
   ],
   ],
   // PUBLISH_OPEN_API: true,
   // PUBLISH_OPEN_API: true,
   // USER_UPPER_LIMIT: 0,
   // USER_UPPER_LIMIT: 0,

+ 2 - 0
config/jest.config.js

@@ -6,6 +6,8 @@ module.exports = {
   verbose: true,
   verbose: true,
 
 
   rootDir: '../',
   rootDir: '../',
+  globalSetup: '<rootDir>/src/test/global-setup.js',
+  globalTeardown: '<rootDir>/src/test/global-teardown.js',
 
 
   projects: [
   projects: [
     {
     {

+ 3 - 4
package.json

@@ -1,6 +1,6 @@
 {
 {
   "name": "growi",
   "name": "growi",
-  "version": "3.5.12-RC",
+  "version": "3.5.13-RC",
   "description": "Team collaboration software using markdown",
   "description": "Team collaboration software using markdown",
   "tags": [
   "tags": [
     "wiki",
     "wiki",
@@ -62,7 +62,6 @@
   "dependencies": {
   "dependencies": {
     "//": [
     "//": [
       "check-node-version: see https://github.com/parshap/check-node-version/issues/35",
       "check-node-version: see https://github.com/parshap/check-node-version/issues/35",
-      "entities: markdown-it@9.0.1 depends on entities@~1.1.1",
       "mongoose: somehow GlobalNotificationSetting CRUD does not work with mongoose v5.6.0",
       "mongoose: somehow GlobalNotificationSetting CRUD does not work with mongoose v5.6.0",
       "openid-client: Node.js 12 or higher is required for openid-client@3 and above."
       "openid-client: Node.js 12 or higher is required for openid-client@3 and above."
     ],
     ],
@@ -83,7 +82,7 @@
     "date-fns": "^2.0.0",
     "date-fns": "^2.0.0",
     "diff": "^4.0.1",
     "diff": "^4.0.1",
     "elasticsearch": "^16.0.0",
     "elasticsearch": "^16.0.0",
-    "entities": "=1.1.2",
+    "entities": "^2.0.0",
     "env-cmd": "^10.0.1",
     "env-cmd": "^10.0.1",
     "esa-nodejs": "^0.0.7",
     "esa-nodejs": "^0.0.7",
     "escape-string-regexp": "^2.0.0",
     "escape-string-regexp": "^2.0.0",
@@ -179,7 +178,7 @@
     "jquery.cookie": "~1.4.1",
     "jquery.cookie": "~1.4.1",
     "load-css-file": "^1.0.0",
     "load-css-file": "^1.0.0",
     "lodash-webpack-plugin": "^0.11.5",
     "lodash-webpack-plugin": "^0.11.5",
-    "markdown-it": "^9.0.1",
+    "markdown-it": "^10.0.0",
     "markdown-it-blockdiag": "^1.0.2",
     "markdown-it-blockdiag": "^1.0.2",
     "markdown-it-emoji": "^1.4.0",
     "markdown-it-emoji": "^1.4.0",
     "markdown-it-footnote": "^3.0.1",
     "markdown-it-footnote": "^3.0.1",

+ 78 - 19
src/client/js/components/PageComment/Comment.jsx

@@ -15,6 +15,7 @@ import { createSubscribedElement } from '../UnstatedUtils';
 import RevisionBody from '../Page/RevisionBody';
 import RevisionBody from '../Page/RevisionBody';
 import UserPicture from '../User/UserPicture';
 import UserPicture from '../User/UserPicture';
 import Username from '../User/Username';
 import Username from '../User/Username';
+import CommentEditor from './CommentEditor';
 
 
 /**
 /**
  *
  *
@@ -32,8 +33,11 @@ class Comment extends React.Component {
     this.state = {
     this.state = {
       html: '',
       html: '',
       isOlderRepliesShown: false,
       isOlderRepliesShown: false,
+      showReEditorIds: new Set(),
     };
     };
 
 
+    this.growiRenderer = this.props.appContainer.getRenderer('comment');
+
     this.isCurrentUserIsAuthor = this.isCurrentUserEqualsToAuthor.bind(this);
     this.isCurrentUserIsAuthor = this.isCurrentUserEqualsToAuthor.bind(this);
     this.isCurrentRevision = this.isCurrentRevision.bind(this);
     this.isCurrentRevision = this.isCurrentRevision.bind(this);
     this.getRootClassName = this.getRootClassName.bind(this);
     this.getRootClassName = this.getRootClassName.bind(this);
@@ -41,6 +45,7 @@ class Comment extends React.Component {
     this.deleteBtnClickedHandler = this.deleteBtnClickedHandler.bind(this);
     this.deleteBtnClickedHandler = this.deleteBtnClickedHandler.bind(this);
     this.renderText = this.renderText.bind(this);
     this.renderText = this.renderText.bind(this);
     this.renderHtml = this.renderHtml.bind(this);
     this.renderHtml = this.renderHtml.bind(this);
+    this.commentButtonClickedHandler = this.commentButtonClickedHandler.bind(this);
   }
   }
 
 
   componentWillMount() {
   componentWillMount() {
@@ -56,6 +61,10 @@ class Comment extends React.Component {
     this.renderHtml(markdown);
     this.renderHtml(markdown);
   }
   }
 
 
+  checkPermissionToControlComment() {
+    return this.props.appContainer.isAdmin || this.isCurrentUserEqualsToAuthor();
+  }
+
   isCurrentUserEqualsToAuthor() {
   isCurrentUserEqualsToAuthor() {
     return this.props.comment.creator.username === this.props.appContainer.me;
     return this.props.comment.creator.username === this.props.appContainer.me;
   }
   }
@@ -90,6 +99,20 @@ class Comment extends React.Component {
       this.isCurrentRevision() ? 'label-primary' : 'label-default'}`;
       this.isCurrentRevision() ? 'label-primary' : 'label-default'}`;
   }
   }
 
 
+  editBtnClickedHandler(commentId) {
+    const ids = this.state.showReEditorIds.add(commentId);
+    this.setState({ showReEditorIds: ids });
+  }
+
+  commentButtonClickedHandler(commentId) {
+    this.setState((prevState) => {
+      prevState.showReEditorIds.delete(commentId);
+      return {
+        showReEditorIds: prevState.showReEditorIds,
+      };
+    });
+  }
+
   deleteBtnClickedHandler() {
   deleteBtnClickedHandler() {
     this.props.deleteBtnClicked(this.props.comment);
     this.props.deleteBtnClicked(this.props.comment);
   }
   }
@@ -214,11 +237,29 @@ class Comment extends React.Component {
     );
     );
   }
   }
 
 
+  renderCommentControl(comment) {
+    return (
+      <div className="page-comment-control">
+        <button type="button" className="btn btn-link p-2" onClick={() => { this.editBtnClickedHandler(comment._id) }}>
+          <i className="ti-pencil"></i>
+        </button>
+        <button type="button" className="btn btn-link p-2 mr-2" onClick={this.deleteBtnClickedHandler}>
+          <i className="ti-close"></i>
+        </button>
+      </div>
+    );
+  }
+
   render() {
   render() {
     const comment = this.props.comment;
     const comment = this.props.comment;
+    const commentId = comment._id;
     const creator = comment.creator;
     const creator = comment.creator;
     const isMarkdown = comment.isMarkdown;
     const isMarkdown = comment.isMarkdown;
     const createdAt = new Date(comment.createdAt);
     const createdAt = new Date(comment.createdAt);
+    const updatedAt = new Date(comment.updatedAt);
+    const isEdited = createdAt < updatedAt;
+
+    const showReEditor = this.state.showReEditorIds.has(commentId);
 
 
     const rootClassName = this.getRootClassName(comment);
     const rootClassName = this.getRootClassName(comment);
     const commentDate = formatDistanceStrict(createdAt, new Date());
     const commentDate = formatDistanceStrict(createdAt, new Date());
@@ -232,31 +273,49 @@ class Comment extends React.Component {
         {format(createdAt, 'yyyy/MM/dd HH:mm')}
         {format(createdAt, 'yyyy/MM/dd HH:mm')}
       </Tooltip>
       </Tooltip>
     );
     );
+    const editedDateTooltip = isEdited
+      ? (
+        <Tooltip id={`editedDateTooltip-${comment._id}`}>
+          {format(updatedAt, 'yyyy/MM/dd HH:mm')}
+        </Tooltip>
+      )
+      : null;
 
 
     return (
     return (
       <React.Fragment>
       <React.Fragment>
 
 
-        <div className={rootClassName}>
-          <UserPicture user={creator} />
-          <div className="page-comment-main">
-            <div className="page-comment-creator">
-              <Username user={creator} />
-            </div>
-            <div className="page-comment-body">{commentBody}</div>
-            <div className="page-comment-meta">
-              <OverlayTrigger overlay={commentDateTooltip} placement="bottom">
-                <span>{commentDate}</span>
-              </OverlayTrigger>
-              <span className="ml-2"><a className={revisionLavelClassName} href={revHref}>{revFirst8Letters}</a></span>
-            </div>
-            <div className="page-comment-control">
-              <button type="button" className="btn btn-link" onClick={this.deleteBtnClickedHandler}>
-                <i className="ti-close"></i>
-              </button>
+        {showReEditor ? (
+          <CommentEditor
+            growiRenderer={this.growiRenderer}
+            currentCommentId={commentId}
+            commentBody={comment.comment}
+            replyTo={undefined}
+            commentButtonClickedHandler={this.commentButtonClickedHandler}
+          />
+        ) : (
+          <div className={rootClassName}>
+            <UserPicture user={creator} />
+            <div className="page-comment-main">
+              <div className="page-comment-creator">
+                <Username user={creator} />
+              </div>
+              <div className="page-comment-body">{commentBody}</div>
+              <div className="page-comment-meta">
+                <OverlayTrigger overlay={commentDateTooltip} placement="bottom">
+                  <span>{commentDate}</span>
+                </OverlayTrigger>
+                { isEdited && (
+                  <OverlayTrigger overlay={editedDateTooltip} placement="bottom">
+                    <span>&nbsp;(edited)</span>
+                  </OverlayTrigger>
+                ) }
+                <span className="ml-2"><a className={revisionLavelClassName} href={revHref}>{revFirst8Letters}</a></span>
+              </div>
+              { this.checkPermissionToControlComment() && this.renderCommentControl(comment) }
             </div>
             </div>
           </div>
           </div>
-        </div>
-
+        )
+      }
         {this.renderReplies()}
         {this.renderReplies()}
 
 
       </React.Fragment>
       </React.Fragment>

+ 42 - 28
src/client/js/components/PageComment/CommentEditor.jsx

@@ -36,7 +36,7 @@ class CommentEditor extends React.Component {
     const isUploadableFile = config.upload.file;
     const isUploadableFile = config.upload.file;
 
 
     this.state = {
     this.state = {
-      comment: '',
+      comment: this.props.commentBody || '',
       isMarkdown: true,
       isMarkdown: true,
       html: '',
       html: '',
       key: 1,
       key: 1,
@@ -84,42 +84,54 @@ class CommentEditor extends React.Component {
   }
   }
 
 
   toggleEditor() {
   toggleEditor() {
-    this.props.commentButtonClickedHandler(this.props.replyTo);
+    const targetId = this.props.replyTo || this.props.currentCommentId;
+    this.props.commentButtonClickedHandler(targetId);
+  }
+
+  initializeEditor() {
+    this.setState({
+      comment: '',
+      isMarkdown: true,
+      html: '',
+      key: 1,
+      errorMessage: undefined,
+    });
+    // reset value
+    this.editor.setValue('');
+    this.toggleEditor();
   }
   }
 
 
   /**
   /**
    * Post comment with CommentContainer and update state
    * Post comment with CommentContainer and update state
    */
    */
-  postHandler(event) {
+  async postHandler(event) {
     if (event != null) {
     if (event != null) {
       event.preventDefault();
       event.preventDefault();
     }
     }
 
 
-    const { commentContainer } = this.props;
-
-    this.props.commentContainer.postComment(
-      this.state.comment,
-      this.state.isMarkdown,
-      this.props.replyTo,
-      commentContainer.state.isSlackEnabled,
-      commentContainer.state.slackChannels,
-    )
-      .then((res) => {
-        this.setState({
-          comment: '',
-          isMarkdown: true,
-          html: '',
-          key: 1,
-          errorMessage: undefined,
-        });
-        // reset value
-        this.editor.setValue('');
-        this.toggleEditor();
-      })
-      .catch((err) => {
-        const errorMessage = err.message || 'An unknown error occured when posting comment';
-        this.setState({ errorMessage });
-      });
+    try {
+      if (this.props.currentCommentId != null) {
+        await this.props.commentContainer.putComment(
+          this.state.comment,
+          this.state.isMarkdown,
+          this.props.currentCommentId,
+        );
+      }
+      else {
+        await this.props.commentContainer.postComment(
+          this.state.comment,
+          this.state.isMarkdown,
+          this.props.replyTo,
+          this.props.commentContainer.state.isSlackEnabled,
+          this.props.commentContainer.state.slackChannels,
+        );
+      }
+      this.initializeEditor();
+    }
+    catch (err) {
+      const errorMessage = err.message || 'An unknown error occured when posting comment';
+      this.setState({ errorMessage });
+    }
   }
   }
 
 
   uploadHandler(file) {
   uploadHandler(file) {
@@ -321,6 +333,8 @@ CommentEditor.propTypes = {
 
 
   growiRenderer: PropTypes.instanceOf(GrowiRenderer).isRequired,
   growiRenderer: PropTypes.instanceOf(GrowiRenderer).isRequired,
   replyTo: PropTypes.string,
   replyTo: PropTypes.string,
+  currentCommentId: PropTypes.string,
+  commentBody: PropTypes.string,
   commentButtonClickedHandler: PropTypes.func.isRequired,
   commentButtonClickedHandler: PropTypes.func.isRequired,
 };
 };
 
 

+ 1 - 0
src/client/js/components/PageComments.jsx

@@ -138,6 +138,7 @@ class PageComments extends React.Component {
       <div key={commentId} className={`mb-5 ${rootClassNames}`}>
       <div key={commentId} className={`mb-5 ${rootClassNames}`}>
         <Comment
         <Comment
           comment={comment}
           comment={comment}
+          editBtnClicked={this.confirmToEditComment}
           deleteBtnClicked={this.confirmToDeleteComment}
           deleteBtnClicked={this.confirmToDeleteComment}
           growiRenderer={this.growiRenderer}
           growiRenderer={this.growiRenderer}
           replyList={replies}
           replyList={replies}

+ 23 - 0
src/client/js/services/CommentContainer.js

@@ -100,6 +100,29 @@ export default class CommentContainer extends Container {
       });
       });
   }
   }
 
 
+  /**
+   * Load data of comments and rerender <PageComments />
+   */
+  putComment(comment, isMarkdown, commentId) {
+    const { pageId, revisionId } = this.getPageContainer().state;
+
+    return this.appContainer.apiPost('/comments.update', {
+      commentForm: {
+        comment,
+        page_id: pageId,
+        revision_id: revisionId,
+        is_markdown: isMarkdown,
+        comment_id: commentId,
+        author: this.appContainer.me,
+      },
+    })
+      .then((res) => {
+        if (res.ok) {
+          return this.retrieveComments();
+        }
+      });
+  }
+
   deleteComment(comment) {
   deleteComment(comment) {
     return this.appContainer.apiPost('/comments.remove', { comment_id: comment._id })
     return this.appContainer.apiPost('/comments.remove', { comment_id: comment._id })
       .then((res) => {
       .then((res) => {

+ 2 - 2
src/client/styles/bootstrap4/_mixins.scss

@@ -5,7 +5,7 @@
 // Utilities
 // Utilities
 @import 'mixins/breakpoints';
 @import 'mixins/breakpoints';
 // @import "mixins/hover";
 // @import "mixins/hover";
-// @import "mixins/image";
+@import 'mixins/image';
 // @import "mixins/badge";
 // @import "mixins/badge";
 // @import "mixins/resize";
 // @import "mixins/resize";
 // @import "mixins/screen-reader";
 // @import "mixins/screen-reader";
@@ -29,7 +29,7 @@
 
 
 // // Skins
 // // Skins
 // @import "mixins/background-variant";
 // @import "mixins/background-variant";
-// @import "mixins/border-radius";
+@import 'mixins/border-radius';
 // @import "mixins/box-shadow";
 // @import "mixins/box-shadow";
 // @import "mixins/gradients";
 // @import "mixins/gradients";
 // @import "mixins/transition";
 // @import "mixins/transition";

+ 1 - 1
src/client/styles/bootstrap4/_utilities.scss

@@ -9,7 +9,7 @@
 @import 'utilities/position';
 @import 'utilities/position';
 // @import "utilities/screenreaders";
 // @import "utilities/screenreaders";
 // @import "utilities/shadows";
 // @import "utilities/shadows";
-// @import "utilities/sizing";
+@import 'utilities/sizing';
 @import 'utilities/spacing';
 @import 'utilities/spacing';
 @import 'utilities/text';
 @import 'utilities/text';
 // @import "utilities/visibility";
 // @import "utilities/visibility";

+ 49 - 49
src/client/styles/bootstrap4/_variables.scss

@@ -7,17 +7,17 @@
 // // Color system
 // // Color system
 // //
 // //
 
 
-// $white:    #fff !default;
-// $gray-100: #f8f9fa !default;
-// $gray-200: #e9ecef !default;
-// $gray-300: #dee2e6 !default;
-// $gray-400: #ced4da !default;
-// $gray-500: #adb5bd !default;
-// $gray-600: #6c757d !default;
-// $gray-700: #495057 !default;
-// $gray-800: #343a40 !default;
-// $gray-900: #212529 !default;
-// $black:    #000 !default;
+$white:    #fff !default;
+$gray-100: #f8f9fa !default;
+$gray-200: #e9ecef !default;
+$gray-300: #dee2e6 !default;
+$gray-400: #ced4da !default;
+$gray-500: #adb5bd !default;
+$gray-600: #6c757d !default;
+$gray-700: #495057 !default;
+$gray-800: #343a40 !default;
+$gray-900: #212529 !default;
+$black:    #000 !default;
 
 
 // $grays: () !default;
 // $grays: () !default;
 // // stylelint-disable-next-line scss/dollar-variable-default
 // // stylelint-disable-next-line scss/dollar-variable-default
@@ -103,18 +103,18 @@
 // $yiq-text-dark:             $gray-900 !default;
 // $yiq-text-dark:             $gray-900 !default;
 // $yiq-text-light:            $white !default;
 // $yiq-text-light:            $white !default;
 
 
-// // Options
-// //
-// // Quickly modify global styling by enabling or disabling optional features.
+// Options
+//
+// Quickly modify global styling by enabling or disabling optional features.
 
 
-// $enable-caret:              true !default;
-// $enable-rounded:            true !default;
-// $enable-shadows:            false !default;
-// $enable-gradients:          false !default;
-// $enable-transitions:        true !default;
-// $enable-hover-media-query:  false !default; // Deprecated, no longer affects any compiled CSS
-// $enable-grid-classes:       true !default;
-// $enable-print-styles:       true !default;
+$enable-caret:              true !default;
+$enable-rounded:            true !default;
+$enable-shadows:            false !default;
+$enable-gradients:          false !default;
+$enable-transitions:        true !default;
+$enable-hover-media-query:  false !default; // Deprecated, no longer affects any compiled CSS
+$enable-grid-classes:       true !default;
+$enable-print-styles:       true !default;
 
 
 // Spacing
 // Spacing
 //
 //
@@ -145,19 +145,19 @@ $spacers: map-merge(
   $spacers
   $spacers
 );
 );
 
 
-// // This variable affects the `.h-*` and `.w-*` classes.
-// $sizes: () !default;
-// // stylelint-disable-next-line scss/dollar-variable-default
-// $sizes: map-merge(
-//   (
-//     25: 25%,
-//     50: 50%,
-//     75: 75%,
-//     100: 100%,
-//     auto: auto
-//   ),
-//   $sizes
-// );
+// This variable affects the `.h-*` and `.w-*` classes.
+$sizes: () !default;
+// stylelint-disable-next-line scss/dollar-variable-default
+$sizes: map-merge(
+  (
+    25: 25%,
+    50: 50%,
+    75: 75%,
+    100: 100%,
+    auto: auto
+  ),
+  $sizes
+);
 
 
 // // Body
 // // Body
 // //
 // //
@@ -224,12 +224,12 @@ $grid-breakpoints: (
 // $line-height-lg:              1.5 !default;
 // $line-height-lg:              1.5 !default;
 // $line-height-sm:              1.5 !default;
 // $line-height-sm:              1.5 !default;
 
 
-// $border-width:                1px !default;
-// $border-color:                $gray-300 !default;
+$border-width:                1px !default;
+$border-color:                $gray-300 !default;
 
 
-// $border-radius:               .25rem !default;
-// $border-radius-lg:            .3rem !default;
-// $border-radius-sm:            .2rem !default;
+$border-radius:               .25rem !default;
+$border-radius-lg:            .3rem !default;
+$border-radius-sm:            .2rem !default;
 
 
 // $box-shadow-sm:               0 .125rem .25rem rgba($black, .075) !default;
 // $box-shadow-sm:               0 .125rem .25rem rgba($black, .075) !default;
 // $box-shadow:                  0 .5rem 1rem rgba($black, .15) !default;
 // $box-shadow:                  0 .5rem 1rem rgba($black, .15) !default;
@@ -859,17 +859,17 @@ $zindex-fixed:                      1030 !default;
 
 
 // // Image thumbnails
 // // Image thumbnails
 
 
-// $thumbnail-padding:                 .25rem !default;
-// $thumbnail-bg:                      $body-bg !default;
-// $thumbnail-border-width:            $border-width !default;
-// $thumbnail-border-color:            $gray-300 !default;
-// $thumbnail-border-radius:           $border-radius !default;
-// $thumbnail-box-shadow:              0 1px 2px rgba($black, .075) !default;
+$thumbnail-padding:                 .25rem !default;
+$thumbnail-bg:                      $body-bg !default;
+$thumbnail-border-width:            $border-width !default;
+$thumbnail-border-color:            $gray-300 !default;
+$thumbnail-border-radius:           $border-radius !default;
+$thumbnail-box-shadow:              0 1px 2px rgba($black, .075) !default;
 
 
-// // Figures
+// Figures
 
 
-// $figure-caption-font-size:          90% !default;
-// $figure-caption-color:              $gray-600 !default;
+$figure-caption-font-size:          90% !default;
+$figure-caption-color:              $gray-600 !default;
 
 
 // // Breadcrumbs
 // // Breadcrumbs
 
 

+ 1 - 1
src/client/styles/bootstrap4/bootstrap.scss

@@ -11,7 +11,7 @@
 // @import "root";
 // @import "root";
 // @import "reboot";
 // @import "reboot";
 // @import "type";
 // @import "type";
-// @import "images";
+@import 'images';
 // @import "code";
 // @import "code";
 // @import "grid";
 // @import "grid";
 // @import "tables";
 // @import "tables";

+ 1 - 1
src/client/styles/scss/_layout.scss

@@ -64,7 +64,7 @@
     position: fixed;
     position: fixed;
     right: 25%;
     right: 25%;
     bottom: 25px;
     bottom: 25px;
-    z-index: 1039;
+    z-index: 1;
     display: block;
     display: block;
     padding: 5px 8px;
     padding: 5px 8px;
     font-size: 0.8em;
     font-size: 0.8em;

+ 1 - 1
src/client/styles/scss/_search.scss

@@ -210,7 +210,7 @@
 .search-page-input {
 .search-page-input {
   position: sticky;
   position: sticky;
   top: 0;
   top: 0;
-  z-index: 99;
+  z-index: 1;
 
 
   // for sticky layout
   // for sticky layout
   padding-top: 15px;
   padding-top: 15px;

+ 6 - 21
src/server/models/attachment.js

@@ -1,7 +1,6 @@
 // disable no-return-await for model functions
 // disable no-return-await for model functions
 /* eslint-disable no-return-await */
 /* eslint-disable no-return-await */
 
 
-const debug = require('debug')('growi:models:attachment');
 // eslint-disable-next-line no-unused-vars
 // eslint-disable-next-line no-unused-vars
 const logger = require('@alias/logger')('growi:models:attachment');
 const logger = require('@alias/logger')('growi:models:attachment');
 const path = require('path');
 const path = require('path');
@@ -68,28 +67,14 @@ module.exports = function(crowi) {
     return attachment;
     return attachment;
   };
   };
 
 
-  attachmentSchema.statics.removeAttachmentsByPageId = function(pageId) {
-    const Attachment = this;
+  attachmentSchema.statics.removeAttachmentsByPageId = async function(pageId) {
+    const attachments = await this.find({ page: pageId });
 
 
-    return new Promise((resolve, reject) => {
-      Attachment.find({ page: pageId })
-        .then((attachments) => {
-          for (const attachment of attachments) {
-            Attachment.removeWithSubstanceById(attachment._id)
-              .then((res) => {
-                // do nothing
-              })
-              .catch((err) => {
-                debug('Attachment remove error', err);
-              });
-          }
-
-          resolve(attachments);
-        })
-        .catch((err) => {
-          reject(err);
-        });
+    const promises = attachments.map(async(attachment) => {
+      return this.removeWithSubstanceById(attachment._id);
     });
     });
+
+    return Promise.all(promises);
   };
   };
 
 
   attachmentSchema.statics.removeWithSubstanceById = async function(id) {
   attachmentSchema.statics.removeWithSubstanceById = async function(id) {

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

@@ -12,9 +12,10 @@ module.exports = function(crowi) {
     revision: { type: ObjectId, ref: 'Revision', index: true },
     revision: { type: ObjectId, ref: 'Revision', index: true },
     comment: { type: String, required: true },
     comment: { type: String, required: true },
     commentPosition: { type: Number, default: -1 },
     commentPosition: { type: Number, default: -1 },
-    createdAt: { type: Date, default: Date.now },
     isMarkdown: { type: Boolean, default: false },
     isMarkdown: { type: Boolean, default: false },
     replyTo: { type: ObjectId },
     replyTo: { type: ObjectId },
+  }, {
+    timestamps: true,
   });
   });
 
 
   commentSchema.statics.create = function(pageId, creatorId, revisionId, comment, position, isMarkdown, replyTo) {
   commentSchema.statics.create = function(pageId, creatorId, revisionId, comment, position, isMarkdown, replyTo) {
@@ -64,6 +65,16 @@ module.exports = function(crowi) {
     }));
     }));
   };
   };
 
 
+  commentSchema.statics.updateCommentsByPageId = function(comment, isMarkdown, commentId) {
+    const Comment = this;
+
+    return Comment.findOneAndUpdate(
+      { _id: commentId },
+      { $set: { comment, isMarkdown } },
+    );
+
+  };
+
   commentSchema.statics.removeCommentsByPageId = function(pageId) {
   commentSchema.statics.removeCommentsByPageId = function(pageId) {
     const Comment = this;
     const Comment = this;
 
 

+ 1 - 0
src/server/routes/attachment.js

@@ -332,6 +332,7 @@ module.exports = function(crowi, app) {
       await Attachment.removeWithSubstanceById(id);
       await Attachment.removeWithSubstanceById(id);
     }
     }
     catch (err) {
     catch (err) {
+      logger.error(err);
       return res.status(500).json(ApiResponse.error('Error while deleting file'));
       return res.status(500).json(ApiResponse.error('Error while deleting file'));
     }
     }
 
 

+ 47 - 0
src/server/routes/comment.js

@@ -159,6 +159,53 @@ module.exports = function(crowi, app) {
     }
     }
   };
   };
 
 
+  /**
+   * @api {post} /comments.update Update comment dody
+   * @apiName UpdateComment
+   * @apiGroup Comment
+   */
+  api.update = async function(req, res) {
+    const { commentForm } = req.body;
+
+    const pageId = commentForm.page_id;
+    const revisionId = commentForm.revision_id;
+    const comment = commentForm.comment;
+    const isMarkdown = commentForm.is_markdown;
+    const commentId = commentForm.comment_id;
+    const author = commentForm.author;
+
+    if (comment === '') {
+      return res.json(ApiResponse.error('Comment text is required'));
+    }
+
+    if (commentId == null) {
+      return res.json(ApiResponse.error('\'comment_id\' is undefined'));
+    }
+
+    if (author !== req.user.username) {
+      return res.json(ApiResponse.error('Only the author can edit'));
+    }
+
+    // check whether accessible
+    const isAccessible = await Page.isAccessiblePageByViewer(pageId, req.user._id, revisionId, comment, isMarkdown, req.user);
+    if (!isAccessible) {
+      return res.json(ApiResponse.error('Current user is not accessible to this page.'));
+    }
+
+    let updatedComment;
+    try {
+      updatedComment = await Comment.updateCommentsByPageId(comment, isMarkdown, commentId);
+    }
+    catch (err) {
+      logger.error(err);
+      return res.json(ApiResponse.error(err));
+    }
+
+    res.json(ApiResponse.success({ comment: updatedComment }));
+
+    // process notification if needed
+  };
+
   /**
   /**
    * @api {post} /comments.remove Remove specified comment
    * @api {post} /comments.remove Remove specified comment
    * @apiName RemoveComment
    * @apiName RemoveComment

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

@@ -164,8 +164,7 @@ module.exports = function(crowi, app) {
   app.post('/_api/admin/import/testQiitaAPI' , loginRequired() , adminRequired , csrf , form.admin.importerQiita , admin.api.testQiitaAPI);
   app.post('/_api/admin/import/testQiitaAPI' , loginRequired() , adminRequired , csrf , form.admin.importerQiita , admin.api.testQiitaAPI);
 
 
   // export management for admin
   // export management for admin
-  // FIXME: comment out for v3.5.12
-  // app.get('/admin/export' , loginRequired() , adminRequired ,admin.export.index);
+  app.get('/admin/export' , loginRequired() , adminRequired ,admin.export.index);
 
 
   app.get('/me'                       , loginRequired() , me.index);
   app.get('/me'                       , loginRequired() , me.index);
   app.get('/me/password'              , loginRequired() , me.password);
   app.get('/me/password'              , loginRequired() , me.password);
@@ -216,6 +215,7 @@ module.exports = function(crowi, app) {
   app.post('/_api/tags.update'        , accessTokenParser, loginRequired(false), tag.api.update);
   app.post('/_api/tags.update'        , accessTokenParser, loginRequired(false), tag.api.update);
   app.get('/_api/comments.get'        , accessTokenParser , loginRequired(false) , comment.api.get);
   app.get('/_api/comments.get'        , accessTokenParser , loginRequired(false) , comment.api.get);
   app.post('/_api/comments.add'       , comment.api.validators.add(), accessTokenParser , loginRequired() , csrf, comment.api.add);
   app.post('/_api/comments.add'       , comment.api.validators.add(), accessTokenParser , loginRequired() , csrf, comment.api.add);
+  app.post('/_api/comments.update'       , comment.api.validators.add(), accessTokenParser , loginRequired() , csrf, comment.api.update);
   app.post('/_api/comments.remove'    , accessTokenParser , loginRequired() , csrf, comment.api.remove);
   app.post('/_api/comments.remove'    , accessTokenParser , loginRequired() , csrf, comment.api.remove);
   app.get('/_api/bookmarks.get'       , accessTokenParser , loginRequired(false) , bookmark.api.get);
   app.get('/_api/bookmarks.get'       , accessTokenParser , loginRequired(false) , bookmark.api.get);
   app.post('/_api/bookmarks.add'      , accessTokenParser , loginRequired() , csrf, bookmark.api.add);
   app.post('/_api/bookmarks.add'      , accessTokenParser , loginRequired() , csrf, bookmark.api.add);

+ 2 - 0
src/server/service/file-uploader/aws.js

@@ -63,6 +63,8 @@ module.exports = function(crowi) {
       Key: filePath,
       Key: filePath,
     };
     };
 
 
+    // TODO: ensure not to throw error even when the file does not exist
+
     return s3.deleteObject(params).promise();
     return s3.deleteObject(params).promise();
   };
   };
 
 

+ 7 - 5
src/server/service/file-uploader/gridfs.js

@@ -20,6 +20,7 @@ module.exports = function(crowi) {
 
 
   // create promisified method
   // create promisified method
   AttachmentFile.promisifiedWrite = util.promisify(AttachmentFile.write).bind(AttachmentFile);
   AttachmentFile.promisifiedWrite = util.promisify(AttachmentFile.write).bind(AttachmentFile);
+  AttachmentFile.promisifiedUnlink = util.promisify(AttachmentFile.unlink).bind(AttachmentFile);
 
 
   lib.deleteFile = async function(attachment) {
   lib.deleteFile = async function(attachment) {
     let filenameValue = attachment.fileName;
     let filenameValue = attachment.fileName;
@@ -30,11 +31,12 @@ module.exports = function(crowi) {
 
 
     const attachmentFile = await AttachmentFile.findOne({ filename: filenameValue });
     const attachmentFile = await AttachmentFile.findOne({ filename: filenameValue });
 
 
-    AttachmentFile.unlink({ _id: attachmentFile._id }, (error, unlinkedFile) => {
-      if (error) {
-        throw new Error(error);
-      }
-    });
+    if (attachmentFile == null) {
+      logger.warn(`Any AttachmentFile that relate to the Attachment (${attachment._id.toString()}) does not exist in GridFS`);
+      return;
+    }
+
+    return AttachmentFile.promisifiedUnlink({ _id: attachmentFile._id });
   };
   };
 
 
   /**
   /**

+ 8 - 0
src/server/service/file-uploader/local.js

@@ -31,6 +31,14 @@ module.exports = function(crowi) {
   };
   };
 
 
   lib.deleteFileByFilePath = async function(filePath) {
   lib.deleteFileByFilePath = async function(filePath) {
+    // check file exists
+    try {
+      fs.statSync(filePath);
+    }
+    catch (err) {
+      logger.warn(`Any AttachmentFile which path is '${filePath}' does not exist in local fs`);
+    }
+
     return fs.unlinkSync(filePath);
     return fs.unlinkSync(filePath);
   };
   };
 
 

+ 1 - 2
src/server/views/admin/widget/menu.html

@@ -8,8 +8,7 @@
   <li class="{% if current == 'markdown'%}active{% endif %}"><a href="/admin/markdown"><i class="icon-fw icon-note"></i> {{ t('Markdown Settings') }}</a></li>
   <li class="{% if current == 'markdown'%}active{% endif %}"><a href="/admin/markdown"><i class="icon-fw icon-note"></i> {{ t('Markdown Settings') }}</a></li>
   <li class="{% if current == 'customize'%}active{% endif %}"><a href="/admin/customize"><i class="icon-fw icon-wrench"></i> {{ t('Customize') }}</a></li>
   <li class="{% if current == 'customize'%}active{% endif %}"><a href="/admin/customize"><i class="icon-fw icon-wrench"></i> {{ t('Customize') }}</a></li>
   <li class="{% if current == 'importer'%}active{% endif %}"><a href="/admin/importer"><i class="icon-fw icon-cloud-upload"></i> {{ t('Import Data') }}</a></li>
   <li class="{% if current == 'importer'%}active{% endif %}"><a href="/admin/importer"><i class="icon-fw icon-cloud-upload"></i> {{ t('Import Data') }}</a></li>
-  {# FIXME: comment out for v3.5.12 #}
-  {# <li class="{% if current == 'export'%}active{% endif %}"><a href="/admin/export"><i class="icon-fw icon-cloud-download"></i> {{ t('Export Data') }}</a></li> #}
+  <li class="{% if current == 'export'%}active{% endif %}"><a href="/admin/export"><i class="icon-fw icon-cloud-download"></i> {{ t('Export Data') }}</a></li>
   <li class="{% if current == 'notification'%}active{% endif %}"><a href="/admin/notification"><i class="icon-fw icon-bell"></i> {{ t('Notification Settings') }}</a></li>
   <li class="{% if current == 'notification'%}active{% endif %}"><a href="/admin/notification"><i class="icon-fw icon-bell"></i> {{ t('Notification Settings') }}</a></li>
   <li class="{% if current == 'user' || current == 'external-account' %}active{% endif %}"><a href="/admin/users"><i class="icon-fw icon-user"></i> {{ t('User_Management') }}</a></li>
   <li class="{% if current == 'user' || current == 'external-account' %}active{% endif %}"><a href="/admin/users"><i class="icon-fw icon-user"></i> {{ t('User_Management') }}</a></li>
   <li class="{% if current == 'user-group'%}active{% endif %}"><a href="/admin/user-groups"><i class="icon-fw icon-people"></i> {{ t('UserGroup Management') }}</a></li>
   <li class="{% if current == 'user-group'%}active{% endif %}"><a href="/admin/user-groups"><i class="icon-fw icon-people"></i> {{ t('UserGroup Management') }}</a></li>

+ 9 - 0
src/test/global-setup.js

@@ -0,0 +1,9 @@
+const mongoUri = process.env.MONGOLAB_URI || process.env.MONGOHQ_URL || process.env.MONGO_URI || 'mongodb://localhost/growi_test';
+
+const mongoose = require('mongoose');
+
+module.exports = async() => {
+  await mongoose.connect(mongoUri, { useNewUrlParser: true });
+  await mongoose.connection.dropDatabase();
+  await mongoose.disconnect();
+};

+ 2 - 0
src/test/global-teardown.js

@@ -0,0 +1,2 @@
+module.exports = async() => {
+};

+ 0 - 1
src/test/setup.js

@@ -8,7 +8,6 @@ jest.setTimeout(30000); // default 5000
 
 
 beforeAll(async(done) => {
 beforeAll(async(done) => {
   await mongoose.connect(mongoUri, { useNewUrlParser: true });
   await mongoose.connect(mongoUri, { useNewUrlParser: true });
-  await mongoose.connection.dropDatabase();
   done();
   done();
 });
 });
 
 

+ 10 - 10
yarn.lock

@@ -4005,15 +4005,15 @@ enhanced-resolve@4.1.0, enhanced-resolve@^4.1.0:
     memory-fs "^0.4.0"
     memory-fs "^0.4.0"
     tapable "^1.0.0"
     tapable "^1.0.0"
 
 
-entities@=1.1.2:
-  version "1.1.2"
-  resolved "https://registry.yarnpkg.com/entities/-/entities-1.1.2.tgz#bdfa735299664dfafd34529ed4f8522a275fea56"
-  integrity sha512-f2LZMYl1Fzu7YSBKg+RoROelpOaNrcGmE9AZubeDfrCEia483oW4MI4VyFd5VNHIgQ/7qm1I0wUHK1eJnn2y2w==
-
 entities@^1.1.1, entities@~1.1.1:
 entities@^1.1.1, entities@~1.1.1:
   version "1.1.1"
   version "1.1.1"
   resolved "https://registry.yarnpkg.com/entities/-/entities-1.1.1.tgz#6e5c2d0a5621b5dadaecef80b90edfb5cd7772f0"
   resolved "https://registry.yarnpkg.com/entities/-/entities-1.1.1.tgz#6e5c2d0a5621b5dadaecef80b90edfb5cd7772f0"
 
 
+entities@^2.0.0, entities@~2.0.0:
+  version "2.0.0"
+  resolved "https://registry.yarnpkg.com/entities/-/entities-2.0.0.tgz#68d6084cab1b079767540d80e56a39b423e4abf4"
+  integrity sha512-D9f7V0JSRwIxlRI2mjMqufDrRDnx8p+eEOz7aUM9SuvF8gsBzra0/6tbjl1m8eQHrZlYj6PxqE00hZ1SAIKPLw==
+
 env-cmd@^10.0.1:
 env-cmd@^10.0.1:
   version "10.0.1"
   version "10.0.1"
   resolved "https://registry.yarnpkg.com/env-cmd/-/env-cmd-10.0.1.tgz#bcaedad78a0172c62113890dd4efec36d2ba0775"
   resolved "https://registry.yarnpkg.com/env-cmd/-/env-cmd-10.0.1.tgz#bcaedad78a0172c62113890dd4efec36d2ba0775"
@@ -7528,13 +7528,13 @@ markdown-it-toc-and-anchor-with-slugid@^1.1.4:
     clone "^2.1.0"
     clone "^2.1.0"
     uslug "^1.0.4"
     uslug "^1.0.4"
 
 
-markdown-it@^9.0.1:
-  version "9.0.1"
-  resolved "https://registry.yarnpkg.com/markdown-it/-/markdown-it-9.0.1.tgz#aafe363c43718720b6575fd10625cde6e4ff2d47"
-  integrity sha512-XC9dMBHg28Xi7y5dPuLjM61upIGPJG8AiHNHYqIaXER2KNnn7eKnM5/sF0ImNnyoV224Ogn9b1Pck8VH4k0bxw==
+markdown-it@^10.0.0:
+  version "10.0.0"
+  resolved "https://registry.yarnpkg.com/markdown-it/-/markdown-it-10.0.0.tgz#abfc64f141b1722d663402044e43927f1f50a8dc"
+  integrity sha512-YWOP1j7UbDNz+TumYP1kpwnP0aEa711cJjrAQrzd0UXlbJfc5aAq0F/PZHjiioqDC1NKgvIMX+o+9Bk7yuM2dg==
   dependencies:
   dependencies:
     argparse "^1.0.7"
     argparse "^1.0.7"
-    entities "~1.1.1"
+    entities "~2.0.0"
     linkify-it "^2.0.0"
     linkify-it "^2.0.0"
     mdurl "^1.0.1"
     mdurl "^1.0.1"
     uc.micro "^1.0.5"
     uc.micro "^1.0.5"