Преглед изворни кода

Merge remote-tracking branch 'origin/master' into imprv/apply-unstated

# Conflicts:
#	src/client/js/app.js
#	src/client/js/components/PageComment/CommentContainer.jsx
#	src/client/js/components/PageComments.jsx
Yuki Takei пре 6 година
родитељ
комит
d7daa6494c

+ 1 - 0
package.json

@@ -85,6 +85,7 @@
     "express-form": "~0.12.0",
     "express-sanitizer": "^1.0.4",
     "express-session": "^1.16.1",
+    "express-validator": "^5.3.1",
     "express-webpack-assets": "^0.1.0",
     "googleapis": "^39.1.0",
     "graceful-fs": "^4.1.11",

+ 14 - 7
resource/locales/en-US/translation.json

@@ -24,6 +24,10 @@
   "Page Path": "Page Path",
   "Category": "Category",
   "User": "User",
+  "User Name": "User Name",
+  "User List": "User List",
+  "Add": "Add",
+  "Method": "Method",
 
   "Update": "Update",
   "Update Page": "Update Page",
@@ -46,6 +50,7 @@
 
   "Created": "Created",
   "Last updated": "Updated",
+  "Last Login": "Last Login",
 
   "Share": "Share",
   "Share Link": "Share Link",
@@ -107,7 +112,7 @@
   "UserGroup Management": "UserGroup Management",
   "Full Text Search Management": "Full Text Search Management",
   "Import Data": "Import Data",
-  "Basic settings": "Basic settings",
+  "Basic Settings": "Basic Settings",
   "Basic authentication": "Basic authentication",
   "Guest users access": "Guest users access",
   "Register limitation": "Register limitation",
@@ -680,12 +685,9 @@
     "give_admin_access": "Give admin access",
     "remove_admin_access": "Remove admin access",
     "external_account": "External account management",
-    "user_list": "List of users",
     "external_account_list": "External Account List",
     "back_to_user_management": "Back to User Management",
     "authentication_provider": "Authentication Provider",
-    "Date created": "Date created",
-    "Last login": "Last login",
     "Manage": "Manage",
     "Edit menu": "Edit menu",
     "password_setting": "Password Setting",
@@ -705,11 +707,12 @@
   },
 
   "user_group_management": {
-    "group_list": "List of Group",
+    "group_list": "Group List",
+    "back_to_list": "Go Back to Group List",
     "create_group": "Create New Group",
     "group_example": "e.g. : Group1",
     "created_group": "Group was created",
-    "add_user": "Add a user to the created group",
+    "add_user": "Add a User to the Created Group",
     "deny_create_group": "You can't create a new group with the current settings",
     "is_loading_data": "Loading data...",
     "choose_action": "Choose an action for private pages",
@@ -720,7 +723,11 @@
     "delete_pages": "Delete All",
     "transfer_pages": "Transfer to another group",
     "select_group": "Select a group",
-    "no_groups": "No groups to select"
+    "no_groups": "No groups to select",
+    "no_pages": "There are no pages the group has view permission",
+    "how_to_add1": "Enter a username to add",
+    "how_to_add2": "Select a user from user list",
+    "remove_from_group": "Remove this group"
   },
 
   "importer_management": {

+ 12 - 4
resource/locales/ja/translation.json

@@ -24,6 +24,10 @@
   "Page Path": "ページパス",
   "Category": "カテゴリー",
   "User": "ユーザー",
+  "User Name": "ユーザーネーム",
+  "User List": "ユーザーリスト",
+  "Add": "追加",
+  "Method": "方法",
 
   "Update": "更新",
   "Update Page": "ページを更新",
@@ -46,6 +50,7 @@
 
   "Created": "作成日",
   "Last updated": "最終更新",
+  "Last Login": "最終ログイン",
 
   "Share": "共有",
   "Share Link": "共有用リンク",
@@ -107,7 +112,7 @@
   "UserGroup Management": "グループ管理",
   "Full Text Search Management": "全文検索管理",
   "Import Data": "データインポート",
-  "Basic settings": "基本設定",
+  "Basic Settings": "基本設定",
   "Basic authentication": "Basic認証",
   "Guest users access": "ゲストユーザーのアクセス",
   "Register limitation": "登録の制限",
@@ -684,8 +689,6 @@
     "external_account_list": "外部アカウント一覧",
     "back_to_user_management": "ユーザー管理に戻る",
     "authentication_provider": "認証情報プロバイダ",
-    "Date created": "作成日",
-    "Last login": "最終ログイン",
     "Manage": "操作",
     "Edit menu": "編集メニュー",
     "password_setting": "パスワード設定",
@@ -706,6 +709,7 @@
 
   "user_group_management": {
     "group_list": "グループ一覧",
+    "back_to_list": "グループ一覧に戻る",
     "create_group": "新規グループの作成",
     "group_example": "例: Group1",
     "created_group": "グループを作成しました",
@@ -720,7 +724,11 @@
     "delete_pages": "全て削除する",
     "transfer_pages": "全て他のグループに移譲する",
     "select_group": "グループを選択してください",
-    "no_groups": "グループがありません"
+    "no_groups": "グループがありません",
+    "no_pages": "グループが閲覧権限を保有するページはありません",
+    "how_to_add1": "ユーザー名を入力して追加",
+    "how_to_add2": "ユーザーを下のリストから選択",
+    "remove_from_group": "グループから外す"
   },
 
   "importer_management": {

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

@@ -117,8 +117,8 @@ const crowiRenderer = new GrowiRenderer(crowi, null, {
 window.crowiRenderer = crowiRenderer;
 
 // create unstated container instance
-const pageContainer = new PageContainer();
-const commentContainer = new CommentContainer(crowi, pageContainer);
+const pageContainer = new PageContainer(appContainer);
+const commentContainer = new CommentContainer(appContainer);
 const editorContainer = new EditorContainer(appContainer, defaultEditorOptions, defaultPreviewOptions);
 
 // FIXME
@@ -317,24 +317,14 @@ const componentMappings = {
 };
 
 // additional definitions if data exists
-let pageComments = null;
 if (pageId) {
   componentMappings['page-comments-list'] = (
     <I18nextProvider i18n={i18n}>
-      <Provider inject={[commentContainer, editorContainer]}>
+      <Provider inject={[appContainer, commentContainer, editorContainer]}>
         <PageComments
-          ref={(elem) => {
-            if (pageComments == null) {
-              pageComments = elem;
-            }
-          }}
           revisionCreatedAt={pageRevisionCreatedAt}
-          pageId={pageId}
-          pagePath={pagePath}
           slackChannels={slackChannels}
-          crowi={crowi}
           crowiOriginRenderer={crowiRenderer}
-          revisionId={pageRevisionId}
         />
       </Provider>
     </I18nextProvider>

+ 108 - 42
src/client/js/components/PageComment/Comment.jsx

@@ -1,9 +1,13 @@
+/* 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 dateFnsFormat from 'date-fns/format';
 
+import AppContainer from '../../services/AppContainer';
+import PageContainer from '../../services/PageContainer';
+
 import RevisionBody from '../Page/RevisionBody';
 
 import ReactUtils from '../ReactUtils';
@@ -18,13 +22,14 @@ import Username from '../User/Username';
  * @class Comment
  * @extends {React.Component}
  */
-export default class Comment extends React.Component {
+class Comment extends React.Component {
 
   constructor(props) {
     super(props);
 
     this.state = {
       html: '',
+      isLayoutTypeGrowi: false,
     };
 
     this.isCurrentUserIsAuthor = this.isCurrentUserEqualsToAuthor.bind(this);
@@ -33,11 +38,16 @@ 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() {
     this.renderHtml(this.props.comment.comment);
+    this.init();
+  }
+
+  init() {
+    const layoutType = this.props.appContainer.getConfig().layoutType;
+    this.setState({ isLayoutTypeGrowi: layoutType === 'crowi-plus' || layoutType === 'growi' });
   }
 
   componentWillReceiveProps(nextProps) {
@@ -50,11 +60,11 @@ export default class Comment extends React.Component {
   }
 
   isCurrentUserEqualsToAuthor() {
-    return this.props.comment.creator.username === this.props.crowi.me;
+    return this.props.comment.creator.username === this.props.appContainer.me;
   }
 
   isCurrentRevision() {
-    return this.props.comment.revision === this.props.revisionId;
+    return this.props.comment.revision === this.props.pageContainer.state.revisionId;
   }
 
   getRootClassName() {
@@ -71,12 +81,8 @@ 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 config = this.props.appContainer.getConfig();
     const isMathJaxEnabled = !!config.env.MATHJAX;
     return (
       <RevisionBody
@@ -94,7 +100,7 @@ export default class Comment extends React.Component {
     };
 
     const crowiRenderer = this.props.crowiRenderer;
-    const interceptorManager = this.props.crowi.interceptorManager;
+    const interceptorManager = this.props.appContainer.interceptorManager;
     interceptorManager.process('preRenderComment', context)
       .then(() => { return interceptorManager.process('prePreProcess', context) })
       .then(() => {
@@ -119,6 +125,27 @@ export default class Comment extends React.Component {
 
   }
 
+  renderReplies() {
+    const isLayoutTypeGrowi = this.state.isLayoutTypeGrowi;
+    let replyList = this.props.replyList;
+    if (!isLayoutTypeGrowi) {
+      replyList = replyList.slice().reverse();
+    }
+    return replyList.map((reply) => {
+      return (
+        <div key={reply._id} className="col-xs-offset-1 col-xs-11 col-sm-offset-1 col-sm-11 col-md-offset-1 col-md-11 col-lg-offset-1 col-lg-11">
+          <CommentWrapper
+            comment={reply}
+            deleteBtnClicked={this.props.deleteBtnClicked}
+            crowiRenderer={this.props.crowiRenderer}
+            replyList={[]}
+            revisionCreatedAt={this.props.revisionCreatedAt}
+          />
+        </div>
+      );
+    });
+  }
+
   render() {
     const comment = this.props.comment;
     const creator = comment.creator;
@@ -131,36 +158,45 @@ export default class Comment extends React.Component {
     const revFirst8Letters = comment.revision.substr(-8);
     const revisionLavelClassName = this.getRevisionLabelClassName();
 
+    const revisionId = this.props.pageContainer.state.revisionId;
+    const revisionCreatedAt = this.props.revisionCreatedAt;
+    let isNewer;
+    if (comment.revision === revisionId) {
+      isNewer = 'page-comments-list-current';
+    }
+    else if (Date.parse(comment.createdAt) / 1000 > revisionCreatedAt) {
+      isNewer = 'page-comments-list-newer';
+    }
+    else {
+      isNewer = 'page-comments-list-older';
+    }
+
+
     return (
-      <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-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>
+      <div>
+        <div className={isNewer}>
+          <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">
+                {commentDate}&nbsp;
+                <a className={revisionLavelClassName} href={revHref}>{revFirst8Letters}</a>
+              </div>
+              <div className="page-comment-control">
+                <button type="button" className="btn btn-link" onClick={this.deleteBtnClickedHandler}>
+                  <i className="ti-close"></i>
+                </button>
+              </div>
+            </div>
           </div>
-          <div className="page-comment-control">
-            <button type="button" className="btn btn-link" onClick={this.deleteBtnClickedHandler}>
-              <i className="ti-close"></i>
-            </button>
+        </div>
+        <div className="container-fluid">
+          <div className="row">
+            {this.renderReplies()}
           </div>
         </div>
       </div>
@@ -169,11 +205,41 @@ export default class Comment extends React.Component {
 
 }
 
+/**
+ * Wrapper component for using unstated
+ */
+class CommentWrapper extends React.Component {
+
+  render() {
+    return (
+      <Subscribe to={[AppContainer, PageContainer]}>
+        { (appContainer, pageContainer) => (
+          // eslint-disable-next-line arrow-body-style
+          <Comment appContainer={appContainer} pageContainer={pageContainer} {...this.props} />
+        )}
+      </Subscribe>
+    );
+  }
+
+}
+
 Comment.propTypes = {
+  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
+  pageContainer: PropTypes.instanceOf(PageContainer).isRequired,
+
   comment: PropTypes.object.isRequired,
   crowiRenderer: PropTypes.object.isRequired,
   deleteBtnClicked: PropTypes.func.isRequired,
-  onReplyButtonClicked: PropTypes.func.isRequired,
-  crowi: PropTypes.object.isRequired,
-  revisionId: PropTypes.string,
+  replyList: PropTypes.array,
+  revisionCreatedAt: PropTypes.number,
 };
+
+CommentWrapper.propTypes = {
+  comment: PropTypes.object.isRequired,
+  crowiRenderer: PropTypes.object.isRequired,
+  deleteBtnClicked: PropTypes.func.isRequired,
+  replyList: PropTypes.array,
+  revisionCreatedAt: PropTypes.number,
+};
+
+export default CommentWrapper;

+ 30 - 39
src/client/js/components/PageComment/CommentContainer.jsx

@@ -8,12 +8,11 @@ import { Container } from 'unstated';
  */
 export default class CommentContainer extends Container {
 
-  constructor(crowi, pageContainer) {
+  constructor(appContainer) {
     super();
 
-    this.crowi = crowi;
-    this.pageId = pageContainer.state.pageId;
-    this.revisionId = pageContainer.state.revisionId;
+    this.appContainer = appContainer;
+    this.appContainer.registerContainer(this);
 
     this.state = {
       comments: [],
@@ -22,8 +21,14 @@ export default class CommentContainer extends Container {
     this.retrieveComments = this.retrieveComments.bind(this);
   }
 
+  getPageContainer() {
+    return this.appContainer.getContainer('PageContainer');
+  }
+
   init() {
-    if (!this.props.pageId) {
+    const { pageId } = this.getPageContainer().state;
+
+    if (!pageId) {
       return;
     }
   }
@@ -44,8 +49,10 @@ export default class CommentContainer extends Container {
    * Load data of comments and store them in state
    */
   retrieveComments() {
+    const { pageId } = this.getPageContainer().state;
+
     // get data (desc order array)
-    return this.crowi.apiGet('/comments.get', { page_id: this.pageId })
+    return this.appContainer.apiGet('/comments.get', { page_id: pageId })
       .then((res) => {
         if (res.ok) {
           this.setState({ comments: res.comments });
@@ -57,12 +64,14 @@ export default class CommentContainer extends Container {
    * Load data of comments and rerender <PageComments />
    */
   postComment(comment, isMarkdown, replyTo, isSlackEnabled, slackChannels) {
-    return this.crowi.apiPost('/comments.add', {
+    const { pageId, revisionId } = this.getPageContainer().state;
+
+    return this.appContainer.apiPost('/comments.add', {
       commentForm: {
         comment,
-        _csrf: this.crowi.csrfToken,
-        page_id: this.pageId,
-        revision_id: this.revisionId,
+        _csrf: this.appContainer.csrfToken,
+        page_id: pageId,
+        revision_id: revisionId,
         is_markdown: isMarkdown,
         replyTo,
       },
@@ -79,7 +88,7 @@ export default class CommentContainer extends Container {
   }
 
   deleteComment(comment) {
-    return this.crowi.apiPost('/comments.remove', { comment_id: comment._id })
+    return this.appContainer.apiPost('/comments.remove', { comment_id: comment._id })
       .then((res) => {
         if (res.ok) {
           this.findAndSplice(comment);
@@ -88,34 +97,16 @@ export default class CommentContainer extends Container {
   }
 
   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();
-    //   });
+    const { pageId, pagePath } = this.getPageContainer().state;
+
+    const endpoint = '/attachments.add';
+    const formData = new FormData();
+    formData.append('_csrf', this.appContainer.csrfToken);
+    formData.append('file', file);
+    formData.append('path', pagePath);
+    formData.append('page_id', pageId);
+
+    return this.appContainer.apiPost(endpoint, formData);
   }
 
 }

+ 35 - 40
src/client/js/components/PageComment/CommentEditor.jsx

@@ -7,9 +7,11 @@ 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 * as toastr from 'toastr';
 import UserPicture from '../User/UserPicture';
 import ReactUtils from '../ReactUtils';
 
+import AppContainer from '../../services/AppContainer';
 import GrowiRenderer from '../../util/GrowiRenderer';
 
 import Editor from '../PageEditor/Editor';
@@ -29,7 +31,7 @@ class CommentEditor extends React.Component {
   constructor(props) {
     super(props);
 
-    const config = this.props.crowi.getConfig();
+    const config = this.props.appContainer.getConfig();
     const isUploadable = config.upload.image || config.upload.file;
     const isUploadableFile = config.upload.file;
 
@@ -47,7 +49,7 @@ class CommentEditor extends React.Component {
       slackChannels: this.props.slackChannels,
     };
 
-    this.growiRenderer = new GrowiRenderer(this.props.crowi, this.props.crowiOriginRenderer, { mode: 'comment' });
+    this.growiRenderer = new GrowiRenderer(window.crowi, this.props.crowiOriginRenderer, { mode: 'comment' });
 
     this.updateState = this.updateState.bind(this);
     this.updateStateCheckbox = this.updateStateCheckbox.bind(this);
@@ -66,7 +68,7 @@ class CommentEditor extends React.Component {
   }
 
   init() {
-    const layoutType = this.props.crowi.getConfig().layoutType;
+    const layoutType = this.props.appContainer.getConfig().layoutType;
     this.setState({ isLayoutTypeGrowi: layoutType === 'crowi-plus' || layoutType === 'growi' });
   }
 
@@ -105,7 +107,7 @@ class CommentEditor extends React.Component {
     this.props.commentContainer.postComment(
       this.state.comment,
       this.state.isMarkdown,
-      null, // TODO set replyTo
+      this.props.replyTo,
       this.state.isSlackEnabled,
       this.state.slackChannels,
     )
@@ -120,6 +122,7 @@ class CommentEditor extends React.Component {
         });
         // reset value
         this.editor.setValue('');
+        this.props.commentButtonClickedHandler(this.props.replyTo);
       })
       .catch((err) => {
         const errorMessage = err.message || 'An unknown error occured when posting comment';
@@ -128,18 +131,7 @@ class CommentEditor extends React.Component {
   }
 
   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)
+    this.props.commentContainer.uploadAttachment(file)
       .then((res) => {
         const attachment = res.attachment;
         const fileName = attachment.originalName;
@@ -157,19 +149,18 @@ class CommentEditor extends React.Component {
       .then(() => {
         this.editor.terminateUploadingState();
       });
-    */
   }
 
-  // apiErrorHandler(error) {
-  //   toastr.error(error.message, 'Error occured', {
-  //     closeButton: true,
-  //     progressBar: true,
-  //     newestOnTop: false,
-  //     showDuration: '100',
-  //     hideDuration: '100',
-  //     timeOut: '3000',
-  //   });
-  // }
+  apiErrorHandler(error) {
+    toastr.error(error.message, 'Error occured', {
+      closeButton: true,
+      progressBar: true,
+      newestOnTop: false,
+      showDuration: '100',
+      hideDuration: '100',
+      timeOut: '3000',
+    });
+  }
 
   getCommentHtml() {
     return (
@@ -186,7 +177,7 @@ class CommentEditor extends React.Component {
     };
 
     const growiRenderer = this.growiRenderer;
-    const interceptorManager = this.props.crowi.interceptorManager;
+    const interceptorManager = this.props.appContainer.interceptorManager;
     interceptorManager.process('preRenderCommnetPreview', context)
       .then(() => { return interceptorManager.process('prePreProcess', context) })
       .then(() => {
@@ -215,12 +206,12 @@ class CommentEditor extends React.Component {
   }
 
   render() {
-    const crowi = this.props.crowi;
-    const username = crowi.me;
-    const user = crowi.findUser(username);
+    const { appContainer } = this.props;
+    const username = appContainer.me;
+    const user = appContainer.findUser(username);
     const comment = this.state.comment;
     const commentPreview = this.state.isMarkdown ? this.getCommentHtml() : ReactUtils.nl2br(comment);
-    const emojiStrategy = this.props.crowi.getEmojiStrategy();
+    const emojiStrategy = appContainer.getEmojiStrategy();
 
     const isLayoutTypeGrowi = this.state.isLayoutTypeGrowi;
 
@@ -236,7 +227,7 @@ class CommentEditor extends React.Component {
     );
 
     return (
-      <div>
+      <div className="form page-comment-form">
 
         { username
           && (
@@ -257,7 +248,7 @@ class CommentEditor extends React.Component {
                       value={this.state.comment}
                       isGfmMode={this.state.isMarkdown}
                       lineNumbers={false}
-                      isMobile={this.props.crowi.isMobile}
+                      isMobile={appContainer.isMobile}
                       isUploadable={this.state.isUploadable && this.state.isLayoutTypeGrowi} // enable only when GROWI layout
                       isUploadableFile={this.state.isUploadableFile}
                       emojiStrategy={emojiStrategy}
@@ -336,10 +327,10 @@ class CommentEditorWrapper extends React.Component {
 
   render() {
     return (
-      <Subscribe to={[CommentContainer]}>
-        { commentContainer => (
+      <Subscribe to={[AppContainer, CommentContainer]}>
+        { (appContainer, commentContainer) => (
           // eslint-disable-next-line arrow-body-style
-          <CommentEditor commentContainer={commentContainer} {...this.props} />
+          <CommentEditor appContainer={appContainer} commentContainer={commentContainer} {...this.props} />
         )}
       </Subscribe>
     );
@@ -348,15 +339,19 @@ class CommentEditorWrapper extends React.Component {
 }
 
 CommentEditor.propTypes = {
-  crowi: PropTypes.object.isRequired,
-  crowiOriginRenderer: PropTypes.object.isRequired,
+  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
   commentContainer: PropTypes.instanceOf(CommentContainer).isRequired,
+
+  crowiOriginRenderer: PropTypes.object.isRequired,
   slackChannels: PropTypes.string,
+  replyTo: PropTypes.string,
+  commentButtonClickedHandler: PropTypes.func.isRequired,
 };
 CommentEditorWrapper.propTypes = {
-  crowi: PropTypes.object.isRequired,
   crowiOriginRenderer: PropTypes.object.isRequired,
   slackChannels: PropTypes.string,
+  replyTo: PropTypes.string,
+  commentButtonClickedHandler: PropTypes.func.isRequired,
 };
 
 export default CommentEditorWrapper;

+ 39 - 9
src/client/js/components/PageComment/CommentEditorLazyRenderer.jsx

@@ -2,6 +2,8 @@ import React from 'react';
 import PropTypes from 'prop-types';
 import CommentEditor from './CommentEditor';
 
+import UserPicture from '../User/UserPicture';
+
 export default class CommentEditorLazyRenderer extends React.Component {
 
   constructor(props) {
@@ -25,25 +27,53 @@ export default class CommentEditorLazyRenderer extends React.Component {
   }
 
   showCommentFormBtnClickHandler() {
-    this.setState({ isEditorShown: true });
+    this.setState({ isEditorShown: !this.state.isEditorShown });
   }
 
   render() {
+    const crowi = this.props.crowi;
+    const username = crowi.me;
+    const user = crowi.findUser(username);
+    const isLayoutTypeGrowi = this.state.isLayoutTypeGrowi;
     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>
+          <div className="form page-comment-form">
+            { username
+              && (
+                <div className="comment-form">
+                  { isLayoutTypeGrowi
+                  && (
+                    <div className="comment-form-user">
+                      <UserPicture user={user} />
+                    </div>
+                  )
+                  }
+                  <div className="comment-form-main">
+                    <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>
+                  </div>
+                </div>
+              )
+            }
+          </div>
           )
         }
         { this.state.isEditorShown
-          && <CommentEditor {...this.props}></CommentEditor>
+          && (
+          <CommentEditor
+            {...this.props}
+            replyTo={undefined}
+            commentButtonClickedHandler={this.showCommentFormBtnClickHandler}
+          >
+          </CommentEditor>
+)
         }
       </React.Fragment>
     );

+ 78 - 120
src/client/js/components/PageComments.jsx

@@ -2,17 +2,22 @@
 /* eslint-disable react/no-access-state-in-setstate */
 import React from 'react';
 import PropTypes from 'prop-types';
+import Button from 'react-bootstrap/es/Button';
 
 import { Subscribe } from 'unstated';
 
 import { withTranslation } from 'react-i18next';
 import GrowiRenderer from '../util/GrowiRenderer';
 
+import AppContainer from '../services/AppContainer';
 import CommentContainer from './PageComment/CommentContainer';
+
 import CommentEditor from './PageComment/CommentEditor';
 
 import Comment from './PageComment/Comment';
 import DeleteCommentModal from './PageComment/DeleteCommentModal';
+import PageContainer from '../services/PageContainer';
+
 
 /**
  * Load data of comments and render the list of <Comment />
@@ -39,7 +44,7 @@ class PageComments extends React.Component {
       showEditorIds: new Set(),
     };
 
-    this.growiRenderer = new GrowiRenderer(this.props.crowi, this.props.crowiOriginRenderer, { mode: 'comment' });
+    this.growiRenderer = new GrowiRenderer(window.crowi, this.props.crowiOriginRenderer, { mode: 'comment' });
 
     this.init = this.init.bind(this);
     this.confirmToDeleteComment = this.confirmToDeleteComment.bind(this);
@@ -47,6 +52,7 @@ class PageComments extends React.Component {
     this.showDeleteConfirmModal = this.showDeleteConfirmModal.bind(this);
     this.closeDeleteConfirmModal = this.closeDeleteConfirmModal.bind(this);
     this.replyButtonClickedHandler = this.replyButtonClickedHandler.bind(this);
+    this.commentButtonClickedHandler = this.commentButtonClickedHandler.bind(this);
   }
 
   componentWillMount() {
@@ -54,11 +60,11 @@ class PageComments extends React.Component {
   }
 
   init() {
-    if (!this.props.pageId) {
+    if (!this.props.pageContainer.state.pageId) {
       return;
     }
 
-    const layoutType = this.props.crowi.getConfig().layoutType;
+    const layoutType = this.props.appContainer.getConfig().layoutType;
     this.setState({ isLayoutTypeGrowi: layoutType === 'crowi-plus' || layoutType === 'growi' });
 
     this.props.commentContainer.retrieveComments();
@@ -98,19 +104,24 @@ class PageComments extends React.Component {
     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]);
-        }
+  commentButtonClickedHandler(commentId) {
+    this.setState((prevState) => {
+      prevState.showEditorIds.delete(commentId);
+      return {
+        showEditorIds: prevState.showEditorIds,
+      };
+    });
+  }
+
+  // adds replies to specific comment object
+  addRepliesToComments(comment, replies) {
+    const replyList = [];
+    replies.forEach((reply) => {
+      if (reply.replyTo === comment._id) {
+        replyList.push(reply);
       }
-    }
-    return comments;
+    });
+    return replyList;
   }
 
   /**
@@ -121,11 +132,13 @@ class PageComments extends React.Component {
    * @memberOf PageComments
    */
   generateCommentElements(comments, replies) {
-    const commentsWithReplies = this.reorderBasedOnReplies(comments, replies);
-    return commentsWithReplies.map((comment) => {
+    return comments.map((comment) => {
 
       const commentId = comment._id;
       const showEditor = this.state.showEditorIds.has(commentId);
+      const username = this.props.appContainer.me;
+
+      const replyList = this.addRepliesToComments(comment, replies);
 
       return (
         <div key={commentId}>
@@ -133,15 +146,41 @@ class PageComments extends React.Component {
             comment={comment}
             deleteBtnClicked={this.confirmToDeleteComment}
             crowiRenderer={this.growiRenderer}
-            onReplyButtonClicked={() => { this.replyButtonClickedHandler(commentId) }}
-            crowi={this.props.crowi}
+            replyList={replyList}
+            revisionCreatedAt={this.props.revisionCreatedAt}
           />
-          { showEditor && (
-            <CommentEditor
-              crowi={this.props.crowi}
-              crowiOriginRenderer={this.props.crowiOriginRenderer}
-            />
-          )}
+          <div className="container-fluid">
+            <div className="row">
+              <div className="col-xs-offset-1 col-xs-11 col-sm-offset-1 col-sm-11 col-md-offset-1 col-md-11 col-lg-offset-1 col-lg-11">
+                { !showEditor && (
+                  <div>
+                    { username
+                    && (
+                      <div className="col-xs-offset-6 col-sm-offset-6 col-md-offset-6 col-lg-offset-6">
+                        <Button
+                          bsStyle="primary"
+                          className="fcbtn btn btn-sm btn-primary btn-outline btn-rounded btn-1b"
+                          onClick={() => { return this.replyButtonClickedHandler(commentId) }}
+                        >
+                          <i className="icon-bubble"></i> Reply
+                        </Button>
+                      </div>
+                    )
+                  }
+                  </div>
+                )}
+                { showEditor && (
+                  <CommentEditor
+                    crowiOriginRenderer={this.props.crowiOriginRenderer}
+                    slackChannels={this.props.slackChannels}
+                    replyTo={commentId}
+                    commentButtonClickedHandler={this.commentButtonClickedHandler}
+                  />
+                )}
+              </div>
+            </div>
+          </div>
+          <br />
         </div>
       );
     });
@@ -149,11 +188,7 @@ class PageComments extends React.Component {
 
   render() {
     const currentComments = [];
-    const newerComments = [];
-    const olderComments = [];
     const currentReplies = [];
-    const newerReplies = [];
-    const olderReplies = [];
 
     let comments = this.props.commentContainer.state.comments;
     if (this.state.isLayoutTypeGrowi) {
@@ -161,100 +196,29 @@ class PageComments extends React.Component {
       comments = comments.slice().reverse(); // non-destructive reverse
     }
 
-    // divide by revisionId and createdAt
-    const revisionId = this.props.revisionId;
-    const revisionCreatedAt = this.props.revisionCreatedAt;
     comments.forEach((comment) => {
-      // comparing ObjectId
-      // eslint-disable-next-line eqeqeq
       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);
-        }
+      // comment is not a reply
+        currentComments.push(comment);
       }
-      else
+      else {
       // comment is a reply
-      if (comment.revision === revisionId) {
         currentReplies.push(comment);
       }
-      else if (Date.parse(comment.createdAt) / 1000 > revisionCreatedAt) {
-        newerReplies.push(comment);
-      }
-      else {
-        olderReplies.push(comment);
-      }
     });
 
     // generate elements
     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">
         {currentElements}
       </div>
     );
-    const newerBlock = (
-      <div className="page-comments-list-newer collapse in" id="page-comments-list-newer">
-        {newerElements}
-      </div>
-    );
-    const olderBlock = (
-      <div className="page-comments-list-older collapse in" id="page-comments-list-older">
-        {olderElements}
-      </div>
-    );
-
-    // generate toggle elements
-    const iconForNewer = (this.state.isLayoutTypeGrowi)
-      ? <i className="fa fa-angle-double-down"></i>
-      : <i className="fa fa-angle-double-up"></i>;
-    const toggleNewer = (newerElements.length === 0)
-      ? <div></div>
-      : (
-        <a className="page-comments-list-toggle-newer text-center" data-toggle="collapse" href="#page-comments-list-newer">
-          {iconForNewer} Comments for Newer Revision {iconForNewer}
-        </a>
-      );
-    const iconForOlder = (this.state.isLayoutTypeGrowi)
-      ? <i className="fa fa-angle-double-up"></i>
-      : <i className="fa fa-angle-double-down"></i>;
-    const toggleOlder = (olderElements.length === 0)
-      ? <div></div>
-      : (
-        <a className="page-comments-list-toggle-older text-center" data-toggle="collapse" href="#page-comments-list-older">
-          {iconForOlder} Comments for Older Revision {iconForOlder}
-        </a>
-      );
 
     // layout blocks
-    const commentsElements = (this.state.isLayoutTypeGrowi)
-      ? (
-        <div>
-          {olderBlock}
-          {toggleOlder}
-          {currentBlock}
-          {toggleNewer}
-          {newerBlock}
-        </div>
-      )
-      : (
-        <div>
-          {newerBlock}
-          {toggleNewer}
-          {currentBlock}
-          {toggleOlder}
-          {olderBlock}
-        </div>
-      );
+    const commentsElements = (<div>{currentBlock}</div>);
 
     return (
       <div>
@@ -280,10 +244,10 @@ class PageCommentsWrapper extends React.Component {
 
   render() {
     return (
-      <Subscribe to={[CommentContainer]}>
-        { commentContainer => (
+      <Subscribe to={[AppContainer, PageContainer, CommentContainer]}>
+        { (appContainer, pageContainer, commentContainer) => (
           // eslint-disable-next-line arrow-body-style
-          <PageComments commentContainer={commentContainer} {...this.props} />
+          <PageComments appContainer={appContainer} pageContainer={pageContainer} commentContainer={commentContainer} {...this.props} />
         )}
       </Subscribe>
     );
@@ -291,24 +255,18 @@ class PageCommentsWrapper extends React.Component {
 
 }
 
-PageCommentsWrapper.propTypes = {
-  crowi: PropTypes.object.isRequired,
+PageComments.propTypes = {
+  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
+  pageContainer: PropTypes.instanceOf(PageContainer).isRequired,
+  commentContainer: PropTypes.instanceOf(CommentContainer).isRequired,
+
   crowiOriginRenderer: PropTypes.object.isRequired,
-  pageId: PropTypes.string.isRequired,
-  revisionId: PropTypes.string.isRequired,
   revisionCreatedAt: PropTypes.number,
-  pagePath: PropTypes.string,
   slackChannels: PropTypes.string,
 };
-PageComments.propTypes = {
-  commentContainer: PropTypes.object.isRequired,
-
-  crowi: PropTypes.object.isRequired,
+PageCommentsWrapper.propTypes = {
   crowiOriginRenderer: PropTypes.object.isRequired,
-  pageId: PropTypes.string.isRequired,
-  revisionId: PropTypes.string.isRequired,
   revisionCreatedAt: PropTypes.number,
-  pagePath: PropTypes.string,
   slackChannels: PropTypes.string,
 };
 

+ 10 - 3
src/client/js/services/AppContainer.js

@@ -71,7 +71,7 @@ export default class AppContainer extends Container {
   }
 
   /**
-   * Register instance
+   * Register unstated container instance
    * @param {object} instance unstated container instance
    */
   registerContainer(instance) {
@@ -88,12 +88,19 @@ export default class AppContainer extends Container {
     this.containerInstances[className] = instance;
   }
 
+  /**
+   * Get registered unstated container instance
+   * !! THIS METHOD SHOULD ONLY BE USED FROM unstated CONTAINERS !!
+   * !! From component instances, inject containers with `import { Subscribe } from 'unstated'` !!
+   *
+   * @param {string} className
+   */
   getContainer(className) {
     return this.containerInstances[className];
   }
 
   /**
-   * Register instance
+   * Register React component instance
    * @param {object} instance React component instance
    */
   registerComponentInstance(instance) {
@@ -111,7 +118,7 @@ export default class AppContainer extends Container {
   }
 
   /**
-   * Get registered instance
+   * Get registered React component instance
    * @param {string} className
    */
   getComponentInstance(className) {

+ 4 - 1
src/client/js/services/PageContainer.js

@@ -8,9 +8,12 @@ import * as entities from 'entities';
  */
 export default class PageContainer extends Container {
 
-  constructor() {
+  constructor(appContainer) {
     super();
 
+    this.appContainer = appContainer;
+    this.appContainer.registerContainer(this);
+
     const mainContent = document.querySelector('#content-main');
 
     if (mainContent == null) {

+ 29 - 4
src/server/routes/comment.js

@@ -5,11 +5,15 @@ module.exports = function(crowi, app) {
   const Page = crowi.model('Page');
   const ApiResponse = require('../util/apiResponse');
   const globalNotificationService = crowi.getGlobalNotificationService();
+  const { body } = require('express-validator/check');
+  const mongoose = require('mongoose');
+  const ObjectId = mongoose.Types.ObjectId;
 
   const actions = {};
   const api = {};
 
   actions.api = api;
+  api.validators = {};
 
   /**
    * @api {get} /comments.get Get comments of the page of the revision
@@ -50,6 +54,25 @@ module.exports = function(crowi, app) {
     res.json(ApiResponse.success({ comments }));
   };
 
+  api.validators.add = function() {
+    const validator = [
+      body('commentForm.page_id').exists(),
+      body('commentForm.revision_id').exists(),
+      body('commentForm.comment').exists(),
+      body('commentForm.comment_position').isInt(),
+      body('commentForm.is_markdown').isBoolean(),
+      body('commentForm.replyTo').exists().custom((value) => {
+        if (value === '') {
+          return undefined;
+        }
+        return ObjectId(value);
+      }),
+
+      body('slackNotificationForm.isSlackEnabled').isBoolean().exists(),
+    ];
+    return validator;
+  };
+
   /**
    * @api {post} /comments.add Post comment for the page
    * @apiName PostComment
@@ -61,11 +84,13 @@ module.exports = function(crowi, app) {
    * @apiParam {Number} comment_position=-1 Line number of the comment
    */
   api.add = async function(req, res) {
-    const commentForm = req.form.commentForm;
-    const slackNotificationForm = req.form.slackNotificationForm;
+    const { commentForm, slackNotificationForm } = req.body;
+    const { validationResult } = require('express-validator/check');
 
-    if (!req.form.isValid) {
+    const errors = validationResult(req.body);
+    if (!errors.isEmpty()) {
       // return res.json(ApiResponse.error('Invalid comment.'));
+      // return res.status(422).json({ errors: errors.array() });
       return res.json(ApiResponse.error('コメントを入力してください。'));
     }
 
@@ -74,7 +99,7 @@ module.exports = function(crowi, app) {
     const comment = commentForm.comment;
     const position = commentForm.comment_position || -1;
     const isMarkdown = commentForm.is_markdown;
-    const replyTo = commentForm.replyTo === '' ? undefined : commentForm.replyTo;
+    const replyTo = commentForm.replyTo;
 
     // check whether accessible
     const isAccessible = await Page.isAccessiblePageByViewer(pageId, req.user);

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

@@ -209,7 +209,7 @@ module.exports = function(crowi, app) {
   app.get('/_api/tags.search'         , accessTokenParser, loginRequired(crowi, app, false), tag.api.search);
   app.post('/_api/tags.update'         , accessTokenParser, loginRequired(crowi, app, false), tag.api.update);
   app.get('/_api/comments.get'        , accessTokenParser , loginRequired(crowi, app, false) , comment.api.get);
-  app.post('/_api/comments.add'       , form.comment, accessTokenParser , loginRequired(crowi, app) , csrf, comment.api.add);
+  app.post('/_api/comments.add'       , comment.api.validators.add(), accessTokenParser , loginRequired(crowi, app) , csrf, comment.api.add);
   app.post('/_api/comments.remove'    , accessTokenParser , loginRequired(crowi, app) , csrf, comment.api.remove);
   app.get('/_api/bookmarks.get'      , accessTokenParser , loginRequired(crowi, app, false) , bookmark.api.get);
   app.post('/_api/bookmarks.add'      , accessTokenParser , loginRequired(crowi, app) , csrf, bookmark.api.add);

+ 17 - 17
src/server/views/admin/user-group-detail.html

@@ -1,11 +1,11 @@
 {% extends '../layout/admin.html' %}
 
-{% block html_title %}{{ customTitle(t('UserGroup management') + '/' + userGroup.name) | preventXss }}{% endblock %}
+{% block html_title %}{{ customTitle(t('UserGroup Management') + '/' + userGroup.name) | preventXss }}{% endblock %}
 
 {% block content_header %}
 <div class="header-wrap">
   <header id="page-header">
-    <h1 id="admin-title" class="title">{{ t('UserGroup management') + '/' + userGroup.name | preventXss }}</h1>
+    <h1 id="admin-title" class="title">{{ t('UserGroup Management') + '/' + userGroup.name | preventXss }}</h1>
   </header>
 </div>
 {% endblock %}
@@ -34,7 +34,7 @@
     <div class="col-md-9">
       <a href="/admin/user-groups" class="btn btn-default">
         <i class="icon-fw ti-arrow-left" aria-hidden="true"></i>
-        グループ一覧に戻る
+        {{ t('user_group_management.back_to_list') }}
       </a>
 
       <div class="modal fade" id="admin-add-user-group-relation-modal">
@@ -43,27 +43,27 @@
             <div class="modal-header">
               <button type="button" class="close" data-dismiss="modal" aria-hidden="true">&times;</button>
               <h4 class="modal-title">
-                グループへのユーザー追加
+                {{ t('user_group_management.add_user') }}
               </h4>
             </div>
 
             <div class="modal-body">
               <p>
-                <strong>方法1.</strong> ユーザ名を入力して追加
+                <strong>{{ t('Method') }}1.</strong> {{ t('user_group_management.how_to_add1') }}
               </p>
               <form class="form-inline" role="form" action="/admin/user-group-relation/create" method="post">
                 <div class="form-group">
-                  <input type="text" name="user_name" class="form-control input-sm" id="inputRelatedUserName" placeholder="username">
+                  <input type="text" name="user_name" class="form-control input-sm" id="inputRelatedUserName" placeholder="{{ t('User Name')}}">
                 </div>
                 <input type="hidden" name="user_group_id" value="{{userGroup.id}}">
                 <input type="hidden" name="_csrf" value="{{ csrf() }}">
-                <button type="submit" class="btn btn-sm btn-success">追加</button>
+                <button type="submit" class="btn btn-sm btn-success">{{ t('Add') }}</button>
               </form>
 
               {% if 0 < notRelatedusers.length %}
               <hr>
               <p>
-                <strong>方法2.</strong> ユーザーを下のリストから選択
+                <strong>{{ t('Method') }}2.</strong> {{ t('user_group_management.how_to_add2') }}
               </p>
 
               <ul class="list-inline">
@@ -91,7 +91,7 @@
       <div class="m-t-20 form-box">
         <form action="/admin/user-group/{{userGroup.id}}/update" method="post" class="form-horizontal" role="form">
           <fieldset>
-            <legend>基本情報</legend>
+            <legend>{{ t('Basic Settings') }}</legend>
             <div class="form-group">
               <label for="name" class="col-sm-2 control-label">{{ t('Name') }}</label>
               <div class="col-sm-4">
@@ -114,18 +114,18 @@
         </form>
       </div>
 
-      <legend class="m-t-20">ユーザー一覧</legend>
+      <legend class="m-t-20">{{ t('User List') }}</legend>
 
       <table class="table table-bordered table-user-list">
         <thead>
           <tr>
             <th width="100px">#</th>
             <th>
-              <code>username</code>
+              {{ t('User') }}
             </th>
-            <th>名前</th>
-            <th width="100px">作成日</th>
-            <th width="150px">最終ログイン</th>
+            <th>{{ t('Name') }}</th>
+            <th width="100px">{{ t('Created') }}</th>
+            <th width="150px">{{ t('Last Login')}}</th>
             <th width="70px"></th>
           </tr>
         </thead>
@@ -155,7 +155,7 @@
                   </form>
                   <li>
                     <a href="javascript:form_removeFromGroup_{{ loop.index }}.submit()">
-                      <i class="icon-fw icon-user-unfollow"></i> グループから外す
+                      <i class="icon-fw icon-user-unfollow"></i> {{ t('user_group_management.remove_from_group')}}
                     </a>
                   </li>
                 </ul>
@@ -183,10 +183,10 @@
 
       <!-- {% include '../widget/pager.html' with {path: "/admin/user-group-detail", pager: pager} %} -->
 
-      <legend class="m-t-20">ページ一覧</legend>
+      <legend class="m-t-20">{{ t('Page') }}</legend>
 
       <div class="page-list">
-        {% if relatedPages.length == 0 %}<p>グループが閲覧権限を保有するページはありません</p>{% endif %}
+        {% if relatedPages.length == 0 %}<p>{{ t('user_group_management.no_pages') }}</p>{% endif %}
         {% include '../widget/page_list.html' with { pages: relatedPages } %}
       </div>
 

+ 4 - 4
src/server/views/admin/users.html

@@ -144,18 +144,18 @@
         </div><!-- /.modal-dialog -->
       </div>
 
-      <h2>{{ t("user_management.user_list") }}</h2>
+      <h2>{{ t("User List") }}</h2>
 
       <table class="table table-default table-bordered table-user-list">
         <thead>
           <tr>
             <th width="100px">#</th>
             <th>{{ t('user_management.Status') }}</th>
-            <th><code>username</code></th>
+            <th>{{ t('Name') }}</th>
             <th>{{ t('Name') }}</th>
             <th>{{ t('Email') }}</th>
-            <th width="100px">{{ t('user_management.Date created') }}</th>
-            <th width="150px">{{ t('user_management.Last login') }}</th>
+            <th width="100px">{{ t('Created') }}</th>
+            <th width="150px">{{ t('Last Login') }}</th>
             <th width="70px"></th>
           </tr>
         </thead>

+ 9 - 1
yarn.lock

@@ -3887,6 +3887,14 @@ express-session@^1.16.1:
     safe-buffer "5.1.2"
     uid-safe "~2.1.5"
 
+express-validator@^5.3.1:
+  version "5.3.1"
+  resolved "https://registry.yarnpkg.com/express-validator/-/express-validator-5.3.1.tgz#6f42c6d52554441b0360c40ccfb555b1770affe2"
+  integrity sha512-g8xkipBF6VxHbO1+ksC7nxUU7+pWif0+OZXjZTybKJ/V0aTVhuCoHbyhIPgSYVldwQLocGExPtB2pE0DqK4jsw==
+  dependencies:
+    lodash "^4.17.10"
+    validator "^10.4.0"
+
 express-webpack-assets@^0.1.0:
   version "0.1.0"
   resolved "https://registry.yarnpkg.com/express-webpack-assets/-/express-webpack-assets-0.1.0.tgz#000fb3413eb0d512cbd6cd3f6a10b5e70dbe0079"
@@ -10940,7 +10948,7 @@ validate-npm-package-license@^3.0.1:
     spdx-correct "~1.0.0"
     spdx-expression-parse "~1.0.0"
 
-validator@>=10.11.0, validator@^10.0.0:
+validator@>=10.11.0, validator@^10.0.0, validator@^10.4.0:
   version "10.11.0"
   resolved "https://registry.yarnpkg.com/validator/-/validator-10.11.0.tgz#003108ea6e9a9874d31ccc9e5006856ccd76b228"
   integrity sha512-X/p3UZerAIsbBfN/IwahhYaBbY68EN/UQBWHtsbXGT5bfrH/p4NQzUCG1kF/rtKaNpnJ7jAu6NGTdSNtyNIXMw==